Monte Carlo Tree Search pour le jeu Tic-Tac-Toe

Monte Carlo Tree recherche le jeu Tic-Tac-Toe

1. Vue d'ensemble

Dans cet article, nous allons explorer lesMonte Carlo Tree Search (MCTS) algorithm et ses applications.

Nous examinerons ses phases en détail parimplementing the game of Tic-Tac-Toe in Java. Nous allons concevoir une solution générale qui pourrait être utilisée dans de nombreuses autres applications pratiques, avec des changements minimes.

2. introduction

En termes simples, la recherche dans les arbres de Monte-Carlo est un algorithme de recherche probabiliste. C’est un algorithme de prise de décision unique en raison de son efficacité dans des environnements ouverts avec une énorme quantité de possibilités.

Si vous êtes déjà familier avec les algorithmes de théorie des jeux commeMinimax, cela nécessite une fonction pour évaluer l'état actuel, et il doit calculer de nombreux niveaux dans l'arborescence du jeu pour trouver le mouvement optimal.

Malheureusement, il n'est pas possible de le faire dans un jeu commeGo dans lequel il y a un facteur de branchement élevé (résultant en des millions de possibilités lorsque la hauteur de l'arbre augmente), et il est difficile d'écrire une bonne fonction d'évaluation pour calculer la qualité de l'état actuel.

La recherche dans l'arborescence Monte Carlo appliqueMonte Carlo method à la recherche dans l'arborescence du jeu. Comme il est basé sur un échantillonnage aléatoire des états de jeu, il n’a pas besoin de forcer brutalement à sortir de chaque possibilité. De plus, cela ne nous oblige pas nécessairement à écrire une évaluation ou de bonnes fonctions heuristiques.

Et, note rapide: il a révolutionné le monde de l'ordinateur Go. Depuis mars 2016, il est devenu un sujet de recherche répandu alors que lesAlphaGo de Google (construits avec les SCTM et le réseau neuronal) battentLee Sedol (le champion du monde de Go).

3. Algorithme de recherche arborescente de Monte Carlo

Voyons maintenant comment fonctionne l'algorithme. Au départ, nous allons créer une arborescence d'anticipation (arborescence de jeu) avec un nœud racine, puis nous continuerons de l'étendre avec des déploiements aléatoires. Dans ce processus, nous maintiendrons le nombre de visites et le nombre de victoires pour chaque nœud.

À la fin, nous allons sélectionner le noeud avec les statistiques les plus prometteuses.

L'algorithme se compose de quatre phases; explorons-les tous en détail.

3.1. Sélection

Dans cette phase initiale, l'algorithme commence par un nœud racine et sélectionne un nœud enfant de manière à ce qu'il sélectionne le nœud avec le taux de gain maximal. Nous voulons également nous assurer que chaque nœud a une chance équitable.

The idea is to keep selecting optimal child nodes until we reach the leaf node of the tree. Un bon moyen de sélectionner un tel nœud enfant est d'utiliser la formule UCT (Upper Confidence Bound appliquée aux arbres):imageDans laquelle

  • wi = nombre de victoires après le déplacementi

  • ni = nombre de simulations après le déplacementi

  • c = paramètre d'exploration (théoriquement égal à √2)

  • t = nombre total de simulations pour le nœud parent

La formule garantit qu'aucun État ne sera victime de la famine et elle joue aussi plus souvent que les branches prometteuses.

3.2. Expansion

Lorsqu'il ne peut plus utiliser UCT pour trouver le nœud successeur, il développe l'arborescence du jeu en ajoutant tous les états possibles à partir du nœud feuille.

3.3. Simulation

Après l’extension, l’algorithme sélectionne un nœud enfant de façon arbitraire et simule une partie aléatoire du nœud sélectionné jusqu’à ce qu’elle atteigne l’état résultant. Si les nœuds sont sélectionnés de manière aléatoire ou semi-aléatoire au cours de la lecture, cela s'appelle une lecture légère. Vous pouvez également opter pour un jeu intensif en écrivant des heuristiques de qualité ou des fonctions d'évaluation.

3.4. Rétropropagation

Ceci est également appelé phase de mise à jour. Une fois que l'algorithme a atteint la fin de la partie, il évalue l'état pour déterminer quel joueur a gagné. Il parcourt la racine vers le haut et incrémente le score de visite pour tous les nœuds visités. Il met également à jour le score de gain pour chaque nœud si le joueur pour cette position a gagné la partie jouée.

Les SCTM continuent de répéter ces quatre phases jusqu'à un certain nombre fixe d'itérations ou une durée fixe.

