Reproducing Schwab et al. (2020) from dandiset 000947¶

Paper. Schwab BC, Kase D, Zimnik A, Rosenbaum R, Codianni MG, Rubin JE, Turner RS (2020). Neural activity during a simple reaching task in macaques is counter to gating and rebound in basal ganglia-thalamic communication. PLOS Biology 18(10): e3000829. doi:10.1371/journal.pbio.3000829. The paper is open access and this attempt used its full text.

Dandiset. DANDI:000947, version 0.240510.2211. The paper predates the dandiset and cites no DANDI version (its data statement points to Dryad). 0.240510.2211 is the only published version, so it is pinned; the draft lists the same 378 files.

What the dandiset holds. Single-unit recordings from two monkeys (Gaia, "NHP G", and Isis, "NHP I") in GPi, VL thalamus, motor cortex and other areas, before and after MPTP, with trial tables and raw and filtered traces. The paper uses the pre-MPTP GPi and VLa recordings. EMG, the stimulation mapping of Fig 1 and the simulations of Fig 6 are not in the dandiset.

Outside inputs. The paper's Dryad deposit (doi:10.5061/dryad.0cfxpnvxm, read here from its Zenodo mirror, record 4000719) holds the analysis code read for the method and the per-unit result files Gaia_Analy.mat and Isis_Analy.mat. Those files name each analysed unit by file, channel and sort letter; they are used only by the claim unit-link and by variants that use the paper's own unit list. Every outcome on the paper's findings is decided on a unit selection made from the dandiset alone.

Claim Data Status
unit-link with inputs tested
table1-unit-counts dandiset tested
table1-rest-rate dandiset tested
table1-ap-width dandiset tested
hold-period-ramps dandiset tested
mvt-responsive-fraction dandiset tested
increases-most-common dandiset tested
monophasic-more-in-vla dandiset tested
integrated-change-positive dandiset tested
fig3a-vla-leads-gpi dandiset tested
fig3b-gpi-decr-lag-vla-incr dandiset tested
fig3c-vla-decr-lead-gpi-incr dandiset tested
gating-conflict-fraction dandiset tested
directional-units dandiset tested
fig4-ccf-rare dandiset tested
fig4-noise-corr-rare dandiset tested
fig5-vla-decrease-after-gpi-bursts dandiset tested
behavior-rt-mt dandiset tested
figs3-emg-onset elsewhere blocked: methods underspecified
fig1-vla-localization dandiset blocked: data missing
fig6-simulation elsewhere blocked: other
In [1]:
import io
import time
import warnings

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
import scipy.io as sio
import statsmodels.api as sm
from dandi.dandiapi import DandiAPIClient
from scipy import stats
from scipy.ndimage import gaussian_filter1d
from statsmodels.formula.api import ols

import gpivla as g

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

NB_START = started = time.time()
all_files = g.asset_urls(pre_only=False)
sessions = g.load_all()
print(f"{len(all_files)} files in dandiset {g.DANDISET_ID} version {g.DANDISET_VERSION}; "
      f"loaded the {len(sessions)} pre-MPTP files in {time.time() - started:.0f} s")
378 files in dandiset 000947 version 0.240510.2211; loaded the 254 pre-MPTP files in 298 s

Data check¶

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

In [2]:
with DandiAPIClient() as client:
    meta = client.get_dandiset(g.DANDISET_ID, g.DANDISET_VERSION).get_raw_metadata()
summary = meta["assetsSummary"]
print("Archive summary: subjects", summary["numberOfSubjects"], "| files", summary["numberOfFiles"],
      "| species", [s["name"] for s in summary["species"]])
print("Related resources:", [r.get("identifier") or r.get("url") for r in meta.get("relatedResource", [])])
paths = pd.DataFrame({"path": [p for p, _ in all_files]})
paths["subject"] = paths.path.str.extract(r"sub-(\w+)/")[0]
paths["condition"] = np.where(paths.path.str.contains("ses-pre-MPTP"), "pre-MPTP", "post-MPTP")
print("\nFiles by subject and condition:")
print(pd.crosstab(paths.subject, paths.condition).to_string())

info = pd.DataFrame([{
    "session": s["session_id"], "key": s["key"], "subject": s["subject"], "mptp_attr": s["mptp"],
    "start": s["session_start"][:10], "has_trials": s["trials"] is not None,
    "n_trials": 0 if s["trials"] is None else len(s["trials"]), "n_units": len(s["units"]),
    "publications": ";".join(s["related_publications"]), "n_raw": s["n_raw"], "groups": str(sorted(s["groups"].values()))}
    for s in sessions])
print("\nMPTP status attribute of the pre-MPTP files:", info.mptp_attr.value_counts().to_dict())
print("related_publications in the files:", info.publications.value_counts().to_dict())
print("Files without a trials table (session, curated units, raw samples):",
      [(r.session, r.n_units, r.n_raw) for r in info[~info.has_trials].itertuples()])
print("Files without curated units:", int((info.n_units == 0).sum()), "of", len(info))
pu_rows = []
for s in sessions:
    if s["units"]:
        continue
    f = g._open(s["url"])
    pu = f["processing/ecephys/units"] if "units" in f.get("processing/ecephys", {}) else None
    pu_rows.append({"session": s["session_id"], "sorted_clusters": 0 if pu is None else len(pu["id"]),
                    "colnames": "(no table)" if pu is None else ", ".join(g._s(c) for c in pu.attrs["colnames"])})
    f.close()
pu_rows = pd.DataFrame(pu_rows)
print("  their sorted clusters in processing/ecephys/units: files with clusters", int((pu_rows.sorted_clusters > 0).sum()),
      "; clusters", int(pu_rows.sorted_clusters.sum()), "; columns:", pu_rows.colnames.value_counts().to_dict())
print(pu_rows[pu_rows.session.str.contains("G-140813-2")].to_string(index=False))
print("Trials per file: min", info.n_trials[info.has_trials].min(), "median",
      info.n_trials[info.has_trials].median(), "max", info.n_trials.max())
day = pd.to_datetime(info.session.str.extract(r"-(\d{6})-")[0], format="%y%m%d")
offset = (pd.to_datetime(info.start) - day).dt.days
print("session_start_time minus the date in session_id (days):", offset.value_counts().to_dict())
print("Electrode group locations (files):")
print(info.groups.value_counts().head(12).to_string())
Archive summary: subjects 2 | files 378 | species ['Macaca mulatta - Rhesus monkey']
Related resources: ['https://doi.org/10.1371/journal.pbio.3000829', 'https://github.com/dandi/example-notebooks/blob/master/000947/TurnerLab/public_demo/000947_demo.ipynb', 'https://doi.org/10.1101/2023.09.08.556120']

Files by subject and condition:
condition  post-MPTP  pre-MPTP
subject                       
Gaia             101       119
Isis              23       135

MPTP status attribute of the pre-MPTP files: {'pre_MPTP': 254}
related_publications in the files: {'https://doi.org/10.1101/546598': 254}
Files without a trials table (session, curated units, raw samples): [('pre-MPTP-I-160616-4', 5, 10330112)]
Files without curated units: 21 of 254
  their sorted clusters in processing/ecephys/units: files with clusters 13 ; clusters 124 ; columns: {'spike_times, unit_name, location': 13, '(no table)': 8}
            session  sorted_clusters                         colnames
pre-MPTP-G-140813-2               16 spike_times, unit_name, location
Trials per file: min 11 median 103.0 max 215
session_start_time minus the date in session_id (days): {2: 254}
Electrode group locations (files):
groups
['GPi', 'VLa']             79
['GPi', 'VLp']             65
['VLa']                    21
['M1-G', 'M1-S']           18
['M1-S', 'SMA']            12
['GPi', 'Thalamus']        10
['VLp']                     7
['GP', 'VL']                5
['SMA']                     5
['M1-G', 'M1-S', 'SMA']     5
['M1-S']                    4
['GPi', 'VMb']              3

The archive summary counts 2 subjects, as the files do (Gaia and Isis). Every pre-MPTP file carries the MPTP status pre_MPTP, consistent with its name. The pre-MPTP files name the paper only by its bioRxiv preprint (doi:10.1101/546598); the dandiset metadata lists the published paper. One pre-MPTP file, I-160616-4, has no trials table; it has 5 curated units and raw data, and its units enter no analysis here. 21 files have no curated units. 13 of them hold sorted clusters in processing/ecephys/units (124 in all, 16 in G-140813-2), whose only columns are spike times, unit name and location, so they cannot be matched to the paper's channel and sort letter; 8 have no such table. In every file the session_start_time is two days after the date in the session_id; the paper does not use dates, so this does not affect the analysis, but one of the two is wrong or shifted on purpose and the files do not say which.

In [3]:
rows = []
for s in sessions:
    span = None
    for u in s["units"]:
        sp = u["spikes"]
        rows.append({"session": s["session_id"], "key": s["key"], "subject": s["subject"],
                     "unit_name": u.get("unit_name"), "label": u.get("location"),
                     "area": g.area(u.get("location")), "channel": pd.to_numeric(u.get("channel_ids"), errors="coerce"),
                     "sort": str(u.get("sort_label")), "quality": u.get("unit_quality_post_sorting"),
                     "n_spikes": len(sp), "first": sp[0] if len(sp) else np.nan, "last": sp[-1] if len(sp) else np.nan,
                     "sorted": u["sorted_in_file"], "rec_s": s["n_raw"] / 24414.0625})
U = pd.DataFrame(rows)
print("Curated units by subject and location label as written in the files:")
print(pd.crosstab(U.label.fillna("(none)").replace("", "(empty)"), U.subject).to_string())
print("\nUnits with no channel id:", int(U.channel.isna().sum()), "; with an empty location:", int((U.label.fillna("") == "").sum()))
print("Sort labels:", U["sort"].value_counts().to_dict())
print("Quality labels (GPi and VLa):", U[U.area.isin(["GPi", "VLa"])].quality.fillna("(column absent)").value_counts().to_dict())
print("\nSpike times stored in increasing order in every unit:", bool(U.sorted.all()))
print("Spike times within the raw recording (0 to its length):",
      bool(((U["first"] >= 0) & (U["last"] <= U.rec_s + 1)).all()))
# Recordings deposited twice: identical spike trains within or across files.
digest = {}
for s in sessions:
    for u in s["units"]:
        if len(u["spikes"]):
            digest.setdefault((len(u["spikes"]), round(float(u["spikes"][0]), 6), round(float(u["spikes"][-1]), 6)), []).append(
                (s["session_id"], u.get("unit_name")))
dups = {k: v for k, v in digest.items() if len(v) > 1}
print("Spike trains with the same count, first and last spike in more than one unit:", len(dups))
for k, v in list(dups.items())[:10]:
    us = [u for s in sessions if s["session_id"] == v[0][0] for u in s["units"] if u.get("unit_name") in [x[1] for x in v]]
    print("  ", v, "labels", [u.get("location") for u in us], "channels", [u.get("channel_ids") for u in us],
          "identical:", all(np.array_equal(us[0]["spikes"], x["spikes"]) for x in us[1:]))
same_name = U.groupby(["session", "unit_name"]).size()
print("Unit names used twice within a file:", int((same_name > 1).sum()))
grid = []
for s in sessions:
    sp = np.concatenate([u["spikes"] for u in s["units"]]) if s["units"] else np.array([])
    if len(sp):
        grid.append({"sample": np.mean(np.abs(sp * 24414.0625 - np.round(sp * 24414.0625)) < 1e-3),
                     "whole_ms": np.mean(np.abs(sp * 1000 - np.round(sp * 1000)) < 1e-6)})
grid = pd.DataFrame(grid)
print(f"Fraction of spike times on the 24414.0625 Hz sample grid: lowest over {len(grid)} files {grid['sample'].min():.4f}; "
      f"on whole ms: highest over files {grid.whole_ms.max():.4f}")
print("Files where a channel id is given to more than one electrode:",
      sum(bool(s["electrodes"].channel_ids.duplicated().any()) for s in sessions))
print("Electrode table columns read:", list(sessions[0]["electrodes"].columns))
Curated units by subject and location label as written in the files:
subject   Gaia  Isis
label               
(empty)      0    33
GPE          1     0
GPI         52     0
GPi         99   212
M1-G         0    98
M1-S         0   145
R-V          0    12
RTN          0    14
S-R          0     2
SMA          0    77
STR          0     2
Thalamus    54     0
VLA         66     0
VLP          8     0
VLa         19   219
VLp          6   118

Units with no channel id: 33 ; with an empty location: 33
Sort labels: {'a': 710, '1.0': 295, 'b': 130, '2.0': 48, '': 33, 'c': 11, '3.0': 7, '4.0': 3}
Quality labels (GPi and VLa): {'excellent': 353, '(column absent)': 179, 'F': 35, 'good': 25, 'changed to excellent from good based on post-sorting quality': 23, 'B -> C': 16, 'C': 10, 'B -> B': 10, 'no quality': 6, 'B -> F': 4, 'changed to good from excellent based on post-sorting quality': 3, '?': 1, 'C -> A': 1, 'M': 1}

Spike times stored in increasing order in every unit: True
Spike times within the raw recording (0 to its length): True
Spike trains with the same count, first and last spike in more than one unit: 1
   [('pre-MPTP-I-170518-2', '6'), ('pre-MPTP-I-170518-2', '7')] labels ['M1-S', 'M1-S'] channels [np.float64(40.0), np.float64(43.0)] identical: True
Unit names used twice within a file: 33
Fraction of spike times on the 24414.0625 Hz sample grid: lowest over 233 files 1.0000; on whole ms: highest over files 0.0009
Files where a channel id is given to more than one electrode: 0
Electrode table columns read: ['channel_ids', 'location', 'group_name', 'acx_x', 'acx_y', 'acx_z']

The location labels differ in case between files (GPi and GPI, VLa and VLA), so they are compared here ignoring case. In Gaia, 54 thalamic units are labelled only "Thalamus", and in Isis 33 units have an empty label and no channel id; the paper's code calls many of these VLa (see unit-link), but the dandiset does not say so, and the dandiset rule leaves them out. Sort labels are written as letters in some files and as numbers in others, and the quality column is missing from some files and holds free text in others. Unit names repeat within some files (a unit on the GPi probe and a unit with an empty label share a name). One spike train is stored twice, as two M1-S units on different channels of one file; no GPi or VLa train is, so no recording used here is counted twice by identity. A neuron recorded over consecutive files appears once per file (see table1-unit-counts). Spike times lie on the 24414.0625 Hz sample grid in every file, where the paper says they were saved at millisecond accuracy; no time falls on a whole millisecond more often than chance. No file gives one channel id to two electrodes, so a unit's channel id names its electrode.

In [4]:
for s in sessions:
    s["vt"] = None if s["trials"] is None else g.valid_trials(s["trials"])
tr = pd.concat([s["vt"].assign(session=s["session_id"], subject=s["subject"]) for s in sessions if s["vt"] is not None])
print("Trial table columns (files):")
print(pd.Series([tuple(s["trials"].columns) for s in sessions if s["trials"] is not None]).value_counts().to_string())
print("\nTrials by subject: total, errors, outliers, valid")
print(tr.groupby("subject").agg(total=("valid", "size"), errors=("error", "sum"),
                                outliers=("outlier", "sum"), valid=("valid", "sum")).to_string())
print("Targets:", tr.target.value_counts().to_dict())
v = tr[tr.valid]
print("Reaction time of valid trials (s): min %.3f median %.3f max %.3f" % (v.rt.min(), v.rt.median(), v.rt.max()))
print("Movement duration of valid trials (s): min %.3f median %.3f max %.3f; trials without one: %d of %d"
      % (v.mt.min(), v.mt.median(), v.mt.max(), v.mt.isna().sum(), len(v)))
order_ok = (tr.cue_onset_time <= tr.movement_start_time) | ~tr.valid
print("Valid trials with cue onset before movement onset:", bool(order_ok.all()))
print("Files with movement ends, by subject:",
      {k: int(x) for k, x in pd.Series({s["session_id"]: ("movement_stop_time" in s["trials"]) for s in sessions if s["trials"] is not None}).groupby(lambda k: k.split("-")[2]).sum().items()})
Trial table columns (files):
(start_time, stop_time, error_onset_time, reward_start_time, reward_stop_time, movement_start_time, movement_stop_time, return_start_time, return_stop_time, cue_onset_time, target)    84
(start_time, stop_time, reward_start_time, reward_stop_time, movement_start_time, movement_stop_time, return_start_time, return_stop_time, cue_onset_time, target)                      82
(start_time, stop_time, reward_start_time, reward_stop_time, movement_start_time, cue_onset_time, target)                                                                               44
(start_time, stop_time, error_onset_time, reward_start_time, reward_stop_time, movement_start_time, cue_onset_time, target)                                                             43

Trials by subject: total, errors, outliers, valid
         total  errors  outliers  valid
subject                                
Gaia      9676     273       114   9289
Isis     15470     540       591  14061
Targets: {'Left': 12584, 'Right': 12562}
Reaction time of valid trials (s): min 0.104 median 0.239 max 0.529
Movement duration of valid trials (s): min 0.136 median 0.196 max 0.422; trials without one: 6131 of 23350
Valid trials with cue onset before movement onset: True
Files with movement ends, by subject: {'G': 32, 'I': 134}

Times are in seconds, and reaction times and movement durations of valid trials fall in the range the task allows (the animal had 1 s to reach). The paper's reaction times (S2 Fig) are around 0.2 to 0.3 s, as here. Error trials are flagged only in files that have an error_onset_time column; in the other files no trial is marked as an error, so error trials there, if any, cannot be excluded. Movement ends are missing from many files, so movement durations are available for part of the data only.

