Gymnasium入門 — 強化学習の実験環境を使いこなす

強化学習の論文を読んで理論を理解しても、実際にエージェントを動かしてみなければ本当の理解は得られません。しかし、物理シミュレーションや環境構築をゼロから行うのは大変な作業です。

Gymnasium(旧OpenAI Gym)は、強化学習の標準的な実験環境ライブラリです。統一されたAPIで多様な環境(ゲーム、ロボット制御、古典的な制御問題など)にアクセスでき、自分のアルゴリズムの性能を簡単にテストできます。

強化学習を実践するための最初のステップとして、Gymnasiumの使い方を理解することは不可欠です。

Gymnasiumを使いこなすと、以下のような場面で活用できます。

  • アルゴリズムの実装・検証: 論文で提案された手法を実装して性能を確認
  • ベンチマーク比較: 異なるアルゴリズムの公平な比較
  • カスタム環境の構築: 自分の研究テーマに合わせた環境の設計
  • 教育: 強化学習の概念を可視化して直感的に理解

本記事の内容

  • GymnasiumのAPIと基本概念
  • 主要な環境の分類と特性
  • CartPole環境でのQ学習の実装
  • DQNの実装と学習の可視化
  • カスタム環境の作り方

前提知識

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

GymnasiumのAPI

基本的なインターフェース

Gymnasiumの設計思想は「すべての強化学習環境を同じ方法で扱えるようにする」というものです。カートポールの制御も、Atariゲームも、ロボットの歩行も、すべて同じ4行のパターンで操作できます。これは、USBポートがどんなデバイスでも同じ形状で接続できるのと似ています。アルゴリズム開発者は環境の内部構造を知らなくても、統一されたインターフェースを通じて自分のアルゴリズムをテストできます。

Gymnasiumの全ての環境は統一されたインターフェースを持ちます。

import gymnasium as gym

# 環境の作成
env = gym.make("CartPole-v1")

# 環境のリセット(初期状態を取得)
observation, info = env.reset()

# 1ステップの実行
action = env.action_space.sample()  # ランダムな行動
observation, reward, terminated, truncated, info = env.step(action)

# 環境の終了
env.close()

主要な概念

env.step(action) が返す5つの値は、強化学習のMDP(マルコフ決定過程)の各要素に直接対応しています。MDPの理論では環境を $(S, A, P, R, \gamma)$ の組で定義しますが、Gymnasiumではこれを具体的なプログラムの戻り値として扱います。

observation(観測): 環境の現在の状態を表す値。数値配列(連続値)またはインデックス(離散値)です。CartPoleでは [cart_position, cart_velocity, pole_angle, pole_angular_velocity] の4次元ベクトルです。

reward(報酬): 行動の結果として得られるスカラー値。CartPoleではポールが倒れずに立っている限り毎ステップ+1が得られます。

terminated(終了): エピソードが自然に終了したかどうかのブール値。CartPoleではポールが一定角度以上傾くか、カートが画面外に出ると True になります。

truncated(打ち切り): エピソードが時間制限で打ち切られたかどうか。CartPole-v1では500ステップで打ち切りです。terminatedtruncated の区別は、価値関数の学習で重要です。terminated のときは「ゲームオーバー」なので次状態の価値は0です。一方、truncated のときは「時間切れ」なので、もし続きがあれば報酬を得られたはずです。したがって次状態の価値は0とは限りません。

具体的に、TD更新の式での違いを示します。terminated の場合は

$$ y = r $$

とし、truncated の場合は

$$ y = r + \gamma V(s’) $$

とするのが正しい処理です。この区別を無視すると(どちらもゲームオーバーとして扱うと)、エージェントは「時間切れの直前に得られる報酬」を過小評価し、本来の最適方策と異なる方策を学習してしまう可能性があります。Gymnasium以前のOpenAI Gym v0.25以前では done という1つのフラグしかなく、この区別が曖昧でした。Gymnasiumではこの問題を解決するために terminatedtruncated に分離されました。

info(情報): デバッグ用の追加情報を含む辞書。

行動空間と観測空間

