terça-feira, 6 de março de 2012

Othello e Minimax com poda alpha beta - parte 1

Olá pesssoal, tudo bem?
Nesse post vamos aplicar o famoso Minimax com poda alfa-beta, um dos meus algoritmos preferidos. Como aplicação, não queria escolher algo tão simples como Tic-Tac-Toe, nem tão complexo como xadrez. Foi aí que eu descobri o jogo Othello, também conhecido como Reversi.

Nosso objetivo é criar uma engine simples, mas que jogue de maneira convincente. O interessante no Othello é que o jogo ainda não foi 'resolvido' computacionalmente, pelo menos no tabuleiro padrão 8 x 8. Além disso é extremamente simples de aprender, e as estratégias básicas são fáceis de codificar. Caso não conheça o jogo, visite o link acima e tente algumas applets, depois volte aqui!

Minimax

A idéia básica do Minimax é minimizar a perda máxima, ou caso você seja otimista, maximizar o ganho mínimo. Para isso, a partir de determinada posição, geramos a árvore com todas as posições possíveis até determinada profundidade. Em seguida, percorremos essa árvore de baixo para cima, escolhendo os lances que maximizam nossos ganhos e lembrando que o nosso oponente fará o mesmo. Como temos um jogo de soma-zero , sob nosso pontos de vista o oponente irá minimizar seus ganhos, daí vem o nome.

Se você já jogou xadrez contra uma engine forte, é fácil perceber esse mecanismo de 'ganho mínimo' em ação. Ao fazer um lance fraco ou anti-posicional, vemos como a engine pouco a pouco nos força a uma posição inferior, mesmo quando nos defendemos corretamente (minimizamos nossa perda máxima). Ao fazer um lance realmente ruim, o nosso score cai abruptmente e o da engine aumenta a mesma quantia, e não é incomum um mate em 15 ou mais lances ser anunciado: provavelmente você foi pego pela poda alfa-beta!

Poda alfa-beta

Voltando ao exemplo do xadrez, como uma engine consegue determinar quase que instantaneamente uma situação de mate em 10, por exemplo? A resposta é simples: otimizações. O alpha-beta pruning, ou poda alfa-beta, é uma das otimizações que podem ser aplicadas ao Minimax, com o objetivo de reduzir o tempo de busca. Outra otimização que funciona junto com a poda alfa-beta é a ordenação de lances. No exemplo do mate em 10, são essas duas técnicas trabalhando junto que fazem a engine ser tão eficiente.

Para entender como isso funciona, imagine o seguinte: é a sua vez de mover e você tem 20 lances disponíveis. Ao analizar a árvore do primeiro lance (A), percebemos que ele nos dá uma vantagem decisiva em todas as variantes. Ao analizar o segundo lance disponível (B), percebemos que este pode ser imediatamente neutralizado pelo adversário. Obviamente não vale a pena continuar expandindo a árvore do lance B, pois sabemos que podemos fazer melhor com A. Ou seja, os nós provenientes de B não serão expandidos, e assim ganhamos tempo de execução. A ordenação de lances acelera esse processo, colocando os lances mais promissores para serem avalidados primeiro. Para codificar esse mecanismo o algoritmo mantém duas variáveis, alpha e beta, que representam os melhores scores atingidos pelos jogadores até então.

Heurística

Como vimos acima, precisamos de um método para decidir se uma determinada posição é vantajosa ou não. No nosso caso, as estratégias básicas do Othello já nos garantem uma engine razoável. Geralmente o nível de sofisticação e a força de uma engine refletem a complexidade da heurística utilizada. O programa Logistello, por exemplo, contém milhares de variáveis e padrões pré-calculados, além de utilizar técnicas estatísticas e A.I. para refinar parâmetros.

Após implementar o framework básico do nosso programa, você pode facilmente extender a heurística, tomando o cuidado de testar cada nova função separadamente. Outra idéia é criar um pequeno framework para testar a força do programa, o ideal seria utilizar uma engine superior como adversária. A maioria das engines de xadrez adotam o protocolo UCI para se comunicar com uma GUI. No nosso caso, Othello Engine Protocol parece ser a solução.

O código

Vamos começar definindo a estrutura básica do jogo, criando as classes OthelloBoard e OthelloMove. Na parte de AI, a classe MinimaxOthello é responsável por executar o algoritmo em uma Thread separada, e a classe OthelloHeuristics define métodos para avaliar a 'qualidade' de determinada posição. Finalmente, criamos uma GUI para testar a engine. Mãos a obra!

Classe OthelloBoard

Essa classe define apenas 4 variáveis de instância: emptyCells, phase, currentPlayer e cells. A variável phase nos diz em qual fase do jogo estamos (abertura, meio-jogo ou final) e será utilizada na hora de avaliar a posição, pois cada fase exige uma estratégia diferente. A array cells representa as casas do tabuleiro 8 x 8, e será interpretada como uma array 10 x 10, onde as 2 linhas e colunas extras irão facilitar a listagem de lances possíveis. Por fim, currentPlayer representa o jogador que tem a vez, e emptyCells nos diz quantas casas estão vazias.

Os métodos makeMove e undoMove serão utilizados no Minimax para gerar a árvore de posições, evitando criar milhares de cópias do objeto OthelloBoard que será passado ao algoritmo. O método getAllMoves retorna uma lista com todos os lances possíveis para determinado jogador, iterando sobre as células e invocando getFlips para cada casa vazia. Para checar um possível fim de jogo, checkEnd verifica se não existem mais casas vazias, ou se nenhum dos jogadores possui lances válidos.

O restante do código são getters, setters, ou métodos triviais como cloneBoard. É fundamental entender como lidamos com a situação em que um jogador não possui lances disponíveis: getAllMoves retorna um lance 'vazio', que é processado normalmente dentro de makeMove ou undoMove, assim não precisamos tratar deste caso especial ao implementar o algoritmo. Segue a classe:

import java.util.*;

public class OthelloBoard
{
   public static final int BLACK = 1;
   public static final int WHITE = 2;
   public static final int EMPTY = 0;
   public static final int WALL = 5;
   public static final int BOARD_SIZE = 8;
   public static final int[] DIRECTIONS = { -11, -10, -9, -1, 1, 9, 10, 11}; 
   public static final int PHASE_OPENING = 100;
   public static final int PHASE_MIDGAME = 200;
   public static final int PHASE_ENDGAME = 300;
    
   private int emptyCells;        
   private int phase;
   private int currentPlayer = BLACK;
   private int[] cells = new int[(BOARD_SIZE + 2) * (BOARD_SIZE + 2)];
 
   public OthelloBoard()
   {
        this.currentPlayer = BLACK;
  
        cells[44] = WHITE;
        cells[55] = WHITE;
        cells[45] = BLACK;
        cells[54] = BLACK;        
        
        emptyCells = BOARD_SIZE * BOARD_SIZE - 4;
        
        for (int i = 0; i < cells.length; i++) 
        {
            int col = i % 10;
            int row = i / 10;
            
            if (col == 0 || col == 9)
                cells[i] = WALL;
            if (row == 0 || row == 9)
                cells[i] = WALL;
        }
        
        phase = PHASE_OPENING;                            
   }
    
    public void toogleCurrentPlayer()
    {
        currentPlayer = getOpponent(currentPlayer);
    }
                
    public void setEmptyCells(int emp)
    {
        this.emptyCells = emp;
    }

    public boolean checkEnd()
    {
        boolean endGame = false;
        
        if (emptyCells == 0)
            endGame = true;
        else if ((getAllMoves(BLACK).get(0).getFlipSquares() == null) && 
                 (getAllMoves(WHITE).get(0).getFlipSquares() == null))
        {
            endGame = true;
        }                
        
        return endGame;
    }
    
    public OthelloBoard cloneBoard()
    {
        OthelloBoard b = new OthelloBoard();
        for (int i = 0; i < this.cells.length; i++)
            b.cells[i] = this.cells[i];
        
        b.phase = this.phase;
        b.currentPlayer = this.currentPlayer;
        b.emptyCells = this.emptyCells;        
        
        return b;
    }
        
    public int getEmptyCells()
    {
        return this.emptyCells;
    }
    
    public int getPhase()
    {
        return this.phase;
    }
    
    public int getCurrentPlayer()
    {
        return this.currentPlayer;
    }
    
    public int getOpponent(int player)
    {
        return 3 - player;
    }

    public int[] getCells()
    {
        return this.cells;
    }
       
