【動手學深度學習】第三章筆記:線性迴歸、SoftMax 迴歸、交叉熵損失

bringlu發表於2023-04-09

這章感覺沒什麼需要特別記住的東西,感覺忘了回來翻一翻程式碼就好。

3.1 線性迴歸

3.1.1 線性迴歸的基本元素

1. 線性模型

\(\boldsymbol{x}^{(i)}\) 是一個列向量,表示第 \(i\) 個樣本。用符號標識的矩陣 \(\boldsymbol{X} \in \mathbb{R}^{n\times d}\) 可以很方便地引用整個資料集中的 \(n\) 個樣本。其中 \(\boldsymbol{X}\) 的每一行是一個樣本,每一列是一種特徵。

對於特徵集合 \(\boldsymbol{X}\),預測值 \(\hat{\boldsymbol{y}} \in \mathbb{R}^n\) 可以透過矩陣-向量乘法表示為

\[\hat{\boldsymbol{y}} = \boldsymbol{Xw} + b \]

然後求和的過程使用廣播機制。另外,即使確信特徵與標籤的潛在關係是線性的,也會加入一個噪聲項以考慮觀測誤差帶來的影響。

2. 損失函式

這裡採用的損失函式為平方誤差函式。當樣本 \(i\) 的預測值為 \(\hat{y}^{(i)}\),其相應的真實標籤為 \(y^{(i)}\) 時,平方誤差可以定義為:

\[l^{(i)}(\boldsymbol{w}, b) = \frac{1}{2} (\hat{y}^{(i)}-y^{(i)})^2 \]

這裡的係數 \(\frac{1}{2}\) 的目的是為了求導後常數為 \(1\)

因此,整個資料集上的損失均值為:

\[L(\boldsymbol{w}, b) = \frac{1}{n} \sum_{i=1}^n l^{(i)}(\boldsymbol{w}, b) = \frac{1}{n} \sum_{i=1}^n \frac{1}{2} (\boldsymbol{w}^T \boldsymbol{x}^{(i)} + b - y^{(i)})^2 \]

最後在訓練模型時要找一組引數 \((\boldsymbol{w}^*, b^*)\) ,最小化總損失,即如:

\[\boldsymbol{w}^*, b^* = {\arg \min}_{\boldsymbol{w}, b} L(\boldsymbol{w}, b) \]

3. 解析解

這裡原書寫的不是很清楚。具體合併大概是

\[\boldsymbol{X} \leftarrow \begin{bmatrix} x_{11} & x_{12} & \cdots & x_{1d} & 1\\ x_{21} & x_{22} & \cdots & x_{2d} & 1\\ \vdots & \vdots & \ddots & \vdots & \vdots \\ x_{n1} & x_{n2} & \cdots & x_{nd} & 1 \end{bmatrix} , ~~ \boldsymbol{w} \leftarrow \begin{bmatrix} w_1 \\ w_2 \\ \vdots \\ w_d \\ b \end{bmatrix} \]

是這樣合併的,然後問題就轉化為最小化 \(||\boldsymbol{y} - \boldsymbol{Xw}||^2\),然後令損失關於 \(\boldsymbol{w}\) 的導數設為 \(0\),那麼有解析解:

\[\boldsymbol{w}^* = (\boldsymbol{X}^T\boldsymbol{X})^{-1} \boldsymbol{X}^T\boldsymbol{y} \]

當然了,一般的深度學習問題也沒有解析解能給你求出來(233)。

4. 隨機梯度下降

在每次需要計算更新的時候隨機抽取一小批樣本,這種變體叫做小批次隨機梯度下降(minibatch stochastic gradient descent)。

大致可以寫作如下公式:

\[(\boldsymbol{w},b) \leftarrow (\boldsymbol{w}, b) - \frac{\eta}{|B|} \sum_{i \in B} \partial_{(\boldsymbol{w}, b)} l^{(i)} (\boldsymbol{w}, b) \]

其中,\(|B|\) 是 batch size,\(\eta\) 是學習率。

5. 用模型進行預測

由於在統計學中,推斷(inference)更多地表示基於資料集估計引數,所以請儘量將給定特徵的情況下估計目標的過程稱為預測

