第 5 章 · 学习是怎么发生的

怎么变“好”:梯度下降

上一章我们把“错”变成了一个数字——损失。现在的问题非常具体: 该把每个参数往哪个方向、调多大,才能让这个数字变小? 答案是深度学习的发动机:梯度下降(gradient descent)

读完这一章,你会明白

  • “梯度”到底是什么,为什么它指向“下降最快的方向”;
  • 参数更新那条核心公式 wwη·梯度;
  • 学习率 η 是什么、太大太小分别会怎样;
  • 什么是 mini-batch SGD,以及 batch / epoch / step 三个词到底谁管谁;
  • batch size 该取多大——“稳”和“快”之间的权衡,并逐行读懂参数更新的真实代码。

1. 把训练想成“蒙着眼下山”

想象损失是一片山地的海拔:你站的位置由所有参数决定,海拔就是当前的损失。 我们想走到谷底(损失最小)。但你蒙着眼,看不到全局地形,只能感受 脚下哪个方向最陡,然后朝下坡迈一步;到了新位置再感受、再迈一步…… 反复下去,通常就能走到一个谷底。

参数 w 损失 L 当前参数(损失高) 谷底 = 损失最小

每一步都朝“最陡下坡”方向走一小步,一步步逼近损失的谷底。

2. “梯度”就是最陡的方向

“脚下最陡的方向”,在数学里有个名字叫梯度(gradient)。 它其实就是损失对每个参数的导数:导数告诉你“这个参数动一点点,损失会朝哪边变、变多快”。

两种情况合起来,规律惊人地一致:朝“梯度的反方向”走,损失就会下降。 这就是“梯度下降”名字的由来。写成公式就是那条你以后会见无数次的更新规则:

wwη · Lw 新参数 = 旧参数 − 学习率 × 梯度。对每一个参数都这么做一遍

3. 学习率 η:每一步迈多大

公式里的 η(读作 eta)就是学习率(learning rate), 它控制每一步的步子大小。这是整个训练里最关键、也最需要调的一个旋钮:

想亲眼看看?

下一章的“前向 / 反向传播动画”里有一个 Learning Rate 滑块, 你可以拖动它,观察同样的梯度下,参数每一步被改动的幅度如何随学习率变化。 到第 9 章我们还会让学习率在训练过程中动态变化(学习率调度)。

4. 不必每次都看完所有数据:mini-batch SGD

严格说,要算“真正的梯度”,得把所有训练样本都过一遍。但数据量动辄百万, 每走一步都全看一遍太慢了。实践中我们用一个聪明的折中:每次只随机抓一小批(mini-batch) 样本,用这一小批估算梯度,就更新一次。这叫小批量随机梯度下降(mini-batch SGD)

看看训练主循环是怎么做的(精简自 Train):

src/deeplearning/neural_network.cpp · Train(精简)
// 每轮开始前打乱样本顺序
std::shuffle(index_pos.begin(), index_pos.end(), shuffle_gen);  1

// 取出一小批样本, 走一遍 "前向 → 反向 → 更新"
ForwardPropagationBatch(batch_data);   2
ResetGradients();
BackPropagationBatch(batch_target);    3
ApplyGradient(B);                      4
  1. 打乱顺序,避免模型“记住样本的排列”,让每一批都更有代表性。
  2. 对这一小批做前向传播,得到预测(第 3 章)。
  3. 反向传播,算出这一批里每个参数的梯度(第 6 章马上讲)。
  4. 用算好的梯度更新一次参数。B 是这批的样本数,用来把梯度取平均。

5. batch、epoch、step:三个最容易搞混的词

一上手训练,你就会被这三个词轰炸,它们其实各管一件事,分清楚就不慌了:

是什么一句话记住
batch size 一次拿多少个样本来估梯度、更新一次参数 “一口吃多少”
step / iteration 参数被更新了一次,就是一步 “更新了几次”
epoch 整个训练集完整过一遍 “全套题刷了几遍”

三者的关系可以用一个除法串起来。假设有 10000 个训练样本、batch size 取 100:

每个 epoch 的步数 = 样本总数 ÷ batch size = 10000 ÷ 100 = 100 步 跑 5 个 epoch,就一共更新了 5 × 100 = 500 步

所以“训练 5 个 epoch”和“训练 500 步”在这个设定下是同一件事,只是一个按“刷了几遍数据”数、一个按“更新了几次”数。 学习率调度通常就是按 step 来推进的。

