前言
之前我们在cnn已经搞过VGG和GoogleNet模型了,这两种较深的模型出现了一些问题:
梯度传播问题
在反向传播过程中,梯度通过链式法则逐层传递。对于包含 L 层的网络,第 l 层的梯度计算为:
其中 a(k) 表示第 k层的激活值。当多个雅可比矩阵 ∂a(k+1)/∂a(k) 的乘积中出现大量小于1的特征值时(例如使用Sigmoid激活函数),梯度会指数级衰减(梯度消失);反之若特征值大于1,则梯度爆炸式增长(梯度爆炸)。
实验证明,VGG-19的训练损失曲线在后期趋于平缓,参数更新停滞。
网络退化问题
当网络深度超过某个阈值时(例如20层),VGG会出现以下矛盾现象:
- 训练误差不降反升(与过拟合无关)
- 测试集准确率显著低于更浅的网络
网络退化问题通常是过深的网络的表达力下降导致的,原始像素信息需经过所有层的非线性变换,关键特征可能在传递过程中被破坏。
计算代价问题
以VGG-16为例:
- 全连接层占总参数量的90%以上(约1.38亿参数中的1.22亿)
- 最后三个全连接层(4096→4096→1000)产生巨大计算开销(我在训练的时候不得不减少前两个全连接层的神经元数量来尽快完成训练)。
单张224×224图像前向传播的浮点运算量(FLOPs):
其中l是神经网络层数, Kl 为卷积核尺寸,Cin、Cout 为输入/输出通道数。VGG-16的FLOPs高达15.5G 。训练起来太费劲了。
过拟合问题
模型复杂度应与训练数据规模匹配。VGG-16的1.38亿参数需要极大训练集(ImageNet的120万图像勉强足够),但在小数据集上,测试集准确率显著低于更紧凑的网络。
这些问题说明单纯叠深度不是万能的,甚至有副作用。这里我们使用ResNet来一定程度解决上面的问题。
源码
import torch
from torch import nn
from torchsummary import summary
class Residual(nn.Module):
def __init__(self, in_channels, out_channels, use_1conv=False, strides = 1):
super().__init__()
self.Rulu = nn.ReLU()
self.conv1 = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, padding=1, stride=strides)
self.conv2 = nn.Conv2d(in_channels=out_channels, out_channels=out_channels, kernel_size=3, padding=1)
self.bn1 = nn.BatchNorm2d(out_channels)
self.bn2 = nn.BatchNorm2d(out_channels)
if use_1conv:
self.conv3 = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=1, stride=strides)
else:
self.conv3 = None
def forward(self, x):
y = self.Rulu(self.bn1(self.conv1(x)))
y = self.bn2(self.conv2(y))
if self.conv3:
x = self.conv3(x)
y = self.Rulu(x + y)
return y
class ResNet18(nn.Module):
def __init__(self, Residual):
super().__init__()
self.block1 = nn.Sequential(
nn.Conv2d(in_channels=1, out_channels=64, kernel_size=7, stride=3, padding=3),
nn.ReLU(),
nn.BatchNorm2d(64),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)
self.block2 = nn.Sequential(
Residual(64, 64, use_1conv=False, strides=1),
Residual(64, 64, use_1conv=False, strides=1)
)
self.block3 = nn.Sequential(
Residual(64, 128, use_1conv=True, strides=2),
Residual(128, 128, use_1conv=False, strides=1)
)
self.block4 = nn.Sequential(
Residual(128, 256, use_1conv=True, strides=2),
Residual(256, 256, use_1conv=False, strides=1)
)
self.block5 = nn.Sequential(
Residual(256, 512, use_1conv=True, strides=2),
Residual(512, 512, use_1conv=False, strides=1)
)
self.block6 = nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(512, 10)
)
def forward(self, x):
x = self.block1(x)
x = self.block2(x)
x = self.block3(x)
x = self.block4(x)
x = self.block5(x)
x = self.block6(x)
return x
if __name__ == "__main__":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = ResNet18(Residual).to(device)
print(summary(model, (1, 224, 224)))
残差块
resnet的核心就是多个残差块,从图片中可以看到,残差块有两个显著特征:
- 每次卷积后都加入了一个批量规范化层。
- 每个残差块在计算完毕后都会将原始输入x叠加到输出里。
这种跳跃连接允许梯度直接回传至浅层,一定程度缓解了梯度爆炸/梯度消失问题。可以同时学习新特征和保留原始特征。
假设理想映射为 H(x),传统网络直接拟合 H(x),而残差网络拟合残差 F(x)=H(x)−x。当 H(x)≈x 时,后者优化目标 F(x)→0 比前者 H(x)→x 的优化难度更低。
批量规范化层
批量规范化(Batch Normalization,BN)是深度学习中革命性的技术之一,神经网络里加上这个效果嘎嘎好。
bn要解决的问题:
内部协变量偏移(Internal Covariate Shift)
网络参数更新导致各层输入分布不断变化,迫使后续层需要持续适应新的数据分布,显著降低训练速度。分布偏移迫使网络使用更低的学习率来维持稳定性。
梯度传播障碍
梯度幅度在各层差异巨大,在链式传导时容易导致梯度消失/爆炸。此外,参数初始值对训练结果影响很明显。
bn的数学理论:
对于每个batch B={x1,...,xm}:
从公式可以看出,bn的本质上是对每层神经网络的输入做了标准化处理。首先计算当前批次的平均值和方差,再进行归一化处理,消除物理量纲上的差异 。
最后有一步仿射变换,引入可学习的参数 γ(缩放因子) 和 β(平移因子),恢复数据表达能力。
恢复数据表达能力是咋回事呢?
首先标准化是一种数据处理方法,其目的是将数据调整到一个标准范围内,从而使得不同特征具有相同的尺度。标准化适用于特征的分布呈正态分布或接近正态分布的情况。
将每层强制标准化,可能某些特征的重要性被抑制,假设某层理想输出应为 N(2, 0.5),但标准化后变为 N(0,1),直接使用会损失原有分布的信息。
BN的仿射变换等价于在原模型上叠加了一个线性变换层,使网络能自主选择是否保留标准化效果。
自适应平均池化
nn.AdaptiveAvgPool2d((1, 1)),
自适应平均池化在resnet里替代了一部分全连接层。自适应平均池化和普通平均池化的区别是:
- 普通平均池化:需手动指定窗口大小(如
kernel_size=3
)和步幅(如stride=2
),输出尺寸由输入尺寸和参数共同决定。 - 自适应平均池化:直接指定输出尺寸(如
(1,1)
),PyTorch自动计算所需的窗口大小和步幅。
自适应平均池化的输入与输出:
输入:形状为 (batch_size, channels, H, W)
的4D张量。
输出:形状为 (batch_size, channels, 1, 1)
的4D张量。
其数学原理是对每个通道的特征图,计算所有元素求平均值,每个通道的特征图被池化为单个数值(平均值)。
自适应平均池化的主要优点是减少参数,防止过拟合,常用于较深的神经网络中。
思考:x+y和torch.cat
之前做GoogleNet的时候,前向传播里使用torch.cat做多路径计算融合,那么resNet里前向传播里的x+y融合是否也可以使用torch.cat代替?
答案是不行,因为resNet里的相加和GoogleNet里的相加有区别。
x + y
是逐元素相加,要求两个张量的形状完全相同(通道数、尺寸一致)。torch.cat([x, y], dim)
是沿指定维度拼接,会改变输出张量的形状(通道数翻倍)。
所以resNet里残差块做相加时,两个张量的通道数,宽,高,要完全相同才行,相加之后宽,高,通道数都没有变化;而googleNet的inception块里做相加时,多个张量的通道数可以不同,宽,高完全相同,相加之后宽,高不变,而通道数是所有张量的通道数之和。