Source code for ogcore.TPI

"""
Time path iteration (TPI) module for OG-Core.

This module contains the following functions:
    get_initial_SS_values()
    firstdoughnutring()
    twist_doughnut()
    inner_loop()
    run_TPI()
"""

# imports
import numpy as np
import pickle
import scipy.optimize as opt
from scipy.sparse import csr_matrix
from ogcore import tax, utils, household, firm, fiscal, pensions, solvers
from ogcore import aggregates as aggr
from ogcore.constants import SHOW_RUNTIME
import os
import uuid
import warnings
import logging
from distributed import wait

# ``approx_derivative`` and ``group_columns`` live in a private scipy module
# (scipy.optimize._numdiff). Guard the import so a future scipy change disables
# the optional sparse-FOC-Jacobian path -- falling back to dense finite
# differences -- instead of breaking TPI entirely.
try:
    from scipy.optimize._numdiff import approx_derivative, group_columns

    _HAVE_SPARSE_FD = True
except Exception:  # pragma: no cover
    approx_derivative = None
    group_columns = None
    _HAVE_SPARSE_FD = False

logger = logging.getLogger(__name__)

if not SHOW_RUNTIME:
    warnings.simplefilter("ignore", RuntimeWarning)

"""
Set minimizer tolerance
"""
MINIMIZER_TOL = 1e-13

"""
Set flag for enforcement of solution check
"""
ENFORCE_SOLUTION_CHECKS = True


# Cache of detected sparsity patterns, keyed by (function name, problem
# size) and scoped to a single run_TPI call (see _BANDED_JAC_SCOPE): the
# pattern depends on the parameterization, e.g. a Defined Benefits pension
# couples retirement-age conditions to working-age labor, so a pattern
# detected for an earlier run in this process, or on a long-lived dask
# worker, must not be reused for a later one.
_BANDED_JAC_CACHE = {}
_BANDED_JAC_SCOPE = None


def _detect_jac_sparsity(fun, x0, args, scope=None, thr_rel=1e-9):
    """Auto-detect the Jacobian sparsity of `fun` near x0 and return a
    (csr_pattern, groups) coloring for sparse finite differences, or None if
    the Jacobian is too dense to benefit (caller then uses plain dense FD).

    One-time per problem dimension within a run (cached; the cache is
    emptied when ``scope`` changes). The pattern is unioned over two
    nearby points so a coupling that happens to vanish at one point is not
    missed. Portable across countries/calibrations/pensions: it reads the
    *actual* structure each run rather than assuming a stencil, so a
    history-dependent pension (dense columns) is detected automatically and
    falls back to dense finite differences.

    Args:
        fun (function): the residual function whose Jacobian is probed
        x0 (array_like): point at which to probe the sparsity
        args (tuple): extra arguments passed to ``fun``
        scope (hashable or None): identifier of the run the pattern
            belongs to (``p._jac_cache_scope``, set by ``run_TPI``);
            cached patterns from a different scope are discarded
        thr_rel (float): relative threshold below which an entry is treated
            as a structural zero

    Returns:
        result (tuple or None): ``(csr_pattern, groups)`` coloring for sparse
            finite differences, or None if the Jacobian is not sparse enough
            to benefit
    """
    global _BANDED_JAC_SCOPE
    if scope != _BANDED_JAC_SCOPE:
        _BANDED_JAC_CACHE.clear()
        _BANDED_JAC_SCOPE = scope
    n = len(x0)
    key = (getattr(fun, "__name__", repr(fun)), n)
    cached = _BANDED_JAC_CACHE.get(key, "miss")
    if cached != "miss":
        return cached
    result = None
    try:
        x = np.asarray(x0, dtype=float)
        mask = np.zeros((n, n), dtype=bool)
        for xp in (x, x * 1.05 + 0.01):
            Jd = approx_derivative(fun, xp, args=args, method="2-point")
            sc = max(float(np.max(np.abs(Jd))), 1e-300)
            mask |= np.abs(Jd) > thr_rel * sc
        P = csr_matrix(mask.astype(int))
        groups = group_columns(P)
        if int(groups.max()) + 1 < n // 2:
            result = (P, groups)
        # else: pattern not sparse enough to benefit (expected at small
        # firstdoughnutring dims); silently return None so the caller uses
        # dense FD without surfacing a noisy notice.
    except Exception:
        result = None
    _BANDED_JAC_CACHE[key] = result
    return result


def _banded_jac_for(fun, x0, args, enabled):
    """Return a sparse finite-difference `jac` callable for ``opt.root`` when
    ``enabled`` (``p.use_sparse_FOC_jac``) and the Jacobian is sparse enough to
    benefit; otherwise None, so the caller uses the default dense solver. Used
    at the household full-lifetime and triangle root-finds.

    Args:
        fun (function): the residual function passed to ``opt.root``
        x0 (array_like): initial guess for the root-find
        args (tuple): extra arguments passed to ``fun``
        enabled (bool): value of ``p.use_sparse_FOC_jac``

    Returns:
        _jac (function or None): a callable returning the dense Jacobian built
            by sparse finite differences, or None to use dense finite
            differences
    """
    if not enabled or not _HAVE_SPARSE_FD:
        return None
    # the parameters object is the last element of args at both call sites
    p = args[-1]
    bj = _detect_jac_sparsity(
        fun, x0, args, scope=getattr(p, "_jac_cache_scope", None)
    )
    if bj is None:
        return None
    P, groups = bj

    def _jac(_x, *_a):
        return approx_derivative(
            fun, _x, args=_a, method="2-point", sparsity=(P, groups)
        ).toarray()

    return _jac


