Tutorial

This tutorial will guide you through building increasingly complex agent-based models with AMBER.

Part 1: Your First Model

Let’s start with a simple wealth transfer model where agents randomly exchange money. AMBER’s view API expresses this in a handful of Polars operations — no per-agent loop.

Step 1: Define the Model

import ambr as am

class WealthModel(am.Model):
    def setup(self):
        # Bulk-create the population with columnar initial state.
        n = self.p['n_agents']
        self.add_agents(
            n,
            wealth=self.nprandom.integers(1, 10, size=n),
        )

    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

        # Scatter the $1 credits. Using ``scatter_add`` (rather than a
        # plain ``view.wealth = ...``) is what makes the math right when
        # two donors happen to pick the same recipient.
        ids = self.agents.ids.to_numpy()
        recipients = self.nprandom.choice(ids, size=len(donors))
        self.agents.at[recipients].scatter_add(wealth=1)

Step 2: Run the Model

# Run the simulation
model = WealthModel({
    'n_agents': 100,
    'steps': 100,
    'seed': 42
})
results = model.run()

# Examine results
print("Final wealth distribution:")
final_wealth = results['agents'].filter(
    results['agents']['step'] == results['agents']['step'].max()
)
print(final_wealth.select(['id', 'wealth']).head(10))

Part 2: Adding Spatial Structure

Now let’s enhance our model with a grid environment where agents can only interact with neighbors.

Step 1: Create Spatial Model

class SpatialWealthModel(am.Model):
    def setup(self):
        # Create grid environment
        self.grid = am.GridEnvironment(self, size=(20, 20))

        n = self.p['n_agents']
        # Columnar creation: position + wealth together, no loop.
        self.add_agents(
            n,
            wealth=self.nprandom.integers(1, 10, size=n),
            x=self.nprandom.integers(0, 20, size=n),
            y=self.nprandom.integers(0, 20, size=n),
        )

    def step(self):
        # Same donor-pays-$1 idiom as Part 1 — the grid only affects
        # *who* the recipients are, not the vectorized shape of the
        # update. Here we keep the transfer global for simplicity; see
        # ``examples/segregation_model.py`` for a neighbourhood-scoped
        # variant built on ``GridEnvironment.get_neighbors``.
        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)

Step 2: Visualize Results

import matplotlib.pyplot as plt

# Run spatial model
spatial_model = SpatialWealthModel({
    'n_agents': 200,
    'steps': 50,
    'seed': 42
})
results = spatial_model.run()

# Plot wealth distribution on grid
final_data = results['agents'].filter(
    results['agents']['step'] == results['agents']['step'].max()
)

plt.figure(figsize=(10, 8))
scatter = plt.scatter(
    final_data['x'],
    final_data['y'],
    c=final_data['wealth'],
    cmap='viridis',
    s=50
)
plt.colorbar(scatter, label='Wealth')
plt.title('Final Wealth Distribution on Grid')
plt.xlabel('X Position')
plt.ylabel('Y Position')
plt.show()

Part 3: Data Collection and Analysis

Let’s add comprehensive data collection to track model-level metrics.

Step 1: Enhanced Model with Analytics

import numpy as np

class AnalyticalWealthModel(am.Model):
    def setup(self):
        n = self.p['n_agents']
        self.add_agents(n, wealth=self.nprandom.integers(1, 10, size=n))

    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 aggregates are the idiomatic way to record metrics.
        wealth = self.agents.wealth
        self.record_model('total_wealth', int(wealth.sum()))
        self.record_model('mean_wealth', float(wealth.mean()))
        self.record_model('wealth_std', float(wealth.std() or 0.0))
        self.record_model('gini_coefficient', self.calculate_gini(wealth.to_numpy()))

    @staticmethod
    def calculate_gini(values):
        """Calculate Gini coefficient of wealth inequality."""
        if values.size == 0 or values.sum() == 0:
            return 0.0
        sorted_vals = np.sort(values)
        n = len(sorted_vals)
        cumsum = np.cumsum(sorted_vals)
        return (n + 1 - 2 * cumsum.sum() / cumsum[-1]) / n

Step 2: Analyze Results

# Run analytical model
model = AnalyticalWealthModel({
    'n_agents': 100,
    'steps': 200,
    'seed': 42
})
results = model.run()

# Create comprehensive analysis plots
fig, axes = plt.subplots(2, 2, figsize=(15, 10))

# Plot 1: Mean wealth over time
axes[0,0].plot(results['model']['mean_wealth'])
axes[0,0].set_title('Mean Wealth Over Time')
axes[0,0].set_xlabel('Time Step')
axes[0,0].set_ylabel('Mean Wealth')

