def adjust_with_indexing(self, params_or_path, **kwargs): """ Custom adjust method that handles special indexing logic. The logic is: 1. If "parameter_indexing_CPI_offset" is adjusted, first set parameter_indexing_CPI_offset to zero before implementing the adjusted parameter_indexing_CPI_offset to avoid stacking adjustments. Then, revert all values of indexed parameters to the 'known' values: a. The current values of parameters that are being adjusted are deleted after the first year in which parameter_indexing_CPI_offset is adjusted. b. The current values of parameters that are not being adjusted (i.e. are not in params) are deleted after the last known year, with the exception of parameters that revert to their pre-TCJA values in 2026. Instead, these (2026) parameter values are recalculated using the new inflation rates. After the 'unknown' values have been deleted, the last known value is extrapolated through the budget window. If there are indexed parameters in the adjustment, they will be included in the final adjustment call (unless their indexed status is changed). 2. If the "indexed" status is updated for any parameter: a. If a parameter has values that are being adjusted before the indexed status is adjusted, update those parameters first. b. Extend the values of that parameter to the year in which the status is changed. c. Change the indexed status for the parameter. d. Update parameter values in adjustment that are adjusted after the year in which the indexed status changes. e. Using the new "-indexed" status, extend the values of that parameter through the remaining years or until the -indexed status changes again. 3. Update all parameters that are not indexing related, i.e. they are not "parameter_indexing_CPI_offset" or do not end with "-indexed". 4. Return parsed adjustment with all adjustments, including "-indexed" parameters. Notable side-effects: - All values of a parameter whose indexed status is adjusted are wiped out after the year in which the value is adjusted for the same hard-coding reason. """ # Temporarily turn off extra ops during the intermediary adjustments # so that expensive and unnecessary operations are not run. label_to_extend = self.label_to_extend array_first = self.array_first self.array_first = False self._gfactors = GrowFactors() params = self.read_params(params_or_path) # Check if parameter_indexing_CPI_offset is adjusted. If so, reset # values of all indexed parameters after year where # parameter_indexing_CPI_offset is changed. If # parameter_indexing_CPI_offset is changed multiple times, then # reset values after the first year in which the # parameter_indexing_CPI_offset is changed. needs_reset = [] if params.get("parameter_indexing_CPI_offset") is not None: # Update parameter_indexing_CPI_offset with new value. cpi_adj = super().adjust( { "parameter_indexing_CPI_offset": params["parameter_indexing_CPI_offset"] }, **kwargs) # turn off extend now that parameter_indexing_CPI_offset # has been updated. self.label_to_extend = None # Get first year in which parameter_indexing_CPI_offset # is changed. cpi_min_year = min(cpi_adj["parameter_indexing_CPI_offset"], key=lambda vo: vo["year"]) rate_adjustment_vals = self.select_gte( "parameter_indexing_CPI_offset", year=cpi_min_year["year"]) # "Undo" any existing parameter_indexing_CPI_offset for # years after parameter_indexing_CPI_offset has # been updated. self._inflation_rates = self._inflation_rates[:cpi_min_year[ "year"] - self.start_year] + self._gfactors.price_inflation_rates( cpi_min_year["year"], self.LAST_BUDGET_YEAR) # Then apply new parameter_indexing_CPI_offset values to # inflation rates for cpi_vo in rate_adjustment_vals: self._inflation_rates[cpi_vo["year"] - self.start_year] += cpi_vo["value"] # 1. Delete all unknown values. # 1.a For revision, these are years specified after cpi_min_year. init_vals = {} to_delete = {} for param in params: if (param == "parameter_indexing_CPI_offset" or param in self._wage_indexed): continue if param.endswith("-indexed"): param = param.split("-indexed")[0] if self._data[param].get("indexed", False): init_vals[param] = pt.select_lte( self._init_values[param], True, {"year": cpi_min_year["year"]}, ) to_delete[param] = self.select_gt( param, year=cpi_min_year["year"]) needs_reset.append(param) self.delete(to_delete, **kwargs) super().adjust(init_vals, **kwargs) # 1.b For all others, these are years after last_known_year. last_known_year = max(cpi_min_year["year"], self._last_known_year) # calculate 2026 value, using new inflation rates, for parameters # that revert to their pre-TCJA values. long_params = [ 'II_brk7', 'II_brk6', 'II_brk5', 'II_brk4', 'II_brk3', 'II_brk2', 'II_brk1', 'PT_brk7', 'PT_brk6', 'PT_brk5', 'PT_brk4', 'PT_brk3', 'PT_brk2', 'PT_brk1', 'PT_qbid_taxinc_thd', 'ALD_BusinessLosses_c', 'STD', 'II_em', 'II_em_ps', 'AMT_em', 'AMT_em_ps', 'AMT_em_pe', 'ID_ps', 'ID_AllTaxes_c' ] final_ifactor = 1.0 pyear = 2017 # prior year before TCJA first implemented fyear = 2026 # final year in which parameter values revert to # pre-TCJA values # construct final-year inflation factor from prior year # NOTE: pvalue[t+1] = pvalue[t] * ( 1 + irate[t] ) for year in range(pyear, fyear): final_ifactor *= 1 + \ self._inflation_rates[year - self.start_year] long_param_vals = defaultdict(list) # compute final year parameter value for param in long_params: # only revert param in 2026 if it's not in revision if params.get(param) is None: # grab param values from 2017 vos = self.select_eq(param, year=pyear) # use final_ifactor to inflate from 2017 to 2026 for vo in vos: long_param_vals[param].append( # Create new dict to avoid modifying the original dict( vo, value=min( 9e99, round(vo["value"] * final_ifactor, 0)), year=fyear, )) needs_reset.append(param) super().adjust(long_param_vals, **kwargs) init_vals = {} to_delete = {} for param in self._data: if (param in params or param == "parameter_indexing_CPI_offset" or param in self._wage_indexed): continue if self._data[param].get("indexed", False): init_vals[param] = pt.select_lte(self._init_values[param], True, {"year": last_known_year}) to_delete[param] = self.select_eq(param, strict=True, _auto=True) needs_reset.append(param) self.delete(to_delete, **kwargs) super().adjust(init_vals, **kwargs) self.extend(label="year") # 2. Handle -indexed parameters. self.label_to_extend = None index_affected = set([]) for param, values in params.items(): if param.endswith("-indexed"): base_param = param.split("-indexed")[0] if not self._data[base_param].get("indexable", None): msg = f"Parameter {base_param} is not indexable." raise pt.ValidationError({"errors": { base_param: msg }}, labels=None) index_affected |= {param, base_param} indexed_changes = {} if isinstance(values, bool): indexed_changes[self.start_year] = values elif isinstance(values, list): for vo in values: indexed_changes[vo.get("year", self.start_year)] = vo["value"] else: msg = ("Index adjustment parameter must be a boolean or " "list.") raise pt.ValidationError({"errors": { base_param: msg }}, labels=None) # 2.a Adjust values less than first year in which index status # was changed. if base_param in params: min_index_change_year = min(indexed_changes.keys()) vos = pt.select_lt( params[base_param], False, {"year": min_index_change_year}, ) if vos: min_adj_year = min(vos, key=lambda vo: vo["year"])["year"] self.delete({ base_param: self.select_gt(base_param, year=min_adj_year) }) super().adjust({base_param: vos}, **kwargs) self.extend( params=[base_param], label="year", label_values=list( range(self.start_year, min_index_change_year)), ) for year in sorted(indexed_changes): indexed_val = indexed_changes[year] # Get and delete all default values after year where # indexed status changed. self.delete( {base_param: self.select_gt(base_param, year=year)}) # 2.b Extend values for this parameter to the year where # the indexed status changes. if year > self.start_year: self.extend( params=[base_param], label="year", label_values=list(range(self.start_year, year + 1)), ) # 2.c Set indexed status. self._data[base_param]["indexed"] = indexed_val # 2.d Adjust with values greater than or equal to current # year in params if base_param in params: vos = pt.select_gte(params[base_param], False, {"year": year}) super().adjust({base_param: vos}, **kwargs) # 2.e Extend values through remaining years. self.extend(params=[base_param], label="year") needs_reset.append(base_param) # Re-instate ops. self.label_to_extend = label_to_extend self.array_first = array_first # Filter out "-indexed" params. nonindexed_params = { param: val for param, val in params.items() if param not in index_affected } needs_reset = set(needs_reset) - set(nonindexed_params.keys()) if needs_reset: self._set_state(params=needs_reset) # 3. Do adjustment for all non-indexing related parameters. adj = super().adjust(nonindexed_params, **kwargs) # 4. Add indexing params back for return to user. adj.update({ param: val for param, val in params.items() if param in index_affected }) return adj
def _update(self, revision, print_warnings, raise_errors): """ A translation layer on top of Parameters.adjust. Projects that have historically used the `_update` method with Tax-Calculator styled adjustments can continue to do so without making any changes to how they handle adjustments. Converts reforms that are compatible with Tax-Calculator: adjustment = { "standard_deduction": {2024: [10000.0, 10000.0]}, "ss_rate": {2024: 0.2} } into reforms that are compatible with ParamTools: { 'standard_deduction': [ {'year': 2024, 'marital_status': 'single', 'value': 10000.0}, {'year': 2024, 'marital_status': 'joint', 'value': 10000.0} ], 'ss_rate': [{'year': 2024, 'value': 0.2}]} } """ if not isinstance(revision, dict): raise pt.ValidationError( {"errors": { "schema": "Revision must be a dictionary." }}, None) new_params = defaultdict(list) for param, val in revision.items(): if not isinstance(param, str): msg = f"Parameter {param} is not a string." raise pt.ValidationError({"errors": {"schema": msg}}, None) if (param not in self._data and param.split("-indexed")[0] not in self._data): if self._removed_params and param in self._removed_params: msg = f"{param} {self._removed_params[param]}" elif (self._redefined_params and param in self._redefined_params): msg = self._redefined_params[param] else: msg = f"Parameter {param} does not exist." raise pt.ValidationError({"errors": {"schema": msg}}, None) if param.endswith("-indexed"): for year, yearval in val.items(): new_params[param] += [{"year": year, "value": yearval}] elif isinstance(val, dict): for year, yearval in val.items(): val = getattr(self, param) if (self._data[param].get("type", None) == "str" and isinstance(yearval, str)): new_params[param] += [{"value": yearval}] continue yearval = np.array(yearval) if (getattr(val, "shape", None) and yearval.shape != val[0].shape): exp_dims = val[0].shape if exp_dims == tuple(): msg = (f"{param} is not an array " f"parameter.") elif yearval.shape: msg = (f"{param} has {yearval.shape[0]} elements " f"but should only have {exp_dims[0]} " f"elements.") else: msg = (f"{param} is an array parameter with " f"{exp_dims[0]} elements.") raise pt.ValidationError({"errors": { "schema": msg }}, None) value_objects = self.from_array(param, yearval.reshape( (1, *yearval.shape)), year=year) new_params[param] += value_objects else: msg = (f"{param} must be a year:value dictionary " f"if you are not using the new adjust method.") raise pt.ValidationError({"errors": {"schema": msg}}, None) return self.adjust(new_params, print_warnings=print_warnings, raise_errors=raise_errors)
def adjust(self, params_or_path, **kwargs): """ Custom adjust method that handles special indexing logic. The logic is: 1. If "CPI_offset" is adjusted, revert all values of indexed parameters to the 'known' values: a. The current values of parameters that are being adjusted are deleted after the first year in which CPI_offset is adjusted. b. The current values of parameters that are not being adjusted (i.e. are not in params) are deleted after the last known year. After the 'unknown' values have been deleted, the last known value is extrapolated through the budget window. If there are indexed parameters in the adjustment, they will be included in the final adjustment call (unless their indexed status is changed). 2. If the "indexed" status is updated for any parameter: a. If a parameter has values that are being adjusted before the indexed status is adjusted, update those parameters first. b. Extend the values of that parameter to the year in which the status is changed. c. Change the the indexed status for the parameter. d. Update parameter values in adjustment that are adjusted after the year in which the indexed status changes. e. Using the new "-indexed" status, extend the values of that parameter through the remaining years or until the -indexed status changes again. 3. Update all parameters that are not indexing related, i.e. they are not "CPI_offset" or do not end with "-indexed". 4. Return parsed adjustment with all adjustments, including "-indexed" parameters. Notable side-effects: - All values of indexed parameters, including default values, are wiped out after the first year in which the "CPI_offset" is changed. This is only necessary because Tax-Calculator hard-codes inflated values. If Tax-Calculator only hard-coded values that were changed for non-inflation related reasons, then this would not be necessary for default values. - All values of a parameter whose indexed status is adjusted are wiped out after the year in which the value is adjusted for the same hard-coding reason. """ min_year = min(self._stateless_label_grid["year"]) # Temporarily turn off extra ops during the intermediary adjustments # so that expensive and unnecessary operations are not run. label_to_extend = self.label_to_extend array_first = self.array_first self.array_first = False params = self.read_params(params_or_path) # Check if CPI_offset is adjusted. If so, reset values of all indexed # parameters after year where CPI_offset is changed. If CPI_offset is # changed multiple times, then reset values after the first year in # which the CPI_offset is changed. needs_reset = [] if params.get("CPI_offset") is not None: # Update CPI_offset with new value. cpi_adj = super().adjust({"CPI_offset": params["CPI_offset"]}, **kwargs) # turn off extend now that CPI_offset has been updated. self.label_to_extend = None # Get first year in which CPI_offset is changed. cpi_min_year = min(cpi_adj["CPI_offset"], key=lambda vo: vo["year"]) # Apply new CPI_offset values to inflation rates rate_adjustment_vals = self.select_gt("CPI_offset", True, year=cpi_min_year["year"] - 1) for cpi_vo in rate_adjustment_vals: self._inflation_rates[cpi_vo["year"] - self.start_year] += \ cpi_vo["value"] # 1. delete all unknown values. # 1.a for revision these are years specified after cpi_min_year to_delete = {} for param in params: if param == "CPI_offset" or param in self._wage_indexed: continue if param.endswith("-indexed"): param = param.split("-indexed")[0] if self._data[param].get("indexed", False): gt = self.select_gt(param, True, year=cpi_min_year["year"]) to_delete[param] = [ dict(vo, **{"value": None}) for vo in gt ] needs_reset.append(param) super().adjust(to_delete, **kwargs) # 1.b for all others these are years after last_known_year to_delete = {} last_known_year = max(cpi_min_year["year"], self._last_known_year) for param in self._data: if (param in params or param == "CPI_offset" or param in self.WAGE_INDEXED_PARAMS): continue if self._data[param].get("indexed", False): gt = self.select_gt(param, True, year=last_known_year) to_delete[param] = [ dict(vo, **{"value": None}) for vo in gt ] needs_reset.append(param) super().adjust(to_delete, **kwargs) self.extend(label_to_extend="year") # 2. Handle -indexed parameters. self.label_to_extend = None index_affected = set([]) for param, values in params.items(): if param.endswith("-indexed"): base_param = param.split("-indexed")[0] if not self._data[base_param].get("indexable", None): msg = f"Parameter {base_param} is not indexable." raise pt.ValidationError({"errors": { base_param: msg }}, labels=None) index_affected |= {param, base_param} indexed_changes = {} if isinstance(values, bool): indexed_changes[min_year] = values elif isinstance(values, list): for vo in values: indexed_changes[vo.get("year", min_year)] = vo["value"] else: raise Exception( "Index adjustment parameter must be a boolean or list." ) # 2.a Adjust values less than first year in which index status # was changed. if base_param in params: min_index_change_year = min(indexed_changes.keys()) vos = select_lt(params[base_param], False, {"year": min_index_change_year}) if vos: min_adj_year = min(vos, key=lambda vo: vo["year"])["year"] gt = self.select_gt(base_param, True, year=min_adj_year) super().adjust({ base_param: [dict(vo, **{"value": None}) for vo in gt] }) super().adjust({base_param: vos}, **kwargs) self.extend( params=[base_param], label_to_extend="year", label_to_extend_values=list( range(min_year, min_index_change_year)), ) for year in sorted(indexed_changes): indexed_val = indexed_changes[year] # Get and delete all default values after year where # indexed status changed. gte = self.select_gt(base_param, True, year=year) super().adjust({ base_param: [dict(vo, **{"value": None}) for vo in gte] }) # 2.b Extend values for this parameter to the year where # the indexed status changes. if year > min_year: self.extend( params=[base_param], label_to_extend="year", label_to_extend_values=list( range(min_year, year + 1)), ) # 2.c Set indexed status. self._data[base_param]["indexed"] = indexed_val # 2.d Adjust with values greater than or equal to current # year in params if base_param in params: vos = pt.select_gt(params[base_param], False, {"year": year - 1}) super().adjust({base_param: vos}, **kwargs) # 2.e Extend values through remaining years. self.extend(params=[base_param], label_to_extend="year") needs_reset.append(base_param) # Re-instate ops. self.label_to_extend = label_to_extend self.array_first = array_first # Filter out "-indexed" params. nonindexed_params = { param: val for param, val in params.items() if param not in index_affected } needs_reset = set(needs_reset) - set(nonindexed_params.keys()) if needs_reset: self._set_state(params=needs_reset) # 3. Do adjustment for all non-indexing related parameters. adj = super().adjust(nonindexed_params, **kwargs) # 4. Add indexing params back for return to user. adj.update({ param: val for param, val in params.items() if param in index_affected }) return adj