没有残差连接的深层网络,越深效果越差。ResNet 用一条"跳跃连接"彻底解决了这个问题。
1. ResNet 解决了什么问题?
为什么想要更深的网络?
深层网络的每一层都在前一层的基础上提取更抽象的特征:
| 层次 | 学到的特征 |
|---|---|
| 浅层(1-3 层) | 边缘、颜色、纹理 |
| 中层(4-8 层) | 眼睛、轮子、轮廓 |
| 深层(9+ 层) | "这是一只狗"、"这是一辆车" |
理论上,网络越深,表达能力越强------能组合出更复杂的特征。VGG、GoogLeNet 等网络的成功也验证了这一点。
深层网络反而更差
2015 年之前,大家以为"网络越深越好"。但实验发现了一个反直觉的现象------退化问题:
| 网络深度 | 训练误差 |
|---|---|
| 20 层 | 5.6% |
| 56 层 | 8.3% ← 更深反而更差! |
注意:这不是过拟合(训练误差也更高),而是优化本身失败了。更深的网络连训练集都学不好。
为什么"不是过拟合"?
- 过拟合 = 训练误差很低,测试误差很高。模型"死记硬背"了训练数据,泛化能力差。
- 但这里 56 层网络在训练集上就表现很差,说明它不是"学太多了",而是"压根学不会"------优化器在更深的网络中找不到好的解。
- 这是"退化问题"(degradation),需要架构层面的创新来解决,而不是正则化、dropout 等常规手段。
理论上不应该更差
何恺明团队的推理:
- 56 层网络至少不应该比 20 层差
- 因为最差情况下,多出来的 36 层可以学成"什么都不做"(恒等映射),那就跟 20 层一样
- 但实际上网络学不会恒等映射 → 说明优化器找不到这个解
- 解决方案:显式提供一条恒等映射的路径(shortcut),让网络只需要学"偏差"
2. 核心思想:残差学习
普通网络让每层学完整的映射 <math xmlns="http://www.w3.org/1998/Math/MathML"> H ( x ) H(x) </math>H(x)。
ResNet 让每层学残差 (输出与输入的差) <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) = H ( x ) − x F(x) = H(x) - x </math>F(x)=H(x)−x。
为什么学残差更容易?
普通网络要学"输出等于输入",需要让若干层的复合精确 等于恒等映射 <math xmlns="http://www.w3.org/1998/Math/MathML"> H ( x ) = x H(x) = x </math>H(x)=x------这是一个特定的非平凡函数,优化器很难精确找到。
ResNet 只需让残差 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) = 0 F(x) = 0 </math>F(x)=0,相当于"把权重推向零"------而这恰好是网络的默认状态,也就是说,优化器只需在"几乎什么都不做"的基础上微调,比让普通深层网络从头拟合恒等映射容易得多。
ResNet 让"什么都不做"成为优化器的默认解,再在此基础上学有用的偏差,这就是残差学习的本质。
残差块结构图:
scss
输入 x ──────────────────────────┐
│ │ (shortcut: 直接连接)
▼ │
Conv → BN → ReLU │
│ │
▼ │
Conv → BN │
│ │
▼ │
(+) ◀─────────────────────────┘
│
▼
ReLU
│
▼
输出 H(x) = F(x) + x
3. 残差块(ResidualBlock)详解
3.1 代码实现
python
# 3x3 卷积工具函数
def conv3x3(in_channels, out_channels, stride=1):
return nn.Conv2d(in_channels, out_channels, kernel_size=3,
stride=stride, padding=1, bias=False)
class ResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super(ResidualBlock, self).__init__()
self.conv1 = conv3x3(in_channels, out_channels, stride)
self.bn1 = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU(inplace=True)
self.conv2 = conv3x3(out_channels, out_channels)
self.bn2 = nn.BatchNorm2d(out_channels)
self.downsample = downsample
def forward(self, x):
residual = x # 保存输入
out = self.conv1(x) # 第一个卷积
out = self.bn1(out) # 归一化
out = self.relu(out) # 激活
out = self.conv2(out) # 第二个卷积
out = self.bn2(out) # 归一化(注意:这里没有 ReLU)
if self.downsample:
residual = self.downsample(x)
out += residual # 核心:输出 = F(x) + x
out = self.relu(out) # 相加之后再 ReLU
return out
代码结构说明:
每个残差块包含两条路径:
- 主路径 :
conv3×3 → bn → relu → conv3×3 → bn,负责提取特征(即残差 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) F(x) </math>F(x)) - shortcut 路径 :要么直连(尺寸/通道相同),要么经过
downsample(conv1×1 + bn,对齐尺寸和通道)
最后两路相加 out += residual,再统一 ReLU 激活。
BatchNorm(批归一化)是什么?
每次卷积后,不同通道的数值范围可能差异很大(有的在 0--100,有的在 -0.01--0.01)。
BatchNorm2d对每个通道做标准化,拉到均值≈0、方差≈1 的范围:
- 训练更稳定:每层输入分布一致,数值不会层层放大或缩小
- 可以用更大学习率:不用担心某层数值失控
- 轻微正则化效果:因为每个 mini-batch 的统计量有随机性
简单记:卷积后、激活前,先做一次"数据拉平"。
3.2 两种运行情况
残差块的核心是 out += residual(主路径 + shortcut 相加)。
为什么要相加? 这就是残差学习的实现:主路径学到的是"偏差" <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) F(x) </math>F(x),shortcut 保留了原始输入 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x,两者相加得到最终输出 <math xmlns="http://www.w3.org/1998/Math/MathML"> H ( x ) = F ( x ) + x H(x) = F(x) + x </math>H(x)=F(x)+x。这样即使主路径什么都没学到( <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) = 0 F(x)=0 </math>F(x)=0),输出也等于输入,不会变差------这就解决了退化问题。
但相加要求两边形状完全一致。根据输入输出的形状关系,分两种情况:
| 直连(identity shortcut) | 下采样(downsample shortcut) | |
|---|---|---|
| 含义 | shortcut 什么都不做,原样返回 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x | shortcut 经过 conv1×1+bn,把 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 变换到目标形状 |
| 何时触发 | 输入输出的通道数、尺寸都相同 | 通道数加倍 或 尺寸减半(stride=2) |
| 代码体现 | downsample=None,跳过 if |
downsample=Conv1×1(stride=2)+BN |
情况 A:直连(stride=1,通道相同)
shortcut 什么都不做,x 和 out 形状完全一致,直接相加。
csharp
x (16ch, 32×32)
├── 主路径: Conv→BN→ReLU→Conv→BN → out (16ch, 32×32)
└── shortcut: 直连 ─────────────→ residual = x
out += residual → ReLU → 输出
情况 B:下采样(stride=2,通道加倍)
x 和 out 形状不同,需要 downsample 对齐维度。
erlang
x (16ch, 32×32)
├── 主路径: Conv(stride=2)→BN→ReLU→Conv→BN → out (32ch, 16×16)
└── shortcut: downsample(Conv+BN) ──────────→ residual (32ch, 16×16)
out += residual → ReLU → 输出
3.3 Conv 为什么设 bias=False?
因为后面紧跟 BatchNorm。BN 会先减均值,相当于把 bias 抵消了------所以 Conv 加 bias 是多余的。去掉可以少几个参数,不影响效果。
3.4 为什么 ReLU 用 inplace=True?
inplace=True 直接在原始 tensor 上修改(而不是创建新 tensor),节省显存。在 ResNet 的前向路径中,被 ReLU 覆盖的值之后不会再用到,所以安全。
3.5 为什么 bn2 后面没有 ReLU?
python
out = self.bn2(out) # 这里没有 ReLU
out += residual # 先加
out = self.relu(out) # 后激活
先加再 ReLU,是为了让残差 F(x) 保留正负值。
如果 bn2 后面先 ReLU 再相加:
- F(x) 被截断成非负 → 残差只能往"正方向"调整
- 不能往"负方向"修正 → 表达能力减半
一个直觉例子:假设输入 <math xmlns="http://www.w3.org/1998/Math/MathML"> x = 5 x = 5 </math>x=5,这一层的最优输出应该是 <math xmlns="http://www.w3.org/1998/Math/MathML"> H ( x ) = 3 H(x) = 3 </math>H(x)=3(即需要把值往下调)。
那么残差就应该是 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) = H ( x ) − x = 3 − 5 = − 2 F(x) = H(x) - x = 3 - 5 = -2 </math>F(x)=H(x)−x=3−5=−2(负数!)。
| 方式 | 计算过程 | 最终输出 | 是否正确 |
|---|---|---|---|
| 先 ReLU 再加 | <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) = − 2 F(x)=-2 </math>F(x)=−2 → ReLU(截断负数)→ <math xmlns="http://www.w3.org/1998/Math/MathML"> 0 0 </math>0 → <math xmlns="http://www.w3.org/1998/Math/MathML"> 0 + x = 0 + 5 = 5 0 + x = 0+5 = 5 </math>0+x=0+5=5 | 5 | ✖ 调不下来 |
| 先加再 ReLU | <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) = − 2 F(x)=-2 </math>F(x)=−2 → 先加 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) + x = − 2 + 5 = 3 F(x)+x = -2+5 = 3 </math>F(x)+x=−2+5=3 → ReLU <math xmlns="http://www.w3.org/1998/Math/MathML"> ( 3 ) = 3 (3) = 3 </math>(3)=3 | 3 | ✔ 正确 |
结论:先加再 ReLU,残差 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) F(x) </math>F(x) 可以是负数,网络既能往上调也能往下调。
3.6 为什么是 2 次卷积一个残差?
1 次太少: 只有一个卷积的残差 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) = W x F(x) = Wx </math>F(x)=Wx,跟 shortcut 相加后 <math xmlns="http://www.w3.org/1998/Math/MathML"> y = ( W + I ) x y = (W+I)x </math>y=(W+I)x,等价于普通线性层,shortcut 加了等于没加。必须有 ReLU 夹在中间才有意义,最少需要 2 个卷积。
超过 2 次: shortcut 间隔过大,残差学习的"微调"语义被稀释,实验证明效果下降。
| 间隔 | 是否可行 | 实际使用 |
|---|---|---|
| 1 层 | 不行 | F(x) 是纯线性,shortcut 无意义 |
| 2 层 | 最优 | ResNet-18/34(BasicBlock) |
| 3+ 层 | 不推荐 | shortcut 间隔过大,效果下降 |
3.7 downsample 用 1×1 卷积
downsample 用 1×1 卷积,只做维度对齐(通道映射 + 下采样),不提取空间特征:
python
downsample = nn.Sequential(
nn.Conv2d(in_ch, out_ch, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_ch))
shortcut 的设计哲学:尽可能简单、轻量,让主路径负责学特征。
4. 完整训练代码
下面用我们自己实现的 ResNet 在 CIFAR-10 数据集上进行训练。
4.0 CIFAR-10 数据集简介
CIFAR-10 是计算机视觉领域最经典的入门数据集之一:
| 属性 | 值 |
|---|---|
| 图片尺寸 | 32×32 像素,RGB 3 通道 |
| 类别数 | 10 类(飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车) |
| 训练集 | 50,000 张 |
| 测试集 | 10,000 张 |
| 特点 | 小图片、多类别、均衡分布 |
CIFAR-10 是验证网络架构的理想选择------图片小(训练快)、类别少(容易调试)、社区基准丰富(方便对比)。
为什么不用 ImageNet?
- ImageNet 输入 224×224,训练慢得多
- CIFAR-10 输入 32×32,单卡几分钟就能跑完
- 对于理解 ResNet 原理,小数据集足够
python
import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms
device = torch.device(
'cuda' if torch.cuda.is_available()
else 'mps' if torch.backends.mps.is_available()
else 'cpu'
)
# 3x3 卷积工具函数
def conv3x3(in_channels, out_channels, stride=1):
return nn.Conv2d(in_channels, out_channels, kernel_size=3,
stride=stride, padding=1, bias=False)
# 残差块
class ResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super(ResidualBlock, self).__init__()
self.conv1 = conv3x3(in_channels, out_channels, stride)
self.bn1 = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU(inplace=True)
self.conv2 = conv3x3(out_channels, out_channels)
self.bn2 = nn.BatchNorm2d(out_channels)
self.downsample = downsample
def forward(self, x):
residual = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
if self.downsample:
residual = self.downsample(x)
out += residual
out = self.relu(out)
return out
# ResNet
class ResNet(nn.Module):
def __init__(self, block, layers, num_classes=10):
super(ResNet, self).__init__()
self.in_channels = 16
self.conv = conv3x3(3, 16)
self.bn = nn.BatchNorm2d(16)
self.relu = nn.ReLU(inplace=True)
self.layer1 = self.make_layer(block, 16, layers[0])
self.layer2 = self.make_layer(block, 32, layers[1], 2)
self.layer3 = self.make_layer(block, 64, layers[2], 2)
self.avg_pool = nn.AvgPool2d(8)
self.fc = nn.Linear(64, num_classes)
def make_layer(self, block, out_channels, blocks, stride=1):
downsample = None
if (stride != 1) or (self.in_channels != out_channels):
downsample = nn.Sequential(
nn.Conv2d(self.in_channels, out_channels, kernel_size=1,
stride=stride, bias=False),
nn.BatchNorm2d(out_channels))
layers = []
layers.append(block(self.in_channels, out_channels, stride, downsample))
self.in_channels = out_channels
for i in range(1, blocks):
layers.append(block(out_channels, out_channels))
return nn.Sequential(*layers)
def forward(self, x):
out = self.conv(x)
out = self.bn(out)
out = self.relu(out)
out = self.layer1(out)
out = self.layer2(out)
out = self.layer3(out)
out = self.avg_pool(out)
out = out.view(out.size(0), -1)
out = self.fc(out)
return out
# 数据增强
transform = transforms.Compose([
transforms.Pad(4),
transforms.RandomHorizontalFlip(),
transforms.RandomCrop(32),
transforms.ToTensor()])
train_dataset = torchvision.datasets.CIFAR10(
root='./data/', train=True, transform=transform, download=True)
test_dataset = torchvision.datasets.CIFAR10(
root='./data/', train=False, transform=transforms.ToTensor())
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=100, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=100, shuffle=False)
# 构建模型
model = ResNet(ResidualBlock, [2, 2, 2]).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
# 训练
for epoch in range(80):
for i, (images, labels) in enumerate(train_loader):
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# 每 20 个 epoch 衰减学习率
if (epoch+1) % 20 == 0:
for param_group in optimizer.param_groups:
param_group['lr'] /= 3
4.1 代码讲解
初始卷积
python
self.conv = conv3x3(3, 16) # RGB 3通道 → 16通道,尺寸不变
self.bn = nn.BatchNorm2d(16)
self.relu = nn.ReLU(inplace=True)
CIFAR-10 图像是 32×32,尺寸较小,所以用 3×3 卷积(stride=1)作为入口,不做下采样。(ImageNet 版本用 7×7 stride=2 + MaxPool,因为输入是 224×224)
为什么需要这一步?
- 通道对齐:原始图像只有 3 个通道(RGB),而 layer1 内部全程是 16→16。如果不先提升到 16 通道,layer1 第一个残差块的 shortcut 就必须做 downsample(3→16),破坏了 layer1 "通道不变、全部直连"的干净设计。
- 提供有意义的输入 :残差块学的是"偏差" <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x ) F(x) </math>F(x),如果 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 还是原始像素值,残差块需要同时完成"基础特征提取"和"残差学习"两件事。先做一次 conv+bn+relu,相当于把原始像素变成初步特征图(边缘、纹理等),让后面的残差块专注于精细调整。
make_layer ------ 构建残差层的工厂方法
python
def make_layer(self, block, out_channels, blocks, stride=1):
downsample = None
if (stride != 1) or (self.in_channels != out_channels):
downsample = nn.Sequential(
nn.Conv2d(self.in_channels, out_channels, kernel_size=1,
stride=stride, bias=False),
nn.BatchNorm2d(out_channels))
layers = []
layers.append(block(self.in_channels, out_channels, stride, downsample))
self.in_channels = out_channels # ← 关键:立即更新
for i in range(1, blocks):
layers.append(block(out_channels, out_channels))
return nn.Sequential(*layers)
核心逻辑:
- 第 1 个 block:带 stride 和 downsample,负责维度变换(通道加倍 + 尺寸减半)
- 后续 block:同尺寸同通道,纯粹的特征提炼
以 self.layer2 = self.make_layer(block, 32, 2, stride=2) 为例:
| Block | in_channels | out_channels | stride | downsample |
|---|---|---|---|---|
| 第 1 个 | 16 | 32 | 2 | Conv1×1 + BN |
| 第 2 个 | 32 | 32 | 1 | 无 |
为什么 layer1 不需要 downsample?
注意上面 downsample 的触发条件:(stride != 1) or (self.in_channels != out_channels)------两者任一成立才触发。
调用 self.layer1 = self.make_layer(block, 16, layers[0]) 时:
stride = 1(用了默认值,没显式传)self.in_channels = 16(初始卷积后赋的值),out_channels = 16
两个条件都不满足,downsample = None,所以 layer1 的两个 block 都是纯直连。
self.in_channels = out_channels 这一行很关键
第 1 个 block 处理完后,特征图的通道数变成了 out_channels。立即更新 self.in_channels,是为了让:
- 同一个 layer 里的后续 block :进入循环时
in = out = out_channels,对称结构,无需 downsample - 下一次调用 make_layer 时 (构造下一个 layer):
self.in_channels已经是上一个 layer 的输出通道数,新 layer 的第一个 block 能正确判断是否需要通道变换
如果忘了这行,要么循环里后续 block 的输入通道对不上,要么下一个 layer 的 downsample 判断出错------这是手写 ResNet 时最容易踩的坑。
模型实例化
python
model = ResNet(ResidualBlock, [2, 2, 2])
[2, 2, 2] = 3 个 layer × 每个 2 个残差块 = 6 个残差块。每个残差块含 2 个卷积层,加上初始卷积和 fc,总深度 6×2 + 2 = 14 层。
训练策略
- 数据增强 :
Pad(4)→RandomHorizontalFlip→RandomCrop(32),通过随机翻转和裁剪增加训练多样性 - 损失函数 :
CrossEntropyLoss(交叉熵损失)------ 多分类任务的标配。它把模型输出的 10 个原始分数先做 Softmax(变成概率),再计算与真实标签的差距。模型对正确类别越确信,loss 越小 - 优化器:Adam(lr=0.001),自适应学习率
- 学习率衰减:每 20 个 epoch 将学习率除以 3,越往后越精细
全局平均池化
python
self.avg_pool = nn.AvgPool2d(8)
self.fc = nn.Linear(64, num_classes)
nn.AvgPool2d(kernel_size) 对输入特征图的每个通道,用 kernel_size × kernel_size 的窗口取平均值。当 kernel_size 等于输入的空间尺寸时,就是"全局平均池化"------整张特征图压缩成一个数:
scss
一个通道的 8×8 特征图:
┌─────────────────────────┐
│ 1 3 2 4 5 6 7 8 │
│ 2 4 1 3 6 7 8 9 │
│ ... (共 8×8 = 64 个值) │
└─────────────────────────┘
↓ AvgPool2d(8)
↓ 把整个 8×8 区域取平均
[4.2] ← 一个数(该通道的"全局摘要")
对 64 个通道各做一次,整体数据流:
scss
(64, 8, 8) → 每个通道 8×8=64 个值取平均 → (64, 1, 1) → flatten → (64,) → fc → (10,)
为什么用 AvgPool 而不是 MaxPool?
| AvgPool | MaxPool | |
|---|---|---|
| 操作 | 区域内取平均 | 区域内取最大值 |
| 保留信息 | 整体激活强度 | 只保留最强响应 |
| 网络末尾 | 更全面地总结特征图 | 易被异常值干扰 |
对比直接 Flatten:
| 方式 | 连接 fc 的参数量 |
|---|---|
| 直接 Flatten: (64, 8, 8) → (4096,) | 4096×10 = 40,960 |
| 全局平均池化: (64, 8, 8) → (64,) | 64×10 = 640 |
参数少 64 倍,有效防止过拟合。
4.2 ResNet 的层级结构
完整的数据流:
ini
输入图像 (3, 32, 32)
│
├─ conv + bn + relu → (16, 32, 32) ← 初始特征提取
│
├─ layer1: ResidualBlock × 2 → (16, 32, 32) ← 通道不变,尺寸不变
│ ├─ Block1: conv3×3(stride=1)→bn→relu→conv3×3(stride=1)→bn
│ │ + shortcut(直连,无 downsample) ← 尺寸/通道相同,直接相加
│ │ → 相加 → relu → (16, 32, 32)
│ └─ Block2: conv3×3(stride=1)→bn→relu→conv3×3(stride=1)→bn
│ + shortcut(直连) ← 尺寸/通道相同,直接相加
│ → 相加 → relu → (16, 32, 32)
│
├─ layer2: ResidualBlock × 2 → (32, 16, 16) ← 通道加倍,尺寸减半
│ ├─ Block1: conv3×3(stride=2)→bn→relu→conv3×3(stride=1)→bn
│ │ + downsample(conv1×1 stride=2 + bn) ← 主路径下采样 + shortcut 对齐
│ │ → 相加 → relu → (32, 16, 16)
│ └─ Block2: conv3×3(stride=1)→bn→relu→conv3×3(stride=1)→bn
│ + shortcut(直连) ← 尺寸/通道相同,直接相加
│ → 相加 → relu → (32, 16, 16)
│
├─ layer3: ResidualBlock × 2 → (64, 8, 8) ← 通道加倍,尺寸减半
│ ├─ Block1: conv3×3(stride=2)→bn→relu→conv3×3(stride=1)→bn
│ │ + downsample(conv1×1 stride=2 + bn) ← 主路径下采样 + shortcut 对齐
│ │ → 相加 → relu → (64, 8, 8)
│ └─ Block2: conv3×3(stride=1)→bn→relu→conv3×3(stride=1)→bn
│ + shortcut(直连) ← 尺寸/通道相同,直接相加
│ → 相加 → relu → (64, 8, 8)
│
├─ avg_pool(8) → (64, 1, 1) ← 全局平均池化
├─ flatten → (64,)
└─ fc → (10,) ← 10 类分类
设计规律:通道每翻倍一次,空间尺寸减半一次,总信息量大致守恒。
4.3 为什么是 3 层 layer?
3 层不是随意选的,是输入尺寸决定的。CIFAR-10 输入 32×32,每次 stride=2 尺寸减半,最后需要用 AvgPool 压到 1×1:
ini
32×32(输入)
│
├─ layer1 (stride=1) → 32×32 保持
├─ layer2 (stride=2) → 16×16 减半 ①
├─ layer3 (stride=2) → 8×8 减半 ②
│
└─ AvgPool(8) → 1×1 刚好压完
如果再加一层 layer4 (stride=2) → 4×4,空间信息已经太少,效果反而下降。
对比 ImageNet 版本(输入 224×224,空间更大):
| 版本 | 输入尺寸 | layer 数 | 空间变化 |
|---|---|---|---|
| CIFAR-10 ResNet | 32×32 | 3 | 32→32→16→8→1 |
| ImageNet ResNet | 224×224 | 4 | 56→56→28→14→7→1 |
结论:输入尺寸决定能下采样几次,下采样次数决定 layer 数。
5. ResNet 是经验算法吗?
既有理论直觉,也有大量经验验证。
| 阶段 | 方式 | 内容 |
|---|---|---|
| 观察问题 | 经验发现 | 深层网络训练误差反而更高(退化问题) |
| 提出方案 | 理论推导 | shortcut 让网络学残差而非完整映射,更容易优化 |
| 验证效果 | 实验验证 | 110 层 > 56 层 > 20 层,退化问题解决 |
| 事后解释 | 理论分析 | 集成学习视角、loss landscape 平滑性等 |
具体设计中,哪些是理论、哪些是经验:
| 设计选择 | 来源 |
|---|---|
| 跳跃连接的思想 | 理论驱动 |
| 每 2 层一个 shortcut | 经验:最小有效单位 |
| 通道数 16→32→64 翻倍 | 经验:来自 VGG |
| Conv→BN→ReLU 的顺序 | 经验:多种变体对比 |
大方向靠理论,细节靠实验。 这在深度学习中非常常见。
6. 一句话总结
ResNet 的核心发明是跳跃连接(shortcut):让网络学"输出与输入的差"而非"输出本身"。这解决了深层网络的退化问题,使得 100+ 层的网络成为可能。结构上,它由"初始卷积 + N 组残差层(通道加倍、尺寸减半)+ 全局池化 + 分类头"组成,是计算机视觉最重要的基础架构之一。