ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从梯度下降到反向传播:神经网络训练核心流程详解

从梯度下降到反向传播:神经网络训练核心流程详解

1. 把"学习"翻译成数学:损失函数、梯度和一次完整的参数更新

我见过太多初学者把神经网络的学习过程当成某种黑箱魔法,总觉得模型里的几百万个参数是被什么神秘力量调出来的。但如果你真正动手把一个最朴素的训练循环写一遍,会发现整个过程没有任何玄学:先让模型在当前参数下做一次预测,算算预测错得有多离谱,再求出"错得有多离谱"这件事对每个参数的偏导,沿着让错误变小的方向把参数挪一小步,然后重复几千次。这套流程叫梯度下降,它构成了从线性回归到最深层网络几乎所有"学习"的共同底座。这篇文章不是讲理论推导,而是从一个常年和训练曲线较劲的人的角度,把这根从梯度下降到神经网络学习的线完整捋一遍,包括每个关键环节背后的"为什么"。

1.1 学习的唯一裁判:损失函数

要理解神经网络学习,先得接受一个有点枯燥但极度重要的设定:我们把模型的所有"智能"全部浓缩在一组参数里。对线性模型来说是权重 w 和偏置 b,对神经网络来说就是所有层之间的连接权重和偏置。所谓学习,换句话说是在参数空间里找到一组参数,让模型在训练数据上表现最好。

"表现最好"必须有一个可量化的标准,否则一切讨论都是空谈,这个标准就是损失函数。回归问题常用均方误差(MSE),分类问题常用交叉熵。损失函数的值越低,代表模型的预测和真实标签越接近。这里有一个关键认知:损失函数是参数的函数,而不是数据的函数。虽然损失是通过数据算出来的,但一旦训练集固定,损失的大小就完全由当前参数决定。参数一变,损失就变,我们的目标就是找到让损失最小的那一组参数组合。

这个视角一旦建立起来,很多困惑就迎刃而解了。比如有人问"神经网络到底在学什么",答案就变得非常具体:学到的是一组参数,这组参数能让训练集上的损失函数尽量小。而"学得好不好"直接对应损失函数值的高低。

1.2 梯度为什么告诉我们应该往哪走

稍微回忆一下微积分,一个多元函数在某个点的梯度,是一个向量,它的方向是函数值增长最快的方向。所以想减小函数值,就沿着梯度的反方向走。这就是梯度下降名称的来源——沿着梯度相反的方向下降。

一个特别好的类比是摸黑下山:你闭着眼睛站在山坡上,想知道哪边是下坡,最简单的办法是用脚感受哪个方向地面倾斜最陡,然后朝那个方向迈一步,走完再重新感受,再迈下一步。梯度就是那个"地面最陡的方向",学习率就是"一步迈多大"。你不需要知道整座山的形状,只需要掌握脚下这一小片区域的倾斜方向就足够前进了。神经网络训练的迷人之处恰恰在这里:你不需要理解损失函数在参数空间里的全局形状——那个形状是高维的、无法想象的——你只需要能算出当前位置的梯度。

1.3 线性回归就是最原始的神经网络

这个观点我几乎对每个来问问题的人都会说:一个神经元做的事,本质上就是把输入乘以权重、加偏置、过激活函数。单神经元加均方误差加梯度下降,就是线性回归;把多个神经元堆叠起来并引入非线性激活函数,就成了神经网络。

拿一元线性回归举例。目标函数是 J(w, b) = (1/n) Σ (w·x_i + b - y_i)²,对 w 求偏导得到 ∂J/∂w = (2/n) Σ (w·x_i + b - y_i)·x_i,对 b 求偏导得到 ∂J/∂b = (2/n) Σ (w·x_i + b - y_i)。于是更新规则就是 w ← w - η·∂J/∂w,b ← b - η·∂J/∂b。用纯 Python 写出来不过十几行:

import numpy as np rng = np.random.default_rng(42) X = rng.uniform(-1, 1, size=100) true_w, true_b = 2.0, 0.5 y = true_w * X + true_b + rng.normal(0, 0.1, size=100) w, b = 0.0, 0.0 lr = 0.1 for step in range(200): pred = w * X + b loss = np.mean((pred - y) ** 2) grad_w = np.mean(2 * (pred - y) * X) grad_b = np.mean(2 * (pred - y)) w -= lr * grad_w b -= lr * grad_b if step % 40 == 0: print(f"step {step:3d}, loss {loss:.4f}, w {w:.3f}, b {b:.3f}")

