很多人第一次打开 PyTorch 的教程,照着敲完那个不到八十行的图像分类例子,跑出个准确率,心里觉得"我会了"。可关掉教程自己动手,往往在数据读入那一步就卡住三天。我在带人入门深度学习时反复看到一个规律:新手卡住的地方,几乎从来不是模型结构设计,而是张量形状对不上、梯度忘了清零、显存莫名其妙爆掉、loss 打印出来是个带 grad 的对象。这篇 deep learning with pytorch 的第一篇,我想讲的就是把这些"地基"打牢——不追求炫技,只求你能独立搭出一个能跑通、能调试、能复现的最小训练闭环。
内容面向两类人:一类是刚学完 Python、想正经上手深度学习的同学;另一类是用过别的框架、想切到 PyTorch 的人。前者需要建立正确的心智模型,后者需要知道这套框架的"设计哲学"和别的框架差在哪。全文所有代码都在 CPU 上可以直接跑,涉及 GPU 的部分我会标注清楚,你不必先配好显卡才能跟着做。
1. 把"跑通一次完整训练"当作唯一目标
1.1 为什么第一篇不该从网络结构讲起
初学者最常见的路径是:先看 ResNet 的论文,再看 Transformer 的结构图,然后试图一次性写出一个"完整"的模型。这个路径的问题在于,它把工程实现中最不重要的部分放在了最前面。网络结构在今天高度模块化,一个残差块三十行代码就能写完,抄错一个维度改一天也能改对。真正耗时间的是外围:数据怎么进来、形状怎么对齐、梯度怎么流动、显存为什么不够、结果为什么不复现。
我见过太多人写了三百行模型定义,却从来没验证过自己的损失函数输入形状是不是对的。深度学习的调试成本主要来自"静默错误"——代码不报错,训练也在跑,loss 也在缓慢下降,但下降的是个错误的目标。要尽早发现这类问题,唯一有效的办法是先建立一个能端到端验证的最小闭环,跑通之后再往里加复杂度。
所以第一篇的目标很明确:用一个极小的数据集、一个极简的模型,把输入、前向、损失、反向、更新这五步串起来,并且每一步都能被单独打印、单独检查。
1.2 一次训练里必须存在的五个零件
把这五件事拆清楚,比记住任何 API 都重要。
| 零件 | 作用 | 出问题时最典型的症状 |
|---|---|---|
| 张量(Tensor) | 承载数据与参数 | 形状不匹配、设备不一致 |
| 数据集与加载器 | 按批次喂数据 | 批次维度丢失、顺序错乱 |
| 模型(nn.Module) | 定义前向计算 | 参数没注册、评估模式没切 |
| 损失函数 | 把输出变成标量 | 输入形状不对、归约方式选错 |
| 优化器 | 根据梯度更新参数 | 忘了清零、参数组没传进去 |
这五个零件之间存在严格的依赖顺序:数据决定输入形状,输入形状决定模型第一层的维度,模型输出决定损失函数的选择,损失函数的可导性决定梯度能否流回参数,优化器只负责最后那一步更新。任何一个环节错位,后面的全部作废。我在实际项目里排查问题的顺序永远是自前向后:先打印一个批次的数据形状,再打印模型输出形状,再看损失值是否合理,最后才怀疑超参数。
1.3 版本固定这件事,比你想的更重要
框架的 API 在版本之间会有细节调整,尤其是默认行为和警告信息。入门阶段遇到报错时,你最不希望发生的事情是"搜到的答案和你的版本不匹配"。我的习惯是在项目根目录放一个依赖清单文件,把主框架版本、数值计算库版本、可视化库版本全部写死,新建环境时一次性装好。
# 创建独立环境并安装依赖,避免污染全局 python -m venv .venv source .venv/bin/activate # Windows 下改为 .venv\Scripts\activate pip install -r requirements.txtrequirements.txt里至少要写清楚主版本号,比如torch==2.3.0这种精确到小版本的写法。很多人习惯写torch>=2.0,第二天在新机器上装出一个行为略不一样的版本,然后开始怀疑自己的代码。这不是代码问题,是环境问题,但排查起来极其浪费时间。
还有一件小事:装完之后立刻验证一次。
import torch print(torch.__version__) print(torch.rand(2, 3).device) print(torch.backends.mps.is_available() if hasattr(torch.backends, "mps") else "n/a")确认能创建张量、能打印设备信息,环境就算初步可用。如果打算用 GPU 加速,这一步能立刻告诉你装的到底是 GPU 版还是纯 CPU 版——装错版本是入门期最高频的无效折腾之一。
2. 张量的三个心智锚点:形状、设备、数据类型
2.1 形状背后的真正概念是步长
新手对张量的理解往往停留在"多维数组"。这个理解不够用,因为一旦涉及转置、切片、拼接,你必须知道内存是怎么排的。PyTorch 的每个张量除了 shape,还有一个 stride(步长),它描述"沿着某个维度走一步,在底层一维内存里需要跳过多少个元素"。
x = torch.arange(12).reshape(3, 4) print(x.shape, x.stride()) # torch.Size([3, 4]) (4, 1) y = x.permute(1, 0) # 转置,只是换了看待内存的方式 print(y.shape, y.stride()) # torch.Size([4, 3]) (1, 4) z = y.reshape(-1) # 这里会报错或触发隐式拷贝permute和transpose不复制数据,只改元信息。所以转置之后,张量在内存里依然是连续存放的原顺序,只是步长变了。这时候如果调用view,框架会要求内存连续,不满足就报错。解决办法是显式调用.contiguous()让它真正复制一份排好序的内存,或者干脆用reshape——reshape在能视图的情况下返回视图,不能的时候自动拷贝。
我在实际代码里的选择标准很简单:中间计算优先用reshape,因为不用关心连续性;只在明确需要视图语义(比如原地修改要反映到原张量)时才用view。这条经验能省掉大量莫名其妙的报错。
还有一类高频错误来自批处理维度。单张图片的形状是[通道, 高, 宽],模型期望的是[批次, 通道, 高, 宽]。加载器通常会自动加批次维,但你自己手写测试代码时最容易忘。
img = torch.randn(3, 32, 32) # model(img) 大概率报维度错误 out = model(img.unsqueeze(0)) # 显式补一个批次维 print(out.shape) # [1, 类别数]养成习惯:凡是自己拼的输入,先print形状再送进模型。这一个动作能挡掉入门阶段一半以上的报错。
2.2 广播规则要能手动推算,而不是碰运气
广播是让形状不同的张量能做逐元素运算的机制。它的规则从最右边的维度开始向左逐位比对:相等则继续,其中一个为 1 则扩展成另一个的大小,缺位则补 1 后再比对。听起来绕,举一次例子就清楚了。
a = torch.randn(4, 1, 3) b = torch.randn(2, 3) print((a + b).shape) # torch.Size([4, 2, 3])推一遍:右起第一位,3 对 3,相等;第二位,1 对 2,1 被扩展成 2;第三位,4 对缺位,缺位视为 1,扩展成 4。最终是[4, 2, 3]。
广播的危险在于它"太宽容"。你本意是做逐样本的加权,结果因为某一位恰好是 1,程序悄悄按另一种方式算了,还不报错。我的做法是在写带广播的代码时,用注释标出推理后的形状,并在第一次运行时打印一次实际结果形状。等这段逻辑稳定了再把打印删掉。这看起来笨,但比事后发现损失曲线诡异强得多。
另外提醒一个容易忽略的点:广播产生的结果张量,其梯度的求和方式会自动沿被扩展的维度进行。这恰好是数学上正确的行为,但如果你写的是自定义损失,最好自己显式地做sum或mean,别依赖框架猜你的意图。
2.3 数据类型选错的代价是显存和精度双重损失
默认的浮点类型是 32 位。做推理时很多人会切成 16 位来省显存、提速度,这一步没问题。但在训练里,尤其是小模型小数据集,盲目上 16 位会导致数值下溢,梯度变成 0 或 NaN,然后你会花一天时间怀疑模型结构。
x = torch.randn(8, 8, dtype=torch.float32) w = torch.randn(8, 8, dtype=torch.float64) # x @ w 会直接报类型不匹配混合精度训练有成熟的方案,但它的前提是你已经能稳定跑通全精度版本,并且知道怎么判断数值是否正常。入门阶段先用 32 位把流程跑对,之后再谈优化,顺序不能反。
整数类型也有坑。分类任务的标签必须是 64 位整型,也就是torch.long。如果你用默认的整型构造标签,交叉熵损失会直接报错。这不是设计者故意刁难,而是因为索引类操作对整型位宽的要求是硬性的。
label = torch.tensor([3]) # 默认 int64,可用 label_bad = torch.tensor([3.0]) # float32,交叉熵会拒绝 label_bad2 = torch.tensor([3], dtype=torch.int32) # 也会拒绝同理,索引切片、gather、embedding这类操作都对整型位宽敏感。记住"标签一律 long,输入一律 float"这条口诀,能避开相当一批报错。
3. autograd 不是黑盒,它是需要你主动管理的状态
3.1 计算图是在前向过程中建起来的
很多人把自动求导理解成"框架自动算梯度"。这句话不算错,但漏掉了关键的一半:框架是在前向传播的同时,记录下每一步运算,构建一张有向无环图,反向传播只是沿这张图按链式法则回传。这意味着两件事——只有参与过前向运算并且requires_grad=True的张量才会被记录;图的生命周期默认只到反向传播结束。
import torch w = torch.tensor([2.0], requires_grad=True) x = torch.tensor([3.0]) loss = (w * x - 10) ** 2 print(loss.requires_grad) # True,因为链路上有 w loss.backward() print(w.grad) # tensor([-24.])手动验算一下:loss = (2*3 - 10)^2 = (-4)^2 = 16,对 w 的导数2*(2*3-10)*3 = -24。和你手算一致,说明这条链路是通的。
理解到这一层,你就能解释为什么有些操作会把梯度断掉。比如把张量转成 Python 数值、把张量存进列表再取出来、在with torch.no_grad()里做运算,都会让图断开。反向传播时如果报 "element 0 of tensors does not require grad",八成就是中途断链了。
3.2 梯度是累加的,这正是你每次要清零的原因
这是入门期最经典的一个认知:backward()是把梯度加到grad属性上,而不是覆盖它。框架这样设计是为了支持梯度累加的进阶用法,但对新手来说,忘了清零就是灾难——第二次迭代的梯度等于第一次加第二次。
w = torch.tensor([1.0], requires_grad=True) (w * 2).backward() print(w.grad) # tensor([2.]) (w * 2).backward() print(w.grad) # tensor([4.]) 累加了,不是 2训练循环里的optimizer.zero_grad()就是干这个的。顺序也有讲究:标准写法是先清零、再前向、再反向、再更新。
for batch in loader: optimizer.zero_grad(set_to_none=True) # 清零 out = model(batch["x"]) # 前向 loss = criterion(out, batch["y"]) # 损失 loss.backward() # 反向 optimizer.step() # 更新这里有个小优化:set_to_none=True会把梯度直接置为 None 而不是填零,省一次内存写入,在新版本里是推荐的默认做法。不过要注意,如果你有依赖"梯度存在与否"来判断逻辑的代码,切换之后行为会变。入门阶段先按上面的写法,稳定后再微调。
梯度累加也不是纯粹的坑,它是"用小显存模拟大批次"的标准手段:连续前向反向若干次、不清零,最后再step一次,等效于批次扩大若干倍。这个技巧在显存吃紧时非常实用,但要用对,学习率得跟着调整。
3.3 detach 与 no_grad 的分工要说清楚
这两个东西经常被混着用,实际场景不同。
torch.no_grad()是一个上下文管理器,块里所有运算都不记录梯度,适合推理、评估、参数统计。它省的是内存和计算。
.detach()作用于单个张量,把它从计算图中摘下来,返回一个共享内存但不带梯度信息的张量。适合"我需要这个数值,但不想让梯度从这里流过去"的场景,比如记录损失、做指标计算、或者在自定义训练逻辑里截断某条支路。
with torch.no_grad(): pred = model(x) # 不建图 acc = (pred.argmax(1) == y).float().mean() # 或者 pred = model(x) running_loss = loss.detach() # 只取数值,不让它挂在图上我见过一个典型的内存泄漏:在训练循环里把loss直接 append 到一个列表里,最后统一画图。这样每一次的 loss 张量都把整张计算图留着,几十轮之后显存就满了。正确写法是 appendloss.item()或loss.detach()。这个小细节在跑大模型时是致命的,在小模型上只是慢一点,所以很多人一直没意识到自己写错了。
3.4 参数冻结的实现思路
微调预训练模型时,常常要冻结一部分参数。做法是把对应参数的requires_grad置为 False,同时告诉优化器只优化剩下的那部分。
for p in model.backbone.parameters(): p.requires_grad = False optimizer = torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, )注意这里的顺序:先改标志位,再建优化器。如果先建优化器再改,优化器里已经持有那些参数了,step时仍然会去更新(虽然梯度是 None 或 0,行为未必符合预期)。另外,被冻结的参数在state_dict里依然存在,保存和加载不受影响,这一点和"删除参数"完全是两回事。
4. nn.Module 的三种写法与选型依据
4.1 顺序堆叠:够用就好,别为了优雅硬套
最简单的模型写法是把层按顺序装进容器。
import torch.nn as nn model = nn.Sequential( nn.Flatten(), nn.Linear(784, 256), nn.ReLU(), nn.Dropout(0.2), nn.Linear(256, 10), )这种写法的优点是短、可读、参数自动注册、state_dict自动生成。缺点是没法表达分支、残差、多输入这类结构。我的判断标准是:如果前向就是一条直线,就用Sequential,不要为了显得专业而写成类。代码越简单,调试成本越低。
需要提醒的是Sequential里的层是按传入顺序执行的,Dropout放在ReLU之前还是之后会实际影响结果。这类顺序问题在堆叠写法里特别隐蔽,因为它没有显式的forward让你一眼看出数据流向。所以我只有在结构确实简单时才用它。
4.2 显式 forward:把数据流画出来
一旦涉及分支或复用,就该换成类写法。
class TinyNet(nn.Module): def __init__(self, in_dim=784, hidden=256, num_classes=10): super().__init__() self.stem = nn.Linear(in_dim, hidden) self.block = nn.Sequential( nn.Linear(hidden, hidden), nn.GELU(), nn.Linear(hidden, hidden), ) self.head = nn.Linear(hidden, num_classes) self.act = nn.GELU() def forward(self, x): x = self.act(self.stem(x)) x = x + self.block(x) # 残差连接,Sequential 表达不了 return self.head(x)两个要点。第一,super().__init__()必须先调用,否则参数注册的机制没初始化,后面赋值子模块会报错。第二,所有带参数的层必须作为属性赋给self,只有这样才会被自动注册进parameters()。如果你把层存在一个普通 Python 列表里,它们不会出现在model.parameters()中,优化器也就看不到,训练时这些层的参数永远不更新——而且不报错。
# 错误写法:列表里的层参数不会被注册 self.layers = [nn.Linear(64, 64) for _ in range(3)] # 正确写法:用 ModuleList self.layers = nn.ModuleList([nn.Linear(64, 64) for _ in range(3)])ModuleList和普通列表的区别就在这里。同理还有ModuleDict,用于需要按名字索引子模块的场景。这两个容器是入门必知,因为"参数没被优化器接住"这种问题几乎无法从 loss 曲线上看出来——你会看到 loss 不动,然后去调学习率、换优化器,全都没用。
4.3 state_dict 才是权重的真身
保存模型时最常见的错误是直接torch.save(model)。这样存的是整个对象,依赖类的定义路径,换一个目录结构加载就可能失败。正确做法是只存参数字典。
# 保存 torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "epoch": epoch, "best_acc": best_acc, }, "ckpt.pt") # 加载 ckpt = torch.load("ckpt.pt", map_location="cpu") model.load_state_dict(ckpt["model"]) optimizer.load_state_dict(ckpt["optimizer"]) start_epoch = ckpt["epoch"] + 1要一起存优化器状态,是因为带动量的优化器(Adam 系列)内部维护着一阶、二阶矩估计,不恢复的话相当于热身白做,续训初期 loss 会有一个明显的抖动。这一点在长训练里影响很大,短实验里看不出来,所以经常被忽略。
map_location="cpu"的作用是把权重先落到 CPU 上,避免在没有 GPU 的机器上加载时报设备错误。加载完再model.to(device)就行。
顺便说一个排查技巧:如果load_state_dict报键名不匹配,先用两行代码对比一下。
print(set(model.state_dict().keys()) - set(ckpt["model"].keys())) print(set(ckpt["model"].keys()) - set(model.state_dict().keys()))前者是模型有但权重里没有的,后者是权重里有但模型没有的。多半是改了层名或者多包了一层容器,看差集一眼就能定位。
4.4 初始化不是玄学,是可以验证的
默认初始化在浅层网络上通常够用,但层数一多,激活值的方差会逐层变化。判断方法很直接:造一个随机输入,逐层打印输出的均值和标准差。
x = torch.randn(64, 784) for name, m in model.named_children(): x = m(x) print(f"{name:12s} mean={x.mean():.4f} std={x.std():.4f}")如果某一层之后标准差骤降到 0.01 以下,说明信号被压得太狠,后面基本学不动;如果飙到几十,反向时梯度容易爆。这时候再考虑换初始化方式,比如对线性层用 Kaiming 正态初始化,配合 relu 类激活。
def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, nonlinearity="relu") if m.bias is not None: nn.init.zeros_(m.bias) model.apply(init_weights)apply会递归遍历所有子模块,是个很好用的工具。要注意它也会作用到你不希望改动的层上,所以里面的isinstance判断要写清楚。
5. 手写训练循环:每一行都有它的理由
5.1 train 与 eval 的切换不是可选动作
model.train()和model.eval()改变的是某些层的行为:Dropout 在训练时随机置零、评估时透传;BatchNorm 在训练时用当前批次的统计量、评估时用滑动平均。忘记切换不会报错,但会让你在验证集上得到一个偏低的准确率,然后你会以为模型欠拟合,加大训练轮数,越走越偏。
model.train() for batch in train_loader: ... model.eval() with torch.no_grad(): for batch in val_loader: ...我的习惯是把评估逻辑封装成一个函数,函数第一行就写model.eval(),最后一行写model.train(),这样调用方永远不会忘。
5.2 一个可以照着跑的最小闭环
下面这段代码把前面所有零件串起来,用一个随机生成的回归数据集,不依赖任何外部数据文件,复制即可运行。
import torch import torch.nn as nn from torch.utils.data import TensorDataset, DataLoader torch.manual_seed(42) # 1. 造数据:y = 3x1 - 2x2 + 1 + 噪声 n, d = 2048, 5 X = torch.randn(n, d) true_w = torch.tensor([3.0, -2.0, 0.0, 0.0, 0.0]) y = X @ true_w + 1.0 + 0.05 * torch.randn(n) y = y.unsqueeze(1) ds = TensorDataset(X, y) loader = DataLoader(ds, batch_size=64, shuffle=True) # 2. 模型 model = nn.Sequential( nn.Linear(d, 32), nn.ReLU(), nn.Linear(32, 1), ) # 3. 损失与优化器 criterion = nn.MSELoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-2) # 4. 训练 for epoch in range(20): model.train() total, count = 0.0, 0 for xb, yb in loader: optimizer.zero_grad(set_to_none=True) pred = model(xb) loss = criterion(pred, yb) loss.backward() optimizer.step() total += loss.item() * xb.size(0) count += xb.size(0) print(f"epoch {epoch:02d} loss={total / count:.5f}") # 5. 检查学到的权重 print(model[0].weight.detach().numpy().round(2))跑下来你会看到 loss 稳定下降到一个很小的值,学到的第一层权重里前两维明显偏离零,后三维接近零。这个现象本身就是对"模型确实学到了东西"的一次有效验证,比只看 loss 数字更有说服力。
注意几个细节。total += loss.item() * xb.size(0)这种按样本数加权的平均,比直接对 batch loss 求算术平均更准确——因为最后一个批次可能不满。这种小严谨在数据集不能整除批次大小时才显现出来,但养成习惯不亏。
5.3 学习率是最值得先调的参数
如果只能调一个超参数,我选学习率。经验上,先用一个偏大的值跑几十步,看 loss 是否震荡甚至上升;再缩小十倍,看是否收敛太慢。这个粗扫过程花不了几分钟,比反复改模型结构高效得多。
一个实用的做法是用框架自带的学习率调度器做分段衰减。
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=len(loader) * 20 ) for epoch in range(20): for xb, yb in loader: ... optimizer.step() scheduler.step() # 按步更新,写在 step 之后关于顺序有个约定:调度器要在优化器更新之后调用。如果用的是按轮更新的调度器,就要放在整个 epoch 循环之外。放错位置不会报错,但学习率曲线和你想的不一样,这类问题排查起来很费神。建议在训练时每轮打印一次当前学习率,眼见为实。
print(f"lr={optimizer.param_groups[0]['lr']:.2e}")5.4 固定随机种子与确定性的边界
想复现结果,至少要固定三处:Python 的随机、数值库的随机、框架的随机。
import random, numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)但要有心理准备:即使全固定,多卡并行、某些原子操作、数据加载的多进程顺序,都可能带来微小差异。这不是 bug,是并行计算固有的不确定性。入门阶段单卡单进程,固定种子后基本能复现;等上了多卡,就该接受"结果在很小范围内波动"这个现实,用多次运行的平均值来判断改进是否有效。
6. 头几个项目最爱踩的坑与排查链路
6.1 一份按顺序执行的排查清单
出问题时最怕乱试。我的建议是按下面的顺序逐项确认,每一步都有明确的判据,避免东一榔头西一棒子。
| 顺序 | 检查项 | 快速判据 | 常见原因 |
|---|---|---|---|
| 1 | 一个批次的数据形状 | 打印xb.shape, yb.shape | 少了批次维、通道顺序反了 |
| 2 | 模型输出形状 | 打印out.shape | 最后一层维度与类别数不符 |
| 3 | 损失初值 | 分类任务应接近ln(类别数) | 标签类型错、输出没归一化 |
| 4 | 梯度是否存在 | 打印某个参数的grad | 参数没注册、链路断了 |
| 5 | 梯度量级 | 范数应在 1e-6 到 1e2 之间 | 学习率过大、初始化不当 |
| 6 | 是否遗忘清零 | 看zero_grad位置 | 梯度累积导致发散 |
| 7 | train/eval 切换 | 评估前是否调eval() | 验证指标异常偏低 |
第 3 条值得展开说。十分类任务的交叉熵初值,如果模型输出接近随机,应该在ln(10) ≈ 2.3附近。如果你看到的是 0.3 或者 23,那说明有问题:偏小可能是标签泄漏或输出被错误缩放,偏大可能是输入数值范围不对。这个判据几乎不花时间,却能立刻筛掉一批低级错误。
第 4 条同样重要。反向传播后随手打印一下第一个参数的梯度范数:
for name, p in model.named_parameters(): if p.grad is not None: print(name, p.grad.norm().item()) else: print(name, "grad is None")全为 None,基本就是参数没被注册或者被冻结了;个别为 None,说明那条支路没参与前向。这个方法比盯着 loss 曲线猜要快得多。
6.2 三个真实发生过的静默错误
第一个:数据归一化与不归一化混用。训练时对输入做了标准化,验证时忘了做,模型在验证集上表现极差。代码不报错,loss 也正常,只是指标难看。这类问题唯一的防线是把预处理逻辑封装进数据集对象本身,让训练和验证走同一条路径。
第二个:标签和输入的批次顺序被打乱。用了两个独立的加载器分别喂输入和标签,各自shuffle=True,结果两者对不上。模型在学噪声,loss 卡在一个不掉的位置。正确做法是用TensorDataset把输入和标签绑在一起,让一个加载器同时产出。
第三个:评估时忘了torch.no_grad()。程序能跑,指标也对,但显存占用是训练时的好几倍,批次稍微一大就爆。加上上下文管理器之后,显存立刻降下来。这个坑特别隐蔽,因为它在小数据集上完全不显现。
6.3 让日志替你记事
训练脚本里我最舍得花代码的地方是日志。每一轮至少记录:训练损失、验证损失、验证指标、当前学习率、耗时。输出成一行文本,同时追加写入文件。
import time def log_line(path, **kw): line = " ".join(f"{k}={v}" for k, v in kw.items()) print(line) with open(path, "a", encoding="utf-8") as f: f.write(line + "\n")看起来很土,但它的价值在于:三天后你回头看,能立刻知道哪一轮开始变差、当时的学习率是多少。用可视化面板当然更好,但在远程机器上跑实验时,一个纯文本日志文件往往是最可靠的信息载体,断线了也不丢。
7. 我个人在这部分积累的几点体会
先说一个反直觉的结论:入门阶段写代码,宁可多写几行打印,也不要追求代码短。我早期特别喜欢把训练封装成一个大函数,参数一大堆,看起来很专业。结果每次出问题都要在函数里加日志、再删日志,来回折腾。后来改成"主脚本保持线性、每一步都能单独打断点"的写法,调试效率反而高了一截。等流程稳定了再抽函数,那时候你才知道哪些边界真的需要封装。
第二个体会是关于调试顺序的。绝大多数人的本能是从后往前查:loss 不对,先怀疑模型结构,再怀疑损失函数,最后才看数据。正确的顺序恰恰相反。数据错了,后面怎么调都是错的;数据对了,模型哪怕很简陋也能跑出个像样的结果。我现在接手任何新任务,第一件事永远是:把一个批次的数据可视化或者打印出来,人工确认它和标签的对应关系是对的。这一步花十分钟,能省掉后面几小时。
第三个是关于抄代码的态度。开源实现里的很多写法是有上下文前提的,比如某个归一化常数是针对特定数据集统计出来的,某个学习率是针对特定批次大小调出来的。整段抄过来,跑是能跑,但一旦你的数据分布不一样,效果就会差很多。我的做法是抄结构、不抄常数,把所有魔法数字都提成变量放在文件开头,跑之前逐个确认一遍它们在你的场景下是否合理。
最后一个建议:给自己准备一个"最小可运行模板",就本文第 5 节那种规模,包含数据构造、模型、损失、优化器、训练循环、保存、日志。每开一个新任务,从这个模板复制一份,先让它在你自己的数据上跑通一个批次,再逐步往里加东西。这个模板我用了很多年,改动过无数次,但骨架一直没变。它最大的价值不是省时间,而是让你每次都有一个已知正确的起点——当新代码出问题时,你能确定问题出在新增的那部分,而不是从零开始怀疑一切。
下一篇我打算从数据管道往下走,聊聊自定义数据集怎么写、多进程加载在什么情况下会拖慢速度、以及增强操作该放在哪个环节才不会引入额外的内存开销。