1. 为什么还要手写一遍ResNet
如果你翻过PyTorch官方仓库里的torchvision.models.resnet,第一反应大概率是:这东西已经写好了,直接resnet50(pretrained=True)一行搞定,为什么还要自己动手实现一遍?我当初也是这么想的,直到有一次需要把ResNet的BasicBlock改成带通道注意力的变体,结果对着官方源码里层层嵌套的_make_layer和Bottleneck看了半天才理清结构关系。那次之后我就意识到,能不能手写一遍ResNet,其实是区分"会调库"和"懂网络"的一条分水岭。
ResNet(残差网络)在2015年被提出时,核心要解决的问题非常朴素:网络越深,训练误差反而越大。注意,这不是过拟合,因为训练集上的误差都降不下去。这个现象叫退化问题(degradation problem)。残差连接(skip connection)的引入让梯度可以绕过若干层直接回传,使得训练上百层甚至上千层的网络成为可能。今天你看到的几乎所有现代骨干网络,多多少少都带着残差结构的影子。
这篇文章面向的读者是:已经会用PyTorch搭简单CNN,但想真正吃透ResNet结构细节的人;或者需要魔改ResNet做自己任务,却被官方源码的抽象层级绕晕的人。我会从残差块的设计动机讲起,一步步把BasicBlock、Bottleneck、不同深度的配置、下采样处理、权重初始化全部拆开,最后给出一份可以直接跑通的完整实现,并附上我在实际训练中踩过的坑。全程不依赖torchvision.models,纯手写。
提示:本文假设你已经了解卷积、批归一化(BatchNorm)、ReLU这些基础组件,不会从头解释卷积怎么算。如果你对这些还不熟,建议先补一下基础再回来。
2. 残差块到底在解决什么问题
2.1 退化现象:深网络的"反常"表现
在ResNet出现之前,大家的直觉是网络越深表达能力越强。但实验发现,一个56层的普通卷积网络,在训练集和测试集上的表现都劣于20层的网络。这不是过拟合导致的,因为过拟合的表现应该是训练误差低、测试误差高。退化问题的本质是:深层网络难以优化,而不是深层网络表达能力不够。
为什么会难优化?一种解释是梯度在反向传播过程中经过太多层连乘,要么爆炸要么消失。批归一化缓解了一部分,但没有根治。另一种更本质的解释是:如果一个浅层网络已经能学到不错的特征,那么深层网络至少应该能学到一个"恒等映射"——即多出来的层什么都不做,直接把手里的特征原样传下去,这样深层网络的表现不应该比浅层差。但实验表明,让一堆非线性层去拟合恒等映射,反而很难。
2.2 残差连接的核心思想
ResNet的解法很巧妙:不让网络去直接学目标映射H(x),而是让它学残差F(x) = H(x) - x,最终输出是F(x) + x。如果恒等映射是最优的,网络只需要把F(x)的权重推向0就行,这比让一堆非线性层硬拟合恒等映射容易得多。
用生活化的类比:你要从家走到一个陌生地点,直接记路线很难。但如果有人告诉你"从家出发,先直走200米,再左转"——也就是告诉你相对于当前位置的偏移量——就好记多了。残差连接给网络提供的就是这个"偏移量"的参照系。
数学上,前向传播变成 y = F(x, {Wi}) + x。反向传播时,梯度∂y/∂x = ∂F/∂x + 1,那个"+1"保证了梯度至少能以恒等的方式回传,不会因为连乘而消失。这就是残差连接让超深网络可训练的根本原因。
2.3 两种残差块:BasicBlock与Bottleneck
ResNet里实际用到两种残差块,理解它们的区别是手写实现的关键。
BasicBlock用于较浅的网络(ResNet-18/34),结构是两个3×3卷积串联,每个卷积后面跟BatchNorm和ReLU,最后把输入加到输出上再过一次ReLU。它的特点是结构简单,参数量适中。
Bottleneck用于较深的网络(ResNet-50/101/152),结构是1×1 → 3×3 → 1×1的三层组合。第一个1×1负责降维(比如从256通道降到64),第二个3×3在低维空间做卷积,第三个1×1负责升维(从64回到256)。这样做的好处是:虽然层数多了,但3×3卷积是在低维通道上做的,整体参数量和计算量反而比直接用两个3×3卷积要小。
| 对比项 | BasicBlock | Bottleneck |
|---|---|---|
| 适用网络 | ResNet-18/34 | ResNet-50/101/152 |
| 卷积层数 | 2层(3×3 + 3×3) | 3层(1×1 + 3×3 + 1×1) |
| 中间通道变化 | 不变 | 先降4倍再升回 |
| 参数量(以256通道为例) | 约59万 | 约7万 |
| 设计目的 | 结构简洁 | 减少深层网络计算量 |
这个表格里的参数量差异值得算一下。BasicBlock两个3×3卷积,输入输出都是256通道,每个卷积参数量是3×3×256×256 ≈ 59万,两个就是118万。Bottleneck的1×1降维是1×1×256×64 ≈ 1.6万,3×3是3×3×64×64 ≈ 3.7万,1×1升维是1×1×64×256 ≈ 1.6万,加起来约7万。差了将近17倍。这就是为什么ResNet-50虽然比ResNet-34深得多,但参数量并没有爆炸式增长。
3. 从零搭建ResNet的完整实现
3.1 环境准备与项目结构
先把环境理清楚。我用的是PyTorch 1.13以上版本,Python 3.9。理论上PyTorch 1.7+都能跑,因为用到的都是很基础的API。不需要额外装什么特殊库,torch和torch.nn就够了。
项目结构建议这样组织,方便后续魔改:
resnet_impl/ ├── model.py # ResNet网络定义 ├── train.py # 训练脚本 └── config.py # 超参数配置我见过不少人把所有代码塞进一个文件,跑通没问题,但一旦要改结构或者做对比实验就乱了。分开写,model.py只负责网络结构,train.py只负责训练流程,这样你想换数据集或者换优化器的时候,不用动网络代码。
3.2 BasicBlock的实现细节
先看BasicBlock。它的核心是两个3×3卷积,但有几个容易忽略的细节。
import torch import torch.nn as nn class BasicBlock(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) 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几个关键点解释一下。第一,bias=False。因为卷积后面紧跟着BatchNorm,BN会减去均值,卷积的偏置项会被抵消掉,设成False既省参数又不影响效果。这是ResNet实现里的标准做法,但很多新手会忽略。
第二,stride参数只加在第一个卷积上。当需要下采样时(特征图尺寸减半),stride设为2,第二个卷积的stride始终是1。这样设计的原因是:下采样只需要发生一次,放在第一个卷积做就行。
第三,downsample的作用。当输入输出通道数不一致,或者特征图尺寸发生变化时,恒等映射的x没法直接和F(x)相加,需要一个1×1卷积把x的维度调整到和F(x)一致。这个1×1卷积后面也跟BN,但不跟ReLU,因为它只是做维度对齐,不是做非线性变换。
第四,ReLU的位置。注意最后的ReLU是在相加之后才做的,不是每个卷积后都做。顺序是:conv → bn → relu → conv → bn → (加identity) → relu。这个顺序如果搞错,比如在相加之前就做了ReLU,会导致负值被截断,残差连接的效果会打折扣。
3.3 Bottleneck的实现与通道数计算
Bottleneck比BasicBlock多一层,但逻辑是类似的。关键是要理解那个expansion=4的含义。
class Bottleneck(nn.Module): expansion = 4 def __init__(self, in_channels, out_channels, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels * self.expansion) self.relu = nn.ReLU(inplace=True) 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 outexpansion=4的意思是:Bottleneck的输出通道数是中间通道数的4倍。比如传入out_channels=64,中间三层卷积的通道分别是64、64、256。这个4倍关系是ResNet论文里定的,目的是让Bottleneck的输出通道和BasicBlock在相同配置下保持一致,方便对比。
这里有个容易搞混的地方:_make_layer里传的out_channels参数,对BasicBlock来说是最终输出通道,对Bottleneck来说是中间通道(最终要乘4)。我在第一次写的时候就在这里翻过车,导致ResNet-50的通道数全错了,训练时维度对不上直接报错。
3.4 下采样模块的正确写法
下采样(downsample)是残差连接里最容易被忽视但最容易出错的部分。它的作用是当输入输出维度不匹配时,用1×1卷积加BN把identity调整到正确的形状。
def _make_downsample(self, in_channels, out_channels, stride): return nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) )注意这里的stride要和主分支第一个卷积的stride保持一致。如果主分支用stride=2做了下采样,identity也必须用stride=2把尺寸降下来,否则相加时特征图大小对不上。这个细节在官方源码里是通过传参保证的,手写的时候要特别注意。
还有一个坑:下采样模块里不能加ReLU。我见过有人在downsample后面习惯性加了ReLU,结果训练效果明显变差。原因是identity分支的作用是保留原始信息,加ReLU会把负值截断,破坏了恒等映射的完整性。
3.5 组装完整的ResNet类
有了BasicBlock和Bottleneck,就可以组装完整的ResNet了。核心是_make_layer方法,它负责堆叠多个残差块。
class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000): super().__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) 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) self.layer3 = self._make_layer(block, 256, layers[2], stride=2) self.layer4 = self._make_layer(block, 512, layers[3], stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes) self._initialize_weights() def _make_layer(self, block, out_channels, blocks, stride=1): 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_size=1, stride=stride, bias=False), 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) 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_make_layer的逻辑值得细说。第一个残差块可能需要下采样(当stride≠1或通道数变化时),后续的块就不需要了,因为通道数和尺寸已经对齐。self.in_channels在每层结束后更新,记录当前的特征通道数,供下一层判断是否需要downsample。
_initialize_weights是权重初始化,这个后面单独讲,因为它对训练稳定性影响很大。
3.6 不同深度ResNet的配置
有了上面的类,定义不同深度就是传不同的layers参数:
def resnet18(num_classes=1000): return ResNet(BasicBlock, [2, 2, 2, 2], num_classes) def resnet34(num_classes=1000): return ResNet(BasicBlock, [3, 4, 6, 3], num_classes) def resnet50(num_classes=1000): return ResNet(Bottleneck, [3, 4, 6, 3], num_classes) def resnet101(num_classes=1000): return ResNet(Bottleneck, [3, 4, 23, 3], num_classes) def resnet152(num_classes=1000): return ResNet(Bottleneck, [3, 8, 36, 3], num_classes)注意ResNet-18和ResNet-34用的是BasicBlock,ResNet-50及以上用的是Bottleneck。虽然ResNet-34和ResNet-50的layers参数看起来一样都是[3,4,6,3],但因为block类型不同,实际层数和参数量差别很大。
4. 训练前的关键配置与权重初始化
4.1 权重初始化的门道
ResNet论文里特别强调了权重初始化的重要性。如果直接用PyTorch默认的初始化,深层ResNet训练初期可能会不稳定。论文里的做法是:
def _initialize_weights(self): 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.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0)Kaiming初始化(也叫He初始化)是专门为ReLU激活函数设计的。它的核心思想是让每一层输出的方差保持一致,避免信号在深层网络中逐层放大或衰减。mode='fan_out'表示用输出通道数来计算方差缩放因子,这是卷积层的标准做法。
BatchNorm的权重初始化为1,偏置为0,这是让BN在训练初期相当于恒等变换,不会引入额外的扰动。
还有一个细节:ResNet论文里提到,他们把每个残差块的最后一个BN的权重初始化为0。这样在训练开始时,整个残差块的输出就是0,相当于恒等映射,网络从一个"浅层网络"开始训练,逐步学习残差。这个技巧对超深网络(比如ResNet-152)特别有用。不过在实践中,用Kaiming初始化加上BN,即使不做这个零初始化,大多数情况也能正常训练。如果你训练ResNet-152发现loss不下降,可以试试这个技巧。
4.2 数据预处理与增强策略
ResNet原论文用的输入是224×224。训练时的标准预处理流程是:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])那几个mean和std是ImageNet数据集的统计值,如果你在自己的数据集上训练,最好重新算一下。用ImageNet的统计值不是不能用,但归一化不准确会影响收敛速度。
RandomResizedCrop和RandomHorizontalFlip是标准增强。如果你数据量少,可以加ColorJitter做颜色扰动,或者用RandAugment这类更强的增强策略。但要注意,增强太强可能导致欠拟合,特别是小数据集上。
4.3 学习率与优化器选择
ResNet训练的标准配置是SGD加动量,初始学习率0.1,weight decay 1e-4,动量0.9。学习率用step decay,每30个epoch降10倍。这个配置在ImageNet上经过验证,但在小数据集上需要调整。
我个人的经验是:如果你在CIFAR-10这种小图上训练,把第一层7×7卷积改成3×3,stride改成1,去掉maxpool,效果会更好。因为CIFAR的图只有32×32,用7×7 stride=2的卷积加maxpool,特征图很快就降到很小了,信息损失太多。
# CIFAR版本的stem层 self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) # 去掉maxpool学习率方面,如果你用Adam,初始学习率设1e-3就行。但SGD在ResNet上通常能训出更好的最终精度,只是收敛慢一些。这个取舍看你的时间预算。
5. 实测中踩过的坑与排查思路
5.1 维度不匹配:最常见的报错
手写ResNet最常遇到的报错就是维度不匹配,通常发生在残差相加那一步。报错信息类似RuntimeError: The size of tensor a (64) must match the size of tensor b (256)。
排查思路是这样的:先看报错发生在哪个layer。如果是layer2的第一个block,那大概率是downsample的通道数算错了。检查out_channels * block.expansion有没有正确计算。对Bottleneck来说,如果传的out_channels是128,那输出应该是512,downsample的输出通道也必须是512。
另一个常见原因是stride不一致。主分支用了stride=2,但downsample忘了设stride=2,导致特征图尺寸对不上。这种报错信息会是The size of tensor a (14) must match the size of tensor b (28),数字是特征图边长。
5.2 训练loss不下降的几种可能
如果你确认网络结构没问题,但训练loss就是不动,按这个顺序排查:
第一,检查学习率。太大loss会震荡甚至爆炸,太小loss下降极慢。可以先跑几个iteration,打印loss值,如果loss在几个epoch内完全不变,大概率是学习率太小或者梯度没传过去。
第二,检查数据预处理。我遇到过一次,Normalize的mean和std写反了,导致输入数据分布完全错误,loss卡在2.3左右不动(10分类的随机猜测水平)。这种问题很隐蔽,因为代码不报错。
第三,检查BN层。如果batch size太小(比如小于8),BN的统计量估计不准,训练会不稳定。这种情况可以试试GroupNorm或者SyncBN。
第四,检查权重初始化。如果用了默认初始化而不是Kaiming,深层网络可能训不起来。特别是ResNet-101/152这种超深网络,初始化不对的话loss会一直卡在高位。
5.3 显存不够时的取舍
ResNet-50在224×224输入、batch size 32的情况下,大约需要8GB显存。如果你显存不够,有几个选择:
- 减小batch size,但要注意BN在小batch下效果会变差
- 用混合精度训练(AMP),能省大约40%显存
- 用梯度累积,模拟大batch的效果
- 减小输入尺寸,比如从224降到128
混合精度训练现在很成熟了,PyTorch的torch.cuda.amp几行代码就能用:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, labels in dataloader: with autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()实测下来,混合精度对最终精度的影响很小(通常0.1%以内),但训练速度能快30%以上,显存省40%左右。唯一需要注意的是,某些操作在FP16下可能溢出,GradScaler会自动处理,但你要留意有没有NaN出现。
5.4 迁移学习时的层冻结策略
如果你用预训练的ResNet做迁移学习,冻结策略很重要。我的经验是:
- 如果目标数据集很小(几千张),冻结前面所有层,只训练最后的fc层
- 如果数据量中等(几万张),冻结layer1和layer2,训练layer3、layer4和fc
- 如果数据量充足,全部解冻微调,但学习率要设小一些(比如1e-4)
冻结的实现很简单:
for name, param in model.named_parameters(): if 'layer1' in name or 'layer2' in name: param.requires_grad = False但要注意,如果你冻结了某些层,这些层里的BN仍然会在训练模式下更新running_mean和running_var。如果你希望BN也完全冻结,需要显式调用model.eval()或者把BN设成eval模式。这个细节很多人会忽略,导致冻结效果不如预期。
6. 结构变体与扩展思路
6.1 把BasicBlock改成预激活版本
ResNet v2(也叫Pre-activation ResNet)把BN和ReLU放到了卷积前面,结构变成BN → ReLU → Conv。这样做的好处是梯度可以直接通过skip connection回传,没有任何阻碍。实现上只需要调整forward的顺序:
def forward(self, x): identity = x out = self.bn1(x) out = self.relu(out) out = self.conv1(out) out = self.bn2(out) out = self.relu(out) out = self.conv2(out) if self.downsample is not None: identity = self.downsample(x) out += identity return out注意最后的ReLU去掉了,因为下一个block的开头就有BN+ReLU。这个改动看起来小,但在超深网络上(比如ResNet-1001)效果很明显。
6.2 加入通道注意力机制
如果你想把ResNet改成SE-ResNet,只需要在每个残差块的最后加一个SE模块:
class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), 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 * ySE模块的作用是学习每个通道的重要性权重,然后对通道做加权。在残差块里,SE通常加在最后一个卷积之后、相加之前。实测在ImageNet上,SE-ResNet-50比ResNet-50的top-1精度高1%左右,代价是参数量和计算量增加约10%。
6.3 替换下采样方式
标准的ResNet下采样用的是stride=2的1×1卷积。有些变体用平均池化加1×1卷积,或者用stride=2的3×3卷积。不同下采样方式对精度有细微影响,但差异通常在0.5%以内。如果你在做对比实验,建议保持和基线一致,不要在这个地方引入额外变量。
7. 完整训练脚本与验证
7.1 训练循环的骨架
把上面所有部分串起来,一个完整的训练脚本大概长这样:
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in dataloader: 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 = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return running_loss / total, correct / total def evaluate(model, dataloader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in dataloader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return running_loss / total, correct / total训练循环本身不复杂,但有几个实践细节:model.train()和model.eval()必须正确切换,否则BN和Dropout的行为会不对。验证时用torch.no_grad()关掉梯度计算,能省显存加速。
7.2 验证实现正确性的方法
写完网络后,怎么确认自己实现的是对的?最直接的方法是和官方实现对比输出。用相同的输入,分别过你的模型和torchvision.models.resnet50,看输出是否一致(权重需要手动拷贝或者都用随机初始化但设相同种子)。
另一个方法是检查参数量。ResNet-50的参数量应该是25.5M左右,ResNet-18是11.7M。如果你算出来的参数量差很多,说明结构有问题。可以用这个快速检查:
def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad) model = resnet50() print(f"ResNet-50 params: {count_parameters(model) / 1e6:.2f}M") # 应该输出约25.56M如果参数量对得上,结构基本就没大问题了。再跑几个epoch看loss能不能正常下降,就能确认实现是正确的。
7.3 一些实用的训练技巧
最后分享几个我在训练ResNet时总结的技巧。第一,用torch.backends.cudnn.benchmark = True可以让cuDNN自动选择最快的卷积算法,输入尺寸固定时能提速10%到20%。第二,如果训练数据不大,用persistent_workers=True和pin_memory=True能加快数据加载。第三,保存模型时同时保存optimizer的状态,方便断点续训。第四,用TensorBoard或者WandB记录训练曲线,比打印到终端直观得多。
关于学习率调度,除了step decay,CosineAnnealing现在也很流行。它让学习率按余弦曲线平滑下降,通常能比step decay高0.5%左右的精度。实现上PyTorch有现成的CosineAnnealingLR,直接用就行。
我在实际项目里发现,ResNet对weight decay比较敏感。1e-4是标准值,但如果你的数据集和ImageNet差异很大,可以试试5e-4或者1e-5,看验证集表现调整。另外,如果你用了数据增强,训练epoch数要相应增加,否则模型还没收敛就停了。ImageNet上标准是90到120个epoch,小数据集上通常50到100个epoch就够了。