Reproducing Hickman et al. (2026) from dandiset 000774ΒΆ

Paper. Hickman, Hughes, Sahai, Miles and Denman (2026), "Neural population dynamics of direct electrical stimulation of neocortex", Cell Reports 45, 117420, doi:10.1016/j.celrep.2026.117420.

Dataset. DANDI:000774, version 0.260520.1753 (doi:10.48324/dandi.000774/0.260520.1753), streamed from the DANDI API. This is the version the paper cites in its key resources table, and the only published version of the dandiset; the draft has the same ten assets.

Text the claims were written from. The NIH author manuscript (PMC13404931) with the STAR Methods and Document S1 (legends of Figures S1 to S12); Table S1 was not in the text read (claims/000774/10.1016-j.celrep.2026.117420.yaml). Method details the claims file does not give are taken from the paper and cited where used; where the paper is silent, from its code (github.com/denmanlab/estim_populations at c4aaa608), as the claims file does.

What the dandiset holds. Spike times, Kilosort templates, CCF coordinates and regions of the units and the table of electrical pulses, for ten acute recordings. The raw AP band, the LFP band and the behaviour are not in it, as the paper says ("Raw data (LFP band, AP band, and NiDAQ binary files) are not included in the NWB"), so the evoked-potential claims (Figures 1 to 3, S1, S2) and the behaviour claims (Figure 7, S10) are blocked. The three claims that compare units with the evoked-potential boundaries (fig4f, fig4i, fig5i) are tested with the boundaries the paper's code computed (intermediates/EP_distances.pkl at commit c4aaa608), the outside input their criteria name; the units' side of these tests comes from the dataset.

Claim Tested here
fig1l-saturation-amplitude, figs1b-saturation-polarity blocked, raw AP band not in the dandiset
fig2g-extent-increases, fig2g-extent-sublinear, fig2-extent-polarity, fig2h-probe-identity, figs2gh-magnitude-linear blocked, raw AP band
fig3c-volume, fig3e-ventral-compression, fig3fg-anisotropy-strength, fig3h-com-displacement blocked, raw AP band
fig4f-responders-within-ep, fig4i-proportion-within-ep, fig5i-fs-within-ep tested, with the code's evoked-potential boundaries (outside input)
fig4d-latency-trough, figs3cd-gmm-window tested
fig4h-extent-constant, fig4h-distance-decreases, fig4h-ks-distinct, fig4j-drp-increases, figs3e-early-windows tested
fig5bc-amplitude-clusters, fig5ef-polarity-clusters tested
fig5-fsrs-proportion, fig5j-fs-closer, figs6b-fsrs-early-firing tested
fig6c-modulation-cortex, fig6d-hva-subcortical, fig6e-circuit-responders, fig6f-no-propagating-wave tested
figs9-state-drp, fig6g-dimensionality-collapse tested
fig7d to fig7j, figs10c, figs10e, no-learning-across-sessions (10 claims) blocked, behaviour not in the dandiset

Every random step has its own seeded generator. The analysis takes about 13 minutes, most of it the ZETA tests (7 minutes on four cores) and the k-means stability check (2 minutes).

InΒ [1]:
import os
for _v in ["OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"]:
    os.environ.setdefault(_v, "1")
import time
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
from scipy.cluster.hierarchy import linkage, fcluster, cophenet
from scipy.spatial.distance import pdist
from sklearn.metrics import silhouette_score
from sklearn.mixture import GaussianMixture
import statsmodels.formula.api as smf
from statsmodels.stats.anova import anova_lm, AnovaRM
from statsmodels.stats.multicomp import pairwise_tukeyhsd
from statsmodels.stats.multitest import multipletests
import estim

warnings.filterwarnings("ignore", category=RuntimeWarning)
pd.set_option("display.width", 200)
pd.set_option("display.max_columns", 30)
T0 = time.time()

ASSETS = estim.list_assets()
DATA = {}
for a in ASSETS:
    DATA[a["session"]] = estim.load_session(a)
print(f"loaded {len(DATA)} files in {time.time() - T0:.0f} s")
loaded 10 files in 65 s
InΒ [2]:
# distances from the dataset's coordinates, and, as a variant, from the paper's code coordinates
CODE_COORDS = {s: estim.code_coords(s) for s in estim.PAPER_SESSIONS}
for s in estim.PAPER_SESSIONS:
    u, tr, trains, meta = DATA[s]
    contact_xyz = tr[["neg_ap", "neg_dv", "neg_ml"]].iloc[0].to_numpy()
    u["distance"], u["norm_distance"] = estim.signed_distances(u, contact_xyz)
    u["distance_code"], u["norm_distance_code"] = estim.code_distances(
        u, CODE_COORDS[s], tr.contact_negative.iloc[0])
    # the raw CCF distance signed as the code's `distance` column: the code makes it negative from the
    # channel closest to the contact on, as norm_distance (figure4_figure5_figure6.ipynb cell 7,
    # `dists[cross_index:] = -np.abs(dists[cross_index:])`); checked on the code's direct_df.csv below
    u["sdistance"] = np.where(u.norm_distance < 0, -u.distance, u.distance)
    u["sdistance_code"] = np.where(u.norm_distance_code < 0, -u.distance_code, u.distance_code)
    u["keep"] = (u.group == "good") & ~u.unstable
    u["sub_cort"] = u.region.map(estim.classify_region)
    u["region_group"] = u.region.map(estim.map_region_to_group)

Data checkΒΆ

InΒ [3]:
rows = []
for s, (u, tr, trains, meta) in DATA.items():
    probes = "".join(sorted(u.probe.unique()))
    rows.append(dict(session=s, subject=meta["subject"], probes=probes, units=len(u),
                     good=int((u.group == "good").sum()),
                     good_stable=int(((u.group == "good") & ~u.unstable).sum()),
                     has_ccf="region" in u, trials=len(tr),
                     spikes_s=f"{min(t.min() for t in trains if len(t)):.0f} to {max(t.max() for t in trains if len(t)):.0f}",
                     trials_s=f"{tr.start_time.min():.0f} to {tr.start_time.max():.0f}",
                     description=meta["description"][:60]))
files = pd.DataFrame(rows)
print(files.to_string(index=False))
paper = files[files.session.isin(estim.PAPER_SESSIONS)]
print("\nPaper recordings:", len(paper), "| probes:", sum(len(p) for p in paper.probes),
      "| probe A:", sum("A" in p for p in paper.probes), "B:", sum("B" in p for p in paper.probes),
      "C:", sum("C" in p for p in paper.probes))
print("Subjects of the paper recordings:", paper.subject.nunique(), sorted(paper.subject.unique()))
print("jlh48 and jlh49v1 (same subject id):",
      [(s, DATA[s][3]["session_id"]) for s in ["jlh48", "jlh49v1"]])

print("\nUnit groups:", pd.concat([d[0].group for d in DATA.values()]).value_counts().to_dict())
print("Unstable among good units, by recording:",
      {s: int((DATA[s][0].group.eq("good") & DATA[s][0].unstable).sum()) for s in estim.PAPER_SESSIONS})

print("\nTrials per condition (single pulses, 100 us per phase), the counts used (first 75):")
cond = {}
for s in estim.PAPER_SESSIONS:
    tr = DATA[s][1]
    cond[s] = {f"{p[0]}{a}": len(estim.condition_trials(tr, p, a, n=None)) for p in estim.POLARITIES for a in estim.AMPS}
print(pd.DataFrame(cond).T.to_string())
print("\nTrial table values: pulse_duration", sorted(pd.concat([d[1].pulse_duration for d in DATA.values()]).unique()),
      "| amplitude", sorted(pd.concat([d[1].amplitude for d in DATA.values()]).unique()),
      "| polarity", sorted(pd.concat([d[1].polarity for d in DATA.values()]).unique()))

print("\nRuns and contacts of the cathodal single-pulse conditions (all matching trials, and the trials used):")
rows = []
for s in estim.PAPER_SESSIONS:
    tr = DATA[s][1]
    for a in estim.AMPS:
        sub = estim.condition_trials(tr, "cathodal", a, n=None)
        if len(sub):
            used = estim.condition_trials(tr, "cathodal", a)
            rows.append(dict(session=s, amp=a, runs=sorted(sub.run.unique().tolist()), contact_negative=sorted(sub.contact_negative.unique().tolist()),
                             contact_positive=sorted(sub.contact_positive.unique().tolist()), trials=len(sub), used=len(used), used_runs=sorted(used.run.unique().tolist())))
print(pd.DataFrame(rows).to_string(index=False))
print("Cathodal contacts per recording (monopolar pulses):", {s: sorted(DATA[s][1][DATA[s][1].pol == "cathodal"].contact_negative.unique().tolist()) for s in estim.PAPER_SESSIONS})

allu = pd.concat([DATA[s][0] for s in estim.PAPER_SESSIONS])
print("\nUnit CCF ranges (um): AP %.0f to %.0f, DV %.0f to %.0f, ML %.0f to %.0f" % (
    allu.ap.min(), allu.ap.max(), allu.dv.min(), allu.dv.max(), allu.ml.min(), allu.ml.max()))
print("Distance to the first trial's cathodal contact (um): %.0f to %.0f, median %.0f" % (
    allu.distance.min(), allu.distance.max(), allu.distance.median()))
print("Signed distance (um): %.0f to %.0f" % (allu.norm_distance.min(), allu.norm_distance.max()))
print("Waveform duration (ms): %.2f to %.2f" % (allu.wf_duration.min() * 1e3, allu.wf_duration.max() * 1e3))
print("Regions of good units:", allu[allu.group == "good"].sub_cort.value_counts(dropna=False).to_dict())
    session subject probes  units  good  good_stable  has_ccf  trials       spikes_s       trials_s                                                  description
      jlh31   jlh31    ABC    785   409          405     True    2700   1860 to 8502   1992 to 8439                                                   craniotomy
      jlh32   jlh32    ABC    725   403          381     True    1923   2478 to 7534   2569 to 7475                                                   burr holes
      jlh33   jlh33     BC    258   175          175     True    1440   2585 to 6806   2669 to 6777                                                   burr holes
      jlh34   jlh34    ABC    589   263          263     True    2100  5026 to 10925  5133 to 10908                                                   craniotomy
      jlh39   jlh39    ABC    580   209          197     True     750   1559 to 3260   1732 to 4014                                             small craniotomy
      jlh40   jlh40     BC   1353   200          200     True     825 -4071 to -1540 -3951 to -1565                                             small craniotomy
      jlh41   jlh41    ABC   1542   216          207    False     525      7 to 3573    408 to 3113 small craniotomy, lots of noise issues. potentially related 
      jlh48   jlh48    ABC   1162   321          306     True    1200    565 to 3867    772 to 3833 burr holes. goal was to test the brains board but this nwb c
    jlh49v1   jlh48    ABC   1094   459          452     True     300   2024 to 6368   2525 to 3229 burr holes. goal was to test the brains board but this nwb c
jlh53concat   jlh53    ABC    857   391          384     True    1800  -1799 to 4947  -1136 to 4841 burr holes. typical experiment with a very slightly modified

Paper recordings: 9 | probes: 25 | probe A: 7 B: 9 C: 9
Subjects of the paper recordings: 8 ['jlh31', 'jlh32', 'jlh33', 'jlh34', 'jlh39', 'jlh40', 'jlh48', 'jlh53']
jlh48 and jlh49v1 (same subject id): [('jlh48', 'jlh48_2024-11-05_13-08-08'), ('jlh49v1', 'jlh49v1_2024-07-11_16-18-42')]

Unit groups: {'mua': 3453, 'good': 3046, 'nan': 1753, 'noise': 693}
Unstable among good units, by recording: {'jlh31': 4, 'jlh32': 22, 'jlh33': 0, 'jlh34': 0, 'jlh39': 12, 'jlh40': 0, 'jlh48': 15, 'jlh49v1': 7, 'jlh53concat': 7}

Trials per condition (single pulses, 100 us per phase), the counts used (first 75):
              c5  c25  c50  c100  a5  a25  a50  a100   b5  b25  b50  b100
jlh31         75  150  150   150  75  150  150   150  150  150  150   150
jlh32         75   75   75    75  75   75   75    75  150  150  150   150
jlh33         60   60   60    60  60   60    0    60  120  180  180   180
jlh34        150  150  150   150  75  150  150    75   75  150  150   150
jlh39          0    0    0     0   0    0    0     0  150  225  225   150
jlh40         75   75   75    75  75   75   75    75    0    0    0     0
jlh48         75   75   75    75  75   75   75    75  150  150  150   150
jlh49v1        0    0    0     0   0    0    0     0   75   75   75    75
jlh53concat   75   75   75    75  75   75   75    75  150  150  150   150

Trial table values: pulse_duration [np.int32(30), np.int32(50), np.int32(100), np.int32(200)] | amplitude [np.int32(-100), np.int32(-50), np.int32(-25), np.int32(-10), np.int32(-5), np.int32(5), np.int32(10), np.int32(25), np.int32(50), np.int32(100)] | polarity ['bipolar', 'monopolar']

Runs and contacts of the cathodal single-pulse conditions (all matching trials, and the trials used):
    session  amp     runs contact_negative contact_positive  trials  used used_runs
      jlh31    5     [16]             [16]              [0]      75    75      [16]
      jlh31   25 [18, 28]             [16]              [0]     150    75      [18]
      jlh31   50 [20, 30]             [16]              [0]     150    75      [20]
      jlh31  100 [22, 32]             [16]              [0]     150    75      [22]
      jlh32    5     [12]              [3]              [0]      75    75      [12]
      jlh32   25     [14]              [3]              [0]      75    75      [14]
      jlh32   50     [16]              [3]              [0]      75    75      [16]
      jlh32  100     [18]              [3]              [0]      75    75      [18]
      jlh33    5      [8]              [5]              [0]      60    60       [8]
      jlh33   25     [10]              [5]              [0]      60    60      [10]
      jlh33   50     [12]              [5]              [0]      60    60      [12]
      jlh33  100     [13]              [5]              [0]      60    60      [13]
      jlh34    5  [7, 15]             [15]              [0]     150    75       [7]
      jlh34   25  [9, 16]             [15]              [0]     150    75       [9]
      jlh34   50 [11, 19]             [15]              [0]     150    75      [11]
      jlh34  100 [13, 18]             [15]              [0]     150    75      [13]
      jlh40    5      [0]             [16]              [0]      75    75       [0]
      jlh40   25      [1]             [16]              [0]      75    75       [1]
      jlh40   50      [2]             [16]              [0]      75    75       [2]
      jlh40  100      [6]             [16]              [0]      75    75       [6]
      jlh48    5      [8]             [14]              [0]      75    75       [8]
      jlh48   25     [10]             [14]              [0]      75    75      [10]
      jlh48   50     [12]             [14]              [0]      75    75      [12]
      jlh48  100     [14]             [14]              [0]      75    75      [14]
jlh53concat    5      [0]             [16]              [0]      75    75       [0]
jlh53concat   25      [2]             [16]              [0]      75    75       [2]
jlh53concat   50      [4]             [16]              [0]      75    75       [4]
jlh53concat  100      [6]             [16]              [0]      75    75       [6]
Cathodal contacts per recording (monopolar pulses): {'jlh31': [16], 'jlh32': [3], 'jlh33': [5], 'jlh34': [15], 'jlh39': [], 'jlh40': [16], 'jlh48': [14], 'jlh49v1': [], 'jlh53concat': [16]}

Unit CCF ranges (um): AP 6859 to 9635, DV 368 to 2633, ML 1527 to 4376
Distance to the first trial's cathodal contact (um): 86 to 2281, median 664
Signed distance (um): -882 to 1912
Waveform duration (ms): 0.03 to 1.48
Regions of good units: {'Cortical': 1761, nan: 561, 'Subcortical': 508}
InΒ [4]:
# The dataset's coordinates against the paper's code coordinates (outside input, a check only)
rows = []
for s in estim.PAPER_SESSIONS:
    u, tr, trains, meta = DATA[s]
    cc = CODE_COORDS[s]
    off = []
    for c in tr.contact_negative.unique():
        ds = tr.loc[tr.contact_negative == c, ["neg_ap", "neg_dv", "neg_ml"]].iloc[0].to_numpy()
        off.append(np.round(ds - cc["stim"][estim.NN_CONTACTS.index(int(c))]).astype(int).tolist())
    dd = []
    for i, r in u.iterrows():
        arr = cc.get("probe" + r.probe)
        if arr is None:
            continue
        dd.append(np.linalg.norm(arr[min(r.ch, len(arr) - 1)] - np.array([r.ap, r.dv, r.ml])))
    dd = np.array(dd)
    k = u.keep.to_numpy()
    rows.append(dict(session=s, contact_offset_um=off[0], contacts_same_offset=all(o == off[0] for o in off),
                     unit_offset_median_um=round(float(np.median(dd)), 1),
                     units_over_100um=int((dd > 100).sum()),
                     distance_diff_median_um=round(float(np.nanmedian(np.abs(u.distance - u.distance_code)[k])), 1)))
print(pd.DataFrame(rows).to_string(index=False))
    session contact_offset_um  contacts_same_offset  unit_offset_median_um  units_over_100um  distance_diff_median_um
      jlh31  [100, 150, -100]                  True                   24.0                 0                    129.9
      jlh32     [370, 0, -50]                  True                   24.5                 1                    144.0
      jlh33         [0, 0, 0]                  True                   24.0                 1                     18.3
      jlh34         [0, 0, 0]                  True                   24.0                 1                      3.6
      jlh39         [0, 0, 0]                  True                   24.8                 3                      4.8
      jlh40         [0, 0, 0]                  True                   24.0                 5                      5.0
      jlh48         [0, 0, 0]                  True                   25.3                 3                      7.2
    jlh49v1         [0, 0, 0]                  True                   24.0                 3                      9.3
jlh53concat         [0, 0, 0]                  True                   24.0                 7                     10.1
InΒ [5]:
# Clock check: population spikes in 0 to 20 ms after 100 uA pulses (cathodal, or bipolar where
# there is none) against the 20 ms before, with the true onsets and with onsets moved 1 s later
# (half way between pulses, which are 2 s apart). Paired Wilcoxon over trials.
rows = []
for s in estim.PAPER_SESSIONS:
    u, tr, trains, meta = DATA[s]
    pol = "cathodal" if len(estim.condition_trials(tr, "cathodal", 100)) else "bipolar"
    pop = np.sort(np.concatenate([trains[i] for i in np.where(u.keep)[0]]))
    res = {"session": s, "polarity": pol}
    for label, shift in [("true", 0.0), ("shifted", 1.0)]:
        st = estim.condition_trials(tr, pol, 100).start_time.to_numpy() + shift
        post = np.searchsorted(pop, st + 0.02) - np.searchsorted(pop, st)
        pre = np.searchsorted(pop, st) - np.searchsorted(pop, st - 0.02)
        res[f"{label}_post_over_pre"] = round(post.mean() / pre.mean(), 2)
        res[f"{label}_p"] = stats.wilcoxon(post, pre).pvalue if (post != pre).any() else 1.0
    rows.append(res)
print(pd.DataFrame(rows).to_string(index=False))
# Population spike count per trial in 1 ms bins from 8 ms before to 2 ms after the logged onset, 100 uA
edges = np.round(np.arange(-0.008, 0.0021, 0.001), 4)
rows = {}
for s in estim.PAPER_SESSIONS:
    u, tr, trains, meta = DATA[s]
    pol = "cathodal" if len(estim.condition_trials(tr, "cathodal", 100)) else "bipolar"
    pop = np.sort(np.concatenate([trains[i] for i in np.where(u.keep)[0]]))
    rows[f"{s} ({pol})"] = np.round(estim.mean_counts(pop, estim.condition_trials(tr, pol, 100).start_time.to_numpy(), edges), 2)
print("\nPopulation spikes per trial in 1 ms bins starting at", (edges[:-1] * 1000).astype(int).tolist(), "ms:")
print(pd.DataFrame(rows, index=(edges[:-1] * 1000).astype(int)).T.to_string())
    session polarity  true_post_over_pre       true_p  shifted_post_over_pre  shifted_p
      jlh31 cathodal                4.55 5.261854e-14                   0.96   0.214150
      jlh32 cathodal                1.44 7.157224e-09                   1.04   0.442914
      jlh33 cathodal                1.96 3.180128e-10                   0.96   0.587237
      jlh34 cathodal                4.03 5.203523e-14                   1.06   0.487245
      jlh39  bipolar                2.00 1.557926e-10                   1.07   0.294861
      jlh40 cathodal                1.29 2.888356e-05                   0.98   0.483911
      jlh48 cathodal                1.55 8.214772e-11                   1.01   0.920567
    jlh49v1  bipolar                1.78 1.058268e-13                   0.99   0.978502
jlh53concat cathodal                0.58 3.560547e-11                   1.03   0.607904
Population spikes per trial in 1 ms bins starting at [-8, -7, -6, -5, -4, -3, -2, -1, 0, 1] ms:
                          -8    -7    -6    -5    -4    -3    -2    -1     0     1
jlh31 (cathodal)        1.73  1.87  1.44  1.41  1.11  0.99  0.61  1.48  4.97  2.87
jlh32 (cathodal)        1.60  1.75  1.49  1.28  0.80  0.41  0.07  0.45  8.05  4.96
jlh33 (cathodal)        0.98  0.95  0.97  0.72  0.63  0.50  0.12  0.15  0.70  1.32
jlh34 (cathodal)        0.81  1.15  1.09  0.79  0.64  0.36  0.16  0.36  6.43  5.11
jlh39 (bipolar)         0.84  0.69  0.69  0.69  0.44  0.24  0.11  0.41  1.07  0.13
jlh40 (cathodal)        1.12  1.08  1.12  0.95  0.67  0.37  0.12  0.13  0.55  0.17
jlh48 (cathodal)        1.68  1.28  1.32  0.80  0.59  0.35  0.12  0.36  1.47  0.72
jlh49v1 (bipolar)       2.03  2.12  1.71  1.71  1.12  0.37  0.20  1.77  3.73  0.27
jlh53concat (cathodal)  1.31  1.45  1.37  1.29  0.89  0.51  0.12  0.12  1.55  0.35

