Pencarian Pohon Monte Carlo untuk Permainan Tic-Tac-Toe di Java

1. Gambaran keseluruhan

Dalam artikel ini, kita akan meneroka algoritma Monte Carlo Tree Search (MCTS) dan aplikasinya.

Kami akan melihat fasa-fasa tersebut secara terperinci dengan menerapkan permainan Tic-Tac-Toe di Java . Kami akan merancang penyelesaian umum yang boleh digunakan dalam banyak aplikasi praktikal lain, dengan sedikit perubahan.

2. Pengenalan

Ringkasnya, carian pokok Monte Carlo adalah algoritma carian probabilistik. Ini adalah algoritma pembuatan keputusan yang unik kerana kecekapannya dalam persekitaran terbuka dengan sejumlah besar kemungkinan.

Sekiranya anda sudah biasa dengan algoritma teori permainan seperti Minimax, ia memerlukan fungsi untuk menilai keadaan semasa, dan ia harus menghitung banyak peringkat di pohon permainan untuk mencari langkah yang optimum.

Sayangnya, tidak dapat dilakukan dalam permainan seperti Go di mana terdapat faktor percabangan yang tinggi (mengakibatkan berjuta-juta kemungkinan apabila ketinggian pokok meningkat), dan sukar untuk menulis fungsi penilaian yang baik untuk menghitung seberapa baik keadaan semasa adalah.

Pencarian pokok Monte Carlo menggunakan kaedah Monte Carlo untuk pencarian pokok permainan. Oleh kerana berdasarkan pada persampelan rawak keadaan permainan, tidak perlu melakukan kekerasan keluar dari setiap kemungkinan. Juga, ini tidak semestinya memerlukan kita menulis penilaian atau fungsi heuristik yang baik.

Dan, catatan sampingan pantas - ia merevolusikan dunia komputer Go. Sejak bulan Mac 2016, topik ini menjadi topik penyelidikan yang lazim kerana AlphaGo Google (dibangun dengan MCTS dan rangkaian saraf) mengalahkan Lee Sedol (juara dunia di Go).

3. Algoritma Pencarian Pokok Monte Carlo

Sekarang, mari kita terokai bagaimana algoritma berfungsi. Pada mulanya, kami akan membina pohon lookahead (pokok permainan) dengan simpul akar, dan kemudian kami akan terus mengembangkannya dengan peluncuran rawak. Dalam prosesnya, kami akan mengekalkan jumlah kunjungan dan jumlah kemenangan untuk setiap nod.

Pada akhirnya, kita akan memilih simpul dengan statistik yang paling menjanjikan.

Algoritma terdiri daripada empat fasa; mari kita meneroka semuanya secara terperinci.

3.1. Pemilihan

Pada fasa awal ini, algoritma dimulakan dengan simpul akar dan memilih simpul anak sehingga memilih simpul dengan kadar kemenangan maksimum. Kami juga ingin memastikan bahawa setiap simpul diberi peluang yang adil.

Ideanya adalah untuk terus memilih simpul anak yang optimum sehingga kita mencapai simpul daun pokok. Kaedah yang baik untuk memilih simpul anak seperti itu adalah dengan menggunakan formula UCT (Upper Confidence Bound diterapkan pada pokok):

Di mana

  • w i = bilangan kemenangan selepas i -th bergerak
  • n i = bilangan simulasi selepas pergerakan i -th
  • c = parameter penerokaan (secara teori sama dengan √2)
  • t = jumlah simulasi bagi nod induk

Rumusannya memastikan bahawa tidak ada negara yang menjadi mangsa kelaparan dan ia juga memainkan cabang-cabang yang menjanjikan lebih kerap daripada rakan-rakan mereka.

3.2. Pengembangan

Apabila tidak lagi dapat menggunakan UCT untuk mencari simpul pengganti, ia mengembangkan pohon permainan dengan menambahkan semua keadaan yang mungkin dari simpul daun.

3.3. Simulasi

Setelah Perluasan, algoritma memilih simpul anak dengan sewenang-wenangnya, dan ia mensimulasikan permainan rawak dari simpul yang dipilih sehingga mencapai keadaan permainan yang dihasilkan. Sekiranya simpul dipilih secara rawak atau separa rawak semasa bermain, ia dipanggil permainan ringan. Anda juga boleh memilih untuk bermain dengan menulis heuristik berkualiti atau fungsi penilaian.

