Skip to content

Commit da3c26c

Browse files
committed
First pass on reproducible matches and parallel tournaments with random seeding
1 parent 09b776a commit da3c26c

37 files changed

+407
-343
lines changed

axelrod/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from axelrod.load_data_ import load_pso_tables, load_weights
66
from axelrod import graph
77
from axelrod.action import Action
8-
from axelrod.random_ import random_choice, random_flip, seed, Pdf
8+
from axelrod.random_ import Pdf, RandomGenerator, BulkRandomGenerator
99
from axelrod.plot import Plot
1010
from axelrod.game import DefaultGame, Game
1111
from axelrod.history import History, LimitedHistory

axelrod/match.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from axelrod import DEFAULT_TURNS
66
from axelrod.action import Action
77
from axelrod.game import Game
8+
from axelrod.random_ import RandomGenerator
89

910
from .deterministic_cache import DeterministicCache
1011

@@ -29,7 +30,8 @@ def __init__(
2930
deterministic_cache=None,
3031
noise=0,
3132
match_attributes=None,
32-
reset=True
33+
reset=True,
34+
seed=None
3335
):
3436
"""
3537
Parameters
@@ -52,6 +54,8 @@ def __init__(
5254
but these can be overridden if desired.
5355
reset : bool
5456
Whether to reset players or not
57+
seed : int
58+
Random seed for reproducibility
5559
"""
5660

