1. 为什么大家都在用PyTorch
1.1 动态图到底解决了什么问题
如果你接触过深度学习,大概率听过这句话:PyTorch 用起来像写普通 Python 一样舒服。这句话不是客套话,背后牵扯到一个很底层的设计选择——动态计算图。
我当年第一次接触另一个框架时,被静态图折磨得不轻:先用一套特殊语法描述整个模型结构,编译成图,再喂数据进去跑。中间想打印一个张量的形状、看一眼某个中间层的输出?不行,图已经固定了,得先改定义、重新构建整个流程。这个体验就像你先要把房子的施工图画完,才能让工人动工,中途想改个窗户朝向,整个图纸要推翻重画。
PyTorch 走的是另一条路:你在代码里写self.fc1(x)的那一刻,计算图这张“施工图”就自动生成了,而且每一步都能拿到中间结果。你要打印某个张量?直接print(x.shape),没有任何阻碍。你要在训练到一半时临时改网络结构?完全可以,下一次前向传播时就生效了。这种感觉就是“边画图边施工”,哪一步不对,当场就能发现。
动态图带来的调试体验提升是巨大的。我印象特别深的一次,是某次训练图像分类模型时 loss 一直不降,换成 PyTorch 之后我在 forward 里直接打印了每一层的输出分布,发现某个卷积层输出全部变成了同一常数,立刻定位到是激活函数的问题。这种排查方式在静态图时代很难想象。
1.2 生态与社区带来的复利效应
除了动态图,PyTorch 能成为主流选择的另一大原因是生态。
我指的不是单纯“库多”这个浅层含义,而是围绕模型部署、移动端推理、分布式训练、模型库、预训练权重这些环节形成了一整套闭环。你想做图像分类,有现成的预训练模型可以加载;你想做文本分类,模型库同样丰富;你需要把模型部署到服务器甚至移动端,也有对应的转换工具链。
这种生态复利效应让学习成本变得很低。对一个刚入门的人来说,遇到问题在社区里几乎都能搜到解决方案。我自己的感受是:很多坑并不是你一个人踩过,而是有一大批人踩过并留下了记录。对一个框架来说,这种“前人铺路”的积累比文档本身更宝贵。
从团队协作角度看,PyTorch 的代码风格和 Python 原生语法高度一致,这意味着新成员上手的心理门槛很低。我见过不少转行做算法的新人,第一周就能读懂 PyTorch 的训练代码并尝试修改。
2. 必须理解的核心机制
2.1 张量不只是个多维数组
PyTorch 里最基本的操作对象是张量。很多人把它简单理解成“多维数组”,这个说法对但不全对。它和普通数组的关键差别在于:张量能感知自己的运算历史,并自动记录求导所需的信息。
看一个例子。假设你有两个张量a和b,设置了requires_grad=True,然后执行c = a * b、d = c.sum()。当你调用d.backward()时,PyTorch 会顺着自己记录的运算链,自动算出a.grad和b.grad。你根本不用手推链式法则,也不用自己写梯度公式。
这里我想多强调一句:requires_grad不是默认开启的。我见过太多新手把输入数据设置成requires_grad=True,导致计算图不断累积,内存越占越多,最后程序卡死。正确的做法是:只有需要求梯度的模型参数才开启,输入数据和标签保持普通张量即可。
张量还有个常被忽略的细节——设备。你可以用.to('cuda')把张量搬到 GPU,也可以.cpu()搬回 CPU。很多报错都是因为张量在 CPU 而模型在 GPU,或者反过来。
打个比方:张量就像一个既能在办公室工作、也能去机房干活的员工,但老板(训练代码)必须说清楚“今天在哪儿上班”,否则两边对不上就报错。
2.2 autograd 与计算图的链式求导
PyTorch 的自动求导模块是其灵魂所在。它做的事情可以概括为:每次前向传播时,把每一步运算记录成一个“节点”,节点与节点之间按照运算顺序连成一张有向无环图。
这张图记录了什么东西?它记录了两个信息:当前节点的输出,以及从输入到输出需要做什么运算。你调用backward()时,PyTorch 从最终的 loss 节点出发,沿着图反向走一遍,每经过一个节点就利用链式法则计算该步的局部梯度,然后乘以上游传来的梯度,一路传播到每个叶子节点(也就是模型参数)。
理解这个机制后,你就明白几个常见操作背后的逻辑了:
optimizer.zero_grad()为什么必须在每个 batch 开始前调用?因为梯度默认是累积的。如果不手动清零,上一个 batch 的梯度会和当前 batch 的梯度加在一起。我最初在此踩过坑,结果是 loss 曲线表现为波浪式震荡,让人误以为是学习率太大,其实是梯度累加。为什么验证阶段要用
torch.no_grad()?因为验证时不需要更新参数,也不需要保存梯度。不加这个上下文管理器,计算图照样构建,白白浪费显存,速度也会明显变慢。为什么有些中间张量只在训练时存在,验证时没有?因为像 BatchNorm 的滑动均值这类信息是训练时维护的,这提醒你
model.eval()不能随意省略。
2.3 训练循环里那 5 行代码为什么不能少
所有 PyTorch 训练代码,核心循环几乎都长这样:
for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step()这 5 行代码的顺序有严格逻辑:
先算预测值,再算 loss,这是前向过程。loss.backward()反向传播,把梯度存到每个参数上。optimizer.step()拿着梯度去更新参数。而zero_grad()必须在下一个 batch 开始前清除本次的梯度。
我见过有人把zero_grad()放到backward()之后、step()之前,结果就是梯度被清掉之后参数根本没更新到。这种错误不报异常,模型也“能跑”,但 loss 几乎不动,排查起来很折磨人。
理解了这 5 行之后,你再去看那些封装好的训练器源码,会发现它们在完备性上做扩展,但骨架就是这个。
3. 一个完整训练流程的落地细节
3.1 数据管线:Dataset 与 DataLoader 的正确打开方式
多数新手直接train_data = torch.tensor(x)然后开训练,这样在小 demo 里没问题,但一旦数据量大一点,你就会发现内存爆了、加载极慢、batch 管理全靠手写——这完全是在浪费 PyTorch 提供的数据管线能力。
正确做法是实现一个Dataset子类,然后交给DataLoader。Dataset需要实现两个方法:__len__返回样本总数,__getitem__按索引返回一个样本。
from torch.utils.data import Dataset, DataLoader class MyDataset(Dataset): def __init__(self, samples, labels): self.samples = samples self.labels = labels def __len__(self): return len(self.samples) def __getitem__(self, idx): x = self.samples[idx] y = self.labels[idx] return x, yDataLoader的几个参数值得多说两句:
batch_size:一次喂给模型多少个样本。太大会显存溢出,太小则梯度更新频繁、训练不稳定。shuffle:训练时设为True,让每个 epoch 的数据顺序都不同,避免模型学到数据排列顺序的伪规律。验证集则建议False,保证评估的稳定性和可复现性。num_workers:加载数据的子进程数。我一般在 CPU 机器上设为 4 到 8,在 GPU 训练时这个值可以适当调高,但太多反而会因为进程切换开销拖慢速度。pin_memory=True:如果你用 GPU 训练,这个选项能把数据加载到页锁定内存,从而加快 CPU 到 GPU 的拷贝速度。
3.2 模型定义与初始化
PyTorch 中定义模型的标准姿势是继承nn.Module,然后在__init__里搭建层,在forward里定义前向计算过程。
import torch.nn as nn class SimpleClassifier(nn.Module): def __init__(self, input_dim, hidden_dim, num_classes): super().__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.relu = nn.ReLU() self.fc2 = nn.Linear(hidden_dim, num_classes) def forward(self, x): x = self.fc1(x) x = self.relu(x) x = self.fc2(x) return x这里面有个初学者容易忽略的点:forward里用到的层,最好都在__init__里定义好。虽然你也可以在forward里直接torch.relu这种函数式调用,但如果你需要参数(比如 Linear 层的权重),就必须在__init__里声明,否则这些参数不会被注册到模型里,也就不会被优化器更新。
模型初始化也有讲究。PyTorch 的nn.Linear默认使用 Kaiming 均匀初始化,在多数情况下是合理的。但如果你自己用nn.Parameter定义权重,一定要自己初始化,否则可能因为初始值过大导致输出分布异常。我习惯用以下方式:
def init_weights(m): if isinstance(m, nn.Linear): nn.init.xavier_uniform_(m.weight) nn.init.zeros_(m.bias)3.3 损失函数、优化器与学习率
损失函数的选择会直接影响训练信号的强弱。分类问题常用CrossEntropyLoss,它内部已经包含了 softmax,所以你不需要在模型最后一层额外加 softmax。回归问题常用MSELoss。序列生成这类结构化输出,则会用更复杂的损失组合。
优化器方面,Adam是最省心的选择,它自适应学习率,几乎不需要怎么调参就能收敛。但有时候我也会换回SGD,因为 SGD 配合合适的学习率和动量,最终的泛化效果往往更好。我的习惯是:先拿 Adam 快速看模型能不能过拟合一个小数据集,确认没 bug 后再换 SGD 做正式训练。
学习率是最重要的超参数之一。我常用的策略是预热加余弦衰减:训练开始的前几个 epoch 用一个很小的学习率预热,等模型稳定了再按余弦曲线逐步降低。
import torch.optim as optim optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)学习率过大的后果,我后来总结成一个判断口诀:loss 变成 NaN 或乱跳,先看学习率;loss 一直在同一个量级不动,再看学习率是不是太小了。
3.4 训练验证循环的完整骨架
完整的训练流程可以拆成两段:训练阶段和验证阶段。训练阶段有梯度,验证阶段没有。最容易犯的错,就是遗忘切换模型的train()/eval()状态。
def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() total_loss = 0 for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) return total_loss / len(dataloader.dataset) def evaluate(model, dataloader, criterion, device): model.eval() total_loss = 0 correct = 0 with torch.no_grad(): for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) outputs = model(inputs) loss = criterion(outputs, targets) total_loss += loss.item() * inputs.size(0) preds = outputs.argmax(dim=1) correct += (preds == targets).sum().item() return total_loss / len(dataloader.dataset), correct / len(dataloader.dataset)这段代码里有几个细节很实用:
loss.item()把张量转成 Python 数值,用于累计平均值。直接用loss累加会导致计算图一直挂在内存里,显存越占越大。- 用
inputs.size(0)作为加权,而不是简单平均,是为了处理最后一个 batch 可能样本数量不足的情况。 - 验证阶段的
with torch.no_grad()是必须的,不加它验证速度会明显变慢,内存占用也会升高。
至于训练的 epoch 数,我的习惯是先过拟合一个小数据集,比如只拿 100 条样本训练到 loss 很低,验证代码逻辑没问题,再放全量数据。这个习惯帮我过滤掉大量低级 bug。
4. 踩坑实录与提速技巧
4.1 新老手都会遇到的典型报错
先说两个我在教学和交流中见到频率最高的报错,以及背后原因。
第一个:device 不匹配
Expected all tensors to be on the same device这个错几乎每个人都见过。原因是部分张量在 CPU、部分在 GPU。最常见的情况是:模型搬到了 GPU,但输入数据和标签忘了.to(device)。另一个隐蔽的情况是,你手动构造了一些辅助张量(比如 mask、索引),忘了把它们挪到 GPU。
我的排查思路是:报错信息里通常带张量名字和设备信息,先看哪个张量没跟上;改完再跑一遍,如果还有错就继续找。这个方法虽然笨但有效。
避免这个问题的技巧是:在代码开头定义统一的device = torch.device('cuda' if torch.cuda.is_available() else 'cpu'),然后所有张量和模型都调用同一个.to(device),不要在某处写死'cuda'。
第二个:显存溢出(OOM)
CUDA out of memory是炼丹人绕不开的痛。我遇到过几种情况:
- batch_size 太大,显存装不下。
- 模型本身太大。
- 验证阶段忘了
torch.no_grad(),计算图一直在累积。 - 数据加载太慢导致 GPU 一直空转等数据,看起来像卡死了。
我的处理策略是:先降 batch_size 到能跑为止,再观察显存是否动态增长。如果显存在整个训练过程中只增不减,多半是计算图没释放,检查有没有把loss.item()之外的操作带出循环。如果只是瞬时不够,可以尝试torch.cuda.empty_cache()清一下缓存碎片。
4.2 让训练快一倍的几个设置
很多人用 PyTorch 训练时只关注模型结构,忽略了数据管线和硬件利用率的设置。以下这几个改动,实测对训练速度提升非常明显。
第一,把数据加载做成并行。
DataLoader的num_workers=0意味着在主进程里同步加载数据,GPU 会在每个 batch 加载时闲置。设置num_workers=4或者8,数据加载可以和 GPU 计算并行,训练吞吐量能明显提升。
第二,开启pin_memory=True并配合.non_blocking=True。
页锁定内存允许 GPU 以异步方式拷贝数据,减少等待时间。配合.to(device, non_blocking=True),数据搬运和计算也能重叠。
第三,使用混合精度训练。
对于 Ampere 及更新架构的 GPU,混合精度不仅省显存,还能显著提速:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() with autocast(): outputs = model(inputs) loss = criterion(outputs, targets) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()这段代码的关键是scaler.scale(loss):混合精度模式下梯度值可能过小,直接更新会丢失精度,所以需要先缩放梯度、更新完参数再缩放回来。
第四,把固定的操作移出循环。
比如不需要梯度的预处理,应该放到训练循环外只做一次。把.to(device)尽量做到每次循环开头一次性完成,而不是在模型内部到处调用。
4.3 排查思路:从 NaN 到局部最优
训练中 loss 变成 NaN 也是高频问题。我总结出的排查顺序:
- 先看学习率。学习率过大导致梯度更新步长太大,是 NaN 最常见的原因。直接把学习率降到当前值的十分之一,看是否恢复稳定。
- 再看数据里有没有异常值。数据中如果包含 NaN 或无穷大,loss 大概率也会变成 NaN。检查原始数据的统计信息,比如最大值、最小值、是否有缺失值。
- 检查模型中是否有除零操作。比如自定义 loss 里分母经过 softmax 之后接近 0,会导致除零得到无穷大。
- 检查梯度是否爆炸。可以在
backward()之后加一句torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0),限制梯度大小。
如果是 loss 卡在某个偏高的值一动不动,往往不是 bug,而是模型陷入了局部最优或者数据预处理出了问题。我的做法是:
- 拿一个很小的训练子集跑,看是否能过拟合。
- 检查标签是否从 0 开始连续编号。分类问题里如果有缺失的类别编号,
CrossEntropyLoss的表现为 loss 奇高且不降。 - 对输入特征做标准化,让每个维度的均值为 0、方差为 1,避免某些特征因为数值范围太大而主导梯度。
4.4 一个避免踩坑的 demo 实践
把上面这些知识串起来,我建议新手从图像分类任务练手,因为它数据直观、结果容易判断。我自己经常用一个小规模的模拟数据集来验证训练流程正确性,流程如下:
- 生成随机样本和对应标签。
- 用
MyDataset包装,用DataLoader加载。 - 定义含有一个隐藏层的
SimpleClassifier。 - 用
CrossEntropyLoss和Adam训练 10 个 epoch。 - 观察训练 loss 持续下降,验证准确率逐步上升。
这个流程跑通后,再替换成真实数据、更复杂的模型和分布式训练,就能逐步适应 PyTorch 的各种进阶玩法。
我个人在实际操作中的体会是:PyTorch 的强大之处不在于某个单独的功能,而在于它把“实验迭代”这件事的效率拉满了。甭管你理论懂得再多,最后都要落到代码上跑实验,而它的动态调试能力和生态完备性是支撑这种快速迭代的关键。如果你正处在刚学完张量和自动求导、准备上手完整项目的阶段,建议不要急着套高级封装,先手写一个最朴素的训练循环,把每个环节打印出来看一遍。踩过几次坑之后,你对框架的理解会远超那些只会用工具库的同行。