←返回大模型学习资料
学习资料第 6 章 · PyTorch 实战

CHAPTER 06 · PYTORCH PRACTICE

PyTorch
实战

上一章把神经网络的计算链讲清楚,本章把它落到可以运行、调试、保存和复盘的 PyTorch 程序。你会从环境检查和第一个 Tensor 脚本开始,逐步写 Dataset、DataLoader、nn.Module、训练/评估循环,完成 MNIST 图像分类,再把错误诊断、性能记录、检查点和推理交付串成一条完整路径。每个示例都强调输入输出契约和可验证的证据。

难度:实战入门建议:先跑通再优化代码:Python 3.11+ / PyTorch小节:27 张独立学习卡片
TEXTBOOK MAP · PYTORCH PRACTICE

本章学习地图

从一条 import 语句走到能复盘的 MNIST 训练项目。

阅读方式:先按大节顺序学习;需要回看时可从地图跳到某个主题。页面会把每个大节显示为一张独立“书页”,底部的上一节/下一节只在大节之间翻页。

本章的判断标准:不是代码能运行就算完成,而是环境、输入形状、标签 dtype、模型参数、训练/评估模式、指标分母、检查点和推理入口都能被验证。示例遵循 PyTorch 官方入门路径,并在每个阶段加入适合初学者的断言、日志和失败复盘。

本章可运行实验 · 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,并在报告中说明是否开启确定性算法及其速度代价。

表 6.1F · 环境交接单字段
字段示例验证方式缺失后果
python3.12.xpython --version语法/轮子不兼容
torch2.ximport torch; print(torch.__version__)API 或权重加载差异
devicecuda:0 / cputorch.cuda.is_available()性能和显存无法解释
data_rootdata/mnist检查文件与哈希数据版本混淆
seed2026manifest.json结果难以复现
commandpython train.py从干净终端执行交接依赖个人记忆

完成后做一次“冷启动演练”:关闭当前终端,进入项目根目录,新建一个临时虚拟环境,只按交接单安装依赖并运行最小脚本。若冷启动失败,把失败步骤补回文档。这个动作看似花时间,却能尽早暴露路径写死、缺少依赖、未下载数据或环境变量未说明等隐性成本。

ENV LAB 6.1.4制作一份环境 manifest

为自己的机器写出至少八个字段的 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/ 放快速断言。目录不是越复杂越专业,关键是别人能猜到文件的职责。

图 6.1 · PyTorch 项目的第一条路径自制示意:环境、代码、证据彼此独立
创建 venv隔离依赖
→
安装 PyTorch匹配平台
→
运行探针验证 Tensor
→
保存报告版本与设备

先建立一条最短可验证路径,再添加数据集和模型,错误才容易归因。

表 6.1A · 环境安装的检查点
动作目的应记录什么常见误区
创建虚拟环境隔离项目依赖环境路径、Python 版本终端没有真正激活
安装 PyTorch获得 Tensor 与 autograd安装命令、平台选择CPU/CUDA 包混用
查询 pip确认包属于当前解释器python -m pip --version调用了另一个 Python
保存依赖后续重建环境requirements/锁文件只记包名不记版本
运行探针确认核心功能版本、设备、输出摘要只看 import 成功
PYTHON · bootstrap_check.py
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。把标准写成检查清单,初学者遇到问题时更容易定位缺哪一步。

ENV LAB 6.1.1为自己的项目写安装卡

创建一个独立环境,写下 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 报告均值和波动。

表 6.1B · 运行条件报告
字段示例为什么重要保存位置
Python/torch3.11 / 2.xAPI 与数值实现可能变化环境报告
devicecpu / cuda:0速度、显存和算子可用性运行 manifest
seed7、19、31比较初始化与采样波动每次运行配置
deterministicTrue/False解释速度与复现差异日志开头
数据版本mnist-v1避免数据换了却误判模型数据清单
PYTHON · environment_report.py
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 进行速度测量。设备选择是配置,不是业务逻辑。把它作为参数传给函数,训练、验证和推理才可以在不同机器上复用。

ENV LAB 6.1.2比较两个运行条件

在同一环境中分别记录 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 报告,记录运行时间、版本和关键数值;这份报告会成为后续训练曲线和检查点的起点。

表 6.1C · 第一个脚本的验收条件
验收项通过条件失败时先查
输入(B,F)、float32、设备正确构造方式与 .to(device)
模型参数出现在 parameters()是否注册为 nn.Parameter
输出(B,C) 且全为有限值Linear 维度与输入范围
loss标量、可 backwardreduction 与标签形状
梯度关键参数有有限梯度requires_grad、是否断图
报告JSON 可再次读取路径和编码
PYTHON · first_torch_run.py
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;如果它失败,暂停增加功能。小项目中多花几分钟写这种测试,会省下后续数小时的盲目调参。

ENV LAB 6.1.3完成你的 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 构建和实际设备,最后才查业务代码。

表 6.1D · 本节完成标准
能力最低证据迁移到后续实践
环境能创建隔离环境并记录版本安装 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 可以揭示这种差异。

表 6.1E · 安装说明的最小字段
字段示例用途
平台Windows/Linux/macOS选择正确轮子
Python3.11.x解释兼容性
计算平台CPU/CUDA 12.x说明速度与驱动
安装来源官方命令/镜像追溯依赖
验证命令python scripts/...判断安装完成
项目根目录绝对路径或推导规则避免相对路径错
PYTHON · environment_manifest.py
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 和梯度大小。

表 6.2F · Tensor 读码顺序
检查问题典型证据修复方向
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_ptrview/clone/contiguous
grad是否需要反向requires_grad、grad_fndetach/no_grad
数值是否有限isfinite、min/max检查输入与学习率
PYTHON · tensor_contract.py
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。

