智慧型控制 Intelligent Control

神經網路模型預測控制

模型預測控制(model predictive control,MPC)每個取樣時刻都用模型預測未來,以數值最佳化找出最好的控制輸入。它不要求模型是線性的,所以很適合搭配上一頁學到的 NARX 網路。MPC 從 1970 年代起就是石化工業的主力控制方法,近年隨計算能力提升,也用在汽車、無人機與機器人上。

滾動時域 Receding Horizon

-10-50510150.511.5時間(相對現在,s)水位現在 k設定值 r試很多組未來的閥開度,用模型預測水位選成本最小的那組(綠線),只執行第一步;下一秒重做一次過去(量測)

在時刻 \(k\),從目前量測到的狀態出發,對未來 \(N\) 步的輸入序列 \(u(k), \ldots, u(k+N-1)\) 解一個最佳化問題:

$$\min_{u(k), \ldots, u(k+N-1)} \; J = \sum_{j=1}^{N} \big(r - \hat y(k+j)\big)^2 + \lambda \big(u(k) - u(k-1)\big)^2$$ $$\text{subject to}\quad \hat y(k+j+1) = f\big(\hat y(k+j), \hat y(k+j-1), u(k+j), u(k+j-1)\big), \qquad 0 \le u \le 1$$

第一項要水位接近設定值 \(r\),第二項懲罰閥開度的劇烈變動。解出整個序列,但只執行第一步 \(u(k)\);下一個取樣時刻量測新的水位,整個問題重解一次。因為每一步都用新的量測重新出發,模型的誤差與外來干擾會被回授修正,這就是 MPC 和「離線算好一條開迴路軌跡」的差別。

MPC 的優點:

代價是每個取樣時刻都要解一個最佳化問題,計算量大,而且非線性模型的最佳化可能有多個區域最小值。

怎麼解:輸入分段 Move Blocking

預測 \(N = 15\) 秒,有 15 個未知的輸入。常用的簡化是輸入分段(move blocking):前 \(m = 3\) 秒用 \(u_1\)、之後都用 \(u_2\),未知數只剩兩個。兩個變數可以直接格點搜尋,保證找到全域最小值:

  1. 粗搜:\(u_1, u_2\) 各取 0, 0.05, …, 1,共 21 × 21 = 441 組。
  2. 細搜:在最好的一組附近,各取間隔 0.005 的 11 個值,共 121 組。

每組都要用網路往前推 15 步,每秒共約 8,400 次網路計算。用 PyTorch 把 441 組排成一批同時計算,一個取樣時刻不到 10 毫秒。變數多時,則改用梯度法:網路可微分,用自動微分求 \(\partial J / \partial u\),再交給最佳化器。

只用 \(u_1\) 一段(整個區間閥開度固定)也可以,但試過的結果比 PI 還差:要快速加水時,固定的開度只能在「開大、之後會超越」與「開小、上升很慢」之間妥協。兩段讓 MPC 可以「先開大、再收回」。

偏差修正 Offset-Free MPC

模型一定有誤差。如果模型在某個水位預測水會繼續上升,實際上卻已經平衡,MPC 就會為了「抵消」不存在的上升而把閥關小一點,水位停在設定值下方,形成穩態誤差。範例的 NARX 在 1.5 m 平衡時,每秒會錯估 0.3 cm 的上升,15 秒的預測累積起來約 4 cm,結果 MPC 的穩態誤差約 −3 cm。

標準的解法是把誤差當成一個固定的干擾:模型和真實系統並行,用同樣的輸入自由模擬,得到模型輸出 \(y_m(k)\),兩者的差

$$d(k) = y(k) - y_m(k)$$

就是模型目前的偏差。預測時假設它在未來保持不變,成本改為 \(\sum (r - \hat y(k+j) - d(k))^2\),預測則從 \(y_m\) 出發。到穩態時,模型輸出不再變化、預測曲線是平的,成本最小的條件就變成 \(y_m + d = y = r\),所以不論模型在這個水位偏多少,都沒有穩態誤差。作用和 PI 的積分項相同。

範例:控制水槽水位 Example

水位設定值 0.5 → 1.5 → 0.8 → 1.8 m,各維持 100 秒。比較六種控制器:

# 用學到的模型做模型預測控制(MPC):控制水槽水位(同模糊控制章),和 PI 控制比較
# 需要先執行 tank_narx.py 產生 tank_narx.json(裡面有 NARX 網路與 ARX 係數)
import json
import math
from pathlib import Path

import torch

torch.set_default_dtype(torch.float64)
HERE = Path(__file__).parent
d = json.loads((HERE / "tank_narx.json").read_text(encoding="utf-8"))
W = [(torch.tensor(w), torch.tensor(b)) for w, b in d["layers"]]
mu, sd, theta = torch.tensor(d["mu"]), torch.tensor(d["sd"]), torch.tensor(d["arx"])

def true_model(y, ym1, u, um1):
    """真實的水槽(取樣 1 s,每秒 RK4 十步);可一次算一批"""
    h = y.clone()
    f = lambda x: 0.5 * u - 0.05 * torch.sqrt(2 * 9.8 * x.clamp(min=0))
    for _ in range(10):
        k1 = f(h); k2 = f(h + 0.05 * k1); k3 = f(h + 0.05 * k2); k4 = f(h + 0.1 * k3)
        h = (h + 0.1 / 6 * (k1 + 2 * k2 + 2 * k3 + k4)).clamp(0, 2)
    return h

def narx_model(y, ym1, u, um1):
    h = (torch.stack([y, ym1, u, um1], -1) - mu) / sd
    for k, (w, b) in enumerate(W):
        h = h @ w.T + b
        if k < len(W) - 1:
            h = torch.tanh(h)
    return y + h[..., 0]

def arx_model(y, ym1, u, um1):
    return theta[0] * y + theta[1] * ym1 + theta[2] * u + theta[3] * um1 + theta[4]

def mpc(model, y, ym1, u_prev, r, N=15, m=3, lam=0.5, d=0.0):
    """閥開度在預測區間內分兩段:前 m 秒 u1、之後 u2。先在 0~1 間隔 0.05 試 21×21 組,再在最好的附近間隔 0.005 細搜。
    成本 J = Σ (r − ŷ − d)² + λ(u1 − u_前)²,只執行 u1(滾動時域);d 為偏差修正量(量測值 − 模型輸出)"""
    def best_of(cand):
        u1, u2 = cand[:, 0], cand[:, 1]
        yk, ykm1, up = torch.full_like(u1, y), torch.full_like(u1, ym1), torch.full_like(u1, u_prev)
        J = lam * (u1 - u_prev) ** 2
        for j in range(N):
            u = u1 if j < m else u2
            yn = model(yk, ykm1, u, up).clamp(0, 2)
            J = J + (r - yn - d) ** 2
            ykm1, yk, up = yk, yn, u
        return cand[int(J.argmin())]
    g = torch.arange(21) * 0.05
    c = best_of(torch.cartesian_prod(g, g))
    off = (torch.arange(11) - 5) * 0.005
    return float(best_of(torch.cartesian_prod((c[0] + off).clamp(0, 1), (c[1] + off).clamp(0, 1)))[0])

ref = [0.5] * 20 + [1.5] * 100 + [0.8] * 100 + [1.8] * 100           # 設定值:0.5 → 1.5 → 0.8 → 1.8 m
u_ss = lambda h: 0.1 * math.sqrt(2 * 9.8 * h)                           # 維持水位 h 所需的閥開度

MODELS = {"MPC(真實模型)": true_model, "MPC(NARX)": narx_model, "MPC(ARX)": arx_model,
          "MPC(NARX+偏差修正)": narx_model, "MPC(ARX+偏差修正)": arx_model}