这个循环里已经包含了深度学习中"训练"的全部基本动作:预测、计算损失、求梯度、更新参数。后面不管模型换成卷积还是 Transformer,循环骨架一个字都不会变。你每一次调学习率、改优化器、加正则化,本质上都是在跟这个朴素的循环打交道。

2. 学习率:这个超参数定不好,损失曲线会当场给你颜色看

如果说梯度告诉模型"往哪走",学习率就是"一步走多远"。这一步的距离,是所有超参数里对训练结果影响最直接、也最容易出问题的。我在调试模型时,第一步几乎永远是看学习率有没有问题,因为它的表现形式非常典型,基本不用猜。

2.1 学习率太大、太小和"刚好"的三种脸

学习率太大时,损失曲线会出现高频震荡,严重的时候 loss 直接变成 nan。原因是参数每次迈的步子太大,直接跨过了最小值所在的低谷,跳到对面更高的坡上,下一次又跳回来,结果在山谷两边反复横跳,永远落不到谷底。步子大到一定程度,梯度本身被放大,数值溢出,损失直接变成非数值。

学习率太小时,损失曲线看起来"很稳定",但下降速度慢得让人绝望。训练了几千个 step,损失还在高位原地踏步。很多人这时候会误以为是模型结构的问题,疯狂改网络深度和宽度,实际上只是步长太小,需要几十倍的迭代次数才能看到明显变化。

学习率合适时,损失曲线呈平滑的下降趋势,前期下降快,后期逐渐放缓,最终稳定在一个较低的水平。这个"前期快后期慢"是有理论依据的:损失曲面在远离最小值的地方通常比较陡峭,梯度大,所以迈步有效;接近最小值附近变得平坦,梯度趋近于零,步长不变的话,每一步迈出去的实际距离也变小了。

学习率状态损失曲线表现常见误判
过大剧烈震荡或直接变 nan误以为是数据问题、梯度爆炸
过小平稳但下降极慢,几乎不动误以为是模型容量不足
适中平滑下降,先快后慢正常

2.2 一个实用的初始学习率试法

很多人喜欢直接套一个"默认值",比如 0.01 或者 0.001。这不完全是错的,主流自动微分框架的很多优化器默认学习率确实是 1e-3,在小模型上普遍能跑通,算是安全起点。但安全不等于最优,不同数据尺度、不同损失曲面下,最合适的学习率数量级可以差出几个量级。

我自己的做法是在小规模数据上快速试探。具体来说:拿一小批数据,固定模型结构不变,分别用 0.1、0.01、0.001、0.0001 训练几十个 step,观察损失曲线。0.1 的 loss 如果剧烈震荡甚至变 nan,说明太高,往下除以 10;0.001 如果让 loss 明显下降但速度偏慢,就往上乘 3 试试。通常两三轮试探下来,就能锁定大致范围。

这里有一个不少人忽略的技巧:初始学习率应该跟数据特征尺度联动。如果输入特征的量级在 0.01 到 0.1 之间,模型梯度的数值天然偏小,同样的学习率表现会偏慢;如果特征量级在几百上千,梯度数值偏大,同样学习率又容易震荡。这也是为什么特征标准化(归一化到零均值单位方差)常常能直接改善训练——它不是改变算法,而是把损失曲面的形状变得对你更友好。

2.3 为什么要做学习率衰减

训练后期,参数已经进入最小值附近的一片平坦区域,梯度非常小。这时如果仍然用初始学习率,参数会在极小值附近往复震荡,损失难以继续下降。学习率衰减(learning rate schedule)就是让训练中后期步长逐步变小,从而在最小值附近做更精细的调整。

常见的衰减方式有按步数阶梯式衰减(比如每隔 N 轮乘以 0.1)、指数衰减和余弦退火。实际效果上,余弦退火在很多任务里表现不错,因为它前期衰减慢、后期衰减快,跟损失下降的自然节奏吻合。我的经验是:先把常数学习率调通模型,再考虑加衰减。学习率衰减确实能挤出一些额外的精度,但它不是用来救训练失败问题的,而是在模型已经基本收敛时锦上添花。

