# 第 5 讲 · 深度学习框架与自动微分 · Deep Learning Frameworks and Auto Differentiation

> **对应 Maps to**：CMU 11-868 / 11-968 *Large Language Model Systems*（Spring 2026，Lei Li），Lecture 5 · Wed 1/28 · **Deep Learning Frameworks and Auto Differentiation**。
> **阅读材料**：Baydin et al. (2018) *Automatic Differentiation in Machine Learning: a Survey*（JMLR）；Blondel & Roulet (2024) *The Elements of Differentiable Programming*；Abadi et al. (2016) *TensorFlow: A System for Large-Scale Machine Learning*（OSDI）；原课 HW2 说明（miniTorch：自动微分、网络、训练）。
> **依据说明**：本讲以原课第 5 讲公开的 slides（llmsys-05-dlframework.pdf，57 页）为主线：学习问题与交叉熵、通用的迭代学习算法与梯度下降、计算图与拓扑排序、slides 的例子 f = x₁ + exp(1.5x₁ + 2x₂) 的前向与反向、扇出求和、雅可比矩阵与向量–雅可比积、"构建梯度计算图"的自动微分、有限差分检查、框架的设计原则与 PyTorch / TensorFlow / JAX / NumPy 的对比、TensorFlow 式的占位符 / 变量 / 运算 / 会话、拓扑排序与会话的实现（课上的 mini_tensorflow 练习）。**前向模式与对偶数、运算次数与显存的计量、"不按拓扑序"和"沿路径递归"的反例、三个后端的张量框架、静态图与动态图的对照实验、用自己的框架训练 HW2 的网络，都是本讲义自己设计的，不代表课堂上讲了什么**。
> **配套代码 Code**：[`code/lecture05/`](../code/lecture05/)（6 个脚本 + `autodiff.py`、`tensor.py`、`mini_tf.py`；每个脚本 3 秒以内）
> **预计阅读时间**：4–5 小时（含运行代码）

---

## 0. 本讲导航 · How to read this lecture

### 0.1 学习目标 Learning goals

学完这一讲，你应该能够：

1. 从"通用的迭代学习算法"推出梯度下降，说清楚训练神经网络为什么归结为"**对任意网络算出每个参数的梯度**"。
2. 把一个表达式画成**计算图**，用**拓扑排序**安排计算顺序，并手算反向模式的**伴随**（adjoint），包括"一个节点被多次使用时梯度相加"。
3. 区分**前向模式**和**反向模式**自动微分，说出它们各自的代价（遍数、运算次数、显存），解释深度学习为什么用反向模式、为什么框架只算**向量–雅可比积**。
4. 写出原课 HW2 的 `topological_sort` 和 `backpropagate`，解释为什么必须按拓扑序、为什么不能沿路径递归、为什么要用显式栈。
5. 说出一个深度学习框架的组成：**存储与视图、可替换的后端（算子库）、运算的前向 / 反向、自动微分引擎、nn 模块与优化器**；区分**静态图**（先定义后执行）和**动态图**（边写边算）的取舍。
6. 用**有限差分**正确地检查梯度（精度和步长怎么选）。

### 0.2 先修与本讲范围 Prerequisites & scope

| 默认你已经会 Assumed | 本讲只给直觉，后面再展开 Previewed only |
|---|---|
| 导数与链式法则；梯度下降、交叉熵、Adam（[前置讲 P3](prereq3_neural_network_crash_course.md)） | JAX 的函数变换（grad、jit、vmap）与 XLA 编译（第 12 讲） |
| 张量 = 存储 + 形状 + 步长（P1 §5）；第 2–4 讲的四类底层运算与内核 | 算子融合、图编译器（第 10–11 讲）；torch.compile |
| P3 里手写过的文本分类网络的反向传播 | 激活重算等显存优化（大模型训练的几讲） |

### 0.3 本讲地图 Map

```
§1  学习问题：从"迭代地减小损失"到梯度下降；为什么需要对任意网络求梯度
§2  计算图与拓扑排序：slides 的例子                                       ← 代码 01
§3  自动微分：反向模式的伴随、扇出、雅可比矩阵与 VJP、前向 vs 反向、       ← 代码 01、02、03
    HW2 的 topological_sort 与 backpropagate、梯度检查
§4  把它们拼起来：一个深度学习框架                                         ← 代码 04、05
    框架要满足什么；分层设计；张量与后端；静态图（TensorFlow 1）vs 动态图（PyTorch）
§5  用自己的框架训练 HW2 的网络                                            ← 代码 06
§6  小结   §7  常见误区   §8  练习   §9  延伸阅读   §10 下一讲预告
```

---

## 1. 学习问题 · The learning problem

### 1.1 从文本分类网络说起

slides 又一次回到了那个影评分类网络（第 2 讲 §1）：嵌入查表 → 平均 → 线性 → ReLU → 线性 → softmax。第 2–4 讲关心的是"前向怎样在 GPU 上算得快"；这一讲关心另一半：**怎样训练它**。

学习问题（slides 第 5 页）：给定训练集 $\{(x_n, y_n)\}_{n=1}^N$（x、y 都可以是向量），在网络结构固定的前提下，找到参数 θ，使网络对每个训练输入的输出尽量接近对应的 y。分类任务的损失是交叉熵：

$$
\mathcal{L}(\theta) = \frac{1}{N}\sum_{n=1}^{N} -\log p_\theta(y_n \mid x_n)
$$