環境の行動空間と観測空間は Space オブジェクトで定義されています。

env = gym.make("CartPole-v1")

# 行動空間
print(env.action_space)        # Discrete(2) → 0(左) or 1(右)
print(env.action_space.n)      # 2

# 観測空間
print(env.observation_space)   # Box(4,) → 4次元の連続値
print(env.observation_space.shape)  # (4,)
print(env.observation_space.low)    # 各次元の最小値
print(env.observation_space.high)   # 各次元の最大値

主な空間のタイプ:

空間 説明
Discrete(n) $\{0, 1, \ldots, n-1\}$ の整数 CartPoleの行動
Box(low, high) 有界の連続値 MountainCarの観測
MultiBinary(n) $n$次元の0/1ベクトル 同時行動
MultiDiscrete(nvec) 複数の離散値 複合行動

行動空間と観測空間の実践的な意味

行動空間と観測空間の型は、使用するアルゴリズムの選択に直結します。行動空間が Discrete なら DQN や Q学習が適用できますが、Box(連続値)なら DQN はそのままでは使えず、PPO、SAC、DDPG などの連続行動に対応したアルゴリズムが必要です。

観測空間も同様に重要です。Box で低次元のベクトル観測(CartPoleの4次元など)なら全結合ニューラルネットワークで十分ですが、Box で画像観測(Atariの $210 \times 160 \times 3$ など)なら畳み込みニューラルネットワーク(CNN)が必要になります。

空間の情報は環境のメタデータとしてプログラムから取得できるため、アルゴリズムが環境に応じて自動的にネットワーク構造を決定することも可能です。

Wrapper

Wrapperは環境を修飾して機能を追加する仕組みです。デザインパターンでいうDecoratorパターンに相当し、元の環境を変更せずに前処理や報酬の変換を行えます。

from gymnasium.wrappers import TimeLimit, RecordEpisodeStatistics

# 時間制限の追加
env = gym.make("CartPole-v1")
env = TimeLimit(env, max_episode_steps=200)

# エピソード統計の記録
env = RecordEpisodeStatistics(env)

主なWrapper:

Wrapper 機能
TimeLimit 最大ステップ数の制限
RecordEpisodeStatistics エピソードの報酬・長さを記録
NormalizeObservation 観測の正規化(平均0、分散1)
NormalizeReward 報酬の正規化
ClipReward 報酬のクリッピング
FrameStack 複数フレームのスタック(Atari用)

Wrapperは重ねがけできます。たとえば「観測を正規化」→「報酬をクリッピング」→「統計を記録」のように、複数のWrapperを順に適用できます。内側の環境から外側に向かって処理が伝播するため、適用順序に注意が必要です。

Gymnasiumの基本APIを理解したところで、次に利用可能な主要な環境について見ていきましょう。

主要な環境

古典的制御(Classic Control)

強化学習の入門に最適な環境です。

環境 観測 行動 目標
CartPole-v1 4次元連続値 離散(左/右) ポールを立て続ける
MountainCar-v0 2次元連続値 離散(左/停止/右) 山の頂上に到達
Acrobot-v1 6次元連続値 離散(3方向のトルク) リンクを上げる
Pendulum-v1 3次元連続値 連続(トルク) 振り子を立てる

CartPoleは離散行動・連続観測の最もシンプルな環境で、Q学習やDQNの最初のテストに適しています。MountainCarはスパース報酬問題の良い例です(頂上到達時のみ報酬が得られるため、ランダム方策ではほぼ報酬を得られません)。Pendulumは連続行動空間を持つ最小の環境であり、PPOやSACの最初のテストに最適です。

環境選択の一般的な指針として、新しいアルゴリズムを実装したら、まずCartPoleやMountainCarなどの簡単な環境で動作確認をし、次にLunarLanderなどの中程度の環境でテストし、最後にMuJoCo環境で本格的な評価を行うという段階的なアプローチが推奨されます。

Box2D

2次元の物理シミュレーション環境です。

