qinghuan
← 训练与研究 / 模型基础

模型基础 · 独立栏目

FashionMNIST:把一批图片走完训练与重载

先用 8 张图片看清数据、梯度和参数怎样连接,不设准确率目标。

核查日期:2026-10-11。状态:本文是待执行的教学练习;代码、训练、保存和重载均未运行,未下载数据或安装依赖。准确率、耗时与费用目前留空。

这一节属于独立的“模型基础”栏目。Java Agent 路线学习怎样安全调用现成模型;这里学习模型怎样从数据计算、求梯度和更新参数。先完成自己需要的一条路线即可,不必同时安装两套框架。

目标与前置知识

完成后应能解释一批图片的形状和标签含义,检查模型的一次前向、反向与参数更新,并把同一模型的参数保存后重新加载。这里先证明数据和计算过程接得上,不设准确率目标。

需要会 Python 变量、函数、类和列表;能看懂矩阵的行列与基本导数。遇到概念问题,回到已有的张量文章、自动微分文章、多层感知机文章和分类损失文章,无需再学一套平行的概念课程。

先读 PyTorch 基础入口的使用说明,再按下方步骤阅读指定章节。官网当前教程页展示的构建版本是 2.14.0+cu130;这不是本练习已经安装、验证的版本组合,也不要求修改本站已有教程所用版本。练习者应在独立 Python 环境中记录自己选用且相互匹配的 torch、torchvision、Python 和操作系统版本。先使用 CPU;本次没有完成任何依赖安装或版本组合验证。

1. 先认识一批图片

固定使用 FashionMNIST:每张是 28×28 的灰度服饰图片,标签是 10 类之一。先只取 8 个样本,记录每个标签编号及对应类别。图片张量预期是 [8, 1, 28, 28]:8 张图片、1 个通道、高 28、宽 28;标签预期是 [8] 的整数。不要把标签当成连续数值去回归,也不要把一个批次当成一张图片。

原始来源:Datasets & DataLoaders,只读 Loading a Dataset 与 Preparing your data for training with DataLoaders;Tensors,只读 Attributes of a Tensor。数据集说明来自官网;下面的形状、类型和范围断言尚未实际执行。

特征转换选择 v2.ToImage() 加 v2.ToDtype(torch.float32, scale=True),把图像转换为浮点张量。标签保持整数类别编号。本练习采用 CrossEntropyLoss 的类别索引用法,因此不复制 Transforms 中单独示范的 one-hot 标签转换。

2. 走通前向、梯度与一次更新

先把 [8, 1, 28, 28] 展平为 [8, 784],经过一个 32 单元的隐藏层,输出 [8, 10]。输出是各类别的原始分数,直接交给交叉熵损失,训练前不再手动做 softmax。argmax(dim=1) 可以得到预测类别,但第一次预测是否正确不是这一步的验收条件。

一次更新按 zero_grad → forward → loss → backward → step 执行。backward() 计算梯度,step() 才修改参数;只看到损失值并不能证明参数更新发生。记录梯度是否存在、是否有限,并比较更新前后的参数。一次更新也不保证这一批损失下降,更不能证明新图片上的效果。

原始来源:Build the Neural Network 的 Define the Class 与 Model Layers;Automatic Differentiation 的 Computing Gradients;Optimizing Model Parameters 的 Loss Function 与 Optimizer。下面 784→32→10 的结构和断言是本站自拟练习。

3. 只保存参数,再用相同结构加载

保存 state_dict 后,新建相同结构的模型再加载。用固定的那一批图片,对比保存时模型与重载模型的输出。比较必须记录设备、版本、模式和容差;本练习只检查当前环境中的这一次往返,不承诺 CPU 与 GPU、不同系统或不同版本之间完全相同。

原始来源:Save and Load the Model 的 Saving and Loading Model Weights。该页用 VGG 展示保存原理;本练习继续用自己的小模型,不需要下载 VGG 预训练权重。只保存模型参数适合本节的推理重载检查;接着同一次训练还需要优化器等其他状态,见已有训练循环与检查点文章。

4. 分别验收评估模式、梯度记录和缓冲状态

适用阶段:能够解释本页的一次前向、损失、反向与参数更新,准备组织训练和验证循环。前置见自动微分与反向传播和训练循环与检查点;BatchNorm 的统计来源见同一个样本与批次伙伴。

原始核查:2026-10-20 12:11—12:12 JST。原资料记录 PyTorch 文档 v2.14.0;本次再次阅读固定 /docs/2.14/ 正文。Module 页未见独立发布日期,Autograd mechanics 标示创建于 2017-01-16、更新于 2026-08-03。文档版本是阅读依据,不是已经安装验证的环境,也不要求升级站内其他文章的 2.9 基线。下面练习均未运行。

两个开关分别控制什么

model.eval() 相当于 model.train(False),切换具有训练/评估差异的模块行为,例如 Dropout 与 BatchNorm;它不关闭自动微分。no_grad 控制上下文内的运算不记录反向图,不能代替模块模式切换。来源:Module.eval及Autograd 的局部梯度控制与评估模式。

本页原始 784→32→10 小网络没有 Dropout 或 BatchNorm,不应把“切到 eval 后输出必须变化”设为断言。原代码分别检查 model.training 和输出的 requires_grad,继续保留这个基线。后续对照请另建小模型,不把增加层后的结果冒充原基线结果。

inference_mode 也是梯度控制维度,并不替你调用 eval()。它对其中创建的张量有更严格的后续自动微分使用限制;不要仅凭名称替换 no_grad,或把本节当成已经测得推理更快、占用更少内存的证据。

建议练习一:四种组合分别填记录

准备含 Linear 与 Dropout 的小模型,固定权重和同一输入;明确 Linear 参数参与计算且 requires_grad=True,让正常梯度模式下确实有可记录的路径。对照四种组合,记录 model.training、输出 requires_grad、grad_fn 和重复前向输出。

模块模式梯度上下文要核对的模式标志梯度图预期
train()正常梯度模式training=True本练习存在可训练参数路径,应记录
eval()正常梯度模式training=False仍应记录,eval 不关闭梯度
train()no_grad()training=True不记录,但 Dropout 仍处于训练行为
eval()no_grad()training=False不记录,Dropout 处于评估行为

这张表是有明确前提的验收预期,不是运行结果。若所有输入和参数都不需要梯度,正常模式也不会凭空生成需要梯度的输出,不能脱离前提照抄断言。

训练模式的 Dropout 可能使重复前向不同,但两次随机结果恰好一样也可能发生;不要只用“随机两次必须不同”判定模式是否正确。也不要据 eval() 推导所有硬件、算子和自定义层都绝对可复现。实际记录还需包括层配置、随机数设置、环境与比较容差。

建议练习二:没有更新参数,状态仍可能改变

创建两份初始状态相同的 BatchNorm 模型,明确设置 track_running_stats=True,用相同且满足该层形状约束的假数据分别执行 train()+no_grad() 与 eval()+no_grad() 的前向。为避免均值恰巧相同遮住现象,夹具应选择与初始运行统计明显不同的非退化批次;同时记录运行均值、运行方差及 num_batches_tracked 的前后值。

比较前应分别复制状态快照,而不是让第二次调用接着第一次已修改的状态。验收重点是区分“没有调用 optimizer.step”与“整个模型状态未改变”:默认跟踪运行统计的 BatchNorm 在训练模式前向仍可能更新缓冲区,关闭梯度不能阻止这种状态更新。标准评估模式使用保存的运行统计。若 track_running_stats=False,评估时也使用当前批统计,需另按配置设计检查。来源:BatchNorm1d 的运行统计与配置。

记录模式切换前后的状态,继续训练前恢复 train()。自定义层可能有额外行为,因此只检查根模块的一个标志也不一定覆盖全部配置;存在单独切换的子模块时,应记录相关子模块模式。

交付时分别提供模式记录、梯度图记录和缓冲区快照,不把它们合成一句“推理成功”。限制:本节只核对文档并设计练习,未安装 PyTorch、运行代码、训练、测量准确率或评估内存与性能;不会产生提升结论。

待执行的最小代码

下面代码需要练习者准备匹配的 PyTorch/TorchVision 环境。默认不联网下载;首次准备数据时,明确把 allow_download 改为 True。训练文件和权重写入自己的练习目录,不放进网站部署包。未准备数据时产生“数据不存在”错误,应记录为环境未就绪,不能写成实验通过。

展开收起代码 · 文中示例python · 82 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

文中示例 · python
from pathlib import Path
import platform
import torch
import torchvision
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets
from torchvision.transforms import v2

