mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
5658 字
15 分钟
从 LeNet 到 ResNet:经典 CNN 的结构演进与训练框架抽象

一、先看全景:这 5 个网络各自在解决什么问题#

先给出一张总览表,建立统一坐标系。

说明:下面的“本地训练记录”来自当前已有训练产物。由于数据集、类别数、输入尺寸都不一致,这些数字只能说明各自实现已经跑通,不能把它们当成严格横向排名。

网络当前任务 / 数据集输入尺寸核心机制参数量本地训练记录
LeNetFashionMNIST28x28卷积 + 池化 + 全连接61,750未统一记录
AlexNetFashionMNIST64x64更深的卷积堆叠 + 更强分类头6,910,666最佳val_acc=0.9412,第 23
VGGCIFAR-1032x32小卷积连续堆叠 + 配置表构网14,857,034最佳val_acc=0.8981,第 25
GoogLeNetCIFAR-1032x32Inception 多分支多尺度特征提取6,016,170最佳val_acc=0.8998,第 25
ResNet18CIFAR-10032x32残差连接 + stage 化深层堆叠11,220,132最佳val_acc=0.9156,第 45

仅作为学习练习使用,没做最优化调整和数据集适配,训练结果存在有偏差

如果把它们放在同一条学习主线上,可以这样理解:

  • LeNet 建立了“最小 CNN 闭环”。
  • AlexNet 说明了更深、更宽的卷积网络为什么更强。
  • VGG 把深度写成了规则,而不是一层层手搓。
  • GoogLeNet 把同一层里的多尺度并行提取推到了结构中心。
  • ResNet 则把关注点推进到了深层网络的可训练性问题。

二、统一坐标系:先把 CNN 训练闭环看明白#

在真正拆每个网络之前,先把所有模型共用的基础逻辑讲清楚。后面所有结构,不管是 LeNet 还是 ResNet,本质上都没有跳出这个闭环。

1. 输入张量到底长什么样#

在 PyTorch 图像任务里,最常见的输入形状是:

[batch_size, channels, height, width]

例如:

  • FashionMNIST 的一批灰度图可能是 [32, 1, 28, 28]
  • CIFAR-10 的一批 RGB 图可能是 [128, 3, 32, 32]

这里四个维度的含义分别是:

  • batch_size:一次送进模型多少张图片
  • channels:通道数,灰度图通常是 1,彩色图通常是 3
  • height:图像高
  • width:图像宽

2. 卷积、池化、激活、BatchNorm 各自做什么#

一个典型 CNN 里,最常见的是这四类模块:

  • Conv2d:负责从局部邻域里提取模式,比如边缘、纹理、形状片段
  • ReLU / LeakyReLU:负责引入非线性,否则再多层线性叠起来也还是线性
  • MaxPool2d / AvgPool2d:负责压缩空间尺寸,减少计算量,同时扩大后续层的有效感受野
  • BatchNorm2d:负责稳定激活分布,让训练更稳、更容易调学习率

可以把它们看成一条流水线:

像素 -> 卷积提局部模式 -> 激活引入非线性 -> 池化压缩空间 -> 更深层继续组合特征

3. 为什么最后一层通常不手写 softmax#

分类模型最后一层经常直接输出 logits,也就是每个类别的原始分数:

[batch_size, num_classes]

比如 10 分类任务,输出形状可能就是:

[128, 10]

这里通常不在模型里手写 softmax,因为训练时更常搭配:

nn.CrossEntropyLoss()

这个损失函数内部已经会处理 logits -> 概率分布 的数值计算。 所以模型最后一层更常见的写法是“直接输出线性层结果”。

4. 一次最小训练 step 到底发生了什么#

下面这段代码,是后面所有训练脚本都绕不开的最小骨架:

model.train()
images = images.to(device)
labels = labels.to(device)
logits = model(images)
loss = criterion(logits, labels)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()

这几行代码的含义:

  1. model.train():把模型切到训练模式,DropoutBatchNorm 会按训练逻辑工作。
  2. logits = model(images):执行前向传播,得到每个类别的原始分数。
  3. loss = criterion(logits, labels):计算预测和真实标签之间的误差。
  4. optimizer.zero_grad(...):清空旧梯度。
  5. loss.backward():反向传播,计算每个参数该往哪个方向改。
  6. optimizer.step():真正更新参数。

验证和测试时则要切换到:

model.eval()
with torch.no_grad():
logits = model(images)

原因也很简单:

  • eval() 会关闭训练态下的随机行为
  • torch.no_grad() 会关闭梯度记录,减少显存和计算开销

三、LeNet:最小 CNN 闭环从这里真正成立#

1. 它解决了什么结构问题#

如果完全不用卷积,而是直接把图片展平成一个长向量再接全连接层,会有两个明显问题:

  • 参数量很容易爆炸
  • 空间局部结构会被破坏

LeNet 的核心价值就在于: 它先用卷积和池化提取局部模式,再把高层特征交给全连接层做分类。

因此,LeNet 可以看成卷积网络学习的第一个真正起点,因为它第一次把下面这条链条完整连了起来:

输入图片 -> 卷积提特征 -> 池化压缩 -> 展平 -> 全连接分类

2. 当前实现的任务适配#

