残差网络的介绍及ResNet-18的搭建(pytorch版)
文章目录
- 前言
- 1.为什么需要残差网络?
- 1.1梯度消失 / 梯度爆炸
- 1.2深度退化现象
- 2.ResNet 的核心创新:残差块与残差连接
- 2.1 什么是 “残差”?
- 2.2. 残差块的两种结构
- 2.2.1恒等映射残差块(Identity Block)
- 2.2.2 1×1 卷积残差块(Conv Block)
- 3.搭建ResNet-18
- 3.1ResNet-18介绍
- 3.2代码部分
- 3.3完整代码及测试
- 3.4 为什么叫ResNet-18?
- 结语
前言
在深度学习领域,“更深的网络性能更好” 曾是研究者们的共识 —— 理论上,网络层数越多,能捕捉的特征越复杂,拟合能力也越强。但在 2015 年之前,当网络深度超过 20 层后,研究者们发现了一个致命问题:梯度消失 / 梯度爆炸导致模型无法训练,甚至出现 “深度退化” 现象(深层网络的测试误差反而比浅层网络更高)。而残差网络(Residual Network,简称 ResNet)的出现,彻底打破了这一困境,不仅让 1000 层以上的超深网络成为可能,更成为如今计算机视觉领域的 “基石架构” 之一。本篇博客主要介绍残差网络以及如何搭建残差网络,以ResNet-18为例,原始论文地址:ResNet
1.为什么需要残差网络?
在 ResNet 诞生前,传统卷积神经网络(如 AlexNet、VGG)的深度通常在 10-20 层。当研究者尝试将网络层数提升到 50 层、100 层时,遇到了两个核心问题:
1.1梯度消失 / 梯度爆炸
深度学习的训练依赖反向传播模型通过计算损失函数对各层参数的梯度,不断调整参数以降低误差。但梯度在反向传播过程中,会经过多层权重的 “乘积”
如果每一层的梯度绝对值小于 1,经过几十层后,梯度会趋近于 0(梯度消失)
如果每一层的梯度绝对值大于 1,经过几十层后,梯度会趋近于无穷大(梯度爆炸)。
无论是梯度消失还是爆炸,都会导致深层网络的参数无法有效更新:浅层参数几乎不动,深层参数更新混乱,模型最终无法收敛。
1.2深度退化现象
即使通过 权重初始化、Batch Normalization 等技术缓解了梯度问题,研究者还发现了更奇怪的现象:当网络深度超过一定阈值后,测试误差会随着层数增加而上升。

正是为了解决这两个痛点,微软亚洲研究院的何凯明团队在 2015 年的 ImageNet 竞赛中提出了 ResNet,一举夺冠并引发深度学习架构的 “深度革命”。
2.ResNet 的核心创新:残差块与残差连接
ResNet 的核心思想非常简洁:在传统网络的基础上,增加 残差连接”(Residual Connection),让网络可以直接学习 “残差” 而非 “完整特征”。
2.1 什么是 “残差”?
假设传统网络中,某一层的输入为x,期望输出为H(x)(即该层需要学习的完整特征)。ResNet 没有让该层直接学习H(x),而是引入了一条 “shortcut path”(捷径),将输入x直接传递到该层的输出端,让该层学习 “残差”F(x) = H(x) - x。
最终该层的输出为:H(x) = F(x) + x。
这里的F(x)就是 “残差”,而x通过捷径直接传递的过程,就是 “残差连接”。
为什么要学习残差?因为当网络需要学习 “恒等映射”(即H(x) = x,该层不需要改变特征)时,传统网络需要让参数学习到H(x) = x,这在深层网络中很难实现;而 ResNet 只需让F(x) = 0(残差为 0)即可,大大降低了学习难度。
2.2. 残差块的两种结构
ResNet 的基本组成单元是 “残差块”(Residual Block),根据输入输出特征图的尺寸是否一致,分为两种结构:
2.2.1恒等映射残差块(Identity Block)
当输入x的特征图尺寸(高度、宽度)和通道数,与残差块的输出特征图尺寸一致时,使用这种结构。此时,残差连接可以直接将x与F(x)相加(元素 - wise add)。
其结构流程为:
- 输入x经过第一个卷积层(Conv2d),激活函数为 ReLU;
- 经过第二个卷积层(Conv2d),此时不使用 ReLU;
- 通过残差连接,将原始输入x与第二个卷积层的输出相加;
- 经过 ReLU 激活函数,得到残差块的输出。
如下图所示:

代码如下:
import torch
import torch.nn as nn
import torch.nn.functional as Fclass IdentityBlock(nn.Module):def __init__(self, in_channels, out_channels, stride=1):"""初始化恒等映射残差块:param in_channels: 输入特征图的通道数:param out_channels: 输出特征图的通道数(ResNet-18中in_channels=out_channels):param stride: 卷积步长(默认1,不改变尺寸)"""super(IdentityBlock, self).__init__()# 第一层卷积:3×3卷积(提取特征)+ ReLU(激活函数)self.conv1 = nn.Conv2d(in_channels=in_channels,out_channels=out_channels,kernel_size=3, # 3×3卷积核stride=stride,padding=1, # padding=1保证输入输出尺寸一致(H/W不变))# 第二层卷积:3×3卷积(进一步提取特征,无ReLU,后续与捷径相加后再激活)self.conv2 = nn.Conv2d(in_channels=out_channels,out_channels=out_channels,kernel_size=3,stride=1,padding=1,)def forward(self, x):"""前向传播:输入x → 卷积→ReLU → 卷积→ 加捷径 → ReLU"""residual = x # 捷径:保存原始输入(恒等映射)# 第一层卷积+ReLUout = self.conv1(x)out = F.relu(out)# 第二层卷积(无ReLU)out = self.conv2(out)# 残差连接:输出 + 捷径(恒等映射)out += residual# 最终激活out = F.relu(out)return outif __name__ == '__main__':Net = IdentityBlock(3, 3)input=torch.randn(3,32,32)output=Net(input)print(output.shape)
2.2.2 1×1 卷积残差块(Conv Block)
当输入x的特征图尺寸或通道数,与残差块的输出不一致时(例如网络需要下采样或调整通道数),直接相加会出现 “维度不匹配” 的问题。此时,需要在残差连接中增加一个1×1 卷积层,将x的维度调整为与F(x)一致,再进行相加。
代码如下:
class ConvBlock(nn.Module):def __init__(self, in_channels, out_channels, stride=2):"""初始化1×1卷积调整残差块:param in_channels: 输入特征图的通道数:param out_channels: 输出特征图的通道数(通常是输入的2倍):param stride: 卷积步长(默认2,实现下采样,H/W变为原来的1/2)"""super(ConvBlock, self).__init__()# 主路径:3×3卷积(stride=2下采样)+ ReLU → 3×3卷积self.conv1 = nn.Conv2d(in_channels=in_channels,out_channels=out_channels,kernel_size=3,stride=stride, # 步长=2,下采样padding=1)self.conv2 = nn.Conv2d(in_channels=out_channels,out_channels=out_channels,kernel_size=3,stride=1,padding=1,)# 捷径:1×1卷积(调整通道数+下采样)+ BN(确保维度与主路径输出一致)self.shortcut = nn.Conv2d(in_channels=in_channels,out_channels=out_channels,kernel_size=1, # 1×1卷积(仅调整通道数,不改变特征图内容)stride=stride, # 与主路径一致,实现下采样)def forward(self, x):"""前向传播:输入x → 主路径卷积→ReLU → 主路径卷积 → 捷径卷积 → 相加 → ReLU"""# 主路径out = self.conv1(x)out = F.relu(out)out = self.conv2(out)# 捷径路径(1×1卷积调整维度)residual = self.shortcut(x)# 残差连接:主路径输出 + 调整后的捷径out += residualout = F.relu(out)return outif __name__ == '__main__':# Net = IdentityBlock(3, 3)Net=ConvBlock(3,6)input=torch.randn(3,32,32)output=Net(input)print(output.shape)
3.搭建ResNet-18
了解上述两种结构后,下面开始搭建经典网络结构ResNet-18。
3.1ResNet-18介绍
ResNet-18 的整体结构遵循 “输入层→4 个残差块组→全局平均池化→全连接层”,具体配置如下:
- 输入层:7×7 卷积(下采样)+ 最大池化
- 残差块组 1(通道 64):2 个恒等映射块(无下采样,尺寸不变)
- 残差块组 2(通道 128):1 个 Conv Block(下采样)+ 1 个 Identity Block
- 残差块组 3(通道 256):1 个 Conv Block(下采样)+ 1 个 Identity Block
- 残差块组 4(通道 512):1 个 Conv Block(下采样)+ 1 个 Identity Block
- 输出层:全局平均池化 + 全连接层(输出类别数,如 ImageNet 的 1000 类)
3.2代码部分
这里为了避免代码的过度重复,引入了_make_layer()函数,此函数用于批量创建残差块组。
代码如下:
def _make_layer(self, out_channels, num_blocks, stride):"""批量创建残差块组:param out_channels: 该组残差块的输出通道数:param num_blocks: 该组包含的残差块数量:param stride: 该组第一个残差块的步长(用于下采样):return: 残差块组(nn.Sequential)"""layers = []# 每组的第一个残差块:若stride≠1或输入通道≠输出通道,用ConvBlock(调整维度)if stride != 1 or self.in_channels != out_channels:layers.append(ConvBlock(self.in_channels, out_channels, stride))else:layers.append(IdentityBlock(self.in_channels, out_channels, stride))# 更新输入通道数(后续块的输入=当前块的输出)self.in_channels = out_channels# 每组剩余的残差块:均为IdentityBlock(维度已匹配)for _ in range(1, num_blocks):layers.append(IdentityBlock(self.in_channels, out_channels))return nn.Sequential(*layers)
此时创建ResNet-18,代码如下:
class ResNet18(nn.Module):def __init__(self, num_classes=1000):"""初始化ResNet-18:param num_classes: 输出类别数(默认1000,对应ImageNet数据集;若为CIFAR-10则设为10)"""super(ResNet18, self).__init__()# 1. 输入层:7×7卷积(下采样)+ BN + ReLU + 最大池化self.in_channels = 64 # 输入层卷积后的通道数(固定为64)self.conv1 = nn.Conv2d(in_channels=3, # 输入图像为RGB三通道out_channels=self.in_channels,kernel_size=7,stride=2, # 步长=2,下采样(H/W从224→112)padding=3, # 7×7卷积+padding=3,保证尺寸计算:(224-7+2*3)/2 +1 = 112bias=False)self.bn1 = nn.BatchNorm2d(self.in_channels)self.maxpool = nn.MaxPool2d(kernel_size=3,stride=2, # 进一步下采样(H/W从112→56)padding=1)# 2. 残差块组(共4组,对应ResNet-18的结构)self.layer1 = self._make_layer(out_channels=64, num_blocks=2, stride=1) # 无下采样(56→56)self.layer2 = self._make_layer(out_channels=128, num_blocks=2, stride=2) # 下采样(56→28)self.layer3 = self._make_layer(out_channels=256, num_blocks=2, stride=2) # 下采样(28→14)self.layer4 = self._make_layer(out_channels=512, num_blocks=2, stride=2) # 下采样(14→7)# 3. 输出层:全局平均池化 + 全连接层self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) # 自适应池化,无论输入尺寸,输出(1,1)特征图self.fc = nn.Linear(512, num_classes) # 512通道→num_classes类别def forward(self, x):"""ResNet-18前向传播完整流程"""# 输入层out = self.conv1(x)out = self.bn1(out)out = F.relu(out)out = self.maxpool(out)# 残差块组out = self.layer1(out)out = self.layer2(out)out = self.layer3(out)out = self.layer4(out)# 输出层out = self.avgpool(out) # 输出尺寸:(batch_size, 512, 1, 1)out = torch.flatten(out, 1) # 展平:(batch_size, 512)out = self.fc(out) # 最终输出:(batch_size, num_classes)return out
3.3完整代码及测试
根据以上信息介绍,完整代码如下:
import torch
import torch.nn as nn
import torch.nn.functional as Fclass IdentityBlock(nn.Module):def __init__(self, in_channels, out_channels, stride=1):"""初始化恒等映射残差块:param in_channels: 输入特征图的通道数:param out_channels: 输出特征图的通道数(ResNet-18中in_channels=out_channels):param stride: 卷积步长(默认1,不改变尺寸)"""super(IdentityBlock, self).__init__()# 第一层卷积:3×3卷积(提取特征)+ ReLU(激活函数)self.conv1 = nn.Conv2d(in_channels=in_channels,out_channels=out_channels,kernel_size=3, # 3×3卷积核stride=stride,padding=1, # padding=1保证输入输出尺寸一致(H/W不变))# 第二层卷积:3×3卷积(进一步提取特征,无ReLU,后续与捷径相加后再激活)self.conv2 = nn.Conv2d(in_channels=out_channels,out_channels=out_channels,kernel_size=3,stride=1,padding=1,)def forward(self, x):"""前向传播:输入x → 卷积→ReLU → 卷积→ 加捷径 → ReLU"""residual = x # 捷径:保存原始输入(恒等映射)# 第一层卷积+ReLUout = self.conv1(x)out = F.relu(out)# 第二层卷积(无ReLU)out = self.conv2(out)# 残差连接:输出 + 捷径(恒等映射)out += residual# 最终激活out = F.relu(out)return outclass ConvBlock(nn.Module):def __init__(self, in_channels, out_channels, stride=2):"""初始化1×1卷积调整残差块:param in_channels: 输入特征图的通道数:param out_channels: 输出特征图的通道数(通常是输入的2倍):param stride: 卷积步长(默认2,实现下采样,H/W变为原来的1/2)"""super(ConvBlock, self).__init__()# 主路径:3×3卷积(stride=2下采样)+ ReLU → 3×3卷积self.conv1 = nn.Conv2d(in_channels=in_channels,out_channels=out_channels,kernel_size=3,stride=stride, # 步长=2,下采样padding=1)self.conv2 = nn.Conv2d(in_channels=out_channels,out_channels=out_channels,kernel_size=3,stride=1,padding=1,)# 捷径:1×1卷积(调整通道数+下采样)+ BN(确保维度与主路径输出一致)self.shortcut = nn.Conv2d(in_channels=in_channels,out_channels=out_channels,kernel_size=1, # 1×1卷积(仅调整通道数,不改变特征图内容)stride=stride, # 与主路径一致,实现下采样)def forward(self, x):"""前向传播:输入x → 主路径卷积→ReLU → 主路径卷积 → 捷径卷积 → 相加 → ReLU"""# 主路径out = self.conv1(x)out = F.relu(out)out = self.conv2(out)# 捷径路径(1×1卷积调整维度)residual = self.shortcut(x)# 残差连接:主路径输出 + 调整后的捷径out += residualout = F.relu(out)return outclass ResNet18(nn.Module):def __init__(self, num_classes=1000):"""初始化ResNet-18:param num_classes: 输出类别数(默认1000,对应ImageNet数据集;若为CIFAR-10则设为10)"""super(ResNet18, self).__init__()# 1. 输入层:7×7卷积(下采样)+ BN + ReLU + 最大池化self.in_channels = 64 # 输入层卷积后的通道数(固定为64)self.conv1 = nn.Conv2d(in_channels=3, # 输入图像为RGB三通道out_channels=self.in_channels,kernel_size=7,stride=2, # 步长=2,下采样(H/W从224→112)padding=3, # 7×7卷积+padding=3,保证尺寸计算:(224-7+2*3)/2 +1 = 112bias=False)self.bn1 = nn.BatchNorm2d(self.in_channels)self.maxpool = nn.MaxPool2d(kernel_size=3,stride=2, # 进一步下采样(H/W从112→56)padding=1)# 2. 残差块组(共4组,对应ResNet-18的结构)self.layer1 = self._make_layer(out_channels=64, num_blocks=2, stride=1) # 无下采样(56→56)self.layer2 = self._make_layer(out_channels=128, num_blocks=2, stride=2) # 下采样(56→28)self.layer3 = self._make_layer(out_channels=256, num_blocks=2, stride=2) # 下采样(28→14)self.layer4 = self._make_layer(out_channels=512, num_blocks=2, stride=2) # 下采样(14→7)# 3. 输出层:全局平均池化 + 全连接层self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) # 自适应池化,无论输入尺寸,输出(1,1)特征图self.fc = nn.Linear(512, num_classes) # 512通道→num_classes类别def _make_layer(self, out_channels, num_blocks, stride):"""批量创建残差块组:param out_channels: 该组残差块的输出通道数:param num_blocks: 该组包含的残差块数量:param stride: 该组第一个残差块的步长(用于下采样):return: 残差块组(nn.Sequential)"""layers = []# 每组的第一个残差块:若stride≠1或输入通道≠输出通道,用ConvBlock(调整维度)if stride != 1 or self.in_channels != out_channels:layers.append(ConvBlock(self.in_channels, out_channels, stride))else:layers.append(IdentityBlock(self.in_channels, out_channels, stride))# 更新输入通道数(后续块的输入=当前块的输出)self.in_channels = out_channels# 每组剩余的残差块:均为IdentityBlock(维度已匹配)for _ in range(1, num_blocks):layers.append(IdentityBlock(self.in_channels, out_channels))return nn.Sequential(*layers)def forward(self, x):"""ResNet-18前向传播完整流程"""# 输入层out = self.conv1(x)out = self.bn1(out)out = F.relu(out)out = self.maxpool(out)# 残差块组out = self.layer1(out)out = self.layer2(out)out = self.layer3(out)out = self.layer4(out)# 输出层out = self.avgpool(out) # 输出尺寸:(batch_size, 512, 1, 1)out = torch.flatten(out, 1) # 展平:(batch_size, 512)out = self.fc(out) # 最终输出:(batch_size, num_classes)return outif __name__ == '__main__':input=torch.randn(3,3,224,224)Net=ResNet18()output=Net(input)print(output.shape)
此处测试输入的是批量大小为3的三通道彩色图,图片尺寸大小为224×224,该任务完成的是1000分类。输出结果output尺寸为:

3.4 为什么叫ResNet-18?
仔细观察本网络结构,依次为:输入层(卷积层,调整数据规格,此处为第一层)、四个残差组(每个残差组有两个BLOCKS,即残差块,Identity Block 或者Conv Block,每个Block包括两个卷积层,所以此处有2×2×4=16层网络)、全连接层(此处为最后一层),共计18层网络结构,因此称为ResNet-18。
结语
本篇博客主要介绍了如何从零搭建一个ResNet-18网络,可以使用该网络结构实现分类问题,可以动手实现利用该网络在CIFAR-10数据集、MINST数据集等公开数据集进一步熟悉,希望能够对你有所帮助!
