智慧型控制 Intelligent Control

策略梯度與 PPO

DQN 先學價值,再由價值決定動作。策略梯度(policy gradient)方法則直接把策略寫成有參數的函數 \(\pi_{\boldsymbol\theta}(a \mid s)\),沿著「讓期望報酬變大」的方向調整參數。它可以自然地處理連續的動作(例如連續的馬達扭力),也是目前機器人與連續控制最常用的方法。

策略梯度定理與 REINFORCE Policy Gradient

目標是最大化期望報酬 \(J(\boldsymbol\theta) = \mathbb E_{\pi_\theta}[G_0]\)。策略梯度定理給出它的梯度:

$$\nabla_{\boldsymbol\theta} J = \mathbb E_{\pi_\theta}\!\left[\sum_t \nabla_{\boldsymbol\theta}\log \pi_{\boldsymbol\theta}(a_t \mid s_t)\,\left(G_t - b(s_t)\right)\right]$$

直觀的意思:如果某個動作之後的報酬 \(G_t\) 比平常(基準線 b)好,就提高這個動作的機率,反之降低。REINFORCE(Williams,1992)就是跑完一個回合,用實際的報酬總和代入上式更新。減去基準線不會改變梯度的期望值,但能大幅降低變異數。

以倒單擺為例,用最簡單的線性策略:往右推的機率 \(p = \sigma(\mathbf w \cdot \boldsymbol\phi(s))\),σ 為 logistic 函數,\(\boldsymbol\phi\) 為四個狀態(擺角乘 10 讓大小相近)。這時 \(\nabla_{\mathbf w}\log\pi = (a - p)\,\boldsymbol\phi\),其中 a = 1 表示往右推,整個策略只有 4 個參數:

# REINFORCE(策略梯度):只用 4 個權值的線性策略,學平衡倒單擺;並和由模型算出的 LQR 增益比較
import math
import numpy as np
from scipy.linalg import expm, solve_discrete_are

def step(s, f):
    """經典 CartPole:台車 1 kg、擺桿 0.1 kg(半長 0.5 m)、每步 0.02 s;推力 f = ±10 N"""
    x, xd, th, thd = s
    c, sn = math.cos(th), math.sin(th)
    tmp = (f + 0.05 * thd * thd * sn) / 1.1
    thacc = (9.8 * sn - c * tmp) / (0.5 * (4 / 3 - 0.1 * c * c / 1.1))
    xacc = tmp - 0.05 * thacc * c / 1.1
    s = np.array([x + 0.02 * xd, xd + 0.02 * xacc, th + 0.02 * thd, thd + 0.02 * thacc])
    return s, abs(s[0]) > 2.4 or abs(s[2]) > 12 * math.pi / 180

SCALE = np.array([1, 1, 10, 1])                                        # 擺角放大 10 倍,讓四個輸入大小相近

def train(seed, episodes=200, lr=0.03, gamma=0.99):
    rng = np.random.default_rng(seed)
    w, baseline, lens = np.zeros(4), 0.0, []
    for ep in range(episodes):
        s, S, A = rng.uniform(-0.05, 0.05, 4), [], []
        for t in range(500):
            p = 1 / (1 + math.exp(-np.clip(w @ (s * SCALE), -30, 30)))  # 往右推的機率 π(右 | s)
            a = int(rng.random() < p)
            S.append(s * SCALE); A.append(a)
            s, done = step(s, 10.0 if a else -10.0)
            if done:
                break
        G = np.zeros(len(A)); acc = 0.0
        for t in range(len(A) - 1, -1, -1):                            # 每一步之後的折扣報酬總和
            acc = 1 + gamma * acc; G[t] = acc
        S, A = np.array(S), np.array(A)
        P = 1 / (1 + np.exp(-np.clip(S @ w, -30, 30)))
        w += lr * ((A - P) * (G - baseline)) @ S / len(A)              # ∇log π = (a − p)·φ(s),乘上(報酬 − 基準線)
        baseline = 0.9 * baseline + 0.1 * G.mean()
        lens.append(len(A))
    return w, lens

