深度残差网络ResNet:从理论到实践,手把手教你复现经典模型

深度残差网络ResNet:从理论到实践,手把手教你复现经典模型

1. 为什么需要残差网络?

在2015年之前,深度学习领域普遍认为"网络越深,性能越好"。从LeNet到AlexNet,再到VGG和GoogLeNet,网络深度确实在不断加深。但研究者很快发现一个奇怪现象:当网络深度超过某个阈值后,继续增加层数反而会导致训练误差和测试误差同时增大。这不是过拟合问题,因为过拟合应该表现为训练误差降低而测试误差升高。

这种现象被称为退化问题(Degradation Problem)。想象一下,你正在教一个学生做数学题,给他100道练习题后成绩提高了,但给他1000道题后成绩反而下降——这显然不是因为他"学过头"了,而是训练方法出了问题。

残差网络(ResNet)的提出者何恺明团队发现,深层网络难以训练的关键在于:随着网络加深,梯度信号在反向传播时会逐渐减弱。即使使用了BatchNorm等技巧,深层网络的优化仍然困难。这就好比你在玩"传话游戏",一句话经过20个人传递后,最后一个人听到的内容可能已经面目全非。

2. 残差块:ResNet的核心创新

2.1 残差学习的基本思想

传统神经网络直接学习目标映射H(x),而ResNet改为学习残差映射F(x) = H(x) - x。这个看似简单的改动带来了深远影响:

  1. 恒等映射的保底作用:当F(x)=0时,H(x)=x,网络至少能保持浅层网络的性能
  2. 梯度高速公路:通过跨层连接(shortcut),梯度可以直接回传到浅层
  3. 特征复用:深层网络可以专注于学习新增特征,而非重复学习浅层特征

用一个生活类比:假设你要从北京到上海,传统网络像让你一步步走到上海;而ResNet则是先让你坐高铁到南京(学习残差),再补充走到上海这段距离。

2.2 两种残差块结构

ResNet使用了两种基本模块,适用于不同深度的网络:

BasicBlock(用于ResNet-18/34)
class BasicBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): 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.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) # 当输入输出维度不一致时需要1x1卷积调整维度 self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) # 关键残差连接 return F.relu(out)
Bottleneck(用于ResNet-50/101/152)
class Bottleneck(nn.Module): expansion = 4 # 输出通道的倍乘系数 def __init__(self, in_channels, out_channels, stride=1): super().__init__() mid_channels = out_channels // self.expansion self.conv1 = nn.Conv2d(in_channels, mid_channels, kernel_size=1, stride=1, bias=False) self.bn1 = nn.BatchNorm2d(mid_channels) self.conv2 = nn.Conv2d(mid_channels, mid_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(mid_channels) self.conv3 = nn.Conv2d(mid_channels, out_channels, kernel_size=1, stride=1, bias=False) self.bn3 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = F.relu(self.bn2(self.conv2(out))) out = self.bn3(self.conv3(out)) out += self.shortcut(x) return F.relu(out)

Bottleneck结构通过1x1卷积先降维再升维,大幅减少了参数量。例如对于输入256维的特征:

  • BasicBlock需要:3x3x256x256 + 3x3x256x256 ≈ 1.18M参数
  • Bottleneck只需:1x1x256x64 + 3x3x64x64 + 1x1x64x256 ≈ 70k参数

3. 完整ResNet架构实现

3.1 网络组装逻辑

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.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, blocks, stride=1): layers = [] # 第一个块可能需要下采样 layers.append(block(self.in_channels, out_channels, stride)) 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 = F.relu(self.bn1(self.conv1(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

3.2 不同深度ResNet配置

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)

4. 实战:在Fashion-MNIST上训练ResNet

4.1 数据准备与增强

虽然Fashion-MNIST原始尺寸是28x28,但我们可以将其上采样到224x224以适应ResNet:

transform = transforms.Compose([ transforms.Resize(224), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_set = datasets.FashionMNIST(root='./data', train=True, download=True, transform=transform) test_set = datasets.FashionMNIST(root='./data', train=False, download=True, transform=transform) train_loader = DataLoader(train_set, batch_size=64, shuffle=True) test_loader = DataLoader(test_set, batch_size=64, shuffle=False)

4.2 模型训练技巧

  1. 学习率预热:初始几轮使用较小学习率
  2. 余弦退火:平滑降低学习率
  3. 混合精度训练:节省显存并加速
model = resnet18(num_classes=10) model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) # 修改输入通道 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10) scaler = torch.cuda.amp.GradScaler() # 混合精度训练 for epoch in range(10): model.train() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step() # 测试集评估 model.eval() correct = 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs.data, 1) correct += (predicted == labels).sum().item() print(f'Epoch {epoch+1}, Accuracy: {100 * correct / len(test_set):.2f}%')

5. 进阶技巧与常见问题

5.1 残差连接的设计变体

  1. Pre-activation:将BN和ReLU放在卷积之前(ResNet V2)
  2. Group Conv:在残差块中使用分组卷积(ResNeXt)
  3. Attention机制:引入SE模块(SENet)

5.2 训练深层ResNet的注意事项

  • 使用Kaiming初始化:nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
  • 对于超过100层的网络,建议:
    • 增加batch size(至少256)
    • 使用warmup学习率策略
    • 添加额外的正则化(如Label Smoothing)

5.3 模型部署优化

  1. 剪枝:移除不重要的通道
  2. 量化:将FP32转为INT8
  3. TensorRT加速:转换模型为引擎文件
# 示例:模型量化 quantized_model = torch.quantization.quantize_dynamic( model, {nn.Linear, nn.Conv2d}, dtype=torch.qint8 )

在实际项目中,ResNet-18量化后模型大小可减少4倍,推理速度提升2-3倍。我曾在一个工业质检项目中,通过量化将ResNet-50的推理时间从15ms降到6ms,满足了产线实时检测的需求。