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.

Grids

Grids define the outcome space for state and action variables — what values they can take. They are passed via the states and actions mappings on a Regime.

A grid is ordinarily the same for every period the regime is active. For a continuous state, see Age-specialized functions and grids to let the grid’s bounds or node values vary with age instead (e.g., an age-dependent borrowing limit).

Grid breakpoints are numerical, not economic metadata. A piecewise grid places nodes around a known location. It does not tell NBEGM that the budget has a kink, jump, or hard constraint. Declare that structure separately with case pieces or a piecewise-affine schedule.

Quick Reference

Grid TypeUse CaseKey Parameters
DiscreteGridCategorical choicescategory_class or values
LinSpacedGridEvenly spaced continuousstart, stop, n_points
LogSpacedGridLog-spaced continuousstart, stop, n_points
IrregSpacedGridCustom point placementpoints or n_points
PiecewiseLinSpacedGridDense in some regionsstart, stop, breakpoints, points_per_segment
PiecewiseLogSpacedGridLog-dense in some regionsstart, stop, breakpoints, points_per_segment

All grid classes are imported from lcm:

from lcm import (
    DiscreteGrid,
    LinSpacedGrid,
    LogSpacedGrid,
    IrregSpacedGrid,
    GridBreakpoint,
    PiecewiseLinSpacedGrid,
    PiecewiseLogSpacedGrid,
    categorical,
)

Discrete Grids

DiscreteGrid

For categorical variables. Requires a @categorical frozen dataclass defining the categories:

from lcm import DiscreteGrid, categorical
from lcm.typing import ScalarInt


@categorical(ordered=True)
class LaborSupply:
    do_not_work: ScalarInt
    work: ScalarInt


actions = {"labor_supply": DiscreteGrid(category_class=LaborSupply)}

Values are integer codes (0, 1, 2, ...) auto-assigned by @categorical. In simulation output, labels are preserved via pandas Categorical.

When each category is a combination of characteristics, such as a labour-market status that fixes job holding and benefit duration, see Categories with characteristics.

When used as an action, no further configuration is needed. When used as a state, the transition is specified via state_transitions on the Regime — see Transitions.

Discrete grids with numeric values

When the outcomes of a discrete variable are numbers, such as hours of work or years of experience, declare the numbers instead of a category class:

import jax.numpy as jnp

from lcm import DiscreteGrid

actions = {"hours": DiscreteGrid(values=jnp.array([0, 20, 40]))}
states = {
    "experience": DiscreteGrid(values=jnp.arange(31)),
    "productivity": DiscreteGrid(values=jnp.array([0.5, 1.0, 2.0]), tolerance=1e-9),
}

values is a one-dimensional NumPy or JAX array with an integer or floating dtype, so the array’s dtype decides whether the grid holds integers or floats; a list, tuple or range is refused. Values are strictly ascending and finite. Inside pylcm the variable is indexed by the codes 0..n-1 of its values, but every function you write receives the values: hours arrives as 0, 20 or 40, not as 0, 1 or 2, and a deterministic law of experience returns the next year of experience, which pylcm maps back to its code. Integer values arrive as int32, as codes do, so jnp.log(hours) on an integer grid is float32 even when x64 is enabled; write jnp.log(hours * 1.0) or declare float values if you need the model’s float precision.

A function that needs the position of a node, for example to index a parameter array, reads <name>_code next to or instead of <name>: hours is the value (in pandas’ terms the category) and hours_code the int32 position (the code). Index arrays with the code, premium[hours_code]; indexing with the value, premium[hours], is refused when the model is built. <name>_code exists for every discrete variable, so for a category class it equals <name>. It can be read wherever <name> can: in functions, constraints, state transitions and regime-transition laws. No state, action or function may be named <name>_code for a discrete variable <name>.

A value matches a node only if it equals it. Integer grids match integers exactly. A float grid matches within its absolute tolerance (zero by default), and grid.nearest(x) snaps a float that arithmetic left between nodes.

Every place where you hand values to pylcm or read them back uses values by default, and integer codes when the call passes use_categorical_codes=True:

The one exception is Model.simulate(shock_paths=...). A shock path holds category codes even for a value grid, whatever use_categorical_codes says, because a path is replayed exactly and losslessly: a code names exactly one node, while a float value would need the grid’s tolerance to find it. Map codes and values with the grid itself:

import jax.numpy as jnp

grid = DiscreteGrid(values=jnp.array([0.5, 1.0, 2.0]))

grid.values[2]  # 2.0, the value of code 2
grid.encode(jnp.array([0.5, 2.0]))  # the codes 0 and 2, as int32
grid.decode(jnp.array([0, 2]))  # the values 0.5 and 2.0

A value-grid column that to_dataframe() published becomes a shock path by its codes, df["productivity"].cat.codes.

Continuous Grids

Values outside a state grid

Continuous grids accept off_grid= with an explicit meaning:

RuleContinuation or policy lookupEconomic next state
"extrapolate" (default)Preserve ordinary extrapolationPreserve the raw law of motion
"clip_lookup"Read at the nearest closed endpointPreserve the raw value in histories and later laws
"clip_state"Read at the nearest closed endpointCap the next-period handoff to the grid
"raise"Refuse a feasible exterior next stateRefuse an exterior realized next state