環境 特性
LunarLander-v2 月面着陸。離散行動(4方向のスラスター)
BipedalWalker-v3 二足歩行。連続行動(4つの関節トルク)
CarRacing-v2 レーシング。連続行動(ステアリング、加速、ブレーキ)

LunarLanderはDQNのベンチマークとして広く使われ、BipedalWalkerはPPOなどの方策勾配法のテストに使われます。

MuJoCo

高品質な物理シミュレーション環境で、連続制御の標準ベンチマークです。

環境 特性
HalfCheetah-v4 チーターの走行。17次元観測、6次元行動
Hopper-v4 片足ジャンプ。11次元観測、3次元行動
Walker2d-v4 二足歩行。17次元観測、6次元行動
Humanoid-v4 ヒューマノイド。376次元観測、17次元行動

MuJoCo環境は次元が高く、PPO、SAC、TD3などの高度なアルゴリズムの評価に使われます。Humanoid環境は376次元の観測と17次元の行動を持ち、二足歩行のバランスを取りながら前進するという非常に困難な課題です。

Atari

Atariゲーム環境は、DQNの原論文で使用された歴史的に重要なベンチマークです。

環境 特性
Breakout-v5 ブロック崩し。画像入力、離散行動
Pong-v5 卓球ゲーム。画像入力、離散行動
SpaceInvaders-v5 シューティング。画像入力、離散行動

Atari環境の特徴は、観測が $210 \times 160 \times 3$ のRGB画像であることです。この高次元の入力を処理するために、畳み込みニューラルネットワーク(CNN)が必要になります。また、1フレームの画像からは速度情報が得られないため、直近の数フレームをスタックして入力とする(FrameStack Wrapper)のが標準的な前処理です。

環境の難易度と適切なアルゴリズムの選択

環境の複雑さに応じて適切なアルゴリズムは異なります。一般的な指針は以下の通りです。

  • 離散行動 + 低次元観測(CartPole等): 表形式Q学習、DQN
  • 離散行動 + 高次元観測(Atari等): DQN + CNN、Rainbow DQN
  • 連続行動 + 低次元観測(Pendulum等): PPO、SAC、TD3
  • 連続行動 + 高次元観測(MuJoCo等): PPO、SAC

環境の概要と適切なアルゴリズムの選択指針を理解したところで、実際にCartPoleでエージェントを学習させてみましょう。

CartPoleでのQ学習

離散化による表形式Q学習

CartPoleの観測空間は連続値ですが、Q学習はテーブル(表)にQ値を格納するため、連続値をそのまま扱えません。そこで、連続値を一定間隔の区間(ビン)に分割して離散化する必要があります。

たとえばカート位置 $x \in [-2.4, 2.4]$ を10個のビンに分割すると、$[-2.4, -1.92), [-1.92, -1.44), \ldots, [1.92, 2.4]$ の10区間になります。4つの状態変数をそれぞれ10分割すると、状態空間のサイズは $10^4 = 10{,}000$ になり、行動が2種類なので、Q値テーブルのサイズは $10{,}000 \times 2 = 20{,}000$ です。

離散化の粒度(ビン数)はハイパーパラメータです。ビン数が少なすぎると異なる状態が同じビンに入り情報が失われます。多すぎると各ビンのデータが少なくなり学習が遅くなります。

CartPoleの観測空間は連続値ですが、この離散化を施すことで表形式のQ学習を適用できます。

import numpy as np
import matplotlib.pyplot as plt

# CartPole環境のシミュレーション(Gymnasium非依存版)
class CartPoleSimple:
    """CartPole環境の簡易実装"""
    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  # 12度
        self.x_threshold = 2.4

    def reset(self):
        self.state = np.random.uniform(-0.05, 0.05, 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])
        terminated = (abs(x) > self.x_threshold or abs(theta) > self.theta_threshold)
        reward = 1.0 if not terminated else 0.0
        return self.state.copy(), reward, terminated

def discretize_state(state, bins):
    """連続状態を離散状態に変換"""
    ratios = []
    for i in range(len(state)):
        low, high = bins[i][0], bins[i][-1]
        s = np.clip(state[i], low, high)
        idx = np.digitize(s, bins[i]) - 1
        idx = min(idx, len(bins[i]) - 2)
        ratios.append(idx)
    return tuple(ratios)

