qinghuan
← 训练与研究学习路线

训练与研究 · PYTHON CPU · 连续实验

训练验收:从损失输入到最佳模型重载

模型能打印 loss,还需要检查标签、梯度和保存的状态。按五个环节找出最早的差异,并留下可复核的记录。

模型能打印 loss,并不代表整段训练代码已经接对。先确认标签含义,再确认每次更新用的是哪一份梯度,最后检查实验能否重跑、选出的模型能否重新加载。这五个环节放在同一条线上,方便从最早出错的位置往后排查。

这是一组已有训练课程的验收补充。先完成 FashionMNIST 的一次更新和训练循环与检查点。原课程中的数据划分、混淆矩阵、eval 与梯度记录已有专节;这里直接引用前置,不重复开篇,也不把旧练习的“未运行”状态改成已完成。

顺序要解决的问题本次新增的证据
1. 损失输入张量传进去了,标签含义也对吗?错误形状、类型、范围、软标签和 ignore_index 断言
2. 梯度清理没收到梯度,和梯度为零一样吗?相同动量状态下,None 与零张量的参数变化对照
3. 裁剪与非有限值报错之后,更新真的停住了吗?NaN、正负无穷使 step 调用次数为零,参数保持原值
4. 随机状态两次启动能从同一位置重放吗?0/2 worker 各两个新进程,逐项比较样本、loss、权重和预测
5. 最佳快照加载的是最佳轮次,还是最后一轮?独立快照、落盘、另进程重载参数及持久缓冲区

版本、数据与实际运行范围

官方资料核查日期为 2026-10-23 JST(Asia/Tokyo)。候选资料使用 PyTorch 2.14 API 和显示 2.14.0+cu130 的教程;本次也核对 2.9 API/固定 v2.9.1 源码,实验沿用站内已有基线:Python 3.14.0、PyTorch 2.9.1+cpu、NumPy 2.3.4、Windows 11,CPU 单线程。资料页版本不是本机安装版本,不需要升级网站或其他练习的依赖。

本次确实运行了 PyTorch 自动微分、SGD、DataLoader 多进程和权重保存重载。输入均为脚本中直接写出的极小虚构张量,没有下载 FashionMNIST 或其他数据集,没有使用 GPU、生产数据或付费模型。它们证明的是所列 API 与程序流程,不是准确率提升、收敛保证或完整训练完成。

下载后怎样运行

下载本专题源码包,将其中的文件放在同一个练习目录。推荐先运行 README 中的隔离环境安装命令,再执行:

文中示例 · powershell
.\.venv\Scripts\python.exe training_checks.py --case all --output results

程序会保存 results/verification.json、各子进程标准输出和自己生成的权重。完整检查包括每节列出的失败反例;只运行某个最小片段,不等于完成整套验收。所有片段都从同一份 training_checks.py 提取,并另行执行核对,避免文章与下载代码各写一套。

遇到失败,保留原始日志并记录阶段。不要删除断言后把“脚本跑完”写成通过。下方数值只属于上面的实测环境;换环境后重新运行,以自己的记录为准。

第 01 节 / 共 5 节

交叉熵输入与标签契约

目标与前置

这一节要能在第一次更新前拦住坏标签,并解释为什么“预测类别相同”不等于“训练目标相同”。前置是 Softmax 与分类损失中的 logits、类别轴和平均损失。已有推导继续使用;这里补数据入口与错误对照。

适用范围为普通单标签批次:logits 为 [N,C]、硬标签为 [N]。官方 API 核查于 2026-10-23 JST,阅读版本 2.14 并对照 2.9;下方实际运行版本是 torch==2.9.1+cpu,Python 3.14.0。

为什么能算出数,还不一定输入正确

logits 是未经归一化的类别分数,可以为负。硬标签模式直接把分数交给 cross_entropy;先做 softmax 再传入,会把概率当作另一组分数,改变损失和梯度。软标签是另一份契约:浮点型、与 logits 同形状,每行是合法概率分布。框架不会替调用者完整检查概率范围及行和。官方 2.14 CrossEntropyLoss、2.9 对照。

像后端接口一样,shape 和 dtype 只是第一层检查;类别 0 到底代表什么,也要与保存的类别表对上。把坏标签随手 softmax 成一个分布,不能修复它原本错误的含义。

最小 Python 对照

将下载包放在同一目录后运行 python 01-loss.py。其中 check_hard 来自同包的训练入口校验器;它不会修改标签。

文中示例 · python
import json
import torch
from torch.nn import functional as F
from training_checks import check_hard

def loss_demo():
    z = torch.tensor([[2., -1., .5], [-.5, 1., 2.]], dtype=torch.float64,
                     requires_grad=True)
    y = torch.tensor([0, 2], dtype=torch.long)
    check_hard(z, y)
    ce = F.cross_entropy(z, y)
    g_ce, = torch.autograd.grad(ce, z)
    z2 = z.detach().clone().requires_grad_()
    nll = F.nll_loss(F.log_softmax(z2, dim=1), y)
    g_nll, = torch.autograd.grad(nll, z2)
    torch.testing.assert_close(ce, nll, rtol=0, atol=1e-12)
    torch.testing.assert_close(g_ce, g_nll, rtol=0, atol=1e-12)
    z3 = z.detach().clone().requires_grad_()
    wrong = F.cross_entropy(z3.softmax(1), y)
    g_wrong, = torch.autograd.grad(wrong, z3)
    assert torch.equal(z.argmax(1), z.softmax(1).argmax(1))
    assert not torch.allclose(g_ce, g_wrong, rtol=0, atol=1e-12)
    return dict(ce=ce.item(), logsoftmax_nll=nll.item(),
                wrongly_softmaxed_loss=wrong.item(),
                gradient_max_diff=(g_ce - g_nll).abs().max().item(),
                wrong_gradient_max_diff=(g_ce - g_wrong).abs().max().item(),
                predictions=z.argmax(1).tolist())

print(json.dumps(loss_demo(), sort_keys=True))

这里用了三个独立叶子张量,避免不同分支的梯度互相累积。比较的是同一 logits 的梯度;错误分支不能拿 softmax 后张量的梯度混作原分数的梯度。

操作步骤与验收

  1. 用固定的 2 样本、3 类分数,比较直接交叉熵与 log_softmax + nll_loss,同时比较损失和梯度,容差设为绝对 1e-12。
  2. 保留“先 softmax”的错误分支,确认两种分支的 argmax 相同,但损失或梯度不相同。不要因为预测标签没变就接受它。
  3. 执行 python training_checks.py --case loss,分别注入硬标签越界、错误 dtype/shape、全部忽略,以及软标签负值、错误行和、错误 dtype/shape、NaN 和非有限 logits,共 10 个入口失败场景。每项都必须在训练前给出明确错误。
  4. 专门测试 ignore_index=-100:只有指定哨兵被忽略,另外一条正常参与,忽略行的梯度为零;本练习拒绝“平均损失没有任何有效样本”的批次。这是明确的应用层策略,不声称 PyTorch 自动替你拒绝全部坏输入。

实测直接损失和组合损失都是 0.30642516425491995,梯度最大差为 0。错误先 softmax 得到 0.7399728827304388,对原 logits 的梯度最大差约 0.0665533。10 个失败场景全部被入口拒绝;合法 ignore_index 对照通过。另一个反例证明:行和为 0.5 的非法软标签仍能让库返回有限 loss,所以不能用“数值有限”代替语义检查。

常见误区与边界

ignore_index 只适用于类别索引目标,不是软标签的通用掩码;不能把所有越界类别静默改成忽略。源码中的软标签行和容差 1e-12 针对本次 float64 夹具;低精度或大类别数要预先规定自己的容差。这里没有覆盖类别权重、label smoothing、像素级分类或多标签损失,也不推导概率校准结论。

本节通过后,下一步是清理梯度并检查跳步语义。

第 02 节 / 共 5 节

梯度缓冲清理与跳步语义

目标与前置

