簡介
深度殘差網(wǎng)絡(luò)(Deep residual network, ResNet)的提出是CNN圖像史上的一件里程碑事件磁浇,讓我們先看一下ResNet在ILSVRC和COCO數(shù)據(jù)集上的戰(zhàn)績:ResNet取得了5項第一,并又一次刷新了CNN模型在ImageNet上的歷史成績猩系。ResNet的主要創(chuàng)新點在于設(shè)計了一種使用了Shortcut Connection的殘差結(jié)構(gòu),使得網(wǎng)絡(luò)可以設(shè)計的很深,有效解決了梯度消失問題并且同時提升了性能犀概。
網(wǎng)絡(luò)退化問題
深度卷積神經(jīng)網(wǎng)絡(luò)在圖像識別領(lǐng)域取得了一系列重大的突破综看。深度神經(jīng)網(wǎng)絡(luò)以端到端的多層方式集成了低級脾拆、中級馒索、高級的特征以及分類器,通過增加網(wǎng)絡(luò)層數(shù)名船,網(wǎng)絡(luò)可以進(jìn)行更加復(fù)雜的特征提取绰上。最近的一些證據(jù)表明網(wǎng)絡(luò)深度對模型的性能至關(guān)重要,在ImageNet數(shù)據(jù)集上的表現(xiàn)良好的模型普遍層數(shù)都較大渠驼。即便是在一些非視覺識別的任務(wù)上蜈块,深度模型也帶來了很大的好處。
作者分別使用了20層和56層的網(wǎng)絡(luò)結(jié)構(gòu)在CIFAR-10數(shù)據(jù)集上進(jìn)行對比實驗,可以看到隨著網(wǎng)絡(luò)層數(shù)加深述雾,訓(xùn)練錯誤率和測試錯誤率反而越高街州。
殘差結(jié)構(gòu)
深度網(wǎng)絡(luò)的退化問題表明不是所有的系統(tǒng)都容易優(yōu)化兼丰。假設(shè)我們現(xiàn)在有一個淺層網(wǎng)絡(luò),我們再通過以下方式構(gòu)造一個對應(yīng)的深層網(wǎng)絡(luò)唆缴。這個深層網(wǎng)絡(luò)首先復(fù)制已經(jīng)訓(xùn)練好的淺層網(wǎng)絡(luò)鳍征,其次再往上堆疊更多的恒等映射(Identity mapping)層,即這些新增的層什么都不學(xué)習(xí)面徽。在這種情況下艳丛,這個深層網(wǎng)絡(luò)應(yīng)該至少和淺層網(wǎng)絡(luò)性能一樣,也不應(yīng)該出現(xiàn)退化現(xiàn)象趟紊。但是實驗表明我們目前掌握的方法無法構(gòu)造出這種對應(yīng)的深層網(wǎng)絡(luò)(也有可能是無法在有限時間內(nèi)找到)氮双。
為此,論文作者提出了殘差學(xué)習(xí)來解決網(wǎng)絡(luò)退化問題霎匈。對于一個堆積層結(jié)構(gòu)(由幾層疊加組成)戴差,當(dāng)輸入為時,傳統(tǒng)方式是期望它學(xué)到的特征為铛嘱。但是對于殘差網(wǎng)絡(luò)而言造挽,它期望這個堆積層學(xué)到的特征為,其中弄痹,即這個堆積層學(xué)到的特征可以看成是在學(xué)習(xí)實際輸出和輸入之間的殘差饭入,所以命名為殘差模塊。那么原始輸出肛真。作者認(rèn)為學(xué)習(xí)殘差特征會比直接學(xué)習(xí)原始特征更容易谐丢。在極端情況下,當(dāng)殘差時蚓让,此時堆積層僅僅做了恒等映射乾忱,即這些堆積的層不會引起網(wǎng)絡(luò)性能下降。當(dāng)然實際上殘差也不會為0历极,這也會使得殘差結(jié)構(gòu)可以在輸入特征的基礎(chǔ)上學(xué)習(xí)到新的特征窄瘟,從而即加大了網(wǎng)絡(luò)深度并且學(xué)習(xí)了更復(fù)雜的特征,但同時又不會引起網(wǎng)絡(luò)性能下降趟卸。
殘差網(wǎng)絡(luò)結(jié)構(gòu)如下:
其中右邊的曲線就是代表的恒等映射蹄葱,它跳過了2個層,直接從輸入連接到了輸出锄列,有點類似電路中的短路連接(shortcut connection)图云。這種短路連接既不需要額外的參數(shù),也不會增加計算復(fù)雜度邻邮。整個網(wǎng)絡(luò)仍然可以使用SGD算法搭配反向傳播來進(jìn)行端到端的訓(xùn)練竣况。
這里簡單分析一下為什么殘差學(xué)習(xí)相對容易,從直觀上看筒严,讓網(wǎng)絡(luò)直接學(xué)習(xí)的映射丹泉,會比讓網(wǎng)絡(luò)直接學(xué)習(xí)的映射所學(xué)的內(nèi)容少情萤。因為殘差一般比較小,學(xué)習(xí)難度小一點摹恨。下面從數(shù)學(xué)的角度來分析這個問題筋岛,殘差模塊可以表示為:
其中和表示第個殘差單元的輸入和輸出,注意每一個殘差單元一般包含多層結(jié)構(gòu)睬塌。是殘差函數(shù)泉蝌,表示殘差網(wǎng)絡(luò)學(xué)習(xí)到的殘差。函數(shù)代表的是恒等映射揩晴,即上圖中的曲線部分勋陪,那么有。是ReLU激活函數(shù)硫兰∽缬蓿基于上式,我們求得網(wǎng)絡(luò)從淺層到深層學(xué)習(xí)到的特征為:
利用鏈?zhǔn)椒▌t劫映,可以求得反向過程的梯度:
其中注意看小括號中的部分违孝,其中的1表明短路機(jī)制可以無損地傳播梯度,而另外一項殘差則需要繼續(xù)經(jīng)過鏈?zhǔn)椒▌t求導(dǎo)獲得殘差梯度再傳播泳赋。而殘差梯度也不會那么巧剛好為-1雌桑,這就意味著總體梯度不太可能每次都為0,因此使得網(wǎng)絡(luò)變得更加容易學(xué)習(xí)祖今。
完整的內(nèi)容可以參考論文《Identity Mappings in Deep Residual Networks》校坑。
網(wǎng)絡(luò)結(jié)構(gòu)
ResNet網(wǎng)絡(luò)結(jié)構(gòu)主要參考了VGG19網(wǎng)絡(luò),在其基礎(chǔ)上通過短路連接加上了殘差單元千诬。ResNet大多使用3x3的卷積核并且遵循以下兩條設(shè)計原則:
- 對于同樣的輸出feature map大小耍目,每層擁有同樣數(shù)量的filters。
- 當(dāng)feature map的大小降低一半時徐绑,feature map的數(shù)量增加一倍邪驮,以保持網(wǎng)絡(luò)的復(fù)雜度。
上圖中最左邊是VGG-19網(wǎng)絡(luò)傲茄,中間是樸素ResNet-34網(wǎng)絡(luò)毅访,右邊是包含殘差單元的ResNet-34網(wǎng)絡(luò)。其中ResNet相比普通網(wǎng)絡(luò)在每兩層之間添加了短路機(jī)制烫幕,這就形成了殘差學(xué)習(xí)俺抽。虛線表示的是feature map的數(shù)量發(fā)生了變化。
其中以ResNet34為例较曼,紅色部分代表的是不同殘差層的殘差單元的數(shù)量。
殘差單元
上圖中進(jìn)行的是兩層間的殘差學(xué)習(xí)振愿,當(dāng)網(wǎng)絡(luò)更深的時候捷犹,可以進(jìn)行3層之間的殘差學(xué)習(xí)弛饭。下面是不同的殘差單元示意圖:網(wǎng)絡(luò)結(jié)構(gòu)剖析
接下來以ResNet-34為例,一層一層地分析它的結(jié)構(gòu)萍歉,首先從另外一個角度來看一下ResNet-34侣颂。我們的輸入圖像是224x224,首先通過1個卷積層枪孩,接著通過4個殘差層憔晒,最后通過Softmax之中輸出一個1000維的向量,代表ImageNet的1000個分類蔑舞。
1.卷積層1
ResNet的第一步是將圖像通過一個名為Conv1的塊拒担,這個塊包含卷積操作、批量歸一化攻询、最大池化操作从撼。
最大池化操作的時候設(shè)置padding大小為2,步長為2誓沸,池化塊大小為3梅桩,因此得到最后輸出大小為56。完整計算過程見下圖:
2.殘差層
我們先來解釋一個名詞拜隧,塊宿百。ResNet的每一層都包含若干個塊。這是因為ResNet網(wǎng)絡(luò)深度的加大是通過增加一個塊中的操作來實現(xiàn)的洪添,而總體的層數(shù)仍然保持不變垦页。這里所說的一個塊中的操作通常指的是對輸入進(jìn)行卷積操作、批量歸一化操作以及通過ReLU激活函數(shù)干奢,當(dāng)然除了最后一個塊痊焊,因為它不包含ReLU激活函數(shù)。
塊操作
我們先來描述一下一個塊中的操作是怎樣的?見下圖:經(jīng)過Conv1層之后薄啥,我們的輸入變?yōu)榱?6x56辕羽,接著通過查看ResNet架構(gòu)參數(shù)表中可得,使用的是[3x3,64]的卷積核垄惧,輸出大小是56x56刁愿。我們需要注意的是,在一個block中進(jìn)行的操作是不會改變輸入大小的到逊。這是因為我們設(shè)置padding為1铣口,并且步長也設(shè)置為1。所以得到的輸出大小與輸入一致觉壶。
上圖的左半部分代表的是實際計算過程脑题,右圖對應(yīng)的是ResNet模型框架圖中的部分。
同理掰曾,3個殘差單元堆疊起來之后的計算示意圖如下(卷積核為3x[3x3,64]):
ResNet網(wǎng)絡(luò)結(jié)構(gòu)圖中的其他層也類似旭蠕,只要知道其中一層的殘差單元計算方式,我們很容易就可以推廣到整個網(wǎng)絡(luò)結(jié)構(gòu)中去旷坦。 如果我們仔細(xì)觀察每一層的第一個操作掏熬,我們會發(fā)現(xiàn)第一個操作使用的stride設(shè)置為2,而其余操作的stride設(shè)置為1秒梅。這意味著網(wǎng)絡(luò)是通過增大步長來進(jìn)行下采樣的旗芬,而不是像傳統(tǒng)CNN網(wǎng)絡(luò)那樣通過池化操作來進(jìn)行。實際上捆蜀,只有Conv1層中使用了一個最大池化操作疮丛,以及在ResNet末尾的全連接層之前執(zhí)行了一個平均池化操作。
上圖的紅色部分代表的是第三和第四層中的第一個殘差單元辆它,藍(lán)色部分代表的殘差單元中的第一個塊操作誊薄,可以看到stride設(shè)置為2,而其余均為默認(rèn)值1锰茉。
再看一下上圖呢蔫,模型架構(gòu)中的虛線代表的是要改變輸入的維度,對于短路連接飒筑,當(dāng)輸入和輸出維度一致時片吊,可以直接將輸入加到輸出上。但是當(dāng)維度不一致時协屡,這就不能直接相加俏脊。注意看ResNet網(wǎng)絡(luò)模型圖,每個不同顏色代表的不同的層肤晓,不同層之間的輸入和輸出大小是不一樣的爷贫,因此不能直接相加认然,實際上每個不同層所做的第一個操作就是降低維度。關(guān)于降低維度主要有兩種策略:
- 采用zero-padding增加維度沸久,此時一般要先做一個downsamp季眷,可以采用strde=2的pooling余蟹,這樣不會增加參數(shù)卷胯。
- 采用新的映射(Projection Shortcut),一般采用1x1的卷積威酒,這樣會增加參數(shù)窑睁,也會增加計算量。
下面展示一下Projection Shortcut方式的計算過程葵孤。以下圖為例担钮,輸入為56x56x64,輸出為28x28x128尤仍,選擇3x3大小的卷積核箫津,通過設(shè)置stride為2,padding為1宰啦,得到輸出大小為28x28苏遥。
接著采用1x1的卷積,stride設(shè)置為1赡模,padding設(shè)置為0田炭,得到的輸出大小為28x28。
下面這張示意圖展示了ResNet第二層的整體計算過程瞬矩。
接下來的3、4層計算流程也是一樣的锋玲,就不再贅述景用。
實驗結(jié)果
下圖是ResNet與其他模型在ImageNet數(shù)據(jù)集上的結(jié)果對比,可以看到ResNet-152在Top-1和Top-5的錯誤率上均達(dá)到了SOTA嫩絮,再仔細(xì)觀察下ResNet網(wǎng)絡(luò)自身之間的對比丛肢,也可以發(fā)現(xiàn)隨著層數(shù)的增加,錯誤率持續(xù)降低剿干,可見ResNet有效地解決了層數(shù)增加帶來的副作用蜂怎。代碼實踐
網(wǎng)絡(luò)模型定義相關(guān)代碼,主要定義了BasicBlock類置尔,即包含2個卷積塊的殘差單元杠步;Bottleneck類,即包含了3個卷積塊的殘差單元;以及ResNet類幽歼,定義了整個網(wǎng)絡(luò)結(jié)構(gòu)朵锣。完整代碼如下:
import torch
import torch.nn as nn
import torch.nn.functional as F
class BasicBlock(nn.Module):
# 2層的殘差單元
expansion = 1
def __init__(self, in_planes, planes, stride=1):
super(BasicBlock, self).__init__()
self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(planes)
# 第二個卷積操作不改變維度和輸出大小,因為stride=1 padding=1
self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(planes)
self.shortcut = nn.Sequential()
# 如果步長不為1,或者輸入與輸出通道不一致甸私,則需要進(jìn)行Projection Shortcut操作
if stride != 1 or in_planes != self.expansion*planes:
# Projection Shortcut
self.shortcut = nn.Sequential(
nn.Conv2d(in_planes, self.expansion*planes, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(self.expansion*planes)
)
def forward(self, x):
# 依次通過兩個卷積層诚些,和shortcut連接層,再累加起來皇型。
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += self.shortcut(x)
out = F.relu(out)
return out
class Bottleneck(nn.Module):
# 3層的殘差單元
expansion = 4
def __init__(self, in_planes, planes, stride=1):
super(Bottleneck, self).__init__()
self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=1, bias=False)
self.bn1 = nn.BatchNorm2d(planes)
self.conv2 = nn.Conv2d(planes, planes, kernel_size=3,
stride=stride, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(planes)
self.conv3 = nn.Conv2d(planes, self.expansion *
planes, kernel_size=1, bias=False)
self.bn3 = nn.BatchNorm2d(self.expansion*planes)
self.shortcut = nn.Sequential()
if stride != 1 or in_planes != self.expansion*planes:
self.shortcut = nn.Sequential(
nn.Conv2d(in_planes, self.expansion*planes,
kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(self.expansion*planes)
)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = F.relu(self.bn2(self.conv2(out)))
out = self.bn3(self.conv3(out))
out += self.shortcut(x)
out = F.relu(out)
return out
class ResNet(nn.Module):
def __init__(self, config):
super(ResNet, self).__init__()
self._config = config
# 默認(rèn)輸入通道為64
self.in_channels = 64
# 代表ResNet中的Conv1卷積層
self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(64)
# 分別代表ResNet中的4層
self.layer1 = self._make_layer(config['block_type'], 64, config['num_blocks'][0], stride=1)
self.layer2 = self._make_layer(config['block_type'], 128, config['num_blocks'][1], stride=2)
self.layer3 = self._make_layer(config['block_type'], 256, config['num_blocks'][2], stride=2)
self.layer4 = self._make_layer(config['block_type'], 512, config['num_blocks'][3], stride=2)
self.linear = nn.Linear(512 * config['block_type'].expansion, config['num_classes'])
def _make_layer(self, block, planes, num_blocks, stride):
strides = [stride] + [1]*(num_blocks-1)
layers = []
for stride in strides:
layers.append(block(self.in_channels, planes, stride))
self.in_channels = planes * block.expansion
return nn.Sequential(*layers)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = self.layer1(out)
out = self.layer2(out)
out = self.layer3(out)
out = self.layer4(out)
out = F.avg_pool2d(out, 4)
out = out.view(out.size(0), -1)
out = self.linear(out)
return out
def saveModel(self):
torch.save(self.state_dict(), self._config['model_name'])
def loadModel(self, map_location):
state_dict = torch.load(self._config['model_name'], map_location=map_location)
self.load_state_dict(state_dict, strict=False)
配置模型參數(shù)定義ResNet-18網(wǎng)絡(luò)诬烹,設(shè)置batch size為500,訓(xùn)練輪次20弃鸦,采用Adam優(yōu)化算法绞吁,學(xué)習(xí)率設(shè)置為0.0001。
測試相關(guān)代碼如下:
import torch
from ResNet.network import ResNet
from ResNet.network import BasicBlock
from ResNet.network import Bottleneck
from ResNet.trainer import Trainer
from ResNet.dataloader import LoadCIFAR10
from ResNet.dataloader import Construct_DataLoader
from torch.autograd import Variable
resnet_config = \
{
'block_type': BasicBlock,
'num_blocks': [2,2,2,2], #ResNet18
'num_epoch': 20,
'batch_size': 500,
'lr': 1e-3,
'l2_regularization':1e-4,
'num_classes': 10,
'device_id': 0,
'use_cuda': True,
'model_name': '../TrainedModels/ResNet18.model'
}
if __name__ == "__main__":
####################################################################################
# ResNet 模型
####################################################################################
train_dataset, test_dataset = LoadCIFAR10(True)
# define ResNet model
resNet = ResNet(resnet_config)
####################################################################################
# 模型訓(xùn)練階段
####################################################################################
# 實例化模型訓(xùn)練器
trainer = Trainer(model=resNet, config=resnet_config)
# 訓(xùn)練
trainer.train(train_dataset)
# 保存模型
trainer.save()
####################################################################################
# 模型測試階段
####################################################################################
resNet.eval()
if resnet_config['use_cuda']:
resNet.loadModel(map_location=torch.device('cpu'))
resNet = resNet.cuda()
else:
resNet.loadModel(map_location=lambda storage, loc: storage.cuda(resnet_config['device_id']))
correct = 0
total = 0
for images, labels in Construct_DataLoader(test_dataset, resnet_config['batch_size']):
images = Variable(images)
labels = Variable(labels)
if resnet_config['use_cuda']:
images = images.cuda()
labels = labels.cuda()
y_pred = resNet(images)
_, predicted = torch.max(y_pred.data, 1)
total += labels.size(0)
temp = (predicted == labels.data).sum()
correct += temp
print('Accuracy of the model on the test images: %.2f%%' % (100.0 * correct / total))
測試結(jié)果
訓(xùn)練和測試都是在CIFAR-10小型圖像數(shù)據(jù)集上進(jìn)行唬格,經(jīng)過20次迭代之后家破,在訓(xùn)練集上得到97.96%的準(zhǔn)確率,在測試集上得到81.41%的準(zhǔn)確率购岗。通過參數(shù)調(diào)整還可以達(dá)到更高的準(zhǔn)確率汰聋。完整代碼見https://github.com/HeartbreakSurvivor/ClassicNetworks/tree/master/ResNet。