Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added player assumption classification #1418

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e51a310
added attributes to mock player
alexhroom May 2, 2023
5e4f350
fixed mock player addition
alexhroom May 2, 2023
81d2524
added test for size checking
alexhroom May 2, 2023
d0351a5
added actions_size to Classifier
alexhroom May 3, 2023
ce7d798
added actions_size to tutorial
alexhroom May 3, 2023
d037915
re-added the actual implementation (git shenanigans)
alexhroom May 3, 2023
c1f6ab0
fixed typo
alexhroom May 3, 2023
cdb72d8
fixed docstring
alexhroom May 5, 2023
0737227
changed size checking to a general assumptions model
alexhroom May 8, 2023
76f93b6
fixed some bugs
alexhroom May 8, 2023
0cffa9b
added some tests
alexhroom May 8, 2023
44992be
added user option for strictness of assumption checking
alexhroom May 8, 2023
a443887
fixed tests
alexhroom May 8, 2023
89c438c
updated docs
alexhroom May 8, 2023
8b4aa6c
added assumptions_satisfy helper method
alexhroom May 11, 2023
c4359bc
removed unnecessary if statement
alexhroom May 11, 2023
46cbf02
Revert "removed unnecessary if statement"
alexhroom May 11, 2023
f705ffd
added test for assumptions_satisfy
alexhroom May 12, 2023
58a7218
changed attributes -> characteristics
alexhroom May 12, 2023
24eccc5
more attributes -> characteristics
alexhroom May 12, 2023
88d65d4
changed name to game_characteristics when not directly referencing a …
alexhroom May 16, 2023
16048c1
Adds a read the docs config file (#1423)
alexhroom May 17, 2023
fc80c19
Merge branch 'dev' into action-size-classification
alexhroom May 18, 2023
d59cab9
removed default characteristic
alexhroom May 22, 2023
22b61cf
made mock player safer
alexhroom May 22, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: 2

build:
os: ubuntu-22.04
tools:
python: "3.11"

sphinx:
configuration: docs/conf.py

python:
install:
- requirements: docs/requirements.txt
2 changes: 2 additions & 0 deletions axelrod/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def classify_player(self, player: Type[Player]) -> T:
manipulates_state = Classifier[Optional[bool]](
"manipulates_state", lambda _: None
)
assumptions = Classifier[Optional[dict]]("assumptions", lambda _: {'actions_size': 2})

# Should list all known classifiers.
all_classifiers = [
Expand All @@ -82,6 +83,7 @@ def classify_player(self, player: Type[Player]) -> T:
inspects_source,
manipulates_source,
manipulates_state,
assumptions,
]

all_classifiers_map = {c.name: c.classify_player for c in all_classifiers}
Expand Down
41 changes: 37 additions & 4 deletions axelrod/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ class AsymmetricGame(object):
----------
scores: dict
The numerical score attribute to all combinations of action pairs.
attributes: dict
A dictionary of attributes of the game. Used to ensure strategies
used with the game are valid.
"""

# pylint: disable=invalid-name
def __init__(self, A: np.array, B: np.array) -> None:
def __init__(self, A: np.array, B: np.array, **characteristics) -> None:
"""
Creates an asymmetric game from two matrices.

Expand All @@ -30,6 +33,9 @@ def __init__(self, A: np.array, B: np.array) -> None:
the payoff matrix for player A.
B: np.array
the payoff matrix for player B.
**characteristics
optional characteristics detailing features of the game. Used
to ensure strategies used with the game are valid.
"""

if A.shape != B.transpose().shape:
Expand All @@ -41,6 +47,8 @@ def __init__(self, A: np.array, B: np.array) -> None:
self.A = A
self.B = B

self.characteristics = characteristics

self.scores = {
pair: self.score(pair) for pair in ((C, C), (D, D), (C, D), (D, C))
}
Expand Down Expand Up @@ -75,6 +83,27 @@ def get_value(x):

return (self.A[row][col], self.B[row][col])

@property
def characteristics(self):
return self._characteristics

@characteristics.setter
def characteristics(self, characteristics):
"""
Adds or changes game characteristics.

Parameters
----------
characteristics: dict
characteristics to add to the game. If the added
characteristic already exists, it will overwrite the
previous value.
"""
try:
self._characteristics = {**self._characteristics, **characteristics}
except AttributeError:
self._characteristics = characteristics

def __repr__(self) -> str:
return "Axelrod game with matrices: {}".format((self.A, self.B))

Expand All @@ -97,7 +126,7 @@ class Game(AsymmetricGame):
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:
def __init__(self, r: Score = 3, s: Score = 0, t: Score = 5, p: Score = 1, **characteristics) -> None:
"""Create a new game object.

Parameters
Expand All @@ -110,10 +139,14 @@ def __init__(self, r: Score = 3, s: Score = 0, t: Score = 5, p: Score = 1) -> No
Score obtained by a player for defecting against a cooperator.
p: int or float
Score obtained by both player for mutual defection.
**attributes
optional attributes for the game. Used
to ensure strategies used with the game are valid.

"""
A = np.array([[r, s], [t, p]])

super().__init__(A, A.transpose())
super().__init__(A, A.transpose(), **characteristics)

def RPST(self) -> Tuple[Score, Score, Score, Score]:
"""Returns game matrix values in Press and Dyson notation."""
Expand All @@ -132,4 +165,4 @@ def __eq__(self, other):
return self.RPST() == other.RPST()


DefaultGame = Game()
DefaultGame = Game(3, 0, 5, 1, game_type='prisoners_dilemma')
alexhroom marked this conversation as resolved.
Show resolved Hide resolved
13 changes: 13 additions & 0 deletions axelrod/match.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def __init__(
match_attributes=None,
reset=True,
seed=None,
strict_player_checking=True
):
"""
Parameters
Expand All @@ -54,6 +55,9 @@ def __init__(
Whether to reset players or not
seed : int
Random seed for reproducibility
strict_player_checking: bool, default True
If True, throws an error if strategies make assumptions which aren't
compatible with the game. if False, just produces warnings instead.
"""

defaults = {
Expand Down Expand Up @@ -89,6 +93,7 @@ def __init__(
else:
self.match_attributes = match_attributes

self.strict_player_checking = strict_player_checking
self.players = list(players)
self.reset = reset

Expand All @@ -111,6 +116,14 @@ def players(self):
def players(self, players):
"""Ensure that players are passed the match attributes"""
newplayers = []
# ensure the game satisfies the player assumptions
# note the game size characteristic is added here because the player
# and coplayer may have different game sizes if the game is asymmetric!
players[0].check_assumptions({**self.game.characteristics, 'actions_size': self.game.A.shape[0]},
raise_error=self.strict_player_checking)
players[1].check_assumptions({**self.game.characteristics, 'actions_size': self.game.B.shape[0]},
raise_error=self.strict_player_checking)

for player in players:
player.set_match_attributes(**self.match_attributes)
newplayers.append(player)
Expand Down
19 changes: 16 additions & 3 deletions axelrod/mock_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,31 @@


class MockPlayer(Player):
"""Creates a mock player that plays a given sequence of actions. If
no actions are given, plays like Cooperator. Used for testing.
"""Creates a mock player that cycles through a given
sequence of actions. If no actions are given,
plays like Cooperator. Used for testing.

Parameters
----------
actions: List[Action], default []
The sequence of actions played by the mock player.
attributes: dict, default {}
A dictionary of player attributes.
"""

name = "Mock Player"

def __init__(self, actions: List[Action] = None) -> None:
def __init__(self, actions: List[Action] = None, classifier: dict = None) -> None:
super().__init__()
if not actions:
actions = []
self.actions = cycle(actions)

if not classifier:
self.classifier = {}
else:
self.classifier = classifier

def strategy(self, opponent: Player) -> Action:
# Return the next saved action, if present.
try:
Expand Down
58 changes: 58 additions & 0 deletions axelrod/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,64 @@ def reset(self):
def update_history(self, play, coplay):
self.history.append(play, coplay)

def check_assumptions(self, game_characteristics: dict, raise_error: bool=True):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the assumptions check be here or in the Match class? It's in a Match that Players and Games are combined (and also in the Moran process class, which uses the Match class). The Player class just needs to be initialized properly and be able to return values when .strategy() is called, it's not involved in the scoring process and so it doesn't need to care about the Game usually (unless it requires info about the game). Note that it's in a Match that Player instances are currently given info about the game or match.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checking assumptions has use cases outside of matches - if it's in the Player class we can use it for filtering, e.g. if we wanted all strategies from a list strategies that work on a game with assumptions my_assumptions, we could do

[strategy() for strategy in strategies if strategy().assumptions_satisfy(my_assumptions)].

Note that in general, the methods just take a dict, not a Game object specifically; the Game itself is still completely hidden from the Player class. In the Match class, the Players and Game are combined in the way that you want, without either of them having their whole class exposed to the other.

it doesn't need to care about the Game usually (unless it requires info about the game).

"how should strategies handle requiring info about the game" is really the issue that we want to solve in this PR! :)

"""
Compares the player assumptions to a dictionary of game characteristics.
Generates a warning or error if an assumption is not fulfilled.

Parameters:
-----------
game_characteristics: dict
The dictionary of game characteristics to compare the player's assumptions to.
raise_error: bool, default True
If True, raises an error if the assumption is violated. Else,
just generate a warning.
"""

for key, value in self.classifier.get('assumptions', {}).items():
msg = None
if key not in game_characteristics.keys():
msg = ("Player {} assumes that "
"the game has the attribute {}, "
"but the game does not declare this attribute."
"".format(self.name, key))
elif value != game_characteristics[key]:
msg = ("Player {} assumes that the game attribute "
"{} is set to {}, but it is actually set to {}."
"".format(self.name, key, value, game_characteristics[key]))

if msg is not None:
if raise_error:
raise RuntimeError(msg)
warnings.warn(msg + " The strategy may not behave as expected.")

def assumptions_satisfy(self, game_characteristics: dict) -> bool:
"""
Compares the player assumptions to a dictionary of game characteristics.
Returns True if the player assumptions are all satisfied by
these characteristics, and False otherwise.

Parameters:
-----------
game_characteristics: dict
The dictionary of game characteristics to compare the player's assumptions to.

Returns
-------
bool
A boolean of whether or not the game characteristics satisfy the player's
assumptions.
"""

# we use check_assumptions as our 'base' rather than the other way
# around as check_assumptions needs finer grained understanding of
# the assumptions to produce useful error messages
try:
self.check_assumptions(game_characteristics, raise_error=True)
except RuntimeError:
return False
return True

@property
def history(self):
return self._history
Expand Down
36 changes: 36 additions & 0 deletions axelrod/tests/strategies/test_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,42 @@ def test_init_kwargs(self):
TypeError, ParameterisedTestPlayer, "other", "other", "other"
)

