Skip to content

Commit 200fa11

Browse files
authored
Merge pull request #1178 from marcelolafleur/feature/tpi-stall-detection
Stop the TPI outer loop early when it has stalled, with a diagnosis
2 parents 3978542 + 956666d commit 200fa11

5 files changed

Lines changed: 182 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Stall detection for the TPI outer loop (Issue #1177): when the best
13+
distance has not improved over the last `TPI_stall_window` iterations
14+
(default 50; 0 disables), `run_TPI` logs a diagnosis distinguishing a
15+
cycling outer loop (suggesting a lower `nu` or
16+
`TPI_outer_method="anderson"`) from a diverging economy (usually an
17+
inconsistent fiscal block, which solver settings cannot fix). The
18+
default `TPI_stall_action="warn"` only logs, leaving model solutions
19+
unchanged; `"stop"` also ends the loop early, so a hopeless run fails
20+
through the existing non-convergence checks instead of spending the
21+
rest of `maxiter`. The window check lives in
22+
`ogcore.solvers.diagnose_stall` and works for both the picard and
23+
anderson update rules. The diagnosis is re-logged if it changes (e.g.
24+
escalates from cycling to diverging), and a run that ends unconverged
25+
while stalled carries the diagnosis in the `RuntimeError` message, so
26+
it reaches users who only see the traceback.
1027
### Bug Fixes
1128

1229
- Fixes Issue [#1186](https://github.com/PSLmodels/OG-Core/issues/1186):
@@ -720,7 +737,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
720737
[0.17.0]: https://github.com/PSLmodels/OG-Core/compare/v0.16.4...v0.17.0
721738
[0.16.4]: https://github.com/PSLmodels/OG-Core/compare/v0.16.3...v0.16.4
722739
[0.16.3]: https://github.com/PSLmodels/OG-Core/compare/v0.16.2...v0.16.3
723-
[0.16.3]: https://github.com/PSLmodels/OG-Core/compare/v0.16.2...v0.16.3
724740
[0.16.2]: https://github.com/PSLmodels/OG-Core/compare/v0.16.1...v0.16.2
725741
[0.16.1]: https://github.com/PSLmodels/OG-Core/compare/v0.16.0...v0.16.1
726742
[0.16.0]: https://github.com/PSLmodels/OG-Core/compare/v0.15.13...v0.16.0

‎ogcore/TPI.py‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,8 @@ def run_TPI(p, client=None):
931931
TPIdist = 10
932932
euler_errors = np.zeros((p.T, 2 * p.S, p.J))
933933
TPIdist_vec = np.zeros(p.maxiter)
934+
stall = None
935+
stall_reported = None
934936
# Pluggable outer-loop update rule. Default "picard" -> None -> the native
935937
# damped functional-iteration path below (unchanged, so golden outputs
936938
# are preserved); "anderson" accelerates using the residual history. See
@@ -1525,6 +1527,37 @@ def run_TPI(p, client=None):
15251527
TPIiter += 1
15261528
logger.info(f"Iteration: {TPIiter}")
15271529
logger.info(f"Distance: {TPIdist}")
1530+
# Stall detection: when the best distance has stopped improving
1531+
# over a window of iterations, further iterations repeat the same
1532+
# pattern and cannot reach the tolerance -- diagnose the cause and,
1533+
# if TPI_stall_action="stop", end the loop early (the solution
1534+
# checks after the loop then fail the run as any non-convergence).
1535+
# In the default warn mode the diagnosis is logged once per stall,
1536+
# and again only if it changes (e.g. escalates to diverging).
1537+
stall = solvers.diagnose_stall(
1538+
TPIdist_vec, TPIiter, p.TPI_stall_window
1539+
)
1540+
if stall != stall_reported:
1541+
stall_reported = stall
1542+
if stall == "diverging":
1543+
logger.error(
1544+
"TPI stalled and diverging: the best distance over "
1545+
f"the last {p.TPI_stall_window} iterations is far "
1546+
"above the earlier best. This usually signals an "
1547+
"inconsistent fiscal block (spending, revenue, and "
1548+
"debt_ratio_ss), not a solver problem."
1549+
)
1550+
elif stall == "oscillating":
1551+
logger.error(
1552+
"TPI stalled: the best distance has not improved "
1553+
f"over the last {p.TPI_stall_window} iterations "
1554+
f"(current {TPIdist:.2e}, tolerance "
1555+
f"{p.mindist_TPI}). The outer loop is cycling; try "
1556+
"a lower nu, or TPI_outer_method='anderson' if not "
1557+
"already enabled."
1558+
)
1559+
if stall is not None and p.TPI_stall_action == "stop":
1560+
break
15281561

15291562
# Compute effective and marginal tax rates for all agents
15301563
num_params = len(p.mtrx_params[0][0])
@@ -1788,9 +1821,18 @@ def run_TPI(p, client=None):
17881821
if (
17891822
(TPIiter >= p.maxiter) or (np.absolute(TPIdist) > p.mindist_TPI)
17901823
) and ENFORCE_SOLUTION_CHECKS:
1791-
raise RuntimeError(
1792-
"Transition path equlibrium not found" + " (TPIdist)"
1793-
)
1824+
msg = "Transition path equlibrium not found (TPIdist)"
1825+
if stall == "oscillating":
1826+
msg += (
1827+
"; the outer loop stalled cycling -- try a lower nu, or "
1828+
"TPI_outer_method='anderson'"
1829+
)
1830+
elif stall == "diverging":
1831+
msg += (
1832+
"; the outer loop stalled diverging -- check the fiscal "
1833+
"block (spending, revenue, debt_ratio_ss)"
1834+
)
1835+
raise RuntimeError(msg)
17941836

17951837
if (np.any(np.absolute(RC_error) >= p.RC_TPI)) and ENFORCE_SOLUTION_CHECKS:
17961838
raise RuntimeError(

‎ogcore/default_parameters.json‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4734,6 +4734,46 @@
47344734
}
47354735
}
47364736
},
4737+
"TPI_stall_window": {
4738+
"title": "Iteration window for TPI stall detection",
4739+
"description": "Number of trailing TPI outer-loop iterations over which the best distance must improve on the best from before the window. When it does not, the loop has stalled (cycling or diverging) and a diagnosis is logged; see TPI_stall_action for whether the loop also stops. A value of 0 disables stall detection.",
4740+
"short_description": "TPI stall-detection window",
4741+
"section_1": "Model Solution Parameters",
4742+
"notes": "",
4743+
"type": "int",
4744+
"value": [
4745+
{
4746+
"value": 50
4747+
}
4748+
],
4749+
"validators": {
4750+
"range": {
4751+
"min": 0,
4752+
"max": 500
4753+
}
4754+
}
4755+
},
4756+
"TPI_stall_action": {
4757+
"title": "Action when TPI stall detection fires",
4758+
"description": "What to do when stall detection (TPI_stall_window) diagnoses a stalled TPI outer loop. 'warn' (default) logs the diagnosis once and lets the loop continue, leaving model solutions unchanged; 'stop' also ends the loop early, so the run fails through the usual non-convergence checks instead of spending the rest of maxiter.",
4759+
"short_description": "TPI stall action",
4760+
"section_1": "Model Solution Parameters",
4761+
"notes": "",
4762+
"type": "str",
4763+
"value": [
4764+
{
4765+
"value": "warn"
4766+
}
4767+
],
4768+
"validators": {
4769+
"choice": {
4770+
"choices": [
4771+
"warn",
4772+
"stop"
4773+
]
4774+
}
4775+
}
4776+
},
47374777
"SS_root_method": {
47384778
"title": "Root finding algorithm for outer loop of the SS solution",
47394779
"description": "Root finding algorithm for outer loop of the SS solution.",

‎ogcore/solvers.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,49 @@ def make_outer_updater(method, p):
191191
raise ValueError(f"unknown TPI_outer_method: {method!r}")
192192

193193

194+
def diagnose_stall(dist_vec, iteration, window, tol=0.05, deterioration=2.0):
195+
"""
196+
Check the TPI outer loop's distance history for a stall.
197+
198+
The loop has stalled when the best distance over the most recent
199+
``window`` iterations is no better (by a relative margin ``tol``)
200+
than the best from before that window: the remaining iterations
201+
repeat the same pattern and cannot reach the tolerance. A recent
202+
best that is far worse than the earlier best (``deterioration``)
203+
means the path is drifting away -- a diverging economy, typically
204+
an inconsistent fiscal block; a bounce around the earlier best
205+
means the outer loop is cycling around the solution, typically
206+
``nu`` too large for the problem.
207+
208+
Args:
209+
dist_vec (Numpy array): per-iteration outer-loop distances,
210+
filled for the first ``iteration`` entries
211+
iteration (int): number of completed outer-loop iterations
212+
window (int): number of trailing iterations that must improve
213+
on the earlier best; a non-positive value disables the
214+
check
215+
tol (float): minimum relative improvement of the recent best
216+
over the earlier best that counts as progress
217+
deterioration (float): factor by which the recent best must
218+
exceed the earlier best to be read as divergence rather
219+
than cycling
220+
221+
Returns:
222+
diagnosis (str or None): None while the loop is progressing
223+
(or the check is disabled), otherwise "diverging" or
224+
"oscillating"
225+
"""
226+
if window <= 0 or iteration < 2 * window:
227+
return None
228+
recent = dist_vec[iteration - window : iteration]
229+
earlier_best = dist_vec[: iteration - window].min()
230+
if recent.min() < (1.0 - tol) * earlier_best:
231+
return None
232+
if recent.min() >= deterioration * earlier_best:
233+
return "diverging"
234+
return "oscillating"
235+
236+
194237
def _selftest():
195238
"""
196239
Validate the accelerator math on a linear contraction fixed point,

‎tests/test_TPI.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,43 @@ def test_anderson_scaling_and_reset():
335335
assert u._F == [] and u._X == []
336336

337337

338+
def test_stall_defaults_are_warn_only():
339+
# Stall detection defaults: a 50-iteration window and warn-only
340+
# action, so model solutions are unchanged.
341+
p = Specifications()
342+
assert p.TPI_stall_window == 50
343+
assert p.TPI_stall_action == "warn"
344+
345+
346+
def test_diagnose_stall_progressing_and_disabled():
347+
# A steadily improving distance history is never a stall, and a
348+
# non-positive window (or too-short history) disables the check.
349+
improving = 10.0 * 0.9 ** np.arange(100)
350+
assert solvers.diagnose_stall(improving, 100, 20, 0.05) is None
351+
stuck = np.full(100, 5.0)
352+
assert solvers.diagnose_stall(stuck, 100, 0, 0.05) is None
353+
assert solvers.diagnose_stall(stuck, 30, 20, 0.05) is None
354+
355+
356+
def test_diagnose_stall_oscillating():
357+
# A distance that bounces in a band without improving on the earlier
358+
# best is diagnosed as the outer loop cycling.
359+
rng = np.arange(100)
360+
bouncing = 0.3 + 0.25 * (-1.0) ** rng
361+
assert solvers.diagnose_stall(bouncing, 100, 20, 0.05) == "oscillating"
362+
363+
364+
def test_diagnose_stall_diverging():
365+
# A recent best far above the earlier best is diagnosed as a
366+
# diverging economy rather than a cycling solver -- whether the
367+
# growth is smooth or a widening bounce.
368+
growing = 0.1 * 1.05 ** np.arange(100)
369+
assert solvers.diagnose_stall(growing, 100, 20, 0.05) == "diverging"
370+
rng = np.arange(100)
371+
growing_bounce = 0.05 * 1.1**rng * (1.0 + 0.3 * (-1.0) ** rng)
372+
assert solvers.diagnose_stall(growing_bounce, 100, 20, 0.05) == "diverging"
373+
374+
338375
file_in1 = os.path.join(
339376
CUR_PATH, "test_io_data", "twist_doughnut_inputs_2.pkl"
340377
)

0 commit comments

Comments
 (0)