slides 特意提到，PyTorch 的 `CrossEntropyLoss` 直接作用在 logits 上（内部把 log-softmax 和负对数似然合在一起算），而不是先算 softmax 再取对数——这是数值稳定性的考虑（P3 §2）。

然后 slides 提出了本讲的核心问题：

```python
loss = nn.CrossEntropyLoss()
output = loss(input_logits, target_labels)
output.backward()        # backward 是怎样实现的？它为什么对任意网络都管用？
```

### 1.2 通用的迭代学习算法

slides 第 8–10 页从最一般的形式讲起：要最小化 $f(x)$，$x \in \mathbb{R}^d$ 未知。迭代地更新 $x_{t+1} \leftarrow x_t + \Delta$，使 $f(x_{t+1}) < f(x_t)$。Δ 怎么选？一阶泰勒展开：

$$
f(x_t + \Delta x) \approx f(x_t) + \Delta x^\top \nabla f(x_t)
$$

要让右边第二项尽量小（负得最多），Δx 应该和梯度方向相反：$\Delta x = -\eta \nabla f(x_t)$，η 是学习率。这就是梯度下降。随机梯度下降（SGD）每次只用一小批数据估计梯度：

```
设置学习率 η，初始参数 θ ← θ₀
for epoch = 1 .. maxEpoch（或直到收敛）:
    for 每一批数据:
        total_g = 0
        for 批里的每个 (x, y):
            计算误差 err(f(x; θ), y)
            计算梯度 g = ∂err/∂θ          ← 本讲要解决的就是这一行
            total_g += g
        θ = θ − η · total_g / N
```

整个算法里，只有"计算梯度"这一行和具体的网络有关。P3 里我们为文本分类网络手写了每一层的反向传播——几十行，而且网络一改就要重写。slides 第 11 页的问题是：**怎样对一个"任意的网络"，自动算出损失对每个参数的梯度？** 答案分两步：把计算表示成**计算图**（§2），在图上做**自动微分**（§3）。

---

## 2. 计算图与拓扑排序 · Computation graph

### 2.1 计算图

slides 第 13 页：神经网络上的计算可以用**计算图**定义——每个节点表示一个变量或一个运算，有向边把输入连到运算上。slides 的例子（01 号脚本第 1 部分）：$x_1 = 3, x_2 = 0.5$，

$$
f = x_1 + \exp(1.5 x_1 + 2.0 x_2)
$$

| 节点 | 定义 | 值 |
|---|---|---|
| v1 | x₁ | 3 |
| v2 | x₂ | 0.5 |
| v3 | 1.5 · v1 | 4.5 |
| v4 | 2.0 · v2 | 1.0 |
| v5 | v3 + v4 | 5.5 |
| v6 | exp(v5) | 244.691932 |
| v7 | v1 + v6 | 247.691932 |

注意 v1 被用了两次（v3 和 v7 都以它为输入）：计算图不是一棵树，而是**有向无环图**（DAG）。这一点在反向时至关重要。

### 2.2 拓扑排序：按什么顺序算

要计算（slides 第 14 页）：1. 对所有节点做**拓扑排序**；2. 按顺序，用每个节点的输入算出它的值。slides 第 15 页给出的排序方法：把所有节点放进"未处理"队列，反复找一个**没有来自未处理节点的输入边**的节点，计算它，移到"已处理"队列。01 号脚本照此实现，得到 v1 → v2 → v3 → v4 → v5 → v6 → v7。

为什么这很重要？因为它让框架**不需要理解整个函数**——只要会算每一种基本运算，按拓扑序依次算下去，任意复杂的表达式都能求值。同样的道理在反向时更加关键。

---

## 3. 自动微分 · Auto differentiation

### 3.1 链式法则与伴随

slides 第 20 页：参数也是变量，也是图里的节点；求损失对参数的梯度，靠的是链式法则：

$$
\frac{\partial f(g(x))}{\partial x} = \frac{\partial f}{\partial g}\cdot\frac{\partial g}{\partial x}
$$

定义每个节点的**伴随**（adjoint）$\bar v_i = \partial f / \partial v_i$。从输出开始，$\bar v_7 = 1$，一步步往回传（01 号脚本第 2 部分）：

| 伴随 | 怎么算 | 值 |
|---|---|---|
| v̄7 | 1 | 1 |
| v̄6 | v̄7 · ∂v7/∂v6 = v̄7 · 1 | 1 |
| v̄5 | v̄6 · ∂v6/∂v5 = v̄6 · exp(v5) | 244.691932 |
| v̄4 | v̄5 · 1 | 244.691932 |
| v̄3 | v̄5 · 1 | 244.691932 |
| v̄2 | v̄4 · 2.0 | 489.383865 |
| v̄1 | v̄3 · 1.5 **+ v̄7 · 1** | 368.037898 |

手算、我们的标量引擎 [`autodiff.py`](../code/lecture05/autodiff.py)、PyTorch autograd、解析式（1 + 1.5e^5.5、2e^5.5）四者完全一致：∂f/∂x₁ = 368.0378983963，∂f/∂x₂ = 489.3838645284。

两个要点：

- **每一步只用到一个节点的局部导数**（exp 的导数是它自己，乘以常数的导数是那个常数）和它下游传来的伴随。框架里每种运算只需要实现自己的局部导数。
- **扇出求和**（slides 第 23 页）：一个节点有多个使用者时，$\bar v_i = \sum_{j \in \text{使用者}} \bar v_j \cdot \partial v_j / \partial v_i$。v1 有两个使用者（v3 和 v7），所以 v̄1 是两项之和；漏掉 v̄7 · 1 就会得到 367.0379——错。这意味着：**一个节点的伴随，要等它所有的使用者都处理完才完整**。这就是反向也要按拓扑序（倒过来）的原因。

