实验管理、可复现与调试方法论

从零到前沿 ML 自学课程 · 阶段0:数学与工具基础 · 能力点:实验管理与调试——可复现清单 + 故障诊断树 + 先过拟合一个 batch(90% 时间在调试)

第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,你必须把它们全部钉住,漏一个就前功尽弃。

要区分两个层次的"可复现":

最小可跑。下面这段只用 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 ③ 锁环境 钉死依赖版本(requirements / conda) ④ 固定数据划分 train / val / test 切分用固定索引 ⑤ 记 commit hash 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 加 eps 梯度裁剪 loss scaling ② loss 不降 症状 loss 水平不动 常见原因 lr 太小 标签错位 忘 shuffle 模型太小 对策 先过拟合一个 batch lr range test 查数据对齐 ③ OOM 显存溢出 症状 CUDA out of memory 常见原因 batch / 序列太大 累加带图张量 显存泄漏 对策 减小 batch 累加用 .item() 梯度累积 ④ 过拟合 症状 train 降 val 升 常见原因 模型太大 训练过久 对策 正则化 数据增强 早停 early stop 每支自上而下:症状(浅)→ 常见原因(中)→ 对策(高亮) 训练故障诊断树 根节点“训练出问题”分出四大分支,每支沿“症状→常见原因→对策”展开
训练故障诊断树:根节点"训练出问题"分出四大分支,每支沿"症状→常见原因→对策"展开。
类别症状常见原因对策
① 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 八成出在三件事上,养成出错先打印这三样的肌肉记忆:

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 的作用就是堵住这个洞。

动手练习

  1. 写一份 set_seed。仿照参考块写一个 set_seed(seed),钉住 random/numpy(torch 部分用注释写出即可)。然后用纯 NumPy 验证:调用 set_seed(7) 后生成的随机数组,两次调用逐位相同;不调用则不同。
  2. 给训练循环加 NaN 守卫。在"过拟合一个 batch"的循环里加一行:每步用 np.isfinite(loss) 检查,一旦非有限就打印当前 step 和触发的张量形状并 break。把 lr 调到 100 触发它。
  3. 过拟合判定(综合)。给"过拟合一个 batch"的网络故意制造一个 bug——把反向里 (p - y) 写成 (p + y)(梯度方向错)。观察 loss 是否还能压到接近 0?预期现象:loss 不降反升、卡在 6 左右压不下去(你可能还会看到 exp overflow 警告)。用这个例子向自己解释:"过拟合一个 batch 失败"为什么是通路 bug的信号而非调参问题。

掌握自检