前幾天有人問我一個問題,他想把一個自己創建的 tensor(而不是網絡的權重)放進 optimizer,但是 PyTorch 報錯:ValueError: can't optimize a non-leaf Tensor。短話長說,我決定寫一個關於反向傳播的文章,至於前面這個問題,會在中間用一節去解釋(不是解決,用一個 .detach() 就能解決了)。

另外我先要說一點就是(可能是漢語習慣的問題),對於一個網絡,它更靠近最終輸出的部分叫「前面」,英文的 forward、network head 這些就是這個意思,靠近輸入的地方叫「後面」。

Table of Contents

  1. 最基本的反向傳播例子
  2. 全連接层中的反向傳播
    1. 如何簡明地實現
  3. 卷積层中的反向傳播
    1. 簡單例子和代碼
  4. 最開始的問題
  5. 池化层中的反向傳播

最基本的反向傳播例子

我們定義一個 tensor x,然後對其進行一些運算:

1
2
3
4
5
6
7
8
9
import torch

x = torch.ones(2, 2, requires_grad=True)
y = x + 2
z = y * y * 3
out = z.mean()

out.backward()
print(x.grad)

在這裡,x 是一個 2×2 的数組,最終輸出的結果是 tensor([[4.5000, 4.5000],[4.5000, 4.5000]])。我們手動計算一下這個梯度:有 ,且 ,所以 ,此時 x=1,則梯度

這個例子之所以簡單明瞭,是因為前一层中一個元素實際上只和前後一层中的同位置的一個元素相對應,如下所示。

1
2
3
4
5
x1 ─ y1 ─ z1 ┐
x2 ─ y2 ─ z2 ┤
├─ out
x3 ─ y3 ─ z3 ┤
x4 ─ y4 ─ z4 ┘

在進行到下一步之前,先來看一個 PyTorch 的操作。

如果 print(out),結果為 tensor(27., grad_fn=<MeanBackward0>),這裡不僅可以看到它的值,還可以看到它所關聯的梯度函数,這個函数,可以通過 out.grad_fn 訪問,它有一個属性 next_functions。對於 out.grad_fn.next_functions,輸出的結果為 ((<MulBackward0 object at 0x7fa140178850>, 0),),如果看看 z.grad_fn 可以得到 <MulBackward0 object at 0x7fa140178850>。也就是說,next_functions 指向的就是反向傳播網絡中,某一個梯度函数的下一級的梯度函数。

next_functions 返回的第一层 tuple 內是指向的所有的下一級,也就是說,它可能指向多個函数;第二层 tuple 內的第二個元素一般是 0,它是反向函数的輸入值,只有返回多個可微分的值的函数(例如 torch.unbind())才會使它的反向函数的這個輸入值非零。第二层 tuple 內的第一個元素如果是 None,說明它指向了一個常数。

總之,我們可以一层一层去看這個反向傳播網絡,結果如下圖。我們可以看到,PyTorch 自動創建的這個反向網絡是一級一級的,每次只有一步操作,我們寫在一行內的操作也會自動被拆分開來。

backward network automatic built by pytorchbackward network automatic built by pytorch

最末尾的 AccumulateGrad object 有一個 .variable 属性,它指向的就是 x,這個後面還會提到。

全連接层中的反向傳播

對於卷積神經網絡,裡面也不排除包含全連接层。此外,一個具體而簡單的神經網絡例子,通常選擇全連接網絡,或者叫 MLP (Mutli-Layer Perceptron)。為了簡單一般只作三层:輸入层、隱藏层(hidden layer)、輸出层。其中每一個神經元,都與前後层中每個神經元相連,同樣為了簡便,在本文的例子中我們每一层只有兩個神經元,畫出結構圖如下。

a simple MLP networka simple MLP network

這裡可以看到,與上一節不同,神經元之間的連結更複雜了,不再只是平行地連結;但是這裡也只有一次乘法(線性運算),沒有二次的項了。此外,對每一個神經元(圓圈節點),如上圖右側所示(h1 為例),除了權重 w 以外,還有加上一個偏置 b,從 neth1outh1,需要經過一個可微分的激活函数,這裡我們使用 sigmoid 函数,它的表達式和微分為:

現在我們給定輸入值和目標(藍色),以及初始化值的權重(紅色),和初始化的偏置(黃色,假設每一层的偏置都相同,為了簡便),這樣一來,稍有常識的人都可以計算出每個神經元的激活值(綠色),如下圖所示。本文均假定對各種正向過程都已經非常了解。

forward pass of MLPforward pass of MLP

現在我們需要計算每個權重和偏置相對於最終偏差的梯度,損失函数這裡使用 squared error function: ,那麼 ,同理

一般來說,總的損失就是二者之和,可以說是權重相等,都為 1。如果我們看看 PyTorch,我們總是需要在一個標量而不是向量上使用 .backward() 計算整個 graph 的梯度,如果在一個向量上使用會報錯 RuntimeError: grad can be implicitly created only for scalar outputs。對於一個輸出向量的函数 相對於 的梯度是一個雅可比矩陣(Jacobian Matrix):

torch.autograd 用於計算 Vector-Jacobian Product,它不能直接計算整個 Jacobian 矩陣。也就是說,我們需要給定一個向量 v,去計算 。如果這個 v 正好是某一個結果為標量的函数 的梯度,也就是說 ,那麼根據鏈式法則,這個乘積是 相對於 的梯度:

所以如果我們想在一個向量上使用 vector.backward(v),我們需要傳入一個 v = torch.tensor([weight1, weight2, ...]),它表示的就是每一個損失在總損失中的權重(它的參数名為 gradient,也可以看作是這個向量對某一個外部標量的梯度)。如果我們在一個標量上使用 scalar.backward(),那麼無需傳入任何參数。

回到開始的話題,我們現在開始計算每一個權重關於最終損失的梯度,對於輸出层的 w5~w8,以 w5 為例,首先我們有,總損失對於 out o1 的梯度:

總損失的第二項 out o1 沒有關係,所以對其的梯度為 0

out o1 對於 net o1,就是 sigmoid 的梯度,。幾乎所有激活函数是對每一個元素單獨做的運算,不涉及相互影響(即對每一個元素,操作公式都是相同的),所以跟上一節的情況完全相同。梯度通過激活函数時,整個梯度圖的大小是不會改変的。

最終,我們看 net o1 對於 w5,有 ,所以 。所以根據鏈式法則,我們把上述三項相乘,就得到總損失 L 對於 w5 的梯度 0.7414 × 0.1868 × 0.5933 = 0.082

如果只把前兩項相乘,得到 。通常把總損失對某一层(或某一個神經元)未激活之前的結果(即 net)的導数記作 ,例如這裡可記作

計算輸出层的其它權重和偏置也都類似。再看中間的隱藏层,它跟輸出层的區別就是,它的右側與多個神經元相連,所以梯度會來自於右側多條通路,所以

看著很恐怖,其實並不複雜。以第二行加號左邊的三項為例,前兩項 上面已經算過了;第三項仍然有 ,所以 ,由於這都是一次線性的運算,所以對權重求導就得輸入,對輸入求導就得權重;對於第三行,第一項是激活函数,和上面也一樣,最後一項是對權重求導,所以得輸入值 i1

這裡也看到一件事,就是越往後梯度越來越小了,因為乘上小於一的項越來越多,「梯度消失」說的就是這麼回事。

如何簡明地實現

顯然對於每一個權重,我們不應當從損失函数開始一個一個地計算到最後,而是應當先計算一級(輸出层),然後把一些計算結果傳給前一級(隱藏层),然後前一級拿到這個結果只需計算和他直接相連的部分。實際使用上,全連接层也是一個整體,肯定不會一個一個神經元單獨計算。

我們在得到 [, ] 之後,可以作為一個整體傳給後一层,然後做一個矩陣乘法

卷積层中的反向傳播

卷積层自然是卷積神經網絡的關鍵,所以這一部分先會說得比較抽象和泛化。對於卷積层,在運算上與上一節沒有什麼不同,所以我們可以預料對權重求導就得輸入,對輸入求導就得權重還是一樣的。區別在於前後的連結方式,卷積层是稀疏的連接,一個神經元並不會和後一級所有神經元相連,此外,權重會有復用,所以不同的連線,不表示這個連線上的權重不同,如下圖。當卷積核大小等於輸入圖時,卷積层就変為一個全連接层,所以這裡會是上面一節進一步抽象、泛化的反向傳播方式。

forward pass of convforward pass of conv