Clock check. With spikes and a trial table only, the check is whether the trial labels carry information in the spikes at the time of movement but not before the cue, when the animal does not know the target. For each unit with at least 10 valid trials per direction, left and right spike counts are compared (rank sum) in the movement window (-200 to +300 ms) and in a window before the cue (-1500 to -1000 ms from movement onset). Two controls: the labels permuted within the file, and the true labels with every trial time moved 1 s later (a clock offset).

In [5]:
def window_counts(spikes, align, lo, hi):
    a = np.searchsorted(spikes, align + lo)
    b = np.searchsorted(spikes, align + hi)
    return b - a

rng = np.random.default_rng(0)
rows = []
for s in sessions:
    if s["vt"] is None:
        continue
    t = s["vt"][s["vt"].valid]
    left = (t.target == "Left").to_numpy()
    permuted = rng.permutation(left)
    al = t.movement_start_time.to_numpy()
    for u in s["units"]:
        if g.area(u.get("location")) not in ("GPi", "VLa") or left.sum() < 10 or (~left).sum() < 10:
            continue
        r = {"session": s["session_id"], "subject": s["subject"], "area": g.area(u.get("location"))}
        for name, (lo, hi) in {"movement": (-0.2, 0.3), "pre_cue": (-1.5, -1.0)}.items():
            for lab, mask, shift in (("true", left, 0.0), ("permuted", permuted, 0.0), ("offset_1s", left, 1.0)):
                c = window_counts(u["spikes"], al + shift, lo, hi)
                r[f"{name}_{lab}"] = stats.mannwhitneyu(c[mask], c[~mask]).pvalue if c.std() > 0 else 1.0
        rows.append(r)
clock = pd.DataFrame(rows)
cols = [f"{w}_{l}" for w in ("movement", "pre_cue") for l in ("true", "permuted", "offset_1s")]
print("Fraction of units whose spike counts differ between left and right trials (p < 0.05):")
print(clock.groupby("area")[cols].agg(lambda p: (p < 0.05).mean()).round(3).T.to_string())
per_session = clock.groupby("session").movement_true.apply(lambda p: (p < 0.05).any())
print(f"\nFiles with at least one direction-tuned unit in the movement window: {per_session.sum()} of {len(per_session)}")
print("Files where none is tuned (the clock cannot be checked this way):", sorted(per_session[~per_session].index))
Fraction of units whose spike counts differ between left and right trials (p < 0.05):
area                  GPi    VLa
movement_true       0.485  0.386
movement_permuted   0.067  0.034
movement_offset_1s  0.372  0.319
pre_cue_true        0.087  0.078
pre_cue_permuted    0.038  0.041
pre_cue_offset_1s   0.253  0.214

Files with at least one direction-tuned unit in the movement window: 129 of 163
Files where none is tuned (the clock cannot be checked this way): ['pre-MPTP-G-140511-3', 'pre-MPTP-G-140511-6', 'pre-MPTP-G-140511-8', 'pre-MPTP-G-140511-9', 'pre-MPTP-G-140512-1', 'pre-MPTP-G-140512-8', 'pre-MPTP-G-140512-9', 'pre-MPTP-G-140514-10', 'pre-MPTP-G-140514-8', 'pre-MPTP-G-140521-3', 'pre-MPTP-G-140524-1', 'pre-MPTP-G-140524-3', 'pre-MPTP-G-140524-5', 'pre-MPTP-G-140524-6', 'pre-MPTP-G-140525-6', 'pre-MPTP-G-140611-1', 'pre-MPTP-G-140611-3', 'pre-MPTP-G-140612-5', 'pre-MPTP-G-140612-9', 'pre-MPTP-G-140617-3', 'pre-MPTP-G-140617-4', 'pre-MPTP-G-140627-2', 'pre-MPTP-G-140701-1', 'pre-MPTP-G-140723-1', 'pre-MPTP-G-140723-2', 'pre-MPTP-G-140902-3', 'pre-MPTP-G-140902-4', 'pre-MPTP-I-160624-1', 'pre-MPTP-I-160714-1', 'pre-MPTP-I-160714-4', 'pre-MPTP-I-160718-1', 'pre-MPTP-I-170615-2', 'pre-MPTP-I-170616-2', 'pre-MPTP-I-170623-2']

With the true labels, 48.5% of GPi and 38.6% of VLa units differ between left and right trials in the movement window, against 8.7% and 7.8% before the cue; with permuted labels both windows are near the 5% expected by chance (3.4% to 6.7%). The 1 s offset does not remove the tuning (37.2% and 31.9%), because activity while holding at the target also depends on the target, so it does not test the clock. The contrast between the movement and pre-cue windows is consistent with the spike times and the trial table sharing a clock; the detected response onsets below, with medians between -84 and -19 ms from movement onset, support it. In 34 of the 163 files with GPi or VLa units no unit is tuned, and for them the shared clock is assumed, not shown.

Unit selection and peri-movement responses¶

The dandiset rule (fixed in the claims file): curated units labelled GPi or VLa (ignoring case) in pre-MPTP files; a unit-direction is analysed with at least 10 valid trials; a unit is kept when its mean rate is at least 30 spikes/s (GPi) or 1 spike/s (VLa) and at least one direction is analysed. Each file's unit is one unit. For every unit-direction the helper computes the SDF and ISI functions, detects increases and decreases, and records the rest rate, the integrated change and the control-period slope. The rate is taken over the span of the spike train; taken over the length of the raw recording, it would leave out 2 more GPi units (output below).

In [6]:
started = time.time()
def run_units(records, alpha=g.ALPHA):
    """records: list of dicts with unit id, area, subject and segments [(spikes, valid trials)]."""
    out = []
    for r in records:
        for target in ("Left", "Right"):
            d = g.unit_direction(r["segments"], target, alpha)
            if d is None:
                continue
            out.append({"uid": r["uid"], "area": r["area"], "subject": r["subject"], "target": target,
                        **{k: d[k] for k in ("n_trials", "median_rt_ms", "rest_rate", "integrated", "inc_onset",
                                             "dec_onset", "slope_per_s2", "slope_sig")},
                        "sdf": d["sdf"], "counts": d["trial_counts"]})
    df = pd.DataFrame(out)
    df["form"] = [g.response_form(i, d) for i, d in zip(df.inc_onset, df.dec_onset)]
    return df

dand_records, dropped, rate_rule = [], {"rate": 0, "trials": 0}, []
for s in sessions:
    if s["vt"] is None:
        continue
    for k, u in enumerate(s["units"]):
        a = g.area(u.get("location"))
        if a not in ("GPi", "VLa") or len(u["spikes"]) < 2:
            continue
        rate = len(u["spikes"]) / (u["spikes"][-1] - u["spikes"][0])
        rec_s = s["n_raw"] / 24414.0625
        rate_rule.append({"area": a, "span": rate >= g.MIN_RATE[a],
                          "recording": "no raw data" if rec_s == 0 else len(u["spikes"]) / rec_s >= g.MIN_RATE[a]})
        if rate < g.MIN_RATE[a]:
            dropped["rate"] += 1
            continue
        dand_records.append({"uid": f"{s['session_id']}/{k}", "area": a, "subject": s["subject"], "session": s["session_id"],
                             "key": s["key"], "channel": pd.to_numeric(u.get("channel_ids"), errors="coerce"),
                             "sort": str(u.get("sort_label")), "rate": rate, "segments": [(u["spikes"], s["vt"])],
                             "unit": u, "sess": s})
ud = run_units(dand_records)
kept = set(ud.uid)
dropped["trials"] = sum(r["uid"] not in kept for r in dand_records)
units = pd.DataFrame([{k: r[k] for k in ("uid", "area", "subject", "session", "key", "channel", "sort", "rate")}
                      for r in dand_records if r["uid"] in kept])
print(f"Dandiset rule: {len(units)} units, {len(ud)} unit-directions ({time.time() - started:.0f} s)")
print("Left out: below the rate criterion", dropped["rate"], "; fewer than 10 valid trials in both directions", dropped["trials"])
print(units.groupby(["area", "subject"]).size().unstack().to_string())
print("Variant, the rate over the length of the raw recording instead of the span of the spike train; units passing "
      "(rows: span rule, columns: recording rule):")
print(pd.crosstab([pd.DataFrame(rate_rule).area, pd.DataFrame(rate_rule).span], pd.DataFrame(rate_rule).recording.astype(str)).to_string())
Dandiset rule: 547 units, 1083 unit-directions (7 s)
Left out: below the rate criterion 107 ; fewer than 10 valid trials in both directions 8
subject  Gaia  Isis
area               
GPi       137   131
VLa        78   201
Variant, the rate over the length of the raw recording instead of the span of the spike train; units passing (rows: span rule, columns: recording rule):
recording   False  True
area span              
GPi  False     84     0
     True       2   272
VLa  False     23     0
     True       0   281

The paper's unit list, for the variants: the records the paper's code keeps (Fig2_3_S8_S9.m drops VLp units and records with missing SDFs) are matched to the curated units by file, channel id and sort letter; a record that spans several files pools the trials of all of them, as the paper's records do. Records not found in every file they span are left out of the variants.

In [7]:
started = time.time()
ZENODO = "https://zenodo.org/records/4000719/files/{}?download=1"
analy = {}
for monk in ("Gaia", "Isis"):
    r = requests.get(ZENODO.format(f"{monk}_Analy.mat"), timeout=600)
    r.raise_for_status()
    m = sio.loadmat(io.BytesIO(r.content), squeeze_me=True, struct_as_record=False)
    analy[monk] = (m["RespSDFIncr"], m["RespISIIncr"])

def sort_letter(x):
    return {"1.0": "a", "2.0": "b", "3.0": "c", "4.0": "d"}.get(str(x), str(x))

by_key = {s["key"]: s for s in sessions}
paper_rows, paper_records = [], []
for monk, (A, B) in analy.items():
    for i in range(len(A.fname)):
        utype = str(A.unit_type[i])
        files = str(A.fname[i]).split("/")
        keep = utype != "VLp" and not np.isnan(A.evt_sdf[i, :, 0]).any()
        found = []
        for f in files:
            s = by_key.get(f)
            hits = [] if s is None else [u for u in s["units"]
                    if pd.to_numeric(u.get("channel_ids"), errors="coerce") == float(A.Uchan[i])
                    and sort_letter(u.get("sort_label")) == A.Usort[i]]
            found.append((s, hits[0]) if len(hits) == 1 else None)
        complete = all(x is not None for x in found)
        paper_rows.append({"monk": monk, "i": i, "fname": str(A.fname[i]), "area": utype, "keep": keep,
                           "n_files": len(files), "n_found": sum(x is not None for x in found), "complete": complete,
                           "labels": sorted({g.area(x[1].get("location")) or "(empty)" for x in found if x})})
        if keep and complete:
            paper_records.append({"uid": f"paper/{monk}/{i}", "area": utype, "subject": monk, "monk": monk, "i": i,
                                  "segments": [(u["spikes"], s["vt"]) for s, u in found],
                                  "sessions": [s["session_id"] for s, u in found]})
paper = pd.DataFrame(paper_rows)
ud_paper = run_units(paper_records)
print(f"Paper's records: {len(paper)}; kept by its code: {int(paper.keep.sum())}; found in every file: "
      f"{int((paper.keep & paper.complete).sum())}; unit-directions analysed: {len(ud_paper)} ({time.time() - started:.0f} s)")
Paper's records: 406; kept by its code: 394; found in every file: 364; unit-directions analysed: 724 (68 s)

One record per presumed neuron. The dandiset has no identifier for a neuron recorded over consecutive files, and the rule above counts each file's unit once, so a neuron recorded in several files enters several times and its directions twice. As a variant for every claim, units of the dandiset rule with the same animal, day, area, channel id and sort letter are taken as one neuron (an assumption) and the trials of all its files are pooled, as the paper's records do. Tests that compare onsets or slopes are also shown with one value per presumed neuron.

In [8]:
def same_sort(x):
    t = str(x)
    try:
        return "abcd"[int(float(t)) - 1]
    except (ValueError, IndexError):
        return t

groups = {}
for r in dand_records:
    if r["uid"] not in kept:
        continue
    day = r["session"].split("-")[-2]
    key = (r["subject"], day, r["area"], r["channel"], same_sort(r["sort"])) if np.isfinite(r["channel"]) else (r["uid"],)
    groups.setdefault(key, []).append(r)
merged_records, uid2neuron = [], {}
for n, (key, rs) in enumerate(groups.items()):
    rs = sorted(rs, key=lambda r: r["session"])
    for r in rs:
        uid2neuron[r["uid"]] = f"neuron/{n}"
    merged_records.append({"uid": f"neuron/{n}", "area": rs[0]["area"], "subject": rs[0]["subject"], "key": key,
                           "segments": [seg for r in rs for seg in r["segments"]], "files": tuple(sorted(r["key"] for r in rs))})
ud_m = run_units(merged_records)
ud["neuron"] = ud.uid.map(uid2neuron)
print(f"Presumed neurons: {len(merged_records)} {ud_m.groupby('area').uid.nunique().to_dict()}; unit-directions analysed: {len(ud_m)}")
print("Files per presumed neuron:", pd.Series([len(r["files"]) for r in merged_records]).value_counts().sort_index().to_dict())
paper_sets = {tuple(sorted(str(x).split("/"))) for x in paper[paper.keep].fname}
multi = [r for r in merged_records if len(r["files"]) > 1]
print(f"Distinct file sets of the paper's records spanning several files: {sum(len(x) > 1 for x in paper_sets)}; "
      f"presumed neurons spanning several files: {len(multi)}, with exactly the file set of a paper record: "
      f"{sum(r['files'] in paper_sets for r in multi)}")
prec = [(set(str(p.fname).split("/")), float(analy[p.monk][0].Uchan[p.i]), str(analy[p.monk][0].Usort[p.i]))
        for p in paper[paper.keep].itertuples()]
n_rec = [sum(ch == r["key"][3] and srt == r["key"][4] and fs <= set(r["files"]) for fs, ch, srt in prec) for r in multi]
print("Presumed neurons spanning several files that hold more than one of the paper's records (merged too far):",
      sum(n > 1 for n in n_rec), "of", len(multi))
Presumed neurons: 318 {'GPi': 132, 'VLa': 186}; unit-directions analysed: 632
Files per presumed neuron: {1: 201, 2: 64, 3: 27, 4: 11, 5: 4, 6: 7, 7: 2, 8: 1, 9: 1}
Distinct file sets of the paper's records spanning several files: 53; presumed neurons spanning several files: 117, with exactly the file set of a paper record: 53
Presumed neurons spanning several files that hold more than one of the paper's records (merged too far): 47 of 117

The merge gives 318 presumed neurons (132 GPi, 186 VLa) from 547 units. All 53 distinct file sets of the paper's multi-file records appear among them, but 47 of the 117 presumed neurons that span several files hold more than one of the paper's records, so the merge also joins units the paper kept apart. This variant therefore has fewer neurons than the paper, as the main rule has more.

Chance level of the detection. The same detection is run on each unit of the dandiset rule with every valid trial aligned to a random time, drawn uniformly from the unit's recording, in place of its movement onset (same trials, reaction times and spike train; 3 draws). This gives the fraction of unit-directions in which the detector finds an increase, a decrease or a significant control slope when nothing is locked to the alignment.

In [9]:
started = time.time()
rng = np.random.default_rng(11)

def random_alignment(vt, spikes):
    t = vt.copy()
    t["movement_start_time"] = rng.uniform(spikes[0] + 1.6, spikes[-1] - 1.1, len(t))
    return t

nul = pd.concat([run_units([{**r, "segments": [(sp, random_alignment(vt, sp)) for sp, vt in r["segments"]]}
                            for r in dand_records if r["uid"] in kept]).assign(rep=rep) for rep in range(3)], ignore_index=True)

def detection_summary(df, key):
    per_dir = df.groupby("area").agg(increase=("inc_onset", lambda x: x.notna().mean()),
                                     decrease=("dec_onset", lambda x: x.notna().mean()))
    per_unit = (df.assign(resp=df.form != "none").groupby(key).agg(responsive=("resp", "any"), slope_sig=("slope_sig", "any"))
                .groupby("area").mean())
    return pd.concat([per_dir, per_unit], axis=1).round(3)

print(f"Random alignment, 3 draws ({time.time() - started:.0f} s). Columns: unit-directions with an increase, with a "
      "decrease; units responsive, with a significant control slope, in at least one direction.")
print("Data, dandiset rule:"); print(detection_summary(ud, ["uid", "area"]).to_string())
print("Chance, mean of 3 draws:"); print(detection_summary(nul, ["rep", "uid", "area"]).to_string())
print("Responsive units by draw:", nul.assign(resp=nul.form != "none").groupby(["rep", "uid", "area"]).resp.any()
      .groupby(["rep", "area"]).mean().unstack().round(3).to_dict("list"))
for k in ("inc", "dec"):
    print(f"Chance {k}rease onsets (ms): median and count by area",
          nul.groupby("area")[f"{k}_onset"].agg(["median", "count"]).to_dict("index"))
print("Chance forms, percent of unit-directions:")
print((100 * pd.crosstab(nul.area, nul.form, normalize="index")).round(1).to_string())
Random alignment, 3 draws (22 s). Columns: unit-directions with an increase, with a decrease; units responsive, with a significant control slope, in at least one direction.
Data, dandiset rule:
      increase  decrease  responsive  slope_sig
area                                           
GPi      0.710     0.497       0.985      0.985
VLa      0.668     0.442       0.975      0.986
Chance, mean of 3 draws:
      increase  decrease  responsive  slope_sig
