引入
在深度学习的世界里,我们经常会听到一个问题:网络越深,效果一定越好吗?
其实,答案是否定的!如果你直接把网络层数加深,模型的训练反而可能变得更难,甚至出现
梯度消失 或 退化问题(深度越大,训练效果可能反而变差)。
ResNet(Residual Network,残差网络)就是为了解决这个问题而诞生的,它的核心思想是
加一条“捷径”,让数据可以跳跃传播!。
ResNet-18 是 ResNet 家族 的一员,它的数字 “18” 指的是网络总共有 18 层(主要是包含可学
习参数的卷积层和全连接层)。ResNet 的核心特点是 “残差连接”(Residual Connection),它能
让信息在网络中 跳跃 传播,避免梯度消失,让深层网络更容易训练。
ResNet-18的网络简图如下图(假设网络的输入的张量的形状为3 × 64 × 64 ),如图resnet的结
构分为四个stage,完整的ResNet-18的结构图在最后。
ResNet-18 的基本架构如下:
🔹 这里的 残差块(Residual Block) 负责做特征提取,每个块内部包含两个 3×3 卷积。
🔹 跳跃连接(Shortcut)让数据既可以通过卷积层,也可以 直接“抄近路” 传递到下一层。
残差连接
假设 输入是 X,而普通 CNN 的目标是直接学一个映射 F(X),但 ResNet 让网络学的是 F(X) -
X,这样一来,我们可以重写成:
F(X) 是卷积层学习的内容(残差)。
X 是输入的跳跃连接(shortcut)。
F(X) + X 就是输出。
残差块通常包含两个卷积层,每个卷积层后面跟着批量归一化(Batch Normalization)和激
活函数(如ReLU)。这些层负责提取和转换输入特征。
为啥残差块有这两种模式呐?
现在我们手动模拟一下残差块的运算:
【1】给定3 ×3的输入张量
【2】通过第一个卷积层,批量归一化,激活函数(ReLU)(负数变成0)
【3】通过第二个卷积层,批量归一化,激活函数(ReLU)(负数变成0)得到 F(X)
【4】F(X)+ X 得到最后的输出
F(X)+ X 可以直接相加的前提的是两者的大小一样,通道数一样
所以当两者不一样的时候我们需要增加一个分支(1×1的卷积进行调整)(需要 shortcut 的情况)
可以通过设置1×1的卷积的卷积核的数量来调整通道数
可以通过设置1×1的卷积的填充padding=0来调整大小
啥时侯两者不一样鸭?
步长 ≠ 1 时,意味着 输入的尺寸会缩小,比如 stride=2 时 H × W 变为 H/2 × W/2。
由于 x 形状变小了,我们需要用 1×1 卷积 让 x 也变小,以便与 F(x) 匹配。
in_channels != out_channels:
输入通道数 ≠ 输出通道数,意味着 x 的通道数 不能直接加到 F(x) 上。
例如:之前的特征图有 64 个通道,当前块的 F(x) 计算出了 128 个通道。
这样 x 是 64 通道,而 F(x) 是 128 通道,无法相加。解决办法:用 1×1 卷积 升维/降维,把 x 变
成 F(x) 一样的通道数。
残差块的实现
代码实现
import torch import torch.nn as nn import torch.nn.functional as F # ===== 定义 BasicBlock ===== class BasicBlock(nn.Module): 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, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) # 如果尺寸或通道不同,调整 shortcut self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): print("输入 x 形状:", x.shape) identity = self.shortcut(x) if len(self.shortcut) > 0: print("shortcut 调整后形状:", identity.shape) else: print("shortcut 未调整(直接跳接)") out = self.conv1(x) print("conv1 输出:", out.shape) out = self.bn1(out) print("bn1 输出:", out.shape) out = F.relu(out) print("relu1 输出:", out.shape) out = self.conv2(out) print("conv2 输出:", out.shape) out = self.bn2(out) print("bn2 输出:", out.shape) out += identity print("残差相加后:", out.shape) out = F.relu(out) print("relu2 输出:", out.shape) print("-" * 50) return out # ===== 二、创建一个实例并测试 ===== # 情况1:输入输出通道相同,不降采样 block1 = BasicBlock(in_channels=3, out_channels=3, stride=1) x1 = torch.randn(1, 3, 32, 32) # batch=1, 通道=3, 尺寸=32x32 print("===== 测试 block1(stride=1)=====") out1 = block1(x1) print("最终输出:", out1.shape) # 情况2:输入输出通道不同,降采样 block2 = BasicBlock(in_channels=3, out_channels=4, stride=2) x2 = torch.randn(1, 3, 32, 32) print("\n===== 测试 block2(stride=2)=====") out2 = block2(x2) print("最终输出:", out2.shape)
现在我们开始图解每一步的实现过程:
情况1:输入输出通道相同,不降采样
首先,经过第一个卷积,归一化,和激活函数,大小仍然是3*32*32
然后我们开始经过第二个卷积,归一化,大小仍然是3*32*32, 到这里为止,我们的第一个支路已经计算结束了。
然后我们开始计算残差那个分支:由于这个我们这个残差模块的输出通道数和输入通道数一样,步
长也是1,所以不需要残差分支的输出就是原始的X。然后把X和上面的另一个分支的F(X)逐个
元素相加就行。然后再经过一个Relu激活函数的 处理。残差模块就计算结束了。
情况2:输入输出通道不相同,降采样
首先,经过第一个卷积(4个大小是3*3,步长为2的卷积)归一化,和激活函数,由于卷积核的个
数是4,所以最后的输出通道数也是4,然后步长为2,所以最后图片的大小会会变成原来的一半,
最后的大小是4*16*16。
然后我们开始经过第二个卷积((4个大小是3*3,步长为1的卷积),归一化,大小仍然是
4*16*16,到这里为止,我们的第一个支路已经计算结束了。
然后我们开始计算残差那个分支:由于这个我们这个残差模块的输出通道数和输入通道数 不一
样,所以残差分支的输出就是原始的X经过shortcut之后的输出(1*16*16)。
最终的输出就是原始分支的输出(F(x))和残差分支的输出(X)的相加。然后再经过Relu激活函数之
后得到4*16*16大小的特征图。
make_layer(堆叠的多个
BasicBlock)
我们知道,Resnet一共包含4个layer,每个layer中含有多个num_blocks,为了更加简洁的表示,
我们可以使用一个layer函数,集成多个残差层,简化表示。
def _make_layer(self, out_channels, num_blocks, stride): layers = [] # 创建一个空列表,用于存储这一层的 BasicBlock # 第一个 BasicBlock 的 stride 为输入参数 stride,这里是第一个块 layers.append(BasicBlock(self.in_channels, out_channels, stride)) # 更新当前通道数 self.in_channels = out_channels # 接下来的 num_blocks - 1 个 BasicBlock 都使用 stride=1 for _ in range(1, num_blocks): layers.append(BasicBlock(self.in_channels, out_channels, stride=1)) # 将所有的 BasicBlock 堆叠成一个 nn.Sequential 模块 return nn.Sequential(*layers)
初始化 layers 列表
layers 列表用于存储构建的每一个 BasicBlock,最终返回的是一个 nn.Sequential(一个有序
的模块容器)。
添加第一个 BasicBlock:
layers.append(BasicBlock(self.in_channels, out_channels, stride))
第一个 BasicBlock 使用了传入的 stride,并且将当前的输入通道数(self.in_channels)作为输
入通道数,输出通道数为 out_channels。stride 影响卷积操作的步长,通常第一个 BasicBlock 需
要通过卷积来改变特征图的尺寸(例如降采样)。
更新当前通道数:
self.in_channels = out_channels
每次添加一个 BasicBlock 后,输入的通道数变为该 BasicBlock 输出的通道(out_channels)。
添加后续的 BasicBlock:
for _ in range(1, num_blocks):
从第 2 个 BasicBlok 开始,使用 stride=1,即不改变特征图尺寸。这里是为了保持相同的空间
分辨率,通常在残差网络的后续块中使用步长为 1 来保证特征图尺寸不变。
返回 nn.Sequential:
return nn.Sequential(*layers)
最后通过 nn.Sequential 将所有的 BasicBlock 按顺序组合成一层(layer)。nn.Sequential 会
按照列表中的顺序依次执行每个 BasicBlock。
举一个简单的例子
# ---------- 包含 _make_layer 的简单 ResNet 片段 ---------- class MiniResNet(nn.Module): def __init__(self): super(MiniResNet, self).__init__() self.in_channels = 3 # 使用 _make_layer 创建两层:第一层不降采样(保持 64 通道),第二层降采样并将通道提升到 128 self.layer1 = self._make_layer(out_channels=64, num_blocks=2, stride=1) def _make_layer(self, out_channels, num_blocks, stride): layers = [] layers.append(BasicBlock(self.in_channels, out_channels, stride)) self.in_channels = out_channels for _ in range(1, num_blocks): layers.append(BasicBlock(self.in_channels, out_channels, stride=1)) return nn.Sequential(*layers) def forward(self, x): print("input:", x.shape) x = self.layer1(x) print("after layer1:", x.shape) # 64 通道, 空间尺寸不变 return x # ---------- 测试 ---------- model = MiniResNet() # 假设输入是常见的 32x32 RGB 图像 inp = torch.randn(1, 3, 32, 32) out = model(inp)
完整代码
import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): 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, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) return F.relu(out) class ResNet18(nn.Module): def __init__(self, num_classes=2): super(ResNet18, self).__init__() self.in_channels = 64 self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(64) self.layer1 = self._make_layer(64, 2, stride=1) 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, num_blocks, stride): layers = [] layers.append(BasicBlock(self.in_channels, out_channels, stride)) self.in_channels = out_channels for _ in range(1, num_blocks): layers.append(BasicBlock(self.in_channels, out_channels, stride=1)) return nn.Sequential(*layers) def forward(self, x): print("输入形状: ", x.shape) out = self.conv1(x) print("conv1输出: ", out.shape) out = self.bn1(out) print("bn1输出: ", out.shape) out = F.relu(out) print("relu输出: ", out.shape) out = self.layer1(out) print("layer1输出:", out.shape) out = self.layer2(out) print("layer2输出:", out.shape) out = self.layer3(out) print("layer3输出:", out.shape) out = self.layer4(out) print("layer4输出:", out.shape) out = self.avgpool(out) print("avgpool输出:", out.shape) out = torch.flatten(out, 1) print("flatten输出:", out.shape) out = self.fc(out) print("fc输出: ", out.shape) return out # 测试网络(打印结构+输出形状) if __name__ == "__main__": model = ResNet18(num_classes=2) print("="*50) print("ResNet18 网络结构:") print(model) # 打印网络结构 print("="*50) x = torch.randn(1, 3, 32, 32) # 输入:1张3通道32x32图像 print("\n各层输出形状:") model(x)
==================================================
ResNet18 网络结构:
ResNet18(
(conv1): Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(layer1): Sequential(
(0): BasicBlock(
(conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(shortcut): Sequential()
)
(1): BasicBlock(
(conv1): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(shortcut): Sequential()
)
)
(layer2): Sequential(
(0): BasicBlock(
(conv1): Conv2d(64, 128, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(shortcut): Sequential(
(0): Conv2d(64, 128, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(shortcut): Sequential()
)
)
(layer3): Sequential(
(0): BasicBlock(
(conv1): Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(shortcut): Sequential(
(0): Conv2d(128, 256, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(shortcut): Sequential()
)
)
(layer4): Sequential(
(0): BasicBlock(
(conv1): Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(shortcut): Sequential(
(0): Conv2d(256, 512, kernel_size=(1, 1), stride=(2, 2), bias=False)
(1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
)
(1): BasicBlock(
(conv1): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(conv2): Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
(bn2): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(shortcut): Sequential()
)
)
(avgpool): AdaptiveAvgPool2d(output_size=(1, 1))
(fc): Linear(in_features=512, out_features=2, bias=True)
)
==================================================
各层输出形状:
输入形状: torch.Size([1, 3, 32, 32])
conv1输出: torch.Size([1, 64, 32, 32])
bn1输出: torch.Size([1, 64, 32, 32])
relu输出: torch.Size([1, 64, 32, 32])
layer1输出: torch.Size([1, 64, 32, 32])
layer2输出: torch.Size([1, 128, 16, 16])
layer3输出: torch.Size([1, 256, 8, 8])
layer4输出: torch.Size([1, 512, 4, 4])
avgpool输出: torch.Size([1, 512, 1, 1])
flatten输出: torch.Size([1, 512])
fc输出: torch.Size([1, 2])