3.1.2 向量化加速

先定義一下 Timer 類,這個類可以丟進小本本里。

class Timer:
    def __init__(self):
        self.times = []
        self.start()
    
    def start(self):
        self.tik = time.time()
    
    def stop(self):
        self.times.append(time.time() - self.tik)
        return self.times[-1]
    
    def avg(self):
        return sum(self.times) / len(self.times)
    
    def sum(self):
        return sum(self.times)
    
    def cumsum(self):
        return np.array(self.times).cumsum().tolist()

然後用下面的 python 迴圈加法和 tensor 的向量加法比較。可以發現,儘量使用 PyTorch 向量化後的 tensor 進行運算。不得不感慨一下 python 是真的慢啊,即使是 tensor 加法也還要比 C++ 慢(tensor 的基礎運算應該就是拿 C++ 實現的)。這也側面證明向量加法在 CPU 環境下應該沒有涉及到應用多核。

n = 10000
a = torch.ones(n)
b = torch.ones(n)

c = torch.zeros(n)
timer = Timer()
for i in range(n):
    c[i] = a[i] + b[i]
f'{timer.stop():.5f} sec'
# '0.09908 sec'

timer.start()
d = a + b 
f'{timer.stop():.5f} sec'
'0.00035 sec'

3.1.3 正態分佈與平方損失

正態分佈機率密度函式如下:

\[p(x) = \frac{1}{\sqrt{2\pi \sigma^2}} \exp\left(-\frac{1}{2\sigma^2}(x-\mu)^2\right) \]

然後本書假設觀測中包含的噪聲服從正態分佈。噪聲正態分佈如:\(y = \boldsymbol{w}^T\boldsymbol{x} + b + \epsilon\),其中,\(\epsilon \sim N(0, \sigma^2)\)

透過給定 \(\boldsymbol{x}\) 觀測到特定的 \(y\) 的似然為:

\[P(y|\boldsymbol{x}) = \frac{1}{\sqrt{2\pi\sigma^2}} \exp \left(- \frac{1}{2\sigma^2}(y-\boldsymbol{w}^T\boldsymbol{x}-b)^2\right) \]

那麼引數 \(\boldsymbol{w}\) 和 b 的最優值是使整個資料集的似然最大的值:

\[p(\boldsymbol{y} | \boldsymbol{X}) = \prod_{i=1}^n p(y^{(i)} | \boldsymbol{x}^{(i)}) \]

等價於最小化負對數似然:

\[-\log P(\boldsymbol{y} |\boldsymbol{X}) = \sum_{i=1}^n \frac{1}{2} \log (2\pi \sigma^2) + \frac{1}{2\sigma^2}(y^{(i)} - \boldsymbol{w}^T\boldsymbol{x}^{(i)} - b)^2 \]

要讓上式最小,即讓最後一項最小。因此在有高斯噪聲的假設下,最小化均方誤差等價於對線性模型的極大似然估計。

3.2 線性迴歸的從零開始實現

這節就不詳細寫了,有一些程式碼裡感覺有意思的點寫在這裡好了。

  • 假如說有一個 numpy 陣列或者 PyTorch 的向量 \(\boldsymbol{x}\),那麼可以用 y = x[torch.tensr([1, 2, 5])] 來獲得一個只包含 \(x_1, x_2, x_5\)\(\boldsymbol{y}\)

最佳化演演算法程式碼如下:

def sgd(params, lr, batch_size):
    with torch.no_grad():
        for param in params:
            param -= lr * param.grad / batch_size
            param.grad.zero_()

也就是說本質上是讓它在不計算梯度的情況下,更新 param,然後讓它的梯度更新成零。

訓練用的程式碼為:

lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss
for epoch in range(num_epochs):
    for X, y in data_iter(batch_size, features, labels):
        l = loss(net(X, w, b), y)
        l.sum().backward()
        sgd([w, b], lr, batch_size)
    with torch.no_grad():
        train_l = loss(net(features, w, b), labels)
        print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

