Session S05

Backpropagation II反向传播 II

Vectorized backprop · how autograd engines work向量化反向传播 · 自动微分引擎如何工作

Deep Learning — CS 4715/5715 · University of Idaho

Where S04 left us: the graph, and the costS04 讲到哪里:图,和代价

  • A network together with its loss is one computation graph, and it ends in a single scalar, \(L\)网络加上损失就是一张计算图,终点是一个标量 \(L\)
  • Backprop is reverse-mode automatic differentiation: one backward sweep from \(L\) hands every node its gradient, \(\partial L/\partial(\text{node})\)反向传播是反向模式自动微分:从 \(L\) 出发的一趟反向扫描,把梯度 \(\partial L/\partial(\text{node})\) 交到每个节点手里
  • The sweep costs a small multiple of the forward pass; the factor depends on the operations. It avoids a separate forward run per parameter反向扫描的代价是前向的小常数倍,倍数取决于运算;无需为每个参数单独重跑前向
  • Today: the same two rules on whole layers at a time, then the engine that applies them for you今天:同样两条规则一次用在整层上,然后是替你应用它们的引擎

The two structural rules, written as formulas两条结构规则,写成公式

Chain (nodes in series, \(a \to b \to L\)): multiply along the path链(串联的节点,\(a \to b \to L\)):沿路径相乘

\[\frac{\partial L}{\partial a} = \frac{\partial L}{\partial b}\,\frac{\partial b}{\partial a}\]

Reuse (one node feeding consumers \(b_1, \dots, b_k\); the reading calls this fan-out): sum across paths复用(一个节点喂给消费者 \(b_1, \dots, b_k\);阅读里叫 fan-out):跨路径求和

\[\frac{\partial L}{\partial a} = \sum_{i=1}^{k} \frac{\partial L}{\partial b_i}\,\frac{\partial b_i}{\partial a}\]

  • A node's gradient is the downstream gradient times its own local derivative, added over every consumer. Every formula today is one of these two, applied to matrices一个节点的梯度是下游梯度乘它自己的局部导数,再对每个消费者相加。今天的每个公式都是这两条之一,用在矩阵上

The smallest reuse case: \(L = x \cdot x\)最小的复用例子:\(L = x \cdot x\)

  • Write the product with both slots visible, \(L = u \cdot v\), both holding \(x\): one node feeding two places, so the chain rule has two terms把乘积连两个槽一起写出来,\(L = u \cdot v\),两个槽里都是 \(x\):一个节点喂给两个地方。\(L\) 沿两条路径到达 \(x\),所以链式法则有两项

\[\frac{\partial L}{\partial x} = \frac{\partial L}{\partial u}\,\frac{du}{dx} + \frac{\partial L}{\partial v}\,\frac{dv}{dx} = v \cdot 1 + u \cdot 1 = x + x = 2x\]

  • For a multiply, each slot's derivative is the other slot: \(\partial L/\partial u = v\), \(\partial L/\partial v = u\). Each path gives \(x\), the two add: that is the reuse rule, and \(\tfrac{d}{dx}\,x^2 = 2x\) agrees对乘法,对一个槽求导得到的是另一个槽:\(\partial L/\partial u = v\),\(\partial L/\partial v = u\)。每条路径各贡献 \(x\),两份贡献相加。这就是复用规则,微积分也同意:\(\tfrac{d}{dx}\,x^2 = 2x\)
  • Skip the sum: \(x\), half the gradient, and no error message; the resulting update can be silently wrong漏掉求和、只留一条路径:得到 \(x\),只有梯度的一半,而且没有任何报错。程序可以照跑,但更新方向或大小已经算错

Vectorized backprop: one layer = one node向量化反传:一层 = 一个节点

Linear layer \(Z = XW + b\), with \(X: (N, D)\), \(W: (D, H)\), incoming \(dZ = \partial L/\partial Z: (N, H)\):线性层 \(Z = XW + b\),其中 \(X: (N, D)\),\(W: (D, H)\),传入的 \(dZ = \partial L/\partial Z: (N, H)\):

  • \(dW = X^\top dZ\) — shape \((D, H)\), same as \(W\)\(dW = X^\top dZ\),形状 \((D, H)\),与 \(W\) 相同
  • \(dX = dZ\,W^\top\) — shape \((N, D)\), same as \(X\)\(dX = dZ\,W^\top\),形状 \((N, D)\),与 \(X\) 相同
  • \(db\) = sum over batch rows of \(dZ\) — shape \((H,)\)\(db\) = \(dZ\) 沿 batch 的各行求和,形状 \((H,)\)

Shape discipline: a parameter's gradient always has the parameter's shape — \((D,N)(N,H)=(D,H)\) checks the transpose; the chain rule establishes the formula形状纪律:参数的梯度永远和参数同形;\((D,N)(N,H)=(D,H)\) 帮助检查转置,公式本身由链式法则推出

Broadcast forward ⇒ sum backward: \(b\) was copied across N rows, so its gradient sums the N contributions前向广播 ⇒ 反向求和:\(b\) 被复制到 N 行,所以它的梯度把 N 份贡献加起来

One shared weight, two examples同一个权重,两个样本

2 examples · 1 input feature and 1 output each · \(b=0,\ W=[w]\)2 个样本 · 每个样本 1 个输入特征、1 个输出 · \(b=0,\ W=[w]\)

