Transitions define how agents move between regimes and how state variables evolve over time:
Regime transitions (
Model(edges=...)) — which regime next period?State transitions (the
state_transitionsfield of a Regime) — how do states evolve?
Write transition laws for the solver you intend to use. In particular, an NBEGM liquid law is written through the declared post-decision savings node so the solver can evaluate its landing points and derivative. A law that hides the same accounting in another form may be valid under GridSearch but refused by the specialized route. See Consumption-saving regimes and margins.
import jax.numpy as jnp
from lcm import (
AgeGrid,
ByAge,
DiscreteGrid,
LinSpacedGrid,
Model,
Regime,
StochasticTransition,
Transition,
categorical,
fixed_transition,
)
from lcm.typing import DiscreteState, FloatND, ScalarIntCoarse and Granular Spellings¶
Two words recur throughout this page, describing how precisely a spelling names its target regimes:
A granular spelling names each target regime explicitly —
{"retired": law_a, "dead": law_b}— so the engine knows exactly which target regime gets what.A coarse spelling is target-regime blind — a bare law, a bare
StochasticTransition— and means “the same thing toward every (reachable) target”.
The canonical form the engine stores is always granular; coarse user spellings are sugar that broadcasts into it. This one rule governs state transitions, regime transitions, and parameters.
Regime Transitions¶
Model(edges=...) declares every regime transition, structure and law. Each source
regime maps to one of
a plain
{target: source_ages}mapping, when it has exactly one destination at every source age — the graph is the law;Transition(targets={target: source_ages, ...}, law=...), when some source age has several destinations; the targets declare which destinations the law may select. A law that names its destinations — a regime name, a per-target dict, or aByAgeof those — lets you omittargets: they are derived from the law, and supplied anyway they must say exactly the same.
A regime with no outgoing edges is terminal. A Transition law is one of:
Regime name — deterministic:
"retired"always moves to that regime.DeterministicTransition(func=selector)— deterministic: the function returns a regime ID supported byModel.edgesat the source age.StochasticTransition(func=probs)(coarse) — stochastic: the function returns a probability vector over all regimes that is zero outsideModel.edges.Per-target dict (granular) — stochastic:
{target_regime: StochasticTransition(func=prob_func)}. Each cell returns that target’s probability, and its keys are the regime’s reachable targets — omitted regimes are structurally unreachable.ByAge(cases=...)— one of the forms above per source age; see Age-indexed regimes. It must select every source age with several destinations; an age with a single destination uses that edge.
Deterministic¶
The function returns an integer regime ID (from the @categorical RegimeId
class). Use this for transitions that depend deterministically on states and
actions — for example, mandatory retirement at a certain age, or a retirement
decision triggered by a discrete action.
Pass the function directly as the law of a Transition:
@categorical(ordered=False)
class RegimeId:
working_life: ScalarInt
retirement: ScalarInt
def next_regime(*, age: float, retirement_age: float) -> ScalarInt:
return jnp.where(age >= retirement_age, RegimeId.retirement, RegimeId.working_life)
deterministic_edges = {
"working_life": Transition(
targets={"working_life": (25, 45), "retirement": (25, 45)},
law=next_regime, # just a plain function — no wrapper needed
)
}Stochastic¶
The function returns a probability array over all regimes. Use this when the regime transition is uncertain — for example, a mortality risk that determines whether the agent survives to the next period.
Wrap the transition function in StochasticTransition:
@categorical(ordered=False)
class RegimeIdMortality:
alive: ScalarInt
dead: ScalarInt
def survival_transition(survival_prob: float) -> FloatND:
"""Return [P(alive), P(dead)]."""
return jnp.array([survival_prob, 1 - survival_prob])
survival_edges = {
"alive": Transition(
targets={"alive": (60, 61), "dead": (60, 61)},
law=StochasticTransition(func=survival_transition),
)
}Internally, deterministic transitions are converted to one-hot probability arrays, so both forms end up in the same format during the solve step.
Per-Target Dict¶
Use a per-target mapping to give separate scalar probability functions to graph
destinations. Each cell is a StochasticTransition-wrapped function returning that target’s
probability; across cells the probabilities must sum to one. The keys are the
structural destinations, at every source age but the last, so the Transition needs no
targets:
edges = {
"working": Transition(
# No return-to-work edge: working is left for good.
law={
"retired": StochasticTransition(func=prob_retired), # P(retired next)
"dead": StochasticTransition(func=prob_dead), # P(dead next)
},
),
}Narrowing reachability is not just documentation: state values are handed across
the regime boundary only toward reachable targets, so graph edges restrict the
carries at the boundary and make structural intent explicit. The law belongs to
the edges, and each cell’s parameters nest under its target there —
prob_retired’s parameters live at template["edges"]["working"]["retired"] (see
Per-target parameters below). An empty per-target law is
rejected — a regime is terminal by having no outgoing edges.
State Transitions¶
The state_transitions dict on a Regime defines how each state variable evolves
over time. Every state in a non-terminal regime must have an entry — except
continuous stochastic processes, which carry their own transitions (see below).
Deterministic Transitions¶
Pass a callable that returns the next-period value:
state_transitions={
"wealth": next_wealth, # callable: (wealth, consumption, ...) -> next_wealth
}Fixed States (fixed_transition)¶
States that don’t change over time get the identity law via
fixed_transition(state_name) — the argument must match the dict key:
from lcm import fixed_transition
state_transitions={
"education": fixed_transition("education"), # education stays fixed
}Stochastic Transitions (StochasticTransition)¶
For states with stochastic transitions (e.g., a Markov chain over health states),
wrap the transition function in StochasticTransition. The function returns a
probability vector over grid points:
from lcm import StochasticTransition
state_transitions={
"health": StochasticTransition(func=health_transition_probs),
}A stochastic law may differ between the phases. Wrapping it in Phased lets the
agent solve under a kernel it perceives while the simulation draws from the
kernel that is true — perceived mortality, perceived health or income risk, a
misread policy rule:
state_transitions={
"health": Phased(
solve=StochasticTransition(func=health_believed),
simulate=StochasticTransition(func=health_true),
),
}See The Phase Grammar for which phase supplies which part of the decision.
Per-Target Dicts¶
When a state’s transition depends on which regime the agent enters next period, use a dict keyed by target regime name:
state_transitions={
"health": {
"working": StochasticTransition(func=health_probs_working),
"retired": StochasticTransition(func=health_probs_retired),
},
}Within a per-target dict, stochasticity must be consistent — all entries must be
StochasticTransition or all must be plain callables (fixed_transition entries
count as plain callables). Per-target dicts are the building block of multi-regime
models; see Cross-regime transitions below for the
full semantics.
Terminal Regimes¶
Terminal regimes (no outgoing edges) must have empty state_transitions — there is
no next period, so no transitions are needed.
Continuous Stochastic Processes¶
Continuous stochastic processes (e.g., NormalIIDProcess, TauchenAR1Process)
derive their own transition probabilities from the discretization scheme, so you
do not specify them in state_transitions — placing one there is an error. See
Continuous stochastic processes for the available process classes.
Where transitions live — and why¶
State transitions come in two kinds, and the kind decides where you specify the transition:
Functional — a
next_<state>function you write yourself. Because you supply the dynamics, the function goes instate_transitions. Deterministic transitions,fixed_transitionentries,StochasticTransition-wrapped stochastic transitions, and per-target dicts are all functional.Parametric — the dynamics follow from a distribution and its parameters. A continuous stochastic process (
NormalIIDProcess,TauchenAR1Process, and the other*Processclasses) derives both its grid points and its transition matrix from that single spec, so the grid and the transition travel together as one object placed instates.
This is why a continuous stochastic process lives in states and never in
state_transitions: its transition is not something you write, it falls out of
the discretization. It is also why there is no DiscreteProcess — a
DiscreteGrid is just a set of categories, and its Markov transition is an
arbitrary function you supply. The two share no common parametrization, so they
stay separate: the grid in states, the StochasticTransition-wrapped function in
state_transitions.
A functional transition is ordinarily the same closure for every period the regime is solved. A policy-dependent law of motion — one whose content changes as the agent ages through calendar time — is written as a plain transition reading an age-specialized helper function, rather than carrying a marker itself; see Policy-dependent laws of motion.
Cross-Regime Transitions¶
A multi-regime model needs five pieces of boundary semantics, and one worked example covers them all:
Reachability comes from
Model.edges. Each destination has a source-age selector. ATransitionlaw supplies behavior on these edges without defining topology.Coverage rule. Every reachable target that carries the state needs a law of motion for it. A bare law broadcasts to all of them; a per-target dict lists them explicitly.
Handing a value into a state the source regime does not carry. An entering law keyed by the target regime computes the target’s state from the source’s variables.
States the target does not carry are simply dropped — no law needed toward that target.
The same state name can change grids across the boundary (a cross-grid law maps source values into the target’s grid), and
fixed_transitionworks inside per-target cells.
The example: agents work, retire (possibly involuntarily), and die. Retirement is absorbing — there is no way back to work. Health is tracked at three levels while working but only two in retirement; job tenure exists only while working; pension points exist only in retirement.
LAST_WORKING_AGE = 61
FORCED_RETIREMENT_AGE = 62
MAX_AGE = 63
@categorical(ordered=False)
class RegimeIdRetirement:
work: ScalarInt
retired: ScalarInt
dead: ScalarInt
@categorical(ordered=True)
class HealthWork:
bad: ScalarInt
fair: ScalarInt
good: ScalarInt
@categorical(ordered=True)
class HealthRetired:
bad: ScalarInt
good: ScalarInt
def utility_work(*, consumption: float, health: DiscreteState) -> FloatND:
return jnp.log(consumption) + 0.1 * health
def utility_retired(
*, consumption: float, health: DiscreteState, pension_points: float
) -> FloatND:
return jnp.log(consumption) + 0.05 * health + 0.1 * pension_points
def next_wealth(*, wealth: float, consumption: float) -> float:
return (wealth - consumption) * 1.05
def consumption_feasible(*, consumption: float, wealth: float) -> bool:
return consumption <= wealth
def coarsen_health(health: DiscreteState) -> ScalarInt:
"""Map three working-life health levels onto the two retirement levels."""
return jnp.where(health == HealthWork.good, HealthRetired.good, HealthRetired.bad)
def accrue_pension_points(job_tenure: float) -> FloatND:
"""Convert job tenure into pension points on entering retirement."""
return jnp.clip(0.1 * job_tenure, 0.0, 3.0)
def grow_job_tenure(job_tenure: float) -> float:
return job_tenure + 1.0work = Regime(
states={
"wealth": LinSpacedGrid(start=1.0, stop=100.0, n_points=10),
"health": DiscreteGrid(category_class=HealthWork),
"job_tenure": LinSpacedGrid(start=0.0, stop=40.0, n_points=5),
},
state_transitions={
# Coverage (2): a bare law broadcasts to every reachable target that
# carries the state — here `work` and `retired` (dead carries nothing).
"wealth": next_wealth,
# Cross-grid + fixed_transition in cells (5): identity toward work,
# a 3-to-2 category map toward retirement.
"health": {
"work": fixed_transition("health"),
"retired": coarsen_health,
},
# Dropping (4): only work carries job_tenure — no law toward `retired`
# or `dead` is needed; the state is dropped at the boundary.
"job_tenure": {"work": grow_job_tenure},
# Entering law (3): work does not carry pension_points; the law keyed
# by `retired` hands a value into the target's state.
"pension_points": {"retired": accrue_pension_points},
},
actions={"consumption": LinSpacedGrid(start=1.0, stop=10.0, n_points=5)},
constraints={"consumption_feasible": consumption_feasible},
functions={"utility": utility_work},
)
retired = Regime(
states={
"wealth": LinSpacedGrid(start=1.0, stop=100.0, n_points=10),
"health": DiscreteGrid(category_class=HealthRetired),
"pension_points": LinSpacedGrid(start=0.0, stop=3.0, n_points=4),
},
state_transitions={
"wealth": next_wealth,
"health": fixed_transition("health"),
"pension_points": fixed_transition("pension_points"),
},
actions={"consumption": LinSpacedGrid(start=1.0, stop=10.0, n_points=5)},
constraints={"consumption_feasible": consumption_feasible},
functions={"utility": utility_retired},
)
# No edge leaves `dead`, so it is terminal.
dead = Regime(functions={"utility": lambda: 0.0})
edges = {
# All three destinations from work at 60; at the last working age, only
# retirement and death remain.
"work": Transition(
targets={"work": 60, "retired": (60, 61), "dead": (60, 61)},
law=ByAge.until(
stop_age_exclusive=FORCED_RETIREMENT_AGE,
law={
"work": StochasticTransition(func=lambda: jnp.asarray(0.6)),
"retired": StochasticTransition(func=lambda: jnp.asarray(0.3)),
"dead": StochasticTransition(func=lambda: jnp.asarray(0.1)),
},
then={
"retired": StochasticTransition(func=lambda: jnp.asarray(0.8)),
"dead": StochasticTransition(func=lambda: jnp.asarray(0.2)),
},
),
),
# No retired-to-work edge — returning to work is structurally impossible,
# so no laws of motion toward work are ever required in `retired`.
"retired": Transition(
targets={"retired": (60, 61), "dead": (60, 61, 62)},
law=ByAge.until(
stop_age_exclusive=MAX_AGE,
law={
"retired": StochasticTransition(func=lambda: jnp.asarray(0.9)),
"dead": StochasticTransition(func=lambda: jnp.asarray(0.1)),
},
then={"dead": StochasticTransition(func=lambda: jnp.asarray(1.0))},
),
),
}
model = Model(
regimes={"work": work, "retired": retired, "dead": dead},
ages=AgeGrid(start=60, inclusive_stop=63, step="Y"),
edges=edges,
initial_nodes=((60, "work"),),
regime_id_class=RegimeIdRetirement,
)Solving and simulating shows the boundary semantics at work: health is coarsened from three categories to two on retirement, pension points appear there computed from job tenure, and job tenure disappears.
result = model.simulate(
params={"discount_factor": 0.95},
initial_conditions={
"age": jnp.array([60.0, 60.0]),
"wealth": jnp.array([30.0, 80.0]),
"health": jnp.array([HealthWork.fair, HealthWork.good]),
"job_tenure": jnp.array([10.0, 30.0]),
"regime_id": jnp.array([RegimeIdRetirement.work] * 2),
},
log_level="warning",
)
df = result.to_dataframe()
df[
[
"period",
"subject_id",
"regime_name",
"wealth",
"health",
"job_tenure",
"pension_points",
]
]Reading the output:
healthtakes the working-life labels (fair,good) while inworkand the coarsened retirement labels (bad,good) after the cross-grid law fires at the boundary.pension_pointsis empty during work (the regime does not carry it) and is seeded by the entering lawaccrue_pension_pointson retirement.job_tenuregrows while working and is dropped on retirement — no law towardretiredexists, and none is required.
Per-Target Parameters¶
The params template mirrors how you spelled each transition. A bare state law keeps a single entry under its own name; a per-target state law nests its parameters under the target regime’s name — the target is a genuine level of the params tree. Per-target state laws belong to the source regime, so their parameters stay under it:
template = model.get_params_template()
# A bare state law's params: template["work"]["next_wealth"]
# A per-target state law's params: template["work"]["retired"]["next_wealth"]State-law values can be supplied at four levels, most to least specific:
Target level —
{"work": {"retired": {"next_wealth": {"exit_tax": 0.1}}}}: a value for that target’s law only.Function level —
{"work": {"next_wealth": {"exit_tax": 0.1}}}: one value broadcasts over all targets of the law.Regime level —
{"work": {"exit_tax": 0.1}}: propagates to every function in the regime needingexit_tax.Model level —
{"exit_tax": 0.1}: propagates to all regimes.
The regime-transition law belongs to the edges, not to the source regime. Its
parameters sit at their declaration path under template["edges"]:
# A law over all targets: template["edges"]["work"]
# A per-target cell of the law: template["edges"]["work"]["retired"]A law over all targets (a plain function, DeterministicTransition or a full-vector
StochasticTransition) is evaluated once and shared across targets, so its parameters
sit directly under the source. Values resolve from that path, from
{"edges": {"work": {"exit_rate": 0.1}}} for every edge callable of work, or from the
model level. A regime-level value such as {"work": {"exit_rate": 0.1}} never reaches
the law.
Each parameter must be supplied at exactly one level — multi-level specifications are rejected as ambiguous. See Edge parameter paths.
See Also¶
Regimes — regime anatomy, terminal vs non-terminal
Age-indexed regimes —
ByAgeschedules and the declared startsGrids — outcome-space definitions for state and action variables
Continuous stochastic processes — grids with built-in transitions
The Phase Grammar — perceived versus realized transitions