這裡有一大堆沒定義的東西在書中前文提到了,但是我這裡僅提供訓練程式碼僅供示意。

  • linreg(X, w, b) 表示用 \(\boldsymbol{X}, \boldsymbol{w}, b\) 計算 \(\hat{\boldsymbol{y}}\)
  • squared_loss(y_hat, y) 為均方損失
  • featureslabels 是資料。
  • data_iter 是一個生成器,負責迭代 batch_size 大小的資料。

練習中問了個很有趣的問題:

如果將權重初始化為零,會發生什麼?演演算法仍然有效嗎?

參考文章 談談神經網路權重為什麼不能初始化為0,來回答這一問題。

  1. 如同書中只有一層線性層的時候:

    由於

    \[\hat{\boldsymbol{y}} = \boldsymbol{Xw} + b \\ l^{(i)}(\boldsymbol{w}, b) = \frac{1}{2} (\hat{y}^{(i)}-y^{(i)})^2 \\ L = \sum_{i=1}^n l^{(i)}(\boldsymbol{w}, b) \]

    代入,有

    \[L = \sum_{i=1}^n l^{(i)}(\boldsymbol{w}, b) = \sum_{i=1}^n \frac{1}{2} (\hat{y}^{(i)}-y^{(i)})^2 = \sum_{i=1}^n \frac{1}{2} (\boldsymbol{x}^{(i)^T} \boldsymbol{w} + b -y^{(i)})^2 \]

    求導數,有

    \[\frac{\partial L}{\partial w_0} = \sum_{i=1}^n x_0^{(i)} (x_0^{(i)}w_0 + x_1^{(i)}w_1 + b - y^{(i)}) \\ \frac{\partial L}{\partial w_1} = \sum_{i=1}^n x_1^{(i)} (x_0^{(i)}w_0 + x_1^{(i)}w_1 + b - y^{(i)}) \\ \]

    那麼在第一次求導的時候,得

    \[\frac{\partial L}{\partial w_0} = \sum_{i=1}^n x_0^{(i)} (x_0^{(i)}w_0 + x_1^{(i)}w_1 + b - y^{(i)}) = \sum_{i=1}^n x_0^{(i)} (b-y^{(i)}) \\ \frac{\partial L}{\partial w_1} = \sum_{i=1}^n x_1^{(i)} (x_0^{(i)}w_0 + x_1^{(i)}w_1 + b - y^{(i)}) = \sum_{i=1}^n x_1^{(i)} (b-y^{(i)}) \\ \]

    那麼,導數不為 \(0\),就確實對於演演算法沒有影響。

  2. 但是如果不是隻有一層線性層的話:

    不妨假設此時有兩層線性層。第一層 \(\boldsymbol{W^{(0)} \in \mathbb{R}^{2 \times 2}}\),第二層 \(\boldsymbol{W}^{(1)} \in \mathbb{R}^2\)。過第一個線性層後輸出的值就與輸入 \(\boldsymbol{X}\) 無關了,那麼再過第二個線性層後得到的結果就僅與第一個線性層的偏置以及第二個線性層有關了。那麼,第二個線性層的權重關於第一個線性層權重的 Jacobi 矩陣是什麼樣子的呢?由於 \(y_{i} = (\sum_{j=1}^n w_{ij} x_j) + b\),因此 \(\frac{\mathrm{d}y_i}{\mathrm{d}x_j} = w_{ij} = 0\)。所以該矩陣是 \(0\) 矩陣,因此無法更新第一個線性層。然後又由於過了第一個線性層就與輸入 \(\boldsymbol{X}\) 無關,所以權重不可以初始化為 \(0\)

3.3 線性迴歸的簡潔實現

仍然不全抄,只寫一些有趣的程式碼放在這裡。

torch.utils.data.DataLoader 返回的是一個可迭代物件(Iterable)而不是一個迭代器(Iterator)。

可以用 iter() 函式構造 Python 迭代器,並使用 next() 函式從迭代器中獲取第一項。如下所示:

next(iter(torch.utils.data.DataLoader(dataset, batch_size, shuffle=is_train)))

