方策勾配法の理論と実装

Q学習やSARSAなどの価値ベースの手法は価値関数を学習し、そこから方策を導きます。一方、方策勾配法(Policy Gradient Methods)は方策そのものをパラメトリックに表現し、勾配上昇法で直接最適化します。連続行動空間や確率的方策が自然に扱える点が大きな利点です。

本記事の内容

  • 方策のパラメトリック表現
  • 目的関数 $J(\bm{\theta})$ の定義
  • 方策勾配定理の導出
  • REINFORCEアルゴリズム
  • ベースラインによる分散低減
  • Pythonでの実装

前提知識

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

方策のパラメトリック表現

方策をパラメータ $\bm{\theta}$ で表します。

$$ \pi_{\bm{\theta}}(a|s) = P(A_t = a | S_t = s; \bm{\theta}) $$

離散行動空間: ソフトマックス方策

状態 $s$ での行動 $a$ の選好度(preference)を $h(s, a; \bm{\theta})$ として:

$$ \pi_{\bm{\theta}}(a|s) = \frac{\exp(h(s, a; \bm{\theta}))}{\sum_{a’} \exp(h(s, a’; \bm{\theta}))} $$

連続行動空間: ガウス方策

$$ \pi_{\bm{\theta}}(a|s) = \frac{1}{\sqrt{2\pi}\sigma} \exp\left(-\frac{(a – \mu_{\bm{\theta}}(s))^2}{2\sigma^2}\right) $$

ここで $\mu_{\bm{\theta}}(s)$ はパラメータ $\bm{\theta}$ で決まる平均値です。

目的関数

方策の良さを測る目的関数 $J(\bm{\theta})$ を定義します。エピソディックタスクでは、開始状態からの期待収益とします。

$$ J(\bm{\theta}) = E_{\pi_{\bm{\theta}}}[G_0] = E_{\pi_{\bm{\theta}}}\left[\sum_{t=0}^{T-1} \gamma^t R_{t+1}\right] $$

方策勾配法の目標は、$J(\bm{\theta})$ を最大化するパラメータ $\bm{\theta}$ を見つけることです。

$$ \bm{\theta} \leftarrow \bm{\theta} + \alpha \nabla_{\bm{\theta}} J(\bm{\theta}) $$

方策勾配定理

目的関数の勾配を計算するのが方策勾配法の核心です。

方策勾配定理の主張

$$ \boxed{\nabla_{\bm{\theta}} J(\bm{\theta}) = E_{\pi_{\bm{\theta}}}\left[\sum_{t=0}^{T-1} \nabla_{\bm{\theta}} \log \pi_{\bm{\theta}}(A_t|S_t) \cdot G_t\right]} $$

導出(1エピソードのシンプルなケース)

$\tau = (S_0, A_0, R_1, S_1, A_1, R_2, \dots, S_{T-1}, A_{T-1}, R_T)$ を軌道(trajectory)とします。

軌道の確率は:

$$ P(\tau | \bm{\theta}) = \mu(S_0) \prod_{t=0}^{T-1} \pi_{\bm{\theta}}(A_t|S_t) P(S_{t+1}|S_t, A_t) $$

ここで $\mu(S_0)$ は初期状態分布です。

目的関数:

$$ J(\bm{\theta}) = E_{\tau \sim \pi_{\bm{\theta}}}[R(\tau)] = \sum_{\tau} P(\tau|\bm{\theta}) R(\tau) $$

ここで $R(\tau) = \sum_{t=0}^{T-1} \gamma^t R_{t+1}$ は軌道の総収益です。

勾配を計算します。

$$ \begin{align} \nabla_{\bm{\theta}} J(\bm{\theta}) &= \nabla_{\bm{\theta}} \sum_{\tau} P(\tau|\bm{\theta}) R(\tau) \\ &= \sum_{\tau} \nabla_{\bm{\theta}} P(\tau|\bm{\theta}) R(\tau) \\ &= \sum_{\tau} P(\tau|\bm{\theta}) \frac{\nabla_{\bm{\theta}} P(\tau|\bm{\theta})}{P(\tau|\bm{\theta})} R(\tau) \\ &= \sum_{\tau} P(\tau|\bm{\theta}) \nabla_{\bm{\theta}} \log P(\tau|\bm{\theta}) \cdot R(\tau) \end{align} $$

3行目で対数微分のトリック(log-derivative trick)$\nabla f / f = \nabla \log f$ を使いました。

$\log P(\tau|\bm{\theta})$ を展開します。

$$ \begin{align} \log P(\tau|\bm{\theta}) &= \log \mu(S_0) + \sum_{t=0}^{T-1} \log \pi_{\bm{\theta}}(A_t|S_t) + \sum_{t=0}^{T-1} \log P(S_{t+1}|S_t, A_t) \end{align} $$

$\bm{\theta}$ に依存するのは $\pi_{\bm{\theta}}$ の項のみなので:

$$ \nabla_{\bm{\theta}} \log P(\tau|\bm{\theta}) = \sum_{t=0}^{T-1} \nabla_{\bm{\theta}} \log \pi_{\bm{\theta}}(A_t|S_t) $$

したがって:

$$ \nabla_{\bm{\theta}} J(\bm{\theta}) = E_{\tau}\left[\left(\sum_{t=0}^{T-1} \nabla_{\bm{\theta}} \log \pi_{\bm{\theta}}(A_t|S_t)\right) R(\tau)\right] $$

さらに因果性を考慮すると(時刻 $t$ の行動は $t$ 以前の報酬に影響しない):

$$ \nabla_{\bm{\theta}} J(\bm{\theta}) = E_{\tau}\left[\sum_{t=0}^{T-1} \nabla_{\bm{\theta}} \log \pi_{\bm{\theta}}(A_t|S_t) \cdot G_t\right] $$

ここで $G_t = \sum_{k=t}^{T-1} \gamma^{k-t} R_{k+1}$ は時刻 $t$ 以降の収益です。

環境の遷移確率 $P(s’|s,a)$ が消えている点が重要です。モデルフリーで勾配を推定できます。

REINFORCEアルゴリズム

REINFORCE(Williams, 1992)は方策勾配定理に基づく最も基本的なアルゴリズムです。

アルゴリズム

  1. 方策 $\pi_{\bm{\theta}}$ に従ってエピソードを生成: $S_0, A_0, R_1, \dots, S_{T-1}, A_{T-1}, R_T$
  2. 各時刻 $t$ について収益を計算: $G_t = \sum_{k=t}^{T-1} \gamma^{k-t} R_{k+1}$
  3. パラメータを更新: $\bm{\theta} \leftarrow \bm{\theta} + \alpha \gamma^t \nabla_{\bm{\theta}} \log \pi_{\bm{\theta}}(A_t|S_t) G_t$

問題点: 高い分散

$G_t$ は確率的であり、その分散が大きいと学習が不安定になります。

ベースラインによる分散低減

方策勾配定理において、収益 $G_t$ から状態のみに依存する関数 $b(S_t)$(ベースライン)を引いても勾配の期待値は変わりません。

$$ \nabla_{\bm{\theta}} J(\bm{\theta}) = E\left[\sum_{t=0}^{T-1} \nabla_{\bm{\theta}} \log \pi_{\bm{\theta}}(A_t|S_t) \cdot (G_t – b(S_t))\right] $$

不偏性の証明

$$ \begin{align} E_{A_t \sim \pi_{\bm{\theta}}}[\nabla \log \pi_{\bm{\theta}}(A_t|S_t) \cdot b(S_t)] &= b(S_t) \sum_a \nabla \pi_{\bm{\theta}}(a|S_t) \\ &= b(S_t) \nabla \sum_a \pi_{\bm{\theta}}(a|S_t) \\ &= b(S_t) \nabla 1 = 0 \end{align} $$

$\sum_a \pi(a|s) = 1$ の勾配はゼロなので、ベースラインを引いてもバイアスは生じません。

ベースラインの選択

最も一般的なベースラインは状態価値関数 $V(S_t)$ です。このとき $G_t – V(S_t)$ はアドバンテージ(advantage)と呼ばれ、その行動が平均的な行動よりどれだけ良いかを表します。

Pythonでの実装

CartPoleでのREINFORCE

import numpy as np
import matplotlib.pyplot as plt

