ResNet網絡
- ResNet原理和實現
- 總結
一、ResNet原理和實現
神經網絡第一次出現在1998年,當時用5層的全連接網絡LetNet實現了手寫數字識別,現在這個模型已經是神經網絡界的“helloworld”,一些能夠構建神經網絡的庫比如TensorFlow、keras等等會把這個模型當成第一個入門例程。后來卷積神經網絡(Convolutional Neural Networks, CNN)一出現就秒殺了全連接神經網絡,用卷積核代替全連接,大大降低了參數個數,網絡因此也能延伸到十幾層到二十幾層,2012年的8層AlexNet獲得ILSVRC 2012比賽冠軍,2014年22層的GooLeNet獲得ILSVRC 2014亞軍,19層VGG獲得亞軍,但層數也只能到這了,因為研究者們發現:隨着網絡層級的不斷增加,模型精度不斷得到提升,而當網絡層級增加到一定的數目以后,訓練精度和測試精度迅速下降,這說明當網絡變得很深以后,深度網絡就變得更加難以訓練了。為什么?
這得從神經網絡的傳播算法開始說起。神經網絡用反向傳播算法通過計算參數的梯度,從而將總誤差E分配到各個待調整的參數w上,以此來指導參數的更新。但如果層數過高(比如二十層),梯度變化值會逐漸衰減,以sigmoid函數為例,由於其求導結果等於f(x) *(1-f(x)),且其值域為(0,1),這意味着導數值小於等於0.25,而每經過一層神經元,都要再上一個偏導數的基礎上乘以這個求導結果,因此每經過一層,誤差就會衰減為原來的至少0.25倍,顯然只需經過十幾層二十幾層,誤差就會變的極小以至於趨近於0,這就是限制網絡深度的問題之一:梯度消失。而在另一種極端情況下,每層計算出的梯度值即使乘以f(x) *(1-f(x))后仍然大於1,梯度值以指數級增加最終溢出,這是梯度爆炸。正是這兩個問題導致梯度變得不穩定,網絡難以訓練了。
梯度消失和梯度爆炸都有一定的緩解方法,比如換成使用ReLU函數作為激活函數,或者是在每層輸入之后添加正則化層。正則化可以使數據分布重新回到標准正態分布,在數學上解釋為“防止輸入分布變動”,一定程度上解決了梯度消失問題,也提高了訓練速度和收斂速度,網絡因此能訓練到幾十層。
但是即使用了正則化等手段,隨着層數加深,但神經網絡在訓練集的准確度仍然會發生飽和甚至精度下降的問題。這個問題無法解釋為過擬合,因為過擬合是在訓練集上的准確率很高,在測試集上要低。而現在是神經網絡在訓練集上的准確率都下降了。研究者把這種現象稱之為網絡退化。
如何解決退化問題?Kaiming He等人做了實驗:假設現有一個淺層網絡已達到了飽和的准確率,這時在它后面再加上幾個恆等映射層(輸入=輸出的層),這樣可以增加網絡的深度,並且誤差不應該增加。而實驗結果不是這樣,這說明現有的神經網絡似乎很難學習恆等映射函數,也就是去擬合y=x。殘差網絡就是為了解決這個問題而誕生的:讓神經網絡具有學習恆等映射的能力。如果神經網絡的某一層被訓練為恆等映射層,那么這層是否存在對結果不會有影響。換句話說,如果神經網絡能學習恆等映射,那么網絡就具有了自行選擇是否需要某層神經的能力。(思路上似乎與dropout層有一定的共性,只不過dropout層更笨一些:隨機使得一定比例的神經元失效來減小過擬合效果。)
那么殘差網絡是如何實現學習恆等映射的能力呢?目標函數是H(x)=x,其中H(x)是神經網絡。如果把目標函數設計為H(x)=F(x) + x,當F(x)=0時就能構成一個恆等映射。轉換一下我們需要學F(x) = H(x) - x。按照論文中給出的圖,具體的殘差層可以這樣設計:(這個圖不夠直觀,下面我會畫一個更直觀的)
舉個例子:輸入 , 經過擬合后的輸出為
,那么殘差就是
;
假設 從
經過兩層卷積層之后變為
,普通網絡模塊相比較於之前的輸出,其變化率為
,而殘差模塊的變化率為
放大變化有助於網絡更加敏感,學得更快。如何放大變化?減去基數x,只讓網絡學習在x上所疊加的波動,也就是殘差。如果把殘差層畫的更清晰一點,應該是這樣的:
通過上圖我們更容易理解,殘差網絡將要學習疊加在輸入x上的波動信息,這也是殘差網絡為什么稱之為殘差網絡:殘差就是波動。將波動與輸入x本身分割開來,這能使得殘差網絡更容易學習與輸入本身無關的、更一般的特征。
這個圖更直觀的表現為什么殘差是“輸入本身無關的、更一般的特征”。普通的網絡只能直接學習橙色虛線(神經網絡的輸出結果),但如果在輸出結果中減去輸入x得到殘差曲線(藍色實線)后再學習,可以預見網絡將更快的收斂,精度也會更高。
回憶前面所說的“學習恆等映射”,在殘差網絡結構中,這個問題就轉換為“學習獲得0輸出”,神經網絡更擅長擬合這類非線性問題。
這部分的實現體現在下面的代碼中,其中考慮了一個細節問題:如果輸入與輸出的通道數相同,則將輸入的x與輸出結果進行相加作為下一層的輸出;如果輸入與輸出的圖像的通道數不同,那么輸入將會經過一個分支層(conv->bn)轉換其通道數,再與輸出相加。
downsample = None # 在前后通道數不一樣的時候,對輸入進行conv->bn來變換通道數,與輸出相加,實現殘差結構 if (stride != 1) or (self.in_channels != out_channels): downsample = nn.Sequential(nn.Conv2d(self.in_channels, out_channels,kernel_size=3, stride=stride, padding=1,bias=False), nn.BatchNorm2d(out_channels))
def forward(self, input): # input->conv1->bn1->relu->conv2->bn2=out->out+input->out x = input out = self.conv1(input) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) # F(x)+ x 部分,當輸入輸出通道數一樣,則直接與輸入相加,不一樣則經過conv->bn來變換通道數再相加。 if self.downsample: x = self.downsample(input) out = out + x # F(x)+ x out = self.relu(out) # 經過relu return out
為什么加x而不是x/2等其他形式?這個問題再何凱明大神的另一篇文章里提到了,如果x前有系數λ會導致梯度里多一項,反向傳播的時候碰上極端情況(all λ>1)會導致梯度爆炸,而(all λ<1)會使得網絡退化為普通網絡,所以只能是1,更詳細的解釋和實驗都在鏈接里。
到這里,殘差網絡的學習就差不多了,我借用兩個大神的代碼實現了18層的殘差網絡,結構圖如下:
最后附上全部代碼(使用Pytorch框架,強烈推薦):