目标是把“计算梯度”“清理旧梯度”“更新参数”分开,并能解释一个本批未参与计算的参数为什么仍可能移动。先完成上一节,能看懂 loss.backward() 与 optimizer.step() 的区别;训练循环顺序见已有章节。

官方资料核查于 2026-10-23 JST:2.14 API/教程及 2.9 API、v2.9.1 实现。实测是 Python 3.14.0、PyTorch 2.9.1+cpu,CPU float64;动量实验明确使用 SGD,lr=0.1, momentum=0.9。

从缓冲区理解清理

backward() 把梯度加到 .grad 缓冲区中,不是自动替换。普通逐批训练在本批反向前清理;有意累积则需要另一套明确规则。optimizer.zero_grad 只处理该优化器管理的参数。官方清零教程。

set_to_none=True 后,本批未收到梯度的参数可以保持 grad=None,优化器跳过该参数;零张量则仍进入更新逻辑,历史动量等状态可能让参数改变。Optimizer.zero_grad 2.14、2.9 对照。

还要注意:set_to_none=False 是把已有梯度缓冲清零,不会给所有从未有过梯度的参数凭空建立零张量。固定 v2.9.1 optimizer.py 中先检查 p.grad is not None,实验也单独验证了这个边界。

最小 Python 对照

下载包内运行 python 02-reset.py:

文中示例 · python
import json
import torch
from torch import nn

def gradient_demo():
    w = nn.Parameter(torch.tensor(2., dtype=torch.float64))
    opt = torch.optim.SGD([w], lr=.1)
    values = []
    for _ in range(2):
        (w.square()).backward()  # Rebuild the graph on every pass.
        values.append(w.grad.item())
    opt.zero_grad(set_to_none=True)
    (w.square()).backward()
    values.append(w.grad.item())
    assert values == [4., 8., 4.] and w.item() == 2.
    return values

print(json.dumps(gradient_demo(), sort_keys=True))

每轮重新计算 w.square(),因此没有重复使用已释放的计算图。这里没调用 step(),参数仍为 2;变化的只有梯度。

操作步骤与验收

  1. 先手算 w² 在 w=2 处的导数 4。两次反向中间不清理应观察到 4、8;清理后再反向应回到 4。实测为 [4, 8, 4],参数未变。
  2. 执行 python training_checks.py --case reset。两个标量先共同参与一次 SGD 更新,建立动量;然后复制相同参数、优化器状态和已有梯度缓冲。优化器 state_dict 不保存参数的 .grad,所以这里必须额外复制已有缓冲,才能公平对比清理方式。
  3. 第二批只让第一个参数参与损失。分别清成 None 和零,记录未参与参数的 grad、step 前后数值。两组都从 2.4 开始;None 组保持 2.4,零梯度组变为 1.86。这正是本次动量配置的实测结果。
  4. 验收两个附加边界:从未有 grad 的参数在 set_to_none=False 后仍为 None;不属于该优化器的另一个参数,其梯度不会被这个优化器清掉。

常见误区与边界

“梯度为零”不等于“所有优化器都保持参数不变”;是否移动取决于历史状态和配置。反过来,None 组跳过一个参数,也不代表整个批次没有更新其他参数。这里没有测试 AdamW、混合精度、分布式梯度累积或微批损失归一化,不把 SGD 对照数值泛化成所有优化器结果。

一批梯度准备好后,再进入裁剪与非有限值验收。

第 03 节 / 共 5 节

梯度裁剪与非有限值

目标与前置

这一节要能区分整体范数裁剪与逐元素裁剪,并证明 NaN 或无穷出现后没有继续调用 step()。先完成梯度清理;公式与学习率计划见学习率预热、调度与梯度裁剪。旧章已讲整体范数和 Inf 报错,这里补逐元素实测及“报错后参数未变”的完整证据。

官方资料核查于 2026-10-23 JST,阅读 2.14 与 2.9 API;实验为 PyTorch 2.9.1+cpu、Python 3.14.0、CPU float64。阈值 1 仅为手算夹具,不是给真实训练推荐的数值。

两种裁剪控制不同的量

