资讯详情

ResNet图像分类实战:残差块原理与PyTorch完整实现

📅 2026/10/17 4:20:55 | 华诺云谱 👁 阅读
ResNet图像分类实战:残差块原理与PyTorch完整实现
1. 为什么图像分类教程都绕不开ResNet我先说个比较直接的结论如果你只想学一个经典的卷积网络架构把图像分类这件事彻底跑通那ResNet是性价比最高的选择没有之一。这个系列写到第二十篇前面的内容已经覆盖了卷积操作、池化、激活函数、批量归一化这些基础组件也手写过简单的CNN分类器。但真正到做图像分类会面临一个很现实的问题网络加深之后训练不动了。你可能会发现层数堆到20层以上训练集上的损失反而降不下去甚至比浅层网络还要差。这不是过拟合因为验证集也在同步变差而是深层网络本身在训练阶段就出现了退化。ResNet的出现就是为了解决这个问题。它用了一个很朴素但极其有效的思路在网络里引入“捷径连接”。通俗地说每一层不再被要求直接学会完整的映射而是只需要拟合输入和输出之间的残差。原来要学一个复杂函数现在只需要学“还差多少”。这个改动让几十层、上百层的网络在训练时误差可以稳定地反向传播不会因为连乘导致梯度消失。在我接触过的各种任务里小到医疗影像的病灶定位大到工业质检的缺陷分类ResNet几乎都能作为骨干网络直接使用。即便现在出现了各种新的注意力机制和Transformer类模型ResNet依然是最稳的落地方案。它参数适中、结构简单、部署友好而且有海量的预训练权重可以拿来微调对动手实践的人来说是最好上手的起点。这篇博文我打算沿着一条完整的算法实现路径来讲先拆解网络结构设计再写残差块和整体模型的代码然后跑通一个真实的训练流程最后把我在实际训练中踩过的坑和排查思路一并整理出来。读完你就能自己动手写一个基于ResNet的图像分类器并且知道出了问题该从哪里下手修。2. 残差结构网络加深的核心密码2.1 深层网络为什么会“退化”先回到一个基础问题。早期的VGG网络做到19层再往上加深效果就不会变好了。理论上说深层网络的表达能力一定不弱于浅层网络因为多出来的层至少可以学到恒等映射。但实际训练中梯度经过多层连乘之后会指数级衰减相当于越靠前的层越学不到东西。我用一个简单的类比来说明想象你让一个团队传递一句话第一个人说完整原话第二个人复述第三个人再复述传到第十个人时已经面目全非了。信息在每一层都经过一次非线性变换误差会不断累积而梯度反向传播时也类似每经过一层就乘上一个小数乘到前面几层时已经趋近于零。ResNet的做法是给这个传递链条加了一个“直达通道”。每个残差块内部输入除了进入卷积层做特征提取之外还会通过一条捷径直接加到输出上。这样一来梯度可以通过捷径直接流向前层哪怕卷积分支的梯度很小前层仍然能收到信息。更深层的网络也就有了被训练好的可能。2.2 BasicBlock与Bottleneck两种残差块ResNet的核心代码并不复杂。看官方论文给出的结构会发现残差块其实只有两种变体BasicBlock和Bottleneck。前者用在ResNet-18、ResNet-34这类较浅的网络里后者用在ResNet-50及以上的深层网络中。BasicBlock的结构是两个3x3卷积卷积之间夹着一个批量归一化层和ReLU激活函数最后通过捷径把输入加过来。代码实现如下import torch import torch.nn as nn class BasicBlock(nn.Module): expansion 1 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.downsample downsample 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(x) out identity out self.relu(out) return out注意这里的细节第一个卷积的stride参数是可变的目的是在分辨率减半时用步长来实现下采样而不是在最后强行池化。第二个卷积的stride固定为1保证输出尺寸不变这样才能和输入做加法。Bottleneck的设计更精巧一些。它用三层卷积替代了两层先是1x1卷积把通道数压缩然后3x3卷积做空间特征提取最后再用1x1卷积把通道数扩展回去。压缩再扩张的目的是减少计算量。假设输入是256个通道直接做两个3x3卷积的计算量会非常大而先压缩到64个通道再扩充回来整体计算量能降一个量级。class Bottleneck(nn.Module): expansion 4 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.conv3 nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels * self.expansion) self.relu nn.ReLU(inplaceTrue) self.downsample downsample 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) out self.relu(out) out self.conv3(out) out self.bn3(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out2.3 捷径连接的两种补偿策略现在看一个容易忽略的细节。当残差块的输入和输出尺寸不一致时捷径上不能直接做加法。这种情况发生在每个Stage开头的第一个残差块因为特征图分辨率减半、通道数翻倍。解决办法是在捷径上加一个1x1卷积同时调整通道数和空间尺寸。我在代码里用downsample模块来实现这个补偿def make_layer(self, block, out_channels, blocks, stride1): downsample None if stride ! 1 or self.in_channels ! out_channels * block.expansion: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels * block.expansion, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels * block.expansion) ) layers [] layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels out_channels * block.expansion for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) return nn.Sequential(*layers)这里有个值得说的点为什么补偿用1x1卷积而不是直接对输入做池化或填充因为1x1卷积可以同时完成通道数调整和空间尺寸调整而且自身也是可学习的能对输入特征做一次线性变换信息损失比直接裁剪填充小得多。batch norm加上的原因也简单捷径分支跨过了两个卷积层数值分布可能有偏移归一化能让两条分支的数值scale比较接近训练会更稳。3. 模型整体组装从残差块到完整网络3.1 网络主干与分类头的结构分工有了残差块组装完整模型只是按顺序拼接。整个ResNet的结构可以看成四个部分最前面的stem负责把原始图像做初步下采样中间四个Stage负责逐步提取高层次特征最后的全局平均池化把特征图压成向量最后接一个全连接层输出类别得分。class ResNet(nn.Module): def __init__(self, block, layers, num_classes1000): super().__init__() self.in_channels 64 self.conv1 nn.Conv2d(3, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(kernel_size3, stride2, padding1) self.layer1 self._make_layer(block, 64, layers[0]) self.layer2 self._make_layer(block, 128, layers[1], stride2) self.layer3 self._make_layer(block, 256, layers[2], stride2) self.layer4 self._make_layer(block, 512, layers[3], stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512 * block.expansion, num_classes) def forward(self, x): x self.conv1(x) x self.bn1(x) x self.relu(x) x self.maxpool(x) x self.layer1(x) x self.layer2(x) x self.layer3(x) x self.layer4(x) x self.avgpool(x) x torch.flatten(x, 1) x self.fc(x) return x第一层用7x7的大卷积核配合stride2直接把224x224的输入图降采样到56x56这是早期卷积网络留下的传统。大卷积核的感受野大能在一个卷积操作内看到更多像素内容适合图像输入尺寸较大时做初步特征提取。后续每个Stage的第一个残差块都会让分辨率减半经过四个Stage之后特征图从56x56逐级降到28、14、7通道数则从64翻倍到512整体呈现一个“空间变小、通道变深”的漏斗形状。3.2 不同版本的ResNet到底差在哪定义模型时只需要传入不同的层数配置就能组装出不同深度的网络。下表可以清楚看出各版本的差异模型版本残差块类型各Stage层数参数量约适用场景ResNet-18BasicBlock[2, 2, 2, 2]1100万小数据集、边缘设备ResNet-34BasicBlock[3, 4, 6, 3]2100万通用分类任务ResNet-50Bottleneck[3, 4, 6, 3]2500万迁移学习首选ResNet-101Bottleneck[3, 4, 23, 3]4400万高精度场景实例化的时候这么写def resnet18(num_classes1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classesnum_classes) def resnet50(num_classes1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classesnum_classes)我个人的建议是如果你刚接触这个系列第一版代码先用ResNet-18跑通因为训练速度快、显存占用小出了问题也好快速迭代。等你把整个流程都熟悉了再换ResNet-50做更大规模的数据实验。上来直接用ResNet-101不是不行但如果数据量不大提升并不明显反而训练时间拉长了好几倍。3.3 用预训练权重做迁移学习实际项目中很少从零开始训练ResNet更常见的做法是加载在ImageNet上预训练好的权重然后针对自己的任务微调。在PyTorch里只需要一行import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_classes 10 model.fc nn.Linear(model.fc.in_features, num_classes)最后一行很关键预训练模型最后的全连接层输出是1000类我们要把它替换成自己的类别数。替换时只改了fc层前面所有卷积层都保留下来因为它们在ImageNet上学到的底层特征——边缘、纹理、形状——对绝大多数视觉任务都有用。微调的时候有两种策略。第一种是特征提取冻结所有卷积层的参数只训练新替换的fc层适合数据集很小的情况。第二种是全量微调让卷积层也参与梯度更新适合数据量中等以上的场景。关于这两种策略的选择我后面会展开讲。4. 完整训练流程从数据准备到模型保存4.1 数据加载与预处理细节图像分类算法的实现数据部分往往是第一个坑。假设你现在有一个自己的图像数据集目录结构按类别分好比如train/cat、train/dog用now的ImageFolder接口配合DataLoader就能完成加载。from torchvision import transforms, datasets from torch.utils.data import DataLoader data_transform { train: transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]), val: transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) } train_dataset datasets.ImageFolder(data/train, transformdata_transform[train]) val_dataset datasets.ImageFolder(data/val, transformdata_transform[val]) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)这里每一行预处理都有讲究。Resize到256再RandomResizedCrop到224是为了让模型看到不同尺度的目标增强尺度不变性。RandomHorizontalFlip是图像分类最常用的数据增强手段等效于把训练集扩大一倍。ColorJitter做颜色扰动适合颜色分布本来就不稳定的数据集。Normalize用的均值和标准差是ImageNet的统计值预训练模型输入层期望的数值分布就是这个所以用预训练权重时必须沿用这一组参数。训练集和验证集的预处理方式刻意做了区别训练集用随机增强验证集只用中心裁剪目的是保证评估时每次看到的是确定性的内容不会因为随机性导致指标波动。4.2 损失函数与优化器配置分类任务最标准的损失函数是交叉熵。PyTorch里直接用CrossEntropyLoss就行它在内部已经做了softmax和one-hot的合并计算不要在外面再加一层softmax否则梯度数值会出错。优化器我通常选AdamW或带动量的SGD。新手更推荐AdamW它对学习率的敏感度低一些调节起来比较省心。用SGD的话学习率、动量、权重衰减这几个参数需要配合着调效果好但费时间。import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr0.001, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50)关于学习率的设定如果是从零训练0.001对AdamW来说是一个比较安全的起点但如果是微调预训练模型我这边的经验是不要一上来就用这么大的学习率因为预训练权重已经处于一个比较好的局部最优附近学习率太大容易一步跑偏破坏掉学好的特征。微调时建议把初始学习率调到0.0001甚至更低。4.3 训练循环与验证循环的标准写法训练循环本身并不复杂但有几件小事不要漏掉。一是训练阶段要调用model.train()这会影响Dropout和BatchNorm的行为二是验证阶段要调用model.eval()并且用torch.no_grad()包住推理禁止梯度计算加快速度同时省显存三是在每个epoch结束时保存一次模型权重而不是训练完之后再一次性保存防止中途崩溃导致前面的训练全部白跑。def train_one_epoch(model, train_loader, optimizer, criterion, 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) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) 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 * 100 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 * 100 return epoch_loss, epoch_acc计算准确率时用torch.max(outputs, 1)取最大得分对应的索引和labels做逐元素比较再求和就是预测正确的样本数。这里有一个细节如果训练集类别不均衡整体准确率并不能反映真实效果需要额外计算每个类别的召回率或macro-F1。我在做工业质检的时候遇到过某个缺陷类别只占5%的情况准确率看起来97%但那个缺陷类别几乎全错所以分类实验一定要单独看混淆矩阵。完整训练的主循环如下device torch.device(cuda if torch.cuda.is_available() else cpu) model resnet18(num_classes10).to(device) best_acc 0.0 for epoch in range(50): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step() print(fEpoch {epoch1:02d} | Train Loss {train_loss:.4f} | Train Acc {train_acc:.2f}% f| Val Loss {val_loss:.4f} | Val Acc {val_acc:.2f}%) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)4.4 冻结骨干网络做特征提取如果你的数据集只有几百张图全量微调很容易过拟合。这时候先冻结骨干网络会更合适for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True optimizer optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr0.001)冻结之后反向传播时骨干网络的参数不会更新只训练最后一层分类头。这其实等价于用主干网络把每张图“编码”成一个固定向量然后在这个向量上做逻辑回归。在小数据场景下这是一个非常有效的基线方案。有一点需要提醒如果用了frozen的BatchNorm层训练时BatchNorm仍然会计算当前batch的均值和方差。如果batch size太小统计量会很不稳定。如果冻结模式下效果不好可以把BatchNorm也保持训练状态只冻结卷积核参数。5. 训练中躲不开的坑与排查实录5.1 显存溢出与Batch Size的权衡训练ResNet最常见的报错是CUDA out of memory。这个问题80%的情况下都出在batch size太大或输入分辨率太大。我自己的经验是在消费级显卡上用ResNet-18224x224的输入batch size设为32占用显存大约6GB左右ResNet-50同样条件下batch size 32大约占8GB。如果你的显卡显存不够有两条路第一把batch size降下来同时为了保持训练的稳定性可以考虑用梯度累积来模拟大batch。第二把输入分辨率从224降到160或128显存占用会大幅下降但精度也会有一定损失。图像分类任务里分辨率和精度强相关因为细小纹理信息在高分辨率下才能被捕捉到。我遇到过一种情况batch size只有16还会爆显存最后发现是DataLoader的num_workers设得太高系统内存不够导致进程崩溃并不是GPU显存的问题。排查时先用nvidia-smi看显存占用再用系统监控看内存占用别把锅全甩给显存。5.2 训练集损失不下降的排查顺序训练一开始loss就在原地打转完全不降新手很容易慌。我按照概率高低给出排查顺序先看学习率。学习率太大损失会震荡甚至直接发散学习率太小损失下降极慢看起来像是不动。用AdamW时如果初始学习率远超数据的合理范围loss可能长时间在同一个量级上徘徊。可以打印一下梯度的范数如果前几层的梯度接近零多半是学习率过小或梯度计算链路出了问题。再看数据归一化。检查输入图像的像素值范围是否与预训练时一致。很多人忘记对训练数据做Normalize或者用的均值方差不对预训练模型看到的数据分布和它见过的完全不一样前期的表现就会非常挣扎。再看标签是否对齐。ImageFolder会按照目录名的字母顺序自动分配类别索引如果你的验证集目录顺序和训练集不一致label就全部错位了模型永远学不对。这个问题看起来低级但在多标签、子目录嵌套的场景下很容易埋雷。5.3 训练曲线异常的识别与应对正常的训练曲线应该是训练损失平滑下降验证损失先降后略微上升或走平。如果你的曲线是训练集表现很好但验证集很差那就是过拟合。最常见的补救手段是增强数据扩充、加大权重衰减系数。如果验证集和训练集都表现很差问题大概率出在网络本身或数据质量上先检查数据有没有错标、模糊或类别重叠。另一个常见的坑是验证集loss不降反升但准确率还在爬升。这种情况一般是因为模型正在对训练集做“自信过头”的预测概率分布变得越来越尖锐交叉熵损失就下不来。但这种情况下准确率通常还是正常的所以不要只看loss准确率才是分类任务最直观的指标。我个人有个习惯把每个epoch的train_loss、train_acc、val_loss、val_acc全部记录到CSV文件里训练结束后再画曲线。这样一旦某个版本的效果异常可以回溯到具体哪个epoch开始恶化快速定位是过拟合还是学习率衰减节奏不对。5.4 使用预训练权重的其他注意事项加载预训练权重时偶尔会遇到strict参数的问题。如果你改了最后一层fc的类别数直接load_state_dict会报key不匹配错误。解决办法是设置strictFalse或者先把权重加载到旧模型上再替换fc层。我遇到过一个隐藏比较深的坑torchvision预训练模型在训练时用了特定像素的标准化方式某些第三方提供的权重可能版本不同加载时虽然不报错但数值分布对不上导致微调后效果比随机初始化还差。所以我的建议是能用官方预训练权重就优先用官方的。如果必须使用自定义权重先跑一个快速评估脚本用一小批验证集数据看看输出特征是否正常确认没问题再进入正式训练。5.5 分类问题的经典易错点记录我把这个系列实操以来踩过的高频问题整理成了一张速查表纯个人经验参考现象常见原因优先处理方式训练loss不降学习率过小或数据未归一化打印梯度范数检查预处理验证集准确率低于随机猜标签对齐错误对比训练/验证集的类别索引表训练很快收敛但验证集差数据增强不足或模型容量过大增强扩充加大weight_decay验证集loss持续上升过拟合减小模型规模或加入DropoutGPU显存溢出batch size过大减小batch size或启用梯度累积加载预训练权重报错fc层类别数不匹配设置strictFalse并替换fc层每轮训练时间异常长num_workers过低或磁盘IO瓶颈增加worker数换SSD数据目录训练过程中我还发现一个容易被忽略的细节BatchNorm和Dropout在训练与验证阶段的行为不同如果代码里忘了在验证阶段切换到eval模式指标会波动得很厉害。这个表现非常隐蔽因为验证准确率时而正常时而异常让人误以为是数据或模型的问题。我的办法是在训练循环里把model.train()和model.eval()显式写好并且每次跑验证前打印一下模型状态不依赖PyTorch默认的环境推断。6. 我的实际体会与小建议这个系列写到这里ResNet的图像分类算法实现已经完整落地了。回头再看整个实现过程有一点我特别想强调不要急着堆入先进技巧先把最基础的残差块写对、把数据预处理和训练循环跑通再去追求准确率的提升。我见过很多初学者代码还没跑通就想去换更复杂的Backbone结果问题出在batch normalization的坑上换了模型一样有问题。如果你是自己动手复现我建议第一版代码先用ResNet-18在公开基准数据集上做实验把整个流程跑通之后再尝试换成ResNet-50对比效果差异。用脚本把实验记录保存下来包括每次的准确率、损失、训练时间和硬件配置这些记录在你后续调参时会变成最宝贵的参考资料。关于迁移学习我的态度是数据量少于一万张时优先考虑加载预训练权重做全量微调学习率用小一点的0.0001数据量大且和ImageNet场景差异明显时再从零训练或者在预训练基础上多训几个周期让高层特征充分适配你的数据分布。最后分享一个我在实际项目中高频使用的小技巧训练结束后别急着交付模型先跑一次测试集按类别输出混淆矩阵找出错误率最高的那个类别单独看一下它的样本。很多时候你会发现不是模型不好而是某些样本本身标注就有问题或者类别边界不清晰。修完这批脏数据准确率可能直接提升一两个点这比换更复杂的模型往往更加高效可靠。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