我最早接触PyTorch的时候还是一个刚转了方向的小白,那时候框架圈子还在混战,TensorFlow的静态图虽然性能好,但对新手来说调试起来太不友好了。后来试了一下PyTorch的动态图,一下子就觉得“对了,写代码就该是这样的感觉”。再后来我拿它做了几个项目,从简单的图像分类Demo到需要自定义数据加载、混合精度训练、模型部署的完整流程,前后踩了不少坑,也积累了很多可以复用的经验。这篇博文就当作一个个人项目总结,把我用PyTorch从零开始搭一套图像分类训练流程的完整路径写下来,包括框架选型时的思考、数据加载的细节、训练循环的正确写法、常见坑的排查方法,以及最后怎么把模型高效部署出去。适合刚刚接触PyTorch但已经对Python和深度学习基础概念有了解的人,也适合那些已经跑通过一两个例子、但想要更系统地把训练流程做扎实的朋友。
1. 为什么我最终选了PyTorch:框架选型背后的真实考量
1.1 从需求出发:先搞清楚你要做的是什么
在动手写代码之前,我习惯先问自己:这个项目的核心需求是什么?我的算力条件是什么?部署目标是哪里?这些问题的答案,直接决定了框架选型的方向。我当时做的是一个图像分类任务,数据量不大不小,约几万张图片,需要灵活的预处理逻辑,后期要导出到服务端推理。相比之下,PyTorch在学术研究和快速迭代上的优势非常明显,它让我的注意力始终集中在模型本身,而不是被静态图的编译流程绊住。
另外一个重要的考量因素是生态。PyTorch生态里对预训练模型、数据增强库、分布式训练的覆盖面非常广,而且大部分最新出来的论文都会优先放PyTorch代码,这意味着我可以拿到很多现成的资源,不必重复造轮子。如果你要做的项目是强工程化、高并发在线推理,那可能TensorFlow的 Serving方案会更成熟;但如果你需要快速验证想法、频繁改动模型结构、处理复杂的自定义数据流程,PyTorch的灵活性和可调试性优势是压倒性的。
1.2 动态图机制为什么更适合实际开发
动态图意味着每一次前向传播都会真正执行Python代码,反向传播的梯度计算通过自动求导机制完成。我个人在调试中的最大体感是:哪一步出错了,直接打印这个张量的shape和值,马上就能定位问题,不需要像静态图那样去脑内编译一张计算图。这一点在复杂数据流和自定义损失函数时尤其重要。
我举一个具体的例子。有一次我在写一个需要根据样本属性动态调整损失权重的任务,传统静态图实现起来要么用条件控制的特殊算子,要么得把整个图重写一遍。而PyTorch里就是简单的Python条件判断,直接根据当前batch的标签分布去修改损失函数的计算方式,代码可读性非常高。这种灵活性不是空谈,是实实在在的研发效率提升。当然动态图也有开销,每个op都要经过Python解释器,纯算力密集型的“炼丹”场景下吞吐量可能略低于优化后的静态图方案,但配合合适的算子融合方式,这个差距在大多数项目中是可以接受的。
1.3 团队协作与代码维护视角
框架选型不只是一个人的偏好,还关乎团队协作。我当时和几个同学一起推进这个项目,大家的背景不完全一致,水平也参差不齐。PyTorch代码的可读性和Python风格让新成员上手成本明显降低,遇到的瓶颈多集中在模型本身,而不是框架的特殊语法。我们当时的代码库可以直接从论文代码进行修改,不需要做底层的图构建逻辑调整,这对团队开发进度来说是很大的助力。
从长期维护的角度看,PyTorch每年更新的节奏很稳定,官方文档和论坛社区的问题记录都非常详尽,遇到问题搜索一下基本都有答案。虽然偶尔会有API变动带来的迁移成本,但相比其他框架动不动就需要重写模型定义的情况,PyTorch的兼容性做得还是相当好的。现在很多工业级项目也开始使用PyTorch作为主框架,配合OnnxRuntime等工具做推理优化,所以即便你是面向工程落地去学习,这也不是一条走不通的路。
2. 数据准备与加载实操:从原始图片到高效训练的必经之路
2.1 数据集整理与目录结构的设定
很多新手拿到图片数据后第一反应就是直接写个循环去读文件,这样不是不行,但一旦数据量大起来,读写效率、缓存管理、断点续训都会变成问题。我的习惯是先用合理的目录结构把原始数据组织好,比如train/类别A/xxx.jpg、val/类别A/yyy.jpg这样的形式,然后用torchvision.datasets.ImageFolder直接读取。这个类会自动根据子目录名生成类别索引,在返回图片数据的同时返回对应的标签,省去了自己维护映射表的工作。
如果你的数据本身就是类似CIFAR、ImageNet那样的标准格式,直接用官方接口即可。但对于随机命名、散落在不同文件夹、或者存在脏数据的场景,我更推荐自己写一个Dataset子类,在__getitem__里做精细控制。比如有些图片可能是损坏的,读取时会报错,这时候可以在代码里加一个异常捕获,返回同batch中的另一张可用图,而不要让整个训练流程因为一张坏图崩溃。
from torch.utils.data import Dataset from PIL import Image import os class CustomImageDataset(Dataset): def __init__(self, img_dir, transform=None): self.img_dir = img_dir self.img_paths = [os.path.join(img_dir, fname) for fname in os.listdir(img_dir)] self.transform = transform def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img_path = self.img_paths[idx] try: image = Image.open(img_path).convert("RGB") except Exception: # 坏图处理:默认取第一张图兜底 image = Image.open(self.img_paths[0]).convert("RGB") if self.transform: image = self.transform(image) return image, idx2.2 DataLoader参数设置:batch_size、num_workers与pin_memory
DataLoader的几个参数如果不理解原理,很容易凭感觉乱设,导致GPU利用率上不去。我见过不少人训练时GPU利用率只有百分之三四十,数据加载瓶颈占了很大因素。num_workers表示用于数据加载的子进程数量,这个值并不是越大越好。太小,CPU来不及把数据送进GPU;太大,进程切换的开销反而会影响主进程训练。
以我常用的单机单卡配置为例,CPU有16个物理核心,我会把num_workers设成4x8区间内去测试,观察GPU利用率的变化。如果批量大小是64,图像分辨率是224x224,一张图解码加增强大约耗时几毫秒,一个worker进程大概能维持在每秒数百张的处理速度。你需要保证所有worker的总吞吐量大于GPU的消费速度,这样才能让GPU一直处于计算状态。
pin_memory=True是我必开的一个选项。它的作用是把数据加载到页锁定内存中,这样GPU拷贝数据时可以使用直接内存访问,速度比常规内存拷贝要快不少。当你用nvidia-smi看到GPU显存用了不少,但利用率仍然忽高忽低时,优先排查数据管道的这些细节,往往比盲目调模型结构更有效。
2.3 数据增强策略与归一化细节
数据增强不能无脑堆,尤其是和任务强相关的增强更要想清楚。我的通用策略是:先做RandomResizedCrop和RandomHorizontalFlip这两个基础操作,几乎适用于大多数图像分类任务;然后根据数据集特点决定要不要加ColorJitter、RandomRotation等。这里有个非常容易踩的坑:很多在ImageNet上预训练的模型,它们归一化所用的均值和标准差是固定的,分别是[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225]。如果你用TorchVision下载了预训练权重,但自己预处理数据时没有做同样的归一化,结果就是模型收敛慢,甚至无法收敛。
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])测试集和验证集不应当使用随机增强,只做Resize和CenterCrop,这是为了保证评估结果的稳定性和可复现性。对于训练集,增强强度也需要根据数据量进行调整,数据量少的时候增强可以稍微激进一些,但幅度过大的增强会让模型学到一些和真实场景不符的纹理伪影,适得其反。
2.4 缓存加速与小数据集的经验之谈
如果你的数据集不太大,比如几千张图片,那么每次迭代都进行磁盘I/O就太浪费了。我尝试过一个简单的优化方案:在内存足够的情况下,把整个数据集预读到内存里,训练时直接查字典,省去图片解码的开销。实际操作上就是使用lmdb或者直接把所有图片转成numpy数组放进内存。这样做能明显降低训练初期的卡顿感,但要注意不要占用过多内存导致机器swap,反而拖慢整体速度。
对于中等规模的数据集,还可以用torchdata提供的流式数据管道,或者直接使用WebDataset格式进行动态读取。这种方式把数据打包成tar文件,并且支持流式解压,可以避免大量小文件导致的inode开销。如果你经常遇到数据读取卡顿,可以试试这个思路。
3. 模型搭建与训练循环:把每一步都写成可控的代码
3.1 用nn.Module搭建模型的正确姿势
PyTorch的模型搭建可以从零开始,也可以基于预训练模型进行微调。我的建议是尽量用nn.Module子类来组织模型结构,这样你的forward逻辑、中间层输出和权重管理都会清晰很多。不要在forward函数里堆一大堆独立的nn.Conv2d,而是把相同的结构块抽出来定义成子模块,这样一个是代码简洁,另一个是便于后续修改模型深度或宽度。
import torch.nn as nn class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = None if stride != 1 or in_channels != out_channels: self.downsample = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity = x out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) if self.downsample is not None: identity = self.downsample(identity) out += identity return self.relu(out)为什么要在Block里定义downsample而不是在forward里临时判断?因为这样可以让模型结构在初始化阶段就确定好,方便参数初始化和后续的状态管理。一个容易忽略的点是:定义了nn.BatchNorm2d后,如果模型包含可学习参数,你必须调用.train()和.eval()来切换BN层的状态,否则模型在验证时统计均值方差用的是当前batch的统计量,结果会非常不稳定。很多新手在验证集上结果飘忽不定,排查半天发现是没有切到eval模式。
3.2 迁移学习到底怎么选冻结层
用ImageNet预训练权重进行微调,是大多数图像分类项目的默认做法。但“微调”并不是简单地把所有权重都换成预训练权重然后继续训练。你需要根据新数据集和原数据集之间的差距来决策:如果你的新数据集和ImageNet内容非常接近,比如也是自然图像分类,只是类别数不同,那可以冻结前面大部分卷积层,只训练最后几层和新增的分类头;如果你的新数据非常特殊,比如是医学影像或者卫星图,那可能需要从头微调整个网络,或者只保留前几层的浅层特征。
我在实际项目中常用的策略是:把backbone的requires_grad设为False,只训练分类头和最后一两个Block。这样训练速度快,而且不容易在小数据集上过拟合。当训练稳定后,再解冻所有层,用很小的学习率进行全局微调。这种二阶段训练策略在实际工程中非常好用。
# 示例:冻结部分层 for name, param in model.named_parameters(): if "layer4" in name or "fc" in name: param.requires_grad = True else: param.requires_grad = False3.3 优化器、损失函数与学习率调度
用PyTorch训练,optimizer的选择直接影响收敛质量。我的经验是:Adam适合快速试错,SGD+momentum适合最终精调。如果你是新项目,先用Adam设一个1e-3学习率跑通流程,看loss能否稳定下降;如果模型结构已经定型,想刷精度,换成SGD,动量为0.9,学习率从1e-2起步,配合CosineAnnealing或StepLR做衰减。
损失函数方面,图像分类一般用nn.CrossEntropyLoss,它内部已经包含了Softmax和交叉熵的计算,不需要在模型输出前额外加Softmax。这个细节非常关键,因为如果你把Softmax加在模型里,再用CrossEntropyLoss,会在数值上出现二次softmax的问题,虽然结果通常不会差太多,但模型的logits输出会被破坏,不利于后续的置信度分析和模型蒸馏。
学习率调度器的使用也要注意。用torch.optim.lr_scheduler可以很方便地实现学习率衰减。我个人偏爱CosineAnnealingLR,它能够让学习率在训练过程中平滑下降,比Step那样突变的效果更稳定。如果使用了调度器,记得在每个epoch结束时调用scheduler.step(),而在每个step里调用optimizer.step(),这两个千万不能混。
3.4 标准的训练循环长什么样
一个健壮的训练循环,绝不能只是“前向-反向-更新”三步就完事。你需要把日志打印、梯度裁剪、模型保存、学习率更新这些全部整合进去。
for epoch in range(start_epoch, epochs): model.train() for batch_idx, (images, labels) in enumerate(train_loader): images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() if batch_idx % log_interval == 0: print(f"Epoch [{epoch}/{epochs}] Batch [{batch_idx}/{len(train_loader)}] Loss: {loss.item():.4f}") # 每个epoch结束后做验证 validate(model, val_loader, criterion, device) save_checkpoint(model, optimizer, scheduler, epoch, best_acc) scheduler.step()你可能会问为什么要加梯度裁剪。主要是为了防止个别batch的梯度异常大导致loss爆炸,尤其在训练初期或者使用大学习率时,梯度范数失控很常见。clip_grad_norm_不是必须的,但加上它以后训练会稳定很多。
模型保存也很有讲究。不要每个epoch都全量保存,这样硬盘很快就满了。我一般只保存最近一次的checkpoint和当前最佳模型。checkpoint里除了模型参数,还要保存optimizer的state_dict、scheduler的state_dict、epoch数、最佳精度等元信息。这样即便训练中断,也能恢复原状态继续跑,不会前功尽弃。
4. 调试与性能优化:那些让你怀疑人生的瞬间
4.1 Loss不下降时的排查套路
训练遇到loss不下降,第一反应不是去调模型结构,而是检查数据。我总结了一个排查顺序:先看数据标签对不对,再看数据增强有没有做对,然后看学习率大小是否合适,最后才怀疑模型结构。有一次我训练一个二分类模型,loss一直在0.693附近打转,怎么调都下不去。最后打出来一批样本看了看,发现是数据增强里的RandomResizedCrop把很多图片的主体裁掉了,模型看到的几乎都是背景。调整增强参数后,loss立刻开始下降。
还有一种常见情况是标签错位。比如我们写Dataset时返回了错误的索引,或者做了shuffle但标签没有对应跟过去,这会导致loss忽高忽低,永远收敛不了。遇到这种情况,最简单的办法是在训练前跑一个batch的样本,把图片和标签可视化检查一遍。提前花十分钟做这一步,能省下后面几天的排查时间。
4.2 GPU显存不足与OOM的实战对策
OOM几乎是每个PyTorch用户都会遇到的问题。首先要学会用torch.cuda.memory_summary()查看显存占用情况,看看到底是哪一层占用了最多显存。如果是激活值过大,可以考虑减小batch_size或者降低输入分辨率。如果只有某一层占用极高,可以尝试使用激活检查点技术,用计算换显存。
# 启用激活检查点:每层前向不保存所有激活,反向时重新计算 from torch.utils.checkpoint import checkpoint在模型不变的前提下,梯度累积也是一个绕开OOM的常用方案。比如你想用batch_size为32,但显存只能放下8,那就每8张图更新一次梯度,累积4次再执行optimizer.step()。注意要做梯度累积时,不能在每次backward后就立即step,而是累积了足够的梯度之后再更新。实现上可以这样:循环里先loss.backward(),当达到累积步数后再optimizer.step()并optimizer.zero_grad()。
4.3 混合精度训练:提速并且不损失精度
从PyTorch 1.6开始,官方提供了完整支持的自动混合精度训练,通过torch.cuda.amp可以进行半精度和单精度之间的自动切换。我的经验是:在支持Tensor Core的GPU上,混合精度训练大约能带来1.5到2倍的训练速度提升,而且显存占用明显下降。关键是它的默认策略已经很智能,它会自动把卷积和矩阵乘法放到半精度上执行,同时把损失函数、归一化等对精度敏感的操作保留在单精度。
from torch.cuda.amp import GradScaler, autocast scaler = GradScaler() for images, labels in train_loader: 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()混合精度训练还有一个隐藏的好处:可以使用更大的batch size,因为半精度张量占显存只有单精度的一半。这在有限显存下非常实用。需要留意的是,如果模型里有自定义的层操作,有些算子可能在半精度下数值不稳,这时候可以利用autocast的局部禁用机制,或者手动把某个层的输入转换回float32。
4.4 超参数搜索:怎么找到最合适的学习率
手动去试学习率效率很低,我建议用学习率预热配合范围测试的方法来快速找到合理区间。做法很简单:从一个很小的学习率开始,每隔几个step增大一点,同时记录每个step的loss值。当你把学习率和loss画出来,会看到一个明显的低谷区间,那个区间就是合适的学习率范围。
虽然PyTorch官方没有现成的学习率范围测试实现,但你可以自己写一个循环,在训练前动态修改optimizer的学习率。另外在训练时,前几个epoch使用线性预热可以有效避免模型在初始大步长下震荡。我常用的组合是:前5个epoch学习率从0线性增加到目标值,然后使用余弦退火衰减。
def warmup_lr_scheduler(optimizer, warmup_epochs, target_lr, current_epoch): if current_epoch < warmup_epochs: lr = target_lr * (current_epoch + 1) / warmup_epochs for param_group in optimizer.param_groups: param_group["lr"] = lr4.5 多GPU训练的注意事项
如果手头有多张卡,PyTorch的DataParallel和DistributedDataParallel是两种选择。我的建议是,除非只是临时测试几小时,否则直接用DistributedDataParallel。DataParallel在单进程内把每个batch拆到多张卡上,但主卡的负载会明显偏高,且只能用于单机多卡;DistributedDataParallel则每个进程负责一张卡,通信效率更高,也支持多机扩展。
用DDP时要注意几个细节。数据加载需要使用DistributedSampler来保证每个进程拿到的数据互不重叠。另外,在训练循环的每个epoch开始时,要调用sampler.set_epoch(epoch)来重新打乱数据,否则每个epoch的shuffle顺序都是一样的,会影响模型泛化。如果你的模型里用了BN,在多卡环境下BN统计量通信也需要额外考虑,必要时改用SyncBatchNorm。
4.6 断点续训:再也不用担心跑了一半断电
训练几十个小时是常态,一旦中途中断全部重来会非常痛苦。我在保存checkpoint时,除了常规的模型参数,还会保存一个rng_state,用于恢复PyTorch、NumPy和Python的随机数生成器状态,这样能最大程度保证续训后的数据增强顺序和之前一致。
torch.save({ "epoch": epoch, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "scheduler_state_dict": scheduler.state_dict(), "best_acc": best_acc, "rng_state": torch.get_rng_state(), "np_rng_state": np.random.get_state(), }, checkpoint_path)恢复时,创建模型和优化器后依次load这些state_dict,然后从保存的epoch继续循环即可。遇到过的问题是,不同版本的PyTorch保存的state_dict偶尔会出现key不匹配的情况,这时可以用strict=False加载,手动检查缺失key,或者考虑用ONNX导出来避免版本兼容性问题。
5. 模型评估、导出与部署经验
5.1 验证集评估与混淆矩阵分析
模型训练的最终目的是在真实数据上表现良好,所以验证集评估不能只看一个准确率数字。我习惯在每个epoch结束后不仅计算accuracy,还打印出各类别的precision、recall和F1。如果某个类别严重不平衡,光看accuracy会掩盖问题。这时可以用sklearn的classification_report来生成报表,或者用ConfusionMatrixDisplay画出混淆矩阵,直观看到模型把哪些类别混在了一起。
有一次我在一个多类别任务中发现整体准确率到了90%,但某一小类的recall只有40%,就是这个类别的样本在训练集中占比太少。我用过采样和类平衡损失函数去做干预,效果立竿见影。所以验证阶段不要偷懒,多角度看指标,才能指导后续调优方向。
5.2 模型导出为ONNX与TorchScript的实战对比
训练完的模型要想部署到服务端,直接用PyTorch的Python模型效率不高,而且对运行环境要求苛刻。我通常选择导出为ONNX格式,再用OnnxRuntime做推理。ONNX导出最重要的一个坑是:如果你的模型里有动态尺寸输入,比如可以接收不同分辨率的图片,需要在导出时指定动态轴。
dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}, opset_version=11 )这里特别要注意的是,torch.onnx.export默认是按照当前输入张量的shape来trace模型的,如果模型里有依赖于输入shape的条件分支,你导出时用的dummy_input只能覆盖其中一种情况,这在部署时会导致错误。解决方法是尽量保证模型forward逻辑和shape无关,或者分别导出不同分辨率的版本。
TorchScript则是PyTorch原生支持的部署方式,优点是保留了动态图的一些特性,但兼容性不如ONNX广泛。我的策略是:如果目标只在自己的Python服务里跑,用TorchScript最简单;如果目标是部署到C++环境或者需要跨框架调用,优先ONNX。
5.3 推理性能优化与批处理策略
部署时,单张图推理的速度往往不是最关键的,吞吐量才是。减少耗时的一个有效方式是动态批处理:服务端收到请求后,把一定时间窗口内的请求拼成一个batch一起推理。这需要模型支持动态batch维度,因此导出时要给batch轴设置dynamic_axes。
另一个优化方向是量化。PyTorch提供了静态量化、动态量化和量化感知训练三种方案。如果只是做CPU部署,动态量化是最简单的:把模型权重从float32转成int8或float16,推理速度能提高不少,准确率下降通常在1%以内。如果对准确率要求极高,可以考虑量化感知训练。
quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear, nn.Conv2d}, dtype=torch.qint8 )要注意的是,BN层在量化时可能会引入额外的误差,因此量化前建议先fuse一些层,比如把Conv2d和BN合并成一体。虽然PyTorch提供了一些fuse接口,但效果因模型而异,最好在具体任务上进行测试。
5.4 从训练到部署的完整示例
我这里给出一个非常简化的部署示例流程:训练好模型后导出ONNX,然后加载到OnnxRuntime,对单张图片做预处理、推理和后处理。
import cv2 import numpy as np import onnxruntime as ort session = ort.InferenceSession("model.onnx") input_name = session.get_inputs()[0].name image = cv2.imread("test.jpg") image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image = cv2.resize(image, (224, 224)) image = image.astype(np.float32) / 255.0 image = (image - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) image = np.transpose(image, (2, 0, 1))[None] outputs = session.run(None, {input_name: image}) pred_id = np.argmax(outputs[0], axis=1)[0] print(pred_id)整个流程下来你会发现,部署环节的代码量并不大,真正花时间的反而是模型格式转换和精度对齐。如果你在导出后测试发现推理结果和PyTorch里不一致,先检查归一化参数是否一致,再检查预处理时有没有做同样的数据增强。
6. 常见问题速查与一些压箱底的经验
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| Loss不下降 | 学习率太高或太低,数据预处理错误,标签错位 | 先可视化一批数据,确认标签正确;再做学习率范围测试 |
| GPU利用率低 | DataLoader的num_workers太小、数据增强太慢、每次读取都做磁盘I/O | 增大num_workers、开启pin_memory、考虑缓存数据到内存 |
| 显存OOM | batch_size过大、输入分辨率过高、激活值占用过多 | 减小batch、降低分辨率、使用梯度累积、开启混合精度 |
| 验证集精度飘忽 | 模型没有切换到eval模式、BN统计量混乱、数据增强不一致 | 在验证前调用model.eval(),验证集固定使用同一套预处理 |
| 推理结果和训练时不一致 | 预处理不一致、动态shape导出遗漏、量化误差过大 | 导出时固定输入尺寸或在dynamic_axes中标注,量化后进行精度验证 |
| 训练速度慢 | 数据读取慢、CPU与GPU同步开销大、启用了不必要的梯度计算 | 检查DataLoader各参数,优化数据管道,尝试混合精度训练 |
最后分享一个小技巧,是我在多次项目里验证有效的习惯:训练开始前,先跑一个极小的subset(比如16张图),用很小的迭代次数验证整个数据管道、模型和优化器是通的;再把学习率调大,故意让模型快速过拟合这16张图,如果loss能够降到很小,说明反向传播和更新逻辑没问题。这个做法可以让你在开始完整训练前就过滤掉八成以上的低级错误。我刚入门时没这个意识,结果经常在跑了大半天之后才发现是数据管道的问题,白白浪费了一整天。
PyTorch给我的感觉不是一个冷冰冰的框架,它更像一个可以随时拆解的工具箱。你理解得越深,使用起来就越得心应手。从最初照着别人的代码改参数,到现在能独立设计数据管道、调训练策略、优化部署性能,整个过程踩了很多坑,但也积累了很多实实在在的经验。希望这篇总结能帮你少走一些弯路,把重心放在真正重要的模型设计上。后面我还会继续折腾更多有意思的方向,到时候再来分享新的体会。