长文预警!!!(›´ω`‹ )
上一章我们了解了 CNN 的基本原理,那么在这一章自然考虑进行应用,章节开头就给出了这一章中我们预期实现的模型,包括:
- AlexNet
- VGG (这其实更类似于处理方法)
- NiN(碟中谍网中网)
- GoogLeNet:并行连结
- ResNet:残差网络
- DenseNet:稠密神经网络
虽然神经网络的概念非常简单,但不同的网络架构和超参数选择导致不同的网络性能各不相同。因此对经典网络的参数进行探究是合理且必要的,它有助于培养相关直觉,帮助我们开发自己的架构。
考虑到 ImageNet 数据集的内容过多,下述模型训练的数据集不做说明均为CIFAR10
AlexNet
总体来看, AlexNet 的设计理念和 LeNet5 的理念其实是差不多的,但 AlexNet 较之于 LeNet5 要深得多,它包括 5 个卷积层,2 个全连接隐藏层以及 1 个输出层.相较之下 LeNet 才 2 个卷积
另外 AlexNet 的激活函数用的是 ReLU 而非 Sigmoid,这有效降低了计算的难度,并且保证了输入在趋近于 $[0, 1]$ 时可能存在的的梯度消失问题。
另外,模型本身的加深可能导致模型最后输出时过拟合,为了缓解这一症状,在进行全连接时进行了暂退,通过丢弃神经元来保证模型的泛化能力。
所以实现思路就相当明确了:
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torchvision import transforms, datasets
class AlexNet(nn.Module):
""" AlexNet 类 """
def __init__(self, num_classes=1000):
super().__init__()
self.squeeze = nn.Sequential(
nn.Conv2d(3, 96, kernel_size=3, stride=1, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Conv2d(96, 256, kernel_size=5, padding=2),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3,stride=2),
nn.Conv2d(256, 384, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(384, 384, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.Conv2d(384, 256, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2),
)
# 自适应池化层
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d((1,1)),
nn.Flatten(),
nn.Linear(256, 4096), nn.ReLU(inplace=True),
nn.Dropout(0.5), # 减轻过拟合
nn.Linear(4096, 4096), nn.ReLU(inplace=True),
nn.Dropout(0.5), # 减轻过拟合
nn.Linear(4096, num_classes)
)
def forward(self,X):
X = self.squeeze(X)
X = self.classifier(X)
return X
VGG
VGG(Visual Geometry Group)网络是由牛津大学 Visual Geometry Group 在2014年提出(论文《Very Deep Convolutional Networks for Large-Scale Image Recognition》)。其核心思想包括:
- 更深的网络(Depth):用深度换质量,论文中提及,增加网络深度可以有效提升模型的特征学习能力,VGG 通过堆叠大量小型卷积核,构建起比 AlexNet 还要深得多的神经网络
- 小型卷积核:这是与 AlexNet 最大的差别,通过大批量、小规模的卷积核,模型能够保证在感受野基本一致的情况下保持更少的参数量,且通过大量附加的非线性激活函数来保证模型的非线性,使决策函数更有判别力
- 对块进行封装(Block-based Design):VGG 同时对块进行组织封装以实现卷积块,具体而言
- 每个块包含: 连续数个(如2个、3个或4个)的
Conv2d (3x3) -> ReLU
层。 - 块末尾: 跟随一个
MaxPool2d (2x2, stride=2)
层,用于对特征图进行空间下采样(尺寸减半,通道数翻倍) - 事实上,这种设计极大的提升了模型的可复现性、可读性和可扩展性,如果我们想进一步提升网络深度,只需要在块中加几层卷积就行
- 每个块包含: 连续数个(如2个、3个或4个)的
- 全连接分类器: 在卷积块提取特征后,VGG和AlexNet一样,使用几个全连接层(FC)来进行最终的分类
在实现上,我们先考虑封装一个 VGG 类内的构建层方法:
def make_layers(cfg, batch_norm=False):
layers = []
in_channels = 3
for v in cfg:
if v == 'M':
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
else:
conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
if batch_norm:
layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
else:
layers += [conv2d, nn.ReLU(inplace=True)]
in_channels = v
return nn.Sequential(*layers)
这使得我们可以通过配置一个 config 字典来迅速准确的构建对应的 VGG 网络:
cfg = {
'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'],
}
之后就是常规的后全连接层、前向传播以及权重初始化的相关定义:
class VGG(nn.Module):
def __init__(self, features, num_classes=10, init_weights=True):
super(VGG, self).__init__()
self.features = features
# 对于CIFAR-10,特征图最终尺寸为1x1,因此自适应池化为(1, 1)
self.adaptive_avg_pool = nn.AdaptiveAvgPool2d((1, 1))
self.classifier = nn.Sequential(
nn.Linear(512, 1024), # 输入尺寸 512
nn.ReLU(True),
nn.Dropout(0.5),
nn.Linear(1024, 1024),
nn.ReLU(True),
nn.Dropout(0.6),
nn.Linear(1024, num_classes),
)
if init_weights:
self._initialize_weights()
def forward(self, x):
x = self.features(x) # 通过VGG网络提取特征
x = self.adaptive_avg_pool(x) # 自适应池化到1x1
x = torch.flatten(x, 1) # 展平为[batch_size, 512]
x = self.classifier(x) # 通过分类器
return x
def _initialize_weights(self):
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
if m.bias is not None:
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.BatchNorm2d):
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
nn.init.constant_(m.bias, 0)
但 VGG 原本的设计是针对 ImageNet 这种大输入图像集的神经网络。我们的测试数据集为 CIFAR10,原本的 VGG 网络可能过深,导致模型的最终训练效果其实并不如 AlexNet.比如 VGG11 在lr = 0.005, weight_decay = 5e-4 情况下的训练结果为:

我们可以很明显的发现在 Epoch 7 后模型学习到的特征是对泛化能力没有帮助的,此后模型表现出了强过拟合行为,即高 Training acc,高 Test loss 但 Test acc 却基本不动。
NiN
原始的NiN论文同样是在ImageNet数据集上进行设计的,输入为 224×224 的RGB图像。其架构主要由两种核心组件重复堆叠构成:
- NiN 块
- 最大池化层(MaxPoolingLayer)
并在最后以一个全局平均池化层(Global Average Layer)结束
NiN 块
这是 NiN 网络的核心组成,但和前述网络存在区别的是,它在最大池化层来提取特征之前,就通过两个 $1\times 1$ 的卷积层先进行操作,且每一层的后面都紧跟着一个 ReLU 激活函数。
我们为什么采用 $1\times 1$ 卷积?
其实在前文我们讨论过 $1\times 1$ 卷积层的相关作用,它能够:
- 跨通道信息交互:它可以灵活地融合不同通道的特征图,实现降维或升维。
- 增加非线性:每个1×1卷积后都接一个ReLU,使这个“微网络”能够拟合更复杂的函数,比单次卷积+ReLU的表达能力强的多
所以 NiN 整体架构就非常简单了,就是 NiN 块的交替堆叠和最大池化。当然论文结尾还有一个不得不提的神来之笔:全局平均池化(GAP)
全局平均池化层对最后一个NiN块输出的特征图的每个通道(Channel)求平均值,并直接展平送入预测。这完全移除了全连接层,极大降低了模型最终的参数量,有效控制了模型的过拟合。
实现
在 NiN 的实现上,我们先考虑 NiN Block 的构建:
class NiNBlock(nn.Module):
""" NiN 块 """
def __init__(self, in_channels, out_channels, kernel_size, stride, padding):
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels, kernel_size=1),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels, kernel_size=1),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
)
def forward(self, x):
return self.block(x)
以及 NiN 类的实现:
class NiN(nn.Module):
""" NiN 网络 """
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
NiNBlock(3, 192, kernel_size=5, stride=1, padding=2),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
nn.Dropout2d(p=0.3),
NiNBlock(192, 192, kernel_size=5, stride=1, padding=2),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
nn.Dropout2d(p=0.4),
NiNBlock(192, 256, kernel_size=3, stride=1, padding=1),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
nn.Dropout2d(p=0.5),
NiNBlock(256, num_classes, kernel_size=3, stride=1, padding=1)
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten()
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
不过可能是我的实现方式有问题,在不考虑数据增强的情况下,我构建出的 NiN 在面对 CIFAR10 数据集上的表现较之于 VGG 低了 3% 左右,怎么说呢,很玄学的事(
GoogLeNet
与前面几个模型相比, GoogLeNet 最显著,也是最直观的特点是他引入了并行化的相关概念,通过并行地应用多种不同尺度的卷积操作(和池化操作),将它们的输出在通道维度上进行拼接(Concatenate)后作为下一层的输入,从而实现了在计算预算和内存限制内,让模型通过一种高效的“网络结构设计”来获得更强的表达能力
并行化
我们来梳理一前面三种的网络实现架构:
$$\begin{align}&\text{AlexNet / VGG}: \text{Conv} \rightarrow \text{ReLU} \rightarrow \text{Pool} \rightarrow \text{Conv} \rightarrow \text{ReLU} \rightarrow \text{Pool} \rightarrow … \rightarrow \text{FC}\\ &\text{NiN}: \text{MLPConv} \rightarrow \text{Pool} \rightarrow \text{MLPConv} \rightarrow \text{Pool} \rightarrow … \rightarrow \text{GAP}\end{align}$$
可以很明显的看到,之前的网络都是线性(串行) 结构,每一层的输出直接作为下一层的输入。
但 GoogLeNet 的架构为
$$\text{GoogLeNet}: \text{Inception Module} \rightarrow \text{Pool} \rightarrow \text{Inception Module} \rightarrow \text{Pool} \rightarrow …$$
我们可以看到一个很明显的趋势,相较于我们手动指定规模卷积核,为什么不能让网络”我全都要”()
因此,Inception模块并行地应用了多种不同尺度的卷积操作(和池化操作),并将它们的输出在通道维度上进行拼接(Concatenate)。
所以,一个 Naïve 的 Inception 块的构成包括:
- 并行进行:
- 1×1 卷积
- 3×3 卷积
- 5×5 卷积
- 3×3 最大池化
- 将四个分支的输出堆叠起来。
但上述Naïve版本有一个致命问题:计算量巨大,尤其是5×5卷积和池化后的通道拼接。GoogLeNet巧妙地吸收了NiN的1×1卷积,将其作为“瓶颈层(Bottleneck Layer)”,来降维,这极大减少了计算量。
所以实际的 Inception 块构成为:
在3×3和5×5卷积之前,以及池化之后,都添加了1×1卷积。
- 作用1(对3×3/5×5分支): 降维。先通过 1×1 卷积减少通道数,再进行昂贵的 3×3 或 5×5 卷积,计算量骤降。
- 作用2(对池化分支): 控制通道数。池化操作不改变通道数,直接拼接会导致通道数只增不减。1×1 卷积可以将其投影到新的、更少的通道空间。
而这就使得GoogLeNet在保持甚至提升模型表达能力的同时,参数量(5M)远小于VGG(138M)和AlexNet(60M),计算效率极高
辅助分类器(实现中没考虑)
为了解决深度网络带来的梯度消失问题,GoogLeNet 在网络的中部还引入了两个辅助分类器。他们的结构是完全相同的都是一个迷你版的卷积网络分类器,具体来说:
- 输出层: 另一个全连接层,输出单元数与主分类器相同(例如ImageNet是1000个类),后面接一个Softmax函数,产生一个分类预测。
- 平均池化层(Average Pooling): 使用5×5的滤波器,步幅为3。(例如,对于第一个辅助分类器,输入是14x14x512,输出为4x4x512)
- 1×1卷积层: 用于降维和减少计算量。(例如,将512个通道减少到128个)
- 全连接层: 将特征图展平后连接到一个1024个单元的隐藏层。
- Dropout层: 防止过拟合。
当然在测试阶段,辅助分类器会被丢弃
此外 GoogLeNet 还丢弃了全连接层而是采用了全局平均池化层来进行替代,这样就进一步减少了参数量。
总的来说,GoogLeNet通过 Inception 模块实现了多尺度并行处理,让网络能够在同一层级捕获不同范围的上下文信息(通过设置卷积核大小);同时,为了减少计算的大量性能开销,它采用 $1 \times 1 $ 瓶颈层大幅降低了计算成本,使得模型构建更宽(更多通道)更深的网络成为可能。
实现
前面劈里啪啦说了这么多,只有实践才是最重要的。现在来看 GoogLeNet 的实现。
Inception 块
首先是 Inception 块的实现,这是 GoogLeNet 的核心之一。
我们在这考虑到数据集的变换,对 Inception 块做出了一些调整,但其核心还是不变的——不同感受野的同步提取:
class Inception(nn.Module):
def __init__(self, in_channels, out_channels, **kwargs):
super(Inception, self).__init__(**kwargs)
# 分支1: 1x1卷积
self.module_1 = nn.Sequential(
nn.Conv2d(in_channels, out_channels[0], kernel_size=1),
nn.BatchNorm2d(out_channels[0]),
nn.ReLU(inplace=True)
)
# 分支2: 1x1卷积 -> 3x3卷积
self.module_2 = nn.Sequential(
nn.Conv2d(in_channels, 64, kernel_size=1), # 减少维度
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.Conv2d(64, out_channels[1], kernel_size=3, padding=1),
nn.BatchNorm2d(out_channels[1]),
nn.ReLU(inplace=True)
)
# 分支3: 1x1卷积 -> 5x5卷积
self.module_3 = nn.Sequential(
nn.Conv2d(in_channels, 64, kernel_size=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.Conv2d(64, out_channels[2], kernel_size=5, padding=2), # padding=2保持尺寸
nn.BatchNorm2d(out_channels[2]),
nn.ReLU(inplace=True)
)
# 分支4: 3x3最大池化 -> 1x1卷积
self.module_4 = nn.Sequential(
nn.MaxPool2d(kernel_size=3, stride=1, padding=1), # stride=1保持尺寸
nn.Conv2d(in_channels, out_channels[3], kernel_size=1),
nn.BatchNorm2d(out_channels[3]),
nn.ReLU(inplace=True)
)
def forward(self, x):
x1 = self.module_1(x)
x2 = self.module_2(x)
x3 = self.module_3(x)
x4 = self.module_4(x)
return torch.cat([x1, x2, x3, x4], dim=1)
那么主体的实现就很简单了:
class GoogLeNet(nn.Module):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 预处理层:输出尺寸16x16x192
self.preLayer = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
nn.Conv2d(64, 64, kernel_size=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.Conv2d(64, 192, kernel_size=3, padding=1),
nn.BatchNorm2d(192),
nn.ReLU(inplace=True),
)
# 下采样层:将尺寸从16x16下采样到8x8
self.maxPoolingLayer = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
# Inception模块序列:所有模块保持8x8尺寸
self.inception_1 = nn.Sequential(
Inception(192, [128, 64, 32, 64]), # 输出通道288
Inception(288, [128, 64, 32, 64]), # 输出通道288
)
self.inception_2 = nn.Sequential(
Inception(288, [128, 96, 48, 64]), # 输出通道336
Inception(336, [128, 96, 48, 64]), # 输出通道336
Inception(336, [128, 96, 48, 64]), # 输出通道336
Inception(336, [128, 96, 48, 64]), # 输出通道336
Inception(336, [128, 96, 48, 64]), # 输出通道336
)
self.inception_3 = nn.Sequential(
Inception(336, [192, 96, 48, 64]), # 输出通道400
Inception(400, [192, 96, 48, 64]), # 输出通道400
)
# 全局平均池化和全连接层
self.avgPoolingLayer = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(400, 10) # 输入特征数为400,输出10类(CIFAR-10)
def forward(self, x):
x = self.preLayer(x)
x = self.maxPoolingLayer(x)
x = self.inception_1(x)
x = self.inception_2(x)
x = self.inception_3(x)
x = self.avgPoolingLayer(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
Batch Normalization – 批量归一化
在介绍 ResNet 之前,我们不妨将目光移回到我们已经构建出的相关模型上。在进行训练时,前面层参数会不断更新,导致后面每一层输入的分布也随之发生改变,在模型过深时就会导致每一层都要不停地去适应一个新的数据分布,使得训练变得困难,也就是所谓的“内部协变量偏移(ICS)”
虽然似乎我并没有遇上,我遇上的都是过拟合(头疼
如果输入分布稳定,我们可以通过精心初始化权重,让输入尽可能保持在激活函数的线性区域(梯度最大的区域附近),从而缓解梯度消失。但 ICS 的存在破坏了这种稳定性,使得精心设置的初始化可能很快失效。导致某一层的输入整体偏移到激活函数的饱和区。而 一旦输入进入饱和区,该层激活函数的梯度 $\frac{\partial a}{\partial z}$ 就会变得极小。在反向传播时,这个极小的梯度值就会参与链式相乘,迅速导致传向更前面层的梯度消失于是模型的学习就完蛋了。
那么很自然就想到了,既然我们遇上的是偏移,那把偏移的部分给“揪”回来不就好了。这也是 BN 的核心思想,即通过对神经网络中每一层的输入进行重新中心化(Re-centering)和重新缩放(Re-scaling)从而显著加快训练速度,提升模型稳定性,并具有一定的正则化效果
具体步骤
Batch Normalization 在一个 mini-batch 数据上操作,其实现过程可以清晰地分为两步:标准化(Standardization) 和 缩放平移(Scale and Shift)。
假设我们有一个 mini-batch $\mathcal{B} = {x_1, x_2, …, x_m\\}$,其中 $m$ 是 batch size。BN 对 batch 中的每一个特征维度(每一个通道,对于CNN)独立地进行以下计算。
第一步:标准化(Standardization)
这一步的目的是将数据转换为均值为0,方差为1的标准正态分布。
计算 mini-batch 的均值:
$$\mu_{\mathcal{B}} \leftarrow \frac{1}{m} \sum_{i=1}^{m} x_i$$
计算 mini-batch 的方差:
$$\sigma_{\mathcal{B}}^2 \leftarrow \frac{1}{m} \sum_{i=1}^{m} (x_i – \mu_{\mathcal{B}})^2$$
对每个样本进行归一化:
$$\hat{x}_i \leftarrow \frac{x_i – \mu_{\mathcal{B}}}{\sqrt{\sigma_{\mathcal{B}}^2 + \epsilon}}$$
$\hat{x}_i$ 是标准化后的值。
$\epsilon$ 是一个极小的常数(例如 $10^{-5}$),是为了防止分母为零,增加数值稳定性。
第二步:缩放与平移(Scale and Shift)
第一步的强制标准化有可能会改变原始数据本身有价值的分布特征。为了解决这个问题,BN 引入了两个可学习的参数 $\gamma$(缩放因子)和 $\beta$(平移因子),让网络自己去决定归一化的程度。
仿射变换(缩放和平移):
$$y_i \leftarrow \gamma \hat{x}_i + \beta \equiv \text{BN}_{\gamma, \beta}(x_i)$$
$y_i$ 是 BatchNorm 层的最终输出。
如果网络认为标准正态分布是最好的,它可以通过学习将 $\gamma$ 设置为 $\sqrt{\sigma_{\mathcal{B}}^2}$,将 $\beta$ 设置为 $\mu_{\mathcal{B}}$,从而完美地恢复原始分布。这赋予了网络强大的灵活性。
所以整个 Batch Normalization 操作可以总结为一个公式:
$$y_i = \gamma \cdot \frac{x_i – \mu_{\mathcal{B}}}{\sqrt{\sigma_{\mathcal{B}}^2 + \epsilon}} + \beta$$
但需要注意的是,BN 在训练和推理时的表现是不同的,具体来说:
- 训练阶段(Training):
- 使用当前 mini-batch 的数据来计算均值 $\mu_{\mathcal{B}}$ 和方差 $\sigma_{\mathcal{B}}^2$。
- 同时,算法会持续计算整个训练过程中 $\mu_{\mathcal{B}}$ 和 $\sigma_{\mathcal{B}}^2$ 的移动平均(Moving Average),为推理阶段做准备。
- 移动平均均值: $\mu_{\text{mov}} \leftarrow \lambda \mu_{\text{mov}} + (1 – \lambda) \mu_{\mathcal{B}}$
- 移动平均方差: $\sigma_{\text{mov}}^2 \leftarrow \lambda \sigma_{\text{mov}}^2 + (1 – \lambda) \sigma_{\mathcal{B}}^2$
($\lambda$ 是动量参数,通常取0.9或0.99)
- 推理阶段(Inference/Testing):
- 此时我们通常没有 batch 的概念(可能一次只预测一个样本),或者 batch 的统计量不稳定。
- 因此,不再使用当前 batch 的统计量,而是直接使用训练阶段最终得到的移动平均 $\mu_{\text{mov}}$ 和 $\sigma_{\text{mov}}^2$。
此时推理时的计算变为:
$$y_i = \gamma \cdot \frac{x_i – \mu_{\text{mov}}}{\sqrt{\sigma_{\text{mov}}^2 + \epsilon}} + \beta$$
这样,通过训练和推理时的割裂,BN 保证了输出的稳定性和确定性。
ResNet
我觉得 ResNet 与前面我们提及的网络有着本质上的区别。如果说我们想要神经网络拟合的目标函数是 $H(x)$ ,那么 VGG 或者 GoogLeNet 的行为其实是让一层或者多层网络层直接学习我们期望的底层映射 $H(x)$ ,但 ResNet 不同,它的网络层的学习目标是残差映射(Residual Mapping) $ H(x)= F(x) + x $ 中的 $ F(x) $.
这就相当于什么呢,如果我们一顿饭要吃 $H(X)$ 的量才能吃饱,而我们当前碗里有 $x$ 量的饭,那么接下来我们 要做的就是打上剩余 $F(X)$ 量的饭来喂饱自己。
也就是说,我们可以将其视为是一种 “自调节机制”,具体来说,调节作用于两个方面:
- 对模型下一次输出的调节:在传统网络中,每一层都需要直接产出完整的、正确的
H(x)
,这是一个“绝对目标”。对于深层网络,这个目标可能过于困难。而在ResNet中,学习目标变成了 “相对于输入x,需要做出多少改变(F(x))”。这是一个“相对目标”或“增量目标”。- 如果当前的输出
x
已经是理想状态(即H(x) = x
),那么网络只需要将残差映射F(x)
轻松地学习为0即可。F(x) = 0
->H(x) = x + 0 = x
。 - 如果需要对
x
进行修改才能得到理想输出,网络就学习一个非零的F(x)
来施加这个必要的改变。
- 如果当前的输出
- 对训练过程的调节:跳跃连接(见下文)为梯度流动提供了一条“高速公路”或“捷径”,绕过了主路径上可能存在的梯度衰减层。因为梯度可以从损失函数直接、快速地流回浅层,确保所有层都能得到有效的训练信号。这就可以看作网络自动调节了反向传播的难度,确保了信息流的通畅,从而自我优化了训练过程。
在理论实现上,ResNet 的操作为:
- 前向传播:
- 输入
x
同时进入两个路径。 - 主路径:
x
经过一系列权重层(通常是两个3×3卷积层),得到输出 $F(x)$。 - 快捷路径:
x
本身直接传递(如果维度不变),或通过一个 $1\times 1$ 卷积进行投影以匹配维度(如果维度变化)。 - 最终输出: 将两条路径的输出逐元素相加 $\text{out} = F(x) + x$。
- 相加后的结果再通过一个ReLU激活函数。
- 输入
- 反向传播:梯度通过快捷连接毫无衰减地直接传回更早的层。
这彻底解决了极深网络中的梯度消失问题,使得训练成百上千层的网络成为可能。
在模型构建中,我们主要依靠残差块进行实现,一个残差块的实现包括:
- 保存输入: identity = x
- 主路径计算: 通过2层(BasicBlock)或3层(Bottleneck)卷积层计算残差 F(x)。
- 快捷路径计算: 判断是否需要1×1卷积来调整 x 的维度以匹配主路径输出。
- 相加: out = F(x) + shortcut(x)
- 激活: out = ReLU(out)
流程大概是这样:
[输入 x]
│
┍───────────────────────────────────┐
│ │
[1x1 Conv降维] [快捷路径]
[BatchNorm] [1x1 Conv]
[ReLU] │
[3x3 Conv] │
[BatchNorm] │
[ReLU] │
[1x1Conv升维] │
[BatchNorm] │
│ │
└─────────────[相加] ───────────────┘
│
[ReLU激活]
│
[输出 out]
可以观察到实现中有一些细节:
- Batch Normalization (BN):
- 每个卷积层后都立即跟随一个BN层,这是ResNet原文的标准配置。BN极大地稳定了深度网络的训练过程。
- 注意: ReLU激活函数是在BN之后使用的。
- 快捷路径的处理:
- 恒等映射: 当维度不变时,
self.shortcut
是一个空序列,直接传递输入x
。 - 投影映射: 当需要改变维度或下采样时,
self.shortcut
是一个1×1卷积层(+BN),用于将输入x
投影到与主路径输出相同的维度,以便能进行加法操作。
- 恒等映射: 当维度不变时,
- ReLU的位置:
- ReLU是在加法操作之后进行的。如果放在加法之前,会破坏残差学习的效果。
- 下采样:
- 通常在每个Stage的第一个残差块中进行。
- 主路径: 通过3×3卷积的
stride=2
来实现下采样。 - 快捷路径: 通过1×1卷积的
stride=2
来同时实现下采样和通道数调整。
实现
首先构建残差块。由论文的论述,对于残差块,有两种类型,分别针对前后通道数发生/不发生改变
- 无通道拓展类,常用于ResNet-18/34
class BasicBlock(nn.Module):
"""用于ResNet-18/34的基础残差块,无通道拓展"""
expansion = 1
def __init__(self, in_channels, out_channels, stride=1):
super(BasicBlock, self).__init__()
# 主路径
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels * self.expansion)
# 快捷路径
self.shortcut = nn.Sequential()
# 如果步长不为1(需要下采样)或者输入输出通道数不匹配,就需要用1x1卷积进行投影
if stride != 1 or in_channels != out_channels * self.expansion:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels * self.expansion, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels * self.expansion)
)
def forward(self, x):
identity = self.shortcut(x) # 先计算快捷路径,更清晰
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out)) # 注意:这里主路径最后是BN,没有ReLU
out += identity # 主路径输出 + 快捷路径输出
out = F.relu(out) # 相加后再激活
return out
- 有通道拓展,针对ResNet-50/101/152的深层网络
class Bottleneck(nn.Module):
"""用于ResNet-50/101/152的瓶颈残差块"""
expansion = 4
def __init__(self, in_channels, out_channels, stride=1):
super(Bottleneck, self).__init__()
# 主路径
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, bias=False)
self.bn3 = nn.BatchNorm2d(out_channels * self.expansion)
# 快捷路径
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels * self.expansion:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels * self.expansion, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels * self.expansion)
)
def forward(self, x):
identity = self.shortcut(x)
out = F.relu(self.bn1(self.conv1(x)))
out = F.relu(self.bn2(self.conv2(out)))
out = self.bn3(self.conv3(out)) # 注意:这里主路径最后是BN,没有ReLU
out += identity
out = F.relu(out)
return out
而残差网络的构建,则基于多个残差块进行组合,此处考虑解耦,提供封装类函数 _make_layer
:
def _make_layer(self, block, out_channels, num_blocks, stride):
"""构建一个包含多个残差块的阶段"""
strides = [stride] + [1] * (num_blocks - 1) # 第一个块可能下采样,后续块保持尺寸
layers = []
for stride in strides:
layers.append(block(self.in_channels, out_channels, stride))
self.in_channels = out_channels * block.expansion # 更新下一块的输入通道数
return nn.Sequential(*layers)
所以 ResNet 整体的构建:
class ResNet(nn.Module):
def __init__(self, block, num_blocks, num_classes=10):
super(ResNet, self).__init__()
self.in_channels = 64
self.conv1 = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True)
)
# 构建四个残差阶段 (stage)
# 每个阶段通过_make_layer函数创建,第一个块的stride可能为2(用于下采样)
self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
# 分类器
self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) # 全局平均池化,输出尺寸为(1,1)
self.fc = nn.Linear(512 * block.expansion, num_classes)
def _make_layer(self, block, out_channels, num_blocks, stride):
"""构建一个包含多个残差块的阶段"""
strides = [stride] + [1] * (num_blocks - 1) # 第一个块可能下采样,后续块保持尺寸
layers = []
for stride in strides:
layers.append(block(self.in_channels, out_channels, stride))
self.in_channels = out_channels * block.expansion # 更新下一块的输入通道数
return nn.Sequential(*layers)
def forward(self, x):
out = self.conv1(x)
out = self.layer1(out)
out = self.layer2(out)
out = self.layer3(out)
out = self.layer4(out)
out = self.avgpool(out)
out = out.view(out.size(0), -1) # 展平
out = self.fc(out)
return out
DenseNet
最后一节!
我们先来考虑一个函数:
$$f(x + f(x + f(\dots)))$$
为什么提及这个呢,这其实是我突然想到的(划掉),我们来想想看这玩意和 ResNet 和我们将要提起的 DenseNet的结构上有没有啥相似之处。
ResNet 采用的是“跳跃连接”。第 L
层的输入来自第 L-1
层的输出,再加上更早的某一层(如第 L-2, L-3 层) 的输出。其信息流是:$x_L = F(x_{L-1}) + x_{L-3}$。这是一种跨层相加。
更严谨一点:我们设设第 $l$ 层的输出为 $x_l$。那么 ResNet 的前向传播可以定义为:
$$x_l = x_{l-1} + F_l(x_{l-1})$$
展开后:
$$\begin{cases}x_1 = x_0 + F_1(x_0)\\x_2 = x_1 + F_2(x_1) = [x_0 + F_1(x_0)] + F_2(x_1)\\x_3 = x_2 + F_3(x_2) = [x_0 + F_1(x_0) + F_2(x_1)] + F_3(x_2)$
$…$
$x_L = x_0 + \sum_{i=1}^{L} F_i(x_{i-1})$
而对于我们将要实现的 DenseNet ,其采用的是“密集连接”。第 L 层的输入来自所有前面层(第 0, 1, 2, …, L-1 层) 输出的拼接。其信息流是:$x_L = F([x_0, x_1, x_2, …, x_{L-1}])$。[,]表示跨层拼接。
为什么要提这个呢,其实想表达的是,ResNet 和 DenseNet 的核心是“特征复用”思想
- $x$: 代表最原始的输入特征。
- 第一个 $f(…)$: 代表第一层网络,它从 x 中提取了特征 f(x)。
- $x \text{op} f(x)$: 代表第二层网络的输入,它同时看到了原始特征 x 和第一层提取的特征 f(x)。
- $f(x \text{op} f(x))$: 第二层基于这些信息提取了更深层次的特征。
做个类比,那就是:
特性 | ResNet | DenseNet |
---|---|---|
连接方式 | 跳跃连接(Sum) | 密集连接(Concatenation) |
信息流动 | x_L = F(x_{L-1}) + x_{L-n} | x_L = F([x_0, x_1, ..., x_{L-1}]) |
特征处理 | 特征融合(加法) | 特征收集(拼接) |
比喻 | “接力赛”:下一棒接过上一棒的成果,并可能从更早的某棒获得直接鼓励。 | “团队协作”:每个新成员(层)的工作都建立在所有前辈(前面所有层)的工作成果之上。 |
优势 | 解决梯度消失,训练极深网络。 | 极强的特征复用,大幅减少参数量,缓解梯度消失效果更好。 |
核心特征讲得差不多了,现在来考虑实现:
实现
DenseNet 的核心是 Dense Block 和 Transition Layer ,而在一个 Dense Block 内部,任意两层之间均有直接连接。 Transition Layer 则是在 Dense Block 之间进行特征降维以维持输入输出的平衡。
单层的 Dense Block 通常由 $BN Layer \rightarrow ReLU \rightarrow 1\times 1 Conv$ 组成,所以单层的封装类实现起来很简单:
class _DenseLayer(nn.Module):
""" 单个 Dense 层 """
def __init__(self, num_input_features, growth_rate):
super().__init__()
# 瓶颈层结构:BN - ReLU - 1x1 Conv
self.bn1 = nn.BatchNorm2d(num_input_features)
self.conv1 = nn.Conv2d(num_input_features, 4*growth_rate, kernel_size=1, bias=False)
self.bn2 = nn.BatchNorm2d(4*growth_rate)
self.conv2 = nn.Conv2d(4*growth_rate, growth_rate, kernel_size=3, padding=1, bias=False)
def forward(self, pre_features):
cat_features = torch.cat(pre_features, 1)
out = self.conv1(F.relu(self.bn1(cat_features)))
out = self.conv2(F.relu(self.bn2(out)))
return out
那么在单层基础上,我们考虑 Dense 的完整实现, Dense 的实现需要考虑一个问题:
“我们如何将前层的结果接入到本层?”
这本质上是输入通道的拓展,我们在每一层的最后实际上要进行一个 contact 操作,所以我们考虑维护一个 grow_rate 来对各层将要拓展到的特征数进行扩展。
class DenseBlock(nn.Module):
""" 完整的 Dense 块 """
def __init__(self, num_layers, num_input_features, growth_rate):
super().__init__()
self.layers = nn.ModuleList()
for i in range(num_layers):
layer = _DenseLayer(
num_input_features + i*growth_rate, # 输入通道随层数增加
growth_rate)
self.layers.append(layer)
def forward(self, init_features):
features = [init_features]
for layer in self.layers:
new_features = layer(features) # 逐层更新特征图
features.append(new_features)
return torch.cat(features, 1) # 拼接特征图
另外是过渡层,由上述的阐述可知其实现可为:
class TransitionLayer(nn.Module):
""" 过渡层 """
def __init__(self, num_input_features):
super().__init__()
self.bn = nn.BatchNorm2d(num_input_features)
self.conv = nn.Conv2d(num_input_features, num_input_features//2, kernel_size=1, bias=False)
self.pool = nn.AvgPool2d(kernel_size=2, stride=2) # 平均池化
def forward(self, x):
out = self.conv(F.relu(self.bn(x)))
out = self.pool(out)
return out
所以综合一下, DenseNet 类的实现:
class DenseNet(nn.Module):
""" DenseNet 网络 """
def __init__(self, num_init_features=24, growth_rate=12, block_config=[16, 16, 16], num_classes=10):
super().__init__()
# 此处修改了初始层:原论文卷积核尺寸过大,此处使用kernel_size=3, stride=1, padding=1,移除MaxPool,适应CIFAR-10的32x32输入
self.features = nn.Sequential(
nn.Conv2d(3, num_init_features, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(num_init_features),
nn.ReLU(inplace=True)
)
num_features = num_init_features
for i, num_layers in enumerate(block_config):
block = DenseBlock(num_layers=num_layers, num_input_features=num_features, growth_rate=growth_rate)
self.features.add_module('denseblock%d' % (i+1), block)
num_features = num_features + num_layers * growth_rate
# 在最后一个块之前添加过渡层
if i != len(block_config)-1:
trans = TransitionLayer(num_input_features=num_features)
self.features.add_module('transition%d' % (i+1), trans)
num_features = num_features // 2
# 全局平均池化和分类器
self.final_bn = nn.BatchNorm2d(num_features) # 最终BatchNorm
self.classifier = nn.Linear(num_features, num_classes)
def forward(self, x):
features = self.features(x)
out = F.relu(self.final_bn(features), inplace=True)
out = F.adaptive_avg_pool2d(out, (1, 1))
out = torch.flatten(out, 1)
out = self.classifier(out)
return out
而在CFIAR – 10s-上的训练结果为:

完结撒花~
发表回复