神经网络如何学习?手写多层神经网络详解反向传播
做深度学习这几年被问得最多的一个问题就是神经网络到底是怎么学会东西的尤其是刚入门的同学看着框架文档里一行model.add(Dense(64, activationrelu))就能把精度刷上去却完全不理解背后发生了什么。这篇文章我把多层神经网络从原理到实现完整拆开讲一遍。里面用到的公式不多每一条我都用大白话解释代码是纯numpy手写的不依赖任何深度学习框架可以直接复制跑起来。不管你是刚准备入门深度学习的学生还是被项目deadline逼着调模型的朋友这篇都能帮你把多层这两个字背后的设计思路和工程细节理顺。1. 多层神经网络到底在学什么1.1 单层网络的边界从感知机说起要说多层神经网络必须先从它的老祖宗——感知机说起。1958年Rosenblatt提出的感知机结构非常简单输入乘以权重加上偏置过一个阶跃函数输出0或1。放到今天看就是一层线性分类器它能做的事情有限得可怜——只能处理线性可分的问题。当年感知机最著名的翻车案例是异或问题XOR。异或函数的真值表是输入(0,0)输出0(0,1)输出1(1,0)输出1(1,1)输出0。你把这四个点在二维平面上画出来会发现输出1的两个点在对角线的位置输出0的两个点也在对角线的位置这两类点没法用一条直线分开。而AND和OR问题都能用一条直线轻松划分所以感知机搞不定XOR这件事当时被Minsky和Papert在1969年的《Perceptrons》一书里直接点名指出来直接把神经网络研究送进了第一次寒冬。为什么单层搞不定XOR因为单层的本质是对输入做线性加权再加一个非线性阈值。无论你怎么调权重决策边界始终是一条直线。而XOR需要的是非线性决策边界——比如两条直线围出一个区域。这就是关键所在单层模型的表达能力被线性限制死了。要突破这个限制就必须引入隐藏层也就是多层。1.2 隐藏层在做什么特征再组合加入一个隐藏层之后网络的能力发生了质变。隐藏层做的事情本质上是对输入的特征做再组合和再抽象。还是拿XOR举例一个带隐藏层的网络可以学习出这样一条路径第一个隐藏神经元学会检测x11第二个隐藏神经元学会检测x21然后输出层再学会如果两者只有一个成立那就输出1。这就是层层递进的特征变换低层学的是简单模式高层把简单模式组合成复杂模式。我经常跟新人打一个比方网络的前面几层就像是搭乐高的基础积木块只能识别边缘、颜色、朝向这些最原始的属性越往后的层越像是把积木块拼成了轮子、车身、窗户这些局部结构最后一层则是把整个车子认出来。在图像任务里这个类比是字面意义的真实存在在表格数据或者文本数据里原理也一模一样——前几层学的是词的局部共现规律或者连续特征的边界后几层学的是由这些基础模式堆出来的高阶语义。还有一个理论基础必须提通用近似定理。1989年Cybenko证明只要隐藏层神经元数量足够多一个单隐藏层的前馈网络就能以任意精度逼近任意连续函数。那为什么我们还要更深的网络答案是参数效率。某些函数用深度网络表示需要的神经元数量是指数级减少的浅层网络也许理论上能逼近但实际可能需要天文数字的宽度训练起来根本不现实。这就好比你用一堆一次性筷子也能搭一座桥但远不如用钢结构来得省料和省事。1.3 深度和宽度的权衡层数代表深度每层神经元个数代表宽度。这两者怎么取舍是每个新手都会头大的问题。我的经验是一句话宁深勿宽但别一上来就太深。宽网络的问题是参数爆炸。一个宽度1000的单隐藏层网络参数量就是输入维度乘以1000很容易把训练集的噪声一起背下来导致过拟合。深网络则不同它用分层抽象的方式共享中间特征同样的参数量表达能力往往更强。但深度有深度的痛——训练更难梯度在传播过程中容易消失或爆炸对初始化、学习率、激活函数的要求都更高。所以我的建议是拿到一个任务先从一个很小的网络开始比如两层隐藏层每层32或64个神经元跑通之后再逐步加层、加宽观察验证集表现。不要一上来就堆一个20层的网络除非你用的是现代框架和成熟的训练技巧否则大概率会撞上一堆让人崩溃的梯度问题。后面第4节我会专门讲怎么排查这些坑。2. 网络结构设计与核心参数怎么定2.1 激活函数非线性从哪来你可能要问不就是多叠了几层线性变换吗凭什么就有非线性能力了答案是激活函数。如果每一层只做矩阵乘法加偏置那么不管叠多少层整个网络本质上还是一个大线性变换——你拿两个线性函数复合得到的还是线性函数等于白叠。激活函数是给网络注入非线性的唯一途径。常用的激活函数我按实战经验排个序ReLUmax(0, x)现在绝对的主流。正区间梯度恒为1不会像sigmoid那样把梯度越传越小计算也快只需要一个比较操作。但它有个毛病输入小于0时梯度直接变0一旦某个神经元的输入长期落在负区间它就可能永远死掉不再更新。Leaky ReLU / ELU就是来解决ReLU死区问题的。Leaky ReLU在负数区间给一个小斜率比如0.01至少保证梯度还在流动。ELU则在负数区间用一个指数曲线过渡效果更平滑。sigmoid和tanh老牌选手适合浅层网络或者输出层。sigmoid输出范围0到1天然适合做二分类概率输出tanh输出范围-1到1零中心化比sigmoid更容易收敛。问题是两者在饱和区也就是输入绝对值很大时梯度几乎为0深了之后梯度传不回去。我自己用下来的一个组合拳是隐藏层优先ReLU如果发现很多神经元死掉可以用summary工具统计激活值为0的比例再换Leaky ReLU输出层根据任务来二分类用sigmoid多分类用softmax回归则不用激活函数。2.2 权重初始化不能拍脑袋乱设权重初始化是最容易被新手忽略、却最容易让模型训不动的环节。先说两个反例全零初始化所有神经元输出一样、梯度一样更新后还是一模一样的对称状态网络直接废了随机大数初始化激活值容易落入激活函数的饱和区梯度瞬间消失loss纹丝不动。正确定初始化思路是让每一层的输入输出方差保持稳定不随层数增加而放大或缩小。按这个原则开源界沉淀了两套经典方案Xavier/Glorot初始化权重从均值为0、方差为2 / (n_in n_out)的分布里采样适用于tanh和sigmoid这类饱和激活函数。它综合考虑了前向传播和反向传播的方差传递。He/Kaiming初始化权重从均值为0、方差为2 / n_in的分布里采样专门为ReLU设计。因为ReLU在负区间把一半的信号砍成了0所以需要把方差稍微放大一点来补偿。这里有个细节我说一下很多框架的默认初始化已经内置了智能匹配逻辑比如PyTorch的nn.Linear默认用的是Kaiming初始化直接用问题不大。但如果你像我一样喜欢手写numpy网络或者自定义层那初始化这条一定要自己盯好。不然训了半天loss纹丝不动查到最后发现是初始化搞的鬼血压直接拉满。2.3 损失函数和优化器怎么选损失函数决定了网络往哪个方向努力。我的选择表极其简单回归任务均方误差MSE即预测值和真实值差的平方的平均。它天然放大大误差样本的惩罚。二分类任务二元交叉熵BCE配合sigmoid输出。它输出的是概率且和极大似然估计直接挂钩。多分类任务交叉熵CrossEntropy配合softmax输出。这是最普适的分类损失。为什么分类不用MSE两个原因。一是分类任务本质是预测概率分布交叉熵才是分布之间合理的距离度量MSE假设了高斯噪声跟分类场景不匹配。二是从梯度角度看交叉熵配合softmax的梯度形式极其干净——输出层的梯度直接等于预测概率 - 真实标签推导出来就是这么漂亮。而MSE配合sigmoid由于sigmoid导数在饱和区趋近0会导致输出层梯度很小训起来慢得多。这个推导细节我在第3节会展示。优化器方面SGD是最朴素的版本沿着梯度反方向走固定步长。它的缺点是步子太死遇到峡谷地形会来回震荡收敛慢。Momentum动量加了一个惯性维度可以把历史梯度的方向累积下来穿越峡谷时更稳收敛明显更快。Adam则是把一阶动量梯度均值和二阶动量梯度平方均值都拿来用给每个参数自适应学习率几乎不用调就能收敛得不错。实战中的经验是炼丹初期省心用Adam冲刺阶段换成SGDMomentum。因为Adam后期容易出现泛化性不足的问题很多人发现最后阶段用SGD微调验证集精度还能再往上提一点。我自己经常这么干Adam训百分之八十的epoch切到SGD动量把最后的loss压到位。3. 实战从零手写一个多层神经网络3.1 准备一份拿来就能用的数据集手工实现网络最怕在数据处理上分心所以选数据集要小而干净。我推荐用经典的鸢尾花数据集4个特征花萼长、宽花瓣长、宽150个样本3个类别三种鸢尾花。数据量小、维度低、分类边界还算明显非常适合用来验证网络逻辑有没有写对。当然你如果用MNIST的784维像素输入原理一样只是输出层改成10个神经元就行。动手前最重要的一步是数据标准化。为什么假如花瓣长度是0.1到2.5花萼宽度是2到4.4尺度不一致梯度更新方向会被大数值特征带偏网络学起来会很别扭。标准做法是z-score标准化每个特征减去均值再除以标准差让数据变成均值为0、方差为1的分布。这一步在真实项目里同样重要我见过无数人忘记标准化loss曲线像个心电图跳来跳去一查根因就在这里。import numpy as np # 假设 X 是 (150, 4) 的特征矩阵y 是 (150,) 的标签 mean X.mean(axis0, keepdimsTrue) std X.std(axis0, keepdimsTrue) X (X - mean) / std # 标签转成 one-hot Y np.zeros((y.size, 3)) Y[np.arange(y.size), y] 13.2 网络结构与初始化我要搭的是一个三层的全连接网络输入层4个神经元隐藏层5个神经元输出层3个神经元。隐藏层激活函数用tanh输出层用softmax。结构画出来就是4 - 5 - 3中间只有一层隐藏层但已经足够把多层神经网络的全部核心逻辑跑通了。为什么隐藏层用tanh而不用ReLU因为tanh的导数公式是1 - tanh^2(z)不但好推导而且计算后得到的梯度可以逐步骤验证非常适合教学演示。ReLU的导数是个分段常数写起来太简略不太容易看出梯度的流动路径。实际项目里你还是可以换回ReLU推导逻辑不变。权重初始化我用Xavier方案按均匀分布取范围是±sqrt(6 / (n_in n_out))。这样既符合前面的方差保持原则又不需要手动再乘缩放系数写起来省事。def init_weights(n_in, n_out): limit np.sqrt(6.0 / (n_in n_out)) return np.random.uniform(-limit, limit, size(n_in, n_out)) W1 init_weights(4, 5) b1 np.zeros(5) W2 init_weights(5, 3) b2 np.zeros(3)3.3 前向传播数据怎么流过网络前向传播就是把输入从第一层送到最后一层中间每一层做线性加权 激活。用矩阵写出来非常紧凑隐藏层输入Z1 X W1 b1隐藏层输出A1 tanh(Z1)输出层输入Z2 A1 W2 b2最终概率A2 softmax(Z2)我建议你在第一次跑代码前把每个矩阵的维度在纸上标一遍X是(150,4)W1是(4,5)Z1是(150,5)A1是(150,5)W2是(5,3)Z2是(150,3)A2是(150,3)。一旦维度对不上前向传播立刻报错检查起来非常直观。矩阵乘法的本质就是批量同时处理所有样本对于150个样本一次矩阵运算就把全部样本的中间结果都算出来了这也是GPU为什么擅长做深度学习的根本原因——满屏都是矩阵运算。3.4 反向传播梯度是怎么算出来的反向传播是整个神经网络最核心、也最劝退新人的部分。我把它拆成一句话从输出层的误差出发用链式法则把误差对每个参数的偏导数逐层往前传。第一步算输出层的梯度。这里有个非常漂亮的结论当输出层用softmax、损失用交叉熵时输出层的梯度直接就是A2 - Y不需要任何复杂推导中间项。你可以自己验算一遍交叉熵L -Σy_i log(p_i)softmax的p_i exp(z_i) / Σexp(z_j)对参数z_k求偏导由于softmax的分母同时依赖所有z求导后各项互相抵消最后恰好剩下p_k - y_k。这个简化直接让代码短了一截也是数学之美在工程里的典型体现。第二步把梯度传到输出层参数上dZ2 A2 - Y dW2 A1.T dZ2 db2 dZ2.sum(axis0, keepdimsTrue)第三步误差继续往隐藏层传。先算dA1 dZ2 W2.T这是误差回传的矩阵形式然后乘上tanh的导数1 - A1^2得到隐藏层的梯度dZ1 dA1 * (1 - A1 * A1)。错了隐藏层梯度公式是逐元素的代码写出来是dZ1 (dZ2 W2.T) * (1 - A1 ** 2) dW1 X.T dZ1 db1 dZ1.sum(axis0, keepdimsTrue)最后用梯度下降更新参数learning_rate 0.1 W2 - learning_rate * dW2 b2 - learning_rate * db2.reshape(-1) W1 - learning_rate * dW1 b1 - learning_rate * db1.reshape(-1)这里我踩过一次坑db的shape如果没对齐减法会把广播逻辑搞乱导致bias更新错误loss降到一半又反弹。你初始化bias时用了np.zeros(5)这种shape为(5,)的数组而db1.sum(axis0, keepdimsTrue)返回的是(1, 5)减法前一定要reshape成向量不然numpy的广播规则会做出一个二维结果bug非常隐蔽。3.5 训练循环与完整代码训练就是反复执行前向传播 - 计算梯度 - 更新参数三步。我写训练循环时习惯每100轮打印一次loss方便观察收敛过程。150个样本可以直接全批量训练也就是每轮都用全部数据算梯度因为数据量小梯度估计足够稳。learning_rate 0.1 epochs 2000 for epoch in range(epochs): # 前向传播 Z1 X W1 b1 A1 np.tanh(Z1) Z2 A1 W2 b2 exp_scores np.exp(Z2) A2 exp_scores / exp_scores.sum(axis1, keepdimsTrue) loss -np.mean(np.sum(Y * np.log(A2 1e-8), axis1)) # 反向传播 dZ2 A2 - Y dW2 A1.T dZ2 db2 dZ2.sum(axis0, keepdimsTrue) dZ1 (dZ2 W2.T) * (1 - A1 ** 2) dW1 X.T dZ1 db1 dZ1.sum(axis0, keepdimsTrue) # 更新参数 W2 - learning_rate * dW2 b2 - learning_rate * db2.reshape(-1) W1 - learning_rate * dW1 b1 - learning_rate * db1.reshape(-1) if epoch % 200 0: print(fEpoch {epoch}, loss: {loss:.4f})跑完你再看预测准确率基本能到95%以上。这就是一个完整的手写多层神经网络没有任何框架帮助全链路逻辑都在你脑子里。我强烈建议你把这段代码敲一遍而不是直接复制——手敲的过程中你会注意到那些之前忽略掉的维度细节这些细节才是理解深度学习的真正门槛。4. 常见问题与排查技巧实录4.1 Loss不降反升先查这几个地方Loss不降反升是新手遇到的最头疼问题。我的排查顺序是固定的先看数据标准化有没有做再看学习率是不是太大最后看标签有没有配错。标准化没做的话loss曲线会像心电图一样剧烈抖动学习率太大loss会直接冲上天甚至变成NaN标签配错则通常表现为loss降到一个平台后突然反弹。踩过一个印象很深的坑有一次我做一个二分类任务网络输出层用了sigmoid却手滑把损失写成了多分类交叉熵接口一运行loss就在0.7附近纹丝不动。后来排查发现BCE和CrossEntropy对标签形状的要求不同一个要(batch, 1)一个要(batch, class_num)接口混乱导致梯度方向完全错了。所以遇到loss不动的状况先静下心检查损失函数和输出层是否匹配。4.2 梯度消失和爆炸深层网络的隐形杀手当网络层数加深梯度消失问题会越来越明显。为什么梯度在反向传播时是一个连乘的过程每一层乘上激活函数的导数。sigmoid的导数最大只有0.25tanh的导数最大只有1如果权重再小于1连乘几十层之后梯度就接近0了——前面几层的参数几乎得不到更新等于白设。判断梯度是否消失有一个实用技巧训练时在反向传播后打印每一层梯度的均值或范数。如果你发现第一层梯度比最后一层小了好几个数量级比如10的负8次方对10的负2次方基本可以确诊梯度消失。对应的对策有四个方向换ReLU族激活函数、换He初始化、加Batch Normalization、加残差连接ResNet的思路。梯度爆炸则相反梯度数值超大loss经常跳成NaN。对策是梯度裁剪gradient clipping设定一个阈值梯度过大时按比例缩放回阈值范围内。这在RNN和Transformer训练里几乎是标配操作了。4.3 过拟合训练集好但验证集差过拟合的表征是训练loss一直在降验证loss却先降后升两者之间的剪刀差越来越大。本质是模型参数太多、数据太少网络把训练样本里的噪声也当成规律背下来了。我的处理手段按优先级排序第一优先是增加训练数据量这永远是治本之策第二优先是降低模型复杂度减少层数或神经元个数第三才是加正则化手段——L2正则化权重衰减、Dropout、早停。L2正则化的原理是给大权重施加惩罚逼着网络不要走极端Dropout则是训练时随机让一部分神经元失效强迫网络学到冗余的特征表示效果近似于隐式集成。这里我想提醒一点正则化不是万能的。如果你发现正则化越加越猛还是压不住过拟合说明模型容量和数据结构根本不匹配这时候最该做的是回去收集数据、清洗数据而不是在代码里叠更多正则化技巧。4.4 排查速查表我把自己这些年遇到的典型问题整理成一张表新项目调试时直接按表索骥。现象可能原因检查方式解决方案Loss完全不降学习率太小 / 初始化不对 / 数据没标准化打印每层梯度值看数量级调大学习率、换He/Xavier初始化、标准化数据Loss变成NaN学习率太大 / 梯度爆炸检查梯度的最大值降低学习率、梯度裁剪训练loss降但验证loss升过拟合对比训练集和验证集loss增加数据、降模型复杂度、加Dropout或L2验证集acc一直50%左右标签错位 / 数据shuffle错误抽查一批训练样本的标签修正数据预处理梯度前几层近乎为0梯度消失打印各层梯度范数换ReLU/Leaky ReLU、加BN、加残差Loss收敛后又反弹学习率周期性过大 / 动量惯性可视化loss曲线细看周期用学习率衰减或余弦退火5. 手写实现和框架实现我的一些建议5.1 为什么新手一定要手写一遍现在PyTorch、TensorFlow做得非常成熟几行代码就能搭一个网络出来。但我依然坚持认为入门阶段至少手写一遍反向传播。因为框架把梯度计算全部封装成自动微分你看到的只是调用.backward()但梯度到底从哪里来、连乘了哪些项、为什么会消失这些只有亲手写过才能建立真正的直觉。带过不少新人我发现一个规律能白板推导softmax交叉熵梯度的人后面看Transformer的注意力梯度、看扩散模型的损失推导都不会太怵反之只会调框架接口的人一旦遇到loss异常、梯度异常就完全无从下手只能上网发帖子求助。手写网络练的是排查能力和数学直觉这是框架给不了你的。5.2 什么时候回框架手写是为了理解理解完就该回框架做正事。框架的价值在工程效率自动微分、GPU加速、分布式训练、成熟的数据加载管线、海量的预训练模型这些才是工业级项目的底座。同样是搭一个MLPPyTorch里十几行代码就够了而且跑在GPU上比numpy版本快几个量级。import torch.nn as nn model nn.Sequential( nn.Linear(4, 5), nn.Tanh(), nn.Linear(5, 3), nn.Softmax(dim1) )如果你已经完成了上面手写numpy版的全部理解再看这段代码你会清楚每一步在做什么而不是把它当咒语念。这就达到了我心目中的最佳状态用框架时行云流水掉坑时能自己爬出来。这几年我见了不少入行者有个体会越来越深能在百行内手写出MLP并且跑通的人后面无论学卷积网络、Transformer还是各类前沿架构上手速度都明显更快。建议你训练时永远盯着验证集而不是训练集宁可每多跑一轮多打几行日志也不要黑盒跑一夜再回来面对一个莫名其妙的loss。这个小习惯救过我很多次希望你用得上。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →