Morikatron Engineer Blog

モリカトロン開発者ブログ

【CEDEC2020】模倣学習でAIに3Dアクションゲームを攻略させてみる(後編)【ML-Agents】

こんにちは、エンジニアの竹内です。
この記事は【CEDEC2020】模倣学習でAIに3Dアクションゲームを攻略させてみる(前編)【ML-Agents】 - Morikatron Engineer Blogの続きとなります。
前編ではUnity側で行った学習の準備を中心的に扱いましたが、後編ではPython側における学習パイプラインやアルゴリズムの実装、それから学習した結果や考察などを中心に扱っていきます。

Python側でやること

特徴量や報酬などの一通りの設定を行いUnityの実行環境をビルドした後、それを使って学習を開始するためにPython側でやることは

  • 行動の選択肢の変換
  • gymラッパーの作成
  • デモデータをロードする仕組みの実装
  • アルゴリズムの実装

このあたりの作業となります。 これらを順に説明していきます。

行動の選択肢の変換

少しUnity側の説明に戻りますが(前回の記事に入れ忘れていた)、ML-AgentsのBehavior Parametersコンポーネントを設定することで、以下の図の様にUnityエディタ上でエージェントの行動の選択肢をどのような形にするか決定することができます。

f:id:morika-takeuchi:20200914114031p:plain
エディタ上での行動の設定

3D Game Kitのキャラクターが行う行動はWSADキーでの前後左右移動、スペースキーでのジャンプ、マウスクリックでの攻撃の4種類にカテゴリー分けされます。上図のブランチはそれら4種類のカテゴリーに該当し、各ブランチのサイズはそれぞれのカテゴリーにおける行動の選択肢の数を表しています。例えばブランチ0は左右移動のカテゴリーに該当し「移動しない」「右に移動」「左に移動」の3つ選択肢の中から、ブランチ2はジャンプのカテゴリに該当し「ジャンプしない」「ジャンプする」の2つ選択肢の中からそれぞれ行動を選ぶことになります。これに応じて例えば出力された行動が[1, 1, 1, 0]であれば「右に移動、前に移動、ジャンプを実行、攻撃はしない」という行動が同時に実行され、結果的に「右前にジャンプして移動」という行動が実現されます。
一方で、DQNで学習を行う際はこのような複数の離散値で表現される行動の組み合わせ(=multi-discreteな行動)をうまく扱うことが難しいため*1複数の行動の組み合わせに関してもそれぞれ別々の行動(=discreteな行動)として定義し直す必要があります。
今回はエージェントの行動を以下のように設定しました。この設定に基づいて、デモをロードする部分であったり、gymラッパーなどでmulti-discreteからdiscreteな行動を変換する関数を噛ませています。

DISCRETE _ACTION_MEANINGS= {
    0: "↙",  # "↙"は↓と←を同時に入力することに該当します。
    1: "↙+JUMP",
    2: "←",
    3: "←+JUMP",
    4: "↖",
    5: "↖+JUMP",
    6: "↓",
    7: "↓+JUMP",
    8: "NOOP",
    9: "JUMP",
    10: "↑ ",
    11: "↑+JUMP",
    12: "↘",
    13: "↘+JUMP",
    14: "→",
    15: "→+JUMP",
    16: "↗",
    17: "↗+JUMP",
    18: "ATTACK"
}

gymラッパーの作成

ML-AgentsのPython APIは独特な手順でUnityの実行環境とのやり取りを行うため、これをOpenAIが提供しているgym環境と同じ様に使用できるようなラッパーを作っておくと便利です。gymラッパーとしてはML-Agentsが提供しているgym unityというPythonパッケージもありますが、今回はカスタマイズ性を重視して自分で作成しました。gym unityでは複数エージェントでの学習機能が廃止されてしまっているので(なんでなんだろう)、その機能も追加してあります。これにより実験用の環境などでは1つのシーン上に複数個ステージを置くことで同時に学習を実行することができました。3D Game Kitのサンプルステージでも1つのシーン上に複数のステージを立てて学習させたかったのですが、さすがに手元のPCだと処理に負荷がかかりすぎてしまったため断念しました。