当前这版 LeNet 不是严格的经典论文复刻,而是做了现代化改写:

  • 把经典 Sigmoid 换成了 LeakyReLU
  • 把经典 AvgPool2d 换成了 MaxPool2d
  • 在卷积层后加入了 BatchNorm2d
  • 在全连接层之间加入了 Dropout

这意味着它更像“适合今天训练习惯的 LeNet 版本”,而不是历史原型原封不动照搬。

3. 关键模型代码#

import torch
from torch import nn
class LeNet(nn.Module):
def __init__(self):
super().__init__()
self.c1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, padding=2)
self.bn1 = nn.BatchNorm2d(6)
self.act = nn.LeakyReLU(inplace=True)
self.s2 = nn.MaxPool2d(kernel_size=2, stride=2)
self.c3 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5)
self.bn2 = nn.BatchNorm2d(16)
self.s4 = nn.MaxPool2d(kernel_size=2, stride=2)
self.flatten = nn.Flatten()
self.f5 = nn.Linear(16 * 5 * 5, 120)
self.dropout = nn.Dropout(p=0.5)
self.f6 = nn.Linear(120, 84)
self.f7 = nn.Linear(84, 10)
def forward(self, x):
x = self.act(self.bn1(self.c1(x)))
x = self.s2(x)
x = self.act(self.bn2(self.c3(x)))
x = self.s4(x)
x = self.flatten(x)
x = self.act(self.f5(x))
x = self.dropout(x)
x = self.act(self.f6(x))
x = self.f7(x)
return x

4. shape 是怎么流动的#

以一张 28x28 的灰度图为例:

阶段运算输出 shape
输入原图1 x 28 x 28
c15x5 卷积,padding=26 x 28 x 28
s22x2 最大池化6 x 14 x 14
c35x5 卷积16 x 10 x 10
s42x2 最大池化16 x 5 x 5
flatten展平400
f5全连接120
f6全连接84
f7分类输出10

这里最关键的理解是:

  • 卷积层负责把图像变成特征图
  • 池化层负责在保留主要模式的同时压缩空间尺寸
  • 最后全连接层负责把高层特征映射成类别分数

5. LeNet 真正建立了什么基础#

LeNet 最重要的价值不是“模型很强”,而是第一次把下面这套方法论完整建立起来:

  • CNN 不是直接分类像素,而是分阶段提取特征
  • forward() 里每一步都对应 shape 的变化
  • 最后一层输出的是 logits,不是概率
  • 训练脚本、本体模型、测试流程必须能闭环协同

四、AlexNet:从最小 CNN 走向更深、更宽、更强的卷积网络#

1. 它解决了什么结构问题#

LeNet 能跑通,但它太浅、太小,适合的是相对简单的任务。 AlexNet 往前迈了一大步,它把 CNN 的能力从“能提局部特征”推进到“能提更复杂、更抽象的层级特征”。

AlexNet 代表的结构升级主要有三点:

  • 更深的卷积堆叠
  • 更多的通道数
  • 更强的分类头

也就是说,它不仅让网络更“长”,也让网络更“宽”。

2. 当前实现的任务适配#

当前实现并不是论文版 AlexNet 的原样照抄,而是一版面向 FashionMNIST 做过适配的 AlexNet-like 结构,主要改动是:

  • 输入从超大图改成了 64x64
  • 首层从 11x11 / stride=4 改成了 5x5 / stride=1
  • 每个卷积块后加入 BatchNorm2d
  • 分类头从超重全连接改成 1024 -> 256 -> 10

这几个改动背后的逻辑很统一:

  • 小图任务更怕过早丢细节
  • 类别数较少时,分类头没必要极重
  • 参数预算应该更多花在卷积特征提取上

3. 关键模型代码#

import torch
from torch import nn
class AlexNet(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 64, kernel_size=5, stride=1, padding=2),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(64, 192, kernel_size=5, padding=2),
nn.BatchNorm2d(192),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.Conv2d(192, 384, kernel_size=3, padding=1),
nn.BatchNorm2d(384),
nn.ReLU(inplace=True),
nn.Conv2d(384, 256, kernel_size=3, padding=1),
nn.BatchNorm2d(256),
nn.ReLU(inplace=True),
nn.Conv2d(256, 256, kernel_size=3, padding=1),
nn.BatchNorm2d(256),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2),
nn.AdaptiveAvgPool2d((4, 4)),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Dropout(p=0.5),
nn.Linear(256 * 4 * 4, 1024),
nn.ReLU(inplace=True),
nn.Dropout(p=0.5),
nn.Linear(1024, 256),
nn.ReLU(inplace=True),
nn.Linear(256, 10),
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x

4. 数据流为什么这样安排#

这版 AlexNet 的一个关键设计点,是“不是每做一次卷积就马上池化”。

它的特征流可以写成:

1x64x64
-> 64x64x64
-> pool -> 64x32x32
-> 192x32x32
-> pool -> 192x16x16
-> 384x16x16
-> 256x16x16
-> 256x16x16
-> pool -> 256x8x8
-> adaptive avg pool -> 256x4x4
-> flatten -> 4096
-> 1024 -> 256 -> 10

这里值得特别注意三点:

  1. 前期保细节 第一层不用大步长,是为了避免小图像一上来就被采样得太稀。
  2. 中期扩通道 通道数从 64 -> 192 -> 384 -> 256 -> 256,意味着网络在更深层能并行表示更多模式。
  3. 后期控参数 分类头虽然仍然存在,但不再像原论文那样极重,因为当前任务只有 10 类。

