🤖

OpenAI Gymを使った強化学習の応用へ 〜パート1 Gymの基本情報〜

に公開

*この記事はQiita記事の再投稿となります。

こんにちは!
株式会社アイディオットでデータサイエンティストをしています、秋田と申します。
このシリーズは、強化学習のフレームワークを用いた最適化問題への応用を目的に、強化学習についてPythonライブラリの使用方法の観点から学ぼうというものになります。
まずは強化学習のフレームワークとしてよく活用されているOpen AIのGymを使った「環境」の作成方法について理解しましょう!

Gymの基礎

Gymは、強化学習における環境を定義するためによく使われるOpen AIが提供しているPythonライブラリになります(2025年現在、開発は終了しています)。
強化学習では、「環境」と「エージェント」が相互作用し合うことで状態が変化します。
それに伴って報酬の値が異なってくるため、エージェントがその報酬を最大化する行動を選択できるように学習させます。
すなわち、エージェントはニューラルネットワークなどの学習モデルで定義され、そのエージェントが学習する上でのフィールドが環境になります。
Gymが担当するのがこの「環境」の方になります(なのでGym単体で強化学習のフレームワークが完成するということでは無いので、その点は注意が必要です)。
環境を正しく設定しなければ、解きたい問題が上手く反映されずに何だかよく分からないことになってしまいます。

Gymで提供されているベンチマーク環境

Gymにはデフォルトでいくつものベンチマーク環境が用意されています。
古典的なCart Pole問題や、Atariというアーケードゲームの問題(例えばパックマンなど)があります。

CartPole

Pacman

まずはこれらのベンチマークを使用することから始めてみましょう!
簡単のため、Cart Pole問題を扱いますが、他の問題をやってみたい場合は公式のドキュメントを参考にしながらご自身で作ってみてください!

# ライブラリのインポート
import gym
from gym import spaces
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
# Cart Pole問題の環境を作成
cart_pole_env = gym.make(
    "CartPole-v1", render_mode="rgb_array", new_step_api=True
)

実はたったこれだけなんです。
これだけで環境が作成出来てしまいます。
と言っても何が起こっているかよく分からないので、少しだけ詳しく見てみましょう。

def arr_to_img(arr: np.ndarray) -> None:
    """
    NumPy配列から画像を表示させる関数

    Parameters
    ----------
    arr: np.ndarray
        画像に変換したいNumPyの配列

    Returns
    ----------
    None
    """

    # NumPy配列をPILのImageオブジェクトに変換
    img = Image.fromarray(arr)

    # Matplotlibで画像を表示
    plt.figure(figsize=(6, 4), tight_layout=True)
    plt.imshow(img)
    plt.axis("off")
    plt.show()
# 環境の初期化
cart_pole_env.reset(seed=42)

# レンダーの結果をNumPy配列で取得
arr = cart_pole_env.render()[0]

# 画像を表示
arr_to_img(arr)

Cart Pole環境の初期化を行い、最初の様子を画像で表現しました。
続いて、7回ほど左に動いた後にどのような画像になるかを確認してみましょう。

# 7回状態を遷移する
for _ in range(7):
    # 左に動く(step()メソッドの引数を0にすると左、1にすると右に動く)
    _, _, _, _, _ = cart_pole_env.step(0)  # 返り値は今回は考えなくて良い

# レンダーの結果をNumPy配列で取得
arr = cart_pole_env.render()

まず、 arr = cart_pole_env.render() の部分を見てみると、7回行動をしているのでそれぞれの状態が記録されています。
なので最後のものが7回左に移動した後の結果になるので、それを描画してみましょう。

# 画像を表示
arr_to_img(arr[-1])

状態が変わり、右側に傾いていることが確認出来ました。
それではここまでのコードの解説を順に行います。

cart_pole_env = gym.make(
    "CartPole-v1", render_mode="rgb_array", new_step_api=True
)

こちらは、 gym.make() というメソッドを使い、デフォルトで用意されている環境を設定することができます。
第1引数の "CartPole-v1" の部分でどのベンチマークを使うかを指定します。
第2引数の render_mode="rgb_array" の部分では、レンダーの際にどうするかを指定するのですが、基本的に "human" or "rgb_array" です。
しかし、問題によって変わってくるので、ドキュメントを参照してどうするのかを考えましょう。
第3引数の new_step_api=True は新しいAPIを使う上で必要になってきます。
これから先は自分で環境を作ることを想定しているので、ここら辺の引数についてはあまり詳しく扱いません。

cart_pole_env.reset()
cart_pole_env.render()
cart_pole_env.step()

