Benchmark Consumption Models

A guided tour of skagent.models.benchmarks. Consumption economics asks how a household should split each period’s resources between spending now and saving for later. The closed-form policies in this registry are the classic answers, and they retrace the theory in roughly the order the field discovered it. Each model teaches a fact the previous ones could not. Reading from top to bottom:

  1. Finite horizons fade. Once the distance to the terminal date \(T - t\) is large, the finite-horizon rule is indistinguishable from the infinite-horizon one.

  2. Mortality erodes patience. A survival probability below one acts like extra impatience: it scales the discount factor and pushes up the marginal propensity to consume (MPC), the fraction of an extra dollar of wealth that is spent rather than saved.

  3. Consumption is a martingale. Under \(\beta R = 1\), the change in consumption is the fundamental object, not its level. Income shocks of standard deviation \(\sigma_\eta\) produce consumption changes of standard deviation \((r/R)\,\sigma_\eta\) only, a factor of \(R/r \approx 34\) smaller at this calibration.

  4. Normalization collapses the state. Dividing every level variable by permanent income turns a 2-D Bellman problem into a 1-D one. This trick is what makes neural-network solvers practical for richer models.

  5. Closed forms run out. When no normalization saves you, the model has no closed-form policy, and the registry keeps it around for limit-checking.

The short registry keys in the code and figure labels below (D-1 through U-3) are internal identifiers; the section titles give the names the models actually go by.

This example is the runnable companion to Benchmark Models. The code is intentionally verbose; production code should compose helpers, but here every step is written out so the reader can follow the algebra.

Notation

Periods are indexed by \(t = 0, 1, 2, \ldots\), and the symbols below carry fixed meaning throughout every model on this page:

  • \(A_{t-1}\): beginning-of-period assets (the arrival state, before interest);

  • \(R = 1 + r > 1\): gross return on assets, with net rate \(r\);

  • \(y_t\): non-capital income realized in period \(t\);

  • \(m_t = R\, A_{t-1} + y_t\): cash-on-hand (market resources);

  • \(c_t\): consumption, the control;

  • \(A_t = m_t - c_t\): end-of-period assets;

  • \(H_t = \mathbb{E}_t \sum_{s \ge 1} R^{-s}\, y_{t+s}\): human wealth, the present value of expected future income;

  • \(W_t = m_t + H_t\): total wealth;

  • \(u(c)\): period utility;

  • \(\beta\): the discount factor.

The normalized models (U-2, U-3) additionally use lowercase \(m, c, a\) for ratios to permanent income \(P_t\).

References

  • Hall, R.E. (1978). Stochastic implications of the life cycle-permanent income hypothesis. Journal of Political Economy 86(6), 971-987.

  • Blanchard, O.J. (1985). Debt, deficits, and finite horizons. Journal of Political Economy 93(2), 223-247.

  • Carroll, C.D. (2024). Solution Methods for Solving Microeconomic Dynamic Stochastic Optimization Problems. https://llorracc.github.io/SolvingMicroDSOPs/

import matplotlib.pyplot as plt
import numpy as np
import torch

from skagent.models.benchmarks import (
    EPS_VALIDATION,
    get_analytical_policy,
    get_benchmark_calibration,
    has_analytical_policy,
    list_benchmark_models,
    validate_analytical_solution,
)

Step 1: What’s in the Registry

Five of the seven entries carry an analytical policy that validate_analytical_solution checks against the standard test grid. U-3 is registered without one because the borrowing constraint plus uncertainty break the linearity that every other entry relies on, and D-4 lacks one because its binding borrowing constraint forecloses a closed form (its oracle is the value-function-iteration reference policy from get_reference_policy()). The helper still reports both as FAILED because no analytical policy was found, not because anything is wrong with the models.

The registry itself records which models have a closed form, so the distinction is queried with has_analytical_policy() rather than re-derived here.

for model_id, description in list_benchmark_models().items():
    marker = "closed form" if has_analytical_policy(model_id) else "numerical only"
    result = validate_analytical_solution(
        model_id, test_points=20, tolerance=EPS_VALIDATION
    )
    print(f"  {model_id} ({marker:14s}): {result['validation']:6s}  {description}")
D-1 (closed form   ): PASSED  D-1: Finite horizon log utility
D-2 (closed form   ): PASSED  D-2: Infinite horizon CRRA perfect foresight
D-3 (closed form   ): PASSED  D-3: Blanchard discrete-time mortality
U-1 (closed form   ): PASSED  U-1: Hall random walk consumption
U-2 (closed form   ): PASSED  U-2: Log utility (normalized), no borrowing constraint, σ_ψ=0
U-3 (numerical only): FAILED  U-3: Buffer stock model (normalized) with CRRA=2, permanent + transitory shocks
D-4 (numerical only): FAILED  D-4: Deterministic CRRA with binding borrowing constraint (no closed form)

Step 2: Finite Horizons Fade Away (D-1 → D-2)

The model. D-1 is finite-horizon log utility. With a deterministic terminal date \(T\), the agent solves

\[V_T(W_T) = \log W_T, \qquad V_t(W_t) = \max_{0 < c_t \le W_t}\; \log c_t + \beta\, V_{t+1}\!\bigl((W_t - c_t)\, R\bigr),\]

and the first-order condition yields the remaining-horizon decision rule

\[c_t = \frac{1 - \beta}{1 - \beta^{\,T - t}}\; W_t .\]

Lesson: A 30-year-old human has an almost infinite-horizon MPC. That is why infinite-horizon models survive as a baseline despite being obviously unrealistic.

D-1’s remaining-horizon MPC is \((1-\beta) / (1 - \beta^{T-t})\). It is above the infinite-horizon constant \((1-\beta)\) for any finite horizon and decays geometrically as \(T - t \to \infty\). Holding wealth fixed at \(W = 5\) and sweeping \(T\), the gap is already below 1% of the limit by \(T = 30\).

beta_d1 = get_benchmark_calibration("D-1")["DiscFac"]
W_fixed = 5.0
horizons = np.arange(1, 81)
# Evaluate the *registered* D-1 policy at fixed wealth W = 5 for each
# horizon T (with t = 0, so the remaining horizon is T). Stacking the
# per-horizon outputs reproduces the remaining-horizon decay while
# exercising the same d1_analytical_policy the test suite validates.
d1_policy = get_analytical_policy("D-1")
d1_calibration = get_benchmark_calibration("D-1")
c_finite = np.array(
    [
        float(
            d1_policy({"W": W_fixed, "t": 0}, {}, {**d1_calibration, "T": int(T)})["c"]
        )
        for T in horizons
    ]
)
c_infinite = (1 - beta_d1) * W_fixed

fig, ax = plt.subplots(figsize=(8, 5))
ax.plot(horizons, c_finite, label="D-1 (finite horizon)", linewidth=2)
ax.axhline(
    c_infinite,
    linestyle="--",
    color="C1",
    linewidth=2,
    label=rf"D-2 limit $(1-\beta)\, W = {c_infinite:.2f}$",
)
ax.set_xlabel("Horizon $T$", fontsize=11)
ax.set_ylabel("Optimal consumption at $W = 5$", fontsize=11)
ax.set_title(
    "Finite-Horizon Consumption Decays to the Infinite-Horizon Rule", fontsize=12
)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
fig.tight_layout()
Finite-Horizon Consumption Decays to the Infinite-Horizon Rule

Step 3: Mortality Erodes Patience (D-3)

The models. D-2 is the infinite-horizon CRRA workhorse plotted as the limit above. It solves

\[\max_{\{c_t\}}\; \mathbb{E}_0 \sum_{t=0}^{\infty} \beta^t\, \frac{c_t^{1-\sigma}}{1-\sigma} \quad\text{s.t.}\quad m_t = R\, A_{t-1} + y,\;\; A_t = m_t - c_t,\]

and its closed form is linear in total wealth,

\[c_t = \kappa\, W_t, \qquad \kappa = \frac{R - (\beta R)^{1/\sigma}}{R}, \qquad W_t = m_t + H, \qquad H = \frac{y}{r}.\]

Here \(\sigma\) is the coefficient of relative risk aversion and \(\kappa\) the constant MPC out of total wealth \(W_t\).

D-3 adds an i.i.d. survival probability \(s \in (0, 1]\): an agent alive today reaches tomorrow with probability \(s\), so it discounts the future by \(s\beta\) in place of \(\beta\). The identical algebra then gives \(\kappa_s = (R - (s\beta R)^{1/\sigma})/R\).

Lesson: I.i.d. mortality risk \(s\) is observationally equivalent to scaling the discount factor from \(\beta\) to \(s\beta\). The MPC \(\kappa_s = (R - (s\beta R)^{1/\sigma})/R\) strictly exceeds the no-mortality MPC \(\kappa\), and the wedge widens as \(s\) falls.