# 離散化のビン定義
n_bins = 10
bins = [
    np.linspace(-2.4, 2.4, n_bins + 1),     # カート位置
    np.linspace(-3.0, 3.0, n_bins + 1),     # カート速度
    np.linspace(-0.21, 0.21, n_bins + 1),   # ポール角度
    np.linspace(-3.0, 3.0, n_bins + 1),     # ポール角速度
]

np.random.seed(42)
env = CartPoleSimple()

# Q学習
Q = np.zeros([n_bins] * 4 + [2])
lr = 0.1
gamma = 0.99
epsilon = 1.0
epsilon_decay = 0.995
epsilon_min = 0.01
n_episodes = 1000

episode_lengths = []

for episode in range(n_episodes):
    state = env.reset()
    ds = discretize_state(state, bins)
    total_steps = 0

    for step in range(500):
        # epsilon-greedy
        if np.random.rand() < epsilon:
            action = np.random.randint(2)
        else:
            action = np.argmax(Q[ds])

        next_state, reward, terminated = env.step(action)
        ds_next = discretize_state(next_state, bins)

        # Q更新
        if terminated:
            target = reward
        else:
            target = reward + gamma * np.max(Q[ds_next])
        Q[ds + (action,)] += lr * (target - Q[ds + (action,)])

        ds = ds_next
        total_steps += 1
        if terminated:
            break

    episode_lengths.append(total_steps)
    epsilon = max(epsilon_min, epsilon * epsilon_decay)
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# (a) 学習曲線
ax = axes[0]
window = 50
smoothed = np.convolve(episode_lengths, np.ones(window)/window, mode='valid')
ax.plot(episode_lengths, alpha=0.3, color='blue', linewidth=0.5)
ax.plot(range(window-1, len(episode_lengths)), smoothed, 'b-', linewidth=2,
        label=f"Moving avg (w={window})")
ax.axhline(500, color='red', linestyle='--', linewidth=1.5, label="Max steps (500)")
ax.set_xlabel("Episode", fontsize=12)
ax.set_ylabel("Episode Length (steps)", fontsize=12)
ax.set_title("Q-Learning on CartPole", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)

# (b) 探索率の推移
ax = axes[1]
epsilons = [max(epsilon_min, 1.0 * epsilon_decay**i) for i in range(n_episodes)]
ax.plot(epsilons, 'g-', linewidth=2)
ax.set_xlabel("Episode", fontsize=12)
ax.set_ylabel("Epsilon", fontsize=12)
ax.set_title("Exploration Rate Decay", fontsize=13)
ax.grid(True, alpha=0.3)

# (c) エピソード長の分布(後半500エピソード)
ax = axes[2]
late_lengths = episode_lengths[500:]
ax.hist(late_lengths, bins=30, color='blue', alpha=0.7, edgecolor='black')
ax.axvline(np.mean(late_lengths), color='red', linestyle='--', linewidth=2,
           label=f"Mean = {np.mean(late_lengths):.1f}")
ax.set_xlabel("Episode Length", fontsize=12)
ax.set_ylabel("Frequency", fontsize=12)
ax.set_title("Episode Length Distribution (last 500)", fontsize=13)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("cartpole_qlearning.png", dpi=150, bbox_inches="tight")
plt.show()

print(f"Last 100 episodes average: {np.mean(episode_lengths[-100:]):.1f} steps")

このグラフから、Q学習の学習過程が読み取れます。

  1. 左図(学習曲線): 初期はランダムな行動のためエピソード長が短い(10-50ステップ)ですが、学習が進むにつれてエピソード長が増加し、最終的に500ステップ(最大値)に到達するエピソードが増えています。移動平均(青い太線)の安定的な増加が、Q学習がCartPoleの最適方策を学習していることを示しています

  2. 中央図(探索率の推移): epsilonが指数的に減衰し、約500エピソードでほぼ0.01に到達しています。初期の高い探索率が状態空間のカバーを助け、後半の低い探索率が学習された方策の活用を促しています

  3. 右図(エピソード長の分布): 後半500エピソードのエピソード長の分布です。500ステップ(最大値)に大きなピークがあり、多くのエピソードでポールを倒さずに維持できていることがわかります。一方、短いエピソードも残っており、離散化の粗さや局所的な方策の不完全さを反映しています

