Skip to content

Commit 3857f6a

Browse files
Add income-scaled match tolerance to compare (--rel-tolerance) (#1061)
* Add income-scaled match tolerance to compare (--rel-tolerance) The flat $15 absolute match tolerance flags negligible tax differences on extreme-magnitude records (large S-corp income/losses), where PE and TAXSIM agree to a tiny fraction of income. Add an optional income-scaled tolerance: a record matches within max($15, rel-tolerance * |AGI|). Default 0.0 preserves the absolute-only behavior, so nothing is hidden by default. On the eCPS this reveals the true alignment (2025: federal 84% -> 97.7%, state 82% -> 90.6% at 0.1% of AGI); the genuine remaining diffs are S-corp loss/passive treatment (federal) and a per-state tail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Apply ruff format Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 09e6fbe commit 3857f6a

4 files changed

Lines changed: 109 additions & 4 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add an income-scaled match tolerance option (--rel-tolerance) to the compare command so negligible tax differences on extreme-magnitude records (e.g. large S-corp income/losses) are not flagged as mismatches; default preserves the flat $15 absolute tolerance.

policyengine_taxsim/cli.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,28 @@ def taxsim(input_file, output, sample, taxsim_path):
309309
default=False,
310310
help="Assume large W-2 wages for QBID (aligns with TAXSIM S-Corp handling)",
311311
)
312-
def compare(input_file, sample, output_dir, year, disable_salt, logs, assume_w2_wages):
312+
@click.option(
313+
"--rel-tolerance",
314+
type=float,
315+
default=0.0,
316+
help=(
317+
"Income-scaled match tolerance as a fraction of |AGI| (e.g. 0.001 = "
318+
"0.1%). A record matches if the tax difference is within "
319+
"max($15, rel-tolerance * |AGI|), avoiding false mismatches on "
320+
"extreme-magnitude records (e.g. large S-corp income/losses). "
321+
"Default 0 uses the flat $15 absolute tolerance."
322+
),
323+
)
324+
def compare(
325+
input_file,
326+
sample,
327+
output_dir,
328+
year,
329+
disable_salt,
330+
logs,
331+
assume_w2_wages,
332+
rel_tolerance,
333+
):
313334
"""Compare PolicyEngine and TAXSIM results"""
314335
try:
315336
# Load and optionally sample data
@@ -365,7 +386,15 @@ def compare(input_file, sample, output_dir, year, disable_salt, logs, assume_w2_
365386

366387
# Compare results
367388
click.echo("Comparing results...")
368-
config = ComparisonConfig(federal_tolerance=15.0, state_tolerance=15.0)
389+
config = ComparisonConfig(
390+
federal_tolerance=15.0,
391+
state_tolerance=15.0,
392+
relative_tolerance=rel_tolerance,
393+
)
394+
if rel_tolerance > 0:
395+
click.echo(
396+
f"Using income-scaled tolerance: max($15, {rel_tolerance:.3%} of |AGI|)"
397+
)
369398

370399
comparator = TaxComparator(taxsim_results, pe_results, config)
371400
comparison_results = comparator.compare()

policyengine_taxsim/comparison/comparator.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ class ComparisonConfig:
1818
state_tax_col: str = "siitax"
1919
federal_tolerance: float = 15.0 # $15 absolute tolerance
2020
state_tolerance: float = 15.0 # $15 absolute tolerance
21+
# Optional income-scaled tolerance: a record matches if the tax difference
22+
# is within max(absolute_tolerance, relative_tolerance * |AGI|). This avoids
23+
# flagging negligible differences on extreme-magnitude records (e.g. large
24+
# S-corp income/losses), where PE and TAXSIM agree to a tiny fraction of
25+
# income but exceed a flat $15. Default 0.0 preserves the absolute-only
26+
# behavior. `agi_col` is the column used for scaling (federal AGI, v10).
27+
relative_tolerance: float = 0.0
28+
agi_col: str = "v10"
2129
id_col: str = "taxsimid"
2230

2331

@@ -133,11 +141,22 @@ def _compare_tax_column(
133141
# Return empty results if column doesn't exist
134142
return np.array([]), mismatches
135143

136-
# Compare with tolerance
144+
# Effective per-record tolerance: absolute, optionally raised to an
145+
# income-scaled floor (relative_tolerance * |AGI|) so negligible
146+
# differences at extreme income magnitudes are not flagged.
147+
eff_tol = tolerance
148+
rel = getattr(self.config, "relative_tolerance", 0.0) or 0.0
149+
if rel > 0 and self.config.agi_col in self.taxsim_results.columns:
150+
agi = self.taxsim_results[self.config.agi_col].abs().fillna(0)
151+
eff_tol = np.maximum(tolerance, rel * agi.to_numpy())
152+
153+
# Compare with tolerance. np.isclose applies atol elementwise when atol
154+
# is an array, giving the per-record income-scaled tolerance.
137155
matches = np.isclose(
138156
self.taxsim_results[col_name],
139157
self.policyengine_results[col_name],
140-
atol=tolerance,
158+
atol=eff_tol,
159+
rtol=0.0,
141160
equal_nan=True,
142161
)
143162

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Tests for the income-scaled match tolerance in the comparator.
3+
4+
The flat $15 absolute tolerance flags negligible differences on
5+
extreme-magnitude records (e.g. large S-corp income/losses), where PE and
6+
TAXSIM agree to a tiny fraction of income. `relative_tolerance` lets a record
7+
match within max($15, relative_tolerance * |AGI|). Default 0.0 preserves the
8+
absolute-only behavior.
9+
"""
10+
11+
import pandas as pd
12+
13+
from policyengine_taxsim.comparison.comparator import (
14+
TaxComparator,
15+
ComparisonConfig,
16+
)
17+
18+
19+
def _frames(taxsimid, agi, fiitax_t, fiitax_p):
20+
tx = pd.DataFrame(
21+
{"taxsimid": taxsimid, "v10": agi, "fiitax": fiitax_t, "siitax": 0.0}
22+
)
23+
pe = pd.DataFrame(
24+
{"taxsimid": taxsimid, "v10": agi, "fiitax": fiitax_p, "siitax": 0.0}
25+
)
26+
return tx, pe
27+
28+
29+
def test_default_absolute_tolerance_flags_large_dollar_diff():
30+
"""With default (absolute-only) tolerance, a $500 diff is a mismatch."""
31+
tx, pe = _frames([1], [10_000_000], [1_000_000], [1_000_500])
32+
r = TaxComparator(tx, pe, ComparisonConfig()).compare()
33+
assert r.federal_match_percentage == 0.0
34+
35+
36+
def test_income_scaled_tolerance_absorbs_negligible_diff():
37+
"""A $500 diff on $10M AGI is 0.005% — within a 0.1% income-scaled
38+
tolerance, so it matches; a genuinely large diff still fails."""
39+
# negligible: $500 on $10M
40+
tx, pe = _frames([1], [10_000_000], [1_000_000], [1_000_500])
41+
r = TaxComparator(tx, pe, ComparisonConfig(relative_tolerance=0.001)).compare()
42+
assert r.federal_match_percentage == 100.0
43+
44+
# material: $50k diff on $10M (0.5%) exceeds the 0.1% floor -> mismatch
45+
tx2, pe2 = _frames([1], [10_000_000], [1_000_000], [1_050_000])
46+
r2 = TaxComparator(tx2, pe2, ComparisonConfig(relative_tolerance=0.001)).compare()
47+
assert r2.federal_match_percentage == 0.0
48+
49+
50+
def test_absolute_floor_preserved_for_small_income():
51+
"""The $15 absolute floor still applies for low-AGI records (the scaled
52+
tolerance never lowers it below $15)."""
53+
# $20 diff on $1,000 AGI: 0.1% of AGI = $1, so floor stays $15 -> mismatch
54+
tx, pe = _frames([1], [1_000], [100], [120])
55+
r = TaxComparator(tx, pe, ComparisonConfig(relative_tolerance=0.001)).compare()
56+
assert r.federal_match_percentage == 0.0

0 commit comments

Comments
 (0)