### 3.2 向量的情形：雅可比矩阵与 VJP

slides 第 24–26 页把标量推广到向量。y = f(x)（x ∈ Rⁿ、y ∈ Rᵐ）的偏导数排成**雅可比矩阵** $J = \partial y/\partial x$（m × n，第 i 行是 yᵢ 对所有 x 的导数）。链式法则变成

$$
\bar x = J^\top \bar y
$$

叫**向量–雅可比积**（vector-Jacobian product，VJP）。例子：y = Wx 时 J = W，所以 $\bar x = W^\top \bar y$——一次矩阵乘法，**不需要把 J 写出来**。02 号脚本第 4 部分验证了两种情形：y = Wx 时反向得到的 x̄ 与 Wᵀȳ 差 0；逐元素的 sigmoid 雅可比矩阵是对角的，VJP 就是 $\bar s \odot \sigma(1-\sigma)$（差 2.8e-17）。

为什么框架从不构造雅可比矩阵？看尺寸：一个 4096 维向量的雅可比矩阵要 0.134 GB（float64）；一层 Transformer 输出（2048 个 token × 4096 维 = 838 万个数）对输入的雅可比矩阵要 **5.6 × 10⁵ GB**，而 VJP 只需要一个 67 MB 的向量。**每个运算的 backward 实现的正是它自己的 VJP**——tensor.py 里每个 `Function.backward` 都是这样。

### 3.3 前向模式 vs 反向模式

链式法则可以从两头算起。**前向模式**和函数值一起往前算：每个中间量带着"对某一个输入的导数"。最优雅的实现是**对偶数** $a + b\varepsilon$（$\varepsilon^2 = 0$）：$f(a + b\varepsilon) = f(a) + f'(a)\,b\,\varepsilon$，把对偶数代进函数，ε 的系数自动就是导数。02 号脚本第 1 部分：对 slides 的函数，前向模式第一遍（x₁ 的 ε = 1）得到 ∂f/∂x₁、第二遍得到 ∂f/∂x₂；反向模式一遍前向 + 一遍反向，两个都得到。

代价对比（02 号脚本第 2 部分，n 个参数的逻辑回归损失，8 个样本）：

| n | 前向模式的运算次数（n 遍） | 反向模式的运算次数（1 遍前向 + 1 遍反向） | 前向 ÷ 反向 |
|---|---|---|---|
| 4 | 448 | 224 | 2.0 |
| 16 | 4,864 | 608 | 8.0 |
| 64 | 68,608 | 2,144 | 32.0 |
| 256 | 1,060,864 | 8,288 | 128.0 |

一遍前向本身就和 n 成正比；反向模式的总代价约为两遍前向，前向模式要 n 遍——表里的比值正好是 n/2。一个 LLM 有几十亿个参数，**前向模式完全不可行**。

推广到 Rⁿ → Rᵐ（02 号脚本第 3 部分）：前向模式一遍得到雅可比矩阵的一列，要 n 遍；反向模式一遍得到一行，要 m 遍。16 → 1 时反向更省，1 → 16 时前向更省。**神经网络训练永远是"参数多、损失一个"**，所以框架以反向模式为主；前向模式在 JAX 里也有（`jvp`），用于方向导数、Hessian–向量积等场合（第 12 讲）。

**反向模式的代价：显存。** 前向模式的导数跟着值一起走，用完就扔；反向模式要等前向全部算完才开始往回传，所以前向的中间结果（反向要用的那些）必须**存下来**。02 号脚本第 5 部分统计了一个 MLP（批 32、宽 64）反向需要额外保存的中间结果：每层两份 B × 宽度（矩阵乘法的输入，算权重梯度 Xᵀ·Ȳ 要用；ReLU 的输入，判断哪里放行），共 2·层数 − 1 份，随层数和批大小线性增长。**这就是第 1 讲 §5.5 训练显存里"激活值"那一项的来源**。推理时不求梯度，就不需要存（PyTorch 的 `torch.no_grad()`）；显存不够时，可以只存一部分、反向时重算其余的（激活重算），用时间换显存。

### 3.4 自动微分 = 为梯度构建计算图

slides 第 28–30 页给自动微分下的定义：不是对每个数据样本显式地往回算导数，而是**为每个节点构建计算梯度的图**，这张图对任何输入数据都适用。slides 第 30 页画出了 slides 例子的"梯度图"：v̄5 = v̄6 × exp(v5)、v̄1 = v̄3 × 1.5 + v̄7……每个伴随都是由乘法、加法等运算节点算出来的。

这有两种实现风格，本讲都实现了：

- **符号式**（TensorFlow 1、[`mini_tf.py`](../code/lecture05/mini_tf.py) 的 `gradients`）：真的往图里添加计算梯度的新节点，图建好之后再执行（§4.4）。
- **基于记录**（tape-based，PyTorch、[`autodiff.py`](../code/lecture05/autodiff.py) 和 [`tensor.py`](../code/lecture05/tensor.py)）：前向时每做一次运算就记下"谁由谁、用哪个函数算出来"（`History`），反向时沿着记录直接算出梯度的数值。

两者算的是同一个东西（05 号脚本第 3 部分：50 步训练的损失与 PyTorch 差 1.1e-16）。

### 3.5 HW2 的核心：topological_sort 与 backpropagate

原课 HW2 Problem 1 要求实现两个函数（slides 第 31–33 页标注"对 HW2 很重要"）：

