Simulation Results#

This example demonstrates how to collect and analyze simulation results using the SimulationResults class. Learn how to:

  • Collect data during simulation runs

  • Access data via results["Producer.price"] or results.Producer.price

  • Use results.get() for programmatic access with optional aggregation

  • Use results.available() to discover collected variables

  • Export data to pandas DataFrames

  • Generate summary statistics

The results module makes it easy to extract insights from simulations without manual data collection.

Basic Data Collection#

Pass collect=True to sim.run() to collect data automatically. This returns a SimulationResults object containing time series data.

import numpy as np

import bamengine as bam

# Run simulation with data collection
# We collect Worker employed data without aggregation to calculate unemployment
sim = bam.Simulation.init(n_firms=100, n_households=500, seed=42)
results = sim.run(
    n_periods=50,
    collect={
        "Producer": True,
        "Worker": ["employed"],  # Boolean employed status for each worker
        "Employer": True,
        "Borrower": True,
        "Lender": True,
        "Consumer": True,
        # Capture timing: when to snapshot each variable during the period
        # Worker.employed should be captured after production runs (steady state)
        "capture_timing": {
            "Worker.employed": "firms_run_production",
        },
    },
)

print(f"Collected results: {results}")
print("\nMetadata:")
print(f"  Periods simulated: {results.metadata.get('n_periods', 'N/A')}")
print(f"  Firms: {results.metadata.get('n_firms', 'N/A')}")
print(f"  Households: {results.metadata.get('n_households', 'N/A')}")
Collected results: SimulationResults(periods=50, firms=100, households=500, roles=[Worker, Producer, Employer, Borrower, Lender, Consumer], relationships=[None])

Metadata:
  Periods simulated: 50
  Firms: 100
  Households: 500

Discovering Available Data#

Use results.available() to list all collected variables as "Name.variable" strings.

print("All available data:")
for key in results.available():
    print(f"  {key}")
All available data:
  Borrower.credit_demand
  Borrower.gross_profit
  Borrower.loan_apps_head
  Borrower.loan_apps_targets
  Borrower.net_profit
  Borrower.net_worth
  Borrower.projected_fragility
  Borrower.retained_profit
  Borrower.total_funds
  Borrower.wage_bill
  Consumer.income
  Consumer.income_to_spend
  Consumer.largest_prod_prev
  Consumer.propensity
  Consumer.savings
  Consumer.shop_visits_head
  Consumer.shop_visits_targets
  Economy.avg_price
  Economy.inflation
  Economy.n_bank_bankruptcies
  Economy.n_firm_bankruptcies
  Employer.current_labor
  Employer.desired_labor
  Employer.n_vacancies
  Employer.recv_job_apps
  Employer.recv_job_apps_head
  Employer.total_funds
  Employer.wage_bill
  Employer.wage_offer
  Employer.wage_shock
  Lender.credit_supply
  Lender.equity_base
  Lender.interest_rate
  Lender.opex_shock
  Lender.recv_loan_apps
  Lender.recv_loan_apps_head
  Producer.breakeven_price
  Producer.desired_production
  Producer.expected_demand
  Producer.inventory
  Producer.labor_productivity
  Producer.price
  Producer.price_shock
  Producer.prod_mask_dn
  Producer.prod_mask_up
  Producer.prod_shock
  Producer.production
  Producer.production_prev
  Worker.employed

Primary Data Access#

The primary API uses bracket notation results["Name.variable"] or attribute access results.Name.variable. Economy metrics are always collected automatically (no need to request them).

# Bracket access (recommended for most use cases)
avg_price = results["Economy.avg_price"]
inflation = results["Economy.inflation"]

# Attribute access (convenient for interactive exploration)
avg_price_attr = results.Economy.avg_price
print(f"\nAverage price (bracket): {bam.ops.mean(avg_price):.3f}")
print(f"Average price (attr):   {bam.ops.mean(avg_price_attr):.3f}")

# Role data works the same way
worker_employed = results["Worker.employed"]
print(f"Worker employed shape:  {worker_employed.shape}")