符號說明:

  1. 卷積核 的尺寸為 表示這個卷積核內通道為 ,位置為 的參数值。

  2. 表示一個卷積的結果,它是由上一层的輸出與卷積核權重相乘加上偏置值得到。輸入特徵圖 (即進行卷積之前的)的尺寸使用 表示, 為特徵圖上的某一個位置。
  1. 為激活函数的輸出值,即 表示某種激活函数。

假設進行的是 padding=0, stride=1 的卷積,那麼正向過程如符號說明內的公式,

這裡的 就是上一節中的 net 层中的元素,比如 net h1net o1。所以這個公式表示的就是直達權重的最後一步,第二項類似於之前的 ,第一項 如前所述是從前方层傳遞過來的,在這裡是個已知值。積分的區域為卷積核上的這個參数觸及的區域。

又有

所以第二項有

繼續進一步展開,卷積核內的每一個權重顯然是獨立的,前一层的輸出 o 顯然也不受這個卷積核的影響,所以展開後,只有一項求偏導不為零

把這個結論代入最開始的公式(1):

這個式子裡面雙重求和就是權重共享的結果,隨著 k1k2 的增大,卷積核的大小逐漸接近於輸入特徵圖,這個求和區域也逐漸減小。當核的大小等於特徵圖大小時,這個式子與上一節中的全連接的形式相同。同時我們對比一下最開始的卷積公式:

就發現這個(gradients of L w.r.t to the weight)求導的公式其實就是個卷積操作,輸入是 ,卷積核是前方层傳來的梯度圖(gradients of L w.r.t to the feature maps),表示特徵圖上一個值的変動,會對損失值造成多大影響。這個梯度圖顯然也不是憑空產生的,我們現在就還要計算它。

對於一個 ,它在 l+1 层中的影響區域是左上角 到右下角 中間的矩形區域。那麼

還是一樣地看第二項,展開裡面的 x

繼續展開兩個求和,在這裡同樣,下一层的權重不會受這一层的特徵圖的影響,一個特徵圖之內的各個值也是獨立的,所以它們的偏導都為零:

把這個結論代回開始的公式(3):

這同樣是一個卷積,只是 i-m, j-n 表示我們需要把卷積核反轉 180 度。這個卷積的輸入是更前面层傳回的 ,卷積核是 l+1 层的權重,而 這個激活函数的導数是個常数,前面也提到了,它是對每個元素單獨做的。

公式(2)和(4)就是反向傳播的核心,前者用於更新參数,後者用於將對總損失的梯度向後傳播,用於後一层的計算。同時,這裡我們看到,卷積求梯度也是由卷積完成,所以有些地方會說反卷積就是用卷積的梯度。

簡單例子和代碼

上述分析可以看出,形式都是類似的,但是有點抽象,這裡我們看一個具體的例子幫助理解。下面的圖是一個卷積的正向過程,這裡我們略去 bias。

foward pass of a simple conv examplefoward pass of a simple conv example

把上面四個式子加在一起,然後分別對四個參数求偏導,可得

我們在之前已經知道,,所以上面的式子可以寫成

如果仔細看看,這個形式,其實就是卷積,卷積的輸入是正向過程相同的輸入,卷積核是從後一层傳過來的梯度圖,如下圖所示。

weight gradient calculation as convweight gradient calculation as conv

現在我們得到了卷積核 W 的每個元素對於最終損失的梯度,我們可以更新這個卷積核的參数了。同樣,下一件事是,得到輸入特徵圖 O 上每個元素關於最終損失的梯度,這樣我們才可以把這個梯度繼續向後傳播。

同樣把四個式子相加,然後求偏導,結果如下:

這看起來顯然也是一種卷積操作,有人把它叫做全卷積(完全的「全」,不是全部的「全」),示意圖在下面,簡單說就是 padding = 1。這裡我們需要用到正向過程中的卷積核,將它旋轉 180 度後使用。

input gradient calculation as full convinput gradient calculation as full conv
full conv operation schematic diagramfull conv operation schematic diagram

這裡有一個簡單的代碼描述上面的過程

最開始的問題

從上面我們可以看出,在反向傳播的過程中,除了損失對參数的梯度是必須要計算以及儲存之外,我們還必須計算一個損失對特徵圖的梯度,並且要把它傳給後一层。