コードの実装面をもう少し掘り下げましょう。CartPoleSimple クラスはCartPole環境の物理シミュレーションを自前で実装しています。内部ではオイラー法による数値積分を使い、カートとポールの運動方程式を解いています。ポールの角加速度は

$$ \ddot{\theta} = \frac{g \sin\theta – \cos\theta \cdot \frac{F + m_p l \dot{\theta}^2 \sin\theta}{m_c + m_p}}{\displaystyle l\left(\frac{4}{3} – \frac{m_p \cos^2\theta}{m_c + m_p}\right)} $$

で与えられます。ここで $g = 9.8\,\text{m/s}^2$ は重力加速度、$F$ は左右の力($\pm 10\,\text{N}$)、$m_c = 1.0\,\text{kg}$ はカートの質量、$m_p = 0.1\,\text{kg}$ はポールの質量、$l = 0.5\,\text{m}$ はポールの半分の長さです。この式は回転の運動方程式から導かれるもので、ポールが倒れようとする重力のトルクと、カートの加速による見かけの力のトルクのバランスを記述しています。

discretize_state 関数は、np.digitize を使って連続値がどのビンに属するかを判定します。各状態変数のビンの範囲は、CartPole環境で実際に取りうる値の範囲に基づいて設定しています。ポール角度のビン範囲 $[-0.21, 0.21]$ ラジアン(約$\pm 12$度)は、ポールが倒れたと判定される角度閾値に対応しています。

表形式Q学習の限界として、離散化の粒度を上げれば精度は向上しますが、Q値テーブルのサイズが指数的に増大する「次元の呪い」が生じます。4つの状態変数を各20分割にすると、テーブルサイズは $20^4 = 160{,}000$ になり、各セルに十分なデータが集まるまでに多くのエピソードが必要です。この問題を根本的に解決するのが、ニューラルネットワークによる関数近似(DQN)です。

カスタム環境の作り方

Gymnasium APIに準拠した環境

自分のタスクに合わせた環境を作るには、gymnasium.Env を継承します。

import gymnasium as gym
from gymnasium import spaces
import numpy as np

class SimpleGridEnv(gym.Env):
    """カスタムグリッドワールド環境"""
    metadata = {"render_modes": ["human"]}

    def __init__(self, size=5):
        super().__init__()
        self.size = size
        self.observation_space = spaces.Box(
            low=0, high=size-1, shape=(2,), dtype=np.int32
        )
        self.action_space = spaces.Discrete(4)
        self.goal = np.array([size-1, size-1])

    def reset(self, seed=None, options=None):
        super().reset(seed=seed)
        self.agent_pos = np.array([0, 0])
        return self.agent_pos.copy(), {}

    def step(self, action):
        # 移動
        moves = [np.array([-1,0]), np.array([1,0]),
                 np.array([0,-1]), np.array([0,1])]
        new_pos = self.agent_pos + moves[action]
        new_pos = np.clip(new_pos, 0, self.size - 1)
        self.agent_pos = new_pos

        # 報酬と終了判定
        terminated = np.array_equal(self.agent_pos, self.goal)
        reward = 1.0 if terminated else -0.01
        truncated = False

        return self.agent_pos.copy(), reward, terminated, truncated, {}

この実装について補足します。gymnasium.Env を継承する際に必須のメソッドは resetstep の2つです。reset はエピソードの初期状態を返し、step は行動を受け取って次の状態・報酬・終了フラグを返します。

reset メソッドで super().reset(seed=seed) を呼ぶのは、乱数生成器を適切に初期化するためです。Gymnasiumでは環境ごとに独立した乱数生成器 self.np_random が用意されており、シードを設定することで結果の再現性を保証できます。

