PyTorch 作為一個深度學習平臺,在深度學習任務中比 NumPy 這個科學計算庫強在哪里呢?我覺得一是 PyTorch 提供了自動求導機制,二是對 GPU 的支持。由此可見,自動求導 (autograd) 是 PyTorch,乃至其他大部分深度學習框架中的重要組成部分。
了解自動求導背后的原理和規則,對我們寫出一個更干凈整潔甚至更高效的 PyTorch 代碼是十分重要的。但是,現在已經有了很多封裝好的 API,我們在寫一個自己的網絡的時候,可能幾乎都不用去注意求導這些問題,因為這些 API 已經在私底下處理好了這些事情。現在我們往往只需要,搭建個想要的模型,處理好數據的載入,調用現成的 optimizer 和 loss function,直接開始訓練就好了。仔細一想,好像連需要設置 requires_grad=True 的地方好像都沒有。有人可能會問,那我們去了解自動求導還有什么用啊?
原因有很多,可以幫我們更深入地了解 PyTorch 這些寬泛的理由我就不說了,我舉一個例子:當我們想使用一個 PyTorch 默認中并沒有的 loss function 的時候,比如目標檢測模型 YOLO 的 loss,我們可能就得自己去實現。如果我們不熟悉基本的 PyTorch 求導機制的話,對于實現過程中比如 tensor 的 in-place 操作等很容易出錯,導致需要話很長時間去 debug,有的時候即使定位到了錯誤的位置,也不知道如何去修改。相反,如果我們理清楚了背后的原理,我們就能很快地修改這些錯誤,甚至根本不會去犯這些錯誤。鑒于現在官方支持的 loss function 并不多,而且深度學習領域日新月異,很多新的效果很好的 loss function 層出不窮,如果要用的話可能需要我們自己來實現。基于這個原因,我們了解一下自動求導機制還是很有必要的。
本文所有代碼例子都基于 Python3 和 PyTorch 1.1, 也就是不會涉及 0.4 版本以前的 Variable 這個數據結構。在文章中我們不會去分析一些非常底層的代碼,而是通過一系列實例來理解自動求導機制。在舉例的過程中我盡量保持場景的一致性,不用每個例子都需要重新了解假定的變量。另外,本文篇幅比較長。如果發現文章中有錯誤或者沒有講清楚的地方,歡迎大家在評論區指正和討論。
目錄
計算圖
一個具體的例子
葉子張量
inplace 操作
動態圖,靜態圖
計算圖
首先,我們先簡單地介紹一下什么是計算圖(Computational Graphs),以方便后邊的講解。假設我們有一個復雜的神經網絡模型,我們把它想象成一個錯綜復雜的管道結構,不同的管道之間通過節點連接起來,我們有一個注水口,一個出水口。我們在入口注入數據的之后,數據就沿著設定好的管道路線緩緩流動到出水口,這時候我們就完成了一次正向傳播。想象一下輸入的 tensor 數據在管道中緩緩流動的場景,這就是為什么 TensorFlow 叫 TensorFlow 的原因!emmm,好像走錯片場了,不過計算圖在 PyTorch 中也是類似的。至于這兩個非常有代表性的深度學習框架在計算圖上有什么區別,我們稍后再談。
計算圖通常包含兩種元素,一個是 tensor,另一個是 Function。張量 tensor 不必多說,但是大家可能對 Function 比較陌生。這里 Function 指的是在計算圖中某個節點(node)所進行的運算,比如加減乘除卷積等等之類的,Function 內部有 forward() 和 backward() 兩個方法,分別應用于正向、反向傳播。
a=torch.tensor(2.0,requires_grad=True) b=a.exp() print(b) #tensor(7.3891,grad_fn=)
在我們做正向傳播的過程中,除了執行 forward() 操作之外,還會同時會為反向傳播做一些準備,為反向計算圖添加 Function 節點。在上邊這個例子中,變量 b 在反向傳播中所需要進行的操作是
一個具體的例子
了解了基礎知識之后,現在我們來看一個具體的計算例子,并畫出它的正向和反向計算圖。假如我們需要計算這么一個模型:
l1 = input x w1 l2 = l1 + w2 l3 = l1 x w3 l4 = l2 x l3 loss = mean(l4)
這個例子比較簡單,涉及的最復雜的操作是求平均,但是如果我們把其中的加法和乘法操作換成卷積,那么其實和神經網絡類似。我們可以簡單地畫一下它的計算圖:
圖1:正向計算圖
下面給出了對應的代碼,我們定義了input,w1,w2,w3 這三個變量,其中 input 不需要求導結果。根據 PyTorch 默認的求導規則,對于 l1 來說,因為有一個輸入需要求導(也就是 w1 需要),所以它自己默認也需要求導,即 requires_grad=True(如果對這個規則不熟悉,歡迎參考 我上一篇博文的第一部分 或者直接查看 官方 Tutorial 相關部分)。在整張計算圖中,只有 input 一個變量是 requires_grad=False 的。正向傳播過程的具體代碼如下:
input=torch.ones([2,2],requires_grad=False) w1=torch.tensor(2.0,requires_grad=True) w2=torch.tensor(3.0,requires_grad=True) w3=torch.tensor(4.0,requires_grad=True) l1=input*w1 l2=l1+w2 l3=l1*w3 l4=l2*l3 loss=l4.mean() print(w1.data,w1.grad,w1.grad_fn) #tensor(2.)NoneNone print(l1.data,l1.grad,l1.grad_fn) #tensor([[2.,2.], #[2.,2.]])Noneprint(loss.data,loss.grad,loss.grad_fn) #tensor(40.)None
正向傳播的結果基本符合我們的預期。我們可以看到,變量 l1 的 grad_fn 儲存著乘法操作符
圖2:反向計算圖
反向圖也比較簡單,從 loss 這個變量開始,通過鏈式法則,依次計算出各部分的導數。說到這里,我們不妨先自己手動推導一下求導的結果,再與程序運行結果作對比。如果對這部分不感興趣的讀者,可以直接跳過。
再擺一下公式:
input = [1.0, 1.0, 1.0, 1.0] w1 = [2.0, 2.0, 2.0, 2.0] w2 = [3.0, 3.0, 3.0, 3.0] w3 = [4.0, 4.0, 4.0, 4.0]
l1 = input x w1 = [2.0, 2.0, 2.0, 2.0] l2 = l1 + w2 = [5.0, 5.0, 5.0, 5.0] l3 = l1 x w3 = [8.0, 8.0, 8.0, 8.0] l4 = l2 x l3 = [40.0, 40.0, 40.0, 40.0] loss = mean(l4) = 40.0
首先 , 所以 對 的偏導分別為 ;
接著 , 同時 ;
現在看 對它的兩個變量的偏導: ,
因此 , 其和為 10 ;
同理,再看一下求 導數的過程:
,其和為 8。
其他的導數計算基本上都類似,因為過程太多,這里就不全寫出來了,如果有興趣的話大家不妨自己繼續算一下。
接下來我們繼續運行代碼,并檢查一下結果和自己算的是否一致:
loss.backward() print(w1.grad,w2.grad,w3.grad) #tensor(28.)tensor(8.)tensor(10.) print(l1.grad,l2.grad,l3.grad,l4.grad,loss.grad) #NoneNoneNoneNoneNone
首先我們需要注意一下的是,在之前寫程序的時候我們給定的 w 們都是一個常數,利用了廣播的機制實現和常數和矩陣的加法乘法,比如 w2 + l1,實際上我們的程序會自動把 w2 擴展成 [[3.0, 3.0], [3.0, 3.0]],和 l1 的形狀一樣之后,再進行加法計算,計算的導數結果實際上為 [[2.0, 2.0], [2.0, 2.0]],為了對應常數輸入,所以最后 w2 的梯度返回為矩陣之和 8 。另外還有一個問題,雖然 w 開頭的那些和我們的計算結果相符,但是為什么 l1,l2,l3,甚至其他的部分的求導結果都為空呢?想要解答這個問題,我們得明白什么是葉子張量。
葉子張量
對于任意一個張量來說,我們可以用 tensor.is_leaf 來判斷它是否是葉子張量(leaf tensor)。在反向傳播過程中,只有 is_leaf=True 的時候,需要求導的張量的導數結果才會被最后保留下來。
對于 requires_grad=False 的 tensor 來說,我們約定俗成地把它們歸為葉子張量。但其實無論如何劃分都沒有影響,因為張量的 is_leaf 屬性只有在需要求導的時候才有意義。
我們真正需要注意的是當 requires_grad=True 的時候,如何判斷是否是葉子張量:當這個 tensor 是用戶創建的時候,它是一個葉子節點,當這個 tensor 是由其他運算操作產生的時候,它就不是一個葉子節點。我們來看個例子:
a=torch.ones([2,2],requires_grad=True) print(a.is_leaf) #True b=a+2 print(b.is_leaf) #False #因為b不是用戶創建的,是通過計算生成的
這時有同學可能會問了,為什么要搞出這么個葉子張量的概念出來?原因是為了節省內存(或顯存)。我們來想一下,那些非葉子結點,是通過用戶所定義的葉子節點的一系列運算生成的,也就是這些非葉子節點都是中間變量,一般情況下,用戶不會去使用這些中間變量的導數,所以為了節省內存,它們在用完之后就被釋放了。
我們回頭看一下之前的反向傳播計算圖,在圖中的葉子節點我用綠色標出了。可以看出來,被叫做葉子,可能是因為游離在主干之外,沒有子節點,因為它們都是被用戶創建的,不是通過其他節點生成。對于葉子節點來說,它們的 grad_fn 屬性都為空;而對于非葉子結點來說,因為它們是通過一些操作生成的,所以它們的 grad_fn 不為空。
我們有辦法保留中間變量的導數嗎?當然有,通過使用 tensor.retain_grad() 就可以:
#和前邊一樣 #... loss=l4.mean() l1.retain_grad() l4.retain_grad() loss.retain_grad() loss.backward() print(loss.grad) #tensor(1.) print(l4.grad) #tensor([[0.2500,0.2500], #[0.2500,0.2500]]) print(l1.grad) #tensor([[7.,7.], #[7.,7.]])
如果我們只是想進行 debug,只需要輸出中間變量的導數信息,而不需要保存它們,我們還可以使用 tensor.register_hook,例子如下:
#和前邊一樣 #... loss=l4.mean() l1.register_hook(lambdagrad:print('l1grad:',grad)) l4.register_hook(lambdagrad:print('l4grad:',grad)) loss.register_hook(lambdagrad:print('lossgrad:',grad)) loss.backward() #lossgrad:tensor(1.) #l4grad:tensor([[0.2500,0.2500], #[0.2500,0.2500]]) #l1grad:tensor([[7.,7.], #[7.,7.]]) print(loss.grad) #None #loss的grad在print完之后就被清除掉了
這個函數的功能遠遠不止打印導數信息用以 debug,但是一般很少用,所以這里就不擴展了,
到此為止,我們已經討論完了這個實例中的正向傳播和反向傳播的有關內容了。回過頭來看, input 其實很像神經網絡輸入的圖像,w1, w2, w3 則類似卷積核的參數,而 l1, l2, l3, l4 可以表示4個卷積層輸出,如果我們把節點上的加法乘法換成卷積操作的話。實際上這個簡單的模型,很像我們平時的神經網絡的簡化版,通過這個例子,相信大家多少也能對神經網絡的正向和反向傳播過程有個大致的了解了吧。
inplace 操作
現在我們來看一下本篇的重點,inplace operation。可以說,我們求導時候大部分的 bug,都出在使用了 inplace 操作上。現在我們以 PyTorch 不同的報錯信息作為驅動,來講一講 inplace 操作吧。第一個報錯信息:
RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation: balabala...
不少人可能會感到很熟悉,沒錯,我就是其中之一。之前寫代碼的時候竟經常報這個錯,原因是對 inplace 操作不了解。要搞清楚為什么會報錯,我們先來了解一下什么是 inplace 操作:inplace 指的是在不更改變量的內存地址的情況下,直接修改變量的值。我們來看兩種情況,大家覺得這兩種情況哪個是 inplace 操作,哪個不是?或者兩個都是 inplace?
#情景1 a=a.exp() #情景2 a[0]=10
答案是:情景1不是 inplace,類似 Python 中的 i=i+1, 而情景2是 inplace 操作,類似 i+=1。依稀記得當時做機器學習的大作業,很多人都被其中一個 i+=1 和 i=i+1 問題給坑了好長時間。那我們來實際測試一下:
#我們要用到id()這個函數,其返回值是對象的內存地址 #情景1 a=torch.tensor([3.0,1.0]) print(id(a))#2112716404344 a=a.exp() print(id(a))#2112715008904 #在這個過程中a.exp()生成了一個新的對象,然后再讓a #指向它的地址,所以這不是個inplace操作 #情景2 a=torch.tensor([3.0,1.0]) print(id(a))#2112716403840 a[0]=10 print(id(a),a)#2112716403840tensor([10.,1.]) #inplace操作,內存地址沒變
PyTorch 是怎么檢測 tensor 發生了 inplace 操作呢?答案是通過 tensor._version 來檢測的。我們還是來看個例子:
a=torch.tensor([1.0,3.0],requires_grad=True) b=a+2 print(b._version)#0 loss=(b*b).mean() b[0]=1000.0 print(b._version)#1 loss.backward() #RuntimeError:oneofthevariablesneededforgradientcomputationhasbeenmodifiedbyaninplaceoperation...
每次 tensor 在進行 inplace 操作時,變量 _version 就會加1,其初始值為0。在正向傳播過程中,求導系統記錄的 b 的 version 是0,但是在進行反向傳播的過程中,求導系統發現 b 的 version 變成1了,所以就會報錯了。但是還有一種特殊情況不會報錯,就是反向傳播求導的時候如果沒用到 b 的值(比如 y=x+1, y 關于 x 的導數是1,和 x 無關),自然就不會去對比 b 前后的 version 了,所以不會報錯。
上邊我們所說的情況是針對非葉子節點的,對于 requires_grad=True 的葉子節點來說,要求更加嚴格了,甚至在葉子節點被使用之前修改它的值都不行。我們來看一個報錯信息:
RuntimeError: leaf variable has been moved into the graph interior
這個意思通俗一點說就是你的一頓 inplace 操作把一個葉子節點變成了非葉子節點了。我們知道,非葉子節點的導數在默認情況下是不會被保存的,這樣就會出問題了。舉個小例子:
a=torch.tensor([10.,5.,2.,3.],requires_grad=True) print(a,a.is_leaf) #tensor([10.,5.,2.,3.],requires_grad=True)True a[:]=0 print(a,a.is_leaf) #tensor([0.,0.,0.,0.],grad_fn=)False loss=(a*a).mean() loss.backward() #RuntimeError:leafvariablehasbeenmovedintothegraphinterior
我們看到,在進行對 a 的重新 inplace 賦值之后,表示了 a 是通過 copy operation 生成的,grad_fn 都有了,所以自然而然不是葉子節點了。本來是該有導數值保留的變量,現在成了導數會被自動釋放的中間變量了,所以 PyTorch 就給你報錯了。還有另外一種情況:
a=torch.tensor([10.,5.,2.,3.],requires_grad=True) a.add_(10.)#或者a+=10. #RuntimeError:aleafVariablethatrequiresgradhasbeenusedinanin-placeoperation.
這個更厲害了,不等到你調用 backward,只要你對需要求導的葉子張量使用了這些操作,馬上就會報錯。那是不是需要求導的葉子節點一旦被初始化賦值之后,就不能修改它們的值了呢?我們如果在某種情況下需要重新對葉子變量賦值該怎么辦呢?有辦法!
#方法一 a=torch.tensor([10.,5.,2.,3.],requires_grad=True) print(a,a.is_leaf,id(a)) #tensor([10.,5.,2.,3.],requires_grad=True)True2501274822696 a.data.fill_(10.) #或者a.detach().fill_(10.) print(a,a.is_leaf,id(a)) #tensor([10.,10.,10.,10.],requires_grad=True)True2501274822696 loss=(a*a).mean() loss.backward() print(a.grad) #tensor([5.,5.,5.,5.]) #方法二 a=torch.tensor([10.,5.,2.,3.],requires_grad=True) print(a,a.is_leaf) #tensor([10.,5.,2.,3.],requires_grad=True)True withtorch.no_grad(): a[:]=10. print(a,a.is_leaf) #tensor([10.,10.,10.,10.],requires_grad=True)True loss=(a*a).mean() loss.backward() print(a.grad) #tensor([5.,5.,5.,5.])
修改的方法有很多種,核心就是修改那個和變量共享內存,但 requires_grad=False 的版本的值,比如通過 tensor.data 或者 tensor.detach()(至于這二者更詳細的介紹與比較,歡迎參照我上一篇文章的第四部分)。我們需要注意的是,要在變量被使用之前修改,不然等計算完之后再修改,還會造成求導上的問題,會報錯的。
為什么 PyTorch 的求導不支持絕大部分 inplace 操作呢?從上邊我們也看出來了,因為真的很 tricky。比如有的時候在一個變量已經參與了正向傳播的計算,之后它的值被修改了,在做反向傳播的時候如果還需要這個變量的值的話,我們肯定不能用那個后來修改的值吧,但沒修改之前的原始值已經被釋放掉了,我們怎么辦?一種可行的辦法就是我們在 Function 做 forward 的時候每次都開辟一片空間儲存當時輸入變量的值,這樣無論之后它們怎么修改,都不會影響了,反正我們有備份在存著。但這樣有什么問題?這樣會導致內存(或顯存)使用量大大增加。因為我們不確定哪個變量可能之后會做 inplace 操作,所以我們每個變量在做完 forward 之后都要儲存一個備份,成本太高了。除此之外,inplace operation 還可能造成很多其他求導上的問題。
總之,我們在實際寫代碼的過程中,沒有必須要用 inplace operation 的情況,而且支持它會帶來很大的性能上的犧牲,所以 PyTorch 不推薦使用 inplace 操作,當求導過程中發現有 inplace 操作影響求導正確性的時候,會采用報錯的方式提醒。但這句話反過來說就是,因為只要有 inplace 操作不當就會報錯,所以如果我們在程序中使用了 inplace 操作卻沒報錯,那么說明我們最后求導的結果是正確的,沒問題的。這就是我們常聽見的沒報錯就沒有問題。
動態圖,靜態圖
可能大家都聽說過,PyTorch 使用的是動態圖(Dynamic Computational Graphs)的方式,而 TensorFlow 使用的是靜態圖(Static Computational Graphs)。所以二者究竟有什么區別呢,我們本節來就來討論這個事情。
所謂動態圖,就是每次當我們搭建完一個計算圖,然后在反向傳播結束之后,整個計算圖就在內存中被釋放了。如果想再次使用的話,必須從頭再搭一遍,參見下邊這個例子。而以 TensorFlow 為代表的靜態圖,每次都先設計好計算圖,需要的時候實例化這個圖,然后送入各種輸入,重復使用,只有當會話結束的時候創建的圖才會被釋放(不知道這里我對 tf.Session 的理解對不對,如果有錯誤希望大佬們能指正一下),就像我們之前舉的那個水管的例子一樣,設計好水管布局之后,需要用的時候就開始搭,搭好了就往入口加水,什么時候不需要了,再把管道都給拆了。
#這是一個關于 PyTorch 是動態圖的例子: a=torch.tensor([3.0,1.0],requires_grad=True) b=a*a loss=b.mean() loss.backward()#正常 loss.backward()#RuntimeError #第二次:從頭再來一遍 a=torch.tensor([3.0,1.0],requires_grad=True) b=a*a loss=b.mean() loss.backward()#正常
從描述中我們可以看到,理論上來說,靜態圖在效率上比動態圖要高。因為首先,靜態圖只用構建一次,然后之后重復使用就可以了;其次靜態圖因為是固定不需要改變的,所以在設計完了計算圖之后,可以進一步的優化,比如可以將用戶原本定義的 Conv 層和 ReLU 層合并成 ConvReLU 層,提高效率。
但是,深度學習框架的速度不僅僅取決于圖的類型,還很其他很多因素,比如底層代碼質量,所使用的底層 BLAS 庫等等等都有關。從實際測試結果來說,至少在主流的模型的訓練時間上,PyTorch 有著至少不遜于靜態圖框架 Caffe,TensorFlow 的表現。具體對比數據可以參考:
https://github.com/ilkarman/DeepLearningFrameworks
大家不要急著糾正我,我知道,我現在就說:當然,在 9102 年的今天,動態圖和靜態圖直接的界限已經開始慢慢模糊。PyTorch 模型轉成 Caffe 模型越來越方便,而 TensorFlow 也加入了一些動態圖機制。
除了動態圖之外,PyTorch 還有一個特性,叫 eager execution。意思就是當遇到 tensor 計算的時候,馬上就回去執行計算,也就是,實際上 PyTorch 根本不會去構建正向計算圖,而是遇到操作就執行。真正意義上的正向計算圖是把所有的操作都添加完,構建好了之后,再運行神經網絡的正向傳播。
正是因為 PyTorch 的兩大特性:動態圖和 eager execution,所以它用起來才這么順手,簡直就和寫 Python 程序一樣舒服,debug 也非常方便。除此之外,我們從之前的描述也可以看出,PyTorch 十分注重占用內存(或顯存)大小,沒有用的空間釋放很及時,可以很有效地利用有限的內存。
總結
本篇文章主要討論了 PyTorch 的 Autograd 機制和使用 inplace 操作不當可能會導致的各種報錯。在實際寫代碼的過程中,涉及需要求導的部分,不建議大家使用 inplace 操作。除此之外我們還比較了動態圖和靜態圖框架,PyTorch 作為動態圖框架的代表之一,對初學者非常友好,而且運行速度上不遜于靜態圖框架,再加上現在通過 ONNX 轉換為其他框架的模型用以部署也越來越方便,我覺得是一個非常稱手的深度學習工具。
-
函數
+關注
關注
3文章
4327瀏覽量
62569 -
模型
+關注
關注
1文章
3226瀏覽量
48807 -
深度學習
+關注
關注
73文章
5500瀏覽量
121111 -
pytorch
+關注
關注
2文章
807瀏覽量
13198
原文標題:PyTorch 的 Autograd詳解
文章出處:【微信號:zenRRan,微信公眾號:深度學習自然語言處理】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論