The checks against the description:

  • Ten files; nine are the recordings the paper's code loads, and the tenth (jlh41, "lots of noise issues ... not much analyzed yet here") has no CCF coordinates or regions; it is left out. The nine give the paper's 25 probes (A 7, B 9, C 9). They come from eight subject ids: the file of session jlh49v1 is filed under subject jlh48, which the paper and its code treat as a separate recording and mouse; its age (P381D) is one day older than the jlh48 file recorded four months later.
  • The electrodes table holds placeholders (location "none", constant coordinates), so channel CCF coordinates come from the units' own coordinates.
  • Conditions are labelled "monopolar" or "bipolar" with signed amplitudes; cathodal and anodal are the sign. Cathodal single pulses are in seven of the nine recordings (jlh39 and jlh49v1 hold only bipolar pulses, jlh40 no bipolar), so the claims on cathodal pulses rest on seven recordings, where the paper's legends say nine. jlh33 has 60 trials per condition, where the Methods say 75; its anodal 50 uA single pulse is missing.
  • Each recording has one cathodal contact for monopolar pulses. jlh31 (25 to 100 uA) and jlh34 (all four amplitudes) ran each cathodal condition twice at that contact, 150 trials; the first 75 are the first run, which the analysis uses, and the claims file's "layer 5" reading takes the same trials. The second run is shown as a variant in fig4j-drp-increases. In jlh53concat, a concatenated recording, the run numbers 0 to 7 are used both by monopolar and by bipolar runs.
  • Trial onsets and spike times share a clock: population firing in the 20 ms after a 100 uA pulse differs from the 20 ms before it with the true onsets, and not with onsets moved 1 s later (table above). Two files (jlh40, jlh53concat) have negative times; this does not affect the alignment. In every recording the population fires less in the 3 ms before the logged onset, down to 0.07 to 0.20 spikes per trial from 2 to 1 ms before it (0.61 in jlh31), against 0.81 to 2.03 from 8 to 7 ms before (second table). The trials table does not say what happens there; the direct window and the nDRP baseline (7 to 5 ms before onset) lie outside it.
  • Unit coordinates agree with the paper's code coordinates at the unit's channel to a median of about 24 um; units beyond the end of the code's channel arrays differ more. The stimulation contact coordinates of jlh31 and jlh32 differ from the code's by 206 and 373 um; the other seven agree exactly. Distances are computed from the dataset's coordinates, with the code's shown as a variant where they matter.
InΒ [6]:
# Direct response table: nDRP per good stable unit and condition (Methods, DRP)
def drp_table(sessions=estim.PAPER_SESSIONS, win=0.002, base=(0.007, 0.005), polarities=estim.POLARITIES,
              contact_choice=None):
    rows = []
    for s in sessions:
        u, tr, trains, meta = DATA[s]
        idx = np.where(u.keep)[0]
        for pol in polarities:
            for a in estim.AMPS:
                contact = contact_choice.get((s, pol)) if contact_choice else None
                c = estim.condition_trials(tr, pol, a, contact=contact)
                if len(c) == 0:
                    continue
                st = c.start_time.to_numpy()
                for i in idx:
                    p, pb, n = estim.spike_prob(trains[i], st, win=win, base=base)
                    if np.isnan(p):
                        continue
                    rows.append((s, i, pol, a, p, pb, n))
    df = pd.DataFrame(rows, columns=["session", "unit", "pol", "amp", "drp", "base", "ndrp"])
    meta_cols = ["distance", "norm_distance", "distance_code", "norm_distance_code", "probe", "region",
                 "sub_cort", "wf_kmeans", "wf_threshold", "sdistance", "sdistance_code"]
    parts = []
    for s, g in df.groupby("session"):
        parts.append(g.join(DATA[s][0][meta_cols], on="unit"))
    return pd.concat(parts, ignore_index=True)

DRP = drp_table()
CAT = DRP[DRP.pol == "cathodal"]
RESP = CAT[CAT.ndrp > 0.15]
print("Unit and condition values:", len(DRP), "| cathodal:", len(CAT))
print(CAT.groupby("amp").agg(units=("ndrp", "size"), responders=("ndrp", lambda x: int((x > 0.15).sum())),
                             proportion=("ndrp", lambda x: (x > 0.15).mean())).round(4).to_string())
print("Responders by recording and amplitude:")
print(RESP.groupby(["session", "amp"]).size().unstack(fill_value=0).to_string())
print("Unit values per condition:", DRP.groupby(["pol", "amp"]).size().to_dict())
Unit and condition values: 26385 | cathodal: 8265
     units  responders  proportion
amp                               
5     2056          29      0.0141
25    2068          34      0.0164
50    2070          45      0.0217
100   2071          45      0.0217
Responders by recording and amplitude:
amp          5    25   50   100
session                        
jlh31          5   11   10   12
jlh32          2    5    9   11
jlh33          0    0    0    2
jlh34          2   10   18   14
jlh40         11    3    1    1
jlh48          5    3    5    2
jlh53concat    4    2    2    3
Unit values per condition: {('anodal', 5): 2082, ('anodal', 25): 2082, ('anodal', 50): 1901, ('anodal', 100): 2067, ('bipolar', 5): 2467, ('bipolar', 25): 2501, ('bipolar', 50): 2514, ('bipolar', 100): 2506, ('cathodal', 5): 2056, ('cathodal', 25): 2068, ('cathodal', 50): 2070, ('cathodal', 100): 2071}
InΒ [7]:
# Check against the code's intermediate table direct_df.csv (outside input, a check only): the
# number of good stable units per condition, and the sign of its raw `distance` column
CDF = estim.code_direct_df().assign(pol=lambda x: x.polarity, amp=lambda x: x.amplitude.abs())
cmp_n = pd.concat([DRP.groupby(["pol", "amp"]).size().rename("this notebook"),
                   CDF.groupby(["pol", "amp"]).size().rename("code direct_df.csv")], axis=1)
print(cmp_n.T.to_string())
print("Equal in", int((cmp_n.iloc[:, 0] == cmp_n.iloc[:, 1]).sum()), "of", len(cmp_n), "conditions;",
      "cathodal equal in", int((cmp_n.loc["cathodal"].iloc[:, 0] == cmp_n.loc["cathodal"].iloc[:, 1]).sum()), "of 4")
print(f"Code's table: distance negative in {(CDF.distance < 0).mean():.3f} of rows; distance <= 0 exactly where "
      f"norm_distance <= 0 in {((CDF.distance <= 0) == (CDF.norm_distance <= 0)).mean():.4f} of rows")
pol                anodal                   bipolar                   cathodal                  
amp                   5     25    50    100     5     25    50    100      5     25    50    100
this notebook        2082  2082  1901  2067    2467  2501  2514  2506     2056  2068  2070  2071
code direct_df.csv   2082  2082  1901  2067    2465  2502  2515  2511     2056  2068  2070  2071
Equal in 8 of 12 conditions; cathodal equal in 4 of 4
Code's table: distance negative in 0.294 of rows; distance <= 0 exactly where norm_distance <= 0 in 1.0000 of rows

fig4d-latency-troughΒΆ

First-spike latencies after a pulse are bimodal, with an early peak, a pause at a median of 1.95 ms across parameters and a broader peak from about 2.5 ms, which sets the 2 ms direct window.

First spike after each pulse (within 500 ms) for all good units, all trials of the condition's contact (the code's choose_stim_parameter without the 75-trial cut, figure4_figure5_figure6.ipynb cell 20), 0.1 ms bins over 0 to 15 ms, trough the lowest bin with centre in 0.5 to 5 ms (cell 22).

InΒ [8]:
def latencies(pol, a, n=None, stable=False):
    out = []
    for s in estim.PAPER_SESSIONS:
        u, tr, trains, meta = DATA[s]
        c = estim.condition_trials(tr, pol, a, n=n)
        if len(c) == 0:
            continue
        st = c.start_time.to_numpy()
        sel = u.keep if stable else (u.group == "good")
        for i in np.where(sel)[0]:
            out.append(estim.first_latencies(trains[i], st))
    return np.concatenate(out)

EDGES = np.arange(0, 15.1, 0.1)
def trough(lat):
    cnt, e = np.histogram(lat, EDGES)
    ctr = e[:-1] + 0.05
    m = (ctr >= 0.5) & (ctr <= 5)
    j = np.where(m)[0][np.argmin(cnt[m])]
    return ctr[j], cnt[j], cnt[:j].max(), cnt[j + 1:].max(), ctr[j + 1:][np.argmax(cnt[j + 1:])]

LAT = {}
rows = []
for pol in estim.POLARITIES:
    for a in estim.AMPS:
        lat = latencies(pol, a)
        LAT[(pol, a)] = lat
        t, c, before, after, after_at = trough(lat)
        t75 = trough(latencies(pol, a, n=75, stable=True))[0]
        rows.append(dict(pol=pol, amp=a, n_latencies=int(((lat >= 0) & (lat <= 15)).sum()), trough_ms=round(t, 2),
                         trough_count=c, max_before=before, max_after=after, after_peak_ms=round(after_at, 2),
                         trough_first75_stable=round(t75, 2)))
lt = pd.DataFrame(rows)
print(lt.to_string(index=False))
print("\nMedian trough, cathodal (criterion):", lt[lt.pol == "cathodal"].trough_ms.median(),
      "| all 12 conditions:", lt.trough_ms.median(),
      "| first 75 trials, stable units, cathodal:", lt[lt.pol == "cathodal"].trough_first75_stable.median())
print("Conditions with a higher bin before and after the trough:", int(((lt.max_before > lt.trough_count) & (lt.max_after > lt.trough_count)).sum()), "of", len(lt))
fig, ax = plt.subplots(1, 4, figsize=(11, 2.5), sharey=False)
for k, a in enumerate(estim.AMPS):
    ax[k].hist(LAT[("cathodal", a)], EDGES, color="k")
    ax[k].axvline(lt[(lt.pol == "cathodal") & (lt.amp == a)].trough_ms.iloc[0], color="r")
    ax[k].set_title(f"cathodal {a} uA"); ax[k].set_xlabel("first spike latency (ms)")
plt.tight_layout(); plt.show()
print("All latencies plotted lie in 0 to 15 ms by the bins; latencies beyond 15 ms are not shown.")
     pol  amp  n_latencies  trough_ms  trough_count  max_before  max_after  after_peak_ms  trough_first75_stable
cathodal    5         9045       1.95            23         374         81           5.25                   1.95
cathodal   25        12672       1.75            25         323        111          11.85                   1.75
cathodal   50        16182       1.95            51         297        141           7.65                   1.45
cathodal  100        19842       1.95            76         453        165           3.75                   1.95
  anodal    5         8896       1.05            22         397         77           8.05                   1.05
  anodal   25        11317       1.45            17         459        106           8.25                   1.45
  anodal   50        12463       1.45            27         347        107           7.15                   2.25
  anodal  100        14693       1.45            34         376        129           7.25                   1.45
 bipolar    5        19032       1.25            58         927        169           5.95                   1.25
 bipolar   25        21245       1.35            42         974        169           9.05                   1.35
 bipolar   50        25778       1.25            57         842        228           5.85                   1.25
 bipolar  100        28580       1.45            67         632        249           8.85                   1.25

Median trough, cathodal (criterion): 1.95 | all 12 conditions: 1.45 | first 75 trials, stable units, cathodal: 1.85
Conditions with a higher bin before and after the trough: 12 of 12
No description has been provided for this image
All latencies plotted lie in 0 to 15 ms by the bins; latencies beyond 15 ms are not shown.

Result: reproduced. The trough of the first-spike latency histogram lies at 1.95, 1.75, 1.95 and 1.95 ms for 5 to 100 uA cathodal pulses, a median of 1.95 ms, with a higher bin before and after it in all 12 conditions. Across all 12 conditions the median is 1.45 ms (anodal and bipolar troughs at 1.05 to 1.45 ms); the criterion takes the cathodal amplitudes. The later cathodal peaks lie at 3.75 to 11.85 ms.

figs3cd-gmm-windowΒΆ

Gaussian mixtures of first-spike latencies give amplitude-specific direct windows (0.68, 1.14, 1.22 and 1.47 ms at 5, 25, 50 and 100 uA cathodal), and the proportion of direct responders does not differ from that of the 2 ms window.

Latencies of fig4d (0 to 15 ms), one-dimensional GMM (sklearn, maximum likelihood, 10 initialisations, seeded), readings (a) 4 components and (b) the number of components (1 to 4) with the lowest BIC. The window is where the posterior of the second-earliest component first equals that of the earliest (Methods, Gaussian mixture models). Responders per recording with each window and the 2 ms window, Wilcoxon signed-rank over recordings, FDR (Benjamini-Hochberg) over amplitudes.

InΒ [9]:
rng_gmm = np.random.default_rng(3)
GRID = np.linspace(0, 15, 15001)
def gmm_window(x, k, seed):
    g = GaussianMixture(k, n_init=10, random_state=seed).fit(x[:, None])
    order = np.argsort(g.means_.ravel())
    if k < 2:
        return np.nan, g
    post = g.predict_proba(GRID[:, None])[:, order]
    m0 = g.means_.ravel()[order][0]
    after = GRID > m0
    cross = np.where(after & (post[:, 1] >= post[:, 0]))[0]
    return (GRID[cross[0]] if len(cross) else np.nan), g

WIN = {}
rows = []
for a in estim.AMPS:
    x = LAT[("cathodal", a)]
    x = x[(x >= 0) & (x <= 15)]
    seed = int(rng_gmm.integers(1e9))
    w4, g4 = gmm_window(x, 4, seed)
    bics = {k: GaussianMixture(k, n_init=10, random_state=seed).fit(x[:, None]).bic(x[:, None]) for k in range(1, 5)}
    kb = min(bics, key=bics.get)
    wb, _ = gmm_window(x, kb, seed)
    WIN[a] = {"a": w4, "b": wb}
    rows.append(dict(amp=a, n=len(x), window_k4_ms=round(w4, 3), means_k4=np.round(np.sort(g4.means_.ravel()), 2).tolist(),
                     bic_k=kb, window_bic_ms=round(wb, 3) if np.isfinite(wb) else np.nan))
print(pd.DataFrame(rows).to_string(index=False))
print("Expected 0.68, 1.14, 1.22, 1.47 ms (tolerance 10%)")

CAT_SESS = sorted(CAT.session.unique())
def prop_responders(win, base):
    out = {}
    for s in CAT_SESS:
        u, tr, trains, meta = DATA[s]
        idx = np.where(u.keep)[0]
        out[s] = {}
        for a in estim.AMPS:
            st = estim.condition_trials(tr, "cathodal", a).start_time.to_numpy()
            w = win[a] if isinstance(win, dict) else win
            v = np.array([estim.spike_prob(trains[i], st, win=w / 1000, base=base(w))[2] for i in idx])
            v = v[np.isfinite(v)]
            out[s][a] = np.mean(v > 0.15)
    return pd.DataFrame(out).T

P2 = prop_responders(2.0, lambda w: (0.007, 0.005))
for reading in ["a", "b"]:
    win = {a: WIN[a][reading] for a in estim.AMPS}
    if not all(np.isfinite(list(win.values()))):
        print(f"reading {reading}: no window at some amplitude, {win}")
        continue
    for bname, base in [("(i) baseline as long as the window", lambda w: (0.005 + w / 1000, 0.005)),
                        ("(ii) 2 ms baseline", lambda w: (0.007, 0.005))]:
        PG = prop_responders(win, base)
        p = [stats.wilcoxon(PG[a], P2[a]).pvalue if (PG[a] != P2[a]).any() else 1.0 for a in estim.AMPS]
        padj = multipletests(p, method="fdr_bh")[1]
        allp = stats.wilcoxon(PG.to_numpy().ravel(), P2.to_numpy().ravel()).pvalue
        print(f"\nGMM reading {reading}, {bname}: mean proportion GMM {PG.mean().round(4).to_dict()} | 2 ms {P2.mean().round(4).to_dict()}")
        print("   Wilcoxon p", np.round(p, 4).tolist(), "FDR", np.round(padj, 4).tolist(), "| pooled over amplitudes p", round(allp, 4))
print("\nRecordings:", len(CAT_SESS))
 amp     n  window_k4_ms                  means_k4  bic_k  window_bic_ms
   5  9045         0.683 [0.24, 4.34, 9.21, 13.17]      4          0.683
  25 12672         1.139 [0.42, 4.57, 8.88, 12.87]      4          1.139
  50 16182         1.221  [0.5, 4.52, 9.03, 13.03]      4          1.221
 100 19842         1.470 [0.68, 4.13, 8.93, 12.86]      4          1.470
Expected 0.68, 1.14, 1.22, 1.47 ms (tolerance 10%)
GMM reading a, (i) baseline as long as the window: mean proportion GMM {5: 0.0149, 25: 0.0145, 50: 0.015, 100: 0.0178} | 2 ms {5: 0.0156, 25: 0.0157, 50: 0.0208, 100: 0.0207}
   Wilcoxon p [1.0, 0.5, 0.0625, 0.125] FDR [1.0, 0.6667, 0.25, 0.25] | pooled over amplitudes p 0.0022
GMM reading a, (ii) 2 ms baseline: mean proportion GMM {5: 0.0149, 25: 0.0145, 50: 0.015, 100: 0.017} | 2 ms {5: 0.0156, 25: 0.0157, 50: 0.0208, 100: 0.0207}
   Wilcoxon p [1.0, 0.5, 0.0625, 0.125] FDR [1.0, 0.6667, 0.25, 0.25] | pooled over amplitudes p 0.0022
GMM reading b, (i) baseline as long as the window: mean proportion GMM {5: 0.0149, 25: 0.0145, 50: 0.015, 100: 0.0178} | 2 ms {5: 0.0156, 25: 0.0157, 50: 0.0208, 100: 0.0207}
   Wilcoxon p [1.0, 0.5, 0.0625, 0.125] FDR [1.0, 0.6667, 0.25, 0.25] | pooled over amplitudes p 0.0022
GMM reading b, (ii) 2 ms baseline: mean proportion GMM {5: 0.0149, 25: 0.0145, 50: 0.015, 100: 0.017} | 2 ms {5: 0.0156, 25: 0.0157, 50: 0.0208, 100: 0.0207}
   Wilcoxon p [1.0, 0.5, 0.0625, 0.125] FDR [1.0, 0.6667, 0.25, 0.25] | pooled over amplitudes p 0.0022

Recordings: 7

Result: reproduced. Four-component mixtures put the direct window at 0.683, 1.139, 1.221 and 1.470 ms for 5 to 100 uA, and BIC also chooses four components at every amplitude. Over the seven recordings with cathodal pulses, the proportion of responders with these windows does not differ from the 2 ms window at any amplitude (smallest FDR-adjusted p 0.25, under both baselines). Pooled over the four amplitudes, the GMM windows give fewer responders (Wilcoxon p = 0.0022), a test the legend does not name.

fig4f-responders-within-epΒΆ

Direct responders are sparse and mostly lie within the average boundaries of the evoked potential (33.3%, 74.1%, 88.2% and 100% at 5, 25, 50 and 100 uA), with more outside at 5 uA.

The evoked-potential boundaries need the raw AP band, which the dandiset does not hold. The criterion names the code's EP_distances.pkl as an outside input for the author to judge; it is used here (estim.code_ep_bounds: the code's per-probe boundaries, averaged over probes as in its cell 25), and everything on the units' side comes from the dataset. A cathodal responder is within when its signed distance lies between the mean superficial and deep boundaries at its amplitude (code cell 27). Readings (a) all responders, (b) RS responders (code cell 54), with the code's threshold classes and the Methods' k-means; distances from the dataset's coordinates and, as a variant, the code's.

InΒ [10]:
EPB = estim.code_ep_bounds()["cathodal"]
print("Cathodal evoked-potential boundaries (outside input; mean superficial, mean deep, probes):",
      {a: (round(lo), round(hi), n) for a, (lo, hi, n) in EPB.items()})
def within_ep(d, col, bounds_amp=None):
    lo = d.amp.map(lambda a: EPB[bounds_amp or a][0])
    hi = d.amp.map(lambda a: EPB[bounds_amp or a][1])
    return (d[col] >= lo) & (d[col] <= hi)
rows = []
for col in ["norm_distance", "norm_distance_code"]:
    for reading, cls_col in [("a", None), ("b", "wf_threshold"), ("b", "wf_kmeans")]:
        d = RESP if cls_col is None else RESP[RESP[cls_col] == "rs"]
        w = within_ep(d, col).groupby(d.amp).agg(["mean", "size"])
        rows.append(dict(distances=col, reading=reading, classes=cls_col or "-",
                         **{f"{a} uA": f"{w.loc[a, 'mean']:.3f} (n={int(w.loc[a, 'size'])})" for a in estim.AMPS}))
print(pd.DataFrame(rows).to_string(index=False))
print("By recording, reading (b), threshold classes, dataset coordinates (fraction within, n):")
d = RESP[RESP.wf_threshold == "rs"]
print(within_ep(d, "norm_distance").groupby([d.session, d.amp]).agg(lambda x: f"{x.mean():.2f} ({len(x)})").unstack().to_string())
print("Expected 0.333, 0.741, 0.882, 1.0 (10%), 5 uA the lowest; the code prints 0.483, 0.765, 0.889, 1.0 for all responders")
Cathodal evoked-potential boundaries (outside input; mean superficial, mean deep, probes): {5: (-424, 510, 5), 25: (-725, 605, 31), 50: (-812, 747, 30), 100: (-750, 888, 19)}
         distances reading      classes         5 uA        25 uA        50 uA       100 uA
     norm_distance       a            - 0.552 (n=29) 0.794 (n=34) 0.889 (n=45) 1.000 (n=45)
     norm_distance       b wf_threshold 0.400 (n=15) 0.778 (n=27) 0.882 (n=34) 1.000 (n=30)
     norm_distance       b    wf_kmeans 0.520 (n=25) 0.774 (n=31) 0.900 (n=40) 1.000 (n=37)
norm_distance_code       a            - 0.483 (n=29) 0.765 (n=34) 0.889 (n=45) 1.000 (n=45)
norm_distance_code       b wf_threshold 0.333 (n=15) 0.741 (n=27) 0.882 (n=34) 1.000 (n=30)
norm_distance_code       b    wf_kmeans 0.480 (n=25) 0.742 (n=31) 0.900 (n=40) 1.000 (n=37)
By recording, reading (b), threshold classes, dataset coordinates (fraction within, n):
amp               5          25         50         100
session                                               
jlh31        1.00 (3)  1.00 (11)   1.00 (8)   1.00 (9)
jlh32        1.00 (2)   1.00 (3)   1.00 (5)   1.00 (7)
jlh33             NaN        NaN        NaN   1.00 (1)
jlh34        0.00 (2)   0.67 (9)  0.88 (17)  1.00 (12)
jlh40        0.00 (5)   0.00 (2)   0.00 (1)        NaN
jlh48        0.50 (2)   0.00 (1)   0.50 (2)        NaN
jlh53concat  0.00 (1)   1.00 (1)   1.00 (1)   1.00 (1)
Expected 0.333, 0.741, 0.882, 1.0 (10%), 5 uA the lowest; the code prints 0.483, 0.765, 0.889, 1.0 for all responders

