1. 项目概述:为什么ResNet18是PyTorch新手绕不开的“第一座桥”
如果你刚打开Jupyter Notebook,敲下import torch,接着想跑一个真正能分类图像的模型——不是MNIST手写数字那种玩具级任务,而是能在CIFAR-10上稳定达到92%+准确率、结构清晰、参数可控、训练不崩的实战模型——那ResNet18几乎就是你当前阶段最合理、最安全、也最有教学价值的选择。它不像VGG16那样堆叠大量全连接层导致显存爆炸,也不像ViT那样依赖大规模预训练和复杂位置编码,更不像Inception系列那样分支交错难于调试。ResNet18用极简的残差块(Residual Block)设计,把深度网络的梯度消失问题从“理论难题”变成了“一行代码就能解决”的工程事实。我带过不少刚转AI方向的某高校学生,他们第一次独立完成端到端训练、验证、推理全流程的模型,90%以上都是从ResNet18开始的。它不是最先进的,但它是最诚实的:没有花哨的注意力机制,没有动态路由,没有多尺度融合,就靠两个3×3卷积+短路连接(skip connection)+批量归一化(BatchNorm)+ReLU,老老实实把50层网络的训练稳定性问题给解了。标题里写的“初始PyTorch(六+)”,这个“六+”不是指第六章之后,而是强调:当你已经走过了Tensor基础、Dataloader构建、自定义Dataset、损失函数选择、优化器配置、训练循环编写这些前五关,ResNet18就是你必须亲手搭出来、画出来、debug出来的第六关——它既是终点,也是起点。接下来所有更复杂的模型,比如ResNet50、ResNeXt、EfficientNet,甚至YOLOv8的主干网络,你都能在ResNet18的骨架上一眼认出它的基因。所以这篇内容不讲“ResNet有多牛”,只讲“你怎么在PyTorch里把它从零搭出来、看明白、调得稳”。
2. 网络结构设计逻辑:为什么是18层?为什么必须有残差?
2.1 层数计数的“行业潜规则”:什么才算一层?
很多初学者看到ResNet18这个名字,第一反应是“它有18个卷积层”,这其实是个常见误解。PyTorch官方实现中,ResNet18的总层数统计方式遵循的是可学习参数层(learnable layer)的惯例计数法,具体包括:
- 所有带权重的卷积层(Conv2d)
- 所有带权重的全连接层(Linear)
- 不包括:激活函数(ReLU)、池化层(MaxPool2d)、批归一化层(BatchNorm2d)、Dropout、AdaptiveAvgPool2d等无参操作
我们来手动数一遍官方torchvision.models.resnet18()的结构(使用print(model)或list(model.children())可验证):
conv1: 7×7卷积 → 1层bn1: 批归一化 →不计数relu: 激活 →不计数maxpool: 池化 →不计数layer1(含2个BasicBlock):每个BasicBlock含2个3×3卷积 → 2×2 = 4层layer2(含2个BasicBlock):同理 → 4层layer3(含2个BasicBlock):同理 → 4层layer4(含2个BasicBlock):同理 → 4层avgpool: 自适应平均池化 →不计数fc: 全连接分类头 → 1层
加总:1 + 4 + 4 + 4 + 4 + 1 =18层。这个数字背后没有玄学,就是设计者按模块化方式堆叠BasicBlock后自然得出的结果。你可以把它理解成“18个可训练权重块”,而不是“18个计算步骤”。
提示:如果你用
torchsummary.summary(model, (3, 224, 224))查看,输出的“Layer (type)”列里标为“Conv2d”或“Linear”的行数,就是这18层的直观体现。其他如“BatchNorm2d”“ReLU”“MaxPool2d”都归类为“Non-trainable params”,它们不参与层数命名。
2.2 残差结构的本质:不是加法,是“恒等映射的工程实现”
残差连接(skip connection)常被简化为“x + F(x)”,但这句话容易让人误以为它只是数学上的加法运算。实际上,在ResNet18中,残差结构的核心价值在于为反向传播提供一条低阻抗、无衰减的梯度通路。我们来看一个BasicBlock内部的典型结构:
class BasicBlock(nn.Module): expansion = 1 def __init__(self, inplanes, planes, stride=1, downsample=None): super().__init__() self.conv1 = conv3x3(inplanes, planes, stride) self.bn1 = nn.BatchNorm2d(planes) self.relu = nn.ReLU(inplace=True) 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注意out += identity这一行。它的物理意义是:无论中间两个卷积层把特征图扭曲成什么样,梯度都可以直接从out回传到identity所指向的原始输入x上,完全绕过中间所有非线性变换和权重更新。这就像在一条崎岖山路上修了一条笔直的隧道——即使山路塌方(梯度消失),隧道依然畅通(梯度直达)。我在某实验室调试一个34层CNN时,去掉残差连接后,第20层之后的梯度norm直接掉到1e-8量级,loss曲线彻底躺平;加上后,整个网络的梯度norm分布非常均匀,从浅层到深层都在1e-2~1e-1区间波动。这不是魔法,是工程上对反向传播链式法则的精准干预。
2.3 为什么是BasicBlock,而不是Bottleneck?
ResNet家族有两个主力模块:ResNet18/34用BasicBlock,ResNet50/101/152用Bottleneck。区别在于:
| 特性 | BasicBlock(ResNet18) | Bottleneck(ResNet50) |
|---|---|---|
| 结构 | 3×3 → 3×3(两层卷积) | 1×1 → 3×3 → 1×1(三层卷积) |
| 参数量 | 小(适合小数据集、低算力) | 大(需ImageNet级数据支撑) |
| 计算量 | 低(FLOPs少) | 高(但1×1卷积大幅压缩通道) |
| 设计目标 | 解决深度网络训练稳定性 | 在更深网络中平衡精度与效率 |
ResNet18选BasicBlock,根本原因是它要成为“可解释性优先”的教学模型。两层3×3卷积的堆叠,你能清晰看到每层输出的H×W×C形状如何变化,能手动算出参数量(例如:第一个BasicBlock输入64通道,输出64通道,两个3×3卷积参数量 = 2 × (64×64×3×3) = 73,728),而Bottleneck里的1×1卷积会引入额外的通道压缩/扩展逻辑,对新手构成认知负担。我试过让零基础学员先学ResNet50,结果80%的人卡在“为什么第一个1×1卷积要把256通道压到64”这个问题上超过两小时——这不是他们的问题,是模型设计目标错位了。
3. PyTorch代码逐层解析:从nn.Module到可运行模型
3.1 官方实现 vs 手写实现:何时该抄,何时该造?
PyTorch官方通过torchvision.models.resnet18(pretrained=False)能一键加载结构,但仅靠调用API永远无法真正理解ResNet18。我的建议是:第一遍用官方API快速跑通流程,第二遍必须手写一个功能等价的Minimal版本。下面是我精简后的可运行手写实现(已去除所有非核心装饰,保留全部结构逻辑):
import torch import torch.nn as nn def conv3x3(in_planes, out_planes, stride=1, groups=1, dilation=1): """3x3 convolution with padding""" return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride, padding=dilation, groups=groups, bias=False, dilation=dilation) def conv1x1(in_planes, out_planes, stride=1): """1x1 convolution""" return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride, bias=False) class BasicBlock(nn.Module): expansion = 1 def __init__(self, inplanes, planes, stride=1, downsample=None, groups=1, base_width=64, dilation=1, norm_layer=None): super(BasicBlock, self).__init__() if norm_layer is None: norm_layer = nn.BatchNorm2d if groups != 1 or base_width != 64: raise ValueError('BasicBlock only supports groups=1 and base_width=64') if dilation > 1: raise NotImplementedError("Dilation > 1 not supported in BasicBlock") # Both self.conv1 and self.downsample layers downsample the input when stride != 1 self.conv1 = conv3x3(inplanes, planes, stride) self.bn1 = norm_layer(planes) self.relu = nn.ReLU(inplace=True) self.conv2 = conv3x3(planes, planes) self.bn2 = norm_layer(planes) self.downsample = downsample self.stride = stride 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 class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000, zero_init_residual=False, groups=1, width_per_group=64, replace_stride_with_dilation=None, norm_layer=None): super(ResNet, self).__init__() if norm_layer is None: norm_layer = nn.BatchNorm2d self._norm_layer = norm_layer self.inplanes = 64 self.dilation = 1 if replace_stride_with_dilation is None: replace_stride_with_dilation = [False, False, False] if len(replace_stride_with_dilation) != 3: raise ValueError("replace_stride_with_dilation should be None " "or a 3-element tuple, got {}".format(replace_stride_with_dilation)) self.groups = groups self.base_width = width_per_group self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = norm_layer(self.inplanes) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(block, 64, layers[0]) self.layer2 = self._make_layer(block, 128, layers[1], stride=2, dilate=replace_stride_with_dilation[0]) self.layer3 = self._make_layer(block, 256, layers[2], stride=2, dilate=replace_stride_with_dilation[1]) self.layer4 = self._make_layer(block, 512, layers[3], stride=2, dilate=replace_stride_with_dilation[2]) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes) for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) # Zero-initialize the last BN in each residual branch, # so that the residual branch starts with zeros, and each residual block behaves like an identity. # This improves the model by 0.2~0.3% according to https://arxiv.org/abs/1706.02677 if zero_init_residual: for m in self.modules(): if isinstance(m, Bottleneck): nn.init.constant_(m.bn3.weight, 0) elif isinstance(m, BasicBlock): nn.init.constant_(m.bn2.weight, 0) def _make_layer(self, block, planes, blocks, stride=1, dilate=False): norm_layer = self._norm_layer downsample = None previous_dilation = self.dilation if dilate: self.dilation *= stride stride = 1 if stride != 1 or self.inplanes != planes * block.expansion: downsample = nn.Sequential( conv1x1(self.inplanes, planes * block.expansion, stride), norm_layer(planes * block.expansion), ) layers = [] layers.append(block(self.inplanes, planes, stride, downsample, self.groups, self.base_width, previous_dilation, norm_layer)) self.inplanes = planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes, groups=self.groups, base_width=self.base_width, dilation=self.dilation, norm_layer=norm_layer)) return nn.Sequential(*layers) def _forward_impl(self, x): # See note [TorchScript: Sequential] 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 def forward(self, x): return self._forward_impl(x) def resnet18(**kwargs): r"""ResNet-18 model from `"Deep Residual Learning for Image Recognition" <https://arxiv.org/pdf/1512.03385.pdf>`_ """ return ResNet(BasicBlock, [2, 2, 2, 2], **kwargs)这段代码不是为了炫技,而是为了让你看清三个关键设计决策:
_make_layer函数的复用逻辑:[2,2,2,2]这个列表决定了每组block的数量。layer1调用一次_make_layer生成2个BasicBlock,layer2再调用一次生成另外2个——这种“工厂模式”避免了硬编码18个独立层,是模块化设计的精髓。downsample的触发条件:只有当stride != 1(空间下采样)或self.inplanes != planes * block.expansion(通道数不匹配)时,才需要插入1×1卷积+BN来对齐shortcut路径。这是残差结构能跨层连接的物理保障。初始化策略的深意:
nn.init.kaiming_normal_针对ReLU做了专门适配(fan_out模式),而zero_init_residual选项则把每个BasicBlock最后一个BN的weight设为0,让初始状态下的残差分支完全“关闭”,整个block退化为纯恒等映射——这极大提升了训练初期的稳定性,实测在CIFAR-10上能让收敛速度提升约15%。
3.2 形状追踪实战:输入一张224×224 RGB图,每层输出是什么?
光看代码不够,必须动手算一遍shape变化。我们以标准输入x = torch.randn(1, 3, 224, 224)为例,逐层推导(括号内为H×W×C):
| 层名 | 操作 | 输入shape | 输出shape | 关键说明 |
|---|---|---|---|---|
conv1 | 7×7, stride=2, pad=3 | (1,3,224,224) | (1,64,112,112) | H/W = (224+2×3−7)/2+1 = 112 |
bn1+relu | 无shape变化 | (1,64,112,112) | (1,64,112,112) | BN不改变尺寸 |
maxpool | 3×3, stride=2, pad=1 | (1,64,112,112) | (1,64,56,56) | H/W = (112+2×1−3)/2+1 = 56 |
layer1[0] | 第一个BasicBlock,stride=1 | (1,64,56,56) | (1,64,56,56) | 通道不变,无下采样 |
layer1[1] | 第二个BasicBlock,stride=1 | (1,64,56,56) | (1,64,56,56) | 同上 |
layer2[0] | 第一个BasicBlock,stride=2 | (1,64,56,56) | (1,128,28,28) | H/W减半,通道翻倍 |
layer2[1] | 第二个BasicBlock,stride=1 | (1,128,28,28) | (1,128,28,28) | 保持尺寸 |
layer3[0] | stride=2 | (1,128,28,28) | (1,256,14,14) | 再次减半 |
layer3[1] | stride=1 | (1,256,14,14) | (1,256,14,14) | — |
layer4[0] | stride=2 | (1,256,14,14) | (1,512,7,7) | 最后一次空间压缩 |
layer4[1] | stride=1 | (1,512,7,7) | (1,512,7,7) | — |
avgpool | Adaptive (1,1) | (1,512,7,7) | (1,512,1,1) | 全局平均池化 |
flatten | 展平 | (1,512,1,1) | (1,512) | 为全连接做准备 |
fc | Linear(512→1000) | (1,512) | (1,1000) | 分类头输出 |
这个表格的价值在于:它把抽象的“网络深度”转化成了可触摸的“内存占用”。例如,layer4输出是(1,512,7,7),即单张图在GPU上占512×7×7=25,088个float32数值,约100KB;而conv1输出是(1,64,112,112)=802,816个数值,约3.2MB。这意味着你在调试时如果发现OOM(显存溢出),首先要盯住layer1和layer2的输出尺寸——它们才是显存大户。我在某公司部署边缘设备时,就是靠这张表把ResNet18的输入从224×224裁剪到160×160,使layer2输出从(1,128,28,28)变成(1,128,20,20),显存直接降了35%,精度只掉0.4%。
4. 可视化与调试技巧:让看不见的特征图“显形”
4.1 用Hook机制提取任意层输出:不只是为了画图
PyTorch的register_forward_hook是调试网络结构的利器,但它常被误用为“画特征图”的工具。其实它的核心价值在于验证你对网络数据流的理解是否正确。下面是一个轻量级hook注册脚本,专为ResNet18设计:
# 定义一个全局字典存储各层输出 activation = {} def get_activation(name): def hook(model, input, output): activation[name] = output.detach() return hook # 加载模型 model = resnet18(num_classes=10) # 改为CIFAR-10的10类 model.eval() # 注册hook到关键节点 model.layer1[0].conv1.register_forward_hook(get_activation('layer1_conv1')) model.layer2[0].conv1.register_forward_hook(get_activation('layer2_conv1')) model.layer3[0].conv1.register_forward_hook(get_activation('layer3_conv1')) model.layer4[0].conv1.register_forward_hook(get_activation('layer4_conv1')) # 前向传播 x = torch.randn(1, 3, 224, 224) output = model(x) # 检查各层输出shape是否符合预期 for name, act in activation.items(): print(f"{name}: {act.shape}")运行后你会看到:
layer1_conv1: torch.Size([1, 64, 56, 56]) layer2_conv1: torch.Size([1, 128, 28, 28]) layer3_conv1: torch.Size([1, 256, 14, 14]) layer4_conv1: torch.Size([1, 512, 7, 7])这比看文档更可靠。有一次我修改了layer2的stride参数,但忘了同步调整downsample逻辑,hook输出显示layer2_conv1的shape是(1,128,56,56)——明显没下采样!立刻定位到_make_layer里stride传参错误。这种问题用loss曲线根本发现不了,因为网络还在训,只是效率极低。
4.2 特征图可视化三原则:不炫技,只诊断
很多人一上来就用torchvision.utils.make_grid把64个通道全画出来,结果密密麻麻全是噪点。真正有效的可视化要遵循三个原则:
聚焦“变化”而非“存在”:比较同一张图经过不同block前后的差异。例如,取
layer1[0]的输入x和输出out,计算(out - x).abs().mean(dim=1, keepdim=True),得到一个单通道的“残差强度图”。如果某区域值接近0,说明该区域特征几乎没变——这就是残差连接在起作用。通道聚合要合理:不要直接取
out.mean(1)(平均所有通道),因为不同通道检测不同纹理。改用out.max(1).values(取最强响应通道),更能反映模型关注点。尺寸还原要匹配感受野:
layer4输出是7×7,但它的感受野覆盖整张224×224图。可视化时应把7×7热力图双线性上采样到224×224,再叠加到原图上,才能看出模型到底在看哪里。
我用这个方法分析过ResNet18在CIFAR-10上的行为:发现layer1的残差强度图集中在边缘,layer2开始出现局部纹理响应,layer3和layer4的响应则高度集中在物体主体区域——这印证了“浅层学边缘、中层学纹理、深层学语义”的经典结论,而且是用你自己的模型、自己的数据验证的。
4.3 梯度检查:确认残差连接真的在工作
残差结构的灵魂不在前向,而在反向。一个简单却致命的错误是:在out += identity后忘记调用relu,导致梯度在负值区域被截断。用以下代码可快速验证:
model = resnet18(num_classes=10) x = torch.randn(1, 3, 224, 224, requires_grad=True) y = model(x) loss = y.sum() # 构造一个标量loss loss.backward() # 检查layer1第一个block的梯度 block0 = model.layer1[0] print("conv1 grad norm:", block0.conv1.weight.grad.norm().item()) print("conv2 grad norm:", block0.conv2.weight.grad.norm().item()) print("input grad norm:", x.grad.norm().item())正常情况下,conv1和conv2的梯度norm应在同一数量级(如1e-2),且x.grad.norm显著大于任一卷积层梯度(因为梯度从顶层一路回传,沿途累加)。如果conv2.grad.norm远小于conv1,或者x.grad.norm异常小,大概率是残差路径被意外中断(比如downsample返回None但输入输出不匹配,导致out += identity报错后被静默忽略)。
5. 常见问题与避坑指南:那些文档里不会写的细节
5.1 “RuntimeError: Given groups=1, weight of size [64, 3, 7, 7], expected input[1, 64, 112, 112] to have 3 channels, but got 64 channels instead”——这是什么鬼?
这是PyTorch新手最高频的报错之一,表面看是通道数不匹配,根源却在模型和数据的预处理不一致。ResNet18的conv1期待输入是(3, H, W),但你的数据加载器可能做了如下操作:
- 错误1:
transforms.Grayscale()把RGB转成单通道,再transforms.ToTensor()得到(1, H, W),但没补零到3通道; - 错误2:
transforms.Normalize()的mean/std用了ImageNet的[0.485,0.456,0.406]和[0.229,0.224,0.225],但你的数据是[0,1]范围,而模型权重是按[-2.12,2.64]范围初始化的,导致数值溢出; - 错误3:
transforms.Resize(256)后接transforms.CenterCrop(224),但你的图片本身只有128×128,crop后变成全黑。
解决方案不是改模型,而是统一数据管道:
# 正确的CIFAR-10预处理(不用ImageNet stats!) transform_train = transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) # CIFAR-10均值std ]) # 正确的ImageNet风格预处理(用于迁移学习) transform_imagenet = 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]) ])注意:
transforms.Normalize的std值必须是标准差,不是方差。我见过有人把std=[0.052, 0.051, 0.051](方差)直接填进去,结果模型输入全变成nan——因为除以了一个极小的数。
5.2 训练时loss震荡剧烈,accuracy上不去?先检查这三处
ResNet18以“稳”著称,如果出现剧烈震荡,90%是工程配置问题:
学习率过大:ResNet18在ImageNet上常用初始lr=0.1,但CIFAR-10数据量小,用0.1会直接冲飞。实测最佳是0.01~0.05,配合StepLR在60/120轮衰减。
BatchNorm统计量未冻结:做迁移学习时,如果用
model.eval(),BN层会用训练时保存的running_mean/var,但如果你在微调(finetune)阶段仍用model.train(),BN会继续更新统计量,导致验证集表现忽高忽低。正确做法是:# 冻结BN统计量,只训练权重 for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() # 不更新running_mean/varDataLoader的num_workers设置不当:设为0时单进程加载,安全但慢;设为>0时多进程,但若worker进程崩溃(如内存不足),DataLoader会静默跳过batch,导致每个epoch实际训练样本数减少,loss曲线出现阶梯式下跌。建议始终设
pin_memory=True并监控GPU memory usage。
5.3 如何把ResNet18改成二分类?别只改fc层!
很多教程说“把fc = nn.Linear(512, 2)就行”,这没错,但忽略了两个关键点:
损失函数必须匹配:
nn.CrossEntropyLoss()内部已包含Softmax,输出直接接logits;而nn.BCEWithLogitsLoss()要求输出是单通道+sigmoid,此时应设fc = nn.Linear(512, 1)。类别不平衡必须处理:二分类常面临正负样本1:10甚至更悬殊。不能只靠
weight参数,要在DataLoader层面做WeightedRandomSampler,确保每个batch内正负样本比例均衡。
# 计算每个样本的权重 class_counts = [len(pos_indices), len(neg_indices)] weights = [1.0/class_counts[0]] * len(pos_indices) + [1.0/class_counts[1]] * len(neg_indices) sampler = WeightedRandomSampler(weights, len(weights)) train_loader = DataLoader(dataset, batch_size=32, sampler=sampler)我在某医疗影像项目中,把ResNet18用于肺结节良恶性二分类,原始acc 72%,加上sampler后提升到89%——这比调参带来的提升大得多。
6. 实战延伸:从ResNet18到你的第一个改进版
6.1 轻量级改造1:SE Block嵌入(提升2% accuracy)
Squeeze-and-Excitation(SE)模块是ResNet18最易集成的改进,只需在每个BasicBlock末尾插入几行代码:
class SELayer(nn.Module): def __init__(self, channel, reduction=16): super(SELayer, self).__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(channel, channel // reduction, bias=False), nn.ReLU(inplace=True), nn.Linear(channel // reduction, channel, bias=False), nn.Sigmoid() ) def forward(self, x): b, c, _, _ = x.size() y = self.avg_pool(x).view(b, c) y = self.fc(y).view(b, c, 1, 1) return x * y.expand_as(x) # 在BasicBlock.forward末尾添加: # out = self.se(out) # 需在__init__中定义self.se = SELayer(planes)SE Block通过全局池化+MLP学习每个通道的重要性权重,计算开销极小(<1% FLOPs),但在CIFAR-10上实测能将top-1 acc从92.3%提升到94.1%。关键是它不改变任何shape,无需调整下游代码。
6.2 轻量级改造2:Stochastic Depth(提升训练稳定性)
Stochastic Depth是一种训练时随机“删除”某些block的技术,相当于给深层网络加了个dropout。对ResNet18,只需在_make_layer中修改:
class StochasticDepthBasicBlock(BasicBlock): def __init__(self, *args, survival_prob=0.8, **kwargs): super().__init__(*args, **kwargs) self.survival_prob = survival_prob def forward(self, x): if self.training and torch.rand(1) > self.survival_prob: return x # 直接跳过整个block return super().forward(x)设置survival_prob=0.8意味着训练时20%的block被跳过,但推理时全部启用。这迫使网络学会“冗余表达”,实测在小数据集上能降低过拟合,val loss波动减少约40%。
6.3 为什么我不推荐你立刻上Attention?
看到ViT火了,很多新手想给ResNet18加Self-Attention。但要注意:ResNet18的layer4输出是7×7×512,展平后序列长度L=49,Attention的计算复杂度是O(L²),即