系列回顾:上一篇我们推导了梯度下降的数学原理——负梯度是损失下降最快的方向,学习率控制步长,学习率太大振荡、太小收敛慢。那一篇暗含一个假设:每步用全部训练数据计算精确梯度。但 DeepSeek V3 有 14.8 万亿个训练 token,每步遍历一遍完全不现实。这篇解决这个问题——不只是"计算上省事",随机梯度下降(SGD)的噪声有深刻的统计学含义和正则化效果。
回顾批量梯度下降(Batch GD)的更新规则:
$$\theta \leftarrow \theta - \alpha \cdot \frac{1}{n} \sum_{i=1}^n \nabla_\theta \ell(\theta; x^{(i)}, y^{(i)})$$
每步需要遍历所有 $n$ 个样本,计算每个样本的梯度,然后取平均。
对 DeepSeek V3 的训练:
以 H800 GPU 集群(约 $10^{17}$ FLOPS/秒)的速度,一步全量梯度需要约 $10^8$ 秒——超过 3 年。而整个 DeepSeek V3 预训练只用了约 2 个月。
全量梯度下降在大规模深度学习里根本不可行。
随机梯度下降(Stochastic Gradient Descent,SGD):每步不用全部数据,只随机抽取一个样本(或一小批),用它的梯度代替全量梯度:
$$\theta \leftarrow \theta - \alpha \cdot \nabla_\theta \ell(\theta; x^{(i)}, y^{(i)})$$
其中 $i$ 是随机抽取的样本索引。
单样本梯度 $g_i = \nabla_\theta \ell(\theta; x^{(i)}, y^{(i)})$ 是一个随机变量(因为 $i$ 是随机选的)。
关键性质:$g_i$ 是全量梯度的无偏估计。
$$\mathbb{E}_i[g_i] = \mathbb{E}_i[\nabla_\theta \ell(\theta; x^{(i)})] = \frac{1}{n}\sum_{i=1}^n \nabla_\theta \ell(\theta; x^{(i)}) = \nabla_\theta L(\theta)$$
证明:如果样本 $i$ 从训练集中均匀随机抽取,则期望就是所有样本梯度的算术平均,正好等于全量梯度。
含义:单样本梯度的方向平均上是正确的——长期来看,SGD 每步都在朝正确的方向移动。短期看,每步可能方向偏差很大(噪声),但平均起来和全量梯度下降一致。
这正是大数定律(第11篇)的直接应用:足够多的随机步骤,平均效果收敛到真实梯度方向。
无偏性保证方向平均正确,但每一步的噪声有多大?用方差衡量:
$$\text{Var}[g_i] = \mathbb{E}[\|g_i - \nabla_\theta L\|^2] = \frac{1}{n}\sum_{i=1}^n \|\nabla_\theta \ell_i - \nabla_\theta L\|^2$$
这是各样本梯度偏离全量梯度的平均平方距离。
训练初期:各样本梯度差异大,方差大,每步噪声大,更新方向不稳定。
训练后期:模型已经拟合大多数样本,各样本梯度趋于相似,方差减小,更新更稳定。
Mini-batch 梯度下降:每步从训练集随机抽取 $B$ 个样本(一个 batch),用这 $B$ 个样本的平均梯度近似全量梯度:
$$g_{\mathcal{B}} = \frac{1}{B} \sum_{i \in \mathcal{B}} \nabla_\theta \ell(\theta; x^{(i)}, y^{(i)})$$
$$\theta \leftarrow \theta - \alpha \cdot g_{\mathcal{B}}$$
无偏性(同 SGD):
$$\mathbb{E}[g_{\mathcal{B}}] = \nabla_\theta L(\theta)$$
方差(随 $B$ 增大而减小):
$$\text{Var}[g_{\mathcal{B}}] = \frac{1}{B} \text{Var}[g_i]$$
这是统计学中最基础的结论:$B$ 个独立同分布随机变量的均值,方差是单个变量方差的 $\frac{1}{B}$。
信噪比(SNR):
$$\text{SNR} = \frac{\|\mathbb{E}[g_{\mathcal{B}}]\|^2}{\text{Var}[g_{\mathcal{B}}]} = \frac{\|\nabla_\theta L\|^2}{\text{Var}[g_i] / B} = B \cdot \frac{\|\nabla_\theta L\|^2}{\text{Var}[g_i]}$$
Batch size 越大,信噪比越高,梯度方向越准确。
实践中,batch size 不是越大越好——增大 $B$ 有收益递减效应:
上一篇提到,高维空间里鞍点数量远多于极小值。鞍点的梯度为零,全量梯度下降在鞍点附近会停滞(梯度趋向零,更新越来越小)。
SGD 的噪声在梯度为零的附近仍然有随机扰动,可以把参数"踢出"鞍点:
$$\theta \leftarrow \theta - \alpha \cdot (\underbrace{0}_{\text{鞍点梯度}} + \underbrace{\varepsilon}_{\text{SGD噪声}}) = \theta - \alpha\varepsilon$$
这个随机踢动能让参数沿鞍点的负曲率方向(下坡方向)移动,继续优化。
这是 SGD 最深刻的性质之一:SGD 的随机噪声有隐式正则化效果,使模型偏向于"宽谷"(flat minima),而不是"尖谷"(sharp minima)。
为什么?
考虑两种极小值:
宽谷:参数在一个大邻域内损失都很低。随机扰动 $\varepsilon$ 很难让参数跳出这个低损失区域,SGD 能稳定停留在这里。
尖谷:损失最小值很低,但周围急剧升高。随机扰动 $\varepsilon$ 很容易让参数跳出这个尖谷,于是 SGD 难以稳定在尖谷,反而继续搜索,最终偏向宽谷。
宽谷和泛化:
宽谷对应的参数,在训练集和测试集上的损失差距更小。直觉:参数偏移一点(比如从训练分布到测试分布)时,宽谷里的损失变化小,尖谷里的损失变化大。宽谷的参数对扰动更鲁棒,泛化更好。
这解释了一个长期困惑的经验现象:同等训练损失下,小 batch size 训练的模型测试性能往往更好。不是偶然,是因为更大的噪声(小 batch)更倾向于宽谷。
SGD 的参数更新可以近似为一个随机过程(朗之万动力学):
$$d\theta = -\alpha \nabla_\theta L \, dt + \sqrt{\alpha} \cdot \underbrace{\sqrt{\frac{\alpha}{B} \Sigma}}_{\text{扩散项}} \, dW$$
其中 $\Sigma$ 是梯度协方差矩阵,$dW$ 是布朗运动增量。
扩散系数(类似"温度"):
$$\text{Diffusion} \propto \frac{\alpha}{B}$$
线性缩放规则(Linear Scaling Rule):如果 batch size 增大 $k$ 倍,学习率也应该增大 $k$ 倍,以保持相同的扩散系数(相同的"探索温度"):
$$\frac{\alpha_{new}}{B_{new}} = \frac{\alpha_{old}}{B_{old}} \implies \alpha_{new} = k \cdot \alpha_{old}$$
Facebook AI Research 在训练 ResNet 时验证了这个规则。DeepSeek V3 使用大 batch size 的同时配合较大的峰值学习率 $4.2 \times 10^{-4}$,也体现了这个平衡。
Epoch(轮):遍历一遍完整训练集。
迭代/步数(Step):一次参数更新(处理一个 mini-batch)。
关系:每个 epoch 的步数 = $\frac{n}{B}$(样本总数除以 batch size)。
语言模型训练通常不以 epoch 计,因为数据太多,一个 epoch 都不一定能完整跑完:DeepSeek V3 14.8T token,即使 batch size 18.9M token/步,也需要约 782,000 步——约跑了一遍数据。
每个 epoch 前随机打乱训练数据顺序,保证每个 mini-batch 是独立同分布的随机样本,维持梯度估计的无偏性。
不打乱的后果:
连续流式数据(语言模型的 token 序列)的处理:不直接按句子随机打乱(会破坏上下文),而是在文档级别打乱,然后按固定长度(2048 或 4096 token)切分为训练序列。
当 GPU 显存不足以容纳目标 batch size 时,用梯度累积实现等效大 batch:
for k in range(accumulation_steps): loss = model(mini_batch_k) / accumulation_steps loss.backward() # 累积梯度,不清零 optimizer.step() # 累积 k 个小 batch 后,一次性更新 optimizer.zero_grad() # 清零梯度
等效 batch size = 单卡 batch size × 累积步数 × GPU 数。
DeepSeek V3 训练时,H800 单卡显存 80GB,通过梯度累积 + 多卡并行实现全局 batch size 18.9M token/步。
多卡训练时,每张 GPU 处理一部分 mini-batch,需要同步梯度:
数据并行(Data Parallelism):每张 GPU 有完整的模型副本,各自处理不同的数据,反向传播后用 AllReduce 操作对所有卡的梯度取平均:
$$g_{global} = \frac{1}{K}\sum_{k=1}^K g_k$$
这相当于每步使用 $K$ 倍 batch size 的等效大 batch,梯度噪声减小 $\sqrt{K}$ 倍。
DeepSeek V3 使用 2048 张 H800,数据并行 + 模型并行 + 流水线并行的混合策略,实际等效 batch size 极大,同时用学习率预热和梯度裁剪保持训练稳定。
对 $\beta$-光滑凸函数,使用学习率 $\alpha_t = \frac{c}{\sqrt{t}}$(递减学习率),SGD 的期望收敛率:
$$\mathbb{E}[L(\bar{\theta}^{(T)})] - L(\theta^*) \leq \frac{c \cdot \mathbb{E}[\|g\|^2]}{\sqrt{T}} = O\left(\frac{1}{\sqrt{T}}\right)$$
其中 $\bar{\theta}^{(T)} = \frac{1}{T}\sum_{t=1}^T \theta^{(t)}$ 是参数的均值(Polyak-Ruppert 平均)。
对比全量 GD 的 $O(1/T)$ 收敛率,SGD 的 $O(1/\sqrt{T})$ 更慢——这是用计算效率换来的代价。
但实际总计算量的对比:
当 $n$ 很大(比如 $n = 14.8 \times 10^{12}$),$O(n/\varepsilon) \gg O(1/\varepsilon^2)$——SGD 在大数据下总计算量反而更小!
对 $\mu$-强凸、$\beta$-光滑函数,使用固定学习率 $\alpha = \frac{1}{\beta}$,即使有梯度噪声,SGD 也能线性收敛到噪声球附近:
$$\mathbb{E}[\|\theta^{(T)} - \theta^*\|^2] \leq \left(1 - \frac{\mu}{\beta}\right)^T \|\theta^{(0)} - \theta^*\|^2 + \frac{\alpha \sigma^2}{\mu}$$
第一项是指数衰减(和 GD 一样),第二项 $\frac{\alpha \sigma^2}{\mu}$ 是"噪声地板"——即使无限步,也无法精确到 $\theta^*$,只能到一个半径为 $\sqrt{\frac{\alpha\sigma^2}{\mu}}$ 的噪声球内。
降低噪声地板的方法:减小学习率 $\alpha$(但收敛变慢),或增大 batch size $B$(方差 $\sigma^2 \to \sigma^2/B$,噪声地板缩小 $\sqrt{B}$ 倍)。
这是深度学习训练后期常见操作"学习率退火"的理论依据:训练后期降低学习率,让参数更精确地收敛,减小噪声地板。
GPU 是 SIMD(单指令多数据)架构,天然适合批量计算。Batch size 太小时,GPU 大量计算单元空闲,利用率低;Batch size 适中时,GPU 满负荷运行。
典型建议:
实践中,增大 batch size 时的调参策略:
线性缩放规则(Facebook, 2017):batch size 增大 $k$ 倍,学习率增大 $k$ 倍。
$$\alpha_{new} = k \cdot \alpha_{old}, \quad B_{new} = k \cdot B_{old}$$
适用范围:$k$ 不太大时(通常 $k \leq 32$)效果好。
平方根缩放规则(理论上对噪声驱动阶段更准确):
$$\alpha_{new} = \sqrt{k} \cdot \alpha_{old}$$
实践建议:从小 batch 开始找好学习率,然后按线性规则放大,配合预热阶段。
import numpy as np import torch import torch.nn as nn import torch.optim as optim np.random.seed(42) torch.manual_seed(42) # ===== 1. SGD 无偏性验证 ===== print("===== SGD 梯度的无偏性验证 =====\n") np.random.seed(0) n = 1000 X = np.random.randn(n, 3) true_w = np.array([1.5, -0.5, 2.0]) y = X @ true_w + 0.1 * np.random.randn(n) def compute_full_grad(w, X, y): """全量梯度(MSE损失)""" resid = X @ w - y return 2 * X.T @ resid / len(y) w = np.zeros(3) full_grad = compute_full_grad(w, X, y) # 随机抽取 1000 个单样本梯度,验证其均值 ≈ 全量梯度 single_grads = [] for _ in range(1000): i = np.random.randint(n) resid_i = X[i] @ w - y[i] g_i = 2 * X[i] * resid_i single_grads.append(g_i) mean_single_grad = np.mean(single_grads, axis=0) print(f"全量梯度: {full_grad.round(4)}") print(f"1000次单样本梯度均值: {mean_single_grad.round(4)}") print(f"误差: {np.abs(full_grad - mean_single_grad).round(4)}") print(f"→ 单样本梯度的均值收敛到全量梯度(无偏性验证)\n") # 方差随 batch size 的变化 print(f"{'Batch size B':>12} | {'梯度方差(第1维)':>18} | {'理论比例(1/B)':>14} | 信噪比") print("-" * 65) single_var = np.var([g[0] for g in single_grads]) for B in [1, 4, 16, 64, 256, 1000]: batch_grads = [] for _ in range(500): idx = np.random.choice(n, B, replace=False) resid_b = X[idx] @ w - y[idx] g_b = 2 * (X[idx].T @ resid_b) / B batch_grads.append(g_b[0]) var_b = np.var(batch_grads) snr = full_grad[0]**2 / (var_b + 1e-10) print(f"{B:>12} | {var_b:>18.6f} | {single_var/B:>14.6f} | {snr:.2f}") # ===== 2. 不同 Batch Size 的收敛行为对比 ===== print("\n===== 不同 Batch Size 的收敛行为 =====\n") def sgd_train(X, y, batch_size, lr, n_steps): """用指定 batch size 训练 n_steps 步,返回损失历史""" n = len(X) w = np.zeros(X.shape[1]) loss_history = [] for step in range(n_steps): idx = np.random.choice(n, batch_size, replace=False) Xb, yb = X[idx], y[idx] resid = Xb @ w - yb grad = 2 * Xb.T @ resid / batch_size w -= lr * grad # 计算全量损失 full_resid = X @ w - y loss = np.mean(full_resid**2) loss_history.append(loss) return w, loss_history n_steps = 200 print(f"训练{n_steps}步,记录全量损失变化\n") print(f"{'Batch Size':>12} | {'初始损失':>10} | {'50步损失':>10} | {'200步损失':>10} | {'最终参数误差'}") print("-" * 65) for B, lr in [(1, 0.001), (16, 0.01), (64, 0.03), (256, 0.05), (1000, 0.1)]: _, hist = sgd_train(X, y, B, lr, n_steps) final_w, _ = sgd_train(X, y, B, lr, 2000) param_err = np.linalg.norm(final_w - true_w) print(f"{B:>12} | {hist[0]:>10.4f} | {hist[49]:>10.4f} | {hist[-1]:>10.4f} | {param_err:.4f}") # ===== 3. SGD 噪声对宽谷的偏好(隐式正则化)===== print("\n===== 隐式正则化:SGD 偏向宽谷 =====\n") # 构造一个有两个极小值的1D损失函数 # 宽谷(θ=-3)和 尖谷(θ=3) def loss_1d(theta): """双极小值函数:宽谷在θ=-3(曲率小),尖谷在θ=3(曲率大)""" # 宽高斯 + 窄高斯,两个极小值损失值相同 return (np.exp(-0.5*(theta+3)**2 / 4.0) + np.exp(-0.5*(theta-3)**2 / 0.3)) * (-1) + 1.5 def loss_grad_1d(theta, noise_std=0.0): """数值梯度 + 可选噪声""" eps = 1e-5 grad = (loss_1d(theta + eps) - loss_1d(theta - eps)) / (2*eps) return grad + noise_std * np.random.randn() np.random.seed(7) n_trials = 200 wide_count_noisy = 0 # 有噪声(小batch)落入宽谷次数 wide_count_clean = 0 # 无噪声(大batch)落入宽谷次数 for _ in range(n_trials): theta = np.random.uniform(-1, 1) # 从中间随机初始化 # 有噪声 SGD(小batch) th = theta for _ in range(500): g = loss_grad_1d(th, noise_std=0.5) th -= 0.02 * g if th < 0: # 宽谷在负半轴 wide_count_noisy += 1 # 无噪声 GD(大batch) th = theta for _ in range(500): g = loss_grad_1d(th, noise_std=0.0) th -= 0.02 * g if th < 0: wide_count_clean += 1 print(f"从相同初始点出发,各跑 {n_trials} 次:") print(f" 有噪声 SGD(小batch)落入宽谷: {wide_count_noisy/n_trials:.0%}") print(f" 无噪声 GD (大batch)落入宽谷: {wide_count_clean/n_trials:.0%}") print(f"→ SGD 的噪声让它更多地落入宽谷,对应更好的泛化性") # ===== 4. 学习率 × Batch Size 的线性缩放规则 ===== print("\n===== 线性缩放规则验证 =====\n") def train_lr_bs(X_t, y_t, lr, batch_size, n_epochs=10): """返回每个epoch的平均损失""" dataset = torch.utils.data.TensorDataset(X_t, y_t) loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, shuffle=True) model = nn.Linear(3, 1, bias=False) nn.init.zeros_(model.weight) optimizer = optim.SGD(model.parameters(), lr=lr) epoch_losses = [] for epoch in range(n_epochs): total_loss, steps = 0, 0 for Xb, yb in loader: pred = model(Xb).squeeze() loss = nn.MSELoss()(pred, yb) optimizer.zero_grad(); loss.backward(); optimizer.step() total_loss += loss.item(); steps += 1 epoch_losses.append(total_loss / steps) return epoch_losses X_t = torch.tensor(X, dtype=torch.float32) y_t = torch.tensor(y, dtype=torch.float32) configs = [ (0.005, 16, "基准(B=16, lr=0.005)"), (0.010, 32, "2× batch,2× lr"), (0.020, 64, "4× batch,4× lr"), (0.040, 128, "8× batch,8× lr(线性缩放)"), (0.005, 128, "8× batch,不变 lr(不缩放)"), ] print(f"{'配置':28} | {'第1轮损失':>10} | {'第5轮损失':>10} | {'第10轮损失':>10}") print("-" * 68) torch.manual_seed(42) for lr, bs, name in configs: hist = train_lr_bs(X_t, y_t, lr, bs, n_epochs=10) print(f"{name:28} | {hist[0]:>10.4f} | {hist[4]:>10.4f} | {hist[9]:>10.4f}") print(f"\n→ 线性缩放后(2/4/8× batch+lr),收敛速度和基准相近") print(f"→ 只增大batch不增大lr,收敛变慢(等效于降低了扩散系数)") # ===== 5. 噪声地板:固定学习率下的收敛行为 ===== print("\n===== 噪声地板:固定学习率 vs 递减学习率 =====\n") def sgd_fixed_vs_decay(X, y, n_steps=1000): results = {} for schedule, name in [("fixed", "固定学习率 α=0.01"), ("decay", "递减学习率 α=0.1/√t")]: w = np.zeros(X.shape[1]) losses = [] for t in range(1, n_steps+1): lr_t = 0.01 if schedule == "fixed" else 0.1 / np.sqrt(t) i = np.random.randint(len(X)) r = X[i] @ w - y[i] w -= lr_t * 2 * X[i] * r if t % 50 == 0: losses.append((t, np.mean((X @ w - y)**2))) results[name] = losses return results results = sgd_fixed_vs_decay(X, y) print(f"{'步数':>6} | {'固定lr损失':>12} | {'递减lr损失':>12}") print("-" * 36) for (t1, l1), (t2, l2) in zip(results["固定学习率 α=0.01"], results["递减学习率 α=0.1/√t"]): print(f"{t1:>6} | {l1:>12.6f} | {l2:>12.6f}") print(f"\n→ 固定学习率:快速收敛到噪声地板后停止下降") print(f"→ 递减学习率:更慢但持续下降,最终损失更低") print(f"→ 实践折中:余弦退火(慢慢降低学习率),兼顾两者优点")
这篇文章把随机梯度下降的统计原理、噪声的正则化效果、batch size 的工程选择,以及分布式训练的梯度同步,系统讲透了。
第一,SGD 的核心性质是无偏性:单样本梯度的期望等于全量梯度(大数定律的应用)。这保证了 SGD 平均上朝正确方向走。Batch size 增大 $B$ 倍,梯度方差减小 $B$ 倍,信噪比提高 $B$ 倍。
第二,SGD 的噪声不只是麻烦,更是正则化。噪声帮助逃离鞍点(梯度为零时仍有随机扰动),更重要的是,噪声让 SGD 偏向"宽谷"(flat minima)——宽谷对应更好的泛化性,解释了为什么小 batch 训练通常泛化更好。
第三,SGD 的扩散系数正比于 $\alpha/B$——学习率越大、batch size 越小,"探索温度"越高。线性缩放规则:batch size 增大 $k$ 倍,学习率也应增大 $k$ 倍,以保持相同的扩散特性。
第四,固定学习率 SGD 收敛到噪声地板(半径 $\propto \sqrt{\alpha/B}$),无法继续精确收敛。应对方案:余弦退火(训练后期降低学习率),让参数更精确地定位在极小值——DeepSeek V3 从 $4.2\times10^{-4}$ 衰减到 $4.2\times10^{-5}$,噪声地板缩小 $\sqrt{10}$ 倍。
第五,SGD 收敛率 $O(1/\sqrt{T})$ 慢于全量 GD 的 $O(1/T)$,但每步计算量是 $O(B)$ vs $O(n)$。大数据时($n \gg B$),SGD 的总计算量反而远小于全量 GD——这是 SGD 在大规模学习中取胜的本质原因。
下一篇预告:
第19篇,我们讲 Adam 优化器:DeepSeek 训练配置的核心。
朴素 SGD 有一个大问题:所有参数用同一个学习率,但不同参数的梯度量级差异可达几个数量级,一个学习率无法同时照顾所有参数。Adam 用梯度的一阶矩(动量)和二阶矩(方差)自适应调整每个参数的学习率——为什么这样做能加速收敛?$\beta_1, \beta_2, \varepsilon$ 三个超参数分别控制什么?DeepSeek V3 用的具体配置是什么?
SGD 的随机性不是工程妥协,是数学礼物。它把"无法使用全部数据"的限制,变成了"天然的正则化器"。每一步带噪声的更新,都是在说:不要过于相信眼前这一批数据,要对整个数据分布的结构负责。这个思想,贯穿了深度学习优化的整个历史。
还没有评论,来抢沙发吧!
博客管理员
40 篇文章
还没有评论,来抢沙发吧!