for seed in (1, 2, 3):
    w, lens = train(seed)
    first = next((i + 1 for i, n in enumerate(lens) if n == 500), None)
    print(f"種子 {seed}:第 {first} 回合首次撐滿 500 步,最後 20 回合平均 {np.mean(lens[-20:]):.0f} 步,「往右推」logit 的係數 {np.round(w * SCALE, 2)}")

# 對照:在直立點線性化,用 LQR 算回授增益(推力 u = −Kx)
den = 0.5 * (4 / 3 - 0.1 / 1.1)
a_th, b_th = 9.8 / den, -1 / (1.1 * den)
Ac = np.array([[0, 1, 0, 0], [0, 0, -0.05 * a_th / 1.1, 0], [0, 0, 0, 1], [0, 0, a_th, 0]])
Bc = np.array([[0], [1 / 1.1 - 0.05 * b_th / 1.1], [0], [b_th]])
M = expm(np.block([[Ac, Bc], [np.zeros((1, 5))]]) * 0.02)
A, B = M[:4, :4], M[:4, 4:]
P = solve_discrete_are(A, B, np.diag([1, 1, 10, 1]), np.array([[0.1]]))
K = np.linalg.solve(0.1 + B.T @ P @ B, B.T @ P @ A)[0]
print("LQR:往右推的程度 −Kx 的係數", np.round(-K, 2), "(依序為 x、ẋ、θ、θ˙)")

⬇ 下載 reinforce_linear.py(Python 3+numpy+scipy)

實際執行的輸出:

種子 1:第 53 回合首次撐滿 500 步,最後 20 回合平均 467 步,「往右推」logit 的係數 [ 0.61 -0.38 20.91  1.84]
種子 2:第 73 回合首次撐滿 500 步,最後 20 回合平均 475 步,「往右推」logit 的係數 [ 0.57 -0.32 18.84  1.63]
種子 3:第 48 回合首次撐滿 500 步,最後 20 回合平均 423 步,「往右推」logit 的係數 [ 0.34 -0.48 16.65  1.64]
LQR:往右推的程度 −Kx 的係數 [ 2.78  5.22 45.31 11.74] (依序為 x、ẋ、θ、θ˙)

三個亂數種子分別在第 53、73、48 回合首次撐滿 500 步。學到的權值中,擺角的係數最大,擺角與角速度的符號都和 LQR 相同:擺桿往右倒,就把台車往右推到擺桿底下。台車速度的係數符號則與 LQR 相反:只要不出界就有報酬,策略並沒有被要求讓台車停下來。

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

演員–評論家與 PPO Actor-Critic and PPO

REINFORCE 要等回合結束才更新,報酬總和的雜訊也大。演員–評論家(actor-critic)方法多訓練一個價值網路 \(V(s)\)(評論家),用它估計每個動作比平常好多少的優勢,代替雜訊很大的 \(G_t - b\)。常用的廣義優勢估計(GAE)為

$$\hat A_t = \sum_{k \ge 0} (\gamma\lambda)^k \delta_{t+k},\qquad \delta_t = r_t + \gamma V(s_{t+1}) - V(s_t)$$

策略梯度的另一個問題是步伐:一次更新太大,策略就壞掉、很難恢復。PPO(proximal policy optimization,Schulman 等 2017)用新舊策略的機率比 \(\rho_t = \pi_{\boldsymbol\theta}(a_t \mid s_t)/\pi_{\text{舊}}(a_t \mid s_t)\),把它裁剪在 \([1-\epsilon, 1+\epsilon]\) 之內,避免一次更新太多:

$$L^{\text{CLIP}}(\boldsymbol\theta) = \mathbb E\!\left[\min\!\left(\rho_t \hat A_t,\ \operatorname{clip}(\rho_t, 1-\epsilon, 1+\epsilon)\,\hat A_t\right)\right]$$