clip_grad_norm_ 把传入参数的已有梯度作为整体计算范数,再原地缩放;返回值是裁剪前总范数。clip_grad_value_ 则逐个元素限制到正负阈值区间。二者的阈值不能直接互换。整体范数 API、逐值 API。

error_if_nonfinite 默认 False;显式 True 才会在总范数为 NaN、正负无穷时抛错。检查位置应在反向之后、参数更新之前。裁剪不能把损坏的计算修成正确梯度,也不能保证收敛。2.9 范数 API 对照、2.9 逐值 API 对照。

最小 Python 对照

运行下载包中的 python 03-clip.py:

文中示例 · python
import json
import torch
from torch import nn

def clipping_demo():
    def pair():
        ps = [nn.Parameter(torch.tensor(0., dtype=torch.float64)) for _ in range(2)]
        for p, g in zip(ps, (3., 4.)):
            p.grad = torch.tensor(g, dtype=torch.float64)
        return ps
    norm_pair, value_pair = pair(), pair()
    returned = nn.utils.clip_grad_norm_(norm_pair, 1., error_if_nonfinite=True)
    nn.utils.clip_grad_value_(value_pair, 1.)
    norm_values = torch.stack([p.grad for p in norm_pair])
    value_values = torch.stack([p.grad for p in value_pair])
    torch.testing.assert_close(returned, torch.tensor(5., dtype=torch.float64))
    torch.testing.assert_close(norm_values, torch.tensor([.6, .8], dtype=torch.float64),
                               rtol=0, atol=1e-6)
    assert value_values.tolist() == [1., 1.]
    return dict(returned_preclip_norm=returned.item(), norm_clipped=norm_values.tolist(),
                postclip_norm=norm_values.norm().item(), value_clipped=value_values.tolist(),
                value_postclip_norm=value_values.norm().item())

print(json.dumps(clipping_demo(), sort_keys=True))

这里直接构造 .grad=[3,4],是 API 契约实验,不是通过训练得到的梯度。参数本身为 0,也不会因为裁剪而更新。

操作步骤与验收

  1. 手算原 L2 范数 5。范数裁剪到 1 应约为 [0.6,0.8];逐值裁剪到 1 应为 [1,1],后者范数是 sqrt(2)。实测分别为 [0.599999880000024,0.799999840000032] 与 [1,1],差异来自实现的安全小量,使用绝对容差 1e-6。
  2. 同时记录返回的 5 与裁剪后约 0.9999998 的范数,不要把返回值误标为裁剪后。
  3. 执行 python training_checks.py --case clip。在三个独立参数副本中分别放入 NaN、+Inf、-Inf。程序使用计数优化器,先裁剪,只有裁剪成功才调用 step;捕获错误后仅记录,不能无条件继续更新。
  4. 三个失败场景实测都抛出 RuntimeError,step_calls=0,参数与操作前完全相等。另用全新有限梯度副本作正对照,step 调用一次且参数改变,避免“测试从来没走到更新代码”的假通过。

常见误区与边界

不要 catch 后继续 step,也不要把 NaN 改成零后宣称训练恢复。参数未变是本次“抛错阻止更新”的断言,不代表任意失败都没有修改模型缓冲区。范数裁剪限制梯度大小,不直接约束带动量或权重衰减优化器的最终参数步长;存在梯度累积时还要明确先汇总再裁剪。这里不覆盖 AMP 反缩放、稀疏梯度、分布式分片或真实阈值选择。

接下来确认同一套输入与更新是否能按记录重放。

第 04 节 / 共 5 节

随机状态与可复现性

目标与前置

目标是找到两次运行最早出现差异的位置,不只比较最终准确率。前置是本专题的单批更新,以及随机种子边界和训练循环与检查点。旧文已有标准库随机流和单进程恢复;这一节补真实 PyTorch 新进程、随机变换与双 worker 记录。

官方资料核查于 2026-10-23 JST,PyTorch 2.14 文档并对照 2.9。实际验证为 Python 3.14.0、PyTorch 2.9.1+cpu、NumPy 2.3.4、Windows 11,CPU 单线程、确定性算法开关开启。这里只在相同已测环境内采用完全相等标准。

