Operations Module#

The ops module provides NumPy-like operations with safe defaults for writing custom events. It handles common pitfalls (division by zero, in-place mutation semantics) and provides a consistent API that works on agent arrays without requiring direct NumPy imports.

Quick Example#

from bamengine import ops

# Safe division (handles zeros)
unit_cost = ops.divide(wages, productivity)

# Conditional assignment
new_prices = ops.where(demand > supply, price * 1.1, price * 0.9)

# In-place assignment
ops.assign(prod.price, new_prices)

Why Use ops?#

The ops module exists for several reasons:

  • Safe division: ops.divide(a, b) returns 0 when b is zero, avoiding ZeroDivisionError and RuntimeWarning from NumPy

  • In-place semantics: ops.assign(target, value) ensures role arrays are updated in place, which is required for changes to be visible across events

  • Consistent API: All operations work uniformly on scalars and arrays

  • Self-documenting: Using ops.divide signals intent more clearly than raw a / b in economic model code

The ops functions are thin wrappers around NumPy; there is no performance overhead compared to using NumPy directly.

Quick Reference#

Category

Functions

Notes

Arithmetic

add, subtract, multiply, divide

divide uses epsilon guard (default: 1e-10)

Assignment

assign

Required for in-place role updates

Comparisons

equal, not_equal, less, greater, less_equal, greater_equal

Return boolean arrays

Logical

logical_and, logical_or, logical_not

Combine boolean masks

Conditional

where, select

Vectorized if-then-else

Element-wise

maximum, minimum, clip

Per-element bounds

Mathematical

log, exp

Natural logarithm and exponential

Aggregation

sum, mean, std, min, max, any, all

Reduce across agents

Array Creation

zeros, ones, full, empty, arange, asarray, array

Create agent-sized arrays

Utilities

unique, bincount, isin, argsort, sort

Sorting and set operations

Random

uniform

Requires an RNG argument

Core Operations#

Arithmetic#

Standard element-wise arithmetic on agent arrays:

from bamengine import ops

revenue = ops.multiply(price, quantity_sold)
gross_profit = ops.subtract(revenue, wage_bill)
markup = ops.divide(price, unit_cost)  # safe: returns 0 when unit_cost is 0

All arithmetic functions accept an optional out= parameter for in-place computation:

# Write result directly into an existing array
ops.multiply(price, 1.1, out=price)

Safe division. ops.divide(a, b) adds a tiny epsilon (1e-10) to the denominator to prevent division by zero. This is essential in economic models where denominators like production or net worth can legitimately be zero:

# Without ops: RuntimeWarning when productivity is 0
unit_cost = wage_bill / productivity

# With ops: safely returns 0 for zero-productivity firms
unit_cost = ops.divide(wage_bill, productivity)

Assignment#

ops.assign copies values into an existing array in place. This is the correct way to update role fields:

# Correct: updates the array that prod.price points to
ops.assign(prod.price, new_prices)

# Wrong: rebinds the local variable, role is unchanged
prod.price = new_prices

Warning

Direct assignment (role.field = value) creates a new array instead of updating the existing one. Other events that hold a reference to the original array will not see the change. Always use ops.assign().

Conditional#

ops.where is the vectorized if-then-else, the workhorse for conditional logic in events:

# Increase price if sold out, decrease if overstocked
new_price = ops.where(
    inventory == 0,  # condition
    price * (1 + shock),  # value when True
    price * (1 - shock),  # value when False
)
ops.assign(prod.price, new_price)

Aggregation#

Reduce operations across agent arrays:

total_output = ops.sum(prod.production)
avg_price = ops.mean(prod.price)
any_bankrupt = ops.any(bor.net_worth < 0)

# With mask: aggregate only employed workers
avg_employed_wage = ops.mean(wrk.wage, where=wrk.employed)

Array Creation#

Create arrays sized to the agent population:

# Zero-initialized array for n_firms agents
scratch = ops.zeros(sim.n_firms)

# Constant-filled array
base_rate = ops.full(sim.n_banks, 0.02)

Random#

ops.uniform draws from a uniform distribution, but requires an explicit RNG argument to maintain determinism:

# Draw production shocks for all firms
shock = ops.uniform(sim.rng, 0, sim.h_rho, size=sim.n_firms)

Common Patterns#

These patterns appear frequently in BAM Engine events:

Safe denominator pattern. When dividing by a value that may be zero, guard the denominator with ops.where:

# Avoid division by zero for zero-NW firms
safe_nw = ops.where(bor.net_worth > 0, bor.net_worth, 1.0)
leverage = ops.where(bor.net_worth > 0, ops.divide(debt, safe_nw), max_leverage)

Masked update. Update only agents that meet a condition:

# Only raise wages for firms with vacancies
ops.assign(
    emp.wage_offer,
    ops.where(emp.n_vacancies > 0, emp.wage_offer * (1 + shock), emp.wage_offer),
)

Aggregate then broadcast. Compute an economy-wide statistic and use it per-agent:

avg_savings = ops.mean(con.savings)
relative_savings = ops.divide(con.savings, avg_savings)

When to Use ops vs. NumPy Directly#

Use ops for:

  • Role mutations: Any write to a role field must use ops.assign()

  • Division: Use ops.divide() whenever the denominator might be zero

  • Conditional logic: ops.where() is clearer than nested np.where

NumPy directly is fine for:

  • Local computation: Intermediate calculations that don’t mutate roles

  • Advanced operations: Functions not in ops (e.g., np.cumsum, np.histogram, np.linalg)

  • Indexing: Fancy indexing and slicing work the same on role arrays

import numpy as np
from bamengine import ops

# NumPy fine for local computation
log_prices = np.log(prod.price)
sorted_idx = np.argsort(prod.price)

# ops required for role mutation
ops.assign(prod.price, np.exp(log_prices * 1.01))

See also