接手一个"基于PyTorch的图像分类(ResNet)"项目,看似是经典的入门路线,但真正动手做起来,从环境搭建的坑到训练收敛的调参,再到最后把模型跑起来做推理,每一步都有不少值得记录的地方。这篇文章我打算把整个项目的思考过程和实操链路都梳理一遍,包括为什么选ResNet、环境怎么配、数据怎么处理、训练脚本怎么写,以及我在实际运行中踩过的一些坑。
1. 为什么第一件正经事选ResNet:不只看它经典
很多人学PyTorch,第一个正式项目不是LeNet手写数字识别就是VGG分类CIFAR。我自己的建议是,如果时间只够做一个项目,直接上ResNet。原因很朴素:LeNet太老了,很多现代训练技巧用不上;VGG结构简单但参数爆炸,显存吃不消。ResNet正好卡在一个甜点上——结构有讲究,但又不至于复杂到难以理解,而且它是目前绝大多数视觉任务backbone的事实起点,后续做检测、分割、迁移学习都绕不开它。
先说ResNet解决的到底是什么问题。在ResNet出现之前,大家发现一个奇怪的现象:网络越深,训练集上的错误率反而越高。这不是过拟合,而是纯粹的优化困难——几十层网络堆上去,梯度在反向传播中很容易消失或者爆炸,网络根本训练不动。ResNet的做法用一个很简单的类比就能讲清楚:与其让每一层硬学一个完整的映射,不如让每一层只学"和输入的差异"。
比如你希望网络深层能学到某个理想映射 H(x),传统网络让层直接拟合 H(x);ResNet把结构改成 F(x) + x,即让层去拟合残差 H(x) - x。如果某个阶段已经最优了,那网络只需要把残差学成0,比从零学一个恒等映射容易得多。这个"跳跃连接"(skip connection)不引入额外参数,但给梯度提供了一条高速公路,深层网络也能稳定训练。
实操层面还有两个更直接的理由让我推荐用它起步:
- PyTorch官方torchvision里直接集成了ResNet的预训练权重,ResNet18、ResNet34、ResNet50都一键可下,先跑通推理再从头训练,阶梯感很舒服。
- 这个模型结构非常规整,BasicBlock和Bottleneck两种模块划分清晰,后续哪怕自己改结构做实验,也是在它骨架上做文章。
2. 环境准备:PyTorch安装里最容易翻车的三个细节
这个项目虽然模型是主角,但真正花掉我最多时间的反而是环境搭建。PyTorch安装和普通Python库不一样,它牵扯到CUDA版本和Python版本的匹配问题,热搜词里那一堆"ubuntu安装pytorch""pytorch环境搭建wsl""cuda和pytorch对应"其实都是同一个痛点。我建议无论你是Windows、Linux还是WSL,都先统一走Anaconda路线,虚拟环境隔离省心太多。
2.1 先确认CUDA再决定安装命令
PyTorch的加速依赖NVIDIA显卡驱动和CUDA。注意一个常见误区:你系统里装的显卡驱动版本并不直接等于CUDA版本。PyTorch安装的是CUDA runtime库,它只要求你的显卡驱动版本 >= 某个最低版本即可。
所以你第一步应该是打开命令行,输入nvidia-smi,看右上角的CUDA Version,那个数字才是驱动支持的上限。比如显示CUDA 12.1,那么你安装cu121版本的PyTorch就没问题。如果驱动显示CUDA 11.8,就装cu118版本的。这个对应关系搞错了,最常见的报错就是"PyTorch is not compiled with CUDA enabled",或者运行torch.cuda.is_available()返回False。
2.2 用conda还是pip
我的建议是:Windows下用conda创建环境,然后用pip安装PyTorch。原因是conda的PyTorch频道有时候解析依赖很慢,而且容易把环境里的其他包搞乱。pip安装的PyTorch同样能正常用GPU,两者区别没那么大。
创建环境的常规操作:
conda create -n resnet python=3.9 -y conda activate resnetPython版本我推荐3.9或3.10,PyTorch 2.x对这两个版本的支持最成熟。如果你用的老代码或者CUDA版本很低,建议先到PyTorch官网首页的Get Started页面查一下自己适合的命令。
2.3 装完必须做的一次性验证
装完先别急着写模型,两条命令确认GPU可用:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果输出True和显卡型号,恭喜,环境这块你通关了。如果输出False,八成是三个原因:CUDA版本命令装错了、驱动太老、或者你机器上装了多个Python环境导致torch装到了别的环境里。排查顺序先看torch.__version__里的cu后缀和你的驱动CUDA版本是否匹配。
3. 数据准备:不是所有图像分类都得从零收集数据集
图像分类项目最核心的资源就是数据。如果你有特定需求,比如做森林图像分类、猫狗识别、工业缺陷检测,那就需要自己整理数据集。但作为学习项目,我强烈建议先从CIFAR-10或ImageNet子集入手,别一上来就挑战从零训练一个大数据集分类器。
CIFAR-10是60,000张32x32的彩色小图,分为10个类别,训练集50,000张,测试集10,000张。它的大小非常合适——ResNet18在单卡上训练一个几十轮的版本,普通GPU半小时到几小时能跑完。你完全可以在CIFAR-10上把整个pipeline跑通、把超参调明白,然后再用相同代码去跑自己的数据。
3.1 自定义数据集的两种组织方式
如果你确实想用自己的图片数据做分类,最简单的目录结构是这样:
dataset/ train/ class_a/ img1.jpg img2.jpg class_b/ img1.jpg val/ class_a/ class_b/torchvision的datasets.ImageFolder可以直接吃这种结构,不用手写Dataset类:
from torchvision import datasets, transforms transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_dataset = datasets.ImageFolder('dataset/train', transform=transform) val_dataset = datasets.ImageFolder('dataset/val', transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=4)这里有个很多人没注意的细节:Normalize的mean和std用的是ImageNet的统计值。因为这个项目我们会用到ImageNet预训练权重,所以输入数据的分布必须和预训练时保持一致,否则迁移效果会打折扣。如果你是从零训练,建议用自己数据集的均值方差,但大多数人没必要绕这个弯。
3.2 数据增强:性价比极高的涨点手段
别急着堆模型深度,先看看数据增强。这是整个项目里性价比最高的模块,几个简单的随机操作就能让模型鲁棒性明显提升。我在训练脚本里最常配的增强组合是:
train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), 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,验证集只用Resize和Normalize,不做随机增强——否则验证集每次迭代都在变,你没法判断模型真实好坏。
还有一个特别容易踩的坑:DataLoader的num_workers参数。在Windows上如果设置num_workers > 0,经常会出现死锁或者内存爆炸的情况,需要把代码包在if __name__ == '__main__':里。我第一次在公司Windows机器上跑就卡在这里,后来改num_workers=0或者放到Linux服务器上才顺利解决。
4. 模型搭建:ResNet的两种残差块和PyTorch实现细节
PyTorch的torchvision里已经有现成的ResNet实现,平时直接用torchvision.models.resnet18(pretrained=True)就能拿到。但既然要搞明白原理,我建议至少手写一遍核心模块,再回到官方实现上做对比。
4.1 BasicBlock和Bottleneck
ResNet18和ResNet34用的是BasicBlock,结构是两条3x3卷积串起来,然后和跳跃连接的输入相加:
import torch 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.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) # 输入输出通道不一致时,跳跃连接也要调整 self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity = self.shortcut(x) out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out += identity out = self.relu(out) return out注意几个关键点:卷积层bias=False,因为后面紧跟BatchNorm,卷积的偏置会被BN层抵消,留着反而浪费参数;shortcut通道不匹配时用1x1卷积做投影,步长为2时刚好把空间尺寸减半。
ResNet50以上用的是Bottleneck,先1x1降维、3x3卷积、再1x1升维,计算量小很多,适合更深的网络。用ResNet50做迁移学习时,预训练权重的效果通常比ResNet18更好,但训练和推理开销也大不少。
4.2 预训练模型和自定义类别数的适配
如果你做的是十分类以内的小任务,强烈建议用预训练模型做迁移学习。torchvision里加载预训练权重的标准做法是:
import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_ftrs = model.fc.in_features model.fc = nn.Linear(num_ftrs, num_classes)这里最关键的一步是替换最后一层全连接fc,因为预训练模型是在ImageNet的1000类上训的,你得改成自己数据集的类别数。fc层之前的卷积特征提取部分全部保留,它会提供非常强的通用视觉特征,比如边缘、纹理、形状。就算你的数据和ImageNet差别很大,前面几层学到的底层特征依然能用。
4.3 冻结特征层还是全量微调
这取决于你的数据量。数据量小(每个类别几百张以下),推荐冻结前面的所有层,只训练最后新加的fc层:
for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True这样训练非常快,而且不用担心在少量数据上把预训练特征破坏掉。数据量足够大(每类几千张以上),就放开全量微调,让模型根据你的数据特性做进一步适配,效果上限更高。我自己通常先冻结卷积层跑到验证集准确率不再上升,再解冻最后几个Block做低学习率微调,效果比一上来就全量训练稳定得多。
刚开始学习的时候,最好还是先从训练集和测试集分布差异的角度去理解冻结的意义:预训练模型已经见过了数千万张图片的通用分布,你的任务只是把新数据映射到那套特征分布里边,强行微调反而是舍近求远。
5. 训练流程:Loss、优化器、学习率,以及完整的训练脚本
5.1 损失函数为什么用交叉熵
图像分类是离散标签预测,最合适的损失是交叉熵(CrossEntropyLoss)。PyTorch的nn.CrossEntropyLoss内部把LogSoftmax和NLLLoss合二为一了,所以当你用它的时候,模型的最后一层不需要额外加Softmax,直接输出未归一化的logits就行。这一点很容易搞混——很多新手在最后一层加了Softmax,再用CrossEntropyLoss,结果训练时梯度不稳定或者数值出现问题。
交叉熵可以简单理解为:我们希望模型对正确类别的预测概率尽量接近1,对其他类别的概率尽量接近0,损失函数度量当前预测分布和真实分布的差异。它比MSE在这种离散分类任务上收敛快得多,因为Softmax输出的梯度形式更有利于模型快速调整。
5.2 优化器和学习率的搭配
优化器我推荐SGD的动量版本:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4)现在Adam系列很流行,但在这个项目里SGD + Momentum的表现通常更稳,尤其配合预训练模型做微调时。Adam的优点是自适应学习率、少调参,但泛化性能在不少视觉任务上略逊于精调的SGD。如果你经验不够,建议先固定用SGD,lr从0.01开始,碰到不收敛就降到0.001,大部分情况这个范围都能兜住。
训练过程中最常用的学习率策略是余弦退火(CosineAnnealingLR)或者阶梯下降(StepLR)。余弦退火前段下降平缓、后段接近收敛时精细调优,我现在基本固定用这个:
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)每轮结束后调用scheduler.step()。
Batch size这个问题也值得单独提一句。在显存允许范围内,batch size尽量往大了设,但不要贪心。同样的学习率下,batch size翻倍会导致梯度估计更稳定但收敛步数变多。我常用的区间是32到128,小数据集64就够用。
5.3 完整训练循环
这是我项目里实际在用的训练核心循环,做了适当精简,但关键逻辑都保留了:
def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc def evaluate(model, val_loader, criterion, device): model.eval() running_loss = 0.0 correct = 0 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) running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc这里最值得说的是model.train()和model.eval()的切换。因为网络里有BatchNorm层,它在训练时用当前batch的均值方差做归一化,在评估时用训练阶段统计好的全局均值方差。忘了切model.eval()是新手最容易掉进去的坑,后果是验证时准确率忽高忽低,还找不到原因。
device这块,我用的是一个很简洁的写法:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device)不要忽略model.to(device),很多人模型定义时在CPU上,数据集加载也在CPU上,两边的参数和数据张量在不同设备,一forward就报错或者巨慢。每次训练循环里还要记得images, labels = images.to(device), labels.to(device),数据不搬上GPU,照样报TypeError。
主循环加日志输出,每5轮打印一次:
epochs = 50 best_acc = 0.0 for epoch in range(epochs): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step() print(f"Epoch {epoch+1}/{epochs} | " f"Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | " f"Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth')保存模型的时候保存state_dict而不是整个模型是推荐做法,体积小、可移植性好,加载时只需要事先重建好模型结构再load_state_dict。
5.4 一个常被忽略的参数:weight_decay
在优化器里加weight_decay=1e-4相当于L2正则化,给大权重加惩罚,抑制过拟合。这个参数某种程度上比调dropout更省心。尤其在迁移学习全量微调阶段,如果不加weight_decay,很快就能看到验证集指标开始挣扎,训练集指标却仍然很漂亮——典型的过拟合信号。加了之后,差距会更平滑。
6. 评估与推理:混淆矩阵比准确率更能说明问题
训练完之后,很多人看一眼准确率就收工了。但真正想把这个项目做好,我建议再做两层工作:混淆矩阵分析和单张图片的可视化推理流程。
6.1 混淆矩阵:看清模型到底错在哪
准确率只能告诉你"多少对",混淆矩阵能告诉你"在哪些类别上翻车"。比如在森林图像分类场景里,你很快会发现模型总把"落叶林"和"针叶林"搞混,但"火烧迹地"分得很准。这种信息对后续优化数据集结构很有价值——是不是这两类的训练样本太少?是不是角度、光照特征太相似?
我用sklearn提供的混淆矩阵计算方法,然后配合matplotlib画出来:
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) disp = ConfusionMatrixDisplay(confusion_matrix=cm) disp.plot(cmap='Blues')注意推理阶段同样要model.eval()和torch.no_grad(),前者关掉BN的batch统计,后者避免计算图保存中间变量,省显存而且加速。
6.2 单张图片的推理入口
为了方便,我把单张推理封装成一个简单的函数,后续接API或者做批量测试都很顺手:
from PIL import Image def predict_image(model, image_path, class_names, device): image = Image.open(image_path).convert('RGB') transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) input_tensor = transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): output = model(input_tensor) probs = torch.softmax(output, dim=1) confidence, pred_idx = torch.max(probs, 1) return class_names[pred_idx.item()], confidence.item()有个关键点是PyTorch模型的输入是四维张量[N, C, H, W],单张图片只有三维[C, H, W],必须用unsqueeze(0)在最前面加一维batch维度。我最初写推理代码时漏过这一步,报了一堆维度不匹配的错。
6.3 关于置信度的一个提醒
softmax输出的概率分布不一定代表模型"真的这么有把握"。当输入图像完全不在训练数据分布里时(比如一张空白的纯色图),模型照样会输出某个类别的较高置信度。这是深度模型的常见毛病,解决思路是额外做OOD检测或者设置置信度阈值,低于阈值就返回"不确定"。这个项目阶段不用做得很复杂,但要在心里有这根弦。
7. 模型部署:从PyTorch到ONNX的转换经验
热搜词里有人搜"pytorch转onnx",这确实是这个项目往后走的一个自然需求。训练完的模型要跑到生产环境,通常不会直接在服务上装PyTorch——环境太重量级。ONNX(Open Neural Network Exchange)是主流的中间表示格式,可以转成ONNX Runtime、TensorRT等运行时推理,轻量且高效。
7.1 导出ONNX的基本流程
如果你的输入是动态尺寸,需要提前指定一批固定尺寸:
model.eval() dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, "resnet18.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}, opset_version=13 )这里用dummy_input做了一次假推理,目的就是让PyTorch记录一遍模型结构。dynamic_axes允许batch维度可变,部署时请求多少张图都行。但如果你的场景固定batch=1,可以省略dynamic_axes,转出的模型在某些推理框架下能再优化一些。
7.2 转换之后必须做一致性验证
转换完不是直接上线,先跑一下对比。拿同一张图片分别用PyTorch模型和ONNX Runtime推理,看输出差异有多大。
import onnxruntime as ort ort_session = ort.InferenceSession("resnet18.onnx") ort_inputs = {ort_session.get_inputs()[0].name: input_tensor.numpy()} ort_outputs = ort_session.run(None, ort_inputs) print(torch_output.numpy().flatten()[:5]) print(ort_outputs[0].flatten()[:5])正常情况下两者的输出应该是几乎完全一致的,差几个小数点后几位。如果出现明显差异,优先怀疑BatchNorm层在导出时的行为——ONNX会固化BN的统计参数,如果转换前没有调用model.eval(),导出的模型行为会和在训练模式下一样,推理结果就错乱了。这个问题检查方式很直接:导出前打印model.training,确保是False。
7.3 ONNX Runtime的推理优化感受
ONNX Runtime有个特点,它根据CPU或GPU的硬件指令集做图优化,同样的模型推理速度通常比原生PyTorch在CPU上快不少。我这里没有具体到数字的跑分,因为在不同机器上差异很大,但体感是——同样一个ResNet18,在普通CPU机器上转ONNX之后跑,延迟能压到几十毫秒级别,这已经能支撑不少实时性要求不高的业务场景了。
8. 整个项目做完后的个人体会和复盘
8.1 最浪费时间的坑排序
我把这次项目里踩过、以及带人时常见的问题按消耗时间排个序,给后来人一个预期的心理建设:
- 环境问题:CUDA版本对应错误、conda和pip环境混了、Windows下num_workers死锁。这些加起来可能占整个项目40%的时间。
- 训练不收敛:学习率设太高导致loss震荡、BN层忘了切换model.train()/model.eval()。
- 数据问题:图片没统一resize、Normalize参数用错、类别的目录名错位。
- 模型设计问题:最后一层类别数和数据集不一致。
这些看起来都是小问题,但排查起来一个比一个隐蔽。我自己的习惯是每到一个阶段先在控制台打印几行关键信息——数据加载后验证一下batch的shape和类别数、模型拿到后打印一下结构、训练第一个batch前打印一次loss初始值。这些习惯能省下大量事后debug的时间。
8.2 关于"跑通"和"做好"的差距
跑通一个ResNet图像分类项目,跟着教程走,快的话一个下午就能完成。但从"跑通"到"做好"之间,还有很长的路要磨。比如在CIFAR-10上,ResNet18随便训练就能有75%以上的准确率,但想上到90%以上,你就必须认真做数据增强、精细调学习率、多跑几个epoch看趋势。这个过程才是最值得花时间的部分——它教给你的不是某个API的用法,而是"我的模型距离真实可用还差多少"的工程判断力。
8.3 后续可以扩展的方向
如果你做完这个项目还想继续深入,我建议按下面的顺序尝试:
- 把自己的数据集换成更复杂的场景(比如森林图像分类),增加类别数和样本量,体会数据规模对模型选择的影响。
- 把ResNet18换到ResNet50,对比效果和显存开销,理解模型容量和数据集规模的匹配关系。
- 尝试用相同的代码结构跑一个目标检测模型,比如Faster R-CNN或YOLO,感受从分类到回归+分类联合任务的跳跃。
- 封装一个简单的HTTP推理服务,把ONNX模型接上去,走一遍真正的部署链路。
最后说一个个人很强烈的体会:图像分类是深度学习中"麻雀虽小五脏俱全"的典型任务,它把数据集组织、模型设计、损失函数、优化器、评估、部署这些核心概念全部串了一遍。认真做完,你对PyTorch的理解不会停留在API调用层面,而是建立起了"数据流向模型、梯度回流、评估闭环"的整体直觉。这份直觉,比任何单独的知识点都值钱。