# 教学草案,未运行。固定种子只辅助本次调试,不保证跨环境完全复现。
torch.manual_seed(20261011)
allow_download = False
device = torch.device("cpu")
transform = v2.Compose([
    v2.ToImage(),
    v2.ToDtype(torch.float32, scale=True),
])
data = datasets.FashionMNIST(
    root="data", train=True, download=allow_download, transform=transform
)
indices = list(range(8))
loader = DataLoader(Subset(data, indices), batch_size=8,
                    shuffle=False, num_workers=0)
x, y = next(iter(loader))
x, y = x.to(device), y.to(device)
assert tuple(x.shape) == (8, 1, 28, 28)
assert tuple(y.shape) == (8,)
assert x.dtype == torch.float32 and y.dtype == torch.int64
assert torch.isfinite(x).all()
assert bool(((0 <= x) & (x <= 1)).all())
assert bool(((0 <= y) & (y < 10)).all())
print("environment", platform.python_version(), torch.__version__,
      torchvision.__version__, platform.platform(), str(device))
print("data", indices, x.shape, x.dtype, y.shape, y.dtype)
print("labels", [(int(label), data.classes[int(label)]) for label in y])

def make_model():
    return nn.Sequential(nn.Flatten(), nn.Linear(28 * 28, 32),
                         nn.ReLU(), nn.Linear(32, 10)).to(device)

