第 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 来自同包的训练入口校验器;它不会修改标签。
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 后张量的梯度混作原分数的梯度。
操作步骤与验收
- 用固定的 2 样本、3 类分数,比较直接交叉熵与
log_softmax + nll_loss,同时比较损失和梯度,容差设为绝对1e-12。 - 保留“先 softmax”的错误分支,确认两种分支的 argmax 相同,但损失或梯度不相同。不要因为预测标签没变就接受它。
- 执行
python training_checks.py --case loss,分别注入硬标签越界、错误 dtype/shape、全部忽略,以及软标签负值、错误行和、错误 dtype/shape、NaN 和非有限 logits,共 10 个入口失败场景。每项都必须在训练前给出明确错误。 - 专门测试
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:
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;变化的只有梯度。
操作步骤与验收
- 先手算
w²在w=2处的导数 4。两次反向中间不清理应观察到 4、8;清理后再反向应回到 4。实测为 [4, 8, 4],参数未变。 - 执行
python training_checks.py --case reset。两个标量先共同参与一次 SGD 更新,建立动量;然后复制相同参数、优化器状态和已有梯度缓冲。优化器state_dict不保存参数的.grad,所以这里必须额外复制已有缓冲,才能公平对比清理方式。 - 第二批只让第一个参数参与损失。分别清成 None 和零,记录未参与参数的 grad、step 前后数值。两组都从 2.4 开始;None 组保持 2.4,零梯度组变为 1.86。这正是本次动量配置的实测结果。
- 验收两个附加边界:从未有 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:
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,也不会因为裁剪而更新。
操作步骤与验收
- 手算原 L2 范数 5。范数裁剪到 1 应约为
[0.6,0.8];逐值裁剪到 1 应为[1,1],后者范数是sqrt(2)。实测分别为[0.599999880000024,0.799999840000032]与[1,1],差异来自实现的安全小量,使用绝对容差1e-6。 - 同时记录返回的 5 与裁剪后约 0.9999998 的范数,不要把返回值误标为裁剪后。
- 执行
python training_checks.py --case clip。在三个独立参数副本中分别放入 NaN、+Inf、-Inf。程序使用计数优化器,先裁剪,只有裁剪成功才调用 step;捕获错误后仅记录,不能无条件继续更新。 - 三个失败场景实测都抛出 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 的状态:
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];恢复保存的状态后回到首次向量。这是这份夹具的观测,不是宣称任意两次随机结果都绝不碰巧相同。
从状态片段走到真实训练
- 运行完整命令
python training_checks.py --case all --output results。脚本先验证同进程显式重置的两次训练相同,再启动两个新进程,分别跑num_workers=0;然后再启动两个新进程跑num_workers=2。 - 每次使用 8 个虚构 ID、每批 2 条、4 次 SGD 更新。小模型包含 Dropout;自制小扰动同时消耗 Python、NumPy 与 PyTorch 随机数。记录初始化参数摘要、首批 ID、每条样本的 worker 编号/种子/扰动、每步 loss、最终参数摘要、原始输出和预测标签。
- 对相同 worker 数的两次新进程运行逐项完全比较。实测两组分别一致,首批 ID 都是
[6,2]。双 worker 的种子分别为2632164412242878610和2632164412242878611,避免把两个 worker 人为设成同一固定种子。 - 另外构造 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 持久缓冲区:
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))
显式原地修改让浅引用问题立即可见,避免依靠“再训练几轮也许会变”。完整实验还会在修改前立即写文件,并确认文件保留原值。
操作步骤与验收
- 同一时刻建立普通 state_dict、deepcopy、立即落盘三份记录;修改一个参数与一个缓冲区。实测普通引用跟着变,deepcopy 和已保存文件保持原值。
- 执行完整脚本。它固定 4 个虚构训练点与 2 个验证点,跑 4 轮;验证标签故意与训练方向相反,用于制造“最佳不是最后一轮”的回归夹具,不是合理数据集设计或模型质量评测。不使用测试集来选择最佳。
- 每轮正常训练后切 eval 并在 no_grad 中计算验证损失;只在更好时保存 deepcopy,同时记轮次、指标、结构与预处理版本。实测四轮验证损失约为
0.927612、1.150861、1.343389、1.510309,因此选中第 1 轮,末轮是 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 练习,再按真实数据准备情况逐步扩大;这组小实验不自动把旧课程的全部练习变成已通过。