时序差分学习 — 实验#

验证 temporal-difference-learning.tex 中的核心结论:

  1. TD vs MC(随机游走):同一份数据下,TD(0) 的价值估计比蒙特卡洛更准(偏差-方差权衡);

  2. N 步回报:\(n\) 越大越接近 MC(低偏差高方差),\(n\) 越小越依赖 bootstrapping(高偏差低方差);

  3. on-policy vs off-policy(Cliff Walking):SARSA 学到保守安全的策略,Q-Learning 学到最优但训练时更“冒险”。

环境:

  • 5 状态随机游走(Sutton & Barto 例 6.2):状态 A–E,左端终点奖励 0、右端终点奖励 +1,\(\gamma=1\);

  • CliffWalking-v0(Gymnasium,Sutton & Barto 例 6.6):\(4\times12\) 悬崖行走,普通步 \(-1\),掉下悬崖 \(-100\) 回到起点。

输出图:

  • fig1_random_walk_mc_vs_td.pdf

  • fig2_nstep_bias_variance.pdf

  • fig3_cliff_sarsa_vs_qlearning.pdf

预计运行时间:< 2 分钟(CPU)。

%matplotlib inline
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt

# ── Academic style ──────────────────────────────────────────────
mpl.rcParams.update({
    'font.family':        'serif',
    'font.serif':         ['Times New Roman', 'DejaVu Serif', 'serif'],
    'font.size':          11,
    'axes.labelsize':     12,
    'axes.titlesize':     13,
    'axes.titleweight':   'bold',
    'axes.titlepad':      10,
    'xtick.labelsize':    10,
    'ytick.labelsize':    10,
    'legend.fontsize':    10,
    'legend.framealpha':  0.9,
    'legend.edgecolor':   '#CCCCCC',
    'figure.dpi':         150,
    'axes.spines.top':    False,
    'axes.spines.right':  False,
    'axes.grid':          True,
    'grid.color':         '#DDDDDD',
    'grid.linestyle':     '--',
    'grid.linewidth':     0.6,
    'lines.linewidth':    2.0,
    'savefig.bbox':       'tight',
    'pdf.fonttype':       42,
})

# Color palette (same as other chapters)
C_BLUE   = '#2166AC'
C_RED    = '#D6604D'
C_PURPLE = '#762A83'
C_GREEN  = '#4DAC26'
C_GRAY   = '#888888'

try:
    import gymnasium as gym
    GYM_BACKEND = 'gymnasium'
except ImportError:
    import gym
    GYM_BACKEND = 'gym'

OUTDIR = '.'

print(f'Setup complete (gym backend: {GYM_BACKEND}).')
Setup complete (gym backend: gymnasium).

Figure 1 — 随机游走:TD(0) 与 MC 的价值估计#

5 个非终止状态 A–E(编号 1–5),左右各一个终点。每步以 \(0.5\) 向左/右走;进入右终点得 \(+1\),进入左终点得 \(0\)。真实价值 \(v(s)=s/6\)。

用同一局轨迹(固定种子)分别跑 MC 与 TD(0),步长沿用 Sutton & Barto 图 6.2 的设置(\(\alpha_{\text{TD}}=0.1\),\(\alpha_{\text{MC}}=0.01\)):MC 必须等整局结束、且需要较小步长来平均回报;TD(0) 每步就地修正、可以用更大的步长,因而更快贴近真实价值。

TRUE_V = np.array([1, 2, 3, 4, 5]) / 6.0        # 状态 1..5 的真实价值
GAMMA = 1.0
ALPHA_TD = 0.1     # TD(0) 步长
ALPHA_MC = 0.01    # MC 步长(Sutton & Barto 图 6.2 的设置)


def one_episode(rng):
    """从状态 3(C)出发跑一局,返回 (状态序列, 奖励序列)。"""
    s = 3
    states = [s]
    rewards = []
    while True:
        s = s + rng.choice([-1, 1])
        if s == 0:               # 左终点:奖励 0,结束
            rewards.append(0.0); break
        elif s == 6:             # 右终点:奖励 +1,结束
            rewards.append(1.0); break
        else:
            rewards.append(0.0)
            states.append(s)
    return states, rewards


def mc_predict(episodes, alpha, seed):
    """蒙特卡洛:跑完整局后用真实回报 G_t 增量更新 V(s) += α(G_t - V(s))。"""
    rng = np.random.default_rng(seed)
    V = np.zeros(7)
    for _ in range(episodes):
        states, rewards = one_episode(rng)
        G = 0.0
        for s, r in zip(reversed(states), reversed(rewards)):
            G = r + GAMMA * G
            V[s] += alpha * (G - V[s])
    return V


