Skip to content

Commit b58cf3b

Browse files
authored
Target SSI fiscal-year outlays in calibration (#1110)
* Normalize SSI calibration targets for payment timing * Use SSI fiscal year outlays calibration target * Handle legacy SSI target replacement * Bump PolicyEngine US dependency * Bump PolicyEngine US for payroll wage fix * Scale SSI fiscal year target for single-year data
1 parent 4544761 commit b58cf3b

15 files changed

Lines changed: 438 additions & 49 deletions

changelog.d/1109.changed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Targeted single-year-compatible SSI federal fiscal-year outlays in calibration.

policyengine_us_data/calibration/sanity_checks.py

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
"income_tax_before_credits",
3737
]
3838

39+
COMPUTED_KEY_MONETARY_VARS = [
40+
"ssi_federal_fiscal_year_outlays",
41+
]
42+
3943
TAKEUP_VARS = [
4044
"takes_up_snap_if_eligible",
4145
"takes_up_ssi_if_eligible",
@@ -375,6 +379,27 @@ def _get_person_weights(f, period, person_count, household_weights):
375379
except KeyError:
376380
return None
377381

382+
def _append_finite_check(var: str, vals) -> None:
383+
vals = np.asarray(vals)
384+
n_nan = int(np.isnan(vals).sum())
385+
n_inf = int(np.isinf(vals).sum())
386+
if n_nan > 0 or n_inf > 0:
387+
results.append(
388+
{
389+
"check": f"no_nan_inf_{var}",
390+
"status": "FAIL",
391+
"detail": f"{n_nan} NaN, {n_inf} Inf",
392+
}
393+
)
394+
else:
395+
results.append(
396+
{
397+
"check": f"no_nan_inf_{var}",
398+
"status": "PASS",
399+
"detail": "",
400+
}
401+
)
402+
378403
with h5py.File(h5_path, "r") as f:
379404
# 1. Weight non-negativity
380405
w_key = f"household_weight/{period}"
@@ -440,24 +465,7 @@ def _get_person_weights(f, period, person_count, household_weights):
440465
vals = _get(f, f"{var}/{period}")
441466
if vals is None:
442467
continue
443-
n_nan = int(np.isnan(vals).sum())
444-
n_inf = int(np.isinf(vals).sum())
445-
if n_nan > 0 or n_inf > 0:
446-
results.append(
447-
{
448-
"check": f"no_nan_inf_{var}",
449-
"status": "FAIL",
450-
"detail": f"{n_nan} NaN, {n_inf} Inf",
451-
}
452-
)
453-
else:
454-
results.append(
455-
{
456-
"check": f"no_nan_inf_{var}",
457-
"status": "PASS",
458-
"detail": "",
459-
}
460-
)
468+
_append_finite_check(var, vals)
461469

462470
# 4. Person-to-household mapping
463471
person_hh_arr = _get(f, f"person_household_id/{period}")
@@ -650,9 +658,35 @@ def _get_person_weights(f, period, person_count, household_weights):
650658
)
651659
)
652660

661+
for var, vals in _computed_key_monetary_values(h5_path, period).items():
662+
_append_finite_check(var, vals)
663+
653664
return results
654665

655666

667+
def _computed_key_monetary_values(h5_path: str, period: int) -> dict[str, np.ndarray]:
668+
try:
669+
from policyengine_us import Microsimulation
670+
671+
sim = Microsimulation(dataset=h5_path)
672+
except Exception as error:
673+
logger.info("Skipping computed monetary sanity checks: %s", error)
674+
return {}
675+
676+
values = {}
677+
for var in COMPUTED_KEY_MONETARY_VARS:
678+
try:
679+
result = sim.calculate(var, period)
680+
values[var] = np.asarray(
681+
result.values if hasattr(result, "values") else result
682+
)
683+
except Exception as error:
684+
logger.info(
685+
"Skipping computed monetary sanity check for %s: %s", var, error
686+
)
687+
return values
688+
689+
656690
def main():
657691
import argparse
658692

policyengine_us_data/calibration/target_config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ include:
205205
geo_level: national
206206
- variable: social_security_survivors
207207
geo_level: national
208-
- variable: ssi
208+
- variable: ssi_federal_fiscal_year_outlays
209209
geo_level: national
210210
- variable: person_count
211211
geo_level: national

