1. 概要

この記事では、モンテカルロ木探索(MCTS)アルゴリズムとそのアプリケーションについて説明します。

がJavaでTic-Tac-Toeのゲームを実装することにより、そのフェーズを詳細に見ていきます。 最小限の変更で、他の多くの実用的なアプリケーションで使用できる一般的なソリューションを設計します。

2. 序章

簡単に言えば、モンテカルロ木探索は確率的探索アルゴリズムです。 これは、非常に多くの可能性を秘めたオープンエンド環境での効率性のため、独自の意思決定アルゴリズムです。

Minimax のようなゲーム理論アルゴリズムに既に精通している場合は、現在の状態を評価する関数が必要であり、最適な動きを見つけるためにゲームツリーの多くのレベルを計算する必要があります。

残念ながら、 Go のように分岐係数が高く(木の高さが高くなると何百万もの可能性があります)、良い評価を書くのが難しいゲームではそうすることができません。現在の状態がどれだけ良いかを計算する関数。

モンテカルロ木探索は、ゲーム木探索にモンテカルロ法を適用します。 ゲームの状態のランダムなサンプリングに基づいているため、それぞれの可能性からブルートフォース攻撃を行う必要はありません。 また、必ずしも評価や優れたヒューリスティック関数を作成する必要はありません。

そして、簡単な補足–それはコンピュータGoの世界に革命をもたらしました。 2016年3月以降、Googleの AlphaGo (MCTSとニューラルネットワークで構築)が Lee Sedol (Goの世界チャンピオン)を打ち負かすにつれて、これは一般的な研究トピックになりました。

3. モンテカルロ木探索アルゴリズム

それでは、アルゴリズムがどのように機能するかを見てみましょう。 最初に、ルートノードを使用して先読みツリー(ゲームツリー)を構築し、次にランダムなロールアウトで拡張し続けます。 その過程で、各ノードの訪問数と勝者数を維持します。

最後に、最も有望な統計を持つノードを選択します。

アルゴリズムは4つのフェーズで構成されています。 それらすべてを詳しく調べてみましょう。

3.1. 選択

この初期フェーズでは、アルゴリズムはルートノードから開始し、最大の勝率を持つノードを選択するように子ノードを選択します。 また、各ノードに公平なチャンスが与えられるようにしたいのです。

ツリーのリーフノードに到達するまで、最適な子ノードを選択し続けるという考え方です。このような子ノードを選択する良い方法は、UCT(ツリーに適用されるUpper Confidence Bound)式を使用することです。 その中で

  • w i = i番目の移動後の勝利数
  • n i = i番目の移動後のシミュレーションの数
  • c =探索パラメーター(理論的には√2に等しい)
  • t=親ノードのシミュレーションの総数

この公式は、どの州も飢餓の犠牲になることはなく、また、対応する州よりも頻繁に有望な支部を演じることを保証します。

3.2. 拡張

後続ノードを見つけるためにUCTを適用できなくなった場合、リーフノードからすべての可能な状態を追加することによってゲームツリーを拡張します。

3.3. シミュレーション

拡張後、アルゴリズムは子ノードを任意に選択し、ゲームの結果の状態に達するまで、選択されたノードからランダム化されたゲームをシミュレートします。 プレイアウト中にノードがランダムまたはセミランダムに選択された場合、それはライトプレイアウトと呼ばれます。 質の高いヒューリスティックまたは評価関数を作成することで、大量のプレイを選択することもできます。

3.4. 誤差逆伝播法

これは、更新フェーズとも呼ばれます。 アルゴリズムがゲームの終わりに達すると、状態を評価して、どのプレーヤーが勝ったかを判断します。 ルートまで上向きにトラバースし、訪問したすべてのノードの訪問スコアを増分します。 また、そのポジションのプレーヤーがプレイアウトに勝った場合、各ノードの勝ちスコアを更新します。

MCTSは、一定の反復回数または一定の時間になるまで、これらの4つのフェーズを繰り返し続けます。

このアプローチでは、ランダムな動きに基づいて各ノードの勝ちスコアを推定します。 したがって、反復回数が多いほど、推定の信頼性が高くなります。 アルゴリズムの推定値は、検索の開始時の精度が低くなり、十分な時間が経過しても改善され続けます。 繰り返しますが、それは問題のタイプにのみ依存します。