observation_spaceaction_space はコンストラクタで定義する必要があります。これらの空間定義により、Gymnasiumはランダム行動のサンプリング(env.action_space.sample())や観測の妥当性チェックを自動的に行えます。

この構造に従えば、あなたの研究課題に合わせたカスタム環境を作成し、Gymnasiumの全てのWrapperやツールと互換性のある形で使用できます。

カスタム環境の設計指針

カスタム環境を設計する際には、以下の4つのポイントを慎重に検討する必要があります。

1. 観測は十分な情報を含むか: マルコフ性を満たすために、現在の状態から最適行動が決定できるだけの情報を含める必要があります。たとえばポールの角度だけでなく角速度も観測に含めなければ、「今ポールが倒れ始めているのか、それとも起き上がっている途中なのか」を区別できません。情報が不足していると、エージェントは現在の観測だけでは最適行動を判断できず、学習が困難になります。

2. 行動空間は適切か: 離散か連続かの選択は問題の性質に依存します。ロボットの関節トルクのように本質的に連続的な制御量を離散化すると、制御の精度が落ちます。一方、チェスの手のように本質的に離散的な選択を連続空間で表現するのは非効率です。行動の次元数や範囲も、実際の物理的な制約を反映して設定します。

3. 報酬は意図を正しく反映するか: 報酬設計は強化学習で最も重要かつ困難な要素の1つです。報酬が疎すぎる(ゴール到達時のみ報酬)と学習が困難になり、密すぎる(距離に応じた細かい報酬)と意図しない近道を見つけてしまう可能性があります。CartPoleでは「立っている間毎ステップ+1」というシンプルな報酬設計が、ポールを長く立て続けるという目標をうまく表現しています。

4. terminated と truncated を正しく区別: エピソードの自然な終了(ゲームオーバー)と時間制限による打ち切りは、先に述べたようにTDターゲットの計算に影響するため、正しく設定する必要があります。

実践的なヒント

環境のデバッグ

新しい環境やアルゴリズムを試す際は、以下の手順でデバッグします。強化学習はバグの発見が難しいことで知られています。プログラムがエラーなく動いていても、学習が進まない(報酬が改善しない)という形でバグが現れることが多いためです。体系的なデバッグ手順を踏むことが重要です。

  1. ランダム方策のテスト: env.action_space.sample() でランダムに行動し、環境が正常に動作するか確認します。観測値の範囲、報酬の値、エピソードの長さが期待通りかをチェックします。ランダム方策での平均報酬はベースラインとして、学習したエージェントがこれを上回っているかの確認にも使えます
  2. 手動操作: 人間が操作して、期待通りの報酬が得られるか確認します。render_mode="human" を指定して環境を作成すると、可視化ウィンドウが表示されます
  3. 簡単な環境での検証: まずCartPoleなどの簡単な環境でアルゴリズムが動作することを確認してから、本命の環境に適用します。CartPoleで学習が進まない場合は、アルゴリズムの実装にバグがある可能性が高いです
  4. ログの活用: 学習中の損失値、Q値の大きさ、探索率などの指標を記録し、異常な値(Q値の発散、損失のNaN等)がないかを監視します

シード管理

強化学習の実験では再現性が非常に重要です。同じアルゴリズム・同じハイパーパラメータでも、乱数のシードが異なると学習結果が大きく変わることがあります。論文やレポートで結果を報告する際には、複数のシードで実験を行い、平均と標準偏差を報告するのが標準的な実務です。

再現性のため、環境とアルゴリズムの両方にシードを設定します。

env = gym.make("CartPole-v1")
obs, info = env.reset(seed=42)
np.random.seed(42)

並列環境

学習の高速化のため、複数の環境を並列に実行できます。Gymnasiumの gymnasium.vector モジュールを使います。

envs = gym.make_vec("CartPole-v1", num_envs=8,
                     vectorization_mode="sync")
observations, infos = envs.reset()

8つの環境が同時に動作し、バッチで step を呼ぶことで1回あたりの計算効率が向上します。PPOなどのオンポリシーアルゴリズムでは、1回の方策更新に多くのサンプルが必要なため、並列環境がほぼ必須です。

