Reproducing Chang et al. (2023) from dandiset 000232¶
Paper. Chang Y-T, Finkel EA, Xu D, O'Connor DH. Rule-based modulation of a sensorimotor transformation across cortical areas. bioRxiv, doi:10.1101/2023.08.21.554194. This attempt used the full text of preprint version 2 (2024-03-09, CC-BY), as served by eLife for its reviewed preprint 92620v2, whose source is bioRxiv version 2; bioRxiv refused the download. Table 1 and the table of coding-dimension trial types are images in that copy and were not read.
Dandiset. DANDI:000232, version 0.240510.2038. The preprint cites no DANDI version (its data statement says "available upon request"); the paper's code repository cites this version's DOI, and it is the only published version. The draft holds the same 179 files (same assets and blobs) and differs only in contributor metadata.
What the dandiset holds. 49 silicon-probe sessions of the cross-modal selection task (S1, S2, MM, ALM and AMM, one area per session; trials and spike times of the units kept after the paper's quality filter) and 130 optogenetic behaviour sessions (cross-modal task and tactile detection task).
Code read. The paper's analysis code, https://github.com/YitingChang/cross_modal_task at commit 227631ee133709df979b9914c9031cfa992c3736 (MATLAB; preprocessing, data arrays, one folder per figure). Where the Methods leave a choice open, the analysis follows the code; where they disagree, the Methods' choice is the main result and the code's a variant. Nothing from outside the dandiset is used as data.
| Claim | Tested |
|---|---|
| fig1e-recording-counts | tested |
| fig6-session-counts | tested |
| fig1d-performance | tested |
| fig1d-outcome-fractions | tested |
| fig1h-thit-tcr-selective | tested |
| fig2c-rule-prestim-units | tested |
| fig2d-stimulus-prestim-units | tested |
| fig2e-stimulus-poststim-units | tested |
| fig2f-prestim-predicts-touch-response | tested |
| fig3b-rule-decoding | tested |
| fig3c-rule-decoding-motor-stronger | tested |
| fig3de-stimulus-decoding-controls | tested |
| figs3-svm-rf-decoding | tested |
| fig4c-prestim-distance-predicts-divergence | tested |
| fig4e-subspace-overlap | tested |
| fig4f-prestim-overlap-predicts-poststim | tested |
| figs4ef-coding-dimensions | tested |
| figs4h-choice-right-left | tested |
| fig5b-first-hit-switches | tested |
| fig5de-motor-states-track-switch | tested |
| figs5-sensory-states-no-tracking | tested |
| fig6c-pre-inhibition-tactile | tested |
| figs6b-pre-inhibition-hit-rate | tested |
| fig6d-post-inhibition-tactile | tested |
| figs7gh-visual-sensitivity | tested |
| fig6-ppc-inhibition | tested |
| fig6fg-detection-task | tested |
| fig6ij-laser-only-licks | tested |
| fig1e-probe-locations | blocked (data missing: no histology or electrode coordinates) |
import time
import warnings
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import stats
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
import xmodal as xm
pd.set_option("display.width", 200)
pd.set_option("display.max_columns", 40)
pd.set_option("display.max_rows", 200)
warnings.filterwarnings("ignore", category=RuntimeWarning)
BLUE, GRAY, ORANGE = "#2a78d6", "#8a8984", "#eb6834"
started = time.time()
assets = xm.asset_urls()
files = xm.load_all(assets, spikes=True)
ephys = [f for f in files if f["kind"] == "ephys"]
opto = [f for f in files if f["kind"] == "opto"]
print(f"dandiset {xm.DANDISET_ID} version {xm.DANDISET_VERSION}: {len(assets)} NWB files, "
f"{len(ephys)} with units, {len(opto)} without; loaded in {time.time() - started:.0f} s")
dandiset 000232 version 0.240510.2038: 179 NWB files, 49 with units, 130 without; loaded in 249 s
Data check¶
Every check of the description against the values is stated under its output.
info = pd.DataFrame([{"path": f["path"], "subject": f["subject"], "genotype": f["genotype"], "sex": f["sex"],
"kind": f["kind"], "description": f["description"], "trials": len(f["trials"]),
"units": f.get("n_units", 0)} for f in files])
print("Files by experiment description:")
print(info.groupby("description").agg(files=("path", "size"), mice=("subject", "nunique")).to_string())
print("\nSubjects:")
print(info.groupby("subject").agg(genotype=("genotype", "first"), sex=("sex", "first"),
files=("path", "size"), ephys=("kind", lambda k: (k == "ephys").sum())).to_string())
print("\nArchive summary: 18 subjects, 179 files; files hold", info.subject.nunique(), "subjects,", len(info), "files")
Files by experiment description:
files mice
description
electrophysiological recording of ALM during the cross-modal sensory selection task 13 8
electrophysiological recording of AMM during the cross-modal sensory selection task 9 7
electrophysiological recording of MM during the cross-modal sensory selection task 9 7
electrophysiological recording of S1 during the cross-modal sensory selection task 10 6
electrophysiological recording of S2 during the cross-modal sensory selection task 8 5
optogenetic inhibition of ALM during the cross-modal sensory selection task 10 4
optogenetic inhibition of ALM during the tactile detection task 4 3
optogenetic inhibition of AMM during the cross-modal sensory selection task 21 7
optogenetic inhibition of AMM during the tactile detection task 4 3
optogenetic inhibition of MM during the cross-modal sensory selection task 11 4
optogenetic inhibition of MM during the tactile detection task 5 3
optogenetic inhibition of PPC during the cross-modal sensory selection task 21 7
optogenetic inhibition of PPC during the tactile detection task 4 3
optogenetic inhibition of S1/S2 during the cross-modal sensory selection task 10 4
optogenetic inhibition of S1/S2 during the tactile detection task 3 3
sham experiment for optogentic inhibition during the cross-modal sensory selection task 28 7
sham experiment for optogentic inhibition during the tactile detection task 9 5
Subjects:
genotype sex files ephys
subject
EF0147 Emx-Cre-Ai32 M 2 2
EF0148 Emx-Cre-Ai32 M 5 5
EF0150 Emx-Cre-Ai32 M 7 7
EF0151 Emx-Cre-Ai32 M 2 2
JL005 VGAT-ChR2 M 3 3
YT053 PV-Cre-Ai32 M 16 0
YT071 VGAT-ChR2 M 3 3
YT080 PV-Cre-Ai32 M 14 3
YT081 PV-Cre-Ai32 M 15 4
YT083 PV-Cre-Ai32 F 18 1
YT084 PV-Cre-Ai32 F 26 8
YT085 PV-Cre-Ai32 M 22 9
YT086 PV-Cre-Ai32 M 17 2
YT091 PV-Cre-Ai32 M 10 0
YT092 PV-Cre-Ai32 F 6 0
YT093 PV-Cre-Ai32 M 4 0
YT094 PV-Cre-Ai32 M 4 0
YT095 PV-Cre-Ai32 F 5 0
Archive summary: 18 subjects, 179 files; files hold 18 subjects, 179 files
The descriptions name one recorded area per ephys file and one inhibited area (or sham) and one task per optogenetic file. The 18 subjects and 179 files agree with the archive's summary.
rows = []
for f in ephys:
rows.append({"session": f["path"].split("/")[1][:31], "subject": f["subject"], "area": f["area"],
"group_location": f["group_location"], "electrode_locations": ",".join(f["electrode_locations"]),
"units": f["n_units"], "unit_columns": ",".join(f["unit_columns"]), "trials": len(f["trials"])})
eph_info = pd.DataFrame(rows)
print("Area in the description equals the electrode group's location in every file:",
bool((eph_info.area == eph_info.group_location).all()))
print("Electrode-level locations:", eph_info.electrode_locations.unique().tolist())
print("Units table columns:", eph_info.unit_columns.unique().tolist())
print("\nSessions, mice and units per area:")
print(eph_info.groupby("area").agg(sessions=("session", "size"), mice=("subject", "nunique"),
units=("units", "sum"), units_min=("units", "min"), units_max=("units", "max"),
trials_min=("trials", "min"), trials_max=("trials", "max")).to_string())
f0 = ephys[0]
print("\nTrials table columns (ephys):", f0["trial_columns"])
print("Files whose electrode x, y and z are all NaN:", sum(f["electrode_xyz_all_nan"] for f in ephys), "of", len(ephys))
print("First unit ids of each file start at 0:", all(f["unit_ids_head"][0] == 0 for f in ephys), "; e.g.", f0["unit_ids_head"], "...")
Area in the description equals the electrode group's location in every file: True
Electrode-level locations: ['unknown']
Units table columns: ['spike_times,obs_intervals']
Sessions, mice and units per area:
sessions mice units units_min units_max trials_min trials_max
area
ALM 13 8 256 9 35 163 368
AMM 9 7 110 2 28 160 358
MM 9 7 140 4 39 185 263
S1 10 6 177 4 28 210 315
S2 8 5 162 5 30 212 330
Trials table columns (ephys): ['start_time', 'stop_time', 'blockType', 'trialType', 'response', 'visStimType', 'somStimType', 'somOnset_times', 'somOffset_times', 'visOnset_times', 'visOffset_times', 'stimOnset_times', 'rLickOnset_times', 'rLickOffset_times', 'lLickOnset_times', 'lLickOffset_times', 'firstLick_times']
Files whose electrode x, y and z are all NaN: 49 of 49
First unit ids of each file start at 0: True ; e.g. [0, 1, 2, 3, 4] ...
# Trial coding: block, stimulus and response against the lick times.
rows = []
for f in ephys:
t = xm.code_trials(f["trials"])
first_side = []
for r, l, s0 in zip(t.r_licks, t.l_licks, t.stim_on):
rr, ll = r[(r - s0 > 0.1) & (r - s0 < 2)], l[(l - s0 > 0.1) & (l - s0 < 2)]
first_side.append(3 if len(rr) + len(ll) == 0 else 1 if len(ll) == 0 or (len(rr) and rr[0] < ll[0]) else 2)
first_side = np.array(first_side)
lick_pre = xm.licks_in(t, -1, 0.1)
gaps = t.start.to_numpy()[1:] - t.stop.to_numpy()[:-1]
rows.append({"session": f["path"].split("/")[1][:31], "trials": len(t), "cue_trials": int((t.b == 0).sum()),
"no_stimulus": int((t.s == 0).sum()), "response_values": ",".join(map(str, sorted(t.response.unique()))),
"response_eq_first_lick_side": round(float(np.mean(first_side[t.d > 0] == t.d[t.d > 0])), 3),
"trials_lick_-1_to_0.1s": int(lick_pre.sum()),
"stim_minus_start_min_s": round(float((t.stim_on - t.start).min()), 3),
"stop_minus_stim_min_s": round(float((t.stop - t.stim_on).min()), 3),
"gaps_over_10ms": int((gaps > 0.01).sum()), "gap_total_s": round(float(gaps[gaps > 0.01].sum()), 0)})
coding = pd.DataFrame(rows)
print(coding.describe().T[["min", "50%", "max"]].to_string())
allt = pd.concat([xm.code_trials(f["trials"]).assign(area=f["area"]) for f in ephys], ignore_index=True)
print("\nBlock type x trial type x response, all ephys sessions:")
print(pd.crosstab([allt.block_type, allt.trial_type], allt.response).to_string())
print("\nStimulus types:", allt.vis_stim_type.unique().tolist(), allt.som_stim_type.unique().tolist())
min 50% max
trials 160.000 236.000 368.000
cue_trials 2.000 4.000 6.000
no_stimulus 0.000 0.000 0.000
response_eq_first_lick_side 0.948 1.000 1.000
trials_lick_-1_to_0.1s 0.000 0.000 0.000
stim_minus_start_min_s 1.049 1.077 1.140
stop_minus_stim_min_s 3.477 3.852 4.116
gaps_over_10ms 8.000 29.000 64.000
gap_total_s 77.000 303.000 835.000
Block type x trial type x response, all ephys sessions:
response 0.0 1.0 2.0
block_type trial_type
Change Stim_Som_NoCue 0 94 0
Stim_Vis_NoCue 0 0 91
Visual Stim_Som_NoCue 2338 279 390
Stim_Vis_NoCue 722 28 2235
Whisker Stim_Som_NoCue 721 2093 56
Stim_Vis_NoCue 2151 476 306
Stimulus types: ['Amp0p5Dur0p150'] ['SineAmp0p875Freq20Cyc3', 'SineAmp0p8375Freq20Cyc3', 'SineAmp1p000Freq20Cyc3', 'SineAmp0p750Freq20Cyc3']
response agrees with the side of the first lick between 0.1 and 2 s after the stimulus in at least the
minimum fraction printed (the remainder are licks the behavioural system timed differently); 1 is a right
lick, 2 a left lick and 0 no lick. "Change" rows are the cue trials (water given on the 9th trial of a
block), left out of all analyses as in the paper's code. No trial in any ephys file has a lick between -1 and 0.1 s of the stimulus: the trials the
Methods remove for pre-stimulus licking (about 25%) are already absent, and the gaps between trials mark
where they were. The dandiset does not say this. The stimulus comes at least 1 s after the trial start and
the trial ends at least 3.4 s after it, so every analysis window lies inside a trial.
rows = []
for f in ephys:
t = f["trials"]
dur = float((t.stop - t.start).sum())
rates = np.array([len(s) / dur for s in f["spikes"]])
obs_is_trials = all(np.allclose(o, np.c_[t.start, t.stop], atol=2e-3) for o in f["obs_intervals"])
starts, stops = t.start.to_numpy() - 2e-3, t.stop.to_numpy() + 2e-3
in_trial = np.mean([np.mean(np.searchsorted(starts, sp, side="right") - 1 == np.searchsorted(stops, sp, side="left"))
for sp in f["spikes"]])
rows.append({"session": f["path"].split("/")[1][:31], "area": f["area"], "units": f["n_units"],
"sorted": all(np.all(np.diff(s) >= 0) for s in f["spikes"]),
"obs_intervals_are_trials": obs_is_trials, "spikes_in_trials": round(float(in_trial), 4),
"rate_min_hz": rates.min().round(2), "rate_max_hz": rates.max().round(1),
"spike_hash": hash(np.concatenate(f["spikes"][:3]).tobytes())})
units_check = pd.DataFrame(rows)
print(units_check.agg({"sorted": "all", "obs_intervals_are_trials": "all", "spikes_in_trials": "min",
"rate_min_hz": "min", "rate_max_hz": "max", "units": "sum"}).to_string())
print("Sessions whose first three spike trains are identical to another session's:",
int(units_check.spike_hash.duplicated().sum()))
sorted True obs_intervals_are_trials True spikes_in_trials 0.9995 rate_min_hz 0.16 rate_max_hz 49.1 units 845 Sessions whose first three spike trains are identical to another session's: 0
Spike times are sorted, each unit's observation intervals are the trial intervals, and spikes are stored only inside trials. No unit has a mean rate within trials above 50 Hz, consistent with the code's removal of such units, and the unit counts per area equal the paper's after its quality filter (see fig1e-recording-counts), so the Units tables hold the selected units only. They carry no quality metrics, waveforms, channel or depth, and unit ids run from 0, so the units the paper names by cluster number (for example unit 127 of YT084, 2020-02-01) cannot be found. Electrode x, y and z are NaN in all 49 files and electrode locations "unknown". No two sessions share spike trains, so no recording is deposited twice.
# Clock check: decoding the stimulus (tactile against visual, correct trials) in 100 ms windows around its onset.
rng_clock = np.random.default_rng(101)
windows = [(-0.3, -0.2), (-0.2, -0.1), (-0.1, 0.0), (0.0, 0.1), (0.1, 0.2)]
rows = []
for f in ephys:
t = xm.code_trials(f["trials"])
sel, block, stim = xm.correct_trials(t)
on = t.stim_on.to_numpy()[sel]
acc = {}
for lo, hi in windows:
x = xm.binned_rates(f["spikes"], on, lo, hi, hi - lo)[:, :, 0]
acc[f"{lo:+.1f}"] = xm.cv_accuracy(x, stim, rng_clock)
x = xm.binned_rates(f["spikes"], on, 0.0, 0.1, 0.1)[:, :, 0]
acc["shuffled_0.0"] = xm.cv_accuracy(x, rng_clock.permutation(stim), rng_clock)
rows.append({"session": f["path"].split("/")[1][:31], "area": f["area"], "units": f["n_units"], **acc})
clock = pd.DataFrame(rows)
print(clock.groupby("area")[[f"{lo:+.1f}" for lo, _ in windows] + ["shuffled_0.0"]].median().round(3).to_string())
clock["jump"] = clock["+0.0"] - clock["-0.1"]
print("\nSessions where accuracy 0 to 100 ms exceeds -100 to 0 ms by at least 0.05:",
int((clock.jump >= 0.05).sum()), "of", len(clock))
print(clock.loc[clock.jump < 0.05, ["session", "area", "units", "-0.1", "+0.0", "+0.1"]].round(3).to_string())
-0.3 -0.2 -0.1 +0.0 +0.1 shuffled_0.0
area
ALM 0.528 0.529 0.527 0.720 0.736 0.546
AMM 0.531 0.555 0.536 0.642 0.760 0.531
MM 0.497 0.515 0.517 0.653 0.745 0.510
S1 0.504 0.539 0.482 0.830 0.744 0.503
S2 0.506 0.541 0.510 0.887 0.774 0.562
Sessions where accuracy 0 to 100 ms exceeds -100 to 0 ms by at least 0.05: 43 of 49
session area units -0.1 +0.0 +0.1
1 sub-EF0147_ses-20190206T150628_ ALM 10 0.527 0.539 0.648
33 sub-YT084_ses-20200202T162019_b AMM 2 0.539 0.535 0.544
34 sub-YT084_ses-20200203T174908_b AMM 5 0.545 0.581 0.545
39 sub-YT085_ses-20200310T113706_b MM 4 0.528 0.565 0.745
46 sub-YT085_ses-20200322T133303_b AMM 2 0.552 0.541 0.639
48 sub-YT086_ses-20200320T171513_b MM 5 0.630 0.649 0.649
In 43 of 49 sessions the stimulus is decoded from 0 to 100 ms after the onset in the trial table, at a median accuracy of 0.64 to 0.89 by area, and not from the 100 ms windows before it (medians 0.48 to 0.56, shuffled 0.50 to 0.56), which shows that spike times and trial times share a clock. In the six sessions listed the rise in the first 100 ms is below 0.05. Three of them (EF0147 2019-02-06, YT085 2020-03-10 and 2020-03-22) rise in the next window, 100 to 200 ms (0.64 to 0.75); for the other three, YT084 2020-02-02 and 2020-02-03 (AMM, 2 and 5 units) and YT086 2020-03-20 (MM, 5 units, 0.63 before and 0.65 after), the shared clock is not established by this check.
# Optogenetic files: conditions, laser timing, trial labels and the code's performance check.
rows = []
for f in opto:
t = f["trials"]
rel = np.round((t.opto_on - t.stim_on).to_numpy(), 1)
dur = np.round((t.opto_off - t.opto_on).to_numpy(), 1)
licks = xm.opto_licks(t, f["task"])
rows.append({"session": f["path"].split("/")[1][:31], "mouse": f["subject"], "genotype": f["genotype"],
"task": f["task"], "condition": f["condition"], "trials": len(t),
"laser_onsets": ",".join(sorted({str(v) for v in rel[~np.isnan(rel)]})),
"laser_durations": ",".join(sorted({str(v) for v in dur[~np.isnan(dur)]})),
"laser_trials": int((~np.isnan(rel)).sum()),
"start_eq_stim": bool(np.allclose(t.start, t.stim_on)),
"overlapping_trials": int((t.start.to_numpy()[1:] < t.stop.to_numpy()[:-1]).sum()),
"passes_code_check": xm.cross_modal_passed(licks) if f["task"] == "cross-modal" else None,
"licks": licks})
optos = pd.DataFrame(rows)
print(optos.groupby(["task", "condition"]).agg(sessions=("session", "size"), mice=("mouse", "nunique"),
genotypes=("genotype", lambda g: ",".join(sorted(set(g)))),
laser_trials_median=("laser_trials", "median"),
passes_code_check=("passes_code_check", lambda p: int(p.sum()) if p.notna().all() else "not run")).to_string())
print("\nLaser onset relative to the reference time (s):", sorted(set(",".join(optos.laser_onsets).split(","))))
print("Laser durations (s):", sorted(set(",".join(optos.laser_durations).split(","))))
onsets = np.concatenate([np.round((f["trials"].opto_on - f["trials"].stim_on).to_numpy(), 1) for f in opto])
durs = np.concatenate([np.round((f["trials"].opto_off - f["trials"].opto_on).to_numpy(), 1) for f in opto])
print("Laser trials by onset:", pd.Series(onsets[~np.isnan(onsets)]).value_counts().to_dict(),
"; by duration:", pd.Series(durs[~np.isnan(durs)]).value_counts().to_dict())
print("start_time equals stimulus onset in every file:", bool(optos.start_eq_stim.all()),
"; files with overlapping trial intervals:", int((optos.overlapping_trials > 0).sum()))
ot = pd.concat([f["trials"].assign(task=f["task"]) for f in opto], ignore_index=True)
print("\nTrial types by task (rows) with somatosensory / visual onsets present:")
print(ot.groupby(["task", "trial_type"]).agg(trials=("stim_on", "size"), som=("som_on", lambda x: x.notna().sum()),
vis=("vis_on", lambda x: x.notna().sum()),
laser=("opto_on", lambda x: x.notna().sum())).to_string())
print("\nDuplicate trial tables among optogenetic files:",
int(pd.Series([hash(f["trials"].stim_on.to_numpy().tobytes()) for f in opto]).duplicated().sum()))
sessions mice genotypes laser_trials_median passes_code_check
task condition
cross-modal ALM 10 4 PV-Cre-Ai32 125.0 10
AMM 21 7 PV-Cre-Ai32 132.0 21
MM 11 4 PV-Cre-Ai32 99.0 11
PPC 21 7 PV-Cre-Ai32 127.0 21
S1/S2 10 4 PV-Cre-Ai32 124.0 10
sham 28 7 PV-Cre-Ai32 130.5 27
detection ALM 4 3 PV-Cre-Ai32 109.0 not run
AMM 4 3 PV-Cre-Ai32 111.0 not run
MM 5 3 PV-Cre-Ai32 111.0 not run
PPC 4 3 PV-Cre-Ai32 102.0 not run
S1/S2 3 3 PV-Cre-Ai32 89.0 not run
sham 9 5 PV-Cre-Ai32 93.0 not run
Laser onset relative to the reference time (s): ['-0.0', '-0.8', '-1.6', '-2.7', '0.0']
Laser durations (s): ['0.8', '2.0', '9.5']
Laser trials by onset: {0.0: 8146, -0.8: 7980, -2.7: 1, -1.6: 1} ; by duration: {2.0: 8114, 0.8: 8013, 9.5: 1}
start_time equals stimulus onset in every file: True ; files with overlapping trial intervals: 130
Trial types by task (rows) with somatosensory / visual onsets present:
trials som vis laser
task trial_type
cross-modal Stim_Som 3977 3977 0 3767
Stim_Som_NoCue 9480 9480 0 2
Stim_Vis 3306 0 3306 3147
Stim_Vis_NoCue 8163 0 8163 0
catch 6076 0 0 6076
detection Stim_Som 1398 1398 0 1398
Stim_Som_NoCue 3261 3261 0 1
Stim_Vis 816 0 0 816
Stim_Vis_NoCue 1943 0 0 0
catch 921 0 0 921
Duplicate trial tables among optogenetic files: 0
Laser onsets fall at -0.8 s (pre-stimulus inhibition, 0.8 s long) or 0 s (from the stimulus, 2 s long)
from the reference time, as the Methods state; on laser-only ("catch") trials the reference time is the
laser offset for short trains and the onset for long ones. Two laser trials start at -1.6 and -2.7 s and
one lasts 9.5 s; as in the code, any onset before 0 counts as pre-stimulus. In these files start_time equals the
stimulus onset and trial intervals overlap, so start_time and stop_time do not delimit trials. In the
detection task, trials without a whisker stimulus are labelled "Stim_Vis" though no visual stimulus is
given (no visual onset). The code's performance check passes all cross-modal sessions but one sham
session, so the files hold the sessions the paper kept (the counts match, fig6-session-counts), and the
one sham session that fails here is kept as deposited.
# Per-session arrays for the ephys analyses, and the code's session checks.
sessions = [xm.ephys_session(f) for f in ephys]
main = [s for s in sessions if s["area"] in xm.AREAS]
rows = []
for s in main:
t = s["trials"]
n = {cat: int(xm.is_category(t, key).sum()) for key, cat in xm.CATEGORIES.items()}
rows.append({"session": s["path"].split("/")[1][:31], "area": s["area"], **n})
counts = pd.DataFrame(rows)
needed = [c for c in xm.CATEGORIES.values() if c not in ("TTV", "VVT")]
print("Sessions with fewer than 2 trials of a category the code requires:", int((counts[needed] < 2).any(axis=1).sum()))
print("Trials per category over the 40 sessions (min, median, max):")
print(counts[list(xm.CATEGORIES.values())].agg(["min", "median", "max"]).T.to_string())
print("\nRates used: 100 ms bins", main[0]["r100"].shape[2], "; smoothed 10 ms bins", main[0]["r10"].shape[2],
"; max smoothed rate over all sessions", round(max(float(s["r10"].max()) for s in main), 1), "Hz")
Sessions with fewer than 2 trials of a category the code requires: 0
Trials per category over the 40 sessions (min, median, max):
min median max
TTT 26.0 40.0 76.0
TTV 0.0 1.0 7.0
TTN 3.0 13.5 41.0
TVT 3.0 7.0 27.0
TVV 2.0 5.5 12.0
TVN 24.0 44.5 61.0
VTT 2.0 6.0 13.0
VTV 2.0 7.0 16.0
VTN 19.0 44.0 76.0
VVT 0.0 0.0 3.0
VVV 20.0 40.0 86.0
VVN 2.0 14.5 35.0
Rates used: 100 ms bins 2 ; smoothed 10 ms bins 25 ; max smoothed rate over all sessions 318.5 Hz
fig1e-recording-counts¶
Single units were recorded in S1 (6 mice, 10 sessions, 177 neurons), S2 (5, 8, 162), MM (7, 9, 140) and ALM (8, 13, 256), one area per session, and in AMM (110 neurons).
paper = pd.DataFrame({"mice": [6, 5, 7, 8, None], "sessions": [10, 8, 9, 13, None], "units": [177, 162, 140, 256, 110]},
index=["S1", "S2", "MM", "ALM", "AMM"])
found = eph_info.groupby("area").agg(mice=("subject", "nunique"), sessions=("session", "size"), units=("units", "sum"))
table = paper.join(found, lsuffix="_paper", rsuffix="_dandiset")
print(table.to_string())
print("All stated counts match:", bool(all((table[c + "_paper"].isna() | (table[c + "_paper"] == table[c + "_dandiset"])).all()
for c in ("mice", "sessions", "units"))))
mice_paper sessions_paper units_paper mice_dandiset sessions_dandiset units_dandiset S1 6.0 10.0 177 6 10 177 S2 5.0 8.0 162 5 8 162 MM 7.0 9.0 140 7 9 140 ALM 8.0 13.0 256 8 13 256 AMM NaN NaN 110 7 9 110 All stated counts match: True
Result: reproduced. Sessions, mice and units per area match the paper exactly: S1 10 sessions, 6 mice, 177 units; S2 8, 5, 162; MM 9, 7, 140; ALM 13, 8, 256; AMM 110 units (9 sessions, 7 mice).
fig6-session-counts¶
Optogenetic sessions and mice per condition in the cross-modal and detection tasks (Figure 6 legend).
paper6 = {("cross-modal", "S1/S2"): (4, 10), ("cross-modal", "MM"): (4, 11), ("cross-modal", "ALM"): (4, 10),
("cross-modal", "sham"): (7, 28), ("cross-modal", "AMM"): (7, 21), ("cross-modal", "PPC"): (7, 21),
("detection", "sham"): (5, 9), ("detection", "S1/S2"): (3, 3), ("detection", "MM"): (3, 5),
("detection", "ALM"): (3, 4), ("detection", "AMM"): (3, 4), ("detection", "PPC"): (3, 4)}
g = optos.groupby(["task", "condition"]).agg(mice=("mouse", "nunique"), sessions=("session", "size"))
rows = [{"task": k[0], "condition": k[1], "mice_paper": v[0], "mice": g.loc[k, "mice"],
"sessions_paper": v[1], "sessions": g.loc[k, "sessions"]} for k, v in paper6.items()]
t6 = pd.DataFrame(rows)
print(t6.to_string(index=False))
print("Counts matching:", int((t6.mice == t6.mice_paper).sum() + (t6.sessions == t6.sessions_paper).sum()), "of", 2 * len(t6))
task condition mice_paper mice sessions_paper sessions cross-modal S1/S2 4 4 10 10 cross-modal MM 4 4 11 11 cross-modal ALM 4 4 10 10 cross-modal sham 7 7 28 28 cross-modal AMM 7 7 21 21 cross-modal PPC 7 7 21 21 detection sham 5 5 9 9 detection S1/S2 3 3 3 3 detection MM 3 3 5 5 detection ALM 3 3 4 4 detection AMM 3 3 4 4 detection PPC 3 3 4 4 Counts matching: 24 of 24
Result: reproduced. All 24 counts of optogenetic sessions and mice per condition match the Figure 6 legend.
fig1d-performance and fig1d-outcome-fractions¶
Performance was about 75% correct under both rules (touch 74 ± 1%, light 75 ± 1%, 12 mice), with no difference (paired t-test, p = 0.56). Outcome fractions, touch against light: hit 36/35%, correct rejection 38/40%, miss 13/14%, false alarm 13/12%.
Reading (a) uses the trials as deposited, reading (b) drops the last 20 trials of each session (Methods).
def block_outcomes(t):
c = t.category[t.category != ""]
out = {}
for blk, hit, cr, miss, fa in (("touch", "TTT", "TVN", "TTN", ["TTV", "TVT", "TVV"]),
("light", "VVV", "VTN", "VVN", ["VTT", "VTV", "VVT"])):
n = c.str[0].eq("T" if blk == "touch" else "V").sum()
out.update({f"{blk}_hit": (c == hit).sum() / n, f"{blk}_cr": (c == cr).sum() / n,
f"{blk}_miss": (c == miss).sum() / n, f"{blk}_fa": c.isin(fa).sum() / n})
out[f"{blk}_perf"] = out[f"{blk}_hit"] + out[f"{blk}_cr"]
return out
perf = pd.DataFrame([{"subject": s["subject"], "area": s["area"], "reading": r,
**block_outcomes(s["trials"] if r == "a" else s["trials"].iloc[:-20])}
for s in main for r in ("a", "b")])
gap_rows = []
for s in main:
t = s["trials"]
gap = t.start.to_numpy()[1:] - t.stop.to_numpy()[:-1] > 0.01
gap_rows.append({"last_20": gap[-19:].mean(), "earlier": gap[:-19].mean(), "last_row_gap_before": bool(gap[-1])})
gap_rows = pd.DataFrame(gap_rows)
print("Fraction of consecutive rows separated by removed trials: within the last 20 rows", gap_rows.last_20.mean().round(3),
"; earlier rows", gap_rows.earlier.mean().round(3), "(mean over the 40 sessions)\n")
fig1d = {}
for r, g in perf.groupby("reading"):
m = g.groupby("subject").mean(numeric_only=True)
test = stats.ttest_rel(m.touch_perf, m.light_perf)
fig1d[r] = (m, test)
print(f"Reading ({r}): {len(m)} mice; percent, mean and SEM over mice")
print(pd.DataFrame({"mean": 100 * m.mean(), "sem": 100 * m.sem(), "min_mouse": 100 * m.min(),
"max_mouse": 100 * m.max()}).round(1).T.to_string())
print(f" paired t-test touch against light performance: t = {test.statistic:.3f}, p = {test.pvalue:.3f}\n")
Fraction of consecutive rows separated by removed trials: within the last 20 rows 0.045 ; earlier rows 0.127 (mean over the 40 sessions)
Reading (a): 12 mice; percent, mean and SEM over mice
touch_hit touch_cr touch_miss touch_fa touch_perf light_hit light_cr light_miss light_fa light_perf
mean 35.6 38.1 13.0 13.3 73.6 35.2 39.6 13.6 11.6 74.8
sem 1.1 1.0 1.1 1.0 0.8 1.8 1.2 1.4 0.6 1.5
min_mouse 28.4 31.7 5.9 6.9 68.6 25.3 30.9 5.0 8.6 69.1
max_mouse 43.2 42.6 19.4 19.3 77.1 47.7 46.2 19.9 15.5 86.1
paired t-test touch against light performance: t = -0.599, p = 0.561
Reading (b): 12 mice; percent, mean and SEM over mice
touch_hit touch_cr touch_miss touch_fa touch_perf light_hit light_cr light_miss light_fa light_perf
mean 35.7 37.1 13.2 14.0 72.8 35.9 38.3 13.1 12.7 74.2
sem 1.1 1.0 1.2 1.1 0.9 1.8 1.2 1.5 0.7 1.6
min_mouse 28.3 30.9 5.9 7.4 66.2 26.6 28.9 5.6 8.1 65.0
max_mouse 43.2 41.9 20.4 19.3 76.4 47.8 45.3 20.4 17.0 84.9
paired t-test touch against light performance: t = -0.604, p = 0.558
expected = {"touch_perf": (74, 1), "light_perf": (75, 1), "touch_hit": (36, 1), "light_hit": (35, 2),
"touch_cr": (38, 1), "light_cr": (40, 1), "touch_miss": (13, 1), "light_miss": (14, 1),
"touch_fa": (13, 1), "light_fa": (12, 1)}
for r, (m, test) in fig1d.items():
within = {k: abs(100 * m[k].mean() - v) <= tol for k, (v, tol) in expected.items()}
print(f"Reading ({r}) within tolerance:", within)
print(f" fractions within tolerance: {sum(within[k] for k in expected if 'perf' not in k)} of 8")
print("\nPer area (reading a, mean over sessions):")
print((100 * perf[perf.reading == "a"].groupby("area")[["touch_perf", "light_perf"]].mean()).round(1).to_string())
Reading (a) within tolerance: {'touch_perf': np.True_, 'light_perf': np.True_, 'touch_hit': np.True_, 'light_hit': np.True_, 'touch_cr': np.True_, 'light_cr': np.True_, 'touch_miss': np.True_, 'light_miss': np.True_, 'touch_fa': np.True_, 'light_fa': np.True_}
fractions within tolerance: 8 of 8
Reading (b) within tolerance: {'touch_perf': np.False_, 'light_perf': np.True_, 'touch_hit': np.True_, 'light_hit': np.True_, 'touch_cr': np.True_, 'light_cr': np.False_, 'touch_miss': np.True_, 'light_miss': np.True_, 'touch_fa': np.False_, 'light_fa': np.True_}
fractions within tolerance: 6 of 8
Per area (reading a, mean over sessions):
touch_perf light_perf
area
ALM 72.3 73.7
MM 71.9 78.0
S1 73.1 73.7
S2 74.4 74.6
Result: reproduced. Under reading (a), the trials as deposited, performance is 73.6 ± 0.8% in respond-to-touch and 74.8 ± 1.5% in respond-to-light blocks (mean and SEM over 12 mice), both within 1 point of the paper, and the paired t-test gives p = 0.561 (paper 0.56). Reading (b), without the last 20 trials, gives 72.8 and 74.2% (p = 0.558); its touch value is outside tolerance. Removed trials (gaps between rows) separate 4.5% of consecutive rows within the last 20 rows of a session against 12.7% earlier, this does not tell whether 20 trials were removed after the last row.
Result: reproduced. Under reading (a) all eight outcome fractions are within the paper's SEM (touch against light: hit 35.6 against 35.2%, correct rejection 38.1 against 39.6%, miss 13.0 against 13.6%, false alarm 13.3 against 11.6%); reading (b) puts 6 of 8 within.
Single-neuron discrimination (fig1h, fig2c, fig2d, fig2e, fig2f)¶
18 to 33% of neurons discriminate tactile hits (tHit) from tactile correct rejections (tCR) in the first 150 ms (S1 28.8, S2 17.9, MM 32.9, ALM 23.4%), mostly with AUC above 0.5 (fig1h). Before the stimulus, S1 4.5, S2 2.5, MM 21.4, ALM 10.2% discriminate the rule (fig2c); almost none discriminate the stimulus (fig2d; 0, 0, 0, 0.8%); after it, 37.3, 35.2, 15, 14.1% do (fig2e). Among rule-discriminating neurons, the pre-stimulus rule preference predicts the early touch-response modulation (fig2f; MM r = 0.93, 30 neurons; ALM r = 0.83, 26 neurons).
The bootstrap resamples are shared by all units and bins of a session (the code draws them separately); each unit's interval depends only on its own resamples.
rng_roc = np.random.default_rng(202)
N_BOOT = 1000
roc = []
for s in main:
t = s["trials"]
hit, cr, vh, vcr = (xm.is_category(t, k) for k in (xm.T_HIT, xm.T_CR, xm.V_HIT, xm.V_CR))
n = s["n_units"]
b1h = xm.bootstrap_auc(s["r10"][hit][:, :, 10:25].reshape(hit.sum(), -1),
s["r10"][cr][:, :, 10:25].reshape(cr.sum(), -1), N_BOOT, rng_roc).reshape(N_BOOT, n, 15)
rule = xm.bootstrap_auc(s["r100"][hit | vcr][:, :, 0], s["r100"][cr | vh][:, :, 0], N_BOOT, rng_roc)
stim_pre = xm.bootstrap_auc(s["r100"][hit | cr][:, :, 0], s["r100"][vh | vcr][:, :, 0], N_BOOT, rng_roc)
stim_post = xm.bootstrap_auc(s["r100"][hit | cr][:, :, 1], s["r100"][vh | vcr][:, :, 1], N_BOOT, rng_roc)
roc.append({"area": s["area"], "subject": s["subject"], "n": n, "trials": (hit.sum(), cr.sum(), vh.sum(), vcr.sum()),
"b1h": b1h, "rule": rule, "stim_pre": stim_pre, "stim_post": stim_post})
units = []
for area in xm.AREAS:
R = [r for r in roc if r["area"] == area]
n_area = sum(r["n"] for r in R)
b1h = np.concatenate([r["b1h"] for r in R], axis=1)
sig1h = xm.bonferroni_significant(b1h.reshape(N_BOOT, -1), n_area).reshape(n_area, 15)
df = pd.DataFrame({"area": area, "subject": np.concatenate([[r["subject"]] * r["n"] for r in R]),
"selective": xm.consecutive(sig1h, 3), "selective_2bins": xm.consecutive(sig1h, 2),
"selective_4bins": xm.consecutive(sig1h, 4),
"auc_0_150": b1h.mean(axis=(0, 2)), "auc_0_50": b1h[:, :, :5].mean(axis=(0, 2))})
for name in ("rule", "stim_pre", "stim_post"):
b = np.concatenate([r[name] for r in R], axis=1)
df[name + "_sig"] = xm.bonferroni_significant(b, n_area)
df[name + "_sig_uncorrected"] = xm.bonferroni_significant(b, 1)
df[name + "_auc"] = b.mean(axis=0)
units.append(df)
units = pd.concat(units, ignore_index=True)
print("Trials per class (tHit, tCR, vHit, vCR), min over sessions:", np.min([r["trials"] for r in roc], axis=0))
Trials per class (tHit, tCR, vHit, vCR), min over sessions: [26 19 20 24]
summary = units.groupby("area", sort=False).agg(
neurons=("selective", "size"), fig1h_pct=("selective", "mean"), fig2c_pct=("rule_sig", "mean"),
fig2d_pct=("stim_pre_sig", "mean"), fig2e_pct=("stim_post_sig", "mean"))
for c in summary.columns[1:]:
summary[c] = (100 * summary[c]).round(1)
summary["fig1h_paper"] = [28.8, 17.9, 32.9, 23.4]
summary["fig2c_paper"] = [4.5, 2.5, 21.4, 10.2]
summary["fig2d_paper"] = [0, 0, 0, 0.8]
summary["fig2e_paper"] = [37.3, 35.2, 15.0, 14.1]
summary["fig2c_neurons"] = units.groupby("area", sort=False).rule_sig.sum()
print(summary.to_string())
sel = units[units.selective]
print(f"\nfig1h: selective neurons with mean AUC above 0.5: {(sel.auc_0_150 > 0.5).sum()} of {len(sel)} "
f"({100 * (sel.auc_0_150 > 0.5).mean():.1f}%); by area:",
sel.groupby("area", sort=False).auc_0_150.apply(lambda a: round(100 * (a > 0.5).mean(), 1)).to_dict())
print("\nVariants: percent selective with 2 or 4 consecutive bins, and fig2 percentages without Bonferroni correction")
print((100 * units.groupby("area", sort=False)[["selective_2bins", "selective_4bins", "rule_sig_uncorrected",
"stim_pre_sig_uncorrected", "stim_post_sig_uncorrected"]].mean()).round(1).to_string())
print("\nBy mouse (percent; neurons):")
print(units.groupby(["area", "subject"], sort=False).agg(n=("selective", "size"), fig1h=("selective", "mean"),
fig2c=("rule_sig", "mean"), fig2e=("stim_post_sig", "mean"))
.assign(fig1h=lambda d: (100 * d.fig1h).round(0), fig2c=lambda d: (100 * d.fig2c).round(0),
fig2e=lambda d: (100 * d.fig2e).round(0)).to_string())
neurons fig1h_pct fig2c_pct fig2d_pct fig2e_pct fig1h_paper fig2c_paper fig2d_paper fig2e_paper fig2c_neurons
area
S1 177 30.5 5.6 0.0 36.2 28.8 4.5 0.0 37.3 10
S2 162 16.7 2.5 0.0 35.8 17.9 2.5 0.0 35.2 4
MM 140 36.4 20.0 0.0 13.6 32.9 21.4 0.0 15.0 28
ALM 256 23.0 9.8 0.8 14.5 23.4 10.2 0.8 14.1 25
fig1h: selective neurons with mean AUC above 0.5: 165 of 191 (86.4%); by area: {'S1': 92.6, 'S2': 100.0, 'MM': 74.5, 'ALM': 84.7}
Variants: percent selective with 2 or 4 consecutive bins, and fig2 percentages without Bonferroni correction
selective_2bins selective_4bins rule_sig_uncorrected stim_pre_sig_uncorrected stim_post_sig_uncorrected
area
S1 36.2 22.6 16.9 4.0 53.7
S2 26.5 15.4 17.3 4.9 44.4
MM 39.3 30.7 37.9 3.6 32.9
ALM 30.9 18.0 26.2 5.9 28.9
By mouse (percent; neurons):
n fig1h fig2c fig2e
area subject
S1 EF0150 8 62.0 25.0 62.0
YT080 22 32.0 5.0 23.0
YT081 55 27.0 4.0 20.0
YT084 49 29.0 4.0 51.0
YT085 30 23.0 3.0 47.0
YT086 13 46.0 15.0 31.0
S2 EF0148 41 2.0 0.0 59.0
EF0150 34 29.0 6.0 47.0
YT080 5 40.0 20.0 60.0
YT084 25 28.0 0.0 40.0
YT085 57 12.0 2.0 9.0
MM JL005 13 38.0 0.0 31.0
YT071 44 45.0 25.0 16.0
YT081 21 33.0 24.0 10.0
YT083 5 20.0 0.0 20.0
YT084 39 36.0 23.0 10.0
YT085 13 8.0 15.0 0.0
YT086 5 60.0 20.0 20.0
ALM EF0147 22 41.0 18.0 27.0
EF0148 32 19.0 12.0 9.0
EF0150 26 35.0 31.0 8.0
EF0151 29 17.0 10.0 10.0
JL005 67 19.0 7.0 16.0
YT081 13 8.0 0.0 8.0
YT084 50 22.0 2.0 16.0
YT085 17 29.0 0.0 18.0
# How much the percentages move with the bootstrap draws: the same analysis with four other seeds.
reps = []
for seed in (11, 12, 13, 14):
rng_rep = np.random.default_rng(seed)
per_area = {a: {"b1h": [], "rule": [], "stim_pre": [], "stim_post": []} for a in xm.AREAS}
for s in main:
t = s["trials"]
hit, cr, vh, vcr = (xm.is_category(t, k) for k in (xm.T_HIT, xm.T_CR, xm.V_HIT, xm.V_CR))
n = s["n_units"]
d = per_area[s["area"]]
d["b1h"].append(xm.bootstrap_auc(s["r10"][hit][:, :, 10:25].reshape(hit.sum(), -1),
s["r10"][cr][:, :, 10:25].reshape(cr.sum(), -1), N_BOOT, rng_rep).reshape(N_BOOT, n, 15))
d["rule"].append(xm.bootstrap_auc(s["r100"][hit | vcr][:, :, 0], s["r100"][cr | vh][:, :, 0], N_BOOT, rng_rep))
d["stim_pre"].append(xm.bootstrap_auc(s["r100"][hit | cr][:, :, 0], s["r100"][vh | vcr][:, :, 0], N_BOOT, rng_rep))
d["stim_post"].append(xm.bootstrap_auc(s["r100"][hit | cr][:, :, 1], s["r100"][vh | vcr][:, :, 1], N_BOOT, rng_rep))
for a, d in per_area.items():
b1h = np.concatenate(d["b1h"], axis=1)
na = b1h.shape[1]
row = {"seed": seed, "area": a,
"fig1h": 100 * xm.consecutive(xm.bonferroni_significant(b1h.reshape(N_BOOT, -1), na).reshape(na, 15)).mean()}
for k, name in (("rule", "fig2c"), ("stim_pre", "fig2d"), ("stim_post", "fig2e")):
row[name] = 100 * xm.bonferroni_significant(np.concatenate(d[k], axis=1), na).mean()
reps.append(row)
main_seed = pd.DataFrame({"seed": 202, "area": units.area, "fig1h": 100 * units.selective,
"fig2c": 100 * units.rule_sig, "fig2d": 100 * units.stim_pre_sig,
"fig2e": 100 * units.stim_post_sig}).groupby("area", sort=False).mean().reset_index()
reps = pd.concat([main_seed, pd.DataFrame(reps)], ignore_index=True)
print("Percent of neurons over the main seed and four other seeds:")
print(reps.pivot(index="seed", columns="area", values=["fig1h", "fig2c"]).round(1).to_string())
avg = reps.groupby("area", sort=False)[["fig1h", "fig2c", "fig2d", "fig2e"]].agg(["mean", "min", "max"])
print("\nMean, min and max over the five seeds:")
print(avg.round(1).to_string())
tol1h = {"S1": (28.8, 2.88), "S2": (17.9, 1.79), "MM": (32.9, 3.29), "ALM": (23.4, 2.34)}
tol2c = {"S1": (4.5, 0.56), "S2": (2.5, 0.62), "MM": (21.4, 2.14), "ALM": (10.2, 1.02)}
for name, tol in (("fig1h", tol1h), ("fig2c", tol2c)):
print(f"{name}, five-seed mean within tolerance:", {a: bool(abs(avg.loc[a, (name, "mean")] - v) <= d) for a, (v, d) in tol.items()},
"; seeds within tolerance per area:", {a: int((abs(reps[reps.area == a][name] - v) <= d).sum()) for a, (v, d) in tol.items()})
Percent of neurons over the main seed and four other seeds:
fig1h fig2c
area ALM MM S1 S2 ALM MM S1 S2
seed
11.0 23.0 35.0 29.4 18.5 9.0 20.0 5.1 3.1
12.0 22.7 36.4 28.2 19.1 10.9 21.4 5.1 3.1
13.0 21.9 35.0 30.5 17.9 11.3 20.0 3.4 3.1
14.0 25.0 35.0 29.9 18.5 8.6 22.1 5.6 2.5
202.0 23.0 36.4 30.5 16.7 9.8 20.0 5.6 2.5
Mean, min and max over the five seeds:
fig1h fig2c fig2d fig2e
mean min max mean min max mean min max mean min max
area
S1 29.7 28.2 30.5 5.0 3.4 5.6 0.0 0.0 0.0 35.9 35.0 36.2
S2 18.1 16.7 19.1 2.8 2.5 3.1 0.2 0.0 0.6 35.7 35.2 36.4
MM 35.6 35.0 36.4 20.7 20.0 22.1 0.6 0.0 0.7 16.1 13.6 17.1
ALM 23.1 21.9 25.0 9.9 8.6 11.3 0.9 0.8 1.2 14.1 12.9 15.2
fig1h, five-seed mean within tolerance: {'S1': True, 'S2': True, 'MM': True, 'ALM': True} ; seeds within tolerance per area: {'S1': 5, 'S2': 5, 'MM': 3, 'ALM': 5}
fig2c, five-seed mean within tolerance: {'S1': True, 'S2': True, 'MM': True, 'ALM': True} ; seeds within tolerance per area: {'S1': 0, 'S2': 5, 'MM': 5, 'ALM': 2}
fig, axes = plt.subplots(1, 4, figsize=(12, 2.6), sharey=True)
for ax, area in zip(axes, xm.AREAS):
u = units[units.area == area]
ax.hist(u.auc_0_150, bins=np.arange(0, 1.01, 0.05), color=GRAY, label="all")
ax.hist(u.auc_0_150[u.selective], bins=np.arange(0, 1.01, 0.05), color=BLUE, label="selective")
ax.set_title(f"{area} ({100 * u.selective.mean():.1f}% selective)")
ax.set_xlabel("AUC tHit vs tCR, 0-150 ms")
axes[0].set_ylabel("neurons")
axes[0].legend(frameon=False)
plt.tight_layout()
plt.show()
print("Mean AUC range over neurons:", units.auc_0_150.min().round(3), "to", units.auc_0_150.max().round(3), "(all inside the axes)")
Mean AUC range over neurons: 0.241 to 0.933 (all inside the axes)
Result: partially reproduced. S1 30.5%, S2 16.7% and ALM 23.0% are within tolerance; MM is 36.4%, above 32.9 ± 3.29 and above the stated range widened by 10% (up to 36.3%). 165 of 191 selective neurons (86.4%) have mean AUC above 0.5. The outcome is judged on the main seed. Over the main seed and four others, MM gives 35.0 to 36.4% and is within tolerance in 3 of 5; the five-seed means (S1 29.7, S2 18.1, MM 35.6, ALM 23.1%) are all within tolerance. With 1000 resamples the Bonferroni interval is the minimum and maximum of the resamples, so the MM excess over the tolerance depends on the draw.
Result: partially reproduced. S2 2.5%, MM 20.0% and ALM 9.8% are within tolerance, S1 is 5.6% (10 neurons) against 4.5 ± 0.56%. MM and ALM each exceed S1 and S2. The outcome is judged on the main seed. Over five seeds S1 gives 3.4 to 5.6% and is within tolerance in none (9 neurons, 5.1%, is just above it), and ALM is within tolerance in 2 of 5; the five-seed means (S1 5.0, S2 2.8, MM 20.7, ALM 9.9%) are all within tolerance.
Result: reproduced. Before the stimulus 0% of S1, S2 and MM neurons and 0.8% of ALM neurons discriminate tactile from visual trials, as in the paper.
Result: reproduced. After the stimulus S1 36.2%, S2 35.8%, MM 13.6% and ALM 14.5% of neurons discriminate the stimulus, all within tolerance of 37.3, 35.2, 15 and 14.1%.
rows = []
for area in xm.AREAS:
u = units[(units.area == area) & units.rule_sig]
r, p = stats.pearsonr(u.rule_auc, u.auc_0_50) if len(u) > 2 else (np.nan, np.nan)
rho, p_s = stats.spearmanr(u.rule_auc, u.auc_0_50) if len(u) > 2 else (np.nan, np.nan)
ua = units[units.area == area]
r_all, p_all = stats.pearsonr(ua.rule_auc, ua.auc_0_50)
rows.append({"area": area, "neurons": len(u), "pearson_r": r, "p": p, "spearman_rho": rho, "p_spearman": p_s,
"r_all_neurons": r_all, "p_all": p_all,
"x_range": f"{u.rule_auc.min():.2f}-{u.rule_auc.max():.2f}" if len(u) else "",
"y_range": f"{u.auc_0_50.min():.2f}-{u.auc_0_50.max():.2f}" if len(u) else ""})
f2f = pd.DataFrame(rows)
f2f["paper_r"] = [0.69, 0.91, 0.93, 0.83]
f2f["paper_neurons"] = [8, 4, 30, 26]
print(f2f.round(4).to_string(index=False))
area neurons pearson_r p spearman_rho p_spearman r_all_neurons p_all x_range y_range paper_r paper_neurons S1 10 0.6807 0.0303 0.8182 0.0038 0.4592 0.0 0.38-0.67 0.39-0.84 0.69 8 S2 4 0.9015 0.0985 1.0000 0.0000 0.4968 0.0 0.32-0.70 0.42-0.92 0.91 4 MM 28 0.9578 0.0000 0.9431 0.0000 0.8131 0.0 0.11-0.81 0.20-0.73 0.93 30 ALM 25 0.8405 0.0000 0.8585 0.0000 0.6118 0.0 0.31-0.76 0.41-0.73 0.83 26
Result: reproduced. Among rule-discriminating neurons, r = 0.958 in MM (28 neurons, p < 0.001) and 0.841 in ALM (25 neurons, p < 0.001), within tolerance of 0.93 and 0.83. S1 gives r = 0.681 (10 neurons, p = 0.030) and S2 0.902 (4 neurons, p = 0.0985).
fig3b-rule-decoding¶
The rule can be decoded from pre-stimulus population activity; median LDA accuracy, true against shuffled, S1 0.61/0.5, S2 0.62/0.53, MM 0.7/0.52, ALM 0.68/0.55.
rng_dec = np.random.default_rng(303)
rows = []
for s in main:
sel, block, stim = xm.correct_trials(s["trials"])
for name, y, b in (("rule", block, 0), ("stimulus_pre", stim, 0), ("stimulus_post", stim, 1)):
x = s["r100"][sel][:, :, b]
rows.append({"area": s["area"], "subject": s["subject"], "units": s["n_units"], "what": name,
"true": xm.cv_accuracy(x, y, rng_dec), "shuffled": xm.cv_accuracy(x, rng_dec.permutation(y), rng_dec)})
dec = pd.DataFrame(rows)
med = dec.groupby(["what", "area"], sort=False)[["true", "shuffled"]].median().round(3)
print("Median accuracy over sessions:")
print(med.to_string())
r = dec[dec.what == "rule"]
print("\nRule decoding per session: range", r.true.min().round(3), "to", r.true.max().round(3),
"; sessions above their shuffled value:", int((r.true > r.shuffled).sum()), "of", len(r))
print(r.groupby("area", sort=False).apply(lambda g: pd.Series({"sessions": len(g), "min": g.true.min(), "max": g.true.max(),
"wilcoxon_p_vs_shuffled": stats.wilcoxon(g.true, g.shuffled).pvalue})).round(3).to_string())
bal = pd.DataFrame([{"area": s["area"], "larger_class": max(np.mean(xm.correct_trials(s["trials"])[1] == v)
for v in np.unique(xm.correct_trials(s["trials"])[1]))} for s in main])
print("\nShare of the larger block among correct trials, median (min to max) per area, next to the shuffled median:")
print(bal.groupby("area", sort=False).larger_class.agg(["median", "min", "max"]).assign(
shuffled_median=r.groupby("area", sort=False).shuffled.median()).round(3).to_string())
print("\nBy mouse (median rule accuracy):")
print(r.groupby(["area", "subject"], sort=False).true.median().round(3).to_string())
Median accuracy over sessions:
true shuffled
what area
rule ALM 0.682 0.535
stimulus_pre ALM 0.535 0.514
stimulus_post ALM 0.706 0.504
rule S2 0.617 0.532
stimulus_pre S2 0.520 0.511
stimulus_post S2 0.882 0.534
rule S1 0.606 0.564
stimulus_pre S1 0.491 0.500
stimulus_post S1 0.839 0.529
rule MM 0.708 0.526
stimulus_pre MM 0.500 0.503
stimulus_post MM 0.663 0.513
Rule decoding per session: range 0.49 to 0.853 ; sessions above their shuffled value: 37 of 40
sessions min max wilcoxon_p_vs_shuffled
area
ALM 13.0 0.533 0.801 0.000
S2 8.0 0.524 0.721 0.008
S1 10.0 0.490 0.740 0.055
MM 9.0 0.572 0.853 0.004
Share of the larger block among correct trials, median (min to max) per area, next to the shuffled median:
median min max shuffled_median
area
ALM 0.552 0.503 0.656 0.535
S2 0.556 0.507 0.600 0.532
S1 0.556 0.500 0.627 0.564
MM 0.529 0.509 0.578 0.526
By mouse (median rule accuracy):
area subject
ALM EF0147 0.695
S2 EF0148 0.607
ALM EF0148 0.683
S2 EF0150 0.617
S1 EF0150 0.667
ALM EF0150 0.771
EF0151 0.741
JL005 0.680
MM JL005 0.572
YT071 0.838
S2 YT080 0.721
S1 YT080 0.570
YT081 0.606
MM YT081 0.818
ALM YT081 0.560
MM YT083 0.665
YT084 0.853
S1 YT084 0.542
ALM YT084 0.593
S2 YT084 0.584
MM YT085 0.662
S1 YT085 0.649
S2 YT085 0.612
ALM YT085 0.533
S1 YT086 0.740
MM YT086 0.708
Result: reproduced. Median rule-decoding accuracy is S1 0.606, S2 0.617, MM 0.708 and ALM 0.682, all within 10% of the paper, against shuffled medians of 0.564, 0.532, 0.526 and 0.535. 37 of 40 sessions exceed their shuffled value. The shuffled medians (0.526 to 0.564) are about equal to the larger block's share of correct trials (medians 0.529 to 0.556 by area).
fig3c-rule-decoding-motor-stronger and fig3de-stimulus-decoding-controls¶
Rule decoding CIs do not overlap shuffled ones in MM and ALM and overlap in S1 and S2; the stimulus is decoded after, not before, its onset.
rng_boot = np.random.default_rng(304)
rows = []
for name, target, b in (("rule", "block", 0), ("stimulus_pre", "stimulus", 0), ("stimulus_post", "stimulus", 1)):
for area in xm.AREAS:
bb = xm.bootstrap_decoding([s for s in main if s["area"] == area], target, b, 200, rng_boot)
lo, hi = np.quantile(bb, [0.025, 0.975], axis=0)
rows.append({"what": name, "area": area, "true_mean": bb[:, 0].mean(), "true_lo": lo[0], "true_hi": hi[0],
"shuffled_mean": bb[:, 1].mean(), "shuffled_lo": lo[1], "shuffled_hi": hi[1],
"overlap": bool(lo[0] <= hi[1] and lo[1] <= hi[0])})
boot_dec = pd.DataFrame(rows)
print(boot_dec.round(3).to_string(index=False))
print("\nPaper, rule: S1 [0.52,0.61] vs [0.49,0.55]; S2 [0.53,0.63] vs [0.49,0.54]; MM [0.60,0.73] vs [0.48,0.53]; ALM [0.56,0.66] vs [0.49,0.54]")
what area true_mean true_lo true_hi shuffled_mean shuffled_lo shuffled_hi overlap
rule S1 0.570 0.530 0.614 0.518 0.493 0.545 True
rule S2 0.574 0.528 0.619 0.514 0.488 0.539 True
rule MM 0.667 0.601 0.731 0.499 0.472 0.523 False
rule ALM 0.610 0.560 0.655 0.517 0.495 0.544 False
stimulus_pre S1 0.495 0.464 0.527 0.503 0.479 0.529 True
stimulus_pre S2 0.509 0.473 0.550 0.513 0.487 0.542 True
stimulus_pre MM 0.499 0.464 0.536 0.502 0.476 0.527 True
stimulus_pre ALM 0.506 0.472 0.534 0.510 0.486 0.532 True
stimulus_post S1 0.783 0.728 0.831 0.502 0.477 0.527 False
stimulus_post S2 0.805 0.701 0.900 0.509 0.483 0.537 False
stimulus_post MM 0.642 0.578 0.728 0.502 0.477 0.529 False
stimulus_post ALM 0.660 0.603 0.720 0.511 0.488 0.535 False
Paper, rule: S1 [0.52,0.61] vs [0.49,0.55]; S2 [0.53,0.63] vs [0.49,0.54]; MM [0.60,0.73] vs [0.48,0.53]; ALM [0.56,0.66] vs [0.49,0.54]
fig, axes = plt.subplots(1, 3, figsize=(11, 2.8), sharey=True)
for ax, name in zip(axes, ("rule", "stimulus_pre", "stimulus_post")):
d = boot_dec[boot_dec.what == name].reset_index(drop=True)
x = np.arange(len(d))
ax.errorbar(x - 0.1, d.true_mean, yerr=[d.true_mean - d.true_lo, d.true_hi - d.true_mean], fmt="o", color=BLUE, label="true")
ax.errorbar(x + 0.1, d.shuffled_mean, yerr=[d.shuffled_mean - d.shuffled_lo, d.shuffled_hi - d.shuffled_mean],
fmt="o", color=GRAY, label="shuffled")
ax.axhline(0.5, color="#cccccc", lw=1, zorder=0)
ax.set_xticks(x, d.area)
ax.set_title(name.replace("_", " "))
axes[0].set_ylabel("accuracy, 95% CI")
axes[0].legend(frameon=False)
plt.tight_layout()
plt.show()
print("Plotted range:", boot_dec[["true_lo", "shuffled_lo"]].min().min().round(3), "to",
boot_dec[["true_hi", "shuffled_hi"]].max().max().round(3))
Plotted range: 0.464 to 0.9
Result: reproduced. The bootstrap CIs of true and shuffled accuracy do not overlap in MM (0.601 to 0.731 against 0.472 to 0.523) and ALM (0.560 to 0.655 against 0.495 to 0.544) and overlap in S1 (0.530 to 0.614 against 0.493 to 0.545) and S2 (0.528 to 0.619 against 0.488 to 0.539), close to the paper's intervals.
Result: reproduced. Before the stimulus the true and shuffled CIs overlap in all four areas (true means 0.495 to 0.509); after it they do not overlap in any (true means 0.642 to 0.805).
figs3-svm-rf-decoding¶
SVM and random forests decode the rule about as well as LDA (medians in the claims file).
rng_svm = np.random.default_rng(305)
class LinearSVM:
def fit(self, x, y):
self.model = SVC(kernel="linear", C=1.0).fit(x, y)
return self
def predict(self, x):
return self.model.predict(x)
rows = []
for s in main:
t = s["trials"]
sel, block, _ = xm.correct_trials(t)
x = s["r100"][sel][:, :, 0]
ref = s["r100"][(t.category != "").to_numpy()][:, :, 0]
xs = xm.soft_normalize(x.T, ref.T).T
xz = (x - ref.mean(0)) / (ref.std(0, ddof=1) + 0.01)
yp = rng_svm.permutation(block)
row = {"area": s["area"], "units": s["n_units"],
"svm": xm.cv_accuracy(xs, block, rng_svm, make_model=LinearSVM),
"svm_shuffled": xm.cv_accuracy(xs, yp, rng_svm, make_model=LinearSVM),
"svm_zscore": xm.cv_accuracy(xz, block, rng_svm, make_model=LinearSVM),
"svm_zscore_shuffled": xm.cv_accuracy(xz, yp, rng_svm, make_model=LinearSVM)}
for name, y in (("rf", block), ("rf_shuffled", yp)):
forest = RandomForestClassifier(500, max_features="sqrt", oob_score=True, n_jobs=4,
random_state=int(rng_svm.integers(2**31))).fit(xs, y)
row[name] = forest.oob_score_
rows.append(row)
svm = pd.DataFrame(rows)
med = svm.groupby("area").median().loc[xm.AREAS].drop(columns="units")
med["svm_paper"] = [0.60, 0.61, 0.71, 0.65]
med["rf_paper"] = [0.59, 0.60, 0.65, 0.70]
print(med.round(3).to_string())
within = sum(abs(med.loc[a, m] - med.loc[a, m + "_paper"]) <= 0.1 * med.loc[a, m + "_paper"] for a in xm.AREAS for m in ("svm", "rf"))
print("\nTrue medians within 10% of the paper:", within, "of 8; all above shuffled:",
bool(((med.svm > med.svm_shuffled) & (med.rf > med.rf_shuffled)).all()))
print("Per-session range: SVM", svm.svm.min().round(3), "-", svm.svm.max().round(3), "; RF", svm.rf.min().round(3), "-", svm.rf.max().round(3))
svm svm_shuffled svm_zscore svm_zscore_shuffled rf rf_shuffled svm_paper rf_paper area S1 0.606 0.556 0.575 0.511 0.586 0.481 0.60 0.59 S2 0.589 0.562 0.603 0.515 0.606 0.533 0.61 0.60 MM 0.658 0.545 0.701 0.539 0.676 0.545 0.71 0.65 ALM 0.648 0.535 0.642 0.537 0.655 0.503 0.65 0.70 True medians within 10% of the paper: 8 of 8; all above shuffled: True Per-session range: SVM 0.506 - 0.866 ; RF 0.455 - 0.914
Result: reproduced. All eight true medians are within 10% of the paper (SVM 0.606, 0.589, 0.658, 0.648; random forest 0.586, 0.606, 0.676, 0.655 for S1, S2, MM, ALM) and all exceed their shuffled medians.
fig4c-prestim-distance-predicts-divergence¶
Sessions with a larger pre-stimulus tHit-tCR distance show a larger distance after the stimulus (permutation test, p < 0.001, 40 sessions).
rng_dist = np.random.default_rng(404)
def split_test(dist, n_perm=1000):
pre = dist[:, :10].mean(1)
top = pre >= np.median(pre)
post = dist[:, 10:25]
stat = np.linalg.norm(post[top].mean(0) - post[~top].mean(0))
null = np.array([np.linalg.norm(post[p].mean(0) - post[~p].mean(0))
for p in (rng_dist.permutation(len(pre)) < top.sum() for _ in range(n_perm))])
return top, post[top].mean(), post[~top].mean(), stat, float(np.mean(null >= stat))
dist = np.array([xm.trajectory_distance(s) for s in main])
dist_code = np.array([xm.trajectory_distance(s, offset=0.0) for s in main])
for name, d in (("Methods normalization (range + 5)", dist), ("code normalization (range)", dist_code)):
top, a, b, stat, p = split_test(d)
print(f"{name}: top {top.sum()} sessions mean post distance {a:.3f}, bottom {(~top).sum()} {b:.3f}; "
f"statistic {stat:.3f}, permutation p = {p:.3f}")
areas = pd.Series([s["area"] for s in main])
print(" top-group sessions by area:", areas[top].value_counts().to_dict())
print("Distance range over sessions and bins:", dist.min().round(3), "to", dist.max().round(3))
pre, post = dist[:, :10].mean(1), dist[:, 10:25].mean(1)
print("Spearman correlation of pre and post mean distance over 40 sessions:", stats.spearmanr(pre, post))
Methods normalization (range + 5): top 20 sessions mean post distance 1.171, bottom 20 0.966; statistic 0.830, permutation p = 0.004
top-group sessions by area: {'ALM': 8, 'MM': 6, 'S1': 5, 'S2': 1}
code normalization (range): top 20 sessions mean post distance 1.428, bottom 20 1.112; statistic 1.251, permutation p = 0.001
top-group sessions by area: {'ALM': 9, 'MM': 5, 'S1': 4, 'S2': 2}
Distance range over sessions and bins: 0.279 to 2.16
Spearman correlation of pre and post mean distance over 40 sessions: SignificanceResult(statistic=np.float64(0.5369606003752346), pvalue=np.float64(0.00035391093709066643))
Result: partially reproduced. The top 20 sessions by pre-stimulus distance have a larger mean post-stimulus distance than the bottom 20 (1.171 against 0.966), with permutation p = 0.004, not below 0.001. The code's normalization gives p = 0.001.
fig4e-subspace-overlap and fig4f-prestim-overlap-predicts-poststim¶
tCR overlap minus control tHit overlap: S1 -0.23, S2 -0.23, MM -0.36, ALM -0.35 (paired t-tests); pre- and post-stimulus tCR overlaps correlate, r = 0.68, 31 sessions.
rng_ov = np.random.default_rng(505)
rows = []
for s in main:
o = xm.subspace_overlaps(s, rng_ov, n_splits=100)
single = [xm.subspace_overlaps(s, rng_ov, n_splits=1, ref_offset=5.0, cmp_offset=0.01, per_neuron_split=True)
for _ in range(200)]
rows.append({"area": s["area"], "units": s["n_units"], **o,
"code_control": [v["control_post"] for v in single], "code_cr": [v["cr_post"] for v in single],
"code_cr_pre": [v["cr_pre"] for v in single]})
ov = pd.DataFrame(rows)
def overlap_tests(df, ctrl="control_post", cr="cr_post"):
out = []
for area in xm.AREAS:
g = df[df.area == area]
d = g[cr] - g[ctrl]
out.append({"area": area, "sessions": len(g), "difference": d.mean(), "p_paired_t": stats.ttest_rel(g[cr], g[ctrl]).pvalue,
"sessions_negative": int((d < 0).sum())})
return pd.DataFrame(out)
main_ov = overlap_tests(ov[ov.units >= 10])
main_ov["paper"] = [-0.23, -0.23, -0.36, -0.35]
main_ov["within_10pct"] = (main_ov.difference - main_ov.paper).abs() <= 0.1 * main_ov.paper.abs()
print("Main (at least 10 units, 100 equal splits, offset 5):")
print(main_ov.round(4).to_string(index=False))
print("\nVariant, code's session rule (more than 10 units):")
print(overlap_tests(ov[ov.units > 10]).round(4).to_string(index=False))
# The code's single per-neuron split with offsets 5 and 0.01, repeated 200 times: distribution of each area's mean difference.
code_runs = []
g10 = ov[ov.units > 10]
for k in range(200):
for area in xm.AREAS:
g = g10[g10.area == area]
code_runs.append({"run": k, "area": area,
"difference": np.mean([c[k] - h[k] for c, h in zip(g.code_cr, g.code_control)]),
"p": stats.ttest_rel([c[k] for c in g.code_cr], [h[k] for h in g.code_control]).pvalue})
code_runs = pd.DataFrame(code_runs)
print("\nVariant, code's procedure (one split per run, 200 runs): difference 2.5, 50, 97.5 percentiles, and runs with p < 0.05")
print(code_runs.groupby("area", sort=False).agg(p2_5=("difference", lambda d: d.quantile(0.025)), median=("difference", "median"),
p97_5=("difference", lambda d: d.quantile(0.975)),
runs_p_below_0_05=("p", lambda p: int((p < 0.05).sum()))).round(3).to_string())
print("Overlap range:", ov[["control_post", "cr_post", "cr_pre"]].min().min().round(3), "to", ov[["control_post", "cr_post", "cr_pre"]].max().max().round(3))
Main (at least 10 units, 100 equal splits, offset 5): area sessions difference p_paired_t sessions_negative paper within_10pct S1 8 -0.2908 0.0007 8 -0.23 False S2 7 -0.2181 0.0254 6 -0.23 True MM 5 -0.4658 0.0029 5 -0.36 False ALM 12 -0.4224 0.0000 12 -0.35 False Variant, code's session rule (more than 10 units): area sessions difference p_paired_t sessions_negative S1 8 -0.2908 0.0007 8 S2 7 -0.2181 0.0254 6 MM 5 -0.4658 0.0029 5 ALM 11 -0.4198 0.0000 11
Variant, code's procedure (one split per run, 200 runs): difference 2.5, 50, 97.5 percentiles, and runs with p < 0.05
p2_5 median p97_5 runs_p_below_0_05
area
S1 -0.327 -0.286 -0.239 200
S2 -0.257 -0.222 -0.168 181
MM -0.497 -0.445 -0.376 200
ALM -0.473 -0.420 -0.359 200
Overlap range: 0.171 to 0.988
Result: partially reproduced. The tCR overlap is lower than the control tHit overlap in all four areas (S1 -0.291, p = 0.0007; S2 -0.218, p = 0.025; MM -0.466, p = 0.003; ALM -0.422, p < 0.0001), but only S2 is within 10% of the paper's difference; MM and ALM are larger in magnitude. The code's single-split procedure gives medians of -0.286, -0.222, -0.445 and -0.420 over 200 runs, and its 95% ranges exclude the paper's MM (-0.36) and ALM (-0.35) values.
for label, g in (("at least 10 units (main)", ov[ov.units >= 10]), ("more than 10 units (code)", ov[ov.units > 10])):
r, p = stats.pearsonr(g.cr_pre, g.cr_post)
print(f"{label}: {len(g)} sessions, Pearson r = {r:.3f}, p = {p:.2g}")
for area in xm.AREAS:
a = g[g.area == area]
print(f" {area}: {len(a)} sessions, r = {stats.pearsonr(a.cr_pre, a.cr_post)[0]:.2f}")
g = ov[ov.units > 10]
rs = [stats.pearsonr([c[k] for c in g.code_cr_pre], [c[k] for c in g.code_cr])[0] for k in range(200)]
print("Code's single-split procedure, 200 runs: r 2.5, 50, 97.5 percentiles", np.quantile(rs, [0.025, 0.5, 0.975]).round(3))
at least 10 units (main): 32 sessions, Pearson r = 0.688, p = 1.3e-05 S1: 8 sessions, r = 0.36 S2: 7 sessions, r = 0.70 MM: 5 sessions, r = 0.83 ALM: 12 sessions, r = 0.59 more than 10 units (code): 31 sessions, Pearson r = 0.686, p = 2e-05 S1: 8 sessions, r = 0.36 S2: 7 sessions, r = 0.70 MM: 5 sessions, r = 0.83 ALM: 11 sessions, r = 0.59 Code's single-split procedure, 200 runs: r 2.5, 50, 97.5 percentiles [0.573 0.653 0.734]
Result: reproduced. Over 32 sessions with at least 10 units, r = 0.688 (p = 1.3e-05); with the code's 31 sessions r = 0.686. The paper gives 0.68.
figs4ef-coding-dimensions and figs4h-choice-right-left¶
Stimulus CDs are aligned between rules in S1, S2 and MM but not ALM; choice CDs are not aligned in any area. Right-against-left choice CDs are still not well aligned.
For figs4h the criterion's "needed condition" was first read as each of the four rare categories (left
licks in touch blocks, right licks in light blocks, by stimulus); that leaves 1 of 10 S1 sessions, none in S2
and MM and 1 of 13 in ALM, so the criterion cannot decide. As the code averages over the stimulus types that exist (nanmean), the right-
and left-lick trials are pooled over stimuli and need at least 2 each; the first reading is shown as a
variant.
rng_cd = np.random.default_rng(606)
cd_rows, cd_boot = [], {}
for area in xm.AREAS:
ss = [s for s in main if s["area"] == area]
variants = {"main": (ss, "pooled"), "each_category": (ss, "each")}
if any(s["n_units"] <= 10 for s in ss):
variants["more_than_10_units"] = ([s for s in ss if s["n_units"] > 10], "pooled")
for vname, (sub, rule) in variants.items():
df = xm.bootstrap_coding_dimensions(sub, 1000 if vname == "main" else 300, rng_cd, rl_rule=rule)
cd_boot[(area, vname)] = df
for k in ("stim", "choice", "rl"):
if vname == "each_category" and k != "rl":
continue
tq, sq = df[k + "_true"].quantile([0.025, 0.975]), df[k + "_shuffled"].quantile([0.025, 0.975])
cd_rows.append({"area": area, "variant": vname, "sessions": len(sub), "cd": k,
"true": df[k + "_true"].mean(), "true_lo": tq.iloc[0], "true_hi": tq.iloc[1],
"shuffled": df[k + "_shuffled"].mean(), "shuffled_lo": sq.iloc[0], "shuffled_hi": sq.iloc[1],
"aligned": bool(tq.iloc[0] > sq.iloc[1] or sq.iloc[0] > tq.iloc[1]),
"resamples_with_value": int(df[k + "_true"].notna().sum())})
cd = pd.DataFrame(cd_rows)
print(cd.round(3).to_string(index=False))
rl_sessions = {area: sum(min(sum(xm.is_category(s["trials"], (b, st, d)).sum() for st in (1, 2)) for b in (1, 2) for d in (1, 2)) >= 2
for s in main if s["area"] == area) for area in xm.AREAS}
print("\nSessions with at least 2 right- and 2 left-lick trials in each block (pooled over stimuli):", rl_sessions)
rl_each = {area: sum(min(xm.is_category(s["trials"], k).sum() for k in ((1, 1, 2), (1, 2, 2), (2, 1, 1), (2, 2, 1))) >= 2
for s in main if s["area"] == area) for area in xm.AREAS}
print("Sessions with at least 2 trials in each of the four rare categories (first reading):", rl_each,
"of", {area: sum(s["area"] == area for s in main) for area in xm.AREAS})
area variant sessions cd true true_lo true_hi shuffled shuffled_lo shuffled_hi aligned resamples_with_value
S1 main 10 stim 0.557 0.397 0.714 0.252 0.137 0.384 True 1000
S1 main 10 choice 0.302 0.169 0.450 0.258 0.142 0.399 False 1000
S1 main 10 rl 0.438 0.286 0.585 0.243 0.128 0.376 False 1000
S1 each_category 10 rl 0.376 0.044 0.734 0.226 0.027 0.600 False 193
S1 more_than_10_units 8 stim 0.506 0.342 0.649 0.210 0.109 0.329 True 300
S1 more_than_10_units 8 choice 0.283 0.148 0.440 0.215 0.110 0.322 False 300
S1 more_than_10_units 8 rl 0.413 0.249 0.576 0.209 0.111 0.340 False 300
S2 main 8 stim 0.620 0.431 0.781 0.230 0.114 0.361 True 1000
S2 main 8 choice 0.249 0.132 0.399 0.230 0.121 0.360 False 1000
S2 main 8 rl 0.422 0.226 0.616 0.226 0.117 0.352 False 1000
S2 each_category 8 rl NaN NaN NaN NaN NaN NaN False 0
S2 more_than_10_units 7 stim 0.617 0.403 0.777 0.209 0.107 0.335 True 300
S2 more_than_10_units 7 choice 0.230 0.112 0.362 0.212 0.116 0.331 False 300
S2 more_than_10_units 7 rl 0.356 0.202 0.515 0.206 0.114 0.330 False 300
MM main 9 stim 0.366 0.218 0.521 0.282 0.150 0.441 False 1000
MM main 9 choice 0.304 0.165 0.465 0.286 0.154 0.448 False 1000
MM main 9 rl 0.445 0.268 0.621 0.275 0.153 0.424 False 1000
MM each_category 9 rl NaN NaN NaN NaN NaN NaN False 0
MM more_than_10_units 5 stim 0.295 0.139 0.502 0.201 0.090 0.349 False 300
MM more_than_10_units 5 choice 0.236 0.111 0.402 0.207 0.089 0.353 False 300
MM more_than_10_units 5 rl 0.516 0.287 0.704 0.205 0.091 0.353 False 300
ALM main 13 stim 0.348 0.230 0.474 0.228 0.140 0.323 False 1000
ALM main 13 choice 0.289 0.180 0.410 0.226 0.143 0.326 False 1000
ALM main 13 rl 0.307 0.196 0.424 0.223 0.138 0.321 False 1000
ALM each_category 13 rl 0.284 0.024 0.631 0.238 0.020 0.580 False 195
ALM more_than_10_units 11 stim 0.332 0.199 0.461 0.212 0.121 0.320 False 300
ALM more_than_10_units 11 choice 0.269 0.161 0.380 0.209 0.119 0.313 False 300
ALM more_than_10_units 11 rl 0.289 0.183 0.410 0.213 0.123 0.321 False 300
Sessions with at least 2 right- and 2 left-lick trials in each block (pooled over stimuli): {'S1': np.int64(10), 'S2': np.int64(8), 'MM': np.int64(9), 'ALM': np.int64(13)}
Sessions with at least 2 trials in each of the four rare categories (first reading): {'S1': np.int64(1), 'S2': np.int64(0), 'MM': np.int64(0), 'ALM': np.int64(1)} of {'S1': 10, 'S2': 8, 'MM': 9, 'ALM': 13}
m = cd[cd.variant == "main"].set_index(["cd", "area"])
expected_aligned = {("stim", "S1"): True, ("stim", "S2"): True, ("stim", "MM"): True, ("stim", "ALM"): False,
**{("choice", a): False for a in xm.AREAS}}
match = {k: m.loc[k, "aligned"] == v for k, v in expected_aligned.items()}
print("figs4ef judgements matching the paper:", sum(match.values()), "of 8;", {f"{k[0]} {k[1]}": bool(v) for k, v in match.items()})
paper_rl = {"S1": 0.39, "S2": 0.32, "MM": 0.35, "ALM": 0.28}
for a in xm.AREAS:
v = m.loc[("rl", a), "true"]
print(f"figs4h {a}: true mean {v:.3f} (paper {paper_rl[a]}), below 0.5: {v < 0.5}, within 0.1: {abs(v - paper_rl[a]) <= 0.1}")
figs4ef judgements matching the paper: 7 of 8; {'stim S1': True, 'stim S2': True, 'stim MM': False, 'stim ALM': True, 'choice S1': True, 'choice S2': True, 'choice MM': True, 'choice ALM': True}
figs4h S1: true mean 0.438 (paper 0.39), below 0.5: True, within 0.1: True
figs4h S2: true mean 0.422 (paper 0.32), below 0.5: True, within 0.1: False
figs4h MM: true mean 0.445 (paper 0.35), below 0.5: True, within 0.1: True
figs4h ALM: true mean 0.307 (paper 0.28), below 0.5: True, within 0.1: True
# figs4h S2: the bootstrap mean of the right-left dot product with four other seeds (1000 resamples each).
ss = [s for s in main if s["area"] == "S2"]
s2 = [cd_boot[("S2", "main")]["rl_true"].mean()]
for seed in (61, 62, 63, 64):
s2.append(xm.bootstrap_coding_dimensions(ss, 1000, np.random.default_rng(seed))["rl_true"].mean())
print("S2 right-left true mean, main seed then four others:", np.round(s2, 3),
"; within 0.1 of 0.32:", [bool(abs(v - 0.32) <= 0.1) for v in s2])
S2 right-left true mean, main seed then four others: [0.422 0.42 0.424 0.424 0.425] ; within 0.1 of 0.32: [False, True, False, False, False]
Result: partially reproduced. Seven of eight judgements match: stimulus CDs are aligned in S1 (0.557 against shuffled up to 0.384) and S2 (0.620 against up to 0.361) and not in ALM, and choice CDs are not aligned in any area, but the MM stimulus CD is not aligned (0.366, CI 0.218 to 0.521, against shuffled 0.150 to 0.441), where the paper reports 0.48 against 0.24.
Result: partially reproduced. Right-against-left choice CDs have true means below 0.5 in all four areas (S1 0.438, S2 0.422, MM 0.445, ALM 0.307), and S1, MM and ALM are within 0.1 of the paper; S2 is 0.422 against 0.32, more than 0.1 above it. Over the main seed and four others the S2 mean is 0.420 to 0.425 and within 0.1 of the paper in 1 of 5 (0.420); the outcome is judged on the main seed. The CIs overlap the shuffled ones in all four areas.
fig5b-first-hit-switches¶
88 switches into touch blocks and 91 into light blocks; mice switched before or immediately after the cue.
blocks = []
for s in main:
_, b = xm.transition_info(s["trials"])
blocks.append(b.assign(session=s["path"], area=s["area"]))
blocks = pd.concat(blocks, ignore_index=True)
nonfirst = blocks[~blocks.first_block]
with_hit = nonfirst[nonfirst.first_hit.notna()]
print("Blocks of each type, all:", blocks.groupby("block").size().to_dict(),
"; first blocks of a session:", blocks[blocks.first_block].groupby("block").size().to_dict())
print("Switches (non-first blocks) with a hit:", with_hit.groupby("block").size().to_dict(), "(paper: touch 88, light 91)")
c = with_hit[with_hit.cue.notna()].copy()
c["rel"] = c.first_hit.astype(float) - c.cue.astype(float)
c["at_or_before_cue_plus_1"] = c.rel <= 1
print("First hit relative to the cue row (rows of the file, removed trials not counted):")
print(c.groupby("block").agg(blocks_with_cue=("rel", "size"), fraction_by_cue_plus_1=("at_or_before_cue_plus_1", "mean"),
median_rel=("rel", "median"), min_rel=("rel", "min"), max_rel=("rel", "max")).round(3).to_string())
print("Blocks with a hit but no cue row in the file:", int(with_hit.cue.isna().sum()))
print("\nBlocks with a hit, first blocks included:", blocks[blocks.first_hit.notna()].groupby("block").size().to_dict(),
"; first blocks with a hit:", blocks[blocks.first_block & blocks.first_hit.notna()].groupby("block").size().to_dict())
est = []
for s in main:
_, b = xm.transition_info(s["trials"])
num = xm.estimated_trial_numbers(s["trials"])
b = b[~b.first_block & b.first_hit.notna() & b.cue.notna()]
est.append(pd.DataFrame({"block": b.block, "rel_rows": b.first_hit.astype(int) - b.cue.astype(int),
"rel_trials": num[b.first_hit.astype(int)] - num[b.cue.astype(int)]}))
est = pd.concat(est, ignore_index=True)
print("First hit relative to the cue in estimated trial numbers (removed trials counted from the gaps, xm.estimated_trial_numbers):")
print(est.groupby("block").agg(blocks=("rel_trials", "size"), fraction_by_cue_plus_1=("rel_trials", lambda x: (x <= 1).mean()),
median=("rel_trials", "median"), min=("rel_trials", "min"), max=("rel_trials", "max"),
differs_from_rows=("rel_rows", lambda x: (x != est.loc[x.index, "rel_trials"]).mean())).round(3).to_string())
print("Trials from block start to first hit (rows): median", (with_hit.first_hit.astype(float) - with_hit.start).median() + 1)
Blocks of each type, all: {'light': 91, 'touch': 88} ; first blocks of a session: {'light': 16, 'touch': 24}
Switches (non-first blocks) with a hit: {'light': 75, 'touch': 63} (paper: touch 88, light 91)
First hit relative to the cue row (rows of the file, removed trials not counted):
blocks_with_cue fraction_by_cue_plus_1 median_rel min_rel max_rel
block
light 66 0.652 1.0 -8.0 15.0
touch 56 0.571 1.0 -8.0 12.0
Blocks with a hit but no cue row in the file: 16
Blocks with a hit, first blocks included: {'light': 91, 'touch': 87} ; first blocks with a hit: {'light': 16, 'touch': 24}
First hit relative to the cue in estimated trial numbers (removed trials counted from the gaps, xm.estimated_trial_numbers):
blocks fraction_by_cue_plus_1 median min max differs_from_rows
block
light 66 0.621 1.0 -8 21 0.227
touch 56 0.554 1.0 -9 13 0.250
Trials from block start to first hit (rows): median 9.0
Result: not reproduced. Blocks after the first of a session that contain a hit, as the criterion and the code's GetFirstHitInfo define them, number 63 (touch) and 75 (light), outside tolerance of 88 and 91, so two of the three conditions fail. The third holds: more than half of the first hits come before the cue trial or on the trial after it (touch 0.571, light 0.652 in rows of the file; 0.554 and 0.621 in estimated trial numbers, which count the removed trials). The paper's numbers equal other counts: all blocks, first blocks included, are 88 and 91, and blocks with a hit, first blocks included, are 87 and 91.
fig5de-motor-states-track-switch and figs5-sensory-states-no-tracking¶
Kendall's tau of the touch-block probability over transitions: touch to light MM -0.39 [-0.67, -0.11], ALM -0.29 [-0.54, -0.04]; light to touch MM 0.37 [0.07, 0.63], ALM 0.27 [0.03, 0.49]; S1 and S2 CIs include 0.
rng_tr = np.random.default_rng(707)
true_tau = pd.DataFrame([{"area": s["area"], **{k: v for k, v in xm.transition_taus(s, rng_tr).items() if not k.endswith("values")}}
for s in main])
print("Mean tau over sessions, 10-fold estimate (no resampling):")
print(true_tau.groupby("area", sort=False)[["to_light", "to_touch", "pre_touch", "pre_light"]].mean().round(3).to_string())
rows = []
for area in xm.AREAS:
ss = [s for s in main if s["area"] == area]
boot = []
for b in range(500):
vals = [xm.transition_taus(ss[j], rng_tr, bootstrap=True) for j in rng_tr.integers(len(ss), size=len(ss))]
boot.append((np.nanmean([v["to_light"] for v in vals]), np.nanmean([v["to_touch"] for v in vals])))
boot = np.array(boot)
for i, name in enumerate(("to_light", "to_touch")):
lo, hi = np.nanquantile(boot[:, i], [0.025, 0.975])
rows.append({"area": area, "transition": name, "sessions": len(ss), "mean": np.nanmean(boot[:, i]), "lo": lo, "hi": hi,
"excludes_0": bool(lo > 0 or hi < 0)})
taus = pd.DataFrame(rows)
taus["paper"] = ["-0.16 [-0.43, 0.1]", "0.21 [-0.07, 0.5]", "-0.15 [-0.54, 0.21]", "0.25 [-0.17, 0.63]",
"-0.39 [-0.67, -0.11]", "0.37 [0.07, 0.63]", "-0.29 [-0.54, -0.04]", "0.27 [0.03, 0.49]"]
print("\nBootstrap (500 resamples of sessions, neurons and trials):")
print(taus.round(3).to_string(index=False))
Mean tau over sessions, 10-fold estimate (no resampling):
to_light to_touch pre_touch pre_light
area
ALM -0.487 0.436 0.597 0.391
S2 -0.333 0.583 0.537 0.425
S1 -0.267 0.333 0.551 0.439
MM -0.370 0.519 0.672 0.351
Bootstrap (500 resamples of sessions, neurons and trials): area transition sessions mean lo hi excludes_0 paper S1 to_light 10 -0.163 -0.433 0.117 False -0.16 [-0.43, 0.1] S1 to_touch 10 0.219 -0.067 0.500 False 0.21 [-0.07, 0.5] S2 to_light 8 -0.140 -0.500 0.250 False -0.15 [-0.54, 0.21] S2 to_touch 8 0.259 -0.105 0.620 False 0.25 [-0.17, 0.63] MM to_light 9 -0.401 -0.667 -0.111 True -0.39 [-0.67, -0.11] MM to_touch 9 0.382 0.074 0.630 True 0.37 [0.07, 0.63] ALM to_light 13 -0.302 -0.538 -0.051 True -0.29 [-0.54, -0.04] ALM to_touch 13 0.268 0.026 0.513 True 0.27 [0.03, 0.49]
n_trans = []
for s in main:
f, _ = xm.transition_info(s["trials"])
n_trans.append({"area": s["area"], "early": int(f.early.sum()), "late": int(f.late.sum()), "train": int(f.not_trans.sum())})
print("Transition trials per session (median, min):")
print(pd.DataFrame(n_trans).groupby("area", sort=False).agg(["median", "min"]).to_string())
Transition trials per session (median, min):
early late train
median min median min median min
area
ALM 6.0 2 16.0 5 197.0 146
S2 5.0 1 11.0 3 222.0 183
S1 6.5 3 20.5 11 207.0 172
MM 5.0 2 21.0 10 198.0 161
Result: reproduced. All four CIs exclude 0 with the paper's sign: touch to light MM -0.401 [-0.667, -0.111] and ALM -0.302 [-0.538, -0.051]; light to touch MM 0.382 [0.074, 0.630] and ALM 0.268 [0.026, 0.513], close to the paper's values.
Result: reproduced. All four S1 and S2 CIs include 0 (S1 -0.163 [-0.433, 0.117] and 0.219 [-0.067, 0.500]; S2 -0.140 [-0.500, 0.250] and 0.259 [-0.105, 0.620]). The 10-fold estimates without resampling are of the same size in S1 and S2 as in MM and ALM (S2 to touch 0.583, MM 0.519), so the absence rests on the wide CIs from 8 and 10 sessions.
Optogenetic inhibition (fig6c, figs6b, fig6d, figs7gh, fig6-ppc, fig6fg, fig6ij)¶
Changes are with laser minus without, per session, averaged within mouse and then over mice; 95% CIs from 10000 hierarchical resamples. "methods" resamples hit and false-alarm trials; "code" keeps the observed false-alarm rate, as the code does.
rng_opto = np.random.default_rng(808)
def counts_for(sub, key):
return (np.array([len(L[key]) for L in sub.licks]), np.array([int(np.sum(L[key])) for L in sub.licks]))
def change(sub, task, measure, o):
if task == "cross-modal":
hit, fa = {"tactile": ((1, 1, 1), (1, 2, 1)), "visual": ((2, 2, 2), (2, 1, 2)), "tactile_hit": ((1, 1, 1), None)}[measure]
c = {"h0": counts_for(sub, hit + (1,)), "ho": counts_for(sub, hit + (o,))}
if fa:
c.update({"f0": counts_for(sub, fa + (1,)), "fo": counts_for(sub, fa + (o,))})
else:
c = {"h0": counts_for(sub, (1, 1)), "ho": counts_for(sub, (1, o)), "f0": counts_for(sub, (2, 1)), "fo": counts_for(sub, (2, o))}
formula = (lambda p: (p["ho"] - p["fo"]) - (p["h0"] - p["f0"])) if "f0" in c else (lambda p: p["ho"] - p["h0"])
mouse = sub.mouse.to_numpy()
b = xm.hierarchical_bootstrap(mouse, c, formula, 10000, rng_opto)
bc = xm.hierarchical_bootstrap(mouse, c, formula, 10000, rng_opto, resample={"h0", "ho"})
return {"point": xm.point_estimate(mouse, c, formula), "boot_mean": b.mean(),
"lo": np.quantile(b, 0.025), "hi": np.quantile(b, 0.975),
"code_lo": np.quantile(bc, 0.025), "code_hi": np.quantile(bc, 0.975),
"sessions": len(sub), "mice": sub.mouse.nunique(), "trials_with_laser": int(c["ho"][0].sum())}
rows = []
for task, measures in (("cross-modal", ("tactile", "tactile_hit", "visual")), ("detection", ("sensitivity",))):
for measure in measures:
for o, window in ((2, "pre"), (3, "post")):
for cond in ("sham", "S1/S2", "MM", "ALM", "AMM", "PPC"):
sub = optos[(optos.task == task) & (optos.condition == cond)]
rows.append({"task": task, "measure": measure, "window": window, "condition": cond, **change(sub, task, measure, o)})
opto_res = pd.DataFrame(rows)
opto_res["ci_excludes_0"] = np.where(opto_res.hi < 0, "below", np.where(opto_res.lo > 0, "above", "includes 0"))
opto_res["code_ci_excludes_0"] = np.where(opto_res.code_hi < 0, "below", np.where(opto_res.code_lo > 0, "above", "includes 0"))
print(opto_res.round(3).to_string(index=False))
task measure window condition point boot_mean lo hi code_lo code_hi sessions mice trials_with_laser ci_excludes_0 code_ci_excludes_0 cross-modal tactile pre sham 0.011 0.010 -0.126 0.144 -0.118 0.134 28 7 297 includes 0 includes 0 cross-modal tactile pre S1/S2 -0.178 -0.179 -0.468 0.094 -0.457 0.080 10 4 98 includes 0 includes 0 cross-modal tactile pre MM -0.390 -0.388 -0.658 -0.123 -0.642 -0.135 11 4 88 below below cross-modal tactile pre ALM -0.373 -0.372 -0.707 0.005 -0.682 -0.018 10 4 73 includes 0 below cross-modal tactile pre AMM -0.062 -0.062 -0.216 0.077 -0.193 0.061 21 7 201 includes 0 includes 0 cross-modal tactile pre PPC -0.232 -0.232 -0.408 -0.052 -0.399 -0.060 21 7 174 below below cross-modal tactile post sham 0.028 0.029 -0.111 0.168 -0.099 0.159 28 7 300 includes 0 includes 0 cross-modal tactile post S1/S2 -0.389 -0.393 -0.699 -0.080 -0.677 -0.092 10 4 87 below below cross-modal tactile post MM -0.487 -0.487 -0.612 -0.327 -0.598 -0.334 11 4 100 below below cross-modal tactile post ALM -0.402 -0.402 -0.568 -0.253 -0.545 -0.274 10 4 98 below below cross-modal tactile post AMM -0.306 -0.307 -0.516 -0.111 -0.495 -0.118 21 7 196 below below cross-modal tactile post PPC -0.218 -0.218 -0.466 -0.009 -0.435 -0.034 21 7 195 below below cross-modal tactile_hit pre sham 0.022 0.021 -0.076 0.108 -0.074 0.110 28 7 297 includes 0 includes 0 cross-modal tactile_hit pre S1/S2 -0.147 -0.147 -0.303 0.010 -0.306 0.010 10 4 98 includes 0 includes 0 cross-modal tactile_hit pre MM -0.318 -0.318 -0.492 -0.152 -0.495 -0.151 11 4 88 below below cross-modal tactile_hit pre ALM -0.227 -0.227 -0.463 -0.007 -0.466 0.002 10 4 73 below includes 0 cross-modal tactile_hit pre AMM -0.048 -0.049 -0.158 0.053 -0.158 0.055 21 7 201 includes 0 includes 0 cross-modal tactile_hit pre PPC -0.205 -0.204 -0.365 -0.045 -0.359 -0.049 21 7 174 below below cross-modal tactile_hit post sham 0.050 0.050 -0.041 0.141 -0.042 0.139 28 7 300 includes 0 includes 0 cross-modal tactile_hit post S1/S2 -0.341 -0.340 -0.566 -0.098 -0.564 -0.105 10 4 87 below below cross-modal tactile_hit post MM -0.544 -0.546 -0.748 -0.296 -0.748 -0.294 11 4 100 below below cross-modal tactile_hit post ALM -0.471 -0.472 -0.714 -0.215 -0.712 -0.220 10 4 98 below below cross-modal tactile_hit post AMM -0.304 -0.304 -0.468 -0.147 -0.471 -0.153 21 7 196 below below cross-modal tactile_hit post PPC 0.038 0.037 -0.149 0.193 -0.149 0.195 21 7 195 includes 0 includes 0 cross-modal visual pre sham -0.061 -0.061 -0.186 0.063 -0.178 0.054 28 7 200 includes 0 includes 0 cross-modal visual pre S1/S2 -0.100 -0.099 -0.282 0.070 -0.271 0.050 10 4 60 includes 0 includes 0 cross-modal visual pre MM -0.383 -0.382 -0.603 -0.173 -0.591 -0.188 11 4 76 below below cross-modal visual pre ALM -0.207 -0.207 -0.494 0.050 -0.498 0.043 10 4 60 includes 0 includes 0 cross-modal visual pre AMM -0.221 -0.221 -0.356 -0.082 -0.346 -0.085 21 7 173 below below cross-modal visual pre PPC -0.173 -0.174 -0.333 -0.031 -0.309 -0.052 21 7 142 below below cross-modal visual post sham -0.081 -0.082 -0.237 0.066 -0.228 0.059 28 7 190 includes 0 includes 0 cross-modal visual post S1/S2 -0.197 -0.198 -0.460 0.045 -0.448 0.046 10 4 60 includes 0 includes 0 cross-modal visual post MM -0.673 -0.672 -0.839 -0.518 -0.825 -0.529 11 4 70 below below cross-modal visual post ALM -0.631 -0.631 -0.870 -0.385 -0.839 -0.391 10 4 91 below below cross-modal visual post AMM -0.542 -0.543 -0.725 -0.353 -0.717 -0.353 21 7 144 below below cross-modal visual post PPC -0.301 -0.302 -0.555 -0.070 -0.538 -0.074 21 7 165 below below detection sensitivity pre sham 0.066 0.066 -0.100 0.240 -0.071 0.211 9 5 207 includes 0 includes 0 detection sensitivity pre S1/S2 -0.038 -0.037 -0.373 0.243 -0.337 0.178 3 3 63 includes 0 includes 0 detection sensitivity pre MM -0.094 -0.094 -0.270 0.087 -0.230 0.055 5 3 130 includes 0 includes 0 detection sensitivity pre ALM -0.113 -0.114 -0.320 0.112 -0.281 0.067 4 3 83 includes 0 includes 0 detection sensitivity pre AMM -0.005 -0.007 -0.264 0.198 -0.239 0.158 4 3 94 includes 0 includes 0 detection sensitivity pre PPC -0.004 -0.003 -0.175 0.159 -0.119 0.103 4 3 90 includes 0 includes 0 detection sensitivity post sham 0.034 0.035 -0.152 0.225 -0.130 0.200 9 5 206 includes 0 includes 0 detection sensitivity post S1/S2 -0.448 -0.448 -0.643 -0.251 -0.593 -0.304 3 3 69 below below detection sensitivity post MM -0.434 -0.435 -0.628 -0.233 -0.593 -0.259 5 3 121 below below detection sensitivity post ALM -0.266 -0.265 -0.471 -0.065 -0.446 -0.098 4 3 80 below below detection sensitivity post AMM -0.106 -0.107 -0.331 0.127 -0.301 0.103 4 3 97 includes 0 includes 0 detection sensitivity post PPC -0.079 -0.079 -0.282 0.106 -0.238 0.059 4 3 98 includes 0 includes 0
# Monte Carlo spread of the CI bounds: the same hierarchical bootstraps (10000 resamples) with ten other seeds.
judged = [("cross-modal", "tactile", "pre", c) for c in ("S1/S2", "MM", "ALM", "sham", "AMM", "PPC")] + \
[("cross-modal", "tactile_hit", "pre", c) for c in ("S1/S2", "MM", "ALM")] + \
[("cross-modal", "tactile", "post", c) for c in ("S1/S2", "MM", "ALM", "PPC")] + \
[("cross-modal", "visual", w, c) for w in ("pre", "post") for c in ("S1/S2", "MM", "ALM", "AMM", "PPC")] + \
[("detection", "sensitivity", w, c) for w in ("pre", "post") for c in ("S1/S2", "MM", "ALM", "AMM", "PPC")]
main_rng = rng_opto
mc_rows = []
for seed in range(21, 31):
rng_opto = np.random.default_rng(seed)
for task, measure, window, cond in judged:
sub = optos[(optos.task == task) & (optos.condition == cond)]
r = change(sub, task, measure, 2 if window == "pre" else 3)
mc_rows.append({"task": task, "measure": measure, "window": window, "condition": cond, "seed": seed,
"lo": r["lo"], "hi": r["hi"], "code_lo": r["code_lo"], "code_hi": r["code_hi"]})
rng_opto = main_rng
mc = pd.DataFrame(mc_rows)
side = lambda lo, hi: np.where(hi < 0, "below", np.where(lo > 0, "above", "includes 0"))
mc["call"], mc["code_call"] = side(mc.lo, mc.hi), side(mc.code_lo, mc.code_hi)
g = mc.groupby(["task", "measure", "window", "condition"], sort=False)
mc_sum = pd.DataFrame({"lo_mean": g.lo.mean(), "lo_sd": g.lo.std(), "hi_mean": g.hi.mean(), "hi_sd": g.hi.std(),
"hi_min": g.hi.min(), "hi_max": g.hi.max(), "seeds_below_0": g.call.apply(lambda x: int((x == "below").sum())),
"code_hi_mean": g.code_hi.mean(), "code_hi_sd": g.code_hi.std(),
"code_seeds_below_0": g.code_call.apply(lambda x: int((x == "below").sum()))})
main_call = opto_res.set_index(["task", "measure", "window", "condition"])
mc_sum["main_seed_call"] = main_call.loc[mc_sum.index, "ci_excludes_0"].to_numpy()
mc_sum["calls_that_differ_from_main"] = g.call.apply(lambda x: int((x != main_call.loc[x.name, "ci_excludes_0"]).sum())).to_numpy()
print("CI bounds over ten other seeds (mean and SD of each bound; the SD is the Monte Carlo error of one run):")
print(mc_sum.round(3).to_string())
CI bounds over ten other seeds (mean and SD of each bound; the SD is the Monte Carlo error of one run):
lo_mean lo_sd hi_mean hi_sd hi_min hi_max seeds_below_0 code_hi_mean code_hi_sd code_seeds_below_0 main_seed_call calls_that_differ_from_main
task measure window condition
cross-modal tactile pre S1/S2 -0.473 0.005 0.099 0.004 0.094 0.105 0 0.075 0.002 0 includes 0 0
MM -0.661 0.002 -0.121 0.003 -0.128 -0.118 10 -0.133 0.003 10 below 0
ALM -0.710 0.004 -0.001 0.003 -0.006 0.001 7 -0.018 0.004 10 includes 0 7
sham -0.128 0.003 0.145 0.002 0.141 0.147 0 0.134 0.002 0 includes 0 0
AMM -0.212 0.002 0.076 0.001 0.073 0.078 0 0.062 0.001 0 includes 0 0
PPC -0.410 0.003 -0.051 0.002 -0.054 -0.048 10 -0.059 0.003 10 below 0
tactile_hit pre S1/S2 -0.304 0.002 0.013 0.002 0.010 0.016 0 0.011 0.002 0 includes 0 0
MM -0.496 0.003 -0.152 0.002 -0.155 -0.150 10 -0.152 0.003 10 below 0
ALM -0.465 0.002 -0.004 0.002 -0.007 -0.002 10 -0.003 0.002 9 below 0
tactile post S1/S2 -0.692 0.004 -0.075 0.004 -0.080 -0.067 10 -0.091 0.004 10 below 0
MM -0.611 0.001 -0.328 0.004 -0.336 -0.324 10 -0.341 0.003 10 below 0
ALM -0.568 0.002 -0.253 0.002 -0.256 -0.249 10 -0.272 0.002 10 below 0
PPC -0.467 0.004 -0.011 0.003 -0.017 -0.008 10 -0.032 0.001 10 below 0
visual pre S1/S2 -0.286 0.003 0.069 0.001 0.067 0.071 0 0.051 0.001 0 includes 0 0
MM -0.606 0.003 -0.176 0.003 -0.179 -0.172 10 -0.183 0.003 10 below 0
ALM -0.501 0.005 0.048 0.004 0.043 0.059 0 0.041 0.002 0 includes 0 0
AMM -0.356 0.001 -0.078 0.002 -0.082 -0.075 10 -0.086 0.002 10 below 0
PPC -0.332 0.002 -0.030 0.002 -0.034 -0.026 10 -0.053 0.001 10 below 0
post S1/S2 -0.461 0.003 0.049 0.003 0.046 0.053 0 0.043 0.002 0 includes 0 0
MM -0.840 0.002 -0.520 0.001 -0.523 -0.518 10 -0.526 0.003 10 below 0
ALM -0.873 0.004 -0.382 0.003 -0.386 -0.377 10 -0.394 0.003 10 below 0
AMM -0.725 0.002 -0.350 0.001 -0.352 -0.348 10 -0.358 0.003 10 below 0
PPC -0.553 0.003 -0.066 0.003 -0.072 -0.060 10 -0.071 0.004 10 below 0
detection sensitivity pre S1/S2 -0.372 0.003 0.246 0.002 0.242 0.250 0 0.179 0.001 0 includes 0 0
MM -0.272 0.002 0.087 0.002 0.085 0.091 0 0.056 0.002 0 includes 0 0
ALM -0.321 0.003 0.117 0.002 0.114 0.119 0 0.068 0.002 0 includes 0 0
AMM -0.261 0.005 0.197 0.002 0.192 0.199 0 0.158 0.001 0 includes 0 0
PPC -0.179 0.002 0.159 0.002 0.155 0.161 0 0.101 0.001 0 includes 0 0
post S1/S2 -0.650 0.003 -0.250 0.002 -0.254 -0.247 10 -0.306 0.002 10 below 0
MM -0.625 0.003 -0.231 0.003 -0.236 -0.226 10 -0.257 0.003 10 below 0
ALM -0.470 0.003 -0.067 0.002 -0.071 -0.063 10 -0.101 0.001 10 below 0
AMM -0.332 0.002 0.130 0.003 0.123 0.134 0 0.104 0.003 0 includes 0 0
PPC -0.278 0.002 0.106 0.003 0.100 0.110 0 0.060 0.001 0 includes 0 0
def show(task, measure, window, conds, paper):
d = opto_res[(opto_res.task == task) & (opto_res.measure == measure) & (opto_res.window == window)].set_index("condition").loc[conds]
d = d[["sessions", "mice", "point", "lo", "hi", "ci_excludes_0", "code_lo", "code_hi", "code_ci_excludes_0"]].copy()
d["paper_ci"] = paper
print(f"{task}, {measure}, laser {window}-stimulus")
print(d.round(3).to_string())
print()
show("cross-modal", "tactile", "pre", ["S1/S2", "MM", "ALM", "sham", "AMM"],
["[-0.47, 0.08]", "[-0.65, -0.12]", "[-0.68, -0.02]", "[-0.12, 0.13]", "[-0.19, 0.06]"])
cross-modal, tactile, laser pre-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
S1/S2 10 4 -0.178 -0.468 0.094 includes 0 -0.457 0.080 includes 0 [-0.47, 0.08]
MM 11 4 -0.390 -0.658 -0.123 below -0.642 -0.135 below [-0.65, -0.12]
ALM 10 4 -0.373 -0.707 0.005 includes 0 -0.682 -0.018 below [-0.68, -0.02]
sham 28 7 0.011 -0.126 0.144 includes 0 -0.118 0.134 includes 0 [-0.12, 0.13]
AMM 21 7 -0.062 -0.216 0.077 includes 0 -0.193 0.061 includes 0 [-0.19, 0.06]
Result: partially reproduced. Pre-stimulus inhibition of MM lowers tactile sensitivity (-0.390, CI -0.658 to -0.123), and S1/S2, sham and AMM CIs include 0; for ALM the change is -0.373 but its CI reaches 0.005 when false alarms are also resampled. The outcome is judged on the main seed. The ALM upper bound is at the edge: over ten other seeds it averages -0.001 (SD 0.003, -0.006 to 0.001) and is below 0 in 7 of 10, so the ALM interval touches 0 under the Methods' resampling. With the code's resampling the ALM CI is -0.682 to -0.018, as in the paper (-0.68 to -0.02), and below 0 in all ten other seeds.
show("cross-modal", "tactile_hit", "pre", ["S1/S2", "MM", "ALM"], ["[-0.31, 0.01]", "[-0.49, -0.15]", "[-0.46, -0.07]"])
cross-modal, tactile_hit, laser pre-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
S1/S2 10 4 -0.147 -0.303 0.010 includes 0 -0.306 0.010 includes 0 [-0.31, 0.01]
MM 11 4 -0.318 -0.492 -0.152 below -0.495 -0.151 below [-0.49, -0.15]
ALM 10 4 -0.227 -0.463 -0.007 below -0.466 0.002 includes 0 [-0.46, -0.07]
Result: reproduced. The tactile hit rate falls with MM (-0.318, CI -0.492 to -0.152) and ALM (-0.227, -0.463 to -0.007) inhibition, and the S1/S2 CI includes 0 (-0.303 to 0.010). The ALM upper bound is close to 0 but below it in all ten other seeds (mean -0.004, SD 0.002); the "code" column, the same procedure with another draw, gives 0.002 here and is below 0 in 9 of 10 other seeds.
show("cross-modal", "tactile", "post", ["S1/S2", "MM", "ALM"], ["[-0.67, -0.08]", "[-0.6, -0.34]", "[-0.55, -0.27]"])
cross-modal, tactile, laser post-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
S1/S2 10 4 -0.389 -0.699 -0.080 below -0.677 -0.092 below [-0.67, -0.08]
MM 11 4 -0.487 -0.612 -0.327 below -0.598 -0.334 below [-0.6, -0.34]
ALM 10 4 -0.402 -0.568 -0.253 below -0.545 -0.274 below [-0.55, -0.27]
Result: reproduced. Inhibition from the stimulus onset lowers tactile sensitivity for S1/S2 (-0.389, CI -0.699 to -0.080), MM (-0.487, -0.612 to -0.327) and ALM (-0.402, -0.568 to -0.253).
show("cross-modal", "visual", "pre", ["MM", "AMM", "ALM", "S1/S2"], ["[-0.6, -0.18]", "[-0.35, -0.08]", "[-0.5, 0.04]", "[-0.27, 0.05]"])
show("cross-modal", "visual", "post", ["MM", "ALM", "AMM", "S1/S2"], ["[-0.83, -0.52]", "[-0.84, -0.39]", "[-0.72, -0.36]", "[-0.44, 0.04]"])
cross-modal, visual, laser pre-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
MM 11 4 -0.383 -0.603 -0.173 below -0.591 -0.188 below [-0.6, -0.18]
AMM 21 7 -0.221 -0.356 -0.082 below -0.346 -0.085 below [-0.35, -0.08]
ALM 10 4 -0.207 -0.494 0.050 includes 0 -0.498 0.043 includes 0 [-0.5, 0.04]
S1/S2 10 4 -0.100 -0.282 0.070 includes 0 -0.271 0.050 includes 0 [-0.27, 0.05]
cross-modal, visual, laser post-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
MM 11 4 -0.673 -0.839 -0.518 below -0.825 -0.529 below [-0.83, -0.52]
ALM 10 4 -0.631 -0.870 -0.385 below -0.839 -0.391 below [-0.84, -0.39]
AMM 21 7 -0.542 -0.725 -0.353 below -0.717 -0.353 below [-0.72, -0.36]
S1/S2 10 4 -0.197 -0.460 0.045 includes 0 -0.448 0.046 includes 0 [-0.44, 0.04]
Result: reproduced. All eight judgements match: pre-stimulus MM (-0.603 to -0.173) and AMM (-0.356 to -0.082) CIs are below 0, ALM and S1/S2 include 0; post-stimulus MM, ALM and AMM are below 0 and S1/S2 includes 0 (-0.460 to 0.045).
for measure, window, paper in (("tactile", "pre", "[-0.4, -0.06]"), ("tactile", "post", "[-0.43, -0.04]"),
("visual", "pre", "[-0.31, -0.05]"), ("visual", "post", "[-0.54, -0.07]")):
show("cross-modal", measure, window, ["PPC"], [paper])
cross-modal, tactile, laser pre-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
PPC 21 7 -0.232 -0.408 -0.052 below -0.399 -0.06 below [-0.4, -0.06]
cross-modal, tactile, laser post-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
PPC 21 7 -0.218 -0.466 -0.009 below -0.435 -0.034 below [-0.43, -0.04]
cross-modal, visual, laser pre-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
PPC 21 7 -0.173 -0.333 -0.031 below -0.309 -0.052 below [-0.31, -0.05]
cross-modal, visual, laser post-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
PPC 21 7 -0.301 -0.555 -0.07 below -0.538 -0.074 below [-0.54, -0.07]
Result: reproduced. PPC inhibition lowers tactile sensitivity before (-0.408 to -0.052) and after (-0.466 to -0.009) the stimulus onset and visual sensitivity before (-0.333 to -0.031) and after (-0.555 to -0.070). The closest bound, tactile post-stimulus, is below 0 in all ten other seeds (mean -0.011, SD 0.003).
show("detection", "sensitivity", "pre", ["S1/S2", "MM", "ALM", "AMM", "PPC", "sham"],
["[-0.34, 0.18]", "[-0.23, 0.06]", "[-0.28, 0.07]", "[-0.25, 0.16]", "[-0.12, 0.1]", ""])
show("detection", "sensitivity", "post", ["S1/S2", "MM", "ALM", "AMM", "PPC", "sham"],
["[-0.59, -0.31]", "[-0.59, -0.25]", "[-0.45, -0.1]", "[-0.17, 0.18]", "[-0.14, 0.3]", ""])
detection, sensitivity, laser pre-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
S1/S2 3 3 -0.038 -0.373 0.243 includes 0 -0.337 0.178 includes 0 [-0.34, 0.18]
MM 5 3 -0.094 -0.270 0.087 includes 0 -0.230 0.055 includes 0 [-0.23, 0.06]
ALM 4 3 -0.113 -0.320 0.112 includes 0 -0.281 0.067 includes 0 [-0.28, 0.07]
AMM 4 3 -0.005 -0.264 0.198 includes 0 -0.239 0.158 includes 0 [-0.25, 0.16]
PPC 4 3 -0.004 -0.175 0.159 includes 0 -0.119 0.103 includes 0 [-0.12, 0.1]
sham 9 5 0.066 -0.100 0.240 includes 0 -0.071 0.211 includes 0
detection, sensitivity, laser post-stimulus
sessions mice point lo hi ci_excludes_0 code_lo code_hi code_ci_excludes_0 paper_ci
condition
S1/S2 3 3 -0.448 -0.643 -0.251 below -0.593 -0.304 below [-0.59, -0.31]
MM 5 3 -0.434 -0.628 -0.233 below -0.593 -0.259 below [-0.59, -0.25]
ALM 4 3 -0.266 -0.471 -0.065 below -0.446 -0.098 below [-0.45, -0.1]
AMM 4 3 -0.106 -0.331 0.127 includes 0 -0.301 0.103 includes 0 [-0.17, 0.18]
PPC 4 3 -0.079 -0.282 0.106 includes 0 -0.238 0.059 includes 0 [-0.14, 0.3]
sham 9 5 0.034 -0.152 0.225 includes 0 -0.130 0.200 includes 0
Result: reproduced. In the detection task, pre-stimulus inhibition CIs include 0 for S1/S2, MM and ALM, and post-stimulus CIs are below 0 (S1/S2 -0.643 to -0.251, MM -0.628 to -0.233, ALM -0.471 to -0.065); AMM and PPC include 0 in both windows.
rows = []
for task in ("cross-modal", "detection"):
for duration in ("short", "long"):
for cond in ("sham", "S1/S2", "MM", "ALM", "AMM", "PPC"):
sub = optos[(optos.task == task) & (optos.condition == cond)]
if task == "cross-modal":
iti, rw = ((1, 3, 1, 1), (1, 3, 1, 2)) if duration == "short" else ((1, 4, 1, 1), (1, 4, 1, 3))
else:
iti, rw = ((4, 1), (4, 3)) if duration == "short" else ((5, 1), (5, 3))
c = {"iti": counts_for(sub, iti), "rw": counts_for(sub, rw)}
f = lambda p: p["rw"] - p["iti"]
b = xm.hierarchical_bootstrap(sub.mouse.to_numpy(), c, f, 10000, rng_opto)
lo, hi = np.quantile(b, [0.025, 0.975])
rows.append({"task": task, "laser": duration, "condition": cond, "laser_only_trials": int(c["rw"][0].sum()),
"p_iti": c["iti"][1].sum() / max(c["iti"][0].sum(), 1), "p_response": c["rw"][1].sum() / max(c["rw"][0].sum(), 1),
"point": xm.point_estimate(sub.mouse.to_numpy(), c, f), "lo": lo, "hi": hi,
"includes_0": bool(lo <= 0 <= hi)})
for cond in ("sham", "S1/S2", "MM", "ALM", "AMM", "PPC"):
sub = optos[(optos.task == "detection") & (optos.condition == cond)]
c = {"iti": tuple(a + b for a, b in zip(counts_for(sub, (4, 1)), counts_for(sub, (5, 1)))),
"rw": tuple(a + b for a, b in zip(counts_for(sub, (4, 3)), counts_for(sub, (5, 3))))}
f = lambda p: p["rw"] - p["iti"]
b = xm.hierarchical_bootstrap(sub.mouse.to_numpy(), c, f, 10000, rng_opto)
lo, hi = np.quantile(b, [0.025, 0.975])
rows.append({"task": "detection", "laser": "pooled", "condition": cond, "laser_only_trials": int(c["rw"][0].sum()),
"p_iti": c["iti"][1].sum() / max(c["iti"][0].sum(), 1), "p_response": c["rw"][1].sum() / max(c["rw"][0].sum(), 1),
"point": xm.point_estimate(sub.mouse.to_numpy(), c, f), "lo": lo, "hi": hi, "includes_0": bool(lo <= 0 <= hi)})
laser_only = pd.DataFrame(rows)
paper_lo = {("cross-modal", "S1/S2"): "[-0.18, 0.24]", ("cross-modal", "MM"): "[-0.31, 0.03]", ("cross-modal", "ALM"): "[-0.24, 0.16]",
("detection", "S1/S2"): "[-0.13, 0.31]", ("detection", "MM"): "[-0.06, 0.45]", ("detection", "ALM"): "[-0.18, 0.34]"}
laser_only["paper_ci"] = [paper_lo.get((t, c), "") for t, c in zip(laser_only.task, laser_only.condition)]
print(laser_only.round(3).to_string(index=False))
key = laser_only[laser_only.condition.isin(["S1/S2", "MM", "ALM"]) & (laser_only.laser != "pooled")]
print("\nS1/S2, MM and ALM CIs including 0:", int(key.includes_0.sum()), "of", len(key))
task laser condition laser_only_trials p_iti p_response point lo hi includes_0 paper_ci cross-modal short sham 403 0.218 0.213 -0.000 -0.084 0.090 True cross-modal short S1/S2 163 0.196 0.202 0.005 -0.117 0.128 True [-0.18, 0.24] cross-modal short MM 124 0.242 0.226 0.005 -0.144 0.156 True [-0.31, 0.03] cross-modal short ALM 131 0.168 0.305 0.114 -0.058 0.284 True [-0.24, 0.16] cross-modal short AMM 308 0.198 0.214 -0.021 -0.192 0.108 True cross-modal short PPC 298 0.141 0.168 -0.001 -0.113 0.101 True cross-modal long sham 429 0.184 0.200 0.014 -0.093 0.114 True cross-modal long S1/S2 173 0.179 0.225 0.033 -0.180 0.245 True [-0.18, 0.24] cross-modal long MM 138 0.210 0.109 -0.143 -0.312 0.030 True [-0.31, 0.03] cross-modal long ALM 149 0.221 0.242 -0.040 -0.243 0.152 True [-0.24, 0.16] cross-modal long AMM 332 0.154 0.145 0.012 -0.095 0.146 True cross-modal long PPC 331 0.160 0.411 0.096 -0.102 0.290 True detection short sham 236 0.347 0.394 0.053 -0.092 0.200 True detection short S1/S2 62 0.290 0.435 0.140 -0.042 0.309 True [-0.13, 0.31] detection short MM 141 0.333 0.553 0.258 0.045 0.483 False [-0.06, 0.45] detection short ALM 99 0.384 0.586 0.184 -0.037 0.406 True [-0.18, 0.34] detection short AMM 120 0.325 0.467 0.135 -0.090 0.356 True detection short PPC 97 0.320 0.505 0.200 0.026 0.383 False detection long sham 220 0.386 0.423 0.062 -0.131 0.277 True detection long S1/S2 61 0.377 0.361 -0.014 -0.197 0.167 True [-0.13, 0.31] detection long MM 146 0.377 0.411 0.024 -0.240 0.257 True [-0.06, 0.45] detection long ALM 111 0.360 0.324 -0.042 -0.309 0.231 True [-0.18, 0.34] detection long AMM 123 0.374 0.504 0.108 -0.077 0.282 True detection long PPC 99 0.394 0.596 0.200 0.017 0.383 False detection pooled sham 456 0.366 0.408 0.051 -0.057 0.164 True detection pooled S1/S2 123 0.333 0.398 0.060 -0.074 0.194 True [-0.13, 0.31] detection pooled MM 287 0.355 0.481 0.149 0.020 0.274 False [-0.06, 0.45] detection pooled ALM 210 0.371 0.448 0.068 -0.114 0.291 True [-0.18, 0.34] detection pooled AMM 243 0.350 0.486 0.125 -0.005 0.257 True detection pooled PPC 196 0.357 0.551 0.201 0.061 0.353 False S1/S2, MM and ALM CIs including 0: 11 of 12
# Monte Carlo spread of the laser-only CIs: the same hierarchical bootstraps (10000 resamples) with ten other seeds.
lo_rows = []
for seed in range(21, 31):
rng_lo = np.random.default_rng(seed)
for task in ("cross-modal", "detection"):
for duration in ("short", "long"):
for cond in ("S1/S2", "MM", "ALM"):
sub = optos[(optos.task == task) & (optos.condition == cond)]
if task == "cross-modal":
iti, rw = ((1, 3, 1, 1), (1, 3, 1, 2)) if duration == "short" else ((1, 4, 1, 1), (1, 4, 1, 3))
else:
iti, rw = ((4, 1), (4, 3)) if duration == "short" else ((5, 1), (5, 3))
c = {"iti": counts_for(sub, iti), "rw": counts_for(sub, rw)}
b = xm.hierarchical_bootstrap(sub.mouse.to_numpy(), c, lambda p: p["rw"] - p["iti"], 10000, rng_lo)
lo, hi = np.quantile(b, [0.025, 0.975])
lo_rows.append({"task": task, "laser": duration, "condition": cond, "seed": seed, "lo": lo, "hi": hi,
"includes_0": bool(lo <= 0 <= hi)})
lo_mc = pd.DataFrame(lo_rows)
g = lo_mc.groupby(["task", "laser", "condition"], sort=False)
lo_sum = pd.DataFrame({"lo_mean": g.lo.mean(), "lo_sd": g.lo.std(), "lo_min": g.lo.min(), "lo_max": g.lo.max(),
"hi_mean": g.hi.mean(), "hi_sd": g.hi.std(), "seeds_including_0": g.includes_0.sum()})
main_lo = key.set_index(["task", "laser", "condition"]).includes_0
lo_sum["main_seed_includes_0"] = main_lo.loc[lo_sum.index].to_numpy()
print("Laser-only CI bounds over ten other seeds:")
print(lo_sum.round(3).to_string())
Laser-only CI bounds over ten other seeds:
lo_mean lo_sd lo_min lo_max hi_mean hi_sd seeds_including_0 main_seed_includes_0
task laser condition
cross-modal short S1/S2 -0.119 0.002 -0.122 -0.117 0.130 0.002 10 True
MM -0.140 0.002 -0.142 -0.136 0.153 0.001 10 True
ALM -0.060 0.002 -0.063 -0.057 0.289 0.002 10 True
long S1/S2 -0.180 0.003 -0.187 -0.175 0.246 0.002 10 True
MM -0.312 0.003 -0.317 -0.308 0.033 0.002 10 True
ALM -0.244 0.003 -0.248 -0.240 0.156 0.001 10 True
detection short S1/S2 -0.038 0.004 -0.044 -0.034 0.311 0.002 10 True
MM 0.045 0.004 0.040 0.053 0.483 0.004 0 False
ALM -0.036 0.002 -0.037 -0.031 0.404 0.003 10 True
long S1/S2 -0.200 0.004 -0.206 -0.192 0.171 0.003 10 True
MM -0.240 0.002 -0.243 -0.236 0.252 0.002 10 True
ALM -0.314 0.001 -0.314 -0.312 0.235 0.004 10 True
Result: not reproduced. 11 of the 12 CIs include 0, but on short laser-only trials of the detection task MM inhibition raises the right-lick probability (0.258, CI 0.045 to 0.483); over ten other seeds its lower bound is 0.040 to 0.053, and the other eleven CIs include 0 in every seed. All six cross-modal CIs and all long-laser CIs include 0. The windows are the code's (GetBehavInfo and GetBehavInfo_tDetection): in the cross-modal task licks on short laser-only trials are scored 0 to 2 s after the laser offset, in the detection task -0.8 to 1.2 s from it, which covers the laser. The paper's cross-modal CIs match the long-laser rows (MM -0.312 to 0.030 against -0.31 to 0.03); its detection CIs match neither the short, the long nor the pooled rows (the code's "all laser trials"; pooled MM 0.020 to 0.274 also excludes 0). A rise in licking does not bear on the paper's point that inhibition does not prevent licking.
Summary¶
| Claim | Outcome |
|---|---|
| fig1e-recording-counts | reproduced |
| fig6-session-counts | reproduced |
| fig1d-performance | reproduced |
| fig1d-outcome-fractions | reproduced |
| fig1h-thit-tcr-selective | partially reproduced |
| fig2c-rule-prestim-units | partially reproduced |
| fig2d-stimulus-prestim-units | reproduced |
| fig2e-stimulus-poststim-units | reproduced |
| fig2f-prestim-predicts-touch-response | reproduced |
| fig3b-rule-decoding | reproduced |
| fig3c-rule-decoding-motor-stronger | reproduced |
| fig3de-stimulus-decoding-controls | reproduced |
| figs3-svm-rf-decoding | reproduced |
| fig4c-prestim-distance-predicts-divergence | partially reproduced |
| fig4e-subspace-overlap | partially reproduced |
| fig4f-prestim-overlap-predicts-poststim | reproduced |
| figs4ef-coding-dimensions | partially reproduced |
| figs4h-choice-right-left | partially reproduced |
| fig5b-first-hit-switches | not reproduced |
| fig5de-motor-states-track-switch | reproduced |
| figs5-sensory-states-no-tracking | reproduced |
| fig6c-pre-inhibition-tactile | partially reproduced |
| figs6b-pre-inhibition-hit-rate | reproduced |
| fig6d-post-inhibition-tactile | reproduced |
| figs7gh-visual-sensitivity | reproduced |
| fig6-ppc-inhibition | reproduced |
| fig6fg-detection-task | reproduced |
| fig6ij-laser-only-licks | not reproduced |
| fig1e-probe-locations | blocked (data missing) |
A failure to reproduce here may be a failure of this attempt, not of the paper: see the reasoning and the differences from the paper's method.
How the analysis differs from the paper's. The analysis is in Python, not MATLAB. Bootstrap resamples for AUC are shared by all units and bins of a session. The decoding CIs use 200 resamples per area, the transition CIs 500 and the coding-dimension CIs 1000. The transition classifier is LDA with Ledoit-Wolf shrinkage in place of the code's optimized LDA. Subspace overlaps average 100 equal splits of the tactile hits; the code's single split per neuron is a variant. Where the Methods and code disagree (normalization offsets, the session rule of the subspace analysis, resampling of false alarms in the optogenetic CIs), the Methods' choice is the main result and the code's is shown. The first-hit trial numbers are counted in rows of the files, which lack the removed trials, and in trial numbers estimated from the gaps between rows. Outcomes that rest on a bootstrap are judged on the main seed. Other seeds are shown for the single-neuron percentages and the figs4h S2 mean (five seeds each) and the optogenetic CIs (ten seeds). Over the main seed and four others, fig1h MM is within tolerance in 3 of 5, fig2c S1 in 0 of 5 and ALM in 2 of 5, and figs4h S2 in 1 of 5. On the five-seed means all four areas of fig1h and fig2c are within tolerance. Over ten other seeds the fig6c ALM interval under the Methods' resampling is below 0 in 7 of 10, and no other Methods-resampling call of the optogenetic CIs checked over seeds changes side; the figs6b ALM interval with the code's resampling includes 0 on the main draw (upper bound 0.002) and is below 0 in 9 of 10 other seeds. The fig6ij laser-only calls keep their side in all ten seeds.
What was found about the dandiset. Its counts of sessions, mice, units and optogenetic sessions
match the paper exactly, and most results reproduce closely, so the dandiset holds the data the paper
analysed. The ephys files hold only the trials and units the paper kept: trials with licks between -1 and
0.1 s of the stimulus and units failing the quality filter are absent, which the dandiset does not say.
Units carry no quality metrics, channel, depth or original cluster id; electrode coordinates are NaN and
locations "unknown", and no histology is included. In the behaviour files start_time equals the stimulus
onset and trials overlap; in the detection task, trials without a whisker stimulus are labelled
"Stim_Vis". Trial table columns have no descriptions, and codes such as response (1 right, 2 left,
0 none) and blockType ("Change" for the cue trial) are explained only by the paper's code.
print(f"Notebook ran in {(time.time() - started) / 60:.1f} minutes")
Notebook ran in 12.8 minutes