5. AlexNet 相对 LeNet 多解决了什么#

和 LeNet 相比,AlexNet 真正推进的是“结构设计”这件事本身:

  • 同样是 CNN,不同任务不能机械照搬同一套输入尺寸
  • 第一层卷积核大小和步长,直接决定早期信息损失有多大
  • 池化不是越多越好,而是要在“已经提到足够特征”的前提下再压缩
  • 分类头不能只看传统写法,还要看当前任务类别数和参数预算

如果说 LeNet 主要回答“CNN 会怎么跑”,那么 AlexNet 开始回答“CNN 为什么要这么搭”。


五、VGG:把“深度”写成一种规则,而不是一层层手搓#

1. 它解决了什么结构问题#

AlexNet 虽然已经很强,但不同卷积核大小、不同层次安排仍然比较“手工”。VGG 的核心贡献是把网络结构变得更规整:

  • 大量使用 3x3 小卷积
  • 通过连续堆叠小卷积来增加深度
  • 每过一个阶段再做一次池化

这背后的思想非常重要:

与其直接用一个很大的卷积核,不如连续堆叠多个小卷积。

这样做的好处是:

  • 感受野可以逐步扩大
  • 非线性层数更多
  • 结构更规则,工程上更容易配置化

2. 当前实现的任务适配#

当前这版 VGG 实现不是固定死的单一版本,而是做成了可切换配置的系列模型:

  • 支持 VGG11 / VGG13 / VGG16 / VGG19
  • 支持是否打开 BatchNorm
  • 使用 AdaptiveAvgPool2d((1, 1))
  • 分类头改成更轻的 512 -> 256 -> num_classes

这说明这里不只是“复现一个网络”,而是在学习如何把一类网络抽象成规则。

3. 关键模型代码#

import torch
from torch import nn
VGG_CONFIGS = {
"VGG11": [64, "M", 128, "M", 256, 256, "M", 512, 512, "M", 512, 512, "M"],
"VGG13": [64, 64, "M", 128, 128, "M", 256, 256, "M", 512, 512, "M", 512, 512, "M"],
"VGG16": [
64, 64, "M",
128, 128, "M",
256, 256, 256, "M",
512, 512, 512, "M",
512, 512, 512, "M",
],
"VGG19": [
64, 64, "M",
128, 128, "M",
256, 256, 256, 256, "M",
512, 512, 512, 512, "M",
512, 512, 512, 512, "M",
],
}
def make_layers(config, in_channels, use_batch_norm=False):
layers = []
current_channels = in_channels
for layer_cfg in config:
if layer_cfg == "M":
layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
continue
conv = nn.Conv2d(
in_channels=current_channels,
out_channels=layer_cfg,
kernel_size=3,
padding=1,
)
if use_batch_norm:
layers.extend([conv, nn.BatchNorm2d(layer_cfg), nn.ReLU(inplace=True)])
else:
layers.extend([conv, nn.ReLU(inplace=True)])
current_channels = layer_cfg
return nn.Sequential(*layers)
class VGGNet(nn.Module):
def __init__(self, config_name="VGG16", num_classes=10, in_channels=3, use_batch_norm=False):
super().__init__()
self.features = make_layers(
VGG_CONFIGS[config_name],
in_channels=in_channels,
use_batch_norm=use_batch_norm,
)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(512, 256),
nn.ReLU(inplace=True),
nn.Dropout(p=0.5),
nn.Linear(256, num_classes),
)
def forward(self, x):
x = self.features(x)
x = self.avgpool(x)
x = self.classifier(x)
return x

4. 这段代码里最值得学的不是层数,而是“规则”#

VGG 的关键不只是“深”,而是“可规则化”。

比如 VGG1632x32 输入下的主干流可以近似理解为:

3x32x32
-> 64x32x32
-> pool -> 64x16x16
-> 128x16x16
-> pool -> 128x8x8
-> 256x8x8
-> pool -> 256x4x4
-> 512x4x4
-> pool -> 512x2x2
-> 512x2x2
-> pool -> 512x1x1
-> classifier

这里有两个特别重要的结构思想:

  1. 连续多个 3x3 小卷积堆叠 这样既能扩大感受野,又能增加非线性层数。
  2. 配置表驱动结构生成 一旦把“哪些层是卷积,哪些层是池化”编码成配置表,模型就从“写死的一份代码”变成了“规则生成的一类网络”。

5. VGG 相对 AlexNet 的关键推进#

和 AlexNet 相比,VGG 代表的是另一种进步:

  • AlexNet 更像“任务适配后的一个具体网络”
  • VGG 更像“一个可以被抽象成模板的家族”

也就是说,这一步不再只需要盯着单个模型,而是开始需要思考:

  • 哪些部分是网络共性
  • 哪些部分可以写成参数化规则
  • 哪些地方适合做配置化抽象

这其实已经在为后面的通用训练框架铺路了。


六、GoogLeNet:同一层里并行看不同尺度#

1. 它解决了什么结构问题#

当网络越来越深、越来越宽时,计算量会迅速变大。GoogLeNet 的关键想法不是继续简单堆深,而是:

在同一层内部,让不同分支并行提取不同尺度的特征。

这就是 Inception 模块的思想。