import torch # 1.0.1 import torch.nn as nn import torchvision # 0.2.2 import torchvision.transforms as transforms # 圖像預處理模塊 transform = transforms.Compose([transforms.Pad(4), transforms.RandomHorizontalFlip(), transforms.RandomCrop(32), transforms.ToTensor()]) # CIFAR-10 dataset train_dataset = torchvision.datasets.CIFAR10(root='./data', train=True, transform=transform, download=True) test_dataset = torchvision.datasets.CIFAR10(root='./data', train=False, transform=transforms.ToTensor()) # Data loader train_loader = torch.utils.data.DataLoader(dataset=train_dataset, batch_size=100, shuffle=True) test_loader = torch.utils.data.DataLoader(dataset=test_dataset, batch_size=100, shuffle=False) # 殘差塊 conv->BN->relu->conv->BN class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, downsample=None): super(ResidualBlock, self).__init__() # 繼承父類init() # print("---------------殘差塊開始-------------------------") self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = downsample # print("---------------殘差塊結束-------------------------") def forward(self, input): # input->conv1->bn1->relu->conv2->bn2=out->out+input->out x = input out = self.conv1(input) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) # F(x)+ x 部分,當輸入輸出通道數一樣,則直接與輸入相加,不一樣則經過conv->bn來變換通道數再相加。 if self.downsample: x = self.downsample(input) out = out + x # F(x)+ x out = self.relu(out) # 經過relu return out # ResNet class ResNet(nn.Module): def __init__(self, block, layers, num_classes=10): super(ResNet, self).__init__() self.in_channels = 64 # 輸入通道 self.conv = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn = nn.BatchNorm2d(64) # BN self.relu = nn.ReLU(inplace=True) # relu self.layer1 = self.make_layer(block, 64, layers[0], 1) self.layer2 = self.make_layer(block, 128, layers[1], 2) self.layer3 = self.make_layer(block, 256, layers[2], 2) self.layer4 = self.make_layer(block, 512, layers[3], 2) self.avg_pool = nn.AvgPool2d(4) # 平均池化 self.fc = nn.Linear(512, num_classes) def make_layer(self, block, out_channels, blocks, stride=1): downsample = None # 在前后通道數不一樣的時候,對輸入進行conv->bn來變換通道數,與輸出相加,實現殘差結構 if (stride != 1) or (self.in_channels != out_channels): downsample = nn.Sequential(nn.Conv2d(self.in_channels, out_channels, kernel_size=3, stride=stride, padding=1,bias=False), nn.BatchNorm2d(out_channels)) layers = [] # 聲明list layers.append(block(self.in_channels, out_channels, stride, downsample)) # 構建殘差模塊 # print(self.in_channels, out_channels, stride) self.in_channels = out_channels # 把輸出通道設為輸入通道 for i in range(1, blocks): # 只運行了一次 blocks=2 layers.append(block(out_channels, out_channels)) # 構建16輸入 16輸出的殘差模塊 return nn.Sequential(*layers) # 把網絡層按序添加到模型 def forward(self, input): out = self.conv(input) out = self.bn(out) out = self.relu(out) out = self.layer1(out) out = self.layer2(out) # print(out.shape) out = self.layer3(out) out = self.layer4(out) out = self.avg_pool(out) out = out.view(out.size(0), -1) out = self.fc(out) return out if __name__ == '__main__': device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')# 設置GPU torch.set_num_threads(8) # 設定用於並行化CPU操作的OpenMP線程數 windows環境下必須在main函數下運行,不然報錯 # 0.定義參數 num_epochs = 1 learning_rate = 0.001 # 1.搭建網絡 model = ResNet(ResidualBlock, [2, 2, 2, 2]).to(device) # 模型遷移到GPU運行 # # 查看每層參數 # for name, parameters in model.named_parameters(): # print(name, ':', parameters.size()) # # tensorboard 查看模型結構 # rand_input = torch.rand(100, 3, 32, 32) # with SummaryWriter(comment="network") as w: # w.add_graph(model,rand_input.to(device)) # 2.定義損失和優化器 criterion = nn.CrossEntropyLoss() # 交叉熵損失 optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) # adam優化 # 3.訓練模型 total_step = len(train_loader) # 訓練步長 curr_lr = learning_rate for epoch in range(num_epochs): for i, (images, labels) in enumerate(train_loader): images = images.to(device) labels = labels.to(device) # print(images.shape) # print(labels) # exit() outputs = model(images) # 前向傳播 loss = criterion(outputs, labels) # 計算損失 optimizer.zero_grad() # 梯度清零 loss.backward() # 后向傳播 optimizer.step() # 參數更新 if (i + 1) % 100 == 0: print("Epoch [{}/{}], Step [{}/{}] Loss: {:.4f}" .format(epoch + 1, num_epochs, i + 1, total_step, loss.item())) # 每20周期 學習率下降為原來的三分之一 if (epoch + 1) % 20 == 0: curr_lr /= 3 for param_group in optimizer.param_groups: param_group['lr'] = curr_lr # 4.測試模型 model.eval() # 測試模式 # eval()時,pytorch會自動把BN和DropOut固定住,不會取平均,而是用訓練好的值。 # 不然的話,一旦test的batch_size過小,很容易就會被BN層導致生成圖片顏色失真極大。 with torch.no_grad(): # 無需計算梯度 correct = 0 total = 0 for images, labels in test_loader: images = images.to(device) labels = labels.to(device) outputs = model(images) _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print('Accuracy of the model on the test images: {} %'.format(100 * correct / total)) # 5.保存模型及參數 torch.save(model.state_dict(), 'resnet.ckpt')
代碼里含有tensorboard的相關函數,用於網絡結構的可視化,不需要可以注釋掉。
二、總結
殘差網絡的設計簡潔漂亮,把輸出分解為輸入+殘差,將神經網絡的學習重心全部轉移到殘差上,使得網絡更專注的學習與輸入無關的、更一般的特征。這就是殘差網絡的成功原因。
參考文獻
梯度消失、爆炸 https://blog.csdn.net/u011734144/article/details/80164927
Batch Normalization 批標准化 https://www.cnblogs.com/guoyaohua/p/8724433.html
理解ResNet: https://www.cnblogs.com/alanma/p/6877166.html
https://blog.csdn.net/bryant_meng/article/details/81187434
https://blog.csdn.net/sunqiande88/article/details/80100891
使用TensorFlow、keras、theano、pytorch框架搭建神經網絡:莫煩老師的神經網絡教程