读完这篇,你能做什么
运行 CPU 实验,检查恢复后的参数是否与连续训练一致,并解释在 epoch(遍历一轮数据)中途恢复还缺少哪些状态。
学习理由、准备要求与知识点
为什么学它
只保存权重通常够用来推理,却可能让后续训练走上不同路线。记录训练循环的状态,才能复查和恢复实验。
先读这些内容
知识点清单
- batch与epoch和optimizer step的区别
zero_grad与backward和step顺序- train
/eval与梯度开关 - Dataset与Sampler和
DataLoader分工 - 尾批次和样本计数
- 模型与优化器
state_dict - 随机数及采样器状态
- 恢复边界与可复现范围
运行前先确认PyTorch 2.9.1+cpu、Python 3.14、Java 17。CPU 小数据实验在完整 epoch 边界恢复;不覆盖多进程预取队列、GPU 非确定性或跨版本逐位一致。
01先弄懂它是什么
要知道训练有没有正常进行,可以把一次更新拆开看。输入 x 的形状可以是 [B,d],表示本批有 B 个样本、每个样本有 d 个特征;分类标签 y 的形状是 [B],里面放类别编号。模型先算出 [B,C] 的 logits,也就是各类别分数;损失函数再比较分数与正确标签,反向传播算出梯度,优化器最后修改参数。完成这样一次参数修改,才叫一次 optimizer step。
记录“训练了多少步”时,要先说明一步指什么。epoch 是按当前采样规则走完一轮数据,batch 是一次交给模型的一批样本,它们不一定对应同样多的参数更新。例如梯度累积会处理多批后才更新一次;丢弃最后不足一批的数据会少看一些样本,有放回采样又可能重复取到同一样本。因此日志最好同时记下样本数或有效 token 数、批次位置和已完成的更新次数。
断点包含的不只是权重。AdamW 的一阶、二阶矩,学习率调度进度,随机数状态,混合精度缩放器和数据读取位置都会影响下一步。只加载模型再新建优化器,相当于用原来的参数开始一段新的优化过程。若目标是接着同一次实验训练,就必须明确恢复协议,而不是看模型能否成功执行一次前向。
文中这几个词,先认一认
- state_dict
- 对象提供的一份状态记录。模型和优化器各自记录的内容不同,保存它也不代表已经保存了训练所需的全部外部状态。
- DataLoader
- 按规定顺序从数据集取样,并组合成一批批数据供训练使用的入口;它本身不负责修改模型参数。
- 恢复边界
- 保存进度所对应的明确时刻,例如一轮数据已经处理完、该做的参数更新也已完成。恢复时要从这个时刻接着走。
02看看它怎么工作
典型训练循环先调用 model.train(),在每次更新前按需要清除梯度,再依次做前向计算、loss.backward() 和 optimizer.step()。梯度默认累加,若本来只想使用当前批次,却忘了清除,就改变了训练算法。
验证时 model.eval() 用来切换 Dropout 等模块的行为,no_grad 或 inference_mode 用来关闭不需要的自动微分。两者各管一件事,调用 eval 并不会自动关闭梯度。
DataLoader 的采样器提供索引,批采样器把索引组成批,数据集据此读取样本,collate_fn 合并为张量。固定随机种子只是起点,恢复时还需要恢复随机发生器当前状态。下面实验令 num_workers=0,并在 epoch 结束保存 DataLoader 的独立 generator 与 torch 全局 RNG;这样避开了工作进程预取和中途游标问题,使断言能精确检验本篇声称的范围。
保存时注意 state_dict 中的张量可能仍关联正在训练的对象,保留一份内存中的最佳状态时应复制或及时序列化。加载后先构造相同结构与优化器,再加载对应状态,最后恢复随机状态,避免新对象初始化消耗随机数后改变轨迹。实验用内存字节流完成序列化往返,以同时检查保存格式与恢复计算;它不证明跨硬件和所有算子都能逐位复现。
先跑通一个最小例子
保存为 experiment.py,用安装 PyTorch 2.9.1+cpu 的 Python 执行。仅 CPU、单进程、小分类数据;断言验证六个epoch的连续与三加三恢复轨迹。
展开收起代码 · experiment.py
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import io
import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader
torch.set_num_threads(1)
torch.use_deterministic_algorithms(True)
x = torch.linspace(-1, 1, 24).reshape(12, 2)
y = (x.sum(dim=1) > 0).long()
data = TensorDataset(x, y)
def create():
torch.manual_seed(17) # 模型初始化与Dropout使用全局随机状态
model = nn.Sequential(nn.Linear(2, 8), nn.ReLU(), nn.Dropout(.2), nn.Linear(8, 2))
optimizer = torch.optim.AdamW(model.parameters(), lr=.05)
generator = torch.Generator().manual_seed(29) # 数据顺序使用独立发生器
return model, optimizer, generator
def train(model, optimizer, generator, epochs):
loader = DataLoader(data, batch_size=4, shuffle=True, generator=generator, num_workers=0)
for _ in range(epochs):
model.train()
for bx, by in loader:
optimizer.zero_grad(set_to_none=True)
loss = nn.functional.cross_entropy(model(bx), by)
assert torch.isfinite(loss)
loss.backward()
optimizer.step()
full, full_opt, gen = create()
full.eval()
with torch.no_grad():
initial = nn.functional.cross_entropy(full(x), y).item()
train(full, full_opt, gen, 6)
part, part_opt, part_gen = create()
train(part, part_opt, part_gen, 3)
buffer = io.BytesIO()
torch.save({"model": part.state_dict(), "optimizer": part_opt.state_dict(),
"rng": torch.get_rng_state(), "generator": part_gen.get_state(), "epoch": 3}, buffer)
buffer.seek(0)
saved = torch.load(buffer, weights_only=True)
resumed, resumed_opt, resumed_gen = create()
resumed.load_state_dict(saved["model"])
resumed_opt.load_state_dict(saved["optimizer"])
resumed_gen.set_state(saved["generator"])
torch.set_rng_state(saved["rng"]) # 必须在重建对象之后恢复
train(resumed, resumed_opt, resumed_gen, 6 - saved["epoch"])
assert all(torch.equal(a, b) for a, b in zip(full.parameters(), resumed.parameters()))
assert all(torch.equal(full_opt.state[a]["exp_avg"], resumed_opt.state[b]["exp_avg"])
for a, b in zip(full.parameters(), resumed.parameters()))
assert len(list(DataLoader(data, batch_size=5, drop_last=False))) == 3
assert len(list(DataLoader(data, batch_size=5, drop_last=True))) == 2
resumed.eval()
with torch.no_grad():
first, second = resumed(x), resumed(x)
final = nn.functional.cross_entropy(first, y).item()
assert torch.equal(first, second) # eval使Dropout关闭
assert final < initial
print(f"CPU checkpoint checks passed; loss {initial:.4f} -> {final:.4f}")
核对运行结果打印 checkpoint checks passed;连续与恢复参数、Adam一阶矩完全相同,验证损失较初始下降。数值仅说明这个固定小实验。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
A["数据索引与随机状态"] --> B["取批次并前向"]
B --> C["反向累积梯度"]
C --> D["优化器更新"]
D --> E["epoch完成保存完整状态"]
E --> F["重建对象并加载状态"]
F --> A按顺序看,每一步做了什么
- 固定小数据、张量形状和采样器配置。
- 分别记录训练、验证与更新计数。
- 在明确边界保存模型、优化器及随机状态。
- 连续训练与恢复训练对照参数和状态。
03对照实现代码
PyTorch v2.9.1 · DataLoader.__init__;DataLoader._get_iterator;DataLoader._index_sampler
以下是原创单进程索引组批模型,不是PyTorch源码节选;省略多进程、预取、pin memory和collate类型处理。
def batches(dataset, order, size, drop_last=False):
if size <= 0:
raise ValueError("batch size")
for start in range(0, len(order), size):
indices = order[start:start + size] # 采样器先决定哪些样本被访问
if drop_last and len(indices) < size:
break # 尾批策略影响实际样本数
yield [dataset[i] for i in indices] # 读取和组批不等于模型更新
真实初始化根据数据集类别和shuffle选择采样器,再在需要自动组批时建立BatchSampler。
_get_iterator根据num_workers选择单进程或多进程迭代器,因此改变worker数会改变运行与恢复条件。
_index_sampler在自动组批时返回batch_sampler,数据抓取层接收的不是一个普通递增计数。
模型没有序列化迭代器能力;本篇恢复实验只承诺epoch边界,并独立保存随机发生器状态。
代码先调用谁,再调用谁
- Dataset与Sampler配置
BatchSampler产出索引批DataLoader迭代器读取- 训练循环计算损失
- 优化器更新并保存断点
04动手写一个例子
可恢复的固定排列批次游标
给定0到n-1的完整排列、批大小和当前位置,继续返回尚未消费的批次。快照必须复制排列与游标;dropLast丢弃不足一批的尾部。本算法只恢复索引顺序。
- 输入 1
排列[2,0,3,1,4],批2- 输出 1
[2,0]、[3,1]、[4];第一批后恢复从[3,1]开始
- 输入 2
相同排列,dropLast=true- 输出 2
只返回两个完整批
先验证排列无重复、无越界,确保一轮数据访问具有明确覆盖范围。
游标表示下一未消费位置,下一批结束位置由剩余长度和批大小决定。
快照复制状态,恢复不能引用仍在改变的外部数组;尾批丢弃后游标推进到末尾。
为什么这样写是对的当前位置之前的排列元素已返回或按尾批规则舍弃;当前位置之后的元素尚未返回。
保存排列和验证集合需要O(n),返回批次复制至多b个索引。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Snapshot(int[] order,int position) {}
static class Cursor {
private final int[] order;
private final int size;
private final boolean drop;
private int position;
Cursor(Snapshot saved,int size,boolean drop) {
if(size<=0||saved.position()<0||saved.position()>saved.order().length)
throw new IllegalArgumentException();
this.order=saved.order().clone(); // 防止调用者修改恢复后的排列
boolean[] seen=new boolean[order.length];
for(int x:order) {
if(x<0||x>=order.length||seen[x])throw new IllegalArgumentException();
seen[x]=true;
}
this.position=saved.position(); this.size=size; this.drop=drop;
}
int[] next() {
int remain=order.length-position;
if(drop&&remain<size){position=order.length;return new int[0];}
int end=position+Math.min(size,remain); // 用剩余长度避免加法越界
int[] batch=Arrays.copyOfRange(order,position,end);
position=end; return batch;
}
Snapshot save(){return new Snapshot(order.clone(),position);} // 快照独立于可变数组
}
static void check(boolean v){if(!v)throw new AssertionError();}
public static void main(String[] args){
Cursor c=new Cursor(new Snapshot(new int[]{2,0,3,1,4},0),2,false);
check(Arrays.equals(c.next(),new int[]{2,0}));
Cursor restored=new Cursor(c.save(),2,false);
check(Arrays.equals(restored.next(),new int[]{3,1}));
check(Arrays.equals(restored.next(),new int[]{4}));
check(restored.next().length==0);
Cursor dropped=new Cursor(new Snapshot(new int[]{0,1,2},2),2,true);
check(dropped.next().length==0&&dropped.save().position()==3);
check(new Cursor(new Snapshot(new int[0],0),1,false).next().length==0);
boolean bad=false;
try{new Cursor(new Snapshot(new int[]{0,0},0),1,false);}catch(IllegalArgumentException e){bad=true;}
check(bad);
System.out.println("7 batch cursor checks passed");
}
}这些特殊情况也要试一试
- 空数据立即结束。
- 中途恢复仍需确认已完成更新与批游标在同一提交边界。
- 真实多worker预取位置可能超前于已用于训练的位置。
进阶选读 · 生产拓展:数据位置与更新提交协同
场景训练被强制终止时,样本已预取但未必完成优化器更新。
做法区分读取游标与已提交游标,保存可重建的采样排列、累积窗口状态和事务性断点清单;恢复从已提交边界重放。
复杂度快照成本随参数、优化器状态和采样元数据增长,不能只按游标常数空间估计。
好处与代价更精细恢复减少重复工作,却增加预取和随机增强状态的管理难度。
05项目里怎么用
训练指标应按真实样本数或有效token数加权,不能将大小不同的尾批均值直接平均。当一次更新失败或因非有限梯度跳过时,调度器计数与日志也要说明是否前进,否则学习率轨迹与标称步数会分离。
保存断点应写到新的临时文件并在完整写入后发布清单,保留至少一个已验证可恢复版本。使用可信来源的断点,记录代码、数据版本与配置;加载任意外部序列化文件不是本实验的目标。
先用CPU小模型做中断对照,再扩大设备与worker数量。更多worker可能提高吞吐,也可能让随机增强、样本重复和恢复位置更复杂。训练下降只证明优化过程在当前数据上工作,最终效果仍需独立验证集和测试协议。
容易踩的坑
- 把eval当成关闭autograd。
- 忘记
zero_grad导致非预期梯度累积。 - 只保存权重却声称完整恢复Adam训练。
- 固定seed后忽略采样器当前位置与随机状态。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| epoch边界断点 | 状态边界清楚 | 中断可能重做较多批次 | 初学与可控数据集 |
| 更新边界断点 | 减少重复计算 | 需管理数据及累积状态 | 长epoch训练 |
| 多worker预取 | 提高数据供给能力 | 调试和恢复更复杂 | 读取或增强成为瓶颈 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01batch与optimizer step一定一一对应吗?
展开参考答案与推导
不一定,梯度累积或跳过更新会改变对应关系。
02为何要保存Adam状态?
展开参考答案与推导
动量和二阶矩影响下一次更新,只加载权重会改变优化轨迹。
03eval与no_grad有什么区别?
展开参考答案与推导
eval改变模块训练行为,no_grad控制自动微分记录,通常验证需要二者配合。
换个情况,还会处理吗
01恢复后首步损失不同,先排查什么?
展开参考答案与推导
检查数据顺序、随机状态、模型模式、优化器状态和恢复边界。
02最后一批只有一个样本,怎么统计epoch平均损失?
展开参考答案与推导
用各批损失总和除以总样本数,或按批样本数加权。
03增加worker后重复样本,应该检查什么?
展开参考答案与推导
检查IterableDataset的worker分片和各worker随机种子,不能只看DataLoader长度。
写代码试一试
01五个样本批大小二,不丢尾批有几批?
- 输入 / 条件
n=5,b=2- 输出 / 预期
3批
提示 向上取整。
展开参考答案与推导
两个完整批和一个单样本批。
02游标为2的排列[2,0,3,1,4]下一批是什么?
- 输入 / 条件
b=2- 输出 / 预期
[3,1]
提示 游标索引的是排列位置。
展开参考答案与推导
从排列下标2开始复制两个元素。
03快照为什么要复制数组?
- 输入 / 条件
保存后外部修改原数组- 输出 / 预期
恢复顺序应保持不变
提示 隔离可变引用。
展开参考答案与推导
否则快照内容会被后续代码修改,无法复查原顺序。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
运行 CPU 实验,检查恢复后的参数是否与连续训练一致,并解释在 epoch(遍历一轮数据)中途恢复还缺少哪些状态。
本篇重点加练
精确恢复不仅保存权重,还要定义样本消费与更新边界,重点加练中途状态。
- 尾批次和样本计数
- 恢复边界与可复现范围
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
数据量为 11、批大小 4、每两批累积更新一次。分别在保留和丢弃尾批的设置下画一个 epoch 的训练事件表。
- 提交内容
- 事件时序及样本计数
- 怎样算完成
- batch 与更新次数分开
- 尾窗口处理有声明
练到的知识点batch与epoch和optimizer step的区别 · zero_grad与backward和step顺序 · 尾批次和样本计数
- 作业 02原理问答
为含 Dropout 的模型整理 train
/eval、梯度开关和DataLoader洗牌三类控制的职责;给出能够分别检验它们的观测。- 提交内容
- 职责表与检验方案
- 怎样算完成
- 模式与求导不混用
- 采样责任边界清楚
练到的知识点train/eval与梯度开关 · Dataset与Sampler和DataLoader分工
- 作业 03原理问答
一个微批已经完成 backward,但还未
optimizer.step时中断。列出从该点恢复与回退到上一更新边界各自需要的数据和允许重复的工作。- 提交内容
- 两种恢复契约
- 怎样算完成
- 消费与更新边界明确
- 不遗漏尚未应用的训练状态
练到的知识点尾批次和样本计数 · 恢复边界与可复现范围
综合应用
- 作业 04编程实践
用 CPU 小网络在第三次参数更新后保存断点,恢复后继续五次更新,并与不中断运行比较参数、优化器状态及损失。
- 提交内容
- 两条运行日志与断点清单
- 怎样算完成
- 恢复边界明确
- 后续数据顺序一致
练到的知识点模型与优化器state_dict · 随机数及采样器状态 · 恢复边界与可复现范围
- 作业 05排错实验
构造尾批只有一个样本且损失明显偏大的案例,比较逐批平均与按样本汇总的 epoch 指标;为日志统计写独立验收测试。
- 提交内容
- 日志复现与统计测试
- 怎样算完成
- 计数对应实际参与样本
- 优化损失和报告指标分开
练到的知识点zero_grad与backward和step顺序 · 尾批次和样本计数
- 作业 06面试场景
实验中途改变 worker 数量后无法逐位复现。制定诊断计划,区分样本身份、采样顺序、数据增强随机性与硬件算子变化。
- 提交内容
- 诊断清单及可复现范围声明
- 怎样算完成
- 每个随机源有证据字段
- 不承诺未经验证的逐位一致
练到的知识点Dataset与Sampler和DataLoader分工 · 随机数及采样器状态 · 恢复边界与可复现范围
- 作业 07编程实践
CPU 训练使用七个样本、批大小三,在第二个微批后模拟中断;分别采用声明的两种恢复策略,对账样本 ID、梯度与最终更新。
- 提交内容
- 恢复程序和样本账本
- 怎样算完成
- 恢复范围与实现一致
- 重复及遗漏样本可定位
练到的知识点尾批次和样本计数 · 恢复边界与可复现范围
- 作业 08排错实验
数据加载器按批报告均值,某些样本被标记无效。自建含尾批的小数据,审计 epoch 报告究竟按读取数还是有效目标数统计。
- 提交内容
- 指标对账实验
- 怎样算完成
- 计数来源明确
- 被过滤样本的去向完整
练到的知识点尾批次和样本计数
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
设计断点落盘协议,模拟写文件中断和配置不兼容;要求启动时能区分完整断点、损坏断点与不适配断点。
- 提交内容
- 协议、CPU 故障注入及日志
- 怎样算完成
- 旧断点不被破坏
- 身份和完整性校验可复核
练到的知识点模型与优化器state_dict · 随机数及采样器状态 · 恢复边界与可复现范围
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 跨域迁移
将训练恢复与数据库事务比较:一次更新的“提交”应以什么事件为界?设计进程在不同位置退出的案例,并说明该类比在哪些随机计算中不成立。
- 提交内容
- 提交模型与故障窗口表
- 怎样算完成
- 外部副作用和训练状态分开
- 类比限制有具体例子
练到的知识点尾批次和样本计数 · 恢复边界与可复现范围
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
PyTorch2.9.1 DataLoader源码 (新标签页)↗
核对采样、组批和迭代器选择。
- 深入参考文档英文
PyTorch2.9.1 Sampler源码 (新标签页)↗
查看
RandomSampler与BatchSampler的顺序和尾批规则。 - 深入参考文档英文
PyTorch保存与加载教程 (新标签页)↗
理解一般训练断点与仅推理权重的区别,API以固定基线为准。
- 实践实践指南英文
PyTorch:相同随机种子为何还不够 (新标签页)↗
先读开头限制和 Controlling sources of randomness,再看 Avoiding nondeterministic algorithms。为一个实验列出种子、随机库、调用顺序、框架版本、设备五栏;用已有小例子重复运行并保存输出。首轮跳过 CUDA 注意力后端细节;不要把固定种子当成跨版本或跨设备保证。 固定版本公开文档,无需账号。只读和记录环境无需安装;真实
PyTorch或 CUDA 行为未在本机复跑。确定性设置可能降低性能,也可能遇到不支持的操作。 - 实践实践指南英文
Keras:一轮训练里有多少批、多少次更新 (新标签页)↗
先读 fit 的
batch_size、epochs、initial_epoch、steps_per_epoch。给十条数据画每批四条的账本,先声明保留末批、每批更新一次;再讨论主动截短一轮会改变什么。另看 Adam 的gradient_accumulation_steps,分开计批次与参数更新。首轮跳过编译优化、分布式训练和性能比较。 API 正文公开可读,纸上计数无需安装。实际训练需 Keras 和支持的后端;本轮未运行。参数语义按 Keras 3 解释,不当作所有框架的统一契约。