深度 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\) 複製過來 |
範例: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 回合
- 訓練 600 回合後,學到的網路在另一組初始狀態下,20 回合全部撐滿 500 步。
- 但過程很不穩定:每 50 回合測一次,成績在 76 到 500 步之間上下擺盪,學好的網路還會「忘掉」。所以實務上會定期測試、保留最好的網路(範例的做法),而不是直接用最後的網路。
- 學到第 550 回合,總共和環境互動了 55,802 步。換算成實體設備,約是 19 分鐘的連續操作,而且其中有幾百次擺桿倒下。這是在實體設備上直接用強化學習的主要障礙,所以通常先在模擬器裡訓練。
(補充)取 max 會讓 Q 值系統性地高估。Double DQN(2016)把「選動作」與「估價值」分給兩組網路,是常見的改良。
用學到的網路控制 Try It
範例訓練好的 DQN 權值(4→64→64→2,共 4,610 個參數)匯出到網頁,下方動畫直接用它控制倒單擺;也可以切換成下一頁的線性策略、由模型算出的 LQR,或是隨機亂推作為對照。可以推擺桿一下,看它能不能救回來: