# 【Pytorch 深度學習筆記】Autograd 自動微分機制與 Optimizers 優化器 [TOC] 哈囉大家好我是 LukeTseng,感謝您點進本篇筆記,該篇筆記主要配合讀本 《Deep Learning with pytorch》 進行學習,另外透過網路資料作為輔助。本系列筆記是我本人奠基深度學習基礎知識的開始,若文章有誤煩請各位指正,謝謝! 本篇為 《Deep Learning with pytorch》 這本書 5.5 PyTorch’s autograd: Backpropagating all things 的相關筆記。 ## 什麼是 Autograd 在機器學習中,需要計算損失函數對參數(權重 $w$ 和偏差 $b$ )的導數,以便更新參數。 雖然可以手動推導並解析運算式,但如果今天是擁有數百萬個參數的深層模型呢?總不可能一個一個手動去算吧,因此 PyTorch 提供一個機制叫做 Autograd。 > 只要給定一個前向運算式(無論多麼複雜),PyTorch 都能自動提供該運算式對其輸入參數的梯度。 ## 使用 Autograd 先前溫度計例子定義的模型函數、損失函數如下([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/2_autograd.ipynb)): ```python= def model(t_u, w, b): return w * t_u + b def loss_fn(t_p, t_c): squared_diffs = (t_p - t_c)**2 return squared_diffs.mean() ``` 接著來初始化參數 tensor: ```python params = torch.tensor([1.0, 0.0], requires_grad=True) ``` 當建立一個 tensor時,PyTorch 提供了一個參數叫 `requires_grad=True`。 這個參數主要是告訴 PyTorch 追蹤由該 tensor 產生的整個家譜。 什麼意思呢?任何以該 tensor(如 params) 為祖先的 tensor,都能夠存取從初始 tensor 到當前 tensor 所呼叫過的函數鏈。只要這些運算是可微的(大多數 PyTorch tensor 運算都是),導數值就會被自動計算,且會自動填入該 tensor 的屬性 `grad`。 所有 PyTorch tensor 都有一個名為 `grad` 的屬性,一般來說其值為 `None`。 開 `requires_grad=True` 然後做反向傳播的時候 `grad` 屬性才有值。 ### 反向傳播 `.backward()` 在計算出損失值 Loss 後,不用手動計算梯度,只需對 Loss tensor 呼叫 `.backward()` 方法即可算出梯度。 註:`model(t_u, *params)` 當中的 `*` 是解包(unpack)的語法,因為 `params` 是二維的 tensor,裡面都有每組的 $w$ 跟 $b$ 綁在一起,為了要傳遞給 `model` 當作參數,因此用 `*` 來分別提取 $w$ 跟 $b$。 ([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/2_autograd.ipynb)) ```python= loss = loss_fn(model(t_u, *params), t_c) loss.backward() params.grad ``` Output: ``` tensor([4517.2969, 82.6000]) ``` `.backward()` 運作原理:PyTorch 會建立一個自動求導圖(Autograd Graph),將運算作為節點。呼叫 `loss.backward()` 時,引擎會以相反的方向遍歷此圖來計算梯度。 最後計算出的梯度值會自動填入參數張量的 `.grad` 屬性中。 書中也畫了示意圖來說明 `.backward()` 的運作原理: ![image](https://hackmd.io/_uploads/BkYsM4KQWx.png) Source:"Deep Learning with PyTorch" P.125 Figure 5.10 這張圖要由上往下看。 來看這張圖上半部分,首先 X 的地方是輸入資料的部分,因為這裡是不需要被拿去訓練的,因此 `requires_grad = False`,不用去做追蹤。 $w$(Weight)跟 $b$(Bias)是模型要學習的參數,因此要把 `requires_grad=True` 打開,表示 PyTorch 需要追蹤它們的運算,以便稍後計算梯度來更新這些參數。剛開始的時候它們的 grad 都是 None。 $\bar{y}$ 是 Ground Truth,代表正確答案。 運算流程: 1. $*$(乘法):$x$ 乘以權重 $w$。 2. $+$(加法):加上偏差 $b$,得到預測值。 3. $-$(減法):預測值減去真實值 $\bar{y}$,計算誤差。 4. $sq$(square, 平方):將誤差平方,做均方誤差(MSE)。 5. $loss$:最終得到的損失值。 中間有個 `loss.backward()`,代表著中間指令,要做反向傳播,會從 loss 開始,沿著計算圖往回走,利用連鎖律(Chain Rule)計算每個參數對損失的影響(梯度)。 當中黃色箭頭代表梯度的流動方向,梯度從 loss 一路傳回葉節點(Leaf Nodes,即 $w$ 和 $b$)。 參數更新狀態: - $w$:`grad` 變成 $\frac{\partial loss}{\partial w}$(Loss 對 $w$ 的偏微分)。 - $b$:`grad` 變成 $\frac{\partial loss}{\partial b}$。 - $x$:因為一開始設定 `requires_grad=False`,所以梯度流到這裡就停止了,不會計算 $x$ 的梯度。 ### 梯度陷阱:「累積」機制 PyTorch 會累積梯度,而不會覆蓋掉先前的參數。 如果呼叫 `.backward()` 時 `.grad` 屬性中已經有值,新的梯度會加在舊值之上,會造成在 training loop 中的梯度值錯誤。 解決方法是在每次訓練迭代中,把梯度給清零,用方法 `.zero_()` 來做到這件事。 像這樣([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/2_autograd.ipynb)): ```python= if params.grad is not None: params.grad.zero_() ``` ### 結合 Autograd 的 Training loop `torch.no_grad()` 是 PyTorch 的停用自動微分模式(相當於 Python 裡面的 context manager / 裝飾器),在這個 `with` 區塊內做的 tensor 運算不會建立計算圖、也不會追蹤梯度。 這邊是希望更新參數這個動作本身不被記錄在求導圖中,否則會干擾下一輪的圖構建。 而 `if epoch % 500 == 0:` 的部分是每 500 個 epoch 就印出一次 Loss,避免說 epoch 太多,然後每一個都要印這樣,會變得比較雜。 ([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/2_autograd.ipynb)) ```python= def training_loop(n_epochs, learning_rate, params, t_u, t_c): for epoch in range(1, n_epochs + 1): if params.grad is not None: params.grad.zero_() t_p = model(t_u, *params) loss = loss_fn(t_p, t_c) loss.backward() with torch.no_grad(): params -= learning_rate * params.grad if epoch % 500 == 0: print('Epoch %d, Loss %f' % (epoch, float(loss))) return params ``` 接著執行看結果([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/2_autograd.ipynb)): ```python= training_loop( n_epochs = 5000, learning_rate = 1e-2, # params 這邊加 requires_grad=True # 然後也用正規化的資料 t_un 替代掉 t_u params = torch.tensor([1.0, 0.0], requires_grad=True), t_u = t_un, t_c = t_c) ``` Output: ``` Epoch 500, Loss 7.860116 Epoch 1000, Loss 3.828538 Epoch 1500, Loss 3.092191 Epoch 2000, Loss 2.957697 Epoch 2500, Loss 2.933134 Epoch 3000, Loss 2.928648 Epoch 3500, Loss 2.927830 Epoch 4000, Loss 2.927679 Epoch 4500, Loss 2.927652 Epoch 5000, Loss 2.927647 tensor([ 5.3671, -17.3012], requires_grad=True) ``` ## 優化器菜單(Optimizers a la carte) a la carte 是菜單的意思,源自於法文的英文單字。 在 PyTorch 中,優化策略是從模型邏輯中解耦出來的,也就是說不論模型有多複雜,在更新參數的步驟都被標準化了。 :::info 解耦(Decoupling):軟體工程名詞,降低系統不同模組或元件間的相互依賴性,讓各部分能更獨立地開發、維護與擴展,避免「牽一髮動全身」的狀況,提升系統的靈活性、可維護性、可擴展性與獨立性。 可以理解成 OOP(物件導向程式設計)中將使用者程式碼上做「抽象化」的行為。 ::: 所以能像在點菜一樣,在 `torch.optim` 模組中隨意更換不同的演算法(如 `ASGD`、`Adam`、`RMSprop` 等),不用特別修改 training loop 的邏輯。 像是以下的程式碼範例就列出所有的優化演算法([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/3_optimizers.ipynb)): 註:`SparseAdam` 再下去的就不算是演算法範疇,那些都是 Python 內建的語法。 ```python= import torch.optim as optim dir(optim) ``` Output: ``` ['ASGD', 'Adadelta', 'Adagrad', 'Adam', 'AdamW', 'Adamax', 'LBFGS', 'Optimizer', 'RMSprop', 'Rprop', 'SGD', 'SparseAdam', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__', 'lr_scheduler'] ``` 每個優化器建構子都要接收一個參數列表(通常是有 requires_grad=True 的 Tensor)作為第一個輸入,優化器物件內部會保留這些參數的參考(reference),以便在後續直接存取它們的 `.grad` 屬性。 在繼續下去之前,要先知道優化器兩個重要的方法: - `zero_grad()`:將所有受管理的參數之梯度(.grad)清零。 - `step()`:根據特定優化算法的規則,利用參數中的梯度來更新參數值。 ![image](https://hackmd.io/_uploads/H1uBhDt7-l.png) Source:"Deep Learning with PyTorch" P.128 Figure 5.11 這張圖在講優化器(Optimizer)、模型(Model)、損失函數(Loss)之間如何互動來更新模型的參數。 首先來看 A(建立優化器):模型把他的參數交給了 optimizer,在此時 optimizer 就有了對模型參數的參考(reference),寫成程式碼會像是以下那樣([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/3_optimizers.ipynb)): ```python= params = torch.tensor([1.0, 0.0], requires_grad=True) learning_rate = 1e-5 optimizer = optim.SGD([params], lr=learning_rate) ``` 當中這個 SGD 是 Stochastic Gradient Descent,隨機梯度下降,是深度學習中最基礎、也最常用的優化演算法。 SGD 的 gradient 是用隨機抽取的 1 筆資料算出來的,因為每次只用一筆資料算梯度,計算量極小,參數更新頻率極高,這會讓模型收斂速度變快很多。 --- 接下來再看 B,B 是在做前向傳播的事情,輸入模型、產生輸出,然後計算出損失值(Loss)。 --- C 的話則是在做反向傳播了,看圖來說的話就是呼叫 `.backward()`,導致 `.grad` 被填入參數中。 注意圖中的 `.grad` 出現在參數旁,就表示這些梯度的資訊已被算出來並存進參數物件中了。 --- 最後 D 就在做參數更新了,優化器讀取 `.grad`,計算出參數的變化量 ($\Delta params$),然後執行 update 更新模型參數。 --- 接下來再將上面這四步 A、B、C、D 結合起來,就可以來撰寫 training_loop 了,不過需要注意每次要做 `.backward()` 之前都要用 `optimizer.zero_grad()` 把先前計算好的 gradient 清掉。 ([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/3_optimizers.ipynb)) ```python= def training_loop(n_epochs, optimizer, params, t_u, t_c): for epoch in range(1, n_epochs + 1): t_p = model(t_u, *params) loss = loss_fn(t_p, t_c) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 500 == 0: print('Epoch %d, Loss %f' % (epoch, float(loss))) return params ``` 然後接著讓模型去學習: 在這邊書中作者有提到一個重點,在 optimizer 處的 params 跟 training_loop 裡面的 params,一定要是相同的物件,否則 optimizer 會不知道該優化哪一個物件。 ([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/3_optimizers.ipynb)) ```python= params = torch.tensor([1.0, 0.0], requires_grad=True) learning_rate = 1e-2 optimizer = optim.SGD([params], lr=learning_rate) training_loop( n_epochs = 5000, optimizer = optimizer, params = params, t_u = t_un, t_c = t_c) ``` Output: ``` Epoch 500, Loss 7.860118 Epoch 1000, Loss 3.828538 Epoch 1500, Loss 3.092191 Epoch 2000, Loss 2.957697 Epoch 2500, Loss 2.933134 Epoch 3000, Loss 2.928648 Epoch 3500, Loss 2.927830 Epoch 4000, Loss 2.927680 Epoch 4500, Loss 2.927651 Epoch 5000, Loss 2.927648 tensor([ 5.3671, -17.3012], requires_grad=True) ``` ### 換其他的優化器 SGD 對於資料尺度(Scaling)很敏感,因此需要手動將輸入的資料 $t_u$ 縮放 10 倍。 但等下要換的 Adam 改進了這個問題,對參數尺度的敏感度低得很多,低到可以不用做正規化(Normalization)的動作,也可以將 learning rate 設得大一點沒關係,可以讓大幅減少模型訓練的時間。 要換其他優化器也很簡單,只需將 `optimizer = optim.SGD(...)` 改為 `optimizer = optim.Adam(...)` 即可,就只是把 SGD 換成 Adam。 完整程式碼([Code Source](https://github.com/deep-learning-with-pytorch/dlwpt-code/blob/master/p1ch5/3_optimizers.ipynb)): ```python= params = torch.tensor([1.0, 0.0], requires_grad=True) learning_rate = 1e-1 optimizer = optim.Adam([params], lr=learning_rate) # SGD 改 Adam training_loop( n_epochs = 2000, optimizer = optimizer, params = params, t_u = t_u, # 在這邊直接用原始數據,不做正規化 t_c = t_c) ``` ### 小結 引入優化器後,Training Loop 就變得非常簡潔而且靈活性很高,不需要像之前土法煉鋼在那邊算半天。 除了這以外,很重要的是無論要訓練的模型是簡單的線性函數還是複雜的卷積網路,優化器的使用方法完全相同。 另外,也只要把 `model.parameters()`(模型的參數)丟給優化器,它就會自動處理成千上萬個權重的更新,非常的方便。 ## 總整理 ### Autograd Autograd 就是自動微分系統。 Autograd 只要定義前向傳播,PyTorch 便能自動建立計算圖,再透過 Chain Rule(連鎖律),在 `.backward()` 時自動算出梯度。 Autograd 解決了模型參數動輒上百萬,無法手動微分的問題。 ### `requires_grad=True` 在做什麼? 開啟 `requires_grad=True` 後,PyTorch 會追蹤該 tensor 參與的所有可微運算,這些運算會形成一張 Autograd Graph。 反向傳播 `.backward()` 時,梯度會自動寫入 `.grad` 屬性裡面。 需要注意以下兩點事項來決定要不要開啟: - 要學的參數(w, b) → requires_grad=True - 純資料(x, label) → requires_grad=False ### 反向傳播 `.backward()` 反向傳播演算法流程: 1. 從 loss 節點出發。 2. 反向走訪計算圖。 3. 依照 Chain Rule 計算每個葉節點(w, b)的偏微分。 好處是不用自己寫微分公式,但要注意梯度會累積在 `.grad` 屬性上。 ### 梯度陷阱:「累積」機制 因為每次算梯度會累積,在 training loop 裡面要記得清梯度:`params.grad.zero_()`。 或是直接交由 optimizer 管理:`optimizer.zero_grad()` ### `torch.no_grad()` 這個方法的作用是更新參數時,不要再被 Autograd 追蹤。 因為更新參數的時候並不是模型運算的部分,如果被記錄,計算圖會污染下一輪的訓練。 ### Optimizer Optimizer 的工作: 1. 管理參數 2. 清梯度:`zero_grad()` 3. 更新參數:`step()` 有 Optimizer 的 training loop 被簡化為固定四步: 1. Forward(算輸出與 loss) 2. `zero_grad()` 3. `backward()` 4. `step()` 不論模型有多複雜,基本上就是這個流程在跑。 ### SGD vs Adam SGD: - 對資料尺度(scaling)非常敏感。 - 通常需要先做 normalization。 - 收斂穩定但較慢。 Adam: - 自動調整學習率。 - 對尺度不敏感。 - 可用較大 learning rate。 若要修改其他的優化器,直接將原本的改成另外一個名稱的優化器即可,在 training loop 也不必做更動。