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

【単発DeepLearning本】技術書典12 頒布中

技術書典12は2022年1月22日から30日まで開催される技術系同人誌の頒布イベントです。新型コロナウイルスの影響によりオンライン開催ですので、お祭り感のある通販のようなものとお考え下さい。

当サークル「ヤマブキ計算所」は技術書典12に出展します。新刊「Deep Learning Code Golfやってみた」を頒布します。深層学習のモデル定義コードを無理やり短くする黒魔術を解説します。コンテンツとしては、ブログ記事シリーズを書籍の形に編集したものとなっております。電子書籍(PDF)は500円、物理本は600円です。物理本を購入した場合も、直ちに電子書籍をダウンロード可能です。本づくりの技術としては、本文は今まで通りre:viewを使っている一方、表紙は技術が向上しています。従来はWordで、表表紙と裏表紙を別々にB5サイズぴったりのPDFとして作っていたため紙の端まで色が塗られている表紙が実現できませんでした。2021年に入ってからPhotoshopを習得したので、表表紙~背表紙~裏表紙を1枚の画像として塗り足しつきで作ることができるようになりました。さらにイラスト作成ソフトのCLIP STUDIO PAINTと、イラストの描き方自体も新規習得したので、新規でデザインしたオリジナルキャラクターのイラストを中央に描きました。イラスト付きの表紙は一度やってみたかったので、実現できて嬉しいです。

以下のリンクから購入ページに飛べます。

techbookfest.org

f:id:select766:20220121220612j:plain
表紙
f:id:select766:20220121220623p:plain
目次
f:id:select766:20220121220630p:plain
本文サンプル1
f:id:select766:20220121220638p:plain
本文サンプル2

新刊は単発のDeep Learning関係の本ですが、既存のポケモンバトルAI本も同様に購入いただけます。

techbookfest.org

当サークル以外にも面白い技術書がたくさんあると思うので、ぜひ覗いてみてください。

iPadのNeural Engineで将棋AI part01 構想

最近のiPhoneiPadには、Neural Engineという機械学習専用のコアが搭載されています。 これを使ったら、モバイル端末でどの程度強いディープラーニングベースの将棋AIが作れるのか検証してみたくなりました。

というわけで、まだアイデア段階なのですが連載していきたいと思います。私はいつも実験結果をためて長大な記事を書きがちなのですが、短くして速報性を上げたいと思っています。メモ書きみたいな記事も出るかもしれませんがご了承ください。

目標

2022年5月3日の、世界コンピュータ将棋選手権に、iPadで動作する将棋AIで出場すること。特に、ディープラーニングベースの評価関数をできるだけ高速に動作させる手段を検討し、現代のモバイル端末上で実現可能な棋力を検証すること。

実装手段など

  • ハードウェア
    • iPad 第9世代
    • 64ビットアーキテクチャ搭載A13 Bionicチップ・Neural Engine
    • iPadOS 15
    • 冷却手段も考慮がいるかもしれません。
    • 2021年12月に、お絵かき用に買ったものを転用します。
  • 開発言語
    • Swift
    • 標準ライブラリ以外原則使わず、スクラッチで書きます。
    • 強さを愚直に求めるならC++で書かれたdlshogiをどうにかしてiPad向けにビルドする手段を探るべきかと思います。しかし私はApple系開発環境の初心者なので、中身を理解していないC++コードを含んだアプリをビルド・デバッグするのに困難が予想されること、今まで将棋AI開発でほぼ使われていないであろうSwiftでどうやったら将棋AIを書けるかということに興味があることから、Swiftでの開発を目指します。
    • 一応Swiftでの合法手生成ライブラリはあります(jiro/SwiftShogi)が、中身を見ないで自分で考えてみます。
  • 思考部
    • MCTSベースのシンプルなものを書きます。
  • 通信部
    • ソフト以前に、選手権で使う有線LANにiPadを接続する手段がいるので、今後準備します。
    • 選手権ではCSAプロトコルが使われるので、iPad単体で参戦するには(将棋所がないので)CSAプロトコルを自前で喋れる必要があります。CSAプロトコルの通信は書いたことがないので手間がかかりそうです。
    • 開発初期段階では、UIがないというのもありMacの将棋所に仲介してもらってUSIプロトコルを喋るものを作ろうかと思っています。
  • UI
    • iPad単体で参戦するには、ルール上局面表示や相手の指し手の手入力ができるUIが必要です。思考部が確立したら頑張って作ります。

新規性

  • モバイル向けOS(iPadOS/iOS/Android)での世界コンピュータ将棋選手権への出場は史上初(過去の参加者リストで確認)。
    • Swift言語も同様に史上初。
    • WindowsタブレットはWCSC26(2016年)の「HoneyWaffle」で前例あり。
    • 電竜戦では、第2回世界将棋AI電竜戦本戦にて「チームシャイニー」でAndroidスマホの使用例あり。人間との合議。アピール文書がなく、言語、機種は不明。
    • 大会を除けば、App StoreでCPU戦が可能な将棋アプリが複数配布されているため、Swift製の将棋ソフト自体は存在すると思われます。深層学習の使用有無については判断できません。
  • NVIDIA以外のGPU使用も多分史上初。

評価関数

書籍「強い将棋ソフトの創りかた」のサンプルコードとして提供されているモデルを用います。正確には、提供された棋譜と学習プログラムを、私が契約したGoogle Colab Pro上で実行してモデルを得ました。著者の山岡さんに確認しましたが、このようにして得たモデルは自由に使用していいそうです。

モデルファイルmodel_resnet10_swish-072のSHA1SUM

  • サンプルとして配布されているファイル: 74cba7cb7f29937498cebeee06a87da21d51346c
  • 自前の追実験で得たモデル: 5cf25d7687006974967d62800e838cc8c99b6e65

次は、評価関数をiPad上で動作させるところから進めようと思います。

Deep Learning Code Golfやってみた part03 Tensorflowへの移植

前回(PyTorch)で終わりのつもりだったのですがちょっとだけ続編です。

PyTorchと並び著名な深層学習フレームワークとして、Tensorflowがあります。私はPyTorchを使うことがほとんどですが、TensorflowでもDLCGを行うとどんな違いがあるか検証しました。

結論から言えば、PyTorchを利用する場合と解答となるモデルそのものは変わりませんでした。畳み込み層のほうが全結合層より短く書けるというような変化はないようです。

環境構築

環境構築はPyTorchより簡単です。学習ループを自前で書いたりライブラリを入れなくてもmodel.fit()を呼ぶだけで自動的にループを回してくれます。そのため、コードの長さの計測機能を除けば、以下のコードだけで学習・評価ができます。データオーグメンテーションがないなど、PyTorchと若干違うため同じモデル構造でも正解率に若干の差があります。

import tensorflow as tf
tf.random.set_seed(1) # 乱数固定
# データセット読み込み
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.cifar10.load_data()
x_train, x_test = x_train / 255.0, x_test / 255.0
# モデル構築
model = m() # プレイヤーが定義するモデル
# 学習設定
loss_fn = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
model.compile(optimizer='adam',
              loss=loss_fn,
              metrics=['accuracy'])
model.fit(x_train, y_train, epochs=5, batch_size=256)
# 正解率の評価
test_loss, test_acc = model.evaluate(x_test, y_test)

PyTorchとの相違点

実際のコードを示しながら、PyTorchとの違いを解説します。

最初に、Tensorflowでの通常のモデル定義コードを示します。SequentialFlattenはPyTorchとほぼ同じです。ただし、Sequentialは可変長引数ではなくレイヤーのリストを引数にとります。PyTorchでのLinearDenseに変わり、入力チャンネル数は不要となります。すなわちDenseはPyTorchのLazyLinear相当です。もちろん、ResNetのような分岐があるモデルを定義するための、クラスを用いたモデル定義方法もありますが明らかにコード量が増大します(あとで触れます)。

画像データのデータ配置が、PyTorchでは[バッチサイズ, チャンネル, 幅, 高さ]なのに対し、Tensorflowではデフォルトでは[バッチサイズ, 幅, 高さ, チャンネル]となっています。この違いは学習済みモデルを移植したりReshapeを行ったりする場合に影響が出るものの、本稿の範囲では影響ありませんでした。

import tensorflow as tf
def m():
    return tf.keras.models.Sequential([
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(128, activation='relu'),
        tf.keras.layers.Dense(10) 
    ])

正解率を問わない最短のコード(38.08%, 85文字)を示します。PyTorchではSequentialFlattenは同じモジュール(torch.nn)からインポートできたのですが、Tensorflowでは、tf.keras.models.Sequentialtf.keras.layers.Flattenというように別々のモジュールからインポートする必要があります。ここで、tf.keras.models.Sequentialtf.keras.Sequentialとしてもインポートできるため、from tensorflow.keras import*Sequentialをインポートしつつ、同時にlayerstf.keras.layersがインポートされるようにするのが最短と考えれます。インポートの煩雑さが影響して、コードの長さはPyTorchの67文字から85文字に増加してしまいました。

from tensorflow.keras import*
L=layers
m=lambda:Sequential([L.Flatten(),L.Dense(10)])

次に、複数の全結合層を利用するコード(43.41%, 103文字)を示します。Tensorflowでの活性化関数は、Denseへのパラメータ引数を用いてDense(99,activation='elu')と記述することが可能ですが、それよりも単体のELUクラスを用いるほうが短くなります。

from tensorflow.keras import*
L=layers
D=L.Dense
m=lambda:Sequential([L.Flatten(),D(99),L.ELU(),D(10)])

畳み込みを用いたコードを示します。順に51.93%, 108文字、60.22%, 110文字です。畳み込みはConv2Dで、引数はConv2D(filters, kernel_size, strides=(1,1), ...)です。入力チャンネル数は不要です。データオーグメンテーションの違い等により(過学習気味)、チャンネル数99では正解率60%に達しなかったためチャンネル数が3桁になっています。

from tensorflow.keras import*
L=layers
m=lambda:Sequential([L.Conv2D(9,3),L.Flatten(),L.ReLU(),L.Dense(10)])
from tensorflow.keras import*
L=layers
m=lambda:Sequential([L.Conv2D(512,3),L.Flatten(),L.ReLU(),L.Dense(10)])

最後に、ループを導入した深いモデル(70.26%, 148文字)を示します。PyTorchと同様、出力チャンネル数が10より大きくてもエラーになりません。チャンネル数68は、正解率70%以上となるようパラメータの探索を行った結果です。Sequentialの引数は可変長引数ではなくリストなので、リストの結合を用いて最後のFlattenを付加しています。

from tensorflow.keras import*
L=layers
S=Sequential
m=lambda:S([S([L.Conv2D(68,3,i),L.BatchNormalization(),L.ReLU()])for i in[1,2]*3]+[L.Flatten()])

Residual構造を記述する

PyTorchで書かれたConvMixerの解説で登場した、Residual構造(y=f(x)+x)をTensorflowでも短く書いてみます。Tensorflowで分岐があるモデルを記述する方法は2つあり、Functional APIによる方法と、サブクラス化による方法と呼ばれます。

まず、Functional APIでのモデル記述例を示します。

from tensorflow.keras import *
from tensorflow.keras.layers import *
def m():
    i=Input([32,32,3])
    j=Flatten()(i)
    k=Dense(128)(j)
    l=ReLU()(k)
    m=Dense(10)(l)
    return Model(i,m)

Functional APIでは、モデルの入力を表す変数をInputで生成し、これをレイヤーオブジェクトに渡してその出力を表す変数を順次生成していくことでモデルの構造を表します。最後に、モデル全体の入出力となる変数をModel関数に渡すことで、モデルが完成します。変数は複数回使用できるので、分岐を表現することができます。l=ReLU(k)+kと記述すれば、非常にシンプルにResidual構造が表現できます。Residualレイヤーを定義するのではなく、モデル定義文の中で2回同じ変数を使うことで表現します。レイヤー同士のつながりを表す変数を明示的に扱えることがシンプルさの要因です。ただし、変数を明示的に代入して取り回さないといけないため代入文が必須で、リスト内包表記(=式)で深いモデルを表現することが難しくなります。DLCG的には使いづらいでしょう。

最後に最も柔軟性の高いモデル定義方法であるサブクラス化による方法を示します。Residualレイヤーの記述例を示します。PyTorchのときと考え方は同じで、Sequentialクラスを継承し、レイヤー実行時に呼び出されるcallメソッドをオーバーライドしてResidual構造を実装します。PyTorchにおけるforwardcallに、self[0]self.layers[0]に置換した形となります。

from tensorflow.keras import *
from tensorflow.keras.layers import *
class Residual(Sequential):
    def call(self, inputs):
        return self.layers[0](inputs) + inputs

# 使用例
r = Residual([ReLU()])
x = tf.constant([1.0, -1.0])
r(x)
# => [ 2., -1.]

DLCGらしく、このコードを短く記述した結果を示します。

from tensorflow.keras import *
from tensorflow.keras.layers import *
R=type('x',(Sequential,),{'call':lambda s,x,**d:s.layers[0](x)+x})

