Monte Carlo Tree Search para o jogo Tic-Tac-Toe

Monte Carlo Tree Search para o jogo Tic-Tac-Toe

1. Visão geral

Neste artigo, vamos explorarMonte Carlo Tree Search (MCTS) algorithm e seus aplicativos.

Veremos suas fases em detalhes porimplementing the game of Tic-Tac-Toe in Java. Vamos projetar uma solução geral que pode ser usada em muitas outras aplicações práticas, com mudanças mínimas.

2. Introdução

Simplificando, a pesquisa de árvores em Monte Carlo é um algoritmo de pesquisa probabilístico. É um algoritmo de tomada de decisão único devido à sua eficiência em ambientes abertos com uma enorme quantidade de possibilidades.

Se você já está familiarizado com algoritmos da teoria dos jogos comoMinimax, é necessária uma função para avaliar o estado atual e computar muitos níveis na árvore do jogo para encontrar o movimento ideal.

Infelizmente, não é viável fazer isso em um jogo comoGo em que há um alto fator de ramificação (resultando em milhões de possibilidades conforme a altura da árvore aumenta), e é difícil escrever uma boa função de avaliação para calcule o quão bom é o estado atual.

A pesquisa da árvore de Monte Carlo aplicaMonte Carlo method à pesquisa da árvore do jogo. Como é baseado na amostragem aleatória dos estados do jogo, não é necessário forçar a saída brutalmente de cada possibilidade. Além disso, não exige necessariamente que escrevamos uma avaliação ou boas funções heurísticas.

E, uma rápida observação lateral - revolucionou o mundo do computador Go. Desde março de 2016, tornou-se um tópico de pesquisa predominante, já queAlphaGo (construído com MCTS e rede neural) do Google venceuLee Sedol (campeão mundial em Go).

3. Algoritmo de pesquisa de árvore de Monte Carlo

Agora, vamos explorar como o algoritmo funciona. Inicialmente, vamos construir uma árvore de lookahead (árvore do jogo) com um nó raiz, e então vamos continuar expandindo com lançamentos aleatórios. No processo, manteremos a contagem de visitas e de vitórias para cada nó.

No final, vamos selecionar o nó com estatísticas mais promissoras.

O algoritmo consiste em quatro fases; vamos explorar todos eles em detalhes.

3.1. Seleção

Nesta fase inicial, o algoritmo começa com um nó raiz e seleciona um nó filho de forma que ele escolha o nó com taxa de vitória máxima. Também queremos garantir que cada nó tenha uma chance justa.

The idea is to keep selecting optimal child nodes until we reach the leaf node of the tree. Uma boa maneira de selecionar esse nó filho é usar a fórmula UCT (limite de confiança superior aplicado às árvores):imageIn que

  • wi = número de vitórias após ai-ésima jogada

  • ni = número de simulações após oi-ésimo movimento

  • c = parâmetro de exploração (teoricamente igual a √2)

  • t = número total de simulações para o nó pai

A fórmula garante que nenhum estado será vítima de fome e também desempenha ramos promissores com mais frequência do que seus pares.

3.2. Expansão

Quando ele não pode mais aplicar o UCT para localizar o nó sucessor, ele expande a árvore do jogo anexando todos os estados possíveis do nó folha.

3.3. Simulação

Após a expansão, o algoritmo escolhe um nó filho arbitrariamente e simula um jogo aleatório do nó selecionado até atingir o estado resultante do jogo. Se os nós são selecionados aleatoriamente ou semi-aleatoriamente durante a reprodução, isso é chamado de reprodução leve. Você também pode optar por uma execução pesada escrevendo funções de avaliação ou heurísticas de qualidade.

3.4. Retropropagação

Isso também é conhecido como fase de atualização. Quando o algoritmo chega ao final do jogo, ele avalia o estado para descobrir qual jogador ganhou. Ele percorre a raiz e aumenta a pontuação de visitas para todos os nós visitados. Ele também atualiza a pontuação de vitória para cada nó se o jogador para essa posição venceu o playout.