class MultiAgentGymWrapper(Env):
    """
    Unityでビルドした環境をOpenAI Gym風に扱うためのラッパー(ML-Agents==0.16.0のみ対応)
    """
    def __init__(self, env, action_type="multi_discrete"):
        self._env = env
        self._env.reset()  # 行動空間と状態空間を取得するために一回リセット呼ぶ
        self.group_name = self._env.get_behavior_names()[0]  # エージェントのグループ名
        self.group_spec = self._env.get_behavior_spec(self.group_name)  # 環境の情報
        decision_steps, _ = self._env.get_steps(self.group_name)  # 行動の実行結果を取得
        self.num_agents = len(decision_steps)  # エージェント数
        # Set these in ALL subclasses
        self.action_type = action_type  # 最終的にはdiscreteの方を主に使用
        if action_type == "multi_discrete":
            self.action_space = MultiDiscrete(self.group_spec.action_shape)
        elif action_type == "discrete":
            self.action_space = Discrete(len(MULTI_DISCRETE_ACTIONS))
        else:
            raise NotImplementedError()
        self.num_ray_comp = len(self.group_spec.observation_shapes) - 1  # 取り付けたレイセンサーのコンポーネントの数
        size = self._get_vec_obs_size()
        # observationを平坦化 (ray1, ray2, vec) -> (ray1+ray2+vec, )
        self.observation_space = gym.spaces.Box(  
            low=-np.inf, high=np.inf, shape=(size, )
        )

    def step(self, action):
        if self.action_type == "discrete":  # multi-discreteからdiscreteへの行動の変換
            action = np.array([MULTI_DISCRETE_ACTIONS[int(action[agent_i])] for agent_i in range(len(action))])
        self._env.set_actions(self.group_name, action)
        self._env.step()
        # エピソードが終了したエージェントの情報はterminal_stepsの方に記録される
        decision_steps, terminal_steps = self._env.get_steps(self.group_name)
        ob = np.concatenate(decision_steps.obs, axis=1)  # observationの平坦化
        reward = decision_steps.reward
        # 終了判定は直接与えられないのでterminal_stepsに記録されたagentのidから判別する
        done = np.full((self.num_agents,), False)
        if len(terminal_steps):
            done[terminal_steps.agent_id] = True
        info = {'max_step': terminal_steps.max_step, 'agent_id': decision_steps.agent_id, 'action_mask': decision_steps.action_mask}
        return ob, reward, done, info

    def reset(self):
        self._env.reset()
        decision_steps, _ = self._env.get_steps(self.group_name)
        ob = np.concatenate(decision_steps.obs, axis=1)  # observationの平坦化
        return ob

    def _get_vec_obs_size(self):
        # 平坦化した場合のobservationの要素数を取得
        result = 0
        for shape in self.group_spec.observation_shapes:
            if len(shape) == 1:
                result += shape[0]
        return result

デモデータのロード

エディタ上で作成したデモデータはプロトコルバッファーに変換され、ファイル名.demoとして保存されます。
模倣学習に使用する際のデモデータのロードはML-Agentsに実装されているデモロード関数をラップして、模倣学習に利用できる形に変換しています。

from mlagents.trainers import demo_loader


def get_discrete_action(md_action):
    """
    multi-discrete actionをdiscrete actionに変換
    md_action = [左右移動(0~2), 前後移動(0~2), ジャンプ(0~1), 攻撃(0~1)]
    """
    ATTACK = 18
    VERTICAL = 6
    HORIZONTAL = 2
    if md_action[-1] == 1:  # 攻撃が選択されていればは他の行動に関わらず攻撃行動を選択する
        discrete_action = ATTACK
    else:
        discrete_action = VERTICAL * md_action[0] + HORIZONTAL * md_action[1] + md_action[2]
    return discrete_action


def multi_demo_file_parser(demo_dir, obs_flatten=True, action_type="multi_discrete"):
    """
    複数のデモファイルを一括で読み込み
    """
    trajectories = []
    demo_pathes = glob.glob(demo_dir + "/*")  # デモファイルが保存されているパス
    for demo_path in demo_pathes:
        episodes = []  # エピソード毎に分けて保存
        # ML-Agentsのload_demonstration関数を利用
        agent_group_spec, info_action_pairs, total_expected = demo_loader.load_demonstration(file_path=demo_path)
        for info_act, next_info_act in zip(info_action_pairs[:-1], info_action_pairs[1:]):
            action = np.array(info_act.action_info.vector_actions, dtype=int)
            if action_type == "discrete":
                action = get_discrete_action(action)
            obs = _flatten_obs(info_act)
            next_obs = _flatten_obs(next_info_act)
            reward = info_act.agent_info.reward
            done = info_act.agent_info.done
            episodes.append((obs, action, reward, next_obs, done))
        trajectories.append(episodes)
    return trajectories


def _flatten_obs(info_act):
    """
    observationを平坦化
    """
    observations = [list(obs.float_data.data) for obs in
                    info_act.agent_info.observations]  # [sensor1, sensor2, ... sensorN, vector]

    sensor_obs, vector_obs = sum(observations[:-1], []), observations[-1]
    obs = np.array(sensor_obs + vector_obs)
    return obs

アルゴリズムの実装

