Artificial Intelligence - Chapter 5 with Java
MINES Saint-Etienne
Table of Contents
Introduction
The goal of this tutorial is to understand how to (i) model adversarial games to solve and (ii) implement the search algorithms as described in AIMA. We take inspiration here from the algorithm provided by AIMA-Java Project. The slides containing the pseudocode are available here.
Simple Minimax implementation
As to check your understanding of the Minimax procedure, we will first implement a function that returns the minimax value of a tree. We use here a simple two-ply game tree :
package fr.emse.ai.util; import java.util.ArrayList; import java.util.List; public class SimpleTwoPlyGameTree<V> { private V value; private boolean max; private ArrayList<SimpleTwoPlyGameTree<V>> children; public SimpleTwoPlyGameTree(V value, boolean max) { this.value = value; this.max = max; children = new ArrayList<SimpleTwoPlyGameTree<V>>(); } public SimpleTwoPlyGameTree(V value, boolean max, List<SimpleTwoPlyGameTree<V>> children) { this(value, max); for (SimpleTwoPlyGameTree<V> child : children) this.children.add(child); } public boolean isLeaf() { return children.isEmpty(); } public boolean isMax() { return max; } public void addChild(SimpleTwoPlyGameTree<V> child) { this.children.add(child); } public V getValue() { return this.value; } public void setValue(V value) { this.value = value; } public ArrayList<SimpleTwoPlyGameTree<V>> getChildren() { return children; } public void setChildren(ArrayList<SimpleTwoPlyGameTree<V>> children) { this.children = children; } public String toString() { String s = ""; s += "value = " + value + " | "; s += "child = " + children; return s; } }
To instanciate such a tree, we can do the following:
ArrayList<SimpleTwoPlyGameTree<Integer>> sublist1 = new ArrayList<SimpleTwoPlyGameTree<Integer>>(); sublist1.add(new SimpleTwoPlyGameTree<Integer>(3,true)); sublist1.add(new SimpleTwoPlyGameTree<Integer>(12,true)); sublist1.add(new SimpleTwoPlyGameTree<Integer>(8,true)); SimpleTwoPlyGameTree<Integer> subtree1 = new SimpleTwoPlyGameTree<Integer>(Integer.MIN_VALUE,false,sublist1); ArrayList<SimpleTwoPlyGameTree<Integer>> sublist2 = new ArrayList<SimpleTwoPlyGameTree<Integer>>(); sublist2.add(new SimpleTwoPlyGameTree<Integer>(2,true)); sublist2.add(new SimpleTwoPlyGameTree<Integer>(4,true)); sublist2.add(new SimpleTwoPlyGameTree<Integer>(6,true)); SimpleTwoPlyGameTree<Integer> subtree2 = new SimpleTwoPlyGameTree<Integer>(Integer.MIN_VALUE,false,sublist2); ArrayList<SimpleTwoPlyGameTree<Integer>> sublist3 = new ArrayList<SimpleTwoPlyGameTree<Integer>>(); sublist3.add(new SimpleTwoPlyGameTree<Integer>(14,true)); sublist3.add(new SimpleTwoPlyGameTree<Integer>(5,true)); sublist3.add(new SimpleTwoPlyGameTree<Integer>(2,true)); SimpleTwoPlyGameTree<Integer> subtree3 = new SimpleTwoPlyGameTree<Integer>(Integer.MIN_VALUE,false,sublist3); tree1.addChild(subtree1); tree1.addChild(subtree2); tree1.addChild(subtree3);
This tree corresponds to the one used as an example in AIMA chapter 5.
Exercise 1
Download the SimpleTwoPlyGameTree
class here. Implement the minimax
function that returns the minimax value of a given instance of SimpleTwoPlyGameTree<Integer>
.
Modeling games
Let's now implement real games!
As to focus one the game modeling and the implementation of search algorithms, we provide here some reusable code.
The Game
interface
The first important structure we use is the Game
interface. To define a particular problem to solve, you must implement this interface.
package fr.emse.ai.adversarial; import java.util.List; public interface Game<STATE, ACTION, PLAYER> { STATE getInitialState(); PLAYER[] getPlayers(); PLAYER getPlayer(STATE state); List<ACTION> getActions(STATE state); STATE getResult(STATE state, ACTION action); boolean isTerminal(STATE state); double getUtility(STATE state, PLAYER player); }
This interface is parametric: its definition relies on the type of the states, the type of the actions and the type of the players.
This interface defines a game as stated in AIMA chapter 5:
getInitialState
returns the initial state of the game,getPlayers
returns the set of players (usually two, like0
and1
, ormax
andmin
),getPlayer
returns the active player of a given state,getActions
returns the list of possible actions for a given state,getResult
returns the state resulting from performing a given action to a given state,isTerminal
checks if a state is terminal,getUtility
return the utility value of a given state.
An example of game modeling
To be more concrete, let's take an example of a simple game, the Nim Game (more precisely Subtraction Game):
package fr.emse.ai.adversarial.nim; import fr.emse.ai.adversarial.Game; import java.util.ArrayList; import java.util.List; public class NimGame implements Game<List<Integer>, Integer, Integer> { public final static Integer[] players = {0, 1}; public final static List<Integer> initialState = new ArrayList<Integer>(); public NimGame(int size) { initialState.add(0); initialState.add(size); } @Override public List<Integer> getInitialState() { return initialState; } @Override public Integer[] getPlayers() { return players; } @Override public Integer getPlayer(List<Integer> state) { return state.get(0); } @Override public List<Integer> getActions(List<Integer> state) { ArrayList<Integer> actions = new ArrayList<Integer>(); for (int i = 1; i <= Math.min(3, state.get(1)); i++) actions.add(i); return actions; } @Override public List<Integer> getResult(List<Integer> state, Integer action) { ArrayList<Integer> newState = new ArrayList<Integer>(); newState.add(1 - state.get(0)); newState.add(state.get(1) - action); return newState; } @Override public boolean isTerminal(List<Integer> state) { return state.get(1) == 0; } @Override public double getUtility(List<Integer> state, Integer player) { if (state.get(0) == 1 - player) { if (state.get(1) == 1) return Double.POSITIVE_INFINITY; else if (((state.get(1) - 1) % 4) == 0) return 1; else return -1; } else { if (state.get(1) == 1) return Double.NEGATIVE_INFINITY; else if (((state.get(1) - 1) % 4) == 0) return -1; else return 1; } } }
In this game, two players (represented by Integer
values, 0
and 1
) alternatively pick 1, 2 or 3 sticks from a pool of sticks. The player who removes the last stick from the pool loses the game. Here the state of the game is represented by a List<Integer>
, with first cell containing the player, and the second cell containing the number of remaining stick. Actions are represented by Integer
values, i.e. the number of sticks removed by the player.
Look carefully at this example. The rationale behing the utility function is subject to your investigation…
Decision making algorithms
Playing games against a machine now requires equiping it with proper decision making mechanisms.
The AdversarialSearch
interface
To implement game algorithms, we provide the AdversarialSearch
interface:
package fr.emse.ai.adversarial; import fr.emse.ai.adversarial.core.Metrics; public interface AdversarialSearch<STATE, ACTION> { ACTION makeDecision(STATE state); Metrics getMetrics(); }
This interface is used to implement a game solver for a given state type and action type. It requires a method to make decision, i.e. returns an action to perform given a game state. The getMetrics
method will be used later on.
Some implementations of AdversarialSearch
As to focus on modeling games and analyzing the performances of different algorithms to play them, we provide some already implemented versions of Minimax search, alpha-beta search and iterative deepening Minimax search with alpha-beta pruning.
Here is the Minimax code:
package fr.emse.ai.adversarial; import fr.emse.ai.adversarial.core.Metrics; public class MinimaxSearch<STATE, ACTION, PLAYER> implements AdversarialSearch<STATE, ACTION> { private Game<STATE, ACTION, PLAYER> game; private int expandedNodes; public MinimaxSearch(Game<STATE, ACTION, PLAYER> game) { this.game = game; } @Override public ACTION makeDecision(STATE state) { expandedNodes = 0; ACTION result = null; double resultValue = Double.NEGATIVE_INFINITY; PLAYER player = game.getPlayer(state); for (ACTION action : game.getActions(state)) { double value = minValue(game.getResult(state, action), player); if (value > resultValue) { result = action; resultValue = value; } } return result; } public double maxValue(STATE state, PLAYER player) { // returns an utility // value expandedNodes++; if (game.isTerminal(state)) return game.getUtility(state, player); double value = Double.NEGATIVE_INFINITY; for (ACTION action : game.getActions(state)) value = Math.max(value, minValue(game.getResult(state, action), player)); return value; } public double minValue(STATE state, PLAYER player) { // returns an utility // value expandedNodes++; if (game.isTerminal(state)) return game.getUtility(state, player); double value = Double.POSITIVE_INFINITY; for (ACTION action : game.getActions(state)) value = Math.min(value, maxValue(game.getResult(state, action), player)); return value; } @Override public Metrics getMetrics() { Metrics result = new Metrics(); result.set("expandedNodes", expandedNodes); return result; } }
You can download all the search algorithms and required classes here.
Playing against the computer
Our agent is now able to make good decisions, let's now play against it!
As to do so, we must implement a main program that implement the two-ply game. For instance, for the Nim game, we can use the following code:
package fr.emse.ai.adversarial.nim; import fr.emse.ai.adversarial.AlphaBetaSearch; import fr.emse.ai.adversarial.IterativeDeepeningAlphaBetaSearch; import fr.emse.ai.adversarial.MinimaxSearch; import java.util.List; import java.util.Scanner; public class NimGameplay { public static void main(String[] args) { NimGame game = new NimGame(20); MinimaxSearch<List<Integer>, Integer, Integer> minimaxSearch = MinimaxSearch.createFor(game); AlphaBetaSearch<List<Integer>, Integer, Integer> alphabetaSearch = AlphaBetaSearch.createFor(game); IterativeDeepeningAlphaBetaSearch<List<Integer>, Integer, Integer> iterativeDeepeningAlphaBetaSearch = IterativeDeepeningAlphaBetaSearch.createFor(game, -1, 1, 10); List<Integer> state = game.getInitialState(); while (!game.isTerminal(state)) { System.out.println("======================"); System.out.println(state); int action = -1; if (state.get(0) == 0) { //human List<Integer> actions = game.getActions(state); Scanner in = new Scanner(System.in); while (!actions.contains(action)) { System.out.println("Human player, what is your action?"); action = in.nextInt(); } } else { //machine System.out.println("Machine player, what is your action?"); action = minimaxSearch.makeDecision(state); System.out.println("Metrics for Minimax : " + minimaxSearch.getMetrics()); alphabetaSearch.makeDecision(state); System.out.println("Metrics for AlphaBeta : " + alphabetaSearch.getMetrics()); iterativeDeepeningAlphaBetaSearch.makeDecision(state); System.out.println("Metrics for IDAlphaBetaSearch : " + iterativeDeepeningAlphaBetaSearch.getMetrics()); } System.out.println("Chosen action is " + action); state = game.getResult(state, action); } System.out.print("GAME OVER: "); if (state.get(0) == 0) System.out.println("Human wins!"); else System.out.println("Machine wins!"); } }
Exercises
Exercise 3
Model and implement the tic-tac-toe game. Compare the performance of the different provided algorithms.
Exercise 4
Model and implement the Connect Four game. Compare the performance of the different provided algorithms.