area                                           
GPi      0.148     0.252       0.623      0.984
VLa      0.162     0.337       0.708      0.990
Responsive units by draw: {'GPi': [0.597, 0.646, 0.627], 'VLa': [0.706, 0.713, 0.706]}
Chance increase onsets (ms): median and count by area {'GPi': {'median': 83.0, 'count': 236}, 'VLa': {'median': 70.0, 'count': 268}}
Chance decrease onsets (ms): median and count by area {'GPi': {'median': 52.0, 'count': 402}, 'VLa': {'median': -8.0, 'count': 558}}
Chance forms, percent of unit-directions:
form  dec_first  decrease  inc_first  increase  none
area                                                
GPi         0.5      24.1        0.6      13.7  61.1
VLa         1.1      31.6        1.0      14.1  52.2

With random alignment the detector finds an increase in 14.8% (GPi) and 16.2% (VLa) of unit-directions and a decrease in 25.2% and 33.7%, so 62.3% of GPi and 70.8% of VLa units are responsive by chance, against 98.5% and 97.5% in the data. Chance onsets fall late for increases (medians +83 and +70 ms) and GPi decreases (+52 ms), and at -8 ms for VLa decreases. A significant control slope is found in 98.4% and 99.0% of units by chance, as often as in the data: the slope test, the 95% CI of an OLS fit to a smoothed function, does not separate ramps from noise. These chance levels are used below.

Variant, stricter detection. The same detection at an omnibus p of 0.0001 in place of 0.001, on the data and on one draw of random alignment, to see how the onset comparisons change when fewer chance detections enter. It is shown in the claims on response forms and onsets.

In [10]:
started = time.time()
rng = np.random.default_rng(12)
STRICT = 0.0001
ud_strict = run_units([r for r in dand_records if r["uid"] in kept], alpha=STRICT)
nul_strict = run_units([{**r, "segments": [(sp, random_alignment(vt, sp)) for sp, vt in r["segments"]]}
                        for r in dand_records if r["uid"] in kept], alpha=STRICT)
print(f"Detection at alpha {STRICT} ({time.time() - started:.0f} s). Columns as above.")
print("Data:"); print(detection_summary(ud_strict, ["uid", "area"]).to_string())
print("Chance, one draw:"); print(detection_summary(nul_strict, ["uid", "area"]).to_string())
for k in ("inc", "dec"):
    print(f"Chance {k}rease onsets at alpha {STRICT} (ms): median and count by area",
          nul_strict.groupby("area")[f"{k}_onset"].agg(["median", "count"]).to_dict("index"))
Detection at alpha 0.0001 (15 s). Columns as above.
Data:
      increase  decrease  responsive  slope_sig
area                                           
GPi      0.678     0.475       0.981      0.985
VLa      0.634     0.420       0.953      0.986
Chance, one draw:
      increase  decrease  responsive  slope_sig
area                                           
GPi      0.115     0.196       0.519      0.985
VLa      0.147     0.308       0.667      0.993
Chance increase onsets at alpha 0.0001 (ms): median and count by area {'GPi': {'median': 126.0, 'count': 61}, 'VLa': {'median': 142.0, 'count': 81}}
Chance decrease onsets at alpha 0.0001 (ms): median and count by area {'GPi': {'median': 98.0, 'count': 104}, 'VLa': {'median': 8.5, 'count': 170}}

At alpha 0.0001 the data keep most of their detections (98.1% of GPi and 95.3% of VLa units responsive), and so does chance: one draw of random alignment still finds a decrease in 19.6% of GPi and 30.8% of VLa unit-directions (42.0% in the VLa data), with VLa chance decrease onsets at a median of +8.5 ms. A stricter threshold removes few of the chance decreases in VLa.

unit-link¶

The units the paper analysed can be found in the dandiset, and the dandiset's spike times and trials reproduce the per-unit peri-movement activity the paper's code stored.

In [11]:
k = paper[paper.keep]
print("Records kept by the paper's code, by animal and area:")
print(k.groupby(["monk", "area"]).size().unstack().to_string())
print("\nFound in every file they span:")
print(k.groupby(["monk", "area"]).complete.agg(["sum", "size", "mean"]).round(3).to_string())
print(f"Overall: {int(k.complete.sum())} of {len(k)} ({k.complete.mean():.1%}); records spanning 2 to 5 files: {int((k.n_files > 1).sum())}")
missing = k[~k.complete]
print("\nRecords not found, by file:")
print(missing.groupby("fname").size().to_string())
print("\nDandiset labels of the matched records, by the paper's area:")
print(pd.crosstab(k[k.complete].area, k[k.complete].labels.map(lambda l: "/".join(l))).to_string())

rows = []
for _, r in ud_paper.iterrows():
    monk, i = r.uid.split("/")[1], int(r.uid.split("/")[2])
    A, B = analy[monk]
    t = 0 if r.target == "Left" else 1
    ref = A.evt_sdf[i, t, :]
    rows.append({"area": r.area, "r": np.corrcoef(r.sdf, ref)[0, 1], "mean_ratio": r.sdf.mean() / ref.mean(),
                 "n": r.n_trials, "n_ref": np.shape(A.evt_spkt[i, t])[0],
                 "inc": r.inc_onset, "inc_ref": A.on_ind[i, t] - 1500, "dec": r.dec_onset, "dec_ref": B.on_ind[i, t] - 1500})
cmp_ = pd.DataFrame(rows)
print(f"\nUnit-directions compared: {len(cmp_)}")
print(f"SDF correlation with the stored SDF: median {cmp_.r.median():.4f}; r > 0.9 for {(cmp_.r > 0.9).mean():.1%}; "
      f"r > 0.99 for {(cmp_.r > 0.99).mean():.1%}; mean rate ratio median {cmp_.mean_ratio.median():.4f}")
print(f"Valid trial counts equal to the paper's: {(cmp_.n == cmp_.n_ref).mean():.1%}; "
      f"median absolute difference {np.median(np.abs(cmp_.n - cmp_.n_ref)):.0f}, max {np.max(np.abs(cmp_.n - cmp_.n_ref)):.0f}")
for kind in ("inc", "dec"):
    a, b = cmp_[kind].notna(), cmp_[kind + "_ref"].notna()
    both = a & b
    print(f"{kind}reases: detected here {a.sum()}, by the paper {b.sum()}, agreement on presence {(a == b).mean():.1%}; "
          f"onset difference where both, median absolute {np.median(np.abs(cmp_[kind][both] - cmp_[kind + '_ref'][both])):.0f} ms")
Records kept by the paper's code, by animal and area:
area  GPi  VLa
monk          
Gaia  105   66
Isis  104  119

Found in every file they span:
           sum  size   mean
monk area                  
Gaia GPi    95   105  0.905
     VLa    60    66  0.909
Isis GPi   104   104  1.000
     VLa   105   119  0.882
Overall: 364 of 394 (92.4%); records spanning 2 to 5 files: 97

Records not found, by file:
fname
G_140524_3                          1
G_140524_8                          1
G_140525_1                          1
G_140731_1/G_140731_2               1
G_140731_2                          2
G_140813_1                          1
G_140813_2                          3
G_140813_3                          2
G_140815_1                          2
G_140815_1/G_140815_2               1
G_140815_3                          1
I_160620_3/I_160620_4               1
I_160621_1                          1
I_160621_3                          1
I_160622_1                          1
I_160622_3                          2
I_160624_1/I_160624_2               1
I_160629_4                          1
I_160630_2/I_160630_3               1
I_160701_1                          1
I_160707_1/I_160707_2/I_160707_3    1
I_160817_1                          2
I_160818_2                          1

Dandiset labels of the matched records, by the paper's area:
labels  GPi  Thalamus  VLa
area                      
GPi     199         0    0
VLa       0        25  140

Unit-directions compared: 724
SDF correlation with the stored SDF: median 0.9992; r > 0.9 for 94.6%; r > 0.99 for 89.5%; mean rate ratio median 1.0003
Valid trial counts equal to the paper's: 62.6%; median absolute difference 0, max 64
increases: detected here 538, by the paper 524, agreement on presence 90.9%; onset difference where both, median absolute 7 ms
decreases: detected here 330, by the paper 286, agreement on presence 80.7%; onset difference where both, median absolute 11 ms

Result: reproduced. Of the 394 records the paper's code keeps, 364 (92.4%) are found among the dandiset's curated units in every file they span (Isis GPi 104 of 104, Gaia GPi 95 of 105, Gaia VLa 60 of 66, Isis VLa 105 of 119). For the 724 matched unit-directions, the SDF computed from the dandiset correlates with the stored one at a median r of 0.9992, above 0.9 for 94.6%, with a median rate ratio of 1.0003. Valid trial counts equal the paper's for 62.6% of unit-directions (median difference 0). Detection agrees on the presence of an increase for 90.9% and of a decrease for 80.7% of unit-directions, with median onset differences of 7 and 11 ms. Of the matched units the paper calls VLa, 25 are labelled "Thalamus" in the dandiset; 97 records span 2 to 5 files, which the dandiset does not link.

table1-unit-counts¶

209 single units met the criteria to be included as GPi neurons (105 in NHP G, 104 in NHP I) and 185 as VLa neurons (66 and 119).

In [12]:
counts = units.groupby(["area", "subject"]).size().unstack()
counts["total"] = counts.sum(axis=1)
print("Dandiset rule (one unit per file):")
print(counts.to_string())
for a, ref in (("GPi", [209]), ("VLa", [185, 218])):
    n = int(counts.loc[a, "total"])
    print(f"{a}: {n}; paper {ref}; within 10%: {[abs(n - x) <= 0.1 * x for x in ref]}")
day = units.session.str.extract(r"-(\d{6})-")[0]
distinct = units.assign(day=day).dropna(subset=["channel"]).drop_duplicates(["subject", "day", "area", "channel", "sort"])
print("\nVariant, units with the same channel and sort letter on the same day taken as one neuron (an assumption):")
print(distinct.groupby(["area", "subject"]).size().unstack().assign(total=lambda d: d.sum(axis=1)).to_string())
print("\nVariant, the paper's records found in the dandiset:")
print(paper[paper.keep & paper.complete].groupby(["area", "monk"]).size().unstack().assign(total=lambda d: d.sum(axis=1)).to_string())
in_paper_file = units.key.isin({f for x in paper.fname for f in x.split("/")})
print(f"\nDandiset-rule units in files the paper used: {int(in_paper_file.sum())} of {len(units)}")
Dandiset rule (one unit per file):
subject  Gaia  Isis  total
area                      
GPi       137   131    268
VLa        78   201    279
GPi: 268; paper [209]; within 10%: [False]
VLa: 279; paper [185, 218]; within 10%: [False, False]

Variant, units with the same channel and sort letter on the same day taken as one neuron (an assumption):
subject  Gaia  Isis  total
area                      
GPi        46    86    132
VLa        42   144    186

Variant, the paper's records found in the dandiset:
monk  Gaia  Isis  total
area                   
GPi     95   104    199
VLa     60   105    165

Dandiset-rule units in files the paper used: 518 of 547

Result: not reproduced. The dandiset rule gives 268 GPi units (Gaia 137, Isis 131) and 279 VLa units (78 and 201), against 209 and 185 (or 218 in the text); neither is within 10%. Counting units with the same channel and sort letter on the same day once gives 132 GPi and 186 VLa units (the presumed neurons above, a merge that joins some units the paper kept apart), and the paper's own records found in the dandiset are 199 and 165. The dandiset holds more GPi and VLa-labelled units than the paper analysed (518 of the 547 are in files the paper used) and does not say which ones the paper kept, nor which units in consecutive files are one neuron.

table1-rest-rate¶

The resting firing rate of GPi neurons (mean 70.1 spikes/s) is significantly higher than that of VLa neurons (13.5 spikes/s), and the rates are consistent between the two animals.

In [13]:
def rest_table(df):
    t = df.groupby(["area", "subject"]).rest_rate.agg(["mean", "std", "size"])
    t2 = df.groupby("area").rest_rate.agg(["mean", "std", "size"]).assign(subject="all").set_index("subject", append=True)
    return pd.concat([t, t2]).sort_index()

print("Rest rate (spikes/s), mean SDF from -1500 to -301 ms, over unit-directions. Dandiset rule:")
print(rest_table(ud).round(2).to_string())
gp, vl = ud[ud.area == "GPi"].rest_rate, ud[ud.area == "VLa"].rest_rate
res = stats.ranksums(gp, vl)
print(f"GPi {gp.mean():.2f} against VLa {vl.mean():.2f}; rank sum z = {res.statistic:.2f}, p = {res.pvalue:.2g}")
for a in ("GPi", "VLa"):
    x = ud[ud.area == a]
    p = stats.ranksums(x[x.subject == "Gaia"].rest_rate, x[x.subject == "Isis"].rest_rate).pvalue
    print(f"{a}: Gaia against Isis, rank sum p = {p:.3g}")
print("\nRange of rest rates (spikes/s):", ud.groupby("area").rest_rate.agg(["min", "max"]).round(1).to_dict("index"))
print("\nVariant, the paper's records:")
print(rest_table(ud_paper).round(2).to_string())
print(f"rank sum p = {stats.ranksums(ud_paper[ud_paper.area == 'GPi'].rest_rate, ud_paper[ud_paper.area == 'VLa'].rest_rate).pvalue:.2g}")
print("\nVariant, one record per presumed neuron:")
print(rest_table(ud_m).round(2).to_string())
print(f"rank sum p = {stats.ranksums(ud_m[ud_m.area == 'GPi'].rest_rate, ud_m[ud_m.area == 'VLa'].rest_rate).pvalue:.2g}; "
      f"Gaia against Isis in GPi p = {stats.ranksums(ud_m[(ud_m.area == 'GPi') & (ud_m.subject == 'Gaia')].rest_rate, ud_m[(ud_m.area == 'GPi') & (ud_m.subject == 'Isis')].rest_rate).pvalue:.3g}")
Rest rate (spikes/s), mean SDF from -1500 to -301 ms, over unit-directions. Dandiset rule:
               mean    std  size
area subject                    
GPi  Gaia     73.17  21.55   272
     Isis     78.25  29.12   259
     all      75.65  25.63   531
VLa  Gaia     12.29  11.85   154
     Isis     12.97  11.54   398
     all      12.78  11.62   552
GPi 75.65 against VLa 12.78; rank sum z = 27.93, p = 1.2e-171
GPi: Gaia against Isis, rank sum p = 0.0409
VLa: Gaia against Isis, rank sum p = 0.531

Range of rest rates (spikes/s): {'GPi': {'min': 13.2, 'max': 152.3}, 'VLa': {'min': 0.3, 'max': 80.1}}

Variant, the paper's records:
               mean    std  size
area subject                    
GPi  Gaia     69.12  25.02   190
     Isis     59.75  33.70   205
     all      64.26  30.17   395
VLa  Gaia     15.54  11.12   120
     Isis     12.38   9.89   209
     all      13.53  10.45   329
rank sum p = 4.3e-94

Variant, one record per presumed neuron:
               mean    std  size
area subject                    
GPi  Gaia     74.23  21.63    92
     Isis     74.21  28.25   171
     all      74.22  26.08   263
VLa  Gaia     11.22   8.25    83
     Isis     13.22  11.49   286
     all      12.77  10.86   369
rank sum p = 3.9e-98; Gaia against Isis in GPi p = 0.691

Result: reproduced. The mean rest rate over unit-directions is 75.65 spikes/s in GPi (SD 25.63, 531) and 12.78 in VLa (SD 11.62, 552), both within 10% of 70.1 and 13.5, and GPi is higher (rank sum p = 1.2e-171). The animals differ slightly in GPi (73.17 and 78.25 spikes/s, p = 0.041), where the paper reports no difference, and not in VLa (p = 0.53); with one record per presumed neuron they do not differ in GPi (74.23 and 74.21, p = 0.69). With the paper's records the means are 64.26 and 13.53 spikes/s, and with one record per presumed neuron 74.22 and 12.77.

table1-ap-width¶

Action potentials of GPi neurons are shorter than those of VLa neurons (trough to peak 0.24 against 0.50 ms; rank sum p = 1e-33).

In [14]:
from concurrent.futures import ThreadPoolExecutor
started = time.time()
jobs = {}
for r in dand_records:
    if r["uid"] not in kept or np.isnan(r["channel"]):
        continue
    s = r["sess"]
    rows_ = np.flatnonzero(pd.to_numeric(s["electrodes"].channel_ids, errors="coerce").to_numpy() == r["channel"])
    for name, p in s["processed"].items():
        cols = np.flatnonzero(np.isin(p["electrodes"], rows_))
        if len(cols) == 1:
            jobs.setdefault((s["url"], name), (p, []))[1].append((r["uid"], int(cols[0]), r["segments"][0][0]))
            break

def width_job(item):
    (url, name), (p, us) = item
    return g.ap_widths(url, name, p["rate"], p["start"], p["chunks"][0], us)

with ThreadPoolExecutor(16) as ex:
    widths = pd.DataFrame([w for ws in ex.map(width_job, jobs.items()) for w in ws])
widths = widths.merge(units[["uid", "area", "subject"]], on="uid")
print(f"Units with a channel in a filtered series: {len(widths)} of {len(units)}, in {len(jobs)} file-series; widths measured: "
      f"{int(widths.width_ms.notna().sum())} ({time.time() - started:.0f} s)")
