引言
ResNet 及其变体在图像分类、目标检测、语义分割等多个领域展现出了卓越的性能,已经成为计算机视觉任务中比较常用的神经网络。
本文将基于PyTorch 框架,详细介绍如何使用 python 从零实现 ResNet18 的具体过程,并附上完整代码。
本文的写作主要是为了记录本人在神经网络编程学习过程中获得的一些感悟,并分享自己对 Resnet 神经网络的理解。由于自身水平有限,代码及内容可能存在错误。希望能与读者共同探讨,共同进步!
提示:请理解代码的基本原理后,再使用以下代码进行实验,避免造成不必要的麻烦。
ResNet简介
ResNet,即 Residual Network (残差网络),是由微软研究院的何恺明、张祥雨等研究人员提出的一种创新性的深度卷积神经网络架构。
ResNet 创新性地引入了一种特殊的结构单元——残差块。每个残差块不仅包含标准的卷积层与激活函数,还添加了一条从输入直接连接到输出的路径(Shortcut connection)。这条路径将前一层的特征原封不动传递给后面的层,实现 y = x
的映射效果。通过这种方式,神经网络在学习复杂非线性变换的同时,还能够保留原始输入信息,从而有效地解决了神经网络中的“退化现象”。
基于上述创新设计, ResNet 在2015年的 ImageNet 大规模视觉识别挑战赛(ILSVRC)中取得了冠军。它证明了通过适当的设计,神经网络能够以较少的层数实现较好的性能,为后续神经网络的进一步研究提供了新思路。
完整代码实现及注释
# 基于PyTorch实现ResNet-18模型
import torch
import torch.nn as nn
# 定义BasicBlock类,实现ResNet-18的残差结构
class BasicBlock(nn.Module):
def __init__(self, in_channel, out_channel, stride, downsample):
"""
参数说明:
in_channel: 输入特征图的通道数
out_channel: 输出特征图的通道数
stride: 卷积步长(控制空间分辨率变化)
downsample: 是否需要下采样(用于调整输入与输出维度差异)
"""
# 调用父类nn.Module的构造函数
super(BasicBlock, self).__init__()
# 第一个卷积层(3x3卷积核)
self.conv1 = nn.Conv2d(
in_channels=in_channel,
out_channels=out_channel,
kernel_size=3,
stride=stride, # 控制空间分辨率变化
padding=1, # 保持特征图尺寸不变(当stride=1时)
bias=False # 卷积层不使用偏置,因为BN会处理偏移
)
# 批归一化层(加速训练并稳定模型)
self.bn1 = nn.BatchNorm2d(out_channel)
# 激活函数(ReLU非线性变换)
self.relu = nn.ReLU()
# 第二个卷积层(3x3卷积核)
self.conv2 = nn.Conv2d(
in_channels=out_channel,
out_channels=out_channel,
kernel_size=3,
stride=1, # 固定步长为1
padding=1, # 保持特征图尺寸
bias=False
)
# 第二个批归一化层
self.bn2 = nn.BatchNorm2d(out_channel)
# 下采样模块(当输入与输出维度不匹配时使用)
self.downsample = downsample
def forward(self, x):
# 如果下采样操作不为空,则需要对输入进行下采样得到捷径分支的输出
if self.downsample is not None:
residual = self.downsample(x)
else: # 保存输入数据,便于后面进行残差链接
residual = x
# 前向传播主路径
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
# 残差连接核心:将主路径输出与调整后的输入相加
out += residual # 这里原代码有误,应该是residual而不是identity
out = self.relu(out) # 非线性激活在残差相加后执行
return out
# 定义完整的ResNet-18网络结构
class ResNet_18(nn.Module):
def __init__(self, num_classes):
"""
参数:
num_classes: 分类任务的类别数量
"""
# 调用父类nn.Module的构造函数
super(ResNet_18, self).__init__()
# 第一个卷积层(输入通道数设为4可能需要根据实际输入调整)
self.conv1 = nn.Conv2d(
in_channels=3, # 输入通道数为3(需根据实际输入维度修改)
out_channels=64, # 输出通道数
kernel_size=7, # 大卷积核用于特征提取
stride=2, # 初始空间分辨率减半
padding=3, # 保持尺寸计算:(7-1)/2=3
bias=False
)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU()
self.maxpool = nn.MaxPool2d( # 初始最大池化进一步减半空间尺寸
kernel_size=3,
stride=2,
padding=1
)
# 创建四个残差层,分别对应resnet18的四个stage
# 第一个残差层,输出通道数64,残差块个数2,不进行下采样
self.layer1 = nn.Sequential(
BasicBlock(64, 64, 1, None), # 原代码basic_block应该为BasicBlock
BasicBlock(64, 64, 1, None)
)
# 第二个残差层,输出通道数128,残差块个数2,步长为2,进行下采样
self.layer2 = nn.Sequential(
BasicBlock(
64, 128, 2,
# 下采样模块:1x1卷积调整通道并下采样
downsample=nn.Sequential(
nn.Conv2d(in_channels=64, out_channels=128, kernel_size=1, stride=2, bias=False),
nn.BatchNorm2d(128)
)
),
BasicBlock(128, 128, 1, None)
)
# 第三个残差层,输出通道数256,残差块个数2,步长为2,进行下采样
self.layer3 = nn.Sequential(
BasicBlock(
128, 256, 2,
downsample=nn.Sequential(
nn.Conv2d(in_channels=128, out_channels=256, kernel_size=1, stride=2, bias=False),
nn.BatchNorm2d(256)
)
),
BasicBlock(256, 256, 1, None)
)
# 第四个残差层,输出通道数512,残差块个数2,步长为2,进行下采样
self.layer4 = nn.Sequential(
BasicBlock(
256, 512, 2,
downsample=nn.Sequential(
nn.Conv2d(in_channels=256, out_channels=512, kernel_size=1, stride=2, bias=False),
nn.BatchNorm2d(512)
)
),
BasicBlock(512, 512, 1, None)
)
# 全局平均池化(自适应输出尺寸为1x1)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
# 全连接层(分类器)
self.fc = nn.Linear(512, num_classes) # 输入维度为最后通道数512
# 参数初始化
for m in self.modules():
if isinstance(m, nn.Conv2d):
# 使用He初始化(Kaiming初始化)
nn.init.kaiming_normal_(
m.weight,
mode='fan_out',
nonlinearity='relu'
)
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
代码说明
基本构成单元 BasicBlock 类
ResNet18 是 ResNet 系列中最基础的版本之一,包含 18 个可训练层。它的设计目标是通过引入残差结构来解决深层神经网络中的退化问题。
ResNet18 的基本构建单元是 BasicBlock,每个 BasicBlock 由两个 3×3 的卷积层构成,并通过跳跃连接将输入张量传递到卷积层输出。这种架构设计不仅能够有效缓解梯度消失问题,还能使网络更容易优化。
下图展示了 BasicBlock 的具体实现架构。
下面是基于图示的具体代码实现:
# 定义BasicBlock类,实现ResNet-18的残差结构
class BasicBlock(nn.Module):
def __init__(self, in_channel, out_channel, stride, downsample):
"""
参数说明:
in_channel: 输入特征图的通道数
out_channel: 输出特征图的通道数
stride: 卷积步长(控制空间分辨率变化)
downsample: 是否需要下采样(用于调整输入与输出维度差异)
"""
# 调用父类nn.Module的构造函数
super(BasicBlock, self).__init__()
# 第一个卷积层(3x3卷积核)
self.conv1 = nn.Conv2d(
in_channels=in_channel,
out_channels=out_channel,
kernel_size=3,
stride=stride, # 控制空间分辨率变化
padding=1, # 保持特征图尺寸不变(当stride=1时)
bias=False # 卷积层不使用偏置,因为BN会处理偏移
)
# 批归一化层(加速训练并稳定模型)
self.bn1 = nn.BatchNorm2d(out_channel)
# 激活函数(ReLU非线性变换)
self.relu = nn.ReLU()
# 第二个卷积层(3x3卷积核)
self.conv2 = nn.Conv2d(
in_channels=out_channel,
out_channels=out_channel,
kernel_size=3,
stride=1, # 固定步长为1
padding=1, # 保持特征图尺寸
bias=False
)
# 第二个批归一化层
self.bn2 = nn.BatchNorm2d(out_channel)
# 下采样模块(当输入与输出维度不匹配时使用)
self.downsample = downsample
BasicBlock 在初始化过程中定义了两个卷积层、一个批归一化和激活函数,同时保存了下采样模块。
如果输入图像大小为
W
×
H
W \times H
W×H(宽度和高度),卷积核大小为
F
×
F
F \times F
F×F,步长(stride)为
S
S
S,填充(padding)大小为
P
P
P,那么输出特征图的大小
W
o
u
t
×
H
o
u
t
W_{out} \times H_{out}
Wout×Hout 可以按照如下公式计算:
W
o
u
t
=
W
−
F
+
2
P
S
+
1
W_{out} = \frac{W - F + 2P}{S} + 1
Wout=SW−F+2P+1
H
o
u
t
=
H
−
F
+
2
P
S
+
1
H_{out} = \frac{H - F + 2P}{S} + 1
Hout=SH−F+2P+1
这里的 W o u t W_{out} Wout 和 H o u t H_{out} Hout 需要是整数。如果上述表达式的结果不是整数,或者在实际情况下不能构成有效的输出(比如输出大小小于1),那么通常意味着该配置不适用于给定的输入大小、卷积核大小、步长和填充。至于层数(也就是通道数),它取决于卷积层中使用的卷积核数量。如果你有 N N N 个卷积核,那么输出特征图将会有 N N N 层。
在上述代码中,第二个卷积层的卷积核大小为 3×3,步长为 1,填充为 2,因此在进行卷积操作后,图像的形状保持不变。此外,批归一化层和激活函数层也不会改变特征图的尺寸。因此,在 ResNet18 中,BasicBlock 第二个卷积层的输入与输出维度大小一致。这意味着在BasicBlock 中,对输入特征尺寸和通道数的调整只能在第一个卷积层和downsample
模块。
def forward(self, x):
# 如果下采样操作不为空,则需要对输入进行下采样得到捷径分支的输出
if self.downsample is not None:
residual = self.downsample(x)
else: # 保存输入数据,便于后面进行残差链接
residual = x
# 前向传播主路径
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
# 残差连接核心:将主路径输出与调整后的输入相加
out += residual # 这里原代码有误,应该是residual而不是identity
out = self.relu(out) # 非线性激活在残差相加后执行
return out
上述代码定义了 BasicBlock
的前向传播过程。
-
在前向传播过程中,首先需要将输入张量
x
保存到另一个变量 residual ,方便后面完成残差连接。如果需要调整输入维度,则通过downsample
模块完成。 -
在进行两次卷积操作后,最终将
out
与residual
相加,并通过 ReLU 激活函数输出结果。
从代码中可以看出,BasicBlock
的核心在于两层卷积操作和批量归一化(Batch Normalization),并通过ReLU激活函数引入非线性。此外,当输入和输出通道数不一致时,downsample
模块会调整输入的维度,确保能够正确相加。
模型整体构造 ResNet_18 类
ResNet_18
类实现了整个ResNet18模型。resnet18 的具体结构需要参考下面表格中的详细数据。
下面表格给出了不同 Resnet 神经网络的具体结构,并以输入图像大小为224*224的图像为例,给出了不同层的特征图尺寸。
从上面表格可看到,无论是resnet18,还是resnet152,它们的前两层结构和最后一层结构都是相同的。所以,在resnet18 的起始部分,我们需要定义一个卷积层和最大池化层。
-
卷积层:卷积核大小为7*7,输出通道数为64,步长为2。在经过这个卷积层后,图像大小减半。
-
最大池化层:卷积核大小为3*3,步长为2。在经过这个最大池化层后,图像大小减半。
# 第一个卷积层(输入通道数设为4可能需要根据实际输入调整)
self.conv1 = nn.Conv2d(
in_channels=3, # 输入通道数为3(需根据实际输入维度修改)
out_channels=64, # 输出通道数
kernel_size=7, # 大卷积核用于特征提取
stride=2, # 初始空间分辨率减半
padding=3, # 保持尺寸计算:(7-1)/2=3
bias=False
)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU()
self.maxpool = nn.MaxPool2d( # 初始最大池化进一步减半空间尺寸
kernel_size=3,
stride=2,
padding=1
)
每一个 ResNet 神经网络中有四个残差层。不同大小的Resnet神经网络的区别在于每一个残差层的结构和大小不同。
同一个残差层的特征通道数是一样的,不同残差层的特征通道数是不一样的。
在Resnet18中,四个残差层的特征通道数依次倍增,尺寸大小依次倍减。比如说,第一个残差层的通道数为64,尺寸大小为56*56;第二个残差层的通道数为128,尺寸大小为28*28;
不同残差层之间特征形状和通道数的调整依靠卷积层的步长调整和下采样操作。观察BasicBlock结构可知,如果需要调整不同残差层之间的特征形状和通道数,则需同时调整两个通路:卷积层通路和残差连接通路。
在卷积层通路,残差层之间的特征形状和通道数的变换通过调整卷积层的步长和输出通道数进行实现;在残差连接通路,相应变换通过下采样进行实现。
# 创建四个残差层,分别对应resnet18的四个stage
# 第一个残差层,输出通道数64,残差块个数2,不进行下采样
self.layer1 = nn.Sequential(
BasicBlock(64, 64, 1, None), # 原代码basic_block应该为BasicBlock
BasicBlock(64, 64, 1, None)
)
# 第二个残差层,输出通道数128,残差块个数2,步长为2,进行下采样
self.layer2 = nn.Sequential(
BasicBlock(
64, 128, 2,
# 下采样模块:1x1卷积调整通道并下采样
downsample=nn.Sequential(
nn.Conv2d(in_channels=64, out_channels=128, kernel_size=1, stride=2, bias=False),
nn.BatchNorm2d(128)
)
),
BasicBlock(128, 128, 1, None)
)
# 第三个残差层,输出通道数256,残差块个数2,步长为2,进行下采样
self.layer3 = nn.Sequential(
BasicBlock(
128, 256, 2,
downsample=nn.Sequential(
nn.Conv2d(in_channels=128, out_channels=256, kernel_size=1, stride=2, bias=False),
nn.BatchNorm2d(256)
)
),
BasicBlock(256, 256, 1, None)
)
# 第四个残差层,输出通道数512,残差块个数2,步长为2,进行下采样
self.layer4 = nn.Sequential(
BasicBlock(
256, 512, 2,
downsample=nn.Sequential(
nn.Conv2d(in_channels=256, out_channels=512, kernel_size=1, stride=2, bias=False),
nn.BatchNorm2d(512)
)
),
BasicBlock(512, 512, 1, None)
)
Resnet18 的最后一层包含了平均池化层和全连接层。
在全局平均池化层,无论输入特征尺寸大小是多少,特征尺寸都会被平均到1*1 大小。
在Resnet18 中,在经过全局平均池化层后,特征尺寸大小为1*1,通道数为512。
在经过展平操作后,特征尺寸大小为512*1,可以输入到全连接层,从而输出最后的结果。
# 全局平均池化(自适应输出尺寸为1x1)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
# 全连接层(分类器)
self.fc = nn.Linear(512, num_classes) # 输入维度为最后通道数512
# 参数初始化
for m in self.modules():
if isinstance(m, nn.Conv2d):
# 使用He初始化(Kaiming初始化)
nn.init.kaiming_normal_(
m.weight,
mode='fan_out',
nonlinearity='relu'
)
在上述代码中,我还对参数进行了初始化操作。需要说明的是,这部分代码并不属于 Resnet 神经网络的核心内容,因此读者可以自行判断是否需要添加。
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
上述代码实现了 ResNet18 的前向推理函数。通过这个函数,我们可以清晰地看到 ResNet18 的整体架构,其中包括初始卷积处理、四个残差层、池化层以及全连接层。
总结与展望
本文简单地介绍了我对于Resnet神经网络的理解,并给出了ResNet18的代码实现方法。ResNet18不仅结构清晰易懂,而且在实际应用中表现优异,非常适合用作神经网络学习的入门模型。目前,我也正在学习与神经网络相关的课程。如果你有任何疑问或建议,欢迎在评论区留言,让我们一起交流,共同成长!