TENSOR LAB 6.2.4追踪一个广播结果

写出 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,先问被压平的轴是否具有相同语义。

图 6.2 · Tensor 从创建到取值自制示意:元素、轴和视图
创建dtype/shape
→
标轴B、T、F
→
索引保留语义
→
断言形状/范围

每次取值都要知道是否减少了轴,以及结果是否仍能交给下一个模块。

表 6.2A · 创建与索引的语义
写法结果是否可能共享/降维检查重点
torch.tensor(data)从数据创建 Tensor通常复制dtype、复制成本
torch.zeros(shape)全零 Tensor新分配设备与类型
x[0]取第一项/行可能少一个轴是否误删 batch
x[:, 1:3]保留所有行的列切片常为视图范围是否含右端
x[mask]筛选有效元素常改变形状位置是否仍可追踪
x.clone()复制数据不共享存储是否需要复制梯度关系
PYTHON · tensor_indexing.py
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 不需要被覆盖的旧值。初学阶段优先使用非原地表达式,等性能测量确实需要时再逐个引入,并用梯度测试保护。

TENSOR LAB 6.2.1保持和删除 batch 轴

创建一个形状为 (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 求平均是统计样本,对特征求平均是压缩表示,对时间求平均则可能忽略长度差异,三者不能混为一谈。

表 6.2B · 运算与形状契约
运算输入示例输出示例主要风险
逐元素加(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)标量或保留轴数值随长度/批次变化
PYTHON · tensor_shapes.py
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 的输入维度,并在模型卡里写明新维度来源。

TENSOR LAB 6.2.2预测广播结果

写出 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()。

表 6.2C · dtype、device 与内存布局
属性常见值适用场景错误信号
dtypefloat32普通连续特征与模型参数精度/速度不合适
dtypelong/int64类别索引、词表 id传给浮点算子或损失
dtypeboolmask、条件筛选被当成数值求平均
deviceCPU/CUDA数据与模型计算位置Expected all tensors on same device
layoutstride/contiguous视图与底层性能view 失败或速度异常
PYTHON · dtype_device_layout.py
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() 更容易追踪。

TENSOR LAB 6.2.3写一份 Tensor 契约

为你准备在下一节使用的一个 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。

表 6.2D · 本节完成标准
能力最低证据下一步应用
索引能解释整数索引、切片和 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 大致正确;要验证标签、预处理和指标,必须使用手工构造的样本。把“结构测试”和“数据测试”分开,失败时才知道应该看哪个层次。

表 6.2E · Tensor 三种视角的检查清单
视角问题推荐检查典型后果
数值范围、均值、有限性min/max/finite溢出、尺度错
形状轴的名字和可变性shape/ndim/assert广播或 flatten 错
类型float/long/bool 是否匹配dtype 检查损失或索引报错
设备模型和数据在哪里device 对照跨设备失败
生命周期梯度、视图、复制grad_fn/clone/stride断图或原地覆盖
PYTHON · tensor_assertions.py
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 里临时转换更容易复用。

图 6.2E · Tensor 调试的三道门自制示意:数值、形状、生命周期
数值门finite/range
→
形状门axis/shape
→
生命周期门grad/view/device

三道门全部通过后,再把 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,并记录吞吐和内存。

表 6.3F · 数据管道验收问题
层级要回答的问题建议证据失败信号
索引样本 ID 是否稳定index.csv / hash重跑顺序变化
标签标签从哪张表生成类别映射、范围断言空值或错位
切分是否存在组/时间泄漏组交集检查验证异常高
transform训练和评估是否有边界同一样本前后对照评估仍随机
collate可变长度如何处理padding mask/长度静默截断
吞吐瓶颈在数据还是模型batch 等待时间GPU 空转
PYTHON · dataset_audit.py
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 时,可以明确哪些缓存需要失效,而不是拿旧结果继续训练。

DATA LAB 6.3.5画出一条样本的旅程

任选一个图像或文本样本,从索引文件开始写出它经过读取、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、特征、标签和时间,避免把标签或未来字段拼进输入。

表 6.3A · Dataset 返回值契约
字段类型示例用途必须检查
feature/imagefloat Tensor模型输入shape、范围、dtype
labelint 或 long Tensor损失目标范围、编码、缺失
id字符串错误定位、去重唯一性、脱敏
length/masklong/bool Tensor变长序列padding 约定
meta字典分组、时间、版本不泄漏未来信息
PYTHON · simple_dataset.py
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 哈希,确保“同一个数据集”不仅是一个文件夹名字。

DATA LAB 6.3.1实现一个可审计 Dataset

为一个包含 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。

表 6.3B · DataLoader 关键参数
参数作用训练常用倾向验证风险
batch_size一次取样数量受显存与吞吐约束改变 loss 分母
shuffle每轮重排索引通常开启结果难对齐
drop_last丢弃不足一批按任务决定样本未被评估
num_workers并行取样进程测量后再增加随机性/文件句柄
collate_fn自定义 batch 拼接变长数据必需padding/mask 错位
pin_memory辅助 CPU→GPU 搬运配合测量使用不是准确性开关
PYTHON · loader_probe.py
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 顺序。

DATA LAB 6.3.2检查最后一个 batch

创建 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,并在训练开始时拒绝版本不匹配的数据。若允许过滤坏样本,报告过滤规则和数量,不能让样本数在日志里悄悄变化。

表 6.3C · 数据切分与审计
任务情形推荐切分重点审计错误后果
独立表格样本分层随机类别比例、重复行少数类波动
时间序列按时间向前未来字段、窗口重叠回测虚高
用户/文档分组按 group 切分ID 交集、近重复泄漏泛化
图像数据按主体/来源切分尺寸、坏图、增强背景记忆
文本数据按文档/时间切分tokenizer、长度、脱敏模板泄漏
PYTHON · split_audit.py
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 不能修复切分规则错误,只能揭示随机波动。

预处理统计量只能从训练集来

标准化均值、方差、词表、类别映射和阈值都属于从数据中学习出来的状态。它们应该只使用训练集拟合,再应用到验证和测试;否则验证信息会泄漏到训练流程。保存这些状态时带上数据版本和特征顺序,线上推理必须读取同一份文件。

质量审计还要关注人工流程。标签是否由同一规则生成,复核者是否看到预测结果,测试集是否被反复查看,都会影响结论。把测试集当作一次最终验收,而不是日常调参表,是从练习走向真实项目的重要纪律。

DATA LAB 6.3.3找出一次数据泄漏

设计一个含用户 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 实体交集为空。

表 6.3D · 本节完成标准
能力最低证据进入模型前要做到
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 和文件路径后抛出包含上下文的异常。

表 6.3E · DataLoader 状态与风险
状态改变什么适合用途需记录
sampler索引顺序/频率分层、不平衡类计数与 seed
batch_sampler一批索引的组成长度分桶、组 batchbatch 规则
num_workers读取并行度IO 瓶颈worker seed/内存
prefetch提前准备 batch隐藏 IO 延迟缓存占用
drop_last是否保留尾批稳定 batch实际样本数
PYTHON · loader_seed.py
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 而产生无关错误。

DATA LAB 6.3.4比较两个采样策略

为一个 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 表,再写代码,能把“维度不匹配”从运行时错误变成设计时决策。

表 6.4F · Module 结构检查
对象是否注册是否进 state_dict适用场景
nn.Parameter是是可学习权重
ModuleList子模块是是循环堆叠层
ModuleDict子模块是是按名称选择分支
register_buffer否(不可学习)是均值、位置编码常量
普通 list否否仅临时 Python 对象
forward 局部 Tensor否否中间激活
PYTHON · module_contract.py
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,确认输出差异符合预期。模块测试通过后,才把它接入长训练;否则训练曲线同时混入结构错误和优化问题,排查成本会迅速上升。

MODULE LAB 6.4.4找出未注册的层

构造一个包含三个 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、设备和训练/评估差异要写成接口。

表 6.4A · PyTorch 中的状态类型
对象是否训练是否进 state_dict典型用途
nn.Parameter是是可学习权重、温度
子模块内部参数决定递归保存Linear、Conv、Norm
register_buffer否是均值、位置表、运行统计
普通 Tensor 属性否否临时常量或缓存
普通 Python 列表不自动不自动仅适合无状态配置
PYTHON · module_registration.py
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 迁移、错误日志和后续可视化。

MODULE LAB 6.4.5写模块接口说明

为一个两层 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 模式。

MODULE LAB 6.4.1找出没有注册的层

比较把两个 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 混在一起做隐式广播,必要时使用断言说明每个分支的轴。

表 6.4B · 选择模型组织方式
结构需求推荐写法可读性优势测试重点
纯直线nn.Sequential层顺序一目了然首尾 shape
多个分支自定义 Module/forward命名每条路径合并前后 shape
跳连残差自定义 forward显式 x+f(x)分支 dtype/device
共享权重同一子模块多次调用参数只有一份梯度是否累加合理
多任务输出字典/数据类字段语义清楚每个 loss 的分母
PYTHON · custom_forward.py
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 和输出类别数都属于模型版本的一部分;只保存类名而不保存配置,往往无法准确重建。

MODULE LAB 6.4.2把直线结构改成残差

用一个 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 或激活大小,所以仍需要固定输入的探针。

表 6.4C · 一次 shape probe 应记录什么
字段示例用途异常提示
module nameencoder.0定位层名称不稳定
input/output shape(8,4)→(8,16)验证轴契约batch/feature 颠倒
dtype/devicefloat32/cuda检查兼容性混用设备或精度
mean/std0.01/0.83观察尺度激活爆炸/塌缩
finite count全部有限定位 NaN/Inf上游输入或参数坏
parameters272估算模型大小层未注册
PYTHON · shape_probe.py
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,并再次运行一次确认模型输出没有被探针改变。

MODULE LAB 6.4.3为模型写体检报告

给一个三层 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 模式。

表 6.4D · 本节完成标准
能力最低证据下一步用途
注册状态能区分 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 键都会改变。报告中说明共享是有意设计还是偶然复制,避免后续消融无法解释。

表 6.4E · 模型状态验收
动作观察通过条件失败含义
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。

表 6.5F · 训练功能的加入顺序
阶段只加入什么新增证据通过后再做什么
baseline全精度/单设备小批次可过拟合完整数据训练
logginghistory 与耗时曲线可重绘checkpoint
scheduler学习率策略lr 曲线正确早停
accumulation多 micro-batch有效 batch 与基线接近AMP/clip
AMP混合精度无 NaN、速度/显存对照多卡
PYTHON · accumulation_step.py
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。

LOOP LAB 6.5.5安排一次功能回归

给训练循环依次加入 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 分开,每个函数有清晰输入输出。

图 6.5 · 一个 epoch 的职责边界自制示意:训练与评估共享数据,行为不同
DataLoaderbatch/ID
→
model.train记录图
→
loss.backward梯度
→
optimizer.step更新
→
model.eval只读评估

训练和评估的模式、梯度和指标分母都要在日志中可见。

表 6.5A · train loop 与 eval loop
步骤训练评估容易漏掉什么
模式model.train()model.eval()Dropout/Norm 行为
梯度需要记录与 backwardno_grad/inference_mode验证图占内存
更新zero_grad→backward→step不更新参数梯度累积
指标记录训练统计固定口径聚合batch 平均偏差
输出可保存梯度/学习率可保存预测/错误模式未恢复
PYTHON · loop_skeleton.py
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、正确数、样本数应保留原始累计量,方便复核分母。

LOOP LAB 6.5.1补全一个 batch 的顺序

把一个线性分类器接到 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 路径绑定,未来才能回答“这条曲线对应哪一次运行”。

表 6.5B · 训练日志字段
字段聚合方式用途常见误读
train_loss有效元素加权平均优化过程把 batch 平均当样本平均
valid_loss固定验证集分母选择 checkpoint验证集每轮变化
accuracycorrect / total分类直觉忽略类别不平衡
macro_f1先按类算再平均少数类护栏与 micro 混淆
lr记录当前 optimizer 值解释曲线日志滞后一个 step
samples_seen累计真实样本公平比较预算只用 epoch
PYTHON · metric_logger.py
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 或数据类,能让多个实验脚本输出可比较的记录。

LOOP LAB 6.5.2修正错误的指标聚合

某验证集有两个 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,确认模型、优化器和数据预处理都接回了正确版本。

表 6.5C · checkpoint 内容与用途
字段推理是否必需继续训练是否必需说明
model_state是是参数与 buffer
optimizer_state否是momentum/Adam 状态
scheduler_state否视调度器学习率时间表
epoch/step记录即可是恢复预算与日志
best_metric报告有用是早停比较基线
config/data_version是是重建接口
random_state否建议近似复现采样
PYTHON · checkpoint_manager.py
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 后运行一次,并保存预测与错误样例。

LOOP LAB 6.5.3设计一次恢复测试

写出保存前后需要比较的四项证据,验证从 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 复现。

表 6.5D · 本节完成标准
能力最低证据下一步应用
训练循环能按顺序完成一个 epochMNIST 训练
评估模式能区分 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、混淆矩阵和错误样例可以共享预测结果,避免多次运行模型得到不同的随机输出。

表 6.5E · 一个 epoch 的状态记录
状态输入输出日志字段
epoch_start配置/模型模式train 状态epoch、seed
batch_readyCPU batchdevice batchshape、样本数
forwardfeatureslogits有限性、耗时
backward标量 lossgrad梯度范数
stepgrad/optimizer新参数lr、裁剪次数
eval_end预测/目标指标分母、best
PYTHON · train_state_log.py
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、梯度累积或分布式训练越容易。

图 6.5E · 训练循环的回放点自制示意:每个箭头都能对应日志
batch样本数/ID
→
forwardshape/finite
→
backwardgrad norm
→
steplr/参数变化
→
eval分母/错误

训练失败时沿日志回到第一个异常状态。

LOOP LAB 6.5.4写一个 one-step 记录

给一个 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 后的概率当作未经校准的可信度证明。先验证一个样本的完整链路,再批量生成图表。

表 6.6F · MNIST 报告最小指标集
指标定义用途需要附带
accuracy正确数/总数总体直觉总样本数
macro recall各类召回平均类别公平每类支持数
confusion真实×预测计数错误方向轴约定
error confidence错误样本最大概率过度自信分桶规则
params/time参数与 wall-clock资源权衡设备和 batch
PYTHON · mnist_report_row.py
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"]),
    }

