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 population

  • self.agents.where(predicate) — a view of agents matching a condition

  • self.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 run

  • model — a Polars DataFrame of the model-level metrics you record-ed

  • info — a small dict with steps and run_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

  • Tutorial — the longer-form walkthrough

  • Sequences — the full view API reference

  • examples/ — worked models you can copy from

Key concepts

  • Views are always DataFrame-backed. self.agents.wealth is a Polars Series read from self.agents_df; it stays in sync with every other update automatically.

  • Bulk create with add_agents. Avoid Agent(self, i); add_agent(agent) loops unless you actually need a Python class per agent.

  • Use scatter_add for resource flow. view.col = ... handles deterministic updates; use scatter_add when ids may repeat and you want the deltas to sum.

  • Reproducibility comes from self.nprandom and self.random, both seeded from parameters['seed'].