回爐重造:計算圖——深入理解深度學習框架細節(jié)
點擊藍字
?關注我們

前言
相信各位做算法的同學都很熟悉框架的使用,但未必很清楚了解我們跑模型的時候,框架內部在做什么,比如怎么自動求導,反向傳播。這一系列細節(jié)雖然用戶不需要關注,但如果能深入理解,那會對整個框架底層更加熟悉。
從一道算法題開始
有算法基礎的同學,應該都知道迪杰斯特拉的雙棧算術表達式求和這個經典算法。他的原理是利用兩個棧分別存放運算數,操作。根據不同的情況彈出棧里的元素,并進行運算,我們可以具體看下圖

這里討論的是最簡單的情況,我們根據操作符的優(yōu)先級,以及括號的種類(左括號和右括號),分別進行運算,然后得到最終結果。
神經網絡里怎么做?
在神經網絡里,我們把數據和權重都以矩陣運算的形式來計算得到最終的結果。舉個常見的例子,在全連接層中,我們都是使用矩陣乘法matmul來進行運算,形式如下

如圖,一個(2x3)的矩陣W和一個(3x2)的矩陣X運算出來的結果Y1是(2x2) 那么Y可以被表示為
那后續(xù)還有一系列相關操作,比如我們可以假設
這一系列運算,都是我們拿輸入X一層,一層的前向計算,因此這一個過程被稱為前向傳播
神經網絡為了學習調節(jié)參數,那就需要優(yōu)化,我們通過一個損失函數來衡量模型性能,然后使用梯度下降法對模型進行優(yōu)化 原理如下(完整的可以參考我寫的一篇深度學習里的優(yōu)化)

可以看到最后我們能讓loss值變小,這也能代表模型性能得到了優(yōu)化。那既然涉及到了梯度,就需要對里面的元素進行求導了。那么應該對誰求呢, 也就是神經網絡里的權重W1, W2, W3
可以觀察到,要想求各個權重,就需要從最后一層往前逐層推進。求導得到各個權重對應的梯度,這叫后向傳播。那既然算術表達式可以用雙棧來輕松的表達
對于神經網絡里的運算,需要前向傳播和后向傳播,有沒有什么好的數據結構對其進行抽象呢?有的,那就是我們需要說的計算圖
計算圖
我們借用圖的結構就能很好的表示整個前向和后向的過程。形式如下

我們再來看一個更具體的例子

