Skip to content

Commit 463156e

Browse files
authored
Merge pull request PSLmodels#1144 from SeaCelo/feature/sparse-foc-jacobian
Add sparse FOC Jacobian to the household solve
2 parents d8e454e + bd8b98f commit 463156e

9 files changed

Lines changed: 266 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.16.1] - 2026-06-04 12:00:00
9+
10+
### Added
11+
12+
- Adds a `use_sparse_FOC_jac` `Specifications` parameter (default `True`) that accelerates the time path iteration (TPI) household solve. With it on, `scipy.optimize.root` is given a sparse (banded) finite-difference Jacobian for the stacked household Euler and labor first order conditions: the sparsity pattern is auto-detected once per problem size and the solver then needs far fewer function evaluations per Jacobian build (about 20x fewer on the default S=80 cohort solve), with an automatic fallback to dense finite differences if the Jacobian is not sparse enough to benefit or if a solve fails. The result matches the legacy dense-finite-difference solution to within the model's resource-constraint accuracy floor on every calibration tested (OG-Core standard example, OG-ETH, OG-ZAF, OG-PHL, OG-IDN), giving roughly a 1.9-2.4x TPI speedup at no accuracy cost. Set `use_sparse_FOC_jac=False` to recover bit-identical agreement with v0.16.0 and earlier.
813

914
## [0.16.0] - 2026-06-02 12:00:00
1015

