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.

The NB-EGM solver

NBEGM is an endogenous-grid solver for a one-dimensional consumption–saving regime whose budget is split by declared institutional breakpoints — asset tests, subsidy brackets, benefit notches, consumption floors. The model author exposes each boundary as metadata (a case piece); within each piece the budget is smooth, so NBEGM runs EGM per piece and merges the pieces on the liquid grid with a branch-aware upper envelope, resolving the kinks and jumps exactly at their declared locations.

Reach for NBEGM when a regime carries institutional discontinuities. A dense brute grid (GridSearch) can only approximate the optimum near a cliff — unless you can specify your grid statically as described in PiecewiseLinSpacedGrid, it places no candidate exactly at the threshold and averages across it — so brute force is a diagnostic here, not the correctness reference (see Validating an NB-EGM regime). For the full theory, correctness results, and the conditions under which NB-EGM beats brute force, see the NB-EGM methods paper; this page is the how-to.

When it applies

NBEGM solves the sub-class where:

Declaring breakpoints

Institutional boundaries are declared, not discovered — no solver can recover a threshold’s exact location from finitely many black-box evaluations, so the model author exposes each boundary as metadata. The decorators only attach metadata and return the function unchanged, so the same model still solves identically under GridSearch.

import jax.numpy as jnp

import lcm
from lcm.typing import BoolND, ContinuousState, FloatND


@lcm.case_boundary(
    lcm.boundary("liquid", "medicaid_asset_limit", equality="otherwise", kind="jump")
)
def medicaid_eligible(liquid: ContinuousState, medicaid_asset_limit: float) -> BoolND:
    """Medicaid asset test: eligible while liquid wealth is below the limit."""
    return liquid < medicaid_asset_limit


@lcm.piece("subsidy", when=medicaid_eligible)
def subsidy_medicaid(subsidy_high: float) -> FloatND:
    """Subsidy into market resources for the Medicaid-eligible (low-asset) case."""
    return jnp.asarray(subsidy_high)


@lcm.piece("subsidy", otherwise=medicaid_eligible)
def subsidy_private(subsidy_low: float) -> FloatND:
    """Subsidy into market resources for the private (high-asset) case."""
    return jnp.asarray(subsidy_low)


def resources(liquid: ContinuousState, subsidy: FloatND) -> FloatND:
    """Market resources: liquid wealth plus the Medicaid-contingent subsidy."""
    return liquid + subsidy

The Medicaid-eligible subsidy exceeds the private one, so market resources — and hence the value function — jump down as liquid wealth crosses the limit upward.

Selecting the solver

The solver is a per-regime slot. Pass an NBEGM instance where you would otherwise leave the default GridSearch:

from lcm import LinSpacedGrid, Regime
from lcm.solvers import NBEGM

alive_regime = Regime(
    transition=next_regime,
    states={"liquid": LinSpacedGrid(start=0.0, stop=20.0, n_points=80)},
    actions={},  # consumption is the solver's continuous action
    state_transitions={"liquid": next_liquid},
    functions={
        "utility": utility,
        "medicaid_eligible": medicaid_eligible,
        "subsidy_medicaid": subsidy_medicaid,
        "subsidy_private": subsidy_private,
        "resources": resources,
    },
    constraints={"feasible": feasible},
    solver=NBEGM(savings_grid=LinSpacedGrid(start=0.0, stop=20.0, n_points=100)),
)

NBEGM requires a savings_grid (the post-decision savings nodes). Key optional arguments:

The two cliff-read modes

A child regime’s value cliffs cannot be represented by a single continuous interpolant. jump_read selects how the continuation carry is published to parents:

The smoothness gate

Declared breakpoints are only as good as the smoothness of what lies between them. At model build, NBEGM.validate runs two validators over the user economic functions reachable in each case:

Mark a reviewed numerical clip/max/abs guard with @lcm.smooth_helper to exempt it, stating the domain on which it is smooth.

The piecewise-constancy probe

When the continuation reads the current liquid state (a co-state’s next-state law or a regime-transition probability switched at a declared threshold), NBEGM solves one continuation row per declared interval. This is exact only if that liquid dependence is piecewise-constant on the declared partition. A build-time probe screens for it and refuses by default (probe_failure="reject") when it detects smooth dependence or cannot evaluate the model’s DAG. Passing probe_failure="assume_declared" asserts the precondition explicitly (emitting a warning); every exactness claim is then conditional on that assertion, which must be discharged by independent validation.

The probe is a finite diagnostic, not a certificate — a dependence whose derivative vanishes at every probed point passes undetected.

Performance knobs

Every batching knob streams one axis and changes peak memory and schedule only, never the result (up to floating-point reassociation):

KnobAxis streamed
cell_block_sizeride-along cells
branch_batch_sizediscrete-action branches (lax.map, body compiled once)
interval_batch_sizeper-interval continuation reads
stochastic_node_batch_sizechild stochastic-node mesh
envelope_segment_block_sizeenvelope segment blocks (two-pass scan)

All default to 0 (the whole axis in one vectorized pass). Raise a knob when the corresponding buffer is the memory wall.

Relation to other EGM methods

NB-EGM sits alongside pylcm’s other endogenous-grid solvers rather than replacing them (see Choosing a solver for the full map):

Validating an NB-EGM regime

GridSearch is a diagnostic, not the correctness oracle. Brute force evaluates the combined (jnp.where) budget on a finite action grid, so it smooths across every breakpoint. The optimal policy is often to save to just below an eligibility cliff (to keep the benefit), so the optimum sits one step inside the eligible side — a point no finite grid holds exactly, though a PiecewiseLinSpacedGrid aligned to the breakpoint may come close enough for practical purposes. Asserting exact agreement with brute is therefore the wrong acceptance test.

Euler residuals are a useful report but not the acceptance criterion — they are blind to the corner and boundary candidates that carry the economics of interest.