第6课你已经能从零写出一个完整的训练循环,第7、8课让它吃得进数据、跑得上 GPU。但当你真的把它放出去训练,会发现一个残酷的事实:写代码只花了 10% 的时间,剩下 90% 都耗在"为什么 loss 是 NaN""为什么它就是不降""为什么换台机器结果对不上"。本课不教新模型,而是教一套工程肌肉——让你的实验可复现、可追踪、出了问题能系统排查而不是抓瞎调参。这是把"会写训练循环"变成"能可靠地做实验"的最后一块拼图。
读完这一课,你将能够
- 列出并实现一份可复现清单(随机种子、cudnn、环境锁、数据划分、commit hash),并区分"逐位可复现"与"结果可比"。
- 面对训练故障,用诊断树定位到 NaN/Inf、loss 不降、显存溢出(OOM)、过拟合 四类中的一类,并对症下药。
- 执行"先过拟合一个 batch"这一黄金第一步,用它判断前向/loss/反向通路是否接对。
- 用 np.isnan / np.isfinite 主动侦测发散,做 shape/device/dtype 三连排查。
- 用实验追踪工具(W&B 或 TensorBoard)记录曲线,并坚持"一次只改一个变量"。
可复现:让"同一个实验"真的相同
机制直觉。神经网络训练里到处是随机:权重初始化、数据 shuffle、dropout、数据增强……每一处随机都来自伪随机数生成器(PRNG),而 PRNG 由一个种子(seed)决定。固定种子,随机序列就完全确定,两次运行才会一致。问题是 Python 里同时存在好几个独立的 PRNG,你必须把它们全部钉住,漏一个就前功尽弃。
要区分两个层次的"可复现":
- 严格逐位可复现(bitwise):两次运行每个数字都一模一样。需要固定全部种子、关掉 GPU 上的非确定性算法、锁死环境。代价是可能变慢。
- 结果可比(comparable):两次运行曲线走势、最终指标在合理波动内一致,足以支撑"A 方案比 B 方案好"的结论。日常实验通常追求这个就够了。
最小可跑。下面这段只用 NumPy 演示种子的威力:固定种子跑两次,结果逐位相同;去掉种子,两次就不同。
import numpy as np
def run_once(seed=None):
if seed is not None:
np.random.seed(seed) # 钉住 numpy 的全局 PRNG
W = np.random.randn(3, 2) # 随机初始化
x = np.random.randn(2)
for _ in range(3): # 几步"更新"
W -= 0.1 * np.outer(W @ x, x)
return W.sum()
print("seed=0 第一次:", run_once(0))
print("seed=0 第二次:", run_once(0)) # 与上行完全一致
print("不固定 第一次:", run_once())
print("不固定 第二次:", run_once()) # 与上行不同
输出里前两行逐位相同(都是 5.3736...),后两行各不相同。这就是"固定种子"的全部意义。
真实 API 当指针。在 PyTorch 里你要钉住的不止 numpy 一个,而是四处,外加关掉 cudnn 的非确定性:
import os, random, numpy as np, torch
def set_seed(seed=42):
random.seed(seed) # Python 标准库 random
np.random.seed(seed) # NumPy
torch.manual_seed(seed) # PyTorch CPU
torch.cuda.manual_seed_all(seed) # 所有 GPU
os.environ["PYTHONHASHSEED"] = str(seed)
set_seed(42)
# 追求严格逐位可复现时再加这两行(会变慢):
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False # benchmark=True 会为速度选非确定算法
(注:新版 PyTorch 若要真正严格逐位一致,往往还需 torch.use_deterministic_algorithms(True) 并设 CUBLAS_WORKSPACE_CONFIG 环境变量;本课只作指针,知道"还有这层"即可,细节到要做逐位复现时再查。)
完整可复现清单不止种子。配图给出五项打勾清单:
- 固定随机种子:random / numpy / torch / cuda 四处都要。
- cudnn 确定性:deterministic=True、benchmark=False(只在需要逐位复现时)。
- 锁环境:上接第1课——锁死依赖版本(如 requirements.txt 钉死版本号或 conda 环境文件),换台机器才能装出同样的栈。
- 固定数据划分:train/val/test 的切分要用固定种子或落盘成固定索引,否则今天的验证集是明天的训练集,指标毫无意义。
- 记 commit hash:每次实验把当前代码的 git commit 记进日志(
git rev-parse HEAD)。三个月后想复现某条曲线,你得知道当时跑的是哪版代码。
黄金第一步:先过拟合一个 batch
机制直觉。新手遇到"loss 不降"第一反应是调学习率(learning rate, lr)。但很多时候问题根本不在调参,而在通路接错了:前向算错、loss 写反、反向没接上、标签和输入对不齐。怎么一眼区分"通路问题"和"调参问题"?
答案是:拿单个 batch,反复在它上面训练。一个足够大的网络一定能把这区区几个样本背下来,loss 应当被压到接近 0。如果连一个 batch 都过拟合不了,那不是 lr 的问题,是你的前向/loss/反向通路某处断了。这是开始任何认真训练前的黄金第一步——几十秒就能跑完,却能挡掉一整天的盲调。
最小可跑。一个纯 NumPy 两层网络,在单个玩具 batch 上做二分类,把 loss 从 1.1 压到接近 0:
import numpy as np
np.random.seed(0)
X = np.random.randn(8, 4) # 单个 batch: 8 个样本
y = (np.random.rand(8) > 0.5).astype(float)
W1 = np.random.randn(4, 16) * 0.5; b1 = np.zeros(16)
W2 = np.random.randn(16, 1) * 0.5; b2 = np.zeros(1)
sigmoid = lambda z: 1/(1+np.exp(-z))
lr = 0.5
for step in range(201):
h = np.maximum(0, X @ W1 + b1) # 前向: ReLU
p = sigmoid(h @ W2 + b2).ravel()
loss = -np.mean(y*np.log(p+1e-7) + (1-y)*np.log(1-p+1e-7))
dz = (p - y).reshape(-1,1) / len(y) # 反向
dW2 = h.T @ dz; db2 = dz.sum(0)
dh = dz @ W2.T; dh[h<=0] = 0
dW1 = X.T @ dh; db1 = dh.sum(0)
for p_, g_ in [(W2,dW2),(b2,db2),(W1,dW1),(b1,db1)]:
p_ -= lr * g_ # 原地更新
if step % 50 == 0:
print(f"step {step:3d} loss={loss:.4f}")
你会看到 loss 一步步往下走:step0≈1.11 → step50≈0.04 → step200≈0.006,最终降到 0.01 量级以下。注意前半程(step50)还在 0.04,别一看没立刻到 0.01 就以为跑错了——只要它持续单调下降,就说明通路是通的。这时再把它接到完整数据集上,如果反而不降,问题就缩小到了"数据/调参"层面,而不是"代码 bug"层面——排查范围一下小了一半。
故障诊断树:四类病、对症下药
机制直觉。训练出问题时,先别动手改参数,先分类。绝大多数故障落进四个桶,每个桶有典型症状、常见原因和标准对策。下图是这棵诊断树,遇到问题先走一遍:
| 类别 | 症状 | 常见原因 | 对策 |
|---|---|---|---|
| ① NaN / Inf | loss 突然变 nan 或 inf,之后全乱 | lr 太大导致发散;log(0)/除 0;混合精度下数值溢出;梯度爆炸 | 调小 lr;给 log 加 eps(epsilon,一个防 0 的小常数);做梯度裁剪;混合精度用 loss scaling(损失缩放,第8课);用 isnan 定位是哪一步先炸 |
| ② loss 不降 | loss 几乎水平,不动或抖动 | lr 太小;标签/数据错位;忘了 shuffle;模型太小/容量不够;忘了把梯度清零 | 先过拟合一个 batch;做 lr range test;检查 dataloader 的 (X, y) 是否对齐;确认 shuffle=True |
| ③ OOM(显存溢出) | CUDA out of memory,常在第一步或若干步后崩 | batch/序列太大;显存泄漏=把带计算图的张量累加进列表(如 total += loss 而非 loss.item()) |
调小 batch/序列长度;累计标量时用 .item() 或 .detach();用梯度累积换等效大 batch;混合精度省显存 |
| ④ 过拟合 | train loss 一直降,val loss 先降后升 | 模型相对数据太大;训练过久;没有正则 | 加正则(weight decay 权重衰减/dropout);数据增强;早停(early stopping);减小模型或加数据 |
最小可跑——NaN 侦测。不要在 loss 变 nan 后盲目调参,要主动抓。下面故意用过大的 lr 造发散,再用 np.isfinite 在第一时间拦截。注意:发散时 np.exp 类运算会在 stderr 打印 RuntimeWarning: overflow,这正是发散的征兆,属预期,不是"报错":
import numpy as np
np.random.seed(1)
W = np.random.randn(5); x = np.random.randn(5)
lr = 50.0 # 故意过大 -> 发散
for step in range(60): # 步数留够, 让它真的炸到 inf
grad = (W @ x - 1.0) * x # 拟合 W·x = 1 的玩具目标
W -= lr * grad
loss = (W @ x - 1.0)**2
if not np.isfinite(loss): # 主动侦测!
print(f"step {step}: 检测到非有限值 -> 停止")
print("isnan:", np.isnan(loss), " isfinite:", np.isfinite(loss))
break
print(f"step {step} loss={loss:.3e}")
你会看到 loss 以指数速度爆炸:几步内就冲到 1e+38 量级(step6 已是 5e+38),但 float64 上限约 1.8e308,所以要再多跑几十步才会真正 overflow 成 inf。到那一刻(本例约 step 57),np.isfinite(loss) 终于返回 False,break 分支真的触发一次,打印出"检测到非有限值"。关键不是"它炸了",而是 np.isnan / np.isfinite 让你在炸的瞬间就定位到 step 几、哪个张量,而不是事后对着一屏 nan 发呆。真实工程里,会在训练循环里对 loss 和关键梯度加这种断言。
shape / device / dtype 三连,与最重要的超参 lr
机制直觉。张量类 bug 八成出在三件事上,养成出错先打印这三样的肌肉记忆:
- shape:广播(broadcasting,第2课)把 (N, 1) 和 (N,) 悄悄"算对"成 (N, N),loss 看着能跑但全错。打印
.shape是第一动作。 - device:CPU 张量和 GPU 张量相加会直接报错;模型在 GPU、数据在 CPU 是最常见的 device 不匹配(第3课)。
- dtype:float32 vs float64、float16 溢出、整型标签当成浮点喂进 loss——打印
.dtype一眼看穿。
lr 是最重要的超参。四类故障里,NaN(太大)和 loss 不降(太小)都直指 lr。找 lr 的标准做法是 lr range test:让 lr 从极小指数级增大,画 loss-lr 曲线,loss 下降最快的那段对应的 lr 就是好起点。其他超参搜索方式——网格搜索(grid search)、随机搜索(random search)、贝叶斯优化(Bayesian optimization)——一句带过:随机搜索通常比网格更高效,贝叶斯在评估昂贵时更省,但先把 lr 调对,收益远大于纠结其他超参。
实验追踪:让每次实验都留下证据
机制直觉。跑十个实验后,你绝不可能靠记忆回答"哪个 lr 配哪个 batch 得到了那条最好的曲线"。实验追踪工具(experiment tracking)把每次运行的超参、loss/指标曲线、资源占用自动记录、可视化、可对比。
配套一条铁律——一次只改一个变量。如果这次同时改了 lr 和 batch size,结果变好了,你无法归因是哪个起的作用;结果变差了同样不知道怪谁。科学实验的对照原则在这里一模一样适用。
真实 API 当指针。两个主流选择,TensorBoard(轻量、本地)与 W&B(云端、协作强):
# 方案 A:TensorBoard
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter("runs/exp_lr0.001") # 一个实验一个目录
for step, (x, y) in enumerate(loader):
loss = train_step(x, y)
writer.add_scalar("train/loss", loss.item(), step)
writer.add_scalar("train/lr", optimizer.param_groups[0]["lr"], step)
writer.close()
# 终端运行: tensorboard --logdir runs
# 方案 B:Weights & Biases (W&B)
import wandb
wandb.init(project="my-proj", config={"lr": 1e-3, "batch_size": 64},
notes=f"commit {git_hash}") # 超参 + commit 一并记下
for step, (x, y) in enumerate(loader):
loss = train_step(x, y)
wandb.log({"train/loss": loss.item()}, step=step) # 自动画曲线
wandb.finish()
注意 W&B 的 config 把超参和(上一节的)commit hash 一起钉进这次运行——可复现清单和实验追踪在这里汇合。
调一调,观察现象
微任务 1:去掉种子。把可复现 demo 里 run_once(0) 改成 run_once()(不传种子)跑两次。预期现象:两次结果不同。为什么:没钉住 numpy 的 PRNG,每次 randn 从系统状态接着往下取,序列不再确定。
import numpy as np
def run_once(seed=None):
if seed is not None: np.random.seed(seed)
W = np.random.randn(3, 2); x = np.random.randn(2)
for _ in range(3): W -= 0.1 * np.outer(W @ x, x)
return W.sum()
print(run_once(), run_once()) # 改成 run_once(0), run_once(0) 对比
微任务 2:把 lr 调到压不下去。把"过拟合一个 batch"里的 lr = 0.5 改成 lr = 0.001。预期现象:200 步后 loss 几乎没动(实测 step0≈1.11 → step200≈0.75,远没背下来)。为什么:单个 batch 本该能过拟合,压不下去说明此刻是 lr 太小(而非通路 bug)——这正演示了诊断树"loss 不降"分支里"lr 太小"和"通路错"如何区分。(运行时可能看到 overflow encountered in exp 警告,属预期,不影响结论。)
import numpy as np
np.random.seed(0)
X = np.random.randn(8,4); y = (np.random.rand(8)>0.5).astype(float)
W1 = np.random.randn(4,16)*0.5; b1 = np.zeros(16)
W2 = np.random.randn(16,1)*0.5; b2 = np.zeros(1)
sig = lambda z: 1/(1+np.exp(-z)); lr = 0.001 # 改这个数
for step in range(201):
h = np.maximum(0, X@W1+b1); p = sig(h@W2+b2).ravel()
loss = -np.mean(y*np.log(p+1e-7)+(1-y)*np.log(1-p+1e-7))
dz = (p-y).reshape(-1,1)/len(y); dW2 = h.T@dz; db2 = dz.sum(0)
dh = dz@W2.T; dh[h<=0]=0; dW1 = X.T@dh; db1 = dh.sum(0)
W2-=lr*dW2; b2-=lr*db2; W1-=lr*dW1; b1-=lr*db1
if step%100==0: print(f"step {step} loss={loss:.4f}")
微任务 3:制造 log(0)。把上面 loss 里的 +1e-7(即 eps)全删掉,并把 lr 调回大值(如 5.0)让某些 p 被推到 0 或 1。预期现象:很可能在某步 loss 变成 nan / -inf(实测 lr=5.0 下约 step4 就触发);若没出现,就把 lr 再调大或多跑几步。为什么:log(0) = -inf,这就是诊断树 NaN 分支里"log(0)"那一条的真身;eps 的作用就是堵住这个洞。
动手练习
- 写一份 set_seed。仿照参考块写一个
set_seed(seed),钉住 random/numpy(torch 部分用注释写出即可)。然后用纯 NumPy 验证:调用set_seed(7)后生成的随机数组,两次调用逐位相同;不调用则不同。 - 给训练循环加 NaN 守卫。在"过拟合一个 batch"的循环里加一行:每步用
np.isfinite(loss)检查,一旦非有限就打印当前 step 和触发的张量形状并 break。把 lr 调到 100 触发它。 - 过拟合判定(综合)。给"过拟合一个 batch"的网络故意制造一个 bug——把反向里
(p - y)写成(p + y)(梯度方向错)。观察 loss 是否还能压到接近 0?预期现象:loss 不降反升、卡在 6 左右压不下去(你可能还会看到 exp overflow 警告)。用这个例子向自己解释:"过拟合一个 batch 失败"为什么是通路 bug的信号而非调参问题。
掌握自检
- 我能说出固定种子时必须钉住的四个 PRNG,并解释漏掉一个会怎样。
- 我能区分"严格逐位可复现"与"结果可比",并说出各自代价与适用场景。
- 我能默写可复现清单的五项:种子 / cudnn / 环境锁 / 数据划分 / commit hash。
- 遇到训练故障,我会先归类到 NaN/不降/OOM/过拟合 四桶之一,而不是直接调参。
- 我会把"先过拟合一个 batch"当作认真训练前的第一步,并能解释它为何能区分通路 bug 与调参问题。
- 我知道出现张量 bug 时先打印 shape/device/dtype,且知道 lr 是最该先调对的超参。
- 我理解"一次只改一个变量"是归因的前提,并会用 W&B/TensorBoard 记录每次实验。
可以先放过的点
现在不必纠结的:贝叶斯优化、混合精度的 loss scaling 细节、cudnn 各算法的确定性差异、W&B 的高级功能(sweep、artifact)。这些是"做大规模实验时的优化",不是"能不能正确做实验"的关卡。等到你已经稳定地跑通若干实验、开始为找最优超参或加速而烦恼时,再回来研究自动超参搜索和分布式可复现。本课你真正要带走的只有三句:固定种子再调参、出问题先过拟合一个 batch、一次只改一个变量。把这三条变成肌肉记忆,就足以支撑你后面所有的训练实验。