def run(controller):
    y, u_prev, us = [0.5, 0.5], u_ss(0.5), []
    I = u_ss(0.5) / 0.08
    ym = [0.5, 0.5]                                                     # 偏差修正:模型和真實系統並行,用同樣的輸入往前跑
    for k, r in enumerate(ref):
        if controller == "PI":                                          # PI:在 h = 1 m 線性化設計,兩極點都在 −0.2
            e = r - y[-1]
            u_raw = 0.578 * e + 0.08 * (I + e)
            u = min(max(u_raw, 0.0), 1.0)
            if u == u_raw:
                I += e                                                  # 閥飽和時停止積分(防積分飽和)
        elif "偏差修正" in controller:
            d = y[-1] - ym[-1]                                          # 把模型的誤差當成一個固定的干擾
            u = mpc(MODELS[controller], ym[-1], ym[-2], u_prev, r, d=d)
        else:
            u = mpc(MODELS[controller], y[-1], y[-2], u_prev, r)
        ym.append(float(MODELS.get(controller, true_model)(torch.tensor(ym[-1]), torch.tensor(ym[-2]), torch.tensor(u), torch.tensor(u_prev))))
        y.append(float(true_model(torch.tensor(y[-1]), None, torch.tensor(u), None)))
        us.append(u); u_prev = u
    return y[1:], us

out = {"ref": ref}
for name in ("PI", "MPC(真實模型)", "MPC(NARX)", "MPC(ARX)", "MPC(NARX+偏差修正)", "MPC(ARX+偏差修正)"):
    y, us = run(name)
    iae = sum(abs(a - b) for a, b in zip(y[1:], ref))
    over = max(y[k + 1] - ref[k] for k in list(range(20, 120)) + list(range(220, 320)))
    out[name] = {"y": y, "u": us}
    ss = [y[k] - ref[k - 1] for k in (120, 220, 320)]                   # 各段結束時的誤差(穩態誤差)
    print(f"{name:16s}:IAE {iae:5.2f} m·s,往上調時最大超越 {max(over, 0) * 100:4.1f} cm,"
          f"各段結束時的誤差 {", ".join(f"{e * 100:+.1f}" for e in ss)} cm")
(HERE / "tank_mpc.json").write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8")

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

實際執行的輸出:

PI              :IAE  6.33 m·s,往上調時最大超越  1.6 cm,各段結束時的誤差 +0.0, -0.0, +0.0 cm
MPC(真實模型)       :IAE  5.29 m·s,往上調時最大超越  0.7 cm,各段結束時的誤差 +0.6, +0.0, -0.1 cm
MPC(NARX)       :IAE  9.02 m·s,往上調時最大超越  0.0 cm,各段結束時的誤差 -2.9, -0.4, -2.6 cm
MPC(ARX)        :IAE  4.70 m·s,往上調時最大超越  0.2 cm,各段結束時的誤差 -0.3, -0.4, +0.0 cm
MPC(NARX+偏差修正)  :IAE  5.83 m·s,往上調時最大超越  0.7 cm,各段結束時的誤差 +0.6, +0.1, -0.0 cm
MPC(ARX+偏差修正)   :IAE  5.57 m·s,往上調時最大超越  5.3 cm,各段結束時的誤差 +0.4, -0.2, +0.3 cm

需要先執行上一頁的 tank_narx.py 產生 tank_narx.json(或直接下載 tank_narx.json)。

0501001502002503000.511.52時間(s)水位(m)PI:IAE 6.33、超越 1.6 cm模糊控制:IAE 8.69、超越 1.0 cmMPC(NARX):IAE 9.02、超越 0.0 cmMPC(NARX+偏差修正):IAE 5.83、超越 0.7 cm灰虛線:設定值 IAE=∫|r − h| dt(m·s) 超越:設定值往上調的兩段
控制器IAE(m·s)往上調時最大超越各段結束時的誤差
PI6.331.6 cm0
模糊控制(模糊控制章的水槽 FIS)8.691.0 cm−1.0 cm
MPC(真實模型)5.290.7 cm+0.6 cm 以內
MPC(NARX)9.020−2.9、−0.4、−2.6 cm
MPC(NARX+偏差修正)5.830.7 cm+0.6 cm 以內
MPC(ARX)4.700.2 cm−0.4 cm 以內
MPC(ARX+偏差修正)5.575.3 cm+0.4 cm 以內

從這個比較可以看到:

即時的神經網路 MPC Try It

下方動畫在網頁裡即時執行同樣的 MPC:每個取樣時刻用範例訓練好的 NARX 做 562 組的格點搜尋(每秒約 8,400 次網路計算)。綠點線是這一刻 MPC 預測的未來 15 秒水位,可以看到每一步都在修正預測。移動設定值滑桿,比較有無偏差修正與 PI:

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

實務上的神經網路 MPC In Practice