Sweeping \(s \in \{1.0, 0.95, 0.9, 0.8, 0.7\}\) (median lifetime falling from infinity to about 2 periods) makes the wedge visible. At \(s = 0.7\) the agent consumes nearly six times more per dollar of total wealth than at \(s = 1\). Empirical annual mortality at age 30 is around \(s = 0.999\), which is essentially indistinguishable from the no-mortality limit at this scale, but life-cycle models that aggregate over many decades pick up a measurable mortality wedge.

shared = get_benchmark_calibration("D-2")
a_grid = torch.linspace(0.0, 6.0, 121)
m_grid_np = (a_grid * shared["R"] + shared["y"]).numpy()


def _kappa(beta_eff: float) -> float:
    """MPC out of total wealth at effective discount factor ``beta_eff``."""
    return (shared["R"] - (beta_eff * shared["R"]) ** (1 / shared["CRRA"])) / shared[
        "R"
    ]


fig, ax = plt.subplots(figsize=(8, 5))
for s in [1.0, 0.95, 0.9, 0.8, 0.7]:
    if s == 1.0:
        c = get_analytical_policy("D-2")({"a": a_grid}, {}, shared)["c"]
        label = rf"D-2: $s = 1.00$, $\kappa\;\,= {_kappa(shared['DiscFac']):.4f}$"
    else:
        c = get_analytical_policy("D-3")(
            {"a": a_grid}, {}, {**shared, "SurvivalProb": s}
        )["c"]
        label = rf"D-3: $s = {s:.2f}$, $\kappa_s = {_kappa(s * shared['DiscFac']):.4f}$"
    ax.plot(m_grid_np, c.numpy(), label=label, linewidth=2)

ax.set_xlabel("Cash-on-hand $m_t$", fontsize=11)
ax.set_ylabel("Optimal consumption $c_t$", fontsize=11)
ax.set_title("Mortality Risk Visibly Increases the MPC", fontsize=12)
ax.legend(fontsize=9, loc="upper left")
ax.grid(True, alpha=0.3)
fig.tight_layout()
Mortality Risk Visibly Increases the MPC

Step 4: Hall’s Martingale (U-1)

The model. U-1 pairs quadratic utility with the neutral discount condition \(\beta R = 1\) and a stochastic income stream:

\[u(c) = a\, c - \tfrac{b}{2}\, c^2, \qquad \beta R = 1, \qquad m_t = R\, A_{t-1} + y_t ,\]

for utility constants \(a, b > 0\). Under \(\beta R = 1\) the Euler equation collapses to the martingale property \(\mathbb{E}_t[c_{t+1}] = c_t\), and the decision rule consistent with it (plus a transversality condition ruling out explosive borrowing) is the permanent-income annuity rule

\[c_t = \frac{r}{R}\,(m_t + H), \qquad H = \frac{\mathbb{E}_t\, y}{r}.\]

Lesson: Hall’s contribution wasn’t the level of consumption. It was the prediction that, under \(\beta R = 1\), consumption changes are unforecastable from period-\(t\) information, and that the standard deviation of those changes is much smaller than the standard deviation of income.

We simulate U-1 forward with 1000 agents under the analytical policy. The left panel shows one agent’s income (volatile, mean-reverting) against her consumption (smooth, slowly drifting): the textbook image of consumption smoothing. The right panel overlays the histograms of income innovations \(\eta_t\) and consumption changes \(\Delta c_t\). Both are mean-zero, but \(\Delta c_t\) is concentrated near zero while \(\eta_t\) spreads out by a factor of about \(R/r \approx 34\). The closed-form prediction \(\sigma_{\Delta c} = (r/R)\,\sigma_\eta\) drops out exactly because the agent has substituted saving for consumption volatility. Empirical PIH tests are precisely tests of whether this picture survives in real data.

torch.manual_seed(42)
n_agents = 1000
T_sim = 60
calib_u1 = get_benchmark_calibration("U-1")
beta_u1 = calib_u1["DiscFac"]
R_u1 = calib_u1["R"]
sigma_eta = calib_u1["income_std"]
y_mean = calib_u1["y_mean"]
r_u1 = R_u1 - 1
H_u1 = y_mean / r_u1

A_state = torch.zeros(n_agents)
c_paths = torch.zeros(T_sim, n_agents)
y_paths = torch.zeros(T_sim, n_agents)
for t in range(T_sim):
    eta = sigma_eta * torch.randn(n_agents)
    y = y_mean + eta
    m = R_u1 * A_state + y
    c = (r_u1 / R_u1) * (m + H_u1)
    A_state = m - c
    c_paths[t] = c
    y_paths[t] = y