def td0_predict(episodes, alpha, seed):
    """TD(0):每步用一步奖励 + 对下一步的当前估计更新 V(s) += α[R+γV(s')-V(s)]。"""
    rng = np.random.default_rng(seed)
    V = np.zeros(7)
    for _ in range(episodes):
        states, rewards = one_episode(rng)
        for t in range(len(rewards)):
            s = states[t]; ns = states[t + 1] if t + 1 < len(states) else None
            r = rewards[t]
            target = r if ns is None else r + GAMMA * V[ns]
            V[s] += alpha * (target - V[s])
    return V


def rms(V):
    return np.sqrt(np.mean((V[1:6] - TRUE_V) ** 2))


# 同一局轨迹(固定种子)的价值估计
SEED = 0
N_RUNS = 100
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
for ax, n_ep in zip(axes, (100, 200)):
    V_mc = mc_predict(n_ep, ALPHA_MC, SEED)
    V_td = td0_predict(n_ep, ALPHA_TD, SEED)
    x = np.arange(1, 6)
    ax.plot(x, TRUE_V, 'k-o', label='True values', linewidth=2.2)
    ax.plot(x, V_td[1:6], marker='s', color=C_BLUE, label='TD(0)')
    ax.plot(x, V_mc[1:6], marker='^', color=C_RED, label='MC')
    ax.set_xlabel('State')
    ax.set_ylabel('Estimated value')
    ax.set_title(f'After {n_ep} episodes')
    ax.set_xticks(x)
    ax.legend()
    # 100 局平均的个体 RMS(Sutton & Barto 的指标)
    mc_rms = np.mean([rms(mc_predict(n_ep, ALPHA_MC, s)) for s in range(N_RUNS)])
    td_rms = np.mean([rms(td0_predict(n_ep, ALPHA_TD, s)) for s in range(N_RUNS)])
    print(f'after {n_ep:5d} episodes: mean RMS over {N_RUNS} runs  '
          f'MC = {mc_rms:.3f}   TD(0) = {td_rms:.3f}')

fig.suptitle('Random Walk: TD(0) vs MC Value Estimation (same trajectory)',
             fontsize=13, fontweight='bold')
fig.tight_layout(rect=[0, 0, 1, 0.93])
fig.savefig(f'{OUTDIR}/fig1_random_walk_mc_vs_td.pdf', bbox_inches='tight')
plt.show()

print('Saved fig1_random_walk_mc_vs_td.pdf')
after   100 episodes: mean RMS over 100 runs  MC = 0.149   TD(0) = 0.062
after   200 episodes: mean RMS over 100 runs  MC = 0.070   TD(0) = 0.055
../../_images/85b5b6b2a9d3c63ea5a6e7d675c27dd9a5810f7833ab60d2481032b271a6f4ec.png
Saved fig1_random_walk_mc_vs_td.pdf

Figure 2 — N 步回报:偏差与方差的权衡#

\(n\) 步回报 \(G_t^{(n)} = R_{t+1}+\gamma R_{t+2}+\cdots+\gamma^{n-1}R_{t+n}+\gamma^n V(S_{t+n})\) 是 TD(0)(\(n=1\),全 bootstrapping)与 MC(\(n\to\infty\),无 bootstrapping)之间的连续谱。同一 \(\alpha=0.1\) 下,平均 100 局 5 状态随机游走的 RMS 误差随时间(局数)的变化如下。

def run_nstep_predict(n, n_episodes, alpha, seed):
    """n 步 TD 预测:目标为 G_t^{(n)}(n 个真实奖励 + 第 n 步处 bootstrap)。n 足够大时退化为 MC。"""
    rng = np.random.default_rng(seed)
    V = np.zeros(7)
    rms_curve = []
    for _ in range(n_episodes):
        states, rewards = one_episode(rng)
        T = len(rewards)
        for t in range(T):
            horizon = min(t + n, T)
            G = sum(GAMMA ** (k - t) * rewards[k] for k in range(t, horizon))
            if t + n < T:
                G += GAMMA ** n * V[states[t + n]]
            V[states[t]] += alpha * (G - V[states[t]])
        rms_curve.append(rms(V))
    return rms_curve