Both rows use the same两行共用同一个 \(w\)
Input输入 \(x_n\)Shared共享的 \(w\)Output输出 \(z_n\)
Example 1样本 1 \(2\)\(\times\)\(2.0\)\(=\)\(4.0\)
Example 2样本 2 \(3\)\(\times\)\(2.0\)\(=\)\(6.0\)

Change from \(w=2.0\):相对 \(w=2.0\) 的变化: \(\Delta w=0.0,\quad \Delta z_1=0.0,\quad \Delta z_2=0.0\)

Local derivative = input:局部导数 = 各自的输入: \(\displaystyle\frac{\partial z_1}{\partial w}=2,\quad\frac{\partial z_2}{\partial w}=3\)

Two contributions, one weight gradient两份贡献,加到同一个权重

At \(w=2\), take the incoming \(dZ=\partial L/\partial Z\) as given.
The remaining computation has already returned \(dZ_1=1,\ dZ_2=2\).
在 \(w=2\) 处,把传入梯度 \(dZ=\partial L/\partial Z\) 作为已知量。
后续计算已传回 \(dZ_1=1,\ dZ_2=2\)。

Local derivative局部导数
\(x_n\)
Incoming gradient传入梯度
\(dZ_n\)
Contribution to \(w\)对 \(w\) 的贡献
Example 1样本 1 \(2\)\(\times\)\(1\)\(=\)\(2\)
Example 2样本 2 \(3\)\(\times\)\(2\)\(=\)\(6\)
Same \(w\): add both contributions同一个 \(w\):两份贡献相加
\(dw=2+6=8\)
The same sum is a dot product同一次求和,就是点积
\(X^\top dZ=\begin{bmatrix}2&3\end{bmatrix}\begin{bmatrix}1\\2\end{bmatrix}=8\)

For any weight entry:推广到任意权重元素: \(\displaystyle dW_{ij}=\sum_n X_{ni}\,dZ_{nj}=(X^\top dZ)_{ij}\)

Feature column \(i\) of \(X\) · gradient column \(j\) of \(dZ\): multiply matching rows, then sum.取 \(X\) 的特征列 \(i\) 和 \(dZ\) 的梯度列 \(j\):对应行相乘,再求和。

Input gradients stay with their own examples输入梯度分别留在各自的样本

Same values: \(w=2,\ dZ_1=1,\ dZ_2=2\). Each input has local derivative \(w\).沿用 \(w=2,\ dZ_1=1,\ dZ_2=2\)。每个输入对应的局部导数都是 \(w\)。

Input输入Incoming × local传入梯度 × 局部导数Its gradient各自的梯度
\(x_1\)\(1\times2\)\(dx_1=2\)
\(x_2\)\(2\times2\)\(dx_2=4\)

\(x_1\) and \(x_2\) are different nodes. Keep \(2\) and \(4\) separate.\(x_1\)、\(x_2\) 是不同节点,梯度 \(2\)、\(4\) 分别保存。

\[dX_{ni}=\sum_j dZ_{nj}W_{ij}=(dZ\,W^\top)_{ni}\]

With several outputs, one input reaches every output column \(j\): sum over \(j\), within the same example.有多个输出时,一个输入影响每个输出列 \(j\):在同一样本内对 \(j\) 求和。

A shared bias collects contributions from both rows共享偏置收集两行的贡献

\(z_n=x_nw+b\). Even at \(b=0\), the local derivative with respect to \(b\) is \(1\).\(z_n=x_nw+b\)。即使当前 \(b=0\),输出对 \(b\) 的局部导数仍为 \(1\)。

Example样本Incoming × local传入梯度 × 局部导数Contribution to the same b对同一个 b 的贡献
1\(1\times1\)1
2\(2\times1\)2

\[db=1+2=3\]

\[db_j=\sum_n dZ_{nj},\qquad \operatorname{shape}(db)=(H,)\]

One bias per output column, reused across examples: broadcast forward, sum backward.每个输出列有一个偏置,在不同样本间共享:前向广播,反向求和。

Two-layer classifier: the forward graph两层分类网络:前向计算图

XW₁, b₁ReLUW₂, b₂Z₂ softmaxPmean loss平均损失Ly 3 inputs3 个输入 4 hidden units4 个隐藏单元 2 class scores2 个类别得分

Two trainable layers: \((W_1,b_1)\) and \((W_2,b_2)\). ReLU follows the first affine transformation.两组可训练层:\((W_1,b_1)\) 与 \((W_2,b_2)\)。第一层仿射变换之后使用 ReLU。

The diagram shows one example. Repeat the same network for all \(N\) rows; \(L\) averages their losses.图中画出一个样本。对全部 \(N\) 行使用同一个网络;\(L\) 对各行损失取平均。

Forward arrays, formulas, and shapes前向数组、计算公式与形状

\(N\): examples · \(D\): input features · \(H\): hidden units · \(C\): classes.\(N\):样本数 · \(D\):输入特征数 · \(H\):隐藏单元数 · \(C\):类别数。

Array数组Definition定义Shape形状
\(X\)Input batch输入 batch\((N,D)\)
\(Z_1\)\(XW_1+b_1\)\((N,H)\)
\(A_1\)\(\max(0,Z_1)\)\((N,H)\)
\(Z_2\)\(A_1W_2+b_2\)\((N,C)\)
\(P\)\(\operatorname{softmax}(Z_2)\)\((N,C)\)
\(L\)\(-\frac1N\sum_n\log P_{n,y_n}\)scalar标量

