NOTE

PyTorch 基础、反向传播与训练工程

从环境、Tensor 和计算图进入 loss、反向传播、mini-batch 与训练工程。

本文目录

2026-W34 · PyTorch 基础、反向传播与训练工程

日期:2026-08-23

本周笔记整理第一次系统学习 PyTorch 时提出的问题。内容从运行环境开始,依次讨论 Tensor、计算图、网络层、loss、反向传播、梯度下降、mini-batch,以及大模型训练时 如何控制超参数实验成本。

本次完成证据

环境检查程序:

运行环境检查:

uv run python learning/00-environment/environment_check.py

指定随机种子:

uv run python learning/00-environment/environment_check.py --seed 57

运行测试:

TMPDIR=/tmp TEMP=/tmp TMP=/tmp \
  uv run pytest learning/00-environment/test_environment_check.py -q

本次验证结果:

3 passed in 8.95s

1. uv run python ... 到底执行了什么

命令:

uv run python learning/00-environment/environment_check.py

可以拆成三部分:

  • uv run:在当前项目由 uv 管理的虚拟环境中执行命令,使用 pyproject.toml 和 uv.lock 中确定的依赖;
  • python:启动这个虚拟环境中的 Python 解释器;
  • learning/00-environment/environment_check.py:交给 Python 执行的脚本。

直接运行 python 可能意外使用 WSL 系统 Python,导致 PyTorch 或其他依赖版本与项目 不一致。uv run python 的目的,是让仓库中的同一条命令始终进入同一套受控环境。


2. CPU、GPU 和 CUDA 的关系

真正互相对应的硬件是 CPU 和 GPU。CUDA 不是第三种处理器,而是 NVIDIA 提供的并行 计算平台、运行时和编程接口。PyTorch 通过 CUDA 把 Tensor 运算提交给 NVIDIA GPU。

PyTorch
├── device="cpu"    → CPU
└── device="cuda:0" → CUDA → NVIDIA RTX 4060 Ti

2.1 CPU

CPU 的核心数量相对少,但单个核心功能复杂,适合:

  • 执行 Python 和操作系统逻辑;
  • 数据读取与预处理;
  • 分支复杂、并行度不高的通用任务。

2.2 GPU

GPU 包含大量适合并行数值计算的执行单元,擅长:

  • 矩阵乘法;
  • Tensor 批量运算;
  • 神经网络的前向传播和反向传播。

Python 控制逻辑仍主要由 CPU 执行。只有位于 GPU 上的 Tensor 运算会通过 CUDA 交给 GPU。

2.3 cuda:0

tensor.to("cuda:0")

cuda:0 表示当前进程看到的第一张 NVIDIA GPU。这里的 0 是设备序号,不是 CUDA 版本。

2.4 环境报告中的 CUDA 字段

cuda:
  available: true
  runtime: "12.8"
  device_count: 1
  cudnn: 91900
  • available: true:当前 PyTorch 能通过 CUDA 使用 GPU;
  • runtime: 12.8:当前 PyTorch wheel 对应 CUDA 12.8;
  • device_count: 1:进程能看到一张 NVIDIA GPU;
  • cudnn: 91900:cuDNN 9.19.0,cuDNN 是 NVIDIA 针对神经网络算子提供的优化库。

PyTorch 2.11.0+cu128 中的 cu128 同样表示 CUDA 12.8 构建。

2.5 Compute Capability

RTX 4060 Ti 的报告包含:

compute_capability: 8.9

Compute Capability 是 NVIDIA GPU 架构的功能版本。它告诉编译器和运行时这张显卡支持 哪些指令、数值格式和硬件功能。CUDA 编译目标中常写作 sm_89。

它不是:

  • CUDA 版本;
  • GPU 性能分数;
  • 显存大小;
  • “性能是 8.9 倍”。

只要安装的 PyTorch 构建支持 sm_89,这个字段主要用于记录和诊断硬件兼容性。


3. 随机种子与可复现性

计算机通常使用伪随机数生成器。它从一个初始状态出发,根据确定算法生成一串看似随机 的值。这个初始值就是 seed。

seed = 57 → 固定的一串伪随机数
seed = 58 → 另一串伪随机数

机器学习中会使用随机数的地方包括:

  • 模型参数初始化;
  • 训练数据打乱;
  • Dropout;
  • 随机裁剪和数据增强;
  • 训练集与验证集划分。

环境检查脚本分别设置:

Python random
NumPy random
PyTorch CPU random
PyTorch CUDA random

它们是相互独立的随机数生成器,因此需要分别设置。报告中的 random_samples 不是 模型结果,而是随机状态的“指纹”。同样的 seed 连续运行两次时,这些值应该相同; 更换 seed 后应该变化。

uv run python learning/00-environment/environment_check.py --seed 57
uv run python learning/00-environment/environment_check.py --seed 57
uv run python learning/00-environment/environment_check.py --seed 58

固定 seed 是可复现性的必要措施,但不是充分条件。PyTorch 版本、CUDA/cuDNN 版本、 硬件、并行调度、DataLoader worker 和某些非确定性算子仍可能造成差异。因此正式实验 还应保存代码、环境、数据版本、配置和指标。


4. PyTorch 与 TensorFlow

两者都提供:

  • Tensor 运算;
  • 自动求导;
  • 神经网络层;
  • CPU/GPU 加速;
  • 优化器和训练循环;
  • 多设备训练与模型部署。

Transformer、CNN、VAE 和 GAN 背后的数学不依赖框架。主要差异在编程模型和生态。

方面PyTorchTensorFlow
常用模型类torch.nn.Moduletf.keras.Model
自动求导loss.backward()tf.GradientTape
数据加载Dataset、DataLoadertf.data
默认执行方式EagerTensorFlow 2 也是 Eager
编译优化torch.compiletf.function、XLA
当前常见场景新研究、生成式模型、开源模型既有生产系统、Keras、部分端侧和浏览器生态

4.1 为什么早期 TensorFlow 看起来更复杂

TensorFlow 1 的典型流程是:

先声明计算图
→ 创建 Session
→ 传入数据
→ session.run()

这种方式适合整体优化和跨运行时部署,但要求开发者明确区分图构建阶段与执行阶段。 PyTorch 则让普通 Python 代码在执行时动态记录计算图,条件、循环、断点和 print 更自然。

TensorFlow 2 已经默认使用 Eager Execution。现在两者都可以概括为:

平时以 Eager 方式开发和调试
需要性能或部署时再编译成优化后的图

当前新模型生态明显偏向 PyTorch,但 TensorFlow 并没有消失。Keras 3 已经可以选择 TensorFlow、PyTorch 或 JAX 作为后端。音乐领域也同时存在两类项目:AudioCraft 是 PyTorch 项目;Magenta 和 Basic Pitch 的原始实现则来自 TensorFlow 生态。

当前学习阶段只使用 PyTorch。以后遇到 TensorFlow 项目时,再把 Tensor、模型、梯度和 优化器概念映射过去,不需要同时学习两套 API。


