Skip to content

Commit af28802

Browse files
fix: stabilize nearest-neighbor matching ties
1 parent e5c144a commit af28802

6 files changed

Lines changed: 153 additions & 35 deletions

File tree

.tierd_campaign/CAMPAIGN.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,3 +457,15 @@ noisy/forest-dependent — anchored on β₁ + null structure instead.
457457
`proximal`): 17/17 green.
458458
- **P2 tally: 20 batches, 114 tests, 47 estimators.** worklist → 182
459459
(`scripts/tierd_classify.py` exact).
460+
461+
### 2026-06-09 (cont.) — matching tie-break reproducibility follow-up
462+
- Fixed the deferred nearest-neighbor reproducibility lane: `sp.match`
463+
Euclidean/propensity NN ties are now resolved by source DataFrame index
464+
instead of `argpartition` / incidental row order. Lower-index pool units win
465+
exact equal-distance ties; lower-index target units break
466+
without-replacement assignment ties. This is a correctness/reproducibility
467+
fix, not a new estimator batch.
468+
- Added a shuffled-row regression test for equal-distance controls and tightened
469+
the LaLonde guard from the old cross-backend PSM band to the pinned
470+
`1963.43` ATT. CHANGELOG/MIGRATION document that only exact-tie designs can
471+
move.

CHANGELOG.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,12 +184,18 @@ All notable changes to StatsPAI will be documented in this file.
184184
outside its own enum. (Also surfaced and corrected a latent duplicate
185185
`harvest_did` registration.)
186186