print("Spikes per unit used: median", int(widths.n_spikes.median()), ", min", int(widths.n_spikes.min()))
print(widths.groupby(["area", "subject"]).width_ms.agg(["mean", "std", "median", "size"]).round(3).to_string())
print(widths.groupby("area").width_ms.agg(["mean", "std", "median", "min", "max", "size"]).round(3).to_string())
w_gp, w_vl = widths[widths.area == "GPi"].width_ms.dropna(), widths[widths.area == "VLa"].width_ms.dropna()
res = stats.ranksums(w_gp, w_vl)
print(f"GPi {w_gp.mean():.3f} ms against VLa {w_vl.mean():.3f} ms; rank sum z = {res.statistic:.2f}, p = {res.pvalue:.2g}")
print("Sampling interval of the trace: %.4f ms" % (1000 / list(sessions[0]["processed"].values())[0]["rate"]))
fig, ax = plt.subplots(figsize=(5, 3))
ax.hist([w_gp, w_vl], bins=np.arange(0, 1.2, 0.041), label=["GPi", "VLa"])
ax.set_xlabel("trough to peak (ms)"); ax.set_ylabel("units"); ax.legend()
print("Units outside the axes (above 1.2 ms):", int((widths.width_ms > 1.2).sum()))
print(f"Widths of 0 (peak at the trough sample): {int((widths.width_ms == 0).sum())}; peak at the last sample of the 1.2 ms "
      f"window (width capped): {int(widths.at_end.sum())}; not measured (fewer than 10 spikes in the chunk): {int(widths.width_ms.isna().sum())}")
wz = widths[(widths.width_ms > 0) & ~widths.at_end]
print(f"Variant, without widths of 0 or capped: GPi {wz[wz.area == 'GPi'].width_ms.mean():.3f} ms (n {int((wz.area == 'GPi').sum())}), "
      f"VLa {wz[wz.area == 'VLa'].width_ms.mean():.3f} ms (n {int((wz.area == 'VLa').sum())}); rank sum p = "
      f"{stats.ranksums(wz[wz.area == 'GPi'].width_ms, wz[wz.area == 'VLa'].width_ms).pvalue:.2g}")
Units with a channel in a filtered series: 547 of 547, in 205 file-series; widths measured: 532 (263 s)
Spikes per unit used: median 273 , min 0
               mean    std  median  size
area subject                            
GPi  Gaia     0.239  0.064   0.246   137
     Isis     0.256  0.133   0.205   131
VLa  Gaia     0.489  0.223   0.532    78
     Isis     0.518  0.218   0.573   201
       mean    std  median    min    max  size
area                                          
GPi   0.247  0.104   0.246  0.000  0.983   268
VLa   0.509  0.219   0.553  0.082  1.065   279
GPi 0.247 ms against VLa 0.509 ms; rank sum z = -12.76, p = 2.9e-37
Sampling interval of the trace: 0.0410 ms
Units outside the axes (above 1.2 ms): 0
Widths of 0 (peak at the trough sample): 1; peak at the last sample of the 1.2 ms window (width capped): 9; not measured (fewer than 10 spikes in the chunk): 15
Variant, without widths of 0 or capped: GPi 0.249 ms (n 266), VLa 0.499 ms (n 257); rank sum p = 1.9e-35
No description has been provided for this image

Result: reproduced. Trough-to-peak widths from the filtered traces average 0.247 ms in GPi and 0.509 ms in VLa (532 of 547 units measured), within 10% of 0.24 and 0.50 ms, and GPi widths are smaller (rank sum p = 2.9e-37). One width is 0, 9 reach the end of the 1.2 ms window, and 15 units have fewer than 10 spikes in the chunk read; without the 0 and capped widths the means are 0.249 and 0.499 ms (p = 1.9e-35). The trace is sampled every 0.041 ms, and the 200 Hz high-pass filter of the dandiset differs from the paper's 300 Hz.

hold-period-ramps¶

The firing rate of most units ramps during the start-position hold period (97% of GPi and 98% of VLa units with a significant linear trend); slopes are distributed symmetrically around zero, without a difference between GPi and VLa.

In [15]:
def ramps(df):
    per_unit = df.groupby(["uid", "area"]).slope_sig.any().groupby("area").agg(["mean", "sum", "size"])
    return per_unit

for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper)):
    t = ramps(df)
    print(name, "- units with a significant control-period slope in at least one direction:")
    print(t.assign(percent=lambda d: (100 * d["mean"]).round(1)).to_string())
    gs, vs = df[df.area == "GPi"].slope_per_s2, df[df.area == "VLa"].slope_per_s2
    print(f"  mean slope (spikes/s2) GPi {gs.mean():.3f}, VLa {vs.mean():.3f}; medians {gs.median():.3f}, {vs.median():.3f}; "
          f"rank sum p = {stats.ranksums(gs, vs).pvalue:.3g}")
    sig = df[df.slope_sig]
    print(f"  significant unit-direction slopes positive: {(sig.slope_per_s2 > 0).mean():.1%} of {len(sig)}")
print("By animal (dandiset rule):")
print(ud.groupby(["uid", "area", "subject"]).slope_sig.any().groupby(["area", "subject"]).mean().round(3).to_string())
print("Variant, significance at alpha 0.01 (99% CI) instead of 0.05:")
def sig01(df):
    out = []
    for _, r in df.iterrows():
        cue = int(round(1500 - r.median_rt_ms))
        x = np.arange(cue - 700, cue)
        lr = stats.linregress(x, r.sdf[x])
        out.append(lr.pvalue < 0.01)
    return pd.Series(out, index=df.index)
ud["slope_sig01"] = sig01(ud)
print(ud.groupby(["uid", "area"]).slope_sig01.any().groupby("area").mean().round(3).to_string())
print("Chance (random alignment, 3 draws), units with a significant slope:",
      nul.groupby(["rep", "uid", "area"]).slope_sig.any().groupby("area").mean().round(3).to_dict())
pn = ud.groupby(["neuron", "area"]).slope_per_s2.mean().reset_index()
print(f"One mean slope per presumed neuron (dandiset rule): GPi against VLa rank sum p = "
      f"{stats.ranksums(pn[pn.area == 'GPi'].slope_per_s2, pn[pn.area == 'VLa'].slope_per_s2).pvalue:.3g}")
Dandiset rule - units with a significant control-period slope in at least one direction:
          mean  sum  size  percent
area                              
GPi   0.985075  264   268     98.5
VLa   0.985663  275   279     98.6
  mean slope (spikes/s2) GPi -0.598, VLa 0.233; medians -0.531, 0.176; rank sum p = 0.0415
  significant unit-direction slopes positive: 49.9% of 969
Variant, one record per presumed neuron - units with a significant control-period slope in at least one direction:
          mean  sum  size  percent
area                              
GPi   0.992424  131   132     99.2
VLa   0.983871  183   186     98.4
  mean slope (spikes/s2) GPi -0.581, VLa 0.269; medians -0.739, 0.199; rank sum p = 0.0128
  significant unit-direction slopes positive: 50.5% of 562
Variant, the paper's records - units with a significant control-period slope in at least one direction:
          mean  sum  size  percent
area                              
GPi   0.994975  198   199     99.5
VLa   0.987879  163   165     98.8
  mean slope (spikes/s2) GPi -0.383, VLa -0.011; medians -0.353, 0.199; rank sum p = 0.187
  significant unit-direction slopes positive: 49.6% of 639
By animal (dandiset rule):
area  subject
GPi   Gaia       0.993
      Isis       0.977
VLa   Gaia       0.974
      Isis       0.990
Variant, significance at alpha 0.01 (99% CI) instead of 0.05:
area
GPi    0.970
VLa    0.975
Chance (random alignment, 3 draws), units with a significant slope: {'GPi': 0.984, 'VLa': 0.99}
One mean slope per presumed neuron (dandiset rule): GPi against VLa rank sum p = 0.0326

Result: partially reproduced. 98.5% of GPi and 98.6% of VLa units have a significant control-period slope in at least one direction, within 10% of 97% and 98% (97.0% and 97.5% at alpha 0.01), and 49.9% of significant slopes are positive. The test finds as many with random alignment (98.4% and 99.0%), so these fractions do not show that the rate ramps more than noise would make it appear to. The slopes of GPi and VLa differ by the rank sum test (mean -0.598 against 0.233 spikes/s2, p = 0.042), where the paper finds no difference (means -0.05 and -0.14, p = 0.29); they also differ with one record per presumed neuron (p = 0.013) and with one mean slope per presumed neuron (p = 0.033). With the paper's records the fractions are 99.5% and 98.8% and the slopes do not differ (p = 0.187), which would meet the criterion, so the outcome depends on the selection of units. The criterion did not define a partial outcome; the first part holds and the second does not.

mvt-responsive-fraction¶

Nearly all units change their discharge around movement for at least one direction (GPi 99%; VLa 88.1% in Table 1, 94% in the text).

In [16]:
def responsive(df):
    return df.assign(resp=df.form != "none").groupby(["uid", "area", "subject"]).resp.any()

for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper)):
    r = responsive(df)
    t = r.groupby("area").agg(["sum", "size", "mean"])
    print(name); print(t.assign(percent=lambda d: (100 * d["mean"]).round(1)).to_string())
    print(r.groupby(["area", "subject"]).mean().round(3).to_string())
print("\nUnit-directions with a response (dandiset rule):", (ud.form != "none").groupby(ud.area).agg(["sum", "size"]).to_dict("index"))
print("Variant, alpha 0.0001 instead of 0.001 (stricter):")
strict = []
for _, r in ud.iterrows():
    f_ = g.detect(r.sdf, r.median_rt_ms, alpha=0.0001)
    strict.append(np.isfinite(f_["onset"]))
print("  units with an increase in at least one direction:", ud.assign(s=strict).groupby(["uid", "area"]).s.any().groupby("area").mean().round(3).to_dict())
print("Chance (random alignment, 3 draws), responsive units:",
      nul.assign(resp=nul.form != "none").groupby(["rep", "uid", "area"]).resp.any().groupby("area").mean().round(3).to_dict())
Dandiset rule
      sum  size      mean  percent
area                              
GPi   264   268  0.985075     98.5
VLa   272   279  0.974910     97.5
area  subject
GPi   Gaia       0.971
      Isis       1.000
VLa   Gaia       0.974
      Isis       0.975
Variant, one record per presumed neuron
      sum  size      mean  percent
area                              
GPi   132   132  1.000000    100.0
VLa   183   186  0.983871     98.4
area  subject
GPi   Gaia       1.000
      Isis       1.000
VLa   Gaia       0.976
      Isis       0.986
Variant, the paper's records
      sum  size      mean  percent
area                              
GPi   199   199  1.000000    100.0
VLa   164   165  0.993939     99.4
area  subject
GPi   Gaia       1.000
      Isis       1.000
VLa   Gaia       0.983
      Isis       1.000

Unit-directions with a response (dandiset rule): {'GPi': {'sum': 497, 'size': 531}, 'VLa': {'sum': 497, 'size': 552}}
Variant, alpha 0.0001 instead of 0.001 (stricter):
  units with an increase in at least one direction: {'GPi': 0.81, 'VLa': 0.774}
Chance (random alignment, 3 draws), responsive units: {'GPi': 0.623, 'VLa': 0.708}

Result: reproduced. 98.5% of GPi units (264 of 268) and 97.5% of VLa units (272 of 279) respond in at least one direction, against 62.3% and 70.8% with random alignment. GPi is within 10% of 99%. VLa is within 10% of the text's 94% but not of Table 1's 88.1%; the criterion accepts either reading. With alpha 0.0001 the fractions with an increase alone fall to 81.0% and 77.4%; with one record per presumed neuron the fractions are 100.0% and 98.4%.

increases-most-common¶

Monophasic increases are the most common form of response in both GPi and VLa (48.7% and 65.3% of responses), and increases make up most detected changes (61.5% and 68.8%, biphasic responses counted twice), more than decreases.

In [17]:
FORMS = ["increase", "decrease", "inc_first", "dec_first"]
def forms_table(df):
    r = df[df.form != "none"]
    t = pd.crosstab(r.area, r.form)[FORMS]
    pct = (100 * t.div(t.sum(axis=1), axis=0)).round(1)
    inc = t.increase + t.inc_first + t.dec_first
    changes = t.increase + t.decrease + 2 * (t.inc_first + t.dec_first)
    return t, pct, inc, changes

for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper),
                 ("Variant, alpha 0.0001", ud_strict)):
    t, pct, inc, changes = forms_table(df)
    print(name, "- responses by form (unit-directions):"); print(t.to_string()); print("percent:"); print(pct.to_string())
    for a in t.index:
        print(f"  {a}: increases {inc[a]} of {changes[a]} changes ({100 * inc[a] / changes[a]:.1f}%)")
    chi = stats.chi2_contingency(np.array([[inc[a], changes[a] - inc[a]] for a in t.index]))
    allinc, allchg = inc.sum(), changes.sum()
    print(f"  increases against decreases over both areas: {allinc} of {allchg}, binomial p = {stats.binomtest(int(allinc), int(allchg)).pvalue:.2g}; "
          f"GPi against VLa chi-squared p = {chi.pvalue:.3g}")
print("By animal (dandiset rule), percent monophasic increases:")
r = ud[ud.form != "none"]
print((100 * pd.crosstab([r.area, r.subject], r.form, normalize="index")).round(1)[FORMS].to_string())
Dandiset rule - responses by form (unit-directions):
form  increase  decrease  inc_first  dec_first
area                                          
GPi        233       120         86         58
VLa        253       128         86         30
percent:
form  increase  decrease  inc_first  dec_first
area                                          
GPi       46.9      24.1       17.3       11.7
VLa       50.9      25.8       17.3        6.0
  GPi: increases 377 of 641 changes (58.8%)
  VLa: increases 369 of 613 changes (60.2%)
  increases against decreases over both areas: 746 of 1254, binomial p = 1.9e-11; GPi against VLa chi-squared p = 0.66
Variant, one record per presumed neuron - responses by form (unit-directions):
form  increase  decrease  inc_first  dec_first
area                                          
GPi        106        53         56         39
VLa        175        85         67         22
percent:
form  increase  decrease  inc_first  dec_first
area                                          
GPi       41.7      20.9       22.0       15.4
VLa       50.1      24.4       19.2        6.3
  GPi: increases 201 of 349 changes (57.6%)
  VLa: increases 264 of 438 changes (60.3%)
  increases against decreases over both areas: 465 of 787, binomial p = 3.9e-07; GPi against VLa chi-squared p = 0.492
Variant, the paper's records - responses by form (unit-directions):
form  increase  decrease  inc_first  dec_first
area                                          
GPi        192        69         69         48
VLa        170        85         43         16
percent:
form  increase  decrease  inc_first  dec_first
area                                          
GPi       50.8      18.3       18.3       12.7
VLa       54.1      27.1       13.7        5.1
  GPi: increases 309 of 495 changes (62.4%)
  VLa: increases 229 of 373 changes (61.4%)
  increases against decreases over both areas: 538 of 868, binomial p = 1.7e-12; GPi against VLa chi-squared p = 0.811
Variant, alpha 0.0001 - responses by form (unit-directions):
form  increase  decrease  inc_first  dec_first
area                                          
GPi        235       127         76         49
VLa        250       132         77         23
percent:
form  increase  decrease  inc_first  dec_first
area                                          
GPi       48.3      26.1       15.6       10.1
VLa       51.9      27.4       16.0        4.8
  GPi: increases 360 of 612 changes (58.8%)
  VLa: increases 350 of 582 changes (60.1%)
  increases against decreases over both areas: 710 of 1194, binomial p = 6.6e-11; GPi against VLa chi-squared p = 0.687
By animal (dandiset rule), percent monophasic increases:
form          increase  decrease  inc_first  dec_first
area subject                                          
GPi  Gaia         53.6      23.0       15.3        8.1
     Isis         40.2      25.3       19.3       15.3
VLa  Gaia         48.9      19.5       22.6        9.0
     Isis         51.6      28.0       15.4        4.9

Result: partially reproduced. Monophasic increase is the most common form in both areas (GPi 46.9%, VLa 50.9% of responses), and increases make up 58.8% of GPi and 60.2% of VLa changes (746 of 1254 overall, binomial p = 1.9e-11). The GPi values are within 10% of 48.7% and 61.5%; the VLa values are below 65.3% and 68.8% by more than 10%, also with the paper's records (54.1% and 61.4%) and with one record per presumed neuron (50.1% and 60.3%) and at alpha 0.0001 (51.9% and 60.1%). With random alignment, monophasic decreases are the most common chance detection (24.1% and 31.6% of unit-directions), which pulls the share of increases down.

monophasic-more-in-vla¶

Monophasic changes are more common in VLa than in GPi (88.9% against 66.7% of responses), and polyphasic responses more common in GPi.

In [18]:
for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper)):
    r = df[df.form != "none"]
    mono = r.form.isin(["increase", "decrease"])
    t = pd.crosstab(r.area, mono)
    chi = stats.chi2_contingency(t.to_numpy())
    print(name); print((100 * mono.groupby(r.area).mean()).round(1).to_string())
    print(f"  counts (polyphasic, monophasic): {t.to_dict('index')}; chi-squared p = {chi.pvalue:.2g}")
r = ud[ud.form != "none"]
print("By animal (dandiset rule):", (100 * r.form.isin(["increase", "decrease"]).groupby([r.area, r.subject]).mean()).round(1).to_dict())
Dandiset rule
area
GPi    71.0
VLa    76.7
  counts (polyphasic, monophasic): {'GPi': {False: 144, True: 353}, 'VLa': {False: 116, True: 381}}; chi-squared p = 0.051
Variant, one record per presumed neuron
area
GPi    62.6
VLa    74.5
  counts (polyphasic, monophasic): {'GPi': {False: 95, True: 159}, 'VLa': {False: 89, True: 260}}; chi-squared p = 0.0023
Variant, the paper's records
area
GPi    69.0
VLa    81.2
  counts (polyphasic, monophasic): {'GPi': {False: 117, True: 261}, 'VLa': {False: 59, True: 255}}; chi-squared p = 0.00036