Result: not reproduced, resting on an assumption. With the dataset's coordinates the fractions of RS responders within the evoked potential (reading b, the code's classes) are 0.400, 0.778, 0.882 and 1.000 at 5 to 100 uA: three are within 10% of 0.741, 0.882 and 1.0, the 5 uA fraction is the lowest, and 0.400 is outside 0.333 +- 0.033. Over all responders (reading a) they are 0.552, 0.794, 0.889 and 1.000. With the code's coordinates reading (b) gives 0.333, 0.741, 0.882 and 1.000 and reading (a) 0.483, 0.765, 0.889 and 1.000, the code's printed values; the difference at 5 uA is the contact coordinates of jlh31 and jlh32. Under the Methods' k-means classes RS fractions are 0.520, 0.774, 0.900 and 1.000. The boundaries are the code's (outside input). The 5 uA fraction is outside 0.333 +- 0.033 under every reading run (0.400, 0.552, 0.520), and the criterion has no partial clause.

fig4h-extent-constantΒΆ

The spatial extent of direct responders stays relatively constant across amplitudes (1180 +- 559, 884 +- 652, 965 +- 451 and 522 +- 313 um), with no significant amplitude effect.

Per recording and cathodal amplitude, extent = |minimum| + maximum of the responders' signed distances (code cell 30); one-way ANOVA across amplitudes of the per-recording extents.

InΒ [11]:
for col in ["norm_distance", "norm_distance_code"]:
    ext = RESP.groupby(["amp", "session"])[col].agg(lambda d: abs(d.min()) + d.max()).reset_index()
    groups = [g[col].to_numpy() for _, g in ext.groupby("amp")]
    F, p = stats.f_oneway(*groups)
    print(f"{col}: mean +- SD per amplitude", {a: f"{g[col].mean():.0f} +- {g[col].std(ddof=0):.0f} (n={len(g)})" for a, g in ext.groupby("amp")},
          f"| one-way ANOVA F = {F:.3f}, p = {p:.3f}")
    if col == "norm_distance":
        print(ext.pivot(index="session", columns="amp", values=col).round(0).to_string())
print("Expected 1180, 884, 965, 522 um (10%); paper F = 1.265, p = 0.312")
norm_distance: mean +- SD per amplitude {5: '1121 +- 626 (n=6)', 25: '825 +- 664 (n=6)', 50: '813 +- 683 (n=6)', 100: '452 +- 252 (n=7)'} | one-way ANOVA F = 1.250, p = 0.317
amp             5       25      50     100
session                                   
jlh31         415.0   366.0   252.0  788.0
jlh32         278.0   284.0   284.0  530.0
jlh33           NaN     NaN     NaN  112.0
jlh34        2131.0  1108.0  1148.0  827.0
jlh40        1186.0  2045.0  2048.0  314.0
jlh48        1411.0  1049.0  1049.0  310.0
jlh53concat  1307.0   100.0   100.0  280.0
norm_distance_code: mean +- SD per amplitude {5: '1179 +- 559 (n=6)', 25: '884 +- 652 (n=6)', 50: '868 +- 671 (n=6)', 100: '522 +- 313 (n=7)'} | one-way ANOVA F = 1.265, p = 0.312
Expected 1180, 884, 965, 522 um (10%); paper F = 1.265, p = 0.312

Result: reproduced. The per-recording extent of responders does not change with amplitude (one-way ANOVA F = 1.250, p = 0.317), with means of 1121, 825, 813 and 452 um; the last two are outside 10% of the paper's 965 and 522 um. With the paper's code coordinates the means are 1179, 884, 868 and 522 um and F = 1.265, p = 0.312: the paper's F and p and three of its four means (1180, 884 and 522 um); 868 um is just outside 10% of the paper's 965 um at 50 uA.

fig4h-distance-decreasesΒΆ

Higher amplitudes bring direct responders closer to the stimulation site (mean distance 426, 300, 137 and 146 um at 5, 25, 50 and 100 uA), driven by 5 against 50 and 5 against 100 uA.

InΒ [12]:
for col in ["norm_distance", "norm_distance_code"]:
    g = RESP.groupby("amp")[col]
    F, p = stats.f_oneway(*[x.to_numpy() for _, x in g])
    print(f"{col}: mean (SD, n)", {a: f"{x.mean():.0f} ({x.std():.0f}, {len(x)})" for a, x in g}, f"| ANOVA F = {F:.2f}, p = {p:.4f}")
    tk = pairwise_tukeyhsd(RESP[col], RESP.amp)
    print(pd.DataFrame(tk.summary().data[1:], columns=tk.summary().data[0]).to_string(index=False))
    print("By recording (mean, n):", RESP.groupby(["session", "amp"])[col].agg(lambda x: f"{x.mean():.0f} ({len(x)})").unstack().to_string())
print("Expected 426, 300, 137, 146 um (10%); paper F = 4.15, p = 0.007; Tukey 5-50 p = 0.015, 5-100 p = 0.019")
print("Range of signed distances of responders: %.0f to %.0f um" % (RESP.norm_distance.min(), RESP.norm_distance.max()))
norm_distance: mean (SD, n) {5: '414 (499, 29)', 25: '264 (442, 34)', 50: '129 (389, 45)', 100: '142 (234, 45)'} | ANOVA F = 3.97, p = 0.0094
 group1  group2  meandiff  p-adj     lower    upper  reject
      5      25 -149.3185 0.4279 -404.4829 105.8458   False
      5      50 -284.1923 0.0134 -524.5725 -43.8120    True
      5     100 -271.6323 0.0199 -512.0125 -31.2520    True
     25      50 -134.8737 0.4236 -364.2540  94.5065   False
     25     100 -122.3138 0.5104 -351.6940 107.0665   False
     50     100   12.5600 0.9987 -200.2524 225.3724   False
By recording (mean, n): amp               5         25        50        100
session                                            
jlh31         173 (5)   98 (11)  -26 (10)   46 (12)
jlh32         137 (2)   102 (5)    42 (9)  128 (11)
jlh33             NaN       NaN       NaN  -224 (2)
jlh34        1065 (2)  538 (10)  387 (18)  322 (14)
jlh40        631 (11)  1023 (3)  1024 (1)   157 (1)
jlh48         -34 (5)  -267 (3)  -407 (5)    26 (2)
jlh53concat   490 (4)  -124 (2)  -124 (2)    54 (3)
norm_distance_code: mean (SD, n) {5: '426 (509, 29)', 25: '300 (443, 34)', 50: '137 (399, 45)', 100: '146 (263, 45)'} | ANOVA F = 4.15, p = 0.0074
 group1  group2  meandiff  p-adj     lower    upper  reject
      5      25 -125.8157 0.5986 -388.1590 136.5275   False
      5      50 -288.8159 0.0148 -535.9591 -41.6727    True
      5     100 -280.0835 0.0194 -527.2267 -32.9403    True
     25      50 -163.0001 0.2794 -398.8339  72.8336   False
     25     100 -154.2678 0.3275 -390.1015  81.5659   False
     50     100    8.7323 0.9996 -210.0674 227.5321   False
By recording (mean, n): amp               5         25        50        100
session                                            
jlh31         285 (5)  254 (11)   16 (10)  101 (12)
jlh32           3 (2)    31 (5)    36 (9)   78 (11)
jlh33             NaN       NaN       NaN  -267 (2)
jlh34        1051 (2)  525 (10)  392 (18)  340 (14)
jlh40        644 (11)  1042 (3)  1043 (1)   169 (1)
jlh48         -32 (5)  -270 (3)  -420 (5)     5 (2)
jlh53concat   478 (4)  -146 (2)  -146 (2)    36 (3)
Expected 426, 300, 137, 146 um (10%); paper F = 4.15, p = 0.007; Tukey 5-50 p = 0.015, 5-100 p = 0.019
Range of signed distances of responders: -868 to 1233 um

Result: partially reproduced. Mean signed distances are 414, 264, 129 and 142 um (ANOVA F = 3.97, p = 0.0094; Tukey 5 against 50 uA p = 0.0134, 5 against 100 uA p = 0.0199, no other pair below 0.05). The tests hold; the 25 uA mean (264 um) is outside 10% of 300 um. With the paper's code coordinates the means are 426, 300, 137 and 146 um and F = 4.15, p = 0.0074, the paper's values: the difference is the stimulation contact coordinates of jlh31 and jlh32 in the dataset.

fig4h-ks-distinctΒΆ

5 and 100 uA recruit distinct spatial populations of direct responders; the other pairs the paper reports (5-25, 25-50, 25-100 and 50-100 uA) do not differ after correction.

InΒ [13]:
for col in ["norm_distance", "norm_distance_code"]:
    pairs = [(5, 25), (5, 50), (5, 100), (25, 50), (25, 100), (50, 100)]
    p = [stats.ks_2samp(RESP[RESP.amp == x][col], RESP[RESP.amp == y][col]).pvalue for x, y in pairs]
    adj = estim.holm_sidak(p)
    D = [stats.ks_2samp(RESP[RESP.amp == x][col], RESP[RESP.amp == y][col]).statistic for x, y in pairs]
    print(col)
    print(pd.DataFrame(dict(pair=[f"{x}-{y}" for x, y in pairs], D=np.round(D, 3), p=np.round(p, 4), p_holm_sidak=np.round(adj, 4))).to_string(index=False))
print("Paper adjusted p: 5-25 0.566, 5-100 0.016, 25-50 0.566, 25-100 0.363, 50-100 0.083")
norm_distance
  pair     D      p  p_holm_sidak
  5-25 0.268 0.1700        0.5255
  5-50 0.336 0.0277        0.1309
 5-100 0.349 0.0193        0.1105
 25-50 0.166 0.5865        0.8290
25-100 0.198 0.3650        0.7439
50-100 0.133 0.8245        0.8290
norm_distance_code
  pair     D      p  p_holm_sidak
  5-25 0.247 0.2430        0.5662
  5-50 0.372 0.0105        0.0516
 5-100 0.416 0.0028        0.0164
 25-50 0.219 0.2577        0.5662
25-100 0.263 0.1064        0.3625
50-100 0.133 0.8245        0.8245
Paper adjusted p: 5-25 0.566, 5-100 0.016, 25-50 0.566, 25-100 0.363, 50-100 0.083

Result: not reproduced. With the dataset's coordinates, 5 against 100 uA has KS p = 0.0193 and Holm-Sidak adjusted p = 0.1105, so the 5 and 100 uA populations do not differ after correction; the other four pairs do not differ (adjusted p 0.5255 to 0.8290). With the paper's code coordinates the adjusted p are 0.5662 (5-25), 0.0164 (5-100), 0.5662 (25-50), 0.3625 (25-100) and 0.8245 (50-100), which reproduce the claim (the paper prints 0.083 for 50-100).

fig4i-proportion-within-epΒΆ

The proportion of direct responders among units within the evoked potential is small and increases with amplitude (0.014, 0.020, 0.031 and 0.030), so fewer than 5% of neurons are directly activated.

Cathodal good stable units with a value, within the code's mean evoked-potential boundaries (outside input, see fig4f-responders-within-ep); responder indicator nDRP > 0.15, pooled over recordings; OLS of the indicator on numeric amplitude (the "linear ANOVA"). Readings of the criterion: (a) the amplitude's own boundaries on the signed distance (norm_distance); (b) the 100 uA boundaries; (c) the amplitude's own boundaries on the raw CCF distance, signed as the code's distance column (negative on the superficial side of the probe's closest point). Dataset and code coordinates.

InΒ [14]:
# (c unsigned): the raw CCF distance without the code's sign, a variant of reading (c)
FIG4I = {"a": ("norm_distance", None), "b": ("norm_distance", 100), "c": ("sdistance", None),
         "c unsigned": ("distance", None)}
for coords in ["", "_code"]:
    for reading, (col, ba) in FIG4I.items():
        d = CAT[within_ep(CAT, col + coords, ba)].assign(resp=lambda x: (x.ndrp > 0.15).astype(float))
        m = smf.ols("resp ~ amp", data=d).fit()
        t = anova_lm(m, typ=2)
        print(f"reading {reading}{' (code coordinates)' if coords else ''}:", {a: f"{x.mean():.4f} +- {x.std():.3f} (n={len(x)})" for a, x in d.groupby("amp").resp},
              f"| F = {t.loc['amp', 'F']:.2f}, p = {t.loc['amp', 'PR(>F)']:.4f}, slope {m.params['amp']:.2e}")
        if reading == "c" and not coords:
            print("   by recording:", d.groupby(["session", "amp"]).resp.mean().round(3).unstack().to_dict("index"))
print("Expected 0.014, 0.020, 0.031, 0.030 (10%), all below 0.05; paper F = 5.41, p = 0.020")
reading a: {5: '0.0124 +- 0.111 (n=1292)', 25: '0.0186 +- 0.135 (n=1449)', 50: '0.0242 +- 0.154 (n=1651)', 100: '0.0254 +- 0.157 (n=1773)'} | F = 5.87, p = 0.0154, slope 1.22e-04
reading b: {5: '0.0119 +- 0.108 (n=1766)', 25: '0.0152 +- 0.123 (n=1771)', 50: '0.0225 +- 0.148 (n=1775)', 100: '0.0254 +- 0.157 (n=1773)'} | F = 10.10, p = 0.0015, slope 1.44e-04
reading c: {5: '0.0133 +- 0.115 (n=678)', 25: '0.0208 +- 0.143 (n=1059)', 50: '0.0307 +- 0.173 (n=1271)', 100: '0.0301 +- 0.171 (n=1427)'} | F = 4.99, p = 0.0256, slope 1.49e-04
   by recording: {'jlh31': {5: 0.015, 25: 0.044, 50: 0.037, 100: 0.034}, 'jlh32': {5: 0.008, 25: 0.011, 50: 0.028, 100: 0.034}, 'jlh33': {5: 0.0, 25: 0.0, 50: 0.0, 100: 0.017}, 'jlh34': {5: 0.0, 25: 0.033, 50: 0.105, 100: 0.077}, 'jlh40': {5: 0.053, 25: 0.0, 50: 0.0, 100: 0.01}, 'jlh48': {5: 0.0, 25: 0.019, 50: 0.027, 100: 0.013}, 'jlh53concat': {5: 0.028, 25: 0.015, 50: 0.009, 100: 0.011}}
reading c unsigned: {5: '0.0129 +- 0.113 (n=778)', 25: '0.0221 +- 0.147 (n=997)', 50: '0.0312 +- 0.174 (n=1248)', 100: '0.0293 +- 0.169 (n=1467)'} | F = 4.55, p = 0.0330, slope 1.39e-04
reading a (code coordinates): {5: '0.0122 +- 0.110 (n=1147)', 25: '0.0197 +- 0.139 (n=1322)', 50: '0.0258 +- 0.159 (n=1549)', 100: '0.0259 +- 0.159 (n=1738)'} | F = 5.34, p = 0.0209, slope 1.23e-04
reading b (code coordinates): {5: '0.0121 +- 0.110 (n=1730)', 25: '0.0156 +- 0.124 (n=1735)', 50: '0.0230 +- 0.150 (n=1739)', 100: '0.0259 +- 0.159 (n=1738)'} | F = 10.08, p = 0.0015, slope 1.47e-04
reading c (code coordinates): {5: '0.0137 +- 0.116 (n=731)', 25: '0.0196 +- 0.139 (n=1073)', 50: '0.0306 +- 0.172 (n=1276)', 100: '0.0299 +- 0.170 (n=1440)'} | F = 5.41, p = 0.0201, slope 1.52e-04
reading c unsigned (code coordinates): {5: '0.0135 +- 0.116 (n=812)', 25: '0.0203 +- 0.141 (n=1033)', 50: '0.0307 +- 0.173 (n=1270)', 100: '0.0295 +- 0.169 (n=1457)'} | F = 5.26, p = 0.0219, slope 1.47e-04
Expected 0.014, 0.020, 0.031, 0.030 (10%), all below 0.05; paper F = 5.41, p = 0.020

Result: reproduced. Under reading (c), the amplitude's own boundaries on the raw signed distance, the proportions of responders among units within the evoked potential are 0.0133, 0.0208, 0.0307 and 0.0301 at 5 to 100 uA, all within 10% of 0.014, 0.020, 0.031 and 0.030 and below 0.05, and rise with amplitude (F = 4.99, p = 0.0256, slope positive). With the code's coordinates reading (c) gives F = 5.41, p = 0.0201, the paper's values. Readings (a) and (b) give the same trend (p = 0.0154 and 0.0015) but values below the paper's at 50 and 100 uA (0.0242 and 0.0254 under a). The boundaries are the code's (outside input). Reading (c) signs the raw distance as the code's distance column does; the code's direct_df.csv has distance <= 0 exactly where norm_distance <= 0 (check above). The unsigned raw distance gives 0.0129, 0.0221, 0.0312 and 0.0293 (F = 4.55, p = 0.0330): 0.0221 is just outside 0.020 +- 0.002, so the outcome rests on the code's sign, which the code applies.

fig4j-drp-increasesΒΆ

Among units responsive at any amplitude (99 units), direct response probability increases with amplitude (0.12, 0.14, 0.19 and 0.23).

InΒ [15]:
PIV = CAT.pivot_table(index=["session", "unit"], columns="amp", values="ndrp")
resp_ids = RESP.set_index(["session", "unit"]).index.unique()
R99 = PIV.loc[resp_ids].dropna()
print("Responders at any cathodal amplitude:", len(resp_ids), "| with a value at all four:", len(R99))
print("Mean +- SD nDRP:", {a: f"{R99[a].mean():.3f} +- {R99[a].std():.3f}" for a in estim.AMPS})
long = R99.stack().rename("ndrp").reset_index()
m = smf.ols("ndrp ~ amp", data=long).fit()
print("(a) OLS on numeric amplitude:", anova_lm(m, typ=2).loc["amp", ["F", "PR(>F)"]].round(5).to_dict(), "slope", round(m.params["amp"], 5))
print("(b) one-way ANOVA, amplitude categorical: F = %.2f, p = %.2e" % stats.f_oneway(*[R99[a] for a in estim.AMPS]))
rm = AnovaRM(long.assign(uid=long.session + "_" + long.unit.astype(str)), "ndrp", "uid", within=["amp"]).fit()
print("(c) repeated-measures ANOVA:", rm.anova_table.round(5).to_dict("records"))
print("By recording, responders:", R99.groupby(level=0).size().to_dict())
print("Expected 99 units (10%), 0.12, 0.14, 0.19, 0.23; paper F = 12.78, p = 0.0004")
# Variant: jlh31 and jlh34 ran most cathodal conditions twice at the same contact; the second run in place of the first
rows = []
for s in ["jlh31", "jlh34"]:
    u, tr, trains, meta = DATA[s]
    for a in estim.AMPS:
        c = estim.condition_trials(tr, "cathodal", a, n=None)
        runs = c.run.unique()
        if len(runs) < 2:
            continue
        st = c[c.run == runs[1]].start_time.to_numpy()
        for i in np.where(u.keep)[0]:
            n = estim.spike_prob(trains[i], st)[2]
            if np.isfinite(n):
                rows.append((s, i, a, n))
SECOND = pd.DataFrame(rows, columns=["session", "unit", "amp", "ndrp"]).set_index(["session", "unit", "amp"]).ndrp
CAT_2 = CAT.set_index(["session", "unit", "amp"]).ndrp.copy()
CAT_2.loc[CAT_2.index.intersection(SECOND.index)] = SECOND
P2V = CAT_2.unstack("amp")
ids2 = P2V.index[(P2V > 0.15).any(axis=1)]
R2 = P2V.loc[ids2].dropna()
long2 = R2.stack().rename("ndrp").reset_index()
t2 = anova_lm(smf.ols("ndrp ~ amp", data=long2).fit(), typ=2)
print(f"\nSecond run for jlh31 and jlh34 ({len(SECOND)} unit values replaced): responders {len(ids2)}, with all four {len(R2)}, mean nDRP",
      {a: round(float(R2[a].mean()), 3) for a in estim.AMPS}, f"| OLS F = {t2.loc['amp', 'F']:.2f}, p = {t2.loc['amp', 'PR(>F)']:.5f}",
      "| by recording", R2.groupby(level=0).size().to_dict())
Responders at any cathodal amplitude: 103 | with a value at all four: 99
Mean +- SD nDRP: {5: '0.116 +- 0.201', 25: '0.142 +- 0.207', 50: '0.194 +- 0.253', 100: '0.233 +- 0.310'}
(a) OLS on numeric amplitude: {'F': 12.77902, 'PR(>F)': 0.00039} slope 0.00124
(b) one-way ANOVA, amplitude categorical: F = 4.44, p = 4.39e-03
(c) repeated-measures ANOVA: [{'F Value': 4.65709, 'Num DF': 3.0, 'Den DF': 294.0, 'Pr > F': 0.00339}]
By recording, responders: {'jlh31': 26, 'jlh32': 16, 'jlh33': 2, 'jlh34': 28, 'jlh40': 13, 'jlh48': 7, 'jlh53concat': 7}
Expected 99 units (10%), 0.12, 0.14, 0.19, 0.23; paper F = 12.78, p = 0.0004
Second run for jlh31 and jlh34 (2163 unit values replaced): responders 93, with all four 91, mean nDRP {5: 0.125, 25: 0.092, 50: 0.157, 100: 0.223} | OLS F = 12.41, p = 0.00048 | by recording {'jlh31': 31, 'jlh32': 16, 'jlh33': 2, 'jlh34': 15, 'jlh40': 13, 'jlh48': 7, 'jlh53concat': 7}

Result: reproduced. 99 units respond at some cathodal amplitude and have values at all four; their mean nDRP is 0.116, 0.142, 0.194 and 0.233, and the code's regression on numeric amplitude gives F = 12.779, p = 0.00039 (paper F = 12.78, p = 0.0004). The number of good stable units per cathodal condition (2056, 2068, 2070, 2071) equals that of the code's direct_df.csv (check above). A one-way ANOVA with amplitude categorical (F = 4.44, p = 0.0044) and a repeated-measures ANOVA (F = 4.66, p = 0.0034) agree. With the second run of jlh31 and jlh34 in place of the first, 91 units respond with values at all four amplitudes, the mean nDRP is 0.125, 0.092, 0.157 and 0.223 and F = 12.41, p = 0.00048: the rise holds, and the 25 uA mean (0.092) would be outside 10% of 0.14.

figs3e-early-windowsΒΆ

Broader windows (5 and 10 ms) give more responsive units than the 2 ms window (0.092 and 0.175 at 100 uA), and their amplitude dependence is stronger.

Proportion of good stable units with nDRP > 0.15 per recording, windows of 2, 5 and 10 ms; baseline readings (a) 2 ms ending 3 ms plus the window before onset, (b) as long as the window, ending 3 ms before onset; the 2 ms comparator is the main DRP. Paired Wilcoxon over recordings, FDR over the eight comparisons.

InΒ [16]:
for reading, base in [("a", lambda w: (0.005 + w / 1000, 0.003 + w / 1000)), ("b", lambda w: (0.003 + w / 1000, 0.003))]:
    PW = {w: prop_responders(float(w), base) for w in (5, 10)}
    print(f"Reading {reading}: mean (SEM) proportion over {len(P2)} recordings")
    tab = pd.DataFrame({f"{w} ms": [f"{d[a].mean():.3f} ({d[a].sem():.3f})" for a in estim.AMPS] for w, d in [(2, P2)] + list(PW.items())}, index=estim.AMPS)
    print(tab.to_string())
    p, lab = [], []
    for w in (5, 10):
        for a in estim.AMPS:
            p.append(stats.wilcoxon(PW[w][a], P2[a]).pvalue if (PW[w][a] != P2[a]).any() else 1.0)
            lab.append(f"{w} ms vs 2 ms, {a} uA")
    adj = multipletests(p, method="fdr_bh")[1]
    print(pd.DataFrame(dict(comparison=lab, p=np.round(p, 4), p_fdr=np.round(adj, 4))).to_string(index=False))
    print("Increase 5 to 100 uA in mean proportion: 2 ms %.3f, 5 ms %.3f, 10 ms %.3f\n" % (
        P2[100].mean() - P2[5].mean(), PW[5][100].mean() - PW[5][5].mean(), PW[10][100].mean() - PW[10][5].mean()))
print("Expected at 100 uA: 5 ms 0.092, 10 ms 0.175 (10%)")
Reading a: mean (SEM) proportion over 7 recordings
              2 ms           5 ms          10 ms
5    0.016 (0.007)  0.017 (0.007)  0.033 (0.007)
25   0.016 (0.005)  0.024 (0.006)  0.047 (0.010)
50   0.021 (0.009)  0.040 (0.013)  0.094 (0.023)
100  0.021 (0.007)  0.058 (0.016)  0.106 (0.026)
           comparison      p  p_fdr
   5 ms vs 2 ms, 5 uA 0.5000 0.5000
  5 ms vs 2 ms, 25 uA 0.0156 0.0208
  5 ms vs 2 ms, 50 uA 0.0625 0.0714
 5 ms vs 2 ms, 100 uA 0.0156 0.0208
  10 ms vs 2 ms, 5 uA 0.0156 0.0208
 10 ms vs 2 ms, 25 uA 0.0156 0.0208
 10 ms vs 2 ms, 50 uA 0.0156 0.0208
10 ms vs 2 ms, 100 uA 0.0156 0.0208
Increase 5 to 100 uA in mean proportion: 2 ms 0.005, 5 ms 0.041, 10 ms 0.073

Reading b: mean (SEM) proportion over 7 recordings
              2 ms           5 ms          10 ms
5    0.016 (0.007)  0.014 (0.006)  0.016 (0.006)
25   0.016 (0.005)  0.019 (0.006)  0.032 (0.009)
50   0.021 (0.009)  0.037 (0.013)  0.068 (0.021)
100  0.021 (0.007)  0.057 (0.016)  0.091 (0.023)
           comparison      p  p_fdr
   5 ms vs 2 ms, 5 uA 0.5000 0.5714
  5 ms vs 2 ms, 25 uA 0.1250 0.1667
  5 ms vs 2 ms, 50 uA 0.0625 0.1000
 5 ms vs 2 ms, 100 uA 0.0156 0.0417
  10 ms vs 2 ms, 5 uA 1.0000 1.0000
 10 ms vs 2 ms, 25 uA 0.0156 0.0417
 10 ms vs 2 ms, 50 uA 0.0312 0.0625
10 ms vs 2 ms, 100 uA 0.0156 0.0417
Increase 5 to 100 uA in mean proportion: 2 ms 0.005, 5 ms 0.043, 10 ms 0.076

Expected at 100 uA: 5 ms 0.092, 10 ms 0.175 (10%)

Result: not reproduced. Broader windows give more responders and a stronger amplitude dependence: from 5 to 100 uA the mean proportion rises by 0.005 with 2 ms, 0.041 with 5 ms and 0.073 with 10 ms (reading a), and at 100 uA both broader windows exceed 2 ms (FDR p = 0.0208 under reading a, 0.0417 under b). The 100 uA proportions are 0.058 and 0.106 (reading a) or 0.057 and 0.091 (b), below 10% of the paper's 0.092 and 0.175. With seven recordings the smallest exact p is 0.0156. Both 100 uA proportions are outside tolerance under both readings, and the criterion has no partial clause.

fig5bc-amplitude-clustersΒΆ

Clustering the direct responders by response probability across amplitude gives six clusters, broadly dose-dependent (58.6% of responders) or amplitude-selective, responding almost only at one amplitude (19.2% at 5 uA, 15.2% at 25 uA, 7.1% at 100 uA).

The 99 responders of fig4j; Ward linkage, maxclust 6; features (a) the four nDRP values, as the code, (b) each unit's vector divided by its maximum. A cluster is selective when its mean peaks at one amplitude and is below half the peak at every other (the claims file's rule). The fractions are also checked over every assignment of clusters to the four labels (the criterion's "some assignment"), and over those in which a cluster labelled selective for an amplitude peaks at that amplitude.

InΒ [17]:
import itertools
EXP_AMP = {"dose": 0.586, 5: 0.192, 25: 0.152, 100: 0.071}

def cluster_summary(X, k=6):
    Z = linkage(X, method="ward")
    lab = fcluster(Z, k, criterion="maxclust")
    ccc = cophenet(Z, pdist(X))[0]
    sil = silhouette_score(X, lab)
    return Z, lab, ccc, sil

for reading, X in [("a", R99.to_numpy()), ("b", (R99.T / R99.max(axis=1)).T.to_numpy())]:
    Z, lab, ccc, sil = cluster_summary(X)
    rows = []
    for c in np.unique(lab):
        m = R99.to_numpy()[lab == c].mean(0)
        pk = estim.AMPS[int(np.argmax(m))]
        sel = all(m[j] < 0.5 * m.max() for j in range(4) if estim.AMPS[j] != pk)
        rows.append(dict(cluster=c, n=int((lab == c).sum()), frac=round((lab == c).mean(), 3),
                         mean_ndrp=np.round(m, 3).tolist(), peak_uA=pk, selective=sel))
    cl = pd.DataFrame(rows)
    fr = {"dose": cl[~cl.selective].frac.sum()}
    for a in estim.AMPS:
        fr[a] = cl[cl.selective & (cl.peak_uA == a)].frac.sum()
    print(f"Reading {reading}: CCC = {ccc:.3f}, silhouette = {sil:.3f}")
    print(cl.to_string(index=False))
    print("   fractions by the half-peak rule:", {k: round(v, 3) for k, v in fr.items()})
    ok = []
    for assign in itertools.product(["dose", 5, 25, 100], repeat=len(cl)):
        if any(l != "dose" and l != p for l, p in zip(assign, cl.peak_uA)):
            continue
        f = {key: sum(cl.frac[i] for i, l in enumerate(assign) if l == key) for key in EXP_AMP}
        if all(abs(f[key] - EXP_AMP[key]) <= 0.1 * EXP_AMP[key] for key in EXP_AMP):
            ok.append(assign)
    print("   assignments with selective labels at the cluster's peak, matching all four fractions within 10%:", len(ok), ok[:3])
    ok_any = []
    for assign in itertools.product(["dose", 5, 25, 100], repeat=len(cl)):
        f = {key: sum(cl.frac[i] for i, l in enumerate(assign) if l == key) for key in EXP_AMP}
        if all(abs(f[key] - EXP_AMP[key]) <= 0.1 * EXP_AMP[key] for key in EXP_AMP):
            ok_any.append(assign)
    print("   any assignment of clusters to the labels matching within 10%:", len(ok_any), ok_any[:3])
print("Expected CCC 0.702, silhouette 0.352 (10%)")
Reading a: CCC = 0.702, silhouette = 0.352
 cluster  n  frac                     mean_ndrp  peak_uA  selective
       1 15 0.152   [0.27, 0.474, 0.276, 0.088]       25      False
       2 19 0.192   [0.35, 0.034, 0.006, 0.008]        5       True
       3 40 0.404     [0.016, 0.1, 0.12, 0.117]       50      False
       4 13 0.131 [-0.004, 0.034, 0.106, 0.748]      100       True
       5  7 0.071 [-0.004, 0.021, 0.651, 0.343]       50      False
       6  5 0.051   [0.048, 0.339, 0.84, 0.955]      100      False
   fractions by the half-peak rule: {'dose': np.float64(0.678), 5: np.float64(0.192), 25: np.float64(0.0), 50: np.float64(0.0), 100: np.float64(0.131)}
   assignments with selective labels at the cluster's peak, matching all four fractions within 10%: 0 []
   any assignment of clusters to the labels matching within 10%: 2 [(25, 'dose', 'dose', 5, 100, 5), (25, 5, 'dose', 'dose', 100, 'dose')]
Reading b: CCC = 0.840, silhouette = 0.501
 cluster  n  frac                    mean_ndrp  peak_uA  selective
       1 28 0.283 [0.006, 0.034, 0.102, 0.506]      100       True
       2 10 0.101 [-0.008, 0.016, 0.44, 0.059]       50       True
       3 13 0.131  [0.026, 0.16, 0.514, 0.523]      100      False
       4 19 0.192  [0.35, 0.034, 0.006, 0.008]        5       True
       5  9 0.091 [0.453, 0.361, 0.255, 0.127]        5      False
       6 20 0.202  [0.018, 0.346, 0.142, 0.01]       25       True
   fractions by the half-peak rule: {'dose': np.float64(0.222), 5: np.float64(0.192), 25: np.float64(0.202), 50: np.float64(0.101), 100: np.float64(0.283)}
   assignments with selective labels at the cluster's peak, matching all four fractions within 10%: 0 []
   any assignment of clusters to the labels matching within 10%: 0 []
Expected CCC 0.702, silhouette 0.352 (10%)

Result: reproduced. On the code's features (reading a) the 99 responders give CCC = 0.702 and silhouette = 0.352, the paper's values, and clusters of 15, 19, 40, 13, 7 and 5 units. Labelling the clusters 25 uA, 5 uA, dose, dose, 100 uA and dose gives the paper's 15.2%, 19.2%, 58.6% and 7.1%, the assignment the criterion allows. By the half-peak rule the cluster of 7 peaks at 50 uA (mean nDRP 0.651 against 0.343 at 100 uA) and the cluster of 13 is the one selective for 100 uA, so no assignment that follows the clusters' peaks matches; reading (b) gives CCC 0.840 and silhouette 0.501 and no matching assignment.

fig5ef-polarity-clustersΒΆ

Clustering responders by their response to cathodal, anodal and bipolar pulses shows a polarity bias: 69.1% are polarity-specific, preferring bipolar (27.7%), anodal (23.6%) or cathodal (17.7%) pulses.

As the code (cells 77 to 81): per polarity the maximum nDRP over amplitudes, missing polarities 0, divided by the unit's maximum; Ward, maxclust 6. Populations (a) units with nDRP > 0.15 in any polarity and amplitude, (b) the 99 cathodal responders. A cluster prefers a polarity when its mean is highest there and below half of that for both others (the claims file's rule); the fractions are also checked over every assignment of clusters to the three polarities or none, the criterion's "some assignment", as for fig5bc. Variant: for bipolar pulses, the contact pair whose cathodal contact lies nearest layer 5 units (the Methods' "in or near Layer 5"), in place of the first pair.

