第 6 章 2015—2016:残差革命——ResNet、BatchNorm 与 DenseNet
学习目标
- 理解深层网络「退化」问题,以及残差连接为什么能解决它
- 理解批量归一化(BatchNorm)的原理与效果
- 理解 DenseNet(2016)的稠密连接思想
- 用 PyTorch 实现并训练 ResNet、DenseNet,并对比有无 BatchNorm
6.1 时代背景:更深,然后撞墙
第 5 章的三条路线都指向同一个方向:更深。但 2015 年初,研究者发现一个反直觉的现象——当网络超过约 20 层,训练误差反而上升。这不是过拟合(训练误差也高),而是优化问题:深层网络很难收敛,梯度在层层传递中消失,网络退化(degeneration)了。
2015 年,微软研究院的何恺明(Kaiming He)等人提出 ResNet(残差网络),在 ImageNet 上训练出 152 层网络夺冠,错误率降到 3.57%——低于人类水平。2016 年,黄高(Gao Huang)等人提出 DenseNet,用稠密连接进一步改善梯度流。深度 CNN 从此进入「越深越好」的时代。
6.2 ResNet:让梯度有「高速公路」
普通层的映射是 F(x) = 输出;残差块改为学习残差:
输出 = F(x) + xF(x) 是两层卷积的输出,x 是输入,两者逐元素相加(形状不同时用 1×1 卷积投影)。这个 +x 的旁路叫捷径连接(shortcut)。
为什么有效?梯度从输出回传时,经过捷径直接「抄近道」到浅层,路径上没有任何需要连乘的参数——梯度消失被绕开。即使 F(x) 学不动,F(x)+x 至少保留输入,网络不会退化。
examples/ch06_resnet.py 实现 ResNet-18(4 个阶段 × 2 个残差块):
class BasicBlock(nn.Module):
def __init__(self, in_planes, planes, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_planes, planes, 3, stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(planes)
self.conv2 = nn.Conv2d(planes, planes, 3, 1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(planes)
self.relu = nn.ReLU(inplace=True)
self.shortcut = nn.Sequential()
if stride != 1 or in_planes != planes:
self.shortcut = nn.Sequential(
nn.Conv2d(in_planes, planes, 1, stride, bias=False),
nn.BatchNorm2d(planes),
)
def forward(self, x):
out = self.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += self.shortcut(x) # 残差相加
return self.relu(out)ResNet-18 风格网络参数量:11,172,810
epoch 1:训练准确率 = 90.28%
epoch 2:训练准确率 = 92.63%
epoch 3:训练准确率 = 92.38%
测试准确率 = 90.35%和 VGG 风格网络(TinyVGG,292 万参数,91.8%)相比,ResNet 用更多参数换来了 90.4% 的测试准确率——深度 + 残差的组合在更大数据集上优势会更明显。
6.3 BatchNorm:让每层输入保持稳定
2015 年,Ioffe 和 Szegedy 提出 批量归一化(BatchNorm):对每个 batch,把该层的输入按通道标准化(减均值、除标准差),再乘可学习的缩放与平移参数:
ẑ = (z - μ_batch) / √(σ²_batch + ε)
输出 = γ·ẑ + β作用有三:
- 稳定训练:每层输入分布不再漂移(内部协变量偏移),可以使用更大的学习率;
- 缓解梯度问题:归一化让梯度更健康;
- 轻微正则化:batch 统计带来的噪声有正则效果。
examples/ch06_batchnorm.py 用两个结构完全相同、一个有 BN 一个没有的 CNN 对比:
带 BatchNorm:
BN epoch 1:验证准确率 = 89.03%
BN epoch 2:验证准确率 = 87.97%
BN epoch 3:验证准确率 = 89.63%
BN epoch 4:验证准确率 = 90.17%
不带 BatchNorm:
无BN epoch 1:验证准确率 = 87.23%
无BN epoch 2:验证准确率 = 87.03%
无BN epoch 3:验证准确率 = 89.13%
无BN epoch 4:验证准确率 = 89.23%带 BN 的网络从第 1 轮就领先,最终高约 1 个百分点。在小数据集、短训练下差距有限;在真实大规模训练中,BN 常常是「没有它训练不起来」级别的组件。注意:训练时 BN 用 batch 统计,eval 时用滑动平均统计,所以 model.train() / model.eval() 的切换必须正确。
6.4 DenseNet:把前面的所有层都接起来
2016 年,DenseNet 问了一个更激进的问题:ResNet 的加法捷径只把输入加到输出,能不能把前面所有层的特征全部保留?
稠密块(DenseBlock)里,每一层都把之前所有层的输出拼接起来作为输入:
class DenseLayer(nn.Module):
def __init__(self, in_c, growth=12):
super().__init__()
self.layer = nn.Sequential(
nn.BatchNorm2d(in_c), nn.ReLU(),
nn.Conv2d(in_c, growth, 3, padding=1),
)
def forward(self, x):
return torch.cat([x, self.layer(x)], dim=1) # 拼接,而不是相加每层只贡献 growth 个新通道(增长率),特征在层间「复用」:浅层特征直达深层,梯度也有一条条直达浅层的通路。参数量因此比 ResNet 更省。
examples/ch06_densenet.py 运行结果:
DenseNet 风格参数量:13,330
epoch 1:训练准确率 = 70.66%
epoch 2:训练准确率 = 75.66%
epoch 3:训练准确率 = 77.95%
测试准确率 = 77.83%仅 1.3 万参数(是 ResNet 风格网络的千分之一)就达到 77.8%——稠密连接的效率惊人。缺点是拼接导致内存占用随深度线性增长,所以 DenseNet 的深层版本需要大量显存。
6.5 一条主线:让信息与梯度自由流动
ResNet 用加法,让信息「绕过」;DenseNet 用拼接,让信息「保留」;BatchNorm 让每层输入「稳定」。它们从不同角度解决同一个问题:深网络里信号(前向的信息、反向的梯度)不能衰减。这条主线在 2017 年的 Transformer 里变成残差连接 + LayerNorm(第 9 章),一直延续到今天。
完整代码
本章用到的完整示例代码:
examples/ch06_resnet.py
"""第 6 章示例(2015):ResNet-18 风格网络在 Fashion-MNIST 上训练。
残差连接让梯度可以"跳过"卷积层直达浅层,使 152 层的极深网络
第一次变得可训练。这里复现 ResNet-18 的结构(4 个阶段 × 2 个残差块)。
运行方式:
uv run python examples/ch06_resnet.py
"""
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.2860,), (0.3530,)),
])
train = datasets.FashionMNIST("data", train=True, download=True, transform=transform)
test = datasets.FashionMNIST("data", train=False, download=True, transform=transform)
train_loader = DataLoader(train, batch_size=256, shuffle=True)
test_loader = DataLoader(test, batch_size=1024)
class BasicBlock(nn.Module):
"""两个 3×3 卷积 + 批量归一化 + 残差相加。"""
def __init__(self, in_planes, planes, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_planes, planes, 3, stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(planes)
self.conv2 = nn.Conv2d(planes, planes, 3, 1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(planes)
self.relu = nn.ReLU(inplace=True)
self.shortcut = nn.Sequential()
if stride != 1 or in_planes != planes:
self.shortcut = nn.Sequential(
nn.Conv2d(in_planes, planes, 1, stride, bias=False),
nn.BatchNorm2d(planes),
)
def forward(self, x):
out = self.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += self.shortcut(x)
return self.relu(out)
class ResNet18(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(1, 64, 3, 1, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True)
self.layers = nn.Sequential(*self._make_stages())
self.pool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Linear(512, num_classes)
def _make_stages(self):
layers, in_planes, planes = [], 64, 64
for stage, count in enumerate([2, 2, 2, 2]):
stride = 1 if stage == 0 else 2
layers.append(BasicBlock(in_planes, planes, stride))
in_planes = planes
for _ in range(count - 1):
layers.append(BasicBlock(planes, planes))
planes *= 2
return layers
def forward(self, x):
x = self.relu(self.bn1(self.conv1(x)))
x = self.layers(x)
x = self.pool(x).flatten(1)
return self.fc(x)
model = ResNet18().to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
print(f"ResNet-18 风格网络参数量:{sum(p.numel() for p in model.parameters()):,}")
def evaluate(loader):
model.eval()
correct = total = 0
with torch.no_grad():
for xb, yb in loader:
pred = model(xb.to(device)).argmax(1)
correct += (pred == yb.to(device)).sum().item()
total += len(yb)
return correct / total
for epoch in range(3):
model.train()
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
loss = criterion(model(xb), yb)
loss.backward()
opt.step()
print(f"epoch {epoch + 1}:训练准确率 = {evaluate(train_loader) * 100:.2f}%")
print(f"测试准确率 = {evaluate(test_loader) * 100:.2f}%")examples/ch06_batchnorm.py
"""第 6 章示例(2015):BatchNorm 对训练的影响。
两个结构完全相同的 CNN,一个带批量归一化,一个不带,
在同样的数据和超参数下训练,对比验证准确率。
运行方式:
uv run python examples/ch06_batchnorm.py
"""
import torch
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.2860,), (0.3530,)),
])
train = datasets.FashionMNIST("data", train=True, download=True, transform=transform)
test = datasets.FashionMNIST("data", train=False, download=True, transform=transform)
train_loader = DataLoader(train, batch_size=256, shuffle=True)
val_loader = DataLoader(Subset(test, range(3000)), batch_size=512)
def make_cnn(use_bn):
def block(in_c, out_c):
layers = [nn.Conv2d(in_c, out_c, 3, padding=1)]
if use_bn:
layers.append(nn.BatchNorm2d(out_c))
layers += [nn.ReLU(), nn.MaxPool2d(2)]
return nn.Sequential(*layers)
return nn.Sequential(
block(1, 32),
block(32, 64),
nn.Flatten(),
nn.Linear(64 * 7 * 7, 10),
)
def train_and_eval(model, name, epochs=4):
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(epochs):
model.train()
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
loss = nn.functional.cross_entropy(model(xb), yb)
loss.backward()
opt.step()
model.eval()
correct = total = 0
with torch.no_grad():
for xb, yb in val_loader:
pred = model(xb.to(device)).argmax(1)
correct += (pred == yb.to(device)).sum().item()
total += len(yb)
print(f"{name} epoch {epoch + 1}:验证准确率 = {correct / total * 100:.2f}%")
print("带 BatchNorm:")
train_and_eval(make_cnn(True).to(device), "BN ")
print("不带 BatchNorm:")
train_and_eval(make_cnn(False).to(device), "无BN ")examples/ch06_densenet.py
"""第 6 章示例(2016):DenseNet 的稠密连接。
每一层都把前面所有层的输出拼接起来作为输入,特征在层间"复用",
缓解梯度消失,同时参数量更小。
运行方式:
uv run python examples/ch06_densenet.py
"""
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.2860,), (0.3530,)),
])
train = datasets.FashionMNIST("data", train=True, download=True, transform=transform)
test = datasets.FashionMNIST("data", train=False, download=True, transform=transform)
train_loader = DataLoader(train, batch_size=256, shuffle=True)
test_loader = DataLoader(test, batch_size=1024)
class DenseLayer(nn.Module):
def __init__(self, in_c, growth=12):
super().__init__()
self.layer = nn.Sequential(
nn.BatchNorm2d(in_c), nn.ReLU(),
nn.Conv2d(in_c, growth, 3, padding=1),
)
def forward(self, x):
return torch.cat([x, self.layer(x)], dim=1) # 拼接,而不是相加
class DenseBlock(nn.Module):
def __init__(self, in_c, n_layers, growth=12):
super().__init__()
self.layers = nn.ModuleList()
c = in_c
for _ in range(n_layers):
self.layers.append(DenseLayer(c, growth))
c += growth
self.out_c = c
def forward(self, x):
for layer in self.layers:
x = layer(x)
return x
class TinyDenseNet(nn.Module):
def __init__(self):
super().__init__()
self.stem = nn.Sequential(nn.Conv2d(1, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU())
self.block1 = DenseBlock(16, 2) # 16→40
self.transition = nn.Sequential(
nn.BatchNorm2d(40), nn.ReLU(),
nn.Conv2d(40, 24, 1), nn.AvgPool2d(2), # 28→14
)
self.block2 = DenseBlock(24, 2) # 24→48
self.head = nn.Sequential(
nn.BatchNorm2d(48), nn.ReLU(),
nn.AdaptiveAvgPool2d(1), nn.Flatten(),
nn.Linear(48, 10),
)
def forward(self, x):
return self.head(self.block2(self.transition(self.block1(self.stem(x)))))
model = TinyDenseNet().to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()
print(f"DenseNet 风格参数量:{sum(p.numel() for p in model.parameters()):,}")
def evaluate(loader):
model.eval()
correct = total = 0
with torch.no_grad():
for xb, yb in loader:
pred = model(xb.to(device)).argmax(1)
correct += (pred == yb.to(device)).sum().item()
total += len(yb)
return correct / total
for epoch in range(3):
model.train()
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
loss = criterion(model(xb), yb)
loss.backward()
opt.step()
print(f"epoch {epoch + 1}:训练准确率 = {evaluate(train_loader) * 100:.2f}%")
print(f"测试准确率 = {evaluate(test_loader) * 100:.2f}%")动手实践
- 运行三个示例,对比参数量与准确率,说明「信息流动」设计的代价与收益。
- 在
examples/ch06_resnet.py中删除所有 shortcut(改回普通卷积堆叠),训练 3 轮,观察准确率是否下降。 - 修改
examples/ch06_batchnorm.py,把学习率从 1e-3 提高到 3e-3,观察「有 BN 的网络能承受更大学习率」是否成立。 - 把 DenseNet 的 growth 从 12 改成 24,观察参数量与准确率。
常见错误
| 错误写法 | 现象 | 原因 |
|---|---|---|
| 残差块形状不匹配时直接相加 | The size of tensor a must match... | stride 或通道变化时要加 1×1 卷积投影 |
| BN 在 eval 阶段还在用 batch 统计 | 推理结果漂移 | 必须 model.eval(),训练完评估要切换 |
| 批量大小 = 1 时用 BN | 报错或效果差 | batch 统计不可靠;用 BatchNorm1d/2d 时 batch ≥ 2 |
| DenseNet 忘记录入通道增长 | 拼接后维度对不上 | 每层后通道数 += growth |
| 深度加深但没加残差 | 训练误差升高 | 这就是退化问题本身,用残差块解决 |
章末练习
基础
- 什么是「退化问题」?它和过拟合的区别是什么?
- 残差连接为什么能让梯度直达浅层?
- BatchNorm 训练与推理时统计量的来源有何不同?
提高
- 写一个 50 层纯卷积网络与 50 层残差网络的对比实验,报告训练 loss(小数据集、短训练即可)。
- 解释 DenseNet 为什么参数量少;计算 growth=12、3 层稠密块的通道数变化。
- 把 ResNet 的 BatchNorm 换成 GroupNorm(分组归一化),比较训练曲线与 batch=4 时的稳定性。
挑战
- 实现 ResNet-34(阶段 [3,4,6,3]),在 Fashion-MNIST 上训练,报告参数与准确率。
- 实现带 transition 层(1×1 卷积降维 + 池化)的完整 DenseNet-121 简化版,并验证前向形状。
章末自测
- ResNet 解决的核心问题是?
- A. 过拟合
- B. 深层网络的退化
- C. 数据不足
- D. 推理太慢
- 残差块的输出公式是?
- A. F(x)
- B. F(x) + x
- C. F(x) × x
- D. x - F(x)
- 捷径连接的作用是?
- A. 增加参数量
- B. 让梯度有直达浅层的通路
- C. 数据增强
- D. 降低精度
- BatchNorm 在训练时使用什么统计量?
- A. 全数据统计
- B. 当前 batch 的均值方差
- C. 固定常数
- D. 随机数
- 2015 年 ResNet 在 ImageNet 上训练的深度是?
- A. 18 层
- B. 50 层
- C. 152 层
- D. 1000 层
- DenseNet 的层间连接方式是?
- A. 相加
- B. 拼接
- C. 平均
- D. 卷积
- DenseNet 的 growth 参数控制?
- A. 每层新增的通道数
- B. 网络深度
- C. 学习率
- D. 批大小
- 评估模型时忘记
model.eval()会导致?- A. 更快
- B. BN/Dropout 行为异常,结果不准
- C. 报错
- D. 无影响
- BN 的 ε 参数作用是?
- A. 加速
- B. 防止除零
- C. 放大梯度
- D. 正则化
- 「让信息与梯度自由流动」在 Transformer 中的对应物是?
- A. 卷积
- B. 残差连接与 LayerNorm
- C. 池化
- D. Dropout