然後用 net = nn.Sequential(nn.Linear(2, 1)) 得到模型,那麼,除了新寫一個類初始化引數外,怎麼初始化第一層的引數呢?透過 net[0] 選擇網路中第一層,然後使用 weight.databias.data 方法來訪問引數。

net[0].weighttorch.nn.parameter.Parameter 類的例項,這個類很有趣。一種被視為模組引數的張量。Parameter 是 Tensor 的子類,當與 Module 類一起使用時具有非常特殊的屬性:當 Parameter 類被分配為 Module 類的屬性時,它們會自動新增到其引數列表中,並將出現在例如在 parameters() 迭代器中。但是分配一個 Tensor 就沒有這樣的效果。呼叫 net[0].weight 返回:

net[0].weight
# Parameter containing:
# tensor([[-0.3418, -0.5904]], requires_grad=True)

呼叫 net[0].weight.data 返回 tensor,這說明它們僅僅是張量:

net[0].weight.data
# tensor([[-0.3418, -0.5904]])

還可以使用替換方法 normal_fill_ 來重寫引數值。這兩個方法是在 torch.tensor() 裡面的方法。

net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)

損失函式 nn.MSELoss() 及最佳化演演算法 torch.optim.SGD(net.parameters(), lr=0.03)。這裡 net.parameters() 是個生成器,同時也是一個特殊的迭代器。輸出一下它:

loss = nn.MSELoss()
trainer = torch.optim.SGD(net.parameters(), lr=0.03)
print(net.parameters())
# <generator object Module.parameters at 0x716c1cf61150>

下面是訓練程式碼,這段程式碼先把梯度清零再做反向傳播,證明把梯度清零不會把 Jacobi 矩陣之類的中間狀態清理掉,實踐中極其不推薦像書中這麼寫,最好還是在求 loss 之前就清理梯度:

num_epochs = 3
for epoch in range(num_epochs):
    for X, y in data_iter:
        l = loss(net(X), y)
        trainer.zero_grad()
        l.backward()
        trainer.step()
    l = loss(net(features), labels)
    print(f'epoch {epoch + 1}, loss {l:f}')
"""
epoch 1, loss 0.000226
epoch 2, loss 0.000103
epoch 3, loss 0.000103
"""

3.4 softmax 迴歸

希望在對硬性類別分類的同時使用軟性帶有機率的模型。

3.4.1 模型

本章介紹了表示分類資料的簡單方法:獨熱編碼(one-hot encoding)。獨熱編碼是一個向量,它的分量和類別一樣多。類別對應的分量設定為 \(1\),其他所有分量設定為 \(0\)

本節的網路架構仍為線性層,這裡有和輸出一樣多的仿射函式,向量形式記作 \(\boldsymbol{o} = \boldsymbol{Wx} + \boldsymbol{b}\)。具有 \(d\) 個輸入和 \(q\) 個輸出的全連線層,引數開銷為 \(O(dq)\),但是論文 Beyond Fully-Connected Layers with Quaternions: Parameterization of Hypercomplex Multiplications with \(\frac{1}{n}\) Parameters 提及可以將具有 \(d\) 個輸入和 \(q\) 個輸出的全連線層的成本減少到 \(O(dq/n)\),其中超引數 \(n\) 可以設定,以在實際應用中在引數節省和模型有效性之間進行平衡。(完全沒讀這論文說了啥233)

Softmax 函式可以表示為:

\[\hat{\boldsymbol{y}} = \text{softmax} (\boldsymbol{o}) \]

其中,

\[\hat{y_j} = \frac{\exp(o_j)}{\sum_k \exp(o_k)} \]

文中說,儘管 softmax 是一個非線性函式,但 softmax 迴歸的輸出仍然由輸入特徵的仿射變換決定。因此,softmax 迴歸是一個線性模型(linear model)。

3.4.2 損失函式

1. 對數似然

假設資料集 \(\{ \boldsymbol{X}, \boldsymbol{Y}\}\) 具有 \(n\) 個樣本,其中索引 \(i\) 的樣本由特徵向量 \(\boldsymbol{x}^{(i)}\) 和獨熱標籤向量 \(\boldsymbol{y}^{(i)}\) 組成。因此可以將估計值與實際值比較:

\[p(\boldsymbol{Y} | \boldsymbol{X}) = \prod_{i=1}^n p(\boldsymbol{y}^{(i)} | \boldsymbol{x}^{(i)}) \]

最大化似然仍然是等價於熟悉的最小化負對數似然:

\[-\log P(\boldsymbol{Y} |\boldsymbol{X}) = \sum_{i=1}^n -\log P(\boldsymbol{y}^{(i)} | \boldsymbol{x}^{(i)}) = \sum_{i=1}^n l(\boldsymbol{y}^{(i)}, \hat{\boldsymbol{y}}^{(i)}) \]

其中,對於任何標籤 \(\boldsymbol{y}\) 和模型預測 \(\hat{\boldsymbol{y}}\),損失函式為:

\[l(\boldsymbol{y}, \hat{\boldsymbol{y}}) = - \sum_{j=1}^q y_j \log \hat{y}_j \]

上式一般被稱為交叉熵損失。

2. softmax 及其導數

利用 softmax 定義可得:

\[l(\boldsymbol{y}, \hat{\boldsymbol{y}}) = - \sum_{j=1}^q y_j \log \frac{\exp (o_j)}{\sum_{k=1}^q \exp (o_k)} \\ = \sum_{j=1}^q y_j \log \sum_{k=1}^q \exp(o_k) - \sum_{j=1}^q y_j o_j \\ = \log \sum_{k=1}^q \exp(o_k) - \sum_{j=1}^q y_j o_j \]

考慮相對於任何未規範化的預測 \(o_j\) 的導數,可以得到:

\[\partial_{o_j} l(\boldsymbol{y}, \hat{\boldsymbol{y}}) = \frac{\exp(o_j)}{\sum_{k=1}^q \exp(o_k)} - y_j = \text{softmax}(\boldsymbol{o})_j - y_j = \hat{y}_j - y_j \]

不妨設 \(s_i = \text{softmax} (\boldsymbol{o})_i\),再求二階導:

\[\frac{\partial l(\boldsymbol{y}, \hat{\boldsymbol{y}})}{\partial o_i \partial o_j} = \frac{\partial s_i}{\partial o_j} = \begin{cases} s_i (1 - s_i),& i=j \\ -s_j s_i, & i \not = j \end{cases} \]

課後題還要求 \(\text{softmax} (\boldsymbol{o})\) 給出的分佈方差,並和二階導匹配起來,所以有

\[\begin{align} var(\boldsymbol{o}) &= \frac{1}{q-1} \sum_{j=1}^q (s_j - \overline{s})^2 \\ &= \frac{1}{q-1} [s_1^2 + s_2^2 + \cdots + s_q^2 + \overline{s}^2 * q - 2\overline{s}(s_1+s_2+\cdots+s_q)] \\ &= \frac{1}{q-1} \left( \sum_{j=1}^q s_j^2 - 2 \overline{s} \sum_{j=1}^q s_j\right) + \frac{q}{q-1} \overline{s}^2 \\ &= \frac{1}{q-1} \left( \sum_{j=1}^q s_j(s_j - 1) + \sum_{j=1}^q s_j - 2 \overline{s} \sum_{j=1}^q s_j \right) + \frac{q}{q-1} \overline{s}^2 \\ &= - \frac{1}{q-1} \sum_{j=1}^q \frac{\partial^2 l}{\partial o_j^2} + \frac{q}{q-1} (\overline{s} - \overline{s}^2 ) \\ &\approx - \frac{1}{q} \sum_{j=1}^q \frac{\partial^2 l}{\partial o_j^2} + (\overline{s} - \overline{s}^2 ) \end{align} \]

上面式子裡除以 \(q-1\) 是符合統計學中無偏估計的做法。當然和除以 \(q\) 差別也不太大。

3. 資訊理論淺談

資訊理論的基本想法是一個不太可能的事件居然發生了,要比一個非常可能的事件發生,能提供更多的資訊。如果要透過這種基本想法來量化資訊,可以遵循以下三個點:

  • 非常可能發生的事件資訊量要比較少,並且極端情況下,確保能夠發生的事件應該沒有資訊量。
  • 較不可能發生的事件具有更高的資訊量。
  • 獨立事件應具有增量的資訊。例如,投擲的硬幣兩次正面朝上傳遞的資訊量,應該是投擲一次硬幣正面朝上的資訊量的兩倍。

