(52)【Othello AI】古典的AI モンテカルロ探索で CvR 9900に到達

投稿者: | 2025年5月28日

745 views

この記事は最終更新から 386日 が経過しています。

【1】やりたいこと

DQNを使ったオセロAI作りを続けているが、ランダム指し手相手に勝率 90%で足踏み状態…

ルールが明確な問題に対しては、
第一次AIブーム(1960年前後)の探索・推論技術が強い

と聞いたので、気分転換にこれを作って動かしてみたい。

今回試すのは モンテカルロ法 だ。
モンテカルロ法とは、今から先の手を何通りもシミュレーション実行(=プレイアウト)し、
その結果の中から統計的に良さげな手を選ぶ手法のこと。

つまり…
自分の手番になったら、
そこから先の指し手を何通りもシミュレーションし、
その過程で打った指し手で木構造を作り、
最終的に数百回、数千回のシミュレーション結果の中から、
勝利に到達する可能性が一番高い一手を選ぶ

という戦術だ。

【2】やってみた

1) 設計

後日追記

2) 実装

後日追記

3) CvR 10000計測結果

Iterationsは、推論時のシミュレーション試行回数(=プレイアウト数 : # of playout)のこと。
Iterations=100であれば、一手を選ぶたびに、100通りのシミュレーションを行うということ。
シミュレーションでは指し手をランダムに選び、その結果が勝利であれば「そのルートは勝てる可能性あり」と重みづけをする。

(1) Iterations = 100

一手を打つたびに、100通りのシミュレーションを実行する。
試行した 100手の中から、勝利する可能性が高い最良の一手を選択する。

$ python main_CvR.py -p MCTS -n 10000 --mi 100 --mp 1
対戦数: 10000
AI 勝利数      : 9665
ランダム 勝利数: 45
引き分け       : 290
AI(A) 勝率     : 99.54%
[1] 4919.437496 sec : END
Total elapsed time: 4919.437496 sec

(2) Iterations = 200

一手を打つたびに、200通りのシミュレーションを実行する。
試行した 200手の中から、勝利する可能性が高い最良の一手を選択する。

$ python main_CvR.py -p MCTS -n 10000 --mi 200 --mp 1
対戦数: 10000
AI 勝利数      : 9832
ランダム 勝利数: 12
引き分け       : 156
AI(A) 勝率     : 99.88%
[1] 9299.509499 sec : END
Total elapsed time: 9299.509499 sec

(3) Iterations = 300

一手を打つたびに、300通りのシミュレーションを実行する。
試行した 300手の中から、勝利する可能性が高い最良の一手を選択する。

$ python main_CvR.py -p MCTS -n 10000 --mi 300 --mp 1
対戦数: 10000
AI 勝利数      : 9850
ランダム 勝利数: 6
引き分け       : 144
AI(A) 勝率     : 99.94%
[1] 13568.940375 sec : END
Total elapsed time: 13568.940375 sec

(4) Iterations = 500

一手を打つたびに、500通りのシミュレーションを実行する。
試行した 500手の中から、勝利する可能性が高い最良の一手を選択する。

なんと CvR 9908 に到達した!
CvR : 勝手に作った指標で、Random指し手を相手に 10000戦して何勝するかを表す値
9908勝 3敗 89引分 勝率 99.97%

$ python main_CvR.py -p MCTS -n 10000 --mi 500 --mp 1
対戦数: 10000
AI 勝利数      : 9908
ランダム 勝利数: 3
引き分け       : 89
AI(A) 勝率     : 99.97%
[1] 21839.737502 sec : END
Total elapsed time: 21839.737502 sec

(5) Iterations = 1000~

Iteration=500の時点で、処理時間は 21839秒(≒6時間)も要した。
家庭用パソコンを 6時間超も高負荷状態にしていると、電源、マザボ、グラボ、CPU, SSD, その他諸々のパーツが傷み始めそうで怖い…

現時点で CvR 9908まで到達した。
頑張ってこれを 9950, 9960 にするためにパソコンを傷める必要は無いと判断する。

よって、Iterations ≧ 1000 は今は計測しないことにする。

4) 他モデルとの対戦結果

CvR 10000の計測結果から予想はしていたが・・・
ダントツでチャンピオンの座を奪取した!

対戦時間を短くしたかったので、Iterations=100(プレイアウト数=100回)で参戦した。
全185モデルの総当たり2回戦(先手、後手)での対戦成績は、321勝 20敗 27引分 勝率 94.1%

【3】人間(私)と対戦

過去二回、作成済みモデル総当たり戦のチャンピオンと対戦したが、こんな結果だった・・・

もしかして俺ってオセロ強いのか?
と勘違いしてしまうような結果だった。

今回はこれが勘違いであった現実が突き付けられた・・・

vs Iterations=5000

vs Iterations=1000

恥を覚悟で正直に書くと
Iterations > 500 の相手にはほとんど勝てない・・・

強い、強すぎる、化け物だ、モンテカルロ・・・

1960年代の技術だなんて軽視して申し訳ございませんでした。。。

【4】今後の方針

