Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Working with DataFrames and Series

solve() and simulate() accept pandas objects directly. Initial conditions can be a DataFrame, and parameters can contain pd.Series values with labeled indices. Simulation results come back as a DataFrame via .to_dataframe(). The typical workflow is DataFrame in, DataFrame out.

Initial Conditions as a DataFrame

Pass a pandas DataFrame directly to simulate() as initial_conditions. One row per agent, one column per state variable, plus a "regime_name" column:

df = pd.DataFrame(
    {
        "regime_name": ["working", "working", "retired"],
        "wealth": [10.0, 50.0, 30.0],
        "health": ["good", "bad", "good"],
        "age": [25.0, 25.0, 25.0],
    }
)

result = model.simulate(
    params=params,
    initial_conditions=df,
    log_level="debug",
)

The example above is for an age model. A model declared with n_periods requires an integer "period" column instead of "age". Use exactly the coordinate kind the model declares; supplying both names or the wrong name fails before simulation.

You can also pass initial conditions as a plain dict of JAX arrays (see Solving and Simulating).

Parameters with pd.Series

When parameters include array values — transition probabilities, wage profiles, or any array indexed by model variables — prepare them as labeled pd.Series with a named MultiIndex. Pass them directly in the params dict; solve() and simulate() convert them automatically:

params = {
    "discount_factor": 0.95,
    "working": {
        "next_health": {
            "probs_array": health_probs_series,  # pd.Series with MultiIndex
        },
        "utility": {"risk_aversion": 1.5},
    },
}

# Series values are converted to JAX arrays transparently
result = model.simulate(
    params=params,
    initial_conditions=df,
    log_level="debug",
)

Scalars remain constants. Ordinary arrays retain positional indexing, while declared temporal parameters require labelled inputs. TimeVarying provides the same time alignment without pandas; see Periods and temporal parameters.

Series format

Each pd.Series must have:

A discrete state or action labels a level wherever the regime declares it, including a horizon at which no demanded function reads it and the model prunes it: the parameter a dormant law reads keeps its slot, and its Series converts by the same categories.

A function indexes an array by the codes of a discrete variable, its positions in pandas’ terms. For a variable declared with values, the function reads <name> as the value (pandas: the category) and <name>_code as the int32 position (pandas: the code), and indexes with the code:

def wage(*, experience, experience_code, wage_premium, base_wage):
    return base_wage * (1 + 0.02 * experience + wage_premium[experience_code])


params = {
    "working_life": {
        "wage": {
            "wage_premium": pd.Series(
                [0.25, 0.0, 0.1], index=pd.Index([10, 0, 5], name="experience")
            ),
            "base_wage": 2.0,
        }
    }
}

The level is still named experience and labelled by the values; pylcm places each label at its code, whatever order the Series is written in. Indexing by the value, wage_premium[experience], is refused when the model is built, because the value 10 is no position of a three-node grid. For a category class <name> already holds the code, so <name>_code equals it and either spelling indexes the same way.

Level order does not matter — levels are reordered to match the function signature automatically.

For temporal data, schema checks precede filtering. Rows outside the model’s age or period grid are silently discarded, including duplicates outside the grid. Duplicate selected full keys and missing required time–category combinations raise errors. Extra rows never substitute for missing required rows. A transition consumes its source period; an unused terminal slot needs no fabricated observation.

With @time_varying_params("wage"), the consumer receives only the current time slice and indexes any remaining categorical axes normally. The existing manual wage[period] route also accepts labelled Series. Never index a normalized array with age: its positions are periods. Known manual temporal reads of unlabelled arrays warn in age models and fail in period models, independently of log_level.

What happens during conversion

Your model functions work with plain JAX arrays and integer indexing — nothing about pandas enters the model at runtime. The Series is purely an input convenience. Before any model code runs, the conversion inspects the function signature to determine which dimensions the array is indexed over, maps each label to an integer position using the model’s grids (e.g., "good" → 0, "bad" → 1), and scatters the Series values into a JAX array of the correct shape. The function receives a normal jnp.ndarray and never sees pandas.

Why Labeled Indices Matter

Every discrete variable axis must use string labels from the model’s categorical classes, not raw integer codes. This is a deliberate design choice.

The conversion step validates every label against the model’s grids before building the array. If a label is misspelled, a category is missing, or axes are swapped, you get a clear error before the array enters JAX. Without this validation, a wrong index would silently produce a misshapen array. JAX would then vmap that array over millions of simulated agents — producing garbage results with no error message and no way to trace the problem back to the input.

Labeled indices turn silent data corruption into loud, early errors with actionable messages.

derived_categoricals

A derived categorical changes parameter indexing but is not a state grid or transition. Declare it before selecting solver-specific row structure, and return integer category codes rather than Booleans.

When a function indexes its array parameter by a variable that is not a state or action in the model — typically a DAG function output — the model has no grid to validate labels against. You will see an error like:

Unrecognised indexing parameter 'employment_type'. Expected 'age' or a
discrete grid name (['health', 'partner']). If 'employment_type' is a DAG
function output, add derived_categoricals={"employment_type": DiscreteGrid(category_class=EmploymentType)}
to the Regime or Model constructor.

Fix this by declaring the grid on the Regime that uses it:

working = Regime(
    # Other regime fields are omitted in this fragment.
    derived_categoricals={
        "employment_type": DiscreteGrid(category_class=EmploymentType)
    },
)

If the variable has different categories in different regimes, each regime declares its own grid:

working = Regime(
    # Other regime fields are omitted in this fragment.
    derived_categoricals={
        "employment_type": DiscreteGrid(category_class=FullEmploymentType)
    },
)
retired = Regime(
    # Other regime fields are omitted in this fragment.
    derived_categoricals={
        "employment_type": DiscreteGrid(category_class=RetiredEmploymentType)
    },
)

For convenience, model-level derived_categoricals are broadcast to all regimes:

Model(
    regimes={"working": working, "retired": retired},
    derived_categoricals={
        "employment_type": DiscreteGrid(category_class=EmploymentType)
    },
    ages=ages,
    regime_id_class=RegimeId,
    edges=edges,
    initial_nodes=initial_nodes,
)

Integer return types required

Functions used as derived categoricals must return integer values, not booleans. JAX cannot use boolean values as array indices inside JIT-compiled code (NonConcreteBooleanIndexError). If your derived categorical compares states:

# Wrong — returns bool, fails inside JIT
def is_good_health(health: DiscreteState) -> BoolND:
    return health == Health.good


# Correct — returns int32
def is_good_health(health: DiscreteState) -> IntND:
    return jnp.int32(health == Health.good)

Validating State Transition Probabilities

State (and regime) transition probabilities are validated automatically during model.solve() and model.simulate(): their shape, [0,1][0, 1] range, and rows summing to 1 are checked on a sweep over the regime’s grids when runtime_checks=True, the default. Failures raise independently of console verbosity — see Solving and Simulating.

Regime transition probabilities are validated automatically during model.solve() and model.simulate(), so this helper covers only state transitions.

Under the Hood

Internally, solve() and simulate() call convert_series_in_params (in _lcm.pandas_utils) to walk the already-broadcast params and convert each pd.Series via array_from_series. For initial conditions, initial_conditions_from_dataframe handles the DataFrame-to-dict conversion. Both are internal helpers — you don’t need to call them directly.

See Also