[PyTorch 學習筆記] 4.3 優化器

本章程式碼:

這篇文章主要介紹了 PyTorch 中的優化器,包括 3 個部分:優化器的概念、optimizer 的屬性、optimizer 的方法。

優化器的概念

PyTorch 中的優化器是用於管理並更新模型中可學習參數的值,使得模型輸出更加接近真實標籤。

optimizer 的屬性

PyTorch 中提供了 Optimizer 類,定義如下:

class Optimizer(object):
	def __init__(self, params, defaults):
		self.defaults = defaults
        self.state = defaultdict(dict)
        self.param_groups = []

主要有 3 個屬性

  • defaults:優化器的超參數,如 weight_decay,momentum
  • state:參數的快取,如 momentum 中需要用到前幾次的梯度,就快取在這個變數中
  • param_groups:管理的參數組,是一個 list,其中每個元素是字典,包括 momentum、lr、weight_decay、params 等。
  • _step_count:記錄更新 次數,在學習率調整中使用

optimizer 的方法

  • zero_grad():清空所管理參數的梯度。由於 PyTorch 的特性是張量的梯度不自動清零,因此每次反向傳播之後都需要清空梯度。程式碼如下:

    def zero_grad(self):
        r"""Clears the gradients of all optimized :class:`torch.Tensor` s."""
        for group in self.param_groups:
            for p in group['params']:
                if p.grad is not None:
                    p.grad.detach_()
                    p.grad.zero_()
    
  • step():執行一步梯度更新

  • add_param_group():添加參數組,主要程式碼如下:

    def add_param_group(self, param_group):
    	params = param_group['params']
        if isinstance(params, torch.Tensor):
            param_group['params'] = [params]
        ...
        self.param_groups.append(param_group)
    
  • state_dict():獲取優化器當前狀態資訊字典

  • load_state_dict():載入狀態資訊字典,包括 state 、momentum_buffer 和 param_groups。主要用於模型的斷點續訓練。我們可以在每隔 50 個 epoch 就保存模型的 state_dict 到硬碟,在意外終止訓練時,可以繼續載入上次保存的狀態,繼續訓練。程式碼如下:

    def state_dict(self):
        r"""Returns the state of the optimizer as a :class:`dict`.
        ...
        return {
        'state': packed_state,
        'param_groups': param_groups,
        }
    

下面是程式碼示例:

step()

張量 weight 的形狀為 $2 \times 2$,並設置梯度為 1,把 weight 傳進優化器,學習率設置為 1,執行optimizer.step()更新梯度,也就是所有的張量都減去 1。

weight = torch.randn((2, 2), requires_grad=True)
weight.grad = torch.ones((2, 2))

optimizer = optim.SGD([weight], lr=1)
print("weight before step:{}".format(weight.data))
optimizer.step()        # 修改lr=1, 0.1觀察結果
print("weight after step:{}".format(weight.data))

輸出為:

weight before step:tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]])
weight after step:tensor([[-0.3386, -0.7331],
        [-0.9383, -0.3787]])

zero_grad()

程式碼如下:

print("weight before step:{}".format(weight.data))
optimizer.step()        # 修改lr=1 0.1觀察結果
print("weight after step:{}".format(weight.data))

print("weight in optimizer:{}\nweight in weight:{}\n".format(id(optimizer.param_groups[0]['params'][0]), id(weight)))

print("weight.grad is {}\n".format(weight.grad))
optimizer.zero_grad()
print("after optimizer.zero_grad(), weight.grad is\n{}".format(weight.grad))

輸出為:

weight before step:tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]])
weight after step:tensor([[-0.3386, -0.7331],
        [-0.9383, -0.3787]])
weight in optimizer:1932450477472
weight in weight:1932450477472
weight.grad is tensor([[1., 1.],
        [1., 1.]])
after optimizer.zero_grad(), weight.grad is
tensor([[0., 0.],
        [0., 0.]])

可以看到優化器的 param_groups 中存儲的參數和 weight 的記憶體地址是一樣的,所以優化器中保存的是參數的地址,而不是把參數複製到優化器中。

add_param_group()

向優化器中添加一組參數,程式碼如下:

print("optimizer.param_groups is\n{}".format(optimizer.param_groups))
w2 = torch.randn((3, 3), requires_grad=True)
optimizer.add_param_group({"params": w2, 'lr': 0.0001})
print("optimizer.param_groups is\n{}".format(optimizer.param_groups))

輸出如下:

optimizer.param_groups is
[{'params': [tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]], requires_grad=True)], 'lr': 1, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}]
optimizer.param_groups is
[{'params': [tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]], requires_grad=True)], 'lr': 1, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}, {'params': [tensor([[-0.4519, -0.1661, -1.5228],
        [ 0.3817, -1.0276, -0.5631],
        [-0.8923, -0.0583, -0.1955]], requires_grad=True)], 'lr': 0.0001, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}]

state_dict()

首先進行 10 次反向傳播更新,然後對比 state_dict 的變化。可以使用torch.save()把 state_dict 保存到 pkl 文件中。

optimizer = optim.SGD([weight], lr=0.1, momentum=0.9)
opt_state_dict = optimizer.state_dict()

print("state_dict before step:\n", opt_state_dict)

for i in range(10):
optimizer.step()

print("state_dict after step:\n", optimizer.state_dict())

torch.save(optimizer.state_dict(), os.path.join(BASE_DIR, "optimizer_state_dict.pkl"))

輸出為:

state_dict before step:
 {'state': {}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [1976501036448]}]}
state_dict after step:
 {'state': {1976501036448: {'momentum_buffer': tensor([[6.5132, 6.5132],
        [6.5132, 6.5132]])}}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [1976501036448]}]}

經過反向傳播後,state_dict 中的字典保存了1976501036448作為 key,這個 key 就是參數的記憶體地址。

load_state_dict()

上面保存了 state_dict 之後,可以先使用torch.load()把載入到記憶體中,然後再使用load_state_dict()載入到模型中,繼續訓練。程式碼如下:

optimizer = optim.SGD([weight], lr=0.1, momentum=0.9)
state_dict = torch.load(os.path.join(BASE_DIR, "optimizer_state_dict.pkl"))

print("state_dict before load state:\n", optimizer.state_dict())
optimizer.load_state_dict(state_dict)
print("state_dict after load state:\n", optimizer.state_dict())

輸出如下:

state_dict before load state:
 {'state': {}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [2075286132128]}]}
state_dict after load state:
 {'state': {2075286132128: {'momentum_buffer': tensor([[6.5132, 6.5132],
        [6.5132, 6.5132]])}}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [2075286132128]}]}

學習率

學習率是影響損失函數收斂的重要因素,控制了梯度下降更新的步伐。下面構造一個損失函數 $y=(2x)^{2}$,$x$ 的初始值為 2,學習率設置為 1。

iter_rec, loss_rec, x_rec = list(), list(), list()

lr = 0.01    # /1. /.5 /.2 /.1 /.125
max_iteration = 20   # /1. 4     /.5 4   /.2 20 200

for i in range(max_iteration):

y = func(x)
y.backward()

print("Iter:{}, X:{:8}, X.grad:{:8}, loss:{:10}".format(
i, x.detach().numpy()[0], x.grad.detach().numpy()[0], y.item()))

x_rec.append(x.item())

x.data.sub_(lr * x.grad)    # x -= x.grad  數學表達式意義:  x = x - x.grad    # 0.5 0.2 0.1 0.125
x.grad.zero_()

iter_rec.append(i)
loss_rec.append(y)

plt.subplot(121).plot(iter_rec, loss_rec, '-ro')
plt.xlabel("Iteration")
plt.ylabel("Loss value")

x_t = torch.linspace(-3, 3, 100)
y = func(x_t)
plt.subplot(122).plot(x_t.numpy(), y.numpy(), label="y = 4*x^2")
plt.grid()
y_rec = [func(torch.tensor(i)).item() for i in x_rec]
plt.subplot(122).plot(x_rec, y_rec, '-ro')
plt.legend()
plt.show()

結果如下:

損失函數沒有減少,而是增大,這時因為學習率太大,無法收斂,把學習率設置為 0.01 後,結果如下;

從上面可以看出,適當的學習率可以加快模型的收斂。

下面的程式碼是試驗 10 個不同的學習率 ,[0.01, 0.5] 之間線性選擇 10 個學習率,並比較損失函數的收斂情況

    iteration = 100
    num_lr = 10
    lr_min, lr_max = 0.01, 0.2  # .5 .3 .2

    lr_list = np.linspace(lr_min, lr_max, num=num_lr).tolist()
    loss_rec = [[] for l in range(len(lr_list))]
    iter_rec = list()

    for i, lr in enumerate(lr_list):
        x = torch.tensor([2.], requires_grad=True)
        for iter in range(iteration):

            y = func(x)
            y.backward()
            x.data.sub_(lr * x.grad)  # x.data -= x.grad
            x.grad.zero_()

            loss_rec[i].append(y.item())

    for i, loss_r in enumerate(loss_rec):
        plt.plot(range(len(loss_r)), loss_r, label="LR: {}".format(lr_list[i]))
    plt.legend()
    plt.xlabel('Iterations')
    plt.ylabel('Loss value')
    plt.show()

結果如下:

上面的結果表示在學習率較大時,損失函數越來越大,模型不能收斂。把學習率區間改為 [0.01, 0.2] 之後,結果如下:

這個損失函數在學習率為 0.125 時最快收斂,學習率為 0.01 收斂最慢。但是不同模型的最佳學習率不一樣,無法事先知道,一般把學習率設置為比較小的數就可以了。

momentum 動量

momentum 動量的更新方法,不僅考慮當前的梯度,還會結合前面的梯度。

momentum 來源於指數加權平均:$\mathrm{v}{t}=\boldsymbol{\beta} * \boldsymbol{v}{t-1}+(\mathbf{1}-\boldsymbol{\beta}) * \boldsymbol{\theta}{t}$,其中 $v{t-1}$ 是上一個時刻的指數加權平均,$\theta_{t}$ 表示當前時刻的值,$\beta$ 是係數,一般小於 1。指數加權平均常用於時間序列求平均值。假設現在求得是 100 個時刻的指數加權平均,那麼

$\mathrm{v}{100}=\boldsymbol{\beta} * \boldsymbol{v}{99}+(\mathbf{1}-\boldsymbol{\beta}) * \boldsymbol{\theta}{100}$
$=(\mathbf{1}-\boldsymbol{\beta}) * \boldsymbol{\theta}
{100}+\boldsymbol{\beta} *\left(\boldsymbol{\beta} * \boldsymbol{v}{98}+(\mathbf{1}-\boldsymbol{\beta}) * \boldsymbol{\theta}{99}\right)$
$=(\mathbf{1}-\boldsymbol{\beta}) * \boldsymbol{\theta}{100}+(\mathbf{1}-\boldsymbol{\beta}) * \boldsymbol{\beta} * \boldsymbol{\theta}{99}+\left(\boldsymbol{\beta}^{2} * \boldsymbol{v}_{98} \right)$

$=\sum_{i}^{N}(\mathbf{1}-\boldsymbol{\beta}) * \boldsymbol{\beta}^{i} * \boldsymbol{\theta}_{N-i}$

從上式可以看到,由於 $\beta$ 小於 1,越前面時刻的 $\theta$,$\beta$ 的次方就越大,係數就越小。

$\beta$ 可以理解為記憶周期,$\beta$ 越小,記憶周期越短,$\beta$ 越大,記憶周期越長。通常 $\beta$ 設置為 0.9,那麼 $\frac{1}{1-\beta}=\frac{1}{1-0.9}=10$,表示更關注最近 10 天的數據。

下面程式碼展示了 $\beta=0.9$ 的情況

    weights = exp_w_func(beta, time_list)

    plt.plot(time_list, weights, '-ro', label="Beta: {}\ny = B^t * (1-B)".format(beta))
    plt.xlabel("time")
    plt.ylabel("weight")
    plt.legend()
    plt.title("exponentially weighted average")
    plt.show()

    print(np.sum(weights))

結果為:

下面程式碼展示了不同的 $\beta$ 取值情況

    beta_list = [0.98, 0.95, 0.9, 0.8]
    w_list = [exp_w_func(beta, time_list) for beta in beta_list]
    for i, w in enumerate(w_list):
        plt.plot(time_list, w, label="Beta: {}".format(beta_list[i]))
        plt.xlabel("time")
        plt.ylabel("weight")
    plt.legend()
    plt.show()

結果為:

$\beta$ 的值越大,記憶周期越長,就會更多考慮前面時刻的數值,因此越平緩。

在 PyTroch 中,momentum 的更新公式是:

$v_{i}=m * v_{i-1}+g\left(w_{i}\right)$
$w_{i+1}=w_{i}-l r * v_{i}$

其中 $w_{i+1}$ 表示第 $i+1$ 次更新的參數,lr 表示學習率,$v_{i}$ 表示更新量,$m$ 表示 momentum 係數,$g(w_{i})$ 表示 $w_{i}$ 的梯度。展開表示如下:

$\begin{aligned} \boldsymbol{v}{100} &=\boldsymbol{m} * \boldsymbol{v}{99}+\boldsymbol{g}\left(\boldsymbol{w}{100}\right) \ &=\boldsymbol{g}\left(\boldsymbol{w}{100}\right)+\boldsymbol{m} *\left(\boldsymbol{m} * \boldsymbol{v}{98}+\boldsymbol{g}\left(\boldsymbol{w}{99}\right)\right) \ &=\boldsymbol{g}\left(\boldsymbol{w}{100}\right)+\boldsymbol{m} * \boldsymbol{g}\left(\boldsymbol{w}{99}\right)+\boldsymbol{m}^{2} * \boldsymbol{v}{98} \ &=\boldsymbol{g}\left(\boldsymbol{w}{100}\right)+\boldsymbol{m} * \boldsymbol{g}\left(\boldsymbol{w}{99}\right)+\boldsymbol{m}^{2} * \boldsymbol{g}\left(\boldsymbol{w}{98}\right)+\boldsymbol{m}^{3} * \boldsymbol{v}_{97} \end{aligned}$

下面的程式碼是構造一個損失函數 $y=(2x)^{2}$,$x$ 的初始值為 2,記錄每一次梯度下降並畫圖,學習率使用 0.01 和 0.03,不適用 momentum。

    def func(x):
        return torch.pow(2*x, 2)    # y = (2x)^2 = 4*x^2        dy/dx = 8x

    iteration = 100
    m = 0     # .9 .63

    lr_list = [0.01, 0.03]

    momentum_list = list()
    loss_rec = [[] for l in range(len(lr_list))]
    iter_rec = list()

    for i, lr in enumerate(lr_list):
        x = torch.tensor([2.], requires_grad=True)

        momentum = 0. if lr == 0.03 else m
        momentum_list.append(momentum)

        optimizer = optim.SGD([x], lr=lr, momentum=momentum)

        for iter in range(iteration):

            y = func(x)
            y.backward()

            optimizer.step()
            optimizer.zero_grad()

            loss_rec[i].append(y.item())

    for i, loss_r in enumerate(loss_rec):
        plt.plot(range(len(loss_r)), loss_r, label="LR: {} M:{}".format(lr_list[i], momentum_list[i]))
    plt.legend()
    plt.xlabel('Iterations')
    plt.ylabel('Loss value')
    plt.show()

結果為:

可以看到學習率為 0.3 時收斂更快。然後我們把學習率為 0.1 時,設置 momentum 為 0.9,結果如下:

1593601633017

雖然設置了 momentum,但是震蕩收斂,這是由於 momentum 的值太大,每一次都考慮上一次的比例太多,可以把 momentum 設置為 0.63 後,結果如下:

可以看到設置適當的 momentum 後,學習率 0.1 的情況下收斂更快了。

下面介紹 PyTroch 所提供的 10 種優化器。

PyTroch 提供的 10 種優化器

optim.SGD

optim.SGD(params, lr=<required parameter>, momentum=0, dampening=0, weight_decay=0, nesterov=False

隨機梯度下降法

主要參數:

  • params:管理的參數組
  • lr:初始學習率
  • momentum:動量係數 $\beta$
  • weight_decay:L2 正則化係數
  • nesterov:是否採用 NAG

optim.Adagrad

自適應學習率梯度下降法

optim.RMSprop

Adagrad 的改進

optim.Adadelta

optim.Adam

RMSProp 集合 Momentum,這個是目前最常用的優化器,因為它可以使用較大的初始學習率。

optim.Adamax

Adam 增加學習率上限

optim.SparseAdam

稀疏版的 Adam

optim.ASGD

隨機平均梯度下降

optim.Rprop

彈性反向傳播,這種優化器通常是在所有樣本都一起訓練,也就是 batchsize 為全部樣本時使用。

optim.LBFGS

BFGS 在記憶體上的改進

參考資料

如果你覺得這篇文章對你有幫助,不妨點個贊,讓我有更多動力寫出好文章。

Tags: