Skip to main content
  1. Posts/

从 Forward 到 Optimizer:重建 Neural Network Training 的 Mental Model

·3862 words·8 mins
Table of Contents
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

起因
#

最近在继续 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
1011gradients
1213optimizer
1415updated parameters
1617next 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}$$

参考资料
#

Related