当结果表同时包含主指标和护栏指标时,模型选择会更诚实。一个模型即使准确率略高,也可能参数量、耗时或高置信度错误明显增加;这些信息会影响是否值得继续优化。最后一次测试只在模型和超参数冻结后运行,并把预测文件与报告版本绑定。

MNIST LAB 6.6.5写出公平对照协议

为三个模型写一份五行以内的实验协议,固定数据切分、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 而不是最终结果。

图 6.6 · 一张 MNIST 图片的输入链自制示意:像素、通道、批次与标签
PIL 图像28×28
→
ToTensor1×28×28
→
DataLoaderB×1×28×28
→
CNN10 logits

标签是 0~9 的 long 索引,不需要在输入端做 one-hot。

表 6.6A · MNIST 输入契约
对象类型/形状含义常见错误
原始图像PIL / 28×28灰度像素误当 RGB 三通道
模型输入float32 (B,1,28,28)批次、通道、高、宽HWC/CHW 颠倒
标签long (B,)0~9 类别索引float 或 one-hot
训练 transform转换+可选增强只用于训练验证也随机增强
评估 transform确定性转换固定真实分布重新估计 Normalize
PYTHON · mnist_dataset_probe.py
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) <= 9

Normalize 后像素不再严格位于 0 到 1,而是以训练统计为中心的浮点范围;因此不要把归一化后的数值直接当作原始灰度显示。可视化时先做逆变换,或明确图像是“模型输入张量”。标签从 torchvision 返回的 Python 整数或 Tensor 形式可能随版本/包装改变,进入损失前统一成 long 并检查范围。