The ambiguous spelling "clip" is rejected. clip_lookup is appropriate when a finite numerical grid approximates a state whose economic support is larger; clip_state changes the economic handoff and therefore requires an economic reason. Clipping preserves non-finite values so a broken law cannot become a finite endpoint. Stochastic-process support admission remains a separate contract.

The clipped rules are honoured by GridSearch. The endogenous-grid solvers (EGM, DCEGM, NBEGM, NEGM, NNBEGM) read their continuation and policy on their own endogenous grids, so a model whose endogenous-grid regime, or any regime it transitions into, declares clip_lookup or clip_state is rejected at construction with OffGridPolicyError; declare "raise" or "extrapolate" there instead.

For an age-dependent grid, the bounds belong to the target period. For a grid whose points arrive at runtime, those supplied points define the closed endpoints. Interior piecewise boundary ownership is unchanged. Numerical grid rules do not replace a tax, benefit or other economic cap declared in the model.

With runtime_checks=True and log_level="debug", diagnostics count raw exterior next states before clipping, excluding infeasible cells and unreachable lottery support, and log one Off-grid next states line per regime, target, period and state. At every other log_level these count-only diagnostic sweeps are skipped, because each one can expand a full phase grid. The explicit "raise" policy applies at every log_level, even with runtime_checks=False.

A regime with value-dependent constraints or gated edges only knows which actions and branches a next state is reached through once values exist. Its solve therefore checks "raise" grids inside the maximization: an exterior next state counts only for an action every constraint keeps, value constraints included, and through a gated edge only on the branch the gate selects — the target where it opens, the projected fallback where it closes. Such a regime emits no solve-phase diagnostic counts for the other rules.

LinSpacedGrid

Evenly spaced points from start to stop (inclusive). The most common grid type for wealth, consumption, and similar variables.

LinSpacedGrid(start=0, stop=100, n_points=50)

LogSpacedGrid

Points concentrated near start (logarithmic spacing). Good for variables with diminishing marginal effects. start must be positive.

LogSpacedGrid(start=0.1, stop=100, n_points=50)

IrregSpacedGrid

Explicit point placement. Use when you need specific grid points (e.g., at policy kinks). points is a one-dimensional NumPy or JAX array; a list or tuple is refused:

IrregSpacedGrid(points=jnp.array([0.0, 0.5, 1.0, 5.0, 10.0, 50.0]))

You can also defer points to runtime by specifying only n_points. The actual points are then supplied via the params dict:

IrregSpacedGrid(n_points=4)

PiecewiseLinSpacedGrid

Use explicit breakpoints when different parts of one finite domain need different linear resolutions. Each breakpoint declares which neighboring segment owns the exact boundary value:

from lcm import GridBreakpoint, PiecewiseLinSpacedGrid

grid = PiecewiseLinSpacedGrid(
    start=0.0,
    stop=100.0,
    breakpoints=(
        GridBreakpoint(value=10.0, owner="right"),
        GridBreakpoint(value=40.0, owner="left"),
    ),
    points_per_segment=(20, 50, 30),
)

This declaration forms the nominal segments [0, 10), [10, 40], and (40, 100]. The outer endpoints are always included. A right-owned breakpoint is the first node of the segment to its right; a left-owned breakpoint is the last node of the segment to its left. Every breakpoint therefore appears exactly once, and each count is the number of output nodes its segment contributes:

grid.n_points == 20 + 50 + 30

On the open side, the effective endpoint is the representable floating-point value immediately next to the breakpoint. This keeps equality ownership exact without removing a full grid spacing.

Use breakpoints=() with one entry in points_per_segment for a one-segment piecewise declaration. LinSpacedGrid is normally simpler for that case.

PiecewiseLogSpacedGrid

PiecewiseLogSpacedGrid uses the same breakpoint and ownership declarations but logarithmic spacing within each segment. The complete domain and all breakpoints must be positive.

from lcm import GridBreakpoint, PiecewiseLogSpacedGrid

PiecewiseLogSpacedGrid(
    start=0.1,
    stop=1_000.0,
    breakpoints=(GridBreakpoint(value=10.0, owner="right"),),
    points_per_segment=(50, 30),
)

Conditional actions

Often a continuous action matters only for some values of a discrete action: hours worked only when working, a loan amount only when taking a loan. Declare it with ConditionalAction instead of pinning it with a constraint:

from lcm import ConditionalAction, DiscreteGrid, LinSpacedGrid

actions = {
    "labor": DiscreteGrid(category_class=Labor),
    "hours": ConditionalAction(
        grid=LinSpacedGrid(start=0.0, stop=40.0, n_points=5),
        active_when={"labor": Labor.yes},
        inactive_value=0.0,
    ),
    "consumption": LinSpacedGrid(start=0.5, stop=10.0, n_points=100),
}

The declaration means the same as the constraint (labor == Labor.yes) | (hours == 0.0), and pylcm enforces it that way: results are identical to that constraint.

Grid Hierarchy (advanced)

All grids inherit from the Grid base class:

The to_jax() method converts any grid to a JAX array. ContinuousGrid subclasses provide get_coordinate() for mapping values to grid coordinates, used in interpolation.

See Also