Parameter shapes: \(W_1:(D,H),\ b_1:(H,),\ W_2:(H,C),\ b_2:(C,)\).参数形状:\(W_1:(D,H),\ b_1:(H,),\ W_2:(H,C),\ b_2:(C,)\)。

The output layer: softmax and cross-entropy, one row at a time输出层:softmax 与交叉熵,一次一行

\[P_{nc} = \frac{e^{Z_{2,nc}}}{\sum_{c'} e^{Z_{2,nc'}}} \qquad \ell_n = -\log P_{n,\,y_n} \qquad L = \frac{1}{N}\sum_{n} \ell_n\]

\[\ell_n = -Z_{2,ny_n} + \log \sum_{c'} e^{Z_{2,nc'}}\]

  • Softmax turns the \(C\) scores of row \(n\) into probabilities that sum to 1; the loss of the row is the negative log of the probability given to the true class \(y_n\); \(L\) averages the \(N\) rowssoftmax 把第 \(n\) 行的 \(C\) 个得分变成和为 1 的概率;这一行的损失是真实类别 \(y_n\) 所得概率的负对数;\(L\) 对 \(N\) 行取平均
  • The second line is the same loss written with the scores: the true-class score, and a log-sum-exp. Its derivative is the next page第二行是同一个损失用得分写出来:真实类别的得分,加一个 log-sum-exp。它的导数在下一页

Backward starts at the loss: the gradient of the logits从损失开始反向:对类别得分求导

Backward route: \(L\ \longrightarrow\ dZ_2\ \longrightarrow\ dA_1\ \longrightarrow\ dZ_1\).反向路线:\(L\ \longrightarrow\ dZ_2\ \longrightarrow\ dA_1\ \longrightarrow\ dZ_1\)。

\[\frac{\partial\ell_n}{\partial Z_{2,nc}}=\underbrace{-\mathbf1[c=y_n]}_{\text{true-class score}}+\underbrace{P_{nc}}_{\text{log-sum-exp}}\]

\[dZ_2=\frac{\partial L}{\partial Z_2}=\frac{P-Y}{N},\qquad Y=\operatorname{onehot}(y)\]

Shape: \(P,\ Y,\ dZ_2\in\mathbb R^{N\times C}\): \(N\) rows, \(C\) columns.形状:\(P,\ Y,\ dZ_2\in\mathbb R^{N\times C}\):\(N\) 行、\(C\) 列。

Subtract 1 only at each true class. Divide by N once because the loss averages examples.只在每行的真实类别处减 1。损失对样本取平均,因此在这里除一次 N。

Layer 2 backward: three destinations for the gradient第 2 层反向:梯度传向三个位置

Local forward operation: \(Z_2=A_1W_2+b_2\). Incoming gradient: \(dZ_2\).当前的前向运算:\(Z_2=A_1W_2+b_2\)。传入梯度:\(dZ_2\)。

Derivative of L with respect toL 对谁求导Formula计算公式Shape形状
\(W_2\)\(dW_2=A_1^\top dZ_2\)\((H,C)\)
\(b_2\)\(db_2=\sum_n(dZ_2)_{n,:}\)\((C,)\)
\(A_1\)\(dA_1=dZ_2W_2^\top\)\((N,H)\)
  • Weight and bias gradients sum contributions across examples. \(A_1\) is this layer’s input.权重和偏置梯度对样本贡献求和;\(A_1\) 是这一层的输入。
  • \(dA_1\) continues upstream into ReLU. It is a gradient, not a parameter update.\(dA_1\) 继续向前传给 ReLU。它是梯度,不是参数更新。

ReLU backward: match each gradient with its maskReLU 反向:每个梯度乘对应的掩码

Forward: \(A_1=\max(0,Z_1)\). Backward: \(dZ_1=dA_1\odot\mathbf1[Z_1>0]\).前向:\(A_1=\max(0,Z_1)\)。反向:\(dZ_1=dA_1\odot\mathbf1[Z_1>0]\)。

One entry of某个元素123
\(Z_1\)−203
Local derivative / mask局部导数 / 掩码001
\(dA_1\)456
\(dZ_1\)006

At zero, the ordinary derivative does not exist; this backward rule chooses \(0\).零点不存在普通导数;这里的反向规则约定取 \(0\)。

All three arrays have shape \((N,H)\). \(\odot\) means elementwise multiplication.三个数组的形状都是 \((N,H)\)。\(\odot\) 表示逐元素相乘。

Layer 1 backward and the saved forward values第 1 层反向与保存的前向值

Local forward operation: \(Z_1=XW_1+b_1\). Incoming gradient: \(dZ_1\).当前的前向运算:\(Z_1=XW_1+b_1\)。传入梯度:\(dZ_1\)。

Formula公式Shape形状Use用途
\(dW_1=X^\top dZ_1\)\((D,H)\)Weight gradient权重梯度
\(db_1=\sum_n(dZ_1)_{n,:}\)\((H,)\)Bias gradient偏置梯度
\(dX=dZ_1W_1^\top\)\((N,D)\)If an upstream task needs it上游任务需要时计算

Saved values: \(X,A_1\) for weight gradients; \(Z_1\) for the mask; \(P\) for \(dZ_2\); \(W_2\) for \(dA_1\).保存值:权重梯度用 \(X,A_1\);掩码用 \(Z_1\);\(dZ_2\) 用 \(P\);\(dA_1\) 用 \(W_2\)。

