做机器学习的人,大部分时间其实都在跟梯度下降打交道。你可能早就听过这样的对话:模型效果不好?调一下学习率;loss不下降了?换一个优化器;训练崩了?多半是学习率太大。这些回答的底层都是同一个算法——梯度下降。我在一次公开训练营里把梯度下降从头到尾重新系统学了一遍,再回头看网上那些调参玄学,很多都有了明确解释。这篇文章就是整理后的完整笔记,适合刚入门机器学习、或者训练过几个模型但总觉得调参靠感觉的人。全篇不依赖某个具体框架,核心概念、必要的公式推导、可运行的示例代码都在里面。读完你至少能独立回答三个问题:梯度下降到底在优化什么、学习率为什么是命门、训练卡住的时候该怎么办。
1. 为什么说梯度下降是机器学习的真正主角
1.1 先讲一个调参的夜晚
某天晚上朋友找我调模型,网络结构看起来没问题,数据预处理也按常规做了标准化,但训练 loss 卡在 0.6 左右纹丝不动。我们试了更深的网络,试了不同的激活函数,都没用。最后我做了一个很小但关键的改动:把学习率从 0.01 降到 0.003,再加了三个 epoch 的预热,loss 肉眼可见地往下走了。朋友问:"所以问题出在网络结构上?"我说不是,从你按下训练按钮那一刻起,真正在干活的其实一直是梯度下降。
这个案例想说明的道理很简单:在一个神经网络的训练过程里,前向传播负责算出当前预测和真实标签之间的损失,反向传播负责把每个参数对这个损失的"敏感度"算出来,也就是梯度,而优化器做的事情就是梯度下降——沿着负梯度方向微微调整参数,让损失变小。网络结构决定了你能拟合出什么形状的函数,但真正决定你能不能找到那个损失足够小的点、用多快的速度找到它,是梯度下降。理解了这一点,你就明白为什么很多老练的工程师不急着换网络,而是先调优化相关的东西。
1.2 山顶下雾:梯度下降的直觉模型
想象你被放在一座大山的半腰,四周全是雾,看不见整条下山的路。你唯一能感知的是脚下地面的坡度。请问你怎么下山?最合理的策略是:先感受哪个方向是最陡的下坡方向,朝那里迈一步;到了新位置再感受一次,再迈一步。一步一步走下去,大概率能到山脚。梯度下降就是这样工作的——参数是你的位置,损失函数是地形,梯度是"最陡上升方向"的指示牌,我们偏偏要反着走,所以叫负梯度方向。
数学上,假设损失函数是 L(θ),当前参数是 θ。梯度 ∇L(θ) 是一个向量,告诉你:在这个点,如果把每个参数稍微调大一点点,损失会以多快的速度变大。为了让损失变小,我们朝相反方向走:
θ ← θ - η∇L(θ)
这里的 η 就是学习率,也就是步长。每次迭代,我们其实是在用当前位置的一阶信息(斜率)去猜测远处的路况,然后走出一步。它是很"贪婪"的算法,只看脚下,不看全局。这也是为什么它需要很多步、也容易被地形坑——这些都是后面几节要展开的话题。
1.3 参数、损失和梯度到底怎么算出来的
很多人背下了更新公式,却说不清每一项是怎么来的。用最简单的例子:一元线性回归。预测值是 ŷ = wx + b,损失函数用均方误差:
L(w, b) = (1/N) Σ (y_i - wx_i - b)²
对 w 求偏导得到 ∂L/∂w = -(2/N) Σ x_i (y_i - wx_i - b),对 b 求偏导得到 ∂L/∂b = -(2/N) Σ (y_i - wx_i - b)。这两个偏导合在一起就是梯度向量,指向损失上升最快的方向。更新时用负号,就是在往损失下降的方向走。
这个例子虽然简单,但能说明一个通用事实:梯度其实是"损失函数对每一个参数的敏感度"的完整清单。某个参数梯度大,说明当前状态下动它一点,损失变化就很大;梯度小,说明它附近地形平坦,动它半天也没反应。神经网络里成千上万个参数,训练时算出来的梯度就是一个同样巨大的向量,反向传播算法的作用就是高效算出这个向量。注意,梯度下降本身并不关心网络长什么样,它只拿到梯度向量,然后更新参数。这也是为什么我觉得理解梯度下降,比理解某个具体网络结构更根本。
2. 学习率:最不该随意拍脑袋的超参数
2.1 步长过大过小分别会发生什么
学习率是梯度下降唯一一个直接控制"步长"的旋钮,决定你每走一步会改变多少参数。学习率过大,你可能一步跨出好远,直接跳过山谷跑到对面山坡上,甚至越跳越远,最后 loss 变成 NaN,训练彻底崩掉。学习率过小,理论上一定能收敛,只是每一步都像原地踏步,需要成千上万个 epoch 才走得到,训练时间被无限拉长。
给一个具体的数学直觉。假设损失函数是 f(x) = x²,初始点在 x = 1,梯度是 2。按更新规则 x ← x - η·2x,每一步之后,x 变成原来的 (1 - 2η) 倍。要想收敛,必须满足 |1 - 2η| < 1,也就是 η < 1。当 η = 1.1 时,x 的绝对值每步乘以 1.2,直接发散。这个一维例子看着简单,但它解释了所有你见过的"loss 飞了"的现象:梯度下降每一步都在用当前位置的线性近似去估计远处,步长一大,超出当前线性近似的可信范围,误差被自己放大,连锁反应就崩了。
2.2 固定学习率不够用:为什么每个参数需要自己的步长
现实里的损失函数不是 x² 这种圆圆的碗,而是歪歪扭扭的山谷。有的方向很陡,有的方向很平。用一个固定学习率,对陡的方向来说可能太大,对平的方向来说又太小。于是工业界和学术界都转向了自适应学习率:给每个参数单独配一个步长。
最早的做法叫 AdaGrad,把历史上所有梯度的平方累加起来,用它去除以当前学习率。效果是:经常出现大梯度的参数,它的步长自动被压低;梯度一直很小的参数,步长自动被放大。但 AdaGrad 有个毛病,累加值只增不减,训练后期几乎所有参数的学习率都被压到接近 0,就学不动了。后来的 RMSProp 改成用滑动平均估计梯度的二阶矩,解决了累加无上限的问题。再后来的 Adam 同时估计一阶矩和二阶矩,还加了偏差校正,处理训练早期估计不准的问题,于是成了这些年最常用的优化器。默认的 η=0.001、β1=0.9、β2=0.999 在大部分任务上都能跑出不错的结果。
虽然我现在大多数情况直接上 Adam,但我还是建议至少手动把 RMSProp 的思想实现一遍,或者对比一下 AdaGrad 和 RMSProp 在同一个问题上的表现差异。理解了"为什么不同参数需要不同步长",才算真正理解学习率,而不是停留在"记住用 Adam"的层面。
2.3 一个能复现的小实验:学习率如何改变结局
为了直观,我写了一段不依赖任何第三方库的小代码,在 f(x) = x² 上跑梯度下降,对比几个学习率:
def gradient_descent_1d(lr, epochs=20, start=1.0): x = start curve = [] for _ in range(epochs): grad = 2 * x # f(x) = x^2 的梯度 x = x - lr * grad # 梯度下降更新 curve.append(x) return curve for lr in [0.1, 0.5, 0.9, 1.1, 1.5]: curve = gradient_descent_1d(lr) print("lr =", lr, "最后的 x =", round(curve[-1], 6))输出会很直观:lr=0.1 时 x 慢慢趋近 0;lr=0.5 时几步就非常接近 0;lr=0.9 时 x 在 0 附近来回震荡,幅度越来越小;lr=1.1 时 x 的绝对值越来越大,直接发散;lr=1.5 时发散得更快。一个简单实验,把"小步慢走""震荡收敛""原地爆炸"三种典型曲线全看到了。理解了它,再去看真实训练日志里的 loss 曲线,很多现象就不奇怪了。
3. 全量、随机与小批量:三种梯度下降形态怎么选
3.1 计算量与噪声的本质差异
理论上,梯度下降里的梯度应该是在整个数据集上计算的期望,也就是把所有样本的梯度求平均。但大数据集没法一次全算,于是有了三种近似:
- 全量梯度下降(Batch GD):把全部样本的梯度求平均,方向最准,但一次要算完全部数据,慢且吃内存。
- 随机梯度下降(SGD):每次只拿一个样本的梯度当近似,计算便宜,但单个样本的方向和真实方向偏差大,曲线噪声很大。
- 小批量梯度下降(Mini-batch GD):折中方案。每次取一小批样本,比如 32、64 个,求平均梯度。误差可控、计算量合理,是实际训练中的绝对主流。
关键在于理解"噪声"并不完全是坏事。单个样本的梯度里带着随机扰动,这种扰动有时候反而能把参数从损失函数的浅坑里"晃"出来。而且小批量梯度是真实梯度的无偏估计,只要 batch 不是太小,收敛方向大致不会偏。我经常跟人说,SGD 的噪声不是缺陷,是正则化的一个来源。
3.2 batch size 到底怎么定
我给出自己常用的决策顺序。第一步看显存能承受多大:模型参数、激活值、中间变量都占显存,batch size 直接决定单次前向传播需要的空间。第二步在能承受的范围内选 32、64、128 这类 2 的幂,因为很多底层实现是按 2 的幂做并行优化的。第三步,如果你增大了 batch size,通常要同步调大学习率——因为梯度估计更稳定了,可以放心走更大的步子,这个经验法则叫线性缩放规则。反过来,把 batch 从 128 减到 16,学习率还维持原来的值,训练就容易震荡。
还有一个很容易忽略的点:如果网络里用了 BatchNorm,batch size 不宜太小。BatchNorm 是用一个 batch 的统计量去做归一化的,batch 太小,统计量抖动太大,训练可能不稳定。我见过有人在 batch=2 上跑带 BatchNorm 的分类模型,loss 怎么调都降不下来,最后把 batch 提到 16 就好了。这属于那种"看起来像模型问题,其实是配置问题"的经典案例。
3.3 训练曲线上的噪声怎么读
很多人第一次跑小批量训练,看到 loss 曲线像心电图一样抖动就慌了,各种拍脑袋调参。其实这是正常的:每个 mini-batch 只是个近似,不同 batch 的损失天然有高有低。读曲线我有三招:
- 看趋势,不看单点:把最近十几个 loss 的平均值画一条平滑线,只要平滑线在下降,训练就没问题。
- 训练 loss 和验证 loss 分开看:训练 loss 噪声大很正常,验证集上通常用全量数据评估,噪声小很多,更适合用来做决策。
- 用震荡幅度判断稳定性:如果震荡在一个下降的大趋势里逐渐收窄,说明学习率偏大但还在收敛;如果震荡幅度越来越大,说明学习率过大,该降了。
这些经验我在很多次实验里反复验证过,很管用。别一看到尖峰就急着降学习率,先画个平滑曲线再说。
4. 局部极小值、鞍点和高原:收敛路上的三个陷阱
4.1 低维直觉在高维会骗人
教科书里常画二维等高线图,有山谷、有山丘、有局部极小值,于是大家自然以为深度学习的主要难点是"掉进局部极小值出不来"。但神经网络的参数空间可能有几百万维,这时候人的低维直觉经常失效。
在高维空间里,一个点是局部极小值,需要损失函数在每一个维度方向上都往上翘,也就是说,微分几何里那个描述曲率的二阶导数矩阵,在这个点上所有方向的特征值都得为正。如果每个方向"刚好是凹"的概率接近 1/2,那同时满足几百万个方向的概率就是 (1/2) 的几百万次方,几乎为零。结论很反直觉:真正的高维网络训练里,纯粹的局部极小值其实很少见,你撞上的大概率是别的东西。
4.2 鞍点和平原才是更常见的卡点
鞍点像一个马鞍:有的方向是凸的,下坡路就在眼前;有的方向是凹的,一过去就是上坡。鞍点处梯度为零或接近零,标准的梯度下降会以为"到底了",实际却哪儿也不是。更麻烦的是,高维空间里鞍点周围往往有大片"平原"——所有方向的梯度都非常小。这时参数早该继续更新,但梯度小得几乎推不动,loss 曲线表现为长时间纹丝不动,非常像"卡住了"。
遇到这种卡点,常见的解法有几类:一是靠随机性,小批量梯度带来的噪声本身就是一种扰动,能帮参数从鞍点附近"震出去";二是用动量,靠历史速度冲过平原;三是换个参数初始化,或者重新打乱数据顺序,给当前位置一点扰动。所以我的建议是:别一看到 loss 不动就断言"这是掉进了局部最优",多数时候是鞍点或平原,应对策略完全不同。
4.3 动量:让小球带惯性滚下去
动量法有一个非常直观的类比:标准梯度下降像一个老人,每走一步都停下来重新感受脚下,再迈一小步;动量版本像一个从山上滚下的小球,滚起来之后有惯性,遇到小坑小坎不会立刻停,而是靠着冲劲滚过去。数学上,它引入一个速度变量 v:
v ← βv + η∇L(θ);θ ← θ - v
这里 β 一般取 0.9,表示保留上一部分速度。如果连续几步梯度方向一致,速度会越来越大,相当于在平坦区域加速;如果梯度方向来回变(比如在狭窄山谷里震荡),速度会被抵消,自动刹车。这两个效果分别解决了"平原走得慢"和"峡谷来回震荡"两大痛点。
动量法还有一种改良版本,先按当前速度"预演"一步,在预演位置计算梯度,再修正速度。它在理论上收敛更快,实现起来也只多一两行。我在实际训练里,常常把带动量的 SGD 当成比 Adam 更朴素但可靠的基线。不少项目里,它的泛化结果反而比 Adam 好,等调不动了再切 Adam 比较合适。
5. 从公式到手写代码:一次完整的梯度下降实现
5.1 为什么拿线性回归来做验证
线性回归是最适合练手梯度下降的模型,因为它有解析解:一步到位算出最优的 w* = (XᵀX)⁻¹Xᵀy。你可以拿解析解当"标准答案",检查迭代结果有没有算对。对线性回归来说,梯度下降显得有点笨拙,但一旦换到非线性模型,解析解不存在,梯度下降就成了唯一的常规路线。
所以学习路径应该是:先在线性回归上用自己的梯度下降代码验证正确性,再去用框架训练神经网络。如果线性回归上的结果和解析解对不上,说明梯度方向或更新逻辑写错了,这时候排查成本很低;直接上神经网络再排查,就难多了。
5.2 用纯 Python 写一个梯度下降
我故意不用第三方库,只用 Python 内置数据结构,这样每一步都看得清清楚楚:
def predict(X, w, b): # X 是样本列表,每个样本是一个特征列表;返回每个样本的预测值 return [sum(xi * wi for xi, wi in zip(x, w)) + b for x in X] def mse_loss(y_true, y_pred): n = len(y_true) return sum((yt - yp) ** 2 for yt, yp in zip(y_true, y_pred)) / n def compute_gradient(X, y, w, b): n = len(y) dw = [0.0] * len(w) db = 0.0 for x, yt in zip(X, y): pred = sum(xi * wi for xi, wi in zip(x, w)) + b err = yt - pred for i in range(len(w)): dw[i] += -2 * err * x[i] / n db += -2 * err / n return dw, db def gradient_descent(X, y, lr=0.01, epochs=200): w = [0.0] * len(X[0]) b = 0.0 loss_history = [] for _ in range(epochs): preds = predict(X, w, b) loss_history.append(mse_loss(y, preds)) dw, db = compute_gradient(X, y, w, b) w = [wi - lr * dwi for wi, dwi in zip(w, dw)] b = b - lr * db return w, b, loss_history这段代码把上一节的公式逐行翻译成了程序:predict 对应 ŷ,compute_gradient 对应那两个偏导公式,更新那两行对应 θ ← θ - η∇L。只要 loss_history 在稳定下降,最终参数接近解析解,就说明梯度方向算对了。我在带人入门的时候,最喜欢让对方先在这段代码上跑通,再去看框架。因为框架的自动求导会掩盖很多细节,从零实现一遍,那些细节才会真正进入肌肉记忆。
5.3 在深度学习框架里,训练循环其实就是那几步
把上面的线性回归换成任意神经网络,训练循环的结构不变:
model = build_model() # 搭建网络 optimizer = create_optimizer(model.parameters(), lr=0.01, momentum=0.9) criterion = create_loss('mse') # 损失函数 for epoch in range(epochs): model.train() for x_batch, y_batch in dataloader: optimizer.zero_grad() # 清零上一次的梯度 pred = model(x_batch) # 前向:算出预测 loss = criterion(pred, y_batch) # 算损失 loss.backward() # 反向:自动求梯度 optimizer.step() # 梯度下降:更新参数为什么必须有 optimizer.zero_grad()?因为很多框架默认梯度是"累加"的,不清零的话,下一个 batch 的梯度会叠加到上一个 batch 上,方向就完全错了。这是我见过新手报错最多的原因之一。backward() 里发生的本质上是链式法则的自动执行,它算出每个参数的 ∂L/∂θ;step() 则是你刚刚手写实现过的那个更新:θ -= lr * 梯度。框架把"算梯度"自动化了,但"怎么更新"仍然还是那个老朋友——梯度下降。
5.4 一组对比实验:学习率 × batch size × 动量
我用一个人工生成的数据集跑了一组对照实验,数据是 y = 2x + 1 加少量噪声,训练 200 轮,记录最终 loss:
| 配置 | 学习率 | batch size | 动量 | 最终 loss | 现象 |
|---|---|---|---|---|---|
| A | 0.01 | 全量 | 无 | 0.008 | 正常收敛 |
| B | 0.1 | 全量 | 无 | 0.0004 | 更快更准 |
| C | 0.5 | 全量 | 无 | 3.2 | 在最优值附近剧烈震荡 |
| D | 0.1 | 16 | 无 | 0.0012 | 收敛但曲线毛刺多 |
| E | 0.1 | 16 | 0.9 | 0.0006 | 毛刺少,收敛更稳 |
| F | 0.001 | 16 | 0.9 | 0.045 | 学习率太小,200轮不够 |
最有信息量的是 B 和 C 的对比:同一个模型、同一批数据,学习率从 0.1 换成 0.5,结果就从"全场最佳"变成"完全没法用"。D 和 E 的对比则说明,同样的学习率和 batch size,加上动量后收敛又快又稳。F 提醒大家:如果训练日志里 loss 一直在缓慢线性下降,很可能不是模型不行,只是学习率太小了。
6. 训练中的实操心法:调参顺序与习惯
6.1 第一件事:先让模型过拟合一小批数据
我调任何模型的第一件事永远一样:拿一小批数据,比如 32 个样本,把学习率设大一点,训练几十轮,看训练 loss 能不能降到接近 0。如果连一小批数据都过拟合不了,那问题几乎可以肯定是代码写错了——梯度方向算错了、数据预处理错了、损失函数和标签不匹配。如果它能过拟合,说明模型和梯度链路没问题,接下来才轮到调学习率、batch size、正则化这些超参数。
这一步能帮你把"代码 bug"和"超参数不合适"彻底分开。我在无数次调试里发现,90% 的"模型不收敛"最终都落到代码 bug、数据泄漏或者预处理不一致上,真正需要靠玄学调参来拯救的情况少之又少。
6.2 学习率衰减:从大步探索到小步收敛
固定学习率训练到后期,经常出现 loss 在某个值附近抖动。原因不是模型到极限了,而是步长太大,在最优值附近来回跳动却迈不进去。解法是让学习率随时间下降:前期大一点,快速探索;后期小一点,精修参数。
我常用的三种策略:
- 阶梯衰减:每过固定轮数,学习率乘一个衰减系数,比如每 30 轮乘 0.1。简单直接,适合快速迭代。
- 余弦退火:学习率按余弦曲线从初始值平滑降到接近 0,省心效果好,是我现在默认的选择。
- 预热加衰减:前几个 epoch 学习率从小值线性爬到目标值,再开始衰减,主要用来避免训练一开始因为步子太大导致发散。
选哪个策略不重要,重要的是"必须衰减"。很多模型效果上不去,真不是结构差,而是一直在大步长下原地抖,没有用小步长去踩那些细小的坑。
6.3 特征归一化:先做归一化,再谈调参
梯度下降对特征尺度非常敏感。如果特征 x1 取值范围是 0 到 1,x2 是 0 到 100000,损失函数的地形会被拉成一个又长又窄的峡谷:沿 x2 方向走一步,loss 变化巨大;沿 x1 方向走一步,几乎没反应。一个统一的学习率要么在 x2 方向过大导致震荡,要么在 x1 方向太小导致停滞。
最直接的解法是标准化:x ← (x - mean) / std,把所有特征拉到差不多同一个量级。神经网络内部常见的 BatchNorm,本质也是在做类似的事。我见过不少"调参调了一个月毫无进展"的案例,最后发现只是忘了做特征标准化。这句提醒听起来像废话,但它值得排在任何调参技巧前面。
6.4 几个踩过的坑和现在保留的习惯
最后分享几个我真实踩过的坑。
第一,训练过程中加载了一个中间 checkpoint 继续训练,却没有重新创建优化器,导致旧优化器里记录的动量缓冲和新模型参数对不上,loss 瞬间跳变,看起来像模型被污染了,其实只是优化器状态没重建。第二,训练集和验证集用了两套不一致的预处理管道,验证 loss 一直很高,我还以为是欠拟合,白白排查了一天。第三,小批量训练忘了在每个 epoch 前打乱数据,模型学到的是批量顺序里的伪规律,测试时一换数据顺序效果就崩。
现在我固定的习惯有三个:一是训练日志把所有关键信息都打出来,epoch、当前学习率、训练 loss、验证 loss,一个都不能少;二是脚本里固定随机种子,保证每个实验可复现,这样调参前后才能知道差异到底来自哪;三是每次只动一个超参数,改完立即记录,绝不同时改两个变量。机器学习训练里大部分被称作"玄学"的东西,在这些习惯面前都会慢慢变成可以解释的工程问题。梯度下降说到底就是一个更新规则,但它牵动的细节,比想象中多得多。