系列博客目錄:Caffe轉Pytorch模型系列教程 概述
目錄
本文用的Caffe網絡模型文件:SfSNet_deploy.prototxt(右鍵另存爲)。這是一個很牛逼的模型,SfSNet_deploy.prototxt的定義也很長,本文就以它(SfSNet)爲例,說明如何把Caffe網絡手動轉換爲Pytorch網絡。轉換後的Pytorch代碼地址:model.py(右鍵另存爲)。
一、可視化Caffe的網絡模型
可視化網絡模型的好處是可以直觀地看到網絡模型,對網絡模型能先有個大概的瞭解,再結合.prototxt文件,就能完整無誤的把Caffe模型轉換爲Pytorch模型。在線網站要比本地可視化直觀準確得多。在後面的實現過程中,請大家結合結合.prototxt和可視化結果來轉換模型,很重要。
1、在線網站
打開網站http://ethereon.github.io/netscope/#/editor之後,把SfSNet_deploy.prototxt的內容粘貼到編輯框裏,然後按Shift+Enter,右邊的框就會出現網絡模型。鼠標移動到右邊的方塊上時,會顯示每層的參數。
2、本地可視化
請參考:https://www.cnblogs.com/denny402/p/5106764.html
二、SfSNet的第一部分
第一部分的網絡結構圖。Caffe模型中所有的操作都定義爲層。
1、網絡名
打開SfSNet_deploy.prototxt中,第一行是:
name : "PS-Net"
指定此網絡的名稱是"PS-Net"。
2、輸入層
#data
layer {
name: "data" # 名稱,使用Caffe的Python接口時會用到這個。
type: "Input" # 類型,爲輸入
top: "data" # “頂”,暫時可以理解爲數據流向。
input_param { shape: { dim: 1 dim: 3 dim: 128 dim: 128 } }
}
此部分代碼的輸入層:shape=(1, 3, 128, 128)。 “#”後面是我添加的註釋,之後也是如此。
3、第一個卷積層conv1
#C64
layer {
name: "conv1" # 層名
type: "Convolution" # 類型:卷積
bottom: "data" # 數據輸入:data
top: "conv1" # 數據輸出:conv1
param { # 權重 weight
name : "c1_w" # 權重名
lr_mult: 1 # 權重學習率
decay_mult: 1 # 衰減係數
}
param { # 偏置 bias
name : "c1_b" # 偏置名
lr_mult: 2 # 偏置學習率
decay_mult: 0 # 衰減係數
}
convolution_param { # 卷積層參數
num_output: 64 # 卷積核個數,對應torch.nn.Conv2d的out_channels
kernel_size: 7 # 卷積核大小
stride: 1 # 步長大小
pad: 3 # padding大小
weight_filler { # 權重初始化器參數
type: "xavier" # 權重初始化器類型
}
}
}
以上就是一個卷積層的定義。Pytorch中,卷積層是:
Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True)
in_channels是輸入卷積層的通道數,也就是data層中的“3”;out_channels對應num_output,也就是卷積核的個數:“64”;其他的就是一一對應。在Pytorch中,conv1就定義爲:
conv1 = nn.Conv2d(3, 64, 7, 1, 3)
4、第一個BN層bn1
下圖是Pytorch中BatchNorm完成的工作,“*”號左邊的部分是使用E[x](均值)和Var[x](方差) 歸一化x,乘以γ加上β是縮放x和平移x。Caffe中的BatchNorm層只完成了歸一化x的工作,歸一化由Scale層完成,所以:BatchNorm(Pytorch)= BatchNorm(Caffe)+ Scale(Caffe)。
SfSNet只是使用了BatchNorm層,沒有使用Scale層。所以使用Pytorch的BatchNorm層來替換Caffe中的層時,需要把nn.BatchNorm參數affine設置爲True,且要把BatchNorm層的weight(相當於γ)設置爲全1,bias(相當於β)設置爲全0。 至於如何設置,將會在下一篇博客給出,本文只關注如何轉換模型。給出bn1的.prototxt定義:
layer {
name: "bn1" # 層名
type: "BatchNorm" # 類型:BatchNorm
bottom: "conv1" # 數據輸入:conv1
top: "conv1" # 數據輸出:conv1
batch_norm_param {
use_global_stats: false # 訓練時爲false,不使用保存的E[x]和Var[x]
}
param {
name : "b1_a"
lr_mult: 0
}
param {
name: "b1_b"
lr_mult: 0
}
param {
name: "b1_c"
lr_mult: 0
}
include {
phase: TRAIN # 訓練時包含此層
}
}
layer {
name: "bn1"
type: "BatchNorm"
bottom: "conv1"
top: "conv1"
batch_norm_param {
use_global_stats: true # 測試模型是使用保存的E[x]和Var[x]
}
param {
name : "b1_a"
lr_mult: 0
}
param {
name : "b1_b"
lr_mult: 0
}
param {
name: "b1_c"
lr_mult: 0
}
include {
phase: TEST # 測試時包含此層
}
}
bn1的輸入和輸出均爲conv1,所以bn1是“In-Place”操作,把修改完的數據又存回了conv1。 可以發現,bn1層被定義了兩次,主要差別是參數use_global_stats和phase。第一個定義是在訓練網絡(phase:TRAIN)時使用,第二個定義是在測試時(phase:TEST)時使用。Caffe的BatchNorm對應Pytorch中的BatchNorm2d,定義如下:
BatchNorm2d(num_features, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
num_features是特徵數量,數值和Conv2d的out_channels相同;eps是ϵ;affine默認爲True,不要修改;track_running_stats=True 保持默認。所以bn1的對應的pytorch定義爲:
bn1 = nn.BatchNorm2d(64)
64對應out_channels。
5、第一個Relu層relu1
layer {
name: "relu1" # 層名
type: "ReLU" # 類型:relu
bottom: "conv1" # 輸入:conv1
top: "conv1" # 輸出:conv1
}
這是一個ReLU層的定義,也是一個in-Place層, 輸入輸出均爲conv1。在Pytorch中,可以直接使用:
torch.nn.functional.relu(conv1)
6、第一部分的完整Pytorch代碼
上面只是簡單的介紹了三個層,實際上只完成了:
這一小部分。但是我不再重複舉例說明,而是直接給出第一部分模型的代碼,相信讀者可以舉一反三,完成第一部分模型的轉換。請讀者先自行完成conv1到conv3的Pytorch代碼,再和下面的比對。
# coding=utf-8
from __future__ import absolute_import, division, print_function
import torch
import torchvision
import pickle as pkl
from torch import nn
import torch.nn.functional as F
class SfSNet(nn.Module): # SfSNet = PS-Net in SfSNet_deploy.prototxt
def __init__(self):
# C64
super(SfSNet, self).__init__()
# TODO 初始化器 xavier
self.conv1 = nn.Conv2d(3, 64, 7, 1, 3)
self.bn1 = nn.BatchNorm2d(64)
# C128
self.conv2 = nn.Conv2d(64, 128, 3, 1, 1)
self.bn2 = nn.BatchNorm2d(128)
# C128 S2
self.conv3 = nn.Conv2d(128, 128, 3, 2, 1)
def forward(self, inputs):
# C64
x = F.relu(self.bn1(self.conv1(inputs)))
# C128
x = F.relu(self.bn2(self.conv2(x)))
# C128 S2
conv3 = self.conv3(x)
二、SfSNet的殘差塊
觀察網絡的可視化結果,可以發現有很多重複的結構(下面框框裏面包着的),SfSNet的作者說它們是殘差塊(ResidualBlock),其實還是有丟丟區別的,不過不影響我們轉換模型。這些重複的結構層數相同,參數相同,就是名字不一樣,因此我把每個框框裏面的結構實現爲一個“ResidualBlock”,以便於重複利用,還能減少出錯。
1、Caffe的Eltwise層
layer {
name: "nsum1" # 層名
type: "Eltwise" # 類型:Eltwise
bottom: "nconv1r" # 輸入1:nconv1r
bottom: "conv3" # 輸入2:conv3
top: "nsum1" # 輸出:nsum1
eltwise_param { # 操作類型
operation: SUM # 求和
}
}
顧名思義,Eltwise層就是逐元素的操作(相加、相減等等)。operation指定了類型是SUM(求和)。所以,此層就是將nconv1r和conv3求和,存到nsum1裏面。
2、實現一個“ResidualBlock”
直接上代碼:
class ResidualBlock(nn.Module):
def __init__(self, in_channel, out_channel):
super(ResidualBlock, self).__init__()
# nbn1/nbn2/.../nbn5 abn1/abn2/.../abn5
self.bn = nn.BatchNorm2d(in_channel)
# nconv1/nconv2/.../nconv5 aconv1/aconv2/.../aconv5
self.conv = nn.Conv2d(in_channel, out_channel, kernel_size=3, stride=1, padding=1)
# nbn1r/nbn2r/.../nbn5r abn1r/abn2r/.../abn5r
self.bnr = nn.BatchNorm2d(out_channel)
# nconv1r/nconv2r/.../nconv5r aconv1r/aconv2r/.../anconv5r
self.convr = nn.Conv2d(out_channel, out_channel, kernel_size=3, stride=1, padding=1)
def forward(self, x):
out = self.conv(F.relu(self.bn(x)))
out = self.convr(F.relu(self.bnr(out)))
# num1/nsum2/.../nsum5 aum1/asum2/.../asum5
out += x
return out
上面就是一個框框內的所有層,都定義在一個ResidualBlock內,並且可以重複使用。Eltwise層使用“+=”替代了,ReLU層使用“F.relu”替代了。接下來就舉個例子,說明ResidualBlock怎麼用:
class SfSNet(nn.Module): # SfSNet = PS-Net in SfSNet_deploy.prototxt
def __init__(self):
# C64
super(SfSNet, self).__init__()
# TODO 初始化器 xavier
self.conv1 = nn.Conv2d(3, 64, 7, 1, 3)
self.bn1 = nn.BatchNorm2d(64)
# C128
self.conv2 = nn.Conv2d(64, 128, 3, 1, 1)
self.bn2 = nn.BatchNorm2d(128)
# C128 S2
self.conv3 = nn.Conv2d(128, 128, 3, 2, 1)
# ------------RESNET for normals------------
# RES1
self.n_res1 = ResidualBlock(128, 128)
# RES2
self.n_res2 = ResidualBlock(128, 128)
def forward(self, inputs):
# C64
x = F.relu(self.bn1(self.conv1(inputs)))
# C128
x = F.relu(self.bn2(self.conv2(x)))
# C128 S2
conv3 = self.conv3(x)
# ------------RESNET for normals------------
# RES1
x = self.n_res1(conv3)
# RES2
x = self.n_res2(x)
return x
三、反捲積層、Concat層、池化層以及全連接層
1、反捲積層
SfSNet中,還用到了反捲積層,分別爲nup6和aup6。
在.prototxt中,一個反捲積層定義爲:
#CD128
layer {
name: "nup6" # 層名
type: "Deconvolution" # 類型:Deconvolution
bottom: "nsum5" # 輸入:nsum5
top: "nup6" # 輸出:nup6
convolution_param { # 卷積參數
kernel_size: 4 # 卷積核大小
stride: 2 # 步長
num_output: 128 # 輸出特徵數
group: 128 # group大小,不理解是啥意思,不過Pytorch中的反捲積層有同名參數,不慌,小場面
pad: 1 # padding大小
weight_filler { # 權重初始化參數
type: "bilinear" # 權重初始化器類型
}
bias_term: false # 使用偏置:false爲不使用
}
param { # 權重學習參數
lr_mult: 0 # 學習率:0
decay_mult: 0 # 學習率衰減率:0
}
}
Pytorch中,反捲積層的定義爲:
ConvTranspose2d(in_channels, out_channels, kernel_size, stride=1, padding=0, output_padding=0, groups=1, bias=True, dilation=1)
in_channels爲輸入通道數,在SfSNet中是128;out_channels爲輸出通道數,也爲128;groups對應.prototxt中的“group: 128 ”;bias要設置爲false,因爲“bias_term: false”,其他參數保持默認,所以,nup6的Pytorch代碼爲:
aup6 = nn.ConvTranspose2d(128, 128, 4, 2, 1, groups=128, bias=False)
2、Concat層
#concat
layer {
name: "lconcat1" # 層名
bottom: "nsum5" # 輸入1:nsum5
bottom: "asum5" # 輸入2:asum5
top: "lconcat1" # 輸出:lconcat1
type: "Concat" # 類型:lconcat1
concat_param { # Concat參數
axis: 1 # 軸:連接(N, C, H, W)第二維,也就是C(channel)。當axis=0是,連接N,也就是一批的數量
}
}
Pytorch裏和Caffe中的Concat層對應的操作是:
torch.cat(tensors, dim=0, out=None) → Tensor
tensors是Tenor組成的列表;dim對應axis;out保持默認。所以,lconcat1對應的Python代碼就是:
lconcat1 = torch.cat((nsum5, asum5), 1)
感覺沒毛病吧?大錯特錯!!!,讓我們再次打開網絡可視化之後的結果:
可以看到,lconcat1的兩個輸入明明是nrelu6r和arelu6r啊,難道是可視化結果錯了?爲啥.prototxt文件中寫成num5和asum5呢?因爲nbn6r/abn6r和nrelu6r/arelu6r都是“In-Place”操作(輸入輸出都是num5/asum5),計算完之後又存回num5/asum5了,所以.prototxt文件寫成num5和asum5,實際應爲nrelu6r/arelu6r處理後的結果。所以,lconcat1的Pytorch代碼應爲:
lconcat1 = torch.cat((nrelu6r, arelu6r), 1)
需要特別注意那種“In-Place”操作,要結合可視化圖和.prototxt文件來完成模型的轉換,免得走彎路啊啊啊啊啊。
3、池化層
layer {
name: "lpool2r" # 層名
type: "Pooling" # 類型:Pooling
bottom: "lconv1" # 輸入:lconv1
top: "lpool2r" # 輸出:lpool2r
pooling_param { # 池化層參數
pool: AVE # 池化類型:AVE,對應AvgPool2d;還有MAX,對應MaxPool2d.
kernel_size: 64 # 池化核大小
}
}
上面就是池化層的定義。在Pytorch中,上述池化層的對應:
AvgPool2d(kernel_size, stride=None, padding=0, ceil_mode=False, count_include_pad=True)
kernel_size是池化核大小;其他參數不做說明。lpool2r對應的Pytorch代碼爲:
lpool2r = nn.AvgPool2d(64)
如果“pool: AVE”裏的“AVE”換成“MAX”,那麼lpool2r對應的Pytorch代碼爲:
lpool2r = nn.MaxPool2d(64)
4、全連接層
layer {
name: "fc_light" # 層明
type: "InnerProduct" # 類型:InnerProduct,也就是全連接層
bottom: "lpool2r" # 輸入:lpool2r
top: "fc_light" # 輸出:fc_light
param { # 權重參數
lr_mult: 1
decay_mult: 1
}
param { # 偏置參數
lr_mult: 2
decay_mult: 0
}
inner_product_param { # 全連接層參數
num_output: 27 # 輸出通道數:27
weight_filler { # 權重初始化器參數
type: "gaussian" # 類型:gaussian
std: 0.005 # 標準差:0.005
}
bias_filler { # 偏置初始化參數
type: "constant" # 類型:constant
value: 1 # 值:1
}
}
}
越寫到後面越不想寫,這篇博客都編輯了五六個小時了(哭,還是堅持寫完吧)。上面就是全連接層fc_light的定義,在Pytorch中,對應的層是:
Linear(in_features, out_features, bias=True)
fc_light對應的Pytorch代碼爲:
fc_light = nn.Linear(128, 27)
等等!!!fc_light前面不是一個lpool2r層嗎?
lpool2r層的輸出形狀是(1, 128, 1, 1),也就是四維啊,全連接層前面應該是一維的啊,所以在Pytorch的模型的forward函數裏,還需要reshape(調整爲[1, 128]) lpool2r的結果:
class SfSNet(nn.Module):
def __init__(self):
...
def forward(self, inputs):
...
x = self.lpool2r(x)
# 調整輸出的shape
x = x.view(-1, 128)
# fc_light
light = self.fc_light(x)
四、總結
- 不懂的多查多看!
- 轉換模型要結合.prototxt和可視化結果。