Skip to content

Commit a0c9ade

Browse files
committed
Merge remote-tracking branch 'upstream/master' into demog_J
2 parents bdf51ec + 7cae932 commit a0c9ade

15 files changed

Lines changed: 521 additions & 30 deletions

File tree

.github/workflows/check_catalog.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
catalog-in-sync:
1919
runs-on: ubuntu-latest
2020
steps:
21-
- uses: actions/checkout@v6
21+
- uses: actions/checkout@v7
2222
- name: Emit catalog from each installer
2323
run: |
2424
bash scripts/install.sh --list-json > /tmp/catalog_sh.json

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ 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.4] - 2026-07-02 12:00:00
9+
10+
### Added
11+
12+
- Adds an opt-in accelerated update rule for the TPI outer loop, selected by the new `TPI_outer_method` parameter (default `"picard"`, which leaves the model's historical damped functional iteration and all model solutions unchanged). Setting `TPI_outer_method="anderson"` uses limited-memory Anderson acceleration on the outer-loop residual history, guarded by a trust region anchored to the always-feasible damped point (controlled by `TPI_anderson_m`, `TPI_anderson_beta`, and `TPI_trust_radius`). On a stiff multi-industry reform that limit-cycles under constant dampening, this converged to the same equilibrium in 53 outer iterations vs 126 for constant `nu=0.1` (about −37% wall-clock vs the best damped-`nu` schedule). The new solver lives in `ogcore/solvers.py`. See PR [#1164](https://github.com/PSLmodels/OG-Core/pull/1164).
13+
814
## [0.16.3] - 2026-06-25 15:00:00
915

1016
### Added

docs/book/content/api/public_api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ There is also a link to the source code for each documented member.
2424
parameter_tables
2525
parameters
2626
pensions
27+
solvers
2728
tax
2829
txfunc
2930
utils

docs/book/content/api/solvers.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.. _solvers:
2+
3+
Solvers
4+
=================================================
5+
6+
**solvers.py classes and functions**
7+
8+
ogcore.solvers
9+
------------------------------------------
10+
11+
.. currentmodule:: ogcore.solvers
12+
13+
.. autoclass:: AndersonAccelerator
14+
:members: update, reset
15+
16+
.. automodule:: ogcore.solvers
17+
:members: make_outer_updater, pack_outer_vars, unpack_outer_vars

examples/run_ogcore_example.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ def main():
8585
"""
8686
# update the effective corporate income tax rate
8787
og_spec.update({"cit_rate": [[0.35]]})
88+
# Optional: accelerate the TPI outer loop with anchored Anderson
89+
# acceleration, which solves to the same equilibrium in roughly half
90+
# the outer-loop iterations (see the TPI_outer_method parameter).
91+
# og_spec.update({"TPI_outer_method": "anderson"})
8892
p2 = Specifications(
8993
baseline=False,
9094
num_workers=num_workers,

ogcore/SS.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -912,7 +912,7 @@ def SS_solver(
912912
(p.S, 1),
913913
)
914914
capital_noncompliance_rate_2D = np.tile(
915-
np.reshape(p.labor_income_tax_noncompliance_rate[-1, :], (1, p.J)),
915+
np.reshape(p.capital_income_tax_noncompliance_rate[-1, :], (1, p.J)),
916916
(p.S, 1),
917917
)
918918
income_tax_filer_2D = np.tile(

ogcore/TPI.py

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pickle
1515
import scipy.optimize as opt
1616
from scipy.sparse import csr_matrix
17-
from ogcore import tax, utils, household, firm, fiscal, pensions
17+
from ogcore import tax, utils, household, firm, fiscal, pensions, solvers
1818
from ogcore import aggregates as aggr
1919
from ogcore.constants import SHOW_RUNTIME
2020
import os
@@ -924,6 +924,27 @@ def run_TPI(p, client=None):
924924
TPIdist = 10
925925
euler_errors = np.zeros((p.T, 2 * p.S, p.J))
926926
TPIdist_vec = np.zeros(p.maxiter)
927+
# Pluggable outer-loop update rule. Default "picard" -> None -> the native
928+
# damped functional-iteration path below (unchanged, so golden outputs
929+
# are preserved); "anderson" accelerates using the residual history. See
930+
# ogcore.solvers.
931+
outer_updater = solvers.make_outer_updater(
932+
getattr(p, "TPI_outer_method", "picard"), p
933+
)
934+
# Optional trust region for the accelerated step ("anchored" Anderson):
935+
# clamp each step to within trust_radius x the damped-step length of the
936+
# always-feasible damped point, growing the radius after an improving step
937+
# and shrinking it (with a reset) after a worsening one. This keeps the
938+
# accelerated iterate near a feasible point -- the standard cure for the
939+
# overshoot-into-infeasible-region divergence of unguarded accelerators.
940+
# Defaults to 1.0 (anchored) when accelerating; a non-positive radius
941+
# disables the trust region (unguarded accelerator, for testing only).
942+
trust_radius = getattr(p, "TPI_trust_radius", 1.0)
943+
if trust_radius is not None and trust_radius <= 0:
944+
trust_radius = None
945+
trust_radius_min = getattr(p, "TPI_trust_radius_min", 0.1)
946+
trust_radius_max = getattr(p, "TPI_trust_radius_max", 10.0)
947+
prev_accel_dist = np.inf
927948

928949
# Before scattering, temporarily remove unpicklable schema objects
929950
schema_backup = {}
@@ -1366,17 +1387,80 @@ def run_TPI(p, client=None):
13661387
RM = np.concatenate([RM, np.ones(p.S) * RM[-1]])
13671388

13681389
# update vars for next iteration
1369-
w[: p.T] = utils.convex_combo(wnew[: p.T], w[: p.T], p.nu)
1370-
r[: p.T] = utils.convex_combo(rnew[: p.T], r[: p.T], p.nu)
1390+
if outer_updater is None:
1391+
# "picard": the historical damped functional-iteration step,
1392+
# unchanged, so the default behavior (and golden outputs) is
1393+
# preserved exactly.
1394+
w[: p.T] = utils.convex_combo(wnew[: p.T], w[: p.T], p.nu)
1395+
r[: p.T] = utils.convex_combo(rnew[: p.T], r[: p.T], p.nu)
1396+
r_p[: p.T] = utils.convex_combo(r_p_new[: p.T], r_p[: p.T], p.nu)
1397+
p_m[: p.T, :] = utils.convex_combo(
1398+
new_p_m[: p.T, :], p_m[: p.T, :], p.nu
1399+
)
1400+
BQ[: p.T] = utils.convex_combo(BQnew[: p.T], BQ[: p.T], p.nu)
1401+
if not p.baseline_spending:
1402+
TR[: p.T] = utils.convex_combo(TR_new[: p.T], TR[: p.T], p.nu)
1403+
else:
1404+
# Accelerated outer step on the packed macro/price vector
1405+
# {r_p, r, w, p_m, BQ[, TR]}; the update rule (Anderson) uses the
1406+
# recent residual history. Auxiliaries stay damped below.
1407+
blocks = [
1408+
(r_p, r_p_new),
1409+
(r, rnew),
1410+
(w, wnew),
1411+
(p_m, new_p_m),
1412+
(BQ, BQnew),
1413+
]
1414+
if not p.baseline_spending:
1415+
blocks.append((TR, TR_new))
1416+
x, gx = solvers.pack_outer_vars(blocks, p.T)
1417+
# True fixed-point residual (implied vs the PRE-update guess). The
1418+
# post-update TPIdist below is spuriously ~0 for steps that set
1419+
# x_next ~= gx (e.g. Anderson's undamped first step), so it is
1420+
# overridden with this to avoid declaring false convergence.
1421+
accel_dist = float(np.max(utils.pct_diff_func(gx, x)))
1422+
# Anchored/trust-region control: grow the radius after an improving
1423+
# accelerated step and shrink it (resetting the memory) after a
1424+
# worsening one, using the residual trend as the accept/reject
1425+
# signal. None => unclamped.
1426+
if trust_radius is not None and TPIiter > 0:
1427+
if accel_dist <= prev_accel_dist:
1428+
trust_radius = min(trust_radius_max, trust_radius * 1.5)
1429+
else:
1430+
trust_radius = max(trust_radius_min, trust_radius * 0.5)
1431+
outer_updater.reset()
1432+
logger.info(
1433+
f"accel step worsened; trust radius -> {trust_radius}"
1434+
)
1435+
prev_accel_dist = accel_dist
1436+
# Accelerate the DAMPED map (1-nu)x + nu*G(x), which is contractive
1437+
# at the calibrated nu, rather than the raw map G -- G is
1438+
# non-contractive on the stiff case (why Picard needs a low nu), so
1439+
# accelerating it directly overshoots and diverges. Same fixed
1440+
# point; convergence is still measured on the raw residual above.
1441+
gx_damped = (1.0 - p.nu) * x + p.nu * gx
1442+
x_next = outer_updater.update(x, gx_damped)
1443+
if not np.all(np.isfinite(x_next)):
1444+
# accelerated step blew up -> damped Picard fallback + reset
1445+
x_next = p.nu * gx + (1.0 - p.nu) * x
1446+
outer_updater.reset()
1447+
logger.info(
1448+
"accelerated step non-finite; Picard fallback + reset"
1449+
)
1450+
elif trust_radius is not None:
1451+
# Clamp the accelerated step to the trust region around the
1452+
# always-feasible damped point gx_damped, sized relative to the
1453+
# damped step length so it tightens as the solve converges.
1454+
dev = x_next - gx_damped
1455+
dev_norm = float(np.linalg.norm(dev))
1456+
max_dev = trust_radius * float(np.linalg.norm(gx_damped - x))
1457+
if dev_norm > max_dev > 0.0:
1458+
x_next = gx_damped + dev * (max_dev / dev_norm)
1459+
solvers.unpack_outer_vars(x_next, blocks, p.T)
1460+
# Auxiliaries (unchanged): government rate, debt, and the household
1461+
# policy warm-starts stay on the damped update.
13711462
r_gov[: p.T] = utils.convex_combo(r_gov_new[: p.T], r_gov[: p.T], p.nu)
1372-
r_p[: p.T] = utils.convex_combo(r_p_new[: p.T], r_p[: p.T], p.nu)
1373-
p_m[: p.T, :] = utils.convex_combo(
1374-
new_p_m[: p.T, :], p_m[: p.T, :], p.nu
1375-
)
1376-
BQ[: p.T] = utils.convex_combo(BQnew[: p.T], BQ[: p.T], p.nu)
13771463
D[: p.T] = Dnew[: p.T]
1378-
if not p.baseline_spending:
1379-
TR[: p.T] = utils.convex_combo(TR_new[: p.T], TR[: p.T], p.nu)
13801464
guesses_b = utils.convex_combo(b_mat, guesses_b, p.nu)
13811465
guesses_n = utils.convex_combo(n_mat, guesses_n, p.nu)
13821466
logger.info(
@@ -1414,15 +1498,23 @@ def run_TPI(p, client=None):
14141498
+ list(utils.pct_diff_func(BQnew[: p.T], BQ[: p.T]).flatten())
14151499
+ list(utils.pct_diff_func(TR_new[: p.T], TR[: p.T]))
14161500
).max()
1501+
if outer_updater is not None:
1502+
# accelerated methods: use the true residual accel_dist, computed
1503+
# above from the pre-update guess, because the post-update
1504+
# distance can be spuriously ~0 for accelerated steps.
1505+
TPIdist = accel_dist
14171506

14181507
TPIdist_vec[TPIiter] = TPIdist
1419-
# After T=10, if cycling occurs, drop the value of nu
1420-
# wait til after T=10 or so, because sometimes there is a jump up
1421-
# in the first couple iterations
1422-
# if TPIiter > 10:
1423-
# if TPIdist_vec[TPIiter] - TPIdist_vec[TPIiter - 1] > 0:
1424-
# nu /= 2
1425-
# print 'New Value of nu:', nu
1508+
# Accelerated safety net: if a step raised the distance sharply or went
1509+
# non-finite, reset the accelerator so the next step is a fresh damped
1510+
# restart (prevents runaway on the stiff case). Never fires for picard.
1511+
if outer_updater is not None and TPIiter > 0:
1512+
if (
1513+
not np.isfinite(TPIdist)
1514+
or TPIdist > 10.0 * TPIdist_vec[TPIiter - 1]
1515+
):
1516+
outer_updater.reset()
1517+
logger.info("accelerated step diverged; reset accelerator")
14261518
TPIiter += 1
14271519
logger.info(f"Iteration: {TPIiter}")
14281520
logger.info(f"Distance: {TPIdist}")

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.3"
24+
__version__ = "0.16.4"

ogcore/default_parameters.json

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -956,9 +956,9 @@
956956
},
957957
"alpha_FA": {
958958
"title": "Foreign aid payments to domestic government as a share of GDP",
959-
"description": "Foreign aid payments to domestic government as a share of GDP.",
959+
"description": "Foreign aid payments to domestic government as a share of GDP. Set value for base year, click '+' to add value for next year. All future years not specified are set to last value entered.",
960960
"short_description": "Foreign aid as a share of GDP",
961-
"param_notation": "$\\alpha_{FA}$",
961+
"param_notation": "$\\alpha_{FA,t}$",
962962
"section_1": "Fiscal Policy Parameters",
963963
"section_2": "Fiscal Policy Parameters",
964964
"notes": "",
@@ -4590,6 +4590,84 @@
45904590
}
45914591
}
45924592
},
4593+
"TPI_outer_method": {
4594+
"title": "Update rule for the TPI outer (price/aggregate) loop",
4595+
"description": "Update rule for the transition-path outer loop. 'picard' (default) is the model's historical damped functional iteration x <- (1-nu) x + nu G(x) (see nu), which leaves model solutions unchanged. 'anderson' uses limited-memory Anderson acceleration on the residual history to take larger, better-directed steps, guarded by a trust region anchored to the damped point (TPI_trust_radius).",
4596+
"short_description": "TPI outer-loop update rule",
4597+
"section_1": "Model Solution Parameters",
4598+
"notes": "Opt-in solver acceleration. The default ('picard') reproduces the constant-nu behavior exactly.",
4599+
"type": "str",
4600+
"value": [
4601+
{
4602+
"value": "picard"
4603+
}
4604+
],
4605+
"validators": {
4606+
"choice": {
4607+
"choices": [
4608+
"picard",
4609+
"anderson"
4610+
]
4611+
}
4612+
}
4613+
},
4614+
"TPI_anderson_m": {
4615+
"title": "Anderson acceleration memory depth",
4616+
"description": "Number of past iterate/residual differences retained by the Anderson accelerator when TPI_outer_method='anderson'. Ignored otherwise.",
4617+
"short_description": "Anderson memory depth (m)",
4618+
"section_1": "Model Solution Parameters",
4619+
"notes": "",
4620+
"type": "int",
4621+
"value": [
4622+
{
4623+
"value": 5
4624+
}
4625+
],
4626+
"validators": {
4627+
"range": {
4628+
"min": 1,
4629+
"max": 50
4630+
}
4631+
}
4632+
},
4633+
"TPI_anderson_beta": {
4634+
"title": "Anderson acceleration mixing parameter",
4635+
"description": "Mixing weight for the Anderson step when TPI_outer_method='anderson'. beta=1 is undamped; beta<1 adds damping for robustness far from the solution. Ignored otherwise.",
4636+
"short_description": "Anderson mixing (beta)",
4637+
"section_1": "Model Solution Parameters",
4638+
"notes": "",
4639+
"type": "float",
4640+
"value": [
4641+
{
4642+
"value": 1.0
4643+
}
4644+
],
4645+
"validators": {
4646+
"range": {
4647+
"min": 0.1,
4648+
"max": 1.0
4649+
}
4650+
}
4651+
},
4652+
"TPI_trust_radius": {
4653+
"title": "Trust radius for the anchored Anderson step",
4654+
"description": "Initial trust radius for the accelerated TPI step, as a multiple of the damped functional-iteration step length around the always-feasible damped point. Grown after an improving iteration and shrunk (with a reset) after a worsening one. A non-positive value disables the trust region (unguarded accelerator; not recommended). Ignored when TPI_outer_method='picard'.",
4655+
"short_description": "Anchored-Anderson trust radius",
4656+
"section_1": "Model Solution Parameters",
4657+
"notes": "",
4658+
"type": "float",
4659+
"value": [
4660+
{
4661+
"value": 1.0
4662+
}
4663+
],
4664+
"validators": {
4665+
"range": {
4666+
"min": 0.0,
4667+
"max": 100.0
4668+
}
4669+
}
4670+
},
45934671
"SS_root_method": {
45944672
"title": "Root finding algorithm for outer loop of the SS solution",
45954673
"description": "Root finding algorithm for outer loop of the SS solution.",

ogcore/parameters.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ def compute_default_params(self):
181181
"alpha_G",
182182
"alpha_T",
183183
"alpha_I",
184+
"alpha_FA",
184185
"alpha_bs_G",
185186
"alpha_bs_T",
186187
"alpha_bs_I",

0 commit comments

Comments
 (0)