3. 反向传播:梯度如何穿过层与层之间的关卡

上面的线性回归只有一个权重,求导不需要费太多力气。换成多层神经网络后,问题来了:损失函数对第一层权重的偏导,要穿过后面所有层的计算才能算出来。反向传播就是组织这件事的算法,它是神经网络能从"一个神经元"扩展到"几亿参数"的工程基石。

3.1 链式法则:反向传播的数学骨架

反向传播的数学基础只有一句话,就是微积分里的链式法则:复合函数的导数等于外层导数乘以内层导数。神经网络本质上就是一个超大的复合函数,输入经过第一层、激活函数、第二层、激活函数……最终得到输出,然后跟真实标签计算损失。

求损失对某一层权重的梯度,需要从输出端开始,一层一层往回乘上每一层的局部梯度。这个过程被称为"反向"传播,因为它和计算的方向正好相反。前向传播是从输入到输出算预测值,反向传播是从输出到输入算梯度。前向传播过程中,每一层需要把中间结果记住,比如激活函数的输出,因为反向传播计算梯度时要复用这些数值。这也是为什么训练神经网络比单纯推理要占用更多内存——中间激活都被保留着等反向传播来用。

3.2 手算一次两层网络的梯度

空谈容易晕,我们来手算一个最简两层网络。结构是:输入 x=1,第一层权重 w1=0.5,b1=0,激活函数用 sigmoid σ(z)=1/(1+e^(-z));第二层权重 w2=0.8,b2=0,输出层不加激活函数。真实标签 y=1,损失用均方误差 L=0.5·(ŷ-y)²。注意这里为了求导方便,MSE 加了 0.5 系数,实际完全等价,只是梯度公式更干净。

先做前向传播。z1=0.5×1=0.5,a1=σ(0.5)≈0.622;输出 ŷ=0.8×0.622≈0.498;损失 L=0.5×(0.498-1)²≈0.126。

然后反向传播。损失对输出的偏导是 ∂L/∂ŷ=ŷ-y=-0.502。接着就能算第二层权重梯度:∂L/∂w2=∂L/∂ŷ×a1=-0.502×0.622≈-0.312。

第一层的权重梯度要麻烦一点,它要穿过第二层的权重、穿过 sigmoid 的导数:∂L/∂w1=∂L/∂ŷ×w2×σ(z1)×(1-σ(z1))×x=-0.502×0.8×0.622×0.378×1≈-0.094。

得到两个梯度后,设学习率 0.1,更新:w2←0.8-0.1×(-0.312)=0.831,w1←0.5-0.1×(-0.094)=0.509。可以看到,两个权重都朝着减小损失的方向移动了。这个手算过程虽然数值简单,但它完整展示了反向传播的核心形态:梯度从输出端往回流,逐层乘上局部梯度,每一层的权重梯度都依赖"后面所有层贡献的误差信号"。

3.3 梯度消失不是 bug,是连乘的宿命

手算过程中有一个值得注意的细节:sigmoid 的导数最大只有 0.25。当网络层数变多,反向传播的误差信号每穿过一个 sigmoid 层就要乘一个小于等于 0.25 的数,穿十几层之后,梯度就指数级衰减到几乎等于零,靠近输入层的权重根本得不到有效更新。这就是梯度消失问题,早期深层网络训不动的主要原因。

理解了梯度消失的来源,很多工程技巧就变得顺理成章了。ReLU 激活函数在正区间导数恒为 1,不会把误差信号缩小;残差连接给梯度提供了一条"近道",绕过部分连乘路径;归一化初始化让每一层的输出方差保持稳定,避免信号放大或衰减。这些都不是凭空冒出来的 trick,而是针对"连续乘法导致信号失控"这一根本机制开出的药方。

4. 从朴素 SGD 到 Adam:优化器的每一步进化都在治哪个病

前面讲的一直是最朴素的梯度下降:用全部数据计算梯度,沿负梯度更新参数。真实项目中,几乎没人这样训练神经网络。不是因为朴素梯度下降有错,而是它有两处明显的"不舒服",优化器的进化史就是针对这两处不舒服不断打补丁的历史。

4.1 朴素梯度下降的两个痛点

