智慧型控制 Intelligent Control

遞迴神經網路 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)\) 形式相同。每一步都用同一組權值,把網路沿時間展開,就像一個很深、各層權值相同的前饋網路:

RNNxt​yt​ht−1​=展開RNNxt−1​yt−1​ht−1​RNNxt​yt​ht​RNNxt+1​yt+1​…每一步用同一組權值

訓練時在展開後的網路上做倒傳遞,稱為沿時間倒傳遞(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 核對):

0102030405060-8-6-4-202往回幾步 k梯度大小 ‖∂hₜ/∂hₜ₋ₖ‖(log₁₀)線性 RNN,ρ = 0.9:0.9ᵏ線性 RNN,ρ = 1.1:爆炸tanh RNN,ρ = 1.0tanh RNN,ρ = 1.5LSTM 細胞路徑,f = 0.99隱藏層 16 個神經元、隨機正交權值乘上 ρ(譜半徑)

梯度爆炸可以用梯度裁剪(gradient clipping,梯度範數超過上限就等比例縮小)處理;梯度消失則要從網路結構下手,這就是 LSTM。

自製動畫(依控制理論數值模擬)

長短期記憶 LSTM Long Short-Term Memory

LSTM(Hochreiter 與 Schmidhuber,1997)在隱藏狀態之外多了一條記憶細胞 \(\mathbf c_t\),並用三個「閘」(輸出 0~1 的 sigmoid)控制資訊的進出:

ct−1​ct​記憶細胞(只有乘法與加法,梯度容易傳遞)×+××σf:遺忘閘σi:輸入閘tanhg:候選值σo:輸出閘tanhht​ht−1​xt​(與 h 一起送進四個閘) $$\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
05101520-0.100.8時間(s)位移 x測試一:t = 1 s 起單位步階05101520-101時間(s)位移 x測試二:正弦輸入 1.5 sin 0.8t(訓練沒看過)真實系統(RK4 模擬)RNN(16)LSTM(16)

同樣 16 個隱藏神經元,LSTM 的測試誤差約為單純 RNN 的十分之一,預測曲線幾乎與真實系統重合,連訓練時沒看過的正弦輸入也預測得很準。這樣學出來的網路模型可以拿來設計控制器或做模型預測控制(MPC),詳見後續「神經網路用於控制」章。