```python
def topological_sort(variable):
    """返回计算图的反向拓扑序：输出在最前，每个节点排在它的所有输入之前。
       提示：后序深度优先搜索；访问完一个节点的所有子节点后，把它放到结果的最前面。"""

def backpropagate(variable, deriv):
    """在计算图上做反向传播，把导数累加到叶子节点上。"""
```

它们只依赖一个很小的"变量协议"：`unique_id`、`is_leaf()`、`is_constant()`、`parents`、`chain_rule(d)`（返回每个输入的梯度贡献）、`accumulate_derivative(d)`。`autodiff.py` 的实现：

```python
def backpropagate(variable, deriv):
    order = topological_sort(variable)          # 反向拓扑序
    grads = {variable.unique_id: deriv}
    for v in order:                             # 轮到 v 时，它的所有使用者都已处理完
        d = grads.pop(v.unique_id, None)
        if d is None: continue
        if v.is_leaf():
            v.accumulate_derivative(d); continue
        for p, dp in v.chain_rule(d):           # 链式法则：把梯度分给输入
            if not p.is_constant():
                grads[p.unique_id] = grads.get(p.unique_id, 0) + dp     # 扇出求和
```

03 号脚本检验了它，并用三个反例说明每个设计决定为什么必要：

**它对任意图都对。** 50 个随机生成的计算图（平均 22.8 个节点、9.7 个被多次使用的值），与中心差分的最大相对误差 3.7e-10（有限差分本身的误差）。

**必须按拓扑序。** 换成"从输出开始宽度优先、每个节点第一次被碰到就把手里的梯度往回传"：x₁ 作为输出的直接输入很早就被碰到，此时它只收到了来自 v7 的那一份，来自 v3 的那一份还没到——∂f/∂x₁ 被算成 **1.0**（正确值 368.04）。200 个随机图里 189 个（94%）出错。

**不能沿路径递归。** 不排序、直接沿每条路径递归往回传，结果是对的——链式法则本来就是"对所有路径求和"——但代价是路径数。每层把上一层用两次的链：

| 层数 | 路径数 | 拓扑序：chain_rule 调用次数 | 沿路径递归：调用次数 |
|---|---|---|---|
| 4 | 16 | 12 | 45 |
| 8 | 256 | 24 | 765 |
| 16 | 65,536 | 48 | 196,605 |

拓扑序让每个节点只处理一次（线性），递归会把共享的子图处理很多遍（指数）。真实网络里扇出无处不在：残差连接、注意力里同一个 x 生成 Q、K、V、参数共享。

**要用显式栈。** HW2 提示的"后序深度优先"如果直接写成递归，在一条 3000 层的链上会抛 `RecursionError`（Python 默认递归上限约 1000 层）；`autodiff.topological_sort` 用显式栈，正确地得到 ∂h/∂x = 1.0002³⁰⁰⁰ = 1.8220094911。真实框架（PyTorch 的 C++ autograd 引擎）也用显式的队列和依赖计数，而不是递归。

**梯度是累加的。** 叶子用 `+=` 累加梯度：同一个 loss 不清零地 backward 两次，w 的梯度从 20 变成 40。所以训练循环每一步都要先清零（`optimizer.zero_grad()`）。累加也有正当用途：显存放不下大批量时，分成几个小批依次 backward、梯度自然累加，最后再更新一次（梯度累积）。常量（数据、标签、`requires_grad=False` 的张量）不进入计算图，反向时直接跳过。

### 3.6 怎样检查梯度

slides 第 34 页：用有限差分检查梯度，

$$
\frac{\partial f(x_1, x_2)}{\partial x_1} \approx \frac{f(x_1 + h, x_2) - f(x_1 - h, x_2)}{2h}
$$

并且**注意精度**：用 double（fp64），h 取 1e-6 左右，通过整个图算两次前向。01 号脚本第 3 部分说明了为什么：

| h | float64 相对误差 | float32 相对误差 |
|---|---|---|
| 1e-1 | 3.7e-03 | 3.7e-03 |
| 1e-2 | 3.7e-05 | 2.6e-05 |
| 1e-4 | 3.7e-09 | 1.6e-03 |
| 1e-6 | **1.5e-10** | 6.7e-02 |
| 1e-8 | 3.6e-08 | x ± h 在 float32 里与 x 相等 |

h 太大有**截断误差**（中心差分是 O(h²)），h 太小有**舍入误差**（两个几乎相等的数相减，误差约为 机器精度 × |f| / h）。两者此消彼长：float64 的机器精度约 1e-16，最好的 h 在 1e-5 附近；float32 的机器精度约 1e-7，最好的 h 在 1e-2 附近，而且误差下限高得多。有限差分每个参数要算两次前向，只能用来**检查**，不能用来训练。

slides 的小测是一张 sigmoid 的计算图：v7 = 1 / (1 + exp(−v1·v2))。把它拆成乘、取负、exp、加 1、取倒数五个基本运算，链起来就得到 σ(1 − σ) 的公式（01 号脚本第 4 部分：引擎算出的 ∂v7/∂v1 = −0.26684166 = σ(1−σ)·v2）。框架里不需要写"sigmoid 的导数"——但实际框架仍然会把 sigmoid 做成一个运算：一个节点比五个节点少存中间结果、少调度四次内核，数值也更稳定。**基本运算的粒度**是框架设计里的一个权衡。

---

## 4. 把它们拼起来：一个深度学习框架 · Putting it together

### 4.1 框架要满足什么

