|
14 | 14 | import pickle |
15 | 15 | import scipy.optimize as opt |
16 | 16 | 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 |
18 | 18 | from ogcore import aggregates as aggr |
19 | 19 | from ogcore.constants import SHOW_RUNTIME |
20 | 20 | import os |
@@ -924,6 +924,27 @@ def run_TPI(p, client=None): |
924 | 924 | TPIdist = 10 |
925 | 925 | euler_errors = np.zeros((p.T, 2 * p.S, p.J)) |
926 | 926 | 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 |
927 | 948 |
|
928 | 949 | # Before scattering, temporarily remove unpicklable schema objects |
929 | 950 | schema_backup = {} |
@@ -1366,17 +1387,80 @@ def run_TPI(p, client=None): |
1366 | 1387 | RM = np.concatenate([RM, np.ones(p.S) * RM[-1]]) |
1367 | 1388 |
|
1368 | 1389 | # 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. |
1371 | 1462 | 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) |
1377 | 1463 | 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) |
1380 | 1464 | guesses_b = utils.convex_combo(b_mat, guesses_b, p.nu) |
1381 | 1465 | guesses_n = utils.convex_combo(n_mat, guesses_n, p.nu) |
1382 | 1466 | logger.info( |
@@ -1414,15 +1498,23 @@ def run_TPI(p, client=None): |
1414 | 1498 | + list(utils.pct_diff_func(BQnew[: p.T], BQ[: p.T]).flatten()) |
1415 | 1499 | + list(utils.pct_diff_func(TR_new[: p.T], TR[: p.T])) |
1416 | 1500 | ).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 |
1417 | 1506 |
|
1418 | 1507 | 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") |
1426 | 1518 | TPIiter += 1 |
1427 | 1519 | logger.info(f"Iteration: {TPIiter}") |
1428 | 1520 | logger.info(f"Distance: {TPIdist}") |
|
0 commit comments