def test_assumption_checking(self):
"""
Checks that assumptions are checked, and warnings
or errors are raised when they're unfulfilled.
"""
player = axl.MockPlayer(classifier={'assumptions': {'foo': True, 'bar': 3}})

# these should pass without errors/warnings
player.check_assumptions({'foo': True, 'bar': 3}) # correct characteristics
player.check_assumptions({'foo': True, 'bar': 3, 'baz': []}) # extraneous characteristic

with self.assertRaises(RuntimeError):
player.check_assumptions({'foo': True}) # missing characteristic
with self.assertRaises(RuntimeError):
player.check_assumptions({'foo': True, 'bar': 5}) # invalid charateristic value

with self.assertWarns(UserWarning):
player.check_assumptions({'foo': True}, raise_error=False) # missing characteristic
with self.assertWarns(UserWarning):
player.check_assumptions({'foo': True, 'bar': 5}, raise_error=False) # invalid charateristic value

def test_assumptions_satisfy(self):
"""
Tests that the assumptions_satisfy() method works as intended.
It is a wrapper around check_assumptions() so the actual assumption
testing logic is checked more thoroughly there.
"""
player = axl.MockPlayer(classifier={'assumptions': {'foo': True, 'bar': 3}})

self.assertEqual(player.assumptions_satisfy({'foo': True, 'bar': 3}), True) # correct characteristics
self.assertEqual(player.assumptions_satisfy({'foo': True, 'bar': 3, 'baz': []}), True) # extraneous characteristic
self.assertEqual(player.assumptions_satisfy({'foo': True}), False) # missing characteristic
self.assertEqual(player.assumptions_satisfy({'foo': True, 'bar': 5}), False) # invalid charateristic value




