Quick Start Guide
This guide will get you up and running with AMBER in just a few minutes.
The vectorized mindset
AMBER stores the entire population as a Polars DataFrame under the hood, so the fast path is to update many agents in one expression instead of looping over individuals. The view API is built around three moves:
self.agents— a view of the whole populationself.agents.where(predicate)— a view of agents matching a conditionself.agents.at[ids]— a view of specific agents by id
On any view, view.col returns a Polars Series sourced from
self.agents_df, and view.col = value queues a columnar update that
lands in the DataFrame on the next flush.
Your first model
A wealth transfer model where agents randomly exchange money, written the vectorized way:
import ambr as am
class WealthModel(am.Model):
def setup(self):
# Bulk-create 100 agents with random initial wealth — no loop.
self.add_agents(
100,
wealth=self.nprandom.integers(1, 10, size=100),
)
def step(self):
# Every agent with wealth > 0 gives $1 to a random other agent.
donors = self.agents.where(self.agents.wealth > 0)
donors.wealth -= 1
# Pick a random recipient for each donor (with replacement),
# then scatter the $1 credits — duplicate recipients correctly
# receive multiple dollars via scatter_add.
ids = self.agents.ids.to_numpy()
recipients = self.nprandom.choice(ids, size=len(donors))
self.agents.at[recipients].scatter_add(wealth=1)
# Track aggregate state at the model level.
self.record('total_wealth', int(self.agents.wealth.sum()))
# Run the model
model = WealthModel({'steps': 100, 'seed': 42, 'show_progress': False})
results = model.run()
# Inspect the results
print("Final wealth distribution (first 10 agents):")
print(results['agents'].select(['id', 'wealth']).head(10))
That’s the whole idiom. No per-agent loops, no update_agent_data calls,
and no .item() ceremonies. The step() body is four Polars calls
regardless of whether you have 100 agents or 100 000.
Understanding the results
The model returns a dictionary with three keys:
agents— a Polars DataFrame of agent state at the end of the runmodel— a Polars DataFrame of the model-level metrics yourecord-edinfo— a small dict withstepsandrun_time
print("Agent data shape:", results['agents'].shape)
print("Agent columns:", results['agents'].columns)
print("Simulation info:", results['info'])
Filtering and conditional updates
where accepts either an attribute-predicate (self.agents.wealth > 0)
or a raw Polars expression (pl.col('wealth') > 0). Both lower to the
same filter:
import polars as pl
# Attribute-predicate form — the most common
wealthy = self.agents.where(self.agents.wealth > 100)
# Polars expression form — useful when chaining multiple conditions
rich_adults = self.agents.where((pl.col('wealth') > 100) & (pl.col('age') >= 18))
# Mark the wealthy with a tag column — created on first assignment
wealthy.tag = 'rich'
Adding spatial structure
Let’s enhance the model with a 20×20 grid:
class SpatialWealthModel(am.Model):
def setup(self):
self.grid = am.GridEnvironment(self, size=(20, 20))
n = 200
# Place agents randomly on the grid (sampled with replacement)
xs = self.nprandom.integers(0, 20, size=n)
ys = self.nprandom.integers(0, 20, size=n)
self.add_agents(
n,
wealth=self.nprandom.integers(1, 10, size=n),
x=xs,
y=ys,
)
def step(self):
# Same "donor gives $1" idiom as before.
donors = self.agents.where(self.agents.wealth > 0)
donors.wealth -= 1
ids = self.agents.ids.to_numpy()
recipients = self.nprandom.choice(ids, size=len(donors))
self.agents.at[recipients].scatter_add(wealth=1)
spatial_model = SpatialWealthModel({'steps': 50, 'seed': 42, 'show_progress': False})
spatial_results = spatial_model.run()
Model-level analytics
Aggregate metrics go through self.record, which takes any scalar you
can compute from the current DataFrame:
import numpy as np
class AnalyticalWealthModel(am.Model):
def setup(self):
self.add_agents(100, wealth=self.nprandom.integers(1, 10, size=100))
def step(self):
donors = self.agents.where(self.agents.wealth > 0)
donors.wealth -= 1
ids = self.agents.ids.to_numpy()
recipients = self.nprandom.choice(ids, size=len(donors))
self.agents.at[recipients].scatter_add(wealth=1)
# Polars Series expose the usual aggregate methods.
wealth = self.agents.wealth
self.record('mean_wealth', float(wealth.mean()))
self.record('wealth_std', float(wealth.std() or 0.0))
self.record('gini', self._gini(wealth.to_numpy()))
@staticmethod
def _gini(values):
if values.size == 0 or values.sum() == 0:
return 0.0
sorted_vals = np.sort(values)
n = len(sorted_vals)
cum = np.cumsum(sorted_vals)
return (n + 1 - 2 * cum.sum() / cum[-1]) / n
When per-agent loops are OK
The view API isn’t mandatory — you can still write OOP-style agents for
behaviours that genuinely don’t vectorize (graph traversal, bespoke
scheduling). Agent.record() and Agent.update_data() queue writes
through the same batched flush path, so you won’t pay a per-call
DataFrame clone:
class Walker(am.Agent):
def step(self):
# Per-agent behaviour — appropriate when the logic depends on
# this specific agent's neighbourhood in a way that can't be
# expressed as a single Polars expression.
neighbours = self.get_neighbors()
self.record('neighbour_count', neighbours.height)
In general: reach for self.agents.where(...).col = ... first. Fall
back to per-agent style only when the logic is inherently sequential or
needs side effects on external state.
Next Steps
Key concepts
Views are always DataFrame-backed.
self.agents.wealthis a Polars Series read fromself.agents_df; it stays in sync with every other update automatically.Bulk create with
add_agents. AvoidAgent(self, i); add_agent(agent)loops unless you actually need a Python class per agent.Use
scatter_addfor resource flow.view.col = ...handles deterministic updates; usescatter_addwhen ids may repeat and you want the deltas to sum.Reproducibility comes from
self.nprandomandself.random, both seeded fromparameters['seed'].