slides 第 37 页列出了深度学习框架（也包括 LLM 的框架）的目标：

- **表达力强**：能描述任意神经网络，支持将来的自定义算子和层；
- **对机器学习工程师高效**：隐藏底层细节（不用写 CUDA），自动微分（不用手推梯度）；
- **在大规模训练和推理中高效**：自动扩展到数据和模型的规模，自动利用硬件加速。

slides 第 38 页的对比：

| | PyTorch | TensorFlow | JAX | NumPy |
|---|---|---|---|---|
| 主要用途 | 深度学习 | 深度学习 | 数值计算与机器学习 | 数值计算 |
| 编程范式 | 动态（即时执行） | 静态（图模式，也支持即时执行） | 函数变换 | 过程式 |
| 自动微分 | 动态计算图 | 静态计算图 | 基于函数的 grad / jit | 没有 |
| 硬件 | CPU、GPU、TPU | CPU、GPU、TPU | CPU、GPU、TPU | 只有 CPU |
| 易用性 | Pythonic | 有一定学习曲线 | Pythonic、函数式 | 非常简单 |
| 并行 | DataParallel / DDP | tf.distribute | pmap | 无 |

### 4.2 分层设计

本讲的 [`tensor.py`](../code/lecture05/tensor.py) 是一个约 800 行（一半以上是注释）的"NumPy 版 miniTorch"，结构就是一个真实框架的缩影：

```
 nn / optim   : Module、Parameter、Linear、Embedding、SGD、Adam            ← 写模型、训练的人
 Tensor       : 运算符重载、计算图（History）、backward()                 ← 自动微分（复用 autodiff.py 的两个函数）
 Function     : 每个运算的 forward / backward（局部导数 / VJP）
 后端 Ops     : map、zip、reduce、matmul 四类底层运算                      ← 第 2–4 讲的内核在这一层
                PythonOps（纯 Python 循环）/ NumpyOps / SimtOps（第 3 讲的模拟器内核）
 TensorData   : 一维存储 + 形状 + 步长                                      ← P1 §5
```

每一层只管自己的事：`Function` 只写局部导数；`backpropagate` 不知道有哪些运算；模型代码不知道后端是谁。几个细节：

- **存储与视图**（04 号脚本第 1 部分）：`permute` 只交换形状和步长，和原张量共用存储；`view` 要求连续存放，对转置后的张量直接 `view` 会报错，要先 `contiguous()` 拷一份——和 PyTorch 的规则一模一样。
- **后端可替换**（04 号脚本第 2 部分）：同一个运算在三个后端上，纯 Python 循环和 NumPy 在 map / zip / reduce 上逐位相同、矩阵乘法差 2.7e-15（BLAS 换了求和顺序），SIMT 模拟器后端（float32）差 1e-7 到 1e-6 量级。一个两层网络的前向，模型代码一行不改、只换后端，输出差 4.8e-08。⏱ NumPy 最快；纯 Python 慢一个数量级左右（张量只有 512 个元素）；模拟器再慢一到两个数量级。**真实框架的 GPU 后端，就是把这一层换成第 2–4 讲那样的 CUDA 内核**——原课 HW1 和 HW2 正是这样分工的。
- **每个运算的梯度都对**（04 号脚本第 3 部分）：15 个运算（含广播的加法和乘法、批量矩阵乘法、permute、view、embedding）与 PyTorch 的梯度最大差 8.9e-16。
- **广播的反向**（04 号脚本第 4 部分）：偏置 b（形状 (h,)）加到 (B, h) 上，前向时 b 被"复制"到每一行，相当于被用了 B 次；扇出求和，所以 b 的梯度是上游梯度**沿批求和**。tensor.py 在 `chain_rule` 里统一处理：梯度形状比输入大时自动求和缩回（`_sum_to`）。
- **Embedding 的反向是 scatter-add**：同一个词在一批里出现几次，它那一行的梯度就加几次（`np.add.at`；P3 §4 讲过 `G[ids] += g` 的坑）。

### 4.3 静态图：TensorFlow 1 的设计

slides 第 39 页给出了 TensorFlow 1 的设计原则：

- 用**基本运算组成的数据流图**（计算图）表示程序；
- **延迟执行，分两个阶段**：① 定义程序——搭一张带占位符的符号式数据流图；② 在可用的设备上执行（优化过的）程序。

基本组成（slides 第 40–49 页）：

| 组成 | 作用 | 例子 |
|---|---|---|
| 占位符 Placeholder | 存放输入数据、标签，值在执行时提供 | `x = tf.placeholder(tf.float32, (1, 784))` |
| 变量 Variable | 有状态的节点，值在多次执行之间保留——通常是参数 | `W = tf.Variable(tf.random_uniform((784, 100), -1, 1))` |
| 常量 Constant | 固定的数据 | |
| 运算 Operation | 每个网络层的数学运算，要定义前向和反向 | `h = tf.nn.relu(tf.matmul(x, W) + b)` |
| 会话 Session | 执行环境：对节点拓扑排序，依次计算 | `with tf.Session() as s: s.run(...)` |

[`mini_tf.py`](../code/lecture05/mini_tf.py) 实现了这一套（对应课上的 mini_tensorflow 练习）。05 号脚本第 1 部分用 slides 的例子：搭完 h = relu(x·W + b) 之后，图里有 6 个节点，**一个数都没算**；`sess.run(h, {x: 数据})` 才按拓扑序算了 6 个节点；`sess.run(x·W, …)` 只算它依赖的 3 个；没喂 x 时报错。slides 第 52 页描述的会话实现正是这样：占位符从 feed_dict 取值，变量和常量用自己的值，运算节点取输入的值再调用 forward。

