ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

揭开SGD的神秘面纱:随机梯度下降的“随机”到底指什么?

揭开SGD的神秘面纱:随机梯度下降的“随机”到底指什么? 开门见山问一句SGD 的“随机”到底随机在哪很多人的第一反应是“随机抽样本”再追问“那为什么随机抽样本反而比用全部样本效果好”答上来的人就少了一大半。我这些年面试算法岗简历上十个有九个写“熟悉 SGD”但能把随机梯度下降里这个“随机”拆开讲明白的确实不多。这篇文章不打算停留在概念层面我会直接手写代码把全批量梯度下降、随机梯度下降、小批量随机梯度下降放在一起做可视化对比再补一个非凸函数上的实验让你从梯度估计、收敛路径、loss 震荡这几个维度真正理解“随机”二字的含义。内容不挑基础只要你写过一点 Python、知道导数怎么算就能跟下来。看完之后以后看 PyTorch 里的torch.optim.SGD你会有一种“原来如此”的感觉。1. 先搞清楚一件事梯度下降到底在干什么1.1 损失函数与梯度下山理论要理解 SGD就得先理解梯度下降本身。我习惯用一个比喻你半夜摸黑下山看不见整个山体的全貌只能靠脚下感受坡度坡度最陡的方向就是下降最快的方向你就朝那个方向迈一步然后重新感受坡度继续走。这里的“山”就是损失函数L(w)脚下的“坡度”就是梯度∇L(w)迈的步子大小就是学习率η。参数更新公式就一行w w - η * ∇L(w)但这里藏着一个容易被忽略的问题∇L(w)是个什么梯度是全部样本的梯度还是一个样本的梯度还是一批样本的梯度教科书上默认讲的是“全批量梯度下降”也就是用全部训练样本算出一个总损失再对这个总损失求梯度。这在数学上干干净净但放到现实中很奢侈。举个例子假设训练集有 100 万张图片每迭代一次就要把 100 万个样本全部过一遍才算出一个梯度。一次两次还好迭代几百个 epoch 就是几亿次前向计算。更麻烦的是很多模型的目标函数是非凸的全批量梯度下降往往只会稳定地滚进离初始点最近的局部极小值后面再也出不来。所以我一直觉得把 SGD 理解成“为了速度牺牲精度”是不准确的。它确实更快但它的优点远不止“快”这么简单。1.2 全量梯度 vs 单样本梯度SGD 名字的由来1951 年Kiefer 和 Wolfowitz 提出随机逼近方法后来 Robbins 和 Monro 在 1951 年前后奠定了随机优化理论再到 1986 年 Rumelhart、Hinton 等人在反向传播论文里把 SGD 用于神经网络训练“随机梯度下降”这个词就从学术界走进了工程界。那名字里的“随机”是怎么来的因为每一步迭代它不拿全部样本算梯度而是从训练集里随机抽出一个样本用这个样本的梯度当作全量梯度的近似然后更新参数。w w - η * ∇L_i(w) # 其中 i 是随机抽取的样本下标注意看这里的梯度带了下标i意思是“第 i 个样本产生的梯度”。这个梯度跟全量梯度一比一般是不相等的它是一个有噪声的估计量。所以 SGD 每一步走得歪歪扭扭不成直线但无数个歪歪扭扭的步子叠起来反而能走到一个不错的位置。你们看 PyTorch 里的torch.optim.SGD默认参数里有个batch_size吗没有。因为优化器不负责切 batch它只负责更新参数。数据怎么切成一个个 batch是DataLoader的事。这个设计经常被新手忽略但恰恰说明一件重要的事所谓“随机梯度下降”在实际工程里通常指的就是“小批量随机梯度下降”每次随机抽一批样本比如 32 个、64 个而不是严格意义上的“每次只抽一个样本”。2. “随机”的三层含义数据随机、梯度随机、路径随机2.1 每次迭代从训练集中随机抽样本“随机”的第一个层面体现在“抽样本”这个动作上。全批量梯度下降每次用的是同一个数据集计算出来的梯度方向是确定的SGD 每次先打乱数据再依次或者随机抽出一个或一批样本从数学上讲每个样本被抽中的概率都相等所以叫“随机”。这一步带来的直接好处是计算量大幅下降。一个 epoch 里全批量梯度下降只更新 1 次参数而 SGD 可以更新 N 次N 等于样本数或者 N/batch_size 次。参数更新频率上去了模型收敛自然就快。这不是什么玄学就是字面上的“更新次数变多”。不过要注意工程实现里更常用的做法是每个 epoch 先shuffle数据然后顺序切 batch。这样能保证在一个 epoch 内每个样本恰好吃到一次同时 batch 之间的顺序又具备随机性。这是一种“有放回”与“无放回”的折中。严格数学意义的 SGD 是有放回抽样但实践的 mini-batch 通常是无放回抽样加洗牌效果一样好还更稳定。2.2 随机梯度是真实梯度的无偏估计第二个层面是“梯度随机”。你随机抽一个样本 i 算出来的梯度跟全量梯度之间有偏差但期望上它们相等E[∇L_i(w)] ∇L(w)也就是说如果反复抽很多次把每个样本梯度求平均最终会逼近真实梯度。这叫做“无偏估计”。为什么这个性质重要因为它保证了 SGD 在理论上最终能收敛到和批量梯度下降相似的解附近只是路径更曲折。统计学里有个朴素直觉每个随机样本梯度都有噪声但噪声平均下来会被抵消。你扔硬币单次结果是随机的扔一万次正反面比例就会接近 1:1。SGD 靠的就是这种“大量噪声加期望不变”的机制。但要注意无偏并不等于高效。随机梯度的方差很大尤其当数据里存在大量离群点时个别样本的梯度会非常大导致参数更新步子迈得特别夸张。这也是为什么后来出现 Momentum、RMSProp、Adam 的原因——它们本质上都是在想办法抑制随机梯度带来的大方差。2.3 噪声是坏事吗跳出局部极小与正则化效应第三个层面也是最容易误解的一点随机梯度的“噪声”不是缺陷反而是一种优势。全批量梯度下降特别“一根筋”遇到局部极小值梯度为零就停住不动了。但 SGD 因为每一步的梯度是带噪声的即使当前到了局部极小点下一步随机抽到的样本产生的梯度也大概率不为零于是它就被“踢”出去继续探索。你可以把 SGD 想象成一位喝了点小酒的旅人下山的路径虽然歪歪扭扭但遇到一个小坑时踉跄一下就迈过去了而全批量梯度下降像个过分谨慎的人走到哪个坑就稳稳地蹲在哪个坑里。这引出了一个很著名的实验结果在深度神经网络训练中SGD 的解往往比全批量梯度下降泛化得更好。2017 年 Keskar 等人的论文对此做了详细讨论后来很多研究跟进虽然机制尚无定论但一个主流解释是小批量梯度的噪声对优化轨迹施加了一种隐式的正则化效果帮助模型避开那些尖锐的极小值而趋向更平坦的极值区域。平坦区域的解对测试数据的扰动更不敏感所以泛化性能更好。我们不需要背结论只需要记住一个判断方法如果你的模型用torch.optim.SGD训练时 loss 曲线是一条几乎没有抖动的光滑曲线你要警惕——这可能说明你用的数据 loader 顺序固定、学习率太低、或者 batch 太大随机性没有真正发挥出来。反而那些看起来“毛毛躁躁”的 loss 曲线往往才是训练健康的标志。3. 代码实战BGD、SGD、Mini-batch 手写对比3.1 实验环境与数据准备纸上谈兵够多了下面用代码把三种优化器跑一遍。我用的是最普通的线性回归任务损失函数是 MSE梯度可以精确算出来方便对比。环境只需要numpy和matplotlib不需要深度学习框架这样能看清楚优化器内部的每一步。import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # 生成 200 个样本真实关系是 y 2x 1加一点高斯噪声 X np.random.uniform(-2, 2, (200, 1)) y_true 2 * X 1 y y_true np.random.randn(200, 1) * 0.3 plt.scatter(X, y, s8, alpha0.6) plt.xlabel(x) plt.ylabel(y) plt.title(Simulated dataset) plt.show()这组数据跟真实场景里的训练集很像样本少、有噪声、线性关系隐藏在噪声背后。如果优化器够好它应该收敛到接近w2, b1的参数。我额外说明一下参数初始化。这里统一用一个固定的随机种子初始化w0.0, b0.0保证三个实验的起点完全一致对比才公平。3.2 三个优化器完整实现全批量梯度下降BGDdef bgd(X, y, lr0.1, epochs150): m len(y) w np.zeros((1, 1)) b 0.0 history [] for epoch in range(epochs): y_pred X w b grad_w (2 / m) * X.T (y_pred - y) grad_b (2 / m) * np.sum(y_pred - y) w w - lr * grad_w b b - lr * grad_b loss np.mean((X w b - y) ** 2) history.append(loss) return w, b, history注意 BGD 的梯度是全量计算的(2 / m) * X.T (y_pred - y)括号里是每个样本的预测误差乘上 X 再除 m得到的就是整个数据集的梯度方向。这里没有随机因素同一个初始点、同一个数据每次跑出来的轨迹完全一样。随机梯度下降SGD严格版def sgd(X, y, lr0.01, epochs150): m len(y) w np.zeros((1, 1)) b 0.0 history [] for epoch in range(epochs): # 严格意义的SGD每次迭代随机抽一个样本 idx np.random.choice(m) xi X[idx:idx1] yi y[idx:idx1] y_pred xi w b grad_w 2 * xi.T (y_pred - yi) grad_b 2 * np.sum(y_pred - yi) w w - lr * grad_w b b - lr * grad_b # 每个epoch结束后记录一次全量loss loss np.mean((X w b - y) ** 2) history.append(loss) return w, b, history严格版的 SGD 在每个 epoch 里只更新一次参数且只用 1 个样本估计梯度。你会发现它的 loss 曲线非常颠簸这是正常的。我故意让它的 lr 比 BGD 低一个数量级因为单样本梯度的方差太大学习率稍微调高就容易直接发散。小批量随机梯度下降Mini-batch SGDdef mini_batch_sgd(X, y, batch_size16, lr0.05, epochs150): m len(y) w np.zeros((1, 1)) b 0.0 history [] for epoch in range(epochs): # 每个epoch先洗牌再切成小批量 perm np.random.permutation(m) for j in range(0, m, batch_size): idx perm[j:jbatch_size] xi X[idx] yi y[idx] y_pred xi w b n len(idx) grad_w (2 / n) * xi.T (y_pred - yi) grad_b (2 / n) * np.sum(y_pred - yi) w w - lr * grad_w b b - lr * grad_b loss np.mean((X w b - y) ** 2) history.append(loss) return w, b, historyMini-batch 的逻辑是每个 epoch 先把数据随机打乱然后按batch_size16切成 13 个 batch每个 batch 都更新一次参数。所以一个 epoch 内参数更新 13 次lOSS 会来回波动但整体趋势向下。3.3 可视化loss 曲线与收敛路径对比写一个调用函数把三条 loss 曲线画在同一张图里w_bgd, b_bgd, hist_bgd bgd(X, y, lr0.1, epochs150) w_sgd, b_sgd, hist_sgd sgd(X, y, lr0.01, epochs150) w_mini, b_mini, hist_mini mini_batch_sgd(X, y, batch_size16, lr0.05, epochs150) plt.figure(figsize(10, 5)) plt.plot(hist_bgd, labelBGD (full batch), linewidth2) plt.plot(hist_sgd, labelSGD (single sample), alpha0.7) plt.plot(hist_mini, labelMini-batch SGD (batch16), alpha0.7) plt.yscale(log) plt.xlabel(epoch) plt.ylabel(MSE loss (log scale)) plt.title(Loss curves: BGD vs SGD vs Mini-batch SGD) plt.legend() plt.grid(True, alpha0.3) plt.show() print(fBGD: w{w_bgd[0][0]:.4f}, b{b_bgd[0]:.4f}) print(fSGD: w{w_sgd[0][0]:.4f}, b{b_sgd[0]:.4f}) print(fMini-batch: w{w_mini[0][0]:.4f}, b{b_mini[0]:.4f})正常跑出来的结果应该是这样方法w 估计值b 估计值150 epoch 后 lossBGD~1.99~0.98约 0.09SGD严格在 1.8~2.1 之间波动在 0.8~1.2 之间波动约 0.10Mini-batch~2.00~0.99约 0.09几个点值得展开第一BGD 的 loss 曲线最平滑一路稳步下降没有任何抖动。严格 SGD 的 loss 曲线有的 epoch 在下降有的在反弹像一个醉汉在下楼。Mini-batch 介于二者之间虽然有震荡但波动幅度比严格 SGD 小得多。第二严格 SGD 的最终参数估计明显更“不稳”单独跑一次运气好收敛到接近最优运气差甚至会偏不少。这恰好说明随机梯度的方差很大。实践里如果真用严格 SGD 训练模型一般需要配合学习率衰减否则后期参数会在最优值附近来回震荡无法精确定位。第三Mini-batch 在综合表现上完胜。它的收敛速度接近 SGD参数精度接近 BGD训练时间也短。这就是为什么深度学习框架里的SGD优化器实战中几乎总是搭配一个大于 1 的batch_size来使用。3.4 非凸函数上的“随机”优势实验线性回归太“温柔”了损失函数的等高线是碗状的只有一个全局最小点体现不出 SGD 跳出局部极小的本事。我再加一个实验在一维非凸函数上做梯度下降直观看看随机噪声如何救命。# 定义目标函数 f(x) 0.2*x^4 x^3 - 2*x^2 - x # 导数: df(x) 0.8*x^3 3*x^2 - 4*x - 1 def f(x): return 0.2 * x**4 x**3 - 2 * x**2 - x def df(x): return 0.8 * x**3 3 * x**2 - 4 * x - 1 xs np.linspace(-5, 4, 500) plt.plot(xs, f(xs), labelf(x), linewidth2) plt.axhline(0, colorblack, linewidth0.5) plt.axvline(0, colorblack, linewidth0.5) plt.xlabel(x) plt.ylabel(f(x)) plt.title(Non-convex function) plt.grid(True, alpha0.3) plt.legend() plt.show()这个函数有两个局部极小值一个在x ≈ -4.8附近另一个在x ≈ 1.2附近哪个局部极小值更低可以肉眼对比。我用x0.5作为初始点分别跑全量梯度下降和带随机噪声的梯度下降。def bgd_nonconvex(x0, lr0.01, steps200): x x0 path [x] for _ in range(steps): x x - lr * df(x) path.append(x) return np.array(path) def sgd_noise_nonconvex(x0, lr0.01, steps200, noise_std0.3): x x0 path [x] for _ in range(steps): grad df(x) np.random.randn() * noise_std x x - lr * grad path.append(x) return np.array(path)这里给梯度人为加一个高斯噪声模拟 SGD 随机采样带来的梯度扰动。跑出来的路径会展示出两种完全不同的结局BGD 从 0.5 出发大概率稳定滑入离它更近的那个局部极小点。加了噪声的 SGD 在前期会大幅摆动有可能越过中间的高坡直接滚进更低的那个极小点。这就是随机噪声赋予的“探索能力”。虽然模拟用的不是真数据集但机制跟真实训练完全一致。深度学习的损失函数动辄上百万维布满局部极小和鞍点如果不用带随机性的优化算法模型基本会被困在最初的坑里出不来。所以 SGD 的“随机”不是工程妥协而是数学上赋予模型逃离局部极小的核心机制。4. 参数调优与经验总结4.1 学习率和 batch_size 怎么配参数调优里学习率和 batch_size 是最关键的两个旋钮。根据我自己的经验给几个实用参考学习率过大SGD 的 loss 曲线会发散训练直接崩掉。此时你会看到 loss 变成nan或者猛增几个数量级。解决办法不是反复换学习率而是先调低一个数量级试试。学习率过小模型收敛极慢鉴定方法是画 loss 曲线如果 50 个 epoch 后下降幅度太少说明学习率偏低。batch_size 越大梯度估计越接近全量梯度训练越稳定但泛化性可能变差batch_size 越小梯度噪声越大越容易跳出局部极小但收敛不稳。我常用的初始配置是batch_size 取 32 或 64学习率先按 1e-3 起步Adam 场景或 1e-2 起步SGDMomentum 场景跑几十个 epoch 看曲线趋势再调整。深度学习项目里很少上来就微调学习率都是先让模型“跑起来”再根据曲线形态反向调节。4.2 训练策略shuffle、epoch 与学习率衰减实践中还有几个比调参更重要的细节。首先是数据打乱。DataLoader里的shuffleTrue不是可有可无的选项它直接决定每个 epoch 内的 batch 构成是否随机进而决定随机梯度下降是否名副其实。如果数据原始顺序里存在类别聚集比如前 5000 个样本全是猫、后 5000 个全是狗不打乱就直接切 batch某些 batch 的梯度会严重偏斜。其次是学习率衰减。SGD 后期收敛不精确很大程度上是因为学习率恒定。常见的衰减策略有 step decay、指数衰减、cosine annealing还有 PyTorch 支持的torch.optim.lr_scheduler.ReduceLROnPlateau——它会监控 loss如果连续几个 epoch 没下降就自动降学习率。这个调度器我建议大家在 CV / NLP 之外的普通任务里当作首选省心且效果稳定。最后是 epoch 和 total steps 的关系。很多人混淆这两个概念。epoch 是整个训练集过一遍的次数total steps 是参数更新的总次数。mini-batch 里total steps epoch × (样本数 / batch_size)。看学习率调度时有的衰减策略是按 epoch 衰减有的是按 step 衰减搞清楚单位才不会在代码里踩坑。4.3 常见误区SGD 不等于随机初始化也不等于每次抽一个样本说几个我在代码 review 里经常遇见的误区。误区一觉得“SGD 优化器必须搭配单样本训练”。实际上 PyTorch 的SGD是支持任意 batch_size 的你把DataLoader的 batch_size 设为 64配合SGD跑的就是 mini-batch 随机梯度下降。学术论文里写 “SGD” 时九成指代的也是 mini-batch 版本。误区二把随机初始化权重当成“随机梯度下降”里的“随机”。随机初始化解决的是参数对称性问题SGD 里的“随机”指的是每次梯度估计使用的数据子集随机两者是不同维度的概念。误区三看见 loss 曲线抖动就以为出 bug 了。对 SGD / mini-batch 训练来说loss 波动是正常的。真正的异常是长期不下降、数值溢出、或者同一份代码设置相同的随机种子却得到不同结果——后者通常是全局状态没控制好。5. 常见问题与排查技巧实录5.1 loss 震荡剧烈但不下降这是最常被问到的现象。loss 曲线像心电图一样高频抖动但整体没有下降趋势。我一般按下面的顺序排查把学习率调低一个数量级看是否好转。如果学习率太高梯度会来回越过最优值loss 居高不下。检查数据是否做了归一化。特征尺度差太大时梯度方向会被大尺度特征主导小批量样本抽出不同的特征组合导致梯度方向混乱。查看是否存在离群点。个别极端样本产生的梯度可能远超普通样本让每次更新都像被拽了一下。可以统计一下梯度范数的分布如果最大值跟中位数差几个数量级就要考虑裁剪梯度gradient clipping。真实项目中最常见的原因就是学习率偏大加数据未归一化。把StandardScaler接在数据前面很多 loss 震荡问题能够直接消失。5.2 用了 SGD 还是逃不出局部极小有些朋友跟我说SGD 不是能跳出局部极小吗怎么我的模型还是收敛得很差这里要泼盆冷水跳出局部极小的能力与噪声强度成正比而噪声强度与 batch_size 成反比。如果你把 batch_size 设成 512、1024梯度噪声被大量样本平均掉了跟全批量梯度下降几乎没有区别随机带来的探索能力也荡然无存。另外学习率太低也会削弱探索能力。随机梯度的绝对值太小噪声更新压不过势垒模型照样困在局部极小里。建议把学习率调高到能明显看到 loss 波动的水平前几个 epoch 允许它大幅震荡然后再做衰减这是一种简单有效的“预热 退火”策略。5.3 随机数种子到底该不该固定必须固定尤其是你想复现实验结果的时候。Python 里的随机数涉及多个源头random、numpy.random、PyTorch 的torch.manual_seed、CUDA 的torch.cuda.manual_seed_all。光固定其中一个另几个还是随机的。固定种子的意义不只是复现实验更重要的是让你能稳定地对比不同超参数。如果每次训练的数据顺序都在变你很难分清 loss 的改善是来自学习率调整还是纯粹撞运气。我通常会在脚本开头统一调用def set_seed(seed42): import random random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)但只说固定种子也不全面。真要追求“完全可复现”还要注意 GPU 算子的非确定性、数据加载多线程的随机性、以及 cuDNN 的 auto-tune。这些东西在生产环境里很难 100% 锁死所以我一般把复现目标设定在“同一台机器、同一套环境、同一种子”这个级别追求绝对复现意义不大。我个人调试优化器时还有个习惯建一个临时脚本把模型和数据缩到一个小规模版本比如 2000 个样本、单层网络然后用不同的优化器分别跑 50 个 epoch画 loss 曲线对比。这种小实验 5 分钟就能出结果比在大模型上盲调高效得多。SGD 这类优化器的行为在小规模场景下和大规模场景下规律是一致的一旦你在小实验里确认了“这个 batch_size 下曲线太震荡”“这个学习率下收敛太慢”放大到真实数据时只需要按比例调整学习率方向不会变。最后再分享一个和本文主题直接相关的感悟优化的“随机性”真的是个宝贝。很多人追求让 loss 曲线平平稳稳地下滑甚至觉得抖动是训练不健康的信号。但看完这篇文章你应该明白了跳动意味着探索探索意味着模型有机会发现更优的解。下次训练时再看到那条毛毛躁躁的下降曲线不用慌这恰好说明你的优化器正在用“随机”的力量帮你把模型推向一个更远的地方。
返回列表