经典卷积神经网络 (Modern Convolutional Neural Networks) #
LeNet #
LeNet 分为卷积层块和全连接层块两个部分。卷积层块里的基本单位是卷积层后接最大池化层:卷积层用来识别图像里的空间模式,如线条和物体局部,之后的最大池化层则用来降低卷积层对位置的敏感性。卷积层块由两个这样的基本单位重复堆叠构成。在卷积层块中,每个卷积层都使用
\(5×5\)
的窗口,并在输出上使用 sigmoid
激活函数。第一个卷积层输出通道数为 6,第二个卷积层输出通道数则增加到 16。这是因为第二个卷积层比第一个卷积层的输入的高和宽要小,所以增加输出通道使两个卷积层的参数尺寸类似。卷积层块的两个最大池化层的窗口形状均为 \(2×2\)
,且步幅为 2。由于池化窗口与步幅形状相同,池化窗口在输入上每次滑动所覆盖的区域互不重叠。
卷积层块的输出形状为(批量大小, 通道, 高, 宽)。当卷积层块的输出传入全连接层块时,全连接层块会将小批量中每个样本 变平(flatten)。也就是说,全连接层的输入形状将变成二维,其中第一维是小批量中的样本,第二维是每个样本变平后的向量表示,且向量长度为通道、高和宽的乘积。全连接层块含 3 个全连接层。它们的输出个数分别是120、84和10,其中 10 为输出的类别个数。

- LeNet 代码实现
import torch import torch.nn as nn class LeNet(nn.Module): def __init__(self): super().__init__() # input image size 28x28 -> output size = 28-5+1=24x24 self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5) # input size 24x24 -> output size = (24-2+2)/2=12x12 self.pool = nn.AvgPool2d(kernel_size=2, stride=2) # input size 12x12 -> output size = 12-5+1=8x8 self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5) # after pooling 8x8 -> 4x4 self.fc1 = nn.Linear(16*4*4, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, 10) self.sigmoid = nn.Sigmoid() def forward(self, x): x = self.sigmoid(self.conv1(x)) x = self.pool(x) x = self.sigmoid(self.conv2(x)) x = self.pool(x) x = x.view(-1, 16 * 4 * 4) x = self.sigmoid(self.fc1(x)) x = self.sigmoid(self.fc2(x)) x = self.fc3(x) return x
AlexNet #
2012年,AlexNet 横空出世。这个模型的名字来源于论文第一作者的姓名 Alex Krizhevsky。AlexNet 使用了 8 层卷积神经网络,并以很大的优势赢得了 ImageNet 2012 图像识别挑战赛。它首次证明了学习到的特征可以超越手工设计的特征,从而一举打破计算机视觉研究的前状。