# Helper function to calculate unemployment rate from Worker employed data
def calc_unemployment_rate(worker_employed: np.ndarray) -> np.ndarray:
    """Calculate unemployment rate per period from Worker employed boolean array.

    Args:
        worker_employed: 2D boolean array of shape (n_periods, n_workers) where
            True indicates employed, False indicates unemployed.

    Returns:
        1D array of unemployment rates per period.
    """
    # Employment rate = mean of employed (True=1, False=0) across workers
    # Unemployment rate = 1 - employment rate
    return 1.0 - np.mean(worker_employed.astype(float), axis=1)


# Calculate unemployment rate from Worker employed data
unemployment = calc_unemployment_rate(worker_employed)
print("\nUnemployment rate:")
print(f"  Mean: {bam.ops.mean(unemployment):.2%}")
print(f"  Final: {unemployment[-1]:.2%}")

print("\nAverage price:")
print(f"  Mean: {bam.ops.mean(avg_price):.3f}")
print(f"  Final: {avg_price[-1]:.3f}")
Average price (bracket): 0.590
Average price (attr):   0.590
Worker employed shape:  (50, 500)

Unemployment rate:
  Mean: 0.14%
  Final: 0.00%

Average price:
  Mean: 0.590
  Final: 0.760

Accessing Role Data#

Role data contains per-period snapshots of agent states. With dict-form collect, data is full per-agent arrays (periods x agents) by default.

# Access Producer price data - shape is (periods, firms) with full per-agent data
price_data = results["Producer.price"]
print(f"Price data shape: {price_data.shape}")
# Calculate mean price per period
avg_prices = np.mean(price_data, axis=1)
print(f"Price trend (first 5): {avg_prices[:5].round(3)}")
Price data shape: (50, 100)
Price trend (first 5): [0.504 0.504 0.504 0.503 0.502]

Programmatic Access with get()#

Use results.get() for programmatic access, especially when the role or variable name comes from a variable. It also supports on-the-fly aggregation.

# get() for role data
prices_via_get = results.get("Producer", "price")
print("\nUsing get():")
print(f"  results.get('Producer', 'price').shape: {prices_via_get.shape}")

# get() for economy data - use "Economy" as the name
price_via_get = results.get("Economy", "avg_price")
print(f"  results.get('Economy', 'avg_price').shape: {price_via_get.shape}")

# Aggregation on-the-fly (useful when you have full per-agent data)
full_data_sim = bam.Simulation.init(n_firms=50, n_households=250, seed=42)
full_data_results = full_data_sim.run(
    n_periods=20,
    collect={
        "Producer": ["price"],  # Specific variables for Producer
    },
)

# Get full 2D data (periods x firms)
prices_2d = full_data_results.get("Producer", "price")
print(f"\n  Full data shape: {prices_2d.shape}")

# Get mean aggregated on-the-fly
prices_mean = full_data_results.get("Producer", "price", aggregate="mean")
print(f"  With aggregate='mean': {prices_mean.shape}")
Using get():
  results.get('Producer', 'price').shape: (50, 100)
  results.get('Economy', 'avg_price').shape: (50,)

  Full data shape: (20, 50)
  With aggregate='mean': (20,)

Legacy Access#

The underlying data is also available via role_data, economy_data, and relationship_data dictionaries. The data property merges them into a single dict with an “Economy” key.

print("\nLegacy dict access:")
print(f"  results.role_data keys: {list(results.role_data.keys())}")
print(f"  results.economy_data keys: {list(results.economy_data.keys())}")

# The unified data property
all_data = results.data
print(f"\nresults.data keys: {list(all_data.keys())}")
Legacy dict access:
  results.role_data keys: ['Worker', 'Producer', 'Employer', 'Borrower', 'Lender', 'Consumer']
  results.economy_data keys: ['avg_price', 'inflation', 'n_firm_bankruptcies', 'n_bank_bankruptcies']

results.data keys: ['Worker', 'Producer', 'Employer', 'Borrower', 'Lender', 'Consumer', 'Economy']

Visualizing Results#

Plot key economic indicators over time.

import matplotlib.pyplot as plt

fig, axes = plt.subplots(2, 2, figsize=(12, 8))

# Unemployment rate
ax1 = axes[0, 0]
if len(unemployment) > 0:
    ax1.plot(unemployment * 100, linewidth=2, color="tab:blue")
    ax1.set_ylabel("Unemployment Rate (%)")
    ax1.set_title("Unemployment")
    ax1.grid(True, alpha=0.3)

# Average price
ax2 = axes[0, 1]
if len(avg_price) > 0:
    ax2.plot(avg_price, linewidth=2, color="tab:green")
    ax2.set_ylabel("Price Level")
    ax2.set_title("Average Market Price")
    ax2.grid(True, alpha=0.3)

# Inflation
ax3 = axes[1, 0]
if len(inflation) > 0:
    ax3.plot(inflation * 100, linewidth=2, color="tab:orange")
    ax3.set_ylabel("Inflation Rate (%)")
    ax3.set_title("Annual Inflation")
    ax3.axhline(y=0, color="black", linestyle="--", alpha=0.5)
    ax3.grid(True, alpha=0.3)

# Production - compute mean across firms since data is 2D
ax4 = axes[1, 1]
production_data = results["Producer.production"]
# Average across firms (axis=1) since data is (periods, firms)
avg_production = np.mean(production_data, axis=1)
ax4.plot(avg_production, linewidth=2, color="tab:red")
ax4.set_ylabel("Avg Production")
ax4.set_title("Average Firm Production")
ax4.grid(True, alpha=0.3)

for ax in axes.flat:
    ax.set_xlabel("Period")

plt.tight_layout()
plt.show()
Unemployment, Average Market Price, Annual Inflation, Average Firm Production

Custom Data Collection#

Use a dictionary to specify exactly what data to collect. Keys are role names (or “Economy”), values are True for all variables or a list of specific variable names.

# Collect only specific roles (economy metrics are always included automatically)
custom_results = bam.Simulation.init(n_firms=100, n_households=500, seed=42).run(
    n_periods=30,
    collect={
        "Producer": True,  # All Producer variables
        "Worker": True,  # All Worker variables
        "aggregate": "mean",  # Average across agents
    },
)

print("Custom collection:")
print(f"  Roles collected: {list(custom_results.role_data.keys())}")
Custom collection:
  Roles collected: ['Producer', 'Worker']

Full Agent-Level Data#

Dict-form collect returns full per-agent data by default (larger arrays).

# Warning: This collects full arrays - can be memory intensive!
full_results = bam.Simulation.init(n_firms=50, n_households=250, seed=42).run(
    n_periods=20,
    collect={
        "Producer": ["price", "production"],  # Specific variables only
    },
)

prices_full = full_results["Producer.price"]
print(f"Full price data shape: {prices_full.shape}")
print(f"  (periods x firms): ({prices_full.shape[0]} x {prices_full.shape[1]})")

# Access individual firm's price history
firm_0_prices = prices_full[:, 0]
print(f"Firm 0 price history: {firm_0_prices[:5].round(3)}...")
Full price data shape: (20, 50)
  (periods x firms): (20 x 50)
Firm 0 price history: [0.5 0.5 0.5 0.5 0.5]...

Export to pandas DataFrame#

Convert results to pandas DataFrames for further analysis. Note: pandas is an optional dependency.

try:
    import pandas

    # Run a new simulation for DataFrame export
    df_results = bam.Simulation.init(n_firms=100, n_households=500, seed=42).run(
        n_periods=50,
        collect=True,
    )

    # Get all data as a single DataFrame (aggregated)
    df = df_results.to_dataframe(aggregate="mean")
    print("Full DataFrame:")
    print(f"  Shape: {df.shape}")
    print(f"  Columns: {list(df.columns)[:5]}...")

    # Get economy metrics only
    df_economy = df_results.economy_metrics
    print("\nEconomy metrics DataFrame:")
    print(df_economy.head())

    # Get specific role data
    df_producer = df_results.get_role_data("Producer", aggregate="mean")
    print(f"\nProducer DataFrame columns: {list(df_producer.columns)}")

except ImportError:
    print("pandas not installed. Install with: pip install pandas")
Full DataFrame:
  Shape: (50, 52)
  Columns: ['Producer.production.mean', 'Producer.production_prev.mean', 'Producer.inventory.mean', 'Producer.expected_demand.mean', 'Producer.desired_production.mean']...

Economy metrics DataFrame:
        avg_price  inflation  n_firm_bankruptcies  n_bank_bankruptcies
period
0        0.500000   0.000000                    4                    0
1        0.500000   0.000000                    4                    0
2        0.500000   0.000000                    4                    0
3        0.499194   0.000000                    4                    0
4        0.498755  -0.001612                    4                    0

Producer DataFrame columns: ['Producer.production.mean', 'Producer.production_prev.mean', 'Producer.inventory.mean', 'Producer.expected_demand.mean', 'Producer.desired_production.mean', 'Producer.labor_productivity.mean', 'Producer.breakeven_price.mean', 'Producer.price.mean', 'Producer.prod_shock.mean', 'Producer.prod_mask_up.mean', 'Producer.prod_mask_dn.mean', 'Producer.price_shock.mean']

Summary Statistics#

Get descriptive statistics for all collected metrics.

try:
    import pandas  # noqa: F401 - check if pandas is installed

    summary_results = bam.Simulation.init(n_firms=100, n_households=500, seed=42).run(
        n_periods=100,
        collect=True,
    )

    # Get summary statistics
    summary = summary_results.summary
    print("Summary Statistics:")
    print(summary[["mean", "std", "min", "max"]].round(4))

except ImportError:
    print("pandas not installed for summary statistics")
Summary Statistics:
                                       mean      std       min       max
Producer.production.mean             2.4818   0.1147    2.1976    2.5947
Producer.production_prev.mean        2.4818   0.1147    2.1976    2.5947
Producer.inventory.mean              0.5006   0.2330    0.0000    0.9006
Producer.expected_demand.mean        2.4236   0.1266    2.1378    2.6229
Producer.desired_production.mean     2.4236   0.1266    2.1378    2.6229
Producer.labor_productivity.mean     0.5000   0.0000    0.5000    0.5000
Producer.breakeven_price.mean        0.4886   0.0952    0.0000    0.6285
Producer.price.mean                  0.7289   0.1530    0.5024    0.9703
Producer.prod_shock.mean             0.0501   0.0028    0.0433    0.0552
Producer.prod_mask_up.mean           0.3027   0.2167    0.0100    1.0000
Producer.prod_mask_dn.mean           0.2516   0.1156    0.0000    0.5200
Producer.price_shock.mean            0.0501   0.0028    0.0443    0.0575
Worker.employer.mean                42.3932  13.8191    1.6520   49.8120
Worker.employer_prev.mean           44.4916  12.6545   -1.0000   49.5920
Worker.wage.mean                     0.2266   0.0816    0.0099    0.3372
Worker.periods_left.mean             3.5008   1.9385    0.0560    6.6080
Worker.contract_expired.mean         0.1148   0.2694    0.0000    0.9440
Worker.fired.mean                    0.0025   0.0069    0.0000    0.0520
Worker.job_apps_head.mean           -1.0000   0.0000   -1.0000   -1.0000
Employer.desired_labor.mean          5.1148   0.3582    4.4200    5.9600
Employer.current_labor.mean          4.2899   1.3551    0.2800    5.0000
Employer.wage_offer.mean             0.2590   0.0413    0.1674    0.3300
Employer.wage_bill.mean              1.2800   0.2275    0.8088    1.7387
Employer.n_vacancies.mean            0.2467   0.2135    0.0000    1.0400
Employer.total_funds.mean           12.1788   1.8487    7.6912   15.0621
Employer.recv_job_apps_head.mean    -1.0000   0.0000   -1.0000   -1.0000
Employer.wage_shock.mean             0.0106   0.0064    0.0019    0.0271
Borrower.net_worth.mean             12.1788   1.8487    7.6912   15.0621
Borrower.total_funds.mean           12.1788   1.8487    7.6912   15.0621
Borrower.wage_bill.mean              1.2800   0.2275    0.8088    1.7387
Borrower.credit_demand.mean          0.0029   0.0093    0.0000    0.0515
Borrower.projected_fragility.mean    0.0263   0.0708    0.0000    0.4539
Borrower.gross_profit.mean           0.0725   0.1127   -0.0885    0.3916
Borrower.net_profit.mean             0.0723   0.1126   -0.0885    0.3916
Borrower.retained_profit.mean        0.0471   0.1065   -0.0972    0.3524
Borrower.loan_apps_head.mean        -0.3932   1.1959   -1.0000    3.8200
Lender.equity_base.mean              4.1240   1.0118    2.3605    5.0000
Lender.credit_supply.mean           41.2986  10.1039   23.3304   50.0000
Lender.interest_rate.mean            0.0209   0.0004    0.0186    0.0214
Lender.recv_loan_apps_head.mean     -1.0000   0.0000   -1.0000   -1.0000
Lender.opex_shock.mean               0.0488   0.0089    0.0293    0.0708
Consumer.income.mean                 0.0000   0.0000    0.0000    0.0000
Consumer.savings.mean                0.2532   0.1590    0.0991    0.9332
Consumer.income_to_spend.mean        0.0000   0.0000    0.0000    0.0000
Consumer.propensity.mean             0.7781   0.0292    0.6639    0.8269
Consumer.largest_prod_prev.mean     47.7601   2.6988   41.5620   53.3240
Consumer.shop_visits_head.mean     499.0000   0.0000  499.0000  499.0000
Shareholder.dividends.mean           0.0050   0.0018    0.0017    0.0088
avg_price                            0.7224   0.1517    0.4988    0.9646
inflation                            0.0166   0.0420   -0.0519    0.0905
n_firm_bankruptcies                  3.8900   2.2870    0.0000   13.0000
n_bank_bankruptcies                  0.0200   0.1407    0.0000    1.0000

Comparing Multiple Simulation Runs#

Run multiple simulations and compare their results.

# Run with different random seeds
n_runs = 5
all_unemployment = []

print("Running ensemble of simulations...")
for i in range(n_runs):
    run_results = bam.Simulation.init(n_firms=100, n_households=500, seed=42 + i).run(
        n_periods=50,
        collect={
            "Worker": ["employed"],
            "capture_timing": {"Worker.employed": "firms_run_production"},
        },
    )
    # Calculate unemployment from Worker employed data
    unemp_rate = calc_unemployment_rate(run_results["Worker.employed"])
    all_unemployment.append(unemp_rate)

# Plot ensemble results
if all_unemployment:
    fig, ax = plt.subplots(figsize=(10, 6))

    for i, unemp in enumerate(all_unemployment):
        ax.plot(bam.ops.multiply(unemp, 100), alpha=0.5, label=f"Run {i + 1}")

    # Calculate and plot mean unemployment across runs
    # Stack arrays and compute mean along axis 0
    stacked = bam.ops.asarray([list(u) for u in all_unemployment])
    mean_unemployment = bam.ops.multiply(bam.ops.mean(stacked, axis=0), 100)
    ax.plot(mean_unemployment, "k-", linewidth=2, label="Mean")

    ax.set_xlabel("Period")
    ax.set_ylabel("Unemployment Rate (%)")
    ax.set_title(f"Ensemble of {n_runs} Simulation Runs")
    ax.legend(loc="upper right")
    ax.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()

    # Summary across runs
    final_rates = bam.ops.asarray([unemp[-1] * 100 for unemp in all_unemployment])
    print("\nFinal unemployment rates:")
    print(f"  Mean: {bam.ops.mean(final_rates):.2f}%")
    print(f"  Std:  {bam.ops.std(final_rates):.2f}%")
    print(f"  Range: {bam.ops.min(final_rates):.2f}% - {bam.ops.max(final_rates):.2f}%")
Ensemble of 5 Simulation Runs
Running ensemble of simulations...

Final unemployment rates:
  Mean: 0.60%
  Std:  0.91%
  Range: 0.00% - 2.40%

Collecting Relationship Data#

Relationships (like LoanBook) can be collected alongside role data. Unlike roles, relationships are opt-in only and NOT included with collect=True.

# Manually add some loans to demonstrate relationship data collection
rel_sim = bam.Simulation.init(n_firms=50, n_households=250, seed=42)
loans = rel_sim.get_relationship("LoanBook")
loans.append_loans_for_lender(
    lender_idx=np.intp(0),
    borrower_indices=np.array([0, 1, 2, 3, 4], dtype=np.int64),
    amount=np.array([1000.0, 1500.0, 2000.0, 500.0, 750.0]),
    rate=np.array([0.02, 0.03, 0.025, 0.018, 0.022]),
)

