0. 前言
衆所周知,反向傳播(back propagation)算法 (Rumelhart et al., 1986c),經常簡稱爲backprop,它允許來自代價函數的信息通過網絡向後流動,以便計算梯度。它是神經網絡之所以可以進行學習的最根本因素。在如PyTorch
、Tensorflow
等深度學習框架中,都廣泛的使用了自動微分(Autograd)的機制,主要也就是從哈佛的智能概率系統組(Harvard Intelligent Probabilistic Systems Group)的Autograd的基礎上進行的[1]
。
在PyTorch的1.0dev版發佈之際,我將以ATen後端中對某一卷積層(二維卷積Conv2d
)的weight和bias進行梯度求解、誤差信號求解以及權重更新邏輯進行系統的梳理。
需要注意的是,這篇文章涉及的內容非常多,所以在一些地方可能會有疏忽或者紕漏,如果您發現了,請告知我進行更正,提前感謝一下本文的讀者朋友們。
下面,正式開始PyTorch中的2維卷積層中weight和bias的梯度求解和權重更新的邏輯分析。
1. 提出問題
當你準備開始學習PyTorch後端的2維卷積的weight和bias的更新邏輯時,首先需要避免將時間浪費在無謂的尋找上,最好的方法是去官方論壇詢問或者看看是否之前有人問過類似的問題。
這裏我就找到了一個論壇版主@SimonW 回覆了這個問題 “AutoGrad about the Conv2d”[2]
:
這裏我只看了gpu cuda中的實現。其中有兩個方法是這裏要着重強調的內容,也就是pytorch/aten/src/THCUNN/generic/SpatialConvolutionMM.cu
中的THNN_(SpatialConvolutionMM_updateGradInput)
和
THNN_(SpatialConvolutionMM_accGradParameters)
。
這裏,先簡明扼要的告訴大家這兩個函數的作用:
-
THNN_(SpatialConvolutionMM_updateGradInput)
xxx_updateGradInput的作用(比如當前要進行權值更新的卷積層爲第層)是根據第層的誤差信號(i表示當前Batch中的第i個樣本),求得當前層第層的誤差信號。 -
THNN_(SpatialConvolutionMM_accGradParameters)
xxx_accGradParameters的作用是求得需要更新權重的梯度,對2維卷積層,因爲更新邏輯爲:
所以其輸出爲用於weight更新的和用於bias更新的。
ps:誤差信號以及DNN、CNN的反向傳播以及更新邏輯細節請看下面的第2部分《預備知識》。
2.預備知識
2.1 回顧DNN的反向傳播算法[3]
在學習PyTorch中CNN反向傳播中梯度和權重更新的內容時,在諸如GEMM
、im2col
等計算中的邏輯會讓我覺的非常困惑。
通過分析發現,對於有必要先把DNN和CNN的反向傳播的理論搞的非常清楚,再去閱讀代碼會效果更好更有針對性,以便於我們可以更清晰的理解這部分代碼的設計邏輯。
DNN這個部分就比較簡單了,
① 首先,誤差信號的定義爲:
其中,是神經網絡的損失函數,爲未經過激活函數的值,
爲經過激活函數之後得到的值。
② 因爲最後一層(輸出層)的誤差信號容易求得:
設損失函數爲
那麼最後一層的誤差信號根據前面的定義爲
所以根據鏈式法則,前面任意一層的誤差信號可求得爲(設)
所以,問題的變成:如何求,根據前面的和的定義,可以容易的求得,這裏不再展開了。
③ DNN中的weight和bias更新策略如下:
總結一下
2.2 回顧CNN的反向傳播算法[3]
這部分主要是參考李建平博士的博客,這裏不展開,只說結論:
因爲在DNN中的和的遞推關係在CNN中仍然成立:
只不過,DNN(用於全連接層)和CNN(用於卷積層)的誤差信號求解方式有一些改變
至於爲什麼讓 rot180,這個就需要看參考資料
[3]
,劉博士舉了一個非常生動的例子便於理解。
最後,已知某卷積層的誤差信號,根據下面的方式對卷積核的權重進行更新。
2.3 卷積操作中的矩陣乘法(gemm)[7]
2.3.1 全連接
k 個輸入;
n 個神經元;
每個神經元都會學到一組權值向量,以和輸入進行內積運算;
n 個輸出;
2.3.2 卷積
卷積操作對於高維(多個平面)的輸入,單個卷積核的深度應和輸入的深度(depth)保持一致:
3 維卷積運算執行完畢,得一個 2 維的平面:
注,n 個3維卷積核以得到 n 個 feature maps;
2.3.3 卷積操作中的矩陣乘法
- 按 [kernel_height, kernel_width, kernel_depth] ⇒ 將輸入分成 3 維的 patch,並將其展成一維向量;
- 此時的卷積操作就可轉化爲矩陣乘法:
3. GPU Cuda版的nn.Conv2d
反向傳播梯度更新策略分析
根據第1部分最後的內容,我們這裏對這pytorch/aten/src/THCUNN/generic/SpatialConvolutionMM.cu
中的THNN_(SpatialConvolutionMM_updateGradInput)
和THNN_(SpatialConvolutionMM_accGradParameters)
這兩個文件進行仔細分析,中間涉及到的一些內容,會放在第2部分中。
3.1 THNN_(SpatialConvolutionMM_updateGradInput)
這兩個方法大體類似,這裏重點詳細分析xxx_updateGradInput
方法,剩下的xxx_accGradParameters
就不展開說了。
-
方法定義:
-
部分參數說明:
gradOutput
是由autograd根據輸出來求得的上一層的當前層的誤差信號。
gradInput
是根據col2im_kernel
設計的邏輯來將gradColumns
的權重梯度進行彙總到grad_input
中的操作,也就是存放當前層誤差信號這個結果的地方。
grad_columns
是一個臨時的buffer,爲了效率,緩存weight(權值)的中間結果之用。
input
是輸入卷積層的內容,這裏按標準的二維卷積輸入爲N x C x H x W。
weight
是當前層也就是第層的權重。
ones
沒用,爲了對齊輸入。
kW, kH
是卷積核的寬和高。
dW, dH
是寬和高的步長。
padW和padH
是padding的寬和高。 -
代碼分析:
void THNN_(SpatialConvolutionMM_updateGradInput)(
THCState *state,
THCTensor *input,
THCTensor *gradOutput,
THCTensor *gradInput,
THCTensor *weight,
THCTensor *gradColumns,
THCTensor *ones,
int kW, int kH,
int dW, int dH,
int padW, int padH) {
// 以單張RGB圖像爲例 Batchsizex3xHxW
THCUNN_assertSameGPU(state, 5, input, gradOutput, weight,
gradColumns, gradInput);
THArgCheck(THCTensor_(isContiguous)(state, weight), 4,
"weight tensor has to be contiguous");
// weight的nDimension=2表示1維卷積
// Params
// weight -> size[1] 表示當前的feature map的個數, 這裏爲3
// weight -> size[0] 表示經過conv後, feature map的個數,也就是卷積核的個數
int nInputPlane = weight->nDimension == 2 ? weight->size[1]/(kW*kH) : weight->size[1];
int nOutputPlane = weight->size[0];
int freeWeight = 0;
// 其weight->size[1] = 3,卷積kernel設置爲10, 則weight->size[0]=10
// 卷積核假定都取3x3
// 0:新的feature map數/新channel數量
// 1:舊的feature map數/舊channel數量
// 2:kH 3:kW
// 那麼選取的例子中的weight爲 10 x 3 x 3 x 3
if (weight->nDimension == 4) {
int64_t s1 = weight->size[0];
int64_t s2 = weight->size[1] * weight->size[2] * weight->size[3];
// 構建一個weight
weight = THCTensor_(newWithStorage2d)(state, weight->storage, weight->storageOffset, s1, -1, s2, -1);
freeWeight = 1;
}
// 檢查
THNN_(SpatialConvolutionMM_shapeCheck)
(state, input, gradOutput, weight, NULL, kH, kW, dH, dW, padH, padW);
// 重新構建一個連續的input和gradOutput
input = THCTensor_(newContiguous)(state, input);
gradOutput = THCTensor_(newContiguous)(state, gradOutput);
int batch = 1;
// input的nDimension爲3, 表示輸入的batchsize=1
if (input->nDimension == 3) {
// Force batch
batch = 0;
THCTensor_(resize4d)(state, input, 1, input->size[0], input->size[1], input->size[2]);
THCTensor_(resize4d)(state, gradOutput, 1, gradOutput->size[0], gradOutput->size[1], gradOutput->size[2]);
}
// 圖像輸入和輸出的大小
// 例子中取padW = padH = 0, kW = kH = 3, dW = dH = 3 inputHeight = inputWidth = 9
int64_t inputWidth = input->size[3];
int64_t inputHeight = input->size[2];
int64_t outputWidth = (inputWidth + 2*padW - kW) / dW + 1;
int64_t outputHeight = (inputHeight + 2*padH - kH) / dH + 1;
// Batch size + input planes
int64_t batchSize = input->size[0];
// Resize temporary columns
// 重要:gradColumns 現在變成27 x 9的形式, 表示梯度對應的結構.
THCTensor_(resize2d)(state, gradColumns, nInputPlane*kW*kH, outputHeight*outputWidth);
...
// Helpers
// 沒找到THCTensor_(new)對應的內容,
// 其含義應該是創建了兩個新的Tensor, 分別名爲gradInput_n和gradOutput_n
THCTensor *gradInput_n = THCTensor_(new)(state);
THCTensor *gradOutput_n = THCTensor_(new)(state);
// For each elt in batch, do:
for (int elt = 0; elt < batchSize; elt ++) {
// Matrix mulitply per sample(每個樣本都進行矩陣乘法):
THCTensor_(select)(state, gradInput_n, gradInput, 0, elt);
// 大膽推測, gradOutput在傳入THNN_(SpatialConvolutionMM_updateGradInput)時,
// 應該是空的.
THCTensor_(select)(state, gradOutput_n, gradOutput, 0, elt);
// M,N,K are dims of matrix A and B
// (see http://docs.nvidia.com/cuda/cublas/#cublas-lt-t-gt-gemm)
int64_t m = nInputPlane*kW*kH; // 以上面的例子來看, m = 3x3x3 = 27
int64_t n = gradColumns->size[1]; // 按照THCTensor_(resize2d)(..., gradColumns, ...)來看, n = outputHeight*outputWidth = 3 x 3 = 9
int64_t k = nOutputPlane; // k = 10
// Do GEMM (note: this is a bit confusing because gemm assumes column-major matrices)
// 列優先矩陣, 比如matlab就是列優先(column-major),也就是說存儲一個M*N矩陣,訪問順序爲第1列,第2列…第N列。
// FLOAT——> THCudaBlas_Sgemm
// HALF ——> THCudaBlas_Hgemm
// DOUBLE ——> THCudaBlas_Dgemm
// gradColumns = 1 x op(gradOutput_n) x op(weight)
#ifdef THC_REAL_IS_FLOAT
THCudaBlas_Sgemm(
#elif defined(THC_REAL_IS_HALF)
THCudaBlas_Hgemm(
#elif defined(THC_REAL_IS_DOUBLE)
THCudaBlas_Dgemm(
#endif
state,
'n', 't',
n, m, k,
ScalarConvert<int, real>::to(1),
THCTensor_(data)(state, gradOutput_n), n,
THCTensor_(data)(state, weight), m,
ScalarConvert<int, real>::to(0),
THCTensor_(data)(state, gradColumns), n
);
// Unpack columns back into input:
// col2im 已經在筆記上進行了一點說明, 在PyTorch新版中, caffe2和Aten都有相應的實現
// caffe2的在caffe2/operators/im2col_op.cc
// aten的在aten/src/THCUNN/generic/Col2Im.cu(CUDA) & aten/src/THNN/generic/Col2Im.c(C)
// CUDA版的col2im定義在 pytorch/aten/src/THCUNN/im2col.h 的末尾
col2im<real, accreal>(
THCState_getCurrentStream(state),
THCTensor_(data)(state, gradColumns),
nInputPlane, inputHeight, inputWidth, outputHeight, outputWidth, kH, kW, padH, padW, dH, dW,
1, 1, THCTensor_(data)(state, gradInput_n)
);
}
...
代碼的註釋在裏面,一些我認爲不重要的地方已經忽略,看到這裏,大家估計會有很多的問號?這到底啥玩意啊?
下面將對其中涉及到的一些重點進行更細的分析,這裏我們需要以用具體的數值爲例進行描述,以便於讀者更直觀的理解。
- 輸入量化(對裏面的參數用形象的數值替換,便於理解。)
輸入爲3通道的RGB圖像 channel = nInputPlane = 3
輸入圖片尺寸Inputsize = 9 x 9
channel = 3
卷積核kW = kH = 3
padW = padH = 0
dW =dH = 3
輸出通道爲nOutputPlane =10
-
Q1:
THCTensor_(resize4d)
和THCTensor_(resize2d)
有啥用?
Answer:如下圖,resize4d把gradInput變成N x C x H x W結構的形式,這裏面把batchsize變成1,相當於做了一個unsqueeze的操作。THCTensor_(resize4d)(state, gradInput, batchSize, nInputPlane, inputHeight, inputWidth);
resize2d將gradColumns 變成27 x 9的形式.
THCTensor_(resize2d)(state, gradColumns, nInputPlane*kW*kH, outputHeight*outputWidth);
-
Q2. Helpers作用?
THCTensor *gradInput_n
和THCTensor *gradOutput_n
的作用?
Answer:沒找到THCTensor_(new)對應的內容, 其含義是創建了兩個新的Tensor, 分別名爲gradInput_n
和gradOutput_n
。用於進行後續的操作,即對每個batch,是對其中的每個樣本進行逐個串行計算的,也就是gradInput_n
是gradInput
中的其中1個樣本對應的內容,比如gradInput
爲64 x 32 x 3,第1維表示batchsize,那麼gradInput_n
就是 1 x 32 x 3,gradOutput_n
同理。
// For each elt in batch, do:
for (int elt = 0; elt < batchSize; elt ++) {
// Matrix mulitply per sample(每個樣本都進行矩陣乘法):
THCTensor_(select)(state, gradInput_n, gradInput, 0, elt);
// gradOutput在傳入THNN_(SpatialConvolutionMM_updateGradInput)時, 爲空.
THCTensor_(select)(state, gradOutput_n, gradOutput, 0, elt);
...
}
這部分代碼的作用是:將gradInput
的第elt個樣本提出來,放到gradInput_n
中,對gradOutput
同理。
- Q3.
gradColumns
作用?
Answer: 保存彙總權重梯度中間結果的矩陣,根據Q4中描述的Sgemm
等廣義矩陣乘積操作定義的,已知權重的值(weight)和權重的梯度(gradOutput_n)容易得知其用處。
在本例中,被resize爲27 x 9的形式, 表示梯度對應的結構。THCTensor_(resize2d)(state, gradColumns, nInputPlane*kW*kH, outputHeight*outputWidth);
- Q4.
GEMM
計算
Answer: GEMM是廣義矩陣乘積操作的簡稱[4]
,可以簡單理解爲將卷積操作變成矩陣乘法,形式如下:
GEMM在深度學習中發揮了十分重要的作用,全連接層以及卷積層基本上都是通過GEMM來實現的,而網絡中大約90%的運算都是在這兩層中。而一個良好的GEMM的實現可以充分利用系統的多級存儲結構和程序執行的局部性來充分加速運算。
其接口如圖[4]
:
在PyTorch中,接口跟這個類似,不同之處在於加了一個參數(THCState *state),
容易看出ScalarConvert<int, real>::to(1)和ScalarConvert<int, real>::to(0)分別表示sgemm中的
ALPHA和BETA。
其中,ScalarConvert結構體定義在pytorch/aten/src/THC/THCNumerics.cuh
中
補充說明,在CUDA編程中,當函數前綴中使用__host__ __device__時,表示對應的函數將會被編譯爲兩個版本,分別可以由CPU和GPU線程調用
[6]
。
接着回來說GEMM,我們需要知道里面的這些參數的定義才能更好的理解在PyTorch中調用此庫的邏輯:
以SGEMM爲例(SGEMM的代碼是1989年2月8號寫的,遠古代碼…,此外sgemm中的s表示是單精度的運算,類似的,還有dgemm,表示雙精度的運算。),它是用於實現矩陣-矩陣運算的廣義矩陣運算,根據其參數,計算邏輯是:
其中,op(X)可能是op(X) = X, op(X) = X**T(轉置)的兩種中的一種。
alpha和beta都是標量,A, B, C都是矩陣,其中A爲m x k
的矩陣,B爲 k x n
的矩陣, C爲 m x n
的矩陣。
M
表示op(A)和矩陣C的行數(Rows)
N
表示op(B)和矩陣C的列數(Columns)
K
表示op(A)的列數和op(B)的行數(Rows)
在convNd
的函數中,SGEMM/DGEMM等使用的beta = 0, alpha = 1,也就是說:
下面將其對應到THNN_(SpatialConvolutionMM_updateGradInput)
中使用的SGemm
:
其所採用的TRANSA = ‘n’, TRANSB = ‘t’。
根據文檔[5]
定義,當TRANSA = ‘N’ or ‘n’, op( A ) = A. TRANSA = ‘T’ or ‘t’, op( A ) = A**T(A的轉置). TRANSA = ‘C’ or ‘c’, op( A ) = A**T.,所以op(A) = A, op(B) = B**T。
接着,LDA,LDB和LDC都是integer,表示矩陣A、B、C的第一維度的大小。TRANSA = ‘N’ or ‘n’ then
LDA must be at least max( 1, m ), otherwise LDA must be at least max( 1, k ). 對本例中的A,因爲TRANSA = ‘n’,所以LDA= max(1, m)
,對B,因爲TRANSB = ‘t’,所以LDB = max(1, n)
。
最後,需要對C進行一下說明:C就是 M X N
的矩陣。因爲LDC = max(1, m)
(沒有TRANSC這個東西存在),在exit的時候
C會被 的矩陣overwritten。
根據SGEMM中的設置,得知本例中:LDA = n,LDB = m, LDC = n ,對應的
也就是說A = op (A)爲n x k,B = op(B)^T爲k x m,C爲n x m。根據代碼定義,有:
m = nInputPlane × kW x kH = 3 x 3 x 3 = 27
n = gradColumns->size[1] 爲9
k = 10(nOutputPlane,我這裏設的是10。)
A = THCTensor_(data)(state, gradOutput_n)
B = THCTensor_(data)(state, weight)
C = THCTensor_(data)(state, gradColumns)
聯想第2.1和2.2節的內容,可以知道這裏的gradColumns
就是根據當前層的誤差信號 = gradOutput_n
和 當前層的權重weight = weight
求得(根據下式)。
- Q5.
col2im
計算是什麼?
Answer:col2im在第2.3節[7]
稍微提到了一下:
作用是將1xCxHxW的輸入圖像,根據卷積核的情況拆分爲一個個Patch(圖中的例子取kW = kH,stride = kW),這樣就跟實際的卷積核展開後的維度互爲轉置,可以容易的用向量乘法進行計算了。
代碼如下(這裏有個小驚喜,修改了pytorch/aten/src/THCUNN/im2col.h
的一個問題,已被merge了,哈哈)
// Unpack columns back into input:
// col2im在PyTorch新版中, caffe2和Aten都有相應的實現
// caffe2的在caffe2/operators/im2col_op.cc
// aten的在aten/src/THCUNN/generic/Col2Im.cu(CUDA) & aten/src/THNN/generic/Col2Im.c(C)
// CUDA版的col2im定義在 pytorch/aten/src/THCUNN/im2col.h 的末尾
col2im<real, accreal>(
THCState_getCurrentStream(state),
THCTensor_(data)(state, gradColumns),
nInputPlane, inputHeight, inputWidth, outputHeight, outputWidth, kH, kW, padH, padW, dH, dW,
1, 1, THCTensor_(data)(state, gradInput_n)
);
}
我們這裏看pytorch/aten/src/THCUNN/im2col.h
這裏先不關注最下面的col2im_kernel的計算邏輯。對比col2im<real, accreal>中傳入的參數,這裏容易發現:
col2im中的data_col
就是gradColumns
這個指針;
channels
就是nInputPlane
(比如以分析的例子來講,一個圖片爲3通道的RGB圖像,那麼其nInputPlane就是3,輸入的長度和寬度都是9,kW和kH都是3);
dilation_h
, dilation_w
取得都是1,如果設爲更大的值,效果可見參考資料[8]
中的dilation設置大於1的情況的示意圖;data_im
是gradInput_n
。
綜上,因爲data_col
也就是gradColumns
其實是保存權重梯度的矩陣。那麼data_im
就是根據col2im_kernel設計的邏輯來將這些權重梯度進行彙總的操作。
- 驗證
爲了驗證自己的思路,我去論壇問了一下,熱心的版主alban D回覆了我[9]
,可以看出,我的分析是沒錯的哈哈。
3.2 THNN_(SpatialConvolutionMM_accGradParameters)
-
方法定義:
-
部分參數說明:
gradOutput
是由THNN_(SpatialConvolutionMM_updateGradInput)
計算得到的誤差信號。
gradWeight
和gradBias
根據方法THNN_(SpatialConvolutionMM_accGradParameters)
的情況,可以看出它是scale*op(columns)*op(gradOutput_n)
(gradOutput_n是從gradOutput(共有batchsize個gradOutput_n)中提取出來的一個個slice。)
input
是由autograd返回的,用於在下面的權值更新中發揮作用。
columns
跟方法THNN_(SpatialConvolutionMM_updateGradInput)
中的grad_columns
作用類似,爲了提高效率,緩存weight(權值)的中間結果之用。
ones
方法THNN_(SpatialConvolutionMM_updateGradInput)
中的grad_columns
作用也類似,爲了提高效率,緩存bias(偏置)的中間結果之用。
scale_
這個是權值的學習率,相當於下面中的α
-
總結:
此方法就不詳細展開了,具體結構跟方法THNN_(SpatialConvolutionMM_updateGradInput)
類似,其結果是返回gradWeight
和gradBias
(如果有必要),然後PyTorch就據此,來更新卷積層的Weight(權重)和Bias(偏置)。
4. 總結
本文仔細的說明了在PyTorch 0.4.1的ATen後端,對於nn.Conv2d
的操作進行權重更新的策略進行了詳細分析。其中用到的autograd機制,從概念上講,因爲任意維度的張量Back Propagation和向量的Back Propagation完全相同,唯一的區別是如何將數字排列成網格以形成張量。
所以在計算中使用到的GEMM
和im2col
就是用於將誤差信號
、weight/bias的梯度
gradWeight, gradBias的位置進行正確排列用於輸出的計算與組合步驟。
在PyTorch後面的章程中,會更新PyTorch Autograd的原理說明,以及此框架跟Symbol 2 Symbol類的框架(Theano
和Tensorflow
)的區別等內容,歡迎大家提出建議和意見。
最後,Thanks for reading!
參考資料
[1] HIPS/autograd(哈佛HIPS組發佈的autograd)
[2] PyTorch Forum——AutoGrad about the Conv2d
[3] 劉建平——《卷積神經網絡(CNN)反向傳播算法》
[4] NoneLand——《深度學習中GEMM的前世今生》
[5] sgemm官方文檔說明
[6] CUDA 函數前綴與存儲器前綴討論
[7] 卷積操作中的矩陣乘法(gemm)—— 爲什麼矩陣乘法是深度學習的核心所在
[8] CNN概念之上採樣,反捲積,Unpooling概念解釋
[9] Pytorch Forum——Confused about autograd in Conv2d