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})"
)
Starting solution
Age: 2  regimes=1  (149.5ms)
Age: 1  regimes=1  (163.8ms)
Age: 0  regimes=1  (95.3ms)
Solution complete  (411.2ms)
Starting simulation
Age: 0  regimes=1  (945.9ms)
Age: 1  regimes=1  (150.7ms)
Age: 2  regimes=1  (39.8ms)
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})"
)
Starting solution
Age: 2  regimes=1  (38.8ms)
Age: 1  regimes=1  (95.5ms)
Age: 0  regimes=1  (94.6ms)
Solution complete  (232.2ms)
Starting simulation
Age: 0  regimes=1  (211.0ms)
Age: 1  regimes=1  (153.0ms)
Age: 2  regimes=1  (32.0ms)
Simulation complete  (402.4ms)
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.