まずはリバーシの初歩的なAIを作る!
前回の記事
はじめに
皆さんお久しぶりです!はじめましての方は初めまして!りみころと申します
最近は大学やら私生活やらが忙しくてプログラムは書いても記事を書く時間が無くて久々の投稿になりました
前回の記事で作成したリバーシの盤面を用いて実際にAI対人だったりAI対AIをするためにAIの制作に取り掛かります!
と、言ってもいきなり高度なことはせず、まずはランダムAI(AIと呼んでよいかわからん。でもここでは自発的にランダムだけど手を選ぶということでAIとします)とモンテカルロ法を用いたルールベースのAIの導入を簡単に説明したいと思います。
手法の説明はしますが作成して導入するのがメインになるのですごく詳しい説明などはしないことをご理解ください。
環境
- windows10
- python3.10.4
ファイル構成
- app.py
- logic.py
- ai
- __init__.py
- random_ai.py
- montecarlo_ai
- human.py
前回から試行錯誤した結果このようなファイル構成になりました
tkinter周りを動かすapp.py, AIも使いたい関数を詰め込んだlogic.py, aiと人の動作をaiフォルダにまとめて、__init__.pyではapp.pyでのインポートが楽になるように入れています。
ここで実際のコードを交えて説明する前に、オセロAIにおけるモンテカルロ法について説明します
モンテカルロ法
モンテカルロ法を用いるオセロAIの手番が来た時に、現在の盤面から全盤面が埋まるまでそれぞれ置いていく行為をシミュレーションします
このとき、自分も相手も置く場所は現在置ける場所(合法手)からランダムで一つ選ぶようにします
このシミュレーションを複数回繰り返すという行為を現在の盤面における合法手の分だけ繰り返し、その中で一番勝率が高い手を選ぶ手法になっています
実装
まずは前回のapp.pyを一部logic.pyとして切り出し、一部修正をしたのでその部分です
from ai import *
from tkinter import *
from tkinter import messagebox, simpledialog, ttk
import numpy as np
from logic import judge, put_logic
class App(Frame):
def __init__(self, master):
super().__init__(master)
self.pack()
self.master.geometry("400x400")
self.master.title("reversi")
self.master.resizable(False, False)
self.master.withdraw()
# 盤面
self.board = [[0]*8 for _ in range(8)]
self.board[3][4] = 1
self.board[4][3] = 1
self.board[3][3] = -1
self.board[4][4] = -1
self.turn = 1 # 現在の手番
# 実装済みの手法
self.value = {"human": Human(),
"random": RandomAI(),
"greedy": GreedyAI(),
"montecarlo": MonteCarloAI(),
}
self.player = {1 : None, -1 : None}
self.create_widget()
self.setting()
# ウィジェットの配置
def create_widget(self):
# キャンバス作成
self.canvas = Canvas(self.master, height=400, width=400, bg="green")
self.canvas.pack()
# 線引き
for line in range(1,8,1):
self.canvas.create_line(50*line, 0, 50*line, 400)
self.canvas.create_line(0, 50*line, 400, 50*line)
# 初期石配置
self.canvas.create_oval(150+3,150+3,200-3,200-3, fill="white", outline="white", tag="stone_3_3")
self.canvas.create_oval(200+3,200+3,250-3,250-3, fill="white", outline="white", tag="stone_4_4")
self.canvas.create_oval(200+3,150+3,250-3,200-3, fill="black", tag="stone_3_4")
self.canvas.create_oval(150+3,200+3,200-3,250-3, fill="black", tag="stone_4_3")
self.canvas.update()
# ターン制御
def execute_turn(self):
self.result = judge(self.board, self.turn)
if not self.result: # パス
self.turn *= -1
self.result = judge(self.board, self.turn)
if not self.result: # お互いにパス(ゲーム終了)
self.board = np.array(self.board)
white = np.count_nonzero(self.board == -1)
black = np.count_nonzero(self.board == 1)
if white < black:
text = "先手の勝利"
elif black < white:
text = "後手の勝利"
else:
text = "引き分け"
messagebox.showinfo(title="ゲーム結果", message=f"{text}\n黒石({self.player[1].name}) : {black}個\n白石({self.player[-1].name}) : {white}個")
self.master.destroy()
return
self.show_move()
if isinstance(self.player[self.turn], Human):
self.canvas.bind("<Button>", self.click)
else:
self.after(500)
y, x = self.player[self.turn].get_move(self.board, self.result, self.turn)
self.canvas.delete("legal_move")
self.put(y, x)
self.turn *= -1
self.execute_turn()
# 設定画面を閉じる
def setting_destroy(self, combo, func):
# 黒石
self.player[1] = self.value[combo[0]]
if combo[0] == "human":
self.player[1].name = simpledialog.askstring(" ", "あなたは黒石です\nニックネームを決めてください")
# 白石
self.player[-1] = self.value[combo[1]]
if combo[1] == "human":
self.player[-1].name = simpledialog.askstring(" ", "あなたは白石です\nニックネームを決めてください")
func()
self.master.deiconify()
self.execute_turn()
# 設定
def setting(self):
setting_frame = Toplevel(self.master)
setting_frame.geometry("175x100")
setting_frame.title("設定")
setting_frame.resizable(False, False)
setting_frame.attributes("-toolwindow", 1)
setting_frame.grab_set()
setting_frame.focus_set()
setting_frame.protocol("WM_DELETE_WINDOW", lambda: self.setting_destroy([combo1.get(), combo2.get()],setting_frame.destroy))
label1 = Label(setting_frame, text="先手(黒石)")
label1.place(x=10, y=10)
label2 = Label(setting_frame, text="後手(白石)")
label2.place(x=10, y=40)
combo1 = ttk.Combobox(setting_frame, values=list(self.value.keys()), width=10, state="readonly")
combo1.set(list(self.value.keys())[0])
combo1.place(x=80, y=10)
combo2 = ttk.Combobox(setting_frame, values=list(self.value.keys()), width=10, state="readonly")
combo2.set(list(self.value.keys())[0])
combo2.place(x=80, y=40)
button = Button(setting_frame, text="ゲームを始める", command=lambda: self.setting_destroy([combo1.get(), combo2.get()],setting_frame.destroy))
button.place(x=90, y=70)
# クリック
def click(self, event):
x = event.x//50
y = event.y//50
if [y, x] not in self.result:
return
self.canvas.unbind("<Button>")
self.canvas.delete("legal_move")
self.put(y, x)
self.turn *= -1
self.execute_turn()
# 石を描画
def draw_stone(self, y, x, turn):
tag = f"stone_{y}_{x}"
color = "black" if turn == 1 else "white"
self.canvas.create_oval(x*50+3,y*50+3,(x+1)*50-3,(y+1)*50-3, fill=color, outline=color, tag=tag)
# 石を置く
def put(self, y, x):
self.board, flipped = put_logic(self.board, y, x, self.turn)
self.draw_stone(y, x, self.turn)
for fy, fx in flipped:
self.canvas.delete(f"stone_{fy}_{fx}")
self.draw_stone(fy, fx, self.turn)
# 置ける手を表示
def show_move(self):
for b in self.result:
color = "black" if self.turn == 1 else "white"
self.canvas.create_oval(b[1]*50+20,b[0]*50+20,(b[1]+1)*50-20,(b[0]+1)*50-20, fill=color, outline=color, tag="legal_move")
self.canvas.update()
if __name__ == "__main__":
root = Tk()
app = App(root)
app.mainloop()
こちらがapp.pyの全コードになります
一部変更はありますが、前回のコードとさほど変わらないので特に説明はしません
一つ説明するなら、新しく追加された関数settingではどちらの石を誰が操作するのかなどの初期設定を行うためのものです
次にlogic.pyです
import numpy as np
# 自石が置ける場所を判断する
def judge(board, turn):
move = [(1,0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
return_board = []
for row_board in range(8):
for col_board in range(8):
if board[row_board][col_board] != 0:
continue
# 8方向確認
for vec in move:
y = row_board + vec[0]
x = col_board + vec[1]
# 盤面を外れたらやり直し
if not (0 <= y <= 7 and 0 <= x <= 7):
continue
# 値が0ならやり直し
if board[y][x] == 0:
continue
# 値が自分の石ならやり直し
if board[y][x] == turn:
continue
while True:
y += vec[0]
x += vec[1]
if not (0 <= y <= 7 and 0 <= x <= 7):
break
elif board[y][x] == 0:
break
# 値が自分の石の時
if board[y][x] == turn:
return_board.append([row_board, col_board])
break
if [row_board, col_board] in return_board:
break
return return_board
# ひっくり返す場所を判断
def put_logic(board, y, x, turn):
board[y][x] = turn
flipped = []
move = [(1,0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
for vec in move:
put_stone = []
new_y = y + vec[0]
new_x = x + vec[1]
while 0 <= new_y <= 7 and 0 <= new_x <= 7:
if board[new_y][new_x] == 0:
break
if board[new_y][new_x] == turn:
for stone in put_stone:
board[stone[0]][stone[1]] = turn
flipped.extend(put_stone)
break
put_stone.append([new_y, new_x])
new_y += vec[0]
new_x += vec[1]
return board, flipped
# 盤面をコピーして返す
def board_copy(board):
return np.array(board, copy=True)
簡単にコメント文が打ってあるように、自分の石が置けるかどうかを判断するための関数judgeと、石が置かれた際にどこがひっくり返るのかを判断するput_logicと、現在の盤面をコピーするboard_copyになります。
judgeは前回のコードとほとんど変わらないと思います
put_logicは前回は判断してから実際に石を置く(画面に表示する)所まで担っていましたが、その部分をapp.pyとlogic.pyに分けてこちらでは置く場所を判断するapp.pyでは指定個所に石を置くというようにtkinterを軸に関数を切り分けています。
では次にAIの基底クラスの説明になります
このクラスは基底クラスまたは親クラスと言ってこれから作るAIクラスや人クラスを作成するときに継承するクラスとなります。
このクラスを作ることでクラスを作成する際にすべて同じ書き方ができるのでapp.pyでも使うときに統一した書き方ができ、コードをすっきりできるのでお勧めです。
# 基底クラス
class BasePlayer:
def __init__(self):
self.name = "player name"
def get_move(self, board, legal_move, turn):
raise NotImplementedError
このクラスを継承した人クラスとrandomクラスとmontecarloクラスについて説明します
from .base import BasePlayer
class Human(BasePlayer):
def __init__(self, name = "human"):
super().__init__()
self.name = name
def get_move(self, board, legal_move, turn):
raise NotImplementedError
このhumanクラスでは基底クラスほとんど変わりません。
app.pyを見るとわかるのですが、手を打つ段階でクラスがhumanであった場合は既にある関数を用いて手を打つのでクラスは作っていますが、このクラス内ではなにもしません。
次にrandomクラスです
from random import choice
from .base import BasePlayer
# ランダムに行動
class RandomAI(BasePlayer):
def __init__(self):
super().__init__()
self.name = "random AI"
def get_move(self, board, legal_move, turn):
return choice(legal_move)
AIは基本この構造になっていて、get_moveをapp.pyから引数を渡し呼び出すことで行動をしています。
ランダムに関しては、python標準ライブラリのrandomのchoiceをインポートして、この関数を使用しています。
choiceには合法手がリストになったものを引数として渡し、その中から一つランダムに選んでもらうようにしています
次に、montecarloクラスです
from logic import board_copy, put_logic, judge
from .base import BasePlayer
from random import choice
import numpy as np
# モンテカルロ法
class MonteCarloAI(BasePlayer):
def __init__(self):
super().__init__()
self.name = "montecarlo AI"
def get_move(self, board, legal_move, turn):
n = 50
return_dic = {}
now_turn = turn
for y, x in legal_move:
return_dic[y ,x] = 0
for _ in range(n):
# 引数のboardをコピー(numpy配列)
start_board = board_copy(board)
# turnの初期化
turn = now_turn
# start_boardの更新
start_board, _ = put_logic(start_board, y, x, turn)
# ターンを返す
turn *= -1
# ランダム全探索
while True:
# 合法手の2次元リストを返す
result = judge(start_board, turn)
if not result:# 空ならパス判定
turn *= -1
if not judge(start_board, turn): # 2連続パスでゲーム終了
mine = np.count_nonzero(start_board == now_turn)
yours = np.count_nonzero(start_board == -now_turn)
if yours < mine: # 石の数を判定
return_dic[y, x] += mine - yours # 勝っていたら差分を追加
break
continue
# start_boardの更新
start_board, _ = put_logic(start_board, *choice(result), turn)
# ターンを返す
turn *= -1
# 一番評価が高い合法手を返す
return max(return_dic, key=return_dic.get)
モンテカルロクラスではget_moveの中でモンテカルロ法を実装しています
モンテカルロのシミュレーション回数を50回としています
コメント文をかなり書いたつもりなのでそちらを読んでいただければわかるかなと思います
簡単に説明を入れるとfor文の中で前回のコードのように現在の盤面から最後までをシミュレーションして最後に自分の石の数を数えて一番多い合法手を選ぶようにしています
ここまでが今回のコードの説明になります
新しくAIを追加したいときにはaiフォルダにbase.pyのクラスを継承したクラスを作ればすぐにでも追加できます!
ここまでで基盤が作り終わったのでこれからは基本改善点が見つかるまではAI作成だけでOK!
コード全文
from ai import *
from tkinter import *
from tkinter import messagebox, simpledialog, ttk
import numpy as np
from logic import judge, put_logic
class App(Frame):
def __init__(self, master):
super().__init__(master)
self.pack()
self.master.geometry("400x400")
self.master.title("reversi")
self.master.resizable(False, False)
self.master.withdraw()
# 盤面
self.board = [[0]*8 for _ in range(8)]
self.board[3][4] = 1
self.board[4][3] = 1
self.board[3][3] = -1
self.board[4][4] = -1
self.turn = 1 # 現在の手番
# 実装済みの手法
self.value = {"human": Human(),
"random": RandomAI(),
"greedy": GreedyAI(),
"montecarlo": MonteCarloAI(),
}
self.player = {1 : None, -1 : None}
self.create_widget()
self.setting()
# ウィジェットの配置
def create_widget(self):
# キャンバス作成
self.canvas = Canvas(self.master, height=400, width=400, bg="green")
self.canvas.pack()
# 線引き
for line in range(1,8,1):
self.canvas.create_line(50*line, 0, 50*line, 400)
self.canvas.create_line(0, 50*line, 400, 50*line)
# 初期石配置
self.canvas.create_oval(150+3,150+3,200-3,200-3, fill="white", outline="white", tag="stone_3_3")
self.canvas.create_oval(200+3,200+3,250-3,250-3, fill="white", outline="white", tag="stone_4_4")
self.canvas.create_oval(200+3,150+3,250-3,200-3, fill="black", tag="stone_3_4")
self.canvas.create_oval(150+3,200+3,200-3,250-3, fill="black", tag="stone_4_3")
self.canvas.update()
# ターン制御
def execute_turn(self):
self.result = judge(self.board, self.turn)
if not self.result: # パス
self.turn *= -1
self.result = judge(self.board, self.turn)
if not self.result: # お互いにパス(ゲーム終了)
self.board = np.array(self.board)
white = np.count_nonzero(self.board == -1)
black = np.count_nonzero(self.board == 1)
if white < black:
text = "先手の勝利"
elif black < white:
text = "後手の勝利"
else:
text = "引き分け"
messagebox.showinfo(title="ゲーム結果", message=f"{text}\n黒石({self.player[1].name}) : {black}個\n白石({self.player[-1].name}) : {white}個")
self.master.destroy()
return
self.show_move()
if isinstance(self.player[self.turn], Human):
self.canvas.bind("<Button>", self.click)
else:
self.after(500)
y, x = self.player[self.turn].get_move(self.board, self.result, self.turn)
self.canvas.delete("legal_move")
self.put(y, x)
self.turn *= -1
self.execute_turn()
# 設定画面を閉じる
def setting_destroy(self, combo, func):
# 黒石
self.player[1] = self.value[combo[0]]
if combo[0] == "human":
self.player[1].name = simpledialog.askstring(" ", "あなたは黒石です\nニックネームを決めてください")
# 白石
self.player[-1] = self.value[combo[1]]
if combo[1] == "human":
self.player[-1].name = simpledialog.askstring(" ", "あなたは白石です\nニックネームを決めてください")
func()
self.master.deiconify()
self.execute_turn()
# 設定
def setting(self):
setting_frame = Toplevel(self.master)
setting_frame.geometry("175x100")
setting_frame.title("設定")
setting_frame.resizable(False, False)
setting_frame.attributes("-toolwindow", 1)
setting_frame.grab_set()
setting_frame.focus_set()
setting_frame.protocol("WM_DELETE_WINDOW", lambda: self.setting_destroy([combo1.get(), combo2.get()],setting_frame.destroy))
label1 = Label(setting_frame, text="先手(黒石)")
label1.place(x=10, y=10)
label2 = Label(setting_frame, text="後手(白石)")
label2.place(x=10, y=40)
combo1 = ttk.Combobox(setting_frame, values=list(self.value.keys()), width=10, state="readonly")
combo1.set(list(self.value.keys())[0])
combo1.place(x=80, y=10)
combo2 = ttk.Combobox(setting_frame, values=list(self.value.keys()), width=10, state="readonly")
combo2.set(list(self.value.keys())[0])
combo2.place(x=80, y=40)
button = Button(setting_frame, text="ゲームを始める", command=lambda: self.setting_destroy([combo1.get(), combo2.get()],setting_frame.destroy))
button.place(x=90, y=70)
# クリック
def click(self, event):
x = event.x//50
y = event.y//50
if [y, x] not in self.result:
return
self.canvas.unbind("<Button>")
self.canvas.delete("legal_move")
self.put(y, x)
self.turn *= -1
self.execute_turn()
# 石を描画
def draw_stone(self, y, x, turn):
tag = f"stone_{y}_{x}"
color = "black" if turn == 1 else "white"
self.canvas.create_oval(x*50+3,y*50+3,(x+1)*50-3,(y+1)*50-3, fill=color, outline=color, tag=tag)
# 石を置く
def put(self, y, x):
self.board, flipped = put_logic(self.board, y, x, self.turn)
self.draw_stone(y, x, self.turn)
for fy, fx in flipped:
self.canvas.delete(f"stone_{fy}_{fx}")
self.draw_stone(fy, fx, self.turn)
# 置ける手を表示
def show_move(self):
for b in self.result:
color = "black" if self.turn == 1 else "white"
self.canvas.create_oval(b[1]*50+20,b[0]*50+20,(b[1]+1)*50-20,(b[0]+1)*50-20, fill=color, outline=color, tag="legal_move")
self.canvas.update()
if __name__ == "__main__":
root = Tk()
app = App(root)
app.mainloop()
import numpy as np
# 自石が置ける場所を判断する
def judge(board, turn):
move = [(1,0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
return_board = []
for row_board in range(8):
for col_board in range(8):
if board[row_board][col_board] != 0:
continue
# 8方向確認
for vec in move:
y = row_board + vec[0]
x = col_board + vec[1]
# 盤面を外れたらやり直し
if not (0 <= y <= 7 and 0 <= x <= 7):
continue
# 値が0ならやり直し
if board[y][x] == 0:
continue
# 値が自分の石ならやり直し
if board[y][x] == turn:
continue
while True:
y += vec[0]
x += vec[1]
if not (0 <= y <= 7 and 0 <= x <= 7):
break
elif board[y][x] == 0:
break
# 値が自分の石の時
if board[y][x] == turn:
return_board.append([row_board, col_board])
break
if [row_board, col_board] in return_board:
break
return return_board
# ひっくり返す場所を判断
def put_logic(board, y, x, turn):
board[y][x] = turn
flipped = []
move = [(1,0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
for vec in move:
put_stone = []
new_y = y + vec[0]
new_x = x + vec[1]
while 0 <= new_y <= 7 and 0 <= new_x <= 7:
if board[new_y][new_x] == 0:
break
if board[new_y][new_x] == turn:
for stone in put_stone:
board[stone[0]][stone[1]] = turn
flipped.extend(put_stone)
break
put_stone.append([new_y, new_x])
new_y += vec[0]
new_x += vec[1]
return board, flipped
# 盤面をコピーして返す
def board_copy(board):
return np.array(board, copy=True)
# 基底クラス
class BasePlayer:
def __init__(self):
self.name = "player name"
def get_move(self, board, legal_move, turn):
raise NotImplementedError
from .base import BasePlayer
class Human(BasePlayer):
def __init__(self, name = "human"):
super().__init__()
self.name = name
def get_move(self, board, legal_move, turn):
raise NotImplementedError
from random import choice
from .base import BasePlayer
# ランダムに行動
class RandomAI(BasePlayer):
def __init__(self):
super().__init__()
self.name = "random AI"
def get_move(self, board, legal_move, turn):
return choice(legal_move)
from logic import board_copy, put_logic, judge
from .base import BasePlayer
from random import choice
import numpy as np
# モンテカルロ法
class MonteCarloAI(BasePlayer):
def __init__(self):
super().__init__()
self.name = "montecarlo AI"
def get_move(self, board, legal_move, turn):
n = 50
return_dic = {}
now_turn = turn
for y, x in legal_move:
return_dic[y ,x] = 0
for _ in range(n):
# 引数のboardをコピー(numpy配列)
start_board = board_copy(board)
# turnの初期化
turn = now_turn
# start_boardの更新
start_board, _ = put_logic(start_board, y, x, turn)
# ターンを返す
turn *= -1
# ランダム全探索
while True:
# 合法手の2次元リストを返す
result = judge(start_board, turn)
if not result:# 空ならパス判定
turn *= -1
if not judge(start_board, turn): # 2連続パスでゲーム終了
mine = np.count_nonzero(start_board == now_turn)
yours = np.count_nonzero(start_board == -now_turn)
if yours < mine: # 石の数を判定
return_dic[y, x] += mine - yours # 勝っていたら差分を追加
break
continue
# start_boardの更新
start_board, _ = put_logic(start_board, *choice(result), turn)
# ターンを返す
turn *= -1
# 一番評価が高い合法手を返す
return max(return_dic, key=return_dic.get)
# human
from .human import Human
# ai
from .random_ai import RandomAI
from .greedy_ai import GreedyAI
from .montecarlo_ai import MonteCarloAI
バージョン1.0.0です
さいごに
今回は前回のコードを少し変更してその後二つのAIを実装してみました
このgithubにもコードを上げているのでよかったらダウンロードして実際にランダムやモンテカルロ法と戦ってみてください
もしプログラミングができる人なら、いくつか自作でAIを作って追加してみるのも面白いかもしれないですね
次回ではminimax法やalphabeta法などを新たなAIとして作成していこうと思うので記事が上がった時よかったらそちらも確認お願いします!
Discussion