第一个痛点是计算效率。每更新一次参数,都要用全部训练数据计算梯度。数据量一大,一次更新就慢得无法接受,训练周期变得不可行。于是出现了小批量随机梯度下降(mini-batch SGD):每次随机从训练集抽一小批样本,用这一小批的平均梯度近似整个数据集的梯度。虽然每次的梯度带噪声,但足够指引正确的方向,而且能极大提升更新频率。

第二个痛点是优化路径。参数空间中,损失函数的陡峭方向和平坦方向往往差异巨大。朴素梯度下降在陡峭方向上来回震荡,在平坦方向上龟速前进,路径呈锯齿状。这在某些损失曲面上表现得尤其明显。怎么让更新过程更聪明,成了优化器要解决的核心问题。

4.2 动量:让历史方向指导下一步

动量(Momentum)的直觉是:给参数更新加上"惯性"。下山时不希望每一步都被当前局部的陡峭方向带偏,而是让脚步兼顾过去一段时间的运动方向。数学上,动量维护一个梯度历史 v,每次更新时 v 由当前梯度和历史 v 按比例混合,然后用 v 而不是原始梯度去更新参数。

这样做的好处体现在两个场景。在震荡严重的陡峭方向上,前后梯度方向相反,混合之后互相抵消,震荡被压小;在平坦方向上,历史中的动量方向一致,梯度叠加,前进速度被加快。动量系数 β 通常取 0.9,意思是当前方向大概 90% 来自历史动量,10% 来自最新梯度。可以这样理解:带动量的 SGD 不再是"摸黑走一步算一步",而是"记住刚才滑行的方向,顺势调整"。

4.3 自适应学习率:每个参数该有自己的步长

动量解决了方向问题,但还有一个问题没解决:不同参数的更新幅度差异。有的参数梯度大,对应的损失面陡峭,需要更小的步长来避免震荡;有的参数梯度小,需要更大的步长来加速前进。用一个全局统一的学习率,本质上是对所有参数一视同仁,并不合理。

RMSProp 的思路是给每个参数维护一个梯度平方的滑动平均,更新时用当前梯度除以这个滑动平均的平方根。梯度大的参数,分母大,步长自动缩小;梯度小的参数,分母小,步长自动放大。每个参数因此拥有了自己的"自适应步长"。

Adam 则是动量和自适应步长的合体,再额外加了偏差修正来稳定训练初期滑动平均估计不准的问题。它的默认参数(学习率 1e-3,β1=0.9,β2=0.999)在绝大多数模型上都能直接跑通,这也是它成为现代深度学习默认优化器的原因。

优化器核心思想主要优势我的使用建议
朴素 SGD沿负梯度直接更新理论性质清晰,无额外状态教学、理解原理时练手
SGD + Momentum梯度历史叠加,带惯性路径更平滑,平坦方向加速追求最终精度时精调
RMSProp按梯度平方调整单参数步长每个参数自适应步长非平稳目标、RNN 类任务
Adam动量 + 自适应步长 + 偏差修正收敛快,默认参数鲁棒绝大多数项目的起点

4.4 实际项目中我是怎么选优化器的

我的选择逻辑分为两条线。如果目标是快速验证想法、让模型先跑起来看效果,直接上 Adam,默认参数一般就够,不用花太多心思。如果项目已经进入精度攻坚阶段,我会切换到 SGD + Momentum 并精调学习率和衰减策略。原因是在很多任务上,SGD 配合合适的学习率调度,最终收敛的泛化性能往往比 Adam 更好。学术上有各种解释,但我的体感就一句话:Adam 擅长快速找到一个"还不错的解",而 SGD 更适合在充分训练后走向"更精细的解"。

不过这里要提醒一句,如果你用 Adam 训到 0.3 的损失,换成 SGD 后损失反而变高,不代表 SGD 不行。SGD 需要配合更长的训练时间、合适的学习率和衰减计划,它有自己的一套脾气,不是换个优化器就能无缝衔接的。

5. 用纯数值库手写一个两层神经网络并训练它

理论聊得再多,不如自己写一遍。这里我用一个纯数值计算库,实现一个可训练的两层神经网络,不借助任何自动微分框架,让"从梯度下降到神经网络学习"这条线完整落地。数据造一个简单的非线性函数拟合任务,目标函数是带噪声的正弦曲线,网络结构是 1 个输入、4 个隐层神经元、tanh 激活、1 个线性输出,损失使用均方误差。

