智慧型控制 Intelligent Control

神經網路系統鑑別

系統鑑別(system identification)是只用量測的輸入、輸出資料,建立受控體的動態模型。這一頁以模糊控制章的水槽為例,假裝不知道它的方程式,只記錄「閥開度 → 水位」的資料,比較線性 ARX 模型與神經網路 NARX 模型學得如何。

NARX 模型 Nonlinear AutoRegressive with eXogenous input

離散時間的動態系統,下一刻的輸出由過去的輸出與輸入決定。取過去 \(n_y\) 個輸出、\(n_u\) 個輸入當作迴歸向量:

$$\hat y(k+1) = f\big(y(k), \ldots, y(k-n_y+1),\ u(k), \ldots, u(k-n_u+1)\big)$$

\(f\) 是線性函數時就是傳統的 ARX 模型:

$$\hat y(k+1) = a_1 y(k) + a_2 y(k-1) + b_1 u(k) + b_2 u(k-1) + c$$

係數可以用最小平方法一次解出。\(f\) 用類神經網路表示時稱為 NARX,可以描述非線性的關係。本頁兩者都取 \(n_y = n_u = 2\),也就是迴歸向量 \(\mathbf x(k) = [y(k), y(k-1), u(k), u(k-1)]\)。

模型有兩種用法,差別很重要:

一步預測(訓練時)自由模擬(拿來控制時)NARX網路 fŷk+1​uk​, uk−1​量測的 yk​, yk−1​每一步都用真實的過去輸出誤差不會累積NARX網路 fŷk+1​uk​, uk−1​延遲一步回授自己的預測小誤差會一步步累積

一步預測很準不代表模型好:任何模型只要學到「\(y(k+1) \approx y(k)\)」,一步預測的誤差就很小。判斷模型好壞要看沒看過的資料上的自由模擬。

實驗設計 Experiment Design

學得好不好,資料比網路更重要:

網路的兩個實用技巧:輸入先標準化(減平均、除以標準差),以及讓網路輸出變化量 \(\Delta y = y(k+1) - y(k)\) 而不是 \(y(k+1)\) 本身,也就是 \(\hat y(k+1) = y(k) + \text{NN}(\mathbf x(k))\)。水位每秒只變幾公分,直接學 \(y(k+1)\) 時網路大部分的能力都花在「複製 \(y(k)\)」上。

範例:水槽的 ARX 與 NARX Example

網路為 4→32→32→1,tanh 激活函數,共 1,249 個參數,用 Adam 對整批資料訓練 3000 次(約 20 秒):

# 系統鑑別:只用量測的「閥開度 u → 水位 h」資料,學出水槽(同模糊控制章)的動態模型
# 線性 ARX 與神經網路 NARX 比較;兩者都用 y(k+1) = f(y(k), y(k−1), u(k), u(k−1))
import json
import math
from pathlib import Path

import numpy as np
import torch
from torch import nn

def tank(u, h0=0.5):
    """真實系統(只用來產生資料):dh/dt = 0.5u − 0.05√(2gh),取樣 1 s,槽高 2 m"""
    h, out = h0, [h0]
    for uk in u:
        for _ in range(10):                                            # 每秒內 RK4 十步
            f = lambda x: 0.5 * uk - 0.05 * math.sqrt(2 * 9.8 * max(x, 0.0))
            k1 = f(h); k2 = f(h + 0.05 * k1); k3 = f(h + 0.05 * k2); k4 = f(h + 0.1 * k3)
            h = min(max(h + 0.1 / 6 * (k1 + 2 * k2 + 2 * k3 + k4), 0.0), 2.0)
        out.append(h)
    return np.array(out)

def steps_input(n, rng, hi=0.62):
    """隨機階梯輸入:每 10~60 s 換一個 0~0.62 的閥開度(涵蓋 0~2 m 的操作範圍)"""
    u = []
    while len(u) < n:
        u += [rng.uniform(0, hi)] * int(rng.integers(10, 61))
    return np.array(u[:n])

rng = np.random.default_rng(0)
u_tr = steps_input(3000, rng); y_tr = tank(u_tr)                        # 訓練資料:50 分鐘
u_te = steps_input(600, rng); y_te = tank(u_te)                         # 測試資料:另外 10 分鐘
X = lambda y, u: np.stack([y[1:-1], y[:-2], u[1:], u[:-1]], 1)          # [y(k), y(k−1), u(k), u(k−1)]
Xtr, Ytr = X(y_tr, u_tr), y_tr[2:]

# 線性 ARX:最小平方
A = np.c_[Xtr, np.ones(len(Xtr))]
theta = np.linalg.lstsq(A, Ytr, rcond=None)[0]
arx = lambda x: np.r_[x, 1.0] @ theta

# 神經網路 NARX:輸入先標準化,輸出水位的變化量 Δy
mu, sd = Xtr.mean(0), Xtr.std(0)
torch.manual_seed(0)
net = nn.Sequential(nn.Linear(4, 32), nn.Tanh(), nn.Linear(32, 32), nn.Tanh(), nn.Linear(32, 1))
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
xt = torch.tensor((Xtr - mu) / sd, dtype=torch.float32)
dt_ = torch.tensor(Ytr - Xtr[:, 0], dtype=torch.float32).unsqueeze(1)
for epoch in range(3000):                                               # 一步預測的誤差(序列–平行模式)
    loss = nn.functional.mse_loss(net(xt), dt_)
    opt.zero_grad(); loss.backward(); opt.step()
