智慧型控制 Intelligent Control

深度 Q 網路 DQN

表格式的 Q-learning 處理不了連續的狀態。深度 Q 網路(deep Q-network,DQN)用類神經網路來近似 Q 函數:輸入狀態,輸出每個動作的 Q 值。2015 年 DeepMind 用同一個 DQN,只看遊戲畫面的像素,就學會了幾十種 Atari 遊戲,開啟了深度強化學習的熱潮。

用網路近似 Q 函數 Function Approximation

把 Q-learning 的更新改寫成「讓網路的輸出 \(Q(s, a; \boldsymbol\theta)\) 去逼近 TD 目標」的迴歸問題,用梯度下降最小化:

$$L(\boldsymbol\theta) = \mathbb E\!\left[\left(r + \gamma \max_{a'} Q(s', a'; \boldsymbol\theta^-) - Q(s, a; \boldsymbol\theta)\right)^2\right]$$

直接這樣做常常發散,原因有兩個,DQN 各用一個技巧解決:

問題DQN 的解法
連續幾步的資料高度相關(都在同一個地方),違反梯度下降假設的「獨立樣本」,網路會一直被最近的經驗帶著走經驗回放:把經驗 \((s, a, r, s')\) 存進一個大緩衝區,每次隨機抽一小批來訓練
學習目標裡也有 Q,參數一更新目標就跟著動,像追自己的尾巴目標網路:目標改用另一組參數 \(\boldsymbol\theta^-\) 計算,每隔一段時間才從 \(\boldsymbol\theta\) 複製過來
倒單擺環境(模擬或實體)經驗回放區存 (s, a, r, s′) 兩萬筆隨機抽 64 筆Q 網路4 → 64 → 64 → 2目標網路每 500 步複製一次 Qε-貪婪選動作損失 = [ Q(s, a) − ( r + γ·max Q_目標(s′, ·) ) ]²,只更新 Q 網路

範例:DQN 學平衡倒單擺 Example

使用經典的 CartPole 問題,物理參數與模糊控制章的倒單擺相同:台車 1 kg、擺桿 0.1 kg、半長 0.5 m。不同的是動作只有兩種:往左或往右推 10 N,每 0.02 秒決定一次。台車超出 ±2.4 m 或擺桿倒超過 12° 就算失敗,每撐一步得 +1,最多 500 步(10 秒)。網路不知道任何物理方程式,只能從互動中學習:

# 深度 Q 網路(DQN)學平衡倒單擺:經驗回放+目標網路(Mnih 等 2015 的做法),環境自己寫、不需安裝 gym
import json
import math
import random
from pathlib import Path

import torch
from torch import nn

def step(s, a):
    """經典 CartPole:台車 1 kg、擺桿 0.1 kg(半長 0.5 m)、推力 ±10 N、每步 0.02 s"""
    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      # 出界或倒超過 12° 就失敗

random.seed(0); torch.manual_seed(0)
Q = nn.Sequential(nn.Linear(4, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 2))
Q_target = nn.Sequential(nn.Linear(4, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 2))
Q_target.load_state_dict(Q.state_dict())                               # 目標網路:固定一陣子才更新,讓學習目標穩定
opt = torch.optim.Adam(Q.parameters(), lr=1e-3)
replay, CAP = [], 20000                                                # 經驗回放:存過去的 (s, a, r, s′, done)
def evaluate(net, n=10, seed=100):
    """不探索(永遠選 Q 最大的動作)跑 n 回合,回傳每回合撐的步數"""
    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(net(torch.tensor(s)).argmax()))
            T += 1
            if done:
                break
        out.append(T)
    return out

steps, lens, tests, best = 0, [], [], (-1, None, 0)
for ep in range(1, 601):
    s, T = tuple(random.uniform(-0.05, 0.05) for _ in range(4)), 0
    while T < 500:
        eps = max(0.05, 1.0 - steps / 10000)                           # ε 從 1 線性降到 0.05
        if random.random() < eps:
            a = random.randrange(2)
        else:
            with torch.no_grad():
                a = int(Q(torch.tensor(s)).argmax())
        s2, done = step(s, a)
        replay.append((s, a, 1.0, s2, float(done)))
        if len(replay) > CAP:
            replay.pop(0)
        s, T, steps = s2, T + 1, steps + 1
        if len(replay) >= 1000:                                        # 從回放中隨機抽一小批,打破資料的時間相關
            S, A, R, S2, D = map(torch.tensor, zip(*random.sample(replay, 64)))
            with torch.no_grad():
                target = R + 0.99 * (1 - D) * Q_target(S2).max(1).values
            q = Q(S).gather(1, A.view(-1, 1)).squeeze(1)
            loss = nn.functional.smooth_l1_loss(q, target)
            opt.zero_grad(); loss.backward(); opt.step()
        if steps % 500 == 0:
            Q_target.load_state_dict(Q.state_dict())
        if done:
            break
    lens.append(T)
    if ep % 50 == 0:
        score = sum(evaluate(Q)) / 10                                  # 每 50 回合測一次,保留表現最好的網路
        tests.append([ep, score])
        if score > best[0]:
            best = (score, {k: v.clone() for k, v in Q.state_dict().items()}, ep)
        print(f"第 {ep:3d} 回合:最近 50 回合平均撐 {sum(lens[-50:]) / 50:5.1f} 步,不探索測試 {score:5.1f} 步")

Q.load_state_dict(best[1])
evals = evaluate(Q, 20, seed=1000)                                      # 用另一組初始狀態測 20 回合
print(f"採用第 {best[2]} 回合的網路,另測 20 回合:平均 {sum(evals) / 20:.1f} 步,撐滿 500 步的有 {evals.count(500)} 回合")
layers = [(m.weight.tolist(), m.bias.tolist()) for m in Q if isinstance(m, nn.Linear)]
Path(__file__).with_name("dqn_cartpole.json").write_text(json.dumps({"layers": layers, "lens": lens, "tests": tests, "best": best[2]}), encoding="utf-8")   # 給網頁動畫

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

實際執行的輸出:

第  50 回合:最近 50 回合平均撐  23.9 步,不探索測試   9.8 步
第 100 回合:最近 50 回合平均撐  19.9 步,不探索測試 166.6 步
第 150 回合:最近 50 回合平均撐  27.9 步,不探索測試 140.9 步
第 200 回合:最近 50 回合平均撐  71.7 步,不探索測試 259.6 步
第 250 回合:最近 50 回合平均撐 139.3 步,不探索測試 197.9 步
第 300 回合:最近 50 回合平均撐 158.9 步,不探索測試 124.7 步
第 350 回合:最近 50 回合平均撐 111.7 步,不探索測試 137.9 步
第 400 回合:最近 50 回合平均撐 143.5 步,不探索測試 177.7 步
第 450 回合:最近 50 回合平均撐 189.0 步,不探索測試  90.4 步
第 500 回合:最近 50 回合平均撐 108.4 步,不探索測試  76.4 步
第 550 回合:最近 50 回合平均撐 121.8 步,不探索測試 500.0 步
第 600 回合:最近 50 回合平均撐 268.8 步,不探索測試 500.0 步
採用第 550 回合的網路,另測 20 回合:平均 500.0 步,撐滿 500 步的有 20 回合
01002003004005006000100200300400500訓練回合撐住的步數採用第 550 回合的網路訓練中(含探索)每回合步數,20 回合移動平均每 50 回合不探索測試 10 次的平均

(補充)取 max 會讓 Q 值系統性地高估。Double DQN(2016)把「選動作」與「估價值」分給兩組網路,是常見的改良。

用學到的網路控制 Try It

範例訓練好的 DQN 權值(4→64→64→2,共 4,610 個參數)匯出到網頁,下方動畫直接用它控制倒單擺;也可以切換成下一頁的線性策略、由模型算出的 LQR,或是隨機亂推作為對照。可以推擺桿一下,看它能不能救回來:

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