模块 1 · 阅读 1.5 · 在 S05:反向传播 II 之前读

当你在 PyTorch 里调用 loss.backward(),autograd 会为需要梯度且参与了该损失计算的参数求梯度。这一行看起来和你在反向传播里手算的链式法则记账毫无关系,而把它当成魔法会让调试更困难:你迟早会碰上一个悄无声息的零梯度、一个翻倍的梯度,或者暴涨的内存占用,而你需要能解释这些问题的模型。

解药是自己把这台机器造一次。反向模式自动微分引擎的核心(PyTorch、JAX、TensorFlow 里都有的那个东西)大约六十行 Python 就能装下。本节一步一步搭出一个,然后在同一个表达式上和 torch.autograd 对照。HW1(”micro-autograd”)要你搭一个更完整的版本,搭的正是这个引擎,所以读本节时请开着编辑器。

怎么读这一节

  • 向量化反传与梯度检查,一页说完:课堂的前半段从这里出发。它自足地回顾了1.4 节结尾的两件事:线性层的三条矩阵规则,以及检验它们的有限差分检查。梯度检查你在 S04 录像里看过;三条矩阵规则到目前为止你只读过,S04 讲到它们之前就停了,S05 会逐步推导它们。认真读这一节并把例子跑一遍;后面的一切都默认你会这些规则。

  • 节点必须记住什么:每个节点存的四个核心字段(值、梯度、父节点、局部反向规则),以及反向模式的权衡:一趟扫描得到全部参数梯度,同时为反向保存所需的前向值。
  • 六十行的可用引擎:真正要跑的例子。在三处值得细读的细节上放慢:闭包携带局部状态、梯度用 += 累加、包装让常数能用。
  • 为什么拓扑序重要:复用图 a * a + a:任何节点的规则都不能在它的每个消费者存完贡献之前触发。
  • 与 torch.autograd 对照:把引擎和一个参照在同一个表达式上对照的习惯。HW1 的测试做的是同一件事,用已知的值和一次中心差分,不需要 PyTorch;那套测试里的 torch 对照是可选的,不计分。注意为什么一致到约 1e-8 而不是完全相等(float32 对 float64)。
  • 微积分失效之处:折角与无穷:relu'(0) = 0 的约定(PyTorch 和 HW1 的 starter 都这样定)和 where 陷阱:inf * 0 = nan 可能污染上游梯度。
  • 图的生命周期:保留、累加、分离:什么时候清空梯度,以及 detach() 和 no_grad() 是干什么的。对导数求导和从六十行到真正的框架第一遍可以略读。

下面六个可运行的例子里有四个要 import torch。如果你还没装,读它们打印的输出就好:课程在 S08 才装 PyTorch,HW1 完全不需要它。你自己要跑的两个例子是层检查(需要 Python 和 NumPy)以及六十行引擎(只用 Python 标准库)。

向量化反传与梯度检查,一页说完

下面的一切都在搭一个把链式法则自动化的引擎。把一件事自动化之前,你应该能在引擎真正工作的尺度上手算它:整层,而不是单个标量。1.4 节就结束在那里,本节把它重述一遍,好让这一页自己站得住。

逐标量的反向传播在真实网络上会算到天荒地老。好在链式法则尊重矩阵结构,所以整整一层可以是一个节点,配一条矩阵级的反向规则。对线性层 Z = X @ W + b(batch X 形状 (N, D),权重 W 形状 (D, H),传入的梯度 dZ 形状 (N, H)),恰好有三条规则:

规则 表达式 形状 为什么
权重 dW = X.T @ dZ (D, H),与 W 相同 W[i,j] 在 N 个样本的每一个里都把 X[n,i] 乘进 Z[n,j],所以它的梯度把 X[n,i] * dZ[n,j] 沿 n 求和,正是 X.T @ dZ 的 (i,j) 项
输入 dX = dZ @ W.T (N, D),与 X 相同 同一个论证沿另一个下标再走一遍;要把扫描继续推进更早的层就需要它
偏置 db = dZ.sum(axis=0) (H,),与 b 相同 b 在前向被广播到 N 行,这是 fan-out(复用),所以它的梯度把 N 份贡献求和

两个习惯能抓住很多错误。第一,形状纪律:参数的梯度永远和参数自己同形,X.T @ dZ 把 (D, N) 和 (N, H) 相乘,得到 (D, H)。形状匹配是必要条件,但不能证明公式正确。第二,前向广播,反向求和:db 这条规则把偏置每次使用产生的贡献相加,忘了求和是经典的偏置梯度 bug。

下面是在一个两层 ReLU 分类器上的全过程:前向、反向、形状检查,以及检验反向规则的有限差分检查。dZ2 那行 softmax - onehot 来自1.3 节;ReLU 的反向是一个逐元素的门,在预激活为正的地方放行梯度,其余地方阻断。

import numpy as np

np.random.seed(0)

# The same two-layer ReLU classifier as section 1.4: 8 examples, 3 features,
# 4 hidden ReLU units, 2 classes.
N, D, H, C = 8, 3, 4, 2
X = np.random.randn(N, D)
y = np.random.randint(0, C, size=N)
params = {
    "W1": 0.5 * np.random.randn(D, H), "b1": np.zeros(H),
    "W2": 0.5 * np.random.randn(H, C), "b2": np.zeros(C),
}

def forward_backward(params, X, y):
    W1, b1, W2, b2 = params["W1"], params["b1"], params["W2"], params["b2"]
    N = X.shape[0]

    # forward: backward reuses X, Z1 (or its mask), A1, W2, and logp
    Z1 = X @ W1 + b1                                     # (N, H)
    A1 = np.maximum(0, Z1)                               # (N, H)
    Z2 = A1 @ W2 + b2                                    # (N, C)
    Z2s = Z2 - Z2.max(axis=1, keepdims=True)             # stable log-softmax
    logp = Z2s - np.log(np.exp(Z2s).sum(axis=1, keepdims=True))
    loss = -logp[np.arange(N), y].mean()

    # backward — same graph, reverse order, three rules per linear layer
    dZ2 = np.exp(logp)                                   # softmax probabilities
    dZ2[np.arange(N), y] -= 1                            # the softmax - onehot rule
    dZ2 /= N                                             # the mean's 1/N, exactly once
    grads = {"W2": A1.T @ dZ2, "b2": dZ2.sum(axis=0)}
    dA1 = dZ2 @ W2.T                                     # dX rule, second layer
    dZ1 = dA1 * (Z1 > 0)                                 # ReLU gate: pass or block
    grads["W1"] = X.T @ dZ1
    grads["b1"] = dZ1.sum(axis=0)
    return loss, grads

