【Pytorch 深度學習筆記】Autograd 自動微分機制與 Optimizers 優化器

哈囉大家好我是 LukeTseng,感謝您點進本篇筆記,該篇筆記主要配合讀本 《Deep Learning with pytorch》 進行學習,另外透過網路資料作為輔助。本系列筆記是我本人奠基深度學習基礎知識的開始,若文章有誤煩請各位指正,謝謝!

本篇為 《Deep Learning with pytorch》 這本書 5.5 PyTorch’s autograd: Backpropagating all things 的相關筆記。

什麼是 Autograd

在機器學習中,需要計算損失函數對參數(權重 ww 和偏差 bb )的導數,以便更新參數。

雖然可以手動推導並解析運算式,但如果今天是擁有數百萬個參數的深層模型呢?總不可能一個一個手動去算吧,因此 PyTorch 提供一個機制叫做 Autograd。

只要給定一個前向運算式(無論多麼複雜),PyTorch 都能自動提供該運算式對其輸入參數的梯度。

使用 Autograd

先前溫度計例子定義的模型函數、損失函數如下(Code Source):

1
2
3
4
5
6
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:

1
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,裡面都有每組的 wwbb 綁在一起,為了要傳遞給 model 當作參數,因此用 * 來分別提取 wwbb

Code Source

1
2
3
4
loss = loss_fn(model(t_u, *params), t_c)
loss.backward()

params.grad

Output:

1
tensor([4517.2969, 82.6000])

.backward() 運作原理:PyTorch 會建立一個自動求導圖(Autograd Graph),將運算作為節點。呼叫 loss.backward() 時,引擎會以相反的方向遍歷此圖來計算梯度。

最後計算出的梯度值會自動填入參數張量的 .grad 屬性中。

書中也畫了示意圖來說明 .backward() 的運作原理:

image

Source:“Deep Learning with PyTorch” P.125 Figure 5.10

這張圖要由上往下看。

來看這張圖上半部分,首先 X 的地方是輸入資料的部分,因為這裡是不需要被拿去訓練的,因此 requires_grad = False,不用去做追蹤。

ww(Weight)跟 bb(Bias)是模型要學習的參數,因此要把 requires_grad=True 打開,表示 PyTorch 需要追蹤它們的運算,以便稍後計算梯度來更新這些參數。剛開始的時候它們的 grad 都是 None。

yˉ\bar{y} 是 Ground Truth,代表正確答案。

運算流程:

  1. *(乘法):xx 乘以權重 ww
  2. ++(加法):加上偏差 bb,得到預測值。
  3. -(減法):預測值減去真實值 yˉ\bar{y},計算誤差。
  4. sqsq(square, 平方):將誤差平方,做均方誤差(MSE)。
  5. lossloss:最終得到的損失值。

中間有個 loss.backward(),代表著中間指令,要做反向傳播,會從 loss 開始,沿著計算圖往回走,利用連鎖律(Chain Rule)計算每個參數對損失的影響(梯度)。

當中黃色箭頭代表梯度的流動方向,梯度從 loss 一路傳回葉節點(Leaf Nodes,即 wwbb)。

參數更新狀態:

  • wwgrad 變成 lossw\frac{\partial loss}{\partial w}(Loss 對 ww 的偏微分)。
  • bbgrad 變成 lossb\frac{\partial loss}{\partial b}
  • xx:因為一開始設定 requires_grad=False,所以梯度流到這裡就停止了,不會計算 xx 的梯度。

梯度陷阱:「累積」機制

PyTorch 會累積梯度,而不會覆蓋掉先前的參數。

如果呼叫 .backward().grad 屬性中已經有值,新的梯度會加在舊值之上,會造成在 training loop 中的梯度值錯誤。

解決方法是在每次訓練迭代中,把梯度給清零,用方法 .zero_() 來做到這件事。

像這樣(Code Source):

1
2
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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):

1
2
3
4
5
6
7
8
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:

1
2
3
4
5
6
7
8
9
10
11
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 模組中隨意更換不同的演算法(如 ASGDAdamRMSprop 等),不用特別修改 training loop 的邏輯。

像是以下的程式碼範例就列出所有的優化演算法(Code Source):

註:SparseAdam 再下去的就不算是演算法範疇,那些都是 Python 內建的語法。

1
2
3
import torch.optim as optim

dir(optim)

Output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
['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

Source:“Deep Learning with PyTorch” P.128 Figure 5.11

這張圖在講優化器(Optimizer)、模型(Model)、損失函數(Loss)之間如何互動來更新模型的參數。

首先來看 A(建立優化器):模型把他的參數交給了 optimizer,在此時 optimizer 就有了對模型參數的參考(reference),寫成程式碼會像是以下那樣(Code Source):

1
2
3
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,計算出參數的變化量 (Δparams\Delta params),然後執行 update 更新模型參數。


接下來再將上面這四步 A、B、C、D 結合起來,就可以來撰寫 training_loop 了,不過需要注意每次要做 .backward() 之前都要用 optimizer.zero_grad() 把先前計算好的 gradient 清掉。

Code Source

1
2
3
4
5
6
7
8
9
10
11
12
13
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

1
2
3
4
5
6
7
8
9
10
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:

1
2
3
4
5
6
7
8
9
10
11
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)很敏感,因此需要手動將輸入的資料 tut_u 縮放 10 倍。

但等下要換的 Adam 改進了這個問題,對參數尺度的敏感度低得很多,低到可以不用做正規化(Normalization)的動作,也可以將 learning rate 設得大一點沒關係,可以讓大幅減少模型訓練的時間。

要換其他優化器也很簡單,只需將 optimizer = optim.SGD(...) 改為 optimizer = optim.Adam(...) 即可,就只是把 SGD 換成 Adam。

完整程式碼(Code Source):

1
2
3
4
5
6
7
8
9
10
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 也不必做更動。