Q学習とSARSAの理論と実装

動的計画法による方法(価値反復法、方策反復法)は環境モデル(遷移確率 $P$ と報酬関数 $R$)が完全に既知であることを前提とします。しかし現実の多くの問題ではモデルが未知です。TD学習(Temporal Difference Learning)はモデルフリーな手法であり、環境との直接的なインタラクションから価値関数を学習します。本記事では、代表的なTD学習手法であるQ学習とSARSAを解説します。

本記事の内容

  • TD学習の基本概念
  • Q学習(off-policy)
  • SARSA(on-policy)
  • ε-greedy方策
  • Q学習とSARSAの違い
  • グリッドワールドでのPython実装

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

TD学習の基本概念

モンテカルロ法 vs TD学習

モンテカルロ法はエピソード終了まで待ち、実際の収益 $G_t$ で価値関数を更新します。

$$ V(S_t) \leftarrow V(S_t) + \alpha \left[G_t – V(S_t)\right] $$

TD学習はエピソード終了を待たず、次の時刻の推定値を使って更新します。

$$ V(S_t) \leftarrow V(S_t) + \alpha \left[R_{t+1} + \gamma V(S_{t+1}) – V(S_t)\right] $$

ここで $R_{t+1} + \gamma V(S_{t+1})$ をTD目標(TD target)、$\delta_t = R_{t+1} + \gamma V(S_{t+1}) – V(S_t)$ をTD誤差(TD error)と呼びます。

TD学習の利点:

  • エピソードの途中でも更新できる(オンライン学習)
  • エピソードが終了しない(連続的な)タスクにも適用できる
  • モンテカルロ法より分散が小さい(ただしバイアスを持つ)

ε-greedy方策

Q値を学習する際、探索(exploration)と活用(exploitation)のバランスが重要です。ε-greedy方策は最も単純な探索戦略です。

$$ \pi(a|s) = \begin{cases} 1 – \varepsilon + \frac{\varepsilon}{|A|} & \text{if } a = \arg\max_{a’} Q(s, a’) \\ \frac{\varepsilon}{|A|} & \text{otherwise} \end{cases} $$

  • 確率 $1 – \varepsilon$ で最良の行動(活用)
  • 確率 $\varepsilon$ でランダムな行動(探索)

$\varepsilon$ を時間とともに減衰させることで、初期は探索を重視し、学習が進むにつれて活用に移行します。

Q学習(off-policy TD制御)

Q学習(Q-learning)はWatkinsが1989年に提案したアルゴリズムで、行動価値関数 $Q(s, a)$ を直接学習します。

更新則

$$ \boxed{Q(S_t, A_t) \leftarrow Q(S_t, A_t) + \alpha \left[R_{t+1} + \gamma \max_{a’} Q(S_{t+1}, a’) – Q(S_t, A_t)\right]} $$

TD目標に $\max_{a’} Q(S_{t+1}, a’)$ を使う点が特徴です。これは最適方策に対するQ値の推定を用いることに相当します。

off-policyとは

Q学習はoff-policyな手法です。つまり:

  • 行動方策(behavior policy): 実際に環境で行動を選ぶ方策(例: ε-greedy)
  • 目標方策(target policy): 学習の対象となる方策(greedy方策: $\max_a Q(s,a)$)

行動方策と目標方策が異なるため「off-policy」と呼ばれます。

収束性

適切な条件(全ての $(s, a)$ ペアが無限回訪問される、学習率が $\sum \alpha = \infty$, $\sum \alpha^2 < \infty$ を満たす)のもとで、Q学習は最適行動価値関数 $Q^*$ に確率1で収束します。

SARSA(on-policy TD制御)

SARSAは、名前の通り $(S_t, A_t, R_{t+1}, S_{t+1}, A_{t+1})$ の5つ組を用いる手法です。

更新則

$$ \boxed{Q(S_t, A_t) \leftarrow Q(S_t, A_t) + \alpha \left[R_{t+1} + \gamma Q(S_{t+1}, A_{t+1}) – Q(S_t, A_t)\right]} $$

Q学習との違いは、TD目標に $Q(S_{t+1}, A_{t+1})$(次の状態で実際に選んだ行動のQ値)を使う点です。

on-policyとは

SARSAはon-policyな手法です。行動方策と目標方策が同一です。ε-greedy方策で行動し、そのε-greedy方策自体のQ値を学習します。

Q学習とSARSAの比較