dc = (c_paths[1:] - c_paths[:-1]).flatten()
eta_realized = (y_paths - y_mean).flatten()
theoretical_dc_std = (r_u1 / R_u1) * sigma_eta
empirical_dc_std = float(dc.std())
empirical_eta_std = float(eta_realized.std())

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

axes[0].plot(
    y_paths[:, 0].numpy(),
    label=rf"income $y_t$ (std $\approx {empirical_eta_std:.3f}$)",
    color="C0",
    alpha=0.7,
    linewidth=1.5,
)
axes[0].plot(
    c_paths[:, 0].numpy(),
    label=rf"consumption $c_t$ (std $\approx {float(c_paths[:, 0].std()):.3f}$)",
    color="C3",
    linewidth=2,
)
axes[0].axhline(y_mean, linestyle=":", color="k", alpha=0.5, linewidth=1)
axes[0].set_xlabel("Period $t$", fontsize=11)
axes[0].set_ylabel("Level", fontsize=11)
axes[0].set_title("One Agent: Income Volatile, Consumption Smooth", fontsize=12)
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)

bins = np.linspace(-0.4, 0.4, 60)
axes[1].hist(
    eta_realized.numpy(),
    bins=bins,
    density=True,
    color="C0",
    alpha=0.5,
    label=rf"income shock $\eta_t$, std$\,= {empirical_eta_std:.3f}$",
)
axes[1].hist(
    dc.numpy(),
    bins=bins,
    density=True,
    color="C3",
    alpha=0.8,
    label=rf"$\Delta c_t$, std$\,= {empirical_dc_std:.4f}$",
)
axes[1].axvline(0, color="k", linestyle="--", linewidth=1, alpha=0.5)
axes[1].set_xlabel("Innovation magnitude", fontsize=11)
axes[1].set_ylabel("Density", fontsize=11)
axes[1].set_title(
    rf"Theory: $\sigma_{{\Delta c}} = (r/R)\,\sigma_\eta = {theoretical_dc_std:.4f}$",
    fontsize=12,
)
axes[1].legend(fontsize=9)
axes[1].grid(True, alpha=0.3)
fig.tight_layout()
One Agent: Income Volatile, Consumption Smooth, Theory: $\sigma_{\Delta c} = (r/R)\,\sigma_\eta = 0.0029$

Step 5: Normalization Collapses the State Space (U-2)

The model. U-2 is log utility with permanent income shocks, written in variables normalized by permanent income \(P_t\) (lowercase \(m = M/P\), \(c = C/P\), \(a = A/P\)):

\[u(c) = \log c, \qquad a_t = m_t - c_t, \qquad m_{t+1} = \frac{R\, a_t}{\psi_{t+1}} + \theta_{t+1},\]

where \(\psi\) is the permanent shock and \(\theta\) normalized transitory income (\(\mathbb{E}[\theta] = 1\)). With normalized human wealth \(h = 1/r\), the closed form is the permanent-income line

\[c = (1 - \beta)\,(m + 1/r).\]

Lesson: A clever change of variables can turn an \(n\)-dimensional state space into an \((n-1)\)-dimensional one. For U-2 the trick is dividing every level by permanent income \(P_t\), which removes \(P_t\) from the state entirely. A neural-network solver that would have needed to learn a 2-D function \(C(M, P)\) now only has to learn a 1-D function \(c(m)\), an enormous reduction in sample complexity.

Left panel: the level rule \(C = (1-\beta)(M + P/r)\) is a family of parallel lines, one per \(P\). Right panel: the same four policies in normalized variables \((m, c) = (M/P, C/P)\) collapse onto the single curve \(c = (1-\beta)(m + 1/r)\). The four line-styles all trace the same curve; the visual coincidence is the state-space collapse.

calib_u2 = get_benchmark_calibration("U-2")
beta_u2 = calib_u2["DiscFac"]
R_u2 = calib_u2["R"]
r_u2 = R_u2 - 1

P_values = [0.5, 1.0, 1.5, 2.0]
linestyles = ["-", "--", ":", "-."]
M_grid = torch.linspace(0.0, 4.0, 81)

fig, axes = plt.subplots(1, 2, figsize=(12, 5))
for P, ls in zip(P_values, linestyles):
    C = (1 - beta_u2) * (M_grid + P / r_u2)
    axes[0].plot(
        M_grid.numpy(), C.numpy(), label=f"$P = {P}$", linewidth=2, linestyle=ls
    )
axes[0].set_xlabel("Level cash-on-hand $M$", fontsize=11)
axes[0].set_ylabel("Level consumption $C$", fontsize=11)
axes[0].set_title("Level Variables: 2-D Family", fontsize=12)
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)

