起因#
最近在继续 CS336 的过程中,发现一个问题:很多模型训练侧的基础概念,虽然"知道",但已经不像以前那么顺手了。
做 ML Infra 久了,日常打交道的是 backward latency、optimizer state memory、DDP communication overhead。这些系统侧的东西非常熟悉。但最基础的那条线——forward 怎么走、loss 怎么定义、gradient 怎么回传、optimizer 怎么更新——已经很久没有被完整地重新组织过了。
于是开始看 MIT 6.S191(Introduction to Deep Learning),把它当作一次 deep learning 的康复训练。不是从零学习,是把多年没有显式使用的知识重新加载进 working memory。
Training 是一个 Optimization Problem#
一个 neuron 做的事情很简单:
$$z = Wx + b$$$$h = f(z)$$\(W\) 和 \(b\) 是 parameter,\(f\) 是 activation function。
把很多这样的单元堆成多层,就有了 neural network。但这里有一个容易因为"太基础"而被跳过的问题:depth 本身并不自动带来表达能力。
如果没有 non-linear activation,连续堆叠多层:
$$W_3(W_2(W_1 x))$$本质上仍然只是一个线性变换——等价于某个 \(W'x\)。Non-linearity 才让多层 composition 真正有意义。 这是 activation function 存在的根本原因,不是为了好看。
有了 architecture 以后,神经网络所谓的"学习"被形式化为一个优化问题:
$$\theta^* = \arg\min_\theta L(\theta)$$Architecture 定义了 function family——这个网络能表达哪些函数。Loss function 定义了我们希望它变成什么。Training algorithm 负责在 parameter space 里寻找一个好的位置。
Training 的本质是在一个由 architecture 定义的 function family 中,找到一组 parameter,使得 loss 尽可能小。
Backward 不更新模型#
很多地方会笼统地说"backward 一下,模型就学了"。但这里有一个重要的概念边界。
Backward 做的事情是:
$$L \rightarrow \nabla_\theta L$$它回答的问题是:loss 对每个 parameter 有多敏感。
Optimizer 做的事情是:
$$\nabla_\theta L \rightarrow \Delta\theta$$它回答的问题是:知道了 gradient,parameter 到底应该怎么走。
最终:
$$\theta_{t+1} = \theta_t + \Delta\theta$$Backward 只负责算 gradient,不负责更新 parameter。
对应到 PyTorch 的 training loop:
1optimizer.zero_grad()
2
3pred = model(x)
4loss = loss_fn(pred, y)
5
6loss.backward()
7optimizer.step()pred = model(x) 和 loss = loss_fn(pred, y) 是 forward:从 input 到 prediction 到 loss。
loss.backward() 是 backward:沿 computation graph 反向计算每个 parameter 的 gradient,结果存进 .grad 属性。
optimizer.step() 才是 parameter update:根据 .grad 和 optimizer 自身的逻辑更新 parameter。
那 optimizer.zero_grad() 呢?
PyTorch 的 .grad 默认是累加的。如果不手动清零,这一轮的 gradient 会叠加到上一轮还没清掉的 gradient 上面。这不是设计失误——gradient accumulation 恰好依赖这个行为:多个 mini-batch 的 gradient 累加之后再做一次 update,模拟更大 batch size 的效果。
但在标准 training loop 中,每一步都是独立的,所以 zero_grad() 是必须的。
Chain Rule 与 Backpropagation#
Backward 的核心是 chain rule。不需要重新上一遍微积分,只需要一个直觉。
假设有一条最简单的计算链:
$$x \rightarrow y \rightarrow L$$Chain rule:
$$\frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot \frac{\partial y}{\partial x}$$用更直觉的说法:
$$\text{upstream gradient} \times \text{local derivative} = \text{gradient passed backward}$$Backpropagation 就是在 computation graph 上反向、重复、高效地执行这个过程。
这里有一个经常被忽略的细节。考虑某一层:
$$y = f(x, W)$$Backward 时需要计算两个东西。
第一个是 parameter gradient:
$$\frac{\partial L}{\partial W}$$用于更新当前层的 \(W\)。
第二个是 input gradient:
$$\frac{\partial L}{\partial x}$$但这一层的 \(x\) 通常不是一个可学习的 parameter——它是上一层的输出。为什么还要算它?
因为上一层的 parameter gradient 依赖它。上一层要计算自己的 \(\frac{\partial L}{\partial W_{\text{prev}}}\),需要知道 \(\frac{\partial L}{\partial x}\)——也就是当前层传回去的 gradient。
对 activation / input 求梯度,通常不是为了更新这个 activation,而是为了继续把 gradient 传回更早的 computation graph。
这也解释了为什么 backward 的 compute 和 memory 行为与 forward 很不一样。Forward 时每一层算完就可以把 input 丢掉(推理场景)。但 backward 要算 parameter gradient 往往需要重新访问 forward 时的中间结果——这就是为什么训练时 forward 阶段的 activation 必须保留到对应的 backward 完成。
Optimizer:gradient 只是开始#
拿到 gradient 以后,最简单的做法是直接沿负梯度方向走一步。这就是 SGD:
$$\theta_{t+1} = \theta_t - \eta g_t$$其中 \(g_t = \nabla_\theta L\)。
如果 gradient 已经告诉了下降方向,为什么还需要那么多不同的 optimizer?
因为 gradient 描述的是当前这一个点的 local information。它可能很 noisy(mini-batch 采样导致),可能在不同 dimension 上 scale 差异巨大,可能让 optimization trajectory 在某些方向上反复震荡。Optimizer 要解决的问题是:如何把一系列 noisy local gradients 转换成一条 able 的 optimization trajectory。
Momentum#
SGD 完全由当前 gradient 决定下一步。如果 gradient 噪声很大,trajectory 会非常抖。
Momentum 引入历史方向:
$$v_t = \beta v_{t-1} + g_t$$$$\theta_{t+1} = \theta_t - \eta v_t$$核心想法:如果某个方向上的 gradient 长期一致,就应该在这个方向上积累速度。偶尔出现的反方向噪声会被平滑掉。
Adaptive Learning Rate#
SGD 和 Momentum 对所有 parameter 使用同一个 learning rate。但不同 parameter、不同 dimension 的 gradient scale 可能差异很大。
AdaGrad、RMSProp 这类方法引入 gradient 二阶矩的历史,对梯度经常很大的 parameter 自动缩小 effective learning rate,对梯度经常很小的 parameter 自动放大。不同 parameter 不一定应该使用完全相同的步长。
Adam#
Adam 可以高层次理解为:momentum-like first moment + adaptive scaling from second moment。
它为每个 parameter 维护两个状态:
$$m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t$$$$v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2$$\(m_t\) 是 gradient 的一阶矩估计(方向),\(v_t\) 是 gradient 平方的二阶原始矩估计(scale)。Update 大致是用 \(m_t\) 除以 \(\sqrt{v_t}\) 再乘以 learning rate(还有 bias correction,这里略过)。
Optimizer State 是真实的 Memory#
从 ML Infra 视角看,optimizer 的数学设计直接变成训练系统的 memory footprint。
Momentum 需要为每个 parameter 保存 \(v_t\)。Adam 需要为每个 parameter 同时保存 \(m_t\) 和 \(v_t\)。
FP32 Adam 粗略估算:每个 parameter 对应 parameter 本身、gradient、first moment、second moment——四份 float32 tensor。一个 1B 参数的模型,仅这些状态就大约 16 GB。
Optimizer 的选择不仅影响 convergence behavior,它直接定义了训练时的一部分 memory model。 Mixed precision、ZeRO optimizer-state sharding、master weights——归根结底都在处理 optimizer state 带来的显存压力。
Optimization 不等于 Generalization#
到这里,training loop 的主线已经完整了:forward → loss → backward → gradient → optimizer → parameter update。
但还有一个根本性的问题。
训练时我们在优化的是 \(L_{\text{train}}\),真正关心的是模型对 unseen data 的表现 \(L_{\text{test}}\)。而:
$$\min L_{\text{train}} \nRightarrow \min L_{\text{test}}$$Training loss 可以继续下降,validation performance 可能已经开始恶化。模型把 training data 里的 noise 和 pattern 一起学了进去。
仅仅把 optimization 做好是不够的,还需要手段来约束模型,使它的能力更多地花在 generalizable pattern 上。
Dropout#
Dropout 在 training 时随机 mask 一部分 activation:
$$h' = \frac{m \odot h}{1 - p}$$其中 \(m\) 是随机 binary mask,\(p\) 是 dropout rate。
注意不是"随机删除神经元"。Neuron 还在,只是这一次 forward 中它的输出被置零了。下一次 forward,mask 重新随机生成,被 mask 掉的可能是另一批 activation。
分母的 \(\frac{1}{1-p}\) 是 inverted dropout 的关键:训练时 scale up,使得 \(E[h'] = h\)。这样 inference 时可以直接关掉 dropout,不需要额外缩放。
Dropout 打破了模型对固定 feature 组合的过度 co-adaptation——迫使每个 hidden unit 不能依赖特定的其他 unit 一定会出现,从而学到更 robust 的 representation。
有一个常见的 intuition 是"dropout 相当于训练很多不同的 subnetwork"。有一定道理,但只是 intuition,不是严格的 ensemble。
Early Stopping#
另一种 regularization 策略不改变模型怎么学,而是控制模型学多久。
Early stopping 的做法:训练过程中定期在 validation set 上评估。当 validation performance 不再改善时,停止训练。
需要注意:early stopping 不一定发生在 epoch boundary。现代大规模训练中,一个 epoch 可能包含几十亿 token,epoch 本身不一定是最自然的 evaluation 单位。更常见的做法是 every N steps 或 every N tokens 做一次 validation。
两者做一个对照:
Dropout 改变模型怎么学。Early stopping 控制模型学多久。 两者都是 regularization,但作用的层面不同。Dropout 在每一步的 forward pass 中引入随机性。Early stopping 在 optimization trajectory 的层面做截断——它限制的不是模型本身,而是 optimizer 在 parameter space 里能走多远。
把所有东西放回一条 Training Loop#
回到整体。一次 training iteration 的完整流程:
1data
2 ↓
3forward
4 ↓
5prediction
6 ↓
7loss
8 ↓
9backward
10 ↓
11gradients
12 ↓
13optimizer
14 ↓
15updated parameters
16 ↓
17next iteration这条线本身并不复杂。但在实际训练系统中,它展开成很多具体的工程问题:forward 阶段的 activation 必须保留到 backward 完成;autograd 框架自动追踪 computation graph;gradient 可能在 accumulate 多个 micro-batch 后才触发一次 update;optimizer state 为每个 parameter 额外占用若干份 memory;checkpoint 定期保存 parameter 和 optimizer state;distributed training 需要跨 GPU 同步 gradient;mixed precision 在不同 stage 使用不同 dtype;activation checkpointing 主动丢掉中间结果以减少显存峰值。
这些问题不是脱离 neural network training 独立存在的。它们每一个都能在上面那条简单 pipeline 里找到对应的位置。
ML Infra 里的很多复杂性,只是最基础 training loop 在规模扩大之后的物理实现。
做系统久了,很容易习惯 backward latency、optimizer state memory、all-reduce bandwidth 这些概念,却反而很少重新问一次:这些 tensor 为什么存在?它们在 training loop 里的角色是什么?
重新过一遍 6.S191,对我来说价值不在于学几个新名词,而是把训练过程重新压缩成几个稳定的 mental model:
$$\text{Forward} \rightarrow \text{Loss} \rightarrow \text{Backward} \rightarrow \text{Gradient} \rightarrow \text{Optimizer} \rightarrow \text{Parameter Update}$$以及一个很容易在日常工作中被忽略的提醒:
$$\text{Optimization} \neq \text{Generalization}$$参考资料#
- MIT 6.S191: Introduction to Deep Learning — https://introtodeeplearning.com/