The implementation below returns the four parameter gradients; its input data are fixed.接下来的实现返回四个参数梯度,输入数据视为固定。

NumPy 1: build the forward arraysNumPy 1:计算前向数组

Inside forward_backward(params, X, y); params stores W1, b1, W2, b2.位于 forward_backward(params, X, y) 内;params 保存 W1、b1、W2、b2。

W1, b1 = params["W1"], params["b1"]
W2, b2 = params["W2"], params["b2"]
N = X.shape[0]
\(Z_1=XW_1+b_1\quad(N,H)\)
Z1 = X @ W1 + b1
\(A_1=\max(0,Z_1)\quad(N,H)\)
A1 = np.maximum(0, Z1)
\(Z_2=A_1W_2+b_2\quad(N,C)\)
Z2 = A1 @ W2 + b2

@ multiplies matrices; each bias broadcasts across the N rows.@ 执行矩阵乘法;每个偏置向量广播到 N 行。

NumPy 2: stable log-probabilities and mean lossNumPy 2:稳定的对数概率与平均损失

\[s_{nc}=Z_{2,nc}-\max_{c'}Z_{2,nc'},\qquad \log P_{nc}=s_{nc}-\log\sum_{c'}e^{s_{nc'}}\]

Z2s = Z2 - Z2.max(axis=1, keepdims=True)
logp = Z2s - np.log(np.exp(Z2s).sum(axis=1, keepdims=True))
loss = -logp[np.arange(N), y].mean()
  • axis=1: operate across classes in each example. keepdims=True: retain shape (N, 1) for row-wise broadcasting.axis=1:在每个样本内沿类别计算。keepdims=True:保留 (N, 1) 形状,便于逐行广播。
  • Subtracting a row maximum preserves softmax probabilities and avoids positive exponential overflow.减去每行最大值不改变 softmax 概率,并避免指数的正向溢出。
  • Paired indices select each true-class log-probability; the minus sign and mean implement the loss.成对索引选出每行真实类别的对数概率;负号与 mean 实现损失。

NumPy 3: form the starting gradientNumPy 3:构造反向的起点梯度

\[dZ_2=(P-Y)/N\]

dZ2 = np.exp(logp)
dZ2[np.arange(N), y] -= 1
dZ2 /= N
Line代码行Meaning含义
1Create a new array \(P\) from \(\log P\).从 \(\log P\) 生成新数组 \(P\)。
2Subtract the one-hot labels without building a full Y matrix.不创建完整 Y 矩阵,直接在独热标签的位置减 1。
3Account for averaging the N losses.计入对 N 个损失取均值的因子。

y = [2, 0] → row indices [0, 1] → selected entries (0, 2) and (1, 0).y = [2, 0] → 行索引 [0, 1] → 选中 (0, 2) 和 (1, 0)。

The index example uses 2 rows and 3 classes. All later gradients already carry the 1/N factor.索引示例使用 2 行、3 个类别。后续梯度已经包含 1/N 因子。

NumPy 4: the second affine layerNumPy 4:第二层仿射运算的反向

grads = {}
\(dW_2=A_1^\top dZ_2\)
grads["W2"] = A1.T @ dZ2
\(db_2=\sum_n(dZ_2)_{n,:}\)
grads["b2"] = dZ2.sum(axis=0)
\(dA_1=dZ_2W_2^\top\)
dA1 = dZ2 @ W2.T
  • grads stores parameter derivatives under the same keys as params.grads 使用与 params 相同的键,保存参数导数。
  • .T transposes a 2-D array. axis=0 sums examples and keeps the class columns..T 转置二维数组。axis=0 对样本求和,保留类别列。
  • \(dW_2:(H,C),\ db_2:(C,),\ dA_1:(N,H)\).\(dW_2:(H,C),\ db_2:(C,),\ dA_1:(N,H)\)。

NumPy 5: ReLU, layer 1, and the return valuesNumPy 5:ReLU、第一层与返回值

\(dZ_1=dA_1\odot\mathbf1[Z_1>0]\)
dZ1 = dA1 * (Z1 > 0)
\(dW_1=X^\top dZ_1\)
grads["W1"] = X.T @ dZ1
\(db_1=\sum_n(dZ_1)_{n,:}\)
grads["b1"] = dZ1.sum(axis=0)
return loss, grads
  • Z1 > 0 creates a Boolean mask. Multiplication treats False/True as 0/1.Z1 > 0 创建布尔掩码,相乘时 False/True 对应 0/1。
  • * is elementwise; @ is matrix multiplication. The function returns a scalar loss and four parameter-gradient arrays.* 逐元素相乘,@ 做矩阵乘法。函数返回标量损失和四个参数梯度数组。

Gradient check: perturb one parameter entry梯度检查:只扰动一个参数元素

Reading example: \(N=8,\ D=3,\ H=4,\ C=2\), float64, random seed 0. Keep the batch and every other parameter fixed.阅读中的例子:\(N=8,\ D=3,\ H=4,\ C=2\),float64,随机种子 0。固定 batch 和其余全部参数。

name, idx = "W1", (0, 0)
p = params[name]             # the actual array, not a copy
eps = 1e-5
loss, grads = forward_backward(params, X, y)
Parameter value参数值Recomputed loss (rounded)重算的损失(显示值已舍入)
\(p_{00}+\epsilon\)1.0065300857
\(p_{00}-\epsilon\)1.0065315136

