十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ResNet18残差结构解析与PyTorch实战:从原理到部署

ResNet18残差结构解析与PyTorch实战:从原理到部署 简介面向图像识别与深度学习入门阶段的开发者这份资源提供 ResNet18 残差网络的核心实现与结构说明。压缩包内共 3 个文件包含模型代码和两张网络结构示意图整体仅 318KB轻量便携便于快速下载与阅读。已有 2655 人学习浏览。代码基于 PyTorch 框架编写通过残差块中的卷积层、批量归一化与 ReLU 激活函数的组合让网络能够有效缓解梯度消失问题结构图则直观展示了每个残差块的连接方式、输入输出相加操作以及必要时使用的 1×1 卷积层。与 ResNet34、ResNet50、ResNet101 相比ResNet18 层数更少参数量和计算开销更低适合嵌入式或移动端等资源受限场景读者既可对照图示理解残差学习的基本原理也可直接利用代码进行模型实例化、训练、验证与测试是学习经典 CNN 架构和快速搭建基准模型的实用材料。1. ResNet18为什么是深度学习入门的必修课如果你问一个搞了五年视觉算法的人“初学者先看哪个网络”得到的答案大概率不是VGG也不是Transformer而是ResNet18。原因很矛盾它是个过时的结构但它的残差思想至今统治着CNN的设计底线。相比ResNet50、ResNet101ResNet18只有11.7M参数在单卡上跑得动、改得动、拆得开是唯一一个能让你把“梯度流”和“感受野”这两个概念同时看清楚的网络。反直觉的地方在于更深并不总是更好。论文里ResNet18在ImageNet上top-1错误率是30.24%而ResNet34是26.69%但如果你用不佳的初始化直接硬叠34层不带残差准确率反而掉到28%以下。这就是退化问题在实战里的样子。ResNet18的“浅”恰好让它成为研究残差机制的最小可行样本。这篇文章给两类人看一是刚接触端到端训练的新手需要一份能跑通、能解释的训练代码二是在工程里做baseline迁移的老手关心ResNet18在特征提取、部署量化、参数量权衡上的边界在哪。下面按“结构拆解 → 训练配置 → 迁移复用 → 部署验证”这条线展开代码全部基于PyTorch 1.13以上写法。2. ResNet18的残差结构拆解与PyTorch复现2.1 从退化现象看残差块的必然性在残差思想出现之前网络加深的主要障碍不是过拟合而是训练损失下不去。梯度在反向传播中逐层相乘一旦某一层饱和信号就断掉了更微妙的是即便用了BatchNorm和ReLU深层网络在收敛后仍然出现比浅层网络更高的误差。数学上可以用输入输出的映射来解释假设网络要逼近目标函数H(x)如果没有残差结构每一层都在学完整的非线性变换F(x)堆叠之后自由度很大但优化困难。残差机制把目标重写成H(x) F(x) x。这里x是输入F(x)是卷积BNReLU学到的残差。如果最优解就是恒等映射网络只需要让F(x)趋向0就够了比在多层非线性里逼近恒等要简单得多。ResNet18全称是“18层权重层残差卷积网络”这18层包含17个卷积层和1个全连接层。它把输入切成四个阶段每个阶段堆叠不同数量的残差块完成从高分辨率低通道到低分辨率高通道的过渡。2.2 用PyTorch复现BasicBlock的最小实现ResNet18用的是BasicBlock也就是两个连续的3×3卷积组成一个残差块区别于ResNet50的Bottleneck1×1 3×3 1×1。下面一段代码是ResNet18里最核心的积木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参数是让残差块在阶段边界降采样的关键。默认conv1的stride等于1时输出尺寸和输入一致shortcut直接相加当stride为2时主路径分辨率减半此时downsample用1×1卷积将x的通道对齐到out_channels并同步降采样。biasFalse是因为BN层自带可学习的偏移卷积如果再保留bias会产生冗余且影响权重初始化效果。2.3 组装完整的ResNet18网络拿到BasicBlock之后ResNet18就是按层数表堆叠四次“重复残差块”的工程class ResNet18(nn.Module): def __init__(self, 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(64, blocks2, stride1) self.layer2 self._make_layer(128, blocks2, stride2) self.layer3 self._make_layer(256, blocks2, stride2) self.layer4 self._make_layer(512, blocks2, stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(512, num_classes) for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) def _make_layer(self, out_channels, blocks, stride): downsample None if stride ! 1 or self.in_channels ! out_channels: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(out_channels) ) layers [] layers.append(BasicBlock(self.in_channels, out_channels, stride, downsample)) self.in_channels out_channels for _ in range(1, blocks): layers.append(BasicBlock(out_channels, out_channels)) return nn.Sequential(*layers) 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 model ResNet18(num_classes10) print(model)参数说明藏在层数表里layer1到layer4的输出通道依次是64、128、256、512每层两个BasicBlock正是ResNet18的“18”。上面代码里的初始化用了kaiming初始化modefan_out让梯度更均匀地回传到所有输出通道AdaptiveAvgPool2d((1,1))把任意输入尺寸压缩成1×1这也是ResNet能在224×224或32×32上通用的原因。2.4 ResNet18与ResNet50在选型上的边界指标ResNet18ResNet34ResNet50权重层数183450瓶颈块无BasicBlock无有Bottleneck参数总量约11.7M约21.8M约25.6MImageNet top-130.24%26.69%22.85%单次前向推理相对耗时1.0x1.8x2.2x从表格能看到一个关键事实ResNet50的参数量只是ResNet18的两倍左右但效果提升明显这和Bottleneck将计算集中到3×3卷积上有关。工程上常见的选择标准是如果目标设备内存低于512MB或者延迟要求小于10ms用ResNet18如果追求精度且训练资源允许优先ResNet50。ResNet18真正不可替代的价值是特征提取速度快、部署侵入性小以及作为对比实验中最稳定的下界。3. 用PyTorch配置ResNet18并跑通CIFAR训练3.1 准备数据加载器与数据增强策略CIFAR-10是验证ResNet18最快的数据集单卡GTX 3090上每个epoch大约30秒。训练集50000张、测试集10000张、总共10个类别。图片本身是32×32ResNet18的原始设计输入是224×224所以需要重新调整结构细节——把第一层7×7步长2的卷积替换成3×3步长1并去掉最开始的MaxPool层否则32×32的输入经过下采样后直接损失大量空间信息。import torch import torchvision import torchvision.transforms as transforms transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) trainset torchvision.datasets.CIFAR10( root./data, trainTrue, downloadTrue, transformtransform_train) testset torchvision.datasets.CIFAR10( root./data, trainFalse, downloadTrue, transformtransform_test) trainloader torch.utils.data.DataLoader( trainset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue) testloader torch.utils.data.DataLoader( testset, batch_size256, shuffleFalse, num_workers4, pin_memoryTrue)逻辑说明数据增强只用了RandomCrop和水平翻转这是轻量级CNN训练的标准组合。CIFAR数据集的均值和方差需要提前算好上面对应的数值是公开数据集的标准统计量如果你换成自定义数据集一定要重新统计自己的mean和std否则BatchNorm在初始阶段会收到分布偏移的数据导致收敛变慢。这里还有三个容易被忽视的细节num_workers在Linux上可以取CPU核心数Windows上建议设为0避免DataLoader卡死pin_memoryTrue配合GPU训练能从内存拷贝上省几毫秒测试集不要开shuffle否则自己看准确率曲线的时候会无意中把验证顺序打乱影响排查。3.2 训练循环与学习率调度RusNet18在CIFAR-10上最稳定的配置是SGD 动量0.9 重量衰减5e-4。我一般不用Adam训练纯CNN分类器因为Adam对权重衰减的处理和SGD不同得到的稀疏性和泛化性都不如SGD调出来的效果。学习率采用余弦退火或者固定的StepLR在160个epoch内分段指数下降。import torch.optim as optim from torch.optim import lr_scheduler device torch.device(cuda if torch.cuda.is_available() else cpu) model ResNet18(num_classes10).to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) scheduler lr_scheduler.CosineAnnealingLR(optimizer, T_max200) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() running_loss 0.0 correct 0 total 0 for inputs, labels in loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return running_loss / len(loader), 100.0 * correct / total for epoch in range(200): train_loss, train_acc train_one_epoch( model, trainloader, optimizer, criterion, device) scheduler.step() if epoch % 10 0: print(fEpoch {epoch}: loss{train_loss:.4f}, acc{train_acc:.2f}%)批大小128对应BN表现最稳定studies表明batch小于32时BatchNorm的均值和方差噪声增大小于8时甚至会拖垮模型。余弦退火的T_max200意思是200个epoch内学习率从初始值平滑降到接近0这会比每隔60个epoch下降0.1倍的做法高出0.5到1个点。如果显存不够把batch降成64同时把lr按线性缩放规则调成0.05别直接用0.1。3.3 训练过程参数速查表参数推荐值不推荐的错误用法初始学习率0.1batch128用0.01收敛太慢用0.5直接发散优化器SGD momentum0.9Adam的泛化性差尤其小数据集权重衰减5e-41e-4以下会过拟合1e-2以上loss震荡BatchNorm momentum0.1默认0.9会导致验证集上test acc波动大训练epoch200少于100需要调低lr否则欠拟合3.4 训练中常见的三个坑BatchNorm在训练和推理时的行为差异属于最隐蔽的问题。训练时model.train()会用当前batch计算mean和var推理时要用running stats。如果你在大batch上训练到推理时切小batch会导致精度骤降这时候先查model.eval()有没有调用。第二个坑是梯度累积的误用。显存不够时很多人把batch拆成多个micro-batch累加梯度但BN的统计量仍然是per-micro-batch的这会导致BN统计量被过度平均训练不稳定。正确做法是在累加梯度时同步用EMA近似全局统计。第三个坑是学习率warming。ResNet18的论文里没有专门做warmup但在50000张图的CIFAR上前几个epoch使用0.1的学习率会非常震荡。提示自定义数据集上先用10%的数据做一次overfit测试。把模型训练到100%训练准确率如果做不到说明结构或者数据标签有bug不值得花时间调参。4. ResNet18的迁移学习与特征复用实战4.1 用预训练权重替换分类头真实项目很少从零训练常见做法是加载ImageNet上的预训练权重只替换最后的全连接层。PyTorch官方对ResNet18的写法是import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, 10) for name, param in model.named_parameters(): if not name.startswith(fc.): param.requires_grad False optimizer optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr0.01, momentum0.9)这段代码做的事情分三步先把模型fc层的输入维度读出来替换掉成10分类的线性层再把fc层以外的所有参数冻结这样反向传播时前层权重不更新最后把优化器设为只对可学习参数生效。冻结策略对数据量小于1万张的私有数据集极其有效相当于把ResNet18当作固定特征提取器只在顶层学一个线性分类边界。4.2 全量微调的学习率策略如果数据集足够大比如超过5万张且和ImageNet分布差异大就需要解冻全部层做全量微调。常见的做法是把学习率从提取模式的0.01降到0.001并且把BatchNorm的momentum调回默认。还有一个技巧不同层组用不同学习率backbone层lr0.001新增分类层lr0.01PyTorch里通过ParameterGroup实现。backbone_params [] classifier_params [] for name, param in model.named_parameters(): if name.startswith(fc.): classifier_params.append(param) else: backbone_params.append(param) optimizer optim.SGD([ {params: backbone_params, lr: 1e-3}, {params: classifier_params, lr: 1e-2} ], momentum0.9, weight_decay1e-4)逻辑说明这样设计是因为卷积层在ImageNet上学到的边缘和纹理特征普遍可迁移不需要大更新而新分类头是从随机初始化开始的必须用更大步长快速收敛。训练时把model.train()放在epoch开头验证时切成model.eval()用统计指标看是否出现灾难性遗忘——也就是冻结层提取的特征在新任务里被破坏。4.3 ResNet18作为特征提取器的直接用法在需要做图像检索、人脸比对、少样本分类时ResNet18的倒数第二层特征输往往是768维或512维向量可以用下面的方式提取class FeatureExtractor(nn.Module): def __init__(self, original_model): super().__init__() self.features nn.Sequential( *list(original_model.children())[:-1]) self.flatten nn.Flatten() def forward(self, x): return self.flatten(self.features(x)) backbone models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) extractor FeatureExtractor(backbone) extractor.eval() with torch.no_grad(): feat extractor(batch_images) print(feat.shape) # (batch_size, 512)这段代码把resnet18的最后一层池化和fc剥掉输出每个样本的512维特征向量。实际场景里512维特征配合余弦相似度已经能在工业缺陷检索任务里跑到相当好的准确率这也是ResNet18在轻量级视觉系统里经久不衰的原因。4.4 与目标检测和SNN的关联ResNet18经常被上层检测网络当作backboneYOLO系列的small版本就是一个跨阶段使用类残差结构。换到SNN脉冲神经网络方向ResNet18的残差连接和工作在ANN-to-SNN转换里的“膜电位重置”问题高度相关。这件事的启示是残差连接不只是提高深度还天然适合帮助梯度跨层流动在时间维度上让脉冲信号保持稳定。5. 把ResNet18送入部署前的三种验证与提速技巧5.1 用混淆矩阵验证分类边界准确率只说明整体水平对多分类任务需要看每个类别的recall和precision。在测试集上收集预测结果生成混淆矩阵之后真正的信息在于“哪些类被系统性混淆”。对于CIFAR-10猫和狗、鹿和马的混淆往往反映训练数据量不足而不是网络能力不够。import numpy as np from sklearn.metrics import confusion_matrix, classification_report model.eval() all_preds, all_labels [], [] with torch.no_grad(): for inputs, labels in testloader: inputs, labels inputs.to(device), labels.to(device) outputs model(inputs) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, target_names[airplane, car, bird, cat, deer, dog, frog, horse, ship, truck]))如果某个类别召回率特别低优先检查数据增强是否破坏了该类别独有的纹理特征而不是直接加训练数据——前者成本低得多。5.2 ONNX导出与推理对齐检查PyTorch模型在C推理引擎里的部署通常走ONNX中间格式。导出后必须做前后精度对比因为部分算子在不同opset下的行为不一致。dummy_input torch.randn(1, 3, 32, 32).to(device) torch.onnx.export(model, dummy_input, resnet18.onnx, input_names[input], output_names[output], opset_version17, dynamic_axes{input: {0: batch_size}, output: {0: batch_size}})导出后对比PyTorch输出和ONNX Runtime的输出np.allclose(pytorch_out.detach().cpu().numpy(), onnx_out, atol1e-5)。如果不对齐80%的可能是BatchNorm被错误融合这时把model.eval()设置好并检查导出的图节点里有没有BatchNorm残留。5.3 用TorchScript或ONNX Runtime做INT8量化提速ResNet18在CPU平台上的推理瓶颈是卷积算子。PyTorch官方量化API提供post-training静态量化对CPU推理提速通常在2到3倍之间。关键是先给量化配置指定后端。model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.eval() model.qconfig torch.quantization.get_default_qconfig(fbgemm) model_prepared torch.quantization.prepare(model, inplaceFalse) with torch.no_grad(): for inputs, _ in testloader: model_prepared(inputs) model_quantized torch.quantization.convert(model_prepared, inplaceFalse) # 保存为ONNX量化版本或直接用torch.jit打包 scripted torch.jit.script(model_quantized) scripted.save(resnet18_quantized.pt)静态量化需要在准备阶段跑少量校准数据这个步骤会让权重从FP32映射到INT8牺牲1%以内的精度换取推理速度提升。在工业场景里如果目标是树莓派或工业工控机INT8的ResNet18单帧推理时间能从前向的40ms压到15ms左右。最后补一个调试技巧如果量化后精度掉得超过3%尝试只量化conv层保留第一层和最后一层的FP32计算。原因是最初的卷积层对输入分布特别敏感最后一层直接决定类别置信度不适合离散化。改配置时在qconfig_dict里手动指定{ : None, conv: QConfig(...)}就能精细化控制哪些层参与量化而不是全盘接受默认策略。本文还有配套的精品资源点击获取
返回列表