一、先看全景:这 5 个网络各自在解决什么问题
先给出一张总览表,建立统一坐标系。
说明:下面的“本地训练记录”来自当前已有训练产物。由于数据集、类别数、输入尺寸都不一致,这些数字只能说明各自实现已经跑通,不能把它们当成严格横向排名。
| 网络 | 当前任务 / 数据集 | 输入尺寸 | 核心机制 | 参数量 | 本地训练记录 |
|---|---|---|---|---|---|
| LeNet | FashionMNIST | 28x28 | 卷积 + 池化 + 全连接 | 61,750 | 未统一记录 |
| AlexNet | FashionMNIST | 64x64 | 更深的卷积堆叠 + 更强分类头 | 6,910,666 | 最佳val_acc=0.9412,第 23 轮 |
| VGG | CIFAR-10 | 32x32 | 小卷积连续堆叠 + 配置表构网 | 14,857,034 | 最佳val_acc=0.8981,第 25 轮 |
| GoogLeNet | CIFAR-10 | 32x32 | Inception 多分支多尺度特征提取 | 6,016,170 | 最佳val_acc=0.8998,第 25 轮 |
| ResNet18 | CIFAR-100 | 32x32 | 残差连接 + 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,彩色图通常是3height:图像高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()这几行代码的含义:
model.train():把模型切到训练模式,Dropout、BatchNorm会按训练逻辑工作。logits = model(images):执行前向传播,得到每个类别的原始分数。loss = criterion(logits, labels):计算预测和真实标签之间的误差。optimizer.zero_grad(...):清空旧梯度。loss.backward():反向传播,计算每个参数该往哪个方向改。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 torchfrom 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 x4. shape 是怎么流动的
以一张 28x28 的灰度图为例:
| 阶段 | 运算 | 输出 shape |
|---|---|---|
| 输入 | 原图 | 1 x 28 x 28 |
c1 | 5x5 卷积,padding=2 | 6 x 28 x 28 |
s2 | 2x2 最大池化 | 6 x 14 x 14 |
c3 | 5x5 卷积 | 16 x 10 x 10 |
s4 | 2x2 最大池化 | 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 torchfrom 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 x4. 数据流为什么这样安排
这版 AlexNet 的一个关键设计点,是“不是每做一次卷积就马上池化”。
它的特征流可以写成:
1x64x64-> 64x64x64-> pool -> 64x32x32-> 192x32x32-> pool -> 192x16x16-> 384x16x16-> 256x16x16-> 256x16x16-> pool -> 256x8x8-> adaptive avg pool -> 256x4x4-> flatten -> 4096-> 1024 -> 256 -> 10这里值得特别注意三点:
- 前期保细节 第一层不用大步长,是为了避免小图像一上来就被采样得太稀。
- 中期扩通道
通道数从
64 -> 192 -> 384 -> 256 -> 256,意味着网络在更深层能并行表示更多模式。 - 后期控参数 分类头虽然仍然存在,但不再像原论文那样极重,因为当前任务只有 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 torchfrom 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 x4. 这段代码里最值得学的不是层数,而是“规则”
VGG 的关键不只是“深”,而是“可规则化”。
比如 VGG16 在 32x32 输入下的主干流可以近似理解为:
3x32x32-> 64x32x32-> pool -> 64x16x16-> 128x16x16-> pool -> 128x8x8-> 256x8x8-> pool -> 256x4x4-> 512x4x4-> pool -> 512x2x2-> 512x2x2-> pool -> 512x1x1-> classifier这里有两个特别重要的结构思想:
- 连续多个
3x3小卷积堆叠 这样既能扩大感受野,又能增加非线性层数。 - 配置表驱动结构生成 一旦把“哪些层是卷积,哪些层是池化”编码成配置表,模型就从“写死的一份代码”变成了“规则生成的一类网络”。
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 torchfrom 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 x4. Inception 模块为什么成立
Inception 的精华不在“分支多”,而在“分工明确”:
1x1分支:保留局部线性组合,做轻量通道变换3x3分支:看中等尺度局部结构5x5分支:看更大尺度上下文pool分支:做平滑汇总,再用1x1做压缩
最后拼接的结果相当于:
同一层同时拿到了多个尺度的观察结果对当前这版 GoogLeNet 而言,整体 shape 主线大致是:
| 阶段 | 输出 shape |
|---|---|
| 输入 | 3 x 32 x 32 |
| stem | 192 x 32 x 32 |
| maxpool1 | 192 x 16 x 16 |
| inception3a | 256 x 16 x 16 |
| inception3b | 480 x 16 x 16 |
| maxpool2 | 480 x 8 x 8 |
| inception4a ~ 4e | 512/528/832 x 8 x 8 |
| maxpool3 | 832 x 4 x 4 |
| inception5a | 832 x 4 x 4 |
| inception5b | 1024 x 4 x 4 |
| avgpool | 1024 x 1 x 1 |
| classifier | 10 |
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 torchfrom 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 + identityout = self.relu(out)这里的逻辑必须真正想明白:
- 如果输入输出 shape 一致,shortcut 就可以直接相加
- 如果 shape 不一致,就先用
1x1 conv + BN对齐 - 最终 block 学到的是“在原输入上还要补什么”,而不是“从零重新造一个输出”
这会直接改善梯度传播路径。 因为即使主分支一开始学得不好,shortcut 仍然能把原始信息稳定往后传。
5. stage 级 shape 表
当前这版 ResNet18 在小图像输入下的主线非常清晰:
| 阶段 | 结构 | 输出 shape |
|---|---|---|
| 输入 | RGB 图像 | 3 x 32 x 32 |
| stem | 3x3 conv + BN + ReLU | 64 x 32 x 32 |
| layer1 | 2 个 BasicBlock | 64 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, replacefrom 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 层:训练核心不关心你接入的是谁
框架里最重要的分层之一,就是把“训练流程”和“模型接入”解耦。训练核心不应该写死 VGG、ResNet 或 GoogLeNet,它只应该关心:
- 模型怎么构建
- 默认实验配置是什么
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 torchimport torch.nn as nnimport torch.utils.data as Datafrom torch.amp.grad_scaler import GradScalerfrom torchvision import transformsfrom 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:保存best和last两类权重CSV / JSON / 曲线:把训练过程落盘,而不是只在终端里看一眼
十、接入一个新模型时的标准流程
当训练骨架稳定之后,接入一个新模型的流程就可以被压缩成 4 步。
第 1 步:先把模型本体写清楚
import torchfrom 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 步背后的工程思想非常明确:
- 模型本体只关心前向传播
- adapter 负责模型接入
- 配置层负责实验默认值
- 训练核心只负责执行通用流程
一旦这套分层习惯建立起来,以后切模型、切数据集、切优化器,都会比“复制旧脚本再到处改字符串”稳得多。
十一、收束:为什么经典 CNN 学习需要和框架抽象放在一起
如果只学经典网络,很容易变成“看过很多结构图,但项目里还是不断复制训练脚本”。 如果只学训练框架,又容易变成“会搭骨架,但不知道为什么这个模型该用这种结构”。
这条路线最终说明,两条线必须同时成立:
- 结构线:知道 LeNet、AlexNet、VGG、GoogLeNet、ResNet 各自解决了什么问题
- 工程线:知道怎样把这些模型放进一套稳定、可复用、可记录实验的训练系统里
回头看这条路线,其实每一代模型都在回答一个新问题:
LeNet:卷积网络怎么从零成立AlexNet:更深更宽以后为什么更强VGG:深度怎样被规则化GoogLeNet:同一层里为什么要并行看多尺度ResNet:网络足够深以后,为什么必须考虑优化路径
而这套框架代码,则是在回答另一类问题:
当模型越来越多时,哪些东西应该继续改,哪些东西必须固定成骨架。
部分信息可能已经过时









