Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Hawk/dove updating risk attitudes #36

Merged
merged 9 commits into from
Oct 25, 2023
1 change: 0 additions & 1 deletion requirements.txt

This file was deleted.

Empty file removed requirements/main.txt
Empty file.
2 changes: 1 addition & 1 deletion simulatingrisk/about_app.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@

These simulatiions are associated with the CDH project [Simulating risk, risking simulations](https://cdh.princeton.edu/projects/simulating-risk/).
These simulations are associated with the CDH project [Simulating risk, risking simulations](https://cdh.princeton.edu/projects/simulating-risk/).
5 changes: 4 additions & 1 deletion simulatingrisk/app.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import os.path

Check warning on line 1 in simulatingrisk/app.py

View check run for this annotation

Codecov / codecov/patch

simulatingrisk/app.py#L1

Added line #L1 was not covered by tests

import solara

from simulatingrisk.hawkdove.app import page as hawkdove_page
from simulatingrisk.hawkdovevar.app import page as hawkdove_var_page

Check warning on line 6 in simulatingrisk/app.py

View check run for this annotation

Codecov / codecov/patch

simulatingrisk/app.py#L6

Added line #L6 was not covered by tests
from simulatingrisk.risky_bet.app import page as riskybet_page
from simulatingrisk.risky_food.app import page as riskyfood_page


@solara.component
def Home():
with open("simulatingrisk/about_app.md") as readmefile:
# load about markdown file in the same directory
with open(os.path.join(os.path.dirname(__file__), "about_app.md")) as readmefile:

Check warning on line 14 in simulatingrisk/app.py

View check run for this annotation

Codecov / codecov/patch

simulatingrisk/app.py#L14

Added line #L14 was not covered by tests
return solara.Markdown("\n".join(readmefile.readlines()))


Expand All @@ -17,9 +20,9 @@
return hawkdove_page


@solara.component
def hawkdove_var():
return hawkdove_var_page

Check warning on line 25 in simulatingrisk/app.py

View check run for this annotation

Codecov / codecov/patch

simulatingrisk/app.py#L23-L25

Added lines #L23 - L25 were not covered by tests


@solara.component
Expand Down
2 changes: 1 addition & 1 deletion simulatingrisk/charts/histogram.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,4 @@ def plot_risk_histogram(model):
ax.set_title("risk levels")
# You have to specify the dependencies as follows, so that the figure
# auto-updates when viz.model or viz.df is changed.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment outdated?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooh, yes, thanks for catching

solara.FigureMatplotlib(fig, dependencies=[model])
solara.FigureMatplotlib(fig)
18 changes: 16 additions & 2 deletions simulatingrisk/hawkdove/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ def set_risk_level(self):
raise NotImplementedError

def __repr__(self):
return f"<{self.__class__.__name__} id={self.unique_id} r={self.risk_level}>"
return (
f"<{self.__class__.__name__} id={self.unique_id} "
+ f"r={self.risk_level} points={self.points}>"
)

def initial_choice(self, hawk_odds=None):
# first round : choose what to play randomly or based on initial hawk odds
Expand Down Expand Up @@ -129,7 +132,18 @@ def points_rank(self):


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 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)
"""

#: whether the simulation is running
running = True # required for batch run
Expand Down
22 changes: 22 additions & 0 deletions simulatingrisk/hawkdovevar/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,30 @@
)
from simulatingrisk.hawkdove.app import plot_hawks

# adjust single-risk params for variable risk
jupyterviz_params_var = jupyterviz_params.copy()
# remove parameter for agent risk level;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this removed? not clear from immediate context but perhaps clear elsewhere

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, I should clarify that in the comment at least - it's a parameter that's only relevant for the single-risk level simulation because in the variable simulation risk attitudes are generated randomly; I don't currently have base/common parameters (common params would probably be clearer)

# add parameters for adaptive risk strategies
del jupyterviz_params_var["agent_risk_level"]
jupyterviz_params_var.update(
{
"risk_adjustment": {
"type": "Select",
"value": "adopt",
"values": ["none", "adopt", "average"],
"description": "If and how agents update their risk level",
},
"adjust_every": {
"label": "Adjustment frequency (# rounds)",
"type": "SliderInt",
"min": 1,
"max": 30,
"step": 1,
"value": 10,
"description": "How many rounds between risk adjustment",
},
}
)

page = JupyterViz(
HawkDoveVariableRiskModel,
Expand Down
84 changes: 82 additions & 2 deletions simulatingrisk/hawkdovevar/model.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import statistics

from simulatingrisk.hawkdove.model import HawkDoveModel, HawkDoveAgent


class HawkDoveVariableRiskAgent(HawkDoveAgent):
"""
An agent with random risk attitude playing Hawk or Dove
An agent with random risk attitude playing Hawk or Dove. Optionally
adjusts risks based on most successful neighbor, depending on model
configuration.
"""

def set_risk_level(self):
Expand All @@ -13,18 +17,94 @@ def set_risk_level(self):
# generate a random risk level
self.risk_level = self.random.randint(0, num_neighbors)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.random presumably comes from the base mesa class? Otherwise wondering what it refers to

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, mesa provides access to built-in random methods that works just like the main python library random module

It's in mentioned in their documentation best practices, it would allow using a random seed at model instantiation: https://mesa.readthedocs.io/en/stable/best-practices.html#randomization


def play(self):
super().play()
# when enabled by the model, periodically adjust risk level
if self.model.adjustment_round:
self.adjust_risk()

@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]

def adjust_risk(self):
# look at neighbors
# if anyone has more points
# either adopt their risk attitude or average theirs with yours

best = self.most_successful_neighbor
# if most successful neighbor has more points and a different
# risk attitude, adjust
if best.points > self.points and best.risk_level != self.risk_level:
# adjust risk based on model configuration
if self.model.risk_adjustment == "adopt":
# adopt neighbor's risk level
self.risk_level = best.risk_level
elif self.model.risk_adjustment == "average":
# average theirs with mine, then round to a whole number
# since this model uses discrete risk levels
self.risk_level = round(
statistics.mean([self.risk_level, best.risk_level])
)
Comment on lines +33 to +51

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very, very cool, and slick. I remember talking about this and/or similar strategies of risk adjustment with David and Lara and you.



class HawkDoveVariableRiskModel(HawkDoveModel):
"""
Model for hawk/dove game with variable 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 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)
"""

risk_attitudes = "variable"
agent_class = HawkDoveVariableRiskAgent

supported_risk_adjustments = (None, "adopt", "average")

def __init__(
self,
grid_size,
include_diagonals=True,
hawk_odds=0.5,
risk_adjustment=None,
adjust_every=10,
):
super().__init__(
grid_size, include_diagonals=include_diagonals, hawk_odds=hawk_odds
)
# no custom logic or params yet, but will be adding risk updating logic
# convert string input from solara app parameters to None
if risk_adjustment == "none":
risk_adjustment = None
# make sure risk adjustment is valid
if risk_adjustment not in self.supported_risk_adjustments:
risk_adjust_opts = ", ".join(
[opt or "none" for opt in self.supported_risk_adjustments]
)
raise ValueError(
f"Unsupported risk adjustment '{risk_adjustment}'; "
+ f"must be one of {risk_adjust_opts}"
)

self.risk_adjustment = risk_adjustment
self.adjust_round_n = adjust_every

@property
def adjustment_round(self) -> bool:
"""is the current round an adjustment round?"""
# check if the current step is an adjustment round
# when risk adjustment is enabled, agents should adjust their risk
# strategy every N rounds;
return (
self.risk_adjustment
and self.schedule.steps > 0
and self.schedule.steps % self.adjust_round_n == 0
)
16 changes: 4 additions & 12 deletions tests/test_hawkdove.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
HawkDoveSingleRiskModel,
HawkDoveSingleRiskAgent,
)
from simulatingrisk.hawkdovevar.model import HawkDoveVariableRiskModel


def test_agent_neighbors():
Expand Down Expand Up @@ -67,7 +66,10 @@ def test_agent_repr():
agent_id = 1
risk_level = 3
agent = HawkDoveSingleRiskAgent(agent_id, Mock(agent_risk_level=risk_level))
assert repr(agent) == f"<HawkDoveSingleRiskAgent id={agent_id} r={risk_level}>"
assert (
repr(agent)
== f"<HawkDoveSingleRiskAgent id={agent_id} r={risk_level} points=0>"
)
Comment on lines +69 to +72

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice test



def test_model_single_risk_level():
Expand All @@ -87,16 +89,6 @@ def test_model_single_risk_level():
assert agent.risk_level == risk_level


def test_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


def test_num_dove_neighbors():
# initialize an agent with a mock model
agent = HawkDoveSingleRiskAgent(1, Mock(agent_risk_level=2))
Expand Down
150 changes: 150 additions & 0 deletions tests/test_hawkdovevar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import statistics
from unittest.mock import patch, Mock

import pytest

from simulatingrisk.hawkdovevar.model import (
HawkDoveVariableRiskModel,
HawkDoveVariableRiskAgent,
)


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")
Comment on lines +30 to +41

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These easy access object property-methods really make using and testing quite easy and legible.



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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nice, so it runs the test 4 times according to the number of tuples in the adjustment_testdata list?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, isn't that cool? very useful sometimes

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_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):
Comment on lines +120 to +121

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very elegant patching

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])
)
Loading