1. 为什么“pytorch”这个标题值得单独拎出来聊
如果你在技术社区里闲逛,看到有人只甩出一个“pytorch”当标题,底下大概率会分成两拨人。一拨觉得这标题也太大了,根本不知道想表达什么;另一拨人则会心一笑,因为他们知道,敢用这么短的标题,要么是准备从零讲透整个框架的底层逻辑,要么就是踩过足够多的坑,想一次性把经验倒出来。
我属于后者。过去几年里,我用 PyTorch 做过图像分类、做过序列预测、也做过一些轻量级的部署尝试。从最早被动态图惊艳到,到后来被显存泄漏折磨得半夜爬起来改代码,再到慢慢摸清楚什么场景该用它、什么场景该换别的工具,这个过程里积累下来的东西,远比官方文档里那几页教程要厚实。
这篇文章不打算写成又一个“PyTorch 入门教程”。那种内容网上已经太多了,随便搜一下就能找到从张量创建到线性回归的完整代码。我想聊的是:当你已经知道torch.tensor怎么用、nn.Module怎么继承之后,真正会让你在项目里卡住的是什么。是数据加载时那个num_workers到底该设多少,是训练到一半 loss 突然变成 nan 该怎么排查,是模型明明在验证集上表现很好但一上线就拉胯的诡异现象。
所以这篇内容适合谁看?如果你刚接触 PyTorch,能跑通官方那个 MNIST 例子,但自己写项目时总觉得心里没底,那这篇就是写给你的。如果你已经用过一段时间,但每次遇到报错还是靠搜索引擎碰运气,那这篇也能帮你把很多零散的经验串起来。至于完全没碰过深度学习框架的朋友,建议先找个基础教程把张量和自动求导的概念过一遍,再回来看这些实操层面的东西,会顺畅很多。
PyTorch 的核心价值在于它把“定义模型”和“运行模型”这两件事揉在了一起,你写代码的时候感觉就像在写普通的 Python 程序,该打印打印,该断点断点,不需要先画一个静态图再往里灌数据。这种“所见即所得”的体验,是它从学术界一路火到工业界的根本原因。但便利性背后也有代价,动态图带来的灵活性意味着很多优化需要你自己心里有数,框架不会替你兜底所有事情。
2. 从张量到计算图:那些教程里不会细说的底层逻辑
2.1 动态图到底“动”在哪里
很多介绍 PyTorch 的文章都会提“动态计算图”这个词,但真正解释清楚它和静态图区别的并不多。我用一个生活化的类比来说明:静态图就像你出门前先把整条路线的每一个路口都规划好,然后严格按照路线走,中途不能改道;动态图则是你边走边看导航,发现前面堵车了随时可以换一条路。
在 PyTorch 里,这个“边走边看”的能力体现在每一次前向传播都会重新构建计算图。你可以在forward函数里写if判断,可以根据中间结果决定下一步用哪个分支,甚至可以在循环里动态改变层的行为。这种灵活性在做自然语言处理或者强化学习时特别有用,因为序列长度和动作空间往往是不固定的。
但动态图也有它的代价。每次迭代都要重新构建图,意味着框架需要花额外的时间在图的创建和销毁上。如果你做的是固定结构的图像分类任务,并且对训练速度有极致要求,那静态图框架在某些场景下确实会有优势。不过 PyTorch 后来推出的torch.compile和 TorchScript 在一定程度上弥补了这个短板,让你可以在保持动态图开发体验的同时,把最终模型编译成更高效的形态。
我自己的经验是:在研究和实验阶段,完全不需要考虑动态图的性能开销,因为你的瓶颈大概率在数据加载和模型设计上,而不是图构建那点时间。只有当你确定模型结构不会再变、准备大规模训练或者部署时,才需要去折腾编译优化的事情。
2.2 自动求导的“记忆”与“遗忘”
PyTorch 的自动求导机制是它的另一大核心。简单说,当你对一个张量设置requires_grad=True后,所有基于它的运算都会被记录下来,形成一个有向无环图。调用backward()时,框架会沿着这个图反向传播,计算出每个参数的梯度。
这里有个关键细节:默认情况下,每次调用backward()后,中间变量的梯度缓存会被释放。如果你需要多次反向传播(比如在对抗生成网络里),就必须设置retain_graph=True。但很多人不知道的是,这个参数如果滥用,会导致显存持续增长,最后爆掉。
我踩过的一个坑是:在训练循环里,我习惯性地在每个 batch 结束后调用optimizer.zero_grad(),但有一次写了个自定义的训练逻辑,忘了加这一句,结果梯度不断累加,loss 直接飞到了 nan。排查了半天才想起来是梯度没清零。这个错误看起来很低级,但在复杂的训练流程里,尤其是涉及多个优化器或者梯度累积的时候,真的很容易漏掉。
另一个容易被忽略的点是torch.no_grad()的使用。在验证和推理阶段,一定要用这个上下文管理器把梯度计算关掉,否则不仅浪费显存,还会拖慢速度。我见过有人把验证集也放在with torch.enable_grad()里跑,然后抱怨显存不够用,其实只要改成no_grad就能省下大量空间。
2.3 设备管理的“隐形陷阱”
PyTorch 支持 CPU 和 GPU 之间的切换,代码里写.to(device)看起来很简单,但实际项目里设备管理经常出问题。最常见的就是张量在不同设备上做运算,报错信息虽然明确,但如果你在多个模块之间传递数据,追踪起来就很麻烦。
我的习惯是在项目一开始就定义一个全局的device变量,然后在创建张量和模型时统一使用。对于数据加载器,可以在DataLoader的collate_fn里就把数据转到目标设备,这样训练循环里就不用反复写.to(device)。但要注意,如果用了多卡训练,数据应该先留在 CPU,由框架自己分配到各个 GPU 上,手动转反而会出错。
还有一个关于显存管理的经验:PyTorch 的缓存分配器会保留一部分显存以备后用,所以你在nvidia-smi里看到的占用往往比实际模型需要的多。如果遇到显存不足,可以先试试torch.cuda.empty_cache(),但这个操作会清空缓存,可能导致后续分配变慢,所以不要频繁调用。真正有效的办法还是减小 batch size、使用梯度累积、或者把部分计算移到 CPU 上。
3. 数据管道:决定训练效率的隐形战场
3.1 Dataset 和 DataLoader 的分工与配合
PyTorch 的数据加载体系由Dataset和DataLoader两个类组成。Dataset负责定义“一条数据长什么样”以及“怎么取到它”,DataLoader则负责批量组装、打乱顺序、并行加载。这个设计看起来很清晰,但实际用起来有很多细节决定成败。
写Dataset时,最重要的方法是__getitem__,它接收一个索引,返回一条样本。这里有个性能陷阱:如果你在__getitem__里做复杂的预处理,比如实时解码高分辨率图像或者做数据增强,那么当num_workers大于 0 时,这些操作会在子进程里并行执行,速度确实会提升。但如果你把预处理放在__init__里一次性做完,虽然省去了重复计算,但内存占用会飙升,而且失去了在线增强的灵活性。
我的建议是:对于小数据集,可以全部加载到内存里;对于大数据集,保持__getitem__的轻量化,把耗时的操作交给 GPU 或者专门的预处理库。另外,__getitem__里尽量避免使用 Python 的全局变量,因为多进程环境下每个子进程有独立的内存空间,全局变量可能不会按你预期的方式共享。
3.2 num_workers 到底设多少才合适
num_workers是DataLoader里最常被问到的一个参数。设成 0 表示在主进程里加载数据,设成大于 0 则会启动相应数量的子进程。理论上,子进程越多,数据准备越快,但实际并不是线性增长的。
我做过一个简单的测试:在一个图像分类任务上,分别用 0、2、4、8 个 worker 跑同样的训练循环。结果发现,从 0 到 2 的提升非常明显,从 2 到 4 有一定提升,但到了 8 反而变慢了。原因是子进程之间的通信和内存拷贝也有开销,而且当 worker 数量超过 CPU 核心数时,进程调度本身就会成为瓶颈。
一般来说,num_workers设成 CPU 核心数的一半到三分之二比较稳妥。如果你的机器有 8 个核心,可以先试 4。另外,在 Windows 上使用多进程 DataLoader 需要把训练代码放在if __name__ == '__main__':保护块里,否则会无限递归创建子进程。这个坑我在第一次用 Windows 跑 PyTorch 时就踩过,报错信息看得一头雾水,后来才知道是平台差异。
还有一个细节:当num_workers > 0时,DataLoader会预取一定数量的 batch。这个预取数量由prefetch_factor控制,默认是 2。如果你发现 GPU 利用率忽高忽低,可以适当调大这个值,让数据准备更平滑。但调得太大也会占用更多内存,需要根据实际情况权衡。
3.3 数据增强的时机与策略
数据增强是提升模型泛化能力的常用手段,但在 PyTorch 里,增强操作放在哪里是有讲究的。如果你用的是torchvision.transforms,通常会在Dataset的__getitem__里对图像做变换。这样做的好处是每次取数据时增强都是随机的,相当于免费扩充了数据集。
但要注意,有些增强操作会改变图像的尺寸或通道数,如果和后续的模型输入不匹配,就会报错。我习惯在__getitem__的最后一步做ToTensor()和Normalize(),确保输出的张量格式统一。另外,对于验证集和测试集,千万不要做随机增强,只需要做确定性的缩放和归一化,否则评估结果会不稳定。
还有一个进阶技巧:如果你发现某些增强操作特别耗时,比如RandomRotation或者ColorJitter,可以考虑用torchvision.transforms.v2里的新版本,它们对性能做了一些优化。或者干脆用 GPU 来做增强,把数据先传到显卡上,再用kornia之类的库做批量变换,速度会快很多。
4. 模型训练:从能跑到跑得好的关键跨越
4.1 损失函数的选择不是越复杂越好
PyTorch 提供了丰富的损失函数,从简单的MSELoss到复杂的CTCLoss都有。但很多人会陷入一个误区:觉得损失函数越复杂、越新潮,模型效果就越好。实际上,损失函数的选择应该由任务本身决定,而不是反过来。
分类任务用交叉熵,回归任务用均方误差,这是最基本的对应关系。但实际项目中,类别不平衡是很常见的问题。这时候可以用带权重的交叉熵,给少数类更高的权重。PyTorch 的CrossEntropyLoss有一个weight参数,传入一个和类别数等长的张量即可。权重的设置通常有两种思路:一种是按类别频率的倒数来设,另一种是让所有类别的总权重相等。我一般先用第一种,如果效果不好再调整。
还有一个容易忽略的点是reduction参数。默认是'mean',表示对 batch 里所有样本的损失取平均。如果你在做一些特殊的训练策略,比如在线难例挖掘,可能需要设成'none',然后自己写逻辑来筛选样本。这个参数虽然小,但在自定义训练循环里很关键。
4.2 优化器的参数配置与学习率调度
PyTorch 的optim模块里,Adam和SGD是最常用的两个优化器。Adam对学习率不那么敏感,适合快速实验;SGD配合动量在调参得当的情况下往往能获得更好的最终精度,但需要更细致的学习率调整。
我通常的做法是:在项目初期用Adam快速验证模型结构和数据管道的正确性,等确定方案可行后,再切换到SGD做精细调优。学习率方面,Adam的默认值 1e-3 是个不错的起点,SGD则通常从 1e-2 或 1e-1 开始试。
学习率调度器是另一个提升训练效果的重要工具。StepLR简单直接,每隔固定轮数衰减一次;CosineAnnealingLR则让学习率按余弦曲线平滑下降,在很多任务上表现更好。我最近比较喜欢用OneCycleLR,它先让学习率从低到高升温,再逐渐降下来,配合适当的动量调整,收敛速度往往比固定学习率快不少。
但要注意,调度器的step()调用时机很关键。对于按 epoch 调度的,应该在每个 epoch 结束后调用;对于按 batch 调度的,则在每个 batch 后调用。如果搞混了,学习率的变化节奏就会乱掉,训练效果大打折扣。
4.3 模型保存与恢复的完整方案
训练一个模型可能要几个小时甚至几天,中途遇到意外中断是常有的事。所以模型保存策略必须提前设计好,不能等训练完了才想起来。
最基本的保存方式是torch.save(model.state_dict(), path),只保存参数,不保存模型结构。这样做的好处是文件小、加载灵活,但需要你在加载时重新定义模型类。另一种方式是保存整个模型对象,虽然方便,但依赖具体的类定义,跨项目迁移时容易出问题。
我的习惯是同时保存三样东西:模型参数、优化器状态、当前 epoch 数。这样即使训练中断,也能从断点继续,而不是从头开始。代码大概长这样:
checkpoint = { 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'loss': loss, } torch.save(checkpoint, f'checkpoint_epoch_{epoch}.pt')恢复的时候,先创建模型和优化器实例,再加载对应的状态字典。注意map_location参数,如果你在 GPU 上保存的模型要在 CPU 上加载,或者反过来,这个参数能帮你自动处理设备映射,避免报错。
还有一个经验:不要只保存最后一个 epoch 的模型,最好每隔几个 epoch 保存一次,并且保留验证集上表现最好的那个。我见过有人训练了三天,结果最后一次保存时程序崩溃,之前的所有成果都没了。这种教训一次就够记一辈子。
5. 常见问题与排查技巧实录
5.1 训练 loss 不下降的排查思路
Loss 不下降是训练中最常见的问题之一,可能的原因很多,需要按顺序排查。我的习惯是从数据开始查起,因为数据问题往往是最隐蔽也最致命的。
首先确认输入数据和标签是否对应正确。可以手动取一个 batch,把图像和标签打印出来看看。如果图像是乱码或者标签全是同一个值,那问题就出在数据管道上。其次检查模型输出是否正常,可以在前向传播后打印输出的均值和方差,如果全是零或者全是同一个值,说明模型初始化或者某层的激活函数有问题。
如果数据和模型都没问题,那就看学习率。学习率太大,loss 会震荡甚至发散;学习率太小,loss 下降极其缓慢。可以尝试把学习率调小一个数量级,看看 loss 是否有变化。另外,梯度消失或爆炸也会导致 loss 不动,可以通过打印每层的梯度范数来判断。如果发现梯度接近零,可以考虑用BatchNorm或者换用ReLU的变体;如果梯度爆炸,则可以用梯度裁剪。
5.2 显存不足的应急处理与长期方案
显存不足的报错信息通常很明确,但解决起来需要根据具体情况选择方案。应急处理包括:减小 batch size、使用torch.cuda.empty_cache()、把部分层或数据移到 CPU 上。但这些都只是权宜之计,长期来看需要从模型和训练策略上优化。
模型层面,可以考虑用更轻量的 backbone、减少全连接层的宽度、或者使用混合精度训练。混合精度通过torch.cuda.amp实现,能在几乎不损失精度的情况下把显存占用降低一半左右。训练策略上,梯度累积可以让你在小 batch 的情况下模拟大 batch 的效果,具体做法是多次前向传播后再统一反向传播和更新参数。
还有一个容易被忽略的点是中间变量的释放。在训练循环里,如果某些张量不再需要,可以手动del掉并调用torch.cuda.empty_cache()。但要注意,Python 的垃圾回收机制不是实时的,所以del之后显存不一定马上释放。更可靠的做法是尽量减少不必要的中间变量,把计算逻辑写得更紧凑。
5.3 模型评估指标与验证集设计
训练 loss 下降不代表模型真的好用,必须用独立的验证集来评估。验证集的设计要尽量贴近真实应用场景,不能随便从训练集里切一小块就完事。如果数据有时间属性,验证集应该按时间划分,而不是随机划分;如果数据有类别属性,验证集里每个类别的样本数要足够多,否则指标波动会很大。
评估指标的选择也很关键。分类任务不能只看准确率,尤其是类别不平衡的时候。精确率、召回率、F1 分数、AUC 这些指标各有侧重,需要根据业务目标来选。我一般会同时看多个指标,如果它们之间出现矛盾,就说明模型在某些类别上存在系统性问题,需要进一步分析混淆矩阵。
还有一个实操技巧:在验证时固定随机种子,确保每次评估的条件一致。否则数据增强的随机性会导致指标波动,让你误以为模型性能发生了变化。另外,验证频率不需要太高,每个 epoch 一次通常就够了,太频繁反而浪费时间。
5.4 常见报错速查表
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
RuntimeError: Expected all tensors to be on the same device | 张量分布在不同的设备上 | 统一用.to(device)把数据和模型放到同一设备 |
CUDA out of memory | 显存不够 | 减小 batch size、用混合精度、清理缓存 |
RuntimeError: size mismatch | 张量形状不匹配 | 打印各层输入输出形状,检查全连接层维度 |
ValueError: num_samples should be a positive integer | Dataset 长度为 0 或 DataLoader 参数错误 | 检查__len__返回值,确认数据集路径正确 |
RuntimeError: element 0 of tensors does not require grad | 对不需要梯度的张量调用了 backward | 检查requires_grad设置,确认损失函数输入 |
BrokenPipeError | 多进程 DataLoader 在 Windows 上未加保护 | 把训练代码放在if __name__ == '__main__':下 |
这张表里的问题我都实际遇到过,有些甚至反复踩坑。比如设备不一致的问题,在单卡环境下可能不明显,一旦换成多卡或者混合 CPU/GPU 环境,就会频繁出现。我的建议是养成习惯,在创建任何张量时都显式指定设备,不要依赖默认值。
6. 从实验到部署:PyTorch 模型的落地路径
6.1 模型导出与格式转换
训练好的模型要部署到生产环境,第一步就是导出。PyTorch 提供了多种导出方式,最常用的是torch.save保存参数,然后在部署端重新加载。但这种方式依赖 Python 环境,如果部署目标是移动端或者嵌入式设备,就需要转换成其他格式。
TorchScript是 PyTorch 官方的中间表示,可以通过torch.jit.trace或torch.jit.script把模型转换成不依赖 Python 解释器的格式。trace适合结构固定的模型,它通过运行一次前向传播来记录计算图;script则支持更复杂的控制流,但需要模型代码符合一定的规范。我一般先用trace试,如果报错再考虑script。
另一个流行的选择是导出成 ONNX 格式,然后用 ONNX Runtime 或者 TensorRT 来加速推理。ONNX 的好处是跨框架兼容性好,可以在不同的硬件平台上运行。但转换过程中可能会遇到算子不支持的问题,需要查文档确认哪些操作可以转换、哪些需要自定义实现。
6.2 推理性能优化的几个方向
模型部署后,推理速度和资源占用就成了关键指标。优化的方向主要有三个:减少计算量、降低精度、并行化。
减少计算量可以通过模型剪枝、知识蒸馏或者换用更高效的网络结构来实现。降低精度则是指用 FP16 甚至 INT8 来替代 FP32,在精度损失可控的前提下大幅提升速度。PyTorch 提供了torch.quantization模块来做量化,但量化后的模型可能需要校准,否则精度会掉得比较厉害。
并行化方面,如果服务端有多张显卡,可以用DataParallel或者DistributedDataParallel来做多卡推理。但要注意,推理时的并行策略和训练时不太一样,训练时更关注吞吐量,推理时则要兼顾延迟。对于实时性要求高的场景,可能单卡加批处理反而是更好的选择。
6.3 版本兼容性与环境管理
PyTorch 的版本更新比较频繁,不同版本之间的 API 可能有细微变化。我遇到过最头疼的问题是:在本地用最新版 PyTorch 训练的模型,放到服务器上因为版本不一致而加载失败。所以现在我的习惯是,在项目开始时就确定好 PyTorch 版本,并且用requirements.txt或者environment.yml把依赖固定下来。
CUDA 版本和 PyTorch 版本的对应关系也需要特别注意。不是所有 PyTorch 版本都支持最新的 CUDA,反过来也一样。安装时最好去官网查一下兼容性表格,或者直接用 conda 安装,它会自动处理依赖关系。如果手动用 pip 安装,很容易出现 CUDA 版本不匹配的问题,报错信息往往很隐晦,排查起来费时费力。
还有一个经验:如果项目周期比较长,不要盲目升级 PyTorch 版本。新版本可能引入了性能优化,但也可能带来不兼容的改动。除非有明确的需求,否则保持版本稳定比追新更重要。
7. 一些零散但实用的经验碎片
7.1 随机种子的设置与复现性
深度学习实验的复现性一直是个难题,但设置随机种子能解决大部分问题。PyTorch 的随机性来源主要有几个:参数初始化、数据打乱、Dropout、数据增强。要完全固定这些随机性,需要同时设置torch.manual_seed、torch.cuda.manual_seed_all、numpy.random.seed和random.seed。
但即使这样,也不能保证百分之百复现,因为 CUDA 的某些操作本身是非确定性的。如果对复现性要求极高,可以在代码开头加上torch.use_deterministic_algorithms(True),但这会牺牲一些性能,而且某些算子可能没有确定性实现,会直接报错。所以通常只在调试阶段开启,正式训练时还是以性能优先。
7.2 日志记录与实验追踪
训练过程中产生的信息很多,loss、准确率、学习率、梯度范数等等。如果只是打印到终端,信息很快就会刷过去,事后想分析都找不到。我的做法是用tensorboard或者wandb这类工具把指标记录下来,训练结束后可以直观地看到曲线变化。
日志的粒度也很重要。太粗了看不出问题,太细了又会被淹没。我一般会记录每个 epoch 的训练 loss 和验证指标,以及每个 batch 的学习率。如果发现异常,再临时加更细的日志。另外,把超参数也记录下来,方便对比不同配置的效果。
7.3 代码组织与模块化
PyTorch 项目很容易写成一个大文件,所有逻辑都堆在一起。刚开始可能觉得方便,但随着项目变大,维护成本会急剧上升。我的建议是从一开始就做好模块化:数据加载、模型定义、训练循环、评估逻辑分别放在不同的文件里,用一个配置文件来管理超参数。
这样做的另一个好处是方便复用。比如你写好了一个数据增强的管道,下一个项目可以直接拿过来用,只需要改改配置就行。模型定义也可以做成可插拔的,换 backbone 的时候不用动训练代码。这些工程上的投入,在项目初期可能看不出价值,但到了后期会帮你省下大量时间。
7.4 学习资源与社区生态
PyTorch 的社区非常活跃,遇到问题基本都能找到答案。官方论坛、GitHub Issues、Stack Overflow 都是很好的资源。但要注意,有些回答可能针对的是旧版本,直接照搬可能会踩坑。看回答的时候先确认一下版本号,如果版本差异太大,最好自己验证一下。
另外,PyTorch 的官方教程质量很高,尤其是那个 60 分钟入门,虽然实际可能要花两三个小时才能消化,但内容确实扎实。进阶的话可以看看torchvision、torchtext、torchaudio这些领域库的文档,它们提供了很多预训练模型和数据处理工具,能省去不少重复劳动。
最后再分享一个小技巧:如果你在实现某个功能时不确定怎么写,可以去 PyTorch 的 GitHub 仓库里搜类似的代码。官方示例和测试用例里往往有最规范的写法,比网上随便找的博客靠谱得多。我很多最佳实践都是从源码里学来的,虽然过程慢一点,但学到的东西更扎实。