CNN 从 LeNet 到 ResNet


文档摘要

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 到 ResNet

本节摘要:过去三十年里每一个主流 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)。

学习目标

阅读完本节,你应当能够:

  1. 描绘 LeNet-5 → AlexNet → VGG → Inception → ResNet 的架构谱系,说出每个家族贡献的那一个新想法。
  2. 用 PyTorch 在 40 行内实现 LeNet-5、VGG 风格块、ResNet BasicBlock
  3. 解释为什么残差连接能把 1000 层网络从训不动变成最先进。
  4. 在不看源码前,读一个现代骨干(ResNet-18、ResNet-50)就预测它的输出形状、感受野与参数量。

一、问题与直觉

2011 年,最好的 ImageNet 分类器 top-5 准确率约 74%。2012 年 AlexNet 拿到 85%。2015 年 ResNet 拿到 96%。没有新数据,没有新 GPU 代际——这些提升全部来自架构想法。一个称职的视觉工程师必须知道哪个想法来自哪篇论文,因为你在 2026 年交付的每个生产骨干,都是这些零件的重新组合;而且这些想法还在跨界转移:分组卷积从 CNN 走进了 transformer,残差连接从 ResNet 走进了现存每一个 LLM,批归一化活在扩散模型里。

按顺序学这些网络,还能让你免犯一个常见错误:在一个 LeNet 体量就能解决的问题上,伸手去拿最大的模型。MNIST 不需要 ResNet。知道每个家族的缩放曲线,才知道该坐在哪里。

改变视觉的四个想法

经典视觉里,再没有别的跳跃比这四次更重要。

LeNet-5(1998)

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。

AlexNet(2012)

三处改动合力打穿了 ImageNet:

  1. ReLU 取代 tanh。梯度不再消失,训练速度提升六倍。
  2. Dropout 进了全连接头。正则化成了一层,不再是个 trick。
  3. 深度与宽度。五个卷积层、三个全连接层、6000 万参数,在两块 GPU 上训练,模型被劈成两半。

论文的 Figure 2 至今还显示着 GPU 切分形成的两条并行流——那是硬件的权宜之计,不是架构洞见;但上面三个想法,至今还在你用的每个模型里。

VGG(2014)

VGG 问:如果只用 3×3 卷积,而且走深,会怎样?

堆叠: conv 3x3 -> conv 3x3 -> pool 2x2 重复: 16 或 19 层卷积

两个 3×3 卷积看到的输入面积等于一个 5×5,但参数更少(2*9*C^2 = 18C^225*C^2),中间还多一次 ReLU。VGG 把这条观察放大成整个架构。它的简洁——一种块重复到底——让它成为之后一切的参照点。

代价:1.38 亿参数,训练慢,推理贵。

Inception(2014,同年)

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 层以上的深度。

ResNet(2015)

何恺明、张祥雨、任少卿、孙剑提出了一个改动,修好了一切:

普通块: y = F(x) 残差块: y = F(x) + x

那个 + x 意味着:这一层总可以选择「什么都不做」——只要把 F(x) 推到零。一个 1000 层的 ResNet,最多和一个 1 层网络一样差,因为每个额外的块都有一条「平凡的逃生通道」。有了这个保证,优化器就愿意把每个块做得略微有用;略微有用、堆 100 次,就是最先进。

这个块有两个变体,到处出现:

  • BasicBlock(ResNet-18、ResNet-34):两个 3×3 卷积,跨两者做 skip。
  • Bottleneck(ResNet-50、-101、-152):1×1 降、3×3 中、1×1 升,跨三者做 skip;通道数高时更省。

当 skip 要跨过一次下采样(stride=2)时,恒等路径被换成一个 1×1、stride=2 的卷积,以匹配形状。

为什么残差超越视觉

这个想法其实不关乎图像分类,而是关乎把深度网络从「祈祷梯度能存活」变成一个可靠、可扩展的工程工具。你下一章要读的每个 transformer,每个块里都有同款 skip 连接。没有 ResNet,就没有 GPT。

二、从零实现

步骤 1:LeNet-5

一个极简、忠实的 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。这就是开启了现代视觉的整套数字分类器。

步骤 2:一个 VGG 块

一块可复用块:两个 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 绰绰有余。

步骤 3:一个 ResNet BasicBlock

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 的恒等。

步骤 4:一个微型 ResNet

堆四组 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 的标准配方。

步骤 5:对比参数-特征效率

把同一个输入喂进三个网络,比较参数量。

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 相对加法的位置)。

五、练习

  1. (简单) 逐层手算 TinyResNet 的参数量,对比 sum(p.numel() for p in net.parameters())。参数预算主要花在哪——卷积、BN,还是分类头?
  2. (中等) 实现 Bottleneck 块(1×1 → 3×3 → 1×1,带 skip),用它搭一个 ResNet-50 风格的网络用于 CIFAR,与 TinyResNet 比参数量。
  3. (困难)BasicBlock 的 skip 拿掉,训一个 34 块的「普通」网络和一个 34 块的 ResNet,在 CIFAR-10 上各跑 10 个 epoch。画出两者训练损失-epoch 曲线,复现何恺明等论文的 Figure 1——普通深网络收敛到比它更浅的双胞胎更高的损失。

本节要点回顾

  1. 架构谱系四跳——LeNet 定义模板,AlexNet 加 ReLU+Dropout+深,VGG 用 3×3 堆叠,Inception 并行多尺度,ResNet 加残差连接解锁深度。
  2. LeNet 是所有 CNN 的祖先——卷积+下采样交替,再喂给小分类头;现代 CNN 只是更深更宽激活更好。
  3. 3×3 堆叠省参数——两个 3×3 等于一个 5×5 的视野,但参数从 25C² 降到 18C²,还多一次非线性。
  4. 退化问题不是过拟合——20 层以上普通深网络训练/测试误差都变差,根因是梯度乘法式缩水。
  5. 残差 = F(x) + x——每个块都能选择「什么都不做」,深网络至少不比浅网络差;优化器才敢把每块做得略微有用。
  6. 两个块变体——BasicBlock(ResNet-18/34,两个 3×3)、Bottleneck(ResNet-50/101/152,1×1-3×3-1×1)。
  7. skip 跨下采样要投影——步幅或通道变化时,恒等路径换成 1×1 stride 卷积匹配形状。
  8. bias=False 是 BN 约定——beta 已承担偏置,卷积偏置是浪费。
  9. 参数效率差 12 倍——ResNet-18 vs VGG-16,ImageNet 准确率相近,这正是 ResNet 统治真实部署的原因。
  10. 残差超越视觉——每个 transformer 块都有同款 skip;没有 ResNet,就没有 GPT。

下一节,我们把这些骨干接到一个端到端的图像分类流水线上——数据增强、训练循环、Top-1/Top-5 指标、混淆矩阵,把「能跑」变成「能交付」。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U