InΒ [18]:
EXP_POL = {"bipolar": 0.277, "anodal": 0.236, "cathodal": 0.177}

def polarity_clusters(drp, ids):
    sub = drp.set_index(["session", "unit"]).loc[ids].reset_index()
    feat = sub.groupby(["session", "unit", "pol"]).ndrp.max().unstack(fill_value=0.0)
    feat = feat.reindex(columns=["bipolar", "cathodal", "anodal"], fill_value=0.0)
    feat = feat.div(feat.max(axis=1), axis=0).fillna(0.0)
    X = feat.to_numpy()
    Z, lab, ccc, sil = cluster_summary(X)
    rows = []
    for c in np.unique(lab):
        m = X[lab == c].mean(0)
        top = int(np.argmax(m))
        pref = feat.columns[top] if all(m[j] < 0.5 * m[top] for j in range(3) if j != top) else "none"
        rows.append(dict(cluster=c, n=int((lab == c).sum()), frac=round((lab == c).mean(), 3),
                         mean=np.round(m, 2).tolist(), prefers=pref))
    cl = pd.DataFrame(rows)
    cl["top"] = [feat.columns[int(np.argmax(m))] for m in cl["mean"]]
    fr = {p: round(cl[cl.prefers == p].frac.sum(), 3) for p in EXP_POL}
    return len(X), ccc, sil, cl, fr

def polarity_assignments(cl):
    """Every assignment of the clusters to the three polarities or none (the criterion's 'some
    assignment') whose three fractions are within 10%; marked when each labelled cluster's highest
    mean is at its label."""
    out = []
    for assign in itertools.product(["none"] + list(EXP_POL), repeat=len(cl)):
        f = {p: sum(cl.frac[i] for i, l in enumerate(assign) if l == p) for p in EXP_POL}
        if all(abs(f[p] - EXP_POL[p]) <= 0.1 * EXP_POL[p] for p in EXP_POL):
            out.append(dict(assignment=assign, at_top=all(l == "none" or l == t for l, t in zip(assign, cl.top)),
                            specific=round(sum(f.values()), 3), **{p: round(f[p], 3) for p in EXP_POL}))
    return pd.DataFrame(out)

all_ids = DRP[DRP.ndrp > 0.15].set_index(["session", "unit"]).index.unique()
# layer 5 variant: per recording, the bipolar cathodal contact nearest layer 5 units
L5 = {}
for s in estim.PAPER_SESSIONS:
    u, tr = DATA[s][0], DATA[s][1]
    regs = estim.contact_regions(u, tr)
    bip = tr[(tr.pol == "bipolar") & (tr.pulse_duration == 100) & (tr.pulse_number == 1)]
    cands = [int(c) for c in bip.contact_negative.unique()]
    l5 = [c for c in cands if "layer 5" in regs[c][0]]
    if l5:
        L5[(s, "bipolar")] = l5[0]
print("Bipolar contacts (first used by the code, layer 5 variant):",
      {s: (int(DATA[s][1][DATA[s][1].pol == 'bipolar'].contact_negative.iloc[0]) if (DATA[s][1].pol == 'bipolar').any() else None, L5.get((s, 'bipolar'))) for s in estim.PAPER_SESSIONS})
DRP_L5 = pd.concat([DRP[DRP.pol != "bipolar"], drp_table(polarities=["bipolar"], contact_choice=L5)], ignore_index=True)
for name, drp, ids in [("(a) any polarity", DRP, all_ids), ("(b) 99 cathodal responders", DRP, R99.index),
                       ("(a) any polarity, layer 5 bipolar contact", DRP_L5, DRP_L5[DRP_L5.ndrp > 0.15].set_index(["session", "unit"]).index.unique())]:
    n, ccc, sil, cl, fr = polarity_clusters(drp, ids)
    print(f"\n{name}: n = {n}, CCC = {ccc:.3f}, silhouette = {sil:.3f}, polarity-specific = {sum(fr.values()):.3f}, {fr}")
    print(cl.to_string(index=False))
    asg = polarity_assignments(cl)
    print(f"   assignments matching the three fractions within 10%: {len(asg)}, of which with each label at its cluster's highest mean: {int(asg.at_top.sum()) if len(asg) else 0}")
    if len(asg):
        print(asg.to_string(index=False))
print("\nExpected CCC 0.806, silhouette 0.519, polarity-specific 0.691, bipolar 0.277, anodal 0.236, cathodal 0.177 (10%)")
Bipolar contacts (first used by the code, layer 5 variant): {'jlh31': (16, 16), 'jlh32': (3, None), 'jlh33': (5, 5), 'jlh34': (15, 11), 'jlh39': (15, 15), 'jlh40': (None, None), 'jlh48': (14, 14), 'jlh49v1': (15, None), 'jlh53concat': (16, 16)}
(a) any polarity: n = 220, CCC = 0.773, silhouette = 0.538, polarity-specific = 0.623, {'bipolar': np.float64(0.205), 'anodal': np.float64(0.232), 'cathodal': np.float64(0.186)}
 cluster  n  frac               mean  prefers      top
       1 51 0.232   [0.21, 0.2, 1.0]   anodal   anodal
       2 45 0.205  [1.0, 0.04, 0.03]  bipolar  bipolar
       3 41 0.186  [0.16, 1.0, 0.13] cathodal cathodal
       4 27 0.123 [0.53, 0.98, 0.82]     none cathodal
       5 18 0.082  [0.98, 0.4, 0.79]     none  bipolar
       6 38 0.173  [0.98, 0.76, 0.2]     none  bipolar
   assignments matching the three fractions within 10%: 5, of which with each label at its cluster's highest mean: 2
                                      assignment  at_top  specific  bipolar  anodal  cathodal
(anodal, none, bipolar, none, bipolar, cathodal)   False     0.673    0.268   0.232     0.173
(anodal, none, cathodal, none, bipolar, bipolar)    True     0.673    0.255   0.232     0.186
(anodal, none, cathodal, bipolar, none, bipolar)   False     0.714    0.296   0.232     0.186
(anodal, bipolar, none, none, bipolar, cathodal)   False     0.692    0.287   0.232     0.173
(anodal, bipolar, cathodal, none, bipolar, none)    True     0.705    0.287   0.232     0.186

(b) 99 cathodal responders: n = 99, CCC = 0.711, silhouette = 0.393, polarity-specific = 0.434, {'bipolar': np.float64(0.0), 'anodal': np.float64(0.0), 'cathodal': np.float64(0.434)}
 cluster  n  frac               mean  prefers      top
       1 12 0.121  [0.48, 1.0, 0.15] cathodal cathodal
       2 31 0.313  [0.03, 1.0, 0.18] cathodal cathodal
       3 14 0.141  [0.98, 0.77, 0.1]     none  bipolar
       4 11 0.111 [0.23, 0.75, 0.97]     none   anodal
       5 13 0.131 [0.98, 0.86, 0.53]     none  bipolar
       6 18 0.182 [0.71, 0.98, 0.81]     none cathodal
   assignments matching the three fractions within 10%: 4, of which with each label at its cluster's highest mean: 0
                                        assignment  at_top  specific  bipolar  anodal  cathodal
(bipolar, none, bipolar, anodal, anodal, cathodal)   False     0.686    0.262   0.242     0.182
(bipolar, none, anodal, anodal, bipolar, cathodal)   False     0.686    0.252   0.252     0.182
(anodal, none, bipolar, bipolar, anodal, cathodal)   False     0.686    0.252   0.252     0.182
(anodal, none, bipolar, anodal, bipolar, cathodal)   False     0.686    0.272   0.232     0.182

(a) any polarity, layer 5 bipolar contact: n = 235, CCC = 0.773, silhouette = 0.524, polarity-specific = 0.669, {'bipolar': np.float64(0.26), 'anodal': np.float64(0.213), 'cathodal': np.float64(0.196)}
 cluster  n  frac               mean  prefers      top
       1 61 0.260  [1.0, 0.04, 0.06]  bipolar  bipolar
       2 50 0.213  [0.22, 0.22, 1.0]   anodal   anodal
       3 46 0.196 [0.08, 0.99, 0.29] cathodal cathodal
       4 35 0.149 [0.89, 0.79, 0.14]     none  bipolar
       5 14 0.060  [0.99, 0.26, 0.8]     none  bipolar
       6 29 0.123 [0.88, 0.91, 0.71]     none cathodal
   assignments matching the three fractions within 10%: 1, of which with each label at its cluster's highest mean: 0
                                       assignment  at_top  specific  bipolar  anodal  cathodal
(bipolar, anodal, none, none, cathodal, cathodal)   False     0.656     0.26   0.213     0.183

Expected CCC 0.806, silhouette 0.519, polarity-specific 0.691, bipolar 0.277, anodal 0.236, cathodal 0.177 (10%)

Result: reproduced. Under reading (a), the code's population, 220 units give CCC = 0.773 and silhouette = 0.538 (within 10% of 0.806 and 0.519). Of the assignments of the six clusters to the three polarities or none, five give all three fractions within 10%, and two of them label each cluster with its highest mean polarity; one gives bipolar 0.287, anodal 0.232 and cathodal 0.186 (polarity-specific 0.705), the criterion's "some assignment". It labels as bipolar both the cluster of 45 units (mean 1.0, 0.04, 0.03 for bipolar, cathodal, anodal) and one of 18 units (0.98, 0.40, 0.79) whose anodal response is 0.79 of its bipolar one. By the half-maximum rule only the first is bipolar-specific and the fractions are 0.205, 0.232 and 0.186 (total 0.623). With the bipolar contact nearest layer 5 (235 units) the half-maximum rule gives 0.260, 0.213 and 0.196. Among the 99 cathodal responders (reading b) CCC is 0.711 and silhouette 0.393, outside tolerance.

fig5-fsrs-proportionΒΆ

Direct responders stay sparse for both FS and RS units across amplitudes, with a main effect of amplitude but no effect of waveform class or interaction.

Responder indicator (nDRP > 0.15) over FS or RS good stable units pooled across recordings, cathodal; two-way ANOVA (type II) of the indicator on amplitude and class. Classes under the Methods' k-means and the code's 0.4 ms threshold. Reading (b) of the criterion, units within the evoked potential, uses the code's evoked-potential boundaries (outside input, see fig4f-responders-within-ep) under the three readings of fig4i-proportion-within-ep. The paper's F = 3.04 with p = 0.028 fits 3 numerator degrees of freedom, amplitude categorical.

InΒ [19]:
for cls_col in ["wf_kmeans", "wf_threshold"]:
    d = CAT[CAT[cls_col].isin(["fs", "rs"])].assign(resp=lambda x: (x.ndrp > 0.15).astype(float), cls=lambda x: x[cls_col])
    print(f"Classes by {cls_col}: units per class", d.groupby("cls").unit.size().div(4).round().to_dict())
    print(d.groupby(["cls", "amp"]).resp.agg(["mean", "std", "size"]).round(4).unstack(0).to_string())
    for amp_term in ["C(amp)", "amp"]:
        t = anova_lm(smf.ols(f"resp ~ {amp_term} * C(cls)", data=d).fit(), typ=2)
        print(f"   amplitude {'categorical' if 'C' in amp_term else 'numeric'}:",
              {k: f"F = {t.loc[k, 'F']:.2f}, p = {t.loc[k, 'PR(>F)']:.3f}" for k in t.index[:-1]})
