遞迴神經網路 RNN 與 LSTM
控制系統處理的大多是時間序列:輸入與輸出隨時間變化,而且現在的輸出取決於過去的輸入(系統有記憶,也就是有「狀態」)。前饋網路每次只看一個固定大小的輸入,處理不了這種情形。遞迴神經網路(recurrent neural network,RNN)在網路裡加一個會一直傳下去的隱藏狀態,正好對應動態系統的狀態。
遞迴網路 Recurrent Networks
$$\mathbf h_t = \tanh\!\left(\mathbf W_h \mathbf h_{t-1} + \mathbf W_x \mathbf x_t + \mathbf b\right),\qquad \mathbf y_t = \mathbf W_y \mathbf h_t + \mathbf b_y$$隱藏狀態 \(\mathbf h_t\) 由前一刻的狀態與這一刻的輸入算出,與離散時間的狀態方程式 \(\mathbf s_{k+1} = f(\mathbf s_k, \mathbf u_k)\) 形式相同。每一步都用同一組權值,把網路沿時間展開,就像一個很深、各層權值相同的前饋網路:
訓練時在展開後的網路上做倒傳遞,稱為沿時間倒傳遞(backpropagation through time,BPTT)。
沿時間的梯度消失與爆炸 Vanishing and Exploding Gradients
由連鎖律,T 時刻的狀態對 k 步之前狀態的梯度,是 k 個雅可比矩陣的乘積:
$$\frac{\partial \mathbf h_T}{\partial \mathbf h_{T-k}} = \prod_{t=T-k+1}^{T} \operatorname{diag}\!\left(1 - \mathbf h_t^2\right)\mathbf W_h$$這與深層網路的梯度消失是同一件事,只是「層」換成了「時間步」,而且每一步乘的都是同一個 \(\mathbf W_h\)。若 \(\mathbf W_h\) 的譜半徑為 \(\rho\):線性 RNN 的梯度大小恰為 \(\rho^k\),\(\rho < 1\) 時指數消失、\(\rho > 1\) 時指數爆炸;tanh 的導數 \(1 - h^2 \le 1\),又讓梯度再縮小一些。下圖是 16 個神經元的實算結果(遞迴權值為隨機正交矩陣乘上 \(\rho\),verify_dl2.py 核對):
- 線性 RNN,ρ = 1.1:往回 60 步,梯度放大 \(1.1^{60} \approx 304\) 倍(爆炸)。
- tanh RNN,ρ = 1:往回 60 步只剩約 \(4 \times 10^{-6}\):一分鐘前(以每步一秒計)的事情幾乎學不到。
- tanh RNN,ρ = 1.5:放大與 tanh 導數的縮小大致抵銷,梯度在 1 附近。但這個平衡很脆弱:同樣的權值,輸入大小 0.5 時往回 60 步放大 17 倍,輸入大小 3 時隱藏狀態更飽和,只剩 \(1.6 \times 10^{-5}\)(可用下方動畫的「輸入大小」滑桿試試)。
梯度爆炸可以用梯度裁剪(gradient clipping,梯度範數超過上限就等比例縮小)處理;梯度消失則要從網路結構下手,這就是 LSTM。
長短期記憶 LSTM Long Short-Term Memory
LSTM(Hochreiter 與 Schmidhuber,1997)在隱藏狀態之外多了一條記憶細胞 \(\mathbf c_t\),並用三個「閘」(輸出 0~1 的 sigmoid)控制資訊的進出:
$$\begin{aligned} \mathbf f_t &= \sigma(\mathbf W_f[\mathbf h_{t-1}, \mathbf x_t] + \mathbf b_f) &&\text{遺忘閘:保留多少舊記憶}\\ \mathbf i_t &= \sigma(\mathbf W_i[\mathbf h_{t-1}, \mathbf x_t] + \mathbf b_i) &&\text{輸入閘:寫入多少新資訊}\\ \mathbf g_t &= \tanh(\mathbf W_g[\mathbf h_{t-1}, \mathbf x_t] + \mathbf b_g) &&\text{候選的新資訊}\\ \mathbf o_t &= \sigma(\mathbf W_o[\mathbf h_{t-1}, \mathbf x_t] + \mathbf b_o) &&\text{輸出閘:讀出多少}\\ \mathbf c_t &= \mathbf f_t \odot \mathbf c_{t-1} + \mathbf i_t \odot \mathbf g_t,\qquad \mathbf h_t = \mathbf o_t \odot \tanh(\mathbf c_t) \end{aligned}$$關鍵在記憶細胞的更新只有「乘上遺忘閘、再加上新資訊」:沿著細胞往回傳的梯度是 \(\partial \mathbf c_t / \partial \mathbf c_{t-1} = \operatorname{diag}(\mathbf f_t)\),沒有 tanh 導數,也沒有反覆乘上同一個權值矩陣。只要網路學會讓遺忘閘接近 1,梯度就能保留很多步:f = 0.99 時往回 60 步仍有 \(0.99^{60} \approx 0.55\)(上圖綠線)。這與 ResNet 的捷徑是同樣的想法。
(補充)GRU(2014)把遺忘閘與輸入閘合併、省掉獨立的記憶細胞,參數較少,效果通常與 LSTM 相近。
範例:用 LSTM 學一個動態系統 System Identification
拿模糊控制章的質量–彈簧–阻尼系統(m = 1、c = 1、k = 2)當成「不知道模型的受控體」:只給網路輸入力 F(t) 與量到的位移 x(t),讓它自己學出輸入到輸出的關係。訓練資料是 40 段 20 秒、每 1~3 秒隨機換一個力的資料;測試則用訓練時沒出現過的步階與正弦輸入:
# 用遞迴網路做系統鑑別:只看輸入力 F(t) 與位移 x(t) 的資料,學出 MCK 系統(m = 1、c = 1、k = 2,同模糊控制章)
import json
import math
from pathlib import Path
import torch
from torch import nn
DT, N = 0.05, 400 # 取樣 0.05 s,每段 20 s
def simulate(F):
"""m ẍ + c ẋ + k x = F,RK4、每個取樣區間內 F 固定"""
x = v = 0.0
out = []
for f in F:
out.append(x)
acc = lambda x_, v_: f - 1.0 * v_ - 2.0 * x_
k1 = (v, acc(x, v)); k2 = (v + DT / 2 * k1[1], acc(x + DT / 2 * k1[0], v + DT / 2 * k1[1]))
k3 = (v + DT / 2 * k2[1], acc(x + DT / 2 * k2[0], v + DT / 2 * k2[1])); k4 = (v + DT * k3[1], acc(x + DT * k3[0], v + DT * k3[1]))
x += DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]); v += DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
return out
def random_input(g):
"""訓練用輸入:每 1~3 秒隨機換一個 −2~2 的定值"""
F, level, left = [], 0.0, 0
for _ in range(N):
if left == 0:
level, left = float(4 * torch.rand(1, generator=g) - 2), int(torch.randint(20, 61, (1,), generator=g))
F.append(level); left -= 1
return F
g = torch.Generator().manual_seed(0)
Ftr = [random_input(g) for _ in range(40)]
U = torch.tensor(Ftr).unsqueeze(-1) # 形狀(40 段, 400 步, 1)
Y = torch.tensor([simulate(F) for F in Ftr]).unsqueeze(-1)
tests = {"step": [0.0] * 20 + [1.0] * (N - 20), # t = 1 s 起單位步階(同模糊章)
"sine": [math.sin(0.8 * k * DT) * 1.5 for k in range(N)]} # 訓練時沒出現過的正弦輸入
class SeqNet(nn.Module):
def __init__(self, cell):
super().__init__()
self.rnn = {"RNN": nn.RNN, "LSTM": nn.LSTM}[cell](1, 16, batch_first=True)
self.out = nn.Linear(16, 1)
def forward(self, u):
h, _ = self.rnn(u) # 每一步的隱藏狀態
return self.out(h) # 每一步預測一個 x
result = {"t": [k * DT for k in range(N)], "true": {k: simulate(F) for k, F in tests.items()}}
for cell in ("RNN", "LSTM"):
torch.manual_seed(0)
net = SeqNet(cell)
opt = torch.optim.Adam(net.parameters(), lr=0.01)
for epoch in range(800):
opt.zero_grad()
loss = nn.functional.mse_loss(net(U), Y)
loss.backward() # 沿時間倒傳遞(BPTT)
nn.utils.clip_grad_norm_(net.parameters(), 1.0) # 梯度裁剪:避免梯度爆炸
opt.step()
with torch.no_grad():
result[cell] = {}
msg = []
for k, F in tests.items():
pred = net(torch.tensor(F).view(1, N, 1)).view(-1)
rmse = (pred - torch.tensor(result["true"][k])).pow(2).mean().sqrt().item()
result[cell][k] = pred.tolist()
msg.append(f"{k} 測試 RMSE {rmse:.4f}")
print(f"{cell:4s}:訓練 mse {loss.item():.2e}," + ",".join(msg))
Path(__file__).with_name("mck_lstm.json").write_text(json.dumps(result), encoding="utf-8") # 給網頁的圖使用⬇ 下載 mck_lstm.py(Python 3+PyTorch)
實際執行的輸出:
RNN :訓練 mse 2.25e-02,step 測試 RMSE 0.0626,sine 測試 RMSE 0.1344
LSTM:訓練 mse 2.90e-04,step 測試 RMSE 0.0066,sine 測試 RMSE 0.0141
同樣 16 個隱藏神經元,LSTM 的測試誤差約為單純 RNN 的十分之一,預測曲線幾乎與真實系統重合,連訓練時沒看過的正弦輸入也預測得很準。這樣學出來的網路模型可以拿來設計控制器或做模型預測控制(MPC),詳見後續「神經網路用於控制」章。