模倣学習のアルゴリズムにはDeep Q-Learning from Demonstrations(DQfD)を使用しました。アルゴリズムの詳細については以前のブログで紹介しているので、そちらをご参照ください。先程のgymラッパーを利用することによりAtariの環境とほぼ同じ様に扱うことができ、実装を変える必要は殆どありませんでした。

ネットワークの構造
学習に使用したネットワークには多層パーセプトロン(MLP)に残差スキップ接続を導入したResNetライクな構造を使用しました。
当初は単純な3層64ユニットのMLPを使用していましたが、ある水準からスコアが伸び悩む現象が見られたため、ユニット数や隠れ層を増やすなどの工夫を行うとともに、この構造を採用した結果手応えが得られたため採用に至りました。具体的な実装についてはkaggleのdiscussionにGMの方が公開されていた構造を参考にさせていただきました*2。ただしネットワークの細かい部分の重要度についてはまだ未検証な部分も多いため、今後の課題としたいです。

f:id:morika-takeuchi:20200914190609p:plain
ネットワークの構造
def mlp_resnet(num_layers=12, num_hidden=1024, activation=tf.nn.leaky_relu):

    def fc_unit(x, n=num_hidden, d=0.5):
        x = Dense(n)(x)
        x = BatchNormalization()(x)
        x = activation(x)
        x = Dropout(d)(x)
        return x

    def res_unit(x, n=num_hidden, d=0.5):
        h = Dense(n)(fc_unit(x, n, d))
        x = Add()([h, x])
        x = activation(x)
        return x

    def network_fn(input_shape):
        print('input shape is {}'.format(input_shape))
        x_input = tf.keras.Input(shape=input_shape)
        x = fc_unit(x_input)
        for i in range(num_layers):
            x = res_unit(x)
        x = Dense(num_hidden)(fc_unit(x))
        network = tf.keras.Model(inputs=[x_input], outputs=[x])
        # network.summary()
        # plot_model(network, to_file='resnet_model.png')  # need graphviz
        return network
    return network_fn

学習

模倣学習に使用するデモについては特に寄り道などはせずに、最短でゲームを攻略できるルートで5エピソード分を作成しました。
ただし、1エピソードだけ「1つ目のスイッチを作動させてからライフが0になり、直近のチェックポイントでリスポーン(再スタート)する」ような軌跡を含むデモを作成しました。これについては後述します。

学習には以下のマシンを使用しました。
CPU: Intel Core i9-9900K 3.6GHz 64GB
GPU: NVIDIA TITAN RTX 24GB
下の動画は学習後のエージェントのプレイ動画になります。
www.youtube.com
動画ではボスを倒すところまで到達していますが、8割近いエピソードでは最後のギミックを作動させてからボスを倒すまでに20000ステップが終了するような感じです。なかなかボスは手強いですね…
100万ステップの事前学習後の学習時のスコアの推移は以下のようになりました。学習自体は1週間近く回しましたが、100万ステップ(約3日程度)を過ぎてから頭打ちから少しスコアが下がるような傾向が見られました。
f:id:morika-takeuchi:20200914180543p:plain

他に試した事や失敗例、考察

CEDEC2020のセッションではスペースや時間の関係上載せられなかった考察や失敗例なども載せておきます。

デモの行動を重視しすぎると敵を攻撃しなくなる

DQfDでは人間のデモプレイを利用して学習を効率化させる手法ですが、必ずしも人間のプレイが常に最善の行動であるとは限りません。
これによって起きうる弊害について「前方の敵をレイセンサーで検知し、攻撃をする」という一連の行動を例に考えてみます。この場合エージェントの最適な行動はもちろん前方の敵に攻撃が当たる距離まで近づいた瞬間、攻撃行動を選択するというものとなります。しかし、人間が操作する場合はどうしても、以下の図のように「敵を倒せる距離まで十分近づいていながらまだ攻撃ボタンを押していないフレーム」が攻撃を行うフレームより多く存在することになります。

f:id:morika-takeuchi:20200915164239p:plain
デモプレイ作成時に最適な行動を取っていない例

そのため、デモの行動を優先するようなハイパーパラメータを高く設定しすぎたり、事前学習のみを行ったモデルを使用したりした場合には敵を攻撃する頻度が顕著に低下するという現象が見られました。
また、デモデータのみを利用し、環境の報酬を全く利用しないような模倣学習手法(逆強化学習など)を仮に採用したとしても敵を攻撃する行動をとることが難しくなるということが予想されます。

チェックポイントに戻ることでデモにない状態に陥る事がある