for P, ls in zip(P_values, linestyles):
    m = M_grid / P
    c = (1 - beta_u2) * (m + 1 / r_u2)
    axes[1].plot(m.numpy(), c.numpy(), label=f"$P = {P}$", linewidth=2, linestyle=ls)
axes[1].set_xlabel("Normalized cash-on-hand $m = M/P$", fontsize=11)
axes[1].set_ylabel("Normalized consumption $c = C/P$", fontsize=11)
axes[1].set_title("Normalized: All Four Lines Coincide", fontsize=12)
axes[1].legend(fontsize=10, title="all overlap")
axes[1].grid(True, alpha=0.3)
fig.tight_layout()
Level Variables: 2-D Family, Normalized: All Four Lines Coincide

Step 6: When the Closed Form Breaks (U-3)

The model. U-3 is the Carroll buffer-stock problem: U-2’s normalized dynamics with CRRA utility (\(\gamma > 1\)), genuine permanent and transitory shocks, and a binding borrowing constraint \(c \le m\),

\[V(m) = \max_{0 < c \le m}\; \frac{c^{1-\gamma}}{1-\gamma} + \beta\, \mathbb{E}\!\left[V\!\left( \frac{R\,(m - c)}{\psi'} + \theta' \right)\right].\]

The constraint together with income uncertainty breaks the linearity that every other entry relies on, so U-3 has no closed-form policy; only its limiting MPC properties are known (the MPC stays in \((0, 1)\), decreases in wealth, and converges to D-2’s \(\kappa\) as \(m \to \infty\)).

Lesson: U-3 is U-2 plus a binding borrowing constraint \(c \leq m\). The U-2 closed form still satisfies the Euler equation everywhere, but it violates the constraint at low \(m\), because at \(m = 0\) it prescribes \(c = (1-\beta)/r > 0\) (the agent wants to borrow against future income). Below the intersection of the PIH line with the constraint \(c = m\), the U-2 policy is infeasible. Above the intersection, it is feasible but suboptimal, because the U-3 agent has precautionary saving motives that U-2 lacks. The actual U-3 policy bends below the PIH line at moderate \(m\) and approaches D-2’s \(\kappa\) only as \(m \to \infty\). Neither bend nor approach has a closed form, which is exactly why U-3 is in the registry as "numerical only".

m_u3 = torch.linspace(0.0, 4.0, 81)
c_pih = (1 - beta_u2) * (m_u3 + 1 / r_u2)
intersect_m = (1 - beta_u2) / (r_u2 * beta_u2)  # solves (1-β)(m+1/r) = m

fig, ax = plt.subplots(figsize=(8.5, 5))
ax.plot(
    m_u3.numpy(),
    c_pih.numpy(),
    label=r"U-2 PIH: $c = (1-\beta)(m + 1/r)$",
    linewidth=2,
)
ax.plot(
    m_u3.numpy(),
    m_u3.numpy(),
    label=r"Constraint: $c = m$",
    linewidth=2,
    linestyle="--",
    color="k",
)
ax.fill_between(
    m_u3.numpy(),
    c_pih.numpy(),
    m_u3.numpy(),
    where=(c_pih > m_u3).numpy(),
    color="red",
    alpha=0.18,
    label=r"PIH infeasible: $c > m$",
)
ax.axvline(
    intersect_m,
    color="grey",
    linestyle=":",
    linewidth=1.2,
    label=rf"intersection $m = (1-\beta)/(r\beta) \approx {intersect_m:.2f}$",
)
ax.set_xlabel("Normalized cash-on-hand $m$", fontsize=11)
ax.set_ylabel("Normalized consumption $c$", fontsize=11)
ax.set_title("U-3: PIH Violates the Borrowing Constraint at Low Wealth", fontsize=12)
ax.legend(fontsize=9, loc="upper left")
ax.grid(True, alpha=0.3)
ax.set_xlim(0, 4)
ax.set_ylim(0, 3.5)
fig.tight_layout()

try:
    get_analytical_policy("U-3")
except ValueError as exc:
    print("U-3 has no analytical policy by design:")
    print(f"  {exc}")
U-3: PIH Violates the Borrowing Constraint at Low Wealth
U-3 has no analytical policy by design:
  Model 'U-3' does not have an analytical policy. This model requires numerical solution via Euler equation training. Use EulerEquationLoss with maliar_training_loop (constrained=True for models with upper-bound constrained controls). See tests/test_maliar.py for examples.