本章可运行实验 · PyTorch 图像分类
实验包提供 06_pytorch_mnist.ipynb、普通训练脚本、自动测试和答案。先用离线合成数字图跑通 Dataset、CNN、训练、checkpoint 与重新加载,再切换真实 MNIST。
01 · ENVIRONMENT & FIRST RUN
环境与第一个脚本:先保证实验能被别人复现
安装命令只是起点。真正可靠的 PyTorch 项目还要记录 Python、PyTorch、CUDA、设备、依赖和随机性,并用一个最小 Tensor 脚本确认环境确实能完成创建、计算和反向。先把基线写清楚,后面遇到错误才知道是代码、数据还是环境的问题。
环境交接单:把“能运行”变成“可复现”
安装成功只是起点,真正可用的环境还要能被另一个人按照同一份说明重建。交接单至少写清 Python 版本、虚拟环境名称、PyTorch 与 torchvision 版本、操作系统、设备类型、CUDA 是否可见、项目根目录和启动命令。不要只截图安装过程;截图无法表达依赖版本,也无法在半年后自动验证。把关键命令放进 README,把机器相关信息写入 JSON,二者一个给人读,一个给脚本读。
依赖文件要区分“直接依赖”和“间接依赖”。学习项目可以先记录手工安装的 torch、torchvision、numpy 和 matplotlib,再用 pip freeze 生成完整快照;如果快照里出现大量与项目无关的包,不要全部当成项目需求。升级时一次只改一个包,并先执行导入、Tensor 运算、DataLoader 取 batch 和模型前向四个 smoke check。任何一步失败,都先记录版本差异,再决定回滚或升级。
设备选择也应写成规则,而不是藏在代码里的猜测。优先顺序可以是 CUDA、MPS、CPU,但每种设备都要有可接受的降级路径;GPU 不可见时,程序应打印清楚“当前使用 CPU”,而不是悄悄运行数小时。随机性无法完全消除时,至少固定 Python、NumPy、PyTorch 和 DataLoader 的 seed,并在报告中说明是否开启确定性算法及其速度代价。
| 字段 | 示例 | 验证方式 | 缺失后果 |
|---|---|---|---|
| python | 3.12.x | python --version | 语法/轮子不兼容 |
| torch | 2.x | import torch; print(torch.__version__) | API 或权重加载差异 |
| device | cuda:0 / cpu | torch.cuda.is_available() | 性能和显存无法解释 |
| data_root | data/mnist | 检查文件与哈希 | 数据版本混淆 |
| seed | 2026 | manifest.json | 结果难以复现 |
| command | python train.py | 从干净终端执行 | 交接依赖个人记忆 |
完成后做一次“冷启动演练”:关闭当前终端,进入项目根目录,新建一个临时虚拟环境,只按交接单安装依赖并运行最小脚本。若冷启动失败,把失败步骤补回文档。这个动作看似花时间,却能尽早暴露路径写死、缺少依赖、未下载数据或环境变量未说明等隐性成本。
为自己的机器写出至少八个字段的 manifest,并说明其中两个字段如何用脚本自动验证。再设计一个 CPU 降级方案,让同学拿到项目后不需要修改源代码就能运行 smoke check。
参考结论:manifest 可包含 Python、torch、torchvision、系统、设备、项目提交号、数据版本、seed、启动命令和输出目录。脚本验证版本、设备、Tensor 前向和 DataLoader;设备由命令行或配置选择,CUDA 不可用时回退 CPU 并在日志中明确记录。
完整实现路径:先生成机器可读 manifest,再运行 CPU smoke test,最后把退出码、stdout 和 JSON 报告一起保存。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
import json, platform, sys, torch
report = {
"python": sys.version.split()[0],
"platform": platform.platform(),
"torch": torch.__version__,
"cuda_build": torch.version.cuda,
"cuda_available": torch.cuda.is_available(),
}
x = torch.tensor([1.0, 2.0], requires_grad=True)
(x.square().mean()).backward()
assert torch.isfinite(x.grad).all()
print(json.dumps(report, ensure_ascii=False, indent=2))验收标准:字段齐全、脚本返回 0,且另一台机器能够解释每个环境差异。
若结果不对:先确认当前解释器路径,再查 torch 构建和实际设备,最后才查业务代码。
6.1.1 安装与项目结构:先把“能运行”变成可管理
PyTorch 项目的第一步不是打开一个 notebook,而是确认你知道代码、数据、环境和输出分别放在哪里。建议为每个练习建立独立的虚拟环境,避免不同项目抢同一个依赖版本。Windows、macOS 和 Linux 的安装命令可能不同,CPU 与 CUDA 版本也要按自己的硬件选择;不要从随机博客复制一条命令就执行,先阅读官方安装选择器给出的 Python、操作系统和计算平台组合。
虚拟环境解决的是“包安装到哪里”的问题,不能自动解决 CUDA 驱动、显卡显存或系统编译器问题。创建环境后先激活,再用 python -m pip 安装和查询,确保 pip 属于当前 Python。项目应保存一个依赖清单,例如 requirements.txt 或锁定文件,并写明安装日期、Python 版本、PyTorch 版本和设备类型。这样换电脑时可以重建近似环境,而不是凭记忆补包。
目录结构也属于实验设计。把源码、配置、数据缓存、运行输出、检查点和报告分开,避免把下载的数据、临时日志和模型权重混进源代码。初学者可以从下面的最小结构开始:src/ 放模块,scripts/ 放入口,configs/ 放 YAML 或 JSON,runs/ 放一次运行的日志和权重,tests/ 放快速断言。目录不是越复杂越专业,关键是别人能猜到文件的职责。
先建立一条最短可验证路径,再添加数据集和模型,错误才容易归因。
| 动作 | 目的 | 应记录什么 | 常见误区 |
|---|---|---|---|
| 创建虚拟环境 | 隔离项目依赖 | 环境路径、Python 版本 | 终端没有真正激活 |
| 安装 PyTorch | 获得 Tensor 与 autograd | 安装命令、平台选择 | CPU/CUDA 包混用 |
| 查询 pip | 确认包属于当前解释器 | python -m pip --version | 调用了另一个 Python |
| 保存依赖 | 后续重建环境 | requirements/锁文件 | 只记包名不记版本 |
| 运行探针 | 确认核心功能 | 版本、设备、输出摘要 | 只看 import 成功 |
from pathlib import Path
import platform
import sys
def project_report():
root = Path(__file__).resolve().parents[1]
return {
"python": sys.version.split()[0],
"platform": platform.platform(),
"executable": sys.executable,
"project_root": str(root),
"expected_dirs": [
name for name in ("src", "scripts", "configs", "runs", "tests")
if (root / name).exists()
],
}
for key, value in project_report().items():
print(f"{key}: {value}")
assert sys.version_info >= (3, 10), "需要 Python 3.10 或更高版本"这段脚本故意不调用复杂框架,只报告解释器和项目目录。把它放进 scripts/ 后,可以在任何新环境运行一次。若脚本显示的 executable 路径不是你刚创建的虚拟环境,先修复激活问题,再继续安装。工程中最浪费时间的情况之一,就是在错误的 Python 里反复安装,最后运行脚本时仍然找不到包。
安装失败时的排查顺序
先区分三类错误:下载或证书错误属于网络/镜像,找不到匹配版本属于 Python/平台组合,导入后 CUDA 不可用属于驱动或设备。错误信息中包含的 Python 路径、包版本和平台标签要完整保存。不要看到 “CUDA unavailable” 就立刻重装全部依赖;如果当前任务只需要 CPU,先确认 CPU 路径能完成小实验,再决定是否配置 GPU。
在团队或学习小组中,安装说明应包含“如何验证成功”,而不仅是命令。比如成功标准可以是:能导入 torch,能创建 float32 Tensor,能完成一次矩阵乘法和 backward,能打印 torch.__version__,并且输出报告写入 runs/env-report.json。把标准写成检查清单,初学者遇到问题时更容易定位缺哪一步。
创建一个独立环境,写下 Python、PyTorch、操作系统和设备信息。再设计一个你认为“安装成功”的最小条件,并把它变成一个可以重复运行的脚本。
参考结论:安装卡至少包含环境创建方式、PyTorch 安装来源、解释器路径、版本、CPU/CUDA 状态和一个成功输出。脚本应能在新终端运行,不能只依赖 notebook 中已经执行过的变量。
完整实现路径:先生成机器可读 manifest,再运行 CPU smoke test,最后把退出码、stdout 和 JSON 报告一起保存。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
import json, platform, sys, torch
report = {
"python": sys.version.split()[0],
"platform": platform.platform(),
"torch": torch.__version__,
"cuda_build": torch.version.cuda,
"cuda_available": torch.cuda.is_available(),
}
x = torch.tensor([1.0, 2.0], requires_grad=True)
(x.square().mean()).backward()
assert torch.isfinite(x.grad).all()
print(json.dumps(report, ensure_ascii=False, indent=2))验收标准:字段齐全、脚本返回 0,且另一台机器能够解释每个环境差异。
若结果不对:先确认当前解释器路径,再查 torch 构建和实际设备,最后才查业务代码。
6.1.2 版本、设备与随机性:让实验条件写在日志里
PyTorch 的行为受到版本、操作系统、CPU 指令集、CUDA 驱动、GPU 型号和随机种子的共同影响。版本号相同不代表所有硬件都逐位一致,但至少应该记录足以解释差异的条件。训练日志的开头建议写入 Python 版本、torch 版本、torchvision 版本、设备名称、显存、是否启用 CUDA、Git 或源码版本以及配置文件哈希。
torch.cuda.is_available() 只能说明当前进程是否能使用 CUDA,不保证显存足够,也不保证每个算子都有 GPU 实现。选择设备时可以从 torch.device("cuda" if ... else "cpu") 开始,随后让模型和 batch 显式调用 .to(device)。不要在模块内部把设备写死成 "cuda:0",否则代码在没有 GPU 的同学电脑上无法运行。
随机性来自 Python、NumPy、PyTorch CPU、PyTorch CUDA、DataLoader worker 和数据增强。固定 seed 可以让调试更稳定,但会牺牲一部分探索不同初始化的机会;严格确定性算法还可能更慢,某些 CUDA 算子也可能没有确定性实现。学习阶段先做到“同一环境同一 seed 可以近似复现”,做结论时再使用多个 seed 报告均值和波动。
| 字段 | 示例 | 为什么重要 | 保存位置 |
|---|---|---|---|
| Python/torch | 3.11 / 2.x | API 与数值实现可能变化 | 环境报告 |
| device | cpu / cuda:0 | 速度、显存和算子可用性 | 运行 manifest |
| seed | 7、19、31 | 比较初始化与采样波动 | 每次运行配置 |
| deterministic | True/False | 解释速度与复现差异 | 日志开头 |
| 数据版本 | mnist-v1 | 避免数据换了却误判模型 | 数据清单 |
import random
import platform
import sys
import torch
def seed_everything(seed):
random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def report(seed=7):
seed_everything(seed)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
sample = torch.randn(2, 3, device=device)
return {
"python": sys.version.split()[0],
"platform": platform.platform(),
"torch": torch.__version__,
"cuda_build": torch.version.cuda,
"cuda_available": torch.cuda.is_available(),
"device": str(device),
"device_name": torch.cuda.get_device_name(0) if device.type == "cuda" else "CPU",
"sample_mean": round(sample.mean().item(), 6),
}
for key, value in report().items():
print(f"{key}: {value}")这里把随机种子和设备选择集中在报告函数中,便于在训练脚本开头调用。不要把一次随机 Tensor 的均值当作跨设备完全相同的金标准;它只用于确认 Tensor 确实在目标设备上创建。真正的可复现测试还需要固定输入、保存模型初值,并允许合理的浮点容差。
CPU、GPU 与显存:先理解搬运成本
GPU 适合大量并行的矩阵和卷积运算,但 CPU 与 GPU 之间的数据搬运也需要时间。每个 batch 都在循环里频繁来回 .cpu()、.cuda(),可能抵消加速收益;日志和绘图通常才把少量结果搬回 CPU。显存不足时,先查看 batch、输入分辨率、模型参数和保存的激活,而不是只盲目改设备。
调试时可以先使用 CPU 和极小 batch,让错误信息更快出现;确认形状、损失和梯度正确后,再切换到 GPU 进行速度测量。设备选择是配置,不是业务逻辑。把它作为参数传给函数,训练、验证和推理才可以在不同机器上复用。
在同一环境中分别记录 CPU 或 CUDA 的报告。列出哪些字段在换设备后会改变,哪些字段应该保持不变,并说明你会给数值比较设置什么容差。
参考结论:设备名称、CUDA 构建和运行时间可能改变;模型结构、Tensor 形状、标签字典和任务配置应保持不变。浮点输出允许小幅差异,需用固定输入、相对/绝对误差和金样本定义通过标准。
完整实现路径:先生成机器可读 manifest,再运行 CPU smoke test,最后把退出码、stdout 和 JSON 报告一起保存。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
import json, platform, sys, torch
report = {
"python": sys.version.split()[0],
"platform": platform.platform(),
"torch": torch.__version__,
"cuda_build": torch.version.cuda,
"cuda_available": torch.cuda.is_available(),
}
x = torch.tensor([1.0, 2.0], requires_grad=True)
(x.square().mean()).backward()
assert torch.isfinite(x.grad).all()
print(json.dumps(report, ensure_ascii=False, indent=2))验收标准:字段齐全、脚本返回 0,且另一台机器能够解释每个环境差异。
若结果不对:先确认当前解释器路径,再查 torch 构建和实际设备,最后才查业务代码。
6.1.3 第一个可验证脚本:从 import 到 backward 完成闭环
第一个 PyTorch 脚本不需要训练 MNIST。它只要完成四件事:创建一个带明确 dtype 的输入 Tensor,执行一个有形状契约的计算,得到标量 loss,并让 autograd 产生梯度。这个闭环能验证环境、设备、模型参数和反向路径;以后任何复杂脚本都可以先用同样的方式缩小。
建议把输出分成“人读日志”和“机器可检查值”。人读日志可以打印 shape、dtype、device、loss 和梯度范数;机器检查使用断言,例如输出最后一维必须是类别数、loss 必须是有限标量、参数梯度不能为 None。断言失败时给出实际值和期望值,错误会比深层网络最后报出的模糊异常更有用。
运行脚本时从命令行进入项目根目录,使用 python scripts/first_torch_run.py,避免依赖当前编辑器的工作目录。输入文件路径、输出目录和 seed 都应由参数或配置控制。脚本成功后写一个简短的 JSON 报告,记录运行时间、版本和关键数值;这份报告会成为后续训练曲线和检查点的起点。
| 验收项 | 通过条件 | 失败时先查 |
|---|---|---|
| 输入 | (B,F)、float32、设备正确 | 构造方式与 .to(device) |
| 模型 | 参数出现在 parameters() | 是否注册为 nn.Parameter |
| 输出 | (B,C) 且全为有限值 | Linear 维度与输入范围 |
| loss | 标量、可 backward | reduction 与标签形状 |
| 梯度 | 关键参数有有限梯度 | requires_grad、是否断图 |
| 报告 | JSON 可再次读取 | 路径和编码 |
import json
from pathlib import Path
import torch
from torch import nn
def main():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
torch.manual_seed(7)
model = nn.Linear(4, 3).to(device)
features = torch.tensor([[1.0, 0.0, 0.5, -1.0], [0.2, 0.7, 0.1, 0.3]], device=device)
targets = torch.tensor([1, 2], dtype=torch.long, device=device)
logits = model(features)
loss = nn.CrossEntropyLoss()(logits, targets)
loss.backward()
grad_norm = torch.linalg.vector_norm(model.weight.grad).item()
assert logits.shape == (2, 3)
assert torch.isfinite(loss) and torch.isfinite(model.weight.grad).all()
report = {"device": str(device), "loss": loss.item(), "grad_norm": grad_norm}
Path("runs").mkdir(exist_ok=True)
Path("runs/first-run.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(report)
if __name__ == "__main__":
main()注意这里没有调用 optimizer.step,因为这个脚本的职责是验证 forward、loss 和 backward,不是完成训练。把职责拆开有两个好处:一是遇到错误时能知道属于哪一段,二是测试可以在不改变参数的情况下重复运行。真正训练时再增加 zero_grad、step、epoch 和验证逻辑,并为每一步增加独立日志。
看到异常输出时,先保存“第一现场”
若 loss 是 NaN,保存输入最小样本、logits 范围、模型参数范数、梯度范数、dtype 和设备;若 shape 报错,保存每个模块的输入输出;若 backward 后梯度为空,检查参数是否参与了 loss。错误报告不要只写“程序失败”,而要写触发命令、环境报告和最小输入,这样下一次可以直接回放。
当脚本通过后,把它作为项目的 smoke test。每次更新依赖、重构模型或切换设备,都先运行 smoke test;如果它失败,暂停增加功能。小项目中多花几分钟写这种测试,会省下后续数小时的盲目调参。
把示例改成 5 个输入特征、4 个类别,写出形状、标签 dtype、loss 和一个梯度范数断言。运行后读取 JSON 报告,说明它如何证明环境和反向路径可用。
参考结论:模型应为 nn.Linear(5,4) ,输入形状 (B,5) ,标签为 long 的 (B,) ,输出为 (B,4) 。报告至少有 device、loss、grad_norm,并能在新进程用 JSON 读取。
完整实现路径:先生成机器可读 manifest,再运行 CPU smoke test,最后把退出码、stdout 和 JSON 报告一起保存。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
import json, platform, sys, torch
report = {
"python": sys.version.split()[0],
"platform": platform.platform(),
"torch": torch.__version__,
"cuda_build": torch.version.cuda,
"cuda_available": torch.cuda.is_available(),
}
x = torch.tensor([1.0, 2.0], requires_grad=True)
(x.square().mean()).backward()
assert torch.isfinite(x.grad).all()
print(json.dumps(report, ensure_ascii=False, indent=2))验收标准:字段齐全、脚本返回 0,且另一台机器能够解释每个环境差异。
若结果不对:先确认当前解释器路径,再查 torch 构建和实际设备,最后才查业务代码。
| 能力 | 最低证据 | 迁移到后续实践 |
|---|---|---|
| 环境 | 能创建隔离环境并记录版本 | 安装 torchvision、工具链 |
| 设备 | 能选择 CPU/CUDA 并显式搬运 | 训练与推理切换 |
| 随机性 | 能固定 seed 并说明限制 | 多 seed 实验 |
| 闭环 | 能从 Tensor 跑到 backward | 接入 optimizer 与 Dataset |
| 证据 | 能写可回读的环境报告 | 保存曲线与 checkpoint |
把安装命令写成可迁移的步骤
一份好的安装说明不是一条神秘命令,而是“选择平台—创建环境—安装—验证—记录”的顺序。先确认 Python 版本和操作系统,再根据官方页面选择 CPU 或 CUDA 构建;安装后用当前解释器查询 pip 和 torch,最后运行 smoke test。每一步都写出预期输出或失败分支,初学者才不必猜“现在算成功了吗”。
依赖清单要分运行依赖和开发依赖。运行依赖包括 torch、torchvision 和项目真正导入的库;开发依赖包括测试、格式化和 profiling 工具。不要把整个系统环境的几百个包无差别冻结到教程里,否则别人很难复现;也不要只写一个没有版本的包名,导致未来安装得到完全不同的 API。
项目根目录和当前工作目录经常是初学者的隐形错误来源。脚本中使用相对路径时,说明它相对于哪里;更稳妥的方式是从 __file__ 推导项目根目录,并把数据、runs 和配置路径作为参数。编辑器的“运行”按钮可能改变工作目录,命令行 smoke test 可以揭示这种差异。
| 字段 | 示例 | 用途 |
|---|---|---|
| 平台 | Windows/Linux/macOS | 选择正确轮子 |
| Python | 3.11.x | 解释兼容性 |
| 计算平台 | CPU/CUDA 12.x | 说明速度与驱动 |
| 安装来源 | 官方命令/镜像 | 追溯依赖 |
| 验证命令 | python scripts/... | 判断安装完成 |
| 项目根目录 | 绝对路径或推导规则 | 避免相对路径错 |
import json
from pathlib import Path
import sys
import torch
def make_manifest():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
return {
"python": sys.version.split()[0],
"torch": torch.__version__,
"cuda_build": torch.version.cuda,
"device": str(device),
"project": "pytorch-practice",
"schema": 1,
}
manifest = make_manifest()
Path("runs").mkdir(exist_ok=True)
Path("runs/environment.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
assert json.loads(Path("runs/environment.json").read_text(encoding="utf-8")) == manifest
print(manifest)环境清单使用 schema 版本,是为了将来增加字段时仍能识别旧报告。把清单和每次运行的 run_id 绑定,不能用一个全局文件覆盖不同机器的结果。遇到“在我的电脑上可以”时,先比较清单,再决定是依赖、驱动、路径还是随机性差异。
在教学项目中,CPU 路径应尽量保持可用,因为不是每个人都有 CUDA。代码先选择 device,再把模型和 batch 显式搬运;性能对比时另写基准,不要为了追求速度把 CPU 逻辑删除。这样读者可以在没有显卡的环境完成所有概念验证,再把同一脚本扩展到 GPU。
02 · TENSOR WORKBENCH
Tensor 操作:把数组变成可推理的计算对象
Tensor 是 PyTorch 的基本语言。本节不只列 API,而是从创建、索引、切片、形状变换、广播、聚合、dtype、device 和内存布局说明每个操作的语义,并用可运行的小探针把错误尽早暴露。
Tensor 合同:每一次运算都先问四个问题
阅读一段 Tensor 代码时,可以固定询问:它的 shape 是什么,dtype 是什么,device 在哪里,是否与计算图相连。四个问题比背函数名更有迁移性。例如一个批次图像可能是 (B,C,H,W) 的 float32,标签可能是 (B,) 的 int64;把标签误转到 float,或把图像留在 CPU,都会在损失处才暴露。建议在模块边界打印一次摘要,而不是在每一行打印完整 Tensor。
视图与拷贝是初学者容易忽略的内存语义。reshape 在可能时返回共享存储的视图,也可能因为布局不连续而创建拷贝;view 对连续性要求更严格。transpose、permute 后若要交给只接受连续内存的代码,可以明确调用 contiguous,但不要把它当成无成本修复。修改视图可能影响原 Tensor,训练中再叠加原地操作还会破坏 autograd 保存的中间值。
广播让表达式简洁,也让错误看起来合理。两个维度从右向左比较,维度相等或其中一个为 1 才能广播;因此 (B,10) 加上 (10,) 是按类别偏置,(B,10) 加上 (B,) 通常不是你想要的。把广播后的 shape 写进注释或断言,能避免 batch 维被意外对齐。归约也要说明维度:sum(dim=1) 与 mean(dim=1) 的缩放不同,会直接影响 loss 和梯度大小。
| 检查 | 问题 | 典型证据 | 修复方向 |
|---|---|---|---|
| shape | 轴代表什么 | assert x.ndim / x.shape | 明确 permute/flatten |
| dtype | 算子需要什么类型 | x.dtype、target.dtype | 显式 to/long/float |
| device | 是否在同一设备 | x.device == next(model.parameters()).device | 统一搬运策略 |
| layout | 是否共享存储 | stride、data_ptr | view/clone/contiguous |
| grad | 是否需要反向 | requires_grad、grad_fn | detach/no_grad |
| 数值 | 是否有限 | isfinite、min/max | 检查输入与学习率 |
import torch
def check_batch(images, targets, classes=10):
assert images.ndim == 4 and images.shape[1] in (1, 3)
assert images.dtype == torch.float32
assert targets.ndim == 1 and targets.dtype == torch.long
assert images.shape[0] == targets.shape[0]
assert int(targets.min()) >= 0 and int(targets.max()) < classes
assert torch.isfinite(images).all()
return {"shape": tuple(images.shape), "device": str(images.device)}
batch = (torch.randn(8, 1, 28, 28), torch.randint(0, 10, (8,)))
print(check_batch(*batch))把合同函数放在数据进入模型的第一处,训练时间越长,越值得这样做。断言在正式大规模运行中可以按配置关闭,但 smoke run、单元测试和调试模式应保留。你会发现很多“模型不学习”其实不是优化算法问题,而是一个轴、一个类型或一次错误的 detach。
写出 x.shape=(4,3)、b.shape=(3,) 与 scale.shape=(4,1) 时 x+b、x*scale 的结果 shape,并设计一个断言阻止 scale 误写成 (4,)。
参考结论:两次结果都是 (4,3) ; (3,) 对齐最后一轴, (4,1) 在最后一轴广播。应要求 scale.ndim==2 且 shape==(4,1),不要依赖隐式广播猜测意图。
完整实现路径:先在纸上写 shape、dtype、device 和梯度需求,再运行操作;每个可能广播或展平的位置都加断言。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
import torch
x = torch.randn(4, 3)
b = torch.randn(3)
scale = torch.randn(4, 1)
assert (x + b).shape == (4, 3)
assert (x * scale).shape == (4, 3)
def checked_linear_input(t):
if t.ndim != 2 or t.shape[1] != 3:
raise ValueError(f"expected (B, 3), got {tuple(t.shape)}")
return t验收标准:断言覆盖正常、边界和故意错误输入,错误应在进入模型前发生。
若结果不对:从报错最后一行倒查第一个形状变化点,逐层打印而不是盲改 Linear。
6.2.1 创建、索引与切片:先认识 Tensor 里的每一个元素
Tensor 可以由 Python 列表、NumPy 数组、随机函数或已有 Tensor 创建。torch.tensor 通常会复制数据,torch.as_tensor 可能尝试共享内存;如果原始数组随后还会改变,先确认共享关系,再决定是否复制。创建时显式指定 dtype,能避免整数输入被误当成浮点特征,或默认类型在不同环境中给你意外的精度。
标量、向量、矩阵和更高维 Tensor 的索引规则与 Python 序列相似,但每个逗号分隔的位置都对应一个轴。x[0] 取第一行,x[:, 1] 取第二列,x[..., -1] 取最后一个轴的最后元素。切片通常返回视图,索引结果可能降低维度;如果你需要保持 batch 轴,使用 0:1 而不是整数 0。写代码时先给轴命名,再决定索引表达式。
高级索引、布尔 mask 和 gather 可以选择性地取出元素,但它们的结果形状不总是直观。布尔 mask 常把多个轴压成一维,适合筛选但可能丢失位置信息;若要按类别或时间位置回收值,先保存索引和原始 shape。不要为了让结果能传进下一层而随意 flatten,先问被压平的轴是否具有相同语义。
每次取值都要知道是否减少了轴,以及结果是否仍能交给下一个模块。
| 写法 | 结果 | 是否可能共享/降维 | 检查重点 |
|---|---|---|---|
torch.tensor(data) | 从数据创建 Tensor | 通常复制 | dtype、复制成本 |
torch.zeros(shape) | 全零 Tensor | 新分配 | 设备与类型 |
x[0] | 取第一项/行 | 可能少一个轴 | 是否误删 batch |
x[:, 1:3] | 保留所有行的列切片 | 常为视图 | 范围是否含右端 |
x[mask] | 筛选有效元素 | 常改变形状 | 位置是否仍可追踪 |
x.clone() | 复制数据 | 不共享存储 | 是否需要复制梯度关系 |
import torch
scores = torch.arange(12, dtype=torch.float32).reshape(3, 4)
print("scores:", scores)
print("first row:", scores[0])
print("second column:", scores[:, 1])
print("middle block:", scores[:, 1:3])
row_keep = scores[1:2]
row_drop = scores[1]
print("keep axis:", row_keep.shape, "drop axis:", row_drop.shape)
assert row_keep.shape == (1, 4)
assert row_drop.shape == (4,)
mask = scores.ge(6)
selected = scores[mask]
print("selected shape:", selected.shape)
assert selected.tolist() == [6.0, 7.0, 8.0, 9.0, 10.0, 11.0]
copied = scores.clone()
copied[0, 0] = -1
assert scores[0, 0].item() == 0.0运行这段代码时,重点不是记住每个打印结果,而是观察整数索引与范围切片对维度的区别。模型一般期待保留 batch 轴,数据预览却常常只取一条样本;这两个场景要用不同写法。对于训练数据,推荐在 Dataset 单样本阶段明确返回 (F,),在 collate 后再得到 (B,F),不要在多个层次反复猜测。
视图、复制和原地修改
切片和某些 reshape 可能共享底层存储。修改视图会改变原 Tensor,修改原 Tensor 也会影响视图;这在节省内存时很有用,在调试数据时却很危险。需要独立缓存、异步处理或保存快照时使用 clone(),并决定是否要保留计算图。记录一个 Tensor 的 storage_offset 和 stride,可以帮助理解它是否是连续的视图。
原地操作的下划线命名,如 add_、zero_,表示直接改变对象。对不需要梯度的临时 buffer,原地操作可以减少分配;对参与反向的 Tensor,要确认 autograd 不需要被覆盖的旧值。初学阶段优先使用非原地表达式,等性能测量确实需要时再逐个引入,并用梯度测试保护。
创建一个形状为 (4,3) 的 Tensor,分别用整数索引和范围切片取第二行。再写出取第三列且保留列轴的写法,并说明每次操作后的形状。
参考结论:第二行整数索引为 x[1] ,形状是 (3,) ;保留行轴用 x[1:2] ,形状是 (1,3) 。第三列若要保留列轴可用 x[:,2:3] ,形状是 (4,1) 。
完整实现路径:先在纸上写 shape、dtype、device 和梯度需求,再运行操作;每个可能广播或展平的位置都加断言。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
import torch
x = torch.randn(4, 3)
b = torch.randn(3)
scale = torch.randn(4, 1)
assert (x + b).shape == (4, 3)
assert (x * scale).shape == (4, 3)
def checked_linear_input(t):
if t.ndim != 2 or t.shape[1] != 3:
raise ValueError(f"expected (B, 3), got {tuple(t.shape)}")
return t验收标准:断言覆盖正常、边界和故意错误输入,错误应在进入模型前发生。
若结果不对:从报错最后一行倒查第一个形状变化点,逐层打印而不是盲改 Linear。
6.2.2 形状、广播与聚合:让每一次运算都能说清轴
Tensor 运算的第一原则是先写输入输出形状。逐元素加减乘除要求形状相同或满足广播规则;矩阵乘法要求相邻乘积轴一致;拼接要求除目标轴外其他轴相同。把形状写在变量名或注释中,例如 features_bf、weights_fh、logits_btc,可以减少“看着都像二维数组”的误判。
广播从最后一个轴开始对齐,长度为 1 的轴或缺失轴可以扩展。它适合给每个特征加同一组偏移,也适合把一个标量应用到所有元素;但它不会检查你想要的语义。(B,F)+(B,) 在某些尺寸下会报错,在 B=F 时却可能静默通过。最好显式写成 (1,F) 或使用断言,避免偶然的数字相等掩盖轴错误。
聚合操作如 sum、mean、amax 可以沿指定轴进行,也可以保留维度。keepdim=True 常用于后续广播,避免把一个轴完全删除;但保留维度不等于保留语义,仍需记录分母和有效 mask。对 batch 求平均是统计样本,对特征求平均是压缩表示,对时间求平均则可能忽略长度差异,三者不能混为一谈。
| 运算 | 输入示例 | 输出示例 | 主要风险 |
|---|---|---|---|
| 逐元素加 | (B,F)+(1,F) | (B,F) | 偏移是否按特征 |
| 矩阵乘 | (B,F)@(F,H) | (B,H) | 乘积轴是否对齐 |
| 拼接 cat | (B,F1),(B,F2) | (B,F1+F2) | ID 与样本是否一致 |
| 堆叠 stack | 多个 (T,D) | (N,T,D) | 新增轴代表什么 |
| mean | (B,T,D) | (B,D) | padding 是否被平均 |
| sum | (B,F) | 标量或保留轴 | 数值随长度/批次变化 |
import torch
batch = torch.arange(12, dtype=torch.float32).reshape(3, 4)
offset = torch.tensor([[10.0, 20.0, 30.0, 40.0]])
shifted = batch + offset
print("broadcast:", shifted.shape)
assert shifted.shape == (3, 4)
weights = torch.randn(4, 2)
hidden = batch @ weights
print("matmul:", hidden.shape)
assert hidden.shape == (3, 2)
mean_features = batch.mean(dim=0)
mean_samples = batch.mean(dim=1, keepdim=True)
print("feature mean:", mean_features.shape)
print("sample mean:", mean_samples.shape)
assert mean_features.shape == (4,)
assert mean_samples.shape == (3, 1)
parts = torch.split(batch, 2, dim=1)
joined = torch.cat(parts, dim=1)
assert torch.equal(joined, batch)把两个均值都打印出来,你会看到 mean(dim=0) 得到每个特征在 batch 上的统计,而 mean(dim=1) 得到每个样本的统计。数据预处理中的标准化、模型中的 LayerNorm 和指标中的 loss reduction 都依赖类似的轴选择。初学阶段建议在每次 mean 或 sum 后写注释,说明统计对象和分母。
矩阵乘法的手算路径
给定 X 为 (B,F)、W 为 (F,H),输出第 b 行第 h 列是第 b 个样本的 F 个特征与 W 的第 h 列做点积。它不是把 batch 中不同样本混在一起,而是对每个样本使用同一组权重。理解这一点后,Linear 层的参数形状和 batch size 的可变性就不再神秘。
残差连接要求两条分支的 shape 与语义对齐。若隐藏维不同,可以增加线性投影,但不要直接 reshape“凑形状”;reshape 不会学习任何对齐关系。拼接后通常要更新下一个 Linear 的输入维度,并在模型卡里写明新维度来源。
写出 a 形状为 (2,3)、b 形状为 (1,3)、c 形状为 (2,1) 时,a+b 与 a+c 的形状。再举一个数字相等但语义可能错误的广播例子。
参考结论:两者结果都是 (2,3) ,因为长度为 1 的轴被扩展。若 batch=3、feature=3, (B,F)+(B,) 也可能得到 (3,3) ,但它把每个样本的值当成每个特征的偏移,必须避免。
完整实现路径:先在纸上写 shape、dtype、device 和梯度需求,再运行操作;每个可能广播或展平的位置都加断言。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
import torch
x = torch.randn(4, 3)
b = torch.randn(3)
scale = torch.randn(4, 1)
assert (x + b).shape == (4, 3)
assert (x * scale).shape == (4, 3)
def checked_linear_input(t):
if t.ndim != 2 or t.shape[1] != 3:
raise ValueError(f"expected (B, 3), got {tuple(t.shape)}")
return t验收标准:断言覆盖正常、边界和故意错误输入,错误应在进入模型前发生。
若结果不对:从报错最后一行倒查第一个形状变化点,逐层打印而不是盲改 Linear。
6.2.3 dtype、device 与布局:同一个数在不同容器里不一样
dtype 决定 Tensor 如何存储和计算。float32 是常用的训练类型,float64 精度更高但更慢更占内存,float16/bfloat16 可以节省显存却需要关注溢出、下溢和混合精度策略;int64 常用作类别索引,bool 常用作 mask。把所有 Tensor 都强行转成 float32 并不能解决接口问题,标签、索引和 mask 仍要保持正确类型。
设备决定计算发生在哪里。模型参数、输入、目标和部分 buffer 需要在兼容设备上;一个 CPU Tensor 与 CUDA Tensor 相加会报错。建议在函数入口选择 device,在 batch 到达训练循环时统一搬运,在 Dataset 中保持 CPU 数据。保存检查点时可以先把张量映射到 CPU,加载时再根据当前环境决定目标设备。
内存布局由 stride 描述。transpose、permute 往往只改变轴解释,结果可能不连续;view 要求合适的连续布局,reshape 必要时会复制。布局问题是性能和 API 约束,不是把轴语义弄对的替代方案。图像从 HWC 变 CHW 时先确认通道含义,再考虑 contiguous()。
| 属性 | 常见值 | 适用场景 | 错误信号 |
|---|---|---|---|
| dtype | float32 | 普通连续特征与模型参数 | 精度/速度不合适 |
| dtype | long/int64 | 类别索引、词表 id | 传给浮点算子或损失 |
| dtype | bool | mask、条件筛选 | 被当成数值求平均 |
| device | CPU/CUDA | 数据与模型计算位置 | Expected all tensors on same device |
| layout | stride/contiguous | 视图与底层性能 | view 失败或速度异常 |
import torch
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
features = torch.randn(2, 3, dtype=torch.float32, device=device)
labels = torch.tensor([0, 1], dtype=torch.long, device=device)
mask = labels.eq(1)
print(features.dtype, features.device, features.is_contiguous())
print(labels.dtype, labels.device, mask.dtype)
matrix = torch.arange(24, dtype=torch.float32).reshape(2, 3, 4)
permuted = matrix.permute(0, 2, 1)
print("strides:", matrix.stride(), permuted.stride())
contiguous = permuted.contiguous()
assert contiguous.is_contiguous()
assert contiguous.shape == (2, 4, 3)
try:
bad = features.to("cpu") + labels.to(device)
except RuntimeError as error:
print("device mismatch caught:", error.__class__.__name__)上面的 device mismatch 示例是否触发取决于当前是否有 CUDA,但它提醒我们不要在表达式中隐式混用设备。更稳妥的写法是先选择一个 device,再让相关 Tensor 显式 .to(device)。转换 dtype 时也要关注复制成本和梯度关系:to 可能创建新 Tensor,模型参数转换应在模型构造或加载阶段完成。
与 NumPy 互操作的边界
CPU Tensor 可以通过 .numpy() 与 NumPy 共享存储,但需要先确认它不在 CUDA、没有要求梯度,或使用 detach().cpu().numpy()。反向传播不会穿过 NumPy 运算;如果在数据处理阶段使用 NumPy,处理结果应在进入模型前重新转换成 Tensor,并测试 dtype 和 shape。不要在训练循环中频繁来回转换,这会增加同步和复制成本。
数据类型转换也可能改变标签语义。类别 id 从 CSV 读取后通常先是字符串,映射成 int64 才能交给交叉熵;连续目标则需要 float32。写一个小函数统一转换,比在模型 forward 中到处补 .float()、.long() 更容易追踪。
为你准备在下一节使用的一个 batch 写出 shape、dtype、device、是否需要梯度、每个轴的语义和允许的取值范围。再写三条断言,分别保护输入、标签和输出。
参考结论:表格分类示例:features 为 (B,F) float32,labels 为 (B,) long,二者同 device;模型输出为 (B,C) 且有限。断言至少检查 ndim/shape、dtype、device 一致和标签范围。
完整实现路径:先在纸上写 shape、dtype、device 和梯度需求,再运行操作;每个可能广播或展平的位置都加断言。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
import torch
x = torch.randn(4, 3)
b = torch.randn(3)
scale = torch.randn(4, 1)
assert (x + b).shape == (4, 3)
assert (x * scale).shape == (4, 3)
def checked_linear_input(t):
if t.ndim != 2 or t.shape[1] != 3:
raise ValueError(f"expected (B, 3), got {tuple(t.shape)}")
return t验收标准:断言覆盖正常、边界和故意错误输入,错误应在进入模型前发生。
若结果不对:从报错最后一行倒查第一个形状变化点,逐层打印而不是盲改 Linear。
| 能力 | 最低证据 | 下一步应用 |
|---|---|---|
| 索引 | 能解释整数索引、切片和 mask 的形状 | Dataset 单样本读取 |
| 广播 | 能写出输入输出轴并避免静默错位 | 归一化与 batch 运算 |
| 聚合 | 能说明 dim、keepdim 与分母 | loss/指标聚合 |
| 类型设备 | 能匹配 float、long、bool 与 device | 训练循环和推理 |
| 布局 | 能区分 permute、reshape 与 contiguous | 图像/序列模型输入 |
Tensor 计算的三种视角
第一种视角是数值:每个元素是什么,范围是否合理;第二种视角是形状:每个轴代表样本、时间、通道还是特征;第三种视角是生命周期:它是否需要梯度、是否共享存储、是否要搬到另一个设备。只有三种视角都说清楚,Tensor 操作才不会变成“能运行就算对”。
布尔 mask 适合表达有效性,不应被误当作 0/1 连续特征;索引 Tensor 常使用 long,传给 gather 或 Embedding;概率和损失输入通常使用浮点。类型转换要集中在数据边界,模型内部如果到处补 .float()、.long(),很难判断原始数据到底是什么。
随机 Tensor 适合生成形状探针,但不适合验证业务逻辑。模型通过随机输入只能说明算子连接和 shape 大致正确;要验证标签、预处理和指标,必须使用手工构造的样本。把“结构测试”和“数据测试”分开,失败时才知道应该看哪个层次。
| 视角 | 问题 | 推荐检查 | 典型后果 |
|---|---|---|---|
| 数值 | 范围、均值、有限性 | min/max/finite | 溢出、尺度错 |
| 形状 | 轴的名字和可变性 | shape/ndim/assert | 广播或 flatten 错 |
| 类型 | float/long/bool 是否匹配 | dtype 检查 | 损失或索引报错 |
| 设备 | 模型和数据在哪里 | device 对照 | 跨设备失败 |
| 生命周期 | 梯度、视图、复制 | grad_fn/clone/stride | 断图或原地覆盖 |
import torch
def check_features(features, batch, feature_count):
assert features.shape == (batch, feature_count)
assert features.dtype == torch.float32
assert torch.isfinite(features).all()
def check_labels(labels, batch, classes):
assert labels.shape == (batch,)
assert labels.dtype == torch.long
assert int(labels.min()) >= 0 and int(labels.max()) < classes
features = torch.tensor([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], dtype=torch.float32)
labels = torch.tensor([1, 0], dtype=torch.long)
check_features(features, batch=2, feature_count=3)
check_labels(labels, batch=2, classes=2)
indices = torch.tensor([[2, 0], [1, 1]], dtype=torch.long)
table = torch.arange(12).reshape(3, 4)
selected = table.gather(0, indices)
print("selected:", selected)
assert selected.shape == indices.shape代码中的 gather 示例说明索引 Tensor 的 dtype 和 shape 都有契约。实际使用时还要检查索引范围,避免负索引或越界把错误推迟到更深层。对 mask、index 和 feature 分别写小函数,比在大型 forward 里临时转换更容易复用。
三道门全部通过后,再把 Tensor 交给损失或优化器。
当你开始使用高维 Tensor 时,养成“先打印一个样本,再打印整体统计”的习惯。整体均值可能正常,单个样本却已经被错误广播;shape 可能正确,通道顺序却反了。把最小样本和统计摘要一起保存,后续错误分析会更高效。
03 · DATA PIPELINE
数据集与批次:让每一条样本稳定进入模型
Dataset、DataLoader 和 collate 不是样板代码,而是训练输入的边界。本节会从单样本职责开始,处理变长数据、shuffle、采样、增强、切分和质量审计,确保模型看到的每个 batch 都可以追溯。
数据管道的审计表:样本从哪里来、变成了什么
一个可靠的 Dataset 不只是“能返回数据”,还要能回答样本身份、标签来源、预处理版本和异常处理方式。建议让每个样本在内部保留稳定 ID;调试时返回 id、feature、target 和可选的 metadata,训练批次再通过 collate 组合。这样错误样例可以回到原始文件,而不是只剩一个批次内的序号。若数据来自多个目录或表格,先建立索引清单,再由 Dataset 按索引读取,避免遍历目录顺序变化。
切分数据时要把“独立性”放在随机性之前。同一用户、同一文档、同一视频帧或同一拍摄序列的样本不能随意散落到训练和验证中,否则指标会虚高。分类任务至少检查每个 split 的类别计数;时间数据按时间切分;分组数据按 group 切分。固定 seed 只能保证重复同一种错误,不能替代合理的切分策略。
DataLoader 的性能问题要用时间账本分析:读取、解码、transform、collate、等待 worker 和 GPU 计算分别花了多少时间。提高 num_workers 不一定更快,Windows 下还要留意进程启动和主模块保护;pin_memory 只在 CPU 到 CUDA 搬运时可能有帮助。先用单 worker 建立正确基线,再逐项改变 batch size、worker、prefetch 或 persistent workers,并记录吞吐和内存。
| 层级 | 要回答的问题 | 建议证据 | 失败信号 |
|---|---|---|---|
| 索引 | 样本 ID 是否稳定 | index.csv / hash | 重跑顺序变化 |
| 标签 | 标签从哪张表生成 | 类别映射、范围断言 | 空值或错位 |
| 切分 | 是否存在组/时间泄漏 | 组交集检查 | 验证异常高 |
| transform | 训练和评估是否有边界 | 同一样本前后对照 | 评估仍随机 |
| collate | 可变长度如何处理 | padding mask/长度 | 静默截断 |
| 吞吐 | 瓶颈在数据还是模型 | batch 等待时间 | GPU 空转 |
from collections import Counter
def audit_records(records, split_name):
ids = [row["id"] for row in records]
labels = [row["label"] for row in records]
if len(ids) != len(set(ids)):
raise ValueError(f"{split_name} 存在重复 ID")
if any(label is None for label in labels):
raise ValueError(f"{split_name} 存在空标签")
return {"split": split_name, "count": len(ids), "labels": dict(Counter(labels))}
train = [{"id": "a", "label": 0}, {"id": "b", "label": 1}]
print(audit_records(train, "train"))当数据管道通过审计后,再谈增强、采样和缓存。每一种优化都要保持样本 ID、标签和 split 不变;若缓存 transform 结果,缓存键应包含 transform 版本和输入文件哈希。这样未来发现预处理 bug 时,可以明确哪些缓存需要失效,而不是拿旧结果继续训练。
任选一个图像或文本样本,从索引文件开始写出它经过读取、transform、collate、搬到 device、进入模型和产生 loss 的每一步,并在每一步标出类型、shape 或 ID。
参考结论:答案不要求固定格式,但必须能追溯原始 ID、标签映射、transform 版本、批次 shape、device 和损失输入。若某一步无法写清楚,说明接口仍隐含了假设,应增加日志或断言。
完整实现路径:把读取、校验、变换、组 batch 和搬设备分开;样本保留稳定 ID,切分在构造 DataLoader 前完成。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
import torch
from torch.utils.data import Dataset, DataLoader
class Records(Dataset):
def __init__(self, rows): self.rows = rows
def __len__(self): return len(self.rows)
def __getitem__(self, i):
row = self.rows[i]
x = torch.tensor(row["features"], dtype=torch.float32)
y = torch.tensor(row["label"], dtype=torch.long)
assert x.ndim == 1 and 0 <= int(y) < 2
return {"id": row["id"], "x": x, "y": y}
loader = DataLoader(Records(rows), batch_size=3, shuffle=False)
batch = next(iter(loader))
assert batch["x"].shape[0] == len(batch["id"])验收标准:随机抽样与整批检查都通过,且 train/valid 实体交集为空。
若结果不对:先单独调用 dataset[0],再用 num_workers=0 取一个 batch,最后才恢复并行加载。
6.3.1 Dataset 的单样本职责:把原始记录变成稳定接口
Dataset 的核心是两个方法:__len__ 告诉 DataLoader 有多少个索引,__getitem__ 根据索引返回一条样本。返回值可以是 Tensor、字典或包含元数据的结构,但字段要稳定。对于图像分类,最小返回可以是 {"image": tensor, "label": int, "id": str};对于文本任务,还可能需要 token、长度和原始文本的脱敏摘要。
Dataset 不应该在每次取样时改变全局状态、读取验证指标或更新模型。它可以做必要的解码和轻量预处理,也可以根据传入的 transform 产生训练增强;但随机性要有边界,验证集不应复用训练增强。索引错误、文件缺失、标签不在词表和空样本应在数据入口尽早报出,而不是等模型产生 NaN。
路径和标签映射要版本化。扫描目录时按文件名排序并保存清单,避免操作系统返回顺序不同造成样本索引漂移;从字符串类别生成索引时固定字典顺序,并把映射保存到 JSON。若数据来自表格,明确哪一列是 ID、特征、标签和时间,避免把标签或未来字段拼进输入。
| 字段 | 类型示例 | 用途 | 必须检查 |
|---|---|---|---|
| feature/image | float Tensor | 模型输入 | shape、范围、dtype |
| label | int 或 long Tensor | 损失目标 | 范围、编码、缺失 |
| id | 字符串 | 错误定位、去重 | 唯一性、脱敏 |
| length/mask | long/bool Tensor | 变长序列 | padding 约定 |
| meta | 字典 | 分组、时间、版本 | 不泄漏未来信息 |
from dataclasses import dataclass
import torch
from torch.utils.data import Dataset
@dataclass(frozen=True)
class Record:
sample_id: str
features: tuple[float, ...]
label: int
class TinyDataset(Dataset):
def __init__(self, records, feature_count):
self.records = list(records)
self.feature_count = feature_count
if not self.records:
raise ValueError("数据集不能为空")
labels = [record.label for record in self.records]
if min(labels) < 0:
raise ValueError("标签必须非负")
def __len__(self):
return len(self.records)
def __getitem__(self, index):
record = self.records[index]
if len(record.features) != self.feature_count:
raise ValueError(f"{record.sample_id}: 特征数错误")
features = torch.tensor(record.features, dtype=torch.float32)
label = torch.tensor(record.label, dtype=torch.long)
return {"features": features, "label": label, "id": record.sample_id}
records = [Record("a", (0.1, 0.2), 0), Record("b", (0.8, 0.4), 1)]
dataset = TinyDataset(records, feature_count=2)
print(len(dataset), dataset[0])
assert dataset[0]["features"].shape == (2,)返回字典的好处是字段名比位置更清楚,训练循环可以写 batch["features"] 而不是猜 batch[0]。但字典也要求 collate 逻辑明确;如果某个字段只是调试元数据,不能让默认 collate 把字符串或可变长度对象处理成错误形状。先对单个索引、边界索引和一个无效索引写测试,再交给 DataLoader。
读文件型 Dataset 的工程细节
文件型 Dataset 可以在初始化时扫描路径并保存索引,避免每个样本重复遍历目录;大型数据集则可保存轻量 manifest,在 __getitem__ 中按需读取。缓存图像或文本时记录缓存版本,换预处理后不要悄悄复用旧缓存。文件句柄、解码器和多进程 worker 的生命周期也要符合库的要求。
如果样本损坏,不建议在训练中静默跳过,因为样本数量和分母会变化。可以在训练前做完整质量扫描,或把坏样本写入明确的 quarantine 清单,再报告被排除的数量。每次训练保存 manifest 哈希,确保“同一个数据集”不仅是一个文件夹名字。
为一个包含 id/features/label 的列表实现 Dataset。要求检查空数据、特征长度、标签范围,并让 dataset[0] 返回带字段名的字典。
参考结论:初始化时保存稳定的 records 和 feature_count; __len__ 返回记录数, __getitem__ 将特征转 float32、标签转 long,并原样保留唯一 ID。无效索引或坏记录应给出可定位的错误信息。
# 对应本节练习 2:先复制运行,再替换为自己的数据
import torch
from torch.utils.data import Dataset, DataLoader
class Records(Dataset):
def __init__(self, rows): self.rows = rows
def __len__(self): return len(self.rows)
def __getitem__(self, i):
row = self.rows[i]
x = torch.tensor(row["features"], dtype=torch.float32)
y = torch.tensor(row["label"], dtype=torch.long)
assert x.ndim == 1 and 0 <= int(y) < 2
return {"id": row["id"], "x": x, "y": y}
loader = DataLoader(Records(rows), batch_size=3, shuffle=False)
batch = next(iter(loader))
assert batch["x"].shape[0] == len(batch["id"])验收标准:随机抽样与整批检查都通过,且 train/valid 实体交集为空。
6.3.2 DataLoader 与 collate:把多个样本拼成一个 batch
DataLoader 在 Dataset 之上负责批次、顺序、采样和 worker。训练集通常开启 shuffle=True,验证和测试保持确定顺序;drop_last 是否开启要结合 BatchNorm、统计分母和样本利用率决定。学习阶段先用单 worker 跑通,再增加并行度,避免把数据内容错误和 worker 竞态混在一起。
固定长度的 Tensor 可以由默认 collate 自动堆叠,变长序列则需要自定义 collate。collate 的职责是对齐长度、填充 padding、生成 mask、堆叠标签和保留 ID;它不应重写标签含义或随机切分数据。padding 值、左/右填充、长度字段和 mask 方向必须写进契约,否则模型和损失很容易各自理解一套。
batch size 改变后,最后一个 batch 可能更小,指标聚合不能简单平均 batch loss。训练时把每批 loss 乘以有效样本或 token 数后累加,最后除以总有效数;分类正确数也按样本计数。若使用梯度累积,要明确一个 optimizer step 包含多少 micro-batch,并适当缩放 loss。
| 参数 | 作用 | 训练常用倾向 | 验证风险 |
|---|---|---|---|
| batch_size | 一次取样数量 | 受显存与吞吐约束 | 改变 loss 分母 |
| shuffle | 每轮重排索引 | 通常开启 | 结果难对齐 |
| drop_last | 丢弃不足一批 | 按任务决定 | 样本未被评估 |
| num_workers | 并行取样进程 | 测量后再增加 | 随机性/文件句柄 |
| collate_fn | 自定义 batch 拼接 | 变长数据必需 | padding/mask 错位 |
| pin_memory | 辅助 CPU→GPU 搬运 | 配合测量使用 | 不是准确性开关 |
import torch
from torch.utils.data import DataLoader, TensorDataset
features = torch.arange(20, dtype=torch.float32).reshape(10, 2)
labels = torch.arange(10, dtype=torch.long) % 2
dataset = TensorDataset(features, labels)
loader = DataLoader(dataset, batch_size=3, shuffle=False, drop_last=False)
seen = []
for batch_index, (batch_features, batch_labels) in enumerate(loader):
print(batch_index, batch_features.shape, batch_labels.tolist())
assert batch_features.ndim == 2
assert batch_features.shape[0] == batch_labels.shape[0]
seen.extend(batch_labels.tolist())
assert len(seen) == len(dataset)
assert len(loader) == 4
print("all labels seen:", seen)这个探针把最后一个不足 batch 的情况保留下来,能帮助你检查指标分母。若开启 drop_last=True,长度会变成 3,最后一条样本不会被看到;这在训练中有时为了稳定 batch,在验证中通常需要谨慎。不要只打印 len(loader),还要确认所有样本 ID 或标签确实按预期出现。
变长序列的 collate 心法
先求 batch 内最大长度,再创建 padding Tensor 和 bool mask。长度为 L 的样本前 L 个位置为 True,其余为 False;mask 的 shape 应和 token 的前两维对齐。若目标需要右移一位,collate 可以同时返回 input_ids、target_ids 和 target_mask,避免训练循环临时切片导致错位。
对图像和表格,collate 通常只是 stack;对文本、音频或检测框,collate 可能还要返回长度、边界框列表或多尺度信息。字段越复杂,越应写一个只接收少量样本的单元测试,逐项检查类型、shape、mask 总数和 ID 顺序。
创建 10 个样本、batch_size=3 的 DataLoader,分别比较 drop_last=False 和 True 时的 batch 数和样本数。说明训练与验证为什么可能采用不同选择。
参考结论:不丢弃时有 4 个 batch,最后一批 1 个样本;丢弃时有 3 个 batch,实际只看到 9 个样本。训练可为稳定 BatchNorm 选择丢弃,验证通常保留全部样本并按总样本数聚合。
# 对应本节练习 3:先复制运行,再替换为自己的数据
import torch
from torch.utils.data import Dataset, DataLoader
class Records(Dataset):
def __init__(self, rows): self.rows = rows
def __len__(self): return len(self.rows)
def __getitem__(self, i):
row = self.rows[i]
x = torch.tensor(row["features"], dtype=torch.float32)
y = torch.tensor(row["label"], dtype=torch.long)
assert x.ndim == 1 and 0 <= int(y) < 2
return {"id": row["id"], "x": x, "y": y}
loader = DataLoader(Records(rows), batch_size=3, shuffle=False)
batch = next(iter(loader))
assert batch["x"].shape[0] == len(batch["id"])验收标准:随机抽样与整批检查都通过,且 train/valid 实体交集为空。
6.3.3 切分、增强与质量:数据决定你能相信什么
切分不是把数组随机分成三份那么简单。独立同分布的独立样本可以做分层随机切分;时间序列要按时间向前验证;同一用户、同一文档或同一设备的样本要按组切分,避免近重复跨集合泄漏。切分规则、seed、每个集合的 ID 清单和标签分布都应保存。
训练增强只作用于训练路径,验证和测试应尽量保持真实分布。图像的裁剪、翻转、颜色变化要符合标签不变性;文本的删除、替换和截断要保留任务信息;音频的速度与噪声变化要符合采集条件。增强强度是超参数,不能因为训练集分数下降就断定有益,必须比较验证指标和错误桶。
质量审计至少包含缺失值、重复 ID、标签范围、类别比例、特征统计、图像尺寸、文本长度、非法编码和训练/验证交集。连续运行时可以把审计摘要写进 manifest,并在训练开始时拒绝版本不匹配的数据。若允许过滤坏样本,报告过滤规则和数量,不能让样本数在日志里悄悄变化。
| 任务情形 | 推荐切分 | 重点审计 | 错误后果 |
|---|---|---|---|
| 独立表格样本 | 分层随机 | 类别比例、重复行 | 少数类波动 |
| 时间序列 | 按时间向前 | 未来字段、窗口重叠 | 回测虚高 |
| 用户/文档分组 | 按 group 切分 | ID 交集、近重复 | 泄漏泛化 |
| 图像数据 | 按主体/来源切分 | 尺寸、坏图、增强 | 背景记忆 |
| 文本数据 | 按文档/时间切分 | tokenizer、长度、脱敏 | 模板泄漏 |
from collections import Counter
def audit_split(train_ids, valid_ids, test_ids, labels):
sets = {
"train": set(train_ids),
"valid": set(valid_ids),
"test": set(test_ids),
}
overlaps = {
"train_valid": sets["train"] & sets["valid"],
"train_test": sets["train"] & sets["test"],
"valid_test": sets["valid"] & sets["test"],
}
if any(overlaps.values()):
raise ValueError(f"发现 ID 交集: {overlaps}")
label_summary = {name: Counter(labels[name]) for name in labels}
return {
"sizes": {name: len(values) for name, values in sets.items()},
"labels": {name: dict(counts) for name, counts in label_summary.items()},
}
report = audit_split(
["a", "b", "c"], ["d"], ["e"],
{"train": [0, 1, 0], "valid": [1], "test": [0]},
)
print(report)分层不等于保证所有业务子群都代表充分。报告每个切分的总体类别比例、关键群组覆盖和样本数,必要时增加按群组的指标。小数据集上的一次高分可能只是切分运气,多 seed 不能修复切分规则错误,只能揭示随机波动。
预处理统计量只能从训练集来
标准化均值、方差、词表、类别映射和阈值都属于从数据中学习出来的状态。它们应该只使用训练集拟合,再应用到验证和测试;否则验证信息会泄漏到训练流程。保存这些状态时带上数据版本和特征顺序,线上推理必须读取同一份文件。
质量审计还要关注人工流程。标签是否由同一规则生成,复核者是否看到预测结果,测试集是否被反复查看,都会影响结论。把测试集当作一次最终验收,而不是日常调参表,是从练习走向真实项目的重要纪律。
设计一个含用户 ID 的数据集,先随机按行切分,再按用户切分。比较两种方式可能造成的指标差异,并写出你会保存的三份审计证据。
参考结论:同一用户的近似样本跨集合会让模型记住用户特征,随机切分指标可能虚高。应保存各集合 ID 清单、交集检查结果、切分规则和每组标签/样本摘要。
# 对应本节练习 4:先复制运行,再替换为自己的数据
import torch
from torch.utils.data import Dataset, DataLoader
class Records(Dataset):
def __init__(self, rows): self.rows = rows
def __len__(self): return len(self.rows)
def __getitem__(self, i):
row = self.rows[i]
x = torch.tensor(row["features"], dtype=torch.float32)
y = torch.tensor(row["label"], dtype=torch.long)
assert x.ndim == 1 and 0 <= int(y) < 2
return {"id": row["id"], "x": x, "y": y}
loader = DataLoader(Records(rows), batch_size=3, shuffle=False)
batch = next(iter(loader))
assert batch["x"].shape[0] == len(batch["id"])验收标准:随机抽样与整批检查都通过,且 train/valid 实体交集为空。
| 能力 | 最低证据 | 进入模型前要做到 |
|---|---|---|
| Dataset | 单索引返回稳定字段 | shape/dtype/ID 可检查 |
| DataLoader | 能解释 batch、shuffle、drop_last | 分母与样本数正确 |
| collate | 能处理固定/变长数据 | padding 与 mask 对齐 |
| 切分 | 能按时间/群组避免泄漏 | 清单和 seed 可回放 |
| 审计 | 能报告缺失、重复、比例和版本 | 训练拒绝不匹配数据 |
从采样器到 worker:DataLoader 的隐含状态
DataLoader 不只是一个 for 循环。shuffle、sampler、batch_sampler、worker、prefetch、pin_memory 和 drop_last 共同决定样本何时、以什么顺序到达训练循环。默认配置适合许多固定长度数据,但换成不平衡、时间序列或变长数据后,需要重新检查每个状态。先用确定性、单 worker 的配置建立基线,再逐个开启优化。
类别不平衡可以用加权损失、WeightedRandomSampler 或分层 batch,但三种方法改变的对象不同。采样器改变模型看到的频率,权重改变每个样本的梯度,二者同时使用可能过度放大少数类。训练日志要保存每类实际看到的样本数,评估仍在原始验证分布上进行。
worker 的随机增强需要独立种子。若所有 worker 继承同一个随机状态,可能产生重复增强;若没有固定 worker_init_fn,复现也会漂移。数据读取错误不要在 worker 中静默吞掉,可以捕获样本 ID 和文件路径后抛出包含上下文的异常。
| 状态 | 改变什么 | 适合用途 | 需记录 |
|---|---|---|---|
| sampler | 索引顺序/频率 | 分层、不平衡 | 类计数与 seed |
| batch_sampler | 一批索引的组成 | 长度分桶、组 batch | batch 规则 |
| num_workers | 读取并行度 | IO 瓶颈 | worker seed/内存 |
| prefetch | 提前准备 batch | 隐藏 IO 延迟 | 缓存占用 |
| drop_last | 是否保留尾批 | 稳定 batch | 实际样本数 |
import random
import torch
from torch.utils.data import DataLoader, TensorDataset
def seed_worker(worker_id):
seed = torch.initial_seed() % 2**32
random.seed(seed)
generator = torch.Generator().manual_seed(7)
features = torch.arange(24, dtype=torch.float32).reshape(12, 2)
labels = torch.arange(12, dtype=torch.long)
dataset = TensorDataset(features, labels)
loader = DataLoader(
dataset, batch_size=4, shuffle=True, num_workers=0,
worker_init_fn=seed_worker, generator=generator,
)
first_epoch = [int(label) for _, batch_labels in loader for label in batch_labels]
generator = torch.Generator().manual_seed(7)
loader_again = DataLoader(dataset, batch_size=4, shuffle=True, num_workers=0, generator=generator)
second_epoch = [int(label) for _, batch_labels in loader_again for label in batch_labels]
assert first_epoch == second_epoch
assert sorted(first_epoch) == list(range(12))
print("deterministic order:", first_epoch)示例固定 generator 后,shuffle 顺序可以重复;如果增加 worker 或随机 transform,要额外验证 worker 的随机源。顺序可复现不等于训练完全可复现,但它能先保护一个重要边界。对时间序列或组数据,仍要使用自定义 sampler,而不是依赖随机打散。
数据缓存也要有上限和版本。把所有图像预先读入内存可能加快训练,却会让不同机器得到不同的内存压力;把缓存写进固定目录,换 transform 后可能读到旧版本。manifest 中记录缓存策略和预处理哈希,才能解释吞吐变化。
让 batch 在进入模型前完成验收
训练循环拿到 batch 后,可以在 debug 模式执行一次轻量检查:字段齐全、样本数大于零、features 和 labels 的第一维一致、dtype/device 正确、数值有限、标签范围合法。正式长训练时减少昂贵打印,但保留关键断言和异常计数。验收通过的 batch 才交给 model。
如果 Dataset 返回字典,统一写一个 move_batch 函数递归搬运 Tensor,保留字符串 ID 在 CPU。这样模型不需要知道数据来自哪种文件,也不会因为把 ID 误传到 CUDA 而产生无关错误。
为一个 90%/10% 不平衡数据集分别设计普通 shuffle 和加权采样。记录每个 epoch 实际看到的类别数量,并说明评估为什么仍应使用原始分布。
参考结论:普通 shuffle 反映原始频率,加权采样提高少数类出现次数但改变训练分布。验证/测试应保持原始分布,报告每类指标和支持数;若再加 class weight,要说明两种机制的叠加效果。
# 对应本节练习 5:先复制运行,再替换为自己的数据
import torch
from torch.utils.data import Dataset, DataLoader
class Records(Dataset):
def __init__(self, rows): self.rows = rows
def __len__(self): return len(self.rows)
def __getitem__(self, i):
row = self.rows[i]
x = torch.tensor(row["features"], dtype=torch.float32)
y = torch.tensor(row["label"], dtype=torch.long)
assert x.ndim == 1 and 0 <= int(y) < 2
return {"id": row["id"], "x": x, "y": y}
loader = DataLoader(Records(rows), batch_size=3, shuffle=False)
batch = next(iter(loader))
assert batch["x"].shape[0] == len(batch["id"])验收标准:随机抽样与整批检查都通过,且 train/valid 实体交集为空。
04 · MODEL MODULES
模块化模型:用 nn.Module 组织参数和 forward
一个可维护的模型要能声明子模块、注册参数、描述 forward、检查输出形状并在需要时暴露中间结果。本节用 Sequential 和自定义模块对照,说明什么时候应该拆类、怎样写 shape probe 和 hook。
模块设计的职责边界:让 forward 可以被替换和测试
一个好的 nn.Module 通常有清晰的输入输出合同、可枚举的子模块和尽量少的隐藏副作用。初始化阶段定义层、参数和固定 buffer;forward 只描述一次计算,不负责下载数据、改变全局配置或写文件。预处理可以作为独立模块或调用方责任,关键是边界稳定。这样你可以用随机 Tensor 测试形状,也可以在不改训练循环的情况下替换 MLP、CNN 或残差块。
参数注册和模块注册决定了优化器能否看到它们。把层放进普通 Python 列表,层虽然能被调用,却不会出现在 model.parameters() 和 state_dict() 中;用 ModuleList、ModuleDict 或 Sequential 才能建立注册关系。常量 Tensor 若需要随设备移动和保存,应使用 register_buffer;临时中间结果不要注册,否则 checkpoint 会膨胀。
共享权重是有意的设计,不是复制粘贴。两个位置调用同一个子模块时,它们会更新同一份参数;如果想要独立参数,就分别实例化。残差连接要求主分支和捷径分支 shape 可相加,通道变化时用投影层对齐。先写 shape 表,再写代码,能把“维度不匹配”从运行时错误变成设计时决策。
| 对象 | 是否注册 | 是否进 state_dict | 适用场景 |
|---|---|---|---|
| nn.Parameter | 是 | 是 | 可学习权重 |
| ModuleList | 子模块是 | 是 | 循环堆叠层 |
| ModuleDict | 子模块是 | 是 | 按名称选择分支 |
| register_buffer | 否(不可学习) | 是 | 均值、位置编码常量 |
| 普通 list | 否 | 否 | 仅临时 Python 对象 |
| forward 局部 Tensor | 否 | 否 | 中间激活 |
import torch
from torch import nn
class ResidualBlock(nn.Module):
def __init__(self, width):
super().__init__()
self.main = nn.Sequential(nn.Linear(width, width), nn.ReLU(), nn.Linear(width, width))
self.norm = nn.LayerNorm(width)
def forward(self, x):
if x.ndim != 2 or x.shape[-1] != self.norm.normalized_shape[0]:
raise ValueError(f"期望 (B,{self.norm.normalized_shape[0]})")
return self.norm(x + self.main(x))
block = ResidualBlock(16)
print(tuple(block(torch.randn(4, 16)).shape))
print([name for name, _ in block.named_parameters()])测试模块时至少覆盖正常 shape、错误 shape、空 batch、不同 device 和 train/eval 模式。对于 Dropout、BatchNorm 等有状态层,固定 seed 后分别调用 train 与 eval,确认输出差异符合预期。模块测试通过后,才把它接入长训练;否则训练曲线同时混入结构错误和优化问题,排查成本会迅速上升。
构造一个包含三个 Linear 的模型,先把它们放入普通列表,再改用 ModuleList。分别打印参数量和 state_dict 键,说明为什么前者不会被 optimizer 更新。
参考结论:普通列表中的 Linear 不会递归注册,参数量可能为零且 state_dict 没有对应键;ModuleList 会注册每个子模块。优化器只接收 model.parameters(),因此必须使用注册容器。
完整实现路径:所有可训练层放进 Module/ModuleList;forward 只描述数据流,并用一次 shape probe 检查参数注册。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
import torch
from torch import nn
class MLP(nn.Module):
def __init__(self, dims=(4, 8, 3)):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(a, b) for a, b in zip(dims, dims[1:])
])
def forward(self, x):
for layer in self.layers[:-1]: x = torch.relu(layer(x))
return self.layers[-1](x)
model = MLP()
out = model(torch.randn(2, 4))
assert out.shape == (2, 3)
assert len(dict(model.named_parameters())) == 4验收标准:named_parameters、state_dict 和输出形状与接口合同一致。
若结果不对:先比较输入和首层 in_features,再检查普通 list、设备和 train/eval 模式。
6.4.1 nn.Module 与参数注册:让框架知道哪些值要学习
PyTorch 模型通常继承 nn.Module。在 __init__ 中创建并赋给属性的 nn.Linear、nn.Conv2d 等子模块会被自动注册,model.parameters() 能找到它们,state_dict() 能保存它们,model.to(device) 也会递归搬运它们。这个注册机制是模块化训练的基础,不只是形式上的父类继承。
需要学习但不是一个完整层的 Tensor,应使用 nn.Parameter;不需要学习、但要随设备和检查点移动的常量,可以用 register_buffer。普通 Python 列表里的 Tensor 和层不会自动注册,除非使用 nn.ParameterList 或 nn.ModuleList。这种区别很容易导致“代码运行、loss 下降,但某个权重没有更新”的隐蔽错误。
forward 描述数据流,不应该在里面临时创建每次都要训练的层。若把 nn.Linear 写进 forward,每次调用都可能产生新参数,优化器和 state_dict 都无法稳定管理。初始化阶段完成结构,forward 只使用已经声明的模块和输入。模型的输入输出 shape、dtype、设备和训练/评估差异要写成接口。
| 对象 | 是否训练 | 是否进 state_dict | 典型用途 |
|---|---|---|---|
| nn.Parameter | 是 | 是 | 可学习权重、温度 |
| 子模块 | 内部参数决定 | 递归保存 | Linear、Conv、Norm |
| register_buffer | 否 | 是 | 均值、位置表、运行统计 |
| 普通 Tensor 属性 | 否 | 否 | 临时常量或缓存 |
| 普通 Python 列表 | 不自动 | 不自动 | 仅适合无状态配置 |
import torch
from torch import nn
class TemperatureHead(nn.Module):
def __init__(self, features, classes):
super().__init__()
self.classifier = nn.Linear(features, classes)
self.temperature = nn.Parameter(torch.ones(()))
self.register_buffer("running_scale", torch.tensor(1.0))
def forward(self, features):
logits = self.classifier(features)
return logits / self.temperature.clamp_min(1e-4) * self.running_scale
model = TemperatureHead(4, 3)
print("parameters:", [name for name, _ in model.named_parameters()])
print("buffers:", [name for name, _ in model.named_buffers()])
assert "classifier.weight" in model.state_dict()
assert "temperature" in model.state_dict()
assert "running_scale" in model.state_dict()运行这段代码时,temperature 会出现在参数清单中,running_scale 出现在 buffer 清单中,二者都能随 state_dict 保存,但只有前者接受梯度更新。这样的区分在 BatchNorm、位置编码和校准层中都很常见。写模型时先决定状态的生命周期,再选择属性、Parameter 或 buffer。
模块命名会进入错误报告
模块属性名会成为 state_dict 的键,例如 encoder.0.weight 或 head.bias。稳定、可读的命名有助于比较版本、冻结某一部分和定位异常。重命名会影响严格加载旧 checkpoint,结构升级时应写迁移脚本或明确版本,而不是在加载时静默忽略全部 missing/unexpected keys。
如果模型包含多个同类层,使用 ModuleList 而不是普通列表;如果结构是直线型,Sequential 能减少样板代码;如果有分支、共享权重或需要多次使用同一个模块,则写清楚自定义 forward。选择哪种写法不是风格竞赛,取决于参数注册、可读性和未来修改的需要。
模块接口的异常策略与组合方式
模块接口还应写明异常策略和训练状态的边界。输入 shape 错误时尽早抛出 ValueError,标签越界时提供样本 ID,外部资源失败时交给调用方决定重试;不要让 forward 静默修正所有错误。对于可选分支,可以在构造函数中保存配置,并把配置写入 repr 或 manifest,避免只看 state_dict 却不知道模型为何有两个输出头。一个模块完成后,尝试用假的 batch、真实 batch 和空 batch 各调用一次,再用 named_modules() 检查注册树,这四个动作足以发现大量初期设计问题。
当模型进入组合阶段,父模块应负责连接子模块,子模块只负责自己的局部合同。父模块可以把 encoder、pooler、head 按名字注册;评估脚本只依赖父模块的统一输出。若需要返回中间特征,用明确的字典键或配置开关,不要让调用方依赖某一层的下标。稳定的命名会直接影响 checkpoint 迁移、错误日志和后续可视化。
为一个两层 MLP 写出输入、输出、参数、训练/评估差异和三类异常的接口说明,再列出父模块调用它时必须满足的条件。
参考结论:说明应包含输入 dtype/shape、输出 logits shape、参数是否注册、Dropout/BatchNorm 的模式差异,以及 shape、dtype、标签或设备异常的处理方式。父模块只依赖公开合同,不应读取子模块的私有中间变量。
完整实现路径:所有可训练层放进 Module/ModuleList;forward 只描述数据流,并用一次 shape probe 检查参数注册。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
import torch
from torch import nn
class MLP(nn.Module):
def __init__(self, dims=(4, 8, 3)):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(a, b) for a, b in zip(dims, dims[1:])
])
def forward(self, x):
for layer in self.layers[:-1]: x = torch.relu(layer(x))
return self.layers[-1](x)
model = MLP()
out = model(torch.randn(2, 4))
assert out.shape == (2, 3)
assert len(dict(model.named_parameters())) == 4验收标准:named_parameters、state_dict 和输出形状与接口合同一致。
若结果不对:先比较输入和首层 in_features,再检查普通 list、设备和 train/eval 模式。
比较把两个 Linear 放进普通列表和 nn.ModuleList 的结果。打印 named_parameters(),说明为什么前一种写法不能交给优化器。
参考结论:普通列表不会递归注册子模块,参数清单可能为空或缺少这些层;使用 nn.ModuleList([nn.Linear(...), ...]) 后,参数会出现在 state_dict 和 optimizer 参数中。
完整实现路径:所有可训练层放进 Module/ModuleList;forward 只描述数据流,并用一次 shape probe 检查参数注册。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
import torch
from torch import nn
class MLP(nn.Module):
def __init__(self, dims=(4, 8, 3)):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(a, b) for a, b in zip(dims, dims[1:])
])
def forward(self, x):
for layer in self.layers[:-1]: x = torch.relu(layer(x))
return self.layers[-1](x)
model = MLP()
out = model(torch.randn(2, 4))
assert out.shape == (2, 3)
assert len(dict(model.named_parameters())) == 4验收标准:named_parameters、state_dict 和输出形状与接口合同一致。
若结果不对:先比较输入和首层 in_features,再检查普通 list、设备和 train/eval 模式。
6.4.2 Sequential 与自定义 forward:把直线、分支和共享写明白
nn.Sequential 适合输入经过一层接一层的结构,例如 Linear→ReLU→Linear。它的优点是短、容易打印和替换;局限是每层只有一个主要输入输出,不能自然表达残差、跳连、多个输出或条件分支。不要为了少写几行而把所有模型塞进 Sequential,结构含义比代码长度重要。
自定义 forward 可以显式命名中间变量,检查输入,合并多个分支,并返回训练所需的连续结果。若模型既要返回 logits 又要返回中间特征,可以返回字典或数据类,但要让训练循环知道哪个字段进入损失。推理代码也要固定接口,避免调用者依赖某个临时的元组位置。
残差连接要求分支 shape 对齐;共享权重要求同一个子模块被调用多次,而不是创建多个看似相同的副本。条件分支要注意不同路径的参数是否都能收到梯度。自定义模块中,避免把 Python 列表、标量和 Tensor 混在一起做隐式广播,必要时使用断言说明每个分支的轴。
| 结构需求 | 推荐写法 | 可读性优势 | 测试重点 |
|---|---|---|---|
| 纯直线 | nn.Sequential | 层顺序一目了然 | 首尾 shape |
| 多个分支 | 自定义 Module/forward | 命名每条路径 | 合并前后 shape |
| 跳连残差 | 自定义 forward | 显式 x+f(x) | 分支 dtype/device |
| 共享权重 | 同一子模块多次调用 | 参数只有一份 | 梯度是否累加合理 |
| 多任务输出 | 字典/数据类 | 字段语义清楚 | 每个 loss 的分母 |
import torch
from torch import nn
class ResidualMLP(nn.Module):
def __init__(self, features, hidden, classes):
super().__init__()
self.input_layer = nn.Linear(features, hidden)
self.block = nn.Sequential(nn.ReLU(), nn.Linear(hidden, hidden))
self.output_layer = nn.Linear(hidden, classes)
def forward(self, features, return_hidden=False):
if features.ndim != 2 or features.shape[-1] != self.input_layer.in_features:
raise ValueError("输入必须是 (B, features)")
hidden = self.input_layer(features)
hidden = hidden + self.block(hidden)
logits = self.output_layer(torch.relu(hidden))
if return_hidden:
return {"logits": logits, "hidden": hidden}
return logits
model = ResidualMLP(4, 8, 3)
result = model(torch.randn(5, 4), return_hidden=True)
print(result["hidden"].shape, result["logits"].shape)
assert result["logits"].shape == (5, 3)这个模块把隐藏表示命名为 hidden,并在可选字段中返回它,便于可视化或诊断。训练默认只拿 logits,不会因为调试需要而改变损失接口。残差的两条分支都为 (B,8),如果以后改隐藏宽度,输入投影和输出层的契约会在探针中立即暴露。
输出接口要对训练和推理负责
分类模型通常在 forward 返回 logits,训练循环交给 CrossEntropyLoss;推理函数再根据需要计算概率、类别和置信度。不要在 forward 中默认做 argmax,因为它会把连续输出变成离散结果,影响 loss、阈值选择和错误分析。多任务模型则要为每个输出写出形状、损失和权重,不要把一个无单位的总和当作自然正确。
模型可以接受配置对象或显式参数,但要把最终结构写入 manifest。隐藏宽度、层数、激活、归一化、dropout 和输出类别数都属于模型版本的一部分;只保存类名而不保存配置,往往无法准确重建。
用一个 4→8→3 的 Sequential 作为基线,再写一个包含隐藏残差的自定义模块。列出两者必须保持一致的输入、输出和损失接口。
参考结论:二者都应接受 (B,4) float 输入,返回 (B,3) logits,训练时使用同一标签和 CrossEntropyLoss。残差分支合并前必须都是 (B,8) ,比较时固定数据、seed 和训练预算。
完整实现路径:所有可训练层放进 Module/ModuleList;forward 只描述数据流,并用一次 shape probe 检查参数注册。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
import torch
from torch import nn
class MLP(nn.Module):
def __init__(self, dims=(4, 8, 3)):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(a, b) for a, b in zip(dims, dims[1:])
])
def forward(self, x):
for layer in self.layers[:-1]: x = torch.relu(layer(x))
return self.layers[-1](x)
model = MLP()
out = model(torch.randn(2, 4))
assert out.shape == (2, 3)
assert len(dict(model.named_parameters())) == 4验收标准:named_parameters、state_dict 和输出形状与接口合同一致。
若结果不对:先比较输入和首层 in_features,再检查普通 list、设备和 train/eval 模式。
6.4.3 shape probe 与 hook:让中间层也能被检查
复杂模型最容易在中间层出错,因此要为关键边界写 shape probe。用一个小 batch 运行 forward,打印或收集每个子模块的输入输出形状、dtype、device、均值、标准差和非有限计数。probe 不需要参与训练,可以在正式循环前运行一次;它是模型的“体检报告”,不是额外的业务逻辑。
forward hook 可以在不修改模块源码的情况下读取中间输出,适合快速定位激活爆炸、ReLU 长期为零或某层没有被调用。hook 会增加运行开销,也可能保存带图 Tensor;记录时要 detach(),使用完毕后移除句柄。不要把 hook 永久留在生产推理路径,避免内存和性能不可控。
模块摘要、参数量和 state_dict 键可以组成模型卡。摘要应说明每层的输入输出、参数数量、是否共享权重、训练/评估差异和预计资源。单看 print(model) 不会告诉你真实 batch shape 或激活大小,所以仍需要固定输入的探针。
| 字段 | 示例 | 用途 | 异常提示 |
|---|---|---|---|
| module name | encoder.0 | 定位层 | 名称不稳定 |
| input/output shape | (8,4)→(8,16) | 验证轴契约 | batch/feature 颠倒 |
| dtype/device | float32/cuda | 检查兼容性 | 混用设备或精度 |
| mean/std | 0.01/0.83 | 观察尺度 | 激活爆炸/塌缩 |
| finite count | 全部有限 | 定位 NaN/Inf | 上游输入或参数坏 |
| parameters | 272 | 估算模型大小 | 层未注册 |
import torch
from torch import nn
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 3))
records = []
def make_hook(name):
def hook(module, inputs, output):
tensor = output if isinstance(output, torch.Tensor) else output[0]
records.append({
"name": name,
"shape": tuple(tensor.shape),
"dtype": str(tensor.dtype),
"device": str(tensor.device),
"mean": tensor.detach().float().mean().item(),
"finite": bool(torch.isfinite(tensor).all()),
})
return hook
handles = [module.register_forward_hook(make_hook(name)) for name, module in model.named_modules() if name]
logits = model(torch.randn(6, 4))
for handle in handles:
handle.remove()
print(records)
assert records[-1]["shape"] == (6, 3)
assert torch.isfinite(logits).all()hook 的输出只保留 detached 标量和形状,避免把整张激活图放进列表导致内存增长。若某层没有记录,可能是 forward 没有调用它、条件分支没有走到,或者 hook 注册在错误的对象上。调试结束后一定移除 handle,并再次运行一次确认模型输出没有被探针改变。
给一个三层 MLP 添加 shape probe,报告每层输出 shape、均值、标准差和 finite 状态。再统计可训练参数量,并说明你会如何检查所有层都被注册。
参考结论:使用 forward hook 或手动日志,记录 detached 输出的统计;参数量来自 sum(p.numel() for p in model.parameters() if p.requires_grad) 。检查 named_parameters 、state_dict 键和 probe 层名是否覆盖预期结构。
完整实现路径:所有可训练层放进 Module/ModuleList;forward 只描述数据流,并用一次 shape probe 检查参数注册。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 5:先复制运行,再替换为自己的数据
import torch
from torch import nn
class MLP(nn.Module):
def __init__(self, dims=(4, 8, 3)):
super().__init__()
self.layers = nn.ModuleList([
nn.Linear(a, b) for a, b in zip(dims, dims[1:])
])
def forward(self, x):
for layer in self.layers[:-1]: x = torch.relu(layer(x))
return self.layers[-1](x)
model = MLP()
out = model(torch.randn(2, 4))
assert out.shape == (2, 3)
assert len(dict(model.named_parameters())) == 4验收标准:named_parameters、state_dict 和输出形状与接口合同一致。
若结果不对:先比较输入和首层 in_features,再检查普通 list、设备和 train/eval 模式。
| 能力 | 最低证据 | 下一步用途 |
|---|---|---|
| 注册状态 | 能区分 Parameter、buffer 和普通属性 | 保存加载与冻结 |
| 模块组织 | 能选择 Sequential 或自定义 forward | 构建 CNN/Transformer 子模块 |
| 接口 | 能写输入输出 shape、dtype、模式 | 接入训练循环 |
| 探针 | 能记录中间层并移除 hook | 定位 NaN/shape/激活问题 |
| 模型卡 | 能保存结构、参数和版本 | checkpoint 与推理交付 |
参数注册的三个验收动作
第一步打印 named_parameters(),确认预期层和 Parameter 都出现,并统计总量;第二步把模型移到另一个 device,确认参数与 buffer 一起移动;第三步保存 state_dict 再读取,确认键和形状与模型卡一致。三个动作分别保护训练、设备和交付边界。
如果层放在普通列表中,optimizer 不会发现它;如果把常量直接设成 Tensor 属性,切换设备时它可能还留在 CPU;如果在 forward 中创建层,每次调用都可能产生新的参数。把这三种错误故意写进小实验,再观察 named_parameters、buffers 和 state_dict 的差异,能建立非常牢固的直觉。
共享模块也要检查调用次数和梯度。一个同样的 Linear 被两个分支使用,参数只有一份,反向时来自两条路径的梯度会相加;如果误写成两个 Linear,模型容量和 state_dict 键都会改变。报告中说明共享是有意设计还是偶然复制,避免后续消融无法解释。
| 动作 | 观察 | 通过条件 | 失败含义 |
|---|---|---|---|
| named_parameters | 键、shape、requires_grad | 覆盖预期层 | 层未注册/被冻结 |
| named_buffers | 均值、运行统计 | 随设备移动 | 常量仍在旧设备 |
| state_dict | 键与 Tensor | 可保存/加载 | 结构版本不匹配 |
| parameters 总量 | numel 总和 | 与模型卡一致 | 重复或漏层 |
| 更新前后 | 参数差值 | 指定层变化 | optimizer 未接入 |
模型模块写好后,不要立刻加复杂的训练技巧。先用一个固定 batch 运行 forward、loss 和 backward,检查参数是否有梯度、step 后是否变化,再把模块接入完整 DataLoader。这样模块问题和训练循环问题不会同时出现。
05 · TRAINING LOOP
训练与评估:把每个 step 写成可观察的流程
训练循环连接数据、模型、损失、autograd 和优化器。本节从一个 batch 的完整顺序开始,补上指标聚合、日志曲线、梯度检查、检查点与早停,让“训练完成”不再只是终端里的一句 print。
训练循环的附加边界:预算、梯度和评估口径
训练循环最容易因为“顺手加一个功能”而失去可读性。建议把每个 epoch 的职责拆成读取 batch、前向、损失、反向、更新、日志和评估,扩展功能时逐项加入。学习率调度器要明确是在每个 batch 还是每个 epoch 后调用;ReduceLROnPlateau 通常依赖验证指标,不能像按 step 调度器一样盲目放在训练循环尾部。
梯度累积时,先把 loss 除以累积步数,再在达到步数或遇到 epoch 末尾时更新。梯度裁剪应发生在 backward 之后、optimizer.step 之前,并记录被裁剪的次数;如果每一步都被大幅裁剪,问题可能来自学习率、输入尺度或损失实现。混合精度需要 autocast 和 GradScaler 配合,初次实现应与全精度基线并跑,比较有限性、速度、峰值显存和最终指标。
评估阶段要固定数据顺序、关闭随机增强、切换 eval() 并关闭梯度。若指标来自不同长度样本,分母必须写清楚;token 级损失按有效 token 聚合,序列级准确率按序列聚合,二者不能混为一个“平均 loss”。分布式或多卡时还要汇总所有进程的计数,不能只报告主进程看到的局部 batch。
| 阶段 | 只加入什么 | 新增证据 | 通过后再做什么 |
|---|---|---|---|
| baseline | 全精度/单设备 | 小批次可过拟合 | 完整数据训练 |
| logging | history 与耗时 | 曲线可重绘 | checkpoint |
| scheduler | 学习率策略 | lr 曲线正确 | 早停 |
| accumulation | 多 micro-batch | 有效 batch 与基线接近 | AMP/clip |
| AMP | 混合精度 | 无 NaN、速度/显存对照 | 多卡 |
def train_with_accumulation(model, loader, optimizer, criterion, device, accum_steps=4):
model.train()
optimizer.zero_grad(set_to_none=True)
running = 0.0
for step, (features, targets) in enumerate(loader, start=1):
logits = model(features.to(device))
loss = criterion(logits, targets.to(device)) / accum_steps
loss.backward()
running += float(loss.detach())
if step % accum_steps == 0 or step == len(loader):
optimizer.step()
optimizer.zero_grad(set_to_none=True)
return running这个片段只展示梯度累积的边界,正式代码还应按真实样本数聚合 loss,并处理最后不足一个累积周期的 batch。把功能拆成小函数后,单元测试可以模拟两批数据,比较累积与大 batch 的参数更新方向。任何优化都应有回退开关,出现异常时能迅速回到已验证的 baseline。
给训练循环依次加入 scheduler、梯度裁剪和 AMP,写出每一步要保存的对照字段,并说明哪一个字段能证明功能真正生效。
参考结论:scheduler 记录每步 lr,clip 记录梯度范数与裁剪次数,AMP 记录是否溢出、吞吐和峰值显存;同时固定数据、seed、预算和模型,比较 loss 曲线与最终验证指标。
完整实现路径:把 train_one_epoch 与 evaluate 分开,严格执行 zero_grad→forward→loss→backward→step,并按样本数聚合指标。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
def run_epoch(model, loader, loss_fn, optimizer=None):
training = optimizer is not None
model.train(training)
total_loss = total_n = 0
with torch.set_grad_enabled(training):
for x, y in loader:
if training: optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = loss_fn(logits, y)
if training:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item() * len(y); total_n += len(y)
return total_loss / total_n验收标准:评估阶段参数不变化,恢复 checkpoint 后下一步学习率和优化器状态连续。
若结果不对:记录 batch ID、loss、学习率、梯度范数和 mode;从首次异常 step 复现。
6.5.1 train/eval 的完整步骤:一次更新要按固定顺序执行
训练循环的基本顺序是:切到 train 模式,取得一个 batch,搬到目标设备,清理旧梯度,前向计算,计算 loss,反向传播,必要时裁剪梯度,调用 optimizer.step,然后记录 detached 的统计。顺序不是格式偏好;如果在 backward 前 step,更新使用的不是当前 loss 的梯度;如果忘记清零,梯度会无意累加;如果把 argmax 放进训练 forward,损失会失去连续信号。
评估循环职责不同:切到 eval 模式,使用 no_grad 或 inference_mode,按同一损失和指标聚合,但不更新参数。eval() 会改变 Dropout、BatchNorm 等模块的行为,no_grad() 只关闭梯度记录,二者不能互相替代。评估结束后若还要继续训练,必须再次调用 model.train(),否则后续训练会悄悄处于错误模式。
训练函数和评估函数可以共享一个“遍历 batch”的骨架,但要通过参数明确是否反向和更新。把数据搬运、forward、loss、指标和日志都塞进一条长循环,初学时看似方便,后续很难测试。建议把 train_one_epoch、evaluate、move_batch 和 compute_metrics 分开,每个函数有清晰输入输出。
训练和评估的模式、梯度和指标分母都要在日志中可见。
| 步骤 | 训练 | 评估 | 容易漏掉什么 |
|---|---|---|---|
| 模式 | model.train() | model.eval() | Dropout/Norm 行为 |
| 梯度 | 需要记录与 backward | no_grad/inference_mode | 验证图占内存 |
| 更新 | zero_grad→backward→step | 不更新参数 | 梯度累积 |
| 指标 | 记录训练统计 | 固定口径聚合 | batch 平均偏差 |
| 输出 | 可保存梯度/学习率 | 可保存预测/错误 | 模式未恢复 |
import torch
def train_one_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss = 0.0
total_items = 0
for features, targets in loader:
features = features.to(device)
targets = targets.to(device)
optimizer.zero_grad(set_to_none=True)
logits = model(features)
loss = criterion(logits, targets)
if loss.ndim != 0:
raise ValueError("训练 loss 必须是标量")
loss.backward()
optimizer.step()
count = targets.shape[0]
total_loss += loss.detach().item() * count
total_items += count
return total_loss / max(total_items, 1)
@torch.no_grad()
def evaluate(model, loader, criterion, device):
model.eval()
total_loss = 0.0
total_items = 0
total_correct = 0
for features, targets in loader:
logits = model(features.to(device))
targets = targets.to(device)
loss = criterion(logits, targets)
total_loss += loss.item() * targets.shape[0]
total_items += targets.shape[0]
total_correct += (logits.argmax(dim=1) == targets).sum().item()
return {"loss": total_loss / max(total_items, 1), "accuracy": total_correct / max(total_items, 1)}训练函数把每批标量 loss 乘以样本数后累加,最后再除以总样本数;这比直接平均 batch loss 更能处理最后一个小 batch。若 loss 已经是 sum 或 token-level mean,聚合方式要相应调整。评估函数用装饰器关闭梯度,但仍通过 model.eval() 改变模块行为。
更新顺序的可读写法
把 zero_grad、backward 和 step 连续写在一起,方便读者看到一次更新的完整生命周期。若加入梯度累积,把 step 放到每 N 个 micro-batch 后,并把 loss 除以 N;若加入 AMP,则把 autocast、GradScaler 和 unscale/clip 的顺序写在单独函数中。不要把所有优化技巧一次塞入第一个训练循环。
训练结束后把模型恢复为 eval 或由调用者明确决定下一状态。函数最好返回 history 字典,而不是只打印字符串,这样上层可以画曲线、选择最佳 checkpoint 和写报告。返回值中的 loss、正确数、样本数应保留原始累计量,方便复核分母。
把一个线性分类器接到 DataLoader,写出 train_one_epoch 的 zero_grad、forward、loss、backward、step 顺序,再写 evaluate 的 eval/no_grad 边界。
参考结论:训练顺序是 train→搬设备→zero_grad→forward→loss→backward→step;评估是 eval→no_grad→forward→loss/指标,不调用 backward 和 step。epoch 结束后用总样本数聚合,而不是简单平均 batch。
# 对应本节练习 2:先复制运行,再替换为自己的数据
def run_epoch(model, loader, loss_fn, optimizer=None):
training = optimizer is not None
model.train(training)
total_loss = total_n = 0
with torch.set_grad_enabled(training):
for x, y in loader:
if training: optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = loss_fn(logits, y)
if training:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item() * len(y); total_n += len(y)
return total_loss / total_n验收标准:评估阶段参数不变化,恢复 checkpoint 后下一步学习率和优化器状态连续。
若结果不对:记录 batch ID、loss、学习率、梯度范数和 mode;从首次异常 step 复现。
6.5.2 指标、日志与曲线:让训练过程可观察
日志不是把每个数字都打印出来,而是为决策保留足够证据。每个 epoch 至少记录 train/valid loss、主指标、学习率、样本数、耗时和当前模式;发现不稳定时再加入梯度范数、参数范数、GPU 显存、数据加载时间和非有限计数。日志字段要有稳定名称和单位,避免一个脚本写 acc,另一个写 accuracy 却表示不同分母。
指标聚合要站在样本层面。分类累计正确数和总数,回归累计绝对误差与样本数,token 任务累计有效 token 的损失与数量;不要把每个 batch 的百分比直接平均。对于多分类,至少保留混淆矩阵或每类支持数;对于不平衡任务,报告宏平均与少数类召回,避免多数类把结果掩盖。
曲线的横轴可以是 epoch、step、样本数或 token 数,但必须明确。不同 batch size 下,同样的 epoch 代表不同更新次数;比较性能时还要记录 wall-clock。训练日志与配置、数据版本、seed、模型结构和 checkpoint 路径绑定,未来才能回答“这条曲线对应哪一次运行”。
| 字段 | 聚合方式 | 用途 | 常见误读 |
|---|---|---|---|
| train_loss | 有效元素加权平均 | 优化过程 | 把 batch 平均当样本平均 |
| valid_loss | 固定验证集分母 | 选择 checkpoint | 验证集每轮变化 |
| accuracy | correct / total | 分类直觉 | 忽略类别不平衡 |
| macro_f1 | 先按类算再平均 | 少数类护栏 | 与 micro 混淆 |
| lr | 记录当前 optimizer 值 | 解释曲线 | 日志滞后一个 step |
| samples_seen | 累计真实样本 | 公平比较预算 | 只用 epoch |
from dataclasses import dataclass, asdict
import json
@dataclass
class EpochLog:
epoch: int
samples_seen: int
train_loss: float
valid_loss: float
accuracy: float
learning_rate: float
seconds: float
history = []
history.append(EpochLog(1, 128, 0.83, 0.79, 0.71, 1e-3, 2.4))
history.append(EpochLog(2, 256, 0.55, 0.61, 0.80, 1e-3, 2.3))
with open("runs/history.json", "w", encoding="utf-8") as handle:
json.dump([asdict(row) for row in history], handle, ensure_ascii=False, indent=2)
best = min(history, key=lambda row: row.valid_loss)
print("best epoch:", best.epoch, "samples:", best.samples_seen)把 history 保存为 JSON 后,绘图脚本不必重新运行训练。绘图时给训练和验证曲线加图例、单位和数据版本,标出最佳 epoch 与早停位置。曲线不是装饰:验证 loss 先降后升、学习率突然变化、samples_seen 不连续,都会成为下一步排查的线索。
日志的三个层次
运行级日志回答“这次实验是什么”:配置、环境、数据、seed 和输出目录;epoch 级日志回答“训练怎样变化”:loss、指标、学习率和耗时;样本级日志回答“错在哪里”:ID、标签、预测和错误桶。三层分开保存,既不会让终端被海量输出淹没,也能在需要时定位一个错误样本。
日志字段应避免隐含状态。例如 best_metric 要说明越大越好还是越小越好,loss 要说明 reduction,accuracy 要说明是否按权重。写一份 schema 或数据类,能让多个实验脚本输出可比较的记录。
某验证集有两个 batch,第一个 32 个样本准确率 1.0,第二个 8 个样本准确率 0.5。分别算简单 batch 平均和按样本累计的准确率,说明哪个能反映整体。
参考结论:简单平均是 0.75;按样本累计为 (32+4)/(32+8)=0.90。验证指标应累计正确数和总样本数,不能让小 batch 与大 batch 权重相同。
# 对应本节练习 3:先复制运行,再替换为自己的数据
def run_epoch(model, loader, loss_fn, optimizer=None):
training = optimizer is not None
model.train(training)
total_loss = total_n = 0
with torch.set_grad_enabled(training):
for x, y in loader:
if training: optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = loss_fn(logits, y)
if training:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item() * len(y); total_n += len(y)
return total_loss / total_n验收标准:评估阶段参数不变化,恢复 checkpoint 后下一步学习率和优化器状态连续。
若结果不对:记录 batch ID、loss、学习率、梯度范数和 mode;从首次异常 step 复现。
6.5.3 检查点与早停:保存“最好的一次”而不是最后一次
检查点用于两件事:从中断处继续训练,以及保存验证表现最好的模型用于推理。只保存 model.state_dict() 足够做一次推理,却不足以准确恢复训练,因为 optimizer 的动量、学习率调度器、epoch、最佳指标、随机状态和数据版本也会影响后续轨迹。训练型 checkpoint 应把这些状态放在一个字典里。
早停需要三个明确字段:监控指标、方向和 patience。每次验证后先比较是否改善,再更新 best checkpoint;连续若干轮没有改善才停止。若主指标越大越好,初始化 best 为负无穷;若 loss 越小越好,初始化为正无穷。不要在测试集上选择最佳 epoch,测试集只用于最终一次验收。
加载 checkpoint 前先重建相同结构,再调用 load_state_dict;严格加载失败时阅读 missing/unexpected keys,而不是立刻设置 strict=False 隐藏问题。恢复后用固定的金样本比较 logits、学习率和下一步 loss,确认模型、优化器和数据预处理都接回了正确版本。
| 字段 | 推理是否必需 | 继续训练是否必需 | 说明 |
|---|---|---|---|
| model_state | 是 | 是 | 参数与 buffer |
| optimizer_state | 否 | 是 | momentum/Adam 状态 |
| scheduler_state | 否 | 视调度器 | 学习率时间表 |
| epoch/step | 记录即可 | 是 | 恢复预算与日志 |
| best_metric | 报告有用 | 是 | 早停比较基线 |
| config/data_version | 是 | 是 | 重建接口 |
| random_state | 否 | 建议 | 近似复现采样 |
from pathlib import Path
import torch
def save_checkpoint(path, model, optimizer, epoch, best_metric, config):
payload = {
"model": model.state_dict(),
"optimizer": optimizer.state_dict(),
"epoch": epoch,
"best_metric": best_metric,
"config": config,
"torch_version": torch.__version__,
}
Path(path).parent.mkdir(parents=True, exist_ok=True)
torch.save(payload, path)
def load_checkpoint(path, model, optimizer=None, device="cpu"):
payload = torch.load(path, map_location=device, weights_only=False)
model.load_state_dict(payload["model"])
if optimizer is not None and "optimizer" in payload:
optimizer.load_state_dict(payload["optimizer"])
return payload["epoch"], payload["best_metric"], payload["config"]保存路径中最好包含 run_id 和 checkpoint 类型,例如 runs/mnist-cnn-s7/best.pt、last.pt;不要用一个固定文件名覆盖所有实验。保存前先写临时文件,再原子替换,可以减少训练中断造成的半文件。大文件的哈希、大小和保存时间也应进入 manifest。
最佳模型与最后模型的区别
best checkpoint 对应监控指标最优的 epoch,适合最终推理;last checkpoint 对应最近一次训练状态,适合崩溃后继续。两者可以是同一个文件,也可以分别保存。报告中明确使用哪个文件产生测试结果,避免训练继续后 best 被覆盖。
早停不是自动证明泛化,它只是根据一份验证曲线停止。若验证集很小或噪声大,patience、最小改善幅度和多 seed 都要记录。最终测试只在确定配置和 checkpoint 后运行一次,并保存预测与错误样例。
写出保存前后需要比较的四项证据,验证从 checkpoint 恢复后下一步训练没有从错误状态开始。
参考结论:至少比较固定金样本 logits、epoch/step、optimizer 学习率或状态摘要、恢复后的下一批 loss/梯度趋势;同时确认配置、数据版本和 train/eval 模式一致。
# 对应本节练习 4:先复制运行,再替换为自己的数据
def run_epoch(model, loader, loss_fn, optimizer=None):
training = optimizer is not None
model.train(training)
total_loss = total_n = 0
with torch.set_grad_enabled(training):
for x, y in loader:
if training: optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = loss_fn(logits, y)
if training:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item() * len(y); total_n += len(y)
return total_loss / total_n验收标准:评估阶段参数不变化,恢复 checkpoint 后下一步学习率和优化器状态连续。
若结果不对:记录 batch ID、loss、学习率、梯度范数和 mode;从首次异常 step 复现。
| 能力 | 最低证据 | 下一步应用 |
|---|---|---|
| 训练循环 | 能按顺序完成一个 epoch | MNIST 训练 |
| 评估模式 | 能区分 eval 与 no_grad | 错误分析/推理 |
| 指标聚合 | 能按有效分母记录 | 多类指标比较 |
| 日志 | 能保存可重绘 history | 调参和报告 |
| 检查点 | 能保存并恢复训练状态 | 长训练与发布 |
训练循环的最小状态机
可以把一个 epoch 看成状态机:开始时读取配置和模式,循环中依次进入 batch、device、forward、loss、backward、clip、step、log,结束时切换 eval 并聚合验证。每个状态只做一件事,输入和输出清楚,出现异常时可以知道停在哪个状态。把状态机画出来比把代码压成一行更容易维护。
梯度累积时,micro-batch 的 loss 通常除以累积步数,只有达到步数才调用 step;否则有效学习率会随着累积次数变大。混合精度时,autocast、scaler.scale(loss).backward、unscale、clip 和 scaler.step 的顺序有严格约束。初学阶段先用全精度跑通,再把每一个新增状态单独加入并比较曲线。
评估指标不要在训练循环里临时改变阈值。阈值若需要选择,应在验证阶段单独搜索并固定下来,再在测试阶段一次使用。分类的 accuracy、macro F1、混淆矩阵和错误样例可以共享预测结果,避免多次运行模型得到不同的随机输出。
| 状态 | 输入 | 输出 | 日志字段 |
|---|---|---|---|
| epoch_start | 配置/模型模式 | train 状态 | epoch、seed |
| batch_ready | CPU batch | device batch | shape、样本数 |
| forward | features | logits | 有限性、耗时 |
| backward | 标量 loss | grad | 梯度范数 |
| step | grad/optimizer | 新参数 | lr、裁剪次数 |
| eval_end | 预测/目标 | 指标 | 分母、best |
import torch
def run_step(model, batch, optimizer, criterion, device, update=True):
features, targets = batch
features = features.to(device)
targets = targets.to(device)
if update:
model.train()
optimizer.zero_grad(set_to_none=True)
else:
model.eval()
with torch.set_grad_enabled(update):
logits = model(features)
loss = criterion(logits, targets)
if update:
loss.backward()
optimizer.step()
return {"loss": float(loss.detach()), "count": int(targets.shape[0]), "mode": "train" if update else "eval"}这个片段使用 torch.set_grad_enabled(update) 展示共享骨架,但正式评估仍建议使用 no_grad 或 inference_mode,尤其在长序列和大模型中更省内存。代码中引用了 torch 时,完整脚本需要先导入;练习的重点是状态边界和返回结构。
日志中保存 count 后,可以在上层按样本数重新聚合。若某一批失败,记录 batch 索引和样本 ID,重新运行它即可;不用从整个 epoch 重新猜测。训练循环越清楚,后续接入 scheduler、AMP、梯度累积或分布式训练越容易。
训练失败时沿日志回到第一个异常状态。
给一个 Linear 模型写函数,返回 loss、batch count、mode 和梯度范数;分别用 update=True/False 调用并确认评估不会修改参数。
参考结论:训练调用 zero_grad/backward/step,评估调用 eval 与关闭梯度;两次调用前后比较参数副本,只有训练调用应发生变化。返回值要保留样本数,便于上层正确聚合。
# 对应本节练习 5:先复制运行,再替换为自己的数据
def run_epoch(model, loader, loss_fn, optimizer=None):
training = optimizer is not None
model.train(training)
total_loss = total_n = 0
with torch.set_grad_enabled(training):
for x, y in loader:
if training: optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = loss_fn(logits, y)
if training:
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
total_loss += loss.item() * len(y); total_n += len(y)
return total_loss / total_n验收标准:评估阶段参数不变化,恢复 checkpoint 后下一步学习率和优化器状态连续。
若结果不对:记录 batch ID、loss、学习率、梯度范数和 mode;从首次异常 step 复现。
06 · MNIST LAB
MNIST 分类:把前面的接口连成一条真实实验
MNIST 足够小,适合第一次完整训练,又保留图像形状、卷积、类别指标和错误样例等真实问题。本节从 torchvision 数据集与归一化开始,完成 CNN 分类器,并用混淆矩阵和错误图像解释结果。
MNIST 实验的公平比较:先固定口径,再讨论准确率
MNIST 很小,反而适合把实验方法练完整。比较 Linear、MLP 和 CNN 时,三者应使用同一份训练/验证切分、相同的 seed 集合、相同的停止规则和相近的训练预算。若 CNN 使用了额外增强而 Linear 没有,结果就同时包含结构和数据变换的差异。报告中把“结构变量”“训练变量”“数据变量”分栏列出,任何一项改变都能被读者识别。
准确率只说明整体正确比例。十个类别均衡时它很直观,但仍要保留每类召回、混淆矩阵和错误置信度。把错误按真实标签、预测标签、置信度区间和图像边缘情况分桶,可以发现模型是“看不懂某一类”,还是“偶尔高置信度犯错”。错误样例展示时同时标记样本索引和 transform 版本,防止图片与日志错位。
可视化输入时要区分显示路径和计算路径。模型使用 Normalize 后的 Tensor,绘图需要逆归一化;预测概率来自 logits 的 Softmax,但训练损失仍应直接接 logits。不要把 argmax 结果传给 CrossEntropyLoss,也不要把 Softmax 后的概率当作未经校准的可信度证明。先验证一个样本的完整链路,再批量生成图表。
| 指标 | 定义 | 用途 | 需要附带 |
|---|---|---|---|
| accuracy | 正确数/总数 | 总体直觉 | 总样本数 |
| macro recall | 各类召回平均 | 类别公平 | 每类支持数 |
| confusion | 真实×预测计数 | 错误方向 | 轴约定 |
| error confidence | 错误样本最大概率 | 过度自信 | 分桶规则 |
| params/time | 参数与 wall-clock | 资源权衡 | 设备和 batch |
def report_row(name, metrics, model, seconds, device, seed):
params = sum(parameter.numel() for parameter in model.parameters())
required = {"accuracy", "macro_recall", "errors"}
missing = required - metrics.keys()
if missing:
raise ValueError(f"{name} 缺少 {sorted(missing)}")
return {
"model": name, "params": params, "seconds": round(seconds, 2),
"device": str(device), "seed": seed,
"accuracy": float(metrics["accuracy"]),
"macro_recall": float(metrics["macro_recall"]),
"error_count": len(metrics["errors"]),
}当结果表同时包含主指标和护栏指标时,模型选择会更诚实。一个模型即使准确率略高,也可能参数量、耗时或高置信度错误明显增加;这些信息会影响是否值得继续优化。最后一次测试只在模型和超参数冻结后运行,并把预测文件与报告版本绑定。
为三个模型写一份五行以内的实验协议,固定数据切分、seed、训练预算、主指标和三个护栏指标,并列出一次允许的结构改动。
参考结论:协议要让不同模型只在结构上有差异;主指标可为 accuracy 与 macro recall,护栏包括参数量、训练秒数、峰值显存和错误置信度。结构改动后重新跑同一协议,不能临时挑最好 seed。
完整实现路径:冻结数据切分、seed 和预算,以 Linear 为基线,再只改变一个变量比较 MLP/CNN。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
from torch import nn
class MnistCNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 10),
)
def forward(self, x): return self.net(x)
probe = MnistCNN()(torch.zeros(2, 1, 28, 28))
assert probe.shape == (2, 10)验收标准:报告总体指标、类别切片、混淆样例与 best/last checkpoint。
若结果不对:先可视化输入范围和标签 dtype,再核对卷积后的展平维度。
6.6.1 图像输入与预处理:从 28×28 像素读到模型
MNIST 是由手写数字灰度图组成的经典入门数据集。每张图通常是 28×28 像素、一个通道,标签是 0 到 9 的类别索引。对初学者来说,它同时包含了图像 shape、归一化、训练/测试切分、卷积和混淆矩阵等完整问题。使用 torchvision 下载时,要记录数据根目录、下载版本、是否使用 train split 和 transform 配置,避免一次运行下载到不同文件却没有说明。
ToTensor 通常会把图像转换成浮点 Tensor,并把像素范围从整数区间映射到较小范围;进一步的 Normalize 使用训练数据统计量或官方约定的均值方差。归一化要在训练、验证和测试使用同一套参数,不能根据测试集重新计算。图像输入给 Conv2d 时,shape 通常是 (B,C,H,W),不要把 PIL 图像、NumPy 的 HWC 数组和模型需要的 CHW Tensor 混在一起。
MNIST 官方训练集和测试集可以直接使用,但训练集内部仍需划出验证集来选择 epoch、学习率和模型版本。验证集只能从训练部分划出,测试集留到最后验收。若为了快速实验只用一个小子集,保存抽样 seed、索引和类别比例,并在报告中标注这是 smoke test 而不是最终结果。
标签是 0~9 的 long 索引,不需要在输入端做 one-hot。
| 对象 | 类型/形状 | 含义 | 常见错误 |
|---|---|---|---|
| 原始图像 | PIL / 28×28 | 灰度像素 | 误当 RGB 三通道 |
| 模型输入 | float32 (B,1,28,28) | 批次、通道、高、宽 | HWC/CHW 颠倒 |
| 标签 | long (B,) | 0~9 类别索引 | float 或 one-hot |
| 训练 transform | 转换+可选增强 | 只用于训练 | 验证也随机增强 |
| 评估 transform | 确定性转换 | 固定真实分布 | 重新估计 Normalize |
from pathlib import Path
import torch
from torchvision import datasets, transforms
root = Path("data")
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
train_set = datasets.MNIST(root, train=True, download=True, transform=transform)
test_set = datasets.MNIST(root, train=False, download=True, transform=transform)
image, label = train_set[0]
print("train/test:", len(train_set), len(test_set))
print("image:", image.shape, image.dtype, image.min().item(), image.max().item())
print("label:", label, type(label))
assert image.shape == (1, 28, 28)
assert image.dtype == torch.float32
assert 0 <= int(label) <= 9Normalize 后像素不再严格位于 0 到 1,而是以训练统计为中心的浮点范围;因此不要把归一化后的数值直接当作原始灰度显示。可视化时先做逆变换,或明确图像是“模型输入张量”。标签从 torchvision 返回的 Python 整数或 Tensor 形式可能随版本/包装改变,进入损失前统一成 long 并检查范围。
增强与切分的边界
手写数字的轻微平移、裁剪或旋转可能保持标签不变,但过强旋转会改变数字含义。第一次实验先不加增强,建立一个稳定基线;之后一次加入一种增强,比较验证错误类型。测试集不使用随机增强,这样不同模型看到的是同一验收分布。
为了快速迭代可以使用 Subset 或固定索引,但不能在每次运行随机重新抽取而不记录 seed。若划分验证集时用 random_split,保存生成的索引或 generator 状态,确保后续实验使用相同验证样本。
加载 MNIST 的一张样本,打印图像 shape、dtype、数值范围和标签。说明为什么输入要有通道轴,标签为什么使用 long 索引。
参考结论:单张图像应为 (1,28,28) ,DataLoader 后为 (B,1,28,28) ;通道轴是 Conv2d 的接口要求。多分类 CrossEntropyLoss 通常接 long 的 (B,) 类别索引和 (B,10) logits。
完整实现路径:冻结数据切分、seed 和预算,以 Linear 为基线,再只改变一个变量比较 MLP/CNN。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
from torch import nn
class MnistCNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 10),
)
def forward(self, x): return self.net(x)
probe = MnistCNN()(torch.zeros(2, 1, 28, 28))
assert probe.shape == (2, 10)验收标准:报告总体指标、类别切片、混淆样例与 best/last checkpoint。
若结果不对:先可视化输入范围和标签 dtype,再核对卷积后的展平维度。
6.6.2 CNN 分类器:按形状搭出第一张卷积网络
一个入门 CNN 可以由 Conv2d、ReLU、池化、展平和 Linear 组成。第一层接收 1 个灰度通道,输出若干特征图;池化或 stride 降低空间尺寸;最后把特征图展平送入 10 类输出头。设计前先用卷积公式算每层空间尺寸,再用 shape probe 验证,避免把全连接层输入维度凭感觉写死。
例如 Conv2d(1, 16, kernel_size=3, padding=1) 保持 28×28,MaxPool2d(2) 变成 14×14;第二个 Conv2d(16,32,3,padding=1) 后再池化变成 7×7。展平维度是 32×7×7。若改变池化次数、输入分辨率或 padding,Linear 的输入必须同步改变;更稳妥的方式是使用 AdaptiveAvgPool2d 让输出空间尺寸固定。
卷积层参数共享局部模式,但并不自动理解数字的整体结构。增加通道数可以保存更多方向,增加深度可以组合更大感受野,过度扩大则带来计算和过拟合。第一版模型要足够小,能在 CPU 上快速运行,方便调试和比较,而不是一开始追求复杂架构。
| 模块 | 输出形状 | 参数量(含偏置) | 检查点 |
|---|---|---|---|
| 输入 | (B,1,28,28) | 0 | 通道/范围 |
| Conv 1→16, 3×3 | (B,16,28,28) | 160 | padding=1 |
| MaxPool 2 | (B,16,14,14) | 0 | 尺寸减半 |
| Conv 16→32, 3×3 | (B,32,14,14) | 4640 | 通道匹配 |
| MaxPool 2 | (B,32,7,7) | 0 | 展平前 |
| Linear 1568→10 | (B,10) | 15690 | 类别轴 |
import torch
from torch import nn
class MnistCNN(nn.Module):
def __init__(self, classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(16, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
)
self.classifier = nn.Linear(32 * 7 * 7, classes)
def forward(self, images):
if images.ndim != 4 or images.shape[1:] != (1, 28, 28):
raise ValueError(f"期望 (B,1,28,28),得到 {tuple(images.shape)}")
features = self.features(images)
return self.classifier(features.flatten(1))
model = MnistCNN()
batch = torch.randn(4, 1, 28, 28)
logits = model(batch)
print(logits.shape)
assert logits.shape == (4, 10)
assert torch.isfinite(logits).all()flatten(1) 保留 batch 轴,从第 1 维开始合并通道和空间轴;写成 flatten() 会把 batch 也合并,损失函数将无法区分样本。输出层保留 10 个 logits,不在模型中 Softmax 或 argmax,交给 CrossEntropyLoss 和推理代码决定。
通道数和空间尺寸是一对取舍:更多通道保存方向,池化扩大单位像素的感受野。
先过拟合一小批,再扩大训练
选择 16 或 32 张 MNIST 图片,让 CNN 训练到几乎零训练 loss,是检查模型、标签、损失和优化器的快速方法。若小批次都过拟合不了,先查 shape、标签、学习率和 train/eval;不要急着增加层数。小批次测试通过后,再使用完整训练集和验证集,曲线才有解释。
参数量计算可以用 sum(p.numel() for p in model.parameters()),但显存还包括中间激活和 optimizer 状态。记录 batch size、输入 shape、参数量和每秒 batch,比较不同网络时才公平。
对于两个 Conv2d(都 padding=1、kernel=3)和两个 2×2 池化,输入 28×28,通道变化 1→16→32。写出展平前 shape 和 Linear 输入维度。
参考结论:卷积保持空间尺寸,第一次池化 28→14,第二次池化 14→7;展平前是 (B,32,7,7) ,Linear 输入为 32*7*7=1568 。
完整实现路径:冻结数据切分、seed 和预算,以 Linear 为基线,再只改变一个变量比较 MLP/CNN。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
from torch import nn
class MnistCNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 10),
)
def forward(self, x): return self.net(x)
probe = MnistCNN()(torch.zeros(2, 1, 28, 28))
assert probe.shape == (2, 10)验收标准:报告总体指标、类别切片、混淆样例与 best/last checkpoint。
若结果不对:先可视化输入范围和标签 dtype,再核对卷积后的展平维度。
6.6.3 错误样例与改进:准确率之外还要看数字怎么错
MNIST 的总体准确率容易让人忽略少数错误。评估时保存真实标签、预测标签、最大 logit、概率、样本 ID 或索引,并按真实/预测类别建立混淆矩阵。数字 4 被预测成 9,和数字 1 被预测成 7,可能需要不同的增强或模型容量;把错误图像画出来,能让你提出比“再训练几轮”更具体的假设。
混淆矩阵的行列约定要写在报告里。按真实类别归一化可以观察每类召回,按预测类别归一化可以观察预测精度;支持数很少的类别需要同时报告样本量。若在测试集上反复挑模型或阈值,就把测试集变成了验证集,应重新保留一个最终验收集或承认结论已被调参影响。
错误分析也要检查预处理和切分。若某一类错误集中在图像边缘,可能是裁剪或 padding;若所有数字在归一化后都偏暗,可能是均值方差或显示逆变换错误;若验证异常高,可能是重复样本或索引泄漏。每个改进都应回到相同的验证集,报告错误桶、主指标和资源代价。
| 分析项 | 记录内容 | 能回答什么 | 注意事项 |
|---|---|---|---|
| 混淆矩阵 | 真实×预测计数 | 哪些数字互相混淆 | 标明行列方向 |
| 错误图像 | 索引、标签、预测、置信度 | 视觉模式与边界 | 逆归一化显示 |
| 每类召回 | TP / 真实支持数 | 少数类是否被忽略 | 保留支持数 |
| 置信度分桶 | 正确/错误的概率区间 | 是否过度自信 | 不等同校准证明 |
| 版本对照 | 结构、增强、seed | 改动是否有效 | 一次只改一项 |
import torch
def confusion_matrix(targets, predictions, classes=10):
matrix = torch.zeros(classes, classes, dtype=torch.long)
for truth, prediction in zip(targets.tolist(), predictions.tolist()):
matrix[truth, prediction] += 1
return matrix
def collect_errors(model, loader, device, limit=20):
model.eval()
errors = []
with torch.no_grad():
for images, targets in loader:
logits = model(images.to(device))
probabilities = logits.softmax(dim=1)
predictions = probabilities.argmax(dim=1).cpu()
for index, (truth, prediction) in enumerate(zip(targets, predictions)):
if int(truth) != int(prediction) and len(errors) < limit:
errors.append({
"target": int(truth),
"prediction": int(prediction),
"confidence": float(probabilities[index, prediction]),
})
return errors
# matrix = confusion_matrix(targets, predictions)
# print(matrix)错误收集代码限制样本数量,避免把整套测试集图像和计算图留在内存。实际项目应额外保存原始索引和数据版本,才能回到 Dataset 重新读取;若数据包含个人信息,错误样例要先脱敏。置信度可用于排序人工复核,但不能单凭最大 Softmax 概率宣称模型已校准。
从错误桶提出下一轮实验
如果 4→9 错误多,先检查这些样本的笔画、裁剪和训练覆盖;如果某个类别召回低,比较 class weight、采样和增强是否改善;如果高置信度错误集中出现,增加校准评估或保留人工复核阈值。每个假设写成“只改一个因素、预期改变什么、若不改变如何解释”的实验卡。
比较模型时同时记录准确率、宏平均指标、参数量、每批耗时和最常见错误。一个稍高准确率但延迟翻倍的模型不一定适合交付;一个总体相同但减少关键错误的模型可能更有价值。把主指标与护栏指标分开,报告才不会被一个数字绑架。
从测试结果中选择一个混淆对(如 4→9),写出错误数量、支持数、两张样例图和一个只改变单因素的改进实验。
参考结论:卡片应包含真实/预测类别、混淆矩阵轴、样本索引、置信度和预处理版本。实验可以只加入轻微旋转、改变隐藏通道或调整训练预算,并固定其余条件,最后比较验证集而非反复使用测试集。
完整实现路径:冻结数据切分、seed 和预算,以 Linear 为基线,再只改变一个变量比较 MLP/CNN。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
from torch import nn
class MnistCNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 10),
)
def forward(self, x): return self.net(x)
probe = MnistCNN()(torch.zeros(2, 1, 28, 28))
assert probe.shape == (2, 10)验收标准:报告总体指标、类别切片、混淆样例与 best/last checkpoint。
若结果不对:先可视化输入范围和标签 dtype,再核对卷积后的展平维度。
| 能力 | 最低证据 | 下一步应用 |
|---|---|---|
| 读数据 | 能解释图像/标签 shape 与 transform | 其他图像数据集 |
| 搭 CNN | 能按公式计算空间尺寸和参数 | 更深卷积模块 |
| 跑基线 | 能在小批次过拟合后训练全量 | 正式训练预算 |
| 看错误 | 能生成混淆矩阵和错误样例 | 消融与模型选择 |
| 写结论 | 能把错误桶变成单变量实验 | 端到端项目报告 |
MNIST 训练的三个基线
第一个基线可以是像素展平后接 Linear,用来验证数据、标签、损失和指标;第二个基线是小型 MLP,验证非线性和参数容量;第三个基线是 CNN,验证局部结构先验。三个基线共享 transform、切分、训练预算和评估脚本,才能把结构收益与其他改动分开。
基线不需要追求最优准确率,但必须有稳定的 smoke run、小集过拟合和完整训练记录。若 Linear 已经满足练习目标,CNN 的额外复杂度要用错误减少、速度或资源优势解释。若 CNN 提升明显,查看混淆矩阵和边缘错误,确认提升不是由数据泄漏或不同预处理造成。
图像显示和模型输入是两回事。Normalize 后的 Tensor 用于计算,显示时需要逆归一化;如果直接把负值截断到 0~1,图像可能看起来全黑,误导你判断模型。保存错误样例时同时保留原始索引、真实标签、预测和预处理版本。
| 基线 | 结构假设 | 优点 | 主要限制 |
|---|---|---|---|
| Linear | 像素组合即可分离 | 最快、易审计 | 缺少局部结构 |
| MLP | 全局非线性组合 | 结构简单 | 忽略空间邻域 |
| CNN | 局部共享模式 | 更适合图像 | shape/资源更复杂 |
| 增强版 CNN | 结构+不变性 | 可能更稳 | 需要验证增强合理 |
import torch
from torch import nn
class LinearBaseline(nn.Module):
def __init__(self, classes=10):
super().__init__()
self.net = nn.Sequential(nn.Flatten(), nn.Linear(28 * 28, classes))
def forward(self, images):
return self.net(images)
class MLPBaseline(nn.Module):
def __init__(self, hidden=128, classes=10):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(), nn.Linear(28 * 28, hidden), nn.ReLU(),
nn.Linear(hidden, classes),
)
def forward(self, images):
return self.net(images)
def probe(model):
images = torch.randn(4, 1, 28, 28)
logits = model(images)
return tuple(logits.shape), sum(p.numel() for p in model.parameters())
for model in (LinearBaseline(), MLPBaseline()):
print(type(model).__name__, probe(model))完整脚本需要导入 torch 才能执行 torch.randn,这里把类定义与 probe 分开,是为了突出结构对照。比较基线时保存每个模型的参数量、训练时间、验证指标和最常见混淆对,不能只看 CNN 的最终准确率。
正式实验可以把数据量、epoch 和 seed 写入同一份配置,然后循环构造不同模型。每次运行前清空旧 optimizer,避免把上一模型的状态错误复用;保存 best checkpoint 时写入模型名称和配置哈希。这样即使运行顺序改变,结果仍然可追溯。
每次升级只改变结构,数据和评估口径保持一致。
为 Linear、MLP、CNN 写出共享的数据、训练和评估条件。列出你会比较的两个主指标和三个护栏指标。
参考结论:共享 transform、切分、batch、epoch、seed 集合和指标脚本;主指标可为 accuracy/macro F1,护栏包括参数量、训练时间、峰值显存和常见混淆。一次只换结构。
完整实现路径:冻结数据切分、seed 和预算,以 Linear 为基线,再只改变一个变量比较 MLP/CNN。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 5:先复制运行,再替换为自己的数据
from torch import nn
class MnistCNN(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(), nn.Linear(32 * 7 * 7, 10),
)
def forward(self, x): return self.net(x)
probe = MnistCNN()(torch.zeros(2, 1, 28, 28))
assert probe.shape == (2, 10)验收标准:报告总体指标、类别切片、混淆样例与 best/last checkpoint。
若结果不对:先可视化输入范围和标签 dtype,再核对卷积后的展平维度。
07 · DEBUG & PERFORMANCE
调试与性能:先定位第一个错误,再谈加速
PyTorch 报错通常已经给出线索,但需要把线索缩小到最小复现。本节整理设备、shape、dtype、梯度、数据泄漏和模式切换的诊断步骤,再介绍 profiler、吞吐、显存和数据加载账本。
调试决策树:先分类,再缩小范围
遇到异常时先判断它属于输入、模型、损失、优化还是资源问题。输入类检查样本 ID、shape、dtype、标签范围和有限性;模型类检查参数注册、输出 shape 与 logits 数值;损失类检查 reduction、ignore index 和目标格式;优化类检查学习率、梯度范数、zero_grad 顺序;资源类检查设备、显存、worker 和单步耗时。分类后只做与该类相关的最小改动,避免同时改五处导致证据消失。
NaN 要从第一次出现的位置追踪,而不是只在 epoch 末打印“loss=nan”。在 forward 后、loss 后、backward 后分别检查有限性,并把 batch 索引、样本 ID、输入范围、学习率和梯度摘要写入 snapshot。若只在混合精度出现,先关闭 AMP 验证全精度基线;若只在某一批出现,优先检查该批数据和增强边界。
性能优化也必须有基线。记录固定数量 batch 的 wall-clock、数据等待时间、GPU 利用率、峰值显存和吞吐;改变一个参数后重复同样窗口。profiler 的调用栈很长,先找占比最高且可行动的算子,再决定是否改 batch、缓存、融合或设备搬运。速度提升若伴随指标下降或显存超预算,就不是可接受的优化。
| 症状 | 第一动作 | 不要先做 | 保留证据 |
|---|---|---|---|
| shape error | 打印每个边界 shape | 盲目 reshape | 输入/输出契约 |
| device mismatch | 比较所有 Tensor device | 到处 .to() | 设备策略 |
| loss NaN | 定位首次非有限 batch | 直接降低十倍 lr | 输入/梯度快照 |
| loss 不动 | 检查小批次过拟合 | 立即加深模型 | 参数变化、梯度 |
| GPU 空转 | 拆数据等待与计算时间 | 盲目加 worker | 吞吐账本 |
| 显存上涨 | 检查图和缓存引用 | 只调用 empty_cache | 分配曲线 |
import torch
def finite_guard(name, tensor):
if not torch.isfinite(tensor).all():
finite = tensor[torch.isfinite(tensor)]
summary = {
"name": name,
"shape": tuple(tensor.shape),
"dtype": str(tensor.dtype),
"finite_count": int(finite.numel()),
"total": int(tensor.numel()),
}
raise FloatingPointError(summary)
x = torch.randn(4, 3)
finite_guard("features", x)最小复现还应尽量可复制:命令、输入文件或样本 ID、seed、版本、设备和预期结果都写在同一段说明中。修复后把原错误加入回归测试,确保未来重构不会让它回来。好的调试记录不是“我试了很多办法”,而是观察、假设、单变量修复和验证结果的链条。
设计一个会产生 NaN 的小例子,再写一个测试确认 finite_guard 能在第一次异常处抛出,并记录 batch、参数和学习率摘要。
参考结论:可用除以零、log 负数或过大的学习率构造异常;测试应断言异常类型和摘要字段存在。正式训练里在 forward/loss/grad 三个边界调用守卫,失败后保存 snapshot 而不是继续更新。
完整实现路径:把问题缩成单 batch、固定 seed 和最少模块,先写失败断言,再修复并保留回归测试。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
import torch
def finite_guard(name, value, batch_id):
if not torch.isfinite(value).all():
raise FloatingPointError(
f"{name} is non-finite; batch={batch_id}; "
f"shape={tuple(value.shape)}; dtype={value.dtype}"
)
x = torch.tensor([1.0, float("nan")])
try:
finite_guard("features", x, "sample-07")
except FloatingPointError as exc:
print(exc)验收标准:故障可以稳定复现,修复前失败、修复后通过,性能实验只改变一个因素。
若结果不对:按输入→形状/dtype/device→前向→loss→梯度→优化器顺序定位。
6.7.1 常见错误的最小复现:从报错信息找到第一个坏边界
PyTorch 报错通常描述了最后一个无法执行的算子,但真正的原因可能在更早的数据或 shape。遇到错误时先保存完整 traceback、命令、环境报告和触发 batch,再把数据量缩到一个或两个样本。关闭随机增强、固定 seed、使用 CPU 或 float64,直到问题可以在几十行代码中重现。最小复现比在完整训练脚本里反复试错更容易定位。
设备错误通常表现为不同 device 的 Tensor 混合;shape 错误表现为矩阵乘法或卷积轴不匹配;dtype 错误常见于标签 float、索引负数或混合精度;梯度错误则包括 grad 为 None、NaN、原地修改和图被重复使用。每一类错误都应对应一条入口断言,阻止坏数据继续向下游传播。
“没有报错但结果不对”更危险。例如把 model.eval() 漏掉,验证结果会受 Dropout 影响;把标签列拼入特征,指标会异常高;把 mean 沿错误轴求值,loss 仍是标量却改变了任务含义。调试不能只依赖异常,必须结合 shape、范围、ID、模式和指标分母。
每次只删除一个变量,避免“修好了但不知道为什么”。
| 症状 | 第一检查 | 最小复现 | 不要先做什么 |
|---|---|---|---|
| device mismatch | 模型/输入/目标 device | 一层 Linear + 一个 batch | 到处调用 cuda() |
| shape mismatch | 每层实际/期望 shape | 固定 shape probe | 随意 unsqueeze |
| dtype 错 | features/labels/mask dtype | 手工两条样本 | 全部转 float |
| loss NaN | 输入/logits/梯度范围 | 关闭随机性和 AMP | 直接丢弃 NaN batch |
| 结果漂移 | train/eval、seed、数据顺序 | 同一金样本两次运行 | 只看最终准确率 |
| 指标异常高 | 标签泄漏、切分交集 | 打乱标签做对照 | 继续调参 |
import torch
from torch import nn
def check_batch(features, targets, feature_count, classes):
if features.ndim != 2 or features.shape[1] != feature_count:
raise ValueError(f"features shape: {tuple(features.shape)}")
if targets.ndim != 1 or targets.shape[0] != features.shape[0]:
raise ValueError("targets 必须与 batch 对齐")
if targets.dtype != torch.long:
raise TypeError(f"targets dtype: {targets.dtype}")
if targets.min().item() < 0 or targets.max().item() >= classes:
raise ValueError("targets 超出类别范围")
model = nn.Linear(4, 3)
features = torch.tensor([[0.1, 0.2, 0.3, 0.4]])
targets = torch.tensor([1], dtype=torch.long)
check_batch(features, targets, feature_count=4, classes=3)
logits = model(features)
loss = nn.CrossEntropyLoss()(logits, targets)
assert torch.isfinite(loss)
print("minimal reproduction passed")最小复现脚本应该能独立运行,不引用训练脚本里的全局变量。修复后把它放进 tests 或 scripts/smoke 中,作为未来重构的回归测试。错误消息应包含字段名、实际值和期望值,避免测试只报一个无上下文的 AssertionError。
读 traceback 的方法
从最底层的异常类型开始,再向上看最后一个你写的文件和行号。算子名称能提示是在 Linear、Conv、loss 还是数据转换阶段;实际 shape 和 dtype 能提示是哪条边界违反契约。若 traceback 进入第三方库很深,先在进入库前打印参数,通常比修改库源码更有效。
修复后不要只确认“这次能运行”,还要复现原先的失败输入,确认错误消失且核心输出没有被意外改变。保存一份失败样本和一份通过样本,测试的价值就在于未来还能触发同一个边界。
故意把 Linear 的输入维度设错,记录完整报错,再把它缩小为一个 batch 的最小脚本。写出修复前后的 shape 断言。
参考结论:保留实际输入 shape、Linear 的 in_features 和触发代码;最小脚本只需创建 Tensor、模型和一次 forward。修复后断言输入最后一维等于 in_features,输出 batch 轴保持一致。
完整实现路径:把问题缩成单 batch、固定 seed 和最少模块,先写失败断言,再修复并保留回归测试。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
import torch
def finite_guard(name, value, batch_id):
if not torch.isfinite(value).all():
raise FloatingPointError(
f"{name} is non-finite; batch={batch_id}; "
f"shape={tuple(value.shape)}; dtype={value.dtype}"
)
x = torch.tensor([1.0, float("nan")])
try:
finite_guard("features", x, "sample-07")
except FloatingPointError as exc:
print(exc)验收标准:故障可以稳定复现,修复前失败、修复后通过,性能实验只改变一个因素。
若结果不对:按输入→形状/dtype/device→前向→loss→梯度→优化器顺序定位。
6.7.2 单元测试与断言:给数据、模型和指标设门槛
单元测试不一定要覆盖全部训练时间,重点是保护稳定契约。Dataset 测试索引数量、单样本字段和边界错误;collate 测试 batch shape、padding、mask 和 ID;模型测试输入输出、参数注册和有限性;loss 测试极端样本与分母;训练循环测试一次更新是否真的改变参数;保存加载测试金样本输出是否一致。
断言应放在最靠近假设的位置。数据入口检查范围和 dtype,模块边界检查 shape,loss 前检查标签,step 后检查梯度和参数是否有限,评估后检查指标分母。断言失败时宁可让训练停止,也不要把坏结果写成“成功运行”。生产推理可以把昂贵断言分成严格模式和轻量模式,但核心输入检查不能完全删除。
测试数据要小且确定。使用固定 Tensor、固定标签和手算期望值,不要依赖下载数据或随机初始化;若必须随机,固定 seed 并设置容差。对于模型输出,用 torch.testing.assert_close 比较浮点结果,明确 rtol/atol。测试的目标是保护关系,不是强迫不同硬件逐位相同。
| 层次 | 测试对象 | 示例断言 | 失败说明 |
|---|---|---|---|
| 数据单元 | Dataset/transform | 字段、范围、ID | 原始数据或预处理错 |
| batch 单元 | collate/loader | shape、mask、总数 | 拼接或 padding 错 |
| 模型单元 | Module/forward | 输出 shape/finite | 结构或参数错 |
| 损失单元 | loss/metric | 极端样本性质 | 类别轴/分母错 |
| 流程单元 | one step/checkpoint | 参数变化/回读 | 训练状态错 |
| 回归测试 | 历史失败样例 | 错误不再复现 | 重构引入变化 |
import torch
from torch import nn
def test_classifier_contract():
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 3))
features = torch.randn(5, 4)
labels = torch.tensor([0, 1, 2, 1, 0], dtype=torch.long)
logits = model(features)
assert logits.shape == (5, 3)
assert logits.dtype == features.dtype
assert torch.isfinite(logits).all()
loss = nn.CrossEntropyLoss()(logits, labels)
assert loss.ndim == 0 and torch.isfinite(loss)
before = [parameter.detach().clone() for parameter in model.parameters()]
loss.backward()
assert all(parameter.grad is not None for parameter in model.parameters())
changed = any(not torch.equal(old, new.detach()) for old, new in zip(before, model.parameters()))
assert not changed, "backward 不应自动改变参数"
test_classifier_contract()
print("contract tests passed")测试中特意断言 backward 后参数没有变化,提醒我们 backward 只写梯度,真正修改参数的是 optimizer.step。还可以增加一次 step 后参数发生变化的测试、zero_grad 后梯度为空的测试和 model.eval 下 Dropout 输出稳定的测试。每条测试只验证一个关系,失败时才能快速定位。
测试命名和运行速度
测试名称要说明条件和期望,例如 test_collate_masks_padding、test_checkpoint_round_trip。快速测试每次提交都运行,下载数据和完整训练作为单独的集成测试。不要让一个需要几分钟的测试成为唯一保障,否则你在改一行 shape 时很可能选择不运行它。
记录测试使用的 PyTorch 版本和容差。升级版本后先跑契约测试,再比较金样本输出;若只是浮点实现变化,调整容差要有理由,不能用无限放宽来掩盖语义改变。
为一个 Dataset 写长度/字段测试,为模型写 shape/finite 测试;再故意破坏一条契约,确认测试真的失败。
参考结论:Dataset 测试可以检查 len 、特征 dtype、标签范围和 ID;模型测试检查输入输出 shape、logits 有限、loss 是标量、参数在 backward 后有梯度。故意把类别数或特征数改错,测试应立即指出边界。
完整实现路径:把问题缩成单 batch、固定 seed 和最少模块,先写失败断言,再修复并保留回归测试。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
import torch
def finite_guard(name, value, batch_id):
if not torch.isfinite(value).all():
raise FloatingPointError(
f"{name} is non-finite; batch={batch_id}; "
f"shape={tuple(value.shape)}; dtype={value.dtype}"
)
x = torch.tensor([1.0, float("nan")])
try:
finite_guard("features", x, "sample-07")
except FloatingPointError as exc:
print(exc)验收标准:故障可以稳定复现,修复前失败、修复后通过,性能实验只改变一个因素。
若结果不对:按输入→形状/dtype/device→前向→loss→梯度→优化器顺序定位。
6.7.3 profiler 与资源账本:知道时间和显存花在哪里
性能优化前先建立基线:每个 epoch 耗时、每秒样本或 token、数据等待时间、forward/backward 时间、峰值显存、参数量和检查点大小。训练变慢可能来自数据读取、CPU/GPU 同步、过多日志、模型计算或保存文件;没有拆分计时,就只能凭感觉调参。
PyTorch profiler 可以记录 CPU/CUDA 活动、算子耗时和内存,但 profiler 本身有开销,适合在短窗口和固定小 batch 上使用。先预热若干步,再采样几步,避免把第一次编译或缓存开销当作平均速度。分析结果时关注最耗时的算子、数据加载空洞和频繁同步,而不是只看总时间。
显存包括模型参数、梯度、optimizer state、中间激活、输入 batch 和缓存。减小 batch 可能缓解激活显存,冻结参数会减少梯度,但 Adam 的状态仍可能占空间;混合精度和梯度检查点会改变精度与速度。每次性能改动只改变一个变量,并在相同数据、seed、模型和预算下比较。
| 指标 | 测量方法 | 解释 | 护栏 |
|---|---|---|---|
| 吞吐 | samples/token ÷ 秒 | 单位时间处理量 | 指标不下降 |
| 数据等待 | 取 batch 到开始 forward | DataLoader 是否瓶颈 | worker/缓存 |
| forward/backward | 同步计时窗口 | 模型计算成本 | 固定 batch/shape |
| 峰值显存 | cuda max memory | 是否接近上限 | 避免 OOM |
| P95/P99 延迟 | 多次推理分位数 | 尾部体验 | 输入长度分桶 |
| checkpoint 大小 | 文件大小/保存秒数 | 交付与存储成本 | 回读可用 |
import torch
from torch import nn
model = nn.Sequential(nn.Linear(256, 512), nn.ReLU(), nn.Linear(512, 10))
optimizer = torch.optim.SGD(model.parameters(), lr=1e-2)
inputs = torch.randn(32, 256)
targets = torch.randint(0, 10, (32,))
criterion = nn.CrossEntropyLoss()
with torch.profiler.profile(
activities=[torch.profiler.ProfilerActivity.CPU],
record_shapes=True,
profile_memory=True,
) as profile:
for _ in range(3):
optimizer.zero_grad(set_to_none=True)
loss = criterion(model(inputs), targets)
loss.backward()
optimizer.step()
print(profile.key_averages().table(sort_by="cpu_time_total", row_limit=5))示例只启用 CPU profiler,方便没有 GPU 的环境运行;有 CUDA 时再按官方文档增加 CUDA activity,并注意同步计时。性能报告必须注明硬件、batch、输入 shape、是否预热、是否包含数据读取和 profiler 开销。否则“快了 20%”没有可比意义。
先优化瓶颈,再优化代码风格
如果 profiler 显示时间主要在 DataLoader,先检查 worker、磁盘和预取;如果时间在 GPU 算子,检查 batch、矩阵尺寸和混合精度;如果时间在 Python 循环,考虑向量化或减少日志。不要在没有测量前把所有代码改成复杂的异步结构,复杂度本身也是维护成本。
性能优化不能改变任务契约。吞吐上升但标签错位、loss 分母改变或验证模式错误,不能算成功。每次优化后运行数据、模型、指标和 checkpoint 契约测试,再比较资源指标。
固定一个模型和 batch,记录 3 次 warm-up 后的平均 step 时间、峰值显存、吞吐和数据等待时间。提出一个只改一项的优化实验。
参考结论:基线卡应写硬件、PyTorch 版本、batch、输入 shape、是否含数据读取/预热。实验可以只调 num_workers、batch_size 或混合精度,并同时检查指标与输出一致性。
完整实现路径:把问题缩成单 batch、固定 seed 和最少模块,先写失败断言,再修复并保留回归测试。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
import torch
def finite_guard(name, value, batch_id):
if not torch.isfinite(value).all():
raise FloatingPointError(
f"{name} is non-finite; batch={batch_id}; "
f"shape={tuple(value.shape)}; dtype={value.dtype}"
)
x = torch.tensor([1.0, float("nan")])
try:
finite_guard("features", x, "sample-07")
except FloatingPointError as exc:
print(exc)验收标准:故障可以稳定复现,修复前失败、修复后通过,性能实验只改变一个因素。
若结果不对:按输入→形状/dtype/device→前向→loss→梯度→优化器顺序定位。
| 能力 | 最低证据 | 下一步应用 |
|---|---|---|
| 最小复现 | 能把错误缩到固定 batch | 独立修复训练问题 |
| 断言 | 能在数据/模型/loss 边界设门槛 | 持续集成测试 |
| 回归 | 能保存历史失败样例 | 安全重构 |
| 性能 | 能拆分计时并使用 profiler | 选择设备与 batch |
| 资源 | 能报告吞吐、显存和延迟 | 推理发布 |
调试不是修补,是建立可重复的证据
每次调试都写出四句话:观察到什么、最小复现是什么、假设哪一个边界坏了、修复后用什么测试证明。比如“验证准确率异常高;固定样本的标签列被拼进特征;怀疑数据泄漏;删除该列后重新跑 split audit 与 baseline”。这种写法能防止把一次偶然结果当成原因。
错误修复要避免扩大范围。设备错只修设备搬运,shape 错只修轴,dtype 错只修类型,指标错只修分母;如果同时换了模型和数据,下一次失败会更难定位。提交一个最小修复后立即运行相关契约测试,再进入下一项优化。
性能分析也需要基线和护栏。修改 num_workers、batch、AMP 或编译选项时,保存相同金样本的输出、loss 和指标;如果速度上升但数值不一致,先判断是可接受浮点差异还是语义回归。把资源变化写进运行记录,未来才能解释为什么选择一个看似更慢但更稳定的配置。
| 字段 | 要写什么 | 示例 | 价值 |
|---|---|---|---|
| observation | 可重复现象 | 第二层 shape 错 | 避免模糊描述 |
| reproduction | 命令、输入、环境 | 一个固定 batch | 第三方可回放 |
| hypothesis | 边界与原因 | HWC 未转 CHW | 限制修复范围 |
| patch | 只改动什么 | 增加 permute | 便于审查 |
| regression | 哪条测试通过 | shape + gold output | 防止复发 |
| resource | 时间/显存变化 | step -12% | 公平比较 |
import json
from pathlib import Path
import torch
def summarize_batch(features, targets, batch_index, sample_ids):
return {
"batch_index": batch_index,
"shape": tuple(features.shape),
"dtype": str(features.dtype),
"device": str(features.device),
"finite": bool(torch.isfinite(features).all()),
"target_shape": tuple(targets.shape),
"target_min": int(targets.min()),
"target_max": int(targets.max()),
"sample_ids": list(sample_ids),
}
# snapshot = summarize_batch(features, targets, 0, ids)
# Path("runs").mkdir(exist_ok=True)
# Path("runs/debug-batch.json").write_text(json.dumps(snapshot, indent=2), encoding="utf-8")示例的注释行需要在完整脚本中导入 torch,并传入真实的 features、targets 和 ids;它的重点是保存第一现场,而不是把所有数据写入日志。保存 snapshot 后可以在没有 DataLoader 的情况下重放一个 batch,定位模型或指标边界。
没有回归测试的修复,未来仍可能悄悄失效。
任选一个故意制造的 dtype、shape 或 device 错误,保存最小 batch、环境、错误消息、假设、修复和回归断言。
参考结论:报告应包含可复制命令、实际/期望 shape 或 dtype、触发样本 ID、只改动的一处修复和一条能在未来复现的测试。不要只写“改成了正确类型”。
完整实现路径:把问题缩成单 batch、固定 seed 和最少模块,先写失败断言,再修复并保留回归测试。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 5:先复制运行,再替换为自己的数据
import torch
def finite_guard(name, value, batch_id):
if not torch.isfinite(value).all():
raise FloatingPointError(
f"{name} is non-finite; batch={batch_id}; "
f"shape={tuple(value.shape)}; dtype={value.dtype}"
)
x = torch.tensor([1.0, float("nan")])
try:
finite_guard("features", x, "sample-07")
except FloatingPointError as exc:
print(exc)验收标准:故障可以稳定复现,修复前失败、修复后通过,性能实验只改变一个因素。
若结果不对:按输入→形状/dtype/device→前向→loss→梯度→优化器顺序定位。
可复现报错:Linear 输入形状不匹配
故意把 (B, 28, 28) 的图像直接交给 Linear(784, 10),可得到类似 RuntimeError: mat1 and mat2 shapes cannot be multiplied。报错不是让你随便改 784,而是在提醒“最后一维”仍是 28。
import torch
from torch import nn
layer = nn.Linear(28 * 28, 10)
x = torch.randn(4, 28, 28)
# layer(x) # 错误:Linear 看到的最后一维是 28
x = x.flatten(start_dim=1)
assert x.shape == (4, 784)
logits = layer(x)
assert logits.shape == (4, 10)排错流程
- 保留完整 traceback,先看最后一行的期望维度和实际维度。
- 用一个 batch 重现;打印进入每一层前后的
shape/dtype/device。 - 找到第一次偏离接口合同的位置,再决定展平、转置还是修改层配置。
- 修复后加入 shape 断言和回归测试,不删除失败样例。
08 · SAVE & INFERENCE
保存与推理:把训练产物变成可靠入口
权重文件只是交付的一部分。模型结构、类别映射、预处理、设备、版本和推理接口必须一起保存和验证。本节完成 state_dict 恢复、单样本预测、批量推理、导出清单和发布前检查。
保存与发布的三层合同:权重、预处理、版本
保存模型不能只考虑“文件能打开”。第一层是权重合同:模型结构、参数名、dtype 和 state_dict 能严格加载;第二层是预处理合同:输入尺寸、通道顺序、归一化均值方差、标签映射和输出解释保持一致;第三层是版本合同:代码提交号、数据版本、依赖版本和训练配置可追溯。缺少第二层时,模型即使加载成功,也可能因为输入尺度不同而输出完全错误。
推理包装器应把设备选择、eval()、inference_mode()、批处理、概率计算和结果格式集中管理。调用方只传入约定好的原始输入,返回固定字段,如类别索引、标签、概率和模型版本。不要让网页或脚本直接操作内部层;接口稳定后,替换 CNN 或量化实现不会影响上游。
checkpoint 读取要考虑设备迁移和安全边界。保存完整训练状态便于恢复,发布推理时可以只保留权重和清晰的 manifest。加载来自不可信来源的 pickle 文件存在执行风险,实际部署应使用可信文件、受限目录和明确的加载策略。每次发布前用固定金样本运行预测,比较 logits 或类别、概率范围和预处理摘要。
| 文件 | 内容 | 验收 | 用途 |
|---|---|---|---|
| model.pt | state_dict 或推理权重 | strict load | 重建模型 |
| manifest.json | 版本、标签、预处理 | 字段校验 | 接口解释 |
| predict.py | 固定输入输出包装器 | 金样本预测 | 调用入口 |
| requirements.txt | 直接依赖版本 | 冷启动导入 | 环境重建 |
| checksums.txt | 文件哈希与大小 | 哈希匹配 | 传输完整性 |
| README.md | 运行命令和限制 | 新终端执行 | 交接与审计 |
import torch
class Predictor:
def __init__(self, model, preprocess, labels, version, device="cpu"):
self.model = model.to(device).eval()
self.preprocess = preprocess
self.labels = labels
self.version = version
self.device = device
@torch.inference_mode()
def __call__(self, raw_items):
batch = torch.stack([self.preprocess(item) for item in raw_items]).to(self.device)
logits = self.model(batch)
probabilities = logits.softmax(dim=-1)
values, indices = probabilities.max(dim=-1)
return [{"index": int(i), "label": self.labels[int(i)], "probability": float(v), "version": self.version}
for v, i in zip(values.cpu(), indices.cpu())]发布检查还要验证失败路径:输入 shape 错误时返回可读异常,未知标签索引不会静默映射,manifest 缺字段会阻止启动,权重哈希不一致会拒绝加载。把这些失败路径写成自动化测试,比只测一个成功样例更能保护接口。
为你的模型写出保存、加载、金样本、预处理、版本和失败路径六项验收,并说明哪些证据要放进 manifest。
参考结论:manifest 至少含模型/代码/数据版本、标签映射、输入 shape、Normalize 参数和权重哈希;验收要包含 strict load、固定样本输出、错误输入拒绝、依赖冷启动和文件完整性。
完整实现路径:保存 state_dict、结构配置、预处理和标签映射;重建新模型加载,并用 gold input 比较 logits。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
from pathlib import Path
import json, torch
path = Path("model.pt")
config = {"in_features": 4, "out_features": 3}
model.eval(); gold = torch.randn(2, 4)
with torch.no_grad(): before = model(gold)
torch.save({"model": model.state_dict(), "config": config}, path)
blob = torch.load(path, weights_only=True)
restored = torch.nn.Linear(**blob["config"])
restored.load_state_dict(blob["model"]); restored.eval()
with torch.no_grad(): after = restored(gold)
assert torch.allclose(before, after)验收标准:round-trip 输出在容差内一致,manifest 可解析,非法输入被明确拒绝。
若结果不对:先检查 config/权重是否配套,再检查 eval 模式和预处理,最后检查序列化格式。
6.8.1 state_dict 与恢复:保存模型状态而不是保存幻觉
PyTorch 推荐使用 state_dict 保存参数和注册的 buffer。它是一个从名称到 Tensor 的映射,适合配合代码和配置重建模型;直接保存整个 Python 模型对象会依赖类的导入路径和版本,迁移环境时更脆弱。保存前先确定模型结构版本、类别数、预处理和设备,加载时实例化相同结构,再调用 load_state_dict。
推理 checkpoint 通常只需要 model state;继续训练则要保存 optimizer、scheduler、epoch、最佳指标、随机状态、数据版本和配置。加载时使用 map_location 把 GPU 权重映射到当前设备,避免在没有 GPU 的机器上直接失败。PyTorch 版本变化可能影响某些序列化选项,运行前阅读对应官方文档并记录版本。
严格加载是好习惯。strict=True 时 missing keys 和 unexpected keys 会立即暴露结构不匹配;只有在明确迁移部分权重时才使用非严格加载,并把忽略的键写入报告。加载后用固定金样本比较 logits、预测和指标,不能只看到“没有异常”就相信恢复成功。
| 产物 | 包含 | 用途 | 缺失后果 |
|---|---|---|---|
| model state | 参数与 buffer | 推理/迁移 | 无法重建输出 |
| optimizer state | momentum/Adam 缓存 | 继续训练 | 轨迹改变 |
| scheduler state | 学习率进度 | 恢复调度 | 步长跳变 |
| config | 结构与超参数 | 实例化模型 | shape/激活错 |
| preprocess | Normalize/词表/类别映射 | 入口一致 | 预测语义变 |
| manifest | 版本/数据/环境 | 审计和回滚 | 无法解释差异 |
from pathlib import Path
import torch
from torch import nn
class Classifier(nn.Module):
def __init__(self, features=4, classes=3):
super().__init__()
self.linear = nn.Linear(features, classes)
def forward(self, features):
return self.linear(features)
model = Classifier()
sample = torch.tensor([[0.1, 0.2, 0.3, 0.4]])
reference = model(sample).detach()
path = Path("runs/classifier.pt")
path.parent.mkdir(parents=True, exist_ok=True)
torch.save({"model": model.state_dict(), "config": {"features": 4, "classes": 3}}, path)
restored = Classifier(features=4, classes=3)
payload = torch.load(path, map_location="cpu", weights_only=False)
restored.load_state_dict(payload["model"], strict=True)
restored_output = restored(sample).detach()
torch.testing.assert_close(reference, restored_output)
print("round trip passed:", path)金样本比较使用 assert_close 而不是字符串比较,允许合理的浮点误差。若恢复后差异很大,依次检查模型结构、state_dict 键、dtype、设备、预处理和 eval 模式。若只比较 argmax,可能掩盖 logits 已经改变但仍落在同一类别的情况。
文件命名和安全边界
检查点目录可以按 run_id/checkpoints/best.pt、last.pt 和 manifest.json 组织。保存成功后立即尝试重新读取,检查文件大小、哈希和关键键;不要等到训练结束才发现保存路径没有权限或磁盘已满。来自不可信来源的模型文件要谨慎加载,避免执行未知对象,优先使用只包含权重的格式和受控环境。
迁移学习时可以只加载 backbone,再明确初始化新的分类头。报告中列出加载的键、忽略的键和新初始化的层,推理前先跑 shape probe。这样读者能知道哪些参数来自旧模型,哪些参数真正接受了新任务的训练。
建立一个三类 Linear 模型,保存 state_dict 和 config,再实例化新模型加载它。使用固定输入比较保存前后 logits,并列出继续训练还需要哪些字段。
参考结论:保存前后应使用相同结构和固定输入,用容差比较 logits。继续训练还需 optimizer/scheduler state、epoch/step、最佳指标、随机状态、数据与环境版本。
# 对应本节练习 2:先复制运行,再替换为自己的数据
from pathlib import Path
import json, torch
path = Path("model.pt")
config = {"in_features": 4, "out_features": 3}
model.eval(); gold = torch.randn(2, 4)
with torch.no_grad(): before = model(gold)
torch.save({"model": model.state_dict(), "config": config}, path)
blob = torch.load(path, weights_only=True)
restored = torch.nn.Linear(**blob["config"])
restored.load_state_dict(blob["model"]); restored.eval()
with torch.no_grad(): after = restored(gold)
assert torch.allclose(before, after)验收标准:round-trip 输出在容差内一致,manifest 可解析,非法输入被明确拒绝。
若结果不对:先检查 config/权重是否配套,再检查 eval 模式和预处理,最后检查序列化格式。
6.8.2 推理入口与打包:把一条预测路径固定下来
训练脚本和推理脚本的职责不同。推理入口应接收明确的原始输入,执行与训练一致的预处理,加载模型,切换 eval 和 no_grad/inference_mode,返回类别、logits 或概率以及必要的版本信息。不要让调用者自己拼 Tensor、选择 Normalize 或猜类别字典,否则同一个模型在不同入口可能得到不同含义。
推理函数要处理单样本和 batch 的差异。单张 MNIST 图片可能是 (28,28)、(1,28,28) 或带 batch 的 (1,1,28,28);入口可以统一增加缺失轴,但必须拒绝超过预期的形状,并在错误消息中说明实际值。批量推理返回结果时保留输入顺序和 ID,便于回写业务系统和错误分析。
概率展示与模型训练接口要分开。CrossEntropyLoss 训练 logits,推理时可以调用 softmax 得到展示概率;二分类要明确使用单 logit 的 sigmoid 还是两类 logits 的 softmax。阈值、类别映射、标签说明和置信度解释都应进入 manifest,不能藏在前端按钮或 notebook 单元格中。
| 阶段 | 输入/输出 | 固定约定 | 错误处理 |
|---|---|---|---|
| 原始输入 | 文件、数组或字节 | 尺寸、通道、编码 | 拒绝非法格式 |
| 预处理 | float Tensor | Normalize/词表版本 | 范围与 dtype 断言 |
| 模型调用 | batch Tensor→logits | device、eval、无梯度 | shape/finite 检查 |
| 后处理 | 类别/概率/阈值 | 映射和阈值版本 | 保留原始 logits |
| 响应 | 结构化 JSON | 顺序、ID、版本 | 可定位错误样本 |
import torch
from torch import nn
class Predictor:
def __init__(self, model, device="cpu", labels=None):
self.model = model.to(device)
self.model.eval()
self.device = torch.device(device)
self.labels = labels or [str(index) for index in range(10)]
@torch.inference_mode()
def __call__(self, images):
tensor = torch.as_tensor(images, dtype=torch.float32)
if tensor.ndim == 2:
tensor = tensor.unsqueeze(0).unsqueeze(0)
elif tensor.ndim == 3:
tensor = tensor.unsqueeze(0)
if tensor.ndim != 4 or tensor.shape[1] != 1:
raise ValueError(f"期望 (B,1,H,W),实际 {tuple(tensor.shape)}")
logits = self.model(tensor.to(self.device))
probabilities = logits.softmax(dim=-1)
indices = probabilities.argmax(dim=-1).cpu().tolist()
return [{"label": self.labels[index], "index": index, "probability": float(probabilities[row, index])}
for row, index in enumerate(indices)]
# predictor = Predictor(model, labels=[str(i) for i in range(10)])
# print(predictor(torch.zeros(28, 28)))推理入口把输入形状归一化到四维,并始终返回结构化结果。真实服务还要限制输入大小、记录请求版本、处理超时和拒绝异常值;学习项目至少要保存一份示例请求与响应,作为回归测试。若返回概率,说明它是否经过校准、阈值在哪里选择,不要把 Softmax 最大值直接称为“真实概率”。
打包清单:模型不是一个 .pt 文件
发布包至少包含权重、模型配置、预处理参数、类别映射、推理代码版本、Python/PyTorch 版本和一组金样本。对于 MNIST,还应写明输入是灰度、尺寸 28×28、归一化均值方差和输出类别 0~9。把这些文件压缩在一个版本目录中,读取时先检查 manifest,再加载权重。
接口变更要有版本号。输入从单图改成 batch、类别映射增加新类、Normalize 更新或阈值改变,都应提高模型/预处理版本,并保留旧版本以便回滚。推理结果中返回版本字段,业务日志出现异常时才能知道使用的是哪份模型。
让推理入口同时接受单张 (28,28) 和批量 (B,1,28,28),统一输出类别索引与概率。列出它必须拒绝的两种非法输入。
参考结论:入口可以为 2D/3D 输入补 batch/通道轴,最终必须是四维且通道为 1。应拒绝通道数错误、维度超过预期、非有限值或尺寸不符合模型契约的输入,并给出实际 shape。
# 对应本节练习 3:先复制运行,再替换为自己的数据
from pathlib import Path
import json, torch
path = Path("model.pt")
config = {"in_features": 4, "out_features": 3}
model.eval(); gold = torch.randn(2, 4)
with torch.no_grad(): before = model(gold)
torch.save({"model": model.state_dict(), "config": config}, path)
blob = torch.load(path, weights_only=True)
restored = torch.nn.Linear(**blob["config"])
restored.load_state_dict(blob["model"]); restored.eval()
with torch.no_grad(): after = restored(gold)
assert torch.allclose(before, after)验收标准:round-trip 输出在容差内一致,manifest 可解析,非法输入被明确拒绝。
若结果不对:先检查 config/权重是否配套,再检查 eval 模式和预处理,最后检查序列化格式。
6.8.3 复现与发布检查:让别人能重现你的数字
复现不是要求所有硬件逐位相同,而是让运行条件和差异边界透明。保存环境报告、依赖版本、设备、seed、deterministic 设置、数据 manifest、配置、模型结构、训练日志和 checkpoint。对关键输出保存金样本与容差,说明在 CPU、CUDA 或不同版本下允许多大差异。
发布前做三类检查。功能检查确认推理入口对单样本和 batch 正常,输出 shape、类别和概率合法;回归检查比较金样本 logits、类别和关键指标;安全/工程检查确认文件可读取、路径不依赖当前目录、输入大小受限、异常有清楚消息。若模型要在长期服务中运行,再加入延迟分位数、显存、并发和回滚检查。
复现报告要区分“代码相同”和“数据相同”。同一个脚本如果读取了最新目录、随机重新切分或重新计算 Normalize,结果仍可能不同。manifest 应包含数据内容哈希或索引清单、预处理参数和类别字典;启动时校验版本,不匹配就停止或明确警告。
| 类别 | 检查项 | 最低证据 | 失败处理 |
|---|---|---|---|
| 功能 | 单样本/批量推理 | 示例请求响应 | 修 shape/预处理 |
| 数值 | logits/概率有限 | 金样本容差 | 查 dtype/设备 |
| 版本 | 权重/代码/数据匹配 | manifest 哈希 | 拒绝加载 |
| 性能 | 吞吐/P95/P99/显存 | 固定基准脚本 | 调 batch/缓存 |
| 回滚 | 旧模型仍可读取 | 双版本目录 | 保留 last/best |
| 文档 | 输入、输出、限制 | README/模型卡 | 补齐说明 |
import json
from pathlib import Path
required = {"model", "config", "preprocess", "labels", "data_version", "code_version"}
manifest = {
"model": "mnist-cnn-v1",
"config": "configs/mnist-cnn.json",
"preprocess": "normalize-0.1307-0.3081",
"labels": "labels-v1.json",
"data_version": "mnist-v1",
"code_version": "commit-or-date",
}
missing = required - manifest.keys()
if missing:
raise ValueError(f"manifest 缺少: {sorted(missing)}")
Path("runs/release-manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
loaded = json.loads(Path("runs/release-manifest.json").read_text(encoding="utf-8"))
assert loaded == manifest
print("release manifest passed")脚本先检查 manifest 字段,再写入并读回,保证发布记录本身可用。真正的发布流程还应计算权重文件哈希、检查类别映射长度、跑一批金样本并保存输出。把这些检查放进命令行入口,未来换成文本分类或大模型微调时仍能复用。
当数字不一致时的排查顺序
先比较输入 Tensor 和预处理输出,再比较模型结构与 state_dict 键,接着比较 dtype/device 和 eval 状态,最后才怀疑随机性或硬件。若输入已不同,比较 logits 没有意义;若 logits 不同但输入和权重相同,检查版本、算子和精度。每一步都保存一份摘要,避免来回猜测。
发布检查完成后,才可以把 checkpoint 交给别人。对外文档写清楚“适用数据、输入格式、输出解释、已知限制和版本”,这比一句“准确率 98%”更能保护使用者,也为下一章的文本与表示学习打好工程基础。
为你的 MNIST 模型列出权重、结构配置、Normalize、类别映射、数据版本、代码版本和金样本路径。写一个脚本验证字段齐全并能 JSON 回读。
参考结论:manifest 应同时描述模型和入口约定,不能只写 .pt 文件名。脚本缺字段时应停止,写入后读回并比较,发布前还要用固定输入验证 logits/预测在容差内一致。
# 对应本节练习 4:先复制运行,再替换为自己的数据
from pathlib import Path
import json, torch
path = Path("model.pt")
config = {"in_features": 4, "out_features": 3}
model.eval(); gold = torch.randn(2, 4)
with torch.no_grad(): before = model(gold)
torch.save({"model": model.state_dict(), "config": config}, path)
blob = torch.load(path, weights_only=True)
restored = torch.nn.Linear(**blob["config"])
restored.load_state_dict(blob["model"]); restored.eval()
with torch.no_grad(): after = restored(gold)
assert torch.allclose(before, after)验收标准:round-trip 输出在容差内一致,manifest 可解析,非法输入被明确拒绝。
若结果不对:先检查 config/权重是否配套,再检查 eval 模式和预处理,最后检查序列化格式。
| 能力 | 最低证据 | 下一步应用 |
|---|---|---|
| 保存 | 能保存/加载 state_dict 并严格检查 | 迁移学习和长训练 |
| 推理 | 能统一单样本、batch 和后处理 | 服务或命令行入口 |
| 打包 | 能把权重、配置、预处理放入 manifest | 模型版本管理 |
| 复现 | 能用金样本和容差回归 | 升级与回滚 |
| 发布 | 能说明限制、指标和适用范围 | 端到端项目交付 |
推理与训练的接口要像两份合同
训练合同关心梯度、优化器、损失和 batch;推理合同关心输入格式、预处理、版本、延迟和输出解释。训练代码里可以返回 logits、loss 和 history,推理入口则应该隐藏这些内部细节,只返回稳定的结果结构。把两份合同分开,才能在不重新训练的情况下替换服务框架或前端。
state_dict 只保存 Tensor 状态,不能自动保存 Python 代码的结构、类别映射和预处理。manifest 要把这些信息连起来,并在加载时检查 schema。模型升级可以通过迁移脚本重命名键、初始化新头,但每个迁移动作都应写入记录,以便回滚。
发布前的 gold input 不要只选择一个“容易”的数字。至少覆盖一个常规样本、一个边界样本、一个低置信度样本和一个已知错误样本,保存 logits、概率、类别和版本。新模型必须在这些输入上通过约定,再交给更大测试集。
| 样本类别 | 保存内容 | 用途 | 失败后先看 |
|---|---|---|---|
| 常规 | 输入摘要与预测 | 确认基本路径 | 预处理/权重 |
| 边界 | logits 与概率 | 观察阈值敏感性 | 校准/精度 |
| 低置信度 | top-k 结果 | 人工复核入口 | 类别映射 |
| 已知错误 | 真实标签与版本 | 防止错误消失被误判 | 数据/模型变更 |
| 非法输入 | 错误类型与消息 | 保护服务边界 | 输入校验 |
from dataclasses import dataclass, asdict
import json
from pathlib import Path
@dataclass(frozen=True)
class BundleManifest:
model_version: str
preprocess_version: str
labels: tuple[str, ...]
input_shape: tuple[int, int, int]
code_version: str
manifest = BundleManifest(
model_version="mnist-cnn-v1",
preprocess_version="normalize-v1",
labels=tuple(str(index) for index in range(10)),
input_shape=(1, 28, 28),
code_version="2026-08-28",
)
bundle = Path("release/mnist-v1")
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "manifest.json").write_text(
json.dumps(asdict(manifest), ensure_ascii=False, indent=2), encoding="utf-8"
)
loaded = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
assert tuple(loaded["input_shape"]) == manifest.input_shape
assert len(loaded["labels"]) == 10
print("bundle ready:", bundle)Manifest 中的日期只是示例,正式项目应使用真实代码版本或提交哈希。模型目录应同时放入权重、配置、预处理说明、README 和 gold outputs,避免把一个 JSON 当作完整的可运行发布包。
任何一个环节版本不匹配,预测语义就可能改变。
为自己的模型选择常规、边界、低置信度、已知错误和非法输入各一个,保存预期输出或预期错误。
参考结论:每个样本要带输入摘要、预处理版本、模型版本、logits/类别或错误类型。新模型发布前逐项回归,合法输入允许浮点容差,非法输入必须保持清楚的拒绝行为。
# 对应本节练习 5:先复制运行,再替换为自己的数据
from pathlib import Path
import json, torch
path = Path("model.pt")
config = {"in_features": 4, "out_features": 3}
model.eval(); gold = torch.randn(2, 4)
with torch.no_grad(): before = model(gold)
torch.save({"model": model.state_dict(), "config": config}, path)
blob = torch.load(path, weights_only=True)
restored = torch.nn.Linear(**blob["config"])
restored.load_state_dict(blob["model"]); restored.eval()
with torch.no_grad(): after = restored(gold)
assert torch.allclose(before, after)验收标准:round-trip 输出在容差内一致,manifest 可解析,非法输入被明确拒绝。
若结果不对:先检查 config/权重是否配套,再检查 eval 模式和预处理,最后检查序列化格式。
09 · CAPSTONE PROJECT
端到端项目:交付一份可以复跑的 MNIST 实验
最后把环境、数据、模型、训练、评估、调试、保存和推理合并成项目。重点不是追求排行榜,而是完成任务卡、配置、日志、错误样例、检查点和报告,让别人能够在同一约定下重现你的结论。
端到端项目的验收门槛:从“跑过”到“可交付”
MNIST 项目最后不应只展示一张准确率截图。一个可交付的项目要能从干净环境开始,下载或读取明确版本的数据,执行 smoke run,完成训练和验证,保存 best/last checkpoint,重新加载并生成测试报告。目录结构、配置文件、日志、错误样例和 manifest 都是成果的一部分。把训练脚本、评估脚本和报告脚本分开,别人才能只重跑需要的环节。
项目迭代可以采用三条轨道:正确性轨道验证 shape、标签、损失和保存加载;效果轨道比较模型、增强和训练预算;工程轨道记录速度、显存、文件大小和复现成本。三条轨道不要互相替代。准确率上升但 checkpoint 无法加载,是正确性失败;准确率相同但耗时减半,是工程改进;错误类别改变但总体指标不变,仍可能具有业务价值。
消融实验要提前写假设和停止规则。例如先固定 CNN,分别去掉第二个池化、关闭增强、减半通道数;每次只改变一个因素,使用相同验证集和 seed 集合。结果表同时记录主指标、标准差、参数量、训练秒数和最常见混淆。若差异小于运行波动,不要把它写成确定性结论,而要说明证据不足。
| 门槛 | 通过证据 | 常见缺口 | 负责人脚本 |
|---|---|---|---|
| 环境 | 冷启动 smoke check | 版本写死在个人机器 | environment_report.py |
| 数据 | split/标签/transform 审计 | 测试泄漏 | dataset_audit.py |
| 模型 | shape probe 与参数表 | Linear 输入凭感觉 | mnist_cnn.py |
| 训练 | history、best、last | 指标简单平均 | train_mnist.py |
| 评估 | 混淆矩阵与错误样例 | 只报 accuracy | mnist_errors.py |
| 发布 | 重新加载金样本一致 | 预处理未绑定 | final_smoke.py |
def acceptance(report, manifest, required_metrics=("accuracy", "macro_recall")):
for metric in required_metrics:
if metric not in report:
raise AssertionError(f"缺少指标: {metric}")
if report["sample_count"] <= 0 or report["seed_count"] <= 0:
raise AssertionError("报告没有有效样本或 seed")
for key in ("model", "data", "preprocess", "code_version"):
if not manifest.get(key):
raise AssertionError(f"manifest 缺少 {key}")
if report["reload_match"] is not True:
raise AssertionError("重新加载后金样本不一致")
return "accepted"
print(acceptance({"accuracy": 0.97, "macro_recall": 0.96, "sample_count": 1000,
"seed_count": 3, "reload_match": True},
{"model": "cnn", "data": "mnist-v1", "preprocess": "norm", "code_version": "abc"}))最终报告建议按“问题—方法—证据—限制—下一步”组织。先说明要解决什么,再说明数据和模型如何处理,随后给出可复现命令、指标表、错误图和资源账本,最后诚实写出测试集使用边界、随机波动和未验证的假设。报告不是训练日志的堆积,而是帮助读者判断结论是否值得相信的证据链。
把本章项目整理成一页验收单:列出启动命令、数据版本、模型结构、主指标、三个护栏、最佳 checkpoint、错误样例位置和一个尚未解决的限制。
参考结论:验收单应能让别人从干净环境重跑 smoke,再找到训练/评估/发布产物。限制要具体,如“只在 MNIST 上验证”“未做多 seed 统计”“CPU 推理延迟未优化”,下一步与限制一一对应。
完整实现路径:把环境、数据、模型、训练、评估、保存和推理串成可从空目录执行的流水线。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 1:先复制运行,再替换为自己的数据
from pathlib import Path
import json
required = {"environment", "data", "model", "metrics", "checkpoint"}
manifest = json.loads(Path("run/manifest.json").read_text(encoding="utf-8"))
missing = required - manifest.keys()
if missing: raise ValueError(f"manifest missing: {sorted(missing)}")
for name in ("metrics", "checkpoint"):
artifact = Path(manifest[name])
if not artifact.exists(): raise FileNotFoundError(artifact)
print("release evidence complete")验收标准:同学仅按 README 能完成 smoke run、测试、训练、评估与推理。
若结果不对:每个阶段保存输入、输出和退出码;失败时只重跑该阶段。
6.9.1 MNIST 端到端项目:把每个职责接成一条命令
端到端项目从任务卡开始,而不是从模型类开始。任务卡写明输入是一张 28×28 灰度图,标签是 0~9,训练/验证/测试的来源和比例,主指标与护栏,允许的训练时间、设备和停止条件。接着建立目录和配置,运行环境探针,下载并审计数据,先用小批次过拟合,再启动完整训练。每一步都产生一个可回读的文件。
项目代码可以分成五个职责:build_datasets 负责 transform、切分和 DataLoader;build_model 负责结构和参数;train_one_epoch 负责更新;evaluate 负责固定模式下聚合指标;save_artifacts 负责 checkpoint、manifest 和错误样例。职责清楚后,换模型、换 batch 或重跑一个 seed 都不需要复制整份脚本。
完整训练前先做 smoke run:只取少量样本、少量 epoch,确认下载、shape、loss、反向、保存和推理都通过。再做小集过拟合,确认模型有能力学习;最后才扩大到完整数据和正式预算。若正式运行失败,可以根据最后一个成功的工件定位是在数据、模型、循环还是保存阶段,而不是从头猜。
项目完成的标志是证据链闭合,而不是终端显示一个漂亮数字。
| 阶段 | 关键动作 | 交付物 | 通过条件 |
|---|---|---|---|
| 任务 | 定义输入/标签/指标 | task-card.md | 别人能复述 |
| 环境 | 记录版本/设备/seed | env-report.json | smoke test 通过 |
| 数据 | 下载、切分、审计 | manifest + ID 清单 | 无交集泄漏 |
| 基线 | Linear/小 CNN | baseline history | loss/指标可计算 |
| 训练 | 完整 epoch 与验证 | history + best/last | 曲线可重绘 |
| 复盘 | 错误样例与消融 | report.md + tables | 结论有对照 |
| 交付 | 推理入口与版本 | release bundle | 金样本回读 |
from pathlib import Path
import json
import torch
from torch import nn
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms
class Net(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
)
self.head = nn.Linear(32 * 7 * 7, 10)
def forward(self, images):
return self.head(self.features(images).flatten(1))
def evaluate(model, loader, device):
model.eval()
correct = total = 0
with torch.no_grad():
for images, labels in loader:
logits = model(images.to(device))
labels = labels.to(device)
correct += (logits.argmax(1) == labels).sum().item()
total += labels.numel()
return correct / max(total, 1)
def main(epochs=2, batch_size=128):
torch.manual_seed(7)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
])
full = datasets.MNIST("data", train=True, download=True, transform=transform)
train_set, valid_set = random_split(full, [54000, 6000], generator=torch.Generator().manual_seed(7))
test_set = datasets.MNIST("data", train=False, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=batch_size, shuffle=True)
valid_loader = DataLoader(valid_set, batch_size=batch_size)
test_loader = DataLoader(test_set, batch_size=batch_size)
model = Net().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
criterion = nn.CrossEntropyLoss()
history = []
best = 0.0
for epoch in range(1, epochs + 1):
model.train()
for images, labels in train_loader:
optimizer.zero_grad(set_to_none=True)
loss = criterion(model(images.to(device)), labels.to(device))
loss.backward()
optimizer.step()
valid_accuracy = evaluate(model, valid_loader, device)
history.append({"epoch": epoch, "valid_accuracy": valid_accuracy})
if valid_accuracy > best:
best = valid_accuracy
Path("runs").mkdir(exist_ok=True)
torch.save({"model": model.state_dict(), "epoch": epoch}, "runs/mnist-best.pt")
print({"best_valid": best, "test": evaluate(model, test_loader, device)})
Path("runs/history.json").write_text(json.dumps(history, indent=2), encoding="utf-8")
if __name__ == "__main__":
main()示例把验证集从训练集随机划出,并固定 generator seed;正式项目还要保存索引、配置和环境。它展示的是职责骨架,不是追求某个固定准确率的最终脚本。你可以先把 epochs 改成 1、batch 改成 16 进行 smoke run,再逐步增加预算;每次改动都写入运行配置。
把“能跑”提升为“能复盘”
训练脚本应返回或写出结构化 history,不要只在循环里 print 当前 loss。保存 best checkpoint 时记录它依据的验证指标和 epoch,保存 last checkpoint 时记录最近状态;训练结束后重新加载 best,跑一次测试和错误收集。把测试结果与 checkpoint 路径绑定,避免误用最后一轮权重。
如果下载数据失败或显存不足,先降低数据量、batch 或设备要求,保留任务卡和配置不变。错误修复后从最近成功工件继续,不要删除失败日志。长期习惯是保留 runs/run_id,而不是把所有结果覆盖在一个 latest 文件中。
按任务卡、环境报告、数据探针、模型 probe、smoke run、小集过拟合、完整训练、测试和推理的顺序执行。每一步写出输入、输出和保存的文件。
参考结论:合格项目至少有任务卡、env-report、数据 manifest、history、best/last checkpoint、金样本预测、混淆矩阵或错误列表、推理示例和 README。步骤失败时保留日志并能说明属于哪个职责。
完整实现路径:把环境、数据、模型、训练、评估、保存和推理串成可从空目录执行的流水线。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 2:先复制运行,再替换为自己的数据
from pathlib import Path
import json
required = {"environment", "data", "model", "metrics", "checkpoint"}
manifest = json.loads(Path("run/manifest.json").read_text(encoding="utf-8"))
missing = required - manifest.keys()
if missing: raise ValueError(f"manifest missing: {sorted(missing)}")
for name in ("metrics", "checkpoint"):
artifact = Path(manifest[name])
if not artifact.exists(): raise FileNotFoundError(artifact)
print("release evidence complete")验收标准:同学仅按 README 能完成 smoke run、测试、训练、评估与推理。
若结果不对:每个阶段保存输入、输出和退出码;失败时只重跑该阶段。
6.9.2 消融与回归:用对照证明改动真的有用
当基线跑通后,不要一次改变模型、增强、学习率和数据切分。先列出一个问题,例如“卷积是否比展平 MLP 更适合 MNIST”“weight decay 是否减少验证过拟合”“增大 batch 是否提高吞吐而不损害指标”。每个问题写假设、只改变的变量、固定变量、主指标、护栏和停止条件。
消融实验要有共同的 data_version、split、seed 集合和预算。比较多个 seed 的均值、标准差和最差值,记录训练时间、参数量、峰值显存和错误桶。若某版本平均准确率略高但最差 seed 崩溃,不能简单称为更好;如果两个版本指标相同,延迟和可解释性也应进入选择。
回归测试保护的是接口和关键数字。重构 Dataset 后,固定样本的输入和标签不应改变;重构模型后,shape probe、参数键和小集 loss 应符合约定;更换保存方式后,金样本 logits 应在容差内一致。对指标脚本,保存一个手工混淆矩阵和分母,防止未来把行列方向或 batch 聚合改错。
| 问题 | Baseline | 只改一项 | 主要指标/护栏 |
|---|---|---|---|
| 卷积是否有益 | flatten MLP | 替换为 CNN | valid acc / 参数量、延迟 |
| 正则是否有效 | weight_decay=0 | 只加 1e-4 | valid loss / train-valid gap |
| 增强是否合理 | 无增强 | 轻微平移 | 每类召回 / 错误桶 |
| batch 是否合适 | 64 | 改 128 | 吞吐 / 指标、显存 |
| 稳定性是否改善 | 单 seed | 三 seed | 均值/最差 / 时间成本 |
from dataclasses import dataclass, asdict
import json
@dataclass(frozen=True)
class RunSpec:
run_id: str
model: str
weight_decay: float
augmentation: str
batch_size: int
seed: int
runs = [
RunSpec("mlp-s7", "mlp", 0.0, "none", 64, 7),
RunSpec("cnn-s7", "cnn", 0.0, "none", 64, 7),
RunSpec("cnn-decay-s7", "cnn", 1e-4, "none", 64, 7),
RunSpec("cnn-aug-s7", "cnn", 1e-4, "shift", 64, 7),
]
with open("runs/ablation.json", "w", encoding="utf-8") as handle:
json.dump([asdict(run) for run in runs], handle, ensure_ascii=False, indent=2)
for run in runs:
assert run.batch_size > 0
assert run.model in {"mlp", "cnn"}
print("planned runs:", len(runs))这个清单先登记计划,再由训练器读取并生成结果。计划与结果分开,能避免只保留成功的运行;失败的配置也应记录失败原因和资源消耗。比较时从同一份 JSON 读取参数,减少手工复制错误。
回归失败时先判断是数值还是语义
如果 logits 只在最后几位不同,可能是设备或精度差异;如果 shape、类别或混淆矩阵轴改变,则是语义回归;如果验证指标大幅变化但训练输入摘要相同,检查 optimizer、随机状态和模式切换。回归报告要同时展示绝对/相对误差、样本数和第一个不同的层。
消融结果不支持假设也有价值。它说明某个改动在当前数据、预算和实现下没有证据支持,下一步可以换错误桶、补数据或重新定义问题。不要删除失败运行,也不要把“没有显著提升”改写成“模型无效”的普遍结论。
从 CNN、weight decay、增强和 batch size 中选择一个问题,写出 baseline、只改变量、固定项、主指标、护栏和至少两个 seed。保存一份 JSON 运行清单。
参考结论:清单中的每一组应只改变一个变量,data_version、split、预算和评估脚本一致。报告均值、标准差、最差值和资源指标;失败运行保留配置和原因。
完整实现路径:把环境、数据、模型、训练、评估、保存和推理串成可从空目录执行的流水线。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 3:先复制运行,再替换为自己的数据
from pathlib import Path
import json
required = {"environment", "data", "model", "metrics", "checkpoint"}
manifest = json.loads(Path("run/manifest.json").read_text(encoding="utf-8"))
missing = required - manifest.keys()
if missing: raise ValueError(f"manifest missing: {sorted(missing)}")
for name in ("metrics", "checkpoint"):
artifact = Path(manifest[name])
if not artifact.exists(): raise FileNotFoundError(artifact)
print("release evidence complete")验收标准:同学仅按 README 能完成 smoke run、测试、训练、评估与推理。
若结果不对:每个阶段保存输入、输出和退出码;失败时只重跑该阶段。
6.9.3 报告与下一步:把一次练习变成可以继续的资产
项目报告应从结论开始,但每个结论都能回到证据。先写任务与边界,再写数据版本和切分,接着写模型结构、输入输出 shape、损失与 reduction、训练预算、曲线、最佳 checkpoint、测试指标、错误样例和消融。最后写限制和下一轮假设。不要用一张准确率截图替代整条证据链。
报告中的表格要标明分母和单位。例如测试准确率是正确样本数除以全部测试样本,宏 F1 是先按类计算再平均,吞吐是样本/秒,显存是峰值还是平均。不同 seed 结果放在同一表中,并标出使用的 checkpoint 和配置版本。读者应该能区分观察、解释、结论和仍未验证的猜想。
把代码和文档交付给别人前,运行一次从新进程开始的推理 smoke test:创建干净模型、读取 manifest、加载权重、应用预处理、预测金样本、比较输出并打印版本。若需要下载数据,说明缓存路径和离线替代方案;若模型只能在 CUDA 上运行,明确设备和显存要求。交付的价值在于可使用、可回滚、可解释。
| 章节 | 必须回答 | 对应文件 | 验收问题 |
|---|---|---|---|
| 任务 | 输入、标签、指标、限制 | task-card.md | 目标是否可复述 |
| 数据 | 来源、版本、切分、审计 | manifest/IDs | 是否存在泄漏 |
| 模型 | 结构、参数、shape、模式 | model.py/probe | 输出是否匹配 loss |
| 训练 | optimizer、预算、曲线 | history/checkpoint | 最佳依据是什么 |
| 评估 | 分母、混淆、错误桶 | metrics/errors | 错误是否可定位 |
| 交付 | 推理、版本、限制、回滚 | release/README | 新进程能否回读 |
from pathlib import Path
import json
import torch
def check_release(predictor, sample, expected_index, manifest_path):
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
result = predictor(sample)[0]
if result["index"] != expected_index:
raise AssertionError(f"预测 {result['index']} != 期望 {expected_index}")
if not 0.0 <= result["probability"] <= 1.0:
raise AssertionError("概率超出范围")
required = {"model", "preprocess", "labels", "code_version"}
missing = required - manifest.keys()
if missing:
raise ValueError(f"manifest 缺少 {sorted(missing)}")
return {"index": result["index"], "version": manifest["model"]}
# result = check_release(predictor, sample, expected_index=7, manifest_path="release/manifest.json")
# print(result)示例中的 f-string 使用了嵌套引号,运行前可以把内部表达式改成临时变量以提高可读性;测试的重点是校验 manifest、预测索引和概率范围。交付时把真实 predictor、金样本和 manifest 路径接上,并把输出写入回归文件。
从 PyTorch 实战走向大模型
下一阶段学习文本与表示时,输入会从图像 Tensor 变成 token id 和 embedding,模型从 CNN 变成 Embedding、Attention 和 Transformer block,但本章的工程纪律不变:先写 shape 和 dtype 契约,明确 loss 与有效分母,区分 train/eval 与 no_grad,记录数据/代码/模型版本,保存可回读的 checkpoint,并用错误样例检验结论。
你不需要在进入下一章前背完所有 API。只要能独立完成一次 MNIST 或小型分类项目,能解释每个函数的职责、能定位一条错误、能恢复一个 checkpoint、能写出公平的消融实验,就已经具备继续学习 token、Embedding、Transformer 和微调的基础。
把本章项目整理成一个可分享目录,包含任务卡、环境报告、数据 manifest、模型代码、训练 history、best/last checkpoint、测试指标、错误样例、消融清单、推理 smoke test 和 README。
参考结论:第三方应能从 README 找到运行命令和版本,从 manifest 知道输入/输出约定,从 history 重绘曲线,从 checkpoint 回读模型,并从错误样例和消融表理解限制。缺少任何一项,就列为下一轮补齐任务。
完整实现路径:把环境、数据、模型、训练、评估、保存和推理串成可从空目录执行的流水线。 本题不要只交一段说明:应同时提交输入样例、可运行实现、输出或断言,以及失败时的明确状态。
# 对应本节练习 4:先复制运行,再替换为自己的数据
from pathlib import Path
import json
required = {"environment", "data", "model", "metrics", "checkpoint"}
manifest = json.loads(Path("run/manifest.json").read_text(encoding="utf-8"))
missing = required - manifest.keys()
if missing: raise ValueError(f"manifest missing: {sorted(missing)}")
for name in ("metrics", "checkpoint"):
artifact = Path(manifest[name])
if not artifact.exists(): raise FileNotFoundError(artifact)
print("release evidence complete")验收标准:同学仅按 README 能完成 smoke run、测试、训练、评估与推理。
若结果不对:每个阶段保存输入、输出和退出码;失败时只重跑该阶段。
| 能力 | 完成证据 | 第 7 章的迁移 | 大模型对应 |
|---|---|---|---|
| 环境 | 版本/设备/seed 报告 | Tokenizer 与库版本 | 训练框架与硬件 |
| 数据 | Dataset/Loader/切分审计 | 文本 Dataset | 语料、packing、mask |
| 模型 | nn.Module、shape probe | Embedding/Attention | Transformer block |
| 训练 | train/eval、指标、checkpoint | 长序列训练 | 梯度累积/混合精度 |
| 评估 | 错误样例、消融、回归 | 文本分类/检索 | 基准与安全评测 |
| 交付 | manifest、推理 smoke test | 模型与 tokenizer 打包 | 服务化与回滚 |