diff --git a/pyproject.toml b/pyproject.toml index c140f63..ab01470 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ classifiers = [ "Programming Language :: Python :: 3", ] dependencies = [ - "mesa>=2.1.2", + "mesa==2.1.2", # "mesa @ git+https://github.com/projectmesa/mesa.git@main", "matplotlib", "altair>5.0.1" diff --git a/simulatingrisk/app.py b/simulatingrisk/app.py index 9eda061..e18a36b 100644 --- a/simulatingrisk/app.py +++ b/simulatingrisk/app.py @@ -3,7 +3,7 @@ import solara from simulatingrisk.hawkdove.app import page as hawkdove_page -from simulatingrisk.hawkdovevar.app import page as hawkdove_var_page +from simulatingrisk.hawkdovemulti.app import page as hawkdove_multi_page from simulatingrisk.risky_bet.app import page as riskybet_page from simulatingrisk.risky_food.app import page as riskyfood_page @@ -21,8 +21,8 @@ def hawkdove(): @solara.component -def hawkdove_var(): - return hawkdove_var_page +def hawkdove_multi(): + return hawkdove_multi_page @solara.component @@ -41,7 +41,9 @@ def riskyfood(): path="hawkdove-single", component=hawkdove, label="Hawk/Dove (single r)" ), solara.Route( - path="hawkdove-variable", component=hawkdove_var, label="Hawk/Dove (variable r)" + path="hawkdove-multiple", + component=hawkdove_multi, + label="Hawk/Dove (multiple r)", ), solara.Route(path="riskybet", component=riskybet, label="Risky Bet"), solara.Route(path="riskyfood", component=riskyfood, label="Risky Food"), diff --git a/simulatingrisk/batch_run.py b/simulatingrisk/batch_run.py index fce64c7..8f76c0b 100755 --- a/simulatingrisk/batch_run.py +++ b/simulatingrisk/batch_run.py @@ -7,7 +7,7 @@ from mesa import batch_run from simulatingrisk.hawkdove.model import HawkDoveSingleRiskModel -from simulatingrisk.hawkdovevar.model import HawkDoveVariableRiskModel +from simulatingrisk.hawkdovemulti.model import HawkDoveMultipleRiskModel from simulatingrisk.risky_bet.model import RiskyBetModel from simulatingrisk.risky_food.model import RiskyFoodModel @@ -76,14 +76,14 @@ def hawkdove_singlerisk_batch_run(args): save_results("hawkdove_single", results) -def hawkdove_variablerisk_batch_run(args): +def hawkdove_multiplerisk_batch_run(args): params = { "grid_size": 10, "risk_adjustment": "adopt", # run adopt only for now } iterations = 100 results = batch_run( - HawkDoveVariableRiskModel, + HawkDoveMultipleRiskModel, parameters=params, iterations=iterations, number_processes=1, @@ -92,7 +92,7 @@ def hawkdove_variablerisk_batch_run(args): max_steps=250, # converges fairly quickly, don't run 1000 times ) # include the mode in the output filename - save_results("hawkdove_variable", results) + save_results("hawkdove_multiple", results) def save_results(simulation, results): @@ -132,8 +132,8 @@ def save_results(simulation, results): # help="Mode for initializing agent risk attitudes", # ) hawkdove_parser.set_defaults(func=hawkdove_singlerisk_batch_run) - hawkdovevar_parser = subparsers.add_parser("hawkdove-var") - hawkdovevar_parser.set_defaults(func=hawkdove_variablerisk_batch_run) + hawkdove_multi_parser = subparsers.add_parser("hawkdove-multi") + hawkdove_multi_parser.set_defaults(func=hawkdove_multiplerisk_batch_run) args = parser.parse_args() # run appropriate function based on the selected subcommand diff --git a/simulatingrisk/hawkdove/README.md b/simulatingrisk/hawkdove/README.md index c1f3a7c..7f9ef05 100644 --- a/simulatingrisk/hawkdove/README.md +++ b/simulatingrisk/hawkdove/README.md @@ -31,14 +31,14 @@ Players arranged on a lattice [options for both 4 neighbors (AYBD) and 8 neighbo - If I play DOVE and neighbor plays DOVE: 2.1 - If I play DOVE and neighbor plays HAWK: 1 - If I play HAWK and neighbor plays HAWK: 0 - + Each player on a lattice (grid in Mesa): -- Has parameter $r$ [from 0 to 8, or 0 to 4 for four neighbors] +- Has parameter $r$ [from 0 to 8] - Let `d` be the number of neighbors who played DOVE during the previous round. If $d > r$, then play HAWK. Otherwise play DOVE. (Agents who are risk-avoidant only play HAWK if there are a lot of doves around them. More risk-avoidance requires a higher number of doves to get an agent to play HAWK.) - The proportion of neighbors who play DOVE corresponds to your probability of encountering a DOVE when playing a randomly-selected neighbor. The intended interpretation is that you maximize REU for this probability of your opponent playing DOVE. Thus, $r$ corresponds to the probability above which playing HAWK maximizes REU. - Choice for the first round could be randomly determined, or add parameters to see how initial conditions matter? - [OR VARY FIRST ROUND: what proportion starts as HAWK - - Who is a HAWK and who is a DOVE is randomly determined; proportion set at the beginning of each simulation. E.g. 30% are HAWKS; if we have 100 players, then each player has a 30% chance of being HAWK; + - Who is a HAWK and who is a DOVE is randomly determined; proportion set at the beginning of each simulation. E.g. 30% are HAWKS; if we have 100 players, then each player has a 30% chance of being HAWK; - Call this initial parameter HAWK-ODDS; default is 50/50 @@ -91,4 +91,3 @@ Another way to visualize the risk attitudes and choices in this game is this tab 7DDDDDDDDH 8DDDDDDDDD - diff --git a/simulatingrisk/hawkdove/model.py b/simulatingrisk/hawkdove/model.py index c6a05ee..e6bc2c0 100644 --- a/simulatingrisk/hawkdove/model.py +++ b/simulatingrisk/hawkdove/model.py @@ -66,18 +66,47 @@ def initial_choice(self, hawk_odds=None): def choice_label(self): return "hawk" if self.choice == Play.HAWK else "dove" + def get_neighbors(self, size): + """get all neighbors for a supported neighborhood size""" + check_neighborhood_size(size) + # 4 and 8 neighborhood use default radius 1 + # 8 and 24 both use moore neighborhood (includes diagonals) + opts = {"moore": True} + if size == 4: + # use von neumann neighborhood instead of moore (no diagonal) + opts["moore"] = False + + # for 24 size neighborhood, use radius 2 + if size == 24: + opts["radius"] = 2 + + return self.model.grid.get_neighbors(self.pos, include_center=False, **opts) + @property - def neighbors(self): - # use configured neighborhood (with or without diagonals) on the model; - # don't include the current agent - return self.model.grid.get_neighbors( - self.pos, moore=self.model.include_diagonals, include_center=False - ) + def play_neighbors(self): + """neighbors to play against, based on model play neighborhood size""" + return self.get_neighbors(self.model.play_neighborhood) + + @property + def observed_neighbors(self): + """neighbors to look at when deciding what to play; + based on model observed neighborhood size""" + return self.get_neighbors(self.model.observed_neighborhood) @property def num_dove_neighbors(self): - """count how many neighbors played DOVE on the last round""" - return len([n for n in self.neighbors if n.last_choice == Play.DOVE]) + """count how many neighbors played DOVE on the last round + (uses `observed_neighborhood` size from model)""" + return len([n for n in self.observed_neighbors if n.last_choice == Play.DOVE]) + + @property + def proportional_num_dove_neighbors(self): + """adjust the number of dove neighbors based on ratio between + play neighborhood and observed neighborhood, to scale observations + to the range of agent risk level.""" + ratio = self.model.max_risk_level / self.model.observed_neighborhood + # always round to an integer + return round(ratio * self.num_dove_neighbors) def choose(self): "decide what to play this round" @@ -90,7 +119,7 @@ def choose(self): # (any risk is acceptable). # agent with r = max should always take the safe option # (no risk is acceptable) - if self.num_dove_neighbors > self.risk_level: + if self.proportional_num_dove_neighbors > self.risk_level: self.choice = Play.HAWK else: self.choice = Play.DOVE @@ -98,7 +127,7 @@ def choose(self): def play(self): # play against each neighbor and calculate cumulative payoff payoff = 0 - for n in self.neighbors: + for n in self.play_neighbors: payoff += self.payoff(n) # update total points based on payoff this round self.points += payoff @@ -136,8 +165,10 @@ class HawkDoveModel(mesa.Model): Model for hawk/dove game with risk attitudes. :param grid_size: number for square grid size (creates n*n agents) - :param include_diagonals: whether agents should include diagonals - or not when considering neighbors (default: True) + :param play_neighborhood: size of neighborhood each agent plays + against; 4, 8, or 24 (default: 8) + :param observed_neighborhood: size of neighborhood each agent looks + at when choosing what to play; 4, 8, or 24 (default: 8) :param hawk_odds: odds for playing hawk on the first round (default: 0.5) :param risk_adjustment: strategy agents should use for adjusting risk; None (default), adopt, or average @@ -153,19 +184,29 @@ class HawkDoveModel(mesa.Model): min_window = 15 #: class to use when initializing agents agent_class = HawkDoveAgent + #: supported neighborhood sizes + neighborhood_sizes = {4, 8, 24} + #: minimu risk level + min_risk_level = 0 # TODO: allow -1 ? + #: maximum risk level allowed + max_risk_level = 8 def __init__( self, grid_size, - include_diagonals=True, + play_neighborhood=8, + observed_neighborhood=8, hawk_odds=0.5, ): super().__init__() # assume a fully-populated square grid self.num_agents = grid_size * grid_size - # mesa get_neighbors supports moore neighborhood (include diagonals) - # and von neumann (exclude diagonals) - self.include_diagonals = include_diagonals + for nsize in [play_neighborhood, observed_neighborhood]: + check_neighborhood_size(nsize) + + self.play_neighborhood = play_neighborhood + self.observed_neighborhood = observed_neighborhood + # distribution of first choice (50/50 by default) self.hawk_odds = hawk_odds @@ -262,6 +303,15 @@ def converged(self): ) +def check_neighborhood_size(size): + # neighborhood size check, shared by model and agent + if size not in HawkDoveModel.neighborhood_sizes: + raise ValueError( + f"{size} is not a supported neighborhood size; " + + f"must be one of {HawkDoveModel.neighborhood_sizes}" + ) + + class HawkDoveSingleRiskAgent(HawkDoveAgent): """ An agent with a risk attitude playing Hawk or Dove; must be initialized @@ -273,23 +323,28 @@ def set_risk_level(self): class HawkDoveSingleRiskModel(HawkDoveModel): - """hawk/dove simulation where all agents have the same risk atttitude""" + """hawk/dove simulation where all agents have the same risk atttitude. + Adds a required `agent_risk_level` parameter; supports all + parameters in :class:`HawkDoveModel`. + """ #: class to use when initializing agents agent_class = HawkDoveSingleRiskAgent risk_attitudes = "single" - def __init__( - self, - grid_size, - agent_risk_level, - include_diagonals=True, - hawk_odds=0.5, - ): + def __init__(self, grid_size, agent_risk_level, *args, **kwargs): + if ( + agent_risk_level > self.max_risk_level + or agent_risk_level < self.min_risk_level + ): + raise ValueError( + f"Agent risk level {agent_risk_level} is out of range; must be between " + + f"{self.min_risk_level} - {self.max_risk_level}" + ) + # store agent risk level self.agent_risk_level = agent_risk_level + # pass through options and initialize base class - super().__init__( - grid_size, include_diagonals=include_diagonals, hawk_odds=hawk_odds - ) + super().__init__(grid_size, *args, **kwargs) diff --git a/simulatingrisk/hawkdove/server.py b/simulatingrisk/hawkdove/server.py index 0e89587..b27b0cd 100644 --- a/simulatingrisk/hawkdove/server.py +++ b/simulatingrisk/hawkdove/server.py @@ -6,7 +6,11 @@ import solara import pandas as pd -from simulatingrisk.hawkdove.model import Play, divergent_colors_9, divergent_colors_5 +from simulatingrisk.hawkdove.model import ( + Play, + divergent_colors_9, + HawkDoveModel, +) def agent_portrayal(agent): @@ -25,11 +29,8 @@ def agent_portrayal(agent): # "color": "tab:gray", } - # color based on risk level - if agent.model.include_diagonals: - colors = divergent_colors_9 - else: - colors = divergent_colors_5 + # color based on risk level; risk levels are always 0-8 + colors = divergent_colors_9 portrayal["Color"] = colors[agent.risk_level] # copy to lowercase color for solara @@ -57,6 +58,8 @@ def agent_portrayal(agent): "grid_size": grid_size, } +neighborhood_sizes = sorted(list(HawkDoveModel.neighborhood_sizes)) + # parameters common to both hawk/dove variants common_jupyterviz_params = { "grid_size": { @@ -67,10 +70,17 @@ def agent_portrayal(agent): "max": 100, "step": 1, }, - "include_diagonals": { - "type": "Checkbox", - "value": True, - "label": "Include diagonal neighbors", + "play_neighborhood": { + "type": "Select", + "value": 8, + "values": neighborhood_sizes, + "label": "Play neighborhood size", + }, + "observed_neighborhood": { + "type": "Select", + "value": 8, + "values": neighborhood_sizes, + "label": "Observed neighborhood (determines choice of play)", }, "hawk_odds": { "type": "SliderFloat", diff --git a/simulatingrisk/hawkdovevar/app.py b/simulatingrisk/hawkdovemulti/app.py similarity index 88% rename from simulatingrisk/hawkdovevar/app.py rename to simulatingrisk/hawkdovemulti/app.py index 0252618..2d68ab2 100644 --- a/simulatingrisk/hawkdovevar/app.py +++ b/simulatingrisk/hawkdovemulti/app.py @@ -4,11 +4,12 @@ import solara -from simulatingrisk.hawkdovevar.model import HawkDoveVariableRiskModel +from simulatingrisk.hawkdovemulti.model import HawkDoveMultipleRiskModel from simulatingrisk.hawkdove.server import ( agent_portrayal, common_jupyterviz_params, draw_hawkdove_agent_space, + neighborhood_sizes, ) from simulatingrisk.hawkdove.app import plot_hawks @@ -31,6 +32,12 @@ "value": 10, "description": "How many rounds between risk adjustment", }, + "adjust_neighborhood": { + "type": "Select", + "value": 8, + "values": neighborhood_sizes, + "label": "Adjustment neighborhood size", + }, } ) @@ -60,9 +67,8 @@ def plot_agents_by_risk(model): x=alt.X( "risk_level", title="risk attitude", - # don't display any 0.5 ticks when max is 4 - axis=alt.Axis(tickCount=model.num_neighbors + 1), - scale=alt.Scale(domain=[0, model.num_neighbors]), + axis=alt.Axis(tickCount=model.max_risk_level + 1), + scale=alt.Scale(domain=[model.min_risk_level, model.max_risk_level]), ), y=alt.Y("total", title="Number of agents"), ) @@ -124,10 +130,10 @@ def plot_hawks_by_risk(model): page = JupyterViz( - HawkDoveVariableRiskModel, + HawkDoveMultipleRiskModel, jupyterviz_params_var, measures=[plot_hawks, plot_agents_by_risk, plot_hawks_by_risk], - name="Hawk/Dove game with variable risk attitudes", + name="Hawk/Dove game with multiple risk attitudes", agent_portrayal=agent_portrayal, space_drawer=draw_hawkdove_agent_space, ) diff --git a/simulatingrisk/hawkdovevar/model.py b/simulatingrisk/hawkdovemulti/model.py similarity index 84% rename from simulatingrisk/hawkdovevar/model.py rename to simulatingrisk/hawkdovemulti/model.py index 63b69be..c5d614e 100644 --- a/simulatingrisk/hawkdovevar/model.py +++ b/simulatingrisk/hawkdovemulti/model.py @@ -1,11 +1,11 @@ import statistics from collections import Counter -from enum import Enum +from enum import IntEnum from simulatingrisk.hawkdove.model import HawkDoveModel, HawkDoveAgent -class HawkDoveVariableRiskAgent(HawkDoveAgent): +class HawkDoveMultipleRiskAgent(HawkDoveAgent): """ An agent with random risk attitude playing Hawk or Dove. Optionally adjusts risks based on most successful neighbor, depending on model @@ -16,8 +16,11 @@ def set_risk_level(self): # risk level is based partially on neighborhood size, # which is configurable at the model level - # generate a random risk level between zero and number of neighbors - self.risk_level = self.random.randint(0, self.model.num_neighbors) + # generate a random risk level between zero and 8 + # (using same range for all neighborhood sizes) + self.risk_level = self.random.randint( + self.model.min_risk_level, self.model.max_risk_level + ) def play(self): super().play() @@ -25,12 +28,18 @@ def play(self): if self.model.adjustment_round: self.adjust_risk() + @property + def adjust_neighbors(self): + """neighbors to look at when adjusting risk attitude; uses + model adjust_neighborhood size""" + return self.get_neighbors(self.model.adjust_neighborhood) + @property def most_successful_neighbor(self): """identify and return the neighbor with the most points""" # sort neighbors by points, highest points first # adapted from risky bet wealthiest neighbor - return sorted(self.neighbors, key=lambda x: x.points, reverse=True)[0] + return sorted(self.adjust_neighbors, key=lambda x: x.points, reverse=True)[0] def adjust_risk(self): # look at neighbors @@ -53,7 +62,7 @@ def adjust_risk(self): ) -class RiskState(Enum): +class RiskState(IntEnum): """Categorization of population risk states""" # majority risk inclined @@ -91,36 +100,36 @@ def category(cls, val): return "no majority" -class HawkDoveVariableRiskModel(HawkDoveModel): +class HawkDoveMultipleRiskModel(HawkDoveModel): """ - Model for hawk/dove game with variable risk attitudes. + Model for hawk/dove game with variable risk attitudes. Supports + all parameters in :class:`~simulatingrisk.hawkdove.model.HawkDoveModel` + and adds several parmeters to control if and how agents adjust + their risk attitudes (strategy, frequency, and neighborhood size). - :param grid_size: number for square grid size (creates n*n agents) - :param include_diagonals: whether agents should include diagonals - or not when considering neighbors (default: True) - :param hawk_odds: odds for playing hawk on the first round (default: 0.5) :param risk_adjustment: strategy agents should use for adjusting risk; None (default), adopt, or average :param adjust_every: when risk adjustment is enabled, adjust every N rounds (default: 10) + :param adjust_neighborhood: size of neighborhood to look at when + adjusting risk attitudes; 4, 8, or 24 (default: play_neighborhood) """ risk_attitudes = "variable" - agent_class = HawkDoveVariableRiskAgent + agent_class = HawkDoveMultipleRiskAgent supported_risk_adjustments = (None, "adopt", "average") def __init__( self, grid_size, - include_diagonals=True, - hawk_odds=0.5, risk_adjustment=None, adjust_every=10, + adjust_neighborhood=None, + *args, + **kwargs, ): - super().__init__( - grid_size, include_diagonals=include_diagonals, hawk_odds=hawk_odds - ) + super().__init__(grid_size, *args, **kwargs) # convert string input from solara app parameters to None if risk_adjustment == "none": risk_adjustment = None @@ -135,12 +144,9 @@ def __init__( ) self.risk_adjustment = risk_adjustment self.adjust_round_n = adjust_every - - @property - def num_neighbors(self) -> int: - # number of neighbors for each agent - depends on whether - # diagonals are included or not - return 8 if self.include_diagonals else 4 + # if adjust neighborhood is not specified, then use the same size + # as play neighborhood + self.adjust_neighborhood = adjust_neighborhood or self.play_neighborhood @property def adjustment_round(self) -> bool: diff --git a/tests/test_hawkdove.py b/tests/test_hawkdove.py index 17298bd..f0b38f2 100644 --- a/tests/test_hawkdove.py +++ b/tests/test_hawkdove.py @@ -13,21 +13,23 @@ def test_agent_neighbors(): - # initialize model with a small grid, include diagonals - model = HawkDoveSingleRiskModel(3, include_diagonals=True, agent_risk_level=4) + # initialize model with a small grid, neighborhood of 8 + model = HawkDoveSingleRiskModel(3, play_neighborhood=8, agent_risk_level=4) # every agent should have 8 neighbors when diagonals are included - assert all([len(agent.neighbors) == 8 for agent in model.schedule.agents]) + assert all([len(agent.play_neighbors) == 8 for agent in model.schedule.agents]) - # every agent should have 4 neighbors when diagonals are not included - model = HawkDoveSingleRiskModel(3, include_diagonals=False, agent_risk_level=2) - assert all([len(agent.neighbors) == 4 for agent in model.schedule.agents]) + # neighborhood of 4 + model = HawkDoveSingleRiskModel(3, play_neighborhood=4, agent_risk_level=2) + assert all([len(agent.play_neighbors) == 4 for agent in model.schedule.agents]) + + # neighborhood of 24 (grid needs to be at least 5x5) + model = HawkDoveSingleRiskModel(5, play_neighborhood=24, agent_risk_level=5) + assert all([len(agent.play_neighbors) == 24 for agent in model.schedule.agents]) def test_agent_initial_choice(): grid_size = 100 - model = HawkDoveSingleRiskModel( - grid_size, include_diagonals=False, agent_risk_level=5 - ) + model = HawkDoveSingleRiskModel(grid_size, agent_risk_level=5) # for now, initial choice is random (hawk-odds param still todo) initial_choices = [a.choice for a in model.schedule.agents] choice_count = Counter(initial_choices) @@ -40,9 +42,7 @@ def test_agent_initial_choice(): def test_agent_initial_choice_hawkodds(): grid_size = 100 # specify hawk-odds other than 05 - model = HawkDoveSingleRiskModel( - grid_size, include_diagonals=False, hawk_odds=0.3, agent_risk_level=2 - ) + model = HawkDoveSingleRiskModel(grid_size, hawk_odds=0.3, agent_risk_level=2) initial_choices = [a.choice for a in model.schedule.agents] choice_count = Counter(initial_choices) # expect about 30% hawks @@ -74,21 +74,39 @@ def test_agent_repr(): def test_model_single_risk_level(): risk_level = 3 - model = HawkDoveSingleRiskModel( - 5, include_diagonals=True, agent_risk_level=risk_level - ) + model = HawkDoveSingleRiskModel(5, agent_risk_level=risk_level) for agent in model.schedule.agents: assert agent.risk_level == risk_level # handle zero properly (should not be treated the same as None) risk_level = 0 - model = HawkDoveSingleRiskModel( - 5, include_diagonals=True, agent_risk_level=risk_level - ) + model = HawkDoveSingleRiskModel(5, agent_risk_level=risk_level) for agent in model.schedule.agents: assert agent.risk_level == risk_level +def test_bad_neighborhood_size(): + with pytest.raises(ValueError): + HawkDoveSingleRiskModel(3, play_neighborhood=3, agent_risk_level=6) + with pytest.raises(ValueError): + agent = HawkDoveSingleRiskAgent(1, Mock(agent_risk_level=2)) + agent.get_neighbors(5) + + +def test_observed_neighborhood_size(): + # observed neighborhood size is also configurable + # common options, irrelevant for this test + opts = {"agent_risk_level": 1, "play_neighborhood": 4} + model = HawkDoveSingleRiskModel(3, observed_neighborhood=4, **opts) + assert model.observed_neighborhood == 4 + model = HawkDoveSingleRiskModel(3, observed_neighborhood=8, **opts) + assert model.observed_neighborhood == 8 + model = HawkDoveSingleRiskModel(3, observed_neighborhood=24, **opts) + assert model.observed_neighborhood == 24 + with pytest.raises(ValueError): + HawkDoveSingleRiskModel(3, observed_neighborhood=23, **opts) + + def test_num_dove_neighbors(): # initialize an agent with a mock model agent = HawkDoveSingleRiskAgent(1, Mock(agent_risk_level=2)) @@ -99,7 +117,7 @@ def test_num_dove_neighbors(): Mock(last_choice=Play.DOVE), ] - with patch.object(HawkDoveSingleRiskAgent, "neighbors", mock_neighbors): + with patch.object(HawkDoveSingleRiskAgent, "observed_neighbors", mock_neighbors): assert agent.num_dove_neighbors == 1 @@ -113,7 +131,7 @@ def test_agent_choose(): agent.model.schedule.steps = 1 # given a specified number of dove neighbors and risk level - with patch.object(HawkDoveAgent, "num_dove_neighbors", 3): + with patch.object(HawkDoveAgent, "proportional_num_dove_neighbors", 3): # an agent with `r=0` will always take the risky choice # (any risk is acceptable). agent.risk_level = 0 @@ -137,6 +155,39 @@ def test_agent_choose(): assert agent.choice == Play.DOVE +def test_proportional_num_dove_neighbors(): + model = HawkDoveSingleRiskModel(4, agent_risk_level=3) + agent = HawkDoveSingleRiskAgent(1, model) + + ## equal play/observed; scales to 8 (risk level range) + model.observed_neighborhood = 4 + with patch.object(HawkDoveAgent, "num_dove_neighbors", 3): + assert agent.proportional_num_dove_neighbors == 6 + + model.observed_neighborhood = 8 + with patch.object(HawkDoveAgent, "num_dove_neighbors", 5): + assert agent.proportional_num_dove_neighbors == 5 + + # observe more than 8 + model.observed_neighborhood = 24 + with patch.object(HawkDoveAgent, "num_dove_neighbors", 20): + assert agent.proportional_num_dove_neighbors == 7 + + +def test_agent_choose_when_observe_play_differ(): + # confirm that adjusted value is used to determine play + + model = HawkDoveSingleRiskModel( + 4, agent_risk_level=3, observed_neighborhood=24, play_neighborhood=8 + ) + agent = HawkDoveSingleRiskAgent(3, model) + with patch.object(HawkDoveAgent, "num_dove_neighbors", 5): + agent.choose() == Play.DOVE + + with patch.object(HawkDoveAgent, "num_dove_neighbors", 6): + agent.choose() == Play.HAWK + + def test_agent_play(): agent = HawkDoveSingleRiskAgent(1, Mock(agent_risk_level=3)) # on the first round, last choice should be unset @@ -148,7 +199,7 @@ def test_agent_play(): agent.choice = Play.HAWK neighbor_hawk = Mock(choice=Play.HAWK) neighbor_dove = Mock(choice=Play.DOVE) - with patch.object(HawkDoveAgent, "neighbors", [neighbor_hawk, neighbor_dove]): + with patch.object(HawkDoveAgent, "play_neighbors", [neighbor_hawk, neighbor_dove]): agent.play() # should get 3 points against dove and 0 against the hawk assert agent.points == 3 + 0 diff --git a/tests/test_hawkdovevar.py b/tests/test_hawkdovevar.py deleted file mode 100644 index 7796d69..0000000 --- a/tests/test_hawkdovevar.py +++ /dev/null @@ -1,190 +0,0 @@ -import statistics -from unittest.mock import patch, Mock - -import pytest - -from simulatingrisk.hawkdovevar.model import ( - HawkDoveVariableRiskModel, - HawkDoveVariableRiskAgent, - RiskState, -) - - -def test_init(): - model = HawkDoveVariableRiskModel(5) - # defaults - assert model.risk_adjustment is None - assert model.hawk_odds == 0.5 - assert model.include_diagonals is True - # unused but should be set to default - assert model.adjust_round_n == 10 - - # init with risk adjustment - model = HawkDoveVariableRiskModel( - 5, - include_diagonals=False, - hawk_odds=0.2, - risk_adjustment="adopt", - adjust_every=5, - ) - - assert model.risk_adjustment == "adopt" - assert model.adjust_round_n == 5 - assert model.hawk_odds == 0.2 - assert model.include_diagonals is False - - # handle string none for solara app parameters - model = HawkDoveVariableRiskModel(5, risk_adjustment="none") - assert model.risk_adjustment is None - - # complain about invalid adjustment type - with pytest.raises(ValueError, match="Unsupported risk adjustment 'bogus'"): - HawkDoveVariableRiskModel(3, risk_adjustment="bogus") - - -def test_num_neighbors(): - with_diagonals = HawkDoveVariableRiskModel(3) - assert with_diagonals.num_neighbors == 8 - - no_diagonals = HawkDoveVariableRiskModel(3, include_diagonals=False) - assert no_diagonals.num_neighbors == 4 - - -def test_init_variable_risk_level(): - model = HawkDoveVariableRiskModel( - 5, - include_diagonals=True, - ) - # when risk level is variable/random, agents should have different risk levels - risk_levels = set([agent.risk_level for agent in model.schedule.agents]) - assert len(risk_levels) > 1 - - -adjustment_testdata = [ - # init parameters, expected adjustment round - ({"risk_adjustment": None}, None), - ({"risk_adjustment": "adopt"}, 10), - ({"risk_adjustment": "average"}, 10), - ({"risk_adjustment": "average", "adjust_every": 3}, 3), -] - - -@pytest.mark.parametrize("params,expect_adjust_step", adjustment_testdata) -def test_adjustment_round(params, expect_adjust_step): - model = HawkDoveVariableRiskModel(3, **params) - - run_for = (expect_adjust_step or 10) + 1 - - # step through the model enough rounds to encounter one adjustment rounds - # if adjustment is enabled; start at 1 (step count starts at 1) - for i in range(1, run_for): - model.step() - if i == expect_adjust_step: - assert model.adjustment_round - else: - assert not model.adjustment_round - - -def test_population_risk_category(): - model = HawkDoveVariableRiskModel(3) - model.schedule = Mock() - - # majority risk inclined - model.schedule.agents = [Mock(risk_level=0), Mock(risk_level=1), Mock(risk_level=2)] - assert model.population_risk_category == RiskState.c1 - # three risk-inclined agents and one risk moderate - model.schedule.agents.append(Mock(risk_level=4)) - assert model.population_risk_category == RiskState.c2 - - # majority risk moderate - model.schedule.agents = [Mock(risk_level=4), Mock(risk_level=3), Mock(risk_level=5)] - assert model.population_risk_category == RiskState.c7 - - # majority risk avoidant - model.schedule.agents = [Mock(risk_level=6), Mock(risk_level=7), Mock(risk_level=8)] - assert model.population_risk_category == RiskState.c12 - - -def test_riskstate_label(): - # enum value or integer value - assert RiskState.category(RiskState.c1) == "majority risk inclined" - assert RiskState.category(2) == "majority risk inclined" - assert RiskState.category(RiskState.c5) == "majority risk moderate" - assert RiskState.category(6) == "majority risk moderate" - assert RiskState.category(RiskState.c11) == "majority risk avoidant" - assert RiskState.category(RiskState.c13) == "no majority" - assert RiskState.category(13) == "no majority" - - -def test_most_successful_neighbor(): - # initialize an agent with a mock model - agent = HawkDoveVariableRiskAgent(1, Mock(), 1000) - mock_neighbors = [ - Mock(points=2), - Mock(points=4), - Mock(points=23), - Mock(points=31), - ] - - with patch.object(HawkDoveVariableRiskAgent, "neighbors", mock_neighbors): - assert agent.most_successful_neighbor.points == 31 - - -def test_agent_play_adjust(): - mock_model = Mock(risk_adjustment="adopt") - agent = HawkDoveVariableRiskAgent(1, mock_model) - # simulate no neighbors to skip payoff calculation - with patch.object( - HawkDoveVariableRiskAgent, "neighbors", new=[] - ) as mock_adjust_risk: - with patch.object(HawkDoveVariableRiskAgent, "adjust_risk") as mock_adjust_risk: - # when it is not an adjustment round, should not call adjust risk - mock_model.adjustment_round = False - agent.play() - assert mock_adjust_risk.call_count == 0 - - # should call adjust risk when the model indicates - mock_model.adjustment_round = True - agent.play() - assert mock_adjust_risk.call_count == 1 - - -def test_adjust_risk_adopt(): - # initialize an agent with a mock model - agent = HawkDoveVariableRiskAgent(1, Mock(risk_adjustment="adopt")) - # set a known risk level - agent.risk_level = 2 - # adjust wealth as if the model had run - agent.points = 20 - # set a mock neighbor with more points than current agent - neighbor = Mock(points=1500, risk_level=3) - with patch.object(HawkDoveVariableRiskAgent, "most_successful_neighbor", neighbor): - agent.adjust_risk() - # default behavior is to adopt successful risk level - assert agent.risk_level == neighbor.risk_level - - # now simulate a wealthiest neighbor with fewer points than current agent - neighbor.points = 12 - neighbor.risk_level = 3 - prev_risk_level = agent.risk_level - agent.adjust_risk() - # risk level should not be changed - assert agent.risk_level == prev_risk_level - - -def test_adjust_risk_average(): - # same as previous test, but with average risk adjustment strategy - agent = HawkDoveVariableRiskAgent(1, Mock(risk_adjustment="average")) - # set a known risk level - agent.risk_level = 2 - # adjust points as if the model had run - agent.points = 300 - # set a neighbor with more points than current agent - neighbor = Mock(points=350, risk_level=3) - with patch.object(HawkDoveVariableRiskAgent, "most_successful_neighbor", neighbor): - prev_risk_level = agent.risk_level - agent.adjust_risk() - # new risk level should be average of previous and most successful - assert agent.risk_level == round( - statistics.mean([neighbor.risk_level, prev_risk_level]) - )