跳轉到

4.1 Gym 環境與 DQN:經驗回放、目標網路(2013-2015 Atari)#

前言:兩件事同時發生,才有了深度 RL#

2013 年 12 月,DeepMind 在 NIPS 發表《Playing Atari with Deep Reinforcement Learning》;2015 年 2 月,升級版登上 Nature《Human-level control through deep reinforcement learning》。這兩篇論文做的不只是「用神經網路玩遊戲」,而是同時完成兩件事:(1)用深度卷積網路取代 Q 表,一舉擊碎第 3.2 節的維度災難——網路從原始像素直接學價值,泛化到沒見過的局面;(2)DeepMind 與 OpenAI(2016)把「環境」標準化成 Gym,讓整個領域第一次擁有可複現的基準。本節將逐行拆解 DQN 的兩大發明——經驗回放(experience replay)與目標網路(target network)——並說明它們各自馴服「致命三要素」的哪一環。理解了 DQN,你就理解了後續所有深度 RL 演算法(PPO、SAC、AlphaZero)都必須回答的同樣問題:如何讓「自舉 + 神經網路 + off-policy 資料」這個組合不崩潰?

歷史背景:為什麼是 2013?三塊拼圖同時到位#

第一塊:卷積網路成熟。 2012 年 AlexNet 在 ImageNet 爆發,證明 CNN 能從原始像素學出層次化表示。此前 RL 界不是沒想過用神經網路近似 Q(Tesauro 的 TD-Gammon 1992 就用了,見第 6 章),但淺層網路的表示力有限。第二塊:Atari 作為基準。 2013 年前 RL 基準極度零散(cart-pole、迷宮、模擬機器人各說各話)。Bellemare 等人 2013 年的 Arcade Learning Environment(ALE)把 57 款 Atari 遊戲包裝成統一介面——「只看畫面、只給分數」,排除了人工特徵工程的作弊空間。OpenAI Gym(2016)隨後把 ALE 與幾十個環境統一成一個 pip 套件與 API(reset()/step()/observation_space),成為全書程式碼的基礎。第三塊:算力與訓練技巧。 GPU 讓「百萬步的採樣 + 反覆的小批次梯度更新」成為可能;經驗回放則把 RL 的「線上、順序、相關」資料流改造成監督學習熟悉的「離線、洗牌、獨立」小批次。

值得強調的因果鏈:1992 年 TD-Gammon 已證明「TD + 網路」可行(第 6 章),但它是 on-policy(自我對弈即行為),避開了 off-policy 的不穩定;DQN 是 off-policy(回放舊經驗),踩進致命三要素的核心,所以必須發明新的穩定化手段。DQN 的每一個補丁都是對理論失效的工程回應——這是深度 RL「理論讓位給工程」時代的開端。

DQN 的完整架構#

輸入表示:把 POMDP 折回 MDP#

單幀畫面無法判斷移動方向與速度(第 2.1 節的部分可觀測問題)。DQN 的解法樸素而有效:疊最近 4 幀 \(x_t = (f_{t-3}, f_{t-2}, f_{t-1}, f_t)\) ,讓「位移」成為可見資訊。加上灰階、縮小到 \(84 \times 84\) 、取最後 2 幀取 max 去除 Atari 的閃爍精靈——這些「環境前處理」後來成為整個領域的標準配方。

Q 網路#

輸入 \(84 \times 84 \times 4\) ,三層卷積 + 兩層全連接,輸出每個動作一個 Q 值(而非輸入 \((s,a)\) 對輸出一個值)——這樣一次前向就能對所有動作取 argmax,且天然支援第 4.2 節的 Dueling 改造。

數學:TD 損失與兩大補丁的針對性#

基本損失#

DQN 最小化的目標是把 Q-Learning 更新式改寫成監督學習形式:

\[ \mathcal{L}_i(\theta_i) = \mathbb{E}_{(s,a,r,s') \sim U(D)} \left[ \left( r + \gamma \max_{a'} Q(s', a'; \theta^-) - Q(s,a;\theta_i) \right)^2 \right] \]

其中 \(D\) 是回放池、 \(U(\cdot)\) 是均匀採樣、 \(\theta^-\) 是目標網路參數。注意這就是第 3.2 節 Q-Learning 更新式的平方損失版,只是(1)期望用小批次近似,(2)目標裡的 \(Q\) 換成了凍結的 \(\theta^-\) 。

補丁一:經驗回放——消除「資料相關性」#

