文章目錄
- 前言
- 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 F
class 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 # 捷徑:保存原始輸入(恆等映射)
# 第一層卷積+ReLU
out = self.conv1(x)
out = F.relu(out)
# 第二層卷積(無ReLU)
out = self.conv2(out)
# 殘差連接:輸出 + 捷徑(恆等映射)
out += residual
# 最終激活
out = F.relu(out)
return out
if __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 += residual
out = F.relu(out)
return out
if __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 = 112
bias=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 F
class 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 # 捷徑:保存原始輸入(恆等映射)
# 第一層卷積+ReLU
out = self.conv1(x)
out = F.relu(out)
# 第二層卷積(無ReLU)
out = self.conv2(out)
# 殘差連接:輸出 + 捷徑(恆等映射)
out += residual
# 最終激活
out = F.relu(out)
return out
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 += residual
out = F.relu(out)
return out
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 = 112
bias=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 out
if __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數據集等公開數據集進一步熟悉,希望能夠對你有所幫助!