[docs] def get_initial_SS_values(p): """ Get values of variables for the initial period and the steady state equilibrium values. Args: p (OG-Core Specifications object): model parameters Returns: (tuple): initial period and steady state values: * initial_values (tuple): initial period variable values, (B0, b_sinit, b_splus1init, factor, initial_n), where B0 is aggregate household wealth and b_sinit and b_splus1init the S x J wealth held and savings chosen in the initial period * ss_vars (dictionary): dictionary with steady state solution results * theta (Numpy array): steady-state retirement replacement rates, length J * baseline_values (tuple): (Ybaseline, TRbaseline, Gbaseline, D0_baseline), GDP, lump sum transfer, and government spending amounts from the baseline model run """ baseline_ss = os.path.join(p.baseline_dir, "SS", "SS_vars.pkl") ss_baseline_vars = utils.safe_read_pickle(baseline_ss) factor = ss_baseline_vars["factor"] # Initial distribution of savings, b_sp1 chosen at age s and held at # s+1, scaled so that aggregate household wealth evaluated with the # period-0 population equals the steady-state aggregate. Its shape is # the steady-state distribution unless initial_hhwealth_dist (an S x J # matrix of shares of aggregate wealth) is given. When # initial_wealth_ratio is given, the level is revised inside run_TPI # so that initial wealth is that ratio times initial GDP. B0 = aggr.get_B(ss_baseline_vars["b_sp1"], p, "SS", True) if p.initial_hhwealth_dist is None: initial_b_sp1 = ss_baseline_vars["b_sp1"] * ( ss_baseline_vars["B"] / B0 ) else: initial_b_sp1_prelim = p.initial_hhwealth_dist * ss_baseline_vars["B"] initial_b_sp1 = initial_b_sp1_prelim * ( ss_baseline_vars["B"] / aggr.get_B(initial_b_sp1_prelim, p, "SS", True) ) initial_b = np.zeros((p.S, p.J)) initial_b[1:, :] = initial_b_sp1[:-1, :] initial_n = ss_baseline_vars["n"] # The DB/NDC/PS pension formulas need the labor supplied before the # time path begins by cohorts alive at t=0. Use the model's initial # labor condition (the same baseline object that initializes wealth); # pre-time-path wages are anchored to the period-0 wage of the # current path inside pensions.py. See Issue #1014 for a fully # history-consistent treatment. p.n_preTP = initial_n Ybaseline = None TRbaseline = None Gbaseline = None Ig_baseline = None if not p.baseline: baseline_tpi = os.path.join(p.baseline_dir, "TPI", "TPI_vars.pkl") tpi_baseline_vars = utils.safe_read_pickle(baseline_tpi) Ybaseline = tpi_baseline_vars["Y"] if p.baseline_spending: baseline_tpi = os.path.join(p.baseline_dir, "TPI", "TPI_vars.pkl") tpi_baseline_vars = utils.safe_read_pickle(baseline_tpi) TRbaseline = tpi_baseline_vars["TR"] Gbaseline = tpi_baseline_vars["G"] Ig_baseline = tpi_baseline_vars["I_g"] if p.baseline: ss_vars = ss_baseline_vars else: reform_ss_path = os.path.join(p.output_base, "SS", "SS_vars.pkl") ss_vars = utils.safe_read_pickle(reform_ss_path) theta = ss_vars["theta"] """ ------------------------------------------------------------------------ Set other parameters and initial values ------------------------------------------------------------------------ """ # Get an initial distribution of wealth with the initial population # distribution. When small_open=True, the value of K0 is used as a # placeholder for first-period wealth B0 = aggr.get_B(initial_b_sp1, p, "SS", True) b_sinit = initial_b.copy() b_splus1init = initial_b_sp1.copy() # Intial gov't debt and capital stock must match that in the baseline if not p.baseline: baseline_tpi = os.path.join(p.baseline_dir, "TPI", "TPI_vars.pkl") tpi_baseline_vars = utils.safe_read_pickle(baseline_tpi) if ( p.initial_wealth_ratio is not None or p.initial_hhwealth_dist is not None ): # The baseline's initial wealth was set by the initial wealth # parameters (and, for the ratio, solved for along with the # baseline path), so the reform must start from the same # period-0 wealth rather than from the steady-state values. # b_sp1_preTP is the savings chosen before the time path # began (held at t=0); an older baseline pickle without it is # rebuilt from the wealth held at t=0, keeping the current # last row (savings of the oldest cohort, which only enter # period-0 bequests) B0 = tpi_baseline_vars["B"][0] b_sinit = tpi_baseline_vars["b_s"][0, :, :] if "b_sp1_preTP" in tpi_baseline_vars: b_splus1init = tpi_baseline_vars["b_sp1_preTP"] else: b_splus1init = b_splus1init.copy() b_splus1init[:-1, :] = b_sinit[1:, :] D0_baseline = tpi_baseline_vars["D"][0] Kg0_baseline = tpi_baseline_vars["K_g"][0] else: D0_baseline = None Kg0_baseline = None initial_values = (B0, b_sinit, b_splus1init, factor, initial_n) baseline_values = ( Ybaseline, TRbaseline, Gbaseline, Ig_baseline, D0_baseline, Kg0_baseline, ) return initial_values, ss_vars, theta, baseline_values
[docs] def firstdoughnutring( guesses, r, w, p_tilde, p_i, bq, rm, tr, theta, factor, ubi, j, initial_b, p, ): """ Solves the first entries of the upper triangle of the twist doughnut. This is separate from the main TPI function because the values of b and n are scalars, so it is easier to just have a separate function for these cases. Args: guesses (Numpy array): initial guesses for b and n, length 2 r (scalar): real interest rate w (scalar): real wage rate p_tilde (scalar): composite good price p_i (Numpy array): output goods prices bq (scalar): bequest amounts by age rm (scalar): remittance amounts by age tr (scalar): government transfer amount theta (Numpy array): retirement replacement rates, length J factor (scalar): scaling factor converting model units to dollars ubi (scalar): individual UBI credit to household s=E+S of type j in period 0 j (int): index of ability type initial_b (Numpy array): SxJ matrix, savings of agents alive at T=0 p (OG-Core Specifications object): model parameters Returns: euler errors (Numpy array): errors from first order conditions, length 2 """ b_splus1 = float(guesses[0]) n = float(guesses[1]) b_s = float(initial_b[-2, j]) # Find errors from FOC for savings and FOC for labor supply error1 = household.FOC_savings( np.array([r]), np.array([w]), np.array([p_tilde]), np.array([p_i]), b_s, np.array([b_splus1]), np.array([n]), np.array([bq]), np.array([rm]), factor, np.array([tr]), np.array([ubi]), theta[j], p.rho[0, -1, j], p.etr_params[0][-1], p.mtry_params[0][-1], None, j, p, "TPI_scalar", ) error2 = household.FOC_labor( np.array([r]), np.array([w]), np.array([p_tilde]), np.array([p_i]), b_s, b_splus1, np.array([n]), np.array([bq]), np.array([rm]), factor, np.array([tr]), np.array([ubi]), theta[j], p.chi_n[0, -1], p.etr_params[0][-1], p.mtrx_params[0][-1], None, j, p, "TPI_scalar", ) if n <= 0 or n >= 1: error2 += 1e12 if b_splus1 <= 0: error1 += 1e12 return [np.squeeze(error1)] + [np.squeeze(error2)]
[docs] def twist_doughnut( guesses, r, w, p_tilde, p_i, bq, rm, tr, theta, factor, ubi, j, s, t, etr_params, mtrx_params, mtry_params, initial_b, p, ): """ Solves the upper triangle of time path iterations. These are the agents who are alive at time T=0 so that we do not solve for their full lifetime (so of their life was before the model begins). Args: guesses (list): initial guesses for b and n, length 2s r (Numpy array): real interest rate w (Numpy array): real wage rate p_tilde (Numpy array): composite good price p_i (Numpy array): output goods prices bq (Numpy array): bequest amounts by age, length s rm (Numpy array): remittance amounts by age, length s tr (Numpy array): government transfer amount theta (scalar): retirement replacement rate for ability type j, before any replacement_rate_adjust scaling factor (scalar): scaling factor converting model units to dollars ubi (Numpy array): length remaining periods of life UBI payout to household j (int): index of ability type s (int): years of life remaining t (int): model period etr_params (list): ETR function parameters, list of lists with size = sxsxnum_params mtrx_params (list): labor income MTR function parameters, list of lists with size = sxsxnum_params mtry_params (list): capital income MTR function parameters, lists of lists with size = sxsxnum_params initial_b (Numpy array): savings of agents alive at T=0, size = SxJ p (OG-Core Specifications object): model parameters Returns: euler errors (Numpy array): errors from first order conditions, length 2s """ length = int(len(guesses) / 2) b_guess = np.array(guesses[:length]) n_guess = np.array(guesses[length:]) if length == p.S: b_s = np.array([0] + list(b_guess[:-1])) else: b_s = np.array([initial_b[-(s + 3), j]] + list(b_guess[:-1])) b_splus1 = b_guess w_s = w[t : t + length] r_s = r[t : t + length] p_tilde_s = p_tilde[t : t + length] p_i_s = p_i[t : t + length, :] n_s = n_guess chi_n_s = np.diag(p.chi_n[t : t + p.S, :], max(p.S - length, 0)) rho_s = np.diag(p.rho[t : t + p.S, :, j], max(p.S - length, 0)) error1 = household.FOC_savings( r_s, w_s, p_tilde_s, p_i_s, b_s, b_splus1, n_s, bq, rm, factor, tr, ubi, theta, rho_s, etr_params, mtry_params, t, j, p, "TPI", ) error2 = household.FOC_labor( r_s, w_s, p_tilde_s, p_i_s, b_s, b_splus1, n_s, bq, rm, factor, tr, ubi, theta, chi_n_s, etr_params, mtrx_params, t, j, p, "TPI", ) # Check and punish constraint violations mask1 = n_guess < 0 error2[mask1] += 1e12 mask2 = n_guess > p.ltilde error2[mask2] += 1e12 mask4 = b_guess <= 0 error2[mask4] += 1e12 mask5 = b_splus1 < 0 error2[mask5] += 1e12 return list(error1.flatten()) + list(error2.flatten())
def _params_to_array(nested, tax_func_type): """Convert a nested tax-parameter slice to a numpy array. For numeric tax functions this lets ``get_tax_rates`` skip a costly per-call list-to-array conversion. ``mono`` and ``mono2D`` store callables rather than numbers, so their nested-list form is returned unchanged. """ if tax_func_type in ("mono", "mono2D"): return nested return np.array(nested) def _retry_root_if_failed(solutions, fun, x0, args, p): """ Re-solve a household problem with Levenberg-Marquardt when the configured root finder stops short of a root. ``hybr`` can return "not making good progress" after a few evaluations when the starting point is far from the solution, which happens for the short partial-lifetime problems of households alive at t=0. In that case ``solutions.x`` is just the initial guess. ``lm`` is more robust from such starting points, so it is tried next and the result with the smaller maximum residual is kept. Args: solutions (OptimizeResult): result of the first root-finding call fun (function): the residual function x0 (array_like): the initial guess used in the first call args (tuple): extra arguments passed to ``fun`` p (OG-Core Specifications object): model parameters Returns: solutions (OptimizeResult): the better of the two results """ max_resid = np.abs(solutions.fun).max() if solutions.success and max_resid <= p.mindist_TPI: return solutions retry = opt.root(fun, x0, args=args, method="lm", tol=MINIMIZER_TOL) if np.abs(retry.fun).max() < max_resid: logger.debug( "Household root finder did not converge (max residual " f"{max_resid:.2e}); the Levenberg-Marquardt retry reached " f"{np.abs(retry.fun).max():.2e}" ) return retry return solutions
[docs] def inner_loop(guesses, outer_loop_vars, initial_values, ubi, j, ind, p): """ Given path of economic aggregates and factor prices, solves household problem. This has been termed the inner-loop (in contrast to the outer fixed point loop that solves for GE factor prices and economic aggregates). Args: guesses (tuple): initial guesses for b and n, (guesses_b, guesses_n) outer_loop_vars (tuple): values for factor prices and economic aggregates used in household problem (r_p, r, w, p_m, BQ, RM, TR, theta) r_p (Numpy array): real interest rate on household portfolio r (Numpy array): real interest rate on private capital w (Numpy array): real wage rate p_m (Numpy array): output goods prices BQ (array_like): aggregate bequest amounts TR (Numpy array): lump sum transfer amount theta (Numpy array): retirement replacement rates, length J initial_values (tuple): initial period variable values, (b_sinit, b_splus1init, factor, initial_n, D0_baseline) ubi (array_like): T+S x S x J array time series of UBI transfers in model units for each type-j age-s household in every period t j (int): index of ability type ind (Numpy array): integers from 0 to S-1 p (OG-Core Specifications object): model parameters Returns: (tuple): household solution results: * euler_errors (Numpy array): errors from FOCs, size = (T+S)x2S. Row t holds the period-t residuals by age: the savings FOCs in the first S columns and the labor FOCs in the last S, so that entry [t, s] (or [t, S+s]) lines up with b_mat[t, s] and n_mat[t, s]. This includes the partial-lifetime problems of the households alive at t=0. * b_mat (Numpy array): savings amounts, size = (T+S)xS * n_mat (Numpy array): labor supply amounts, size = (T+S)xS """ K0, b_sinit, b_splus1init, factor, initial_n = initial_values guesses_b, guesses_n = guesses r_p, r, w, p_m, BQ, RM, TR, theta = outer_loop_vars # compute composite good price p_i = ( np.tile(p.io_matrix.reshape(1, p.I, p.M), (p.T + p.S, 1, 1)) * np.tile(p_m.reshape(p.T + p.S, 1, p.M), (1, p.I, 1)) ).sum(axis=2) p_tilde = aggr.get_ptilde(p_i[:, :], p.tau_c[:, :], p.alpha_c, "TPI") # compute bq bq = household.get_bq(BQ, None, p, "TPI") # compute tr tr = household.get_tr(TR, None, p, "TPI") # compute rm rm = household.get_rm(RM, None, p, "TPI") # initialize arrays b_mat = np.zeros((p.T + p.S, p.S)) n_mat = np.zeros((p.T + p.S, p.S)) # Euler errors are stored in the same (period, age) layout as b_mat # and n_mat, with the savings FOC residuals in the first S columns # and the labor FOC residuals in the last S columns euler_errors = np.zeros((p.T + p.S, 2 * p.S)) _fdr_x0 = [guesses_b[0, -1], guesses_n[0, -1]] _fdr_args = ( r_p[0], w[0], p_tilde[0], p_i[0, :], bq[0, -1, j], rm[0, -1, j], tr[0, -1, j], theta, factor, ubi[0, -1, j], j, b_splus1init, p, ) solutions = opt.root( firstdoughnutring, _fdr_x0, args=_fdr_args, method=p.FOC_root_method, tol=MINIMIZER_TOL, ) solutions = _retry_root_if_failed( solutions, firstdoughnutring, _fdr_x0, _fdr_args, p ) b_mat[0, -1], n_mat[0, -1] = solutions.x[0], solutions.x[1] euler_errors[0, p.S - 1] = solutions.fun[0] euler_errors[0, 2 * p.S - 1] = solutions.fun[1] for s in range(p.S - 2): # Upper triangle ind2 = np.arange(s + 2) b_guesses_to_use = np.diag(guesses_b[: p.S, :], p.S - (s + 2)) n_guesses_to_use = np.diag(guesses_n[: p.S, :], p.S - (s + 2)) # Pass the unadjusted replacement rate. pensions.SS_amount applies # the replacement rate adjustment for each year of payment, so # scaling theta here would apply the factor twice. theta_to_use = theta[j] bq_to_use = np.diag(bq[: p.S, :, j], p.S - (s + 2)) rm_to_use = np.diag(rm[: p.S, :, j], p.S - (s + 2)) tr_to_use = np.diag(tr[: p.S, :, j], p.S - (s + 2)) ubi_to_use = np.diag(ubi[: p.S, :, j], p.S - (s + 2)) num_params = len(p.etr_params[0][0]) # Convert the per-age tax-parameter slices to arrays here, once, # rather than letting get_tax_rates re-convert them from nested # lists on each of its many calls. mono/mono2D store callables, # not numbers, so _params_to_array leaves them as nested lists. temp_etr = [ [p.etr_params[t][p.S - s - 2 + t][i] for i in range(num_params)] for t in range(s + 2) ] etr_params_to_use = _params_to_array(temp_etr, p.tax_func_type) temp_mtrx = [ [p.mtrx_params[t][p.S - s - 2 + t][i] for i in range(num_params)] for t in range(s + 2) ] mtrx_params_to_use = _params_to_array(temp_mtrx, p.tax_func_type) temp_mtry = [ [p.mtry_params[t][p.S - s - 2 + t][i] for i in range(num_params)] for t in range(s + 2) ] mtry_params_to_use = _params_to_array(temp_mtry, p.tax_func_type) _tri_x0 = list(b_guesses_to_use) + list(n_guesses_to_use) _tri_args = ( r_p, w, p_tilde, p_i, bq_to_use, rm_to_use, tr_to_use, theta_to_use, factor, ubi_to_use, j, s, 0, etr_params_to_use, mtrx_params_to_use, mtry_params_to_use, b_splus1init, p, ) _tri_jac = _banded_jac_for( twist_doughnut, _tri_x0, _tri_args, p.use_sparse_FOC_jac ) try: solutions = opt.root( twist_doughnut, _tri_x0, args=_tri_args, method=p.FOC_root_method, tol=MINIMIZER_TOL, jac=_tri_jac, ) except Exception: solutions = opt.root( twist_doughnut, _tri_x0, args=_tri_args, method=p.FOC_root_method, tol=MINIMIZER_TOL, ) solutions = _retry_root_if_failed( solutions, twist_doughnut, _tri_x0, _tri_args, p ) b_vec = solutions.x[: s + 2] b_mat[ind2, p.S - (s + 2) + ind2] = b_vec n_vec = solutions.x[s + 2 :] n_mat[ind2, p.S - (s + 2) + ind2] = n_vec euler_errors[ind2, p.S - (s + 2) + ind2] = solutions.fun[: s + 2] euler_errors[ind2, 2 * p.S - (s + 2) + ind2] = solutions.fun[s + 2 :] for t in range(0, p.T): b_guesses_to_use = 0.75 * np.diag(guesses_b[t : t + p.S, :]) n_guesses_to_use = np.diag(guesses_n[t : t + p.S, :]) theta_to_use = theta[j] bq_to_use = np.diag(bq[t : t + p.S, :, j]) rm_to_use = np.diag(rm[t : t + p.S, :, j]) tr_to_use = np.diag(tr[t : t + p.S, :, j]) ubi_to_use = np.diag(ubi[t : t + p.S, :, j]) # initialize array of diagonal elements num_params = len(p.etr_params[t][0]) etr_params_to_use = _params_to_array( [ [p.etr_params[t + s][s][i] for i in range(num_params)] for s in range(p.S) ], p.tax_func_type, ) mtrx_params_to_use = _params_to_array( [ [p.mtrx_params[t + s][s][i] for i in range(num_params)] for s in range(p.S) ], p.tax_func_type, ) mtry_params_to_use = _params_to_array( [ [p.mtry_params[t + s][s][i] for i in range(num_params)] for s in range(p.S) ], p.tax_func_type, ) _td_args = ( r_p, w, p_tilde, p_i, bq_to_use, rm_to_use, tr_to_use, theta_to_use, factor, ubi_to_use, j, None, t, etr_params_to_use, mtrx_params_to_use, mtry_params_to_use, b_splus1init, p, ) _td_x0 = list(b_guesses_to_use) + list(n_guesses_to_use) # When p.use_sparse_FOC_jac is set, supply a banded finite-difference # Jacobian (far fewer evaluations per build); otherwise _bj_jac is None # and opt.root uses its default dense finite differences. The except # clause is a safety net: any solver failure retries with the dense # Jacobian, so the result is unchanged. _bj_jac = _banded_jac_for( twist_doughnut, _td_x0, _td_args, p.use_sparse_FOC_jac ) try: solutions = opt.root( twist_doughnut, _td_x0, args=_td_args, method=p.FOC_root_method, tol=MINIMIZER_TOL, jac=_bj_jac, ) except Exception: solutions = opt.root( twist_doughnut, _td_x0, args=_td_args, method=p.FOC_root_method, tol=MINIMIZER_TOL, ) solutions = _retry_root_if_failed( solutions, twist_doughnut, _td_x0, _td_args, p ) b_vec = solutions.x[: p.S] b_mat[t + ind, ind] = b_vec n_vec = solutions.x[p.S :] n_mat[t + ind, ind] = n_vec euler_errors[t + ind, ind] = solutions.fun[: p.S] euler_errors[t + ind, p.S + ind] = solutions.fun[p.S :] return euler_errors, b_mat, n_mat
def _euler_error_message(eul_savings, eul_laborleisure): """ Build the message for a household first order condition failure along the transition path. Reports the largest absolute savings and labor FOC residuals and the (period, age, income group) at which each occurs. Residuals at ages greater than the period index belong to households already alive when the time path begins, whose partial-lifetime problems are the ones most sensitive to the initial guesses. Args: eul_savings (Numpy array): savings FOC residuals, (T+S) x S x J eul_laborleisure (Numpy array): labor FOC residuals, (T+S) x S x J Returns: msg (str): the diagnostic error message """ msg = "Transition path equilibrium not found (eulers)." for name, err in ( ("savings", eul_savings), ("labor", eul_laborleisure), ): abs_err = np.absolute(err) t, s, j = np.unravel_index(np.nanargmax(abs_err), abs_err.shape) msg += ( f" Max {name} FOC error = {abs_err[t, s, j]:.2e} at period " f"t={t}, age s={s}, income group j={j}" ) if s > t: msg += " (household alive at t=0)" msg += "." return msg def _rc_error_message(RC_error, RC_TPI): """ Build the message for a transition resource-constraint failure. Reports the maximum absolute resource-constraint error, the period it occurs in, and how to read it: a violation confined to the first or last periods is usually an initial- or terminal-boundary artifact, while one spread across the path points to an inconsistent calibration. Args: RC_error (array_like): resource constraint error, time on axis 0 RC_TPI (scalar): the tolerance the error is checked against Returns: msg (str): the diagnostic error message """ abs_rc = np.absolute(RC_error) rc_by_period = abs_rc.reshape(abs_rc.shape[0], -1).max(axis=1) t_worst = int(np.argmax(rc_by_period)) return ( "Transition path equilibrium not found (RC_error): max " f"|resource constraint error| = {rc_by_period[t_worst]:.2e} at " f"period {t_worst} of {rc_by_period.shape[0]} (tolerance " f"RC_TPI = {RC_TPI}). A violation confined to the first or last " "periods is usually an initial- or terminal-boundary artifact; one " "spread across the path points to an inconsistent calibration " "(spending, revenue, debt_ratio_ss)." )
[docs] def run_TPI(p, client=None): """ Solve for transition path equilibrium of OG-Core. Args: p (OG-Core Specifications object): model parameters client (Dask client object): client Returns: output (dictionary): dictionary with transition path solution results """ if p.use_sparse_FOC_jac and not _HAVE_SPARSE_FD: print("[TPI] sparse FOC jacobian unavailable; using dense") # Scope the Jacobian sparsity cache to this solve (see # _detect_jac_sparsity); set before p is scattered so that every copy # of p on the dask workers carries the same identifier p._jac_cache_scope = uuid.uuid4().hex # unpack tuples of parameters initial_values, ss_vars, theta, baseline_values = get_initial_SS_values(p) B0, b_sinit, b_splus1init, factor, initial_n = initial_values ( Ybaseline, TRbaseline, Gbaseline, Ig_baseline, D0_baseline, Kg0_baseline, ) = baseline_values # Create time path of UBI household benefits and aggregate UBI outlays ubi = p.ubi_nom_array / factor UBI = aggr.get_L(ubi[: p.T], p, "TPI") logger.info( f"Government spending breakpoints are tG1: {p.tG1}; and tG2: {p.tG2}" ) # Initialize guesses at time paths # Make array of initial guesses for labor supply and savings guesses_b = utils.get_initial_path( b_splus1init, ss_vars["b_sp1"], p, "quadratic" ) guesses_n = utils.get_initial_path(initial_n, ss_vars["n"], p, "quadratic") b_mat = guesses_b n_mat = guesses_n ind = np.arange(p.S) # Get path for aggregate savings and labor supply L_init = np.ones((p.T + p.S,)) * ss_vars["L"] B_init = utils.get_initial_path(B0, ss_vars["B"], p, "quadratic") L_init[: p.T] = aggr.get_L(n_mat[: p.T], p, "TPI") # Get initial paths for aggregate capital by scaling the steady-state # capital stocks with the path of B. These paths only serve as guesses # for the first iteration: inside the loop K, K_d, K_f, and K_m are # recomputed from household wealth and K_g from the fiscal rule. # Starting a reform instead from the period-0 capital stocks of the # baseline time path was found to send the outer loop off on a # divergent track in the multi-industry example, so the same guesses # are used for baseline and reform runs. K_init = B_init * ss_vars["K"] / ss_vars["B"] K_d = K_init * ss_vars["K_d"] / ss_vars["K"] K_g = np.ones_like(K_init) * ss_vars["K_g"] K_vec_init = ( np.ones((p.T + p.S, p.M)) * ss_vars["K_m"].reshape(1, p.M) * np.tile((K_init / ss_vars["K"]).reshape(p.T + p.S, 1), (1, p.M)) ) K = K_init K_f = K_init - K_d L = L_init B = B_init # Get initial paths for industry specific labor L_vec_init = np.ones((p.T + p.S, p.M)) * ss_vars["L_m"].reshape(1, p.M) # Get the initial paths for industry specific output Y_vec_init = np.ones((p.T + p.S, p.M)) * ss_vars["Y_m"].reshape(1, p.M) for m in range(p.M): Y_vec_init[: p.T, m] = firm.get_Y( K_vec_init[: p.T, m], K_g[: p.T], L_vec_init[: p.T, m], p, "TPI", m ) # compute w w = np.ones_like(K) * ss_vars["w"] # compute goods prices p_m = np.ones((p.T + p.S, p.M)) * ss_vars["p_m"].reshape(1, p.M) p_m[: p.T, :] = firm.get_pm( w[: p.T], Y_vec_init[: p.T, :], L_vec_init[: p.T, :], p, "TPI" ) p_m = p_m / p_m[:, -1].reshape( p.T + p.S, 1 ) # normalize prices by industry M Y = ( (Y_vec_init * p_m) .sum(axis=1) .reshape( p.T + p.S, ) ) p_i = ( np.tile(p.io_matrix.reshape(1, p.I, p.M), (p.T + p.S, 1, 1)) * np.tile(p_m.reshape(p.T + p.S, 1, p.M), (1, p.I, 1)) ).sum(axis=2) p_tilde = aggr.get_ptilde(p_i[:, :], p.tau_c[:, :], p.alpha_c, "TPI") if not any(p.zeta_K == 1): w[: p.T] = np.squeeze( firm.get_w(Y[: p.T], L[: p.T], p_m[: p.T, :], p, "TPI") ) # repeat with updated w p_m[: p.T, :] = firm.get_pm( w[: p.T], Y_vec_init[: p.T, :], L_vec_init[: p.T, :], p, "TPI" ) p_m = p_m / p_m[:, -1].reshape( p.T + p.S, 1 ) # normalize prices by industry M p_i = ( np.tile(p.io_matrix.reshape(1, p.I, p.M), (p.T + p.S, 1, 1)) * np.tile(p_m.reshape(p.T + p.S, 1, p.M), (1, p.I, 1)) ).sum(axis=2) p_tilde = aggr.get_ptilde(p_i[:, :], p.tau_c[:, :], p.alpha_c, "TPI") # path for interest rates r = np.zeros_like(Y) r[: p.T] = np.squeeze( firm.get_r(Y[: p.T], K[: p.T], p_m[: p.T, :], p, "TPI") ) r[p.T :] = ss_vars["r"] # For case where economy is small open econ r[p.zeta_K == 1] = p.world_int_rate[p.zeta_K == 1] # initial guesses at fiscal vars if p.budget_balance: if np.abs(ss_vars["TR"]) < 1e-13: TR_ss2 = 0.0 # sometimes SS is very small but not zero, # even if taxes are zero, this get's rid of the # approximation error, which affects the pct changes below else: TR_ss2 = ss_vars["TR"] TR = np.ones(p.T + p.S) * TR_ss2 total_tax_revenue = TR - ss_vars["agg_pension_outlays"] G = np.zeros(p.T + p.S) D = np.zeros(p.T + p.S) D_d = np.zeros(p.T + p.S) D_f = np.zeros(p.T + p.S) I_g = fiscal.get_I_g(Y[: p.T], None, p, "TPI") else: if p.baseline_spending: # Will set to TRbaseline here, but will be updated in TPI loop # with call to fiscal.get_TR TR = np.concatenate( (TRbaseline[: p.T], np.ones(p.S) * ss_vars["TR"]) ) # Will set to Ig_baseline here, but will be updated in TPI loop # with call to fiscal.get_I_g I_g = np.concatenate( (Ig_baseline[: p.T], np.ones(p.S) * ss_vars["I_g"]) ) # Will set to Gbaseline here, but will be updated in TPI loop # with call to fiscal.D_G_path, which also does closure rule G = np.concatenate((Gbaseline[: p.T], np.ones(p.S) * ss_vars["G"])) else: TR = p.alpha_T * Y G = np.ones(p.T + p.S) * ss_vars["G"] I_g = np.ones(p.T + p.S) * ss_vars["I_g"] D = np.ones(p.T + p.S) * ss_vars["D"] D_d = D * ss_vars["D_d"] / ss_vars["D"] D_f = D * ss_vars["D_f"] / ss_vars["D"] if p.baseline: K_g0 = p.initial_Kg_ratio * Y[0] else: K_g0 = Kg0_baseline K_g = fiscal.get_K_g(K_g0, I_g, p, "TPI") total_tax_revenue = np.ones(p.T + p.S) * ss_vars["total_tax_revenue"] # Compute other interest rates r_gov = np.ones_like(r) * ss_vars["r_gov"] r_gov[: p.T] = fiscal.get_r_gov(r[: p.T], D[: p.T] / Y[: p.T], p, "TPI") r_p = np.ones_like(r) * ss_vars["r_p"] MPKg = np.zeros((p.T, p.M)) for m in range(p.M): MPKg[:, m] = np.squeeze( firm.get_MPx( Y_vec_init[: p.T, m], K_g[: p.T], p.gamma_g[m], p, "TPI", m ) ) r_p[: p.T] = aggr.get_r_p( r[: p.T], r_gov[: p.T], p_m[: p.T, :], K_vec_init[: p.T, :], K_g[: p.T], D[: p.T], MPKg, p, "TPI", ) # Initialize bequests BQ0 = aggr.get_BQ(r_p[0], b_splus1init, None, p, "SS", True) if not p.use_zeta: BQ = np.zeros((p.T + p.S, p.J)) for j in range(p.J): BQ[:, j] = ( list(np.linspace(BQ0[j], ss_vars["BQ"][j], p.T)) + [ss_vars["BQ"][j]] * p.S ) BQ = np.array(BQ) else: BQ = list(np.linspace(BQ0, ss_vars["BQ"], p.T)) + [ss_vars["BQ"]] * p.S BQ = np.array(BQ) # Initialize aggregate remittances if p.baseline: RM = aggr.get_RM(Y, p, "TPI") else: # This is the reform case and is based off of Ybaseline, but allows for # remittance parameters to change in a reform and update the RM series Ybaseline_ext = np.concatenate( [Ybaseline, np.ones(p.S) * Ybaseline[-1]] ) RM = aggr.get_RM(Ybaseline_ext, p, "TPI") # Start transition path iteration (TPI) TPIiter = 0 TPIdist = 10 euler_errors = np.zeros((p.T + p.S, 2 * p.S, p.J)) TPIdist_vec = np.zeros(p.maxiter) stall = None stall_reported = None # Pluggable outer-loop update rule. Default "picard" -> None -> the native # damped functional-iteration path below (unchanged, so golden outputs # are preserved); "anderson" accelerates using the residual history. See # ogcore.solvers. outer_updater = solvers.make_outer_updater( getattr(p, "TPI_outer_method", "picard"), p ) # Optional trust region for the accelerated step ("anchored" Anderson): # clamp each step to within trust_radius x the damped-step length of the # always-feasible damped point, growing the radius after an improving step # and shrinking it (with a reset) after a worsening one. This keeps the # accelerated iterate near a feasible point -- the standard cure for the # overshoot-into-infeasible-region divergence of unguarded accelerators. # Defaults to 1.0 (anchored) when accelerating; a non-positive radius # disables the trust region (unguarded accelerator, for testing only). trust_radius = getattr(p, "TPI_trust_radius", 1.0) if trust_radius is not None and trust_radius <= 0: trust_radius = None trust_radius_min = getattr(p, "TPI_trust_radius_min", 0.1) trust_radius_max = getattr(p, "TPI_trust_radius_max", 10.0) prev_accel_dist = np.inf if client: # Before scattering, temporarily remove unpicklable schema objects schema_backup = {} for attr in ["_defaults_schema", "_validator_schema", "sel"]: if hasattr(p, attr): schema_backup[attr] = getattr(p, attr) try: delattr(p, attr) except Exception: pass # Scatter the parameters scattered_p_future = client.scatter(p, broadcast=True) # Restore the schema objects (they're not needed by workers anyway) for attr, value in schema_backup.items(): try: setattr(p, attr, value) except Exception: pass # TPI loop while (TPIiter < p.maxiter) and (TPIdist >= p.mindist_TPI): outer_loop_vars = (r_p, r, w, p_m, BQ, RM, TR, theta) # compute composite good price p_i = ( np.tile(p.io_matrix.reshape(1, p.I, p.M), (p.T + p.S, 1, 1)) * np.tile(p_m.reshape(p.T + p.S, 1, p.M), (1, p.I, 1)) ).sum(axis=2) p_tilde = aggr.get_ptilde(p_i[:, :], p.tau_c[:, :], p.alpha_c, "TPI") # Initialize Euler errors euler_errors = np.zeros((p.T + p.S, 2 * p.S, p.J)) # Solve for household decisions results = [] if client: futures = [] for j in range(p.J): guesses = (guesses_b[:, :, j], guesses_n[:, :, j]) f = client.submit( inner_loop, guesses, outer_loop_vars, initial_values, ubi, j, ind, scattered_p_future, ) futures.append(f) try: # Wait for futures with timeout, then gather results done, not_done = wait(futures, timeout=600) if not_done: # Some futures didn't complete in time raise TimeoutError( f"{len(not_done)} futures did not complete" " within 600 seconds" ) results = client.gather(futures) except Exception as e: # Cancel remaining futures and fall back to serial computation logger.warning( f"Dask computation failed with error: {e}. " "Falling back to serial computation for this iteration." ) for future in futures: future.cancel() results = [] for j in range(p.J): guesses = (guesses_b[:, :, j], guesses_n[:, :, j]) res = inner_loop( guesses, outer_loop_vars, initial_values, ubi, j, ind, p, ) results.append(res) else: # Serial fallback (no dask client) for j in range(p.J): guesses = (guesses_b[:, :, j], guesses_n[:, :, j]) res = inner_loop( guesses, outer_loop_vars, initial_values, ubi, j, ind, p, # pass the raw object directly ) results.append(res) for j, result in enumerate(results): euler_errors[:, :, j], b_mat[:, :, j], n_mat[:, :, j] = result bmat_s = np.zeros((p.T, p.S, p.J)) bmat_s[0, 1:, :] = b_splus1init[:-1, :] bmat_s[1:, 1:, :] = b_mat[: p.T - 1, :-1, :] bmat_splus1 = np.zeros((p.T, p.S, p.J)) bmat_splus1[:, :, :] = b_mat[: p.T, :, :] num_params = len(p.etr_params[0][0]) etr_params_4D = [ [ [ [p.etr_params[t][s][i] for i in range(num_params)] for j in range(p.J) ] for s in range(p.S) ] for t in range(p.T) ] bqmat = household.get_bq(BQ, None, p, "TPI") rmmat = household.get_rm(RM, None, p, "TPI") trmat = household.get_tr(TR, None, p, "TPI") tax_mat = tax.net_taxes( r_p[: p.T], w[: p.T], bmat_s, n_mat[: p.T, :, :], bqmat[: p.T, :, :], factor, trmat[: p.T, :, :], ubi[: p.T, :, :], theta, 0, None, False, "TPI", p.e, etr_params_4D, p, ) income_tax_mat = tax.income_tax_liab( r_p[: p.T], w[: p.T], bmat_s, n_mat[: p.T, :, :], factor, 0, None, "TPI", p.e, etr_params_4D, p, ) pension_mat = pensions.pension_amount( r_p[: p.T], w[: p.T], n_mat[: p.T, :, :], 1, theta, 0, None, False, "TPI", p.e, factor, p, ) bq_tax_mat = tax.bequest_tax_liab( r_p[: p.T], bmat_s, bqmat[: p.T, :, :], 0, None, "TPI", p ) wealth_tax_mat = tax.wealth_tax_liab( r_p[: p.T], bmat_s, 0, None, "TPI", p ) r_p_path = utils.to_timepath_shape(r_p) p_tilde_path = utils.to_timepath_shape(p_tilde) wpath = utils.to_timepath_shape(w) c_mat = household.get_cons( r_p_path[: p.T, :, :], wpath[: p.T, :, :], p_tilde_path[: p.T, :, :], p_i[: p.T, :], bmat_s, bmat_splus1, n_mat[: p.T, :, :], bqmat[: p.T, :, :], rmmat[: p.T, :, :], tax_mat, p.e, p.tau_c[: p.T, :], p, method="TPI", ) c_i = household.get_ci( c_mat[: p.T, :, :], p_i[: p.T, :], p_tilde[: p.T], p.tau_c[: p.T, :], p.alpha_c, p.c_min, "TPI", ) sales_tax_mat = tax.cons_tax_liab(c_i, p_i, p, "TPI") C = aggr.get_C(c_mat, p, "TPI") c_i = household.get_ci( c_mat[: p.T, :, :], p_i[: p.T, :], p_tilde[: p.T], p.tau_c[: p.T, :], p.alpha_c, p.c_min, "TPI", ) y_before_tax_mat = household.get_y( r_p_path[: p.T, :, :], wpath[: p.T, :, :], bmat_s[: p.T, :, :], n_mat[: p.T, :, :], p, "TPI", ) labor_income_mat = ( w[: p.T].reshape(p.T, 1, 1) * n_mat[: p.T, :, :] * p.e ) capital_income_mat = ( r_p[: p.T].reshape(p.T, 1, 1) * bmat_s[: p.T, :, :] ) # Update aggregate variables L[: p.T] = aggr.get_L(n_mat[: p.T], p, "TPI") B[1 : p.T] = aggr.get_B(bmat_splus1[: p.T], p, "TPI", False)[: p.T - 1] w_open = firm.get_w_from_r(p.world_int_rate[: p.T], p, "TPI") # Find output, labor demand, capital demand for M-1 industries L_vec = np.zeros((p.T, p.M)) K_vec = np.zeros((p.T, p.M)) C_vec = np.zeros((p.T, p.I)) K_demand_open_vec = np.zeros((p.T, p.M)) for i_ind in range(p.I): C_vec[:, i_ind] = aggr.get_C(c_i[: p.T, i_ind, :, :], p, "TPI") Y_vec = ( np.tile(p.io_matrix.reshape(1, p.I, p.M), (p.T, 1, 1)) * np.tile(C_vec[: p.T, :].reshape(p.T, p.I, 1), (1, 1, p.M)) ).sum(axis=1) for m_ind in range(p.M - 1): KYrat_m = firm.get_KY_ratio( r[: p.T], p_m[: p.T, :], p, "TPI", m_ind ) K_vec[:, m_ind] = KYrat_m * Y_vec[:, m_ind] L_vec[:, m_ind] = firm.solve_L( Y_vec[:, m_ind], K_vec[:, m_ind], K_g, p, "TPI", m_ind ) K_demand_open_vec[:, m_ind] = firm.get_K( p.world_int_rate[: p.T], w_open[: p.T], L_vec[: p.T, m_ind], p, "TPI", m_ind, ) # Find output, labor demand, capital demand for last industry L_M = np.maximum( np.ones(p.T) * 0.001, L[: p.T] - L_vec[: p.T, :].sum(-1) ) # make sure L_M > 0 K_demand_open_vec[:, -1] = firm.get_K( p.world_int_rate[: p.T], w_open[: p.T], L_M[: p.T], p, "TPI", -1 ) K[: p.T], K_d[: p.T], K_f[: p.T] = aggr.get_K_splits( B[: p.T], K_demand_open_vec[: p.T, :].sum(-1), D_d[: p.T], p.zeta_K[: p.T], ) K_M = np.maximum( np.ones(p.T) * 0.001, K[: p.T] - K_vec[: p.T, :].sum(-1) ) # make sure K_M > 0 L_vec[:, -1] = L_M K_vec[:, -1] = K_M Y_vec[:, -1] = firm.get_Y( K_vec[: p.T, -1], K_g[: p.T], L_vec[: p.T, -1], p, "TPI", -1 ) Y = (p_m[: p.T, :] * Y_vec[: p.T, :]).sum(-1) ( total_tax_rev, iit_payroll_tax_revenue, agg_pension_outlays, UBI_outlays, bequest_tax_revenue, wealth_tax_revenue, cons_tax_revenue, business_tax_revenue, payroll_tax_revenue, iit_revenue, ) = aggr.revenue( r_p[: p.T], w[: p.T], bmat_s, n_mat[: p.T, :, :], bqmat[: p.T, :, :], c_i[: p.T, :, :, :], Y_vec[: p.T, :], L_vec[: p.T, :], K_vec[: p.T, :], p_m[: p.T, :], factor, ubi[: p.T, :, :], theta, etr_params_4D, p.e, p, None, "TPI", ) total_tax_revenue[: p.T] = total_tax_rev if not p.baseline_spending: I_g = fiscal.get_I_g(Y[: p.T], None, p, "TPI") if p.baseline: K_g0 = p.initial_Kg_ratio * Y[0] K_g = fiscal.get_K_g(K_g0, I_g, p, "TPI") dg_fixed_values = ( Y, total_tax_revenue, agg_pension_outlays, UBI_outlays, TR, I_g, Gbaseline, D0_baseline, ) ( Dnew, G[: p.T], D_d[: p.T], D_f[: p.T], r_gov_new, new_borrowing, debt_service, new_borrowing_f, ) = fiscal.D_G_path(r, dg_fixed_values, p) rnew = r.copy() rnew[: p.T] = np.squeeze( firm.get_r( Y_vec[: p.T, -1], K_vec[: p.T, -1], p_m[: p.T, :], p, "TPI", -1 ) ) # For case where economy is small open econ rnew[p.zeta_K == 1] = p.world_int_rate[p.zeta_K == 1] MPKg_vec = np.zeros((p.T, p.M)) for m in range(p.M): MPKg_vec[:, m] = np.squeeze( firm.get_MPx( Y_vec[: p.T, m], K_g[: p.T], p.gamma_g[m], p, "TPI", m ) ) K[: p.T], K_d[: p.T], K_f[: p.T] = aggr.get_K_splits( B[: p.T], K_demand_open_vec.sum(-1), D_d[: p.T], p.zeta_K[: p.T] ) r_gov_new = fiscal.get_r_gov( rnew[: p.T], Dnew[: p.T] / Y[: p.T], p, "TPI" ) r_p_new = aggr.get_r_p( rnew[: p.T], r_gov_new[: p.T], p_m[: p.T, :], K_vec[: p.T, :], K_g[: p.T], Dnew[: p.T], MPKg_vec, p, "TPI", ) # compute w wnew = np.squeeze( firm.get_w( Y_vec[: p.T, -1], L_vec[: p.T, -1], p_m[: p.T, :], p, "TPI", -1 ) ) # compute new prices new_p_m = firm.get_pm(wnew, Y_vec, L_vec, p, "TPI") new_p_m = new_p_m / new_p_m[:, -1].reshape( p.T, 1 ) # normalize prices by industry M b_mat_shift = np.append( np.reshape(b_splus1init, (1, p.S, p.J)), b_mat[: p.T - 1, :, :], axis=0, ) BQnew = aggr.get_BQ(r_p_new[: p.T], b_mat_shift, None, p, "TPI", False) bqmat_new = household.get_bq(BQnew, None, p, "TPI") ( total_tax_rev, iit_payroll_tax_revenue, agg_pension_outlays, UBI_outlays, bequest_tax_revenue, wealth_tax_revenue, cons_tax_revenue, business_tax_revenue, payroll_tax_revenue, iit_revenue, ) = aggr.revenue( r_p_new[: p.T], wnew[: p.T], bmat_s, n_mat[: p.T, :, :], bqmat_new[: p.T, :, :], c_i[: p.T, :, :, :], Y_vec[: p.T, :], L_vec[: p.T, :], K_vec[: p.T, :], new_p_m[: p.T, :], factor, ubi[: p.T, :, :], theta, etr_params_4D, p.e, p, None, "TPI", ) total_tax_revenue[: p.T] = total_tax_rev TR_new = fiscal.get_TR( Y[: p.T], TR[: p.T], G[: p.T], total_tax_revenue[: p.T], agg_pension_outlays[: p.T], UBI_outlays[: p.T], I_g[: p.T], p, "TPI", ) RM = aggr.get_RM(Y[: p.T], p, "TPI") RM = np.concatenate([RM, np.ones(p.S) * RM[-1]]) # update vars for next iteration if outer_updater is None: # "picard": the historical damped functional-iteration step, # unchanged, so the default behavior (and golden outputs) is # preserved exactly. w[: p.T] = utils.convex_combo(wnew[: p.T], w[: p.T], p.nu) r[: p.T] = utils.convex_combo(rnew[: p.T], r[: p.T], p.nu) r_p[: p.T] = utils.convex_combo(r_p_new[: p.T], r_p[: p.T], p.nu) p_m[: p.T, :] = utils.convex_combo( new_p_m[: p.T, :], p_m[: p.T, :], p.nu ) BQ[: p.T] = utils.convex_combo(BQnew[: p.T], BQ[: p.T], p.nu) if not p.baseline_spending: TR[: p.T] = utils.convex_combo(TR_new[: p.T], TR[: p.T], p.nu) else: # Accelerated outer step on the packed macro/price vector # {r_p, r, w, p_m, BQ[, TR]}; the update rule (Anderson) uses the # recent residual history. Auxiliaries stay damped below. blocks = [ (r_p, r_p_new), (r, rnew), (w, wnew), (p_m, new_p_m), (BQ, BQnew), ] if not p.baseline_spending: blocks.append((TR, TR_new)) x, gx = solvers.pack_outer_vars(blocks, p.T) # True fixed-point residual (implied vs the PRE-update guess). The # post-update TPIdist below is spuriously ~0 for steps that set # x_next ~= gx (e.g. Anderson's undamped first step), so it is # overridden with this to avoid declaring false convergence. accel_dist = float(np.max(utils.pct_diff_func(gx, x))) # Anchored/trust-region control: grow the radius after an improving # accelerated step and shrink it (resetting the memory) after a # worsening one, using the residual trend as the accept/reject # signal. None => unclamped. if trust_radius is not None and TPIiter > 0: if accel_dist <= prev_accel_dist: trust_radius = min(trust_radius_max, trust_radius * 1.5) else: trust_radius = max(trust_radius_min, trust_radius * 0.5) outer_updater.reset() logger.info( f"accel step worsened; trust radius -> {trust_radius}" ) prev_accel_dist = accel_dist # Accelerate the DAMPED map (1-nu)x + nu*G(x), which is contractive # at the calibrated nu, rather than the raw map G -- G is # non-contractive on the stiff case (why Picard needs a low nu), so # accelerating it directly overshoots and diverges. Same fixed # point; convergence is still measured on the raw residual above. gx_damped = (1.0 - p.nu) * x + p.nu * gx x_next = outer_updater.update(x, gx_damped) if not np.all(np.isfinite(x_next)): # accelerated step blew up -> damped Picard fallback + reset x_next = p.nu * gx + (1.0 - p.nu) * x outer_updater.reset() logger.info( "accelerated step non-finite; Picard fallback + reset" ) elif trust_radius is not None: # Clamp the accelerated step to the trust region around the # always-feasible damped point gx_damped, sized relative to the # damped step length so it tightens as the solve converges. dev = x_next - gx_damped dev_norm = float(np.linalg.norm(dev)) max_dev = trust_radius * float(np.linalg.norm(gx_damped - x)) if dev_norm > max_dev > 0.0: x_next = gx_damped + dev * (max_dev / dev_norm) solvers.unpack_outer_vars(x_next, blocks, p.T) # Auxiliaries (unchanged): government rate, debt, and the household # policy warm-starts stay on the damped update. r_gov[: p.T] = utils.convex_combo(r_gov_new[: p.T], r_gov[: p.T], p.nu) D[: p.T] = Dnew[: p.T] if p.baseline and p.initial_wealth_ratio is not None: # Initial aggregate household wealth is a target ratio to # initial GDP, which is endogenous: move B[0] toward the # target with the outer-loop damping and scale the initial # distribution of savings (whose shape is fixed) to match, so # that the next inner loop starts households from it B0 = utils.convex_combo(p.initial_wealth_ratio * Y[0], B[0], p.nu) B[0] = B0 b_splus1init = b_splus1init * ( B0 / aggr.get_B(b_splus1init, p, "SS", True) ) b_sinit = np.zeros((p.S, p.J)) b_sinit[1:, :] = b_splus1init[:-1, :] initial_values = (B0, b_sinit, b_splus1init, factor, initial_n) guesses_b = utils.convex_combo(b_mat, guesses_b, p.nu) guesses_n = utils.convex_combo(n_mat, guesses_n, p.nu) logger.info( f"w diff: {(wnew[: p.T] - w[: p.T]).max()}, " + f"{(wnew[: p.T] - w[: p.T]).min()}" ) logger.info( f"r diff: {(rnew[: p.T] - r[: p.T]).max()}, " + f"{(rnew[: p.T] - r[: p.T]).min()}" ) logger.info( f"r_p diff: {(r_p_new[: p.T] - r_p[: p.T]).max()}, " + f"{(r_p_new[: p.T] - r_p[: p.T]).min()}" ) logger.info( f"p_m diff: {(new_p_m[: p.T, :] - p_m[: p.T, :]).max()}, " + f"{(new_p_m[: p.T, :] - p_m[: p.T, :]).min()}" ) logger.info( f"BQ diff: {(BQnew[: p.T] - BQ[: p.T]).max()}, " + f"{(BQnew[: p.T] - BQ[: p.T]).min()}" ) logger.info( f"TR diff: {(TR_new[: p.T] - TR[: p.T]).max()}, " + f"{(TR_new[: p.T] - TR[: p.T]).min()}" ) TPIdist = np.array( list(utils.pct_diff_func(r_p_new[: p.T], r_p[: p.T])) + list(utils.pct_diff_func(rnew[: p.T], r[: p.T])) + list(utils.pct_diff_func(wnew[: p.T], w[: p.T])) + list( utils.pct_diff_func(new_p_m[: p.T, :], p_m[: p.T, :]).flatten() ) + list(utils.pct_diff_func(BQnew[: p.T], BQ[: p.T]).flatten()) + list(utils.pct_diff_func(TR_new[: p.T], TR[: p.T])) ).max() if outer_updater is not None: # accelerated methods: use the true residual accel_dist, computed # above from the pre-update guess, because the post-update # distance can be spuriously ~0 for accelerated steps. TPIdist = accel_dist TPIdist_vec[TPIiter] = TPIdist # Accelerated safety net: if a step raised the distance sharply or went # non-finite, reset the accelerator so the next step is a fresh damped # restart (prevents runaway on the stiff case). Never fires for picard. if outer_updater is not None and TPIiter > 0: if ( not np.isfinite(TPIdist) or TPIdist > 10.0 * TPIdist_vec[TPIiter - 1] ): outer_updater.reset() logger.info("accelerated step diverged; reset accelerator") TPIiter += 1 logger.info(f"Iteration: {TPIiter}") logger.info(f"Distance: {TPIdist}") # Stall detection: when the best distance has stopped improving # over a window of iterations, further iterations repeat the same # pattern and cannot reach the tolerance -- diagnose the cause and, # if TPI_stall_action="stop", end the loop early (the solution # checks after the loop then fail the run as any non-convergence). # In the default warn mode the diagnosis is logged once per stall, # and again only if it changes (e.g. escalates to diverging). stall = solvers.diagnose_stall( TPIdist_vec, TPIiter, p.TPI_stall_window ) if stall != stall_reported: stall_reported = stall if stall == "diverging": logger.error( "TPI stalled and diverging: the best distance over " f"the last {p.TPI_stall_window} iterations is far " "above the earlier best. This usually signals an " "inconsistent fiscal block (spending, revenue, and " "debt_ratio_ss), not a solver problem." ) elif stall == "oscillating": logger.error( "TPI stalled: the best distance has not improved " f"over the last {p.TPI_stall_window} iterations " f"(current {TPIdist:.2e}, tolerance " f"{p.mindist_TPI}). The outer loop is cycling; try " "a lower nu, or TPI_outer_method='anderson' if not " "already enabled." ) if stall is not None and p.TPI_stall_action == "stop": break # Compute effective and marginal tax rates for all agents num_params = len(p.mtrx_params[0][0]) etr_params_4D = [ [ [ [p.etr_params[t][s][i] for i in range(num_params)] for j in range(p.J) ] for s in range(p.S) ] for t in range(p.T) ] mtrx_params_4D = [ [ [ [p.mtrx_params[t][s][i] for i in range(num_params)] for j in range(p.J) ] for s in range(p.S) ] for t in range(p.T) ] mtry_params_4D = [ [ [ [p.mtry_params[t][s][i] for i in range(num_params)] for j in range(p.J) ] for s in range(p.S) ] for t in range(p.T) ] labor_noncompliance_rate_3D = np.tile( np.reshape( p.labor_income_tax_noncompliance_rate[: p.T, :], (p.T, 1, p.J) ), (1, p.S, 1), ) capital_noncompliance_rate_3D = np.tile( np.reshape( p.capital_income_tax_noncompliance_rate[: p.T, :], (p.T, 1, p.J) ), (1, p.S, 1), ) income_tax_filer_3D = np.tile( np.reshape(p.income_tax_filer[: p.T, :], (p.T, 1, p.J)), (1, p.S, 1), ) e_3D = p.e mtry_path = tax.MTR_income( r_p_path[: p.T], wpath[: p.T], bmat_s[: p.T, :, :], n_mat[: p.T, :, :], factor, True, e_3D, etr_params_4D, mtry_params_4D, capital_noncompliance_rate_3D, income_tax_filer_3D, p, ) mtrx_path = tax.MTR_income( r_p_path[: p.T], wpath[: p.T], bmat_s[: p.T, :, :], n_mat[: p.T, :, :], factor, False, e_3D, etr_params_4D, mtrx_params_4D, labor_noncompliance_rate_3D, income_tax_filer_3D, p, ) etr_path = tax.ETR_income( r_p_path[: p.T], wpath[: p.T], bmat_s[: p.T, :, :], n_mat[: p.T, :, :], factor, e_3D, etr_params_4D, labor_noncompliance_rate_3D, capital_noncompliance_rate_3D, income_tax_filer_3D, p, ) # Note that implicitly in this computation is that immigrants' # wealth is all in the form of private capital I_d = aggr.get_I( bmat_splus1[: p.T], K_d[1 : p.T + 1], K_d[: p.T], p, "TPI" ) I = aggr.get_I(bmat_splus1[: p.T], K[1 : p.T + 1], K[: p.T], p, "TPI") # noqa: E741 # solve resource constraint # foreign debt service costs debt_service_f = fiscal.get_debt_service_f(r_p, D_f) net_capital_outflows = aggr.get_capital_outflows( r_p[: p.T], K_f[: p.T], new_borrowing_f[: p.T], debt_service_f[: p.T], p, ) # Fill in arrays, noting that M-1 industries only produce consumption goods G_vec = np.zeros((p.T, p.M)) G_vec[:, -1] = G[: p.T] # Map consumption goods back to demands for production goods C_m_vec = ( np.tile(p.io_matrix.reshape(1, p.I, p.M), (p.T, 1, 1)) * np.tile(C_vec[: p.T, :].reshape(p.T, p.I, 1), (1, 1, p.M)) ).sum(axis=1) I_d_vec = np.zeros((p.T, p.M)) I_d_vec[:, -1] = I_d[: p.T] I_g_vec = np.zeros((p.T, p.M)) I_g_vec[:, -1] = I_g[: p.T] net_capital_outflows_vec = np.zeros((p.T, p.M)) net_capital_outflows_vec[:, -1] = net_capital_outflows[: p.T] RM_vec = np.zeros((p.T, p.M)) RM_vec[:, -1] = RM[: p.T] foreign_aid_vec = np.zeros((p.T, p.M)) foreign_aid_vec[:, -1] = p.alpha_FA[: p.T] * Y[: p.T] RC_error = aggr.resource_constraint( Y_vec, C_m_vec, G_vec, I_d_vec, I_g_vec, net_capital_outflows_vec, RM_vec, foreign_aid_vec, ) # Compute total investment (not just domestic) I_total = aggr.get_I(None, K[1 : p.T + 1], K[: p.T], p, "total_tpi") # Compute resource constraint error rce_max = np.amax(np.abs(RC_error)) logger.info(f"Max absolute value resource constraint error: {rce_max}") logger.info("Checking time path for violations of constraints.") for t in range(p.T): household.constraint_checker_TPI( b_mat[t], n_mat[t], c_mat[t], t, p.ltilde ) eul_savings = euler_errors[:, : p.S, :] eul_laborleisure = euler_errors[:, p.S :, :] logger.info(f"Max Euler error, savings: {np.abs(eul_savings).max()}") logger.info( f"Max Euler error labor supply: {np.abs(eul_laborleisure).max()}" ) # Aggregate hours worked (not weighted by effective-labor units). N = aggr.get_N(n_mat[: p.T], p, "TPI") """ ------------------------------------------------------------------------ Save variables/values so they can be used in other modules ------------------------------------------------------------------------ """ output = { "Y": Y[: p.T, ...], "B": B[: p.T, ...], "K": K[: p.T, ...], "K_f": K_f[: p.T], "K_d": K_d[: p.T, ...], "L": L[: p.T, ...], "N": N[: p.T, ...], "C": C[: p.T, ...], "I": I[: p.T, ...], "I_total": I_total[: p.T, ...], "I_d": I_d[: p.T, ...], "K_g": K_g[: p.T, ...], "I_g": I_g[: p.T, ...], "BQ": BQ[: p.T, ...], "RM": RM[: p.T, ...], "Y_m": Y_vec[: p.T, ...], "K_m": K_vec[: p.T, ...], "L_m": L_vec[: p.T, ...], "C_i": C_vec[: p.T, ...], "TR": TR[: p.T, ...], "agg_pension_outlays": agg_pension_outlays[: p.T, ...], "G": G[: p.T, ...], "UBI": UBI[: p.T, ...], "total_government_outlays": ( TR[: p.T, ...] + UBI[: p.T, ...] + G[: p.T, ...] + I_g[: p.T, ...] + debt_service[: p.T, ...] + agg_pension_outlays[: p.T, ...] ), "total_primary_government_outlays": ( agg_pension_outlays[: p.T, ...] + TR[: p.T, ...] + UBI[: p.T, ...] + G[: p.T, ...] + I_g[: p.T, ...] ), "total_tax_revenue": total_tax_revenue[: p.T, ...], "business_tax_revenue": business_tax_revenue[: p.T, ...], "iit_payroll_tax_revenue": iit_payroll_tax_revenue[: p.T, ...], "iit_revenue": iit_revenue[: p.T, ...], "payroll_tax_revenue": payroll_tax_revenue[: p.T, ...], "bequest_tax_revenue": bequest_tax_revenue[: p.T, ...], "wealth_tax_revenue": wealth_tax_revenue[: p.T, ...], "cons_tax_revenue": cons_tax_revenue[: p.T, ...], "D": D[: p.T, ...], "D_f": D_f[: p.T, ...], "D_d": D_d[: p.T, ...], "new_borrowing": new_borrowing[: p.T, ...], "debt_service": debt_service[: p.T, ...], "new_borrowing_f": new_borrowing_f[: p.T, ...], "debt_service_f": debt_service_f[: p.T, ...], "r": r[: p.T, ...], "r_gov": r_gov[: p.T, ...], "r_p": r_p[: p.T, ...], "w": w[: p.T, ...], "p_m": p_m[: p.T, ...], "p_i": p_i[: p.T, ...], "p_tilde": p_tilde[: p.T, ...], "b_sp1": bmat_splus1[: p.T, ...], "b_s": bmat_s[: p.T, ...], "b_sp1_preTP": b_splus1init, "n": n_mat[: p.T, ...], "c": c_mat[: p.T, ...], "c_i": c_i[: p.T, ...], "bq": bqmat[: p.T, ...], "rm": rmmat[: p.T, ...], "tr": trmat[: p.T, ...], "ubi": ubi[: p.T, ...], "before_tax_income": y_before_tax_mat[: p.T, ...], "labor_income": labor_income_mat[: p.T, ...], "capital_income": capital_income_mat[: p.T, ...], "hh_net_taxes": tax_mat[: p.T, ...], "income_payroll_taxes": income_tax_mat[: p.T, ...], "sales_tax": sales_tax_mat[: p.T, ...], "wealth_tax": wealth_tax_mat[: p.T, ...], "bequest_tax": bq_tax_mat[: p.T, ...], "pension_benefits": pension_mat[: p.T, ...], "etr": etr_path[: p.T, ...], "mtrx": mtrx_path[: p.T, ...], "mtry": mtry_path[: p.T, ...], "theta": theta, "factor": factor, "euler_savings": eul_savings[: p.T, ...], "euler_labor_leisure": eul_laborleisure[: p.T, ...], "resource_constraint_error": RC_error[: p.T, ...], } tpi_dir = os.path.join(p.output_base, "TPI") utils.mkdirs(tpi_dir) tpi_vars = os.path.join(tpi_dir, "TPI_vars.pkl") with open(tpi_vars, "wb") as f: pickle.dump(output, f) if np.any(G) < 0: logger.warning( "Government spending is negative along transition path" + " to satisfy budget" ) if ( (TPIiter >= p.maxiter) or (np.absolute(TPIdist) > p.mindist_TPI) ) and ENFORCE_SOLUTION_CHECKS: msg = "Transition path equlibrium not found (TPIdist)" if stall == "oscillating": msg += ( "; the outer loop stalled cycling -- try a lower nu, or " "TPI_outer_method='anderson'" ) elif stall == "diverging": msg += ( "; the outer loop stalled diverging -- check the fiscal " "block (spending, revenue, debt_ratio_ss)" ) raise RuntimeError(msg) if (np.any(np.absolute(RC_error) >= p.RC_TPI)) and ENFORCE_SOLUTION_CHECKS: raise RuntimeError(_rc_error_message(RC_error, p.RC_TPI)) if ( np.any(np.absolute(eul_savings) >= p.mindist_TPI) or (np.any(np.absolute(eul_laborleisure) > p.mindist_TPI)) ) and ENFORCE_SOLUTION_CHECKS: raise RuntimeError(_euler_error_message(eul_savings, eul_laborleisure)) return output