@@ -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
0 commit comments