Reproducing Sohn et al. (2019) from dandiset 000130¶

Paper. Sohn H, Narain D, Meirhaeghe N, Jazayeri M (2019). Bayesian computation through cortical latent dynamics. Neuron 103(5):934-947. doi:10.1016/j.neuron.2019.06.012. This attempt used the full text of the accepted author manuscript in PubMed Central (PMC6805134). The supplementary figures and tables (Tables S1 to S3) were not available to it.

Dandiset. DANDI:000130, version 0.241017.1448. The paper cites no DANDI version (it predates the dandiset, which was made for the Neural Latents Benchmark in 2021). Of the two published versions, 0.220113.0407 and 0.241017.1448, this is the most recent. Both hold the same two files (same asset and blob ids and sizes), and their metadata differ only in the publication date, the citation and the repository URL's trailing slash, so neither matches the paper better.

What the dandiset holds. One recording session (2016-12-11) of monkey H ("Haydn"), from the Neural Latents Benchmark (NLB): a train file with the full trial table and the spikes of 54 units, and a test file with 283 further trials of the same session that carry no task variables and only 40 of the units. The paper analyses 17 sessions of monkey H and 12 of monkey G. Monkey G is not in the dandiset. All results here come from this one session, and a "dataset" is one effector and target direction (4 here; the paper's 8 are 2 animals x 4).

Code. The paper's code is "available from the corresponding author on request" and was not read. The dandiset links the NLB, whose code (nlb_tools at commit 42f8410b) loads these files, builds tensors aligned to Go (1500 ms before Go), and scores models by a trial-by-trial correlation of neural speed with tp within each prior, effector and direction. It holds none of the paper's analyses; its speed measure (mean step between successive states) matches the one chosen here.

Claim Source Tested here
beh-slopes: slopes of tp on ts between 0 and 1 Fig. 1E Tested
beh-overlap-prior: opposite biases at ts = 800 ms Fig. 1E Tested
beh-sigmoid-increments: smaller tp increments at the extremes Fig. 2F Tested
beh-long-more-bias: smaller slopes for the Long prior Fig. 2H Tested
beh-bayes-model: the Bayesian observer captures bias and variance Fig. 2C, D Tested
glm-prior-ts: 65% prior-dependent and 27% ts-dependent units (H) Fig. 3E Tested
dfr-overlap-support: prior difference at 800 ms peaks during the support Fig. 3D Tested
pca-dimensionality: 3-4 PCs (estimation), 5-10 (production) Fig. S3 Tested
encoding-r2: projections match the Bayesian estimate (R2 0.993, 0.996) Fig. 4C Tested
encoding-bayes-beats-linear: Bayesian better than linear Fig. 4D Tested
short-in-long-control: no sigmoid in the matched Long period Fig. 4D Tested
neural-long-more-bias: smaller neural slope for Long Fig. 4D Tested
speed-vs-projection: larger projection, slower speed Fig. 4E Tested
initial-speed-biased: initial speed biased toward the mean Fig. 4F Tested
speed-predicts-tp: speed predicts tp Fig. 4G Tested
alt-speed-unmodulated: speed constant during the support Fig. 5B Tested
alt-transient-parallel: parallel trajectories after Set Fig. 5D Tested
alt-threshold-monotonic: distances fall steadily up to Go Fig. 5F Tested
gpfa-variance-inverted-u: inverted-U variance of single-trial projections Fig. 6C Tested
gpfa-mean-sigmoid: sigmoidal mean, higher Short slope Fig. 6D Tested
rnn-curved-manifold, rnn-perturbation: recurrent network models Fig. 7 Blocked, data missing (no model in the dandiset; code on request)
monkey-g: the results hold in monkey G throughout Blocked (monkey G not in the dandiset)
In [1]:
import os
import time
import warnings

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy import stats
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

import rsg

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

started = time.time()
data = rsg.load()
S, T = data["train"], data["test"]
trials, spikes = S["trials"], S["spikes"]
good = rsg.completed(trials)
units, unit_table = rsg.select_units(S, good)
DS = [(e, th, rsg.dataset_name(e, th)) for e, th in rsg.DATASETS]
print(f"loaded dandiset {rsg.DANDISET_ID} version {rsg.DANDISET_VERSION} in {time.time() - started:.0f} s")
loaded dandiset 000130 version 0.241017.1448 in 5 s

Data check¶

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

In [2]:
for key, f in [("train", S), ("test", T)]:
    print(f"{key}: {data['urls'][rsg.TRAIN if key == 'train' else rsg.TEST]}")
    for k, v in f["meta"].items():
        print(f"   {k}: {v}")
    print(f"   trials: {len(f['trials'])}; columns: {list(f['trials'].columns)}")
    print(f"   units: {len(f['unit_ids'])}; held out: {int(f['heldout'].sum())}")
print("\nTest-file units that are in the train file:", int(np.isin(T["unit_ids"], S["unit_ids"]).sum()), "of", len(T["unit_ids"]),
      "; train units held out:", int(S["heldout"].sum()), "; held-out train units in the test file:",
      int(np.isin(S["unit_ids"][S["heldout"]], T["unit_ids"]).sum()))
print("\nTrain trials by NLB split:", trials.split.value_counts().to_dict())
print("Train trials with Set and Go:", int((trials.set_time.notna() & trials.go_time.notna()).sum()),
      "; of these flagged is_outlier:", int(trials[trials.set_time.notna() & trials.go_time.notna()].is_outlier.sum()),
      "; completed and not outliers (used here):", len(good))
print("Used trials by split:", good.split.value_counts().to_dict())
print("\nUsed trials per condition (rows: effector, direction, prior; columns: ts in ms):")
print(pd.crosstab([good.is_eye.map({True: 'Eye', False: 'Hand'}), good.theta.map({0.0: 'Right', 180.0: 'Left'}),
                   good.is_short.map({True: 'Short', False: 'Long'})], good.ts).to_string())
print("\nColumn descriptions (train trial table):")
for c, d in S["descriptions"].items():
    print(f"   {c}: {d}")
train: https://dandiarchive.s3.amazonaws.com/blobs/73a/6f6/73a6f648-8e97-4c90-b323-a15844e4e3d4
   session_description: Data from monkey Haydn performing ready-set-go time interval reproduction task. This file contains continuous segments of the full session on 2016-12-11 that can be used for training models for the Neural Latents Benchmark.
   session_start_time: 2016-12-11T00:00:00-05:00
   session_id: 20161211
   subject_id: Haydn
   species: Macaca mulatta
   age: P4Y
   related_publications: ['http://dx.doi.org/10.1016/j.neuron.2019.06.012']
   electrode_location: ['DMFC']
   electrode_x_all_nan: True
   n_electrodes: 72
   trials: 1160; columns: ['start_time', 'stop_time', 'fix_on_time', 'fix_time', 'target_on_time', 'ready_time', 'set_time', 'go_time', 'target_acq_time', 'reward_time', 'bad_time', 'is_short', 'is_eye', 'theta', 'ts', 'tp', 'fix_time_dur', 'target_time_dur', 'iti', 'reward_dur', 'is_outlier', 'split']
   units: 54; held out: 14
test: https://dandiarchive.s3.amazonaws.com/blobs/eaf/1fa/eaf1fa97-ce58-477d-b79d-82425cea818f
   session_description: Data from monkey Haydn performing ready-set-go time interval reproduction task. This file contains segments of trials from the full session on 2016-12-11 that are to be used for evaluating models for the Neural Latents Benchmark.
   session_start_time: 2016-12-11T00:00:00-05:00
   session_id: 20161211
   subject_id: Haydn
   species: Macaca mulatta
   age: P4Y
   related_publications: ['http://dx.doi.org/10.1016/j.neuron.2019.06.012']
   electrode_location: ['DMFC']
   electrode_x_all_nan: True
   n_electrodes: 72
   trials: 283; columns: ['start_time', 'stop_time', 'go_time', 'split']
   units: 40; held out: 0

Test-file units that are in the train file: 40 of 40 ; train units held out: 14 ; held-out train units in the test file: 0

Train trials by NLB split: {'train': 748, 'val': 258, 'none': 154}
Train trials with Set and Go: 1029 ; of these flagged is_outlier: 46 ; completed and not outliers (used here): 983
Used trials by split: {'train': 720, 'val': 246, 'none': 17}

Used trials per condition (rows: effector, direction, prior; columns: ts in ms):
ts                     480.0   560.0   640.0   720.0   800.0   900.0   1000.0  1100.0  1200.0
is_eye theta is_short                                                                        
Eye    Left  Long           0       0       0       0      36      29      30      29      31
             Short         21      28      17      17      22       0       0       0       0
       Right Long           0       0       0       0      38      34      23      25      27
             Short         24      27      31      22      25       0       0       0       0
Hand   Left  Long           0       0       0       0      14      24      16      16      18
             Short         33      21      25      31      24       0       0       0       0
       Right Long           0       0       0       0      19      17      30      21      25
             Short         21      35      14      21      22       0       0       0       0

Column descriptions (train trial table):
   start_time: Start time of epoch, in seconds
   stop_time: Stop time of epoch, in seconds
   fix_on_time: Time when the fixation cue on the display turns on
   fix_time: Time when the animal fixates on the fixation cue
   target_on_time: Time of target presentation
   ready_time: Time of ready cue delivery
   set_time: Time of set cue presentation
   go_time: Time of monkey response, either with an eye saccade or joystick movement
   target_acq_time: Time of target acquisition
   reward_time: Time of monkey reward, if there was one
   bad_time: Time of trial failure, if the trial was unsuccessful
   is_short: Whether short prior (True) or long prior (False)
   is_eye: Whether motion to target was with hand (False) or eye (True)
   theta: Whether the target was on the right (0) or left (180)
   ts: Time between ready and set stimuli, in ms
   tp: Time between set stimulus and monkey go response, in ms
   fix_time_dur: Time between animal fixation and target onset, in ms
   target_time_dur: Time between target onset and ready, in ms
   iti: Inter-trial interval, in ms. Equal to 500 for trials sampled from short prior and 1220 for trials sampled from long prior
   reward_dur: Duration of reward, in ms
   is_outlier: Whether a trial's tp is considered an outlier using probabilistic mixture model
   split: Trial split that the trial belongs to for the Neural Latents Benchmark. Can be "train", "val", "test", or "none"

The dandiset summary says one subject (Rhesus monkey) and 2 files, which the files confirm. The test file holds 283 trials of the same session (session_id 20161211) with only start_time, stop_time, go_time and split, on its own clock (0 to 509 s), and only the 40 held-in units; its task variables and the 14 held-out units are withheld for the benchmark. These trials cannot enter any analysis that needs ts, the prior or the effector, so the analyses use the train file's trials. Each condition has at least 14 used trials, so the Methods' rule of at least 5 trials per condition does not remove any unit.

In [3]:
c = trials[trials.set_time.notna() & trials.go_time.notna()].copy()
d_ts = c.ts - (c.set_time - c.ready_time) * 1000
d_tp = c.tp - (c.go_time - c.set_time) * 1000
print("ts minus (set_time - ready_time), ms: median %.1f, range %.1f to %.1f; within 5 ms: %.0f%%"
      % (d_ts.median(), d_ts.min(), d_ts.max(), 100 * (abs(d_ts) <= 5).mean()))
for flag, g in d_tp.groupby(c.is_outlier):
    print(f"tp minus (go_time - set_time), ms, is_outlier={flag}: median {g.median():.1f}, 5th to 95th percentile "
          f"{g.quantile(.05):.1f} to {g.quantile(.95):.1f}, range {g.min():.1f} to {g.max():.1f}")
print("Distinct ts by prior:", {('Short' if k else 'Long'): sorted(v) for k, v in trials.groupby('is_short').ts.unique().items()})
print("iti by prior (ms):", {('Short' if k else 'Long'): sorted(v) for k, v in trials.groupby('is_short').iti.unique().items()})
print("Paper: inter-trial intervals of 1220 ms (Short) and 500 ms (Long).")
print("theta values:", sorted(trials.theta.unique()), "; is_eye values:", sorted(trials.is_eye.unique()))
print("tp of used trials, ms: %.0f to %.0f; of uncompleted trials (no Go):" % (good.tp.min(), good.tp.max()),
      sorted(trials[trials.go_time.isna()].tp.round().unique())[:6], "...")

# Refit of the Methods' mixture model, per prior, ts, effector and direction, on completed trials.
flags = []
for key, g in c[c.tp > 0].groupby(["is_short", "ts", "is_eye", "theta"]):
    r = rsg.outlier_mixture(g.tp.to_numpy(), key[1])
    flags.append(pd.Series(r > 0.5, index=g.index))
refit = pd.concat(flags).reindex(c.index, fill_value=True)
print("\nMixture-model refit against is_outlier (completed trials):")
print(pd.crosstab(c.is_outlier.rename("is_outlier"), refit.rename("refit lapse")).to_string())
print(f"Outlier fraction of completed trials: file {c.is_outlier.mean():.2%}, refit {refit.mean():.2%}; paper, monkey H: 3.84%")
only_file = c[c.is_outlier & ~refit]
print("tp / ts of trials flagged by the file but kept by the refit: %.2f to %.2f (%d trials); of trials flagged by both: %s"
      % ((only_file.tp / only_file.ts).min(), (only_file.tp / only_file.ts).max(), len(only_file),
         np.round(np.sort((c[c.is_outlier & refit].tp / c[c.is_outlier & refit].ts).to_numpy()), 2).tolist()))
ts minus (set_time - ready_time), ms: median 0.0, range -23.5 to 23.5; within 5 ms: 69%
tp minus (go_time - set_time), ms, is_outlier=False: median 17.9, 5th to 95th percentile 1.1 to 29.8, range 0.1 to 200.6
tp minus (go_time - set_time), ms, is_outlier=True: median 17.5, 5th to 95th percentile -1151.9 to 38.2, range -5322.0 to 162.4
Distinct ts by prior: {'Long': [np.float64(800.0), np.float64(900.0), np.float64(1000.0), np.float64(1100.0), np.float64(1200.0)], 'Short': [np.float64(480.0), np.float64(560.0), np.float64(640.0), np.float64(720.0), np.float64(800.0)]}
iti by prior (ms): {'Long': [np.float64(500.0)], 'Short': [np.float64(1220.0)]}
Paper: inter-trial intervals of 1220 ms (Short) and 500 ms (Long).
theta values: [np.float64(0.0), np.float64(180.0)] ; is_eye values: [np.False_, np.True_]
tp of used trials, ms: 407 to 1463; of uncompleted trials (no Go): [np.float64(-2400.0), np.float64(-2200.0), np.float64(-2000.0), np.float64(-1800.0), np.float64(-1600.0), np.float64(-1440.0)] ...
Mixture-model refit against is_outlier (completed trials):
refit lapse  False  True 
is_outlier               
False          983      0
True            36     10
Outlier fraction of completed trials: file 4.47%, refit 0.97%; paper, monkey H: 3.84%
tp / ts of trials flagged by the file but kept by the refit: 0.53 to 1.60 (36 trials); of trials flagged by both: [-2.0, -2.0, -2.0, 0.14, 0.21, 0.37, 0.59, 0.62, 2.21, 2.49]

ts is the nominal interval; the event times give intervals within 23.5 ms of it, consistent with a 60 Hz display (one frame is 16.7 ms). tp is a median 18 ms longer than go_time - set_time for used trials, so the two columns do not measure the same interval; the analyses use tp for behaviour, as the trial table defines it, and the event times for aligning spikes. The iti values agree with the paper (1220 ms after Short trials, 500 ms after Long ones), and its column description (500 for Short, 1220 for Long) has them the other way round. The is_outlier flag marks 46 completed trials (4.47%, against 3.84% for monkey H in the paper). A refit of the Methods' mixture model as read here, per prior, ts, effector and direction, flags only 10 of them and no other trial; the 36 others have tp between the ratios to ts printed above, which the refit keeps as part of the Gaussian component. How the flag was computed is not documented beyond "probabilistic mixture model". The analyses use the file's flag, as the claims file fixes.

In [4]:
print("Units: rate over the observed intervals, spikes/s")
print(unit_table.rate.describe().round(2).to_string())
print(f"Kept by the Methods' rule (>= {rsg.MIN_RATE} spike/s and >= {rsg.MIN_TRIALS} trials per condition): "
      f"{len(units)} of {len(unit_table)} (held-out units among the kept: {int(unit_table.heldout[unit_table.kept].sum())})")
print("Paper: GPFA on 'simultaneously recorded neurons in a single session (N = 48 in H)'.")
obs = S["obs_intervals"]
same = all(np.array_equal(o, obs[0]) for o in obs)
print("\nAll units share the same observation intervals:", same, "; intervals:", np.round(obs[0], 2).tolist())
gaps = obs[0][1:, 0] - obs[0][:-1, 1]
inside = [((obs[0][:, 0] <= a) & (obs[0][:, 1] >= b)).any() for a, b in zip(trials.start_time, trials.stop_time)]
print(f"Gaps between intervals: {np.round(gaps, 3).tolist()} s; trials inside one interval: {sum(inside)} of {len(trials)}")
print("Spike times sorted within every unit:", all(np.all(np.diff(s) >= 0) for s in spikes),
      "; spikes outside the observation intervals:",
      int(sum((~((s[:, None] >= obs[0][:, 0]) & (s[:, None] <= obs[0][:, 1])).any(1)).sum() for s in spikes)))

# Recordings deposited twice: does any test trial reappear in the train file? Compare the
# inter-spike-interval sequence of each test trial with the whole train recording, for 3 units.
hits, checked = 0, 0
for uid in T["unit_ids"][:3]:
    s_tr = np.round(np.diff(spikes[int(np.flatnonzero(S["unit_ids"] == uid)[0])]), 4)
    s_te = T["spikes"][int(np.flatnonzero(T["unit_ids"] == uid)[0])]
    for a, b in zip(T["trials"].start_time, T["trials"].stop_time):
        pat = np.round(np.diff(s_te[(s_te >= a) & (s_te < b)]), 4)
        if len(pat) < 3:
            continue
        checked += 1
        hits += any(np.array_equal(s_tr[i:i + len(pat)], pat) for i in np.flatnonzero(s_tr == pat[0]))
print(f"\nTest trials whose spike pattern recurs in the train file: {hits} of {checked} checked (3 units)")
seg = rsg.counts(spikes, units, good.ready_time.to_numpy(), 0, 400, 400)[:, 0, :]
dup = pd.DataFrame(seg).duplicated().sum()
print("Train trials with identical spike counts in all kept units over Ready to Ready + 400 ms:", int(dup))
Units: rate over the observed intervals, spikes/s
count    54.00
mean      6.57
std       5.66
min       0.39
25%       2.47
50%       4.18
75%      11.13
max      21.69
Kept by the Methods' rule (>= 1.0 spike/s and >= 5 trials per condition): 48 of 54 (held-out units among the kept: 13)
Paper: GPFA on 'simultaneously recorded neurons in a single session (N = 48 in H)'.

All units share the same observation intervals: True ; intervals: [[0.0, 740.01], [740.1, 1519.46], [1519.6, 2277.3], [2277.4, 3054.63], [3054.7, 3792.03], [3792.1, 4809.33]]
Gaps between intervals: [0.09, 0.138, 0.105, 0.072, 0.072] s; trials inside one interval: 1160 of 1160
Spike times sorted within every unit: True ; spikes outside the observation intervals: 0
Test trials whose spike pattern recurs in the train file: 0 of 674 checked (3 units)
Train trials with identical spike counts in all kept units over Ready to Ready + 400 ms: 0

The 48 units kept by the Methods' rate rule match the paper's count for the single session of monkey H used for GPFA (Methods, N = 48), which is consistent with this being that session; the dandiset does not say so. All units share six observation intervals separated by gaps of 0.1 to 0.14 s; every trial lies inside one interval. No test trial repeats a stretch of the train file, and no two train trials are identical, so no recording is deposited twice. The units carry no single-unit or multi-unit label, no electrode, and the electrode table has no positions or impedances.

In [5]:
# Do spikes share the clock of the trial table? Decode "before" against "after" an event from the
# population counts in 200 ms windows, with the event time shifted by an offset. A response
# locked to the event gives the best decoding at zero offset.
rng_clock = np.random.default_rng(1)
offsets = [-400, -200, -100, -50, 0, 50, 100, 200, 400]
rows = []
for event in ["ready_time", "set_time", "go_time"]:
    for off in offsets:
        t0 = good[event].to_numpy() + off / 1000
        pre = rsg.counts(spikes, units, t0, -200, 0, 200)[:, 0, :]
        post = rsg.counts(spikes, units, t0, 0, 200, 200)[:, 0, :]
        X = np.sqrt(np.vstack([pre, post]))
        y = np.r_[np.zeros(len(pre)), np.ones(len(post))]
        perm = rng_clock.permutation(len(y))
        acc = cross_val_score(LogisticRegression(max_iter=2000), X[perm], y[perm], cv=5).mean()
        rows.append({"event": event, "offset_ms": off, "accuracy": acc})
clock = pd.DataFrame(rows).pivot(index="offset_ms", columns="event", values="accuracy")
print("Cross-validated accuracy, before against after the event (chance 0.5):")
print(clock.round(3).to_string())
print("Offset of best decoding:", clock.idxmax().to_dict())

# Contrast between conditions: decode the prior from the counts in the 400 ms before Ready, with
# the trial labels as they are and shifted by one block of trials.
pre_ready = np.sqrt(rsg.counts(spikes, units, good.ready_time.to_numpy(), -400, 0, 400)[:, 0, :])
for shift in [0, 25, 50]:
    y = np.roll(good.is_short.to_numpy(), shift)
    perm = rng_clock.permutation(len(y))
    acc = cross_val_score(LogisticRegression(max_iter=2000), pre_ready[perm], y[perm], cv=5).mean()
    print(f"Prior decoded before Ready, labels shifted by {shift} trials: accuracy {acc:.3f}")
Cross-validated accuracy, before against after the event (chance 0.5):
event      go_time  ready_time  set_time
offset_ms                               
-400         0.728       0.555     0.679
-200         0.865       0.555     0.673
-100         0.900       0.588     0.690
-50          0.920       0.626     0.708
 0           0.941       0.766     0.791
 50          0.957       0.890     0.884
 100         0.974       0.932     0.929
 200         0.949       0.928     0.917
 400         0.769       0.913     0.884
Offset of best decoding: {'go_time': 100, 'ready_time': 100, 'set_time': 100}
Prior decoded before Ready, labels shifted by 0 trials: accuracy 0.641
Prior decoded before Ready, labels shifted by 25 trials: accuracy 0.558
Prior decoded before Ready, labels shifted by 50 trials: accuracy 0.520

Decoding of "before against after" is best at an offset of +100 ms for Ready, Set and Go, and falls for negative offsets (at -400 ms, 0.56, 0.68 and 0.73). A best offset of +100 ms is what a response with a latency of about 100 ms after each event gives, since the "after" window then holds the response. The prior is decoded from the 400 ms before Ready with accuracy 0.64 with the labels in place, 0.56 and 0.52 with the labels shifted by 25 and 50 trials. This is consistent with the spikes and the trial table sharing one clock in the train file; it does not prove it. The test file could not be checked this way, as it has no task variables; it is not used.

Behaviour¶

beh-slopes¶

Regression slopes of tp on ts are positive and less than one in every condition, so the animals estimated ts and their responses were biased toward the mean of the prior (Figure 1E).

In [6]:
rows = []
for e, th, name in DS:
    for short in [True, False]:
        g = good[(good.is_eye == e) & (good.theta == th) & (good.is_short == short)]
        b, se, ci = rsg.weighted_slope(g.ts, g.tp)
        b_ols = np.polyfit(g.ts, g.tp, 1)[0]
        rows.append({"dataset": name, "prior": "Short" if short else "Long", "trials": len(g), "slope": b,
                     "ci_low": ci[0], "ci_high": ci[1], "slope_unweighted": b_ols})
slopes = pd.DataFrame(rows)
print(slopes.round(3).to_string(index=False))
inside = ((slopes.slope > 0) & (slopes.slope < 1)).sum()
strict = ((slopes.ci_low > 0) & (slopes.ci_high < 1)).sum()
print(f"\nSlopes between 0 and 1: {inside} of 8; with the 95% interval excluding 0 and 1: {strict} of 8")
print("Slope range: %.3f to %.3f" % (slopes.slope.min(), slopes.slope.max()))
   dataset prior  trials  slope  ci_low  ci_high  slope_unweighted
 Eye Right Short     129  1.110   1.014    1.206             1.112
 Eye Right  Long     147  0.903   0.769    1.038             0.894
  Eye Left Short     105  1.011   0.896    1.126             1.060
  Eye Left  Long     155  0.845   0.743    0.948             0.850
Hand Right Short     113  0.861   0.728    0.993             0.857
Hand Right  Long     112  0.612   0.488    0.736             0.617
 Hand Left Short     134  0.612   0.499    0.725             0.616
 Hand Left  Long      88  0.502   0.369    0.636             0.496

Slopes between 0 and 1: 6 of 8; with the 95% interval excluding 0 and 1: 5 of 8
Slope range: 0.502 to 1.110
In [7]:
fig, axes = plt.subplots(1, 4, figsize=(14, 3.4), sharey=True)
for ax, (e, th, name) in zip(axes, DS):
    for short, col in [(True, "tab:red"), (False, "tab:blue")]:
        g = good[(good.is_eye == e) & (good.theta == th) & (good.is_short == short)]
        m = g.groupby("ts").tp.agg(["mean", "std"])
        ax.errorbar(m.index, m["mean"], m["std"], color=col, marker="o", capsize=2)
    ax.plot([450, 1250], [450, 1250], "k:", lw=1)
    ax.set_title(name)
    ax.set_xlabel("ts (ms)")
axes[0].set_ylabel("tp (ms), mean and SD")
plt.tight_layout()
plt.show()
print("Mean tp per prior and ts: %.0f to %.0f ms; all points within the axes."
      % (good.groupby(['is_eye', 'theta', 'is_short', 'ts']).tp.mean().min(), good.groupby(['is_eye', 'theta', 'is_short', 'ts']).tp.mean().max()))
No description has been provided for this image
Mean tp per prior and ts: 497 to 1177 ms; all points within the axes.

Result: not reproduced. Six of the 8 slopes lie between 0 and 1 (0.50 to 0.90), and 5 of 8 have 95% intervals that exclude both 0 and 1. In the Short prior with the eye, the slopes are 1.11 (Eye Right, interval 1.01 to 1.21) and 1.01 (Eye Left), so in these two conditions this session shows no regression toward the mean. All slopes are positive. The criterion needs all 8 between 0 and 1. The paper's slopes pool 17 sessions of monkey H and 12 of G; this is one session.

beh-overlap-prior¶

At the overlapping ts of 800 ms, biases were in opposite directions for the two priors (rank-sum test, p < 1e-43 in animal H over 17 sessions).

In [8]:
o = good[good.ts == 800]
s_tp, l_tp = o[o.is_short].tp, o[~o.is_short].tp
p = stats.ranksums(s_tp, l_tp).pvalue
print(f"ts = 800 ms: Short n = {len(s_tp)}, median tp - 800 = {s_tp.median() - 800:.1f} ms; "
      f"Long n = {len(l_tp)}, median tp - 800 = {l_tp.median() - 800:.1f} ms; rank-sum p = {p:.2g}")
for e, th, name in DS:
    g = o[(o.is_eye == e) & (o.theta == th)]
    a, b = g[g.is_short].tp, g[~g.is_short].tp
    print(f"   {name}: Short {a.median() - 800:+.1f} (n={len(a)}), Long {b.median() - 800:+.1f} (n={len(b)}), "
          f"p = {stats.ranksums(a, b).pvalue:.2g}")
ts = 800 ms: Short n = 93, median tp - 800 = 28.3 ms; Long n = 107, median tp - 800 = 47.6 ms; rank-sum p = 0.073
   Eye Right: Short +40.9 (n=25), Long +36.5 (n=38), p = 0.81
   Eye Left: Short +79.1 (n=22), Long +18.6 (n=36), p = 0.051
   Hand Right: Short +29.7 (n=22), Long +96.8 (n=19), p = 0.00099
   Hand Left: Short -61.6 (n=24), Long -2.8 (n=14), p = 0.069

Result: not reproduced. At ts = 800 ms the median bias is +28.3 ms in the Short prior and +47.6 ms in the Long prior, so both are positive and not of opposite sign; the rank-sum test gives p = 0.073 (93 and 107 trials). The Long prior gives the longer tp in 2 of 4 datasets, Hand Right (+29.7 against +96.8 ms, p = 0.00099) and Hand Left (-61.6 against -2.8 ms, p = 0.069), where the Short bias is negative. In the eye datasets the Short tp is the longer (Eye Left +79.1 against +18.6 ms, p = 0.051).

beh-sigmoid-increments¶

Increments of tp between neighbouring ts are smaller at the extremes of the prior than near its mean (signed-rank test, p < 1e-11 in animal H).

In [9]:
rows = []
for e, th, name in DS:
    for short in [True, False]:
        g = good[(good.is_eye == e) & (good.theta == th) & (good.is_short == short)]
        ext, mid = rsg.increments(g.groupby("ts").tp.mean().loc[list(rsg.prior_ts(short))])
        rows.append({"dataset": name, "prior": "Short" if short else "Long", "extreme": ext, "middle": mid})
inc = pd.DataFrame(rows)
inc["middle_minus_extreme"] = inc.middle - inc.extreme
print(inc.round(1).to_string(index=False))
w = stats.wilcoxon(inc.middle, inc.extreme)
print(f"\nMiddle above extreme in {(inc.middle_minus_extreme > 0).sum()} of 8; median difference "
      f"{inc.middle_minus_extreme.median():.1f} ms; two-sided signed-rank p = {w.pvalue:.4f}")
   dataset prior  extreme  middle  middle_minus_extreme
 Eye Right Short     65.7   104.1                  38.4
 Eye Right  Long     49.1   117.0                  67.8
  Eye Left Short     66.2    96.1                  29.9
  Eye Left  Long     78.0    89.3                  11.3
Hand Right Short     47.9    80.6                  32.7
Hand Right  Long     36.8    80.3                  43.5
 Hand Left Short     56.1    45.3                 -10.7
 Hand Left  Long     40.8    56.8                  16.0

Middle above extreme in 7 of 8; median difference 31.3 ms; two-sided signed-rank p = 0.0156

Result: reproduced. The mean increment of tp between neighbouring ts is larger in the middle of the prior than at its extremes in 7 of 8 conditions (median difference 31.3 ms), two-sided signed-rank p = 0.0156. The exception is Hand Left, Short prior (-10.7 ms).

beh-long-more-bias¶

Biases are larger for the Long prior than for the Short prior; regression slopes are smaller for Long (Figure 2H).

In [10]:
wide = slopes.pivot(index="dataset", columns="prior", values="slope")
wide["long_below_short"] = wide.Long < wide.Short
print(wide.round(3).to_string())
print(f"\nLong slope below Short slope in {int(wide.long_below_short.sum())} of 4 datasets; "
      f"mean Short {wide.Short.mean():.3f}, mean Long {wide.Long.mean():.3f}")
wu = slopes.pivot(index="dataset", columns="prior", values="slope_unweighted")
print(f"Unweighted slopes: Long below Short in {int((wu.Long < wu.Short).sum())} of 4")
prior        Long  Short  long_below_short
dataset                                   
Eye Left    0.845  1.011              True
Eye Right   0.903  1.110              True
Hand Left   0.502  0.612              True
Hand Right  0.612  0.861              True

Long slope below Short slope in 4 of 4 datasets; mean Short 0.898, mean Long 0.716
Unweighted slopes: Long below Short in 4 of 4

Result: reproduced. The Long prior slope is below the Short prior slope in all 4 datasets (mean 0.716 against 0.898), weighted or unweighted.

beh-bayes-model¶

A Bayesian observer model with scalar measurement and production noise fit to the behaviour captures the bias and the variance of tp in all conditions (Figure 2C and 2D).

In [11]:
models, rows, pred_rows = {}, [], []
for e, th, name in DS:
    g = good[(good.is_eye == e) & (good.theta == th)]
    m = rsg.BayesObserver.fit(g)
    models[name] = m
    obs_bias, pred_bias, obs_sd, pred_sd = [], [], [], []
    for short in [True, False]:
        ts = np.array(rsg.prior_ts(short), float)
        _, mean_tp, sd_tp = m.moments(ts, short)
        gg = g[g.is_short == short].groupby("ts").tp.agg(["mean", "std"]).loc[ts]
        obs_bias += list(gg["mean"] - ts)
        pred_bias += list(mean_tp - ts)
        obs_sd += list(gg["std"])
        pred_sd += list(sd_tp)
        for t, ob, pb, os_, ps in zip(ts, gg["mean"] - ts, mean_tp - ts, gg["std"], sd_tp):
            pred_rows.append({"dataset": name, "prior": "Short" if short else "Long", "ts": t,
                              "bias_obs": ob, "bias_model": pb, "sd_obs": os_, "sd_model": ps})
    ob, pb = np.array(obs_bias), np.array(pred_bias)
    r2 = 1 - np.sum((ob - pb) ** 2) / np.sum((ob - ob.mean()) ** 2)
    r_sd = stats.pearsonr(obs_sd, pred_sd)[0]
    rows.append({"dataset": name, "wm": m.wm, "wp": m.wp, "b_short": m.b[True], "b_long": m.b[False],
                 "converged": m.converged, "bias_R2": r2, "sd_r": r_sd})
fits = pd.DataFrame(rows)
print(fits.round(3).to_string(index=False))
print(f"\nBias R2 >= 0.8 in {(fits.bias_R2 >= 0.8).sum()} of 4; SD correlation > 0 in {(fits.sd_r > 0).sum()} of 4")
pred = pd.DataFrame(pred_rows)
print("\nObserved and model bias and SD (ms):")
print(pred.round(1).to_string(index=False))
   dataset    wm    wp  b_short  b_long  converged  bias_R2  sd_r
 Eye Right 0.013 0.106    6.411  24.391       True    0.122 0.909
  Eye Left 0.025 0.103   40.838   1.691       True    0.671 0.807
Hand Right 0.074 0.104   36.932  50.359       True    0.865 0.277
 Hand Left 0.117 0.090    7.749 -81.479       True    0.978 0.749

Bias R2 >= 0.8 in 2 of 4; SD correlation > 0 in 4 of 4

Observed and model bias and SD (ms):
   dataset prior     ts  bias_obs  bias_model  sd_obs  sd_model
 Eye Right Short  480.0      16.9        11.8    55.1      51.6
 Eye Right Short  560.0     -16.3         6.6    48.5      60.0
 Eye Right Short  640.0      -9.4         6.6    71.6      68.5
 Eye Right Short  720.0      32.0         6.7    62.7      77.1
 Eye Right Short  800.0      36.6        -2.5    63.3      84.2
 Eye Right  Long  800.0      44.3        33.6    86.3      86.1
 Eye Right  Long  900.0      22.3        24.7    85.8      96.4
 Eye Right  Long 1000.0       2.4        24.7    95.0     107.1
 Eye Right  Long 1100.0      56.2        24.8   127.8     117.8
 Eye Right  Long 1200.0     -23.5        10.9   165.2     126.2
  Eye Left Short  480.0      59.5        51.8    45.1      50.7
  Eye Left Short  560.0      20.0        41.6    52.7      59.3
  Eye Left Short  640.0      26.9        41.7    81.6      67.8
  Eye Left Short  720.0      52.1        41.7   100.4      76.2
  Eye Left Short  800.0      64.0        23.7    79.3      80.9
  Eye Left  Long  800.0      21.3        20.1    93.0      84.5
  Eye Left  Long  900.0      16.0         2.9    91.9      95.3
  Eye Left  Long 1000.0       6.7         3.0   103.5     105.9
  Eye Left  Long 1100.0      -5.3         2.6   112.2     116.3
  Eye Left  Long 1200.0     -44.0       -24.1    91.9     121.3
Hand Right Short  480.0      80.5        72.7    78.1      56.2
Hand Right Short  560.0      33.0        47.2    61.1      69.7
Hand Right Short  640.0      21.1        41.8   106.2      80.5
Hand Right Short  720.0      34.2        27.4   116.4      83.3
Hand Right Short  800.0      17.4       -10.6    80.1      82.4
Hand Right  Long  800.0     117.2       110.2    93.1      93.6
Hand Right  Long  900.0      70.0        72.0    90.7     109.2
Hand Right  Long 1000.0      63.8        55.2    93.4     121.9
Hand Right  Long 1100.0      30.5        29.2    93.6     124.6
Hand Right  Long 1200.0     -48.7       -21.0    87.8     123.5
 Hand Left Short  480.0      68.7        69.6    70.6      54.6
 Hand Left Short  560.0      34.5        35.3    63.4      68.8
 Hand Left Short  640.0      21.5        11.5    65.7      78.0
 Hand Left Short  720.0     -34.9       -19.2    71.5      78.6
 Hand Left Short  800.0     -48.5       -63.7    91.3      75.7
 Hand Left  Long  800.0       8.3        18.5    66.6      89.0
 Hand Left  Long  900.0     -27.4       -34.3    87.7     101.7
 Hand Left  Long 1000.0     -94.2       -79.2    86.5     111.1
 Hand Left  Long 1100.0    -113.8      -128.1   109.7     113.6
 Hand Left  Long 1200.0    -196.4      -187.7   103.4     112.1
In [12]:
fig, axes = plt.subplots(1, 2, figsize=(8, 3.5))
for short, col in [(True, "tab:red"), (False, "tab:blue")]:
    p_ = pred[pred.prior == ("Short" if short else "Long")]
    axes[0].scatter(p_.bias_model, p_.bias_obs, color=col, s=15)
    axes[1].scatter(p_.sd_model, p_.sd_obs, color=col, s=15)
for ax, lab in zip(axes, ["bias (ms)", "SD of tp (ms)"]):
    lim = [min(ax.get_xlim()[0], ax.get_ylim()[0]), max(ax.get_xlim()[1], ax.get_ylim()[1])]
    ax.plot(lim, lim, "k:", lw=1)
    ax.set_xlabel("model " + lab)
    ax.set_ylabel("observed " + lab)
plt.tight_layout()
plt.show()
print("Bias: observed %.0f to %.0f, model %.0f to %.0f ms; SD: observed %.0f to %.0f, model %.0f to %.0f ms; all within the axes."
      % (pred.bias_obs.min(), pred.bias_obs.max(), pred.bias_model.min(), pred.bias_model.max(),
         pred.sd_obs.min(), pred.sd_obs.max(), pred.sd_model.min(), pred.sd_model.max()))
No description has been provided for this image
Bias: observed -196 to 117, model -188 to 110 ms; SD: observed 45 to 165, model 51 to 126 ms; all within the axes.

Result: partially reproduced. The observer model predicts the SD of tp with a positive correlation in all 4 datasets (r = 0.28 to 0.91), but its predicted bias reaches R2 >= 0.8 in only 2 of 4 (R2 = 0.12, 0.67, 0.87 and 0.98). The two eye datasets, whose Short slopes exceed 1 (beh-slopes), get very low measurement noise (wm = 0.013 and 0.025) and are the poorly fit ones. Every fit converged. Table S3 was not available, so the fitted parameters are not compared with the paper's.

Single units¶

glm-prior-ts¶

About 30% of neurons were modulated by elapsed time (27% in monkey H) and more than 60% were sensitive to the prior (65% in monkey H) (Figure 3E).

In [13]:
def glm_rows(trs, ts_values):
    # One row per trial and time point t (ms after Ready) for which Set came after t (attrition).
    elapsed = ((trs.set_time - trs.ready_time) * 1000).to_numpy()
    cnt = np.stack([rsg.counts(spikes, units, trs.ready_time.to_numpy() + t / 1000, -rsg.GLM_WINDOW_MS, 0,
                               rsg.GLM_WINDOW_MS)[:, 0, :] for t in ts_values], axis=1)
    rows, ys = [], []
    for i, (_, r) in enumerate(trs.iterrows()):
        for k, t in enumerate(ts_values):
            if elapsed[i] >= t - 1e-6:
                rows.append({"trial": i, "t": k, "prior": float(not r.is_short), "eff": float(not r.is_eye), "dir": float(r.theta == 180)})
                ys.append(cnt[i, k])
    return pd.DataFrame(rows), np.array(ys)


def design(df, with_t=True, with_prior=True):
    cols = [np.ones(len(df)), df.eff.to_numpy(), df["dir"].to_numpy()]
    if with_prior:
        cols.append(df.prior.to_numpy())
    if with_t:
        cols += [(df.t == k).to_numpy().astype(float) for k in range(1, 5)]
    return np.column_stack(cols)


def fit(y, X):
    r = sm.GLM(y, X, family=sm.families.Poisson()).fit()
    return r.llf, X.shape[1]


def bic(llf, k, n):
    return -2 * llf + k * np.log(n)


res = []
df_all, Y_all = glm_rows(good, rsg.SHORT_TS)
per_prior = {short: glm_rows(good[good.is_short == short], rsg.prior_ts(short)) for short in [True, False]}
for j, u in enumerate(units):
    y = Y_all[:, j]
    l_full, k_full = fit(y, design(df_all))
    l_red, k_red = fit(y, design(df_all, with_prior=False))
    n = len(y)
    row = {"unit": int(S["unit_ids"][u]), "prior_dBIC": bic(l_red, k_red, n) - bic(l_full, k_full, n),
           "prior_LR_p": stats.chi2.sf(2 * (l_full - l_red), k_full - k_red)}
    for short, (dfp, Yp) in per_prior.items():
        yp = Yp[:, j]
        lf, kf = fit(yp, design(dfp, with_prior=False))
        lr, kr = fit(yp, design(dfp, with_t=False, with_prior=False))
        row[f"ts_dBIC_{'short' if short else 'long'}"] = bic(lr, kr, len(yp)) - bic(lf, kf, len(yp))
        row[f"ts_LR_p_{'short' if short else 'long'}"] = stats.chi2.sf(2 * (lf - lr), kf - kr)
    res.append(row)
glm = pd.DataFrame(res)
glm["prior_dep"] = glm.prior_dBIC > 0
glm["ts_dep"] = (glm.ts_dBIC_short > 0) | (glm.ts_dBIC_long > 0)


def wilson(k, n):
    lo, hi = sm.stats.proportion_confint(k, n, method="wilson")
    return f"{100 * k / n:.1f}% ({k} of {n}; 95% interval {100 * lo:.1f} to {100 * hi:.1f}%)"


print("Rows (trial x time point): all trials", len(df_all), "; Short", len(per_prior[True][0]), "; Long", len(per_prior[False][0]))
print("Prior-dependent (BIC):", wilson(int(glm.prior_dep.sum()), len(glm)), "; paper, H: 65%")
print("ts-dependent (BIC):   ", wilson(int(glm.ts_dep.sum()), len(glm)), "; paper, H: 27%")
print("Both:", int((glm.prior_dep & glm.ts_dep).sum()), "; prior only:", int((glm.prior_dep & ~glm.ts_dep).sum()),
      "; ts only:", int((~glm.prior_dep & glm.ts_dep).sum()), "; neither:", int((~glm.prior_dep & ~glm.ts_dep).sum()))
print("\nVariants, likelihood-ratio test in place of BIC:")
for a in [0.05, 0.01]:
    pd_ = (glm.prior_LR_p < a).sum()
    td_ = ((glm.ts_LR_p_short < a) | (glm.ts_LR_p_long < a)).sum()
    print(f"   p < {a}: prior {100 * pd_ / len(glm):.1f}%, ts {100 * td_ / len(glm):.1f}%")
print("ts-dependent in Short only:", int(((glm.ts_dBIC_short > 0) & ~(glm.ts_dBIC_long > 0)).sum()),
      "; Long only:", int((~(glm.ts_dBIC_short > 0) & (glm.ts_dBIC_long > 0)).sum()),
      "; both:", int(((glm.ts_dBIC_short > 0) & (glm.ts_dBIC_long > 0)).sum()))
Rows (trial x time point): all trials 3690 ; Short 1198 ; Long 1416
Prior-dependent (BIC): 75.0% (36 of 48; 95% interval 61.2 to 85.1%) ; paper, H: 65%
ts-dependent (BIC):    45.8% (22 of 48; 95% interval 32.6 to 59.7%) ; paper, H: 27%
Both: 19 ; prior only: 17 ; ts only: 3 ; neither: 9

Variants, likelihood-ratio test in place of BIC:
   p < 0.05: prior 87.5%, ts 77.1%
   p < 0.01: prior 77.1%, ts 66.7%
ts-dependent in Short only: 6 ; Long only: 7 ; both: 9
In [14]:
# Chance levels: the same BIC rule with the prior labels shuffled across trials within each
# effector and direction, and with the time points shuffled among the rows of each trial.
rng_glm = np.random.default_rng(7)
N_SHUFFLES = 5


def shuffled_prior(df, trs):
    lab = trs.is_short.to_numpy().copy()
    for _, idx in trs.reset_index(drop=True).groupby(["is_eye", "theta"]).groups.items():
        lab[idx] = rng_glm.permutation(lab[idx])
    out = df.copy()
    out["prior"] = (~lab[df.trial.to_numpy()]).astype(float)
    return out


def shuffled_time(df):
    out = df.copy()
    t = out.t.to_numpy().copy()
    for idx in out.groupby("trial").indices.values():
        t[idx] = rng_glm.permutation(t[idx])
    out["t"] = t
    return out


null_rows = []
for s in range(N_SHUFFLES):
    dfs = shuffled_prior(df_all, good)
    dft = {short: shuffled_time(dfp) for short, (dfp, _) in per_prior.items()}
    n_prior = n_ts_s = n_ts_l = n_ts = 0
    for j in range(len(units)):
        y = Y_all[:, j]
        lf, kf = fit(y, design(dfs))
        lr, kr = fit(y, design(dfs, with_prior=False))
        n_prior += bic(lr, kr, len(y)) - bic(lf, kf, len(y)) > 0
        dep = {}
        for short, (_, Yp) in per_prior.items():
            yp = Yp[:, j]
            lf, kf = fit(yp, design(dft[short], with_prior=False))
            lr, kr = fit(yp, design(dft[short], with_t=False, with_prior=False))
            dep[short] = bic(lr, kr, len(yp)) - bic(lf, kf, len(yp)) > 0
        n_ts_s += dep[True]
        n_ts_l += dep[False]
        n_ts += dep[True] or dep[False]
    null_rows.append({"shuffle": s, "prior_dep_pct": 100 * n_prior / len(units), "ts_dep_short_pct": 100 * n_ts_s / len(units),
                      "ts_dep_long_pct": 100 * n_ts_l / len(units), "ts_dep_either_pct": 100 * n_ts / len(units)})
glm_null = pd.DataFrame(null_rows)
print("Fractions of units passing the BIC rule with shuffled labels (%):")
print(glm_null.round(1).to_string(index=False))
print(f"\nObserved: prior {100 * glm.prior_dep.mean():.1f}%, ts in Short {100 * (glm.ts_dBIC_short > 0).mean():.1f}%, "
      f"ts in Long {100 * (glm.ts_dBIC_long > 0).mean():.1f}%, ts in either {100 * glm.ts_dep.mean():.1f}%")
print(f"Shuffled, maximum over {N_SHUFFLES}: prior {glm_null.prior_dep_pct.max():.1f}%, ts in either {glm_null.ts_dep_either_pct.max():.1f}%")

# Slow drift can mimic a prior effect when the priors come in blocks. Shift the prior labels
# along the session (as in the clock check) so that the block structure is kept.
print("\nPrior labels shifted along the session, block structure kept:")
for shift in [50, 100, 200]:
    lab = np.roll(good.is_short.to_numpy(), shift)
    dfs = df_all.copy()
    dfs["prior"] = (~lab[df_all.trial.to_numpy()]).astype(float)
    n_prior = 0
    for j in range(len(units)):
        y = Y_all[:, j]
        lf, kf = fit(y, design(dfs))
        lr, kr = fit(y, design(dfs, with_prior=False))
        n_prior += bic(lr, kr, len(y)) - bic(lf, kf, len(y)) > 0
    print(f"   shifted by {shift} trials: labels unchanged on {100 * (lab == good.is_short.to_numpy()).mean():.0f}% of trials; "
          f"prior-dependent {100 * n_prior / len(units):.1f}%")
runs = (good.is_short.to_numpy()[1:] != good.is_short.to_numpy()[:-1]).sum() + 1
print(f"Prior blocks in the session (runs of one prior over used trials): {runs}; median length "
      f"{np.median(np.diff(np.r_[0, np.flatnonzero(np.diff(good.is_short.to_numpy().astype(int))) + 1, len(good)])):.0f} trials")
Fractions of units passing the BIC rule with shuffled labels (%):
 shuffle  prior_dep_pct  ts_dep_short_pct  ts_dep_long_pct  ts_dep_either_pct
       0            0.0               2.1              0.0                2.1
       1            4.2               0.0              0.0                0.0
       2            0.0               0.0              0.0                0.0
       3            8.3               0.0              0.0                0.0
       4            0.0               2.1              0.0                2.1

Observed: prior 75.0%, ts in Short 31.2%, ts in Long 33.3%, ts in either 45.8%
Shuffled, maximum over 5: prior 8.3%, ts in either 2.1%

Prior labels shifted along the session, block structure kept:
   shifted by 50 trials: labels unchanged on 56% of trials; prior-dependent 10.4%
   shifted by 100 trials: labels unchanged on 53% of trials; prior-dependent 14.6%
   shifted by 200 trials: labels unchanged on 48% of trials; prior-dependent 2.1%
Prior blocks in the session (runs of one prior over used trials): 87; median length 9 trials

Result: not reproduced. By the GLM and BIC rule of the Methods, 75.0% of units are prior-dependent (36 of 48, 95% interval 61.2 to 85.1%; paper 65 +/- 6.5%) and 45.8% are ts-dependent (22 of 48, interval 32.6 to 59.7%; paper 27 +/- 2.7%). Both fractions are above the paper's and outside tolerance; the interval of the prior fraction includes 65%, that of the ts fraction does not include 27%. With a likelihood-ratio test in place of BIC the fractions are higher still (prior 77.1 to 87.5%, ts 66.7 to 77.1%). The paper's fractions pool 536 units over 17 sessions; these 48 come from one session. Both fractions are far above chance. With the prior labels shuffled within effector and direction, 0 to 8.3% of units pass the prior test; with the labels shifted along the session by 50 to 200 trials, which keeps the blocks of the priors, 2.1 to 14.6%. With the time points shuffled within trials, 0 to 2.1% pass the ts test in either prior. Counting ts dependence in either prior's test gives 45.8%; the Short test alone gives 31.2% and the Long test alone 33.3%, both still above 27 +/- 2.7%.

dfr-overlap-support¶

At the overlapping ts of 800 ms, firing rates differ between priors, with the largest difference during the support of the prior, between 480 and 800 ms after Ready (Figure 3D).

In [15]:
rng_dfr = np.random.default_rng(3)
edges = np.arange(-320, 1440 + 1, 160)
centres = (edges[:-1] + edges[1:]) / 2


def rms_dfr(cnt, short):
    d = cnt[short].mean(0) - cnt[~short].mean(0)          # bins x units, counts per 160 ms
    return np.sqrt(np.mean((d * 1000 / 160) ** 2, axis=1))  # spikes/s


per_ds, nulls = {}, []
cnts = {}
for e, th, name in DS:
    g = good[(good.is_eye == e) & (good.theta == th) & (good.ts == 800)]
    cnts[name] = (rsg.counts(spikes, units, g.ready_time.to_numpy(), -320, 1440, 160), g.is_short.to_numpy())
    per_ds[name] = rms_dfr(*cnts[name])
mean_rms = np.mean(list(per_ds.values()), axis=0)
for _ in range(200):
    nulls.append(np.mean([rms_dfr(cnt, rng_dfr.permutation(lab)) for cnt, lab in cnts.values()], axis=0))
nulls = np.array(nulls)
tab = pd.DataFrame({"bin_start": edges[:-1], "bin_end": edges[1:], **{k: v for k, v in per_ds.items()},
                    "mean": mean_rms, "shuffle_mean": nulls.mean(0), "shuffle_95": np.percentile(nulls, 95, axis=0)})
print("RMS over units of the Short minus Long rate difference at ts = 800 ms (spikes/s), by 160 ms bin from Ready:")
print(tab.round(2).to_string(index=False))
k = int(np.argmax(mean_rms))
thr = np.percentile(nulls.max(1), 95)
print(f"\nLargest mean RMS: {mean_rms[k]:.2f} spikes/s in the bin {edges[k]} to {edges[k + 1]} ms after Ready; "
      f"95th percentile of the shuffled maximum: {thr:.2f}")
print("Largest bin per dataset:", {n: f"{edges[np.argmax(v)]}-{edges[np.argmax(v) + 1]}" for n, v in per_ds.items()})
print("Used trials at ts = 800 ms per dataset (Short, Long):",
      {n: (int(l.sum()), int((~l).sum())) for n, (c_, l) in cnts.items()})
RMS over units of the Short minus Long rate difference at ts = 800 ms (spikes/s), by 160 ms bin from Ready:
 bin_start  bin_end  Eye Right  Eye Left  Hand Right  Hand Left  mean  shuffle_mean  shuffle_95
      -320     -160       2.16      2.24        2.30       2.87  2.39          2.09        2.42
      -160        0       1.97      1.85        2.59       2.73  2.29          2.05        2.32
         0      160       2.58      2.77        2.78       2.58  2.68          2.14        2.44
       160      320       3.51      4.36        3.75       4.73  4.09          2.60        3.01
       320      480       8.92      7.84        2.91       4.19  5.97          2.38        2.78
       480      640       9.14      9.49        3.94       4.88  6.86          2.45        2.91
       640      800       4.59      5.97        4.39       4.61  4.89          2.33        2.60
       800      960       6.81      9.73        4.24       3.49  6.07          2.45        2.80
       960     1120       5.15      4.83        3.82       4.58  4.59          2.58        2.97
      1120     1280       4.41      4.46        3.44       2.61  3.73          2.41        2.76
      1280     1440       1.88      2.70        2.15       3.04  2.45          2.22        2.61

Largest mean RMS: 6.86 spikes/s in the bin 480 to 640 ms after Ready; 95th percentile of the shuffled maximum: 3.11
Largest bin per dataset: {'Eye Right': '480-640', 'Eye Left': '800-960', 'Hand Right': '640-800', 'Hand Left': '480-640'}
Used trials at ts = 800 ms per dataset (Short, Long): {'Eye Right': (25, 38), 'Eye Left': (22, 36), 'Hand Right': (22, 19), 'Hand Left': (24, 14)}
In [16]:
plt.figure(figsize=(6, 3.2))
for n, v in per_ds.items():
    plt.plot(centres, v, color="0.6", lw=1)
plt.plot(centres, mean_rms, "k", lw=2, label="mean of datasets")
plt.fill_between(centres, np.percentile(nulls, 5, axis=0), np.percentile(nulls, 95, axis=0), color="tab:green", alpha=.2, label="shuffle 5-95%")
plt.axvspan(480, 800, color="tab:red", alpha=.1, label="Short support")
plt.axvline(0, color="k", ls=":")
plt.axvline(800, color="k", ls="--")
plt.xlabel("time from Ready (ms); Set at 800")
plt.ylabel("RMS rate difference (spikes/s)")
plt.legend(fontsize=7)
plt.show()
print("Plotted values: %.2f to %.2f spikes/s; all within the axes." % (min(v.min() for v in per_ds.values()), max(v.max() for v in per_ds.values())))
No description has been provided for this image
Plotted values: 1.85 to 9.73 spikes/s; all within the axes.
In [17]:
# Stability of the peak: resample trials with replacement within each dataset and prior, and
# find the bin of the largest mean RMS difference.
rng_dfr_boot = np.random.default_rng(8)
peaks = []
for _ in range(200):
    per = []
    for cnt, lab in cnts.values():
        i = np.concatenate([rng_dfr_boot.choice(np.flatnonzero(lab == v), (lab == v).sum()) for v in (True, False)])
        per.append(rms_dfr(cnt[i], lab[i]))
    peaks.append(int(np.argmax(np.mean(per, axis=0))))
peaks = pd.Series(peaks).map(lambda k: f"{edges[k]} to {edges[k + 1]}")
print("Bin of the largest mean RMS over 200 bootstrap samples of trials (ms after Ready):")
print(peaks.value_counts().to_string())
in_support = peaks.isin(["480 to 640", "640 to 800"]).mean()
print(f"Fraction of samples with the peak between 480 and 800 ms: {in_support:.2f}")
Bin of the largest mean RMS over 200 bootstrap samples of trials (ms after Ready):
480 to 640    192
800 to 960      8
Fraction of samples with the peak between 480 and 800 ms: 0.96

Result: reproduced. At ts = 800 ms, the RMS over units of the Short minus Long rate difference, averaged over datasets, is largest in the bin 480 to 640 ms after Ready (6.86 spikes/s), within the support of the Short prior, and above the 95th percentile of the shuffled maximum (3.11). Before Ready it is at the shuffle level (2.39 and 2.29 against 95th percentiles of 2.42 and 2.32). Per dataset the largest bin is 480-640 (2 datasets), 640-800 and 800-960 ms; the mean is also high just after Set (6.07 in 800 to 960 ms). Over 200 bootstrap samples of trials the peak is in 480 to 640 ms in 192 and in 800 to 960 ms in 8, so it lies within 480 to 800 ms in 96% of samples.

Population trajectories¶

pca-dimensionality¶

Population activity is low dimensional; 3 to 4 principal components in the estimation epoch and 5 to 10 in the production epoch explain about 75% of the variance (Figure S3).

The PCA fitted here is used by the claims that follow.

In [18]:
EST, PROD, rows = {}, {}, []
for e, th, name in DS:
    g = good[(good.is_eye == e) & (good.theta == th)]
    est = {}
    for short in [True, False]:
        gg = g[g.is_short == short]
        rate, ends, n = rsg.estimation_rates(spikes, units, gg, max(rsg.prior_ts(short)))
        est[short] = {"rate": rate, "t": ends, "n": n}
    pca_e = rsg.PCA([est[True]["rate"], est[False]["rate"]])
    prod = {}
    for short in [True, False]:
        for ts in rsg.prior_ts(short):
            gg = g[(g.is_short == short) & (g.ts == ts)]
            prod[(short, ts)] = {"rate": rsg.production_rates(spikes, units, gg), "min_tp": gg.tp.min(),
                                 "mean_tp": gg.tp.mean(), "n": len(gg)}
    pca_p = rsg.PCA([v["rate"] for v in prod.values()])
    EST[name] = {"pca": pca_e, **est}
    PROD[name] = {"pca": pca_p, "traj": prod}
    cum_e, cum_p = np.cumsum(pca_e.explained), np.cumsum(pca_p.explained)
    rows.append({"dataset": name, "est_PCs_75": pca_e.n, "prod_PCs_75": pca_p.n,
                 "est_PCs_70": int(np.searchsorted(cum_e, .7) + 1), "est_PCs_80": int(np.searchsorted(cum_e, .8) + 1),
                 "prod_PCs_70": int(np.searchsorted(cum_p, .7) + 1), "prod_PCs_80": int(np.searchsorted(cum_p, .8) + 1),
                 "est_var_3PC": cum_e[2], "prod_var_5PC": cum_p[4]})
dims = pd.DataFrame(rows)
print(dims.round(3).to_string(index=False))
n_ok = dims.est_PCs_75.between(3, 4).sum() + dims.prod_PCs_75.between(5, 11).sum()
print(f"\nIn range (estimation 3 or 4, production 5 to 11): {n_ok} of 8")
print("Shortest tp per ts, used to cut production trajectories (ms):",
      {f"{'S' if k[0] else 'L'}{k[1]}": round(min(PROD[n]['traj'][k]['min_tp'] for n in PROD)) for k in PROD[DS[0][2]]['traj']})
   dataset  est_PCs_75  prod_PCs_75  est_PCs_70  est_PCs_80  prod_PCs_70  prod_PCs_80  est_var_3PC  prod_var_5PC
 Eye Right           2            2           2           2            2            2        0.902         0.916
  Eye Left           2            2           2           2            2            2        0.899         0.911
Hand Right           2            5           2           3            4            6        0.869         0.771
 Hand Left           2            4           2           3            3            5        0.863         0.810

In range (estimation 3 or 4, production 5 to 11): 1 of 8
Shortest tp per ts, used to cut production trajectories (ms): {'S480': 407, 'S560': 455, 'S640': 447, 'S720': 546, 'S800': 602, 'L800': 637, 'L900': 711, 'L1000': 736, 'L1100': 794, 'L1200': 797}
In [19]:
# Variants: rates normalised per unit before PCA (soft normalisation, and z-scoring), with the
# components reaching 75% of the variance. Estimation and production scales are taken from the
# trajectories that enter each PCA.
rows = []
for e, th, name in DS:
    E, P = EST[name], PROD[name]
    est_m = [E[True]["rate"], E[False]["rate"]]
    prod_m = [v["rate"] for v in P["traj"].values()]
    for how in ["soft", "z"]:
        se, sp = rsg.unit_scale(est_m, how), rsg.unit_scale(prod_m, how)
        pe = rsg.PCA([m / se for m in est_m])
        pp = rsg.PCA([m / sp for m in prod_m])
        E[f"pca_{how}"], E[f"scale_{how}"] = pe, se
        rows.append({"dataset": name, "normalisation": how, "est_PCs_75": pe.n, "prod_PCs_75": pp.n})
dims_norm = pd.DataFrame(rows)
print(dims_norm.to_string(index=False))
for how, g in dims_norm.groupby("normalisation"):
    print(f"{how}: in range (estimation 3 or 4, production 5 to 11) {g.est_PCs_75.between(3, 4).sum() + g.prod_PCs_75.between(5, 11).sum()} of 8")
   dataset normalisation  est_PCs_75  prod_PCs_75
 Eye Right          soft           3            4
 Eye Right             z           3            6
  Eye Left          soft           3            4
  Eye Left             z           3            6
Hand Right          soft           3            7
Hand Right             z           4           10
 Hand Left          soft           4            7
 Hand Left             z           4            9
soft: in range (estimation 3 or 4, production 5 to 11) 6 of 8
z: in range (estimation 3 or 4, production 5 to 11) 8 of 8

Result: not reproduced. Two components reach 75% of the variance in the estimation epoch in all 4 datasets (3 components explain 86 to 90%), and 2, 2, 5 and 4 in the production epoch, so 1 of the 8 values falls in the paper's ranges. Activity here is lower dimensional than the paper reports. This session has 48 units; the paper's PCA pools up to 536 units recorded over 17 sessions, which can add dimensions. The count depends on normalisation, which the Methods do not mention. With rates z-scored per unit, 3 or 4 components reach 75% in the estimation epoch and 6 to 10 in the production epoch, so all 8 values are in range; with soft normalisation (range plus 5 spikes/s) 6 of 8 are (production 4 in both eye datasets). The outcome is judged on unnormalised rates, as the claims file fixed before the analysis.

encoding-r2¶

Projections of estimation-epoch neural states onto the encoding axis match the Bayesian estimates from the model fitted to the behaviour (R2 = 0.993 for the Short prior, 0.996 for the Long prior, monkey H, Hand Left; Figure 4C).

In [20]:
rng_axes = np.random.default_rng(4)
PROJ, rows = {}, []
for e, th, name in DS:
    E = EST[name]
    for short in [True, False]:
        lo, hi = min(rsg.prior_ts(short)), max(rsg.prior_ts(short))
        X = E["pca"](E[short]["rate"])
        t = E[short]["t"]
        u = rsg.encoding_axis(X, t, lo, hi)
        sel = (t >= lo - 1e-6) & (t <= hi + 1e-6)
        xu, tt, w = X[sel] @ u, t[sel], E[short]["n"][sel]
        te = models[name].moments(tt, short)[0]
        _, _, r2_b, rmse_b = rsg.weighted_fit(te, xu, w)
        _, slope_ts, r2_l, rmse_l = rsg.weighted_fit(tt, xu, w)
        rand = []
        for _ in range(1000):
            v = rng_axes.standard_normal(X.shape[1])
            rand.append(rsg.weighted_fit(te, X[sel] @ (v / np.linalg.norm(v)), w)[2])
        rand = np.array(rand)
        PROJ[(name, short)] = {"t": tt, "xu": xu, "w": w, "te": te, "u": u, "X": X, "tall": t}
        rows.append({"dataset": name, "prior": "Short" if short else "Long", "points": int(sel.sum()), "PCs": X.shape[1],
                     "R2_bayes": r2_b, "RMSE_bayes": rmse_b, "R2_linear": r2_l, "RMSE_linear": rmse_l,
                     "slope_ts": slope_ts, "random_R2_median": np.median(rand),
                     "random_R2_95": np.percentile(rand, 95), "frac_random_above": np.mean(rand >= r2_b)})
enc = pd.DataFrame(rows)
print(enc.round(4).to_string(index=False))
hl = enc[enc.dataset == "Hand Left"].set_index("prior").R2_bayes
mean_r2 = enc.groupby("prior").R2_bayes.mean()
print(f"\nReading (a), Hand Left: Short R2 = {hl['Short']:.4f}, Long R2 = {hl['Long']:.4f}")
print(f"Reading (b), mean of 4 datasets: Short R2 = {mean_r2['Short']:.4f}, Long R2 = {mean_r2['Long']:.4f}")
print("Tolerance: R2 >= 0.894 (Short) and >= 0.896 (Long)")
   dataset prior  points  PCs  R2_bayes  RMSE_bayes  R2_linear  RMSE_linear  slope_ts  random_R2_median  random_R2_95  frac_random_above
 Eye Right Short      17    2    0.9581      3.1506     0.9533       3.3274    0.1899            0.9433        0.9965              0.396
 Eye Right  Long      21    2    0.9766      2.5824     0.9717       2.7761    0.1203            0.9732        0.9780              0.286
  Eye Left Short      17    2    0.9097      6.2773     0.8975       6.7255    0.2288            0.9053        0.9793              0.475
  Eye Left  Long      21    2    0.9889      1.5583     0.9798       2.1310    0.1249            0.9722        0.9940              0.265
Hand Right Short      17    2    0.9699      0.9907     0.9723       0.8045    0.0497            0.9135        0.9896              0.284
Hand Right  Long      21    2    0.9939      0.4332     0.9904       0.5557    0.0484            0.9833        0.9938              0.046
 Hand Left Short      17    2    0.9982      0.3730     0.9975       0.4362    0.0803            0.8963        0.9975              0.025
 Hand Left  Long      21    2    0.9960      0.2070     0.9936       0.2521    0.0272            0.7347        0.9942              0.028

Reading (a), Hand Left: Short R2 = 0.9982, Long R2 = 0.9960
Reading (b), mean of 4 datasets: Short R2 = 0.9590, Long R2 = 0.9888
Tolerance: R2 >= 0.894 (Short) and >= 0.896 (Long)
In [21]:
fig, axes = plt.subplots(1, 4, figsize=(14, 3.2))
for ax, (e, th, name) in zip(axes, DS):
    for short, col in [(True, "tab:red"), (False, "tab:blue")]:
        p_ = PROJ[(name, short)]
        a, b, _, _ = rsg.weighted_fit(p_["te"], p_["xu"], p_["w"])
        ax.plot(p_["t"], (p_["xu"] - a) / b, "o", color=col, ms=3)   # projections mapped onto te by the fit
        ax.plot(p_["t"], p_["te"], "-", color=col)
    ax.plot([450, 1250], [450, 1250], "k:", lw=1)
    ax.set_title(name)
    ax.set_xlabel("time from Ready = ts (ms)")
axes[0].set_ylabel("te (line) and mapped Xu (dots), ms")
plt.tight_layout()
plt.show()
vals = np.concatenate([(PROJ[k]["xu"] - rsg.weighted_fit(PROJ[k]["te"], PROJ[k]["xu"], PROJ[k]["w"])[0]) / rsg.weighted_fit(PROJ[k]["te"], PROJ[k]["xu"], PROJ[k]["w"])[1] for k in PROJ])
print("Mapped projections: %.0f to %.0f ms; all within the axes." % (vals.min(), vals.max()))
No description has been provided for this image
Mapped projections: 515 to 1159 ms; all within the axes.
In [22]:
# Variant: the encoding axis in the PC spaces of normalised rates (pca-dimensionality).
rng_norm = np.random.default_rng(9)
enc_rows = []
for e, th, name in DS:
    E = EST[name]
    for how in ["soft", "z"]:
        pe, se = E[f"pca_{how}"], E[f"scale_{how}"]
        for short in [True, False]:
            lo, hi = min(rsg.prior_ts(short)), max(rsg.prior_ts(short))
            X, t = pe(E[short]["rate"] / se), E[short]["t"]
            u = rsg.encoding_axis(X, t, lo, hi)
            sel = (t >= lo - 1e-6) & (t <= hi + 1e-6)
            w, te = E[short]["n"][sel], PROJ[(name, short)]["te"]
            r2 = rsg.weighted_fit(te, X[sel] @ u, w)[2]
            rand = []
            for _ in range(1000):
                v = rng_norm.standard_normal(X.shape[1])
                rand.append(rsg.weighted_fit(te, X[sel] @ (v / np.linalg.norm(v)), w)[2])
            enc_rows.append({"dataset": name, "prior": "Short" if short else "Long", "normalisation": how, "PCs": X.shape[1],
                             "R2_bayes": r2, "random_R2_median": np.median(rand), "frac_random_above": np.mean(np.array(rand) >= r2)})
enc_norm = pd.DataFrame(enc_rows)
print("Encoding axis with normalised rates (75% of variance):")
print(enc_norm.round(4).to_string(index=False))
for how, g in enc_norm.groupby("normalisation"):
    hl = g[g.dataset == "Hand Left"].set_index("prior").R2_bayes
    print(f"{how}: Hand Left R2 Short {hl['Short']:.4f}, Long {hl['Long']:.4f}; mean of 4 Short {g[g.prior == 'Short'].R2_bayes.mean():.4f}, "
          f"Long {g[g.prior == 'Long'].R2_bayes.mean():.4f}; above 95% of random axes in {(g.frac_random_above < 0.05).sum()} of 8")
Encoding axis with normalised rates (75% of variance):
   dataset prior normalisation  PCs  R2_bayes  random_R2_median  frac_random_above
 Eye Right Short          soft    3    0.9656            0.9105              0.296
 Eye Right  Long          soft    3    0.9815            0.9207              0.144
 Eye Right Short             z    3    0.9558            0.8894              0.297
 Eye Right  Long             z    3    0.9775            0.8829              0.108
  Eye Left Short          soft    3    0.9428            0.9067              0.372
  Eye Left  Long          soft    3    0.9904            0.9230              0.049
  Eye Left Short             z    3    0.9402            0.9089              0.395
  Eye Left  Long             z    3    0.9858            0.8762              0.076
Hand Right Short          soft    3    0.9759            0.8432              0.108
Hand Right  Long          soft    3    0.9967            0.8978              0.031
Hand Right Short             z    4    0.9731            0.7605              0.092
Hand Right  Long             z    4    0.9514            0.8029              0.175
 Hand Left Short          soft    4    0.9842            0.8982              0.185
 Hand Left  Long          soft    4    0.9749            0.7946              0.074
 Hand Left Short             z    4    0.9900            0.8990              0.142
 Hand Left  Long             z    4    0.9596            0.7884              0.093
soft: Hand Left R2 Short 0.9842, Long 0.9749; mean of 4 Short 0.9671, Long 0.9859; above 95% of random axes in 2 of 8
z: Hand Left R2 Short 0.9900, Long 0.9596; mean of 4 Short 0.9648, Long 0.9686; above 95% of random axes in 0 of 8

Result: reproduced. Under reading (a), Hand Left, R2 = 0.998 (Short) and 0.996 (Long); under reading (b), the mean of the 4 datasets, 0.959 and 0.989. Both readings are within tolerance. The estimation trajectories here have only 2 PCs, and projections on random unit vectors in that space also fit te well (median R2 0.73 to 0.98 by dataset and prior). The R2 on u exceeds that of 95% of random axes in 3 of 8 dataset-priors (Hand Right Long, both Hand Left priors), so in this session the fit is specific to u only for those three. With normalised rates (3 or 4 PCs) R2 stays above 0.94 in every dataset-prior (Hand Left 0.984 and 0.975 with soft normalisation, 0.990 and 0.960 z-scored), but the R2 on u exceeds that of 95% of random axes in 2 of 8 dataset-priors with soft normalisation and in 0 of 8 with z-scoring. The match to te is reproduced; its specificity to u is not shown in this session.

encoding-bayes-beats-linear¶

Neural projections are better explained by the Bayesian model than by a linear function of ts with the same number of parameters, for both priors (signed-rank test on RMSE, p = 0.008 for each prior; Figure 4D top).

In [23]:
enc["dRMSE_bayes_minus_linear"] = enc.RMSE_bayes - enc.RMSE_linear
print(enc[["dataset", "prior", "RMSE_bayes", "RMSE_linear", "dRMSE_bayes_minus_linear"]].round(3).to_string(index=False))
for prior, g in enc.groupby("prior", sort=False):
    print(f"{prior}: Bayesian lower in {(g.dRMSE_bayes_minus_linear < 0).sum()} of 4; "
          f"signed-rank p = {stats.wilcoxon(g.RMSE_bayes, g.RMSE_linear).pvalue:.3f} (smallest possible with 4: 0.125)")
   dataset prior  RMSE_bayes  RMSE_linear  dRMSE_bayes_minus_linear
 Eye Right Short       3.151        3.327                    -0.177
 Eye Right  Long       2.582        2.776                    -0.194
  Eye Left Short       6.277        6.726                    -0.448
  Eye Left  Long       1.558        2.131                    -0.573
Hand Right Short       0.991        0.805                     0.186
Hand Right  Long       0.433        0.556                    -0.122
 Hand Left Short       0.373        0.436                    -0.063
 Hand Left  Long       0.207        0.252                    -0.045
Short: Bayesian lower in 3 of 4; signed-rank p = 0.625 (smallest possible with 4: 0.125)
Long: Bayesian lower in 4 of 4; signed-rank p = 0.125 (smallest possible with 4: 0.125)
In [24]:
# Variant: RMSE weighted by the number of trials in each bin, as in the fit.
def wrmse(x, y, w):
    a, b, _, _ = rsg.weighted_fit(x, y, w)
    return float(np.sqrt(np.sum(w * (y - a - b * x) ** 2) / np.sum(w)))


enc["dWRMSE"] = [wrmse(PROJ[(r.dataset, r.prior == "Short")]["te"], PROJ[(r.dataset, r.prior == "Short")]["xu"], PROJ[(r.dataset, r.prior == "Short")]["w"])
                 - wrmse(PROJ[(r.dataset, r.prior == "Short")]["t"], PROJ[(r.dataset, r.prior == "Short")]["xu"], PROJ[(r.dataset, r.prior == "Short")]["w"])
                 for r in enc.itertuples()]
for prior, g in enc.groupby("prior", sort=False):
    print(f"Weighted RMSE, {prior}: Bayesian lower in {(g.dWRMSE < 0).sum()} of 4; differences " + ", ".join(f"{x:+.3f}" for x in g.dWRMSE))
Weighted RMSE, Short: Bayesian lower in 3 of 4; differences -0.192, -0.424, +0.031, -0.052
Weighted RMSE, Long: Bayesian lower in 4 of 4; differences -0.204, -0.509, -0.103, -0.050

Result: partially reproduced. The Bayesian model has the lower RMSE in all 4 datasets for the Long prior and in 3 of 4 for the Short prior (Hand Right Short is the exception, 0.99 against 0.80). The differences are small: where the Bayesian model wins, it is better by 0.045 to 0.573 in units of Xu (spikes/s), and in Hand Right Short it is worse by 0.186. With the RMSE weighted by trial counts, as in the fit, the counts are the same (Long 4 of 4, Short 3 of 4, Hand Right Short +0.031). In the matched control period of the Long prior (short-in-long-control) the Bayesian model is also better in 3 of 4 datasets, so in this session its advantage is not specific to the support of the prior.

short-in-long-control¶

In the Long prior during the period matched to the support of the Short prior ("Short In Long"), the Bayesian model is not better than the linear one (signed-rank test, p = 0.74).

In [25]:
rows = []
for e, th, name in DS:
    p_ = PROJ[(name, False)]
    X, t = p_["X"], p_["tall"]
    u = rsg.encoding_axis(X, t, 480, 800)
    sel = (t >= 480 - 1e-6) & (t <= 800 + 1e-6)
    xu, tt = X[sel] @ u, t[sel]
    w = EST[name][False]["n"][sel]
    te = models[name].moments(tt, True)[0]
    rows.append({"dataset": name, "RMSE_bayes": rsg.weighted_fit(te, xu, w)[3], "RMSE_linear": rsg.weighted_fit(tt, xu, w)[3],
                 "R2_bayes": rsg.weighted_fit(te, xu, w)[2], "R2_linear": rsg.weighted_fit(tt, xu, w)[2]})
sil = pd.DataFrame(rows)
sil["dRMSE"] = sil.RMSE_bayes - sil.RMSE_linear
print(sil.round(4).to_string(index=False))
p_sil = stats.wilcoxon(sil.RMSE_bayes, sil.RMSE_linear).pvalue
print(f"\nShort In Long: Bayesian lower in {(sil.dRMSE < 0).sum()} of 4; median dRMSE {sil.dRMSE.median():.3f}; "
      f"signed-rank p = {p_sil:.3f} (smallest possible with 4 datasets: 0.125)")
sh = enc[enc.prior == "Short"]
print(f"For comparison, Short prior: median dRMSE {sh.dRMSE_bayes_minus_linear.median():.3f}; "
      f"relative dRMSE (dRMSE / RMSE_linear) Short In Long {np.median(sil.dRMSE / sil.RMSE_linear):.3f}, "
      f"Short {np.median(sh.dRMSE_bayes_minus_linear / sh.RMSE_linear):.3f}")
   dataset  RMSE_bayes  RMSE_linear  R2_bayes  R2_linear   dRMSE
 Eye Right      1.1392       1.2534    0.9931     0.9916 -0.1142
  Eye Left      1.4967       1.7048    0.9886     0.9853 -0.2082
Hand Right      1.0873       1.1897    0.8714     0.8461 -0.1024
 Hand Left      0.2877       0.2268    0.9952     0.9970  0.0609

Short In Long: Bayesian lower in 3 of 4; median dRMSE -0.108; signed-rank p = 0.250 (smallest possible with 4 datasets: 0.125)
For comparison, Short prior: median dRMSE -0.120; relative dRMSE (dRMSE / RMSE_linear) Short In Long -0.089, Short -0.060

Result: reproduced by the paper's test, which cannot detect an effect here. The signed-rank test over the 4 datasets gives p = 0.25; with 4 datasets the smallest attainable p is 0.125, so it could not have found an effect. The effect sizes do not show the contrast the paper describes: in the control period the Bayesian model is better in 3 of 4 datasets, with a median relative RMSE difference of -0.089, at least as large as in the Short prior itself (-0.060).

neural-long-more-bias¶

Neural projections show more bias for the Long prior than for the Short prior, as a smaller regression slope of Xu on ts (signed-rank test, p = 0.008; Figure 4D bottom).

In [26]:
sl = enc.pivot(index="dataset", columns="prior", values="slope_ts")
sl["long_below_short"] = sl.Long < sl.Short
print("Slope of Xu on ts (spikes/s per ms, shared PC space of each dataset):")
print(sl.round(4).to_string())
print(f"\nLong below Short in {int(sl.long_below_short.sum())} of 4 datasets; ratio Long / Short: "
      + ", ".join(f"{r:.2f}" for r in sl.Long / sl.Short))
Slope of Xu on ts (spikes/s per ms, shared PC space of each dataset):
prior         Long   Short  long_below_short
dataset                                     
Eye Left    0.1249  0.2288              True
Eye Right   0.1203  0.1899              True
Hand Left   0.0272  0.0803              True
Hand Right  0.0484  0.0497              True

Long below Short in 4 of 4 datasets; ratio Long / Short: 0.55, 0.63, 0.34, 0.97

Result: reproduced. The slope of Xu on ts is smaller for the Long prior in all 4 datasets (ratios Long / Short 0.55, 0.63, 0.34 and 0.97); the Hand Right difference is small.

speed-vs-projection¶

Larger projections on the encoding axis at Set are followed by slower neural trajectories in the production epoch (Pearson correlation, rho Short = -0.74, rho Long = -0.51; Figure 4E).

In [27]:
rows = []
for e, th, name in DS:
    P = PROD[name]
    for (short, ts), v in P["traj"].items():
        states = P["pca"](v["rate"])
        p_ = PROJ[(name, short)]
        rows.append({"dataset": name, "prior": "Short" if short else "Long", "ts": ts,
                     "xu_at_set": p_["xu"][np.argmin(abs(p_["t"] - ts))], "speed": rsg.step_speed(states),
                     "duration_ms": len(states) * rsg.BIN_MS, "mean_tp": v["mean_tp"], "trials": v["n"]})
spd = pd.DataFrame(rows)
spd["speed_per_tp"] = spd.speed / spd.mean_tp
print(spd.round(3).to_string(index=False))
for prior in ["Short", "Long"]:
    g = spd[spd.prior == prior]
    r, p = stats.pearsonr(g.xu_at_set, g.speed)
    rb, pb = stats.pearsonr(g.xu_at_set, g.speed_per_tp)
    z = g.groupby("dataset").xu_at_set.transform(lambda x: (x - x.mean()) / x.std())
    rz, pz = stats.pearsonr(z, g.speed)
    within = [stats.pearsonr(h.xu_at_set, h.speed)[0] for _, h in g.groupby("dataset")]
    print(f"{prior}: rho = {r:.3f} (p = {p:.2g}); variant speed / mean tp: rho = {rb:.3f} (p = {pb:.2g}); "
          f"Xu z-scored per dataset: rho = {rz:.3f} (p = {pz:.2g}); within datasets: " + ", ".join(f"{x:.2f}" for x in within))
   dataset prior   ts  xu_at_set  speed  duration_ms  mean_tp  trials  speed_per_tp
 Eye Right Short  480    -41.591  7.926          400  496.868      24         0.016
 Eye Right Short  560    -37.181  7.723          440  543.739      27         0.014
 Eye Right Short  640    -19.017  6.358          500  630.550      31         0.010
 Eye Right Short  720      1.280  5.299          640  752.037      22         0.007
 Eye Right Short  800     13.480  4.379          720  836.602      25         0.005
 Eye Right  Long  800    -15.897  5.908          700  844.335      38         0.007
 Eye Right  Long  900     -7.900  4.806          760  922.279      34         0.005
 Eye Right  Long 1000      8.071  4.158          840 1002.392      23         0.004
 Eye Right  Long 1100     19.829  3.902          920 1156.215      25         0.003
 Eye Right  Long 1200     23.605  3.392          880 1176.536      27         0.003
  Eye Left Short  480    -44.640  9.128          460  539.534      21         0.017
  Eye Left Short  560    -44.994  7.389          460  579.976      28         0.013
  Eye Left Short  640    -23.645  6.881          540  666.884      17         0.010
  Eye Left Short  720      8.176  5.069          580  772.095      17         0.007
  Eye Left Short  800     16.639  5.195          700  864.015      22         0.006
  Eye Left  Long  800    -18.508  6.807          620  821.290      36         0.008
  Eye Left  Long  900    -10.591  5.139          740  916.020      29         0.006
  Eye Left  Long 1000      2.436  4.471          760 1006.664      30         0.004
  Eye Left  Long 1100     19.204  4.111          840 1094.700      29         0.004
  Eye Left  Long 1200     24.193  3.691          960 1155.967      31         0.003
Hand Right Short  480      5.492  6.303          400  560.489      21         0.011
Hand Right Short  560      7.982  5.331          460  593.034      35         0.009
Hand Right Short  640     12.336  5.168          440  661.104      14         0.008
Hand Right Short  720     16.562  4.196          540  754.223      21         0.006
Hand Right Short  800     22.484  3.855          660  817.430      22         0.005
Hand Right  Long  800      3.034  4.939          740  917.209      19         0.005
Hand Right  Long  900      7.330  4.211          820  969.975      17         0.004
Hand Right  Long 1000     13.597  3.621          900 1063.784      30         0.003
Hand Right  Long 1100     16.990  3.146         1000 1130.492      21         0.003
Hand Right  Long 1200     21.276  3.288          980 1151.263      25         0.003
 Hand Left Short  480     -2.382  6.056          420  548.726      33         0.011
 Hand Left Short  560      3.404  5.269          440  594.515      21         0.009
 Hand Left Short  640      9.793  4.758          520  661.548      25         0.007
 Hand Left Short  720     17.029  3.769          560  685.139      31         0.006
 Hand Left Short  800     21.626  3.645          600  751.458      24         0.005
 Hand Left  Long  800      0.049  4.821          680  808.309      14         0.006
 Hand Left  Long  900      2.108  3.793          700  872.599      24         0.004
 Hand Left  Long 1000      5.146  3.954          720  905.788      16         0.004
 Hand Left  Long 1100      7.728  3.817          780  986.246      16         0.004
 Hand Left  Long 1200      9.737  3.683          780 1003.612      18         0.004
Short: rho = -0.941 (p = 6.2e-10); variant speed / mean tp: rho = -0.917 (p = 1.4e-08); Xu z-scored per dataset: rho = -0.759 (p = 0.00011); within datasets: -0.92, -1.00, -0.99, -0.96
Long: rho = -0.878 (p = 3.7e-07); variant speed / mean tp: rho = -0.923 (p = 6.5e-09); Xu z-scored per dataset: rho = -0.779 (p = 5.3e-05); within datasets: -0.92, -0.96, -0.75, -0.95
In [28]:
# Both quantities depend on ts. Variant: residuals of each on ts (linear, within dataset and
# prior), pooled, so that only the variation not explained by ts is correlated.
def resid_on_ts(d, col):
    return d.groupby("dataset")[[col, "ts"]].apply(
        lambda h: pd.Series(h[col] - np.polyval(np.polyfit(h.ts, h[col], 1), h.ts), index=h.index)).droplevel(0)


for prior in ["Short", "Long"]:
    g = spd[spd.prior == prior]
    r, p = stats.pearsonr(resid_on_ts(g, "xu_at_set").loc[g.index], resid_on_ts(g, "speed").loc[g.index])
    print(f"{prior}: rho of residuals on ts = {r:.3f} (nominal p = {p:.2g}, not adjusted for the 8 fitted lines)")
Short: rho of residuals on ts = -0.195 (nominal p = 0.41, not adjusted for the 8 fitted lines)
Long: rho of residuals on ts = 0.069 (nominal p = 0.77, not adjusted for the 8 fitted lines)

Result: partially reproduced. Larger projections at Set are followed by slower production trajectories in both priors, more strongly than the paper reports: rho = -0.941 (Short, p = 6.2e-10) and -0.878 (Long, p = 3.7e-07), outside the tolerances around -0.74 and -0.51. Within each dataset the correlations are -0.75 to -1.00, and with Xu z-scored per dataset they are -0.759 and -0.779. Both quantities change with ts. With the residuals of each on ts within each dataset and prior, the correlation is -0.195 for Short and +0.069 for Long (nominal p = 0.41 and 0.77), so across conditions the relation is carried by their common dependence on ts. The paper's control for this, z-scoring within ts, applies to single-trial GPFA states of the production epoch, which are not computed here.

initial-speed-biased¶

The initial speed of production trajectories (Set to Set + 400 ms) is biased toward the middle of the prior; its dependence on ts is shallower than the unbiased profile proportional to 1/ts (signed-rank test, p < 1e-3 over conditions and animals; Figure 4F).

In [29]:
rows, dropped = [], 0
for e, th, name in DS:
    g = good[(good.is_eye == e) & (good.theta == th)]
    for short in [True, False]:
        ts_v = np.array(rsg.prior_ts(short), float)
        sp = []
        for ts in ts_v:
            gg = g[(g.is_short == short) & (g.ts == ts)]
            dropped += int((gg.tp < 400).sum())
            gg = gg[gg.tp >= 400]
            sp.append(rsg.step_speed(PROD[name]["pca"](rsg.production_rates(spikes, units, gg, 400))))
        sp = np.array(sp)
        unbiased = sp[2] * ts_v[2] / ts_v
        rows.append({"dataset": name, "prior": "Short" if short else "Long", **{f"speed_{int(t)}": s for t, s in zip(ts_v, sp)},
                     "slope_measured": np.polyfit(ts_v, sp, 1)[0], "slope_unbiased": np.polyfit(ts_v, unbiased, 1)[0]})
ini = pd.DataFrame(rows)
ini["measured_minus_unbiased"] = ini.slope_measured - ini.slope_unbiased
print(ini.round(5).to_string(index=False))
w = stats.wilcoxon(ini.slope_measured, ini.slope_unbiased)
print(f"\nMeasured shallower than unbiased in {(ini.measured_minus_unbiased > 0).sum()} of 8; "
      f"two-sided signed-rank p = {w.pvalue:.4f}; trials dropped for tp < 400 ms: {dropped}")
print("Measured slope negative in", int((ini.slope_measured < 0).sum()), "of 8")
   dataset prior  speed_480  speed_560  speed_640  speed_720  speed_800  slope_measured  slope_unbiased  speed_900  speed_1000  speed_1100  speed_1200  measured_minus_unbiased
 Eye Right Short    7.92627    7.76494    6.71340    6.16218    4.80529        -0.00981        -0.01108        NaN         NaN         NaN         NaN                  0.00128
 Eye Right  Long        NaN        NaN        NaN        NaN    8.25311        -0.00778        -0.00670    7.05230     6.46757     5.90688     4.93791                 -0.00108
  Eye Left Short    9.18311    7.59299    7.40464    5.96920    6.25443        -0.00935        -0.01222        NaN         NaN         NaN         NaN                  0.00287
  Eye Left  Long        NaN        NaN        NaN        NaN    8.95276        -0.00563        -0.00662    7.27091     6.39416     6.48285     6.53402                  0.00099
Hand Right Short    6.30300    5.61327    5.57478    4.99242    4.80451        -0.00452        -0.00920        NaN         NaN         NaN         NaN                  0.00468
Hand Right  Long        NaN        NaN        NaN        NaN    7.01812        -0.00482        -0.00565    5.86106     5.45497     4.80427     5.13790                  0.00083
 Hand Left Short    6.37733    5.33394    5.15677    4.12045    4.10442        -0.00720        -0.00851        NaN         NaN         NaN         NaN                  0.00131
 Hand Left  Long        NaN        NaN        NaN        NaN    6.19997        -0.00215        -0.00533    4.86244     5.14397     5.14086     4.98508                  0.00317

Measured shallower than unbiased in 7 of 8; two-sided signed-rank p = 0.0391; trials dropped for tp < 400 ms: 0
Measured slope negative in 8 of 8

Result: reproduced. The initial speed falls with ts in all 8 dataset-priors, and its slope is shallower than the unbiased 1/ts profile in 7 of 8 (two-sided signed-rank p = 0.039). Eye Right, Long is the exception. No trial was dropped for tp < 400 ms.

speed-predicts-tp¶

The speed of neural trajectories in the production epoch predicts the mean tp (Pearson correlation, rho Short = -0.58, rho Long = -0.52; Figure 4G).

In [30]:
for prior in ["Short", "Long"]:
    g = spd[spd.prior == prior]
    r, p = stats.pearsonr(g.speed, g.mean_tp)
    rb, pb = stats.pearsonr(g.speed_per_tp, g.mean_tp)
    within = [stats.pearsonr(h.speed, h.mean_tp)[0] for _, h in g.groupby("dataset")]
    print(f"{prior}: rho(speed, mean tp) = {r:.3f} (p = {p:.2g}), 20 points; variant speed / mean tp: {rb:.3f} (p = {pb:.2g}); "
          "within datasets: " + ", ".join(f"{x:.2f}" for x in within))

# Variant: the Neural Latents Benchmark's measure, trial by trial within prior, effector and
# direction; single-trial rates smoothed as above and projected on the production PCs, from Set
# to the trial's own Go.
rows, trial_rows = [], []
for e, th, name in DS:
    g = good[(good.is_eye == e) & (good.theta == th)]
    for short in [True, False]:
        gg = g[g.is_short == short]
        c_ = rsg.counts(spikes, units, gg.set_time.to_numpy(), 0, 2400)
        sp = []
        for i, tp in enumerate(gg.tp.to_numpy()):
            nb = int(tp // rsg.BIN_MS)
            sp.append(rsg.step_speed(PROD[name]["pca"](rsg.smooth(c_[i, :nb] * 1000 / rsg.BIN_MS))))
        r, p = stats.pearsonr(sp, gg.tp)
        trial_rows += [{"dataset": name, "prior": "Short" if short else "Long", "ts": a, "tp": b, "speed": s_}
                       for a, b, s_ in zip(gg.ts, gg.tp, sp)]
        rows.append({"dataset": name, "prior": "Short" if short else "Long", "trials": len(gg), "r": r, "p": p})
nlb = pd.DataFrame(rows)
print("\nTrial-by-trial speed against tp (NLB-style):")
print(nlb.round(3).to_string(index=False))
print(f"Mean r over the 8 conditions: {nlb.r.mean():.3f}")
Short: rho(speed, mean tp) = -0.733 (p = 0.00024), 20 points; variant speed / mean tp: -0.861 (p = 1.1e-06); within datasets: -0.93, -0.99, -0.96, -0.96
Long: rho(speed, mean tp) = -0.744 (p = 0.00017), 20 points; variant speed / mean tp: -0.858 (p = 1.3e-06); within datasets: -0.96, -0.94, -0.80, -0.97
Trial-by-trial speed against tp (NLB-style):
   dataset prior  trials      r     p
 Eye Right Short     129 -0.740 0.000
 Eye Right  Long     147 -0.539 0.000
  Eye Left Short     105 -0.696 0.000
  Eye Left  Long     155 -0.578 0.000
Hand Right Short     113 -0.546 0.000
Hand Right  Long     112 -0.420 0.000
 Hand Left Short     134 -0.442 0.000
 Hand Left  Long      88 -0.195 0.069
Mean r over the 8 conditions: -0.519
In [31]:
for prior in ["Short", "Long"]:
    g = spd[spd.prior == prior]
    r, p = stats.pearsonr(resid_on_ts(g, "speed").loc[g.index], resid_on_ts(g, "mean_tp").loc[g.index])
    print(f"{prior}: rho of residuals on ts (speed, mean tp) = {r:.3f} (nominal p = {p:.2g})")

# Variant: the paper's control for co-dependence on ts (Methods), trial by trial; speed and tp
# z-scored within each dataset, prior and ts, then correlated.
tt_ = pd.DataFrame(trial_rows)
for col in ["speed", "tp"]:
    tt_[f"z_{col}"] = tt_.groupby(["dataset", "prior", "ts"])[col].transform(lambda x: (x - x.mean()) / x.std())
zr = tt_.groupby(["dataset", "prior"], sort=False).apply(lambda h: pd.Series(stats.pearsonr(h.z_speed, h.z_tp), index=["r", "p"]))
print("Trial by trial, z-scored within ts:")
print(zr.round(3).to_string())
for prior, g in tt_.groupby("prior", sort=False):
    r, p = stats.pearsonr(g.z_speed, g.z_tp)
    print(f"{prior}, pooled over datasets: r = {r:.3f} (p = {p:.2g}, {len(g)} trials); negative in "
          f"{int((zr.loc[(slice(None), prior), 'r'] < 0).sum())} of 4, p < 0.05 and negative in "
          f"{int(((zr.loc[(slice(None), prior), 'r'] < 0) & (zr.loc[(slice(None), prior), 'p'] < 0.05)).sum())}")

# Null for the trial-by-trial measure: units fire as Poisson processes at a constant rate (each
# unit's mean from Set to Go in the dataset and prior), each trial keeps its own duration tp, and
# speed is measured as above. Shorter trials average fewer bins, which alone could tie speed to tp.
rng_tt = np.random.default_rng(11)
null_r = {"Short": [], "Long": []}
for _ in range(20):
    sims = []
    for e, th, name in DS:
        g = good[(good.is_eye == e) & (good.theta == th)]
        for short in [True, False]:
            gg = g[g.is_short == short]
            nbs = (gg.tp.to_numpy() // rsg.BIN_MS).astype(int)
            c_ = rsg.counts(spikes, units, gg.set_time.to_numpy(), 0, 2400)
            lam = np.concatenate([c_[i, :n] for i, n in enumerate(nbs)]).mean(0)
            for ts, tp, n in zip(gg.ts, gg.tp, nbs):
                sim = rng_tt.poisson(lam, size=(n, len(units))).astype(float)
                sims.append({"dataset": name, "prior": "Short" if short else "Long", "ts": ts, "tp": tp,
                             "speed": rsg.step_speed(PROD[name]["pca"](rsg.smooth(sim * 1000 / rsg.BIN_MS)))})
    sims = pd.DataFrame(sims)
    for col in ["speed", "tp"]:
        sims[f"z_{col}"] = sims.groupby(["dataset", "prior", "ts"])[col].transform(lambda x: (x - x.mean()) / x.std())
    for prior, g in sims.groupby("prior"):
        null_r[prior].append(stats.pearsonr(g.z_speed, g.z_tp)[0])
for prior in ["Short", "Long"]:
    g = tt_[tt_.prior == prior]
    r = stats.pearsonr(g.z_speed, g.z_tp)[0]
    v = np.array(null_r[prior])
    print(f"Poisson null with matched durations, {prior}: null r mean {v.mean():.3f}, range {v.min():.3f} to {v.max():.3f} "
          f"(20 simulations); observed {r:.3f}, below every null value: {bool(r < v.min())}")
Short: rho of residuals on ts (speed, mean tp) = 0.084 (nominal p = 0.72)
Long: rho of residuals on ts (speed, mean tp) = -0.283 (nominal p = 0.23)
Trial by trial, z-scored within ts:
                      r      p
dataset    prior              
Eye Right  Short -0.437  0.000
           Long  -0.251  0.002
Eye Left   Short -0.377  0.000
           Long  -0.232  0.004
Hand Right Short -0.261  0.005
           Long  -0.173  0.068
Hand Left  Short -0.203  0.018
           Long  -0.096  0.372
Short, pooled over datasets: r = -0.317 (p = 1.1e-12, 481 trials); negative in 4 of 4, p < 0.05 and negative in 4
Long, pooled over datasets: r = -0.201 (p = 5.5e-06, 502 trials); negative in 4 of 4, p < 0.05 and negative in 2
Poisson null with matched durations, Short: null r mean -0.071, range -0.144 to -0.010 (20 simulations); observed -0.317, below every null value: True
Poisson null with matched durations, Long: null r mean -0.033, range -0.103 to 0.028 (20 simulations); observed -0.201, below every null value: True
In [32]:
fig, axes = plt.subplots(1, 2, figsize=(9, 3.4))
for prior, col in [("Short", "tab:red"), ("Long", "tab:blue")]:
    g = spd[spd.prior == prior]
    for n, h in g.groupby("dataset"):
        axes[0].plot(h.xu_at_set, h.speed, "o-", color=col, ms=3, lw=.7)
        axes[1].plot(h.speed, h.mean_tp, "o-", color=col, ms=3, lw=.7)
axes[0].set_xlabel("Xu at Set (spikes/s)")
axes[0].set_ylabel("speed (spikes/s per 20 ms)")
axes[1].set_xlabel("speed (spikes/s per 20 ms)")
axes[1].set_ylabel("mean tp (ms)")
plt.tight_layout()
plt.show()
print("Xu %.1f to %.1f; speed %.2f to %.2f; mean tp %.0f to %.0f; all within the axes."
      % (spd.xu_at_set.min(), spd.xu_at_set.max(), spd.speed.min(), spd.speed.max(), spd.mean_tp.min(), spd.mean_tp.max()))
No description has been provided for this image
Xu -45.0 to 24.2; speed 3.15 to 9.13; mean tp 497 to 1177; all within the axes.

Result: partially reproduced. Faster production trajectories go with shorter mean tp in both priors: rho = -0.733 (Short, p = 0.00024) and -0.744 (Long, p = 0.00017) over 20 points, stronger than the paper's -0.58 and -0.52 and outside tolerance. Within datasets the correlations are -0.80 to -0.99. The benchmark's trial-by-trial variant gives r = -0.195 to -0.740 (mean -0.519), with 7 of 8 conditions at p < 0.05. With the residuals on ts within each dataset and prior the correlation is +0.084 (Short) and -0.283 (Long), nominal p = 0.72 and 0.23. Trial by trial, with speed and tp z-scored within each ts as in the paper's control, faster trials go with shorter tp: r = -0.317 (Short, 481 trials, p = 1.1e-12) and -0.201 (Long, 502 trials, p = 5.5e-06), negative in all 8 dataset-priors and with p < 0.05 in 6 (4 Short, 2 Long). Shorter trials have fewer bins to average noise over, which could by itself tie speed to tp. For Poisson units at constant rates, with each trial's own duration, the same measure gives null r of -0.071 (Short) and -0.033 (Long) on average over 20 simulations, and the observed values are below every null value (null ranges -0.144 to -0.010 and -0.103 to +0.028). The duration accounts for a small part of the trial-by-trial correlation.

Alternative mechanisms¶

alt-speed-unmodulated¶

Instantaneous speed of neural trajectories in the full neural space is not modulated during the support of each prior (signed-rank test for zero regression slopes, p = 0.64 for Short, p = 0.08 for Long; Figure 5B).

In [33]:
rng_boot = np.random.default_rng(5)


def support_slope(rate, ends, short):
    lo, hi = min(rsg.prior_ts(short)), max(rsg.prior_ts(short))
    sp = np.linalg.norm(np.diff(rate, axis=0), axis=1)
    t = ends[1:]
    sel = (t > lo) & (t <= hi + 1e-6)
    return np.polyfit(t[sel], sp[sel], 1)[0] * 100, sp[sel].mean()   # per 100 ms


rows = []
for e, th, name in DS:
    g = good[(good.is_eye == e) & (good.theta == th)]
    for short in [True, False]:
        gg = g[g.is_short == short]
        t_max = max(rsg.prior_ts(short))
        c_ = rsg.counts(spikes, units, gg.ready_time.to_numpy(), 0, t_max)
        el = ((gg.set_time - gg.ready_time) * 1000).to_numpy()
        rate, ends, _ = rsg.attrition_rates(c_, el)
        slope, mean_sp = support_slope(rate, ends, short)
        boots = []
        for _ in range(200):
            i = rng_boot.integers(0, len(gg), len(gg))
            r_, e_, _ = rsg.attrition_rates(c_[i], el[i])
            boots.append(support_slope(r_, e_, short)[0])
        rows.append({"dataset": name, "prior": "Short" if short else "Long", "mean_speed": mean_sp, "slope_per_100ms": slope,
                     "slope_rel_pct": 100 * slope / mean_sp, "boot_low": np.percentile(boots, 2.5), "boot_high": np.percentile(boots, 97.5)})
asp = pd.DataFrame(rows)
print("Speed in the full space of", len(units), "units (spikes/s per 20 ms step); slope per 100 ms, with 95% bootstrap interval over trials:")
print(asp.round(3).to_string(index=False))
for prior, g in asp.groupby("prior", sort=False):
    print(f"{prior}: signed-rank p = {stats.wilcoxon(g.slope_per_100ms).pvalue:.3f} (smallest possible with 4: 0.125); "
          f"slopes {', '.join(f'{x:+.3f}' for x in g.slope_per_100ms)}; intervals excluding 0: "
          f"{int(((g.boot_low > 0) | (g.boot_high < 0)).sum())} of 4")
Speed in the full space of 48 units (spikes/s per 20 ms step); slope per 100 ms, with 95% bootstrap interval over trials:
   dataset prior  mean_speed  slope_per_100ms  slope_rel_pct  boot_low  boot_high
 Eye Right Short       5.397            1.008         18.683     0.724      2.216
 Eye Right  Long       3.676            0.579         15.743     0.583      1.430
  Eye Left Short       6.312            1.270         20.126     1.024      3.149
  Eye Left  Long       3.590            0.478         13.303     0.389      1.256
Hand Right Short       3.748            0.890         23.753     0.835      2.632
Hand Right  Long       3.067            0.578         18.845     0.551      1.547
 Hand Left Short       4.284            0.826         19.276     0.787      2.132
 Hand Left  Long       2.861            0.668         23.356     0.682      1.855
Short: signed-rank p = 0.125 (smallest possible with 4: 0.125); slopes +1.008, +1.270, +0.890, +0.826; intervals excluding 0: 4 of 4
Long: signed-rank p = 0.125 (smallest possible with 4: 0.125); slopes +0.579, +0.478, +0.578, +0.668; intervals excluding 0: 4 of 4
In [34]:
# Attrition adds noise: later bins average fewer trials, and noisier rates give larger steps.
# Variant 1: only the trials with the longest ts of the prior, which last the whole support, so
# the number of trials is the same in every bin. Null: units firing as Poisson processes at a
# constant rate (each unit's mean over the support before Set), with the same trials and
# Set times, measured both ways. The point estimate of the main measure is also compared with
# the median of its bootstrap.
rng_null = np.random.default_rng(10)
rows = []
for e, th, name in DS:
    g = good[(good.is_eye == e) & (good.theta == th)]
    for short in [True, False]:
        gg = g[g.is_short == short]
        lo, hi = min(rsg.prior_ts(short)), max(rsg.prior_ts(short))
        c_ = rsg.counts(spikes, units, gg.ready_time.to_numpy(), 0, hi)
        el = ((gg.set_time - gg.ready_time) * 1000).to_numpy()
        ends = np.arange(1, c_.shape[1] + 1) * rsg.BIN_MS
        full = gg.ts.to_numpy() == hi
        fixed = support_slope(rsg.smooth(c_[full].mean(0) * 1000 / rsg.BIN_MS), ends, short)[0]
        use = (el[:, None] >= ends[None, :] - 1e-6) & (ends[None, :] > lo)
        lam = c_[use].mean(0)                          # counts per bin and unit, before Set, in the support
        null_main, null_fixed, null_speed = [], [], []
        for _ in range(100):
            sim = rng_null.poisson(lam, size=c_.shape).astype(float)
            sl_, sp_ = support_slope(rsg.attrition_rates(sim, el)[0], ends, short)
            null_main.append(sl_)
            null_speed.append(sp_)
            null_fixed.append(support_slope(rsg.smooth(sim[full].mean(0) * 1000 / rsg.BIN_MS), ends, short)[0])
        main = asp[(asp.dataset == name) & (asp.prior == ("Short" if short else "Long"))].iloc[0]
        rows.append({"dataset": name, "prior": "Short" if short else "Long", "speed_obs": main.mean_speed,
                     "speed_null_mean": np.mean(null_speed), "slope_main": main.slope_per_100ms,
                     "null_main_mean": np.mean(null_main), "null_main_95": np.percentile(null_main, 95),
                     "trials_full": int(full.sum()), "slope_fixed": fixed, "null_fixed_mean": np.mean(null_fixed),
                     "null_fixed_2.5": np.percentile(null_fixed, 2.5), "null_fixed_97.5": np.percentile(null_fixed, 97.5)})
spn = pd.DataFrame(rows)
spn["excess_main"] = spn.slope_main - spn.null_main_mean
print("Slopes of speed per 100 ms of the support; Poisson null of constant rates, 100 simulations:")
print(spn.round(3).to_string(index=False))
for prior, g in spn.groupby("prior", sort=False):
    print(f"{prior}: main slope above the null's 95th percentile in {(g.slope_main > g.null_main_95).sum()} of 4; "
          f"fixed trials, slope outside the null's 95% range in {((g.slope_fixed < g['null_fixed_2.5']) | (g.slope_fixed > g['null_fixed_97.5'])).sum()} of 4, "
          f"positive in {(g.slope_fixed > 0).sum()} of 4; signed-rank p (fixed trials) = {stats.wilcoxon(g.slope_fixed).pvalue:.3f}")
print(f"\nMean speed over the support: null above observed in {(spn.speed_null_mean > spn.speed_obs).sum()} of 8 "
      f"(null / observed {(spn.speed_null_mean / spn.speed_obs).min():.2f} to {(spn.speed_null_mean / spn.speed_obs).max():.2f})")
print("\nBootstrap of the main measure: point estimate below the bootstrap 2.5th percentile in",
      int((asp.slope_per_100ms < asp.boot_low).sum()), "of 8, so the bootstrap is biased upward and its intervals are not used.")
Slopes of speed per 100 ms of the support; Poisson null of constant rates, 100 simulations:
   dataset prior  speed_obs  speed_null_mean  slope_main  null_main_mean  null_main_95  trials_full  slope_fixed  null_fixed_mean  null_fixed_2.5  null_fixed_97.5  excess_main
 Eye Right Short      5.397            3.689       1.008           1.683         2.262           25        0.497            0.694           0.067            1.476       -0.675
 Eye Right  Long      3.676            3.027       0.579           0.999         1.275           27        0.256            0.389           0.020            0.853       -0.420
  Eye Left Short      6.312            4.085       1.270           1.813         2.278           22        0.529            0.801           0.012            1.492       -0.543
  Eye Left  Long      3.590            2.783       0.478           0.905         1.192           31        0.097            0.359           0.017            0.785       -0.428
Hand Right Short      3.748            3.347       0.890           1.430         1.872           22        0.357            0.555           0.067            1.074       -0.540
Hand Right  Long      3.067            2.853       0.578           0.949         1.318           25       -0.106            0.345          -0.040            0.648       -0.371
 Hand Left Short      4.284            3.162       0.826           1.461         1.940           24        0.288            0.591          -0.021            1.149       -0.636
 Hand Left  Long      2.861            3.369       0.668           1.130         1.531           18        0.137            0.418          -0.103            0.835       -0.462
Short: main slope above the null's 95th percentile in 0 of 4; fixed trials, slope outside the null's 95% range in 0 of 4, positive in 4 of 4; signed-rank p (fixed trials) = 0.125
Long: main slope above the null's 95th percentile in 0 of 4; fixed trials, slope outside the null's 95% range in 1 of 4, positive in 3 of 4; signed-rank p (fixed trials) = 0.375

Mean speed over the support: null above observed in 1 of 8 (null / observed 0.65 to 1.18)

Bootstrap of the main measure: point estimate below the bootstrap 2.5th percentile in 2 of 8, so the bootstrap is biased upward and its intervals are not used.

Result: reproduced by the paper's test, which cannot detect an effect here. The signed-rank test over 4 datasets gives p = 0.125 for each prior, the smallest value it can give, so the outcome carries no evidence for the absence of modulation. All 8 slopes of speed on time are positive, but noise alone produces positive slopes of this size: later bins average fewer trials, and noisier rates give larger steps. For units firing as Poisson processes at constant rates, with the same trials and Set times, the mean null slope is above the observed slope in all 8 dataset-priors, and no observed slope exceeds the null's 95th percentile. With only the trials of the longest ts, which last the whole support, the slopes are -0.106 to +0.529 per 100 ms and fall outside the null's 95% range in 1 of 8 (Hand Right Long, below it). The bootstrap intervals over trials are biased upward (the point estimate is below the interval in 2 of 8) and are not used. The observed slopes are below the null mean in all 8 dataset-priors, with all trials and with fixed trials. The null is not noisier than the data overall: its mean speed over the support is below the observed one in 7 of 8 (null / observed 0.65 to 1.18). The observed speed thus rises less over the support than noise alone would make it rise, which points to a speed that falls during the support, as long as the units' noise is no larger than Poisson. This was not tested further. The data show no speed increase beyond what noise produces, and the paper's test has no power to show a change in either direction.

alt-transient-parallel¶

In the first 200 ms after Set, trajectories for different ts run parallel; their distance to the middle ts trajectory does not change (signed-rank test for zero slopes, p = 0.92 for the shortest ts, p = 0.13 for the longest; Figure 5D).

In [35]:
def transient_slopes(kept=rsg.VAR_KEPT, norm=None):
    rows = []
    for e, th, name in DS:
        g = good[(good.is_eye == e) & (good.theta == th)]
        traj = {}
        for short in [True, False]:
            for ts in rsg.prior_ts(short):
                gg = g[(g.is_short == short) & (g.ts == ts)]
                traj[(short, ts)] = rsg.aligned_rates(spikes, units, gg.set_time.to_numpy(), -100, 300)[5:15]  # Set to Set + 200 ms
        if norm:
            sc = rsg.unit_scale(list(traj.values()), norm)
            traj = {k: v / sc for k, v in traj.items()}
        pca_t = rsg.PCA(list(traj.values()), kept) if kept else (lambda x: x)
        t = np.arange(1, 11) * rsg.BIN_MS
        for short in [True, False]:
            ts_v = rsg.prior_ts(short)
            ref = pca_t(traj[(short, ts_v[2])])
            for which, ts in [("shortest", ts_v[0]), ("longest", ts_v[-1])]:
                dist = np.linalg.norm(pca_t(traj[(short, ts)]) - ref, axis=1)
                rows.append({"dataset": name, "prior": "Short" if short else "Long", "ts": which,
                             "PCs": getattr(pca_t, "n", len(units)), "mean_distance": dist.mean(),
                             "slope_per_100ms": np.polyfit(t, dist, 1)[0] * 100})
    out = pd.DataFrame(rows)
    out["slope_rel_pct"] = 100 * out.slope_per_100ms / out.mean_distance
    return out


tr_ = transient_slopes()
print(tr_.round(3).to_string(index=False))
for which, g in tr_.groupby("ts"):
    w = stats.wilcoxon(g.slope_per_100ms)
    print(f"{which} ts: signed-rank p = {w.pvalue:.3f}; positive slopes {(g.slope_per_100ms > 0).sum()} of 8; "
          f"median relative slope {g.slope_rel_pct.median():+.1f}% per 100 ms")
print("\nVariants (signed-rank p for the shortest and the longest ts; PCs kept per dataset):")
for kept, norm, lab in [(0.60, None, "60% of variance"), (0.90, None, "90% of variance"), (None, None, "full space"),
                        (0.75, "z", "z-scored rates, 75%"), (0.75, "soft", "soft-normalised rates, 75%")]:
    v = transient_slopes(kept, norm)
    ps = {w: stats.wilcoxon(g.slope_per_100ms).pvalue for w, g in v.groupby("ts")}
    pos = {w: int((g.slope_per_100ms > 0).sum()) for w, g in v.groupby("ts")}
    print(f"   {lab}: shortest p = {ps['shortest']:.3f} ({pos['shortest']} of 8 positive), longest p = {ps['longest']:.3f} "
          f"({pos['longest']} of 8 positive); PCs {v.groupby('dataset', sort=False).PCs.first().tolist()}")
   dataset prior       ts  PCs  mean_distance  slope_per_100ms  slope_rel_pct
 Eye Right Short shortest    2         33.900           -5.552        -16.379
 Eye Right Short  longest    2         20.800           -6.914        -33.242
 Eye Right  Long shortest    2         24.513            5.228         21.329
 Eye Right  Long  longest    2         22.422            0.347          1.547
  Eye Left Short shortest    1         36.737           -3.630         -9.881
  Eye Left Short  longest    1         30.574           -8.238        -26.944
  Eye Left  Long shortest    1         35.824            3.716         10.374
  Eye Left  Long  longest    1         21.763           -3.087        -14.186
Hand Right Short shortest    3         18.999            6.966         36.668
Hand Right Short  longest    3         17.968           -2.200        -12.244
Hand Right  Long shortest    3         20.095            7.824         38.938
Hand Right  Long  longest    3          7.772           -2.207        -28.402
 Hand Left Short shortest    4         20.994            6.727         32.045
 Hand Left Short  longest    4         15.348           -2.504        -16.318
 Hand Left  Long shortest    4         14.481            8.189         56.549
 Hand Left  Long  longest    4         15.418            0.311          2.019
longest ts: signed-rank p = 0.039; positive slopes 2 of 8; median relative slope -15.3% per 100 ms
shortest ts: signed-rank p = 0.078; positive slopes 6 of 8; median relative slope +26.7% per 100 ms

Variants (signed-rank p for the shortest and the longest ts; PCs kept per dataset):
   60% of variance: shortest p = 0.078 (6 of 8 positive), longest p = 0.016 (1 of 8 positive); PCs [1, 1, 2, 2]
   90% of variance: shortest p = 0.109 (5 of 8 positive), longest p = 0.039 (2 of 8 positive); PCs [5, 4, 6, 8]
   full space: shortest p = 0.078 (6 of 8 positive), longest p = 0.023 (1 of 8 positive); PCs [48, 48, 48, 48]
   z-scored rates, 75%: shortest p = 0.742 (5 of 8 positive), longest p = 0.039 (1 of 8 positive); PCs [6, 5, 7, 8]
   soft-normalised rates, 75%: shortest p = 0.312 (5 of 8 positive), longest p = 0.055 (2 of 8 positive); PCs [4, 4, 5, 6]

Result: not reproduced. For the longest ts, the distance to the middle trajectory changes in the first 200 ms after Set: 6 of 8 slopes are negative (median -15.3% of the mean distance per 100 ms), signed-rank p = 0.039. For the shortest ts p = 0.078 (6 of 8 positive, median +26.7% per 100 ms). The PCA kept 1 to 4 components, and in Eye Left a single component, where the distance is the absolute difference along one axis. The result holds for the longest ts with other numbers of components: p = 0.016 with 60% of the variance, 0.039 with 90%, 0.023 in the full space of 48 units and 0.039 with z-scored rates (75%); with soft-normalised rates it is 0.055. For the shortest ts p is 0.078 to 0.742 across these variants.

alt-threshold-monotonic¶

Aligned to the response, distances between trajectories for different ts decrease steadily up to movement initiation, without the converging then diverging pattern a ts-dependent threshold would produce (Figure 5F).

In [36]:
def go_curves(kept, min_n=1):
    rows, curves = [], {}
    for e, th, name in DS:
        g = good[(good.is_eye == e) & (good.theta == th)]
        traj = {}
        for short in [True, False]:
            for ts in rsg.prior_ts(short):
                gg = g[(g.is_short == short) & (g.ts == ts)]
                traj[(short, ts)] = rsg.aligned_rates(spikes, units, gg.go_time.to_numpy(), -900, 100)[5:45]  # Go - 800 to Go
        pca_g = rsg.PCA(list(traj.values()), kept, min_n) if kept else (lambda x: x)
        t = np.arange(-39, 1) * rsg.BIN_MS   # bin end times relative to Go
        for short in [True, False]:
            ts_v = rsg.prior_ts(short)
            ref = pca_g(traj[(short, ts_v[2])])
            for ts in ts_v[:2] + ts_v[3:]:
                diff = pca_g(traj[(short, ts)]) - ref
                dist = np.linalg.norm(diff, axis=1)
                curves[(name, short, ts)] = dist
                k = np.argmin(dist)
                # With one component, a minimum is a crossing when the signed difference changes sign there.
                crossing = diff.shape[1] == 1 and np.any(np.sign(diff[max(k - 1, 0):k + 2, 0]) != np.sign(diff[k, 0]))
                rows.append({"dataset": name, "prior": "Short" if short else "Long", "ts": ts,
                             "PCs": getattr(pca_g, "n", len(units)), "t_min": t[np.argmin(dist)],
                             "d_at_go_over_min": dist[-1] / dist.min(), "min_at_crossing": bool(crossing), "d_start": dist[0], "d_go": dist[-1]})
    return pd.DataFrame(rows), curves, t


thr_tab, thr_curves, t_go = go_curves(0.60)
print(thr_tab.round(3).to_string(index=False))
print(f"\nMain (60% of variance): median time of the minimum {thr_tab.t_min.median():.0f} ms (bin end, relative to Go); "
      f"minimum in the last 40 ms in {(thr_tab.t_min >= -20).sum()} of {len(thr_tab)} curves; in the last 200 ms in "
      f"{(thr_tab.t_min >= -180).sum()}; distance at Go below that 800 ms earlier in {(thr_tab.d_go < thr_tab.d_start).sum()}")
for kept, lab in [(0.75, "75% of variance"), (None, "full space")]:
    v, _, _ = go_curves(kept)
    print(f"Variant, {lab}: median time of the minimum {v.t_min.median():.0f} ms; in the last 40 ms {(v.t_min >= -20).sum()} of {len(v)}")
   dataset prior   ts  PCs  t_min  d_at_go_over_min  min_at_crossing  d_start   d_go
 Eye Right Short  480    1   -600             2.265             True   30.771  1.455
 Eye Right Short  560    1    -20             1.320             True    4.027  0.123
 Eye Right Short  720    1   -240           177.672             True    9.028  3.734
 Eye Right Short  800    1      0             1.000             True   19.518  0.240
 Eye Right  Long  800    1   -180             9.758             True    1.435  3.711
 Eye Right  Long  900    1   -120            18.362             True    5.267  2.153
 Eye Right  Long 1100    1    -80             1.749            False   34.278  3.511
 Eye Right  Long 1200    1   -280            13.242             True   38.929  3.458
  Eye Left Short  480    1   -540             1.795             True   13.723  1.485
  Eye Left Short  560    1    -80            29.558             True    7.761  2.051
  Eye Left Short  720    1      0             1.000            False    9.958  1.583
  Eye Left Short  800    1      0             1.000            False   20.924  0.457
  Eye Left  Long  800    1   -760             3.855             True    6.222  3.160
  Eye Left  Long  900    1      0             1.000            False    1.695  1.666
  Eye Left  Long 1100    1   -220             3.244             True   18.422  0.732
  Eye Left  Long 1200    1   -200            63.014             True   28.217  0.237
Hand Right Short  480    3   -260             2.342            False   32.141 15.212
Hand Right Short  560    3   -260             6.502            False   11.257  7.488
Hand Right Short  720    3   -460             2.783            False    5.860  9.533
Hand Right Short  800    3   -480             1.924            False   10.949 11.672
Hand Right  Long  800    3   -200             3.617            False   17.831  2.996
Hand Right  Long  900    3   -180             5.847            False    5.973  5.835
Hand Right  Long 1100    3    -40             3.873            False    6.387  4.641
Hand Right  Long 1200    3   -620             3.473            False    5.762  7.041
 Hand Left Short  480    2      0             1.000            False   10.654  0.536
 Hand Left Short  560    2   -620             4.091            False    9.414  2.317
 Hand Left Short  720    2   -700           126.054            False    2.526  4.982
 Hand Left Short  800    2   -740             4.630            False    1.405  5.769
 Hand Left  Long  800    2   -140             1.350            False   15.874  3.555
 Hand Left  Long  900    2   -100             1.266            False    4.264  0.861
 Hand Left  Long 1100    2   -740             2.987            False    6.204  5.125
 Hand Left  Long 1200    2   -280             5.283            False   11.355  6.166

Main (60% of variance): median time of the minimum -210 ms (bin end, relative to Go); minimum in the last 40 ms in 6 of 32 curves; in the last 200 ms in 14; distance at Go below that 800 ms earlier in 26
Variant, 75% of variance: median time of the minimum -80 ms; in the last 40 ms 11 of 32
Variant, full space: median time of the minimum -40 ms; in the last 40 ms 14 of 32
In [37]:
for kept, min_n, lab in [(0.60, 2, "60% of variance, at least 2 components"), (0.60, 3, "60% of variance, at least 3 components")]:
    v, _, _ = go_curves(kept, min_n)
    print(f"Variant, {lab}: median time of the minimum {v.t_min.median():.0f} ms; in the last 40 ms {(v.t_min >= -20).sum()} of {len(v)}; "
          f"in the last 200 ms {(v.t_min >= -180).sum()}")
one = thr_tab[thr_tab.PCs == 1]
print(f"Main, datasets with 1 component: {len(one)} curves, median time of the minimum {one.t_min.median():.0f} ms, "
      f"distance at Go over minimum above 10 in {(one.d_at_go_over_min > 10).sum()}; minimum at Go in {(one.t_min == 0).sum()}; "
      f"minimum where the signed difference changes sign (a crossing) in {one.min_at_crossing.sum()}")
Variant, 60% of variance, at least 2 components: median time of the minimum -230 ms; in the last 40 ms 8 of 32; in the last 200 ms 14
Variant, 60% of variance, at least 3 components: median time of the minimum -70 ms; in the last 40 ms 13 of 32; in the last 200 ms 23
Main, datasets with 1 component: 16 curves, median time of the minimum -150 ms, distance at Go over minimum above 10 in 5; minimum at Go in 4; minimum where the signed difference changes sign (a crossing) in 12
In [38]:
fig, axes = plt.subplots(1, 2, figsize=(9, 3.2), sharey=True)
for (name, short, ts), d in thr_curves.items():
    axes[0 if short else 1].plot(t_go, d, color="tab:red" if short else "tab:blue", lw=.6, alpha=.6)
for ax, lab in zip(axes, ["Short", "Long"]):
    ax.set_title(lab)
    ax.set_xlabel("time from Go (ms)")
axes[0].set_ylabel("distance to middle ts (spikes/s)")
plt.tight_layout()
plt.show()
allv = np.concatenate(list(thr_curves.values()))
print("Distances %.1f to %.1f; all within the axes." % (allv.min(), allv.max()))
No description has been provided for this image
Distances 0.0 to 58.4; all within the axes.

Result: not reproduced. With the components that reach 60% of the variance (1 to 3 per dataset), the median time of the minimum distance is 210 ms before Go; the minimum is in the last 40 ms in 6 of 32 curves and in the last 200 ms in 14. The distance at Go is below that 800 ms earlier in 26 of 32 curves, so distances do fall overall. The result depends on the number of components: with 75% of the variance the median minimum is 80 ms before Go, and in the full space of 48 units 40 ms before Go, which would meet the criterion. With 1 component (Eye datasets) the distance can reach zero where trajectories cross, which moves the minima. In the eye datasets, with 1 component (16 curves), the signed difference changes sign at the minimum in 12 curves, so these minima are crossings; in 4 curves the minimum is at Go (3 of them not crossings), and the distance at Go is more than 10 times the minimum in 5. Requiring at least 2 components gives a median minimum 230 ms before Go, and at least 3 gives 70 ms before Go, within the 200 ms of a partial reproduction. The outcome is judged on the criterion's 60% of the variance.

Single-trial trajectories (GPFA)¶

gpfa-variance-inverted-u¶

The variance across single trials of the GPFA neural state at Set projected on the encoding axis has an inverted-U profile over ts, lower at the extremes of each prior (one-tailed signed-rank test on quadratic coefficients, p = 0.0273, monkey H; Figure 6C).

In [39]:
rng_gpfa = np.random.default_rng(6)
t_gpfa = time.time()


def gpfa_rows(name, g, lat):
    rows = []
    for short in [True, False]:
        mask = g.is_short.to_numpy() == short
        L = [lat[i] for i in np.flatnonzero(mask)]
        nb = max(x.shape[1] for x in L)
        mean = np.array([np.mean([x[:, k] for x in L if x.shape[1] > k], axis=0) for k in range(nb)])
        tt = np.arange(1, nb + 1) * rsg.BIN_MS
        ts_v = rsg.prior_ts(short)
        u = rsg.encoding_axis(mean, tt, min(ts_v), max(ts_v))
        xu = np.array([x[:, -1] @ u for x in L])
        ts_tr = g.ts.to_numpy()[mask]
        z = (xu - xu.mean()) / xu.std()
        var = np.array([z[ts_tr == t].var(ddof=1) for t in ts_v])
        mxu = np.array([xu[ts_tr == t].mean() for t in ts_v])
        rows.append({"dataset": name, "prior": "Short" if short else "Long", **{f"var_{t}": v for t, v in zip(ts_v, var)},
                     "quad_coef": np.polyfit(np.array(ts_v, float), var, 2)[0],
                     "mean_xu": mxu, "slope_xu": np.polyfit(np.array(ts_v, float), mxu, 1)[0]})
    return rows


GP, rows = {}, []
for e, th, name in DS:
    g = good[(good.is_eye == e) & (good.theta == th)]
    sts = rsg.estimation_spiketrains(spikes, units, g)
    model, dim, scores, folds = rsg.fit_gpfa(sts, rng_gpfa)
    lat = rsg.gpfa_transform(model, sts)
    GP[name] = {"lat": lat, "trials": g, "dim": dim, "sts": sts, "folds": folds, "scores": scores, "model": model}
    print(f"{name}: {len(g)} trials, latent dimensionality {dim} (cross-validated log-likelihood "
          + ", ".join(f"{k}: {v:.0f}" for k, v in scores.items()) + ")")
    rows += gpfa_rows(name, g, lat)
gp = pd.DataFrame(rows)
print(f"GPFA fits took {time.time() - t_gpfa:.0f} s")
print(gp.drop(columns=["mean_xu"]).to_string(index=False, float_format=lambda x: f"{x:.3g}"))
w = stats.wilcoxon(gp.quad_coef, alternative="less")
print(f"\nQuadratic coefficient negative in {(gp.quad_coef < 0).sum()} of 8; one-tailed signed-rank p = {w.pvalue:.4f}")
Eye Right: 276 trials, latent dimensionality 8 (cross-validated log-likelihood 4: -174266, 8: -173776)
Eye Left: 260 trials, latent dimensionality 8 (cross-validated log-likelihood 4: -164318, 8: -163780)
Hand Right: 225 trials, latent dimensionality 8 (cross-validated log-likelihood 4: -124348, 8: -123808)
Hand Left: 222 trials, latent dimensionality 8 (cross-validated log-likelihood 4: -115304, 8: -114842)
GPFA fits took 268 s
   dataset prior  var_480  var_560  var_640  var_720  var_800  quad_coef  slope_xu  var_900  var_1000  var_1100  var_1200
 Eye Right Short    0.764    0.525    0.645    0.799    0.262  -6.28e-06   0.00224      NaN       NaN       NaN       NaN
 Eye Right  Long      NaN      NaN      NaN      NaN    0.988  -6.21e-07    0.0014    0.732     0.934     0.353     0.445
  Eye Left Short    0.484    0.228    0.308    0.381    0.189   1.37e-06    0.0027      NaN       NaN       NaN       NaN
  Eye Left  Long      NaN      NaN      NaN      NaN    0.558  -6.67e-06   0.00155    0.917     0.735     0.477     0.407
Hand Right Short    0.842    0.954     1.04    0.882    0.611  -1.13e-05  0.000555      NaN       NaN       NaN       NaN
Hand Right  Long      NaN      NaN      NaN      NaN    0.635  -2.23e-05  0.000375     1.65      1.38     0.518     0.272
 Hand Left Short    0.909     1.22    0.696    0.488    0.694   1.17e-06  0.000939      NaN       NaN       NaN       NaN
 Hand Left  Long      NaN      NaN      NaN      NaN     1.27   5.99e-06  0.000453     1.44     0.468     0.702     0.689

Quadratic coefficient negative in 5 of 8; one-tailed signed-rank p = 0.1250
In [40]:
var_cols = {True: [f"var_{t}" for t in rsg.SHORT_TS], False: [f"var_{t}" for t in rsg.LONG_TS]}
fig, axes = plt.subplots(1, 2, figsize=(8, 3.2), sharey=True)
allv = []
for ax, short in zip(axes, [True, False]):
    for _, r in gp[gp.prior == ("Short" if short else "Long")].iterrows():
        v = r[var_cols[short]].to_numpy(float)
        allv += list(v)
        ax.plot(rsg.prior_ts(short), v, "o-", color="tab:red" if short else "tab:blue", lw=.8, ms=3)
    ax.set_xlabel("ts (ms)")
axes[0].set_ylabel("variance of z-scored Xu")
plt.tight_layout()
plt.show()
print("Variances %.2f to %.2f; all within the axes." % (min(allv), max(allv)))
No description has been provided for this image
Variances 0.19 to 1.65; all within the axes.
In [41]:
# Convergence: the training log-likelihood of each final model over its EM iterations
# (computed every fifth iteration), and the relative gain over the last 25 iterations.
print("Training log-likelihood of the final models:")
for name, G in GP.items():
    ll = np.array(G["model"].fit_info["log_likelihoods"], float)
    it = np.flatnonzero(~np.isnan(ll)) + 1
    ll = ll[~np.isnan(ll)]
    late = ll[it >= it[-1] - 25][0]
    print(f"   {name}: {len(G['model'].fit_info['log_likelihoods'])} iterations; at iteration 50 {ll[it == 50][0]:.0f}, "
          f"75 {ll[it == 75][0]:.0f}, {it[-1]} {ll[-1]:.0f}; gain over the last 25 iterations {100 * (ll[-1] - late) / abs(ll[-1]):.3f}%")

# Variant: a wider grid; 12 latents scored on the same folds. Where 12 wins, the variance test
# is repeated with the 12-latent model.
t_var = time.time()
rows_12, wins = [], []
for name, G in GP.items():
    s12 = rsg.gpfa_cv_score(G["sts"], G["folds"], 12)
    best = max({**G["scores"], 12: s12}.items(), key=lambda kv: kv[1])[0]
    print(f"{name}: cross-validated log-likelihood with 12 latents {s12:.0f} (chosen {G['dim']}: {G['scores'][G['dim']]:.0f}); best of 4, 8, 12: {best}")
    if best == 12:
        wins.append(name)
        lat12 = rsg.gpfa_transform(rsg.gpfa_model(G["sts"], 12), G["sts"])
        rows_12 += gpfa_rows(name, G["trials"], lat12)
    else:
        rows_12 += [r.to_dict() for _, r in gp[gp.dataset == name].iterrows()]
gp12 = pd.DataFrame(rows_12)
w12 = stats.wilcoxon(gp12.quad_coef, alternative="less")
print(f"With the grid 4, 8, 12 ({len(wins)} datasets refit with 12): quadratic coefficient negative in "
      f"{(gp12.quad_coef < 0).sum()} of 8; one-tailed signed-rank p = {w12.pvalue:.4f}")
inc12 = np.array([np.subtract(*rsg.increments(m)[::-1]) for m in gp12.mean_xu])
sg12 = gp12.pivot(index="dataset", columns="prior", values="slope_xu")
print(f"Mean projections with this grid: middle increment above extreme in {(inc12 > 0).sum()} of 8 "
      f"(two-sided p = {stats.wilcoxon(inc12).pvalue:.4f}); Short slope above Long in {int((sg12.Short > sg12.Long).sum())} of 4")
print(f"Variant took {time.time() - t_var:.0f} s")
Training log-likelihood of the final models:
   Eye Right: 100 iterations; at iteration 50 -207406, 75 -207154, 100 -206982; gain over the last 25 iterations 0.083%
   Eye Left: 100 iterations; at iteration 50 -193544, 75 -193328, 100 -193179; gain over the last 25 iterations 0.077%
   Hand Right: 100 iterations; at iteration 50 -147714, 75 -147556, 100 -147441; gain over the last 25 iterations 0.078%
   Hand Left: 100 iterations; at iteration 50 -140688, 75 -140567, 100 -140474; gain over the last 25 iterations 0.066%
Eye Right: cross-validated log-likelihood with 12 latents -173646 (chosen 8: -173776); best of 4, 8, 12: 12
Eye Left: cross-validated log-likelihood with 12 latents -163590 (chosen 8: -163780); best of 4, 8, 12: 12
Hand Right: cross-validated log-likelihood with 12 latents -123685 (chosen 8: -123808); best of 4, 8, 12: 12
Hand Left: cross-validated log-likelihood with 12 latents -114724 (chosen 8: -114842); best of 4, 8, 12: 12
With the grid 4, 8, 12 (4 datasets refit with 12): quadratic coefficient negative in 5 of 8; one-tailed signed-rank p = 0.1250
Mean projections with this grid: middle increment above extreme in 8 of 8 (two-sided p = 0.0078); Short slope above Long in 4 of 4
Variant took 234 s

Result: not reproduced. The quadratic coefficient of variance on ts is negative in 5 of 8 dataset-priors; the one-tailed signed-rank test gives p = 0.125 (paper 0.0273). Cross-validation chose 8 latent dimensions in all 4 datasets, the larger of the two candidates. Cross-validation does not settle the dimensionality: 12 latents, scored on the same folds as a variant, beat 8 in all 4 datasets. With 12 latents the quadratic coefficient is negative in 5 of 8 and p = 0.125, the same result. After 100 EM iterations the training log-likelihood was still rising, by less than 0.1% over the last 25 iterations (0.064 to 0.085% in this run), so the fits are close to convergence but not at it.

gpfa-mean-sigmoid¶

The mean of the single-trial GPFA projections is sigmoidal (signed-rank test on increments, p = 0.0078) and has a higher slope for the Short prior (one-tailed signed-rank, p = 0.0625; Figure 6D).

In [42]:
inc_g = pd.DataFrame([{"dataset": r.dataset, "prior": r.prior, **dict(zip(["extreme", "middle"], rsg.increments(r.mean_xu)))}
                      for _, r in gp.iterrows()])
print(inc_g.round(4).to_string(index=False))
w = stats.wilcoxon(inc_g.middle, inc_g.extreme)
print(f"\nMiddle increment above extreme in {(inc_g.middle > inc_g.extreme).sum()} of 8; two-sided signed-rank p = {w.pvalue:.4f}")
sg = gp.pivot(index="dataset", columns="prior", values="slope_xu")
print("\nSlope of mean Xu on ts (latent units per ms):")
print(sg.round(5).to_string())
print(f"Short slope above Long in {int((sg.Short > sg.Long).sum())} of 4; one-tailed signed-rank p = "
      f"{stats.wilcoxon(sg.Short, sg.Long, alternative='greater').pvalue:.4f}")
print("Mean Xu rises with ts (positive slope) in", int((gp.slope_xu > 0).sum()), "of 8")
print(f"\nTotal run time {time.time() - started:.0f} s")
   dataset prior  extreme  middle
 Eye Right Short   0.0693  0.2527
 Eye Right  Long  -0.0029  0.2355
  Eye Left Short   0.1249  0.2761
  Eye Left  Long   0.0583  0.2201
Hand Right Short   0.0247  0.0575
Hand Right  Long  -0.0148  0.0724
 Hand Left Short   0.0280  0.1065
 Hand Left  Long   0.0363  0.0512

Middle increment above extreme in 8 of 8; two-sided signed-rank p = 0.0078

Slope of mean Xu on ts (latent units per ms):
prior          Long    Short
dataset                     
Eye Left    0.00155  0.00270
Eye Right   0.00140  0.00224
Hand Left   0.00045  0.00094
Hand Right  0.00038  0.00056
Short slope above Long in 4 of 4; one-tailed signed-rank p = 0.0625
Mean Xu rises with ts (positive slope) in 8 of 8

Total run time 539 s

Result: reproduced. The mean single-trial projection rises with ts in all 8 dataset-priors, its middle increments exceed those at the extremes in 8 of 8 (two-sided signed-rank p = 0.0078, the paper's value), and its slope is higher for the Short prior in 4 of 4 datasets (one-tailed p = 0.0625, the paper's value). With 12 latents the middle increments exceed the extreme ones in 8 of 8 (p = 0.0078) and the Short slope is higher in 4 of 4.

Summary¶

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

Claim Outcome
beh-slopes Not reproduced (6 of 8 slopes between 0 and 1)
beh-overlap-prior Not reproduced (both biases positive, p = 0.073)
beh-sigmoid-increments Reproduced (7 of 8, p = 0.0156)
beh-long-more-bias Reproduced (4 of 4)
beh-bayes-model Partially reproduced (SD in 4 of 4, bias R2 >= 0.8 in 2 of 4)
glm-prior-ts Not reproduced (75.0% and 45.8% against 65% and 27%; chance at most 14.6%)
dfr-overlap-support Reproduced (largest at 480 to 640 ms)
pca-dimensionality Not reproduced (2 PCs in the estimation epoch; 8 of 8 in range with z-scored rates, a variant)
encoding-r2 Reproduced (0.998 and 0.996, Hand Left); above 95% of random axes in only 3 of 8
encoding-bayes-beats-linear Partially reproduced (Long 4 of 4, Short 3 of 4)
short-in-long-control Reproduced by the paper's test, which cannot detect an effect with 4 datasets
neural-long-more-bias Reproduced (4 of 4)
speed-vs-projection Partially reproduced (rho -0.94 and -0.88, stronger than reported)
initial-speed-biased Reproduced (7 of 8, p = 0.039)
speed-predicts-tp Partially reproduced (rho -0.73 and -0.74, stronger than reported)
alt-speed-unmodulated Reproduced by the paper's test, which cannot detect an effect; slopes no larger than noise gives
alt-transient-parallel Not reproduced (longest ts, p = 0.039)
alt-threshold-monotonic Not reproduced (median minimum 210 ms before Go with 60% of variance)
gpfa-variance-inverted-u Not reproduced (5 of 8, p = 0.125)
gpfa-mean-sigmoid Reproduced (p = 0.0078 and 0.0625, the paper's values)
rnn-curved-manifold, rnn-perturbation Blocked: data missing (the trained networks and their code are not public)
monkey-g Blocked: data missing

How the analysis differs from the paper's. All results come from one session of monkey H, one of the 29 sessions of the paper, with 48 units where the paper analyses 536 (H) and 636 (G) units, of 617 and 741 recorded, over sessions; a "dataset" here is one effector and direction in this session (4, against the paper's 8). The paper's code was not available, so smoothing, the encoding axis, speed, the GLM design with attrition and the Bayesian observer fit follow the Methods as read and the choices stated in the claims file. The paper's signed-rank tests over 8 datasets become tests over 4, which cannot reach p < 0.05; two claims of absence are judged on such tests. The GPFA latent dimensionality was chosen from 4 and 8 only, with 100 EM iterations; 12 latents score better, and the conclusions do not change with them. The outlier flag is the file's. Rates are not normalised before PCA, as the Methods do not say; z-scoring them brings the PCA dimensionality into the paper's ranges; the encoding axis then still scores above the median random axis in all 8 dataset-priors, but above 95% of random axes in none.

What was found about the dandiset. It holds one session of one of the paper's two monkeys, in the form prepared for the Neural Latents Benchmark: 283 of the session's trials are in a test file without task variables and without 14 of the 54 units. The 48 units that pass the Methods' rate rule match the paper's single-session GPFA count for monkey H, consistent with this being that session. The iti description gives the priors' inter-trial intervals the wrong way round, tp is a median 18 ms longer than go_time - set_time, the is_outlier flag is not reproduced by a refit of the Methods' mixture model (10 of 46 flagged), and units have no single-unit or multi-unit label and no electrode, whose table has no positions.