print("Expected FS 0.032 (5 uA), 0.040 (100 uA); RS 0.013 (5 uA), 0.037 (100 uA) (10%); paper amplitude F = 3.04, p = 0.028; class p = 0.262; interaction p = 0.210")
print("\nReading (b): units within the evoked potential, under the three readings of fig4i-proportion-within-ep (boundaries: outside input)")
for reading, (col, ba) in FIG4I.items():
    din = CAT[within_ep(CAT, col, ba)]
    for cls_col in ["wf_kmeans", "wf_threshold"]:
        d = din[din[cls_col].isin(["fs", "rs"])].assign(resp=lambda x: (x.ndrp > 0.15).astype(float), cls=lambda x: x[cls_col])
        tab = d.groupby(["cls", "amp"]).resp.agg(lambda x: f"{x.mean():.4f} (n={len(x)})").unstack()
        print(f"({reading}) {col}{', 100 uA boundaries' if ba else ''}, {cls_col}:", tab.to_dict("index"))
        for amp_term in ["C(amp)", "amp"]:
            t = anova_lm(smf.ols(f"resp ~ {amp_term} * C(cls)", data=d).fit(), typ=2)
            print(f"   amplitude {'categorical' if 'C' in amp_term else 'numeric'}:",
                  {k: f"F = {t.loc[k, 'F']:.2f}, p = {t.loc[k, 'PR(>F)']:.3f}" for k in t.index[:-1]})
Classes by wf_kmeans: units per class {'fs': 332.0, 'rs': 1637.0}
       mean             std         size      
cls      fs      rs      fs      rs   fs    rs
amp                                           
5    0.0061  0.0153  0.0780  0.1228  328  1633
25   0.0060  0.0189  0.0773  0.1364  334  1636
50   0.0120  0.0244  0.1091  0.1544  333  1639
100  0.0211  0.0225  0.1441  0.1485  331  1641
   amplitude categorical: {'C(amp)': 'F = 1.93, p = 0.122', 'C(cls)': 'F = 4.85, p = 0.028', 'C(amp):C(cls)': 'F = 0.42, p = 0.736'}
   amplitude numeric: {'C(cls)': 'F = 4.84, p = 0.028', 'amp': 'F = 4.33, p = 0.037', 'amp:C(cls)': 'F = 0.71, p = 0.400'}
Classes by wf_threshold: units per class {'fs': 616.0, 'rs': 1352.0}
       mean             std         size      
cls      fs      rs      fs      rs   fs    rs
amp                                           
5    0.0196  0.0111  0.1387  0.1049  613  1348
25   0.0097  0.0200  0.0981  0.1400  619  1351
50   0.0162  0.0251  0.1263  0.1565  618  1354
100  0.0227  0.0221  0.1492  0.1471  616  1356
   amplitude categorical: {'C(amp)': 'F = 1.93, p = 0.123', 'C(cls)': 'F = 0.60, p = 0.437', 'C(amp):C(cls)': 'F = 1.77, p = 0.150'}
   amplitude numeric: {'C(cls)': 'F = 0.60, p = 0.438', 'amp': 'F = 4.33, p = 0.037', 'amp:C(cls)': 'F = 0.09, p = 0.764'}
Expected FS 0.032 (5 uA), 0.040 (100 uA); RS 0.013 (5 uA), 0.037 (100 uA) (10%); paper amplitude F = 3.04, p = 0.028; class p = 0.262; interaction p = 0.210

Reading (b): units within the evoked potential, under the three readings of fig4i-proportion-within-ep (boundaries: outside input)
(a) norm_distance, wf_kmeans: {'fs': {5: '0.0098 (n=205)', 25: '0.0087 (n=230)', 50: '0.0156 (n=256)', 100: '0.0253 (n=277)'}, 'rs': {5: '0.0126 (n=1035)', 25: '0.0207 (n=1160)', 50: '0.0270 (n=1331)', 100: '0.0259 (n=1426)'}}
   amplitude categorical: {'C(amp)': 'F = 2.79, p = 0.039', 'C(cls)': 'F = 1.74, p = 0.187', 'C(amp):C(cls)': 'F = 0.34, p = 0.795'}
   amplitude numeric: {'C(cls)': 'F = 1.75, p = 0.186', 'amp': 'F = 6.18, p = 0.013', 'amp:C(cls)': 'F = 0.20, p = 0.655'}
(a) norm_distance, wf_threshold: {'fs': {5: '0.0233 (n=387)', 25: '0.0115 (n=434)', 50: '0.0209 (n=479)', 100: '0.0278 (n=504)'}, 'rs': {5: '0.0070 (n=853)', 25: '0.0220 (n=956)', 50: '0.0271 (n=1108)', 100: '0.0250 (n=1199)'}}
   amplitude categorical: {'C(amp)': 'F = 2.80, p = 0.039', 'C(cls)': 'F = 0.00, p = 0.983', 'C(amp):C(cls)': 'F = 1.91, p = 0.126'}
   amplitude numeric: {'C(cls)': 'F = 0.00, p = 0.985', 'amp': 'F = 6.20, p = 0.013', 'amp:C(cls)': 'F = 0.13, p = 0.723'}
(b) norm_distance, 100 uA boundaries, wf_kmeans: {'fs': {5: '0.0072 (n=279)', 25: '0.0071 (n=280)', 50: '0.0142 (n=281)', 100: '0.0253 (n=277)'}, 'rs': {5: '0.0120 (n=1419)', 25: '0.0169 (n=1421)', 50: '0.0253 (n=1423)', 100: '0.0259 (n=1426)'}}
   amplitude categorical: {'C(amp)': 'F = 4.32, p = 0.005', 'C(cls)': 'F = 2.18, p = 0.139', 'C(amp):C(cls)': 'F = 0.28, p = 0.836'}
   amplitude numeric: {'C(cls)': 'F = 2.18, p = 0.140', 'amp': 'F = 11.16, p = 0.001', 'amp:C(cls)': 'F = 0.22, p = 0.642'}
(b) norm_distance, 100 uA boundaries, wf_threshold: {'fs': {5: '0.0237 (n=506)', 25: '0.0099 (n=506)', 50: '0.0196 (n=509)', 100: '0.0278 (n=504)'}, 'rs': {5: '0.0059 (n=1192)', 25: '0.0176 (n=1195)', 50: '0.0251 (n=1195)', 100: '0.0250 (n=1199)'}}
   amplitude categorical: {'C(amp)': 'F = 4.33, p = 0.005', 'C(cls)': 'F = 0.26, p = 0.608', 'C(amp):C(cls)': 'F = 2.56, p = 0.053'}
   amplitude numeric: {'C(cls)': 'F = 0.26, p = 0.607', 'amp': 'F = 11.18, p = 0.001', 'amp:C(cls)': 'F = 0.72, p = 0.397'}
(c) sdistance, wf_kmeans: {'fs': {5: '0.0085 (n=117)', 25: '0.0110 (n=181)', 50: '0.0189 (n=212)', 100: '0.0304 (n=230)'}, 'rs': {5: '0.0150 (n=533)', 25: '0.0227 (n=836)', 50: '0.0348 (n=1007)', 100: '0.0307 (n=1140)'}}
   amplitude categorical: {'C(amp)': 'F = 2.58, p = 0.052', 'C(cls)': 'F = 1.74, p = 0.188', 'C(amp):C(cls)': 'F = 0.32, p = 0.810'}
   amplitude numeric: {'C(cls)': 'F = 1.74, p = 0.187', 'amp': 'F = 4.77, p = 0.029', 'amp:C(cls)': 'F = 0.36, p = 0.547'}
(c) sdistance, wf_threshold: {'fs': {5: '0.0275 (n=218)', 25: '0.0123 (n=325)', 50: '0.0265 (n=377)', 100: '0.0324 (n=401)'}, 'rs': {5: '0.0069 (n=432)', 25: '0.0246 (n=692)', 50: '0.0344 (n=842)', 100: '0.0299 (n=969)'}}
   amplitude categorical: {'C(amp)': 'F = 2.60, p = 0.051', 'C(cls)': 'F = 0.05, p = 0.821', 'C(amp):C(cls)': 'F = 1.46, p = 0.223'}
   amplitude numeric: {'C(cls)': 'F = 0.05, p = 0.815', 'amp': 'F = 4.80, p = 0.028', 'amp:C(cls)': 'F = 0.02, p = 0.899'}
(c unsigned) distance, wf_kmeans: {'fs': {5: '0.0076 (n=131)', 25: '0.0115 (n=174)', 50: '0.0190 (n=211)', 100: '0.0299 (n=234)'}, 'rs': {5: '0.0145 (n=619)', 25: '0.0243 (n=781)', 50: '0.0355 (n=985)', 100: '0.0298 (n=1176)'}}
   amplitude categorical: {'C(amp)': 'F = 2.73, p = 0.042', 'C(cls)': 'F = 1.88, p = 0.171', 'C(amp):C(cls)': 'F = 0.38, p = 0.767'}
   amplitude numeric: {'C(cls)': 'F = 1.84, p = 0.175', 'amp': 'F = 4.33, p = 0.037', 'amp:C(cls)': 'F = 0.44, p = 0.507'}
(c unsigned) distance, wf_threshold: {'fs': {5: '0.0282 (n=248)', 25: '0.0129 (n=309)', 50: '0.0269 (n=372)', 100: '0.0316 (n=411)'}, 'rs': {5: '0.0060 (n=502)', 25: '0.0263 (n=646)', 50: '0.0352 (n=824)', 100: '0.0290 (n=999)'}}
   amplitude categorical: {'C(amp)': 'F = 2.74, p = 0.042', 'C(cls)': 'F = 0.01, p = 0.922', 'C(amp):C(cls)': 'F = 1.83, p = 0.139'}
   amplitude numeric: {'C(cls)': 'F = 0.01, p = 0.920', 'amp': 'F = 4.39, p = 0.036', 'amp:C(cls)': 'F = 0.08, p = 0.775'}

Result: not reproduced. No reading gives the four proportions within 10% of FS 0.032 and 0.040 and RS 0.013 and 0.037. Over all FS and RS units (reading a) they are FS 0.0196 and 0.0227 and RS 0.0111 and 0.0221 at 5 and 100 uA under the code's classes. Within the evoked potential (reading b, boundaries from the code) they are closest under reading (c) of fig4i: FS 0.0275 and 0.0324, RS 0.0069 and 0.0299. Responders are sparse in both classes and class and interaction have no effect under the code's classes (class p 0.437 over all units; within the evoked potential 0.983, 0.608 and 0.821 under readings a, b and c of fig4i). The amplitude term, which the paper's F = 3.04 with p = 0.028 implies is categorical, has p = 0.123 over all units and 0.039, 0.005 and 0.051 within the evoked potential under the three readings; numeric, its p is 0.037 or less. Under the Methods' k-means (one run) class has an effect over all units (p = 0.028), and over 20 k-means runs (that run and 19 others; stability check below) class p is below 0.05 in 4.

fig5j-fs-closerΒΆ

FS direct responders lie closer to the stimulation site than RS responders, and higher amplitude brings both closer.

As the code (cell 57): signed distance of cathodal responders classed FS or RS, ols('value ~ C(amplitude) * C(cell_type)'), type II.

InΒ [20]:
for cls_col in ["wf_kmeans", "wf_threshold"]:
    for col in ["norm_distance", "norm_distance_code"]:
        d = RESP[RESP[cls_col].isin(["fs", "rs"])].assign(cls=lambda x: x[cls_col], value=lambda x: x[col])
        t = anova_lm(smf.ols("value ~ C(amp) * C(cls)", data=d).fit(), typ=2)
        means = d.groupby(["cls", "amp"]).value.agg(lambda x: f"{x.mean():.0f} (n={len(x)})").unstack()
        print(f"{cls_col}, {col}:", {k: f"F = {t.loc[k, 'F']:.2f}, p = {t.loc[k, 'PR(>F)']:.4f}" for k in t.index[:-1]},
              "| mean FS %.0f, RS %.0f" % (d[d.cls == "fs"].value.mean(), d[d.cls == "rs"].value.mean()))
        print(means.to_string())
print("Expected FS 205 (5 uA), RS 567 (5 uA), RS 185 (100 uA) um (10%); paper waveform F = 8.04, p = 0.005; amplitude F = 4.10, p = 0.008")
wf_kmeans, norm_distance: {'C(amp)': 'F = 2.91, p = 0.0366', 'C(cls)': 'F = 2.96, p = 0.0874', 'C(amp):C(cls)': 'F = 0.08, p = 0.9733'} | mean FS 38, RS 244
amp         5           25          50          100
cls                                                
fs    121 (n=2)   106 (n=2)    -8 (n=4)    21 (n=7)
rs   422 (n=25)  287 (n=31)  168 (n=40)  169 (n=37)
wf_kmeans, norm_distance_code: {'C(amp)': 'F = 3.07, p = 0.0299', 'C(cls)': 'F = 2.47, p = 0.1181', 'C(amp):C(cls)': 'F = 0.14, p = 0.9356'} | mean FS 56, RS 255
amp         5           25          50          100
cls                                                
fs    114 (n=2)   207 (n=2)   -30 (n=4)    44 (n=7)
rs   430 (n=25)  320 (n=31)  179 (n=40)  164 (n=37)
wf_threshold, norm_distance: {'C(amp)': 'F = 3.95, p = 0.0097', 'C(cls)': 'F = 7.02, p = 0.0090', 'C(amp):C(cls)': 'F = 0.67, p = 0.5715'} | mean FS 111, RS 267
amp         5           25          50          100
cls                                                
fs   205 (n=12)   214 (n=6)    1 (n=10)   65 (n=14)
rs   556 (n=15)  290 (n=27)  197 (n=34)  183 (n=30)
wf_threshold, norm_distance_code: {'C(amp)': 'F = 4.10, p = 0.0080', 'C(cls)': 'F = 8.04, p = 0.0052', 'C(amp):C(cls)': 'F = 0.56, p = 0.6429'} | mean FS 107, RS 285
amp         5           25          50          100
cls                                                
fs   205 (n=12)   204 (n=6)   -2 (n=10)   60 (n=14)
rs   567 (n=15)  337 (n=27)  208 (n=34)  185 (n=30)
Expected FS 205 (5 uA), RS 567 (5 uA), RS 185 (100 uA) um (10%); paper waveform F = 8.04, p = 0.005; amplitude F = 4.10, p = 0.008

Result: reproduced, resting on an assumption. With the classes of the paper's code (duration threshold), FS responders lie closer than RS responders (mean 111 against 267 um; waveform F = 7.02, p = 0.0090) and amplitude has an effect (F = 3.95, p = 0.0097); FS 205 um and RS 556 and 183 um at 5 and 100 uA are within 10% of 205, 567 and 185 um. With the code's coordinates the paper's F = 8.04 and 4.10 are matched exactly. Under the Methods' k-means classes the waveform effect is not significant (p = 0.0874), and in none of 20 k-means runs (that run and 19 from other seeds and initialisations) is it (p 0.087 to 0.401; stability check below).

fig5i-fs-within-epΒΆ

FS responders lie almost entirely within the evoked potential even at low amplitude (75%, 83.3%, 100%, 100%), while only 33% of RS responders do at 5 uA, rising with amplitude.

As fig4f-responders-within-ep, split by waveform class (code cell 54): the fraction of cathodal FS and RS responders whose signed distance lies within the code's mean evoked-potential boundaries (outside input). Classes by the code's threshold and the Methods' k-means; dataset and code coordinates.

InΒ [21]:
rows = []
for col in ["norm_distance", "norm_distance_code"]:
    for cls_col in ["wf_threshold", "wf_kmeans"]:
        for c in ["fs", "rs"]:
            d = RESP[RESP[cls_col] == c]
            w = within_ep(d, col).groupby(d.amp).agg(["mean", "size"])
            rows.append(dict(distances=col, classes=cls_col, cls=c, **{f"{a} uA": f"{w.loc[a, 'mean']:.3f} (n={int(w.loc[a, 'size'])})" for a in estim.AMPS}))
print(pd.DataFrame(rows).to_string(index=False))
print("Expected FS 0.75 (5 uA), 0.833 (25 uA); RS 0.33 (5 uA), 0.741 (25 uA) (10%); FS at or above RS at every amplitude")
         distances      classes cls         5 uA        25 uA        50 uA       100 uA
     norm_distance wf_threshold  fs 0.750 (n=12)  0.833 (n=6) 1.000 (n=10) 1.000 (n=14)
     norm_distance wf_threshold  rs 0.400 (n=15) 0.778 (n=27) 0.882 (n=34) 1.000 (n=30)
     norm_distance    wf_kmeans  fs  1.000 (n=2)  1.000 (n=2)  1.000 (n=4)  1.000 (n=7)
     norm_distance    wf_kmeans  rs 0.520 (n=25) 0.774 (n=31) 0.900 (n=40) 1.000 (n=37)
norm_distance_code wf_threshold  fs 0.750 (n=12)  0.833 (n=6) 1.000 (n=10) 1.000 (n=14)
norm_distance_code wf_threshold  rs 0.333 (n=15) 0.741 (n=27) 0.882 (n=34) 1.000 (n=30)
norm_distance_code    wf_kmeans  fs  1.000 (n=2)  1.000 (n=2)  1.000 (n=4)  1.000 (n=7)
norm_distance_code    wf_kmeans  rs 0.480 (n=25) 0.742 (n=31) 0.900 (n=40) 1.000 (n=37)
Expected FS 0.75 (5 uA), 0.833 (25 uA); RS 0.33 (5 uA), 0.741 (25 uA) (10%); FS at or above RS at every amplitude

Result: not reproduced, resting on an assumption. Under the code's threshold classes and the dataset's coordinates, FS responders lie within the evoked potential at 0.750, 0.833, 1.000 and 1.000 and RS responders at 0.400, 0.778, 0.882 and 1.000 at 5 to 100 uA: FS at 5 and 25 uA and RS at 25 uA are within 10%, FS is at or above RS at every amplitude, and RS at 5 uA (0.400) is outside 0.33 +- 0.033. With the code's coordinates RS gives 0.333 and 0.741, so all four values match. Under the Methods' k-means classes FS responders are 2, 2, 4 and 7 units, all within (1.000), outside 10% of 0.75 and 0.833. The boundaries are the code's (outside input). No class choice gives the four values, and the criterion has no partial clause.

figs6b-fsrs-early-firingΒΆ

In the 10 ms after a pulse, the firing of FS and RS cortical units differs at every amplitude.

From the S6B legend and the code (cells 62 to 67): per recording, cortical good stable units of each class, first 75 cathodal trials; spikes of the class pooled, counted per trial in bins, averaged over trials and divided by the number of units; the bins from 0 to 10 ms of every recording pooled per class; two-sample KS test per amplitude, Holm-Sidak over the four. Bin widths 1 ms (code) and 2 ms (legend).

InΒ [22]:
for cls_col in ["wf_kmeans", "wf_threshold"]:
    for bw in [0.001, 0.002]:
        edges = np.arange(0, 0.010 + bw / 2, bw)
        p, ns, med = [], [], []
        for a in estim.AMPS:
            samp = {"fs": [], "rs": []}
            for s in CAT_SESS:
                u, tr, trains, meta = DATA[s]
                st = estim.condition_trials(tr, "cathodal", a).start_time.to_numpy()
                for c in ["fs", "rs"]:
                    idx = np.where(u.keep & (u.sub_cort == "Cortical") & (u[cls_col] == c))[0]
                    if len(idx) == 0:
                        continue
                    pop = np.sort(np.concatenate([trains[i] for i in idx]))
                    samp[c].extend(estim.counts_matrix(pop, st, edges).mean(0) / len(idx))
            p.append(stats.ks_2samp(samp["fs"], samp["rs"]).pvalue)
            ns.append((len(samp["fs"]), len(samp["rs"])))
            med.append((round(np.mean(samp["fs"]), 4), round(np.mean(samp["rs"]), 4)))
        adj = estim.holm_sidak(p)
        print(f"{cls_col}, {bw * 1000:.0f} ms bins: p", np.round(p, 4).tolist(), "| Holm-Sidak", np.round(adj, 4).tolist(),
              "| bins (FS, RS)", ns, "| mean spikes per unit and bin (FS, RS)", med)
print("Paper adjusted p: 0.010, 0.007, 0.019, 0.010")
wf_kmeans, 1 ms bins: p [0.0192, 0.0513, 0.0318, 0.0035] | Holm-Sidak [0.0564, 0.0627, 0.0627, 0.0139] | bins (FS, RS) [(70, 70), (70, 70), (70, 70), (70, 70)] | mean spikes per unit and bin (FS, RS) [(np.float64(0.0039), np.float64(0.0036)), (np.float64(0.005), np.float64(0.005)), (np.float64(0.0091), np.float64(0.0086)), (np.float64(0.0141), np.float64(0.0123))]
wf_kmeans, 2 ms bins: p [0.1991, 0.0625, 0.1151, 0.0625] | Holm-Sidak [0.2277, 0.2277, 0.2277, 0.2277] | bins (FS, RS) [(35, 35), (35, 35), (35, 35), (35, 35)] | mean spikes per unit and bin (FS, RS) [(np.float64(0.0078), np.float64(0.0072)), (np.float64(0.0099), np.float64(0.01)), (np.float64(0.0182), np.float64(0.0173)), (np.float64(0.0282), np.float64(0.0245))]
wf_threshold, 1 ms bins: p [0.0019, 0.001, 0.0112, 0.0064] | Holm-Sidak [0.0056, 0.0038, 0.0127, 0.0127] | bins (FS, RS) [(70, 70), (70, 70), (70, 70), (70, 70)] | mean spikes per unit and bin (FS, RS) [(np.float64(0.0051), np.float64(0.0032)), (np.float64(0.0062), np.float64(0.0048)), (np.float64(0.0115), np.float64(0.008)), (np.float64(0.0168), np.float64(0.0115))]
wf_threshold, 2 ms bins: p [0.0153, 0.0153, 0.0319, 0.1151] | Holm-Sidak [0.0598, 0.0598, 0.0629, 0.1151] | bins (FS, RS) [(35, 35), (35, 35), (35, 35), (35, 35)] | mean spikes per unit and bin (FS, RS) [(np.float64(0.0102), np.float64(0.0065)), (np.float64(0.0123), np.float64(0.0095)), (np.float64(0.0231), np.float64(0.016)), (np.float64(0.0336), np.float64(0.0229))]
Paper adjusted p: 0.010, 0.007, 0.019, 0.010

