Reproducing Xu et al. (2022) from dandiset 000239¶

Paper. Xu D, Dong M, Chen Y, Delgado AM, Hughes NC, Zhang L, O'Connor DH (2022). Cortical processing of flexible and context-dependent sensorimotor sequences. Nature 603, 464-469. doi:10.1038/s41586-022-04478-7. This attempt used the full text of the accepted author manuscript in PubMed Central (PMC9109820); the version of record is not open access. The R2 values of Figure 3b are printed only inside the figure and were read from the figure on the publisher's page.

Dandiset. DANDI:000239, version 0.230607.1752. The paper cites no version (its data statement says data are available on request). This is the only published version, so it is pinned. The draft lists the same 754 files with the same sizes; it was modified later only in its metadata, none of which the analysis uses.

Code. The paper says its MATLAB code is "available at GitHub" but gives no address, and no repository was found. The conversion code (catalystneuro/oconnor-lab-to-nwb at commit 8d3e9e9) was read: it shows that the recorded area and kind of session come only from the name of the lab's data folder, kept in the session description, and that per-trial time series were placed at the trial's start time.

What is in the dandiset. 754 files from 33 mice: 574 small files of training sessions (trial tables only), and 180 files with per-trial tongue tracking from the bottom-view video, lick port and force sensor voltages, and, for 93 of them, spike times of sorted units. There is no electrode table, no unit location or quality label, and no spike waveform. The opto sessions do not say which region was inhibited, and the control sessions do not say which session or block had the earplugs, the air flow or the lidocaine.

Claim Tested or blocked
ephys-unit-counts tested
fig3-session-counts tested
fig3-units-per-session tested
fig3a-trial-count tested
fig3c-length tested
fig3c-velocity tested
fig3c-angle tested
fig3c-identity tested
fig3c-time tested
ed5b-variables-uncorrelated tested
ed5e-variance-explained tested
ed5f-axes-orthogonal tested
fig3e-cca tested
fig3f-backtracking-svm tested
fig4g-zigzag-context tested
ed7f-target-position-alm tested
ed7h-no-time-coding tested
ed4m-active-neuron-counts tested
ed4l-alm-initiation tested
ed4o-rhythmicity tested
ed6-alm-reward-drop tested
ed1h-sequence-duration tested
ed1l-learning-curves tested
ed1l-proficiency-1500 tested
ed2-backtracking-counts tested
ed2e-missed-licks tested
ed6b-consummatory-licks tested
ed7b-correct-side tested
fig2b-s1tj-angle-variability, fig2b-almm1tj-angle-length, fig2c-almm1tj-initiation, fig2c-termination, ed3lm-half-power blocked: metadata insufficient (inhibited region not stated)
ed3f-inhibition-efficiency blocked: data missing (no spike waveforms, no fibre distance)
ed1i-earplug, ed1j-odor-masking, ed1k-tongue-numbing blocked: metadata insufficient (condition not stated)
ed1f-lick-force blocked: metadata insufficient (no force calibration)
ed4j-nnmf-clusters blocked: methods underspecified

The claims and their criteria are in claims/000239/10.1038-s41586-022-04478-7.yaml; the record with the reasons is attempts/000239-xu-2022-01.yaml. Loading and the per-session measures are in seqlick.py. The analyses run in parallel processes; the notebook takes about 20 to 25 minutes on four cores, about a quarter of it for the seed variants.

In [1]:
import time
import warnings
from collections import Counter

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy import stats

import seqlick as sl

pd.set_option("display.width", 220)
pd.set_option("display.max_columns", 40)
pd.set_option("display.max_rows", 300)
warnings.filterwarnings("ignore", category=RuntimeWarning)

import os

started = time.time()
print(f"CPU cores: {os.cpu_count()}; workers for parallel steps: {sl.cpu_workers()}")
assets = sl.list_assets()
print(f"dandiset {sl.DANDISET_ID} version {sl.VERSION}: {len(assets)} files")
# Trial tables of every file, and unit quality where there are units (spike times are not kept).
tables = sl.load_many(assets, workers=sl.cpu_workers(), video=False, spikes=False)
print(f"read the trial tables of {len(tables)} files in {time.time() - started:.0f} s")
FIG3 = ["S1TJ", "M1TJ", "ALM", "S1BF"]
EPHYS57 = FIG3 + ["M1B", "S1L", "ZZ"]
for t in tables:
    kind, area = sl.group_label(t["description"])
    t["kind"], t["area"] = kind, area
full_paths = {t["path"] for t in tables if t["kind"] == "ephys" and t["area"] in FIG3 + ["ZZ"]}
t1 = time.time()
sessions = sl.load_many([a for a in assets if a["path"] in full_paths], workers=sl.cpu_workers())
for s in sessions:
    s["kind"], s["area"] = sl.group_label(s["description"])
print(f"read video and spikes of {len(sessions)} recording sessions in {time.time() - t1:.0f} s")
CPU cores: 4; workers for parallel steps: 16
dandiset 000239 version 0.230607.1752: 754 files
read the trial tables of 754 files in 31 s
read video and spikes of 41 recording sessions in 21 s

Data check¶

Each check of the metadata against the values is stated under its output.

In [2]:
info = pd.DataFrame([{
    "path": t["path"], "subject": t["subject"], "group": t["group"], "kind": t["kind"], "area": t["area"],
    "trials": t["trials"]["n"], "units": t["n_units"], "size_mb": t["size"] / 1e6,
    "behavior_file": t["path"].endswith("_behavior.nwb")} for t in tables])
print("Files, subjects and units by session description (the name of the lab's data folder):")
print(info.groupby("group").agg(files=("path", "size"), subjects=("subject", "nunique"), trials=("trials", "sum"),
                                files_with_units=("units", lambda u: int((u > 0).sum())), units=("units", "sum"),
                                behavior_files=("behavior_file", "sum")).to_string())
print(f"\nAll files: {len(info)}; subjects: {info.subject.nunique()}; files with units: {(info.units > 0).sum()}; "
      f"units: {info.units.sum()}")
cols = Counter(tuple(t["trials"].keys()) for t in tables)
print("\nTrial table columns (sets) and how many files have them:")
for c, n in cols.most_common():
    print(n, [x for x in c if x != "n"])