By animal (dandiset rule): {('GPi', 'Gaia'): 76.6, ('GPi', 'Isis'): 65.5, ('VLa', 'Gaia'): 68.4, ('VLa', 'Isis'): 79.7}

Result: not reproduced. Monophasic responses are 71.0% in GPi and 76.7% in VLa; the difference is not significant (chi-squared p = 0.051), and VLa is more than 10% below 88.9%. With the paper's records the values are 69.0% and 81.2% (p = 0.00036), both within tolerance; with one record per presumed neuron 62.6% and 74.5% (p = 0.0023), VLa outside tolerance. The difference comes with the selection of units and with how a neuron recorded in several files is counted.

integrated-change-positive¶

The mean integrated change in firing over the peri-movement epoch is positive in both GPi and VLa (3.89 and 1.75 spikes), significantly different from zero.

In [19]:
for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper)):
    print(name)
    for a in ("GPi", "VLa"):
        x = df[df.area == a].integrated
        w = stats.wilcoxon(x)
        print(f"  {a}: mean {x.mean():.2f} spikes (SD {x.std():.2f}), median {x.median():.2f}, n {len(x)}; signed rank p = {w.pvalue:.2g}; "
              f"range {x.min():.1f} to {x.max():.1f}")
    print("  by animal:", df.groupby(["area", "subject"]).integrated.mean().round(2).to_dict())
Dandiset rule
  GPi: mean 3.07 spikes (SD 11.20), median 3.21, n 531; signed rank p = 1.1e-10; range -35.4 to 41.7
  VLa: mean 1.50 spikes (SD 4.34), median 0.78, n 552; signed rank p = 3.7e-18; range -10.9 to 38.2
  by animal: {('GPi', 'Gaia'): 4.22, ('GPi', 'Isis'): 1.87, ('VLa', 'Gaia'): 2.48, ('VLa', 'Isis'): 1.12}
Variant, one record per presumed neuron
  GPi: mean 2.13 spikes (SD 10.87), median 2.53, n 263; signed rank p = 0.00039; range -35.4 to 40.4
  VLa: mean 1.34 spikes (SD 3.68), median 0.92, n 369; signed rank p = 1.9e-12; range -9.3 to 20.1
  by animal: {('GPi', 'Gaia'): 1.59, ('GPi', 'Isis'): 2.42, ('VLa', 'Gaia'): 2.47, ('VLa', 'Isis'): 1.02}
Variant, the paper's records
  GPi: mean 3.42 spikes (SD 10.10), median 2.64, n 395; signed rank p = 4.9e-12; range -35.4 to 41.7
  VLa: mean 1.97 spikes (SD 5.45), median 1.22, n 329; signed rank p = 2.6e-11; range -9.3 to 38.2
  by animal: {('GPi', 'Gaia'): 4.41, ('GPi', 'Isis'): 2.5, ('VLa', 'Gaia'): 3.95, ('VLa', 'Isis'): 0.83}

Result: partially reproduced. The mean integrated change is positive in GPi (3.07 spikes, signed rank p = 1.1e-10) and in VLa (1.50 spikes, p = 3.7e-18), but both are more than 10% below 3.89 and 1.75. With the paper's records they are 3.42 and 1.97 spikes, and with one record per presumed neuron 2.13 and 1.34 spikes (p = 0.00039 and 1.9e-12).

fig3a-vla-leads-gpi¶

Peri-movement changes begin later in GPi than in VLa (median onsets -47 and -90 ms).

In [20]:
def onsets(df, area, kind):
    col = {"inc": "inc_onset", "dec": "dec_onset"}
    if kind == "all":
        return pd.concat([df[df.area == area].inc_onset, df[df.area == area].dec_onset]).dropna()
    return df[df.area == area][col[kind]].dropna()

def compare(df, a1, k1, a2, k2, label):
    x, y = onsets(df, a1, k1), onsets(df, a2, k2)
    res = stats.ranksums(x, y)
    print(f"  {label}: {a1} {k1} median {x.median():.1f} ms (n {len(x)}), {a2} {k2} median {y.median():.1f} ms (n {len(y)}); "
          f"difference {x.median() - y.median():.1f} ms; rank sum z = {res.statistic:.2f}, p = {res.pvalue:.2g}")
    return x, y

for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper),
                 ("Variant, alpha 0.0001", ud_strict)):
    print(name)
    x, y = compare(df, "GPi", "all", "VLa", "all", "all changes")
for subj in ("Gaia", "Isis"):
    print("Dandiset rule,", subj)
    compare(ud[ud.subject == subj], "GPi", "all", "VLa", "all", "all changes")

def compare_per_neuron(df, a1, k1, a2, k2, label):
    """One onset per presumed neuron: the median of its onsets of the kind over directions and files."""
    def per(a, k):
        d = df[df.area == a]
        cols = ["inc_onset", "dec_onset"] if k == "all" else [f"{k}_onset"]
        v = pd.concat([d[["neuron", c]].rename(columns={c: "o"}) for c in cols]).dropna()
        return v.groupby("neuron").o.median()
    x, y = per(a1, k1), per(a2, k2)
    print(f"  {label}, one value per presumed neuron: {a1} {k1} median {x.median():.1f} ms (n {len(x)}), "
          f"{a2} {k2} median {y.median():.1f} ms (n {len(y)}); rank sum p = {stats.ranksums(x, y).pvalue:.2g}")

print("Dandiset rule, variants")
compare_per_neuron(ud, "GPi", "all", "VLa", "all", "all changes")
x, y = onsets(ud, "GPi", "all"), onsets(ud, "VLa", "all")
print(f"  all changes, one-sided rank sum (GPi later): p = {stats.ranksums(x, y, alternative='greater').pvalue:.2g}")
x, y = onsets(ud, "GPi", "all"), onsets(ud, "VLa", "all")
fig, ax = plt.subplots(figsize=(5, 3))
for v, lab in ((x, "GPi"), (y, "VLa")):
    ax.step(np.sort(v), np.arange(1, len(v) + 1) / len(v), label=lab)
ax.set_xlabel("onset from movement onset (ms)"); ax.set_ylabel("cumulative fraction"); ax.legend()
print("Onset range (ms):", int(min(x.min(), y.min())), "to", int(max(x.max(), y.max())), "; all inside the axes")
Dandiset rule
  all changes: GPi all median -48.0 ms (n 641), VLa all median -67.0 ms (n 613); difference 19.0 ms; rank sum z = 1.75, p = 0.079
Variant, one record per presumed neuron
  all changes: GPi all median -73.0 ms (n 349), VLa all median -72.0 ms (n 438); difference -1.0 ms; rank sum z = 0.06, p = 0.95
Variant, the paper's records
  all changes: GPi all median -49.0 ms (n 495), VLa all median -85.0 ms (n 373); difference 36.0 ms; rank sum z = 2.88, p = 0.004
Variant, alpha 0.0001
  all changes: GPi all median -42.5 ms (n 612), VLa all median -63.0 ms (n 582); difference 20.5 ms; rank sum z = 1.94, p = 0.052
Dandiset rule, Gaia
  all changes: GPi all median -48.5 ms (n 306), VLa all median -48.0 ms (n 175); difference -0.5 ms; rank sum z = 0.43, p = 0.67
Dandiset rule, Isis
  all changes: GPi all median -45.0 ms (n 335), VLa all median -77.0 ms (n 438); difference 32.0 ms; rank sum z = 1.39, p = 0.16
Dandiset rule, variants
  all changes, one value per presumed neuron: GPi all median -47.0 ms (n 132), VLa all median -53.0 ms (n 184); rank sum p = 0.44
  all changes, one-sided rank sum (GPi later): p = 0.04
Onset range (ms): -272 to 439 ; all inside the axes
No description has been provided for this image

Result: not reproduced. The median onset of all changes is -48 ms in GPi (641 changes) and -67 ms in VLa (613); GPi is later by 19 ms, but not significantly (rank sum p = 0.079; one-sided p = 0.04), and the VLa median is outside -90 +/- 9 ms. In Gaia the medians are equal (-48.5 and -48 ms). With one record per presumed neuron the medians are -73 and -72 ms (p = 0.95), and with one value per presumed neuron -47 and -53 ms (p = 0.44). With the paper's records the medians are -49 and -85 ms (p = 0.004), which would meet the criterion; the difference comes with the selection of units. At alpha 0.0001 they are -42.5 and -63 ms (p = 0.052).

fig3b-gpi-decr-lag-vla-incr¶

GPi decreases lag behind VLa increases (median onsets -27 and -88 ms).

In [21]:
for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper),
                 ("Variant, alpha 0.0001", ud_strict)):
    print(name)
    compare(df, "GPi", "dec", "VLa", "inc", "GPi decreases against VLa increases")
for subj in ("Gaia", "Isis"):
    print("Dandiset rule,", subj)
    compare(ud[ud.subject == subj], "GPi", "dec", "VLa", "inc", "GPi decreases against VLa increases")
print("Dandiset rule, variant")
compare_per_neuron(ud, "GPi", "dec", "VLa", "inc", "GPi decreases against VLa increases")
Dandiset rule
  GPi decreases against VLa increases: GPi dec median -29.5 ms (n 264), VLa inc median -84.0 ms (n 369); difference 54.5 ms; rank sum z = 3.70, p = 0.00021
Variant, one record per presumed neuron
  GPi decreases against VLa increases: GPi dec median -46.5 ms (n 148), VLa inc median -92.0 ms (n 264); difference 45.5 ms; rank sum z = 2.56, p = 0.011
Variant, the paper's records
  GPi decreases against VLa increases: GPi dec median -36.0 ms (n 186), VLa inc median -85.0 ms (n 229); difference 49.0 ms; rank sum z = 3.39, p = 0.00069
Variant, alpha 0.0001
  GPi decreases against VLa increases: GPi dec median -30.0 ms (n 252), VLa inc median -81.5 ms (n 350); difference 51.5 ms; rank sum z = 3.46, p = 0.00054
Dandiset rule, Gaia
  GPi decreases against VLa increases: GPi dec median 0.0 ms (n 115), VLa inc median -95.0 ms (n 107); difference 95.0 ms; rank sum z = 3.20, p = 0.0014
Dandiset rule, Isis
  GPi decreases against VLa increases: GPi dec median -44.0 ms (n 149), VLa inc median -81.0 ms (n 262); difference 37.0 ms; rank sum z = 1.92, p = 0.055
Dandiset rule, variant
  GPi decreases against VLa increases, one value per presumed neuron: GPi dec median -1.5 ms (n 102), VLa inc median -71.2 ms (n 154); rank sum p = 0.00074

Result: reproduced. GPi decreases begin at a median of -29.5 ms (264) and VLa increases at -84 ms (369), both within tolerance of -27 and -88 ms, and GPi decreases are later (rank sum p = 0.00021). The GPi median is 0.2 ms inside the edge of its tolerance (-29.7 to -24.3 ms). The lag holds in Gaia (0 against -95 ms, p = 0.0014) and in Isis without significance (-44 against -81 ms, p = 0.055). It also holds with the paper's records (-36 against -85 ms, p = 0.00069), with one record per presumed neuron (-46.5 against -92 ms, p = 0.011), with one value per presumed neuron (-1.5 against -71.2 ms, p = 0.00074) and at alpha 0.0001 (-30 against -81.5 ms, p = 0.00054), but in all four the GPi median is outside its tolerance. With the paper's records the outcome would be partially reproduced, so the match of the medians depends on the selection of units and on how they are counted.

fig3c-vla-decr-lead-gpi-incr¶

VLa decreases precede GPi increases (median onsets -108 and -62 ms).

In [22]:
for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper),
                 ("Variant, alpha 0.0001", ud_strict)):
    print(name)
    compare(df, "GPi", "inc", "VLa", "dec", "GPi increases against VLa decreases")
for subj in ("Gaia", "Isis"):
    print("Dandiset rule,", subj)
    compare(ud[ud.subject == subj], "GPi", "inc", "VLa", "dec", "GPi increases against VLa decreases")
print("Dandiset rule, variant")
compare_per_neuron(ud, "GPi", "inc", "VLa", "dec", "GPi increases against VLa decreases")
Dandiset rule
  GPi increases against VLa decreases: GPi inc median -69.0 ms (n 377), VLa dec median -19.0 ms (n 244); difference -50.0 ms; rank sum z = -1.38, p = 0.17
Variant, one record per presumed neuron
  GPi increases against VLa decreases: GPi inc median -92.0 ms (n 201), VLa dec median -8.0 ms (n 174); difference -84.0 ms; rank sum z = -2.64, p = 0.0082
Variant, the paper's records
  GPi increases against VLa decreases: GPi inc median -73.0 ms (n 309), VLa dec median -86.0 ms (n 144); difference 13.0 ms; rank sum z = 1.28, p = 0.2
Variant, alpha 0.0001
  GPi increases against VLa decreases: GPi inc median -57.5 ms (n 360), VLa dec median -21.5 ms (n 232); difference -36.0 ms; rank sum z = -1.03, p = 0.3
Dandiset rule, Gaia
  GPi increases against VLa decreases: GPi inc median -75.0 ms (n 191), VLa dec median 12.5 ms (n 68); difference -87.5 ms; rank sum z = -2.99, p = 0.0028
Dandiset rule, Isis
  GPi increases against VLa decreases: GPi inc median -46.0 ms (n 186), VLa dec median -68.0 ms (n 176); difference 22.0 ms; rank sum z = -0.08, p = 0.94
Dandiset rule, variant
  GPi increases against VLa decreases, one value per presumed neuron: GPi inc median -45.0 ms (n 115), VLa dec median 20.5 ms (n 124); rank sum p = 0.021

Result: not reproduced. GPi increases begin at a median of -69 ms (377) and VLa decreases at -19 ms (244); VLa decreases do not lead (rank sum p = 0.17), and the VLa median is far from -108 ms. In Gaia VLa decreases come later than GPi increases (12.5 against -75 ms, p = 0.0028), and so they do with one record per presumed neuron (-8 against -92 ms, p = 0.0082) and with one value per presumed neuron (20.5 against -45 ms, p = 0.021). With the paper's records VLa decreases lead by 13 ms without significance (-86 against -73 ms, p = 0.2). At alpha 0.0001 the medians are -21.5 (VLa) and -57.5 ms (GPi), p = 0.3. With random alignment the detector finds a decrease in 33.7% of VLa unit-directions (44.2% in the data), with a median onset of -8 ms, so many of the VLa decreases here may be chance detections, which would pull their median late; at alpha 0.0001 chance still gives a decrease in 30.8% of VLa unit-directions, so the stricter threshold does not remove them.

gating-conflict-fraction¶

Comparing every GPi response with every VLa response, 96% of comparisons conflict with gating; only 4% have a GPi onset 0 to 50 ms before the VLa onset with the opposite sign.

In [23]:
def gating(df, lo=0, hi=50):
    def changes(a):
        d = df[df.area == a]
        return np.r_[d.inc_onset.dropna(), d.dec_onset.dropna()], np.r_[np.ones(d.inc_onset.notna().sum()), -np.ones(d.dec_onset.notna().sum())]
    gt, gs = changes("GPi")
    vt, vs = changes("VLa")
    lead = vt[None, :] - gt[:, None]
    fits = (lead >= lo) & (lead <= hi) & (gs[:, None] != vs[None, :])
    return fits.size, int(fits.sum())

for name, df in (("Dandiset rule", ud), ("Variant, one record per presumed neuron", ud_m), ("Variant, the paper's records", ud_paper)):
    n, fit = gating(df)
    print(f"{name}: {n} comparisons, {fit} fit gating; conflicting {100 * (1 - fit / n):.1f}%")
for lo, hi in ((0, 20), (0, 100), (0, 200)):
    n, fit = gating(ud, lo, hi)
    print(f"  variant, GPi leading by {lo} to {hi} ms: conflicting {100 * (1 - fit / n):.1f}%")
n, fit = gating(ud)
rng = np.random.default_rng(1)
d = ud.copy()
null = []
for _ in range(200):
    d["inc_onset"] = rng.permutation(ud.inc_onset.to_numpy())
    d["dec_onset"] = rng.permutation(ud.dec_onset.to_numpy())
    nn, ff = gating(d)
    null.append(100 * (1 - ff / nn))
print(f"Reference: onsets shuffled across unit-directions, conflicting {np.mean(null):.1f}% (2.5 to 97.5 percentile {np.percentile(null, 2.5):.1f} to {np.percentile(null, 97.5):.1f})")
Dandiset rule: 392933 comparisons, 17921 fit gating; conflicting 95.4%
Variant, one record per presumed neuron: 152862 comparisons, 7493 fit gating; conflicting 95.1%
Variant, the paper's records: 184635 comparisons, 8098 fit gating; conflicting 95.6%
  variant, GPi leading by 0 to 20 ms: conflicting 98.0%
  variant, GPi leading by 0 to 100 ms: conflicting 91.9%
  variant, GPi leading by 0 to 200 ms: conflicting 86.6%
Reference: onsets shuffled across unit-directions, conflicting 95.1% (2.5 to 97.5 percentile 95.0 to 95.2)

Result: reproduced. 95.4% of the 392,933 GPi-VLa comparisons conflict with gating (96 +/- 9.6%); 95.6% with the paper's records and 95.1% with one record per presumed neuron. The number says little: with onsets shuffled across unit-directions it is 95.1% (95.0 to 95.2), and it follows the width of the gating window (98.0% for 0 to 20 ms, 86.6% for 0 to 200 ms).

directional-units¶

A majority of GPi neurons (109 of 206) and some VLa neurons (56 of 179) respond significantly more strongly to one movement direction.

In [24]:
def directional(df, records):
    seg = {r["uid"]: r["segments"] for r in records}
    out = []
    for uid, d in df[df.form != "none"].groupby("uid"):
        first = np.nanmin(np.r_[d.inc_onset, d.dec_onset]) / 1000.0
        c = {}
        for target in ("Left", "Right"):
            vals = []
            for spikes, t in seg[uid]:
                tt = t[t.valid & (t.target == target)]
                vals.append(window_counts(spikes, tt.movement_start_time.to_numpy(), first, first + 0.3))
            c[target] = np.concatenate(vals)
        p = stats.ranksums(c["Left"], c["Right"]).pvalue if min(len(c["Left"]), len(c["Right"])) else np.nan
        out.append({"uid": uid, "area": d.area.iloc[0], "subject": d.subject.iloc[0], "p": p})
    return pd.DataFrame(out)

for name, df, recs in (("Dandiset rule", ud, dand_records), ("Variant, one record per presumed neuron", ud_m, merged_records),
                       ("Variant, the paper's records", ud_paper, paper_records)):
    dd = directional(df, recs)
    t = dd.assign(dir=dd.p < 0.005).groupby("area").dir.agg(["sum", "size", "mean"])
    print(name); print(t.assign(percent=lambda x: (100 * x["mean"]).round(1)).to_string())
    chi = stats.chi2_contingency(pd.crosstab(dd.area, dd.p < 0.005).to_numpy())
    print(f"  GPi against VLa chi-squared p = {chi.pvalue:.2g}")
    if name == "Dandiset rule":
        print("  by animal:", dd.assign(dir=dd.p < 0.005).groupby(["area", "subject"]).dir.mean().round(3).to_dict())
        print("  variant p < 0.05:", dd.assign(dir=dd.p < 0.05).groupby("area").dir.mean().round(3).to_dict())
Dandiset rule
      sum  size      mean  percent
area                              
GPi   122   264  0.462121     46.2
VLa    66   272  0.242647     24.3
  GPi against VLa chi-squared p = 1.7e-07
  by animal: {('GPi', 'Gaia'): 0.391, ('GPi', 'Isis'): 0.534, ('VLa', 'Gaia'): 0.237, ('VLa', 'Isis'): 0.245}
  variant p < 0.05: {'GPi': 0.564, 'VLa': 0.419}
Variant, one record per presumed neuron
      sum  size      mean  percent
area                              
GPi    71   132  0.537879     53.8
VLa    40   183  0.218579     21.9
  GPi against VLa chi-squared p = 9.8e-09
Variant, the paper's records
      sum  size      mean  percent
area                              
GPi    96   199  0.482412     48.2
VLa    44   164  0.268293     26.8
  GPi against VLa chi-squared p = 4.9e-05

Result: partially reproduced. GPi units are directional more often than VLa units (46.2%, 122 of 264, against 24.3%, 66 of 272; chi-squared p = 1.7e-07), but both fractions are more than 10% below 52.9% and 31.3%. With the paper's records they are 48.2% and 26.8%, and with one record per presumed neuron 53.8% and 21.9% (p = 9.8e-09).

Pairs of simultaneously recorded GPi and VLa units¶

Pairs are the GPi and VLa units of the dandiset rule in the same file. Windows: rest is 1.5 to 1.0 s before movement onset, movement 0.2 s before to 0.3 s after, in valid trials. The dandiset rule gives 338 pairs in 76 files. As a variant, a GPi and a VLa record of the paper that share a file make a pair in that file: 329 pairs in 73 files (313 distinct pairs of records). Neither matches the paper's 427 pairs over whole recordings; this attempt did not find out how the paper counted its pairs.

In [25]:
pairs = []
for s in sessions:
    us = units[units.session == s["session_id"]]
    gps, vls = us[us.area == "GPi"], us[us.area == "VLa"]
    for _, a in gps.iterrows():
        for _, b in vls.iterrows():
            pairs.append({"session": s["session_id"], "subject": s["subject"], "gpi": a.uid, "vla": b.uid})
pairs = pd.DataFrame(pairs)
spk = {r["uid"]: r["segments"][0][0] for r in dand_records}
sess_of = {s["session_id"]: s for s in sessions}
print(f"GPi-VLa pairs in the same file: {len(pairs)} in {pairs.session.nunique()} files")
print(pairs.groupby("subject").size().to_string())

# Variant, the paper's records: a GPi and a VLa record that share a file make a pair in that file.
analysed = set(ud_paper.uid)
pairs_paper, pair_neuron = [], dict(uid2neuron)
for s in sessions:
    sid = s["session_id"]
    if s["vt"] is None:
        continue
    here = {a: [(r, r["sessions"].index(sid)) for r in paper_records
                if r["uid"] in analysed and r["area"] == a and sid in r["sessions"]] for a in ("GPi", "VLa")}
    for ra, ka in here["GPi"]:
        for rb, kb in here["VLa"]:
            ga, vb = f"{ra['uid']}@{sid}", f"{rb['uid']}@{sid}"
            spk[ga], spk[vb] = ra["segments"][ka][0], rb["segments"][kb][0]
            pair_neuron[ga], pair_neuron[vb] = ra["uid"], rb["uid"]
            pairs_paper.append({"session": sid, "subject": s["subject"], "gpi": ga, "vla": vb})
pairs_paper = pd.DataFrame(pairs_paper)
n_rec_pairs = (pairs_paper.gpi.str.split("@").str[0] + "|" + pairs_paper.vla.str.split("@").str[0]).nunique()
print(f"Variant, the paper's records: GPi-VLa pairs in the same file {len(pairs_paper)} in {pairs_paper.session.nunique()} files "
      f"({n_rec_pairs} distinct pairs of records)")
print(pairs_paper.groupby("subject").size().to_string())
GPi-VLa pairs in the same file: 338 in 76 files
subject
Gaia     88
Isis    250
Variant, the paper's records: GPi-VLa pairs in the same file 329 in 73 files (313 distinct pairs of records)
subject
Gaia    104
Isis    225

fig4-ccf-rare¶

Short-time-scale spike-to-spike correlations between simultaneously recorded GPi and VLa units are rare (3.5% of 427 pairs over whole recordings, 2.2% of 183 at rest, 5.2% of 172 during movement) and their peaks are not larger than those of jittered controls.

In [26]:
started = time.time()
N_SURR = 400

def ccf_run(pairs_df, rng, rng_c0):
    ccf_rows, ctrl_peaks, same_time = [], {"whole": [], "rest": [], "movement": []}, []
    for _, p in pairs_df.iterrows():
        s = sess_of[p.session]
        a, b = spk[p.gpi], spk[p.vla]
        span = max(a[-1], b[-1]) - min(a[0], b[0])
        same_time.append({"same": len(np.intersect1d(np.round(a * 24414.0625), np.round(b * 24414.0625))),
                          "expected": len(a) * len(b) / (span * 24414.0625)})
        t = s["vt"][s["vt"].valid]
        periods = {"whole": None,
                   "rest": np.c_[t.movement_start_time + g.REST_WIN[0], t.movement_start_time + g.REST_WIN[1]],
                   "movement": np.c_[t.movement_start_time + g.MVT_WIN[0], t.movement_start_time + g.MVT_WIN[1]]}
        for name, win in periods.items():
            if win is None:
                x, y = a, b
                dur = span
            else:
                x, y = a[g._in_windows(a, win)], b[g._in_windows(b, win)]
                dur = float(np.sum(win[:, 1] - win[:, 0]))
            if min(len(x), len(y)) < 100:
                continue
            r = g.ccf_test(x, y, dur, rng, N_SURR)
            ccf_rows.append({"period": name, "subject": p.subject, "neuron_pair": pair_neuron[p.gpi] + "|" + pair_neuron[p.vla],
                             "peak": r["peak"], "sign": r["sign"], "lag_ms": r["lag_ms"], "thr95": r["thr95"],
                             "significant": r["significant"], "c0": g.min_detectable(r["expected"], r["thr95"], rng_c0)})
            ctrl_peaks[name].append(r["ctrl_peaks"])
    return pd.DataFrame(ccf_rows), ctrl_peaks, pd.DataFrame(same_time)

cc, ctrl_peaks, st = ccf_run(pairs, np.random.default_rng(4), np.random.default_rng(14))
print(f"CCFs computed in {time.time() - started:.0f} s; {N_SURR} jittered controls per pair and period")
for name in ("whole", "rest", "movement"):
    d = cc[cc.period == name]
    ctrl = np.vstack(ctrl_peaks[name])
    k, n = int(d.significant.sum()), len(d)
    ks_p = g.ks_permutation(d.peak.to_numpy(), ctrl)
    print(f"{name}: {n} pairs; significant {k} ({100 * k / n:.1f}%), binomial p against 5% (greater) = "
          f"{stats.binomtest(k, n, 0.05, alternative='greater').pvalue:.3g}; signs of significant peaks "
          f"+{int((d[d.significant].sign > 0).sum())} / -{int((d[d.significant].sign < 0).sum())}; "
          f"KS permutation p = {ks_p:.3f}; median peak {d.peak.median():.3f}, median control peak {np.median(ctrl):.3f}, "
          f"median control 95th percentile {d.thr95.median():.3f}")
    print(f"   lags of the significant peaks (ms): {d[d.significant].lag_ms.value_counts().sort_index().to_dict()}")
    keep_c0 = (d.c0 <= 0.3).to_numpy()
    k0 = int(d[keep_c0].significant.sum())
    print(f"   variant, pairs with c0 <= 0.3 (approximate c0, median {d.c0.median():.2f}): {keep_c0.sum()} pairs, significant {k0} "
          f"({100 * k0 / keep_c0.sum():.1f}%), KS permutation p = {g.ks_permutation(d[keep_c0].peak.to_numpy(), ctrl[keep_c0]):.3f}")
    first = (~d.neuron_pair.duplicated()).to_numpy()
    print(f"   variant, one file per pair of presumed neurons: {first.sum()} pairs, KS permutation p = "
          f"{g.ks_permutation(d[first].peak.to_numpy(), ctrl[first]):.3f}")
print("By animal, percent significant:", (100 * cc.groupby(["period", "subject"]).significant.mean()).round(1).to_dict())
print(f"GPi and VLa spikes at the same sample, summed over {len(st)} pairs: {int(st.same.sum())}, expected for independent trains "
      f"{st.expected.sum():.0f}; largest ratio in a pair {(st.same / st.expected).max():.2f}")

started = time.time()
cc_p, ctrl_p, _ = ccf_run(pairs_paper, np.random.default_rng(24), np.random.default_rng(34))
print(f"\nVariant, the paper's records ({time.time() - started:.0f} s):")
for name in ("whole", "rest", "movement"):
    d = cc_p[cc_p.period == name]
    ctrl = np.vstack(ctrl_p[name])
    k, n = int(d.significant.sum()), len(d)
    keep_c0 = (d.c0 <= 0.3).to_numpy()
    print(f"  {name}: {n} pairs; significant {k} ({100 * k / n:.1f}%), signs +{int((d[d.significant].sign > 0).sum())} / "
          f"-{int((d[d.significant].sign < 0).sum())}; KS permutation p = {g.ks_permutation(d.peak.to_numpy(), ctrl):.3f}; "
          f"with c0 <= 0.3: {keep_c0.sum()} pairs, KS permutation p = {g.ks_permutation(d[keep_c0].peak.to_numpy(), ctrl[keep_c0]):.3f}")
CCFs computed in 63 s; 400 jittered controls per pair and period
whole: 338 pairs; significant 19 (5.6%), binomial p against 5% (greater) = 0.333; signs of significant peaks +15 / -4; KS permutation p = 0.003; median peak 0.042, median control peak 0.038, median control 95th percentile 0.059
   lags of the significant peaks (ms): {0: 1, 1: 4, 2: 1, 3: 3, 4: 1, 5: 1, 7: 3, 8: 1, 9: 2, 10: 2}
   variant, pairs with c0 <= 0.3 (approximate c0, median 0.09): 332 pairs, significant 19 (5.7%), KS permutation p = 0.003
   variant, one file per pair of presumed neurons: 282 pairs, KS permutation p = 0.003
rest: 298 pairs; significant 10 (3.4%), binomial p against 5% (greater) = 0.932; signs of significant peaks +8 / -2; KS permutation p = 0.705; median peak 0.134, median control peak 0.134, median control 95th percentile 0.215
   lags of the significant peaks (ms): {0: 2, 1: 2, 2: 1, 4: 1, 5: 1, 9: 3}
   variant, pairs with c0 <= 0.3 (approximate c0, median 0.34): 121 pairs, significant 4 (3.3%), KS permutation p = 0.333
   variant, one file per pair of presumed neurons: 257 pairs, KS permutation p = 0.627
movement: 306 pairs; significant 20 (6.5%), binomial p against 5% (greater) = 0.136; signs of significant peaks +15 / -5; KS permutation p = 0.045; median peak 0.116, median control peak 0.113, median control 95th percentile 0.179
   lags of the significant peaks (ms): {0: 2, 1: 1, 2: 1, 3: 2, 4: 1, 5: 4, 6: 2, 7: 2, 8: 1, 9: 1, 10: 3}
   variant, pairs with c0 <= 0.3 (approximate c0, median 0.29): 172 pairs, significant 12 (7.0%), KS permutation p = 0.025
   variant, one file per pair of presumed neurons: 262 pairs, KS permutation p = 0.037
By animal, percent significant: {('movement', 'Gaia'): 6.8, ('movement', 'Isis'): 6.5, ('rest', 'Gaia'): 1.4, ('rest', 'Isis'): 4.0, ('whole', 'Gaia'): 2.3, ('whole', 'Isis'): 6.8}
GPi and VLa spikes at the same sample, summed over 338 pairs: 11376, expected for independent trains 11435; largest ratio in a pair 3.02
Variant, the paper's records (50 s):
  whole: 329 pairs; significant 23 (7.0%), signs +15 / -8; KS permutation p = 0.005; with c0 <= 0.3: 295 pairs, KS permutation p = 0.005
  rest: 298 pairs; significant 18 (6.0%), signs +13 / -5; KS permutation p = 0.033; with c0 <= 0.3: 78 pairs, KS permutation p = 0.745
  movement: 301 pairs; significant 18 (6.0%), signs +11 / -7; KS permutation p = 0.203; with c0 <= 0.3: 121 pairs, KS permutation p = 0.133

Result: not reproduced. By the paper's test the peak CCFs of the 338 whole-recording pairs are larger than those of the jittered controls (KS permutation p = 0.003), and so are those of the 306 movement pairs (p = 0.045); the 298 rest pairs are not (p = 0.705). Significant pairs are few (5.6%, 3.4% and 6.5%, none above 5% by a binomial test, p 0.14 to 0.93), as the paper reports, and their peaks are mostly positive (15 of 19 whole-recording, 15 of 20 movement), where gating predicts negative ones. The effect is small: median peak 0.042 against a control median of 0.038. Keeping only pairs with an approximate c0 of at most 0.3, as the Methods do, leaves 332, 121 and 172 pairs (the paper has 427, 183 and 172) with p = 0.003, 0.333 and 0.025; keeping one file per pair of presumed neurons gives p = 0.003, 0.627 and 0.037. The significant peaks fall at lags spread over 0 to 10 ms, and GPi and VLa spikes share a sample as often as independent trains would (11,376 against 11,435 expected), so shared artefacts do not explain the excess. With the paper's records (329, 298 and 301 pairs) the peaks exceed the controls over whole recordings (p = 0.005) and at rest (p = 0.033), not during movement (p = 0.203); significant pairs are 7.0%, 6.0% and 6.0%. The outcome would be the same.

fig4-noise-corr-rare¶

Trial-to-trial noise correlations between GPi and VLa are rare (5.4% of 332 pairs at rest, 7.2% of 372 during movement), not different from shuffled controls, and equally often positive and negative.

In [27]:
def detrend(c, x):
    return c - np.polyval(np.polyfit(x, c, 1), x)

def nc_run(pairs_df, rng, rng_d):
    nc_rows, nc_ctrl, nc_ctrl_d = [], {"rest": [], "movement": []}, {"rest": [], "movement": []}
    for _, p in pairs_df.iterrows():
        s = sess_of[p.session]
        for target in ("Left", "Right"):
            t = s["vt"][s["vt"].valid & (s["vt"].target == target)]
            al = t.movement_start_time.to_numpy()
            for name, (lo, hi) in (("rest", g.REST_WIN), ("movement", g.MVT_WIN)):
                ca = window_counts(spk[p.gpi], al, lo, hi).astype(float)
                cb = window_counts(spk[p.vla], al, lo, hi).astype(float)
                if ca.sum() < 10 or cb.sum() < 10 or len(al) < 10 or ca.std() == 0 or cb.std() == 0:
                    continue
                za, zb = stats.zscore(ca), stats.zscore(cb)
                ok = (np.abs(za) <= 3) & (np.abs(zb) <= 3)
                ca, cb, x = ca[ok], cb[ok], al[ok]
                if ca.std() == 0 or cb.std() == 0:
                    continue
                r = np.corrcoef(ca, cb)[0, 1]
                sh = np.array([np.corrcoef(ca, rng.permutation(cb))[0, 1] for _ in range(400)])
                da, db = detrend(ca, x), detrend(cb, x)
                shd = np.array([np.corrcoef(da, rng_d.permutation(db))[0, 1] for _ in range(400)])
                own = np.percentile(sh, [2.5, 97.5])
                nc_rows.append({"period": name, "pair": f"{p.gpi}|{p.vla}", "neuron_pair": pair_neuron[p.gpi] + "|" + pair_neuron[p.vla],
                                "subject": p.subject, "target": target, "r": r, "r_detrended": np.corrcoef(da, db)[0, 1],
                                "drift_gpi": stats.spearmanr(x, ca).pvalue < 0.05, "drift_vla": stats.spearmanr(x, cb).pvalue < 0.05,
                                "sig_own": bool(r < own[0] or r > own[1])})
                nc_ctrl[name].append(sh)
                nc_ctrl_d[name].append(shd)
    return pd.DataFrame(nc_rows), nc_ctrl, nc_ctrl_d

