图像分类这个任务,看起来是深度学习里最"基础"的入门项目,但真正动手从零跑通一遍完整流程的人都知道,坑远比想象中多。数据怎么组织、增强怎么配、模型怎么选、训练循环里哪些细节容易翻车、验证集指标为什么和测试集对不上——这些问题在教程里往往一笔带过,实际做的时候却能耗掉一整天。这篇内容面向的是已经了解PyTorch基本语法、想独立完成一个图像分类项目全流程的开发者,我会把从数据准备到模型评估的每个环节拆开讲,重点放在那些文档里不写、但实操中一定会遇到的地方。整套流程以常见的图像分类场景为背景,代码可以直接参考复现。
1. 数据集的目录组织与Dataset类设计
1.1 为什么目录结构值得单独拿出来说
很多人拿到一批图片后的第一反应是写个CSV,把路径和标签一行行列出来,然后用自定义Dataset去读。这种做法在数据量小的时候没问题,但一旦类别多、样本杂,维护成本会急剧上升。PyTorch提供了ImageFolder这个类,它要求数据按类别分文件夹存放,每个子文件夹名就是类别名。这种组织方式的好处是:标签自动从文件夹名映射,不需要额外维护标签文件;新增类别只需要新建文件夹;训练集和验证集可以各自独立成树,互不干扰。
推荐的目录结构是这样的:
dataset/ ├── train/ │ ├── cat/ │ │ ├── 001.jpg │ │ └── ... │ ├── dog/ │ └── bird/ └── val/ ├── cat/ ├── dog/ └── bird/这里有个容易忽略的点:ImageFolder在初始化时会扫描整个目录并建立索引,如果图片数量在几万张以上,首次扫描会有明显延迟。我一般会在数据准备阶段就把图片统一转成较小的尺寸(比如短边缩放到256),既减少IO压力,也避免训练时反复解码大图浪费CPU。
1.2 自定义Dataset的适用场景
ImageFolder不是万能的。当你的标签不是单一类别(比如多标签分类)、或者图片需要从数据库或内存中读取、或者需要做特殊的样本配对时,就得自己写Dataset。自定义Dataset的核心是继承torch.utils.data.Dataset并实现__len__和__getitem__两个方法。__getitem__返回的是一个样本的(image, label)元组,这里的image必须是已经经过transform处理的Tensor。
一个常见的错误是在__getitem__里做太重的操作,比如实时从网络下载图片或者做复杂的预处理。DataLoader会开多个worker并行取数据,如果每个worker都在做重活,GPU反而会饿着等数据。正确的做法是把能离线做的预处理(缩放、格式转换)提前做完,__getitem__里只保留必要的增强操作。
1.3 训练集与验证集的划分策略
如果原始数据只有一个大文件夹,需要自己划分训练集和验证集。我习惯用torch.utils.data.random_split按比例切分,但要注意设置随机种子,否则每次运行划分结果不同,实验无法复现。另一个做法是先在文件系统层面把数据分好,这样更直观,也方便人工检查。
划分比例上,数据量充足时(每类上千张)用8:2或9:1都行;数据量少时(每类几十张)建议用交叉验证,或者至少保证验证集每类都有足够样本,否则验证指标波动会非常大,根本看不出模型好坏。
2. 数据增强的配置逻辑与常见误区
2.1 训练增强与验证增强的区别
数据增强是图像分类里性价比最高的提效手段之一,但很多人会把训练和验证的transform配成一样的,这是个典型错误。训练阶段需要随机性强的增强来提升泛化能力,验证阶段则应该只做确定性的预处理(缩放、中心裁剪、归一化),保证每次评估同一张图得到的结果一致。
训练transform的典型配置:
train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])验证transform则简单得多:
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]) ])注意Normalize里的均值和标准差是ImageNet的统计值。如果你用的是预训练模型,必须用这套参数;如果是从零训练,理论上应该用你自己数据集的统计值,但实践中直接用ImageNet的值差别不大,除非你的数据分布非常特殊(比如医学影像、卫星图)。
2.2 增强强度不是越大越好
RandomResizedCrop的scale参数控制裁剪区域占原图的比例范围。设成(0.08, 1.0)是ImageNet训练时的经典配置,但对于细粒度分类任务(比如区分不同品种的狗),裁剪太狠会把关键特征裁掉,反而伤害性能。我一般从(0.7, 1.0)起步,根据验证集表现再调整。
ColorJitter也是同理。适度的颜色扰动有助于模型忽略颜色偏差,但如果你的任务本身就依赖颜色(比如交通灯识别),过强的颜色增强会让模型学不到东西。这类参数没有万能值,需要结合具体任务试。
还有一个坑是增强操作的顺序。ToTensor必须在所有PIL级别的操作之后,Normalize必须在ToTensor之后。顺序错了轻则报错,重则得到数值范围不对的Tensor,训练时loss直接爆炸。
2.3 用DataLoader把数据管道串起来
DataLoader负责把Dataset包装成可迭代的batch,几个关键参数需要根据实际情况调:
batch_size:受显存限制,一般从32或64起步。太小会导致BatchNorm统计不稳定,太大可能降低泛化。num_workers:取数据用的子进程数。设成CPU核心数的一半到全部之间比较合适,设成0意味着在主进程取数据,会拖慢训练。pin_memory:设为True可以加速CPU到GPU的数据传输,但会占用额外内存。shuffle:训练集必须设为True,验证集设为False。
一个实测经验:当num_workers大于0时,如果Dataset里有任何不可序列化的对象(比如打开的文件句柄),会报pickle错误。解决办法是把这些对象放到__init__里初始化,或者用worker_init_fn在每个worker里单独初始化。
3. 模型选择与迁移学习的实操细节
3.1 从零训练还是用预训练权重
这是每个图像分类项目都要面对的第一个决策。结论很明确:除非你的数据集非常大(每类上万张)且和ImageNet分布差异极大,否则都应该用预训练权重做迁移学习。预训练模型在ImageNet上学到的底层特征(边缘、纹理、颜色块)是通用的,微调能让模型在少量数据上快速收敛。
PyTorch的torchvision.models里提供了大量预训练模型,加载方式很简单:
import torchvision.models as models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)注意新版本PyTorch已经把pretrained=True这个参数废弃了,改用weights参数指定权重版本。如果你看到老教程里写pretrained=True,在新版本上会报警告甚至报错。
3.2 冻结策略与微调层数的选择
迁移学习有两种常见做法:一是冻结骨干网络,只训练最后的分类头;二是整个网络都参与训练,但骨干部分用较小的学习率。前者适合数据量很小(每类几十张)的情况,后者适合数据量中等偏上的情况。
冻结骨干的实现:
for param in model.parameters(): param.requires_grad = False # 替换分类头 num_features = model.fc.in_features model.fc = nn.Linear(num_features, num_classes)这里替换分类头是必须的,因为原模型的输出维度是1000(ImageNet类别数),你的任务类别数几乎不可能正好是1000。替换后新加的层默认requires_grad=True,只有这部分会被优化器更新。
如果选择全网络微调,建议给骨干和分类头设置不同的学习率,骨干用小学习率(比如1e-4),分类头用大学习率(比如1e-3)。这可以通过给优化器传参数组来实现:
optimizer = torch.optim.Adam([ {'params': model.backbone.parameters(), 'lr': 1e-4}, {'params': model.fc.parameters(), 'lr': 1e-3} ])3.3 模型结构的选择依据
ResNet系列是最稳妥的起点,resnet18和resnet50用得最多。数据量小选18,数据量中等选50。如果追求更高的精度且显存充足,可以试EfficientNet或ConvNeXt系列。如果任务对推理速度有要求(比如部署到边缘设备),MobileNet系列是更好的选择。
选模型时不要只看论文里的精度数字,还要考虑输入尺寸、参数量、推理耗时。一个在ImageNet上精度高2个点的模型,如果参数量大10倍、推理慢5倍,在实际项目里未必划算。
4. 训练循环中的关键细节
4.1 损失函数与优化器的搭配
图像分类用交叉熵损失nn.CrossEntropyLoss就够了。注意这个损失函数内部已经包含了softmax操作,所以模型的输出应该是logits(未经过softmax的原始输出),不要在外面再加一层softmax,否则相当于做了两次,梯度会不对。
优化器方面,Adam和SGD是两大主流。Adam收敛快、对学习率不敏感,适合快速实验;SGD配合动量在充分调参后往往能取得更好的最终精度,但需要仔细调学习率和调度策略。我的习惯是先用Adam跑通流程、确认代码没问题,再换SGD精调。
学习率调度器也值得关注。CosineAnnealingLR和StepLR是两种常用策略。Cosine退火在训练后期学习率平滑降到接近0,通常比阶梯式下降效果更好。
4.2 训练循环的标准写法
一个完整的训练epoch包含以下步骤:
model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() epoch_loss = running_loss / total epoch_acc = correct / total几个容易出错的点:optimizer.zero_grad()必须在loss.backward()之前调用,否则梯度会累加;loss.item()取的是当前batch的平均loss,要乘以batch大小才是这个batch的总loss;计算准确率时要用outputs.max(1)取最大logit对应的类别。
4.3 验证阶段的注意事项
验证阶段必须用model.eval()切换到评估模式,这会关闭Dropout和BatchNorm的训练行为。同时要用torch.no_grad()包裹,避免构建计算图浪费显存。
model.eval() val_loss = 0.0 val_correct = 0 val_total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) val_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) val_total += labels.size(0) val_correct += predicted.eq(labels).sum().item()一个常见的疏忽是忘记在验证后切回model.train(),导致下一个epoch的训练在评估模式下进行,BatchNorm不更新统计量,训练效果会大打折扣。
4.4 模型保存与恢复训练
保存模型有两种方式:保存整个模型对象,或者只保存state_dict。推荐后者,因为保存整个模型会把类定义也序列化进去,代码结构一变就加载不了。
torch.save(model.state_dict(), 'best_model.pth')加载时先实例化模型结构,再加载权重:
model = models.resnet50(weights=None) model.fc = nn.Linear(model.fc.in_features, num_classes) model.load_state_dict(torch.load('best_model.pth'))如果要恢复训练(包括优化器状态、epoch数等),需要把这些都保存下来:
checkpoint = { 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_acc': best_acc } torch.save(checkpoint, 'checkpoint.pth')5. 过拟合与欠拟合的排查思路
5.1 从loss曲线判断问题类型
训练过程中最值得盯的就是训练loss和验证loss的走势。如果训练loss持续下降但验证loss开始上升,说明过拟合了;如果两者都居高不下,说明欠拟合或者学习率不对;如果训练loss剧烈震荡,可能是学习率太大或者batch size太小。
我一般会在每个epoch结束后打印训练和验证的loss与准确率,画成曲线看趋势。如果验证准确率连续多个epoch不提升,就该考虑早停或者调整策略了。
5.2 过拟合的应对手段
过拟合在数据量不足时几乎必然出现。应对手段按优先级排列:
- 增加数据增强强度,这是最直接有效的
- 加Dropout层,在全连接层前插入
nn.Dropout(0.5) - 加权重衰减,在优化器里设
weight_decay=1e-4 - 减小模型容量,换更小的骨干网络
- 早停,验证指标不提升就停止训练
这些手段可以组合使用,但不要一次性全加上,否则很难判断哪个起了作用。
5.3 欠拟合的信号与处理
欠拟合的表现是训练准确率本身就不高。原因可能是模型太小、学习率太低、训练轮数不够、或者数据预处理有问题(比如归一化参数用错)。排查时先确认模型能不能在训练集上过拟合一个小batch——如果连几十张图都拟合不了,那肯定是代码有问题,不是模型容量的问题。
6. 评估指标与结果分析
6.1 准确率之外的指标
准确率在类别均衡时够用,但类别不均衡时会产生误导。比如99%的样本是A类,模型全预测A也能拿到99%准确率,但实际毫无价值。这时候要看混淆矩阵、每类的精确率和召回率、以及F1分数。
from sklearn.metrics import classification_report, confusion_matrix all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in test_loader: images = images.to(device) outputs = model(images) _, predicted = outputs.max(1) all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds)) print(confusion_matrix(all_labels, all_preds))混淆矩阵能直观看出模型在哪些类别之间容易混淆,这对后续优化很有指导意义——如果两个类别互相混淆严重,可能需要针对性地增加这两类的区分性特征。
6.2 可视化预测结果
数字指标之外,抽一些样本看看模型的预测结果也很重要。有时候指标看着不错,但实际预测的图片一看就知道模型学偏了(比如学到了背景而不是目标本身)。用matplotlib把图片和预测标签画出来,人工过一遍,往往能发现指标反映不了的问题。
6.3 推理阶段的注意事项
评估完成后,如果要把模型用于实际推理,有几个点需要注意:推理时的预处理必须和验证阶段完全一致,包括resize尺寸、裁剪方式、归一化参数;模型要切到eval模式;如果部署到生产环境,还要考虑batch推理和单张推理的性能差异。
7. 实操中反复踩到的几个坑
7.1 显存溢出与batch size调整
显存溢出是训练中最常见的报错。除了减小batch size,还可以用梯度累积来模拟大batch:每算完几个小batch的梯度后再更新一次参数。另外,如果输入尺寸很大,适当降低分辨率也能省不少显存。
7.2 数据加载成为瓶颈
如果GPU利用率长期低于50%,很可能是数据加载拖了后腿。检查num_workers是否设得太小,或者__getitem__里有没有耗时操作。用time模块在__getitem__里打点,看看单张图的处理时间,超过几毫秒就值得优化了。
7.3 随机种子与复现性
深度学习实验的复现性是个老问题。要尽量保证每次运行结果一致,需要固定多个随机源:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False注意开启deterministic会牺牲一些性能,但换来的是可复现的结果。实验阶段建议开启,最终追求速度时可以关掉。
7.4 学习率找不对导致训练不动
学习率太大loss会震荡甚至变成nan,太小则收敛极慢。一个实用的技巧是先做学习率扫描:从很小的值开始,每个batch指数增大学习率,记录loss变化,找到loss下降最快的区间作为初始学习率。这个技巧在fastai里有现成实现,自己写也不复杂。
整套流程跑下来,最深的体会是:图像分类的难点从来不在模型本身,而在数据管道和训练细节。模型结构网上抄一个就行,但数据怎么组织、增强怎么配、训练循环里哪些地方容易出错,这些才是决定项目成败的关键。我见过太多人卡在数据加载报错或者验证指标不涨上,其实问题往往出在一些很基础但容易被忽略的地方。把上面这些环节都过一遍,基本能覆盖图像分类项目从零到跑通的全过程。