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.95s1. 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 Ti2.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: 91900available: 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.9Compute 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 背后的数学不依赖框架。主要差异在编程模型和生态。
| 方面 | PyTorch | TensorFlow |
|---|---|---|
| 常用模型类 | torch.nn.Module | tf.keras.Model |
| 自动求导 | loss.backward() | tf.GradientTape |
| 数据加载 | Dataset、DataLoader | tf.data |
| 默认执行方式 | Eager | TensorFlow 2 也是 Eager |
| 编译优化 | torch.compile | tf.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]因为:
标签通常是:
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)执行仿射变换:
单个输出分量:
每个输出都读取全部输入,所以称为全连接层。W 和 b 都是训练时学习的参数。
对于第一层:
在 PyTorch 的 batch 表示中:
形状变化:
[64, 784] × [784, 512] → [64, 512]同一组参数分别作用于 64 个样本,样本之间不会因 Linear 而互相混合。
8.2 ReLU
ReLU 的定义:
分段写作:
例如:
输入:[-3.0, -0.2, 0.0, 1.5, 4.0]
输出:[ 0.0, 0.0, 0.0, 1.5, 4.0]ReLU 没有可训练参数,也不改变 Tensor 形状。它的作用是加入非线性。
如果连续两个 Linear 之间没有非线性:
展开得到:
仍能合并成单个 Linear。加入 ReLU 后:
无法再合并成一次矩阵变换,网络因此能表达非线性关系。
8.3 整个网络的公式
合并为:
最后一层不使用 ReLU,因为分类 loss 需要原始 logits。预测类别可以取:
predicted_class = logits.argmax(dim=1)训练时 nn.CrossEntropyLoss 直接接收 logits,不应提前手工添加 Softmax。
8.4 参数量
| 层 | 权重 | 偏置 | 参数量 |
|---|---|---|---|
Linear(784, 512) | 784 × 512 | 512 | 401,920 |
Linear(512, 512) | 512 × 512 | 512 | 262,656 |
Linear(512, 10) | 512 × 10 | 10 | 5,130 |
| 合计 | 669,706 |
Flatten 和 ReLU 都没有训练参数。隐藏宽度 512 是人为选择的超参数,没有特殊的
数学含义。
9. 参数、权重、偏置、buffer 和超参数
严格定义下:
可训练参数 parameter
├── 权重 weight:W
└── 偏置 bias:b当前网络的参数集合是:
可以查看:
for name, parameter in model.named_parameters():
print(name, parameter.shape, parameter.requires_grad)工程上“加载模型权重”经常宽泛地表示加载全部已学习状态,因此有时也会包含 bias,
甚至包含保存于 state_dict 的非参数 buffer。严格数学语境中,weight 只是 parameter
的一种。
9.1 为什么系数称为 weight
一个神经元首先计算加权和:
数学上, 可以叫系数;神经网络称它为 weight,是为了强调它决定对应输入以多大 程度、什么方向影响输出。
因为:
所以在其他条件不变时:
- :该输入暂时不影响输出;
- :输入增大会让输出增大;
- :输入增大会让输出减小;
- 越大:输出对该输入的局部变化越敏感。
例如:
x1 增加 1 → z 增加 2
x2 增加 1 → z 减少 0.5因此可以说 和 在这个加权和中分别获得了 和 的权重。
在深层网络中,不能仅凭单个 weight 的绝对值直接判断原始特征重要性,因为输入尺度、 其他层参数和非线性变换都会影响最终结果。
9.2 为什么常数项称为 bias
如果没有 bias:
当输入为 0 时,输出被固定为 0。加入 bias:
当输入为 0 时:
给模型提供了一个与输入无关的基础偏移。在线性回归中,它也叫 intercept,即 截距。例如:
- weight 控制斜率;
- bias 让整条直线向上移动 3。
9.3 bias 如何改变神经元激活门槛
考虑:
神经元激活的条件是:
也就是:
如果只把 bias 从 改为 :
激活条件就变为:
weight 没有改变,但神经元更容易激活。因此 bias 可以理解为让神经元更容易或更难 激活的基础倾向。
这个名称也与早期感知机的阈值写法有关。感知机可以写成:
移项:
定义:
就得到现在常用的:
weight 控制各输入贡献,bias 控制整体激活倾向。
9.4 几何解释
线性分类边界可以写成:
- 是分界面的法向量,决定边界朝向;
- 决定分界面相对原点的位置。
如果 ,分界面必须经过原点。加入 bias 后,分界面可以在保持方向的同时平移。
weight:控制边界朝向
bias: 控制边界位置9.5 bias 也是可训练参数
bias 被称为常数项,是因为对不同输入 ,它不乘输入变量;它并不是训练过程中固定不变 的常量。它和 weight 一样由 optimizer 学习:
例如:
nn.Linear(784, 512)内部包含:
weight:[512, 784]
bias: [512]512 个输出神经元各自有一个 bias。
9.6 bias 可以写成特殊 weight
把输入增广一个永远等于 1 的分量:
把 bias 放入扩展权重:
那么:
所以数学上 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模型:
单个样本的 loss:
数学上 loss 同时依赖参数、输入和目标。但训练一个 batch 时,数据 被视为固定, 真正要改变的是参数 。
10.1 回归示例:平方误差
模型:
为了便于求导,定义:
代入模型:
10.2 为什么导数是 y_hat - y
令:
则:
根据链式法则:
其中:
所以:
当 :
负号表示在当前位置稍微增大预测值会降低 loss。
数值验证:
差商:
步长趋近于 0 时,差商趋近于 。
前面的 只是为了抵消平方求导产生的 2,不改变最低点位置。如果定义 ,导数就是 。
10.3 分类示例:交叉熵
对十分类模型,网络输出 logits:
真实类别为 时,交叉熵为:
它把十个类别分数与真实类别的关系压缩为一个标量。batch 中通常再对所有样本 loss 取平均。
11. 反向传播与参数梯度
训练真正需要的是:
而不是更新原始输入 。
继续使用:
链式法则给出:
这里出现的 来自:
它不是笼统地计算 。
11.1 梯度是常数吗
对固定输入:
可以是常数;但 loss 对参数的梯度:
依赖当前的 ,通常不是常数函数。
例如固定 :
w = 1.0 → 梯度 = -6
w = 2.0 → 梯度 = -2
w = 2.5 → 梯度 = 0
w = 3.0 → 梯度 = 2每次 backward() 得到的是当前 batch、当前参数位置上的具体数值。参数变化后,下一次
得到的梯度也会变化。
11.2 dL/dx 的作用
如果 是原始输入,通常不会用 更新数据。但深层网络 中某层的输入是前一层的输出:
原始输入 x → h1 → h2 → logits → loss的用途,是继续计算上一层参数的梯度,并进一步得到 。反向传播中的“传播”,就是把梯度从后层传给前层。
12. ReLU 如何参与反向传播
ReLU 的导数:
处严格来说不可导,PyTorch 采用约定的子梯度,通常取 0。
它可以理解为梯度开关:
前向时 z > 0 → 反向梯度通过
前向时 z < 0 → 反向梯度变为 0考虑完整的标量网络:
从后向前:
经过 ReLU:
最后:
如果 ,这一样本经过该神经元的梯度为 0。一个 ReLU 神经元如果长期对所有输入 都位于负区间,就可能停止学习,称为 dead ReLU。
12.1 矩阵形式
对:
设后面传回:
则:
表示逐元素乘法, 是由前向结果产生的 0/1 掩码。 Autograd 会保存反向所需的信息并自动完成这些运算。
13. 梯度下降为什么能更新参数
参数更新不是“加上偏导”,而是:
是学习率。
- 梯度为正:增加参数会增大 loss,因此减小参数;
- 梯度为负:增加参数会降低 loss,因此减去负数、增大参数。
13.1 一阶泰勒展开
对一个小变化 :
选择:
得到:
平方非负,所以学习率足够小时,局部近似预测 loss 会下降。
多参数时,所有参数组成向量 :
梯度 指向局部增长最快的方向,负梯度指向局部下降最快的方向。
13.2 数值示例
模型:
数据与当前参数:
前向:
梯度:
学习率 :
新预测和 loss:
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.gradAdam 会额外维护动量和梯度平方等状态,但梯度仍然提供核心方向信息。
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 开始,沿计算图反向应用链式法则,计算每个需要梯度的叶子参数:
结果保存在与参数形状相同的:
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 近似为:
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
第 个样本的 loss:
整个训练集真正优化的经验目标:
不同样本的梯度可以方向不同,甚至完全相反。
14.1 梯度冲突示例
模型只有一个输出参数:
两个样本的目标分别是 1 和 3:
梯度:
当 :
第一个样本希望参数减小,第二个希望参数增大。完整目标取平均:
是两个冲突目标的折中。
14.2 batch 平均梯度
当前 batch 的目标:
梯度:
一致的梯度方向会加强,冲突部分会抵消。下一批数据完全可能让某些参数朝相反方向 移动,因此训练曲线通常会波动,并不要求每一步都降低完整训练集 loss。
如果 batch 从训练集中均匀随机抽取,那么它的梯度是完整训练集梯度的带噪声估计:
很多随机 batch 的平均方向会接近整体目标的下降方向。shuffle、batch、较小学习率、 Momentum 或 Adam 都有助于控制波动。
15. 局部最低点与训练停滞
15.1 能否保证只有一个最低点
简单线性回归配合 MSE 时,目标关于参数可以是凸函数。凸函数的任意局部最低点都是 全局最低点;进一步满足严格凸条件时,可以有唯一最低点。
深层 ReLU 网络的目标关于所有参数通常是非凸函数,可能存在:
- 多个局部最低点;
- 鞍点;
- 平坦区域;
- 很多功能相同但参数不同的解。
例如交换两个隐藏神经元,同时交换下一层对应连接,网络输出不变。因此唯一参数解通常 既无法保证,也不是实际目标。
真正关注的是:训练 loss 足够低、验证集效果良好、对未见数据能够泛化。
15.2 真正落在较差局部最低点怎么办
如果确实位于严格局部最低点,并且确定性梯度为 0,普通梯度下降不会移动。离开当前 区域需要:
- 不同随机初始化或重新启动;
- 更大的学习率或周期性学习率;
- mini-batch 梯度噪声;
- Momentum;
- 参数扰动;
- 修改初始化、网络结构、数据或目标函数。
但是“loss 不再下降”不能直接证明进入局部最低点。更常见的检查方向包括:
- 学习率太小或太大;
- 鞍点或平坦区域;
- 梯度消失、梯度爆炸或大量 dead ReLU;
- 数据归一化、标签或 loss 实现错误;
- 参数没有加入 optimizer;
- 模型容量不足。
实用诊断顺序:
- 检查梯度范数和参数是否真的变化;
- 尝试让模型过拟合几十个样本;
- 尝试不同学习率;
- 使用多个随机种子;
- 同时观察训练与验证指标;
- 保存验证集表现最好的 checkpoint。
如果模型无法过拟合极小数据集,应先检查实现、数据和优化配置,而不是立即归因于局部 最低点。
16. 工程上如何选择 batch size
batch size 同时影响:
显存容量
GPU 吞吐量
梯度噪声
参数更新次数
泛化与优化行为16.1 太小与太大的权衡
较小 batch:
- 显存占用低;
- 梯度噪声大;
- GPU 利用率可能低;
- BatchNorm 统计可能不稳定;
- 一定噪声有时有助于离开平坦区域。
较大 batch:
- 梯度更稳定;
- GPU 并行效率通常更好;
- 占用更多显存;
- 每个 epoch 的更新次数更少;
- 超过一定规模后吞吐收益会递减;
- 改变 batch 后通常需要重新调整学习率和训练步数。
“大 batch 一定泛化更差”不是普遍定律。实验结果取决于学习率、训练时长、调度器和 比较预算。
16.2 实测流程
- 使用接近最大长度的代表性输入;
- 依次尝试
1、2、4、8、16、32、64; - 记录 OOM、峰值显存、step time、样本/秒或 token/秒;
- 不以刚好占满全部显存为目标,给长样本和临时分配保留余量;
- 找到吞吐开始明显递减的拐点;
- 对两三个候选值做短训练并重新检查学习率;
- 使用验证指标而不是单看训练 loss。
示例:
| Batch | 峰值显存 | 吞吐量 |
|---|---|---|
| 4 | 5 GB | 80 samples/s |
| 8 | 8 GB | 145 samples/s |
| 16 | 13 GB | 210 samples/s |
| 24 | 15 GB | 218 samples/s |
从 16 增加到 24 几乎占满显存,却只增加少量吞吐,通常选择 16 更稳妥。
16.3 Effective Batch Size 与梯度累积
例如单卡 micro-batch 为 4,累积 8 次:
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 个值:
工程上采用漏斗式筛选:
| 阶段 | 模型与数据 | 候选数量 | 目标 |
|---|---|---|---|
| 正确性检查 | 极小模型、几十个样本 | 1~2 | 确认代码能过拟合 |
| 小规模搜索 | 小模型、数据子集、短训练 | 较多 | 淘汰明显错误配置 |
| 中等规模确认 | 较完整数据、中型模型 | 2~5 | 检查结论能否迁移 |
| 完整训练 | 目标模型和完整数据 | 1~2 | 生成最终 checkpoint |
17.1 继承成熟配方
不是所有超参数都从零搜索。通常先继承可靠实现中的:
- optimizer 与常用默认值;
- 初始化方式;
- 学习率调度结构;
- normalization;
- 模型维度约束。
优先搜索少数敏感项:
- learning rate;
- effective batch size;
- warmup;
- weight decay;
- 必要时再搜索 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():
↓
只进行前向计算和指标统计,不修改参数最重要的理解:
- Tensor 是带 shape、dtype、device 和梯度信息的多维数组;
- PyTorch 在普通 Python 执行过程中动态建立计算图;
nn.Module管理子模块、参数、buffer、设备和状态;- Linear 学习仿射变换,ReLU 提供非线性和梯度门控;
- loss 把当前预测质量表示成标量;
backward()使用链式法则计算每个参数的梯度;- optimizer 沿负梯度方向小步更新参数;
- mini-batch 梯度是完整训练目标梯度的带噪声估计;
- 神经网络不保证唯一最低点,验证效果比唯一参数解更重要;
train()和eval()控制层行为,不负责梯度或参数更新;backward()只计算梯度,step()才更新参数,zero_grad()清除累积梯度;- 验证应同时使用
eval()和no_grad(); - 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 1logit 是未经归一化的类别分数,不是概率。预测类别可以用:
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 只使用当前参数、当前梯度和学习率:
如果数据顺序、随机状态和学习率等条件也相同,只恢复 model 参数通常可以继续这种 更新。但 Adam 比基础梯度下降多维护了每个参数的历史状态:
其中 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 状态。
资料
- PyTorch Quickstart
- PyTorch Optimizing Model Parameters
- PyTorch Reproducibility
- PyTorch Performance Tuning Guide
- PyTorch Data Loading Optimization
- PyTorch AMP Gradient Accumulation
- TensorFlow 1.x vs TensorFlow 2
- Keras 3
- Hugging Face Transformers v5
- AudioCraft
- Basic Pitch
- Magenta
- On Large-Batch Training for Deep Learning
- Train Longer, Generalize Better
- An Empirical Model of Large-Batch Training
- Tensor Programs V: Tuning Large Neural Networks via Zero-Shot Hyperparameter Transfer