Result: reproduced, resting on an assumption. With the code's classes and 1 ms bins, FS and RS early firing differ at every amplitude (Holm-Sidak p 0.0056, 0.0038, 0.0127, 0.0127). With 2 ms bins (legend) no adjusted p is below 0.05 (0.0598 to 0.1151), and under the Methods' k-means classes only 100 uA differs (1 ms bins, adjusted p 0.0139). Over 20 k-means runs (that run and 19 from other seeds and initialisations) all four amplitudes differ in 9 and one in the other 11 (stability check below).

Stability of the k-means classes (fig5-fsrs-proportion, fig5j-fs-closer, figs6b-fsrs-early-firing)ΒΆ

The Methods' k-means reading above is one run of kmeans2 per recording (seed 0, random points as initial centres). Here it is run from 10 seeds with each of two initialisations (random points and k-means++), and as the run of lowest within-cluster sum of squares over those 20 per recording (the usual way to pick a k-means solution), and the three claims' tests are repeated on each.

Stability. The k-means classes depend on the initialisation: over the 20 runs the recordings hold 296 to 470 good stable FS units (924 under the threshold). fig5j-fs-closer has a waveform p below 0.05 in none of them, figs6b-fsrs-early-firing differs at all four amplitudes in 9, and the class term of fig5-fsrs-proportion has p below 0.05 in 4. The run of lowest within-cluster sum of squares per recording gives 401 FS units, waveform p = 0.1267 and all four amplitudes in figs6b. The code's threshold classes are deterministic; they are the ones the FS/RS outcomes rest on.

InΒ [23]:
KM = {s: [] for s in estim.PAPER_SESSIONS}
for minit in ["points", "++"]:
    for seed in range(10):
        for s in estim.PAPER_SESSIONS:
            u = DATA[s][0]
            lab, w = estim.kmeans_fs_rs(u.wf_duration, u.wf_ptratio, u.wf_slope, seed=seed, minit=minit)
            KM[s].append((f"{minit} {seed}", estim.label_up_axon(lab, u.wf_duration, u.wf_ptratio), w))
runs = [r[0] for r in KM[estim.PAPER_SESSIONS[0]]] + ["lowest WCSS"]
def km_labels(run):
    if run == "lowest WCSS":
        return {s: min(KM[s], key=lambda r: r[2])[1] for s in estim.PAPER_SESSIONS}
    j = runs.index(run)
    return {s: KM[s][j][1] for s in estim.PAPER_SESSIONS}
edges1 = np.arange(0, 0.0105, 0.001)
rows = []
for run in runs:
    lab = km_labels(run)
    cls = lambda df: [lab[s][i] for s, i in zip(df.session, df.unit)]
    nfs = sum(int(((lab[s] == "fs") & DATA[s][0].keep).sum()) for s in estim.PAPER_SESSIONS)
    nrs = sum(int(((lab[s] == "rs") & DATA[s][0].keep).sum()) for s in estim.PAPER_SESSIONS)
    dc = CAT.assign(cls=cls(CAT), resp=lambda x: (x.ndrp > 0.15).astype(float))
    dc = dc[dc.cls.isin(["fs", "rs"])]
    tf = anova_lm(smf.ols("resp ~ C(amp) * C(cls)", data=dc).fit(), typ=2)
    dj = RESP.assign(cls=cls(RESP), value=lambda x: x.norm_distance)
    dj = dj[dj.cls.isin(["fs", "rs"])]
    tj = anova_lm(smf.ols("value ~ C(amp) * C(cls)", data=dj).fit(), typ=2)
    p6 = []
    for a in estim.AMPS:
        samp = {"fs": [], "rs": []}
        for s in CAT_SESS:
            u, tr, trains, meta = DATA[s]
            st = estim.condition_trials(tr, "cathodal", a).start_time.to_numpy()
            for c in ["fs", "rs"]:
                idx = np.where(u.keep & (u.sub_cort == "Cortical") & (lab[s] == c))[0]
                if len(idx):
                    pop = np.sort(np.concatenate([trains[i] for i in idx]))
                    samp[c].extend(estim.counts_matrix(pop, st, edges1).mean(0) / len(idx))
        p6.append(stats.ks_2samp(samp["fs"], samp["rs"]).pvalue)
    adj6 = estim.holm_sidak(p6)
    rows.append(dict(run=run, fs_units=nfs, rs_units=nrs,
                     fsrs_FS5=round(dc[(dc.cls == "fs") & (dc.amp == 5)].resp.mean(), 4), fsrs_RS100=round(dc[(dc.cls == "rs") & (dc.amp == 100)].resp.mean(), 4),
                     fsrs_class_p=round(tf.loc["C(cls)", "PR(>F)"], 3), fsrs_amp_p=round(tf.loc["C(amp)", "PR(>F)"], 3),
                     fig5j_wave_p=round(tj.loc["C(cls)", "PR(>F)"], 4), fig5j_amp_p=round(tj.loc["C(amp)", "PR(>F)"], 4),
                     fig5j_FS_mean=round(dj[dj.cls == "fs"].value.mean()), fig5j_RS_mean=round(dj[dj.cls == "rs"].value.mean()),
                     s6b_adj_max=round(adj6.max(), 4), s6b_sig=int((adj6 < 0.05).sum())))
KMS = pd.DataFrame(rows)
print(KMS.to_string(index=False))
print("Run 'points 0' is the k-means run of the sections above; the other 19 use other seeds and initialisations.")
print(f"\nOver the 20 runs (the run above and 19 others): FS units {KMS.fs_units[:-1].min()} to {KMS.fs_units[:-1].max()} (threshold classes: "
      f"{sum(int(((DATA[s][0].wf_threshold == 'fs') & DATA[s][0].keep).sum()) for s in estim.PAPER_SESSIONS)});"
      f" fig5j waveform p < 0.05 in {int((KMS.fig5j_wave_p[:-1] < 0.05).sum())}; figs6b significant at all four amplitudes in {int((KMS.s6b_sig[:-1] == 4).sum())};"
      f" fig5-fsrs class p < 0.05 in {int((KMS.fsrs_class_p[:-1] < 0.05).sum())}")
        run  fs_units  rs_units  fsrs_FS5  fsrs_RS100  fsrs_class_p  fsrs_amp_p  fig5j_wave_p  fig5j_amp_p  fig5j_FS_mean  fig5j_RS_mean  s6b_adj_max  s6b_sig
   points 0       470      2155    0.0061      0.0225         0.028       0.122        0.0874       0.0366             38            244       0.0627        1
   points 1       405      2220    0.0074      0.0223         0.138       0.124        0.1267       0.0335             53            241       0.0104        4
   points 2       417      2208    0.0071      0.0219         0.147       0.124        0.0874       0.0366             38            244       0.0627        1
   points 3       372      2253    0.0083      0.0230         0.079       0.124        0.2094       0.0271             76            235       0.0127        4
   points 4       421      2204    0.0070      0.0219         0.134       0.124        0.0874       0.0366             38            244       0.0627        1
   points 5       404      2221    0.0074      0.0223         0.142       0.124        0.1267       0.0335             53            241       0.0104        4
   points 6       363      2262    0.0086      0.0229         0.102       0.124        0.2094       0.0271             76            235       0.0127        4
   points 7       466      2159    0.0062      0.0225         0.031       0.122        0.0874       0.0366             38            244       0.0627        1
   points 8       420      2205    0.0070      0.0219         0.138       0.124        0.0874       0.0366             38            244       0.0627        1
   points 9       362      2263    0.0086      0.0229         0.106       0.124        0.2094       0.0271             76            235       0.0127        4
       ++ 0       362      2263    0.0086      0.0229         0.106       0.124        0.2094       0.0271             76            235       0.0127        4
       ++ 1       421      2204    0.0070      0.0219         0.134       0.124        0.0874       0.0366             38            244       0.0627        1
       ++ 2       311      2314    0.0054      0.0229         0.172       0.123        0.2838       0.0303             71            233       0.0318        4
       ++ 3       470      2155    0.0061      0.0225         0.028       0.122        0.0874       0.0366             38            244       0.0627        1
       ++ 4       296      2329    0.0059      0.0233         0.167       0.123        0.4012       0.0280            100            230       0.0037        4
       ++ 5       379      2246    0.0080      0.0226         0.101       0.124        0.1461       0.0291             56            238       0.0925        1
       ++ 6       422      2203    0.0070      0.0231         0.023       0.123        0.1461       0.0291             56            238       0.0925        1
       ++ 7       420      2205    0.0070      0.0219         0.138       0.124        0.0874       0.0366             38            244       0.0627        1
       ++ 8       420      2205    0.0070      0.0219         0.138       0.124        0.0874       0.0366             38            244       0.0627        1
       ++ 9       296      2329    0.0059      0.0233         0.167       0.123        0.4012       0.0280            100            230       0.0037        4
lowest WCSS       401      2224    0.0075      0.0222         0.151       0.124        0.1267       0.0335             53            241       0.0104        4
Run 'points 0' is the k-means run of the sections above; the other 19 use other seeds and initialisations.

Over the 20 runs (the run above and 19 others): FS units 296 to 470 (threshold classes: 924); fig5j waveform p < 0.05 in 0; figs6b significant at all four amplitudes in 9; fig5-fsrs class p < 0.05 in 4

fig6c-modulation-cortexΒΆ

Spiking modulation after a single pulse increases with amplitude for cortical units (12.60, 13.37, 15.11, 20.21) but stays about constant for subcortical units (12.27, 12.60, 12.08, 13.48), differing between them at 50 and 100 uA.

MI per bin against the median rate 1 s to 0.5 s before onset; magnitude = trapezoid area of |MI| over bins (code, figure7.ipynb cells 28 and 33). Readings (a) 0 to 500 ms, 10 ms bins (code); (b) 0 to 500 ms, 5 ms bins (legend); (c) 500 to 1000 ms, 5 ms bins (Methods). Good stable units, first 75 cathodal trials, cortical or subcortical by the code's classify_region.

InΒ [24]:
READINGS = {"a": (0.0, 0.5, 0.010), "b": (0.0, 0.5, 0.005), "c": (0.5, 1.0, 0.005)}
mi_rows = []
MI50 = {}
for s in CAT_SESS:
    u, tr, trains, meta = DATA[s]
    idx = np.where(u.keep & u.sub_cort.notna())[0]
    for a in estim.AMPS:
        st = estim.condition_trials(tr, "cathodal", a).start_time.to_numpy()
        for i in idx:
            row = dict(session=s, unit=i, amp=a, Region=u.sub_cort[i], region=u.region[i], distance=u.distance[i])
            for k, (t0, t1, bw) in READINGS.items():
                mi = estim.modulation_index(trains[i], st, t0, t1, bw)
                row[f"auc_{k}"] = np.trapezoid(np.abs(mi))
            mi_rows.append(row)
MIDF = pd.DataFrame(mi_rows)
print("Units:", MIDF.groupby(["Region"]).unit.size().div(4).to_dict(), "per amplitude")
for k in READINGS:
    col = f"auc_{k}"
    print(f"\nReading {k}: mean +- SD")
    print(MIDF.groupby(["Region", "amp"])[col].agg(lambda x: f"{x.mean():.2f} +- {x.std():.2f}").unstack().to_string())
    t = anova_lm(smf.ols(f"{col} ~ amp * C(Region)", data=MIDF).fit(), typ=2)
    print("   ANOVA:", {i: f"F = {t.loc[i, 'F']:.2f}, p = {t.loc[i, 'PR(>F)']:.2e}" for i in t.index[:-1]})
    for a in estim.AMPS:
        c = MIDF[(MIDF.amp == a) & (MIDF.Region == "Cortical")][col]
        sc = MIDF[(MIDF.amp == a) & (MIDF.Region == "Subcortical")][col]
        print(f"   {a} uA: Student p = {stats.ttest_ind(c, sc).pvalue:.2e}, Welch p = {stats.ttest_ind(c, sc, equal_var=False).pvalue:.2e}")
    slope = {r: np.polyfit(g.amp, g[col], 1)[0] for r, g in MIDF.groupby("Region")}
    print("   slope on amplitude per region:", {r: round(v, 4) for r, v in slope.items()})
print("By recording, reading a, cortical mean at 5 and 100 uA:",
      MIDF[MIDF.Region == "Cortical"].groupby(["session", "amp"]).auc_a.mean().unstack()[[5, 100]].round(2).to_dict("index"))
print("\nExpected cortical 12.60 (5 uA), 20.21 (100 uA); subcortical 12.27, 13.48 (10%)")
Units: {'Cortical': 1287.0, 'Subcortical': 351.0} per amplitude

Reading a: mean +- SD
amp                    5              25             50              100
Region                                                                  
Cortical     12.60 +- 8.43  13.37 +- 8.41  15.11 +- 8.80  20.21 +- 10.32
Subcortical  12.27 +- 7.21  12.60 +- 8.00  12.08 +- 7.52   13.48 +- 7.70
   ANOVA: {'C(Region)': 'F = 106.27, p = 9.94e-25', 'amp': 'F = 487.36, p = 3.14e-104', 'amp:C(Region)': 'F = 90.88, p = 2.10e-21'}
   5 uA: Student p = 5.01e-01, Welch p = 4.62e-01
   25 uA: Student p = 1.26e-01, Welch p = 1.16e-01
   50 uA: Student p = 4.65e-09, Welch p = 2.39e-10
   100 uA: Student p = 5.50e-29, Welch p = 6.45e-37
   slope on amplitude per region: {'Cortical': np.float64(0.0823), 'Subcortical': np.float64(0.0116)}

Reading b: mean +- SD
amp                     5               25              50              100
Region                                                                     
Cortical     24.72 +- 17.04  26.50 +- 16.90  29.33 +- 18.58  37.04 +- 21.47
Subcortical  27.20 +- 15.95  27.15 +- 15.76  26.58 +- 15.29  27.83 +- 15.45
   ANOVA: {'C(Region)': 'F = 16.57, p = 4.76e-05', 'amp': 'F = 280.85, p = 9.35e-62', 'amp:C(Region)': 'F = 67.53, p = 2.47e-16'}
   5 uA: Student p = 1.42e-02, Welch p = 1.11e-02
   25 uA: Student p = 5.16e-01, Welch p = 4.99e-01
   50 uA: Student p = 1.10e-02, Welch p = 4.62e-03
   100 uA: Student p = 8.75e-14, Welch p = 1.27e-18
   slope on amplitude per region: {'Cortical': np.float64(0.1317), 'Subcortical': np.float64(0.0064)}

Reading c: mean +- SD
amp                     5               25              50              100
Region                                                                     
Cortical     24.64 +- 16.69  25.90 +- 16.70  26.85 +- 16.95  27.49 +- 17.41
Subcortical  27.05 +- 15.72  27.11 +- 15.89  26.46 +- 15.21  25.75 +- 15.29
   ANOVA: {'C(Region)': 'F = 0.55, p = 4.57e-01', 'amp': 'F = 10.72, p = 1.06e-03', 'amp:C(Region)': 'F = 9.40, p = 2.18e-03'}
   5 uA: Student p = 1.52e-02, Welch p = 1.22e-02
   25 uA: Student p = 2.26e-01, Welch p = 2.14e-01
   50 uA: Student p = 6.99e-01, Welch p = 6.81e-01
   100 uA: Student p = 8.86e-02, Welch p = 6.71e-02
   slope on amplitude per region: {'Cortical': np.float64(0.0282), 'Subcortical': np.float64(-0.015)}
By recording, reading a, cortical mean at 5 and 100 uA: {'jlh31': {5: 11.27, 100: 20.67}, 'jlh32': {5: 13.95, 100: 25.0}, 'jlh33': {5: 11.7, 100: 23.9}, 'jlh34': {5: 14.89, 100: 22.52}, 'jlh40': {5: 12.56, 100: 16.42}, 'jlh48': {5: 11.24, 100: 16.51}, 'jlh53concat': {5: 12.62, 100: 15.07}}

Expected cortical 12.60 (5 uA), 20.21 (100 uA); subcortical 12.27, 13.48 (10%)

Result: not reproduced. Under the code's reading (a) the magnitudes are 12.60, 13.37, 15.11 and 20.21 (cortical) and 12.27, 12.60, 12.08 and 13.48 (subcortical), the paper's values, with region F = 106.27, amplitude F = 487.36 and interaction F = 90.88 (all p < 1e-20) and cortical against subcortical Student p = 4.65e-09 at 50 uA and 5.50e-29 at 100 uA. Under the legend's 5 ms bins (b) the effects hold but the 50 uA comparison has p = 1.10e-02. Under the Methods' window of 500 to 1000 ms (c) there is no region effect (p = 0.457), the amplitude term has p = 1.06e-03, above 0.001, and no amplitude comparison has p < 0.001. The criterion asks for every reading and has no partial clause.

fig6d-hva-subcorticalΒΆ

At 100 uA higher visual areas, beyond the reach of direct activation, are more modulated than subcortical units (15.82 against 13.48) although both lie at similar distances from the stimulation site.

Groups by the Figure 6 legend: V1 (primary visual area), HVA (LM, AM, PM), subcortical ("hippocampus (CA1, subiculum, and dentate gyrus) and superior colliculus": Field CA1, Postsubiculum, the dentate gyrus and superior colliculus layers). Variants: the legend's HVA with the code's subcortical list (which has no CA1), and the code's groups (HVA any CCF name with "layer" and not "primary"). Tukey HSD over the three at 100 uA cathodal (magnitude under reading (a)); Mann-Whitney test of distances, HVA against subcortical, over the good stable units of all nine recordings (as the code, cells 40 and 41).

InΒ [25]:
# Magnitude at 100 uA (reading a) for every good stable unit of the recordings with cathodal pulses,
# so that the legend's subcortical regions outside the code's list (CA1) are included
rows = []
for s in CAT_SESS:
    u, tr, trains, meta = DATA[s]
    st = estim.condition_trials(tr, "cathodal", 100).start_time.to_numpy()
    for i in np.where(u.keep)[0]:
        mi = estim.modulation_index(trains[i], st, 0.0, 0.5, 0.010)
        rows.append(dict(session=s, unit=i, region=u.region[i], distance=u.distance[i], auc_a=np.trapezoid(np.abs(mi))))
M100 = pd.DataFrame(rows)
def hva_code(r):
    return ("layer" in r) and ("primary" not in r.lower())
def sub_code(r):
    return estim.classify_region(r) == "Subcortical"
allkeep = pd.concat([DATA[s][0].assign(session=s) for s in estim.PAPER_SESSIONS])
allkeep = allkeep[allkeep.keep]
print("Legend subcortical group, good stable units of the nine recordings by CCF name:",
      allkeep[allkeep.region.map(estim.legend_subcortical)].region.value_counts().to_dict())
print("Field CA1 units among them, which the code's subcortical list leaves out:", int((allkeep.region == "Field CA1").sum()), "\n")
for name, hva, sub in [("legend (HVA LM, AM, PM; subcortical CA1, subiculum, dentate gyrus, superior colliculus)", estim.legend_hva, estim.legend_subcortical),
                       ("legend HVA, the code's subcortical list", estim.legend_hva, sub_code),
                       ("code (HVA any layered name outside V1, the code's subcortical list)", hva_code, sub_code)]:
    d = M100.copy()
    d["group"] = np.where(d.region.str.contains("Primary visual area"), "V1",
                          np.where(d.region.map(hva), "HVA", np.where(d.region.map(sub), "Subcortical", "other")))
    d = d[d.group != "other"]
    print(f"Groups: {name}: n", d.group.value_counts().to_dict(), "| of the subcortical, Field CA1:",
          int((d[d.group == "Subcortical"].region == "Field CA1").sum()), "| of the HVA, superior colliculus or dentate gyrus:",
          int(d[d.group == "HVA"].region.str.contains("Superior colliculus|Dentate gyrus").sum()), "| mean +- SD",
          {g: f"{x.mean():.2f} +- {x.std():.2f}" for g, x in d.groupby("group").auc_a})
    tk = pairwise_tukeyhsd(d.auc_a, d.group)
    print(pd.DataFrame(tk.summary().data[1:], columns=tk.summary().data[0]).to_string(index=False))
    k = allkeep.copy()
    k["group"] = np.where(k.region.map(hva) & ~k.region.str.contains("Primary visual area"), "HVA",
                          np.where(k.region.map(sub), "Subcortical", "other"))
    h, sc = k[k.group == "HVA"].distance, k[k.group == "Subcortical"].distance
    print(f"   distance HVA median {h.median():.0f} um (n={len(h)}), subcortical {sc.median():.0f} um (n={len(sc)}), Mann-Whitney p = {stats.mannwhitneyu(h, sc).pvalue:.3g}\n")
print("Expected HVA 15.82, subcortical 13.48 (10%); paper Tukey p = 0.004; Mann-Whitney p = 0.511")
Legend subcortical group, good stable units of the nine recordings by CCF name: {'Field CA1': 315, 'Postsubiculum': 154, 'Superior colliculus, motor related, intermediate white layer': 68, 'Superior colliculus, motor related, intermediate gray layer': 43, 'Dentate gyrus molecular layer': 36, 'Dentate gyrus granule cell layer': 4}
Field CA1 units among them, which the code's subcortical list leaves out: 315 

Groups: legend (HVA LM, AM, PM; subcortical CA1, subiculum, dentate gyrus, superior colliculus): n {'V1': 1029, 'Subcortical': 460, 'HVA': 120} | of the subcortical, Field CA1: 273 | of the HVA, superior colliculus or dentate gyrus: 0 | mean +- SD {'HVA': '13.91 +- 9.85', 'Subcortical': '13.26 +- 8.36', 'V1': '21.06 +- 10.13'}
     group1      group2  meandiff  p-adj   lower  upper  reject
        HVA Subcortical   -0.6559 0.7843 -2.9723 1.6604   False
        HVA          V1    7.1414 0.0000  4.9615 9.3212    True
Subcortical          V1    7.7973 0.0000  6.5299 9.0647    True
   distance HVA median 1219 um (n=139), subcortical 1053 um (n=620), Mann-Whitney p = 0.54

Groups: legend HVA, the code's subcortical list: n {'V1': 1029, 'Subcortical': 351, 'HVA': 120} | of the subcortical, Field CA1: 0 | of the HVA, superior colliculus or dentate gyrus: 0 | mean +- SD {'HVA': '13.91 +- 9.85', 'Subcortical': '13.48 +- 7.70', 'V1': '21.06 +- 10.13'}
     group1      group2  meandiff  p-adj   lower  upper  reject
        HVA Subcortical   -0.4387  0.902 -2.8180 1.9407   False
        HVA          V1    7.1414  0.000  4.9709 9.3118    True