vectorization_mode には "sync"(同期的、すべての環境のステップが完了するまで待つ)と "async"(非同期的、完了した環境から順に処理)があります。環境のステップ時間にばらつきがある場合は "async" の方が効率的です。

並列環境を使う場合、各環境に異なるシードが自動的に割り当てられるため、多様な経験が収集できます。これはオンポリシー手法の学習安定性を向上させる効果もあります。

Gymnasiumの内部動作とMDPとの対応

ここまでGymnasiumの使い方を実践的に見てきましたが、APIの設計がMDP(マルコフ決定過程)の数学的定義にどう対応しているかを整理しておきましょう。

MDPは以下の5つの要素で定義されます。

$$ \text{MDP} = (S, A, P, R, \gamma) $$

  • 状態空間 $S$: env.observation_space に対応します。CartPoleでは $S \subset \mathbb{R}^4$ です
  • 行動空間 $A$: env.action_space に対応します。CartPoleでは $A = \{0, 1\}$ です
  • 遷移確率 $P(s’|s, a)$: env.step(action) の内部で実現されます。エージェントからは直接アクセスできません(ブラックボックス)
  • 報酬関数 $R(s, a, s’)$: env.step(action) が返す reward に対応します
  • 割引率 $\gamma$: 環境側では定義されず、アルゴリズム側で設定します

Gymnasiumの重要な設計原則は、「環境はMDPの遷移と報酬を提供し、方策と学習はアルゴリズム側が担当する」という役割分担です。この分離により、1つの環境に対して様々なアルゴリズムを適用でき、逆に1つのアルゴリズムを様々な環境でテストできます。

また、Gymnasiumの環境は内部状態を持つステートフルなオブジェクトです。step を呼ぶたびに内部状態が更新されるため、同じ行動を渡しても、呼び出すタイミングによって異なる結果が返されます。これはMDPの「次状態は現在の状態と行動に依存する」という性質を忠実に反映しています。

学習ループの典型的なパターンをMDPの言葉で表現すると、以下のようになります。

  1. 環境を初期状態 $s_0 \sim p_0(s)$ にリセットする(env.reset()
  2. 方策 $\pi(a|s)$ に従って行動 $a_t$ を選択する
  3. 環境から次状態 $s_{t+1} \sim P(\cdot|s_t, a_t)$ と報酬 $r_t = R(s_t, a_t)$ を受け取る(env.step(a_t)
  4. 経験 $(s_t, a_t, r_t, s_{t+1})$ を使ってQ値や方策を更新する
  5. $s_t \leftarrow s_{t+1}$ として2に戻る。terminated または truncated なら1に戻る

この対応を明確に意識しておくと、理論の論文で読んだアルゴリズムをGymnasiumの上で実装する際に、各要素をどのAPIに対応させるかで迷うことが少なくなります。

なお、Gymnasiumの環境はマルコフ性(次の状態が現在の状態と行動のみに依存し、過去の履歴に依存しない)を仮定しています。しかし現実の多くの問題では、部分観測マルコフ決定過程(POMDP)の方が適切なモデルです。たとえばポーカーでは相手の手札が見えないため、観測だけからは最適行動を決定できません。このような場合には、過去の観測履歴をLSTMなどのリカレントネットワークで集約するアプローチが有効です。GymnasiumのFrameStack Wrapperも、連続フレームをスタックすることで部分観測性を緩和する手段の一つです。

まとめ

本記事では、Gymnasiumライブラリの使い方と強化学習の実験環境について解説しました。

  • GymnasiumはMDPの要素(状態、行動、報酬、遷移)を統一されたAPIで提供する
  • terminatedtruncated の区別が価値関数の正しい学習に重要
  • 古典的制御(CartPole等)は離散行動の入門、MuJoCo環境は連続制御のベンチマーク
  • 表形式Q学習でもCartPoleは解けるが、DQNを使えば離散化なしで学習可能
  • gymnasium.Env を継承してカスタム環境を作成し、標準のWrapperとツールを活用できる

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