4. ドライラン

ここで、ノードには、合計訪問数/勝利スコアとしての統計が含まれています。

5. 実装

それでは、モンテカルロ木探索アルゴリズムを使用して、Tic-Tac-Toeのゲームを実装しましょう。

他の多くのボードゲームにも利用できるMCTSの一般化されたソリューションを設計します。 記事自体のほとんどのコードを見ていきます。

説明をわかりやすくするために、いくつかの小さな詳細(特にMCTSに関連しない)をスキップする必要があるかもしれませんが、完全な実装ここをいつでも見つけることができます。

まず、ツリー検索機能を使用するには、TreeクラスとNodeクラスの基本的な実装が必要です。

public class Node {
    State state;
    Node parent;
    List<Node> childArray;
    // setters and getters
}
public class Tree {
    Node root;
}

各ノードには問題の特定の状態があるため、Stateクラスも実装しましょう。

public class State {
    Board board;
    int playerNo;
    int visitCount;
    double winScore;

    // copy constructor, getters, and setters

    public List<State> getAllPossibleStates() {
        // constructs a list of all possible states from current state
    }
    public void randomPlay() {
        /* get a list of all possible positions on the board and 
           play a random move */
    }
}

次に、 MonteCarloTreeSearch クラスを実装しましょう。これは、指定されたゲーム位置から次善の動きを見つける役割を果たします。

public class MonteCarloTreeSearch {
    static final int WIN_SCORE = 10;
    int level;
    int opponent;

    public Board findNextMove(Board board, int playerNo) {
        // define an end time which will act as a terminating condition

        opponent = 3 - playerNo;
        Tree tree = new Tree();
        Node rootNode = tree.getRoot();
        rootNode.getState().setBoard(board);
        rootNode.getState().setPlayerNo(opponent);

        while (System.currentTimeMillis() < end) {
            Node promisingNode = selectPromisingNode(rootNode);
            if (promisingNode.getState().getBoard().checkStatus() 
              == Board.IN_PROGRESS) {
                expandNode(promisingNode);
            }
            Node nodeToExplore = promisingNode;
            if (promisingNode.getChildArray().size() > 0) {
                nodeToExplore = promisingNode.getRandomChildNode();
            }
            int playoutResult = simulateRandomPlayout(nodeToExplore);
            backPropogation(nodeToExplore, playoutResult);
        }

        Node winnerNode = rootNode.getChildWithMaxScore();
        tree.setRoot(winnerNode);
        return winnerNode.getState().getBoard();
    }
}

ここでは、事前定義された時間まで4つのフェーズすべてを繰り返し、最後に、信頼できる統計を含むツリーを取得して、賢明な決定を下します。

それでは、すべてのフェーズのメソッドを実装しましょう。

UCTの実装も必要とする選択フェーズから始めます。

private Node selectPromisingNode(Node rootNode) {
    Node node = rootNode;
    while (node.getChildArray().size() != 0) {
        node = UCT.findBestNodeWithUCT(node);
    }
    return node;
}
public class UCT {
    public static double uctValue(
      int totalVisit, double nodeWinScore, int nodeVisit) {
        if (nodeVisit == 0) {
            return Integer.MAX_VALUE;
        }
        return ((double) nodeWinScore / (double) nodeVisit) 
          + 1.41 * Math.sqrt(Math.log(totalVisit) / (double) nodeVisit);
    }

    public static Node findBestNodeWithUCT(Node node) {
        int parentVisit = node.getState().getVisitCount();
        return Collections.max(
          node.getChildArray(),
          Comparator.comparing(c -> uctValue(parentVisit, 
            c.getState().getWinScore(), c.getState().getVisitCount())));
    }
}

このフェーズでは、拡張フェーズでさらに拡張する必要があるリーフノードを推奨します。

private void expandNode(Node node) {
    List<State> possibleStates = node.getState().getAllPossibleStates();
    possibleStates.forEach(state -> {
        Node newNode = new Node(state);
        newNode.setParent(node);
        newNode.getState().setPlayerNo(node.getState().getOpponent());
        node.getChildArray().add(newNode);
    });
}

次に、ランダムノードを選択してランダムな再生をシミュレートするコードを記述します。また、 update 関数を使用して、リーフからルートまでスコアと訪問数を伝播します。 :