Subcortical          V1    7.5800  0.000  6.1892 8.9709    True
   distance HVA median 1219 um (n=139), subcortical 1033 um (n=499), Mann-Whitney p = 0.577

Groups: code (HVA any layered name outside V1, the code's subcortical list): n {'V1': 1029, 'HVA': 349, 'Subcortical': 260} | of the subcortical, Field CA1: 0 | of the HVA, superior colliculus or dentate gyrus: 91 | mean +- SD {'HVA': '15.82 +- 9.97', 'Subcortical': '13.68 +- 7.64', 'V1': '21.06 +- 10.13'}
     group1      group2  meandiff  p-adj   lower   upper  reject
        HVA Subcortical   -2.1404 0.0202 -4.0122 -0.2685    True
        HVA          V1    5.2385 0.0000  3.8232  6.6539    True
Subcortical          V1    7.3789 0.0000  5.7930  8.9648    True
   distance HVA median 1264 um (n=428), subcortical 939 um (n=348), Mann-Whitney p = 5.92e-08

Expected HVA 15.82, subcortical 13.48 (10%); paper Tukey p = 0.004; Mann-Whitney p = 0.511

Result: not reproduced. With the legend's groups (HVA: LM, AM and PM, 120 units; subcortical: CA1, subiculum, dentate gyrus and superior colliculus, 460 units in the seven recordings with cathodal pulses, of which 273 are CA1 units that the code's list leaves out) HVA units are modulated at 13.91 and subcortical units at 13.26 at 100 uA (Tukey p = 0.784); the distances do not differ (Mann-Whitney p = 0.54), as in the paper. With the code's subcortical list the values are 13.91 and 13.48 (p = 0.902). With the code's broader HVA group (any layered name outside V1, 349 units, of which 91 are superior colliculus or dentate gyrus units) they are 15.82 and 13.68 and Tukey p = 0.0202, but then the distances differ (p = 5.92e-08).

fig6e-circuit-respondersΒΆ

The proportion of cortical units in the circuit response grows with amplitude (0.14, 0.21, 0.52, 0.79); subcortical units respond less but also increase (0.08, 0.09, 0.17, 0.35).

ZETA test (zetapy 2.7.2 getZeta, 300 ms after onset, seeded per unit and condition), p < 0.05. As the code (figure7.ipynb cells 1 and 3 to 8, jlh_ephys/analysis.py get_zetas), the trials are all those of the run that holds the condition's first trial (tr.run == run, with no other filter; the table below gives their composition), the units good and stable, and the regions the code's map_region_to_group. The tests are run for the cathodal conditions, which give the claim's values and the cathodal model Value ~ Amplitude * C(Region) (type II), whose F values the Results also print. Running all three polarities for the code's full model Value ~ Amplitude * C(Polarity) * C(Region) would take about 20 more minutes; the full model is shown with the paper's cached p-values (outside input, a check only). In jlh53concat, a concatenated recording, the run numbers of the monopolar runs are also used by bipolar runs, so a run there holds 150 trials of both polarities; the condition's own 75 trials are run as a variant.

InΒ [26]:
jobs, keys = [], []
for si, s in enumerate(estim.PAPER_SESSIONS):
    u, tr, trains, meta = DATA[s]
    idx = np.where(u.keep & u.region_group.notna())[0]
    for ai, a in enumerate(estim.AMPS):
        c = estim.condition_trials(tr, "cathodal", a, n=None)
        if len(c) == 0:
            continue
        run = c.run.iloc[0]
        st = tr[tr.run == run].start_time.to_numpy()
        for i in idx:
            jobs.append((trains[i], st, int(1_000_000 * si + 10_000 * ai + i)))
            keys.append((s, i, "cathodal", a, int(run)))
comp = []
for s in estim.PAPER_SESSIONS:
    tr = DATA[s][1]
    for a in estim.AMPS:
        c = estim.condition_trials(tr, "cathodal", a, n=None)
        if len(c):
            r = tr[tr.run == c.run.iloc[0]]
            comp.append(dict(session=s, amp=a, run=int(c.run.iloc[0]), trials=len(r), amplitude=sorted(r.amplitude.unique().tolist()),
                             polarity=sorted(r.polarity.unique()), pulse_duration=sorted(r.pulse_duration.unique().tolist()),
                             pulse_number=sorted(r.pulse_number.unique().tolist()), contact_negative=sorted(r.contact_negative.unique().tolist())))
print("Trials of each run used for ZETA:")
print(pd.DataFrame(comp).to_string(index=False))
t = time.time()
ZP = estim.zeta_pvalues(jobs)
print(f"{len(jobs)} ZETA tests in {time.time() - t:.0f} s; failed: {int(np.isnan(ZP).sum())}")
Z = pd.DataFrame(keys, columns=["session", "unit", "pol", "amp", "run"]).assign(p=ZP)
Z = Z.join(pd.concat([DATA[s][0].assign(session=s).set_index(["session", "unit"], drop=False)[["region_group", "sub_cort", "distance"]]
                      for s in estim.PAPER_SESSIONS]), on=["session", "unit"])
Z["Region"] = np.where(Z.region_group.str.contains("Area"), "cortical", np.where(Z.region_group == "subcortical", "subcortical", None))
Z["sig"] = Z.p < 0.05
props = Z[Z.Region.notna()].groupby(["Region", "amp", "session"]).sig.mean().rename("Value").reset_index()
print(props.groupby(["Region", "amp"]).Value.agg(lambda x: f"{x.mean():.3f} +- {x.std():.3f} (n={len(x)})").unstack().to_string())
print("By recording, cortical:", props[props.Region == "cortical"].pivot(index="session", columns="amp", values="Value").round(2).to_dict("index"))
t = anova_lm(smf.ols("Value ~ amp * C(Region)", data=props).fit(), typ=2)
print("Cathodal model:", {i: f"F = {t.loc[i, 'F']:.2f}, p = {t.loc[i, 'PR(>F)']:.2e}" for i in t.index[:-1]})

# The paper's cached ZETA p-values (outside input, a check only)
CZ = {s: estim.code_zeta(s) for s in estim.PAPER_SESSIONS}
zc = Z.assign(p_code=[CZ[s][r][i] if r in CZ[s] else np.nan for s, r, i in zip(Z.session, Z.run, Z.unit)])
zc = zc[zc.p_code.notna()]
print(f"\nCached p-values found for {len(zc)} of {len(Z)} tests; same decision (p < 0.05) for {((zc.p < 0.05) == (zc.p_code < 0.05)).mean():.3f}; "
      f"responders here {(zc.p < 0.05).mean():.3f}, in the cache {(zc.p_code < 0.05).mean():.3f}; Pearson r of p {stats.pearsonr(zc.p, zc.p_code)[0]:.3f}")
print("Same decision by recording:", zc.groupby("session").apply(lambda g: round(((g.p < 0.05) == (g.p_code < 0.05)).mean(), 3)).to_dict())

# Variant: jlh53concat with the condition's own 75 cathodal trials in place of the whole run
s = "jlh53concat"; si = estim.PAPER_SESSIONS.index(s)
u, tr, trains, meta = DATA[s]
idx = np.where(u.keep & u.region_group.notna())[0]
jobs2, keys2 = [], []
for ai, a in enumerate(estim.AMPS):
    st = estim.condition_trials(tr, "cathodal", a, n=None).start_time.to_numpy()
    for i in idx:
        jobs2.append((trains[i], st, int(1_000_000 * si + 10_000 * ai + i)))
        keys2.append((s, i, a))
ZP2 = pd.Series(estim.zeta_pvalues(jobs2), index=pd.MultiIndex.from_tuples(keys2))
Zv = Z.copy()
m = Zv.session == s
Zv.loc[m, "p"] = [ZP2[(s, i, a)] for i, a in zip(Zv[m].unit, Zv[m].amp)]
Zv["sig"] = Zv.p < 0.05
pv = Zv[Zv.Region.notna()].groupby(["Region", "amp", "session"]).sig.mean().rename("Value").reset_index()
print(f"\njlh53concat with its own trials ({len(jobs2)} tests): proportions",
      pv.groupby(["Region", "amp"]).Value.mean().round(3).unstack().to_dict("index"),
      "| jlh53concat alone", pv[pv.session == s].pivot(index="Region", columns="amp", values="Value").round(2).to_dict("index"))
t = anova_lm(smf.ols("Value ~ amp * C(Region)", data=pv).fit(), typ=2)
print("   cathodal model:", {i: f"F = {t.loc[i, 'F']:.2f}, p = {t.loc[i, 'PR(>F)']:.2e}" for i in t.index[:-1]})
rows = []
for s in estim.PAPER_SESSIONS:
    u, tr = DATA[s][0], DATA[s][1]
    idx = np.where(u.keep & u.region_group.notna())[0]
    reg = np.where(u.region_group.str.contains("Area", na=False), "cortical", np.where(u.region_group == "subcortical", "subcortical", None))
    for pol in estim.POLARITIES:
        for a in estim.AMPS:
            c = estim.condition_trials(tr, pol, a, n=None)
            if len(c) == 0 or int(c.run.iloc[0]) not in CZ[s]:
                continue
            pc = CZ[s][int(c.run.iloc[0])]
            for R in ["cortical", "subcortical"]:
                ii = idx[reg[idx] == R]
                rows.append(dict(session=s, pol=pol, amp=a, Region=R, Value=np.mean(pc[ii] < 0.05)))
cp = pd.DataFrame(rows)
print("Cached p-values, cathodal:", cp[cp.pol == "cathodal"].groupby(["Region", "amp"]).Value.mean().round(2).unstack().to_dict("index"))
t = anova_lm(smf.ols("Value ~ amp * C(pol) * C(Region)", data=cp).fit(), typ=2)
print("Cached p-values, full model:", {i: f"F = {t.loc[i, 'F']:.2f}, p = {t.loc[i, 'PR(>F)']:.2e}" for i in t.index[:-1]})
print("Units with 'posteromedial visual area' names, which the code's grouping drops:",
      int(allkeep.region.str.contains("posteromedial visual area").sum()))
print("Expected cortical 0.14, 0.21, 0.52, 0.79; subcortical 0.08 (5 uA), 0.35 (100 uA) (10%); paper F amplitude 175.18, region 81.50 (full model)")
Trials of each run used for ZETA:
    session  amp  run  trials amplitude             polarity pulse_duration pulse_number contact_negative
      jlh31    5   16      75      [-5]          [monopolar]          [100]          [1]             [16]
      jlh31   25   18      75     [-25]          [monopolar]          [100]          [1]             [16]
      jlh31   50   20      75     [-50]          [monopolar]          [100]          [1]             [16]
      jlh31  100   22      75    [-100]          [monopolar]          [100]          [1]             [16]
      jlh32    5   12      75      [-5]          [monopolar]          [100]          [1]              [3]
      jlh32   25   14      75     [-25]          [monopolar]          [100]          [1]              [3]
      jlh32   50   16      75     [-50]          [monopolar]          [100]          [1]              [3]
      jlh32  100   18      75    [-100]          [monopolar]          [100]          [1]              [3]
      jlh33    5    8      60      [-5]          [monopolar]          [100]          [1]              [5]
      jlh33   25   10      60     [-25]          [monopolar]          [100]          [1]              [5]
      jlh33   50   12      60     [-50]          [monopolar]          [100]          [1]              [5]
      jlh33  100   13      60    [-100]          [monopolar]          [100]          [1]              [5]
      jlh34    5    7      75      [-5]          [monopolar]          [100]          [1]             [15]
      jlh34   25    9      75     [-25]          [monopolar]          [100]          [1]             [15]
      jlh34   50   11      75     [-50]          [monopolar]          [100]          [1]             [15]
      jlh34  100   13      75    [-100]          [monopolar]          [100]          [1]             [15]
      jlh40    5    0      75      [-5]          [monopolar]          [100]          [1]             [16]
      jlh40   25    1      75     [-25]          [monopolar]          [100]          [1]             [16]
      jlh40   50    2      75     [-50]          [monopolar]          [100]          [1]             [16]
      jlh40  100    6      75    [-100]          [monopolar]          [100]          [1]             [16]
      jlh48    5    8      75      [-5]          [monopolar]          [100]          [1]             [14]
      jlh48   25   10      75     [-25]          [monopolar]          [100]          [1]             [14]
      jlh48   50   12      75     [-50]          [monopolar]          [100]          [1]             [14]
      jlh48  100   14      75    [-100]          [monopolar]          [100]          [1]             [14]
jlh53concat    5    0     150      [-5] [bipolar, monopolar]          [100]          [1]             [16]
jlh53concat   25    2     150     [-25] [bipolar, monopolar]          [100]          [1]             [16]
jlh53concat   50    4     150     [-50] [bipolar, monopolar]          [100]          [1]             [16]
jlh53concat  100    6     150    [-100] [bipolar, monopolar]          [100]          [1]             [16]
7980 ZETA tests in 518 s; failed: 0
amp                           5                     25                    50                    100
Region                                                                                             
cortical     0.125 +- 0.051 (n=7)  0.209 +- 0.087 (n=7)  0.520 +- 0.255 (n=7)  0.785 +- 0.215 (n=7)
subcortical  0.089 +- 0.015 (n=7)  0.094 +- 0.036 (n=7)  0.164 +- 0.061 (n=7)  0.360 +- 0.197 (n=7)
By recording, cortical: {'jlh31': {5: 0.18, 25: 0.25, 50: 0.56, 100: 0.88}, 'jlh32': {5: 0.06, 25: 0.36, 50: 0.86, 100: 0.97}, 'jlh33': {5: 0.12, 25: 0.12, 50: 0.74, 100: 0.96}, 'jlh34': {5: 0.08, 25: 0.17, 50: 0.65, 100: 0.93}, 'jlh40': {5: 0.16, 25: 0.27, 50: 0.37, 100: 0.39}, 'jlh48': {5: 0.19, 25: 0.16, 50: 0.28, 100: 0.72}, 'jlh53concat': {5: 0.09, 25: 0.14, 50: 0.16, 100: 0.64}}
Cathodal model: {'C(Region)': 'F = 36.59, p = 1.61e-07', 'amp': 'F = 89.48, p = 6.84e-13', 'amp:C(Region)': 'F = 15.26, p = 2.72e-04'}
Cached p-values found for 7980 of 7980 tests; same decision (p < 0.05) for 0.970; responders here 0.325, in the cache 0.326; Pearson r of p 0.992
Same decision by recording: {'jlh31': 0.978, 'jlh32': 0.968, 'jlh33': 0.968, 'jlh34': 0.971, 'jlh40': 0.965, 'jlh48': 0.964, 'jlh53concat': 0.97}
jlh53concat with its own trials (1476 tests): proportions {'cortical': {5: 0.121, 25: 0.203, 50: 0.514, 100: 0.756}, 'subcortical': {5: 0.095, 25: 0.09, 50: 0.141, 100: 0.339}} | jlh53concat alone {'cortical': {5: 0.06, 25: 0.09, 50: 0.12, 100: 0.44}, 'subcortical': {5: 0.1, 25: 0.05, 50: 0.05, 100: 0.39}}
   cathodal model: {'C(Region)': 'F = 32.74, p = 5.27e-07', 'amp': 'F = 71.96, p = 2.21e-11', 'amp:C(Region)': 'F = 13.82, p = 4.93e-04'}
Cached p-values, cathodal: {'cortical': {5: 0.14, 25: 0.21, 50: 0.52, 100: 0.79}, 'subcortical': {5: 0.08, 25: 0.09, 50: 0.17, 100: 0.35}}
Cached p-values, full model: {'C(pol)': 'F = 5.13, p = 6.89e-03', 'C(Region)': 'F = 85.99, p = 1.10e-16', 'C(pol):C(Region)': 'F = 1.07, p = 3.45e-01', 'amp': 'F = 172.99, p = 2.40e-27', 'amp:C(pol)': 'F = 3.93, p = 2.16e-02', 'amp:C(Region)': 'F = 46.27, p = 1.89e-10', 'amp:C(pol):C(Region)': 'F = 0.09, p = 9.16e-01'}
Units with 'posteromedial visual area' names, which the code's grouping drops: 16
Expected cortical 0.14, 0.21, 0.52, 0.79; subcortical 0.08 (5 uA), 0.35 (100 uA) (10%); paper F amplitude 175.18, region 81.50 (full model)

Result: partially reproduced. With zetapy's getZeta the proportion of cortical circuit responders is 0.125, 0.209, 0.520 and 0.785 and of subcortical ones 0.089, 0.094, 0.164 and 0.360 at 5 to 100 uA, rising with amplitude and above one half for cortex at 50 and 100 uA; the cathodal model gives amplitude F = 89.48 (p = 6.84e-13) and region F = 36.59 (p = 1.61e-07). Two values are just outside 10% (cortical 5 uA 0.125 against 0.14, subcortical 5 uA 0.089 against 0.08). The recomputed p-values agree with the paper's cached ones (Pearson r = 0.992, same decision for 0.970 of 7980 tests), and the cached ones give the paper's 0.14, 0.21, 0.52, 0.79 and 0.08 to 0.35 on the dataset's units, with amplitude F = 172.99 and region F = 85.99 in the code's full model. The runs used hold only the condition's trials, except in jlh53concat, where a run number covers 150 trials of monopolar and bipolar pulses; with its own 75 cathodal trials the proportions are 0.121, 0.203, 0.514 and 0.756 (cortical) and 0.095 and 0.339 (subcortical at 5 and 100 uA), the same two values outside 10%.

fig6f-no-propagating-waveΒΆ

Response timing shows no propagating wave: time to peak inhibition and to peak rebound excitation do not correlate with distance from the stimulation site.

As the code (figure7.ipynb cells 20 to 23): cortical good stable units that are ZETA responders to 50 uA cathodal pulses; MI in 5 ms bins over 0 to 500 ms (first 75 trials), Gaussian smoothing (sigma 2.5 ms), the most prominent peak after 20 ms (prominence 0.2), time by the local centre of mass. The code's 4 ms minimum width and 6 ms centre-of-mass half-width are converted to whole 5 ms bins (its _to_bins, rounding 0.8 and 1.2 bins to 1), so it applies no width filter (only above 1 bin) and takes the centre of mass over +-1 bin, and a unit without a prominent peak falls back to its largest bin after 20 ms. The criterion lists the width of at least 4 ms; applied, it changes the outcome, so the width filter and dropping units without a peak are run as variants, alone and together. A centre of mass over +-6 ms and over +-1 bin of 5 ms take the same bins. The distance is the code's: Euclidean distance to the first trial's cathodal contact, signed negative where the unit is dorsal to it (the code takes the anodal contact for jlh32; both shown). jlh39 and jlh49v1, which the code leaves out, have no cathodal pulses, so the two readings are the same.

InΒ [27]:
from scipy.ndimage import gaussian_filter1d
from scipy.signal import find_peaks
BIN = 0.005
def pick_peaks(resp, sign, width=False, drop=False):
    """The code's pick_peak_times: its 4 ms width and 6 ms centre-of-mass half-width round to 1 bin of
    5 ms (_to_bins), so the width filter is skipped (applied only when above 1 bin) and the centre of
    mass spans +-1 bin; units without a prominent peak fall back to the largest bin after 20 ms.
    width: apply the criterion's width of at least 4 ms (0.8 bins); drop: leave out units without
    such a peak in place of the fallback."""
    sig = resp if sign > 0 else -resp
    out = np.full(len(resp), np.nan)
    minlat = int(np.floor(0.02 / BIN))
    for k, y0 in enumerate(sig):
        y = y0.copy()
        y[:minlat] = -np.inf
        pk, props = find_peaks(y, prominence=0.2, width=0.004 / BIN) if width else find_peaks(y, prominence=0.2)
        if len(pk) == 0:
            if drop:
                continue
            idx = int(np.nanargmax(y[minlat:]) + minlat)
        else:
            idx = int(pk[np.argmax(props["prominences"])])
        w = np.maximum(y0, 0)
        lo, hi = max(0, idx - 1), min(len(w), idx + 2)
        out[k] = (np.sum(np.arange(lo, hi) * w[lo:hi]) / w[lo:hi].sum() if w[lo:hi].sum() > 0 else idx) * BIN
    return out

RULES = {"code's rule": dict(), "width >= 4 ms, fallback kept": dict(width=True),
         "no width filter, units without a peak dropped": dict(drop=True),
         "criterion as written (width >= 4 ms, units without a peak dropped)": dict(width=True, drop=True)}
z50 = Z[(Z.pol == "cathodal") & (Z.amp == 50) & Z.sig]
for variant in ["code (jlh32 anodal contact)", "cathodal contact for all"]:
    mis, dists, sess = [], [], []
    for s in CAT_SESS:
        u, tr, trains, meta = DATA[s]
        st = estim.condition_trials(tr, "cathodal", 50).start_time.to_numpy()
        f0 = tr.iloc[0]
        stim = np.array([f0.neg_ap, f0.neg_dv, f0.neg_ml])
        if s == "jlh32" and variant.startswith("code"):
            f = estim.open_asset([a for a in ASSETS if a["session"] == s][0]["asset_id"])
            order = np.argsort(f["intervals/trials/start_time"][:], kind="stable")
            stim = f["intervals/trials/contact_positive_coords"][:][order][0]
            f.close()
        ids = z50[z50.session == s].unit.to_numpy()
        ids = [i for i in ids if "area" in u.region[i].lower()]
        for i in ids:
            mis.append(estim.modulation_index(trains[i], st, 0, 0.5, BIN))
            xyz = np.array([u.ap[i], u.dv[i], u.ml[i]])
            dists.append(np.linalg.norm(xyz - stim) * (-1 if xyz[1] < stim[1] else 1))
            sess.append(s)
    sm = gaussian_filter1d(np.array(mis), sigma=0.0025 / BIN, axis=1, mode="nearest")
    d = np.array(dists)
    print(f"{variant}: units {len(d)} by recording {pd.Series(sess).value_counts().to_dict()}; signed distance {d.min():.0f} to {d.max():.0f} um")
    for rule, kw in RULES.items():
        t_exc, t_inh = pick_peaks(sm, 1, **kw), pick_peaks(sm, -1, **kw)
        for name, tt in [("excitatory", t_exc), ("inhibitory", t_inh)]:
            ok = np.isfinite(tt) & np.isfinite(d)
            r, p = stats.pearsonr(d[ok], tt[ok])
            print(f"   {rule}, time to peak {name}: median {np.median(tt[ok]) * 1000:.0f} ms, Pearson r = {r:.3f}, p = {p:.3f}, n = {ok.sum()} ({len(d) - ok.sum()} dropped)")