增强与切分的边界

手写数字的轻微平移、裁剪或旋转可能保持标签不变,但过强旋转会改变数字含义。第一次实验先不加增强,建立一个稳定基线;之后一次加入一种增强,比较验证错误类型。测试集不使用随机增强,这样不同模型看到的是同一验收分布。

为了快速迭代可以使用 Subset 或固定索引,但不能在每次运行随机重新抽取而不记录 seed。若划分验证集时用 random_split,保存生成的索引或 generator 状态,确保后续实验使用相同验证样本。

MNIST LAB 6.6.1验证一张输入图片

加载 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 上快速运行,方便调试和比较,而不是一开始追求复杂架构。

表 6.6B · CNN 形状与参数量
模块输出形状参数量(含偏置)检查点
输入(B,1,28,28)0通道/范围
Conv 1→16, 3×3(B,16,28,28)160padding=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类别轴
PYTHON · mnist_cnn.py
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 和推理代码决定。

图 6.6B · CNN 的尺寸变化自制示意:空间减小,通道增加
1×28×28输入
→
16×28×28Conv/ReLU
→
16×14×14Pool
→
32×7×7Conv/Pool
→
10logits

通道数和空间尺寸是一对取舍:更多通道保存方向,池化扩大单位像素的感受野。

先过拟合一小批,再扩大训练

