Sugarscape Constant Growback Model with Traders#
Summary#
This is Epstein & Axtell’s Sugarscape model with Traders, a detailed description is in Chapter four of Growing Artificial Societies: Social Science from the Bottom Up (1996). The model shows an emergent price equilibrium can happen via a decentralized dynamics.
This code generally matches the code in the Complexity Explorer Tutorial, but in .py
instead of .ipynb
format.
Agents:#
Resource: Resource agents grow back at one unit of sugar and spice per time step up to a specified max amount and can be harvested and traded by the trader agents. (if you do the interactive run, the color will be green if the resource agent has a bigger amount of sugar, or yellow if it has a bigger amount of spice)
Traders: Trader agents have the following attributes: (1) metabolism for sugar, (2) metabolism for spice, (3) vision, (4) initial sugar endowment and (5) initial spice endowment. The traverse the landscape harvesting sugar and spice and trading with other agents. If they run out of sugar or spice then they are removed from the model. (red circle if you do the interactive run)
The trader agents traverse the landscape according to rule M:
Look out as far as vision permits in the four principal lattice directions and identify the unoccupied site(s).
Considering only unoccupied sites find the nearest position that produces the most welfare using the Cobb-Douglas function.
Move to the new position
Collect all the resources (sugar and spice) at that location (Epstein and Axtell, 1996, p. 99)
The traders trade according to rule T:
Agents and potential trade partner compute their marginal rates of substitution (MRS), if they are equal end.
Exchange resources, with spice flowing from the agent with the higher MRS to the agent with the lower MRS and sugar flowing the opposite direction.
The price (p) is calculated by taking the geometric mean of the agents’ MRS.
If p > 1 then p units of spice are traded for 1 unit of sugar; if p < 1 then 1/p units of sugar for 1 unit of spice
The trade occurs if it will (a) make both agent better off (increases MRS) and (b) does not cause the agents’ MRS to cross over one another otherwise end.
This process then repeats until an end condition is met. (Epstein and Axtell, 1996, p. 105)
The model demonstrates several Mesa concepts and features:
OrthogonalMooreGrid
Multiple agent types (traders, sugar, spice)
Dynamically removing agents from the grid and schedule when they die
Data Collection at the model and agent level
custom solara matplotlib space visualization
How to Run#
To run the model interactively:
$ solara run app.py
Then open your browser to http://127.0.0.1:8521/ and press Reset, then Run.
Files#
model.py
: The Sugarscape Constant Growback with Traders model.agents.py
: Defines the Trader agent class and the Resource agent class which contains an amount of sugar and spice.app.py
: Runs a visualization server via Solara (solara run app.py
).sugar_map.txt
: Provides sugar and spice landscape in raster type format.tests.py
: Has tests to ensure that the model reproduces the results in shown in Growing Artificial Societies.
Additional Resources#
Agents#
import math
from mesa.experimental.cell_space import CellAgent
# Helper function
def get_distance(cell_1, cell_2):
"""
Calculate the Euclidean distance between two positions
used in trade.move()
"""
x1, y1 = cell_1.coordinate
x2, y2 = cell_2.coordinate
dx = x1 - x2
dy = y1 - y2
return math.sqrt(dx**2 + dy**2)
class Trader(CellAgent):
"""
Trader:
- has a metabolism of sugar and spice
- harvest and trade sugar and spice to survive
"""
def __init__(
self,
model,
cell,
sugar=0,
spice=0,
metabolism_sugar=0,
metabolism_spice=0,
vision=0,
):
super().__init__(model)
self.cell = cell
self.sugar = sugar
self.spice = spice
self.metabolism_sugar = metabolism_sugar
self.metabolism_spice = metabolism_spice
self.vision = vision
self.prices = []
self.trade_partners = []
def get_trader(self, cell):
"""
helper function used in self.trade_with_neighbors()
"""
for agent in cell.agents:
if isinstance(agent, Trader):
return agent
def calculate_welfare(self, sugar, spice):
"""
helper function
part 2 self.move()
self.trade()
"""
# calculate total resources
m_total = self.metabolism_sugar + self.metabolism_spice
# Cobb-Douglas functional form; starting on p. 97
# on Growing Artificial Societies
return sugar ** (self.metabolism_sugar / m_total) * spice ** (
self.metabolism_spice / m_total
)
def is_starved(self):
"""
Helper function for self.maybe_die()
"""
return (self.sugar <= 0) or (self.spice <= 0)
def calculate_MRS(self, sugar, spice):
"""
Helper function for
- self.trade()
- self.maybe_self_spice()
Determines what trader agent needs and can give up
"""
return (spice / self.metabolism_spice) / (sugar / self.metabolism_sugar)
def calculate_sell_spice_amount(self, price):
"""
helper function for self.maybe_sell_spice() which is called from
self.trade()
"""
if price >= 1:
sugar = 1
spice = int(price)
else:
sugar = int(1 / price)
spice = 1
return sugar, spice
def sell_spice(self, other, sugar, spice):
"""
used in self.maybe_sell_spice()
exchanges sugar and spice between traders
"""
self.sugar += sugar
other.sugar -= sugar
self.spice -= spice
other.spice += spice
def maybe_sell_spice(self, other, price, welfare_self, welfare_other):
"""
helper function for self.trade()
"""
sugar_exchanged, spice_exchanged = self.calculate_sell_spice_amount(price)
# Assess new sugar and spice amount - what if change did occur
self_sugar = self.sugar + sugar_exchanged
other_sugar = other.sugar - sugar_exchanged
self_spice = self.spice - spice_exchanged
other_spice = other.spice + spice_exchanged
# double check to ensure agents have resources
if (
(self_sugar <= 0)
or (other_sugar <= 0)
or (self_spice <= 0)
or (other_spice <= 0)
):
return False
# trade criteria #1 - are both agents better off?
both_agents_better_off = (
welfare_self < self.calculate_welfare(self_sugar, self_spice)
) and (welfare_other < other.calculate_welfare(other_sugar, other_spice))
# trade criteria #2 is their mrs crossing with potential trade
mrs_not_crossing = self.calculate_MRS(
self_sugar, self_spice
) > other.calculate_MRS(other_sugar, other_spice)
if not (both_agents_better_off and mrs_not_crossing):
return False
# criteria met, execute trade
self.sell_spice(other, sugar_exchanged, spice_exchanged)
return True
def trade(self, other):
"""
helper function used in trade_with_neighbors()
other is a trader agent object
"""
# sanity check to verify code is working as expected
assert self.sugar > 0
assert self.spice > 0
assert other.sugar > 0
assert other.spice > 0
# calculate marginal rate of substitution in Growing Artificial Societies p. 101
mrs_self = self.calculate_MRS(self.sugar, self.spice)
mrs_other = other.calculate_MRS(other.sugar, other.spice)
# calculate each agents welfare
welfare_self = self.calculate_welfare(self.sugar, self.spice)
welfare_other = other.calculate_welfare(other.sugar, other.spice)
if math.isclose(mrs_self, mrs_other):
return
# calculate price
price = math.sqrt(mrs_self * mrs_other)
if mrs_self > mrs_other:
# self is a sugar buyer, spice seller
sold = self.maybe_sell_spice(other, price, welfare_self, welfare_other)
# no trade - criteria not met
if not sold:
return
else:
# self is a spice buyer, sugar seller
sold = other.maybe_sell_spice(self, price, welfare_other, welfare_self)
# no trade - criteria not met
if not sold:
return
# Capture data
self.prices.append(price)
self.trade_partners.append(other.unique_id)
# continue trading
self.trade(other)
######################################################################
# #
# MAIN TRADE FUNCTIONS #
# #
######################################################################
def move(self):
"""
Function for trader agent to identify optimal move for each step in 4 parts
1 - identify all possible moves
2 - determine which move maximizes welfare
3 - find closest best option
4 - move
"""
# 1. identify all possible moves
neighboring_cells = [
cell
for cell in self.cell.get_neighborhood(self.vision, include_center=True)
if cell.is_empty
]
# 2. determine which move maximizes welfare
welfares = [
self.calculate_welfare(
self.sugar + cell.sugar,
self.spice + cell.spice,
)
for cell in neighboring_cells
]
# 3. Find closest best option
# find the highest welfare in welfares
max_welfare = max(welfares)
# get the index of max welfare cells
# fixme: rewrite using enumerate and single loop
candidate_indices = [
i for i in range(len(welfares)) if math.isclose(welfares[i], max_welfare)
]
# convert index to positions of those cells
candidates = [neighboring_cells[i] for i in candidate_indices]
min_dist = min(get_distance(self.cell, cell) for cell in candidates)
final_candidates = [
cell
for cell in candidates
if math.isclose(get_distance(self.cell, cell), min_dist, rel_tol=1e-02)
]
# 4. Move Agent
self.cell = self.random.choice(final_candidates)
def eat(self):
self.sugar += self.cell.sugar
self.cell.sugar = 0
self.sugar -= self.metabolism_sugar
self.spice += self.cell.spice
self.cell.spice = 0
self.spice -= self.metabolism_spice
def maybe_die(self):
"""
Function to remove Traders who have consumed all their sugar or spice
"""
if self.is_starved():
self.remove()
def trade_with_neighbors(self):
"""
Function for trader agents to decide who to trade with in three parts
1- identify neighbors who can trade
2- trade (2 sessions)
3- collect data
"""
# iterate through traders in neighboring cells and trade
for a in self.cell.get_neighborhood(radius=self.vision).agents:
self.trade(a)
return
Model#
from pathlib import Path
import numpy as np
import mesa
from mesa.examples.advanced.sugarscape_g1mt.agents import Trader
from mesa.experimental.cell_space import OrthogonalVonNeumannGrid
from mesa.experimental.cell_space.property_layer import PropertyLayer
# Helper Functions
def flatten(list_of_lists):
"""
helper function for model datacollector for trade price
collapses agent price list into one list
"""
return [item for sublist in list_of_lists for item in sublist]
def geometric_mean(list_of_prices):
"""
find the geometric mean of a list of prices
"""
return np.exp(np.log(list_of_prices).mean())
def get_trade(agent):
"""
For agent reporters in data collector
return list of trade partners and None for other agents
"""
if isinstance(agent, Trader):
return agent.trade_partners
else:
return None
class SugarscapeG1mt(mesa.Model):
"""
Manager class to run Sugarscape with Traders
"""
def __init__(
self,
width=50,
height=50,
initial_population=200,
endowment_min=25,
endowment_max=50,
metabolism_min=1,
metabolism_max=5,
vision_min=1,
vision_max=5,
enable_trade=True,
seed=None,
):
super().__init__(seed=seed)
# Initiate width and height of sugarscape
self.width = width
self.height = height
# Initiate population attributes
self.enable_trade = enable_trade
self.running = True
# initiate mesa grid class
self.grid = OrthogonalVonNeumannGrid(
(self.width, self.height), torus=False, random=self.random
)
# initiate datacollector
self.datacollector = mesa.DataCollector(
model_reporters={
"#Traders": lambda m: len(m.agents),
"Trade Volume": lambda m: sum(len(a.trade_partners) for a in m.agents),
"Price": lambda m: geometric_mean(
flatten([a.prices for a in m.agents])
),
},
agent_reporters={"Trade Network": lambda a: get_trade(a)},
)
# read in landscape file from supplementary material
self.sugar_distribution = np.genfromtxt(Path(__file__).parent / "sugar-map.txt")
self.spice_distribution = np.flip(self.sugar_distribution, 1)
self.grid.add_property_layer(
PropertyLayer.from_data("sugar", self.sugar_distribution)
)
self.grid.add_property_layer(
PropertyLayer.from_data("spice", self.spice_distribution)
)
Trader.create_agents(
self,
initial_population,
self.random.choices(self.grid.all_cells.cells, k=initial_population),
sugar=self.rng.integers(
endowment_min, endowment_max, (initial_population,), endpoint=True
),
spice=self.rng.integers(
endowment_min, endowment_max, (initial_population,), endpoint=True
),
metabolism_sugar=self.rng.integers(
metabolism_min, metabolism_max, (initial_population,), endpoint=True
),
metabolism_spice=self.rng.integers(
metabolism_min, metabolism_max, (initial_population,), endpoint=True
),
vision=self.rng.integers(
vision_min, vision_max, (initial_population,), endpoint=True
),
)
def step(self):
"""
Unique step function that does staged activation of sugar and spice
and then randomly activates traders
"""
# step Resource agents
self.grid.sugar.data = np.minimum(
self.grid.sugar.data + 1, self.sugar_distribution
)
self.grid.spice.data = np.minimum(
self.grid.spice.data + 1, self.spice_distribution
)
# step trader agents
# to account for agent death and removal we need a separate data structure to
# iterate
trader_shuffle = self.agents_by_type[Trader].shuffle()
for agent in trader_shuffle:
agent.prices = []
agent.trade_partners = []
agent.move()
agent.eat()
agent.maybe_die()
if not self.enable_trade:
# If trade is not enabled, return early
self.datacollector.collect(self)
return
trader_shuffle = self.agents_by_type[Trader].shuffle()
for agent in trader_shuffle:
agent.trade_with_neighbors()
# collect model level data
# fixme we can already collect agent class data
# fixme, we don't have resource agents anymore so this can be done simpler
self.datacollector.collect(self)
"""
Mesa is working on updating datacollector agent reporter
so it can collect information on specific agents from
mesa.time.RandomActivationByType.
Please see issue #1419 at
https://github.com/projectmesa/mesa/issues/1419
(contributions welcome)
Below is one way to update agent_records to get specific Trader agent data
"""
# Need to remove excess data
# Create local variable to store trade data
agent_trades = self.datacollector._agent_records[self.steps]
# Get rid of all None to reduce data storage needs
agent_trades = [agent for agent in agent_trades if agent[2] is not None]
# Reassign the dictionary value with lean trade data
self.datacollector._agent_records[self.steps] = agent_trades
def run_model(self, step_count=1000):
for _ in range(step_count):
self.step()
App#
import numpy as np
import solara
from matplotlib.figure import Figure
from mesa.examples.advanced.sugarscape_g1mt.model import SugarscapeG1mt
from mesa.visualization import Slider, SolaraViz, make_plot_component
def SpaceDrawer(model):
def portray(g):
layers = {
"trader": {"x": [], "y": [], "c": "tab:red", "marker": "o", "s": 10},
}
for agent in g.all_cells.agents:
i, j = agent.cell.coordinate
layers["trader"]["x"].append(i)
layers["trader"]["y"].append(j)
return layers
fig = Figure()
ax = fig.subplots()
out = portray(model.grid)
# Sugar
# Important note: imshow by default draws from upper left. You have to
# always explicitly specify origin="lower".
im = ax.imshow(
np.ma.masked_where(model.grid.sugar.data <= 1, model.grid.sugar.data),
cmap="spring",
origin="lower",
)
fig.colorbar(im, orientation="vertical")
# Spice
ax.imshow(
np.ma.masked_where(model.grid.spice.data <= 1, model.grid.spice.data),
cmap="winter",
origin="lower",
)
# Trader
ax.scatter(**out["trader"])
ax.set_axis_off()
return solara.FigureMatplotlib(fig)
model_params = {
"seed": {
"type": "InputText",
"value": 42,
"label": "Random Seed",
},
"width": 50,
"height": 50,
# Population parameters
"initial_population": Slider(
"Initial Population", value=200, min=50, max=500, step=10
),
# Agent endowment parameters
"endowment_min": Slider("Min Initial Endowment", value=25, min=5, max=30, step=1),
"endowment_max": Slider("Max Initial Endowment", value=50, min=30, max=100, step=1),
# Metabolism parameters
"metabolism_min": Slider("Min Metabolism", value=1, min=1, max=3, step=1),
"metabolism_max": Slider("Max Metabolism", value=5, min=3, max=8, step=1),
# Vision parameters
"vision_min": Slider("Min Vision", value=1, min=1, max=3, step=1),
"vision_max": Slider("Max Vision", value=5, min=3, max=8, step=1),
# Trade parameter
"enable_trade": {"type": "Checkbox", "value": True, "label": "Enable Trading"},
}
model = SugarscapeG1mt()
page = SolaraViz(
model,
components=[
SpaceDrawer,
make_plot_component("#Traders"),
make_plot_component("Price"),
],
model_params=model_params,
name="Sugarscape {G1, M, T}",
play_interval=150,
)
page # noqa