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

今回は前回の探索関数、評価関数のざっくりとした修正と、簡易的な評価関数の作成を行います。

前回の記事


MonteCarloクラスの修正
前回の記事では、明確なコードを作ってくれなかったので、Reversiクラスから移植して対応してみました。
そこで、ソースコードを貼り付けて以下の確認を行いました。

以下のクラスについて問題点があれば改善策とともに教えてください。

ChatGPTへの質問

返ってきた内容はこんな感じです。

このクラスに関するいくつかの注意点と改善策を以下に示します:

1. **`place_piece` メソッドの新しい引数**: `place_piece` メソッドは、新しい `board` 引数を導入しましたが、メソッドの定義でこの引数が見つかりません。この新しい引数をメソッドの定義に追加し、これを使ってボードの状態を変更できるようにする必要があります。

修正案:

```python
def place_piece(self, x, y, player, actual=False, board=None):
if board is None:
board = self.board
# ... (以降のコードでself.boardをboardに置き換え)
```

2. **`playout` メソッドでの未定義変数**: `playout` メソッドの最後の行で、`opponent_move` が未定義の場合があります。これは、対戦相手が有効な手を持っていない場合に発生します。この問題を解消するためには、対戦相手が手を持っていない場合の処理を適切に行う必要があります。

修正案:

```python
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) # あるいは別の評価方法
```

3. **`get_possible_moves` メソッド内で`place_piece`の呼び出し**: `get_possible_moves` メソッド内で `place_piece` メソッドを呼び出す際、`actual` 引数が設定されていないため、実際にボードに手を配置してしまいます。`actual` 引数を `False` に設定して呼び出し、実際にボードを変更しないようにする必要があります。

修正案:

```python
if self.place_piece(x, y, player, actual=False):
```

4. **効率の向上**: 現状の `get_possible_moves` メソッドは8x8のグリッドを毎回繰り返し処理していて、少し効率が低いかもしれません。空いているセルだけを考慮するようにすることで効率を向上させることができます。

5. **`simulate` メソッド内での評価関数の呼び出し**: 現時点では、`simulate` メソッドは`playout` メソッドを呼び出しており、このメソッドがさらに`evaluate` メソッドを呼び出しています。これにより、同じボードの状態が複数回評価される可能性があります。ボードの評価をキャッシュすることで、この問題を緩和できるかもしれません。

色々返ってきましたが、ざっくり言うと(特に前半は)これじゃ動かないよってことです。

また、評価関数について、今回は簡易的なものを用意しようと以下のお願いを投げてみました。

evaluate関数において、各マスにおける石の評価を作成し、合計値を評価値として返すロジックを考えてください。 各マスの評価値は事前にマトリクス化して定数で定義することとします。

リバーシでは、必ずではないですがここに置くとあまりよくない、ここに置くといいという目安的な評価が存在していると考えます。それこそ、角を取るといいとかは一番わかりやすいところでしょうか。
今回はこれを事前に定義し、それだけを元にして評価値を算出しようと言うことです。あくまで、簡易的なものです。

で、それを確認した結果が以下の通り。

確かに、モンテカルロ木探索をより洗練された方法で行うためには、ボードの異なる部分での石の位置が異なる価値を持つような評価関数を利用することが一般的です。

以下のように、各セルの位置に重み付けを行い、それを基に評価関数を設計することができます。これはボードの角と端が一般的にはより価値があるとされ、中心に近いセルが次に価値があるとされるオセロの戦略を反映しています:

コード:

この `evaluate` 関数はボード上の各セルの石をそのセルの重みに掛け合わせて合計し、その合計値を評価値として返します。このようにすることで、評価関数はゲームの戦略的側面をより正確に反映することができます。注意してください、このセルの重みはヒューリスティックなものであり、他の値でも実験することができます。

コード部分は以下の通り

class MonteCarlo:
    # ... (省略)

    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 evaluate(self, board):
        # 各セルの評価値を計算し、合計値を評価値として返す
        total_score = 0
        for x in range(8):
            for y in range(8):
                total_score += board[x][y] * self.CELL_WEIGHTS[x][y]
        return total_score

    # ... (省略)

ロジックとしてはかなりシンプルになったのではないかと思います。
本来ならちゃんと石の数を数えて善し悪しを図るものですが、これだけでもコンピュータの思考っぽくなります。

で、最終的にMonteCarloクラスは以下のようになりました。

class MonteCarlo:
    
    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):
        possible_moves = []
        for x in range(8):
            for y in range(8):
                if self.place_piece(x, y, player, board, actual=False):
                    possible_moves.append((x, y))
        return possible_moves


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

        if x < 0 or x >= 8 or y < 0 or y >= 8 or board[x][y] != 0:
            return False 

        directions = [(0, 1), (1, 0), (0, -1), (-1, 0), (1, 1), (-1, -1), (1, -1), (-1, 1)]  
        to_flip = []  

        for dx, dy in directions:  
            i, j, flips = x + dx, y + dy, []  
            while 0 <= i < 8 and 0 <= j < 8 and board[i][j] == -player:  
                flips.append((i, j))  
                i += dx  
                j += dy
            if 0 <= i < 8 and 0 <= j < 8 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):
        best_move = None
        max_score = -float('inf')

        for move in self.get_possible_moves(self.board, self.player):
            score = self.playout(move, self.depth)
            if score > max_score:
                max_score = 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):
        # 各セルの評価値を計算し、合計値を評価値として返す
        total_score = 0
        for x in range(8):
            for y in range(8):
                total_score += board[x][y] * self.CELL_WEIGHTS[x][y]
        return total_score

このあと、新規チャットにてコードの問題点を確認しましたが、速度改善やリファクタリングなどコードの読みやすさの点を指摘されました。
このコードだけをとっても、まだまだ改善はできそうです。

元のコードとセットで動かしてみましたが、現時点ではエラーなく動きそうです。さっと触った感じでは、ランダムの時に比べて格段に強くなっています。

さて、次回はコードのブラッシュアップと、ファミコンソフト「オセロ」との対局をしてみようと思います。

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