Skip to content

第 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) + x

F(x) 是两层卷积的输出,x 是输入,两者逐元素相加(形状不同时用 1×1 卷积投影)。这个 +x 的旁路叫捷径连接(shortcut)。

为什么有效?梯度从输出回传时,经过捷径直接「抄近道」到浅层,路径上没有任何需要连乘的参数——梯度消失被绕开。即使 F(x) 学不动,F(x)+x 至少保留输入,网络不会退化。

examples/ch06_resnet.py 实现 ResNet-18(4 个阶段 × 2 个残差块):

python
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 + ε)
输出 = γ·ẑ + β

作用有三:

  1. 稳定训练:每层输入分布不再漂移(内部协变量偏移),可以使用更大的学习率;
  2. 缓解梯度问题:归一化让梯度更健康;
  3. 轻微正则化: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)里,每一层都把之前所有层的输出拼接起来作为输入:

python
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 ​

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 ​

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 ​

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}%")

动手实践 ​

  1. 运行三个示例,对比参数量与准确率,说明「信息流动」设计的代价与收益。
  2. 在 examples/ch06_resnet.py 中删除所有 shortcut(改回普通卷积堆叠),训练 3 轮,观察准确率是否下降。
  3. 修改 examples/ch06_batchnorm.py,把学习率从 1e-3 提高到 3e-3,观察「有 BN 的网络能承受更大学习率」是否成立。
  4. 把 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
深度加深但没加残差训练误差升高这就是退化问题本身,用残差块解决

章末练习 ​

基础

  1. 什么是「退化问题」?它和过拟合的区别是什么?
  2. 残差连接为什么能让梯度直达浅层?
  3. BatchNorm 训练与推理时统计量的来源有何不同?

提高

  1. 写一个 50 层纯卷积网络与 50 层残差网络的对比实验,报告训练 loss(小数据集、短训练即可)。
  2. 解释 DenseNet 为什么参数量少;计算 growth=12、3 层稠密块的通道数变化。
  3. 把 ResNet 的 BatchNorm 换成 GroupNorm(分组归一化),比较训练曲线与 batch=4 时的稳定性。

挑战

  1. 实现 ResNet-34(阶段 [3,4,6,3]),在 Fashion-MNIST 上训练,报告参数与准确率。
  2. 实现带 transition 层(1×1 卷积降维 + 池化)的完整 DenseNet-121 简化版,并验证前向形状。

章末自测 ​

  1. ResNet 解决的核心问题是?
    • A. 过拟合
    • B. 深层网络的退化
    • C. 数据不足
    • D. 推理太慢
  2. 残差块的输出公式是?
    • A. F(x)
    • B. F(x) + x
    • C. F(x) × x
    • D. x - F(x)
  3. 捷径连接的作用是?
    • A. 增加参数量
    • B. 让梯度有直达浅层的通路
    • C. 数据增强
    • D. 降低精度
  4. BatchNorm 在训练时使用什么统计量?
    • A. 全数据统计
    • B. 当前 batch 的均值方差
    • C. 固定常数
    • D. 随机数
  5. 2015 年 ResNet 在 ImageNet 上训练的深度是?
    • A. 18 层
    • B. 50 层
    • C. 152 层
    • D. 1000 层
  6. DenseNet 的层间连接方式是?
    • A. 相加
    • B. 拼接
    • C. 平均
    • D. 卷积
  7. DenseNet 的 growth 参数控制?
    • A. 每层新增的通道数
    • B. 网络深度
    • C. 学习率
    • D. 批大小
  8. 评估模型时忘记 model.eval() 会导致?
    • A. 更快
    • B. BN/Dropout 行为异常,结果不准
    • C. 报错
    • D. 无影响
  9. BN 的 ε 参数作用是?
    • A. 加速
    • B. 防止除零
    • C. 放大梯度
    • D. 正则化
  10. 「让信息与梯度自由流动」在 Transformer 中的对应物是?
    • A. 卷积
    • B. 残差连接与 LayerNorm
    • C. 池化
    • D. Dropout