Skip to content

第6章 · Tensor 反向传播与训练稳定性

显式 tensor 对拍不替代完整网络的 clean-room 训练诊断。

先修:第3章的反传和第4章的交叉熵。

本章目标

  • 逐 shape 推导 embedding、linear、激活、cross entropy 和 normalization。
  • 比较初始化、LayerNorm、BatchNorm 的统计轴与训练/推理行为。
  • 用梯度/激活分布诊断饱和、爆炸或消失,并解释 AdamW 的更新边界。

公式与 shape

线性层和 LayerNorm 的核心形式:

Y=XW+b,LN(x)=γxμσ2+ϵ+β,

其中均值和方差沿最后一个 feature 轴计算。AdamW 将权重衰减与梯度更新解耦,可写成

θ(1ηλ)θηm^/(v^+ϵ).
对象shape约束
embedding table(V, D)ids (B, T)(B, T, D)
linear weight(D_in, D_out)X @ W + b 输出 (B, T, D_out)
LayerNorm 参数(D,)不跨 batch/time 混算统计
attention 前 hidden(B, T, D)后续 head reshape 保持元素顺序
gradient与 parameter 相同finite、无静默广播

注意:上表的 embedding 只回答「是什么 token」,不回答「在第几个位置」。位置从哪来(sinusoidal 与学习式位置编码)在 第7章 专节展开,RoPE 留到第9章。

LayerNorm 反向传播(逐步推导)

前向(对单行、feature 维大小为 D):μ=1Dixiσ2=1Di(xiμ)2x^i=(xiμ)/ss=σ2+ϵyi=γix^i+βi。给定上游梯度 gi=L/yi,先过仿射支路(γ,β 的梯度对 batch 维求和):

Lγi=batchgix^i,Lβi=batchgi,hiLx^i=giγi.

xj 的梯度沿三条路径回传:

  1. 分子直达x^j 的分子含 xj,贡献 hj/s
  2. 均值路径μ 依赖整行,x^i/μ=1/s,合计 1Dsihi
  3. 方差路径σ2/xj=2D(xjμ)σ2μ 的间接依赖因 i(xiμ)=0 恒消去),再乘 s/σ2=12s,合计 xjμDs3ihi(xiμ)

三路相加并用 x^j=(xjμ)/s 化简,得紧凑形式:

Lxj=1s(hj1Dihix^jDihix^i).

直觉:第一项是上游梯度直通,第二项扣掉「整行一起平移」方向的分量,第三项扣掉沿 x^ 方向的分量——归一化把这两个方向的梯度投影掉了,这正是 LN 稳定训练的几何原因。参考实现(已在 D=8 随机输入上与中心差分对拍,dx/ 相对误差约 4e-10/2e-10):

python
import numpy as np

def layer_norm_backward(dy, x, gamma, eps=1e-5):
    """LN 反向:dy (B, D), x (B, D), gamma (D,) -> dx, dgamma, dbeta。"""
    mu = x.mean(axis=-1, keepdims=True)
    s = np.sqrt(x.var(axis=-1, keepdims=True) + eps)
    xhat = (x - mu) / s
    h = dy * gamma
    dx = (h - h.mean(axis=-1, keepdims=True)
          - xhat * (h * xhat).mean(axis=-1, keepdims=True)) / s
    return dx, (dy * xhat).sum(axis=0), dy.sum(axis=0)

仓库现状要记账:python/llm_core/blocks.py 只有 layer_norm 前向(无 γ/β 的教学版),上面的反向函数是本页参考片段而非仓库模块;结构最接近的对照是 python/llm_core/rmsnorm.pyrmsnorm_backward——少了均值路径,其余同构。

数学桥接:第2章 → 第6章

第2章 §7 的 gradient_descent_step(param, grad, lr) 实现了最简参数更新:θθηL(θ)。本章的 SGD 和 AdamW 都是这个公式的扩展——SGD 每次用当前 batch 的梯度做一步,AdamW 额外维护一阶矩(动量)和二阶矩(自适应步长)。

第2章的 momentum_update(velocity, grad, lr, beta) 返回 (vnew,Δθ),其中 vnew=βv+g。这正是 AdamW 的 m^t=β1m^t1+(1β1)gt 的最简版本(忽略偏差修正)。