(這幅圖摘自Paddle教程。
比如最后一項計算是
則在反向傳播中 650這一項對應的梯度為1.1 1.1這一項對應的梯度為650 以此類推。
常見的反向傳播
卷積層的反向傳播
這里參考的是知乎一篇 Conv卷積層反向求導 我們寫一個簡單的1通道,3x3大小的卷積
import torchimport torch.nn as nnconv = nn.Conv2d(in_channels=1, out_channels=1, kernel_size=3, padding=0, bias=False, stride=1)inputv = torch.range(1, 16).view(1, 1, 4, 4)print(inputv)out = conv(inputv)print(out)out = out.mean()out.backward()print(conv.weight.grad)
最后得到conv的梯度為
tensor([[[[ 3.5000, 4.5000, 5.5000],[ 7.5000, 8.5000, 9.5000],[11.5000, 12.5000, 13.5000]]]])
我們3x3 的卷積核形式如下

我們的數據為4x4矩陣

這里我們只關注卷積核左上角元素W1的求導過程 在stride=1,pad=0情況下,他的移動過程是這樣的

白色是卷積核每次移動覆蓋的區(qū)域,而藍色區(qū)塊,則是與權重W1經過計算的位置
可以看到W1分別和1, 2, 5, 6這四個數字進行計算 我們最后標準化一下
這就是權重W1對應的梯度,以此類推,我們可以得到9個梯度,分別對應著3x3卷積核每個權重的梯度
卷積層求導的延申
其實卷積操作是可以被優(yōu)化成一個矩陣運算的形式,該方法名為img2col 這里簡單介紹下

藍色部分是我們的卷積核,我們可以攤平成1維向量,這里我們有兩個卷積核,就將2個1維向量進行組合,得到一個核矩陣 同理,我們把輸入特征也攤平,得到輸入特征矩陣
這樣我們就可以將卷積操作,轉變成兩個矩陣相乘,最終得到輸出矩陣。而不需要用for循環(huán)嵌套,極大提升了運算效率。
池化層的反向傳播
池化層本身并不存在參數,但是不存在參數并不意味著不參加反向傳播過程。如果池化層不參加反向傳播過程,那么前面層的傳播也就中斷了。因此池化層需要將梯度傳遞到前面一層,而自身是不需要計算梯度優(yōu)化參數。
import torchimport numpy as npinputv = np.array([[1, 2, 3, 4],[5, 6, 7, 8],[9, 10, 11, 12],[13, 14, 15, 16],])inputv = inputv.astype(np.float)inputv = torch.tensor(inputv,requires_grad=True).float()inputv = inputv.unsqueeze(0)inputv.retain_grad()print(inputv)pool = torch.nn.functional.max_pool2d(inputv, kernel_size=(3, 3), stride=1)print(pool)pool = torch.mean(pool)print(pool)pool.backward()print(inputv.grad)
注意這里我們打印的是input的梯度,因為池化層自身不具備梯度
tensor([[[0.0000, 0.0000, 0.0000, 0.0000],[0.0000, 0.0000, 0.0000, 0.0000],[0.0000, 0.0000, 0.2500, 0.2500],[0.0000, 0.0000, 0.2500, 0.2500]]])
其中最大池化層是這樣做的

可以看到我們有4個元素進行了最大池化,但為了保證傳播過程中,梯度總和不變,所以我們要歸一化
也就是
因此最大元素那四個位置對應的梯度是0.25 在平均池化過程中,操作有些許不一樣,具體可以參考 Pool反向傳播求導細節(jié)
靜態(tài)圖與動態(tài)圖的區(qū)別
靜態(tài)圖
在tf1時代,其運行機制是靜態(tài)圖,也就是符號式編程,tensorflow也是按照上面計算圖的思想,把整個運算邏輯抽象成一張數據流圖

tensorflow提出了一個概念,叫PlaceHolder,即數據占位符。PlaceHolder只是有shape,dtype等基礎信息,沒有實際的數據。在網絡定義好后,需要對其進行編譯。于是網絡就根據每一步驟的placeholder信息進行編譯構圖,構圖過程中檢查是否有維度不匹配等錯誤。待構圖好后,再喂入數據給流圖。靜態(tài)圖只構圖一次,運行效率也會相對較高點。當然現在的各大框架也在努力優(yōu)化動態(tài)圖,縮小兩者之間效率差距。
動態(tài)圖
動態(tài)圖也稱為命令式編程,就像我們寫代碼一樣,寫到哪兒就執(zhí)行到哪兒。Pytorch便屬于這種,它與用戶更加友好,可以隨時在中間打印張量信息,方便我們進行debug。
每一次讀取數據進行計算,它都會重新進行一次構圖,并按照流程執(zhí)行下去。其特性更加適合研究者以及入門小白
兩者區(qū)別
靜態(tài)圖只構圖一次 動態(tài)圖每次運行都重新構圖 靜態(tài)圖能在編譯中做更好的優(yōu)化,但動態(tài)圖的優(yōu)化也在不斷提升中

比如按動態(tài)圖我們先乘后加,形式如左圖。在靜態(tài)圖里我們可以優(yōu)化到同一層級,乘法和加法同時做到
總結
這篇文章講解了計算圖的提出,框架內部常見算子的反向傳播方法,以及動靜態(tài)圖的主要區(qū)別。限于篇幅,沒有講的特別深入,但讀完也基本可以對框架原理有了基本的了解~
推薦閱讀

