1. 从“堆层数”到“残差学习”:ResNet到底在解决什么问题
如果你在2015年之前尝试过训练一个超过20层的卷积神经网络,大概率会碰到一个非常反直觉的现象:训练误差不降反升。这不是过拟合——过拟合的表现是训练误差持续下降而验证误差上升,但当时的情况是训练集上的误差本身就比浅层网络更高。换句话说,网络更深了,连“死记硬背”训练数据的能力都变差了。这个问题在当时困扰了很多人,因为按照直觉,更深的网络表达能力应该更强才对。
ResNet(深度残差网络)就是冲着这个问题去的。它的核心贡献不是“发明了更深的网络”,而是提出了一种让深层网络变得可训练的结构设计——残差连接。这个设计后来几乎成了深度学习领域的标配,不管你是做图像分类、目标检测、语义分割,还是做自然语言处理、语音识别,残差连接无处不在。
这篇文章适合谁看?如果你正在学深度学习,已经了解卷积神经网络的基本结构(卷积层、池化层、全连接层),但对ResNet的理解还停留在“它很深、它很强”这个层面,那这篇内容就是为你准备的。我会从“为什么深层网络难训练”这个根问题讲起,把残差块的设计逻辑、两种残差块的区别、网络结构的演进、以及实际训练中的关键细节都拆开讲清楚。读完之后,你不仅能理解ResNet的设计精髓,还能自己动手搭一个残差网络,并且知道训练时该注意什么。
关键词方面,这篇文章会围绕深度残差网络、ResNet、残差块、跳跃连接、退化问题、Batch Normalization、瓶颈结构、迁移学习这些核心概念展开,每个概念都会结合实际操作和代码来讲解,不做纯理论堆砌。
2. 退化问题的本质:为什么加深网络反而更难训练
2.1 退化问题不是过拟合,也不是梯度消失那么简单
很多人第一次接触ResNet时,会把它解决的问题简单归结为“梯度消失”。这个理解不算错,但不够准确。梯度消失确实是深层网络的经典难题——反向传播时梯度逐层相乘,如果每层的梯度都小于1,经过几十层之后梯度就趋近于零,浅层的参数几乎不更新。但ResNet论文中揭示的“退化问题”比梯度消失更微妙。
退化问题的核心现象是:一个56层的网络,在训练集上的表现竟然不如一个20层的网络。注意,这里说的不是验证集,是训练集。这意味着深层网络连拟合训练数据的能力都退化了。如果仅仅是梯度消失导致的参数不更新,那至少浅层网络能学到的特征深层网络也应该能学到——毕竟深层网络可以看作是在浅层网络后面叠加了更多层,最差的情况是后面那些层学成恒等映射(输入等于输出),这样深层网络的性能至少不应该比浅层差。
但实际训练中,网络学不会恒等映射。这就是退化问题的本质:深层网络难以学习恒等映射。随机初始化的额外层会把原本学好的特征破坏掉,而让这些层学到“什么都不做”这件事,对于非线性层堆叠的结构来说并不容易。
2.2 恒等映射为什么这么难学
你可以这样理解:假设有一个20层的网络已经训练得不错了,现在你想把它加深到56层。理想情况下,新增的36层应该学会恒等映射——输入什么就输出什么,这样56层网络的性能至少和20层一样。但问题是,这36层里每一层都包含卷积、批归一化、激活函数,这些操作叠加起来,要让最终输出严格等于输入,需要权重和偏置精确配合。随机梯度下降在这么大的参数空间里搜索,很难恰好找到这组“恒等映射”的解。
打个比方:你让一个人闭着眼睛在一张纸上画一条直线,要求他画完后笔尖回到起点。如果他只画一小段(浅层网络),很容易控制;但如果让他画很长一段再回来(深层网络),偏差就会累积,很难精确回到原点。恒等映射对于多层非线性变换的堆叠来说,就是那个“精确回到原点”的要求。
ResNet的解决方案非常巧妙:既然让网络学会“恒等映射”很难,那我直接把输入加到输出上,让网络只需要学习“残差”——也就是输入和期望输出之间的差值。如果最优的输出就是输入本身,那网络只需要把残差学成零就行了,这比学一个恒等映射简单得多。
2.3 残差连接的数学表达与直觉理解
残差块的基本形式可以写成:
y = F(x, {Wi}) + x其中x是输入,F(x, {Wi})是残差函数(也就是需要学习的部分),y是输出。当F(x) = 0时,y = x,这就是恒等映射。网络只需要把F(x)推向零,而不是从头学习一个恒等映射。
这个设计的精妙之处在于:残差连接提供了一条“高速公路”,让梯度可以直接从深层回传到浅层。反向传播时,梯度经过残差连接时相当于乘以1,不会像经过多层非线性变换那样逐层衰减。这就同时缓解了梯度消失和退化问题。
从另一个角度看,残差连接让网络变成了一个“集成模型”——你可以把ResNet看作是多条不同深度路径的集成,浅层路径负责捕捉低级特征,深层路径负责高级语义,残差连接让信息可以在不同深度之间自由流动。
3. 残差块的两副面孔:基础块与瓶颈块的设计取舍
3.1 基础残差块:两层3x3卷积的经典组合
ResNet-18和ResNet-34使用的是基础残差块(Basic Block),结构很简单:两个3x3卷积层,每个卷积后面接批归一化和ReLU激活,最后把输入加到输出上再过一次ReLU。
具体流程是这样的:
- 输入x经过第一个3x3卷积,输出通道数不变
- 经过Batch Normalization和ReLU
- 再经过第二个3x3卷积,输出通道数不变
- 经过Batch Normalization
- 将原始输入x与步骤4的输出相加
- 最后经过ReLU激活
这里有一个关键细节:第二个卷积之后、相加之前不能加ReLU。因为如果加了ReLU,输出就变成非负的了,而残差连接要求输出可以取任意值(包括负值),否则相加之后的信息会被截断。这个细节在实际写代码时很容易搞错,我见过不少人在第二个卷积后面顺手加了ReLU,结果训练效果大打折扣。
基础块适用于较浅的ResNet(18层、34层),因为它的参数量和计算量相对较小,在浅层网络中已经足够表达。
3.2 瓶颈块:用1x1卷积压缩再扩张
当网络加深到50层、101层、152层时,如果还用基础块,参数量和计算量会急剧膨胀。这时候就需要瓶颈块(Bottleneck Block)出场了。
瓶颈块的设计思路是:先用1x1卷积把通道数压缩,再用3x3卷积在低维空间做特征提取,最后再用1x1卷积把通道数扩张回来。典型的压缩比例是4倍——比如输入256通道,先压到64通道,3x3卷积在64通道上操作,再扩回256通道。
为什么这样设计?因为3x3卷积的计算量是跟通道数的平方成正比的。如果直接在256通道上做3x3卷积,计算量是256×256×9;而先压到64通道再扩回来,3x3卷积的计算量变成64×64×9,加上两个1x1卷积的计算量,总体算下来比直接做3x3卷积少了将近一半。这就是瓶颈结构的价值——在保持表达能力的同时大幅降低计算成本。
瓶颈块还有一个细节:当输入输出通道数不一致或者特征图尺寸需要下采样时,残差连接上需要加一个1x1卷积来调整维度和尺寸,这个叫做“投影捷径”(projection shortcut)。如果输入输出维度一致,就直接用恒等映射。
3.3 两种块的对比与选型建议
| 对比维度 | 基础残差块 | 瓶颈残差块 |
|---|---|---|
| 适用网络深度 | ResNet-18/34 | ResNet-50/101/152 |
| 核心结构 | 两个3x3卷积 | 1x1降维 + 3x3卷积 + 1x1升维 |
| 参数量 | 较大(相同通道数下) | 较小(通过压缩通道) |
| 计算量 | 较高 | 较低 |
| 残差连接处理 | 维度一致时直接相加 | 维度不一致时用1x1卷积投影 |
| 实际使用场景 | 小数据集、轻量级任务 | 大数据集、深层网络 |
选型建议很直接:如果你的任务比较简单、数据集不大,用ResNet-18或34就够了,基础块的结构更简单,训练也更容易收敛。如果你需要更强的表达能力、有足够的计算资源,那就上ResNet-50或更深,瓶颈块能帮你省下大量显存和计算时间。
4. 从18层到152层:ResNet家族的结构演进与设计逻辑
4.1 整体架构:阶段划分与下采样策略
ResNet的整体架构可以分成几个阶段:首先是7x7的大卷积核加最大池化做初步特征提取和下采样,然后进入四个阶段(stage),每个阶段由若干个残差块堆叠而成,阶段之间通过步长为2的卷积做下采样,特征图尺寸减半、通道数翻倍。
以ResNet-50为例,四个阶段的残差块数量分别是3、4、6、3,通道数分别是256、512、1024、2048。这个“3-4-6-3”的配置不是随便定的,它遵循了一个经验规律:随着特征图尺寸减小,通道数增加,每个阶段的残差块数量也相应增加。这是因为浅层阶段处理的是高分辨率、低语义的特征,计算量大但每个特征的信息量少;深层阶段处理的是低分辨率、高语义的特征,需要更多的通道来编码丰富的语义信息。
4.2 下采样时残差连接怎么处理
当特征图尺寸减半、通道数翻倍时,残差连接上的恒等映射就对不上了——输入是56x56x64,输出是28x28x128,直接相加会报错。ResNet的处理方式是在捷径路径上加一个1x1卷积,步长为2,输出通道数设为与主路径一致。这样既完成了下采样,又对齐了通道数。
这里有一个实操中容易忽略的点:这个1x1投影卷积后面通常不加Batch Normalization。虽然有些实现会加,但原始论文和主流实现都不加。原因是这个投影卷积的作用只是维度对齐,加BN反而会增加不必要的复杂度和计算量。
4.3 各版本ResNet的参数量与计算量对比
| 网络版本 | 残差块类型 | 残差块配置 | 参数量(约) | FLOPs(约) | Top-1准确率(ImageNet) |
|---|---|---|---|---|---|
| ResNet-18 | 基础块 | [2,2,2,2] | 11.7M | 1.8G | 69.8% |
| ResNet-34 | 基础块 | [3,4,6,3] | 21.8M | 3.6G | 73.3% |
| ResNet-50 | 瓶颈块 | [3,4,6,3] | 25.6M | 3.8G | 76.1% |
| ResNet-101 | 瓶颈块 | [3,4,23,3] | 44.5M | 7.6G | 77.4% |
| ResNet-152 | 瓶颈块 | [3,8,36,3] | 60.2M | 11.3G | 78.3% |
从表中可以看出,ResNet-50的参数量只比ResNet-34多了不到4M,但准确率提升了近3个百分点,这就是瓶颈结构的威力。而ResNet-101和152虽然参数量和计算量大幅增加,但准确率提升的边际效益在递减。实际选型时,ResNet-50通常是性价比最高的选择。
5. 动手搭建一个ResNet:从零实现的完整路径
5.1 环境准备与依赖确认
在开始写代码之前,先确认你的环境。我推荐用PyTorch,因为它的动态图机制对调试残差连接这种结构非常友好。需要的依赖很简单:
pip install torch torchvision如果你有GPU,确认CUDA可用:
import torch print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))CPU也能跑,但训练ResNet-50这种规模的网络,CPU会慢到让你怀疑人生。建议至少有一块显存8G以上的GPU。
5.2 基础残差块的代码实现与关键注释
先实现基础残差块:
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(BasicBlock, self).__init__() # 第一个3x3卷积 self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) # 第二个3x3卷积 self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) 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) # 注意:这里不加ReLU,直接与identity相加 if self.downsample is not None: identity = self.downsample(x) out += identity # 残差连接 out = self.relu(out) # 相加后再激活 return out这段代码里有几个关键点需要强调。第一,bias=False——因为后面接了Batch Normalization,卷积层的偏置会被BN的均值减法抵消掉,加上去纯属浪费参数。第二,第二个卷积后面不加ReLU,原因前面已经解释过了。第三,downsample是一个独立的模块,通常是一个1x1卷积加BN,用来在维度不匹配时调整identity的维度。
5.3 瓶颈块的实现与通道数变化追踪
瓶颈块的实现稍微复杂一点,因为涉及通道数的压缩和扩张:
class Bottleneck(nn.Module): expansion = 4 # 输出通道数是中间通道数的4倍 def __init__(self, in_channels, mid_channels, stride=1, downsample=None): super(Bottleneck, self).__init__() # 1x1卷积降维 self.conv1 = nn.Conv2d(in_channels, mid_channels, kernel_size=1, bias=False) self.bn1 = nn.BatchNorm2d(mid_channels) # 3x3卷积提取特征 self.conv2 = nn.Conv2d(mid_channels, mid_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(mid_channels) # 1x1卷积升维 self.conv3 = nn.Conv2d(mid_channels, mid_channels * self.expansion, kernel_size=1, bias=False) self.bn3 = nn.BatchNorm2d(mid_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 out注意expansion=4这个类属性,它表示瓶颈块的输出通道数是中间通道数的4倍。在构建网络时,这个属性用来计算每个阶段的输出通道数。比如ResNet-50的第一个阶段,中间通道数是64,输出通道数就是64×4=256。
5.4 组装完整的ResNet网络
有了残差块之后,组装网络就相对直接了:
class ResNet(nn.Module): def __init__(self, block, layers, num_classes=1000): super(ResNet, self).__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], stride=1) 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) def _make_layer(self, block, out_channels, num_blocks, stride): downsample = None # 当stride不为1或通道数不匹配时,需要下采样捷径 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 = [] # 第一个块可能需要下采样 if block == Bottleneck: layers.append(block(self.in_channels, out_channels, stride, downsample)) else: layers.append(block(self.in_channels, out_channels, stride, downsample)) self.in_channels = out_channels * block.expansion # 后续块不需要下采样 for _ in range(1, num_blocks): if block == Bottleneck: layers.append(block(self.in_channels, out_channels)) else: 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里的通道数追踪。self.in_channels需要在每个阶段结束后更新为当前阶段的输出通道数,这样下一个阶段才能正确判断是否需要下采样捷径。我建议在写完之后打印一下每一层的输出形状,确认维度变化符合预期。
6. 训练ResNet时那些文档不会告诉你的实操细节
6.1 学习率预热与衰减策略
ResNet论文中使用的学习率策略是:初始学习率0.1,每30个epoch除以10。但这是针对ImageNet这种大规模数据集的。如果你在自己的数据集上训练,尤其是数据集比较小的时候,直接上0.1的学习率很可能导致训练初期loss爆炸。
我的经验是:先用一个较小的学习率(比如0.01)跑几个epoch,观察loss下降是否平稳,再决定是否调大。另外,学习率预热(warmup)在训练ResNet时非常有用——前5个epoch把学习率从0线性增加到初始值,可以让网络在初期稳定地找到下降方向,避免一开始就震荡。
# 学习率预热示例 def adjust_learning_rate(optimizer, epoch, base_lr=0.1, warmup_epochs=5): if epoch < warmup_epochs: lr = base_lr * (epoch + 1) / warmup_epochs else: lr = base_lr * (0.1 ** ((epoch - warmup_epochs) // 30)) for param_group in optimizer.param_groups: param_group['lr'] = lr6.2 Batch Normalization与权重初始化的配合
ResNet大量使用了Batch Normalization,这带来一个好处:对权重初始化的敏感度降低了。但也不是随便初始化就行。PyTorch默认的Kaiming初始化对于ResNet来说已经够用,但如果你自己实现卷积层,记得用Kaiming正态初始化,fan_out模式,这样可以让每层输出的方差保持一致。
另外一个细节:BN层的weight初始化为1,bias初始化为0。这是PyTorch的默认行为,但如果你手动改了初始化,记得把BN层恢复成这个配置。BN的weight如果初始化太小,相当于把特征压缩了,会影响残差连接的信息流动。
6.3 数据增强与正则化的实际效果
在ImageNet上训练ResNet时,标准的数据增强是随机裁剪(从256x256裁剪到224x224)加随机水平翻转。如果你在自己的数据集上训练,数据量不够大的话,建议加上颜色抖动(Color Jitter)和随机旋转。但要注意,旋转角度不要太大,尤其是对于方向敏感的任务(比如数字识别),过度旋转会引入噪声。
权重衰减方面,ResNet论文用的是1e-4。这个值在大多数场景下都适用。如果你发现训练集准确率很高但验证集准确率上不去,可以适当加大权重衰减到5e-4或1e-3。Dropout在ResNet中通常不用,因为BN已经起到了正则化的作用,再加Dropout反而可能拖慢收敛。
6.4 迁移学习:加载预训练权重的正确姿势
实际项目中,很少有人从零训练ResNet,大多数情况是加载预训练权重做微调。这里有几个坑需要注意:
第一,替换全连接层时要注意输入维度。ResNet-50的全连接层输入是2048(512×4),ResNet-18是512。如果你把num_classes改了,只替换最后一层就行,前面的层不用动。
第二,冻结策略要分阶段。如果目标数据集和预训练数据集差异很大(比如从自然图像迁移到医学图像),建议先冻结前面的层,只训练后面的层和全连接层,跑几个epoch之后再解冻全部层做微调。如果差异不大,可以直接全部解冻。
第三,学习率要调小。微调时的学习率通常设为从头训练的十分之一甚至更小,比如1e-3或1e-4。因为预训练权重已经在一个很好的位置了,学习率太大会把学到的特征破坏掉。
# 加载预训练权重并替换分类头 model = torchvision.models.resnet50(pretrained=True) num_features = model.fc.in_features model.fc = nn.Linear(num_features, num_classes) # 替换为你的类别数 # 冻结前面的层 for name, param in model.named_parameters(): if 'fc' not in name: param.requires_grad = False # 只优化全连接层 optimizer = torch.optim.SGD(model.fc.parameters(), lr=1e-3, momentum=0.9)7. 残差连接之后:它如何影响了后续的网络设计
7.1 残差思想在目标检测与分割中的延伸
ResNet本身是一个分类网络,但它的残差结构被广泛移植到了其他任务中。目标检测领域的Faster R-CNN、YOLO系列,语义分割领域的FCN、U-Net,都大量使用了ResNet作为骨干网络。原因很简单:残差连接让深层骨干网络变得可训练,而深层网络提取的特征语义更强,对小目标和复杂场景的识别能力更好。
在U-Net中,跳跃连接(skip connection)和ResNet的残差连接有异曲同工之妙,但目的不完全一样。U-Net的跳跃连接是把编码器的特征图直接拼接到解码器上,目的是保留空间细节;ResNet的残差连接是把输入加到输出上,目的是让梯度更好地流动。两者可以结合使用,比如在U-Net的每个卷积块里加入残差连接。
7.2 DenseNet与ResNeXt:残差连接的两个进化方向
DenseNet把残差连接推到了极致:每一层的输入都来自前面所有层的输出拼接。这样做的好处是特征复用更充分,参数量更少;缺点是显存占用大,因为每一层的输出都要保存下来用于后续层的拼接。
ResNeXt则是在残差块内部做文章:把3x3卷积拆分成多个并行的组卷积,每个组独立计算,最后拼接。这种“分组卷积+残差连接”的设计在参数量相近的情况下,准确率比ResNet更高。它的核心思想是增加“基数”(cardinality)——也就是并行路径的数量,而不是单纯增加深度或宽度。
7.3 实际项目中的选型建议
如果你现在要做一个图像分类项目,我的建议是:
- 数据量小于1万张:用ResNet-18或34,配合强数据增强和迁移学习。不要上太深的网络,容易过拟合。
- 数据量在1万到10万张:ResNet-50是首选,瓶颈结构在参数量和准确率之间取得了很好的平衡。
- 数据量大于10万张:可以考虑ResNet-101或152,但要注意计算资源。如果资源有限,ResNeXt-101(32x4d)是比ResNet-152更好的选择。
- 移动端或嵌入式部署:考虑ResNet-18配合通道剪枝或量化,或者直接用MobileNet系列。
还有一个经验:不要盲目追求准确率的小数点后几位。在实际业务中,推理速度、模型大小、部署难度往往比那0.5%的准确率提升更重要。ResNet-50在大多数场景下已经足够好,把精力花在数据质量和后处理上,收益往往比换更深的网络更大。
8. 我踩过的那些坑:ResNet实操中的常见问题与排查思路
8.1 训练loss不下降或震荡剧烈
这是最常见的问题。排查顺序应该是:先检查数据预处理有没有问题(比如归一化的均值和方差是否匹配),再检查学习率是不是太大,然后检查Batch Normalization的momentum参数。BN的默认momentum是0.1,如果batch size很小(比如小于16),BN的统计量会很不稳定,这时候可以考虑用Group Normalization替代,或者增大batch size。
还有一个容易被忽略的点:残差连接相加之后的值域。如果残差函数的输出方差很大,加上identity之后会导致数值不稳定。这时候可以在残差函数的最后一个BN层之前加一个缩放系数(比如0.1),但这属于比较进阶的技巧,一般用标准的BN就能解决。
8.2 验证集准确率远低于训练集
如果训练集准确率95%而验证集只有70%,大概率是过拟合。解决方案按优先级排序:加数据增强、加权重衰减、加Dropout(虽然ResNet不常用,但可以试试)、减小模型规模。如果训练集准确率本身就不高(比如80%),那可能是欠拟合,需要增大模型或延长训练时间。
还有一个特殊情况:验证集的预处理和训练集不一致。比如训练时用了随机裁剪,验证时用了中心裁剪,这本身没问题,但如果归一化参数不一致,就会导致验证集准确率异常低。检查一下transforms.Normalize的均值和方差是否在训练和验证时保持一致。
8.3 显存不够用的优化策略
ResNet-50在batch size为32、输入224x224的情况下,大约需要8G显存。如果显存不够,可以尝试以下方法:
- 减小batch size(但不要小于8,否则BN不稳定)
- 使用混合精度训练(AMP),可以节省约40%显存
- 使用梯度累积,用小的batch size模拟大的batch size
- 使用梯度检查点(gradient checkpointing),用计算时间换显存
混合精度训练现在在PyTorch里已经很简单了:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for inputs, labels in dataloader: optimizer.zero_grad() with autocast(): outputs = model(inputs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()8.4 模型推理速度慢的排查方向
如果推理速度不达预期,先确认是不是用了GPU。然后检查输入尺寸——224x224和320x320的推理时间差很多。再检查是不是用了太大的batch size导致显存带宽瓶颈。最后,如果部署在边缘设备上,考虑用TensorRT或ONNX Runtime做推理优化,通常能提速2到3倍。
还有一个容易忽略的点:PyTorch的默认卷积算法在推理时不一定是最优的。可以设置torch.backends.cudnn.benchmark = True,让cuDNN自动搜索最快的卷积算法。但这个设置只适合输入尺寸固定的场景,如果输入尺寸变化频繁,反而会拖慢速度。
9. 关于ResNet,我个人的几点体会
ResNet从2015年提出到现在,已经快十年了,但它依然是深度学习领域最经典、最常用的网络架构之一。我自己的体会是,ResNet的价值不仅在于它解决了深层网络的训练问题,更在于它提供了一种通用的设计思路:当直接学习目标映射很难时,转而学习残差。这个思路后来被用在了很多地方,比如Transformer里的残差连接、扩散模型里的残差预测,本质上都是同一个思想。
另外,ResNet的代码实现虽然不复杂,但细节很多。我建议每个学深度学习的人都自己从零实现一遍ResNet,不要直接调torchvision.models.resnet50()。自己写一遍,你才会真正理解downsample的作用、expansion的含义、以及为什么第二个卷积后面不能加ReLU。这些细节在调参和排错时非常有用。
最后分享一个小技巧:如果你在训练ResNet时发现某个阶段的loss突然跳变,可以检查一下那个阶段的残差块里,下采样捷径的步长和主路径的步长是否一致。我遇到过好几次因为步长不匹配导致特征图尺寸对不上,最后相加时报错的情况。写代码时多打印形状,能省下大量调试时间。