Brock-Mirman with endogenous labor — framework walkthrough¶
Second stop in the examples/ series. Sibling of brock_mirman.ipynb with one added ingredient: the representative agent now chooses labor supply alongside the savings rate. Two policies, two equilibrium conditions, same state space and same shock structure.
Model¶
Agent maximises $$\max_{\{C_t, L_t\}} \mathbb{E}_0 \sum_t \beta^t \left[\ln C_t - \psi\,\frac{L_t^{1+\theta}}{1+\theta}\right]$$ subject to Cobb-Douglas production with labor $$Y_t = Z_t\, L_t^{1-\alpha} K_t^{\alpha}, \qquad K_{t+1} = (1 - \delta) K_t + Y_t - C_t$$ and AR(1) TFP $z_{t+1} = \rho_z\, z_t + \sigma_z \varepsilon_{t+1}$, $Z = e^z$.
FOCs¶
- Capital Euler (intertemporal, takes expectation): $$u'(C_t) = \beta\, \mathbb{E}_t\!\left[u'(C_{t+1})\,(1 + r_{t+1} - \delta)\right], \qquad r = \alpha Z L^{1-\alpha} K^{\alpha-1}$$
- Labor FOC (intratemporal, no expectation): $$\psi\, L_t^{\theta} = w_t\, u'(C_t), \qquad w = (1-\alpha) Z L^{-\alpha} K^{\alpha}$$
Calibration mirrors brock_mirman¶
$\alpha = 0.36$, $\beta = 0.99$, $\delta = 0.1$, $\rho_z = 0.9$, $\sigma_z = 0.04$. New: $\psi = 1$, $\theta = 1$ (quadratic effort cost, Frisch elasticity 1). Same training rect; both notebooks sample from $k \in [0.9, 12]$, $z \in [\ln 0.7, \ln 1.3]$.
What's new vs. brock_mirman.ipynb¶
- Two policy outputs:
sav_rate(sigmoid, bounded in $(0, 1)$) andL(softplus, bounded only below). The framework handles mixed per-dimension activations — seePOLICY_UPPERinvariables.py. - Two equations in the loss. The trainer averages them by default;
loss_reweightand gradient-surgery options are available if one equation dominates. - Same diagnostic stack.
evaluate.euler_equation_errors,irf.run_irf,plots.*all work uniformly — no bm_labor-specific plumbing.
# Notebook setup: rich pytree / array displays.
# treescope (transitive dep of penzai, in dev deps) replaces the default
# repr for JAX arrays and registered pytrees with a collapsible widget
# showing shape, dtype, and stats. Useful for inspecting TrainState,
# the policy network, and residual arrays during a training run.
import treescope
treescope.basic_interactive_setup(autovisualize_arrays=True)
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
import numpy as np
from deqn_jax.config import NetworkConfig, OptimizerConfig, TrainConfig
from deqn_jax.evaluate import euler_equation_errors, print_euler_errors
from deqn_jax.irf import run_irf
from deqn_jax.models.bm_labor import MODEL
from deqn_jax.models.bm_labor.dynamics import step as bml_step
from deqn_jax.models.bm_labor.equations import definitions
from deqn_jax.models.bm_labor.equations import equations as bml_equations
from deqn_jax.models.bm_labor.steady_state import K_LB, K_UB, Z_LB, Z_UB, steady_state
from deqn_jax.plots import plot_irf_grid, plot_loss_curve
from deqn_jax.training.trainer import train_from_config
1. Inspect the model¶
print(f"name : {MODEL.name}")
print(f"states : {MODEL.state_names}")
print(f"policies : {MODEL.policy_names}")
print(f"equations : {MODEL.equation_names}")
print(f"shocks : {MODEL.shock_names} ({MODEL.n_shocks} dim)")
print(
f"policy bounds : lower={np.asarray(MODEL.policy_lower)}, upper={np.asarray(MODEL.policy_upper)}"
)
print()
for k, v in MODEL.constants.items():
print(f" {k:8s} = {v}")
ss_state, ss_policy = steady_state(MODEL.constants)
print()
print("Deterministic steady state:")
print(f" K_ss = {float(ss_state[0]):.4f}")
print(f" z_ss = {float(ss_state[1]):.4f} (Z_ss = 1.0)")
print(f" s_ss = {float(ss_policy[0]):.4f}")
print(f" L_ss = {float(ss_policy[1]):.4f}")
print()
print(
f"Training rect: k in [{K_LB}, {K_UB}], z in [{Z_LB:.3f}, {Z_UB:.3f}] (Z in [0.70, 1.30])"
)
name : bm_labor
states : ('k', 'z')
policies : ('sav_rate', 'L')
equations : ('euler', 'labor_foc')
shocks : ('eps_z',) (1 dim)
policy bounds : lower=[1.e-06 1.e-06], upper=[0.999999 inf]
alpha = 0.36
beta = 0.99
gamma = 1.0
delta = 0.1
psi = 1.0
theta = 1.0
rho_z = 0.9
sigma_z = 0.04
Deterministic steady state:
K_ss = 6.2087
z_ss = 0.0000 (Z_ss = 1.0)
s_ss = 0.3270
L_ss = 0.9752
Training rect: k in [0.9, 12.0], z in [-0.357, 0.262] (Z in [0.70, 1.30])
2. Train¶
Same recipe as brock_mirman.ipynb, just one extra equation in the loss. No configuration pressure from the second equation at first — we'll see how it converges and revisit if needed.
cfg = TrainConfig(
model="bm_labor",
episodes=20001,
batch_size=128,
episode_length=1,
mc_samples=5,
n_epochs_per_rollout=1,
n_minibatches_per_epoch=1,
initialize_each_episode=True,
network=NetworkConfig(
hidden_sizes=(50, 50), activation="relu", init="xavier_uniform"
),
optimizer=OptimizerConfig(
name="adam", learning_rate=3e-4, lr_schedule="cosine", lr_min_factor=0.1
),
warm_start=False,
seed=0,
verbose=False,
)
policy_net, history = train_from_config(cfg)
print(f"Initial loss: {history['loss'][0]:.3e}")
print(f"Final loss: {history['loss'][-1]:.3e}")
Initial loss: 5.065e+04 Final loss: 1.520e-04
3. Loss curve¶
Total training loss (sum of squared equilibrium residuals) on a log scale. The labor FOC is intratemporal and the capital Euler is expectation-bearing, but only the aggregate loss is logged per cycle, so we plot the total.
fig, ax = plt.subplots(figsize=(7, 4))
plot_loss_curve(history, ax=ax, log_y=True)
ax.set_title("Total training loss, log scale")
plt.tight_layout()
plt.show()
4. Ergodic set and capital policy¶
Every result figure below is a scatter over the on-policy simulated set -- the trained policy rolled forward and sampled after burn-in -- exactly the diagnostic stack of the Geneva 2026 Day 2 Exercise 2 solution. Left: the ergodic cloud in $(K, Z)$. Center / right: the capital policy $K_{t+1}$ against capital (with the $K_{t+1}=K_t$ diagonal) and against productivity. Color encodes the other state.
# On-policy simulated ergodic set (Geneva 2026 Day 2 Exercise 2 style).
# Roll the trained policy forward from a spread of starting states and keep
# the post-burn-in cloud; all scatters below live on this set.
from deqn_jax.training.loss import sample_antithetic_shocks
sim_key = jax.random.PRNGKey(20)
n_tracks = 512
n_sim = 240
burn = 120
ik, iz, ish = jax.random.split(sim_key, 3)
k0 = jax.random.uniform(ik, (n_tracks,), minval=K_LB, maxval=K_UB)
z0 = jax.random.uniform(iz, (n_tracks,), minval=Z_LB, maxval=Z_UB)
X = jnp.stack([k0, z0], axis=1)
cloud = []
for t in range(n_sim):
pol = jax.vmap(policy_net)(X)
eps = jax.random.normal(jax.random.fold_in(ish, t), (n_tracks, MODEL.n_shocks))
X = bml_step(X, pol, eps, MODEL.constants)
if t >= burn:
cloud.append(X)
sim_states = jnp.concatenate(cloud, axis=0)
# Subsample for legible scatters
sub = jax.random.permutation(jax.random.PRNGKey(21), sim_states.shape[0])[:3000]
sim_states = sim_states[sub]
K_sim = np.asarray(sim_states[:, 0])
Z_sim = np.exp(np.asarray(sim_states[:, 1])) # productivity in levels
# Policies, derived quantities, and one-step-ahead capital on the cloud
pol_sim = jax.vmap(policy_net)(sim_states)
defs_sim = definitions(sim_states, pol_sim, MODEL.constants)
L_sim = np.asarray(pol_sim[:, 1])
C_sim = np.asarray(defs_sim["c"])
zero_shock = jnp.zeros((sim_states.shape[0], MODEL.n_shocks))
Knext_sim = np.asarray(bml_step(sim_states, pol_sim, zero_shock, MODEL.constants)[:, 0])
fig, axes = plt.subplots(1, 3, figsize=(15, 4.3))
axes[0].scatter(K_sim, Z_sim, s=8, alpha=0.35, color="C0")
axes[0].scatter(
[float(ss_state[0])], [1.0], marker="*", color="red", s=160, zorder=5,
label="steady state",
)
axes[0].set_xlabel("capital $K$")
axes[0].set_ylabel("productivity $Z$")
axes[0].set_title("Simulated ergodic set")
axes[0].legend(loc="best", fontsize=8)
axes[1].scatter(K_sim, K_sim, s=6, color="grey", alpha=0.3, label="$K_{t+1}=K_t$")
sc1 = axes[1].scatter(K_sim, Knext_sim, s=8, alpha=0.5, c=Z_sim, cmap="viridis")
axes[1].axvline(float(ss_state[0]), color="r", ls=":", alpha=0.5)
axes[1].set_xlabel("capital $K_t$")
axes[1].set_ylabel("next capital $K_{t+1}$")
axes[1].set_title("Capital policy vs $K$")
axes[1].legend(loc="best", fontsize=8)
fig.colorbar(sc1, ax=axes[1], label="productivity $Z$")
sc2 = axes[2].scatter(Z_sim, Knext_sim, s=8, alpha=0.5, c=K_sim, cmap="plasma")
axes[2].set_xlabel("productivity $Z$")
axes[2].set_ylabel("next capital $K_{t+1}$")
axes[2].set_title("Capital policy vs $Z$")
fig.colorbar(sc2, ax=axes[2], label="capital $K$")
plt.tight_layout()
plt.show()
5. Labor and consumption policy¶
Labor supply $L$ and consumption $C$ over the same simulated set, against each state. Both are values, drawn on their natural ranges (not zoomed into numerical noise): $L$ rises with productivity and is nearly flat in capital; $C$ tracks output.
fig, axes = plt.subplots(2, 2, figsize=(13, 8))
# Steady-state consumption (for the consumption-panel markers)
c_ss = float(definitions(ss_state, ss_policy, MODEL.constants)["c"])
# Labor policy
sc00 = axes[0, 0].scatter(K_sim, L_sim, s=8, alpha=0.5, c=Z_sim, cmap="viridis")
axes[0, 0].scatter(
[float(ss_state[0])], [float(ss_policy[1])], marker="*", color="red", s=160,
zorder=5, label="steady state",
)
axes[0, 0].set_xlabel("capital $K$")
axes[0, 0].set_ylabel("labor supply $L$")
axes[0, 0].set_title("Labor policy vs $K$")
axes[0, 0].legend(fontsize=8)
fig.colorbar(sc00, ax=axes[0, 0], label="productivity $Z$")
sc01 = axes[0, 1].scatter(Z_sim, L_sim, s=8, alpha=0.5, c=K_sim, cmap="plasma")
axes[0, 1].scatter(
[1.0], [float(ss_policy[1])], marker="*", color="red", s=160, zorder=5,
label="steady state",
)
axes[0, 1].set_xlabel("productivity $Z$")
axes[0, 1].set_ylabel("labor supply $L$")
axes[0, 1].set_title("Labor policy vs $Z$")
axes[0, 1].legend(fontsize=8)
fig.colorbar(sc01, ax=axes[0, 1], label="capital $K$")
# Consumption policy
sc10 = axes[1, 0].scatter(K_sim, C_sim, s=8, alpha=0.5, c=Z_sim, cmap="viridis")
axes[1, 0].scatter(
[float(ss_state[0])], [c_ss], marker="*", color="red", s=160, zorder=5,
label="steady state",
)
axes[1, 0].set_xlabel("capital $K$")
axes[1, 0].set_ylabel("consumption $C$")
axes[1, 0].set_title("Consumption policy vs $K$")
axes[1, 0].legend(fontsize=8)
fig.colorbar(sc10, ax=axes[1, 0], label="productivity $Z$")
sc11 = axes[1, 1].scatter(Z_sim, C_sim, s=8, alpha=0.5, c=K_sim, cmap="plasma")
axes[1, 1].scatter(
[1.0], [c_ss], marker="*", color="red", s=160, zorder=5,
label="steady state",
)
axes[1, 1].set_xlabel("productivity $Z$")
axes[1, 1].set_ylabel("consumption $C$")
axes[1, 1].set_title("Consumption policy vs $Z$")
axes[1, 1].legend(fontsize=8)
fig.colorbar(sc11, ax=axes[1, 1], label="capital $K$")
plt.tight_layout()
plt.show()
6. Relative Euler errors¶
The signed, linear relative Euler errors of the Geneva 2026 Day 2 Exercise 2 cost function -- capital Euler and intratemporal labor FOC -- scattered against each state. A well-trained policy collapses to a thin band at zero (the dotted line). We plot the signed error, not $\log_{10}|\text{residual}|$, so sign changes read as clean crossings of zero rather than spurious $-\infty$ spikes.
$$\text{errREE}_{\text{cap}} = 1 - \frac{u'(C_t)}{\beta\,\mathbb{E}\big[u'(C_{t+1})(1 - \delta + \text{mpk}_{t+1})\big]},\qquad \text{errREE}_{\text{lab}} = \frac{w_t\,u'(C_t)}{\psi L_t^{\theta}} - 1.$$
# Relative Euler errors, Geneva 2026 Day 2 Exercise 2 definitions:
# errREE_cap = 1 - u'(C) / ( beta * E[ u'(C')(1 - delta + mpk') ] ) (capital Euler)
# errREE_lab = w u'(C) / ( psi L^theta ) - 1 (labor FOC)
# Signed and linear, so a converged policy "comes down cleanly" to a flat band
# at zero -- NOT log10|raw residual|, which spikes to -inf at sign changes.
# (Log utility here, gamma=1, so u'(C) = 1/C exactly as in the reference.)
beta = MODEL.constants["beta"]
delta = MODEL.constants["delta"]
psi = MODEL.constants["psi"]
theta = MODEL.constants["theta"]
u_c_sim = defs_sim["u_c"]
w_sim = defs_sim["w"]
# MC expectation E[ u'(C')(1 - delta + mpk') ] with antithetic shocks
n_mc_ee = 200
eps_ee = sample_antithetic_shocks(
jax.random.PRNGKey(22), n_mc_ee // 2, sim_states.shape[0], MODEL.n_shocks, 1.0
)
def _inside(shock):
nx = bml_step(sim_states, pol_sim, shock, MODEL.constants)
npol = jax.vmap(policy_net)(nx)
nd = definitions(nx, npol, MODEL.constants)
return nd["u_c"] * (1.0 + nd["mpk"] - delta)
expectation = jax.vmap(_inside)(eps_ee).mean(axis=0)
# Capital Euler: LHS = u'(C), RHS = beta * E[...]
lhs_cap = u_c_sim
rhs_cap = beta * expectation
errREE_cap = np.asarray(1.0 - lhs_cap / rhs_cap)
lhs_cap = np.asarray(lhs_cap)
rhs_cap = np.asarray(rhs_cap)
# Labor FOC: LHS = w u'(C) (= w/C for log utility), RHS = psi L^theta
lhs_lab = np.asarray(w_sim * u_c_sim)
rhs_lab = psi * np.power(L_sim, theta)
errREE_lab = lhs_lab / rhs_lab - 1.0
fig, axes = plt.subplots(1, 2, figsize=(13, 4.3))
axes[0].axhline(0, color="k", ls=":", alpha=0.6)
axes[0].scatter(K_sim, errREE_cap, s=8, alpha=0.5, label="capital Euler")
axes[0].scatter(K_sim, errREE_lab, s=8, alpha=0.5, label="labor FOC")
axes[0].axvline(float(ss_state[0]), color="r", ls="--", alpha=0.4)
axes[0].set_xlabel("capital $K$")
axes[0].set_ylabel("relative Euler error")
axes[0].set_title("Relative Euler error vs $K$")
axes[0].legend(fontsize=8)
axes[1].axhline(0, color="k", ls=":", alpha=0.6)
axes[1].scatter(Z_sim, errREE_cap, s=8, alpha=0.5, label="capital Euler")
axes[1].scatter(Z_sim, errREE_lab, s=8, alpha=0.5, label="labor FOC")
axes[1].axvline(1.0, color="r", ls="--", alpha=0.4)
axes[1].set_xlabel("productivity $Z$")
axes[1].set_ylabel("relative Euler error")
axes[1].set_title("Relative Euler error vs $Z$")
axes[1].legend(fontsize=8)
plt.tight_layout()
plt.show()
print(
f"capital Euler errREE: median |.| = {np.median(np.abs(errREE_cap)):.3e}, "
f"p99 = {np.percentile(np.abs(errREE_cap), 99):.3e}"
)
print(
f"labor FOC errREE: median |.| = {np.median(np.abs(errREE_lab)):.3e}, "
f"p99 = {np.percentile(np.abs(errREE_lab), 99):.3e}"
)
capital Euler errREE: median |.| = 2.919e-02, p99 = 1.115e-01 labor FOC errREE: median |.| = 9.947e-03, p99 = 2.225e-01
LHS vs RHS equilibrium check¶
For each optimality condition we overlay its two sides against the state, as in the Geneva 2026 Day 2 Exercise 2 solution. Capital Euler: $u'(C)$ (LHS, large markers) vs $\beta\,\mathbb{E}[u'(C')(1-\delta+\text{mpk}')]$ (RHS, small markers). Labor FOC: $w\,u'(C)$ (LHS) vs $\psi L^{\theta}$ (RHS). The two clouds coincide wherever the equation is satisfied; a visible gap is a structural error the residual magnitude alone would not surface.
fig, axes = plt.subplots(2, 2, figsize=(13, 8))
def _lhsrhs(ax, x, lhs, rhs, xlabel, title):
ax.scatter(x, lhs, s=40, alpha=0.45, label="LHS")
ax.scatter(x, rhs, s=12, alpha=0.6, label="RHS")
ax.set_xlabel(xlabel)
ax.set_title(title)
ax.legend(fontsize=8)
_lhsrhs(
axes[0, 0], K_sim, lhs_cap, rhs_cap, "capital $K$",
r"Capital Euler: $u'(C)$ vs $\beta\,\mathbb{E}[\cdot]$",
)
_lhsrhs(
axes[0, 1], Z_sim, lhs_cap, rhs_cap, "productivity $Z$",
"Capital Euler: LHS vs RHS",
)
_lhsrhs(
axes[1, 0], K_sim, lhs_lab, rhs_lab, "capital $K$",
r"Labor FOC: $w\,u'(C)$ vs $\psi L^{\theta}$",
)
_lhsrhs(
axes[1, 1], Z_sim, lhs_lab, rhs_lab, "productivity $Z$",
"Labor FOC: LHS vs RHS",
)
plt.tight_layout()
plt.show()
8. Euler accuracy diagnostics¶
Percentile tables: raw residuals + dimensionless $|\mathrm{resid}/u'(C)|$. Per-equation rows so we can tell whether the Euler or labor FOC is dominating accuracy.
Calibrating "POOR" against the reference. Our brock_mirman stochastic port (same calibration, single-phase 20001 episodes) hits mean $\log_{10}|\mathrm{errREE}| \approx -2.3$. Simon's TF/Keras reference implementation, freshly run end-to-end with the same recipe, hits $-3.14$ on the same model. So the framework has an unexplained ~0.85-decade accuracy gap vs. the reference on the Euler FOC alone; the gap isn't LR schedule, GH vs MC, or residual-form rescaling (all ruled out). Candidates include network input scale (we pass log-TFP, Simon passes level-TFP) and the loss form's implicit per-state weighting. Tracked for a focused investigation after more models ship; the policies we train here are qualitatively correct and economically usable, just not as tight as the reference.
For bm_labor with two equations we land around $-1.8$; consistent with the brock_mirman baseline scaled for the extra FOC.
eval_result = euler_equation_errors(policy_net, MODEL, n_periods=10_000, seed=123)
states_path = np.asarray(eval_result["states"])
resid_path = np.asarray(eval_result["residuals"]) # [T, 2] (euler, labor_foc)
K_path = states_path[:, 0]
Z_path = np.exp(states_path[:, 1])
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].hist(K_path, bins=60, color="C0", alpha=0.8)
axes[0].axvline(
float(ss_state[0]), color="k", ls="--", label=f"$K_{{ss}}={float(ss_state[0]):.2f}$"
)
axes[0].set_xlabel("$K$")
axes[0].set_ylabel("count")
axes[0].set_title("Ergodic $K$")
axes[0].legend()
axes[1].hist(Z_path, bins=60, color="C2", alpha=0.8)
axes[1].axvline(1.0, color="k", ls="--", label="$Z_{ss}=1$")
axes[1].set_xlabel("$Z$")
axes[1].set_ylabel("count")
axes[1].set_title("Ergodic $Z$")
axes[1].legend()
plt.tight_layout()
plt.show()
print(
f"K mean / std = {K_path.mean():.3f} / {K_path.std():.3f} (SS {float(ss_state[0]):.3f})"
)
print(
f"Z mean / std = {Z_path.mean():.3f} / {Z_path.std():.3f} (analytical AR(1) std {0.04 / np.sqrt(1 - 0.9**2):.3f})"
)
K mean / std = 13.946 / 6.648 (SS 6.209) Z mean / std = 1.002 / 0.091 (analytical AR(1) std 0.092)
8. Euler accuracy diagnostics¶
Percentile tables: raw residuals + dimensionless $|\mathrm{resid}/u'(C)|$. Per-equation rows so we can tell whether the Euler or labor FOC is dominating accuracy.
Note: a single-phase 20001-episode run here hits around $\log_{10}|\mathrm{resid}/u'(C)| \approx -1.8$ (grade "POOR" by the Den Haan–Marcet convention). Reaching $-3$ typically requires longer training, per-equation loss reweighting (
loss_reweight: lr_annealing), or the reference's two-phase curriculum (rollout with smaller LR). Deferred to a tuning exercise.
states_jnp = jnp.asarray(states_path)
policies_jnp = jax.vmap(policy_net)(states_jnp)
defs_path = definitions(states_jnp, policies_jnp, MODEL.constants)
u_c_path = np.asarray(defs_path["u_c"]) # [T]
dimless_resid = resid_path / u_c_path[:, None] # [T, 2]
print_euler_errors(eval_result, label="raw residual units")
def _pct_row(name, vals):
lg = np.log10(np.maximum(np.abs(vals), 1e-20))
p = np.percentile(lg, [50, 95, 99, 99.9])
grade = (
"Very good"
if lg.mean() < -4
else "Good"
if lg.mean() < -3
else "Acceptable"
if lg.mean() < -2
else "POOR"
)
print(
f"{name:>30s} {lg.mean():>7.2f} {p[0]:>7.2f} {p[1]:>7.2f} "
f"{p[2]:>7.2f} {p[3]:>7.2f} {lg.max():>7.2f} {grade:>12s}"
)
print("\nEuler Equation Errors (log10) — dimensionless |resid/u_c|")
print("=" * 100)
print(
f"{'Equation':>30s} {'Mean':>7s} {'p50':>7s} {'p95':>7s} {'p99':>7s} {'p99.9':>7s} {'Max':>7s} {'Grade':>12s}"
)
print("-" * 100)
for i, name in enumerate(eval_result["equation_names"]):
_pct_row(name, dimless_resid[:, i])
print()
Euler Equation Errors (log10) — raw residual units
====================================================================================================
Equation Mean p50 p95 p99 p99.9 Max Grade
----------------------------------------------------------------------------------------------------
euler -1.69 -1.64 -1.00 -0.84 -0.65 -0.40 POOR
labor_foc -1.97 -1.99 -0.87 -0.62 -0.39 -0.17 POOR
----------------------------------------------------------------------------------------------------
OVERALL -1.83 -1.79 -0.95 -0.70 -0.44 -0.17
Mean log10 error: -1.83 → 1.5e-02 (POOR >1%)
Max log10 error: -0.17 → 6.7e-01 (POOR >10%)
Euler Equation Errors (log10) — dimensionless |resid/u_c|
====================================================================================================
Equation Mean p50 p95 p99 p99.9 Max Grade
----------------------------------------------------------------------------------------------------
euler -1.55 -1.50 -0.83 -0.65 -0.47 -0.38 POOR
labor_foc -1.83 -1.88 -0.66 -0.38 -0.08 0.05 POOR
Errors conditional on $K$¶
k_quartiles = np.quantile(K_path, [0.25, 0.50, 0.75])
bin_labels = [
f"K ≤ {k_quartiles[0]:.2f}",
f"{k_quartiles[0]:.2f} < K ≤ {k_quartiles[1]:.2f}",
f"{k_quartiles[1]:.2f} < K ≤ {k_quartiles[2]:.2f}",
f"K > {k_quartiles[2]:.2f}",
]
bin_idx = np.digitize(K_path, k_quartiles)
for eq_i, eq_name in enumerate(eval_result["equation_names"]):
print(f"\n{eq_name}:")
print(
f"{'K bin':>36s} {'n':>6s} {'mean':>7s} {'p50':>7s} {'p95':>7s} {'p99':>7s}"
)
print("-" * 82)
for b, label in enumerate(bin_labels):
vals = dimless_resid[bin_idx == b, eq_i]
lg = np.log10(np.maximum(np.abs(vals), 1e-20))
p = np.percentile(lg, [50, 95, 99])
print(
f"{label:>36s} {len(vals):>6d} {lg.mean():>7.2f} {p[0]:>7.2f} {p[1]:>7.2f} {p[2]:>7.2f}"
)
euler:
K bin n mean p50 p95 p99
----------------------------------------------------------------------------------
K ≤ 9.19 2375 -1.86 -1.80 -1.26 -1.10
9.19 < K ≤ 12.35 2375 -1.64 -1.58 -1.03 -0.91
12.35 < K ≤ 17.01 2375 -1.45 -1.42 -0.85 -0.74
K > 17.01 2375 -1.26 -1.24 -0.67 -0.53
labor_foc:
K bin n mean p50 p95 p99
----------------------------------------------------------------------------------
K ≤ 9.19 2375 -2.33 -2.23 -1.78 -1.62
9.19 < K ≤ 12.35 2375 -2.22 -2.14 -1.64 -1.50
12.35 < K ≤ 17.01 2375 -1.63 -1.57 -1.00 -0.84
K > 17.01 2375 -1.14 -1.03 -0.40 -0.16
9. Impulse response to a TFP shock¶
irf_results = run_irf(policy_net, MODEL, shock_name="eps_z", shock_size=1.0, horizon=40)
# Five key economic variables (levels). Drop the duplicate log-productivity
# state "z" and keep the level "Z".
variables_to_plot = ["k", "Z", "L", "c", "y"]
fig = plot_irf_grid(
{"eps_z": irf_results},
variables=variables_to_plot,
title="IRF to +1\u03c3 TFP innovation",
)
plt.show()
Summary¶
Same diagnostic stack as the no-labor variant — nothing bm_labor-specific in the plumbing. Adding the labor margin was a matter of:
- One extra policy column (with softplus output bound via
POLICY_UPPER = [..., inf]) - One extra equation (
labor_foc) alongside the Euler - One extra factor (
w) indefinitions() - Steady-state derivation extended: labor FOC pins $L_{ss}$ once the capital-Euler $K/L$ ratio is known.
Accuracy (mean $\log_{10}|\mathrm{resid}/u'(C)| \approx -1.8$) is on par with brock_mirman after an equal single-phase run scaled for the extra equation. Tuning — LR-annealing reweighting, longer training, warm start from the analytical SS — is a downstream exercise; the framework carries the model without per-model code anywhere outside src/deqn_jax/models/bm_labor/.