simulatingrisk 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simulatingrisk/__init__.py +1 -0
- simulatingrisk/about_app.md +2 -0
- simulatingrisk/app.py +50 -0
- simulatingrisk/batch_run.py +145 -0
- simulatingrisk/charts/histogram.js +51 -0
- simulatingrisk/charts/histogram.py +72 -0
- simulatingrisk/hawkdove/README.md +106 -0
- simulatingrisk/hawkdove/app.py +90 -0
- simulatingrisk/hawkdove/model.py +391 -0
- simulatingrisk/hawkdove/run.py +31 -0
- simulatingrisk/hawkdove/server.py +189 -0
- simulatingrisk/hawkdovemulti/README.md +92 -0
- simulatingrisk/hawkdovemulti/analysis_utils.py +83 -0
- simulatingrisk/hawkdovemulti/app.py +242 -0
- simulatingrisk/hawkdovemulti/batch_run.py +328 -0
- simulatingrisk/hawkdovemulti/model.py +462 -0
- simulatingrisk/hawkdovemulti/run_simulation.ipynb +55 -0
- simulatingrisk/hawkdovemulti/simrisk_batch.slurm +44 -0
- simulatingrisk/risky_bet/README.md +45 -0
- simulatingrisk/risky_bet/app.py +17 -0
- simulatingrisk/risky_bet/model.py +237 -0
- simulatingrisk/risky_bet/run.py +46 -0
- simulatingrisk/risky_bet/server.py +102 -0
- simulatingrisk/risky_food/README.md +32 -0
- simulatingrisk/risky_food/__init__.py +0 -0
- simulatingrisk/risky_food/app.py +19 -0
- simulatingrisk/risky_food/model.py +250 -0
- simulatingrisk/risky_food/run.py +20 -0
- simulatingrisk/risky_food/server.py +78 -0
- simulatingrisk/stag_hunt/README.md +15 -0
- simulatingrisk/stag_hunt/__init__.py +0 -0
- simulatingrisk/stag_hunt/model.py +123 -0
- simulatingrisk/stag_hunt/run.py +45 -0
- simulatingrisk/utils.py +31 -0
- simulatingrisk-1.0.0.dist-info/METADATA +113 -0
- simulatingrisk-1.0.0.dist-info/RECORD +40 -0
- simulatingrisk-1.0.0.dist-info/WHEEL +5 -0
- simulatingrisk-1.0.0.dist-info/entry_points.txt +2 -0
- simulatingrisk-1.0.0.dist-info/licenses/LICENSE +201 -0
- simulatingrisk-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
import statistics
|
|
4
|
+
|
|
5
|
+
import mesa
|
|
6
|
+
|
|
7
|
+
from simulatingrisk.utils import coinflip
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Bet = Enum("Bet", ["RISKY", "SAFE"])
|
|
11
|
+
bet_choices = [Bet.SAFE, Bet.RISKY]
|
|
12
|
+
|
|
13
|
+
# divergent color scheme, eleven colors
|
|
14
|
+
# from https://colorbrewer2.org/?type=diverging&scheme=RdYlGn&n=11
|
|
15
|
+
divergent_colors = [
|
|
16
|
+
"#a50026",
|
|
17
|
+
"#d73027",
|
|
18
|
+
"#f46d43",
|
|
19
|
+
"#fdae61",
|
|
20
|
+
"#fee08b",
|
|
21
|
+
"#ffffbf",
|
|
22
|
+
"#d9ef8b",
|
|
23
|
+
"#a6d96a",
|
|
24
|
+
"#66bd63",
|
|
25
|
+
"#1a9850",
|
|
26
|
+
"#006837",
|
|
27
|
+
]
|
|
28
|
+
# low values = risk inclined (more likely to take a risky bet)
|
|
29
|
+
# higher value = risk averse (less likely to the bet)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Gambler(mesa.Agent):
|
|
33
|
+
def __init__(self, unique_id, model, initial_wealth):
|
|
34
|
+
super().__init__(unique_id, model)
|
|
35
|
+
# starting wealth determined by the model
|
|
36
|
+
self.initial_wealth = initial_wealth # store initial value
|
|
37
|
+
self.wealth = initial_wealth
|
|
38
|
+
# get a random risk tolerance; returns a value between 0.0 and 1.0
|
|
39
|
+
self.risk_level = self.random.random()
|
|
40
|
+
self.choice = None
|
|
41
|
+
|
|
42
|
+
def __repr__(self):
|
|
43
|
+
return (
|
|
44
|
+
f"<Gambler id={self.unique_id} wealth={self.wealth} "
|
|
45
|
+
+ f"risk_level={self.risk_level}>"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def step(self):
|
|
49
|
+
# decide how to bet based on risk tolerance and likelihood
|
|
50
|
+
# that the risky bet will pay off
|
|
51
|
+
if self.model.prob_risky_payoff > self.risk_level:
|
|
52
|
+
self.choice = Bet.RISKY
|
|
53
|
+
else:
|
|
54
|
+
self.choice = Bet.SAFE
|
|
55
|
+
|
|
56
|
+
# determine the payoff for this choice
|
|
57
|
+
self.calculate_payoff(self.choice)
|
|
58
|
+
|
|
59
|
+
# every ten rounds, agents adjust their risk level
|
|
60
|
+
# make this a model method?
|
|
61
|
+
if self.model.adjustment_round:
|
|
62
|
+
self.adjust_risk()
|
|
63
|
+
|
|
64
|
+
def calculate_payoff(self, choice):
|
|
65
|
+
if choice == Bet.RISKY:
|
|
66
|
+
# if the risky bet paid off, multiply current wealth by 1.5
|
|
67
|
+
if self.model.risky_payoff:
|
|
68
|
+
self.wealth = self.wealth * 1.5
|
|
69
|
+
|
|
70
|
+
# if it doesn't, multiply by 0.5
|
|
71
|
+
else:
|
|
72
|
+
self.wealth = self.wealth * 0.5
|
|
73
|
+
|
|
74
|
+
# otherwise, no change
|
|
75
|
+
|
|
76
|
+
@cached_property
|
|
77
|
+
def neighbors(self):
|
|
78
|
+
"""Get neighbors for the current agent; uses Von Neumann
|
|
79
|
+
neighborhood (no diagonals), does not include self."""
|
|
80
|
+
# because this simulation doesn't include any movement,
|
|
81
|
+
# neighbors won't change over the run and we can cache
|
|
82
|
+
return self.model.grid.get_neighbors(
|
|
83
|
+
self.pos, moore=False, include_center=False
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def wealthiest_neighbor(self):
|
|
88
|
+
"""identify and return the current wealthiest neighbor"""
|
|
89
|
+
# sort neighbors by wealth, wealthiest neighbor first
|
|
90
|
+
return sorted(self.neighbors, key=lambda x: x.wealth, reverse=True)[0]
|
|
91
|
+
|
|
92
|
+
def adjust_risk(self):
|
|
93
|
+
# look at neighbors (4)
|
|
94
|
+
# if anyone has more money,
|
|
95
|
+
# either adopt their risk attitude or average theirs with yours
|
|
96
|
+
# then reset wealth back to initial value
|
|
97
|
+
|
|
98
|
+
wealthiest = self.wealthiest_neighbor
|
|
99
|
+
# if wealthiest neighbor is richer, adjust
|
|
100
|
+
if wealthiest.wealth > self.wealth:
|
|
101
|
+
# adjust risk based on model configuration
|
|
102
|
+
if self.model.risk_adjustment == "adopt":
|
|
103
|
+
# adopt wealthiest neighbor's risk level
|
|
104
|
+
self.risk_level = wealthiest.risk_level
|
|
105
|
+
elif self.model.risk_adjustment == "average":
|
|
106
|
+
# average theirs with mine
|
|
107
|
+
self.risk_level = statistics.mean(
|
|
108
|
+
[self.risk_level, wealthiest.risk_level]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# reset wealth back to initial value
|
|
112
|
+
self.wealth = self.initial_wealth
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RiskyBetModel(mesa.Model):
|
|
116
|
+
"""
|
|
117
|
+
Model for simulating a risky bet game.
|
|
118
|
+
|
|
119
|
+
:param grid_size: number for square grid size (creates n*n agents)
|
|
120
|
+
:param risk_adjustment: strategy agents should use for adjusting risk;
|
|
121
|
+
adopt (default), or average
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
initial_wealth = 1000
|
|
125
|
+
running = True # required for batch run
|
|
126
|
+
|
|
127
|
+
def __init__(self, grid_size, risk_adjustment="adopt"):
|
|
128
|
+
super().__init__()
|
|
129
|
+
# assume a fully-populated square grid
|
|
130
|
+
self.num_agents = grid_size * grid_size
|
|
131
|
+
self.risk_adjustment = risk_adjustment
|
|
132
|
+
# initialize a single grid (each square inhabited by a single agent);
|
|
133
|
+
# configure the grid to wrap around so everyone has neighbors
|
|
134
|
+
self.grid = mesa.space.SingleGrid(grid_size, grid_size, True)
|
|
135
|
+
self.schedule = mesa.time.SimultaneousActivation(self)
|
|
136
|
+
|
|
137
|
+
# initialize agents and add to grid and scheduler
|
|
138
|
+
for i in range(self.num_agents):
|
|
139
|
+
a = Gambler(i, self, self.initial_wealth)
|
|
140
|
+
self.schedule.add(a)
|
|
141
|
+
# place randomly in an empty spot
|
|
142
|
+
self.grid.move_to_empty(a)
|
|
143
|
+
|
|
144
|
+
self.datacollector = mesa.DataCollector(
|
|
145
|
+
model_reporters={
|
|
146
|
+
# state of the world
|
|
147
|
+
"prob_risky_payoff": "prob_risky_payoff",
|
|
148
|
+
"risky_bet": "risky_bet",
|
|
149
|
+
# aggregate information about agents
|
|
150
|
+
"risk_min": "risk_min",
|
|
151
|
+
"risk_q1": "risk_q1",
|
|
152
|
+
"risk_mean": "risk_mean",
|
|
153
|
+
"risk_q3": "risk_q3",
|
|
154
|
+
"risk_max": "risk_max",
|
|
155
|
+
},
|
|
156
|
+
agent_reporters={"risk_level": "risk_level", "choice": "choice"},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def step(self):
|
|
160
|
+
# run a single round of the game
|
|
161
|
+
|
|
162
|
+
# determine the probability of the risky bet paying off this round
|
|
163
|
+
self.prob_risky_payoff = self.random.random()
|
|
164
|
+
# determine whether it actually pays off
|
|
165
|
+
self.risky_payoff = self.call_risky_bet()
|
|
166
|
+
|
|
167
|
+
self.schedule.step()
|
|
168
|
+
self.datacollector.collect(self)
|
|
169
|
+
# every ten rounds, agents adjust their risk level
|
|
170
|
+
|
|
171
|
+
# delete cached property before the next round
|
|
172
|
+
try:
|
|
173
|
+
del self.agent_risk_levels
|
|
174
|
+
except AttributeError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def call_risky_bet(self):
|
|
178
|
+
# flip a weighted coin to determine if the risky bet pays off,
|
|
179
|
+
# weighted by current round payoff probability
|
|
180
|
+
self.risky_bet = coinflip([True, False], weight=self.prob_risky_payoff)
|
|
181
|
+
return self.risky_bet
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def adjustment_round(self) -> bool:
|
|
185
|
+
"""is the current round an adjustment round?"""
|
|
186
|
+
# agents should adjust their wealth every 10 rounds;
|
|
187
|
+
# check if the current step is an adjustment round
|
|
188
|
+
return self.schedule.steps > 0 and self.schedule.steps % 10 == 0
|
|
189
|
+
|
|
190
|
+
@cached_property
|
|
191
|
+
def agent_risk_levels(self) -> [float]:
|
|
192
|
+
# list of all risk levels for all current agents;
|
|
193
|
+
# property is cached but should be cleared in each new round
|
|
194
|
+
|
|
195
|
+
# NOTE: occasionally median method is complaining that this is empty
|
|
196
|
+
return [a.risk_level for a in self.schedule.agents]
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def max_agent_wealth(self):
|
|
200
|
+
# what is the current largest wealth of any agent?
|
|
201
|
+
return max([a.wealth for a in self.schedule.agents])
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def risk_median(self):
|
|
205
|
+
# calculate median of current agent risk levels
|
|
206
|
+
if self.agent_risk_levels:
|
|
207
|
+
# occasionally this complains about an empty list
|
|
208
|
+
# hopefully only possible in unit tests...
|
|
209
|
+
return statistics.median(self.agent_risk_levels)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def risk_mean(self):
|
|
213
|
+
return statistics.mean(self.agent_risk_levels)
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def risk_min(self):
|
|
217
|
+
return min(self.agent_risk_levels)
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def risk_max(self):
|
|
221
|
+
return max(self.agent_risk_levels)
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def risk_q1(self):
|
|
225
|
+
risk_median = self.risk_median
|
|
226
|
+
# first quartile is the median of values less than the median
|
|
227
|
+
submedian_values = [r for r in self.agent_risk_levels if r < risk_median]
|
|
228
|
+
if submedian_values:
|
|
229
|
+
return statistics.median(submedian_values)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def risk_q3(self):
|
|
233
|
+
risk_median = self.risk_median
|
|
234
|
+
# third quartile is the median of values greater than the median
|
|
235
|
+
supermedian_values = [r for r in self.agent_risk_levels if r > risk_median]
|
|
236
|
+
if supermedian_values:
|
|
237
|
+
return statistics.median(supermedian_values)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import mesa
|
|
2
|
+
|
|
3
|
+
from simulatingrisk.risky_bet.model import RiskyBetModel, divergent_colors
|
|
4
|
+
from simulatingrisk.risky_bet.server import agent_portrayal, grid_size, model_params
|
|
5
|
+
from simulatingrisk.charts.histogram import RiskHistogramModule, risk_bins
|
|
6
|
+
from simulatingrisk.utils import labelLabel
|
|
7
|
+
|
|
8
|
+
grid = mesa.visualization.CanvasGrid(agent_portrayal, grid_size, grid_size, 500, 500)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
risk_chart = mesa.visualization.ChartModule(
|
|
12
|
+
labelLabel(
|
|
13
|
+
[
|
|
14
|
+
{"Label": "risk_min", "Color": divergent_colors[0]},
|
|
15
|
+
{"Label": "risk_q1", "Color": divergent_colors[3]},
|
|
16
|
+
{"Label": "risk_mean", "Color": divergent_colors[5]},
|
|
17
|
+
{"Label": "risk_q3", "Color": divergent_colors[7]},
|
|
18
|
+
{"Label": "risk_max", "Color": divergent_colors[-1]},
|
|
19
|
+
]
|
|
20
|
+
),
|
|
21
|
+
data_collector_name="datacollector",
|
|
22
|
+
canvas_height=100,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
world_chart = mesa.visualization.ChartModule(
|
|
26
|
+
labelLabel(
|
|
27
|
+
[
|
|
28
|
+
{"Label": "prob_risky_payoff", "Color": "gray"},
|
|
29
|
+
{"Label": "risky_bet", "Color": "blue"},
|
|
30
|
+
]
|
|
31
|
+
),
|
|
32
|
+
data_collector_name="datacollector",
|
|
33
|
+
canvas_height=100,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
histogram = RiskHistogramModule(risk_bins, 175, 500, "risk levels")
|
|
38
|
+
|
|
39
|
+
server = mesa.visualization.ModularServer(
|
|
40
|
+
RiskyBetModel,
|
|
41
|
+
[grid, histogram, world_chart, risk_chart],
|
|
42
|
+
"Risky Bet Simulation",
|
|
43
|
+
model_params=model_params,
|
|
44
|
+
)
|
|
45
|
+
server.port = 8521 # The default
|
|
46
|
+
server.launch()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
import mesa
|
|
4
|
+
from simulatingrisk.risky_bet.model import divergent_colors
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def risk_index(risk_level):
|
|
8
|
+
"""Calculate a risk bin index for a given risk level.
|
|
9
|
+
Risk levels range from 0.0 to 1.0,
|
|
10
|
+
Implement eleven bins, with bins for 0 - 0.05 and 0.95 - 1.0,
|
|
11
|
+
since risk = 0, 0.5, and 1 are all special cases we want clearly captured.
|
|
12
|
+
"""
|
|
13
|
+
# implementation adapted from https://stackoverflow.com/a/64995801/9706217
|
|
14
|
+
|
|
15
|
+
# if we think of it as a range from -0.05 to 1.05,
|
|
16
|
+
# then we can work with evenly sized 0.1 bins
|
|
17
|
+
minval = -0.05
|
|
18
|
+
binwidth = 0.1
|
|
19
|
+
nbins = 11
|
|
20
|
+
# Determine which bin this risk level belongs in
|
|
21
|
+
binnum = int((risk_level - minval) // binwidth) # // = floor division
|
|
22
|
+
# convert bin number to 0-based index
|
|
23
|
+
return min(nbins - 1, binnum)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def agent_portrayal(agent):
|
|
27
|
+
# initial display
|
|
28
|
+
portrayal = {
|
|
29
|
+
# styles for mesa runserver
|
|
30
|
+
"Shape": "circle",
|
|
31
|
+
"Color": "gray",
|
|
32
|
+
"Filled": "true",
|
|
33
|
+
"Layer": 0,
|
|
34
|
+
"r": 0.5,
|
|
35
|
+
# styles for solara / jupyterviz
|
|
36
|
+
"size": 25,
|
|
37
|
+
"color": "tab:gray",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# color based on risk level, with ten bins
|
|
41
|
+
# convert 0.0 to 1.0 to 1 - 10
|
|
42
|
+
color_index = math.floor(agent.risk_level * 10)
|
|
43
|
+
portrayal["color"] = "%s" % divergent_colors[color_index]
|
|
44
|
+
# runserver requires uppercase; duplicate for now
|
|
45
|
+
portrayal["Color"] = portrayal["color"]
|
|
46
|
+
|
|
47
|
+
# size based on wealth within current distribution
|
|
48
|
+
max_wealth = agent.model.max_agent_wealth
|
|
49
|
+
wealth_index = math.floor(agent.wealth / max_wealth * 10)
|
|
50
|
+
# set radius based on wealth, but don't go smaller than 1 radius
|
|
51
|
+
# or too large to fit in the grid
|
|
52
|
+
portrayal["r"] = (wealth_index / 15) + 0.1
|
|
53
|
+
# size for solara / jupyterviz
|
|
54
|
+
portrayal["size"] = (wealth_index / 15) * 50
|
|
55
|
+
|
|
56
|
+
# TODO: change shape based on number of times risk level has been adjusted?
|
|
57
|
+
# can't find a list of available shapes; setting to triangle and square
|
|
58
|
+
# results in a 404 for a local custom url
|
|
59
|
+
# NOTE: matplotlib scatter supports different shapes/markers,
|
|
60
|
+
# but not in a single scatter plot; would need to be plotted in groups
|
|
61
|
+
|
|
62
|
+
return portrayal
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
grid_size = 20
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# make model parameters user-configurable
|
|
69
|
+
model_params = {
|
|
70
|
+
"grid_size": grid_size, # mesa.visualization.StaticText(value=grid_size),
|
|
71
|
+
# "grid_size": mesa.visualization.Slider(
|
|
72
|
+
# "Grid size",
|
|
73
|
+
# value=20,
|
|
74
|
+
# min_value=10,
|
|
75
|
+
# max_value=100,
|
|
76
|
+
# description="Grid dimension (n*n = number of agents)",
|
|
77
|
+
# ),
|
|
78
|
+
"risk_adjustment": mesa.visualization.Choice(
|
|
79
|
+
"Risk adjustment strategy",
|
|
80
|
+
value="adopt",
|
|
81
|
+
choices=["adopt", "average"],
|
|
82
|
+
description="How agents update their risk level",
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
jupyterviz_params = {
|
|
87
|
+
# "grid_size": grid_size,
|
|
88
|
+
"grid_size": {
|
|
89
|
+
"type": "SliderInt",
|
|
90
|
+
"value": 20,
|
|
91
|
+
"label": "Grid Size",
|
|
92
|
+
"min": 10,
|
|
93
|
+
"max": 100,
|
|
94
|
+
"step": 1,
|
|
95
|
+
},
|
|
96
|
+
"risk_adjustment": {
|
|
97
|
+
"type": "Select",
|
|
98
|
+
"value": "adopt",
|
|
99
|
+
"values": ["adopt", "average"],
|
|
100
|
+
"description": "How agents update their risk level",
|
|
101
|
+
},
|
|
102
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Risky Food Simulation
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
Game: risky food source is 3 if **N**, 1 if **C**; safe source is 2
|
|
6
|
+
|
|
7
|
+
- **N**: non-contaminated
|
|
8
|
+
- **C**: contaminated
|
|
9
|
+
|
|
10
|
+
Every agent gets a parameter `r` between 0 and 1. [or DISCRETE: 8 buckets etc.]
|
|
11
|
+
|
|
12
|
+
EACH ROUND:
|
|
13
|
+
- Nature selects a probability `p` for **N**
|
|
14
|
+
- For each agent: if `r` > `p`, then they choose RISKY; else SAFE
|
|
15
|
+
- Nature flips a coin with bias `p` for **N**, and announces **N** or **C**
|
|
16
|
+
- If **N**: everyone who chose RISKY gets 3, everyone who chose SAFE gets 2
|
|
17
|
+
- If **C**: everyone who chose RISKY gets 1, everyone SAFE 2
|
|
18
|
+
- Reproduce in proportion to payoff
|
|
19
|
+
- Either agent gets # of offspring = payoff [they replace–original “dies off”]
|
|
20
|
+
- OR: take the total payoff for RISKYs over total for everyone, there are that proportion of RISKYs in the new population
|
|
21
|
+
|
|
22
|
+
END ROUND
|
|
23
|
+
|
|
24
|
+
SEE: We’ll see what are the risk attitudes that are replicated more and less over time
|
|
25
|
+
|
|
26
|
+
## Running the simulation
|
|
27
|
+
|
|
28
|
+
- Install python dependencies as described in the main project readme (requires mesa)
|
|
29
|
+
- To run from the main `simulating-risk` project directory:
|
|
30
|
+
- Configure python to include the current directory in import path;
|
|
31
|
+
for C-based shells, run `setenv PYTHONPATH .` ; for bash, run `export $PYTHONPATH=.`
|
|
32
|
+
- To run interactively with mesa runserver: `mesa runserver simulatingrisk/risky_food/`
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# solara/jupyterviz app
|
|
2
|
+
from mesa.experimental import JupyterViz
|
|
3
|
+
|
|
4
|
+
from simulatingrisk.risky_food.model import RiskyFoodModel
|
|
5
|
+
from simulatingrisk.risky_food.server import (
|
|
6
|
+
jupyterviz_params,
|
|
7
|
+
plot_total_agents,
|
|
8
|
+
)
|
|
9
|
+
from simulatingrisk.charts.histogram import plot_risk_histogram
|
|
10
|
+
|
|
11
|
+
page = JupyterViz(
|
|
12
|
+
RiskyFoodModel,
|
|
13
|
+
jupyterviz_params,
|
|
14
|
+
measures=[plot_total_agents, plot_risk_histogram],
|
|
15
|
+
name="Risky Food",
|
|
16
|
+
space_drawer=False, # no agent portrayal because this model does not use a grid
|
|
17
|
+
)
|
|
18
|
+
# required to render the visualization with Jupyter/Solara
|
|
19
|
+
page
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from statistics import mean
|
|
5
|
+
|
|
6
|
+
import mesa
|
|
7
|
+
|
|
8
|
+
from simulatingrisk.utils import coinflip
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FoodChoice(Enum):
|
|
12
|
+
RISKY = "R"
|
|
13
|
+
SAFE = "S"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FoodStatus(Enum):
|
|
17
|
+
CONTAMINATED = "C"
|
|
18
|
+
NOTCONTAMINATED = "N"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Agent(mesa.Agent):
|
|
22
|
+
def __init__(self, unique_id, model, risk_level=None):
|
|
23
|
+
super().__init__(unique_id, model)
|
|
24
|
+
# get a random risk tolerance; returns a value between 0.0 and 1.0
|
|
25
|
+
if risk_level is None: # only set randomly if None; allow zero risk
|
|
26
|
+
risk_level = self.random.random()
|
|
27
|
+
self.risk_level = risk_level
|
|
28
|
+
self.choice = None
|
|
29
|
+
|
|
30
|
+
def __repr__(self):
|
|
31
|
+
return f"<RiskyFoodAgent id={self.unique_id} risk_level={self.risk_level}>"
|
|
32
|
+
|
|
33
|
+
def step(self):
|
|
34
|
+
# choose food based on the probability not contaminated and risk tolerance
|
|
35
|
+
# lower risk level = risk seeking
|
|
36
|
+
# higher = risk averse
|
|
37
|
+
# risk level 1.0 should always choose safe (not strictly greater)
|
|
38
|
+
if self.model.prob_notcontaminated > self.risk_level:
|
|
39
|
+
self.choice = FoodChoice.RISKY
|
|
40
|
+
else:
|
|
41
|
+
self.choice = FoodChoice.SAFE
|
|
42
|
+
self.payoff = self.model.payoff(self.choice)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RiskyFoodModel(mesa.Model):
|
|
46
|
+
prob_notcontaminated = None
|
|
47
|
+
running = True # required for batch running
|
|
48
|
+
|
|
49
|
+
def __init__(self, n=110, mode="types"):
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.num_agents = n
|
|
52
|
+
self.mode = mode
|
|
53
|
+
self.schedule = mesa.time.SimultaneousActivation(self)
|
|
54
|
+
# initialize agents for the first round
|
|
55
|
+
|
|
56
|
+
# when mode is types, initialize 10 agents each of 11 risk types
|
|
57
|
+
if mode == "types":
|
|
58
|
+
# currently ignores n...
|
|
59
|
+
# maybe n could be n per type when mode is types
|
|
60
|
+
for i in range(11):
|
|
61
|
+
risk_level = i / 10 # 0, 0.1, 0.2, 0.3, ... 1.0
|
|
62
|
+
for j in range(10):
|
|
63
|
+
# agents need unique ids;
|
|
64
|
+
# create them from type & index
|
|
65
|
+
agent_id = f"{i}-{j}"
|
|
66
|
+
a = Agent(agent_id, self, risk_level=risk_level)
|
|
67
|
+
self.schedule.add(a)
|
|
68
|
+
|
|
69
|
+
else:
|
|
70
|
+
# when mode is not types, initialize risk level randomly
|
|
71
|
+
for i in range(self.num_agents):
|
|
72
|
+
a = Agent(i, self)
|
|
73
|
+
self.schedule.add(a)
|
|
74
|
+
|
|
75
|
+
self.nextid = self.num_agents + 1
|
|
76
|
+
|
|
77
|
+
model_data = {
|
|
78
|
+
"prob_notcontaminated": "prob_notcontaminated",
|
|
79
|
+
"contaminated": "contaminated",
|
|
80
|
+
"average_risk_level": "avg_risk_level",
|
|
81
|
+
"min_risk_level": "min_risk_level",
|
|
82
|
+
"max_risk_level": "max_risk_level",
|
|
83
|
+
"num_agents": "total_agents",
|
|
84
|
+
}
|
|
85
|
+
# # report percent agents by risk level
|
|
86
|
+
# for i in range(11):
|
|
87
|
+
# risk_level = i / 10
|
|
88
|
+
# model_data["pct_r%.1f" % risk_level] = lambda m: m.percent_agents_risk(
|
|
89
|
+
# risk_level
|
|
90
|
+
# )
|
|
91
|
+
|
|
92
|
+
self.datacollector = mesa.DataCollector(
|
|
93
|
+
model_reporters=model_data,
|
|
94
|
+
agent_reporters={"risk_level": "risk_level", "payoff": "payoff"},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def step(self):
|
|
98
|
+
"""Advance the model by one step."""
|
|
99
|
+
# pick a probability for risky food being not contaminated this round
|
|
100
|
+
self.prob_notcontaminated = self.random.random()
|
|
101
|
+
|
|
102
|
+
self.risky_food_status = self.get_risky_food_status()
|
|
103
|
+
|
|
104
|
+
self.schedule.step()
|
|
105
|
+
self.datacollector.collect(self)
|
|
106
|
+
|
|
107
|
+
# setup agents for the next round
|
|
108
|
+
self.propagate()
|
|
109
|
+
|
|
110
|
+
# delete cached property before the next round
|
|
111
|
+
del self.agent_risk_levels
|
|
112
|
+
|
|
113
|
+
def get_risky_food_status(self):
|
|
114
|
+
# determine actual food status for this round,
|
|
115
|
+
# weighted by probability of non-contamination
|
|
116
|
+
|
|
117
|
+
# randomly choose status, with first choice weighted by
|
|
118
|
+
# current probability not contaminated
|
|
119
|
+
return coinflip(
|
|
120
|
+
choices=[FoodStatus.NOTCONTAMINATED, FoodStatus.CONTAMINATED],
|
|
121
|
+
weight=self.prob_notcontaminated,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def propagate(self):
|
|
125
|
+
# update agents based on payoff from the completed round
|
|
126
|
+
|
|
127
|
+
# when mode is types, payoff is based on number of each type of agent
|
|
128
|
+
if self.mode == "types":
|
|
129
|
+
self.propagate_types()
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# otherwise, use previous logic
|
|
133
|
+
|
|
134
|
+
# get a generator of agents from the scheduler that
|
|
135
|
+
# will allow us to add and remove
|
|
136
|
+
for agent in self.schedule.agents:
|
|
137
|
+
# add offspring based on payoff; keep risk level
|
|
138
|
+
# logic is offspring = to payoff, original dies off,
|
|
139
|
+
# but for efficiency just add payoff - 1 and keep the original
|
|
140
|
+
for i in range(agent.payoff - 1):
|
|
141
|
+
a = Agent(i + self.nextid, self, risk_level=agent.risk_level)
|
|
142
|
+
self.schedule.add(a)
|
|
143
|
+
|
|
144
|
+
self.nextid = self.total_agents + 1
|
|
145
|
+
|
|
146
|
+
def propagate_types(self):
|
|
147
|
+
for risk_level, agents in self.agents_by_risktype.items():
|
|
148
|
+
# adjust population based on payoff and number of agents
|
|
149
|
+
total = len(agents)
|
|
150
|
+
# calculate number of agents of this type for next round
|
|
151
|
+
# - convert to int so we can use for array slicing
|
|
152
|
+
new_total = int((total * agents[0].payoff) / 2)
|
|
153
|
+
|
|
154
|
+
# if new total is less, remove agents over the expected total
|
|
155
|
+
for agent in agents[new_total:]:
|
|
156
|
+
self.schedule.remove(agent)
|
|
157
|
+
# if new total is more, add new agents with same risk level
|
|
158
|
+
if new_total > total:
|
|
159
|
+
for i in range(new_total - total):
|
|
160
|
+
a = Agent(self.nextid, self, risk_level)
|
|
161
|
+
self.schedule.add(a)
|
|
162
|
+
self.nextid += 1
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def contaminated(self):
|
|
166
|
+
# return a value for food status this round, for data collection
|
|
167
|
+
if self.risky_food_status == FoodStatus.CONTAMINATED:
|
|
168
|
+
return 1
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def agents(self):
|
|
173
|
+
# custom property to make it easy to access all current agents
|
|
174
|
+
|
|
175
|
+
# uses a generator of agents from the scheduler that
|
|
176
|
+
# will allow adding and removing agents from the scheduler
|
|
177
|
+
return self.schedule.agents
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def total_agents(self):
|
|
181
|
+
return self.schedule.get_agent_count()
|
|
182
|
+
|
|
183
|
+
def total_agents_risk(self, risk_level):
|
|
184
|
+
# total number of agents with a particular risk level
|
|
185
|
+
return len(self.agents_by_risktype[risk_level])
|
|
186
|
+
# return len([a for a in self.agents if a.risk_level == risk_level])
|
|
187
|
+
|
|
188
|
+
def percent_agents_risk(self, risk_level):
|
|
189
|
+
# # percent of agents with a particular risk level
|
|
190
|
+
risk_total = self.total_agents_risk(risk_level)
|
|
191
|
+
# print(
|
|
192
|
+
# "risk %s total %s total agents %d percent %s"
|
|
193
|
+
# % (
|
|
194
|
+
# risk_level,
|
|
195
|
+
# risk_total,
|
|
196
|
+
# self.total_agents,
|
|
197
|
+
# (risk_level / self.total_agents) * 100,
|
|
198
|
+
# )
|
|
199
|
+
# )
|
|
200
|
+
return (risk_total / self.total_agents) * 100
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def agents_by_risktype(self):
|
|
204
|
+
# group agents by risk level for propagation
|
|
205
|
+
agents = defaultdict(list)
|
|
206
|
+
for a in self.agents:
|
|
207
|
+
agents[a.risk_level].append(a)
|
|
208
|
+
return agents
|
|
209
|
+
|
|
210
|
+
@cached_property
|
|
211
|
+
def agent_risk_levels(self) -> [float]:
|
|
212
|
+
# list of all risk levels for all current agents;
|
|
213
|
+
# property is cached but should be cleared in each new round
|
|
214
|
+
|
|
215
|
+
# NOTE: occasionally median method is complaining that this is empty
|
|
216
|
+
return [a.risk_level for a in self.agents]
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def avg_risk_level(self):
|
|
220
|
+
return mean(self.agent_risk_levels)
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def min_risk_level(self):
|
|
224
|
+
return min(self.agent_risk_levels)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def max_risk_level(self):
|
|
228
|
+
return max(self.agent_risk_levels)
|
|
229
|
+
|
|
230
|
+
payoffs = {
|
|
231
|
+
"range": {"safe": 2, "not_contaminated": 3, "contaminated": 1},
|
|
232
|
+
"types": {"safe": 2, "not_contaminated": 4, "contaminated": 1},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def payoff(self, choice):
|
|
236
|
+
"Calculate the payoff for a given choice, based on current food status"
|
|
237
|
+
|
|
238
|
+
# safe food choice always has a payoff of 2
|
|
239
|
+
if choice == FoodChoice.SAFE:
|
|
240
|
+
# return 2
|
|
241
|
+
return self.payoffs[self.mode]["safe"]
|
|
242
|
+
# payoff for risky food choice depends on contamination
|
|
243
|
+
# - if not contaminated, payoff of 3
|
|
244
|
+
if self.risky_food_status == FoodStatus.NOTCONTAMINATED:
|
|
245
|
+
# return 3
|
|
246
|
+
return self.payoffs[self.mode]["not_contaminated"]
|
|
247
|
+
|
|
248
|
+
# otherwise only payoff of 1
|
|
249
|
+
return self.payoffs[self.mode]["contaminated"]
|
|
250
|
+
# return 1
|