-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4 from Princeton-CDH/risky-food
Risky food simulation
- Loading branch information
Showing
11 changed files
with
341 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
name: unit tests | ||
|
||
on: | ||
push: # run on every push or PR to any branch | ||
pull_request: | ||
|
||
jobs: | ||
python-unit: | ||
name: Python unit tests | ||
runs-on: ubuntu-latest | ||
|
||
steps: | ||
- name: Checkout repository | ||
uses: actions/checkout@v3 | ||
|
||
- name: Setup Python | ||
uses: actions/setup-python@v4 | ||
with: | ||
python-version: 3.9 | ||
|
||
# base the python cache on the hash of all pyproject.toml, | ||
# which includes python requirements. | ||
# if any change, the cache is invalidated. | ||
- name: Cache pip | ||
uses: actions/cache@v3 | ||
with: | ||
path: ~/.cache/pip | ||
key: pip-${{ hashFiles('pyproject.toml') }} | ||
restore-keys: | | ||
pip-${{ hashFiles('pyproject.toml') }} | ||
pip- | ||
- name: Install dependencies | ||
run: pip install ".[dev]" | ||
|
||
- name: Run pytest | ||
run: pytest --cov=./ --cov-report=xml | ||
|
||
- name: Upload test coverage to Codecov | ||
uses: codecov/codecov-action@v3 | ||
|
||
# Set the color of the slack message used in the next step based on the | ||
# status of the build: "danger" for failure, "good" for success, | ||
# "warning" for error | ||
- name: Set Slack message color based on build status | ||
if: ${{ always() }} | ||
env: | ||
JOB_STATUS: ${{ job.status }} | ||
run: echo "SLACK_COLOR=$(if [ "$JOB_STATUS" == "success" ]; then echo "good"; elif [ "$JOB_STATUS" == "failure" ]; then echo "danger"; else echo "warning"; fi)" >> $GITHUB_ENV | ||
|
||
# Send a message to slack to report the build status. The webhook is stored | ||
# at the organization level and available to all repositories. Only run on | ||
# scheduled builds & pushes, since PRs automatically report to Slack. | ||
- name: Report status to Slack | ||
uses: rtCamp/action-slack-notify@master | ||
if: ${{ always() && (github.event_name == 'schedule' || github.event_name == 'push') }} | ||
continue-on-error: true | ||
env: | ||
SLACK_COLOR: ${{ env.SLACK_COLOR }} | ||
SLACK_WEBHOOK: ${{ secrets.ACTIONS_SLACK_WEBHOOK }} | ||
SLACK_TITLE: "Workflow `${{ github.workflow }}`: ${{ job.status }}" | ||
SLACK_MESSAGE: "Run <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|#${{ github.run_number }}> on <https://github.com/${{ github.repository }}/|${{ github.repository }}@${{ github.ref }}>" | ||
SLACK_FOOTER: "<https://github.com/${{ github.repository }}/commit/${{ github.sha }}|View commit>" | ||
MSG_MINIMAL: true # use compact slack message format |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,13 @@ | ||
repos: | ||
- repo: https://github.com/astral-sh/ruff-pre-commit | ||
# Ruff version. | ||
rev: v0.0.270 | ||
hooks: | ||
- id: ruff | ||
args: [ --fix, --exit-non-zero-on-fix ] | ||
- repo: https://github.com/psf/black | ||
rev: 23.3.0 # Replace by any tag/version: https://github.com/psf/black/tags | ||
hooks: | ||
- id: black | ||
# Assumes that your shell's `python` command is linked to python3.6+ | ||
language_version: python | ||
- repo: https://github.com/pycqa/isort | ||
rev: 5.12.0 | ||
hooks: | ||
- id: isort | ||
args: ["--profile", "black", "--filter-files"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
[build-system] | ||
requires = ["setuptools", "setuptools-scm"] | ||
build-backend = "setuptools.build_meta" | ||
|
||
[project] | ||
name = "simulating_risk" | ||
description = "Agent-based modeling for simulations related to risk and rationality" | ||
readme = "README.md" | ||
requires-python = ">=3.7" | ||
license = {text = "Apache-2"} | ||
classifiers = [ | ||
"Programming Language :: Python :: 3", | ||
] | ||
dependencies = [ | ||
"mesa", | ||
] | ||
dynamic = ["version"] | ||
|
||
[project.optional-dependencies] | ||
dev = ["pre-commit", "pytest", "pytest-cov"] | ||
|
||
[tool.black] | ||
line-length = 88 | ||
target-version = ['py38'] | ||
# include = '' | ||
# extend-exclude = '' |
This file was deleted.
Oops, something went wrong.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
# Risky Food Simulation | ||
|
||
## Summary | ||
|
||
Game: risky food source is 3 if **N**, 1 if **C**; safe source is 2 | ||
|
||
- **N**: non-contaminated | ||
- **C**: contaminated | ||
|
||
Every agent gets a parameter `r` between 0 and 1. [or DISCRETE: 8 buckets etc.] | ||
|
||
EACH ROUND: | ||
- Nature selects a probability `p` for **N** | ||
- For each agent: if `r` > `p`, then they choose RISKY; else SAFE | ||
- Nature flips a coin with bias `p` for **N**, and announces **N** or **C** | ||
- If **N**: everyone who chose RISKY gets 3, everyone who chose SAFE gets 2 | ||
- If **C**: everyone who chose RISKY gets 1, everyone SAFE 2 | ||
- Reproduce in proportion to payoff | ||
- Either agent gets # of offspring = payoff [they replace–original “dies off”] | ||
- OR: take the total payoff for RISKYs over total for everyone, there are that proportion of RISKYs in the new population | ||
|
||
END ROUND | ||
|
||
SEE: We’ll see what are the risk attitudes that are replicated more and less over time | ||
|
||
## Running the simulation | ||
|
||
- Install python dependencies as described in the main project readme (requires mesa) | ||
- To run from the main `simulating-risk` project directory: | ||
- Configure python to include the current directory in import path; | ||
for C-based shells, run `setenv PYTHONPATH .` ; for bash, run `export $PYTHONPATH=.` | ||
- To run interactively with mesa runserver: `mesa runserver simulatingrisk/risky_food/` |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
from enum import Enum | ||
from statistics import mean | ||
|
||
import mesa | ||
|
||
|
||
class FoodChoice(Enum): | ||
RISKY = "R" | ||
SAFE = "S" | ||
|
||
|
||
class FoodStatus(Enum): | ||
CONTAMINATED = "C" | ||
NOTCONTAMINATED = "N" | ||
|
||
|
||
class Agent(mesa.Agent): | ||
def __init__(self, unique_id, model, risk_level=None): | ||
super().__init__(unique_id, model) | ||
# get a random risk tolerance; returns a value between 0.0 and 1.0 | ||
self.risk_level = risk_level or self.random.random() | ||
|
||
def step(self): | ||
# choose food based on the probability not contaminated and risk tolerance | ||
if self.risk_level > self.model.prob_notcontaminated: | ||
choice = FoodChoice.RISKY | ||
else: | ||
choice = FoodChoice.SAFE | ||
self.payoff = self.model.payoff(choice) | ||
|
||
|
||
class RiskyFoodModel(mesa.Model): | ||
prob_notcontaminated = None | ||
|
||
def __init__(self, n): | ||
self.num_agents = n | ||
self.schedule = mesa.time.SimultaneousActivation(self) | ||
# initialize agents for the first round | ||
for i in range(self.num_agents): | ||
a = Agent(i, self) | ||
self.schedule.add(a) | ||
|
||
self.nextid = i + 1 | ||
|
||
self.datacollector = mesa.DataCollector( | ||
model_reporters={ | ||
"prob_notcontaminated": "prob_notcontaminated", | ||
"contaminated": "contaminated", | ||
"average_risk_level": "avg_risk_level", | ||
"min_risk_level": "min_risk_level", | ||
"max_risk_level": "max_risk_level", | ||
"num_agents": "total_agents", | ||
}, | ||
agent_reporters={"risk_level": "risk_level", "payoff": "payoff"}, | ||
) | ||
|
||
def step(self): | ||
"""Advance the model by one step.""" | ||
# pick a probability for risky food being not contaminated this round | ||
self.prob_notcontaminated = self.random.random() | ||
|
||
self.prob_notcontaminated * 100 | ||
|
||
self.risky_food_status = self.get_risky_food_status() | ||
|
||
self.schedule.step() | ||
self.datacollector.collect(self) | ||
|
||
# setup agents for the next round | ||
self.propagate() | ||
|
||
def get_risky_food_status(self): | ||
# determine actual food status for this round, | ||
# weighted by probability of non-contamination | ||
|
||
# randomly choose, with choice weighted by | ||
# current probability not contaminated | ||
return self.random.choices( | ||
[FoodStatus.NOTCONTAMINATED, FoodStatus.CONTAMINATED], | ||
weights=[self.prob_notcontaminated, 1 - self.prob_notcontaminated], | ||
)[0] | ||
|
||
def propagate(self): | ||
# update agents based on payoff from the completed round | ||
|
||
# get a generator of agents from the scheduler that | ||
# will allow us to add and remove | ||
for agent in self.schedule.agent_buffer(): | ||
# add offspring based on payoff; keep risk level | ||
# logic is offspring = to payoff, original dies off, | ||
# but for efficiency just add payoff - 1 and keep the original | ||
for i in range(agent.payoff - 1): | ||
a = Agent(i + self.nextid, self, agent.risk_level) | ||
self.schedule.add(a) | ||
|
||
self.nextid += agent.payoff | ||
|
||
@property | ||
def contaminated(self): | ||
# return a value for food status this round, for data collection | ||
if self.risky_food_status == FoodStatus.CONTAMINATED: | ||
return 1 | ||
return 0 | ||
|
||
@property | ||
def agents(self): | ||
# custom property to make it easy to access all current agents | ||
|
||
# uses a generator of agents from the scheduler that | ||
# will allow adding and removing agents from the scheduler | ||
return self.schedule.agent_buffer() | ||
|
||
@property | ||
def total_agents(self): | ||
return len(list(self.agents)) | ||
|
||
@property | ||
def avg_risk_level(self): | ||
return mean([agent.risk_level for agent in self.agents]) | ||
|
||
@property | ||
def min_risk_level(self): | ||
return min([agent.risk_level for agent in self.agents]) | ||
|
||
@property | ||
def max_risk_level(self): | ||
return max([agent.risk_level for agent in self.agents]) | ||
|
||
def payoff(self, choice): | ||
"Calculate the payoff for a given choice, based on current food status" | ||
|
||
# safe food choice always has a payoff of 2 | ||
if choice == FoodChoice.SAFE: | ||
return 2 | ||
# payoff for risky food choice depends on contamination | ||
# - if not contaminated, payoff of 3 | ||
if self.risky_food_status == FoodStatus.NOTCONTAMINATED: | ||
return 3 | ||
|
||
# otherwise only payoff of 1 | ||
return 1 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import mesa | ||
|
||
from simulatingrisk.risky_food.model import RiskyFoodModel | ||
|
||
chart = mesa.visualization.ChartModule( | ||
[ | ||
{"Label": "prob_notcontaminated", "Color": "blue"}, | ||
{"Label": "contaminated", "Color": "red"}, | ||
], | ||
data_collector_name="datacollector", | ||
) | ||
risk_chart = mesa.visualization.ChartModule( | ||
[ | ||
{"Label": "average_risk_level", "Color": "blue"}, | ||
{"Label": "min_risk_level", "Color": "green"}, | ||
{"Label": "max_risk_level", "Color": "orange"}, | ||
], | ||
data_collector_name="datacollector", | ||
) | ||
|
||
agent_chart = mesa.visualization.ChartModule( | ||
[ | ||
{"Label": "num_agents", "Color": "gray"}, | ||
], | ||
data_collector_name="datacollector", | ||
) | ||
|
||
server = mesa.visualization.ModularServer( | ||
RiskyFoodModel, [chart, risk_chart, agent_chart], "Risky Food", {"n": 20} | ||
) | ||
server.port = 8521 # The default | ||
server.launch() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
from collections import Counter | ||
import math | ||
import pytest | ||
|
||
from simulatingrisk.risky_food.model import RiskyFoodModel, FoodStatus | ||
|
||
|
||
test_probabilities = [ | ||
(0.5), | ||
(0.2), | ||
(0.8), | ||
] | ||
|
||
|
||
@pytest.mark.parametrize("prob_notcontaminated", test_probabilities) | ||
def test_risky_food_status(prob_notcontaminated): | ||
# test that food status choice is weighted properly | ||
# by probability of not being contaminated | ||
|
||
# initialize model with one agent | ||
model = RiskyFoodModel(1) | ||
model.prob_notcontaminated = prob_notcontaminated | ||
|
||
results = [] | ||
total_runs = 100 | ||
for i in range(total_runs): | ||
results.append(model.get_risky_food_status()) | ||
|
||
# use counter to tally the results | ||
result_count = Counter(results) | ||
|
||
# the expected value is the probability times number of times we ran it | ||
expected = total_runs * model.prob_notcontaminated | ||
assert math.isclose( | ||
result_count[FoodStatus.NOTCONTAMINATED], expected, abs_tol=total_runs * 0.1 | ||
) |