翻譯原文:https://blog.paperspace.com/pytorch-101-understanding-graphs-and-automatic-differentiation/
說在前面:這篇文章是Ayoosh Kathuria關於PyTorch教程的系列文章,非常喜歡他的系列教程,講的很詳細很有啓發。因此把原文的系列教程翻譯了下來,並結合了自己的部分理解。因爲本人能力有限,難免和原文表達的含義有所出入,僅僅作爲交流使用。
PyTorch 101,Part1:計算圖的理解、自動微分和Autograd模塊
PyTorch是最重要的深度學習庫之一。它是深度學習研究不錯的選擇,並且隨着時間的推移,越來越多的公司和研究實驗室都在採用這個庫。
在這個系列教程中,我會向你們介紹PyTorch、如何充分利用這個庫以及圍繞着它所構建的工具生態系統。我們首先會涉及基本的構造模塊,然後教你如何快速的構造定製的架構。最後我們會用兩篇文章總結如何去擴展你的代碼,並且如何去調試出錯的代碼。
這是我們PyTorch 101系列文章的第一部分。
你可以在GitHub倉庫這裏得到這篇文章(以及其他文章)的所有代碼。
目錄
PyTorch 101,Part1:計算圖的理解、自動微分和Autograd模塊
7 PyTorch的圖和TensorFlow的圖有什麼不同?
1 前沿知識
- 鏈式求導法則
- 基本瞭解深度學習
- PyTorch 1.0
2 自動微分
很多PyTorch的系列文章都是以討論什麼是網絡的基本架構開始的。但是,我喜歡先討論一下自動微分。
自動微分不僅是PyTorch,而且還是每一個深度學習庫的一個構造塊。在我看來,PyTorch的自動微分機制叫做Autograd,它是一個很棒的工具來理解自動微分是如何工作的。這不僅有助於你更好的理解PyTorch,而且更好的理解其他深度學習庫。
現在的神經網絡架構有上百萬個可以學習的參數。從計算的角度來說,訓練一個神經網絡由兩個階段組成:
- 前向傳播去計算損失函數
- 反向傳播去計算可學習參數的梯度
前向傳播非常的直接。一層的輸出就是另一層的輸入,依次類推。反向傳播稍微有點複雜,因爲它需要我們使用鏈式求導法則去計算權重相對於損失函數的梯度。
3 一個簡單例子
我們舉一個非常簡單的神經網絡的例子,它有5個神經元。我們的神經網絡就像下面這樣。
下面的等式描述了我們的神經網絡:
讓我們來計算一下每一個可學習的參數w的梯度:
所有的這些梯度都已經使用鏈式法則求得了。注意,上面所有的等式右邊的單獨的梯度都可以直接得到,因爲梯度的分子是分母的顯函數。
4 計算圖
因爲上面的例子非常簡單,所以我們可以手算一下網絡的梯度。設想一下,如果現在你有一個152層網絡,或者,如果這個網絡有多個分支,手算可能就會比較麻煩了。當我們設計軟件去實現神經網絡的時候,我們希望有一個方法,不管網絡結構是什麼類型的,我們都可以無縫銜接地去計算梯度。所以,當網絡發生變化的時候,程序員不需要去手動計算梯度,只需要通過軟件來實現。
我們用計算圖的數據結構來實現這個想法。計算圖和我們在上面製作的示意圖非常的相似。但是,計算圖中的結點表示的是基本的運算。除了需要表示用戶自定義的變量外,這些結點基本上是數學運算符(加減乘除)。
注意爲了表述清晰,在圖中我們已經標記了葉子變量a,w1,w2,w3,w4。但是需要注意的是,他們不是計算圖中的一部分。在我們的圖中,他們代表一個用戶自定義變量的特殊情況,我們僅僅作爲一個例外(圖中藍色的部分是結點,紫色的是葉子結點,葉子結點並不是計算圖的一部分)。
變量b,c和d作爲數學運算的結果,然而a,w1,w2,w3和w4由用戶自己初始化的。因爲他們不能由數學運算來創建,和他們相關的創建的結點由他們自己的名字表示,圖中所有的葉子結點都是這樣。
5 計算梯度
現在,我準備講一下,如何使用一個計算圖去計算梯度。
除了葉子結點,計算圖中的每個結點都可以看做是一個函數,它接收一個輸入然後計算一個輸出。假設圖的結點,它可以從w4c和w3b中計算得到變量d。因此,我們可以這樣寫:
上面的函數可以使用計算圖來表示:
現在,我們可以輕鬆地計算出f相對於它的輸入的梯度。𝜕𝑓/(𝜕𝑤3𝑏)和𝜕𝑓/(𝜕𝑤4𝑐)(它們都是1)現在,用它們相對的梯度給輸入結點的各個邊貼上標籤,像下面圖片一樣:
我們給整張圖貼上標籤。這張圖看起來是這樣的:
接下來,我們描述計算圖中所有結點相對於損失L的導數的算法。假設我們想要去計算導數𝜕𝑓/(𝜕𝑤4):
- 我們首先向後回溯從d到w4的所有可能的路徑
- 在這裏只有一個路徑
- 我們沿着路徑乘以經過所有的邊
你看,這個乘積就是和我們使用鏈式求導法則得到的一樣。如果有不止一條路徑從L到一個變量,然後,我們沿着每一個路徑乘以邊,接着把它們加在一起。舉例來說,𝜕𝐿/𝜕𝑎就是:
6 PyTorch Autograd
現在,我們已經理解什麼是計算圖了,我們回到PyTorch,理解在PyTorch中上面的鏈式求導法則是如何實現的:
6.1 Tensor
Tensor是一個數據結構,它是PyTorch的一個基本的構造塊。Tensor和numpy arrays非常像,除了Tensors可以充分利用GPU的平行計算能力,這點和numpy不一樣,Tensor其他的語法和numpy arrays非常類似。
In [1]: import torch
In [2]: tsr = torch.Tensor(3,5)
In [3]: tsr
Out[3]:
tensor([[ 0.0000e+00, 0.0000e+00, 8.4452e-29, -1.0842e-19, 1.2413e-35],
[ 1.4013e-45, 1.2416e-35, 1.4013e-45, 2.3331e-35, 1.4013e-45],
[ 1.0108e-36, 1.4013e-45, 8.3641e-37, 1.4013e-45, 1.0040e-36]])
Tensor它自己就像一個numpy ndarray。這個數據結構可以讓你更快速的進行線性運算。如果你想讓PyTorch去創建和這些運算符相關的圖,你要設置Tensor的requires_grad的屬性爲True,這樣纔會計算這個Tensor的梯度。
在這裏,這個API可能稍微有點讓人匪夷所思。在PyTorch中,有很多方法去初始化一個Tensors。雖然你可以使用一些方法,在它的構造體中顯示定義requires_grad,但是,其他方法要求你在創建Tensor之後,手動去設置:
>> t1 = torch.randn((3,3), requires_grad = True)
>> t2 = torch.FloatTensor(3,3) # No way to specify requires_grad while initiating
>> t2.requires_grad = True
requires_grad是可傳遞的。這意味着當一個Tensor由其他Tensors運算得到的時候,假設這些進行運算的Tensors中至少有一個的requires_grad爲True,那麼結果Tensor的requires_grad就會置爲True。
每個Tensor都有一個叫做grad_fn的屬性,它指的是變量的數學運算。如果requires_grad置爲了False,那麼grad_fn也會是None。
在我們的例子中,d的梯度函數是一個加法運算,因爲f將它的輸入都加在一起了。注意,加法運算也是我們圖中的結點,它的輸出是d。如果我們的Tensor是一個葉子結點(由用戶初始化),那麼grad_fn也是None。
import torch
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a
c = w2*a
d = w3*b + w4*c
L = 10 - d
print("The grad fn for a is", a.grad_fn)
print("The grad fn for d is", d.grad_fn)
如果運行上面的代碼,你就會得到下面的輸出:
# 因爲a和w1,w2都是有用戶直接指定,他們是葉子(leaf node),因此不存在grad_fn
The grad fn for a is None
# d是在前向傳播中是通過加法運算得到的,因此有grad_fn
The grad fn for d is <AddBackward0 object at 0x1033afe48>
通過上面的例子,我們可以得到兩個信息:
- 葉子結點的requires_grad爲True,但是不存在grad_fn
- 結點的requires_grad爲True,並且存在grad_fn
我們可以使用成員函數is_leaf來判斷一個變量是否是葉子Tensor,使用data.requires_grad來判斷一個張量是否可以進行梯度計算。
6.2 函數
在PyTorch中,所有的數學運算都需要實現torch.nn.Autograd.Function類。這個類有兩個我們需要關注的成員函數。
第一個是forward函數,這個函數使用輸入簡單地計算輸出。
這個backward函數接收它前面部分網絡的梯度。正如你看到的,從函數f反向傳播來的梯度,基本上是從它前面的層到f的反向傳播的梯度乘以以f爲輸出的相對於它的輸入的局部梯度。也正是backward函數在做的事情。
讓我們再次思考我們的例子:
- d是我們的Tensor。它的grad_fn是<ThAddBackward>。這是一個基本的加法運算,因爲構造d的函數,將輸入都加在了一起。
- 它的grad_fn的forward函數接收了輸入w3b和w4c,並且把它們加在一起,這個值基本地保存在d中。
- <ThAddBackward>的backward函數接收從前層傳入的梯度作爲它的輸入。這個就是從L到d沿着邊的主要的𝜕𝐿/𝜕𝑑。這個梯度也是L相對於d的梯度,而且它保存在d的grad屬性中。可以使用d.grad來得到。(每個結點中保存着它相對於L的梯度,可以使用d.grad來獲得當前的梯度值)
- 它與局部梯度𝜕𝑑 / (𝜕𝑤4𝑐)和𝜕𝑑 / (𝜕𝑤3𝑏)進行相乘。
- 然後,反向傳播分別用局部計算的梯度乘以輸入梯度,然後通過激活輸入的grad_fn的反向傳播方法,把梯度“送”到它的輸入。
- 舉個例子來說,和d相關的反向傳播函數的<ThAddBackward>w4*c的grad_fn的反向傳播函數(這裏,w4*c是中間Tensor,它的grad_fn是<ThMulBackward>)。在調用backward函數的同時,梯度(𝜕𝐿 / 𝜕𝑑)∗(𝜕𝑑 / 𝜕𝑤4𝑐)作爲輸入傳遞。
- 現在,對於變量w4∗c,(𝜕𝐿 / 𝜕𝑑)∗(𝜕𝑑 / 𝜕𝑤4𝑐)變爲了傳入的梯度,就像步驟3中𝜕𝐿𝜕𝑑傳給d那樣,然後循環整個過程。
在算法上,這裏是反向傳播在計算圖中是怎樣進行計算的。
def backward (incoming_gradients):
self.Tensor.grad = incoming_gradients
for inp in self.inputs:
if inp.grad_fn is not None:
new_incoming_gradients = //
incoming_gradient * local_grad(self.Tensor, inp)
inp.grad_fn.backward(new_incoming_gradients)
else:
pass
這裏,self.Tensor是由Autograd.Function創建的基本的Tensor,在我們的例子中是d。
輸入梯度和局部梯度在上面都已經定義了。
爲了在我們的神經網絡中計算微分,我們一般會對錶示我們損失的Tensor,調用backward函數。然後,我們從代表我們損失的grad_fn的結點開始,反向傳播整個圖。
就像上面所描述的那樣,但我們回溯的時候,這個backward函數就會穿過整個圖遞歸地調用。一旦我們到達了葉子結點,因爲grad_fn是None,但是,我們僅僅暫停這個路徑上的回溯。
在這裏需要注意的一件事,如果我們在一個矢量的Tensor上調用backward()函數,PyTorch會報錯。這意味着你只能夠在標量Tensor上調用backward函數。在我們的例子中,如果我們假設a是一個矢量Tensor,然後我們在L上調用backward函數,它將會報錯。
import torch
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a
c = w2*a
d = w3*b + w4*c
L = (10 - d)
L.backward()
運行上面的代碼將會導致下面的錯誤:
RuntimeError: grad can be implicitly created only for scalar outputs
這是因爲根據定義,梯度可以相對於標量值計算得到。你不能確切地求一個向量相對於另一個向量的微分。這種情況的數學名詞稱之爲雅克比矩陣,但是這超出了這篇文章的範圍,我們暫且不討論。
這裏有兩個方法來解決這個問題。
(1)第一個方法:如果你只是想稍微改變一下上面的代碼,只需要將設置L爲所有誤差的和,我們的問題就解決了。
import torch
a = torch.randn((3,3), requires_grad = True)
w1 = torch.randn((3,3), requires_grad = True)
w2 = torch.randn((3,3), requires_grad = True)
w3 = torch.randn((3,3), requires_grad = True)
w4 = torch.randn((3,3), requires_grad = True)
b = w1*a
c = w2*a
d = w3*b + w4*c
# Replace L = (10 - d) by
L = (10 -d).sum()
L.backward()
一旦這樣做了,你可以通過調用Tensor的屬性grad來獲取梯度。
(2) 第二個方法:因爲某些原因,你不得不去在一個矢量函數中調用backward,你可以傳入向你正在調用反向傳播的張量的形狀大小torch.ones
# Replace L.backward() with
L.backward(torch.ones(L.shape))
需要注意,backward是如何使用輸入梯度作爲它的輸出的。上面這些讓backward以爲輸入梯度的尺寸和L的尺寸一樣,而且能夠進行反向傳播。
使用這種方式,我們可以讓每個Tensor都有梯度,並且我們可以使用我們選擇的優化算法去更新它們。
w1 = w1 - learning_rate * w1.grad
以此類推
7 PyTorch的圖和TensorFlow的圖有什麼不同?
PyTorch創建一個稱之爲動態計算圖的東西,它意味着圖是動態生成的。
在調用一個變量的forward函數之前,在圖中是不存在Tensor結點的。
a = torch.randn((3,3), requires_grad = True) #No graph yet, as a is a leaf
w1 = torch.randn((3,3), requires_grad = True) #Same logic as above
b = w1*a #Graph with node `mulBackward` is created.
這個圖是很多激活Tensors調用foward函數的結果。只有這樣,非葉子節點的緩存器纔會爲圖和中間值(稍後用來計算梯度)分配空間。當你調用backward函數的時候,梯度也計算出來了,這些緩存器(對於非葉子結點變量)被釋放,然後這個圖被殺死了(從某種意義上來說,你不能用它來進行反向傳播,因爲保存數值去計算梯度的緩存器已經消失了),在 PyTorch 的計算圖中,只有葉子結點的變量會保留梯度。而所有中間變量的梯度只被用於反向傳播,一旦完成反向傳播,中間變量的梯度就將自動釋放,從而節約內存(參考自)
下一次,你會用相同的一組tensors來調用forward函數,從前面運行得到的葉子結點緩存器就會共享,同時,非葉子節點緩存器將會再次創建。
如果你在一個圖上的非葉子節點調用多於一次的backward函數,你就會遇到下面的錯誤(因爲圖已經消失了):
RuntimeError: Trying to backward through the graph a second time,
but the buffers have already been freed.
Specify retain_graph=True when calling backward the first time.
這是因爲在第一次調用backward函數的時候,非葉子節點緩存器就被殺死了,因此當backward函數第二次調用的時候,沒有路徑去指引它到葉子結點那裏。你可以向backward函數中添加retain_graph = True的聲明,來撤銷殺死非葉子緩存器。
loss.backward(retain_graph = True)
如果你像上面那樣做了,你就能夠在同一個圖上再次反向傳播,並且也會計算梯度,也就是說下一次你反向傳播的時候,這個梯度會加入到上次反向傳播保存的梯度中了。
和TensorFlow使用的靜態計算圖相比,它在運行程序之前就創建好了圖。之後,這個圖通過向已經定義好的圖中傳入數據來運行。
這個動態圖模型讓我們能夠在運行的時候修改我們網絡架構,這是因爲只有當這段代碼運行的時候,這個圖才能夠創建。
這意味着一個圖在代碼運行的期間,能夠再次定義,因爲你不能夠提前定義。但是,這個在靜態圖中是不可能的,在代碼運行之前要創建圖,然後之後僅僅執行。動態圖很方便調試,因爲很容易定位到你錯誤的地方。
8 一些技巧
8.1 requires_grad
它是Tensor類的一個屬性。默認是False。當你要去凍結一些層的時候會變得容易,並且在訓練的時候,暫停他們去更新參數。你可以簡單設置requires_grad爲False,並且這些Tensor不會參與到計算圖中。
因此,沒有梯度向它們傳播,或者那些依賴於那些梯度流requires_grad的層。當設置爲True的時候,requires_grad是可傳染的,這意味着儘管一個操作符的操作數的requires_grad置爲True,它還是這個結果。
8.2 torch.no_grad()
在我們計算梯度的時候,我們需要保存輸入值和中間特徵,因爲後面它們可能需要去計算梯度。
b = w1*a相對於輸入w1和a的梯度分別是a和w1。在反向傳播的過程中,我們需要爲梯度計算保存這些值。這會影響這個網絡的內存的佔用。
在我們運行推理的時候,我們並不計算梯度,因此我們不需要去保存這些值。事實上,在推理的過程中,不需要創建圖,因爲這會導致無用的內存消耗。
爲了這個要求,PyTorch提供了一個上下文管理器,叫做torch.no_grad
with torch.no_grad:
inference code goes here
在這個上下文管理器下,執行運算不會定義圖。
9 結論
在本文的最後部分,我簡單總結一下這篇文章的主要內容:
- 計算圖:計算圖本質上就是一個數據結構,有了計算圖我們可以更直觀的表示鏈式求導法則。
- Autograd模塊:它是PyTorch進行梯度計算的模塊,當我們希望一個Tensor可以進行梯度計算的時候,我們要設置這個 Tensor的requires_grad爲True。需要注意,計算圖中的非葉子結點存在grad_fn,但是,葉子結點(用於創建的變量) 不存在的。
- no_grad:在我們進行推理的時候,我們是不需要計算梯度的,因此可以在no_grad環境下進行推理。
理解Autograd和計算圖是如何工作的,可以更簡單地使用PyTorch。有了堅實的基礎,下一篇文章中,我們會詳細介紹如果創建定製的複雜的架構,如果去創建定製的數據管道和更多有趣的東西。