目次と前回の記事
これまでに作成したモジュール
以下のリンクから、これまでに作成したモジュールを見ることができます。
これまでに作成した AI
これまでに作成した AI の アルゴリズム は以下の通りです。
関数名 | アルゴリズム |
---|---|
ai1 |
左上から順に空いているマスを探し、最初に見つかったマスに着手する |
ai2 |
ランダムなマスに着手する |
AI の強さの評価方法
本記事ではさまざまな 〇×ゲームの AI を作成しますが、その際に、作成した AI の 強さを評価 する必要があります。何らかの競技で強さを評価する 一般的な方法 として、複数回 の 対戦 を行い、勝率を比較 するという方法があります。本記事でも 作成した AI どうし で 複数回 の 対戦 を行うことで AI の 強さを評価 することにします。
対戦の回数
対戦の回数 が 1 回や数回のように 少ない 場合は、弱い AI が 強い AI に 偶然勝ち越す ような、実際の強さ とは 異なる結果 がでる 可能性 が 高く なりますが、対戦の 回数を増やせば 増やすほど、勝率 が 実際の AI の強さ を より正確 に 反映する ようになります。このような法則の事を 大数の法則 と呼びます。大数の法則の具体例として良く挙げられる例として、投げた時に 表と裏 がそれぞれ 50 % の確率で出る コインを何回か投げた時に、投げる 回数が少なければ 表が出る割合が 50% から大きく かけ離れる場合がある が、投げる 回数を多くすれば するほど表が出る割合が 50 % に近づいていく というものがあります。
参考までに大数の法則の Wikipedia へのリンクを下記に挙げておきます。
大数の法則 から、AI どうし の対戦の 回数を多くすれば するほど、計測された勝率 が、実際の AI どうしの 強さ を より正確に表す ようになりますが、対戦回数 が 一定以上多くなる と 精度 がほとんど 上がらなく なります。また、対戦回数 を 多くすれば するほど 対戦 に 時間がかかる ようになるので、本記事では AI どうしの 対戦 を 一万回 行うことにします。たたし、複雑な処理を行う AI のどうしの対戦などで、一万回の対戦 を行うと 時間がかかりすぎる 場合は、1 分程度 で 終わる ような 回数の対戦 を行うことにします。
以下に、AI どうしの 対戦回数 に関する 方針 をまとめます。
- AI どうしの 対戦の回数 は、原則 として 一万回 行う
- 一万回の対戦に 時間がかかりすぎる場合 は、1 分で行える回数 の対戦を行う
AI のランダム性
上記で複数回の対戦を行うと説明しましたが、対戦する 双方 の AI の アルゴリズム に ランダム性がなければ 、複数回の対戦 を行う 必要 は ありません。その理由は、ランダム性のない アルゴリズムの AI は、同じ局面 で必ず 同じ着手 を行うからです。実際に、前回の記事で説明したように、ランダム性のない ai1
どうしの対戦 では、常に同じ対戦結果 になります。そのため、ランダム性のない アルゴリズムの AI どうしの対戦 から、AI の 強さの評価 を行うことは できません。その場合は 1 回の対戦 のみを行い、勝敗 は 参考結果 とします。
対戦の組み合わせ
複数 の AI の 強さを評価 する方法は一つではありません。そこで、それぞれの方法 について 考察 し、どの方法を選択するか を 決める ことにします。
総当たり方式
複数 の AI の 強さを評価 する 最も正確な方法 は、すべての AI どうし で 対戦 を行う、総当たり方式(リーグ戦)で 対戦を行う という方法です。〇× ゲームは、〇 の手番 と × の手番 で 有利不利 が 大きく異なる ゲームなので、例えば ai1
と ai2
の 強さを評価 する場合は、〇 と × の 担当 を 入れ替えた、ai1
VS ai2
と ai2
VS ai1
の 両方の対戦 を 行う必要 があります1。さらに、〇 と × で どれくらい有利不利があるか を 調べる ために、 ai1
VS ai1
のような、同じ AI どうし の対戦を 含めた場合 は、$n$ 個 の AI どうしの 総当たり戦 の 組み合わせ は、$n ^ 2$ 通り になります。そのため、作成した AI の数 が 増えていく に従い、対戦の 組み合わせ が 急激に増える ため、AI どうしの 対戦 を行うことが 困難 になります。例えば AI の数 が 100 の場合は、対戦の組み合わせ は $100^2$ = 1 万 通りになります。
総当たり方式 の 利点と欠点 は以下の通りです。
利点:最も正確 に 強さを評価 できる
欠点:AI の 数 が 増える と、対戦の 組み合わせ が 急激に増える
実際に総当たり方式で AI の評価を行うと、対戦に時間がかかりすぎるよう になってしまうため、本記事では 総当たり方式 は 採用しない ことにします。
トーナメント方式
高校野球のような、何千以上の多くの チームの 優劣を決める方法 として、トーナメント方式 があります。トーナメント方式では、1 回の試合 で 1 つ のチームが 敗退 し、最終的 には 1 つのチーム が 優勝する ので、n 個 のチームでトーナメント方式で試合を行うと、全部で n - 1 回試合 を行います。従って、チーム数が 増えても 試合数が 急激に増える ことは ありません。そのため、トーナメント方式で AI どうしの強さを評価すればよいと思う人がいるかもしれませんが、以下の理由 で本記事では トーナメント方式 は 採用しません。
- トーナメント方式は、あらかじめ 強さの評価を行いたい AI が すべて揃っている 場合は利用できるが、本記事で行うように、新しい AI を 次々に作成していく ような場合では、新しい AI を作成するたび に トーナメント を 毎回やり直す 必要が生じてしまう
- 下記で具体例を示すように、トーナメント方式は、本当は 強い AI ではない が、AI どうしの相性 がよかったせいで 勝ち残ってしまう可能性 がある
トーナメント方式 の 利点と欠点 は以下の通りです。
利点:AI の 数 が 増えても、対戦の 組み合わせ が 急激に増えない
欠点:新しい AI が増える とトーナメントを やり直す必要 がある
最も強い AI が 優勝 するとは 限らない
相性のせいで弱い AI がトーナメント方式で優勝する確率が高い例
AI どうし の 相性 のせいで、強くない AI が 勝ち残る可能性が高い 具体例を紹介します。
じゃんけんの AI で、下記の 4 つの AI を、下図の トーナメント方式 で戦わせた場合、それぞれの AI が 優勝する確率 を考えてみて下さい。なお、あいこに なった場合は、決着がつくまで 手を出し続けるものとします。
AI の名前 | アルゴリズム |
---|---|
AI1 | 常にグーを出す |
AI2 | 常にパーを出す |
AI3 | 常にチョキを出す |
AI4 | ランダムに手を出す |
AI1 VS AI4 は、AI1 の 勝ち、負け、あいこ の 確率 は いずれも 1/3 ですが、あいこの場合 は どちらかが勝つまで 手を出し続けるので、AI1 の 勝ち と 負け の 確率 は いずれも 50 % になります。これは、AI2 VS AI4 と AI3 と AI4 の場合も 同様 です。従って、上図のトーナメントの それぞれの対戦結果 は以下のようになります。
- 1 回戦の AI1 VS AI2 は、グーに対してパーを出す AI2 が 100 % 勝つ
- 1 回戦の AI3 VS AI4 は、お互い の 勝率は 50 % となる
- 1 回戦で AI3 が勝利 した場合は、AI2 VS AI3 となり、チョキを出す AI3 が 100 % 勝つ
- 1 回戦で AI4 が勝利 した場合は、AI2 VS AI4 となり、お互い の 勝率は 50 % となる
従って、それぞれの AI の勝率は以下の表のようになり、常にチョキを出す AI3 が 50 % の確率 で 最も優勝確率が高く なり、AI2 と AI4 は 25 %、AI1 は決して 優勝できません。
AI の名前 | 1 回戦の勝率 | 決勝の勝率 | 優勝確率 |
---|---|---|---|
AI1 | 0 % | - | 0 % |
AI2 | 100 % | 相手が AI3 の場合は 0 % 相手が AI4 の場合は 50 % |
1 * 0.5 * 0 + 1 * 0.5 * 0.5 = 25 % |
AI3 | 50 % | 100 % | 0.5 * 1 = 50 % |
AI4 | 50 % | 50 % | 0.5 * 0.5 = 25 % |
じゃんけんの グー、チョキ、パー は 3 すくみ の関係なので、優劣 は ありません が、上記の トーナメント では、グー を出す AI1 の 優勝確率 は 0 %、パー を出す AI 2 の 優勝確率 は 25 %、チョキ を出す AI3 の 優勝確率 は 50 % のように、偏った 優勝確率になります。
また、じゃんけんを遊んだことがあれば、上記の トーナメント で最も 優勝確率が高い、常にチョキを出す AI3 が 強いAIではない ことはすぐにわかるはずです。実際に 人間と AI3 が 対戦 した場合は、AI3 はすぐに 勝てなくなる はずです。一方、人間 とランダムな手を出す AI4 が 対戦 した場合は、お互い の 勝率は約 50 % になるので、人間と対戦する場合 は、AI4 のほう が AI3 より 強い という、上記の トーナメント と 異なる結果 になります。
トーナメント方式 でこのようなことがおきるのは、対戦相手の相性 によって 強い AI が必ずしも 優勝する確率 が 高くならない場合がある からです。
総当たり方式の場合
参考までに、上記を 総当たり方式 で対戦した場合の 勝率 を以下の表で示します。表は、左の列 の AI からみた勝率 です。また、AI1、AI2、AI3 は 同じ AI どうし で 対戦 すると永遠に 決着がつかない ので、それらの組み合わせの対戦は 省略 します。
AI1 | AI2 | AI3 | AI4 | 平均 | |
---|---|---|---|---|---|
AI1 | 0 % | 100 % | 50 % | 50 % | |
AI2 | 100 % | 0 % | 50 % | 50 % | |
AI3 | 0 % | 100 % | 50 % | 50 % | |
AI4 | 50 % | 50 % | 50 % | 50 % | 50 % |
表から、総当たり方式 の場合は すべての AI の 平均勝率が 50 % になってしまうので、総当たり方式でも正しく AI の強さが評価できない と 思う人がいる かもしれませんが、そうではありません。確かに、上記の 4 つの AI どうしの対戦の中には、どちらかが 100 % 勝利する という 極端な相性が存在 しますが、この 4 つの AI どうし で ランダムな組み合わせ で 対戦 を行うと、どの AI も勝率が 50 % になる ことは 間違ってはいない のです。また、すべて の 組み合わせ で 対戦が行われる ので、個々 の AI どうしの相性 を 確認 することができます。
なお、総当たり方式やトーナメント方式に限らず、すべての場合で共通する ことですが、参加した AI の中 での 強さを測る こと しかできない という 限界がある 点に 注意が必要 です。分かりやすい例でいうと、小学生 の野球の 総当たり戦で優勝 したからといって、そのチームが 中学生 の野球の 総当たり戦で優勝 できることはほとんど あり得ないない でしょう。本当の強さ を 測る ためには、多種多様な相手 と 対戦 することが 重要 になるのです。
基準となる AI との比較
他の方法として、基準となる AI を用意し、新しい AI を作成した際に、基準となる AI と対戦 させた結果で AI の強さを評価 するという方法が考えられます。この方法は、新しい AI を作成する際に、基準となる AI と 対戦 させる だけで良い ので、評価に手間がかからない という 利点 があります。一方で、基準となる AI が 偏った性質 を持つ場合に、正しい評価 が 行えない という 欠点 があります。例えば、先程の例の、じゃんけんで 常にグーを出す ような AI を 基準としてはいけない ことは誰でもすぐに理解できるのではないかと思います。
これがベストな選択であると断言することはできませんが、ランダムな着手 を行う ai2
は、ランダム という性質から、あまり偏っていない AI であると 考えること ができます。そこで、本記事では ai2
を 基準となる AI とし、新しい AI を作成 するたびに ai2
と対戦 させることで、作成した AI の 強さの評価 を行うことにします。
なお、ai1
を基準となる AI として 選ばなかった理由 の一つは、ai1
の アルゴリズム に ランダム性がない からです。先程説明したように、ランダム性のない AI を作成して ai1
と対戦 させても 毎回同じ結果 になってしまうため、強さの評価 を行うことは できません。
もう一つの理由は、ai1
の アルゴリズム が、じゃんけんの 常に同じ手を出す AI と 同様 に、偏っている からです。また、本記事の最後で説明しますが、ai1
と ai2
は、客観的に考える と ai2
の方が強い と言えますが、ai1
は ai2
に対して 相性が良い ので、ai1
と ai2
を 対戦 させると ai1
のほうが勝率が高く なってしまいます。
強い AI との比較
基準となる AI との 比較 は、作成した AI が 弱いうちは有効 ですが、AI が ある程度以上強くなる と、基準となる AI と対戦しても、勝率 に 差が ほとんど 生じなく なります。
そのため、AI がある程度以上 強くなった場合 は、その時点までで 作成した強い AI どうし で 対戦 することで、AI の強さ を 評価する 必要が生じます。
そこで、本記事では、新しい AI を作成 した際に、基準となる AI だけでなく、それまでに作成 した AI の中で 最も強い と思われる いくつかのAI との対戦 も行うことにします。その際に、どの AI と対戦 させるかについては、状況に応じて その都度 判断 することにします。
本記事で行う対戦の組み合わせ
本記事では、新しい AI を作成するたびに、下記の AI と対戦を行うことにします。
- ランダムな着手を行う
ai2
- それまでに作成 した AI の中で 最も強い AI と思われるのうちの いくつか
なお、上記で説明した以外にも AI どうしの強さをよりよく評価できる方法は実際にあると思います。そのような方法を思いついた人がいれば、それを採用してもかまいません。
AI どうしの対戦処理
AI の強さの評価を行う方法が決まったので、その方法に従って、指定した AI どうし を 複数回対戦 させて、その 通算成績 を 集計 する必要があります。例えば ai1
と ai2
の 対戦 を 2 回 行う場合は、下記のプログラムのように、play
メソッドを for 文 を使って 2 回呼び出せば良い のですが、play
メソッドを 実行 しても、勝敗結果 は画面に winner o
のように 表示 される だけ なので、このまま では 通算成績 を 集計 することは できません。
from marubatsu import Marubatsu
from ai import ai1, ai2
mb = Marubatsu()
for _ in range(2):
mb.play(ai=[ai1, ai2])
実行結果(実行結果はランダムなので下記とは異なる場合があります)
Turn o
...
...
...
略
winner o
oox
oox
xxO
Turn o
...
...
...
略
winner o
ooO
x..
..x
play
メソッドの修正 1(対戦結果を返すようにする)
プログラム で play
メソッドの 対戦結果 を 集計 するためには、play
メソッドが、対戦結果 を 返り値 として 返す ように 修正 する必要があります。対戦結果 は、status
属性 に 代入 されるので、下記のプログラムの 11 行目に return self.status
を追加 することで、play
メソッドが 対戦結果を返す ようになります。
1 def play(self, ai):
2 # 〇×ゲームを再起動する
3 self.restart()
4
元と同じなので省略
5
6 # 決着がついたので、ゲーム盤を表示する
7 print(self)
8 # 勝敗結果を返り値として返す
9 return self.status
10
11 Marubatsu.play = play
行番号のないプログラム
def play(self, ai):
# 〇×ゲームを再起動する
self.restart()
# ゲームの決着がついていない間繰り返す
while self.status == Marubatsu.PLAYING:
# ゲーム盤の表示
print(self)
# 現在の手番を表す ai のインデックスを計算する
index = 0 if self.turn == Marubatsu.CIRCLE else 1
# ai が着手を行うかどうかを判定する
if ai[index] is not None:
x, y = ai[index](self)
else:
# キーボードからの座標の入力
coord = input("x,y の形式で座標を入力して下さい。exit を入力すると終了します")
# "exit" が入力されていればメッセージを表示して関数を終了する
if coord == "exit":
print("ゲームを終了します")
return
# x 座標と y 座標を要素として持つ list を計算する
xylist = coord.split(",")
# xylist の要素の数が 2 ではない場合
if len(xylist) != 2:
# エラーメッセージを表示する
print("x, y の形式ではありません")
# 残りの while 文のブロックを実行せずに、次の繰り返し処理を行う
continue
x, y = xylist
# (x, y) に着手を行う
try:
self.move(int(x), int(y))
except:
print("整数の座標を入力して下さい")
# 決着がついたので、ゲーム盤を表示する
print(self)
return self.status
Marubatsu.play = play
修正箇所
def play(self, ai):
# 〇×ゲームを再起動する
self.restart()
元と同じなので省略
# 決着がついたので、ゲーム盤を表示する
print(self)
# 勝敗結果を返り値として返す
+ return self.status
Marubatsu.play = play
下記のプログラムを実行し、実行結果 に winner: o
のような表示が行われることから、play
メソッドが 勝敗結果を返す ようになったことが 確認 できます。
for _ in range(2):
print("winner: ", mb.play(ai=[ai1, ai2]))
実行結果(実行結果はランダムなので下記とは異なる場合があります)
Turn o
...
...
...
略
winner x
oox
oox
x.X
winner: x
Turn o
...
...
...
略
winner o
ooO
x.x
...
winner: o
勝敗結果 は Marubatsu
クラスのインスタンスの status
属性 に 代入 されるので、play
メソッドを 修正しなくても、下記のプログラムのように記述すれば良いと思った人がいるかもしれません。なお、実行結果は上記と同様なので省略します。
for _ in range(2):
mb.play(ai=[ai1, ai2])
print("winner: ", mb.status)
確かに、上記のプログラム でも 正しく動作 しますが、上記のプログラムを 記述するため には、Marubatsu
クラス に、ゲームの 勝敗結果 を表す status
という 属性が存在する ことを 知っている必要 があります。もちろん、Marubatsu
クラスを定義した作者はそのことは知っているはずですが、作者以外 の人が Marubatsu
クラス を 利用 する場合は、そのこと を何らかの方法で 学ぶ必要 があります。そのため、play
メソッドの 返り値 として、勝敗結果 を 返す ようにしたほうが 親切 です。
他にも、話が長くなるので今回の記事では説明しませんが、インスタンスの属性 に対する 処理(値の参照や代入)は、クラスの メソッドの外 では なるべく行わないほうが良い という理由から、mb.status
と記述して勝敗結果を参照するよりは、play
メソッドの返り値として受け取ったほうが良いでしょう。
play
メソッドの修正 2(途中経過の非表示)
先程のプログラムでは、画面 に AI どうしの 対戦 の 途中経過 が 表示 されていましたが、AI の 強さを評価するため に 対戦 を 複数回行う際 に 重要 となるのは、勝敗結果だけ で、途中経過 の情報は 必要ではありません。また、例えば 100 回の対戦 を行った結果、100 回分 の対戦の 途中経過 が画面に 表示 されてしまうと、「画面 がその表示で 埋め尽くされてしまう」、「表示の処理 に 時間がかかる」などの 問題が発生 します。ちなみに、先程 のプログラムで、対戦回数 を 2 回 にした 理由 は、実行結果 の 表示 を 少なくするため でした。
play
メソッド 実行した際 に、対戦 の 途中経過 を 表示しない ように 修正 すれば良いと 思う人がいるかもしれません が、そのよう に play
メソッドを 修正 してしまうと、今度は 人間と AI の対戦や、人間どうし の 対戦 の際に、途中経過 が 表示されなく なってしまい、人間 が 遊ぶことができなく なってしまうという 欠点 が生じます。
そこで、play
メソッド 実行 した際に、途中経過 を 表示するかどうか を 選択 できるように 改良 することにします。そのために、そのことを 選択するため の 仮引数 を 追加 します。
画面に メッセージ を 表示するかどうか を 選択 するための 仮引数 の 名前 として、画面に表示される メッセージ が デバッグのため のメッセージである場合は debug
が 良く使われます。他にも「言葉数の多い」、「多弁な」という意味を表す verbose
という 名前 も 良く使われる ようです。途中経過 の表示は デバッグ のためのメッセージ ではない ので、本記事では 仮引数の名前 を verbose
とし、下記のプログラムのように、True
が代入されていた場合は 途中経過を表示 し、False
が代入されていた場合は 途中経過を表示しない ように play
メソッドを 修正 します。なお、play
メソッドで 〇×ゲームを 人間が遊ぶ際 に、毎回 実引数 に verbose=True
を 記述 するのは 面倒 なので、verbose
は デフォルト引数値 を True
とする デフォルト引数 とします。
-
1 行目:デフォルト引数値 が
True
の デフォルト引数verbose
を 追加 -
7、12 行目:
verbose
がTrue
の 場合のみ、ゲームの画面を表示 するように修正
なお、人間が座標を入力 する場合は、メッセージを表示 しなければ ゲームを遊べない ので、人間が座標を入力 する 処理の中 の メッセージ は 常に表示 するようにしています。
1 def play(self, ai, verbose=True):
2 # 〇×ゲームを再起動する
3 self.restart()
4 # ゲームの決着がついていない間繰り返す
5 while self.status == Marubatsu.PLAYING:
6 # ゲーム盤の表示
7 if verbose:
8 print(self)
9
元と同じなので省略
10
11 # 決着がついたので、ゲーム盤を表示する
12 if verbose:
13 print(self)
14 return self.status
15
16 Marubatsu.play = play
行番号のないプログラム
def play(self, ai, verbose=True):
# 〇×ゲームを再起動する
self.restart()
# ゲームの決着がついていない間繰り返す
while self.status == Marubatsu.PLAYING:
# ゲーム盤の表示
if verbose:
print(self)
# 現在の手番を表す ai のインデックスを計算する
index = 0 if self.turn == Marubatsu.CIRCLE else 1
# ai が着手を行うかどうかを判定する
if ai[index] is not None:
x, y = ai[index](self)
else:
# キーボードからの座標の入力
coord = input("x,y の形式で座標を入力して下さい。exit を入力すると終了します")
# "exit" が入力されていればメッセージを表示して関数を終了する
if coord == "exit":
print("ゲームを終了します")
return
# x 座標と y 座標を要素として持つ list を計算する
xylist = coord.split(",")
# xylist の要素の数が 2 ではない場合
if len(xylist) != 2:
# エラーメッセージを表示する
print("x, y の形式ではありません")
# 残りの while 文のブロックを実行せずに、次の繰り返し処理を行う
continue
x, y = xylist
# (x, y) に着手を行う
try:
self.move(int(x), int(y))
except:
print("整数の座標を入力して下さい")
# 決着がついたので、ゲーム盤を表示する
if verbose:
print(self)
return self.status
Marubatsu.play = play
修正箇所
def play(self, ai, verbose=True):
# 〇×ゲームを再起動する
self.restart()
# ゲームの決着がついていない間繰り返す
while self.status == Marubatsu.PLAYING:
# ゲーム盤の表示
- print(self)
+ if verbose:
+ print(self)
元と同じなので省略
# 決着がついたので、ゲーム盤を表示する
- print(self)
+ if verbose:
+ print(self)
return self.status
Marubatsu.play = play
下記のプログラムのように、実引数 に verbose=False
を記述して play
を実行することで、途中経過 が 表示されなくなった ことが確認できます。
for _ in range(2):
print("winner: ", mb.play(ai=[ai1, ai2], verbose=False))
実行結果(実行結果はランダムなので下記とは異なる場合があります)
winner: o
winner: o
勝敗結果の集計
勝敗結果 は、〇 の勝利、× の勝利、引き分け の 3 種類 があるので、下記のプログラムのように、3 つの変数 と、if 文 を使って それぞれの回数 を 集計 することができます。
-
1 ~ 3 行目:〇 の勝利、× の勝利、引き分け の 回数を数える ための
circle
、cross
、draw
という 3 つの変数 に0
を代入 して 初期化 する - 4 行目:for 文 で 100 回 の繰り返しを行う
-
5 行目:
ai1
とai2
の 対戦 を行い、結果 をwinner
に 代入 する -
6 ~ 11 行目:if 文 を使って、勝敗結果 に 応じて、回数を数える変数 に
1
を足す - 13 行目:繰り返し処理 が 終了した時点 で 集計が完了 するので、結果を表示 する
実行結果からわかるように、ai1
VS ai2
で 100 回対戦した結果は、ai1
の 72 勝 18 敗 10 分 でした。なお、ai2
には ランダム性がある ので、結果は下記と 異なる場合 があります。
1 circle = 0
2 cross = 0
3 draw = 0
4 for _ in range(100):
5 winner = mb.play(ai=[ai1, ai2], verbose=False)
6 if winner == Marubatsu.CIRCLE:
7 circle += 1
8 elif winner == Marubatsu.CROSS:
9 cross += 1
10 else:
11 draw += 1
12
13 print("circle", circle, "cross", cross, "draw", draw)
行番号のないプログラム
circle = 0
cross = 0
draw = 0
for _ in range(100):
winner = mb.play(ai=[ai1, ai2], verbose=False)
if winner == Marubatsu.CIRCLE:
circle += 1
elif winner == Marubatsu.CROSS:
cross += 1
else:
draw += 1
print("circle", circle, "cross", cross, "draw", draw)
実行結果(実行結果はランダムなので下記とは異なる場合があります)
circle 72 cross 18 draw 10
dict を使った集計
上記のプログラムでは、3 つの変数 と、if 文 を使って 集計 を行いましたが、dict を使うことで 集計 をより 簡潔に記述 することができます。
勝敗結果 を表す play
メソッドの 返り値 の データ型 は 文字列型 で、dict は キー として 文字列型 のデータを 利用 することが できる ので、下記のプログラムのように、play
メソッドの 返り値 を dict のキー として 利用 し、その キーの値 を使って 回数を数えます。
-
1 ~ 5 行目:〇 の勝利、× の勝利、引き分け の回数を 数える ための、
Marubatsu.CIRCLE
、Marubatsu.CROSS
、Marubatsu.EMPTY
を キー として持ち、それぞれの キーの値 が0
である dict をcount
という 変数 に __代入__する - 6 行目:for 文 で 100 回 の __繰り返し__を行う
-
7 行目:
ai1
とai2
の 対戦 を行い、結果 をwinner
に 代入 する -
8 行目:
count
のwinner
の キーの値 に1
を足して、勝敗結果 に応じた 回数を数える -
10、11 行目:
count
のそれぞれの キー と その値 を、items
メソッドと for 文による 繰り返し処理 を使って 表示 することで、集計結果を表示 する
実行結果から、ai1
と ai2
で 100 回対戦した結果は、ai1
の 75 勝 21 負 4 分 であり、先程の結果と 大きく変わらない ことから正しく集計されていることが確認できます。
1 count = {
2 Marubatsu.CIRCLE: 0,
3 Marubatsu.CROSS: 0,
4 Marubatsu.DRAW: 0,
5 }
6 for _ in range(100):
7 winner = mb.play(ai=[ai1, ai2], verbose=False)
8 count[winner] += 1
9
10 for key, value in count.items():
11 print(key, value)
行番号のないプログラム
count = {
Marubatsu.CIRCLE: 0,
Marubatsu.CROSS: 0,
Marubatsu.DRAW: 0,
}
for _ in range(100):
winner = mb.play(ai=[ai1, ai2], verbose=False)
count[winner] += 1
for key, value in count.items():
print(key, value)
実行結果(実行結果はランダムなので下記とは異なる場合があります)
o 75
x 21
draw 4
defaultdict を使った集計
先程のプログラムでは、1 ~ 5 行目で 0
が代入 された 3 つのキー を持つ dict を count
に代入しましたが、その 記述が面倒 だと思った人はいませんか?
先程のプログラムを、下記のように、count
に 空の dict を 代入 するように 記述できれば プログラムが 簡潔 になりますが、下記のプログラムを実行すると エラーが発生 します。
count = {}
for _ in range(100):
winner = mb.play(ai=[ai1, ai2], verbose=False)
count[winner] += 1
for key, value in count.items():
print(key, value)
実行結果
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
Cell In[9], line 4
2 for _ in range(100):
3 winner = mb.play(ai=[ai1, ai2], verbose=False)
----> 4 count[winner] += 1
6 for key, value in count.items():
7 print(key, value)
KeyError: 'o'
上記のような エラーが発生 する 理由 は、下記のプログラムのように、値が一度も代入されていない dict の キーを参照 しようとすると KeyError という エラーが発生 するからです。
count = {}
print(count[Marubatsu.CIRCLE])
実行結果
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
Cell In[10], line 2
1 count = {}
----> 2 print(count[Marubatsu.CIRCLE])
KeyError: 'o'
下記のプログラムの 4 行目の count[winner] += 1
という式は、count[winner] = count[winner] + 1
という処理を行う式です。1 行目の処理によって、count
に 空の dict が 代入された状態 で この式を実行 しようとすると、代入文の =
の右の count[winner] + 1
を 計算する際 に、値が一度も代入されていない dict の キーの値を参照 する必要があるため、このプログラムを実行すると KeyError という エラーが発生 します。
count = {}
for _ in range(100):
winner = mb.play(ai=[ai1, ai2], verbose=False)
count[winner] += 1
collection モジュールと defaultdict
この問題は、Python の collections という モジュール の中で定義された、defaultdict という クラス を利用することで 解決 することができます。
Python では、list、tuple、dict などの、複数のデータを扱う ことができる データ型 の事を コンテナデータ型 と呼びます。collections は、Python が 標準的に用意 する、list、tuple、dict、set 以外 の 特殊なコンテナデータ型 が 定義 された モジュール です。
collections モジュールの詳細については、下記のリンク先を参照して下さい。
defaultdict の使い方
defaultdict は、「一度も値が代入されていないキーを参照 しようとした場合に、エラーとせず に、特定の値 をその キーの値に代入 する」以外 では、dict と同じ性質 を持ちます3。本記事では、その特定の値 の事を、defaultdict の 規定値 と表記することにします。
defaultdict は クラス で、以下のように記述することで インスタンスを作成 します。作成された の defaultdict の インスタンス には、空の dict と同様に、キー は 存在しません。
defaultdict(default_factory)
実引数 default_factory
には、defaultdict の 規定値 を 作成するため に 呼び出す、関数 または クラス を 記述 します。例えば、既定値 として、整数型 の 0
を 代入 したい場合は、下記のプログラムのように default_factory
に int
を記述 します。
int
を 記述 すると 既定値 が 0
になる理由 については 後述 します。また、実引数に、既定値である 0
を 直接記述しない理由 についても 後述 します。
-
1 行目:
collections
モジュールからdefaultdict
をインポート する -
3 行目:
defaultdict
の 実引数 にint
を記述することで、一度も値が代入されていないキー を 参照 しようとした場合に、既定値 として0
がその キーの値 に 代入 される
4 行目 を実行した時点で count
には Marubatsu.CIRCLE
という キーは存在しない ので、count[Marubatsu.CIRCLE]
に 0
が代入 され、その結果 0
が表示 されます。
from collections import defaultdict
count = defaultdict(int)
print(count[Marubatsu.CIRCLE])
実行結果
0
先程のプログラムは、下記のプログラムのように defaultdict で 簡潔に記述 できます。
count = defaultdict(int)
for _ in range(100):
winner = mb.play(ai=[ai1, ai2], verbose=False)
count[winner] += 1
for key, value in count.items():
print(key, value)
修正箇所
-count = {
- Marubatsu.CIRCLE: 0,
- Marubatsu.CROSS: 0,
- Marubatsu.DRAW: 0,
-}
+count = defaultdict(int)
for _ in range(100):
winner = mb.play(ai=[ai1, ai2], verbose=False)
count[winner] += 1
for key, value in count.items():
print(key, value)
実行結果(実行結果はランダムなので下記とは異なる場合があります)
x 20
o 78
draw 2
defaultdict に関する補足
for 文 に記述された count.items()
は、defaultdict に 登録された順 で キー と その値 を 取り出す ので、例えば 最初 の mb.play()
の 返り値 が Marubatsu.CROSS
だった場合は、上記の実行結果のように、最初 に x
の勝利の回数 が 表示 される 場合 があります。
また、一度も引き分けにならなかった 場合は、Marubatsu.DRAW
という キー が count
に 登録されない ので、下記のように draw
が 実行結果 に 表示されない場合 もあります。
o 80
x 20
ただし、キーの 登録の順番 が 処理に影響を与える のは、items
メソッドなどで キー や その値 を 順番に取り出す場合だけ なので、それ以外の場合は 気にする必要はない でしょう。
defaultdict が行う処理
defaultdict(default_factory)
の default_factory
に int
を記述すると、既定値 として 0
が設定 される 理由 について説明します。defaultdict に対して、一度も値が代入されていないキー を 参照 しようとした場合は、下記の手順 で 処理 が行われます。
-
default_factory
が 呼び出される。具体的にはdefault_factory()
が実行 される -
default_factory()
の 返り値 が、その キーの値 として 代入 される
default_factory
の データの種類 によって、規定値 は以下のようになります。
- クラス を記述した場合は、その クラスのインスタンス が 既定値 となる
- 関数 を記述した場合は、その 関数 の 返り値 が 規定値 となる
default_factory
は、defaultdict の __init__
メソッドの 仮引数の名前 で、由来 は 既定(default)値 を 作成 する 工場 (factory)のような 役割 を持つことです。
default_factory
に int
を 記述 した場合、int()
という 処理が実行 されます。int
は 組み込み型のクラス で、int()
のように、実引数 に 何も記述せず に 呼び出した場合 は、下記のプログラムのように、整数型 の 0
を表す インスタンスが作成 されます。
これが、defaultdict(int)
によって、既定値 が 0
の defaultdict が 作成される理由 です。
print(int())
defaultdict では、既定値 として 空の list を 指定 することが 良くあります。その場合は、下記のプログラムのように、default_factory
に list
を記述 します。これは、list
が 組み込み型のクラス で、list()
のように、実引数 に 何も記述せず に 呼び出した場合 は、空の list を表す インスタンスが作成 されるからです。
count = defaultdict(list)
print(count[Marubatsu.CIRCLE])
実行結果
[]
defaultdict の 規定値 に 整数型の 0
を 指定 したい場合は defaultdict(int)
を、空の list を 指定 したい場合は defaultdict(list)
と記述すれば良い。
任意の値を規定値とする defaultdict
今回の記事では利用しませんが、0
や、空の list 以外の 任意の値 を 規定値 とする defaultdict を 作成 することが できます。その場合は、規定値 として 設定したい値 を 返す、仮引数 が 存在しない関数 を 定義 し、その関数 を default_factory
に 記述 します。
例えば、下記は、整数型 の 1
を 規定値 とする defaultdict を 作成 するプログラムです。5 行目の実行結果から、既定値 として 1
が代入 されることが 確認 できます。
-
1、2 行目:常に整数型の
1
を返すcreate_one
という名前の関数を定義する -
4 行目:
create_one
をdefaultdict
の 実引数 に 記述 して呼び出すことで、規定値 が1
の defaultdict を 作成 して、count
に代入 する
def create_one():
return 1
count = defaultdict(create_one)
print(count[Marubatsu.CIRCLE])
実行結果
1
defaultdict の実引数に、規定値を直接記述しない理由
既定値 を 1
とする defaultdict を 作成 する際に、下記のプログラムのように、defaultdict の 実引数 に 既定値 を 直接記述したい と思う人がいるかもしれませんが、下記のプログラムを実行すると エラーが発生 します。その 理由 は、defaultdict の default_factory
に対応する 実引数 は、関数 や クラス などの、呼び出すことができる オブジェクト でなければならない からで、整数型 の 1
は 1()
のように記述して 呼び出すことはできない からです。
count = defaultdict(1)
print(count[Marubatsu.count])
実行結果
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[16], line 1
----> 1 count = defaultdict(1)
2 print(count[Marubatsu.count])
TypeError: first argument must be callable or None
上記のエラーメッセージは、以下のような意味を持ちます。
-
TypeError
データ型(type)に関するエラー -
first argument must be callable or None
(defaultdict の)最初(first)の実引数(argument)は、(関数やクラスなどの)呼び出し可能(callable)なオブジェクトか、None
でなければならない(must be)
defaultdict の 実引数 に、規定値 を 直接記述しない 理由は、そのようにすると、作成した defaultdict の 規定値 が、すべてのキーで常に同じ値 になってしまうからです。例えば、既定値 を 直接記述 するという 方法 では、defaultdict の 規定値 として、値が代入されていないキー の値を 参照した時刻 を 設定 することは できません。
一方、defaultdict の 実引数 に、既定値 を 作成する ための 関数やクラス を 記述 する場合は、下記のプログラムのように、現在時刻 を 返す関数 を 定義 し、その関数 を defaultdict の 実引数に記述 することで、現在時刻 を表す 文字列 を 既定値 として持つ defaultdict を作成 することが できます。なお、現在時刻 を表す 文字列 は、time という モジュール で定義された ctime
という 関数 を呼び出すことで得ることができます。
time モジュールと ctime
の詳細については、下記のリンク先を参照して下さい。
-
1 行目:time モジュールから、
ctime
を インポート する -
3、4 行目:現在の時刻 を表す 文字列 を 返す、
now
という関数を 定義 する -
6 行目:
now
を 実引数 に記述して、defaultdict を作成 し、count
に 代入 する
下記のプログラムを実行すると、count
の 規定値 が、値が代入されていないキー の値を 参照 した時の 時刻 を表す 文字列 になるので、7 行目を実行すると、7 行目 を 実行した時 の 時刻を表す文字列 が 表示 されます。
1 from time import ctime
2
3 def now():
4 return ctime()
5
6 count = defaultdict(now)
7 print(count(Marubatsu.CIRCLE))
行番号のないプログラム
from time import ctime
def now():
return ctime()
count = defaultdict(now)
print(count[Marubatsu.CIRCLE])
実行結果
Fri Dec 29 11:39:50 2023
また、上記のプログラムを 実行した少し後 で、下記のプログラムを実行すると、異なるキー に対して、異なる規定値 が 代入 されることが 確認 できます。
ctime
は 秒単位 で 時刻を表す するので、上記のプログラムと下記のプログラムを 間を空けず に 連続して実行 すると、同じ文字列が表示 されます。
print(count[Marubatsu.CROSS])
実行結果
Fri Dec 29 11:40:01 2023
defaultdict の 実引数 に 既定値 を 直接記述しない理由 は、実引数 に 関数やクラス を 記述したほう が、状況に応じて値が変化する ような、柔軟な既定値 を 設定 することが できるようになる ためです。
上記のプログラムでは、ctime()
を 返り値 として 返す、now
という 関数を定義 しましたが、ctime()
は、ctime
を 呼び出した時 の 返り値 なので、下記のプログラムのように、ctime
を 直接 defaultdict の 実引数に記述 することが できます。
count = defaultdict(ctime)
print(count[Marubatsu.CIRCLE])
実行結果
Fri Dec 29 11:40:09 2023
ラムダ式
今回の記事では defaultdict の 規定値 に 0
を設定 するため、defaultdict(int)
とだけ記述すれば良いので、下記の説明の ラムダ式 を利用することはありませんが、ラムダ式 は 実際に良く使われる ので説明することにします。
今回の記事 のプログラムでは、ラムダ式を使わない ので、分かりづらい と思った方は、そういうものがある ということだけを 覚えて おいて、読み飛ばして も かまいません。
ラムダ式 は うまく使えば便利 ですが、むやみに使うとプログラムが わかりづらくなる という 欠点 があります。そのため、慣れないうち は、ラムダ式 を使うと 便利な例 として学んだ場合で のみで使う ことを 強くお勧めします。
0
や 空の list 以外 の 既定値 を持つ defaultdict を 利用 する際に、上記のような 規定値を返す関数 を 定義 するのは 面倒 なので、defaultdict の default_factory は、より 簡潔に記述 できる ラムダ(lambda)式 を使って 記述する のが 一般的 です。
ラムダ式 は、無名関数 という、名前の無い関数 を 簡潔に定義 することができる 式 で、下記のように記述します。仮引数 が 存在しない場合 は、lambda : 式
のように記述します。
lambda 仮引数: 式
上記のラムダ式 は、下記 の 関数の定義 と 同じ意味 を持ちます。なお、先ほど説明したように、ラムダ式 は 名前の無い関数 を 定義 するので、それに あわせて 下記のプログラムでは 関数の名前 を 記述していません が、def による 関数の定義 で 名前を記述しない と エラーが発生する ので、実際に 下記のプログラムを記述して 実行 すると エラーが発生 します。
下記のプログラムは、ラムダ式の説明 を行うための 架空の記述 だと思ってください。
def (仮引数):
return 式
上記のような、def で 名前の無い関数 を 定義 することが できない理由 は、名前の無い関数 を 定義しても、名前が存在しないため、その 関数を利用することができない からです。
ラムダ式の詳細については下記のリンク先を参照して下さい。
ラムダ式の利用方法
ラムダ式 は 関数の定義 なので、ラムダ式を実行 しても def による 関数の定義 と 同様 に、関数のブロックの処理 は 行われません。また、ラムダ式 で定義した 関数 には 名前がない ので、ラムダ式 を 記述しただけ では、その 関数 を 利用 することは できません。
そのため、ラムダ式は、以下の いずれかの方法 で 利用 します。
- 関数呼び出し の 実引数 に 記述 する。この場合は、ラムダ式 で 定義された関数 が、関数の 仮引数に代入 されるので、仮引数 を使って 利用 することができる
- 変数 やインスタンスの 属性 に 代入 し、代入した 変数 や 属性 を使って 利用 する
後で具体的に説明しますが、defaultdict で利用する ラムダ式 は、前者 の利用方法です。
下記のプログラムは、後者の利用方法 の 具体例 ですが、実際には、このような、プログラムの 複数の場所 で 関数の名前 を 直接記述 して 何度も呼び出す ような場合は ラムダ式 は一般的には 使われません。その 理由の一つ は、ラムダ式 で記述した関数は、def で定義した場合と 比べて、定義した場所 と、見た目 が わかりづらくなる ためです。
後者 の 有効な利用方法 については、必要になった時点で説明します。
add = lambda x, y: x + y
print(add(1, 2))
print(add(2, 3))
実行結果
3
5
ai2
は、ブロック内 で行う 処理 を、1 つの return 文 で 記述 することが できる ので、ラムダ式 を使って、下記のプログラムのように 定義 することができます。その下の元のプログラムと見比べてみて下さい。
from random import choice
ai2 = lambda mb: choice(mb.calc_legal_moves())
元のプログラム
def ai2(mb):
legal_moves = mb.calc_legal_moves()
return choice(legal_moves)
下記のプログラムを実行することで、ai1
と上記の ai2
で対戦を行うことができることが確認できます。なお、実行結果は長くなるので省略します。
mb.play(ai=[ai1, ai2])
1 つの return 文 で 記述できない ような AI は ラムダ式 では 記述できません。また、上記のラムダ式は わかりづらい ので、ai2
は 元のプログラムを採用 します。
ラムダ式の制限事項
ラムダ式 は、関数を簡潔に定義できますが、以下のような 制限があります。
- 関数 が行う 処理 として、複数の文 を 記述 することは できない。例えば、下記のような、複数の文 から 構成される関数 を ラムダ式 で 記述 することは できない
def add(x, y):
print(x, y)
return x + y
ラムダ式は、1 つの式 の 計算結果を返す ような 関数しか定義できない。
- 仮引数や返り値に対する 型アノテーションを記述 することが できない。例えば、下記のようなプログラムを記述するとエラーが発生する
lambda a:int : a -> int
1 つ目の 制限事項 から、ほとんどの関数 は ラムダ式 で 記述 することは できません が、defaultdict など、一部の状況 では ラムダ式 を使うことでプログラムを 簡潔に記述できる ので、そのような 特別な場合 で ラムダ式 は 使われます。
ラムダ式を利用した defaultdict
ラムダ式 を 利用 することで、先程の、規定値 が 整数型の 1
となる defaultdict を作成 するプログラムを、下記のプログラムのように 簡潔に記述 できます。修正箇所 を見て、ラムダ式を使わない プログラムと 見比べて みて下さい。
count = defaultdict(lambda : 1)
print(count[Marubatsu.CIRCLE])
実行結果
1
修正箇所
-def create_one():
- return 1
-count = defaultdict(create_one)
+count = defaultdict(lambda : 1)
print(count[Marubatsu.CIRCLE])
既定値 が 0
の defaultdict は defaultdict(int)
のように 簡潔に記述できる ので、わざわざ defaultdict(lambda : 0)
のように 記述 する 必要はありません。
ラムダ式 は、defaultdict 以外 の 用途 でも 使われます。ラムダ式の他の具体的な利用方法については、必要になった時点で紹介します。
AI どうしの対戦を行う関数の定義
AI どうしの対戦 を行う際に、先程のようなプログラムを 毎回記述 するのは 大変 なので、AI どうしの対戦 を行う 関数を定義 する事にします。その際に、対戦 に 時間がかかる可能性 があることを 考慮 して、対戦回数 を 指定できる ようにします。
処理:AI どうし の 複数回 の 対戦 を行い、通算成績を表示 する
名前:AI どうしの 対戦(match)を行うので、ai_match
という名前にする
入力:
-
ai
という 仮引数 に AI の処理 を行う 関数 を、play
メソッドの仮引数ai
と 同じデータ構造 で 代入 する -
match_num
という 仮引数 に 対戦 を 行う回数 を 代入 する。また、10000
を デフォルト値 とする デフォルト引数 とする
出力:なし(通算成績 は 文字で表示 する)
下記は、ai_match
の定義 です。基本的 には、先程のプログラム で行っていた 処理 を ai_match
の関数の ブロックに記述 していますが、下記の点 が 異なります。
-
1 行目:名前 を
ai_match
、仮引数 をai
とmatch_num
とし、match_num
を デフォルト値 が10000
の デフォルト引数 とする 関数を定義 する -
4 行目:for 文 で、
match_num
回 の 繰り返し処理 を行う -
7、8 行目:先ほど説明したように、defaultdict を使った場合に for 文 と
count.items()
を利用して キー と その値 を 表示 すると、表示の順番 が 〇 の勝利、× の勝利、引き分けの 順にならない場合がある ので、必ずその順番 で 表示する ように 修正 した
1 def ai_match(ai, match_num=10000):
2 mb = Marubatsu()
3 count = defaultdict(int)
4 for _ in range(match_num):
5 count[mb.play(ai, verbose=False)] += 1
6
7 print("o", count[Marubatsu.CIRCLE], "x", count[Marubatsu.CROSS],
8 "draw", count[Marubatsu.DRAW])
行番号のないプログラム
def ai_match(ai, match_num=10000):
mb = Marubatsu()
count = defaultdict(int)
for _ in range(match_num):
count[mb.play(ai, verbose=False)] += 1
print("o", count[Marubatsu.CIRCLE], "x", count[Marubatsu.CROSS],
"draw", count[Marubatsu.DRAW])
修正箇所
関数のブロックのインデントは修正箇所に含めません。
+def ai_match(ai, match_num=10000):
mb = Marubatsu()
count = defaultdict(int)
- for _ in range(100):
+ for _ in range(match_num):
count[mb.play(ai, verbose=False)] += 1
-for key, value in count.items():
- print(key, value)
+ print("o", count[Marubatsu.CIRCLE], "x", count[Marubatsu.CROSS],
"draw", count[Marubatsu.DRAW])
下記のプログラムを実行することで、ai1
VS ai2
の 対戦 を 1 行目で 1000 回、2 行目で 10000 回 行い、通算成績を表示 することができることが 確認 できます。
ai_match(ai=[ai1, ai2], match_num=1000)
ai_match(ai=[ai1, ai2])
実行結果(実行結果はランダムなので下記とは異なる場合があります)
o 759 x 207 draw 34
o 7834 x 1733 draw 433
ai1
と ai2
のすべての組み合わせの対戦とその評価
対戦を行うための関数が定義できたので、下記のプログラムで ai1
と ai2
の すべての組み合わせ で 対戦を行い、それぞれ の 通算成績を評価 することにします。
ai_match(ai=[ai1, ai1]) # ai1 VS ai1
ai_match(ai=[ai1, ai2]) # ai1 VS ai2
ai_match(ai=[ai2, ai1]) # ai2 VS ai1
ai_match(ai=[ai2, ai2]) # ai2 VS ai2
実行結果(実行結果はランダムなので下記とは異なる場合があります)
o 10000 x 0 draw 0
o 7727 x 1816 draw 457
o 5208 x 4392 draw 400
o 5851 x 2878 draw 1271
実行結果を ざっと眺める ことで、下記の事がわかります。
-
ai1
VSai1
では、ai1
の アルゴリズム には ランダム性はない ので、10000 回の すべての対戦 で 〇 が勝利 する -
ai1
VSai2
とai1
VSai2
の 通算成績 を 合計 すると、ai1
から見て $7727 + 4392 =$ 12119 勝、$1816 + 5208 =$ 7024 敗、$457 + 400 =$ 857 分 であり、ai1
のほうがai2
より強く見える -
ai2
VSai2
から、ai2
どうし で対戦した場合は、〇 のほうが × の 約 2 倍勝利 する
ランダムな着手 を行う ai2
どうし の 対戦結果 から、〇×ゲームは 〇 のほうが かなり 有利 なゲームであると 推測 することができます。
ai1
と ai2
の強さ考察
上記の結果から、一見する と、ai1
のほうが ai2
より強い ように 思えるかも しれませんが、そのような 結論を出す のは 早計 です。それぞれの AI の 実際の強さとは別に、ai1
のアルゴリズム が ai2
のアルゴリズム に対して 相性が良い可能性 があるからです。
そこで、上記の 実行結果 を 考慮せず に、ai1
と ai2
の アルゴリズムだけ から、どちらが強い AI であるかを 考察 してみることにします。
〇×ゲームのことを あまりよく知らない人間 と、ai1
が対戦する場合 のことを考えてみて下さい。ai1
は 左上のマスから順番 に 空いているマスを探して着手を行う だけなので、人間 であれば、初心者であっても すぐに ai1
に負けなくなる はずです。
一方、ai2
は ランダムな着手 を行うので、偶然 ai2
が 最善手のみの着手 を行う 可能性 があり、そのような場合は 〇×ゲームの 初心者が負ける可能性 はあるでしょう。
上記から、人間相手 の 対戦 を考えると、ai1
より ai2
のほうが強い と 考察 できます。
しかし、実際に ai1
と ai2
で対戦 した場合は、ai1
が 大きく勝ち越し ます。そのような結果になる理由について少し考えてみて下さい。
ai1
が ai2
に勝ち越す理由
ai1
が 〇 を担当 する場合は、その アルゴリズムから、ai2
が 上の行 に 着手を一度も行わなければ、3 回の着手 で ai1
は 上の行 に 〇 を順番に着手 して 勝利 します。ai2
は ランダムな着手 を行うので、2 手連続 で 上の行 に 着手を行わない確率 は計算しなくても かなりありそう です。従って、ai1
が 〇 を担当 する場合に、ai2
に勝つ確率 はかなりあると 推測 できます。ai1
が × を担当 する場合は ai2
が 3 手連続 で 上の行 に 着手を行わなければ、同様の理由で ai1
が勝利 しますが、その 確率 もそれほど 低くはなさそう です。従って、ai1
は ai2
に対して 相性が良い アルゴリズムであることが 推測 できます。
ただし、上記は 推測に過ぎない ので、実際にそうであるかどうかは、確率を計算 するなどの方法で 検証する必要 があります。そこで、ai1
が 〇 を担当 する場合に、ai2
が 2 手連続 で 上の行 に 着手を行わない 確率の求めることにします。
ai1
が 〇 を担当 する場合に、ai2
が 2 手連続 で 上の行 に 着手を行わない 確率
ai2
が 1、2 手目 に 一行目に着手しない ことによって、ai1
が勝利 する 確率 は、下記の手順 で 求める ことができます。
-
ai2
の 初手 で 空いているマスの数 は 8 マス、2 手目 では 6 マス なので、1, 2 手目 でai2
が 着手 を行うことができる マスの組み合わせ は $8 * 6 =$ 48 通り -
ai1
は 初手 で 必ず (0, 0) のマスに 着手する ので、ai2
の 初手 で 1 行目以外 で 空いているマス は 6 マス である -
ai2
が 初手 で 一番上の行 に 着手しない 場合にai2
は 2 手目 で 必ず (1, 0) のマスに 着手 するので、ai2
の 2 手目 で 1 行目以外 で 空いているマス は 5 マス である - 従って、
ai2
が 初手と 2 手目 で 1 行目以外 に 着手 を行うことができる マスの組み合わせ は $6 * 5 = $ 30 通り - 従って、
ai2
が 初手と 2 手目 で 1 行目以外 に 着手 を行ってai1
が勝利 する 確率 は、$30 / 48 = $ 62.5 % である
ai1
は 上記以外 でも 勝利する場合がある ので、実際の勝率 は 62.5 % 以上 になります。
下記の 考察 は、数学的 には 厳密でない 考察ですが、ai1
という AI は特に 重要な AI では ありません ので、ai1
が ai2
に対して相性が良いということを、厳密に証明 する 必要はない と思います。従って、下記のような雑な考察で 十分 でしょう。
この結果から、〇 を ai1
が担当 した場合は、先程の、ai1
は ai2
に対して 相性が良い アルゴリズムであるという 推測 が 正しい ことが 裏付けられ ました。本当は、他の場合 で ai1
が勝利する確率や、ai1
が × を担当 した場合の勝利の確率などを 計算したほうが良い のですが、先ほどの、人間相手 であれば ai2
のほうが ai1
よりも 強い という 考察 と、ai1
と ai2
の対戦 では ai1
のほうが ai2
よりも 強い という 結果 から、ai1
の アルゴリズム は、ai2
の アルゴリズム に対して 相性が良い と 結論付けても問題はない でしょう。
本記事では計算しませんが、興味と余裕がある方は、他の場合 で ai1
が 勝利する確率 や、ai1
が × を担当 した場合の 勝利の確率 などを計算してみて下さい。
今回の 記事の最初 で、じゃんけんの AI の例を使って「基準 となる AI が 偏った性質を持つ 場合に、正しい評価 が 行えない」ことを説明しましたが、〇×ゲームの AI の場合でも、ai1
と ai2
の例で そのことが説明 できます。ai1
を 基準となる AI に しなかった のは、ランダム性がないだけではなく、偏った性質 を持つ AI だからです。
今回の記事のまとめ
今回の記事では、以下のような、AI の強さの評価 を行う 方法 を決め、その評価を行うための ai_match
という 関数を定義 しました。ただし、この関数は本格的な AI の評価を行うためには いくつかの問題 があるので、次回の記事では ai_match
を改良 することにします。
-
対戦の回数
- AI どうしの 対戦の回数 は、原則 として 一万回 行う
- 一万回の対戦に 時間がかかりすぎる場合 は、1 分で行える回数 の対戦を行う
-
対戦の方法
新しい AI を作成 した場合は、下記の AI と対戦を行う- ランダムな着手を行う
ai2
- それまでに作成 した AI の中で 最も強い AI と思われるうちの いくつか
- ランダムな着手を行う
本記事で入力したプログラム
以下のリンクから、本記事で入力して実行した JupyterLab のファイルを見ることができます。
以下のリンクは、今回の記事で更新した marubatsu.py です。
以下のリンクは、今回の記事で更新した ai.py です。なお、ai_match
はこちらに保存しました。
次回の記事