3.4. Backpropagation

Ini juga dikenali sebagai fasa kemas kini. Setelah algoritma mencapai akhir permainan, ia menilai keadaan untuk mengetahui pemain mana yang telah menang. Ini melintasi ke atas hingga akar dan menambah skor lawatan untuk semua nod yang dikunjungi. Ia juga mengemas kini skor kemenangan untuk setiap simpul jika pemain untuk kedudukan tersebut telah memenangi playout.

MCTS terus mengulangi keempat-empat fasa ini sehingga beberapa bilangan lelaran tetap atau sejumlah waktu tetap.

Dalam pendekatan ini, kami mengira skor kemenangan untuk setiap nod berdasarkan pergerakan rawak. Semakin tinggi bilangan lelaran, anggaran akan lebih dipercayai. Anggaran algoritma akan kurang tepat pada permulaan carian dan terus bertambah baik setelah jumlah masa yang mencukupi. Sekali lagi ia bergantung pada jenis masalahnya.

4. Larian Kering

Di sini, node mengandungi statistik sebagai jumlah lawatan / skor kemenangan.

5. Pelaksanaan

Sekarang, mari kita laksanakan permainan Tic-Tac-Toe - menggunakan algoritma carian pokok Monte Carlo.

Kami akan merancang penyelesaian umum untuk MCTS yang dapat digunakan untuk banyak permainan papan lain juga. Kita akan melihat sebahagian besar kod dalam artikel itu sendiri.

Walaupun untuk membuat penjelasannya ringkas, kami mungkin harus melangkau beberapa butiran kecil (tidak berkaitan dengan MCTS), tetapi anda selalu dapat mengetahui pelaksanaannya di sini.

Pertama sekali, kami memerlukan pelaksanaan asas untuk kelas Pohon dan Node untuk mempunyai fungsi carian pokok:

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

Oleh kerana setiap node mempunyai keadaan masalah tertentu, mari kita laksanakan kelas Negeri juga:

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

Now, let's implement MonteCarloTreeSearch class, which will be responsible for finding the next best move from the given game position:

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()  0) { nodeToExplore = promisingNode.getRandomChildNode(); } int playoutResult = simulateRandomPlayout(nodeToExplore); backPropogation(nodeToExplore, playoutResult); } Node winnerNode = rootNode.getChildWithMaxScore(); tree.setRoot(winnerNode); return winnerNode.getState().getBoard(); } }

Here, we keep iterating over all of the four phases until the predefined time, and at the end, we get a tree with reliable statistics to make a smart decision.

Now, let's implement methods for all the phases.

We will start with the selection phase which requires UCT implementation as well:

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

This phase recommends a leaf node which should be expanded further in the expansion phase:

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. Also, we will have an update function to propagate score and visit count starting from leaf to root:

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

Now we are done with the implementation of MCTS. All we need is a Tic-Tac-Toe particular Board class implementation. Notice that to play other games with our implementation; We just need to change Board class.

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

We just implemented an AI which can not be beaten in Tic-Tac-Toe. Let's write a unit case which demonstrates that AI vs. AI will always result in a draw:

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

  • It does not necessarily require any tactical knowledge about the game
  • A general MCTS implementation can be reused for any number of games with little modification
  • Focuses on nodes with higher chances of winning the game
  • Suitable for problems with high branching factor as it does not waste computations on all possible branches
  • Algorithm is very straightforward to implement
  • Execution can be stopped at any given time, and it will still suggest the next best state computed so far

7. Drawback

If MCTS is used in its basic form without any improvements, it may fail to suggest reasonable moves. It may happen if nodes are not visited adequately which results in inaccurate estimates.

However, MCTS can be improved using some techniques. It involves domain specific as well as domain-independent techniques.

In domain specific techniques, simulation stage produces more realistic play outs rather than stochastic simulations. Though it requires knowledge of game specific techniques and rules.

8. Summary

Pada pandangan pertama, sukar untuk mempercayai bahawa algoritma yang bergantung pada pilihan rawak boleh menyebabkan AI pintar. Namun, pelaksanaan MCTS yang teliti memang dapat memberikan kita solusi yang dapat digunakan dalam banyak permainan dan juga dalam masalah membuat keputusan.

Seperti biasa, kod lengkap untuk algoritma boleh didapati di GitHub.