Morikatron Engineer Blog

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

AIネット対戦システムの紹介2

こんにちは、モリカトロン株式会社チーフエンジニアの松原です。

前回記事(AIネット対戦システムの紹介 - Morikatron Engineer Blog)に続きまして、AIネット対戦システムのAIクライアント(ゲームをプレイするためのプログラム)を紹介いたします。まずは「ゲームをプレイするプログラム」の仕組みについて、プログラマ以外の方でもわかるよう説明してみます。そして後半はAIネット対戦システム上でリバーシのランダムプレイヤーを実現するプログラムコードを紹介します。

 目次

はじめに

モリカトロンが開発したAIネット対戦システムは、インターネット上で[人間x人間][人間xAI][AIxAI]のゲーム対戦を実現するサーバー&クライアントシステムの枠組みです。サーバーサイドのプログラムではロビーの制御、対戦部屋の管理、ゲームの進行などを行い、対人クライアントは人間にゲームをプレイさせるユーザインタフェースです。そして今回紹介するAIクライアントは、ゲームをプレイするプログラムとなります。

 

「ゲームをプレイする」プログラムとは

プログラムでゲームをプレイするには、どういった仕組みが必要でしょうか。ここでは6x6のリバーシを題材として考えてみたいと思います。

f:id:morika-ma2:20201117151430p:plain:h180

まずはじめに考えなければいけないのは「ゲームを構成する要素とは何か」です。リバーシの構成要素を並べると、次のようになります。

  • ボード
  • 黒石
  • 白石

たいへんシンプルでわかりやすいですね。さて、この三つの構成要素をプログラムで扱うには、プログラム内部でこれらの要素をどのように表現するか、を考えます。いわゆる「内部表現」です。まず世界は6x6のマス目(ボード)であり、その中に黒石と白石が存在します。これをプログラムで扱うには次のような2次元配列を用いると便利です。

[[ 0,  0,  0,  0,  0,  0],
 [ 0,  0,  0,  0,  0,  0],
 [ 0,  0, -1,  1,  0,  0],
 [ 0,  0,  1, -1,  0,  0],
 [ 0,  0,  0,  0,  0,  0],
 [ 0,  0,  0,  0,  0,  0]]

この配列は、上図のスタート配置、すなわち横6x縦6のボードに黒石(1)が二つ、白石(-1)が二つ、その他はなにもないマス(0)、という状態を表しています。この状態からゲームをスタートして、先手(黒石=1)のプレイヤーは「1で-1を挟める場所」に新しい1を置く(アクション)ことで、ゲームが進行します。

まとめると、プログラムでゲームをプレイする、とは

  • ゲームの現在の状態が表現されており
  • その状態を見ながら何らかのアクションを実行する

これをお互いのプレイヤーが順に繰り返すことになります。

リバーシをプレイするプログラムコード

それでは、AIネット対戦システムでリバーシをプレイする実際のプログラムを見てみましょう。

import sys
import random
import pprint
import client_main
"""
思考関数(盤面を解析して、どこへ置くか決めるだけ)
この関数は「打てる手」の中からランダムに1つ選んで打ちます。
"""
def place_stone(dic: dict, first_or_second: int):  # 石を置く場所(y,x)のタプルを返す
    # dic['Placeable'][y][x]配列の0以外(=打てる位置)をyxタプルでリスト化して、その中からランダムに手を選ぶ。
    pprint.pprint(dic)
    placeable = []
    for y, row in enumerate(dic['Placeable']):
        for x, val in enumerate(row):
            if val != 0:
                placeable.append((y, x))  # 配置可能な位置のy,xのタプルをリストに追加
    if len(placeable) <= 0:
        return 0, 0
    return random.choice(placeable)
if __name__ == "__main__":
    client_main.main_loop(_run_on_local=True,
                          _game_name="Reversi6",  # ゲームの名前
                          _resident_mode=True,  # 部屋に常駐して戦い続ける
                          _place_stone_func=place_stone,  # 思考関数はコレ
                          _max_games=100,  # _resident_mode=Falseの場合何戦するかを指定
                          _my_name="RandomStay"
                          )

たったこれだけで「ランダムなアクションを選ぶ」プレイヤーが動作します。 え?これだけ?と思われるかもしれませんが、その秘密は4行目でインポートしている client_main にあります。client_main.main_loop関数を呼び出すだけで、WebSocketsを使った通信処理から、ロビー処理(対戦待ちの人がいたらその部屋に入ったり、だれも待っていなかったら自分が新しい部屋を作って対戦相手が来るのを待ったり)、ゲームの開始・終了・進行にまつわる処理まで、すべて実行してくれます。らくちんです。そして、client_main.main_loop関数は、ゲームのアクションを決定する必要が生じた時に、_place_stone_funcで指定した関数(このプログラムの場合は9行目のplace_stone関数)を呼び出してくれます。つまりplace_stone関数がこのプレイヤーの「思考ルーチン」となっています。