今回作ったモンテカルロ探索モデル(MCTS)は、あくまでも息抜きで作成したもの。

目標は Deep Q Networkを使った強化学習で CvR 9500超えのオセロAIを作ること!
なので、今回作った モンテカルロ探索AIは強化学習の良き対戦相手 になってもらおう。

モンテカルロ探索は、順伝播一発で結果が出るニューラルネットと比較して、推論に多くの時間を要する。
推論時のシミュレーション回数(=プレイアウト数)に依存して処理時間が大きく増える。

よって、より強い MCTSエージェントを相手にしようと思えば、処理の高速化が必須になる。

このため、シミュレーション実行をマルチスレッド、マルチプロセスによる並列実行で高速化 する必要がある。ただし Pythonにはマルチスレッド並列実行が出来ない GIL問題がある・・・

強化学習の相手になるということは、数千試合、数万試合の相手をすることになるので、1試合で 1秒縮まるだけでも数時間の差が出るのだ。

【5】所感

2010年以降に進歩した Deep learning 技術で作った AIが、
1960年代の技術で作ったモンテカルロ法を使った AIにまったく太刀打ちできていない。
 ↓
Deep learningをうまく使って成果を出すには、それなりの知識と閃きが必要だ。

チートになってしまうが、そろそろ論文サルベージを始めるか・・・

いや、もう少し自力で頑張ってみよう。
解答を見てしまったら楽しさ半減・・・

付録:

(A) 人 vs COM : コンソール対戦プログラム

↓ 対戦出来ます。
あまり強いのはサーバー負荷が上がって BANされるかもしれないので、iteration=100, 200を公開いたします。
100 playouts : CvT 9665 オセロ歴10日レベル?
200 playouts : CvT 9832 オセロ歴30日レベル?

Webブラウザ対戦オセロも、ラッパーを介してこのプログラムの MCTSPlayerをそのまま動かしています。

実行例: playout 200回指定で実行する。

$ python main.py -i 200

こちら → ChatGPTにこのプログラムを解説してもらいました。
※なので… 私のコメントは邪魔になりそうなので消しました。

import othello_board
from othello_board import OthelloBoardEnv
from MCTSPlayer import MCTSPlayer
import argparse

BOARD_SIZE = 6

class HumanPlayer:
    def select_move(self, env):
        env.display()
        legal = env.legal_moves()
        while True:
            move = input(f"合法手 {legal} から選んでください r c: ")
            try:
                r,c = map(int, move.split())
                if (r,c) in legal:
                    return (r,c)
            except:
                pass
            print("不正な入力。もう一度。")

def play_game( player1, player2, boardSize ):
    env = OthelloBoardEnv( boardSize=boardSize )
    while not env.game_over():
        if env.current_player==1:
            move = player1.select_move(env)
        else:
            move = player2.select_move(env)
        env.step(move)
    env.display()
    black_count = sum(row.count(1)  for row in env.board)
    white_count = sum(row.count(-1) for row in env.board)
    print(f"黒({othello_board.BLACK}): {black_count} 石, 白({othello_board.WHITE}): {white_count} 石")
    w = env.winner()
    print("結果:", "黒({othello_board.BLACK})勝ち" if w==1 else "白({othello_board.WHITE})勝ち" if w==-1 else "引き分け")

if __name__=="__main__":
    parser = argparse.ArgumentParser(description="オセロAI対戦")
    parser.add_argument("-i", type=int,   default=1000, help="プレイアウト数")
    parser.add_argument("-c", type=float, default=1.4,  help="探索-活用バランスパラメータ")
    args = parser.parse_args()
    print(f"オセロ MCTS Lv.{args.i} と対戦")
    p1 = MCTSPlayer(boardSize=BOARD_SIZE, iterations=args.i)
    p2 = HumanPlayer()
    play_game(p1, p2,boardSize=BOARD_SIZE)
import random
import math

class Node:
    def __init__( self, state, parent=None, move=None ):
        self.state    = state
        self.parent   = parent
        self.move     = move
        self.children = []
        self.visits   = 0
        self.wins     = 0
        self.lg_moves = self.state.legal_moves()

    def is_fully_expanded( self ):
        return len(self.children) == len(self.lg_moves)

    def best_child( self, c_param ):
        choices = []
        for child in self.children:
            win_rate = child.wins / child.visits
            explore_bonus = c_param * math.sqrt(math.log(self.visits) / child.visits)
            uct_value     = win_rate + explore_bonus
            choices.append((uct_value, child))
        max_choice = max(choices, key=lambda x: x[0])
        best_choice = max_choice[1]
        return best_choice