Dans cette approche, nous estimons le score de gain pour chaque nœud en fonction de mouvements aléatoires. Donc, plus le nombre d'itérations est élevé, plus l'estimation devient fiable. Les estimations de l'algorithme seront moins précises au début d'une recherche et continueront à s'améliorer après un temps suffisant. Encore une fois, cela dépend uniquement du type de problème.

4. Course à sec

imageimage

Ici, les nœuds contiennent des statistiques sous forme de visites totales / score gagnant.

5. la mise en oeuvre

Maintenant, implémentons un jeu de Tic-Tac-Toe - en utilisant l'algorithme de recherche arborescente de Monte Carlo.

Nous allons concevoir une solution généralisée pour les SCTM qui peut également être utilisée pour de nombreux autres jeux de société. Nous examinerons la plupart du code dans l'article lui-même.

Bien que pour rendre l'explication claire, nous devrons peut-être ignorer quelques détails mineurs (pas particulièrement liés aux MCTS), mais vous pouvez toujours trouver l'implémentation complètehere.

Tout d'abord, nous avons besoin d'une implémentation de base pour les classesTree etNode pour avoir une fonctionnalité de recherche arborescente:

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

Comme chaque nœud aura un état particulier du problème, implémentons également une classeState:

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

    // copy constructor, getters, and setters

    public List 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 */
    }
}

Maintenant, implémentons la classeMonteCarloTreeSearch, qui sera chargée de trouver le meilleur coup suivant à partir de la position de jeu donnée:

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();
    }
}

Ici, nous continuons à parcourir les quatre phases jusqu’à l’heure prédéfinie et, à la fin, nous obtenons un arbre contenant des statistiques fiables pour prendre une décision intelligente.

Maintenant, mettons en œuvre des méthodes pour toutes les phases.

We will start with the selection phase qui nécessite également une implémentation 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())));
    }
}

Cette phase recommande un nœud feuille qui devrait être étendu davantage dans la phase d'expansion:

private void expandNode(Node node) {
    List 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);
    });
}

Next, we write code to pick a random node and simulate a random play out from it. De plus, nous aurons une fonctionupdate pour propager le score et le nombre de visites de la feuille à la racine:

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;
}

Nous en avons terminé avec la mise en œuvre des SCTM. Tout ce dont nous avons besoin est une implémentation particulière de la classeBoard de Tic-Tac-Toe. Notez que pour jouer à d'autres jeux avec notre implémentation; Nous avons juste besoin de changer la classe deBoard.

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 getEmptyPositions() {
        int size = this.boardValues.length;
        List 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;
    }
}

Nous venons de mettre en place une IA qui ne peut pas être battue dans Tic-Tac-Toe. Écrivons un cas unitaire qui démontre que l'IA vs. AI provoquera toujours un match nul:

@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. Avantages

  • Il ne nécessite pas nécessairement de connaissance tactique du jeu.

  • Une implémentation générale des SCTM peut être réutilisée pour n’importe quel nombre de jeux avec peu de modifications.

  • Se concentre sur les nœuds avec de plus grandes chances de gagner le jeu

  • Convient aux problèmes de facteur de ramification élevé, car il ne gaspille pas de calculs sur toutes les branches possibles

  • L'algorithme est très simple à mettre en œuvre

  • L'exécution peut être arrêtée à tout moment, et cela suggérera toujours le prochain meilleur état calculé jusqu'ici

7. Inconvénient

Si le SCTM est utilisé dans sa forme de base sans aucune amélioration, il risque de ne pas suggérer de mesures raisonnables. Cela peut se produire si les nœuds ne sont pas visités correctement, ce qui entraîne des estimations inexactes.

Cependant, les SCTM peuvent être améliorés en utilisant certaines techniques. Cela implique des techniques spécifiques au domaine ainsi que des techniques indépendantes du domaine.

Dans les techniques spécifiques à un domaine, la phase de simulation produit des sorties plus réalistes plutôt que des simulations stochastiques. Bien que cela nécessite une connaissance des techniques et des règles spécifiques au jeu.

8. Sommaire

À première vue, il est difficile de croire qu’un algorithme reposant sur des choix aléatoires puisse conduire à une IA intelligente. Cependant, une mise en œuvre réfléchie des SCTM peut en effet nous fournir une solution qui peut être utilisée dans de nombreux jeux ainsi que dans des problèmes de prise de décision.

Comme toujours, le code complet de l'algorithme peut être trouvéover on GitHub.