Skip to content

Commit 45ccb79

Browse files
authored
Use model housing eligibility for take-up (#996)
* Use model housing eligibility for takeup * Add housing takeup changelog * Use cloned counties for housing takeup eligibility * Pin policyengine-us housing eligibility release
1 parent 52327d6 commit 45ccb79

13 files changed

Lines changed: 244 additions & 75 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Use model housing assistance eligibility for housing take-up draws.

policyengine_us_data/build_outputs/us_augmentations.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
from typing import Any, Callable, Mapping
77

88
import numpy as np
9+
from policyengine_us.variables.gov.hud.is_eligible_for_housing_assistance import (
10+
housing_assistance_eligibility_from_income_limits,
11+
)
912

1013
from policyengine_us_data.calibration.block_assignment import (
1114
derive_geography_from_blocks,
@@ -424,6 +427,11 @@ def _build_takeup_results(
424427
"spm_unit": len(subentity_source_indices["spm_unit"]),
425428
}
426429
reported_anchors = _build_reported_takeup_anchors(data, time_period)
430+
eligibility_masks = self._build_eligibility_masks(
431+
data=data,
432+
context=context,
433+
subentity_source_indices=subentity_source_indices,
434+
)
427435
voluntary_filing_inputs = self._build_voluntary_filing_inputs(
428436
context=context,
429437
tax_unit_source_indices=subentity_source_indices["tax_unit"],
@@ -446,6 +454,7 @@ def _build_takeup_results(
446454
else None
447455
),
448456
reported_anchors=reported_anchors,
457+
eligibility_masks=eligibility_masks,
449458
voluntary_filing_inputs=voluntary_filing_inputs,
450459
)
451460

@@ -495,6 +504,72 @@ def _build_voluntary_filing_inputs(
495504
)[tax_unit_source_indices],
496505
}
497506

507+
def _build_eligibility_masks(
508+
self,
509+
*,
510+
data: PayloadData,
511+
context: PayloadBuildContext,
512+
subentity_source_indices: Mapping[str, np.ndarray],
513+
) -> dict[str, np.ndarray]:
514+
time_period = context.time_period
515+
spm_unit_source_indices = subentity_source_indices["spm_unit"]
516+
spm_unit_household_indices = (
517+
context.reindexed.subentity_household_clone_indices["spm_unit"].astype(
518+
np.int64
519+
)
520+
)
521+
household_county_fips = _required_period_array(
522+
data,
523+
"county_fips",
524+
time_period,
525+
"US take-up housing assistance eligibility requires county_fips "
526+
"from USGeographyPostProcessor",
527+
)
528+
if (
529+
"receives_housing_assistance" in data
530+
and time_period in data["receives_housing_assistance"]
531+
):
532+
receives_housing_assistance = data["receives_housing_assistance"][
533+
time_period
534+
].astype(bool)
535+
else:
536+
receives_housing_assistance = calculate_variable_values(
537+
context.simulation,
538+
"receives_housing_assistance",
539+
period=time_period,
540+
map_to="spm_unit",
541+
)[spm_unit_source_indices].astype(bool)
542+
543+
return {
544+
"takes_up_housing_assistance_if_eligible": (
545+
housing_assistance_eligibility_from_income_limits(
546+
county_fips=np.asarray(household_county_fips)[
547+
spm_unit_household_indices
548+
],
549+
annual_income=calculate_variable_values(
550+
context.simulation,
551+
"hud_annual_income",
552+
period=time_period,
553+
map_to="spm_unit",
554+
)[spm_unit_source_indices],
555+
spm_unit_size=calculate_variable_values(
556+
context.simulation,
557+
"spm_unit_size",
558+
period=time_period,
559+
map_to="spm_unit",
560+
)[spm_unit_source_indices],
561+
spm_unit_tenure_type=calculate_variable_values(
562+
context.simulation,
563+
"spm_unit_tenure_type",
564+
period=time_period,
565+
map_to="spm_unit",
566+
)[spm_unit_source_indices],
567+
receives_housing_assistance=receives_housing_assistance,
568+
year=time_period,
569+
).astype(bool)
570+
)
571+
}
572+
498573

499574
def default_us_postprocessors() -> tuple[
500575
USEntityPostProcessor | USGeographyPostProcessor | USTakeupPostProcessor, ...

policyengine_us_data/calibration/entity_clone.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
import h5py
1818
import numpy as np
19+
from policyengine_us.variables.gov.hud.is_eligible_for_housing_assistance import (
20+
housing_assistance_eligibility_from_income_limits,
21+
)
1922

2023
from policyengine_us_data.calibration.block_assignment import (
2124
derive_geography_from_blocks,
@@ -139,6 +142,13 @@ def _build_reported_takeup_anchors(data: dict, time_period: int) -> dict:
139142
reported_anchors["takes_up_medicaid_if_eligible"] = data[
140143
"has_medicaid_health_coverage_at_interview"
141144
][time_period].astype(bool)
145+
if (
146+
"receives_housing_assistance" in data
147+
and time_period in data["receives_housing_assistance"]
148+
):
149+
reported_anchors["takes_up_housing_assistance_if_eligible"] = data[
150+
"receives_housing_assistance"
151+
][time_period].astype(bool)
142152
return reported_anchors
143153

144154

@@ -423,6 +433,47 @@ def materialize_clone_household_chunk(
423433
map_to="tax_unit",
424434
).values[entity_clone_idx["tax_unit"]],
425435
}
436+
if (
437+
"receives_housing_assistance" in data
438+
and time_period in data["receives_housing_assistance"]
439+
):
440+
receives_housing_assistance = data["receives_housing_assistance"][
441+
time_period
442+
].astype(bool)
443+
else:
444+
receives_housing_assistance = (
445+
sim.calculate(
446+
"receives_housing_assistance",
447+
time_period,
448+
map_to="spm_unit",
449+
)
450+
.values[entity_clone_idx["spm_unit"]]
451+
.astype(bool)
452+
)
453+
eligibility_masks = {
454+
"takes_up_housing_assistance_if_eligible": (
455+
housing_assistance_eligibility_from_income_limits(
456+
county_fips=clone_geo["county_fips"][entity_hh_indices["spm_unit"]],
457+
annual_income=sim.calculate(
458+
"hud_annual_income",
459+
time_period,
460+
map_to="spm_unit",
461+
).values[entity_clone_idx["spm_unit"]],
462+
spm_unit_size=sim.calculate(
463+
"spm_unit_size",
464+
time_period,
465+
map_to="spm_unit",
466+
).values[entity_clone_idx["spm_unit"]],
467+
spm_unit_tenure_type=sim.calculate(
468+
"spm_unit_tenure_type",
469+
time_period,
470+
map_to="spm_unit",
471+
).values[entity_clone_idx["spm_unit"]],
472+
receives_housing_assistance=receives_housing_assistance,
473+
year=time_period,
474+
).astype(bool)
475+
)
476+
}
426477
takeup_results = apply_block_takeup_to_arrays(
427478
hh_blocks=active_blocks,
428479
hh_state_fips=clone_geo["state_fips"].astype(np.int32),
@@ -433,6 +484,7 @@ def materialize_clone_household_chunk(
433484
time_period=time_period,
434485
takeup_filter=takeup_filter,
435486
reported_anchors=reported_anchors,
487+
eligibility_masks=eligibility_masks,
436488
voluntary_filing_inputs=voluntary_filing_inputs,
437489
)
438490
for variable, values in takeup_results.items():

policyengine_us_data/datasets/cps/cps.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
from policyengine_us_data.datasets.cps.takeup import (
2828
align_reported_ssi_disability,
2929
prioritize_reported_recipients,
30-
very_low_income_renter_mask,
3130
)
3231
from policyengine_us_data.datasets.org import (
3332
ORG_BOOL_VARIABLES,
@@ -579,15 +578,14 @@ def add_takeup(self):
579578
),
580579
dtype=bool,
581580
)
582-
very_low_income_renter = very_low_income_renter_mask(
583-
baseline.calculate("hud_income_level").values,
584-
baseline.calculate("spm_unit_tenure_type").values,
585-
)
581+
housing_assistance_eligible = baseline.calculate(
582+
"is_eligible_for_housing_assistance"
583+
).values
586584
data["takes_up_housing_assistance_if_eligible"] = prioritize_reported_recipients(
587585
reported_housing_assistance,
588586
housing_assistance_rate,
589587
rng.random(n_spm_units),
590-
eligible_mask=very_low_income_renter,
588+
eligible_mask=housing_assistance_eligible,
591589
)
592590

593591
# WIC: resolve draws to bools using category-specific rates

policyengine_us_data/datasets/cps/takeup.py

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,12 @@
11
import numpy as np
22

3-
VERY_LOW_INCOME_LEVELS = {"ESPECIALLY_LOW", "VERY_LOW"}
4-
53

64
def _validate_same_shape(*arrays: np.ndarray) -> None:
75
shapes = {np.asarray(array).shape for array in arrays}
86
if len(shapes) != 1:
97
raise ValueError("All arrays must have the same shape")
108

119

12-
def _enum_names(values: np.ndarray) -> np.ndarray:
13-
return np.asarray(
14-
[
15-
getattr(value, "name", str(value))
16-
for value in np.asarray(values, dtype=object)
17-
]
18-
)
19-
20-
21-
def very_low_income_renter_mask(
22-
income_level: np.ndarray,
23-
tenure_type: np.ndarray,
24-
) -> np.ndarray:
25-
income_level = _enum_names(income_level)
26-
tenure_type = _enum_names(tenure_type)
27-
_validate_same_shape(income_level, tenure_type)
28-
return np.isin(income_level, list(VERY_LOW_INCOME_LEVELS)) & (
29-
tenure_type == "RENTER"
30-
)
31-
32-
3310
def prioritize_reported_recipients(
3411
reported_receipt: np.ndarray,
3512
target_rate: float,

policyengine_us_data/utils/takeup.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def assign_takeup_with_reported_anchors(
190190
rates,
191191
reported_mask: Optional[np.ndarray] = None,
192192
group_keys: Optional[np.ndarray] = None,
193+
eligible_mask: Optional[np.ndarray] = None,
193194
) -> np.ndarray:
194195
"""Apply the SSI/SNAP-style reported-first takeup pattern.
195196
@@ -206,22 +207,30 @@ def assign_takeup_with_reported_anchors(
206207
if len(rates_arr) != len(draws):
207208
raise ValueError("rates and draws must align")
208209

210+
if eligible_mask is None:
211+
eligible_mask = np.ones(len(draws), dtype=bool)
212+
else:
213+
eligible_mask = np.asarray(eligible_mask, dtype=bool)
214+
if len(eligible_mask) != len(draws):
215+
raise ValueError("eligible_mask and draws must align")
216+
209217
baseline = draws < rates_arr
210218
if reported_mask is None:
211-
return baseline
219+
return eligible_mask & baseline
212220

213221
reported_mask = np.asarray(reported_mask, dtype=bool)
214222
if len(reported_mask) != len(draws):
215223
raise ValueError("reported_mask and draws must align")
216224

225+
eligible_mask = eligible_mask | reported_mask
217226
result = reported_mask.copy()
218227

219228
if group_keys is None:
220229
unique_rates = np.unique(rates_arr)
221230
if len(unique_rates) != 1:
222231
raise ValueError("group_keys required when rates vary by entity")
223-
target_count = int(unique_rates[0] * len(draws))
224-
non_reporters = ~reported_mask
232+
target_count = int(unique_rates[0] * int(eligible_mask.sum()))
233+
non_reporters = eligible_mask & ~reported_mask
225234
remaining_needed = max(0, target_count - int(reported_mask.sum()))
226235
adjusted_rate = (
227236
remaining_needed / int(non_reporters.sum()) if non_reporters.any() else 0
@@ -238,10 +247,11 @@ def assign_takeup_with_reported_anchors(
238247
group_rates = np.unique(rates_arr[group_mask])
239248
if len(group_rates) != 1:
240249
raise ValueError("Each takeup group must have a single rate")
241-
target_count = int(group_rates[0] * int(group_mask.sum()))
250+
group_eligible = group_mask & eligible_mask
251+
target_count = int(group_rates[0] * int(group_eligible.sum()))
242252
group_reported = reported_mask[group_mask]
243253
remaining_needed = max(0, target_count - int(group_reported.sum()))
244-
group_non_reporters = group_mask & ~reported_mask
254+
group_non_reporters = group_eligible & ~reported_mask
245255
adjusted_rate = (
246256
remaining_needed / int(group_non_reporters.sum())
247257
if group_non_reporters.any()
@@ -423,6 +433,7 @@ def compute_block_takeup_for_entities(
423433
entity_hh_ids: np.ndarray = None,
424434
entity_clone_ids: np.ndarray = None,
425435
reported_mask: Optional[np.ndarray] = None,
436+
eligible_mask: Optional[np.ndarray] = None,
426437
) -> np.ndarray:
427438
"""Compute boolean takeup via block-level seeded draws."""
428439
draws = compute_block_takeup_draws_for_entities(
@@ -448,6 +459,7 @@ def compute_block_takeup_for_entities(
448459
rates,
449460
reported_mask=reported_mask,
450461
group_keys=group_keys,
462+
eligible_mask=eligible_mask,
451463
)
452464

453465

@@ -660,6 +672,7 @@ def apply_block_takeup_to_arrays(
660672
takeup_filter: List[str] = None,
661673
precomputed_rates: Optional[Dict[str, Any]] = None,
662674
reported_anchors: Optional[Dict[str, np.ndarray]] = None,
675+
eligibility_masks: Optional[Dict[str, np.ndarray]] = None,
663676
voluntary_filing_inputs: Optional[Dict[str, np.ndarray]] = None,
664677
) -> Dict[str, np.ndarray]:
665678
"""Compute takeup draws from raw arrays.
@@ -686,13 +699,18 @@ def apply_block_takeup_to_arrays(
686699
precomputed_rates: Optional {rate_key: rate_or_dict} cache.
687700
When provided, skips ``load_take_up_rate`` calls and
688701
uses cached values instead.
702+
reported_anchors: Optional {takeup variable: bool array}; reported
703+
recipients are always assigned take-up.
704+
eligibility_masks: Optional {takeup variable: bool array}; non-reported
705+
take-up is drawn only from the matching eligible entity rows.
689706
690707
Returns:
691708
{variable_name: bool_array} for each takeup variable.
692709
"""
693710
filter_set = set(takeup_filter) if takeup_filter is not None else None
694711
result = {}
695712
reported_anchors = reported_anchors or {}
713+
eligibility_masks = eligibility_masks or {}
696714

697715
for spec in SIMPLE_TAKEUP_VARS:
698716
var_name = spec["variable"]
@@ -716,6 +734,9 @@ def apply_block_takeup_to_arrays(
716734
reported_mask = reported_anchors.get(var_name)
717735
if reported_mask is not None and len(reported_mask) != n_ent:
718736
raise ValueError(f"reported anchor for {var_name} has wrong length")
737+
eligible_mask = eligibility_masks.get(var_name)
738+
if eligible_mask is not None and len(eligible_mask) != n_ent:
739+
raise ValueError(f"eligibility mask for {var_name} has wrong length")
719740
if var_name == "would_file_taxes_voluntarily":
720741
if voluntary_filing_inputs is None:
721742
raise ValueError(
@@ -739,6 +760,7 @@ def apply_block_takeup_to_arrays(
739760
ent_hh_ids,
740761
ent_clone_indices,
741762
reported_mask=reported_mask,
763+
eligible_mask=eligible_mask,
742764
)
743765
result[var_name] = bools
744766

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ classifiers = [
2222
"Programming Language :: Python :: 3.14",
2323
]
2424
dependencies = [
25-
"policyengine-us==1.693.1",
25+
"policyengine-us==1.693.2",
2626
# policyengine-core 3.26.1 is the current 3.26.x runtime and includes the fix for
2727
# PolicyEngine/policyengine-core#482 (user-set ETERNITY inputs lost
2828
# after _invalidate_all_caches) and is required by policyengine-us 1.682.1+.

tests/unit/build_outputs/test_us_augmentations.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ def calculate(self, variable, period=None, map_to=None):
3636
("tax_unit_child_dependents", "tax_unit"): np.array([1, 2]),
3737
("employment_income", "person"): np.array([10, 20, 30]),
3838
("age_head", "tax_unit"): np.array([40, 50]),
39+
("hud_annual_income", "spm_unit"): np.array([200_000, 50_000]),
40+
("spm_unit_size", "spm_unit"): np.array([4, 4]),
41+
("spm_unit_tenure_type", "spm_unit"): np.array(["RENTER", "RENTER"]),
42+
("receives_housing_assistance", "spm_unit"): np.array([False, False]),
3943
}[(variable, map_to)]
4044
return _Calculation(values)
4145

@@ -307,6 +311,10 @@ def fake_sum_person_values(person_values, person_tax_unit_ids, tax_unit_ids):
307311
)
308312
assert seen["entity_counts"] == {"person": 3, "tax_unit": 2, "spm_unit": 2}
309313
assert seen["takeup_filter"] == ["takes_up_snap_if_eligible"]
314+
np.testing.assert_array_equal(
315+
seen["eligibility_masks"]["takes_up_housing_assistance_if_eligible"],
316+
np.array([True, False]),
317+
)
310318
np.testing.assert_array_equal(
311319
seen["voluntary_filing_inputs"]["tax_unit_child_dependents"],
312320
np.array([2, 1]),

0 commit comments

Comments
 (0)