选择 16 或 32 张 MNIST 图片,让 CNN 训练到几乎零训练 loss,是检查模型、标签、损失和优化器的快速方法。若小批次都过拟合不了,先查 shape、标签、学习率和 train/eval;不要急着增加层数。小批次测试通过后,再使用完整训练集和验证集,曲线才有解释。

参数量计算可以用 sum(p.numel() for p in model.parameters()),但显存还包括中间激活和 optimizer 状态。记录 batch size、输入 shape、参数量和每秒 batch,比较不同网络时才公平。

MNIST LAB 6.6.2手算 CNN 的 Linear 输入

对于两个 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;若所有数字在归一化后都偏暗,可能是均值方差或显示逆变换错误;若验证异常高,可能是重复样本或索引泄漏。每个改进都应回到相同的验证集,报告错误桶、主指标和资源代价。

表 6.6C · MNIST 评估与错误桶
分析项记录内容能回答什么注意事项
混淆矩阵真实×预测计数哪些数字互相混淆标明行列方向
错误图像索引、标签、预测、置信度视觉模式与边界逆归一化显示
每类召回TP / 真实支持数少数类是否被忽略保留支持数
置信度分桶正确/错误的概率区间是否过度自信不等同校准证明
版本对照结构、增强、seed改动是否有效一次只改一项
PYTHON · mnist_errors.py
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、采样和增强是否改善;如果高置信度错误集中出现,增加校准评估或保留人工复核阈值。每个假设写成“只改一个因素、预期改变什么、若不改变如何解释”的实验卡。

比较模型时同时记录准确率、宏平均指标、参数量、每批耗时和最常见错误。一个稍高准确率但延迟翻倍的模型不一定适合交付;一个总体相同但减少关键错误的模型可能更有价值。把主指标与护栏指标分开,报告才不会被一个数字绑架。

MNIST LAB 6.6.3写一张错误分析卡