class TestOpponent(axl.Player):
"""A player who only exists so we have something to test against"""
Expand Down
17 changes: 15 additions & 2 deletions axelrod/tests/unit/test_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

import axelrod as axl
from axelrod.deterministic_cache import DeterministicCache
from axelrod.mock_player import MockPlayer
from axelrod.random_ import RandomGenerator
from axelrod.tests.property import games
from hypothesis import example, given
from axelrod.tests.property import games, asymmetric_games
from hypothesis import example, given, settings
from hypothesis.strategies import floats, integers

C, D = axl.Action.C, axl.Action.D
Expand Down Expand Up @@ -354,6 +355,18 @@ def test_sparklines(self):
expected_sparklines = "XXXX\nXYXY"
self.assertEqual(match.sparklines("X", "Y"), expected_sparklines)

@given(game=asymmetric_games(), n1=integers(min_value=2), n2=integers(min_value=2))
@settings(max_examples=5)
def test_game_size_checking(self, game, n1, n2):
"""Tests warnings, errors or normal flow agrees with player action size."""
player1 = MockPlayer(classifier={'assumptions': {'actions_size': n1}})
player2 = MockPlayer(classifier={'assumptions': {'actions_size': n2}})

if (n1 != game.A.shape[0] or n2 != game.B.shape[0]):
with self.assertRaises(RuntimeError):
match = axl.Match((player1, player2), game=game)
else:
match = axl.Match((player1, player2), game=game)