种子是起点,状态是当前位置

每次随机调用都会推进对应发生器。Python、NumPy、PyTorch 和独立 Generator 可能各有状态;仅重置某一个全局 seed 不会重置所有对象。固定种子也不保证跨版本、硬件、平台或 CPU/GPU 完全一致。确定性选项可能变慢,或在没有确定实现时抛错,不能把它写成万能保证。Reproducibility 2.14、2.9 对照。

DataLoader 的 generator 控制随机采样与 worker 基础种子。worker 的 PyTorch 种子按基础种子加编号形成;worker_init_fn 可以读取 torch.initial_seed(),再初始化实际用到的其他随机库。多进程随机性 2.14、2.9 对照。

最小 Python 对照

运行 python 04-rng.py,直接观察同一个 PyTorch Generator 的状态:

文中示例 · python
import json
import torch

def rng_demo():
    g = torch.Generator().manual_seed(17)
    saved = g.get_state().clone()
    first = torch.rand(3, generator=g)
    second = torch.rand(3, generator=g)
    g.set_state(saved)
    replay = torch.rand(3, generator=g)
    assert torch.equal(first, replay)
    # Inspect the concrete next vector. This is a fixture observation,
    # not a mathematical promise that random draws can never collide.
    assert not torch.equal(first, second)
    return dict(first=first.tolist(), next_without_reset=second.tolist(), replay=replay.tolist())

print(json.dumps(rng_demo(), sort_keys=True))

首次向量约为 [0.434241,0.535110,0.830209];不重置继续取数约为 [0.123864,0.029321,0.549396];恢复保存的状态后回到首次向量。这是这份夹具的观测,不是宣称任意两次随机结果都绝不碰巧相同。

从状态片段走到真实训练

  1. 运行完整命令 python training_checks.py --case all --output results。脚本先验证同进程显式重置的两次训练相同,再启动两个新进程,分别跑 num_workers=0;然后再启动两个新进程跑 num_workers=2。
  2. 每次使用 8 个虚构 ID、每批 2 条、4 次 SGD 更新。小模型包含 Dropout;自制小扰动同时消耗 Python、NumPy 与 PyTorch 随机数。记录初始化参数摘要、首批 ID、每条样本的 worker 编号/种子/扰动、每步 loss、最终参数摘要、原始输出和预测标签。
  3. 对相同 worker 数的两次新进程运行逐项完全比较。实测两组分别一致,首批 ID 都是 [6,2]。双 worker 的种子分别为 2632164412242878610 和 2632164412242878611,避免把两个 worker 人为设成同一固定种子。
  4. 另外构造 NumPy 独立 Generator,证明 np.random.seed 不会把它重置。若使用自己的 Generator,必须显式管理它,而不是只调用全局播种。

两个 worker 数之间的最终参数摘要不同,这不算失败:改变 worker 数会改变随机变换流与主进程随机调用。这里验收的是相同配置能重放,没有要求跨配置完全一致。

常见误区与边界

Windows 使用 spawn;Dataset、worker 初始化函数定义在模块顶层,运行入口有 if __name__ == '__main__' 保护,不用 lambda。完整示例明确关闭 persistent_workers;尚未测试持久 worker、多轮预取队列、中途续训、分布式或 GPU。

关闭 cuDNN benchmark 只约束算法选择,不自动证明算法确定;本次 CPU 实验没有测试任何 cuDNN 行为。eval() 改模块行为,也不会关闭梯度记录。固定结果重放两次不能当成两个独立随机实验估计模型能力;多种子研究另见复现与消融。

最后把记录中的最佳模型变成可核对的重载产物。

第 05 节 / 共 5 节

最佳模型快照与重载

目标与前置

目标是证明保存的是验证集选中的那一轮,且后续训练不会悄悄改变它。先完成前面四节,知道参数、持久缓冲区、验证集与测试集的用途。基础权重保存和完整续训的区别见训练循环与检查点;这里补最佳快照独立性与跨进程产物验收。