private void backPropogation(Node nodeToExplore, int playerNo) {
    Node tempNode = nodeToExplore;
    while (tempNode != null) {
        tempNode.getState().incrementVisit();
        if (tempNode.getState().getPlayerNo() == playerNo) {
            tempNode.getState().addScore(WIN_SCORE);
        }
        tempNode = tempNode.getParent();
    }
}
private int simulateRandomPlayout(Node node) {
    Node tempNode = new Node(node);
    State tempState = tempNode.getState();
    int boardStatus = tempState.getBoard().checkStatus();
    if (boardStatus == opponent) {
        tempNode.getParent().getState().setWinScore(Integer.MIN_VALUE);
        return boardStatus;
    }
    while (boardStatus == Board.IN_PROGRESS) {
        tempState.togglePlayer();
        tempState.randomPlay();
        boardStatus = tempState.getBoard().checkStatus();
    }
    return boardStatus;
}

これで、MCTSの実装は完了です。 必要なのは、Tic-Tac-Toe固有のBoardクラスの実装だけです。 私たちの実装で他のゲームをプレイすることに注意してください。 ボードクラスを変更するだけです。

public class Board {
    int[][] boardValues;
    public static final int DEFAULT_BOARD_SIZE = 3;
    public static final int IN_PROGRESS = -1;
    public static final int DRAW = 0;
    public static final int P1 = 1;
    public static final int P2 = 2;
    
    // getters and setters
    public void performMove(int player, Position p) {
        this.totalMoves++;
        boardValues[p.getX()][p.getY()] = player;
    }

    public int checkStatus() {
        /* Evaluate whether the game is won and return winner.
           If it is draw return 0 else return -1 */         
    }

    public List<Position> getEmptyPositions() {
        int size = this.boardValues.length;
        List<Position> emptyPositions = new ArrayList<>();
        for (int i = 0; i < size; i++) {
            for (int j = 0; j < size; j++) {
                if (boardValues[i][j] == 0)
                    emptyPositions.add(new Position(i, j));
            }
        }
        return emptyPositions;
    }
}

Tic-Tac-Toeでは打ち負かされないAIを実装したばかりです。 AI対を示すユニットケースを書いてみましょう。 AIは常に引き分けになります:

@Test
public void givenEmptyBoard_whenSimulateInterAIPlay_thenGameDraw() {
    Board board = new Board();
    int player = Board.P1;
    int totalMoves = Board.DEFAULT_BOARD_SIZE * Board.DEFAULT_BOARD_SIZE;
    for (int i = 0; i < totalMoves; i++) {
        board = mcts.findNextMove(board, player);
        if (board.checkStatus() != -1) {
            break;
        }
        player = 3 - player;
    }
    int winStatus = board.checkStatus();
 
    assertEquals(winStatus, Board.DRAW);
}

6. 利点

  • ゲームに関する戦術的な知識は必ずしも必要ではありません
  • 一般的なMCTSの実装は、ほとんど変更を加えることなく、任意の数のゲームで再利用できます。
  • ゲームに勝つ可能性が高いノードに焦点を当てます
  • 可能なすべての分岐の計算を無駄にしないため、分岐係数が高い問題に適しています
  • アルゴリズムの実装は非常に簡単です
  • 実行はいつでも停止でき、それでもこれまでに計算された次善の状態が提案されます

7. 欠点

MCTSを基本的な形で改善せずに使用すると、合理的な動きを示唆できない場合があります。 ノードが適切に訪問されていない場合に発生する可能性があり、その結果、見積もりが不正確になります。

ただし、MCTSは、いくつかの手法を使用して改善できます。 これには、ドメイン固有の手法とドメインに依存しない手法が含まれます。

ドメイン固有の手法では、シミュレーションステージは、確率的シミュレーションではなく、より現実的なプレイアウトを生成します。 ゲーム固有のテクニックとルールの知識が必要ですが。

8. 概要

一見すると、ランダムな選択に依存するアルゴリズムがスマートAIにつながる可能性があることを信頼することは困難です。 ただし、MCTSを慎重に実装することで、多くのゲームや意思決定の問題で使用できるソリューションを実際に提供できます。

いつものように、アルゴリズムの完全なコードはGitHubにあります。