loss, grads = forward_backward(params, X, y)
print(f"loss = {loss:.4f}\n")

# A parameter's gradient always has the parameter's shape. Check it, every time.
print(f"{'param':<7}{'shape':<10}{'gradient shape':<16}{'max |rel err| vs finite diff'}")
eps = 1e-5
for name, p in params.items():
    assert grads[name].shape == p.shape
    num = np.zeros_like(p)
    for idx in np.ndindex(p.shape):
        old = p[idx]
        p[idx] = old + eps; lp, _ = forward_backward(params, X, y)
        p[idx] = old - eps; lm, _ = forward_backward(params, X, y)
        p[idx] = old
        num[idx] = (lp - lm) / (2 * eps)
    rel = np.abs(num - grads[name]) / (np.abs(num) + np.abs(grads[name]) + 1e-12)
    print(f"{name:<7}{str(p.shape):<10}{str(grads[name].shape):<16}{rel.max():.2e}")

运行后打印:

loss = 1.0065

param  shape     gradient shape  max |rel err| vs finite diff
W1     (3, 4)    (3, 4)          4.32e-10
b1     (4,)      (4,)            5.78e-10
W2     (4, 2)    (4, 2)          7.99e-11
b2     (2,)      (2,)            6.01e-12

这个检查是一个中心差分,(L(p + eps) - L(p - eps)) / (2 * eps)。在光滑点上它的截断误差是 \(O(\varepsilon^2)\);eps 取得太小,浮点相消反而占上风。比较用相对误差,所以差异是相对梯度自身的大小来衡量的。这里打印的极小误差说明反向规则在本测试上与有限差分一致;测试是证据,不是证明。float64、远离折角时的工作规则:

  • 低于 1e-6:一致,就被检查的这些分量而言;
  • 高于 1e-2:找 bug,在代码里或在检查本身;
  • 夹在中间:先改变 eps、看清差异,再下结论。

相对误差大时要多查两件事。两个梯度都接近零时,也看绝对差:极小的绝对差在相对意义下会显得很大。再看扰动有没有翻转某个 ReLU 预激活的符号:那就跨过了折角,中心差分的论证不再适用,缩小 eps 或换一个远离折角的输入。并非每个 bug 都会报 1e0 附近的误差;漏掉转置通常先在形状检查上失败。

用 float64、在玩具规模上跑它。float32 没有足够的有效数字:eps = 1e-6 时一个正确的梯度可以报出 1.00e+00 的相对误差,因为 L(p + eps) 和 L(p - eps) 在 float32 里是同一个数。而且这个检查每个参数分量要两次完整前向,所以它是调试工具,永远不是训练的一部分。

这是你靠手算能达到的最高水平。它管用,但不能规模化:每一种新架构都意味着推导并编写新的反向规则,而每一条都是丢掉一个转置的机会。本节余下的部分搭的就是替你做这件事的机器。

节点必须记住什么

反向模式自动微分建立在一个组织性的想法上:前向在计算值的同时,也记下每个值是怎么算出来的。每个中间结果都成为一张有向无环图里的一个节点,每个节点存四类核心信息:

  1. 它的值:前向算出的那个数;
  2. 它的父节点:它是由哪些节点算出来的(我们叫它 _prev;构造函数里填它的参数叫 _children,那是 micrograd 对同一批节点的叫法。两种叫法都有人用,而且方向相反:数据流图把一个节点的输入叫它的父节点,表达式树把它们叫子节点。认对象,别认名字);
  3. 累积梯度(grad):初始为零,在反向过程中累加各个消费者传回的贡献;
  4. 一条局部反向规则:一个小函数,给定 dL/d(这个节点),用该运算的局部导数把正确的贡献加到每个父节点的梯度上。

然后反向按逆序访问节点,调用每个节点的规则。每条规则就是链式法则的一次应用:对 out = a * b,规则把 b * out.grad 加到 a.grad,把 a * out.grad 加到 b.grad,因为 d(a*b)/da = b、d(a*b)/db = a。没有任何一条规则需要知道图的其余部分。这种局部性,就是不管表达式多复杂、整个引擎都能保持很小的原因。

为什么用反向模式而不是前向模式?前向模式把导数从输入推向输出,代价随你要对其求导的输入个数增长。反向模式把导数从一个输出拉回所有输入,代价随输出个数增长。训练神经网络是对一个标量损失求几百万个参数的导数(一个输出,很多输入),所以反向模式一趟反向扫描就算出每个参数的梯度,代价只是前向的一个小常数倍。

对于一百万个参数,单边数值微分大约要额外做一百万次前向,中心差分则要两百万次。两者都有截断误差和浮点误差。自动微分应用解析的局部规则,避开了有限差分的截断误差,但前向模式和反向模式都仍有浮点舍入误差。前向模式要为每个输入方向扫描一次才能拼出完整梯度;反向模式一趟反向扫描就能得到它。前向加反向的总代价通常是单次前向的小常数倍,具体倍数取决于运算。内存开销来自为反向规则保存所需的前向值,除非选择在反向时重算这些值。

因为图是在普通 Python 代码运行时记录的,这种风格叫 define-by-run(或者”taping”,录带)。你写下 y = (a * b + c).tanh(),图就作为副作用出现了。更早的框架,比如 Theano(2000 年代后期在蒙特利尔大学开发),要你先定义一张静态图再编译;Chainer(2015)演示了 define-by-run 这条路,PyTorch(2017 年发布)把它变成了主流默认。取舍是:define-by-run 更容易调试(图就是你的程序),代价是每次迭代都要重建记录。

历史注记。 反向模式自动微分比它的名气更老。通常认为它出自 Seppo Linnainmaa 1970 年在赫尔辛基大学的硕士论文,而 Paul Werbos 1974 年的哈佛博士论文讨论了把这类梯度方法用在类网络的模型上。这个想法被重新发现了好几次,直到 Rumelhart、Hinton 和 Williams 1986 年的 Nature 论文展示出”反向传播”(同一种反向模式计算,专门用于神经网络)能学到有用的内部表示,神经网络界才注意到它。

