メインコンテンツまでスキップ

A2C (Advantage Actor-Critic) とは?

Actor-Criticのコンセプト (画像はGeminiで作成)

A2Cの概要

REINFORCEの記事では、方策(行動をとる確率)を直接最適化する「方策勾配法」について学びました。しかし、REINFORCEには「分散の爆発」という致命的な弱点がありました。

本記事では、DQNのような「価値ベース」の手法と、REINFORCEのような「方策ベース」の手法を融合させ、現代の深層強化学習(Deep RL)のデファクトスタンダードとなった Actor-Critic(アクター・クリティック) アーキテクチャ、およびその代表的アルゴリズムである A2C (Advantage Actor-Critic) について解説します。

事前知識

本記事は、「価値ベース」の手法である DQN(Deep Q-Network) と、「方策ベース」の手法である REINFORCEアルゴリズム の基礎を理解していることを前提に解説を進めます。事前知識に不安がある方は、先にこれらの記事をお読みいただくことをおすすめします。

REINFORCEの弱点とActor-Criticの誕生

なぜREINFORCEは不安定なのか?

REINFORCEアルゴリズムは、エピソードが完了するまで待ってから、得られた「総収益(累積報酬)」に基づいて方策を更新します。しかし、この手法には以下の問題があります。

  • 不透明な信用割り当て(Credit Assignment): REINFORCEの方策勾配定理の数式(θlogπθ(atst)Gt\nabla_\theta \log \pi_\theta(a_t|s_t) G_t)を思い出してください。この式では、ある時点以降の総収益 GtG_t が大きければ、そのエピソード中に取った(tt以降の)すべての行動の確率が一律に引き上げられてしまいます。そのため、「本当に報酬に貢献した素晴らしい行動」と「実は足を引っ張っていた無意味な行動」の判別がつきにくく、最終的な結果がどの行動のおかげだったのか(誰に信用を割り当てるべきか)が明確になりません。

  • 勾配のノイズ(分散の大きさ): REINFORCEは目的関数の勾配の期待値(Eτπθ[]\mathbb{E}_{\tau \sim \pi_\theta} [\dots])を計算する際、実際に最後までプレイして得られた1回(または数回)の軌跡データを用いる モンテカルロ推定 に依存しています。数式に含まれる Gt=k=tTγktrkG_t = \sum_{k=t}^{T} \gamma^{k-t} r_k はエピソード終了までの全報酬の合計ですが、環境のランダム性やその後の探索行動によって、同じ状態から同じ行動を取ったとしても GtG_t の値は毎回激しく変動します。この「たまたま運が良かった・悪かった展開」がそのまま勾配の更新に使われるため、推定値に巨大なノイズ(分散)が生じ、学習が不安定になります。

    モンテカルロ推定による分散の爆発

    エピソードを最後までやり切って得られた実際の収益(GtG_t)を用いる手法を「モンテカルロ法」と呼びます。バイアスが無いという利点がある反面、ここで説明されているように途中のノイズが蓄積しやすく「分散が極めて大きくなる」という構造的な弱点を持っています。この特性(バイアスと分散のトレードオフ)について詳しく復習したい方は、モンテカルロ法(Monte Carlo Method)とは?の記事をご覧ください。

Actor-Criticによる解決アプローチ

この分散問題を解決するための重要な改善策として提案されたのが、Actor-Critic(アクター・クリティック) アーキテクチャです。 Actor-Criticモデルの最大の貢献は、最適な戦略を学習するために「方策(Policy)」と「価値(Value)」の両方を使用することです。

  • Actor(アクター / 役者): 状態を入力として受け取り、行動の確率分布(方策)を出力するニューラルネットワークです。REINFORCEと同様の目的関数を最大化するように学習します。
  • Critic(クリティック / 評論家): 現在の状態が「どれくらい良いか」という状態価値関数 VV、または行動価値関数 QQ を学習するニューラルネットワークです。

行動する者(Actor)のプレイを、価値を予測する者(Critic)が毎ステップ評価(採点)することで、エピソードの最後まで待たずとも「その行動が平均的に見てどうだったか」を低分散で学習できるようになります。

A2Cのコア概念

a2cのコア概念 (画像はGeminiで作成)

アドバンテージ関数(Advantage Function)

