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.

Beta-Delta (Quasi-Hyperbolic) Discounting

This notebook shows how to implement naive beta-delta discounting in PyLCM using a custom aggregation function HH and SolveSimulateFunctionPair. It covers:

  1. The beta-delta discount function

  2. Why naive agents need different HH for solve vs. simulate

  3. A 3-period consumption-savings model with analytical solutions

  4. Verifying numerical results against closed-form solutions

Background: Beta-Delta Discounting

Standard exponential discounting uses a single discount factor δ\delta to weight future utility:

Ut=ut+δut+1+δ2ut+2+U_t = u_t + \delta\, u_{t+1} + \delta^2\, u_{t+2} + \cdots

Quasi-hyperbolic (beta-delta) discounting (Laibson, 1997) introduces a present-bias parameter β(0,1]\beta \in (0, 1] that discounts all future periods relative to the present:

Ut=ut+βδut+1+βδ2ut+2+U_t = u_t + \beta\delta\, u_{t+1} + \beta\delta^2\, u_{t+2} + \cdots

The discount weights are {1,  βδ,  βδ2,  }\{1,\; \beta\delta,\; \beta\delta^2,\; \ldots\}. When β=1\beta = 1 this reduces to exponential discounting. When β<1\beta < 1 the agent is present-biased — they overweight current utility relative to any future period.

Note the key structure: β\beta appears once (not compounded). The weight nn periods ahead is βδn\beta\delta^n, not (βδ)n(\beta\delta)^n.

PyLCM’s HH Function and the Naive Agent

PyLCM defines the value function recursively via an aggregation function HH:

Vt(s)=maxa  H(u(s,a),  Et[Vt+1(s)])V_t(s) = \max_a \; H\bigl(u(s, a),\; \mathbb{E}_t[V_{t+1}(s')]\bigr)

The default HH is H(u,E[V])=u+δE[V]H(u, \mathbb{E}[V']) = u + \delta \cdot \mathbb{E}[V'].

A naive beta-delta agent believes their future selves will be exponential discounters, but at each decision point applies present bias βδ\beta\delta to the continuation value. This requires two different HH implementations:

  • Solve phase (backward induction): use exponential discounting with δ\delta to compute the perceived continuation values VEV^E

  • Simulate phase (action selection): use βδ\beta\delta to choose actions, applying present bias to the perceived VEV^E

SolveSimulateFunctionPair wraps these two implementations so you can call simulate once with a single params dict.

Why not just use H(u,V)=u+βδVH(u, V') = u + \beta\delta V' for both phases? Because the recursion would compound β\beta at every step, giving effective weights (βδ)n(\beta\delta)^n instead of the correct βδn\beta\delta^n. Splitting the phases ensures β\beta is applied exactly once — at the action selection step.

Setup: A 3-Period Model

We use a minimal consumption-savings model:

  • 3 periods: t=0,1t = 0, 1 (decisions), t=2t = 2 (terminal)

  • 1 state: wealth ww

  • 1 action: consumption cc

  • Utility: u(c)=log(c)u(c) = \log(c)

  • Budget: w=wcw' = w - c (interest rate R=1R = 1)

  • Constraint: cwc \le w

  • Terminal: V2(w)=log(w)V_2(w) = \log(w) (consume everything)

import jax.numpy as jnp

from lcm import (
    AgeGrid,
    LinSpacedGrid,
    Model,
    Regime,
    SolveSimulateFunctionPair,
    categorical,
)
from lcm.typing import BoolND, ContinuousAction, ContinuousState, FloatND, ScalarInt
@categorical(ordered=False)
class RegimeId:
    working: int
    dead: int


def utility(consumption: ContinuousAction) -> FloatND:
    return jnp.log(consumption)


def terminal_utility(wealth: ContinuousState) -> FloatND:
    return jnp.log(wealth)


def next_wealth(
    wealth: ContinuousState, consumption: ContinuousAction
) -> ContinuousState:
    return wealth - consumption


def next_regime(age: float) -> ScalarInt:
    return jnp.where(age >= 1, RegimeId.dead, RegimeId.working)


def borrowing_constraint(
    consumption: ContinuousAction, wealth: ContinuousState
) -> BoolND:
    return consumption <= wealth


def exponential_H(
    utility: float,
    E_next_V: float,
    discount_factor: float,
) -> float:
    return utility + discount_factor * E_next_V


def beta_delta_H(
    utility: float,
    E_next_V: float,
    beta: float,
    delta: float,
) -> float:
    return utility + beta * delta * E_next_V

We build two models:

  • One with exponential_H for the standard exponential agent

  • One with SolveSimulateFunctionPair for the naive beta-delta agent — solving with exponential HH and simulating with beta-delta HH

WEALTH_GRID = LinSpacedGrid(start=0.5, stop=50, n_points=200)
CONSUMPTION_GRID = LinSpacedGrid(start=0.001, stop=50, n_points=500)

dead = Regime(
    transition=None,
    states={"wealth": WEALTH_GRID},
    functions={"utility": terminal_utility},
    active=lambda age: age > 1,
)

# Standard exponential model
working_exp = Regime(
    actions={"consumption": CONSUMPTION_GRID},
    states={"wealth": WEALTH_GRID},
    state_transitions={"wealth": next_wealth},
    constraints={"borrowing_constraint": borrowing_constraint},
    transition=next_regime,
    functions={"utility": utility, "H": exponential_H},
    active=lambda age: age <= 1,
)

model_exp = Model(
    regimes={"working": working_exp, "dead": dead},
    ages=AgeGrid(start=0, stop=2, step="Y"),
    regime_id_class=RegimeId,
)

# Naive beta-delta model (different H per phase)
working_naive = Regime(
    actions={"consumption": CONSUMPTION_GRID},
    states={"wealth": WEALTH_GRID},
    state_transitions={"wealth": next_wealth},
    constraints={"borrowing_constraint": borrowing_constraint},
    transition=next_regime,
    functions={
        "utility": utility,
        "H": SolveSimulateFunctionPair(
            solve=exponential_H,
            simulate=beta_delta_H,
        ),
    },
    active=lambda age: age <= 1,
)

model_naive = Model(
    regimes={"working": working_naive, "dead": dead},
    ages=AgeGrid(start=0, stop=2, step="Y"),
    regime_id_class=RegimeId,
)

Analytical Solution

With log\log utility, the optimal consumption rule is ct=wt/Dtc_t = w_t / D_t, where DtD_t is a “marginal propensity to save” denominator.

At t=1t = 1 (one period before terminal), the agent maximizes:

maxc  [log(c)+βδlog(wc)]\max_c \;\bigl[\log(c) + \beta\delta \cdot \log(w - c)\bigr]

First-order condition: 1/c=βδ/(wc)1/c = \beta\delta / (w - c), giving c1=w/(1+βδ)c_1 = w / (1 + \beta\delta).

At t=0t = 0, the naive agent uses the perceived exponential continuation value V1EV^E_1, which has coefficient (1+δ)(1 + \delta) on logw\log w. So the agent maximizes:

maxc  [log(c)+βδ(1+δ)log(wc)+const]\max_c \;\bigl[\log(c) + \beta\delta (1 + \delta) \log(w - c) + \text{const}\bigr]

giving c0=w/(1+βδ(1+δ))c_0 = w / (1 + \beta\delta(1 + \delta)).

D1D_1D0D_0
Exponential (β=1\beta = 1)1+δ1 + \delta1+δ(1+δ)1 + \delta(1 + \delta)
Naive (β<1\beta < 1)1+βδ1 + \beta\delta1+βδ(1+δ)1 + \beta\delta(1 + \delta)
BETA = 0.7
DELTA = 0.95
W0 = 20.0


def analytical_consumption(beta, delta, w0):
    """Return (c0, c1) from the closed-form solution."""
    bd = beta * delta
    d1 = 1 + bd
    d0 = 1 + bd * (1 + delta)
    c0 = w0 / d0
    c1 = (w0 - c0) / d1
    return c0, c1


c0_exp, c1_exp = analytical_consumption(1.0, DELTA, W0)
c0_naive, c1_naive = analytical_consumption(BETA, DELTA, W0)

print(f"{'Type':<15} {'c_0':>8} {'c_1':>8}")
print("-" * 33)
print(f"{'Exponential':<15} {c0_exp:8.4f} {c1_exp:8.4f}")
print(f"{'Naive':<15} {c0_naive:8.4f} {c1_naive:8.4f}")
Type                 c_0      c_1
---------------------------------
Exponential       7.0114   6.6608
Naive             8.7080   6.7820

The naive agent consumes more at t=0t = 0 than the exponential agent — present bias pulls consumption forward. At t=1t = 1, the naive agent also consumes more because βδ<δ\beta\delta < \delta.

Numerical Results

Exponential Agent (β=1\beta = 1)

initial_conditions = {
    "age": jnp.array([0.0]),
    "wealth": jnp.array([W0]),
    "regime": jnp.array([RegimeId.working]),
}

result_exp = model_exp.simulate(
    params={"working": {"H": {"discount_factor": DELTA}}},
    initial_conditions=initial_conditions,
    period_to_regime_to_V_arr=None,
)

df_exp = result_exp.to_dataframe().query('regime == "working"')
print("Exponential agent:")
print(
    f"  c_0 = {df_exp.loc[df_exp['age'] == 0, 'consumption'].iloc[0]:.4f}  "
    f"(analytical: {c0_exp:.4f})"
)
print(
    f"  c_1 = {df_exp.loc[df_exp['age'] == 1, 'consumption'].iloc[0]:.4f}  "
    f"(analytical: {c1_exp:.4f})"
)
AOT compilation: 3 unique functions (3 regime-period pairs, 2 workers)
1/3  working (age 0)
  lowering ...
  lowered in 55.0ms
2/3  working (age 1)
  lowering ...
  lowered in 25.3ms
3/3  dead (age 2)
  lowering ...
  lowered in 10.9ms
  compiling working (age 0) ...
  compiling working (age 1) ...
  compiled  working (age 0)  120.2ms
  compiling dead (age 2) ...
  compiled  working (age 1)  122.3ms
  compiled  dead (age 2)  32.7ms
Starting solution
Age 2 (1 regimes):
  finished in 104.8ms
Age 1 (1 regimes):
  finished in 4.1ms
Age 0 (1 regimes):
  finished in 3.3ms
Solution complete  (113.7ms)
Starting simulation
Age 0 (1 regimes):
  finished in 950.4ms
Age 1 (1 regimes):
  finished in 147.1ms
Age 2 (1 regimes):
  finished in 41.1ms
Simulation complete  (1.3s)
Exponential agent:
  c_0 = 7.0149  (analytical: 7.0114)
  c_1 = 6.7143  (analytical: 6.6608)

Naive Agent

The naive agent uses model_naive, built with a SolveSimulateFunctionPair wrapping HH. During backward induction, exponential_H computes the perceived value function VEV^E; during simulation, beta_delta_H applies present bias for action selection.

The params dict is the union of both variants’ parameters — discount_factor for exponential_H, plus beta and delta for beta_delta_H. Each variant receives only the kwargs it expects.

result_naive = model_naive.simulate(
    params={
        "working": {
            "H": {"discount_factor": DELTA, "beta": BETA, "delta": DELTA},
        },
    },
    initial_conditions=initial_conditions,
    period_to_regime_to_V_arr=None,
)

df_naive = result_naive.to_dataframe().query('regime == "working"')
print("Naive agent:")
print(
    f"  c_0 = {df_naive.loc[df_naive['age'] == 0, 'consumption'].iloc[0]:.4f}  "
    f"(analytical: {c0_naive:.4f})"
)
print(
    f"  c_1 = {df_naive.loc[df_naive['age'] == 1, 'consumption'].iloc[0]:.4f}  "
    f"(analytical: {c1_naive:.4f})"
)
AOT compilation: 3 unique functions (3 regime-period pairs, 2 workers)
1/3  working (age 0)
  lowering ...
  lowered in 29.3ms
2/3  working (age 1)
  lowering ...
  lowered in 25.7ms
3/3  dead (age 2)
  lowering ...
  lowered in 8.0ms
  compiling working (age 0) ...
  compiling working (age 1) ...
  compiled  working (age 1)  94.6ms
  compiling dead (age 2) ...
  compiled  working (age 0)  139.1ms
  compiled  dead (age 2)  45.0ms
Starting solution
Age 2 (1 regimes):
  finished in 2.8ms
Age 1 (1 regimes):
  finished in 3.2ms
Age 0 (1 regimes):
  finished in 4.0ms
Solution complete  (11.5ms)
Starting simulation
Age 0 (1 regimes):
  finished in 209.3ms
Age 1 (1 regimes):
  finished in 149.6ms
Age 2 (1 regimes):
  finished in 32.5ms
Simulation complete  (396.9ms)
Naive agent:
  c_0 = 8.7183  (analytical: 8.7080)
  c_1 = 6.8145  (analytical: 6.7820)

Comparison

The small differences between numerical and analytical solutions are due to grid discretization.

import pandas as pd

comparison = pd.DataFrame(
    {
        "Agent": ["Exponential", "Naive"],
        "c_0 (numerical)": [
            df_exp.loc[df_exp["age"] == 0, "consumption"].iloc[0],
            df_naive.loc[df_naive["age"] == 0, "consumption"].iloc[0],
        ],
        "c_0 (analytical)": [c0_exp, c0_naive],
        "c_1 (numerical)": [
            df_exp.loc[df_exp["age"] == 1, "consumption"].iloc[0],
            df_naive.loc[df_naive["age"] == 1, "consumption"].iloc[0],
        ],
        "c_1 (analytical)": [c1_exp, c1_naive],
    }
)
comparison = comparison.set_index("Agent")
comparison.style.format("{:.4f}")
Loading...

Summary

Beta-delta discounting in PyLCM requires no core modifications:

  1. Define two HH functions — one exponential, one with present bias:

    def exponential_H(utility, E_next_V, discount_factor):
        return utility + discount_factor * E_next_V
    
    def beta_delta_H(utility, E_next_V, beta, delta):
        return utility + beta * delta * E_next_V
  2. Wrap them in SolveSimulateFunctionPair so backward induction uses exponential HH while action selection uses beta-delta HH:

    from lcm import SolveSimulateFunctionPair
    
    functions={
        "utility": utility,
        "H": SolveSimulateFunctionPair(
            solve=exponential_H,
            simulate=beta_delta_H,
        ),
    }
  3. Provide the union of both variants’ parameters:

    params = {"working": {"H": {"discount_factor": delta, "beta": beta, "delta": delta}}}

This split is essential: using H(u,V)=u+βδVH(u, V') = u + \beta\delta V' for both phases would compound β\beta at every recursive step, giving effective weights (βδ)n(\beta\delta)^n instead of the correct βδn\beta\delta^n.