問題:線上學習中連續樣本是強相關的(同一路軌跡、同一局勢的連續幀), SGD 假設的 iid 被打破;且舊經驗用完即丟,樣本效率低,還可能被近期資料的自相關震盪拖著跑(學壞循環)。解法(Lin 1992 的回放思想 + DQN 大規模化):把 \((s,a,r,s',done)\) 存進大小 \(10^6\) 的環形池,訓練時均匀隨機抽小批次。效果:

  • 相鄰樣本去相關 → 梯度更接近 iid 假設;
  • 每條寶貴經驗被重複使用 → 樣本效率提升;
  • 打破「最近的經驗主導梯度」→ 抑制振盪與災難遺忘。

代價:off-policy 程度加深(池中資料來自舊策略)→ 恰好踩進致命三要素,需要補丁二。後續演化:Prioritized Replay(2015,按 TD 誤差大小優先採樣,成為 Rainbow 一員,第 4.2 節)。

補丁二:目標網路——凍結「老師」#

問題:若目標 \(r + \gamma \max Q(s',\cdot;\theta)\) 用同一套 \(\theta\) ,則每步梯度更新會同時移動「學生」與「老師」——像追自己影子,自舉的誤差會即時放大並自強化,訓練可能發散(Atari 實驗中常見 Q 值飆到天文數字)。解法:維護一份延遲同步的目標網路 \(\theta^-\) ,每 \(C = 10^4\) 步才從主網路複製一次。數學含義:把「移動目標」凍結成「半固定目標」,讓每次小批次的優化問題近似一個良定的回歸問題;而 \(C\) 步一次的更新則讓目標的移動速度遠慢於學生,抑制正回饋迴路。代價:目標過時(bias 進一步增大)→ 這正是第 4.2 節 Double DQN 要修的過估問題。

兩個補丁對致命三要素的分工#

致命三要素 DQN 是否踩到 誰在馴服它
自舉(target 用自己的 Q) 是 目標網路減緩;但本質仍在
函數近似(CNN 代替表格) 是 表示學習的收益來源,無法捨棄
off-policy(回放舊資料) 是 經驗回放本身;ε-greedy 控制分布

理論上三者同在可發散;DQN 用「延遲 + 重放 + 保守步長」把實務推向可用。這種「理論保證缺失、工程補丁護航」的姿態,定義了 2015-2020 深度 RL 的美學,直到 PPO(第 5 章)選擇另一條路:乾脆避開 off-policy。

程式碼:用 Gymnasium 實作精簡版 DQN#

以下是一份完整可跑的精簡 DQN(CartPole,CPU 可訓),保留兩大補丁的核心結構:

# pip install gymnasium torch numpy
import gymnasium as gym, random
import numpy as np, torch, torch.nn as nn

ENV, GAMMA, EPS_END, EPS_DECAY = "CartPole-v1", 0.99, 0.05, 500
BATCH, CAPACITY, SYNC, LR = 64, 20_000, 500, 1e-3
device = "cpu"
torch.manual_seed(0); random.seed(0)

env = gym.make(ENV)
A = env.action_space.n

class QNet(nn.Module):
    """狀態是 4 維向量(車位置/速度/桿角度/角速度); 遊戲版把這層換成 CNN 即可"""
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(nn.Linear(4,128), nn.ReLU(),
                                 nn.Linear(128,128), nn.ReLU(),
                                 nn.Linear(128,A))
    def forward(self, x): return self.net(x)

q_net, tgt_net = QNet().to(device), QNet().to(device)
tgt_net.load_state_dict(q_net.state_dict())
opt = torch.optim.Adam(q_net.parameters(), lr=LR)
buf = []                                       # 經驗回放池
step_count = 0

def act(state, eps):
    if random.random() < eps: return env.action_space.sample()
    with torch.no_grad():
        return int(q_net(torch.tensor(state, dtype=torch.float32)).argmax())

for ep in range(600):
    obs, _ = env.reset(seed=ep); done = False
    eps = max(EPS_END, 1.0 - ep / EPS_DECAY)   # ε-greedy 線性衰減
    while not done:
        step_count += 1
        a = act(obs, eps)
        ns, r, term, trunc, _ = env.step(a)
        buf.append((obs, a, r, ns, term))      # 注意存的是 term 而非 trunc
        if len(buf) > CAPACITY: buf.pop(0)
        obs, done = ns, term or trunc

        if len(buf) < BATCH or step_count % 4: continue
        batch = random.sample(buf, BATCH)      # 補丁一: 均匀隨機抽樣(去相關)
        s, a, r, s2, d = map(np.array, zip(*batch))
        s  = torch.tensor(s,  dtype=torch.float32)
        a  = torch.tensor(a,  dtype=torch.int64)
        r  = torch.tensor(r,  dtype=torch.float32)
        s2 = torch.tensor(s2, dtype=torch.float32)
        d  = torch.tensor(d.astype(np.float32))

        with torch.no_grad():
            # 補丁二: 目標用凍結的 tgt_net
            target = r + GAMMA * (1 - d) * tgt_net(s2).max(1).values
        q = q_net(s).gather(1, a.unsqueeze(1)).squeeze(1)
        loss = nn.functional.smooth_l1_loss(q, target)
        opt.zero_grad(); loss.backward()
        nn.utils.clip_grad_norm_(q_net.parameters(), 10.0)  # 梯度裁剪: 再一道保險
        opt.step()

        if step_count % SYNC == 0:             # 補丁二: 每 SYNC 步同步目標網路
            tgt_net.load_state_dict(q_net.state_dict())

    if (ep + 1) % 100 == 0:
        # 簡易評估: 貪婪策略跑 20 回合取平均步數
        scores = []
        for ev in range(20):
            o, _ = env.reset(seed=9000+ev); dn = False; sc = 0
            while not dn:
                o, r, t, tr, _ = env.step(act(o, 0.0)); dn = t or tr; sc += r
            scores.append(sc)
        print(f"ep {ep+1:4d}  eps={eps:.2f}  平均存活步數={np.mean(scores):.0f}")

觀察要點:(1)eps 從 1.0 衰減——早期全隨機探索,逐漸轉向利用;(2)buf.pop(0) 的環形池在小環境夠用,Atari 版需 collections.deque 或環形陣列;(3)target = r + γ(1-d)·maxQ 的 (1-d) 等價於第 3.2 節的 (not term)——終止狀態截斷自舉;(4)去掉 tgt_net(把目標改回 q_net(s2))再跑一次,多數情況訓練會震盪或崩壞——這是致命三要素最直觀的展示。

結果與意義#

DQN(2015 Nature 版)在 49 款 Atari 中,於多數遊戲達到或超越專業人類測試者水平(以同一套網路與超參數!),並在 Breakout 展示了湧現行為:網路自己發現「挖穿牆讓球從頂上反覆得分」的高分策略——沒有人教它,獎勵訊號自己長出了策略。更深遠的意義:「同一套演算法 + 同一套架構玩所有遊戲」 首次兌現了 Sutton 多年倡議的「通用智能靠獎勵訊號學習」的路線,也直接定調了後來 OpenAI/DeepMind 的研究範式——這條線一路延伸到 RLHF(把遊戲換成對話)與 o1(把遊戲換成數學題)。

本節重點#

  1. 2013-2015 年 DQN 的突破 = CNN 表達力 + ALE/Atari 標準基準 + GPU 算力三塊拼圖同時到位;OpenAI Gym(2016)隨後把環境 API 標準化,成為領域基礎設施。
  2. DQN 核心仍是第 3.2 節 Q-Learning 更新式,只是把表格換成 CNN,並寫成平方損失的小批次優化。
  3. 經驗回放去相關、重複利用樣本、抑制振盪——馴服「off-policy 資料」這一環;目標網路凍結 TD 目標、減緩自舉的正回饋——馴服「移動目標」這一環。
  4. 致命三要素(自舉 + 函數近似 + off-policy)在 DQN 中全部踩到;理論保證讓位給工程補丁,是深度 RL 時代的起點。
  5. 疊 4 幀解決 Atari 的部分可觀測問題——「用歷史折疊出 Markov 表示」的標準工程手法(呼應第 2.1 節)。

思考問題#

  1. 把程式中 target 的 tgt_net(s2) 改回 q_net(s2) 重跑,觀察訓練曲線。請用「老師與學生同時移動」的比喻解釋你觀察到的不穩定。
  2. 經驗回放把舊資料留下來重用,但第 3.1 節思考題提過「非平穩環境要忘掉舊資料」。這兩者矛盾嗎?Atari 為何不矛盾,而對話式 RLHF(第 8 章)可能矛盾?
  3. DQN 輸出「每個動作一個 Q 值」而非「輸入 (s,a) 輸出一個值」。請分析這個設計在計算 \(\max_{a'} Q(s',a')\) 時的效率差異,以及它如何為第 4.2 節 Dueling 架構鋪路。
  4. Breakout 中網路自行發現「挖洞」策略。這在 RL 語言中叫什麼現象?它為什麼必然發生(提示:它是唯一被獎勵訊號強化的行為,無關乎人類意圖)——這個機制與第 8.2 節 reward hacking 有何同構之處?
  5. 若把疊 4 幀改成疊 1 幀,Breakout(需要知道球的方向)與 Pong 會分別發生什麼?請用 POMDP 的語言說明疊幀是在做什麼數學操作。