N_EP_MAX, N_RUNS2 = 200, 100
N_VALUES = [1, 3, 10, 10 ** 6]
NAMES = {1: 'TD(0) (n=1)', 3: 'n-step (n=3)', 10: 'n-step (n=10)', 10 ** 6: 'MC (n=$\\infty$)'}
STYLES = {1: C_BLUE, 3: C_PURPLE, 10: C_GREEN, 10 ** 6: C_RED}

curves = {}
for n in N_VALUES:
    rs = np.stack([run_nstep_predict(n, N_EP_MAX, ALPHA_TD, s) for s in range(N_RUNS2)])
    curves[n] = rs
    print(f'n={NAMES[n]:16s} final RMS = {rs.mean(axis=0)[-1]:.4f}')

fig, ax = plt.subplots(figsize=(8, 5))
x = np.arange(1, N_EP_MAX + 1)
for n in N_VALUES:
    mean, std = curves[n].mean(axis=0), curves[n].std(axis=0)
    ax.plot(x, mean, label=NAMES[n], color=STYLES[n])
    ax.fill_between(x, mean - std, mean + std, alpha=0.10, color=STYLES[n])

ax.set_xlabel('Episodes')
ax.set_ylabel('RMS error')
ax.set_title('N-step TD on the Random Walk: Bias-Variance Tradeoff')
ax.set_xlim(0, N_EP_MAX)
ax.set_ylim(0, 0.6)
ax.legend()
ax.text(0.98, 0.96,
        f'mean of {N_RUNS2} runs, $\\alpha$={ALPHA_TD}, $\\gamma$=1',
        transform=ax.transAxes, ha='right', fontsize=9, color=C_GRAY, style='italic')
fig.savefig(f'{OUTDIR}/fig2_nstep_bias_variance.pdf', bbox_inches='tight')
plt.show()

print('Saved fig2_nstep_bias_variance.pdf')
n=TD(0) (n=1)      final RMS = 0.0554
n=n-step (n=3)     final RMS = 0.0844
n=n-step (n=10)    final RMS = 0.1440
n=MC (n=$\infty$)  final RMS = 0.1666
../../_images/4cd14e7c84964f53ceb3591cb34b7463fe9a1bd9d38d85dfd8e5f9134ebe2c2b.png
Saved fig2_nstep_bias_variance.pdf

Figure 3 — Cliff Walking:SARSA vs Q-Learning#

\(4\times12\) 悬崖行走:起点左下角,目标右下角,底部中间 10 格是悬崖。普通步 \(-1\),踩上悬崖 \(-100\) 并回到起点。参数 \(\\alpha=0.5\)、\(\\varepsilon=0.1\)、\(\\gamma=1\),横轴为局数、纵轴为每局累计奖励(10 局滑动平均)。

  • SARSA(on-policy) 学习的是 \(\varepsilon\)-贪心策略下的 \(q_\pi\),会避开“贴着悬崖走”的路线——训练中累计奖励更高;

  • Q-Learning(off-policy) 学习最优贪心策略,训练时会因随机探索频繁坠崖——累计奖励更低,但最终策略更短。

def run_sarsa(episodes=500, alpha=0.5, gamma=1.0, eps=0.1, seed=0):
    """on-policy:目标用“实际会选”的 A' 的 Q 值(含探索)。"""
    env = gym.make('CliffWalking-v0')
    if hasattr(env.action_space, 'seed'):
        env.action_space.seed(seed)
    rng = np.random.default_rng(seed)
    Q = np.zeros((env.observation_space.n, env.action_space.n))
    returns = []
    for _ in range(episodes):
        s, _ = env.reset()
        if rng.random() < eps:
            a = int(rng.integers(env.action_space.n))
        else:
            a = int(np.argmax(Q[s]))
        G, episode_done = 0.0, False
        while not episode_done:
            ns, r, terminated, truncated, _ = env.step(a)
            episode_done = terminated or truncated
            if terminated:
                target = r
                a_next = None
            else:
                if rng.random() < eps:
                    a_next = int(rng.integers(env.action_space.n))
                else:
                    a_next = int(np.argmax(Q[ns]))
                target = r + gamma * Q[ns, a_next]
            Q[s, a] += alpha * (target - Q[s, a])
            if not episode_done:
                s, a = ns, a_next
            G += r
        returns.append(G)
    env.close()
    return np.array(returns)


