1. 从零搞懂ResNet为什么它成了图像分类的标配做图像分类项目绕不开的一个网络就是ResNet。不管你是打比赛、做毕设还是在公司里落地一个识别系统ResNet几乎都是默认的起点。我自己这几年经手的图像分类任务从工业质检到医疗影像辅助筛查十有八九第一个跑通的baseline就是ResNet-50。原因很简单结构清晰、预训练权重好找、迁移学习效果好、社区资源丰富踩坑了也容易搜到答案。但很多人对ResNet的理解停留在“残差连接”这四个字上真要问为什么残差能解决退化问题、BasicBlock和Bottleneck到底怎么选、迁移学习时该冻结哪些层就说不太清楚了。这篇内容我打算把ResNet的结构从头拆一遍再结合迁移学习的实操把我在实际项目里积累的经验和踩过的坑都倒出来。适合已经能跑通简单CNN、想进一步理解ResNet并把它用到自己任务里的朋友也适合需要做迁移学习但不确定怎么下手的同学。核心关键词我先摆出来ResNet网络结构、残差连接、BasicBlock、Bottleneck、迁移学习、预训练权重、冻结层、微调。这些词后面都会反复出现你读完应该能对每一个都有自己的判断。2. ResNet网络结构详解2.1 退化问题深层网络为什么反而更差在ResNet出现之前大家的直觉是网络越深、表达能力越强、效果应该越好。但实际训练中发现当网络层数增加到一定程度准确率不升反降。这不是过拟合因为训练误差本身也变大了。这个问题被称为退化问题Degradation Problem。退化问题的本质不是梯度消失而是深层网络难以优化。理论上如果一个20层的网络已经能拟合目标函数那么一个56层的网络至少不应该比它差——因为多出来的36层只要学成恒等映射identity mapping输出就应该和20层网络一样。但实际训练中让这些多余的层学成恒等映射非常困难随机初始化的权重很难恰好收敛到那个状态。ResNet的解决思路很直接既然让网络学恒等映射难那我就把恒等映射做成默认路径。具体做法是引入残差连接Skip Connection让某一层的输出变成 F(x) x其中 x 是输入直接跳过来的。这样网络只需要学残差 F(x) H(x) - x当需要恒等映射时把 F(x) 学成0就行了比直接学 H(x) x 容易得多。这里有个常见误解很多人以为残差连接是为了解决梯度消失。梯度消失确实是深层网络的问题之一但ResNet论文里明确说的是解决退化问题。残差连接顺带缓解了梯度消失但这不是它的主要设计动机。2.2 残差块的两副面孔BasicBlock与BottleneckResNet里用到的残差块有两种分别对应不同深度的网络。BasicBlock用在ResNet-18和ResNet-34里结构是两个3x3卷积每个卷积后面接BN和ReLU最后把输入加到输出上再过ReLU。它的特点是结构简单参数量相对少适合浅层网络。Bottleneck用在ResNet-50、ResNet-101、ResNet-152里结构是1x1卷积降维、3x3卷积处理、1x1卷积升维。这个设计的目的很明确减少计算量。以ResNet-50为例输入通道256先用1x1卷积降到643x3卷积在64通道上做再用1x1卷积升回256。如果直接用两个3x3卷积在256通道上做参数量会是Bottleneck的很多倍。我用一个表格把两种结构的关键参数对比一下对比项BasicBlockBottleneck适用网络ResNet-18/34ResNet-50/101/152卷积层数2层3x3 3x33层1x1 3x3 1x1中间通道变化不变先降4倍再升回参数量以256通道为例约59万约7万计算量较高较低设计目的结构简洁减少计算量选择哪个块主要看你的任务复杂度和算力预算。如果只是做简单的分类任务ResNet-18或34足够如果数据量大、类别多ResNet-50是更稳妥的选择。2.3 下采样与通道匹配残差连接怎么加残差连接要求 F(x) 和 x 的维度一致才能相加。但网络里会有下采样操作特征图尺寸减半、通道数翻倍这时候 x 和 F(x) 的维度就对不上了。ResNet的处理方式是在跳跃连接上加一个1x1卷积把 x 的通道数调整到和 F(x) 一致同时用步长为2的卷积做下采样。具体来说每个stage的第一个残差块会做下采样跳跃连接上的1x1卷积步长设为2主路径上的第一个卷积步长也设为2。这样两边输出的特征图尺寸和通道数都匹配可以正常相加。后续的残差块不做下采样跳跃连接就是恒等映射直接相加。这个细节在实际改网络的时候很容易忽略。如果你自己魔改ResNet把某个stage的通道数改了但忘了改跳跃连接的卷积训练时就会报维度不匹配的错误。2.4 整体架构从输入到分类输出ResNet的整体流程可以分成几个阶段预处理层一个7x7卷积步长2输出通道64后面接BN、ReLU和3x3最大池化步长2。这一层把输入图像的尺寸快速降下来减少后续计算量。四个Stage每个Stage由若干个残差块组成Stage之间的特征图尺寸减半、通道数翻倍。ResNet-50的四个Stage分别有3、4、6、3个Bottleneck块。全局平均池化把每个通道的特征图取平均得到一个向量。全连接层输出类别数对应的分数。以ResNet-50为例输入224x224的图像经过预处理后变成56x56四个Stage后依次变成56、28、14、7最后全局平均池化得到2048维向量全连接层输出1000类ImageNet。实际项目中输入尺寸不一定是224。如果输入是32x32的小图比如CIFAR直接用标准ResNet会下采样太狠7x7卷积和最大池化会把信息压得太厉害。这时候通常会把第一层改成3x3卷积、步长1去掉最大池化或者用专门为小图设计的变体。3. 迁移学习站在预训练权重的肩膀上3.1 迁移学习到底迁移了什么迁移学习的核心思想是在大规模数据集如ImageNet上训练好的网络已经学到了通用的视觉特征——浅层学边缘、纹理、颜色中层学局部形状深层学语义概念。这些特征对大多数图像任务都有用不需要从零开始学。具体到操作上迁移学习通常分两种模式特征提取把预训练网络当作固定的特征提取器冻结所有卷积层只训练最后的全连接层。适合目标数据集小、和ImageNet差异不大的场景。微调解冻部分或全部卷积层用较小的学习率一起训练。适合目标数据集较大、或者和ImageNet差异较大的场景。我自己的经验是数据量少于几千张时先做特征提取看看效果如果不够再逐步解冻后面的层做微调。一上来就全网络微调小数据集上很容易过拟合。3.2 加载预训练权重的正确姿势PyTorch里加载预训练权重很简单但有几个细节要注意。import torchvision.models as models import torch.nn as nn # 加载预训练ResNet-50 model models.resnet50(pretrainedTrue) # 替换最后的全连接层 num_classes 10 # 假设你的任务有10类 model.fc nn.Linear(model.fc.in_features, num_classes)这里model.fc.in_features是2048ResNet-50全局平均池化后的维度。替换全连接层后新层的参数是随机初始化的需要训练。如果你用的是旧版本的PyTorchpretrainedTrue可能会提示弃用新写法是from torchvision.models import resnet50, ResNet50_Weights model resnet50(weightsResNet50_Weights.IMAGENET1K_V2)注意预训练权重下载需要网络通畅如果下载失败可以手动下载权重文件放到~/.cache/torch/hub/checkpoints/目录下。权重文件的名字要和代码里期望的一致否则还是会重新下载。3.3 冻结层的策略冻哪些、解冻哪些冻结层的操作在PyTorch里很直接# 冻结所有参数 for param in model.parameters(): param.requires_grad False # 替换全连接层新层默认requires_gradTrue model.fc nn.Linear(model.fc.in_features, num_classes)这样只有model.fc的参数会被更新。如果你想解冻后面的某些层可以这样# 解冻layer4 for param in model.layer4.parameters(): param.requires_grad True冻结策略没有标准答案我一般按这个顺序试先只训练全连接层跑几个epoch看验证集准确率。如果欠拟合训练和验证准确率都低解冻layer4一起训练。如果还是欠拟合继续往前解冻layer3、layer2。如果过拟合训练高验证低增加数据增强或正则化而不是解冻更多层。有个细节BatchNorm层在冻结时要注意。即使你冻结了卷积层的参数BN层的running_mean和running_var仍然会在前向传播时更新。如果你希望完全冻结需要把BN层设为eval模式def set_bn_eval(module): if isinstance(module, nn.BatchNorm2d): module.eval() model.apply(set_bn_eval)不过在实际操作中让BN层继续更新统计量通常也没问题甚至可能更好因为它能适应新数据集的分布。3.4 学习率设置微调时的关键参数微调时学习率要比从头训练小一个数量级左右。从头训练常用0.1或0.01微调时我一般用0.001或0.0001。如果解冻了多层可以给不同层设置不同的学习率浅层用更小的学习率深层用稍大的。optimizer torch.optim.SGD([ {params: model.layer1.parameters(), lr: 1e-5}, {params: model.layer2.parameters(), lr: 1e-5}, {params: model.layer3.parameters(), lr: 1e-4}, {params: model.layer4.parameters(), lr: 1e-4}, {params: model.fc.parameters(), lr: 1e-3}, ], momentum0.9, weight_decay1e-4)这种分层学习率的设置在实际项目中很常见尤其是当你解冻了大部分网络的时候。浅层特征更通用不需要大改深层特征更任务相关需要调整得多一些。4. 完整实操从数据准备到模型训练4.1 数据准备与增强策略假设我们要做一个10类花卉分类任务数据集有几千张图片。数据增强是必须的尤其是小数据集。from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.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]) ])归一化用的均值和标准差是ImageNet的统计值因为预训练权重是在ImageNet上训练的输入分布要匹配。如果你用自己的数据集统计值反而可能破坏预训练特征。数据增强的强度要根据数据集大小调整。数据量少的时候可以激进一些数据量大的时候适度即可。我见过有人在小数据集上用了太强的增强结果模型学不到有效特征训练准确率一直上不去。4.2 训练循环与验证训练循环的框架很标准但有几个细节值得注意。import torch from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.fc.parameters(), lr1e-3, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20) for epoch in range(20): model.train() 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() scheduler.step() # 验证 model.eval() 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) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch1}, Val Acc: {correct/total:.4f})几个实操要点model.train() 和 model.eval() 必须切换。训练时BN用batch统计量验证时用running统计量Dropout训练时开启验证时关闭。忘了切换会导致验证结果不稳定。验证时用 torch.no_grad()减少显存占用加快速度。学习率调度CosineAnnealingLR在微调时效果不错也可以试试StepLR或ReduceLROnPlateau。4.3 模型保存与加载保存模型时推荐保存state_dict而不是整个模型这样加载时更灵活。# 保存 torch.save(model.state_dict(), resnet50_flowers.pth) # 加载 model models.resnet50(pretrainedFalse) model.fc nn.Linear(model.fc.in_features, 10) model.load_state_dict(torch.load(resnet50_flowers.pth)) model model.to(device)如果保存整个模型torch.save(model, ...)加载时需要保证类定义一致换环境容易出问题。state_dict的方式更稳妥。如果你在训练时用了DataParallel或DistributedDataParallel保存的state_dict键名会多一个module.前缀。加载到单卡模型时需要去掉这个前缀或者用model.module.state_dict()保存。5. 常见问题与排查技巧实录5.1 训练不收敛或准确率上不去这是最常见的问题原因可能有很多。我整理了一个排查表现象可能原因排查方法训练loss不降学习率太大或太小试几个数量级的学习率训练loss降但验证不降过拟合增加数据增强、加正则化训练和验证都低欠拟合解冻更多层、增大模型准确率震荡严重batch size太小增大batch size或调小学习率准确率卡在随机水平数据标签有问题检查数据加载和标签对应我踩过的一个坑数据增强里的Normalize用了自己数据集的均值和标准差结果预训练特征被破坏训练loss一直很高。改回ImageNet的统计值后正常了。另一个坑冻结层的时候忘了把模型设为train模式BN层没有更新导致验证准确率很低。后来在训练循环里加了model.train()但BN层仍然保持eval问题解决。5.2 显存不够怎么办ResNet-50在224x224输入、batch size 32时大约需要6-8GB显存。如果显存不够可以减小batch size但不要小于8否则BN统计量不准用混合精度训练AMP用梯度累积模拟大batch用更小的模型ResNet-18或34混合精度训练在PyTorch里很简单from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for images, labels in train_loader: optimizer.zero_grad() with autocast(): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度能省30%-50%显存速度也有提升但要注意有些操作在FP16下可能不稳定遇到NaN时检查一下loss scale。5.3 迁移学习效果不如从头训练这种情况不常见但确实会遇到。可能的原因目标数据集和ImageNet差异太大比如医学影像、遥感图像预训练特征可能不太适用。这时候可以尝试用领域内的预训练权重或者从头训练。冻结层太多只训练全连接层可能不够需要解冻更多层。学习率不合适微调时学习率太大把预训练特征破坏了。试试更小的学习率。数据预处理不一致预训练时用的归一化参数和你的不一致导致输入分布偏移。我遇到过一次做工业缺陷检测图像是灰度图直接套用ImageNet预训练的ResNet效果很差。后来把第一层卷积改成单通道并且用灰度图统计值做归一化效果才上来。5.4 推理速度优化训练完之后推理速度也是实际项目中要考虑的。几个常用手段模型剪枝去掉不重要的通道减小模型体积。量化把FP32转成INT8速度提升明显精度损失可控。ONNX导出用ONNX Runtime推理通常比PyTorch快。TensorRTNVIDIA平台上的推理加速效果最好但配置复杂。# 导出ONNX dummy_input torch.randn(1, 3, 224, 224).to(device) torch.onnx.export(model, dummy_input, resnet50.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}, output: {0: batch_size}})导出ONNX时注意opset版本不同版本的算子支持不一样。我一般用opset 11或12兼容性比较好。6. 一些个人经验和建议ResNet虽然经典但不是万能的。如果你的任务对精度要求极高可以试试EfficientNet、ConvNeXt这些更新的架构。如果对速度要求高MobileNet、ShuffleNet更合适。ResNet的优势在于均衡精度不错、速度可接受、迁移学习方便、社区资源多。迁移学习也不是银弹。如果目标数据集足够大比如几十万张从头训练可能效果更好。我做过一个对比实验10万张以上的数据集从头训练和微调的最终精度差不多但微调收敛更快。最后分享一个调参的小技巧微调时先用较小的学习率跑几个epoch观察验证集准确率的变化。如果准确率稳步上升说明学习率合适如果震荡或下降调小学习率如果上升太慢调大学习率。这个简单的观察能帮你快速找到合适的学习率范围。另外别忘了保存训练过程中的最佳模型而不是最后一个epoch的模型。验证集准确率最高的那个checkpoint往往比最后一个更好用。