policyengine_us_data/db/etl_national_targets.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@
2727
get_geographic_strata,
2828
)
2929
from policyengine_us_data.utils.ssi_targets import (
30+
SSI_PAYMENT_TARGET_SOURCE,
3031
SSI_RECIPIENT_TARGET_NOTES,
3132
SSI_RECIPIENT_TARGET_SOURCE,
3233
SSI_RECIPIENT_TARGET_YEAR,
3334
SSI_RECIPIENT_TARGETS_2024,
35+
get_ssi_payment_target_notes,
36+
scale_ssi_fiscal_year_target_for_single_year_data,
3437
)
3538
from policyengine_us_data.utils.target_variables import (
3639
target_variable_components,
@@ -150,6 +153,33 @@ def _register_target_variable(session: Session, variable: str) -> None:
150153
session.flush()
151154

152155

156+
def _deactivate_replaced_national_target(
157+
session: Session,
158+
*,
159+
stratum_id: int,
160+
old_variable: str,
161+
new_variable: str,
162+
period: int,
163+
) -> None:
164+
old_targets = session.exec(
165+
select(Target).where(
166+
Target.stratum_id == stratum_id,
167+
Target.variable == old_variable,
168+
Target.period == period,
169+
Target.reform_id == 0,
170+
Target.active,
171+
)
172+
).all()
173+
for target in old_targets:
174+
target.active = False
175+
replacement_note = (
176+
f"Deactivated because {new_variable} replaced this target concept."
177+
)
178+
target.notes = (
179+
f"{target.notes} | {replacement_note}" if target.notes else replacement_note
180+
)
181+
182+
153183
WIC_NATIONAL_ANNUAL_SUMMARY_SOURCE = (
154184
"https://www.fns.usda.gov/sites/default/files/resource-files/wisummary-4.xlsx"
155185
)
@@ -751,13 +781,14 @@ def extract_national_targets(year: int = DEFAULT_YEAR):
751781
"income_tax_positive",
752782
"snap",
753783
"social_security",
754-
"ssi",
784+
"ssi_federal_fiscal_year_outlays",
755785
"unemployment_compensation",
756786
]
757787

758788
# Mapping from target variable to CBO parameter name (when different)
759789
cbo_param_name_map = {
760790
"income_tax_positive": "income_tax", # CBO param is income_tax
791+
"ssi_federal_fiscal_year_outlays": "ssi",
761792
}
762793

763794
cbo_targets = []
@@ -767,12 +798,20 @@ def extract_national_targets(year: int = DEFAULT_YEAR):
767798
value = tax_benefit_system.parameters(
768799
time_period
769800
).calibration.gov.cbo._children[param_name]
801+
source = "CBO Budget Projections"
802+
notes = f"CBO projection for {variable_name}"
803+
if variable_name == "ssi_federal_fiscal_year_outlays":
804+
value = scale_ssi_fiscal_year_target_for_single_year_data(
805+
value, time_period
806+
)
807+
source = SSI_PAYMENT_TARGET_SOURCE
808+
notes = get_ssi_payment_target_notes(time_period)
770809
cbo_targets.append(
771810
{
772811
"variable": variable_name,
773812
"value": float(value),
774-
"source": "CBO Budget Projections",
775-
"notes": f"CBO projection for {variable_name}",
813+
"source": source,
814+
"notes": notes,
776815
"year": time_period,
777816
}
778817
)
@@ -912,6 +951,14 @@ def load_national_targets(
912951
for _, target_data in direct_targets_df.iterrows():
913952
target_year = target_data["year"]
914953
_register_target_variable(session, target_data["variable"])
954+
if target_data["variable"] == "ssi_federal_fiscal_year_outlays":
955+
_deactivate_replaced_national_target(
956+
session,
957+
stratum_id=us_stratum.stratum_id,
958+
old_variable="ssi",
959+
new_variable="ssi_federal_fiscal_year_outlays",
960+
period=target_year,
961+
)
915962
# Check if target already exists
916963
existing_target = session.exec(
917964
select(Target).where(

policyengine_us_data/utils/loss.py

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
)
2727
from policyengine_core.reforms import Reform
2828
from policyengine_us_data.utils.soi import pe_to_soi, get_soi, get_tracked_soi_row
29-
from policyengine_us_data.utils.ssi_targets import SSI_RECIPIENT_TARGETS_2024
29+
from policyengine_us_data.utils.ssi_targets import (
30+
SSI_RECIPIENT_TARGETS_2024,
31+
scale_ssi_fiscal_year_target_for_single_year_data,
32+
)
3033
from policyengine_us_data.utils.target_variables import (
3134
target_variable_components,
3235
)
@@ -93,6 +96,19 @@
9396
),
9497
]
9598