一个 Inception 模块通常同时包含:

  • 1x1 分支
  • 1x1 -> 3x3 分支
  • 1x1 -> 5x5 分支
  • pool -> 1x1 分支

最后再把这些分支的输出沿通道维拼接起来。

2. 当前实现的任务适配#

当前这版 GoogLeNet 也不是论文原样复刻,而是针对 CIFAR-10 做过小图像适配:

  • stem 改成了连续 3x3 卷积,而不是大卷积核开头
  • BasicConv 内部统一使用 Conv + BN + ReLU
  • 使用 dropout=0.2
  • 主体保留 Inception 多分支思想,但结构更适合 32x32 输入

这说明这里学习的重点不应该只是“记结构图”,而是理解:

  • 多分支为什么有意义
  • 1x1 卷积为什么能做通道压缩
  • 并行分支拼接后为什么能提升多尺度表征

3. 关键模型代码#

import torch
from torch import nn
class BasicConv(nn.Module):
def __init__(self, input_channels, output_channels, kernel_size, stride=1, padding=0):
super().__init__()
self.conv = nn.Conv2d(
input_channels,
output_channels,
kernel_size,
stride,
padding,
bias=False,
)
self.bn = nn.BatchNorm2d(output_channels)
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
x = self.conv(x)
x = self.bn(x)
x = self.relu(x)
return x
class Inception(nn.Module):
def __init__(self, in_channels, ch1x1, ch3x3red, ch3x3, ch5x5red, ch5x5, pool_proj):
super().__init__()
self.branch1 = BasicConv(in_channels, ch1x1, kernel_size=1)
self.branch2 = nn.Sequential(
BasicConv(in_channels, ch3x3red, kernel_size=1),
BasicConv(ch3x3red, ch3x3, kernel_size=3, padding=1),
)
self.branch3 = nn.Sequential(
BasicConv(in_channels, ch5x5red, kernel_size=1),
BasicConv(ch5x5red, ch5x5, kernel_size=5, padding=2),
)
self.branch4 = nn.Sequential(
nn.MaxPool2d(kernel_size=3, stride=1, padding=1),
BasicConv(in_channels, pool_proj, kernel_size=1),
)
def forward(self, x):
branch1 = self.branch1(x)
branch2 = self.branch2(x)
branch3 = self.branch3(x)
branch4 = self.branch4(x)
return torch.cat([branch1, branch2, branch3, branch4], dim=1)
class GoogLeNet(nn.Module):
def __init__(self, num_classes=10, in_channels=3, dropout=0.2):
super().__init__()
self.stem = nn.Sequential(
BasicConv(in_channels, 64, kernel_size=3, stride=1, padding=1),
BasicConv(64, 64, kernel_size=3, stride=1, padding=1),
BasicConv(64, 192, kernel_size=3, stride=1, padding=1),
)
self.maxpool1 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.inception3a = Inception(192, 64, 96, 128, 16, 32, 32)
self.inception3b = Inception(256, 128, 128, 192, 32, 96, 64)
self.maxpool2 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.inception4a = Inception(480, 192, 96, 208, 16, 48, 64)
self.inception4b = Inception(512, 160, 112, 224, 24, 64, 64)
self.inception4c = Inception(512, 128, 128, 256, 24, 64, 64)
self.inception4d = Inception(512, 112, 144, 288, 32, 64, 64)
self.inception4e = Inception(528, 256, 160, 320, 32, 128, 128)
self.maxpool3 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
self.inception5a = Inception(832, 256, 160, 320, 32, 128, 128)
self.inception5b = Inception(832, 384, 192, 384, 48, 128, 128)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.dropout = nn.Dropout(p=dropout)
self.classifier = nn.Linear(1024, num_classes)
def forward_features(self, x):
x = self.stem(x)
x = self.maxpool1(x)
x = self.inception3a(x)
x = self.inception3b(x)
x = self.maxpool2(x)
x = self.inception4a(x)
x = self.inception4b(x)
x = self.inception4c(x)
x = self.inception4d(x)
x = self.inception4e(x)
x = self.maxpool3(x)
x = self.inception5a(x)
x = self.inception5b(x)
return x
def forward(self, x):
x = self.forward_features(x)
x = self.avgpool(x)
x = torch.flatten(x, start_dim=1)
x = self.dropout(x)
x = self.classifier(x)
return x

4. Inception 模块为什么成立#

Inception 的精华不在“分支多”,而在“分工明确”:

  • 1x1 分支:保留局部线性组合,做轻量通道变换
  • 3x3 分支:看中等尺度局部结构
  • 5x5 分支:看更大尺度上下文
  • pool 分支:做平滑汇总,再用 1x1 做压缩

最后拼接的结果相当于:

同一层同时拿到了多个尺度的观察结果

对当前这版 GoogLeNet 而言,整体 shape 主线大致是:

阶段输出 shape
输入3 x 32 x 32
stem192 x 32 x 32
maxpool1192 x 16 x 16
inception3a256 x 16 x 16
inception3b480 x 16 x 16
maxpool2480 x 8 x 8
inception4a ~ 4e512/528/832 x 8 x 8
maxpool3832 x 4 x 4
inception5a832 x 4 x 4
inception5b1024 x 4 x 4
avgpool1024 x 1 x 1
classifier10

5. GoogLeNet 相对 VGG 的结构变化#

