ResNet可謂大名鼎鼎了,一直遵循拿來主義,沒有好好去學習它,當然,作為一個提出來快五年的網絡結構,已經有太多人寫過它了,不好下筆。
趁著假期好好梳理一遍,相關資源:
原論文:https://arxiv.org/pdf/1512.03385.pdf
Pytorch實現:https://github.com/pytorch/vision/blob/master/torchvision/models/resnet.py
本文將從源碼出發,進行學習。
1. ResNet總體介紹
在ResNet的原始論文裡,共介紹了幾種形式:
如無特殊說明,截圖均來自原始論文
作者根據網絡深度不同,一共定義了5種ResNet結構,從18層到152層,每種網絡結構都包含五個部分的卷積層,從conv1, conv2_x到conv5_x。這些卷積層我們拆解一下,其實就三種類型:
1.1 普通卷積conv1
conv1是一種普通的卷積,卷積核是7*7,輸出64通道,步長2,輸出size是112*112。圖中並沒有說padding值是多少,在Pytorch的官方實現中,該值為3:
self.inplanes = 64self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3, bias=False)self.bn1 = nn.BatchNorm2d(self.inplanes)self.relu = nn.ReLU(inplace=True)輸入size在論文中有寫,是224*224.
在這後面還有一個最大池化層:
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)至此,輸出就變成了56*56。
為什麼輸入是224*224,池化之後是56*56?
我的理解是,這更多是一種實驗發現的,對作者使用的數據集效果比較好的參數(作者使用的數據集當然是比較流行的開放數據集),但是是否在特定場景下是否就是最優呢?我決定並不見得,特定場景下,輸入size可能往往是比較規範的,那就完全有可能產生更好的參數。
1.2 BaseBlock殘差塊結構
就是ResNet18和ResNet34中的3*3卷積核的部分:
def conv3x3(in_planes, out_planes, stride=1, groups=1, dilation=1): """3x3 convolution with padding""" return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride, padding=dilation, groups=groups, bias=False, dilation=dilation)
class BasicBlock(nn.Module): expansion = 1
def __init__(self, inplanes, planes, stride=1, downsample=None, groups=1, base_width=64, dilation=1, norm_layer=None): super(BasicBlock, self).__init__() if norm_layer is None: norm_layer = nn.BatchNorm2d if groups != 1 or base_width != 64: raise ValueError('BasicBlock only supports groups=1 and base_width=64') if dilation > 1: raise NotImplementedError("Dilation > 1 not supported in BasicBlock") self.conv1 = conv3x3(inplanes, planes, stride) self.bn1 = norm_layer(planes) self.relu = nn.ReLU(inplace=True) self.conv2 = conv3x3(planes, planes) self.bn2 = norm_layer(planes) self.downsample = downsample self.stride = stride
def forward(self, x): identity = x
out = self.conv1(x) out = self.bn1(out) out = self.relu(out)
out = self.conv2(out) out = self.bn2(out)
if self.downsample is not None: identity = self.downsample(x)
out += identity out = self.relu(out) return out所謂殘差塊,就是兩個3*3的卷積層堆疊在一起:
例如,對於ResNet18的殘差塊conv2_x結構:
而所謂殘差連結,其實關鍵就一句:
out += identity
就是跳層將輸入和輸出進行相加,以解決深層網絡在訓練時容易導致的梯度消失或者爆炸問題。作者論文對此有過實驗:
網絡從20層增加到56層的時候,訓練誤差和測試誤差都顯著增加了。這也是比較好理解吧,經過深層網絡,原有的特徵可能都已經消失了。
為了殘差能正常連接在一起,殘差塊的輸入輸出需要是一致的。
看源碼,還有一個downsample,這個後面再說。
1.2 Bottleneck殘差塊
除了ResNet18和34,其他幾個用的都是Bottleneck殘差塊,還是一樣,我們先看代碼:
def conv3x3(in_planes, out_planes, stride=1, groups=1, dilation=1): """3x3 convolution with padding""" return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride, padding=dilation, groups=groups, bias=False, dilation=dilation)
def conv1x1(in_planes, out_planes, stride=1): """1x1 convolution""" return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride, bias=False) class Bottleneck(nn.Module):
expansion = 4
def __init__(self, inplanes, planes, stride=1, downsample=None, groups=1, base_width=64, dilation=1, norm_layer=None): super(Bottleneck, self).__init__() if norm_layer is None: norm_layer = nn.BatchNorm2d width = int(planes * (base_width / 64.)) * groups self.conv1 = conv1x1(inplanes, width) self.bn1 = norm_layer(width) self.conv2 = conv3x3(width, width, stride, groups, dilation) self.bn2 = norm_layer(width) self.conv3 = conv1x1(width, planes * self.expansion) self.bn3 = norm_layer(planes * self.expansion) self.relu = nn.ReLU(inplace=True) self.downsample = downsample self.stride = stride
def forward(self, x): identity = x
out = self.conv1(x) out = self.bn1(out) out = self.relu(out)
out = self.conv2(out) out = self.bn2(out) out = self.relu(out)
out = self.conv3(out) out = self.bn3(out)
if self.downsample is not None: identity = self.downsample(x)
out += identity out = self.relu(out) return out圖示大概如下:
核心思想就是利用1*1的卷積核來靈活控制通道數,使得殘差能順利連接在一起。具體做法就是使用一個1*1卷積核將通道數降為原來的1/4(降維),在進行3*3的卷積運算之後(保持通道數不變),又使用一個1*1卷積核將通道數提升為跟輸入一樣的通道數(升維)。
這樣做的好處是大大減少了參數的數量,如上圖的參數量是:1x1x256x64 + 3x3x64x64 + 1x1x64x256 = 69632,而如果這使用BaseBlock來實現的話,參數量變為:3x3x256x256x2 = 1179648,約為前者的17倍。我們看從ResNet34到ResNet50,深度增加了差不多三分之二,但是計算量卻只是增加5%左右(當然這樣比較有點作弊的嫌疑,畢竟ResNet50不少的層都是1*1的卷積運算,用於升降維)。
注意這裡的降維時的通道數計算:
width = int(planes * (base_width / 64.)) * groups
這裡的base_width對應的,就是訓練時的width_per_group參數,在默認值的情況下,width值就等於planes,顯然可以通過改變width_per_group和groups參數,來改變降維後的通道數。
不過,如果說這樣降維沒有特徵損失,我個人是不太相信的,只是說可能這種損失比較小。
2. ResNet的實現
講完了ResNet的基礎介紹,我們就可以講其實現了:
class ResNet(nn.Module):
def __init__(self, block, layers, num_classes=1000, zero_init_residual=False, groups=1, width_per_group=64, replace_stride_with_dilation=None, norm_layer=None): super(ResNet, self).__init__() if norm_layer is None: norm_layer = nn.BatchNorm2d self._norm_layer = norm_layer
self.inplanes = 64 self.dilation = 1 if replace_stride_with_dilation is None: replace_stride_with_dilation = [False, False, False] if len(replace_stride_with_dilation) != 3: raise ValueError("replace_stride_with_dilation should be None " "or a 3-element tuple, got {}".format(replace_stride_with_dilation)) self.groups = groups self.base_width = width_per_group self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = norm_layer(self.inplanes) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.layer1 = self._make_layer(block, 64, layers[0]) self.layer2 = self._make_layer(block, 128, layers[1], stride=2, dilate=replace_stride_with_dilation[0]) self.layer3 = self._make_layer(block, 256, layers[2], stride=2, dilate=replace_stride_with_dilation[1]) self.layer4 = self._make_layer(block, 512, layers[3], stride=2, dilate=replace_stride_with_dilation[2]) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(512 * block.expansion, num_classes)
for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0)
if zero_init_residual: for m in self.modules(): if isinstance(m, Bottleneck): nn.init.constant_(m.bn3.weight, 0) elif isinstance(m, BasicBlock): nn.init.constant_(m.bn2.weight, 0)
def _make_layer(self, block, planes, blocks, stride=1, dilate=False): norm_layer = self._norm_layer downsample = None previous_dilation = self.dilation if dilate: self.dilation *= stride stride = 1 if stride != 1 or self.inplanes != planes * block.expansion: downsample = nn.Sequential( conv1x1(self.inplanes, planes * block.expansion, stride), norm_layer(planes * block.expansion), )
layers = [] layers.append(block(self.inplanes, planes, stride, downsample, self.groups, self.base_width, previous_dilation, norm_layer)) self.inplanes = planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes, groups=self.groups, base_width=self.base_width, dilation=self.dilation, norm_layer=norm_layer))
return nn.Sequential(*layers)
def _forward_impl(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
def forward(self, x): return self._forward_impl(x)其中關鍵的方法就是_make_layer,這是用來生成殘差塊的。現在我們可以看一下這個downsample了,它的觸發條件是,如果卷積步長不等於1或者輸入通道數不滿足對應的關係,則1*1的卷積運算,調整輸入通道數,而這個downsample會在模塊的第一個殘差塊進行。
這裡實現中,看不出來_forward_impl這個為什麼單獨寫成了也給函數,貌似在forward中實現並無不妥。
對於訓練時,ResNet18執行的大概是:
ResNet(BasicBlock, [2, 2, 2, 2])其中第二個參數是conv2_x到conv5_x這四個部分的殘差塊的數量。
再看一下ResNet50的執行:
ResNet(Bottleneck, [3, 4, 6, 3])其他的都類似了。
另外,Pytorch實現的ResNet還有一些技巧,如:zero_init_residual等。
3. ResNet的變體
ResNet發布已經四五年了,相關的研究很多,Pytorch也實現了兩種變體:
3.1 ResNeXT
這是16年底提出的結構,在Pytorch的實現中,其實只是訓練時的參數不同,增加了分組卷積的參數:
# ResNeXt-101 32x8d modelkwargs['groups'] = 32kwargs['width_per_group'] = 8ResNet(Bottleneck, [3, 4, 23, 3],**kwargs)3.2 Wide ResNet
這是16年中提出的結構,在Pytorch的實現差異也不大:
kwargs['width_per_group'] = 64 * 2ResNet(Bottleneck, [3, 4, 6, 3], **kwargs)這個參數的作用其實就是增加了降維之後的通道數為標準ResNet的兩倍,也就是原來降維為1/4改為降維為1/2。
ResNet還有一些其他的升級版本,待續。