AlexNet 设计框架(Architecture):
第一,与相对较小的 LeNet 相比,AlexNet 包含 8 层变换,其中有 5 层卷积和 2 层全连接隐藏层,以及 1 个全连接输出层。AlexNet 第一层中的卷积窗口形状是 \(11×11\) 。因为 ImageNet 中绝大多数图像的高和宽均比 MNIST 图像的高和宽大 10 倍以上,ImageNet 图像的物体占用更多的像素,所以需要更大的卷积窗口来捕获物体。第二层中的卷积窗口形状减小到 \(5×5\) ,之后全采用 \(3×3\) 。此外,第一、第二和第五个卷积层之后都使用了窗口形状为 \(3×3\) 、步幅为 2 的最大池化层。而且,AlexNet 使用的卷积通道数也大于 LeNet 中的卷积通道数数十倍。
紧接着最后一个卷积层的是两个输出个数为 4096 的全连接层。这两个巨大的全连接层带来将近 1 GB的模型参数。由于早期显存的限制,最早的 AlexNet 使用双数据流的设计使一个 GPU 只需要处理一半模型。幸运的是,显存在过去几年得到了长足的发展,因此通常我们不再需要这样的特别设计了。
第二,AlexNet 将 sigmoid 激活函数改成了更加简单的 ReLU 激活函数。一方面,ReLU 激活函数的计算更简单,例如它并没有 sigmoid 激活函数中的求幂运算。另一方面,ReLU 激活函数在不同的参数初始化方法下使模型更容易训练。这是由于当 sigmoid 激活函数输出极接近 0 或 1 时,这些区域的梯度几乎为 0,从而造成反向传播无法继续更新部分模型参数;而 ReLU 激活函数在正区间的梯度恒为 1。因此,若模型参数初始化不当,sigmoid 函数可能在正区间得到几乎为 0 的梯度,从而令模型无法得到有效训练。
第三,AlexNet 通过 丢弃法(dropout)控制全连接层的模型复杂度,而 LeNet 只使用权重衰减。为了进一步增强数据,AlexNet 的训练循环添加了大量图像增强,例如翻转、裁剪和颜色变化。这使得模型更加健壮,更大的样本量有效地减少了过拟合。
LeNet 代码实现
import torch import torch.nn as nn class AlexNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 96, kernel_size=11, stride=4), 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.Linear(256*6*6, 4096), nn.ReLU(inplace=True), nn.Dropout(), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Dropout(), nn.Linear(4096, 10), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x
VGG #
VGG Network (Simonyan and Zisserman, 2014)的名字来源于论文作者所在的实验室Visual Geometry Group。VGG(Networks Using Blocks) 提出了可以通过重复使用简单的基础块来构建深度模型的思路。VGG 的主要特点是通过堆叠小卷积核和池化层来增加网络深度,从而提升模型的表达能力。
CNN 的基本构件是以下的序列。(i) 带有填充的卷积层,以保持分辨率;(ii) 非线性层,如ReLU;(iii) 池化层,如最大池化,以降低分辨率。这种方法的问题之一是,空间分辨率下降得相当快。Simonyan 和 Zisserman 的关键想法是在下采样过程中以 块(block) 的形式在最大池化前使用多个卷积。他们最初的主要关注点是深层网络还是宽层网络表现更好。例如,连续应用两个 \(3×3\) 卷积和单个 \(5×5\) 卷积触及相同的像素哪个效果更好。在一个相当详细的分析中,他们表明深层和窄层网络的表现明显优于浅层的同类网络。这使深度学习走上了追求更深的网络的道路,在典型的应用中,网络层数超过100层。堆叠 \(3×3\) 卷积已经成为后期深度网络的一个黄金标准。
VGG 块的组成规律是:连续使用数个相同的填充为 1、窗口形状为 \(3×3\) 的卷积层后接上一个步幅为 2、窗口形状为 \(2×2\) 的最大池化层。卷积层保持输入的高和宽不变,而池化层则对其减半。
对于给定的感受野(与输出有关的输入图片的局部大小),采用堆积的小卷积核优于采用大的卷积核,因为可以增加网络深度来保证学习更复杂的模式,而且代价还比较小(参数更少)。例如,在 VGG 中,使用了 3 个 \(3×3\) 卷积核来代替 \(7×7\) 卷积核,使用了 2 个 \(3×3\) 卷积核来代替 \(5×5\) 卷积核,这样做的主要目的是在保证具有相同感知野的条件下,提升了网络的深度,在一定程度上提升了神经网络的效果。
- VGG 代码实现
import torch import torch.nn as nn # 定义生成 VGG 卷积块的函数 def make_vgg_block(input_channels, output_channels, num_convs): layers = [] for _ in range(num_convs): layers.append(nn.Conv2d(input_channels, output_channels, kernel_size=3, padding=1)) layers.append(nn.ReLU(inplace=True)) input_channels = output_channels # 更新输入通道为当前输出通道 layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) # 添加池化层 return nn.Sequential(*layers) # 动态定义 VGG 模型 class VGG(nn.Module): def __init__(self, architecture, num_classes=1000): super().__init__() self.features = self._make_features(architecture) self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplace=True), nn.Dropout(), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Dropout(), nn.Linear(4096, num_classes), ) def _make_features(self, architecture): layers = [] for input_channels, output_channels, num_convs in architecture: layers.append(make_vgg_block(input_channels, output_channels, num_convs)) return nn.Sequential(*layers) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) x = self.classifier(x) return x # 定义 VGG 架构 (以 VGG-16 为例) vgg16_architecture = [ (3, 64, 2), # Block 1: 3->64 通道, 2 个卷积层 (64, 128, 2), # Block 2: 64->128 通道, 2 个卷积层 (128, 256, 3), # Block 3: 128->256 通道, 3 个卷积层 (256, 512, 3), # Block 4: 256->512 通道, 3 个卷积层 (512, 512, 3), # Block 5: 512->512 通道, 3 个卷积层 ]
Network in Network (NiN) #
如同 LeNet、AlexNet 和 VGG 的设计中所体现的,传统的卷积神经网络通过一系列卷积层和池化层利用 空间结构(spatial structure) 提取特征,并通过全连接层对表征进行后处理。然而,随着网络深度的增加,特别是在 AlexNet 和 VGG 中,网络末端的全连接层带来了两个主要问题:
- 参数数量庞大:
- 在网络的最后阶段,通常通过全连接层将特征映射到分类结果。这些全连接层通常含有巨量的参数,容易造成训练过程中的过拟合,并且会大大增加计算资源的消耗。
- 无法增加非线性:
- 在传统的卷积神经网络中,卷积层负责从图像中提取局部特征,而全连接层则将这些特征组合成最终的输出。然而,增加非线性通常依赖于全连接层的引入,然而过早地加入全连接层可能会破坏卷积层捕捉到的空间结构信息,从而影响模型的性能。
网络中的网络(network in network (NiN))区块 提供了一个替代方案,能够在一个简单的策略中解决这两个问题。它们是基于一个非常简单的洞察力提出的:
- 使用 \(1×1\)
卷积来增加通道激活的局部非线性:
- 卷积层的输入和输出通常是四维数组(样本,通道,高,宽),而全连接层的输入和输出则通常是二维数组(样本,特征)。如果想在全连接层后再接上卷积层,则需要将全连接层的输出变换为四维。
- \(1×1\) 卷积层。它可以看成全连接层,其中空间维度(高和宽)上的每个元素相当于样本,通道相当于特征。NiN 背后的想法是在每个像素位置应用一个全连接层(对于每个高度和宽度)。由此产生的 \(1×1\) 卷积可以被认为是一个独立作用于每个像素点的全连接层。
- 通过将多个 \(1×1\) 卷积层堆叠,可以有效地将每个局部区域的激活映射变得更加复杂,类似于全连接层的作用,但它不依赖于传统的全连接结构,因此避免了参数量爆炸的问题。
Note: 在传统的卷积层中,每个卷积核只会在 同一通道内 提取信息。这意味着每个卷积核仅能操作输入特征图的一个通道,并不会直接与其他通道的特征进行交互,不同通道间很难实现结合。为了将不同通道的信息结合,我们通常需要依赖网络中的 全连接层,尤其是在网络的最后几层。
1x1卷积改变了这个局限性,它允许在 通道维度 上进行信息融合。 除此之外 1x1 卷积可以通过增加通道数来提高网络的表达能力,同时也能在较低的计算开销下实现更复杂的特征学习。它通过引入非线性(通常通过ReLU激活)来增加模型的表现力。
- 使用全局平均池化 (Global Average Pooling, GAP) 来整合特征:
- NiN去除了容易造成过拟合的全连接层,将它们替换为全局平均池化层(即在所有位置上进行求和)。该池化层通道数量为所需的输出数量。
- 全局平均池化是对每个通道的输出进行空间上的平均,得到一个标量值,最终生成的输出被用来进行分类或其他任务。
- 与传统的最大池化或平均池化不同,GAP 被用作替代全连接层的方案。它不仅有效减少了参数量,还可以在不丢失空间结构信息的情况下将整个图像的空间特征映射整合成最终的类别输出。
Note: 最终的全局平均池化之前,通道数通常会被设置为与分类任务中的类别数量相匹配。每个通道对应一个特征。对于每一个特征图(即每个通道),全局平均池化(GAP)会对该通道中的所有空间位置(即每个像素)进行平均操作。换句话说,GAP将每个通道的所有像素值压缩成一个数字。如果网络有 C 个通道,那么输出就是一个 C-维的向量。这个向量包含了所有通道的全局信息。这些数值作为网络的输出特征,来进行类别预测。
NiN是在AlexNet问世不久后提出的。它们的卷积层设定有类似之处。NiN使用卷积窗口形状分别为 \(11×11\) 、 \(5×5\) 和 \(3×3\) 的卷积层,相应的输出通道数也与 AlexNet 中的一致。每个 NiN 块后接一个步幅为 2、窗口形状为 \(3×3\) 的最大池化层。
- NiN 代码实现
import torch import torch.nn as nn import torch.nn.functional as F # NiN Block class NiNBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride, padding): super(NiNBlock, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size, stride, padding) self.conv3 = nn.Conv2d(out_channels, out_channels, kernel_size, stride, padding) def forward(self, x): x = F.relu(self.conv1(x)) x = F.relu(self.conv2(x)) x = F.relu(self.conv3(x)) return x # NiN Model class NiN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.net = nn.Sequential( NiNBlock(3, 96, kernel_size=11, stride=4, padding=0), nn.MaxPool2d(3, stride=2), NiNBlock(96, 256, kernel_size=5, stride=1, padding=2), nn.MaxPool2d(3, stride=2), NiNBlock(256, 384, kernel_size=3, stride=1, padding=1), nn.MaxPool2d(3, stride=2), nn.Dropout(0.5), # Dropout for regularization NiNBlock(384, num_classes, kernel_size=3, stride=1, padding=1), nn.AdaptiveAvgPool2d((1, 1)), # Global Average Pooling nn.Flatten() # Flatten the output for final classification ) def forward(self, x): x = self.net(x) return x
ResNet #
ResNet (Residual Network) 是由微软研究团队提出的深度神经网络架构,其核心思想是通过 残差模块 (Residual Block) 解决深度网络中常见的 梯度消失问题 和 退化问题。ResNet 在 2015 年的 ImageNet 大赛中取得了冠军,是深度学习领域的里程碑模型。
Residual Block 的结构 #
直观理解:
- 一个标准的网络层尝试学习一个复杂的映射 \(H(x)\) 。
- Residual Block 则将目标分解为 \(F(x) + x\)
,其中:
- \(F(x) = H(x) - x\) :残差,即网络学习的部分。
- \(x\) :输入,通过跳跃连接直接传递到输出。
这种结构鼓励网络专注于学习残差 \(F(x)\) ,而非直接学习 \(H(x)\) 。
公式表示: 假设输入为 \(x\) ,Residual Block 的输出为: \[ y = F(x, \{W_i\}) + x \]
- \(F(x, \{W_i\})\) :通过卷积、Batch Normalization、ReLU 等操作后得到的输出。
- \(x\) :输入,通过跳跃连接直接添加到 \(F(x)\) 。
- \(\{W_i\}\) :Residual Block 中的可学习参数。
举个具体例子: 假设目标映射 \(H(x)\) 是恒等映射(即 \(H(x) = x\) ),如果网络直接学习 \(H(x)\) ,需要每一层的参数精确调整才能接近这个目标;但如果采用残差学习,网络只需学习 \(F(x) = 0\) ,这对优化过程来说非常简单。
如下图所示,设输入为 \(x\) 。假设我们希望学出的理想映射为 \(f(x)\) ,从而作为图中上方激活函数的输入。左图虚线框中的部分需要直接拟合出该映射 \(f(x)\) ,而右图虚线框中的部分则需要拟合出有关恒等映射的残差映射 \(f(x)−x\) 。残差映射在实际中往往更容易优化。当理想映射 \(f(x)\) 极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。图中右图也是 ResNet 的基础块,即残差块(residual block)。其中实线承载层输入 \(x\) 加法运算符称为残差连接(residual connection)。在残差块中,输入可通过跨层的数据线路更快地向前传播。
ResNet 沿用了 VGG 全 \(3×3\) 卷积层的设计。残差块里首先有 2 个有相同输出通道数的 \(3×3\) 卷积层。每个卷积层后接一个批量归一化层和 ReLU 激活函数。然后我们将输入跳过这两个卷积运算后直接加在最后的 ReLU 激活函数前。这样的设计要求两个卷积层的输出与输入形状一样,从而可以相加。如果想改变通道数,就需要引入一个额外的 \(1×1\) 卷积层来将输入变换成需要的形状后再做相加运算。
Residual Block 的意义 #
- 降低学习难度:
- 直接学习 \(H(x)\) (输入到输出的完整映射)可能是一个高度复杂的问题,而学习残差 \(F(x) = H(x) - x\) 相对简单得多。
- 在许多实际任务中,输入 \(x\) 与目标 \(H(x)\) 通常是接近的(例如图像分类任务中,特征提取后的信息不会发生剧烈变化)。
- 通过学习残差 \(F(x)\) ,网络只需关注输入与输出之间的细微差异,而不必重新建模整个映射。
- 缓解梯度消失问题:
- 在深度神经网络中,随着层数增加,梯度会因为多次链式求导而逐渐减小,最终导致梯度消失问题。
- 残差连接提供了一条直接路径,使得梯度能够不经过中间层直接流回前面的层。这种 “shortcut” 避免了梯度的逐层衰减,缓解了梯度消失问题,允许更深层的网络训练。
- 数学上,梯度通过残差连接流回时只需求导 \(\frac{\partial y}{\partial x} = 1 + \frac{\partial F(x)}{\partial x}\) ,即便 \(\frac{\partial F(x)}{\partial x}\) 较小,梯度仍然保留一个恒定值 1。
- 解决退化问题:
- 深度网络常面临 退化问题:随着网络层数增加,模型性能可能不升反降,即便出现过拟合的风险,这种现象依然存在。
- 残差连接的设计允许某些层 “跳过”,从而实现恒等映射(identity mapping),如果某些层对任务无用,网络会自动倾向于学习恒等映射,使这些层的输出等于输入。这确保了增加网络深度不会降低性能。
- 简化优化问题:
- 从优化角度看,直接拟合复杂的 \(H(x)\) 映射可能存在多个局部极小值,导致优化困难。而拟合残差 \(F(x) = H(x) - x\) 则相当于让网络优化从输入的一个初始解 \(x\) 开始,再逐步修正偏差。
- 这将复杂映射分解为多个简单问题,简化了优化过程,使训练更快、更稳定。
- 提高非线性能力:
- 在传统的网络结构中,每层都直接学习 \(H(x)\) ,如果层数较深,层之间的信息传递可能会导致过平滑的问题(即较浅层的细节信息在深层被逐渐削弱)。
- 残差学习通过直接连接输入 \(x\) 和输出 \(F(x)\) ,让浅层信息直接参与深层的输出,避免了过度平滑,从而提高了网络的表达能力。
- ResNet 代码实现
import torch import torch.nn as nn from torchvision import models class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(ResidualBlock, 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.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = downsample def forward(self, x): identity = x if self.downsample is not None: identity = self.downsample(x) out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out += identity out = self.relu(out) return out class ResNet18(nn.Module): def __init__(self, num_classes=1000): super(ResNet18, self).__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(64, 2) self.layer2 = self._make_layer(128, 2, stride=2) self.layer3 = self._make_layer(256, 2, stride=2) self.layer4 = self._make_layer(512, 2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512, num_classes) def _make_layer(self, out_channels, blocks, stride=1): downsample = None if stride != 1 or self.in_channels != out_channels: downsample = nn.Sequential( nn.Conv2d(self.in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels), ) layers = [] layers.append(ResidualBlock(self.in_channels, out_channels, stride, downsample)) self.in_channels = out_channels for _ in range(1, blocks): layers.append(ResidualBlock(out_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.relu(x) x = self.maxpool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x