type関数を利用するのはPyTorchと同じなのですが、Tensorflowでは余分な記述が必要でした。第1引数'x'ですが、空文字列にすると内部のコードでエラーが発生します。クラス名の1文字目が'_'かどうかで挙動を変える機構があり、1文字目がないためIndexErrorが生じます。ラムダ式の引数には、キーワード引数を受け取る**dが必要でした。モデルの実行が学習中か推論中かを示すtrainingキーワード引数(Dropout等の挙動が変わる)が渡されるため、これを受け取らないとエラーになります。しかし、素直にclass構文を用いた定義ではキーワード引数を定義しなくてもエラーになりません。この理由は、Tensorflow内部の実装がメソッドが受け取る引数を列挙し、定義に合わせて適切な引数を渡すように実装されているためです。しかしながらラムダ式でメソッドを定義することが想定されていないのか、判定が間違ってしまい{'call':lambda s,x:s.layers[0](x)+xという定義に対してtrainingキーワード引数を与えてしまいエラーになっています。

以上のように、TensorflowでもPyTorchとほぼ同じようにDLCGを遊ぶことができることがわかりました。"tensorflow.keras"が"torch.nn"より長いことや、気を利かせて挙動を変える機構の誤判定等で、PyTorchよりコードが長くなってしまう傾向があることがわかりました。

Deep Learning Code Golfやってみた part02 コード解説

前回の続きです。本稿では、私が実際にDLCGに取り組んで記述したコードと、そこで用いたテクニックを解説します。

ベースライン

まずはコードを短くするテクニックを用いる前の、最も基本となるコードを示します。

from torch.nn import *
def m():
    return Sequential(Flatten(),Linear(32*32*3,32),ReLU(),Linear(32,10))

これは、全結合層2層からなるモデルです。 本稿のルールでは、コード中で関数mを定義し、これを実行すると、ニューラルネットワークの実行順序を定めるモデル構造及び学習されるパラメータを包含するモジュールオブジェクト(torch.nn.Moduleのサブクラス)が生成されるように実装する必要があります。 PyTorchでモジュールオブジェクトを生成するには、torch.nn以下に定義されているクラスを使います。*でインポートを行うと何がインポートされたのか分かりづらいため通常の開発ではあまり用いませんが、毎回torch.nn.Sequentialのような記述をすると長くなるため、基本テクニックとして使用します。本稿の範囲では、これ以外のインポートは行いません。 Sequentialは、引数で指定したレイヤー(本稿ではモジュールオブジェクトとほぼ同じ意味で使います)全てを包含し、先頭から順に実行するようなレイヤーです。枝分かれのないモデルを定義するのに有用です。枝分かれがある場合も含めた一般的なモデル定義の方法は以下のようになりますが、明らかにコードが長くなります。

from torch.nn import *
class M(Module):
    def __init__(self):
        super().__init__()
        self.conv1 = Conv2d(3, 32, 3)
        self.relu1 = ReLU()
        self.conv2 = Conv2d(32, 64, 3)
    def forward(self, x):
        h = self.conv1(x)
        h = self.relu1(h)
        return self.conv2(h)
def m():
    return M()

モデルへの入力テンソルは、32×32ピクセルのRGB画像なので[バッチサイズ, 3, 32, 32]となっています。Linear(全結合層)レイヤーは2次元のテンソルしか受け付けないため、Flattenを用いて形状を[batch_size, 3*32*32]に変換します。Linearは、Linear(入力チャンネル数,出力チャンネル数)という引数を取ります。ここでは32×32×3(=3072)チャンネル入力、32チャンネル出力のレイヤーを生成します。次に活性化関数としてReLUを挿入します。PyTorchでは、活性化関数はLinearに含まれていませんので別途挿入の必要があります。最後に、32チャンネル入力、(10クラス分類なので)10チャンネル出力のLinearを挿入しています。

正解率30%以上: 最短のコード

まずはできるだけモデルを単純化し、正解率を問わない、最短と思われるコード(43.41%, 104文字)を示します。

from torch.nn import* 
m=lambda:Sequential(Flatten(),LazyLinear(10))

モデルの構造は、全結合1層のみの線形モデルです。学習させてみたところ、正解率32.65%で文字数67でした。まず、import *のスペースは不要で、import*とすることが可能です。これで1文字削減できます。改行を;に置換可能な場合がありますが、改行文字を1文字として数えているため効果はありません。ルール上、モジュールオブジェクトを返す関数mを定義する必要がありますが、これを関数定義文def m():\n xxxとする代わりにラムダ式を使用してm=lambda:xxxとすることができます。1文字の削減となります。ただし、ラムダ「式」のため、関数内で代入文を使えなくなります。そして、Linear(3072,10)の代わりにLazyLinear(10)を用います。LazyLinearは入力チャンネル数の記載を省略可能とする、実験的機能です。文字数としては、3027,の5文字が減ってLazyの4文字が増えます。合計で1文字の削減となります。もし入力チャンネル数が3桁(999以下)ならこれで削減することはできないということになります。なお、Flattenは省略するとLinearに4次元のテンソルが入力されて実行時エラーとなりますので削減できません。何か抜け道がないか、気になるところです。

正解率40%以上: 複数レイヤーの導入

次は、正解率40%以上の部門で最短のコード(44.43%, 83文字)を示します。

from torch.nn import* 
L=LazyLinear 
m=lambda:Sequential(Flatten(),L(99),ELU(),L(10))

線形モデルでは流石に精度が低いため、2層以上のモデルが必要となります。中間層99チャンネルの2層モデルで40%以上を達成することができました。LazyLinearを2回使いたいので、変数Lに代入します。LazyLinearLazyLinearの20文字の代わりに、L=LazyLinear\nLLの15文字となり5文字の削減となります。2つの全結合層の間には非線形の活性化関数が必要です。もし活性化関数がないと、2連続の行列積となりますが、これは1つの行列積と等価となり線形モデルと変わりません。深層学習が最初に流行した際の活性化関数はReLU(ReLU(x)=max(x,0))でしたが、さまざまな亜種が提案されています。そのうちPyTorchで文字数最短はELUです。これは、ELU(x)=x\; (\mathrm{if}\; x > 0),\; \exp(x)-1 (\mathrm{otherwise})で表されます。中間層のチャンネル数が99なのは、2文字でできるだけ大きなパラメータ数を得るためです。普通は32、256など2の冪乗を使いますが、DLCG特有の制約で珍しい数値が出現しています。

正解率50%以上: 畳み込みの導入

正解率50%以上の部門で最短のコード(52.76%, 88文字)を示します。

from torch.nn import*
m=lambda:Sequential(Conv2d(3,9,3),ReLU(),Flatten(),LazyLinear(10))

線形モデル2層では実現が困難なため、ここで畳み込み層を導入しました。画像に対する畳み込みはConv2dクラスで実現されます。引数は、Conv2d(入力チャンネル,出力チャンネル,カーネル幅,ストライド=1,パディング=0,...)となります。ストライド以降の引数は省略可能です。正解率50%以上という条件であれば、畳み込み層の出力チャンネル数は9チャンネルで実現可能でした。60%以上とするにはチャンネル数2桁が必要でした。その場合のコード例(60.78%, 89文字)を示します。

from torch.nn import*
m=lambda:Sequential(Conv2d(3,99,3),ReLU(),Flatten(),LazyLinear(10))

1文字の増加で精度が8ポイント改善するというのもコードゴルフ的には奇妙な感じですが、ハイパーパラメータの性質上仕方ありません。活性化関数ではELUが最短ではあるのですが、正解率が低いという問題があり、ReLUを使用しています。ELUでは入力値が0付近の場合にほぼ恒等関数となるため、長い学習時間をとって非線形性が出るような領域まで使われるようにする必要があるのかもしれません。

正解率70%以上: ループの導入

最後に、正解率70%以上の部門です。コード(74.69%, 119文字)を示します。

from torch.nn import*
S=Sequential
m=lambda:S(*[S(LazyConv2d(99,3,i),BatchNorm2d(99),ReLU())for i in[1,2]*3],Flatten())

2層のモデルでは不十分なため、3層以上のモデルをできるだけ短いコードで定義します。文法が複雑になっているため、1つずつ解きほぐしていきます。 まず、Sequentialが2回使われるため、変数に代入しています。[f(i) for i in X]という構文ですが、リスト内包表記です。Xにはリスト等の列挙可能なオブジェクトを与えます。例えば、[f(i) for i in [1,3,5]]は、[f(1), f(3), f(5)]という記述と等価です。関数fに対して異なる引数を与えて得た結果を、リストとして取得することができます。[1,2]*3は、リストに対する掛け算で、繰り返しを意味します。すなわち、[1,2]*3==[1,2,1,2,1,2]です。リスト内包表記では、通常forの前、inの後ろにはスペースが必要ですが、変数名として使えない文字の場合はスペースを省略してもエラーとなりません。これにより、...)for i in[...というようにスペースを2文字分削減できます。次に、リストの前の*ですが、リストの要素を関数の引数として展開する役割です。例えば、f(*[1,3,5])f(1,3,5)と等価となります。Sequentialは、可変長の引数としてレイヤーを受け取るため、この構文を使用してリスト内包表記で動的生成したレイヤーの列を与えます。これらの文法を展開した結果は以下のようになります。

from torch.nn import*
def m():
    return S(
        S(LazyConv2d(99,3,1),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,2),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,1),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,2),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,1),BatchNorm2d(99),ReLU()),
        S(LazyConv2d(99,3,2),BatchNorm2d(99),ReLU()),
        Flatten()
    )

