从 2017 年第一次接触 PyTorch 到现在,我已经用它完成了十几个项目,从图像分类到目标检测,从单机训练到多卡分布式,可以说踩过的坑比写的模型还多。如果你正准备选一个深度学习框架入门,或者已经写了一些代码但总感觉知其然不知其所以然,这篇文章就是写给你的。我会从框架选型的底层逻辑讲起,把 Tensor、autograd、nn.Module 这些核心概念掰开揉碎,再带你完整走一遍训练图像分类模型的实操流程,最后把那些文档里不会写的显存崩溃、不收敛、过拟合问题一次性聊透。
1. 为什么我最终选了 PyTorch:框架选型的真实考量
1.1 动态图给我的工作效率提升
先聊一个很多人忽视的点:PyTorch 最大的杀手锏不是"好用的 API",而是动态计算图。什么意思?就是你每次 forward 的时候,模型会实时构建一张计算图,下一次输入不同形状的数据,图就跟着变。这听起来抽象,但实际体验差别巨大。
我之前用静态图框架写一个带条件分支的网络时,得先把所有可能的路径都声明出来,还得用专门的tf.cond或者tf.while_loop去控制流程,代码写起来绕得不行。而 PyTorch 里就是普通的if、for,跟写 Python 一样直接。举个例子,你想根据输入的长度决定是否跳过某个模块,直接这么写:
def forward(self, x): if x.size(0) > 32: x = self.big_branch(x) else: x = self.small_branch(x) return x这在 PyTorch 里是合法且完全正常的,因为计算图是运行时动态构建的。动态图让调试变得非常直观,你可以在任意一行打断点,打印中间 tensor 的值,不需要像静态图那样先"编译"再执行。对于做研究或者快速验证想法的人,这几乎就是效率翻倍。
1.2 生态和调试体验:从"玄学调参"到"所见即所得"
第二个让我坚定选择它的理由是调试体验。说实话,刚转过来的时候我都惊了——原来训练循环是可以这么写清楚的。PyTorch 的代码风格极度 Pythonic,训练一个模型就是标准的"五步走":前向传播、算 loss、反向传播、更新参数、清零梯度。每一步都是显式调用,没有任何魔法隐藏在里面。
optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step()这种显式的流程对新手极其友好:你想看哪一步的中间结果,直接print或者用调试器进去看就行。反观某些"高级封装"框架,一句model.fit()就把所有细节吞进去了,遇到问题根本不知道从哪里排查。PyTorch 的生态也成熟得可怕,HuggingFace 的 Transformers、Ultralytics 的 YOLO、各种 SOTA 模型的官方实现基本都是 PyTorch 优先,这意味着你几乎不需要自己从零复现别人论文里的结构,直接拿来改就行。
1.3 和另外两个主流方案的对比
如果用一句话总结我的选型结论:追求灵活性和快速迭代,就选 PyTorch;追求极致的部署优化和自动化,再考虑另外两个方案。
我之前也认真试过其他框架。静态图框架在服务部署上有优势,导出的模型经过优化后推理效率高,但它那套"先定义后运行"的模式对复杂模型来说维护成本太高。另一个老牌框架虽然上手快、文档全,但模型定义和训练的底层逻辑有时候"黑箱"感很强,出了问题难以深入定位。PyTorch 刚好站在一个很好的平衡点:动态图的灵活 + 足够丰富的官方工具链(TorchScript、ONNX、量化、分布式),既能满足快速实验,也能在生产环境中硬碰硬。
提示:框架没有绝对的好坏,关键看团队技术储备和项目需求。但如果你的目标是"快速验证想法、灵活改进网络结构、把深度学习原理搞清楚",PyTorch 是目前最优解。
2. 动手之前必须吃透的 5 个核心概念
2.1 Tensor:不只是多维数组
Tensor 是 PyTorch 的基本数据结构,你可以把它理解成"能自动求导的 N 维数组"。它和 NumPy 的 ndarray 长得几乎一样,但多了两个关键能力:可以搬到 GPU 上计算,可以参与自动求导。
建议新手先记住几个操作规律。张量创建常用torch.tensor()、torch.zeros()、torch.randn();形状变换推荐优先用view()而不是reshape(),区别在于view()要求张量在内存中是连续的,如果不连续就会报错,而reshape()会自动处理拷贝。实际写代码时发现view()报错,换成reshape()就能解决,原因就是源张量经过转置或切片后内存布局不连续了。
还有一个高频踩坑点是torch.Tensor()和torch.tensor()的差异。torch.Tensor()是类的构造器,默认生成 float32 的未初始化数据;torch.tensor(data)会主动拷贝数据并推断 dtype。如果传入的是整数列表,torch.tensor([1, 2])得到 int64;而torch.Tensor([1, 2])得到 float32。这个细节在拼接张量时偶尔会引发 dtype 不一致的报错,我一度被坑得不轻。
import torch a = torch.tensor([1, 2, 3]) print(a.dtype) # torch.int64 b = torch.Tensor([1, 2, 3]) print(b.dtype) # torch.float322.2 autograd:自动求导到底怎么工作
autograd 是 PyTorch 的灵魂。你只需要把网络中参与梯度计算的张量设置requires_grad=True,然后每次前向传播时,PyTorch 会自动记录所有涉及这个张量的运算,构建一张反向传播图。当你调用loss.backward()时,它按照链式法则,从 loss 开始反向计算每个叶子节点的梯度,并把结果存到对应张量的.grad属性上。
这里有一个重要的认知:只有叶子节点才会保存梯度。中间运算得到的张量虽然也有.grad,但默认是None。如果想拿到中间变量的梯度,要主动调用register_hook,这个功能在分析模型内部状态时非常有用。
x = torch.tensor(2.0, requires_grad=True) y = x ** 2 + 3 * x + 1 y.backward() print(x.grad) # 2*x + 3 = 7很多新手犯的错误是:在训练循环里反复调用backward(),但忘了把上一轮存的梯度清零。梯度是累加的!如果不清零,两次 batch 的梯度会叠加在一起,参数更新方向完全错乱。正确做法是在每个 batch 开始时调用optimizer.zero_grad(),这一步不能省。
2.3 nn.Module:把网络写成乐高积木
nn.Module是所有网络层的基类。你用nn.Linear、nn.Conv2d搭积木一样组装网络,这些层都是nn.Module的子类。自定义模型时,有两个必须记住的约定:
class MyNet(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(3, 16, kernel_size=3) self.fc = nn.Linear(16 * 6 * 6, 10) def forward(self, x): x = self.conv1(x) x = torch.flatten(x, start_dim=1) return self.fc(x)第一,在__init__里定义子层,在forward里定义计算逻辑。你只需要保证调用模型前参数已经初始化好,不需要自己手动绑定权重。第二,模型默认处于训练模式,要记住切换——这个我在下一节单独说。
nn.Module还有一个很有用的机制:调用.to(device)时,它会递归地把所有子模块的参数和缓冲区都搬到指定设备上。所以不要手动遍历参数一个个搬,直接整个模型model.to('cuda')就行,省事且不容易漏。
2.4 Dataset 与 DataLoader:数据管线的正确姿势
很多人拿到数据就直接for batch in data循环读取,数据量小看不出问题,一旦数据量大,GPU 会时刻处于等待状态,训练效率极低。正确姿势是自定义Dataset类,然后交给DataLoader做多进程加载。
class MyDataset(torch.utils.data.Dataset): def __init__(self, file_list): self.file_list = file_list def __len__(self): return len(self.file_list) def __getitem__(self, idx): img = read_image(self.file_list[idx]) label = get_label(self.file_list[idx]) return img, labelDataLoader里我最常用的两个参数是shuffle=True和num_workers=4。shuffle在每个 epoch 开始时打乱数据顺序,避免模型学到样本顺序的规律;num_workers控制加载数据的子进程数量,通常设为 CPU 核心数的一半左右。注意注意,在 Windows 系统上,num_workers设成 0(改为多进程时在主进程里加载)反而最稳,否则可能出现子进程报错,这是踩过无数次才得出的经验。
2.5 训练模式与评估模式:一个容易忽略的开关
PyTorch 模型有两种模式:model.train()和model.eval(),切换它会影响 BatchNorm 和 Dropout 等层的行为。BatchNorm 在训练模式下使用当前 batch 的均值和方差做归一化,并更新全局统计量;而评估模式下直接使用训练阶段累计的全局统计量。Dropout 在训练时会随机丢弃部分神经元,评估时则保留所有神经元。
如果不切换模式,最典型的症状是:训练 loss 正常下降,但验证集上指标完全不对,或者相同数据在训练和验证时输出不一样。我在第一次用 ResNet 做迁移学习时就栽过这个跟头,整整查了两天,最后发现是忘了写model.eval()。记住这个最小流程:
model.eval() with torch.no_grad(): total_correct = 0 for data, label in val_loader: outputs = model(data) total_correct += (outputs.argmax(1) == label).sum()torch.no_grad()会关闭梯度计算和追踪,既省显存又能让推理速度更快。推理时千万别让网络计算梯度,这在批量大的时候甚至能快一倍以上。
3. 从零训练一个图像分类模型:完整实操流程
3.1 准备数据:以猫狗分类为例
我拿一个经典的猫狗二分类任务演示。假设你本地有一个data/目录,下面分成train/cat、train/dog、val/cat、val/dog四个子目录。我们先构建一个简洁的 Dataset:
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, Dataset from torchvision import transforms from PIL import Image class CatDogDataset(Dataset): def __init__(self, folder, transform=None): self.samples = [] self.transform = transform for label, name in enumerate(['cat', 'dog']): for img_name in os.listdir(os.path.join(folder, name)): self.samples.append((os.path.join(folder, name, img_name), label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] image = Image.open(path).convert('RGB') if self.transform: image = self.transform(image) return image, label这里有个细节:Image.open(...).convert('RGB')是为了统一通道数。灰度图如果不转成 RGB 会导致后面卷积层输入通道不匹配;PNG 图如果没有convert('RGB'),可能会出现带透明通道(RGBA)的输入,同样会报错。预处理 transform 我建议至少包含Resize((224, 224))、ToTensor()和归一化。
transform_train = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])数据增强里RandomHorizontalFlip是免费午餐,对一般图像分类任务不会降低指标,反而能增加数据多样性,缓解过拟合。归一化的 mean/std 如果不想自己算,可以直接用 ImageNet 统计出来的这个经典值,通用性很强。
3.2 定义网络结构:从简单 CNN 开始
不要一开始就上 ResNet、ViT 这种大模型,先用一个小型 CNN 把整个流程跑通,逻辑没问题之后再换大模型。我写了一个只有三层卷积和一层全连接的迷你网络,参数量很小,CPU 上也能跑:
class SimpleCNN(nn.Module): def __init__(self, num_classes=2): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 28 * 28, 128), nn.ReLU(), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x))注意全连接层输入维度的计算:输入图像是 224×224,经过三次 MaxPool2d(2),空间尺寸变成 224 ÷ 2 ÷ 2 ÷ 2 = 28,所以64 * 28 * 28。这个数字如果不对,跑前向传播时会直接报矩阵乘法维度错误。我的习惯是先把输入 shape 打印出来确认,慢一点但稳妥。
3.3 配置训练超参数:学习率、批次大小、优化器选择
超参数的选择直接决定模型能不能收敛。学习率是最关键的。我一般先用1e-3作为初始值,如果是微调预训练模型,就降到1e-4甚至1e-5。批次大小受显存限制,常规范围是 16~128,但批次太小容易导致梯度噪声大、震荡;批次太大则收敛变慢,且考验显存。
优化器我日常用 Adam 最多,因为它自带自适应学习率,对调参新手很友好。但 Adam 也有坑——在使用预训练模型微调时,如果学习率偏高,容易突然梯度爆炸导致 loss 变成 NaN,我后面会讲如何规避。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3)选择CrossEntropyLoss时有个隐含条件:模型输出的是原始 logits(不需要先经过 softmax),因为该 loss 内部会做log_softmax和NLLLoss。很多新手在模型后面多加一个nn.Softmax()再接CrossEntropyLoss,会得到很奇怪的结果——不能算错,但已经冗余且数值上可能不稳,正确做法是都用nn.CrossEntropyLoss处理 logits。
3.4 训练循环框架代码与解释
下面是一段通用训练循环代码,我在很多项目中反复使用,唯一需要改动的是train_one_epoch里的数据加载方式:
def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss = 0 correct = 0 total = 0 for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += inputs.size(0) return total_loss / total, correct / total核心步骤就是刚才说的五步。注意loss.item()会把 GPU 上的标量同步到 CPU 并转成 Python float,这个过程是必要的,否则 loss 会随着计算图的累积越攒越大,最终导致显存溢出。我见过新手直接把loss累积到 list 里最后再算均值,结果一个 epoch 没跑完就 OOM 了。正确做法是每步就.item()取出数值,断开计算图。
训练主循环:
epochs = 10 for epoch in range(epochs): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer) val_loss, val_acc = evaluate(model, val_loader, criterion) print(f'Epoch {epoch+1}/{epochs} | train_loss {train_loss:.4f} | train_acc {train_acc:.4f} | val_acc {val_acc:.4f}') if val_acc > best_acc: torch.save(model.state_dict(), 'best_model.pth') best_acc = val_acc我习惯每个 epoch 都做一次验证,并把验证集上表现最好的模型保存下来,而不是保留最后一个 epoch 的权重。原因是训练后期模型可能在训练集上继续提升,但验证集已经过拟合,保存验证集最优的那一版更实用。
3.5 评估与保存模型:别只在训练集上自嗨
评估函数和训练函数几乎相同,但必须切换成 eval 模式并关闭梯度追踪:
def evaluate(model, loader, criterion): model.eval() total_loss = 0 correct = 0 total = 0 with torch.no_grad(): for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) total_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += inputs.size(0) return total_loss / total, correct / total保存模型有两种情况:如果只是做推理,直接保存state_dict(也就是参数权重字典)就够了;如果还要在另一个脚本里加载继续训练或者做完整的部署,那就保存整个模型结构定义。大多数情况下torch.save(model.state_dict(), 'model.pth')最轻量,加载时用model.load_state_dict(torch.load('model.pth'))。
提示:加载权重前必须保证当前模型的
state_dict的 key 和保存的文件一致。如果网络结构改了名字,加载会报错,所以不要随意更改nn.Sequential内部的层名。
4. 训练时最容易踩的 6 个坑(含速查表)
4.1 显存不够怎么办
GPU 显存溢出(OOM)是最常见的坑。报错信息里一般会有CUDA out of memory,然后告诉你尝试分配了多少、还剩多少。我的排查顺序是:
先看是不是真的显存不够,还是代码泄漏。用nvidia-smi看占用率,如果程序结束显存还没释放,多半是某些张量被留在计算图里。一个常见原因是把每个 batch 的 loss 累积到 Python list 而不是标量,导致一整轮 epoch 的计算图全部缓存。解决办法:确保每次反向传播后计算图被释放,通常调用optimizer.zero_grad()后,旧的图就会被清掉。
如果确实是 batch 太大,就减小batch_size到当前显存能容纳的最大的值;如果数据本身不小,还可以尝试用torch.utils.checkpoint做激活值重计算,以时间换空间。但新手优先用最简单的方法:batch_size=8或16重新试,先能跑起来再说。
4.2 模型不收敛:学习率与初始化
loss 一直不降,或者降到某个平台就卡住了。有经验的玩家会先怀疑学习率。我觉得 90% 的不收敛问题都是学习率设置不合理。
不同优化器对学习率的容忍度差别很大。SGD 用0.01可能没问题,Adam 用0.01大概率炸。我的建议是:Adam 系列从1e-3起步,SGD 从1e-2起步。如果两个 epoch loss 纹丝不动,先打印一下梯度的范数print(torch.nn.utils.clip_grad_norm_(model.parameters(), 100)),看看梯度是不是小到消失或大到爆炸。梯度很小,就调大学习率;梯度大得离谱,就调小学习率。
还有一种情况是模型本身的初始化就有问题。PyTorch 的nn.Conv2d和nn.Linear都自带合理的初始化策略,正常不会出问题。但如果你的forward里做了奇怪的权重暴露操作,比如直接用nn.Parameter(torch.zeros(...))定义偏置却忘了初始化,代价就是梯度为零、参数长期不更新。建议自定义参数时检查初始化的均值方差,避免全零或全一。
4.3 过拟合的信号与对策
如果训练准确率接近 100%,但验证集准确率明显低一截,这就是过拟合。信号就是"训练和验证的差距越来越大"。对策按优先级排序:
- 增加训练数据量。最朴素但最有效,如果没有新数据,就做更强的数据增强(随机裁剪、色彩抖动、旋转)。
- 增加 Dropout 层或权重衰减(Adam 里的
weight_decay=1e-4就是 L2 正则)。 - 减小模型参数量。我的经验是先用小模型跑通,再决定是否加宽加深,模型提容不是玄学,是在过拟合和欠拟合之间找平衡点。
- 早停(Early Stopping):每轮验证保存最优模型,当验证指标连续 N 个 epoch 没有提升,就停止训练。这个操作能节省大量时间。
过拟合不丢人,深度学习模型本来就是大容量函数逼近器,数据少就会过拟合。关键是你要能识别它,并知道去哪里调。
4.4 数据加载慢导致 GPU 空转
最常见的现象:GPU 利用率在 30%~50% 震荡,而不是接近 100%。你打开资源管理器发现 CPU 核占用高但 GPU 在等待。这种"Eating time"(等待时间)通常是数据预处理瓶颈。
解决方案很直接:调高DataLoader的num_workers。从 4 调到 8 往往立竿见影。但如果你的数据是从机械硬盘或网络盘读取,IO 本身就慢,加再多 worker 也有限,建议先把图像预处理成更小的尺寸或直接缓存成内存映射格式。另一个容易忽略的点:pin_memory=True可以让数据从 CPU 到 GPU 的拷贝更快,对训练吞吐有可感知的提升。
train_loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=8, pin_memory=True)4.5 梯度爆炸与梯度消失
训练过程中出现 loss 变成 NaN,十有八九是梯度爆炸。常见场景是网络层数较深时,连续乘法导致梯度指数级增长,或者交叉熵 loss 中间出现 log(0) 这样的数值问题。
快速解决是用梯度裁剪:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)。在backward()之后、step()之前插入,把梯度范数上限限制在 5,能阻止爆炸。但这只是治标,根本原因是学习率太高或者网络设计不合理。把学习率降到1e-4再观察,多半能缓解。
梯度消失则相反,训练几乎没进展,梯度范数趋近于 0。常见于深层网络 + Sigmoid 激活函数,Sigmoid 在饱和区梯度很小,容易死在局部。换成 ReLU 家族(ReLU、LeakyReLU、SiLU)能显著改善。我现在默认用 ReLU,很少直接用 Sigmoid 做中间层。
4.6 多 GPU 训练与随机种子
如果你有一张以上的 GPU,想把训练加速,最简单的方案是torch.nn.DataParallel,不过它在新版本里越来越边缘化了。我更推荐用torch.nn.parallel.DistributedDataParallel,虽然配置复杂一点,但性能更好、扩展性更强。
import torch.distributed as dist dist.init_process_group('nccl', init_method='tcp://localhost:23456', rank=0, world_size=1) model = torch.nn.parallel.DistributedDataParallel(model.to(device))但如果你只是单卡使用者,这部分可以跳过。一个比多卡更值得注意的问题是随机种子。为了让实验结果可复现,我总是在程序最前面设置:
random.seed(0) np.random.seed(0) torch.manual_seed(0) torch.cuda.manual_seed_all(0) torch.backends.cudnn.deterministic = True注意cudnn.deterministic = True会让部分卷积计算变慢,因为它关闭了非确定性算法。平时做实验可以不设,但当你需要严格对比模型结构时,这套方案必不可少。
| 问题 | 典型症状 | 排查 / 解决方法 |
|---|---|---|
| 显存溢出 | CUDA OOM 或程序崩溃 | batch_size 减小、释放中间张量、用 checkpoint |
| 不收敛 | loss 不降或 NaN | 调低学习率、检查梯度范数、检查数据预处理 |
| 过拟合 | 训练指标远高于验证指标 | 增加数据增强、加 Dropout、早停 |
| 数据瓶颈 | GPU 利用率低 | num_workers 调大、pin_memory=True |
| 梯度爆炸 | loss 变成 NaN | 梯度裁剪、降低学习率 |
| 梯度消失 | loss 卡住不动 | 换 ReLU 系激活、检查初始化 |
| 模式未切换 | 验证结果异常 | 记得model.eval()+torch.no_grad() |
| 梯度未清零 | 训练震荡 | 每个 batch 调optimizer.zero_grad() |
5. 模型训练完还没结束:部署与加速要点
5.1 用 torch.jit 做模型序列化
训练完成后,模型要离开 Python 训练环境去线上推理。PyTorch 提供了 TorchScript,可以把模型转换为一种可被 C++ 调用的静态表示。最常用的方式是torch.jit.script或torch.jit.trace。
trace是追踪执行轨迹生成图,适合模型中不含数据依赖分支的情况;script是真正的编译器,支持if、for等控制流。我个人的经验:首先试trace,如果转换报错或计算结果不对,再换script。
model.eval() scripted_model = torch.jit.script(model.cpu()) scripted_model.save('model_scripted.pt')注意转换前一定要在 CPU 上做,因为 GPU 模型在部署时可能不保证环境里有 GPU。而且script过程对nn.Module的 forward 代码有一定要求:不要使用过多 Python 内置的列表推导式、字典操作,这些未必能被完全追踪。如果想保留部分灵活性,就培训时尽量写纯 tensor 运算逻辑。
5.2 ONNX 导出的注意事项
如果线上服务不是 C++ 或 Python 环境,而是需要介入其他推理框架,ONNX 是通用中间格式。导出其实一行代码:
torch.onnx.export(model, dummy_input, 'model.onnx', input_names=['input'], output_names=['output'])但你大概率会遇到几个问题。第一个是dummy_input的尺寸必须固定,因为 ONNX 默认会把张量形状当作模型输入 shape 的一部分。如果希望输入尺寸可变,要在 export 时指定dynamic_axes:
torch.onnx.export(model, dummy_input, 'model.onnx', dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}})第二个问题是某些算子导出后可能不支持,比如一些特殊的激活函数、Resize 模式在旧版本 ONNX 里容易出幺蛾子。遇到算子导出失败,我的建议是换等价算子组合,或者改用onnx-simplifier做简化,但简化前记得验证输出一致性。导出前后用同一个输入跑一遍比对结果,误差在 1e-5 以内就还算安全。
5.3 量化和剪枝:让模型跑得更快
如果模型直接部署在资源受限的设备,比如手机上,那么量化比剪枝更实用。PyTorch 的量化分为动态量化、静态量化和量化感知训练三种。
动态量化最简单,训练后直接把权重转成 int8,推理时再反量化回来,适合 LSTM 或 Transformer 这类以线性层为主的模型,几乎没有精度损失。静态量化需要校准数据,通过收集激活值的分布来优化缩放系数,精度损失稍大但推理更快。量化感知训练是在训练过程中就模拟量化误差,模型精度最理想但改造成本高。
我的建议是:如果你只是想把模型体积压一压,在 CPU 上用torch.quantization.quantize_dynamic最省事:
quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear, nn.Conv2d}, dtype=torch.qint8 ) torch.save(quantized_model.state_dict(), 'model_quantized.pth')量化后模型大小通常能减到原模型的 1/4,推理速度也明显提升。但注意:量化不是银弹,当模型结构里有大量对数值敏感的操作时(比如 BatchNorm 在量化时表现不佳),需要谨慎验证精度。我做过一个简单的 CNN 分类器,量化后准确率掉了 0.3%,可以接受;但换到一个带注意力机制的小模型,精度直接掉了 2%,最后只能在速度和精度之间妥协,没有做彻底量化。
剪枝这里简单说一句:训练后剪枝跑在 CPU 上通常能带来 30%~50% 的加速,但需要配合稀疏矩阵存储才能发挥最大优势。如果你的部署环境没有特殊优化,剪枝收益不如量化直接,所以我一般先量化,再考虑剪枝。
最后再分享一个个人技巧:无论最终用哪种部署方案,都建议在导出前后做一次黄金样本回归测试。准备 20~50 张有代表性的图片,记录原模型和导出模型的输出 logits,计算平均绝对误差。这不是为了精益求精,而是为了在改动任何一个参数后能快速发现"变了没变、变了多少"。我靠这套方法救回过好几次上线前的危机——你以为只是加了一行torch.jit.script,实际某个算子已经悄悄变了行为。
从第一次接触到今天,我用 PyTorch 完成的项目大概可以分成三个阶段:第一阶段是照猫画虎地跑通教程,第二阶段是无脑换模型调参,第三阶段是把更多时间花在理解机制和数据上。如果你现在正处于第一阶段,不要焦虑,按我上面列的步骤先跑一个完整的图像分类流程,然后把每个 API 的文档翻一遍,你很快会发现 PyTorch 的 API 设计极其一致:需要 GPU 就是.to(device),需要梯度就设置requires_grad,需要保存就state_dict。这些都掌握之后,剩下的就是在具体业务场景里灵活组合的问题了。希望这篇分享能帮你少踩一些我当年踩过的坑。