\[g_{\rm num}=\frac{L(p+\epsilon)-L(p-\epsilon)}{2\epsilon}\]

analytic (backward)   = -0.0713913172
numeric  (two losses) = -0.0713913172

Gradient check code: probe, restore, compare梯度检查代码:扰动、恢复、比较

def numeric_entry(p, idx):
    old = p[idx]
    try:
        p[idx] = old + eps
        lp, _ = forward_backward(params, X, y)
        p[idx] = old - eps
        lm, _ = forward_backward(params, X, y)
    finally:
        p[idx] = old
    return (lp - lm) / (2 * eps)
  • p refers to an array in params; changing p[idx] changes what forward reads.p 引用 params 中的数组;修改 p[idx] 就改变了前向读到的参数。
  • The underscore discards the recomputed analytic gradients. Only the losses lp and lm form the numerical derivative.下划线丢弃重新算出的解析梯度;数值导数只使用损失 lp、lm。
  • finally restores the original value even if evaluation raises an error. No parameter update occurs.finally 即使在计算报错时也恢复原值。这里不执行参数更新。

Gradient check: visit every entry and inspect the errors梯度检查:遍历每个元素并查看误差

for name, p in params.items():
    num = np.zeros_like(p)
    for idx in np.ndindex(p.shape):
        num[idx] = numeric_entry(p, idx)
    ana = grads[name]
    assert ana.shape == p.shape
    rel = np.abs(num - ana) / (np.abs(num) + np.abs(ana) + 1e-12)
    print(name, f"{rel.max():.2e}")
W1 4.32e-10    b1 5.78e-10
W2 7.99e-11    b2 6.01e-12

These maxima summarize all entries of each array. Also inspect absolute error when both gradients are near zero.这些最大值汇总了每个数组的全部元素。两个梯度都接近零时,还要检查绝对误差。

A small discrepancy supports this backward implementation for this forward function and these inputs.误差很小,支持这份反向实现在当前前向函数与输入上的正确性。

Gradient checks: precision, scale, and kinks梯度检查:精度、规模与折角

  • Roundoff: in float32, nearby losses can round to the same number. Then the numerical gradient is 0 and a nonzero analytic gradient can have relative error near 1.舍入误差:float32 中相近的损失可能被舍入成同一个数。数值梯度于是为 0,与非零解析梯度的相对误差可能接近 1。
  • Use float64: start near \(\epsilon=10^{-5}\), then vary it if needed. Smaller \(\epsilon\) reduces truncation error but can amplify roundoff.用 float64:从 \(\epsilon=10^{-5}\) 附近开始,必要时调整。减小 \(\epsilon\) 会降低截断误差,却可能放大舍入误差。
  • Small, varied tests: 2 forward passes per parameter entry. Test several shapes and inputs; success on \(3\to4\to2\) does not establish correctness for all shapes.小而多样的测试:每个参数分量需要 2 次前向。检查不同形状与输入;通过 \(3\to4\to2\) 不能保证所有形状都正确。
  • ReLU kink: crossing zero can make a correct backward rule disagree with finite differences. Move away from the kink, or reduce \(\epsilon\) without losing numerical precision.ReLU 折角:跨过零点可能让正确反向与有限差分不一致。换到远离折角的位置,或在精度允许时减小 \(\epsilon\)。

Engine design: values, local rules, and execution order引擎设计:数值、局部规则与执行顺序

Part组成Responsibility职责Interface接口
A Value nodeValue 节点Store a number and accumulated gradient保存数值与累积梯度data, grad
An operation一个运算Make an output, link its inputs, attach its local rule创建输出,连接输入,保存局部规则_prev, _backward
The backward driver反向执行器Order nodes, then call each stored rule排列节点,再调用保存的规则backward()
a, b, c = Value(2.0), Value(3.0), Value(1.0)
p = a * b
q = p + c
y = q.tanh()
y.backward()

Forward builds the graph and saves rules. Backward later calls those rules in dependency order.前向创建计算图并保存规则;反向随后按依赖关系调用规则。

A node: value, gradient, parents, rule标量节点保存值、梯度、父节点和反向规则


class Value:
    def __init__(self, data, _children=()):
        self.data = float(data)       # forward value
        self.grad = 0.0               # accumulated dL/d(self)
        self._prev = set(_children)   # input nodes: the parents
        self._backward = lambda: None # leaf: nothing to propagate
  • Value(3.0) makes a leaf: empty parent set, zero gradient, a rule that does nothing.Value(3.0) 创建叶节点:父节点集合为空,梯度为零,反向规则不做任何操作。
  • y = (a * b + c).tanh(): each operation creates a result node and replaces its empty rule. This is define-by-run.y = (a * b + c).tanh():每次运算创建一个结果节点,并设置它的反向规则。这就是 define-by-run。
  • One scalar output gives all input gradients in one reverse sweep. The cost still grows with the computation graph.从一个标量输出反向扫描一次,就能得到所有输入的梯度;代价仍随计算图的大小增长。

The tape: values go forward, gradients reach every input计算图:值向前传,梯度回到每个输入

\[p=ab,\qquad q=p+c,\qquad y=\tanh(q)\]

