import os
import numpy as np
import scipy.interpolate as si
import paramtools
import ogcore
from ogcore import elliptical_u_est
from ogcore.utils import (
rate_conversion,
extrapolate_array,
extrapolate_nested_list,
)
from ogcore.constants import BASELINE_DIR
CURRENT_PATH = os.path.abspath(os.path.dirname(__file__))
# Pension parameters that a pension regime may set. A regime assigns its
# values by birth cohort (see Specifications._build_cohort_pension_arrays),
# so each parameter listed here is expanded to a (T + S) x S array indexed
# by period t and age s (a (T + S) x S x J array for those that also vary
# by lifetime income group, listed in COHORT_PENSION_PARAMS_J).
COHORT_PENSION_PARAMS = (
"retirement_age",
"avg_earn_num_years",
"yr_contrib",
"alpha_db",
"tau_p",
"k_ret",
"indR",
)
COHORT_PENSION_PARAMS_J = ("alpha_db",)
def _is_number(value):
"""
Whether ``value`` is a single real number (and not a bool).
Args:
value (object): value to check
Returns:
bool: True if ``value`` is an int, float, or NumPy number
"""
return isinstance(
value, (int, float, np.integer, np.floating)
) and not isinstance(value, bool)
# The keys of a pension regime that describe when and to whom it applies
PENSION_REGIME_KEYS = ("year", "max_age_at_start")
[docs]
class Specifications(paramtools.Parameters):
"""
Inherits ParamTools Parameters abstract base class.
"""
defaults = os.path.join(CURRENT_PATH, "default_parameters.json")
array_first = True
def __init__(
self,
output_base=BASELINE_DIR,
baseline_dir=BASELINE_DIR,
baseline=False,
num_workers=1,
):
super().__init__()
self.output_base = output_base
self.baseline_dir = baseline_dir
self.baseline = baseline
self.num_workers = num_workers
# put OG-Core version in parameters to save for reference
self.ogcore_version = ogcore.__version__
# Pension regimes are entered through update_specifications and
# kept outside of paramtools, which cannot validate a list of
# dicts. See _build_cohort_pension_arrays for their meaning.
self.pension_regimes = []
# does cheap calculations to find parameter values
self.initialize()
self.parameter_warnings = ""
self.parameter_errors = ""
self._ignore_errors = False
[docs]
def initialize(self):
"""
ParametersBase reads JSON file and sets attributes to self
Next call self.compute_default_params for further initialization
Args:
None
Returns:
None
"""
self.compute_default_params()
[docs]
def compute_default_params(self):
"""
Does cheap calculations to return parameter values
Args:
None
Returns:
None
"""
# Catch error if baseline_spending=True and baseline=True
if self.baseline_spending and self.baseline:
err_msg = (
"Parameter baseline_spending=True cannot coincide with "
+ "baseline=True."
)
raise ValueError(err_msg)
# reshape lambdas
self.lambdas = self.lambdas.reshape(self.lambdas.shape[0], 1)
# cast integers as integers
self.S = int(self.S)
self.T = int(self.T)
self.J = len(self.lambdas)
# The initial household wealth parameters are optional: a null
# value in the JSON (or None in a revision) means the time path
# starts from the steady-state distribution of savings, which is
# what TPI does when the attribute is None. paramtools stores a
# null float as NaN and a cleared value as an empty array, so
# normalize both to None here, and check a supplied distribution.
for name in ("initial_wealth_ratio", "initial_hhwealth_dist"):
value = getattr(self, name, None)
if (
value is None
or np.size(value) == 0
or np.all(np.isnan(np.asarray(value, dtype=float)))
):
setattr(self, name, None)
elif name == "initial_hhwealth_dist":
dist = np.asarray(value, dtype=float)
if dist.shape != (self.S, self.J):
raise ValueError(
"initial_hhwealth_dist must have shape (S, J) = "
f"({self.S}, {self.J}); got {dist.shape}"
)
if not np.isclose(dist.sum(), 1.0, atol=1e-6):
raise ValueError(
"initial_hhwealth_dist must sum to one; got "
f"{dist.sum()}"
)
self.initial_hhwealth_dist = dist
else:
self.initial_wealth_ratio = float(np.squeeze(value))
# beta_annual and chi_b carry one value per lifetime-income group, but
# paramtools validates values and nesting depth, not list length, so a
# wrong-length vector used to flow through silently: a short one
# crashed much later (IndexError deep in the SS solve, far from the
# cause) and a long one was silently truncated to the first J entries
# -- wrong economics with no symptom. Guard here, where J is first
# known: one value broadcasts to all J groups, J values pass through,
# a uniform vector of any other length is reshaped losslessly
# (smaller-J runs that keep the uniform defaults rely on this), and a
# non-uniform mismatch raises immediately.
for param in ("beta_annual", "chi_b"):
val = np.asarray(getattr(self, param)).ravel()
if val.shape[0] != self.J:
if val.shape[0] == 1 or np.all(val == val[0]):
setattr(self, param, np.full(self.J, val[0]))
else:
raise ValueError(
f"{param} must have either 1 value (applied to all "
f"J groups) or J={self.J} values; got "
f"{val.shape[0]} non-uniform values."
)
# get parameters of elliptical utility function
self.b_ellipse, self.upsilon = elliptical_u_est.estimation(
self.frisch, self.ltilde
)
# determine length of budget window from individual income tax
# parameters passed in
self.BW = len(self.etr_params)
# Find number of economically active periods of life
self.E = int(
self.starting_age
* (self.S / (self.ending_age - self.starting_age))
)
# Find rates in model periods from annualized rates
self.beta = 1 / (
rate_conversion(
1 / self.beta_annual - 1,
self.starting_age,
self.ending_age,
self.S,
)
+ 1
)
self.delta = -1 * rate_conversion(
-1 * self.delta_annual, self.starting_age, self.ending_age, self.S
)
self.delta_g = -1 * rate_conversion(
-1 * self.delta_g_annual,
self.starting_age,
self.ending_age,
self.S,
)
self.g_y = rate_conversion(
self.g_y_annual, self.starting_age, self.ending_age, self.S
)
# set fraction of income taxes from payroll to zero initially
# will be updated when read in tax function parameters
if self.T + self.S > self.BW:
self.frac_tax_payroll = np.append(
self.frac_tax_payroll,
np.ones(self.T + self.S - self.BW) * self.frac_tax_payroll[-1],
)
# Interpolate chi_n and create omega_SS_80 if necessary
if self.S < 80 and self.chi_n.shape[-1] == 80:
self.age_midp_80 = np.linspace(20.5, 99.5, 80)
reshape_chi_n = np.zeros((self.T + self.S, self.S))
for t in range(self.T + self.S):
if self.chi_n.ndim == 1:
self.chi_n_interp = si.interp1d(
self.age_midp_80,
np.squeeze(self.chi_n),
kind="cubic",
)
else:
self.chi_n_interp = si.interp1d(
self.age_midp_80,
np.squeeze(self.chi_n[t, :]),
kind="cubic",
)
self.newstep = 80.0 / self.S
self.age_midp_S = np.linspace(
20 + 0.5 * self.newstep, 100 - 0.5 * self.newstep, self.S
)
reshape_chi_n[t, :] = self.chi_n_interp(self.age_midp_S)
self.chi_n = reshape_chi_n
# Extend parameters that may vary over the time path
tp_param_list = [
"alpha_G",
"alpha_T",
"alpha_I",
"alpha_FA",
"alpha_bs_G",
"alpha_bs_T",
"alpha_bs_I",
"alpha_bs_FA",
"world_int_rate_annual",
"adjustment_factor_for_cit_receipts",
"tau_bq",
"h_wealth",
"m_wealth",
"p_wealth",
"zeta_D",
"zeta_K",
"r_gov_scale",
"r_gov_shift",
"g_RM",
]
for item in tp_param_list:
param_in = getattr(self, item)
param_out = extrapolate_array(
param_in, dims=(self.T + self.S,), item=item
)
setattr(self, item, param_out)
# The hybrid DB-NDC cutoff factors are paths of length T + S. A
# single value is the initial factor of a path indexed to remove
# trend growth, init_val / e^{g_y t}, so the cutoffs stay fixed
# in real terms; a list of two or more values is used as given,
# with every period after the last value entered keeping it.
for name in ("factor1_db_ndc_hybrid", "factor2_db_ndc_hybrid"):
value = np.atleast_1d(
np.asarray(getattr(self, name), dtype=float)
).ravel()
if value.size == 1:
value = value[0] * np.exp(
-self.g_y * np.arange(self.T + self.S)
)
else:
value = extrapolate_array(
value, dims=(self.T + self.S,), item=name
)
setattr(self, name, value)
self.avg_labor_inc_t1_model_units = float(
np.squeeze(self.avg_labor_inc_t1_model_units)
)
# The hybrid DB-NDC cutoffs are checked against each other here,
# once both are paths of the same length, rather than with a
# paramtools validator, which cannot compare lists of different
# lengths
if self.pension_system == "Hybrid DB-NDC" and np.any(
self.factor2_db_ndc_hybrid < self.factor1_db_ndc_hybrid
):
bad = int(
np.argmax(
self.factor2_db_ndc_hybrid < self.factor1_db_ndc_hybrid
)
)
raise ValueError(
"factor2_db_ndc_hybrid must be at least "
"factor1_db_ndc_hybrid in every period: in model period "
f"{bad}, factor1_db_ndc_hybrid="
f"{self.factor1_db_ndc_hybrid[bad]} and "
f"factor2_db_ndc_hybrid={self.factor2_db_ndc_hybrid[bad]}"
)
# Deal with parameters that vary across industry and over time
tp_param_list2 = [
"Z",
"delta_tau_annual",
"cit_rate",
"inv_tax_credit",
]
for item in tp_param_list2:
param_in = getattr(self, item)
param_out = extrapolate_array(
param_in, dims=(self.T + self.S, self.M), item=item
)
setattr(self, item, param_out)
# Deal with parameters that vary across consumption good and over time
tp_param_list3 = ["tau_c"]
for item in tp_param_list3:
param_in = getattr(self, item)
param_out = extrapolate_array(
param_in, dims=(self.T + self.S, self.I), item=item
)
setattr(self, item, param_out)
# Deal with parameters that vary across J and over time
tp_param_list3 = [
"labor_income_tax_noncompliance_rate",
"capital_income_tax_noncompliance_rate",
"replacement_rate_adjust",
"income_tax_filer",
"wealth_tax_filer",
"tau_payroll",
]
for item in tp_param_list3:
param_in = np.asarray(getattr(self, item))
# If J has changed since this parameter was last extrapolated
# (e.g., via update_specifications), a previously extrapolated
# T+S x J_old array will not match the new J. When the values
# are the same across the old J groups, collapse to one column
# so that it can be re-extrapolated to the new J. Otherwise,
# leave it to extrapolate_array to raise the error.
if (
param_in.ndim == 2
and param_in.shape[1] != self.J
and np.all(param_in == param_in[:, :1])
):
param_in = param_in[:, :1]
param_out = extrapolate_array(
param_in, dims=(self.T + self.S, self.J), item=item
)
setattr(self, item, param_out)
# Deal with parameters that vary across age and over time
# None at the moment - moved to vary by J also
# tp_param_list4 = [
# "rho",
# ]
# for item in tp_param_list4:
# param_in = getattr(self, item)
# param_out = extrapolate_array(
# param_in, dims=(self.T + self.S, self.S), item=item
# )
# setattr(self, item, param_out)
# Deal with tax parameters that maybe age and time specific
tax_params_to_TP = [
"etr_params",
"mtrx_params",
"mtry_params",
]
for item in tax_params_to_TP:
tax_to_set_in = getattr(self, item)
try:
len(tax_to_set_in[0][0])
except TypeError:
print(
"please give a "
+ item
+ " that is a nested lists of"
+ " lists that is three lists deep"
)
assert False
tax_to_set_out = extrapolate_nested_list(
tax_to_set_in, dims=(self.T, self.S, len(tax_to_set_in[0][0]))
)
setattr(self, item, tax_to_set_out)
# Try to deal with size of eta and eta_RM. They may vary by S, J, T,
# but want to allow user to enter one that varies by only S, S and J,
# S and T, or T and S and J.
eta_params_to_TP = ["eta", "eta_RM", "omega", "rho", "imm_rates"]
for item in eta_params_to_TP:
param_in = getattr(self, item)
param_out = extrapolate_array(
param_in, dims=(self.T + self.S, self.S, self.J), item=item
)
setattr(self, item, param_out)
# extrapolate SS demog objects
demog_obj_to_SS = [
"omega_SS",
"omega_S_preTP",
"imm_rates_preTP",
"rho_preTP",
]
for item in demog_obj_to_SS:
param_in = getattr(self, item)
param_out = extrapolate_array(
param_in, dims=(self.S, self.J), item=item
)
setattr(self, item, param_out)
# extrapolate lifetime ability e matrix over time dimension
param_in = getattr(self, "e")
param_out = extrapolate_array(
param_in, dims=(self.T, self.S, self.J), item="e"
)
setattr(self, "e", param_out)
# Extrapolate chi_n over T + S
param_in = getattr(self, "chi_n")
param_out = extrapolate_array(
param_in, dims=(self.T + self.S, self.S), item="chi_n"
)
setattr(self, "chi_n", param_out)
# make sure zeta matrix sums to one (e.g., default off due to rounding)
self.zeta = self.zeta / self.zeta.sum()
# open economy parameters
self.world_int_rate = rate_conversion(
self.world_int_rate_annual,
self.starting_age,
self.ending_age,
self.S,
)
# expand the cohort-specific pension parameters (retirement age
# and the DB and NDC system parameters) to arrays by period and
# age, and set the period of retirement, retire
self._build_cohort_pension_arrays()
# Calculations for business income taxes
# at some point, we will want to make Cost of Capital Calculator
# a dependency to compute tau_b
# this adjustment factor has as the numerator CIT receipts/GDP
# and as the denominator CIT receipts/GDP from the
# model with baseline parameterization and no adjustment to the
# CIT_rate
self.tau_b = (
self.cit_rate
* self.c_corp_share_of_assets
* self.adjustment_factor_for_cit_receipts.reshape(
self.adjustment_factor_for_cit_receipts.shape[0], 1
)
)
self.delta_tau = -1 * rate_conversion(
-1 * self.delta_tau_annual,
self.starting_age,
self.ending_age,
self.S,
)
# for constant demographics
if self.constant_demographics:
self.g_n_ss = 0.0
self.g_n_preTP = 0.0
self.g_n = np.zeros(self.T + self.S)
# Average rho over J for scalar survival computation
rho_ss = self.rho[-1, :, :].mean(axis=-1) # (S,)
surv_rate1 = np.ones((self.S,)) # prob start at age S
surv_rate1[1:] = np.cumprod(1 - rho_ss[:-1], dtype=float)
# number of each age alive at any time
omega_SS_1d = surv_rate1 / surv_rate1.sum() # (S,)
# Expand to (S, J) joint distribution
lambdas_arr = np.array(self.lambdas).reshape(1, self.J)
self.omega_SS = omega_SS_1d.reshape(self.S, 1) * lambdas_arr
self.imm_rates = np.zeros((self.T + self.S, self.S, self.J))
self.omega = np.tile(
self.omega_SS.reshape(1, self.S, self.J),
(self.T + self.S, 1, 1),
)
self.omega_S_preTP = self.omega_SS.copy()
self.imm_rates_preTP = np.zeros((self.S, self.J))
self.rho_preTP = self.rho[0, :, :]
# Create time series of stationarized UBI transfers
self.ubi_nom_array = self.get_ubi_nom_objs()
[docs]
def get_ubi_nom_objs(self):
"""
Generate time series of nominal SxJ UBI household matrix and aggregate
UBI expenditure over necessary time periods. Also generate steady-state
versions
Args:
self: OG-Core Specifications class object
Returns:
ubi_nom_array (array): T+S x S x J array time series of UBI
transfers in dollars for each type-j age-s household in every
period t
"""
# Get matrices of number of children 0-17, number of dependents 18-20,
# number of adults 21-64, and number of seniors >= 65 from
# OG-Core-Calibration package
ubi_num_017_mat = 1.1 * np.ones((self.S, self.J))
ubi_num_1864_mat = 0.85 * np.ones((self.S, self.J))
ubi_num_65p_mat = 0.15 * np.ones((self.S, self.J))
# Calculate the UBI transfers to each household type in the first
# period t=0
ubi_nom_init = np.tile(
np.reshape(
np.minimum(
(
self.ubi_nom_017 * ubi_num_017_mat
+ self.ubi_nom_1864 * ubi_num_1864_mat
+ self.ubi_nom_65p * ubi_num_65p_mat
),
self.ubi_nom_max,
),
(1, self.S, self.J),
),
(self.T + self.S, 1, 1),
)
# Calculate steady-state and transition path of stationary individual
# household UBI payments and stationary aggregate UBI outlays
if self.ubi_growthadj or self.g_y_annual == 0:
# If ubi_growthadj=True or if g_y_annual<0, then ubi_arr is
# just a copy of the initial UBI matrix for T periods.
ubi_nom_array = ubi_nom_init
else:
# If ubi_growthadj=False, and g_y_annual>=0, then must divide
# by e^{g_y t} every period, then set the steady-state matrix
# to its value close to zero at t=T
ubi_nom_array = np.zeros_like(ubi_nom_init)
discount_factor = np.exp(
self.g_y * np.linspace(0, self.T, self.T + 1)
)
ubi_nom_array[: self.T + 1, :, :] = ubi_nom_init[
: self.T + 1, :, :
] / discount_factor.reshape(discount_factor.shape[0], 1, 1)
ubi_nom_array[self.T + 1 :, :, :] = ubi_nom_array[self.T, :, :]
return ubi_nom_array
def _validate_pension_regimes(self, regimes):
"""
Check the structure and values of the pension regimes list.
Args:
regimes (list): list of dicts, each with a ``year``, an
optional ``max_age_at_start``, and the pension parameters
the regime sets (see COHORT_PENSION_PARAMS)
Returns:
None
Raises:
ValueError: naming the offending regime and the problem
"""
allowed = set(PENSION_REGIME_KEYS) | set(COHORT_PENSION_PARAMS)
if not isinstance(regimes, (list, tuple)):
raise ValueError(
"pension_regimes must be a list of dicts, each with a "
"'year', an optional 'max_age_at_start', and the pension "
"parameters the regime sets: "
+ ", ".join(COHORT_PENSION_PARAMS)
)
ypp = 80.0 / self.S
last_year = self.start_year + (self.T + self.S - 1) * ypp
for k, regime in enumerate(regimes):
label = f"pension_regimes[{k}]"
if not isinstance(regime, dict):
raise ValueError(f"{label} must be a dict, got {regime!r}")
unknown = set(regime) - allowed
if unknown:
raise ValueError(
f"{label} has unknown keys {sorted(unknown)}; allowed "
f"keys are {sorted(allowed)}"
)
if "year" not in regime:
raise ValueError(f"{label} is missing the required 'year'")
year = regime["year"]
if not (
isinstance(year, (int, np.integer))
and not isinstance(year, bool)
):
raise ValueError(
f"{label}['year'] must be an integer calendar year, "
f"got {year!r}"
)
if year > last_year:
raise ValueError(
f"{label}['year']={year} is after the end of the time "
f"path ({int(last_year)})"
)
max_age = regime.get("max_age_at_start")
if max_age is not None and not (
isinstance(max_age, (int, float, np.integer, np.floating))
and not isinstance(max_age, bool)
and max_age >= 0
):
raise ValueError(
f"{label}['max_age_at_start'] must be a non-negative "
f"age in years or None, got {max_age!r}"
)
set_params = [q for q in COHORT_PENSION_PARAMS if q in regime]
if not set_params:
raise ValueError(
f"{label} sets none of the pension parameters "
f"{list(COHORT_PENSION_PARAMS)}"
)
for q in set_params:
value = regime[q]
if q in COHORT_PENSION_PARAMS_J:
values = np.asarray(value).reshape(-1)
ok = values.size in (1, self.J) and all(
_is_number(v) for v in values
)
what = f"a single number or a list of J = {self.J}"
else:
values = np.asarray(value).reshape(-1)
ok = _is_number(value)
what = "a single number"
if not ok:
raise ValueError(
f"{label}['{q}'] must be {what}, got {value!r}"
)
rng = self._data[q]["validators"].get("range", {})
lo, hi = rng.get("min"), rng.get("max")
if isinstance(lo, (int, float)) and np.any(values < lo):
raise ValueError(
f"{label}['{q}']={value} is below the minimum {lo}"
)
if isinstance(hi, (int, float)) and np.any(values > hi):
raise ValueError(
f"{label}['{q}']={value} is above the maximum {hi}"
)
def _pension_base_value(self, name):
"""
Return the value of a cohort pension parameter as the user
entered it (before any expansion by regime), read from the
ParamTools data rather than from the attribute, which holds the
expanded array once compute_default_params has run.
Args:
name (str): parameter name, one of COHORT_PENSION_PARAMS
Returns:
base (Numpy array): a 1-element array for the scalar
parameters, a (T + S) x J array for those that vary by
lifetime income group
Raises:
ValueError: for a year-varying ``retirement_age`` (changes
go through ``pension_regimes``)
"""
base = np.asarray(self.to_array(name), dtype=float)
if name in COHORT_PENSION_PARAMS_J:
base = base.reshape(base.shape[0], -1) if base.ndim > 1 else base
# as in compute_default_params for the J x time parameters:
# if J has changed since the value was entered and the
# groups share a value, collapse to one column
if (
base.ndim == 2
and base.shape[1] != self.J
and np.all(base == base[:, :1])
):
base = base[:, :1]
return extrapolate_array(
base, dims=(self.T + self.S, self.J), item=name
)
base = base.reshape(-1)
if name == "retirement_age" and base.size != 1:
if np.all(base == base[0]):
base = base[:1]
else:
raise ValueError(
"retirement_age takes a single value. A retirement age "
"that changes along the time path is a property of "
"birth cohorts, so enter the change as a pension "
"regime: pension_regimes=[{'year': Y, "
"'max_age_at_start': A, 'retirement_age': R}] applies "
"R to households at or below age A in calendar year "
"Y, from that year on."
)
return base
def _build_cohort_pension_arrays(self):
"""
Expand the cohort-specific pension parameters to arrays indexed
by model period t and age s, and set ``retire``.
Each pension parameter in COHORT_PENSION_PARAMS has a base value
as entered: a single number for ``retirement_age``,
``avg_earn_num_years``, ``yr_contrib``, ``tau_p``, ``k_ret``,
and ``indR``, and a path by calendar year of payment and
lifetime income group for ``alpha_db``. Changes by birth cohort
are entered through ``pension_regimes``, a list of dicts
applied in order. A regime has a calendar ``year`` from which
it applies, an optional ``max_age_at_start`` (the oldest age,
in years, at that calendar year to which it applies; None means
everyone, including those already retired), and the new values
of the parameters it sets (for ``alpha_db``, a single number or
a list with one value per lifetime income group). A regime that
leaves a parameter out leaves it unchanged, so a later regime
can change one parameter for a narrow set of cohorts while an
earlier one still governs the rest. The regime applies to the
household of age s in period t when the calendar year of t is
at or after ``year`` and the household's age in that calendar
year is at or below ``max_age_at_start``. Cohorts not yet born
at ``year`` always qualify.
With S model periods over 80 years of life, the calendar year of
period t is start_year + t * 80 / S, the age of the household at
(t, s) is starting_age + s * 80 / S, and a cutoff by birth year
is the same as a cutoff by age at start: born in or after
``year - max_age_at_start``.
After this method runs, ``retire`` is the (T + S) x S integer
array of the model period at which each household becomes
eligible for the public pension, ``alpha_db`` is a
(T + S) x S x J array, and ``avg_earn_num_years``,
``yr_contrib`` (both still in years), ``tau_p``, ``k_ret``, and
``indR`` are (T + S) x S arrays. ``retirement_age`` keeps its
single base value. The pension functions read a cohort's own
values down the diagonal of these arrays; the steady state uses
the final row.
Args:
None
Returns:
None
Raises:
ValueError: for a year-varying ``retirement_age`` (changes
go through ``pension_regimes``), for a malformed regime,
for a regime that raises a cohort's retirement age after
it has retired, and for a final period that spans more
than one regime
"""
regimes = getattr(self, "pension_regimes", None) or []
self._validate_pension_regimes(regimes)
n_rows = self.T + self.S
ypp = 80.0 / self.S
t_idx, s_idx = np.indices((n_rows, self.S))
year_t = self.start_year + t_idx * ypp
masks = []
for regime in regimes:
mask = year_t >= regime["year"]
max_age = regime.get("max_age_at_start")
if max_age is not None:
age_at_start = (
self.starting_age
+ (s_idx - t_idx) * ypp
+ (regime["year"] - self.start_year)
)
mask &= age_at_start <= max_age
masks.append(mask)
arrays = {}
for name in COHORT_PENSION_PARAMS:
base = self._pension_base_value(name)
if name in COHORT_PENSION_PARAMS_J:
arr = np.broadcast_to(
base[:, np.newaxis, :], (n_rows, self.S, self.J)
).copy()
else:
arr = np.full((n_rows, self.S), base[0])
for regime, mask in zip(regimes, masks):
if name in regime:
arr[mask] = np.asarray(regime[name], dtype=float)
arrays[name] = arr
self.retirement_age = self._pension_base_value("retirement_age")
self.retire = (
np.round(
((arrays["retirement_age"] - self.starting_age) * self.S)
/ 80.0
)
- 1
).astype(int)
for name in COHORT_PENSION_PARAMS:
if name != "retirement_age":
setattr(self, name, arrays[name])
self._check_retire_schedule()
self._check_pension_steady_state(arrays)
def _check_pension_steady_state(self, arrays):
"""
Check that the final period of the time path sits in a single
pension regime, so that the steady state has one value of each
cohort pension parameter.
Args:
arrays (dict): the expanded (T + S) x S or (T + S) x S x J
array of each parameter in COHORT_PENSION_PARAMS
Returns:
None
Raises:
ValueError: if the final row of any array varies by age
"""
for name, arr in arrays.items():
last = arr[-1]
if np.any(last != last[0]):
raise ValueError(
"the final period of the time path spans more than "
"one pension regime, so the steady state has no "
f"single value of {name}. Increase T or lower "
"max_age_at_start in pension_regimes."
)
def _check_retire_schedule(self):
"""
Check that the (T + S) x S ``retire`` array describes a
schedule the model can solve.
Args:
None
Returns:
None
Raises:
ValueError: if any retirement period is outside the working
life, or if a cohort's retirement period rises after the
cohort has already retired (its benefits would be
withdrawn)
"""
retire = self.retire
n_rows, S = retire.shape
if np.any(retire < 1) or np.any(retire >= S):
raise ValueError(
"retirement must fall inside the working life: the "
"retirement period must be between 1 and S - 1 = "
f"{S - 1}, but retire ranges from {retire.min()} to "
f"{retire.max()} (retirement_age {self.retirement_age[0]} "
f"and pension_regimes {self.pension_regimes})"
)
ypp = 80.0 / S
# Each diagonal of retire is one birth cohort: offset k holds
# retire[i, i + k], the cohort of age k in period 0 (k >= 0) or
# born in period -k (k < 0).
for k in range(-(n_rows - 1), S):
diag = np.diagonal(retire, offset=k)
if diag.size < 2:
continue
ages = np.arange(diag.size) + max(k, 0)
for i in np.nonzero(np.diff(diag))[0]:
age, prev, new = ages[i + 1], diag[i], diag[i + 1]
if age >= prev and new > age:
period = i + 1 + max(-k, 0)
raise ValueError(
"pension_regimes raises the retirement age of a "
"cohort after it has retired: in model period "
f"{period} (calendar year "
f"{int(self.start_year + period * ypp)}) the "
f"cohort aged {int(self.starting_age + age * ypp)} "
f"years, retired since period {prev}, moves to "
f"retirement period {new}. Lower max_age_at_start "
"(the oldest age the regime covers) so the regime "
"excludes cohorts already retired."
)
[docs]
def update_specifications(self, revision, raise_errors=True):
"""
Updates parameter specification with values in revision
dictionary.
Args:
revision (dict): dictionary or JSON string with one or more
`PARAM: VALUE` pairs
raise_errors (boolean):
if True (the default), raises ValueError when
`parameter_errors` exists;
if False, does not raise ValueError when
`parameter_errors` exists and leaves error
handling to caller of the update_specifications
method.
Returns:
None
Raises:
ValueError: if raise_errors is True AND
`_validate_parameter_names_types` generates errors OR
`_validate_parameter_values` generates errors.
Notes:
Given a reform dictionary, typical usage of the
Specifications class is as follows::
>>> specs = Specifications()
>>> specs.update_specifications(revision)
An example of a multi-parameter specification is as follows::
>>> revision = {
frisch: [0.03]
}
"""
if not (isinstance(revision, dict) or isinstance(revision, str)):
raise ValueError("ERROR: revision is not a dictionary or string")
if isinstance(revision, str):
# a JSON string or a path to a JSON file
revision = self.read_params(revision)
# Pension regimes are a list of dicts, which paramtools cannot
# validate, so set them aside and check them in
# _build_cohort_pension_arrays (called by compute_default_params)
if "pension_regimes" in revision:
revision = dict(revision) # leave the caller's dict alone
self.pension_regimes = revision.pop("pension_regimes")
# Skip over the adjust method if the tax parameters passed in
# are functions (e.g., in the case of tax_func_type = mono)
tax_update_dict = {}
tax_func_params_functions = False
try:
if revision["tax_func_type"] in ["mono", "mono2D"]:
tax_func_params_functions = True
except (KeyError, TypeError):
pass
if self.tax_func_type in ["mono", "mono2D"]:
tax_func_params_functions = True
if tax_func_params_functions:
try:
for item in ["etr_params", "mtrx_params", "mtry_params"]:
if item in revision.keys():
tax_update_dict[item] = revision[item]
del revision[item]
except (KeyError, TypeError):
pass
self.adjust(revision, raise_errors=raise_errors)
# put tax values skipped over in the adjust method back in so
# they are in the parameters class.
if tax_update_dict != {}:
for key, value in tax_update_dict.items():
setattr(self, key, value)
self.compute_default_params()
[docs]
def revision_warnings_errors(spec_revision):
"""
Generate warnings and errors for OG-Core parameter specifications
Args:
spec_revision (dict): dictionary suitable for use with the
`Specifications.update_specifications method`.
Returns:
rtn_dict (dict): with endpoint specific warning and error messages
"""
rtn_dict = {"warnings": "", "errors": ""}
spec = Specifications()
spec.update_specifications(spec_revision, raise_errors=False)
if spec._errors:
rtn_dict["errors"] = spec._errors
return rtn_dict