Skip to content

Commit 737fd4e

Browse files
committed
Merge pull request #534 from marcharper/moran-process
Moran process
2 parents df5cc14 + 9a950d8 commit 737fd4e

File tree

7 files changed

+283
-4
lines changed

7 files changed

+283
-4
lines changed

axelrod/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .player import init_args, is_basic, obey_axelrod, update_history, Player
99
from .mock_player import MockPlayer, simulate_play
1010
from .match import Match
11+
from .moran import MoranProcess
1112
from .strategies import *
1213
from .deterministic_cache import DeterministicCache
1314
from .match_generator import *

axelrod/match.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
C, D = Actions.C, Actions.D
88

99

10+
def is_stochastic(players, noise):
11+
"""Determines if a match is stochastic -- true if there is noise or if any
12+
of the players involved is stochastic."""
13+
return (noise or any(p.classifier['stochastic'] for p in players))
14+
1015
class Match(object):
1116

1217
def __init__(self, players, turns, deterministic_cache=None, noise=0):
@@ -38,10 +43,7 @@ def _stochastic(self):
3843
A boolean to show whether a match between two players would be
3944
stochastic
4045
"""
41-
return (
42-
self._noise or
43-
any(p.classifier['stochastic'] for p in self.players)
44-
)
46+
return is_stochastic(self.players, self._noise)
4547

4648
@property
4749
def _cache_update_required(self):

axelrod/moran.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# -*- coding: utf-8 -*-
2+
from collections import Counter
3+
import random
4+
5+
import numpy as np
6+
7+
from .deterministic_cache import DeterministicCache
8+
from .match import Match, is_stochastic
9+
from .player import Player
10+
from .random_ import randrange
11+
12+
13+
def fitness_proportionate_selection(scores):
14+
"""Randomly selects an individual proportionally to score.
15+
16+
Parameters
17+
----------
18+
scores: Any sequence of real numbers
19+
20+
Returns
21+
-------
22+
An index of the above list selected at random proportionally to the list
23+
element divided by the total.
24+
"""
25+
csums = np.cumsum(scores)
26+
total = csums[-1]
27+
r = random.random() * total
28+
29+
for i, x in enumerate(csums):
30+
if x >= r:
31+
return i
32+
33+
class MoranProcess(object):
34+
def __init__(self, players, turns=100, noise=0):
35+
self.turns = turns
36+
self.noise = noise
37+
self.players = list(players) # initial population
38+
self.winning_strategy_name = None
39+
self.populations = []
40+
self.populations.append(self.population_distribution())
41+
self.score_history = []
42+
self.num_players = len(self.players)
43+
44+
@property
45+
def _stochastic(self):
46+
"""
47+
A boolean to show whether a match between two players would be
48+
stochastic
49+
"""
50+
return is_stochastic(self.players, self.noise)
51+
52+
def __next__(self):
53+
"""Iterate the population:
54+
- play the round's matches
55+
- chooses a player proportionally to fitness (total score) to reproduce
56+
- choose a player at random to be replaced
57+
- update the population
58+
"""
59+
# Check the exit condition, that all players are of the same type.
60+
population = self.populations[-1]
61+
classes = set(p.__class__ for p in self.players)
62+
if len(classes) == 1:
63+
self.winning_strategy_name = str(self.players[0])
64+
raise StopIteration
65+
scores = self._play_next_round()
66+
# Update the population
67+
# Fitness proportionate selection
68+
j = fitness_proportionate_selection(scores)
69+
# Randomly remove a strategy
70+
i = randrange(0, len(self.players))
71+
# Replace player i with clone of player j
72+
self.players[i] = self.players[j].clone()
73+
self.populations.append(self.population_distribution())
74+
75+
def _play_next_round(self):
76+
"""Plays the next round of the process. Every player is paired up
77+
against every other player and the total scores are recorded."""
78+
N = self.num_players
79+
scores = [0] * N
80+
for i in range(N):
81+
for j in range(i + 1, N):
82+
player1 = self.players[i]
83+
player2 = self.players[j]
84+
player1.reset()
85+
player2.reset()
86+
match = Match((player1, player2), self.turns, noise=self.noise)
87+
match.play()
88+
match_scores = np.sum(match.scores(), axis=0) / float(self.turns)
89+
scores[i] += match_scores[0]
90+
scores[j] += match_scores[1]
91+
self.score_history.append(scores)
92+
return scores
93+
94+
def population_distribution(self):
95+
"""Returns the population distribution of the last iteration."""
96+
player_names = [str(player) for player in self.players]
97+
counter = Counter(player_names)
98+
return counter
99+
100+
next = __next__ # Python 2
101+
102+
def __iter__(self):
103+
return self
104+
105+
def reset(self):
106+
"""Reset the process to replay."""
107+
self.winning_strategy_name = None
108+
self.populations = [self.populations[0]]
109+
self.score_history = []
110+
111+
def play(self):
112+
"""Play the process out to completion."""
113+
while True:
114+
try:
115+
self.__next__()
116+
except StopIteration:
117+
break
118+
119+
def __len__(self):
120+
return len(self.populations)

axelrod/random_.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ def random_choice(p=0.5):
1212
if r < p:
1313
return Actions.C
1414
return Actions.D
15+
16+
def randrange(a, b):
17+
"""Python 2 / 3 compatible randrange. Returns a random integer uniformly
18+
between a and b (inclusive)"""
19+
c = b - a
20+
r = c * random.random()
21+
return a + int(r)

axelrod/tests/unit/test_moran.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# -*- coding: utf-8 -*-
2+
import random
3+
import unittest
4+
5+
import axelrod
6+
from axelrod import MoranProcess
7+
from axelrod.moran import fitness_proportionate_selection
8+
9+
from hypothesis import given, example, settings
10+
from hypothesis.strategies import integers, lists, sampled_from, random_module, floats
11+
12+
13+
class TestMoranProcess(unittest.TestCase):
14+
15+
def test_fps(self):
16+
self.assertEqual(fitness_proportionate_selection([0, 0, 1]), 2)
17+
random.seed(1)
18+
self.assertEqual(fitness_proportionate_selection([1, 1, 1]), 0)
19+
self.assertEqual(fitness_proportionate_selection([1, 1, 1]), 2)
20+
21+
def test_stochastic(self):
22+
p1, p2 = axelrod.Cooperator(), axelrod.Cooperator()
23+
mp = MoranProcess((p1, p2))
24+
self.assertFalse(mp._stochastic)
25+
p1, p2 = axelrod.Cooperator(), axelrod.Cooperator()
26+
mp = MoranProcess((p1, p2), noise=0.05)
27+
self.assertTrue(mp._stochastic)
28+
p1, p2 = axelrod.Cooperator(), axelrod.Random()
29+
mp = MoranProcess((p1, p2))
30+
self.assertTrue(mp._stochastic)
31+
32+
def test_exit_condition(self):
33+
p1, p2 = axelrod.Cooperator(), axelrod.Cooperator()
34+
mp = MoranProcess((p1, p2))
35+
mp.play()
36+
self.assertEqual(len(mp), 1)
37+
38+
def test_two_players(self):
39+
p1, p2 = axelrod.Cooperator(), axelrod.Defector()
40+
random.seed(5)
41+
mp = MoranProcess((p1, p2))
42+
mp.play()
43+
self.assertEqual(len(mp), 5)
44+
self.assertEqual(mp.winning_strategy_name, str(p2))
45+
46+
def test_three_players(self):
47+
players = [axelrod.Cooperator(), axelrod.Cooperator(),
48+
axelrod.Defector()]
49+
random.seed(5)
50+
mp = MoranProcess(players)
51+
mp.play()
52+
self.assertEqual(len(mp), 7)
53+
self.assertEqual(mp.winning_strategy_name, str(axelrod.Defector()))
54+
55+
def test_four_players(self):
56+
players = [axelrod.Cooperator() for _ in range(3)]
57+
players.append(axelrod.Defector())
58+
random.seed(10)
59+
mp = MoranProcess(players)
60+
mp.play()
61+
self.assertEqual(len(mp), 9)
62+
self.assertEqual(mp.winning_strategy_name, str(axelrod.Defector()))
63+
64+
@given(strategies=lists(sampled_from(axelrod.strategies),
65+
min_size=2, # Errors are returned if less than 2 strategies
66+
max_size=5, unique=True),
67+
rm=random_module())
68+
@settings(max_examples=5, timeout=0) # Very low number of examples
69+
70+
# Two specific examples relating to cloning of strategies
71+
@example(strategies=[axelrod.BackStabber, axelrod.MindReader],
72+
rm=random.seed(0))
73+
@example(strategies=[axelrod.ThueMorse, axelrod.MindReader],
74+
rm=random.seed(0))
75+
def test_property_players(self, strategies, rm):
76+
"""Hypothesis test that randomly checks players"""
77+
players = [s() for s in strategies]
78+
mp = MoranProcess(players)
79+
mp.play()
80+
self.assertIn(mp.winning_strategy_name, [str(p) for p in players])
81+
82+
def test_reset(self):
83+
p1, p2 = axelrod.Cooperator(), axelrod.Defector()
84+
random.seed(8)
85+
mp = MoranProcess((p1, p2))
86+
mp.play()
87+
self.assertEqual(len(mp), 4)
88+
self.assertEqual(len(mp.score_history), 3)
89+
mp.reset()
90+
self.assertEqual(len(mp), 1)
91+
self.assertEqual(mp.winning_strategy_name, None)
92+
self.assertEqual(mp.score_history, [])

docs/tutorials/further_topics/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Contents:
1111

1212
classification_of_strategies.rst
1313
creating_matches.rst
14+
moran.rst
1415
morality_metrics.rst
1516
probabilistict_end_tournaments.rst
1617
reading_and_writing_interactions.rst
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
Moran Process
2+
=============
3+
4+
The strategies in the library can be pitted against one another in the
5+
[Moran process](https://en.wikipedia.org/wiki/Moran_process), a population
6+
process simulating natural selection. Given the evolutionary basis of the Moran
7+
process it can be compared to the :ref:`ecological-variant`.
8+
While that variant was used by Axelrod in his original works, the Moran process
9+
is now much more widely studied in the literature.
10+
11+
The process works as follows. Given an
12+
initial population of players, the population is iterated in rounds consisting
13+
of:
14+
- matches played between each pair of players, with the cumulative total
15+
scores recored
16+
- a player is chosen to reproduce proportional to the player's score in the
17+
round
18+
- a player is chosen at random to be replaced
19+
20+
The process proceeds in rounds until the population consists of a single player
21+
type. That type is declared the winner. To run an instance of the process with
22+
the library, proceed as follows::
23+
24+
>>> import axelrod as axl
25+
>>> players = [axl.Cooperator(), axl.Defector(),
26+
... axl.TitForTat(), axl.Grudger()]
27+
>>> mp = axl.MoranProcess(players)
28+
>>> mp.play()
29+
>>> mp.winning_strategy_name # doctest: +SKIP
30+
Defector
31+
32+
You can access some attributes of the process, such as the number of rounds::
33+
34+
>>> len(mp) # doctest: +SKIP
35+
6
36+
37+
The sequence of populations::
38+
39+
>>> import pprint
40+
>>> pprint.pprint(mp.populations) # doctest: +SKIP
41+
[Counter({'Defector': 1, 'Cooperator': 1, 'Grudger': 1, 'Tit For Tat': 1}),
42+
Counter({'Defector': 1, 'Cooperator': 1, 'Grudger': 1, 'Tit For Tat': 1}),
43+
Counter({'Defector': 2, 'Cooperator': 1, 'Grudger': 1}),
44+
Counter({'Defector': 3, 'Grudger': 1}),
45+
Counter({'Defector': 3, 'Grudger': 1}),
46+
Counter({'Defector': 4})]
47+
48+
The scores in each round::
49+
50+
>>> for row in mp.score_history: # doctest: +SKIP
51+
... print([round(element, 1) for element in row])
52+
[[6.0, 7.0800000000000001, 6.9900000000000002, 6.9900000000000002],
53+
[6.0, 7.0800000000000001, 6.9900000000000002, 6.9900000000000002],
54+
[3.0, 7.04, 7.04, 4.9800000000000004],
55+
[3.04, 3.04, 3.04, 2.9699999999999998],
56+
[3.04, 3.04, 3.04, 2.9699999999999998]]

0 commit comments

Comments
 (0)