5. 什么是 Tensor 与计算图

5.1 Tensor

Tensor 可以理解为多维数组:

标量:       3.14                         0 维
向量:       [1, 2, 3]                    1 维
矩阵:       [[1, 2], [3, 4]]             2 维
音频 batch: [batch, channel, time]       3 维
频谱 batch: [batch, channel, freq, time] 4 维

PyTorch Tensor 除了数值,还包含:

  • shape:形状;
  • dtype:数据类型;
  • device:数据位于 CPU 还是 GPU;
  • requires_grad:是否需要追踪与它有关的梯度;
  • 与其他 Tensor 之间的运算关系。

5.2 计算图

假设执行:

a = x * w
y_hat = a + b
loss = y_hat**2

它可以表示为依赖图:

x ──┐
    ├─ 乘法 ─→ a ──┐
w ──┘              ├─ 加法 ─→ y_hat ─→ 平方 ─→ loss
              b ───┘

从输入到 loss 的计算是前向传播。从 loss 沿依赖关系反向计算梯度是反向传播。

PyTorch 不是没有图,而是在普通 Python 代码执行时由 Autograd 动态记录图。调用:

loss.backward()

PyTorch 就沿图反向应用链式法则。

TensorFlow 这个名称表达的正是 Tensor 在数据流图中从一个计算节点流向下一个节点。 “Flow”不是另一种数据类型。

5.3 静态图与动态图

静态图:

先构建完整执行计划 → 再反复执行

动态图:

执行 Python 的同时 → 构建本次实际走过的计算图

PyTorch 的动态图更容易表达动态条件和变长序列。现代 PyTorch 又可以用 torch.compile 捕获并优化合适的代码区域,因此“动态图”和“编译优化”并不冲突。


6. Tensor 形状:torch.Size([64, 1, 28, 28])

图像任务通常使用 NCHW 维度顺序:

[N, C, H, W]
[批次, 通道, 高度, 宽度]

因此:

torch.Size([64, 1, 28, 28])

表示:

  • 一个 batch 有 64 张图片;
  • 每张图片有 1 个灰度通道;
  • 每张图片高 28 像素;
  • 每张图片宽 28 像素。

如果 X 是这个 batch:

X.shape  # [64, 1, 28, 28]
X[0].shape  # [1, 28, 28],第一张图片
X[0, 0].shape  # [28, 28],第一张图片的灰度通道
X[0, 0, 0, 0]  # 一个像素标量

Flatten 保留 batch 维,把单张图片的其余维度展开:

[64, 1, 28, 28] → [64, 784]

因为:

1×28×28=7841\times28\times28=784

标签通常是:

y.shape  # [64]

也就是 64 张图片分别对应 64 个整数类别:X[i] 对应 y[i]。


7. .to(device)、nn.Module 与 nn.Sequential

7.1 .to(device)

device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
X = X.to(device)
y = y.to(device)
  • X.to(device):把输入 Tensor 的存储移动到指定设备;
  • model.to(device):递归移动模型中已注册的参数和 buffer;
  • 输入、标签和模型参数必须位于兼容设备,否则运算会报 device mismatch。

Tensor 的 .to() 通常返回一个新 Tensor,所以要接住返回值:

X = X.to(device)

Module 的 .to() 会修改已注册参数和 buffer 的设备,同时返回模型自身。习惯上仍写:

model = model.to(device)

7.2 继承 nn.Module

class NeuralNetwork(nn.Module):
    def __init__(self):
        super().__init__()
        self.flatten = nn.Flatten()
        self.layers = nn.Sequential(...)

    def forward(self, x):
        x = self.flatten(x)
        return self.layers(x)

__init__() 定义模型包含哪些子模块和参数。super().__init__() 初始化 Module 内部的 参数、buffer、子模块和 hook 注册机制。

把一个 Module 赋给实例属性时:

self.linear = nn.Linear(784, 512)

PyTorch 会自动登记这个子模块,因此下面的操作才能递归找到它:

model.parameters()
model.named_parameters()
model.state_dict()
model.to(device)
model.train()
model.eval()

forward() 定义输入如何经过模型。它的参数签名和返回结构可以根据模型需要设计。

7.3 为什么写 model(X)

nn.Module 实现了 Python 的调用协议:

model(X)
→ nn.Module.__call__(X)
→ 处理 Module hooks 等机制
→ NeuralNetwork.forward(X)

一般应调用 model(X),而不是直接调用 model.forward(X),避免绕过 Module 的调用 机制。

7.4 nn.Sequential

stack = nn.Sequential(
    nn.Linear(784, 512),
    nn.ReLU(),
    nn.Linear(512, 10),
)

output = stack(x)

Sequential 本身也是一个 Module,它按登记顺序依次执行子模块:

def conceptual_forward(x):
    x = linear_1(x)
    x = relu(x)
    x = linear_2(x)
    return x

表达式 nn.Sequential(...)(x) 表示先构造 Sequential 对象,再立即使用 (x) 调用。 如果字面上写空的 nn.Sequential()(x),因为内部没有层,它基本会原样返回 x。


8. 当前 MLP 网络结构

教程网络:

nn.Sequential(
    nn.Linear(28 * 28, 512),
    nn.ReLU(),
    nn.Linear(512, 512),
    nn.ReLU(),
    nn.Linear(512, 10),
)

它是多层感知机,也叫 MLP 或全连接前馈网络:

图片           [64, 1, 28, 28]
↓ Flatten
像素向量       [64, 784]
↓ Linear
隐藏特征       [64, 512]
↓ ReLU
隐藏特征       [64, 512]
↓ Linear
隐藏特征       [64, 512]
↓ ReLU
隐藏特征       [64, 512]
↓ Linear
类别 logits    [64, 10]

8.1 Linear

nn.Linear(in_features, out_features)

执行仿射变换:

y=Wx+by=Wx+b

单个输出分量:

yj=∑iwjixi+bjy_j=\sum_i w_{ji}x_i+b_j

每个输出都读取全部输入,所以称为全连接层。W 和 b 都是训练时学习的参数。

对于第一层:

x∈R784,W1∈R512×784,b1∈R512x\in\mathbb{R}^{784},\quad W_1\in\mathbb{R}^{512\times784},\quad b_1\in\mathbb{R}^{512} z1=W1x+b1z_1=W_1x+b_1

在 PyTorch 的 batch 表示中:

X∈R64×784X\in\mathbb{R}^{64\times784} Z1=XW1T+b1Z_1=XW_1^T+b_1

形状变化:

[64, 784] × [784, 512] → [64, 512]

同一组参数分别作用于 64 个样本,样本之间不会因 Linear 而互相混合。

8.2 ReLU

ReLU 的定义:

ReLU⁡(z)=max⁡(0,z)\operatorname{ReLU}(z)=\max(0,z)

分段写作:

ReLU⁡(z)={z,z>00,z≤0\operatorname{ReLU}(z)= \begin{cases} z,&z>0\\ 0,&z\leq0 \end{cases}

例如:

输入:[-3.0, -0.2, 0.0, 1.5, 4.0]
输出:[ 0.0,  0.0, 0.0, 1.5, 4.0]

ReLU 没有可训练参数,也不改变 Tensor 形状。它的作用是加入非线性。

如果连续两个 Linear 之间没有非线性:

y=W2(W1x+b1)+b2y=W_2(W_1x+b_1)+b_2

展开得到:

y=(W2W1)x+(W2b1+b2)y=(W_2W_1)x+(W_2b_1+b_2)

仍能合并成单个 Linear。加入 ReLU 后:

y=W2ReLU⁡(W1x+b1)+b2y=W_2\operatorname{ReLU}(W_1x+b_1)+b_2

无法再合并成一次矩阵变换,网络因此能表达非线性关系。

8.3 整个网络的公式

z1=W1x+b1z_1=W_1x+b_1 h1=ReLU⁡(z1)h_1=\operatorname{ReLU}(z_1) z2=W2h1+b2z_2=W_2h_1+b_2 h2=ReLU⁡(z2)h_2=\operatorname{ReLU}(z_2) logits=W3h2+b3\text{logits}=W_3h_2+b_3

合并为:

logits=W3ReLU⁡(W2ReLU⁡(W1x+b1)+b2)+b3\text{logits} =W_3\operatorname{ReLU} \left( W_2\operatorname{ReLU}(W_1x+b_1)+b_2 \right)+b_3

最后一层不使用 ReLU,因为分类 loss 需要原始 logits。预测类别可以取:

predicted_class = logits.argmax(dim=1)

训练时 nn.CrossEntropyLoss 直接接收 logits,不应提前手工添加 Softmax。

8.4 参数量

层权重偏置参数量
Linear(784, 512)784 × 512512401,920
Linear(512, 512)512 × 512512262,656
Linear(512, 10)512 × 10105,130
合计669,706

Flatten 和 ReLU 都没有训练参数。隐藏宽度 512 是人为选择的超参数,没有特殊的 数学含义。


9. 参数、权重、偏置、buffer 和超参数

严格定义下:

可训练参数 parameter
├── 权重 weight:W
└── 偏置 bias:b

当前网络的参数集合是:

θ={W1,b1,W2,b2,W3,b3}\theta=\{W_1,b_1,W_2,b_2,W_3,b_3\}

可以查看:

for name, parameter in model.named_parameters():
    print(name, parameter.shape, parameter.requires_grad)

工程上“加载模型权重”经常宽泛地表示加载全部已学习状态,因此有时也会包含 bias, 甚至包含保存于 state_dict 的非参数 buffer。严格数学语境中,weight 只是 parameter 的一种。

9.1 为什么系数称为 weight

一个神经元首先计算加权和:

z=w1x1+w2x2+⋯+wnxn+bz=w_1x_1+w_2x_2+\cdots+w_nx_n+b

数学上,wiw_i 可以叫系数;神经网络称它为 weight,是为了强调它决定对应输入以多大 程度、什么方向影响输出。

因为:

∂z∂xi=wi\frac{\partial z}{\partial x_i}=w_i

所以在其他条件不变时:

  • wi=0w_i=0:该输入暂时不影响输出;
  • wi>0w_i>0:输入增大会让输出增大;
  • wi<0w_i<0:输入增大会让输出减小;
  • ∣wi∣|w_i| 越大:输出对该输入的局部变化越敏感。

例如:

z=2x1−0.5x2z=2x_1-0.5x_2
x1 增加 1 → z 增加 2
x2 增加 1 → z 减少 0.5

因此可以说 x1x_1 和 x2x_2 在这个加权和中分别获得了 22 和 −0.5-0.5 的权重。

在深层网络中,不能仅凭单个 weight 的绝对值直接判断原始特征重要性,因为输入尺度、 其他层参数和非线性变换都会影响最终结果。

9.2 为什么常数项称为 bias

如果没有 bias:

z=wxz=wx

当输入为 0 时,输出被固定为 0。加入 bias:

z=wx+bz=wx+b

当输入为 0 时:

z=bz=b

bb 给模型提供了一个与输入无关的基础偏移。在线性回归中,它也叫 intercept,即 截距。例如:

y=2x+3y=2x+3
  • weight 22 控制斜率;
  • bias 33 让整条直线向上移动 3。

9.3 bias 如何改变神经元激活门槛

考虑:

h=ReLU⁡(2x−4)h=\operatorname{ReLU}(2x-4)

神经元激活的条件是:

2x−4>02x-4>0

也就是:

x>2x>2

如果只把 bias 从 −4-4 改为 −2-2:

h=ReLU⁡(2x−2)h=\operatorname{ReLU}(2x-2)

激活条件就变为:

x>1x>1

weight 没有改变,但神经元更容易激活。因此 bias 可以理解为让神经元更容易或更难 激活的基础倾向。

这个名称也与早期感知机的阈值写法有关。感知机可以写成:

∑iwixi>θ\sum_i w_ix_i>\theta

移项:

∑iwixi−θ>0\sum_i w_ix_i-\theta>0

定义:

b=−θb=-\theta

就得到现在常用的:

∑iwixi+b>0\sum_i w_ix_i+b>0

weight 控制各输入贡献,bias 控制整体激活倾向。

9.4 几何解释

线性分类边界可以写成:

wTx+b=0w^Tx+b=0
  • ww 是分界面的法向量,决定边界朝向;
  • bb 决定分界面相对原点的位置。

如果 b=0b=0,分界面必须经过原点。加入 bias 后,分界面可以在保持方向的同时平移。

weight:控制边界朝向
bias:  控制边界位置

9.5 bias 也是可训练参数

bias 被称为常数项,是因为对不同输入 xx,它不乘输入变量;它并不是训练过程中固定不变 的常量。它和 weight 一样由 optimizer 学习:

bnew=b−η∂L∂bb_{\text{new}} =b-\eta\frac{\partial L}{\partial b}

例如:

nn.Linear(784, 512)

内部包含:

weight:[512, 784]
bias:  [512]

512 个输出神经元各自有一个 bias。

9.6 bias 可以写成特殊 weight

把输入增广一个永远等于 1 的分量:

x′=[x1x2⋮xn1]x'= \begin{bmatrix} x_1\\ x_2\\ \vdots\\ x_n\\ 1 \end{bmatrix}

把 bias 放入扩展权重:

w′=[w1w2⋮wnb]w'= \begin{bmatrix} w_1\\ w_2\\ \vdots\\ w_n\\ b \end{bmatrix}

那么:

w′Tx′=w1x1+⋯+wnxn+b×1w'^Tx'=w_1x_1+\cdots+w_nx_n+b\times1

所以数学上 bias 可以看作乘以常量 1 的特殊 weight。工程上仍将它单独保存,是因为它的 shape、初始化和正则化策略可能与 weight 不同,例如某些训练配置不会对 bias 使用 weight decay。

最简洁的总结:

weight 决定每个输入对输出的影响强度和方向;bias 提供与输入无关的基础偏移,并 控制神经元或决策边界的位置。

buffer 是随模型保存和移动、但通常不由 optimizer 更新的 Tensor,例如 BatchNorm 的 运行均值和运行方差。

下面这些不是模型训练参数,而是配置或超参数:

  • 输入维度和隐藏维度;
  • 网络层数;
  • 激活函数;
  • learning rate;
  • batch size;
  • dropout;
  • weight decay。

10. Loss 是什么

需要区分:

x:输入数据
y:目标答案
θ:模型参数
y_hat:模型预测
L:loss

模型:

y^=fθ(x)\hat y=f_\theta(x)

单个样本的 loss:

L(θ;x,y)=ℓ(fθ(x),y)L(\theta;x,y)=\ell(f_\theta(x),y)

数学上 loss 同时依赖参数、输入和目标。但训练一个 batch 时,数据 x,yx,y 被视为固定, 真正要改变的是参数 θ\theta。

10.1 回归示例:平方误差

模型:

y^=wx+b\hat y=wx+b

为了便于求导,定义:

L=12(y^−y)2L=\frac12(\hat y-y)^2

代入模型:

L(w,b;x,y)=12(wx+b−y)2L(w,b;x,y)=\frac12(wx+b-y)^2

10.2 为什么导数是 y_hat - y

令:

u=y^−yu=\hat y-y

则:

L=12u2L=\frac12u^2

根据链式法则:

∂L∂y^=∂L∂u∂u∂y^\frac{\partial L}{\partial\hat y} =\frac{\partial L}{\partial u} \frac{\partial u}{\partial\hat y}

其中:

∂L∂u=u\frac{\partial L}{\partial u}=u ∂u∂y^=1\frac{\partial u}{\partial\hat y}=1

所以:

∂L∂y^=y^−y\frac{\partial L}{\partial\hat y}=\hat y-y

当 y^=2,y=5\hat y=2,y=5:

∂L∂y^=2−5=−3\frac{\partial L}{\partial\hat y}=2-5=-3

负号表示在当前位置稍微增大预测值会降低 loss。

数值验证:

L(2)=12(2−5)2=4.5L(2)=\frac12(2-5)^2=4.5 L(2.001)=12(2.001−5)2=4.4970005L(2.001)=\frac12(2.001-5)^2=4.4970005

差商:

4.4970005−4.52.001−2=−2.9995\frac{4.4970005-4.5}{2.001-2}=-2.9995

步长趋近于 0 时,差商趋近于 −3-3。

前面的 12\frac12 只是为了抵消平方求导产生的 2,不改变最低点位置。如果定义 L=(y^−y)2L=(\hat y-y)^2,导数就是 2(y^−y)2(\hat y-y)。

10.3 分类示例:交叉熵

对十分类模型,网络输出 logits:

z=fθ(x)∈R10z=f_\theta(x)\in\mathbb{R}^{10}

真实类别为 yy 时,交叉熵为:

L=−log⁡ezy∑j=110ezjL=-\log\frac{e^{z_y}}{\sum_{j=1}^{10}e^{z_j}}

它把十个类别分数与真实类别的关系压缩为一个标量。batch 中通常再对所有样本 loss 取平均。


11. 反向传播与参数梯度

训练真正需要的是:

∂L∂W,∂L∂b\frac{\partial L}{\partial W},\qquad \frac{\partial L}{\partial b}

而不是更新原始输入 xx。

继续使用:

y^=wx+b\hat y=wx+b L=12(y^−y)2L=\frac12(\hat y-y)^2

链式法则给出:

∂L∂w=∂L∂y^∂y^∂w=(y^−y)x\frac{\partial L}{\partial w} =\frac{\partial L}{\partial\hat y} \frac{\partial\hat y}{\partial w} =(\hat y-y)x ∂L∂b=∂L∂y^∂y^∂b=y^−y\frac{\partial L}{\partial b} =\frac{\partial L}{\partial\hat y} \frac{\partial\hat y}{\partial b} =\hat y-y

这里出现的 xx 来自:

∂(wx+b)∂w=x\frac{\partial(wx+b)}{\partial w}=x

它不是笼统地计算 ∂L∂x×x\frac{\partial L}{\partial x}\times x。

11.1 梯度是常数吗

对固定输入:

∂y^∂w=x\frac{\partial\hat y}{\partial w}=x

可以是常数;但 loss 对参数的梯度:

∂L∂w=(wx+b−y)x\frac{\partial L}{\partial w}=(wx+b-y)x

依赖当前的 w,b,x,yw,b,x,y,通常不是常数函数。

例如固定 x=2,y=5,b=0x=2,y=5,b=0:

∂L∂w=4w−10\frac{\partial L}{\partial w}=4w-10
w = 1.0 → 梯度 = -6
w = 2.0 → 梯度 = -2
w = 2.5 → 梯度 =  0
w = 3.0 → 梯度 =  2

每次 backward() 得到的是当前 batch、当前参数位置上的具体数值。参数变化后,下一次 得到的梯度也会变化。

11.2 dL/dx 的作用

如果 xx 是原始输入,通常不会用 ∂L∂x\frac{\partial L}{\partial x} 更新数据。但深层网络 中某层的输入是前一层的输出:

原始输入 x → h1 → h2 → logits → loss

∂L∂h2\frac{\partial L}{\partial h_2} 的用途,是继续计算上一层参数的梯度,并进一步得到 ∂L∂h1\frac{\partial L}{\partial h_1}。反向传播中的“传播”,就是把梯度从后层传给前层。


12. ReLU 如何参与反向传播

ReLU 的导数:

ReLU⁡′(z)={1,z>00,z<0\operatorname{ReLU}'(z)= \begin{cases} 1,&z>0\\ 0,&z<0 \end{cases}

z=0z=0 处严格来说不可导,PyTorch 采用约定的子梯度,通常取 0。

它可以理解为梯度开关:

前向时 z > 0 → 反向梯度通过
前向时 z < 0 → 反向梯度变为 0

考虑完整的标量网络:

z=wx+bz=wx+b h=ReLU⁡(z)h=\operatorname{ReLU}(z) y^=vh+c\hat y=vh+c L=12(y^−y)2L=\frac12(\hat y-y)^2

从后向前:

∂L∂y^=y^−y\frac{\partial L}{\partial\hat y}=\hat y-y ∂L∂v=(y^−y)h\frac{\partial L}{\partial v}=(\hat y-y)h ∂L∂c=y^−y\frac{\partial L}{\partial c}=\hat y-y ∂L∂h=(y^−y)v\frac{\partial L}{\partial h}=(\hat y-y)v

经过 ReLU:

∂L∂z=(y^−y)vReLU⁡′(z)\frac{\partial L}{\partial z} =(\hat y-y)v\operatorname{ReLU}'(z)

最后:

∂L∂w=(y^−y)vReLU⁡′(z)x\frac{\partial L}{\partial w} =(\hat y-y)v\operatorname{ReLU}'(z)x ∂L∂b=(y^−y)vReLU⁡′(z)\frac{\partial L}{\partial b} =(\hat y-y)v\operatorname{ReLU}'(z)

如果 z<0z<0,这一样本经过该神经元的梯度为 0。一个 ReLU 神经元如果长期对所有输入 都位于负区间,就可能停止学习,称为 dead ReLU。

12.1 矩阵形式

对:

z1=W1x+b1,h1=ReLU⁡(z1),z2=W2h1+b2z_1=W_1x+b_1,\qquad h_1=\operatorname{ReLU}(z_1),\qquad z_2=W_2h_1+b_2

设后面传回:

δ2=∂L∂z2\delta_2=\frac{\partial L}{\partial z_2}

则:

∂L∂W2=δ2h1T\frac{\partial L}{\partial W_2}=\delta_2h_1^T δ1=(W2Tδ2)⊙1[z1>0]\delta_1=(W_2^T\delta_2)\odot\mathbf{1}[z_1>0] ∂L∂W1=δ1xT\frac{\partial L}{\partial W_1}=\delta_1x^T

⊙\odot 表示逐元素乘法,1[z1>0]\mathbf{1}[z_1>0] 是由前向结果产生的 0/1 掩码。 Autograd 会保存反向所需的信息并自动完成这些运算。


13. 梯度下降为什么能更新参数

参数更新不是“加上偏导”,而是:

pnew=p−η∂L∂pp_{\text{new}}=p-\eta\frac{\partial L}{\partial p}

η\eta 是学习率。

  • 梯度为正:增加参数会增大 loss,因此减小参数;
  • 梯度为负:增加参数会降低 loss,因此减去负数、增大参数。

13.1 一阶泰勒展开

对一个小变化 Δw\Delta w:

L(w+Δw)≈L(w)+∂L∂wΔwL(w+\Delta w) \approx L(w)+\frac{\partial L}{\partial w}\Delta w

选择:

Δw=−η∂L∂w\Delta w=-\eta\frac{\partial L}{\partial w}

得到:

L(w+Δw)≈L(w)−η(∂L∂w)2L(w+\Delta w) \approx L(w)-\eta\left(\frac{\partial L}{\partial w}\right)^2

平方非负,所以学习率足够小时,局部近似预测 loss 会下降。

多参数时,所有参数组成向量 θ\theta:

θnew=θ−η∇θL\theta_{\text{new}}=\theta-\eta\nabla_\theta L

梯度 ∇θL\nabla_\theta L 指向局部增长最快的方向,负梯度指向局部下降最快的方向。

13.2 数值示例

模型:

y^=wx\hat y=wx

数据与当前参数:

x=2,y=5,w=1x=2,\quad y=5,\quad w=1

前向:

y^=2\hat y=2 L=12(2−5)2=4.5L=\frac12(2-5)^2=4.5

梯度:

∂L∂w=(2−5)×2=−6\frac{\partial L}{\partial w}=(2-5)\times2=-6

学习率 η=0.1\eta=0.1:

wnew=1−0.1×(−6)=1.6w_{\text{new}}=1-0.1\times(-6)=1.6

新预测和 loss:

y^new=1.6×2=3.2\hat y_{\text{new}}=1.6\times2=3.2 Lnew=12(3.2−5)2=1.62L_{\text{new}}=\frac12(3.2-5)^2=1.62

loss 从 4.5 降到 1.62。

13.3 对应到 PyTorch

optimizer.zero_grad()

prediction = model(X)
loss = loss_fn(prediction, y)

loss.backward()
optimizer.step()
  • zero_grad():清除上一次累积的梯度;
  • 前向调用:计算预测并建立计算图;
  • loss.backward():计算所有可训练参数的 .grad,不修改参数;
  • optimizer.step():根据梯度修改参数。

最简单的 SGD 近似执行:

with torch.no_grad():
    for parameter in model.parameters():
        parameter -= learning_rate * parameter.grad

Adam 会额外维护动量和梯度平方等状态,但梯度仍然提供核心方向信息。

13.4 训练模式、梯度计算与评估模式

下面六个操作控制三件彼此独立的事情:

模型行为模式:model.train() / model.eval()
梯度计算:    loss.backward() / torch.no_grad()
参数更新:    optimizer.step() / optimizer.zero_grad()
操作作用会更新参数吗
model.train()把 Module 切换到训练模式否
loss.backward()反向计算并累积梯度否
optimizer.step()根据当前梯度更新参数是
optimizer.zero_grad()清除已有梯度否
model.eval()把 Module 切换到评估模式否
torch.no_grad()暂停 Autograd 的计算图记录否

这些操作不能互相替代。train() 不会开始训练,eval() 不会自动验证,backward() 不会修改参数,no_grad() 也不会切换 Dropout 或 BatchNorm 的行为。

13.4.1 model.train()

model.train()

把模型和所有子模块切换到训练模式:

model.training  # True

它只设置 Module 的行为模式,不会:

  • 读取训练数据;
  • 执行前向传播;
  • 计算 loss;
  • 计算梯度;
  • 更新参数。

训练模式主要影响训练和评估行为不同的层。

Dropout 在训练时随机把部分激活变成 0,并对保留的值进行相应缩放;评估时停止随机 丢弃。BatchNorm 在训练时使用当前 batch 的统计量并更新运行统计,评估时使用训练期间 保存的 running mean 和 running variance。

当前 MLP 只有 Linear 和 ReLU,这两种层在训练与评估模式下的前向行为相同。因此 当前模型调用 train() 或 eval() 不会直接改变输出,但仍应保持正确模式切换习惯。

model.train() 也不会解除被冻结的参数。某个参数是否参与梯度计算仍由 requires_grad 等设置控制。

13.4.2 loss.backward()

loss.backward()

从标量 loss 开始,沿计算图反向应用链式法则,计算每个需要梯度的叶子参数:

∂L∂p\frac{\partial L}{\partial p}

结果保存在与参数形状相同的:

parameter.grad

可以检查:

for name, parameter in model.named_parameters():
    print(name, parameter.grad)

第一次反向传播前,.grad 通常是 None。反向传播后,它会变成包含当前累积梯度的 Tensor。

loss.backward()
→ 计算并累积 parameter.grad
→ 不修改 parameter 本身

如果 loss 不是标量,反向传播还需要指定外部梯度,或者先通过 mean()、sum() 等 方式把它归约成标量。常用 loss 模块默认已经完成 batch 归约。

13.4.3 optimizer.step()

optimizer.step()

读取每个参数当前的 .grad,然后按照优化器规则修改参数。

最简单的 SGD 近似为:

pnew=p−ηp.gradp_{\text{new}}=p-\eta p.\text{grad}

Adam 则还会使用梯度的一阶动量、二阶动量、learning rate、weight decay 等状态。

loss.backward()  → 计算梯度
optimizer.step() → 使用梯度更新参数

只调用 backward(),模型参数不会学习;只调用 step(),优化器只能使用之前残留的梯度, 可能不更新或错误地重复更新。

13.4.4 optimizer.zero_grad()

optimizer.zero_grad()

清除 optimizer 管理的模型参数梯度。PyTorch 默认累积梯度,而不是用新梯度覆盖旧 梯度:

第一次 backward:parameter.grad = g1
第二次 backward:parameter.grad = g1 + g2
第三次 backward:parameter.grad = g1 + g2 + g3

这个设计使梯度累积成为可能,但普通训练需要确保下一次反向传播前不保留上一个 batch 的梯度。

常见顺序一是在 batch 开始时清除:

for X, y in dataloader:
    optimizer.zero_grad()

    prediction = model(X)
    loss = loss_fn(prediction, y)

    loss.backward()
    optimizer.step()

PyTorch Quickstart 使用的顺序是在更新后清除:

for X, y in dataloader:
    prediction = model(X)
    loss = loss_fn(prediction, y)

    loss.backward()
    optimizer.step()
    optimizer.zero_grad()

两种顺序都成立。关键条件是每次普通 backward() 前不存在不需要的旧梯度。初学阶段 把 zero_grad() 放在 batch 开头,执行顺序更直观:

清空旧梯度
→ 前向计算
→ 计算 loss
→ 反向计算新梯度
→ 更新参数

如果使用梯度累积,则故意让多个 micro-batch 连续调用 backward(),最后才调用一次 step() 和 zero_grad()。

13.4.5 model.eval()

model.eval()

把模型和所有子模块切换到评估模式:

model.training  # False

它近似等价于:

model.train(False)

评估模式会让 Dropout 停止随机丢弃,让 BatchNorm 使用已经保存的运行统计。但它不会:

  • 自动遍历验证集;
  • 自动计算 accuracy;
  • 自动关闭 Autograd;
  • 永久冻结参数;
  • 阻止手工调用 backward()。

因此单独调用 model.eval() 后,普通前向计算仍可能建立计算图。

13.4.6 with torch.no_grad():

with torch.no_grad():
    prediction = model(X)

在上下文内部暂停 Autograd 的计算图记录。它会:

  • 不保存反向传播所需的中间状态;
  • 降低验证或推理时的显存占用;
  • 减少不必要的 Autograd 开销;
  • 让这段计算的输出不连接到可反向传播的计算图。

它适合 validation、test 和普通 inference,但不会改变 Module 的训练模式。如果模型仍 处于训练模式,Dropout 仍会随机丢弃,BatchNorm 仍会使用当前 batch 统计。

反过来,model.eval() 也不能代替 no_grad()。因此验证通常同时使用:

model.eval()

with torch.no_grad():
    prediction = model(X)

退出 no_grad() 代码块后,Autograd 恢复原来的全局状态;它不会永久改变参数的 requires_grad。

13.4.7 完整训练循环

model.train()

for X, y in train_dataloader:
    X = X.to(device)
    y = y.to(device)

    optimizer.zero_grad()

    prediction = model(X)
    loss = loss_fn(prediction, y)

    loss.backward()
    optimizer.step()

执行过程:

model.train()
→ 使用训练阶段的层行为

optimizer.zero_grad()
→ 清除上一个 batch 的梯度

prediction = model(X)
→ 前向传播并建立计算图

loss = loss_fn(prediction, y)
→ 得到标量 loss

loss.backward()
→ 把参数梯度写入 parameter.grad

optimizer.step()
→ 根据梯度更新参数

13.4.8 完整验证循环

model.eval()

total_loss = 0.0
correct = 0

with torch.no_grad():
    for X, y in validation_dataloader:
        X = X.to(device)
        y = y.to(device)

        prediction = model(X)
        loss = loss_fn(prediction, y)

        total_loss += loss.item()
        correct += (prediction.argmax(dim=1) == y).sum().item()

验证阶段没有:

loss.backward()
optimizer.step()

因为验证只测量当前模型,不应该修改参数。


14. 单样本 loss、训练集目标与 mini-batch

第 ii 个样本的 loss:

ℓi(θ)=ℓ(fθ(xi),yi)\ell_i(\theta)=\ell(f_\theta(x_i),y_i)

整个训练集真正优化的经验目标:

J(θ)=1N∑i=1Nℓi(θ)J(\theta)=\frac1N\sum_{i=1}^{N}\ell_i(\theta)

不同样本的梯度可以方向不同,甚至完全相反。

14.1 梯度冲突示例

模型只有一个输出参数:

y^=w\hat y=w

两个样本的目标分别是 1 和 3:

ℓ1(w)=12(w−1)2\ell_1(w)=\frac12(w-1)^2 ℓ2(w)=12(w−3)2\ell_2(w)=\frac12(w-3)^2

梯度:

g1=w−1,g2=w−3g_1=w-1,\qquad g_2=w-3

当 w=2w=2:

g1=1,g2=−1g_1=1,\qquad g_2=-1

第一个样本希望参数减小,第二个希望参数增大。完整目标取平均:

J(w)=ℓ1(w)+ℓ2(w)2J(w)=\frac{\ell_1(w)+\ell_2(w)}2 ∂J∂w=g1+g22=0\frac{\partial J}{\partial w}=\frac{g_1+g_2}{2}=0

w=2w=2 是两个冲突目标的折中。

14.2 batch 平均梯度

当前 batch 的目标:

LB(θ)=1∣B∣∑i∈Bℓi(θ)L_B(\theta)=\frac1{|B|}\sum_{i\in B}\ell_i(\theta)

梯度:

∇θLB=1∣B∣∑i∈B∇θℓi\nabla_\theta L_B =\frac1{|B|}\sum_{i\in B}\nabla_\theta\ell_i

一致的梯度方向会加强,冲突部分会抵消。下一批数据完全可能让某些参数朝相反方向 移动,因此训练曲线通常会波动,并不要求每一步都降低完整训练集 loss。

如果 batch 从训练集中均匀随机抽取,那么它的梯度是完整训练集梯度的带噪声估计:

E[gB]=∇θJ\mathbb{E}[g_B]=\nabla_\theta J

很多随机 batch 的平均方向会接近整体目标的下降方向。shuffle、batch、较小学习率、 Momentum 或 Adam 都有助于控制波动。


15. 局部最低点与训练停滞

15.1 能否保证只有一个最低点

简单线性回归配合 MSE 时,目标关于参数可以是凸函数。凸函数的任意局部最低点都是 全局最低点;进一步满足严格凸条件时,可以有唯一最低点。

深层 ReLU 网络的目标关于所有参数通常是非凸函数,可能存在:

  • 多个局部最低点;
  • 鞍点;
  • 平坦区域;
  • 很多功能相同但参数不同的解。

例如交换两个隐藏神经元,同时交换下一层对应连接,网络输出不变。因此唯一参数解通常 既无法保证,也不是实际目标。

真正关注的是:训练 loss 足够低、验证集效果良好、对未见数据能够泛化。

15.2 真正落在较差局部最低点怎么办

如果确实位于严格局部最低点,并且确定性梯度为 0,普通梯度下降不会移动。离开当前 区域需要:

  • 不同随机初始化或重新启动;
  • 更大的学习率或周期性学习率;
  • mini-batch 梯度噪声;
  • Momentum;
  • 参数扰动;
  • 修改初始化、网络结构、数据或目标函数。

但是“loss 不再下降”不能直接证明进入局部最低点。更常见的检查方向包括:

  • 学习率太小或太大;
  • 鞍点或平坦区域;
  • 梯度消失、梯度爆炸或大量 dead ReLU;
  • 数据归一化、标签或 loss 实现错误;
  • 参数没有加入 optimizer;
  • 模型容量不足。

实用诊断顺序:

  1. 检查梯度范数和参数是否真的变化;
  2. 尝试让模型过拟合几十个样本;
  3. 尝试不同学习率;
  4. 使用多个随机种子;
  5. 同时观察训练与验证指标;
  6. 保存验证集表现最好的 checkpoint。

如果模型无法过拟合极小数据集,应先检查实现、数据和优化配置,而不是立即归因于局部 最低点。


16. 工程上如何选择 batch size

batch size 同时影响:

显存容量
GPU 吞吐量
梯度噪声
参数更新次数
泛化与优化行为

16.1 太小与太大的权衡

较小 batch:

  • 显存占用低;
  • 梯度噪声大;
  • GPU 利用率可能低;
  • BatchNorm 统计可能不稳定;
  • 一定噪声有时有助于离开平坦区域。

较大 batch:

  • 梯度更稳定;
  • GPU 并行效率通常更好;
  • 占用更多显存;
  • 每个 epoch 的更新次数更少;
  • 超过一定规模后吞吐收益会递减;
  • 改变 batch 后通常需要重新调整学习率和训练步数。

“大 batch 一定泛化更差”不是普遍定律。实验结果取决于学习率、训练时长、调度器和 比较预算。

16.2 实测流程

  1. 使用接近最大长度的代表性输入;
  2. 依次尝试 1、2、4、8、16、32、64;
  3. 记录 OOM、峰值显存、step time、样本/秒或 token/秒;
  4. 不以刚好占满全部显存为目标,给长样本和临时分配保留余量;
  5. 找到吞吐开始明显递减的拐点;
  6. 对两三个候选值做短训练并重新检查学习率;
  7. 使用验证指标而不是单看训练 loss。

示例:

Batch峰值显存吞吐量
45 GB80 samples/s
88 GB145 samples/s
1613 GB210 samples/s
2415 GB218 samples/s

从 16 增加到 24 几乎占满显存,却只增加少量吞吐,通常选择 16 更稳妥。

16.3 Effective Batch Size 与梯度累积

Beffective=Bper-device×accumulation steps×device countB_{\text{effective}} =B_{\text{per-device}} \times\text{accumulation steps} \times\text{device count}

例如单卡 micro-batch 为 4,累积 8 次:

Beffective=4×8=32B_{\text{effective}}=4\times8=32
accumulation_steps = 8
optimizer.zero_grad()

for step, (X, y) in enumerate(dataloader):
    prediction = model(X)
    loss = loss_fn(prediction, y) / accumulation_steps
    loss.backward()

    if (step + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

梯度累积能获得较大的有效 batch,但不能增大单次矩阵计算,所以不一定提升 GPU 利用率。 含 BatchNorm 时,它也不完全等价于真实大 batch;Transformer 多使用 LayerNorm,这个 差异通常更小。

16.4 音频和 MIDI 应按长度组 batch

音乐样本长度差异可能很大,固定“样本个数”不能准确反映计算量。更合理的方式是:

  • 把长度接近的样本放在同一 batch;
  • 限制一个 batch 的总音频帧数;
  • MIDI Transformer 限制总 token 数;
  • 长序列使用更少样本,短序列使用更多样本;
  • 减少 padding 和显存碎片。

对当前 FashionMNIST MLP,batch_size=64 是合理起点。以后在 16 GB 显存上,batch 必须结合模型大小、序列长度、精度格式和冻结策略实测,不能只按显存容量选择最大值。


17. 大模型如何控制超参数实验成本

完整模型上穷举超参数组合通常不可行。若 5 个超参数各试 5 个值:

55=31255^5=3125

工程上采用漏斗式筛选:

阶段模型与数据候选数量目标
正确性检查极小模型、几十个样本1~2确认代码能过拟合
小规模搜索小模型、数据子集、短训练较多淘汰明显错误配置
中等规模确认较完整数据、中型模型2~5检查结论能否迁移
完整训练目标模型和完整数据1~2生成最终 checkpoint

17.1 继承成熟配方

不是所有超参数都从零搜索。通常先继承可靠实现中的:

  • optimizer 与常用默认值;
  • 初始化方式;
  • 学习率调度结构;
  • normalization;
  • 模型维度约束。

优先搜索少数敏感项:

  1. learning rate;
  2. effective batch size;
  3. warmup;
  4. weight decay;
  5. 必要时再搜索 dropout、层数和隐藏宽度。

17.2 多保真搜索与提前终止

不让所有候选完整训练:

20 个配置运行 5% 预算
↓ 淘汰明显落后或发散的配置
5 个配置运行 25% 预算
↓
2 个配置运行完整预算

ASHA、Successive Halving 等方法会根据中间验证指标提前终止较差实验。早期表现不一定 完全预测最终结果,所以应保留少数不同候选,而不是过早只留下第一名。

17.3 低成本代理实验

可以降低:

  • 模型层数或隐藏维度;
  • 数据量;
  • 音频片段长度;
  • MIDI sequence length;
  • 训练步数;
  • 输入分辨率或采样率。

也可以冻结预训练编码器,只训练较小的桥接层、Adapter、LoRA 或解码器。

代理实验必须保留当前问题的关键性质。例如研究长序列注意力时,不能把序列缩得过短, 否则显存和优化行为不再具有代表性。

小模型的最优超参数也不会天然迁移到大模型。大规模预训练中存在 μP/μTransfer 等专门 参数化方法,用于提高部分超参数跨模型尺寸迁移的稳定性。当前阶段只需理解这种思路, 不需要立即引入相应框架。

17.4 当前阶段的策略

成熟默认值
→ 过拟合极小数据集
→ 只调 learning rate 和 effective batch
→ 小规模验证训练流程
→ 最后再扩大模型和数据

每次实验应保存:

  • Git commit 或代码版本;
  • 数据版本;
  • 配置和随机种子;
  • 环境版本;
  • 训练/验证曲线;
  • 显存和吞吐;
  • checkpoint;
  • 失败原因和结论。

大型训练的目标不是在完整模型上寻找答案,而是在便宜的代理实验中逐步排除错误,最后 用极少数大规模运行验证结论。


18. 本次核心概念图

Dataset
  ↓ DataLoader 组成 batch
X, y
  ↓ X/y/model 移动到同一 device
model.train()
  ↓ 使用训练阶段的层行为
optimizer.zero_grad()
  ↓ 清除不需要的旧梯度
model(X)
  ↓ nn.Module.__call__ → forward
Linear → ReLU → Linear → ReLU → Linear
  ↓
logits
  ↓ loss_fn(logits, y)
标量 loss
  ↓ loss.backward()
每个 parameter.grad
  ↓ optimizer.step()
更新 W、b
  ↓
在后续 batch 上重复

训练结束或开始验证
  ↓
model.eval()
  ↓
with torch.no_grad():
  ↓
只进行前向计算和指标统计,不修改参数

最重要的理解:

  1. Tensor 是带 shape、dtype、device 和梯度信息的多维数组;
  2. PyTorch 在普通 Python 执行过程中动态建立计算图;
  3. nn.Module 管理子模块、参数、buffer、设备和状态;
  4. Linear 学习仿射变换,ReLU 提供非线性和梯度门控;
  5. loss 把当前预测质量表示成标量;
  6. backward() 使用链式法则计算每个参数的梯度;
  7. optimizer 沿负梯度方向小步更新参数;
  8. mini-batch 梯度是完整训练目标梯度的带噪声估计;
  9. 神经网络不保证唯一最低点,验证效果比唯一参数解更重要;
  10. train() 和 eval() 控制层行为,不负责梯度或参数更新;
  11. backward() 只计算梯度,step() 才更新参数,zero_grad() 清除累积梯度;
  12. 验证应同时使用 eval() 和 no_grad();
  13. batch 和其他超参数依靠小规模测量、早停和逐级验证选择。

19. PyTorch Quickstart 自测修正

19.1 Dataset 和 DataLoader 分别负责什么?

回答: Dataset 定义样本数量以及如何读取单个样本,常见接口是 __len__() 和 __getitem__();DataLoader 负责按 batch、shuffle、sampling、并行读取和 collate 等策略迭代 Dataset。训练集与测试集通常是不同的 Dataset 实例,不是由 DataLoader 自动划分。

19.2 __init__() 与 forward() 分别定义什么?

回答: __init__() 定义并注册模型包含的层、参数和 buffer;forward() 定义输入 如何经过这些层产生输出。调用 model(X) 时,nn.Module.__call__() 会进入 forward(X)。

19.3 为什么模型和 batch 要位于同一 device?

回答: 普通 Tensor 运算要求输入、模型参数及 loss 所需标签位于兼容 device。 PyTorch 不会隐式在 CPU 内存和 GPU 显存之间复制操作数,因此模型、X 和 y 通常 都要移动到同一 device。

19.4 train()、eval() 和 no_grad() 有什么区别?

回答: train() 与 eval() 控制 Dropout、BatchNorm 等 Module 的训练/评估行为; no_grad() 控制 Autograd 是否记录当前代码块的计算图。eval() 不会自动关闭梯度, no_grad() 也不会自动切换评估模式。

19.5 一次完整训练循环的固定顺序是什么?

回答: 普通训练的因果顺序是:清除旧梯度、前向计算、计算 loss、反向计算梯度、 更新参数。验证是独立阶段,一般在 epoch 结束或固定间隔执行,并使用 model.eval() 与 torch.no_grad(),不调用 backward() 和 optimizer.step()。

19.6 state_dict 保存了什么?

回答: model.state_dict() 保存已注册参数和持久化 buffer,不保存模型类与 forward() 代码。完整恢复训练还应另外保存 optimizer.state_dict(),并按需要保存 scheduler、AMP GradScaler、epoch、随机状态和实验配置。

19.7 二维 MLP 问题 2:为什么最后一层有两个输出,而不是一个?

原回答: 两个输出表示分类问题。

修正: 这说明了方向,但需要把类别数、输出格式和 loss 联系起来。当前任务有两个 类别,并使用 CrossEntropyLoss,所以模型为每个样本输出两个 logits:

logits.shape = [batch_size, 2]

例如:

[2.1, -0.4]  → 更倾向 class 0
[0.3,  1.7]  → 更倾向 class 1

logit 是未经归一化的类别分数,不是概率。预测类别可以用:

predicted_class = logits.argmax(dim=1)

CrossEntropyLoss 接收形如 [batch_size, class_count] 的 logits 和形如 [batch_size] 的类别编号,并在内部完成 log_softmax 与负对数似然计算。因此当前 最后一层不需要手动添加 Softmax。

二分类并不一定必须使用两个输出。另一种设计是只输出一个 logit,并使用 BCEWithLogitsLoss。当前练习选择两个 logits,是因为它把二分类当作一般的多类别 分类问题处理。

19.8 二维 MLP 问题 6:checkpoint 为什么要保存 optimizer 状态?

原回答: 不知道;按说重新从 model 里 load 也是可以的。

修正: 只加载 model 的确可以从已有参数位置重新开始训练,但不一定能接续原来的 优化过程。model 保存当前学习结果,即 weights 和 biases;optimizer 还可能保存过去 多个 batch 形成的更新历史。

最简单、且不带 momentum 的 SGD 只使用当前参数、当前梯度和学习率:

wt+1=wt−ηgtw_{t+1}=w_t-\eta g_t

如果数据顺序、随机状态和学习率等条件也相同,只恢复 model 参数通常可以继续这种 更新。但 Adam 比基础梯度下降多维护了每个参数的历史状态:

mt=β1mt−1+(1−β1)gtm_t=\beta_1m_{t-1}+(1-\beta_1)g_t vt=β2vt−1+(1−β2)gt2v_t=\beta_2v_{t-1}+(1-\beta_2)g_t^2

其中 m 是梯度的一阶移动平均,v 是梯度平方的二阶移动平均。Adam 通常在 optimizer.state 中保存:

step
exp_avg       # m
exp_avg_sq    # v

每个 batch 的相关操作是:

optimizer.zero_grad()
  ↓ 只清除 parameter.grad
loss.backward()
  ↓ 把当前 batch 梯度 g_t 写入 parameter.grad
optimizer.step()
  ↓ 使用 g_t 更新 Adam 的 m、v、step 和模型参数

zero_grad() 清除的是 Autograd 写在参数 .grad 上的当前梯度缓存,不会清除 optimizer 内部的 m、v 和 step。因此两者并不矛盾:每个 batch 使用一份干净的 当前梯度,但 Adam 仍使用跨 batch 的历史统计决定实际更新方向和尺度。

如果只恢复 model 并重新创建 Adam,参数位置虽然恢复了,但 m、v 和 step 会 从零开始。新的下一步更新将和未中断训练不同。要真正恢复训练,checkpoint 至少应 保存:

{
    "model_state_dict": model.state_dict(),
    "optimizer_state_dict": optimizer.state_dict(),
    "epoch": epoch,
    "loss": loss,
}

更复杂的训练还可能需要保存 scheduler、AMP GradScaler、随机数生成器和 DataLoader sampler 状态。

资料