什麼是葉子節點,直觀就是就是不在「莖」上的,後續沒有其它節點的節點,其實是個很常見的概念。在這裡說人話就是直接由用戶創建,而不是從另一個節點計算得到的節點(A leaf Variable is a variable that is at the beginning of the graph)。神經網絡中的參数都是 leaf tensor,而中間的所有特徵圖和輸出都不是 leaf tensor,輸入圖可以是,但是一般沒人計算它的梯度。巧的是問我那個問題就是因為他的優化對象是輸入圖像(可視化、風格遷移這些任務上有時需這樣做)。在 PyTorch 上,如果一個 CPU Tensor 是 requires_grad=True 的,那麼用 .to(device) 將它發送到 GPU 的時候會產生一個關聯操作 grad_fn=<CopyBackwards>,要解決的話可以 .to(device).detach().requires_grad_(True)

為何 optimizer 只能對葉子節點進行梯度下降?因為幾乎所有的深度學習框架,都不會儲存非葉子節點的梯度,它的梯度在傳遞給所有相連的後方层之後就被刪除了,沒有紀錄下梯度自然沒法進行梯度下降。那為何不儲存呢,回到最開始的雅可比矩陣,對於一個 tensor function,假設 H = H′ = W = W′ = 32 and C = C′ = 128,那麼這個雅可比矩陣內的元素数是 H′W′C′HWC ≈ 17 × 10e9 個,需要約 68GB 進行單精度儲存,所以 .backward() 一定是在一個標量上進行(或者說計算的是 Vector-Jacobian Product)。對於一個標量的梯度,矩陣大小就只有 HWC 大小,大約是百 MB 的級別,但是還考慮到 batch size,和各種 skip-connection,這個總大小還是很可觀的。而且現代網絡都是幾百层隨隨便便,絕對沒可能全部儲存下來。

forward network for a DAGforward network for a DAG
backward network for a DAGbackward network for a DAG

如果要判斷一個 tensor 是不是葉子節點,可以使用 tensor.is_leaf,對於葉子節點,它會在反向圖中創建一個 AccumulateGrad object,也就是我們第一節中提到的,表示在這裡累積梯度。

如果需要獲得一個中間值的梯度,可以使用 retain_grad,或者更複雜的可以用 register_hook

這裡再說一個關於 optimizer、grad 和 weight decay 的事。一般來說,weight decay(權值衰減)是說要在 loss 上加上一項,使得網絡的參数小一點,防止過擬合。如果使用 L2 regularization,那麼總的 loss 為:

在 PyTorch 中,weight decay 的參数是傳給 optimizer 的,它是在更新參数時才用這一項,會把某一個參数的梯度 乘以 (1 + weight_decay)。這麼做也是因為參数和參数之間是獨立的,求導之後如下式(係数 2 可以忽略)。

池化层中的反向傳播

眾所周知池化层中是沒有可學習的參数的,所以這裡只看對於特徵圖上的值的梯度怎麼通過。從前面傳來一個 C × H × W 大小的梯度圖,要往後傳一個 C × nH × nW 的梯度圖。

  • 最大值池化,前向過程中會紀錄每一個 bin 中最大值的位置,在反向過程中,梯度會賦給之前紀錄的位置,bin 中的其它位置填 0

  • 平均值池化,梯度會縮小 n × n 倍然後賦給一個 bin 內所有位置,即每個 bin 內梯度相同。


後話:自定義一個網絡层,例如 RoIPooling 這些,主要是要將損失對特徵圖的梯度計算出來然後向後傳出,對於一些有參数的层,還需要計算出對參数的梯度,儲存在 tensor 的 .grad 属性中傳給 optimizer。

我沒想到寫這個畫圖排版之類的居然花了兩天時間,早知如此就不寫了。。(這好像是絕大多說要寫的東西的下場,我去年居然還說要寫一些關於色彩科學計算攝影之類的??對了,我最近好一陣在研究 🎞️,並魔改一個拍立得給中片幅膠片機做後背,真的很好玩)

參考資料:

  1. Deep Learning with PyTorch: A 60 Minute Blitz > Autograd: Automatic Differentiation

  2. A Step by Step Backpropagation Example

  3. Backpropagation In Convolutional Neural Networks

  4. Forward And Backpropagation in Convolutional Neural Network

  5. Back Propagation in Convolutional Neural Networks — Intuition and Code

  6. Manual of MatConvNet: CNNs for MATLAB

  7. 卷积神经網络(CNN)反向传播算法