简介:ResNet18 是深度残差网络中结构精简且常用的 18 层 CNN 模型,由何恺明等人提出,适合希望在图像分类、特征提取等视觉任务中快速上手的初学者,以及需要在嵌入式或移动端部署轻量级网络的开发者。资源包仅 3 个文件,压缩包约 318KB,包含两张网络结构示意图和一份基于 PyTorch 的 Python 实现代码;结构图覆盖输入层、卷积层、池化层、残差块与全连接层的排布,代码侧则清楚写出模型初始化与前向传播逻辑,可直接实例化用于训练、验证或测试。残差块通过跳跃连接将输入与卷积输出相加,配合批量归一化和 ReLU 激活,能够有效缓解深层网络的梯度消失与爆炸问题,这也是理解 ResNet 的核心所在。已有 2655 人学习下载,作为轻量级入门材料,无论用于课程设计、算法复现还是工程部署,都可以从中快速获得可运行的参考实现,并迁移到自己的视觉项目里。
1. ResNet18 网络少的是能训练出来的深度,不是层数
ResNet18 这个名字容易让人误以为它只有 18 层卷积,实际上它是 17 个卷积层加 1 个全连接层共 18 个带权重层,参数量约 11.7M(11,689,512),单张 224×224 输入的浮点计算量约 1.8 GFLOPs。它在深度学习里扮演的角色很有意思:它是研究残差连接的最小可用单元,是目标检测骨干网络里性价比最高的选项,也是你把 ResNet50 跑不动、ResNet34 又嫌不够的时候最自然的降级方案。对于小数据集图像分类、嵌入式部署、蒸馏实验的 teacher 网络,ResNet18 网络几乎是默认起点。这篇博文就围绕 ResNet18 网络的结构拆解、PyTorch 配置、训练参数和隐蔽坑展开,让新手能按步骤跑通,让有经验的工程师能查到边界条件。
2. 拆解 ResNet18 网络的四段结构与残差块 forward 路径
2.1 残差映射 h(x)=F(x)+x 解决的不只是梯度消失
ResNet 的核心思想是让网络去学习残差映射 F(x) = H(x) - x,然后通过捷径连接(shortcut)把输入 x 直接加到 F(x) 的输出上。这里的加法操作在反向传播时会产生两条梯度路径:一条经过 F 的卷积层,另一条直接绕过卷积层把梯度原样传回。后一条路径保证了即使前面的层学习得不好,梯度也不会被连乘效应衰减到零。这就是为什么 50 层以上的网络在 ImageNet 上能稳定收敛,而 VGG 到 19 层就接近极限。
ResNet18 网络里使用的是 BasicBlock,结构是两个 3×3 卷积,每个卷积后接 BatchNorm 和 ReLU,最后通过加法把 shortcut 的输入合进来。设计上的关键细节是:第一个 3×3 卷积步长为 2 时,特征图尺寸减半,此时 shortcut 不能直接加,必须用 1×1 卷积把通道数和尺寸对齐。这个对齐逻辑贯穿整个 ResNet 家族,ResNet18 网络也不例外。
2.2 逐层跟踪一个 224×224×3 输入在 ResNet18 网络里的尺寸和通道变化
下面这张表把 ResNet18 网络的完整前向路径列出,输入统一假设为 224×224×3 的 RGB 图像。
| 阶段 | 具体操作 | 输出尺寸 | 通道数 | 带权重层数 | 说明 |
|---|---|---|---|---|---|
| stem | 7×7 conv, stride 2;BN;ReLU;3×3 maxpool, stride 2 | 56×56 | 64 | 1 | 分辨率一次降 1/4 |
| layer1 | 2 个 BasicBlock,步长均为 1 | 56×56 | 64 | 4 | shortcut 为恒等映射 |
| layer2 | 第 1 个 BasicBlock 步长 2;第 2 个步长 1 | 28×28 | 128 | 4 | 第一个块用 1×1 卷积对齐 |
| layer3 | 同 layer2 模式 | 14×14 | 256 | 4 | 通道翻倍,尺寸减半 |
| layer4 | 同 layer2 模式 | 7×7 | 512 | 4 | 输出特征图 7×7 |
| head | 全局平均池化;全连接层 | 1×1 | 1000 | 1 | 输出类别数 |
合计卷积 17 层,全连接 1 层。注意到一个容易忽略的点:layer2 到 layer4 的每个阶段,第一个 BasicBlock 的 3×3 卷积步长为 2,这意味着 shortcut 侧的 1×1 卷积也必须步长为 2,否则尺寸对不上。PyTorch 官方实现里通过 downsample 参数统一处理了这两个分支。
2.3 从 torchvision 里导出结构并核对 shortcut 对齐方式
最常见的做法是直接用 torchvision 加载官方实现,然后打印结构确认。下面这段代码可以在 Jupyter 里直接运行,统计参数量并检查每个 BasicBlock 的 shortcut 类型。
import torch import torchvision.models as models model = models.resnet18(weights=None) total = sum(p.numel() for p in model.parameters()) print(f"总参数量: {total:,}") # 输出: 总参数量: 11,689,512 # 检查每个 BasicBlock 是否有 downsample for name, module in model.named_modules(): if isinstance(module, models.resnet.BasicBlock): has_downsample = module.downsample is not None print(f"{name:30s} shortcut 类型: " f"{'1x1 conv' if has_downsample else 'identity'}")这段代码先统计全部参数,然后遍历模型里的所有 BasicBlock,打印其 downsample 属性。运行后会看到 layer2.0、layer3.0、layer4.0 三个位置的 shortcut 是 1×1 卷积,其余位置都是恒等映射。理解这个结构对后续做自定义修改很有用,比如把 shortcut 去掉就退化成普通卷积网络,性能会明显下降,这正是验证残差作用的实验。
3. 用 PyTorch 配置 ResNet18 网络的最小训练流程
3.1 数据侧:ResNet18 网络对输入尺寸不敏感,但归一化参数必须正确
ResNet18 网络本身并不强制要求输入是 224×224,只要能被 32 整除即可。但 torchvision 预训练权重是按照 ImageNet 的归一化统计训练的,所以数据预处理必须使用均值 [0.485, 0.456, 0.406]、方差 [0.229, 0.224, 0.225],否则加载预训练权重后第一轮 loss 会异常偏高,甚至出现 NaN。
以下是 CIFAR-10 上的标准数据增强组合,兼顾了复现简单和效果稳定:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomCrop(32, padding=4), # 先 padding 再裁剪,等价于随机平移 transforms.RandomHorizontalFlip(), # 以 0.5 概率水平翻转 transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ), ]) val_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ), ])RandomCrop 加 padding 是 CIFAR 这类小图任务最划算的数据增强,几乎不增加训练时间。归一化里的均值和方差必须和预训练一致,跟数据集无关;如果你在自己的数据域上从头训练,可以用整个训练集的统计代替这里的 ImageNet 值,但要注意 batch size 较小时统计误差大,不如直接用 ImageNet 值省事。
3.2 训练循环里模型冻结与解冻的三种选择
迁移学习场景下,ResNet18 网络通常有三种用法。第一种是只训练最后一层全连接,适合数据量很小(几百到一千张)的情况,速度快且不容易过拟合。第二种是全量微调,适合数据量在万级以上的情况,效果最好但需要更大的显存和更长的时间。第三种是解冻所有卷积层但冻结 BatchNorm 的统计参数,适合风格迁移或数据分布与 ImageNet 差距较大的任务。
import torch.nn as nn model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 替换分类头,num_classes 是自己数据集的类别数 num_classes = 10 model.fc = nn.Linear(model.fc.in_features, num_classes) # 策略一:冻结全部卷积层,只训分类头 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True # 策略三:冻结 BN 的 running stats,但让卷积权重可学习 for module in model.modules(): if isinstance(module, nn.BatchNorm2d): module.track_running_stats = False策略二(全量微调)不需要额外代码,直接把整个模型传入优化器即可。策略三里把 track_running_stats 设为 False 后,BatchNorm 层不再更新 running_mean 和 running_var,只把 batch 内的统计量用于归一化,相当于把 BN 变成了 InstanceNorm 的近似。这个细节在把 ResNet18 网络迁移到医学影像或卫星影像等分布差异大的任务时经常用到。
3.3 单卡训练显存与耗时的经验范围
| batch size | 输入分辨率 | 显存占用(RTX 3080 级别) | 适用场景 |
|---|---|---|---|
| 64 | 224×224 | 5.5 GB 左右 | 有 8GB 以上显存 |
| 32 | 224×224 | 3.0 GB 左右 | 标准配置 |
| 16 | 224×224 | 1.8 GB 左右 | 显存受限或调试阶段 |
| 128 | 32×32(CIFAR) | 1.5 GB 左右 | 小图实验 |
训练一个 epoch 的耗时受数据读取影响很大,常见做法是用 DataLoader 的 num_workers 设为 4 或 8、pin_memory 设为 True,否则 GPU 会大量时间在等数据。ResNet18 网络本身的计算量只有 ResNet50 的四分之一左右,单张卡训练 CIFAR-10 二十个 epoch 在十分钟到半小时这个量级,瓶颈通常在数据加载而不是算力。
4. ResNet18 网络训练中的四个隐蔽坑:BN 统计、loss 基线、shortcut 与参数分组
4.1 第一个 epoch 的 loss 没落在 6.9 附近,先怀疑数据管道
分类任务初始化时,最后一层 fc 的 bias 默认为 0,所以模型输出 logits 基本在 0 附近,softmax 后每个类别的预测概率接近均匀分布。对于 CIFAR-10,初始 loss 应该接近 ln(10) ≈ 2.30;对于 ImageNet,损失接近 ln(1000) ≈ 6.91。如果你从随机初始化开始训练,第一个 batch 的 loss 远高于这个值,说明特征提取部分出了问题,比如输入没有归一化、学习率过大导致数值溢出,或者数据标签和图像错位。
从预训练权重开始微调时,分类头被随机重新初始化,所以 loss 会从接近随机初始化的值开始并迅速下降,这属于正常现象。如果第一个 epoch 的 loss 非常低(比如低于 0.5),反而要警惕:可能是训练集和测试集存在数据泄露,或者归一化参数写错导致模型直接输出了过高的置信度。
4.2 修改输入分辨率后 BN 的 running_mean 和 avgpool 尺寸都会变
ResNet18 网络在 ImageNet 上预训练时,BN 层的 running_mean 和 running_var 是在 224×224 分辨率下累计的。如果你把输入改成 128×128 或 256×256 继续微调,理论上特征图尺寸会变化,但 BN 的统计量依然基于旧的分布。常见做法是在微调初期用较小的学习率让 BN 重新适应新分辨率,或者直接冻结 BN 层只训练卷积层。
全局平均池化(avgpool)的位置在 layer4 之后、fc 之前,它接收 7×7 的特征图。输入分辨率不是 224 时,这个 7×7 会相应变化,比如 128×128 输入对应 4×4 的特征图,但池化操作本身不需要修改。真正需要注意的只有 BN 层的统计量。
4.3 shortcut 对齐检查:自定义残差结构时的隐藏错误
如果你不用 torchvision 而是手写 ResNet18,最常见的错误是 shortcut 的 1×1 卷积分支忘了设置 stride,导致跳连后尺寸不匹配。PyTorch 会在前向传播时报错,但报错信息往往指向 add 操作,让人一时看不出问题根源。
# 自定义 BasicBlock 时 shortcut 的常见错误 class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride=stride, padding=1, bias=False) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, stride=1, padding=1, bias=False) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: # 错误写法:1x1 卷积没有设置步长 self.shortcut = nn.Conv2d( in_channels, out_channels, kernel_size=1, stride=1)正确写法是 stride=1 误解时按 stride 原样传入,即nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride)。这类错误在训练初期不一定立刻暴露,因为某些输入尺寸下 stride=1 的 1×1 卷积结果加不进目标张量,但报错位置在 add 处,容易误判为数据问题。
4.4 优化器参数分组:不对 BN 的 weight 和 bias 做 weight decay
ResNet18 网络里有 16 个 BatchNorm 层,占了模型层数的大部分。BN 层里有两个可学习参数 weight(γ)和 bias(β),它们的作用是对归一化后的分布做仿射变换。对这两个参数施加 L2 weight decay 会破坏这种仿射变换的性质,让训练后期验证集 loss 出现抖动。
import torch.optim as optim decay_params = [] no_decay_params = [] for name, param in model.named_parameters(): if param.requires_grad: if 'bn' in name or 'bias' in name: no_decay_params.append(param) else: decay_params.append(param) optimizer = optim.SGD([ {"params": decay_params, "weight_decay": 5e-4}, {"params": no_decay_params, "weight_decay": 0.0}, ], lr=0.1, momentum=0.9, nesterov=True)这段代码按参数名把 BN 相关参数单独分组,不施加 weight decay。为什么是 5e-4 而不是 L2 正则常见的 1e-4?ResNet 系列在 ImageNet 上的标准配置就是 1e-4 到 5e-4 区间,配合小幅数据增强,这个范围内的差异在训练初期不明显,但在最后几百个 epoch 的验证集准确率上会有 0.2%-0.5% 的差距。对于 ResNet18 网络这种轻量模型,5e-4 属于偏大的取值,数据量小时建议降到 1e-4。
5. 不用 TensorBoard,手工验证 ResNet18 网络学到了什么
训练结束后,除了看验证集准确率,我一般会做两个快速验证。第一个是检查最后一个卷积层输出的特征图是否对输入图像的不同区域产生了区分性响应,第二个是统计模型在验证集上的 logits 输出分布是否合理。
import torch import torch.nn as nn from torchvision import transforms from PIL import Image def grad_plus_activation(model, img_tensor, target_class): """基于最后特征图加权响应的类激活近似,无外部依赖""" model.eval() features = {} def hook_fn(module, input, output): features["feat"] = output # 注册 hook 到 layer4 的最后一个 BasicBlock 输出 handle = model.layer4[-1].register_forward_hook(hook_fn) img_tensor = img_tensor.unsqueeze(0).requires_grad_(True) logits = model(img_tensor) heatmap = torch.zeros(features["feat"].shape[2:]) fc_weight = model.fc.weight[target_class] # [512] feat = features["feat"][0] # [512, 7, 7] # 用 fc 层权重的绝对值做加权求和,避免依赖 ReLU 梯度 for c in range(feat.shape[0]): heatmap += fc_weight[c].abs() * feat[c] heatmap = heatmap.detach().numpy() handle.remove() return heatmap这里没有用真正的 Grad-CAM,而是用全连接层权重的绝对值和最后一层特征图的加权求和近似类别响应。torchvision 标准 ResNet18 的 layer4 输出是 512×7×7,与 fc 层的输入维度正好对齐,所以可以直接做矩阵乘法近似。观察 heatmap 时关注点不是数值大小,而是高响应区域是否集中在图像里的目标物体上。如果热力图散落在背景区域,说明模型学到的判别性特征不在目标上,这时候优先检查数据标注质量而不是调参。
logits 分布验证更容易操作:对验证集跑一遍前向,统计 logits 的标准差。训练良好的分类器 logits 标准差通常在 5 以上;如果大部分 logits 集中在 0 附近(标准差小于 2),说明模型输出过于保守,常见原因是温度参数过高或者最后一层线性层的学习率设置过小,此时可以尝试降低基础学习率并延长训练轮数。
本文还有配套的精品资源,点击获取