為了滿足上述 \(3\) 個性質,因此定義一個事件 \(x\) 的自資訊(self-information)為

\[I(x) = -\log P(x) \]

這裡定義的 \(I(x)\) 單位是奈特(nat)。一奈特是以 \(\frac{1}{e}\) 的機率觀測到一個事件時獲得的資訊量。

自資訊只處理單個的輸出。可以用夏農熵對整個機率分佈中不確定性總量進行量化:

\[H(x) = \mathbb{E}_{x\sim P} [I(x)] = -\mathbb{E}_{x\sim P} [\log P(x)] \]

這個也可以記作 \(H(P)\)。一個分佈的夏農熵是指遵循這個分佈的事件所產生的期望資訊總量。

如果對同一個隨機變數 \(x\) 有兩個單獨的機率分佈 \(P(x)\)\(Q(x)\),可以使用KL散度(Kullback-Leibler divergence)來衡量這兩個分佈的差異:

\[D_{KL}(P||Q) = \mathbb{E}_{x \sim P} \left[\log \frac{P(x)}{Q(x)} \right] = \mathbb{E}_{x \sim P} [\log P(x) - \log Q(x)] \]

在離散型變數的情況下,KL 散度衡量的是,當使用一種被設計成能夠使得機率分佈 \(Q\) 產生的訊息的長度最小的編碼,傳送包含由機率分佈 \(P\) 產生的符號的訊息時,所需要的額外資訊量。

KL 散度有一些有用的性質如下:

  • 非負
  • KL 散度為 \(0\),當且僅當 \(P\)\(Q\) 在離散性變數的情況下是相同的分佈,或者在連續型變數的情況下是“幾乎處處”相同的。

由於上述兩個性質,因此它經常被用作分佈之間的某種距離。然而,它並不滿足交換性,即 \(D_{KL}(P||Q) \not = D_{KL}(Q||P)\)

假設此時有一個分佈 \(p(x)\),並且希望用另一個分佈 \(q(x)\) 來近似它,那麼就可以選擇最小化 \(D_{KL}(p||q)\) 或者最小化 \(D_{KL}(q||p)\)。這其中選擇哪一個 KL 散度是取決於問題的。選擇 \(D_{KL}(p||q)\) 的目的是為了讓近似分佈 \(q\) 在真實分佈 \(p\) 放置高機率的所有地方都放置高機率,而選擇 \(D_{KL}(q||p)\) 的目的是為了讓近似分佈 \(q\) 在真實分佈 \(p\) 放置低機率的所有地方都很少放置高機率。

一個和 KL 散度密切聯絡的量是交叉熵,即 \(H(P,Q) = H(P) + D_{KL}(P||Q) = -\mathbb{E}_{x \sim P} \log Q(x)\)。因此針對 \(Q\) 最小化交叉熵等價於最小化 KL 散度。

3.5 影像分類資料集

本章其實沒啥亮點,有趣的內容稍微寫一下:

一個用來展示圖片以及標題的函式,有 num_rows 行 num_cols 列。

def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5):
    figsize = (num_cols * scale, num_rows * scale)
    _, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize)
    axes = axes.flatten()
    for i, (ax, img) in enumerate(zip(axes, imgs)):
        if torch.is_tensor(img):
            ax.imshow(img.numpy())
        else:
            ax.imshow(img)
        ax.axes.get_xaxis().set_visible(False)
        ax.axes.get_yaxis().set_visible(False)
        if titles:
            ax.set_title(titles[i])
    return axes

此外,num_workers 這個引數列示了使用子程式讀取資料的個數。如果調小 batch_size 的話即使是 CPU 執行的程式碼速度也會減慢,在 num_workers=4 的時候,測試時間長度如下表:

batch_size 時間
1 117.74
4 28
256 3.11

3.6 softmax 迴歸的從零開始實現

仍然是有趣的內容:

torch.normal() 能夠返回一個其中所有值都符合正態分佈的 tensor。

Accumulator 類對多個變數進行累加。

class Accumulator:
    def __init__(self, n):
        self.data = [0.0] * n

    def add(self, *args):
        self.data = [a + float(b) for a, b in zip(self.data, args)]

    def reset(self):
        self.data = [0.0] * len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

還有一個可以在動畫中繪製圖表的實用程式類 Animator。此函式僅能在 notebook 中使用。

import torch
from IPython import display
from d2l import torch as d2l
class Animator:  #@save
    """在動畫中繪製資料"""
    def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None,
                 ylim=None, xscale='linear', yscale='linear',
                 fmts=('-', 'm--', 'g-.', 'r:'), nrows=1, ncols=1,
                 figsize=(3.5, 2.5)):
        # 增量地繪製多條線
        if legend is None:
            legend = []
        d2l.use_svg_display()
        self.fig, self.axes = d2l.plt.subplots(nrows, ncols, figsize=figsize)
        if nrows * ncols == 1:
            self.axes = [self.axes, ]
        # 使用lambda函式捕獲引數
        self.config_axes = lambda: d2l.set_axes(
            self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
        self.X, self.Y, self.fmts = None, None, fmts

    def add(self, x, y):
        # 向圖表中新增多個資料點
        if not hasattr(y, "__len__"):
            y = [y]
        n = len(y)
        if not hasattr(x, "__len__"):
            x = [x] * n
        if not self.X:
            self.X = [[] for _ in range(n)]
        if not self.Y:
            self.Y = [[] for _ in range(n)]
        for i, (a, b) in enumerate(zip(x, y)):
            if a is not None and b is not None:
                self.X[i].append(a)
                self.Y[i].append(b)
        self.axes[0].cla()
        for x, y, fmt in zip(self.X, self.Y, self.fmts):
            self.axes[0].plot(x, y, fmt)
        self.config_axes()
        display.display(self.fig)
        display.clear_output(wait=True)

這個類應該怎麼用呢?見下方程式碼

animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0.3, 0.9],
                    legend=['train loss', 'train acc', 'test acc'])

for epoch in range(num_epochs):
    train_metrics = (train_loss, train_acc)
    animator.add(epoch + 1, train_metrics + (test_acc,))

3.7 softmax 迴歸的簡潔實現

如何在類外給所有線性層初始化?可以使用 nn.Module.apply(fn) 可以做到。它的本來作用是遞迴地對所有子模組(包括自己)做相同的操作。如:

net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights)

即對 net 中所有 Linear 層初始化引數。

為防止由於指數函式導致的上溢位,因此再繼續 softmax 運算之前,先從所有 \(o_k\) 中減去 \(\max (o_k)\),事實上這樣不會改變 softmax 的返回值:

\[\begin{align} \hat{y}_j &= \frac{\exp(o_j - max(o_k)) \exp(\max(o_k))}{\sum_k \exp(o_k - \max (o_k))\exp(\max(o_k))} \\ &= \frac{\exp(o_j - max(o_k))}{\sum_k \exp(o_k - \max (o_k))} \end{align} \]

又由於有些 \(\exp(o_j - max(o_k))\) 具有較大的負值,可能導致求完指數函式後直接下溢位歸零,並使得 \(\log(\hat{y}_j)\) 的值變為負無窮大。反向傳播幾步之後,可能會發現滿螢幕的 nan。因此將交叉熵和 softmax 操作結合在一起:

\[\begin{align} \log (\hat{y}_j) &= \log \left( \frac{\exp(o_j - max(o_k))}{\sum_k \exp(o_k - \max (o_k))}\right) \\ &= \log (\exp (o_j - \max (o_k))) - \log \left( \sum_k \exp (o_k - \max (o_k))\right) \\ &= o_j - \max (o_k) - \log \left( \sum_k \exp(o_k - \max (o_k))\right) \end{align} \]

這些具體落實到程式碼上是模型過完最後一個線性層不要做 softmax 操作,直接往 PyTorch 的 CrossEntropyLoss 裡面丟就行了,因為它已經結合好了。

相關文章