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.
Files changed (40) hide show
  1. simulatingrisk/__init__.py +1 -0
  2. simulatingrisk/about_app.md +2 -0
  3. simulatingrisk/app.py +50 -0
  4. simulatingrisk/batch_run.py +145 -0
  5. simulatingrisk/charts/histogram.js +51 -0
  6. simulatingrisk/charts/histogram.py +72 -0
  7. simulatingrisk/hawkdove/README.md +106 -0
  8. simulatingrisk/hawkdove/app.py +90 -0
  9. simulatingrisk/hawkdove/model.py +391 -0
  10. simulatingrisk/hawkdove/run.py +31 -0
  11. simulatingrisk/hawkdove/server.py +189 -0
  12. simulatingrisk/hawkdovemulti/README.md +92 -0
  13. simulatingrisk/hawkdovemulti/analysis_utils.py +83 -0
  14. simulatingrisk/hawkdovemulti/app.py +242 -0
  15. simulatingrisk/hawkdovemulti/batch_run.py +328 -0
  16. simulatingrisk/hawkdovemulti/model.py +462 -0
  17. simulatingrisk/hawkdovemulti/run_simulation.ipynb +55 -0
  18. simulatingrisk/hawkdovemulti/simrisk_batch.slurm +44 -0
  19. simulatingrisk/risky_bet/README.md +45 -0
  20. simulatingrisk/risky_bet/app.py +17 -0
  21. simulatingrisk/risky_bet/model.py +237 -0
  22. simulatingrisk/risky_bet/run.py +46 -0
  23. simulatingrisk/risky_bet/server.py +102 -0
  24. simulatingrisk/risky_food/README.md +32 -0
  25. simulatingrisk/risky_food/__init__.py +0 -0
  26. simulatingrisk/risky_food/app.py +19 -0
  27. simulatingrisk/risky_food/model.py +250 -0
  28. simulatingrisk/risky_food/run.py +20 -0
  29. simulatingrisk/risky_food/server.py +78 -0
  30. simulatingrisk/stag_hunt/README.md +15 -0
  31. simulatingrisk/stag_hunt/__init__.py +0 -0
  32. simulatingrisk/stag_hunt/model.py +123 -0
  33. simulatingrisk/stag_hunt/run.py +45 -0
  34. simulatingrisk/utils.py +31 -0
  35. simulatingrisk-1.0.0.dist-info/METADATA +113 -0
  36. simulatingrisk-1.0.0.dist-info/RECORD +40 -0
  37. simulatingrisk-1.0.0.dist-info/WHEEL +5 -0
  38. simulatingrisk-1.0.0.dist-info/entry_points.txt +2 -0
  39. simulatingrisk-1.0.0.dist-info/licenses/LICENSE +201 -0
  40. 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