abcp = abq = p+cy q.grad += 1 − y²p.grad += q.grada.grad += b·p.grad b.grad += a·p.gradc.grad += q.grad y.grad = 1 tanh
  • Blue: inputs → result. Orange: add downstream gradient × local derivative into each parent.蓝色:输入 → 结果。橙色:向每个父节点累加下游梯度 × 局部导数。
  • Order: y, q, p. Each binary operation updates both input slots.顺序:y、q、p。每个二元运算都更新两个输入槽。

Multiplication 1: create the result and its parent links乘法 1:创建结果与父节点连接

def __mul__(self, other):
    if not isinstance(other, Value):
        other = Value(other)
    out = Value(self.data * other.data,
                (self, other))
    # Attach the local backward rule next.
Expression表达式Meaning inside this method方法内部的含义
a * ba.__mul__(b): self=a, other=b
a * 3Wrap 3 in Value so every operand has data and grad把 3 包装成 Value,让每个操作数都有 data 和 grad
outA new node; its parents are the original operand objects一个新节点,其父节点就是原来的操作数对象

The forward result is ready, but its gradient is still zero. Its local backward rule has not run.前向结果已经就绪,但它的梯度仍为零,局部反向规则还没有运行。

Multiplication 2: save the two chain-rule contributions乘法 2:保存两份链式法则贡献

Continuation of __mul__; self, other, and out are already defined.继续 __mul__;self、other、out 已经定义。

    def _backward():
        self.grad += other.data * out.grad
        other.grad += self.data * out.grad
    out._backward = _backward
    return out

\[p=ab:\qquad \frac{\partial L}{\partial a}\mathrel{+}=b\,\frac{\partial L}{\partial p},\qquad \frac{\partial L}{\partial b}\mathrel{+}=a\,\frac{\partial L}{\partial p}\]

  • def creates a function; it does not run the indented updates.def 创建函数,不执行缩进中的梯度更新。
  • Assignment without parentheses saves the function. return out returns the result node.不带括号的赋值保存函数;return out 返回结果节点。
  • Each += adds one operand-slot contribution, even when both operands are the same object.每条 += 都累加一个操作数槽的贡献,即使两个操作数是同一个对象。

A closure keeps the operands available for later闭包让反向规则稍后仍能访问操作数

a, b = Value(2.0), Value(3.0)
p = a * b
print(p.data, a.grad, b.grad)  # 6.0  0.0  0.0

p.grad = 1.0
p._backward()                 # call this one local rule
print(a.grad, b.grad)         # 3.0  2.0
Stored rule for pp 保存的规则Still refers to仍然引用
selfa
otherb
outp

These are live object references. Do not mutate operand data between forward and backward.它们是对对象的引用。在前向与反向之间,不要修改操作数的 data。

Addition: two parents, local derivative one加法:两个父节点,局部导数都为 1

def __add__(self, other):
    if not isinstance(other, Value):
        other = Value(other)
    out = Value(self.data + other.data, (self, other))
    def _backward():
        self.grad += out.grad
        other.grad += out.grad
    out._backward = _backward
    return out

\[q=a+b:\qquad \frac{\partial q}{\partial a}=1,\quad \frac{\partial q}{\partial b}=1\]

The constructor, parent links, closure, assignment and return follow the multiplication pattern.构造输出、连接父节点、定义闭包、保存函数与返回结果,都沿用乘法的结构。

Tanh: one parent and a saved outputtanh:一个父节点与保存的输出

def tanh(self):
    t = math.tanh(self.data)
    out = Value(t, (self,))
    def _backward():
        self.grad += (1.0 - t * t) * out.grad
    out._backward = _backward
    return out

# Also inside Value:
__radd__ = __add__
__rmul__ = __mul__

\[t=\tanh(x),\qquad \frac{dt}{dx}=1-t^2\]

  • (self,) is a one-element tuple. The closure reuses the forward output t.(self,) 是只有一个元素的元组,闭包复用前向输出 t。
  • Reflected methods support 1 + x and 2 * x; this shortcut uses add/multiply commutativity.反向运算符方法支持 1 + x 和 2 * x;这种简写依赖加法与乘法的交换律。

Reuse, walked: \(f = a \cdot a + a\) at \(a = 3\)走一遍复用图:\(f = a \cdot a + a\),\(a = 3\)

a p = a · a f = p + a a = 3 p = 9 f = 12 slot 1slot 2 f.grad = 1 (base case) p.grad = 1 a.grad = 1 + 3 + 3 = 7 11 33
  • Reverse topological order: \(f\) first (it deposits 1 into \(p\) and 1 into \(a\)), then \(p\) (each slot deposits \(a \cdot p.\mathrm{grad} = 3\) into \(a\)). Total \(1 + 3 + 3 = 7 = 2a + 1\)逆拓扑序:先 \(f\)(往 \(p\) 存 1、往 \(a\) 存 1),再 \(p\)(每个槽往 \(a\) 存 \(a \cdot p.\mathrm{grad} = 3\))。合计 \(1 + 3 + 3 = 7 = 2a + 1\)
  • Process \(p\) before \(f\) and you propagate \(p.\mathrm{grad}\) while it is still 0. Write = instead of += and the three deposits overwrite each other先处理 \(p\) 再处理 \(f\),传播的 \(p.\mathrm{grad}\) 还是 0。把 += 写成 =,三份存入就互相覆盖

A gradient ledger for the reused node给复用节点记一张梯度表