六十行的可用引擎

下面是完整的引擎,外加第一个测试:一个 tanh 神经元 y = tanh(w1*x1 + w2*x2 + b),以及第二个复用了变量的表达式。这个类刻意做到最小:三种运算就足以暴露每一个重要的设计决定。

import math

class Value:
    """A scalar that remembers how it was computed, so gradients can flow back."""

    def __init__(self, data, _children=(), _op=""):
        self.data = float(data)   # the scalar value (forward pass)
        self.grad = 0.0           # dL/d(this node), filled in by backward()
        self._backward = lambda: None   # how to push out.grad into the parents
        self._prev = set(_children)     # its parents: the nodes this one was computed from
        self._op = _op            # label for debugging ("+", "*", "tanh")

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), "+")

        def _backward():
            self.grad += out.grad      # d(a+b)/da = 1
            other.grad += out.grad     # d(a+b)/db = 1
        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), "*")

        def _backward():
            self.grad += other.data * out.grad   # d(a*b)/da = b
            other.grad += self.data * out.grad   # d(a*b)/db = a
        out._backward = _backward
        return out

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), "tanh")

        def _backward():
            self.grad += (1.0 - t * t) * out.grad   # d tanh(x)/dx = 1 - tanh(x)^2
        out._backward = _backward
        return out

    def backward(self):
        # 1. Topological sort: dependencies/operands before their result.
        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)

        # 2. The base case: set the output gradient to 1, then walk the graph in reverse.
        self.grad = 1.0
        for node in reversed(topo):
            node._backward()

    __radd__ = __add__
    __rmul__ = __mul__


# A single tanh neuron: y = tanh(w1*x1 + w2*x2 + b)
x1, x2 = Value(1.5), Value(-2.0)
w1, w2 = Value(0.6), Value(0.4)
b = Value(0.1)

s = w1 * x1 + w2 * x2 + b
y = s.tanh()
y.backward()

print(f"y      = {y.data:.4f}")
print(f"dy/dw1 = {w1.grad:.4f}   (should be x1 * (1 - y^2) = {1.5 * (1 - y.data**2):.4f})")
print(f"dy/dw2 = {w2.grad:.4f}")
print(f"dy/db  = {b.grad:.4f}")
print(f"dy/dx1 = {x1.grad:.4f}")

# One value in three operand slots: gradients must ACCUMULATE.
a = Value(3.0)
f = a * a + a          # f = a^2 + a, so df/da = 2a + 1 = 7
f.backward()
print(f"f      = {f.data:.4f}")
print(f"df/da  = {a.grad:.4f}   (should be 2a + 1 = 7)")

运行后打印:

y      = 0.1974
dy/dw1 = 1.4416   (should be x1 * (1 - y^2) = 1.4416)
dy/dw2 = -1.9221
dy/db  = 0.9610
dy/dx1 = 0.5766
f      = 12.0000
df/da  = 7.0000   (should be 2a + 1 = 7)

这段代码里有三处细节值得细读。

闭包携带局部状态。 每个运算都定义一个新的 _backward 函数,它捕获操作数(self 和 other,对 tanh 则包括缓存的输出 t)。闭包让这些引用在运算返回后仍然可用。乘法是在反向运行时读取操作数的 .data,所以这些值在前向和反向之间必须保持不变;本引擎既不复制也不保护它们。图的“磁带”就是一组相互连接的 Python 闭包。

梯度用 += 累加。 在 f = a * a + a 里,节点 a 在两个消费它的运算中占了三个操作数位置:在 * 中两次,在 + 中一次。每次使用都对 \(\partial f/\partial a\) 有贡献,这些贡献必须相加。打印出的 7.0000(即 \(a = 3\) 处的 \(2a + 1\))证实三份贡献都到齐了。虽然 _prev 是集合,只访问 a 一次,但乘法闭包仍然会加上两个操作数位置的贡献。

包装让常数能用。 __radd__、__rmul__ 加上 isinstance 检查,让你可以写 2 * x + 1 而不用手动把字面量包成 Value。HW1 把这个模式扩展到减法、除法和幂。

人物。 这里的设计沿用 micrograd,一个核心大约一百行的自动微分引擎,由 Andrej Karpathy(OpenAI 创始成员之一,曾任 Tesla AI 总监)2020 年发布在 GitHub 上,后来又在他的 “Neural Networks: Zero to Hero” 系列讲座里当着镜头现场写出来。它的用意和我们一样是教学:一旦你亲手写过 _backward 闭包和拓扑排序,任何框架的 autograd 都不会再像魔法。

为什么拓扑序重要

backward() 不能随便按任何顺序去调用各节点的 _backward。一个节点的规则读的是它自己的 grad,再把贡献推给父节点,所以它的规则必须等该节点的每个消费者都存完贡献才能触发。否则梯度的一部分被传播了,其余部分却被搁浅。

解法是对图做一次拓扑排序:一种让每个结果节点都出现在它的依赖(它的输入操作数)之后的顺序。递归的 build 函数产生的正是这个:深度优先,一个节点只有在它的所有输入都被追加之后才被追加。然后按 reversed(topo) 处理就保证了不变量:当我们调用 node._backward() 时,node.grad 已经是最终值。

看 f = a * a + a 里的复用图。乘积节点 p = a * a 和求和节点 f 都碰到 a。按逆拓扑序,我们先处理 f(往 p.grad 存 1.0,往 a.grad 存 1.0),再处理 p(往 a.grad 存两次 a * p.grad = 3.0)。合计:1 + 3 + 3 = 7。要是先处理 p 再处理 f,你传播的 p.grad 还是 0.0。

不变量的另一半是链式法则的基例(base case):self.grad = 1.0 说的是 dL/dL = 1。你对一个标量调用 .backward() 时 PyTorch 做的是同一件事;对非标量输出,它要你显式提供这个起始梯度。

陷阱。 手写引擎里最常见的一个 bug,是在 _backward 里写了 self.grad = ... 而不是 self.grad += ...。每个变量只出现一次的测试全都会过,因为只有一个消费者时覆盖和累加恰好一样。这个 bug 在值被复用时才浮现,而复用是家常便饭:共享权重、残差支路、循环状态、被多个后续运算消费的张量。用 a * a + a 这类复用显式的表达式来测你的引擎,别只测直线型的公式。

