1111# below) and applied per ΔDay bin like plot_auc_vs_delta_days.py: for every
1212# session pair, every model's positive set is *re-derived* as the top N
1313# candidate pairs by that model's own "UM Probabilities" score, where N is
14- # REFERENCE_MODEL's own match count for that same pair (an unordered-unit-
15- # pair count, not identity -- see _build_reference_n_lookup()). This mirrors
16- # fast_testing.py's get_matches_1model(fixed_n=...) closely: the ranking
17- # operates over the *full* across-session candidate pool for that pair, not
18- # just the pipeline's own already-decided matches, so a model can both lose
19- # matches it had decided on (if they rank low) and gain ones it hadn't (if
20- # they rank in the top N despite being below that model's own decision
21- # threshold) -- unlike an earlier, more conservative version of this script
22- # that only ever discarded a model's own decided matches and never added new
23- # ones. Per-pair scores are averaged across both directions before ranking
24- # (mirrors the notebooks' avg_across_directions()), and ties are broken by a
25- # random draw (mirrors get_ordered_matches()'s random_tiebreaker column) --
26- # not a full directional-filter/conflict-resolution pass, which the notebooks
27- # also run before ranking (a one-to-one matching constraint) and this script
28- # does not reproduce; see _rank_based_fixed_n_matches()'s docstring.
14+ # REFERENCE_MODEL's own conflict-resolved match count for that same pair (an
15+ # unordered-unit-pair count, not identity -- see _build_reference_n_lookup()).
16+ # This mirrors fast_testing.py's get_matches_1model(fixed_n=...) closely: the
17+ # ranking operates over the *full* across-session candidate pool for that
18+ # pair, not just the pipeline's own already-decided matches, so a model can
19+ # both lose matches it had decided on (if they rank low) and gain ones it
20+ # hadn't (if they rank in the top N despite being below that model's own
21+ # decision threshold) -- unlike an earlier, more conservative version of this
22+ # script that only ever discarded a model's own decided matches and never
23+ # added new ones. Per-pair scores are averaged across both directions before
24+ # ranking (mirrors the notebooks' avg_across_directions()), one-to-many
25+ # conflicts are resolved on that averaged score before ranking (mirrors
26+ # remove_conflicts2(), and runs in the same order fast_testing uses: resolve
27+ # conflicts, then take the top N), and ties are broken by a random draw
28+ # (mirrors get_ordered_matches()'s random_tiebreaker column) -- see
29+ # _rank_based_fixed_n_matches()'s docstring for what's still not reproduced
30+ # (directional_filter's require-both-directions-above-threshold check).
2931#
3032# "UM Probabilities" is only a real, continuously-rankable score for
3133# DeepUnitMatch/UMPy-family models -- EMD/DANT/DANT_no_functional save
100102def _build_reference_n_lookup (session_date_lookup , base_output = BASE_OUTPUT , reference_model = REFERENCE_MODEL ):
101103 """
102104 {dataset: {(RecSes1, RecSes2) with RecSes1 < RecSes2: N}} -- N = how many
103- unordered unit pairs reference_model's own pipeline decision ("Matches")
104- called a match for that session pair, in that dataset.
105+ unordered unit pairs reference_model committed to as a match for that
106+ session pair, in that dataset, AFTER resolving one-to-many conflicts
107+ (dvd._resolve_match_conflicts(), ranked by "UM Probabilities" same as
108+ everywhere else) -- not the raw pipeline "Matches" column's count, which
109+ has no uniqueness constraint at write time (see
110+ dvd._resolve_match_conflicts()'s docstring) and so can overcount relative
111+ to what reference_model actually settled on for a unit. This mirrors
112+ fast_testing.py's own reference N: it comes from a prior
113+ get_matches_1model(fixed_n=False) run, whose remove_conflicts2() always
114+ resolves conflicts before N = len(match_indices) is recorded.
105115
106116 Datasets/pairs missing here get treated as N=0 by
107117 _rank_based_fixed_n_matches() (every other model's matches for that pair
@@ -142,11 +152,14 @@ def _build_reference_n_lookup(session_date_lookup, base_output=BASE_OUTPUT, refe
142152 if not os .path .isfile (match_table_path ):
143153 continue
144154
155+ header = pd .read_csv (match_table_path , nrows = 0 ).columns
145156 usecols = ["ID1" , "ID2" , "RecSes 1" , "RecSes 2" , "Matches" ]
146157 dtype_map = {"ID1" : "int32" , "ID2" : "int32" , "RecSes 1" : "int32" , "RecSes 2" : "int32" , "Matches" : "int8" }
158+ if dvd .MATCH_CONFLICT_RANK_COL in header :
159+ usecols .append (dvd .MATCH_CONFLICT_RANK_COL )
160+ dtype_map [dvd .MATCH_CONFLICT_RANK_COL ] = "float32"
147161 if variant is not None :
148162 _ , (uid_col_a , uid_col_b ) = variant
149- header = pd .read_csv (match_table_path , nrows = 0 ).columns
150163 if uid_col_a not in header or uid_col_b not in header :
151164 print (f" { match_table_path } missing '{ uid_col_a } '/'{ uid_col_b } ', skipping reference lookup for { dataset } ." )
152165 continue
@@ -158,6 +171,8 @@ def _build_reference_n_lookup(session_date_lookup, base_output=BASE_OUTPUT, refe
158171 _ , (uid_col_a , uid_col_b ) = variant
159172 df ["Matches" ] = (df [uid_col_a ] == df [uid_col_b ]).astype (np .int8 )
160173
174+ df = df .assign (Matches = dvd ._resolve_match_conflicts (df ))
175+
161176 pos = df [(df ["RecSes 1" ] < df ["RecSes 2" ]) & (df ["Matches" ] == 1 )]
162177 dup_flagged = dvd ._build_within_session_duplicate_lookup (base_output , {dataset }).get (dataset , set ())
163178 pos , _ = dvd ._drop_within_session_duplicate_pairs (pos , dup_flagged )
@@ -190,13 +205,20 @@ def _rank_based_fixed_n_matches(df, ref_counts_for_dataset, rng, rank_col="UM Pr
190205 (mirrors get_ordered_matches()'s random_tiebreaker column).
191206
192207 Not reproduced here: the notebooks additionally run directional_filter
193- (require both directions individually above threshold) and
194- remove_conflicts2 (a one-to-one matching constraint: at most one partner
195- per unit) before ranking. Averaging across directions already softly
196- penalises one-way-only candidates rather than excluding them outright,
197- and conflicts aren't resolved -- a unit can appear in more than one of
198- the selected top-N pairs. Both are simplifications relative to the
199- notebooks' own fixed_n comparison.
208+ (require both directions individually above threshold) before ranking.
209+ Averaging across directions already softly penalises one-way-only
210+ candidates rather than excluding them outright -- a simplification
211+ relative to the notebooks' own fixed_n comparison. One-to-many conflicts
212+ (remove_conflicts2's one-partner-per-unit constraint) ARE resolved here,
213+ on the direction-averaged score, before top-N ranking -- same order
214+ fast_testing.get_matches_1model(fixed_n=...) uses (remove_conflicts2()
215+ runs, THEN .head(fixed_n) picks winners from what's left), rather than
216+ collect_binned_pairs()'s own post-hoc resolve_match_conflicts pass, which
217+ would otherwise run after N is already fixed (on raw per-direction
218+ "UM Probabilities" values, a different score source) and could shrink the
219+ selected set below N. main() passes resolve_match_conflicts=False to
220+ collect_binned_pairs() for this reason -- conflict resolution happens
221+ exactly once, here.
200222 """
201223 r1 = df ["RecSes 1" ].to_numpy ()
202224 r2 = df ["RecSes 2" ].to_numpy ()
@@ -220,11 +242,32 @@ def _rank_based_fixed_n_matches(df, ref_counts_for_dataset, rng, rank_col="UM Pr
220242 canon ["canon_id" ] = canon .groupby (["sess_lo" , "unit_lo" , "sess_hi" , "unit_hi" ], sort = False ).ngroup ()
221243
222244 pairs = canon .groupby ("canon_id" , sort = False ).agg (
223- sess_lo = ("sess_lo" , "first" ), sess_hi = ("sess_hi" , "first" ), avg_score = ("score" , "mean" ),
245+ sess_lo = ("sess_lo" , "first" ), sess_hi = ("sess_hi" , "first" ),
246+ unit_lo = ("unit_lo" , "first" ), unit_hi = ("unit_hi" , "first" ),
247+ avg_score = ("score" , "mean" ),
224248 )
225249 # NaN scores (shouldn't normally occur -- UM Probabilities is computed for
226- # every candidate pair -- but handled defensively) never win a ranking.
227- avg_score = np .nan_to_num (pairs ["avg_score" ].to_numpy (), nan = - np .inf )
250+ # every candidate pair -- but handled defensively) never win a ranking or
251+ # a conflict.
252+ pairs ["avg_score" ] = pairs ["avg_score" ].fillna (- np .inf )
253+
254+ # Resolve one-to-many conflicts per session pair before ranking (see
255+ # docstring above): a candidate survives only if its avg_score is
256+ # simultaneously maximal among every other candidate sharing its "lo"
257+ # endpoint unit and every other candidate sharing its "hi" endpoint unit,
258+ # within the same session pair -- same both-endpoints-maximal rule as
259+ # dvd._resolve_match_conflicts()/fast_testing.remove_conflicts2(), just
260+ # applied to canonical (direction-averaged) candidates instead of raw
261+ # rows. Losing candidates are dropped from ranking entirely (rather than
262+ # zeroed-but-still-eligible, as remove_conflicts2 does) so a conflict can
263+ # never consume one of the N slots -- a minor, deliberate simplification
264+ # that only differs from fast_testing in the rare case where a session
265+ # pair has fewer than N conflict-free candidates.
266+ max_for_lo = pairs .groupby (["sess_lo" , "sess_hi" , "unit_lo" ])["avg_score" ].transform ("max" )
267+ max_for_hi = pairs .groupby (["sess_lo" , "sess_hi" , "unit_hi" ])["avg_score" ].transform ("max" )
268+ pairs = pairs [(pairs ["avg_score" ] == max_for_lo ) & (pairs ["avg_score" ] == max_for_hi )]
269+
270+ avg_score = pairs ["avg_score" ].to_numpy ()
228271 tiebreak = rng .random (len (pairs ))
229272 rows_by_canon = canon .groupby ("canon_id" , sort = False )["row" ].apply (np .asarray )
230273
@@ -278,6 +321,14 @@ def matches_transform(dataset, df):
278321 models_to_include = RANK_ELIGIBLE_MODELS ,
279322 matches_transform = matches_transform ,
280323 extra_usecols = {"ID2" : "int32" , "UM Probabilities" : "float32" },
324+ # _rank_based_fixed_n_matches() already resolves one-to-many conflicts
325+ # itself, before picking the top N (see its docstring) -- running
326+ # collect_binned_pairs()'s own post-hoc pass here too would re-decide
327+ # conflicts from raw per-direction "UM Probabilities" values (a
328+ # different score source than the direction-averaged one used for
329+ # ranking) after N is already fixed, and could shrink a model's
330+ # selected set below N.
331+ resolve_match_conflicts = False ,
281332 )
282333
283334 auc_df = dvd .summarise_auc (score_bins )
0 commit comments