从测试结果中选择一个混淆对(如 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,再核对卷积后的展平维度。

表 6.6D · 本节完成标准
能力最低证据下一步应用
读数据能解释图像/标签 shape 与 transform其他图像数据集
搭 CNN能按公式计算空间尺寸和参数更深卷积模块
跑基线能在小批次过拟合后训练全量正式训练预算
看错误能生成混淆矩阵和错误样例消融与模型选择
写结论能把错误桶变成单变量实验端到端项目报告

MNIST 训练的三个基线

第一个基线可以是像素展平后接 Linear,用来验证数据、标签、损失和指标;第二个基线是小型 MLP,验证非线性和参数容量;第三个基线是 CNN,验证局部结构先验。三个基线共享 transform、切分、训练预算和评估脚本,才能把结构收益与其他改动分开。

基线不需要追求最优准确率,但必须有稳定的 smoke run、小集过拟合和完整训练记录。若 Linear 已经满足练习目标,CNN 的额外复杂度要用错误减少、速度或资源优势解释。若 CNN 提升明显,查看混淆矩阵和边缘错误,确认提升不是由数据泄漏或不同预处理造成。

图像显示和模型输入是两回事。Normalize 后的 Tensor 用于计算,显示时需要逆归一化;如果直接把负值截断到 0~1,图像可能看起来全黑,误导你判断模型。保存错误样例时同时保留原始索引、真实标签、预测和预处理版本。

表 6.6E · 三种 MNIST 基线
基线结构假设优点主要限制
Linear像素组合即可分离最快、易审计缺少局部结构
MLP全局非线性组合结构简单忽略空间邻域
CNN局部共享模式更适合图像shape/资源更复杂
增强版 CNN结构+不变性可能更稳需要验证增强合理
PYTHON · mnist_baseline_compare.py
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 时写入模型名称和配置哈希。这样即使运行顺序改变,结果仍然可追溯。

图 6.6E · 从基线到 CNN 的证据链自制示意:结构升级要有对照
Linear像素基线
→
MLP非线性
→
CNN局部共享
→
错误复盘决定是否保留

每次升级只改变结构,数据和评估口径保持一致。

MNIST LAB 6.6.4安排三个基线实验

为 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、缓存、融合或设备搬运。速度提升若伴随指标下降或显存超预算,就不是可接受的优化。

表 6.7F · 异常类型与第一动作
症状第一动作不要先做保留证据
shape error打印每个边界 shape盲目 reshape输入/输出契约
device mismatch比较所有 Tensor device到处 .to()设备策略
loss NaN定位首次非有限 batch直接降低十倍 lr输入/梯度快照
loss 不动检查小批次过拟合立即加深模型参数变化、梯度
GPU 空转拆数据等待与计算时间盲目加 worker吞吐账本
显存上涨检查图和缓存引用只调用 empty_cache分配曲线
PYTHON · finite_guard.py
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、版本、设备和预期结果都写在同一段说明中。修复后把原错误加入回归测试,确保未来重构不会让它回来。好的调试记录不是“我试了很多办法”,而是观察、假设、单变量修复和验证结果的链条。

DEBUG LAB 6.7.5建立 NaN 回归测试

设计一个会产生 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、模式和指标分母。

图 6.7 · PyTorch 错误的缩小路径自制示意:先保留证据,再删变量
完整运行保留日志
→
固定 batch去随机
→
单模块查边界
→
最小断言加回回归

每次只删除一个变量,避免“修好了但不知道为什么”。

表 6.7A · 错误类型与第一检查
症状第一检查最小复现不要先做什么
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、数据顺序同一金样本两次运行只看最终准确率
指标异常高标签泄漏、切分交集打乱标签做对照继续调参
PYTHON · minimal_repro.py
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 进入第三方库很深,先在进入库前打印参数,通常比修改库源码更有效。

修复后不要只确认“这次能运行”,还要复现原先的失败输入,确认错误消失且核心输出没有被意外改变。保存一份失败样本和一份通过样本,测试的价值就在于未来还能触发同一个边界。

DEBUG LAB 6.7.1把一个 shape 错误缩小

故意把 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。测试的目标是保护关系,不是强迫不同硬件逐位相同。

表 6.7B · 一个 PyTorch 项目的测试层次
层次测试对象示例断言失败说明
数据单元Dataset/transform字段、范围、ID原始数据或预处理错
batch 单元collate/loadershape、mask、总数拼接或 padding 错
模型单元Module/forward输出 shape/finite结构或参数错
损失单元loss/metric极端样本性质类别轴/分母错
流程单元one step/checkpoint参数变化/回读训练状态错
回归测试历史失败样例错误不再复现重构引入变化
PYTHON · test_contracts.py
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 版本和容差。升级版本后先跑契约测试,再比较金样本输出;若只是浮点实现变化,调整容差要有理由,不能用无限放宽来掩盖语义改变。

DEBUG LAB 6.7.2为 Dataset 和模型各写两条测试

为一个 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、模型和预算下比较。

表 6.7C · 性能账本字段
指标测量方法解释护栏
吞吐samples/token ÷ 秒单位时间处理量指标不下降
数据等待取 batch 到开始 forwardDataLoader 是否瓶颈worker/缓存
forward/backward同步计时窗口模型计算成本固定 batch/shape
峰值显存cuda max memory是否接近上限避免 OOM
P95/P99 延迟多次推理分位数尾部体验输入长度分桶
checkpoint 大小文件大小/保存秒数交付与存储成本回读可用
PYTHON · profile_window.py
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 契约测试,再比较资源指标。

DEBUG LAB 6.7.3写一张性能基线卡

固定一个模型和 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→梯度→优化器顺序定位。

表 6.7D · 本节完成标准
能力最低证据下一步应用
最小复现能把错误缩到固定 batch独立修复训练问题
断言能在数据/模型/loss 边界设门槛持续集成测试
回归能保存历史失败样例安全重构
性能能拆分计时并使用 profiler选择设备与 batch
资源能报告吞吐、显存和延迟推理发布

调试不是修补,是建立可重复的证据

每次调试都写出四句话:观察到什么、最小复现是什么、假设哪一个边界坏了、修复后用什么测试证明。比如“验证准确率异常高;固定样本的标签列被拼进特征;怀疑数据泄漏;删除该列后重新跑 split audit 与 baseline”。这种写法能防止把一次偶然结果当成原因。

错误修复要避免扩大范围。设备错只修设备搬运,shape 错只修轴,dtype 错只修类型,指标错只修分母;如果同时换了模型和数据,下一次失败会更难定位。提交一个最小修复后立即运行相关契约测试,再进入下一项优化。

性能分析也需要基线和护栏。修改 num_workers、batch、AMP 或编译选项时,保存相同金样本的输出、loss 和指标;如果速度上升但数值不一致,先判断是可接受浮点差异还是语义回归。把资源变化写进运行记录,未来才能解释为什么选择一个看似更慢但更稳定的配置。

表 6.7E · 调试报告模板
字段要写什么示例价值
observation可重复现象第二层 shape 错避免模糊描述
reproduction命令、输入、环境一个固定 batch第三方可回放
hypothesis边界与原因HWC 未转 CHW限制修复范围
patch只改动什么增加 permute便于审查
regression哪条测试通过shape + gold output防止复发
resource时间/显存变化step -12%公平比较
PYTHON · debug_snapshot.py
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,定位模型或指标边界。

图 6.7E · 从错误到回归测试自制示意:报告、修复、验证闭环
失败现场保存 batch
→
最小假设只改一处
→
修复验证契约测试
→
永久回归纳入 tests

没有回归测试的修复,未来仍可能悄悄失效。

DEBUG LAB 6.7.4写一份失败现场报告

任选一个故意制造的 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)

排错流程

  1. 保留完整 traceback,先看最后一行的期望维度和实际维度。
  2. 用一个 batch 重现;打印进入每一层前后的 shape/dtype/device。
  3. 找到第一次偏离接口合同的位置,再决定展平、转置还是修改层配置。
  4. 修复后加入 shape 断言和回归测试,不删除失败样例。

08 · SAVE & INFERENCE

保存与推理:把训练产物变成可靠入口

权重文件只是交付的一部分。模型结构、类别映射、预处理、设备、版本和推理接口必须一起保存和验证。本节完成 state_dict 恢复、单样本预测、批量推理、导出清单和发布前检查。

保存与发布的三层合同:权重、预处理、版本

