最开始学 PyTorch,我踩过最冤枉的一个坑,是写在 model 里的层列表。当时一个残差模块里,我顺手把几个子层塞进了普通 Python list,然后循环调用。CPU 上一切正常,代码也短,结果一放到 GPU 上,loss 要么不降,要么直接报设备不匹配。排查了一整个下午,最后发现罪魁祸首就是nn.ModuleList和普通 list 在模块注册机制上的差异。就从那一刻起我开始承认,PyTorch 虽然上手快,但如果不把核心机制吃透,后面每个项目都会有一堆“莫名其妙的脏东西”。
这篇文章想把我在实际项目里和 PyTorch 打交道的经验从头捋一遍:先讲清楚它为什么能成为大多数人的默认选择,再把绕不开的核心机制拆开看,给你一份可以直接照抄的工程骨架,最后聊训练提速、显存优化、模型保存部署和学习路线。无论你是刚装好 PyTorch 的新手,还是已经能跑通简单模型但感觉代码越来越乱的老手,这篇应该都能派上用场。
1. 动态图不是技术炫技,它才是 PyTorch 能成为默认选择的根本原因
1.1 “define by run”到底改变了什么
很多教程会把动态图和静态图的区别讲得特别玄乎,我换一种说法:动态图让“写模型”和“写普通 Python 函数”基本没有区别。你在forward里用if判断、用for循环、随手往 list 里塞东西,都是合法的建模手段,因为这些 Python 语句在模型前向传播那一刻才真正被逐行执行,计算图也随之实时构建。
这在处理变长序列、动态分支、条件跳过的网络结构时特别舒服。你不需要提前把一个“万能图”定义好,也不需要学习某套专门的图描述语法。PyTorch 的做法是:你写什么,它就执行什么,梯度就沿着执行过的路径自动算回来。对大多数开发者来说,这种模式几乎不需要额外的心智负担,是真正意义上的“零门槛建模”。
静态图框架也不是没有优点,它对性能优化、部署裁剪更友好,图在运行前整体可见,可以做很多激进的融合优化。但在研究探索和快速迭代的场景里,动态计算过程带来的自由度和可调试性实在太重要了,这就是 PyTorch 崛起最核心的技术基本盘。
1.2 研究群体和工程群体为什么同时被吸引
技术基本盘只是基础,真正让 PyTorch 滚雪球的是使用体验。在动态图里,你可以给模型任意位置加print,可以断点调试,可以看到中间每一层张量的形状和数值。这种体验在“炼丹”场景里千金不换,因为大部分模型跑不动的时候,你要做的事情不是看论文,而是先搞清楚某一步 tensor 的形状和值对不对。
很多早期框架把人逼疯的地方在于:定义模型和构建图阶段看不到具体数据,只有等到真正 run 起来才报错,而报错信息又往往非常难懂。PyTorch 从第一天起把“Python 原生调试”带进了深度学习,这种体验上的差异,远大于谁多一个算子谁快几个百分点。研究团队一旦用顺手了,开源模型和预训练权重就会跟着用 PyTorch 发布,预训练权重又成为工程团队的刚需,于是社区生态像滚雪球一样形成正循环。
还有一个容易被忽略的因素:Python 编程本身就是 PyTorch 的“领域专用语言”。如果你想在模型里实现自定义逻辑,不需要去了解额外的一整套抽象概念,你所学过的装饰器、迭代器、上下文管理器,甚至__getitem__,都可能直接用在模型代码里。这种和主流开发者经验的无缝衔接,让 PyTorch 从一个框架变成了社区默认的开发范式。
1.3 它赢在补齐了工程短板
因为早期能跑通实验,并不代表能做好工程交付。PyTorch 真正进入生产环境的时间点,是在它把分布式训练、数据加载、导出推理、移动端支持这些配套能力逐一补上之后。动态图在早期确实让部署相对麻烦,但框架团队用torch.jit、onnx导出、torch.compile等一系列方案把这些短板补了回来,同时保留动态图的使用体验。
现在回过头看,PyTorch 的胜出更像是一场开发体验和工程能力同步完善的综合胜利。对普通用户来说,不需要为一个“更优的底层”买单,而是为“更快地把想法跑出来”买单。理解了这一点,你就明白为什么我会强调:学 PyTorch 不要只学 API 怎么调,更要理解它这批设计选择和底层机制,否则后面一踩坑就是大坑。
2. 五个绕不开的核心机制,吃透了才算真正入门
2.1 Tensor 的 dtype、device、shape 是三个独立维度,少管一个都会出事
PyTorch 里最基础的数据结构是torch.Tensor。我见过大量新手把 Tensor 当成“能上 GPU 的 numpy”,这么理解没有大问题,但至少得记住:Tensor 除了形状(shape),还有两个容易被忽略的维度——数据类型(dtype)和所在设备(device)。
比如很多分类任务里,标签不能直接用 float 型张量送进CrossEntropyLoss,它要求 target 是整数类型的长整型张量。你要是把标签也跟输入一块儿float(),loss 计算时报错都算好的,更麻烦的是某些场景会静默出错。再比如你把一个 CUDA 张量和 CPU 张量放在一起做运算,PyTorch 会直接抛设备不匹配的异常,这个异常本身不是什么大事,真正烦人的是你得一层层排查到底哪一步把数据留在 CPU 上了。
设备迁移还有一个高频误区:很多人只在模型定义后.to(device)一次,后面每来一个 batch 都忘记把输入搬过去。我的习惯是写一个统一的to_device逻辑,或者明确要求在每轮迭代里对 batch 里的每个 tensor 做.to(device)。这个习惯看起来很小,但在真实项目里能帮你少踩无数个“设备不匹配”的坑。
2.2 autograd 能帮你算梯度,但不会帮你管梯度
自动求导是 PyTorch 的核心能力,但它有个很容易忽略的规则:梯度默认是累积的。也就是说,loss.backward()会把计算出来的梯度累加到已经存在的.grad属性上,而不是覆写掉。这就是为什么标准训练循环里每一轮都要先调用optimizer.zero_grad(),如果你漏了这一步,模型参数会用到上一轮甚至之前好多轮的梯度,训练过程会表现得非常诡异,但不一定会报错。
另一个重要概念是detach()。有时候你想把某个张量从计算图中分离出来,比如用模型中间层的输出做一些分析、画特征图、或者作为另一个模型输入但不想回传梯度,就可以用detach()。不少人把它当成“复制数据”的工具,其实它更像是在说:这个张量我就当它是纯数据,别再追着它的计算历史求导了。
还需要记住的是,把张量切片然后修改原张量,这种原地操作在 autograd 眼里是有“版本冲突”的,因为它会破坏反向传播时需要用到的旧值。实际开发中,尽量避免对requires_grad=True的叶子张量做原地修改,如果非要修改,优先考虑用新变量接住结果。
2.3 nn.Module 的注册机制:Python list 真的会坑你
这就是我文章开头提到的那个坑。nn.Module有一个魔法般的机制:你在__init__里把子模块赋值给self.xxx,它会被自动注册进模块体系,从而被.to(device)、.state_dict()、.train()等操作管理。但是如果你写的是self.layers = [nn.Linear(...), nn.ReLU(), ...],这个 Python list 本身不会告诉父模块“这里有几个子模块”,于是你在列表里声明的层不会自动搬家、不会自动存权重。
正确的做法是使用nn.ModuleList或nn.ModuleDict来包装。这么设计的原因也很简单:框架需要在模块树上做统一的设备迁移、状态读取、模型保存,如果层对象只是普通 list 里的元素,框架根本不知道它们的存在。这个问题在你只有两个层的时候影响不大,但一旦你要构建有大量子模块的复杂网络,它就会造成莫名其妙的、难以排查的参数缺失和设备不一致。
还有一类同样重要的注册对象是 buffer,比如 BatchNorm 里维护的 running_mean 和 running_var。它们不是需要梯度的参数,但需要随模型一起搬设备和保存状态。如果你在forward里临时创建一个 tensor,然后顺手存成self.some_tensor,它不会自动成为 buffer,也不会被state_dict包含。正确的做法是预先用register_buffer或者在__init__里声明好。
2.4 Dataset 和 DataLoader:数据管道才是多数训练事故的来源
在 PyTorch 里,Dataset的职责是“给定索引,返回一个样本”,DataLoader的职责是“批量取数、多进程加载、打乱顺序”。这个拆分本身非常简单,但实际用起来容易踩几个点。
第一,自定义 Dataset 时,__getitem__里做的数据读取和预处理要尽量轻。如果每次都要从磁盘加载大文件、做复杂增强,DataLoader 的多进程机制会帮你并发跑,但进程数不是越多越好。进程开太多会带来额外的内存拷贝和调度开销,反而变慢。我的经验是先开到num_workers=4或8,然后测一下每个 epoch 的耗时再调整,别一上来就开 32。
第二,如果你的数据增强里有随机操作,那就需要留意每个 worker 进程的随机种子。简单地在主进程里设置torch.manual_seed并不会让每个 worker 的随机序列一致,所以做结果复现时,要在DataLoader里配合worker_init_fn来设置每个进程的随机种子。否则你会在实验记录里看到“同样的配置,两次训练结果差异很大”,然后花很长时间怀疑模型有问题,其实只是数据增强的随机性没有被控制住。
第三,collate_fn是你自己定义“如何把多个样本拼成一个 batch”的地方。默认逻辑是把一组张量直接堆叠,这只能处理形状一致的样本。碰到变长序列、不同尺寸的图、或者混合了文本和数值的特征,就需要自己写collate_fn做 padding 或组合。很多人刚开始直接硬怼默认值,结果要么报错,要么为了形状对齐在模型中做大量 mask 修补,代码臃肿还容易出 bug。
2.5 优化器的状态管理:Adam 不只是“更新参数”那么简单
很多人都知道optimizer.step()会根据梯度更新参数,但没注意到优化器自己也有状态。以 Adam 为例,它会为每个参数维护一阶矩和二阶矩估计,也就是所谓的“动量”和“方差”信息。如果你只是保存了模型参数,下次加载模型后重新创建优化器,那优化器的状态就全部重置了,训练续跑时学习动力学是接不上的。
这也是断点续训时必须同时保存 optimizer 的原因。标准做法是把 model、optimizer、scheduler、epoch、随机数状态一起打包保存,而不是只存一个model.pt。后面第 5 章我会给具体的代码结构。现在你只需要记住:optimizer 不是一次性消耗品,它的状态和模型参数同样重要。
学习率调度器也很容易出错,因为它可能关心“当前在第几个 epoch”或者“已经走了多少步”。如果你从某一个 checkpoint 恢复训练,却没有把 scheduler 的状态恢复,那后续的学习率会从头开始,这会让训练曲线产生突跳。所以,只要做了 checkpoint,就尽量把全套状态存下来,不要偷懒。
3. 一套可以直接照抄的 PyTorch 训练工程骨架
3.1 项目目录怎么组织,后期才不打架
我早期写训练代码的时候,所有函数都堆在一个main.py里,跑一两次实验还行,换参数就要改代码、注释掉一堆逻辑,最后自己也分不清哪个版本跑出的是什么结果。后来痛定思痛,把项目结构固定成这样:
project/ ├── configs/ │ └── exp001.yaml ├── data/ │ ├── dataset.py │ └── transforms.py ├── models/ │ ├── __init__.py │ └── model.py ├── utils/ │ ├── metrics.py │ └── logger.py ├── checkpoints/ ├── logs/ ├── train.py ├── evaluate.py └── requirements.txt核心思想是分离配置、数据、模型、工具和脚本入口。配置放到 YAML 或 JSON 里,不要把所有超参写死在代码里,这样每次实验只要复制一份配置就行,代码保持不动。数据和模型各归各的文件,别把图像增强逻辑写进模型代码里,也别把指标计算埋在训练循环中。工程上稍微“啰嗦”一点,长期看会省非常多事。
这里必须提醒:目录结构不是越复杂越好。如果你只是做一个几十行的小实验,硬套一套“企业级”结构反而很累。我的建议是当你要开始对比多个实验、或者准备把代码给别人复现的时候,就尽早把结构搭起来,越早越好。
3.2 先跑通最小闭环,再追求指标
每次新写一个模型,我都强烈建议用“极小数据集 + 极小 batch”把整个管道先跑通,而不是上来就加载全量数据、跑几百个 epoch。具体操作是:把训练集缩到几十个样本,batch_size设为 4 或 8,只跑几十步,确认 loss 确实在变化、模型能够过拟合这一小批数据,再上全量数据。
这个小步骤能帮你快速定位问题。如果极小数据集上 loss 根本不降,那大概率是模型实现、数据处理或者损失函数里出了问题,这时候别去调学习率,先把代码逻辑理顺。如果极小数据集上能过拟合,说明模型有足够容量,数据管道也是通的,后面调优才有意义。
最小闭环里还有一个必做的事:打印每个 batch 的输入形状、标签形状和输出形状。特别是自定义 Dataset 的时候,你永远不要假设shape一定符合模型预期,打印出来看一次能省很多排查时间。我甚至会在跑正式训练头一两个 step 时加一个批量断言,专门检查输入输出形状和 dtype。
3.3 训练循环里那些修炼过的细节
一个典型的 PyTorch 训练循环骨架大概是这样的:
for epoch in range(start_epoch, epochs): model.train() for step, batch in enumerate(train_loader): x, y = [item.to(device) for item in batch] optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() grad_norm = torch.nn.utils.clip_grad_norm_( model.parameters(), max_norm=1.0 ) optimizer.step() # 记录 loss、grad_norm 等日志 model.eval() with torch.no_grad(): # 在所有验证样本上计算指标 ...几个细节值得展开。第一,model.train()和model.eval()一定要成对出现,因为 BatchNorm 和 Dropout 在两种模式下的行为完全不同。你如果忘了在验证前把模型切到 eval,会发现验证集指标忽高忽低,这种问题排查起来很费劲。
第二,梯度裁剪是很多人忽略的一步。我在跑序列模型和 Transformer 类结构时,经常因为梯度爆炸导致 loss 变成 NaN,加一个clip_grad_norm_之后情况立刻稳定很多。裁剪的阈值我一般从 1.0 开始调,过小会让训练变慢,过大起不到作用。
第三,训练过程中如果你想在日志里记录“最近 N 个 batch 的平均 loss”,要注意用滑动平均或者每隔多少步打印一次,别把几千个 batch 的 loss 全部存在内存里。日志记录最好统一走一个轻量封装,后面做实验对比才方便。
3.4 断点续训的 checkpoint 设计
真正跑长时间训练,你会发现中断是常态。断点续训的 checkpoint 设计核心是“保存足够多的状态,让你仿佛没有中断过”。我推荐的保存内容如下:
torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "scaler": scaler.state_dict(), # 如果用了混合精度 "epoch": epoch, "best_metric": best_metric, }, checkpoint_path)恢复训练时,先加载 checkpoint,再依次加载到 model、optimizer、scheduler、scaler,并且把全局随机种子恢复到保存时的状态。训练过程中还可以做“每隔 N 个 epoch 保存一次”和“每当验证指标刷新时覆盖保存 best model”两种策略,推荐两个都做。我只保留 best 模型和最近一个 checkpoint,避免磁盘被中间文件塞满。
4. 从“能跑”到“跑得快”:训练和显存优化的实战笔记
4.1 优化前先定位瓶颈,别上来就换框架或买显卡
很多人在训练变慢时第一反应是“换更大的 GPU”,但很多时候模型没有让 GPU 吃饱。我见过太多项目,GPU 利用率只有 30%,训练速度上不去纯粹是因为数据加载太慢。你优化数据管道,可能比换一块顶级卡更有效。
定位瓶颈最简单的方法:分别测一下训练步耗时里“取数据”和“模型前反向”所占的比例。可以在一个 epoch 里临时给 DataLoader 加计时,或者直接用 PyTorch Profiler 看时间线。如果发现 GPU 利用率很低而 CPU 占用很高,优先检查数据管道的num_workers、pin_memory、prefetch_factor,以及__getitem__里有没有大量同步的磁盘 IO。
还有一种常见现象:模型本身很小,但你在forward里做的 Python 操作太多,导致 GPU 一直等 CPU 发指令。这种问题属于“CPU bound”,单靠加大并行不够,应该考虑把一些步骤合到张量运算里,或者用torch.compile减少 Python 调度开销。
4.2 混合精度:收益最大、改动最小的训练提速手段
混合精度训练的核心思想是:模型中的大部分计算用 FP16 完成,显存占用减半,速度也可能明显提升,但为了数值稳定,主权重和优化器状态仍然用 FP32 保存。简单说,就是“算的时候减精度,存的时候保精度”。
现代 PyTorch 里实现起来非常轻量,核心是autocast和GradScaler:
from torch.amp import autocast, GradScaler scaler = GradScaler("cuda") for batch in train_loader: x, y = [item.to(device) for item in batch] optimizer.zero_grad() with autocast("cuda"): out = model(x) loss = criterion(out, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的作用是防梯度下溢:FP16 表示的范围比 FP32 窄得多,如果梯度值很小,直接缩放后再反传更稳定。你可以先照着标准用法写,跑稳定后再尝试调节缩放策略。
混合精度并不是万能药。如果你的 GPU 不支持对应的加速指令,提速幅度会有限,但显存方面的收益仍然在。另一个常见现象是开启 FP16 后 loss 出现 NaN,遇到这种情况,可以检查模型里有没有对精度特别敏感的操作,必要时在那一段单独关掉 autocast,或者干脆继续用 FP32。
4.3 显存放不下的急救方案:梯度累积和梯度检查点
显存不够第一反应是调小batch_size,但 batch 太小会让 GPU 并行度下降,而且影响 BatchNorm 的统计量。这时候梯度累积可以派上用场:连续算几个小 batch 的梯度,累加起来,每隔几步再用累积梯度更新一次参数,等效于一个更大的有效 batch。
accum_steps = 4 for step, batch in enumerate(train_loader): out = model(batch) loss = criterion(out, y) / accum_steps loss.backward() if (step + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()注意这里我除以了accum_steps,是为了让累积梯度和单个大 batch 的梯度在量级上更接近。实际项目中很多时候你不会严格等价,但作为经验值已经够用。
梯度检查点则是另一种思路:不保存中间激活值,反向传播时再重新算一遍,用时间换显存。用法也很简单,把模型里的某个子模块包一下:
from torch.utils.checkpoint import checkpoint def forward(self, x): x = checkpoint(self.block, x) ...换来的是显存大幅下降,代价是训练变慢,尤其当模型计算本身开销很大时,耗时可能增加不少。所以建议只在显存实在不够的时候用,优先用混合精度和调整 batch 大小解决。
4.4 torch.compile 值得一试,但别当成银弹
PyTorch 2.0 引入的torch.compile可以在不改变模型代码的前提下,把计算图做编译优化,适合计算密集型的算子。实际使用中,一行代码就能跑:
model = torch.compile(model)但我的经验是:它对静态形状的模型提升明显,对动态形状、包含大量控制流的模型可能效果有限,甚至首次编译时间长得吓人。所以建议是:先跑通功能,再 profile,最后再试torch.compile。如果你的输入尺寸是固定的,编译一次之后速度通常比较可观;如果你每个 batch 的序列长度都不同,那它不一定友好。
“能跑”和“跑得快”之间有一道分界线:先搞清楚瓶颈在数据还是在计算,再决定用混合精度、梯度累积、检查点还是 torch.compile。没有银弹,但按这条路线排查,十个项目里能解决九个。
5. 模型保存、加载与部署:训练完才是麻烦的开始
5.1 state_dict 和完整模型,选谁?
PyTorch 提供了两种保存方式:直接torch.save(model, ...)保存整个模块对象,或者torch.save(model.state_dict(), ...)只保存权重。我强烈建议用state_dict方式。
保存完整模型对象听起来方便,但它用 pickle 序列化整个 Python 对象,加载时必须保证代码里的类定义、模块路径、依赖版本都完全一致。稍微改动一下项目结构,老模型可能就加载不回来了。state_dict是一个纯粹的参数字典,加载时你只需要准备好相同结构的模型实例,然后用load_state_dict把权重填进去,对项目迁移友好得多。
model = build_model() model.load_state_dict(torch.load("model.pt", weights_only=True))此外还要注意torch.load本身的参数,比较新的 PyTorch 版本里默认会改变一些反序列化行为,如果你在加载旧文件时遇到奇怪的安全提示,可以做小版本适配,但核心思路不变:存参数,不要存整个对象。
5.2 跨设备加载和 DDP 模型时 module. 前缀问题
保存和加载还有两个非常折磨人的经典问题:跨设备和多卡。
如果模型在 GPU 上训练,想在 CPU 机器上加载验证,必须使用map_location:
state = torch.load("model.pt", map_location="cpu")如果你不做这个指定,PyTorch 会尝试把张量加载到保存时所在的设备,CPU 机器上可能直接报错或者慢得离谱。部署到 CPU 环境时,这句map_location几乎是必须写的。
另一个高频问题是你用DistributedDataParallel训练时,模型被包裹成了DDP,此时model.state_dict()的 key 会多出module.前缀。直接把这个 state_dict 加载回原始模型,会报 missing keys 或者 unexpected keys。我的习惯是保存时用model.module.state_dict(),或者加载时做一层 key 清理,把module.前缀去掉。
5.3 推理部署的精度差异:eval 模式、no_grad 和 TF32
模型从训练切到推理,第一件事永远是model.eval()。忘记这一步,如果模型里有 Dropout 和 BatchNorm,推理结果会不稳定;有 BatchNorm 的情况下,不同的 batch 统计量会导致同一份输入得到不同的输出,这在线上服务里是大忌。
推理时还需要包一层torch.no_grad(),它能让计算图完全不保留梯度信息,省内存也提速。有朋友问能不能不包,只把requires_grad关掉,实际上torch.no_grad()是一个上下文级生效的开关,在推理循环里它是最干净的方案。
还有一个容易被忽略但很有意思的问题:同一个模型,GPU 上在线推理和 CPU 上离线批量计算的结果可能存在微小差异。一方面原因是 GPU 上很多矩阵乘法默认使用了 TF32 这类低精度加速,另一方面是浮点运算的累积顺序不同。对于绝大多数业务场景,这个差距可以忽略;如果你的系统需要逐位对齐,就要去检查torch.backends.cuda.matmul.allow_tf32等相关开关,并明确选定你的精度标准。
5.4 导出 ONNX 和 TorchScript 时的常见注意点
把 PyTorch 模型导出到 ONNX 或 TorchScript,通常是部署链路里最后一步。我的第一建议是:导出前先用一个固定形状的输入做 trace,确认输出正确,再进行正式导出。如果你用的是基于 trace 的方式导出,模型内部对 Python 控制流的依赖会带来隐患——trace 只会记录样例输入走过的路径,其他分支不会被记录,这就是很多模型“导出时正常、上线时行为不对”的原因。
如果部署场景需要动态 batch 或动态序列长度,你要在导出配置里显式标记动态轴,而不是直接靠样例输入上的固定 shape 一把梭。跑通一个静态尺寸的模型导出,不等于你理解了导出流程,只有把动态维度调通过一次,你才知道 trace 导出和脚本化导出之间的权衡到底在哪。
6. 如果再学一遍 PyTorch,我会按这个顺序学
6.1 不要从复杂项目学起,把“最小闭环”重复十遍
很多人学 PyTorch 容易走两个极端:要么一直刷教程,不写代码;要么直接上手一个大型项目,然后被各种细节淹没。如果让我重新安排学习路线,我会先专注做好一件事:用很少的样本、很小的模型,把“加载数据 → 训练 → 验证 → 保存 → 加载 → 推理”这个闭环完整跑通十次。
第一次可能是 MLP 分类数字,第二次换成 CNN 做图像分类,第三次换成 Transformer 做文本分类。每一轮新模型都复用上一轮的骨架,只替换模型定义和数据处理。这样做的好处是你对训练循环和项目结构越来越熟,后面换领域、换模型时,真正需要思考的只是那些和任务相关的部分。
6.2 遇到问题时,阅读官方文档和源码是最好的路径
现在社区里的学习资料非常多,但很多教程之间的术语不统一,版本也参差不齐,看多了反而混乱。我遇到不确定的 API 行为时,第一选择是查官方文档,第二选择是直接点进源码看实现。比如nn.Module.load_state_dict的strict参数到底影响什么、DataLoader 的worker_init_fn和generator之间有什么关系,这些问题文档里都有明确答案,远比在各种博客里拼凑信息靠谱。
读源码也不是什么高深的事情。像nn.Linear的源码就几十行,看完你就能理解权重和 bias 是怎么初始化的,forward里为什么调用了F.linear。看多了以后,你会发现 PyTorch 本身的代码风格很简单,模块化非常一致。
6.3 环境版本要锁定,实验记录要同步
最后想讲一件看似和学习无关、实际对效率影响巨大的事:环境与复现。同一个项目,PyTorch 版本不同、CUDA 版本不同,甚至 Python 小版本不同,都可能带来行为差异。所以我新建项目哪怕是快速验证,也会顺手生成一个requirements.txt,把关键版本记下来。等实验要复现,或者同事要接手时,你才知道这个东西有多重要。
实验记录我一般用一张表格,列清楚配置文件的路径、模型结构、数据集切分方式、随机种子、训练时长和最终指标。刚开始做项目,你觉得记这些费时间;等你需要对比十几个实验的时候,没有记录就只能靠 memory,纯属自虐。记录得好,每个实验都是资产,记录不好,每个实验都是玄学。
我自己现在带人入门,从来不会要求他们一口气学完所有东西。先对着这个小闭环反复练,再逐步加入混合精度、分布式、部署导出这些进阶项。一件事验证通过了再往前推一步,虽然看起来慢,实际上是最稳也是最快的路径。希望这篇总结,能帮你少走我当年走过的那些弯路。