diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index cd19ca861..5826a7784 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -38,6 +38,7 @@ jobs: python -m pip install sphinx python -m pip install sphinx_rtd_theme python -m pip install mock + python -m pip install numpy cd docs; make clean; make html; cd ..; - name: Run doctests run: | diff --git a/axelrod/__init__.py b/axelrod/__init__.py index 573af1bb9..dc2eefa81 100644 --- a/axelrod/__init__.py +++ b/axelrod/__init__.py @@ -16,7 +16,7 @@ from axelrod.load_data_ import load_pso_tables, load_weights from axelrod import graph from axelrod.plot import Plot -from axelrod.game import DefaultGame, Game +from axelrod.game import DefaultGame, AsymmetricGame, Game from axelrod.history import History, LimitedHistory from axelrod.player import Player from axelrod.classifier import Classifiers diff --git a/axelrod/game.py b/axelrod/game.py index 180a9f110..6b95bbbf3 100644 --- a/axelrod/game.py +++ b/axelrod/game.py @@ -1,4 +1,7 @@ from typing import Tuple, Union +from enum import Enum + +import numpy as np from axelrod import Action @@ -7,7 +10,7 @@ Score = Union[int, float] -class Game(object): +class AsymmetricGame(object): """Container for the game matrix and scoring logic. Attributes @@ -16,9 +19,85 @@ class Game(object): The numerical score attribute to all combinations of action pairs. """ - def __init__( - self, r: Score = 3, s: Score = 0, t: Score = 5, p: Score = 1 - ) -> None: + # pylint: disable=invalid-name + def __init__(self, A: np.array, B: np.array) -> None: + """ + Creates an asymmetric game from two matrices. + + Parameters + ---------- + A: np.array + the payoff matrix for player A. + B: np.array + the payoff matrix for player B. + """ + + if A.shape != B.transpose().shape: + raise ValueError( + "AsymmetricGame was given invalid payoff matrices; the shape " + "of matrix A should be the shape of B's transpose matrix." + ) + + self.A = A + self.B = B + + self.scores = { + pair: self.score(pair) for pair in ((C, C), (D, D), (C, D), (D, C)) + } + + def score( + self, pair: Union[Tuple[Action, Action], Tuple[int, int]] + ) -> Tuple[Score, Score]: + """Returns the appropriate score for a decision pair. + Parameters + ---------- + pair: tuple(int, int) or tuple(Action, Action) + A pair of actions for two players, for example (0, 1) corresponds + to the row player choosing their first action and the column + player choosing their second action; in the prisoners' dilemma, + this is equivalent to player 1 cooperating and player 2 defecting. + Can also be a pair of Actions, where C corresponds to '0' + and D to '1'. + + Returns + ------- + tuple of int or float + Scores for two player resulting from their actions. + """ + + # if an Action has been passed to the method, + # get which integer the Action corresponds to + def get_value(x): + if isinstance(x, Enum): + return x.value + return x + row, col = map(get_value, pair) + + return (self.A[row][col], self.B[row][col]) + + def __repr__(self) -> str: + return "Axelrod game with matrices: {}".format((self.A, self.B)) + + def __eq__(self, other): + if not isinstance(other, AsymmetricGame): + return False + return self.A.all() == other.A.all() and self.B.all() == other.B.all() + + +class Game(AsymmetricGame): + """ + Simplification of the AsymmetricGame class for symmetric games. + Takes advantage of Press and Dyson notation. + + Can currently only be 2x2. + + Attributes + ---------- + scores: dict + The numerical score attribute to all combinations of action pairs. + """ + + def __init__(self, r: Score = 3, s: Score = 0, t: Score = 5, p: Score = 1) -> None: """Create a new game object. Parameters @@ -32,12 +111,9 @@ def __init__( p: int or float Score obtained by both player for mutual defection. """ - self.scores = { - (C, C): (r, r), - (D, D): (p, p), - (C, D): (s, t), - (D, C): (t, s), - } + A = np.array([[r, s], [t, p]]) + + super().__init__(A, A.transpose()) def RPST(self) -> Tuple[Score, Score, Score, Score]: """Returns game matrix values in Press and Dyson notation.""" @@ -47,21 +123,6 @@ def RPST(self) -> Tuple[Score, Score, Score, Score]: T = self.scores[(D, C)][0] return R, P, S, T - def score(self, pair: Tuple[Action, Action]) -> Tuple[Score, Score]: - """Returns the appropriate score for a decision pair. - - Parameters - ---------- - pair: tuple(Action, Action) - A pair actions for two players, for example (C, C). - - Returns - ------- - tuple of int or float - Scores for two player resulting from their actions. - """ - return self.scores[pair] - def __repr__(self) -> str: return "Axelrod game: (R,P,S,T) = {}".format(self.RPST()) diff --git a/axelrod/history.py b/axelrod/history.py index aea7b1f64..12114399b 100644 --- a/axelrod/history.py +++ b/axelrod/history.py @@ -130,12 +130,10 @@ def flip_plays(self): def append(self, play, coplay): """Appends a new (play, coplay) pair an updates metadata for number of cooperations and defections, and the state distribution.""" - self._plays.append(play) self._actions[play] += 1 - if coplay: - self._coplays.append(coplay) - self._state_distribution[(play, coplay)] += 1 + self._coplays.append(coplay) + self._state_distribution[(play, coplay)] += 1 if len(self._plays) > self.memory_depth: first_play, first_coplay = self._plays.pop(0), self._coplays.pop(0) self._actions[first_play] -= 1 diff --git a/axelrod/tests/property.py b/axelrod/tests/property.py index 282079a38..21648fac8 100644 --- a/axelrod/tests/property.py +++ b/axelrod/tests/property.py @@ -11,6 +11,7 @@ lists, sampled_from, ) +from hypothesis.extra.numpy import arrays @composite @@ -381,3 +382,16 @@ def games(draw, prisoners_dilemma=True, max_value=100): game = axl.Game(r=r, s=s, t=t, p=p) return game + + +@composite +def asymmetric_games(draw, valid=True): + """Hypothesis decorator to draw a random asymmetric game.""" + + rows = draw(integers(min_value=2, max_value=255)) + cols = draw(integers(min_value=2, max_value=255)) + + A = draw(arrays(int, (rows, cols))) + B = draw(arrays(int, (cols, rows))) + + return axl.AsymmetricGame(A, B) \ No newline at end of file diff --git a/axelrod/tests/unit/test_game.py b/axelrod/tests/unit/test_game.py index e14eb0fb4..d97fa7b0a 100644 --- a/axelrod/tests/unit/test_game.py +++ b/axelrod/tests/unit/test_game.py @@ -1,9 +1,13 @@ import unittest +import numpy as np + import axelrod as axl -from axelrod.tests.property import games +from axelrod.tests.property import games, asymmetric_games from hypothesis import given, settings from hypothesis.strategies import integers +from hypothesis.extra.numpy import arrays, array_shapes + C, D = axl.Action.C, axl.Action.D @@ -77,3 +81,50 @@ def test_random_repr(self, game): expected_repr = "Axelrod game: (R,P,S,T) = {}".format(game.RPST()) self.assertEqual(expected_repr, game.__repr__()) self.assertEqual(expected_repr, str(game)) + + @given(game=games()) + def test_integer_actions(self, game): + """Test Actions and integers are treated equivalently.""" + pair_ints = { + (C, C): (0 ,0), + (C, D): (0, 1), + (D, C): (1, 0), + (D, D): (1, 1) + } + for key, value in pair_ints.items(): + self.assertEqual(game.score(key), game.score(value)) + +class TestAsymmetricGame(unittest.TestCase): + @given(A=arrays(int, array_shapes(min_dims=2, max_dims=2, min_side=2)), + B=arrays(int, array_shapes(min_dims=2, max_dims=2, min_side=2))) + @settings(max_examples=5) + def test_invalid_matrices(self, A, B): + """Test that an error is raised when the matrices aren't the right size.""" + # ensures that an error is raised when the shapes are invalid, + # and not raised otherwise + error_raised = False + try: + game = axl.AsymmetricGame(A, B) + except ValueError: + error_raised = True + + self.assertEqual(error_raised, (A.shape != B.transpose().shape)) + + @given(asymgame=asymmetric_games()) + @settings(max_examples=5) + def test_random_repr(self, asymgame): + """Test repr with random scores.""" + expected_repr = "Axelrod game with matrices: {}".format((asymgame.A, asymgame.B)) + self.assertEqual(expected_repr, asymgame.__repr__()) + self.assertEqual(expected_repr, str(asymgame)) + + @given(asymgame1=asymmetric_games(), + asymgame2=asymmetric_games()) + @settings(max_examples=5) + def test_equality(self, asymgame1, asymgame2): + """Tests equality of AsymmetricGames based on their matrices.""" + self.assertFalse(asymgame1=='foo') + self.assertEqual(asymgame1, asymgame1) + self.assertEqual(asymgame2, asymgame2) + self.assertEqual((asymgame1==asymgame2), (asymgame1.A.all() == asymgame2.A.all() + and asymgame1.B.all() == asymgame2.B.all())) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 7c2df0fa9..c632251b3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,9 +26,6 @@ "matplotlib.transforms", "mpl_toolkits.axes_grid1", "multiprocess", - "numpy", - "numpy.linalg", - "numpy.random", "pandas", "pandas.util", "pandas.util.decorators", diff --git a/docs/how-to/use_different_stage_games.rst b/docs/how-to/use_different_stage_games.rst index c86b536ee..517c98555 100644 --- a/docs/how-to/use_different_stage_games.rst +++ b/docs/how-to/use_different_stage_games.rst @@ -1,3 +1,5 @@ +.. _use_different_stage_games: + Use different stage games ========================= @@ -47,3 +49,38 @@ The default Prisoner's dilemma has different results:: >>> results = tournament.play() >>> results.ranked_names ['Defector', 'Tit For Tat', 'Cooperator'] + +Asymmetric games can also be implemented via the AsymmetricGame class +with two Numpy arrays for payoff matrices:: + + >>> import numpy as np + >>> A = np.array([[3, 1], [1, 3]]) + >>> B = np.array([[1, 3], [2, 1]]) + >>> asymmetric_game = axl.AsymmetricGame(A, B) + >>> asymmetric_game # doctest: +NORMALIZE_WHITESPACE + Axelrod game with matrices: (array([[3, 1], + [1, 3]]), + array([[1, 3], + [2, 1]])) + +Asymmetric games can also be different sizes (even if symmetric; regular games +can currently only be 2x2), such as Rock Paper Scissors:: + + >>> A = np.array([[0, -1, 1], [1, 0, -1], [-1, 1, 0]]) + >>> rock_paper_scissors = axl.AsymmetricGame(A, -A) + >>> rock_paper_scissors # doctest: +NORMALIZE_WHITESPACE + Axelrod game with matrices: (array([[ 0, -1, 1], + [ 1, 0, -1], + [-1, 1, 0]]), + array([[ 0, 1, -1], + [-1, 0, 1], + [ 1, -1, 0]])) + +**NB: Some features of Axelrod, such as strategy transformers, are specifically created for +use with the iterated Prisoner's Dilemma; they may break with games of other sizes.** +Note also that most strategies in Axelrod are Prisoners' Dilemma strategies, so behave +as though they are playing the Prisoners' Dilemma; in the rock-paper-scissors example above, +they will certainly never choose scissors (because their strategy action set is two actions!) + +For a more detailed tutorial on how to implement another game into Axelrod, :ref:`here is a +tutorial using rock paper scissors as an example. ` \ No newline at end of file diff --git a/docs/tutorials/implement_new_games/index.rst b/docs/tutorials/implement_new_games/index.rst new file mode 100644 index 000000000..a9649defb --- /dev/null +++ b/docs/tutorials/implement_new_games/index.rst @@ -0,0 +1,184 @@ +.. _implement-new-games: +.. highlight:: python + +Implement new games +=================== + +Currently, the default :code:`Strategy`, :code:`Action` and :code:`Game` +implementations in Axelrod are centred around the Iterated Prisoners' Dilemma. +The stage game can be changed as shown in :ref:`use_different_stage_games`. + +However, just changing the stage game may not be sufficient. Take, for example, the +game rock-paper-scissors:: + + >>> import axelrod as axl + >>> import numpy as np + >>> A = np.array([[0, -1, 1], [1, 0, -1], [-1, 1, 0]]) + >>> rock_paper_scissors = axl.AsymmetricGame(A, -A) + >>> rock_paper_scissors # doctest: +NORMALIZE_WHITESPACE + Axelrod game with matrices: (array([[ 0, -1, 1], + [ 1, 0, -1], + [-1, 1, 0]]), + array([[ 0, 1, -1], + [-1, 0, 1], + [ 1, -1, 0]])) + +If we tried to run a rock-paper-scissors match with the :code:`Tit-For-Tat` strategy, +it wouldn't work properly. :code:`Tit-For-Tat` only knows of two actions (cooperate and defect, +corresponding to rows 1 and 2 respectively). If we tried to use it on rock-paper-scissors, it would +interpret the game in the following way: + +1. On the first turn, choose rock (option 1, cooperate) +2. If the opponent's last move is the Python object + :code:`axl.Action.D` (which it may never be unless the opponent also thinks it's playing IPD!), + then choose paper (option 2, defection) + +and so as we see, :code:`Tit-For-Tat` would simply play Rock every turn, unless it +were playing against another Prisoners' Dilemma strategy (then it +plays rock unless the opponent last played paper, in which case it plays paper). In +particular, it would never play scissors - it does not know that Scissors is something +it can even do. This is not a bug, or an issue with the strategy itself; +simply that :code:`Tit-For-Tat` *thinks* it is playing the Iterated Prisoners' Dilemma +and its :code:`Action` set, regardless of what the actual game is. + +Thus, if we wanted to implement new games we should also implement a new Action set, +and some new strategies. + +The Actions are relatively simple; they're an `Enum class `_, +with each action corresponding to a row/column (recall that Python starts counting from 0, +rather than 1). We can also implement some methods that we think might be useful for viewing +our actions and making strategies. (The Prisoners' Dilemma :code:`Action` class, for example, +has :code:`flip`, which flips a :code:`C` to a :code:`D` and vice versa!) + +A simple rock-paper-scissors action class would look like so:: + + >>> from enum import Enum + >>> class RPSAction(Enum): + ... """Actions for Rock-Paper-Scissors.""" + ... R = 0 # rock + ... P = 1 # paper + ... S = 2 # scissors + ... + ... def __repr__(self): + ... return self.name + ... + ... def __str__(self): + ... return self.name + ... + ... def rotate(self): + ... """ + ... Cycles one step through the actions. + ... Maps R->P, P->S, S->R + ... """ + ... rotations = { + ... RPSAction.R: RPSAction.P, + ... RPSAction.P: RPSAction.S, + ... RPSAction.S: RPSAction.R + ... } + ... + ... return rotations[self] + +We can then implement some strategies. Below we have the implementation of an +Axelrod strategy into Python. These follow the same format; + +* A subclass of the :code:`Player` class, with three parts: + + * A :code:`name` and a :code:`classifier` dictionary. + This is used for indexing strategies. + + * (Optionally) an :code:`__init__` method, which allows the setting + of initialisation variables (like probabilities of doing certain + actions, or starting moves) + + * A :code:`strategy` method, which takes the parameters :code:`self` + and :code:`opponent`, representing both players in the match, and provides + the algorithm for determining the player's next move. + +If we want, we can also initialise some shorthand for the actions to +avoid having to evoke their full names:: + + >>> R = RPSAction.R + >>> P = RPSAction.P + >>> S = RPSAction.S + +Here are a couple of examples. One is a strategy which copies the opponent's +previous move, and the other simply cycles through the moves. Both have +an initialisation parameter for which move they start with:: + + >>> from axelrod.player import Player + >>> class Copycat(Player): + ... """ + ... Starts with a chosen move, + ... and then copies their opponent's previous move. + ... + ... Parameters + ... ---------- + ... starting_move: RPSAction, default S + ... What move to play on the first round. + ... """ + ... name = "Copycat" + ... classifier = { + ... "memory_depth": 1, + ... "stochastic": False, + ... "long_run_time": False, + ... "inspects_source": False, + ... "manipulates_source": False, + ... "manipulates_state": False, + ... } + ... + ... def __init__(self, starting_move=S): + ... self.starting_move = starting_move + ... super().__init__() + ... + ... def strategy(self, opponent: Player) -> RPSAction: + ... """Actual strategy definition that determines player's action.""" + ... if not self.history: + ... return self.starting_move + ... return opponent.history[-1] + + >>> class Rotator(Player): + ... """ + ... Cycles through the moves from a chosen starting move. + ... + ... Parameters + ... ---------- + ... starting_move: RPSAction, default S + ... What move to play on the first round. + ... """ + ... name = "Rotator" + ... classifier = { + ... "memory_depth": 1, + ... "stochastic": False, + ... "long_run_time": False, + ... "inspects_source": False, + ... "manipulates_source": False, + ... "manipulates_state": False, + ... } + ... + ... def __init__(self, starting_move=S): + ... self.starting_move = starting_move + ... super().__init__() + ... + ... def strategy(self, opponent: Player) -> RPSAction: + ... """Actual strategy definition that determines player's action.""" + ... if not self.history: + ... return self.starting_move + ... return self.history[-1].rotate() + +We are now all set to run some matches and tournaments in our new game! +Let's start with a match between our two new players:: + + >>> match = axl.Match(players=(Copycat(starting_move=P), Rotator()), + ... turns=5, + ... game=rock_paper_scissors) + >>> match.play() + [(P, S), (S, R), (R, P), (P, S), (S, R)] + +and as with the Prisoners' Dilemma, we can run a tournament in the same way. Just +make sure you specify the game when creating the tournament!:: + + >>> tournament = axl.Tournament(players, game=rock_paper_scissors) # doctest: +SKIP + >>> tournament.play() # doctest: +SKIP + +where :code:`players` is set to a list of Rock-Paper-Scissors strategies; hopefully +more than two, else it isn't a very interesting tournament! diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index e4d8de34a..cf503b7dc 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -13,3 +13,4 @@ Contents: new_to_game_theory_and_or_python/index.rst running_axelrods_first_tournament/index.rst creating_heterogenous_player_moran_process/index.rst + implement_new_games/index.rst