模倣学習の欠点の1つに「デモにない状態に対しては通常の強化学習とパフォーマンスが殆ど変わらない」という点があります。ある程度はニューラルネットによる汎化が効くことでデモから少し外れた状態に遭遇しても正しい行動を取ることができますが、一度大きく道を外れてしまうともとのコースに戻ることは極端に難しくなります。
今回の検証では「ライフが0になると直近のチェックポイントでリスポーンする」というもともとの仕様を変えずに学習を行いましたが、この仕様によりデモに存在しない状態に陥り、そこから先に進むことができなくなってしまう現象が見られました。
具体的には以下の動画のように、1つ目のスイッチを作動させてから2つ目のスイッチを作動させる前にライフが0になり、1つ目のスイッチ作動させる前の地点にリスポーンした場合、エージェントに特徴量として与えているギミックの作動フラグと位置座標の組み合わせがデモに存在しない状態となり、先に進めなくなるといった現象が見られました。
youtu.be
このような問題は特徴量を使用する場合どのゲームにおいても起こりうると考えられるため、デモ作成時に敢えてリスポーンする、学習時にはライフが減らないようにする、リスポーン地点が遠くなりすぎないようにするなどの対処を予め考えておく必要があります。

局所的な方策と大局的な方策を分けて学習させる(失敗)

結局の所、今回のステージにおけるエージェントに求める理想的な振る舞いは、周囲に敵が存在する場合はそれに向かって攻撃を行い、敵が存在しない場合は正しい手順でマップを移動する、ということに尽きます。そのためエージェントの方策は、レイセンサーによって与えられた自身とオブジェクトとの相対的な位置関係から求められる局所的な方策(=ステージに依存しない方策)と、ステージ上での自身の位置座標を与えられた際に求められる大局的な方策に分ける事ができます。局所的な方策と大局的な方策に分けることで、例えば別のステージを学習させる際に局所的な方策はそのまま転用し、大局的な方策のみを再学習するだけで十分となるのではないかと考えました。
そこで、敵を配置せずに位置座標のみでステージを学習させたモデルと、レイセンサーのみを使用し周囲の敵への攻撃とアイテムの取得のみをタスクとして学習させたモデルの2つを用意し、敵が配置されたステージにおいて2つのモデルを動的に切り替えるという方法を試しました。
切り替え方としては、2つのモデルのQ値のスケールを調節した後で値が高い方を採用するという方法と、レイセンサーで敵を検出した時のみ局所的な方策に切り替える方法の2つを試しました。
しかし結果としては、前者の方法ではどちらかの方策に偏らないようにQ値のスケールを調節することが非常に難しく、後者の方法では敵を検出した途端にジャンプや攻撃を頻繁に繰り返してしまい、敵を倒せない限り先に進めなくなるという状態に陥ったため、最終的に2つの方策を分けて学習させるという方法は使用しませんでした。
以下の動画は局所的な方策を学習させた環境及びその環境で学習させた後のエージェントの振る舞いです。
youtu.be

状態価値と行動価値の可視化

ベースとしたアルゴリズムのApe-X DQfDのデモ動画は状態価値と行動価値のプロットアニメーションを一緒に表示する、というとても良いデザインとなっており、それを参考にして今回のステージでも同様なデザインの動画を作ってみました。*3
動画にしてみると概ね直感と相違ない結果が見られ、ギミックを作動させる直前と直後であったり、敵が多い(倒して報酬を得られやすい)場所などが特にわかりやすく変化がみられるかと思います。*4
youtu.be

まとめ

CEDEC2020で発表したセッションの「Unityで制作された3Dアクションゲームを模倣学習によって攻略する」というパートで、発表しきれなかった部分を中心に解説しました。この検証を始めた当初はステージのクリアまで到達することは正直難しいと思っていましたが、特徴量のエンジニアリングやネットワークの構造の改善など、色々と工夫を重ねることで想像以上のパフォーマンスで見事にステージをクリアすることができ、模倣学習のポテンシャルを感じました。人間の代わりにゲームをプレイするAIを強化学習ベースで作成するにあたって、この模倣学習というアプローチは個人的には最も可能性のあるアプローチの1つだと考えています。今後は同様な手法を使って3Dアクションゲームだけではなく2Dアクションゲームやシューティングゲームなど、様々なジャンルのゲームに応用できるか検証し、可能性を模索していきたいです。

*1:実は当初は枝分かれの分だけネットワークの後半部を分岐させたAction Branching Archtechture(https://arxiv.org/abs/1711.08946)に近い方法も試していましたが、攻撃行動だけは他の行動と同時に行うことができないという点を考慮して、結局ノーマルなネットワークの構造を使用しました。

*2:そもそもMLPをResNet-likeにするというアプローチはよくありそうだとは思っていたものの、調べてみると意外と前例や理論的な研究が少ないように感じました。

*3:matplotlibのArtistAnimationで作成したプロット動画を動画編集ソフトで合成し、タイミングの同期を行って作成しました(意外とめんどくさかった)。

*4:事前知識なしだとわかりにくく、時間の都合上CEDEC2020では使用しませんでした…