貝氏最佳化
CMA-ES 調 PID 需要幾百次評估。在電腦上模擬沒問題,但如果每次評估都是一次實機實驗(啟動馬達、記錄響應、等系統回到初始狀態),幾百次就太多了。貝氏最佳化(Bayesian optimization)是為「每次評估都很貴」設計的:它把做過的實驗結果建成一個統計模型,每一次都仔細挑選最值得試的下一點。
高斯過程 Gaussian Process
貝氏最佳化用高斯過程(GP)描述「對未知函數 \(f\) 的了解」。GP 假設任兩點的函數值的相關程度由一個核函數 \(k(\mathbf x, \mathbf x')\) 決定:距離近的點函數值相近。本頁用常見的 Matérn 5/2 核:
$$k(\mathbf x, \mathbf x') = \left(1 + \sqrt 5\, d + \tfrac{5}{3} d^2\right) e^{-\sqrt 5\, d}, \qquad d = \frac{\lVert \mathbf x - \mathbf x' \rVert}{\ell}$$長度尺度 \(\ell\) 決定函數「多快就會變」。給定已做的 \(N\) 次實驗 \((\mathbf X, \mathbf y)\),任一點 \(\mathbf x\) 的預測是一個常態分布,平均與變異數為
$$\mu(\mathbf x) = \mathbf k_*^\mathsf T (\mathbf K + \sigma_n^2 \mathbf I)^{-1} \mathbf y, \qquad s^2(\mathbf x) = k(\mathbf x, \mathbf x) - \mathbf k_*^\mathsf T (\mathbf K + \sigma_n^2 \mathbf I)^{-1} \mathbf k_*$$其中 \(\mathbf K_{ij} = k(\mathbf x_i, \mathbf x_j)\)、\(\mathbf k_*\) 是 \(\mathbf x\) 和各實驗點的核函數值。在做過實驗的點附近 \(s\) 很小,離得越遠越不確定。\(\ell\) 由資料決定:選讓已觀測資料出現機率(邊際概似)最大的值。
期望改善量 Expected Improvement
下一次實驗要在哪裡做?若目前最好的成本是 \(f^*\),在 \(\mathbf x\) 做實驗能「比 \(f^*\) 好多少」的期望值是
$$\mathrm{EI}(\mathbf x) = (f^* - \xi - \mu)\,\Phi(z) + s\,\phi(z), \qquad z = \frac{f^* - \xi - \mu}{s}$$\(\Phi, \phi\) 是標準常態分布的累積分布與機率密度,\(\xi\)(本頁 0.01)是一個小的保留量。第一項在預測很好(\(\mu\) 小)的地方大,第二項在很不確定(\(s\) 大)的地方大,所以 EI 自動在「利用目前所知」與「探索沒試過的地方」之間取捨。每一輪:用所有資料擬合 GP → 找 EI 最大的點 → 做實驗 → 加入資料。
動手看 Try It
下方動畫在網頁裡執行一維貝氏最佳化,函數有兩個區域最小值。先隨機試 3 點,之後每次加一點。可以看到它一開始在 0.14 附近(左邊那個較淺的坑)試了好幾次,第 10 次實驗才因為右邊很不確定而跳過去,之後很快找到真正的最小值 0.757:
範例:30 次實驗調好 PID Example
和 CMA-ES 一頁相同的 PID 調參問題,限制只能做 30 次實驗,比較隨機搜尋、CMA-ES 與貝氏最佳化;作為參考,另外讓 CMA-ES 做到 294 次。貝氏最佳化先隨機試 5 點,GP 對 \(\log(\text{ITAE})\) 建模(成本差好幾個數量級,取對數較平滑),EI 的最大值用 4,000 個隨機候選點加局部細搜找:
# 用最佳化自動調 PID:受控體同 PID 章的 G(s) = 1/(s+1)³,目標是讓單位步階響應的 ITAE = ∫ t|e(t)| dt 最小
# 比較 Ziegler–Nichols、隨機搜尋、CMA-ES、貝氏最佳化;每個方法跑 5 個亂數種子。每「評估」一次 = 做一次閉迴路步階實驗
import json
import math
from pathlib import Path
import numpy as np
from scipy.linalg import expm
from scipy.stats import norm
# ---------- 受控體與數位 PID ----------
DT, T_END, U_MAX, TF = 0.01, 20.0, 10.0, 0.05
A = np.array([[0, 1, 0], [0, 0, 1], [-1, -3, -3]], float) # 1/(s+1)³ 的可控標準式
M = expm(np.block([[A, np.array([[0], [0], [1]])], [np.zeros((1, 4))]]) * DT)
AD, BD = M[:3, :3], M[:3, 3] # 零階保持離散化
def pid_sim(kp, ki, kd):
"""取樣 0.01 s;微分作用在量測值(一階濾波 0.05 s);|u| ≤ 10,飽和時停止積分"""
n = int(round(T_END / DT)); x, I, D, y_old = np.zeros(3), 0.0, 0.0, 0.0
ys = np.empty(n + 1)
for k in range(n + 1):
y = x[0]; e = 1.0 - y
D = (TF * D - (y - y_old)) / (TF + DT)
raw = kp * e + ki * (I + e * DT) + kd * D
u = min(max(raw, -U_MAX), U_MAX)
if u == raw:
I += e * DT
ys[k], y_old = y, y
x = AD @ x + BD * u
return np.arange(n + 1) * DT, ys
def itae(kp, ki, kd):
t, y = pid_sim(kp, ki, kd)
return float(np.sum(t * np.abs(1.0 - y)) * DT)
LO, HI = np.array([-1.0, -2.0, -2.0]), np.array([1.5, 1.0, 1.0]) # log10 Kp、Ki、Kd 的範圍
gains = lambda z: tuple(10 ** (LO + (HI - LO) * np.asarray(z, float))) # 正規化座標 [0, 1]³ → 增益
cost = lambda z: itae(*gains(z))
# ---------- CMA-ES(同 cmaes_benchmark.py)----------
class CMAES:
def __init__(self, x0, sigma0, rng):
n = self.n = len(x0)
self.lam = 4 + int(3 * math.log(n)); self.mu = self.lam // 2
w = np.array([math.log(self.mu + 0.5) - math.log(i + 1) for i in range(self.mu)])
self.w = w / w.sum(); me = self.mueff = 1 / np.sum(self.w ** 2)
self.cs = (me + 2) / (n + me + 5); self.ds = 1 + 2 * max(0.0, math.sqrt((me - 1) / (n + 1)) - 1) + self.cs
self.cc = (4 + me / n) / (n + 4 + 2 * me / n); self.c1 = 2 / ((n + 1.3) ** 2 + me)
self.cmu = min(1 - self.c1, 2 * (me - 2 + 1 / me) / ((n + 2) ** 2 + me))
self.chin = math.sqrt(n) * (1 - 1 / (4 * n) + 1 / (21 * n * n))
self.m, self.sigma, self.rng = np.array(x0, float), sigma0, rng
self.C, self.ps, self.pc, self.g, self.D, self.B = np.eye(n), np.zeros(n), np.zeros(n), 0, np.ones(n), np.eye(n)
def ask(self):
Z = np.array([[float(self.rng.normal()) for _ in range(self.n)] for _ in range(self.lam)])
self.Y = Z @ np.diag(self.D) @ self.B.T
return self.m + self.sigma * self.Y
def tell(self, fits):
n, ys = self.n, self.Y[np.argsort(fits, kind="stable")[:self.mu]]
yw = self.w @ ys; self.m = self.m + self.sigma * yw
self.ps = (1 - self.cs) * self.ps + math.sqrt(self.cs * (2 - self.cs) * self.mueff) * (self.B @ ((self.B.T @ yw) / self.D))
self.g += 1
hs = float(np.linalg.norm(self.ps) / math.sqrt(1 - (1 - self.cs) ** (2 * self.g)) < (1.4 + 2 / (n + 1)) * self.chin)
self.pc = (1 - self.cc) * self.pc + hs * math.sqrt(self.cc * (2 - self.cc) * self.mueff) * yw
self.C = ((1 - self.c1 - self.cmu) * self.C + self.c1 * (np.outer(self.pc, self.pc) + (1 - hs) * self.cc * (2 - self.cc) * self.C)
+ self.cmu * sum(wi * np.outer(y, y) for wi, y in zip(self.w, ys)))
self.C = (self.C + self.C.T) / 2
self.sigma *= math.exp((self.cs / self.ds) * (np.linalg.norm(self.ps) / self.chin - 1))
d2, self.B = np.linalg.eigh(self.C); self.D = np.sqrt(np.maximum(d2, 1e-300))
def run_cma(budget, seed):
"""從範圍中央出發;超出 [0, 1] 的樣本在邊界上評估並加懲罰"""
rng = np.random.default_rng(100 + seed); es = CMAES(np.full(3, 0.5), 0.3, rng); F, XS = [], []
while len(F) + es.lam <= budget:
X = es.ask(); Xc = np.clip(X, 0, 1)
fits = [cost(xc) for xc in Xc]; F += fits; XS += list(Xc)
es.tell(np.array([f + 1e3 * float(np.sum((x - xc) ** 2)) for f, x, xc in zip(fits, X, Xc)]))
return F, XS
# ---------- 貝氏最佳化:高斯過程(Matérn 5/2)+期望改善量 ----------
def matern52(P, Q, ell):
d = np.sqrt(np.maximum(((P[:, None, :] - Q[None, :, :]) ** 2).sum(-1), 0.0)) / ell
return (1 + math.sqrt(5) * d + 5 * d * d / 3) * np.exp(-math.sqrt(5) * d)
def gp_fit(X, y, noise=1e-6):
"""y 標準化;長度尺度 ℓ 取邊際概似最大的格點值"""
mu, sd = y.mean(), y.std() or 1.0; t = (y - mu) / sd; best = None
for ell in (0.05, 0.08, 0.12, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0):
Lc = np.linalg.cholesky(matern52(X, X, ell) + noise * np.eye(len(X)))
a = np.linalg.solve(Lc.T, np.linalg.solve(Lc, t))
ll = -0.5 * t @ a - np.log(np.diag(Lc)).sum()
if best is None or ll > best[0]:
best = (ll, ell, Lc, a)
return X, mu, sd, best[1], best[2], best[3]
def gp_predict(g, P):
X, mu, sd, ell, Lc, a = g
Ks = matern52(P, X, ell); v = np.linalg.solve(Lc, Ks.T)
return mu + sd * (Ks @ a), sd * np.sqrt(np.maximum(1.0 - (v * v).sum(0), 1e-12))
def ei(m, s, best, xi=0.01):
z = (best - xi - m) / s
return (best - xi - m) * norm.cdf(z) + s * norm.pdf(z)
def run_bo(budget, seed, n_init=5):
"""先隨機 5 點,之後每次:對 log(ITAE) 建 GP → 找 EI 最大的點 → 做一次實驗"""
rng = np.random.default_rng(seed)
X = list(rng.random((n_init, 3))); F = [cost(x) for x in X]
while len(X) < budget:
y = np.log(F); g = gp_fit(np.array(X), y)
C = rng.random((4000, 3)); m, s = gp_predict(g, C); e = ei(m, s, y.min())
L = np.clip(np.repeat(C[np.argsort(-e)[:5]], 200, 0) + rng.normal(0, 0.03, (1000, 3)), 0, 1)
m2, s2 = gp_predict(g, L); e2 = ei(m2, s2, y.min())
x = L[int(np.argmax(e2))] if e2.max() > e.max() else C[int(np.argmax(e))]
X.append(x); F.append(cost(x))
return F, X
# ---------- 比較 ----------
def info(g):
t, y = pid_sim(*g)
out = np.where(np.abs(y - 1) > 0.02)[0]
return max(0.0, (y.max() - 1) * 100), t[out[-1] + 1]
Tu = 2 * math.pi / math.sqrt(3); zn = (4.8, 4.8 / (Tu / 2), 4.8 * Tu / 8) # Ziegler–Nichols 臨界增益法(Ku = 8)
os_, ts = info(zn)
print(f"Ziegler–Nichols:Kp={zn[0]:.2f}, Ki={zn[1]:.2f}, Kd={zn[2]:.2f},ITAE {itae(*zn):.2f},超越 {os_:.1f}%,安定時間 {ts:.2f} s")
def run_random(budget, seed):
X = list(np.random.default_rng(50 + seed).random((budget, 3)))
return [cost(x) for x in X], X
methods = {"隨機搜尋 30 次": lambda s: run_random(30, s), "CMA-ES 28 次": lambda s: run_cma(30, s), # CMA-ES 每代 7 個,4 代 = 28 次
"貝氏最佳化 30 次": lambda s: run_bo(30, s), "CMA-ES 294 次": lambda s: run_cma(300, s)}
out = {"zn": zn}
for name, run in methods.items():
runs = [run(s) for s in range(5)]
bests = [min(F) for F, _ in runs]
print(f"{name:10s}:最好的 ITAE 中位數 {np.median(bests):.3f}(5 個種子 {min(bests):.3f}~{max(bests):.3f})")
out[name] = {"best": bests, "curves": [np.minimum.accumulate(F).tolist() for F, _ in runs],
"gains": [gains(X[int(np.argmin(F))]) for F, X in runs]}
for name in ("貝氏最佳化 30 次", "CMA-ES 294 次"): # 中位數那個種子的結果
k = int(np.argsort(out[name]["best"])[2]); g = out[name]["gains"][k]
os_, ts = info(g)
print(f"{name}(中位數的種子):Kp={g[0]:.2f}, Ki={g[1]:.2f}, Kd={g[2]:.2f},ITAE {itae(*g):.3f},超越 {os_:.1f}%,安定時間 {ts:.2f} s")
Path(__file__).with_name("pid_tuning.json").write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8")⬇ 下載 pid_tuning.py(Python 3+numpy+scipy)
實際執行的輸出:
Ziegler–Nichols:Kp=4.80, Ki=2.65, Kd=2.18,ITAE 5.20,超越 52.7%,安定時間 9.66 s
隨機搜尋 30 次 :最好的 ITAE 中位數 8.370(5 個種子 1.716~15.006)
CMA-ES 28 次:最好的 ITAE 中位數 3.900(5 個種子 1.230~5.819)
貝氏最佳化 30 次:最好的 ITAE 中位數 1.784(5 個種子 1.021~3.130)
CMA-ES 294 次:最好的 ITAE 中位數 1.052(5 個種子 1.014~1.470)
貝氏最佳化 30 次(中位數的種子):Kp=15.56, Ki=1.54, Kd=10.00,ITAE 1.784,超越 5.7%,安定時間 6.51 s
CMA-ES 294 次(中位數的種子):Kp=6.48, Ki=0.89, Kd=4.68,ITAE 1.052,超越 2.6%,安定時間 4.08 s
- 只有 30 次實驗時,貝氏最佳化最好:5 個種子的中位數 ITAE 1.78,已經接近 CMA-ES 用 294 次的 1.05,遠比 Ziegler–Nichols 的 5.20 好。
- CMA-ES 在 30 次內只能跑 4 代,中位數 3.90;它要更多次評估才能發揮。
- 隨機亂試 30 次反而不如 Ziegler–Nichols(中位數 8.37):三維空間裡好的區域很小,沒有方法的試誤很沒效率。
- 貝氏最佳化中位數那個種子的 \(K_d = 10.00\) 剛好在搜尋範圍的上限:最佳解可能在範圍外。實務上看到參數貼著邊界時,應該放寬範圍再試一次。
實務注意事項 In Practice
- 安全:在實機上,貝氏最佳化可能為了「探索」而試一組會讓系統不穩定的增益。SafeOpt(Berkenkamp 等人,2016)等方法再用一個 GP 預測安全限制(例如最大電流、最大誤差),只在高機率安全的範圍內探索。
- 雜訊:實際的量測有雜訊,同一組增益做兩次結果不同。GP 的雜訊項 \(\sigma_n^2\) 要設成合理的值(本頁的模擬沒有雜訊,用 \(10^{-6}\))。
- 維度與計算量:GP 每次擬合要解 \(N \times N\) 的線性系統,計算量約 \(N^3\);參數超過約 20 個時,GP 很難學好,效果會變差。
- 也用在機器學習:調整神經網路的學習率、層數等超參數是貝氏最佳化最常見的應用之一,原因相同:每訓練一次都很貴。