a = Value(3.0)
p = a * a
f = p + a
After this action执行之后f.gradp.grada.grad
Forward only只有前向000
f.grad = 1.0100
f._backward()111
p._backward()11\(1+3+3=7\)

_prev = {a} visits the object once. The two multiply statements still contribute twice._prev = {a} 让遍历只访问该对象一次,但乘法中的两条更新仍然贡献两次。

Backward 1: visit parents before appending a node反向 1:先访问父节点,再把当前节点入列

def backward(self):
    topo, visited = [], set()
    def build(v):
        if v not in visited:
            visited.add(v)
            for parent in v._prev:
                build(parent)
            topo.append(v)
    build(self)
Statement语句Reason原因
visited.add(v)Prevent repeated traversal of shared objects避免重复遍历共享对象
build(parent)Finish dependencies first先处理依赖节点
topo.append(v)Put each result after its parents把结果放在父节点之后

build(f) on f = a*a + a produces topo = [a, p, f].对 f = a*a + a 调用 build(f),得到 topo = [a, p, f]。

Backward 2: initialize the output and execute in reverse反向 2:初始化输出梯度,倒序执行

Continuation of the same backward(self) method after build(self).继续同一个 backward(self) 方法,接在 build(self) 之后。

    self.grad = 1.0
    for node in reversed(topo):
        node._backward()

\[\frac{\partial f}{\partial f}=1\]

Execution order执行顺序What is ready before the call调用之前已经就绪
f._backward()f.grad = 1
p._backward()p.grad = 1
a._backward()Leaf: its empty rule does nothing叶节点:空规则不执行任何更新

All consumers must contribute to a node before its rule propagates that total into its parents.一个节点的所有使用方都贡献完梯度,它的规则才能把总和继续传给父节点。

Using the engine: a fresh graph for every update使用引擎:每次更新都构建新图

One example: \(x=2,\ y=3\). Model \(\hat y=wx\); loss \(L=(\hat y-y)^2\).一个样本:\(x=2,\ y=3\)。模型 \(\hat y=wx\),损失 \(L=(\hat y-y)^2\)。

w = Value(1.0)
for step in range(3):
    w.grad = 0.0
    pred = w * 2.0
    error = pred + (-3.0)
    loss = error * error
    loss.backward()
    print(f"{step}: w={w.data:.4f}, L={loss.data:.4f}, dw={w.grad:.4f}")
    w.data -= 0.1 * w.grad
0: w=1.0000, L=1.0000, dw=-4.0000
1: w=1.4000, L=0.0400, dw=-0.8000
2: w=1.4800, L=0.0016, dw=-0.1600

Each printed line is before the update. Rebuild intermediates; keep the parameter object.每一行都在更新之前打印。重新创建中间节点,保留参数对象。

Test local rules, reuse, and graph lifecycle separately分别测试局部规则、复用与图的生命周期

a, b = Value(2.0), Value(3.0)
z = a * b
z.backward()
assert (a.grad, b.grad) == (3.0, 2.0)

a = Value(3.0)
f = a * a + a
f.backward()
assert a.grad == 7.0
  • A primitive test checks the local derivative. A reuse test checks accumulation and ordering together.基本运算测试检查局部导数;复用测试同时检查累加与执行顺序。
  • On the reuse example: running p before f gives 1; replacing both multiply updates with assignment gives 3.在复用例子中,先运行 p 再运行 f 会得到 1;把乘法的两条累加改成赋值会得到 3。
  • Calling f.backward() again without resetting intermediate gradients gives 20 in this toy engine. Training uses a fresh graph.在这个玩具引擎中,不清空中间梯度就再次调用 f.backward() 会得到 20。训练应使用新图。

Reference comparison: one expression for both engines参考对照:两个引擎运行同一个表达式

def expression(a, b, c):
    d = a * b + c
    e = (d + a).tanh()
    return e * d + b

a=0.7, b=-1.3, c=2.0. Call this function with Value nodes, then with torch tensors.a=0.7, b=-1.3, c=2.0。先传入 Value 节点,再传入 torch 张量。

  • The expression reuses d, gives a a second path, and gives b a direct path to the output.表达式复用 d,让 a 多出一条路径,也让 b 直接连到输出。
  • Both types implement multiplication, addition and tanh. Python dispatches each operation to that type’s implementation.两种类型都支持乘法、加法和 tanh;Python 把各次运算分派给相应类型的实现。

Reference comparison: run backward and compare every input参考对照:执行反向并比较每个输入

a, b, c = map(Value, (0.7, -1.3, 2.0))
out = expression(a, b, c)
out.backward()
import torch
ts = [torch.tensor(v, dtype=torch.float32,
                   requires_grad=True)
      for v in (0.7, -1.3, 2.0)]
at, bt, ct = ts
outt = expression(at, bt, ct)
outt.backward()
               micro       torch      |diff|
out        -0.269121   -0.269121    5.19e-08
dL/da      -1.263999   -1.263999    2.42e-08
dL/db       1.742557    1.742557    4.19e-08
dL/dc       1.060796    1.060796    5.93e-08

Compare out.data with outt.item(), and each Value.grad with the matching tensor.grad.item().将 out.data 与 outt.item() 比较,再将每个 Value.grad 与对应 tensor.grad.item() 比较。

This example uses Python float versus torch float32. Use tolerances; matching rounded printouts is not exact equality.这里比较 Python float 与 torch float32,应使用容差;打印后相同不代表数值完全相等。