与 torch.autograd 对照

自动微分引擎很容易错得不明显,专业的习惯是在同一个表达式上把它和一个参照对照。HW1 有一项测试就是在一个复合表达式上用中心差分做这件事,容差 1e-5,不需要 PyTorch;同一套测试里对 torch.autograd 的对照是可选的,不计分。PyTorch 自己两种方式都测(torch.autograd.gradcheck 把解析梯度和有限差分比较)。下面我们把一个中等程度纠缠的表达式(复用、嵌套、一个非线性)分别喂给我们的 Value 类和 PyTorch,比较每一个梯度。

import math
import torch

# --- the same Value class as before, condensed ---
class Value:
    def __init__(self, data, _children=()):
        self.data, self.grad = float(data), 0.0
        self._backward, self._prev = lambda: None, set(_children)  # _prev: its parents

    def __add__(self, other):
        other = other if isinstance(other, Value) else 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

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other))
        def _backward():
            self.grad += other.data * out.grad
            other.grad += self.data * out.grad
        out._backward = _backward
        return out

    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

    def backward(self):
        topo, visited = [], set()
        def build(v):
            if v not in visited:
                visited.add(v)
                for c in v._prev:
                    build(c)
                topo.append(v)
        build(self)
        self.grad = 1.0
        for node in reversed(topo):
            node._backward()

    __radd__ = __add__
    __rmul__ = __mul__


def expression(a, b, c):
    """Same formula for both engines: reuse, nesting, and a nonlinearity."""
    d = a * b + c
    e = (d + a).tanh()
    return e * d + b


# Ours
a, b, c = Value(0.7), Value(-1.3), Value(2.0)
out = expression(a, b, c)
out.backward()

# PyTorch
at = torch.tensor(0.7, dtype=torch.float32, requires_grad=True)
bt = torch.tensor(-1.3, dtype=torch.float32, requires_grad=True)
ct = torch.tensor(2.0, dtype=torch.float32, requires_grad=True)
outt = expression(at, bt, ct)
outt.backward()

print(f"{'':8s}{'micro':>12s}{'torch':>12s}{'|diff|':>12s}")
print(f"{'out':8s}{out.data:12.6f}{outt.item():12.6f}{abs(out.data - outt.item()):12.2e}")
for name, v, t in [("dL/da", a, at), ("dL/db", b, bt), ("dL/dc", c, ct)]:
    print(f"{name:8s}{v.grad:12.6f}{t.grad.item():12.6f}{abs(v.grad - t.grad.item()):12.2e}")

运行后打印:

               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

注意 expression 只写一次,既接收 Value 对象也接收 PyTorch 张量:Python 的鸭子类型让我们复用同一个公式。仍有 1e-8 左右的差,是因为我们的引擎用 Python 的 64 位浮点数计算,而这里的 PyTorch 张量是 float32,相对精度约 1e-7。HW1 可选的对照测试在两个引擎里都使用 float64,仍带容差比较,因为运算顺序和数学库可能带来微小的舍入差异。某个表达式也可能完全一致;完全一致本身并不能说明测试有错。

微积分失效之处:折角与无穷

普通的链式法则假设每个局部导数存在。几个常用函数在孤立的点上违反这个假设:\(\operatorname{relu}(x) = \max(0, x)\) 在 \(x = 0\) 处有折角,左导数为 0,右导数为 1;abs 在那里也有折角,左右导数分别为 -1 和 1。对 sqrt,导数 \(1/(2\sqrt{x})\) 在 \(x\) 从右边趋近 0 时无界增长,所以 0 处不存在有限导数。框架会规定这些点上的反向行为:可能返回一个选定的值、非有限值,也可能报错。下面的例子说明为什么这些选择重要。

import torch

torch.manual_seed(0)

# relu is not differentiable at 0. What does autograd return there?
x = torch.tensor([-1.0, 0.0, 1.0], requires_grad=True)
torch.relu(x).sum().backward()
print(f"relu'(x) at x = -1, 0, 1 : {x.grad.tolist()}")

# abs has the same kink at 0
xa = torch.tensor([-1.0, 0.0, 1.0], requires_grad=True)
xa.abs().sum().backward()
print(f"abs'(x)  at x = -1, 0, 1 : {xa.grad.tolist()}")

# sqrt at 0: the one-sided derivative is genuinely infinite
xs = torch.tensor([0.0, 1.0], requires_grad=True)
xs.sqrt().sum().backward()
print(f"sqrt'(x) at x = 0, 1     : {xs.grad.tolist()}")

# The where-trap: masking the VALUE does not mask the GRADIENT.
xw = torch.tensor([0.0, 4.0], requires_grad=True)
safe_looking = torch.where(xw > 0, torch.sqrt(xw), torch.zeros_like(xw))
safe_looking.sum().backward()
print(f"where(x>0, sqrt(x), 0)'  : {xw.grad.tolist()}")

# Keep the same piecewise values, but evaluate sqrt only on positive inputs.
xf = torch.tensor([0.0, 4.0], requires_grad=True)
positive = xf > 0
safe = torch.zeros_like(xf)
safe[positive] = torch.sqrt(xf[positive])
safe.sum().backward()
print(f"mask BEFORE sqrt       : {xf.grad.tolist()}")

# In-place mutation of a tensor that backward still needs is an error.
a = torch.ones(3, requires_grad=True)
b = a.exp()          # exp's backward rule reuses its OUTPUT b
b.add_(1.0)          # in-place: modifies b after it was saved
try:
    b.sum().backward()
except RuntimeError as e:
    print(f"in-place then backward -> RuntimeError: {str(e)[:75]}...")

运行后打印:

relu'(x) at x = -1, 0, 1 : [0.0, 0.0, 1.0]
abs'(x)  at x = -1, 0, 1 : [-1.0, 0.0, 1.0]
sqrt'(x) at x = 0, 1     : [inf, 0.5]
where(x>0, sqrt(x), 0)'  : [nan, 0.25]
mask BEFORE sqrt       : [0.0, 0.25]
in-place then backward -> RuntimeError: one of the variables needed for gradient computation has been modified by a...