保存模型不能只考虑“文件能打开”。第一层是权重合同:模型结构、参数名、dtype 和 state_dict 能严格加载;第二层是预处理合同:输入尺寸、通道顺序、归一化均值方差、标签映射和输出解释保持一致;第三层是版本合同:代码提交号、数据版本、依赖版本和训练配置可追溯。缺少第二层时,模型即使加载成功,也可能因为输入尺度不同而输出完全错误。

推理包装器应把设备选择、eval()、inference_mode()、批处理、概率计算和结果格式集中管理。调用方只传入约定好的原始输入,返回固定字段,如类别索引、标签、概率和模型版本。不要让网页或脚本直接操作内部层;接口稳定后,替换 CNN 或量化实现不会影响上游。

checkpoint 读取要考虑设备迁移和安全边界。保存完整训练状态便于恢复,发布推理时可以只保留权重和清晰的 manifest。加载来自不可信来源的 pickle 文件存在执行风险,实际部署应使用可信文件、受限目录和明确的加载策略。每次发布前用固定金样本运行预测,比较 logits 或类别、概率范围和预处理摘要。

表 6.8F · 发布包的最低文件
文件内容验收用途
model.ptstate_dict 或推理权重strict load重建模型
manifest.json版本、标签、预处理字段校验接口解释
predict.py固定输入输出包装器金样本预测调用入口
requirements.txt直接依赖版本冷启动导入环境重建
checksums.txt文件哈希与大小哈希匹配传输完整性
README.md运行命令和限制新终端执行交接与审计
PYTHON · inference_contract.py
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 缺字段会阻止启动,权重哈希不一致会拒绝加载。把这些失败路径写成自动化测试,比只测一个成功样例更能保护接口。

SAVE LAB 6.8.5列出一次发布验收

为你的模型写出保存、加载、金样本、预处理、版本和失败路径六项验收,并说明哪些证据要放进 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、预测和指标,不能只看到“没有异常”就相信恢复成功。

表 6.8A · 保存内容的层次
产物包含用途缺失后果
model state参数与 buffer推理/迁移无法重建输出
optimizer statemomentum/Adam 缓存继续训练轨迹改变
scheduler state学习率进度恢复调度步长跳变
config结构与超参数实例化模型shape/激活错
preprocessNormalize/词表/类别映射入口一致预测语义变
manifest版本/数据/环境审计和回滚无法解释差异
PYTHON · save_load_state.py
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。这样读者能知道哪些参数来自旧模型,哪些参数真正接受了新任务的训练。

SAVE LAB 6.8.1完成一次 checkpoint round-trip

建立一个三类 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 单元格中。

表 6.8B · 推理入口契约
阶段输入/输出固定约定错误处理
原始输入文件、数组或字节尺寸、通道、编码拒绝非法格式
预处理float TensorNormalize/词表版本范围与 dtype 断言
模型调用batch Tensor→logitsdevice、eval、无梯度shape/finite 检查
后处理类别/概率/阈值映射和阈值版本保留原始 logits
响应结构化 JSON顺序、ID、版本可定位错误样本
PYTHON · predict.py
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 更新或阈值改变,都应提高模型/预处理版本,并保留旧版本以便回滚。推理结果中返回版本字段,业务日志出现异常时才能知道使用的是哪份模型。

SAVE LAB 6.8.2写一个批量推理函数

