Skip to content

Commit

Permalink
Refactored games to generalise & add asymmetric games (#1413)
Browse files Browse the repository at this point in the history
* Added asymmetric games and made regular games a subclass

* small improvements to code style

* fixing docs mock...

* Revert "fixing docs mock..."

This reverts commit 09fb251.

* used IntEnum to simplify

* small improvements & a fix

* added asymmetric games to docs

* added werror if invalid payoff matrices are given

* removed .item()

* changed dbs.action_to_int to use IntEnum behaviour

* Revert "changed dbs.action_to_int to use IntEnum behaviour"

This reverts commit bb6171c.

* made library code more robust wrt integer actions

* all strategies now work with integer actions

* added tests and fixed __eq__

* improved coverage

* changed Action back to Enum and added casting to score

* added casting test

* removed numpy mocking

* changed doc due to doctests being picky

* re-fixed doctest examples

* review changes

* Added tutorial for implementing new games

* fixed formatting

* made doctests work
  • Loading branch information
alexhroom committed Apr 24, 2023
1 parent c27ad09 commit afac86a
Show file tree
Hide file tree
Showing 10 changed files with 378 additions and 34 deletions.
1 change: 1 addition & 0 deletions .github/workflows/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion axelrod/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 86 additions & 25 deletions axelrod/game.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from typing import Tuple, Union
from enum import Enum

import numpy as np

from axelrod import Action

Expand All @@ -7,7 +10,7 @@
Score = Union[int, float]


class Game(object):
class AsymmetricGame(object):
"""Container for the game matrix and scoring logic.
Attributes
Expand All @@ -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
Expand All @@ -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."""
Expand All @@ -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())

Expand Down
6 changes: 2 additions & 4 deletions axelrod/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions axelrod/tests/property.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
lists,
sampled_from,
)
from hypothesis.extra.numpy import arrays


@composite
Expand Down Expand Up @@ -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)
53 changes: 52 additions & 1 deletion axelrod/tests/unit/test_game.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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()))
3 changes: 0 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@
"matplotlib.transforms",
"mpl_toolkits.axes_grid1",
"multiprocess",
"numpy",
"numpy.linalg",
"numpy.random",
"pandas",
"pandas.util",
"pandas.util.decorators",
Expand Down
37 changes: 37 additions & 0 deletions docs/how-to/use_different_stage_games.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.. _use_different_stage_games:

Use different stage games
=========================

Expand Down Expand Up @@ -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. <implement-new-games>`

0 comments on commit afac86a

Please sign in to comment.