187-
- **`sp.replicate('lalonde_1986')` classic-track 1:1 NN PSM golden number
188-
refreshed 2012.5 → 1963.4.** The original pin was stale relative to the
189-
current deterministic `sp.match(method='nearest')` output (a tie-handling
190-
change on the binary covariates moved it ~2.5%); no estimator numerics
191-
changed. A new regression guard (`tests/test_tierD_lalonde_psm_guard.py`)
192-
pins all three classic LaLonde numbers so future drift fails loudly.
187+
- **⚠️ Reproducibility fix: `sp.match(method='nearest')` now resolves exact
188+
nearest-neighbor ties by source DataFrame index.** The Euclidean/propensity
189+
nearest-neighbor path previously relied on `argpartition` / incidental row
190+
order for equal-distance controls (and target order when matching without
191+
replacement), so exact ties on discrete or binary covariates could move the
192+
ATT across environments. Equal-distance ties now select lower-index pool
193+
units first, with lower-index target units used as the without-replacement
194+
fallback. A shuffled-row regression test guards the policy, and
195+
`tests/test_tierD_lalonde_psm_guard.py` is tightened from the old
196+
cross-backend band to the pinned LaLonde PSM ATT (`1963.43`). Existing results
197+
change only when previous data contained exact equal-distance ties. See
198+
[MIGRATION.md](MIGRATION.md#matching-nearest-tie-break).
193199

194200
### Known issues
195201

MIGRATION.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,30 @@ numeric figure uses an HR or CI E-value.
4141

4242
---
4343

44+
<a id="matching-nearest-tie-break"></a>
45+
46+
## Unreleased — ⚠️ `sp.match` nearest-neighbor tie-breaking stabilised
47+
48+
**What changed.** `sp.match(method='nearest')` now resolves exact equal-distance
49+
nearest-neighbor ties by the source DataFrame index. Previously the
50+
Euclidean/propensity nearest-neighbor path delegated tie selection to
51+
`argpartition` and incidental row order, so ties on discrete or binary
52+
covariates could move the ATT across environments. Lower-index control units
53+
are now selected first; when matching without replacement and multiple treated
54+
units have the same best distance, lower-index treated units are assigned first.
55+
56+
**Who is affected.** Only users whose matching data contain exact
57+
equal-distance ties. Continuous covariates without exact ties are unchanged.
58+
For tied designs, results are now deterministic across row order and backend as
59+
long as the DataFrame index preserves unit identity.
60+
61+
**Action required.** None for code. If you previously recorded a nearest-match
62+
estimate on tied discrete covariates, re-run it once and treat the new value as
63+
the stable pin. The bundled LaLonde 1:1 NN PSM guard is now pinned at `1963.43`
64+
instead of allowing the old cross-backend tie band.
65+
66+
---
67+
4468
<a id="blp-maxiter-fix"></a>
4569

4670
## Unreleased — ⚠️ `sp.blp` functionality fix (was non-functional)

src/statspai/matching/match.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ def fit(self) -> CausalResult:
315315
T = clean[self.treat].values.astype(int)
316316
Y = clean[self.y].values.astype(float)
317317
X = clean[self.covariates].values.astype(float)
318+
row_order = self._stable_index_order(clean.index)
318319

319320
idx_t = np.where(T == 1)[0]
320321
idx_c = np.where(T == 0)[0]
@@ -346,7 +347,7 @@ def fit(self) -> CausalResult:
346347
att, se, balance, extra_info = self._fit_exact(Y, X, T, idx_t, idx_c)
347348
method_label = 'Matching (Exact)'
348349
else:
349-
att, se, balance = self._fit_nearest(Y, X, T, idx_t, idx_c)
350+
att, se, balance = self._fit_nearest(Y, X, T, idx_t, idx_c, row_order)
350351
dist_name = self.distance.capitalize()
351352
bc_tag = ', BC' if self.bias_correction else ''
352353
method_label = f'Matching ({dist_name}{bc_tag})'
@@ -398,23 +399,41 @@ def fit(self) -> CausalResult:
398399
# Nearest-neighbor matching (propensity / mahalanobis / euclidean)
399400
# ==================================================================
400401

401-
def _fit_nearest(self, Y, X, T, idx_t, idx_c):
402+
def _fit_nearest(self, Y, X, T, idx_t, idx_c, row_order=None):
402403
"""Nearest-neighbor matching with configurable distance metric."""
404+
if row_order is None:
405+
row_order = np.arange(len(T), dtype=float)
406+
403407
# For propensity distance, estimate PS once with actual treatment
404408
pscore = self._logit_propensity(X, T, poly=self.ps_poly) if self.distance == 'propensity' else None
405409

406410
# Build distance matrix
407411
dist_mat = self._compute_distance_matrix(X, idx_t, idx_c, pscore)
408412

409413
if self.estimand == 'ATT':
410-
matches, weights = self._nn_match_from_dist(dist_mat, self.caliper)
414+
matches, weights = self._nn_match_from_dist(
415+
dist_mat,
416+
self.caliper,
417+
target_order=row_order[idx_t],
418+
pool_order=row_order[idx_c],
419+
)
411420
att = self._compute_effect(Y, idx_t, idx_c, X, matches, weights)
412421
se = self._ai_se(Y, X, T, idx_t, idx_c, matches, weights)
413422
else:
414423
# ATE: match both directions, reuse the same propensity scores
415424
dist_ct = self._compute_distance_matrix(X, idx_c, idx_t, pscore)
416-
m_tc, w_tc = self._nn_match_from_dist(dist_mat, self.caliper)
417-
m_ct, w_ct = self._nn_match_from_dist(dist_ct, self.caliper)
425+
m_tc, w_tc = self._nn_match_from_dist(
426+
dist_mat,
427+
self.caliper,
428+
target_order=row_order[idx_t],
429+
pool_order=row_order[idx_c],
430+
)
431+
m_ct, w_ct = self._nn_match_from_dist(
432+
dist_ct,
433+
self.caliper,
434+
target_order=row_order[idx_c],
435+
pool_order=row_order[idx_t],
436+
)
418437
att_part = self._compute_effect(Y, idx_t, idx_c, X, m_tc, w_tc)
419438
atc_part = self._compute_effect(Y, idx_c, idx_t, X, m_ct, w_ct)
420439
n_t, n_c = len(idx_t), len(idx_c)
@@ -427,6 +446,29 @@ def _fit_nearest(self, Y, X, T, idx_t, idx_c):
427446

428447
return att, se, balance
429448

449+
@staticmethod
450+
def _stable_index_order(index):
451+
"""Numeric rank of DataFrame index labels for deterministic tie-breaking."""
452+
labels = np.asarray(pd.Index(index))
453+
n = len(labels)
454+
try:
455+
order = np.argsort(labels, kind='mergesort')
456+
except TypeError:
457+
order = np.array(
458+
sorted(
459+
range(n),
460+
key=lambda i: (
461+
type(labels[i]).__name__,
462+
repr(labels[i]),
463+
i,
464+
),
465+
),
466+
dtype=int,
467+
)
468+
ranks = np.empty(n, dtype=float)
469+
ranks[order] = np.arange(n, dtype=float)
470+
return ranks
471+
430472
def _compute_distance_matrix(self, X, idx_from, idx_to, pscore=None):
431473
"""Compute distance matrix between two groups."""
432474
X_from = X[idx_from]
@@ -713,7 +755,7 @@ def _logit_propensity(X, T, poly=1):
713755
# NN matching helpers
714756
# ==================================================================
715757

716-
def _nn_match_from_dist(self, dist, caliper=None):
758+
def _nn_match_from_dist(self, dist, caliper=None, target_order=None, pool_order=None):
717759
"""
718760
k-NN matching from a precomputed distance matrix.
719761
@@ -722,20 +764,38 @@ def _nn_match_from_dist(self, dist, caliper=None):
722764
order of their minimum distance (best match first) so the greedy
723765
assignment favours the closest pairs.
724766
767+
Equal-distance ties are resolved by the source DataFrame index:
768+
lower-index pool units are selected first, and without-replacement
769+
target processing falls back to target index order. This makes
770+
matching deterministic across BLAS/NumPy backends and independent of
771+
incidental row order when index labels preserve unit identity.
772+
725773
References: Cunningham (2021, Ch. 5) discusses with- vs.
726774
without-replacement matching and the bias–variance trade-off.
727775
"""
728776
n_target = dist.shape[0]
729777
matches = [None] * n_target
730778
weights = [None] * n_target
779+
if target_order is None:
780+
target_order = np.arange(n_target, dtype=float)
781+
if pool_order is None:
782+
pool_order = np.arange(dist.shape[1], dtype=float)
783+
784+
def _nearest_indices(d, k):
785+
finite = np.isfinite(d)
786+
if not np.any(finite):
787+
return np.array([], dtype=int)
788+
candidates = np.where(finite)[0]
789+
order = np.lexsort((pool_order[candidates], d[candidates]))
790+
return candidates[order[:k]]
731791

732792
# Without replacement: process treated units greedily by best
733793
# minimum distance so each control is used at most once.
734794
if not self.replace:
735795
used = set()
736796
# Sort treated units by their minimum distance to any control
737797
min_dists = np.min(dist, axis=1)
738-
order = np.argsort(min_dists)
798+
order = np.lexsort((target_order, min_dists))
739799

740800
for i in order:
741801
d = dist[i].copy()
@@ -751,7 +811,7 @@ def _nn_match_from_dist(self, dist, caliper=None):
751811
weights[i] = np.array([])
752812
continue
753813

754-
idx = np.argpartition(d, k)[:k]
814+
idx = _nearest_indices(d, k)
755815
matches[i] = idx
756816
weights[i] = np.ones(k) / k
757817
used.update(idx.tolist())
@@ -770,7 +830,7 @@ def _nn_match_from_dist(self, dist, caliper=None):
770830
weights[i] = np.array([])
771831
continue
772832

773-
idx = np.argpartition(d, k)[:k]
833+
idx = _nearest_indices(d, k)
774834
matches[i] = idx
775835
weights[i] = np.ones(k) / k
776836

tests/test_matching.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,33 @@ def test_basic(self, selection_bias_data):
172172
assert abs(result.estimate - 2.0) < 1.5
173173

174174

175+
class TestNearestTieBreaking:
176+
"""Nearest-neighbor ties should be deterministic and index-anchored."""
177+
178+
def test_equal_distance_ties_use_source_index_not_row_order(self):
179+
df = pd.DataFrame(
180+
{
181+
'unit': ['treated_0', 'control_high', 'control_low',
182+
'treated_1', 'control_1'],
183+
'y': [10.0, 100.0, 0.0, 30.0, 25.0],
184+
'treat': [1, 0, 0, 1, 0],
185+
'x': [0.0, 0.0, 0.0, 1.0, 1.0],
186+
},
187+
# control_low and control_high are exact ties for treated_0.
188+
# The lower source index must win regardless of row order.
189+
index=[100, 20, 5, 200, 30],
190+
)
191+
shuffled = df.loc[[20, 100, 30, 5, 200]]
192+
193+
r1 = match(df, y='y', treat='treat', covariates=['x'],
194+
distance='euclidean', method='nearest')
195+
r2 = match(shuffled, y='y', treat='treat', covariates=['x'],
196+
distance='euclidean', method='nearest')
197+
198+
assert r1.estimate == pytest.approx(7.5, abs=1e-12)
199+
assert r2.estimate == pytest.approx(7.5, abs=1e-12)
200+
201+
175202
class TestExactMatching:
176203
"""distance='exact'."""
177204

tests/test_tierD_lalonde_psm_guard.py

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,17 @@
22
33
Part of the P1 campaign (see ``.tierd_campaign/CAMPAIGN.md``). The
44
``sp.replicate('lalonde_1986')`` registry ships *golden numbers* for the classic
5-
track, but no test guarded the live 1:1 NN propensity-score-matching ATT — so a
6-
tie-handling change in ``sp.match`` silently drifted it from the originally
7-
pinned $2,012.5 to the current deterministic $1,963.4 (a 2.5% move) without any
8-
red flag. This test is that missing guard: it pins all three classic LaLonde
9-
numbers to their current deterministic values on the bundled real data, so any
10-
future drift fails loudly.
5+
track, but no test guarded the live 1:1 NN propensity-score-matching ATT. This
6+
test is that missing guard: it pins all three classic LaLonde numbers to their
7+
current deterministic values on the bundled real data, including the stable
8+
index-anchored nearest-neighbor tie-break in ``sp.match``.
119
1210
The naive and covariate-adjusted OLS values reproduce R ``MatchIt`` to the
13-
dollar; the PSM value is the current deterministic ``sp.match(method='nearest')``
14-
output (sensitive to tie-breaking on the binary covariates, hence the value the
15-
registry pin was refreshed to).
11+
dollar; the PSM value is the deterministic ``sp.match(method='nearest')`` output
12+
after exact-distance ties on the binary covariates are resolved by source index.
1613
17-
Purely additive — no estimator numerics changed (campaign red line).
14+
This guard accompanies the nearest-neighbor tie-break fix; only exact-tie
15+
matching designs can move.
1816
"""
1917

2018
import pytest
@@ -52,16 +50,7 @@ def test_psm_att_is_deterministic_and_pinned(lalonde):
5250
for _ in range(3)
5351
]
5452
assert len(set(round(v, 6) for v in vals)) == 1 # deterministic within a run
55-
# The exact PSM ATT depends on how ties on the binary covariates are broken,
56-
# which is linear-algebra-backend dependent and deterministic only *within*
57-
# an environment: tight BLAS builds land at $1963.4, the GitHub
58-
# ubuntu-latest OpenBLAS build at $1813.4. We keep the strict within-run
59-
# determinism check (the env-independent anti-regression guard) and bound a
60-
# band that still catches any real algorithm change (matching breaking ->
61-
# ~0, negative, or wildly off) while tolerating the documented cross-backend
62-
# tie-break range. Making sp.match tie-breaks backend-deterministic is
63-
# tracked as a separate Tier-D follow-up (see .tierd_campaign/CAMPAIGN.md).
64-
assert 1750.0 <= vals[0] <= 2050.0, f"PSM ATT {vals[0]:.1f} outside tie-break band"
53+
assert vals[0] == pytest.approx(1963.43, abs=0.1)
6554

6655

6756
def test_psm_recovers_experimental_benchmark(lalonde):

0 commit comments

Comments
 (0)