
简介ResNet18是深度学习经典卷积神经网络之一由何恺明等人于2015年提出通过残差块设计有效缓解网络加深时的梯度消失问题在图像分类等视觉任务中表现稳健。资源面向希望理解残差网络原理并快速搭建模型的开发者、学生或工程人员特别适合算力有限的嵌入式或移动端场景。压缩包共3个文件、约318KB包含一份resnet18.py的PyTorch实现代码与两张网络结构图PNG代码提供模型初始化与前向传播等核心逻辑结构图清晰展示残差块、1×1卷积、批量归一化等层间连接关系可边看边练。目前已有2655人浏览学习说明这套资料具有一定参考价值。使用这份资料读者可直接实例化模型进行训练、验证与测试也可以对照图示逐层理解残差学习原理包括残差连接如何在网络中叠加输入与特征图从而更扎实地掌握ResNet18的工程实现与设计思想。1. ResNet18用 18 层的残差连接把梯度消失拉回可控范围在嵌入式设备或移动端场景里做图像识别模型要先过“参数量”和“计算量”这两关。ResNet18 是 ResNet 系列里结构最轻的一档约 1100 万参数完整前向一次 224×224 输入只需要 18 亿 FLOPs比 ResNet50 少了近一半这让它在树莓派、Jetson Nano 这类资源受限的板子上依然能跑到可用的帧率。但这篇要拆的重点不是“轻”而是它为什么能在网络变深之后仍然训练得动残差块Residual Block让每一层学习的是输入与输出的差值而非完整映射梯度可以借恒等映射identity shortcut直接回传梯度消失被从机制上缓解。适合两类人深读一是刚接触 torchvision 预训练模型、想搞懂 resnet18 网络内部排布的新手二是做迁移学习或模型压缩需要手动改骨干网络结构的工程师。2. 从梯度消失到残差学习BasicBlock 的通道与步长设计2.1 深层 CNN 的退化问题不是过拟合是优化不动在 ResNet 出现之前VGG 和 GoogLeNet 已经在把网络往更深推但深度超过一定阈值后训练集上的错误率反而上升。这个现象不叫过拟合叫退化degradation。原因是反向传播时梯度要逐层相乘链式法则连乘一长串小于 1 的导数之后浅层的梯度会指数级缩小权重几乎收不到有效的更新信号。直观感受是你监控 loss 曲线发现前几层卷积的权重在训练几千个 iteration 后变化量仍是 1e-6 量级而最后一层全连接已经收敛。大规模 Batch Normalization 能缓解但根治还靠残差结构。ResNet 的思路是在映射关系上做文章。设期望学习的底层映射为 H(x)残差块不直接拟和 H(x)而是拟合 F(x) H(x) - x再把输入 x 作为 shortcut 加回来即输出 y F(x, {W_i}) x。求导时 ∂y/∂x 1 ∂F/∂x恒等路径的导数为 1梯度反传相当于乘了一个单位矩阵加一个小扰动不会消失。这一改动让网络层数从几十层直接跨到上百层而 ResNet18 恰好是验证这个机制的最小完整单元。在 resnet18 网络里基础残差块被称为 BasicBlock结构是两串 3×3 卷积加 shortcut。每个 BasicBlock 内的计算流程如下输入 x 经过 3×3 卷积 conv1通道数不变或者加倍过 Batch Normalization 和 ReLU 激活再经过 3×3 卷积 conv2通道数与 conv1 输出一致shortcut 分支存在两种可能输入输出 shape 一致时直接相加不一致时用 1×1 卷积改变通道数和空间分辨率相加后的特征图再过一次 ReLU得到输出。这里面最容易被忽略的细节是第二个 ReLU 的位置。PyTorch 官方实现里out self.relu(out) 是在残差相加之后执行的。也就是先加再激活这个顺序决定了网络不会学到负的残差输出也保证了恒等连接路径在相加处不做任何非线性变换。如果顺序颠倒为先激活再加残差连接的梯度传播特性会被破坏shortcut 就不再是恒等映射。2.2 ResNet18 在 ResNet 家族中的定位与层分布ResNet18 和 ResNet34 用的是同一种 BasicBlock而 ResNet50 及以上用的是 Bottleneck三层卷积1×1 降维、3×3 卷积、1×1 升维。下面是不同 ResNet 变体的层数分布对照ResNet18 的 18 层统计口径为开头 1 个 7×7 卷积 四个 stage 共 16 个卷积层每个 BasicBlock 含 2 个卷积stage 内 block 数为 2/2/2/2 最后的全连接层模型Stage1 blocksStage2 blocksStage3 blocksStage4 blocks总卷积层数Block 类型ResNet18222216BasicBlockResNet34346332BasicBlockResNet50346348BottleneckResNet1013423399Bottleneck每个 stage 的第一个 BasicBlock 是 channel 和空间分辨率发生变化的节点。以 ResNet18 为例输入是 224×224×3stem 层的 7×7 卷积 stride 2 把特征图降到 112×112之后 layer1 保持 56×56、64 通道layer2 的第一个 BasicBlock 通过 stride 2 把空间尺寸减半到 28×28通道从 64 加到 128layer3 和 layer4 依次把尺寸降到 14×14、7×7通道加到 256 和 512。每次降采样时shortcut 分支需要引入 1×1 卷积且 stride 设为 2否则维度对不上相加操作会报 shape mismatch 错误。这一步的选型逻辑有必要说清楚为什么要用 1×1 卷积做 shortcut 而不是直接对输入做 stride 2 的池化1×1 卷积是可学习的线性变换它不只是改变通道数还能在降采样时保留更多信息。而池化是固定的下采样会丢失局部位置信息。虽然 shortcut 分支上哪怕加一层非线性也能训练但 ResNet 作者刻意把 shortcut 保持为线性就是要把梯度通道完全锁死在恒等路径上这也是为什么 PyTorch 官方实现里 downsample 模块只由 Conv2d BatchNorm2d 搭成没有 ReLU。3. resnet18.py 逐行拆解18 层的代码如何排布3.1 从 conv3x3 到 BasicBlock最小单元的构造拿到 resnet18.py 这个文件不要先急着看 class ResNet 的大框架顺着依赖关系从底层往上看最好读。文件里一定有一个 conv3x3 的辅助函数这是所有残差块的地基import torch import torch.nn as nn def conv3x3(in_planes, out_planes, stride1): 3x3 convolution with padding return nn.Conv2d( in_planes, out_planes, kernel_size3, stridestride, padding1, biasFalse, )这个函数值得注意的参数是biasFalse。因为卷积层后面紧跟 BatchNormBN 层自带可学习的偏置项卷积层的 bias 在这里是冗余的。如果在 Conv2d 里开了 bias参数量会多出 out_planes 个但训练时 BN 会把偏置归一化掉等于白白增加计算量而没有任何表达能力上的提升。既然文件里给的源码对这个参数专门做了处理在自己改网络时也值得沿用这一约定。BasicBlock 是 ResNet18 的核心类初始化过程要处理两个分支主分支的两层卷积以及 shortcut 分支的 downsample。这里把官方实现的关键逻辑贴出来class BasicBlock(nn.Module): expansion 1 def __init__(self, inplanes, planes, stride1, downsampleNone): super().__init__() self.conv1 conv3x3(inplanes, planes, stride) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) self.conv2 conv3x3(planes, planes) self.bn2 nn.BatchNorm2d(planes) 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 是参数传入的第二条卷积始终是 stride1。expension 1代表这个 block 的输出通道数和主分支最后一层卷积的通道数相等。inplaceTrue是 PyTorch 的显存优化技巧ReLU 直接修改输入张量而不是新开内存在训练大模型或 batch size 受限时能省一点显存但注意在梯度回传时 inplace 操作对 autograd 不友好如果后面接的是需要保留原张量的自定义算子要改成inplaceFalse。downsample参数是外部传入的 nn.Module 或 None。forward 里核心顺序是先算主分支再在相加之前把 shortcut 分支的结果赋给 identity。很多人第一次读 ResNet 源码会疑惑为什么 identity 在 forward 里才计算而不是在__init__里提前建好网络层。原因是 downsample 的构造依赖 stage 间的通道数映射关系放在 make_layer 里统一创建更清晰。3.2 make_layer把 block 串成 stage 的关键一环ResNet18 的 4 个 stage 由_make_layer方法统一生成。这个方法接收 block 类型、输出通道数和 block 数量在 stage 入口判断是否需要对输入做降采样。下面是用在 resnet18.py 里的典型写法def _make_layer(self, block, planes, blocks, stride1): downsample None if stride ! 1 or self.inplanes ! planes * block.expansion: downsample nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size1, stridestride, biasFalse), nn.BatchNorm2d(planes * block.expansion), ) layers [] layers.append(block(self.inplanes, planes, stride, downsample)) self.inplanes planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes)) return nn.Sequential(*layers)这里有个关键变化stage 第一个 block 之后self.inplanes被更新为planes * block.expansion。这意味着从第二个 block 开始输入输出通道已经一致stride 也回到 1不再需要 downsample。后续 block 之间通道恒等残差相加直接进行。注意 stride 参数stride只在 stage 的第一个 block 生效后续 block 的 stride 都是 1。这也是为什么特征图在每个 stage 内尺寸不变只在 stage 交界处减半。在构建自己的网络时如果想改变某个 stage 的降采样位置只需调整_make_layer传给第一个 block 的 stride。3.3 ResNet 主类与前向传播的 shape 追踪ResNet 主类把 stem、四个 stage、全局池化和分类头串起来class ResNet(nn.Module): def __init__(self, block, layers, num_classes1000): super().__init__() self.inplanes 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前向传播中每个阶段的输出 shape 变化是检查网络搭建是否正确的第一道验证。输入 224×224×3 时各层输出尺寸如下层名输出形状分辨率变化通道数conv17×7 stride 2112×112224/264maxpool3×3 stride 256×56112/264layer12 个 BasicBlock56×56不变64layer22 个 BasicBlock28×2856/2128layer32 个 BasicBlock14×1428/2256layer42 个 BasicBlock7×714/2512avgpool1×17→1512fc1000--AdaptiveAvgPool2d((1, 1))是 ResNet 里一个值得展开的细节它不管输入尺寸是多少都能池化成 1×1。这给了模型输入尺寸的灵活性——在推理时可以用 384×384 的输入而不改网络结构因为最后的池化会把空间维度收敛到 1。相比固定 kernel size 的 AvgPool2dAdaptive 池化省去手动计算池化窗口参数的步骤。关于 18 层怎么数出来这里做一个明确的口径说明1 个 stem 卷积 layer1-4 每组 2 个 BasicBlock × 每个 block 2 个卷积 1 8×2 17 个卷积层加上最后的全连接 fc 层总共 18 层。这个统计方式绕过了 BN 和 ReLU把卷积层和全连接层算作权重层。如果你看到别处写 18、20、21 的版本差别都在全连接层算不算进去以及 stem 是不是只算 1 个卷积。4. PyTorch 配置 ResNet18数据加载、训练循环与参数验证4.1 从 torchvision 加载预训练模型与权重迁移在 resnet18.py 基础上做实际训练最常见的入口是 torchvision 提供的预训练实现。首先要分清两种加载方式weightsResNet18_Weights.DEFAULT会加载在 ImageNet-1K 上预训练好的权重weightsNone则随机初始化从头训练。迁移学习场景下前者是标准选择但要注意预训练模型对输入有均值和方差要求必须做相同的归一化。import torch import torchvision.transforms as T from torchvision.models import resnet18, ResNet18_Weights # 加载预训练权重 weights ResNet18_Weights.DEFAULT model resnet18(weightsweights) # 冻结除最后一层外的所有层只训练分类头 for param in model.parameters(): param.requires_grad False # 替换分类头ImageNet 1000 类 - 自定义 10 类 model.fc torch.nn.Linear(model.fc.in_features, 10) # 查看模型是否真的冻结了 trainable sum(p.numel() for p in model.parameters() if p.requires_grad) total sum(p.numel() for p in model.parameters()) print(fTrainable / Total: {trainable} / {total})这段代码里model.fc.in_features是获取 ResNet18 最后一层全连接的输入维度ResNet18 固定为 512。冻结主干参数后反向传播只更新 fc 层和 fc 层之前各 BN 层的 running mean/var如果 BN 处于训练模式。Trainable / Total的输出应该是 5120 / 11180000 左右只占全部参数的约 0.05%这是迁移学习在数据量不大时防止过拟合的核心手段。4.2 预处理管线不要被 ImageNet 的 mean/std 坑住ResNet18 预训练模型在训练时使用的标准化参数是 ImageNet 数据集的统计量mean [0.485, 0.456, 0.406]std [0.229, 0.224, 0.225]。这个参数在网络对外提供服务时也要保持一致否则输入分布漂移会导致精度骤降。常见误区是只在训练时做标准化、推理时忘记或者自定义数据集自己重新算 mean/std导致预训练权重失效。下面给出完整的预处理流程preprocess T.Compose([ T.Resize(256), T.CenterCrop(224), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) from PIL import Image img Image.open(cat.jpg).convert(RGB) input_tensor preprocess(img).unsqueeze(0) # (1, 3, 224, 224) with torch.no_grad(): logits model(input_tensor) probs torch.softmax(logits, dim1) top1 probs.argmax(dim1).item() print(fTop-1 class index: {top1}, confidence: {probs[0, top1].item():.4f})Resize(256)和CenterCrop(224)是配套策略先把最短边拉到 256再从中心裁 224×224避免直接 Resize 到 224 造成缩放变形。如果改用Resize(224, 224)强行拉伸矩形图片中的物体比例会失真ResNet18 对这类畸变比较敏感准确率会掉 2-3 个百分点。实际工程中如果想做多尺度测试可以去掉 CenterCrop直接把 Resize 后的 256×256 张量送进网络AdaptiveAvgPool 会兜底输出 1×1 特征。4.3 训练超参速查表与常见训练循环ResNet18 在自定义数据集上的训练超参有比较成熟的参考区间。以 CIFAR-10 为例的数据集规模下标准配置如下超参数推荐值说明Batch Size64-128视 GPU 显存而定推理时 batch 可以更大优化器SGDmomentum0.9weight_decay1e-4初始学习率0.01-0.1迁移学习建议 0.01从头训练可 0.1学习率策略CosineAnnealingLRT_max epochseta_min 1e-6Epochs30-90迁移学习 30 足够从头训练 90数据增强RandomCrop RandomHorizontalFlipCIFAR 用 4 像素 padImageNet 用 256 缩放一个典型的训练循环中需要注意模型是否处于训练模式model.train()以及 BN 层在训练和推理时的行为差异。训练时 BN 用当前 batch 的均值/方差推理时用 running mean/var如果不切模式BN 会在推理时使用不正确的统计量。下面是一个最小可运行的训练循环骨架optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30, eta_min1e-6) criterion torch.nn.CrossEntropyLoss() for epoch in range(30): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) scheduler.step() # 验证时切 eval 模式 model.eval() correct 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.cuda(), labels.cuda() outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() acc correct / len(val_loader.dataset) print(fEpoch {epoch1}: loss{running_loss/len(train_loader.dataset):.4f}, facc{acc:.4f})optimizer.zero_grad()必须在 loss.backward() 之前调用否则梯度会跨 batch 累积。CosineAnnealing 的T_max30表示 30 个 epoch 内把学习率从初始值余弦衰减到eta_min一般在第 25 个 epoch 之后 loss 曲线进入平台期如果此时验证精度还在上升说明学习率衰减太早可增大 T_max 或把 eta_min 调低一个数量级。4.4 从 resnet18.py 加载自定义实现时的命名约定如果不是用 torchvision 而是直接跑手写的 resnet18.py需要确认文件内resnet18()函数的构造参数。常见自定义实现会有两类差异一是是否带pretrained参数来控制权重加载二是是否把num_classes直接作为构造参数。在使用时建议显式传参# 手写 resnet18.py 的示例导入方式 from resnet18 import resnet18 model resnet18(num_classes10, pretrainedFalse)但要注意pretrained这个标志在 torchvision 新版本中已被废弃官方推荐用weightsResNet18_Weights.DEFAULT的方式。老代码里model resnet18(pretrainedTrue)会触发一个 FutureWarning。两者加载的权重文件是同一个但新 API 在权重下载和校验上更规范。具备跨框架迁移需求的场景可以加载 torchvision 权重后手动拷贝 state_dict 到自定义模型from torchvision.models import resnet18 as tv_resnet18 from resnet18 import resnet18 as custom_resnet18 tv_model tv_resnet18(weightsResNet18_Weights.DEFAULT) my_model custom_resnet18(num_classes1000) # 拷贝权重的关键是 key 名称一致 state tv_model.state_dict() my_model.load_state_dict(state, strictTrue)strictTrue要求 state_dict 的 key 完全匹配。自定义模型的 BasicBlock 或 stage 命名与 torchvision 不一致时要先 print 两边的 keys 做 diff找出需要改名的层。一个常见的坑手写实现里把layer1.0.conv1命名成layer1.0.convAload_state_dict 直接报 missing key 错误。这种情况用strictFalse会静默忽略不匹配的层容易埋下权重未加载的隐患不推荐。5. 结构图反推网络从图片验证模型设计的几个实用技巧5.1 读懂 ResNet18 结构图里的信息密度拿到ResNet18结构1.png和ResNet结构2.png这类结构图有价值的信息不只是几个方块。标准的 ResNet 结构图会用矩形表示特征图标注[H, W, C]或[C, H, W]箭头分为实线和虚线实线代表 stride1 的恒等连接虚线代表 stride2 且通道变化的降采样连接。用下面三个问题去读图能快速判断这张图对应的是哪个变体:第一找降采样点。看图中特征图尺寸从 56×56 到 28×28 的位置短接分支上是否画了 1×1 卷积。如果虚线 shortcut 上没有标注 1×1 卷积块这张图大概率是简化版只画了主路径。第二数每个 stage 的 block 数。图里每个残差块一般会展开成两个小矩形3×3 卷积数 stage 内小矩形的个数除以 2得到 block 数4 个 stage 依次是 2-2-2-2 就是 ResNet183-4-6-3 就是 ResNet34。第三看最后池化层的标注。结构图里如果写AvgPool 7×7而不是AdaptiveAvgPool说明这张图展示的是固定输入 224×224 的推理路径换成其他输入尺寸这个 7×7 要动态调整。5.2 用参数量统计校验结构图的准确性ResNet18 的一个优点是可以手算参数量这对验证结构图是否画错、代码实现是否少层非常有帮助。卷积层参数量公式是 C_in × C_out × k × kBN 层参数量是 2 × C_outgamma 和 beta。以 layer2 第一个 BasicBlock 为例输入 56×56×64输出 28×28×128shortcut 里的 1×1 卷积: 64×128×1×1 8192 参数主分支 conv13×3 stride 2: 64×128×3×3 73728 参数主分支 conv23×3 stride 1: 128×128×3×3 147456 参数三个 BN 层2×128 ×2 2×128 768 参数单 block 约 22.9 万参数。如果从结构图或代码里算出来的参数量与下面的全局统计对不上多半是某个 stage 的通道数写错或 downsample 分支漏画。ResNet18 完整参数量分布如下模块参数数量约占比conv1 bn195000.09%layer12 blocks64 通道148 万13.2%layer22 blocks128 通道526 万47.1%layer32 blocks256 通道276 万24.7%layer42 blocks512 通道165 万14.8%fc512×100051.3 万4.6%layer2 参数量占比最高的原因是输入输出通道跨度大shortcut 的 1×1 卷积也增加了开销。layer4 block 数量相同但输入通道已经是 256主分支卷积参数多但 shortcut 通道跨度为 128所以总量低于 layer2。用这个分布表对照结构图如果某个 stage 的比例严重偏离优先检查该 stage 的通道配置。5.3 随机输入前向测试不训练先验证结构代码写完、结构图画完最靠谱的验证方式是随机生成一个输入确认各层输出 shape 符合预期并且两手数出参数量与统计一致。这个技巧在修改骨干网络时尤其值得养成习惯先跑通前向再开始训练能省下大量调试时间import torch from resnet18 import resnet18 model resnet18(num_classes10, pretrainedFalse) model.eval() x torch.randn(2, 3, 224, 224) with torch.no_grad(): out model(x) print(fOutput shape: {out.shape}) # 期望 [2, 10] # 核对参数量 total sum(p.numel() for p in model.parameters()) print(fTotal params: {total:,}) # 期望输出约 11,181,642ResNet18 10 分类头model.eval()这一步不可省略否则 BN 层会用训练模式统计每个 batch 的动态均值方差随机输入下数值不稳定但 shape 一般没问题。如果想进一步验证 BN 的 running mean 更新逻辑可以切回model.train()再跑一个 batch观察 BN 层的 running_mean 是否从默认 0 变为接近输入分布的均值。单独拿这个技巧去匹配结构图建议把四个 stage 的中间输出都打印出来确认 56、28、14、7 四个分辨率依次出现任何一个 stage 的 stride 配错print 出来的 shape 马上会暴露问题。检查通过后再进入训练环节后面遇到精度不收敛的情况就可以直接排除网络结构错误这个可能。本文还有配套的精品资源点击获取