def run_qlearning(episodes=500, alpha=0.5, gamma=1.0, eps=0.1, seed=0):
    """off-policy:目标用 max_a' Q(s', a'),与实际如何探索无关。"""
    env = gym.make('CliffWalking-v0')
    if hasattr(env.action_space, 'seed'):
        env.action_space.seed(seed)
    rng = np.random.default_rng(seed)
    Q = np.zeros((env.observation_space.n, env.action_space.n))
    returns = []
    for _ in range(episodes):
        s, _ = env.reset()
        G, episode_done = 0.0, False
        while not episode_done:
            if rng.random() < eps:
                a = int(rng.integers(env.action_space.n))
            else:
                a = int(np.argmax(Q[s]))
            ns, r, terminated, truncated, _ = env.step(a)
            episode_done = terminated or truncated
            target = r if terminated else r + gamma * np.max(Q[ns])
            Q[s, a] += alpha * (target - Q[s, a])
            if not episode_done:
                s = ns
            G += r
        returns.append(G)
    env.close()
    return np.array(returns)


EPISODES, N_SEEDS = 500, 20
W = 10  # 滑动平均窗

print('=== Cliff Walking: SARSA vs Q-Learning ===')
sarsa = np.stack([run_sarsa(EPISODES, seed=s) for s in range(N_SEEDS)])
qlearn = np.stack([run_qlearning(EPISODES, seed=s) for s in range(N_SEEDS)])
print(f'SARSA      mean reward/episode (last 50) = {sarsa.mean(axis=0)[-50:].mean():7.2f}')
print(f'Q-Learning mean reward/episode (last 50) = {qlearn.mean(axis=0)[-50:].mean():7.2f}')


def smooth(x, w):
    k = np.ones(w) / w
    return np.convolve(x, k, mode='valid')


fig, ax = plt.subplots(figsize=(8, 5))
x = np.arange(W - 1, EPISODES)
for data, label, color in [(sarsa, 'SARSA', C_BLUE), (qlearn, 'Q-Learning', C_RED)]:
    sm = np.stack([smooth(row, W) for row in data])
    mean, std = sm.mean(axis=0), sm.std(axis=0)
    ax.plot(x, mean, label=label, color=color)
    ax.fill_between(x, mean - std, mean + std, alpha=0.12, color=color)

ax.axhline(-13, color=C_GRAY, linestyle=':', linewidth=1.8, alpha=0.8,
           label='Optimal path (-13)')
ax.set_xlabel('Episode')
ax.set_ylabel('Sum of rewards during episode')
ax.set_title('Cliff Walking: SARSA vs Q-Learning\n'
             f'({N_SEEDS} seeds, {W}-episode moving average)')
ax.legend()
ax.text(0.98, 0.04, f'$\\alpha$=0.5, $\\varepsilon$=0.1, $\\gamma$=1',
        transform=ax.transAxes, ha='right', fontsize=9, color=C_GRAY, style='italic')
fig.savefig(f'{OUTDIR}/fig3_cliff_sarsa_vs_qlearning.pdf', bbox_inches='tight')
plt.show()

print('Saved fig3_cliff_sarsa_vs_qlearning.pdf')
=== Cliff Walking: SARSA vs Q-Learning ===
SARSA      mean reward/episode (last 50) =  -25.58
Q-Learning mean reward/episode (last 50) =  -46.07
../../_images/17fda6be029cf35f3bb3dcb46196334ae24690f3ff301065ec7829612f8ad42d.png
Saved fig3_cliff_sarsa_vs_qlearning.pdf

小结#

  • TD(0) 用“一步真实奖励 + 对下一步的当前估计”更新,无需等 episode 结束;代价是引入有偏估计,换来方差大幅下降和更快收敛;

  • N 步 TD 是 TD 与 MC 之间的连续谱:\(n\) 越大越依赖真实奖励(低偏差高方差),\(n\) 越小越依赖 bootstrapping(高偏差低方差);

  • SARSA(on-policy) 学到 \(\varepsilon\)-贪心策略下的 \(q_\pi\),保守安全;Q-Learning(off-policy) 学到最优 \(q_*\),训练时更“冒险”但最终策略更短。

print('All figures saved:')
print('  fig1_random_walk_mc_vs_td.pdf')
print('  fig2_nstep_bias_variance.pdf')
print('  fig3_cliff_sarsa_vs_qlearning.pdf')
All figures saved:
  fig1_random_walk_mc_vs_td.pdf
  fig2_nstep_bias_variance.pdf
  fig3_cliff_sarsa_vs_qlearning.pdf