99+
CBO_PROGRAMS = [
100+
"income_tax_positive",
101+
"snap",
102+
"social_security",
103+
"ssi_federal_fiscal_year_outlays",
104+
"unemployment_compensation",
105+
]
106+
107+
CBO_PARAM_NAME_MAP = {
108+
"income_tax_positive": "income_tax",
109+
"ssi_federal_fiscal_year_outlays": "ssi",
110+
}
111+
96112
HARD_CODED_TOTALS = {
97113
MEDICARE_PART_B_PREMIUM_VARIABLE: (
98114
get_beneficiary_paid_medicare_part_b_premiums_target(2024)
@@ -233,6 +249,16 @@ def _add_ssi_recipient_targets(loss_matrix, targets_array, sim, time_period):
233249
return targets_array, loss_matrix
234250

235251

252+
def _cbo_program_target_value(sim, variable_name: str, time_period):
253+
param_name = CBO_PARAM_NAME_MAP.get(variable_name, variable_name)
254+
value = sim.tax_benefit_system.parameters(
255+
time_period
256+
).calibration.gov.cbo._children[param_name]
257+
if variable_name == "ssi_federal_fiscal_year_outlays":
258+
return scale_ssi_fiscal_year_target_for_single_year_data(value, time_period)
259+
return value
260+
261+
236262
ACA_SPENDING_TARGETS = {
237263
2024: 98e9,
238264
}
@@ -1316,30 +1342,12 @@ def build_loss_matrix(dataset: type, time_period):
13161342
# refundable credit payments in excess of liability are classified as
13171343
# outlays, not negative receipts. See: https://www.cbo.gov/publication/43767
13181344

1319-
CBO_PROGRAMS = [
1320-
"income_tax_positive",
1321-
"snap",
1322-
"social_security",
1323-
"ssi",
1324-
"unemployment_compensation",
1325-
]
1326-
1327-
# Mapping from variable name to CBO parameter name (when different)
1328-
CBO_PARAM_NAME_MAP = {
1329-
"income_tax_positive": "income_tax",
1330-
}
1331-
13321345
for variable_name in CBO_PROGRAMS:
13331346
label = f"nation/cbo/{variable_name}"
13341347
loss_matrix[label] = sim.calculate(variable_name, map_to="household").values
13351348
if any(loss_matrix[label].isna()):
13361349
raise ValueError(f"Missing values for {label}")
1337-
param_name = CBO_PARAM_NAME_MAP.get(variable_name, variable_name)
1338-
targets_array.append(
1339-
sim.tax_benefit_system.parameters(
1340-
time_period
1341-
).calibration.gov.cbo._children[param_name]
1342-
)
1350+
targets_array.append(_cbo_program_target_value(sim, variable_name, time_period))
13431351

13441352
targets_array, loss_matrix = _add_ssi_recipient_targets(
13451353
loss_matrix,

policyengine_us_data/utils/ssi_targets.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,99 @@
11
"""Shared SSI calibration targets."""
22

3+
from datetime import date, timedelta
4+
5+
SSI_CBO_TARGET_SOURCE = (
6+
"https://www.cbo.gov/system/files/2026-02/51313-2026-02-ssi.xlsx"
7+
)
8+
SSI_PAYMENT_TIMING_SOURCE = "https://www.ssa.gov/oact/ssir/SSI24/IV_C_Payments.html"
9+
SSI_PAYMENT_RULE_SOURCE = "https://www.ssa.gov/OP_Home/cfr20/416/416-0502.htm"
10+
SSI_PAYMENT_TARGET_SOURCE = (
11+
f"{SSI_CBO_TARGET_SOURCE}; {SSI_PAYMENT_TIMING_SOURCE}; {SSI_PAYMENT_RULE_SOURCE}"
12+
)
13+
14+
15+
def _as_fiscal_year(year) -> int:
16+
return int(str(year)[:4])
17+
18+
19+
def _is_new_years_day_observed(day: date) -> bool:
20+
new_years_day = date(day.year, 1, 1)
21+
next_new_years_day = date(day.year + 1, 1, 1)
22+
return (
23+
day == new_years_day
24+
or (new_years_day.weekday() == 6 and day == date(day.year, 1, 2))
25+
or (next_new_years_day.weekday() == 5 and day == date(day.year, 12, 31))
26+
)
27+
28+
29+
def _is_labor_day(day: date) -> bool:
30+
return day.month == 9 and day.weekday() == 0 and day.day <= 7
31+
32+
33+
def _is_federal_holiday_affecting_ssi_payment(day: date) -> bool:
34+
return _is_new_years_day_observed(day) or _is_labor_day(day)
35+
36+
37+
def _ssi_payment_date(year: int, month: int) -> date:
38+
payment_date = date(year, month, 1)
39+
while payment_date.weekday() >= 5 or _is_federal_holiday_affecting_ssi_payment(
40+
payment_date
41+
):
42+
payment_date -= timedelta(days=1)
43+
return payment_date
44+
45+
46+
def _ssi_fiscal_year_benefit_months(year) -> list[date]:
47+
fiscal_year = _as_fiscal_year(year)
48+
fiscal_year_start = date(fiscal_year - 1, 10, 1)
49+
fiscal_year_end = date(fiscal_year, 9, 30)
50+
51+
benefit_months = []
52+
for calendar_year in (fiscal_year - 1, fiscal_year):
53+
for month in range(1, 13):
54+
payment_day = _ssi_payment_date(calendar_year, month)
55+
if fiscal_year_start <= payment_day <= fiscal_year_end:
56+
benefit_months.append(date(calendar_year, month, 1))
57+
return benefit_months
58+
59+
60+
def get_ssi_fiscal_year_payment_count(year) -> int:
61+
"""Return SSI benefit months with payment dates in the federal fiscal year."""
62+
return len(_ssi_fiscal_year_benefit_months(year))
63+
64+
65+
def get_ssi_single_year_available_payment_count(year) -> int:
66+
"""Return fiscal-year SSI benefit months available from a single-year H5."""
67+
fiscal_year = _as_fiscal_year(year)
68+
return sum(
69+
benefit_month.year == fiscal_year
70+
for benefit_month in _ssi_fiscal_year_benefit_months(year)
71+
)
72+
73+
74+
def scale_ssi_fiscal_year_target_for_single_year_data(value, year) -> float:
75+
"""Scale full fiscal-year SSI outlays to months computable from one H5 year."""
76+
return (
77+
float(value)
78+
* get_ssi_single_year_available_payment_count(year)
79+
/ get_ssi_fiscal_year_payment_count(year)
80+
)
81+
82+
83+
def get_ssi_payment_target_notes(year) -> str:
84+
fiscal_year = _as_fiscal_year(year)
85+
available_count = get_ssi_single_year_available_payment_count(year)
86+
payment_count = get_ssi_fiscal_year_payment_count(year)
87+
return (
88+
"CBO SSI federal fiscal-year outlays scaled to the benefit months "
89+
"computable from a single-year PolicyEngine-US-data H5 using "
90+
"policyengine-us ssi_federal_fiscal_year_outlays; "
91+
f"FY{fiscal_year} has {payment_count} SSI benefit months paid in the "
92+
f"federal fiscal year, of which {available_count} are benefit months "
93+
f"in calendar year {fiscal_year}"
94+
)
95+
96+
397
SSI_RECIPIENT_TARGET_YEAR = 2024
498
SSI_RECIPIENT_TARGET_SOURCE = (
599
"https://www.ssa.gov/policy/docs/statcomps/ssi_monthly/2024-12/table01.html"

0 commit comments

Comments
 (0)