Skip to content

Commit 59c5e42

Browse files
committed
Extra analyses
- add one mouse xval - add no drift option
1 parent 4f55913 commit 59c5e42

3 files changed

Lines changed: 224 additions & 21 deletions

File tree

‎UnitMatchPy/DeepUnitMatch/ExtraModels/xval_end_to_end.py‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
and left inference to a separate discovery-based script,
55
PaperAnalyses/run_deepunitmatch_batch_onMerged_ExcludeMiceModels.py), with
66
three differences:
7-
- 3-mouse replicates instead of 1-mouse, plus a new 18-mouse (all mice,
8-
single replicate) control: 3x3 + 3x6 + 3x12 + 1x18 = 10 models total.
7+
- 3 replicates per group size (including 1-mouse, matching
8+
Exclude_mice_experiment.py's original single-mouse subsets), plus a new
9+
18-mouse (all mice, single replicate) control:
10+
3x1 + 3x3 + 3x6 + 3x12 + 1x18 = 13 models total.
911
- Training and inference are bundled into a single resumable pipeline per
1012
model ("xval") instead of two separate scripts.
1113
- The whole thing is safe to launch as several concurrent python processes
1214
(same machine or several machines) pointed at the same shared drive: each
13-
of the 10 xvals is claimed via batch_lock.try_lock() before a process
15+
of the 13 xvals is claimed via batch_lock.try_lock() before a process
1416
starts it, so nobody duplicates another instance's run.
1517
1618
Data
@@ -56,7 +58,7 @@
5658
5759
Example
5860
-------
59-
python xval_end_to_end.py # process all 10 xvals, in order
61+
python xval_end_to_end.py # process all 13 xvals, in order
6062
python xval_end_to_end.py --only m3_1 m6_2 # just these two
6163
python xval_end_to_end.py --status # print progress, don't run anything
6264
"""
@@ -116,9 +118,14 @@
116118
"JF084",
117119
]
118120

119-
GROUP_SPEC = [(3, 3), (6, 3), (12, 3), (18, 1)] # (mice per model, replicates)
121+
GROUP_SPEC = [(1, 3), (3, 3), (6, 3), (12, 3), (18, 1)] # (mice per model, replicates)
120122
SPLIT_SEED = 0 # fixed so every process/machine derives the same manifest
121123

124+
# Hand-picked mice for the 1-mouse xvals (every other group size is drawn at
125+
# random -- see generate_manifest) -- order gives m1_1/m1_2/m1_3 respectively.
126+
SINGLE_MOUSE_XVAL_MICE = ["AL032", "AV009", "FT039"]
127+
assert all(m in CANONICAL_MICE for m in SINGLE_MOUSE_XVAL_MICE)
128+
122129
N_OUTPUT = 256
123130

124131
# ── shared paths (network drive, so every machine agrees) ───────────────────
@@ -139,21 +146,26 @@
139146

140147

141148
def generate_manifest() -> "dict[str, List[str]]":
142-
"""Deterministically assign mice to each of the 10 xval replicates.
149+
"""Deterministically assign mice to each of the 13 xval replicates.
143150
144-
For group sizes < 18, each replicate independently draws `group_size`
151+
For group sizes 3/6/12, each replicate independently draws `group_size`
145152
mice at random (without replacement within a replicate; replicates may
146153
overlap each other, same as Exclude_mice_experiment.py's hand-picked
147154
splits did), seeded so every process/machine derives the identical
148155
manifest without needing to share it. The 18-mouse group has only one
149-
possible draw: every mouse.
156+
possible draw: every mouse. The 1-mouse group is the one exception --
157+
it uses the hand-picked SINGLE_MOUSE_XVAL_MICE instead of a random draw,
158+
and doesn't consume rng state, so it doesn't perturb the 3/6/12-mouse
159+
draws.
150160
"""
151161
rng = random.Random(SPLIT_SEED)
152162
manifest: "dict[str, List[str]]" = {}
153163
for group_size, n_replicates in GROUP_SPEC:
154164
for replicate in range(1, n_replicates + 1):
155165
name = f"m{group_size}_{replicate}"
156-
if group_size >= len(CANONICAL_MICE):
166+
if group_size == 1:
167+
mice = [SINGLE_MOUSE_XVAL_MICE[replicate - 1]]
168+
elif group_size >= len(CANONICAL_MICE):
157169
mice = list(CANONICAL_MICE)
158170
else:
159171
mice = rng.sample(CANONICAL_MICE, group_size)
@@ -553,8 +565,8 @@ def build_arg_parser() -> argparse.ArgumentParser:
553565
)
554566
parser.add_argument(
555567
"--only", nargs="+", default=None,
556-
help="Only run these xval names (default: all 10, in a fixed order). "
557-
"Names are m3_1..m3_3, m6_1..m6_3, m12_1..m12_3, m18_1.",
568+
help="Only run these xval names (default: all 13, in a fixed order). "
569+
"Names are m1_1..m1_3, m3_1..m3_3, m6_1..m6_3, m12_1..m12_3, m18_1.",
558570
)
559571
parser.add_argument(
560572
"--status", action="store_true",

‎UnitMatchPy/PaperAnalyses/run_deepunitmatch_batch_onMerged.py‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ def run_deep_unit_match_core(
414414
spatial_only=False,
415415
total_score_for_threshold=False,
416416
use_umpy_totalscore=False,
417+
apply_drift_correction=True,
417418
):
418419
"""
419420
Run the full DeepUnitMatch pipeline for one pre-loaded session, given an
@@ -465,6 +466,16 @@ def run_deep_unit_match_core(
465466
run_deepunitmatch_batch_onMerged_scoreswap.py to test whether
466467
DUM/UMPy performance differences come from the underlying score or
467468
from the two pipelines' other architectural differences.
469+
apply_drift_correction : bool, optional
470+
When False, mf.drift_n_sessions is never called: avg_centroid/
471+
avg_waveform_per_tp stay exactly as extract_parameters produced them,
472+
so centroid_dist and every downstream step use uncorrected waveforms.
473+
The pre-pass itself still runs unconditionally -- it also builds
474+
pair_matches_cache, which the final per-session-pair Bayes loop reuses
475+
as its supervised match/non-match labels regardless of drift
476+
correction, so skipping it isn't an option. Default True matches the
477+
non-ablated pipeline's original behaviour exactly. See
478+
run_deepunitmatch_batch_onMerged_nodrift.py.
468479
"""
469480
merged_dir = sess["merged_dir"]
470481
print(f"\n--- {label}: {merged_dir}")
@@ -597,14 +608,15 @@ def run_deep_unit_match_core(
597608
# sim_matrix serves as total_score so get_good_matches can de-duplicate pairs.
598609
avg_centroid = extracted_wave_properties["avg_centroid"].copy()
599610
avg_waveform_per_tp = extracted_wave_properties["avg_waveform_per_tp"].copy()
600-
_, avg_centroid, avg_waveform_per_tp = mf.drift_n_sessions(
601-
labels_full.astype(bool),
602-
session_switch,
603-
avg_centroid,
604-
avg_waveform_per_tp,
605-
sim_matrix,
606-
param,
607-
)
611+
if apply_drift_correction:
612+
_, avg_centroid, avg_waveform_per_tp = mf.drift_n_sessions(
613+
labels_full.astype(bool),
614+
session_switch,
615+
avg_centroid,
616+
avg_waveform_per_tp,
617+
sim_matrix,
618+
param,
619+
)
608620

609621
# ── Bayes loop: use drift-corrected arrays ────────────────────────────────
610622
probs = np.zeros(sim_matrix.shape)
@@ -838,7 +850,7 @@ def run_umpy(sess):
838850
run_umpy_core(sess, save_dir, label="UMPy")
839851

840852

841-
def run_umpy_core(sess, save_dir, label="UMPy", to_use=None, model=None):
853+
def run_umpy_core(sess, save_dir, label="UMPy", to_use=None, model=None, niter=2):
842854
"""
843855
Run the full UMPy pipeline for one pre-loaded session, given an explicit
844856
output directory.
@@ -867,6 +879,13 @@ def run_umpy_core(sess, save_dir, label="UMPy", to_use=None, model=None):
867879
run_deepunitmatch_batch_onMerged_scoreswap.py to test UMPy's own
868880
pipeline machinery driven by the DNN's score instead of its own
869881
metrics.
882+
niter : int, optional
883+
Passed straight through to ov.extract_metric_scores -- niter=1 means
884+
no drift correction is applied at all (candidate_pairs/total_score/
885+
scores_to_include are computed once from the raw, uncorrected
886+
waveforms); niter=2 (default) does one pass of drift correction,
887+
matching the non-ablated pipeline's original behaviour exactly. See
888+
run_deepunitmatch_batch_onMerged_nodrift.py.
870889
"""
871890
merged_dir = sess["merged_dir"]
872891
print(f"\n--- {label}: {merged_dir}")
@@ -943,7 +962,7 @@ def run_umpy_core(sess, save_dir, label="UMPy", to_use=None, model=None):
943962
session_switch,
944963
within_session,
945964
param,
946-
niter=2,
965+
niter=niter,
947966
to_use=to_use,
948967
extra_scores=extra_scores,
949968
)
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
# Batch wrapper: runs DeepUnitMatch and UMPy on the *merged* dataset (see
2+
# run_deepunitmatch_batch_onMerged.py for how that tree is built and how
3+
# sessions/good units are derived) with drift correction disabled entirely,
4+
# to see how much each pipeline's normal performance actually depends on it.
5+
# Everything else about each pipeline's default scoring stays exactly as it
6+
# normally is (DUM still uses the DNN similarity score; UMPy still uses its
7+
# own six metrics) -- this ablation isolates drift correction specifically,
8+
# independent of the score-source questions the spatialonly/scoreswap batch
9+
# scripts explore.
10+
#
11+
# DUM_nodrift/: run_deep_unit_match_core(..., apply_drift_correction=False).
12+
# mf.drift_n_sessions is never called, so avg_centroid/avg_waveform_per_tp
13+
# stay exactly as extract_parameters produced them -- centroid_dist and every
14+
# downstream step use uncorrected waveforms. The drift-correction pre-pass
15+
# itself still runs (it also builds the supervised match/non-match labels the
16+
# final Bayes step needs, regardless of drift correction), only the actual
17+
# correction step is skipped.
18+
#
19+
# UMPy_nodrift/: run_umpy_core(..., niter=1). ov.extract_metric_scores' own
20+
# niter loop is what applies drift correction (niter=2 does one pass); niter=1
21+
# runs the loop once and skips it entirely, computing total_score/
22+
# candidate_pairs/scores_to_include from the raw, uncorrected waveforms.
23+
#
24+
# Each such folder sits alongside the DeepUnitMatch/ and UMPy/ subfolders that
25+
# run_deepunitmatch_batch_onMerged.py writes for the same dataset.
26+
27+
import os
28+
import sys
29+
import datetime
30+
import traceback
31+
import argparse
32+
33+
import matplotlib
34+
35+
matplotlib.use("Agg") # non-interactive backend for batch runs
36+
37+
# ── project paths ───────────────────────────────────────────────────────────
38+
_HERE = os.path.dirname(os.path.abspath(__file__))
39+
sys.path.insert(0, _HERE)
40+
sys.path.insert(0, os.path.dirname(_HERE))
41+
sys.path.insert(0, os.path.join(_HERE, "DeepUnitMatch"))
42+
43+
import batch_lock
44+
import run_deepunitmatch_batch_onMerged as base_batch
45+
from DeepUnitMatch.testing import test
46+
47+
# See batch_lock.sentinel_is_fresh() / run_deepunitmatch_batch_onMerged.py's
48+
# REDO_FROM_DATE for what this does. A dataset/condition combo is skipped once
49+
# its MatchingOverview.png exists and is at least this new.
50+
REDO_FROM_DATE = datetime.datetime(2026, 8, 22, 0, 0, 0)
51+
52+
CONDITIONS = ("DUM_nodrift", "UMPy_nodrift")
53+
54+
55+
# ── path helpers ─────────────────────────────────────────────────────────────
56+
57+
58+
def get_nodrift_save_dir(merged_dir, condition):
59+
"""Output dir for a given merged-data group + condition (one of CONDITIONS)."""
60+
subfolder = os.path.relpath(os.path.dirname(merged_dir), base_batch.BASE_INPUT)
61+
return os.path.join(base_batch.BASE_OUTPUT, subfolder, condition)
62+
63+
64+
def nodrift_results_exist(merged_dir, condition):
65+
"""Return True when the sentinel output file is present and fresh for this dataset/condition combo."""
66+
sentinel = os.path.join(
67+
get_nodrift_save_dir(merged_dir, condition), "MatchingOverview.png"
68+
)
69+
return batch_lock.sentinel_is_fresh(sentinel, REDO_FROM_DATE)
70+
71+
72+
def get_group_lock_path(merged_dir):
73+
"""
74+
Lock file marking 'a run is currently processing the no-drift-correction
75+
ablation for this group', so multiple machines pointed at the same
76+
BASE_INPUT/BASE_OUTPUT can split work across groups without
77+
double-processing one. See batch_lock.py. Named distinctly from the other
78+
batch scripts' locks so they can all run on the same group concurrently.
79+
"""
80+
subfolder = os.path.relpath(os.path.dirname(merged_dir), base_batch.BASE_INPUT)
81+
return os.path.join(base_batch.BASE_OUTPUT, subfolder, ".processing_nodrift.lock")
82+
83+
84+
# ── entry point ───────────────────────────────────────────────────────────────
85+
86+
87+
def parse_args():
88+
parser = argparse.ArgumentParser(
89+
description="Run DeepUnitMatch and UMPy on the merged dataset with drift correction disabled."
90+
)
91+
parser.add_argument(
92+
"--write-matlab-compat",
93+
action="store_true",
94+
help="Also write a MATLAB-compatible UnitMatch.mat from the Python outputs.",
95+
)
96+
return parser.parse_args()
97+
98+
99+
def main():
100+
args = parse_args()
101+
base_batch.WRITE_MATLAB_COMPAT = args.write_matlab_compat
102+
103+
print(f"Scanning for merged-data groups under:\n {base_batch.BASE_INPUT}\n")
104+
groups = base_batch.find_merged_groups()
105+
if not groups:
106+
print("No merged-data groups found.")
107+
return
108+
print(f"Found {len(groups)} group(s).\n")
109+
110+
model = None # DUM's default trained model, loaded once and reused across all groups
111+
112+
for i, merged_dir in enumerate(groups):
113+
print(f"\n[{i + 1}/{len(groups)}] {merged_dir}")
114+
115+
pending = [
116+
condition
117+
for condition in CONDITIONS
118+
if not nodrift_results_exist(merged_dir, condition)
119+
]
120+
if not pending:
121+
print(" Skipping (results exist and are fresh).")
122+
continue
123+
124+
lock_path = get_group_lock_path(merged_dir)
125+
with batch_lock.try_lock(lock_path) as acquired:
126+
if not acquired:
127+
print(f" Skipping (already being processed by another run): {lock_path}")
128+
continue
129+
130+
# re-check now that we hold the lock: another machine may have
131+
# finished this group while we were scanning/waiting for the lock
132+
pending = [
133+
condition
134+
for condition in CONDITIONS
135+
if not nodrift_results_exist(merged_dir, condition)
136+
]
137+
if not pending:
138+
print(" Skipping (completed by another run).")
139+
continue
140+
141+
sess = base_batch._prepare_session(merged_dir)
142+
if sess is None:
143+
continue
144+
145+
if model is None and "DUM_nodrift" in pending:
146+
print("Loading DeepUnitMatch model …")
147+
model = test.load_trained_model(device=base_batch.DEVICE)
148+
149+
for condition in pending:
150+
save_dir = get_nodrift_save_dir(merged_dir, condition)
151+
try:
152+
if condition == "DUM_nodrift":
153+
base_batch.run_deep_unit_match_core(
154+
sess,
155+
save_dir,
156+
model=model,
157+
label=condition,
158+
apply_drift_correction=False,
159+
)
160+
else:
161+
base_batch.run_umpy_core(
162+
sess, save_dir, label=condition, niter=1
163+
)
164+
except Exception as e:
165+
print(f" {condition} FAILED: {e}")
166+
traceback.print_exc()
167+
168+
print("\nAll done.")
169+
170+
171+
if __name__ == "__main__":
172+
main()

0 commit comments

Comments
 (0)