5761
defaults = {
@@ -65,6 +69,9 @@ def __init__(
6569
self.result = []
6670
self.noise = noise
6771

72+
self.seed = seed
73+
self._random = RandomGenerator(seed=self.seed)
74+
6875
if game is None:
6976
self.game = Game()
7077
else:
@@ -129,6 +136,19 @@ def _cached_enough_turns(self, cache_key, turns):
129136
return False
130137
return len(self._cache[cache_key]) >= turns
131138

139+
def simultaneous_play(self, player, coplayer, noise=0):
140+
"""This pits two players against each other."""
141+
s1, s2 = player.strategy(coplayer), coplayer.strategy(player)
142+
if noise:
143+
# Note this uses the Match classes random generator, not either
144+
# player's random generator. A player shouldn't be able to
145+
# predict the outcome of this noise flip.
146+
s1 = self.random_flip(s1, noise)
147+
s2 = self.random_flip(s2, noise)
148+
player.update_history(s1, s2)
149+
coplayer.update_history(s2, s1)
150+
return s1, s2
151+
132152
def play(self):
133153
"""
134154
The resulting list of actions from a match between two players.
@@ -147,17 +167,22 @@ def play(self):
147167
148168
i.e. One entry per turn containing a pair of actions.
149169
"""
150-
turns = min(sample_length(self.prob_end), self.turns)
170+
self._random = RandomGenerator(seed=self.seed)
171+
r = self._random.random()
172+
turns = min(sample_length(self.prob_end, r), self.turns)
151173
cache_key = (self.players[0], self.players[1])
152174

153175
if self._stochastic or not self._cached_enough_turns(cache_key, turns):
154176
for p in self.players:
155177
if self.reset:
156178
p.reset()
157179
p.set_match_attributes(**self.match_attributes)
180+
# Generate a random seed for each player
181+
p.set_seed(self._random.randint(0, 100000000))
158182
result = []
159183
for _ in range(turns):
160-
plays = self.players[0].play(self.players[1], self.noise)
184+
plays = self.simultaneous_play(
185+
self.players[0], self.players[1], self.noise)
161186
result.append(plays)
162187

163188
if self._cache_update_required:
@@ -216,7 +241,7 @@ def __len__(self):
216241
return self.turns
217242

218243

219-
def sample_length(prob_end):
244+
def sample_length(prob_end, random_value):
220245
"""
221246
Sample length of a game.
222247
@@ -249,5 +274,4 @@ def sample_length(prob_end):
249274
return float("inf")
250275
if prob_end == 1:
251276
return 1
252-
x = random.random()
253-
return int(ceil(log(1 - x) / log(1 - prob_end)))
277+
return int(ceil(log(1 - random_value) / log(1 - prob_end)))

axelrod/match_generator.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from axelrod.random_ import BulkRandomGenerator
2+
3+
14
class MatchGenerator(object):
25
def __init__(
36
self,
@@ -9,6 +12,7 @@ def __init__(
912
prob_end=None,
1013
edges=None,
1114
match_attributes=None,
15+
seed=None
1216
):
1317
"""
1418
A class to generate matches. This is used by the Tournament class which
@@ -43,6 +47,7 @@ def __init__(
4347
self.opponents = players
4448
self.prob_end = prob_end
4549
self.match_attributes = match_attributes
50+
self.random_generator = BulkRandomGenerator(seed)
4651

4752
self.edges = edges
4853
if edges is not None:
@@ -73,7 +78,8 @@ def build_match_chunks(self):
7378

7479
for index_pair in edges:
7580
match_params = self.build_single_match_params()
76-
yield (index_pair, match_params, self.repetitions)
81+
r = next(self.random_generator)
82+
yield (index_pair, match_params, self.repetitions, r)
7783

7884
def build_single_match_params(self):
7985
"""

axelrod/moran.py

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Implementation of the Moran process on Graphs."""
22

3-
import random
43
from collections import Counter
54
from typing import Callable, List, Optional, Set, Tuple
65

@@ -11,35 +10,7 @@
1110
from .deterministic_cache import DeterministicCache
1211
from .graph import Graph, complete_graph
1312
from .match import Match
14-
from .random_ import randrange
15-
16-
17-
def fitness_proportionate_selection(
18-
scores: List, fitness_transformation: Callable = None
19-
) -> int:
20-
"""Randomly selects an individual proportionally to score.
21-
22-
Parameters
23-
----------
24-
scores: Any sequence of real numbers
25-
fitness_transformation: A function mapping a score to a (non-negative) float
26-
27-
Returns
28-
-------
29-
An index of the above list selected at random proportionally to the list
30-
element divided by the total.
31-
"""
32-
if fitness_transformation is None:
33-
csums = np.cumsum(scores)
34-
else:
35-
csums = np.cumsum([fitness_transformation(s) for s in scores])
36-
total = csums[-1]
37-
r = random.random() * total
38-
39-
for i, x in enumerate(csums):
40-
if x >= r:
41-
break
42-
return i
13+
from .random_ import RandomGenerator
4314

4415

4516
class MoranProcess(object):
@@ -57,7 +28,8 @@ def __init__(
5728
reproduction_graph: Graph = None,
5829
fitness_transformation: Callable = None,
5930
mutation_method="transition",
60-
stop_on_fixation=True
31+
stop_on_fixation=True,
32+
seed = None
6133
) -> None:
6234
"""
6335
An agent based Moran process class. In each round, each player plays a
@@ -128,6 +100,7 @@ def __init__(
128100
self.winning_strategy_name = None # type: Optional[str]
129101
self.mutation_rate = mutation_rate
130102
self.stop_on_fixation = stop_on_fixation
103+
self._random = RandomGenerator(seed=seed)
131104
m = mutation_method.lower()
132105
if m in ["atomic", "transition"]:
133106
self.mutation_method = m
@@ -182,6 +155,32 @@ def set_players(self) -> None:
182155
self.players.append(player)
183156
self.populations = [self.population_distribution()]
184157

158+
def fitness_proportionate_selection(self,
159+
scores: List, fitness_transformation: Callable = None) -> int:
160+
"""Randomly selects an individual proportionally to score.
161+
162+
Parameters
163+
----------
164+
scores: Any sequence of real numbers
165+
fitness_transformation: A function mapping a score to a (non-negative) float
166+
167+
Returns
168+
-------
169+
An index of the above list selected at random proportionally to the list
170+
element divided by the total.
171+
"""
172+
if fitness_transformation is None:
173+
csums = np.cumsum(scores)
174+
else:
175+
csums = np.cumsum([fitness_transformation(s) for s in scores])
176+
total = csums[-1]
177+
r = self._random.random() * total
178+
179+
for i, x in enumerate(csums):
180+
if x >= r:
181+
break
182+
return i
183+
185184
def mutate(self, index: int) -> Player:
186185
"""Mutate the player at index.
187186
@@ -199,10 +198,10 @@ def mutate(self, index: int) -> Player:
199198
# Assuming mutation_method == "transition"
200199
if self.mutation_rate > 0:
201200
# Choose another strategy at random from the initial population
202-
r = random.random()
201+
r = self._random.random()
203202
if r < self.mutation_rate:
204203
s = str(self.players[index])
205-
j = randrange(0, len(self.mutation_targets[s]))
204+
j = self._random.randrange(0, len(self.mutation_targets[s]))
206205
p = self.mutation_targets[s][j]
207206
return p.clone()
208207
# Just clone the player
@@ -223,13 +222,13 @@ def death(self, index: int = None) -> int:
223222
"""
224223
if index is None:
225224
# Select a player to be replaced globally
226-
i = randrange(0, len(self.players))
225+
i = self._random.randrange(0, len(self.players))
227226
# Record internally for use in _matchup_indices
228227
self.dead = i
229228
else:
230229
# Select locally
231230
# index is not None in this case
232-
vertex = random.choice(
231+
vertex = self._random.choice(
233232
sorted(self.reproduction_graph.out_vertices(self.locations[index]))
234233
)
235234
i = self.index[vertex]

axelrod/player.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from axelrod.action import Action
1010
from axelrod.game import DefaultGame
1111
from axelrod.history import History
12-
from axelrod.random_ import random_flip
12+
from axelrod.random_ import RandomGenerator
1313

1414
C, D = Action.C, Action.D
1515

@@ -48,17 +48,6 @@ def obey_axelrod(s):
4848
)
4949

5050

51-
def simultaneous_play(player, coplayer, noise=0):
52-
"""This pits two players against each other."""
53-
s1, s2 = player.strategy(coplayer), coplayer.strategy(player)
54-
if noise:
55-
s1 = random_flip(s1, noise)
56-
s2 = random_flip(s2, noise)
57-
player.update_history(s1, s2)
58-
coplayer.update_history(s2, s1)
59-
return s1, s2
60-
61-
6251
class Player(object):
6352
"""A class for a player in the tournament.
6453
@@ -110,6 +99,7 @@ def __init__(self):
11099
if dimension not in self.classifier:
111100
self.classifier[dimension] = self.default_classifier[dimension]
112101
self.set_match_attributes()
102+
self.set_seed()
113103

114104
def __eq__(self, other):
115105
"""
@@ -123,6 +113,10 @@ def __eq__(self, other):
123113
value = getattr(self, attribute, None)
124114
other_value = getattr(other, attribute, None)
125115

116+
if attribute == "_random":
117+
# Don't compare the random seeds.
118+
continue
119+
126120
if isinstance(value, np.ndarray):
127121
if not (np.array_equal(value, other_value)):
128122
return False
@@ -169,6 +163,11 @@ def set_match_attributes(self, length=-1, game=None, noise=0):
169163
self.match_attributes = {"length": length, "game": game, "noise": noise}
170164
self.receive_match_attributes()
171165

166+
def set_seed(self, seed=None):
167+
"""Set a random seed for the player's random number
168+
generator."""
169+
self._random = RandomGenerator(seed=seed)
170+
172171
def __repr__(self):
173172
"""The string method for the strategy.
174173
Appends the `__init__` parameters to the strategy's name."""
@@ -193,9 +192,9 @@ def strategy(self, opponent):
193192
"""This is a placeholder strategy."""
194193
raise NotImplementedError()
195194

196-
def play(self, opponent, noise=0):
197-
"""This pits two players against each other."""
198-
return simultaneous_play(self, opponent, noise)
195+
# def play(self, opponent, noise=0):
196+
# """This pits two players against each other."""
197+
# return simultaneous_play(self, opponent, noise)
199198

200199
def clone(self):
201200
"""Clones the player without history, reapplying configuration

0 commit comments

Comments
 (0)