**符号式求导**（05 号脚本第 2 部分）：逻辑回归的前向图 22 个节点；`tf.gradients(loss, [W, b])` 不计算任何数值，只是按反向拓扑序对每个运算节点调用它的 `gradient()`，往图里添加了 39 个节点（其中 14 个是处理广播的 sum_to_shape）。会话执行这些节点，得到的梯度与 PyTorch 差 3.5e-17。`GradientDescentOptimizer(lr).minimize(loss)`（slides 第 48 页）在此基础上再加上"更新变量"的节点，返回一个训练步节点。

### 4.4 静态图 vs 动态图

05 号脚本第 3 部分用三种方式训练同一个逻辑回归（全批量梯度下降，学习率 0.5，50 步）：

| 步 | 静态图 mini_tf | 动态图 tensor.py | PyTorch |
|---|---|---|---|
| 0 | 0.7287220853 | 0.7287220853 | 0.7287220853 |
| 10 | 0.2703066809 | 0.2703066809 | 0.2703066809 |
| 49 | 0.2236972273 | 0.2236972273 | 0.2236972273 |

数学完全相同（最大差 1.1e-16），差别在"什么时候建图、谁来执行"：静态图一共 68 个节点，**只建一次**，每步执行训练节点时计算其中 54 个；动态图**每一步都重新建图**（每步 16 个需要梯度的节点，50 步共 800 个，用完即弃）。

两种风格各有所长：

- **动态图擅长控制流和调试。** 05 号脚本第 4 部分：一个最简单的循环网络 $h_{t+1} = \sigma(h_t w + x_t)$。动态图里，循环就是 Python 的 for 循环，长度 3 / 6 / 12 自然建出 10 / 19 / 37 个节点的图，还可以在循环里随便 print；静态图要为每种长度各建一张图（50 / 98 / 194 个节点，含梯度），或者用图内的控制流算子（`tf.while_loop`、`tf.cond`），写起来像另一种语言。这是 PyTorch 在研究界流行、TensorFlow 2 改成默认即时执行的主要原因之一。
- **静态图擅长全局优化。** 拿到整张图，框架就能在执行前优化：05 号脚本第 5 部分的常量折叠（6 个运算里 3 个只依赖常量，可以预先算好）只是最简单的一种；更重要的是删掉没用的节点、把相邻的小运算融合成一个内核（第 10–11 讲）、规划显存复用、决定节点放在哪个设备上。TensorFlow 的 Grappler、XLA 编译器（第 12 讲）就是干这个的。

两条路线后来走到了一起：PyTorch 2 的 `torch.compile` 把动态图的 Python 代码"抓"成图再优化；TensorFlow 2 的 `tf.function` 和 JAX 的 `jit` 把函数追踪成图。**"写起来像动态图、跑起来像静态图"是今天框架的共同目标**。

---

## 5. 用自己的框架训练 HW2 的网络 · Training the HW2 network

### 5.1 自动微分 = 自动写出来的手写反向

06 号脚本第 1 部分把 P3 的文本分类网络（嵌入 16 维、隐藏层 32、2 类 softmax）用 tensor.py 只写前向，然后 `loss.backward()`：损失与 P3 手写版本完全相同，五个参数的梯度最大差 2.1e-17。P3 为这个网络手写了 linear_backward、relu_backward、embedding_backward、平均池化的反向……一共几十行；这里一行反向代码都没写。这就是 slides 说的"对机器学习工程师高效"：网络结构可以随便改，反向自动跟着变。

### 5.2 HW2 的网络

原课 HW2 Problem 2–3 要求在 miniTorch 上实现：

- `Linear`：二维矩阵权重 + 一维偏置；
- `Network`：① 对句子长度取平均；② Linear 到 hidden_dim，ReLU，Dropout；③ Linear 到 1 维；④ sigmoid；
- `binary_cross_entropy_loss` 和训练循环，在 SST-2 上达到 ≥ 75% 的验证准确率（HW2 用固定的 GloVe 词向量）。

06 号脚本第 2 部分用 tensor.py 实现了同样的网络（嵌入可训练，数据换成 P3 的影评小语料），同时用 PyTorch 写一份，复制同样的初始参数，喂同样的批次、同样的 dropout 掩码，用 Adam（学习率 0.01）训练 8 轮：

| 轮 | 训练损失（tensor.py） | 训练损失（PyTorch） | 验证准确率（两边相同） |
|---|---|---|---|
| 1 | 0.669239 | 0.669239 | 0.685 |
| 4 | 0.262635 | 0.262635 | 0.894 |
| 7 | 0.218506 | 0.218506 | 0.907 |
| 8 | 0.219203 | 0.219203 | 0.892 |

320 步里两边损失的最大差 9.0e-15——我们的框架和 PyTorch 做的是同样的计算（Adam 的公式相同、运算顺序略有不同，所以不是逐位相同）。⏱ 在这么小的网络上 tensor.py 慢 3–4 倍：张量这么小，算术几乎不花时间，差距来自每个运算在 Python 里的额外开销（建 History、包装 Tensor、分派到后端）。真实框架把这一层也用 C++ 写了——**框架本身的开销**在小模型、小批量时会成为瓶颈，这也是 CUDA Graphs、torch.compile 等技术要解决的问题之一。