如果说 VGG 更强调“顺序堆叠”,那么 GoogLeNet 推进的是:

  • 网络不一定非要是单链条
  • 并行分支也是一种结构表达能力
  • 1x1 卷积不仅能提特征,也能控制计算量

也就是说,网络设计开始从“纵向堆得更深”扩展到“横向并行得更聪明”。


七、ResNet:残差连接为什么让深网络更容易训练#

1. 它解决了什么结构问题#

当网络不断加深时,问题不只是“参数更多”,还会出现一个更本质的优化问题:

更深的网络不一定更容易训练,甚至可能比浅层网络更差。

这就是经典的 degradation 问题。 ResNet 的核心回答是:

不要强迫每个 block 都从头学一个完整映射,
而是让它去学“在原输入基础上还需要补多少”。

也就是残差思想:

H(x) = F(x) + x

其中:

  • x 是 shortcut 直接传过去的原信息
  • F(x) 是主分支真正学习到的残差

2. 当前实现的任务适配#

当前实现的是适配小图像任务的 ResNet18,核心特点有:

  • 采用 BasicBlock
  • 默认是 2-2-2-2 的四个 stage
  • 32x32 小图像使用 3x3 / stride=1 的 stem
  • AdaptiveAvgPool2d + Linear 做分类头

这说明这里不是在做 ImageNet 大图原样复刻,而是在理解 ResNet 思想之后,针对 CIFAR-100 做合理改写。

3. 关键模型代码#