思考ルーチンに渡されるデータ

さて、思考ルーチンであるplace_stoneに渡される引数dicには、その時のボードの状態や、各種の情報がpythonの辞書形式で格納されています。この辞書の内容を以下に示します(11行目のpprint.pprintで表示した内容です)。

{'Board': [[0, 0, 0, 0, 0, 0],
           [0, 0, 0, 0, 0, 0],
           [0, 0, -1, 1, 0, 0],
           [0, 0, 1, -1, 0, 0],
           [0, 0, 0, 0, 0, 0],
           [0, 0, 0, 0, 0, 0]],
 'BoardSize': 6,
 'OnlyPass': False,
 'Placeable': [[0, 0, 0, 0, 0, 0],
               [0, 0, 1, 0, 0, 0],
               [0, 1, 0, 0, 0, 0],
               [0, 0, 0, 0, 1, 0],
               [0, 0, 0, 1, 0, 0],
               [0, 0, 0, 0, 0, 0]],
 'Playing': True,
 'RecType': 'YourTurn',
 'Timer': 60,
 'Turn': '1'}

Boardは「現在のボードの状態」を表しています。 BoardSizeはボードの大きさです。 OnlyPassは「パス」しか選べない(石を置ける場所がない)時にTrueが渡されます。 Placeableは「石を置ける場所」に1が入っている配列です。これは本来「現在のボード情報」から思考ルーチンが判断すべき内容とも言えるのですが、サーバー側のリバーシを管理しているプログラムが(親切で)用意してくれています。 Playingはゲーム進行中(ゲームオーバーになっていない間)はTrueが渡されます。 RecTypeには、手番の時だけ思考ルーチンが呼び出されるので、ここの値は常に'YourTurn’が入ります。 Timerは思考にかけられる時間の制限(単位は秒)。 Turnは現在のターン数が入ります。

これらの情報を元に、思考ルーチンは「どこへ石を打つか」を判断し、打ちたい場所の座標(y, x)を返します。

思考ルーチンの内容

まずは上記プログラムで、実際に「ランダムなアクション」を選択しているコード(12行目〜16行目)部分を軽く説明します。

  1. まずplaceable(空のリスト)を用意して(12行目)
  2. サーバー側が親切で用意してくれた「石が置ける場所」の配列 dic['Placeable'] をナメて(13行目〜14行目のfor文)
  3. 石が置ける場所のタプル(y,x)を、placeableリストに登録し
  4. 19行目のrandom.choiceでplaceableリスト中の1個をランダムに選択してその石の座標(y,x)を返します

より強い思考ルーチン

上記のような形で「打てる位置に適当に打つ」プログラムが動きますが、これ、ものすごく弱いです。もっと強いプログラムを作るには「適当に打つ」ではダメで「勝てそうな場所に打つ」ようなアルゴリズムを作っていく必要があります。これには大別すると

  • ルールベース
  • 機械学習

の二つの方法があります。ルールベースはプログラムで「こういう場合はここに打とう」と意思決定するやりかたで、プログラミングする人のアイデア(とゲームの実力)次第で、そこそこ強いプログラムになります。ルールベースならば、このランダムプレイヤーのコードに、if-thenみたいな条件分けをどんどん追加していくことで作れます。

もうひとつの機械学習によるボードゲームプレイヤーで現在の主流となっているのは、AlphaZeroに代表される深層強化学習&自己対戦学習方式です。モンテカルロ木探索とディープニューラルネットを組み合わせたアルゴリズムで、自己対戦を何千何万と繰り返すうちにだんだん「勝ち方」をおぼえて行くという、たいへん興味深い方式で、モリカトロンでもいろいろな実験を行い、CEDEC2019で発表しました。*1

こうした機械学習方式のプレイヤーを作る場合、一般的には

  1. 学習環境を整えて
  2. 学習をぐるぐる回し
  3. 完成したニューラルネットの重み(等)をファイルに保存し(ここまで事前処理)
  4. プレイヤーの思考ルーチンで3.を使ってアクションを選択する

といった方法でこれを実現します。

詳しく知りたい方は書籍*2やGithub*3をあたってみてください。

おわりに

AIネット対戦システムのクライアントプログラムの中身を紹介しました。ルームの管理やゲームの進行といった面倒な部分はすべてライブラリ化されているため、ゲームのクライアントプログラムには「与えられた盤面で打ち手を考える」という思考ルーチンだけを書けば良い作りになっており、たいへん素晴らしい構造であります(自画自賛)。現在「AIネット対戦システム」の一般公開はしておらず、モリカトロン社内のクローズドな開発サーバーにて実験運用している段階です。AIネット対戦システムに興味があるかたは、モリカトロンのホームページのコンタクトシート https://morikatron.com/#contact からお気軽にご連絡ください。