O MCTS continua repetindo essas quatro fases até algum número fixo de iterações ou algum período fixo de tempo.

Nesta abordagem, estimamos a pontuação vencedora para cada nó com base em movimentos aleatórios. Quanto maior o número de iterações, mais confiável a estimativa se torna. As estimativas do algoritmo serão menos precisas no início de uma pesquisa e continuarão melhorando após um período de tempo suficiente. Novamente, depende apenas do tipo do problema.

4. Funcionamento a seco

imageimage

Aqui, os nós contêm estatísticas como total de visitas / vitória.

5. Implementação

Agora, vamos implementar um jogo de Tic-Tac-Toe - usando o algoritmo de pesquisa de árvore de Monte Carlo.

Vamos projetar uma solução generalizada para MCTS que pode ser utilizada para muitos outros jogos de tabuleiro também. Vamos dar uma olhada na maior parte do código no próprio artigo.

Embora para tornar a explicação clara, podemos ter que pular alguns pequenos detalhes (não particularmente relacionados ao MCTS), mas você sempre pode encontrar a implementação completahere.

Em primeiro lugar, precisamos de uma implementação básica para as classesTreeeNode para ter uma funcionalidade de pesquisa em árvore:

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

Como cada nó terá um estado particular do problema, vamos implementar uma classeState também:

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

Agora, vamos implementar a classeMonteCarloTreeSearch, que será responsável por encontrar o próximo melhor movimento a partir da posição de jogo dada:

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

Aqui, continuamos repetindo todas as quatro fases até o tempo predefinido e, no final, obtemos uma árvore com estatísticas confiáveis ​​para tomar uma decisão inteligente.

Agora, vamos implementar métodos para todas as fases.

We will start with the selection phase que requer a implementação de UCT também:

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

Esta fase recomenda um nó folha que deve ser expandido ainda mais na fase de expansão:

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. Além disso, teremos uma funçãoupdate para propagar a pontuação e a contagem de visitas começando da folha à raiz:

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

Agora terminamos a implementação do MCTS. Tudo o que precisamos é uma implementação de classeBoard específica do jogo da velha. Observe que para jogar outros jogos com nossa implementação; Precisamos apenas mudar a 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;
    }
}

Acabamos de implementar uma IA que não pode ser derrotada em Tic-Tac-Toe. Vamos escrever um caso de unidade que demonstra que IA vs. A IA sempre resultará em um empate:

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

  • Não requer necessariamente nenhum conhecimento tático sobre o jogo

  • Uma implementação geral do MCTS pode ser reutilizada para qualquer número de jogos com poucas modificações

  • Concentra-se em nós com maiores chances de ganhar o jogo

  • Adequado para problemas com alto fator de ramificação, pois não desperdiça cálculos em todas as ramificações possíveis

  • O algoritmo é muito simples de implementar

  • A execução pode ser interrompida a qualquer momento e ainda sugere o próximo melhor estado calculado até o momento

7. Recua

Se o MCTS for usado em sua forma básica sem melhorias, ele poderá deixar de sugerir movimentos razoáveis. Isso pode acontecer se os nós não forem visitados adequadamente, o que resulta em estimativas imprecisas.

No entanto, o MCTS pode ser melhorado usando algumas técnicas. Envolve técnicas específicas de domínio, bem como técnicas independentes de domínio.

Em técnicas específicas de domínio, o estágio de simulação produz playouts mais realistas, em vez de simulações estocásticas. Embora exija conhecimento de técnicas e regras específicas do jogo.

8. Sumário

À primeira vista, é difícil confiar que um algoritmo baseado em escolhas aleatórias pode levar a IA inteligente. No entanto, a implementação cuidadosa do MCTS pode realmente nos fornecer uma solução que pode ser usada em muitos jogos e também em problemas de tomada de decisão.

Como sempre, o código completo para o algoritmo pode ser encontradoover on GitHub.