5.1 前向传播与参数初始化

import numpy as np def make_data(n=200): rng = np.random.default_rng(7) x = rng.uniform(-3, 3, n) y = np.sin(x) + rng.normal(0, 0.15, n) return x.reshape(-1, 1), y.reshape(-1, 1) X, Y = make_data() D, H = 1, 4 # 初始化:注意方差选择 W1 = np.random.randn(D, H) * 0.5 b1 = np.zeros((1, H)) W2 = np.random.randn(H, 1) * 0.5 b2 = np.zeros((1, 1))

初始化方差这里值得多说一句。如果把 W1 的标准差设成 1.0 而不是 0.5,tanh 的输入 z 会集中在正负几的范围,处于激活函数的饱和区域附近,梯度值偏小,训练会明显变慢。把标准差压小到 0.5,激活处于更线性的区间,梯度流通更顺畅。这就是初始化影响训练的最直接体现。

5.2 前向传播、反向传播与参数更新

lr = 0.05 for step in range(5000): # 前向传播 z1 = X @ W1 + b1 a1 = np.tanh(z1) y_pred = a1 @ W2 + b2 loss = np.mean((y_pred - Y) ** 2) # 反向传播 grad_y_pred = 2 * (y_pred - Y) / Y.shape[0] grad_W2 = a1.T @ grad_y_pred grad_b2 = np.sum(grad_y_pred, axis=0, keepdims=True) grad_a1 = grad_y_pred @ W2.T grad_z1 = grad_a1 * (1 - a1 ** 2) grad_W1 = X.T @ grad_z1 grad_b1 = np.sum(grad_z1, axis=0, keepdims=True) # 更新参数 W1 -= lr * grad_W1 b1 -= lr * grad_b1 W2 -= lr * grad_W2 b2 -= lr * grad_b2 if step % 1000 == 0: print(f"step {step:4d}, loss {loss:.5f}")

写反向传播时最容易出错的地方是矩阵维度。一个很管用的自查习惯:每个参数的梯度矩阵,形状必须和该参数一模一样。grad_W2 的形状要和 W2 一样,grad_W1 要和 W1 一样,偏置同理。我见过不少人在这一步把矩阵乘法的顺序写反,导致维度对不上或者算出来的梯度语义错误。维度对了,至少能保证代码跑通;至于梯度数值是否正确,后面会介绍用数值梯度做交叉验证的方法。

运行这段代码,会看到损失从最初的 1.9 左右稳步下降到 0.02 上下,拟合出的曲线和正弦曲线基本重合。一个 13 个参数的神经网络,只靠"算梯度、更新参数"这一个循环,就学会了隐层自己构造非线性特征去拟合目标函数。这一幕是理解深度学习最震撼的瞬间。

5.3 训练循环里应该盯住的几个信号

训练不是把代码跑完就结束,而是要持续观察。我最关注的是损失曲线的形状,而不是最终数值。正常情况下,前几百个 step 损失应该快速下降,之后进入平缓期。如果前几个 step 损失不降反升,先检查学习率和初始化;如果损失一路震荡,先怀疑学习率偏大。另外,数据量不大时,把训练完成后的预测曲线画出来跟真实曲线叠在一起看,是最直观的验证方式——数学指标可能骗人,图形一眼就能看出拟合得好不好。

5.4 手写一遍之后再回去用框架,你就不慌

现在主流的自动微分框架把上面的过程封装成了几十行代码里的几个调用。框架用起来当然方便,但正因为太方便,很多人遇到"梯度为 None""训练发散""损失不下降"这类问题时会无从下手,因为不知道哪一层出了问题。手写一遍反向传播后,再看框架报错,你大概能猜出梯度断在哪一层、数值爆炸发生在哪个环节。这份判断力不是看书看出来的,是真刀真枪写一遍代码换来的。

6. 当损失不下降:一条可复用的排查链路

训练神经网络的过程中,"损失不下降"是最常见的挫败来源。好消息是,这类问题有规律可循,按照固定的链路排查,大多数情况能快速定位。我把自己平时调试的顺序整理在这里,它不是教科书式的流程,而是踩过足够多坑之后沉淀下来的经验。