結局のところ、畳み込み6層のモデルということになります。

次に、深層学習のモデルとしてのテクニックを解説します。ここでは新たに2つのクラスLazyConv2dBatchNorm2dが登場しています。BatchNorm2dはバッチ正規化層で、入力の各チャンネルについて、ミニバッチ内の値の平均を0、分散を1となるようアフィン変換することで学習を促進するものです。本稿のルールではこれがないと学習イテレーション数に対する正解率の向上速度が低く、所定イテレーション数内に正解率70%を達成できませんでした。文字数が長いですが採用せざるを得ません。チャンネル数指定不要のLazyBatchNorm2dもありますが、このコードでは文字数削減に寄与しません。LazyConv2dは、入力チャンネル数を省略できる畳み込み層です。畳み込み層の入力チャンネル数は3桁以下なので、Lazyよりもチャンネル数を明示的に記述して999,とするほうが短いのですが、入力チャンネル数が最初の層では3なのに対し他の層では99となり、条件分岐が必要となります。ループ変数jが仮に0,1,2,3...となる場合、[3,99][j>0]のように場合分けを短く記述することはできなくはないですが、Lazyを用いる方がより短いです。 最後にLazyConv2dの引数ですが、LazyConv2d(出力チャンネル,カーネル幅,ストライド)となります。ストライド指定の別の実装方法として、j%2+1 for j in range(6)も考えられますが、j for j in[1,2]*3の方が短いです。文法上の定義とは別に、ストライドが1と2に交互に設定されている点が重要です。ストライドは、出力のピクセルが1つ移動するたびに入力に対する畳み込みの窓の移動距離を表すものです。畳み込み層の画像の出力サイズの計算式は(入力ピクセル幅 + 2 \times パディング - カーネル幅)/ストライド+1のようになります。これにより、画像サイズが32→30→14→12→5→3→1と変化し、ちょうどピクセル数が1になるところがポイントです。最後の畳み込み層の出力テンソルの形状は[バッチサイズ, 99, 1, 1]となります。これをFlattenに入力すると、[バッチサイズ, 99]の出力が得られます。出力は10クラス分類なので[バッチサイズ, 10]となるのが正しいのですが、過剰なチャンネルがあってもエラーなく動きます。11個目以降のクラスは学習データに出現しないので、負の無限大を出力するように学習が進んでいくと考えられます。逆にクラスが10個あるのに9チャンネル以下の出力を与えると実行時エラーになりますので、精度不問のコードで10チャンネルの出力のところを9チャンネルに減らしてコードを短くすることには使えません。画像サイズが1になることも重要で、もしサイズが2となって[バッチサイズ, 99, 2, 2]という出力となると、これをFlattenに与えた結果が[バッチサイズ, 396]となります。チャンネル数が過剰という点だけでは画像サイズが1ピクセルの場合と変わりませんが、[バッチサイズ, 0, 0, 0]がクラス0に対応、[バッチサイズ, 0, 0, 1]がクラス1に対応ということになります。しかしこれはチャンネル方向ではなく空間方向の別ピクセルが別のクラスに対応するという正しくない構造となり、うまく学習できません。画像サイズをうまく調節することにより、最後の全結合層を省略することができています。また、通常のモデルでは最後の畳み込み層や全結合層には活性化関数を用いませんが、リスト内包表記の都合で最終層だけ場合分けすることができません。これでも学習が進むので、DLCG特有のテクニックとして使うことができます。 簡潔さの裏に、深層学習初心者には決してお勧めできない黒魔術が入っておりますのでご注意ください。

なお、チャンネル数を2桁の99より増加させると、以下のコード(75.16%, 121文字)のように若干正解率が向上します。

from torch.nn import* 
S=Sequential 
m=lambda:S(*[S(LazyConv2d(256,3,i),BatchNorm2d(256),ReLU())for i in[1,2]*3],Flatten())

一方、他のモデル構造も検討しましたが、今回のルールで正解率80%以上を実現するモデルは残念ながら見つかりませんでした。分岐があるモデル構造としてResidual構造などを試しましたが、エポック数を5に固定した状態では正解率が高くなりませんでした。数十分かけて学習を進めれば本稿で掲載したモデルでも80%以上の正解率は得られますし、より複雑なモデルで90%以上の正解率を目指すことも可能ではあります。しかし細かい数値を変更してコードを短くするゲームとして遊ぶには計算時間がかかりすぎますので、ここまでとしました。私が開発したコードは以上となります。深層学習という課題上、ライブラリに定義されたクラス名の記述が避けられず、また大量のクラスを複雑に組み合わせることが最適解とはならなかったため、コードを見れば何をしているかはわかりやすい解答となりました。DLCGに興味を持ってくださった方が、従来のコードゴルフのような、コードを見ても何をやってるかわからないような奇怪なコードが最適解となるような面白い課題設定を開発してくれることを期待しております。

ConvMixerのテクニック

今回私が定義したルールに対してはモデルが複雑すぎて活用できませんでしたが、DLCGのアイデア元となったConvMixerのコードを短くすることに使われたテクニックについて、私なりに解釈して解説します。