rel_results = rel_sim.run(
    n_periods=20,
    collect={
        "Producer": ["price"],  # Role data
        "LoanBook": ["principal", "rate", "debt"],  # Relationship data
        "aggregate": "sum",  # Sum across all active loans
    },
)

print("Relationship data collection:")
print(f"  Available: {rel_results.available()}")

# Access via bracket notation
total_principal = rel_results["LoanBook.principal"]
print(f"  Total principal over time shape: {total_principal.shape}")
print(f"  Initial total principal: {total_principal[0]:.2f}")

# Access via get()
total_debt = rel_results.get("LoanBook", "debt")
print(f"  Total debt (last period): {total_debt[-1]:.2f}")
Relationship data collection:
  Available: ['Economy.avg_price', 'Economy.inflation', 'Economy.n_bank_bankruptcies', 'Economy.n_firm_bankruptcies', 'LoanBook.debt', 'LoanBook.principal', 'LoanBook.rate', 'Producer.price']
  Total principal over time shape: (20,)
  Initial total principal: 0.00
  Total debt (last period): 0.00

Analyzing Loan Distribution#

Without aggregation (the default for dict-form collect), you get full edge data per period as variable-length arrays. Useful for analyzing distributions but cannot be exported to DataFrame.

loan_dist_sim = bam.Simulation.init(n_firms=50, n_households=250, seed=42)
loans = loan_dist_sim.get_relationship("LoanBook")
# Add loans with varying amounts
loans.append_loans_for_lender(
    lender_idx=np.intp(0),
    borrower_indices=np.array([0, 1, 2], dtype=np.int64),
    amount=np.array([100.0, 200.0, 300.0]),
    rate=np.array([0.02, 0.03, 0.025]),
)

dist_results = loan_dist_sim.run(
    n_periods=5,
    collect={
        "LoanBook": ["principal"],  # Full edge data (variable-length per period)
    },
)

principal_per_period = dist_results["LoanBook.principal"]
print("\nLoan distribution (full per-edge data):")
print(f"  Type: {type(principal_per_period).__name__}")
print(f"  Number of periods: {len(principal_per_period)}")
if principal_per_period:
    print(f"  Period 0 loans: {len(principal_per_period[0])} active")
    print(f"  Period 0 principals: {principal_per_period[0]}")
Loan distribution (full per-edge data):
  Type: list
  Number of periods: 5
  Period 0 loans: 0 active
  Period 0 principals: []

Key Takeaways#

Basic collection:

  • Use collect=True (the default) for full per-agent data collection

  • Economy metrics are always collected automatically

  • Use collect={"Producer": ["price"]} for specific variables

  • Use True for all variables: {"Worker": True}

Data access (primary API):

  • results["Producer.price"] – bracket notation (recommended)

  • results.Producer.price – attribute access (interactive use)

  • results.get("Producer", "price") – programmatic access

  • results.get("Producer", "price", aggregate="mean") – with aggregation

  • results.available() – discover all collected variables

Per-agent data and capture timing:

  • Dict-form collect returns full per-agent data by default (shape: periods x agents)

  • Use capture_timing to control when variables are captured during each period

  • Example: "capture_timing": {"Worker.employed": "firms_run_production"}

Computing derived metrics:

  • Unemployment rate can be computed from Worker.employed: 1.0 - np.mean(employed.astype(float), axis=1)

  • When computing from per-agent data, aggregate across agents with axis=1

Relationship data:

  • Relationships (like LoanBook) are opt-in: use "LoanBook": ["principal"]

  • NOT included with collect=True (must specify explicitly)

  • Aggregations: sum (total), mean (average), std (variation)

  • Without aggregation (default): list of variable-length arrays (can’t export to DataFrame)

  • Access via results["LoanBook.principal"] or results.get("LoanBook", "principal")

Legacy access (still works):

  • results.economy_data, results.role_data, results.relationship_data

  • results.data for unified dict access (includes “Economy” key and relationships)

  • Export to pandas with to_dataframe() for further analysis

  • Get quick statistics with results.summary

Total running time of the script: (0 minutes 7.784 seconds)

Gallery generated by Sphinx-Gallery