ARK · 金融機器學習CHAPTER 09 / 19

CHAPTER 09 / 19 · PART 3 · 時序決策:強化學習

強化學習導論

Introduction to Reinforcement Learning

你的單會推動市場,預測必須升級成決策——從 MDP、Bellman 方程到 Q-learning 的完整地圖。

§01學習重點

§02課程內容

一、從預測到決策:監督式學習止步之處

第 1 章第八節埋過一條伏筆:目前為止的模型都是「旁觀者」——預測違約機率、預測下一秒的價格方向,預測本身不改變世界。前八章我們把旁觀者的本事練到了頂:橫斷面的監督式學習(第一部)、時間序列的動態建模(第二部)。從這一章起進入第三部,角色換了——模型要下場

下場之後,監督式學習的三個隱含前提逐一失效。

第一,回饋從「正解」變成「評價」。 監督式學習像有老師改考卷:每筆訓練資料都附標準答案,模型錯在哪、該往哪修,梯度說得明明白白。決策問題沒有這種老師。你把一張大單拆成五筆下出去,市場只會告訴你這次的成交成本是多少——它不會告訴你「正確的拆法其實是八筆」。你拿到的是分數,不是答案;而且分數高不代表這是最好的做法,只代表它比某些做法好。從答案裡學習和從分數裡學習,是兩種根本不同的學習。

第二,決策是序列,帳要算總的。 今天少賣一點,庫存留到明天,明天的處境就不同——每個動作都改變後面所有動作的舞台。單筆決策的好壞不能只看當下報酬,要看它牽動的整條路徑。這帶來強化學習最棘手的難題之一:功勞分配。一整季的損益出來了,功勞(或黑鍋)該記在哪一步的頭上?

第三,行動改變環境。 這是金融場景最尖銳的一刀。你的市價大單會把限價簿吃穿,價格被你自己推走——市場衝擊;你的放貸模型影響了誰拿得到貸款,也就改變了未來違約資料的分布。監督式學習假設資料分布是固定的、與你無關的;一旦你的輸出會回頭改寫輸入的分布,這個假設就塌了。強化學習從第一天起就把「你在環境裡、環境會回應你」寫進框架。

比喻: 在夜市擺攤賣鹽酥雞,沒有任何人會告訴你「正確的定價是多少」——你只看得到今晚的營收。定價 70 元生意冷清,60 元大排長龍,但排隊排太長隔壁攤就降價搶客,你的定價改變了整條街的行情。營收是評價、不是正解;每晚的定價牽動下一晚的客流;而你的動作會引來環境的反應。攤販學定價的方式——試、看分數、修正、再試——就是強化學習的原型。

整個框架就是這個迴圈:代理人(agent,你的交易策略)觀察狀態,選一個行動丟給環境(市場),環境回你一個報酬和新狀態,週而復始。代理人的目標不是猜對什麼,而是讓長期累積的報酬最大。這一章把迴圈的每個零件講清楚,並在一個小到可以手算、卻五臟俱全的金融問題上把全部演算法實際跑一遍。

二、把決策問題寫成數學:MDP 五要素

強化學習的標準數學骨架叫馬可夫決策過程(Markov Decision Process, MDP),由五個零件組成,記作 \((\mathcal{S}, \mathcal{A}, P, R, \gamma)\)。逐個翻譯成交易室的語言:

| 零件 | 數學身分 | 金融對應(以下單執行為例) | |---|---|---| | 狀態 \(\mathcal{S}\) | 環境的完整描述 | 剩餘庫存、限價簿形狀、剩餘時間 | | 行動 \(\mathcal{A}\) | 代理人的選項集合 | 市價單、限價單、單量、等待 | | 轉移機率 \(P(s'\mid s,a)\) | 環境怎麼回應 | 成交與否、價格怎麼動——通常未知 | | 報酬 \(R\) | 單步評分 | 成交損益 − 衝擊成本 − 持有成本 | | 折現因子 \(\gamma\in[0,1)\) | 未來的匯率 | 資金成本、風險耐受、時間偏好 |

「馬可夫」三個字是對狀態的一個要求:狀態要裝得夠飽,飽到「知道現在的狀態,就不需要再翻歷史」——下一步的分布只由當下狀態與行動決定。這不是對世界的斷言,而是對你怎麼定義狀態的紀律:如果只拿「最新成交價」當狀態,昨天的量價形態顯然還藏著資訊,馬可夫性不成立;把該裝的資訊裝進狀態向量(庫存、簿況、波動率環境……),馬可夫性就近似成立。裝不飽的情形叫部分可觀測(POMDP)——金融裡其實是常態,第九節會回來談這個誠實的難題。

整章的實驗都用同一個玩具問題——四股庫存的出清 MDP。你手上有 4 股要出清(想像單位是「千張」也行),每期可以選三種行動:市價賣 1 股(M1,立刻成交,但吃掉價差後每股只拿 0.97);市價賣 2 股(M2,一次倒兩股衝擊更大,每股 0.935);掛限價賣 1 股(L1,價格漂亮拿 1.02,但只有六成機率成交)。沒賣掉的庫存每期每股付 0.02 的持有成本(庫存風險的代價),折現因子 \(\gamma=0.95\)。庫存歸零,遊戲結束。麻雀雖小:市價單與限價單的取捨、衝擊成本、庫存風險、隨機成交——最適下單問題的骨架全在裡面。

三、政策、回報與價值函數:V 與 Q

代理人的行為由政策(policy)\(\pi\) 描述:每個狀態該做什麼。它可以是確定的(狀態 s 一律做 a),也可以是隨機的(\(\pi(a\mid s)\) 給每個行動一個機率)。學習的目標是找到讓長期成績最好的政策 \(\pi^*\)。

「長期成績」要先定義清楚。從時點 t 起算的回報(return)是折現後的報酬總和:

$$ G_t = R_{t+1} + \gamma R_{t+2} + \gamma^2 R_{t+3} + \cdots = \sum_{k=0}^{\infty} \gamma^k R_{t+k+1} $$

逐項拆解:\(R_{t+1}\) 是做完這一步立刻拿到的報酬;\(\gamma\) 把一期之後的報酬打一次折,兩期之後打兩次折——\(\gamma=0.95\) 意味著大約 14 期之後的報酬價值折半,越遠的未來越不值錢;級數收斂靠 \(\gamma<1\) 保證,所以「無限期的總帳」是一個有限的數。折現在金融裡不是抽象假設而是本行語言:資金有成本、承諾有風險,明天的一塊錢本來就小於今天的一塊錢。

有了回報,就能給「處境」和「處境+動作」標價。狀態價值函數 \(V^\pi(s)\):從狀態 s 出發、之後一路照政策 \(\pi\) 走,回報的期望值。行動價值函數 \(Q^\pi(s,a)\):在狀態 s 先做行動 a、之後再照 \(\pi\) 走的期望回報。兩者的關係:\(V^\pi(s) = \sum_a \pi(a\mid s)\, Q^\pi(s,a)\)——V 是 Q 在政策下的加權平均。

比喻: 下棋的人都懂這兩個數的差別。V 是盤面的形勢分:「這個局面,讓高手接手下去,勝算七成」——只評局面,不指定下一手。Q 是這一手棋的分數:「這個局面走馬三進四,之後讓同一位高手接手,勝算七成三」——把下一手也算進去。棋士挑棋就是在比各步的 Q;而所謂形勢大好,就是存在某一手讓 Q 特別高。強化學習裡代理人真正需要的是 Q——知道每手棋的分數,最好的政策就是每次挑分數最高的那手。

為什麼 Q 比 V 更好用?有了 \(Q^*\)(最適政策的 Q),選行動不需要知道環境模型——直接挑 \(\arg\max_a Q^*(s,a)\) 就好;只有 \(V^*\) 的話,你還得知道轉移機率才能比較各行動的後果。這個差別在「模型未知」的金融世界裡是決定性的,它是第七節 Q-learning 的存在理由。

四、Bellman 方程:把長期問題摺成一步

價值函數的定義裡藏著一個無限長的總和,直接算不可行。Bellman 方程是整個領域的軸心觀察:回報的定義是遞迴的——\(G_t = R_{t+1} + \gamma G_{t+1}\),今天的總帳等於今天的進帳加上打折後的明天總帳。對它取期望,就得到Bellman 期望方程

$$ V^\pi(s) = \sum_{a} \pi(a\mid s) \sum_{s'} P(s'\mid s,a)\,\big[\, r(s,a,s') + \gamma\, V^\pi(s') \,\big] $$

逐項拆解:外層 \(\sum_a \pi(a\mid s)\) 是「政策擲骰選行動」的平均;內層 \(\sum_{s'} P(s'\mid s,a)\) 是「環境擲骰決定去向」的平均;中括號裡是一步的完整帳——立即報酬 \(r\) 加上打折後、站在新狀態 \(s'\) 的身價 \(V^\pi(s')\)。整條式子把「無限長的未來」摺成「一步之後查表」:未來的一切都已經濃縮在 \(V^\pi(s')\) 這個數字裡。Q 版本同理,只是第一步的行動已指定:\(Q^\pi(s,a) = \sum_{s'} P(s'\mid s,a)[\,r + \gamma V^\pi(s')\,]\)。

以上描述「照著給定政策走」的帳。若問的是「最好能多好」,把政策的加權平均換成挑最大,就得到Bellman 最適方程