def ConvMixr(h,d,k,p,n):
 S,C,A=Sequential,Conv2d,lambda x:S(x,GELU(),BatchNorm2d(h))
 R=type('',(S,),{'forward':lambda s,x:s[0](x)+x})
 return S(A(C(3,h,p,p)),*[S(R(A(C(h,h,k,groups=h,padding=k//2))),A(C(h,h,1))) for i in range(d)],AdaptiveAvgPool2d((1,1)),Flatten(),Linear(h,n))

A=lambda x:S(x,GELU(),BatchNorm2d(h))は、ラムダ式定義です。引数xには例えばConv2d()が入ります。S=Sequentialなので、xに続いて、GELU()BatchNorm2d()が実行されるようなモジュールオブジェクトが生成されることになります。つまり、畳み込みなどの処理に活性化関数を追加する作用を持ちます。

次の行は見慣れない構文が用いられています。R=type('',(S,),{'forward':lambda s,x:s[0](x)+x})は、Residual構造を作るためのクラス定義となります。組み込み関数typeは、引数が1つのときと3つ以上のときで全く役割が異なります。今回は、type(name, bases, dict, **kwds)という引数になり、クラス定義を意味します。nameはクラス名ですが空文字列でもエラーになりません。basesは、基底クラスをタプルで指定します。ここでは、S=Sequentialが指定されています。dictは、クラスのメソッドを定義します。ここでは、forwardメソッドをラムダ式の形で与えています。lambda s,x:s[0](x)+xを解説します。引数sは、普通selfと書かれるもので、クラスのインスタンスを指します。xが通常の引数で、テンソルを受け取ります。テンソルxをあるレイヤーに与えた出力s[0](x)と、元々のxを足したものを返すことでResidual構造を実現します。さらに、s[0]を解説します。Sequentialクラスは、コンストラクタの引数を、配列のインデックス指定のように取り出すことができます。例えば、m=Sequential(Conv2d(),ReLU())と定義したとき、m[0]==Conv2d()m[1]==ReLU()のようになります。クラスRSequentialを継承したものであり、コンストラクタは上書きしていないため、R(Conv2d())というコンストラクタ呼び出しに対して、そのメソッド内ではself[0]Conv2d()を取り出すことができます。結局、forward(x)メソッドでは、クラスRのコンストラクタに与えたレイヤーオブジェクトにxを与えて呼び出した結果と、xを足した結果を返すという処理が行われることになります。

最後の行は前の章で解説したテクニックと同じで、リスト内包表記による深いモデルの定義が行われています。なお、) for i in range(d)]は、)for i in[0]*d]と短縮できます。また、AdaptiveAvgPool2d((1,1))AdaptiveAvgPool2d(1)に短縮できます。

私はコードゴルフのために、通常あり得ない構造のモデルを定義しました。一方でこの論文のように、あくまで実用性のあるモデルを短く書き換えるという遊びも考えられますので、気が向いたら考えてみてはいかがでしょうか。

Deep Learning Code Golfやってみた part01 イントロダクション

Deep Learning Code Golfとは

Deep Learning Code Golfは、私(select766)が作成した言葉で、「深層学習のモデル定義をできるだけ短いソースコードで表現する」というゲームです。 このゲームを考えたきっかけは、深層学習の新しいモデル構造ConvMixerを提案する論文*1で、手法の簡潔さを主張するためのおまけとして280文字以下で定義できるという参考コード

def ConvMixr(h,d,k,p,n):
 S,C,A=Sequential,Conv2d,lambda x:S(x,GELU(),BatchNorm2d(h))
 R=type('',(S,),{'forward':lambda s,x:s[0](x)+x})
 return S(A(C(3,h,p,p)),*[S(R(A(C(h,h,k,groups=h,padding=k//2))),A(C(h,h,1))) for i in range(d)],AdaptiveAvgPool2d((1,1)),Flatten(),Linear(h,n))

Twitter上で話題となっていたためです。論文自体はコードの長さ自体を短くすることを目標としているわけではありませんが、私はコードを短くすること自体をゲームにしたらどうなるか興味を持ったので考察することにしました。 Deep Learning Code Golf(以下DLCG)の元となったのは、以前より知られているCode Golf(コードゴルフ)というゲームです。ショートコードとも呼ばれます。Code Golfは、特定の課題を解くプログラムを、できるだけ短いソースコードで表現するゲームです。コードゴルフという名称は、スポーツのゴルフのように短い打数(文字数)でゴールすることからつけられたようです。コードゴルフで解く課題の例として有名なのは、FizzBuzz問題です。FizzBuzz問題とは、「1から100までの数を出力せよ。ただし、3の倍数の場合はFizz、5の倍数の場合はBuzz、3の倍数かつ5の倍数の場合はFizzBuzzと出力せよ」という問題です。この問題をPython言語で普通に解くコードは、

for i in range(1, 101):
    if i % 3 == 0 and i % 5 == 0:
        print("FizzBuzz")
    elif i % 3 == 0:
        print("Fizz")
    elif i % 5 == 0:
        print("Buzz")
    else:
        print(i)

のようになります。もちろん通常のプログラミングではこれで正解なのですが、Code Golfでは、このコードをできるだけ短くする(コード自体の文字数を少なくする)ことを目指します。例えばPythonではインデントが必須であるもののスペース1個で十分で、%などの演算子の前後のスペースも不要ですので、元のコードと実行結果を変えずに

for i in range(1,101):
 if i%3==0 and i%5==0:
  print("FizzBuzz")
 elif i%3==0:
  print("Fizz")
 elif i%5==0:
  print("Buzz")
 else:
  print(i)

のように書き換えることができます。これにより、コードが196文字から143文字に減少します。文法レベルの書き換えだけでなく、「3の倍数かつ5の倍数」を「15の倍数」と言い換えてロジックを組みなおせば

for i in range(1,101):
 if i%15==0:
  print("FizzBuzz")
 elif i%3==0:
  print("Fizz")
 elif i%5==0:
  print("Buzz")
 else:
  print(i)

のように少し短くなります。さらに多くのテクニックを用いると、

for i in range(100):print(i%3//2*"Fizz"+i%5//4*"Buzz"or-~i)

のようなコードになります*2。ここまでくると、理解困難なコードとなるのが分かるでしょう。通常のプログラミングで重視される、コードの理解しやすさや変更のしやすさ、場合によっては実行速度を無視して、コードが短いということそれ自体を追求するのがCode Golfの目的です。 コードゴルフプログラミング言語の仕様を最大限活用するゲームですので、言語によって使えるテクニックや実現できるコードの文字数が変わってきます。本稿では、Python言語(バージョン3系)を使用します。

従来のCode Golfのほとんどでは、入力が決まれば出力が一意に定まるような課題が扱われてきました。アルゴリズムが違ったとしても、結果自体が変化しないことが条件となります。しかし本稿で扱うDLCGでは、若干異なります。深層学習は機械学習の一種で、画像などを入力し、その中に含まれる文字や物体を認識するというモデルを生成することが課題です。分類正解率が100%であれば一意の出力となりますが、通常は100%にはならず、ある程度の誤りが含まれます。統計的に正解率が一定以上であればよいと考え、具体的に各入力画像に対してどのような出力をするかは一意に定まらないのが普通です。正解率を決める要因は課題自体の難しさのほか、モデルの構造や学習時の手法などに影響されます。モデルの構造に自由度を持たせることがDLCGのユニークな点で、「正解率**%以上のモデルを記述せよ」という課題になります。あまりに単純なモデルだと表現力が足りず正解率が低くなるという傾向がありますので、一定の正解率を担保しつつ、モデルの構造を簡略化したりコードレベルの実装方法を短くしたりすることに戦略性が生まれます。

ルール設計と実行環境

Deep Learning Code Golfがゲームとして成立するためのルールを考えました。

深層学習を用いて解くべき課題の設定

深層学習を用いて解く課題としては、画像を入力とし、10種類の物体のうちどれが写っているかを認識して分類する画像分類問題を扱うこととしました。 より具体的には、CIFAR-10データセットを用います。画像は32ピクセル四方のフルカラー(RGBの3チャンネル)で、分類すべき物体は10種類(飛行機、自動車、鳥…)です。学習用画像が5万枚、テスト用画像が1万枚あります。課題設定として重要なのは、要求されるモデルの複雑さと計算コストです。画像分類の入門課題としてよく用いられるMNISTデータセットは簡単すぎて、極めて単純な構造のモデルでも正解率99%が得られてしまい、差がつきません。逆に、実用に近い課題として用いられるImageNetデータセットは複雑すぎて、学習初期の段階では全く正解できず、学習を数時間進めないと差がつきません。解き方によって短時間で差がつくという基準をもとにデータセットを選定しました。 性質の異なる課題としては、ニュース記事(英単語の列)を入力して記事のジャンルのいずれに該当するかに分類するようなシーケンス処理問題なども考えられます。課題によって適切なモデルの構造が大きく異なるため、面白い課題を考えてみるのもよいでしょう。

次に、課題自体とは別に、ゲームとして成立させるための諸条件を考察します。

プレイヤーが記述するコードの範囲

深層学習を実行するには、モデルの定義だけでなく、学習データの前処理機構、オプティマイザなどいくつかの機構が必須となります。いずれの機構も正解率に影響を与えますし、様々な書き方が考えられます。ゲームとして注力すべき部分がブレないように、プレイヤーが記述すべき範囲と、課題として固定された機構を与える範囲を分ける必要があります。

コードの長さは、文字数で計測しました。アルファベットでも漢字でも1文字にカウントします。改行も1文字と数えます。コードの先頭・末尾の改行文字は無視します。

実行時間

書いたコードで生成されるモデルが所定の正解率を達成できるかどうか評価するには、実際に学習を行う必要があります。深層学習は計算コストが高く、高性能なGPUを搭載した計算機で何日もかかる場合もあります。計算時間がかかりすぎるとゲームとして遊べないため、数分以内に完了することが望ましいと考えられます。後述する実行環境において、合理的な複雑さのモデルが1分程度で学習できるようなデータセット及び学習ループ回数(イテレーション数)を目指しました。

これを満たす制約として、5エポック(学習用画像全部をモデルに投入して、モデルパラメータを更新するサイクルを5回行う)学習させるという条件としました。ただし、モデルの複雑さやパラメータ数自体に制約を設けていないので、極端に複雑なものを定義すればいくらでも計算時間が長くなり得ます。現実的には、表現力の高い複雑なモデルは短いエポック数ではあまり正解率が高くならないことが多く、有力な解答にはなりづらいと考えられます。

テスト用データに対する最適化

機械学習では本来、テスト用のデータは未知のデータであり、テスト用データでの正解率を最適化の指標として使うべきではありません。モデルの学習へフィードバックするための評価用データとテスト用データは分かれているのが正当です。しかしながらゲームとしてこれらの指標を明確に分離するのは難しいため、テスト用データでの正解率が高まるようにモデルのハイパーパラメータを最適化しても構わないものとします。

ルールのまとめ

結論として、以下のようなルールとしました。

  • 以下の条件設定で学習させた際に、テスト用データでの正解率X%以上となるコードを記述せよ。
    • X=10,20,...
  • プレイヤーが記述するコードの条件
    • 引数のない関数mを定義する。実行されるとPyTorchのモジュールオブジェクトを返す。
    • mの内部で必要なモジュールは、コード内でインポートする。
  • 学習条件
    • m()が返したモジュールオブジェクトに対し、評価システム側で学習用データによる学習を行なったのち、テスト用データにより正解率を計算し出力する。
    • データセット: CIFAR-10
    • データオーグメンテーション: 下記参照
    • オプティマイザ: Adam(lr=1e-3)
    • バッチサイズ: 256
    • エポック数: 5

データオーグメンテーション(学習用)

torchvision.transforms.Compose([
    torchvision.transforms.RandomCrop(32, padding=4),
    torchvision.transforms.RandomHorizontalFlip(),
    torchvision.transforms.ToTensor(),
    pl_bolts.transforms.dataset_normalizations.cifar10_normalization(),
])

データオーグメンテーション(テスト用)

torchvision.transforms.Compose([
    torchvision.transforms.ToTensor(),
    pl_bolts.transforms.dataset_normalizations.cifar10_normalization(),
])

実行環境

環境構築および起動に手間がかかるとゲームとして遊びにくいです。本稿では、深層学習向けに作られたクラウド上のインタラクティブPython実行環境であるGoogle Colabを活用することとしました。Pythonの標準ライブラリだけで深層学習を行うのは事実上不可能なので、深層学習ライブラリとしてPyTorchを用いました。Google Colabでは初期状態でインストール済みです。さらに、学習ループの記述を簡略化するためPyTorch LightningおよびLightning Boltsを用いました。ただしこれらのライブラリの使用によってプレイヤーの書くべきコードに変化はありません。モデル定義のコードを文字列として評価用の関数に与えると、その文字数と学習結果得られたモデルの正解率を算出するような環境を整備しました。なお、深層学習ライブラリとしてTensorflowを用いれば、クラス名の長さに違いがあるため異なるモデル構造が最適になる可能性があります。

再現性について

深層学習では、モデルパラメータを初期化する乱数や、モデルに与えるデータの順序などにより学習結果に違いが出ます。これらの数学的な違い以外にも、GPU上での並列計算の実行順序に伴うハードウェアレベルの非決定的な挙動があり、完全な再現は難しいのが実情です。本稿の実行環境では以下のコードにより乱数の固定を試みました。同じハードウェア・ソフトウェアのバージョンで複数回実行したところ同じ結果が得られるようでした。一方で環境が違う場合までは保証できないため、賞金が出るようなコンペを実施するには難しい点となります。

pytorch_lightning.seed_everything(1, True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
torch.use_deterministic_algorithms(True)

本稿執筆時点でのライブラリのバージョンを下表に示します。今後掲載するコード例では実験的機能も利用しているため、将来のバージョンでは同じコードが使えない可能性があります。

ライブラリ バージョン
Python 3.7.12
torch 1.10.0+cu111
torchvision 0.11.1+cu111
pytorch_lightning 1.5.1
pl_bolts (lightning-bolts) 0.4.0

実行環境のColab notebookは https://colab.research.google.com/drive/1KGZFdERNGVAofSCEBnEMfeRENOpWcCUA?usp=sharing にて配布しています。また、書いたコードを投稿できるサイトを試作しました。 https://dlcodegolf.web.app/ からアクセスできます。

次回は、このルールに沿って私が実装してみたコードを解説します。

2022-01-22修正: 「再現性について」で再現性をより向上するよう改良

*1:Patches Are All You Need? (執筆時点では査読中の論文で、匿名) https://openreview.net/forum?id=TVHS5Y4dNvM

*2:https://qiita.com/ymg_aq/items/b8e5d26035180bc8797e#python3-59-bytes

ゲーム木探索入門 part05 交互手番MCTSの実装【PokéAI】

前回は原始モンテカルロ法というもっとも単純な探索手法を試しました。原始モンテカルロ法では、ルート局面(行動を決定したい局面)でとれる行動それぞれについて、その行動をとった直後の局面からランダムにゲームを進めた際の勝率を比較して行動を決定します。この手法でもただランダムに行動を決定するよりはましですが、最初の行動以外すべてランダムであるため、大量の悪手を含んだゲーム進行の結果得られる勝率を元に行動を決定するため貧弱な手法といえます。勝率の推定を改善する手法として、モンテカルロ木探索(MCTS)があります。MCTSはもともと囲碁AIのために開発された手法です。囲碁ポケモンバトルの主な違いは、自分と相手が交互に行動を選択する、伏せられている情報がない、ランダム性がないという点です。よりポケモンバトルに近い性質のゲームへの応用も研究されており今後試しますが、今回は囲碁向けのMCTSアルゴリズムをできるだけ少ない変更でポケモンバトルに応用します。

アルゴリズム

MCTSの基本的な説明は囲碁関係の文献を参照してください(本にするときはポケモンバトルに即してちゃんと書きます…)。書籍だと囲碁ディープラーニングプログラミングがわかりやすいです。AlphaGoの文脈でMCTSが解説されることも多いですが、今回の実装に深層学習の要素はないので注意してください。 原始モンテカルロ法に引き続き、本来のポケモンバトルにはない仮定として、相手のパーティ構成がわかっている、ランダム性がないという条件でMCTSを実装します。 これらの仮定を置くことで囲碁にゲームの性質を近づけることができますが、囲碁では交互に行動を選択する一方で、ポケモンバトルでは同時に行動を選択するという違いについては何らかの対処をしないとMCTSを実装することができません。 この課題に対しては、自分が行動を選択し、次に相手が行動を選択するという近似を行います。シミュレータの状態と自分が選んだ行動の組み合わせを状態とみなし、この状態を入力として相手は行動を選ぶと考えます。

MCTS実装のコア部分を示します。完全なコードはこちら。 ポイントは、ノードが持つ状態としてシミュレータの状態のほかに、相手が選んだ行動を含ませる点です。相手が選んだ行動が入っているノードの子ノードは、相手と自分の行動の組み合わせでシミュレータを1ターン進めた状態を保持することになります。

// シミュレータの状態simに対し、プレイヤーsideidの行動を決定する
go(sim: Sim, sideid: SideID): string | null {
    const root = new MCTSNode(new PRNG(), sim, sideid, undefined);
    const playoutPolicy = new AIRandom2({ switchRatio: this.playoutSwitchRatio });

    // ルート局面から木構造をたどってプレイアウトを繰り返す
    for (let i = 0; i < this.playoutCount; i++) {
        let backupNodes: MCTSNode[] = [];
        let node = root;
        backupNodes.push(node);
        while ((!node.canAddChild()) && (!node.isTerminal)) {
            node = this.selectChild(node);
            backupNodes.push(node);
        }

        if (node.canAddChild()) {
            node = node.addRandomChild();
            backupNodes.push(node);
        }

        let winner: SideID;
        if (node.isTerminal) {
            winner = node.winnerIfTerminal!;
        } else {
            // プレイアウトする
            const copySim = node.gameState.clone();
            if (node.opponentMove !== undefined) {
                // 片側の行動だけ決まっている状態なので、もう片方を埋めて1ターン進める
                const leafChoice = playoutPolicy.go(copySim, node.sideid);
                const bothChoice = node.sideid === 'p1' ? [leafChoice, node.opponentMove] : [node.opponentMove, leafChoice];
                copySim.choose(bothChoice);
            }
            let { winner: playoutWinner } = playout(copySim, [playoutPolicy, playoutPolicy]);
            winner = playoutWinner || sideid;// 引き分けは珍しいので、とりあえずsideid側の勝ち扱い
        }

        for(let i = backupNodes.length-1; i >= 0; i--) {
            backupNodes[i].recordWin(winner);
        }
    }

    // ルート局面の子ノードで勝率最大のノードを選択
    let bestMove: string | null = null;
    let bestPct: number = -1.0;
    for (const [move, node] of root.children.entries()) {
        const pct = node.winningPct(sideid);
        if (pct > bestPct) {
            bestPct = pct;
            bestMove = move;
        }
    }

    return bestMove;
}

// MCTSのノードを表現するオブジェクト
class MCTSNode {
    winCounts: {[sideid in SideID]: number};
    numRollouts: number;
    children: Map<string|null, MCTSNode>;
    unvisitedMoves: (string|null)[];
    isTerminal: boolean;
    winnerIfTerminal?: SideID;
    constructor(public prng: any,
        public gameState: Sim, // シミュレータの状態
        public sideid: SideID, // このノードの手番プレイヤー
        // このシミュレータの状態に対し、すでに相手が行動を決定している場合その行動
        public opponentMove?: string | null
    ) {
        this.winCounts = {'p1': 0, 'p2': 0};
        this.numRollouts = 0;
        this.children = new Map();
        const isTerminal = this.gameState.getEnded();
        this.isTerminal = isTerminal;
        if (isTerminal) {
            this.winnerIfTerminal = this.gameState.getWinner();
            this.unvisitedMoves = [];
        } else {
            this.winnerIfTerminal = undefined;
            const choices = enumChoices(gameState.getRequest(sideid));
            if (choices.length > 0) {
                this.unvisitedMoves = choices.map((c) => c.key);
            } else {
                this.unvisitedMoves = [null]; // パスの1手だけを選択肢とみなす
            }
        }
        
    }

    // まだ子ノードを生成していない選択肢から1つランダムに選んで子ノードを作成
    addRandomChild(): MCTSNode {
        const unvisitedLength = this.unvisitedMoves.length;
        const index = unvisitedLength === 1 ? 0 : this.prng.next(unvisitedLength);
        const move = this.unvisitedMoves[index];
        this.unvisitedMoves.splice(index, 1);
        // moveを適用した後の状態を生成
        // シミュレータは決定論的に動作するという仮定
        let newNode: MCTSNode;
        const opponentSideId = invSideID(this.sideid);
        if (this.opponentMove !== undefined) {
            // 自分と相手の行動が揃ったので1ターン進める
            const copySim = this.gameState.clone();
            const bothChoice = this.sideid === 'p1' ? [move, this.opponentMove] : [this.opponentMove, move];
            copySim.choose(bothChoice); // シミュレータに行動を与えて進める
            newNode = new MCTSNode(this.prng, copySim, opponentSideId, undefined); // undefinedが相手の行動未定を表す
        } else {
            // シミュレータはまだ動かせない。相手の行動を決めるフェーズに進む
            newNode = new MCTSNode(this.prng, this.gameState, opponentSideId, move);
        }
        this.children.set(move, newNode);
        return newNode;
    }
}

今回のアルゴリズムにおける、自分が行動を選択し、次に相手が行動を選択するという近似には問題点があります。じゃんけんのようなゲームを考えるとわかりますが、相手の行動が決まっている状態のノードで自分の行動を決めると、相手の手に応じて都合のいい行動を選ぶ結果になり、行動を同時に選ぶ場合と比べ非対称な先手と後手が生じます。この問題を考慮したアルゴリズムを今後利用する予定です。

実験

MCTSと原始モンテカルロ法の強さを比較する実験を行います。

使用するパーティ等の条件は原始モンテカルロ法の時と同じです。

まずは、ランダム、原始モンテカルロ法とともに、MCTSのプレイアウト回数を変えて強さを比較しました。ランダムプレイアウト中に交代を選ぶ確率は10%です。MCTSの探索において未知の行動を探索するか既知の勝率の高い行動をより深く探索するかのバランスを決める温度パラメータは1.0としました。温度パラメータについては0.5, 1.0, 2.0で別途比較し、大きな差はありませんが1.0が最も強いという結果でした。比較結果を示します。

手法 プレイアウト回数 平均レート
ランダム - 1396
原始モンテカルロ法 100 1506
MCTS 30 1474
MCTS 100 1509
MCTS 300 1537
MCTS 1000 1579

計算時間はプレイアウト回数に比例します。また、原始モンテカルロ法MCTSで、プレイアウト回数が同じならほぼ同じ計算時間となります。MCTSにおいてプレイアウト回数を増やせば増やすほど強くなること、また同じプレイアウト回数では原始モンテカルロ法と同等以上の強さになることがわかりました。

次に、プレイアウト回数1000回での原始モンテカルロ法MCTSの比較を行いました。

手法 プレイアウト回数 平均レート
ランダム - 1424
原始モンテカルロ法 1000 1530
MCTS 1000 1546

やはりMCTSのほうが若干高いレートとなっています。

さらにプレイアウト回数を増加させたときに強くなるかどうか、MCTSのみで比較を行いました。

手法 プレイアウト回数 平均レート
MCTS 1000 1481
MCTS 3000 1519

プレイアウト回数3000回は、1000回の場合より強くなることがわかりました。計算に30時間を要したため、これ以上の回数を試すのは困難です。

今回は、ポケモンバトルを交互手番のゲームに近似してMCTSを実装しました。原始モンテカルロ法と比べ、同じプレイアウト回数(計算時間)でより強い可能性が高いことがわかりました。 木構造を探索する思考過程を可視化できないか検討しています。

ゲーム木探索入門 part04 原始モンテカルロ法【PokéAI】

ゲーム木探索を用いたポケモンバトルAIの最も単純なアルゴリズムとして、原始モンテカルロ法を試します。 前回から長らく期間が空いてしまいましたが、再開していきます。

アルゴリズム

ここでは原始モンテカルロ法(pure Monte Carlo search)を試します。

原始モンテカルロ法は、現在の局面からランダムに手を選んで1ターン局面を進め、そこからプレイアウトするという処理を何度も行います。そして、現在の局面で選べる手のうちで勝率が最も高かった選択肢を選ぶ手法です。プレイアウトとは、ランダムに選択肢を選んでゲームの決着がつくまで局面を進めることです。ポケモンでは相手も同時に手を選ぶことになりますが、相手の手はプレイアウトの一部として、ランダムに決めることにします。 なおこの手法の重要な注意点として、相手のパーティ構成がわかっているという仮定が入ります。探索中に、相手の行動をランダムに選んでバトルを進めて勝敗を得るというステップが入っているためです。探索過程で具体的な選択肢を列挙せずランダムに選ぶというブラックボックス状態であっても、例えば相手が一撃必殺技を持っているかどうかによって、こちらが積み技を使う選択肢と攻撃技で目の前の相手をすぐ倒そうとする選択肢の勝率が変わると考えられます。当面はこの仮定が入った状態のアルゴリズムを考えることとなります。

実際のコードからコア部分をコメント付きで掲載します。

export class AIMC extends AIBase {
    playoutCount: number; // 行動決定のためのプレイアウト回数
    // 局面を受け取り、最適な手を返す関数
    go(sim: Sim /* 現在局面を表すシミュレータ */, sideid: SideID /* 自分がプレイヤー1か2か */): string | null /* 最適な手 */ {
        const choices = this.enumChoices(sim.getRequest(sideid)); // 選択肢を列挙(技1, 技2, ...)
        const playoutPolicy = new AIRandom2(); // プレイアウト中の行動決定ポリシー(ここではランダム)
        if (choices.length > 0) {
            const playoutPerAction = Math.floor(this.playoutCount / choices.length); // 1つの選択肢に割り当てるプレイアウトの回数を、単純に全プレイアウト回数を選択肢の数で割って定める
            const winCounts = (new Array(choices.length)).fill(0); // 各選択肢を選んだあとの勝利回数を入れる配列
            for (let c = 0; c < choices.length; c++) { // 各選択肢ごとにループ
                for (let i = 0; i < playoutPerAction; i++) { // 選択肢1つに対する複数回のプレイアウト
                    const copySim = sim.clone(); // シミュレータをコピーし、実際の状態を動かさずに思考できるようにする
                    const opponentChoice = playoutPolicy.go(copySim, invSideID(sideid)); // 相手の行動をランダムに決める
                    const bothChoice = sideid === 'p1' ? [choices[c].key, opponentChoice] : [opponentChoice, choices[c].key];
                    copySim.choose(bothChoice); // 1ターン進める
                    const { winner } = playout(copySim, [playoutPolicy, playoutPolicy]); // プレイアウトして勝者を決める
                    if (winner === sideid) {
                        winCounts[c]++; // 自分が勝った回数を集計
                    }
                }
            }
            // 勝利回数最大の選択肢を、探索結果として返す
            const bestIdx = argsort(winCounts)[winCounts.length - 1];
            return choices[bestIdx].key;
        } else {
            return null;
        }
    }
}

実験

実験条件

プレイアウト回数(コード中playoutCount)を変えて強さを比較します。プレイアウト回数10, 30, 100, 300, 1000およびランダムに行動を選ぶアルゴリズムで合計6種類のポリシーを比較します。各ポリシーが10個のパーティを操作し、合計60プレイヤーでレーティングバトルを行います。各プレイヤーのレートをポリシーごとに平均してポリシーの強さとします。 プレイアウト中に使用する行動決定関数AIRandom2では、交代を選択する確率を10%に設定しました。 1プレイヤー当たり10回の対戦を行いました。

実験結果

実験結果を示します。

プレイアウト回数 平均レート
ランダム 1451
10 1415
30 1485
100 1507
300 1564
1000 1577

プレイアウト回数が増加するほど強くなることがわかりました。プレイアウト回数10の場合はランダムに行動するより弱いという結果になっています。この原因を考察します。ランダムの場合は交代確率を10%に設定しているのに対し、プレイアウトを行う場合は技を出す場合も交代する場合も選択肢として平等に扱っています。各選択肢を選んだ場合のプレイアウトの試行回数が少なすぎて選択肢の良し悪しがうまく推定できない(技の命中や相手の技選択に強く依存する)ため、効果の低い交代を選ぶ確率が上がっているのではないかと考えられます。

行動選択の考察

いくつかの実例をもとに、各行動の勝率がどのように見積もられたかを考察します。

下の例では、p2(プレイヤー2)がプレイアウト1000回の強いプレイヤーです(p1は10回)。技名の下および控えポケモン名の右の数値がプレイアウトにより得られた勝率を表します。この例では、おんがえしが勝率0.96で最も高く、探索結果として選ばれています。ばくれつパンチでも倒せるのは同じですが命中率が低いデメリットが勝率に反映されています。

f:id:select766:20210515180408p:plain
行動選択結果

下の例では、p1とp2両方がプレイアウト1000回のプレイヤーです。双方残りポケモン1体です。カビゴンのHPがほとんど減らず、ケンタロスのHPが減ることで勝率が変化していくことがわかります。ところで、ケンタロスのつのドリル(一撃必殺技)の最終ターンでの勝率が0.12となっています。命中率は30%なので勝率0.3になってほしいところですが、そうなっていません。これはシミュレータの乱数がバトル開始時に決定しており、同じ行動を選択すれば同じ結果になる仕様となっているためです。シミュレータの乱数の内部状態では、このターンでつのドリルを選択したら外れることになっているものと推測されます。この仕様もまた、技が当たるか当たらないかという乱数が間接的に探索アルゴリズムに漏れているということになります。

f:id:select766:20210515180914p:plain
行動選択結果

下の例では、p1とp2両方がプレイアウト1000回のプレイヤーです。双方ポケモン3体が生き残っている状態です。ブラッキーは体力満タンで無意味なねむるを選択してしまっています。バトルの決着がつくまでのターン数が多く、このターン以後の行動選択はすべてランダムであるためミスが生じる確率が高いので、ここで無駄な行動をしたとしてもあまり影響がないというのがこの選択の原因であると考えられます。

f:id:select766:20210515183438p:plain
行動選択結果

以上のように、バトル終盤であればある程度適切な行動選択ができていますが、序盤ではうまくいっていないということが確認できました。 次回は、より高度な探索手法を試そうと考えています。