Skip to content

Commit f83b481

Browse files
authored
Merge pull request #754 from Axelrod-Python/moran
Moran Process with Mutation
2 parents 92de860 + 2413893 commit f83b481

File tree

3 files changed

+139
-5
lines changed

3 files changed

+139
-5
lines changed

axelrod/moran.py

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,33 @@ def fitness_proportionate_selection(scores):
3131

3232

3333
class MoranProcess(object):
34-
def __init__(self, players, turns=100, noise=0, deterministic_cache=None):
34+
def __init__(self, players, turns=100, noise=0, deterministic_cache=None, mutation_rate=0.):
35+
"""
36+
An agent based Moran process class. In each round, each player plays a Match with each other
37+
player. Players are assigned a fitness score by their total score from all matches in the round.
38+
A player is chosen to reproduce proportionally to fitness, possibly mutated, and is cloned. The
39+
clone replaces a randomly chosen player.
40+
41+
If the mutation_rate is 0, the population will eventually fixate on exactly one player type. In this
42+
case a StopIteration exception is raised and the play stops. If mutation_rate is not zero, then
43+
the process will iterate indefinitely, so mp.play() will never exit, and you should use the class as an
44+
iterator instead.
45+
46+
When a player mutates it chooses a random player type from the initial population. This is not the only
47+
method yet emulates the common method in the literature.
48+
49+
Parameters
50+
----------
51+
players, iterable of axelrod.Player subclasses
52+
turns: int, 100
53+
The number of turns in each pairwise interaction
54+
noise: float, 0
55+
The background noise, if any. Randomly flips plays with probability `noise`.
56+
deterministic_cache: axelrod.DeterministicCache, None
57+
A optional prebuilt deterministic cache
58+
mutation_rate: float, 0
59+
The rate of mutation. Replicating players are mutated with probability `mutation_rate`
60+
"""
3561
self.turns = turns
3662
self.noise = noise
3763
self.initial_players = players # save initial population
@@ -40,10 +66,24 @@ def __init__(self, players, turns=100, noise=0, deterministic_cache=None):
4066
self.set_players()
4167
self.score_history = []
4268
self.winning_strategy_name = None
69+
self.mutation_rate = mutation_rate
70+
assert (mutation_rate >= 0) and (mutation_rate <= 1)
71+
assert (noise >= 0) and (noise <= 1)
4372
if deterministic_cache is not None:
4473
self.deterministic_cache = deterministic_cache
4574
else:
4675
self.deterministic_cache = DeterministicCache()
76+
# Build the set of mutation targets
77+
# Determine the number of unique types (players)
78+
keys = set([str(p) for p in players])
79+
# Create a dictionary mapping each type to a set of representatives of the other types
80+
d = dict()
81+
for p in players:
82+
d[str(p)] = p
83+
mutation_targets = dict()
84+
for key in sorted(keys):
85+
mutation_targets[key] = [v for (k, v) in sorted(d.items()) if k != key]
86+
self.mutation_targets = mutation_targets
4787

4888
def set_players(self):
4989
"""Copy the initial players into the first population."""
@@ -60,29 +100,49 @@ def _stochastic(self):
60100
A boolean to show whether a match between two players would be
61101
stochastic
62102
"""
63-
return is_stochastic(self.players, self.noise)
103+
return is_stochastic(self.players, self.noise) or (self.mutation_rate > 0)
104+
105+
def mutate(self, index):
106+
# If mutate, choose another strategy at random from the initial population
107+
r = random.random()
108+
if r < self.mutation_rate:
109+
s = str(self.players[index])
110+
j = randrange(0, len(self.mutation_targets[s]))
111+
p = self.mutation_targets[s][j]
112+
new_player = p.clone()
113+
else:
114+
# Just clone the player
115+
new_player = self.players[index].clone()
116+
return new_player
64117

65118
def __next__(self):
66119
"""Iterate the population:
67120
- play the round's matches
68121
- chooses a player proportionally to fitness (total score) to reproduce
122+
- mutate, if appropriate
69123
- choose a player at random to be replaced
70124
- update the population
71125
"""
72126
# Check the exit condition, that all players are of the same type.
73-
classes = set(p.__class__ for p in self.players)
74-
if len(classes) == 1:
127+
classes = set(str(p) for p in self.players)
128+
if (self.mutation_rate == 0) and (len(classes) == 1):
75129
self.winning_strategy_name = str(self.players[0])
76130
raise StopIteration
77131
scores = self._play_next_round()
78132
# Update the population
79133
# Fitness proportionate selection
80134
j = fitness_proportionate_selection(scores)
135+
# Mutate?
136+
if self.mutation_rate:
137+
new_player = self.mutate(j)
138+
else:
139+
new_player = self.players[j].clone()
81140
# Randomly remove a strategy
82141
i = randrange(0, len(self.players))
83142
# Replace player i with clone of player j
84-
self.players[i] = self.players[j].clone()
143+
self.players[i] = new_player
85144
self.populations.append(self.population_distribution())
145+
return self
86146

87147
def _play_next_round(self):
88148
"""Plays the next round of the process. Every player is paired up
@@ -123,6 +183,8 @@ def reset(self):
123183

