这几年做深度学习相关项目,接触最多、也最离不开的框架就是PyTorch。不管你是刚入门的算法爱好者,还是已经跑过不少实验的老手,应该都听过它的名字:简洁、直观、调试友好,几乎是研究和工程社区里的默认语言。我最早从另一类静态图框架迁移过来,心里其实没底,等真正写完一个项目从数据处理、模型设计、训练调参到评估保存的完整流程之后,只有一个感受:这框架用起来确实顺畅。
这篇文章想做的,不是把官方文档重新翻译一遍,而是把我个人在PyTorch实战中沉淀下来的东西讲清楚:从核心设计逻辑、张量和自动求导的底层机制,到一套可以直接照抄的图像分类训练流程,再到那些文档里不一定会写、但迟早会踩的坑。适合刚准备入门的同学,也适合已经能跑通一些模型、想系统补一补底层细节的人。读完你至少能回答这几个问题:PyTorch为什么学起来顺?计算图到底是怎么工作的?数据加载怎么写才规范?训练不收敛、显存溢出该往哪个方向查?
1. 整体设计与核心优势拆解
1.1 为什么PyTorch能成为主流选择
几年前做深度学习,框架选择还不像现在这么明确。早年的静态图框架需要你先定义一张完整的计算图,再往图里喂数据,中间想打印一个张量看看值,都要借助特殊的会话接口,调试体验非常难受。PyTorch走的是另一条路:代码一行一行执行,运算结果实时可见,跟写普通Python程序几乎没区别。这种“所见即所得”的动态图风格,决定了它天然适合研究者。
动态图带来的好处怎么强调都不过分。你在forward函数里写出了什么逻辑,实际运行就是什么逻辑,Python的if、for、print、assert都可以直接用。我在实际项目里经常会在模型中间层临时加一行print查看张量的shape,或者用断点停住,交互式地看某个中间结果是不是NaN。这在静态图框架时代基本不敢想。
还有一个现实因素:生态。现在主流论文的官方实现基本都以PyTorch为主,社区里大量预训练模型、推理代码、教程都是围绕它展开的,你遇到问题随手一搜就有答案。对绝大多数人来说,选择生态更活跃的方案,会显著降低学习和排障成本。
要注意的是,动态图也不是没有代价。它灵活,但逐算子执行意味着不少额外开销。不过PyTorch在后来的版本中逐步补上了编译优化路线,在保留动态体验的同时把性能差距拉回来了,这一点下面单独说。
1.2 动态图机制与编译模式的博弈
PyTorch 2.0之后,一个重要变化是引入了编译模式。你只需要在你模型的训练或推理外面套一层torch.compile(model),PyTorch就会尝试把整个计算过程捕集成更高效的执行计划,底层做一些算子融合、内存规划之类的优化。对使用者来说,代码逻辑基本不用改,体验上还是动态图的写法,但训练速度可能提升明显。
这块大致的工作原理是这样的:编译器前端(Dynamo)通过追踪你的模型执行路径,把Python层面的运算抽出来转换成一张图;后端(Inductor)再根据目标硬件生成优化过的代码。因为是追踪真实运行路径,所以前面说的if、for这些Python控制流依然可以在编译前正常执行,只是能在编译时固化的部分被固化了。
我第一次用torch.compile时,最直观的感受是训练前期会有一段编译预热时间。如果你的模型比较小,而数据量也不大,这短暂停顿可能比省下的时间还多,不必迷信“编译一定更快”。项目中模型较大、单次迭代耗时较长时,收益会比较明显。
编译模式的出现,本质上解决了动态图“灵活但偏慢”的历史痛点。不过日常debug阶段,我更建议先用默认模式跑通逻辑,等需要长时间跑大训练任务时再考虑开启编译。
1.3 生态与硬件加速支持
PyTorch的周边组件覆盖了从研究到工程的各个环节。torchvision提供常用数据集、预训练模型和图像变换;torchaudio管音频;Hugging Face生态里的绝大多数模型也都能直接以PyTorch权重运行。分布式训练方面,PyTorch自带DistributedDataParallel和更省显存的FSDP,工程上可以直接跨节点扩展。
硬件支持上,除了传统的GPU,PyTorch对苹果芯片的MPS后端也做了原生支持。也就是说,如果你只有一台Mac笔记本,torch.backends.mps.is_available()为True时,直接用mps作为设备就能做大部分中小规模实验。虽然官方对MPS的成熟度评价比较谨慎,但实际体验下来,跑一些小模型和基础实验完全够用。
在业务落地上,PyTorch模型可以导出成ONNX格式,交给推理引擎部署;也可以直接用TorchScript做生产化封装。再加上量化工具、混合精度训练这些能力,基本覆盖了“研究出结果、工程落地上线”的完整链路。
2. 核心细节解析与实操要点
2.1 张量:动手前先搞懂的数据结构
很多人上来就学模型API,结果一遇到维度对不上、dtype不对、设备不一致就卡住。这些问题的根源都在张量。张量说白了就是多维数组,你可以先把它理解成带GPU加速和自动求导能力的numpy数组。但有几个细节确实需要认真理解。
首先是创建方式。下面这张表是我平常用的比较多的:
| 创建方式 | 说明 | 示例 |
|---|---|---|
| torch.tensor | 从列表或已有数组创建 | torch.tensor([1, 2, 3]) |
| torch.zeros / torch.ones | 生成全0或全1张量 | torch.zeros(2, 3) |
| torch.randn | 标准正态分布随机数 | torch.randn(3, 4) |
| torch.arange | 等差数列 | torch.arange(0, 10, 2) |
| torch.linspace | 等间隔序列 | torch.linspace(0, 1, 5) |
| torch.eye | 单位矩阵 | torch.eye(3) |
创建完一张量,建议先确认三件事:shape、dtype、device。shape决定运算怎么对齐,dtype决定数值精度和内存占用,device决定它跑在CPU还是加速卡上。三个属性只要有一个不一致,就可能报错。
dtype有个很常见的坑:PyTorch在做某些运算时会自动做类型提升,比如整数张量和浮点张量相加会转成浮点,但有些操作又要求类型完全一致,比如卷积层输入和模型参数的dtype一般都要是float32。我在项目里通常会在数据加载处统一用.float()或构建张量时显式指定dtype,避免后面各种隐性转换。
device问题更常出现在初学者代码里。你模型放到了GPU上,输入张量还在CPU,一跑前向就会报device mismatch。解决方式很粗暴:把所有输入、标签、模型都统一调用.to(device)。你在代码开头定义好device,后面统一用这个变量即可。这个习惯能省掉大量无谓报错。
view和reshape也是容易混淆的一对。view返回的是原张量在内存上的一个视图,要求原张量内存连续;reshape在视图不可行时会自动拷贝数据,使用限制更少。当你对张量做transpose或permute后,内存顺序会变化,此时直接view可能会报错,用reshape则不会。不过每次reshape都涉及拷贝时,训练性能会受影响。所以我通常在permute之后会习惯性接.contiguous(),确保后续操作安全且高效。
广播机制也值得一提。两个张量维度不完全一致时,PyTorch会尝试按规则自动扩展。常见场景是给一批数据做归一化:假设你的特征张量shape是(B, C, H, W),需要给每个通道减均值,而均值张量shape是(C,),在减法时PyTorch会自动把(C,)扩展到(B, C, H, W)。用对了广播可以让代码非常清爽,但用错维度时它也会偷偷给你“自动对齐”,结果完全不符合预期,所以遇到维度相关的问题先手动打印形状确认一下。
2.2 自动求导:理解计算图,才能不迷茫
张量上有个requires_grad属性,把它设为True后,所有对这个张量的操作都会被记录在一个计算图里。训练时你的loss调用backward(),PyTorch就沿着这张图反向传播,把梯度计算出来。这是整个框架最核心的设计。
概念上,可以把计算图理解成一张有向无环表:节点是张量或运算,边是依赖关系。你在forward里写加法、卷积、矩阵乘,其实每走一步都在往图上新增节点。只有requires_grad=True的张量会作为叶子节点记录梯度,最终结果保存在张量的.grad里,供优化器使用。
我见过不少人在写训练循环时,忘记调用optimizer.zero_grad()。表面上loss也能下降,但结果往往波动很大,或者偶尔出现奇怪的跳变。原因在于梯度默认是累加的:如果你不清零,上一次batch的梯度会和当前batch的梯度叠加在一起,参数更新方向完全被污染。所以训练循环里那四行代码的顺序是铁律:
optimizer.zero_grad() # 先把梯度清零 outputs = model(inputs) # 前向传播 loss = criterion(outputs, labels) # 计算损失 loss.backward() # 反向传播,计算梯度 optimizer.step() # 更新参数按这个顺序执行,每一步都有清晰意义,别随意调换。我在项目中就把这一套封装成了一个标准模板,所有训练脚本都从这里扩展。
detach和no_grad是另外一对容易混淆的操作。no_grad是一个上下文管理器,在它作用范围内的所有运算都不会被记录到计算图里,也不会保留中间节点,既节省内存又加速。验证集推理、测试集评估通常都放在:
model.eval() with torch.no_grad(): outputs = model(inputs)detach则是把一个张量从计算图中剥离出来,返回的新张量不再有梯度路径。如果你希望某个中间结果作为另一个新分支的输入,又不想让梯度从这个分支穿过,就用detach。两者场景不同,但目的都是“不再参与梯度计算”。
还有一个实用技巧:梯度裁剪。当loss突然变成NaN,或者训练后期loss剧烈震荡,很多时候是梯度爆炸。用torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)可以把梯度的整体范数限制在阈值内,让训练稳定很多。尤其训练循环神经网络或Transformer类模型时,这个操作几乎是标配。
2.3 数据加载的规范写法
数据加载是工程里最容易被忽略、也最影响训练效率的环节。PyTorch给的标准答案是自定义Dataset,再用DataLoader包一层。Dataset承担的事情很纯粹:给定一个索引,返回一个样本。DataLoader则负责批次组织、乱序、多进程并行预取。
自定义Dataset的核心就是三个方法:
from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, file_list, labels): self.file_list = file_list self.labels = labels def __len__(self): return len(self.file_list) def __getitem__(self, idx): image = load_image(self.file_list[idx]) # 读取图片 label = self.labels[idx] return image, label__getitem__里可以做数据增强、归一化等预处理。很多新手会把所有数据处理放在 Dataset外面用循环先跑一遍,然后存成列表再塞给模型。小数据量没问题,数据一大内存直接爆,而且丧失了并行加载的能力。
DataLoader的关键参数里,我建议重点关注这几个:
- batch_size:每次迭代取多少个样本。
- shuffle:训练集必须开,打乱数据避免模型学到顺序。
- num_workers:多进程加载数据的进程数。太小会让GPU空等,太大内存压力上升。
- pin_memory:训练时设为True,能加速CPU到GPU的数据拷贝。
- drop_last:数据集长度不能被batch整除时,丢掉最后不完整的batch。训练时常开,保证每个batch形状一致。
num_workers有个实操经验:并不是越大越好,进程调度和内存复制都有开销。我在单机项目里一般从2到4起跳,观察GPU利用率再往上调。如果你发现GPU利用率和数据加载进程CPU占用率都不高,那就是batch_size太小或数据处理逻辑太慢,先别盲目加num_workers。
3. 实操过程与核心环节实现
3.1 数据准备与预处理
下面用一个图像分类项目作为完整示例。目标是训练一个卷积神经网络,在公开的10类小尺寸彩色图像数据集上做分类。这类数据集尺寸小、类别清晰,适合用来验证整套流程。
数据预处理是第一步。训练集和测试集的预处理通常不一样。训练集需要引入随机增强,让模型见过更多样性的数据,降低过拟合;测试集则用确定的变换,保证评估结果稳定可复现。我常用的做法是用transforms.Compose把多个处理步骤串起来:
import torchvision.transforms as transforms transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.2470, 0.2435, 0.2616)), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean=(0.4914, 0.4822, 0.4465), std=(0.2470, 0.2435, 0.2616)), ])RandomCrop和RandomHorizontalFlip是图像分类里最经典的一组增强。RandomCrop(32, padding=4)的意思是先把原图上下左右各补4像素,再随机裁剪回32x32;RandomHorizontalFlip有概率左右翻转。这两个操作合在一起,等价于让模型看到了同一张图的不同变体,能有效缓解过拟合。
ToTensor做两件事:把PIL图像或numpy数组从HWC维度顺序转成CHW,并把像素值从0到255缩放到0到1区间。Normalize则是逐通道做标准化:(x - mean) / std。这里的mean和std是整个训练集在每个颜色通道上的统计值,需要提前算好。附录里经常直接给出,你不需要每次自己统计。
这里有个经常被忽略的点:测试集里不能加随机增强。我见过有同事把训练集的transform直接用在测试上,结果每次评估同一张图都能得到不同结果,评估精度忽高忽低,排查了大半天。测试集只用确定性的ToTensor和Normalize,这是原则。
然后是加载数据集和封装DataLoader:
import torch import torchvision train_set = torchvision.datasets.CIFAR10( root="./data", train=True, download=True, transform=transform_train) train_loader = torch.utils.data.DataLoader( train_set, batch_size=128, shuffle=True, num_workers=4, pin_memory=True) test_set = torchvision.datasets.CIFAR10( root="./data", train=False, download=True, transform=transform_test) test_loader = torch.utils.data.DataLoader( test_set, batch_size=256, shuffle=False, num_workers=4, pin_memory=True)如果下载慢,更可靠的做法是手动下载数据集文件放到root对应的目录下,torchvision检测到文件存在就不会重复下载。网络不稳定的场景下,手动放置数据的成功率要高很多。
3.2 模型构建:从零搭一个分类网络
模型定义有两种常见写法:继承nn.Module,或者用nn.Sequential直接拼。前者更灵活,适合复杂结构;后者写起来快,适合层与层之间是简单串行的网络。一个推荐做法是在模型主体里用nn.Sequential组织特征提取部分,再在外层用nn.Module完成输入输出逻辑。
下面是一个简单但完整的卷积分类网络:
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), # 32x32 nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 16x16 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 8x8 nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1), # 1x1 ) self.classifier = nn.Linear(256, num_classes) def forward(self, x): x = self.features(x) return self.classifier(x.flatten(1))卷积层输出尺寸的计算公式是H_out = (H_in + 2 * padding - kernel_size) / stride + 1。这里每层卷积都用kernel_size=3、padding=1,stride默认1,所以输出尺寸和输入保持一致,靠池化层逐步缩小。卷积层负责提取局部特征,池化层减少空间尺寸,BatchNorm加速收敛并稳定训练过程。
BatchNorm有个特点需要特别记住:训练时它用当前batch的均值方差做归一化,推理时使用训练阶段累计的全局统计量。这意味着模型在不同状态下行为不同,所以后面训练和评估时要切换model.train()和model.eval()。
最后用AdaptiveAvgPool2d(1)把特征图压到1x1,再接一个全连接分类头。AdaptiveAvgPool的好处是无论前面特征图多大,最后都能输出固定长度的向量,这让网络可以接受不同尺寸的输入。
代码里的ReLU(inplace=True)是PyTorch里的一个常见优化选项,意思是在原输入内存上直接修改,不额外分配新内存,稍微省一点显存。用inplace也不是没有风险,如果计算图中某个操作还需要原始输入的值,在inplace操作后可能出问题。但ReLU这种激活函数本就不需要保留负半轴原始值,所以inplace是安全的。
3.3 训练循环与损失计算
训练循环是整个项目中“套路感”最强的一段。写熟之后基本就是个模板,不同项目只在模型、损失、优化器这几个地方有变化。
import torch.optim as optim device = "cuda" if torch.cuda.is_available() else "cpu" model = SimpleCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=5e-4) num_epochs = 20 for epoch in range(num_epochs): model.train() total_loss = 0.0 for inputs, labels in train_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() print(f"epoch {epoch+1:02d} loss={total_loss / len(train_loader):.4f}") if (epoch + 1) % 5 == 0: model.eval() correct = 0 total = 0 with torch.no_grad(): for inputs, labels in test_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs, dim=1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f" top-1 acc on test set: {100.0 * correct / total:.2f}%")损失函数选择上,多分类问题最常用CrossEntropyLoss。这个损失在PyTorch内部已经做了LogSoftmax和NLLLoss的合并,所以你在模型最后一层不需要再加Softmax,直接把原始logits丢进损失函数即可。如果加了Softmax再进CrossEntropyLoss,数值上容易出现奇怪问题,损失曲线也会不那么稳定。
优化器选择方面,SGD配momentum在很多计算机视觉任务上表现依旧稳定;Adam这一类自适应优化器则更省心,适合快速验证和文本类任务。我的习惯是最初阶段先用Adam快速跑通流程,觉得模型结构和数据处理都没问题了,再换回SGD调细学习率,追求更好的收敛效果。
验证阶段这里有几条规则要严格遵守:先调model.eval(),再包torch.no_grad()。eval是切换模型内部状态,让BatchNorm和Dropout使用推理行为;no_grad是停止计算梯度,避免保存中间变量,内存占用更低、速度更快。少了任何一行都会带来问题:少eval,验证精度会偏低或者不稳定;少no_grad,显存占用会明显偏高。
3.4 模型保存、加载与混合精度
训练结束后第一件事就是保存模型。PyTorch最推荐的保存方式不是整个模型对象,而是state_dict,也就是模型所有可学习参数的字典。这样只保存数据、不保存代码结构,跨环境复用更安全。
torch.save(model.state_dict(), "model_state.pth")加载时先把模型实例化,再load_state_dict:
model = SimpleCNN() model.load_state_dict(torch.load("model_state.pth", map_location="cpu"))我通常在评估阶段用map_location="cpu"先把权重加载到CPU,再显式移到目标设备,这样可以减少设备不匹配的报错。如果你后续要继续训练,建议连优化器状态和当前epoch一起保存。一个比较完整的checkpoint是这样:
checkpoint = { "model": model.state_dict(), "optimizer": optimizer.state_dict(), "epoch": epoch, } torch.save(checkpoint, "checkpoint.pth")加载时再各自恢复:
ckpt = torch.load("checkpoint.pth", map_location="cpu") model.load_state_dict(ckpt["model"]) optimizer.load_state_dict(ckpt["optimizer"])这里有个版本相关的坑:新版PyTorch在torch.load时默认weights_only=True,如果你的checkpoint里除了纯张量还包含自定义对象,加载可能报错。处理办法是显式传入weights_only=False,前提是你确认来源安全。这个默认值在几个版本之间有过调整,所以老代码在不同版本环境下表现可能不一样。
训练加速方面,混合精度是性价比很高的手段。核心思路是前向和反向用FP16计算,降低显存占用、提升速度,但主权重和优化器状态保持在FP32,避免精度损失。标准写法是收集一个scaler,并在训练循环里调整:
scaler = torch.amp.GradScaler("cuda")optimizer.zero_grad() with torch.amp.autocast("cuda"): outputs = model(inputs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()GradScaler的存在是为了防止FP16下梯度数值下溢。它先把loss放大若干倍,再缩放回来后执行优化步骤。这个写法虽然多几行,但实际提升非常可观,特别在显卡较大的场景。
4. 常见问题与排查技巧实录
4.1 训练不收敛与loss异常
训练不收敛是最容易让人心态崩的问题。先看是不是loss出现NaN。NaN的常见原因有这么几类:学习率太高导致梯度震荡到数值溢出,梯度爆炸,数据里含有异常值或NaN标签,以及模型初始化不当。
排查思路是先排除数据。把loss计算前的那一批数据、标签打印出来,看看有没有NaN或极端值。数据没问题再看学习率,一个快速判断方法是把学习率降到当前值的十分之一,如果loss马上变得稳定,说明之前学习率偏大了。
loss不下降但也不是NaN,则是另一类问题。如果loss一直在一个高位横盘,先怀疑学习率太低或者数据标签有问题。标签错乱这种事我遇到过不止一次:数据增强时图片和标签对应关系被搞乱,模型看似在训练,实际上在学一个随机映射,损失当然降不下去。这时候拿几十条样本做个可视化,把图片和标签一起显示出来,往往一眼就能发现问题。
还有一个非常实用的自检手段:用小批量数据先做“过拟合测试”。选几十条训练样本,把数据增强关闭,模型在这个小集合上训练几十轮。如果loss能顺利降到接近0,说明模型、数据管道、训练逻辑都正常,问题出在优化或数据量上;如果连这小集合都拟合不了,那大概率是代码逻辑有Bug。
4.2 显存溢出与性能瓶颈
CUDA out of memory大概是训练里最常见的报错。不要一看到就慌,通常就是某个变量占的显存超了。几个高频原因:batch_size过大、输入图像尺寸过大、模型参数量过大、验证阶段忘了关no_grad导致额外建图、DataLoader的num_workers开得过大把内存吃满。
推荐的快速处理顺序是:先把batch_size减半试一次,再把输入图片尺寸调小,最后考虑混合精度。梯度累积是另一个备选方案。如果你已经把所有能砍的都砍了还是爆显存,可以考虑小batch多次累积梯度,等效出一个大的batch size:
accumulation_steps = 4 for i, (inputs, labels) in enumerate(train_loader): outputs = model(inputs) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()这里把loss除以累积步数,目的是让总梯度量和原来用完整batch训练时保持一致。这个技巧在目标检测这类大模型任务里很常用,值得记下来。
GPU利用率低是另一个常见问题。如果你发现显卡利用率一直不到50%,但CPU占用很高,通常是数据加载跟不上。先增大num_workers试试,再检查预处理里是否有太重、无法并行的操作。数据增强如果太耗时,可以把一部分增强放到GPU上做,或者使用缓存预处理的方案。
4.3 排查速查表
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| loss变为NaN | 学习率过高、梯度爆炸、数据含异常值 | 降低学习率、加梯度裁剪、检查输入数据 |
| loss长时间不降 | 数据标签错乱、学习率过小、模型容量不足 | 可视化样本、调整学习率、增强模型 |
| 训练集acc高、测试集acc低 | 过拟合 | 数据增强、权重衰减、Dropout、早停 |
| 训练精度高,验证精度波动大 | 验证阶段未正确切换eval和no_grad | 补model.eval()和torch.no_grad() |
| shape mismatch报错 | 维度没有对齐,常见于拼接或注意力机制 | 打印每个关键张量的shape逐步排查 |
| device mismatch报错 | 输入和模型不在同一设备 | 统一调用.to(device) |
| 多进程DataLoader卡死 | worker在交互式环境下使用不当 | 交互环境里优先num_workers=0 |
| 加载老模型权重报错 | 结构不一致或者版本兼容问题 | 对比state_dict的key,检查版本 |
这个表对应的每一条我基本都在真实项目里遇到过。最想强调的还是“先看数据”:数据一旦有问题,后面所有环节都像是中了邪,模型再大也白搭。
5. 个人项目中的经验与建议
5.1 先跑通再调优,别一上来就做大模型
我见过不少同学第一次做项目就上一个很大的预训练模型,数据集也直接上全量。结果代码还没跑通,先被显存和训练时长劝退。正确的顺序应该是:用小数据集、小模型、小batch size把整个脚本完整跑通。也就是说,数据加载、训练循环、验证、保存、加载这几个环节全部能工作,再逐步放大数据规模和模型容量。
这样做的意义在于把变量拆开。如果一开始所有环节都堆在最复杂的配置上,出了问题你根本不知道是数据的问题、代码的问题还是模型的问题。从最小配置起步,每加一个新变量验证一步,排查成本会低非常多。
我自己的习惯是任何新项目先写一个single batch的冒烟测试:固定一个batch,训练一步,看loss能不能正常下降。这一步通过了再跑完整训练。很多低级错误都能在这一步暴露。
5.2 固定随机种子与实验记录
深度学习里到处是随机性。数据加载打乱是随机的,权重初始化是随机的,GPU矩阵运算在某些情况下也有不确定部分。为了保证实验结果可复现,固定随机种子是必要条件。
import random import numpy as np import torch def seed_everything(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)要注意的是,即使固定了种子,不同GPU型号、不同cuDNN版本仍可能导致微小差异。所以复现最好控制在同一个环境里做。我还会在代码开头设置torch.backends.cudnn.deterministic = True和torch.backends.cudnn.benchmark = False,换取更稳定的可复现性,代价是部分运算速度略降。
实验记录是我后悔没早做的事情。最开始训练模型只看终端打印的loss和acc,隔几天就忘了哪组参数对应哪个结果。后来我把每次实验的关键配置、最终指标、模型保存路径统一写入一个表格,选参对比时效率高了几倍。别小看这个细节,深度学习项目的迭代核心就是“对比实验”,没有记录就没有对比。
5.3 从单卡到多卡再到部署
单卡训练不满足需求时,PyTorch的标准解法是DistributedDataParallel,配合torchrun命令启动。用法不复杂,但有几个细节要留意:数据加载必须在每个进程里独立设置shuffle,随机种子要在每个进程里分别固定,模型参数需要广播保持同步。刚上手多卡训练时,直接用官方模板改,比自己写要可靠得多。
部署方向上,最常见的是把模型导出为ONNX。导出时注意把模型切到eval模式,输入尺寸固定成你实际推理时的尺寸。如果用torch.save保存的是状态字典,导出前先load_state_dict。再配合推理引擎做加速,这就是一条非常清晰的上线路径。如果想省事,也可以直接用TorchScript打包,在Python和C++环境里都能加载。
我实际踩过的两个坑,一并分享出来:一是把数据增强都放在CPU上串行执行,GPU利用率长期上不来,后来把预处理挪到DataLoader的worker里,训练速度立刻提升;二是测试集也做了随机增强,导致评估指标忽高忽低,改成确定性变换后指标彻底稳定。这两个问题看起来不起眼,但经常要折腾大半天才能定位到原因。
PyTorch是个开放且还在快速演进的生态。核心API一旦掌握,新版本带来的变化大多是锦上添花,不会让你推倒重来。从张量到计算图,从一个小项目到完整训练上线流程,把这些基础打牢,后面再学分布式、编译优化、部署这些进阶内容都会顺很多。