Reproducing Seiler et al. (2022) from dandiset 000971¶
Paper. Seiler JL, Cosme CV, Sherathiya VN, Schaid MD, Bianco JM, Bridgemohan AS, Lerner TN (2022). Dopamine signaling in the dorsomedial striatum promotes compulsive behavior. Current Biology 32, 1175-1188. doi:10.1016/j.cub.2022.01.055. This attempt used the full text of the accepted author manuscript in PubMed Central (PMC8930615). The supplementary figures could not be downloaded, so claims on Figures S1 to S7 rest on the main text.
Dandiset. DANDI:000971, version 0.261013.1707. The paper predates the dandiset and cites no version. This is the most recent published version. All three published versions (0.240802.2004, 0.260213.1851, 0.261013.1707) and the draft hold the same 4139 assets with the same blobs; they differ only in dandiset metadata (contributors, ORCIDs, study targets), none of which the analysis uses.
Code read. The authors' photometry code (GitHub jillianseiler/Seiler-et-al.-2022-MATLAB-code,
commit 56f1529) and the conversion code with its notes (GitHub catalystneuro/lerner-lab-to-nwb,
commit d301f7e). The photometry processing here follows the authors' code where the Methods are silent.
Outside inputs. One claim (Figure S3A-B) uses the densitometry table on Zenodo (doi:10.5281/zenodo.11266654), which the dandiset names as its supplement.
| Claim | Tested |
|---|---|
| fig1d-ri60-vs-rr20-shocks | tested |
| fig1e-omission-ri60-longer | tested |
| fig1f-omission-shock-no-correlation | tested |
| fig2a-phenotype-rule | tested |
| fig2b-only-dpr-increase | tested |
| fig2c-nosepoke-rate | tested |
| fig2d-omission-pr-vs-ps | tested |
| fig2k-ps-wait-longer | tested |
| fig2l-extra-nosepokes | tested |
| fig2m-inter-reward-interval | tested |
| fig2n-rewards-per-session | tested |
| fig2o-escalation-timing | tested |
| figs1i-ri60-more-rewards | tested |
| figs2b-males-more-shocks | tested |
| fig3e-ri30-dms-peaks | blocked: no RI30 photometry in the dandiset |
| fig3f-dms-score-phenotype | tested |
| fig3g-dms-score-shock-correlation | tested |
| fig3h-dms-score-omission-no-correlation | tested |
| fig4f-dls-score-no-phenotype | tested |
| fig4g-dls-score-shock-no-correlation | tested |
| fig4h-dls-score-omission-no-correlation | tested |
| fig5f-dms-chr2-escalation | tested |
| fig5g-dms-chr2-more-shocks-early | tested |
| fig5h-dms-chr2-mostly-pr | tested |
| figs5f-chr2-pr-by-sex | tested |
| fig5i-dms-stim-no-omission-effect | tested |
| fig6-nphr-learning-dropout | blocked: the dropped mice are not in the dandiset (counts in its section) |
| fig6g-nphr-fewer-shocks-late | tested |
| fig6h-nphr-more-ps | tested |
| fig6i-nphr-no-omission-effect | tested |
| fig7f-dls-escalation-similar | tested |
| fig7g-dls-stim-no-shock-effect | tested |
| fig7h-dls-similar-phenotypes | tested |
| fig7i-dls-no-omission-effect | tested |
| figs3ab-dat-protein-not-reduced | tested on Zenodo data |
| figs1a-shock-aversive | blocked: fear conditioning not in the dandiset |
| figs3cd-open-field | blocked: open field not in the dandiset |
| fig3c-gcamp-in-dopamine-neurons | blocked: histology not in the dandiset |
import hashlib
import io
import time
import warnings
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
import statsmodels.formula.api as smf
from scipy import stats
from statsmodels.stats.multicomp import pairwise_tukeyhsd
import seiler
pd.set_option("display.width", 220)
pd.set_option("display.max_columns", 40)
pd.set_option("display.max_rows", 400)
warnings.filterwarnings("ignore", category=RuntimeWarning)
warnings.filterwarnings("ignore", category=FutureWarning)
started = time.time()
sessions = seiler.list_sessions()
print(f"{len(sessions)} assets in dandiset {seiler.DANDISET_ID} version {seiler.DANDISET_VERSION}")
# Each subject's experiment and arm, from its files other than the 'Unknown' DLS files.
named = sessions[(sessions.arm != "Unknown") & sessions.experiment.notna()]
subj_arm = named.groupby("subject").group.agg(lambda g: g.value_counts().index[0])
sessions["subject_group"] = sessions.subject.map(subj_arm)
sessions["subject_experiment"] = sessions.subject_group.str.extract(
r"^(FP|Opto-DLS-Excitatory|Opto-DMS-Excitatory|Opto-DMS-Inhibitory)")[0]
sessions["subject_arm"] = sessions.subject_group.str.split("-").str[-1]
is_fp = sessions.subject_experiment == "FP"
is_opto = sessions.subject_experiment.str.startswith("Opto", na=False)
need = is_fp | (is_opto & sessions.stage.isin(["shock", "omission"])) | (
sessions.subject_experiment.isin(["Opto-DMS-Excitatory", "Opto-DLS-Excitatory"]) & sessions.stage.isin(["FR1", "RI30"]))
# one file of every optogenetic subject, for its notes
need |= is_opto & ~sessions.subject.duplicated() & sessions.subject_experiment.notna()
to_load = sessions[need]
t = time.time()
events = seiler.read_all_events(to_load.asset_id.tolist())
print(f"read the events of {len(events)} files in {time.time() - t:.0f} s")
4139 assets in dandiset 000971 version 0.261013.1707
read the events of 2202 files in 1006 s
Data check¶
Each check of the metadata against the values is stated under its output.
subjects = sessions[sessions.subject_experiment.notna()].drop_duplicates("subject")
print("Subjects by experiment and arm (arm from the subject's files other than the 'Unknown' DLS files):")
print(pd.crosstab(subjects.subject_group, subjects.sex).to_string())
print("\nWestern blot assets:", int((sessions.group == "western_blot").sum()))
print("Files by experiment and stage:")
print(pd.crosstab(sessions.subject_experiment.fillna("western_blot"), sessions.stage).to_string())
two = sessions[sessions.experiment.notna() & (sessions.arm != "Unknown")].groupby("subject").experiment.nunique()
print("\nSubjects with files in two experiments:", two[two > 1].index.tolist())
for s in two[two > 1].index:
print(" ", s, sessions[sessions.subject == s].group.value_counts().to_dict())
print("Paper: fiber photometry 36 RI60 mice (9 PR, 9 DPR, 18 PS) and RR20 9 (Fig 1D) or 7 (Methods);"
" DMS excitatory 39 mice, DMS inhibitory 26 (Methods) or 47 (Fig 6F), DLS excitatory 36.")
Subjects by experiment and arm (arm from the subject's files other than the 'Unknown' DLS files):
sex F M
subject_group
FP-DPR 4 5
FP-PR 1 8
FP-PS 17 1
FP-RR20 5 1
Opto-DLS-Excitatory-ChR2 5 6
Opto-DLS-Excitatory-ChR2Scrambled 6 6
Opto-DLS-Excitatory-EYFP 6 6
Opto-DMS-Excitatory-ChR2 7 7
Opto-DMS-Excitatory-ChR2Scrambled 6 6
Opto-DMS-Excitatory-EYFP 3 4
Opto-DMS-Inhibitory-EYFP 9 7
Opto-DMS-Inhibitory-NpHR 8 5
Opto-DMS-Inhibitory-NpHRScrambled 6 7
Western blot assets: 16
Files by experiment and stage:
stage FR1 FR1habit RI30 RI60 RR20 RR5 omission other probetest shock unknown
subject_experiment
FP 231 62 93 640 84 1 23 0 75 97 18
Opto-DLS-Excitatory 218 0 56 538 0 0 39 0 1 83 0
Opto-DMS-Excitatory 172 0 52 449 0 0 35 0 26 68 0
Opto-DMS-Inhibitory 316 31 64 536 0 0 37 0 2 76 0
western_blot 0 0 0 0 0 0 0 16 0 0 0
Subjects with files in two experiments: ['290.407']
290.407 {'Opto-DMS-Excitatory-ChR2Scrambled': 24, 'Opto-DLS-Excitatory-ChR2': 2, 'Opto-DLS-Excitatory-Unknown': 2}
Paper: fiber photometry 36 RI60 mice (9 PR, 9 DPR, 18 PS) and RR20 9 (Fig 1D) or 7 (Methods); DMS excitatory 39 mice, DMS inhibitory 26 (Methods) or 47 (Fig 6F), DLS excitatory 36.
The western blot assets hold one image per condition and no measurements. The fiber photometry arm of each mouse is in its file names; a subject named in two experiments is assigned to the experiment of most of its files.
def event_hash(ev):
h = hashlib.md5()
for k in sorted(ev):
h.update(k.encode())
h.update(np.round(np.asarray(ev[k]), 3).tobytes())
return h.hexdigest()
loaded = sessions[sessions.asset_id.isin(list(events))].copy()
loaded["hash"] = [event_hash(events[a]["events"]) if events[a]["events"] else "" for a in loaded.asset_id]
dups = loaded[(loaded.hash != "") & loaded.duplicated("hash", keep=False)].sort_values("hash")
pairs = dups.groupby("hash").agg(n=("path", "size"), subjects=("subject", "nunique"),
groups=("group", lambda g: ",".join(sorted(set(g)))), stage=("stage", "first"),
n_events=("asset_id", lambda a: sum(len(v) for v in events[a.iloc[0]]["events"].values())))
print("Loaded files whose event series are identical to another loaded file:", len(dups), "in", len(pairs), "sets")
print(pairs.groupby(["subjects", "groups", "stage"]).size().to_string())
exp_files = sessions[sessions.experiment.notna()]
same_start = exp_files[exp_files.duplicated(["subject", "start"], keep=False)]
print("\nFiles in the 'Unknown' DLS excitatory group:", int((sessions.arm == "Unknown").sum()))
print("All files sharing subject and start time with another file:", len(same_start),
"; their groups:", same_start.group.value_counts().to_dict())
odd = pairs[pairs.subjects > 1]
for h in odd.index:
d = dups[dups.hash == h]
r = seiler.rewards(events[d.asset_id.iloc[0]]["events"])
print("\nIdentical across subjects:", d.subject.tolist(), d.stage.iloc[0], "; reward intervals (s) from",
np.round(np.diff(r).min(), 2), "to", np.round(np.diff(r).max(), 2), "; series:", list(events[d.asset_id.iloc[0]]["events"]))
Loaded files whose event series are identical to another loaded file: 77 in 38 sets
subjects groups stage
1 Opto-DLS-Excitatory-ChR2,Opto-DLS-Excitatory-Unknown FR1 8
RI30 3
omission 3
Opto-DLS-Excitatory-ChR2Scrambled,Opto-DLS-Excitatory-Unknown FR1 15
RI30 4
omission 4
3 Opto-DMS-Inhibitory-NpHR,Opto-DMS-Inhibitory-NpHRScrambled omission 1
Files in the 'Unknown' DLS excitatory group: 137
All files sharing subject and start time with another file: 270 ; their groups: {'Opto-DLS-Excitatory-Unknown': 135, 'Opto-DLS-Excitatory-ChR2Scrambled': 82, 'Opto-DLS-Excitatory-ChR2': 53}
Identical across subjects: ['245.464', '266.477', '430.420'] omission ; reward intervals (s) from 20.0 to 20.0 ; series: ['right_reward_times']
The 'Unknown' DLS excitatory files repeat sessions of the same mice that are also deposited under their named arm, with the same start time and identical events; each such session is counted once below (the named copy is kept). The omission probes identical across three mice hold only reward times, at the 20 s spacing the omission schedule gives a mouse that never nosepokes, so they are consistent with three mice that did not respond, not with one file deposited three times.
ok_loaded = loaded[~((loaded.arm == "Unknown") & loaded.duplicated(["subject", "start"], keep=False))].copy()
fp = sessions[sessions.subject_experiment == "FP"]
fp_subj = fp.drop_duplicates("subject").copy()
fp_subj["label"] = [seiler.note_field(events[a]["notes"], "Punishment Group") for a in fp_subj.asset_id]
fp_subj["behavior"] = [seiler.note_field(events[a]["notes"], "Behavior") for a in fp_subj.asset_id]
fp_subj["did_not_learn"] = [seiler.note_field(events[a]["notes"], "Did Not Learn") for a in fp_subj.asset_id]
PHENO = {"Punishment Resistant": "PR", "Delayed Punishment Resistant": "DPR", "Punishment Sensitive": "PS"}
fp_subj["pheno"] = np.where(fp_subj.behavior == "RI60", fp_subj.label.map(PHENO), "RR20")
print("Fiber photometry mice: file-name arm, stored punishment group and schedule")
print(pd.crosstab([fp_subj.subject_arm, fp_subj.behavior], fp_subj.label).to_string())
print("Labels consistent across each mouse's files:",
all(len({seiler.note_field(events[a]["notes"], "Punishment Group") for a in fp[fp.subject == s].asset_id}) == 1
for s in fp_subj.subject))
opto_subj = sessions[sessions.subject_experiment.str.startswith("Opto", na=False)].drop_duplicates("subject").copy()
opto_subj["label"] = [seiler.note_field(events[a]["notes"], "Punishment Group") for a in opto_subj.asset_id]
opto_subj["did_not_learn"] = [seiler.note_field(events[a]["notes"], "Did Not Learn") for a in opto_subj.asset_id]
print("\nOptogenetic mice, stored punishment group:", opto_subj.label.value_counts().to_dict())
print("Did Not Learn flags, all mice:", pd.concat([fp_subj, opto_subj]).groupby("subject_group").did_not_learn.value_counts().unstack(fill_value=0).to_string())
Fiber photometry mice: file-name arm, stored punishment group and schedule
label Delayed Punishment Resistant Punishment Resistant Punishment Sensitive
subject_arm behavior
DPR RI60 9 0 0
PR RI60 0 9 0
PS RI60 0 0 18
RR20 RR20 0 0 6
Labels consistent across each mouse's files: True
Optogenetic mice, stored punishment group: {'nan': 110}
Did Not Learn flags, all mice: did_not_learn False True
subject_group
FP-DPR 9 0
FP-PR 9 0
FP-PS 18 0
FP-RR20 6 0
Opto-DLS-Excitatory-ChR2 11 0
Opto-DLS-Excitatory-ChR2Scrambled 12 0
Opto-DLS-Excitatory-EYFP 12 0
Opto-DMS-Excitatory-ChR2 14 0
Opto-DMS-Excitatory-ChR2Scrambled 12 0
Opto-DMS-Excitatory-EYFP 7 0
Opto-DMS-Inhibitory-EYFP 15 1
Opto-DMS-Inhibitory-NpHR 12 1
Opto-DMS-Inhibitory-NpHRScrambled 13 0
The fiber photometry mice carry their phenotype in two places, the file names and the notes, and the two agree. RR20 mice are labelled 'Punishment Sensitive' in the notes; the paper does not classify RR20 mice. Optogenetic mice have no stored phenotype, so they are classified here by the Methods rule.
side_rows = []
for a, r in ok_loaded.set_index("asset_id").iterrows():
if not isinstance(r.side, str) or r.stage not in ("FR1", "RI30", "RI60", "RR20", "shock"):
continue
ev = events[a]["events"]
rw = seiler.rewards(ev)
if len(rw) == 0:
continue
def frac(s):
p = ev.get(f"{s}_nose_poke_times", np.array([]))
return np.isin(np.round(rw, 2), np.round(p, 2)).mean() if len(p) else 0.0
other = "left" if r.side == "right" else "right"
series = "+".join(k.split("_")[0] for k in ("left_reward_times", "right_reward_times") if k in ev)
side_rows.append({"stage": r.stage, "described_side": r.side, "reward_series": series,
"rewards_in_described_side_pokes": frac(r.side) > 0.9, "rewards_in_other_side_pokes": frac(other) > 0.9})
side_df = pd.DataFrame(side_rows)
print("Do reward times coincide with nosepokes on the side the session description names? (files with rewards)")
print(side_df.groupby(["stage", "described_side", "reward_series", "rewards_in_described_side_pokes",
"rewards_in_other_side_pokes"]).size().to_string())
Do reward times coincide with nosepokes on the side the session description names? (files with rewards)
stage described_side reward_series rewards_in_described_side_pokes rewards_in_other_side_pokes
FR1 left left True False 242
right right True False 277
RI30 left left True False 90
right right True False 102
RI60 left left False False 1
True False 270
right right False False 1
True False 363
RR20 left left False False 20
True False 8
right right False False 25
True False 11
shock left right True False 151
right right True False 155
In training and probe sessions the reward times coincide with nosepokes on the side the description
names. In shock probes on the left side the reward series is stored as right_reward_times while its
times are left nosepokes, so the series name is wrong there; rewards are taken from either series. In
most RR20 files the reward times do not coincide with any nosepoke time (MedPC delivers the RR reward
after the counted nosepoke); RR20 files are used here only for reward counts and probes.
probe_files = ok_loaded[ok_loaded.stage == "shock"]
chk = []
for a in probe_files.asset_id:
ev = events[a]["events"]
if "footshock_times" not in ev:
continue
sh, rw = ev["footshock_times"], seiler.rewards(ev)
chk.append({"n_shock": len(sh), "n_rew": len(rw), "shock_at_reward": np.isin(np.round(sh, 2), np.round(rw, 2)).mean() if len(sh) else np.nan,
"first5_unshocked": not np.isin(np.round(rw[:5], 2), np.round(sh, 2)).any(), "last_event_s": max(v.max() for v in ev.values() if len(v))})
chk = pd.DataFrame(chk)
print(f"Shock probe files with footshock_times: {len(chk)} of {len(probe_files)}")
print(f"Fraction of shocks at a reward time: median {chk.shock_at_reward.median():.2f}, min {chk.shock_at_reward.min():.2f}")
ratio = chk.n_shock / chk.n_rew.replace(0, np.nan)
print(f"Shocks per reward: median {ratio.median():.2f} (Methods: RR3, about 1/3); 5th to 95th percentile {ratio.quantile(.05):.2f} to {ratio.quantile(.95):.2f}")
print("First five rewards never shocked (Methods):", int(chk.first5_unshocked.sum()), "of", len(chk))
print(f"Last event time in probe files: up to {chk.last_event_s.max():.0f} s (Methods: 60 min or 10 min of inactivity)")
dur = []
for a, st in ok_loaded.set_index("asset_id").stage.items():
ev = events[a]["events"]
if ev and st in ("FR1", "RI30", "RI60", "RR20", "omission"):
dur.append({"stage": st, "last_event_s": max((v.max() for v in ev.values() if len(v)), default=np.nan), "n_rewards": len(seiler.rewards(ev))})
dur = pd.DataFrame(dur)
print("\nLast event time (s) and reward count by stage:")
print(dur.groupby("stage").agg(files=("n_rewards", "size"), last_event_max=("last_event_s", "max"), last_event_median=("last_event_s", "median"),
rewards_max=("n_rewards", "max")).round(0).to_string())
Shock probe files with footshock_times: 285 of 311
Fraction of shocks at a reward time: median 1.00, min 1.00
Shocks per reward: median 0.31 (Methods: RR3, about 1/3); 5th to 95th percentile 0.21 to 0.33
First five rewards never shocked (Methods): 285 of 285
Last event time in probe files: up to 3601 s (Methods: 60 min or 10 min of inactivity)
Last event time (s) and reward count by stage:
files last_event_max last_event_median rewards_max
stage
FR1 625 3601.0 3536.0 50
RI30 194 3600.0 3160.0 50
RI60 641 5354.0 3469.0 50
RR20 84 3674.0 3560.0 49
omission 127 3601.0 1463.0 50
Every shock falls on a rewarded nosepoke, about one reward in three is shocked, and the first five rewards are never shocked, as the Methods describe. Sessions hold at most 50 rewards and end within about an hour, consistent with the Methods' one hour or 50 rewards; the exception is an RI60 file whose last event is at 5354 s, which may be one of the sessions the conversion notes say were stitched from two recordings. Times are in seconds from the session start.
fp_probes = seiler.probe_table(fp, events)
print("Probe days per fiber photometry mouse:", fp_probes.groupby("subject").size().value_counts().sort_index().to_dict())
print("Probe days with more than one file:", int((fp_probes.n_files > 1).sum()), "; with more than one file holding footshock_times:",
int((fp_probes.n_with_shocks > 1).sum()))
print(fp_probes[fp_probes.n_with_shocks > 1].to_string(index=False))
print("\nTraining sessions before the early and late probes (Results: 1 to 2 and 13 to 14 days):")
print(fp_probes[fp_probes.probe != ""].groupby("probe").train_days_before.describe().to_string())
fp_el = seiler.early_late(fp_probes).join(fp_subj.set_index("subject")[["pheno", "label", "behavior", "sex"]])
print("\nMice missing a probe:")
print(fp_el[fp_el[["early", "late"]].isna().any(axis=1)].to_string())
print("Shock counts, range:", fp_el[["early", "late"]].min().min(), "to", fp_el[["early", "late"]].max().max())
Probe days per fiber photometry mouse: {1: 5, 2: 27, 3: 10}
Probe days with more than one file: 8 ; with more than one file holding footshock_times: 2
subject date n_files n_with_shocks shocks shocks_sum train_days_before probe
64.205 2018-11-13 2 2 35 62 16 late
87.239 2019-03-04 2 2 5 20 2 early
Training sessions before the early and late probes (Results: 1 to 2 and 13 to 14 days):
count mean std min 25% 50% 75% max
probe
early 40.0 1.575000 0.812956 0.0 1.0 1.0 2.0 4.0
late 39.0 13.435897 1.569359 11.0 12.0 13.0 14.0 18.0
Mice missing a probe:
early late pheno label behavior sex
subject
028.392 28.0 NaN PR Punishment Resistant RI60 M
101.260 7.0 NaN RR20 Punishment Sensitive RR20 F
62.207 28.0 NaN PR Punishment Resistant RI60 M
78.214 NaN 2.0 PS Punishment Sensitive RI60 F
89.247 NaN 23.0 PR Punishment Resistant RI60 F
Shock counts, range: 1.0 to 53.0
Shock probes come in the order the Results give, the early one after 0 to 4 RI60 (or RR20) sessions (median 1) and the late one after 11 to 18 (median 13). Two probe days have two files that both hold shocks; the last of the day is used, and the sum is a variant. Five mice lack a probe in the dandiset: two PR mice (028.392, 62.207) and one RR20 mouse have no late probe, and one PR mouse (89.247) and one PS mouse (78.214) have no early probe. For 89.247 a photometry session one day after its first RI60 session is deposited as a 'Probe Test' session with no footshock series. 32 RI60 mice and 5 RR20 mice have both probes.
all_ph = fp[fp.asset_id.map(lambda a: events[a]["has_photometry"])]
print("Files with photometry by stage:", all_ph.stage.value_counts().to_dict())
fp_sessions = all_ph[all_ph.stage.isin(["RI60", "RR20"])].sort_values("start_ts").copy()
fp_first_probe = fp_probes.sort_values("date").groupby("subject").date.first()
ri60_dates = fp[fp.stage.isin(["RI60", "RR20"])].groupby("subject").date.apply(sorted)
fp_sessions["train_day"] = [sum(d < r.date for d in ri60_dates.get(r.subject, [])) + 1 for r in fp_sessions.itertuples()]
early_probe = fp_probes[fp_probes.probe == "early"].set_index("subject").date
fp_sessions["fp_stage"] = [
("early" if r.date < early_probe[r.subject] else "late") if r.subject in early_probe.index
else ("early" if r.train_day <= 3 else "late") for r in fp_sessions.itertuples()]
fp_sessions = fp_sessions.merge(fp_subj[["subject", "pheno", "behavior"]], on="subject")
print("Photometry sessions:", len(fp_sessions), "from", fp_sessions.subject.nunique(), "mice")
print(pd.crosstab([fp_sessions.pheno, fp_sessions.stage], fp_sessions.fp_stage).to_string())
print("\nMice with photometry per phenotype and stage (paper Fig 3E: RI30 PR 6, DPR 6, PS 11; early PR 7, DPR 9, PS 15; late PR 5, DPR 6, PS 13):")
print(fp_sessions.groupby(["pheno", "fp_stage"]).subject.nunique().unstack().to_string())
print("\nTraining day of each photometry session (1 = first RI60 or RR20 session):")
print(fp_sessions.groupby("fp_stage").train_day.describe().to_string())
def process_photometry(row):
rec = seiler.load_photometry(row.asset_id)
d, fs, t0, ev = rec["data"], rec["fs"], rec["t0"], rec["events"]
rew, unrew = seiler.split_nosepokes(ev, row.side)
def inside(times): # events whose PSTH window lies inside the recording (as seiler.psth)
idx = np.round((np.asarray(times) - t0) * fs)
return int(((idx - seiler.PSTH_PRE_S * fs >= 0) & (idx + seiler.PSTH_POST_S * fs < len(d))).sum())
out = {"asset_id": row.asset_id, "fs": fs, "t0": t0, "n_samples": len(d), "duration_s": len(d) / fs,
"last_event_s": max((v.max() for v in ev.values() if len(v)), default=np.nan),
"n_rewards": len(seiler.rewards(ev)), "locations": ",".join(rec["locations"]),
"sources": ",".join(s.split("_")[-1] for s in rec["sources"]),
"n_rewarded": len(rew), "n_unrewarded": len(unrew),
"n_rewarded_kept": inside(rew), "n_unrewarded_kept": inside(unrew), "coordinates": str(np.round(rec["coordinates"], 2).tolist()),
"modulation": ";".join(f"{k.replace('commanded_voltage_series_', '')}={v:g}" for k, v in sorted(rec["modulation_hz"].items()))}
for region, (si, ci) in {"DMS": (0, 1), "DLS": (2, 3)}.items():
out[f"{region}_dropout_frac"] = seiler.dropout_mask(d[:, si], d[:, ci], fs).mean()
z = seiler.zscored_dff(d[:, si], d[:, ci], fs)
res = seiler.peak_score(z, fs, rew, unrew, t0)
out[f"{region}_score"], out[f"{region}_rew_max"], out[f"{region}_unrew_min"] = res["score"], res["rew_max"], res["unrew_min"]
out[f"{region}_psth_rew"], out[f"{region}_psth_unrew"] = res["psth_rew"], res["psth_unrew"]
out[f"{region}_auc_2_10"] = res["auc_2_10"]
# the authors' processing without the dropout step
z0 = seiler.zscored_dff(d[:, si], d[:, ci], fs, mask_dropouts=False)
out[f"{region}_score_nomask"] = seiler.peak_score(z0, fs, rew, unrew, t0)["score"]
out[f"{region}_max_abs_z_nomask"] = np.nanmax(np.abs(z0))
# clock check: the same events shifted by 37 s
out[f"{region}_rew_max_shifted"] = seiler.peak_score(z, fs, rew + 37.0, unrew + 37.0, t0)["rew_max"]
# column check: the channels the other way round
zs = seiler.zscored_dff(d[:, ci], d[:, si], fs)
out[f"{region}_rew_max_swapped"] = seiler.peak_score(zs, fs, rew, unrew, t0)["rew_max"]
out["t_psth"] = res["t"]
return out
t = time.time()
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(6) as ex:
ph = pd.DataFrame(list(ex.map(process_photometry, fp_sessions.itertuples())))
ph = fp_sessions.merge(ph, on="asset_id")
# Methods: recordings with excessive artifacts were excluded; here a region's session is excluded when
# more than 10% of its samples are dropouts (a choice here).
for region in ["DMS", "DLS"]:
bad = ph[f"{region}_dropout_frac"] > 0.10
ph.loc[bad, [f"{region}_score", f"{region}_rew_max", f"{region}_unrew_min", f"{region}_score_nomask"]] = np.nan
print(f"{region} sessions excluded for dropouts over 10% of samples:", ph.loc[bad, ["subject", "date"]].to_dict("records"))
print(f"\nprocessed {len(ph)} photometry sessions in {time.time() - t:.0f} s")
print("Sampling rate (Hz):", ph.fs.unique(), "; start time:", ph.t0.unique())
print("Column locations:", list(ph.locations.unique()), "; excitation sources:", list(ph.sources.unique()))
print("Modulation frequencies (Hz) in the files:", ph.modulation.value_counts().to_dict())
print("Fiber coordinates (AP, ML, DV) per column in the files:", ph.coordinates.value_counts().to_dict(),
"; Methods: DMS AP 0.8, ML 1.5, DV -2.8; DLS AP -0.1, ML 2.8, DV -3.5")
print(f"Recording length minus last behavioral event (s): {np.nanmin(ph.duration_s - ph.last_event_s):.0f} to {np.nanmax(ph.duration_s - ph.last_event_s):.0f}")
print(f"Rewarded / unrewarded nosepokes per session: {ph.n_rewarded.min()} to {ph.n_rewarded.max()} / {ph.n_unrewarded.min()} to {ph.n_unrewarded.max()}")
kept_r = ph.n_rewarded_kept / ph.n_rewarded.replace(0, np.nan)
kept_u = ph.n_unrewarded_kept / ph.n_unrewarded.replace(0, np.nan)
print(f"Events inside the recording (PSTH window complete): rewarded {ph.n_rewarded_kept.sum()} of {ph.n_rewarded.sum()}, "
f"unrewarded {ph.n_unrewarded_kept.sum()} of {ph.n_unrewarded.sum()}; sessions losing any event: {(kept_r < 1).sum()} rewarded, {(kept_u < 1).sum()} unrewarded")
lost = ph[(kept_r < 0.9) | (kept_u < 0.9)]
print("Sessions keeping under 90% of either kind (subject, date, rewarded kept/all, unrewarded kept/all, recording minus last event in s):")
for r in lost.itertuples():
print(f" {r.subject} {r.date}: {r.n_rewarded_kept}/{r.n_rewarded}, {r.n_unrewarded_kept}/{r.n_unrewarded}, {r.duration_s - r.last_event_s:.0f}")
print(f"Fewest events kept in a session: rewarded {ph.n_rewarded_kept.min()}, unrewarded {ph.n_unrewarded_kept.min()}")
print("Sessions with rewards but no reward at a nosepoke time:", ph[(ph.n_rewarded == 0)][["subject", "date", "n_rewards"]].to_dict("records"))
for region in ["DMS", "DLS"]:
m = ph[f"{region}_dropout_frac"] > 0
fr = ph[f"{region}_dropout_frac"]
print(f"{region}: percent of samples left out per session, median {100 * fr.median():.3f}, above 0.1% in {(fr > 0.001).sum()} sessions, "
f"maximum {100 * fr.max():.2f}; largest |z| without the dropout step {ph[f'{region}_max_abs_z_nomask'].max():.0f} "
f"(above 50 in {(ph[f'{region}_max_abs_z_nomask'] > 50).sum()} sessions)")
Files with photometry by stage: {'RI60': 53, 'RR20': 9, 'probetest': 1}
Photometry sessions: 62 from 40 mice
fp_stage early late
pheno stage
DPR RI60 9 7
PR RI60 7 4
PS RI60 15 11
RR20 RR20 5 4
Mice with photometry per phenotype and stage (paper Fig 3E: RI30 PR 6, DPR 6, PS 11; early PR 7, DPR 9, PS 15; late PR 5, DPR 6, PS 13):
fp_stage early late
pheno
DPR 9 7
PR 7 4
PS 14 11
RR20 5 4
Training day of each photometry session (1 = first RI60 or RR20 session):
count mean std min 25% 50% 75% max fp_stage early 36.0 1.305556 0.624246 1.0 1.0 1.0 1.0 3.0 late 26.0 13.307692 1.761119 12.0 12.0 13.0 14.0 18.0
DMS sessions excluded for dropouts over 10% of samples: []
DLS sessions excluded for dropouts over 10% of samples: [{'subject': '141.308', 'date': '2019-08-09'}]
processed 62 photometry sessions in 402 s
Sampling rate (Hz): [1017.25262451] ; start time: [0.]
Column locations: ['DMS,DMS,DLS,DLS'] ; excitation sources: ['signal,control,signal,control']
Modulation frequencies (Hz) in the files: {'dls_calcium_signal=450;dls_isosbestic_control=270;dms_calcium_signal=211;dms_isosbestic_control=330': 62}
Fiber coordinates (AP, ML, DV) per column in the files: {'[[0.8, 1.5, 2.8], [0.8, 1.5, 2.8], [0.1, 2.8, 3.5], [0.1, 2.8, 3.5]]': 62} ; Methods: DMS AP 0.8, ML 1.5, DV -2.8; DLS AP -0.1, ML 2.8, DV -3.5
Recording length minus last behavioral event (s): -2257 to 2516
Rewarded / unrewarded nosepokes per session: 0 to 87 / 3 to 383
Events inside the recording (PSTH window complete): rewarded 2336 of 2337, unrewarded 10892 of 10916; sessions losing any event: 1 rewarded, 4 unrewarded
Sessions keeping under 90% of either kind (subject, date, rewarded kept/all, unrewarded kept/all, recording minus last event in s):
92.246 2019-02-27: 18/18, 69/85, -1391
Fewest events kept in a session: rewarded 0, unrewarded 3
Sessions with rewards but no reward at a nosepoke time: [{'subject': '332.393', 'date': '2020-07-28', 'n_rewards': 32}]
DMS: percent of samples left out per session, median 0.033, above 0.1% in 10 sessions, maximum 1.61; largest |z| without the dropout step 1730 (above 50 in 6 sessions)
DLS: percent of samples left out per session, median 0.033, above 0.1% in 10 sessions, maximum 99.97; largest |z| without the dropout step 1758 (above 50 in 6 sessions)
chk = pd.DataFrame({
"region": np.repeat(["DMS", "DLS"], len(ph)),
"as_labelled": np.r_[ph.DMS_rew_max, ph.DLS_rew_max],
"events_shifted_37s": np.r_[ph.DMS_rew_max_shifted, ph.DLS_rew_max_shifted],
"channels_swapped": np.r_[ph.DMS_rew_max_swapped, ph.DLS_rew_max_swapped]})
print("Maximum of the rewarded nosepoke PSTH in 0 to 1.5 s (z), median [5th, 95th percentile] over sessions:")
print(chk.groupby("region").agg(lambda x: f"{np.nanmedian(x):.2f} [{np.nanpercentile(x, 5):.2f}, {np.nanpercentile(x, 95):.2f}]").to_string())
for region in ["DMS", "DLS"]:
a, b = ph[f"{region}_rew_max"], ph[f"{region}_rew_max_shifted"]
ok = a.notna() & b.notna()
print(f"{region}: as labelled above shifted in {(a[ok] > b[ok]).sum()} of {ok.sum()} sessions, Wilcoxon p = {stats.wilcoxon(a[ok], b[ok]).pvalue:.2g}")
c = ph[f"{region}_rew_max_swapped"]
print(f"{region}: as labelled above swapped in {(a[ok] > c[ok]).sum()} of {ok.sum()} sessions")
a_dms, a_dls = ph.DMS_auc_2_10, ph.DLS_auc_2_10
ok = a_dms.notna() & a_dls.notna()
print(f"Rewarded PSTH area 2 to 10 s after the nosepoke (z s), median DMS {a_dms.median():.2f}, DLS {a_dls.median():.2f}; "
f"DLS above DMS in {(a_dls[ok] > a_dms[ok]).sum()} of {ok.sum()} sessions, Wilcoxon p = {stats.wilcoxon(a_dls[ok], a_dms[ok]).pvalue:.2g}")
print("Sessions whose rewarded maximum is below 0.3 z in both regions:", ph[(ph.DMS_rew_max < .3) & (ph.DLS_rew_max < .3)].subject.tolist())
fig, axes = plt.subplots(1, 2, figsize=(9, 3), sharey=True)
tt = ph.t_psth.iloc[0]
for ax, region in zip(axes, ["DMS", "DLS"]):
for lab, c in [("PR", "k"), ("DPR", "m"), ("PS", "c"), ("RR20", "y")]:
sel = ph[ph.pheno == lab]
ax.plot(tt, np.nanmean(np.stack(sel[f"{region}_psth_rew"]), 0), c, label=f"{lab} rewarded (dashed: unrewarded)")
ax.plot(tt, np.nanmean(np.stack(sel[f"{region}_psth_unrew"]), 0), c + "--")
ax.axvline(0, color="grey", lw=.5); ax.set_title(region); ax.set_xlabel("s from nosepoke")
axes[0].set_ylabel("z"); axes[0].legend(fontsize=7)
plt.tight_layout(); plt.show()
allp = np.concatenate([np.stack(ph[f"{r}_psth_{k}"]).ravel() for r in ["DMS", "DLS"] for k in ["rew", "unrew"]])
print(f"Plotted session-mean PSTHs span {np.nanmin(allp):.2f} to {np.nanmax(allp):.2f} z before averaging over mice")
Maximum of the rewarded nosepoke PSTH in 0 to 1.5 s (z), median [5th, 95th percentile] over sessions:
as_labelled events_shifted_37s channels_swapped
region
DLS 0.75 [0.03, 2.23] 0.09 [-0.14, 0.48] 0.25 [-0.11, 0.72]
DMS 0.75 [-0.01, 3.50] 0.14 [-0.20, 0.47] 0.28 [-0.12, 0.67]
DMS: as labelled above shifted in 54 of 61 sessions, Wilcoxon p = 1.6e-10
DMS: as labelled above swapped in 46 of 61 sessions
DLS: as labelled above shifted in 50 of 60 sessions, Wilcoxon p = 5.8e-09
DLS: as labelled above swapped in 45 of 60 sessions
Rewarded PSTH area 2 to 10 s after the nosepoke (z s), median DMS 0.70, DLS 1.13; DLS above DMS in 40 of 60 sessions, Wilcoxon p = 0.0082
Sessions whose rewarded maximum is below 0.3 z in both regions: ['249.391', '028.392', '272.396']
Plotted session-mean PSTHs span -1.97 to 4.98 z before averaging over mice
The dandiset holds photometry for RI60 and RR20 training sessions only (and one session described as a probe test, not used): none for RI30 and none for the shock probes, though the Methods say recordings were made during RI30 and on both probes. Early sessions are on the first to third RI60 day and late ones on days 12 to 18. The files label the four columns DMS 465 nm, DMS 405 nm, DLS 465 nm and DLS 405 nm, with the modulation frequencies the Methods give (211 and 330 Hz for DMS, 450 and 270 Hz for DLS). The stored fiber coordinates lose the signs the Methods give (DV 2.8 for -2.8, DLS AP 0.1 for -0.1). The authors' code comments call the first column of each pair the control and the second the signal; the data side with the files: the rewarded nosepoke response computed as labelled is larger than with the two channels swapped in most sessions (printed above). The response is above that for the same events shifted by 37 s in most sessions of both regions, so the behavioral events and the photometry share a clock. The DLS response has the larger area 2 to 10 s after the nosepoke, matching the prolonged DLS component the Results describe, which is consistent with the region labels; it does not prove them. In one session (332.393, 2020-07-28) the reward times are MedPC times that do not coincide with any nosepoke time, which are TDT times, so it has no rewarded nosepokes and no score. The DLS channels of one session (141.308, 2019-08-09) drop out so often that, with 1 s around each dropout, 99.97% of its samples are left out; that DLS recording is excluded. Some recordings are shorter than their behavioral session, by up to 2257 s; events whose PSTH window leaves the recording are dropped, which removes 1 of 2337 rewarded and 24 of 10916 unrewarded nosepokes. Only one session keeps under 90% of either kind (92.246 on 2019-02-27, 69 of 85 unrewarded). Some recordings hold dropouts where a raw channel falls near zero, which give |z| above 1000 without the dropout step.
Fiber photometry cohort: behavior¶
Shared tables: per mouse the early and late probe counts, the omission completion time, and per training session (RI60 or RR20, up to the late probe) the active nosepoke rate, rewards, waits and extra nosepokes.
def mixed_anova(df, between, within="probe", dv="shocks", subject="subject"):
"""Two-way mixed ANOVA (one between, one within factor of two levels), complete cases."""
w = df.pivot_table(index=[subject, between], columns=within, values=dv).dropna().reset_index()
levels = [c for c in w.columns if c not in (subject, between)]
a, b = w[levels[0]].to_numpy(), w[levels[1]].to_numpy()
g = w[between].to_numpy()
groups = pd.unique(g)
n, k = len(w), len(groups)
mean_subj = (a + b) / 2
grand = mean_subj.mean()
ss_between = 2 * sum(((mean_subj[g == x].mean() - grand) ** 2) * (g == x).sum() for x in groups)
ss_subj_err = 2 * sum(((mean_subj[g == x] - mean_subj[g == x].mean()) ** 2).sum() for x in groups)
d = b - a
ss_time = n * (d.mean() ** 2) / 2
ss_inter = sum((g == x).sum() * (d[g == x].mean() - d.mean()) ** 2 for x in groups) / 2
ss_within_err = sum(((d[g == x] - d[g == x].mean()) ** 2).sum() for x in groups) / 2
df_b, df_e = k - 1, n - k
out = {}
for name, ss, dfn, sse in [(between, ss_between, df_b, ss_subj_err), (within, ss_time, 1, ss_within_err),
(f"{between} x {within}", ss_inter, df_b, ss_within_err)]:
F = (ss / dfn) / (sse / df_e)
out[name] = {"F": F, "df": f"{dfn},{df_e}", "p": stats.f.sf(F, dfn, df_e)}
return pd.DataFrame(out).T, w
def fmt(aov):
return aov.assign(F=aov.F.map("{:.2f}".format), p=aov.p.map("{:.2g}".format)).to_string()
def tukey(values, groups):
t = pairwise_tukeyhsd(np.asarray(values, float), np.asarray(groups))
return pd.DataFrame(t._results_table.data[1:], columns=t._results_table.data[0])
fp_long = fp_probes[fp_probes.probe != ""].merge(fp_subj[["subject", "pheno", "sex", "behavior"]], on="subject")
fp_long_sum = fp_long.assign(shocks=fp_long.shocks_sum)
fp_el["schedule"] = fp_el.behavior
om = fp[fp.stage == "omission"].sort_values("start_ts").groupby("subject").asset_id.first()
fp_el["omission_min"] = [seiler.omission_time_min(events[om[s]]["events"]) if s in om.index else np.nan for s in fp_el.index]
late_date = fp_probes[fp_probes.probe == "late"].set_index("subject").date
train = fp[fp.stage.isin(["RI60", "RR20"])].sort_values("start_ts").copy()
train = train[[r.subject not in late_date.index or r.date < late_date[r.subject] for r in train.itertuples()]]
rows = []
for r in train.itertuples():
ev = events[r.asset_id]["events"]
pokes = seiler.active_pokes(ev, r.side)
rm = seiler.reward_measures(ev, r.side)
rows.append({"subject": r.subject, "date": r.date, "stage": r.stage, "n_rewards": len(seiler.rewards(ev)),
"n_active": len(pokes), "n_events": sum(len(v) for v in ev.values()), "series": len(ev),
"last_event_s": max((v.max() for v in ev.values() if len(v)), default=np.nan),
"size_mb": r.size / 1e6, "n_waits": rm["n_waits"], "series_names": ",".join(sorted(ev)),
"rate": len(pokes) / (seiler.session_length(ev) / 60.0), "wait": rm["wait"], "extra": rm["extra"],
"has_ports": rm["has_ports"], "iri": rm["iri"]})
tr_all = pd.DataFrame(rows).merge(fp_subj[["subject", "pheno", "sex"]], on="subject")
# Files with no active nosepoke and no reward are left out (listed in the next cell): files that hold
# port entries only, and a false start followed by the full session on the same day.
empty = tr_all[(tr_all.n_rewards == 0) & (tr_all.n_active == 0)]
tr = tr_all.drop(empty.index).reset_index(drop=True)
tr["day"] = tr.groupby("subject").cumcount() + 1
def mouse_means(t):
return t.groupby("subject").agg(rate=("rate", "mean"), rewards=("n_rewards", "mean"), wait=("wait", "mean"),
extra=("extra", "mean"), sessions=("rate", "size")).join(fp_subj.set_index("subject").pheno)
per_mouse = mouse_means(tr)
per_mouse_all = mouse_means(tr_all) # variant with the empty files kept
print("Training sessions used (up to the late probe):", len(tr), "; per mouse", per_mouse.sessions.min(), "to", per_mouse.sessions.max())
print("Sessions without port entries (no wait or extra nosepokes):", int((~tr.has_ports).sum()))
print("Ranges: rate", tr.rate.min().round(2), "to", tr.rate.max().round(1), "per min; rewards", tr.n_rewards.min(), "to", tr.n_rewards.max())
print(per_mouse.groupby("pheno").size().to_string())
Training sessions used (up to the late probe): 551 ; per mouse 7 to 19 Sessions without port entries (no wait or extra nosepokes): 0 Ranges: rate 0.02 to 39.7 per min; rewards 1 to 50 pheno DPR 9 PR 9 PS 18 RR20 6
print(f"Training files with 0 rewards: {(tr_all.n_rewards == 0).sum()}; with 0 active nosepokes: {(tr_all.n_active == 0).sum()}; "
f"both, left out: {len(empty)} of {len(tr_all)}")
print(empty[["subject", "pheno", "stage", "date", "size_mb", "series_names", "n_events", "last_event_s"]]
.round(2).sort_values(["pheno", "subject", "date"]).to_string(index=False))
print("\nFile size (MB) of all training files, median and range:", round(tr_all.size_mb.median(), 2), round(tr_all.size_mb.min(), 2), round(tr_all.size_mb.max(), 2))
print("Empty files by phenotype:", empty.pheno.value_counts().to_dict(), "; mice:", empty.subject.nunique())
same_day = tr_all.groupby(["subject", "date"]).size()
print("Mouse-days with more than one training file:", same_day[same_day > 1].to_dict())
print("Other files of those days:")
print(tr_all.set_index(["subject", "date"]).loc[same_day[same_day > 1].index][["stage", "series_names", "n_active", "n_rewards", "last_event_s"]].round(1).to_string())
print("Training sessions kept:", len(tr))
# variant: one file per mouse and day, the one with the most rewards
per_mouse_day1 = mouse_means(tr.sort_values("n_rewards").drop_duplicates(["subject", "date"], keep="last"))
Training files with 0 rewards: 21; with 0 active nosepokes: 21; both, left out: 21 of 572
subject pheno stage date size_mb series_names n_events last_event_s
140.306 PS RI60 2019-08-30 0.22 left_nose_poke_times,reward_port_entry_times 30 170.85
100.258 RR20 RR20 2019-04-29 0.22 reward_port_entry_times 291 3563.65
100.258 RR20 RR20 2019-04-30 0.22 reward_port_entry_times 200 3540.90
100.258 RR20 RR20 2019-05-01 0.22 reward_port_entry_times 138 3589.75
100.258 RR20 RR20 2019-05-02 0.22 reward_port_entry_times 192 3192.00
100.258 RR20 RR20 2019-05-03 0.22 reward_port_entry_times 132 3413.25
96.259 RR20 RR20 2019-04-29 0.23 reward_port_entry_times 626 3590.35
96.259 RR20 RR20 2019-04-30 0.22 reward_port_entry_times 466 3594.55
96.259 RR20 RR20 2019-05-01 0.22 reward_port_entry_times 276 3508.20
96.259 RR20 RR20 2019-05-02 0.22 reward_port_entry_times 379 3373.05
96.259 RR20 RR20 2019-05-03 0.22 reward_port_entry_times 250 3220.95
97.257 RR20 RR20 2019-04-29 0.22 reward_port_entry_times 200 3556.80
97.257 RR20 RR20 2019-04-30 0.22 reward_port_entry_times 151 2422.10
97.257 RR20 RR20 2019-05-01 0.22 reward_port_entry_times 69 1736.70
97.257 RR20 RR20 2019-05-02 0.22 reward_port_entry_times 138 3588.95
97.257 RR20 RR20 2019-05-03 0.22 reward_port_entry_times 92 3271.15
99.257 RR20 RR20 2019-04-29 0.22 reward_port_entry_times 482 3480.85
99.257 RR20 RR20 2019-04-30 0.22 reward_port_entry_times 220 2830.80
99.257 RR20 RR20 2019-05-01 0.22 reward_port_entry_times 238 3404.60
99.257 RR20 RR20 2019-05-02 0.22 reward_port_entry_times 262 3291.30
99.257 RR20 RR20 2019-05-03 0.22 reward_port_entry_times 155 3391.80
File size (MB) of all training files, median and range: 0.24 0.22 591.44
Empty files by phenotype: {'RR20': 20, 'PS': 1} ; mice: 5
Mouse-days with more than one training file: {('112.283', '2019-06-07'): 2, ('114.273', '2019-06-12'): 2, ('115.273', '2019-06-28'): 2, ('140.306', '2019-08-30'): 2, ('271.396', '2020-07-07'): 2, ('348.393', '2020-07-23'): 2, ('81.236', '2019-02-06'): 3, ('88.239', '2019-02-19'): 2, ('88.239', '2019-02-28'): 2, ('88.239', '2019-03-01'): 2, ('92.246', '2019-03-11'): 2}
Other files of those days:
stage series_names n_active n_rewards last_event_s
subject date
112.283 2019-06-07 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 59 24 2300.8
2019-06-07 RI60 left_nose_poke_times,left_reward_times,reward_port_entry_times,right_nose_poke_times 150 20 1388.6
114.273 2019-06-12 RI60 left_nose_poke_times,left_reward_times,reward_port_entry_times,right_nose_poke_times 81 17 1720.2
2019-06-12 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 383 36 3586.8
115.273 2019-06-28 RI60 reward_port_entry_times,right_nose_poke_times,right_reward_times 7 3 1698.2
2019-06-28 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 101 42 3599.0
140.306 2019-08-30 RI60 left_nose_poke_times,reward_port_entry_times 0 0 170.8
2019-08-30 RI60 left_nose_poke_times,left_reward_times,reward_port_entry_times,right_nose_poke_times 439 50 3556.8
271.396 2020-07-07 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 2 2 719.6
2020-07-07 RI60 left_nose_poke_times,left_reward_times,reward_port_entry_times,right_nose_poke_times 206 44 3661.7
348.393 2020-07-23 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 4 3 3292.3
2020-07-23 RI60 left_nose_poke_times,left_reward_times,reward_port_entry_times,right_nose_poke_times 164 50 3349.4
81.236 2019-02-06 RI60 reward_port_entry_times,right_nose_poke_times,right_reward_times 317 6 362.0
2019-02-06 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 464 28 2734.4
2019-02-06 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 1370 42 3600.4
88.239 2019-02-19 RI60 left_nose_poke_times,left_reward_times,reward_port_entry_times,right_nose_poke_times 2 1 530.6
2019-02-19 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 772 44 4128.3
2019-02-28 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 196 9 1393.1
2019-02-28 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 912 23 3601.0
2019-03-01 RI60 left_nose_poke_times,left_reward_times,reward_port_entry_times,right_nose_poke_times 1 1 366.9
2019-03-01 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 1128 49 3592.8
92.246 2019-03-11 RI60 left_nose_poke_times,reward_port_entry_times,right_nose_poke_times,right_reward_times 7 4 552.3
2019-03-11 RI60 left_nose_poke_times,left_reward_times,reward_port_entry_times,right_nose_poke_times 144 42 3589.8
Training sessions kept: 551
The 20 RR20 files without rewards or nosepokes hold port entries only, so their nosepokes and rewards are not in the dandiset; they come from four RR20 mice over the same five days. The PS file of 140.306 ends after 170 s, holds 30 events (inactive nosepokes and port entries) with no active nosepoke and no reward, and is followed the same day by a full session of 50 rewards. The rule that leaves these 21 files out of the training measures is no active nosepoke and no reward. It was set after the first round of verification. A false start is a reason that fits some kept files as well (271.396 with 2 rewards in 720 s, 88.239 with 1 reward on two days, 92.246 with 4, 348.393 and 115.273 with 3), which hold active nosepokes and rewards and so are kept by the rule. The sections of fig2c, fig2n and figs1i also give the result with the 21 files kept.
Ten other mouse-days hold two or three RI60 files. Most pair a short file of 1 to 7 rewards with a full session, but some do not (81.236 on 2019-02-06 has files of 6, 28 and 42 rewards). Each file counts as a session here, as the dandiset deposits them; fig2c and fig2n also give the result with one file per mouse-day, the one with the most rewards.
fig1d-ri60-vs-rr20-shocks¶
Mice trained on RI60 accept more shocks in the shock probes than mice trained on RR20 (main effect of schedule, F1,43 = 6.37, p < 0.05; schedule by time interaction, F1,43 = 4.54, p < 0.05), and RI60 mice accept more shocks on the late probe than on the early one (p < 0.01).
def fig1d(long):
aov, w = mixed_anova(long, "behavior")
ri = w[w.behavior == "RI60"]
t_ = stats.ttest_rel(ri["late"], ri["early"])
return aov, w, t_
aov, w, t_ = fig1d(fp_long)
print(fmt(aov))
print(w.groupby("behavior")[["early", "late"]].agg(["mean", "std", "size"]).round(2).to_string())
print(f"RI60 late vs early: paired t = {t_.statistic:.2f}, p = {t_.pvalue:.2g}, Bonferroni x2 p = {min(1, 2 * t_.pvalue):.2g}")
rr = w[w.behavior == "RR20"]
print(f"RR20 late vs early: paired t p = {stats.ttest_rel(rr['late'], rr['early']).pvalue:.2g} (n = {len(rr)})")
aov_s, w_s, t_s = fig1d(fp_long_sum)
print("\nVariant, shocks summed over a probe day's files:"); print(fmt(aov_s)); print(
f"RI60 late vs early Bonferroni p = {min(1, 2 * t_s.pvalue):.2g}")
fig, ax = plt.subplots(figsize=(3.5, 3))
for sched, c in [("RI60", "k"), ("RR20", "grey")]:
d = w[w.behavior == sched]
for _, r in d.iterrows():
ax.plot([0, 1], [r.early, r.late], c, alpha=.3, lw=.8)
ax.plot([0, 1], [d.early.mean(), d.late.mean()], c, lw=3, label=f"{sched} (n={len(d)})")
ax.set_xticks([0, 1], ["early", "late"]); ax.set_ylabel("shocks"); ax.legend(); plt.tight_layout(); plt.show()
print("Plotted counts span", w[["early", "late"]].min().min(), "to", w[["early", "late"]].max().max())
F df p
behavior 3.46 1,35 0.071
probe 5.35 1,35 0.027
behavior x probe 0.30 1,35 0.59
probe early late
mean std size mean std size
behavior
RI60 11.38 7.77 32 16.31 14.21 32
RR20 4.80 1.64 5 6.60 5.37 5
RI60 late vs early: paired t = 2.25, p = 0.032, Bonferroni x2 p = 0.063
RR20 late vs early: paired t p = 0.55 (n = 5)
Variant, shocks summed over a probe day's files:
F df p
behavior 3.48 1,35 0.071
probe 4.65 1,35 0.038
behavior x probe 0.29 1,35 0.6
RI60 late vs early Bonferroni p = 0.089
Plotted counts span 1.0 to 53.0
Result: not reproduced. In the 32 RI60 and 5 RR20 mice with both probes, RI60 mice accept more shocks on average (early 11.38, late 16.31, against 4.80 and 6.60), but the schedule effect has p = 0.071 (F1,35 = 3.46) and the interaction p = 0.59. The RI60 increase from early to late has paired p = 0.032, which is 0.063 after the Bonferroni correction, above the claim's 0.01. The dandiset holds 6 RR20 mice, 5 with both probes, where Figure 1D has 9. Summing a probe day's files changes none of this.
fig1e-omission-ri60-longer¶
RI60-trained mice take longer to complete the omission probe than RR20-trained mice (unpaired t-test, p < 0.05; n = 20 and 7).
o = fp_el.dropna(subset=["omission_min"])
print(o.groupby("schedule").omission_min.agg(["mean", "std", "size", "min", "max"]).round(2).to_string())
t1 = stats.ttest_ind(o[o.schedule == "RI60"].omission_min, o[o.schedule == "RR20"].omission_min)
print(f"Unpaired t-test RI60 vs RR20: t = {t1.statistic:.2f}, p = {t1.pvalue:.3g}")
print("Variance ratio RI60 / RR20:", round(o[o.schedule == "RI60"].omission_min.var() / o[o.schedule == "RR20"].omission_min.var(), 1))
print(f"Welch variant: p = {stats.ttest_ind(o[o.schedule == 'RI60'].omission_min, o[o.schedule == 'RR20'].omission_min, equal_var=False).pvalue:.3g}")
print("Mice with fewer than 50 rewards (60 min):", (o.omission_min >= 60).groupby(o.schedule).sum().to_dict())
print("RI60 omission times by phenotype:", o[o.schedule == "RI60"].groupby("pheno").omission_min.agg(["mean", "size"]).round(1).to_dict("index"))
mean std size min max
schedule
RI60 33.97 16.68 19 17.10 60.0
RR20 17.72 1.05 4 17.08 19.3
Unpaired t-test RI60 vs RR20: t = 1.91, p = 0.0697
Variance ratio RI60 / RR20: 251.1
Welch variant: p = 0.000496
Mice with fewer than 50 rewards (60 min): {'RI60': 4, 'RR20': 0}
RI60 omission times by phenotype: {'DPR': {'mean': 31.7, 'size': 5}, 'PR': {'mean': 51.6, 'size': 4}, 'PS': {'mean': 28.1, 'size': 10}}
Result: partially reproduced. RI60 mice take 33.97 min to complete the omission probe (n = 19) and RR20 mice 17.72 min (n = 4); the unpaired t-test gives p = 0.070, so the direction holds without significance. The RR20 times vary little (SD 1.05, a variance 251 times smaller than RI60's), and a Welch test gives p = 0.0005. The dandiset holds 4 RR20 omission probes where the paper has 7, and 19 RI60 ones where it has 20.
fig1f-omission-shock-no-correlation¶
Among RI60 mice, shocks received on the late probe and omission completion time are not significantly correlated (r = 0.42, ns).
o1 = fp_el[(fp_el.schedule == "RI60")].dropna(subset=["omission_min", "late"])
r1 = stats.pearsonr(o1.late, o1.omission_min)
print(f"Pearson r = {r1.statistic:.3f}, p = {r1.pvalue:.3g}, n = {len(o1)}")
o1e = fp_el[(fp_el.schedule == "RI60")].dropna(subset=["omission_min", "early"])
print(f"Variant, early probe: r = {stats.pearsonr(o1e.early, o1e.omission_min).statistic:.3f}, p = {stats.pearsonr(o1e.early, o1e.omission_min).pvalue:.3g}, n = {len(o1e)}")
print(f"Spearman, late probe: rho = {stats.spearmanr(o1.late, o1.omission_min).statistic:.3f}, p = {stats.spearmanr(o1.late, o1.omission_min).pvalue:.3g}")
Pearson r = 0.335, p = 0.175, n = 18 Variant, early probe: r = 0.592, p = 0.0076, n = 19 Spearman, late probe: rho = 0.403, p = 0.097
Result: reproduced. Late probe shocks and omission time are not significantly correlated (r = 0.335, p = 0.175, n = 18; the paper gives r = 0.42, ns). With the early probe the correlation is significant (r = 0.592, p = 0.0076, n = 19).
fig2a-phenotype-rule¶
RI60 mice are split by their shock probe counts into 9 PR, 9 DPR and 18 PS mice, by the rule in the Methods (DPR when the count rises by more than 85%; otherwise PR when the early count exceeds 13).
ri = fp_el[fp_el.schedule == "RI60"].copy()
print("Stored labels of RI60 mice:", ri.pheno.value_counts().to_dict())
both = ri.dropna(subset=["early", "late"]).copy()
both["rule"] = [seiler.classify(e, l) for e, l in zip(both.early, both.late)]
both["change_pct"] = 100 * (both.late - both.early) / both.early
print(f"Mice with both probes: {len(both)}; rule agrees with the stored label for {(both.rule == both.pheno).sum()}")
print(pd.crosstab(both.pheno, both.rule).to_string())
print("\nDisagreements:")
print(both[both.rule != both.pheno][["early", "late", "change_pct", "pheno", "rule"]].round(1).to_string())
print(f"\nMedian early count of the RI60 mice: {ri.early.median():.1f} (Methods: median split at 13)")
print(f"75th percentile of the percent change: {both.change_pct.quantile(.75):.1f} (Methods: top quartile, above 85%)")
both["rule_ge13"] = ["DPR" if 100 * (l - e) / e > 85 else ("PR" if e >= 13 else "PS") for e, l in zip(both.early, both.late)]
print("Variant, PR when early >= 13: agrees for", int((both.rule_ge13 == both.pheno).sum()), "of", len(both))
bs = seiler.early_late(fp_probes, "shocks_sum").reindex(both.index)
both["rule_sum"] = [seiler.classify(e, l) for e, l in zip(bs.early, bs.late)]
print("Variant, counts summed over a day's files: agrees for", int((both.rule_sum == both.pheno).sum()), "of", len(both))
one = ri[ri[["early", "late"]].isna().any(axis=1)]
print("\nMice with one probe, label and available count:")
print(one[["early", "late", "pheno"]].to_string())
Stored labels of RI60 mice: {'PS': 18, 'PR': 9, 'DPR': 9}
Mice with both probes: 32; rule agrees with the stored label for 31
rule DPR PR PS
pheno
DPR 9 0 0
PR 0 5 1
PS 0 0 17
Disagreements:
early late change_pct pheno rule
subject
048.392 13.0 18.0 38.5 PR PS
Median early count of the RI60 mice: 9.0 (Methods: median split at 13)
75th percentile of the percent change: 87.7 (Methods: top quartile, above 85%)
Variant, PR when early >= 13: agrees for 32 of 32
Variant, counts summed over a day's files: agrees for 30 of 32
Mice with one probe, label and available count:
early late pheno
subject
028.392 28.0 NaN PR
62.207 28.0 NaN PR
78.214 NaN 2.0 PS
89.247 NaN 23.0 PR
Result: partially reproduced. The stored labels count 9 PR, 9 DPR and 18 PS, as in Figure 2A. These counts are the stored labels, so they match by construction; the informative check is the rule. The Methods rule gives the stored label for 31 of the 32 mice with both probes; mouse 048.392 has 13 shocks on its early probe and a 38.5% rise, so the rule ("more than 13") makes it PS, while it is stored as PR. With PR at 13 or more all 32 agree. The 85% cut matches the top quartile of change here (75th percentile 87.7%), but the median early count is 9.0, not 13, so the "median split" at 13 is not a median of these mice.
fig2b-only-dpr-increase¶
There is an interaction of phenotype and probe on shocks received (p < 0.0001), and only DPR mice receive significantly more shocks on the late probe than the early one.
ri_long = fp_long[fp_long.behavior == "RI60"]
aov2, w2 = mixed_anova(ri_long, "pheno")
print(fmt(aov2))
print(w2.groupby("pheno")[["early", "late"]].agg(["mean", "std", "size"]).round(2).to_string())
for ph_ in ["PR", "DPR", "PS"]:
d = w2[w2.pheno == ph_]
p = stats.ttest_rel(d.late, d.early).pvalue
print(f"{ph_}: late - early mean {np.mean(d.late - d.early):.2f}, paired t p = {p:.2g}, Bonferroni x3 p = {min(1, 3 * p):.2g}, n = {len(d)}")
F df p
pheno 25.54 2,29 4e-07
probe 13.35 1,29 0.001
pheno x probe 26.37 2,29 3e-07
probe early late
mean std size mean std size
pheno
DPR 11.56 5.81 9 32.11 11.34 9
PR 23.50 7.26 6 20.50 13.46 6
PS 7.00 2.85 17 6.47 4.54 17
PR: late - early mean -3.00, paired t p = 0.59, Bonferroni x3 p = 1, n = 6
DPR: late - early mean 20.56, paired t p = 0.00014, Bonferroni x3 p = 0.00041, n = 9
PS: late - early mean -0.53, paired t p = 0.56, Bonferroni x3 p = 1, n = 17
Result: reproduced. The phenotype by probe interaction has F2,29 = 26.37, p below 0.0001. DPR mice rise from 11.56 to 32.11 shocks (Bonferroni p = 0.00041); PR (23.50 to 20.50) and PS (7.00 to 6.47) do not change (corrected p = 1 for both).
fig2c-nosepoke-rate¶
During RI60 training PR and DPR mice nosepoke at higher rates than PS mice (main effect of phenotype, p < 0.0001).
pm = per_mouse[per_mouse.pheno.isin(["PR", "DPR", "PS"])]
print(pm.groupby("pheno").rate.agg(["mean", "std", "size", "min", "max"]).round(2).to_string())
print(f"One-way ANOVA: p = {stats.f_oneway(*[pm[pm.pheno == g].rate for g in ['PR', 'DPR', 'PS']]).pvalue:.3g}")
print(tukey(pm.rate, pm.pheno).to_string())
trr = tr[tr.pheno.isin(["PR", "DPR", "PS"])]
m1 = smf.mixedlm("rate ~ C(pheno) + day", trr, groups=trr.subject).fit(reml=False)
m0 = smf.mixedlm("rate ~ day", trr, groups=trr.subject).fit(reml=False)
lr = 2 * (m1.llf - m0.llf)
print(f"Variant, mixed model over {len(trr)} sessions: phenotype likelihood ratio {lr:.1f}, p = {stats.chi2.sf(lr, 2):.2g}")
pz = per_mouse_all[per_mouse_all.pheno.isin(["PR", "DPR", "PS"])]
print("Variant with the empty files kept:", pz.groupby("pheno").rate.mean().round(2).to_dict(),
f"; ANOVA p = {stats.f_oneway(*[pz[pz.pheno == g].rate for g in ['PR', 'DPR', 'PS']]).pvalue:.3g}")
print(tukey(pz.rate, pz.pheno).to_string())
p1 = per_mouse_day1[per_mouse_day1.pheno.isin(["PR", "DPR", "PS"])]
print("Variant, one file per mouse-day:", p1.groupby("pheno").rate.mean().round(2).to_dict(),
f"; ANOVA p = {stats.f_oneway(*[p1[p1.pheno == g].rate for g in ['PR', 'DPR', 'PS']]).pvalue:.3g}")
print(tukey(p1.rate, p1.pheno).to_string())
print("Mean rate by sex within PS (sex is unbalanced across phenotypes):", pm.join(fp_subj.set_index("subject").sex).groupby(["pheno", "sex"]).rate.agg(["mean", "size"]).round(2).to_dict("index"))
mean std size min max pheno DPR 12.79 5.28 9 7.55 20.74 PR 14.20 3.61 9 8.44 18.59 PS 5.27 3.72 18 1.50 14.50 One-way ANOVA: p = 4.94e-06
group1 group2 meandiff p-adj lower upper reject 0 DPR PR 1.4102 0.7508 -3.3655 6.1860 False 1 DPR PS -7.5152 0.0003 -11.6511 -3.3793 True 2 PR PS -8.9255 0.0000 -13.0614 -4.7896 True
Variant, mixed model over 492 sessions: phenotype likelihood ratio 26.0, p = 2.3e-06
Variant with the empty files kept: {'DPR': 12.79, 'PR': 14.2, 'PS': 5.24} ; ANOVA p = 4.27e-06
group1 group2 meandiff p-adj lower upper reject
0 DPR PR 1.4102 0.7490 -3.3460 6.1665 False
1 DPR PS -7.5513 0.0002 -11.6704 -3.4323 True
2 PR PS -8.9616 0.0000 -13.0806 -4.8425 True
Variant, one file per mouse-day: {'DPR': 13.31, 'PR': 14.2, 'PS': 5.36} ; ANOVA p = 7.26e-06
group1 group2 meandiff p-adj lower upper reject
0 DPR PR 0.8860 0.9001 -4.0819 5.8538 False
1 DPR PS -7.9549 0.0002 -12.2572 -3.6526 True
2 PR PS -8.8409 0.0000 -13.1431 -4.5386 True
Mean rate by sex within PS (sex is unbalanced across phenotypes): {('DPR', 'F'): {'mean': 10.81, 'size': 4}, ('DPR', 'M'): {'mean': 14.37, 'size': 5}, ('PR', 'F'): {'mean': 8.44, 'size': 1}, ('PR', 'M'): {'mean': 14.92, 'size': 8}, ('PS', 'F'): {'mean': 5.28, 'size': 17}, ('PS', 'M'): {'mean': 5.18, 'size': 1}}
Result: reproduced. Over RI60 training PR mice make 14.20 and DPR mice 12.79 active nosepokes per minute, PS mice 5.27 (one-way ANOVA p = 4.9e-06); Tukey gives PR > PS with p below 0.0001 and DPR > PS with p = 0.0003. A mixed model over 492 sessions agrees (p = 2.3e-06), as do the variants with the empty files kept (p = 4.3e-06) and with one file per mouse-day (p = 7.3e-06). Sex is confounded with phenotype: 17 of 18 PS mice are female and 8 of 9 PR mice male.
fig2d-omission-pr-vs-ps¶
PR mice take longer than PS mice to complete the omission probe (PR 51.50 +- 17, PS 27.92 +- 13.65; one-way ANOVA F2,18 = 4.36, p < 0.05; Tukey PR vs PS p < 0.05).
o2 = fp_el[(fp_el.schedule == "RI60")].dropna(subset=["omission_min"])
print(o2.groupby("pheno").omission_min.agg(["mean", "std", "size", "min", "max"]).round(2).to_string(), "(minutes; paper n PR 4, DPR 5, PS 11)")
an = stats.f_oneway(*[o2[o2.pheno == g].omission_min for g in ["PR", "DPR", "PS"]])
print(f"One-way ANOVA: F = {an.statistic:.2f}, p = {an.pvalue:.3g}, df = 2,{len(o2) - 3}")
print(tukey(o2.omission_min, o2.pheno).to_string())
print("Values per mouse (min):", o2.sort_values("pheno")[["pheno", "omission_min"]].round(1).to_dict("records"))
mean std size min max pheno DPR 31.67 11.90 5 18.72 45.5 PR 51.65 16.70 4 26.60 60.0 PS 28.06 14.83 10 17.10 60.0 (minutes; paper n PR 4, DPR 5, PS 11) One-way ANOVA: F = 3.85, p = 0.0432, df = 2,16
group1 group2 meandiff p-adj lower upper reject
0 DPR PR 19.9833 0.1328 -5.1860 45.1527 False
1 DPR PS -3.6117 0.8936 -24.1624 16.9390 False
2 PR PS -23.5950 0.0365 -45.7923 -1.3977 True
Values per mouse (min): [{'pheno': 'DPR', 'omission_min': 18.7}, {'pheno': 'DPR', 'omission_min': 38.7}, {'pheno': 'DPR', 'omission_min': 35.6}, {'pheno': 'DPR', 'omission_min': 45.5}, {'pheno': 'DPR', 'omission_min': 19.8}, {'pheno': 'PR', 'omission_min': 60.0}, {'pheno': 'PR', 'omission_min': 26.6}, {'pheno': 'PR', 'omission_min': 60.0}, {'pheno': 'PR', 'omission_min': 60.0}, {'pheno': 'PS', 'omission_min': 19.2}, {'pheno': 'PS', 'omission_min': 19.9}, {'pheno': 'PS', 'omission_min': 24.4}, {'pheno': 'PS', 'omission_min': 23.8}, {'pheno': 'PS', 'omission_min': 48.4}, {'pheno': 'PS', 'omission_min': 17.7}, {'pheno': 'PS', 'omission_min': 60.0}, {'pheno': 'PS', 'omission_min': 17.1}, {'pheno': 'PS', 'omission_min': 33.0}, {'pheno': 'PS', 'omission_min': 17.2}]
Result: reproduced. PR mice take 51.65 min (n = 4) and PS mice 28.06 min (n = 10), both within the stated SDs of 51.50 and 27.92; one-way ANOVA F2,16 = 3.85, p = 0.043 (paper F2,18 = 4.36); Tukey PR vs PS p = 0.037. Three of the four PR mice reach the 60 min limit. The values fit minutes, not the seconds the Results print.
fig2k-ps-wait-longer¶
After a reward PS mice wait longer before nosepoking again (38 +- 15 s) than PR (21 +- 5 s) and DPR (22 +- 4 s) mice (one-way ANOVA p < 0.01; Tukey p < 0.01 for each).
print(pm.groupby("pheno").wait.agg(["mean", "std", "size", "min", "max"]).round(2).to_string())
print(f"One-way ANOVA: p = {stats.f_oneway(*[pm[pm.pheno == g].wait.dropna() for g in ['PR', 'DPR', 'PS']]).pvalue:.3g}")
print(tukey(pm.wait, pm.pheno).to_string())
wm = tr[tr.pheno.isin(["PR", "DPR", "PS"])].groupby(["pheno", "subject"]).agg(
mean_wait=("wait", "mean"), median_session_wait=("wait", "median"), max_session_wait=("wait", "max"),
sessions=("wait", "count"), waits=("n_waits", "sum")).round(1)
print("Per mouse: mean and median of session mean waits (s), and the number of waits:")
print(wm.sort_values(["pheno", "mean_wait"]).to_string())
med = tr[tr.pheno.isin(["PR", "DPR", "PS"])].groupby(["subject", "pheno"]).wait.median().reset_index()
print("Variant, median over sessions per mouse:", med.groupby("pheno").wait.mean().round(2).to_dict())
mean std size min max pheno DPR 21.86 3.90 9 16.42 28.33 PR 21.05 3.55 9 15.32 25.95 PS 53.94 44.67 18 17.95 171.00 One-way ANOVA: p = 0.0174
group1 group2 meandiff p-adj lower upper reject
0 DPR PR -0.8084 0.9984 -38.0156 36.3987 False
1 DPR PS 32.0769 0.0512 -0.1454 64.2993 False
2 PR PS 32.8854 0.0447 0.6630 65.1077 True
Per mouse: mean and median of session mean waits (s), and the number of waits:
mean_wait median_session_wait max_session_wait sessions waits
pheno subject
DPR 64.205 16.4 16.6 24.0 16 761
349.393 18.7 15.7 49.9 13 609
81.236 18.8 18.8 39.1 15 655
346.394 20.8 18.2 41.0 12 588
88.239 21.7 20.0 36.6 16 614
87.239 22.1 23.3 31.2 11 386
333.393 22.7 21.2 37.4 12 567
272.396 27.2 24.1 44.9 12 550
334.394 28.3 20.6 95.1 13 606
PR 141.308 15.3 15.8 40.4 12 488
028.392 17.2 14.6 47.6 15 685
62.207 19.4 18.4 37.7 19 866
89.247 20.1 21.3 30.7 14 495
61.207 20.8 19.3 50.2 17 761
80.236 21.6 23.4 31.1 12 551
111.285 24.0 23.4 38.9 14 622
048.392 25.1 18.0 70.1 13 606
63.207 26.0 23.6 43.4 14 551
PS 347.393 18.0 16.9 28.7 12 573
249.391 18.6 17.9 37.6 12 576
332.393 19.2 20.0 30.8 12 552
90.247 19.7 19.9 26.8 13 590
112.283 32.6 27.6 64.8 15 515
78.214 39.5 39.0 62.4 12 500
110.271 39.9 33.7 81.3 12 421
94.246 39.9 37.4 102.4 15 452
114.273 40.8 38.6 73.3 14 596
115.273 42.5 26.8 235.5 14 579
113.283 44.3 43.8 78.3 13 402
271.396 44.6 32.2 159.4 13 544
92.246 45.9 40.1 84.0 14 518
93.246 47.0 39.9 114.2 13 373
75.214 67.3 60.6 140.9 16 461
140.306 71.4 22.3 655.3 13 520
348.393 168.7 39.2 1082.5 12 450
139.298 171.0 23.7 1893.0 14 375
Variant, median over sessions per mouse: {'DPR': 19.84, 'PR': 19.76, 'PS': 32.2}
Result: partially reproduced. PS mice wait longest after the rewarded port entry (53.94 s, SD 44.67), against PR 21.05 s and DPR 21.86 s, which are within tolerance; the PS mean is above its tolerance (23 to 53 s), pulled up by a few mice (maximum 171 s; with each mouse's median session the PS mean is 32.2 s). The two highest PS mice, 139.298 (171.0 s) and 348.393 (168.7 s), each have one session with a mean wait over 1000 s and median session waits of 23.7 and 39.2 s. The ANOVA has p = 0.017; Tukey PS > PR has p = 0.045 and PS > DPR p = 0.051, so one of the two pairwise tests fails.
fig2l-extra-nosepokes¶
PS mice make fewer extra nosepokes between a rewarded nosepoke and the rewarded port entry than PR mice (PS 1.28 +- 0.85, PR 3.23 +- 1.65, DPR 2.1 +- 2.63; Tukey PS vs PR p < 0.05).
print(pm.groupby("pheno").extra.agg(["mean", "std", "size", "min", "max"]).round(2).to_string())
print(f"One-way ANOVA: p = {stats.f_oneway(*[pm[pm.pheno == g].extra.dropna() for g in ['PR', 'DPR', 'PS']]).pvalue:.3g}")
print(tukey(pm.extra, pm.pheno).to_string())
mean std size min max pheno DPR 2.28 2.84 9 0.29 8.15 PR 3.33 1.81 9 0.36 5.82 PS 1.61 1.71 18 0.40 7.77 One-way ANOVA: p = 0.139
group1 group2 meandiff p-adj lower upper reject 0 DPR PR 1.0520 0.5317 -1.3334 3.4374 False 1 DPR PS -0.6702 0.7080 -2.7361 1.3956 False 2 PR PS -1.7222 0.1172 -3.7881 0.3436 False
Result: partially reproduced. PS mice make the fewest extra nosepokes (1.61 per reward, PR 3.33, DPR 2.28), all within the stated SDs, but Tukey PS vs PR gives p = 0.117 (ANOVA p = 0.139).
fig2m-inter-reward-interval¶
PS mice have longer inter-reward intervals (79.77 +- 26.54 s) than PR (71.2 +- 32.3 s) and DPR (69.58 +- 35.18 s) mice (Kolmogorov-Smirnov, p < 0.0001 for each).
iri = {g: np.concatenate(tr[(tr.pheno == g) & (tr.stage == "RI60")].iri.tolist()) for g in ["PR", "DPR", "PS"]}
for g, v in iri.items():
print(f"{g}: mean {v.mean():.2f} s, SD {v.std(ddof=1):.2f}, median {np.median(v):.1f}, n intervals {len(v)}, range {v.min():.1f} to {v.max():.0f}")
for g in ["PR", "DPR"]:
ks = stats.ks_2samp(iri["PS"], iri[g])
print(f"KS PS vs {g}: D = {ks.statistic:.3f}, p = {ks.pvalue:.2g}")
mouse_iri = tr[tr.stage == "RI60"].groupby(["subject", "pheno"]).iri.apply(lambda x: np.concatenate(x.tolist()).mean()).reset_index()
print("Variant, per mouse mean interval:", mouse_iri.groupby("pheno").iri.agg(["mean", "size"]).round(1).to_dict("index"),
f"; ANOVA p = {stats.f_oneway(*[mouse_iri[mouse_iri.pheno == g].iri for g in ['PR', 'DPR', 'PS']]).pvalue:.3g}")
fig, ax = plt.subplots(figsize=(4, 3))
bins = np.arange(0, 301, 10)
for g, c in [("PR", "k"), ("DPR", "m"), ("PS", "c")]:
ax.hist(iri[g], bins, histtype="step", density=True, color=c, label=g)
ax.set_xlabel("inter-reward interval (s)"); ax.legend(); plt.tight_layout(); plt.show()
print("Intervals above 300 s (outside the axes):", {g: int((v > 300).sum()) for g, v in iri.items()})
PR: mean 71.96 s, SD 71.39, median 60.7, n intervals 5682, range 5.4 to 1678 DPR: mean 69.57 s, SD 65.38, median 60.4, n intervals 5336, range 3.3 to 1954 PS: mean 81.75 s, SD 88.80, median 64.2, n intervals 9053, range 3.8 to 2165
KS PS vs PR: D = 0.099, p = 5e-30
KS PS vs DPR: D = 0.106, p = 3.3e-33
Variant, per mouse mean interval: {'DPR': {'mean': 70.6, 'size': 9}, 'PR': {'mean': 72.5, 'size': 9}, 'PS': {'mean': 83.7, 'size': 18}} ; ANOVA p = 0.0326
Intervals above 300 s (outside the axes): {'PR': 70, 'DPR': 46, 'PS': 184}
Result: reproduced. Pooled RI60 inter-reward intervals average 81.75 s for PS, 71.96 s for PR and 69.57 s for DPR, close to the paper's 79.77, 71.2 and 69.58 s; KS tests give p = 5e-30 (PS vs PR) and 3.3e-33 (PS vs DPR). With one mean per mouse the ANOVA gives p = 0.033.
fig2n-rewards-per-session¶
Averaged over RI60 training, PS mice earn fewer rewards per session (39.91 +- 7.39) than DPR mice (46.56 +- 4.49; PR 45.33 +- 4.33); one-way ANOVA p < 0.05, Tukey PS vs DPR p < 0.05.
print(pm.groupby("pheno").rewards.agg(["mean", "std", "size", "min", "max"]).round(2).to_string())
an = stats.f_oneway(*[pm[pm.pheno == g].rewards for g in ["PR", "DPR", "PS"]])
print(f"One-way ANOVA: F = {an.statistic:.2f}, p = {an.pvalue:.3g}")
print(tukey(pm.rewards, pm.pheno).to_string())
print("Variant with the empty files kept:", pz.groupby("pheno").rewards.mean().round(2).to_dict(),
f"; ANOVA p = {stats.f_oneway(*[pz[pz.pheno == g].rewards for g in ['PR', 'DPR', 'PS']]).pvalue:.3g}")
print(tukey(pz.rewards, pz.pheno).to_string())
print("Variant, one file per mouse-day:", p1.groupby("pheno").rewards.mean().round(2).to_dict(),
f"; ANOVA p = {stats.f_oneway(*[p1[p1.pheno == g].rewards for g in ['PR', 'DPR', 'PS']]).pvalue:.3g}")
print(tukey(p1.rewards, p1.pheno).to_string())
mean std size min max pheno DPR 44.98 5.39 9 35.44 50.00 PR 44.66 3.79 9 36.50 47.92 PS 39.12 7.10 18 28.86 48.92 One-way ANOVA: F = 4.02, p = 0.0275
group1 group2 meandiff p-adj lower upper reject
0 DPR PR -0.3198 0.9931 -7.3072 6.6676 False
1 DPR PS -5.8619 0.0592 -11.9132 0.1894 False
2 PR PS -5.5421 0.0780 -11.5934 0.5092 False
Variant with the empty files kept: {'DPR': 44.98, 'PR': 44.66, 'PS': 38.95} ; ANOVA p = 0.0211
group1 group2 meandiff p-adj lower upper reject
0 DPR PR -0.3198 0.9929 -7.2406 6.6010 False
1 DPR PS -6.0374 0.0480 -12.0310 -0.0439 True
2 PR PS -5.7176 0.0640 -11.7112 0.2759 False
Variant, one file per mouse-day: {'DPR': 46.16, 'PR': 44.66, 'PS': 39.98} ; ANOVA p = 0.033
group1 group2 meandiff p-adj lower upper reject 0 DPR PR -1.4989 0.8588 -8.4795 5.4816 False 1 DPR PS -6.1853 0.0440 -12.2307 -0.1400 True 2 PR PS -4.6864 0.1540 -10.7317 1.3589 False
Result: partially reproduced. PS mice earn 39.12 rewards per RI60 session, DPR 44.98 and PR 44.66, all within tolerance of 39.91, 46.56 and 45.33, and the ANOVA has p = 0.028 (F = 4.02), but Tukey PS vs DPR gives p = 0.059. The result sits at the threshold: with the false start of 140.306 kept as a session of 0 rewards, Tukey p is 0.048, and with one file per mouse-day (means PS 39.98, DPR 46.16, PR 44.66) it is 0.044. The rule used, no active nosepoke and no reward, was set after the first round of verification and is the only one of the three that gives p above 0.05. The other short files of 1 to 4 rewards, which the one-file-per-mouse-day variant drops, fit its false-start reason as well. The claim should be read as borderline.
fig2o-escalation-timing¶
PR mice escalate their nosepoking as soon as they enter RI30, while DPR mice escalate later, during RI60.
ri30 = []
for r in fp[fp.stage == "RI30"].itertuples():
ev = events[r.asset_id]["events"]
ri30.append({"subject": r.subject, "rate": len(seiler.active_pokes(ev, r.side)) / (seiler.session_length(ev) / 60.0)})
ri30 = pd.DataFrame(ri30).groupby("subject").rate.mean().to_frame("ri30_rate").join(fp_subj.set_index("subject").pheno)
slopes = tr[tr.stage == "RI60"].groupby("subject").apply(lambda d: np.polyfit(d.day, d.rate, 1)[0] if len(d) > 2 else np.nan).rename("ri60_slope")
esc = ri30.join(slopes, how="outer")
esc["pheno"] = esc.index.map(fp_subj.set_index("subject").pheno)
esc = esc[esc.pheno.isin(["PR", "DPR", "PS"])]
print(esc.groupby("pheno").agg(ri30_rate_mean=("ri30_rate", "mean"), ri30_n=("ri30_rate", "count"),
ri60_slope_median=("ri60_slope", "median"), ri60_slope_mean=("ri60_slope", "mean"), ri60_n=("ri60_slope", "count")).round(3).to_string())
for g in ["DPR", "PS"]:
p = stats.mannwhitneyu(esc[esc.pheno == "PR"].ri30_rate.dropna(), esc[esc.pheno == g].ri30_rate.dropna()).pvalue
print(f"RI30 rate PR vs {g}: Mann-Whitney p = {p:.3g}")
p = stats.mannwhitneyu(esc[esc.pheno == "DPR"].ri60_slope.dropna(), esc[esc.pheno == "PR"].ri60_slope.dropna()).pvalue
print(f"RI60 slope DPR vs PR: Mann-Whitney p = {p:.3g}")
fig, ax = plt.subplots(figsize=(5, 3))
for g, c in [("PR", "k"), ("DPR", "m"), ("PS", "c")]:
d = tr[(tr.pheno == g) & (tr.stage == "RI60")].groupby("day").rate.mean()
ax.plot(d.index, d.values, c, label=g)
ax.set_xlabel("RI60 day"); ax.set_ylabel("active nosepokes / min"); ax.legend(); plt.tight_layout(); plt.show()
print("Plotted day means span", tr[tr.stage == "RI60"].groupby(["pheno", "day"]).rate.mean().min().round(2), "to", tr[tr.stage == "RI60"].groupby(["pheno", "day"]).rate.mean().max().round(2))
ri30_rate_mean ri30_n ri60_slope_median ri60_slope_mean ri60_n pheno DPR 3.032 9 0.809 0.943 9 PR 4.850 8 0.494 0.557 9 PS 2.216 18 0.098 0.212 18 RI30 rate PR vs DPR: Mann-Whitney p = 0.114 RI30 rate PR vs PS: Mann-Whitney p = 0.00627 RI60 slope DPR vs PR: Mann-Whitney p = 0.289
Plotted day means span 1.82 to 24.75
Result: partially reproduced. In RI30 PR mice nosepoke at 4.85 per minute, above PS (2.22, p = 0.0063) but not significantly above DPR (3.03, p = 0.114), so part (i) fails. Over RI60 the median slope of DPR mice (0.809 nosepokes/min per day) is above that of PR mice (0.494), so part (ii) holds, though this difference has p = 0.289.
figs1i-ri60-more-rewards¶
RI60-trained mice earn more rewards per training session than RR20-trained mice (unpaired t-test, p < 0.0001).
pmr = per_mouse.assign(schedule=np.where(per_mouse.pheno == "RR20", "RR20", "RI60"))
print(pmr.groupby("schedule").rewards.agg(["mean", "std", "size", "min", "max"]).round(2).to_string())
t2 = stats.ttest_ind(pmr[pmr.schedule == "RI60"].rewards, pmr[pmr.schedule == "RR20"].rewards)
print(f"Unpaired t-test: t = {t2.statistic:.2f}, p = {t2.pvalue:.2g}")
pmz = per_mouse_all.assign(schedule=np.where(per_mouse_all.pheno == "RR20", "RR20", "RI60"))
print("Variant with the empty files kept:", pmz.groupby("schedule").rewards.mean().round(2).to_dict(),
f"; t-test p = {stats.ttest_ind(pmz[pmz.schedule == 'RI60'].rewards, pmz[pmz.schedule == 'RR20'].rewards).pvalue:.2g}")
pmr["rate"] = per_mouse.rate
print("Active nosepokes per minute by schedule (Fig S1G, similar rates):", pmr.groupby("schedule").rate.agg(["mean", "std"]).round(2).to_dict("index"))
mean std size min max
schedule
RI60 41.97 6.54 36 28.86 50.00
RR20 22.63 11.55 6 6.86 42.14
Unpaired t-test: t = 5.96, p = 5.4e-07
Variant with the empty files kept: {'RI60': 41.89, 'RR20': 15.38} ; t-test p = 1.4e-11
Active nosepokes per minute by schedule (Fig S1G, similar rates): {'RI60': {'mean': 9.39, 'std': 5.81}, 'RR20': {'mean': 7.92, 'std': 3.97}}
Result: reproduced. RI60 mice earn 41.97 rewards per training session and RR20 mice 22.63 (t-test p = 5.4e-07, not below the paper's 0.0001). With the 21 empty files kept as sessions of 0 rewards (the 20 RR20 files that hold port entries only and the RI60 false start), RR20 has 15.38 and RI60 41.89, p = 1.4e-11. Only 6 RR20 mice are in the dandiset.
figs2b-males-more-shocks¶
Male mice accept more shocks than female mice in the shock probes (main effect of sex, F1,35 = 26.19, p < 0.0001).
aov3, w3 = mixed_anova(ri_long, "sex")
print(fmt(aov3))
print(w3.groupby("sex")[["early", "late"]].agg(["mean", "std", "size"]).round(2).to_string())
print("Sex by phenotype (RI60, all mice):", pd.crosstab(fp_subj[fp_subj.behavior == "RI60"].pheno, fp_subj[fp_subj.behavior == "RI60"].sex).to_dict())
F df p
sex 9.81 1,30 0.0039
probe 4.96 1,30 0.034
sex x probe 0.37 1,30 0.55
probe early late
mean std size mean std size
sex
F 8.25 4.14 20 12.15 11.90 20
M 16.58 9.65 12 23.25 15.52 12
Sex by phenotype (RI60, all mice): {'F': {'DPR': 4, 'PR': 1, 'PS': 17}, 'M': {'DPR': 5, 'PR': 8, 'PS': 1}}
Result: reproduced. Males accept more shocks than females (early 16.58 and late 23.25, against 8.25 and 12.15; sex effect F1,30 = 9.81, p = 0.0039), but not with the paper's p below 0.0001. Sex and phenotype are confounded in this cohort (PR 8 males of 9, PS 17 females of 18).
Fiber photometry cohort: dopamine axon signals¶
fig3e-ri30-dms-peaks¶
Peaks in DMS dopamine axon activity at rewarded nosepokes emerge during RI30 in PR mice, and more slowly across RI60 in DPR mice.
Blocked: the dandiset holds photometry only for RI60 and RR20 sessions (and one session labelled as a probe test), not for RI30, so the emergence of peaks during RI30 cannot be tested. The early and late RI60 PSTHs are shown in the data check above.
fig3f-dms-score-phenotype¶
The DMS rewarded-unrewarded peak score differs by phenotype (F2,33 = 8.16, p < 0.01) and changes across training (p < 0.001).
# One score per mouse and stage: the mean over its sessions in that stage (one mouse has two early sessions).
sc = ph[ph.pheno.isin(["PR", "DPR", "PS"])].groupby(["subject", "pheno", "fp_stage"])[
["DMS_score", "DLS_score", "DMS_score_nomask", "DLS_score_nomask"]].mean().reset_index()
print("Mice per phenotype and stage with a DMS score:")
print(sc.dropna(subset=["DMS_score"]).groupby(["pheno", "fp_stage"]).size().unstack().to_string())
print("Mice per phenotype and stage with a DLS score:")
print(sc.dropna(subset=["DLS_score"]).groupby(["pheno", "fp_stage"]).size().unstack().to_string())
def phenotype_model(df, col):
d = df.dropna(subset=[col]).rename(columns={col: "y"})
full = smf.mixedlm("y ~ C(pheno) * C(fp_stage)", d, groups=d.subject).fit(reml=False)
no_ph = smf.mixedlm("y ~ C(fp_stage)", d, groups=d.subject).fit(reml=False)
no_stage = smf.mixedlm("y ~ C(pheno)", d, groups=d.subject).fit(reml=False)
lr_p = 2 * (full.llf - no_ph.llf)
lr_s = 2 * (full.llf - no_stage.llf)
out = {"n_obs": len(d), "n_mice": d.subject.nunique(),
"phenotype_LR": lr_p, "phenotype_p": stats.chi2.sf(lr_p, 4),
"stage_LR": lr_s, "stage_p": stats.chi2.sf(lr_s, 3)}
for st in ["early", "late"]:
s = d[d.fp_stage == st]
out[f"anova_{st}_p"] = stats.f_oneway(*[s[s.pheno == g].y for g in ["PR", "DPR", "PS"] if (s.pheno == g).sum() > 1]).pvalue
return out, d.groupby(["pheno", "fp_stage"]).y.agg(["mean", "std", "size"])
Mice per phenotype and stage with a DMS score: fp_stage early late pheno DPR 9 7 PR 7 4 PS 14 10 Mice per phenotype and stage with a DLS score: fp_stage early late pheno DPR 9 7 PR 6 4 PS 14 10
res3f, tab3f = phenotype_model(sc, "DMS_score")
print(tab3f.round(3).unstack().to_string())
print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in res3f.items()})
print("Means over both stages:", sc.groupby("pheno").DMS_score.mean().round(3).to_dict())
d = sc.dropna(subset=["DMS_score"])
for st in ["early", "late"]:
s = d[d.fp_stage == st]
print(st, "Tukey:"); print(tukey(s.DMS_score, s.pheno).to_string())
d2 = d.pivot(index="subject", columns="fp_stage", values="DMS_score").dropna()
print(f"Early vs late, paired over {len(d2)} mice: mean change {np.mean(d2.late - d2.early):.3f}, paired t p = {stats.ttest_rel(d2.late, d2.early).pvalue:.3g}")
resn, tabn = phenotype_model(sc, "DMS_score_nomask")
print("Variant, the authors' processing without the dropout step:", {k: round(v, 4) for k, v in resn.items() if "p" in k})
print("Per-mouse scores span", round(d.DMS_score.min(), 2), "to", round(d.DMS_score.max(), 2))
fig, ax = plt.subplots(figsize=(4, 3))
for i, g in enumerate(["PR", "DPR", "PS"]):
for j, st in enumerate(["early", "late"]):
v = d[(d.pheno == g) & (d.fp_stage == st)].DMS_score
ax.scatter(np.full(len(v), i * 3 + j) + np.random.default_rng(0).uniform(-.15, .15, len(v)), v, s=10, c="k" if st == "early" else "r")
ax.plot([i * 3 + j - .3, i * 3 + j + .3], [v.mean()] * 2, "b")
ax.set_xticks([0.5, 3.5, 6.5], ["PR", "DPR", "PS"]); ax.set_ylabel("DMS score (z); black early, red late"); plt.tight_layout(); plt.show()
mean std size
fp_stage early late early late early late
pheno
DPR 1.483 1.879 1.385 1.932 9 7
PR 2.106 2.784 1.880 1.300 7 4
PS 0.900 1.251 0.636 1.350 14 10
{'n_obs': 51, 'n_mice': 34, 'phenotype_LR': np.float64(7.7843), 'phenotype_p': np.float64(0.0998), 'stage_LR': np.float64(6.5535), 'stage_p': np.float64(0.0876), 'anova_early_p': np.float64(0.1242), 'anova_late_p': np.float64(0.2696)}
Means over both stages: {'DPR': 1.656, 'PR': 2.352, 'PS': 1.047}
early Tukey:
group1 group2 meandiff p-adj lower upper reject 0 DPR PR 0.6224 0.5880 -0.9329 2.1776 False 1 DPR PS -0.5831 0.5244 -1.9016 0.7354 False 2 PR PS -1.2055 0.1104 -2.6340 0.2231 False late Tukey:
group1 group2 meandiff p-adj lower upper reject 0 DPR PR 0.9050 0.6320 -1.5922 3.4022 False 1 DPR PS -0.6275 0.6984 -2.5909 1.3360 False 2 PR PS -1.5325 0.2476 -3.8895 0.8246 False Early vs late, paired over 17 mice: mean change 0.490, paired t p = 0.0458
Variant, the authors' processing without the dropout step: {'phenotype_LR': np.float64(4.1299), 'phenotype_p': np.float64(0.3887), 'stage_p': np.float64(0.2742), 'anova_early_p': np.float64(0.1879), 'anova_late_p': np.float64(0.5631)}
Per-mouse scores span -0.12 to 5.69
fig3e-ri30-dms-peaks, the RI60 part¶
The claim is blocked, as its RI30 part needs photometry that is not in the dandiset. Its second part, peaks growing more slowly across RI60 in DPR mice, can be looked at with the early and late RI60 scores. This does not decide an outcome.
d3e = sc.dropna(subset=["DMS_score"]).pivot(index=["subject", "pheno"], columns="fp_stage", values="DMS_score").reset_index()
print("DMS score by phenotype and stage (all mice with that stage):", sc.groupby(["pheno", "fp_stage"]).DMS_score.mean().round(2).unstack().to_dict("index"))
for g in ["PR", "DPR", "PS"]:
x = d3e[(d3e.pheno == g)].dropna(subset=["early", "late"])
if len(x) > 1:
print(f"{g}: {len(x)} mice with both stages, early {x.early.mean():.2f}, late {x.late.mean():.2f}, change {np.mean(x.late - x.early):.2f}, paired t p = {stats.ttest_rel(x.late, x.early).pvalue:.2g}")
else:
print(f"{g}: {len(x)} mice with both stages")
DMS score by phenotype and stage (all mice with that stage): {'DPR': {'early': 1.48, 'late': 1.88}, 'PR': {'early': 2.11, 'late': 2.78}, 'PS': {'early': 0.9, 'late': 1.25}}
PR: 3 mice with both stages, early 1.68, late 2.85, change 1.17, paired t p = 0.19
DPR: 7 mice with both stages, early 1.55, late 1.88, change 0.33, paired t p = 0.3
PS: 7 mice with both stages, early 0.77, late 1.13, change 0.36, paired t p = 0.4
In the RI60 scores PR mice with both stages rise more (1.17 z, 3 mice) than DPR mice (0.33 z, 7 mice), and no group's change has p below 0.05. This gives no sign of the slower DPR rise across RI60 that the claim describes, though with 3 PR mice it has little power.
Result: not reproduced. The DMS score is ordered as the paper describes (mean PR 2.35, DPR 1.66, PS 1.05 z), but the phenotype effect has likelihood ratio p = 0.100, and neither stage alone is significant (one-way ANOVA p = 0.124 early, 0.270 late; no Tukey pair below 0.05). The score rises from early to late in the 17 mice with both (mean 0.49, paired p = 0.046; model stage p = 0.088). With the authors' processing without the dropout step the phenotype p is 0.389. RI30 sessions, part of the paper's test, are not in the dandiset, and the mice with a DMS score differ from Figure 3E's (early PR 7, DPR 9, PS 14 against 7, 9, 15; late PR 4, DPR 7, PS 10 against 5, 6, 13), so the comparison with F2,33 is not like for like.
fig3g-dms-score-shock-correlation¶
The DMS rewarded-unrewarded peak score correlates with the shocks a mouse receives in the shock probes (r = 0.53, p < 0.0001).
pairs = sc.merge(fp_probes[fp_probes.probe != ""][["subject", "probe", "shocks"]].rename(columns={"probe": "fp_stage"}), on=["subject", "fp_stage"])
def corr(df, x, y="shocks"):
d = df.dropna(subset=[x, y])
r = stats.pearsonr(d[x], d[y])
return f"r = {r.statistic:.3f}, p = {r.pvalue:.2g}, n = {len(d)}"
print("(a) pooled early and late:", corr(pairs, "DMS_score"))
print("(b) late only: ", corr(pairs[pairs.fp_stage == "late"], "DMS_score"))
print("(c) early only: ", corr(pairs[pairs.fp_stage == "early"], "DMS_score"))
print("Spearman, pooled:", stats.spearmanr(pairs.dropna(subset=["DMS_score"]).DMS_score, pairs.dropna(subset=["DMS_score"]).shocks))
pc = stats.pearsonr(*pairs[pairs.fp_stage == "early"].dropna(subset=["DMS_score"])[["DMS_score", "shocks"]].T.values).pvalue
print(f"Reading (c) with a Bonferroni correction for the three readings: p = {min(1, 3 * pc):.2g}")
print("Variant without the dropout step, pooled:", corr(pairs, "DMS_score_nomask"))
print("Variant without the dropout step, late only: ", corr(pairs[pairs.fp_stage == "late"], "DMS_score_nomask"))
print("Variant without the dropout step, early only:", corr(pairs[pairs.fp_stage == "early"], "DMS_score_nomask"))
used = ph.merge(pairs[["subject", "fp_stage"]], on=["subject", "fp_stage"])
print(f"\nPercent of samples left out by the dropout step in the {len(used)} sessions behind these pairs:")
for region in ["DMS", "DLS"]:
fr_ = 100 * used[f"{region}_dropout_frac"]
print(f" {region}: median {fr_.median():.3f}, above 0.1% in {(fr_ > 0.1).sum()}, above 1% in {(fr_ > 1).sum()}, maximum {fr_.max():.2f}")
print(used.loc[(used.DMS_dropout_frac > 0.001) | (used.DLS_dropout_frac > 0.001),
["subject", "date", "fp_stage", "DMS_dropout_frac", "DLS_dropout_frac", "DMS_max_abs_z_nomask", "DLS_max_abs_z_nomask",
"DMS_score", "DMS_score_nomask", "DLS_score", "DLS_score_nomask"]]
.assign(DMS_dropout_frac=lambda x: 100 * x.DMS_dropout_frac, DLS_dropout_frac=lambda x: 100 * x.DLS_dropout_frac).round(2).to_string(index=False))
rrp = ph[ph.pheno == "RR20"].groupby(["subject", "fp_stage"]).DMS_score.mean().reset_index().merge(
fp_probes[fp_probes.probe != ""][["subject", "probe", "shocks"]].rename(columns={"probe": "fp_stage"}), on=["subject", "fp_stage"])
print("Variant with RR20 mice added, pooled:", corr(pd.concat([pairs, rrp]), "DMS_score"))
print("Variant, each mouse's mean score against its mean shocks:", corr(pairs.groupby("subject")[["DMS_score", "shocks"]].mean().reset_index(), "DMS_score"))
fig, ax = plt.subplots(figsize=(3.5, 3))
for st, c in [("early", "k"), ("late", "r")]:
s = pairs[pairs.fp_stage == st]
ax.scatter(s.DMS_score, s.shocks, c=c, s=12, label=st)
ax.set_xlabel("DMS score (z)"); ax.set_ylabel("shocks"); ax.legend(); plt.tight_layout(); plt.show()
print("Plotted points span score", round(pairs.DMS_score.min(), 2), "to", round(pairs.DMS_score.max(), 2), "and shocks", pairs.shocks.min(), "to", pairs.shocks.max())
(a) pooled early and late: r = 0.170, p = 0.24, n = 49 (b) late only: r = 0.014, p = 0.95, n = 20 (c) early only: r = 0.375, p = 0.045, n = 29 Spearman, pooled: SignificanceResult(statistic=np.float64(0.156707623422631), pvalue=np.float64(0.2822423150071741)) Reading (c) with a Bonferroni correction for the three readings: p = 0.13 Variant without the dropout step, pooled: r = 0.100, p = 0.49, n = 49 Variant without the dropout step, late only: r = -0.082, p = 0.73, n = 20 Variant without the dropout step, early only: r = 0.362, p = 0.053, n = 29 Percent of samples left out by the dropout step in the 51 sessions behind these pairs: DMS: median 0.033, above 0.1% in 9, above 1% in 1, maximum 1.61 DLS: median 0.033, above 0.1% in 9, above 1% in 1, maximum 99.97 subject date fp_stage DMS_dropout_frac DLS_dropout_frac DMS_max_abs_z_nomask DLS_max_abs_z_nomask DMS_score DMS_score_nomask DLS_score DLS_score_nomask 63.207 2018-10-15 early 0.32 0.32 11.07 9.10 4.65 4.28 1.48 1.38 80.236 2019-01-21 early 0.57 0.11 10.18 73.44 2.35 1.71 0.48 0.40 88.239 2019-02-19 early 1.61 0.03 1521.97 6.98 1.92 0.09 0.50 0.50 92.246 2019-02-27 early 0.70 0.30 1484.95 1666.64 0.30 0.04 2.27 0.01 87.239 2019-02-28 early 0.15 0.21 15.85 37.76 3.40 3.15 1.27 0.16 141.308 2019-08-09 early 0.04 99.97 5.00 3.28 2.68 2.68 NaN NaN 140.306 2019-09-03 late 0.03 0.21 5.17 1156.26 3.95 3.95 0.97 0.02 139.298 2019-09-12 late 0.11 0.11 1729.70 1758.49 -0.06 -0.00 0.51 0.02 346.394 2020-07-22 late 0.55 0.49 1073.11 1609.37 1.10 0.00 3.31 0.03 332.393 2020-07-28 late 0.15 0.15 959.31 90.13 NaN NaN NaN NaN 048.392 2020-07-28 late 0.12 0.04 1681.12 4.70 1.81 0.02 4.44 4.44 Variant with RR20 mice added, pooled: r = 0.116, p = 0.39, n = 58 Variant, each mouse's mean score against its mean shocks: r = 0.210, p = 0.24, n = 33
Plotted points span score -0.12 to 5.69 and shocks 1 to 53
Result: partially reproduced. Pooling early and late pairs (reading a) gives r = 0.170, p = 0.24, n = 49; late pairs (b) r = 0.014, p = 0.95; early pairs (c) r = 0.375, p = 0.045, n = 29. Only reading (c) is positive and significant, and its r is below the tolerance around 0.53. The support is weak: corrected for the three readings, (c) has p = 0.13, and without the dropout step it gives r = 0.362, p = 0.053. The dropout step leaves out more than 1% of samples in only one DMS session of the 51 behind these pairs (1.61%), but a small fraction can still decide a session's score: the step moves 88.239 from 0.09 to 1.92 (1.61% left out), 048.392 from 0.02 to 1.81 (0.12%), 346.394 from 0.00 to 1.10 (0.55%) and 92.246 from 0.04 to 0.30 (0.70%), as dropouts of |z| above 1000 dominate the z-score.
fig3h-dms-score-omission-no-correlation¶
The DMS rewarded-unrewarded peak score does not correlate with omission completion time (r = 0.1, ns).
def r_ci(df, x, y):
d = df.dropna(subset=[x, y])
r = stats.pearsonr(d[x], d[y])
ci = r.confidence_interval(0.95)
return f"95% interval of r {ci.low:.2f} to {ci.high:.2f} (n = {len(d)})"
oms = sc.merge(fp_el[["omission_min"]].reset_index(), on="subject")
print("Late score vs omission time:", corr(oms[oms.fp_stage == "late"], "DMS_score", "omission_min"))
print(" ", r_ci(oms[oms.fp_stage == "late"], "DMS_score", "omission_min"))
print("Variant, pooled early and late:", corr(oms, "DMS_score", "omission_min"))
print("Variant, early score:", corr(oms[oms.fp_stage == "early"], "DMS_score", "omission_min"))
Late score vs omission time: r = 0.014, p = 0.96, n = 12 95% interval of r -0.56 to 0.58 (n = 12) Variant, pooled early and late: r = -0.050, p = 0.8, n = 29 Variant, early score: r = -0.092, p = 0.72, n = 17
Result: reproduced. The late DMS score does not correlate with omission time (r = 0.014, p = 0.96, n = 12); pooled early and late r = -0.050, p = 0.80. With 12 mice the 95% interval of r runs from -0.56 to 0.58, so the test cannot exclude the paper's r = 0.1 or a moderate correlation either way.
fig4f-dls-score-no-phenotype¶
The DLS rewarded-unrewarded peak score has no significant effect of phenotype.
res4f, tab4f = phenotype_model(sc, "DLS_score")
print(tab4f.round(3).unstack().to_string())
print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in res4f.items()})
resn4, _ = phenotype_model(sc, "DLS_score_nomask")
print("Variant, without the dropout step:", {k: round(v, 4) for k, v in resn4.items() if "p" in k})
mean std size
fp_stage early late early late early late
pheno
DPR 1.036 2.006 0.478 1.213 9 7
PR 1.248 1.794 0.904 1.900 6 4
PS 0.927 1.305 0.640 0.757 14 10
{'n_obs': 50, 'n_mice': 33, 'phenotype_LR': np.float64(3.475), 'phenotype_p': np.float64(0.4817), 'stage_LR': np.float64(8.6714), 'stage_p': np.float64(0.034), 'anova_early_p': np.float64(0.6118), 'anova_late_p': np.float64(0.4726)}
Variant, without the dropout step: {'phenotype_LR': np.float64(2.3049), 'phenotype_p': np.float64(0.6799), 'stage_p': np.float64(0.2536), 'anova_early_p': np.float64(0.4868), 'anova_late_p': np.float64(0.6589)}
Result: reproduced. The DLS score has no phenotype effect (likelihood ratio p = 0.48; per stage p = 0.61 and 0.47). It changes with stage (p = 0.034), as the paper also reports for DLS.
fig4g-dls-score-shock-no-correlation¶
The DLS rewarded-unrewarded peak score does not correlate with shocks received (r = -0.01, ns).
print("(a) pooled early and late:", corr(pairs, "DLS_score"))
print("Variant, late only:", corr(pairs[pairs.fp_stage == "late"], "DLS_score"))
print("Variant, early only:", corr(pairs[pairs.fp_stage == "early"], "DLS_score"))
print("Variant without the dropout step, pooled:", corr(pairs, "DLS_score_nomask"))
print("Variant without the dropout step, late only: ", corr(pairs[pairs.fp_stage == "late"], "DLS_score_nomask"))
print("Variant without the dropout step, early only:", corr(pairs[pairs.fp_stage == "early"], "DLS_score_nomask"))
(a) pooled early and late: r = 0.326, p = 0.024, n = 48 Variant, late only: r = 0.374, p = 0.1, n = 20 Variant, early only: r = 0.103, p = 0.6, n = 28 Variant without the dropout step, pooled: r = 0.113, p = 0.45, n = 48 Variant without the dropout step, late only: r = 0.033, p = 0.89, n = 20 Variant without the dropout step, early only: r = 0.182, p = 0.35, n = 28
Result: not reproduced. The pooled DLS score correlates with shocks received (r = 0.326, p = 0.024, n = 48), where the paper finds none (r = -0.01). Late pairs alone give r = 0.374, p = 0.10, and early pairs r = 0.103, p = 0.60. Without the dropout step the pooled r is 0.113, p = 0.45, so the result depends on how the artifacts are handled (without it, late r = 0.033 and early r = 0.182, both p > 0.3). The dropout step is a choice made here, not in the Methods or the authors' code.
fig4h-dls-score-omission-no-correlation¶
The DLS rewarded-unrewarded peak score does not correlate with omission completion time (r = -0.21, ns).
print("Late score vs omission time:", corr(oms[oms.fp_stage == "late"], "DLS_score", "omission_min"))
print(" ", r_ci(oms[oms.fp_stage == "late"], "DLS_score", "omission_min"))
print("Variant, pooled early and late:", corr(oms, "DLS_score", "omission_min"))
Late score vs omission time: r = 0.311, p = 0.32, n = 12 95% interval of r -0.32 to 0.75 (n = 12) Variant, pooled early and late: r = 0.141, p = 0.47, n = 28
Result: reproduced. r = 0.311, p = 0.32, n = 12 (paper r = -0.21, ns). The 95% interval of r runs from -0.32 to 0.75, so with 12 mice the test has little power.
Optogenetic cohorts¶
Optogenetic mice carry no phenotype in the dandiset; they are classified here with the Methods rule from their early and late probes.
def opto_tables(experiment):
s = sessions[sessions.subject_experiment == experiment]
probes = seiler.probe_table(s, events, training_stages=("RI60",))
el = seiler.early_late(probes).join(s.drop_duplicates("subject").set_index("subject")[["subject_arm", "sex"]], how="right")
el["pheno"] = [seiler.classify(e, l) if pd.notna(e) and pd.notna(l) else None for e, l in zip(el.early, el.late)]
omf = s[s.stage == "omission"].sort_values("start_ts").groupby("subject").asset_id.first()
el["omission_min"] = [seiler.omission_time_min(events[omf[x]]["events"]) if x in omf.index else np.nan for x in el.index]
el["n_ri60"] = s[s.stage == "RI60"].drop_duplicates(["subject", "start"]).groupby("subject").size().reindex(el.index).fillna(0).astype(int)
el["did_not_learn"] = el.index.map(opto_subj.set_index("subject").did_not_learn)
return s, probes, el
opto = {e: opto_tables(e) for e in ["Opto-DMS-Excitatory", "Opto-DLS-Excitatory", "Opto-DMS-Inhibitory"]}
for e, (s, probes, el) in opto.items():
print(f"== {e}: {len(el)} mice")
print("Mice per arm and sex:", el.groupby(["subject_arm", "sex"]).size().unstack(fill_value=0).to_dict("index"))
print("Probe days per mouse:", probes.groupby("subject").size().value_counts().sort_index().to_dict(),
"; days with two files holding shocks:", int((probes.n_with_shocks > 1).sum()))
print("Training days before early / late probe:", probes[probes.probe == "early"].train_days_before.describe()[["min", "50%", "max"]].to_dict(),
probes[probes.probe == "late"].train_days_before.describe()[["min", "50%", "max"]].to_dict())
print("Mice with both probes:", int(el[["early", "late"]].notna().all(axis=1).sum()), "; with an omission probe:", int(el.omission_min.notna().sum()))
print("Mice missing a probe:", el[el[["early", "late"]].isna().any(axis=1)][["subject_arm", "early", "late", "n_ri60", "did_not_learn"]].to_dict("index"))
== Opto-DMS-Excitatory: 33 mice
Mice per arm and sex: {'ChR2': {'F': 7, 'M': 7}, 'ChR2Scrambled': {'F': 6, 'M': 6}, 'EYFP': {'F': 3, 'M': 4}}
Probe days per mouse: {2: 32} ; days with two files holding shocks: 1
Training days before early / late probe: {'min': 0.0, '50%': 1.0, 'max': 2.0} {'min': 10.0, '50%': 12.0, 'max': 14.0}
Mice with both probes: 32 ; with an omission probe: 32
Mice missing a probe: {'126.286': {'subject_arm': 'ChR2', 'early': nan, 'late': nan, 'n_ri60': 0, 'did_not_learn': 'False'}}
== Opto-DLS-Excitatory: 35 mice
Mice per arm and sex: {'ChR2': {'F': 5, 'M': 6}, 'ChR2Scrambled': {'F': 6, 'M': 6}, 'EYFP': {'F': 6, 'M': 6}}
Probe days per mouse: {1: 4, 2: 31} ; days with two files holding shocks: 1
Training days before early / late probe: {'min': 0.0, '50%': 1.0, 'max': 2.0} {'min': 10.0, '50%': 12.0, 'max': 24.0}
Mice with both probes: 31 ; with an omission probe: 32
Mice missing a probe: {'128.424': {'subject_arm': 'ChR2Scrambled', 'early': 40.0, 'late': nan, 'n_ri60': 12, 'did_not_learn': 'False'}, '238.388': {'subject_arm': 'EYFP', 'early': 12.0, 'late': nan, 'n_ri60': 12, 'did_not_learn': 'False'}, '313.403': {'subject_arm': 'EYFP', 'early': 8.0, 'late': nan, 'n_ri60': 13, 'did_not_learn': 'False'}, '342.400': {'subject_arm': 'EYFP', 'early': 23.0, 'late': nan, 'n_ri60': 14, 'did_not_learn': 'False'}}
== Opto-DMS-Inhibitory: 42 mice
Mice per arm and sex: {'EYFP': {'F': 9, 'M': 7}, 'NpHR': {'F': 8, 'M': 5}, 'NpHRScrambled': {'F': 6, 'M': 7}}
Probe days per mouse: {1: 5, 2: 32, 3: 2} ; days with two files holding shocks: 0
Training days before early / late probe: {'min': 1.0, '50%': 1.0, 'max': 4.0} {'min': 10.0, '50%': 12.0, 'max': 14.0}
Mice with both probes: 34 ; with an omission probe: 37
Mice missing a probe: {'112.415': {'subject_arm': 'NpHR', 'early': 10.0, 'late': nan, 'n_ri60': 13, 'did_not_learn': 'False'}, '202.465': {'subject_arm': 'NpHR', 'early': 6.0, 'late': nan, 'n_ri60': 18, 'did_not_learn': 'False'}, '244.465': {'subject_arm': 'EYFP', 'early': nan, 'late': nan, 'n_ri60': 8, 'did_not_learn': 'True'}, '245.464': {'subject_arm': 'NpHRScrambled', 'early': 5.0, 'late': nan, 'n_ri60': 16, 'did_not_learn': 'False'}, '259.478': {'subject_arm': 'NpHRScrambled', 'early': 10.0, 'late': nan, 'n_ri60': 14, 'did_not_learn': 'False'}, '262.259': {'subject_arm': 'NpHRScrambled', 'early': nan, 'late': nan, 'n_ri60': 1, 'did_not_learn': 'False'}, '266.477': {'subject_arm': 'NpHR', 'early': nan, 'late': 3.0, 'n_ri60': 15, 'did_not_learn': 'False'}, '270.476': {'subject_arm': 'NpHR', 'early': nan, 'late': nan, 'n_ri60': 0, 'did_not_learn': 'True'}}
fig5f-dms-chr2-escalation¶
Mice receiving DMS dopamine terminal stimulation on rewarded nosepokes (ChR2) escalate their nosepoking faster than EYFP and ChR2 Scrambled controls during FR1 and RI30.
def escalation(experiment, ref="ChR2"):
s = sessions[(sessions.subject_experiment == experiment) & sessions.stage.isin(["FR1", "RI30"])].sort_values("start_ts")
s = s[~((s.arm == "Unknown") & s.duplicated(["subject", "start"], keep=False))].drop_duplicates(["subject", "start"])
rows = []
for r in s.itertuples():
ev = events[r.asset_id]["events"]
if isinstance(r.side, str):
pokes = seiler.active_pokes(ev, r.side)
else: # FR1 on both nosepokes
pokes = np.concatenate([seiler.active_pokes(ev, "left"), seiler.active_pokes(ev, "right")])
rows.append({"subject": r.subject, "arm": r.subject_arm, "stage": r.stage, "rate": len(pokes) / (seiler.session_length(ev) / 60.0)})
d = pd.DataFrame(rows)
d["idx"] = d.groupby(["subject", "stage"]).cumcount() + 1
out = {}
for st in ["FR1", "RI30"]:
x = d[d.stage == st]
fit = smf.ols(f"rate ~ idx * C(arm, Treatment('{ref}'))", x).fit()
slopes = {a: np.polyfit(x[x.arm == a].idx, x[x.arm == a].rate, 1)[0] for a in sorted(x.arm.unique())}
inter = {k.split("T.")[1].rstrip("]"): (fit.params[k], fit.pvalues[k]) for k in fit.params.index if k.startswith("idx:")}
others = [a for a in sorted(x.arm.unique()) if a != ref]
f2 = smf.ols(f"rate ~ idx * C(arm, Treatment('{others[0]}'))", x).fit()
k2 = [k for k in f2.params.index if k.startswith("idx:") and others[1] in k][0]
out[st] = {"slopes": slopes, "interaction_vs_ref": inter, "sessions": len(x), "mice": x.subject.nunique(),
"other_pair": (f"{others[1]} vs {others[0]}", f2.params[k2], f2.pvalues[k2]),
"sessions_per_mouse": (x.groupby("subject").size().min(), x.groupby("subject").size().max())}
return d, out
def escalation_mixed(d, ref="ChR2", re_formula="~idx"):
"""Variant: a mixed model with a random intercept and slope per mouse (or, with re_formula=None, an intercept only).
The fit's warnings are returned with it, as they bear on how far its p values can be trusted."""
out = {}
for st in ["FR1", "RI30"]:
x = d[d.stage == st]
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
try:
m = smf.mixedlm(f"rate ~ idx * C(arm, Treatment('{ref}'))", x, groups=x.subject, re_formula=re_formula).fit(reml=False)
out[st] = {k.split("T.")[1].rstrip("]"): (round(m.params[k], 3), round(m.pvalues[k], 4)) for k in m.params.index if k.startswith("idx:")}
out[st]["converged"] = m.converged
out[st]["random effects covariance"] = np.round(np.asarray(m.cov_re), 4).tolist()
others = [a for a in sorted(x.arm.unique()) if a != ref]
m2 = smf.mixedlm(f"rate ~ idx * C(arm, Treatment('{others[0]}'))", x, groups=x.subject, re_formula=re_formula).fit(reml=False)
k2 = [k for k in m2.params.index if k.startswith("idx:") and others[1] in k][0]
out[st][f"{others[1]} vs {others[0]}"] = (round(m2.params[k2], 3), round(m2.pvalues[k2], 4))
except Exception as e: # report a failed fit, do not hide it
out[st] = {"failed": repr(e)}
msgs = pd.Series([str(w.message).split("\n")[0] for w in caught if not issubclass(w.category, (RuntimeWarning, FutureWarning))])
out[st]["warnings"] = msgs.value_counts().to_dict()
return out
esc5, res5 = escalation("Opto-DMS-Excitatory")
for st, r in res5.items():
print(st, f"{r['sessions']} sessions from {r['mice']} mice ({r['sessions_per_mouse'][0]} to {r['sessions_per_mouse'][1]} per mouse)")
print(" slope (nosepokes/min per session):", {k: round(v, 3) for k, v in r["slopes"].items()})
print(" arm x index interaction against ChR2 (difference in slope, p):", {k: (round(v[0], 3), round(v[1], 4)) for k, v in r["interaction_vs_ref"].items()})
for st, r in escalation_mixed(esc5).items():
print(f"Variant, {st} mixed model with a random intercept and slope per mouse (difference in slope against ChR2, and the third pair, p):", r)
for st, r in escalation_mixed(esc5, re_formula=None).items():
print(f"Variant, {st} mixed model with a random intercept per mouse only:", r)
print("Mean rate by stage and arm:", esc5.groupby(["stage", "arm"]).rate.mean().round(2).to_dict())
per_mouse_slope = esc5.groupby(["stage", "arm", "subject"]).apply(lambda x: np.polyfit(x.idx, x.rate, 1)[0] if len(x) > 1 else np.nan).rename("slope").reset_index()
print("Variant, per-mouse slopes (median):", per_mouse_slope.groupby(["stage", "arm"]).slope.median().round(3).to_dict())
for st in ["FR1", "RI30"]:
x = per_mouse_slope[per_mouse_slope.stage == st]
for a in ["EYFP", "ChR2Scrambled"]:
print(f" {st} ChR2 vs {a}, Mann-Whitney on per-mouse slopes p = {stats.mannwhitneyu(x[x.arm == 'ChR2'].slope.dropna(), x[x.arm == a].slope.dropna()).pvalue:.3g}")
FR1 170 sessions from 33 mice (1 to 13 per mouse)
slope (nosepokes/min per session): {'ChR2': np.float64(0.011), 'ChR2Scrambled': np.float64(0.051), 'EYFP': np.float64(0.005)}
arm x index interaction against ChR2 (difference in slope, p): {'ChR2Scrambled': (np.float64(0.04), np.float64(0.6292)), 'EYFP': (np.float64(-0.006), np.float64(0.9797))}
RI30 52 sessions from 29 mice (1 to 4 per mouse)
slope (nosepokes/min per session): {'ChR2': np.float64(1.487), 'ChR2Scrambled': np.float64(0.957), 'EYFP': np.float64(4.529)}
arm x index interaction against ChR2 (difference in slope, p): {'ChR2Scrambled': (np.float64(-0.531), np.float64(0.8233)), 'EYFP': (np.float64(3.042), np.float64(0.4897))}
Variant, FR1 mixed model with a random intercept and slope per mouse (difference in slope against ChR2, and the third pair, p): {'ChR2Scrambled': (np.float64(-0.419), np.float64(0.0343)), 'EYFP': (np.float64(-0.697), np.float64(0.0075)), 'converged': True, 'random effects covariance': [[0.0001, 0.0009], [0.0009, 0.1644]], 'EYFP vs ChR2Scrambled': (np.float64(-0.277), np.float64(0.2662)), 'warnings': {'Maximum Likelihood optimization failed to converge. Check mle_retvals': 2, 'Retrying MixedLM optimization with lbfgs': 2, 'The MLE may be on the boundary of the parameter space.': 2}}
Variant, RI30 mixed model with a random intercept and slope per mouse (difference in slope against ChR2, and the third pair, p): {'ChR2Scrambled': (np.float64(-0.954), np.float64(0.6817)), 'EYFP': (np.float64(1.128), np.float64(0.7001)), 'converged': True, 'random effects covariance': [[1.3576, -3.8858], [-3.8858, 11.122]], 'EYFP vs ChR2Scrambled': (np.float64(2.082), np.float64(0.5117)), 'warnings': {}}
Variant, FR1 mixed model with a random intercept per mouse only: {'ChR2Scrambled': (np.float64(-0.056), np.float64(0.4634)), 'EYFP': (np.float64(-0.175), np.float64(0.3261)), 'converged': True, 'random effects covariance': [[0.8636]], 'EYFP vs ChR2Scrambled': (np.float64(-0.119), np.float64(0.4963)), 'warnings': {}}
Variant, RI30 mixed model with a random intercept per mouse only: {'ChR2Scrambled': (np.float64(-0.966), np.float64(0.5634)), 'EYFP': (np.float64(2.052), np.float64(0.4769)), 'converged': True, 'random effects covariance': [[13.4514]], 'EYFP vs ChR2Scrambled': (np.float64(3.018), np.float64(0.3222)), 'warnings': {}}
Mean rate by stage and arm: {('FR1', 'ChR2'): 1.59, ('FR1', 'ChR2Scrambled'): 1.22, ('FR1', 'EYFP'): 1.46, ('RI30', 'ChR2'): 7.75, ('RI30', 'ChR2Scrambled'): 4.01, ('RI30', 'EYFP'): 4.21}
Variant, per-mouse slopes (median): {('FR1', 'ChR2'): 0.657, ('FR1', 'ChR2Scrambled'): 0.275, ('FR1', 'EYFP'): -0.021, ('RI30', 'ChR2'): 0.715, ('RI30', 'ChR2Scrambled'): 0.996, ('RI30', 'EYFP'): 1.417}
FR1 ChR2 vs EYFP, Mann-Whitney on per-mouse slopes p = 0.0145
FR1 ChR2 vs ChR2Scrambled, Mann-Whitney on per-mouse slopes p = 0.0208
RI30 ChR2 vs EYFP, Mann-Whitney on per-mouse slopes p = 0.6
RI30 ChR2 vs ChR2Scrambled, Mann-Whitney on per-mouse slopes p = 1
Result: not reproduced. In the pooled fit of the criterion no ChR2 slope differs from a control slope (FR1 interaction p = 0.63 and 0.98; RI30 p = 0.82 and 0.49), and the pooled FR1 slope of ChR2 mice (0.011) is not above the controls'. Per-mouse slopes tell a different story for FR1: the ChR2 median is 0.657 nosepokes/min per session, above EYFP (-0.021, p = 0.015) and ChR2 Scrambled (0.275, p = 0.021); in RI30 they do not differ (p = 0.6 and 1). ChR2 mice nosepoke more during RI30 (mean 7.75 per minute against 4.01 and 4.21). The pooled fit mixes mice with 1 to 13 FR1 sessions, so the two approaches disagree. A mixed model with a random intercept and slope per mouse agrees with the per-mouse slopes: in FR1 the ChR2 slope is above ChR2 Scrambled (difference 0.419, p = 0.034) and EYFP (0.697, p = 0.0075), in RI30 it is not (p = 0.68 and 0.70). That FR1 fit is weak evidence: it converged only after a retry with lbfgs, warns that the MLE may be on the boundary, and its random intercept variance is near zero (0.0001). A mixed model with a random intercept only puts the FR1 ChR2 slope above neither control (p = 0.46 and 0.33). Under the random slope model the criterion's rule would give partially reproduced; the criterion fixed the pooled fit before the analysis, so the outcome stays.
fig5g-dms-chr2-more-shocks-early¶
On the first shock probe ChR2 mice accept more shocks than EYFP mice (interaction of time and manipulation, F2,35 = 3.65, p < 0.05; Tukey p < 0.05); the difference fades by the second probe.
_, p5, el5 = opto["Opto-DMS-Excitatory"]
long5 = p5[p5.probe != ""].merge(el5[["subject_arm"]].reset_index(), on="subject")
aov5, w5 = mixed_anova(long5, "subject_arm")
print(fmt(aov5))
print(w5.groupby("subject_arm")[["early", "late"]].agg(["mean", "std", "size"]).round(2).to_string())
print("First probe, Tukey:"); print(tukey(w5.early, w5.subject_arm).to_string())
print("Second probe, Tukey:"); print(tukey(w5.late, w5.subject_arm).to_string())
e1 = el5.dropna(subset=["early"])
print("Variant, all mice with a first probe:", e1.groupby("subject_arm").early.agg(["mean", "size"]).round(2).to_dict("index"))
print(tukey(e1.early, e1.subject_arm).to_string())
F df p
subject_arm 0.25 2,29 0.78
probe 0.00 1,29 0.99
subject_arm x probe 1.95 2,29 0.16
probe early late
mean std size mean std size
subject_arm
ChR2 28.69 12.98 13 23.38 10.65 13
ChR2Scrambled 20.67 12.03 12 24.83 18.01 12
EYFP 21.86 14.37 7 24.43 15.55 7
First probe, Tukey:
group1 group2 meandiff p-adj lower upper reject 0 ChR2 ChR2Scrambled -8.0256 0.2832 -20.8141 4.7628 False 1 ChR2 EYFP -6.8352 0.5055 -21.8114 8.1411 False 2 ChR2Scrambled EYFP 1.1905 0.9796 -14.0026 16.3836 False Second probe, Tukey:
group1 group2 meandiff p-adj lower upper reject
0 ChR2 ChR2Scrambled 1.4487 0.9678 -13.2159 16.1133 False
1 ChR2 EYFP 1.0440 0.9877 -16.1294 18.2174 False
2 ChR2Scrambled EYFP -0.4048 0.9982 -17.8268 17.0173 False
Variant, all mice with a first probe: {'ChR2': {'mean': 28.69, 'size': 13}, 'ChR2Scrambled': {'mean': 20.67, 'size': 12}, 'EYFP': {'mean': 21.86, 'size': 7}}
group1 group2 meandiff p-adj lower upper reject 0 ChR2 ChR2Scrambled -8.0256 0.2832 -20.8141 4.7628 False 1 ChR2 EYFP -6.8352 0.5055 -21.8114 8.1411 False 2 ChR2Scrambled EYFP 1.1905 0.9796 -14.0026 16.3836 False
Result: not reproduced. On the first probe ChR2 mice accept 28.69 shocks, EYFP 21.86 and ChR2 Scrambled 20.67, the paper's order, but Tukey ChR2 vs EYFP gives p = 0.51 and the group by probe interaction p = 0.16 (F2,29 = 1.95). The dandiset holds 33 DMS excitatory mice (7 EYFP), where the paper's statistics imply 38 to 39.
fig5h-dms-chr2-mostly-pr¶
ChR2 mice are very likely to be classified PR, while EYFP and ChR2 Scrambled mice are distributed across phenotypes as expected.
c5 = el5.dropna(subset=["pheno"])
tab5 = pd.crosstab(c5.subject_arm, c5.pheno)
print(tab5.to_string())
print("Fraction PR:", (c5.pheno == "PR").groupby(c5.subject_arm).mean().round(3).to_dict())
is_chr2 = c5.subject_arm == "ChR2"
ft = stats.fisher_exact([[((c5.pheno == "PR") & is_chr2).sum(), ((c5.pheno != "PR") & is_chr2).sum()],
[((c5.pheno == "PR") & ~is_chr2).sum(), ((c5.pheno != "PR") & ~is_chr2).sum()]])
print(f"Fisher exact, PR vs not, ChR2 vs both controls: odds ratio {ft.statistic:.2f}, p = {ft.pvalue:.3g}")
print("Fiber photometry RI60 mice, for reference:", (both.pheno.value_counts(normalize=True)).round(3).to_dict())
pheno DPR PR PS
subject_arm
ChR2 0 12 1
ChR2Scrambled 3 6 3
EYFP 0 5 2
Fraction PR: {'ChR2': 0.923, 'ChR2Scrambled': 0.5, 'EYFP': 0.714}
Fisher exact, PR vs not, ChR2 vs both controls: odds ratio 8.73, p = 0.0497
Fiber photometry RI60 mice, for reference: {'PS': 0.531, 'DPR': 0.281, 'PR': 0.188}
Result: reproduced. 12 of 13 ChR2 mice are PR (92%), against 5 of 7 EYFP (71%) and 6 of 12 ChR2 Scrambled (50%); Fisher's test of ChR2 against both controls gives p = 0.0497. The control proportions are higher than in the fiber photometry cohort (19% PR).
figs5f-chr2-pr-by-sex¶
Under DMS ChR2 stimulation 100% of male mice and about 71% of female mice are PR.
ch = c5[c5.subject_arm == "ChR2"]
print(pd.crosstab(ch.sex, ch.pheno).to_string())
print("Percent PR by sex:", (100 * (ch.pheno == "PR").groupby(ch.sex).mean()).round(1).to_dict(), "; n:", ch.sex.value_counts().to_dict())
print("ChR2 mice without both probes:", el5[(el5.subject_arm == "ChR2") & el5.pheno.isna()][["sex", "early", "late"]].to_dict("index"))
pheno PR PS
sex
F 5 1
M 7 0
Percent PR by sex: {'F': 83.3, 'M': 100.0} ; n: {'M': 7, 'F': 6}
ChR2 mice without both probes: {'126.286': {'sex': 'F', 'early': nan, 'late': nan}}
Result: partially reproduced. All 7 male ChR2 mice are PR (100%), as the paper says; 5 of 6 female ChR2 mice are PR (83.3%), outside the tolerance around 71%.
fig5i-dms-stim-no-omission-effect¶
DMS dopamine terminal stimulation does not change omission probe performance.
def omission_anova(el):
x = el.dropna(subset=["omission_min"])
print(x.groupby("subject_arm").omission_min.agg(["mean", "std", "size", "min", "max"]).round(2).to_string())
an = stats.f_oneway(*[g.omission_min for _, g in x.groupby("subject_arm")])
kw = stats.kruskal(*[g.omission_min for _, g in x.groupby("subject_arm")])
print(f"One-way ANOVA: F = {an.statistic:.2f}, p = {an.pvalue:.3g}; Kruskal-Wallis variant p = {kw.pvalue:.3g}")
return an
an5 = omission_anova(el5)
mean std size min max subject_arm ChR2 25.79 13.67 13 17.06 60.00 ChR2Scrambled 35.53 15.97 12 17.08 60.00 EYFP 37.29 15.86 7 19.23 53.22 One-way ANOVA: F = 1.87, p = 0.172; Kruskal-Wallis variant p = 0.141
Result: reproduced. Omission times do not differ across the DMS excitatory groups (ChR2 25.79, Scrambled 35.53, EYFP 37.29 min; ANOVA p = 0.17).
fig6-nphr-learning-dropout¶
DMS dopamine terminal inhibition impairs initial learning; 25% of NpHR and 22% of NpHR Scrambled mice in group 1 failed FR1, and 29% of NpHR mice in group 2 failed RI30, and were dropped.
s6, p6, el6 = opto["Opto-DMS-Inhibitory"]
print(el6.groupby("subject_arm").agg(mice=("n_ri60", "size"), did_not_learn=("did_not_learn", lambda x: (x == "True").sum()),
no_ri60=("n_ri60", lambda x: (x == 0).sum()), both_probes=("pheno", "count")).to_string())
print("Mice flagged or without RI60:", el6[(el6.did_not_learn == "True") | (el6.n_ri60 == 0)][["subject_arm", "sex", "n_ri60", "did_not_learn"]].to_dict("index"))
stages6 = s6.groupby(["subject", "stage"]).size().unstack(fill_value=0)
print("Sessions per stage of those mice:", stages6.loc[el6[(el6.did_not_learn == "True") | (el6.n_ri60 == 0)].index].to_dict("index"))
first_fr1 = s6[s6.stage.isin(["FR1", "FR1habit"])].groupby("subject").start_ts.min()
first_ri30 = s6[s6.stage == "RI30"].groupby("subject").start_ts.min()
print("Mice whose first session is RI30 (light from RI30, possibly group 2):", int((first_ri30.reindex(el6.index) < first_fr1.reindex(el6.index)).sum()))
paper_n = {"NpHR": (8, 8), "EYFP": (9, 7), "NpHRScrambled": (9, 6)} # Figure 6F legend, groups 1 and 2
paper_drop = {"NpHR": "25% of 8 in group 1 (2) and 29% of group 2 (2)", "EYFP": "none", "NpHRScrambled": "22% of 9 in group 1 (2)"}
cnt = el6.groupby("subject_arm").size()
for a, (g1, g2) in paper_n.items():
print(f"{a}: Figure 6F {g1 + g2} mice ({g1} + {g2}); dandiset {cnt.get(a, 0)}; shortfall {g1 + g2 - cnt.get(a, 0)}; "
f"paper drops {paper_drop[a]}; flagged or without RI60 in the dandiset {int(((el6.subject_arm == a) & ((el6.did_not_learn == 'True') | (el6.n_ri60 == 0))).sum())}")
print("Session descriptions mentioning a group:", int(s6.description.str.contains("[Gg]roup").sum()))
print("FR1 sessions with optogenetic stimulation, by arm (all mice):", s6[s6.stage == "FR1"].groupby("subject_arm").size().to_dict())
mice did_not_learn no_ri60 both_probes
subject_arm
EYFP 16 1 0 15
NpHR 13 1 1 9
NpHRScrambled 13 0 0 10
Mice flagged or without RI60: {'244.465': {'subject_arm': 'EYFP', 'sex': 'F', 'n_ri60': 8, 'did_not_learn': 'True'}, '270.476': {'subject_arm': 'NpHR', 'sex': 'F', 'n_ri60': 0, 'did_not_learn': 'True'}}
Sessions per stage of those mice: {'244.465': {'FR1': 17, 'FR1habit': 4, 'RI30': 1, 'RI60': 8, 'omission': 0, 'probetest': 0, 'shock': 0}, '270.476': {'FR1': 14, 'FR1habit': 4, 'RI30': 0, 'RI60': 0, 'omission': 0, 'probetest': 0, 'shock': 0}}
Mice whose first session is RI30 (light from RI30, possibly group 2): 0
NpHR: Figure 6F 16 mice (8 + 8); dandiset 13; shortfall 3; paper drops 25% of 8 in group 1 (2) and 29% of group 2 (2); flagged or without RI60 in the dandiset 1
EYFP: Figure 6F 16 mice (9 + 7); dandiset 16; shortfall 0; paper drops none; flagged or without RI60 in the dandiset 1
NpHRScrambled: Figure 6F 15 mice (9 + 6); dandiset 13; shortfall 2; paper drops 22% of 9 in group 1 (2); flagged or without RI60 in the dandiset 0
Session descriptions mentioning a group: 0
FR1 sessions with optogenetic stimulation, by arm (all mice): {'EYFP': 115, 'NpHR': 105, 'NpHRScrambled': 96}
Result: blocked (data missing). The dandiset holds 13 NpHR, 13 NpHR Scrambled and 16 EYFP mice, where Figure 6F counts 16, 15 and 16. The shortfall (3 NpHR, 2 NpHR Scrambled, 0 EYFP) equals the mice the paper drops (about 4 NpHR and 2 NpHR Scrambled) less the one NpHR mouse that is deposited and flagged as not learning (270.476, no RI60 session). So the dropped mice are, with that one exception, not in the dandiset. The "Did Not Learn" flag also marks an EYFP mouse that reached RI60 (244.465, 8 RI60 sessions, no probe), so it is not the paper's dropout criterion, and no file says which group (1 or 2) a mouse is in. Under the criterion the claim is blocked, as the dandiset does not say which mice were dropped.
fig6g-nphr-fewer-shocks-late¶
DMS dopamine terminal inhibition on rewarded nosepokes delays punishment resistance; NpHR mice accept fewer shocks than EYFP mice on the second probe (Tukey p < 0.01).
long6 = p6[p6.probe != ""].merge(el6[["subject_arm"]].reset_index(), on="subject")
aov6, w6 = mixed_anova(long6, "subject_arm")
print(w6.groupby("subject_arm")[["early", "late"]].agg(["mean", "std", "size"]).round(2).to_string())
print("Second probe, Tukey:"); print(tukey(w6.late, w6.subject_arm).to_string())
print("Variant, two-way mixed ANOVA:"); print(fmt(aov6))
l6 = el6.dropna(subset=["late"])
print("Variant, all mice with a second probe:", l6.groupby("subject_arm").late.agg(["mean", "size"]).round(2).to_dict("index"))
print(tukey(l6.late, l6.subject_arm).to_string())
probe early late
mean std size mean std size
subject_arm
EYFP 16.33 6.59 15 23.47 18.03 15
NpHR 9.89 7.98 9 9.33 9.39 9
NpHRScrambled 15.80 6.71 10 18.70 11.92 10
Second probe, Tukey:
group1 group2 meandiff p-adj lower upper reject
0 EYFP NpHR -14.1333 0.0694 -29.1989 0.9323 False
1 EYFP NpHRScrambled -4.7667 0.7032 -19.3539 9.8206 False
2 NpHR NpHRScrambled 9.3667 0.3511 -7.0507 25.7840 False
Variant, two-way mixed ANOVA:
F df p
subject_arm 3.16 2,31 0.056
probe 3.70 1,31 0.064
subject_arm x probe 1.27 2,31 0.3
Variant, all mice with a second probe: {'EYFP': {'mean': 23.47, 'size': 15}, 'NpHR': {'mean': 8.7, 'size': 10}, 'NpHRScrambled': {'mean': 18.7, 'size': 10}}
group1 group2 meandiff p-adj lower upper reject 0 EYFP NpHR -14.7667 0.0431 -29.1414 -0.3919 True 1 EYFP NpHRScrambled -4.7667 0.6967 -19.1414 9.6081 False 2 NpHR NpHRScrambled 10.0000 0.2771 -5.7468 25.7468 False
Result: partially reproduced. On the second probe NpHR mice accept 9.33 shocks and EYFP mice 23.47 (34 mice with both probes), but Tukey gives p = 0.069. Taking every mouse with a second probe (35) gives p = 0.043; the two-way mixed ANOVA has group p = 0.056.
fig6h-nphr-more-ps¶
The NpHR group has more PS mice, and the NpHR Scrambled group more PR mice, than the EYFP group.
c6 = el6.dropna(subset=["pheno"])
print(pd.crosstab(c6.subject_arm, c6.pheno).to_string())
fr = pd.crosstab(c6.subject_arm, c6.pheno, normalize="index").round(3)
print(fr.to_string())
def fisher(df, arm_a, arm_b, ph_):
a, b = df[df.subject_arm == arm_a], df[df.subject_arm == arm_b]
return stats.fisher_exact([[(a.pheno == ph_).sum(), (a.pheno != ph_).sum()], [(b.pheno == ph_).sum(), (b.pheno != ph_).sum()]]).pvalue
print(f"Fisher PS, NpHR vs EYFP: p = {fisher(c6, 'NpHR', 'EYFP', 'PS'):.3g}; PR, NpHRScrambled vs EYFP: p = {fisher(c6, 'NpHRScrambled', 'EYFP', 'PR'):.3g}")
print("By sex:"); print(pd.crosstab([c6.subject_arm, c6.sex], c6.pheno).to_string())
pheno DPR PR PS
subject_arm
EYFP 4 6 5
NpHR 0 1 8
NpHRScrambled 1 6 3
pheno DPR PR PS
subject_arm
EYFP 0.267 0.400 0.333
NpHR 0.000 0.111 0.889
NpHRScrambled 0.100 0.600 0.300
Fisher PS, NpHR vs EYFP: p = 0.0131; PR, NpHRScrambled vs EYFP: p = 0.428
By sex:
pheno DPR PR PS
subject_arm sex
EYFP F 2 3 3
M 2 3 2
NpHR F 0 1 3
M 0 0 5
NpHRScrambled F 1 2 2
M 0 4 1
Result: reproduced. 8 of 9 NpHR mice are PS (89%) against 5 of 15 EYFP (33%; Fisher p = 0.013), and 6 of 10 NpHR Scrambled mice are PR (60%) against 6 of 15 EYFP (40%; p = 0.43, not significant).
fig6i-nphr-no-omission-effect¶
DMS dopamine terminal inhibition does not change omission completion time.
an6 = omission_anova(el6)
mean std size min max subject_arm EYFP 28.74 12.08 15 16.99 53.52 NpHR 21.47 5.51 12 16.67 32.28 NpHRScrambled 27.82 13.46 10 16.67 60.00 One-way ANOVA: F = 1.66, p = 0.206; Kruskal-Wallis variant p = 0.252
Result: reproduced. Omission times do not differ (EYFP 28.74, NpHR 21.47, NpHR Scrambled 27.82 min; ANOVA p = 0.21).
fig7f-dls-escalation-similar¶
Mice receiving DLS dopamine terminal stimulation (ChR2, EYFP, ChR2 Scrambled) escalate their nosepoking at the same rates.
esc7, res7 = escalation("Opto-DLS-Excitatory")
for st, r in res7.items():
print(st, f"{r['sessions']} sessions from {r['mice']} mice")
print(" slope:", {k: round(v, 3) for k, v in r["slopes"].items()})
print(" arm x index interaction against ChR2 (difference in slope, p):", {k: (round(v[0], 3), round(v[1], 4)) for k, v in r["interaction_vs_ref"].items()})
print(" the third pair:", r["other_pair"][0], "difference in slope", round(r["other_pair"][1], 3), "p =", round(r["other_pair"][2], 4))
for st, r in escalation_mixed(esc7).items():
print(f"Variant, {st} mixed model with a random intercept and slope per mouse (difference in slope against ChR2, and the third pair, p):", r)
for st, r in escalation_mixed(esc7, re_formula=None).items():
print(f"Variant, {st} mixed model with a random intercept per mouse only:", r)
for st in ["FR1", "RI30"]:
x = esc7[esc7.stage == st]
f1 = smf.ols("rate ~ idx * C(arm)", x).fit(); f0 = smf.ols("rate ~ idx + C(arm)", x).fit()
print(f"{st}: F test of all arm x index terms p = {f1.compare_f_test(f0)[1]:.3g}")
FR1 197 sessions from 35 mice
slope: {'ChR2': np.float64(0.223), 'ChR2Scrambled': np.float64(0.087), 'EYFP': np.float64(0.224)}
arm x index interaction against ChR2 (difference in slope, p): {'ChR2Scrambled': (np.float64(-0.136), np.float64(0.1173)), 'EYFP': (np.float64(0.002), np.float64(0.9841))}
the third pair: EYFP vs ChR2Scrambled difference in slope 0.137 p = 0.0129
RI30 49 sessions from 35 mice
slope: {'ChR2': np.float64(0.771), 'ChR2Scrambled': np.float64(-1.198), 'EYFP': np.float64(0.925)}
arm x index interaction against ChR2 (difference in slope, p): {'ChR2Scrambled': (np.float64(-1.969), np.float64(0.1194)), 'EYFP': (np.float64(0.153), np.float64(0.9209))}
the third pair: EYFP vs ChR2Scrambled difference in slope 2.123 p = 0.0907
Variant, FR1 mixed model with a random intercept and slope per mouse (difference in slope against ChR2, and the third pair, p): {'ChR2Scrambled': (np.float64(-0.07), np.float64(0.9089)), 'EYFP': (np.float64(-0.173), np.float64(0.5861)), 'converged': True, 'random effects covariance': [[0.0288, 0.0328], [0.0328, 0.0374]], 'EYFP vs ChR2Scrambled': (np.float64(-0.103), np.float64(0.7553)), 'warnings': {'Random effects covariance is singular': 8}}
Variant, RI30 mixed model with a random intercept and slope per mouse (difference in slope against ChR2, and the third pair, p): {'ChR2Scrambled': (np.float64(1.295), np.float64(0.4281)), 'EYFP': (np.float64(0.057), np.float64(0.9608)), 'converged': True, 'random effects covariance': [[6.7192, -3.5321], [-3.5321, 3.3283]], 'EYFP vs ChR2Scrambled': (np.float64(-1.237), np.float64(0.4525)), 'warnings': {}}
Variant, FR1 mixed model with a random intercept per mouse only: {'ChR2Scrambled': (np.float64(-0.146), np.float64(0.057)), 'EYFP': (np.float64(-0.084), np.float64(0.2624)), 'converged': True, 'random effects covariance': [[0.3814]], 'EYFP vs ChR2Scrambled': (np.float64(0.062), np.float64(0.229)), 'warnings': {}}
Variant, RI30 mixed model with a random intercept per mouse only: {'ChR2Scrambled': (np.float64(-1.355), np.float64(0.1647)), 'EYFP': (np.float64(0.105), np.float64(0.9271)), 'converged': True, 'random effects covariance': [[1.6761]], 'EYFP vs ChR2Scrambled': (np.float64(1.46), np.float64(0.1343)), 'warnings': {}}
FR1: F test of all arm x index terms p = 0.0337
RI30: F test of all arm x index terms p = 0.117
Result: not reproduced. In FR1 the ChR2 Scrambled slope (0.087 nosepokes/min per session) is below the EYFP slope (0.224; interaction p = 0.013; all three groups F test p = 0.034). ChR2 does not differ from either control (p = 0.12 and 0.98), and in RI30 no pair differs (p = 0.09 to 0.92). The difference is between the two control groups. A mixed model with a random intercept and slope per mouse finds no difference in slope in any pair (p = 0.43 to 0.96; FR1 EYFP vs ChR2 Scrambled p = 0.76), but its FR1 fit warns that the random effects covariance is singular, so these p values are weak evidence. A mixed model with a random intercept only fits without warnings and also finds no FR1 difference (EYFP vs ChR2 Scrambled p = 0.23, ChR2 against the controls p = 0.057 and 0.26). The FR1 difference rests on the pooled fit the criterion fixed.
fig7g-dls-stim-no-shock-effect¶
DLS dopamine terminal stimulation does not change the number of shocks received.
_, p7, el7 = opto["Opto-DLS-Excitatory"]
long7 = p7[p7.probe != ""].merge(el7[["subject_arm"]].reset_index(), on="subject")
aov7, w7 = mixed_anova(long7, "subject_arm")
print(fmt(aov7))
print(w7.groupby("subject_arm")[["early", "late"]].agg(["mean", "std", "size"]).round(2).to_string())
F df p
subject_arm 3.23 2,28 0.055
probe 13.50 1,28 0.001
subject_arm x probe 2.96 2,28 0.068
probe early late
mean std size mean std size
subject_arm
ChR2 18.36 11.10 11 24.36 14.38 11
ChR2Scrambled 12.82 7.25 11 15.18 10.58 11
EYFP 19.11 9.24 9 32.89 17.14 9
Result: reproduced. Neither the group effect (p = 0.055) nor the group by probe interaction (p = 0.068) is significant across ChR2, ChR2 Scrambled and EYFP mice (31 with both probes).
fig7h-dls-similar-phenotypes¶
DLS ChR2, EYFP and ChR2 Scrambled mice have similar distributions of PR, DPR and PS.
c7 = el7.dropna(subset=["pheno"])
tab7 = pd.crosstab(c7.subject_arm, c7.pheno)
print(tab7.to_string())
chi = stats.chi2_contingency(tab7)
print(f"Chi-square: {chi.statistic:.2f}, df {chi.dof}, p = {chi.pvalue:.3g}; smallest expected count {chi.expected_freq.min():.1f}")
pheno DPR PR PS subject_arm ChR2 0 6 5 ChR2Scrambled 2 3 6 EYFP 3 5 1 Chi-square: 7.28, df 4, p = 0.122; smallest expected count 1.5
Result: reproduced. The phenotype distributions do not differ significantly (chi-square p = 0.12), though the table is small (smallest expected count 1.5) and ChR2 mice include no DPR.
fig7i-dls-no-omission-effect¶
DLS dopamine terminal stimulation does not change omission completion time.
an7 = omission_anova(el7)
mean std size min max subject_arm ChR2 28.44 15.94 10 17.06 56.32 ChR2Scrambled 33.51 16.62 11 17.04 60.00 EYFP 36.08 14.16 11 18.55 60.00 One-way ANOVA: F = 0.64, p = 0.532; Kruskal-Wallis variant p = 0.303
Result: reproduced. Omission times do not differ (ChR2 28.44, Scrambled 33.51, EYFP 36.08 min; ANOVA p = 0.53).
Data outside the dandiset¶
figs3ab-dat-protein-not-reduced¶
Heterozygous DAT-IRES-cre mice do not have reduced DAT protein in DMS or DLS compared with wild-type mice.
The dandiset holds the western blot images (one per condition) and names the densitometry table on Zenodo (doi:10.5281/zenodo.11266654) as its supplement. The table is read from Zenodo here.
zen = requests.get("https://zenodo.org/api/records/11266654", timeout=60).json()
print("Zenodo record:", zen["doi"], "version", zen.get("metadata", {}).get("version"), "; files:", [f["key"] for f in zen["files"]])
xl = requests.get(zen["files"][0]["links"]["self"], timeout=60).content
print("Densitometry.xlsx md5:", hashlib.md5(xl).hexdigest(), "(Zenodo checksum", zen["files"][0]["checksum"] + ")")
sheet = pd.read_excel(io.BytesIO(xl), header=None)
blocks = {}
for i, label in sheet[0].items():
if isinstance(label, str) and label.startswith(("Female", "Male")):
rows = sheet.iloc[i + 1:i + 8]
blocks[label.strip()] = pd.DataFrame({"sample": rows[0].str.strip().values, "corrected": rows[7].astype(float).values,
"sheet_ratio": rows[8].values})
print("Blocks in the table:", list(blocks))
print("Header row and the first block as read (rows i to i+7, columns 0 to 8):")
first = [i for i, l in sheet[0].items() if isinstance(l, str) and l.startswith(("Female", "Male"))]
print(sheet.iloc[:first[0]].to_string())
for i in first:
print(sheet.iloc[i:i + 8, :9].to_string())
dat_rows = []
for region in ["DLS", "DMS"]:
act, dat = blocks[f"Female {region} Actin"], blocks[f"Female {region} DAT"]
ratio = dat.corrected.values / act.corrected.values
geno = np.where(dat["sample"].str.startswith("WT"), "WT", "het")
pct = 100 * ratio / ratio[geno == "WT"].mean()
print(f"{region}: computed DAT/actin equals the sheet's ratio column: {np.allclose(ratio, dat.sheet_ratio.astype(float))}")
for s_, g, p in zip(dat["sample"], geno, pct):
dat_rows.append({"region": region, "sample": s_, "genotype": g, "pct_of_wt": p})
dd = pd.DataFrame(dat_rows)
print(dd.groupby(["region", "genotype"]).pct_of_wt.agg(["mean", "std", "size"]).round(1).to_string())
for region in ["DMS", "DLS"]:
x = dd[dd.region == region]
t3 = stats.ttest_ind(x[x.genotype == "het"].pct_of_wt, x[x.genotype == "WT"].pct_of_wt)
mw = stats.mannwhitneyu(x[x.genotype == "het"].pct_of_wt, x[x.genotype == "WT"].pct_of_wt)
print(f"{region}: het vs WT unpaired t = {t3.statistic:.2f}, p = {t3.pvalue:.3g}; Mann-Whitney p = {mw.pvalue:.3g}")
print("WT sample labels (one reads 'WT 4' where the others read 'WT F'):", sorted(dd[dd.genotype == "WT"]["sample"].unique()))
print("Male samples in the table:", int(dd["sample"].str.contains(" M ").sum()))
Zenodo record: 10.5281/zenodo.11266654 version None ; files: ['Densitometry.xlsx']
Densitometry.xlsx md5: 798a810141f05133b1105fd53ae532c7 (Zenodo checksum md5:798a810141f05133b1105fd53ae532c7)
Blocks in the table: ['Female DLS Actin', 'Female DLS DAT', 'Female DMS Actin', 'Female DMS DAT']
Header row and the first block as read (rows i to i+7, columns 0 to 8):
Empty DataFrame
Columns: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
Index: []
0 1 2 3 4 5 6 7 8
0 Female DLS Actin area mean min max NaN white-sample corrected sample-blank NaN
1 WT F 410.1 0.24 40536.569 0 52771 NaN 24998.431 10502.962 NaN
2 WT F 410.2 0.24 40759.65 0 52155 NaN 24775.35 10279.881 NaN
3 WT 4 410.3 0.24 41490.057 0 52151 NaN 24044.943 9549.474 NaN
4 DAT F 484.1 0.24 39085.262 0 51379 NaN 26449.738 11954.269 NaN
5 DAT F 484.2 0.24 37679.735 0 51403 NaN 27855.265 13359.796 NaN
6 DAT F 484.3 0.24 37732.15 0 51031 NaN 27802.85 13307.381 NaN
7 DAT F 484.4 0.24 38180.667 0 50289 NaN 27354.333 12858.864 NaN
0 1 2 3 4 5 6 7 8
9 Female DLS DAT area mean min max NaN NaN NaN DAT/ACT
10 WT F 410.1 0.403 40176.011 0 47299 NaN 25358.989 3927.81 0.373972
11 WT F 410.2 0.403 41208.121 9886 45405 NaN 24326.879 2895.7 0.281686
12 WT 4 410.3 0.403 41631.266 17656 45151 NaN 23903.734 2472.555 0.258921
13 DAT F 484.1 0.403 41466.513 7454 45795 NaN 24068.487 2637.308 0.220616
14 DAT F 484.2 0.403 40425.958 2604 45275 NaN 25109.042 3677.863 0.275293
15 DAT F 484.3 0.403 40257.63 6972 45883 NaN 25277.37 3846.191 0.289027
16 DAT F 484.4 0.403 42477.53 23400 45917 NaN 23057.47 1626.291 0.126472
0 1 2 3 4 5 6 7 8
19 Female DMS Actin area mean min max NaN NaN NaN NaN
20 WT F 410.1 0.248 38886.222 0 59133 NaN 26648.778 19786.179 NaN
21 WT F 410.2 0.248 44591.149 0 59107 NaN 20943.851 14081.252 NaN
22 WT 4 410.3 0.248 43165.932 0 58975 NaN 22369.068 15506.469 NaN
23 DAT F 484.1 0.248 40640.485 0 58775 NaN 24894.515 18031.916 NaN
24 DAT F 484.2 0.248 40396.025 0 58725 NaN 25138.975 18276.376 NaN
25 DAT F 484.3 0.248 39556.131 0 58661 NaN 25978.869 19116.27 NaN
26 DAT F 484.4 0.248 44629.67 0 58761 NaN 20905.33 14042.731 NaN
0 1 2 3 4 5 6 7 8
28 Female DMS DAT NaN NaN NaN NaN NaN NaN NaN NaN
29 WT F 410.1 0.315 38797.736 7706 45887 NaN 26737.264 5395.135 0.272672
30 WT F 410.2 0.315 42319.147 19720 46131 NaN 23215.853 1873.724 0.133065
31 WT 4 410.3 0.315 37640.921 2748 45679 NaN 27894.079 6551.95 0.42253
32 DAT F 484.1 0.315 37336.337 728 45855 NaN 28198.663 6856.534 0.380244
33 DAT F 484.2 0.315 36479.241 374 45631 NaN 29055.759 7713.63 0.422055
34 DAT F 484.3 0.315 35672.696 0 45557 NaN 29862.304 8520.175 0.445703
35 DAT F 484.4 0.315 41356.669 9018 46675 NaN 24178.331 2836.202 0.201969
DLS: computed DAT/actin equals the sheet's ratio column: True
DMS: computed DAT/actin equals the sheet's ratio column: True
mean std size
region genotype
DLS WT 100.0 20.0 3
het 74.7 24.2 4
DMS WT 100.0 52.4 3
het 131.3 40.0 4
DMS: het vs WT unpaired t = 0.90, p = 0.408; Mann-Whitney p = 0.629
DLS: het vs WT unpaired t = -1.46, p = 0.203; Mann-Whitney p = 0.4
WT sample labels (one reads 'WT 4' where the others read 'WT F'): ['WT 4 410.3', 'WT F 410.1', 'WT F 410.2']
Male samples in the table: 0
Result: reproduced. In the Zenodo table, DAT/actin of het mice is 131.3% of the WT mean in DMS (t-test p = 0.41) and 74.7% in DLS (p = 0.20). The table holds females only (3 WT and 4 het samples per region), and its sample names (410.1 to 410.3, 484.1 to 484.4) read as lanes of one WT and one het mouse; the male samples are not in it. The printed blocks show the columns used: the sample name, the blank-corrected intensity ('corrected sample-blank') and the sheet's DAT/ACT ratio, which the computed ratio matches.
Claims that cannot be tested¶
- figs1a-shock-aversive (Figure S1A, fear conditioning in 13 wild-type mice): the dandiset holds no fear conditioning sessions or freezing scores; every subject belongs to the fiber photometry, the three optogenetic experiments, or the western blot.
- figs3cd-open-field (Figure S3C-D): no open field tracking is in the dandiset.
- fig3c-gcamp-in-dopamine-neurons (Figures 3C and 4C): no histology images or cell counts are in the dandiset.
- fig3e-ri30-dms-peaks: no RI30 photometry (see above).
print("Session stages over all files:", sessions.stage.value_counts().to_dict())
print("Acquisition types in the western blot files: images only;",
"files whose description mentions fear, freezing, open field or histology:",
int(sessions.description.str.contains("fear|freez|open field|histolog", case=False).sum()))
print(f"Total runtime {time.time() - started:.0f} s")
Session stages over all files: {'RI60': 2163, 'FR1': 937, 'shock': 324, 'RI30': 265, 'omission': 134, 'probetest': 104, 'FR1habit': 93, 'RR20': 84, 'unknown': 18, 'other': 16, 'RR5': 1}
Acquisition types in the western blot files: images only; files whose description mentions fear, freezing, open field or histology: 0
Total runtime 1424 s
Summary¶
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.
| Claim | Data | Outcome |
|---|---|---|
| fig1d-ri60-vs-rr20-shocks | dandiset | not reproduced |
| fig1e-omission-ri60-longer | dandiset | partially reproduced |
| fig1f-omission-shock-no-correlation | dandiset | reproduced |
| fig2a-phenotype-rule | dandiset | partially reproduced |
| fig2b-only-dpr-increase | dandiset | reproduced |
| fig2c-nosepoke-rate | dandiset | reproduced |
| fig2d-omission-pr-vs-ps | dandiset | reproduced |
| fig2k-ps-wait-longer | dandiset | partially reproduced |
| fig2l-extra-nosepokes | dandiset | partially reproduced |
| fig2m-inter-reward-interval | dandiset | reproduced |
| fig2n-rewards-per-session | dandiset | partially reproduced |
| fig2o-escalation-timing | dandiset | partially reproduced |
| figs1i-ri60-more-rewards | dandiset | reproduced |
| figs2b-males-more-shocks | dandiset | reproduced |
| fig3e-ri30-dms-peaks | dandiset | blocked: data missing |
| fig3f-dms-score-phenotype | dandiset | not reproduced |
| fig3g-dms-score-shock-correlation | dandiset | partially reproduced |
| fig3h-dms-score-omission-no-correlation | dandiset | reproduced |
| fig4f-dls-score-no-phenotype | dandiset | reproduced |
| fig4g-dls-score-shock-no-correlation | dandiset | not reproduced |
| fig4h-dls-score-omission-no-correlation | dandiset | reproduced |
| fig5f-dms-chr2-escalation | dandiset | not reproduced |
| fig5g-dms-chr2-more-shocks-early | dandiset | not reproduced |
| fig5h-dms-chr2-mostly-pr | dandiset | reproduced |
| figs5f-chr2-pr-by-sex | dandiset | partially reproduced |
| fig5i-dms-stim-no-omission-effect | dandiset | reproduced |
| fig6-nphr-learning-dropout | dandiset | blocked: data missing |
| fig6g-nphr-fewer-shocks-late | dandiset | partially reproduced |
| fig6h-nphr-more-ps | dandiset | reproduced |
| fig6i-nphr-no-omission-effect | dandiset | reproduced |
| fig7f-dls-escalation-similar | dandiset | not reproduced |
| fig7g-dls-stim-no-shock-effect | dandiset | reproduced |
| fig7h-dls-similar-phenotypes | dandiset | reproduced |
| fig7i-dls-no-omission-effect | dandiset | reproduced |
| figs3ab-dat-protein-not-reduced | elsewhere (Zenodo) | reproduced |
| figs1a-shock-aversive | dandiset | blocked: data missing |
| figs3cd-open-field | dandiset | blocked: data missing |
| fig3c-gcamp-in-dopamine-neurons | dandiset | blocked: data missing |
How the analysis differs from the paper's. Statistics are run in Python, not Prism: mixed-effects analyses are linear mixed models with a random intercept per mouse, and two-way repeated measures ANOVAs use complete cases. Training files with no active nosepoke and no reward are left out (20 RR20 files that hold port entries only, and one 170 s false start); other files count as sessions even when a mouse has two on one day. Shock probe days with two files use the last file; mice with one probe day enter only the analyses that need that probe. Photometry follows the authors' MATLAB code, with one added step that leaves out channel dropouts (shown without it as a variant; fig4g turns on it, and fig3f and fig3g weaken without it), and only early and late RI60 sessions are available, so the RI30 stage of Figures 3F and 4F is missing. Optogenetic mice are classified with the Methods rule, as the dandiset stores no phenotype for them. The definitions of the wait after reward, extra nosepokes and nosepoke escalation are chosen here from the Results' wording. The supplementary figure legends were not available.
What was found about the dandiset. The dandiset holds operant behavior for four experiments, fiber
photometry for RI60 and RR20 sessions, and western blot images. Missing against the paper: photometry
during RI30 and the shock probes; 3 of 9 RR20 mice; 6 of 39 DMS excitatory mice and 5 of 47
DMS inhibitory mice; one or both shock probes of several mice; the fear conditioning, open field and
histology data; any marker of the inhibitory experiment's groups 1 and 2. 20 RR20 training files of four mice hold port entries only, and several mice have two or three RI60
files on one day. The dropped DMS inhibitory mice are, but for one, not deposited. The 'Unknown' DLS excitatory
files repeat sessions deposited under the named arms, and one mouse (290.407) is filed under two
experiments. In left-side shock probes the reward series is named right_reward_times. One stitched
photometry session mixes MedPC and TDT clocks. Several photometry recordings hold dropouts that dominate
a z-score. Fiber coordinates are stored without the signs of the Methods. The fiber photometry mice carry
their phenotype in the file names and notes, which made Figure 2 testable; one stored label (048.392)
disagrees with the stated rule by one shock.