class MCTSPlayerSingle:
    def __init__(self, iterations, boardSize, c_param, max_depth):
        self.iterations = iterations
        self.boardSize  = boardSize
        self.c_param    = c_param
        self.max_depth = max_depth
        self.corners = [(0,0),(0,self.boardSize-1),(self.boardSize-1,0),(self.boardSize-1,self.boardSize-1)]

    def heuristic_playout(self, state, moves):
        for corner in self.corners:
            if corner in moves:
                return corner
        return random.choice(moves)

    def simulate(self, env):
        root = Node(env.clone())
        for _ in range(self.iterations):
            node = root
            while node.children and node.is_fully_expanded():
                node = node.best_child(c_param=self.c_param)
            legal_moves = node.state.legal_moves()
            tried_moves = [child.move for child in node.children]
            untried_moves = [m for m in legal_moves if m not in tried_moves]
            if untried_moves:
                move = random.choice(untried_moves)
                new_state = node.state.clone()
                new_state.step(move)
                node = Node(new_state, parent=node, move=move)
                node.parent.children.append(node)
            sim_state = node.state.clone()
            depth = 0
            while not sim_state.game_over() and depth < self.max_depth:
                moves = sim_state.legal_moves()
                if moves:
                    move = self.heuristic_playout(sim_state, moves)
                    sim_state.step(move)
                else:
                    sim_state.switch_player()
                depth += 1
            winner = sim_state.winner()
            while node:
                node.visits += 1
                if node.state.current_player != winner:
                    node.wins += 1
                node = node.parent
        return [(child.move, child.visits, child.wins) for child in root.children]

class MCTSPlayer:
    def __init__(self, boardSize, iterations=300, c_param=1.4, max_depth=30):
        self.boardSize  = boardSize
        self.iterations = iterations
        self.c_param    = c_param
        self.max_depth  = max_depth

    def select_move(self, env):
        player = MCTSPlayerSingle(self.iterations, self.boardSize, self.c_param, self.max_depth)
        children = player.simulate(env)
        if len(children) > 0:
            max_node  = max(children, key=lambda node: node[1])
            best_move = max_node[0]
            return best_move
        else:
            return random.choice(env.legal_moves())
import copy

EMPTY, BLACK, WHITE = '.', '○', '●'

class OthelloBoardEnv:
    def __init__(self, boardSize, state=None):
        self.boardSize = boardSize
        self.dirs  = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)]
        if state is not None:
            self.board = state
        else:
            self.board = [[0]*self.boardSize for _ in range(self.boardSize)]
            mid = self.boardSize // 2
            self.board[mid-1][mid-1], self.board[mid][mid]   = (-1, -1)
            self.board[mid-1][mid],   self.board[mid][mid-1] = ( 1,  1)
        self.current_player = 1

    def clone(self):
        env = OthelloBoardEnv(self.boardSize)
        env.board          = copy.deepcopy(self.board)
        env.current_player = self.current_player
        return env

    def legal_moves(self):
        moves = []
        for r in range(self.boardSize):
            for c in range(self.boardSize):
                if self.board[r][c]==0 and self.can_flip(r,c):
                    moves.append((r,c))
        return moves

    def can_flip(self, r, c):
        for dr, dc in self.dirs:
            nr, nc = (r+dr, c+dc)
            found = False
            while (0 <= nr < self.boardSize) and (0 <= nc < self.boardSize) and (self.board[nr][nc] == -self.current_player):
                nr += dr
                nc += dc
                found = True
            if found and (0 <= nr < self.boardSize) and (0 <= nc < self.boardSize) and (self.board[nr][nc] == self.current_player):
                return True
        return False

    def step(self, move):
        r, c = move
        self.board[r][c] = self.current_player
        for dr, dc in self.dirs:
            stones = []
            nr, nc = (r+dr, c+dc)
            while (0 <= nr < self.boardSize) and (0 <= nc < self.boardSize) and (self.board[nr][nc] == -self.current_player):
                stones.append((nr,nc))
                nr, nc = (nr+dr, nc+dc)
            if stones and (0 <= nr < self.boardSize) and (0 <= nc < self.boardSize) and (self.board[nr][nc] == self.current_player):
                for sr, sc in stones:
                    self.board[sr][sc] = self.current_player
        self.current_player *= -1
        if not self.legal_moves():
            self.current_player *= -1

    def game_over(self):
        return not self.legal_moves() and not self.clone().switch_player().legal_moves()

    def switch_player(self):
        self.current_player *= -1
        return self

    def winner(self):
        black = sum(row.count(1)  for row in self.board)
        white = sum(row.count(-1) for row in self.board)
        return 1 if black > white else -1 if white > black else 0

    def display(self):
        print('  ' + ' '.join(str(c) for c in range(self.boardSize)))
        for r in range(self.boardSize):
            row = [EMPTY if cell==0 else (BLACK if cell==1 else WHITE) for cell in self.board[r]]
            print(r, ' '.join(row))
        player_str = f"Black({BLACK})" if self.current_player == 1 else f"White({WHITE})"
        print(f"Current player: {player_str}")

アクセス数(直近7日): ※試験運用中、BOT除外簡易実装済
  • 2026-06-19: 1回
  • 2026-06-18: 1回
  • 2026-06-17: 1回
  • 2026-06-16: 2回
  • 2026-06-15: 0回
  • 2026-06-14: 0回
  • 2026-06-13: 0回
  • コメントを残す

    メールアドレスが公開されることはありません。 が付いている欄は必須項目です