查看全课大纲(第 6 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

反向传播:手推一个两层网络

反向传播到底在反什么?

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

上一节我们把神经网络拆成了「线性变换 + 激活函数」的堆叠,并知道训练就是在梯度下降的方向上一步步挪动参数。但还有一个关键问题没有回答:一个几十层的网络,几十万个参数,梯度到底怎么算出来的? 本节就把这件事从头推一遍——先用计算图讲清链式法则怎么在图上流动,再手推一个两层网络的每一个梯度公式,最后用 PyTorch 的 autograd 逐项对账。学完本节你能回答:反向传播「反」的到底是什么?为什么它比逐个参数做数值微分快得多?以及 W.grad is None 这类报错是怎么来的。

先修:矩阵乘法与形状规则、偏导数与多元链式法则(见站内数学基础课,本节只用到一阶偏导)。后续:第 2 章余下几节讲激活函数、优化器与正则化,都会反复回到「梯度从哪来」这个问题。

本节的组织顺序沿用老课第三课的讲法:先说明为什么必须换一种算法(手动求导不可行)→ 用计算图和链式法则打底 → 引出后向传播的两阶段与三种情形 → 再落到一个具体网络上手推、与 autograd 对账。老课那套「B 图记账」的说法我一并保留,它和今天的 δ 记号是同一件事。

💡 核心导读

  • 计算图:把前向计算写成一串节点与边,每个节点是一次张量运算。
  • 链式法则在图上流动:上游传来的梯度乘上本节点的局部导数,再分发给下游。
  • 两阶段:前向传播保存中间结果,反向传播复用它们算梯度——省的是重复计算,付出的是内存。
  • autograd:PyTorch 在张量上自动建图,loss.backward() 一次性把梯度填进每个叶子张量的 .grad。

🧭 前置知识

  • 矩阵乘法的形状规则:(m, n) @ (n, p) -> (m, p)。
  • 多元链式法则:∂z∂x=∂z∂y⋅∂y∂x\frac{\partial z}{\partial x} = \frac{\partial z}{\partial y}\cdot\frac{\partial y}{\partial x}。
  • Sigmoid 及其导数:σ(x)=11+e−x\sigma(x) = \frac{1}{1+e^{-x}},σ′(x)=σ(x)(1−σ(x))\sigma'(x) = \sigma(x)\bigl(1-\sigma(x)\bigr)。
  • 均方误差:L=12∑(z2−y)2L = \frac{1}{2}\sum (z_2 - y)^2(本节取单个样本,求和退化为一项)。

计算图:把前向计算画出来

先说为什么非换算法不可。训练一步要做的事,是「算出损失对每个参数的偏导,再照梯度下降挪一步」。如果按高数课的做法,对每个参数逐个求偏导、手写表达式,那么网络一深、参数一多(几十万个),表达式会爆炸,改一次结构就要全部重写——这条路在工程上不可行。后向传播要解决的,正是「怎么高效地把全部参数梯度一次算出来」。

办法是把前向计算画成一张图。神经网络的一次前向,本质上是一串张量运算的复合;写成有向无环图(DAG)之后,「求导」就变成了「在图上按路径做乘法再相加」。

  • 节点分两类:输入节点(数据与参数)和计算节点(一次运算,比如加、乘、sigmoid)。
  • 边表示输入关系:若节点 yy 的计算需要 xx,就从 xx 连一条边到 yy。

一句话把两件事接上:一个人工神经网络本身就是一个计算图——所谓「前向传播」,就是往这张图的输入节点灌数据、沿边一路算到输出。

以一个标量例子说明。设 e=(a+b)×(b+1)e = (a+b)\times(b+1),取 a=2, b=1a=2,\ b=1:

  a=2 ─────┐
           ├──► c = a + b = 3 ──┐
  b=1 ─────┤                    │
           └──► d = b + 1 = 2 ──┴──► e = c * d = 6

要求 ∂e∂a\frac{\partial e}{\partial a} 与 ∂e∂b\frac{\partial e}{\partial b},规则只有一句:把从该变量到输出的每条路径上的局部导数连乘,再把各条路径的结果相加。

  • aa 只有一条路径 a→c→ea \to c \to e:∂e∂a=∂e∂c⋅∂c∂a=d⋅1=2\frac{\partial e}{\partial a} = \frac{\partial e}{\partial c}\cdot\frac{\partial c}{\partial a} = d \cdot 1 = 2。
  • bb 有两条路径:经 cc 的贡献 d⋅1=2d\cdot 1 = 2,经 dd 的贡献 c⋅1=3c\cdot 1 = 3,合计 ∂e∂b=5\frac{\partial e}{\partial b} = 5。

为什么这件事值得单独画图:网络里同一个参数会通过多条路径影响损失(例如一个隐藏层权重既影响自己这一路的输出,也会被后续所有层复用)。没有图,人很容易漏掉某条路径;有了图,「沿边回传 + 分叉处相加」就是机械动作。

为什么是反向,而不是前向

链式法则本身不规定遍历方向,图上求导有两种走法:

  • 前向模式:从输入出发,沿着边把「输入对上游的影响」一路推下去。算完一次,得到的是一个输入对所有输出的导数。
  • 反向模式:从输出出发,沿着边把「输出对下游的要求」一路推回来。算完一次,得到的是所有输入对一个输出的导数。

深度学习的处境是「参数极多、输出极少」:一个网络可能有几十万个参数,而损失只是一个标量。用前向模式,要为每个参数各跑一遍,代价随参数数量线性增长;用反向模式,一次反向就能拿到全部参数的梯度,代价与一次前向同阶。这就是所有主流框架都实现反向模式的根本原因,而不是因为「反向」这个名字更酷。

代价也有:反向模式必须保存前向的中间结果(即下文手推时出现的 z1,a1z_1, a_1 等),显存占用随网络深度增长。这正是训练比推理更吃显存的原因之一。

老课的讲法:B 图与三种情形

老课(第三课)讲后向传播时用了一个很朴素的记账法:B 图——把每个计算节点切成左右两半,左半存「本节点对输入的导数」,右半存「函数值」。

        ┌───────────────┐
  x ───►│   计算节点      │───► y
        ├───────┬───────┤
        │  左半  │  右半  │
        │ ∂y/∂x │  y 值  │
        └───────┴───────┘

有了这张表,两阶段就是围绕它做两件事:

  • 前向:从左往右算,把每个节点的函数值填进右半,并继续往右推;
  • 后向:把箭头调个头,从输出往左走,每到一处用「上游传来的梯度 × 左半存的导数」得到本节点的梯度,再继续往左传。

老课把网络里的计算归纳成三种情形,并说它们「基本覆盖了神经网络的所有组成形式」:

  1. 函数复合 f(g(x))f(g(x)):导数相乘,dfdx=f′(g(x)) g′(x)\frac{df}{dx} = f'(g(x))\,g'(x);
  2. 函数相加 f1+f2f_1 + f_2:分叉处相加,两条路径各传一份回来;
  3. 线性节点 y=Wxy = Wx:对输入的导数就是 WW(这是误差信号回传式里 W2⊤W_2^{\top} 的来历);对权重的导数就是输入 xx(这是参数梯度里 x⊤δx^{\top}\delta、a1⊤δa_1^{\top}\delta 的来历)。两条是对偶的,别记混。

还有一处老课特意提问的细节:后向的第一步给输出节点喂一个 1。因为整条链要算的是 ∂L/∂θ\partial L/\partial\theta,而 ∂L/∂L=1\partial L/\partial L = 1——这个 1 就是误差信号的起点。下面手推时,δz2=z2−y\delta_{z_2} = z_2 - y 正是「1 × 损失对输出的导数」,B 图左半记的就是它。

手推一个两层网络

下面把同一套规则用到真正的两层全连接网络上。网络定义:

  • 输入 xx 形状 (1, 2),一个样本、两个特征。
  • 隐藏层:z1=xW1+b1z_1 = xW_1 + b_1,a1=σ(z1)a_1 = \sigma(z_1);W1W_1 形状 (2, 3),b1b_1 形状 (3,)。
  • 输出层:z2=a1W2+b2z_2 = a_1W_2 + b_2;W2W_2 形状 (3, 1),b2b_2 形状 (1,)。
  • 损失:L=12(z2−y)2L = \frac{1}{2}(z_2 - y)^2。

前向:顺便把中间结果留下来

前向除了算出 LL,还必须保存 x,z1,a1,z2x, z_1, a_1, z_2。反向每一步都要用它们:例如 σ′(z1)\sigma'(z_1) 需要 a1a_1,∂L∂W2\frac{\partial L}{\partial W_2} 需要 a1⊤a_1^{\top}。这就是反向传播「快」的代价——用内存换时间。

形状流转(batch=1):
  x (1,2) ──W1(2,3)──► z1 (1,3) ──sigmoid──► a1 (1,3)
                                                  │
                                          W2(3,1) │
                                                  ▼
                                              z2 (1,1) ──MSE──► L (标量)

反向:四个公式,逐个对账

记误差信号 δz2=∂L∂z2\delta_{z_2} = \frac{\partial L}{\partial z_2}。因为 L=12(z2−y)2L=\frac{1}{2}(z_2-y)^2,有 δz2=z2−y\delta_{z_2} = z_2 - y。

  1. 输出层参数:∂L∂W2=a1⊤δz2\frac{\partial L}{\partial W_2} = a_1^{\top}\delta_{z_2},∂L∂b2=δz2\frac{\partial L}{\partial b_2} = \delta_{z_2}。
  2. 误差信号往回传一层:δa1=δz2W2⊤\delta_{a_1} = \delta_{z_2}W_2^{\top}。
  3. 穿过激活函数:δz1=δa1⊙σ′(z1)=δa1⊙a1⊙(1−a1)\delta_{z_1} = \delta_{a_1}\odot\sigma'(z_1) = \delta_{a_1}\odot a_1\odot(1-a_1),其中 ⊙\odot 表示逐元素乘。
  4. 隐藏层参数:∂L∂W1=x⊤δz1\frac{\partial L}{\partial W_1} = x^{\top}\delta_{z_1},∂L∂b1=δz1\frac{\partial L}{\partial b_1} = \delta_{z_1}。

规律很清楚:参数梯度 = 该层输入(转置)乘该层误差信号;往前的误差信号 = 本层误差信号乘权重转置,再乘激活导数。二者交替,一直退到输入侧为止。

用 B 图的语言再对一遍:每个 δ\delta 是沿边回传的累积梯度——每到一处等于「上游汇入之和 × 左半存的局部导数」,例如 δz1=δa1⊙σ′(z1)\delta_{z_1} = \delta_{a_1}\odot\sigma'(z_1),而左半只存了 σ′(z1)\sigma'(z_1) 这一项;参数梯度里的 a1⊤a_1^{\top}、x⊤x^{\top} 则来自「线性节点对权重的导数就是输入」,W2⊤W_2^{\top} 来自「对输入的导数就是权重」。两套说法一一对应:老课把这两类量分装在左右两半,现代框架直接把张量当节点、把梯度挂到张量上。

下面一个代码块同时做两件事:让 autograd 算一遍梯度,再按上面四个公式手算一遍,最后逐项比较。

import torch

torch.manual_seed(0)

x = torch.tensor([[1.0, 2.0]])                 # (1, 2)
y_true = torch.tensor([[0.5]])                 # (1, 1)
# 注意:先建张量再 requires_grad_(),保证 W1 本身是叶子张量,梯度才会落进 W1.grad
W1 = (torch.randn(2, 3) * 0.1).requires_grad_(True)
b1 = torch.zeros(3, requires_grad=True)
W2 = (torch.randn(3, 1) * 0.1).requires_grad_(True)
b2 = torch.zeros(1, requires_grad=True)

# ---- 前向:留下中间结果 ----
z1 = x @ W1 + b1
a1 = torch.sigmoid(z1)
z2 = a1 @ W2 + b2
loss = 0.5 * (z2 - y_true).pow(2).sum()

# ---- 反向:交给 autograd ----
loss.backward()

# ---- 手推:同样的四个公式,用 detach 后的数值再算一遍 ----
W1v, W2v = W1.detach(), W2.detach()
a1v = torch.sigmoid(x @ W1v + b1.detach())
z2v = a1v @ W2v + b2.detach()
delta_z2 = z2v - y_true
grad_W2_manual = a1v.t() @ delta_z2
delta_z1 = (delta_z2 @ W2v.t()) * (a1v * (1 - a1v))
grad_W1_manual = x.t() @ delta_z1

print("loss =", round(loss.item(), 4))
print("autograd grad_W2 =", [[round(v, 4) for v in r] for r in W2.grad.tolist()])
print("手推     grad_W2 =", [[round(v, 4) for v in r] for r in grad_W2_manual.tolist()])
print("autograd grad_b1 =", [round(v, 4) for v in b1.grad.tolist()])
print("手推     grad_b1 =", [round(v, 4) for v in delta_z1.squeeze().tolist()])
print("autograd grad_W1 =", [[round(v, 4) for v in r] for r in W1.grad.tolist()])
print("手推     grad_W1 =", [[round(v, 4) for v in r] for r in grad_W1_manual.tolist()])
print("grad_W1 最大绝对误差 =", round((W1.grad - grad_W1_manual).abs().max().item(), 8))
loss = 0.1093
autograd grad_W2 = [[-0.2649], [-0.2051], [-0.1768]]
手推     grad_W2 = [[-0.2649], [-0.2051], [-0.1768]]
autograd grad_b1 = [-0.0046, -0.0096, 0.0079]
手推     grad_b1 = [-0.0046, -0.0096, 0.0079]
autograd grad_W1 = [[-0.0046, -0.0096, 0.0079], [-0.0093, -0.0193, 0.0158]]
手推     grad_W1 = [[-0.0046, -0.0096, 0.0079], [-0.0093, -0.0193, 0.0158]]
grad_W1 最大绝对误差 = 0.0

两边的数字逐位相同,最大绝对误差为 0——这就是「手推公式 → 代码验证」的闭环。它比背公式有用得多:以后你在自定义层里写错一个转置,跑一遍这种对账就能立刻定位。

顺带说明一件常被忽略的事:手推梯度不是考试题,而是调试手段。框架报错通常只说「形状不匹配」,不说「你少乘了一个 1/N1/N 或者转置放反」;能自己写出 δ\delta 的传播式,就能把这两类错误区分开。

推广到一批样本

上面的推导为了清楚,只用了 1 个样本。实际训练中损失通常对一个 batch 取平均:

L=1N∑i=1N12(z2(i)−y(i))2L = \frac{1}{N}\sum_{i=1}^{N}\frac{1}{2}\left(z_{2}^{(i)} - y^{(i)}\right)^2

此时误差信号多一个 1/N1/N 的系数:δz2=1N(z2−y)\delta_{z_2} = \frac{1}{N}\left(z_2 - y\right)。其余公式一字不改,只是把 (1, ·) 换成 (N, ·),矩阵乘法自动把 batch 维并行掉:∂L∂W2=a1⊤δz2\frac{\partial L}{\partial W_2} = a_1^{\top}\delta_{z_2} 的形状由 (3, N) @ (N, 1) 得到 (3, 1),与参数形状一致。

记住这个检查习惯:任何参数梯度的形状,都必须和参数本身的形状完全一致。对不上,几乎一定是转置放错了位置。

autograd 在背后做了什么

PyTorch 把上面这套动作自动化了,分三步:

  1. 建图:对 requires_grad=True 的张量做运算时,结果张量会带一个 grad_fn,指回「生成我的那个反向函数」。图是动态的,每次前向重新建。
  2. 回传:loss.backward() 从标量损失出发,沿 grad_fn 反向遍历,每个节点把上游梯度乘上自己的局部导数,分发给它的输入。
  3. 落盘:梯度累加到叶子张量的 .grad 上(是累加,不是覆盖——这正是要 zero_grad() 的原因)。
import torch

x = torch.tensor([1.0, 2.0])                     # 不需要梯度
w = torch.tensor([0.5, -0.2], requires_grad=True)
b = torch.tensor(0.1, requires_grad=True)

y = x @ w + b          # 一次线性运算
loss = y.pow(2)        # 一次幂运算

print("y =", round(y.item(), 4))
print("y.grad_fn 类型 =", type(y.grad_fn).__name__)
print("loss.grad_fn 类型 =", type(loss.grad_fn).__name__)
print("loss 的上游节点类型 =", type(loss.grad_fn.next_functions[0][0]).__name__)

loss.backward()
print("w.grad =", [round(v, 4) for v in w.grad.tolist()])
print("b.grad =", round(b.grad.item(), 4))
y = 0.2
y.grad_fn 类型 = AddBackward0
loss.grad_fn 类型 = PowBackward0
loss 的上游节点类型 = AddBackward0
w.grad = [0.4, 0.8]
b.grad = 0.4

输出里 AddBackward0 / PowBackward0 就是两个局部导数节点。手算也能对上:y=0.5−0.4+0.1=0.2y = 0.5 - 0.4 + 0.1 = 0.2,∂L∂y=2y=0.4\frac{\partial L}{\partial y} = 2y = 0.4,于是 ∂L∂w=0.4 x=(0.4, 0.8)\frac{\partial L}{\partial w} = 0.4\,x = (0.4,\ 0.8),∂L∂b=0.4\frac{\partial L}{\partial b} = 0.4。

怀疑梯度算错时:梯度检查

自己实现一层之后,最实用的自查手段是梯度检查(gradient check):用最笨的数值微分估计梯度,再和反向传播的结果比相对误差。

  • 数值微分用中心差分:对某个参数做 θ+ϵ\theta+\epsilon 与 θ−ϵ\theta-\epsilon 两次前向,用 L(θ+ϵ)−L(θ−ϵ)2ϵ\frac{L(\theta+\epsilon)-L(\theta-\epsilon)}{2\epsilon} 近似导数。它一定对(只要 ϵ\epsilon 取得合适),但每个参数都要跑两次前向,慢到不能用于训练。
  • 做检查时把张量转成 float64(x.double())。float32 下 ϵ\epsilon 太小会被舍入误差吞掉,相对误差常在 10−310^{-3} 量级,看起来像「梯度错了」,其实是精度问题。
  • 检查前把 Dropout 关掉(model.eval()),否则两次前向用的掩码不同,差异不是梯度错误。

这条习惯在写自定义层、改损失函数时尤其值钱:它把「训练不收敛」这种模糊症状,变成「哪个参数的梯度对不上」这种可定位问题。

📝 动手练一练

把隐藏层的激活函数从 Sigmoid 换成 ReLU(ReLU(x)=max⁡(0,x)\mathrm{ReLU}(x)=\max(0,x),导数在 x>0x>0 时为 1、否则为 0),手推 ∂L∂W1\frac{\partial L}{\partial W_1} 并与 autograd 对账。提示:只有第 3 步的激活导数要换。

👉 点击查看参考答案

思路:δz1=δa1⊙1[z1>0]\delta_{z_1} = \delta_{a_1}\odot\mathbf{1}[z_1>0]。ReLU 的导数不再是 a1(1−a1)a_1(1-a_1),而是一个与 z1z_1 同形状的 0/1 掩码;掩码为 0 的位置,该隐藏单元这一路完全不回传梯度(这就是「ReLU 死亡」的来源)。

import torch

torch.manual_seed(0)
x = torch.tensor([[1.0, 2.0]])
y_true = torch.tensor([[0.5]])
W1 = (torch.randn(2, 3) * 0.1).requires_grad_(True)
b1 = torch.zeros(3, requires_grad=True)
W2 = (torch.randn(3, 1) * 0.1).requires_grad_(True)
b2 = torch.zeros(1, requires_grad=True)

z1 = x @ W1 + b1
a1 = torch.relu(z1)
z2 = a1 @ W2 + b2
loss = 0.5 * (z2 - y_true).pow(2).sum()
loss.backward()

relu_deriv = (z1.detach() > 0).float()
delta_z1 = ((z2 - y_true).detach() @ W2.detach().t()) * relu_deriv
grad_W1_manual = x.t() @ delta_z1

print("ReLU 导数掩码 =", [[int(v) for v in r] for r in relu_deriv.tolist()])
print("autograd grad_W1[0] =", [round(v, 4) for v in W1.grad[0].tolist()])
print("手推     grad_W1[0] =", [round(v, 4) for v in grad_W1_manual[0].tolist()])
ReLU 导数掩码 = [[1, 0, 0]]
autograd grad_W1[0] = [-0.0197, 0.0, 0.0]
手推     grad_W1[0] = [-0.0197, -0.0, 0.0]

常见错误

错误理解或写法现象或报错原因正确做法
W1 = torch.randn(2, 3, requires_grad=True) * 0.1 后取 W1.grad打印出来是 None,并伴随 UserWarning: ... not a leaf Tensor ...;再取 W1.grad.shape 才报 AttributeError: 'NoneType' object has no attribute 'shape'乘 0.1 产生了新张量,W1 成了非叶子节点,梯度只落在更内层的叶子张量上先建张量再缩放,或对结果调用 .requires_grad_(True)
每一轮训练忘记清算梯度梯度越来越大,损失发散.grad 是累加语义,backward() 不会自动清零每轮 optimizer.zero_grad()(或 param.grad = None)
把 loss 之外的中间张量当标量直接调用 backward()RuntimeError: grad can be implicitly created only for scalar outputsbackward() 默认要求输出是标量,非标量必须给 gradient 参数对损失求和或取均值再 backward();确需非标量时传 gradient=
在 with torch.no_grad(): 里做前向,却指望之后能反传报错或梯度为 None该上下文不建图,输出没有 grad_fn训练前向放在默认模式里;no_grad 只用于评估与推理

本章小结

  1. 计算图把前向计算写成节点与边,求导变成「沿路径连乘、分叉处相加」。
  2. 反向传播是链式法则在计算图上的系统化执行,一次反向即可得到全部参数梯度。
  3. 前向必须保存中间结果(x,z1,a1,z2x, z_1, a_1, z_2),反向复用它们——用内存换时间。
  4. 两个核心公式:参数梯度 = 该层输入转置 × 该层误差信号;误差信号 = 上游误差 × 权重转置 × 激活导数。
  5. PyTorch 用 grad_fn 自动建图,backward() 把梯度累加到叶子张量的 .grad,所以每轮要清算。
  6. 老课的 B 图(左半导数 / 右半函数值)与今天的 δ\delta 记号是同一件事;网络里的运算可归为复合、相加、线性三类,后向规则各一条。

📋 行动清单

  • 把本节第一个代码块里的隐藏层宽度从 3 改成 4,先预测各张量形状,再跑一遍核对。
  • 故意把 W1 写成 torch.randn(2, 3, requires_grad=True) * 0.1,观察 W1.grad 为什么是 None,再按「常见错误」第一条改回来。
  • 把损失换成对 batch 求平均:先把 x 与 y_true 扩成 4 个样本,再分别用 sum 与 mean 各跑一遍,观察梯度数值约差 4 倍。

延伸阅读

本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。

—— 小象教研组