class SimpleCartPole:
    """簡易CartPole環境(OpenAI Gym不要)"""

    def __init__(self):
        self.gravity = 9.8
        self.masscart = 1.0
        self.masspole = 0.1
        self.total_mass = self.masscart + self.masspole
        self.length = 0.5
        self.polemass_length = self.masspole * self.length
        self.force_mag = 10.0
        self.tau = 0.02
        self.theta_threshold = 12 * np.pi / 180
        self.x_threshold = 2.4
        self.state = None

    def reset(self):
        self.state = np.random.uniform(-0.05, 0.05, size=4)
        return self.state.copy()

    def step(self, action):
        x, x_dot, theta, theta_dot = self.state
        force = self.force_mag if action == 1 else -self.force_mag

        costheta = np.cos(theta)
        sintheta = np.sin(theta)
        temp = (force + self.polemass_length * theta_dot**2 * sintheta) / self.total_mass
        thetaacc = (self.gravity * sintheta - costheta * temp) / (
            self.length * (4.0/3.0 - self.masspole * costheta**2 / self.total_mass))
        xacc = temp - self.polemass_length * thetaacc * costheta / self.total_mass

        x = x + self.tau * x_dot
        x_dot = x_dot + self.tau * xacc
        theta = theta + self.tau * theta_dot
        theta_dot = theta_dot + self.tau * thetaacc

        self.state = np.array([x, x_dot, theta, theta_dot])

        done = (abs(x) > self.x_threshold or abs(theta) > self.theta_threshold)
        reward = 1.0 if not done else 0.0

        return self.state.copy(), reward, done


class PolicyNetwork:
    """線形ソフトマックス方策"""

    def __init__(self, n_features, n_actions):
        self.n_features = n_features
        self.n_actions = n_actions
        # パラメータ(重み)
        self.theta = np.zeros((n_features, n_actions))

    def softmax(self, x):
        exp_x = np.exp(x - np.max(x))
        return exp_x / np.sum(exp_x)

    def get_action_probs(self, state):
        """状態から行動確率を計算"""
        logits = state @ self.theta
        return self.softmax(logits)

    def select_action(self, state):
        """確率的に行動を選択"""
        probs = self.get_action_probs(state)
        action = np.random.choice(self.n_actions, p=probs)
        return action, probs

    def compute_log_grad(self, state, action):
        """∇log π(a|s; θ) を計算"""
        probs = self.get_action_probs(state)
        # ソフトマックスの勾配: ∂logπ(a|s)/∂θ_{ij} = (1_{j=a} - π(j|s)) * s_i
        grad = np.zeros_like(self.theta)
        for j in range(self.n_actions):
            indicator = 1.0 if j == action else 0.0
            grad[:, j] = (indicator - probs[j]) * state
        return grad


def reinforce(env, n_episodes=1000, alpha=0.01, gamma=0.99, use_baseline=False):
    """REINFORCEアルゴリズム"""
    policy = PolicyNetwork(n_features=4, n_actions=2)
    baseline_value = 0.0  # 移動平均ベースライン
    rewards_history = []

    for episode in range(n_episodes):
        state = env.reset()
        states, actions, rewards = [], [], []

        # エピソードの生成
        for t in range(500):
            action, _ = policy.select_action(state)
            next_state, reward, done = env.step(action)

            states.append(state)
            actions.append(action)
            rewards.append(reward)

            state = next_state
            if done:
                break

        T = len(rewards)
        total_reward = sum(rewards)
        rewards_history.append(total_reward)

        # 収益 G_t の計算
        G = np.zeros(T)
        G[-1] = rewards[-1]
        for t in range(T - 2, -1, -1):
            G[t] = rewards[t] + gamma * G[t + 1]

        # ベースラインの更新
        if use_baseline:
            baseline_value = 0.99 * baseline_value + 0.01 * np.mean(G)

        # パラメータ更新
        for t in range(T):
            advantage = G[t] - baseline_value if use_baseline else G[t]
            grad = policy.compute_log_grad(states[t], actions[t])
            policy.theta += alpha * (gamma ** t) * advantage * grad

    return rewards_history


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

env = SimpleCartPole()
rewards_no_baseline = reinforce(env, n_episodes=1000, alpha=0.005,
                                gamma=0.99, use_baseline=False)

np.random.seed(42)
env = SimpleCartPole()
rewards_with_baseline = reinforce(env, n_episodes=1000, alpha=0.005,
                                  gamma=0.99, use_baseline=True)

# --- 可視化 ---
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 学習曲線
window = 50
smooth_no_bl = np.convolve(rewards_no_baseline, np.ones(window)/window, mode='valid')
smooth_with_bl = np.convolve(rewards_with_baseline, np.ones(window)/window, mode='valid')

