说到 pytorch,我得先坦白一件事:我最早碰深度学习用的并不是它,而是某个以静态图闻名、需要先定义完整计算图再执行的老框架。那段时间写模型就像在写 SQL 的查询计划,调试全靠脑补张量的形状,最难受的是想打印中间结果还得先改图结构。后来转到 pytorch,才第一次体会到"写模型就像写普通 Python 代码"是什么感觉。这篇文章不是官方文档的复述,而是我这几年里跑图像分类、目标检测、语义分割等若干项目后攒下来的实战记录,适合刚入门 pytorch、或者已经会用但总在某个环节卡住的读者。我会把那些教程里很少讲清楚的底层逻辑和踩坑经历一次说透。
1. 为什么最终留在 PyTorch:动态图带来的调试自由度
1.1 从 Numpy 迁移要先改掉什么习惯
如果你之前主力是 NumPy,转 pytorch 的前几天通常会觉得处处都像,但处处又不一样。最典型的一个习惯是"先看 shape 再写运算"——在 numpy 里你确实可以随手np.dot、np.reshape,但在 pytorch 里张量的维度匹配问题会直接在反向传播时炸出来,而且报错信息往往比你预期的晚一步。
另一个容易忽略的差异是原地操作。numpy 里arr += 1是很自然的写法,但在 pytorch 中,对需要计算梯度的叶子张量做原地修改,轻则影响梯度计算,重则直接报错:
import torch w = torch.tensor([1.0, 2.0], requires_grad=True) w += 1 # 这样没问题,但如果你之后用 autograd 反向求梯度就要小心这类细小的习惯差异,会在你开始自定义nn.Module的 forward 逻辑时被放大。所以我的建议是:迁移期不要只是"照抄 numpy 写法",而是每写一行运算前先想一下这行是否参与了反向传播,是不是需要被记录进计算图。
1.2 动态图到底"自由"在哪里
静态图框架把网络结构当成一张固定的图,输入进去、输出出来,中间所有分支在编译期就确定了。pytorch 的做法完全不同:它在 forward 执行的时候才"顺便"搭图,这意味着你可以在 forward 里写循环、写if判断、甚至按条件选择不同的子网络,而这一切不会让你付出额外的语法代价。
我在一个变长序列项目里就靠这个省了大量重写成本:序列长度每个 batch 不一样,网络需要根据长度决定循环次数。静态图方案里这种结构相当麻烦,而 pytorch 里只是普通代码:
def forward(self, x, seq_lens): outputs = [] for i in range(x.size(0)): if seq_lens[i] > 0: outputs.append(self.sub_net(x[i])) else: outputs.append(torch.zeros_like(x[i])) return torch.stack(outputs)这种自由最值钱的地方在于调试:你可以在 forward 任意位置加print,可以中途 break,可以拿着中间张量单独跑一小段验证逻辑。模型运行过程对你完全透明,这在排查 loss 不动、输出形状不对这类问题时能省下大把时间。
2. 张量与设备管理:先搞懂内存再从 Numpy 迁移
2.1 device 不匹配是新手遇到最多的 RuntimeError
很多刚上手的人在一个报错上反复栽跟头:Expected all tensors to be on the same device。原因非常简单——模型参数在 GPU 上,而输入数据还在 CPU 上,或者反过来。更隐蔽的情况是两个输入来自不同预处理流程,一个被搬到 GPU 了,另一个没有。
我现在的习惯是在项目开头就统一设备常量,并在一开始就强制把数据和模型都移动过去:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = MyModel().to(device) for batch in dataloader: images = batch["images"].to(device, non_blocking=True) labels = batch["labels"].to(device, non_blocking=True)non_blocking=True通常配合pin_memory=True使用,能减少 CPU 到 GPU 的拷贝阻塞。但这只对异步拷贝有效,别指望它单独解决性能问题。另外要提醒一点:如果你用nn.DataParallel或nn.parallel.DistributedDataParallel包装模型,输入张量仍然默认落在主卡的 device 上,多卡场景下尤其要注意把每个 batch 送到对应卡的 device,而不是以为模块包装了就能自动处理。
2.2 view、reshape、permute 与 contiguous 的纠缠
这是个高频坑。view要求张量在内存中是连续的,reshape不需要,因为它内部会先自动拷贝成连续内存。permute之后张量通常变成了非连续内存,如果你直接对 permute 结果调用view,会报错;先.contiguous()再view是标准写法。
x = torch.randn(2, 3, 4) y = x.permute(2, 0, 1) # 形状变成 [4, 2, 3] # y.view(24) 会报错 z = y.contiguous().view(24) # 正确理解底层原因比记住报错更重要:view只是在原有存储上换了一种"解释方式",所以要求存储连续;permute改变了轴的顺序,原来的存储布局不再符合新轴序,必须要么不改变存储地返回非连续视图,要么拷贝一份连续的。这个机制对transpose、narrow、expand等操作同样适用。项目里如果频繁需要通道维度调整(比如从[N, C, H, W]变成[N, H, W, C]),我建议统一封装一个函数处理 contiguous,避免每处都踩一遍。
2.3 显存不会自动释放:几个被忽略的细节
pytorch 的显存管理有自己的缓存机制,torch.cuda.empty_cache()只是清空缓存归还给系统,并不强制立即归还。真正该做的是减少显存驻留对象:把中间结果及时覆盖、不要为了调试保留巨大的中间张量、该用del的就del。另外,推理阶段请务必包上torch.no_grad(),否则显存会被计算图缓存拖满。
还有一个我自己踩过的坑:验证集评估时忘了把模型切成 eval 模式,导致 BatchNorm 在验证阶段也用 batch 统计量,显存不降反升,指标还忽高忽低。这个问题我放到第 4 节详细说。
3. DataLoader 与数据增强:训练速度的隐性瓶颈
3.1 Dataset 写得规范,后面才省心
很多人图省事,在训练循环里临时读图、resize、转 tensor,结果 GPU 一直在空转等数据。DataLoader 这套机制存在的意义就是让数据准备和模型训练异步进行,前提是你得把数据读取逻辑完整地写进Dataset的子类里。
from torch.utils.data import Dataset class SimpleDataset(Dataset): def __init__(self, file_list, transform=None): self.file_list = file_list self.transform = transform def __len__(self): return len(self.file_list) def __getitem__(self, idx): img = load_image(self.file_list[idx]) # 这一步才有真正的 IO if self.transform: img = self.transform(img) return img__getitem__返回什么,DataLoader 就折叠成什么。如果你返回的是 dict,batch 出来就是 dict 的每个键各自堆叠;如果你在__getitem__里做了太多同步计算,多进程 worker 也救不了你。经验是把耗时操作(比如读取大图、解码、复杂增强)尽量放到__getitem__里而不是预处理脚本里,让多进程 worker 帮你并行掉,但要注意每个 worker 会复制一份 dataset,内存开销会成倍上涨。
3.2 num_workers 不是越大越好
初学阶段几乎所有人都会犯一个错:num_workers 能拉多高拉多高,结果启动十几个 worker 后训练反而更慢,还不断报 DataLoader worker 崩溃。这里有三个实用规则:
num_workers一般设置为 CPU 核心数附近即可,图像项目里 4~8 是常见区间,再高收益骤减。- 如果主进程一次性加载整个数据集到内存,比如把所有图片读成内存数组,那
num_workers设很高反而带来大量额外复制开销。 - Windows 下如果每个 epoch 都崩溃,优先级最高的是把 collate 里不能 pickle 的对象去掉,而不是降 workers。
数据读取是否成为瓶颈,判断方法很简单:看跑 BatchNorm 训练时 GPU 利用率是否长期低于 40%。如果 GPU 一直在等数据,优先检查是不是读取路径上有同步耗时,其次才是 workers 数量。
3.3 数据增强要克制
增强做得太狠,模型看不到"干净"的数据,训练前段 loss 会非常难看。我在一个细分类项目里把随机裁剪、随机旋转、颜色抖动全部拉满,结果前 5 个 epoch 的 loss 几乎不降,后来把增强强度降到原来的三分之一,收敛速度才正常。增强的目的是让模型学到不变量,而不是让它面对一堆变形到几乎认不出的图。合理的做法是从弱增强开始,确认 baseline 能收敛,再逐步加大强度观察影响。
数据增强里还有一个"测试时增强"的常见误解:有些项目在验证时也用随机增强,导致验证指标剧烈波动,无法判断模型好坏。验证集请务必固定 transform,不要让随机性混进评估流程。
4. 手写训练循环:从 optimizer.zero_grad() 到 model.eval() 的完整逻辑
4.1 为什么我建议先手写循环
用现成的 Trainer 很方便,但如果你只停留在填参数阶段,就永远理解不了训练循环里那几个关键步骤为什么不可省略。手写一遍之后,你再去看任何封装库的文档都会顺畅得多,因为那些库只是把同一套逻辑封装了起来。我的建议是:新手至少在第一个真实项目里手写训练循环,跑通了再考虑用高级封装。
for epoch in range(num_epochs): model.train() for batch in dataloader: images, labels = batch images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step()这段代码每一步都有明确使命:zero_grad清空上一轮累积的梯度;backward根据 loss 计算所有参数的梯度;step根据梯度和优化策略更新参数。漏掉zero_grad会导致梯度不断累加,这在某些特殊场景是有意为之(梯度累积),但默认情况下是 bug。
4.2 train() 与 eval() 的差别不是摆设
model.train()和model.eval()影响的核心是 Dropout 和 BatchNorm 的行为。Dropout 在训练时随机丢弃神经元,在 eval 时不丢弃;BatchNorm 在训练时使用当前 batch 的均值和方差,在 eval 时使用训练阶段累积的全局统计量。
如果你验证模型时忘记model.eval(),会出现两个现象:一是验证结果不稳定,有时很差有时很好;二是显存消耗比预期高。所以完整的训练循环里,验证阶段必须显式调用model.eval(),并配合torch.no_grad()关掉计算图记录,这既是准确性问题,也是性能问题。
4.3 模型保存与断点续训的正确姿势
直接用torch.save(model, "model.pt")省事,但只适用于同一个脚本里重新加载的场景。更推荐只保存 state_dict,因为它跟模型结构解耦,升级网络结构后也能尽量复用参数:
torch.save(model.state_dict(), "best_model.pt") # 加载 model.load_state_dict(torch.load("best_model.pt", map_location=device))做断点续训时,光有模型参数不够,还得保存优化器状态、当前 epoch、学习率调度器的状态。我自己遇到过训练中途断电,第二天下班回来发现只能从头再来,从此之后一律存 checkpoint:
torch.save({ "epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), }, f"checkpoint_epoch{epoch}.pt")模型保存还要注意一个时机问题:很多项目只看验证 loss 最低的 epoch 来存"best",但如果最后一次验证提升发生在训练末尾,后面可能已经过拟合。我现在的做法是同时保留验证 loss 最低的一份和最后一个 epoch 的一份,后续对比再决定用哪个。
5. loss 不动与梯度异常:一次完整的排查经历
5.1 loss 为 NaN 的最常见原因
NaN 问题在 pytorch 项目里几乎人人都会遇到。按我的经验,排查顺序应该是这样的:
- 学习率过大。这是最高频原因,尤其是用 Adam 时初始学习率设到 1e-2 或者更大,前几步一冲,梯度直接爆炸。我通常从 1e-4 起步,分类任务试 3e-4,检测类任务有时还要更低。
- 输入数据里包含 NaN 或 Inf。特别是自己写数据预处理时,除以某个可能为 0 的统计量导致产生 NaN,再乘到模型里一路放大。
- Loss 函数输入不合法。比如交叉熵的 target 超出了类别数,或者标签里有负数,这类错误在数据增强旋转翻转后容易出现。
- 梯度爆炸。可以给梯度加 clip:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0),这是个兜底手段,但不能代替排查原因。
我最深的一次教训是在某个回归任务里,目标值范围是几万到几十万,我没有对标签做归一化,模型输出和 loss 直接失控成 NaN。后来把标签缩放到 0~1 区间,问题立刻消失。检查数据分布,永远排在调模型之前。
5.2 梯度消失与初始化
loss 不降未必是 NaN,也可能是梯度消失了。最典型的是深层网络使用 sigmoid 或 tanh 激活,配合不合适的初始化,反向传播时梯度连乘导致几乎为 0。现在 pytorch 默认的nn.Linear和nn.Conv2d初始化已经比较合理,但对于你自己构造的复杂网络,还是要主动考虑初始化:
def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode="fan_in", nonlinearity="relu") if m.bias is not None: nn.init.constant_(m.bias, 0) model.apply(init_weights)判断梯度状态的直接手段是给参数挂 hook。比如检查第一个卷积层的梯度均值是否为 0:
def print_grad(grad): print(grad.mean(), grad.std()) model.conv1.weight.register_hook(print_grad)如果第一层梯度接近 0,而后面的层正常,基本可以断定是梯度消失。如果每一层梯度都异常巨大,再配合 loss 不降,就要往梯度爆炸方向查。
5.3 我现在的排查顺序清单
踩过太多次坑之后,我总结了一套自己的排查流程,按顺序执行能省不少时间:
- 先用单张图或单个 batch 过一遍 forward,确认输出形状和 loss 数值合理。
- 关闭数据增强,用干净数据跑 5~10 个 step,确认 loss 能下降。
- 打开增强后复跑,如果 loss 不降,问题大概率在增强强度或标签错误。
- 检查输入范围、标签范围、学习率这三个最朴素的变量。
- 才考虑初始化、梯度 hook、loss 函数结构这些更深层的原因。
这套流程每次都管用,因为它把"是不是我的代码写错了"这个最朴素的问题放在最前面。实际上很多 loss 不降的 case,最终都是数据本身的问题,而不是模型结构的问题。
6. 性能加速与显存优化:几招亲测有效的技巧
6.1 混合精度训练的收益与坑
用混合精度训练,显存占用能明显下降,同时速度在支持算力的设备上能提升近一倍。pytorch 从 1.6 版本开始推荐直接用自动混合精度:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, labels in dataloader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()有两个坑必须提醒。第一,loss 的缩放:混合精度下梯度可能小于 FP16 的最小精度,所以要用GradScaler对 loss 做缩放,否则小梯度直接变 0,权重永远不更新。第二,不是所有算子都适合低精度:某些 op 仍然以 FP32 运行,使用前最好确认你的模型里没有太多这类敏感操作,或者打开torch.autocast的日志看一眼哪些关键层被排除。
梯度累积也是常用招数。当显存放不下大 batch 时,用小 batch 多步累积:
accumulation_steps = 4 optimizer.zero_grad() for i, batch in enumerate(dataloader): loss = criterion(model(batch), labels) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意要在每个累积周期结束后才 step 和 zero_grad,并且 loss 除以累积步数,保证等效学习率不变。
6.2 别让.item()和 Python 循环拖慢训练
loss.item()本身没问题,但如果你在训练循环里高频调用大量.item()或.cpu(),每次都会触发同步,GPU 流水线被打断。我会在评估阶段或日志记录阶段少量调用,而不是在每个 step 里把一堆中间结果搬回 CPU。
另一个常见问题是用 Python 的for循环逐个处理 batch 内的元素。pytorch 的向量化能力远比手写循环快,能用张量操作解决的问题尽量直接写张量操作。我在某个项目里把一段逐样本计算改成gather+masked_select之后,单 epoch 时间直接缩短了三分之一。
6.3 一些零散但关键时刻能救命的经验
最后分享几个压箱底的心得:
- 先跑通小规模再上全量。我每次换新模型都会先用一张小数据集把训练、验证、保存全流程走一遍,确认 loss 下降趋势合理,再上全量数据。这一步能拦住八成后期才会发现的低级 bug。
- 固定随机种子。训练结果复现依赖
torch.manual_seed和random.seed,但 DataLoader worker 的随机性还得在worker_init_fn里控制。完全复现很麻烦,但至少要保证同一个脚本前后两次能大致对齐。 - 造一个"过拟合冒烟测试"。用极少量的数据把模型训练到过拟合,能证明模型本身有拟合能力。如果连 50 张图都学不能拟合住,别急着加数据增强,先检查网络结构或标签是否有问题。
我在实际使用中的一个体会是:pytorch 的上手门槛确实低,但真正把它用好,靠的是一套稳定的调试方法和数据管理习惯,而不是再去学更多花哨的 API。每次坑都是从 loss、数据、设备、显存这些朴素参数里挖出来的,把这张清单打印出来贴在显示器旁边,比收藏一堆教程有用得多。