第2章 §8 的 relu(x) = \max(0, x)$ 和 mlp_layer_forward 在本章里被张量化:X @ W + b 把第2章的标量链式法则推广到矩阵,activation逐元素作用于整个向量。LayerNorm 的反向三路(分子直达 / 均值路径 / 方差路径)就是第2章partial_derivative` 对每个输入维度的同时求值。

前端类比:AdamW 的 m^/(v^+ϵ) 就像 CSS 的 clamp()——对每个维度独立做"梯度压缩",陡峭方向步子自动变小,平坦方向步子相对变大。

交互观察

交互:AdamW vs SGD

损失面是个「峡谷」(y 方向比 x 方向陡 12 倍)。看两个优化器怎么下山。

SGD — 终点 loss 0.00054(60 步)
AdamW — 终点 loss 0.01160(60 步)
最优点 (0, 0)
$$m_t=\beta_1 m_{t-1}+(1-\beta_1)g_t,\quad v_t=\beta_2 v_{t-1}+(1-\beta_2)g_t^2$$ $$\theta_t=\theta_{t-1}-\eta\left(\frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon}+\lambda\theta_{t-1}\right)$$

教学要点(红旗实验):把 lr 拉到 0.15 以上,SGD 会在陡峭方向剧烈震荡甚至发散, 而 AdamW 因为除以 √v̂ 自动缩小了陡峭方向的步长,依然稳定。 再把 β₂ 调到 0.5 —— AdamW 也开始抖了,这说明二阶动量的「记忆长度」才是它稳的原因。

在同一条病态二次损失「峡谷」上对比两条轨迹:SGD 在陡峭方向来回震荡,AdamW 按维度自适应缩放步长、更快对准谷底;把 weight decay 调大,观察 AdamW 的解耦衰减与 SGD 的 L2 等价写法何时分岔。浏览器演示只建立直觉,参数更新证据仍以下方 pytest 与 diagnostics 实测为准。

从零实践

  1. 对一个小矩阵手算 XW+b、tanh/ReLU 和最后一轴 normalization,写出每个中间 shape。

  2. 用有限差分或 autograd 对拍关键张量反向;记录最大绝对误差和相对误差。

  3. 对比不同初始化与 learning rate,绘制 activation/gradient 的最小统计,不用一次 loss 下结论。

  4. 当前入口与基础测试:

    bash
    PYTHONPATH=python python -m pytest python/tests/test_blocks_opt.py -q
    PYTHONPATH=python python -m pytest python/tests/test_transformer_contract.py -q
    PYTHONPATH=python python -m pytest python/tests/test_diagnostics.py -q

当前 blocks.pyoptimizer.pytransformer.pytensor_grad.py 已有可执行入口;linear_backward 与 CE gradient 提供显式 tensor 反传对拍。diagnostics.pytest_diagnostics.py 现在覆盖两类可观测训练失败:饱和/消失梯度,以及爆炸/非有限梯度。固定合成快照的实测信号分别为 saturation fraction 1.0、gradient RMS 1e-8,以及 gradient RMS 1290.9944487358057、non-finite count 1;这证明诊断分类器可复现,不把单层或合成快照冒充完整训练稳定性,clean-room shape contract 仍是独立门禁。

故障注入与预期信号

注入预期失败信号修复后证据
ReLU 负区导数写成 1gradcheck 在负输入处失败分段有限差分通过
AdamW 把 weight decay 混入梯度参数范数和 loss 轨迹偏离 fixture固定 seed 的更新轨迹对拍

论文与延伸

前端/Agent 迁移

shape contract 类似 TypeScript 类型契约:它应在边界处失败,而不是让错误广播到最后。训练 telemetry 类似状态机的 transition metrics;Agent 也要记录每次状态更新的输入、输出和不变量,避免只看最终文本。

口述与自测(不看资料,5–10 分钟)

  • 指出 linear、cross entropy 与 LayerNorm 的输入/输出/梯度 shape;任选一处广播,解释错误轴为何会让数值看似能运行却语义错误。
  • 对比饱和/消失与爆炸/非有限梯度的 telemetry 信号,并说明初始化、normalization、学习率或裁剪分别为何是不同层面的修复。
  • 用第2章的 gradient_descent_step(5.0, 2.0, lr=0.1) = 4.8 手算一步 SGD 更新;解释 η=0.5 时为什么可能 diverges。
  • 用第2章的 momentum_update 手算例子(v₀=0, grad=2, β=0.9, η=0.01)说明第一次更新为什么等于 SGD,后续更新为什么带惯性。

实验与参考

动手实验

从标量自动微分跨到张量自动反向:验证张量算子满足 shape 契约(前向 shape 与反向梯度 shape 一致)、LayerNorm 在受控张量上把每个样本的特征维归一到均值接近 0、方差接近 1,梯度范数与激活分布在合理阈值内没有爆炸或塌缩,且 optimizer 的 step 能让参数真的朝梯度方向移动。

环境准备

bash
cd <仓库>
export PYTHONPATH="$PWD/python"

命令与预期输出

bash
# 串行跑两个套件,便于对照阈值(test_tensor_grad.py / test_diagnostics.py 已在「从零实践」跑过)
python -m pytest python/tests/test_tensor_grad.py python/tests/test_diagnostics.py -q
text
shape contract: forward=向后梯度=形状一致
grad norm: 1.0xx 处于阈值 [1e-3, 1e2]
layernorm: 每行 mean≈0 var≈1,容差 1e-5

# 判定条件:
# - 所有 shape 断言通过
# - 梯度范数不接近 0 也不溢出
# - LayerNorm 后行均值绝对值 < 1e-5、方差与 1 的偏差 < 1e-5

概念图

图:第6章 前向+反向+优化完整链路 — 第2章的偏导数和优化公式在本章首次组合成可训练的完整循环:前向计算激活值 → loss → 反向求梯度 → optimizer 更新参数。后续第8章把它封装为训练循环,第9章用矩阵分解降低投影成本,第13章把"参数 ← checkpoint"类比为 Agent 状态恢复。

故障注入清单

故障表现修复
LayerNorm 在 batch 维而不是特征维归一化不同 batch size 下行为不一致,相当于把 batch 信息泄漏进单个样本明确归一化轴为特征维,并用 (B, F) 形状断言守住
LayerNorm 的 eps 取 0输入某行方差恰好为 0 时出现 NaNeps 设为 1e-5 量级,并对 NaN/Inf 单独做断言
权重初始化方差过大前向激活饱和,反向梯度爆炸,grad norm 超阈值初始化按 fan-in 缩放(如 Xavier/Kaiming),并在 diagnostics 中加告警
广播导致 shape 静默错位表面测试通过,但梯度与参数 shape 不匹配,下一次 step 立即报错关键算子用 assert shape == expected 显式断言,禁用隐式广播
优化器 step 后没有同步到参数对象loss 不下降,看起来"卡在某个值"step 之后显式回写,并在受控数据集上验证 loss 单调下降

资源 / 成本 / 隐私

小矩阵和 CPU/NumPy 足够完成本章对拍,预计 gross cost 为 0;不需要 API 或外部数据。日志只保留 shape、误差和计数,不保留用户输入。

Evidence

仓库当前机器证据(只读快照)

evidence/module-manifest-v1.json06.evidence 指向当前文件:evidence/06-runtime-v1.json。这是当前 checkout 的脱敏机器运行记录,只覆盖该 JSON 记录的命令、指标、产物和已知失败;它不是学习者提交,也不能推出学习者已完成本章。

学习者提交模板(待填写,不是当前机器证据)

复制下面模板并填写自己的真实运行结果。所有 <...> 都是未填写状态;actualartifacts 尤其不能被当作已运行或已通过。artifacts 必须替换为本次提交中真实存在的仓库相对路径。

yaml
schema: learn-llm.evidence.v1
module: 06-tensor-stability
commit: <learner-commit-sha>
verified_at: <iso-date>
environment: <sanitized-python-device>
seed: 4
commands:
  - PYTHONPATH=python python -m pytest python/tests/test_blocks_opt.py python/tests/test_tensor_grad.py -q
  - PYTHONPATH=python python -m pytest python/tests/test_transformer_contract.py -q
  - PYTHONPATH=python python -m pytest python/tests/test_diagnostics.py -q
metrics:
  - name: gradient_relative_error
    expected: <versioned-threshold>
    actual: <recorded-value>
  - name: non_finite_activation_count
    expected: 0
    actual: <recorded-value>
  - name: activation_saturation_fraction
    expected: 1.0 on saturation fixture
    actual: <recorded-value>
  - name: exploding_gradient_rms
    expected: >=100 on exploding fixture
    actual: <recorded-value>
artifacts:
  - <learner-repo-relative-artifact-path>
cost:
  gross_usd: 0
  credit_usd: 0
licenses:
  - source: <source>
    version: <version>
    license: <license>
    attribution: <attribution>
    redistribution: <redistribution>
known_failures:
  - <sanitized-failure-or-none>

只有统计轴、反向对拍、两类故障诊断和 learner clean-room shape contract 都具备独立证据时,本章才能离开 gate

私有学习站 · 原理从零构建 · 勿提交个人隐私或密钥