Monte Carlo Tree Suche nach Tic-Tac-Toe-Spiel

Monte Carlo Tree Suche nach Tic-Tac-Toe-Spiel

1. Überblick

In diesem Artikel werden wir dieMonte Carlo Tree Search (MCTS) algorithm und ihre Anwendungen untersuchen.

Wir werden die Phasen im Detail mitimplementing the game of Tic-Tac-Toe in Java betrachten. Wir werden eine allgemeine Lösung entwerfen, die mit minimalen Änderungen in vielen anderen praktischen Anwendungen verwendet werden kann.

2. Einführung

Einfach ausgedrückt ist die Monte-Carlo-Baumsuche ein probabilistischer Suchalgorithmus. Es ist ein einzigartiger Entscheidungsalgorithmus aufgrund seiner Effizienz in offenen Umgebungen mit einer enormen Menge an Möglichkeiten.

Wenn Sie bereits mit spieltheoretischen Algorithmen wieMinimax vertraut sind, ist eine Funktion zum Auswerten des aktuellen Status erforderlich, und es müssen viele Ebenen im Spielbaum berechnet werden, um den optimalen Zug zu finden.

Leider ist dies in einem Spiel wieGo mit einem hohen Verzweigungsfaktor nicht möglich (was mit zunehmender Baumhöhe zu Millionen von Möglichkeiten führt), und es ist schwierig, eine gute Bewertungsfunktion zu schreiben Berechnen Sie, wie gut der aktuelle Status ist.

Die Monte-Carlo-Baumsuche wendetMonte Carlo method auf die Spielbaumsuche an. Da es auf zufälligen Stichproben von Spielzuständen basiert, muss es sich nicht aus jeder Möglichkeit herauszwingen. Außerdem müssen wir nicht unbedingt eine Bewertung oder gute heuristische Funktionen schreiben.

Und eine kurze Bemerkung: Es revolutionierte die Welt von Computer Go. Seit März 2016 ist es ein weit verbreitetes Forschungsthema geworden, da GooglesAlphaGo (erstellt mit MCTS und neuronalen Netzen)Lee Sedol (den Weltmeister in Go) schlagen.

3. Monte-Carlo-Baumsuchalgorithmus

Lassen Sie uns nun untersuchen, wie der Algorithmus funktioniert. Zunächst erstellen wir einen Lookahead-Baum (Spielbaum) mit einem Stammknoten und erweitern ihn dann mit zufälligen Rollouts. Dabei behalten wir die Anzahl der Besuche und die Anzahl der Gewinne für jeden Knoten bei.

Am Ende werden wir den Knoten mit den vielversprechendsten Statistiken auswählen.

Der Algorithmus besteht aus vier Phasen; Lassen Sie uns alle im Detail untersuchen.

3.1. Auswahl

In dieser Anfangsphase beginnt der Algorithmus mit einem Stammknoten und wählt einen untergeordneten Knoten so aus, dass er den Knoten mit der maximalen Gewinnrate auswählt. Wir möchten auch sicherstellen, dass jedem Knoten eine faire Chance eingeräumt wird.

The idea is to keep selecting optimal child nodes until we reach the leaf node of the tree. Eine gute Möglichkeit, einen solchen untergeordneten Knoten auszuwählen, ist die Verwendung der UCT-Formel (Upper Confidence Bound, angewendet auf Bäume):imageIn welcher

  • wi = Anzahl der Siege nach dem s-ten Zug voni

  • ni = Anzahl der Simulationen nach deri-ten Bewegung

  • c = Explorationsparameter (theoretisch gleich √2)

  • t = Gesamtzahl der Simulationen für den übergeordneten Knoten

Die Formel stellt sicher, dass kein Staat dem Hunger zum Opfer fällt, und sie spielt auch vielversprechende Branchen häufiger als ihre Amtskollegen.

3.2. Erweiterung

Wenn es UCT nicht mehr anwenden kann, um den Nachfolgeknoten zu finden, wird der Spielbaum erweitert, indem alle möglichen Zustände vom Blattknoten angehängt werden.

3.3. Simulation

Nach der Erweiterung wählt der Algorithmus willkürlich einen untergeordneten Knoten aus und simuliert ein zufälliges Spiel vom ausgewählten Knoten, bis der resultierende Status des Spiels erreicht ist. Wenn Knoten während des Abspielens zufällig oder halbzufällig ausgewählt werden, spricht man von einem leichten Abspielvorgang. Sie können sich auch für ein intensives Ausspielen entscheiden, indem Sie hochwertige Heuristiken oder Bewertungsfunktionen schreiben.

3.4. Backpropagation

Dies wird auch als Aktualisierungsphase bezeichnet. Sobald der Algorithmus das Ende des Spiels erreicht, wertet er den Zustand aus, um herauszufinden, welcher Spieler gewonnen hat. Es bewegt sich nach oben zur Wurzel und erhöht die Besuchspunktzahl für alle besuchten Knoten. Außerdem wird die Gewinnpunktzahl für jeden Knoten aktualisiert, wenn der Spieler für diese Position das Playout gewonnen hat.