分句型的验证准确率：single、negated、both 都是 1.000；contrast（"A 好但 B 差"）只有 0.457。和 P3 的结论一致：ReLU 隐藏层能学会否定，但**平均嵌入丢掉了词序**，转折句的两个从句对调之后输入完全一样，任何词袋模型都做不到。这正是第 6 讲 Transformer（注意力 + 位置编码）要解决的问题。

---

## 6. 小结 · Summary

1. 训练 = 迭代地沿负梯度方向更新参数；唯一和网络有关的一步是**算梯度**。
2. **计算图**：节点是变量或运算，边是输入关系，一般是 DAG。按**拓扑序**计算，框架只需要会算每种基本运算。
3. **反向模式**：从输出的伴随 1 开始，每个节点用局部导数把伴随分给输入；**一个节点被多次使用时梯度相加**，所以必须等它的所有使用者都处理完——反向也按拓扑序。
4. 向量情形下每个运算的反向是 **VJP**：x̄ = Jᵀȳ，从不构造雅可比矩阵（太大）。
5. **前向模式**一遍得到对一个输入的导数，**反向模式**一遍得到一个输出对所有输入的导数；参数多、损失一个，所以用反向模式。代价是要**保存前向的中间结果**——训练显存里的激活值。
6. HW2 的 `topological_sort`（后序 DFS，最好用显式栈）+ `backpropagate`（按反向拓扑序累加梯度）对任意图都对；不按拓扑序会错，沿路径递归是指数级的。
7. **梯度检查**：中心差分、float64、h 约 1e-6；只用来检查。
8. **框架 = 存储与视图 + 可替换的后端 + 运算的前向 / 反向 + 自动微分引擎 + nn / 优化器**。静态图先定义后执行，便于全局优化；动态图边写边算，便于控制流和调试；今天的框架用编译（torch.compile、jit）把两者结合。

## 7. 常见误区 · Common pitfalls

- **"反向传播就是对每个参数手推一个公式。"** 框架只知道每种基本运算的局部导数；整个网络的梯度由链式法则在图上自动拼出来。
- **"计算图是一棵树，每个节点的梯度只有一个来源。"** 计算图是 DAG，扇出的节点要把所有使用者传来的梯度相加。
- **"从输出开始随便按什么顺序往回传都行。"** 必须保证轮到一个节点时它的所有使用者都处理完了（反向拓扑序）；宽度优先、"第一次碰到就传"会漏掉贡献。
- **"沿每条路径递归往回传，结果对就行。"** 结果对，代价是路径数（指数级）。
- **"反向模式的代价和参数个数无关。"** 和参数个数无关的是**遍数**；一遍前向本身的代价仍然随网络大小增长。反向模式真正的额外代价是存中间结果的显存。
- **"框架会算出每一层的雅可比矩阵。"** 只算向量–雅可比积；一层 Transformer 的雅可比矩阵根本存不下。
- **"有限差分用 float32、h 越小越准。"** h 太小会被舍入误差淹没；要用 float64、h 约 1e-6。
- **"backward 两次没关系。"** 叶子的梯度是累加的，每一步都要先清零（除非有意做梯度累积）。
- **"偏置的梯度和输出梯度形状一样。"** 广播过的输入，梯度要沿被广播的维度求和缩回原形状。
- **"静态图过时了，动态图全面胜出。"** 两者各有所长；今天的主流是用编译把动态图的代码转成图来优化。

## 8. 练习 · Exercises

1. f(x) = (x − 3)²，从 x₀ = 0 出发、学习率 η = 0.1 做一步梯度下降，x₁ 是多少？
2. 画出 f = (x₁ + x₂) · exp(x₁) 的计算图，在 x₁ = 1、x₂ = 2 处用反向模式算出 ∂f/∂x₁ 和 ∂f/∂x₂。
3. f = x · x · x 写成 v2 = v1 · v1、v3 = v2 · v1。在 x = 2 处用伴随算出 ∂f/∂x，并指出哪里用到了"扇出求和"。
4. y = W x，W = [[1, 2], [3, 4]]，上游梯度 ȳ = [1, −1]。x̄ 是多少？
5. 函数 f: R¹⁰⁰⁰ → R 和 g: R → R¹⁰⁰⁰，求完整的雅可比矩阵，前向模式和反向模式各要几遍？
6. 用 slides 的例子解释：为什么"从输出开始宽度优先、第一次碰到就往回传"会把 ∂f/∂x₁ 算成 1？
7. 中心差分的误差约为 C·h² + ε·|f| / h（ε 是机器精度）。估算 float64（ε ≈ 1.1e-16）和 float32（ε ≈ 6e-8）下最好的 h 的量级（取 C、|f| 为 1 的量级），与 01 号脚本的表对照。
8. x 的形状 (3, 2)，b 的形状 (2,)，y = x + b，上游梯度 G = [[1, 2], [3, 4], [5, 6]]。b 的梯度是多少？
9. 一个 10 层、宽 4096 的 MLP（每层 Linear + ReLU），批大小 512，float32。按 02 号脚本第 5 部分的规律，反向要额外保存多少字节的中间结果？
10. 下面两种场合，你更倾向静态图还是动态图？为什么？(a) 研究一个每个样本循环次数不同的新模型；(b) 把一个训练好的固定模型部署到手机上。
11. 05 号脚本里，mini_tf 的整张图有 68 个节点，执行训练步时只计算了 54 个。没被计算的 14 个是什么？
12. Embedding 的输入下标 ids = [[3, 1, 3]]，上游梯度的三行分别是 g₀、g₁、g₂。嵌入矩阵哪几行的梯度不为 0？各是多少？