Files, subjects and units by session description (the name of the lab's data folder):
                                    files  subjects  trials  files_with_units  units  behavior_files
group                                                                                               
Data behav air                          6         6    1454                 0      0               6
Data behav earplug                     10         5    1972                 0      0              10
Data behav numbing                     11         6    1643                 0      0              11
Data ephys ALM                         13         8    3457                13    450              13
Data ephys M1B                          4         2    1096                 4    117               4
Data ephys M1TJ                         9         4    2093                 9    328               9
Data ephys S1BF                         5         3    1592                 5     78               5
Data ephys S1L                         12         3    2508                12    453              12
Data ephys S1TJ                         8         4    2375                 8    265               8
Data ephys ZZ                           6         3    1549                 6    226               6
Data ephys others                      22         8    5696                22    742              22
Data learning                         574        15  141232                 0      0               0
Data opto VGAT-CRE Ai32 2s 2.5V        25         5    8222                 0      0              25
Data opto VGAT-CRE Ai32 2s 5V          35         7   10023                 0      0              35
Data opto VGAT-CRE Ai32 efficiency     14         4    3207                14    414              14

All files: 754; subjects: 33; files with units: 93; units: 3073

Trial table columns (sets) and how many files have them:
574 ['start_time', 'stop_time', 'cue', 'posIndex', 'water', 'opto', 'nolickITI', 'waterDelay', 'seqId', 'optoDur1', 'optoDur2', 'optoFreq1', 'optoFreq2', 'optoMod1', 'optoMod2', 'cue_times', 'posIndex_times', 'water_times', 'opto_times', 'lickOn_times', 'lickOff_times', 'cueOff_times', 'waterOff_times', 'waterTrig_times', 'endConsump_times']
163 ['start_time', 'stop_time', 'cue', 'posIndex', 'water', 'opto', 'cue_times', 'posIndex_times', 'water_times', 'opto_times', 'lickOn_times', 'lickOff_times']
17 ['start_time', 'stop_time', 'cue', 'posIndex', 'water', 'opto', 'nolickITI', 'cue_times', 'posIndex_times', 'water_times', 'opto_times', 'lickOn_times', 'lickOff_times']

The archive's summary (33 subjects, 754 files, units in the files) matches the files. The kind of session and the recorded area are given only by the session description, which is the name of the lab's data folder. The 35 sessions described as ephys ALM, M1TJ, S1TJ and S1BF match the 13, 9, 8 and 5 sessions of Figure 3, and the 57 sessions of the Results are these with M1B, S1L and ZZ (the zigzag task); the 22 "ephys others" sessions and 14 opto efficiency recordings are not among them. Learning files have 25 trial columns, the others 12 or 13.

In [3]:
# Recordings deposited more than once: compare the first 20 touch times of every pair of files.
keys = {}
for t in tables:
    touches = np.concatenate(t["trials"]["lickOn_times"]) if t["trials"]["n"] else np.array([])
    keys.setdefault(tuple(np.round(touches[:20], 4)), []).append(t)
dups = [v for v in keys.values() if len(v) > 1]
print(f"Groups of files with identical first 20 touch times: {len(dups)}")
for v in dups:
    for t in v:
        tt = np.concatenate(t["trials"]["lickOn_times"])
        print(f"  {t['path']}  {t['description']}  trials {t['trials']['n']}  touches {len(tt)}  "
              f"touch range {tt.min():.1f} to {tt.max():.1f} s")
    a, b = [np.concatenate(t["trials"]["lickOn_times"]) for t in v[:2]]
    n = min(len(a), len(b))
    print(f"  identical touch times over the first {n} touches: {np.mean(np.isclose(a[:n], b[:n])):.3f} of them; "
          f"the longer file's extra touches start at {max(a, b, key=len)[n]:.1f} s")
dup_paths = {t["path"] for v in dups for t in v}
Groups of files with identical first 20 touch times: 1
  sub-MX181002/sub-MX181002_ses-20181002T153057.nwb  Data learning - MX181002 2018-10-02 15:30:57  trials 259  touches 4028  touch range 2266762.2 to 2269692.5 s
  sub-MX181003/sub-MX181003_ses-20181002T153057.nwb  Data learning - MX181003 2018-10-02 15:30:57  trials 499  touches 8441  touch range 2266762.2 to 2274595.9 s
  identical touch times over the first 4028 touches: 1.000 of them; the longer file's extra touches start at 2270389.9 s

One recording is deposited twice: the learning file of MX181003 for 2018-10-02 15:30:57 repeats every touch of the MX181002 file with the same start time, and then continues. Which mouse it belongs to cannot be told from the files, so both are left out of the learning analyses.

In [4]:
# Clocks of the trial table: start_time against cue_times, per kind of file.
rows = []
for t in tables:
    tr = t["trials"]
    if not tr["n"]:
        continue
    touch = np.concatenate(tr["lickOn_times"])
    rows.append({"kind": t["kind"], "behavior_file": t["path"].endswith("_behavior.nwb"),
                 "start_minus_cue_max": np.nanmax(np.abs(tr["start_time"] - tr["cue_times"])),
                 "first_start": tr["start_time"][0], "first_cue": tr["cue_times"][0],
                 "touch_in_trials": np.mean((touch >= tr["start_time"][0]) & (touch <= tr["stop_time"][-1]))})
clk = pd.DataFrame(rows)
print("Largest |start_time - cue_times| per file, and the fraction of touches inside the span of the trial table:")
print(clk.groupby(["kind", "behavior_file"]).agg(files=("kind", "size"),
      start_minus_cue_median=("start_minus_cue_max", "median"), start_minus_cue_max=("start_minus_cue_max", "max"),
      first_cue_median=("first_cue", "median"), touch_in_trials_median=("touch_in_trials", "median")).to_string())
learn = [t for t in tables if t["kind"] == "learning"]
print("\nseqId in learning files, first value:", repr(learn[0]["trials"]["seqId"][0])[:160])
print("Distinct seqId values over all learning files:", len({v for t in learn for v in t["trials"]["seqId"]}))
Largest |start_time - cue_times| per file, and the fraction of touches inside the span of the trial table:
                                  files  start_minus_cue_median  start_minus_cue_max  first_cue_median  touch_in_trials_median
kind               behavior_file                                                                                              
behav              True              27                   0.000                0.000      0.000000e+00                     1.0
ephys              True              79                   0.000                0.001      1.843993e+01                     1.0
learning           False            574             1023587.282          4274364.864      1.023587e+06                     0.0
opto VGAT-CRE Ai32 True              74                   0.001                0.001      0.000000e+00                     1.0

seqId in learning files, first value: np.str_("[(b'', b'MCOS', b'categorical', array([3707764736,          2,          1,          1,          1,\n                 1], dtype=uint32))                
Distinct seqId values over all learning files: 499

In the 180 behaviour files, trial start times equal the cue times, and all touches fall within the trial table's span. In the 574 learning files, start_time counts from 0 while cue, touch and port times are on another clock (around 10^6 s); the analyses here use the cue times as each trial's reference. The seqId column of the learning files holds the text of an unconverted MATLAB object, not a sequence identity.

In [5]:
# Trial patterns (posIndex: the port positions visited after each port-moving touch).
pat = Counter()
for t in tables:
    for k in t["trials"]["posIndex"]:
        pat[(t["kind"], sl.trial_type(k)[0])] += 1
print("Trials by kind of file and trial type:")
print(pd.Series(pat).unstack(fill_value=0).to_string())
print("\nposIndex patterns not classified as standard, backtracking or zigzag (top 8):")
print(Counter(k for t in tables for k in t["trials"]["posIndex"] if sl.trial_type(k)[0] == "other").most_common(8))
# Opto and condition labels in the opto and control sessions.
for kind in ["opto VGAT-CRE Ai32", "behav"]:
    sub = [t for t in tables if t["kind"] == kind]
    lab = Counter()
    for t in sub:
        tr = t["trials"]
        lab["files"] += 1
        lab["opto column non-NaN"] += int(np.sum(~np.isnan(tr["opto"])))
        lab["opto_times non-NaN"] += int(np.sum(~np.isnan(tr["opto_times"])))
    print(f"\n{kind}: {dict(lab)}; areas in descriptions: {Counter(t['area'] for t in sub)}")
print("Columns that could name the inhibited region or the control condition:",
      sorted({c for t in tables if t["kind"] in ("opto VGAT-CRE Ai32", "behav") for c in t["trials"]} -
             {"n", "start_time", "stop_time", "cue", "posIndex", "water", "opto", "cue_times", "posIndex_times",
              "water_times", "opto_times", "lickOn_times", "lickOff_times", "nolickITI"}))
Trials by kind of file and trial type:
                    backtrack  other  standard  zigzag
behav                     819     10      4240       0
ephys                    3484    357     14976    1549
learning                10370   8644    115115    7103
opto VGAT-CRE Ai32        546    153     20753       0

posIndex patterns not classified as standard, backtracking or zigzag (top 8):
[(np.str_('[3 6 3 0 3 6 3 0 3 6]'), 2280), (np.str_('[3 0 3 6 3 0 3 6 3 0]'), 2272), (np.str_('[1 2 3 5 6]'), 1031), (np.str_('[5 4 3 1 0]'), 974), (np.str_('[1 2 3 2 1 2 3 2 1 2 3 4]'), 970), (np.str_('[3 2 1 2 3 2 1 2 3 2 1 0]'), 968), (np.str_('[3 6 3 0 3 6]'), 271), (np.str_('[3 0 3 6 3 0]'), 271)]

opto VGAT-CRE Ai32: {'files': 74, 'opto column non-NaN': 6221, 'opto_times non-NaN': 6583}; areas in descriptions: Counter({'2s 5V': 35, '2s 2.5V': 25, 'efficiency': 14})

behav: {'files': 27, 'opto column non-NaN': 0, 'opto_times non-NaN': 0}; areas in descriptions: Counter({'numbing': 11, 'earplug': 10, 'air': 6})
Columns that could name the inhibited region or the control condition: []

Trial types follow from posIndex: standard sequences ([1 2 3 4 5 6] and [5 4 3 2 1 0]), backtracking ([1 2 3 1 4 5 6] and [5 4 3 5 2 1 0]), zigzag (12 port moves over 5 positions) and other training variants. The opto sessions mark light trials (opto_times) but no file names the inhibited region, and no column marks the earplug, air flow or lidocaine condition.

In [6]:
# Units: quality, sortedness and duplicates.
uq = pd.DataFrame([{"group": t["group"], "area": t["area"], "kind": t["kind"], "path": t["path"],
                    "rpv": q[0], "contam": q[1], "spikes": n}
                   for t in tables for q, n in zip(t["unit_quality"], t["n_spikes"])])
print("Units with unsorted spike times:", sum(t["unsorted"] for t in tables),
      "; duplicated spike times within a unit:", sum(t["duplicate_spikes"] for t in tables))
dd = np.concatenate([t["duplicate_boundary_distance"] for t in tables])
print(f"Distance of duplicated spike times to the nearest trial start or stop (s): median {np.median(dd):.2f}, "
      f"5th percentile {np.percentile(dd, 5):.3f}; within 10 ms of a boundary: {int(np.sum(dd < 0.01))} of {len(dd)}")
print("Files with duplicated spike times:", sum(t["duplicate_spikes"] > 0 for t in tables))
print("Units with fewer than 2 spikes:", int((uq.spikes < 2).sum()))
print("ISI violation rate (2.5 ms) and contamination, quantiles over all units:")
print(uq[["rpv", "contam", "spikes"]].quantile([0, 0.25, 0.5, 0.75, 0.95, 1]).round(4).to_string())
print("Unit table columns:", Counter(tuple(t["unit_columns"]) for t in tables if t["n_units"]))
print("Files with an electrodes table or extracellular ephys metadata:", sum(t["has_electrodes"] for t in tables))
print("\nOpto sessions per mouse and description (the inhibited region is not stated):")
print(pd.crosstab(info[info.kind == "opto VGAT-CRE Ai32"].subject, info[info.kind == "opto VGAT-CRE Ai32"].area).to_string())
print("\nControl sessions per mouse (which session or block is the manipulation is not stated):")
print(pd.crosstab(info[info.kind == "behav"].subject, info[info.kind == "behav"].area).to_string())
Units with unsorted spike times: 0 ; duplicated spike times within a unit: 1446
Distance of duplicated spike times to the nearest trial start or stop (s): median 2.90, 5th percentile 0.276; within 10 ms of a boundary: 2 of 1446
Files with duplicated spike times: 35
Units with fewer than 2 spikes: 0
ISI violation rate (2.5 ms) and contamination, quantiles over all units:
         rpv  contam    spikes
0.00  0.0000  0.0000      84.0
0.25  0.0003  0.0071    5968.0
0.50  0.0011  0.0285   14390.0
0.75  0.0033  0.0952   29235.0
0.95  0.0333  1.0000   65409.4
1.00  0.5968  1.0000  336559.0
Unit table columns: Counter({('spike_times', 'obs_intervals'): 93})
Files with an electrodes table or extracellular ephys metadata: 0

Opto sessions per mouse and description (the inhibited region is not stated):
area      2s 2.5V  2s 5V  efficiency
subject                             
MX180803        5      5           0
MX180804        5      5           0
MX181002        5      5           0
MX181003        0      5           0
MX190101        0      0           4
MX190103        0      5           0
MX190201        0      0           4
MX190401        0      0           2
MX190402        0      0           4
MX200101        5      5           0
MX200102        5      5           0

Control sessions per mouse (which session or block is the manipulation is not stated):
area      air  earplug  numbing
subject                        
MX190201    1        0        0
MX190602    1        2        0
MX200102    0        0        2
MX200103    0        0        2
MX210203    0        0        2
MX210502    0        0        1
MX210601    0        0        2
MX210602    0        0        2
VC010102    1        2        0
VC010103    1        2        0
WO010401    1        2        0
WO010402    1        2        0

Spike times are sorted. 1446 spike times in 35 files are duplicated within a unit; they are not at trial boundaries (median 2.9 s from the nearest trial start or stop, 2 within 10 ms). The Methods say such duplicates from Phy were removed before analysis; they are removed here as the files are read. The units table holds only spike times and observation intervals: no quality label, location, depth or waveform, and no file has an electrodes table.

In [7]:
# Everything in the opto and control files that could name the inhibited region or the condition:
# free-text fields, general, stimulus and acquisition, and the opto and force series' attributes.
cond_assets = [a for a in assets if sl.group_label(a["description"])[0] in ("opto VGAT-CRE Ai32", "behav")]
meta = sl.run_parallel(sl.file_metadata, cond_assets, workers=sl.cpu_workers())
mdf = pd.DataFrame([{"group": sl.group_label(a["description"])[1], "session_description": m.get("session_description"),
                     "other_text": {k: m[k] for k in ("experiment_description", "notes", "protocol", "stimulus_notes") if k in m},
                     "general": tuple(m["general"]), "stimulus": tuple(m["stimulus"]), "acquisition": tuple(m["acquisition"]),
                     "series": tuple(sorted((k, v["unit"], v["conversion"], v["offset"], v["description"], v["comments"])
                                            for k, v in m["series"].items()))} for a, m in zip(cond_assets, meta)])
print(f"Files read: {len(mdf)}")
print("Session descriptions as stored in the files (first of each group):")
print(mdf.groupby("group").session_description.first().to_string())
print("Descriptions ending in ' se' (cut short in the file itself, not only in the archive):",
      int(mdf.session_description.str.endswith(" se").sum()), "of", len(mdf))
print("Other free-text fields (experiment_description, notes, protocol, stimulus_notes) present in any file:",
      sorted({k for d in mdf.other_text for k in d}))
for col in ("general", "stimulus", "acquisition", "series"):
    print(f"\nDistinct contents of {col} (number of files):")
    for v, n in Counter(mdf[col]).most_common():
        print(f"  {n}: {v}")
print("Objects inside the groups of stimulus (presentation, templates), over all files:",
      sorted({n for v in mdf.stimulus for n in v if "/" in n}))
opto_vals, opto_nan = Counter(), 0
for t in tables:
    if t["kind"] == "opto VGAT-CRE Ai32":
        o = t["trials"]["opto"]
        opto_vals.update(np.unique(o[~np.isnan(o)]).tolist())
        opto_nan += int(np.isnan(o).any())
print(f"\nDistinct values of the trial column opto over the 74 opto files (files holding each): {dict(opto_vals)}; "
      f"files with NaN in it: {opto_nan}")
print("Values of opto1 and opto2 in the first light trial of each file (first 10), distinct sets:",
      len({tuple(m["series"]["opto1"].get("values_first_light_trial", [])) for m in meta if "opto1" in m["series"]}),
      "; largest value over files:",
      max(max(m["series"][k].get("values_first_light_trial", [0])) for m in meta for k in ("opto1", "opto2") if k in m["series"]))
Files read: 101
Session descriptions as stored in the files (first of each group):
group
2s 2.5V       Data opto VGAT-CRE Ai32 2s 2.5V - MX180803 201...
2s 5V         Data opto VGAT-CRE Ai32 2s 5V - MX180803 2018-...
air                     Data behav air - MX190201 2019-10-08 se
earplug             Data behav earplug - MX190602 2019-12-03 se
efficiency    Data opto VGAT-CRE Ai32 efficiency - MX190101 ...
numbing             Data behav numbing - MX200102 2021-01-17 se
Descriptions ending in ' se' (cut short in the file itself, not only in the archive): 101 of 101
Other free-text fields (experiment_description, notes, protocol, stimulus_notes) present in any file: []

Distinct contents of general (number of files):
  101: ('experimenter', 'institution', 'lab', 'subject', 'subject/date_of_birth', 'subject/description', 'subject/genotype', 'subject/sex', 'subject/species', 'subject/subject_id')

Distinct contents of stimulus (number of files):
  101: ('presentation', 'templates')

Distinct contents of acquisition (number of files):
  101: ()

Distinct contents of series (number of files):
  74: (('lickportH', 'volt', 1.0, 0.0, 'no description', 'no comments'), ('opto1', 'categorical', 1.0, 0.0, 'no description', 'no comments'), ('opto2', 'categorical', 1.0, 0.0, 'no description', 'no comments'), ('tubeH', 'volt', 1.0, 0.0, 'no description', 'no comments'), ('tubeV', 'volt', 1.0, 0.0, 'no description', 'no comments'))
  21: (('lickportH', 'volt', 1.0, 0.0, 'no description', 'no comments'), ('tubeH', 'volt', 1.0, 0.0, 'no description', 'no comments'), ('tubeV', 'volt', 1.0, 0.0, 'no description', 'no comments'))
  6: ()
Objects inside the groups of stimulus (presentation, templates), over all files: []

Distinct values of the trial column opto over the 74 opto files (files holding each): {0.0: 56, 1.0: 70, 2.0: 56}; files with NaN in it: 74
Values of opto1 and opto2 in the first light trial of each file (first 10), distinct sets: 28 ; largest value over files: 0.01600000075995922

Nothing in the 74 opto and 27 control files names the inhibited region or the condition. The session description is cut short ("... se") in the files themselves, not only in the archive; there is no experiment description, notes or protocol field; general holds only the experimenter, institution, lab and subject; acquisition is empty, and stimulus holds the groups presentation and templates, both empty. The trial column opto takes the values 0, 1 and 2 (or NaN), with no description; opto1 and opto2 are LED drive series with the unit "categorical". The force sensor series (tubeH, tubeV) have the unit volt, a conversion of 1 and an offset of 0, so the files hold no calibration to newtons. Six of the 27 control files have no force or lick port series.

In [8]:
# Video series: rates, lengths and the scale of L and theta.
vr = pd.DataFrame([{"area": s["area"], "rate": v["rate"], "dur": len(v["out"]) / v["rate"],
                    "start_minus_cue": v["start"] - s["trials"]["cue_times"][i]}
                   for s in sessions for i, v in enumerate(s["video"])])
print("Video series per trial: sampling rate (Hz), duration (s), start minus cue (s):")
print(vr.describe().round(3).to_string())
lk = pd.DataFrame(np.concatenate([np.concatenate([l for l in s["licks"] if len(l)]) for s in sessions]),
                  columns=["on", "off", "mid", "Lmax", "theta_shoot"])
print(f"\nVideo licks: {len(lk)}; duration (ms) quantiles 1/50/99%: "
      f"{np.percentile((lk.off - lk.on) * 1000, [1, 50, 99]).round(1)}")
print(f"Lmax (mm, at 32 um/pixel) quantiles 1/50/99%: {np.nanpercentile(lk.Lmax, [1, 50, 99]).round(2)}  "
      "(Figure 3a: L up to about 2.7 mm)")
print(f"theta_shoot (deg) quantiles 1/50/99%: {np.nanpercentile(lk.theta_shoot, [1, 50, 99]).round(1)}  "
      "(Figure 3a: about -30 to 30 degrees)")
print("Units of the behavioural series as stored: tongue landmarks 'pixels', lick port and force sensors 'volt'.")
Video series per trial: sampling rate (Hz), duration (s), start minus cue (s):
            rate        dur  start_minus_cue
count  11066.000  11066.000        11066.000
mean     400.143      4.564           -0.000
std        0.125      1.431            0.000
min      399.744      2.927           -0.001
25%      400.000      3.790            0.000
50%      400.176      4.064            0.000
75%      400.256      4.637            0.000
max      400.416     10.000            0.000

Video licks: 210903; duration (ms) quantiles 1/50/99%: [ 20.   65.  119.9]
Lmax (mm, at 32 um/pixel) quantiles 1/50/99%: [0.7  2.51 3.55]  (Figure 3a: L up to about 2.7 mm)
theta_shoot (deg) quantiles 1/50/99%: [-38.4   0.3  47.1]  (Figure 3a: about -30 to 30 degrees)
Units of the behavioural series as stored: tongue landmarks 'pixels', lick port and force sensors 'volt'.

The video series are sampled at 399.7 to 400.4 Hz (Methods: 400 Hz) and start at the cue. With the Methods' 32 um per pixel, the maximal tongue length of licks has a median of 2.5 mm and theta_shoot spans about -38 to 47 degrees, consistent with the ranges of Figure 3a. The file gives the landmarks in pixels and no scale; the scale is taken from the Methods.

In [9]:
# Do the video series share the clock of the touch times? For each session, the fraction of touch
# onsets that fall in a video frame classified tongue-out, with the touch times shifted by d.
shifts = np.round(np.arange(-0.2, 0.2001, 0.0025), 4)
vid_rows = []
for s in sessions:
    hits = np.zeros(len(shifts))
    count = 0
    for i, v in enumerate(s["video"]):
        touch = np.asarray(s["trials"]["lickOn_times"][i])
        t_end = v["start"] + len(v["out"]) / v["rate"]
        touch = touch[(touch > v["start"] + 0.25) & (touch < t_end - 0.25)]
        if not len(touch):
            continue
        k = np.round((touch[None, :] + shifts[:, None] - v["start"]) * v["rate"]).astype(int)
        hits += v["out"][np.clip(k, 0, len(v["out"]) - 1)].sum(1)
        count += len(touch)
    frac = hits / count
    vid_rows.append({"session": s["path"].split("/")[1][:32], "area": s["area"], "touches": count,
                     "at_0": frac[shifts == 0][0], "best_shift_ms": 1000 * shifts[np.argmax(frac)],
                     "best": frac.max(), "at_-77ms": frac[np.isclose(shifts, -0.0775)][0],
                     "at_+77ms": frac[np.isclose(shifts, 0.0775)][0]})
vid = pd.DataFrame(vid_rows)
print("Fraction of touches inside tongue-out frames, at zero shift, at the best shift, and half a lick cycle away:")
print(vid.round(3).to_string(index=False))
print(f"\nBest shift over sessions (ms): median {vid.best_shift_ms.median():.1f}, range {vid.best_shift_ms.min():.1f} "
      f"to {vid.best_shift_ms.max():.1f}; at zero shift median {vid.at_0.median():.3f}, lowest {vid.at_0.min():.3f}; "
      f"at +/-77 ms medians {vid['at_-77ms'].median():.3f} and {vid['at_+77ms'].median():.3f}")
Fraction of touches inside tongue-out frames, at zero shift, at the best shift, and half a lick cycle away:
                         session area  touches  at_0  best_shift_ms  best  at_-77ms  at_+77ms
sub-MX170903_ses-20180304T135947  ALM     3327 0.974          -15.0 0.992     0.037     0.103
sub-MX180202_ses-20180504T134624  ALM     1903 1.000           -2.5 1.000     0.011     0.111
sub-MX180203_ses-20180526T163118  ALM     3283 1.000           -2.5 1.000     0.133     0.002
sub-MX180202_ses-20180505T144012  ALM     3766 0.999            5.0 0.999     0.009     0.099
sub-MX180203_ses-20180527T135221  ALM     2977 1.000            0.0 1.000     0.166     0.013
sub-MX180203_ses-20180529T131703  ALM     4426 1.000            0.0 1.000     0.196     0.007
sub-MX180701_ses-20180916T121311  ALM     2522 0.999            2.5 1.000     0.066     0.103
sub-MX180803_ses-20181128T130139 S1BF     4922 1.000            0.0 1.000     0.036     0.002
sub-MX180804_ses-20181125T151623  ALM     3420 1.000            0.0 1.000     0.002     0.096
sub-MX180804_ses-20181128T151110 S1BF     3831 0.998           10.0 1.000     0.008     0.071
sub-MX180804_ses-20181202T124714 S1TJ     3641 1.000            2.5 1.000     0.010     0.052
sub-MX180804_ses-20181201T130007 S1TJ     3060 0.997           10.0 1.000     0.008     0.069
sub-MX180804_ses-20181129T150336 S1BF     4211 0.999            5.0 1.000     0.010     0.052
sub-MX181002_ses-20181224T154648  ALM     5940 1.000            0.0 1.000     0.027     0.008
sub-MX181002_ses-20181229T151032 S1TJ     4932 1.000           -2.5 1.000     0.045     0.007
sub-MX181002_ses-20181228T150236 S1TJ     5553 1.000            0.0 1.000     0.056     0.015
sub-MX181002_ses-20181230T141519 S1TJ     5159 1.000           -2.5 1.000     0.031     0.005
sub-MX181002_ses-20181219T132039 S1BF     4045 1.000            0.0 1.000     0.027     0.010
sub-MX181002_ses-20181218T140101 S1BF     4948 1.000            2.5 1.000     0.028     0.012
sub-MX181002_ses-20181223T145116  ALM     5628 1.000            2.5 1.000     0.034     0.023
sub-MX181101_ses-20190329T105021 S1TJ     2753 1.000            0.0 1.000     0.031     0.182
sub-MX181101_ses-20190401T114039 M1TJ     2572 1.000            0.0 1.000     0.042     0.181
sub-MX181101_ses-20190328T114227 S1TJ     3188 1.000            0.0 1.000     0.023     0.103
sub-MX181101_ses-20190331T103702  ALM     3341 1.000           -2.5 1.000     0.054     0.189
sub-MX181101_ses-20190330T112923  ALM     3424 1.000            2.5 1.000     0.042     0.191
sub-MX181101_ses-20190402T123529 M1TJ     2960 1.000            0.0 1.000     0.048     0.174
sub-MX181301_ses-20190522T105041 M1TJ     2144 1.000            0.0 1.000     0.079     0.074
sub-MX181301_ses-20190524T135431 M1TJ     2696 0.998            7.5 1.000     0.071     0.091
sub-MX181301_ses-20190521T122250 M1TJ     2037 0.997            5.0 1.000     0.080     0.043
sub-MX181302_ses-20190210T163258 M1TJ     3353 0.987            7.5 0.998     0.065     0.018
sub-MX181302_ses-20190212T154624 M1TJ     4182 0.991           10.0 0.998     0.046     0.027
sub-MX190103_ses-20190513T110307 M1TJ     3771 1.000            2.5 1.000     0.031     0.081
sub-MX190103_ses-20190512T114257 M1TJ     4255 0.999            5.0 1.000     0.045     0.081
sub-MX190103_ses-20190515T110643  ALM     2701 1.000            0.0 1.000     0.021     0.092
sub-MX190103_ses-20190514T112816 S1TJ     4108 1.000            0.0 1.000     0.024     0.077
sub-MX200101_ses-20201126T155559   ZZ     5530 0.998            0.0 0.998     0.035     0.001
sub-MX200101_ses-20201127T154735   ZZ     5699 0.998            0.0 0.998     0.035     0.001
sub-MX200105_ses-20201210T175011   ZZ     4737 1.000           -2.5 1.000     0.018     0.003
sub-MX200105_ses-20201212T152521   ZZ     4091 1.000            0.0 1.000     0.027     0.000
sub-MX210301_ses-20210501T160243   ZZ     5088 0.977           10.0 0.994     0.011     0.060
sub-MX210301_ses-20210506T151300   ZZ     5329 0.983            7.5 0.996     0.004     0.054

Best shift over sessions (ms): median 0.0, range -15.0 to 10.0; at zero shift median 1.000, lowest 0.974; at +/-77 ms medians 0.034 and 0.054

The video shares the clock of the touch times: in all 41 sessions at least 97% of touch onsets fall in a tongue-out frame at zero shift, the best shift is between -15 and +10 ms (the 2.5 ms frames around the touch), and half a lick cycle away (77 ms) only 0 to 20% do.

In [10]:
# Do the spike times share the clock of the video? As in Extended Data Fig. 5a, tongue length L is
# decoded with the spike times shifted by each lag (positive: spikes taken later than the
# behaviour). This cell also runs the Figure 3 fits used below (one process per session).
import functools
t0 = time.time()
fig3_sessions = [s for s in sessions if s["area"] in FIG3]
LAGS = (-0.1, -0.05, -0.025, 0.0, 0.025, 0.05, 0.1)
f3 = sl.run_parallel(functools.partial(sl.fig3_session, seed=3, shifts=LAGS), fig3_sessions)
print(f"Figure 3 fits for {len(f3)} sessions in {time.time() - t0:.0f} s")
sh = pd.DataFrame([{"area": o["area"], **{f"{1000 * k:+.0f} ms": v for k, v in o["shift_r2"].items()}} for o in f3])
print("Cross-validated R2 of L (5 folds over trials) with spike times shifted, mean over sessions:")
print(sh.groupby("area").mean().round(3).loc[FIG3].to_string())
best = sh.drop(columns="area").idxmax(axis=1)
print("\nLag of the best R2, sessions per lag, S1TJ and M1TJ:")
print(best[sh.area.isin(["S1TJ", "M1TJ"])].value_counts().to_string())
Figure 3 fits for 35 sessions in 321 s
Cross-validated R2 of L (5 folds over trials) with spike times shifted, mean over sessions:
      -100 ms  -50 ms  -25 ms  +0 ms  +25 ms  +50 ms  +100 ms
area                                                         
S1TJ    0.387   0.390   0.486  0.555   0.537   0.451    0.419
M1TJ    0.307   0.374   0.392  0.398   0.457   0.416    0.351
ALM     0.072   0.092   0.086  0.090   0.111   0.097    0.098
S1BF    0.006   0.026   0.020  0.019   0.040   0.029    0.038

Lag of the best R2, sessions per lag, S1TJ and M1TJ:
+25 ms    7
+0 ms     5
-25 ms    2
+50 ms    2
-50 ms    1

The spike times share the clock of the video: decoding of L in S1TJ and M1TJ is best with the spikes at 0 or +25 ms and falls off at 50 and 100 ms in either direction. This is shown as consistent with the data; a constant offset under about 25 ms could not be told from a response lag.

In [11]:
# Shared by the sections below: the other per-session analyses, each with its own seed.
t0 = time.time()
bt_sessions = [s for s in sessions if s["area"] in FIG3 and all(
    sum(1 for k in s["trials"]["posIndex"] if sl.BACKTRACK.get(k) == d) >= 10 for d in ("LR", "RL"))]
bt = sl.run_parallel(functools.partial(sl.backtrack_session, seed=5), bt_sessions)
zz = sl.run_parallel(functools.partial(sl.zigzag_session, seed=6), [s for s in sessions if s["area"] == "ZZ"])
iti = sl.run_parallel(functools.partial(sl.iti_session, seed=7), fig3_sessions)
pe = sl.run_parallel(functools.partial(sl.peth_session, seed=8), fig3_sessions)
print(f"SVM, ITI and PETH analyses in {time.time() - t0:.0f} s")
quality = pd.DataFrame([{"path": t["path"], "area": t["area"], "kind": t["kind"], "subject": t["subject"],
                         "rpv": q[0], "contam": q[1], "contam_censored": q[2], "contam_span": q[3]}
                        for t in tables for q in t["unit_quality"]])
quality["single"] = (quality.rpv <= sl.RPV_MAX) & (quality.contam <= sl.CONTAM_MAX)


def perm_paired(a, b):
    """Exact two-tailed sign-flip permutation p of the mean paired difference."""
    d = np.asarray(a) - np.asarray(b)
    signs = np.array(np.meshgrid(*[[1, -1]] * len(d))).reshape(len(d), -1)
    null = np.abs((d[:, None] * signs).mean(0))
    return float(np.mean(null >= abs(d.mean()) - 1e-12))
SVM, ITI and PETH analyses in 471 s

ephys-unit-counts¶

Silicon probe recordings gave 1537 single units and 303 multi-units from 57 recording sessions.

In [12]:
e57 = quality[(quality.kind == "ephys") & quality.area.isin(EPHYS57)]
n_sessions = sum(1 for t in tables if t["kind"] == "ephys" and t["area"] in EPHYS57)
print(f"Recording sessions (ephys ALM, M1TJ, S1TJ, S1BF, M1B, S1L, ZZ): {n_sessions}; units {len(e57)}")
print(f"Single units {int(e57.single.sum())}, multi-units {int((~e57.single).sum())} "
      "(paper: 1537 and 303, total 1840)")
print("\nBy area:")
print(e57.groupby("area").single.agg(units="size", single="sum").assign(multi=lambda d: d.units - d.single).to_string())
print("\nBy subject:")
print(e57.groupby("subject").single.agg(units="size", single="sum").to_string())
print("\nOther classifications of the same units:")
for name, m in [("RPV <= 1% only", e57.rpv <= 0.01), ("contamination <= 15% only", e57.contam <= 0.15),
                ("RPV <= 0.5% only (the stricter rule the Methods mention)", e57.rpv <= 0.005),
                ("RPV <= 1% and contamination <= 10%", (e57.rpv <= 0.01) & (e57.contam <= 0.10)),
                ("RPV <= 1% and contamination <= 20%", (e57.rpv <= 0.01) & (e57.contam <= 0.20))]:
    print(f"  {name}: single {int(m.sum())}, multi {int((~m).sum())}")
print("Other estimates of the contamination, with RPV <= 1% and contamination <= 15%:")
for name, col in [("0.5 ms censored period", "contam_censored"), ("spikes inside the trial span, T the whole span", "contam_span")]:
    m = (e57.rpv <= 0.01) & (e57[col] <= 0.15)
    print(f"  {name}: single {int(m.sum())}, multi {int((~m).sum())}")
other = quality[(quality.kind == "ephys") & ~quality.area.isin(EPHYS57)]
print(f"\nNot among the 57: 'ephys others' {len(other)} units in 22 sessions; opto efficiency "
      f"{int((quality.kind == 'opto VGAT-CRE Ai32').sum())} units in 14 sessions")
Recording sessions (ephys ALM, M1TJ, S1TJ, S1BF, M1B, S1L, ZZ): 57; units 1917
Single units 1578, multi-units 339 (paper: 1537 and 303, total 1840)

By area:
      units  single  multi
area                      
ALM     450     411     39
M1B     117     110      7
M1TJ    328     277     51
S1BF     78      70      8
S1L     453     333    120
S1TJ    265     165    100
ZZ      226     212     14

By subject:
          units  single
subject                
MX170903     58      57
MX180201     95      78
MX180202     62      48
MX180203    117     111
MX180601    317     225
MX180602     91      74
MX180701     57      56
MX180803     23      20
MX180804    159     119
MX181002    158     124
MX181101    234     182
MX181301    104      90
MX181302     45      44
MX190103    171     138
MX200101     87      78
MX200105     73      71
MX210301     66      63

Other classifications of the same units:
  RPV <= 1% only: single 1732, multi 185
  contamination <= 15% only: single 1586, multi 331
  RPV <= 0.5% only (the stricter rule the Methods mention): single 1608, multi 309
  RPV <= 1% and contamination <= 10%: single 1490, multi 427
  RPV <= 1% and contamination <= 20%: single 1622, multi 295
Other estimates of the contamination, with RPV <= 1% and contamination <= 15%:
  0.5 ms censored period: single 1576, multi 341
  spikes inside the trial span, T the whole span: single 1439, multi 478

Not among the 57: 'ephys others' 742 units in 22 sessions; opto efficiency 414 units in 14 sessions
In [13]:
# The split per session (Extended Data Fig. 4d shows units per session).
per = e57.groupby(["area", "path"]).single.agg(units="size", single="sum")
per["multi"] = per.units - per.single
per.index = [f"{a:5s} {p.split('/')[1][:32]}" for a, p in per.index]
print(per.to_string())
print(f"\nSessions: {len(per)}; multi-units per session median {per.multi.median():.0f}, range {per.multi.min()} to {per.multi.max()}")
                                        units  single  multi
ALM   sub-MX170903_ses-20180304T135947     58      57      1
ALM   sub-MX180202_ses-20180504T134624     37      25     12
ALM   sub-MX180202_ses-20180505T144012     25      23      2
ALM   sub-MX180203_ses-20180526T163118     14      12      2
ALM   sub-MX180203_ses-20180527T135221     27      24      3
ALM   sub-MX180203_ses-20180529T131703      9       9      0
ALM   sub-MX180701_ses-20180916T121311     57      56      1
ALM   sub-MX180804_ses-20181125T151623     33      30      3
ALM   sub-MX181002_ses-20181223T145116     16      15      1
ALM   sub-MX181002_ses-20181224T154648     44      42      2
ALM   sub-MX181101_ses-20190330T112923     48      48      0
ALM   sub-MX181101_ses-20190331T103702     39      29     10
ALM   sub-MX190103_ses-20190515T110643     43      41      2
M1B   sub-MX180201_ses-20180403T143829     50      44      6
M1B   sub-MX180203_ses-20180601T162617     27      26      1
M1B   sub-MX180203_ses-20180602T153218     30      30      0
M1B   sub-MX180203_ses-20180603T173845     10      10      0
M1TJ  sub-MX181101_ses-20190401T114039     43      32     11
M1TJ  sub-MX181101_ses-20190402T123529     39      32      7
M1TJ  sub-MX181301_ses-20190521T122250     50      41      9
M1TJ  sub-MX181301_ses-20190522T105041     30      26      4
M1TJ  sub-MX181301_ses-20190524T135431     24      23      1
M1TJ  sub-MX181302_ses-20190210T163258     32      32      0
M1TJ  sub-MX181302_ses-20190212T154624     13      12      1
M1TJ  sub-MX190103_ses-20190512T114257     49      41      8
M1TJ  sub-MX190103_ses-20190513T110307     48      38     10
S1BF  sub-MX180803_ses-20181128T130139     23      20      3
S1BF  sub-MX180804_ses-20181128T151110     17      14      3
S1BF  sub-MX180804_ses-20181129T150336     17      17      0
S1BF  sub-MX181002_ses-20181218T140101     12      11      1
S1BF  sub-MX181002_ses-20181219T132039      9       8      1
S1L   sub-MX180201_ses-20180405T154810     45      34     11
S1L   sub-MX180601_ses-20180720T123927     34      21     13
S1L   sub-MX180601_ses-20180721T130219     37      20     17
S1L   sub-MX180601_ses-20180722T130108     40      31      9
S1L   sub-MX180601_ses-20180723T124346     26      20      6
S1L   sub-MX180601_ses-20180725T123219     56      35     21
S1L   sub-MX180601_ses-20180726T131239     37      30      7
S1L   sub-MX180601_ses-20180727T130005     49      37     12
S1L   sub-MX180601_ses-20180728T140319     38      31      7
S1L   sub-MX180602_ses-20180707T103113     21      16      5
S1L   sub-MX180602_ses-20180713T123246     36      31      5
S1L   sub-MX180602_ses-20180714T100456     34      27      7
S1TJ  sub-MX180804_ses-20181201T130007     59      39     20
S1TJ  sub-MX180804_ses-20181202T124714     33      19     14
S1TJ  sub-MX181002_ses-20181228T150236     16       9      7
S1TJ  sub-MX181002_ses-20181229T151032     29      18     11
S1TJ  sub-MX181002_ses-20181230T141519     32      21     11
S1TJ  sub-MX181101_ses-20190328T114227     28      21      7
S1TJ  sub-MX181101_ses-20190329T105021     37      20     17
S1TJ  sub-MX190103_ses-20190514T112816     31      18     13
ZZ    sub-MX200101_ses-20201126T155559     40      35      5
ZZ    sub-MX200101_ses-20201127T154735     47      43      4
ZZ    sub-MX200105_ses-20201210T175011     33      33      0
ZZ    sub-MX200105_ses-20201212T152521     40      38      2
ZZ    sub-MX210301_ses-20210501T160243     27      25      2
ZZ    sub-MX210301_ses-20210506T151300     39      38      1

Sessions: 57; multi-units per session median 5, range 0 to 21
In [14]:
# The contamination estimate on synthetic units of known contamination (20 units per level,
# 10 spikes/s over 1 h, refractory 2.5 ms; contaminating spikes are Poisson).
rng = np.random.default_rng(21)
syn = []
for c in (0.0, 0.05, 0.10, 0.15, 0.20, 0.30):
    for _ in range(20):
        u = sl.simulate_unit(10.0, c, 3600.0, rng)
        q = sl.unit_quality(u, 0.0, 3600.0)
        syn.append({"true": c, "rpv": q[0], "estimate": q[1], "censored_0.5ms": q[2]})
syn = pd.DataFrame(syn)
print(syn.groupby("true")[["rpv", "estimate", "censored_0.5ms"]].agg(["mean", "std"]).round(3).to_string())
print("Fraction classified multi (RPV > 1% or contamination > 15%) by true contamination:")
print(syn.assign(multi=(syn.rpv > 0.01) | (syn.estimate > 0.15)).groupby("true").multi.mean().round(2).to_string())
        rpv        estimate        censored_0.5ms       
       mean    std     mean    std           mean    std
true                                                    
0.00  0.000  0.000    0.000  0.000          0.000  0.000
0.05  0.002  0.000    0.050  0.005          0.050  0.006
0.10  0.005  0.000    0.104  0.009          0.103  0.010
0.15  0.007  0.000    0.163  0.014          0.162  0.017
0.20  0.009  0.000    0.233  0.016          0.236  0.020
0.30  0.013  0.001    0.762  0.300          0.764  0.298
Fraction classified multi (RPV > 1% or contamination > 15%) by true contamination:
true
0.00    0.00
0.05    0.00
0.10    0.00
0.15    0.85
0.20    1.00
0.30    1.00

Result: partially reproduced. The 57 recording sessions are present and hold 1917 units (1840 in the paper, within 10%). With the Methods' two criteria 1578 are single units (1537 in the paper, within tolerance) and 339 multi-units (303, outside the 10% tolerance of 272.7 to 333.3). The split depends on details of the contamination estimate that the Methods leave open; the variants printed above give 1490 to 1732 single units. Two other estimates of the contamination give 341 multi-units (a 0.5 ms censored period) and 478 (only spikes inside the trial span, over the whole span). Three variants put the multi-units inside the tolerance: the contamination criterion alone at 15% (331), the RPV criterion alone at 0.5% (309), and both criteria with a 20% contamination threshold (295). On synthetic units the estimate is close at low contamination (0.050 for 0.05, 0.104 for 0.10) and high near the threshold (0.163 for 0.15, 0.233 for 0.20), so 85% of units with a true contamination of 15% are called multi-units. Multi-units range from 0 to 21 per session.

fig3-session-counts¶

The population analyses of Figure 3 use 8 S1TJ, 9 M1TJ, 13 ALM and 5 S1BF sessions.

In [15]:
cnt = pd.DataFrame([{"area": o["area"], "subject": o["subject"], "units": o["n_units"],
                     "LR": o["n_trials"]["LR"], "RL": o["n_trials"]["RL"]} for o in f3])
print(cnt.groupby("area").agg(sessions=("area", "size"), mice=("subject", "nunique"),
                              min_LR=("LR", "min"), min_RL=("RL", "min"), min_units=("units", "min")).loc[FIG3].to_string())
print("\nEvery session has a units table and selected standard trials in both directions:",
      bool((cnt.units > 0).all() and (cnt.LR >= 10).all() and (cnt.RL >= 10).all()))
      sessions  mice  min_LR  min_RL  min_units
area                                           
S1TJ         8     4      23      23         16
M1TJ         9     4      16      18         13
ALM         13     8      21      13          9
S1BF         5     3      31      24          9

Every session has a units table and selected standard trials in both directions: True

Result: reproduced. The files described as ephys S1TJ, M1TJ, ALM and S1BF number 8, 9, 13 and 5, from 4, 4, 8 and 3 mice, and each holds units and standard trials in both directions.

fig3-units-per-session¶

Regressions used the simultaneously recorded units of each session, 32 plus or minus 13 units (mean and SD).

In [16]:
u35 = cnt.units
u57 = pd.Series([t["n_units"] for t in tables if t["kind"] == "ephys" and t["area"] in EPHYS57])
print(f"(a) 35 Figure 3 sessions: mean {u35.mean():.1f}, SD {u35.std():.1f} units (range {u35.min()} to {u35.max()})")
print(f"(b) 57 recording sessions: mean {u57.mean():.1f}, SD {u57.std():.1f} units")
print("\nPer area, Figure 3 sessions:")
print(cnt.groupby("area").units.agg(["mean", "std", "min", "max"]).round(1).loc[FIG3].to_string())
sq = quality[quality.path.isin([o["path"] for o in f3])].groupby("path").single.sum()
print(f"\nSingle units only, 35 sessions: mean {sq.mean():.1f}, SD {sq.std():.1f}")
(a) 35 Figure 3 sessions: mean 32.0, SD 14.4 units (range 9 to 59)
(b) 57 recording sessions: mean 33.6, SD 13.0 units

Per area, Figure 3 sessions:
      mean   std  min  max
area                      
S1TJ  33.1  12.1   16   59
M1TJ  36.4  12.7   13   50
ALM   34.6  15.8    9   58
S1BF  15.6   5.4    9   23

Single units only, 35 sessions: mean 26.4, SD 13.2

Result: reproduced. Under reading (b), the 57 recording sessions, the mean is 33.6 and the SD 13.0 units, both within tolerance. Under reading (a), the 35 Figure 3 sessions, the mean is 32.0 and the SD 14.4, just outside the SD's tolerance (11.7 to 14.3). Unit counts do not depend on any random seed: the SD is 14.4 under every seed.

fig3a-trial-count¶

Figure 3a averages 2684 trials of standard and backtracking sequences.

In [17]:
nstd = sum(sum(o["n_trials"].values()) for o in f3)
nbt = sum(sum(o["n_bt"].values()) for o in f3)
print(f"Selected trials over the 35 sessions: standard {nstd}, backtracking {nbt}")
print(f"(a) standard and backtracking: {nstd + nbt}; (b) standard only: {nstd}  (paper: 2684)")
print("\nBy area (standard, backtracking):")
print(pd.DataFrame([{"area": o["area"], "standard": sum(o["n_trials"].values()), "backtracking": sum(o["n_bt"].values())}
                    for o in f3]).groupby("area").sum().loc[FIG3].to_string())
Selected trials over the 35 sessions: standard 2169, backtracking 717
(a) standard and backtracking: 2886; (b) standard only: 2169  (paper: 2684)

By area (standard, backtracking):
      standard  backtracking
area                        
S1TJ       518           205
M1TJ       443           183
ALM        860           164
S1BF       348           165
In [18]:
# Does the greedy search find the most similar subset? (1) For every Figure 3 session and direction,
# exhaustive search over the first 16 candidate trials for the best 6, against greedy on the same
# 16. (2) On the full candidate sets, greedy against a local search that swaps one chosen and one
# unchosen trial while the mean pairwise distance falls.
import itertools


def local_search(D, chosen):
    chosen = list(chosen)
    rest = [k for k in range(len(D)) if k not in chosen]
    best = sl.subset_cost(D, chosen)
    improved = True
    while improved:
        improved = False
        for a in range(len(chosen)):
            for b in range(len(rest)):
                cand = chosen[:a] + [rest[b]] + chosen[a + 1:]
                c = sl.subset_cost(D, cand)
                if c < best - 1e-12:
                    chosen[a], rest[b] = rest[b], chosen[a]
                    best, improved = c, True
    return sorted(chosen), best


gx = []
for s in fig3_sessions:
    for d in ("LR", "RL"):
        D = sl.selection_features(s, "standard", d)
        if len(D) >= 16:
            Ds = D[:16, :16]
            combos = np.array(list(itertools.combinations(range(16), 6)))
            costs = Ds[combos[:, :, None], combos[:, None, :]].sum((1, 2)) / 30
            g = sl.greedy_subset(Ds, 6)
            ex = (sl.subset_cost(Ds, g), costs.min(), float(np.mean(costs <= sl.subset_cost(Ds, g) + 1e-12)))
        else:
            ex = (np.nan, np.nan, np.nan)
        n = max(10, int(round(len(D) / 3)))
        if len(D) <= 10:
            continue
        g = sl.greedy_subset(D, n)
        ls, lc = local_search(D, g)
        gx.append({"area": s["area"], "dir": d, "candidates": len(D), "chosen": n, "greedy16": ex[0], "exhaustive16": ex[1],
                   "rank16": ex[2], "greedy": sl.subset_cost(D, g), "local": lc, "shared": len(set(g) & set(ls)) / n})
gx = pd.DataFrame(gx)
e16 = gx.dropna(subset=["greedy16"])
print(f"(1) Exhaustive, 16 candidates choose 6: {len(e16)} session-directions; greedy finds the best subset in "
      f"{int(np.isclose(e16.greedy16, e16.exhaustive16).sum())}; greedy cost over best: median "
      f"{(e16.greedy16 / e16.exhaustive16).median():.3f}, max {(e16.greedy16 / e16.exhaustive16).max():.3f}; "
      f"fraction of the 8008 subsets at least as good as greedy: median {e16.rank16.median():.5f}, max {e16.rank16.max():.5f}")
print(f"(2) Full sets: {len(gx)} session-directions; local search improves on greedy in {int((gx.local < gx.greedy - 1e-12).sum())}; "
      f"greedy cost over local optimum median {(gx.greedy / gx.local).median():.3f}, max {(gx.greedy / gx.local).max():.3f}; "
      f"trials shared median {gx.shared.median():.2f}, min {gx.shared.min():.2f}")
(1) Exhaustive, 16 candidates choose 6: 70 session-directions; greedy finds the best subset in 43; greedy cost over best: median 1.000, max 1.084; fraction of the 8008 subsets at least as good as greedy: median 0.00012, max 0.00400
(2) Full sets: 70 session-directions; local search improves on greedy in 37; greedy cost over local optimum median 1.000, max 1.049; trials shared median 0.97, min 0.53

Result: reproduced. The Methods' selection gives 2169 standard and 717 backtracking trials, 2886 together (reading a), within 10% of 2684. Standard trials alone (2169) are not. The count is a third of the candidate trials (at least 10), so it does not depend on how the subset is searched. The greedy search finds the best subset in 43 of 70 exhaustive checks on 16 candidates (at most 8.4% above the best mean distance), and a swap search lowers the mean distance by at most 4.9% on the full sets, sharing a median of 97% of trials.

Figure 3c: decoding of the five variables¶

The five claims below share one fit per session and variable: elastic net (l1 ratio 0.1) on the soft-normalised rates of all units, samples from -0.5 to 0.5 s around the middle touch of the selected standard trials, 10-fold cross-validation with samples assigned to folds at random, and the largest penalty within one SE of the minimum error. R2 is 1 minus the cross-validated mean squared error over the variance. The variant keeps each trial within one fold.

In [19]:
r2 = pd.DataFrame([{"area": o["area"], "subject": o["subject"], "session": o["path"].split("/")[1][:32],
                    **{v: o["r2"][v] for v in sl.VARS}, **{v + "_trialfolds": o["r2_trialfolds"][v] for v in sl.VARS}}
                   for o in f3])
PAPER_R2 = {"L": (0.56, 0.42, 0.11, 0.04), "Ldot": (0.35, 0.32, 0.07, 0.01), "theta": (0.59, 0.57, 0.54, 0.12),
            "I": (0.23, 0.42, 0.67, 0.14), "tau": (0.17, 0.45, 0.60, 0.08)}
PAPER_SD = {"L": (0.10, 0.19, 0.09, 0.03), "Ldot": (0.12, 0.10, 0.06, 0.02), "theta": (0.12, 0.11, 0.12, 0.10),
            "I": (0.13, 0.15, 0.16, 0.05), "tau": (0.10, 0.15, 0.10, 0.03)}


def show_r2(v):
    g = r2.groupby("area")
    tab = pd.DataFrame({"mean": g[v].mean(), "sd": g[v].std(), "min": g[v].min(), "max": g[v].max(),
                        "trialfolds_mean": g[v + "_trialfolds"].mean(), "sessions": g[v].size()}).loc[FIG3]
    tab["paper"] = PAPER_R2[v]
    tab["paper_sd"] = PAPER_SD[v]
    tab["within"] = (tab["mean"] - tab["paper"]).abs() <= tab["paper_sd"]
    print(f"Cross-validated R2 of {v} by area (paper: mean and SD read from Figure 3b):")
    print(tab.round(3).to_string())
    print("\nBy subject (mean over sessions):")
    print(r2.groupby(["area", "subject"])[v].agg(["mean", "size"]).round(3).to_string())
    fig, ax = plt.subplots(figsize=(4, 2.5))
    for k, a in enumerate(FIG3):
        y = r2[r2.area == a][v]
        ax.scatter(np.full(len(y), k), y, s=12)
        ax.bar(k, y.mean(), alpha=0.3)
    ax.set_xticks(range(4), FIG3)
    ax.set_ylabel(f"R2 of {v}")
    ax.set_ylim(-0.05, 1)
    plt.show()
    print(f"Points outside the axes (below -0.05 or above 1): {int(((r2[v] < -0.05) | (r2[v] > 1)).sum())}")
    return tab

fig3c-length¶

Tongue length L is decoded from population activity best in S1TJ and M1TJ and poorly in ALM and S1BF (R2 0.56, 0.42, 0.11 and 0.04).

In [20]:
tab = show_r2("L")
print("\nOrder (min of S1TJ, M1TJ > max of ALM, S1BF):", min(tab.loc[["S1TJ", "M1TJ"], "mean"]) > max(tab.loc[["ALM", "S1BF"], "mean"]),
      "; trial-fold variant:", min(tab.loc[["S1TJ", "M1TJ"], "trialfolds_mean"]) > max(tab.loc[["ALM", "S1BF"], "trialfolds_mean"]))
Cross-validated R2 of L by area (paper: mean and SD read from Figure 3b):
       mean     sd    min    max  trialfolds_mean  sessions  paper  paper_sd  within
area                                                                                
S1TJ  0.568  0.106  0.475  0.743            0.551         8   0.56      0.10    True
M1TJ  0.423  0.175  0.125  0.683            0.392         9   0.42      0.19    True
ALM   0.117  0.089  0.005  0.288            0.087        13   0.11      0.09    True
S1BF  0.032  0.020  0.006  0.061            0.016         5   0.04      0.03    True

By subject (mean over sessions):
                mean  size
area subject              
ALM  MX170903  0.065     1
     MX180202  0.157     2
     MX180203  0.049     3
     MX180701  0.288     1
     MX180804  0.133     1
     MX181002  0.149     2
     MX181101  0.056     2
     MX190103  0.164     1
M1TJ MX181101  0.585     2
     MX181301  0.328     3
     MX181302  0.351     2
     MX190103  0.475     2
S1BF MX180803  0.028     1
     MX180804  0.032     2
     MX181002  0.034     2
S1TJ MX180804  0.724     2
     MX181002  0.493     3
     MX181101  0.501     2
     MX190103  0.618     1
No description has been provided for this image
Points outside the axes (below -0.05 or above 1): 0

Order (min of S1TJ, M1TJ > max of ALM, S1BF): True ; trial-fold variant: True

Result: reproduced. Mean cross-validated R2 of L is 0.568 (S1TJ), 0.423 (M1TJ), 0.117 (ALM) and 0.032 (S1BF), each within the SD the figure prints of its value (0.56, 0.42, 0.11, 0.04), and S1TJ and M1TJ exceed ALM and S1BF. With folds that keep trials together the order holds.

fig3c-velocity¶

Tongue velocity L' is decoded best in S1TJ and M1TJ and poorly in ALM and S1BF (R2 0.35, 0.32, 0.07 and 0.01).

In [21]:
tab = show_r2("Ldot")
print("\nOrder (min of S1TJ, M1TJ > max of ALM, S1BF):", min(tab.loc[["S1TJ", "M1TJ"], "mean"]) > max(tab.loc[["ALM", "S1BF"], "mean"]),
      "; trial-fold variant:", min(tab.loc[["S1TJ", "M1TJ"], "trialfolds_mean"]) > max(tab.loc[["ALM", "S1BF"], "trialfolds_mean"]))
Cross-validated R2 of Ldot by area (paper: mean and SD read from Figure 3b):
       mean     sd    min    max  trialfolds_mean  sessions  paper  paper_sd  within
area                                                                                
S1TJ  0.361  0.111  0.245  0.537            0.344         8   0.35      0.12    True
M1TJ  0.326  0.102  0.112  0.454            0.305         9   0.32      0.10    True
ALM   0.081  0.060 -0.000  0.203            0.064        13   0.07      0.06    True
S1BF  0.019  0.018  0.005  0.048            0.014         5   0.01      0.02    True

By subject (mean over sessions):
                mean  size
area subject              
ALM  MX170903  0.026     1
     MX180202  0.123     2
     MX180203  0.041     3
     MX180701  0.203     1
     MX180804  0.062     1
     MX181002  0.111     2
     MX181101  0.036     2
     MX190103  0.106     1
M1TJ MX181101  0.408     2
     MX181301  0.238     3
     MX181302  0.331     2
     MX190103  0.371     2
S1BF MX180803  0.048     1
     MX180804  0.015     2
     MX181002  0.007     2
S1TJ MX180804  0.399     2
     MX181002  0.303     3
     MX181101  0.383     2
     MX190103  0.419     1
No description has been provided for this image
Points outside the axes (below -0.05 or above 1): 0

Order (min of S1TJ, M1TJ > max of ALM, S1BF): True ; trial-fold variant: True

Result: reproduced. Mean R2 of L' is 0.361, 0.326, 0.081 and 0.019 for S1TJ, M1TJ, ALM and S1BF (paper 0.35, 0.32, 0.07, 0.01), all within tolerance, and the order holds in both fold schemes.

fig3c-angle¶

Tongue angle theta is encoded comparably in S1TJ, M1TJ and ALM, but not in S1BF (R2 0.59, 0.57, 0.54 and 0.12).

In [22]:
tab = show_r2("theta")
three = tab.loc[["S1TJ", "M1TJ", "ALM"], "mean"]
print(f"\nS1BF below each of the others: {bool((tab.loc['S1BF', 'mean'] < three).all())}; "
      f"spread of the other three (max - min): {three.max() - three.min():.3f}")
t3 = tab.loc[["S1TJ", "M1TJ", "ALM"], "trialfolds_mean"]
print(f"Trial-fold variant: spread {t3.max() - t3.min():.3f}, S1BF {tab.loc['S1BF', 'trialfolds_mean']:.3f}")
Cross-validated R2 of theta by area (paper: mean and SD read from Figure 3b):
       mean     sd    min    max  trialfolds_mean  sessions  paper  paper_sd  within
area                                                                                
S1TJ  0.569  0.074  0.415  0.640            0.528         8   0.59      0.12    True
M1TJ  0.534  0.107  0.345  0.630            0.476         9   0.57      0.11    True
ALM   0.520  0.130  0.289  0.709            0.469        13   0.54      0.12    True
S1BF  0.111  0.107  0.015  0.235            0.071         5   0.12      0.10    True

By subject (mean over sessions):
                mean  size
area subject              
ALM  MX170903  0.533     1
     MX180202  0.477     2
     MX180203  0.485     3
     MX180701  0.699     1
     MX180804  0.659     1
     MX181002  0.426     2
     MX181101  0.544     2
     MX190103  0.523     1
M1TJ MX181101  0.498     2
     MX181301  0.506     3
     MX181302  0.564     2
     MX190103  0.580     2
S1BF MX180803  0.219     1
     MX180804  0.147     2
     MX181002  0.021     2
S1TJ MX180804  0.517     2
     MX181002  0.575     3
     MX181101  0.577     2
     MX190103  0.640     1
No description has been provided for this image
Points outside the axes (below -0.05 or above 1): 0

S1BF below each of the others: True; spread of the other three (max - min): 0.049
Trial-fold variant: spread 0.059, S1BF 0.071

Result: reproduced. Mean R2 of theta is 0.569, 0.534, 0.520 and 0.111 for S1TJ, M1TJ, ALM and S1BF (paper 0.59, 0.57, 0.54, 0.12), all within tolerance; the first three differ by 0.049 and S1BF is below each.

fig3c-identity¶

Sequence identity I is encoded most strongly in ALM, then progressively less in M1TJ, S1TJ and S1BF (R2 0.67, 0.42, 0.23 and 0.14).

In [23]:
tab = show_r2("I")
m = tab["mean"]
print("\nOrder ALM > M1TJ > S1TJ > S1BF:", bool(m["ALM"] > m["M1TJ"] > m["S1TJ"] > m["S1BF"]))
mt = tab["trialfolds_mean"]
print("Trial-fold variant order:", bool(mt["ALM"] > mt["M1TJ"] > mt["S1TJ"] > mt["S1BF"]))
Cross-validated R2 of I by area (paper: mean and SD read from Figure 3b):
       mean     sd    min    max  trialfolds_mean  sessions  paper  paper_sd  within
area                                                                                
S1TJ  0.270  0.141  0.126  0.601            0.188         8   0.23      0.13    True
M1TJ  0.412  0.167  0.126  0.606            0.323         9   0.42      0.15    True
ALM   0.693  0.142  0.376  0.892            0.657        13   0.67      0.16    True
S1BF  0.113  0.047  0.069  0.180            0.049         5   0.14      0.05    True

By subject (mean over sessions):
                mean  size
area subject              
ALM  MX170903  0.775     1
     MX180202  0.583     2
     MX180203  0.635     3
     MX180701  0.850     1
     MX180804  0.764     1
     MX181002  0.649     2
     MX181101  0.706     2
     MX190103  0.834     1
M1TJ MX181101  0.525     2
     MX181301  0.421     3
     MX181302  0.266     2
     MX190103  0.431     2
S1BF MX180803  0.180     1
     MX180804  0.103     2
     MX181002  0.089     2
S1TJ MX180804  0.420     2
     MX181002  0.254     3
     MX181101  0.166     2
     MX190103  0.225     1
No description has been provided for this image
Points outside the axes (below -0.05 or above 1): 0

Order ALM > M1TJ > S1TJ > S1BF: True
Trial-fold variant order: True

Result: reproduced. Mean R2 of I is 0.693 (ALM), 0.412 (M1TJ), 0.270 (S1TJ) and 0.113 (S1BF) (paper 0.67, 0.42, 0.23, 0.14), all within tolerance, in the stated order, also with folds that keep trials together.

fig3c-time¶

Relative sequence time tau is encoded most strongly in ALM, then progressively less in M1TJ, S1TJ and S1BF (R2 0.60, 0.45, 0.17 and 0.08).

In [24]:
tab = show_r2("tau")
m = tab["mean"]
print("\nOrder ALM > M1TJ > S1TJ > S1BF:", bool(m["ALM"] > m["M1TJ"] > m["S1TJ"] > m["S1BF"]))
mt = tab["trialfolds_mean"]
print("Trial-fold variant order:", bool(mt["ALM"] > mt["M1TJ"] > mt["S1TJ"] > mt["S1BF"]))
Cross-validated R2 of tau by area (paper: mean and SD read from Figure 3b):
       mean     sd    min    max  trialfolds_mean  sessions  paper  paper_sd  within
area                                                                                
S1TJ  0.198  0.120  0.068  0.463            0.145         8   0.17      0.10    True
M1TJ  0.440  0.140  0.176  0.650            0.381         9   0.45      0.15    True
ALM   0.637  0.083  0.529  0.807            0.601        13   0.60      0.10    True
S1BF  0.087  0.016  0.068  0.105            0.063         5   0.08      0.03    True

By subject (mean over sessions):
                mean  size
area subject              
ALM  MX170903  0.544     1
     MX180202  0.612     2
     MX180203  0.630     3
     MX180701  0.709     1
     MX180804  0.574     1
     MX181002  0.677     2
     MX181101  0.676     2
     MX190103  0.635     1
M1TJ MX181101  0.553     2
     MX181301  0.422     3
     MX181302  0.363     2
     MX190103  0.430     2
S1BF MX180803  0.088     1
     MX180804  0.103     2
     MX181002  0.071     2
S1TJ MX180804  0.325     2
     MX181002  0.125     3
     MX181101  0.203     2
     MX190103  0.155     1
No description has been provided for this image
Points outside the axes (below -0.05 or above 1): 0

Order ALM > M1TJ > S1TJ > S1BF: True
Trial-fold variant order: True

Result: reproduced. Mean R2 of tau is 0.637 (ALM), 0.440 (M1TJ), 0.198 (S1TJ) and 0.087 (S1BF) (paper 0.60, 0.45, 0.17, 0.08), all within tolerance, in the stated order in both fold schemes.

ed5b-variables-uncorrelated¶

Any pair of the five behavioural variables (L, L', theta, I, tau) is uncorrelated.

In [25]:
cr = pd.DataFrame([o["corr"] for o in f3])
summ = pd.DataFrame({"mean_abs_r": cr.mean(), "max_over_sessions": cr.max()})
summ.index = [f"{a}-{b}" for a, b in summ.index]
print("Absolute Pearson r per pair, over 35 sessions:")
print(summ.round(3).to_string())
print(f"\nPairs with mean |r| >= 0.3: {int((summ.mean_abs_r >= 0.3).sum())} of {len(summ)}")
Absolute Pearson r per pair, over 35 sessions:
            mean_abs_r  max_over_sessions
L-Ldot           0.074              0.259
L-theta          0.103              0.368
L-I              0.042              0.117
L-tau            0.062              0.173
Ldot-theta       0.078              0.229
Ldot-I           0.013              0.033
Ldot-tau         0.033              0.095
theta-I          0.118              0.257
theta-tau        0.083              0.348
I-tau            0.000              0.000

Pairs with mean |r| >= 0.3: 0 of 10

Result: reproduced. The mean absolute correlation of every pair is below 0.3 (largest theta-I, 0.118); the largest single-session value is 0.368 (L-theta).

ed5e-variance-explained¶

Across regions, the summed variance explained by the five coding axes reached about half that of the top five principal components.

In [26]:
ve = pd.DataFrame([{"area": o["area"], "axes_sum": sum(o["ve_axes"].values()), "pc5": o["ve_pc5"],
                    **{"ve_" + v: o["ve_axes"][v] for v in sl.VARS}} for o in f3])
ve["ratio"] = ve.axes_sum / ve.pc5
print(ve.groupby("area")[["axes_sum", "pc5", "ratio"]].median().round(3).loc[FIG3].to_string())
print(f"\nAll 35 sessions: median ratio {ve.ratio.median():.3f} (IQR {ve.ratio.quantile(0.25):.3f} to "
      f"{ve.ratio.quantile(0.75):.3f}); mean {ve.ratio.mean():.3f}")
print(f"Coding axes with all weights zero: {sum(1 for o in f3 for v in sl.VARS if not np.any(o['weights'][v]))} of {5 * len(f3)}")
      axes_sum    pc5  ratio
area                        
S1TJ     0.202  0.459  0.442
M1TJ     0.189  0.452  0.476
ALM      0.187  0.462  0.454
S1BF     0.228  0.565  0.433

All 35 sessions: median ratio 0.454 (IQR 0.387 to 0.544); mean 0.490
Coding axes with all weights zero: 1 of 175

Result: reproduced. The summed VE of the five coding axes over that of the top five PCs has a median of 0.454 over the 35 sessions (mean 0.490, IQR 0.387 to 0.544), within 0.45 to 0.55, though close to its lower bound.

ed5f-axes-orthogonal¶

The five coding axes were largely orthogonal to each other.

In [27]:
cs = pd.DataFrame([o["cos"] for o in f3])
summ = pd.DataFrame({"mean_abs_cos": cs.mean(), "sessions": cs.count()})
summ.index = [f"{a}-{b}" for a, b in summ.index]
print(summ.round(3).to_string())
print(f"\nPairs with mean |cos| >= 0.3: {int((summ.mean_abs_cos >= 0.3).sum())} of {len(summ)}; "
      f"random directions in the same sessions: mean |cos| {np.mean([o['cos_random'] for o in f3]):.3f}")
            mean_abs_cos  sessions
L-Ldot             0.225        34
L-theta            0.151        35
L-I                0.151        35
L-tau              0.182        35
Ldot-theta         0.302        34
Ldot-I             0.114        34
Ldot-tau           0.223        34
theta-I            0.215        35
theta-tau          0.212        35
I-tau              0.209        35

Pairs with mean |cos| >= 0.3: 1 of 10; random directions in the same sessions: mean |cos| 0.154

Result: partially reproduced. Nine of the ten pairs of coding axes have a mean absolute cosine below 0.3 (0.114 to 0.225); L' and theta have 0.302. Random directions in the same sessions give 0.154.

Seed variants of the Figure 3 fits¶

The fold assignment (random over samples) and the random reference directions depend on the seed; the trial selection and the unit counts do not. The fits are repeated with three more seeds, without the trial-fold variant and the spike-shift check. These are variants: the outcomes stay with seed 3.

In [28]:
t0 = time.time()
SEEDS = (11, 12, 13)
f3s = {3: f3}
for sd in SEEDS:
    f3s[sd] = sl.run_parallel(functools.partial(sl.fig3_session, seed=sd, trialfolds=False), fig3_sessions)
print(f"Fits with {len(SEEDS)} more seeds in {time.time() - t0:.0f} s")
srows = []
for sd, res in f3s.items():
    ratio = pd.Series([sum(o["ve_axes"].values()) / o["ve_pc5"] for o in res])
    cosm = pd.DataFrame([o["cos"] for o in res]).mean()
    r2m = pd.DataFrame([{"area": o["area"], **o["r2"]} for o in res]).groupby("area").mean()
    srows.append({"seed": sd, "ed5e_median_ratio": ratio.median(), "ed5f_pairs_ge_0.3": int((cosm >= 0.3).sum()),
                  "ed5f_largest_pair": f"{'-'.join(cosm.idxmax())} {cosm.max():.3f}",
                  "Ldot_theta_cos": cosm[("Ldot", "theta")],
                  **{f"{v}_{a}": r2m.loc[a, v] for v in sl.VARS for a in FIG3}})
sv = pd.DataFrame(srows).set_index("seed")
print(sv[["ed5e_median_ratio", "ed5f_pairs_ge_0.3", "ed5f_largest_pair", "Ldot_theta_cos"]].round(3).to_string())
print(f"\ned5e median ratio over seeds: {sv.ed5e_median_ratio.min():.3f} to {sv.ed5e_median_ratio.max():.3f}; "
      f"seeds inside 0.45 to 0.55: {int(sv.ed5e_median_ratio.between(0.45, 0.55).sum())} of {len(sv)}")
print(f"ed5f: seeds with no pair >= 0.3: {int((sv['ed5f_pairs_ge_0.3'] == 0).sum())}, with one pair: "
      f"{int((sv['ed5f_pairs_ge_0.3'] == 1).sum())}, with two or more: {int((sv['ed5f_pairs_ge_0.3'] >= 2).sum())} of {len(sv)}")
print("\nMean R2 by area and variable, range over the 4 seeds:")
print(pd.DataFrame({v: [f"{sv[f'{v}_{a}'].min():.3f} to {sv[f'{v}_{a}'].max():.3f}" for a in FIG3] for v in sl.VARS},
                   index=FIG3).to_string())
print("\nUnits per session do not depend on the seed: SD over the 35 sessions",
      round(float(np.std([o["n_units"] for o in f3s[11]], ddof=1)), 1), "with seed 11, as with seed 3.")
Fits with 3 more seeds in 367 s
      ed5e_median_ratio  ed5f_pairs_ge_0.3 ed5f_largest_pair  Ldot_theta_cos
seed                                                                        
3                 0.454                  1  Ldot-theta 0.302           0.302
11                0.455                  1  Ldot-theta 0.320           0.320
12                0.455                  1  Ldot-theta 0.306           0.306
13                0.453                  1  Ldot-theta 0.317           0.317

ed5e median ratio over seeds: 0.453 to 0.455; seeds inside 0.45 to 0.55: 4 of 4
ed5f: seeds with no pair >= 0.3: 0, with one pair: 4, with two or more: 0 of 4

Mean R2 by area and variable, range over the 4 seeds:
                   L            Ldot           theta               I             tau
S1TJ  0.568 to 0.568  0.361 to 0.362  0.566 to 0.569  0.270 to 0.270  0.197 to 0.198
M1TJ  0.422 to 0.423  0.325 to 0.328  0.533 to 0.534  0.412 to 0.412  0.438 to 0.440
ALM   0.117 to 0.118  0.080 to 0.083  0.520 to 0.521  0.693 to 0.693  0.637 to 0.637
S1BF  0.032 to 0.032  0.013 to 0.019  0.107 to 0.111  0.113 to 0.114  0.086 to 0.087

Units per session do not depend on the seed: SD over the 35 sessions 14.4 with seed 11, as with seed 3.

With four seeds (3, 11, 12, 13) the median VE ratio of ed5e is 0.453 to 0.455, inside 0.45 to 0.55 every time but always close to the lower bound. In ed5f, L' and theta have a mean absolute cosine of 0.302 to 0.320 and are the only pair at or above 0.3 under every seed, so the partial outcome does not depend on the seed. The mean R2 of the five decoded variables changes little across seeds (range printed above).

fig3e-cca¶

After canonical correlation, ALM trajectories in the top three PCs correlate best with the theta, I, tau coding subspace (group 2), more than with L, L', theta (group 1) or all five variables with five PCs (group 3); in S1TJ and M1TJ the correlation is high only when the coding subspace includes L and L'.

In [29]:
cc = pd.DataFrame([{"area": o["area"], "g1": o["cca"][1], "g2": o["cca"][2], "g3": o["cca"][3]} for o in f3])
print("Mean canonical correlation by group (mean over sessions):")
print(cc.groupby("area")[["g1", "g2", "g3"]].mean().round(3).loc[FIG3].to_string())
tests = [("ALM", "g2", "g1"), ("ALM", "g2", "g3"), ("S1TJ", "g1", "g2"), ("S1TJ", "g3", "g2"), ("M1TJ", "g3", "g2")]
held = 0
print("\nComparisons the figure marks (paired two-tailed sign-flip permutation, exact):")
for a, hi, lo in tests:
    d = cc[cc.area == a]
    p = perm_paired(d[hi], d[lo])
    ok = d[hi].mean() > d[lo].mean() and p < 0.05
    held += ok
    print(f"  {a}: {hi} {d[hi].mean():.3f} vs {lo} {d[lo].mean():.3f}, difference {d[hi].mean() - d[lo].mean():+.3f}, "
          f"p = {p:.4f}, n = {len(d)} -> {'holds' if ok else 'does not hold'}")
print(f"{held} of 5 comparisons hold")
Mean canonical correlation by group (mean over sessions):
         g1     g2     g3
area                     
S1TJ  0.746  0.589  0.735
M1TJ  0.712  0.662  0.809
ALM   0.607  0.900  0.743
S1BF  0.661  0.605  0.690

Comparisons the figure marks (paired two-tailed sign-flip permutation, exact):
  ALM: g2 0.900 vs g1 0.607, difference +0.293, p = 0.0002, n = 13 -> holds
  ALM: g2 0.900 vs g3 0.743, difference +0.157, p = 0.0002, n = 13 -> holds
  S1TJ: g1 0.746 vs g2 0.589, difference +0.156, p = 0.0234, n = 8 -> holds
  S1TJ: g3 0.735 vs g2 0.589, difference +0.146, p = 0.0078, n = 8 -> holds
  M1TJ: g3 0.809 vs g2 0.662, difference +0.147, p = 0.0195, n = 9 -> holds
5 of 5 comparisons hold

Result: reproduced. In ALM the theta, I, tau subspace (group 2) correlates with PC1-3 at 0.900, above group 1 (0.607) and group 3 (0.743), p = 0.0002 each. In S1TJ groups 1 and 3 exceed group 2 (0.746 and 0.735 against 0.589, p = 0.023 and 0.008), and in M1TJ group 3 exceeds group 2 (0.809 against 0.662, p = 0.020). All five comparisons hold.

fig3f-backtracking-svm¶

ALM and M1TJ activity predicts whether the port backtracked from the initial missed lick on; S1TJ shows only an insignificant trend and S1BF no prediction.

In [30]:
print("Sessions with backtracking in both directions, by area:", Counter(o["area"] for o in bt))
print("Trials per class per session (min, max): standard",
      (min(o["n"]["standard"] for o in bt), max(o["n"]["standard"] for o in bt)), "backtracking",
      (min(o["n"]["backtrack"] for o in bt), max(o["n"]["backtrack"] for o in bt)))
fig, axes = plt.subplots(1, 4, figsize=(12, 2.5), sharey=True)
rows = []
for ax, a in zip(axes, FIG3):
    os_ = [o for o in bt if o["area"] == a]
    acc = np.mean([o["acc"] for o in os_], 0)
    real = np.mean([o["acc"][o["null_bins"]].mean() for o in os_])
    null = np.mean([o["null"].mean(1) for o in os_], 0)
    rows.append({"area": a, "sessions": len(os_), "mice": len({o["subject"] for o in os_}),
                 "acc_0.2_0.4": real, "null_mean": null.mean(), "null_95": np.percentile(null, 95),
                 "decodes": real > np.percentile(null, 95),
                 "acc_before_0.1": np.mean([o["acc"][o["t"] < 0.1].mean() for o in os_])})
    ax.plot(os_[0]["t"], acc)
    ax.axhline(0.5, color="k", lw=0.5)
    ax.set_title(a)
    ax.set_xlabel("time from backtracking trigger (s)")
axes[0].set_ylabel("accuracy")
plt.show()
res = pd.DataFrame(rows)
print(res.round(3).to_string(index=False))
print(f"Plotted mean accuracies range from {min(np.mean([o['acc'] for o in bt if o['area'] == a], 0).min() for a in FIG3):.3f} "
      f"to {max(np.mean([o['acc'] for o in bt if o['area'] == a], 0).max() for a in FIG3):.3f}; none outside the axes.")
print("\nPer session, mean accuracy in 0.2 to 0.4 s and its own null 95th percentile:")
for o in bt:
    print(f"  {o['area']:5s} {o['path'].split('/')[1][:32]}  {o['acc'][o['null_bins']].mean():.3f}  "
          f"{np.percentile(o['null'].mean(1), 95):.3f}")
Sessions with backtracking in both directions, by area: Counter({'M1TJ': 9, 'S1TJ': 8, 'ALM': 6, 'S1BF': 5})
Trials per class per session (min, max): standard (32, 84) backtracking (20, 38)
No description has been provided for this image
area  sessions  mice  acc_0.2_0.4  null_mean  null_95  decodes  acc_before_0.1
S1TJ         8     4        0.595      0.500    0.516     True           0.487
M1TJ         9     4        0.734      0.504    0.523     True           0.513
 ALM         6     4        0.740      0.500    0.526     True           0.487
S1BF         5     3        0.556      0.499    0.515     True           0.519
Plotted mean accuracies range from 0.439 to 0.830; none outside the axes.

Per session, mean accuracy in 0.2 to 0.4 s and its own null 95th percentile:
  S1BF  sub-MX180803_ses-20181128T130139  0.564  0.527
  ALM   sub-MX180804_ses-20181125T151623  0.798  0.566
  S1BF  sub-MX180804_ses-20181128T151110  0.603  0.542
  S1TJ  sub-MX180804_ses-20181202T124714  0.653  0.547
  S1TJ  sub-MX180804_ses-20181201T130007  0.588  0.555
  S1BF  sub-MX180804_ses-20181129T150336  0.520  0.541
  ALM   sub-MX181002_ses-20181224T154648  0.680  0.554
  S1TJ  sub-MX181002_ses-20181229T151032  0.495  0.544
  S1TJ  sub-MX181002_ses-20181228T150236  0.541  0.526
  S1TJ  sub-MX181002_ses-20181230T141519  0.605  0.561
  S1BF  sub-MX181002_ses-20181219T132039  0.585  0.551
  S1BF  sub-MX181002_ses-20181218T140101  0.507  0.544
  ALM   sub-MX181002_ses-20181223T145116  0.680  0.550
  S1TJ  sub-MX181101_ses-20190329T105021  0.723  0.553
  M1TJ  sub-MX181101_ses-20190401T114039  0.945  0.592
  S1TJ  sub-MX181101_ses-20190328T114227  0.634  0.553
  ALM   sub-MX181101_ses-20190331T103702  0.705  0.550
  ALM   sub-MX181101_ses-20190330T112923  0.892  0.550
  M1TJ  sub-MX181101_ses-20190402T123529  0.839  0.574
  M1TJ  sub-MX181301_ses-20190522T105041  0.673  0.575
  M1TJ  sub-MX181301_ses-20190524T135431  0.676  0.533
  M1TJ  sub-MX181301_ses-20190521T122250  0.606  0.546
  M1TJ  sub-MX181302_ses-20190210T163258  0.776  0.552
  M1TJ  sub-MX181302_ses-20190212T154624  0.777  0.553
  M1TJ  sub-MX190103_ses-20190513T110307  0.669  0.585
  M1TJ  sub-MX190103_ses-20190512T114257  0.645  0.540
  ALM   sub-MX190103_ses-20190515T110643  0.684  0.560
  S1TJ  sub-MX190103_ses-20190514T112816  0.522  0.543

Result: partially reproduced. ALM (6 sessions, as in the paper) and M1TJ classify backtracking from standard trials in 0.2 to 0.4 s after the trigger, with mean accuracies 0.740 and 0.734 against null 95th percentiles of 0.526 and 0.523, and are near 0.5 before 0.1 s. S1TJ (0.595 against 0.516) and S1BF (0.556 against 0.515) are also above their nulls, which the paper's figure does not show: S1TJ and S1BF are weaker than ALM and M1TJ, but not at chance. In the same window behaviour differs between the classes (1.854 against 0.113 missed licks in the fourth interval, see ed2e-missed-licks), which S1TJ and S1BF activity may reflect.

fig4g-zigzag-context¶

In the zigzag task, ALM population activity predicts the sequence identity (context) during subsequences with the same licking movements, across 6 sessions from 3 mice.

In [31]:
print(f"Zigzag sessions {len(zz)}, mice {len({o['subject'] for o in zz})}")
print("Perfectly executed trials per session (A starts at position 0, B at position 4):",
      [(o["subject"], o["n"]["A"], o["n"]["B"]) for o in zz])
zrows = []
fig, axes = plt.subplots(1, 4, figsize=(12, 2.5), sharey=True)
for k, ax in enumerate(axes):
    accs = [o["comps"][k]["acc"] for o in zz]
    real = np.mean([a.mean() for a in accs])
    null = np.mean([o["comps"][k]["null"].mean(1) for o in zz], 0)
    zrows.append({"comparison": zz[0]["comps"][k]["label"], "mean_acc": real, "null_mean": null.mean(),
                  "null_95": np.percentile(null, 95), "decodes": real > np.percentile(null, 95),
                  "lowest_session": min(a.mean() for a in accs)})
    ax.plot(zz[0]["comps"][k]["t"], np.mean(accs, 0))
    ax.axhline(0.5, color="k", lw=0.5)
    ax.set_title(zz[0]["comps"][k]["label"], fontsize=9)
    ax.set_xlabel("time from aligned touch (s)")
axes[0].set_ylabel("accuracy")
plt.show()
print(pd.DataFrame(zrows).round(3).to_string(index=False))
print("By mouse (mean over its sessions and the four comparisons):")
print(pd.DataFrame([{"subject": o["subject"], "acc": np.mean([c["acc"].mean() for c in o["comps"]])} for o in zz])
      .groupby("subject").acc.mean().round(3).to_string())
Zigzag sessions 6, mice 3
Perfectly executed trials per session (A starts at position 0, B at position 4): [('MX200101', 36, 44), ('MX200101', 18, 54), ('MX200105', 51, 62), ('MX200105', 42, 29), ('MX210301', 63, 72), ('MX210301', 79, 78)]
No description has been provided for this image
       comparison  mean_acc  null_mean  null_95  decodes  lowest_session
 fix B6, shift A2     0.819      0.504    0.518     True           0.739
fix B6, shift A10     0.809      0.500    0.511     True           0.654
 fix A6, shift B2     0.858      0.501    0.510     True           0.730
fix A6, shift B10     0.785      0.501    0.513     True           0.651
By mouse (mean over its sessions and the four comparisons):
subject
MX200101    0.807
MX200105    0.730
MX210301    0.916

Result: reproduced. In the 6 zigzag sessions from 3 mice, sequence identity is classified during matched subsequences with mean accuracies of 0.785 to 0.858 in the four comparisons, against null 95th percentiles of 0.510 to 0.518; the lowest single session is 0.651.

ed7f-target-position-alm¶

During the inter-trial interval, ALM populations encode the upcoming target position more strongly than the other regions.

In [32]:
it = pd.DataFrame(iti)
print(it.groupby("area")[["TP", "TP_trialfolds", "n_trials"]].agg(["mean", "std"]).round(3).loc[FIG3].to_string())
alm = it[it.area == "ALM"].TP
for a in ["M1TJ", "S1TJ", "S1BF"]:
    o = it[it.area == a].TP
    p = stats.mannwhitneyu(alm, o, alternative="greater").pvalue
    pt = stats.mannwhitneyu(it[it.area == "ALM"].TP_trialfolds, it[it.area == a].TP_trialfolds, alternative="greater").pvalue
    print(f"ALM > {a}: difference {alm.mean() - o.mean():+.3f}, Mann-Whitney p = {p:.4f} (trial-fold variant p = {pt:.4f})")
mouse = it.groupby(["area", "subject"]).TP.mean().reset_index()
print("\nVariant, one value per mouse (mean over its sessions):")
for a in ["M1TJ", "S1TJ", "S1BF"]:
    x, y = mouse[mouse.area == "ALM"].TP, mouse[mouse.area == a].TP
    print(f"  ALM ({len(x)} mice) > {a} ({len(y)} mice): difference {x.mean() - y.mean():+.3f}, "
          f"Mann-Whitney p = {stats.mannwhitneyu(x, y, alternative='greater').pvalue:.4f}")
print("\nBy subject:")
print(it.groupby(["area", "subject"]).TP.mean().round(3).to_string())
         TP        TP_trialfolds        n_trials        
       mean    std          mean    std     mean     std
area                                                    
S1TJ  0.075  0.036         0.034  0.030  209.625  35.079
M1TJ  0.097  0.066         0.028  0.048  162.333  31.468
ALM   0.271  0.161         0.159  0.172  220.692  49.663
S1BF  0.051  0.041         0.022  0.035  215.000  25.971
ALM > M1TJ: difference +0.173, Mann-Whitney p = 0.0025 (trial-fold variant p = 0.0226)
ALM > S1TJ: difference +0.196, Mann-Whitney p = 0.0007 (trial-fold variant p = 0.0319)
ALM > S1BF: difference +0.220, Mann-Whitney p = 0.0014 (trial-fold variant p = 0.0473)

Variant, one value per mouse (mean over its sessions):
  ALM (8 mice) > M1TJ (4 mice): difference +0.195, Mann-Whitney p = 0.0242
  ALM (8 mice) > S1TJ (4 mice): difference +0.210, Mann-Whitney p = 0.0141
  ALM (8 mice) > S1BF (3 mice): difference +0.248, Mann-Whitney p = 0.0121

By subject:
area  subject 
ALM   MX170903    0.642
      MX180202    0.191
      MX180203    0.336
      MX180701    0.274
      MX180804    0.400
      MX181002    0.055
      MX181101    0.263
      MX190103    0.180
M1TJ  MX181101    0.090
      MX181301    0.099
      MX181302    0.062
      MX190103    0.138
S1BF  MX180803    0.015
      MX180804    0.092
      MX181002    0.027
S1TJ  MX180804    0.105
      MX181002    0.056
      MX181101    0.052
      MX190103    0.117

Result: reproduced. In the second before the cue, the upcoming direction is decoded with mean R2 0.271 in ALM, against 0.097 (M1TJ), 0.075 (S1TJ) and 0.051 (S1BF); ALM is higher than each (Mann-Whitney p = 0.0025, 0.0007, 0.0014). With folds that keep trials together the values are lower (ALM 0.159) and ALM is still higher than each (p = 0.023 to 0.047). The 13 ALM sessions come from 8 mice; with one value per mouse ALM is still higher than each region (p = 0.012 to 0.024).

ed7h-no-time-coding¶

None of the regions, including ALM, encoded time or distance to the trial start during the inter-trial interval.

In [33]:
print(it.groupby("area")[["time", "time_trialfolds"]].agg(["mean", "max"]).round(3).loc[FIG3].to_string())
print("Regions with mean R2 of the time to cue >= 0.1:", list(it.groupby("area").time.mean().loc[lambda x: x >= 0.1].index))
       time        time_trialfolds       
       mean    max            mean    max
area                                     
S1TJ  0.024  0.043           0.008  0.023
M1TJ  0.033  0.063           0.013  0.028
ALM   0.034  0.091           0.018  0.066
S1BF  0.008  0.019           0.004  0.010
Regions with mean R2 of the time to cue >= 0.1: []

Result: reproduced. The time to the cue over the last second of the interval is decoded with mean R2 0.008 to 0.034 by region (largest session 0.091), below 0.1 in every region.

ed4m-active-neuron-counts¶

Extended Data Fig. 4m counts 324 ALM, 233 M1TJ and 119 S1TJ neurons.

In [34]:
units = []
for o in pe:
    q = o["quality"]
    for k in range(q.shape[0]):
        p6 = {key: o["peth"][key][1][k] for key in o["peth"]}
        mx = max(v.max() for v in p6.values())
        peak = max(p6, key=lambda key: p6[key].max())
        r_avg = np.nanmean([sl.rhythmicity(o["peth"][("mid", d)][0], p6[("mid", d)]) for d in ("LR", "RL")])
        units.append({"area": o["area"], "subject": o["subject"], "path": o["path"], "unit": k,
                      "single": (q[k, 0] <= sl.RPV_MAX) and (q[k, 1] <= sl.CONTAM_MAX),
                      "max_rate": mx, "peak_period": peak[0], "r_avg": r_avg})
units = pd.DataFrame(units)
print("Selected trials per PETH (min over sessions):",
      {f"{p}-{d}": min(o["n_trials"][(p, d)] for o in pe) for p in sl.PETH_WINDOWS for d in ("LR", "RL")})
paper = {"ALM": 324, "M1TJ": 233, "S1TJ": 119}
for thr in (5, 10, 15):
    act = units[units.max_rate >= thr]
    a = act[act.single].groupby("area").size()
    b = act.groupby("area").size()
    print(f"Max PETH rate >= {thr} spikes/s: (a) single units {a.reindex(FIG3).to_dict()}; (b) all units {b.reindex(FIG3).to_dict()}")
print("Paper:", paper)
act = units[(units.max_rate >= 10) & units.single]
print("\nReading (a) at 10 spikes/s by subject:")
print(act.groupby(["area", "subject"]).size().to_string())
Selected trials per PETH (min over sessions): {'init-LR': 16, 'init-RL': 13, 'mid-LR': 16, 'mid-RL': 13, 'term-LR': 16, 'term-RL': 13}
Max PETH rate >= 5 spikes/s: (a) single units {'S1TJ': 164, 'M1TJ': 259, 'ALM': 387, 'S1BF': 67}; (b) all units {'S1TJ': 264, 'M1TJ': 303, 'ALM': 426, 'S1BF': 74}
Max PETH rate >= 10 spikes/s: (a) single units {'S1TJ': 155, 'M1TJ': 223, 'ALM': 351, 'S1BF': 55}; (b) all units {'S1TJ': 251, 'M1TJ': 261, 'ALM': 387, 'S1BF': 59}
Max PETH rate >= 15 spikes/s: (a) single units {'S1TJ': 140, 'M1TJ': 200, 'ALM': 321, 'S1BF': 44}; (b) all units {'S1TJ': 226, 'M1TJ': 234, 'ALM': 353, 'S1BF': 48}
Paper: {'ALM': 324, 'M1TJ': 233, 'S1TJ': 119}

Reading (a) at 10 spikes/s by subject:
area  subject 
ALM   MX170903    39
      MX180202    46
      MX180203    38
      MX180701    47
      MX180804    27
      MX181002    52
      MX181101    68
      MX190103    34
M1TJ  MX181101    56
      MX181301    75
      MX181302    36
      MX190103    56
S1BF  MX180803    15
      MX180804    25
      MX181002    15
S1TJ  MX180804    54
      MX181002    46
      MX181101    38
      MX190103    17

Result: partially reproduced. Single units with a maximal PETH rate of at least 10 spikes/s number 351 in ALM and 223 in M1TJ, within 10% of 324 and 233, but 155 in S1TJ against 119. Counting multi-units too (reading b) all three are higher (387, 261, 251). At 15 spikes/s the single-unit counts are 321, 200 and 140.

ed4l-alm-initiation¶

More ALM neurons are tuned to sequence initiation than neurons of the other regions.

In [35]:
tab = pd.crosstab(act.area, act.peak_period).reindex(FIG3)
tab["frac_init"] = tab["init"] / tab[["init", "mid", "term"]].sum(1)
print(tab.round(3).to_string())
for a in ["M1TJ", "S1TJ", "S1BF"]:
    t = [[tab.loc["ALM", "init"], tab.loc["ALM", ["mid", "term"]].sum()], [tab.loc[a, "init"], tab.loc[a, ["mid", "term"]].sum()]]
    print(f"ALM against {a}: fraction {tab.loc['ALM', 'frac_init']:.3f} vs {tab.loc[a, 'frac_init']:.3f}, "
          f"Fisher one-sided p = {stats.fisher_exact(t, alternative='greater')[1]:.4f}")
print("S1BF is outside the criterion; it is shown because the claim says 'the other regions'.")
print("Window lengths (s):", {p: w[1] - w[0] for p, w in sl.PETH_WINDOWS.items()},
      "; fraction peaking in each window per second of window, by area:")
print((tab[["init", "mid", "term"]].div(tab[["init", "mid", "term"]].sum(1), axis=0)
       / pd.Series({p: w[1] - w[0] for p, w in sl.PETH_WINDOWS.items()})).round(3).to_string())
allu = units[units.max_rate >= 10]
print("\nAll active units (reading b):")
print(pd.crosstab(allu.area, allu.peak_period).reindex(FIG3).to_string())
peak_period  init  mid  term  frac_init
area                                   
S1TJ           20   94    41      0.129
M1TJ           48  115    60      0.215
ALM            91  194    66      0.259
S1BF           17   14    24      0.309
ALM against M1TJ: fraction 0.259 vs 0.215, Fisher one-sided p = 0.1355
ALM against S1TJ: fraction 0.259 vs 0.129, Fisher one-sided p = 0.0006
ALM against S1BF: fraction 0.259 vs 0.309, Fisher one-sided p = 0.8275
S1BF is outside the criterion; it is shown because the claim says 'the other regions'.
Window lengths (s): {'init': 0.5, 'mid': 2.0, 'term': 1.2} ; fraction peaking in each window per second of window, by area:
peak_period   init    mid   term
area                            
S1TJ         0.258  0.303  0.220
M1TJ         0.430  0.258  0.224
ALM          0.519  0.276  0.157
S1BF         0.618  0.127  0.364

All active units (reading b):
peak_period  init  mid  term
area                        
S1TJ           32  137    82
M1TJ           53  136    72
ALM            98  216    73
S1BF           19   15    25
/tmp/ipykernel_2421/2350594777.py:2: Pandas4Warning: Starting with pandas version 4.0 all arguments of sum will be keyword-only.
  tab["frac_init"] = tab["init"] / tab[["init", "mid", "term"]].sum(1)
/tmp/ipykernel_2421/2350594777.py:11: Pandas4Warning: Starting with pandas version 4.0 all arguments of sum will be keyword-only.
  print((tab[["init", "mid", "term"]].div(tab[["init", "mid", "term"]].sum(1), axis=0)

Result: partially reproduced. 25.9% of active ALM neurons peak in the initiation window, more than in S1TJ (12.9%, Fisher p = 0.0006) and M1TJ (21.5%, p = 0.14, not significant). S1BF, outside the criterion, has a higher fraction than ALM (30.9%), against the claim's "the other regions". The windows differ in length (0.5, 2 and 1.2 s); per second of window, ALM (0.519) is still above M1TJ (0.430) and S1TJ (0.258), and S1BF (0.618) above ALM.

ed4o-rhythmicity¶

S1TJ and M1TJ contain more neurons modulated by individual licks than ALM.

In [36]:
print(act.groupby("area").r_avg.describe().round(3).loc[FIG3].to_string())
for a in ["S1TJ", "M1TJ"]:
    p = stats.mannwhitneyu(act[act.area == a].r_avg.dropna(), act[act.area == "ALM"].r_avg.dropna(), alternative="greater").pvalue
    print(f"{a} > ALM: medians {act[act.area == a].r_avg.median():.3f} vs {act[act.area == 'ALM'].r_avg.median():.3f}, "
          f"one-sided Mann-Whitney p = {p:.2g}")
print("Units without r_avg (flat PETH):", int(act.r_avg.isna().sum()))
fig, ax = plt.subplots(figsize=(4, 2.5))
for a in ["S1TJ", "M1TJ", "ALM"]:
    x = np.sort(act[act.area == a].r_avg.dropna())
    ax.plot(x, np.linspace(0, 1, len(x)), label=a)
ax.set_xlabel("r_avg")
ax.set_ylabel("cumulative fraction")
ax.legend()
plt.show()
print(f"r_avg ranges from {act.r_avg.min():.3f} to {act.r_avg.max():.3f}; all within the axes.")
      count   mean    std    min    25%    50%    75%    max
area                                                        
S1TJ  155.0  0.223  0.113  0.028  0.142  0.201  0.285  0.561
M1TJ  223.0  0.166  0.080  0.028  0.106  0.156  0.208  0.487
ALM   351.0  0.102  0.052  0.014  0.063  0.093  0.133  0.300
S1BF   55.0  0.149  0.072  0.031  0.094  0.136  0.184  0.403
S1TJ > ALM: medians 0.201 vs 0.093, one-sided Mann-Whitney p = 2.5e-36
M1TJ > ALM: medians 0.156 vs 0.093, one-sided Mann-Whitney p = 4.1e-24
Units without r_avg (flat PETH): 0
No description has been provided for this image
r_avg ranges from 0.014 to 0.561; all within the axes.

Result: reproduced. The median correlation of the mid-sequence PETH with a 6.5 Hz sinusoid is 0.201 in S1TJ and 0.156 in M1TJ against 0.093 in ALM (one-sided Mann-Whitney p = 2.5e-36 and 4.1e-24).

ed6-alm-reward-drop¶

ALM contains neurons that fire during sequence execution and decrease firing abruptly when the tongue first contacts water, although licking continues.

In [37]:
rw = []
for o in pe:
    for k in range(o["reward_pre"].shape[1]):
        a, b = o["reward_pre"][:, k], o["reward_post"][:, k]
        p = stats.wilcoxon(a, b).pvalue if np.any(a != b) else 1.0
        rw.append({"area": o["area"], "path": o["path"], "unit": k, "pre": a.mean(), "post": b.mean(), "p": p,
                   "trials": len(a)})
rw = pd.DataFrame(rw).merge(units[["path", "unit", "single", "max_rate"]], on=["path", "unit"])
rw = rw[(rw.max_rate >= 10) & rw.single]
for a in FIG3:
    d = rw[rw.area == a]
    dec = int(((d.p < 0.01) & (d.post < d.pre)).sum())
    inc = int(((d.p < 0.01) & (d.post > d.pre)).sum())
    pb = stats.binomtest(dec, dec + inc, 0.5, alternative="greater").pvalue if dec + inc else np.nan
    print(f"{a}: active single units {len(d)}, decrease {dec}, increase {inc}, binomial p = {pb:.3g}; "
          f"median rate before {d.pre.median():.1f}, after {d.post.median():.1f} spikes/s")
for thr in (0.001, 0.05):
    d = rw[rw.area == "ALM"]
    print(f"ALM at p < {thr}: decrease {int(((d.p < thr) & (d.post < d.pre)).sum())}, increase {int(((d.p < thr) & (d.post > d.pre)).sum())}")
S1TJ: active single units 155, decrease 50, increase 51, binomial p = 0.579; median rate before 7.2, after 8.6 spikes/s
M1TJ: active single units 223, decrease 96, increase 74, binomial p = 0.0535; median rate before 9.2, after 6.9 spikes/s
ALM: active single units 351, decrease 188, increase 92, binomial p = 4.95e-09; median rate before 8.5, after 7.0 spikes/s
S1BF: active single units 55, decrease 16, increase 15, binomial p = 0.5; median rate before 5.7, after 5.9 spikes/s
ALM at p < 0.001: decrease 175, increase 79
ALM at p < 0.05: decrease 198, increase 102

Result: reproduced. Of 351 active ALM single units, 188 fire less in the 0.5 s after the first water touch than in the 0.5 s before and 92 more (p < 0.01 each; binomial p = 5e-09). S1TJ (50 against 51) and S1BF (16 against 15) show no such bias; M1TJ leans the same way (96 against 74, p = 0.054).

ed1h-sequence-duration¶

Expert mice completed the 7 port positions in about a second.

In [38]:
def last_position_touch(ev):
    """The first touch at the last position: the first touch after the last port-moving touch,
    leaving 60 ms (the lick detector merges contacts closer than this)."""
    t = ev["touch"][ev["touch"] > ev["pos"][-1] + 0.06] if len(ev["pos"]) else []
    return t[0] if len(t) else np.nan


dur = []
for t in tables:
    if not ((t["kind"] == "ephys" and t["area"] != "ZZ") or
            (t["kind"] == "opto VGAT-CRE Ai32" and t["area"] in ("2s 5V", "2s 2.5V"))):
        continue
    for i in range(t["trials"]["n"]):
        ev = sl.trial_events(t, i)
        if sl.trial_type(ev["kind"])[0] != "standard" or not np.isnan(t["trials"]["opto_times"][i]):
            continue
        end = last_position_touch(ev)
        dur.append({"subject": t["subject"], "kind": t["kind"], "first_to_last": end - ev["pos"][0],
                    "cue_to_last": end - ev["cue"]})
dur = pd.DataFrame(dur).dropna()
per_mouse = dur.groupby("subject")[["first_to_last", "cue_to_last"]].median()
print(f"Standard trials {len(dur)} from {dur.subject.nunique()} mice (paper, Extended Data Fig. 1h: 17 mice)")
print(f"(a) first port-moving touch to the first touch at the last position: median over mice "
      f"{per_mouse.first_to_last.median():.3f} s (mice from {per_mouse.first_to_last.min():.3f} to {per_mouse.first_to_last.max():.3f})")
print(f"(b) cue to that touch: median over mice {per_mouse.cue_to_last.median():.3f} s "
      f"(from {per_mouse.cue_to_last.min():.3f} to {per_mouse.cue_to_last.max():.3f})")
print("\nPer mouse:")
print(per_mouse.round(3).to_string())
print("\nRecording sessions only (no opto sessions):",
      dur[dur.kind == "ephys"].groupby("subject").first_to_last.median().median().round(3), "s for reading (a)")
Standard trials 25438 from 17 mice (paper, Extended Data Fig. 1h: 17 mice)
(a) first port-moving touch to the first touch at the last position: median over mice 0.992 s (mice from 0.890 to 1.138)
(b) cue to that touch: median over mice 1.324 s (from 1.150 to 1.673)

Per mouse:
          first_to_last  cue_to_last
subject                             
MX170903          1.082        1.318
MX180201          1.047        1.457
MX180202          1.091        1.673
MX180203          0.916        1.324
MX180601          0.954        1.246
MX180602          1.040        1.382
MX180701          0.985        1.439
MX180803          0.890        1.150
MX180804          1.015        1.289
MX181002          0.952        1.233
MX181003          1.039        1.323
MX181101          0.992        1.327
MX181301          1.138        1.607
MX181302          0.942        1.284
MX190103          0.992        1.428
MX200101          0.898        1.205
MX200102          1.013        1.369

Recording sessions only (no opto sessions): 1.004 s for reading (a)

Result: reproduced. From the first port-moving touch to the first touch at the last position, the median over 17 mice (the paper's number of mice) is 0.992 s (mice 0.890 to 1.138 s). From the cue it is 1.324 s.

ed1l-learning-curves¶

Over training, 15 mice reduced their sequence initiation time and increased their sequence speed.

In [39]:
learn = sorted([t for t in tables if t["kind"] == "learning" and t["path"] not in dup_paths],
               key=lambda t: (t["subject"], t["session_start"]))
print(f"Learning files {len(learn)} (left out as deposited twice: {sorted(dup_paths)})")
lt = []
for t in learn:
    for i in range(t["trials"]["n"]):
        ev = sl.trial_events(t, i)
        ty = sl.trial_type(ev["kind"])[0]
        row = {"subject": t["subject"], "session": t["session_start"], "type": ty}
        if ty == "standard":
            end = last_position_touch(ev)
            touches = np.concatenate([ev["pos"], [end]])
            row["init"] = sl.first_touch(ev) - ev["cue"]
            row["speed"] = np.mean(1 / np.diff(touches)) if np.all(np.diff(touches) > 0) else np.nan
        lt.append(row)
lt = pd.DataFrame(lt)
lt["trial_number"] = lt.groupby("subject").cumcount() + 1
print(f"Mice with learning files: {lt.subject.nunique()}; trials {len(lt)}, of which standard {int((lt.type == 'standard').sum())}")
rows = []
for m, d in lt[lt.type == "standard"].groupby("subject"):
    a, b = d.iloc[:100], d.iloc[-100:]
    rows.append({"subject": m, "sessions": d.session.nunique(), "standard_trials": len(d),
                 "init_first": a.init.median(), "init_last": b.init.median(),
                 "speed_first": a.speed.median(), "speed_last": b.speed.median()})
lc = pd.DataFrame(rows).set_index("subject")
lc["init_down"] = lc.init_last < lc.init_first
lc["speed_up"] = lc.speed_last > lc.speed_first
print(lc.round(3).to_string())
for col, (x, y) in {"initiation time": ("init_first", "init_last"), "sequence speed": ("speed_first", "speed_last")}.items():
    print(f"{col}: first 100 median over mice {lc[x].median():.3f}, last 100 {lc[y].median():.3f}; "
          f"Wilcoxon p = {stats.wilcoxon(lc[x], lc[y]).pvalue:.2g}")
print(f"Mice with lower initiation time: {int(lc.init_down.sum())}; higher speed: {int(lc.speed_up.sum())}; "
      f"both: {int((lc.init_down & lc.speed_up).sum())} of {len(lc)}")
l14 = lc.drop(index="MX181003")
print(f"Variant without MX181003 (its other file is deposited twice): {int((l14.init_down & l14.speed_up).sum())} of {len(l14)} improve "
      f"in both; Wilcoxon p = {stats.wilcoxon(l14.init_first, l14.init_last).pvalue:.2g} and "
      f"{stats.wilcoxon(l14.speed_first, l14.speed_last).pvalue:.2g}")
Learning files 572 (left out as deposited twice: ['sub-MX181002/sub-MX181002_ses-20181002T153057.nwb', 'sub-MX181003/sub-MX181003_ses-20181002T153057.nwb'])
Mice with learning files: 15; trials 140474, of which standard 114357
          sessions  standard_trials  init_first  init_last  speed_first  speed_last  init_down  speed_up
subject                                                                                                 
MX170903        14             3665       0.468      0.230        5.189       6.005       True      True
MX180201        18             4416       0.367      0.252        6.033       6.584       True      True
MX180601        32             5929       4.335      0.427        4.380       6.733       True      True
MX180602        23             4526       2.863      0.285        4.532       5.884       True      True
MX180701        29             5057       2.573      0.787        5.264       5.749       True      True
MX180803        49            14444       1.988      0.227        5.138       6.798       True      True
MX180804        59            12986       6.207      0.320        4.600       5.780       True      True
MX181002        50            12847       4.639      0.261        5.022       6.429       True      True
MX181003        34             7716       0.323      1.594        5.288       3.337      False     False
MX181101        43             8915       2.855      0.270        4.537       6.182       True      True
MX181301        40             6892       3.313      0.492        3.503       5.188       True      True
MX181302        23             4302       4.796      0.193        4.190       6.620       True      True
MX190103        32             5363       2.567      0.386        3.659       6.038       True      True
MX200101        25             6384       1.789      0.291        4.902       6.847       True      True
MX200102        44            10915       1.323      0.523        5.346       5.550       True      True
initiation time: first 100 median over mice 2.573, last 100 0.291; Wilcoxon p = 0.00043
sequence speed: first 100 median over mice 4.902, last 100 6.038; Wilcoxon p = 0.0043
Mice with lower initiation time: 14; higher speed: 14; both: 14 of 15
Variant without MX181003 (its other file is deposited twice): 14 of 14 improve in both; Wilcoxon p = 0.00012 and 0.00012

Result: reproduced. 15 mice have learning files. From the first to the last 100 standard trials the median initiation time falls from 2.573 to 0.291 s and the sequence speed rises from 4.902 to 6.038 positions/s over mice (Wilcoxon p = 0.00043 and 0.0043); 14 of 15 mice improve in both. MX181003 does not; its file of 2018-10-02 is the one left out as deposited twice, so its learning files may belong in part to another mouse. Without it, 14 of 14 improve in both (p = 0.00012 each).

ed1l-proficiency-1500¶

Mice reached proficiency in standard sequences after about 1500 trials of training.

In [40]:
prof = []
for m, d in lt.groupby("subject"):
    n = len(d)
    final = d.iloc[-500:]
    fin_speed = final[final.type == "standard"].speed.median()
    fin_init = final[final.type == "standard"].init.median()
    a = b = np.nan
    for start in range(0, n - 99, 50):
        w = d.iloc[start:start + 100]
        w = w[w.type == "standard"]
        if len(w) < 10:
            continue
        if np.isnan(a) and w.speed.median() >= 0.9 * fin_speed:
            a = start + 100
        if np.isnan(b) and w.init.median() <= 1.1 * fin_init:
            b = start + 100
    prof.append({"subject": m, "trials_in_files": n, "a_speed": a, "b_initiation": b})
prof = pd.DataFrame(prof).set_index("subject")
print(prof.to_string())
print(f"\nMedian over mice: (a) speed reaches 90% of final at trial {prof.a_speed.median():.0f}; "
      f"(b) initiation within 110% of final at trial {prof.b_initiation.median():.0f}  (paper: about 1500)")
for f in (0.8, 0.95):
    v = []
    for m, d in lt.groupby("subject"):
        fin = d.iloc[-500:]
        fs = fin[fin.type == "standard"].speed.median()
        hit = [s + 100 for s in range(0, len(d) - 99, 50)
               if d.iloc[s:s + 100][lambda x: x.type == "standard"].speed.median() >= f * fs]
        v.append(hit[0] if hit else np.nan)
    print(f"Variant, speed reaches {int(f * 100)}% of final: median over mice {np.nanmedian(v):.0f}")
first_dates = lt.groupby("subject").session.min()
print("\nFirst learning file of each mouse:", first_dates.str[:10].to_dict())
          trials_in_files  a_speed  b_initiation
subject                                         
MX170903             3669    150.0        1200.0
MX180201             4416    100.0         150.0
MX180601             6318   1300.0        1350.0
MX180602             4527    200.0        1150.0
MX180701             5765    150.0        1100.0
MX180803            16133    300.0        1100.0
MX180804            14931    200.0        1050.0
MX181002            14187    500.0         300.0
MX181003             7716    100.0         100.0
MX181101            11112    200.0        3900.0
MX181301             8309    200.0         950.0
MX181302             4915    850.0        3250.0
MX190103             6992    650.0         950.0
MX200101            16927      NaN           NaN
MX200102            14557    100.0        1100.0

Median over mice: (a) speed reaches 90% of final at trial 200; (b) initiation within 110% of final at trial 1100  (paper: about 1500)
Variant, speed reaches 80% of final: median over mice 150
Variant, speed reaches 95% of final: median over mice 250

First learning file of each mouse: {'MX170903': '2018-02-16', 'MX180201': '2018-03-23', 'MX180601': '2018-06-17', 'MX180602': '2018-06-17', 'MX180701': '2018-08-24', 'MX180803': '2018-07-16', 'MX180804': '2018-07-15', 'MX181002': '2018-09-15', 'MX181003': '2018-09-16', 'MX181101': '2019-01-06', 'MX181301': '2019-01-08', 'MX181302': '2019-01-08', 'MX190103': '2019-03-30', 'MX200101': '2020-09-11', 'MX200102': '2020-09-18'}
In [41]:
# Do the learning files start at the start of training? Per mouse: the dates of the first and last
# learning file and of the first recording or opto session, the trials and trial types of the
# first file, and the initiation time and speed in its first 100 standard trials.
other_first = (pd.DataFrame([{"subject": t["subject"], "date": t["session_start"]} for t in tables if t["kind"] != "learning"])
               .groupby("subject").date.min())
ff = []
for m, d in lt.groupby("subject"):
    first = d[d.session == d.session.min()]
    std = d[d.type == "standard"]
    ff.append({"subject": m, "learning_files": d.session.nunique(), "first_file": d.session.min()[:10],
               "last_file": d.session.max()[:10], "first_other_session": str(other_first.get(m, ""))[:10],
               "first_file_trials": len(first), "first_file_types": dict(Counter(first.type)),
               "init_first100": std.init.iloc[:100].median(), "speed_first100": std.speed.iloc[:100].median(),
               "standard_in_last500": int((d.iloc[-500:].type == "standard").sum())})
ff = pd.DataFrame(ff).set_index("subject")
print(ff.round(3).to_string())
print(f"\nMice whose first file has no standard trials: {int(ff.first_file_types.map(lambda x: 'standard' not in x).sum())}; "
      f"mice whose first 100 standard trials already have a median initiation time under 1 s: {int((ff.init_first100 < 1).sum())} of {len(ff)}")
print("Mice with fewer than 10 standard trials among their last 500 trials (no final value):",
      list(ff.index[ff.standard_in_last500 < 10]))
print("\nposIndex patterns of the first learning file of each mouse (top 3):")
for m, d in lt.groupby("subject"):
    t = [t for t in learn if t["subject"] == m and t["session_start"] == d.session.min()][0]
    print(f"  {m}: {Counter(t['trials']['posIndex']).most_common(3)}")
          learning_files  first_file   last_file first_other_session  first_file_trials               first_file_types  init_first100  speed_first100  standard_in_last500
subject                                                                                                                                                                   
MX170903              14  2018-02-16  2018-03-05          2018-03-04                132  {'standard': 130, 'other': 2}          0.468           5.189                  500
MX180201              18  2018-03-23  2018-04-09          2018-04-03                 95               {'standard': 95}          0.367           6.033                  500
MX180601              32  2018-06-17  2018-07-29          2018-07-20                100              {'standard': 100}          4.335           4.380                  340
MX180602              23  2018-06-17  2018-07-14          2018-07-07                104              {'standard': 104}          2.863           4.532                  500
MX180701              29  2018-08-24  2018-09-24          2018-09-16                141              {'standard': 141}          2.573           5.264                  340
MX180803              49  2018-07-16  2018-11-28          2018-08-06                128  {'other': 1, 'standard': 127}          1.988           5.138                  341
MX180804              59  2018-07-15  2018-12-06          2018-08-06                 82               {'standard': 82}          6.207           4.600                  334
MX181002              50  2018-09-15  2019-01-01          2018-10-10                 87   {'other': 1, 'standard': 86}          4.639           5.022                  388
MX181003              34  2018-09-16  2019-04-03          2018-10-10                129              {'standard': 129}          0.323           5.288                  500
MX181101              43  2019-01-06  2019-04-02          2019-03-28                135              {'standard': 135}          2.855           4.537                  358
MX181301              40  2019-01-08  2019-05-31          2019-05-21                142              {'standard': 142}          3.313           3.503                  347
MX181302              23  2019-01-08  2019-02-12          2019-02-01                130              {'standard': 130}          4.796           4.190                  360
MX190103              32  2019-03-30  2019-05-15          2019-04-23                140              {'standard': 140}          2.567           3.659                  349
MX200101              64  2020-09-11  2020-12-03          2020-09-27                108              {'standard': 108}          1.789           4.902                    0
MX200102              62  2020-09-18  2021-01-20          2020-10-05                158              {'standard': 158}          1.323           5.346                  500

Mice whose first file has no standard trials: 0; mice whose first 100 standard trials already have a median initiation time under 1 s: 3 of 15
Mice with fewer than 10 standard trials among their last 500 trials (no final value): ['MX200101']

posIndex patterns of the first learning file of each mouse (top 3):
  MX170903: [(np.str_('[1 2 3 4 5 6]'), 66), (np.str_('[5 4 3 2 1 0]'), 64), (np.str_('[1 2 3 4 5 6 0 1 2 3 4 5 6 0]'), 1)]
  MX180201: [(np.str_('[1 2 3 4 5 6]'), 48), (np.str_('[5 4 3 2 1 0]'), 47)]
  MX180601: [(np.str_('[1 2 3 4 5 6]'), 50), (np.str_('[5 4 3 2 1 0]'), 50)]
  MX180602: [(np.str_('[1 2 3 4 5 6]'), 52), (np.str_('[5 4 3 2 1 0]'), 52)]
  MX180701: [(np.str_('[1 2 3 4 5 6]'), 71), (np.str_('[5 4 3 2 1 0]'), 70)]
  MX180803: [(np.str_('[1 2 3 4 5 6]'), 64), (np.str_('[5 4 3 2 1 0]'), 63), (np.str_('nan'), 1)]
  MX180804: [(np.str_('[1 2 3 4 5 6]'), 41), (np.str_('[5 4 3 2 1 0]'), 41)]
  MX181002: [(np.str_('[1 2 3 4 5 6]'), 43), (np.str_('[5 4 3 2 1 0]'), 43), (np.str_('nan'), 1)]
  MX181003: [(np.str_('[1 2 3 4 5 6]'), 65), (np.str_('[5 4 3 2 1 0]'), 64)]
  MX181101: [(np.str_('[1 2 3 4 5 6]'), 68), (np.str_('[5 4 3 2 1 0]'), 67)]
  MX181301: [(np.str_('[1 2 3 4 5 6]'), 71), (np.str_('[5 4 3 2 1 0]'), 71)]
  MX181302: [(np.str_('[1 2 3 4 5 6]'), 65), (np.str_('[5 4 3 2 1 0]'), 65)]
  MX190103: [(np.str_('[1 2 3 4 5 6]'), 70), (np.str_('[5 4 3 2 1 0]'), 70)]
  MX200101: [(np.str_('[1 2 3 4 5 6]'), 54), (np.str_('[5 4 3 2 1 0]'), 54)]
  MX200102: [(np.str_('[1 2 3 4 5 6]'), 79), (np.str_('[5 4 3 2 1 0]'), 79)]

Result: not reproduced. The paper does not define proficiency. Under the two readings fixed before the analysis, the median over mice is 200 trials (speed at 90% of final) and 1100 trials (initiation time within 110% of final), both outside 1350 to 1650. The learning files may not cover the first days of training: every mouse's first file holds standard sequences in both directions and almost nothing else (2 "other" trials in MX170903, 1 in MX180803 and 1 in MX181002), and in 3 of 15 mice (MX170903, MX180201, MX181003) the median initiation time of the first 100 standard trials is already under 1 s. If earlier training is missing, both counts are too low. In the median mouse the speed reaches 90% of its final value within 200 trials, so the result under reading (a) also reflects how proficiency was defined. MX200101 has no standard trials among its last 500 learning trials, so it has no final value under either reading.

ed2-backtracking-counts¶

Backtracking analyses used 7458 standard and 2695 backtracking sequences from 47 sessions of the 9 mice trained on backtracking.

In [42]:
bk = []
for t in tables:
    if t["kind"] == "learning":
        continue
    types = Counter(sl.trial_type(k)[0] for k in t["trials"]["posIndex"])
    if types["backtrack"]:
        bk.append({"path": t["path"], "subject": t["subject"], "kind": t["kind"], "area": t["area"], "standard": types["standard"],
                   "backtrack": types["backtrack"]})
bk = pd.DataFrame(bk)
readings = {"(a) all non-learning files with backtracking": bk,
            "(b) without air and earplug sessions": bk[~bk.area.isin(["air", "earplug"])],
            "(c) as (b), without opto efficiency sessions": bk[~bk.area.isin(["air", "earplug", "efficiency"])]}
for name, d in readings.items():
    print(f"{name}: sessions {len(d)}, mice {d.subject.nunique()}, standard {d.standard.sum()}, backtracking {d.backtrack.sum()}")
print("Paper: 47 sessions, 9 mice, 7458 standard, 2695 backtracking")
print("\nReading (c) by kind of file:")
print(readings["(c) as (b), without opto efficiency sessions"].groupby(["kind", "area"])[["standard", "backtrack"]]
      .agg(["size", "sum"]).to_string())
print("Mice:", sorted(readings["(c) as (b), without opto efficiency sessions"].subject.unique()))
# Variant, not part of the criterion: the Methods leave out 8 sessions "where only touch onsets were
# correctly registered". Sessions whose touches are mostly very short (5th percentile of the touch
# duration under 10 ms) are candidates for these.
short = []
for t in tables:
    if t["kind"] == "learning" or t["area"] in ("air", "earplug", "efficiency"):
        continue
    if not any(sl.trial_type(k)[0] == "backtrack" for k in t["trials"]["posIndex"]):
        continue
    d = np.concatenate(t["trials"]["lickOff_times"]) - np.concatenate(t["trials"]["lickOn_times"])
    short.append({"path": t["path"], "subject": t["subject"], "p5_ms": 1000 * np.percentile(d, 5),
                  "median_ms": 1000 * np.median(d)})
short = pd.DataFrame(short)
print("\nTouch duration (ms) per session of reading (c), 5th percentile, lowest 10:")
print(short.sort_values("p5_ms").head(10).round(1).to_string(index=False))
keep = set(short[short.p5_ms >= 10].path)
d = bk[~bk.area.isin(["air", "earplug", "efficiency"]) & bk.path.isin(keep)]
print(f"Variant, (c) without sessions whose 5th percentile touch is under 10 ms: sessions {len(d)}, mice {d.subject.nunique()}, "
      f"standard {d.standard.sum()}, backtracking {d.backtrack.sum()}")
(a) all non-learning files with backtracking: sessions 74, mice 15, standard 13248, backtracking 4849
(b) without air and earplug sessions: sessions 59, mice 10, standard 10890, backtracking 4030
(c) as (b), without opto efficiency sessions: sessions 55, mice 9, standard 10365, backtracking 3839
Paper: 47 sessions, 9 mice, 7458 standard, 2695 backtracking

Reading (c) by kind of file:
                           standard       backtrack      
                               size   sum      size   sum
kind               area                                  
ephys              ALM            7  1437         7   513
                   M1TJ           9  1470         9   467
                   S1BF           5  1080         5   512
                   S1L            6   969         6   331
                   S1TJ           8  1685         8   627
                   others        14  2639        14  1034
opto VGAT-CRE Ai32 2s 2.5V        1   292         1    95
                   2s 5V          5   793         5   260
Mice: ['MX180601', 'MX180701', 'MX180803', 'MX180804', 'MX181002', 'MX181101', 'MX181301', 'MX181302', 'MX190103']

Touch duration (ms) per session of reading (c), 5th percentile, lowest 10:
                                                      path  subject  p5_ms  median_ms
sub-MX181302/sub-MX181302_ses-20190210T163258_behavior.nwb MX181302    3.0        5.0
sub-MX180803/sub-MX180803_ses-20181128T130139_behavior.nwb MX180803    3.0        4.0
sub-MX180804/sub-MX180804_ses-20181206T135700_behavior.nwb MX180804    4.0       40.0
sub-MX180804/sub-MX180804_ses-20181205T150915_behavior.nwb MX180804    4.0       39.0
sub-MX180803/sub-MX180803_ses-20181122T135902_behavior.nwb MX180803    4.0       10.0
sub-MX180803/sub-MX180803_ses-20181123T122105_behavior.nwb MX180803    5.0       36.0
sub-MX181302/sub-MX181302_ses-20190201T153422_behavior.nwb MX181302    7.0       46.0
sub-MX181302/sub-MX181302_ses-20190212T154624_behavior.nwb MX181302    7.0       41.0
sub-MX181002/sub-MX181002_ses-20181224T154648_behavior.nwb MX181002   14.0       34.0
sub-MX181002/sub-MX181002_ses-20181219T132039_behavior.nwb MX181002   14.0       33.0
Variant, (c) without sessions whose 5th percentile touch is under 10 ms: sessions 47, mice 8, standard 8891, backtracking 3207
In [43]:
# Variant, not part of the criterion: the paper's trial exclusions on the 47 sessions above. The
# Methods remove the first and last trial of every session and, from analyses of tongue kinematics
# (Extended Data Fig. 2c), trials not finished before the video stopped. Initiation under 1 s is
# the Methods' trial selection for Figure 3 and is shown as a further step.
v47 = [a for a in assets if a["path"] in set(d.path)]
spans = {r["path"]: r["spans"] for r in sl.run_parallel(sl.video_spans, v47, workers=sl.cpu_workers())}
steps = Counter()
for t in tables:
    if t["path"] not in spans:
        continue
    n = t["trials"]["n"]
    for i in range(n):
        ev = sl.trial_events(t, i)
        ty = sl.trial_type(ev["kind"])[0]
        if ty not in ("standard", "backtrack"):
            continue
        steps[("all", ty)] += 1
        if i in (0, n - 1):
            continue
        steps[("without first and last trial", ty)] += 1
        sp = spans[t["path"]].get(i)
        if sp is None or not len(ev["pos"]) or ev["pos"][-1] > sp[1]:
            continue
        steps[("and sequence finished within the video", ty)] += 1
        if not sl.first_touch(ev) - ev["cue"] < 1.0:
            continue
        steps[("and initiation under 1 s", ty)] += 1
st = pd.Series(steps).unstack()
print(f"47-session variant ({len(spans)} files, {sum(len(v) > 0 for v in spans.values())} with video):")
print(st.loc[["all", "without first and last trial", "and sequence finished within the video", "and initiation under 1 s"]].to_string())
print("Paper: 7458 standard, 2695 backtracking (tolerance 6712 to 8204 and 2426 to 2965)")
47-session variant (47 files, 47 with video):
                                        backtrack  standard
all                                          3207      8891
without first and last trial                 3196      8809
and sequence finished within the video       3104      8627
and initiation under 1 s                     2894      8058
Paper: 7458 standard, 2695 backtracking (tolerance 6712 to 8204 and 2426 to 2965)

Result: not reproduced. Under reading (c) the files hold 55 sessions of 9 mice with 10365 standard and 3839 backtracking trials; only the number of mice matches. A variant not in the criterion leaves out the 8 sessions whose touches are mostly a few ms long, which fits the Methods' 8 sessions "where only touch onsets were correctly registered": it gives 47 sessions (the paper's number) of 8 mice with 8891 and 3207 trials, still above 7458 and 2695. On these 47 sessions, the Methods' removal of the first and last trial and of trials not finished within the video leaves 8627 and 3104; adding the initiation-under-1 s rule of the Figure 3 trial selection leaves 8058 and 2894, within tolerance of 7458 and 2695. The counts after the Methods' exclusions (8627 and 3104) are both above tolerance; only the last step, which is the Figure 3 trial selection and not an exclusion the Methods give for Extended Data Fig. 2, brings them inside. The variant has 8 mice where the paper has 9. It was built after seeing the results and does not change the outcome.

ed2e-missed-licks¶

After the port backtracked, mice took on average 1 to 2 missed licks before relocating it.

In [44]:
ml = []
for s in fig3_sessions:
    for i in range(min(s["trials"]["n"], len(s["video"]))):
        ev = sl.trial_events(s, i)
        ty = sl.trial_type(ev["kind"])[0]
        if ty not in ("standard", "backtrack") or len(ev["pos"]) < 5:
            continue
        v = s["video"][i]
        if ev["pos"][4] > v["start"] + len(v["out"]) / v["rate"]:
            continue
        lk = s["licks"][i]
        inside = lk[(lk[:, 0] > ev["pos"][3]) & (lk[:, 0] < ev["pos"][4])]
        miss = {tol: sum(1 for on, off, *_ in inside if not np.any((ev["touch"] >= on - tol) & (ev["touch"] <= off + tol)))
                for tol in (0.0, 0.0125, 0.025)}
        ml.append({"subject": s["subject"], "path": s["path"], "type": ty, "missed": miss[0.0125],
                   "missed_tol0": miss[0.0], "missed_tol25": miss[0.025], "time": ev["pos"][4] - ev["pos"][3]})
ml = pd.DataFrame(ml)
ml = ml[ml.path.isin(ml[ml.type == "backtrack"].path.unique())]
print(f"Sessions with backtracking trials: {ml.path.nunique()}, mice {ml.subject.nunique()}")
print(ml.groupby("type").agg(trials=("missed", "size"), mean_missed=("missed", "mean"), median_missed=("missed", "median"),
                             mean_time_s=("time", "mean")).round(3).to_string())
p = stats.mannwhitneyu(ml[ml.type == "backtrack"].missed, ml[ml.type == "standard"].missed, alternative="greater").pvalue
print(f"Backtracking > standard: Mann-Whitney p = {p:.2g}")
print("\nBy mouse (mean missed licks):")
print(ml.groupby(["subject", "type"]).missed.mean().unstack().round(2).to_string())
mb = ml[ml.type == "backtrack"]
print("\nVariant, touch tolerance, same trials: mean missed licks in backtracking trials "
      f"{mb.missed_tol0.mean():.3f} (0 ms), {mb.missed.mean():.3f} (12.5 ms), {mb.missed_tol25.mean():.3f} (25 ms)")
Sessions with backtracking trials: 29, mice 8
           trials  mean_missed  median_missed  mean_time_s
type                                                      
backtrack    2090        1.854            1.0        0.525
standard     5590        0.113            0.0        0.189
Backtracking > standard: Mann-Whitney p = 0

By mouse (mean missed licks):
type      backtrack  standard
subject                      
MX180701       2.00      0.03
MX180803       2.11      0.12
MX180804       0.97      0.03
MX181002       1.63      0.19
MX181101       2.08      0.08
MX181301       2.26      0.19
MX181302       5.39      0.05
MX190103       2.14      0.11

Variant, touch tolerance, same trials: mean missed licks in backtracking trials 1.854 (0 ms), 1.854 (12.5 ms), 1.854 (25 ms)

Result: reproduced. In 29 sessions of 8 mice, backtracking trials have a mean of 1.854 missed licks in the fourth interval (within 0.9 to 2.2), against 0.113 in standard trials (Mann-Whitney p printed as 0, below floating-point resolution). By mouse the mean ranges from 0.97 to 5.39. A touch tolerance of 0 or 25 ms in place of 12.5 ms gives the same mean, 1.854, on the same trials.

ed6b-consummatory-licks¶

After the first lick that touched water, mice continued with about 5 consummatory licks.

In [45]:
cl = []
for s in fig3_sessions:
    vid_ili = np.concatenate([np.diff(l[:, 2]) for l in s["licks"] if len(l) > 1])
    vmax = 1.5 * np.median(vid_ili[vid_ili < 1])
    tmax = sl.session_ili_max(s)
    for i in range(min(s["trials"]["n"], len(s["video"]))):
        ev = sl.trial_events(s, i)
        if sl.trial_type(ev["kind"])[0] != "standard" or np.isnan(ev["water"]):
            continue
        tw = ev["touch"][ev["touch"] >= ev["water"]]
        if not len(tw):
            continue
        w = tw[0]
        k = 0                                          # (b) touches after the first water touch
        while k + 1 < len(tw) and tw[k + 1] - tw[k] < tmax:
            k += 1
        k2 = 0                                         # (b) variant: video lick threshold for touches
        while k2 + 1 < len(tw) and tw[k2 + 1] - tw[k2] < vmax:
            k2 += 1
        row = {"subject": s["subject"], "touches_after": k, "touches_after_video_thr": k2,
               "thr_touch_ms": 1000 * tmax, "thr_video_ms": 1000 * vmax}
        lk = s["licks"][i]
        v = s["video"][i]
        end = v["start"] + len(v["out"]) / v["rate"]
        j0 = np.where((lk[:, 0] - 0.0125 <= w) & (lk[:, 1] + 0.0125 >= w))[0]
        if len(j0):
            j = j0[0]
            while j + 1 < len(lk) and lk[j + 1, 2] - lk[j, 2] < vmax:
                j += 1
            censored = end - lk[j, 2] < vmax
            row.update(video_after=np.nan if censored else j - j0[0], censored=censored)
        cl.append(row)
cl = pd.DataFrame(cl)
print(f"Standard trials with a water touch: {len(cl)}; with the water lick in the video: {int(cl.censored.notna().sum())}; "
      f"video ended during the bout: {int(cl.censored.fillna(False).astype(bool).sum())}")
va = cl.video_after.dropna()
print(f"(a) video licks after the first water lick: median {va.median():.1f}, mean {va.mean():.2f} (trials {len(va)})")
print(f"(b) touches after the first water touch: median {cl.touches_after.median():.1f}, mean {cl.touches_after.mean():.2f}")
print(f"(c) video licks counting the first water lick: median {va.median() + 1:.1f}")
print("\nBy mouse, median of (a) and (b):")
print(cl.groupby("subject")[["video_after", "touches_after"]].median().to_string())
print("\nDistribution over trials (fraction of trials at each count):")
dist = pd.DataFrame({"(a) video licks": va.value_counts(normalize=True), "(b) touches": cl.touches_after.value_counts(normalize=True),
                     "(b) with the video lick threshold": cl.touches_after_video_thr.value_counts(normalize=True)}).fillna(0).sort_index()
print(dist.loc[0:12].round(3).to_string())
print(f"Bout thresholds over sessions (ms): from touch intervals median {cl.thr_touch_ms.median():.1f}, "
      f"from video lick intervals median {cl.thr_video_ms.median():.1f}")
print(f"(b) with the video lick threshold: median {cl.touches_after_video_thr.median():.1f}, mean {cl.touches_after_video_thr.mean():.2f}")
print(f"Mean of (a) {va.mean():.2f}; trials with (a) of 4, 5 or 6: {va.isin([4, 5, 6]).mean():.3f}")
Standard trials with a water touch: 7106; with the water lick in the video: 6952; video ended during the bout: 88
(a) video licks after the first water lick: median 6.0, mean 6.38 (trials 6864)
(b) touches after the first water touch: median 4.0, mean 3.87
(c) video licks counting the first water lick: median 7.0

By mouse, median of (a) and (b):
          video_after  touches_after
subject                             
MX170903          6.0            3.0
MX180202          6.0            2.0
MX180203          7.0            5.0
MX180701          5.0            2.0
MX180803          7.0            5.0
MX180804          5.0            3.0
MX181002          8.0            6.0
MX181101          5.0            2.0
MX181301          4.0            3.0
MX181302         10.0            7.0
MX190103          6.0            4.0

Distribution over trials (fraction of trials at each count):
      (a) video licks  (b) touches  (b) with the video lick threshold
0.0             0.000        0.148                              0.174
1.0             0.000        0.100                              0.101
2.0             0.005        0.066                              0.062
3.0             0.040        0.132                              0.126
4.0             0.128        0.137                              0.131
5.0             0.202        0.144                              0.138
6.0             0.196        0.105                              0.103
7.0             0.167        0.078                              0.076
8.0             0.113        0.045                              0.045
9.0             0.068        0.025                              0.024
10.0            0.041        0.013                              0.013
11.0            0.021        0.004                              0.004
12.0            0.013        0.001                              0.001
Bout thresholds over sessions (ms): from touch intervals median 243.0, from video lick intervals median 238.0
(b) with the video lick threshold: median 4.0, mean 3.76
Mean of (a) 6.38; trials with (a) of 4, 5 or 6: 0.526

Result: not reproduced. After the first lick touching water the bout continues for a median of 6 video licks (mean 6.38), or 4 touches (mean 3.87); counting the first water lick gives 7. None is within 4.5 to 5.5. The two counts bracket 5, and which lick events the paper counted is not stated. Under reading (a) the most common count is 5 (20.2% of trials, against 19.6% at 6 and 12.8% at 4). Reading (b) ends bouts at 1.5 times the median touch interval (median 243 ms); with the threshold from video lick intervals (238 ms) the median is still 4 touches (mean 3.76).

ed7b-correct-side¶

Expert mice usually initiated sequences from the correct side without exploring the other side.

In [46]:
cs_rows = []
for s in fig3_sessions:
    for i in range(min(s["trials"]["n"], len(s["video"]))):
        ev = sl.trial_events(s, i)
        if sl.trial_type(ev["kind"])[0] != "standard":
            continue
        ft = sl.first_touch(ev)
        lk = s["licks"][i]
        j = np.where((lk[:, 0] - 0.0125 <= ft) & (lk[:, 1] + 0.0125 >= ft))[0]
        if not len(j) or np.isnan(lk[j[0], 4]):
            continue
        side = np.sign(lk[j[0], 4])
        before = lk[(lk[:, 0] >= ev["cue"]) & (lk[:, 0] < lk[j[0], 0]), 4]
        cs_rows.append({"subject": s["subject"], "licks_before": len(before),
                        "explored": bool(np.any(np.sign(before) == -side)),
                        "explored_5deg": bool(np.any(before * side < -5))})
csd = pd.DataFrame(cs_rows)
fr = csd.groupby("subject").agg(trials=("explored", "size"), without_exploring=("explored", lambda x: 1 - x.mean()),
                                without_5deg=("explored_5deg", lambda x: 1 - x.mean()),
                                licks_before_mean=("licks_before", "mean"))
print(fr.round(3).to_string())
print(f"\nMice with fraction above 0.5: {int((fr.without_exploring > 0.5).sum())} of {len(fr)}; "
      f"with the 5 degree margin: {int((fr.without_5deg > 0.5).sum())} of {len(fr)}; "
      f"pooled fraction {1 - csd.explored.mean():.3f}")
          trials  without_exploring  without_5deg  licks_before_mean
subject                                                             
MX170903     261              0.923         0.973              0.966
MX180202     382              0.626         0.788              1.717
MX180203     750              0.900         0.980              1.665
MX180701     166              0.741         0.849              0.723
MX180803     245              0.763         0.845              1.016
MX180804     949              0.920         0.952              1.065
MX181002    1668              0.562         0.733              1.610
MX181101    1163              0.735         0.835              1.727
MX181301     394              0.731         0.802              1.165
MX181302     320              0.569         0.647              1.262
MX190103     709              0.568         0.705              1.444

Mice with fraction above 0.5: 11 of 11; with the 5 degree margin: 11 of 11; pooled fraction 0.714

Result: reproduced. In all 11 mice of the Figure 3 sessions most standard trials start without a lick on the other side of the midline before the first touch (0.562 to 0.923 of trials; 0.647 to 0.980 with a 5 degree margin).

Blocked control claims: what can be checked without the labels¶

ed1i-earplug, ed1j-odor-masking and ed1k-tongue-numbing stay blocked: the files do not say which session (or which half of a session) had the manipulation. Missed licks during the sequence (video licks from the first to the last port-moving touch with no touch within 12.5 ms) are shown per session. For the claims of no effect, the paper's test (paired one-tailed bootstrap over mice) is run under every possible assignment of the labels: if no assignment gives p < 0.05, the absence holds whatever the labels are. This is a variant; it does not decide an outcome.

In [47]:
ctrl_assets = [a for a in assets if sl.group_label(a["description"]) in
               (("behav", "earplug"), ("behav", "numbing"), ("behav", "air"))]
ctrl = sl.load_many(ctrl_assets, workers=sl.cpu_workers(), spikes=False)
for s in ctrl:
    s["kind"], s["area"] = sl.group_label(s["description"])
cm = sl.run_parallel(sl.sequence_missed_licks, ctrl, workers=sl.cpu_workers())
rows = []
for o, s in zip(cm, ctrl):
    r = pd.DataFrame(o["rows"])
    rows.append({"condition": o["area"], "subject": o["subject"], "session": o["session_start"][:16],
                 "standard_trials": sum(sl.trial_type(k)[0] == "standard" for k in s["trials"]["posIndex"]),
                 "with_video": len(r), "missed_mean": r.missed.mean() if len(r) else np.nan,
                 "init_median": r.init.median() if len(r) else np.nan})
cs_ = pd.DataFrame(rows).sort_values(["condition", "subject", "session"])
print(cs_.round(3).to_string(index=False))


def boot_p(diff, rng, n=10000):
    """Paired one-tailed bootstrap: fraction of resampled mean differences at or below 0."""
    diff = np.asarray(diff)
    b = rng.choice(diff, (n, len(diff))).mean(1)
    return float(np.mean(b <= 0))
condition  subject          session  standard_trials  with_video  missed_mean  init_median
      air MX190201 2019-10-08T11:28              249         246        0.980        0.263
      air MX190602 2019-12-02T13:24              197         195        0.554        0.356
      air VC010102 2019-11-16T11:24              158         149        0.584        0.366
      air VC010103 2019-11-14T14:25              194         192        0.266        0.239
      air WO010401 2019-11-28T13:26              184         182        0.934        0.539
      air WO010402 2019-11-28T14:13              194         187        0.209        0.349
  earplug MX190602 2019-12-03T14:29              147         132        0.227        1.691
  earplug MX190602 2019-12-04T12:36              169         163        0.147        0.253
  earplug VC010102 2019-11-17T12:19              113         106        0.123        1.875
  earplug VC010102 2019-11-18T13:34              129         128        0.109        0.387
  earplug VC010103 2019-11-15T12:27              149         134        0.142        0.644
  earplug VC010103 2019-11-16T14:08              176         175        0.143        0.249
  earplug WO010401 2019-11-29T12:41              150         146        0.486        1.405
  earplug WO010401 2019-12-01T14:00              160         158        0.114        0.359
  earplug WO010402 2019-11-29T13:48              100          76        0.276        1.568
  earplug WO010402 2019-11-30T17:10              138         112        0.580        0.378
  numbing MX200102 2021-01-17T16:18              143         131        2.687        0.572
  numbing MX200102 2021-01-20T17:00              202         183        0.956        0.607
  numbing MX200103 2021-01-17T17:29              109          96        2.667        0.484
  numbing MX200103 2021-01-20T18:02              186         181        0.232        0.350
  numbing MX210203 2021-02-26T17:35              190         186        2.586        0.314
  numbing MX210203 2021-02-28T17:16              209         204        1.049        0.332
  numbing MX210502 2021-05-03T14:52              205         196        0.224        0.355
  numbing MX210601 2021-05-30T13:40               54          42        1.857        0.331
  numbing MX210601 2021-05-31T14:10               84          58        0.207        0.249
  numbing MX210602 2021-05-29T12:19              120         109        0.991        0.614
  numbing MX210602 2021-05-30T12:30              131         128        0.664        0.341
In [48]:
# ed1i: under each of the 2^5 assignments of 'earplug' to one of each mouse's two sessions, the
# paired one-tailed bootstrap of earplug > sham in missed licks. The paper's top panel (time to
# first touch, p < 0.001) suggests one assignment; it is shown too.
rng = np.random.default_rng(31)
ep = cs_[cs_.condition == "earplug"]
pairs = [g.sort_values("session") for _, g in ep.groupby("subject")]
print(f"Mice with two earplug sessions: {sum(len(g) == 2 for g in pairs)} of {len(pairs)}")
d0 = np.array([g.missed_mean.iloc[1] - g.missed_mean.iloc[0] for g in pairs if len(g) == 2])
ps = [boot_p(d0 * np.array(sg), rng) for sg in itertools.product([1, -1], repeat=len(d0))]
print(f"Missed licks, later minus earlier session per mouse: {np.round(d0, 3).tolist()}")
print(f"Over all {len(ps)} assignments: smallest p = {min(ps):.4f}; assignments with p < 0.05: {sum(p < 0.05 for p in ps)}")
di = np.array([g.init_median.iloc[1] - g.init_median.iloc[0] for g in pairs if len(g) == 2])
sign = np.sign(di)
print(f"Median initiation time, later minus earlier session: {np.round(di, 3).tolist()}")
print(f"Assignment by the slower initiation (earplug = the session with the longer time to first touch): "
      f"missed licks earplug minus sham {np.round(d0 * sign, 3).tolist()}, mean {np.mean(d0 * sign):+.3f}, "
      f"p = {boot_p(d0 * sign, rng):.4f}")
Mice with two earplug sessions: 5 of 5
Missed licks, later minus earlier session per mouse: [-0.08, -0.013, 0.001, -0.372, 0.304]
Over all 32 assignments: smallest p = 0.0000; assignments with p < 0.05: 4
Median initiation time, later minus earlier session: [-1.438, -1.488, -0.395, -1.045, -1.19]
Assignment by the slower initiation (earplug = the session with the longer time to first touch): missed licks earplug minus sham [0.08, 0.013, -0.001, 0.372, -0.304], mean +0.032, p = 0.3586
In [49]:
# ed1j: the air flow changes after the 100th trial, on first in two mice and off first in four.
# Missed licks in the standard trials before and after the 100th trial of each session; under each
# of the 15 ways to choose the two on-first mice, the paired one-tailed bootstrap of air on > off.
rng = np.random.default_rng(32)
air = [(o, s) for o, s in zip(cm, ctrl) if o["area"] == "air"]
halves = []
for o, s in air:
    r = pd.DataFrame(o["rows"])
    halves.append({"subject": o["subject"], "first_100": r[r.trial < 100].missed.mean(), "after_100": r[r.trial >= 100].missed.mean(),
                   "trials_first": int((r.trial < 100).sum()), "trials_after": int((r.trial >= 100).sum())})
halves = pd.DataFrame(halves).set_index("subject")
print(halves.round(3).to_string())
dd = (halves.after_100 - halves.first_100).to_numpy()
ps = []
for on_first in itertools.combinations(range(len(dd)), 2):
    sgn = np.array([-1 if k in on_first else 1 for k in range(len(dd))])
    ps.append(boot_p(dd * sgn, rng))
print(f"Over all {len(ps)} assignments: smallest p = {min(ps):.4f}; assignments with p < 0.05: {sum(p < 0.05 for p in ps)}")
          first_100  after_100  trials_first  trials_after
subject                                                   
MX190201      1.293      0.769            99           147
MX190602      0.488      0.600            80           115
VC010102      1.051      0.070            78            71
VC010103      0.267      0.265            75           117
WO010401      0.800      1.039            80           102
WO010402      0.295      0.147            78           109
Over all 15 assignments: smallest p = 0.0179; assignments with p < 0.05: 1
In [50]:
# ed1k: lidocaine should increase missed licks. Without labels, only whether each mouse's two
# sessions differ can be shown, over the trials of the first 30 min of each session (the Methods
# limit the analysis to about 30 min).
rows = []
for (m, g) in cs_[cs_.condition == "numbing"].groupby("subject"):
    per_s = []
    for _, r in g.sort_values("session").iterrows():
        o = [x for x in cm if x["subject"] == m and x["session_start"][:16] == r.session][0]
        t = pd.DataFrame(o["rows"])
        t = t[t.cue <= t.cue.min() + 1800] if len(t) else t
        per_s.append(t.missed.to_numpy() if len(t) else np.array([]))
    row = {"subject": m, "sessions": len(per_s), "missed_session1": per_s[0].mean() if len(per_s[0]) else np.nan,
           "trials1": len(per_s[0])}
    if len(per_s) == 2:
        row.update(missed_session2=per_s[1].mean() if len(per_s[1]) else np.nan, trials2=len(per_s[1]),
                   p_two_sided=stats.mannwhitneyu(per_s[0], per_s[1]).pvalue if len(per_s[0]) and len(per_s[1]) else np.nan)
    rows.append(row)
nk = pd.DataFrame(rows).set_index("subject")
print(nk.round(4).to_string())
print(f"Mice with two sessions: {int((nk.sessions == 2).sum())}; whose sessions differ at p < 0.05: {int((nk.p_two_sided < 0.05).sum())}; "
      f"ratio of the larger to the smaller mean, median {np.nanmedian(np.maximum(nk.missed_session1, nk.missed_session2) / np.minimum(nk.missed_session1, nk.missed_session2)):.2f}")
print(f"\nWhole notebook up to here: {(time.time() - started) / 60:.1f} min")
          sessions  missed_session1  trials1  missed_session2  trials2  p_two_sided
subject                                                                            
MX200102         2           2.7381      126           0.9931    145.0       0.0018
MX200103         2           2.6667       96           0.2320    181.0       0.0000
MX210203         2           2.5860      186           1.0663    196.0       0.0000
MX210502         1           0.2222      117              NaN      NaN          NaN
MX210601         2           1.8571       42           0.2703     37.0       0.0002
MX210602         2           0.9908      109           0.6641    128.0       0.2259
Mice with two sessions: 5; whose sessions differ at p < 0.05: 4; ratio of the larger to the smaller mean, median 2.76

Whole notebook up to here: 21.0 min

All 27 control files hold video and standard trials. ed1i: the paper's test finds an effect under 4 of the 32 assignments of the earplug label, so the absence cannot be shown without the labels. In all 5 mice the earlier of the two sessions has the longer median time to first touch (by 0.40 to 1.49 s), which matches the earplug effect the paper reports (Extended Data Fig. 1i, top); the paper does not give the order of the two sessions. Under that inferred assignment, missed licks differ by +0.032 per trial on average (p = 0.36), no effect, as the paper says. ed1j: 1 of the 15 assignments of the air-on block gives p < 0.05 (smallest p = 0.018). ed1k: in all 5 mice with two sessions the earlier session has more missed licks (0.99 to 2.74 against 0.23 to 1.07 per trial), significant in 4; the files do not say whether the earlier session was lidocaine. These checks rest on inferred or enumerated labels; the three claims stay blocked.

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 Outcome
ephys-unit-counts partially reproduced
fig3-session-counts reproduced
fig3-units-per-session reproduced
fig3a-trial-count reproduced
fig3c-length, fig3c-velocity, fig3c-angle, fig3c-identity, fig3c-time reproduced
ed5b-variables-uncorrelated reproduced
ed5e-variance-explained reproduced
ed5f-axes-orthogonal partially reproduced
fig3e-cca reproduced
fig3f-backtracking-svm partially reproduced
fig4g-zigzag-context reproduced
ed7f-target-position-alm reproduced
ed7h-no-time-coding reproduced
ed4m-active-neuron-counts partially reproduced
ed4l-alm-initiation partially reproduced
ed4o-rhythmicity reproduced
ed6-alm-reward-drop reproduced
ed1h-sequence-duration reproduced
ed1l-learning-curves reproduced
ed1l-proficiency-1500 not reproduced
ed2-backtracking-counts not reproduced
ed2e-missed-licks reproduced
ed6b-consummatory-licks not reproduced
ed7b-correct-side reproduced
Opto claims (Figure 2b,c; Extended Data Fig. 3l,m), five claims blocked: metadata insufficient
ed3f-inhibition-efficiency blocked: data missing
ed1i-earplug, ed1j-odor-masking, ed1k-tongue-numbing blocked: metadata insufficient
ed1f-lick-force blocked: metadata insufficient
ed4j-nnmf-clusters blocked: methods underspecified

How the analysis differs from the paper's. The inter-lick-interval standardisation is not applied; trials are selected by the Methods' feature vectors with a greedy search; regressions use scikit-learn's elastic net path with standardised predictors and the one-SE rule, fit on -0.5 to 0.5 s from the middle touch; SVM nulls use 20 shuffles; zigzag subsequences are aligned on touches (the seventh touch against the third or eleventh), not on a cross-correlogram of tongue angle, and the extra selection of trials by tongue angle is not applied; the contamination estimate uses 1 s bins and no censored period; ITI decoding uses the second before the cue of every standard trial; the PCA before the SVMs is fitted on all trials of a session, labels unseen; the clock check decodes L where the paper's Extended Data Fig. 5a uses theta; video frames at about 400.1 Hz are mapped to the 400 Hz grid by the nearest frame. Regression R2 is computed with samples assigned to folds at random, as the criteria fixed; folds that keep trials together give lower values but the same orderings. The paper's code could not be found.

What was found about the dandiset. The data behind the electrophysiology and behaviour of Figures 1, 3 and 4 and most Extended Data are there, and the decoding results of Figure 3 come out close to the published values. The recorded area and the kind of session are stated only in the session description. The opto sessions do not name the inhibited region, and the control sessions do not mark the condition, so Figure 2 and Extended Data Fig. 1i-k cannot be tested. Units have no quality label, location or waveform, and some spike times are duplicated within units. One learning recording is deposited twice under two mice. The learning files keep trial start times on a different clock from their events, and their seqId column is unreadable. Force sensors are in volts without a calibration, and tongue landmarks in pixels without a scale. The session description is cut short in the files themselves, and no other field (notes, experiment description, stimulus, optogenetics metadata) records the conditions.