官方资料核查于 2026-10-23 JST:Module API 2.14/2.9,以及标示 2.14.0+cu130 的保存教程。实际运行的是 Python 3.14.0、PyTorch 2.9.1+cpu、CPU float64;重载结构、输入预处理和设备保持一致。

state_dict 是状态入口,不是冻结的历史照片

state_dict() 的字典是浅拷贝,其中的张量仍引用模型参数和持久缓冲区。只写 best_state = model.state_dict(),后续修改仍可能改变它。改善验证指标时,应立即序列化,或用 deepcopy 保留独立副本;仅对字典再做一次浅拷贝不够。Module.state_dict 2.14、2.9 对照。

也可以逐张量 detach().clone(),但有额外状态的模型要确认覆盖完整;本练习直接 deepcopy 整份 state_dict。教程专门提醒最佳权重需要独立保存;加载时先反序列化字典,再交给同结构模型。官方 Saving and Loading Models。

最小 Python 对照

运行 python 05-snapshot.py。TinyClassifier 来自同包,包含 Linear 参数及 feature_scale 持久缓冲区:

文中示例 · python
import copy
import json
import torch
from training_checks import TinyClassifier

def snapshot_demo():
    model = TinyClassifier()
    alias = model.state_dict()
    frozen = copy.deepcopy(model.state_dict())
    with torch.no_grad():
        model.linear.weight.add_(1.)
        model.feature_scale.add_(1.)
    assert not torch.equal(alias["linear.weight"], frozen["linear.weight"])
    assert not torch.equal(alias["feature_scale"], frozen["feature_scale"])
    return dict(shallow_parameters_changed=True, shallow_buffer_changed=True,
                deepcopy_preserved=True)

print(json.dumps(snapshot_demo(), sort_keys=True))

显式原地修改让浅引用问题立即可见,避免依靠“再训练几轮也许会变”。完整实验还会在修改前立即写文件,并确认文件保留原值。

操作步骤与验收

  1. 同一时刻建立普通 state_dict、deepcopy、立即落盘三份记录;修改一个参数与一个缓冲区。实测普通引用跟着变,deepcopy 和已保存文件保持原值。
  2. 执行完整脚本。它固定 4 个虚构训练点与 2 个验证点,跑 4 轮;验证标签故意与训练方向相反,用于制造“最佳不是最后一轮”的回归夹具,不是合理数据集设计或模型质量评测。不使用测试集来选择最佳。
  3. 每轮正常训练后切 eval 并在 no_grad 中计算验证损失;只在更好时保存 deepcopy,同时记轮次、指标、结构与预处理版本。实测四轮验证损失约为 0.927612、1.150861、1.343389、1.510309,因此选中第 1 轮,末轮是 4。
  4. 保存进程退出后,另开进程用 map_location='cpu', weights_only=True 加载自己生成的 best.pt,用相同结构 strict=True 载入。对比每个参数、feature_scale 缓冲区、原始 logits、预测标签和最佳轮次;不只看“没报错”。

本次参数与持久缓冲区完全相同,logits 最大绝对差 0,预测标签相同,且加载状态摘要与最后一轮不同。实验还单独断言:eval 模式下正常前向仍能记录梯度,再用 no_grad 得到不带梯度的参考输出。

常见误区与边界

只比较 argmax 可能漏掉输出分数变化;只比较模型参数可能漏掉缓冲区;只比较文件名可能误载最后一轮。文件摘要能帮助确认产物身份,但不是模型效果或安全性的证明。

本包只加载自己生成的可信文件。weights_only=True 不应被解读为任意陌生文件都安全。这里保存的最佳产物用于推理重载;完整续训还要保存优化器、调度器、随机状态和数据位置等,不能用本节“输出一致”冒充完整恢复。跨设备、跨版本或其他 dtype 需要重新声明容差并实测。

完成后的交付

保留脚本与锁定依赖、环境、数据定义、错误场景日志、随机重放记录、最佳轮次和重载差异。用自己的话说明:本次证明了什么,哪些环节仍未运行。然后回到 FashionMNIST 练习,再按真实数据准备情况逐步扩大;这组小实验不自动把旧课程的全部练习变成已通过。