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.

Debugging

Dynamic programming models are complex, and most computation happens inside JIT-compiled functions. This page covers practical strategies for diagnosing problems.

Disable JIT for readable tracebacks

By default, pylcm JIT-compiles internal functions for performance. When something goes wrong inside a JIT-compiled function, the traceback is often unhelpful. Disable JIT at model creation time to get standard Python tracebacks:

model = Model(
    regimes={...},
    ages=ages,
    regime_id_class=RegimeId,
    edges=edges,
    initial_nodes=initial_nodes,
    enable_jit=False,  # readable tracebacks, but slower
)

This does not affect correctness --- the same functions run, just without compilation. Without compilation no compiler reports a workspace, so the solve refuses a device-memory budget, including the default one GPUs and TPUs derive from their pool limit. Pass ExecutionConfig(device_memory_bytes=None) alongside enable_jit=False there. Re-enable JIT once the issue is resolved.

Log levels

log_level controls console output. runtime_checks=True independently enables numerical validation and raises on its first failure, including with log_level="off". Use debug output to inspect timing and value statistics; keep runtime checks enabled while diagnosing a model. "debug" also counts and logs exterior next states on continuous grids whose off_grid rule is not "raise"; the other levels skip these count-only sweeps (see Grids). Construction, parameter schema and result-identity checks remain active even when numerical checks are explicitly disabled.

solution = model.solve(params=params, log_level="off", runtime_checks=True)
solution = model.solve(params=params, log_level="debug", log_path="./debug/")

Checks evaluate computed transition probabilities and NaN values before accepting a result. runtime_checks=False selects the numerical fast path at any verbosity. Applications with stronger finite-value requirements must retain their own infinity invariant. See Solving and Simulating for the independent output and validation controls and snapshot behavior.

A DC-EGM or NEGM regime solved with ExactEnvelope can also publish NaN without any of the usual causes. Where more than max_runs candidate runs cover one resource cell, the exact envelope poisons that row with NaN rather than publish a guess, often leaving the whole value function NaN. The error then names the regime’s capacity; raise it with ExactEnvelope(max_runs=...) (see Envelopes).

GridSearch simulation always raises lcm.exceptions.NoFeasibleActionError when an owned subject reaches a state with no feasible action, including with output and runtime checks disabled. The error names the regime, period and realized state. Solve may retain such states because they can be unreachable; warning output counts them per regime and period, separately from other infinite values. A feasible action whose value is negative infinity is not an empty action set. Collective counts refer to household decisions, and valid dissolution routes retain their fallback behavior. GridSearch state domains must fit its native int32 counters.

Check transition laws without a solve

While assembling a calibration, run the same transition preflight as a checked solve:

model.validate_transitions(params=params)

This returns None on success and raises the first failing law’s solve diagnostic. It checks regime probabilities, stochastic state probabilities and joint supports and weights across active periods and phase grids. It does not evaluate utilities or run backward induction, and does not check general deterministic next-state finiteness.

Diagnose with checks enabled

Keep runtime_checks=True during development and production validation. An explicit False trades those numerical checks for speed and can return invalid values or trajectories. Reproduce a failing parameter vector with checks enabled; debug logging adds value statistics, timings and core plan records without changing the verdict.

Core plan records

A compiled solve at "debug" writes one core plan record line per solved regime, period and core, after the widths are chosen. Each is a JSON object, and the log record also carries it as its core_plan_record attribute. It names:

The records are built from planning results and compile-time reports; writing them synchronizes no device. Gathered bytes and compiled collectives are what was planned and emitted, not observed communication, which only a profiler measures. Release and donation events are logged at "debug" too, each naming the artifact and the dispatch that closed it. benchmarks/warm_solve_phases.py --plan-records <file> collects the records of a run.

Debug snapshots

When log_path is provided, pylcm saves a snapshot directory containing all inputs and outputs, so you can reconstruct a failed run on a different machine. In "debug" mode a snapshot is written on every solve and on a raised failure; in "warning" / "progress" mode one is written whenever a warned failure leaves NaN in the value function.

What’s saved

Each snapshot is a directory (e.g. solve_snapshot_001/) containing:

FileContents
arrays.h5Value function arrays in HDF5 (datasets at /V_arr/{period}/{regime})
model.pklThe Model instance (cloudpickle)
params.pklUser parameters (cloudpickle)
initial_conditions.pklInitial state arrays and regime codes (simulate only)
result.pklSimulationResult (simulate only)
metadata.jsonSnapshot type, platform string, field manifest
pixi.lockLock file from the project root
pyproject.tomlProject file from the project root
REPRODUCE.mdStep-by-step reconstruction recipe

Creating snapshots

# Solve snapshot
solution = model.solve(params=params, log_level="debug", log_path="./debug/")
# Creates: ./debug/solve_snapshot_001/

# Simulate snapshot (with a pre-solved complete result)
result = model.simulate(
    params=params,
    initial_conditions=initial_conditions,
    solution=solution,
    log_level="debug",
    log_path="./debug/",
)
# Creates: ./debug/simulate_snapshot_001/

# Simulate snapshot (solving automatically)
result = model.simulate(
    params=params,
    initial_conditions=initial_conditions,
    log_level="debug",
    log_path="./debug/",
)
# Creates: ./debug/simulate_snapshot_001/

Loading snapshots

from lcm import load_snapshot

# Load the full snapshot
snapshot = load_snapshot("./debug/solve_snapshot_001")
snapshot.model  # the Model instance
snapshot.params  # the user parameters
snapshot.period_to_regime_to_V_arr  # value function arrays (loaded from HDF5)

# Re-run the solve to reproduce the result
solution = snapshot.model.solve(params=snapshot.params, log_level="debug")

For large snapshots, skip fields you don’t need:

# Load without the (potentially large) value function arrays
snapshot = load_snapshot(
    "./debug/solve_snapshot_001", exclude=["period_to_regime_to_V_arr"]
)
snapshot.period_to_regime_to_V_arr  # None
snapshot.model  # still available

Platform mismatch

Each snapshot records the platform it was created on (e.g. x86_64-Linux). When loading on a different platform, a warning is emitted:

WARNING  Snapshot created on x86_64-Linux but loading on arm64-Darwin
         — environment may not match

To reproduce the environment exactly, use the bundled lock file:

cp ./debug/solve_snapshot_001/pixi.lock .
cp ./debug/solve_snapshot_001/pyproject.toml .
pixi install --frozen

Snapshot retention

Snapshots accumulate when running inside an optimization loop. The log_keep_n_latest parameter (default 3) limits how many snapshot directories are kept per type:

solution = model.solve(
    params=params, log_level="debug", log_path="./debug/", log_keep_n_latest=5
)

After each write, the oldest directories beyond the limit are deleted automatically.

Recipe: Debugging NaN in parameter estimation with optimagic

A common scenario: you are estimating model parameters with optimagic, and at some iteration the criterion function returns NaN. Here is how to diagnose the problem.

1. Enable optimagic logging

import optimagic as om

result = om.minimize(
    fun=criterion,
    params=start_params,
    algorithm="scipy_lbfgsb",
    logging="my_log.db",
)

2. Find the problematic parameters

reader = om.SQLiteLogReader("my_log.db")
history = reader.read_history()

# history["fun"] contains criterion values, history["params"] the parameter vectors
import numpy as np

fun_values = history["fun"]
nan_mask = np.isnan(fun_values)
if nan_mask.any():
    first_nan_idx = np.argmax(nan_mask)
    bad_params = history["params"].iloc[first_nan_idx]
    print(f"First NaN at iteration {first_nan_idx}")
    print(f"Parameters: {bad_params}")

3. Re-run with JIT disabled

# Re-create the model without JIT
model = Model(
    regimes={...},
    ages=ages,
    regime_id_class=RegimeId,
    edges=edges,
    initial_nodes=initial_nodes,
    enable_jit=False,
)

# Call solve with the bad parameters --- the traceback will be readable
solution = model.solve(params=bad_params, log_level="debug")

The traceback now points to the exact line in your user-defined functions where the NaN originates.

Inspecting value function arrays

The values field of SolutionResult is a nested mapping: period -> regime_name -> array. You can iterate over it to check shapes, look for NaN/inf, or plot slices:

import jax.numpy as jnp
import plotly.graph_objects as go
from plotly.subplots import make_subplots

solution = model.solve(params=params, log_level="debug")
value_functions = solution.values

# Check for issues
for period, regimes in value_functions.items():
    for regime_name, V_arr in regimes.items():
        n_nan = int(jnp.sum(jnp.isnan(V_arr)))
        n_inf = int(jnp.sum(jnp.isinf(V_arr)))
        if n_nan > 0 or n_inf > 0:
            print(
                f"Period {period}, regime '{regime_name}': "
                f"shape={V_arr.shape}, NaN={n_nan}, Inf={n_inf}"
            )

# Plot a 1D slice (e.g. value over wealth grid for first period)
period = 0
regime_name = "working"
V_arr = value_functions[period][regime_name]

fig = go.Figure()
fig.add_trace(go.Scatter(y=V_arr.tolist(), mode="lines", name="V(wealth)"))
fig.update_layout(title=f"Value function, period {period}, regime '{regime_name}'")
fig.show()

Failure snapshots

When log_path is set and solve() raises InvalidValueFunctionError (in "debug" mode), a snapshot is saved automatically. This lets you inspect the partial solution (value functions for periods that completed before the error) on another machine.

# log_path is enough to get a failure snapshot
result = model.simulate(
    params=params,
    initial_conditions=initial_conditions,
    log_level="debug",
    log_path="./debug/",
)

NaN diagnostics

When the solver detects NaN in the value function, it reports which intermediate is the source. The error message includes a diagnostic summary like:

Diagnostics for regime 'working' at age 55:
  F: 0.9500 feasible
  Among feasible state-action pairs:  U: 0.0000 NaN  |  E[V]: 0.3200 NaN
  Regime probs: working: 0.8500 | retired: 0.1500
  E[V] NaN fraction by state (among feasible state-action pairs):
    wealth                   [0.00, 0.00, 0.12, 0.45, 0.80, 0.95, 1.00, 1.00, 1.00, 1.00]
    health                   [0.00, 0.64]

This tells you:

The diagnostic functions are compiled lazily --- only when NaN is detected. There is no compilation overhead in the normal (no-NaN) solve path.

Understanding error messages

pylcm raises specific exceptions to help you diagnose problems. Every one of them lives in lcm.exceptions and derives from lcm.exceptions.PyLCMError, so except PyLCMError catches anything pylcm itself raises:

from lcm.exceptions import ExecutionPlanningError, PyLCMError

Model definition

Parameters, inputs, and results

Planning and execution

Numerical validation

Persistence

Inspect the resolved execution plan

model.simulate(...) reports the execution plan it actually dispatched: the forward route, the ordered subject devices and their backend, the resolved planner axis widths by regime, the outer chunk count and admitted chunk widths, and the budget mode with its effective device-memory bytes. A one-line summary logs at log_level="progress" and "debug", and the complete record at "debug" only.

Read it when a run is slower or larger than expected but raises nothing: it is the only statement of which route engaged, at which widths, and against which budget. The same record is available on the result as SimulationResult.plan_summary, so a batch job can keep it without keeping the log. It is None for a result read back with SimulationResult.load. See Runtime, results, and persistence for the exact contract.

See also