6. batch size 该取多大:一个现实的权衡

既然一次可以吃 1 个,也可以吃几千个,那到底取多大?这又是一个没有免费午餐的权衡:

batch size 变了,学习率通常也要跟着变

batch 越大,梯度越稳,就扛得住更大的学习率。一条常见的经验法则是“线性缩放”: batch 翻倍,学习率也大致翻倍。所以调参时别把这两个旋钮完全分开看——它们是一对的。

7. 逐行读懂“参数更新”

真正执行 wwη·梯度 的,是优化器。最朴素的 SGD 优化器只有几行:

src/deeplearning/optimizer/sgd_optimizer.cpp · CalcChangeValue
double g = delta;                                  1
if (weight_pos != -1 && weight_decay_ != 0.0)
  g += weight_decay_ * param_value;                2
return learning_rate * g;                          3
  1. delta 就是这个参数的梯度(由反向传播算出)。
  2. (可选)weight decay / L2 正则:把权重稍微往 0 拉一点,抑制过拟合;注意只对权重做,偏置不动(weight_pos != -1 才进来)。第 10 章细讲。
  3. 返回“这一步要改变的量” = 学习率 × 梯度。这正是更新公式里的 η·梯度。

而把这个改变量真正从参数里减掉的,是 ApplyGradient:

src/deeplearning/neural_network.cpp · ApplyGradient(精简)
optimizer_function_->BeforeStep();                  1

double avg_dw = g_row[i] * inv_bs;                  2
double w_change = optimizer_function_->CalcChangeValue(
    avg_dw, learning_rate_, {l, o}, i, w_row[i]);
w_row[i] -= w_change;                               3
  1. 先通知优化器“新的一步开始了”。对 SGD 这是空操作;但对 Adam 这类优化器,它会在这里推进内部的计步器(第 8 章)。
  2. 把累加的梯度乘以 1/B,得到这一批的平均梯度
  3. 算出改变量,再从参数里减掉它——这一行,就是 wwη·梯度 的代码版本。
梯度下降不保证找到“全局最优”

真实的损失地形坑坑洼洼,梯度下降可能停在某个“局部谷底”而不是最深的那个。 但好消息是:在深度学习里,绝大多数局部谷底的效果都已经足够好, 而且后面第 8 章的动量Adam 等技巧,能帮我们更稳地走下去。

动手玩:拖动学习率 η,点“走一步 / 自动跑”,看小球怎么滑向谷底。把 η 调到很大(如 > 10),它会反复横跳直到发散——这就是“学习率太大”的样子。

小结

  • 训练就像蒙眼下山:每步朝“最陡下坡”走一小步,逼近损失谷底。
  • 梯度 = 损失对参数的导数,指向“上升最快”的方向;朝它的反方向走,损失下降。
  • 核心更新规则:wwη·梯度,对每个参数都做一遍。
  • 学习率 η 是步长:太小慢、太大震荡发散、刚好又快又稳。
  • 实践用 mini-batch SGD:每次随机抓一小批样本估算梯度并更新一次。
  • batch 是一口吃多少、step 是更新几次、epoch 是全套数据刷几遍;三者用“样本数 ÷ batch”串起来。
  • batch size 是“稳(大)vs 快而抖(小)”的权衡,常取 32/64/128;它一变,学习率通常要跟着线性缩放。

动手与思考

问题 1:某个参数的梯度是 +3,学习率 0.1,这个参数应该怎么变?

wwη·梯度 = w − 0.1×3 = w − 0.3,也就是减小 0.3。因为梯度为正说明增大它会让损失上升,所以要往反方向减。

问题 2:训练时损失剧烈上下震荡、甚至变成 NaN,最可能是哪个旋钮出了问题?

学习率太大。步子迈得太猛,一次次越过谷底冲到更高处,导致损失不降反增甚至发散。先把学习率调小试试。

问题 3:为什么用“一小批”样本而不是每次都用全部数据?

全量数据每走一步都要算很久,太慢。小批量用少量样本就能得到对梯度的合理估计,更新更频繁、训练更快,还能借助随机性帮助跳出一些差的局部谷底。

我们一直说“反向传播会算出梯度”,但它到底怎么算?下一章就来揭开这个让无数初学者头疼、 其实只是“链式法则 + 一点耐心”的过程。