# Plot 2: Wealth inequality (Gini coefficient)
axes[0,1].plot(results['model']['gini_coefficient'])
axes[0,1].set_title('Wealth Inequality (Gini Coefficient)')
axes[0,1].set_xlabel('Time Step')
axes[0,1].set_ylabel('Gini Coefficient')

# Plot 3: Wealth standard deviation
axes[1,0].plot(results['model']['wealth_std'])
axes[1,0].set_title('Wealth Standard Deviation')
axes[1,0].set_xlabel('Time Step')
axes[1,0].set_ylabel('Standard Deviation')

# Plot 4: Final wealth distribution histogram
final_wealth = results['agents'].filter(
    results['agents']['step'] == results['agents']['step'].max()
)['wealth']
axes[1,1].hist(final_wealth, bins=20, alpha=0.7)
axes[1,1].set_title('Final Wealth Distribution')
axes[1,1].set_xlabel('Wealth')
axes[1,1].set_ylabel('Frequency')

plt.tight_layout()
plt.show()

Part 4: Parameter Optimization

Let’s use AMBER’s optimization tools to find the best parameters for our model.

Step 1: Define Optimization Target

from ambr import ParameterSpace, IntRange, grid_search

# Define parameter space to explore
param_space = ParameterSpace({
    'n_agents': IntRange(50, 200),
    'steps': [50, 100, 150],
    'seed': IntRange(1, 10)
})

# Run grid search to minimize final Gini coefficient
best_params, best_score = grid_search(
    model_class=AnalyticalWealthModel,
    param_space=param_space,
    metric='gini_coefficient',  # Minimize inequality
    minimize=True,
    n_runs=3  # Average over multiple runs
)

print(f"Best parameters: {best_params}")
print(f"Best Gini coefficient: {best_score}")

Step 2: Compare Optimization Methods

from ambr import random_search, bayesian_optimization

# Compare different optimization approaches
methods = {
    'grid_search': grid_search,
    'random_search': random_search,
    'bayesian_optimization': bayesian_optimization
}

results = {}
for name, method in methods.items():
    if name == 'random_search':
        best_params, best_score = method(
            model_class=AnalyticalWealthModel,
            param_space=param_space,
            metric='gini_coefficient',
            minimize=True,
            n_samples=20,
            n_runs=3
        )
    elif name == 'bayesian_optimization':
        best_params, best_score = method(
            model_class=AnalyticalWealthModel,
            param_space=param_space,
            metric='gini_coefficient',
            minimize=True,
            n_calls=20,
            n_runs=3
        )
    else:  # grid_search
        best_params, best_score = method(
            model_class=AnalyticalWealthModel,
            param_space=param_space,
            metric='gini_coefficient',
            minimize=True,
            n_runs=3
        )

    results[name] = {
        'params': best_params,
        'score': best_score
    }

# Compare results
for method, result in results.items():
    print(f"{method}: Gini = {result['score']:.4f}, Params = {result['params']}")

Part 5: Running Experiments

Finally, let’s use the experiment framework to run systematic parameter sweeps.

Step 1: Design Experiment

from ambr import Experiment, Sample, IntRange

# Define parameter variations
experiment_params = Sample({
    'n_agents': IntRange(50, 300),
    'steps': 100,
    'seed': [1, 2, 3, 4, 5]  # Multiple seeds for robustness
})

# Create experiment
experiment = Experiment(
    model_class=AnalyticalWealthModel,
    parameters=experiment_params,
    iterations=50  # Number of parameter combinations to try
)

# Run experiment
experiment_results = experiment.run()

Step 2: Analyze Experiment Results

# Analyze relationship between population size and inequality
import pandas as pd

# Convert to pandas for easier analysis
df = experiment_results.to_pandas()

# Group by number of agents and calculate mean Gini coefficient
gini_by_population = df.groupby('n_agents')['gini_coefficient'].mean()

plt.figure(figsize=(10, 6))
plt.plot(gini_by_population.index, gini_by_population.values, 'o-')
plt.xlabel('Number of Agents')
plt.ylabel('Mean Gini Coefficient')
plt.title('Wealth Inequality vs Population Size')
plt.grid(True, alpha=0.3)
plt.show()

# Statistical analysis
correlation = df['n_agents'].corr(df['gini_coefficient'])
print(f"Correlation between population size and inequality: {correlation:.3f}")

Next Steps

You now have the foundation to build complex agent-based models with AMBER. Here are some directions to explore:

  1. Custom Agent Behaviors: Create specialized agent classes with complex decision-making

  2. Network Models: Use NetworkEnvironment for social network simulations

  3. Multi-Agent Interactions: Implement group behaviors and collective decision-making

  4. Real-Time Visualization: Add interactive plotting and animation

  5. Advanced Analytics: Implement custom metrics and statistical analysis

  6. Performance Optimization: Scale models to handle thousands of agents

For more examples, see the Examples section and explore the examples/ directory in the repository.