import torch
from torch import nn
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 BasicBlock(nn.Module):
expansion = 1
def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super().__init__()
self.conv1 = conv3x3(in_channels, out_channels, stride=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):
identity = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
if self.downsample is not None:
identity = self.downsample(x)
out = out + identity
out = self.relu(out)
return out
class ResNet(nn.Module):
def __init__(
self,
block_counts=(2, 2, 2, 2),
num_classes=100,
in_channels=3,
base_channels=64,
small_input=True,
dropout=0.0,
):
super().__init__()
self.inplanes = base_channels
if small_input:
self.stem = nn.Sequential(
conv3x3(in_channels, base_channels, stride=1),
nn.BatchNorm2d(base_channels),
nn.ReLU(inplace=True),
)
else:
self.stem = nn.Sequential(
nn.Conv2d(in_channels, base_channels, kernel_size=7, stride=2, padding=3, bias=False),
nn.BatchNorm2d(base_channels),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
)
self.layer1 = self._make_layer(base_channels, block_counts[0], stride=1)
self.layer2 = self._make_layer(base_channels * 2, block_counts[1], stride=2)
self.layer3 = self._make_layer(base_channels * 4, block_counts[2], stride=2)
self.layer4 = self._make_layer(base_channels * 8, block_counts[3], stride=2)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.dropout = nn.Dropout(p=dropout) if dropout > 0 else nn.Identity()
self.fc = nn.Linear(base_channels * 8, num_classes)
def _make_layer(self, out_channels, blocks, stride):
downsample = None
if stride != 1 or self.inplanes != out_channels:
downsample = nn.Sequential(
nn.Conv2d(self.inplanes, out_channels, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels),
)
layers = [BasicBlock(self.inplanes, out_channels, stride=stride, downsample=downsample)]
self.inplanes = out_channels
for _ in range(1, blocks):
layers.append(BasicBlock(self.inplanes, out_channels))
return nn.Sequential(*layers)
def forward_features(self, x):
x = self.stem(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
return x
def forward(self, x):
x = self.forward_features(x)
x = self.avgpool(x)
x = torch.flatten(x, start_dim=1)
x = self.dropout(x)
x = self.fc(x)
return x
class ResNet18(ResNet):
def __init__(self, num_classes=100, in_channels=3, base_channels=64, small_input=True, dropout=0.0):
super().__init__(
block_counts=(2, 2, 2, 2),
num_classes=num_classes,
in_channels=in_channels,
base_channels=base_channels,
small_input=small_input,
dropout=dropout,
)

4. shortcut 到底在解决什么问题#

BasicBlock 的前向传播时,最关键的就是这几行:

identity = x
...
if self.downsample is not None:
identity = self.downsample(x)
out = out + identity
out = self.relu(out)

这里的逻辑必须真正想明白:

  • 如果输入输出 shape 一致,shortcut 就可以直接相加
  • 如果 shape 不一致,就先用 1x1 conv + BN 对齐
  • 最终 block 学到的是“在原输入上还要补什么”,而不是“从零重新造一个输出”

这会直接改善梯度传播路径。 因为即使主分支一开始学得不好,shortcut 仍然能把原始信息稳定往后传。

5. stage 级 shape 表#

当前这版 ResNet18 在小图像输入下的主线非常清晰:

阶段结构输出 shape
输入RGB 图像3 x 32 x 32
stem3x3 conv + BN + ReLU64 x 32 x 32
layer12BasicBlock64 x 32 x 32
layer2首个 block 下采样128 x 16 x 16
layer3首个 block 下采样256 x 8 x 8
layer4首个 block 下采样512 x 4 x 4
avgpool全局平均池化512 x 1 x 1
flatten展平512
fc分类层100

6. ResNet 相对 GoogLeNet 的核心突破#

如果说 GoogLeNet 把重点放在“横向并行”,那么 ResNet 进一步推进的是:

  • 结构设计不只是表达能力问题,也是优化问题
  • 更深网络之所以难,不只是算得慢,而是不好学
  • shortcut 本质上是在给梯度和信息流开一条更短的路

如果说 VGG 和 GoogLeNet 更强调“怎么提特征”,那么 ResNet 则明确把问题推进到:

深层网络的成功,必须同时考虑表示能力和可训练性。


八、训练框架:从单模型脚本走向可复用训练框架#

当模型越来越多之后,很快就会遇到一个工程问题:

  • 数据加载逻辑反复写
  • 训练循环反复写
  • 验证评估反复写
  • 权重保存、CSV、曲线图、summary 反复写

如果每做一个模型就复制一遍训练脚本,短期内能跑,长期一定会越来越乱。 因此,训练流程最终需要被逐步抽象成一套通用现代化分类框架。 这一章不再重点讲某个网络,而是讲这套训练骨架到底是怎么搭起来的。

1. 配置层:先把实验参数对象化#

这里不再把超参数散落在脚本各处,而是用 dataclass 把配置组织成对象。核心结构如下:

from dataclasses import asdict, dataclass, field, replace
from typing import Any
@dataclass(slots=True)
class ModelConfig:
model_name: str = "VGGNet"
source_model_path: str = ""
model_kwargs: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class DataConfig:
dataset_name: str = "CIFAR10"
data_dir: str = ""
input_size: int = 32
normalization_mean: tuple[float, ...] = (0.4914, 0.4822, 0.4465)
normalization_std: tuple[float, ...] = (0.2023, 0.1994, 0.2010)
train_random_crop_padding: int = 4
train_horizontal_flip_prob: float = 0.5
validation_split: float = 0.2
download: bool = True
resize_for_eval: bool = True
@dataclass(slots=True)
class OptimizerConfig:
optimizer_name: str = "AdamW"
learning_rate: float = 3e-4
weight_decay: float = 1e-4
momentum: float = 0.9
scheduler_name: str = "ReduceLROnPlateau"
scheduler_factor: float = 0.5
scheduler_patience: int = 2
scheduler_min_lr: float = 1e-6
label_smoothing: float = 0.0
@dataclass(slots=True)
class RuntimeConfig:
output_root: str = ""
seed: int = 42
batch_size: int = 128
num_workers: int = 0
device: str = "auto"
use_amp: bool = True
deterministic: bool = True
max_epochs: int = 25
early_stopping_patience: int = 6
smoke_test: bool = False
smoke_test_train_batches: int = 1
smoke_test_eval_batches: int = 1
run_name: str | None = None
checkpoint_path: str | None = None
@dataclass(slots=True)
class ExperimentConfig:
experiment_name: str
model: ModelConfig
data: DataConfig
optimizer: OptimizerConfig
runtime: RuntimeConfig
monitor_metric: str = "val_acc"
monitor_mode: str = "max"
def config_to_dict(config: ExperimentConfig) -> dict[str, Any]:
return asdict(config)
def with_runtime_overrides(config: ExperimentConfig, **runtime_overrides: Any) -> ExperimentConfig:
return replace(config, runtime=replace(config.runtime, **runtime_overrides))

这层抽象的意义非常大:

  • 模型配置、数据配置、优化配置、运行配置被明确拆开
  • 一次实验的所有信息都能被序列化保存
  • CLI 临时覆盖参数时,不再需要在各处手动改全局变量

换句话说,这里不再只是“写一份训练脚本”,而是在“定义一次实验长什么样”。

2. Adapter 层:训练核心不关心你接入的是谁#

框架里最重要的分层之一,就是把“训练流程”和“模型接入”解耦。训练核心不应该写死 VGGResNetGoogLeNet,它只应该关心:

  • 模型怎么构建
  • 默认实验配置是什么

adapter 的核心形状可以概括成下面这样:

import torch
def build_model(model_config: ModelConfig):
return VGGNet(**model_config.model_kwargs)
def count_model_parameters(model) -> int:
return sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
def build_experiment_config() -> ExperimentConfig:
batch_size = 128 if torch.cuda.is_available() else 64
model_config = ModelConfig(
model_name="VGGNet",
model_kwargs={
"config_name": "VGG16",
"num_classes": 10,
"in_channels": 3,
"use_batch_norm": True,
},
)
data_config = DataConfig(
dataset_name="CIFAR10",
input_size=32,
normalization_mean=(0.4914, 0.4822, 0.4465),
normalization_std=(0.2023, 0.1994, 0.2010),
train_random_crop_padding=4,
train_horizontal_flip_prob=0.5,
validation_split=0.2,
download=True,
resize_for_eval=True,
)
optimizer_config = OptimizerConfig(
optimizer_name="AdamW",
learning_rate=3e-4,
weight_decay=1e-4,
scheduler_name="ReduceLROnPlateau",
scheduler_factor=0.5,
scheduler_patience=2,
scheduler_min_lr=1e-6,
label_smoothing=0.0,
)
runtime_config = RuntimeConfig(
seed=42,
batch_size=batch_size,
num_workers=0,
device="auto",
use_amp=True,
deterministic=True,
max_epochs=25,
early_stopping_patience=6,
checkpoint_path="latest/best_model.pth",
)
return ExperimentConfig(
experiment_name="vgg_cifar10",
model=model_config,
data=data_config,
optimizer=optimizer_config,
runtime=runtime_config,
monitor_metric="val_acc",
monitor_mode="max",
)

adapter 的工程价值可以概括为一句话:

训练骨架只写一次,换模型时只改接入层。

这也是为什么后面同样的框架思想能够继续用到 GoogLeNet 和 ResNet 上。

3. Core 层:把数据加载、训练、评估、AMP 统一收口#

真正负责“把训练跑起来”的,是核心层。 这里最关键的不是某一个 API,而是把通用动作收口成可复用函数。

下面这组代码就是核心层最重要的骨架:

import torch
import torch.nn as nn
import torch.utils.data as Data
from torch.amp.grad_scaler import GradScaler
from torchvision import transforms
from torchvision.datasets import CIFAR10, FashionMNIST
DATASET_MAP = {
"CIFAR10": CIFAR10,
"FashionMNIST": FashionMNIST,
}
def build_classification_loaders(config):
dataset_cls = DATASET_MAP[config.data.dataset_name]
train_transform, eval_transform = _build_transforms(config)
train_dataset = dataset_cls(
root=config.data.data_dir,
train=True,
transform=train_transform,
download=config.data.download,
)
val_dataset = dataset_cls(
root=config.data.data_dir,
train=True,
transform=eval_transform,
download=config.data.download,
)
test_dataset = dataset_cls(
root=config.data.data_dir,
train=False,
transform=eval_transform,
download=config.data.download,
)
train_size = int((1 - config.data.validation_split) * len(train_dataset))
generator = torch.Generator().manual_seed(config.runtime.seed)
indices = torch.randperm(len(train_dataset), generator=generator).tolist()
train_indices = indices[:train_size]
val_indices = indices[train_size:]
train_subset = Data.Subset(train_dataset, train_indices)
val_subset = Data.Subset(val_dataset, val_indices)
loader_kwargs = {
"batch_size": config.runtime.batch_size,
"num_workers": config.runtime.num_workers,
"pin_memory": torch.cuda.is_available() and config.runtime.device != "cpu",
}
train_loader = Data.DataLoader(train_subset, shuffle=True, **loader_kwargs)
val_loader = Data.DataLoader(val_subset, shuffle=False, **loader_kwargs)
test_loader = Data.DataLoader(test_dataset, shuffle=False, **loader_kwargs)
return train_loader, val_loader, test_loader
def train_one_epoch(model, dataloader, criterion, optimizer, device, amp_enabled, scaler: GradScaler):
model.train()
total_loss = 0.0
total_correct = 0
total_samples = 0
for inputs, labels in dataloader:
inputs = inputs.to(device, non_blocking=device.type == "cuda")
labels = labels.to(device, non_blocking=device.type == "cuda")
optimizer.zero_grad(set_to_none=True)
with torch.autocast(device_type=device.type, enabled=amp_enabled):
outputs = model(inputs)
loss = criterion(outputs, labels)
preds = outputs.argmax(dim=1)
if amp_enabled:
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
else:
loss.backward()
optimizer.step()
total_loss += loss.item() * inputs.size(0)
total_correct += (preds == labels).sum().item()
total_samples += inputs.size(0)
return {
"loss": total_loss / total_samples,
"acc": total_correct / total_samples,
}
def evaluate(model, dataloader, criterion, device, amp_enabled):
model.eval()
total_loss = 0.0
total_correct = 0
total_samples = 0
with torch.inference_mode():
for inputs, labels in dataloader:
inputs = inputs.to(device, non_blocking=device.type == "cuda")
labels = labels.to(device, non_blocking=device.type == "cuda")
with torch.autocast(device_type=device.type, enabled=amp_enabled):
outputs = model(inputs)
loss = criterion(outputs, labels)
preds = outputs.argmax(dim=1)
total_loss += loss.item() * inputs.size(0)
total_correct += (preds == labels).sum().item()
total_samples += inputs.size(0)
return {
"loss": total_loss / total_samples,
"acc": total_correct / total_samples,
}

这一层的重点不是“函数名”,而是把训练流程里那些和具体模型无关的部分真正抽离出来:

  • 数据集切分
  • DataLoader 构建
  • 单轮训练
  • 单轮评估
  • AMP 自动混合精度

而且这套核心层不仅负责训练和评估,还继续负责:

  • 随机种子控制
  • 学习率调度器
  • early stopping
  • best / last checkpoint
  • 训练历史 CSV
  • 运行摘要 JSON
  • 曲线图输出
  • 重新加载最佳权重复核

也就是说,这里真正被抽象出来的不是“一个循环”,而是一套完整实验闭环。

4. 入口层:脚本应该尽量薄,只负责接参数和调用核心#

训练入口脚本不应该塞满训练细节,它应该尽量薄。 入口层最终被收缩成了“解析参数 + 覆盖默认配置 + 调用核心函数”。

import argparse
def parse_args():
parser = argparse.ArgumentParser(description="通用图像分类训练入口。")
parser.add_argument("--smoke-test", action="store_true")
parser.add_argument("--epochs", type=int, default=None)
parser.add_argument("--batch-size", type=int, default=None)
parser.add_argument("--num-workers", type=int, default=None)
parser.add_argument("--device", type=str, default=None)
parser.add_argument("--run-name", type=str, default=None)
parser.add_argument("--disable-amp", action="store_true")
return parser.parse_args()
def main():
args = parse_args()
config = build_experiment_config()
runtime_overrides = {}
if args.smoke_test:
runtime_overrides["smoke_test"] = True
if args.epochs is not None:
runtime_overrides["max_epochs"] = args.epochs
if args.batch_size is not None:
runtime_overrides["batch_size"] = args.batch_size
if args.num_workers is not None:
runtime_overrides["num_workers"] = args.num_workers
if args.device is not None:
runtime_overrides["device"] = args.device
if args.run_name is not None:
runtime_overrides["run_name"] = args.run_name
if args.disable_amp:
runtime_overrides["use_amp"] = False
if runtime_overrides:
config = with_runtime_overrides(config, **runtime_overrides)
run_training(config, build_model, count_model_parameters)
if __name__ == "__main__":
main()

入口层变薄之后,工程结构就会非常清楚:

  • 配置层定义实验长什么样
  • adapter 层告诉框架模型怎么接入
  • core 层真正执行训练和评估
  • 入口层只负责接外部参数

这套分工一旦建立起来,后面要换模型、换默认数据集、换优化器,就不会再是一场“复制脚本大战”。


九、总结#

1. 三个阶段的变化#

阶段代表实现这一阶段沉淀出的能力
基础闭环阶段LeNet知道model / train / test 如何协同,知道最小分类任务怎么跑通
实验产物化阶段AlexNet、VGG开始稳定记录seed、验证集、scheduler、early stopping、CSV、曲线图、最佳权重
配置化框架阶段GoogLeNet、ResNet,以及通用现代化框架把训练流程抽成配置层、adapter 层、core 层、入口层,开始真正做可复用骨架

2. 已经稳定抽象出的能力#

这些东西一旦被抽象出来,以后接入新模型时就不需要重写:

  • seed:实验可复现
  • DataLoader:训练、验证、测试三套数据管线
  • AMP:在 CUDA 上自动混合精度训练
  • scheduler:学习率调度策略可配置
  • early stopping:验证集长期不提升时提前停止
  • checkpoint:保存 bestlast 两类权重
  • CSV / JSON / 曲线:把训练过程落盘,而不是只在终端里看一眼

十、接入一个新模型时的标准流程#

当训练骨架稳定之后,接入一个新模型的流程就可以被压缩成 4 步。

第 1 步:先把模型本体写清楚#

import torch
from torch import nn
class MyNet(nn.Module):
def __init__(self, num_classes=10, in_channels=3):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(in_channels, 64, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d((1, 1)),
)
self.classifier = nn.Linear(64, num_classes)
def forward(self, x):
x = self.features(x)
x = torch.flatten(x, start_dim=1)
x = self.classifier(x)
return x

第 2 步:在 adapter 里告诉框架怎么构建它#

def build_model(model_config):
return MyNet(**model_config.model_kwargs)
def count_model_parameters(model):
return sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)