これら3つは、Gymクラスの基本的なメソッドになります。
.reset() は環境を初期化するためのメソッド、 .render() は環境を可視化するためのメソッド、 .step() は環境を変化させるためのメソッドになります。
これらはGymで環境を作る上で必ず必要となる重要なメソッドなので、しっかりと覚えておきましょう。

それでは今度は、このCart Pole問題を通じてGymのクラスの属性を確認してみましょう!
確認したいのは以下の3つです。

  • cart_pole_env.action_space
  • cart_pole_env.observation_space
  • cart_pole_env.reward_range
# Action Spaceの確認
print(cart_pole_env.action_space)

Action Spaceは、その環境における行動の種類のようなものを意味します。
今回の場合、 Discrete(2) と「離散値で (0, 1) 」の2つがあることを意味しています。
ここで実際に 01 がそれぞれ何を表しているかは公式のドキュメントに確認する必要がありますが、今後は自分で作っていくため自分がわかりやすい定義をすることを心がけましょう。

Num Action
0 カートを左に動かす
1 カートを右に動かす
# Observation Spaceの確認
print(cart_pole_env.observation_space)

Observation Spaceは、その環境における観測量の範囲を意味します。

Box([-4.8000002e+00 -3.4028235e+38 -4.1887903e-01 -3.4028235e+38], [4.8000002e+00 3.4028235e+38 4.1887903e-01 3.4028235e+38], (4,), float32)

とありますが、すべて float32 で定義されていて、4種類の観測量に対してそれぞれ

  • (-4.8000002e+00, 4.8000002e+00)
  • (-3.4028235e+38, 3.4028235e+38)
  • (-4.1887903e-01, 4.1887903e-01)
  • (-3.4028235e+38, 3.4028235e+38)

の範囲であることを意味しています。
ドキュメントを見ると、

Num Observation Min Max
0 カートの位置 -4.8 4.8
1 カートの速度 -Inf Imf
2 ポールの角度 -0.418 rad 0.418 rad
3 ポールの角速度 -Inf Imf

と書かれているので、同じことが分かりました。

# 報酬の範囲の確認
print(cart_pole_env.reward_range)

報酬の範囲については自由に決められるため、初期設定として (-inf, inf) とすることが多いです。
しかし、問題設定をする上で報酬をどのように渡すかは重要になってくるため、適切な範囲を指定することで上手い学習ができるかもしれません。

さて、ここまでに出てきたことをまとめましょう!
Cart Pole問題を通じて、Gymで作る環境は次のようになっていることが分かりました。

  • 重要なメソッドは、以下の3つ
    • .reset()
      • 環境を初期状態に戻す
    • .render()
      • 環境の状態を表示する
    • .step()
      • エージェントの行動を受け取り、環境を1ステップ進める
  • Gymクラスの主な属性
    • .action_space
      • 行動の種類を定義
    • .observation_space
      • 観測量の範囲を定義
    • .reward_range
      • 報酬の範囲を定義

これらを揃えることが、Gymクラスを上手く使いこなせるようになる鍵になります!

自分だけの環境を作成

自分だけの環境を作るために、ここではコアとなる gym.Env クラスと gym.spaces モジュールの使い方をマスターしましょう。
例として、ジャンケンの問題を考えてみます。
なお、この節ではGymでの環境作りに慣れることを目的としているので、エージェントを学習させることは考えなくて結構です。

まずは問題の概要から説明します。
ジャンケンには普通、

  • グー
  • チョキ
  • パー

の3種類があります(10数種類の意味がわからないのは考えていません)。
ジャンケンなので、相手が出したものに勝つときに報酬が高くなるように設計する必要があります。
今回は極端に、

  • 勝ち: +100
  • あいこ: 0
  • 負け: -100

としましょう。
さて、Gymのクラスに必要なのは

  • メソッド
    • .reset()
    • .render()
    • .step()
  • 属性
    • .action_space
    • .observation_space
    • .reward_range

でした。
属性は __init__() の部分で定義することがほとんどで、問題の設定に関するものになります。
.action_space は行動の種類を定義するもの、 .observation_space は観測量の範囲を定義するもの、 .reward_range は報酬の範囲を定義するものでした。
今回の場合、報酬が観測量と一致するので .reward_range は必要無さそうです。
他の2つについては、 gym.spaces を使って次のように定義できます。

action_space = spaces.Discrete(3)
observation_space = spaces.Box(
    low=np.array([-100, -100, -100]),
    high=np.array([100, 100, 100]), shape=(3,), dtype=int
)

spaces.Discrete(3) は3つの離散値 (0, 1, 2) を持つことを表しています。
今回は

  • 0 \rightarrow グー
  • 1 \rightarrow チョキ
  • 2 \rightarrow パー