$$ V^*(s) = \max_{a} \sum_{s'} P(s'\mid s,a)\,\big[\, r(s,a,s') + \gamma\, V^*(s') \,\big], \qquad Q^*(s,a) = \sum_{s'} P(s'\mid s,a)\,\big[\, r + \gamma \max_{a'} Q^*(s',a') \,\big] $$

逐項拆解:\(V^*\) 版在根部取 \(\max\)——當下就挑最好的行動;\(Q^*\) 版把 \(\max\) 推遲到下一狀態——先做指定的 a,之後每一步都挑最好的 \(a'\)。注意 \(\max\) 讓方程變非線性:期望方程是線性方程組、可以直接解,最適方程只能迭代逼近——這個差別決定了下一節兩條演算法路線的分工。

圖上這棵倒立的樹叫備份圖:值從樹葉「備份」回樹根。左邊是期望備份——各枝加權平均;右邊是最適備份——只挑最好的那枝。圖中的數字不是示意,是接下來兩節用出清 MDP 實跑的結果:庫存 2 股時,三個行動的 \(Q^*\) 分別是 1.8755(M1)、1.8520(L1)、1.8700(M2),取最大得 \(V^*(2)=1.8755\);而「一律掛限價慢慢磨」政策的 \(V(2)=1.8376\)——差額 0.0379 就是換成最適政策能多賺的錢。

五、模型已知時:動態規劃三兄弟

先考慮一個奢侈的情境:轉移機率 \(P\) 與報酬結構全部已知(出清 MDP 正是如此,因為環境是我們自己造的)。這時 Bellman 方程不只是性質描述,而是演算法配方,統稱動態規劃(DP)。三兄弟如下。

政策評估(policy evaluation):給定政策 \(\pi\),求 \(V^\pi\)。期望方程是線性方程組,狀態少可直接解;也可以把方程當更新規則反覆掃過所有狀態,迭代收斂到 \(V^\pi\)。

政策迭代(policy iteration):評估與改進輪流做——先評估目前政策得到 \(V^\pi\),再對每個狀態「貪婪化」:改選讓 \(r + \gamma V^\pi(s')\) 期望最大的行動。可以證明新政策不會更差、通常嚴格更好;反覆「評估→貪婪化」,有限步內收斂到 \(\pi^*\)。

價值迭代(value iteration):不等評估做完,直接拿最適方程當更新規則——每輪對每個狀態取 \(\max\) 更新一次 V。它能收斂的理由值得記住:最適方程右邊那個運算子是收縮映射——任兩個價值函數經它一刷,最大差距至少縮成 \(\gamma\) 倍(sup-norm 意義下),所以誤差以幾何速度歸零,且不動點唯一。白話:每迭代一次,你和正確答案的距離至少打 95 折,而且複利地打下去。

在出清 MDP 上實跑價值迭代:

PYTHON
import numpy as np

GAMMA = 0.95          # 折現因子
FILL  = 0.6           # 限價單成交機率
HOLD  = 0.02          # 每股每期持有成本
P_MKT1, P_MKT2, P_LMT = 0.97, 0.935, 1.02  # 每股成交價

def actions(q):
    """庫存 q 時的合法行動:M1 市價賣1、M2 市價賣2、L1 限價賣1"""
    acts = []
    if q >= 1: acts += ["M1", "L1"]
    if q >= 2: acts += ["M2"]
    return acts

def outcomes(q, a):
    """回傳 [(機率, 即時報酬, 下一庫存), ...]"""
    if a == "M1":   fills = [(1.0, 1, P_MKT1)]
    elif a == "M2": fills = [(1.0, 2, P_MKT2)]
    else:           fills = [(FILL, 1, P_LMT), (1 - FILL, 0, 0.0)]  # L1
    return [(p, n * px - HOLD * (q - n), q - n) for p, n, px in fills]

V = np.zeros(5)       # V[0..4],庫存 0 是吸收態、價值恆為 0
for it in range(1, 200):
    V_new = np.zeros(5)
    for q in range(1, 5):
        V_new[q] = max(sum(p * (r + GAMMA * V[q2]) for p, r, q2 in outcomes(q, a))
                       for a in actions(q))
    err = float(np.abs(V_new - V).max())
    V = V_new
    if it in (1, 2, 5, 10, 20, 30) or err < 1e-10:
        print(f"迭代 {it:>3d}|V*(4)={V[4]:.4f}|sup-norm 誤差 = {err:.2e}")
    if err < 1e-10:
        break

policy = {q: max(actions(q), key=lambda a: sum(p * (r + GAMMA * V[q2])
                 for p, r, q2 in outcomes(q, a))) for q in range(1, 5)}
print("V* =", np.round(V, 4))
print("最適政策:", policy)
# 輸出:
# 迭代   1|V*(4)=1.8300|sup-norm 誤差 = 1.87e+00
# 迭代   2|V*(4)=3.6065|sup-norm 誤差 = 1.78e+00
# 迭代   5|V*(4)=3.6112|sup-norm 誤差 = 8.92e-04
# 迭代  10|V*(4)=3.6117|sup-norm 誤差 = 7.07e-06
# 迭代  20|V*(4)=3.6117|sup-norm 誤差 = 4.44e-10
# 迭代  22|V*(4)=3.6117|sup-norm 誤差 = 6.41e-11
# V* = [0.     0.9742 1.8755 2.7755 3.6117]
# 最適政策: {1: 'L1', 2: 'M1', 3: 'M2', 4: 'M2'}

兩個看點。收斂速度:22 輪就把誤差壓到 \(10^{-10}\),且第 5 輪之後每輪誤差約乘 0.38——比理論上限 \(\gamma=0.95\) 快得多,因為只有限價單不成交那一枝(機率 0.4)會留在原狀態,有效收縮率約 \(0.4\times 0.95=0.38\)。政策本身:最適政策是一道漂亮的梯度——庫存 4、3 股時用市價單一次倒兩股(M2,衝擊成本痛,但庫存風險更痛),剩 2 股改市價單一股一股出(M1),最後 1 股才掛限價單慢慢磨價(L1,這時持有成本已小,值得賭那六成機率的好價格)。「大部位求快、小部位求好價」——實務交易員的直覺,從五要素和一條遞迴方程裡自己長了出來。這正是把問題寫成 MDP 的回報:你放進去的是成本結構,拿出來的是行為。

那為什麼不到此為止?因為 DP 的前提在真實金融裡幾乎永遠不成立:你不知道 \(P\)。限價單在真實限價簿裡的成交機率、大單下去價格怎麼移動——這些是市場微結構最難的問題,寫得出模型也未必敢信。模型未知時怎麼辦?兩條路:把模型學出來再做 DP(model-based),或者跳過模型、直接從互動經驗學價值與政策(model-free)。本章走第二條路,它是 RL 的主流。

六、模型未知時(上):MC 與 TD,用樣本代替期望

Bellman 方程裡到處是期望值,而期望值的萬用平價替代品是樣本平均。model-free 方法的全部秘密就這一句:拿不到機率分布,就拿抽樣結果來平均。差別只在「什麼時候平均」。

蒙地卡羅(MC)法:跑完一整集(episode),把實際發生的回報 \(G_t\) 從尾巴往前結算,拿它當 \(V(s)\) 的一筆樣本,用樣本平均更新。它是無偏的——\(G_t\) 的期望就是真的 \(V^\pi(s)\)——但方差大(一整集的隨機性全疊在一筆樣本裡),而且必須等集結束才能更新。

時序差分(TD)法:不等結局,每走一步就用 Bellman 遞迴「先斬後奏」地更新:

$$ V(S_t) \leftarrow V(S_t) + \alpha\,\underbrace{\big[\, R_{t+1} + \gamma V(S_{t+1}) - V(S_t) \,\big]}_{\delta_t\ \text{:TD 誤差}} $$

逐項拆解:\(R_{t+1} + \gamma V(S_{t+1})\) 是走了一步之後對回報的新估計——實拿的報酬加上下一站的現有身價(拿自己的估計更新自己的估計,術語叫自助,bootstrapping);減去舊估計 \(V(S_t)\) 得到 TD 誤差 \(\delta_t\)——「驚訝的量」:正的 \(\delta\) 表示這一步的遭遇比預期好,該上修;\(\alpha\) 是步長,決定每次修多少。TD 的代價是有偏——早期的 \(V(S_{t+1})\) 本身就不準,用它當靶子會把誤差傳染出去;換來的是方差小、能線上逐步更新、不需要等集結束(對接近連續營運的交易系統是剛需)。

比喻: 兩種記帳性格。MC 是月結派:整個月刷卡不看帳,月底帳單來了才總結算「這個月原來花了這麼多」,據此修正對自己開銷的估計——結算絕對真實,但一個月只學一次,而且單月的大額意外(婚禮紅包)會讓估計大幅跳動。TD 是日結派:每天睡前用「今天實際花的+對月底的最新預估」滾動修正——每天都在學、曲線平滑,但如果你對月底的預估本來就偏,每天的修正都會被這個偏差帶著走,要一段時間才會自我糾正。

在出清 MDP 上做政策評估實驗——被評估的是「不論庫存多少一律掛限價單」的慢慢磨政策(不是最適政策,評誰是我們指定的)。因為環境是自己造的,可以先解出精確答案當對照:

PYTHON
import numpy as np

rng = np.random.default_rng(9)
GAMMA, FILL, HOLD, P_LMT = 0.95, 0.6, 0.02, 1.02

# 被評估的政策:不論庫存多少,一律掛限價單賣 1 股(「慢慢磨」政策)
# 精確解:V(q) = 0.6[1.02 - 0.02(q-1) + γV(q-1)] + 0.4[-0.02q + γV(q)]
V_exact = np.zeros(5)
for q in range(1, 5):
    V_exact[q] = (FILL * (P_LMT - HOLD * (q - 1)) - (1 - FILL) * HOLD * q
                  + FILL * GAMMA * V_exact[q - 1]) / (1 - (1 - FILL) * GAMMA)
print("精確 V^pi =", np.round(V_exact, 4))

def episode():
    """跑一集:從庫存 4 出發掛限價單到出清,回傳 [(狀態, 報酬), ...]"""
    q, traj = 4, []
    while q > 0:
        if rng.random() < FILL:
            traj.append((q, P_LMT - HOLD * (q - 1))); q -= 1
        else:
            traj.append((q, -HOLD * q))
    return traj

V_mc, n_mc = np.zeros(5), np.zeros(5)      # MC:首訪、樣本平均
V_td, ALPHA = np.zeros(5), 0.05            # TD(0):常數步長
for ep in range(1, 10001):
    traj = episode()
    G, ret = 0.0, {}                        # 從後往前算各狀態的回報
    for s, r in reversed(traj):
        G = r + GAMMA * G
        ret[s] = G                          # 迴圈結束時留下的是首訪回報
    for s, G in ret.items():
        n_mc[s] += 1
        V_mc[s] += (G - V_mc[s]) / n_mc[s]
    for i, (s, r) in enumerate(traj):       # TD(0):逐步自助更新
        s2 = traj[i + 1][0] if i + 1 < len(traj) else 0
        V_td[s] += ALPHA * (r + GAMMA * V_td[s2] - V_td[s])
    if ep in (100, 1000, 10000):
        print(f"{ep:>5d} 集|MC V(4)={V_mc[4]:.4f}|TD V(4)={V_td[4]:.4f}"
              f"|精確={V_exact[4]:.4f}")
# 輸出:
# 精確 V^pi = [0.     0.9742 1.8376 2.5991 3.2669]
#   100 集|MC V(4)=3.2645|TD V(4)=3.0250|精確=3.2669
#  1000 集|MC V(4)=3.2798|TD V(4)=3.3029|精確=3.2669
# 10000 集|MC V(4)=3.2721|TD V(4)=3.2684|精確=3.2669

兩條路都通到精確值 3.2669:一萬集後 MC 收在 3.2721、TD 收在 3.2684。過程的性格差異也如理論所料:MC 一百集就落在 3.2645(無偏,樣本平均直奔靶心),TD 一百集還在 3.0250——它正拖著「下一站身價還沒估準」的自助偏差暖機,之後才慢慢貼上來。另外注意:慢慢磨政策的 \(V^\pi(4)=3.2669\),比上一節最適政策的 \(V^*(4)=3.6117\) 少了 0.34——一味求好價、無視庫存風險,四股要磨平均快七期,持有成本與折現把價格優勢吃掉還倒貼。

七、模型未知時(下):SARSA、Q-learning 與探索的代價

政策評估只是踏板,真正要的是控制:找 \(\pi^*\)。把 TD 的思想從 V 搬到 Q(第三節說過,模型未知時 Q 才能直接選行動),得到兩個只差一個字、性格迥異的演算法。

SARSA(on-policy):走一步,看自己實際接著做了哪個行動 \(A_{t+1}\),用它的 Q 值當靶子:

$$ Q(S_t, A_t) \leftarrow Q(S_t, A_t) + \alpha\,\big[\, R_{t+1} + \gamma\, Q(S_{t+1}, A_{t+1}) - Q(S_t, A_t) \,\big] $$

Q-learning(off-policy):不管自己實際做了什麼,一律用下一狀態最好的行動當靶子:

$$ Q(S_t, A_t) \leftarrow Q(S_t, A_t) + \alpha\,\big[\, R_{t+1} + \gamma \max_{a'} Q(S_{t+1}, a') - Q(S_t, A_t) \,\big] $$

逐項拆解兩式的唯一差別——靶子裡那一項:SARSA 用 \(Q(S_{t+1}, A_{t+1})\),評的是「實際的自己」——包含探索時的隨機亂步;Q-learning 用 \(\max_{a'} Q(S_{t+1}, a')\),評的是「假想中從此刻起完全貪婪的自己」。所以 SARSA 學到的是「我這種會手滑的人,這麼走值多少」,Q-learning 學到的是「完美執行的話,這麼走值多少」——即使產生資料的行為政策根本不完美。這就是 on-policy/off-policy 之分:學習目標跟不跟著行為政策走。off-policy 是一種解耦:用誰的經驗都能學最適政策——別人的、過去的、探索時亂走的,這個性質等一下會變成金融應用的命脈。

但兩者都得面對同一個前置問題:探索與利用。一直挑當前 Q 最高的行動(利用),沒試過的行動的 Q 永遠停在初始值,錯的永遠不會被發現;一直亂試(探索),又把已知的好行動晾在一邊。最簡單的折衷是 ε-greedy:以機率 \(1-\varepsilon\) 挑最好的,以機率 \(\varepsilon\) 隨機試。

比喻: 中午吃什麼的兩難。全然利用=每天都吃巷口那家熟悉的老店,穩,但你永遠不會知道兩條街外上個月新開的那家其實好吃得多;全然探索=每天換一家沒吃過的,見多識廣,但平均伙食水準被大量地雷店拖垮。ε-greedy 就是「十次有九次吃老店、一次試新店」。在餐廳問題裡試錯的代價是一頓難吃的午飯;在金融裡,每一次探索都是用真錢下一張可能很蠢的單——這就是為什麼金融 RL 對探索特別小氣。

實跑:把 Q-learning 丟進出清 MDP,agent 看不到轉移機率,只能透過 step() 這個黑盒與環境互動。因為第五節已解出精確 \(Q^*\),可以全程監測它離答案多遠:

PYTHON
import numpy as np

rng = np.random.default_rng(21)
GAMMA, FILL, HOLD = 0.95, 0.6, 0.02
P_MKT1, P_MKT2, P_LMT = 0.97, 0.935, 1.02

def actions(q):
    return (["M1", "L1"] if q >= 1 else []) + (["M2"] if q >= 2 else [])

def outcomes(q, a):
    if a == "M1":   fills = [(1.0, 1, P_MKT1)]
    elif a == "M2": fills = [(1.0, 2, P_MKT2)]
    else:           fills = [(FILL, 1, P_LMT), (1 - FILL, 0, 0.0)]
    return [(p, n * px - HOLD * (q - n), q - n) for p, n, px in fills]

def step(q, a):
    """環境黑盒:抽樣一次轉移。agent 只看得到這個函式的輸出"""
    outs = outcomes(q, a)
    i = rng.choice(len(outs), p=[p for p, _, _ in outs])
    return outs[i][1], outs[i][2]

# 對照組:用第五節的方法迭代出精確 Q*(agent 不知道這個答案)
Q_star = {q: {a: 0.0 for a in actions(q)} for q in range(1, 5)}
for _ in range(300):
    for q in range(1, 5):
        for a in actions(q):
            Q_star[q][a] = sum(p * (r + GAMMA * (max(Q_star[q2].values()) if q2 else 0.0))
                               for p, r, q2 in outcomes(q, a))

# 表格式 Q-learning:只靠抽樣互動學 Q,不碰轉移機率
Q = {q: {a: 0.0 for a in actions(q)} for q in range(1, 5)}
N = {q: {a: 0 for a in actions(q)} for q in range(1, 5)}
EPS = 0.2                                      # ε-greedy 探索率
for ep in range(1, 20001):
    q = int(rng.integers(1, 5))                # 隨機初始庫存,保證覆蓋
    while q > 0:
        acts = actions(q)
        if rng.random() < EPS:                 # 探索:隨機挑一個行動
            a = acts[int(rng.integers(len(acts)))]
        else:                                  # 利用:挑目前 Q 值最高的
            a = max(acts, key=lambda x: Q[q][x])
        r, q2 = step(q, a)
        N[q][a] += 1
        alpha = 1.0 / N[q][a] ** 0.7           # 隨訪問次數衰減的步長
        target = r + GAMMA * (max(Q[q2].values()) if q2 else 0.0)
        Q[q][a] += alpha * (target - Q[q][a])  # off-policy:target 用 max
        q = q2
    if ep in (10, 100, 1000, 5000, 20000):
        gap = max(abs(Q[q][a] - Q_star[q][a]) for q in range(1, 5) for a in actions(q))
        print(f"{ep:>5d} 集|max|Q - Q*| = {gap:.4f}")

print("學到的政策:", {q: max(actions(q), key=lambda a: Q[q][a]) for q in range(1, 5)})
print("q=1 精確 vs 學到:", {a: (round(Q_star[1][a], 4), round(Q[1][a], 4))
                            for a in actions(1)})
# 輸出:
#    10 集|max|Q - Q*| = 3.6117
#   100 集|max|Q - Q*| = 0.7633
#  1000 集|max|Q - Q*| = 0.0931
#  5000 集|max|Q - Q*| = 0.0219
# 20000 集|max|Q - Q*| = 0.0101
# 學到的政策: {1: 'L1', 2: 'M1', 3: 'M2', 4: 'M2'}
# q=1 精確 vs 學到: {'M1': (0.97, 0.97), 'L1': (0.9742, 0.9749)}

值得慢讀的三件事。第一,agent 從頭到尾沒看過機率——不知道限價單六成成交、不知道任何成交價——兩萬集互動後 Q 表離 \(Q^*\) 不到 0.01,學到的政策與第五節動態規劃解出的 \(\pi^*\) 一字不差。第二,看 q=1 那行:M1L1 的真實 Q 值只差 0.0042(0.97 對 0.9742),純靠抽樣把這麼細的差距排對順序,靠的是步長衰減(早期學得快、後期學得穩)與足夠的探索(ε=0.2 保證 L1 被試了夠多次)。第三,錯開步長與探索任何一個,這個排序就會錯——作業二會讓你親手把它弄壞。

八、狀態空間爆炸之後:函數逼近、批次學習與深度 RL

出清 MDP 只有 4 個狀態、9 個合法狀態–行動對,一張表就裝下了。真實問題不是這樣:狀態一旦包含連續的價格、部位、簿況,組合是天文數字,表格式方法當場出局——不是算力問題,是你永遠不可能每格都訪問過。

出路是函數逼近:不再一格一格記 Q,改用參數化函數 \(Q(s,a;\theta)\)——線性模型、樹、或神經網路——讓相似的狀態共享估計,用第一部學過的監督式機器把 TD 靶子當「偽標籤」去擬合。參數化之後,Q-learning 的更新變成對 \(\theta\) 的梯度步;當 \(Q(s,a;\theta)\) 是深度網路,就是深度強化學習(deep RL),2015 年 DQN 在 Atari 遊戲上以像素輸入練到人類水準是其成名作。但要誠實:函數逼近、自助、off-policy 三件事疊在一起,收斂保證大多失效,訓練以不穩定聞名——工程補丁(經驗回放、目標網路)是在馴服這個本質問題,不是消滅它。

對金融讀者,比「更深的網路」重要得多的是另一個維度:批次(batch)/離線學習。前面的演算法都假設能與環境即時互動、邊試邊學——線上學習。金融裡這幾乎是奢望:沒有人會讓一個半生不熟的 agent 拿真錢在市場裡探索。現實是你手上有的是歷史資料:過去的下單紀錄、成交回報、簿況快照——一堆別人(或過去的自己)的 \((s, a, r, s')\) 四元組。而第七節講的 off-policy 性質恰好是為此而生:Q-learning 不要求資料是誰產生的,把歷史四元組反覆餵給它(batch-mode Q-learning、fitted Q iteration,線性版的經典是 LSPI),就能在完全不下一張新單的前提下,從死資料裡萃取出活政策。這是 RL 之於金融最務實的一條路,也是下一章選擇權定價與組合管理應用的方法底座。當然,離線學習有自己的暗礁:資料裡沒出現過的狀態–行動組合,你學到的 Q 純屬外插——資料覆蓋不到的地方,政策再自信都是幻覺。

九、誠實限界:把醜話說在前面

按本課慣例,工具講完,講它會在哪裡騙你。

樣本效率。出清 MDP 區區 9 個 Q 值花了兩萬集才收斂到 0.01——RL 是出了名的吃資料。遊戲裡可以無限重開,市場不行:日頻資料一年只給你 252 筆,你想學的環境卻高維又充滿雜訊。金融 RL 的實務幾乎都繞不開模擬器或離線資料,而模擬器本身就是一個模型——model-free 方法吃著 model-based 的飯,模型錯了照樣一起錯,這筆帳要記得。

非平穩。MDP 假設轉移機率恆定,但市場的 \(P\) 會換:波動制度切換、參與者結構改變、你的策略被同行學走。辛苦收斂的 \(Q^*\) 是對那個市場的最適解,市場換了臉,舊 Q 表就是刻舟求劍。第 7 章的制度切換模型與這裡是同一個警覺的兩種表達。

報酬設計。報酬函數是你告訴 agent「什麼叫好」的唯一管道,而 agent 只會忠於字面。只給損益、不扣風險,它會學成扛著尾部風險賺小錢的策略——直到爆掉那天;忘了寫進交易成本,它會學出瘋狂換手的政策。RL 不會讀你的心,只會鑽你的漏;報酬函數寫錯,演算法越強、錯得越快。

部分可觀測。你以為的狀態往往不是完整狀態:限價簿看得到五檔,看不到冰山單;看得到價格,看不到對手的庫存。狀態裝不飽,馬可夫性破功,所有依賴它的收斂論證都要打折。務實對策是把狀態向量餵飽(加入歷史特徵、微結構訊號),並對「還是不飽」保持敬畏。

即便如此,方向是清楚的:金融的核心問題——執行、對沖、配置——本質上都是序列決策,強化學習是第一個把這件事原生寫進數學的框架。下一章就把這套語言用上真問題:從 Black–Scholes 的 RL 重寫到動態投資組合。

§03原書對照

本章對應原書第九章。特別說明:本課可核對的原書素材僅章首導言兩頁(印刷頁 pp.279–280),以下其餘節名依原書草稿版目錄整理,無出版頁碼可核。導言將本章定位為 Part 3 的方法地基:先建立 MDP 與經典動態規劃,再過渡到 RL 與各種近似解法,行文刻意偏工程風格,並以資產管理與交易的迷你案例收尾;章目標五條——熟悉 MDP、理解 Bellman 方程與經典動態規劃、掌握 RL 與近似求解的思想、分清 on-policy 與 off-policy、建立 RL 在資管與交易應用的洞見——本課全數覆蓋,關鍵計算概念原書點名為「greediness, batch learning, and Q-learning」(p. 279)。導言另把 RL 與監督式/非監督式學習並列比較,強調老師角色的差異與「部分回饋」的性質(pp.279–280),與本課第一節同題但敘事線不同。依草稿目錄,原書該章結構為:第 2 節 RL 的元素(報酬、價值與政策函數、可觀測與部分可觀測環境);第 3 節 MDP(決策政策、價值函數與 Bellman 方程、最適政策與 Bellman 最適性);第 4 節動態規劃方法(政策評估、政策迭代、價值迭代);第 5 節 RL 方法(蒙地卡羅、policy-based 學習、時序差分、SARSA 與 Q-learning、隨機逼近與批次 Q-learning、連續空間的函數逼近、批次 Q-learning、LSPI、深度 RL)。對照之下,原書有、本課未展開或僅點到的內容包括:其一,部分可觀測環境(POMDP)的正式處理——本課只在第二、九節做概念性提醒;其二,policy-based 方法(直接對政策做最佳化而不繞道價值函數)——本課完全走 value-based 路線;其三,隨機逼近理論——Q-learning 收斂性背後的 Robbins–Monro 條件與步長理論,本課只以「步長衰減」帶過;其四,LSPI(最小平方政策迭代)與連續狀態空間函數逼近的完整推導;其五,批次 Q-learning 在原書目錄中出現兩次(5.5 隨機逼近脈絡與 5.7 獨立小節),顯示作者把「從固定歷史資料集學 Q」視為金融場景的重點方法,本課第八節僅述其思想、未展開演算法細節;其六,穿插於原書行文的資管與交易迷你案例及配套 Python notebooks(章目標頁提及 notebooks 收錄於附錄,p.280)。另提醒兩點:原書草稿目錄標記本章起於第 307 頁,與出版印刷頁(第 279 頁起)不一致,引用時應以出版頁碼為準;本課的出清 MDP、全部數字與實驗皆為自創教學設計,原書並無對應例子。進階讀者可循原書該章與 Sutton–Barto 教科書補齊上述縱深。

§04作業和解答

作業一:γ 是耐心的價格

第五節的出清 MDP 用 \(\gamma=0.95\)。保持其他參數不變,分別用 \(\gamma=0.80\) 與 \(\gamma=0.99\) 重跑價值迭代,觀察最適政策怎麼變,並解釋:折現因子在這個問題裡扮演什麼經濟角色?

解答 SOLUTION
PYTHON
import numpy as np

FILL, HOLD = 0.6, 0.02
P_MKT1, P_MKT2, P_LMT = 0.97, 0.935, 1.02

def actions(q):
    return (["M1", "L1"] if q >= 1 else []) + (["M2"] if q >= 2 else [])

def outcomes(q, a):
    if a == "M1":   fills = [(1.0, 1, P_MKT1)]
    elif a == "M2": fills = [(1.0, 2, P_MKT2)]
    else:           fills = [(FILL, 1, P_LMT), (1 - FILL, 0, 0.0)]
    return [(p, n * px - HOLD * (q - n), q - n) for p, n, px in fills]

def solve(gamma):
    V = np.zeros(5)
    for _ in range(2000):
        V_new = np.zeros(5)
        for q in range(1, 5):
            V_new[q] = max(sum(p * (r + gamma * V[q2]) for p, r, q2 in outcomes(q, a))
                           for a in actions(q))
        if np.abs(V_new - V).max() < 1e-12:
            V = V_new; break
        V = V_new
    pi = {q: max(actions(q), key=lambda a: sum(p * (r + gamma * V[q2])
                 for p, r, q2 in outcomes(q, a))) for q in range(1, 5)}
    return V, pi

for gamma in (0.80, 0.95, 0.99):
    V, pi = solve(gamma)
    print(f"γ={gamma:.2f}|V*(4)={V[4]:.4f}|政策={pi}")
# 輸出:
# γ=0.80|V*(4)=3.3260|政策={1: 'M1', 2: 'M2', 3: 'M2', 4: 'M2'}
# γ=0.95|V*(4)=3.6117|政策={1: 'L1', 2: 'M1', 3: 'M2', 4: 'M2'}
# γ=0.99|V*(4)=3.7608|政策={1: 'L1', 2: 'L1', 3: 'M1', 4: 'M2'}

解讀:γ 在這裡就是耐心的價格。\(\gamma=0.80\) 時未來大打折,等待限價單成交的那六成機率「以後才拿錢」變得不值——最適政策全面轉向市價單,連最後一股都直接倒掉(全 M 政策);\(\gamma=0.95\) 出現本章正文的梯度政策;\(\gamma=0.99\) 時未來幾乎不打折,耐心近乎免費,政策整體向限價單傾斜——剩 2 股就開始掛單磨價,只有庫存最重時才用一次倒兩股的市價單。同一個市場、同一套成本,只動「時間偏好」這一顆旋鈕,行為從「急性子清倉」連續變形到「慢工出細活」。這也是實務警訊:γ 常被當成隨手設的超參數,但它直接改寫最適行為——資金成本高、隔夜風險大的臺股當沖帳戶與可以擺三個月的法人帳戶,本來就不該用同一個 γ。

作業二:把探索調壞給自己看

第七節的 Q-learning 用 \(\varepsilon=0.2\)。固定訓練 5000 集,分別用 \(\varepsilon\in\{0.01, 0.1, 0.4\}\) 重跑,比較:(1) 與精確 \(Q^*\) 的最大差距;(2) 訓練期間的每集平均報酬(線上成績);(3) 學到的政策對不對。解釋觀察到的取捨。

解答 SOLUTION
PYTHON
import numpy as np

GAMMA, FILL, HOLD = 0.95, 0.6, 0.02
P_MKT1, P_MKT2, P_LMT = 0.97, 0.935, 1.02

def actions(q):
    return (["M1", "L1"] if q >= 1 else []) + (["M2"] if q >= 2 else [])

def outcomes(q, a):
    if a == "M1":   fills = [(1.0, 1, P_MKT1)]
    elif a == "M2": fills = [(1.0, 2, P_MKT2)]
    else:           fills = [(FILL, 1, P_LMT), (1 - FILL, 0, 0.0)]
    return [(p, n * px - HOLD * (q - n), q - n) for p, n, px in fills]

Q_star = {q: {a: 0.0 for a in actions(q)} for q in range(1, 5)}
for _ in range(300):
    for q in range(1, 5):
        for a in actions(q):
            Q_star[q][a] = sum(p * (r + GAMMA * (max(Q_star[q2].values()) if q2 else 0.0))
                               for p, r, q2 in outcomes(q, a))

def run(eps, episodes=5000, seed=7):
    rng = np.random.default_rng(seed)
    Q = {q: {a: 0.0 for a in actions(q)} for q in range(1, 5)}
    N = {q: {a: 0 for a in actions(q)} for q in range(1, 5)}
    total_reward = 0.0
    for ep in range(episodes):
        q = int(rng.integers(1, 5))
        while q > 0:
            acts = actions(q)
            if rng.random() < eps:
                a = acts[int(rng.integers(len(acts)))]
            else:
                a = max(acts, key=lambda x: Q[q][x])
            outs = outcomes(q, a)
            i = rng.choice(len(outs), p=[p for p, _, _ in outs])
            _, r, q2 = outs[i]
            total_reward += r
            N[q][a] += 1
            target = r + GAMMA * (max(Q[q2].values()) if q2 else 0.0)
            Q[q][a] += (target - Q[q][a]) / N[q][a] ** 0.7
            q = q2
    gap = max(abs(Q[q][a] - Q_star[q][a]) for q in range(1, 5) for a in actions(q))
    pi = {q: max(actions(q), key=lambda a: Q[q][a]) for q in range(1, 5)}
    return gap, total_reward / episodes, pi

for eps in (0.01, 0.1, 0.4):
    gap, avg_r, pi = run(eps)
    print(f"ε={eps:.2f}|max|Q-Q*|={gap:.4f}|每集平均報酬={avg_r:.4f}|政策={pi}")
# 輸出:
# ε=0.01|max|Q-Q*|=0.1122|每集平均報酬=2.3694|政策={1: 'M1', 2: 'M1', 3: 'M2', 4: 'M2'}
# ε=0.10|max|Q-Q*|=0.0565|每集平均報酬=2.3758|政策={1: 'M1', 2: 'M1', 3: 'M2', 4: 'M2'}
# ε=0.40|max|Q-Q*|=0.0161|每集平均報酬=2.4034|政策={1: 'L1', 2: 'M1', 3: 'M2', 4: 'M2'}

解讀:\(\varepsilon=0.01\) 與 0.1 都學了 q=1 的行動(挑了 M1 而非 L1)——M1L1 的真值只差 0.0042,探索太少時 L1 的樣本不夠,估計的雜訊蓋過真實差距,排序翻車;\(\varepsilon=0.4\) 才把政策學全對,且 Q 表誤差最小(0.0161)。更值得玩味的是線上成績:這個環境裡 ε 越大連訓練期間的平均報酬都越高(2.3694 → 2.4034)——因為此處所有行動都不算災難(最差的行動也只是「次好」),探索的即期代價低,換來的估值品質紅利反而更大。這是重要的細部教訓:探索的代價取決於環境的懲罰結構,不是一句「探索必然犧牲短期」能概括。但別把它外推到真市場——作業三的環境裡就存在一步致命的行動,那時探索的每一次手滑都是真實的大額虧損,兩題對照著看才完整。

作業三:SARSA 與 Q-learning 在風控懸崖邊的分歧

設計一個「執行網格」:3 列 × 7 行,起點在左下、終點在右下,底列中段五格是強制平倉區——踩進去罰 40 分並拖回起點;其餘每走一步罰 1 分(時間成本),\(\gamma=1\)。用 \(\varepsilon=0.1\) 的 ε-greedy 分別訓練 SARSA 與 Q-learning 各 3000 集,比較:(1) 關掉探索後的貪婪路徑;(2) 訓練後期(最後 1000 集)的平均每集報酬。解釋為什麼兩者學出不同的路。

解答 SOLUTION
PYTHON
import numpy as np

# 3 列 × 7 行的執行網格:起點左下、終點右下,底列中段是「強制平倉區」
# 踩進去:-40 分並被拖回起點。每走一步 -1 分。γ=1(episodic)
ROWS, COLS = 3, 7
START, GOAL = (2, 0), (2, 6)
CLIFF = {(2, c) for c in range(1, 6)}
MOVES = {"U": (-1, 0), "D": (1, 0), "L": (0, -1), "R": (0, 1)}

def step(s, a):
    dr, dc = MOVES[a]
    r2 = min(max(s[0] + dr, 0), ROWS - 1)
    c2 = min(max(s[1] + dc, 0), COLS - 1)
    if (r2, c2) in CLIFF:
        return -40.0, START          # 觸發風控:重罰並拖回起點
    return -1.0, (r2, c2)

def train(algo, episodes=3000, alpha=0.5, eps=0.1, seed=3):
    rng = np.random.default_rng(seed)
    Q = {(r, c): {a: 0.0 for a in MOVES}
         for r in range(ROWS) for c in range(COLS)}
    def choose(s):
        if rng.random() < eps:
            return "UDLR"[int(rng.integers(4))]
        return max(MOVES, key=lambda a: Q[s][a])
    returns = []
    for _ in range(episodes):
        s, a, G = START, None, 0.0
        a = choose(s)
        while s != GOAL:
            r, s2 = step(s, a)
            G += r
            if s2 == GOAL:
                Q[s][a] += alpha * (r - Q[s][a])
                break
            a2 = choose(s2)
            if algo == "sarsa":                  # on-policy:用實際會走的 a2
                target = r + Q[s2][a2]
            else:                                # q-learning:用 max(假想貪婪)
                target = r + max(Q[s2].values())
            Q[s][a] += alpha * (target - Q[s][a])
            s, a = s2, a2
        returns.append(G)
    # 貪婪路徑(關掉探索走一遍)
    path, s, n = [START], START, 0
    while s != GOAL and n < 30:
        a = max(MOVES, key=lambda x: Q[s][x])
        _, s = step(s, a)
        path.append(s); n += 1
    return np.mean(returns[-1000:]), path

for algo in ("q-learning", "sarsa"):
    avg, path = train(algo)
    rows = "".join(str(r) for r, _ in path)
    print(f"{algo:>10s}|最後 1000 集平均報酬 = {avg:.2f}|貪婪路徑列號 = {rows}"
          f"|路徑長 = {len(path) - 1}")
# 輸出:
# q-learning|最後 1000 集平均報酬 = -17.16|貪婪路徑列號 = 211111112|路徑長 = 8
#      sarsa|最後 1000 集平均報酬 = -16.11|貪婪路徑列號 = 21100000012|路徑長 = 10

解讀:看貪婪路徑的列號序列——Q-learning 走 211111112:上到中列(列 1)後貼著懸崖直線衝刺,8 步到終點,是全域最短路;SARSA 走 21100000012:多爬一層到頂列(列 0)繞遠路,10 步才到。原因就在靶子那一項:Q-learning 評估的是「假想中完全貪婪的自己」——那個自己永遠不手滑,貼崖路線在它的帳本上就是最優;SARSA 評估的是「實際帶著 ε 手滑的自己」——貼崖走時每步都有約 2.5% 機率隨機踩崖(\(\varepsilon/4\)),期望罰款把貼崖格的 Q 值壓低,於是它學會繞開。訓練期間的成績單印證了這一點:SARSA 的線上平均報酬 −16.11 優於 Q-learning 的 −17.16——只要你還在探索,貼崖衝刺就會定期付出 −40 的代價。金融翻譯:Q-learning 給你「理想執行下的最適策略」,SARSA 給你「考慮到自己會出錯時的穩健策略」;上線的系統帶著雜訊與滑價在跑,兩者的差距就是紙上最優與實盤穩健之差。該用哪個,取決於你上線後還探不探索、以及踩一次崖賠不賠得起。

§05參考資料