Q学習 SARSA
種別 off-policy on-policy
TD目標 $R + \gamma \max_{a’} Q(s’, a’)$ $R + \gamma Q(s’, a’)$
学習対象 最適方策のQ値 現在の方策のQ値
探索の影響 TD目標に影響しない TD目標に影響する
振る舞い 大胆(危険な状態の近くも通る) 慎重(危険を避ける傾向)

Pythonでの実装

Cliff Walkingでの比較

import numpy as np
import matplotlib.pyplot as plt

class CliffWalking:
    """Cliff Walking 環境
    4x12 グリッド。左下がスタート、右下がゴール。
    下辺の崖に落ちると大きなペナルティ。
    """

    def __init__(self):
        self.rows = 4
        self.cols = 12
        self.start = (3, 0)
        self.goal = (3, 11)
        # 崖: 下辺のスタートとゴール以外
        self.cliff = [(3, c) for c in range(1, 11)]
        self.actions = [(-1, 0), (1, 0), (0, -1), (0, 1)]  # 上下左右
        self.n_actions = 4

    def reset(self):
        self.state = self.start
        return self.state

    def step(self, action):
        dr, dc = self.actions[action]
        r, c = self.state
        nr = max(0, min(self.rows - 1, r + dr))
        nc = max(0, min(self.cols - 1, c + dc))
        next_state = (nr, nc)

        if next_state in self.cliff:
            # 崖に落ちた
            return self.start, -100, False
        elif next_state == self.goal:
            self.state = next_state
            return next_state, -1, True
        else:
            self.state = next_state
            return next_state, -1, False


def epsilon_greedy(Q, state, epsilon, n_actions):
    """ε-greedy方策"""
    if np.random.random() < epsilon:
        return np.random.randint(n_actions)
    else:
        return np.argmax(Q[state])


def q_learning(env, n_episodes=500, alpha=0.5, gamma=1.0, epsilon=0.1):
    """Q学習"""
    Q = {}
    for r in range(env.rows):
        for c in range(env.cols):
            Q[(r, c)] = np.zeros(env.n_actions)

    rewards_per_episode = []

    for episode in range(n_episodes):
        state = env.reset()
        total_reward = 0

        while True:
            action = epsilon_greedy(Q, state, epsilon, env.n_actions)
            next_state, reward, done = env.step(action)
            total_reward += reward

            # Q学習の更新(max を使う)
            td_target = reward + gamma * np.max(Q[next_state])
            td_error = td_target - Q[state][action]
            Q[state][action] += alpha * td_error

            state = next_state
            if done:
                break

        rewards_per_episode.append(total_reward)

    return Q, rewards_per_episode


def sarsa(env, n_episodes=500, alpha=0.5, gamma=1.0, epsilon=0.1):
    """SARSA"""
    Q = {}
    for r in range(env.rows):
        for c in range(env.cols):
            Q[(r, c)] = np.zeros(env.n_actions)

    rewards_per_episode = []

    for episode in range(n_episodes):
        state = env.reset()
        action = epsilon_greedy(Q, state, epsilon, env.n_actions)
        total_reward = 0

        while True:
            next_state, reward, done = env.step(action)
            total_reward += reward

            next_action = epsilon_greedy(Q, next_state, epsilon, env.n_actions)

            # SARSAの更新(次の行動の Q 値を使う)
            td_target = reward + gamma * Q[next_state][next_action]
            td_error = td_target - Q[state][action]
            Q[state][action] += alpha * td_error

            state = next_state
            action = next_action
            if done:
                break

        rewards_per_episode.append(total_reward)

    return Q, rewards_per_episode


# --- 実行と比較 ---
np.random.seed(42)
env = CliffWalking()

Q_ql, rewards_ql = q_learning(env, n_episodes=500, alpha=0.5, gamma=1.0, epsilon=0.1)
Q_sarsa, rewards_sarsa = sarsa(env, n_episodes=500, alpha=0.5, gamma=1.0, epsilon=0.1)

# --- 可視化 ---
fig, axes = plt.subplots(2, 2, figsize=(16, 10))

# 学習曲線(移動平均)
window = 20
rewards_ql_smooth = np.convolve(rewards_ql, np.ones(window)/window, mode='valid')
rewards_sarsa_smooth = np.convolve(rewards_sarsa, np.ones(window)/window, mode='valid')