をそれぞれ表しているとします。
spaces.Box() については、3つの行動それぞれに対する報酬の最大値と最小値が記録されていましたね。
それぞれのメソッドを作成する前に、 __init__() の部分を作ってみましょう!

class MyEnv(gym.Env):
    def __init__(self):
        # クラス継承のおまじない
        super(MyEnv, self).__init__()

        # Action Spaceの定義
        self.action_space = spaces.Discrete(3)

        # Observation Spaceの定義
        self.observation_space = spaces.Box(
            low=np.array([-100, -100, -100]),
            high=np.array([100, 100, 100]), shape=(3,), dtype=int
        )

続いて .reset() メソッドを作ってみましょう。
初期状態を作成するということですが、ジャンケンはそんなに「スタート」を意識することは無いので、報酬を0にすることだけを考えれば良いかなと思います。
また、最初にインスタンスを作るときに毎回コードで呼び出すのでも良いのですが、 __init__() の中に入れておくとその必要が無くなります。
この変更を加えて MyEnv クラスに修正を入れてみましょう!

class MyEnv(gym.Env):
    def __init__(self):
        # クラス継承のおまじない
        super(MyEnv, self).__init__()

        # Action Spaceの定義
        self.action_space = spaces.Discrete(3)

        # Observation Spaceの定義
        self.observation_space = spaces.Box(
            low=np.array([-100, -100, -100]),
            high=np.array([100, 100, 100]), shape=(3,), dtype=int
        )

        # 初期化
        self.reset()

    def reset(self) -> None:
        # 報酬を0にする
        self.total_reward = 0

今度は .render() メソッドを実装してみましょう。
レンダリングといっても、Cart Pole問題のように画像が動くこともないので単純に print() 関数を用いた合計報酬の表示を行います。
MyEnv クラスに変更を加えましょう!

class MyEnv(gym.Env):
    def __init__(self):
        # クラス継承のおまじない
        super(MyEnv, self).__init__()

        # Action Spaceの定義
        self.action_space = spaces.Discrete(3)

        # Observation Spaceの定義
        self.observation_space = spaces.Box(
            low=np.array([-100, -100, -100]),
            high=np.array([100, 100, 100]), shape=(3,), dtype=int
        )

        # 初期化
        self.reset()

    def reset(self) -> None:
        # 報酬を0にする
        self.total_reward = 0

    def render(self) -> None:
        # 合計報酬を出力する
        print(f"Total Reward:\t{self.total_reward}")

最後に .step() メソッドを書いてみます。
.step() メソッドには行動と相手が何を出してきたかの入力が必要になります。
まず、相手が何を出してきたかをこちらの行動と同様に定義し、相手の手に対して報酬を分岐させます。
そして、こちらの行動に応じてどのように報酬を与えるかを定義します。
MyEnv クラスに変更を加えましょう!

class MyEnv(gym.Env):
    def __init__(self):
        # クラス継承のおまじない
        super(MyEnv, self).__init__()

        # Action Spaceの定義
        self.action_space = spaces.Discrete(3)

        # Observation Spaceの定義
        self.observation_space = spaces.Box(
            low=np.array([-100, -100, -100]),
            high=np.array([100, 100, 100]), shape=(3,), dtype=int
        )

        # 初期化
        self.reset()

    def reset(self) -> None:
        # 報酬を0にする
        self.total_reward = 0

    def render(self) -> None:
        # 合計報酬を出力する
        print(f"Total Reward:\t{self.total_reward}")
    
    def step(self, action: int, opposite: int) -> None:
        # 相手の手によって報酬を分岐
        if opposite == 0:
            reward_dict = {0: 0, 1: -100, 2: 100}
        elif opposite == 1:
            reward_dict = {0: 100, 1: 0, 2: -100}
        else:
            reward_dict = {0: -100, 1: 100, 2: 0}
        
        # 報酬を合計報酬に加算
        reward = reward_dict[action]
        self.total_reward += reward

これでジャンケンの環境の完成です!
次のように実行してみましょう。

# 環境のインスタンスを用意
env = MyEnv()

# ジャンケンの組みを作成
opposite_list = [np.random.choice(3) for _ in range(10)]
print(f"Opposite:\t{opposite_list}")
my_list = [np.random.choice(3) for _ in range(10)]
print(f"Mine:\t\t{my_list}")
# ジャンケンを10回繰り返す
for i in range(len(my_list)):
    opposite = opposite_list[i]
    action = my_list[i]
    env.step(action=action, opposite=opposite)
    env.render()

このように環境を変化させることができます!

次回予告

次回は、より複雑な問題の環境を自分で作ってみます。
Grid Worldと呼ばれる四方向のマス目に動ける迷路のようなものを定義します!

Discussion