nc, nc_ctrl, nc_ctrl_d = nc_run(pairs, np.random.default_rng(5), np.random.default_rng(15))
for name in ("rest", "movement"):
    d = nc[nc.period == name].copy()
    ctrl, ctrl_d = np.vstack(nc_ctrl[name]), np.vstack(nc_ctrl_d[name])
    lo, hi = np.percentile(ctrl, [2.5, 97.5])
    d["sig"] = (d.r < lo) | (d.r > hi)
    pos, neg = int((d.r > hi).sum()), int((d.r < lo).sum())
    ks_p = g.ks_permutation(np.abs(d.r.to_numpy()), np.abs(ctrl))
    by_pair = d.groupby("pair").sig.any()
    print(f"{name}: (a) {len(d)} pair-targets, significant {int(d.sig.sum())} ({100 * d.sig.mean():.1f}%), +{pos} / -{neg} "
          f"(binomial p of the sign balance {stats.binomtest(pos, pos + neg).pvalue:.3g}); "
          f"(b) {len(by_pair)} pairs, significant in either target {int(by_pair.sum())} ({100 * by_pair.mean():.1f}%)")
    print(f"   KS permutation p (absolute correlations against shuffles) = {ks_p:.3f}; median r {d.r.median():.3f}; "
          f"shuffle band {lo:.3f} to {hi:.3f}")
    print(f"   variant, each pair-target against the 2.5 to 97.5 percentiles of its own shuffles: {100 * d.sig_own.mean():.1f}% significant")
    print(f"   drift: spike counts correlated with trial time (Spearman p < 0.05) for GPi in {100 * d.drift_gpi.mean():.1f}%, "
          f"VLa in {100 * d.drift_vla.mean():.1f}%, both in {100 * (d.drift_gpi & d.drift_vla).mean():.1f}% of pair-targets")
    lo_d, hi_d = np.percentile(ctrl_d, [2.5, 97.5])
    print(f"   variant, counts linearly detrended over trial time: median |r| {d.r_detrended.abs().median():.3f} (raw {d.r.abs().median():.3f}); "
          f"significant {100 * ((d.r_detrended < lo_d) | (d.r_detrended > hi_d)).mean():.1f}%; KS permutation p = "
          f"{g.ks_permutation(np.abs(d.r_detrended.to_numpy()), np.abs(ctrl_d)):.3f}")
    first = d.pair.isin(d.drop_duplicates("neuron_pair").pair)
    print(f"   variant, one file per pair of presumed neurons: {int(first.sum())} pair-targets, KS permutation p = "
          f"{g.ks_permutation(np.abs(d[first].r.to_numpy()), np.abs(ctrl[first.to_numpy()])):.3f}")
nc_p, nc_ctrl_p, _ = nc_run(pairs_paper, np.random.default_rng(25), np.random.default_rng(35))
print("Variant, the paper's records:")
for name in ("rest", "movement"):
    d = nc_p[nc_p.period == name]
    ctrl = np.vstack(nc_ctrl_p[name])
    lo, hi = np.percentile(ctrl, [2.5, 97.5])
    sig = (d.r < lo) | (d.r > hi)
    print(f"  {name}: {len(d)} pair-targets, significant {100 * sig.mean():.1f}%, +{int((d.r > hi).sum())} / -{int((d.r < lo).sum())}; "
          f"KS permutation p = {g.ks_permutation(np.abs(d.r.to_numpy()), np.abs(ctrl)):.3f}")
rest: (a) 665 pair-targets, significant 67 (10.1%), +38 / -29 (binomial p of the sign balance 0.328); (b) 338 pairs, significant in either target 59 (17.5%)
   KS permutation p (absolute correlations against shuffles) = 0.000; median r 0.016; shuffle band -0.291 to 0.291
   variant, each pair-target against the 2.5 to 97.5 percentiles of its own shuffles: 10.8% significant
   drift: spike counts correlated with trial time (Spearman p < 0.05) for GPi in 18.9%, VLa in 36.1%, both in 7.8% of pair-targets
   variant, counts linearly detrended over trial time: median |r| 0.114 (raw 0.108); significant 9.3%; KS permutation p = 0.000
   variant, one file per pair of presumed neurons: 555 pair-targets, KS permutation p = 0.000
movement: (a) 669 pair-targets, significant 72 (10.8%), +39 / -33 (binomial p of the sign balance 0.556); (b) 338 pairs, significant in either target 63 (18.6%)
   KS permutation p (absolute correlations against shuffles) = 0.000; median r 0.001; shuffle band -0.291 to 0.292
   variant, each pair-target against the 2.5 to 97.5 percentiles of its own shuffles: 14.2% significant
   drift: spike counts correlated with trial time (Spearman p < 0.05) for GPi in 29.0%, VLa in 38.3%, both in 12.1% of pair-targets
   variant, counts linearly detrended over trial time: median |r| 0.111 (raw 0.121); significant 9.7%; KS permutation p = 0.000
   variant, one file per pair of presumed neurons: 559 pair-targets, KS permutation p = 0.000
Variant, the paper's records:
  rest: 647 pair-targets, significant 9.1%, +28 / -31; KS permutation p = 0.000
  movement: 648 pair-targets, significant 7.4%, +24 / -24; KS permutation p = 0.000

Result: not reproduced. Absolute noise correlations exceed those of trial-shuffled controls at rest and during movement (KS permutation p < 0.001 in both). 10.1% (rest) and 10.8% (movement) of pair-targets lie outside the shuffled 2.5 to 97.5 percentiles, against 5.4% and 7.2%, or 17.5% and 18.6% of pairs counting either target; against each pair-target's own shuffles, 10.8% and 14.2%. Positive and negative correlations are about equally common (38 and 29 at rest, p = 0.33; 39 and 33 during movement, p = 0.56), as the paper reports. Spike counts drift with trial time in many units (VLa 36.1% and 38.3% of pair-targets), but with counts detrended the excess remains (9.3% and 9.7% significant, KS p < 0.001), and so it does with one file per pair of presumed neurons (p < 0.001) and with the paper's records (9.1% and 7.4% significant, KS p < 0.001 in both). The outcome would be the same.

fig5-vla-decrease-after-gpi-bursts¶

At rest, GPi burst onsets, burst offsets and pause onsets are each followed by a small, slow decrease of the population VLa firing rate.

In [28]:
started = time.time()
LAGS = np.arange(-800, 801)
win = (LAGS >= 0) & (LAGS <= 300)

def fig5_run(pairs_df, rng):
    ta = {"burst onset": [], "burst offset": [], "pause onset": []}
    ctrl_sum = {k: 0.0 for k in ta}
    ta_pct = {k: [] for k in ta}
    ctrl_pct_sum = {k: 0.0 for k in ta}
    burst_cache, burst_stats = {}, []
    pair_sig = {k: [] for k in ta}
    pair_sig_ctrl = {k: [] for k in ta}
    n_excluded, n_gap = {k: 0 for k in ta}, {k: 0 for k in ta}
    n_events = {k: [] for k in ta}
    BASE = (LAGS >= -800) & (LAGS < -50)
    POST = (LAGS >= 0) & (LAGS <= 100)
    for _, p in pairs_df.iterrows():
        s = sess_of[p.session]
        t = s["trials"]
        end = t["return_stop_time"] if "return_stop_time" in t else t["stop_time"]
        task = np.c_[t.movement_start_time + g.TASK_EXCL[0], end.fillna(t.stop_time) + g.TASK_EXCL[1]]
        task = task[np.isfinite(task).all(1)]
        a, b = spk[p.gpi], spk[p.vla]
        T = int(np.ceil(max(a[-1], b[-1]) * 1000)) + 2000
        in_task = np.zeros(T, bool)
        for lo_, hi_ in (task * 1000).astype(int):
            in_task[max(lo_, 0):min(hi_, T)] = True
        if p.gpi not in burst_cache:
            rest_a = a[~in_task[(a * 1000).astype(int)]]
            rest_dur = (~in_task[int(a[0] * 1000):int(a[-1] * 1000)]).sum() / 1000
            rate = len(rest_a) / rest_dur
            edges = np.flatnonzero(np.diff(np.r_[0, (~in_task).astype(int), 0]))
            segs = [a[(a >= lo_) & (a < hi_)] for lo_, hi_ in edges.reshape(-1, 2) / 1000]      # rest segments
            bl = [x for seg in segs for x in g.bursts(seg, rate)]
            pl = [x for seg in segs for x in g.pauses(seg, rate)]
            bl10 = [x for seg in segs for x in g.bursts(seg, rate, log=np.log10)]
            pl10 = [x for seg in segs for x in g.pauses(seg, rate, log=np.log10)]
            burst_cache[p.gpi] = (bl, pl)
            burst_stats.append({"gpi": p.gpi, "rest_s": rest_dur, "rate": rate, "n_bursts": len(bl), "n_pauses": len(pl),
                                "burst_ms": np.mean([1000 * (e - b0) for b0, e in bl]) if bl else np.nan,
                                "pause_ms": np.mean([1000 * (e - b0) for b0, e in pl]) if pl else np.nan,
                                "burst_ms_log10": np.mean([1000 * (e - b0) for b0, e in bl10]) if bl10 else np.nan,
                                "pause_ms_log10": np.mean([1000 * (e - b0) for b0, e in pl10]) if pl10 else np.nan})
        bl, pl = burst_cache[p.gpi]
        events = {"burst onset": np.array([e[0] for e in bl]), "burst offset": np.array([e[1] for e in bl]),
                  "pause onset": np.array([e[0] for e in pl])}
        counts = np.bincount((b * 1000).astype(int), minlength=T)[:T].astype(float)
        rest = (~in_task).astype(float)
        vrate = gaussian_filter1d(counts * 1000.0, 10.0) * rest       # VLa rate, rest samples only
        for k, ev in events.items():
            ev = ev[(ev > 1.0) & (ev < T / 1000 - 1.0)]
            if len(ev) < 5:
                continue
            idx = (ev * 1000).astype(int)
            X, W = g.event_sums(vrate, idx), g.event_sums(rest, idx)
            ratio = np.where(W > 0.5, X / np.maximum(W, 0.5), np.nan)     # lags where no event has a rest sample: NaN
            M = len(ratio)
            avg = ratio[LAGS % M]
            if np.isnan(avg).any():
                n_gap[k] += 1
                continue
            base = avg[BASE]
            lo_b, hi_b = np.percentile(base, [2.5, 97.5])
            if hi_b - lo_b > 0.6 * abs(base.mean()):
                n_excluded[k] += 1
                continue
            ta[k].append(avg)
            ta_pct[k].append(100 * avg / base.mean())
            n_events[k].append(len(idx))
            post = avg[POST]
            pair_sig[k].append("decrease" if (post < lo_b).any() else ("increase" if (post > hi_b).any() else "none"))
            shifts = rng.integers(2000, 20001, size=400) * rng.choice([-1, 1], size=400)
            c_ = np.array([ratio[(LAGS + sh) % M] for sh in shifts])
            c_ = np.where(np.isnan(c_), np.nanmean(avg), c_)
            ctrl_sum[k] = ctrl_sum[k] + c_
            ctrl_pct_sum[k] = ctrl_pct_sum[k] + 100 * c_ / c_[:, BASE].mean(1, keepdims=True)
            # the per-pair test applied to each shifted control: its chance level
            clo, chi_ = np.percentile(c_[:, BASE], [2.5, 97.5], axis=1)
            cpost = c_[:, POST]
            pair_sig_ctrl[k].append(np.mean((cpost < clo[:, None]).any(1) | (cpost > chi_[:, None]).any(1)))
    return dict(ta=ta, ctrl_sum=ctrl_sum, ta_pct=ta_pct, ctrl_pct_sum=ctrl_pct_sum, burst_stats=pd.DataFrame(burst_stats),
                pair_sig=pair_sig, pair_sig_ctrl=pair_sig_ctrl, n_excluded=n_excluded, n_gap=n_gap, n_events=n_events)

def population_band(ta_k, ctrl_sum_k):
    """Population average and the control band, widened so that 5% of controls cross it in 0 to 300 ms."""
    pop = np.mean(ta_k, 0)
    c = ctrl_sum_k / len(ta_k)                   # control population averages, 400 x lags
    lo_c, hi_c = np.percentile(c, [2.5, 97.5], axis=0)
    shift = min(np.percentile((c[:, win] - lo_c[win]).min(1), 5), 0)
    return pop, lo_c + shift, hi_c - shift

F = fig5_run(pairs, np.random.default_rng(6))
ta, ctrl_sum, ta_pct, ctrl_pct_sum = F["ta"], F["ctrl_sum"], F["ta_pct"], F["ctrl_pct_sum"]
pair_sig, pair_sig_ctrl, n_excluded, n_gap, n_events = F["pair_sig"], F["pair_sig_ctrl"], F["n_excluded"], F["n_gap"], F["n_events"]
bs = F["burst_stats"]
print(f"Bursts and pauses in {time.time() - started:.0f} s")
print(f"GPi units: {len(bs)}; rest per unit median {bs.rest_s.median():.0f} s; bursts per minute of rest median "
      f"{(60 * bs.n_bursts / bs.rest_s).median():.2f}; pauses per minute median {(60 * bs.n_pauses / bs.rest_s).median():.2f}")
print(f"Mean burst duration {np.nanmean(bs.burst_ms):.1f} ms (paper 24 ms); mean pause duration {np.nanmean(bs.pause_ms):.1f} ms (paper 195 ms)")
print(f"Variant, surprise with log10: mean burst duration {np.nanmean(bs.burst_ms_log10):.1f} ms; mean pause duration {np.nanmean(bs.pause_ms_log10):.1f} ms")
fig, axes = plt.subplots(1, 3, figsize=(12, 3), sharey=True)
for ax, k in zip(axes, ta):
    pop, lo_corr, hi_corr = population_band(ta[k], ctrl_sum[k])
    below = win & (pop < lo_corr)
    above = win & (pop > hi_corr)
    first = LAGS[below][0] if below.any() else None
    sig = pd.Series(pair_sig[k]).value_counts()
    print(f"{k}: {len(ta[k])} pairs of {len(ta[k]) + n_excluded[k] + n_gap[k]} with at least 5 events (excluded: noisy baseline "
          f"{n_excluded[k]}, lags without rest samples {n_gap[k]}), events per pair median {np.median(n_events[k]):.0f}; "
          f"population rate at baseline {pop[LAGS < -50].mean():.2f} spikes/s, minimum in 0 to 300 ms {pop[win].min():.2f} "
          f"({100 * (pop[win].min() / pop[LAGS < -50].mean() - 1):.1f}%); below the corrected band: {bool(below.any())}, "
          f"from {first} ms; above it: {bool(above.any())}")
    print(f"   pairs with a change in 0 to 100 ms: {int(sig.drop('none', errors='ignore').sum())} of {len(pair_sig[k])} "
          f"({100 * sig.drop('none', errors='ignore').sum() / len(pair_sig[k]):.1f}%); decreases {int(sig.get('decrease', 0))}, "
          f"increases {int(sig.get('increase', 0))}; the same test on the shifted controls: {100 * np.mean(pair_sig_ctrl[k]):.1f}%")
    # Variant, as Fig5_S14.m: each pair in percent of its baseline, band per lag from the controls, lower
    # edge shifted by the 2.5th percentile over controls of their lowest excursion from 0 to +800 ms.
    pop_p = np.mean(ta_pct[k], 0)
    cp = ctrl_pct_sum[k] / len(ta_pct[k])
    down = np.percentile(cp, 2.5, axis=0)
    post800 = LAGS >= 0
    down_mc = down + np.percentile((cp[:, post800] - down[post800]).min(1), 2.5)
    below_p = post800 & (pop_p < down_mc)
    print(f"   variant as Fig5_S14.m (percent of baseline): minimum in 0 to 300 ms {pop_p[win].min():.1f}%, "
          f"below the band in 0 to 300 ms: {bool((below_p & win).any())}; first lag below it in 0 to 800 ms: "
          f"{LAGS[below_p][0] if below_p.any() else None}")
    ax.plot(LAGS, pop, "k"); ax.fill_between(LAGS, lo_corr, hi_corr, color="0.8")
    ax.set_title(k); ax.set_xlabel("ms from event")
axes[0].set_ylabel("VLa rate (spikes/s)")
print("All plotted values are inside the axes.")
started = time.time()
Fp = fig5_run(pairs_paper, np.random.default_rng(26))
print(f"\nVariant, the paper's records ({time.time() - started:.0f} s); mean burst duration {np.nanmean(Fp['burst_stats'].burst_ms):.1f} ms:")
for k in Fp["ta"]:
    pop, lo_corr, hi_corr = population_band(Fp["ta"][k], Fp["ctrl_sum"][k])
    below = win & (pop < lo_corr)
    sig = pd.Series(Fp["pair_sig"][k])
    print(f"  {k}: {len(Fp['ta'][k])} pairs (excluded: noisy {Fp['n_excluded'][k]}, lags without rest {Fp['n_gap'][k]}); "
          f"minimum in 0 to 300 ms {100 * (pop[win].min() / pop[LAGS < -50].mean() - 1):.1f}%; below the corrected band: "
          f"{bool(below.any())}; pairs with a change in 0 to 100 ms {100 * (sig != 'none').mean():.1f}% "
          f"(shifted controls {100 * np.mean(Fp['pair_sig_ctrl'][k]):.1f}%)")