axes[0, 0].plot(rewards_ql_smooth, 'b-', linewidth=1.5, label='Q-Learning', alpha=0.8)
axes[0, 0].plot(rewards_sarsa_smooth, 'r-', linewidth=1.5, label='SARSA', alpha=0.8)
axes[0, 0].set_xlabel('Episode', fontsize=12)
axes[0, 0].set_ylabel('Total Reward (moving avg)', fontsize=12)
axes[0, 0].set_title('Learning Curves: Q-Learning vs SARSA', fontsize=13)
axes[0, 0].legend(fontsize=11)
axes[0, 0].grid(True, alpha=0.3)

# 方策の可視化
action_symbols = ['↑', '↓', '←', '→']

for ax_idx, (Q, title) in enumerate([(Q_ql, 'Q-Learning Policy'), (Q_sarsa, 'SARSA Policy')]):
    ax = axes[0, 1] if ax_idx == 0 else axes[1, 0]

    grid = np.zeros((env.rows, env.cols))
    for r in range(env.rows):
        for c in range(env.cols):
            grid[r, c] = np.max(Q[(r, c)])

    ax.imshow(grid, cmap='RdYlGn', interpolation='nearest')

    for r in range(env.rows):
        for c in range(env.cols):
            if (r, c) == env.start:
                ax.text(c, r, 'S', ha='center', va='center', fontsize=12, fontweight='bold')
            elif (r, c) == env.goal:
                ax.text(c, r, 'G', ha='center', va='center', fontsize=12, fontweight='bold')
            elif (r, c) in env.cliff:
                ax.text(c, r, 'X', ha='center', va='center', fontsize=12, color='red')
            else:
                best_action = np.argmax(Q[(r, c)])
                ax.text(c, r, action_symbols[best_action],
                        ha='center', va='center', fontsize=14)

    ax.set_title(title, fontsize=13)

# 最適経路の描画
def extract_path(Q, env, max_steps=100):
    """学習済みQ値から最適経路を抽出"""
    state = env.reset()
    path = [state]
    for _ in range(max_steps):
        action = np.argmax(Q[state])
        next_state, _, done = env.step(action)
        path.append(next_state)
        state = next_state
        if done:
            break
    return path

env_ql = CliffWalking()
env_sarsa = CliffWalking()
path_ql = extract_path(Q_ql, env_ql)
path_sarsa = extract_path(Q_sarsa, env_sarsa)

ax = axes[1, 1]
grid_display = np.zeros((env.rows, env.cols))
for pos in env.cliff:
    grid_display[pos] = -1

ax.imshow(grid_display, cmap='RdYlGn', interpolation='nearest', alpha=0.3)

# Q学習の経路
path_ql_arr = np.array(path_ql)
ax.plot(path_ql_arr[:, 1], path_ql_arr[:, 0], 'b-o', linewidth=2,
        markersize=4, label='Q-Learning path')

# SARSAの経路
path_sarsa_arr = np.array(path_sarsa)
ax.plot(path_sarsa_arr[:, 1] + 0.1, path_sarsa_arr[:, 0] + 0.1, 'r-s',
        linewidth=2, markersize=4, label='SARSA path')

ax.set_title('Learned Paths', fontsize=13)
ax.legend(fontsize=11)
ax.set_xlim(-0.5, env.cols - 0.5)
ax.set_ylim(env.rows - 0.5, -0.5)

plt.tight_layout()
plt.show()

Q学習は最適な経路(崖のすぐ上を通る最短経路)を学習しますが、ε-greedy探索中に崖に落ちるリスクがあります。一方、SARSAはε-greedy方策自体を評価するため、崖から離れた安全な経路を学習する傾向があります。

まとめ

本記事では、Q学習とSARSAの理論と実装について解説しました。

  • TD学習: エピソード終了を待たず、次の推定値で更新する
  • Q学習(off-policy): $Q(s,a) \leftarrow Q(s,a) + \alpha[R + \gamma \max_{a’} Q(s’,a’) – Q(s,a)]$
  • SARSA(on-policy): $Q(s,a) \leftarrow Q(s,a) + \alpha[R + \gamma Q(s’,a’) – Q(s,a)]$
  • ε-greedy: 確率 $\varepsilon$ でランダム行動(探索)、$1-\varepsilon$ で最良行動(活用)
  • Q学習は大胆な方策、SARSAは慎重な方策を学習する傾向がある

次のステップとして、以下の記事も参考にしてください。