因為更新被限制住,同一批資料可以重複使用好幾次,既穩定又有效率。PPO 實作簡單、調參不敏感,是目前最常用的強化學習演算法之一,大型語言模型的人類回饋強化學習(RLHF)也常用它。

# PPO(近端策略最佳化,Schulman 等 2017)學平衡倒單擺:演員–評論家網路、GAE 優勢估計、裁剪的目標函數
import json
import math
import random
from pathlib import Path

import torch
from torch import nn

def step(s, a):
    """經典 CartPole(同 dqn_cartpole.py)"""
    x, xd, th, thd = s
    f = 10.0 if a == 1 else -10.0
    c, sn = math.cos(th), math.sin(th)
    tmp = (f + 0.05 * thd * thd * sn) / 1.1
    thacc = (9.8 * sn - c * tmp) / (0.5 * (4 / 3 - 0.1 * c * c / 1.1))
    xacc = tmp - 0.05 * thacc * c / 1.1
    s = (x + 0.02 * xd, xd + 0.02 * xacc, th + 0.02 * thd, thd + 0.02 * thacc)
    return s, abs(s[0]) > 2.4 or abs(s[2]) > 12 * math.pi / 180

random.seed(0); torch.manual_seed(0)
actor = nn.Sequential(nn.Linear(4, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 2))    # 策略:輸出兩個動作的 logit
critic = nn.Sequential(nn.Linear(4, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 1))   # 價值函數 V(s)
opt = torch.optim.Adam(list(actor.parameters()) + list(critic.parameters()), lr=3e-4)
GAMMA, LAM, CLIP, N = 0.99, 0.95, 0.2, 2048

s, T, finished, curve = tuple(random.uniform(-0.05, 0.05) for _ in range(4)), 0, [], []
for it in range(1, 31):
    S, A, LOGP, R, D = [], [], [], [], []
    for _ in range(N):                                                  # 1. 用目前的策略收集 2048 步經驗
        with torch.no_grad():
            dist = torch.distributions.Categorical(logits=actor(torch.tensor(s)))
            a = int(dist.sample())
        s2, done = step(s, a)
        T += 1
        trunc = T >= 500
        S.append(s); A.append(a); LOGP.append(float(dist.log_prob(torch.tensor(a)))); R.append(1.0); D.append(done)
        s = s2
        if done or trunc:
            finished.append(T)
            s, T = tuple(random.uniform(-0.05, 0.05) for _ in range(4)), 0
    St, At, LOGPt = torch.tensor(S), torch.tensor(A), torch.tensor(LOGP)
    with torch.no_grad():                                               # 2. GAE:用 V 估計每一步的優勢
        V = critic(St).squeeze(1)
        V_next = torch.cat([V[1:], critic(torch.tensor([s])).view(1)])
        adv, gae = torch.zeros(N), 0.0
        for t in range(N - 1, -1, -1):
            nonterm = 0.0 if D[t] else 1.0
            delta = R[t] + GAMMA * V_next[t] * nonterm - V[t]
            gae = delta + GAMMA * LAM * nonterm * gae
            adv[t] = gae
        ret = adv + V
        adv = (adv - adv.mean()) / (adv.std() + 1e-8)
    for epoch in range(10):                                             # 3. 同一批資料重複用 10 次,但限制策略不能變太多
        for idx in torch.randperm(N).split(64):
            dist = torch.distributions.Categorical(logits=actor(St[idx]))
            ratio = torch.exp(dist.log_prob(At[idx]) - LOGPt[idx])    # 新策略/舊策略的機率比
            clipped = torch.clamp(ratio, 1 - CLIP, 1 + CLIP)
            loss_pi = -torch.min(ratio * adv[idx], clipped * adv[idx]).mean()
            loss_v = (critic(St[idx]).squeeze(1) - ret[idx]).pow(2).mean()
            opt.zero_grad(); (loss_pi + 0.5 * loss_v).backward(); opt.step()
    recent = finished[-20:] or [T]
    curve.append([it * N, sum(recent) / len(recent)])
    if it % 5 == 0:
        print(f"{it * N:6d} 步:最近 20 回合平均撐 {curve[-1][1]:5.1f} 步")

