CNN 从 LeNet 到 ResNet 本节摘要:过去三十年里每一个主流 CNN,都是同一套「卷积—非线性—下采样」配方,再在它上面加一个新想法。本节按时间顺序,把 LeNet-5 → AlexNet → VGG → Inception → ResNet 这条架构谱系串起来,讲清每个家族贡献的那「一个」新想法:ReLU、Dropout、3×3 堆叠、并行多尺度、残差连接。然后用 PyTorch 在 40 行内分别实现 LeNet-5、VGG 块和 ResNet 的 BasicBlock,讲清为什么残差连接能把一千层网络从「训不动」变成「最先进」。读完本节,你能在不看源码的情况下,预测 ResNet-18/50 这类现代骨干的输出形状、感受野和参数量,并知道一个任务该坐在哪条缩放曲线上。
本节摘要:过去三十年里每一个主流 CNN,都是同一套「卷积—非线性—下采样」配方,再在它上面加一个新想法。本节按时间顺序,把 LeNet-5 → AlexNet → VGG → Inception → ResNet 这条架构谱系串起来,讲清每个家族贡献的那「一个」新想法:ReLU、Dropout、3×3 堆叠、并行多尺度、残差连接。然后用 PyTorch 在 40 行内分别实现 LeNet-5、VGG 块和 ResNet 的 BasicBlock,讲清为什么残差连接能把一千层网络从「训不动」变成「最先进」。读完本节,你能在不看源码的情况下,预测 ResNet-18/50 这类现代骨干的输出形状、感受野和参数量,并知道一个任务该坐在哪条缩放曲线上。
对应原课程:Phase 4 · Lesson 03 ·
cnns-lenet-to-resnet(原英文phases/04-computer-vision/03-cnns-lenet-to-resnet/docs/en.md)。
阅读完本节,你应当能够:
2011 年,最好的 ImageNet 分类器 top-5 准确率约 74%。2012 年 AlexNet 拿到 85%。2015 年 ResNet 拿到 96%。没有新数据,没有新 GPU 代际——这些提升全部来自架构想法。一个称职的视觉工程师必须知道哪个想法来自哪篇论文,因为你在 2026 年交付的每个生产骨干,都是这些零件的重新组合;而且这些想法还在跨界转移:分组卷积从 CNN 走进了 transformer,残差连接从 ResNet 走进了现存每一个 LLM,批归一化活在扩散模型里。
按顺序学这些网络,还能让你免犯一个常见错误:在一个 LeNet 体量就能解决的问题上,伸手去拿最大的模型。MNIST 不需要 ResNet。知道每个家族的缩放曲线,才知道该坐在哪里。
经典视觉里,再没有别的跳跃比这四次更重要。
Yann LeCun 的手写数字识别器。6 万参数。两个卷积-池化块,两个全连接层,tanh 激活。它定义了所有 CNN 继承的模板:
输入 (1, 32, 32) conv 5x5 -> (6, 28, 28) avg pool 2x2 -> (6, 14, 14) conv 5x5 -> (16, 10, 10) avg pool 2x2 -> (16, 5, 5) flatten -> 400 dense -> 120 dense -> 84 dense -> 10
今天被称为 CNN 的一切——「卷积与下采样交替、再喂给一个小的分类头」——不过是层数更多、通道更宽、激活更好的 LeNet。
三处改动合力打穿了 ImageNet:
论文的 Figure 2 至今还显示着 GPU 切分形成的两条并行流——那是硬件的权宜之计,不是架构洞见;但上面三个想法,至今还在你用的每个模型里。
VGG 问:如果只用 3×3 卷积,而且走深,会怎样?
堆叠: conv 3x3 -> conv 3x3 -> pool 2x2 重复: 16 或 19 层卷积
两个 3×3 卷积看到的输入面积等于一个 5×5,但参数更少(2*9*C^2 = 18C^2 对 25*C^2),中间还多一次 ReLU。VGG 把这条观察放大成整个架构。它的简洁——一种块重复到底——让它成为之后一切的参照点。
代价:1.38 亿参数,训练慢,推理贵。
Google 对「核尺寸该用多大?」的回答是:全都用,并行。
每条分支各司其职——1×1 做通道混合,3×3 做局部纹理,5×5 做更大模式,池化做平移不变特征——拼接让下一层挑选哪条有用。Inception v1 还在每个分支里用 1×1 卷积做瓶颈,把参数量压住。
到 2015 年,VGG-19 能训出来,VGG-32 训不动。深度本该有益,但过了约 20 层,训练误差和测试误差都变差。这不是过拟合——是优化器找不到有用的权重,因为梯度在每一层乘法式地缩水。
普通深网络: y = f_L( f_{L-1}( ... f_1(x) ... ) ) 对早期层的梯度: dL/dW_1 = dL/dy * df_L/df_{L-1} * ... * df_2/df_1 * df_1/dW_1 每个乘法项的量级约为 (权重量级) * (激活增益)。 堆 100 个、每个增益 < 1,梯度就实际为零。
VGG 在 19 层能行,是因为同期发表的批归一化让激活保持良好尺度。但即便是 BN,也救不了 30 层以上的深度。
何恺明、张祥雨、任少卿、孙剑提出了一个改动,修好了一切:
普通块: y = F(x) 残差块: y = F(x) + x
那个 + x 意味着:这一层总可以选择「什么都不做」——只要把 F(x) 推到零。一个 1000 层的 ResNet,最多和一个 1 层网络一样差,因为每个额外的块都有一条「平凡的逃生通道」。有了这个保证,优化器就愿意把每个块做得略微有用;略微有用、堆 100 次,就是最先进。
这个块有两个变体,到处出现:
当 skip 要跨过一次下采样(stride=2)时,恒等路径被换成一个 1×1、stride=2 的卷积,以匹配形状。
这个想法其实不关乎图像分类,而是关乎把深度网络从「祈祷梯度能存活」变成一个可靠、可扩展的工程工具。你下一章要读的每个 transformer,每个块里都有同款 skip 连接。没有 ResNet,就没有 GPT。
一个极简、忠实的 LeNet。tanh 激活,平均池化。唯一向现代做的让步,是下游用 nn.CrossEntropyLoss,而非原版的高斯连接。
import torch import torch.nn as nn import torch.nn.functional as F class LeNet5(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(1, 6, kernel_size=5) self.conv2 = nn.Conv2d(6, 16, kernel_size=5) self.pool = nn.AvgPool2d(2) self.fc1 = nn.Linear(16 * 5 * 5, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, num_classes) def forward(self, x): x = self.pool(torch.tanh(self.conv1(x))) x = self.pool(torch.tanh(self.conv2(x))) x = torch.flatten(x, 1) x = torch.tanh(self.fc1(x)) x = torch.tanh(self.fc2(x)) return self.fc3(x) net = LeNet5() x = torch.randn(1, 1, 32, 32) print(f"output: {net(x).shape}") print(f"params: {sum(p.numel() for p in net.parameters()):,}")
预期输出:output: torch.Size([1, 10])、params: 61,706。这就是开启了现代视觉的整套数字分类器。
一块可复用块:两个 3×3 卷积、ReLU、批归一化、最大池化。
class VGGBlock(nn.Module): def __init__(self, in_c, out_c): super().__init__() self.conv1 = nn.Conv2d(in_c, out_c, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(out_c) self.conv2 = nn.Conv2d(out_c, out_c, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(out_c) self.pool = nn.MaxPool2d(2) def forward(self, x): x = F.relu(self.bn1(self.conv1(x))) x = F.relu(self.bn2(self.conv2(x))) return self.pool(x) class MiniVGG(nn.Module): def __init__(self, num_classes=10): super().__init__() self.stack = nn.Sequential( VGGBlock(3, 32), VGGBlock(32, 64), VGGBlock(64, 128), ) self.head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, num_classes), ) def forward(self, x): return self.head(self.stack(x)) net = MiniVGG() x = torch.randn(1, 3, 32, 32) print(f"output: {net(x).shape}") print(f"params: {sum(p.numel() for p in net.parameters()):,}")
三个 VGG 块作用在 CIFAR 尺寸输入,一个自适应池化,一层线性。约 29 万参数。对 CIFAR-10 绰绰有余。
ResNet-18 和 ResNet-34 的核心构件。
class BasicBlock(nn.Module): def __init__(self, in_c, out_c, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_c, out_c, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_c) self.conv2 = nn.Conv2d(out_c, out_c, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_c) if stride != 1 or in_c != out_c: self.shortcut = nn.Sequential( nn.Conv2d(in_c, out_c, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_c), ) else: self.shortcut = nn.Identity() def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out = out + self.shortcut(x) return F.relu(out)
卷积层上的 bias=False 是 BN 的约定——BN 的 beta 参数已经承担了偏置,再带卷积偏置是浪费。shortcut 只在步幅或通道数变化时才需要真正的卷积;否则就是一个 no-op 的恒等。
堆四组 BasicBlock,得到一个能在 CIFAR 尺寸输入上工作的 ResNet。
class TinyResNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.stem = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, stride=1, padding=1, bias=False), nn.BatchNorm2d(32), nn.ReLU(inplace=True), ) self.layer1 = self._make_group(32, 32, num_blocks=2, stride=1) self.layer2 = self._make_group(32, 64, num_blocks=2, stride=2) self.layer3 = self._make_group(64, 128, num_blocks=2, stride=2) self.layer4 = self._make_group(128, 256, num_blocks=2, stride=2) self.head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(256, num_classes), ) def _make_group(self, in_c, out_c, num_blocks, stride): blocks = [BasicBlock(in_c, out_c, stride=stride)] for _ in range(num_blocks - 1): blocks.append(BasicBlock(out_c, out_c, stride=1)) return nn.Sequential(*blocks) def forward(self, x): x = self.stem(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) return self.head(x) net = TinyResNet() x = torch.randn(1, 3, 32, 32) print(f"output: {net(x).shape}") print(f"params: {sum(p.numel() for p in net.parameters()):,}")
四组、每组两块。第 2、3、4 组开头步幅 2。每次下采样通道数翻倍。约 280 万参数。这就是能干净扩展到 ResNet-152 的标准配方。
把同一个输入喂进三个网络,比较参数量。
def summary(name, net, x): y = net(x) params = sum(p.numel() for p in net.parameters()) print(f"{name:12s} input {tuple(x.shape)} -> output {tuple(y.shape)} params {params:>10,}") x = torch.randn(1, 3, 32, 32) summary("LeNet5", LeNet5(), torch.randn(1, 1, 32, 32)) summary("MiniVGG", MiniVGG(), x) summary("TinyResNet", TinyResNet(), x)
三个模型,三个时代,参数量差三个数量级。在 CIFAR-10 上,粗略地:LeNet 60%、MiniVGG 89%、TinyResNet 93%(训练几个 epoch 后)。
torchvision.models 给你上面所有的预训练版本。各家族调用签名一致,这正是骨干抽象的意义。
from torchvision.models import resnet18, ResNet18_Weights, vgg16, VGG16_Weights r18 = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1) r18.eval() print(f"ResNet-18 params: {sum(p.numel() for p in r18.parameters()):,}") print(r18.layer1[0]) print() v16 = vgg16(weights=VGG16_Weights.IMAGENET1K_V1) v16.eval() print(f"VGG-16 params: {sum(p.numel() for p in v16.parameters()):,}")
ResNet-18 有 1170 万参数。VGG-16 有 1.38 亿。ImageNet top-1 准确率相近(69.8% 对 71.6%)。残差连接换来 12 倍的参数效率。这正是 ResNet 变体从 2016 年到 2021 年 ViT 出现前一直统治、且在算力受限的真实部署里至今统治的原因。
迁移学习的配方永远一样:加载预训练,冻结骨干,换掉分类头。
for p in r18.parameters(): p.requires_grad = False r18.fc = nn.Linear(r18.fc.in_features, 10)
三行。你现在有了一个 10 类 CIFAR 分类器,白嫖了 ImageNet 已经付过钱的表示。
本节产出两个可复用文件(位于原课程 outputs/):
prompt-backbone-selector.md:一个提示词——给定任务、数据集大小和算力预算,挑出合适的 CNN 家族(LeNet/VGG/ResNet/MobileNet/ConvNeXt)。skill-residual-block-reviewer.md:一个技能——读一个 PyTorch 模块,标出 skip 连接的错误(步幅变化时漏了 shortcut、shortcut 的激活顺序、BN 相对加法的位置)。TinyResNet 的参数量,对比 sum(p.numel() for p in net.parameters())。参数预算主要花在哪——卷积、BN,还是分类头?TinyResNet 比参数量。BasicBlock 的 skip 拿掉,训一个 34 块的「普通」网络和一个 34 块的 ResNet,在 CIFAR-10 上各跑 10 个 epoch。画出两者训练损失-epoch 曲线,复现何恺明等论文的 Figure 1——普通深网络收敛到比它更浅的双胞胎更高的损失。F(x) + x——每个块都能选择「什么都不做」,深网络至少不比浅网络差;优化器才敢把每块做得略微有用。下一节,我们把这些骨干接到一个端到端的图像分类流水线上——数据增强、训练循环、Top-1/Top-5 指标、混淆矩阵,把「能跑」变成「能交付」。