124184
def play(self):
125185
"""Play the process out to completion."""
186+
if self.mutation_rate != 0:
187+
raise ValueError("MoranProcess.play() will never exit if mutation_rate is nonzero")
126188
while True:
127189
try:
128190
self.__next__()

axelrod/tests/unit/test_moran.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# -*- coding: utf-8 -*-
2+
from collections import Counter
3+
import itertools
24
import random
35
import unittest
46

@@ -46,6 +48,40 @@ def test_two_players(self):
4648
self.assertEqual(populations, mp.populations)
4749
self.assertEqual(mp.winning_strategy_name, str(p2))
4850

51+
def test_two_random_players(self):
52+
p1, p2 = axelrod.Random(0.5), axelrod.Random(0.25)
53+
random.seed(5)
54+
mp = MoranProcess((p1, p2))
55+
populations = mp.play()
56+
self.assertEqual(len(mp), 2)
57+
self.assertEqual(len(populations), 2)
58+
self.assertEqual(populations, mp.populations)
59+
self.assertEqual(mp.winning_strategy_name, str(p1))
60+
61+
def test_two_players_with_mutation(self):
62+
p1, p2 = axelrod.Cooperator(), axelrod.Defector()
63+
random.seed(5)
64+
mp = MoranProcess((p1, p2), mutation_rate=0.2)
65+
self.assertEqual(mp._stochastic, True)
66+
self.assertDictEqual(mp.mutation_targets, {str(p1): [p2], str(p2): [p1]})
67+
# Test that mutation causes the population to alternate between fixations
68+
counters = [
69+
Counter({'Cooperator': 2}),
70+
Counter({'Defector': 2}),
71+
Counter({'Cooperator': 2}),
72+
Counter({'Defector': 2})
73+
]
74+
for counter in counters:
75+
for _ in itertools.takewhile(lambda x: x.population_distribution() != counter, mp):
76+
pass
77+
self.assertEqual(mp.population_distribution(), counter)
78+
79+
def test_play_exception(self):
80+
p1, p2 = axelrod.Cooperator(), axelrod.Defector()
81+
mp = MoranProcess((p1, p2), mutation_rate=0.2)
82+
with self.assertRaises(ValueError):
83+
mp.play()
84+
4985
def test_three_players(self):
5086
players = [axelrod.Cooperator(), axelrod.Cooperator(),
5187
axelrod.Defector()]
@@ -57,6 +93,24 @@ def test_three_players(self):
5793
self.assertEqual(populations, mp.populations)
5894
self.assertEqual(mp.winning_strategy_name, str(axelrod.Defector()))
5995

96+
def test_three_players_with_mutation(self):
97+
p1 = axelrod.Cooperator()
98+
p2 = axelrod.Random()
99+
p3 = axelrod.Defector()
100+
players = [p1, p2, p3]
101+
mp = MoranProcess(players, mutation_rate=0.2)
102+
self.assertEqual(mp._stochastic, True)
103+
self.assertDictEqual(mp.mutation_targets, {str(p1): [p3, p2], str(p2): [p1, p3], str(p3): [p1, p2]})
104+
# Test that mutation causes the population to alternate between fixations
105+
counters = [
106+
Counter({'Cooperator': 3}),
107+
Counter({'Defector': 3}),
108+
]
109+
for counter in counters:
110+
for _ in itertools.takewhile(lambda x: x.population_distribution() != counter, mp):
111+
pass
112+
self.assertEqual(mp.population_distribution(), counter)
113+
60114
def test_four_players(self):
61115
players = [axelrod.Cooperator() for _ in range(3)]
62116
players.append(axelrod.Defector())

docs/tutorials/getting_started/moran.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,21 @@ The scores in each round::
5454
[3.0, 7.04, 7.04, 4.98],
5555
[3.04, 3.04, 3.04, 2.97],
5656
[3.04, 3.04, 3.04, 2.97]]
57+
58+
59+
The `MoranProcess` class also accepts an argument for a mutation rate. Nonzero mutation changes the Markov process so
60+
that it no longer has absorbing states, and will iterate forever. To prevent this, iterate with a loop (or function
61+
like `takewhile` from `itertools`):
62+
63+
>>> import axelrod as axl
64+
>>> axl.seed(4) # for reproducible example
65+
>>> players = [axl.Cooperator(), axl.Defector(),
66+
... axl.TitForTat(), axl.Grudger()]
67+
>>> mp = axl.MoranProcess(players, mutation_rate=0.1)
68+
>>> for _ in mp:
69+
... if len(mp.population_distribution()) == 1:
70+
... break
71+
>>> mp.population_distribution()
72+
Counter({'Tit For Tat': 4})
73+
74+

0 commit comments

Comments
 (0)