narx = lambda x: x[0] + net(torch.tensor((np.asarray(x) - mu) / sd, dtype=torch.float32)).item()

def one_step(model, y, u):
    return np.array([model(x) for x in X(y, u)])

def free_run(model, y, u):
    """自由模擬(平行模式):只給前兩個真實水位,之後都拿模型自己的預測回授"""
    p = list(y[:2])
    for k in range(1, len(u)):
        p.append(min(max(model([p[k], p[k - 1], u[k], u[k - 1]]), 0.0), 2.0))
    return np.array(p)

rm = lambda a, b: float(np.sqrt(np.mean((a - b) ** 2)))
print("ARX 係數 [a1 a2 b1 b2 c] =", np.round(theta, 4))
for name, m in (("線性 ARX", arx), ("神經網路 NARX", narx)):
    e1 = rm(one_step(m, y_te, u_te), y_te[2:])
    fr = free_run(m, y_te, u_te)
    print(f"{name}:測試資料的一步預測 RMSE {e1:.4f} m,自由模擬 600 s 的 RMSE {rm(fr, y_te[:len(fr)]):.4f} m")

layers = [[m.weight.tolist(), m.bias.tolist()] for m in net if isinstance(m, nn.Linear)]
fr_n, fr_a = free_run(narx, y_te, u_te), free_run(arx, y_te, u_te)
Path(__file__).with_name("tank_narx.json").write_text(json.dumps({
    "layers": layers, "mu": mu.tolist(), "sd": sd.tolist(), "arx": theta.tolist(),
    "test": {"u": u_te.tolist(), "y": y_te.tolist(), "narx": fr_n.tolist(), "arx": fr_a.tolist()}}), encoding="utf-8")

⬇ 下載 tank_narx.py(Python 3+PyTorch)

實際執行的輸出:

ARX 係數 [a1 a2 b1 b2 c] = [ 1.8523e+00 -8.5380e-01  4.4240e-01 -4.3850e-01 -2.0000e-04]
線性 ARX:測試資料的一步預測 RMSE 0.0025 m,自由模擬 600 s 的 RMSE 0.1322 m
神經網路 NARX:測試資料的一步預測 RMSE 0.0019 m,自由模擬 600 s 的 RMSE 0.0147 m

結果(下載 tank_narx.json,內含網路權值,下一頁的 MPC 會用到):

測試資料(600 s)一步預測 RMSE自由模擬 RMSE
線性 ARX(5 個係數)0.25 cm13.2 cm
神經網路 NARX(1,249 個參數)0.19 cm1.5 cm

一步預測兩者差不多,自由模擬差了約 9 倍:

010020030040050060000.511.52水位(m)010020030040050060000.6時間(s)閥開度 u真實水槽NARX 自由模擬(RMSE 0.015 m)線性 ARX(0.132 m)

為什麼線性模型不夠 Static Map

水槽的出水量和 \(\sqrt h\) 成正比:\(\dfrac{dh}{dt} = 0.5u - 0.05\sqrt{2gh}\)。閥開度固定時,水位最後停在 \(h = (u/0.1)^2 / 2g\),是一條拋物線。把模型的輸入固定、反覆套用到收斂,就得到模型的穩態特性:

00.10.20.30.40.50.600.511.52閥開度 u(固定)穩態水位(m)真實:h = (u/0.1)²/2g(出水量 ∝ √h,非線性)NARX:形狀對,高水位偏高約 5 cmARX:只能是一條直線,在中間對、兩端偏

ARX 的穩態是 \(y = \dfrac{(b_1 + b_2)u + c}{1 - a_1 - a_2}\),只能是直線。而且由範例的係數,\(b_1 + b_2 = 0.0039\)、\(1 - a_1 - a_2 = 0.0015\),穩態增益是兩個很小的數相除,係數的一點誤差就讓穩態差很多。這就是 ARX「一步預測準、自由模擬不準」的原因:每一步的偏差很小,但累積下去就跑到錯的穩態。NARX 學到了拋物線的形狀:水位 1 m 以下誤差約 2 cm 以內,水位越高誤差越大,1.5~1.8 m 時偏高約 5 cm。原因之一是高水位的資料較少:閥開度在 0~0.62 均勻分布,但水位和開度的平方成正比,訓練資料中水位超過 1.5 m 的時間只有 11%,低於 0.5 m 的卻有 46%。誤差雖小,下一頁會看到它仍會造成 MPC 的穩態誤差,需要另外修正。

自由模擬對照 Try It

下方動畫把範例訓練好的 NARX 權值與 ARX 係數匯出到網頁,和真實水槽並行,用同樣的閥開度做自由模擬。兩個模型都看不到真實水位,只能靠自己的預測往前推。可以用隨機輸入,或手動把閥開到很大、很小,看 ARX 在兩端偏多少:

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

實務注意事項 Practical Notes

學到模型之後,下一頁用它來做模型預測控制。