機械学習周りのプログラミング中心。 イベント情報
ポケモンバトルAI本電子書籍通販中

ゲームボーイ上で強化学習を行う part7

実装

Q学習とアニメーションの共存

Q学習でQテーブルを更新すると同時に、最新のQテーブルに基づいて車が運動するアニメーションを表示する実装を行います。課題は、学習と表示をどのように並行動作させるかです。もっとも単純に学習過程を表示する方法は、学習に用いているMountainCar環境が1ステップ進むたびに、それに合わせてスプライトの座標を移動することです。しかし実際には、1秒間に100ステップ以上進行するため動きが速くなりすぎますし、スプライトの座標を更新するにはVBLANK割り込みを待つ必要があるため学習アルゴリズム内で直接スプライトの座標を操作できない(1ステップごとにVBLANK割り込みを待つと、学習が著しく遅くなる)という問題があります。今回採用した解決策は、main関数で学習を進めつつ、VBLANK割り込みハンドラで学習とは独立したMountainCar環境を動作させるというものです。VBLANK割り込みはmain関数で何をしているかにかかわらず、60fpsの周期でVBLANK割り込みハンドラを実行し、それが終了するとmain関数の続きを実行する仕様となっています。main関数では、表示のことは気にせずQ学習を行い、Qテーブルを更新します。VBLANK割り込みハンドラは、独立したMountainCar環境を保持し、ハンドラが呼び出されるごとにQテーブルを参照して行動を決定し、環境を1ステップ進め、スプライトの座標を更新します。デメリットとして、ハンドラ内で1エピソードが進行する間にQテーブルがmain関数側で書き換えられてしまい一貫性がなくなることがありますが、テーブルの値の変化はわずかなので実質的に問題はありません。他の方法として、main関数側で実行したエピソードの各ステップでの座標を記録しておき、VBLANKハンドラ側で再生するという方法も考えられます。こちらは一貫性はあるものの、エピソードの長さに比例したメモリが必要になります。以上の考えに基づいて実装したコードが以下の通りです。main関数では学習を行い、100エピソードごとに評価を行います。評価結果として、エポック数(100エピソードを1エポックと数える)および1エピソードの平均ステップ数(短いほうが良い)をTrainState型のグローバル変数経由でVBLANKハンドラに渡しています。アニメーション用のエピソード実行だけでなく、評価結果の表示もVBLANKハンドラ側で担当します。main関数の実行中のどのタイミングでも割り込みが発生するため、データ構造の書き換え中に割り込みが発生して中途半端なデータが読まれないよう、__criticalを用いて一時的に割り込みを停止しています(このコードでそこまでする必要性は薄いですが)。

#define STEP_CYCLE 2

typedef struct
{
    QLearningState *q_state;
    MountainCarState state;
    uint8_t cycle;
    uint8_t updated;
    int epoch;
    uint8_t avg_steps;
} TrainState;

static TrainState *ts;

// VBL割り込み関数側では、現在のモデルを用いて独立にエピソードを実行し、車の動きを可視化する。
void train_vbl()
{
    const QLearningState *q_state = ts->q_state; // main側と共用

    if (--ts->cycle == 0)
    {
        ts->cycle = STEP_CYCLE;
        step_display(q_state, &ts->state); // MountainCar環境の状態であるts->stateはmainと独立
    }

    if (ts->updated)
    {
        ts->updated = 0;
        gotoxy(0, 16);
        printf("%d00 episodes\nAvg steps: %d", ts->epoch, ts->avg_steps);
    }
}

// main関数でスプライト等の初期化後に呼び出される
void train_main()
{
    ts = malloc(sizeof(TrainState));
    MountainCarReset(&ts->state);
    QLearningState *q_state = QLearningStateCreate();
    ts->q_state = q_state;
    ts->cycle = STEP_CYCLE;
    ts->updated = 1;
    ts->epoch = 0;
    ts->avg_steps = MAX_EPISODE_LEN;

    __critical
    {
        add_VBL(train_vbl);
    }

    // main関数側では、割り込み処理をしていない間ずっとモデルの学習を行う。
    for (int epoch = 0; epoch < 1000; epoch++)
    {
        initrand((unsigned int)epoch);
        for (int i = 0; i < 100; i++)
        {
            TrainEpisode(q_state);
        }
        // fix test case
        initrand((unsigned int)1);
        uint32_t total_steps = 0;
        for (int i = 0; i < 10; i++)
        {
            uint8_t steps;
            TestEpisode(q_state, &steps);
            total_steps += steps;
        }

        __critical
        {
            ts->epoch = epoch + 1;
            ts->avg_steps = total_steps / 10;
            ts->updated = 1;
        }
    }
}

以上の実装により、Q学習を行いながら、最新のQテーブルを用いて車を運動させてアニメーション表示させることができました。

学習しながらアニメーションを行っている画面

ボタン入力によるモード選択

ここまでのプログラムでは、計算と画面出力しか実装していませんでしたので、最後にボタン入力の方法について紹介します。

タイトル画面を実装し、Aボタンを押せば学習を行うモード、Bボタンを押せば学習済みQテーブルを用いてアニメーションのみを行うモードに分岐することにします。実装は簡単で、joypad関数で現在のボタンの状態を読み込むことができます。Aボタンが押されていれば、bit 4が1になり、Bボタンが押されていれば、bit 5が1になるという要領です。ボタンとビットの対応関係は、定数J_AJ_Bなどに格納されています。

    uint8_t mode = 0;
    while (!mode)
    {
        uint8_t button = joypad();
        if (button & J_A)
        {
            mode = 1;
        }
        else if (button & J_B)
        {
            mode = 2;
        }
        wait_vbl_done();
    }

以上の実装で、ゲームボーイ上で強化学習を行うソフトが完成しました!

タイトル画面

次回、最終回として実機で動作確認をします。