Skip to content

Commit e344353

Browse files
authored
Fix mortgage interest outliers (#876)
1 parent 10c3036 commit e344353

3 files changed

Lines changed: 88 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed structural mortgage-interest conversion so QRF outliers cannot create implausibly large gross mortgage-interest inputs.

policyengine_us_data/utils/mortgage_interest.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232
"mortgage_owner_status",
3333
]
3434

35+
# Upper bound used to reject impossible QRF mortgage-deduction outliers before
36+
# converting formula-level deductions into structural mortgage inputs. This is
37+
# intentionally above recent mortgage rates, but low enough to prevent an
38+
# imputed current-law deductible from requiring billion-dollar gross interest.
39+
MAX_DEDUCTIBLE_MORTGAGE_INTEREST_RATE = 0.15
40+
3541

3642
def impute_tax_unit_mortgage_balance_hints(
3743
data: Dict[str, Dict[int, np.ndarray]],
@@ -117,10 +123,14 @@ def convert_mortgage_interest_to_structural_inputs(
117123
118124
The conversion is intentionally conservative:
119125
* current-law deductible mortgage interest is preserved exactly
126+
unless a QRF-imputed outlier exceeds a high-rate bound
120127
* current-law total interest deduction is preserved exactly
121128
* SCF-imputed first-lien and HELOC splits are preserved when available
122129
* weak balance hints are lifted to a conservative lower bound implied by
123130
the observed deductible mortgage interest
131+
* itemizer balances are capped at the current-law debt cap when needed so
132+
the conversion cannot create implausible gross interest to preserve a
133+
noisy formula-level imputation
124134
* the origination year is heuristic, because the current public pipeline
125135
does not carry a mortgage-vintage input
126136
@@ -156,12 +166,6 @@ def convert_mortgage_interest_to_structural_inputs(
156166
hinted_total_balance = np.maximum(first_balance_hint + second_balance_hint, 0)
157167
balance_floor = _interest_implied_balance_floor(tax_unit_deductible, tp)
158168

159-
total_interest_deduction = _get_tax_unit_interest_deduction_target(
160-
data,
161-
tp,
162-
tax_unit_deductible,
163-
)
164-
165169
fallback_person_share = _filer_share(data, tp, person_tax_unit_idx, n_tax_units)
166170
person_share = _normalize_person_share(
167171
person_deductible,
@@ -216,6 +220,24 @@ def convert_mortgage_interest_to_structural_inputs(
216220
total_balance = first_balance + second_balance
217221

218222
applicable_cap = np.where(origination_year <= 2017, pre_cap, post_cap)
223+
tax_unit_deductible = _cap_deductible_mortgage_interest(
224+
tax_unit_deductible,
225+
applicable_cap,
226+
)
227+
first_balance, second_balance = _cap_itemizer_balances_to_current_law_cap(
228+
first_balance,
229+
second_balance,
230+
applicable_cap,
231+
has_mortgage,
232+
)
233+
total_balance = first_balance + second_balance
234+
235+
total_interest_deduction = _get_tax_unit_interest_deduction_target(
236+
data,
237+
tp,
238+
tax_unit_deductible,
239+
)
240+
219241
deductible_share = np.ones(n_tax_units, dtype=np.float32)
220242
capped_mask = has_mortgage & (total_balance > applicable_cap)
221243
deductible_share[capped_mask] = (
@@ -294,6 +316,33 @@ def _get_tax_unit_interest_deduction_target(
294316
return np.maximum(values, tax_unit_deductible).astype(np.float32)
295317

296318

319+
def _cap_deductible_mortgage_interest(
320+
deductible_mortgage_interest: np.ndarray,
321+
applicable_cap: np.ndarray,
322+
) -> np.ndarray:
323+
max_deductible = MAX_DEDUCTIBLE_MORTGAGE_INTEREST_RATE * applicable_cap
324+
return np.minimum(deductible_mortgage_interest, max_deductible).astype(np.float32)
325+
326+
327+
def _cap_itemizer_balances_to_current_law_cap(
328+
first_balance: np.ndarray,
329+
second_balance: np.ndarray,
330+
applicable_cap: np.ndarray,
331+
has_mortgage: np.ndarray,
332+
) -> tuple[np.ndarray, np.ndarray]:
333+
total_balance = first_balance + second_balance
334+
needs_cap = has_mortgage & (total_balance > applicable_cap)
335+
if not np.any(needs_cap):
336+
return first_balance, second_balance
337+
338+
scale = np.ones_like(total_balance, dtype=np.float32)
339+
scale[needs_cap] = applicable_cap[needs_cap] / total_balance[needs_cap]
340+
return (
341+
(first_balance * scale).astype(np.float32),
342+
(second_balance * scale).astype(np.float32),
343+
)
344+
345+
297346
def _get_tax_unit_mortgage_balance_hints(
298347
data: Dict[str, Dict[int, np.ndarray]],
299348
time_period: int,

tests/unit/calibration/test_mortgage_interest.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from policyengine_us_data.utils.mortgage_interest import (
6+
MAX_DEDUCTIBLE_MORTGAGE_INTEREST_RATE,
67
STRUCTURAL_MORTGAGE_VARIABLES,
78
_filing_status_for_mortgage_caps,
89
_interest_implied_balance_floor,
@@ -360,6 +361,37 @@ def test_structural_mortgage_conversion_scales_hints_to_interest_floor():
360361
)
361362

362363

364+
@pytest.mark.skipif(
365+
not HAS_STRUCTURAL_MORTGAGE_INPUTS,
366+
reason="Installed policyengine-us does not yet expose structural MID inputs.",
367+
)
368+
def test_structural_mortgage_conversion_caps_impossible_outliers():
369+
data = _base_dataset_dict(
370+
person_tax_unit_ids=[1, 1],
371+
ages=[55, 53],
372+
deductible_mortgage_interest=[100_000_000.0, 0.0],
373+
interest_deduction=[10_000.0],
374+
filing_status=[b"JOINT"],
375+
)
376+
_set_balance_hints(data, first=[2_000_000_000.0], second=[0.0])
377+
378+
converted = convert_mortgage_interest_to_structural_inputs(data, TIME_PERIOD)
379+
380+
origination_year = int(
381+
converted["first_home_mortgage_origination_year"][TIME_PERIOD][0]
382+
)
383+
current_law_cap = _current_law_cap(b"JOINT", origination_year)
384+
max_interest = MAX_DEDUCTIBLE_MORTGAGE_INTEREST_RATE * current_law_cap
385+
386+
assert converted["first_home_mortgage_balance"][TIME_PERIOD][0] <= (
387+
current_law_cap + 1
388+
)
389+
assert converted["first_home_mortgage_interest"][TIME_PERIOD][0] <= (
390+
max_interest + 1
391+
)
392+
assert converted["home_mortgage_interest"][TIME_PERIOD].sum() <= (max_interest + 1)
393+
394+
363395
def test_post_tcja_cap_uses_mfs_limit():
364396
assert _post_tcja_cap("SEPARATE") == pytest.approx(375_000.0)
365397
assert _post_tcja_cap("MARRIED_FILING_SEPARATELY") == pytest.approx(375_000.0)

0 commit comments

Comments
 (0)