-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsimulation.py
More file actions
214 lines (176 loc) · 7.65 KB
/
Copy pathsimulation.py
File metadata and controls
214 lines (176 loc) · 7.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
'''
Simulation class for Chapter 7 Tutorial of Intro Network Science book
Copyright 2018 Indiana University and Cambridge University Press
'''
from collections import Counter
from operator import itemgetter
import matplotlib as mpl
import matplotlib.pyplot as plt
import networkx as nx
class StopCondition(StopIteration):
pass
class Simulation:
'''Simulate state transitions on a network'''
def __init__(self, G, initial_state, state_transition,
stop_condition=None, name=''):
'''
Create a Simulation instance.
Args:
G: a networkx.Graph instance.
initial_state: function with signature `initial_state(G)`, that
accepts a single argument, the Graph, and returns a dictionary
of all node states. The keys in this dict should be node names
and the values the corresponding initial node state.
state_transition: function with signature
`state_transition(G, current_state)` that accepts two
arguments, the Graph and a dictionary of current node states,
and returns a dictionary of updated node states. The keys in
this dict should be node names and the values the corresponding
updated node state.
stop_condition (optional): function with signature
`stop_condition(G, current_state)` that accepts two arguments,
the Graph and a dictionary of current node states, and returns
True if the simulation should be stopped at its current state.
Keyword Args:
name (optional): a string used in titles of plots and drawings.
Raises:
ValueError: if not all graph nodes have an initial state.
'''
self.G = G.copy()
self._initial_state = initial_state
self._state_transition = state_transition
self._stop_condition = stop_condition
# It's okay to specify stop_condition=False
if stop_condition and not callable(stop_condition):
raise TypeError("'stop_condition' should be a function")
self.name = name or 'Simulation'
self._states = []
self._value_index = {}
self._cmap = plt.cm.get_cmap('tab10')
self._initialize()
self._pos = nx.layout.spring_layout(G)
def _append_state(self, state):
self._states.append(state)
# Update self._value_index
for value in set(state.values()):
if value not in self._value_index:
self._value_index[value] = len(self._value_index)
def _initialize(self):
if self._initial_state:
if callable(self._initial_state):
state = self._initial_state(self.G)
else:
state = self._initial_state
nx.set_node_attributes(self.G, state, 'state')
if any(self.G.nodes[n].get('state') is None for n in self.G.nodes):
raise ValueError('All nodes must have an initial state')
self._append_state(state)
def _step(self):
# We're choosing to use the node attributes as the source of truth.
# This allows the user to manually perturb the network in between steps.
state = nx.get_node_attributes(self.G, 'state')
if self._stop_condition and self._stop_condition(self.G, state):
raise StopCondition
state = nx.get_node_attributes(self.G, 'state')
new_state = self._state_transition(self.G, state)
state.update(new_state)
nx.set_node_attributes(self.G, state, 'state')
self._append_state(state)
def _categorical_color(self, value):
index = self._value_index[value]
node_color = self._cmap(index)
return node_color
@property
def steps(self):
''' Returns the number of steps the sumulation has run '''
return len(self._states) - 1
def state(self, step=-1):
'''
Get a state of the simulation; by default returns the current state.
Args:
step: the step of the simulation to return. Default is -1, the
current state.
Returns:
Dictionary of node states.
Raises:
IndexError: if `step` argument is greater than the number of steps.
'''
try:
return self._states[step]
except IndexError:
raise IndexError('Simulation step %i out of range' % step)
def draw(self, step=-1, labels=None, **kwargs):
'''
Use networkx.draw to draw a simulation state with nodes colored by
their state value. By default, draws the current state.
Args:
step: the step of the simulation to draw. Default is -1, the
current state.
kwargs: keyword arguments are passed to networkx.draw()
Raises:
IndexError: if `step` argument is greater than the number of steps.
'''
state = self.state(step)
node_colors = [self._categorical_color(state[n]) for n in self.G.nodes]
nx.draw(self.G, pos=self._pos, node_color=node_colors, **kwargs)
if labels is None:
labels = sorted(set(state.values()), key=self._value_index.get)
patches = [mpl.patches.Patch(color=self._categorical_color(l), label=l)
for l in labels]
plt.legend(handles=patches)
if step == -1:
step = self.steps
if step == 0:
title = 'initial state'
else:
title = 'step %i' % (step)
if self.name:
title = '{}: {}'.format(self.name, title)
plt.title(title)
def plot(self, min_step=None, max_step=None, labels=None, **kwargs):
'''
Use pyplot to plot the relative number of nodes with each state at each
simulation step. By default, plots all simulation steps.
Args:
min_step: the first step of the simulation to draw. Default is
None, which plots starting from the initial state.
max_step: the last step, not inclusive, of the simulation to draw.
Default is None, which plots up to the current step.
labels: ordered sequence of state values to plot. Default is all
observed state values, approximately ordered by appearance.
kwargs: keyword arguments are passed along to plt.plot()
Returns:
Axes object for the current plot
'''
x_range = range(min_step or 0, max_step or len(self._states))
counts = [Counter(s.values()) for s in self._states[min_step:max_step]]
if labels is None:
labels = {k for count in counts for k in count}
labels = sorted(labels, key=self._value_index.get)
for label in labels:
series = [count.get(label, 0) / sum(count.values()) for count in counts]
plt.plot(x_range, series, label=label, **kwargs)
title = 'node state proportions'
if self.name:
title = '{}: {}'.format(self.name, title)
plt.title(title)
plt.xlabel('Simulation step')
plt.ylabel('Proportion of nodes')
plt.legend()
plt.xlim(x_range.start)
return plt.gca()
def run(self, steps=1):
'''
Run the simulation one or more steps, as specified by the `steps`
argument. Default is to run a single step.
Args:
steps: number of steps to advance the simulation.
'''
for _ in range(steps):
try:
self._step()
except StopCondition as e:
print(
"Stop condition met at step %i." % self.steps
)
break