model = make_model()
optimizer = torch.optim.SGD(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()
before = [p.detach().clone() for p in model.parameters()]
model.train()
optimizer.zero_grad(set_to_none=True)
logits = model(x)
assert tuple(logits.shape) == (8, 10)
loss = loss_fn(logits, y)
assert loss.ndim == 0 and torch.isfinite(loss)
loss.backward()
assert all(p.grad is not None and torch.isfinite(p.grad).all()
           for p in model.parameters())
grad_norms = [float(p.grad.norm()) for p in model.parameters()]
optimizer.step()
changed = [not torch.equal(old, new.detach())
           for old, new in zip(before, model.parameters())]
assert any(changed)
print("one_update", float(loss.detach()), grad_norms, changed)

# eval 不会自动关闭梯度;此网络没有 Dropout/BatchNorm,不要求输出变化。
model.eval()
assert model.training is False
with torch.enable_grad():
    eval_with_grad = model(x)
assert eval_with_grad.requires_grad
with torch.no_grad():
    reference = model(x).clone()
assert not reference.requires_grad

out = Path("artifacts")
out.mkdir(exist_ok=True)
checkpoint = out / "fashionmnist-weights.pt"
torch.save(model.state_dict(), checkpoint)
reloaded = make_model()
state = torch.load(checkpoint, map_location=device, weights_only=True)
reloaded.load_state_dict(state)
reloaded.eval()
with torch.no_grad():
    restored = reloaded(x)
torch.testing.assert_close(restored, reference, rtol=1e-5, atol=1e-6)
print("reload_max_abs_diff", float((restored - reference).abs().max()))

验收与练习交付

提交脚本、版本记录、数据准备记录、实际运行日志和一页说明。最低验收是:能说清每一维;标签类型与损失用法一致;梯度非空且有限;参数确实发生更新;相同结构重载后的输出在约定容差内相符;能用两个独立检查区分 eval 与 no_grad。任何断言失败都保留原始信息并解释,不能先删断言再报告通过。

增加三个自拟对照练习:

  1. 把批次从 8 改为 4,先写下各层形状预测,再修改断言核对。只改输入数量,不顺手更换模型或损失。
  2. 在不更新参数的副本模型上,分别做“清零后一次反向”和“重新前向但不清零的第二次反向”,记录同一个参数的梯度。解释梯度累积,随后恢复清零流程;不要直接对已释放的同一计算图重复反向。
  3. 把最后一层改为 9 类后尝试加载 10 类的权重,保留不匹配信息,说明为什么重载需要匹配结构。不能用忽略缺失键来伪装通过。

本练习只处理训练集中的固定小批次,不报告测试集准确率。若继续做完整训练,应另行确定训练/验证/测试划分、停止条件和评测次数,保持测试集用途独立;不能把这 8 个样本上的结果当泛化证据。

记录项预期或填写规则实际结果
运行 ID、脚本版本每次唯一,能定位实际脚本
Python、torch、torchvision、系统、设备填实际版本,不复制资料页版本
数据来源、目录、样本索引FashionMNIST;本练习取训练集 0–7
shape、dtype、标签范围按脚本断言逐项核对
梯度与参数变化保存实际数值和断言结果
权重文件、重载容差与最大差值保存文件与比较记录
eval/no_grad 两个检查分别记录,不合并成一句“推理成功”
准确率、耗时、费用未测量就留空,不填 0
失败分类与人工复核环境、数据、形状、梯度、重载或解释问题

固定种子不保证跨版本、平台或 CPU/GPU 执行完全复现,见 PyTorch Reproducibility。本页现阶段只有来源核查和教学设计,以上运行项全部未验证。

5. 完整训练前,先验收数据家族与拟合边界

适用阶段:已经理解本页的一批图片、标签、前向与参数更新,准备把练习扩展为训练、验证和最终测试。这里验收数据流程,不要求把 PyTorch 项目改用 scikit-learn。先读站内训练集、验证集与测试集;想了解文本近重复检测,再读语料清洗、去重与污染检查。

原始核查:2026-10-19 06:09 JST。原资料记录 scikit-learn 文档版本 1.9.1,未标明准确发布日期;2026-10-20 再次核对官方正文时仍显示 1.9.1。以下是待执行的验收设计,未制作数据、调用划分库、训练模型或计算模型分数。站内既有 1.7.2 源码讲解继续保留其固定版本,本节不要求安装或升级依赖。

从“新图片”说清楚要测什么

本页原先取训练集中的 8 张图片,只检查计算流程。若要进一步说明对新样本的表现,先确定“新”指什么:是同一对象的新观测,还是从未见过的原始样本家族?这两个目标可能需要不同边界。本练习选择后者,为原始样本及其已知裁剪、轻微变换或改写副本保留自定义字段 sourceFamilyId,先分配家族,再只对训练部分做增强。字段是本练习的设计,不是数据集自带身份,也不是要求所有任务按商户 ID 划分。

官方交叉验证文档说明,GroupKFold 根据调用者提供的 groups,隔离同一折中的训练组与验证组。它并不自动识别图片副本或文本改写。家族定义错误时,形式上的组不重叠仍可能留下泄漏。来源:scikit-learn 分组交叉验证。

保留 FashionMNIST 原有的官方训练/测试边界;不要把官方测试集混回训练池后重新抽样。本节的家族切分练习使用另外构造的假样本。如果发现官方边界之间存在近重复疑点,应记录审计范围和事先约定的处理规则,不能据本节文字断言该数据集已发生或已消除泄漏。

建议练习一:ID 不同,不代表来源独立

准备几个虚构家族,每个包括一份原样本和两个已知变体,保存样本 ID、家族 ID、标签以及变体与原样本的关系。先故意逐行切分,检查是否有家族跨侧;再按家族分配训练、验证、测试,并逐对检查家族集合无交集。不要只核对训练和测试,漏掉验证这一侧。

同时记录每侧的样本数、家族数和类别分布。家族不等大时,组比例不等于样本比例;类别覆盖不足应作为设计问题记录,不能为了提高报告分数而临时移动难例。

增加两个反例:一个副本只换文件名,检查文件名不同不能证明内容不同;另一个文本措辞相近但标签不同,检查不能仅凭相似度自动归并。未知近重复需要独立的候选检测和人工复核规则,不能由“已知家族无交集”推导出所有重复都已被发现。此练习尚未运行,交集和计数结果留待实际填写。

建议练习二:改变保留数据,训练参数不应跟着变

scikit-learn 的数据泄漏说明要求从训练部分学习均值、缩放、特征选择等预处理参数,其他集合仅应用既定变换。交叉验证时,每折应在该折训练部分重新拟合;正确使用 Pipeline 有助于保持这条边界,但不能修复错误家族或未来字段。来源:Common pitfalls 的 Data leakage。

用独立的虚构数值夹具固定训练部分,只改变保留测试部分的数值分布。比较两条流程:正确流程先切分再只在训练部分拟合;故意错误的对照先用全量数据拟合再切分。验收的是前者训练均值、缩放参数或特征选择结果不依赖所改的测试数值,后者的参数则可能暴露这种依赖。这不是要求真实测试集反复改动,也不预设错误流程一定得到更高模型分数。

测试集只使用已经拟合的转换,不为让均值更接近零而再次 fit。训练/验证阶段确定方案后才作保留测试;若反复按测试成绩调整方案,应明确该测试已参与开发,不能继续称完全独立的最终评测。

留下能复核边界的记录

记录数据版本、评估目标、家族定义与来源、各集合 ID 清单或可核验摘要、样本数与家族数、类别分布、种子、预处理拟合范围、模型选择所用集合以及最终测试使用记录。固定种子帮助重现某次划分,不证明划分正确或无泄漏。

限制:以上两项练习均未运行,没有性能提升结论;家族划分不能替代时间泄漏、标签来源、分布偏移及未知近重复检查。实际模型效果仍需在按协议隔离的数据上另行测量。

6. 有了固定预测记录,再验收混淆矩阵

适用阶段:已经明确训练、验证、测试的职责,能按样本 ID 对齐真实标签与预测标签。概念前置见站内准确率与类别比例及同一批结果的 micro、macro、weighted F1。这里补标签顺序、归一化与零分母的报表验收,不重复推导三种汇总公式。

原始核查:2026-10-19 12:05—12:06 JST。原资料记录 scikit-learn 文档版本 1.9.1,未标准确发布日期;2026-10-20 重新核对两份官方 API 正文时仍显示 1.9.1。本节未安装或运行评测库,未训练模型、选择阈值或产生模型准确率;下列数字都是虚构夹具的手算值。

先固定轴、类别顺序与单位

scikit-learn 的 confusion_matrix 以真实类别为行、预测类别为列。labels 同时决定行列顺序;省略时,使用在真实或预测标签中出现过的类别并排序。界面上的类别名称必须与这份顺序一致,不能只改标题而保持矩阵位置不变。来源:confusion_matrix 的定义及 labels 参数。

不使用样本权重时,normalize=None 保存样本计数;normalize='true' 按真实类别的行归一化,'pred' 按预测类别的列归一化,'all' 按全部样本归一化。这里 'true' 是字符串选项,不是布尔值 True。始终保留原始计数,比例图不能代替样本量;若使用样本权重,另行说明矩阵累计的是加权量。

classification_report 提供逐类 precision、recall、F1 和 support;macro 对各类等权,weighted 按 support 加权。零分母由 zero_division 决定如何显示,默认 'warn' 返回 0 并警告。占位值不等于这个类别已有充分评测证据。来源:classification_report 的参数与返回说明。

建议练习一:九成正确,少数类仍可能一个都没找到

构造 20 条虚构评测记录,A 类 18 条、B 类 2 条,并预先约定规则“永远预测 A”。按 [A, B] 排列,手算原始矩阵是:

真实类别预测 A预测 B真实数量
A18018
B202

总体准确率为 18/20=90%,B 类召回率为 0/2=0。因为没有任何预测为 B 的记录,B 类 precision 的分母为零,报告必须同时说明所用展示策略。这是人为构造记录的算术结果,不是模型实测成绩,也不是 FashionMNIST 的类别分布。

若后续与真实模型比较,两者应使用同一份固定评测集;多数类基线的类别选择依据训练资料,不能先查看保留测试集,再挑最有利的固定标签。最终同时呈现逐类数量和错误,不能只凭超过某个总体百分比宣称每类都学会。macro 等权也不自动代表实际业务价值;漏判和误判成本需由任务另定。

建议练习二:交换标签顺序后,含义也必须对齐

另用真实标签 [A,A,B,B] 与预测标签 [A,B,B,B]。按 [A,B] 顺序手算,原始计数为两行 [1,1]、[0,2];按行归一化则为 [0.5,0.5]、[0,1]。后者表示比例,不是样本数量。A 行 B 列的 1 表示 A 被错判为 B,不能反过来读。

把 labels 顺序换为 [B,A],先在纸上重新排列,再核对显示名称、行列与计数对应;不要用同一套已排序数组同时产生实现值和所谓“独立答案”。随后在标签列表里增加一个没有真实评测样本的 C 类,明确显示 support=0,检查零分母策略如何影响展示。没有 C 类真实样本就不能评价其召回表现;显示 0 或 1 都不是能力结论。

以上两项报表练习均未运行。待实际接入后,应按样本 ID 保存真实/预测标签、完整标签顺序、数据与模型版本、样本权重设置、normalize、zero_division、逐类 support 和选定汇总方式,并保留失败样本。

限制:夹具只用于核对报表实现,不代表生产分布。只有两条少数类记录时,比例很不稳定,不能外推泛化能力。模型、阈值和报告主指标的选择继续遵守测试集不参与调参的边界;实际结果未测量时留空,不填 0 冒充测量值。