梯度下降这四个字,在机器学习领域几乎天天见。刷课程、读博客,公式都能背下来,但真正动手调参时,loss 曲线不降反升、反复震荡、卡在平台期,这些情况碰到过一遍才知道理论离实践有多远。最近我把那门经典的机器学习公开课特训营翻出来重看了一遍,第三讲整期都围绕梯度下降展开,等于把“最小化损失函数”这件事从头拆了一遍。我把这一讲里对我最有启发的内容,结合自己的实验记录整理成文,给正在学机器学习的你一个落地参考。
这一讲适合谁?计划入坑机器学习、刚学完线性回归和损失函数、准备自己写训练循环的朋友;也适合工作里已经在用深度学习框架训练模型,但对底层参数更新机制一知半解的开发者。它不解决“怎么把模型跑起来”的问题,而是要解决“为什么参数会往这个方向更新”以及“怎么让它更新得更稳、更快”的问题。下面这些内容没有跳过数学推导,但也不用你有多深的微积分功底,能看懂链式法则就行。
1. 为什么这一讲要专门把梯度下降拎出来讲
1.1 先搞懂它要解决什么问题
机器学习说白了,就是让模型从数据里学到规律,再把规律映射到新输入上。以最朴素的线性模型为例,模型是 y = wx + b,w 和 b 就是参数。给定一批数据(x, y),我们希望模型的预测值跟真实值尽可能接近,于是定义一个损失函数 L(w, b),最常见的是均方误差:L = (1/n) Σ (y_i - (w x_i + b))²。
接下来问题就来了:w 和 b 应该取什么值,L 才能最小?
如果你只学过线性代数,第一时间想到的可能是解析解,也就是正规方程那一套。但正则方程要求矩阵求逆,遇到特征数量大、样本量大的情况,计算开销会迅速膨胀,而且它在深层神经网络这种动辄几百万参数的非线性模型上根本不存在闭式解。于是就需要一种“不追求一步到位,但能一步步逼近最优解”的方法,这就是迭代法。梯度下降正是迭代法里面最基础、最通用、几乎一切现代优化算法之母的方法。
梯度本身的概念很容易理解:它是一个向量,指向的是函数值上升最快的方向。我想要让损失变小,就沿着梯度的反方向跨一步,这就是“梯度下降”四个字的由来。每次参数更新可以写成:
w_new = w_old - lr · ∂L/∂w
这里 lr 是学习率,也就是步长;∂L/∂w 是损失函数对 w 的偏导数。整个训练过程,就是不停计算梯度、不停做这个减法。
很多新手会把梯度下降和“自动求导”“反向传播”这三个词混在一起。其实它们是不同层次的东西:梯度下降是优化策略,反向传播是计算梯度的具体方式,自动微分是框架实现的底层技术。这一讲的授课逻辑很清楚,先讲清楚目标是“让 L 变小”,再讲怎么用梯度找方向,最后再引出梯度到底怎么算——也就是反向传播的内容。所以它不是孤立的一课,而是整个训练体系的承重墙。
1.2 为什么这一讲在整个课程里承上启下
特训营的进度安排通常是:先讲回归、分类这些基础场景,然后定义损失函数,接着就进入梯度下降,再之后是逻辑回归、神经网络、反向传播。这条线的逻辑相当自然:没有可微的模型,就谈不上计算梯度;没有梯度这个概念,后面神经网络里层叠的复合函数求导也无法展开。
我当时最大的感触是,很多人学完线性回归就直接跳到神经网络,以为梯度下降无非是“照着公式迭代”。但真到调参时,loss 一直不降,或者刚降几个 epoch 就炸成 NaN,这时候再回头看梯度下降,才知道自己当初漏掉了多少细节。特训营里授课老师反复说的一句话我印象很深:优化器只是工具,真正决定训练走向的是学习率和梯度本身这两个最朴素的东西。
从工程的角度看,现在的主流深度学习框架里,一行optimizer.step()就把参数更新做完了,看起来很方便,但也容易让初学者把整个训练过程黑盒化。如果你一点都不理解梯度下降在干什么,遇到训练不收敛的时候,你连从哪里开始排查看起都不知道。反过来,把这节课吃透了,哪怕后面换再复杂的模型、再花哨的优化器,你都能把它拆解成“算梯度、迈步子”两个动作。
2. 训练营里反复强调的三个梯度下降细节
2.1 学习率这个步长,到底怎么定
学习率是整个梯度下降里最敏感、也最需要手动干预的超参数。我自己在实验里最常见的问题,不是模型结构不对,而是学习率没选好。
先说现象。学习率太大,loss 曲线像过山车,剧烈震荡甚至直接冲成 NaN;学习率太小,loss 曲线则像蜗牛爬,训练了几百个 epoch 还没什么变化。原因不难理解:损失函数在高维空间里就像一个起伏的地形,你每步跨得太远,就可能跳过最低点,在谷壁之间来回弹跳;每步跨得太小,只能贴着山坡慢慢挪,训练时间成倍增加。
一个比较有用的经验法则是:从一个相对大的值开始,比如 0.01,画出 loss 曲线观察。如果曲线在抖动中整体下降,说明步长差不多,只是略大;如果曲线直接发散,就把学习率除以 10 再试;如果曲线稳步下降但很慢,可以考虑增大学习率或者用学习率衰减。
| 学习率状态 | 现象 | 推荐动作 |
|---|---|---|
| 过大 | loss 震荡剧烈、可能出现 NaN | 除以 10 或 100 重试 |
| 适中 | loss 平滑下降,收敛速度快 | 可以继续训练至平台期 |
| 过小 | loss 下降极慢,长时间不逼近 | 乘 10 试一下,或加动量 |
训练营里还提到一种进阶策略叫学习率预热,就是训练刚开始的一小段时间内,学习率从一个很小的值逐渐升到目标值。原因在于训练初期梯度统计不稳定,如果一上来就用大步长,参数可能被推到一片很差的区域,后面再想回来就难了。这在 CV、NLP 大模型里几乎成了标配,但这节课讲预热的目的不是让你急着用,而是让你理解:步长不是一个固定常数,它应该随着训练状态而改变。
2.2 一次用多少样本算梯度,是门学问
梯度下降的三种形态,特训营讲得很清楚,这也是面试和日常开发中最容易被问到的点。
批量梯度下降,也就是全量梯度下降,每次用所有训练样本计算一个平均梯度,方向最准确,但计算量太大,数据一大内存就扛不住。随机梯度下降则反过来,每看到一个样本就更新一次参数,计算量小但梯度噪声极大,收敛路径往往崎岖不平。介于两者之间的小批量梯度下降,也就是 Mini-batch 梯度下降,随机取一小批样本算梯度,兼顾了稳定性、计算效率和内存开销,是实际上用得最多的方案。
| 形态 | 样本量 | 梯度噪声 | 收敛稳定性 | 工程实用性 |
|---|---|---|---|---|
| BGD | 全部样本 | 低 | 高 | 数据量大时不实用 |
| SGD | 单个样本 | 高 | 低 | 一般不直接用 |
| Mini-batch | 小批数据 | 中 | 中 | 实际训练默认选择 |
在特训营的作业里,框架训练接口里通常会有一个batch_size参数,很多人随手填 32 或 64,但不太清楚它为什么重要。batch size 太小,梯度方向噪声大,loss 曲线跳来跳去;batch size 太大,每次迭代方向过于稳定,反而可能收敛到一个平坦但泛化性能差的点。我自己的经验是,在显存允许的范围内,先试 32、64、128 三档,看哪个曲线最平稳、收敛速度最快,再根据项目的实际需求做取舍。
另外有个容易忽略的坑:如果你用了 Batch Normalization 这类依赖批量统计量的结构,batch size 太小会让统计量不稳定,训练和推理结果差异变大。所以 batch size 的选择不止影响梯度下降本身,还会跟模型结构耦合在一起。
2.3 特征缩放为什么会影响收敛速度
这个问题课堂上提了一句,但我当时没在意,后来在数据竞赛里栽过跟头才彻底明白。
损失函数曲面的形状,跟输入特征的尺度有直接关系。假设一个特征 x₁ 取值范围在 0 到 10,另一个特征 x₂ 取值范围在 0 到 10000,那么用均方误差计算出的损失等高线会是一个很扁的椭圆。梯度方向是和等高线垂直的,在椭圆的“腰”上,梯度方向会偏离椭圆的最短轴方向,导致参数更新轨迹走成锯齿状,绕很多弯路才能到谷底。
但如果把两个特征都标准化到 0 到 1 的范围,等高线就接近正圆,梯度方向直接指向圆心,收敛路径会明显变直。实践中做个简单对比,未归一化时可能 500 个 epoch 才收敛到可接受范围,归一化后 100 个 epoch 就够了,速度差距就是这么大。
这个现象虽然简单,却给了一个很重要的启发:梯度下降不会替你“理解”数据的尺度差异,凡是影响损失函数形状的因素——特征范围、异常值、共线性——都会直接影响优化难度。特训营里讲线性回归时怎么强调数据预处理,到后面讲神经网络时照样适用,而且越是深层模型,数据尺度问题越容易被放大。
3. 跟着特训营做一遍:从手写更新公式到框架训练
3.1 先用 numpy 手写一个完整更新循环
特训营有个很有意思的环节,就是让你先不用框架,用最朴素的 Python 写一个线性回归的梯度下降。我当时觉得这是“脱裤子放屁”,写完才发现,手推一遍能补上很多被框架隐藏的细节。
下面这段代码是我简化后的实验版本,逻辑完整,可以直接跑:
import numpy as np np.random.seed(42) x = np.random.rand(100, 1) * 10 true_w, true_b = 2.5, 1.0 y = true_w * x + true_b + 0.1 * np.random.randn(100, 1) w = np.random.randn(1, 1) b = np.zeros(1) lr = 0.01 epochs = 1000 n = len(x) for epoch in range(epochs): y_pred = x @ w + b loss = np.mean((y - y_pred) ** 2) dw = 2 * np.mean(x * (y_pred - y)) db = 2 * np.mean(y_pred - y) w -= lr * dw b -= lr * db if epoch % 100 == 0: print(f"epoch {epoch}, loss {loss:.4f}, w {w.item():.3f}, b {b.item():.3f}")这段代码只干四件事:计算预测值、计算损失、计算梯度、更新参数。很多人看到dw和db会问,为什么是2 * np.mean(x * (y_pred - y))。这是均方误差对 w 的偏导数:损失函数对 w 求导后得到 (2/n) Σ x_i·(pred_i - y_i)。你仔细看,这个梯度的大小跟样本的误差成正比,也跟 x 本身的大小成正比,所以当某个样本误差大或者 x 异常大时,它对梯度的贡献就大,模型更新时会更注意它。这其实解释了为什么特征必须做归一化:如果 x 的范围太大,梯度也会被放大,步长很可能一步迈飞。
手写循环最大的价值在于,你亲眼看到了梯度下降的每一步在做什么。到了框架训练里,loss.backward()帮你算梯度,optimizer.step()帮你更新参数,本质上跟这段手动更新循环没有任何区别,只是框架把计算图、链式求导、参数寄存都封装好了而已。
3.2 训练过程中看什么指标、怎么调
特训营实验课的第二个重要环节是可视化。只看终端里打印的 loss 数字,很难判断训练是不是真的健康,所以我强烈建议把每轮 loss 记录下来画成曲线。
我常用的代码是一段很短的记录逻辑:
losses = [] for epoch in range(epochs): y_pred = x @ w + b loss = np.mean((y - y_pred) ** 2) losses.append(loss) # ... 更新参数然后直接用plot(losses)就能看到收敛过程。下面几种典型的曲线形态,几乎可以当成调试诊断图来用:
- 曲线一直下降最后变平,说明模型在正常收敛;
- 曲线下降后开始反复震荡,说明学习率偏高或者 batch size 太小;
- 曲线先降后升最后变成 NaN,通常是学习率太大导致梯度爆炸;
- 曲线几乎平整,可能是学习率太小、模型容量不够,或者已经陷在鞍点区域。
除了 loss,我还额外记录一个指标:梯度范数。梯度范数就是所有参数梯度的平方和再开根号,它反映的是“当前梯度有多大”。如果梯度范数突然暴涨到几百几千,说明梯度爆炸了;如果训练还没收敛梯度范数就掉到接近零,则可能是梯度消失或者陷入鞍点。这两个现象的处理方向完全不同,提前记录能帮你少走很多弯路。
特训营里还演示了一个很直观的小实验:在二维权重平面上画出参数更新的轨迹,配上损失函数的等高线。你会清楚地看到,如果学习率太大,参数轨迹会在等高线两侧来回跳,像喝了酒一样走“之”字形;学习率合适时,轨迹才会近乎笔直地指向谷底。这个可视化建议每个人都亲手做一次,做完之后很多调参的直觉就建立了。
4. 梯度下降最容易踩的坑,我帮你们提前踩完了
4.1 损失震荡、不收敛怎么排查
我自己在刚跑模型时,遇到 loss 不下降第一反应是改网络结构,后来发现超过一半情况只是优化相关的问题。把排查思路整理成表格可能更直观:
| 现象 | 可能的根因 | 推荐排查顺序 |
|---|---|---|
| loss 直接变成 NaN | 学习率过大、数据尺度异常 | 先降低学习率 10 倍,再检查有无无穷值 |
| loss 震荡下降 | 学习率偏高、batch 偏小 | 降低学习率或调大 batch size |
| loss 几乎不动 | 学习率太小、卡在鞍点 | 调大学习率,或换带动量的优化器 |
| loss 持续不降 | 特征未归一化、损失函数定义错误 | 先做归一化,再检查标签和预测值是否配对 |
排查的时候有个原则:一次只动一个变量。比如先固定 batch size,把学习率从 0.01 改成 0.001,看曲线有没有变化。如果你同时改好几个超参数,模型不收敛时根本说不清是谁的锅。
同时要养成固定随机种子的习惯。神经网络初始化、mini-batch 选取都有随机性,不固定种子的话,同样的代码跑两遍 loss 曲线可能差异很大,干扰判断。我在实验开始前都会加一句np.random.seed(42)或对应框架的种子设置,虽然不起眼,但能省下大量疑惑时间。
4.2 卡在局部极小和鞍点,问题比想象中复杂
面试里常被问到“梯度下降会陷入局部极小值怎么办”,但真到高维场景,最常让模型训练停滞的其实是鞍点,而不是我们直觉里那些大坑。鞍点的特点是某个方向梯度为正值、另一个方向为负值,看起来是个“平坡”,梯度很小,优化过程很容易在这里磨蹭很久。
以前我会觉得换个大学习率就能冲出鞍点,后来发现效果不稳定。真正有效的方法之一是动量法:让更新方向不只依赖当前梯度,还累积历史梯度的移动平均。它的物理直觉是,如果这步梯度小,但之前几步有一个恒定的“冲量”,参数仍然能顺势穿过平坦区域,不至于彻底停住。
我在实验里做过一个小对照:随机初始化一个二维函数,函数中心有个明显的局部最优凹陷,但周围有个更大的全局区域。用普通 SGD 训练,loss 降到一定水平就几乎不走;换成带 momentum 的 SGD,很快就翻过了那个小凹陷,收敛到更低的位置。这个实验让我彻底明白,优化器不是越“聪明”越好,而是看它能不能匹配当前损失曲面的形状。
4.3 从 SGD 到 Momentum、Adam,不是越高级越好
很多初学者一写训练代码就直接用 Adam,说它是“默认优化器”,这话有道理但也不全对。Adam 的优势在于自适应学习率,它会为每个参数分别估计梯度的一阶矩和二阶矩,从而让大步长、小步长都能被自动调整。这对处理稀疏梯度、参数量巨大的模型特别友好,也是它能成为默认选项的原因。
| 优化器 | 核心机制 | 优点 | 已知短板 |
|---|---|---|---|
| SGD | 直接沿负梯度更新 | 简单、稳定、泛化常更好 | 收敛慢、易震荡 |
| Momentum | 累积历史梯度 | 加速收敛、越过鞍点 | 多一个 momentum 超参 |
| Adam | 自适应一阶二阶矩 | 调参省心、适应稀疏场景 | 最终精度有时不如调好的 SGD |
特训营里提到的一个经验我后来在项目里也验证过:先用 Adam 快速把模型训到差不多能用的状态,看看数据有没有问题、模型容量够不够,之后再切到带动量的 SGD 来做最后一段训练,经常能把精度再往上顶一档。Adam 不是万能药,它擅长快速找到一个“能用的区域”,但某些情况下最终收敛到的最优点不如精心调过学习率和动量的 SGD 那么锐利。
我的实际做法是,每个新任务先跑一版 Adam 基线,记录训练速度和验证集指标,然后再花点时间试 SGD with Momentum。对比之后如果 SGD 明显更优,就保留;如果差距不大,就继续用 Adam 省心。这个流程不复杂,但能避免“只会用 Adam、不会调 SGD”的能力短板。
最后聊一点个人体会。我在新任务里习惯性地第一反应是“直接上 Adam”,但见过几个项目之后发现,如果不懂底层的 lr、momentum、batch size 怎么互相作用,换个数据分布照样卡住。特训营第三讲给我的最大收获,不是多背了几个公式,而是把所有训练现象都回溯到“往负梯度方向走一步”这个朴素动作上:loss 不动就调整步长,loss 发散就缩小步长,loss 锯齿就降低噪声,思路清楚之后,调参不再靠玄学。后续如果继续往下学,你会发现 Batch Normalization、学习率调度、二阶近似优化方法,全都在这节课的延长线上。先把这一步踩稳,后面路会好走很多。