Skip to content

Commit

Permalink
Merge pull request #4 from Princeton-CDH/risky-food
Browse files Browse the repository at this point in the history
Risky food simulation
  • Loading branch information
rlskoeser authored Jun 14, 2023
2 parents 90ee4d8 + c4bf01b commit a23f747
Show file tree
Hide file tree
Showing 11 changed files with 341 additions and 15 deletions.
64 changes: 64 additions & 0 deletions .github/workflows/unit_tests.yml
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
11 changes: 6 additions & 5 deletions .pre-commit-config.yaml
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"]
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ Initial setup and installation:
python3 -m venv simrisk
source simrisk/bin/activate
```
- Install python dependencies::
- Install the package, dependencies, and development dependencies:
```sh
pip install -r requirements/dev.txt
pip install -e .
pip install -e ".[dev]"
```

### Install pre-commit hooks

Install pre-commit hooks (currently [black](https://github.com/psf/black) and [isort](https://pycqa.github.io/isort/)):
Install pre-commit hooks (currently [black](https://github.com/psf/black) and [ruff](https://beta.ruff.rs/docs/)):

```sh
pre-commit install
Expand Down
26 changes: 26 additions & 0 deletions pyproject.toml
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 = ''
7 changes: 0 additions & 7 deletions requirements/dev.txt

This file was deleted.

Empty file added simulatingrisk/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions simulatingrisk/risky_food/README.md
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.
141 changes: 141 additions & 0 deletions simulatingrisk/risky_food/model.py
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
32 changes: 32 additions & 0 deletions simulatingrisk/risky_food/run.py
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()
36 changes: 36 additions & 0 deletions tests/test_risky_food.py
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
)

0 comments on commit a23f747

Please sign in to comment.