できるだけChatGPTだけで作るリバーシ(オセロ)ソフト その4

前回は評価関数の作成から、最低限のブラッシュアップを行いました。

次のコードはリバーシの探索関数、評価関数である。 以下の要件以外でブラッシュアップできるところがあるか考えてほしい。
・評価関数はそのまま
・速度改善

回答としては、以下の通りでした

このコードはモンテカルロ法を使用してリバーシのボードを評価し、最適な動きを決定します。提供された条件(評価関数の変更なし、速度向上の不要)を考慮すると、以下のようないくつかの改善点があります:コードの整理と可読性の向上:一部のコードは抽象化や関数の再構成を行うことで整理できます。これにより、コードの可読性と保守性が向上します。
get_possible_movesの最適化:現在、get_possible_moves関数は全てのセルを調べていますが、既に石が置かれているセルを除外することで調査範囲を縮小できます。
リスト内包表記の利用:いくつかの部分でリスト内包表記を利用してコードを簡潔化できます。
マジックナンバーの除去:コード内のマジックナンバー(例:8)を変数に置き換えてコードの可読性を向上させることができます。


以下、上記の改善点を取り入れたコードの例です:

class MonteCarlo:
    BOARD_SIZE = 8
    DIRECTIONS = [(0, 1), (1, 0), (0, -1), (-1, 0), (1, 1), (-1, -1), (1, -1), (-1, 1)]
    CELL_WEIGHTS = [
        # (同じ配列)
    ]

    def __init__(self, board, player, depth):
        self.board = board
        self.player = player
        self.depth = depth

    def get_possible_moves(self, board, player):
        return [(x, y) for x in range(self.BOARD_SIZE) for y in range(self.BOARD_SIZE) if self.place_piece(x, y, player, board, actual=False)]

    def place_piece(self, x, y, player, board=None, actual=False):
        # (同じコード、ただし DIRECTIONS と BOARD_SIZE を使う)
        
    def simulate(self):
        moves = self.get_possible_moves(self.board, self.player)
        return max(moves, key=lambda move: self.playout(move, self.depth), default=None)

    def playout(self, move, depth):
        # (同じコード)
    
    def evaluate(self, board):
        return sum(board[x][y] * self.CELL_WEIGHTS[x][y] for x in range(self.BOARD_SIZE) for y in range(self.BOARD_SIZE))

主な変更点:
1. DIRECTIONSとBOARD_SIZEという定数を導入しました。
2. get_possible_moves関数を一行で書くためにリスト内包表記を使っています。
3. simulate関数でmax関数を使うことでコードを簡潔化しました。
4. evaluate関数でもリスト内包表記を使ってコードを簡潔化しました。

これによりコードが少し短くなり、一部の関数が簡潔になりました。また、ボードのサイズを変更する必要がある場合には、BOARD_SIZEの値を変更するだけでよくなります。

正直、可読性が上がったかと言われると微妙ですが、コードそのものは短くなったのでよしとしましょう。

で、コードを見ていてふと思ったことを確認したら実装されていなかったので、実装をすることに。
その確認内容とは

MTCSって、シミュレート部分は指定回数を行って最善手を決めると思ったけど、今のコードだとそれはやってない?

はい、その通りです。現在のsimulate関数は各手に対して単一のプレイアウトを行っており、それぞれの手の最初のプレイアウト結果に基づいて最善手を決定しています。モンテカルロ木探索(MCTS)の一般的な実装では、各手に対して複数のプレイアウト(シミュレーション)を行い、それらの結果の平均を取ることで最善手を決定します。

以下のようにsimulate関数を変更して、各手に対して複数のプレイアウトを行うように改良できます:

ということで、以下の内容も実装しました。

def simulate(self, num_simulations=100):
    best_move = None
    max_score = -float('inf')

    for move in self.get_possible_moves(self.board, self.player):
        total_score = 0
        for _ in range(num_simulations):
            total_score += self.playout(move, self.depth)
        average_score = total_score / num_simulations

        if average_score > max_score:
            max_score = average_score
            best_move = move

    return best_move

今回はシミュレーション回数をサンプルコードのまま100回とします。

ファミコン「オセロ」と対局

このソフト、レベルは初期で4段階選択でき、勝つとレベル5が出てくるようです。

今回はレベル1から始めて、勝てるところまでやっていきます。

今回対局したプログラムは以下の通り。
実は、対局を始めたらプレイヤー側がパスの場合の実装がされておらず、ChatGPTにコードを見てもらって直しました(主題通り)。
また、このコードは白を選択すると白が先手になるバグがまだあるため、今回はプレイヤーは黒固定となります(次回修正します)。

import tkinter as tk
import random
from tkinter import messagebox

class MonteCarlo:
    
    BOARD_SIZE = 8
    
    DIRECTIONS = [(0, 1), (1, 0), (0, -1), (-1, 0), (1, 1), (-1, -1), (1, -1), (-1, 1)]
    
    CELL_WEIGHTS = [
        [120, -20, 20, 5, 5, 20, -20, 120],
        [-20, -40, -5, -5, -5, -5, -40, -20],
        [ 20,  -5, 15, 3, 3, 15,  -5,  20],
        [  5,  -5,  3, 3, 3,  3,  -5,   5],
        [  5,  -5,  3, 3, 3,  3,  -5,   5],
        [ 20,  -5, 15, 3, 3, 15,  -5,  20],
        [-20, -40, -5, -5, -5, -5, -40, -20],
        [120, -20, 20, 5, 5, 20, -20, 120]
    ]
    
    def __init__(self, board, player, depth):
        self.board = board
        self.player = player
        self.depth = depth

    def get_possible_moves(self, board, player):
        return [(x, y) for x in range(self.BOARD_SIZE) for y in range(self.BOARD_SIZE) if self.place_piece(x, y, player, board, actual=False)]


    def place_piece(self, x, y, player, board=None, actual=False):
        
        if board is None:
            board = self.board

        if x < 0 or x >= self.BOARD_SIZE or y < 0 or y >= self.BOARD_SIZE or board[x][y] != 0:
            return False 

        directions = self.DIRECTIONS  
        to_flip = []  

        for dx, dy in directions:  
            i, j, flips = x + dx, y + dy, []  
            while 0 <= i < self.BOARD_SIZE and 0 <= j < self.BOARD_SIZE and board[i][j] == -player:  
                flips.append((i, j))  
                i += dx  
                j += dy
            if 0 <= i < self.BOARD_SIZE and 0 <= j < self.BOARD_SIZE and board[i][j] == player and flips:  
                to_flip.extend(flips)  

        if not to_flip:
            return False  

        if actual:  
            board[x][y] = player  
            for i, j in to_flip:  
                board[i][j] = player
        return True

    def simulate(self, num_simulations=100):
        best_move = None
        max_score = -float('inf')

        for move in self.get_possible_moves(self.board, self.player):
            total_score = 0
            for _ in range(num_simulations):
                total_score += self.playout(move, self.depth)
            average_score = total_score / num_simulations

            if average_score > max_score:
                max_score = average_score
                best_move = move

        return best_move

    def playout(self, move, depth):
        if depth == 0:
            return self.evaluate(self.board)

        simulated_board = [row[:] for row in self.board]
        x, y = move
        self.place_piece(x, y, self.player, board=simulated_board, actual=True)
        opponent = -self.player

        opponent_moves = self.get_possible_moves(simulated_board, opponent)
        if opponent_moves:
            opponent_move = random.choice(opponent_moves)
            self.place_piece(opponent_move[0], opponent_move[1], opponent, board=simulated_board, actual=True)
            return -self.playout(opponent_move, depth - 1)
        else:
            return self.evaluate(simulated_board)  # 対戦相手が行動できない場合は現在のボードの評価を返します

    def evaluate(self, board):
        return sum(board[x][y] * self.CELL_WEIGHTS[x][y] for x in range(self.BOARD_SIZE) for y in range(self.BOARD_SIZE))