让推理入口同时接受单张 (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 应包含数据内容哈希或索引清单、预处理参数和类别字典;启动时校验版本,不匹配就停止或明确警告。

表 6.8C · 发布前验收
类别检查项最低证据失败处理
功能单样本/批量推理示例请求响应修 shape/预处理
数值logits/概率有限金样本容差查 dtype/设备
版本权重/代码/数据匹配manifest 哈希拒绝加载
性能吞吐/P95/P99/显存固定基准脚本调 batch/缓存
回滚旧模型仍可读取双版本目录保留 last/best
文档输入、输出、限制README/模型卡补齐说明
PYTHON · release_check.py
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%”更能保护使用者,也为下一章的文本与表示学习打好工程基础。

SAVE LAB 6.8.3制作一个发布 manifest

为你的 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 模式和预处理,最后检查序列化格式。

表 6.8D · 本节完成标准
能力最低证据下一步应用
保存能保存/加载 state_dict 并严格检查迁移学习和长训练
推理能统一单样本、batch 和后处理服务或命令行入口
打包能把权重、配置、预处理放入 manifest模型版本管理
复现能用金样本和容差回归升级与回滚
发布能说明限制、指标和适用范围端到端项目交付

推理与训练的接口要像两份合同

训练合同关心梯度、优化器、损失和 batch;推理合同关心输入格式、预处理、版本、延迟和输出解释。训练代码里可以返回 logits、loss 和 history,推理入口则应该隐藏这些内部细节,只返回稳定的结果结构。把两份合同分开,才能在不重新训练的情况下替换服务框架或前端。

state_dict 只保存 Tensor 状态,不能自动保存 Python 代码的结构、类别映射和预处理。manifest 要把这些信息连起来,并在加载时检查 schema。模型升级可以通过迁移脚本重命名键、初始化新头,但每个迁移动作都应写入记录,以便回滚。

发布前的 gold input 不要只选择一个“容易”的数字。至少覆盖一个常规样本、一个边界样本、一个低置信度样本和一个已知错误样本,保存 logits、概率、类别和版本。新模型必须在这些输入上通过约定,再交给更大测试集。

表 6.8E · 推理回归样本
样本类别保存内容用途失败后先看
常规输入摘要与预测确认基本路径预处理/权重
边界logits 与概率观察阈值敏感性校准/精度
低置信度top-k 结果人工复核入口类别映射
已知错误真实标签与版本防止错误消失被误判数据/模型变更
非法输入错误类型与消息保护服务边界输入校验
PYTHON · inference_bundle.py
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 当作完整的可运行发布包。

图 6.8E · 发布包的依赖关系自制示意:输入、模型、输出必须配套
原始输入格式/尺寸
→
预处理版本/参数
→
权重+结构state_dict
→
后处理标签/阈值
→
响应版本/置信度

任何一个环节版本不匹配,预测语义就可能改变。

SAVE LAB 6.8.4准备五个 gold input

为自己的模型选择常规、边界、低置信度、已知错误和非法输入各一个,保存预期输出或预期错误。

参考结论:每个样本要带输入摘要、预处理版本、模型版本、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 集合。结果表同时记录主指标、标准差、参数量、训练秒数和最常见混淆。若差异小于运行波动,不要把它写成确定性结论,而要说明证据不足。

表 6.9E · 端到端项目交付清单
门槛通过证据常见缺口负责人脚本
环境冷启动 smoke check版本写死在个人机器environment_report.py
数据split/标签/transform 审计测试泄漏dataset_audit.py
模型shape probe 与参数表Linear 输入凭感觉mnist_cnn.py
训练history、best、last指标简单平均train_mnist.py
评估混淆矩阵与错误样例只报 accuracymnist_errors.py
发布重新加载金样本一致预处理未绑定final_smoke.py
PYTHON · project_acceptance.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"}))

最终报告建议按“问题—方法—证据—限制—下一步”组织。先说明要解决什么,再说明数据和模型如何处理,随后给出可复现命令、指标表、错误图和资源账本,最后诚实写出测试集使用边界、随机波动和未验证的假设。报告不是训练日志的堆积,而是帮助读者判断结论是否值得相信的证据链。

CAPSTONE LAB 6.9.4写一页项目验收单

把本章项目整理成一页验收单:列出启动命令、数据版本、模型结构、主指标、三个护栏、最佳 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、反向、保存和推理都通过。再做小集过拟合,确认模型有能力学习;最后才扩大到完整数据和正式预算。若正式运行失败,可以根据最后一个成功的工件定位是在数据、模型、循环还是保存阶段,而不是从头猜。

图 6.9 · 端到端项目流水线自制示意:每个阶段都有交付物
任务卡目标/边界
→
数据快照切分/审计
→
模型与训练曲线/检查点
→
错误复盘混淆/假设
→
发布包manifest/推理

项目完成的标志是证据链闭合,而不是终端显示一个漂亮数字。

表 6.9A · 项目阶段与交付物
阶段关键动作交付物通过条件
任务定义输入/标签/指标task-card.md别人能复述
环境记录版本/设备/seedenv-report.jsonsmoke test 通过
数据下载、切分、审计manifest + ID 清单无交集泄漏
基线Linear/小 CNNbaseline historyloss/指标可计算
训练完整 epoch 与验证history + best/last曲线可重绘
复盘错误样例与消融report.md + tables结论有对照
交付推理入口与版本release bundle金样本回读
PYTHON · train_mnist.py
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 文件中。

CAPSTONE LAB 6.9.1运行一个可复跑项目

按任务卡、环境报告、数据探针、模型 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 聚合改错。

表 6.9B · 消融实验矩阵
问题Baseline只改一项主要指标/护栏
卷积是否有益flatten MLP替换为 CNNvalid acc / 参数量、延迟
正则是否有效weight_decay=0只加 1e-4valid loss / train-valid gap
增强是否合理无增强轻微平移每类召回 / 错误桶
batch 是否合适64改 128吞吐 / 指标、显存
稳定性是否改善单 seed三 seed均值/最差 / 时间成本
PYTHON · ablation_runs.py
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、随机状态和模式切换。回归报告要同时展示绝对/相对误差、样本数和第一个不同的层。

消融结果不支持假设也有价值。它说明某个改动在当前数据、预算和实现下没有证据支持,下一步可以换错误桶、补数据或重新定义问题。不要删除失败运行,也不要把“没有显著提升”改写成“模型无效”的普遍结论。

CAPSTONE LAB 6.9.2安排三组消融

从 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 上运行,明确设备和显存要求。交付的价值在于可使用、可回滚、可解释。

表 6.9C · 端到端报告模板
章节必须回答对应文件验收问题
任务输入、标签、指标、限制task-card.md目标是否可复述
数据来源、版本、切分、审计manifest/IDs是否存在泄漏
模型结构、参数、shape、模式model.py/probe输出是否匹配 loss
训练optimizer、预算、曲线history/checkpoint最佳依据是什么
评估分母、混淆、错误桶metrics/errors错误是否可定位
交付推理、版本、限制、回滚release/README新进程能否回读
PYTHON · final_smoke.py
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 和微调的基础。

CAPSTONE LAB 6.9.3提交最终实验报告

把本章项目整理成一个可分享目录,包含任务卡、环境报告、数据 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、测试、训练、评估与推理。

若结果不对:每个阶段保存输入、输出和退出码;失败时只重跑该阶段。

表 6.9D · 本章最终能力地图
能力完成证据第 7 章的迁移大模型对应
环境版本/设备/seed 报告Tokenizer 与库版本训练框架与硬件
数据Dataset/Loader/切分审计文本 Dataset语料、packing、mask
模型nn.Module、shape probeEmbedding/AttentionTransformer block
训练train/eval、指标、checkpoint长序列训练梯度累积/混合精度
评估错误样例、消融、回归文本分类/检索基准与安全评测
交付manifest、推理 smoke test模型与 tokenizer 打包服务化与回滚