Actor-Criticにおける最大の発明の一つが、この アドバンテージ関数(Advantage Function, AA です。 Actorを更新する際、エージェントが取った行動を評価するのに単に「得られた報酬」を基準にするのではなく、「その状態での平均的な期待値(ベースライン)と比べて、今回の行動がどれだけ良かったか」を計算します。

数式では一般に、行動価値 QQ と状態価値 VV を用いて以下のように表されます。

A(s,a)=Q(s,a)V(s)A(s, a) = Q(s, a) - V(s)
  • Q(s,a)Q(s, a) は、状態 ss で行動 aa を取ったあとに得られる実際の収益(行動の実績値)。
  • V(s)V(s) は、行動する前にCriticが予想していた状態 ss での平均的な収益(事前の期待値・ベースライン)。

実績値から期待値を引くことで、「期待していたよりも結果が良かった(A>0A > 0)」ならActorのその行動確率を上げ、「期待外れだった(A<0A < 0)」なら確率を下げるように学習します。 この技術により、学習ごとの勾配のバリアンス(ばらつき)が大幅に抑制され、学習が非常に安定しスピードアップします。

実際の実装では、アドバンテージ AA は次の状態の推定価値と前の状態の推定価値の差(TD誤差)として、以下のように近似計算されることが多くなります。

At=rt+γV(st+1)V(st)A_t = r_t + \gamma V(s_{t+1}) - V(s_t)

DQNのTD誤差(ベルマン方程式)との違い

ここで、「この計算式はDQNで学んだベルマン方程式(TD誤差)に非常に似ている」と気づいた方もいるかもしれません。DQNにおけるTD誤差は、以下のような形をしていました。

δt=rt+γmaxaQ(st+1,a)Q(st,at)\delta_t = r_t + \gamma \max_a Q(s_{t+1}, a) - Q(s_t, a_t)

一見よく似ていますが、設計思想には決定的な違いがあります。

  1. 最大化(max\max)の有無: DQNは常に「次にとれる最善の行動」を前提として価値を計算する(オフポリシー)のに対し、A2Cの VV は「現在の自分の方策に従って行動した場合の平均的な価値」を予測します(オンポリシー)。
  2. 目的の違い: DQNのTD誤差は単に「Q値の予測を正確にするためのズレ」を修正するためのものですが、A2Cのアドバンテージは「その行動をこれからどれくらい積極的に選ぶべきか(方策をどう更新するか)の直接的なシグナル」としてActorの更新に用いられます。

つまり、DQNが「絶対的なベスト(最大値)」を探すのに対し、アドバンテージ関数は「今の自分の実力(平均値)と比べた相対評価」を行っているのが特徴です。

Nステップ収益(N-step Return)

A2Cの学習をさらに効率化する重要な技術に Nステップ収益(N-step Return) があります。 直後(1ステップ先)の報酬だけを見て予測を修正する(TD学習)のではなく、数ステップ(Nステップ)先までの実際の報酬を足し合わせ、それ以降の予測はネットワーク(Critic)の予測値 VV に任せるという手法です。

これにより、1ステップごとの近視眼的な学習と、エピソード終了まで待つ学習(モンテカルロ法)の「良いとこ取り」ができ、学習効率と精度のバランスが最適化されます。

複数ワーカーによる並列学習(分散アーキテクチャ):A3CとA2C

A2Cを語る上で欠かせないのが、DeepMind社が2016年に発表した A3C (Asynchronous Advantage Actor-Critic) というアルゴリズムです。 A3CやA2Cの最大の特徴は、複数のエージェント(ワーカー)を同時に走らせて、それぞれ独立した環境のコピーで並行してデータを収集する分散アーキテクチャにあります。

A3C(非同期式)とA2C(同期式)の違い

  • A3C(非同期式): 各ワーカーが自身のタイミングで環境と相互作用し、グローバルなネットワークを非同期に更新します。マルチコアCPUでの実行に適しています。
  • A2C(同期式): すべてのワーカーが一定ステップ進むのを待ち、集まったデータをバッチ化して一括でネットワークを更新します。

現在では、GPUの並列処理(バッチ処理)を活かしやすい A2C(同期式) が主流となっています。(本質的な数理ロジックはA3Cと同一です)。

経験再生バッファからの脱却

複数のワーカーが並行して多様な状態を経験することで、取得するデータ同士の時間的偏り(相関)を打ち消すことができるようになりました。これにより、それまでのDQNなどで必須だった 「経験再生バッファ(Experience Replay)」を使わずとも学習を安定させる ことが可能になりました。

A2Cのネットワーク構造と損失関数

ネットワークの重み共有(Shared Network Architecture)

カメラ画像(ピクセル)などから学習する場合、Actor(行動を決める)とCritic(価値を評価する)の2つのネットワークで、前半の特徴抽出部分(CNN層など)を共有するアーキテクチャを取ることが一般的です。 これにより、パラメータ数を削減できるだけでなく、双方が得た勾配を使って豊かな特徴量を効率的に学習できます。

実装上は、1つの共有ネットワークから出力層が2つに分岐する(2つのヘッドを持つ)構造になります。

  • Actorヘッド: 行動を選択する確率(softmax出力など)。
  • Criticヘッド: 状態の価値を表す単一の線形出力(linear output)。

損失関数の全体像

A2Cの学習では、以下の3つの要素を組み合わせた損失関数を最小化します。

  1. Actor Loss(方策損失): アドバンテージを用いて、良かった行動の確率を上げます。REINFORCEの方策勾配定理における GtG_t をアドバンテージ AtA_t に置き換えたもので、以下の式で表されます。
    LActor(θ)=E[logπθ(atst)At]L^{Actor}(\theta) = - \mathbb{E} \left[ \log \pi_\theta(a_t|s_t) A_t \right]
    (※深層学習のオプティマイザは通常「最小化」を行うため、符号を反転させて損失関数としています)
  2. Critic Loss(価値損失): Criticが予測した状態価値と、実際の収益との間の平均二乗誤差(MSE)を最小化します。以下の式で表されます。
    LCritic(θ)=E[(RtVθ(st))2]L^{Critic}(\theta) = \mathbb{E} \left[ (R_t - V_\theta(s_t))^2 \right]
    (※実装上は、アドバンテージ At=RtVθ(st)A_t = R_t - V_\theta(s_t) の二乗の平均 E[At2]\mathbb{E}[A_t^2] として計算されることが一般的です)
  3. Entropy Bonus(エントロピー正則化): 方策勾配法には、「学習初期にたまたま少し良い結果が出た行動があると、すぐさまその行動確率を100%に近づけてしまい、他のより良い可能性を試さなくなってしまう(早期収束・局所解への陥り)」という深刻な問題があります。 これを防ぐために導入されたのがエントロピー正則化です。Actorが出力する「行動の確率分布」に対して、エントロピー(乱雑さ・ばらつき)を保っている状態により多くのボーナスを与えます。この技術によって「完全に確信が持てるまでは、あえて多様な行動を試す(探索を続ける)」ようになり、学習が不完全な状態でストップするのを防ぎます。数式上は、方策のエントロピー βθH(π(st;θ))\beta \nabla_{\theta} H(\pi(s_t; \theta)) を目的関数に追加します。

A2Cの実装(CartPoleでのスクラッチ実装)

具体的なコード例として、gymnasiumCartPole-v1環境を用いたA2CのPyTorch実装を示します。 この実装では、A2Cの核となる以下の要素をすべて組み込んでいます。

  • 複数ワーカーによる並列学習(同期式): gym.make_vecで複数の環境を並行実行。
  • Nステップ収益とアドバンテージ関数: Nステップ先までの報酬から価値を推定。
  • 重み共有アーキテクチャ: 1つのネットワークからActorとCriticの2つのヘッドを出力。
  • Actor Loss, Critic Loss, Entropy Bonus: 3つの損失関数を合成して学習。
# A2Cの学習に必要なライブラリをインポートします
import torch
import torch.nn as nn
import torch.optim as optim
from torch.distributions import Categorical
import gymnasium as gym
import numpy as np
import matplotlib.pyplot as plt

# A2Cネットワーク(重み共有アーキテクチャ): ActorとCriticでネットワークの一部を共有します
class A2CNetwork(nn.Module):
def __init__(self, state_dim, action_dim):
super(A2CNetwork, self).__init__()
# ActorとCriticで共有する特徴抽出層(状態を入力として受け取る)
self.shared_fc = nn.Sequential(
nn.Linear(state_dim, 128),
nn.ReLU()
)
# Actorヘッド: 共有層の出力を受け取り、各行動をとる確率を出力(Softmaxで確率分布化)
self.actor_head = nn.Sequential(
nn.Linear(128, action_dim),
nn.Softmax(dim=-1)
)
# Criticヘッド: 共有層の出力を受け取り、状態価値(スカラー値)を出力
self.critic_head = nn.Linear(128, 1)

def forward(self, x):
# 入力xを共有層に通して特徴を抽出
features = self.shared_fc(x)
# 抽出した特徴から行動確率を計算
action_probs = self.actor_head(features)
# 抽出した特徴から状態価値を計算
state_value = self.critic_head(features)
return action_probs, state_value

# --- ハイパーパラメータの設定 ---
num_envs = 8 # 同時に実行する並列環境の数
num_steps = 5 # ネットワークを更新する前に環境を進めるステップ数(Nステップ収益)
gamma = 0.99 # 割引率(将来の報酬をどれくらい重視するか)
entropy_coef = 0.01 # エントロピーボーナスの係数(探索を促す強さ)

# 複数の環境を並行して実行するためのベクトル化環境を作成
envs = gym.make_vec("CartPole-v1", num_envs=num_envs)

# 環境から状態空間と行動空間の次元を取得
state_dim = envs.single_observation_space.shape[0]
action_dim = envs.single_action_space.n

# A2Cネットワークを初期化
model = A2CNetwork(state_dim, action_dim)
# 最適化手法としてAdamを使用(学習率 1e-3)
optimizer = optim.Adam(model.parameters(), lr=1e-3)

# 全環境をリセットして初期状態を取得
state, _ = envs.reset()
# 各環境のエピソードごとの累積報酬を記録する配列
episode_rewards = np.zeros(num_envs)

# --- スコア記録用の変数 ---
recent_scores = [] # 直近のエピソードスコアを保存するリスト
history_epochs = [] # グラフ描画用のX軸データ(エポック数)
history_scores = [] # グラフ描画用のY軸データ(平均スコア)

total_updates = 10000 # 学習の総更新回数

# 学習のメインループ
for update in range(total_updates):
# --- データの収集 ---
log_probs = [] # 選択した行動の対数確率
values = [] # Criticが予測した状態価値
rewards = [] # 実際に得られた報酬
masks = [] # エピソードが継続しているか(1.0)終了したか(0.0)のフラグ
entropies = [] # 方策のエントロピー(ばらつき具合)

# Nステップ分のデータを並列環境から収集
for step in range(num_steps):
# 現在の状態をPyTorchのテンソルに変換
state_tensor = torch.FloatTensor(state)
# ネットワークに入力し、行動確率と状態価値を取得
probs, value = model(state_tensor)

# 行動確率に基づいたカテゴリカル分布を作成
m = Categorical(probs)
# 分布から行動をサンプリング
action = m.sample()

# サンプリングした行動を環境に適用し、次の状態や報酬を取得
next_state, reward, terminated, truncated, _ = envs.step(action.numpy())
# エピソードが終了したかどうかを判定
done = terminated | truncated

# 学習用に各種データを記録
log_probs.append(m.log_prob(action))
values.append(value.squeeze(-1))
rewards.append(torch.FloatTensor(reward))
masks.append(torch.FloatTensor(1.0 - done))
entropies.append(m.entropy())

# 状態を更新
state = next_state
# 報酬を累積
episode_rewards += reward

# エピソード完了時のスコアを記録し、その環境の累積報酬をリセット
for i in range(num_envs):
if done[i]:
recent_scores.append(episode_rewards[i])
episode_rewards[i] = 0

# --- Nステップ先の価値の推定 ---
# Nステップ後の状態の価値をネットワークで計算
state_tensor = torch.FloatTensor(state)
_, next_value = model(state_tensor)

# 勾配計算の対象外とするためにdetach()を呼び出す
next_value = next_value.squeeze(-1).detach()

# --- 収益(Returns)の計算 ---
returns = []
# Rは最後尾のステップの価値から逆算していく
R = next_value
# Nステップ分の報酬とマスクを逆順にたどりながら収益を計算
for r, mask in zip(reversed(rewards), reversed(masks)):
# ベルマン方程式に基づいて収益を更新(エピソード終了時はmaskが0になるためRがリセットされる)
R = r + gamma * R * mask
# 逆順で計算しているので、リストの先頭に追加していく
returns.insert(0, R)

# リストをPyTorchのテンソルに変換
returns = torch.stack(returns)
values = torch.stack(values)
log_probs = torch.stack(log_probs)
entropies = torch.stack(entropies)

# --- アドバンテージの計算と正規化 ---
# アドバンテージ = 実際の収益(Returns) - Criticの予測価値(Values)
advantages = returns - values

# アドバンテージの計算自体は勾配計算に含めないためdetach
adv_detached = advantages.detach()
# 学習を安定させるため、アドバンテージを平均0、標準偏差1に正規化
adv_normalized = (adv_detached - adv_detached.mean()) / (adv_detached.std() + 1e-8)

# --- 損失関数の計算 ---
# Actor Loss: -(対数確率 * アドバンテージ) の平均。数式 L^{Actor}(θ) = - E[log π(a|s) * A] に対応
actor_loss = -(log_probs * adv_normalized).mean()
# Critic Loss: アドバンテージ(収益と予測価値の誤差)の二乗平均。数式 L^{Critic}(θ) = E[(R - V)^2] = E[A^2] に対応
critic_loss = advantages.pow(2).mean()
# Entropy Bonus: エントロピーの平均。これが大きいほど多様な行動をとりやすくなる
entropy_bonus = entropies.mean()

# 最終的な損失 = Actor Loss + 0.5 * Critic Loss - エントロピーボーナス
loss = actor_loss + 0.5 * critic_loss - entropy_coef * entropy_bonus

# --- パラメータの更新 ---
# 勾配をリセット
optimizer.zero_grad()
# 誤差逆伝播法で勾配を計算
loss.backward()
# 勾配の爆発を防ぐため、クリッピングを適用
nn.utils.clip_grad_norm_(model.parameters(), max_norm=0.5)
# パラメータを更新
optimizer.step()

# --- 進捗の表示と記録 ---
# 50回の更新ごとに進捗を記録・表示
if (update + 1) % 50 == 0:
# 直近20エピソードの平均スコアを計算
avg_score = np.mean(recent_scores[-20:]) if len(recent_scores) > 0 else 0
# 500回ごとにコンソールに出力
if (update + 1) % 500 == 0:
print(f"Epoch {update + 1:4d}\tAverage Score: {avg_score:.1f}")

# グラフ用に記録を追加
history_epochs.append(update + 1)
history_scores.append(avg_score)

# 環境を閉じる
envs.close()

# --- グラフの描画 ---
# 以下は学習の進捗をプロットするためのMatplotlibのコード
plt.figure(figsize=(10, 5))
plt.plot(history_epochs, history_scores, label='Average Score (Last 20 Episodes)', color='blue')
plt.xlabel('Epochs (Updates)')
plt.ylabel('Score')
plt.title('A2C Training Progress on CartPole-v1')
plt.grid(True)
plt.legend()
plt.show()

コードの解説と各要素の対応

  • ネットワーク構造: A2CNetwork では前半の shared_fc を共有し、そこから actor_head(行動確率)と critic_head(状態価値)に分岐させています。
  • 並列学習(分散アーキテクチャ): gym.make_vec を用いて、独立した複数の環境(num_envs=8)を同時に同期実行しています。これによりExperience Replayを使わなくても多様なデータが収集できます。
  • Nステップ収益: 内側のループで num_steps=5 ぶん環境を進め、エピソードが終了していなければ最後の状態の予測価値(next_value)を終端値として、そこから逆算して各ステップの収益 R を計算しています。
  • アドバンテージ関数: advantages = returns - values によって、実際に得られた収益(returns)から事前の予測(values)を引くことでアドバンテージを算出しています。また、算出したアドバンテージはそのまま使わず、平均0・標準偏差1になるよう正規化(adv_normalized)してからActor Lossの計算に用いることで学習を安定させる、実務上極めて重要なテクニックを使用しています。
  • 損失関数の統合: 最終的な loss は、Actor Loss(良い行動の確率を上げる)、Critic Loss(価値予測を正確にする)、Entropy Bonus(探索を継続させる)の3つを足し合わせて計算されています。

実行結果

上記のコードを実行すると以下のような結果が得られます。

Epoch 500 Average Score: 49.3
Epoch 1000 Average Score: 26.6
Epoch 1500 Average Score: 51.5
Epoch 2000 Average Score: 134.0
Epoch 2500 Average Score: 81.9
Epoch 3000 Average Score: 222.3
Epoch 3500 Average Score: 134.4
Epoch 4000 Average Score: 107.5
Epoch 4500 Average Score: 75.5
Epoch 5000 Average Score: 56.6
Epoch 5500 Average Score: 368.9
Epoch 6000 Average Score: 436.9
Epoch 6500 Average Score: 500.0
Epoch 7000 Average Score: 500.0
Epoch 7500 Average Score: 379.4
Epoch 8000 Average Score: 490.9
Epoch 8500 Average Score: 500.0
Epoch 9000 Average Score: 500.0
Epoch 9500 Average Score: 500.0
Epoch 10000 Average Score: 500.0

スクラッチ実装の実行結果 グラフとコンソール出力から、A2Cがエピソード(Epoch)を重ねるごとに順調にスコアを伸ばしている様子がわかります。 REINFORCEアルゴリズムの際に生じていた「突然学習が崩壊してスコアが激減する」といった極端な分散が、アドバンテージ関数状態価値(Critic) によるベースラインの導入によって大きく抑制され、最終的にはCartPole-v1の最大スコアである500点に安定して到達・維持できるようになっていることが確認できます。

なぜ REINFORCE よりも学習に時間がかかっているのか?

A2Cは「高度で安定したアルゴリズム」ですが、CartPoleのようなシンプルで短いタスクにおいては、REINFORCE(数百エピソードでクリア)よりも学習に多くのエポックを要しています。これには主に4つの理由があります。

  1. Criticの初期学習による「足踏み」: REINFORCEが「実際の合計報酬」を使うのに対し、A2CはCriticの予測値を学習に用います。学習初期はCriticの予測がデタラメなため、Actorは「間違った教師データ」で学習させられ、Criticが賢くなるまで方策が改善されません。
  2. 重み共有による勾配の干渉: ネットワークの抽出層を共有しているため、学習初期に発生する巨大なCriticの損失(二乗誤差)が、Actorがせっかく見つけた「倒れないための微妙な特徴量」を壊してしまう現象(勾配干渉)が発生します。
  3. N-stepと環境の相性: CartPoleは「ただ長く生き残る」単純なタスクです。エピソード全体を直接評価するREINFORCEの方が効率的であり、A2Cの5ステップという近視眼的な評価を繋ぎ合わせる手法は、かえって学習効率を落とします。
  4. アドバンテージ正規化による停滞: CartPoleは生存している限り常に報酬が+1です。バッチ内で誰も失敗しなかった場合、全員の収益が同じになり、正規化によってアドバンテージが0(更新幅ゼロ)に潰れてしまいます。誰かが偶然ポールを落として差異が生まれるまで学習が停滞しやすくなります。

このように、アルゴリズムが高度になればなるほど、単純なタスクにおいてはオーバーキルとなり、かえって時間がかかることがあるというのは強化学習における興味深い特徴です。

Stable Baselines3 を使ったA2C実装

実務でA2Cを利用する場合は、安定して実装されているライブラリ「Stable Baselines3」を用いるのが一般的です。数行のコードで上記の高度な処理がすべて実行されます。

ライブラリのインストール

pip install stable-baselines3[extra] gymnasium[classic-control]

Stable Baselines3 を用いたA2C実装

# Gymnasium(強化学習環境)とStable Baselines3のインポート
import gymnasium as gym
from stable_baselines3 import A2C
from stable_baselines3.common.env_util import make_vec_env

# 複数の環境を並列化して作成(A2Cの恩恵を受けるため)
# n_envs=8 で8つの環境を同時に実行します
vec_env = make_vec_env("CartPole-v1", n_envs=8)

# A2Cモデルの定義と学習
# - MlpPolicy: 多層パーセプトロンを用いた方策(重み共有も自動で行われます)
# - vec_env: 並列化した環境
# - ent_coef: エントロピー正則化の係数(探索を促す度合い)
model = A2C("MlpPolicy", vec_env, verbose=0, ent_coef=0.01)
# 指定したステップ数分だけ学習を実行します
model.learn(total_timesteps=20000)

# --- 学習したモデルのテストと可視化(Google Colab向け) ---
from matplotlib import animation
from IPython.display import HTML
import matplotlib.pyplot as plt

# 評価用の環境を初期化(描画モードをrgb_arrayに設定)
env = gym.make("CartPole-v1", render_mode="rgb_array")
# 初期状態を取得
obs, _ = env.reset()
# アニメーションのフレームを保存するリスト
frames = []

# 最大1000ステップ実行してフレームを保存
for i in range(1000):
# 現在の画面を画像として保存
frames.append(env.render())
# 学習済みモデルを使って最適な行動を予測(deterministic=Trueで最も確率の高い行動を選択)
action, _states = model.predict(obs, deterministic=True)
# 環境に行動を適用し、次の状態などを取得
obs, reward, terminated, truncated, info = env.step(action)
# エピソードが終了したらループを抜ける
if terminated or truncated:
break # 1エピソード終了でストップ

# 評価用環境を閉じる
env.close()

# --- アニメーションを作成し、Colab上に表示する ---
# 描画用の図形を作成し、サイズを調整
fig = plt.figure(figsize=(frames[0].shape[1] / 72.0, frames[0].shape[0] / 72.0), dpi=72)
ax = fig.add_subplot(111)
ax.axis('off') # 軸を非表示にする
# 最初のフレームを描画
patch = ax.imshow(frames[0])

# アニメーションを更新する関数
def animate(i):
patch.set_data(frames[i])

# FuncAnimationを使ってアニメーションを生成
anim = animation.FuncAnimation(plt.gcf(), animate, frames=len(frames), interval=50)
# 余分な図形が表示されないように閉じる
plt.close(fig)

# HTML形式に変換してJupyter/Colab上に表示
HTML(anim.to_jshtml())

ここでは、Stable Baselines3のA2Cエージェントを用いた実用的な実装について、そのシンプルさと背後にある高度な抽象化に注目して解説します。

  • 統合された学習パイプライン: Stable Baselines3のA2Cクラスは、前述したA2Cの複雑な要素(複数ワーカーによる並列学習、Nステップ収益の計算、Actor/Critic損失の最適化、エントロピー正則化など)をすべて内部で自動的に処理し、数行のコードで堅牢な強化学習パイプラインを構築します。
  • 並列環境の自動管理と効率的なデータ収集: make_vec_env("CartPole-v1", n_envs=8)を使用することで、CartPole-v1環境を8つ並列で自動的に起動し、管理します。これにより、多種多様な経験データを効率的に収集でき、手動での環境管理や経験再生バッファの構築といった手間を削減し、学習の安定性を大幅に向上させます。
  • MlpPolicyによるActor-Criticネットワークの自動構築: A2C("MlpPolicy", ...)と指定するだけで、Actor(方策)とCritic(価値)の機能を担う多層パーセプトロン(MLP)ベースのニューラルネットワークが自動的に構築されます。なお、CartPoleのような1次元のベクトル入力の場合、デフォルト設定ではActorとCriticはそれぞれ独立した別々のネットワークとして学習されます。前半のスクラッチ実装で紹介したような「特徴抽出層の重み共有」は、主に画像入力(CnnPolicy)を用いる際の計算量削減テクニックとして自動適用される仕組みになっています。
  • エントロピー正則化による安定した探索行動: ent_coef=0.01パラメータは、エージェントが学習の初期段階で特定の行動に偏りすぎないよう、行動の多様性(エントロピー)を保つためのボーナスを設定します。これにより、最適な方策を見つけるための探索行動が安定し、早期の局所最適解への収束を防ぎます。
  • テストと可視化による直感的なモデル評価: 学習済みのmodel.predict(obs, deterministic=True)を用いて環境をステップ実行し、render_mode="rgb_array"で取得した各フレームをmatplotlib.animationIPython.display.HTMLでアニメーションとして表示します。これにより、エージェントが実際に環境内でどのように振る舞い、学習成果を発揮しているかを視覚的かつ直感的に評価できます。

実行結果

上記の処理を実行すると、Jupyter NotebookやColab上でインラインの動画プレーヤーが表示されます。 Stable Baselines3によって最適化されたA2Cモデルは非常に強力であり、アニメーションを見ると、カートが左右に小刻みに動きながら見事にバランスを保ち、CartPole-v1環境的クリア条件(最大ステップ数)である500ステップを一度も倒すことなく立ち続けている素晴らしい結果が確認できます。

Stable Baselines3 を用いたA2C実装の実行結果

PPOへの橋渡し:Actor-Criticの限界

A2Cの登場により、強化学習はDQN(価値ベース)の次元の呪いを克服し、REINFORCE(方策ベース)の分散の爆発も抑え込むことに成功しました。

しかし、A2Cにもまだ課題が残されていました。それは 「1回の学習で方策を更新しすぎてしまうと、二度とまともな行動ができなくなり、学習が完全に崩壊してしまうリスク(オンポリシーの恐怖)」 です。方策の更新ステップ幅をどのように安全に制限するかという問題が残っていたのです。

方策の崩壊(Policy Collapse)のシミュレーション

この「方策の崩壊」がいかに簡単に、かつ致命的に起こり得るかを確認してみましょう。 学習率をわざと極端に大きくし、勾配のクリッピングを行わずに方策を更新するコードを実行します。(※本来のA2Cの更新処理ですが、ここでは崩壊のメカニズムのみをわかりやすく示すため、Criticを省略し単純な合計報酬をアドバンテージとして用いる簡略版のREINFORCE的な実装でシミュレーションを行っています)

import gymnasium as gym
import torch
import torch.nn as nn
import torch.optim as optim
from torch.distributions import Categorical

def demonstrate_policy_collapse():
env = gym.make('CartPole-v1')
# シンプルな方策ネットワーク
policy_net = nn.Sequential(
nn.Linear(4, 128),
nn.ReLU(),
nn.Linear(128, 2),
nn.Softmax(dim=-1)
)

# ★注目: 学習率をわざと極端に大きく設定(0.1)し、1回の更新幅を過大にする
optimizer = optim.Adam(policy_net.parameters(), lr=0.1)

print("--- 方策崩壊シミュレーション ---")
for episode in range(5):
state, _ = env.reset(seed=42)
log_probs = []
rewards = []

for t in range(500):
state_tensor = torch.FloatTensor(state).unsqueeze(0)
probs = policy_net(state_tensor)
m = Categorical(probs)
action = m.sample()

log_probs.append(m.log_prob(action))
state, reward, terminated, truncated, _ = env.step(action.item())
rewards.append(reward)

if terminated or truncated:
break

# 単純なアドバンテージ(報酬の合計)で更新
R = sum(rewards)
policy_loss = []
for log_prob in log_probs:
policy_loss.append(-log_prob * R)

optimizer.zero_grad()
loss = torch.stack(policy_loss).sum()
loss.backward()

# ★注目: 勾配クリッピング(nn.utils.clip_grad_norm_)を行わない
optimizer.step()

# 行動確率の偏りを確認(状態がすべて0の場合の、右/左に行く確率)
test_state = torch.zeros(1, 4)
with torch.no_grad():
action_prob = policy_net(test_state).numpy()[0]

print(f"エピソード {episode+1}: スコア = {R}")
print(f" -> 初期状態での行動確率: 左 {action_prob[0]*100:.1f}% / 右 {action_prob[1]*100:.1f}%")

demonstrate_policy_collapse()

実行結果

--- 方策崩壊シミュレーション ---
エピソード 1: スコア = 15.0
-> 初期状態での行動確率: 左 2.7% / 右 97.3%
エピソード 2: スコア = 16.0
-> 初期状態での行動確率: 左 12.9% / 右 87.1%
エピソード 3: スコア = 44.0
-> 初期状態での行動確率: 左 59.5% / 右 40.5%
エピソード 4: スコア = 50.0
-> 初期状態での行動確率: 左 97.3% / 右 2.7%
エピソード 5: スコア = 24.0
-> 初期状態での行動確率: 左 99.9% / 右 0.1%

エピソードを重ねるごとに方策が急激に更新され、エピソード4〜5にかけて行動確率が 「左: 99.9% / 右: 0.1%」 と極端に偏っていく様子がわかります。 このように確率が0%や100%近くに張り付いてしまうと、二度と他の行動を試さなくなる(探索が行われなくなる)ため、最終的にはスコアが低下したまま全く学習が進まなくなる「学習の崩壊」に陥ります。

この「歩幅制限」の数理的難題を解決し、A2Cのアーキテクチャをベースにしながらも圧倒的な安定性と学習効率を誇るようになった最強のアルゴリズムこそが、次回の記事で解説する TRPO / PPO (Proximal Policy Optimization) です。

まとめ

  • REINFORCEの課題: エピソード全体の収益を使うため分散が大きく学習が不安定。
  • Actor-Critic: 価値を予測する「Critic」と、行動を決定する「Actor」に役割を分担させることで、安定した学習を実現。
  • アドバンテージ(Advantage, AtA_t: 「平均的な期待値(ベースライン)」に対して、今回の行動がどれだけ良かったかを示す指標(At=rt+γV(st+1)V(st)A_t = r_t + \gamma V(s_{t+1}) - V(s_t))。※実装ではN-step版に拡張して使用します。
  • A3CとA2C: 複数の環境を並列実行してデータを集める手法。探索を促すためのエントロピー正則化などの工夫が盛り込まれている。
  • A2Cに残された課題: 1回の更新幅が大きすぎると、行動確率が極端に偏って二度と探索を行わなくなる「方策の崩壊(Policy Collapse)」を引き起こす危険性がある。

A2Cは現在の深層強化学習における重要な基盤技術であり、これを理解し、その限界(方策の崩壊)を知ることで、最新の強力なアルゴリズム(PPOなど)がなぜ生まれたのかという理解がより一層深まります。

本記事の文章・構成の一部に生成AIを使用しています。