docs/book/content/theory/derivations.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,16 @@ In the Cobb-Douglas unit elasticity case ($\varepsilon=1$) of the CES production
114114
```
115115

116116
Again, even if this simple case, we cannot solve for $r$ as a function of $w$ for the reasons above.
117+
118+
119+
(SecAppDerivHHjac)=
120+
## Sparsity of the household equation Jacobian
121+
122+
Holding fixed the prices and policies a type-$j$ cohort faces, its $2S$ stationarized necessary conditions {eq}`EqStnrz_eul_n`, {eq}`EqStnrz_eul_b`, and {eq}`EqStnrz_eul_bS` in the $2S$ unknowns $\{n_{j,s},\hat b_{j,s+1}\}_{s=E+1}^{E+S}$ have a banded Jacobian. From the budget constraint {eq}`EqStnrzHHBC`, stationarized consumption at age $s$ depends on only three unknowns,
123+
124+
```{math}
125+
:label: EqAppDerivHHjac_cons
126+
\hat c_{j,s} = \frac{1}{p}\Bigl[(1+r_p)\hat b_{j,s} + \hat w\,e_{j,s}\,n_{j,s} - \widehat{tax}_{j,s} - e^{g_y}\hat b_{j,s+1}\Bigr] + X_{j,s},
127+
```
128+
129+
where $\widehat{tax}_{j,s}$ depends only on $(\hat b_{j,s}, n_{j,s})$ through labor and capital income (already in the active set, so it adds no further coupling), and $X_{j,s}$ collects terms fixed in the inner solve (bequests $\hat{bq}_{j,s}$, remittances $\hat{rm}_{j,s}$, government transfers $\hat{tr}_{j,s}$, UBI $\hat{ubi}_{j,s}$, the pension benefit $\theta_j$, and the $\hat c_{min,i}$ terms). The labor Euler equation {eq}`EqStnrz_eul_n` at age $s$ therefore depends on $\{\hat b_{j,s},\hat b_{j,s+1},n_{j,s}\}$ alone, and the savings Euler equation {eq}`EqStnrz_eul_b`---which links $\hat c_{j,s}$ to $\hat b_{j,s+1}$ and $\hat c_{j,s+1}$---depends on $\{\hat b_{j,s},\hat b_{j,s+1},\hat b_{j,s+2},n_{j,s},n_{j,s+1}\}$. The marginal tax rates $\tau^{mtrx}_s$ and $\tau^{mtry}_{s+1}$ are functions of own-age income (already in these sets), so they add no further coupling, and the terminal condition {eq}`EqStnrz_eul_bS` is sparser still. Each of the $2S$ equations therefore depends on at most five of the $2S$ unknowns, regardless of $S$, so the Jacobian has at most $10S$ nonzero entries rather than the $(2S)^2 = 4S^2$ of a fully coupled system. This is the per-cohort counterpart to the dense $2JS$ system noted at the start of Chapter {ref}`Chap_Eqm`: cohorts couple only through prices, which are held fixed in the inner solve. A finite-difference Jacobian can then be built from a number of evaluations set by the bandwidth---about seven at $S = 80$---rather than $2S$, by probing together unknowns that affect no common equation (Figure {numref}`FigHHjacSparsity`).

docs/book/content/theory/equilibrium.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ In all of the specifications of `OG-Core`, we use a two-stage fixed point algori
2525

2626
Our approach is to choose the minimum number of macroeconomic variables in an outer loop in order to be able to solve the household's $2JS$ Euler equations in terms of only the $\bar{n}_{j,s}$ and $\bar{b}_{j,s+1}$ variables directly, holding all other variables constant. The household system of Euler equations has a provable root solution and is orders of magnitude more tractable (less nonlinear) to solve holding these outer loop variables constant.
2727

28+
Moreover, with the outer-loop variables held fixed, each cohort's system of $2S$ Euler equations is not only less nonlinear but structurally sparse: every equation involves at most five of the $2S$ unknowns---a household's own age and its immediate neighbors. The root finder normally probes each unknown separately when building each step ($2S = 160$ evaluations of the system when $S = 80$), but with most equations depending on only a handful of unknowns, those affecting no common equation can be probed together, cutting the count to about seven at $S = 80$---a number set by how many neighbors couple, not by $S$. The parameter `use_sparse_FOC_jac` (default `True`) controls this; set it to `False` to use the legacy dense-finite-difference Jacobian on every call. The structure is derived in Appendix {ref}`SecAppDerivHHjac`.
29+
30+
```{figure} ./images/HH_jac_sparsity.png
31+
---
32+
name: FigHHjacSparsity
33+
---
34+
Sparsity pattern of the household equation Jacobian, at $S = 12$. Left: the standard finite-difference solve treats every entry of the $2S\times 2S$ matrix as live ($(2S)^2 = 576$ entries). Right: the actual structure---each Euler equation depends only on a household's own age and its immediate neighbors, leaving most entries zero (92 of 576 here; 636 of 25{,}600 at the default $S = 80$).
35+
```
36+
2837
The steady-state solution method for each of the cases above is associated with a solution method that has a subset of the following outer-loop variables $\Bigl\{\bar{r}_p, \bar{r}, \bar{w}, \{\bar{p}_m\}_{m=1}^{M-1}, \bar{Y}, \overline{TR}, \overline{BQ}, factor\Bigr\}$.
2938

3039

65.5 KB
Loading

ogcore/TPI.py

Lines changed: 186 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import numpy as np
1414
import pickle
1515
import scipy.optimize as opt
16+
from scipy.sparse import csr_matrix
1617
from ogcore import tax, utils, household, firm, fiscal, pensions
1718
from ogcore import aggregates as aggr
1819
from ogcore.constants import SHOW_RUNTIME
@@ -21,6 +22,19 @@
2122
import logging
2223
from distributed import wait
2324

25+
# ``approx_derivative`` and ``group_columns`` live in a private scipy module
26+
# (scipy.optimize._numdiff). Guard the import so a future scipy change disables
27+
# the optional sparse-FOC-Jacobian path -- falling back to dense finite
28+
# differences -- instead of breaking TPI entirely.
29+
try:
30+
from scipy.optimize._numdiff import approx_derivative, group_columns
31+
32+
_HAVE_SPARSE_FD = True
33+
except Exception: # pragma: no cover
34+
approx_derivative = None
35+
group_columns = None
36+
_HAVE_SPARSE_FD = False
37+
2438
logger = logging.getLogger(__name__)
2539

2640
if not SHOW_RUNTIME:
@@ -37,6 +51,91 @@
3751
ENFORCE_SOLUTION_CHECKS = True
3852

3953

54+
_BANDED_JAC_CACHE = {}
55+
56+
57+
def _detect_jac_sparsity(fun, x0, args, thr_rel=1e-9):
58+
"""Auto-detect the Jacobian sparsity of `fun` near x0 and return a
59+
(csr_pattern, groups) coloring for sparse finite differences, or None if
60+
the Jacobian is too dense to benefit (caller then uses plain dense FD).
61+
62+
One-time per problem dimension (cached). The pattern is unioned over two
63+
nearby points so a coupling that happens to vanish at one point is not
64+
missed. Portable across countries/calibrations/pensions: it reads the
65+
*actual* structure each run rather than assuming a stencil, so a
66+
history-dependent pension (dense columns) is detected automatically and
67+
falls back to dense finite differences.
68+
69+
Args:
70+
fun (function): the residual function whose Jacobian is probed
71+
x0 (array_like): point at which to probe the sparsity
72+
args (tuple): extra arguments passed to ``fun``
73+
thr_rel (float): relative threshold below which an entry is treated
74+
as a structural zero
75+
76+
Returns:
77+
result (tuple or None): ``(csr_pattern, groups)`` coloring for sparse
78+
finite differences, or None if the Jacobian is not sparse enough
79+
to benefit
80+
"""
81+
n = len(x0)
82+
key = (getattr(fun, "__name__", repr(fun)), n)
83+
cached = _BANDED_JAC_CACHE.get(key, "miss")
84+
if cached != "miss":
85+
return cached
86+
result = None
87+
try:
88+
x = np.asarray(x0, dtype=float)
89+
mask = np.zeros((n, n), dtype=bool)
90+
for xp in (x, x * 1.05 + 0.01):
91+
Jd = approx_derivative(fun, xp, args=args, method="2-point")
92+
sc = max(float(np.max(np.abs(Jd))), 1e-300)
93+
mask |= np.abs(Jd) > thr_rel * sc
94+
P = csr_matrix(mask.astype(int))
95+
groups = group_columns(P)
96+
if int(groups.max()) + 1 < n // 2:
97+
result = (P, groups)
98+
# else: pattern not sparse enough to benefit (expected at small
99+
# firstdoughnutring dims); silently return None so the caller uses
100+
# dense FD without surfacing a noisy notice.
101+
except Exception:
102+
result = None
103+
_BANDED_JAC_CACHE[key] = result
104+
return result
105+
106+
107+
def _banded_jac_for(fun, x0, args, enabled):
108+
"""Return a sparse finite-difference `jac` callable for ``opt.root`` when
109+
``enabled`` (``p.use_sparse_FOC_jac``) and the Jacobian is sparse enough to
110+
benefit; otherwise None, so the caller uses the default dense solver. Used
111+
at the household full-lifetime and triangle root-finds.
112+
113+
Args:
114+
fun (function): the residual function passed to ``opt.root``
115+
x0 (array_like): initial guess for the root-find
116+
args (tuple): extra arguments passed to ``fun``
117+
enabled (bool): value of ``p.use_sparse_FOC_jac``
118+
119+
Returns:
120+
_jac (function or None): a callable returning the dense Jacobian built
121+
by sparse finite differences, or None to use dense finite
122+
differences
123+
"""
124+
if not enabled or not _HAVE_SPARSE_FD:
125+
return None
126+
bj = _detect_jac_sparsity(fun, x0, args)
127+
if bj is None:
128+
return None
129+
P, groups = bj
130+
131+
def _jac(_x, *_a):
132+
return approx_derivative(
133+
fun, _x, args=_a, method="2-point", sparsity=(P, groups)
134+
).toarray()
135+
136+
return _jac
137+
138+
40139
def get_initial_SS_values(p):
41140
"""
42141
Get values of variables for the initial period and the steady state
@@ -489,32 +588,47 @@ def inner_loop(guesses, outer_loop_vars, initial_values, ubi, j, ind, p):
489588
]
490589
mtry_params_to_use = _params_to_array(temp_mtry, p.tax_func_type)
491590

492-
solutions = opt.root(
493-
twist_doughnut,
494-
list(b_guesses_to_use) + list(n_guesses_to_use),
495-
args=(
496-
r_p,
497-
w,
498-
p_tilde,
499-
p_i,
500-
bq_to_use,
501-
rm_to_use,
502-
tr_to_use,
503-
theta_to_use,
504-
factor,
505-
ubi_to_use,
506-
j,
507-
s,
508-
0,
509-
etr_params_to_use,
510-
mtrx_params_to_use,
511-
mtry_params_to_use,
512-
initial_b,
513-
p,
514-
),
515-
method=p.FOC_root_method,
516-
tol=MINIMIZER_TOL,
591+
_tri_x0 = list(b_guesses_to_use) + list(n_guesses_to_use)
592+
_tri_args = (
593+
r_p,
594+
w,
595+
p_tilde,
596+
p_i,
597+
bq_to_use,
598+
rm_to_use,
599+
tr_to_use,
600+
theta_to_use,
601+
factor,
602+
ubi_to_use,
603+
j,
604+
s,
605+
0,
606+
etr_params_to_use,
607+
mtrx_params_to_use,
608+
mtry_params_to_use,
609+
initial_b,
610+
p,
611+
)
612+
_tri_jac = _banded_jac_for(
613+
twist_doughnut, _tri_x0, _tri_args, p.use_sparse_FOC_jac
517614
)
615+
try:
616+
solutions = opt.root(
617+
twist_doughnut,
618+
_tri_x0,
619+
args=_tri_args,
620+
method=p.FOC_root_method,
621+
tol=MINIMIZER_TOL,
622+
jac=_tri_jac,
623+
)
624+
except Exception:
625+
solutions = opt.root(
626+
twist_doughnut,
627+
_tri_x0,
628+
args=_tri_args,
629+
method=p.FOC_root_method,
630+
tol=MINIMIZER_TOL,
631+
)
518632

519633
b_vec = solutions.x[: int(len(solutions.x) / 2)]
520634
b_mat[ind2, p.S - (s + 2) + ind2] = b_vec
@@ -554,32 +668,52 @@ def inner_loop(guesses, outer_loop_vars, initial_values, ubi, j, ind, p):
554668
p.tax_func_type,
555669
)
556670

557-
solutions = opt.root(
558-
twist_doughnut,
559-
list(b_guesses_to_use) + list(n_guesses_to_use),
560-
args=(
561-
r_p,
562-
w,
563-
p_tilde,
564-
p_i,
565-
bq_to_use,
566-
rm_to_use,
567-
tr_to_use,
568-
theta_to_use,
569-
factor,
570-
ubi_to_use,
571-
j,
572-
None,
573-
t,
574-
etr_params_to_use,
575-
mtrx_params_to_use,
576-
mtry_params_to_use,
577-
initial_b,
578-
p,
579-
),
580-
method=p.FOC_root_method,
581-
tol=MINIMIZER_TOL,
671+
_td_args = (
672+
r_p,
673+
w,
674+
p_tilde,
675+
p_i,
676+
bq_to_use,
677+
rm_to_use,
678+
tr_to_use,
679+
theta_to_use,
680+
factor,
681+
ubi_to_use,
682+
j,
683+
None,
684+
t,
685+
etr_params_to_use,
686+
mtrx_params_to_use,
687+
mtry_params_to_use,
688+
initial_b,
689+
p,
582690
)
691+
_td_x0 = list(b_guesses_to_use) + list(n_guesses_to_use)
692+
# When p.use_sparse_FOC_jac is set, supply a banded finite-difference
693+
# Jacobian (far fewer evaluations per build); otherwise _bj_jac is None
694+
# and opt.root uses its default dense finite differences. The except
695+
# clause is a safety net: any solver failure retries with the dense
696+
# Jacobian, so the result is unchanged.
697+
_bj_jac = _banded_jac_for(
698+
twist_doughnut, _td_x0, _td_args, p.use_sparse_FOC_jac
699+
)
700+
try:
701+
solutions = opt.root(
702+
twist_doughnut,
703+
_td_x0,
704+
args=_td_args,
705+
method=p.FOC_root_method,
706+
tol=MINIMIZER_TOL,
707+
jac=_bj_jac,
708+
)
709+
except Exception:
710+
solutions = opt.root(
711+
twist_doughnut,
712+
_td_x0,
713+
args=_td_args,
714+
method=p.FOC_root_method,
715+
tol=MINIMIZER_TOL,
716+
)
583717
euler_errors[t, :] = solutions.fun
584718

585719
b_vec = solutions.x[: p.S]
@@ -603,6 +737,8 @@ def run_TPI(p, client=None):
603737
results
604738
605739
"""
740+
if p.use_sparse_FOC_jac and not _HAVE_SPARSE_FD:
741+
print("[TPI] sparse FOC jacobian unavailable; using dense")
606742
# unpack tuples of parameters
607743
initial_values, ss_vars, theta, baseline_values = get_initial_SS_values(p)
608744
B0, b_sinit, b_splus1init, factor, initial_b, initial_n = initial_values

ogcore/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@
2121
from ogcore.txfunc import * # noqa: F403
2222
from ogcore.utils import * # noqa: F403
2323

24-
__version__ = "0.16.0"
24+
__version__ = "0.16.1"

ogcore/default_parameters.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4392,6 +4392,23 @@
43924392
"choice": {"choices": [true, false]}
43934393
}
43944394
},
4395+
"use_sparse_FOC_jac": {
4396+
"title": "Flag to use a sparse (banded) finite-difference Jacobian in the household first order condition root finder",
4397+
"description": "Flag to use a sparse (banded) finite-difference Jacobian in the household first order condition root finder. When True (the default), the sparsity pattern of the stacked Euler/labor first order conditions is auto-detected once per problem size and supplied to scipy.optimize.root, which then needs far fewer function evaluations per Jacobian build than the default dense finite differences. The solver falls back to the dense finite-difference Jacobian automatically if the Jacobian is not sparse enough to benefit or if a solve fails. Set to False to use the legacy dense Jacobian on every call.",
4398+
"short_description": "Use a banded finite-difference Jacobian in the household solver",
4399+
"param_notation": "$\\texttt{use_sparse_FOC_jac}$",
4400+
"section_1": "Model Solution Parameters",
4401+
"notes": "",
4402+
"type": "bool",
4403+
"value": [
4404+
{
4405+
"value": true
4406+
}
4407+
],
4408+
"validators": {
4409+
"choice": {"choices": [true, false]}
4410+
}
4411+
},
43954412
"start_year": {
43964413
"title": "Calendar year in which to start model analysis",
43974414
"description": "Calendar year in which to start model analysis.",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ogcore"
7-
version = "0.16.0"
7+
version = "0.16.1"
88
authors = [
99
{name = "Jason DeBacker and Richard W. Evans"},
1010
]

0 commit comments

Comments
 (0)