### 参考答案

1. f'(x) = 2(x − 3)，f'(0) = −6，x₁ = 0 − 0.1 × (−6) = **0.6**。
2. 节点：v1 = x₁ = 1，v2 = x₂ = 2，v3 = v1 + v2 = 3，v4 = exp(v1) = e，v5 = v3 · v4 = 3e。伴随：v̄5 = 1；v̄3 = v4 = e，v̄4 = v3 = 3；v̄2 = v̄3 = **e ≈ 2.718**；v̄1 = v̄3 · 1 + v̄4 · exp(v1) = e + 3e = **4e ≈ 10.873**（v1 有两个使用者：v3 和 v4）。
3. v1 = 2，v2 = 4，v3 = 8。v̄3 = 1；v̄2 = v̄3 · v1 = 2；v̄1 = v̄3 · v2（来自 v3 的第二个输入）+ v̄2 · v1（来自 v2 的第一个输入）+ v̄2 · v1（来自 v2 的第二个输入）= 4 + 4 + 4 = **12 = 3x²**。v1 被用了三次（v2 用两次、v3 用一次），三份贡献相加。
4. x̄ = Wᵀȳ = [[1, 3], [2, 4]] · [1, −1] = **[−2, −2]**。
5. f：前向 **1000 遍**，反向 **1 遍**（前向 1 遍 + 反向 1 遍）。g：前向 **1 遍**，反向 **1000 遍**（前向 1 遍 + 反向 1000 遍）。
6. 输出 v7 = v1 + v6，宽度优先时 v1 作为 v7 的直接输入在第二层就被碰到，此时它只收到 v̄7 · 1 = 1；它的另一份梯度要经 v6 → v5 → v3 才传过来，到达时 v1 已经"处理过了"，被丢掉。所以得到 **1** 而不是 1 + 1.5e^5.5。
7. 对 h 求导令其为 0：h ≈ (ε / 2C)^{1/3} ~ ε^{1/3}。float64：(1.1e-16)^{1/3} ≈ **5e-6**；float32：(6e-8)^{1/3} ≈ **4e-3**。与表一致：float64 在 1e-6 附近最好，float32 在 1e-2 附近最好。
8. b 的梯度 = G 沿第 0 维求和 = **[9, 12]**；x 的梯度 = G。
9. 每层两份 B × 宽度，减去输入本身一份：(2 × 10 − 1) × 512 × 4096 × 4 字节 = 19 × 8,388,608 ≈ **159 MB**（≈ 152 MiB）。
10. (a) **动态图**：控制流就是普通的 Python 循环，每个样本的图可以不同，调试方便。(b) **静态图**（或把动态图编译 / 导出成图）：结构固定，可以事先做常量折叠、算子融合、显存规划，部署时不需要 Python 解释器。
11. loss 本身的两个节点（乘 1/n、取负）：训练步只需要梯度，而梯度从常数 1 开始往回传，不需要 loss 的数值；另外 12 个是对占位符 x、y 和常量求导时由各运算的 `gradient()` 顺手建出来、但没有任何节点使用的梯度节点（例如对 x 的 matmul 梯度）。会话只计算训练步依赖的节点，所以它们不会被执行。
12. 第 3 行和第 1 行：第 3 行的梯度 = **g₀ + g₂**（这个词出现了两次），第 1 行 = **g₁**，其余行为 0。

## 9. 延伸阅读 · Further reading

- **Baydin, Pearlmutter, Radul & Siskind (2018) *Automatic Differentiation in Machine Learning: a Survey***（JMLR）：前向 / 反向模式、对偶数、实现方式（运算符重载 vs 源码变换）的全面综述。
- **Blondel & Roulet (2024) *The Elements of Differentiable Programming***（arXiv 2403.14606）：从可微编程的角度系统讲计算图、JVP / VJP、反向模式的显存与检查点。
- **Abadi et al. (2016) *TensorFlow: A System for Large-Scale Machine Learning***（OSDI）：数据流图、延迟执行、设备放置的设计动机。
- **Paszke et al. (2019) *PyTorch: An Imperative Style, High-Performance Deep Learning Library***（NeurIPS）：动态图（即时执行）为什么能同时做到好用和高效，以及 C++ autograd 引擎、缓存分配器的设计。
- **原课 HW2 与 miniTorch**（https://minitorch.github.io/ ）：本讲 `autodiff.py` / `tensor.py` 的原型——Sasha Rush 为教学写的迷你框架，模块划分与这里一致。
- **Ansel et al. (2024) *PyTorch 2: Faster Machine Learning Through Dynamic Python Bytecode Transformation and Graph Compilation***（ASPLOS）：torch.compile 怎样把动态图"抓"成图。

## 10. 下一讲预告 · Next lecture

第 1–5 讲搭好了底座：GPU 怎样执行、内核怎样写快、框架怎样自动求导。第 6 讲进入第二单元——**Transformer**。§5 的实验留下了一个问题：平均嵌入丢掉了词序，转折句只能猜。原课第 6 讲从这里出发：注意力机制怎样让每个 token 按内容去"看"其他 token，多头注意力、位置编码、残差连接、LayerNorm、前馈层各自解决什么问题，编码器–解码器和只有解码器的结构有什么区别（第 1 讲 §3.4 的三类语言模型）。对应原课 HW3：在 miniTorch 上实现多头注意力、Transformer 层和 Decoder-only 语言模型。我们会用本讲的 tensor.py 把它们一块块搭起来，并逐层与 PyTorch 对照。