From the scalar engine to torch.autograd从标量引擎到 torch.autograd

  • Tensor operations: one node can represent many scalar operations. Backward uses matrix rules and optimized kernels.张量运算:一个节点可以表示许多标量运算。反向使用矩阵规则和优化后的计算核。
  • Saved values: keep what backward needs; gradients and optimizer state also cost memory. torch.no_grad() skips recording new reverse-mode history.保存的值:保留反向所需的数据;梯度和优化器状态也占内存。torch.no_grad() 不记录新的反向模式历史。
  • Gradient storage: our toy stores every node’s .grad. PyTorch normally fills .grad only on leaves with requires_grad=True; use retain_grad() to inspect an intermediate.梯度保存:玩具引擎为每个节点保存 .grad。PyTorch 通常只填充 requires_grad=True 的叶节点;要查看中间节点可用 retain_grad()。
  • Execution: our recursive sort can hit Python’s recursion limit. Production engines handle scheduling, devices, and memory lifetimes.执行:我们的递归排序可能达到 Python 的递归深度限制。生产引擎还要处理调度、设备与内存生命周期。

Kinks and NaNs: mask before the unsafe operation折角与 NaN:先筛选输入,再做不安全的运算

ReLU at 0: no ordinary derivative; choose 0 for backward. Any subgradient in \([0,1]\) is valid. Exact zeros occur at any precision.ReLU 零点无普通导数;反向约定取 0。\([0,1]\) 中的数均为合法次梯度;任何精度下都可能出现恰好为零的输入。


# x is a floating-point leaf tensor requiring gradients
bad = torch.where(x > 0, torch.sqrt(x), 0.0) # sqrt runs first

mask = x > 0
good = torch.zeros_like(x)
good[mask] = torch.sqrt(x[mask])            # sqrt sees only x > 0
  • At x=0: unsafe backward can form inf * 0 = nan. Mask inputs before sqrt.x=0:不安全的反向可能形成 inf * 0 = nan。在 sqrt 之前筛选输入。
  • Wrap forward and backward in torch.autograd.detect_anomaly(): identify a NaN-producing backward function and its forward call.用 torch.autograd.detect_anomaly() 包住前向与反向:定位产生 NaN 的反向函数及其前向调用。

PyTorch graphs: lifetime, accumulation, and stop-gradientPyTorch 计算图:生命周期、梯度累积与停止梯度

  • Saved tensors: backward normally releases them. A second backward that needs them requires retain_graph=True on the earlier call. A fresh forward builds a fresh graph.保存的张量:反向通常会释放它们。如果第二次反向仍需要它们,要在前一次调用设置 retain_graph=True。重新前向会构建新图。
  • Accumulation: clear parameter gradients before an update’s accumulation group. For k equal-size minibatches with mean losses, backpropagate loss/k on each fresh graph, then step once.梯度累积:在一次更新的累积组开始前清零参数梯度。k 个等大小 minibatch 各用均值损失,在各自新图上反向传播 loss/k,最后更新一次。
  • detach(): returns a tensor disconnected from earlier history. It shares storage; it does not freeze parameters or copy data by itself.detach():返回一个与先前历史断开的张量。它共享底层存储,本身不会冻结参数,也不复制数据。
  • no_grad(): skips new reverse-mode recording in its context. model.eval() separately changes dropout/BatchNorm behavior.no_grad():在作用域内跳过新的反向模式记录。model.eval() 另行改变 dropout/BatchNorm 的行为。

HW1: implement operations, backward, and a training loopHW1:实现运算、反向传播与训练循环

  • Tasks 1–5: add, mul, pow, neg, sub, div, tanh, relu, exp, log. Give primitive operations local rules with +=; compose neg, sub, and div from supported operations where appropriate.任务 1–5:add、mul、pow、neg、sub、div、tanh、relu、exp、log。为基础运算编写带 += 的局部规则;neg、sub、div 可以用已有运算组合。
  • Task 6: order reachable nodes, set output gradient to 1, call rules in reverse. The a*a+a reuse test and a separate branching diamond test check accumulation and ordering.任务 6:排列可达节点,令输出梯度为 1,倒序调用规则。a*a+a 复用测试和另一个分支菱形测试检查累加与顺序。
  • Task 7, fit_line: clear w.grad and b.grad; rebuild predictions and mean loss; backward; update the two parameters.任务 7,fit_line:清零 w.grad 与 b.grad;重新计算预测值和均值损失;反向传播;更新两个参数。
  • Tests: known derivatives and central finite differences; torch parity is optional and unscored. Due S08.测试:已知导数与中心差分;torch 对照可选、不计分。S08 截止。

For next time下次课前

  • Read: 1.6 Optimizers: SGD to AdamW阅读:1.6 优化器:从 SGD 到 AdamW
  • Next session (S06): Optimizers — SGD, momentum, Adam/AdamW, learning-rate schedules下一讲(S06):优化器:SGD、动量、Adam/AdamW、学习率调度
  • HW1 (micro-autograd) due S08 — today's engine is the blueprint; test on reuse expressions like a * a + aHW1(micro-autograd)S08 截止。今天的引擎就是蓝图;用 a * a + a 这样的复用表达式来测
  • Quiz 1 (S09) covers all of Module 1, today includedQuiz 1(S09)覆盖整个模块 1,包括今天