    public ArrayList<OthelloMove> getAllMoves(int player)
    {
        ArrayList<OthelloMove> moves = new ArrayList<OthelloMove>();
        
        for (int i = 10; i < 90; i++) 
        {
            int col = i % 10;
            
            if (col != 0 && col != 9)            
            {
                if (cells[i] == EMPTY)
                {
                    ArrayList<Integer> flips = getFlips(i, player);                    
                    if (flips.size() > 0)
                    {
                        OthelloMove mv = new OthelloMove();
                        mv.setFlipSquares(flips);
                        mv.setIdx(i);                        
                        mv.setPlayer(player);
                        moves.add(mv);
                    }
                }
            }
        }                                     
        
        if (moves.size() == 0)
        {
            OthelloMove mv = new OthelloMove();
            mv.setPlayer(getOpponent(player));
            moves.add(mv);
        }
        return moves;
    }
    
    public ArrayList<Integer> getFlips(int idx, int player)
    {        
        int opponent = getOpponent(player);
        ArrayList<Integer> flips = new ArrayList<Integer>();
                      
        for (Integer dir : DIRECTIONS)
        {
           int distance = 1;
           int tempIdx = idx;
                        
           while (cells[tempIdx += dir] == opponent)            
               distance++;
                                                
           if ((cells[tempIdx] == player) && (distance > 1)) 
           {               
               while (distance-- > 1)
               {                    
                   tempIdx -= dir;
                   flips.add(tempIdx);
               }                            
           }            
         }
        return flips;
    }
    
    public void updatePhase()
    {
        if (emptyCells > 45)
            phase = PHASE_OPENING;
        else if(emptyCells < 15)
            phase = PHASE_ENDGAME;
        else 
            phase = PHASE_MIDGAME; 
    }
    
    public void makeMove (OthelloMove move)
    {                    
        int player = move.getPlayer();        
        ArrayList<Integer> flips = move.getFlipSquares();
        
        if (flips != null)
        {                                   
            int idx = move.getIdx();                    
            cells[idx] = player;
            for (Integer flip : flips)
                cells[flip] = player;        
                                  
            emptyCells--;    
            this.updatePhase();           
        }
        this.toogleCurrentPlayer();                    
     }
    
    public void undoMove (OthelloMove move)
    {                    
        int player = move.getPlayer();        
        ArrayList<Integer> flips = move.getFlipSquares();
        int opponent = getOpponent(player);
        
        if (flips != null)
        {
            int idx = move.getIdx();
                        
            cells[idx] = EMPTY;
            for (Integer flip : flips)
                cells[flip] = opponent;
            
            emptyCells++;                                         
            this.updatePhase();
        }
        this.toogleCurrentPlayer();                    
    }
    
    public int countDiscs(int player)
    {
        int discs = 0;
        for (int i = 10; i < 90; i++) 
        {
            int col = i % 10;
            
            if (col != 0 && col != 9)
            {
                if (cells[i] == player)                
                    discs++;                
            }            
        }
        return discs;
    }
}

Classe OthelloMove

Além das informações necessárias para fazer e desfazer um lance, definimos a variável de instância eval, que é utilizada pela classe MoveComparator para ordenar os lances. Segue a classe:

import java.util.*;

public class OthelloMove
{
    private ArrayList<Integer> flipSquares;
    private int idx;
    private int player;
    private int eval;
    
    public ArrayList<Integer> getFlipSquares()
    {
       return flipSquares;
    }

    public void setEval(int eval)
    {
        this.eval = eval;
    }

    public int getEval()
    {
        return this.eval;
    }

    public int getPlayer()
    {
        return this.player;
    }

    public void setPlayer(int player)
    {
        this.player = player;
    }

    public int getIdx()
    {
       return idx;
    }

   public void setFlipSquares(ArrayList<Integer> flipSquares)
   {
       this.flipSquares = flipSquares;
   }

   public void setIdx(int idx)
   {
       this.idx = idx;
   }
}

/* classe auxiliar */
class MoveComparator implements Comparator<OthelloMove>
{
    public int compare(OthelloMove move1, OthelloMove move2)
    {
        if (move1.getEval() > move2.getEval())
            return 1;
        else if (move1.getEval() < move2.getEval())
            return -1;
        else 
            return 0;
    }

}

Na segunda parte desta série vamos implementar as classes restantes e ver testar a engine. Até mais!

2 comentários:

  1. As informações sobre os anexos são confidenciais e destinados exclusivamente ao destinatário você.

    ResponderExcluir
  2. As informações sobre os anexos são confidenciais e destinados exclusivamente ao destinatário você.

    ResponderExcluir