axes[0].plot(smooth_no_bl, 'b-', linewidth=1.5, label='REINFORCE', alpha=0.8)
axes[0].plot(smooth_with_bl, 'r-', linewidth=1.5, label='REINFORCE + Baseline', alpha=0.8)
axes[0].set_xlabel('Episode', fontsize=12)
axes[0].set_ylabel('Total Reward (moving avg)', fontsize=12)
axes[0].set_title('REINFORCE on CartPole', fontsize=13)
axes[0].legend(fontsize=11)
axes[0].grid(True, alpha=0.3)

# 報酬の分布比較(後半500エピソード)
axes[1].hist(rewards_no_baseline[500:], bins=30, alpha=0.5, label='No baseline', color='blue')
axes[1].hist(rewards_with_baseline[500:], bins=30, alpha=0.5, label='With baseline', color='red')
axes[1].set_xlabel('Total Reward', fontsize=12)
axes[1].set_ylabel('Count', fontsize=12)
axes[1].set_title('Reward Distribution (last 500 episodes)', fontsize=13)
axes[1].legend(fontsize=11)
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

方策勾配の直感的な可視化

import numpy as np
import matplotlib.pyplot as plt

# --- 方策勾配の直感的な説明 ---
# 2腕バンディット問題での方策パラメータの軌跡

# バンディットの報酬確率
true_probs = [0.3, 0.7]  # 行動0: 30%, 行動1: 70%

# ソフトマックス方策
def softmax_policy(theta):
    exp_theta = np.exp(theta - np.max(theta))
    return exp_theta / np.sum(exp_theta)

# REINFORCE
np.random.seed(0)
theta = np.array([0.0, 0.0])
alpha = 0.1
n_steps = 300

theta_history = [theta.copy()]
prob_history = [softmax_policy(theta)[1]]

for step in range(n_steps):
    probs = softmax_policy(theta)
    action = np.random.choice(2, p=probs)
    reward = 1.0 if np.random.random() < true_probs[action] else 0.0

    # 方策勾配更新
    grad = np.zeros(2)
    for a in range(2):
        indicator = 1.0 if a == action else 0.0
        grad[a] = (indicator - probs[a]) * reward

    theta += alpha * grad
    theta_history.append(theta.copy())
    prob_history.append(softmax_policy(theta)[1])

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# π(a=1|θ) の変化
axes[0].plot(prob_history, 'b-', linewidth=1.5)
axes[0].axhline(y=0.7, color='red', linestyle='--', label='Optimal (0.7)', alpha=0.7)
axes[0].set_xlabel('Step', fontsize=12)
axes[0].set_ylabel('π(a=1)', fontsize=12)
axes[0].set_title('Policy Gradient: Action Probability', fontsize=13)
axes[0].legend(fontsize=11)
axes[0].grid(True, alpha=0.3)
axes[0].set_ylim(0, 1)

# θ パラメータ空間での軌跡
theta_arr = np.array(theta_history)
axes[1].plot(theta_arr[:, 0], theta_arr[:, 1], 'b-', linewidth=0.5, alpha=0.5)
axes[1].plot(theta_arr[0, 0], theta_arr[0, 1], 'go', markersize=10, label='Start')
axes[1].plot(theta_arr[-1, 0], theta_arr[-1, 1], 'ro', markersize=10, label='End')
axes[1].set_xlabel(r'$\theta_0$', fontsize=12)
axes[1].set_ylabel(r'$\theta_1$', fontsize=12)
axes[1].set_title('Parameter Space Trajectory', fontsize=13)
axes[1].legend(fontsize=11)
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

まとめ

本記事では、方策勾配法の理論と実装について解説しました。

  • 方策のパラメトリック表現: $\pi_{\bm{\theta}}(a|s)$ を直接最適化する
  • 方策勾配定理: $\nabla J = E[\sum_t \nabla \log \pi_{\bm{\theta}}(A_t|S_t) \cdot G_t]$
  • 対数微分のトリック: 環境モデルを必要としない勾配推定を可能にする
  • REINFORCE: モンテカルロ的な方策勾配アルゴリズム
  • ベースライン: 勾配推定の分散を低減し、学習を安定化させる

次のステップとして、Actor-Critic法(方策勾配 + 価値関数の組み合わせ)への発展が考えられます。