在 ReLU 的折角处,PyTorch 返回 0。这是一个选择,不是定理:0 和 1 之间的任何值都是合法的”次梯度”(导数在折角处的凸分析推广),框架可以站得住脚地选 0、1 或 0.5。对 abs,PyTorch 在 0 处的答案同样是 0。理论上这个选择几乎从不重要(连续的输入落在折角上的概率为零),但在 float32 的实践里精确的零很常见(ReLU 自己就制造它们),Bertoin 等人(NeurIPS 2021)报告说 relu'(0) 的选择在 16 位和 32 位精度下能可测地改变训练结果,在 64 位下可忽略。你不必为这个约定失眠;你需要知道它存在,因为 HW1 的 starter 和 PyTorch 一样把 relu'(0) 定为 0。

sqrt 和 where 那两行才是真正耗掉调试时间的。PyTorch 在 0 处的反向规则返回 inf,反映的是无界的右侧极限,而不是一个有限导数。表达式 torch.where(x > 0, torch.sqrt(x), 0) 看起来保护了坏点,因为 x = 0 处的值是干净的 0。但两个分支张量都在 where 运行之前就算好了,所以反向仍然会经过 sqrt(0)。它无穷大的局部导数遇上零的传入梯度,产生 nan,可能污染受影响路径上更早的梯度。

新增的一行演示了修法:在计算 sqrt 之前先选出正输入。这保留了原本想要的分段前向值,并在被排除的分支返回 0 梯度,包括按约定在 0 处返回 0;该分段函数在这个边界上仍然没有有限导数。截断输入或加上 epsilon 也能稳定某些计算,但会改变零附近的函数;只加 epsilon 不能保护任意负输入。

陷阱。 先计算不安全的表达式再掩码其输出,即使损失有限,反向仍可能产生 NaN。调试时,把前向和反向都放进 with torch.autograd.detect_anomaly(): 中。它会检查反向产生的 NaN,并能报告出错运算对应的前向调用轨迹;它不会自动重跑反向,也不能捕捉所有无穷值。还应使用 torch.isfinite 检查输入、损失和梯度。这些检查有额外开销,适合调试时使用。

最后一行展示了 PyTorch 为原地运算建的护栏。以 _ 结尾的方法(add_、relu_)和 Python 的 += 会覆盖张量的存储,而不是分配新的。这有时为了省内存值得做,但它和反向模式的核心要求相撞:反向规则要复用前向的值。这里 exp 保存的是它自己的输出(因为 d exp(x)/dx = exp(x),导数最便宜的形式),随后的 add_ 原地改写了那个输出,反向就会悄悄用错的数去算。PyTorch 给每个张量记一个版本号,在保存时记下版本,若张量此后变了就拒绝运行反向规则:报错,而不是给出错误的梯度。我们的六十行引擎没有这种保护:在前向和反向之间改动 some_node.data,你得到的就是被污染的梯度。写你的 HW1 引擎时,留意生产级框架里有多少东西正是这类防御性的记账。

图的生命周期:保留、累加、分离

保留 Value 表达式的输出,会让从它可达的图保持存活。这个玩具引擎既不释放保存的状态,也不在反向后清空中间节点的 .grad。因此,对同一张图再次调用反向会复用旧梯度:对 a = 3 处的 f = a * a + a,第二次 f.backward() 后 a.grad 是 20,而不是 14。要干净地重算,必须先清空所有可达节点的梯度,再给输出梯度设种子。在训练中,每次迭代重建前向表达式,在累积下一次更新的梯度之前清空复用的参数梯度。

PyTorch 区分图结构和为反向保存的张量。默认情况下,每条反向规则完成后会释放自己保存的引用;仍被别处引用的张量会继续存在。图节点也可能通过输出的 grad_fn 保持可达,但如果下一次反向需要已经释放的张量,就会报错。不保存张量的简单运算可能是例外。下面第一个实验的乘法需要保存的值,所以第二次反向确实会失败。

还要区分“计算中间梯度”和“把它保存在 .grad 里”。默认情况下,PyTorch 会在需要梯度的叶张量上累加 .grad,模型参数通常就是这类叶张量。非叶中间张量可以参与反向,而它的 .grad 仍为 None;若需要查看这个字段,要在反向之前调用该张量的 retain_grad()。retain_grad() 保存的是这个梯度,retain_graph=True 保留的则是再次反向所需的保存状态。我们的玩具引擎在每个节点上都保留 .grad 字段。

import torch

torch.manual_seed(0)

# 1. This backward releases saved values; repeating it needs those values.
w = torch.tensor(2.0, dtype=torch.float32, requires_grad=True)
loss = (w * w) * 3.0                       # dL/dw = 6w = 12
loss.backward()
print(f"after 1st backward: w.grad = {w.grad.item():.1f}")
try:
    loss.backward()
except RuntimeError as e:
    print(f"2nd backward -> RuntimeError: {str(e)[:60]}...")

# 2. retain_graph=True keeps the buffers; gradients then ACCUMULATE.
w.grad = None
loss = (w * w) * 3.0
loss.backward(retain_graph=True)
loss.backward()
print(f"two backwards with retain_graph: w.grad = {w.grad.item():.1f}  (12 + 12)")

# 3. detach() cuts the recording: b is a constant as far as autograd knows.
a = torch.tensor(3.0, requires_grad=True)
b = (a * a).detach()                       # value 9.0, no history
c = b * a
c.backward()
print(f"c = detach(a*a) * a: dc/da = {a.grad.item():.1f}  (only the last factor counts)")

# 4. This multiplication in no_grad() creates no reverse-mode history.
with torch.no_grad():
    d = a * a
print(f"inside no_grad: (a*a).requires_grad = {d.requires_grad}")

运行后打印:

after 1st backward: w.grad = 12.0
2nd backward -> RuntimeError: Trying to backward through the graph a second time (or direc...
two backwards with retain_graph: w.grad = 24.0  (12 + 12)
c = detach(a*a) * a: dc/da = 9.0  (only the last factor counts)
inside no_grad: (a*a).requires_grad = False

第一块说明了为什么普通训练每次迭代都建立新图。retain_graph=True 为多次反向复用同一次前向计算保留状态,例如在一次更新之前分别计算两个损失的梯度。它不会在参数变化后重算前向值,所以不能用保留图代替 opt.step() 后的新前向。如果只需要合并梯度,对损失之和做一次反向即可。

第二块显示 w.grad 变成 24:参数梯度跨调用累加。梯度累积把这一点变成工具:对 k 个小 minibatch 各在自己的新图上做一次 backward(),再做一次 opt.step()。若各 minibatch 等大小、组内参数不变,且损失是独立样本损失的均值,每次对 loss / k 做反向,才能匹配合并后的 batch 梯度。大小不等时,应把每个 minibatch 的平均损失乘以 batch_size / total_examples。训练模式下的 BatchNorm 等依赖 batch 的运算会破坏这种等价。参数梯度只在整组开始前清空一次,不在组内清空。PyTorch 的梯度累积示例用的正是这种缩放。

第三块和第四块控制对哪些计算求导。detach() 返回一个共享同一存储、但与此前历史断开的张量;后续运算仍能通过其他输入建立图。它是你将来会遇到的每一种 stop-gradient 技巧背后的机制:冻结预训练编码器只训练头部,或者权重不能收到梯度的目标网络。它不会撤销记录原计算已经花费的计算和内存,修改分离后的存储也仍可能影响原张量。torch.no_grad() 阻止块内运算的反向模式记录。推理不需要梯度时可以使用它;张量是在不再被引用时释放,而不一定是下一行。它和 model.eval() 不同,后者控制 dropout 和 BatchNorm 等模块的行为。

对导数求导

在我们的引擎里,grad 字段是普通的浮点数,是死胡同。没有什么拦着你问一个更尖锐的问题:导数的导数是什么?如果反向本身也是用被记录的运算执行的,梯度就成了和别的函数一样可微的函数,反向模式可以在它上面再跑一次。PyTorch 通过 create_graph=True 暴露这一点,最干净的用法是函数式接口 torch.autograd.grad,它把梯度作为张量返回,而不是塞进 .grad:

import torch

torch.manual_seed(0)

# Higher-order derivatives of tanh at x = 0.5, checked against closed forms.
x = torch.tensor(0.5, requires_grad=True)
y = torch.tanh(x)

(g1,) = torch.autograd.grad(y, x, create_graph=True)   # graph OF the gradient
(g2,) = torch.autograd.grad(g1, x, create_graph=True)
(g3,) = torch.autograd.grad(g2, x)

t = y.item()
print(f"f   = tanh(0.5)          = {t:.6f}")
print(f"f'  autograd = {g1.item():9.6f}   analytic 1 - t^2          = {1 - t*t:9.6f}")
print(f"f'' autograd = {g2.item():9.6f}   analytic -2 t (1 - t^2)   = {-2*t*(1 - t*t):9.6f}")
print(f"f'''autograd = {g3.item():9.6f}   analytic (1 - t^2)(6t^2-2) = {(1 - t*t)*(6*t*t - 2):9.6f}")

# A full Hessian, one call: f(w) = w0^2 * w1 at w = (1.5, -2.0)
def f(w):
    return w[0] ** 2 * w[1]

w = torch.tensor([1.5, -2.0])
H = torch.autograd.functional.hessian(f, w)
print(f"\nHessian of w0^2 * w1 at (1.5, -2.0):")
print(f"  autograd: [[{H[0,0]:.1f}, {H[0,1]:.1f}], [{H[1,0]:.1f}, {H[1,1]:.1f}]]")
print(f"  analytic: [[2*w1, 2*w0], [2*w0, 0]] = [[-4.0, 3.0], [3.0, 0.0]]")

运行后打印:

f   = tanh(0.5)          = 0.462117
f'  autograd =  0.786448   analytic 1 - t^2          =  0.786448
f'' autograd = -0.726862   analytic -2 t (1 - t^2)   = -0.726862
f'''autograd = -0.565209   analytic (1 - t^2)(6t^2-2) = -0.565209

Hessian of w0^2 * w1 at (1.5, -2.0):
  autograd: [[-4.0, 3.0], [3.0, 0.0]]
  analytic: [[2*w1, 2*w0], [2*w0, 0]] = [[-4.0, 3.0], [3.0, 0.0]]

由自动微分计算出的 tanh 的三个导数,每一个都是对算出上一个的那张图求导得到的,还有一个和手算逐项吻合的二乘二 Hessian。想想第三行需要什么:一张记录了”前向的反向的反向”的图。机制还是同一个六十行的想法,用在了它自己的输出上。

代价值得敬畏。每多一阶,运行时间乘上一个常数因子,并且(又是反向模式的标志)保留更多的中间状态。更糟的是,一个有 \(n\) 个参数的网络的完整 Hessian 是一个 \(n \times n\) 的对象,要逐行物化需要 \(n\) 趟反向扫描:\(n = 2\) 时没问题,\(n = 10^7\) 时想都别想。付得起的对象是 Hessian 向量积 \(H\mathbf{v}\),对于二阶连续可微的损失,它是标量 \(\mathbf{g}^\top \mathbf{v}\) 的梯度,其中 \(\mathbf{g} = \nabla L\),\(\mathbf{v}\) 保持常量:多一趟反向扫描,这是 Pearlmutter 1994 年想出的技巧,比它成为曲率诊断和二阶优化器研究的标准工具早了几十年。

你会在哪里真正遇到双重反向?梯度惩罚损失正则化的是一个梯度的范数(第 4 章的 WGAN-GP 目标是典型例子),这需要对一次 backward 求导;元学习方法对整个内层训练循环求导;物理信息网络把 d(output)/d(input) 项直接放进损失。凡是你在真实代码里看到 create_graph=True,发生的就是本节讲的事。

从六十行到真正的框架

这个玩具和 torch.autograd 之间差的是什么?主要是工程,不是概念。

张量,而不是标量。 PyTorch 的节点持有整个张量,每条反向规则是一个向量化的核:矩阵乘法 \(Z = XW\) 的局部导数本身就用矩阵乘法表达(\(\partial L/\partial X = (\partial L/\partial Z)\, W^\top\),\(\partial L/\partial W = X^\top\, \partial L/\partial Z\)),你在反向传播里见过。于是一个图节点可以代表我们引擎里的几百万个标量节点,向量化和优化后的计算核解释了很大一部分加速。

广播规则。 形状 (n,) 的偏置 b 加到形状 (B, n) 的 batch 上时,前向隐式地把 b 复制到 B 行;反向因此必须把传入的梯度沿广播维度求和。把这些归约做对,是手写引擎里经典的形状 bug 来源,开头的 NumPy 例子展示了所需的求和。

图的生命周期与内存。 反向规则保存需要的前向值,这就是训练比推理更耗内存的原因;参数梯度和优化器状态再加上一份。PyTorch 通常随反向推进释放保存的引用;retain_graph=True 推迟释放。无需梯度时,torch.no_grad() 跳过反向模式记录。我们的玩具引擎没有显式的释放策略,而是依赖 Python 回收不可达的节点及其闭包引用环。

健壮性。 真正的引擎要处理原地修改检测、混合的 dtype 和设备、双重反向(对反向本身求导),以及迭代式的图遍历:我们递归的 build 在接近默认约 1000 层递归调用的长链上会撞上 Python 的递归上限,这是你的 HW1 引擎通过测试之后值得做的一项改进。

磁带、图,再回到磁带。 define-by-run 的磁带也处在一场已经摆动了两次的设计争论的一个特定位置上。在它之前的静态图框架(Theano,以及 2015 年之后的 TensorFlow 1.x)要你预先声明整个计算,实际上是用一门小小的图语言写程序,然后编译并运行。这带来了实实在在的好处:手里有整张图,框架就能融合运算、规划内存,并把结果不带 Python 地部署到生产环境。它也让日常工作很痛苦:错误在离引发它的那一行很远的地方浮现,对一个张量的值做一个简单的 if 都需要特殊的图构造。Chainer 然后是 PyTorch 押注研究者的迭代速度更重要,并且赢了:到 2010 年代末,define-by-run 成了主导的研究接口。但磁带把性能留在了桌上:每个运算都经过 Python 分发,而框架一次只见到一个运算时不可能做跨运算的优化。于是这个领域又往回走了一段:JAX 的 jit 追踪 Python 函数,再用 XLA 编译,PyTorch 2.0(2023)加入了 torch.compile,从运行中的字节码里捕获图并编译,捕获失败时回退到 eager 执行。现代的综合不是旧的静态世界(你仍然编写、运行、调试命令式代码),但它悄悄重建了编译器一直想要的那张图。PyTorch in Practice会回到这在日常里意味着什么。

这些都不改变你该带走的图景:loss.backward() 按逆拓扑序走一张记录下来的 DAG,每个节点调用一条小小的局部导数规则,把贡献求和进 .grad。框架围绕同样的反向扫描,增加了保存张量、管理梯度缓冲区及控制记录的策略。

深入阅读

内存墙与 checkpointing。 随着深度、batch 大小或序列长度增加,保存前向激活可能成为主要的内存约束;参数、梯度和优化器状态也占内存。梯度 checkpointing(也叫激活重算或 rematerialization)保存选定的激活,并在反向时重算其余值。Chen 等人 2016 年的论文“Training Deep Nets with Sublinear Memory Cost”描述了在其假设下,存储随深度的平方根增长、计算代价相当于多一次前向的方案。实际节省取决于模型和检查点位置,不能保证降低一个数量级。torch.utils.checkpoint 在 PyTorch 中提供了这种权衡。我们的玩具引擎需要修改后,才能释放并重算闭包保留的值。

更干净的词汇:VJP 与 JVP。 文献把 _backward 做的事叫作向量-雅可比积(VJP):每个节点的规则拿到传入的梯度向量,把它乘上该运算的局部雅可比,却从不把雅可比作为矩阵建出来。反向模式是从输出往回的一串 VJP;前向模式是从一个输入往前的镜像的一串雅可比-向量积(JVP)。这种组合的视角已经变成了接口:JAX,以及 PyTorch 的 torch.func 命名空间,把 grad、vjp、jvp 和 vmap 作为可组合的函数变换暴露出来,于是”一个函数的梯度”本身就是一个你可以再变换的函数。高阶技巧从两种模式的组合里自然掉出来,例如前向套反向(forward-over-reverse)对梯度函数应用 JVP 来计算 Hessian 向量积。它往往比反向套反向更省内存,但最佳选择取决于运算和实现。

反向传播在折角处算的是什么,仍在形式化之中。 前面的 relu'(0) 约定是一个活跃研究问题的小窗口:当程序含有不可微的运算时,autograd 返回的东西并不总是复合函数的梯度,甚至不总是次梯度,优化理论家(Bolte、Pauwels 等,2020 年前后起的工作)发展了”保守梯度”之类的概念,来刻画实际实现的反向传播究竟产生了什么、以及 SGD 为什么用它仍能收敛。对日常实践,结论仍是本节那个谦逊的版本(知道约定,把不安全的表达式挡在计算之外),但值得知道,整洁的链式法则故事确实有开放的边缘。

真正的引擎怎样调度扫描。 我们的 backward() 是递归的深度优先拓扑排序,正确但脆弱(递归深度),而且是串行的。PyTorch 的 autograd 引擎做的是引用计数式反向扫描的等价物:它为每个节点数一数有多少消费者必须先存入梯度贡献、它自己的规则才能触发,并处理计数归零的节点,这是 Kahn 算法的一种形式,不需要递归,还暴露出并行性(独立分支可以同时就绪,但实际并发还取决于设备和流之间的依赖)。被保护的不变量正是复用例子里那一条:任何规则都不能在它的节点梯度最终确定之前触发。

梯度累积不总等于大 batch。 loss / k 的等价要求各 minibatch 等大小、损失是参数固定时按样本求平均。大小不等时要按样本占比加权;训练模式下的 BatchNorm 这类依赖 batch 的运算(S07)会直接破坏等价。

常见陷阱

  • 症状: 简单公式的梯度完全正确,变量出现两次就错。原因: _backward 用 = 覆盖而不是用 += 累加。修法: 累加,并加一个你知道真实导数的回归测试,比如 a * a + a。
  • 症状: 梯度包含先前更新的贡献。原因: 参数的 .grad 字段会保留;复用玩具引擎的同一张图还可能复用旧的中间梯度。修法: 在每次更新的累积组开始之前清空参数梯度,并为每个 minibatch 建立新的前向图。若要在同一张玩具图上干净地重算,先清空所有可达节点的梯度,再给输出梯度设种子。
  • 症状: 在一长串运算上 RecursionError: maximum recursion depth exceeded。原因: 深度优先的拓扑排序是递归的,Python 默认的递归上限约 1000 帧。修法: 用显式的栈把排序改写成迭代版。
  • 症状: 每个梯度都回来 0.0。原因: 要么输出的梯度从未被置为 1.0,要么前向的某处掉出了图,比如调用 math.tanh(x.data) 再把结果包进一个没有父节点的新 Value。修法: 在遍历之前把输出的梯度置为 1.0,并确保每一个运算都走记录 _prev 和 _backward 的方法。
  • 症状: 比较结果在最后几位不同,于是把测试放宽到 1e-2。原因: float32 与 float64 混用可以解释小差异,但不能证明实现正确。修法: 在两个引擎里都用 float64,并采用适当的相对与绝对容差,尤其是在零附近。排查剩余差异,不要放宽 HW1 的测试。

动手试试

  1. 给 Value 加上 __pow__(常数指数)、__neg__、__sub__、__truediv__,以及反射形式 __rsub__ 和 __rtruediv__(把 a / b 定义为 a * b**-1)。使用与 torch.autograd 对照一节的工具核对各个新运算。如果没有安装 PyTorch,就用手推值和光滑点处的中心差分检查。
  2. 加上 exp(),然后用两种方式实现 sigmoid:由你的原语组合(1 / (1 + (-x).exp())),以及作为一个融合的单一运算、局部导数为 s * (1 - s)。在若干适中输入上,检查两者的梯度是否在浮点容差内一致。字面量 1 / ... 需要练习 1 中的 __rtruediv__ 方法。
  3. 故意把 __mul__ 里的一个 += 改成 =。找出梯度会出错的最小表达式,以及一个梯度仍然正确的非平凡表达式。解释两者。
  4. 只用你的 Value 类,用平方误差损失对两个参数 w、b 做梯度下降,从五个数据点拟合 \(y = 2x - 1\)。你现在有了不依赖任何框架的端到端训练。
  5. 解释为什么你的 Value 引擎照现在这样写算不了二阶导数(grad 是什么类型,它需要是什么类型?)。然后勾勒(或只对 + 和 * 实现)一个 _backward 构建新的 Value 图、而不是累加浮点数的版本,并用 create_graph=True 的 torch.autograd.grad 核对 \(\mathrm{d}^2(a^3)/\mathrm{d}a^2 = 6a\)。

延伸阅读

  • Rumelhart, Hinton, and Williams, “Learning representations by back-propagating errors,” Nature, 1986. nature.com/articles/323533a0:让反向模式微分在神经网络里出名的四页论文;值得一读的是它需要说的有多少。
  • Baydin, Pearlmutter, Radul, and Siskind, “Automatic Differentiation in Machine Learning: a Survey,” JMLR, 2018. arxiv.org/abs/1502.05767:前向模式对反向模式、以及 “backprop” 在更广的 AD 版图里位置的最清晰地图。
  • Karpathy, micrograd (2020). github.com/karpathy/micrograd:本节和 HW1 参照的那个约一百行的引擎;读它的测试,看一次针对 PyTorch 的参照检查是怎么组织的。
  • Griewank and Walther, Evaluating Derivatives: Principles and Techniques of Algorithmic Differentiation, 2nd ed., SIAM, 2008. epubs.siam.org/doi/book/10.1137/1.9780898717761:AD 理论的标准参考,包括”深入阅读”里勾勒的 checkpointing 权衡。
  • Pearlmutter, “Fast Exact Multiplication by the Hessian,” Neural Computation, 1994. doi.org/10.1162/neco.1994.6.1.147:Hessian 向量积技巧:多一趟就得到精确的曲率信息,从不物化 Hessian。
  • Bertoin, Bolte, Gerchinovitz, and Pauwels, “Numerical influence of ReLU’(0) on backpropagation,” NeurIPS 2021. arxiv.org/abs/2106.12915:折角约定在 16、32、64 位精度下对真实训练到底做了什么。
  • Paszke et al., “Automatic differentiation in PyTorch,” NIPS 2017 Autodiff Workshop. openreview.net/forum?id=BJJsrmfCZ:关于 torch.autograd 的短篇设计论文:磁带、原地版本号,以及围绕它们的工程。
  • Tokui et al., “Chainer: A Deep Learning Framework for Accelerating the Research Cycle,” KDD 2019. arxiv.org/abs/1908.00213:define-by-run 原创者自己对这一设计、以及它为何在研究中取代静态图的叙述。
  • Dive into Deep Learning, section 2.5, “Automatic Differentiation.” d2l.ai/chapter_preliminaries/autograd.html:张量层面的同一套机制,练习和 HW1 很搭。
  • PyTorch documentation, “Autograd mechanics.” docs.pytorch.org/docs/stable/notes/autograd.html:关于图的保留、原地规则和双重反向的权威页面。

上课前你应该能够

  • 列出每个图节点存的四个核心字段,并解释闭包如何把前向的值带进反向。
  • 解释为什么梯度必须用 += 累加,以及为什么 a * a + a 是暴露覆盖 bug 的那个测试。
  • 用复用例子解释为什么反向扫描必须按逆拓扑序进行。
  • 说出 relu'(0) 的约定,并解释被掩码的 sqrt 为什么仍会产生 NaN 梯度(where 陷阱)。
  • 解释第二次 backward() 什么时候需要保留保存的张量、为什么参数梯度会累加、detach() 如何切断历史,以及 no_grad() 如何阻止反向模式记录。
  • 解释什么时候清空参数梯度,以及梯度累积时如何缩放 minibatch 的平均损失。
  • 复现 S04 阅读 里的向量化层规则,并在一个小 MLP 上通过梯度检查。

关联

  • 本节属于 Quiz 1(模块 1:S01–S08)的范围,在 S09 进行。
  • 本节是 HW1(micro-autograd,S04 发布,S08 截止)的主要参考,作业用已知的梯度值和一次像第一个例子那样的有限差分检查给你的引擎评分;和 torch.autograd 的对照是可选的、不计分的附加项。
  • 面向 PyTorch 的后果(zero_grad()、no_grad()、detach())在 1.8(S08)以及之后的每一份作业里回归。

本页是英文阅读的中文译本,供辅助阅读;Canvas 上的英文 PDF 是指定版本,术语、作业和测验以英文为准。


← S04 · 反向传播 I · 全部阅读 · S06 · 优化器 →