class Reversi:
    def __init__(self, root):
        self.root = root
        self.root.resizable(False, False)  # メインウィンドウの最大化ボタンを無効化
        self.choose_color()  # ユーザーに色を選んでもらうダイアログを表示

    def choose_color(self):
        self.color_choice = tk.Toplevel(self.root)  # 新しいトップレベルウィンドウを作成
        self.color_choice.resizable(False, False)  # ダイアログの最大化ボタンを無効化
        # ラベルとボタンをダイアログに追加
        tk.Label(self.color_choice, text="Choose your color:").pack()
        tk.Button(self.color_choice, text="Black", command=lambda: self.start_game(1)).pack()
        tk.Button(self.color_choice, text="White", command=lambda: self.start_game(-1)).pack()
        self.color_choice.grab_set()  # ダイアログを前面に保持
        self.color_choice.attributes('-topmost', True)  # ダイアログを常にトップに保持

    def start_game(self, color):
        # ゲーム開始準備
        self.color_choice.destroy()  # 色選択ダイアログを閉じる
        self.canvas = tk.Canvas(self.root, width=400, height=400)  # キャンバスを作成
        self.canvas.pack()  # キャンバスをパック
        self.initialize_board()  # ボードを初期化
        self.player = color  # プレイヤーの色を設定
        # スコアラベルを作成
        self.score_label = tk.Label(self.root, text="Player 1: 2, Player 2: 2")
        self.score_label.pack()  # スコアラベルをパック
        self.canvas.bind("<Button-1>", self.click)  # クリックイベントをバインド
        self.draw_board()  # ボードを描画
        if self.player == -1:  # プレイヤーが白の場合、CPUのターンを開始
            self.computer_move()

    def initialize_board(self):
        # ボードを初期化: 8x8のグリッドで、中央に2つずつの黒と白の石を配置
        self.board = [[0 for _ in range(8)] for _ in range(8)]
        self.board[3][3] = -1
        self.board[4][4] = -1
        self.board[3][4] = 1
        self.board[4][3] = 1
        self.move_count = 0  # 手数を0にリセット

    def draw_board(self):
        # ボードと石を描画
        for i in range(8):
            for j in range(8):
                x, y = i * 50, j * 50
                self.canvas.create_rectangle(x, y, x + 50, y + 50, fill="green")  # グリーンのセルを描画
                if self.board[i][j] == 1:
                    self.canvas.create_oval(x + 5, y + 5, x + 45, y + 45, fill="black")  # 黒の石を描画
                elif self.board[i][j] == -1:
                    self.canvas.create_oval(x + 5, y + 5, x + 45, y + 45, fill="white")  # 白の石を描画
        self.update_score()  # スコアを更新

    def click(self, event):
        x, y = event.x // 50, event.y // 50  # クリックされたセルの座標を取得
        if self.place_piece(x, y, self.player, True):  # 石を置けるか確認
            self.move_count += 1  # 手数を増やす
            self.player *= -1  # プレイヤーを切り替え
            self.draw_board()  # ボードを再描画
            self.check_game_end()  # ゲーム終了条件をチェック
            self.computer_move()  # コンピュータの手番
        else:
            valid_moves = [(x, y) for x in range(8) for y in range(8) if self.place_piece(x, y, self.player, False)]
            if not valid_moves:  # If the player has no valid moves
                self.player *= -1  # Switch the player
                self.computer_move()  # Move to computer's turn

    def computer_move(self, depth=3):
        monte_carlo = MonteCarlo(self.board, self.player, depth)
        best_move = monte_carlo.simulate()
        if best_move:
            self.place_piece(best_move[0], best_move[1], self.player, actual=True)
            self.update_score()
            self.move_count += 1  # 手数を増やす
            self.player *= -1  # プレイヤーを切り替え
            self.draw_board()  # ボードを再描画
        else:
            self.player *= -1  # Switch player if no valid moves
        self.check_game_end()  # Check game end conditions

    def computer_move_random(self):
        # コンピュータの手番
        if self.player == -1:  # コンピュータの手番の場合
            # 有効な手を探す
            valid_moves = [(x, y) for x in range(8) for y in range(8) if self.place_piece(x, y, self.player, False)]
            if valid_moves:  # 有効な手がある場合
                move = random.choice(valid_moves)  # ランダムな手を選ぶ
                self.place_piece(move[0], move[1], self.player, True)  # その手を選ぶ
                self.move_count += 1  # 手数を増やす
                self.player *= -1  # プレイヤーを切り替え
                self.draw_board()  # ボードを再描画
            else:  
                self.player *= -1  # 有効な手がない場合、プレイヤーを切り替え
            self.check_game_end()  # ゲーム終了条件をチェック

    def place_piece(self, x, y, player, actual=False):
        # 指定した座標に石を置くためのメソッド
        # x, y: 石を置く座標
        # player: 現在のプレイヤー (1 または -1)
        # actual: 実際に石を置くかどうかを示すフラグ
        if x < 0 or x >= 8 or y < 0 or y >= 8 or self.board[x][y] != 0:
            return False  # 無効な座標または既に石が置かれている場合はFalseを返す

        directions = [(0, 1), (1, 0), (0, -1), (-1, 0), (1, 1), (-1, -1), (1, -1), (-1, 1)]  # 探索する8方向
        to_flip = []  # ひっくり返される石のリスト

        for dx, dy in directions:  # 8方向について繰り返し
            i, j, flips = x + dx, y + dy, []  # 探索開始座標と、ひっくり返される石のリストを初期化
            while 0 <= i < 8 and 0 <= j < 8 and self.board[i][j] == -player:  # 異なる色の石を見つけるまで探索
                flips.append((i, j))  # ひっくり返される石をリストに追加
                i += dx  # 探索座標を更新
                j += dy
            if 0 <= i < 8 and 0 <= j < 8 and self.board[i][j] == player and flips:  # 探索終端がプレイヤーの石の場合
                to_flip.extend(flips)  # ひっくり返される石のリストに追加
        if not to_flip:
            return False  # ひっくり返される石がない場合はFalseを返す

        if actual:  # 実際に石を置く場合
            self.board[x][y] = player  # 指定した座標に石を置く
            for i, j in to_flip:  # ひっくり返される石を更新
                self.board[i][j] = player
        return True

    def update_score(self):
        # スコアを更新するメソッド
        scores = [sum(row.count(player) for row in self.board) for player in [1, -1]]  # 各プレイヤーのスコアを計算
        self.score_label.config(text=f"Player 1: {scores[0]}, Player 2: {scores[1]}")  # スコアラベルを更新

    def check_game_end(self):
        # ゲーム終了条件をチェックするメソッド
        player_moves = [(x, y) for x in range(8) for y in range(8) if self.place_piece(x, y, self.player, False)]  # 現在のプレイヤーの可能な手
        opponent_moves = [(x, y) for x in range(8) for y in range(8) if self.place_piece(x, y, -self.player, False)]  # 相手プレイヤーの可能な手

        if not player_moves and not opponent_moves or self.move_count >= 60:  # どちらも手がないか、60手以上進んだ場合
            self.show_game_end()  # ゲーム終了処理を呼び出し

    def show_game_end(self):
        # ゲーム終了時の処理を行うメソッド
        scores = [sum(row.count(player) for row in self.board) for player in [1, -1]]  # 各プレイヤーのスコアを計算
        winner = "Draw"  # 勝者の初期値を"Draw"に設定
        if scores[0] > scores[1]:  # プレイヤー1の勝利判定
            winner = "Player 1 wins"
        elif scores[1] > scores[0]:  # プレイヤー2の勝利判定
            winner = "Player 2 wins"

        # ゲーム終了のメッセージボックスを表示
        messagebox.showinfo("Game Over", f"{winner}! Final Score - Player 1: {scores[0]}, Player 2: {scores[1]}")
        self.canvas.destroy()  # キャンバスを破棄
        self.score_label.destroy()  # スコアラベルを破棄
        self.choose_color()  # 色選択ダイアログを再表示

root = tk.Tk()  # Tkinterのルートウィンドウを作成
reversi = Reversi(root)  # Reversiクラスのインスタンスを作成
root.mainloop()  # イベントループを開始

結果はキャプション取り忘れましたが、黒34、白30で「オセロ」の勝利です。最後は5連続パスさせられるなど、「オセロ」の強さを感じる1局でした。

対局結果

さてさて、まだまだCPU戦を行うには強くしないといけないようです。ここからどうしていきましょうか?
次回の課題として、少し考えてみます。

ということで、次回は
主題:ロジックだけで強くできるか?
副題:プレイ中に見つけたバグ修正

という感じになります。

この記事が気に入ったらサポートをしてみませんか?