class TestSampleLength(unittest.TestCase):
def test_sample_length(self):
Expand Down
1 change: 1 addition & 0 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
docutils <= 0.17 # Added this for a problem with sphinx https://github.com/sphinx-doc/sphinx/commit/13803a79e7179f40a27f46d5a5a05f1eebbcbb63
numpy==1.24.3 # numpy isn't mocked due to complex use in doctests
7 changes: 7 additions & 0 deletions docs/tutorials/implement_new_games/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ an initialisation parameter for which move they start with::
... "inspects_source": False,
... "manipulates_source": False,
... "manipulates_state": False,
... "assumptions": {"actions_size": 3},
... }
...
... def __init__(self, starting_move=S):
Expand Down Expand Up @@ -153,6 +154,7 @@ an initialisation parameter for which move they start with::
... "inspects_source": False,
... "manipulates_source": False,
... "manipulates_state": False,
... "assumptions": {"actions_size": 3},
... }
...
... def __init__(self, starting_move=S):
Expand All @@ -165,6 +167,11 @@ an initialisation parameter for which move they start with::
... return self.starting_move
... return self.history[-1].rotate()

Note that in the classifier for each strategy we set 'actions_size' to `3`. This
is how we let Axelrod know that this strategy is expecting to have 3 possible actions,
and when a match is created, it will check to make sure that this assumption is
satisfied by the game.

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::

Expand Down