第 3 步:给它一份默认实验配置#

def build_experiment_config():
model_config = ModelConfig(
model_name="MyNet",
model_kwargs={
"num_classes": 10,
"in_channels": 3,
},
)
data_config = DataConfig(
dataset_name="CIFAR10",
input_size=32,
normalization_mean=(0.4914, 0.4822, 0.4465),
normalization_std=(0.2023, 0.1994, 0.2010),
validation_split=0.2,
)
optimizer_config = OptimizerConfig(
optimizer_name="AdamW",
learning_rate=3e-4,
weight_decay=1e-4,
)
runtime_config = RuntimeConfig(
seed=42,
batch_size=128,
device="auto",
use_amp=True,
max_epochs=20,
)
return ExperimentConfig(
experiment_name="mynet_cifar10",
model=model_config,
data=data_config,
optimizer=optimizer_config,
runtime=runtime_config,
monitor_metric="val_acc",
monitor_mode="max",
)

第 4 步:直接调用统一训练入口#

config = build_experiment_config()
run_training(config, build_model, count_model_parameters)

这 4 步背后的工程思想非常明确:

  1. 模型本体只关心前向传播
  2. adapter 负责模型接入
  3. 配置层负责实验默认值
  4. 训练核心只负责执行通用流程

一旦这套分层习惯建立起来,以后切模型、切数据集、切优化器,都会比“复制旧脚本再到处改字符串”稳得多。


十一、收束:为什么经典 CNN 学习需要和框架抽象放在一起#

如果只学经典网络,很容易变成“看过很多结构图,但项目里还是不断复制训练脚本”。 如果只学训练框架,又容易变成“会搭骨架,但不知道为什么这个模型该用这种结构”。

这条路线最终说明,两条线必须同时成立:

  • 结构线:知道 LeNet、AlexNet、VGG、GoogLeNet、ResNet 各自解决了什么问题
  • 工程线:知道怎样把这些模型放进一套稳定、可复用、可记录实验的训练系统里

回头看这条路线,其实每一代模型都在回答一个新问题:

  • LeNet:卷积网络怎么从零成立
  • AlexNet:更深更宽以后为什么更强
  • VGG:深度怎样被规则化
  • GoogLeNet:同一层里为什么要并行看多尺度
  • ResNet:网络足够深以后,为什么必须考虑优化路径

而这套框架代码,则是在回答另一类问题:

当模型越来越多时,哪些东西应该继续改,哪些东西必须固定成骨架。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从 LeNet 到 ResNet:经典 CNN 的结构演进与训练框架抽象
https://castorice.xin/posts/经典卷积神经网络实现/
作者
castorice
发布于
2026-03-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时