多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

手推BP神经网络:反向传播公式、numpy手写与调参排查

手推BP神经网络:反向传播公式、numpy手写与调参排查 调一个两层的小网络损失曲线趴在0.69附近一动不动这是我刚上手神经网络时最常遇到的画面。那时候我把学习率从0.1改到0.01又从0.01改成0.5权重初始化换了好几种问题照旧。后来老老实实拿张A4纸把公式推了一遍才发现是自己输出层误差项定义错了激活函数导数那一项直接漏掉。BP神经网络的算法原理本身并不玄乎真正的门槛在于它的每一行代码都对应着一个具体的偏导数公式推导里哪个符号对应哪个矩阵一旦对不上代码就跑不通。这篇东西我打算把BP神经网络从整体思路、前向传播、公式手推、手写实现一直到排查技巧整套讲一遍面向的是刚学完机器学习基础想动手复现、或者准备面试需要手推BP的朋友也适合那些框架用得很熟但一直没搞明白梯度到底怎么流回来的工程师。1. 先把BP神经网络这件事说明白它到底在学什么1.1 从中间层没有标准答案这个核心矛盾说起BP神经网络属于监督学习里最经典的一类模型全称是误差反向传播神经网络。它的结构就是若干层神经元首尾相连输入层接收特征中间一个或多个隐藏层做非线性变换输出层给出预测值。结构听起来平平无奇但真正让它成为一个可训练系统的是一个很尖锐的矛盾输出层有标签可以对答案隐藏层没有。你手里有输入和输入对应的目标值可中间那些层的权重到底该调成多少没有人告诉你标准答案。解决这个矛盾的办法就是把输出层的误差按照权重比例一层一层往回去分摊。谁的权重贡献大谁就多背一点锅。这个分摊的数学工具就是链式法则。损失函数对某一层权重的偏导可以拆成损失对该层输出的偏导乘该层输出对该层输入的偏导乘该层输入对该层权重的偏导拆到最后每一段都是能算的。反向传播这个名字里的反向说的就是这个从输出层往输入层逐层回推的方向。这里要提醒一个常见的理解偏差很多人以为BP是一种网络结构其实它不是它是一种计算梯度的算法。你可以把BP套在任何由可微运算堆叠起来的结构上卷积网络、循环网络用的都是同一套反向传播逻辑。理解了这一点后面学更复杂的模型时就不会觉得是新东西。1.2 一次完整训练到底转了几个圈整个训练流程可以拆成一个不断循环的四步闭环。第一步前向传播输入数据经过逐层的加权求和和激活函数得到预测输出。第二步算损失拿预测值和真实标签比较得到一个标量衡量这次预测错得有多离谱。第三步反向传播把这个标量损失对每一个权重、每一个偏置的偏导都算出来得到一个和参数矩阵同形状的梯度矩阵。第四步参数更新沿着梯度的反方向按学习率走一小步。这四步走完叫一个迭代跑完整个数据集叫一个epoch通常要跑几百上千个epoch。梯度下降的更新式子写成矩阵形式很简单W ← W − η · ∂L/∂W关键的地方在于每一次迭代里前向传播算出来的中间结果——每一层的输入 z 和激活输出 a——都必须缓存下来因为反向传播要用。这一点在手写代码的时候特别容易踩坑很多新手写完前向就把它当临时变量丢掉反向的时候再去重算代码结构会很乱也更容易出错。另外还有一个绕不开的前提整条计算链条必须处处可微。激活函数得可导损失函数得可导加权求和本身是线性的天然可导。这也是为什么早期用阶跃函数做激活的网络没法训练因为导数在绝大多数位置都是0梯度传回去全是0权重根本更新不了。这个细节在理解激活函数选型的时候很关键后面会展开讲。2. 前向传播的每一步从输入向量到预测值2.1 单个神经元加权求和加一次非线性挤压一个神经元干的事分两半。前半段是线性部分把所有输入乘上对应权重再求和加上偏置z w₁x₁ w₂x₂ … wₙxₙ b wᵀx b后半段是把 z 塞进一个非线性函数里得到这个神经元的输出 a f(z)。为什么非要加这个非线性函数不能只用线性加权因为线性层叠线性层结果还是线性变换三层和一层在表达能力上没有本质区别。加了非线性激活之后网络才能拟合曲线、曲面这类非线性关系。这就是所谓的万能逼近能力的基础。激活函数的选型直接影响训练效果我把几个常用的放在一起对比一下。Sigmoid 的表达式是 σ(z) 1/(1e^(−z))输出范围在 0 到 1 之间它最大的好处是导数可以写成 σ(z)(1−σ(z))直接用输出值就能算不用重新代入 z。缺点是输出不是零中心的而且当 z 的绝对值变大时函数进入饱和区导数趋近于0梯度传到这里就快没了。Tanh 表达式是 (e^z−e^(−z))/(e^ze^(−z))输出范围 −1 到 1是零中心的导数写成 1−tanh²(z)比 Sigmoid 收敛通常快一些。ReLU 就是 max(0, z)正半轴导数恒为1能有效缓解深层网络的梯度消失但负半轴导数恒为0学习率太大时神经元可能永久失活这就是常说的死亡ReLU。激活函数表达式导数形式输出范围典型问题Sigmoid1/(1e^(−z))a(1−a)(0, 1)非零中心饱和区梯度趋零Tanh(e^z−e^(−z))/(e^ze^(−z))1−a²(−1, 1)两端仍会饱和ReLUmax(0, z)1 (z0) / 0 (z≤0)[0, ∞)负半轴神经元失活注意如果你的网络只有两三层、数据量也不大用 Sigmoid 或 Tanh 完全没问题不必无脑上 ReLU。ReLU 的优势要在较深的网络里才明显体现出来。2.2 多个神经元串成层矩阵形式和维度对齐单个神经元翻来覆去就这点东西真正让代码变简洁的是把整层神经元写成矩阵运算。假设第 l 层有 n^(l) 个神经元上一层的输出是 a^(l−1)维度是 n^(l−1)那么这一层的加权求和可以一次性算完z^(l) W^(l) · a^(l−1) b^(l)其中 W^(l) 的形状是 (n^(l), n^(l−1))b^(l) 的形状是 (n^(l), 1)激活之后 a^(l) f(z^(l))形状是 (n^(l), 1)。维度这事看着琐碎但它是最有效的调试手段。我自己的习惯是每写一层就在注释里标一遍维度一旦报形状不匹配的错先别急着改代码拿纸把每一层的 (行, 列) 写下来对一遍八成问题就露出来了。举个例子一个 2-3-1 的网络输入层2个节点第一层权重形状 (3, 2)偏置 (3, 1)输出 (3, 1)第二层权重形状 (1, 3)偏置 (1, 1)输出 (1, 1)。这条链上如果哪一步写反了要么矩阵乘法直接报错要么更隐蔽地算出个形状碰巧对得上的错误结果后者才是真正要命的。还有一个实际工程里的细节批量训练的时候输入通常按 (batch_size, n_features) 的行向量形式组织此时公式要改成Z A_prev · Wᵀ bb 通过广播自动扩展。这两种写法在数学上等价只是矩阵摆的方向不同。我建议你从一开始就固定一种习惯别一会儿行向量一会儿列向量来回切换是形状错误的主要来源。2.3 损失函数选谁MSE 和交叉熵的分工损失函数是反向传播的起点它决定了第一项偏导怎么算。回归任务常用均方误差L (1/2m) Σ (a^(L) − y)²前面的 1/2 纯粹是为了求导方便平方求导会掉下来一个 2正好和 1/2 约掉公式干净很多。分类任务更常用交叉熵二分类形式是L −(1/m) Σ [ y·ln(a) (1−y)·ln(1−a) ]很多人第一次看到交叉熵会发怵但把它和 Sigmoid 搭配起来用会有一个非常漂亮的结果输出层的误差项直接化简成δ a − y那个 Sigmoid 的导数项消失了。这不是巧合而是交叉熵的设计和 Sigmoid 的导数结构刚好对上了。所以如果你用 Sigmoid 做输出层又选了 MSE梯度里会多带一个 a(1−a) 的因子当预测值接近 0 或 1 时这个因子的值很小梯度被压得很扁收敛会明显变慢。这是选型时必须知道的取舍。任务类型输出层激活推荐损失输出层误差项回归线性恒等MSEδ a − y二分类Sigmoid二元交叉熵δ a − y多分类Softmax多元交叉熵δ a − y表格第三列这个统一的结果很有启发性不管具体是哪种组合只要你把激活和损失配对用对了输出层误差项的形式总是预测减真实。这也是为什么框架里能那么统一地处理各种损失函数。3. 反向传播公式手推链式法则一层层剥开3.1 输出层的误差项 δ 究竟从哪里冒出来先定义符号。设第 l 层的加权输入是 z^(l)激活输出是 a^(l)损失记作 L。为了书写方便定义这一层的误差项δ^(l) ≡ ∂L/∂z^(l)意思是损失对第 l 层加权输入的偏导。为什么要按 z 而不是按 a 定义误差项因为链式法则往下传的时候z 是连接上一层权重和这一层激活的枢纽节点用 δ 做中间变量能让公式高度统一这也是初学者看教材时最容易迷惑的一步。从输出层开始推。损失对输出层激活的偏导是 ∂L/∂a^(L)而激活输出对加权输入的偏导是 f′(z^(L))根据链式法则δ^(L) ∂L/∂z^(L) (∂L/∂a^(L)) ⊙ f′(z^(L))这里的 ⊙ 是逐元素相乘。以 MSE 加线性输出为例L (1/2)(a − y)²那么 ∂L/∂a a − y配合线性激活的导数 1得到 δ a − y。如果换成交叉熵加 Sigmoid∂L/∂a 是 −(y/a) (1−y)/(1−a)乘上 Sigmoid 导数 a(1−a) 之后一顿约分最后还是 δ a − y。这两条路径我都自己动手写过建议你也至少完整推一遍交叉熵那条约分的过程很能说明问题。提示这里的 ⊙ 表示逐元素相乘而不是矩阵乘。在代码里对应 numpy 的*写成就是另一个意思了这个符号错误极难通过报错发现只会让梯度数值悄悄错掉。3.2 隐藏层误差反传为什么要乘以权重矩阵的转置输出层的 δ 拿到之后往前一层传。对第 l 层隐藏层它和后面一层的关系是 z^(l1) W^(l1) a^(l) b^(l1)而 a^(l) f(z^(l))。用链式法则把 L 对 z^(l) 的偏导拆开经过整理可以得到δ^(l) ( (W^(l1))ᵀ · δ^(l1) ) ⊙ f′(z^(l))这个式子有三个地方值得停下来想清楚。第一为什么是转置。前向传播时权重矩阵 W^(l1) 的形状是 (n^(l1), n^(l))把本层的 n^(l) 维信号映射到下一层的 n^(l1) 维空间。反向传播的方向正好相反要把 n^(l1) 维的误差信号映射回 n^(l) 维自然就得用转置矩阵形状是 (n^(l), n^(l1))乘上 (n^(l1), 1) 的 δ得到 (n^(l), 1)维度对得上。转置在这里不是数学上的顺手为之而是维度决定的必然要求。第二这个式子揭示了误差分配的逻辑。权重越大说明这个连接对下一层的贡献越大那么下一层的误差就该多分一点回来。转置乘法正是在做这件事把下一层每个神经元的误差按连接权重加权求和分摊给本层的每个神经元。第三为什么要乘本层的激活导数 f′(z^(l))。反向传回来的是误差对本层加权输入的敏感度但真正能调的是权重权重影响的是 zz 影响的是 aa 才影响最终损失。这条链上 f′(z) 是必经的一环不能漏。初学者最常犯的错就在这公式照抄了但忘了乘激活导数结果梯度方向大致对但数值偏差很大训练能走但慢得离谱还很难定位。3.3 梯度公式汇总和一个 2-2-1 网络的完整数值演算误差项都拿到之后权重梯度其实很好写。因为 z^(l) W^(l) a^(l−1) b^(l)所以 ∂z^(l)/∂W^(l) 就是 a^(l−1)∂z^(l)/∂b^(l) 就是 1于是∂L/∂W^(l) δ^(l) · (a^(l−1))ᵀ∂L/∂b^(l) δ^(l)批量训练时再对所有样本求平均W 的梯度形状是 (n^(l), n^(l−1))正好和 W 自己同形逐元素减法更新即可。光看公式容易飘我用一个 2-2-1 的网络完整算一遍。网络结构输入 2 个节点隐藏层 2 个节点用 Sigmoid输出层 1 个节点用 Sigmoid损失用 MSE。初始权重设定为 W^(1) [[0.2, 0.4], [−0.5, 0.3]]b^(1) [0.1, −0.1]ᵀW^(2) [[0.6, −0.8]]b^(2) [0.2]。样本输入 x [0.5, 0.1]ᵀ目标 y 1学习率 η 0.5。前向传播的中间结果我列成表步骤计算式结果隐藏层 z₁0.2×0.5 0.4×0.1 0.10.24隐藏层 a₁σ(0.24)0.5597隐藏层 z₂−0.5×0.5 0.3×0.1 − 0.1−0.32隐藏层 a₂σ(−0.32)0.4207输出 z0.6×0.5597 − 0.8×0.4207 0.20.1993输出 aσ(0.1993)0.5496损失0.5×(0.5496 − 1)²0.1014反向传播从输出层开始。δ^(2) (a − y)·a(1−a) (−0.4504)×0.5496×0.4504 −0.1115。接着算隐藏层误差δ₁^(1) W^(2)₁ × δ^(2) × a₁(1−a₁) 0.6×(−0.1115)×0.5597×0.4403 −0.0165同理 δ₂^(1) (−0.8)×(−0.1115)×0.4207×0.5793 0.0217。权重梯度就是对号入座。W^(2) 的梯度等于 δ^(2) 分别乘上两个隐藏层输出−0.1115×0.5597 −0.0624 和 −0.1115×0.4207 −0.0469。W^(1) 的梯度要用 δ^(1) 乘输入−0.0165×0.5 −0.0082、−0.0165×0.1 −0.0016、0.0217×0.5 0.0109、0.0217×0.1 0.0022。偏置梯度直接就是 δ 本身。最后更新比如 W^(2)₁ 从 0.6 变成 0.6 − 0.5×(−0.0624) 0.6312。这个例子我建议你真的拿纸复现一遍不用算到小数点后四位算到两位能对上趋势就够了。手算过一次之后代码里哪一层乘错了、哪个转置漏了看一眼就能感觉出来。4. 不依赖框架手写一个BP神经网络4.1 参数初始化为什么不能全部填 0初始化听起来是小事实际上是整个训练能不能启动的第一道关。最直觉的做法是把所有权重都设成 0这个做法会直接导致训练失败。原因是对称性问题如果同一层的所有神经元初始权重完全一样那么它们在前向传播中接收的输入相同、输出相同反向传播中拿到的梯度也完全相同更新之后权重依然一模一样。整层神经元等于退化成一个网络的表达能力被砍掉了大半。偏置可以初始化为 0因为它不参与对称性破坏这个问题。合理的初始化要让权重随机同时控制幅度。太小的初始权重会让信号在层层传递中不断衰减最后到达输出层时数值很小梯度也小太大则相反前向输出容易进入饱和区梯度直接趋近 0。Xavier 初始化给出了一个被广泛使用的方案对第 l 层W^(l) ~ U( −√(6/(n_inn_out)), √(6/(n_inn_out)) )这个区间的上下界来自对前向和反向信号方差的分析目标是让每一层的输出方差和输入方差尽量保持一致。对于前面那个 2-2-1 的网络第一层的区间是 ±√(6/4) ≈ ±1.2247第二层是 ±√(6/3) ≈ ±1.4142。配合 Sigmoid 或 Tanh 使用时效果通常不错如果换成 ReLU业界更常用 He 初始化区间改成以 0 为均值、方差为 2/n_in 的正态分布。这些数字不用死记记住背后的目标就行让信号和梯度在层间流动时方差保持稳定。注意初始化的随机种子一定要固定下来。同一个网络跑两次结果差很多的时候先别怀疑算法检查是不是种子没设。实验对比的时候种子不固定会让你的结论完全站不住脚。4.2 完整代码实现与逐行说明下面这份实现我用行向量形式组织数据维度是 (batch_size, n_features)和主流框架的习惯一致。前向传播过程中把每一层的 z 和 a 都缓存进字典反向直接用避免重算。import numpy as np def sigmoid(z): return 1.0 / (1.0 np.exp(-z)) def sigmoid_grad_from_a(a): # 利用 a 直接求导省掉重新代入 z 的一步 return a * (1.0 - a) def tanh_grad_from_a(a): return 1.0 - a * a class BPNetwork: def __init__(self, sizes, activationsigmoid, seed42): self.sizes sizes self.activation activation rng np.random.default_rng(seed) self.W, self.b [], [] for n_in, n_out in zip(sizes[:-1], sizes[1:]): # Xavier 初始化区间 limit np.sqrt(6.0 / (n_in n_out)) self.W.append(rng.uniform(-limit, limit, size(n_out, n_in))) self.b.append(np.zeros(n_out)) self.cache {} def _act(self, z): if self.activation tanh: return np.tanh(z) return sigmoid(z) def _act_grad(self, a): if self.activation tanh: return tanh_grad_from_a(a) return sigmoid_grad_from_a(a) def forward(self, X): # X 形状 (batch, n_in) A X self.cache {A0: X} for i, (W, b) in enumerate(zip(self.W, self.b)): Z A W.T b # 加权求和形状 (batch, n_out) A self._act(Z) # 激活 self.cache[fZ{i1}] Z self.cache[fA{i1}] A return A def backward(self, Y): m Y.shape[0] L len(self.W) grads_W [None] * L grads_b [None] * L # 输出层误差项MSE Sigmoid 的情形 A_last self.cache[fA{L}] delta (A_last - Y) * self._act_grad(A_last) for i in range(L - 1, -1, -1): A_prev self.cache[fA{i}] grads_W[i] delta.T A_prev / m grads_b[i] delta.mean(axis0) if i 0: # 误差反传乘转置权重再乘本层激活导数 delta (delta self.W[i]) * self._act_grad(self.cache[fA{i}]) return grads_W, grads_b def step(self, grads_W, grads_b, lr): for i in range(len(self.W)): self.W[i] - lr * grads_W[i] self.b[i] - lr * grads_b[i] def fit(self, X, Y, epochs5000, lr0.5, verbose500): for ep in range(1, epochs 1): pred self.forward(X) loss np.mean((pred - Y) ** 2) gw, gb self.backward(Y) self.step(gw, gb, lr) if ep % verbose 0: print(fepoch {ep:5d} loss {loss:.6f}) return self几处值得划重点的地方。delta (A_last - Y) * self._act_grad(A_last)这一行就是 3.1 节推出来的公式(A_last - Y)来自 MSE 对激活的偏导后面那个因子来自激活函数本身。反传那一行delta self.W[i]用的就是转置乘法因为 W[i] 形状是 (n_out, n_in)delta 形状是 (batch, n_out)左乘之后自然得到 (batch, n_in)转置在矩阵乘法里已经隐含完成了。第一次写的时候我在这里纠结了很久以为必须显式写W.T其实要看你把 W 定义成什么朝向定义方式不同公式的写法就要跟着改两者必须自洽。4.3 两个验证实验XOR 分类与正弦曲线拟合跑通代码之后要用小规模、可预期的问题来验证别一上来就上大数据集。第一个实验是经典的 XOR 问题四个样本 (0,0)→0、(0,1)→1、(1,0)→1、(1,1)→0。这个问题线性不可分单层感知机永远学不会正好用来验证网络确实具备非线性能力。用 2-4-1 结构Sigmoid 激活学习率 0.5跑五千轮损失能掉到 0.002 以下四个样本的预测值分别接近 0.05、0.95、0.95、0.05。X np.array([[0,0],[0,1],[1,0],[1,1]], dtypefloat) Y np.array([[0],[1],[1],[0]], dtypefloat) net BPNetwork([2, 4, 1], activationsigmoid, seed7) net.fit(X, Y, epochs5000, lr0.5, verbose1000) print(net.forward(X).round(3))第二个实验是拟合正弦曲线这也是很多人说的 BP 神经网络拟合曲线的典型场景。在 −2π 到 2π 之间取 200 个点y sin(x)用 1-8-1 的网络去拟合。这里有个细节输出层如果用 Sigmoid输出范围被限制在 0 到 1 之间而 sin 的取值范围包含负值永远拟合不了。所以做回归任务时输出层要用线性激活隐藏层用 Tanh 更合适因为它输出零中心对拟合这种有正有负的曲线收敛更快。x np.linspace(-2*np.pi, 2*np.pi, 200).reshape(-1, 1) y np.sin(x) net BPNetwork([1, 8, 1], activationtanh, seed1) net.fit(x, y, epochs8000, lr0.05, verbose2000) pred net.forward(x) print(拟合均方误差:, np.mean((pred - y) ** 2))实测下来隐藏层 8 个 Tanh 神经元、八千轮训练均方误差能压到 0.001 这个量级画出来的曲线和原正弦基本重合只在下采样点稀疏的区域略有偏差。如果误差停在 0.05 左右下不去八成是两个原因隐藏层神经元太少或者学习率给大了导致在最优点附近来回跳。5. 实操踩坑与排查速查表5.1 梯度消失、梯度爆炸和学习率的三方博弈梯度消失是 Sigmoid 和 Tanh 的天然短板。反向传播每往前一层就要乘一次本层的激活导数和一次权重。Sigmoid 导数的最大值只有 0.25层数一多0.25 连乘几次就接近 0 了前面的隐藏层几乎收不到有效梯度权重基本不动。表现就是训练日志里损失一开始降一点然后就卡住不动。应对办法有几种把激活换成 ReLU 系列、用 Xavier 或 He 初始化控制信号幅度、减少网络层数、或者引入残差连接给梯度开一条直通路径。梯度爆炸则是另一个极端。权重初始化太大或者学习率设得过高反向传播中梯度连乘后急剧放大参数一步更新就跑到很离谱的地方损失直接变成 nan。判断方法很简单训练日志里损失不是下降而是突然变成 inf 或者 nan基本就是爆炸了。粗暴但有效的处理是梯度裁剪把每次更新的梯度范数限制在一个阈值内比如超过 5 就等比例缩回去。学习率是这两个问题之外的第三个变量也是实际调参时最先动手的一个。它太大损失曲线会剧烈震荡甚至发散太小收敛慢得让人怀疑人生。我一般先用 0.1 试一下看曲线形状如果震荡就除以 10如果下降太慢就乘 3逐步逼近。数据显示配上动量之后学习率可以适当放大而不会那么敏感。5.2 训练不收敛的排查清单出问题的时候最忌讳乱改代码我整理了一份按顺序排查的清单从最容易验证的开始基本上一到两轮就能锁定方向。现象可能原因排查与处理损失完全不降几乎不变学习率过小权重初始化为 0逐个量级放大学习率检查初始化是否随机损失先降后卡住梯度消失隐藏层太少换 ReLU/Tanh加宽隐藏层换初始化损失震荡剧烈或变 nan学习率过大梯度爆炸学习率除以 10加梯度裁剪损失降到某个值就不动了网络容量不足数据本身有噪声上限加隐藏层节点数检查标签是否有错训练集表现好新数据一塌糊涂过拟合加正则项减少参数增大数据量预测值全挤在 0.5 附近输出层激活用错初始化幅度太小回归任务输出层改线性调大初始化范围提示训练前先用一批极小数据比如 4 到 8 个样本做一次过拟合测试。如果网络连这几条数据都拟合不了说明代码哪里有 bug此时去调超参数是浪费时间。这个先过拟合小数据集的习惯是我踩了无数坑之后养成的强烈推荐。它把代码是不是写错了和超参数是不是不合适这两个问题彻底分开了排查效率能提高一大截。5.3 MATLAB 版本对照从 nntool 到 fitnet有不少课程和教材还在用 MATLAB 讲 BP 神经网络拟合曲线这里也顺手对一下。MATLAB 的神经网络工具箱把训练流程封装得很彻底浅层网络最常用的写法是 feedforwardnet 加上 trainx -2*pi:0.05:2*pi; y sin(x); net feedforwardnet(8); % 8 个隐藏层神经元 net.trainParam.epochs 8000; net.trainParam.lr 0.05; net train(net, x, y); % 自动完成初始化、前向、反向、更新 pred net(x); plot(x, y, b, x, pred, r);老版本里还有个图形界面的 nntool可以直接导入数据、设结构、点按钮训练适合教学演示。用它的好处是省去了手写反向传播坏处也在这里整个过程是个黑盒梯度怎么算、初始化怎么做的全被包起来了。我的建议是两边都走一遍先用 MATLAB 快速看到拟合曲线长什么样建立直观感受再用手写的 numpy 版本把每一步对一遍这样既有效率又不至于浮在表面。顺便提一句MATLAB 默认的数据组织方向和 numpy 相反输入是按列放的特征在行的维度上跨语言移植代码时这个转置关系一定要确认清楚不然会出现代码没错但结果就是不对的诡异情况。6. BP 之外这套思想的延伸与边界6.1 从 SGD 到 Adam优化器到底在改什么前面一直用最朴素的批量梯度下降所有样本算平均梯度再更新。它的缺点是每步都要扫全量数据大数据集上慢得没法用。随机梯度下降每次只用一条样本更新快但抖动大。小批量梯度下降折中每次用 32 到 256 条样本这也是现在实际训练中最常用的方式。再往后是动量法思路是给参数更新加一个惯性如果前几步梯度方向一致就加速往前冲如果方向来回变就相互抵消减小震荡。数学上就是在更新量里引入一个衰减的历史梯度累积项。带动量的 SGD 收敛曲线明显更平滑对学习率也没那么敏感。Adam 在动量之外又引入了自适应学习率为每个参数单独维护梯度的一阶矩和二阶矩估计学习率按参数的历史梯度平方自动缩放。梯度一直很大的参数步子自动变小梯度一直很小的参数步子适当放大。这个机制让 Adam 在大多数任务上几乎不用怎么调学习率就能跑出不错的结果代价是占用的显存多一些泛化性能在某些任务上比精调过的 SGD 略差一点。有意思的是Adam 的更新策略有个细节很像 BP 本身它也在做一种按贡献分配的调整谁的历史梯度大谁就被压一压谁一直没动静谁就被推一推。这种自适应的思想和当初把误差按权重比例分摊回去的逻辑内核上是相通的。6.2 什么时候该放弃 BP 思路BP 不是万能钥匙它有自己的适用边界。第一问题是线性可分的比如简单的二分类用逻辑回归就能解决上多层网络纯属杀鸡用牛刀还引入了初始化、学习率一堆要调的参数。第二数据量极小比如只有几十条样本这时候参数多的网络会直接过拟合用决策树、支持向量机这类模型往往更稳。第三需要模型完全可解释比如某些需要给出明确判别规则的场景神经网络的权重矩阵基本没法解释成人类能读懂的规则这时应该换线性模型或者基于规则的算法。还有一个容易被忽略的点BP 依赖梯度意味着损失函数必须可微。一旦你的任务涉及离散的、不可导的决策步骤比如排序、路径选择直接套 BP 就行不通需要引入梯度估计或者强化学习那一套方法。数据集里存在大量噪声、标签本身就不准的时候BP 会把噪声也一起拟合进去效果可能还不如简单的朴素贝叶斯。说到底理解 BP 的价值不只在于会用它更在于知道它什么时候不适用。这套反向传播的思想已经渗透到几乎所有现代深度学习模型里卷积层、注意力机制、批归一化反向传播的逻辑都是同一套沿计算图往回收用链式法则把误差分摊下去每个可微算子贡献自己那一段偏导。我自己在做项目时的一个体会是把 BP 手推一遍这件事投入产出比高得超出预期。后面再遇到梯度为 nan、损失卡住不动、模型不学习这类问题脑子里会自动过一遍公式链条是激活导数那项错了还是转置漏了还是损失函数配错了激活。定位问题的速度和只会调框架的人完全不在一个量级。如果你也正卡在某个训练不收敛的坑里不妨把纸笔拿出来按第 3 节那个 2-2-1 的例子从头推一次很多疑惑会在推导的过程中自己解开。
返回列表