MCTS wiederholt diese vier Phasen so lange, bis eine feste Anzahl von Iterationen oder eine feste Zeitspanne erreicht ist.

Bei diesem Ansatz schätzen wir die Gewinnpunktzahl für jeden Knoten basierend auf zufälligen Zügen. Je höher die Anzahl der Iterationen, desto zuverlässiger wird die Schätzung. Die Algorithmusschätzungen sind zu Beginn einer Suche ungenauer und verbessern sich nach einer ausreichenden Zeitspanne weiter. Auch hier kommt es nur auf die Art des Problems an.

4. Probelauf

imageimage

Hier enthalten Knoten Statistiken als Gesamtbesuchs- / Gewinnpunktzahl.

5. Implementierung

Lassen Sie uns nun ein Tic-Tac-Toe-Spiel implementieren - unter Verwendung des Monte-Carlo-Baumsuchalgorithmus.

Wir werden eine allgemeine Lösung für MCTS entwickeln, die auch für viele andere Brettspiele verwendet werden kann. Wir werden uns den größten Teil des Codes im Artikel selbst ansehen.

Um die Erklärung klar zu machen, müssen wir möglicherweise einige kleinere Details überspringen (die nicht besonders mit MCTS zusammenhängen), aber Sie können immer die vollständige Implementierunghere finden.

Zunächst benötigen wir eine grundlegende Implementierung für die KlassenTree undNode, um über eine Baumsuchfunktion zu verfügen:

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

Da jeder Knoten einen bestimmten Status des Problems aufweist, implementieren wir auch eineState-Klasse:

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

Implementieren wir nun die KlasseMonteCarloTreeSearch, die dafür verantwortlich ist, den nächstbesten Zug von der angegebenen Spielposition aus zu finden:

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

Hier durchlaufen wir alle vier Phasen bis zum festgelegten Zeitpunkt und erhalten am Ende einen Baum mit zuverlässigen Statistiken, um eine kluge Entscheidung zu treffen.

Lassen Sie uns nun Methoden für alle Phasen implementieren.

We will start with the selection phase, für die ebenfalls eine UCT-Implementierung erforderlich ist:

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

In dieser Phase wird ein Blattknoten empfohlen, der in der Erweiterungsphase weiter ausgebaut werden sollte:

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. Außerdem haben wir eineupdate-Funktion, um die Punktzahl und die Anzahl der Besuche vom Blatt bis zur Wurzel zu verbreiten:

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

Nun sind wir mit der Implementierung von MCTS fertig. Alles, was wir brauchen, ist eine Tic-Tac-Toe-Klassenimplementierung vonBoard. Beachten Sie, dass Sie mit unserer Implementierung andere Spiele spielen können. Wir müssen nur die Klasse vonBoardändern.

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

Wir haben gerade eine KI implementiert, die in Tic-Tac-Toe nicht zu übertreffen ist. Schreiben wir einen Einheitsfall, der zeigt, dass KI vs. KI führt immer zu einem Unentschieden:

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

  • Es erfordert nicht unbedingt taktische Kenntnisse über das Spiel

  • Eine allgemeine MCTS-Implementierung kann mit nur geringen Änderungen für eine beliebige Anzahl von Spielen wiederverwendet werden

  • Konzentriert sich auf Knoten mit höheren Gewinnchancen

  • Geeignet für Probleme mit hohem Verzweigungsfaktor, da nicht alle möglichen Verzweigungen berechnet werden müssen

  • Algorithmus ist sehr einfach zu implementieren

  • Die Ausführung kann jederzeit gestoppt werden, und es wird immer noch der nächstbeste bisher berechnete Status angezeigt

7. Nachteil

Wenn MCTS in seiner Grundform ohne Verbesserungen verwendet wird, schlägt es möglicherweise keine sinnvollen Maßnahmen vor. Es kann vorkommen, dass Knoten nicht ausreichend besucht werden, was zu ungenauen Schätzungen führt.

MCTS kann jedoch unter Verwendung einiger Techniken verbessert werden. Es beinhaltet sowohl domänenspezifische als auch domänenunabhängige Techniken.

Bei domänenspezifischen Techniken führt die Simulationsstufe zu realistischeren Wiedergaben als zu stochastischen Simulationen. Es erfordert jedoch Kenntnisse über spielspezifische Techniken und Regeln.

8. Zusammenfassung

Auf den ersten Blick ist es schwierig zu vertrauen, dass ein Algorithmus, der auf zufälligen Entscheidungen beruht, zu einer intelligenten KI führen kann. Eine durchdachte Implementierung von MCTS kann uns jedoch tatsächlich eine Lösung bieten, die sowohl in vielen Spielen als auch bei Entscheidungsproblemen eingesetzt werden kann.

Wie immer kann der vollständige Code für den Algorithmus inover on GitHub gefunden werden.