6.1 先看损失曲线的形状,而不是单个数值

很多人习惯盯着某一个 step 的损失发愁,这是效率很低的做法。损失曲线的形状信息量远超单个数值。如果损失一开始就是 nan,方向很明确:步长过大导致梯度数值溢出,或者数据里存在极端异常值,或者损失计算里出现了对数零值。如果损失维持在初始化附近几乎不动,重点怀疑梯度没传回来或初始化把所有激活推到了饱和区。如果损失先下降后反弹,可能步长后期过大冲过了最小值,也可能训练集里混入了脏标签。如果训练损失降得很好、验证集损失却持续上升,那就是过拟合问题,跟优化算法关系不大了,优先考虑减少模型容量、加正则化或增加数据。

曲线症状大概率原因优先处理方向
一开始就 nan学习率过大 / 数据异常值调小学习率,检查数据
损失完全不动梯度消失 / 初始化不当换激活函数,检查初始化
先降后反弹学习率偏大 / 脏标签调小学习率,清洗数据
训练降验证升过拟合加正则,减容量,增数据

6.2 从数据、初始化、学习率三个方向定位

数据问题首当其冲。输入特征没有做标准化时,不同特征的量级差异会让梯度方向被大尺度特征主导,训练效率极差。把每个特征标准化到零均值单位方差,经常会让训练速度有肉眼可见的提升。另外检查标签有没有异常值,一个数值离谱的标签足以让损失居高不下。

初始化问题排在第二位。把权重初始标准差调小一个量级,往往就能解决激活饱和、梯度消失的问题。我自己调试时有个习惯:第一步先把"初始损失"算出来跟理论值对比。比如二分类问题初始损失应该在 log(2) 附近,均方误差问题初始损失应该在目标方差附近。如果初始损失明显异常,大概率前向传播或者数据预处理出了问题,这时候根本不用急着看梯度。

学习率排在第三位,用前面提到的小规模试探法快速确定数量级。这三个方向排查完,绝大多数"损失不下降"的问题都能解决。

6.3 数值梯度是你最可靠的照妖镜

反向传播代码写得再顺手,也有出错的时候。这时候有一个专门用于验证梯度的工具:数值梯度。对每个参数 θ,计算 (L(θ+ε)-L(θ-ε))/(2ε),这是导数的数值近似,然后用它跟反向传播算出来的梯度对比,两者应该在 ε 足够小时高度一致。这个办法实现成本极低,却能一次性排除"梯度算错"这一大类问题,我在手写任何反向传播代码时都会跑一遍。

6.4 我踩过的一次"模型不动"事故

最后分享一个真实场景。某次我给一个较深的网络换了 sigmoid 激活,训练了上千个 step,损失一直停留在初始值附近。起初我怀疑学习率,反复调了多次都没效果;接着怀疑数据,检查了预处理,也找不出毛病。最后把注意力放到初始化上,发现权重被初始化成了标准正态分布乘以一个比较大的系数,导致每层输入直接进入 sigmoid 的饱和区,梯度被压到几乎为零,模型根本没有在学习。把初始化改成小方差分布后,问题立刻消失。这个经历给我的教训是:遇到模型不动,先检查激活饱和,再怀疑学习率。顺序反了会白费很多时间。

把这条线捋完之后,我的个人体会

从梯度下降到神经网络学习,这条线的终点不是会调 Adam 参数或背出反向传播公式,而是建立起一种"可解释的直觉":你每改动一个超参数、每换一种网络结构,心里都知道它在那个朴素的"预测—算损失—求梯度—更新参数"循环里到底动了哪一环。框架会换、模型架构会换代,但这个循环不会变。我自己带人入门时,从不让他们急着用现成框架搭模型,而是先手写线性回归,再手写两层网络,最后才碰框架,理由很简单:亲手走一遍这条线之后,后面所有的高级话题——批归一化、残差连接、注意力机制——都只是围绕这个循环的不同瓶颈做修补。它们的价值,你会在真正遇到瓶颈时自动体会到。最后分享一个小技巧:训练日志里把每个 step 的损失都记录下来,画成曲线存档,可能是你调试模型时最便宜、最稳定、也最不会被辜负的一笔投入。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进