print("Paper: Pearson r = -0.03 (p = 0.51) and 0.09 (p = 0.64)")
code (jlh32 anodal contact): units 614 by recording {'jlh31': 174, 'jlh32': 150, 'jlh34': 113, 'jlh33': 61, 'jlh40': 53, 'jlh48': 39, 'jlh53concat': 24}; signed distance -1218 to 1396 um
   code's rule, time to peak excitatory: median 205 ms, Pearson r = -0.001, p = 0.987, n = 614 (0 dropped)
   code's rule, time to peak inhibitory: median 70 ms, Pearson r = 0.047, p = 0.244, n = 614 (0 dropped)
   width >= 4 ms, fallback kept, time to peak excitatory: median 215 ms, Pearson r = 0.131, p = 0.001, n = 614 (0 dropped)
   width >= 4 ms, fallback kept, time to peak inhibitory: median 115 ms, Pearson r = -0.115, p = 0.004, n = 614 (0 dropped)
   no width filter, units without a peak dropped, time to peak excitatory: median 205 ms, Pearson r = 0.002, p = 0.969, n = 595 (19 dropped)
   no width filter, units without a peak dropped, time to peak inhibitory: median 70 ms, Pearson r = 0.050, p = 0.219, n = 595 (19 dropped)
   criterion as written (width >= 4 ms, units without a peak dropped), time to peak excitatory: median 220 ms, Pearson r = 0.153, p = 0.000, n = 595 (19 dropped)
   criterion as written (width >= 4 ms, units without a peak dropped), time to peak inhibitory: median 125 ms, Pearson r = -0.121, p = 0.004, n = 580 (34 dropped)
cathodal contact for all: units 614 by recording {'jlh31': 174, 'jlh32': 150, 'jlh34': 113, 'jlh33': 61, 'jlh40': 53, 'jlh48': 39, 'jlh53concat': 24}; signed distance -1218 to 1396 um
   code's rule, time to peak excitatory: median 205 ms, Pearson r = 0.004, p = 0.918, n = 614 (0 dropped)
   code's rule, time to peak inhibitory: median 70 ms, Pearson r = 0.047, p = 0.240, n = 614 (0 dropped)
   width >= 4 ms, fallback kept, time to peak excitatory: median 215 ms, Pearson r = 0.137, p = 0.001, n = 614 (0 dropped)
   width >= 4 ms, fallback kept, time to peak inhibitory: median 115 ms, Pearson r = -0.118, p = 0.004, n = 614 (0 dropped)
   no width filter, units without a peak dropped, time to peak excitatory: median 205 ms, Pearson r = 0.008, p = 0.853, n = 595 (19 dropped)
   no width filter, units without a peak dropped, time to peak inhibitory: median 70 ms, Pearson r = 0.051, p = 0.211, n = 595 (19 dropped)
   criterion as written (width >= 4 ms, units without a peak dropped), time to peak excitatory: median 220 ms, Pearson r = 0.161, p = 0.000, n = 595 (19 dropped)
   criterion as written (width >= 4 ms, units without a peak dropped), time to peak inhibitory: median 125 ms, Pearson r = -0.121, p = 0.003, n = 580 (34 dropped)
Paper: Pearson r = -0.03 (p = 0.51) and 0.09 (p = 0.64)

Result: reproduced, resting on an assumption. With the code's peak rule, among 614 cortical circuit responders at 50 uA, time to peak excitation (Pearson r = -0.001, p = 0.987) and to peak inhibition (r = 0.047, p = 0.244) do not correlate with signed distance, with either contact for jlh32; dropping the 19 units without a prominent peak gives r = 0.002 and 0.050 (p = 0.969 and 0.219). With the 4 ms width filter applied, as the criterion lists it, both correlate: r = 0.131 (p = 0.001) and -0.115 (p = 0.004), and r = 0.153 and -0.121 (p < 0.001 and 0.004) when units without such a peak are dropped. The outcome rests on the code's rule, which skips the width filter.

figs9-state-drpΒΆ

Direct response probability is slightly but significantly higher on high than on low cortical-state trials at every cathodal amplitude.

Trials (first 75 cathodal) split at the median of the population spike count in the 500 ms before onset (high: above the median); DRP or nDRP per unit within each half; paired Wilcoxon over units, FDR over amplitudes. Readings: population (a) all good stable units, (b) cortical ones; measure (i) DRP, (ii) nDRP; units (x) all good stable units, (y) the 99 direct responders.

InΒ [28]:
rows = []
for s in CAT_SESS:
    u, tr, trains, meta = DATA[s]
    idx = np.where(u.keep)[0]
    for popname, pidx in [("a", idx), ("b", np.where(u.keep & (u.sub_cort == "Cortical"))[0])]:
        pop = np.sort(np.concatenate([trains[i] for i in pidx]))
        for a in estim.AMPS:
            st = estim.condition_trials(tr, "cathodal", a).start_time.to_numpy()
            cnt = np.searchsorted(pop, st) - np.searchsorted(pop, st - 0.5)
            hi = cnt > np.median(cnt)
            for i in idx:
                for state, m in [("high", hi), ("low", ~hi)]:
                    p, pb, n = estim.spike_prob(trains[i], st[m])
                    rows.append((s, i, popname, a, state, p, n))
ST = pd.DataFrame(rows, columns=["session", "unit", "pop", "amp", "state", "drp", "ndrp"])
resp_set = set(map(tuple, R99.index))
for popname in ["a", "b"]:
    for meas in ["drp", "ndrp"]:
        for uset in ["x", "y"]:
            d = ST[ST["pop"] == popname]
            if uset == "y":
                d = d[[k in resp_set for k in zip(d.session, d.unit)]]
            w = d.pivot_table(index=["session", "unit", "amp"], columns="state", values=meas).dropna().reset_index()
            res, ps = {}, []
            for a in estim.AMPS:
                x = w[w.amp == a]
                ps.append(stats.wilcoxon(x.high, x.low).pvalue if (x.high != x.low).any() else 1.0)
                res[a] = f"low {x.low.mean():.4f}, high {x.high.mean():.4f}"
            adj = multipletests(ps, method="fdr_bh")[1]
            print(f"population {popname}, {meas}, units {uset} (n={w.groupby('amp').size().iloc[0]}):", res, "| FDR p", np.round(adj, 4).tolist(),
                  "| high above low at", [a for a in estim.AMPS if w[w.amp == a].high.mean() > w[w.amp == a].low.mean()])
print("Expected DRP low 0.051, high 0.056 at 5 uA; low 0.104, high 0.107 at 100 uA (10%); paper p < 0.001, < 0.001, 0.010, 0.030")
population a, drp, units x (n=2056): {5: 'low 0.0120, high 0.0124', 25: 'low 0.0126, high 0.0143', 50: 'low 0.0150, high 0.0159', 100: 'low 0.0159, high 0.0165'} | FDR p [0.0, 0.0, 0.0003, 0.0016] | high above low at [5, 25, 50, 100]
population a, drp, units y (n=99): {5: 'low 0.1328, high 0.1202', 25: 'low 0.1492, high 0.1542', 50: 'low 0.2023, high 0.2128', 100: 'low 0.2441, high 0.2463'} | FDR p [0.3008, 0.3601, 0.3008, 0.9463] | high above low at [25, 50, 100]
population a, ndrp, units x (n=2056): {5: 'low 0.0053, high 0.0043', 25: 'low 0.0054, high 0.0049', 50: 'low 0.0072, high 0.0057', 100: 'low 0.0081, high 0.0065'} | FDR p [0.0336, 0.0336, 0.0, 0.0] | high above low at []
population a, ndrp, units y (n=99): {5: 'low 0.1253, high 0.1068', 25: 'low 0.1411, high 0.1422', 50: 'low 0.1893, high 0.1982', 100: 'low 0.2329, high 0.2325'} | FDR p [0.0382, 0.7443, 0.7443, 0.7443] | high above low at [25, 50]
population b, drp, units x (n=2054): {5: 'low 0.0118, high 0.0127', 25: 'low 0.0131, high 0.0138', 50: 'low 0.0152, high 0.0157', 100: 'low 0.0160, high 0.0163'} | FDR p [0.0, 0.0, 0.0003, 0.0156] | high above low at [5, 25, 50, 100]
population b, drp, units y (n=99): {5: 'low 0.1267, high 0.1264', 25: 'low 0.1531, high 0.1501', 50: 'low 0.2088, high 0.2061', 100: 'low 0.2489, high 0.2410'} | FDR p [0.7679, 0.7679, 0.7679, 0.7679] | high above low at []
population b, ndrp, units x (n=2054): {5: 'low 0.0049, high 0.0047', 25: 'low 0.0058, high 0.0046', 50: 'low 0.0072, high 0.0057', 100: 'low 0.0079, high 0.0067'} | FDR p [0.1843, 0.0042, 0.0002, 0.0002] | high above low at []
population b, ndrp, units y (n=99): {5: 'low 0.1184, high 0.1139', 25: 'low 0.1465, high 0.1367', 50: 'low 0.1966, high 0.1907', 100: 'low 0.2372, high 0.2277'} | FDR p [0.7557, 0.7557, 0.7557, 0.4155] | high above low at []
Expected DRP low 0.051, high 0.056 at 5 uA; low 0.104, high 0.107 at 100 uA (10%); paper p < 0.001, < 0.001, 0.010, 0.030

Result: partially reproduced. With all good stable units, the whole population and DRP (readings a, i, x), DRP is higher on high-state trials at every amplitude (0.0124 against 0.0120 at 5 uA, 0.0165 against 0.0159 at 100 uA; FDR p 0.0016 or less); the cortical population gives the same (FDR p up to 0.0156). The values are about a quarter of the paper's at 5 uA (0.051 and 0.056) and a sixth at 100 uA (0.104 and 0.107). Among the 99 direct responders no difference is significant (FDR p 0.3008 to 0.9463), and with nDRP over all units the low state is higher at every amplitude (FDR p 0.0336 or less).

fig6g-dimensionality-collapseΒΆ

After a pulse the population's dimensionality drops (participation ratio and effective dimensionality fall), with a brief rise in effective dimensionality within 20 ms at 5, 25 and 50 uA.

Per recording, cortical good stable units (CCF name with "area"; the code's "layer" filter as a variant), first 75 cathodal trials, spike counts in bins from -200 to +500 ms; per bin the participation ratio of the neuron covariance across trials and the number of components for 95% of the variance; each recording's trace divided by its median over pre-stimulus bins, then averaged over recordings. Epochs as the code: middle 12 to 150 ms. Bins 2 ms (Methods, code) and 5 ms (legend).

InΒ [29]:
DIM = {}
for unit_filter in ["area", "layer"]:
    for bw in [0.002, 0.005]:
        edges = np.round(np.arange(-0.2, 0.5 + bw / 2, bw), 6)
        ctr = edges[:-1] + bw / 2
        res = {}
        for a in estim.AMPS:
            prs, eds = [], []
            for s in CAT_SESS:
                u, tr, trains, meta = DATA[s]
                idx = np.where(u.keep & u.region.str.contains(unit_filter))[0]
                st = estim.condition_trials(tr, "cathodal", a).start_time.to_numpy()
                C = np.stack([estim.counts_matrix(trains[i], st, edges) for i in idx], axis=2)  # trials x bins x units
                pr, ed = np.array([estim.participation_ratio(C[:, b, :]) for b in range(C.shape[1])]).T
                pre = ctr < 0
                prs.append(pr / np.nanmedian(pr[pre]))
                eds.append(ed / np.nanmedian(ed[pre]))
            res[a] = (np.nanmean(prs, 0), np.nanmean(eds, 0))
        DIM[(unit_filter, bw)] = (ctr, res)
        mid = (ctr >= 0.012) & (ctr <= 0.150)
        early = (ctr >= 0) & (ctr <= 0.020)
        print(f"units '{unit_filter}', {bw * 1000:.0f} ms bins:")
        for a in estim.AMPS:
            pr, ed = res[a]
            print(f"   {a} uA: middle-epoch PR {np.nanmean(pr[mid]):.3f}, ED {np.nanmean(ed[mid]):.3f} | 0-20 ms ED mean {np.nanmean(ed[early]):.3f}, max {np.nanmax(ed[early]):.3f}"
                  f" | late 170-300 ms PR {np.nanmean(pr[(ctr >= 0.17) & (ctr <= 0.3)]):.3f}")
ctr, res = DIM[("area", 0.002)]
pre = ctr < -0.004
print("\nPre-stimulus spread of the normalised traces (2 ms bins, 'area' units, -200 to -4 ms: firing falls in the 4 ms before the logged onset,"
      " see the data check), the reference for the 0 to 20 ms rise:")
print("   ED in the two bins before onset:", {a: np.round(res[a][1][(ctr < 0) & (ctr > -0.004)], 3).tolist() for a in estim.AMPS})
for a in estim.AMPS:
    pr, ed = res[a]
    print(f"   {a} uA: ED pre-stimulus min {np.nanmin(ed[pre]):.3f}, max {np.nanmax(ed[pre]):.3f}, 99th percentile {np.nanpercentile(ed[pre], 99):.3f};"
          f" bins of 0 to 20 ms above the pre-stimulus max: {int((ed[(ctr >= 0) & (ctr <= 0.020)] > np.nanmax(ed[pre])).sum())}"
          f" | PR pre-stimulus min {np.nanmin(pr[pre]):.3f}, max {np.nanmax(pr[pre]):.3f}")
fig, ax = plt.subplots(1, 2, figsize=(9, 2.5))
for a in estim.AMPS:
    ax[0].plot(ctr * 1000, res[a][0], label=f"{a} uA"); ax[1].plot(ctr * 1000, res[a][1])
ax[0].set_ylabel("normalised PR"); ax[1].set_ylabel("normalised ED"); ax[0].legend(fontsize=6)
for x in ax: x.set_xlabel("time from onset (ms)"); x.axhline(1, color="k", lw=0.5)
plt.tight_layout(); plt.show()
print("Plotted ranges: PR %.2f to %.2f, ED %.2f to %.2f" % (min(np.nanmin(res[a][0]) for a in estim.AMPS), max(np.nanmax(res[a][0]) for a in estim.AMPS),
                                                       min(np.nanmin(res[a][1]) for a in estim.AMPS), max(np.nanmax(res[a][1]) for a in estim.AMPS)))
units 'area', 2 ms bins:
   5 uA: middle-epoch PR 0.982, ED 0.986 | 0-20 ms ED mean 0.929, max 1.024 | late 170-300 ms PR 1.033
   25 uA: middle-epoch PR 0.951, ED 0.961 | 0-20 ms ED mean 0.994, max 1.087 | late 170-300 ms PR 1.013
   50 uA: middle-epoch PR 0.765, ED 0.778 | 0-20 ms ED mean 0.960, max 1.094 | late 170-300 ms PR 0.990
   100 uA: middle-epoch PR 0.459, ED 0.476 | 0-20 ms ED mean 0.884, max 1.043 | late 170-300 ms PR 0.979
units 'area', 5 ms bins:
   5 uA: middle-epoch PR 0.966, ED 0.973 | 0-20 ms ED mean 0.954, max 0.977 | late 170-300 ms PR 1.019
   25 uA: middle-epoch PR 0.959, ED 0.974 | 0-20 ms ED mean 1.010, max 1.038 | late 170-300 ms PR 0.998
   50 uA: middle-epoch PR 0.781, ED 0.813 | 0-20 ms ED mean 0.976, max 1.021 | late 170-300 ms PR 0.946
   100 uA: middle-epoch PR 0.478, ED 0.515 | 0-20 ms ED mean 0.894, max 0.984 | late 170-300 ms PR 0.872
units 'layer', 2 ms bins:
   5 uA: middle-epoch PR 0.983, ED 0.988 | 0-20 ms ED mean 0.931, max 1.015 | late 170-300 ms PR 1.030
   25 uA: middle-epoch PR 0.951, ED 0.963 | 0-20 ms ED mean 1.000, max 1.107 | late 170-300 ms PR 1.010
   50 uA: middle-epoch PR 0.773, ED 0.787 | 0-20 ms ED mean 0.954, max 1.079 | late 170-300 ms PR 0.991
   100 uA: middle-epoch PR 0.484, ED 0.505 | 0-20 ms ED mean 0.886, max 1.048 | late 170-300 ms PR 0.974
units 'layer', 5 ms bins:
   5 uA: middle-epoch PR 0.967, ED 0.972 | 0-20 ms ED mean 0.958, max 0.972 | late 170-300 ms PR 1.014
   25 uA: middle-epoch PR 0.959, ED 0.976 | 0-20 ms ED mean 1.016, max 1.045 | late 170-300 ms PR 0.996
   50 uA: middle-epoch PR 0.789, ED 0.826 | 0-20 ms ED mean 0.979, max 1.025 | late 170-300 ms PR 0.950
   100 uA: middle-epoch PR 0.504, ED 0.541 | 0-20 ms ED mean 0.894, max 0.986 | late 170-300 ms PR 0.870

Pre-stimulus spread of the normalised traces (2 ms bins, 'area' units, -200 to -4 ms: firing falls in the 4 ms before the logged onset, see the data check), the reference for the 0 to 20 ms rise:
   ED in the two bins before onset: {5: [0.805, 0.683], 25: [0.823, 0.574], 50: [0.801, 0.475], 100: [0.658, 0.333]}
   5 uA: ED pre-stimulus min 0.877, max 1.079, 99th percentile 1.078; bins of 0 to 20 ms above the pre-stimulus max: 0 | PR pre-stimulus min 0.856, max 1.087
   25 uA: ED pre-stimulus min 0.923, max 1.056, 99th percentile 1.047; bins of 0 to 20 ms above the pre-stimulus max: 5 | PR pre-stimulus min 0.911, max 1.093
   50 uA: ED pre-stimulus min 0.930, max 1.091, 99th percentile 1.074; bins of 0 to 20 ms above the pre-stimulus max: 3 | PR pre-stimulus min 0.917, max 1.098
   100 uA: ED pre-stimulus min 0.910, max 1.054, 99th percentile 1.050; bins of 0 to 20 ms above the pre-stimulus max: 0 | PR pre-stimulus min 0.913, max 1.091
No description has been provided for this image
Plotted ranges: PR 0.30 to 1.13, ED 0.33 to 1.13

Result: reproduced. With 2 ms bins, the normalised participation ratio in 12 to 150 ms is 0.982, 0.951, 0.765 and 0.459 and the effective dimensionality 0.986, 0.961, 0.778 and 0.476 at 5 to 100 uA, all below 1. Within 0 to 20 ms the largest bin of effective dimensionality is 1.024, 1.087 and 1.094 at 5, 25 and 50 uA (reading b; also 1.043 at 100 uA), while its mean over the window is below 1 (reading a). With 5 ms bins the largest bin exceeds 1 only at 25 and 50 uA. Against the pre-stimulus spread (-200 to -4 ms; normalised effective dimensionality up to 1.079, 1.056, 1.091 and 1.054), the largest bin in 0 to 20 ms exceeds the pre-stimulus maximum at 25 uA (5 bins) and 50 uA (3 bins), not at 5 uA (1.024 against 1.079) or 100 uA. The criterion does not compare with the pre-stimulus spread.

SummaryΒΆ

Claim Outcome
fig4d-latency-trough reproduced
figs3cd-gmm-window reproduced
fig4f-responders-within-ep not reproduced (assumption: the code's FS/RS classes; outside input: evoked-potential boundaries)
fig4h-extent-constant reproduced
fig4h-distance-decreases partially reproduced
fig4h-ks-distinct not reproduced
fig4i-proportion-within-ep reproduced (outside input: evoked-potential boundaries)
fig4j-drp-increases reproduced
figs3e-early-windows not reproduced
fig5bc-amplitude-clusters reproduced
fig5ef-polarity-clusters reproduced
fig5-fsrs-proportion not reproduced
fig5j-fs-closer reproduced (assumption: the code's FS/RS classes)
fig5i-fs-within-ep not reproduced (assumption: the code's FS/RS classes; outside input: evoked-potential boundaries)
figs6b-fsrs-early-firing reproduced (assumption: the code's FS/RS classes)
fig6c-modulation-cortex not reproduced
fig6d-hva-subcortical not reproduced
fig6e-circuit-responders partially reproduced
fig6f-no-propagating-wave reproduced (assumption: the code's peak rule, without the width filter)
figs9-state-drp partially reproduced
fig6g-dimensionality-collapse reproduced
fig1l, figs1b, fig2g (2), fig2-extent-polarity, fig2h, figs2gh, fig3c, fig3e, fig3fg, fig3h (11 claims) blocked: the raw AP band is not in the dandiset
fig7d, fig7e, fig7g, fig7h (2), figs10c, fig7i, fig7j, no-learning-across-sessions, figs10e (10 claims) blocked: the behaviour is not in the dandiset

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

How the analysis differs from the paper's. Units, trials, the stability filter and the direct response follow the paper and its code; the unit counts per condition match the code's intermediate table direct_df.csv for the cathodal and anodal conditions, and differ by 1 to 5 units for bipolar. Distances use the dataset's coordinates; the paper's code uses its own, which differ for the stimulation contacts of jlh31 and jlh32, and the distance claims are also shown with the code's coordinates, which give the paper's statistics and most of its values (not the 50 uA extent, 868 against 965 um, or the 50 against 100 uA KS p, 0.8245 against 0.083). The signed distance needs channel coordinates that the dandiset does not give; they are fitted from the units' coordinates. The FS and RS classes follow the paper's code (a 0.4 ms duration threshold), where the Methods describe k-means; both are shown; the k-means classes change with the initialisation. The evoked-potential boundaries of fig4f, fig4i and fig5i are the code's, an outside input. ZETA uses zetapy 2.7.2, whose getZeta the code calls, and only the cathodal conditions are tested. The claims on cathodal pulses rest on seven recordings: two of the nine hold no cathodal pulses.

What was found about the dataset. The dandiset holds the spikes, templates, CCF coordinates and regions of the nine recordings the paper analyses, and a tenth (jlh41) without coordinates that the paper does not use. The raw AP band, LFP and behaviour are not deposited, so 21 of the 42 claims cannot be tested, and three more only with the boundaries the paper's code computed. The file of session jlh49v1 is filed under subject jlh48. The stimulation contact coordinates of jlh31 and jlh32 differ from those the paper's code uses by 206 and 373 um, which changes the outcome of the distance claims. The electrodes table holds placeholder locations and coordinates. jlh33 has 60 trials per condition, where the Methods state 75. Neither FS/RS labels nor the stability flags the paper uses are in the files; both are recomputed here. In jlh53concat the run numbers of monopolar and bipolar runs coincide. Population firing falls in the 3 ms before the logged onset of the pulses in every recording.