def evaluate(n=20, seed=1000):
    out = []
    for k in range(n):
        rng = random.Random(seed + k)
        s, T = tuple(rng.uniform(-0.05, 0.05) for _ in range(4)), 0
        while T < 500:
            with torch.no_grad():
                s, done = step(s, int(actor(torch.tensor(s)).argmax()))
            T += 1
            if done:
                break
        out.append(T)
    return out
ev = evaluate()
print(f"訓練後取機率最大的動作測 20 回合:平均 {sum(ev) / 20:.1f} 步,撐滿 500 步的有 {ev.count(500)} 回合")
Path(__file__).with_name("ppo_cartpole.json").write_text(json.dumps({"curve": curve}), encoding="utf-8")

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

實際執行的輸出:

 10240 步:最近 20 回合平均撐  87.0 步
 20480 步:最近 20 回合平均撐 338.2 步
 30720 步:最近 20 回合平均撐 436.5 步
 40960 步:最近 20 回合平均撐 468.5 步
 51200 步:最近 20 回合平均撐 500.0 步
 61440 步:最近 20 回合平均撐 456.4 步
訓練後取機率最大的動作測 20 回合:平均 500.0 步,撐滿 500 步的有 20 回合
0501001502000100200300400500回合線性策略 REINFORCE(學習率 0.03)02000040000600000100200300400500與環境互動的步數PPO(演員–評論家網路)縱軸:撐住的步數 左:5 個亂數種子,10 回合移動平均 右:每收集 2048 步更新一次,最近 20 回合的平均

PPO 約 5 萬步學會,所需的互動量和 DQN 差不多(約 5.6 萬步),但學習曲線穩定上升,不像 DQN 那樣忽高忽低。

(補充)動作是連續值時,策略常寫成高斯分布:網路輸出平均值 μ(s),動作 \(a \sim \mathcal N(\mu(s), \sigma^2)\),\(\nabla\log\pi\) 一樣容易計算。

和模型為基礎的控制比較 Compared with LQR

同一個倒單擺,從擺角 8.6° 出發,比較 DQN、線性 REINFORCE,以及在直立點線性化、用 LQR 算出的增益。三者都只能推 ±10 N,LQR 只取 \(-\mathbf K\mathbf x\) 的正負號:

01234-10-50510時間(s)擺角(度)0246810-2-1012時間(s)台車位置(m)DQN:撐 500 步線性 REINFORCE:撐 500 步LQR(取正負號):撐 500 步
LQR強化學習(DQN、PPO)模糊控制(倒單擺 FIS)
需要模型需要(線性化)不需要,但要大量互動資料(或模擬器)不需要數學模型,需要專家經驗
設計工作選 Q、R 後解 Riccati 方程式設計報酬、調超參數、長時間訓練設計歸屬函數與規則
本例表現最穩:2 秒後擺角都在 0.5° 以內,台車只漂移 0.3 m撐滿 500 步;2 秒後擺角約在 1~2.5° 以內,台車漂移較大可追蹤位置目標(見該頁)
保證線性化範圍內有穩定性保證沒有,靠測試沒有一般性的保證

對這個模型清楚、又只有四個狀態的系統,直接用模型設計控制器是最簡單、最好的選擇。強化學習的價值在模型很難寫出、或報酬很複雜的問題:例如只用攝影機影像控制、多關節機器人在不平的地面行走、與人互動的任務。這時通常先在模擬器裡用 PPO 等方法大量訓練,再設法移植到實體(sim-to-real),並保留傳統控制作為安全的後備。