Bursts and pauses in 45 s
GPi units: 144; rest per unit median 312 s; bursts per minute of rest median 17.75; pauses per minute median 11.66
Mean burst duration 67.1 ms (paper 24 ms); mean pause duration 145.3 ms (paper 195 ms)
Variant, surprise with log10: mean burst duration 254.0 ms; mean pause duration 237.0 ms
burst onset: 150 pairs of 307 with at least 5 events (excluded: noisy baseline 157, lags without rest samples 0), events per pair median 224; population rate at baseline 21.26 spikes/s, minimum in 0 to 300 ms 20.73 (-2.5%); below the corrected band: False, from None ms; above it: False
   pairs with a change in 0 to 100 ms: 76 of 150 (50.7%); decreases 43, increases 33; the same test on the shifted controls: 47.5%
   variant as Fig5_S14.m (percent of baseline): minimum in 0 to 300 ms 98.1%, below the band in 0 to 300 ms: False; first lag below it in 0 to 800 ms: None
burst offset: 150 pairs of 307 with at least 5 events (excluded: noisy baseline 157, lags without rest samples 0), events per pair median 241; population rate at baseline 21.20 spikes/s, minimum in 0 to 300 ms 20.80 (-1.9%); below the corrected band: False, from None ms; above it: False
   pairs with a change in 0 to 100 ms: 75 of 150 (50.0%); decreases 38, increases 37; the same test on the shifted controls: 47.8%
   variant as Fig5_S14.m (percent of baseline): minimum in 0 to 300 ms 98.4%, below the band in 0 to 300 ms: False; first lag below it in 0 to 800 ms: None
pause onset: 110 pairs of 253 with at least 5 events (excluded: noisy baseline 143, lags without rest samples 0), events per pair median 208; population rate at baseline 22.18 spikes/s, minimum in 0 to 300 ms 21.76 (-1.9%); below the corrected band: False, from None ms; above it: False
   pairs with a change in 0 to 100 ms: 49 of 110 (44.5%); decreases 28, increases 21; the same test on the shifted controls: 47.0%
   variant as Fig5_S14.m (percent of baseline): minimum in 0 to 300 ms 96.9%, below the band in 0 to 300 ms: False; first lag below it in 0 to 800 ms: None
All plotted values are inside the axes.
Variant, the paper's records (39 s); mean burst duration 106.5 ms:
  burst onset: 131 pairs (excluded: noisy 163, lags without rest 0); minimum in 0 to 300 ms -1.7%; below the corrected band: False; pairs with a change in 0 to 100 ms 51.9% (shifted controls 47.3%)
  burst offset: 132 pairs (excluded: noisy 162, lags without rest 0); minimum in 0 to 300 ms -2.5%; below the corrected band: False; pairs with a change in 0 to 100 ms 54.5% (shifted controls 47.8%)
  pause onset: 86 pairs (excluded: noisy 159, lags without rest 0); minimum in 0 to 300 ms -2.0%; below the corrected band: False; pairs with a change in 0 to 100 ms 57.0% (shifted controls 46.8%)
No description has been provided for this image

Result: not reproduced. The population VLa rate does not fall below the control band in 0 to 300 ms after GPi burst onsets (150 pairs; lowest point 2.5% below baseline), burst offsets (150; 1.9%) or pause onsets (110; 1.9%). More than half of the pairs with at least 5 events are left out by the Methods' rule on noisy baselines: 157 of 307 for burst onsets and offsets, 143 of 253 for pause onsets. Computed as Fig5_S14.m does, in percent of each pair's baseline with the band shifted over 0 to 800 ms, the rate does not fall below the band either (lowest 98.1%, 98.4% and 96.9%). Fig5_S14.m plots stored averages and holds no burst detector or per-pair test. Per pair, 50.7% show a change in 0 to 100 ms after burst onsets, 43 decreases and 33 increases, against 11.6% in the paper; the same test on the shifted controls gives 47.5%, so this per-pair test, as the Methods describe it, is at chance here and is not the paper's. Bursts here last 67.1 ms on average and pauses 145.3 ms, against 24 and 195 ms in the paper (254.0 and 237.0 ms with log10 in the surprise), so the burst detection also differs from the paper's. With the paper's records (131, 132 and 86 pairs kept) the rate does not fall below the band either. Because the detector and the per-pair test differ from the paper's, this outcome says little about the paper's finding.

behavior-rt-mt¶

Reaction times do not differ between the animals or reach directions; movement durations are longer for reaches to the right target, and NHP G moves more slowly than NHP I.

In [29]:
v = tr[tr.valid].copy()
v["key"] = v.session.map({s["session_id"]: s["key"] for s in sessions})
per_all = v.groupby(["subject", "session", "key", "target"]).agg(rt=("rt", "median"), mt=("mt", "median"), n=("rt", "size")).reset_index()

def behaviour(per, label):
    print(f"{label}: {per.session.nunique()} files ({per.groupby('subject').session.nunique().to_dict()})")
    print(per.groupby(["subject", "target"])[["rt", "mt"]].agg(["median", "count"]).round(3).to_string())
    for col in ("rt", "mt"):
        d = per.dropna(subset=[col])
        a = sm.stats.anova_lm(ols(f"{col} ~ C(subject) * C(target)", data=d).fit(), typ=2)
        print(f"  two-way ANOVA of {col} (type II), {len(d)} file-directions, residual df {a.loc['Residual', 'df']:.0f}: "
              + "; ".join(f"{k.replace('C(', '').replace(')', '')} F = {a.loc[k, 'F']:.2f}, p = {a.loc[k, 'PR(>F)']:.3g}" for k in a.index[:-1]))

behaviour(per_all[per_all.key.isin(set(units.key))], "Files with units of the dandiset rule")
print()
behaviour(per_all, "Variant, all pre-MPTP files with trials")
print()
behaviour(per_all[per_all.key.isin({f for x in paper.fname for f in str(x).split("/")})], "Variant, the files of the paper's records")
print("\nVariant, trials pooled over all files: RT animal p = %.3g, direction p = %.3g" % (
    stats.ranksums(v[v.subject == "Gaia"].rt, v[v.subject == "Isis"].rt).pvalue,
    stats.ranksums(v[v.target == "Left"].rt, v[v.target == "Right"].rt).pvalue))
Files with units of the dandiset rule: 165 files ({'Gaia': 91, 'Isis': 74})
                   rt           mt      
               median count median count
subject target                          
Gaia    Left    0.242    91  0.216    20
        Right   0.238    91  0.273    20
Isis    Left    0.239    74  0.174    74
        Right   0.245    74  0.195    74
  two-way ANOVA of rt (type II), 330 file-directions, residual df 326: subject F = 0.02, p = 0.89; target F = 0.69, p = 0.408; subject:target F = 2.48, p = 0.116
  two-way ANOVA of mt (type II), 188 file-directions, residual df 184: subject F = 526.30, p = 7.31e-56; target F = 270.38, p = 5.76e-38; subject:target F = 33.39, p = 3.16e-08

Variant, all pre-MPTP files with trials: 250 files ({'Gaia': 119, 'Isis': 131})
                   rt           mt      
               median count median count
subject target                          
Gaia    Left    0.243   119  0.218    32
        Right   0.242   119  0.273    32
Isis    Left    0.235   131  0.175   131
        Right   0.241   131  0.204   131
  two-way ANOVA of rt (type II), 500 file-directions, residual df 496: subject F = 15.19, p = 0.000111; target F = 3.23, p = 0.073; subject:target F = 4.24, p = 0.04
  two-way ANOVA of mt (type II), 326 file-directions, residual df 322: subject F = 691.86, p = 3.41e-82; target F = 483.80, p = 4.17e-66; subject:target F = 30.86, p = 5.81e-08

Variant, the files of the paper's records: 162 files ({'Gaia': 97, 'Isis': 65})
                   rt           mt      
               median count median count
subject target                          
Gaia    Left    0.243    97  0.217    28
        Right   0.242    97  0.273    28
Isis    Left    0.243    65  0.174    65
        Right   0.249    65  0.195    65
  two-way ANOVA of rt (type II), 324 file-directions, residual df 320: subject F = 3.79, p = 0.0525; target F = 0.45, p = 0.503; subject:target F = 2.77, p = 0.097
  two-way ANOVA of mt (type II), 186 file-directions, residual df 182: subject F = 1862.70, p = 1.55e-97; target F = 620.06, p = 1.62e-60; subject:target F = 136.49, p = 6.88e-24

Variant, trials pooled over all files: RT animal p = 2.47e-59, direction p = 2.23e-13

Result: reproduced. In the 165 files that hold the units analysed here, reaction times differ neither between the animals (F = 0.02, p = 0.89) nor between directions (F = 0.69, p = 0.41), and movement durations are longer to the right target (Gaia 0.273 against 0.216 s, Isis 0.195 against 0.174 s; F = 270.38, p = 5.8e-38) and longer in Gaia (F = 526.30, p = 7.3e-56). Over all 250 pre-MPTP files with trials, which include files with motor cortex, SMA or VLp units only, reaction times differ between the animals (F = 15.19, p = 0.00011); over the files of the paper's records they do not (F = 3.79, p = 0.053). Movement durations exist for only 20 of the 91 Gaia files used here. The paper's reaction-time ANOVA has 452 error degrees of freedom; the file sets here give 326, 496 and 320, so what its observations were is not known, and the animal effect on reaction times depends on the files used.

figs3-emg-onset¶

Blocked. The EMG is not in the dandiset; it is in the paper's Dryad deposit (S3_EMGdata files). Neither the Methods nor FigS3.m, which only plots the trial-averaged EMG, say how the onset of the EMG change was determined, so the reported onsets cannot be recomputed by the paper's method.

fig1-vla-localization¶

Blocked. The stimulation mapping (responses to GPi, SCP and motor cortex stimulation along the electrode tracks) is not in the dandiset, which holds the task recordings only, nor in the Dryad deposit.

The files give each electrode a location label and coordinates relative to the anterior commissure. The cell below compares the positions of electrodes labelled VLa and VLp. The files do not say how the labels were assigned, so this does not test the stimulation responses the claim is about.

In [30]:
rows = []
for s in sessions:
    e = s["electrodes"]
    e = e[e.location.map(g.area).isin(["VLa", "VLp"])]
    rows += [{"subject": s["subject"], "area": g.area(r.location), "y": r.acx_y, "z": r.acx_z} for r in e.itertuples()]
loc = pd.DataFrame(rows).drop_duplicates()
f = g._open(sessions[0]["url"])
for c in ("acx_y", "acx_z"):
    print(f"Description of the electrode column {c}:", g._s(f["general/extracellular_ephys/electrodes"][c].attrs.get("description")))
f.close()
print("Electrodes labelled VLa or VLp without coordinates:", int(loc[["y", "z"]].isna().any(axis=1).sum()), "of", len(loc))
loc = loc.dropna()
print("Distinct electrode positions labelled VLa or VLp (mm from the anterior commissure; y posterior negative, z ventral positive):")
print(loc.groupby(["subject", "area"])[["y", "z"]].agg(["median", "min", "max", "count"]).round(2).to_string())
for subj, d in loc.groupby("subject"):
    va, vp = d[d.area == "VLa"], d[d.area == "VLp"]
    if len(va) and len(vp):
        print(f"{subj}: VLa anterior of VLp, one-sided rank sum p = {stats.ranksums(va.y, vp.y, alternative='greater').pvalue:.2g}; "
              f"VLa dorsal of VLp (smaller z), p = {stats.ranksums(va.z, vp.z, alternative='less').pvalue:.2g}; "
              f"VLa ventral of VLp (larger z), p = {stats.ranksums(va.z, vp.z, alternative='greater').pvalue:.2g}")
Description of the electrode column acx_y: Antero-posterior coordinate relative to the anterior commissure crossing, posterior negative, in millimeters.
Description of the electrode column acx_z: Dorso-ventral coordinate relative to the anterior commissure crossing, ventral positive, in millimeters.
Electrodes labelled VLa or VLp without coordinates: 1 of 617
Distinct electrode positions labelled VLa or VLp (mm from the anterior commissure; y posterior negative, z ventral positive):
                  y                        z                  
             median   min   max count median   min   max count
subject area                                                  
Gaia    VLa   -7.44 -9.15 -5.80   135   1.93 -1.62  4.16   135
        VLp   -8.28 -9.05 -6.98    42   0.50 -2.17  2.49    42
Isis    VLa   -6.41 -7.71 -4.30   259   4.03  1.71  6.91   259
        VLp   -7.82 -9.78 -6.28   180   2.84  0.31  4.26   180
Gaia: VLa anterior of VLp, one-sided rank sum p = 6.4e-10; VLa dorsal of VLp (smaller z), p = 1; VLa ventral of VLp (larger z), p = 1.8e-11
Isis: VLa anterior of VLp, one-sided rank sum p = 5.5e-58; VLa dorsal of VLp (smaller z), p = 1; VLa ventral of VLp (larger z), p = 2e-27

In both animals the electrodes labelled VLa lie anterior of those labelled VLp (p = 6.4e-10 and 5.5e-58) and, by the coordinates as the column description gives them (ventral positive), ventral of them (p = 1.8e-11 and 2e-27), where the claim places VLa dorsal. The claim stays blocked: the files do not say how the labels were assigned, and the stimulation responses are not in them.

fig6-simulation¶

Blocked. The simulation uses no recorded data. The Dryad deposit holds its output (Data_Fig6.mat) and a plotting script, but not the model thalamocortical cell or the code that generated correlated GPi spike trains; reimplementing the model of the paper's reference 103 was not attempted.

In [31]:
print(f"Notebook ran in {(time.time() - NB_START) / 60:.1f} min")
Notebook ran in 16.7 min

Summary¶

A failure to reproduce here may be a failure of this attempt, not of the paper: see the reasoning and the differences from the paper's method.

Claim Data Outcome
unit-link with inputs reproduced
table1-unit-counts dandiset not reproduced
table1-rest-rate dandiset reproduced
table1-ap-width dandiset reproduced
hold-period-ramps dandiset partially reproduced
mvt-responsive-fraction dandiset reproduced
increases-most-common dandiset partially reproduced
monophasic-more-in-vla dandiset not reproduced
integrated-change-positive dandiset partially reproduced
fig3a-vla-leads-gpi dandiset not reproduced
fig3b-gpi-decr-lag-vla-incr dandiset reproduced
fig3c-vla-decr-lead-gpi-incr dandiset not reproduced
gating-conflict-fraction dandiset reproduced
directional-units dandiset partially reproduced
fig4-ccf-rare dandiset not reproduced
fig4-noise-corr-rare dandiset not reproduced
fig5-vla-decrease-after-gpi-bursts dandiset not reproduced
behavior-rt-mt dandiset reproduced
figs3-emg-onset elsewhere blocked: methods underspecified
fig1-vla-localization dandiset blocked: data missing
fig6-simulation elsewhere blocked: other

How the analysis differs from the paper's. The units are selected from the dandiset's own labels, and each file's unit is counted once, because the dandiset does not say which units the paper kept nor which units in consecutive files are one neuron; this gives about 40% more units than the paper. Two variants are shown for every claim: the paper's own unit list, linked through the Dryad files, and one record per presumed neuron (same day, channel and sort letter), which joins some units the paper kept apart. With the paper's list, hold-period-ramps, monophasic-more-in-vla and fig3a-vla-leads-gpi would meet their criteria and fig3b-gpi-decr-lag-vla-incr would be partially reproduced (its GPi median, -36 ms, leaves its tolerance), so these four outcomes depend on the selection; the fig3b GPi median also leaves its tolerance with one record per presumed neuron and with stricter detection. The pair claims keep their outcomes with the paper's list (329 pairs in the same file, against the paper's 427). The detection of responses follows the Methods and the paper's detection function, but the detrending and the ISI function are implemented here; on the paper's units they agree with its stored results for 91% (increases) and 81% (decreases) of unit-directions. With random alignment the detection makes 62% of GPi and 71% of VLa units responsive, and the control-slope test finds a slope in 98% to 99% of units, as in the data; chance decreases in VLa (34% of unit-directions, 31% at alpha 0.0001) may pull the VLa decrease onsets of fig3c late. Trial exclusion follows the Methods and matches the paper's trial counts for 63% of unit-directions. Widths are measured on the dandiset's 200 Hz filtered traces. The CCF jitters the VLa spikes; the Methods' exclusion of pairs with c0 > 0.3 is shown as a variant with an approximate c0, as the paper's simulation is not in its deposit. Burst and pause detection is a reimplementation of the surprise method and gives longer bursts than the paper's; its control shifts events within rest, and the per-pair test as the Methods describe it is at chance, and more than half of the pairs are left out as noisy. The surprise uses the natural log of the paper's MATLAB; log10, used in a first run, gives longer bursts still (shown as a variant). The behaviour is tested on the files that hold the units analysed; over all pre-MPTP files the reaction times differ between the animals, and no file set gives the paper's 452 degrees of freedom.

What was found about the dandiset. It holds the paper's pre-MPTP GPi and VLa recordings: 364 of the 394 units the paper analysed are among its curated units, and their spike times reproduce the paper's per-unit activity. It does not identify which units the paper analysed or link a neuron across consecutive files. Location labels vary in case, 54 Gaia thalamic units are labelled only "Thalamus" and 33 Isis units have no label or channel; 30 of the paper's units are missing from the curated tables, and 21 files have no curated units (13 of them have sorted clusters without channel or sort letter). Every session_start_time is two days after the date in the session_id. Spike times are at the sample resolution of the recording. Error trials are flagged only in files with an error_onset_time column (84 and 43 files in the column table), and movement ends are in only 32 of the 119 Gaia files. One pre-MPTP file has no trials table, and one M1 spike train is stored twice. The EMG and the stimulation mapping are not in the dandiset.