Reproducing Scherrer, Lynch et al. (2023) from dandiset 000691¶

Paper. Scherrer JR, Lynch GF, Zhang JJ, Fee MS (2023). An optical design enabling lightweight and large field-of-view head-mounted microscopes. Nature Methods 20, 546-549. doi:10.1038/s41592-023-01806-1. The version of record is not open access. This attempt used its abstract and the parts of its article page that are public: the figure titles, the Extended Data legends, and the data and code availability statements. The main text and the legends of Figures 1 and 2 were not available. The bioRxiv preprint (10.1101/2021.09.03.458947, version 2) was read for its Methods only; no expected value comes from it.

Dandiset. DANDI:000691, version 0.260827.1358. The paper cites no DANDI version: its data statement points to an Academic Torrents deposit. This is the only published version. The draft holds the same two assets, with the same blobs and sizes (printed in the data check).

What the dandiset holds. One session of one mouse (4 mm cranial window, jGCaMP7f) imaged with the large-FOV microscope while it explores a looped corridor maze: the raw imaging video, 2948 EXTRACT footprints with traces and centroids, and a behaviour video (a second asset, an AVI). It holds no position of the mouse, no curation of the footprints, no pixel size, and none of the data of the other three mice, the three birds, or the optical tests.

Outside inputs. Two claims test the illumination profiles of Extended Data Fig. 2 on images the paper's code repository holds (GitHub FeeLab/Feescope-Paper-Code, commit d596646); they are data the dandiset does not hold. Every other result is computed from the dandiset alone. The parameters of the analysis come from the same code; see the helper module kiloscope.py.

Random numbers are drawn from a generator per claim section, each with its own fixed seed.

Claim Data Status
abstract-thousands-of-neurons dandiset tested
ed6a-curated-footprints dandiset blocked: the curation is not in the dandiset
ed6bcd-other-mice dandiset blocked: data missing
ed5-songbirds dandiset blocked: data missing
abstract-kiloscope-fov dandiset blocked: no pixel size in the dandiset
abstract-weights dandiset blocked: data missing
ed1-ed4-resolution dandiset blocked: data missing
ed3-adc dandiset blocked: data missing
ed8-coactive-distributed dandiset tested
fig2-quadrant-direction-decoding dandiset tested
ed9b-octant-decoding dandiset tested
ed9c-local-decoding-map dandiset tested
ed10-localized-activity dandiset tested
ed2b-featherscope-flat-illumination elsewhere (GitHub) tested
ed2c-kiloscope-flat-illumination elsewhere (GitHub) tested
In [1]:
import time
import warnings

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from dandi.dandiapi import DandiAPIClient
from joblib import Parallel, delayed
from scipy import stats
from sklearn.decomposition import NMF

import kiloscope as ks

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

started = time.time()
urls = ks.asset_urls()
f = ks.open_nwb(urls)
S = ks.load_session(f)
print(f"NWB loaded in {time.time() - started:.0f} s")
video_url = next(u for p, u in urls.items() if p.endswith(".avi"))
t0 = time.time()
# Frames kept for display: four in the opening part the code leaves out, four after it.
SAMPLE_FRAMES = (0, 640, 1280, 1900, 4000, 8000, 12000, 15000)
# Decoders are fit in parallel; their random numbers are drawn in order first, so results do not
# depend on the number of workers.
track = ks.track_mouse(video_url, keep_frames=SAMPLE_FRAMES)
B = ks.behaviour(S["t_img"], S["t_video"], track)
print(f"behaviour video decoded and tracked in {time.time() - t0:.0f} s")
NWB loaded in 8 s
behaviour video decoded and tracked in 52 s

Data check¶

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

In [2]:
with DandiAPIClient() as client:
    raw = client.get_dandiset(ks.DANDISET_ID, ks.DANDISET_VERSION).get_raw_metadata()
    assets = {v: {a.path: (a.size, a.blob) for a in client.get_dandiset(ks.DANDISET_ID, v).get_assets()}
              for v in (ks.DANDISET_VERSION, "draft")}
summary = raw["assetsSummary"]
print("Assets (size in bytes, blob id):")
for p in urls:
    print("  ", p, assets[ks.DANDISET_VERSION][p])
print("Draft holds the same assets with the same blobs:", assets["draft"] == assets[ks.DANDISET_VERSION])
print("Dandiset summary: subjects", summary.get("numberOfSubjects"), "; species",
      [s["name"] for s in summary.get("species", [])])
print("  measurement techniques:", [m["name"] for m in summary.get("measurementTechnique", [])])
print("  variables measured:", summary.get("variableMeasured"))
print("Dandiset description says the data come from:",
      raw["description"].split("This dataset includes")[1].split(".")[0].strip())
print()
m = S["meta"]
for k in ["session_description", "device_description", "imaging_series", "imaging_description",
          "video_description", "video_external_file", "indicator", "location", "excitation_lambda",
          "grid_spacing_present", "virus", "surgery", "subject", "trace_description", "trace_unit",
          "segmentation_type", "cellfind_min_snr", "plane_segmentation_columns", "movie_shape",
          "movie_dtype", "movie_chunks"]:
    print(f"{k}: {m[k]}")
Assets (size in bytes, blob id):
   sub-4mm-mouse-1/sub-4mm-mouse-1_image+ophys/60d277d6-24fd-4817-bb02-41382db59172_external_file_0.avi (105329192, '1da756f3-ab4d-4796-bf27-ad8f9054eaf0')
   sub-4mm-mouse-1/sub-4mm-mouse-1_image+ophys.nwb (15232760735, 'c3d0b627-6abb-4b8e-a44b-e51fe53cccfc')
Draft holds the same assets with the same blobs: True
Dandiset summary: subjects 1 ; species ['Mus musculus - House mouse']
  measurement techniques: ['analytical technique', 'two-photon microscopy technique', 'surgical technique']
  variables measured: ['TwoPhotonSeries', 'ImagingPlane', 'ProcessingModule', 'PlaneSegmentation', 'OpticalChannel']
Dandiset description says the data come from: the calcium imaging data from our microscope optimized for weight

session_description: This session includes calcium imaging recorded from a head-mounted microscope in a freely moving mouse while simultaneously recording more than a thousand neurons in cortex.
device_description: Head-mounted microscope (Kiloscope) optimized for weight (1.0 mm FOV; 1.0 g).
imaging_series: TwoPhotonSeries
imaging_description: Calcium imaging data at ~30 fps in grayscale format
video_description: Behavior video of animal moving in environment at ~30 fps
video_external_file: sub-4mm-mouse-1_image+ophys/60d277d6-24fd-4817-bb02-41382db59172_external_file_0.avi
indicator: unknown
location: unknown
excitation_lambda: nan
grid_spacing_present: False
virus: GCaMP7f
surgery: 4mm glass window implant over right visual and somatosensory cortex
subject: {'age': 'P3M', 'description': '~20 virus injections in layer 2/3 of cortex', 'genotype': 'C57/B6', 'sex': 'M', 'species': 'Mus musculus', 'subject_id': '4mm-mouse-1'}
trace_description: Array of df/F traces.
trace_unit: n.a.
segmentation_type: EXTRACTSegmentation
cellfind_min_snr: 2.0
plane_segmentation_columns: ['image_mask', 'ROICentroids']
movie_shape: (36596, 808, 608)
movie_dtype: uint16
movie_chunks: (1200, 20, 20)

The device description names the large-FOV microscope (Kiloscope) but gives the size and weight of the other one ("optimized for weight (1.0 mm FOV; 1.0 g)"), and the dandiset description also says the data come from "our microscope optimized for weight". The data are from the large-FOV microscope: a 4 mm window (the surgery field), thousands of footprints (below), and frames of 808 x 608 pixels, the size used for the Kiloscope in the paper's code (sf3/widefield/analysis.m, w = 808; h = 608). The imaging is stored as a TwoPhotonSeries and the dandiset lists "two-photon microscopy technique", but the microscope is a one-photon head-mounted epifluorescence microscope. The imaging plane gives the indicator and location as "unknown" and no excitation wavelength or pixel size, although general/virus says GCaMP7f. The plane segmentation has only image_mask and ROICentroids: there is no column that marks the footprints kept by the paper's curation. The segmentation parameters record cellfind_min_snr = 2, as the preprint's Methods state for the Kiloscope data.

In [3]:
def rate_summary(t):
    dt = np.diff(t)
    return {"n": len(t), "first_s": round(t[0], 3), "last_s": round(t[-1], 3),
            "median_dt_ms": round(1000 * np.median(dt), 2), "rate_hz": round(1 / np.median(dt), 2),
            "min_dt_ms": round(1000 * dt.min(), 2), "max_dt_ms": round(1000 * dt.max(), 2),
            "within_10pct": round(float(np.mean(np.abs(dt / np.median(dt) - 1) < 0.1)), 3)}

print(pd.DataFrame({"imaging": rate_summary(S["t_img"]), "traces": rate_summary(S["t_trace"]),
                    "behaviour video": rate_summary(S["t_video"])}).to_string())
print("Trace timestamps equal the imaging timestamps:", bool(np.array_equal(S["t_img"], S["t_trace"])))
n_frames = len(track["x"])
print(f"\nAVI: {n_frames} frames of {track['frame_shape']}, nominal {track['nominal_rate']:.0f} frames/s, "
      f"so {n_frames / track['nominal_rate']:.0f} s at that rate; its NWB timestamps span "
      f"{S['t_video'][-1] - S['t_video'][0]:.0f} s")
print("AVI frames equal NWB video timestamps:", n_frames == len(S["t_video"]))

T = S["traces"]
print(f"\nTraces: {T.shape[0]} frames x {T.shape[1]} footprints; values {T.min():.3f} to {T.max():.3f}; "
      f"fraction exactly zero {np.mean(T == 0):.3f}; footprints never above zero {(T.max(0) == 0).sum()}")
print("Per-footprint maximum, percentiles 0/5/50/95/100:", np.round(np.percentile(T.max(0), [0, 5, 50, 95, 100]), 4))
print("ROI ids are 0..n-1 and the trace table references them in order:",
      bool(np.array_equal(S["ids"], np.arange(T.shape[1])) and np.array_equal(S["rois"], S["ids"])))
c = S["centroids"]
print(f"Centroids: first column {c[:, 0].min()} to {c[:, 0].max()} (movie axis 1 has {m['movie_shape'][1]} pixels), "
      f"second column {c[:, 1].min()} to {c[:, 1].max()} (movie axis 2 has {m['movie_shape'][2]})")
um = ks.UM_PER_PIXEL
print(f"At the code's {um} um per pixel (the file gives none): frame {m['movie_shape'][1] * um / 1000:.2f} x "
      f"{m['movie_shape'][2] * um / 1000:.2f} mm; centroid extent {np.ptp(c[:, 0]) * um / 1000:.2f} x {np.ptp(c[:, 1]) * um / 1000:.2f} mm")
                imaging     traces  behaviour video
n             36596.000  36596.000        15521.000
first_s           0.000      0.000            0.259
last_s         1234.515   1234.515         1234.473
median_dt_ms     33.720     33.720           79.910
rate_hz          29.660     29.660           12.510
min_dt_ms         7.830      7.830           42.050
max_dt_ms        62.460     62.460          111.680
within_10pct      0.944      0.944            0.926
Trace timestamps equal the imaging timestamps: True

AVI: 15521 frames of (480, 640, 3), nominal 30 frames/s, so 517 s at that rate; its NWB timestamps span 1234 s
AVI frames equal NWB video timestamps: True
Traces: 36596 frames x 2948 footprints; values 0.000 to 0.348; fraction exactly zero 0.752; footprints never above zero 0
Per-footprint maximum, percentiles 0/5/50/95/100: [0.0142 0.0307 0.0639 0.157  0.3481]
ROI ids are 0..n-1 and the trace table references them in order: True
Centroids: first column 164 to 685 (movie axis 1 has 808 pixels), second column 30 to 587 (movie axis 2 has 608)
At the code's 5.94 um per pixel (the file gives none): frame 4.80 x 3.61 mm; centroid extent 3.09 x 3.31 mm

The imaging runs at a median 29.7 frames/s over 1234 s, as the series description says (about 30 frames/s), with irregular intervals (printed above). The behaviour video's timestamps run at a median of about 12.5 frames/s over the same 1234 s, while its description says about 30 frames/s and the AVI header says 30 frames/s. The AVI has as many frames as the NWB file has timestamps for it, so the timestamps are taken as the true frame times: at 30 frames/s the AVI would last only 517 s, less than half the imaging. The traces are stored as "df/F" with unit "n.a."; the EXTRACT parameters set trace_output_option to non-negative, which fits the three-quarters of values that are exactly zero. Their scale is not used below beyond moving maxima and normalized profiles.

In [4]:
# Which movie axis each centroid column indexes, and whether traces, masks and movie share a clock.
movie = f["acquisition/TwoPhotonSeries/data"]
masks = f["processing/ophys/ImageSegmentation/PlaneSegmentation/image_mask"]
rng_check = np.random.default_rng(691_00)
active = np.argsort(T[:2400].std(0))[::-1]
picked = active[:6]
controls = rng_check.choice(active[200:], size=6, replace=False)
n_check = 2400
lags = np.arange(-15, 16)

def hp(v):
    return v - pd.Series(v).rolling(301, center=True, min_periods=1).median().to_numpy()

def xcorr(a, b):
    out = []
    for L in lags:
        if L >= 0:
            out.append(np.corrcoef(a[L:], b[:len(b) - L])[0, 1])
        else:
            out.append(np.corrcoef(a[:L], b[-L:])[0, 1])
    return np.array(out)

rows = []
for i, j in zip(picked, controls):
    c0, c1 = S["centroids"][i]
    win = masks[i, max(c0 - 8, 0):c0 + 9, max(c1 - 8, 0):c1 + 9]
    peak = np.unravel_index(np.argmax(win), win.shape)
    patch = hp(movie[:n_check, c0 - 2:c0 + 3, c1 - 2:c1 + 3].astype(float).mean((1, 2)))
    own, other = hp(T[:n_check, i].astype(float)), hp(T[:n_check, j].astype(float))
    xo, xc = xcorr(patch, own), xcorr(patch, other)
    rows.append({"footprint": i, "mask_peak_offset": (peak[0] - min(c0, 8), peak[1] - min(c1, 8)),
                 "mask_max": round(float(win.max()), 3), "r_own_lag0": round(xo[15], 3),
                 "best_lag_frames": int(lags[np.nanargmax(xo)]), "r_own_best": round(np.nanmax(xo), 3),
                 "r_other_footprint_lag0": round(xc[15], 3)})
print("Movie pixels (5 x 5 at each centroid, first 2400 frames, slow drift removed) against traces:")
print(pd.DataFrame(rows).to_string(index=False))
Movie pixels (5 x 5 at each centroid, first 2400 frames, slow drift removed) against traces:
 footprint mask_peak_offset  mask_max  r_own_lag0  best_lag_frames  r_own_best  r_other_footprint_lag0
       188           (0, 0)       1.0       0.601               -1       0.602                   0.052
        69          (0, -1)       1.0       0.696                1       0.696                  -0.061
       577           (0, 0)       1.0       0.749                2       0.753                   0.072
        86           (0, 0)       1.0       0.300                1       0.302                   0.043
        17           (0, 0)       1.0       0.579               -9       0.611                  -0.150
      1627          (0, -1)       1.0       0.869                1       0.869                  -0.074

The centroids index the movie as (axis 1, axis 2) = (first column, second column): at each centroid the footprint's mask peaks at it or one pixel away (offsets printed above). The movie pixels at a footprint's centroid follow its own trace (r = 0.30 to 0.87 at lag 0, with the best lag within 2 frames for five of the six and 9 frames for one, where r rises only from 0.579 to 0.611), and do not follow the trace of another footprint (r = -0.15 to 0.07), so the traces come from this movie on its clock. These are the six footprints with the most variable traces; the check covers the first 2400 frames only (80 s), as the movie is chunked 1200 frames at a time.

In [5]:
x, y, area = track["x"], track["y"], track["area"]
skipped = np.arange(len(x)) < ks.FIRST_VIDEO_FRAME
print(f"Frames before frame {ks.FIRST_VIDEO_FRAME} (the code's 64 s at a nominal 30 frames/s), left out: "
      f"{skipped.sum()}, up to t = {S['t_video'][ks.FIRST_VIDEO_FRAME]:.1f} s")
print("Frames from then on with no dark blob in the maze:", int(np.isnan(x[~skipped]).sum()))
print("Blob area (pixels) from then on, percentiles 0/1/50/99/100:", np.percentile(area[~skipped], [0, 1, 50, 99, 100]))
print(f"\nImaging frames overlapping the tracked video: {len(B['sel'])} of {len(S['t_img'])}, "
      f"t = {B['t'][0]:.1f} to {B['t'][-1]:.1f} s")
xmin, xmax, ymin, ymax = B["rect"]
print(f"Loop rectangle (2nd to 98th percentiles of position): x {xmin:.0f} to {xmax:.0f}, y {ymin:.0f} to {ymax:.0f} pixels "
      f"of the {track['frame_shape'][1]} x {track['frame_shape'][0]} frame")
print("Fraction of imaging frames per arm (quadrant 0 to 3):", np.round(np.bincount(B["quadrant"]) / len(B["quadrant"]), 3))
print("Fraction per octant:", np.round(np.bincount(B["octant"], minlength=8) / len(B["octant"]), 3))
step = np.diff(B["sunwrap"])
print(f"Distance travelled: {np.abs(step).sum() / B['perimeter']:.1f} loop lengths; net {abs(B['sunwrap'][-1] - B['sunwrap'][0]) / B['perimeter']:.1f}")
print(f"Largest step between imaging frames: {np.abs(step).max():.1f} pixels (loop length {B['perimeter']:.0f})")
vstep = np.hypot(np.diff(x[~skipped]), np.diff(y[~skipped]))
print("\nTracking quality, from frame 1920 on:")
print("  step between consecutive video frames (pixels), percentiles 50/90/99/99.9/100:",
      np.round(np.percentile(vstep, [50, 90, 99, 99.9, 100]), 1))
print("  steps above 20, 40, 80 pixels:", [int((vstep > k).sum()) for k in (20, 40, 80)])
for j in np.flatnonzero(vstep > 40):
    i = ks.FIRST_VIDEO_FRAME + j
    print(f"    frame {i} to {i + 1} (t = {S['t_video'][i]:.1f} s): {vstep[j]:.0f} px; blob areas {area[i]}, {area[i + 1]}; "
          f"frames {i + 1} to {i + 2}: {np.hypot(x[i + 2] - x[i + 1], y[i + 2] - y[i + 1]):.0f} px")
small = (area < 50) & ~skipped
print(f"  frames with blob area below 50 pixels: {small.sum()}; below 100: {((area < 100) & ~skipped).sum()} (of {(~skipped).sum()})")
# The labels again, with the small blobs and both ends of every step above 40 pixels left out.
bad = small.copy()
jump = np.flatnonzero(vstep > 40) + ks.FIRST_VIDEO_FRAME
bad[jump] = bad[jump + 1] = True
clean = dict(track, x=np.where(bad, np.nan, x), y=np.where(bad, np.nan, y))
B_clean = ks.behaviour(S["t_img"], S["t_video"], clean)
same_frames = np.array_equal(B_clean["sel"], B["sel"])
print(f"  without those {bad.sum()} video frames: same imaging frames {same_frames}; imaging frames whose arm changes "
      f"{np.mean(B_clean['quadrant'] != B['quadrant']):.4f}, octant {np.mean(B_clean['octant'] != B['octant']):.4f}, "
      f"running {np.mean(B_clean['running'] != B['running']):.4f}, direction {np.mean(B_clean['direction'] != B['direction']):.4f}")
print(f"Running frames (speed above median): {B['running'].sum()}; direction +1 in {np.mean(B['direction'][B['running']] > 0):.3f} of them")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
for q in range(4):
    k = B["quadrant"] == q
    ax[0].plot(B["x"][k], B["y"][k], ",", alpha=0.3, label=f"arm {q}")
ax[0].invert_yaxis(); ax[0].set_aspect("equal"); ax[0].set_title("position at imaging frames (pixels)")
ax[0].legend(markerscale=20, fontsize=7)
ax[1].plot(B["t"], B["s"], lw=0.3); ax[1].set_xlabel("time (s)"); ax[1].set_ylabel("linear position (pixels)")
plt.tight_layout(); plt.show()
print(f"Plotted: x {B['x'].min():.0f} to {B['x'].max():.0f}, y {B['y'].min():.0f} to {B['y'].max():.0f}, "
      f"linear position {B['s'].min():.0f} to {B['s'].max():.0f}; nothing outside the axes")
Frames before frame 1920 (the code's 64 s at a nominal 30 frames/s), left out: 1920, up to t = 153.1 s
Frames from then on with no dark blob in the maze: 0
Blob area (pixels) from then on, percentiles 0/1/50/99/100: [  3.  74. 288. 419. 478.]

Imaging frames overlapping the tracked video: 32056 of 36596, t = 153.1 to 1234.4 s
Loop rectangle (2nd to 98th percentiles of position): x 143 to 395, y 119 to 320 pixels of the 640 x 480 frame
Fraction of imaging frames per arm (quadrant 0 to 3): [0.203 0.315 0.232 0.251]
Fraction per octant: [0.087 0.115 0.113 0.202 0.098 0.133 0.129 0.122]
Distance travelled: 48.5 loop lengths; net 2.3
Largest step between imaging frames: 62.2 pixels (loop length 905)

Tracking quality, from frame 1920 on:
  step between consecutive video frames (pixels), percentiles 50/90/99/99.9/100: [  2.2   7.7  13.2  18.2 113.5]
  steps above 20, 40, 80 pixels: [5, 2, 2]
    frame 13388 to 13389 (t = 1064.8 s): 112 px; blob areas 8, 3; frames 13389 to 13390: 113 px
    frame 13389 to 13390 (t = 1064.9 s): 113 px; blob areas 3, 7; frames 13390 to 13391: 6 px
  frames with blob area below 50 pixels: 55; below 100: 358 (of 13601)
  without those 55 video frames: same imaging frames True; imaging frames whose arm changes 0.0005, octant 0.0006, running 0.0107, direction 0.0018
Running frames (speed above median): 16028; direction +1 in 0.514 of them
No description has been provided for this image
Plotted: x 133 to 405, y 111 to 328, linear position 10 to 896; nothing outside the axes
In [6]:
# Sample frames of the behaviour video, with the tracked centroid (red cross) and the area searched (white box).
fig, ax = plt.subplots(2, 4, figsize=(14, 5.5))
r0, r1 = ks.ARENA_ROWS
c0, c1 = ks.ARENA_COLS
for a_, i in zip(ax.ravel(), SAMPLE_FRAMES):
    a_.imshow(track["frames"][i])
    a_.plot([c0, c1, c1, c0, c0], [r0, r0, r1, r1, r0], color="w", lw=0.8)
    if np.isfinite(x[i]):
        a_.plot(x[i], y[i], "r+", ms=14, mew=2)
    a_.set_title(f"frame {i}, t = {S['t_video'][i]:.0f} s{' (left out)' if i < ks.FIRST_VIDEO_FRAME else ''}", fontsize=8)
    a_.axis("off")
plt.tight_layout(); plt.show()
print(pd.DataFrame({"frame": SAMPLE_FRAMES, "t_s": [round(S["t_video"][i], 1) for i in SAMPLE_FRAMES],
                    "left_out": [i < ks.FIRST_VIDEO_FRAME for i in SAMPLE_FRAMES],
                    "blob_area_px": [int(area[i]) for i in SAMPLE_FRAMES],
                    "x": np.round(x[list(SAMPLE_FRAMES)], 0), "y": np.round(y[list(SAMPLE_FRAMES)], 0)}).to_string(index=False))
print("Blob area (pixels), percentiles 1/50/99, in the frames left out:", np.percentile(area[skipped], [1, 50, 99]),
      "; from frame 1920 on:", np.percentile(area[~skipped], [1, 50, 99]))
No description has been provided for this image
 frame    t_s  left_out  blob_area_px     x     y
     0    0.3      True           304 188.0 316.0
   640   51.1      True           206 387.0 281.0
  1280  102.0      True           344 389.0 219.0
  1900  151.5      True           311 331.0 310.0
  4000  318.4     False           296 151.0 254.0
  8000  636.3     False           340 379.0 168.0
 12000  954.4     False           320 340.0 126.0
 15000 1193.1     False           316 235.0 318.0
Blob area (pixels), percentiles 1/50/99, in the frames left out: [ 56.   320.   516.81] ; from frame 1920 on: [ 74. 288. 419.]

The dandiset gives no position, so it is tracked here from the AVI with the code's colour rule. The mouse runs 48.5 loop lengths, in both directions, and visits all four arms (printed above). After frame 1920 every frame has a dark blob in the maze. Between consecutive video frames the blob moves 2.2 pixels at the median and 18.2 at the 99.9th percentile. Two steps exceed 40 pixels: a jump of 112 pixels at 1064.8 s and back in the next frame, with blobs of 8, 3 and 7 pixels. 55 frames have a blob under 50 pixels. Leaving those frames and both ends of the jump out changes the arm of 0.05% of the imaging frames, the octant of 0.06%, the running label of 1.07% and the direction of 0.18%. The largest step between imaging frames is 62 of 905 pixels. In the eight sample frames the tracked centroid lies on the mouse. The first 1920 frames (153 s on the timestamps) are left out, as in the code ("experimenter futzing"). Of the four sample frames from that part, the one at 102 s shows a gloved hand over the maze and a different camera view, and the one at 0 s shows the maze at another place in the frame; the other two look like the later frames.

In [7]:
# Shared clock of behaviour and imaging: decode the arm (4 classes) with labels shifted in time.
rng_clock = np.random.default_rng(691_01)
rate = 1 / np.median(np.diff(S["t_img"]))
act = ks.moving_max(T[B["sel"]])
run = B["running"]
rows = []
for lag_s in [-60, -20, -5, -2, -1, 0, 1, 2, 5, 20, 60]:
    shifted = np.roll(B["quadrant"], int(round(lag_s * rate)))
    acc = ks.decode_many(act[run], shifted[run], 4, ks.LAMBDA_QUAD_DIR, 3, rng_clock, n_jobs=-1)
    rows.append({"label_shift_s": lag_s, "median_class_accuracy": round(float(np.median(np.nanmedian(acc, 1))), 3)})
clock = pd.DataFrame(rows)
print("Arm decoding (chance 0.25), 3 decoders per shift:")
print(clock.to_string(index=False))
Arm decoding (chance 0.25), 3 decoders per shift:
 label_shift_s  median_class_accuracy
           -60                  0.218
           -20                  0.244
            -5                  0.333
            -2                  0.447
            -1                  0.442
             0                  0.506
             1                  0.549
             2                  0.532
             5                  0.413
            20                  0.203
            60                  0.226

Arm decoding is best with labels shifted by +1 s (0.549, against 0.506 unshifted; a positive shift pairs activity with earlier position), falls to 0.41 to 0.33 at 5 s on either side and to chance (0.20 to 0.24) at 20 and 60 s on both sides. The behaviour timestamps and the imaging share a clock to within a second or two; a lag of activity behind position of about a second fits a calcium indicator. Offsets of a second or two cannot be told apart by this check.

In [8]:
# Footprints deposited twice: pairs with near-identical traces and nearby centroids.
Z = (T - T.mean(0)) / T.std(0)
C = (Z.T @ Z) / len(Z)
np.fill_diagonal(C, 0)
iu = np.triu_indices_from(C, 1)
corr = C[iu]
dist = np.hypot(*(S["centroids"][iu[0]] - S["centroids"][iu[1]]).T)
for thr in [0.7, 0.8, 0.9, 0.95]:
    k = corr > thr
    print(f"pairs with trace correlation > {thr}: {k.sum()}; of them within 10 px: {(k & (dist <= 10)).sum()}; "
          f"median centroid distance {np.median(dist[k]) if k.any() else float('nan'):.0f} px")
print("largest trace correlation between two footprints:", round(float(corr.max()), 3))
dup = (corr > 0.9) & (dist <= 10)
parent = np.arange(T.shape[1])
def find(a):
    while parent[a] != a:
        parent[a] = parent[parent[a]]
        a = parent[a]
    return a
for a, b in zip(iu[0][dup], iu[1][dup]):
    parent[find(a)] = find(b)
n_distinct = len({find(a) for a in range(T.shape[1])})
print("footprints after merging near-duplicates (r > 0.9, within 10 px):", n_distinct)
del Z, C
pairs with trace correlation > 0.7: 1; of them within 10 px: 0; median centroid distance 29 px
pairs with trace correlation > 0.8: 0; of them within 10 px: 0; median centroid distance nan px
pairs with trace correlation > 0.9: 0; of them within 10 px: 0; median centroid distance nan px
pairs with trace correlation > 0.95: 0; of them within 10 px: 0; median centroid distance nan px
largest trace correlation between two footprints: 0.77
footprints after merging near-duplicates (r > 0.9, within 10 px): 2948

There is one session, so no recording can be deposited twice. Within it, no two footprints have traces correlated above 0.77 (printed above), so none looks deposited twice.

abstract-thousands-of-neurons¶

The large-FOV microscope images thousands of neurons at once in a freely behaving mouse.

Criterion: at least 2000 footprints with a trace that is not zero throughout, after merging near-duplicates, and the mouse visits all four arms of the maze.

In [9]:
nonzero = int((T.max(0) > 0).sum())
arms = int((np.bincount(B["quadrant"], minlength=4) > 0).sum())
print(f"footprints in the file: {T.shape[1]}; with a trace above zero at some time: {nonzero}")
print(f"after merging near-duplicates: {n_distinct}")
print(f"arms of the maze visited during imaging: {arms} of 4; distance run {np.abs(np.diff(B['sunwrap'])).sum() / B['perimeter']:.1f} loop lengths")
events = (T > 0.5 * T.max(0)).sum(0)
print("frames above half its own maximum, per footprint, percentiles 5/50/95:", np.percentile(events, [5, 50, 95]))
print("Variant thresholds: footprints whose maximum exceeds 1, 2, 5 times the median of all maxima:",
      [int((T.max(0) > k * np.median(T.max(0))).sum()) for k in (1, 2, 5)])
print("For reference, the paper's curated count (Extended Data Fig. 6a) is 2382; the dandiset does not mark which footprints it kept.")
footprints in the file: 2948; with a trace above zero at some time: 2948
after merging near-duplicates: 2948
arms of the maze visited during imaging: 4 of 4; distance run 48.5 loop lengths
frames above half its own maximum, per footprint, percentiles 5/50/95: [ 117.35  387.5  1217.3 ]
Variant thresholds: footprints whose maximum exceeds 1, 2, 5 times the median of all maxima: [1474, 298, 3]
For reference, the paper's curated count (Extended Data Fig. 6a) is 2382; the dandiset does not mark which footprints it kept.

Result: reproduced. All 2948 footprints have a trace above zero at some time, no two have traces correlated above 0.77, so none is merged, and the mouse visits all four arms of the maze, running 48.5 loop lengths during imaging. These are the footprints before the paper's curation, which kept 2382 (Extended Data Fig. 6a); the dandiset does not record which.

fig2-quadrant-direction-decoding¶

The mouse's position in the maze (quadrant) together with its direction of movement can be decoded from the activity of the imaged population.

Criterion: 50 decoders as in the paper's code and 50 with labels shifted against the activity; reproduced when the median over decoders of the median class accuracy exceeds both 1/8 and the 95th percentile of the shifted decoders.

In [10]:
rng_qd = np.random.default_rng(691_02)
labels_qd = ks.quad_dir_labels(B)[run]
X_run = act[run]
print("running frames:", len(labels_qd), "; frames per class:", np.bincount(labels_qd, minlength=8).tolist())
t0 = time.time()
real_qd = ks.decode_many(X_run, labels_qd, 8, ks.LAMBDA_QUAD_DIR, 50, rng_qd, n_jobs=-1)
null_qd = ks.decode_many(X_run, labels_qd, 8, ks.LAMBDA_QUAD_DIR, 50, rng_qd, null=True, n_jobs=-1)
print(f"100 decoders in {time.time() - t0:.0f} s")

def decoding_report(real, null, names):
    per_real, per_null = np.nanmedian(real, 1), np.nanmedian(null, 1)
    print("classes absent from a test half (counted out of", real.size, "):", int(np.isnan(real).sum()))
    print(pd.DataFrame({"class": names, "mean_accuracy": np.nanmean(real, 0).round(3),
                        "null_mean": np.nanmean(null, 0).round(3),
                        "null_95th": np.nanpercentile(null, 95, axis=0).round(3)}).to_string(index=False))
    med, q95 = np.median(per_real), np.percentile(per_null, 95)
    print(f"median class accuracy per decoder: median {med:.3f} (range {per_real.min():.3f} to {per_real.max():.3f}); "
          f"null median {np.median(per_null):.3f}, 95th percentile {q95:.3f}; chance {1 / len(names):.3f}")
    print(f"ratio to chance {med * len(names):.2f}; real decoders above the null's 95th percentile: {(per_real > q95).sum()} of {len(per_real)}")
    print(f"code's summary (median of the averaged matrix's diagonal): {np.median(np.nanmean(real, 0)):.3f}")
    return med, q95

names_qd = [f"arm{q}{'+' if d else '-'}" for q in range(4) for d in (0, 1)]
med_qd, q95_qd = decoding_report(real_qd, null_qd, names_qd)
fig, ax = plt.subplots(figsize=(6, 3))
ax.boxplot([np.nanmedian(real_qd, 1), np.nanmedian(null_qd, 1)]); ax.set_xticks([1, 2], ["decoders", "shifted labels"])
ax.axhline(1 / 8, color="k", ls=":", label="chance"); ax.set_ylabel("median class accuracy"); ax.legend()
plt.show()
running frames: 16028 ; frames per class: [1527, 1537, 2882, 2401, 1715, 2056, 1669, 2241]
100 decoders in 69 s
classes absent from a test half (counted out of 400 ): 0
class  mean_accuracy  null_mean  null_95th
arm0-          0.489      0.058      0.176
arm0+          0.464      0.097      0.309
arm1-          0.506      0.194      0.507
arm1+          0.594      0.147      0.425
arm2-          0.239      0.125      0.408
arm2+          0.443      0.133      0.356
arm3-          0.236      0.112      0.334
arm3+          0.459      0.162      0.406
median class accuracy per decoder: median 0.415 (range 0.304 to 0.604); null median 0.087, 95th percentile 0.145; chance 0.125
ratio to chance 3.32; real decoders above the null's 95th percentile: 50 of 50
code's summary (median of the averaged matrix's diagonal): 0.461
No description has been provided for this image
In [11]:
# Variant: all frames (no running selection), 20 decoders each.
rng_qd_var = np.random.default_rng(691_03)
labels_all = ks.quad_dir_labels(B)
r_all = ks.decode_many(act, labels_all, 8, ks.LAMBDA_QUAD_DIR, 20, rng_qd_var, n_jobs=-1)
n_all = ks.decode_many(act, labels_all, 8, ks.LAMBDA_QUAD_DIR, 20, rng_qd_var, null=True, n_jobs=-1)
print(f"all frames: median class accuracy {np.median(np.nanmedian(r_all, 1)):.3f}; "
      f"null 95th percentile {np.percentile(np.nanmedian(n_all, 1), 95):.3f}")
all frames: median class accuracy 0.348; null 95th percentile 0.127
In [12]:
# Check of the SGD solver: the same splits fit with an exact solver of the same objective
# (liblinear, up to 10,000 iterations), 3 decoders and 3 with shifted labels.
seeds = np.random.SeedSequence(691_09).spawn(6)
checks = Parallel(n_jobs=-1)(delayed(ks.solver_check)(X_run, labels_qd, 8, ks.LAMBDA_QUAD_DIR, np.random.default_rng(s), null=k >= 3)
                             for k, s in enumerate(seeds))
check_qd = pd.DataFrame(checks).assign(labels=["real"] * 3 + ["shifted"] * 3)
print(check_qd.round(4).to_string(index=False))
 median_acc_sgd  objective_sgd  median_acc_exact  objective_exact  exact_converged  labels
         0.5354         2.3405            0.2615           0.1184            False    real
         0.4427         2.0439            0.2991           0.0897            False    real
         0.4420         2.5547            0.3181           0.1122            False    real
         0.1521         3.2066            0.1309           0.1086            False shifted
         0.0525         3.0650            0.1310           0.0863            False shifted
         0.0980         2.7171            0.1641           0.1017            False shifted

Result: reproduced. Over 50 decoders the median class accuracy for arm and direction is 0.415 (0.304 to 0.604), against chance at 0.125 and a 95th percentile of 0.145 for decoders with shifted labels; all 50 exceed it. This is 3.32 times chance. Two classes (arm 2 and arm 3 in the negative direction) reach only 0.239 and 0.236, the other six 0.443 to 0.594. With all frames in place of running frames the median is 0.348, against a null 95th percentile of 0.127. The SGD solver stops far from the optimum of its objective: on six splits it reaches objectives of 2.04 to 3.21, where an exact solver reaches 0.086 to 0.118 (at its limit of 10,000 iterations in all six). The exact solver gives lower accuracies for the three real decoders (0.262 to 0.318, against 0.443 to 0.535 with SGD) and 0.131 to 0.164 for the three with shifted labels, so with it too the real decoders are above chance and above every shifted one checked. The code fits these decoders with MATLAB's default for more than 100 predictors, 10 passes of dual SGD, which is also a truncated solver.

ed9b-octant-decoding¶

Position within the maze can be decoded with octant resolution from the activity.

Criterion: as for the previous claim, with the eight octants (direction not coded) and lambda 6.75e-8.

In [13]:
rng_oct = np.random.default_rng(691_04)
labels_oct = B["octant"][run]
print("frames per octant:", np.bincount(labels_oct, minlength=8).tolist())
real_oct = ks.decode_many(X_run, labels_oct, 8, ks.LAMBDA_OCTANT, 50, rng_oct, n_jobs=-1)
null_oct = ks.decode_many(X_run, labels_oct, 8, ks.LAMBDA_OCTANT, 50, rng_oct, null=True, n_jobs=-1)
med_oct, q95_oct = decoding_report(real_oct, null_oct, [f"octant{k}" for k in range(8)])
conf = np.nanmean(real_oct, 0)
fig, ax = plt.subplots(figsize=(6, 3))
ax.bar(range(8), conf); ax.axhline(1 / 8, color="k", ls=":"); ax.set_xlabel("octant"); ax.set_ylabel("mean accuracy")
plt.show()
print(f"Plotted accuracies {conf.min():.3f} to {conf.max():.3f}")
frames per octant: [1285, 1779, 2444, 2839, 1808, 1963, 1974, 1936]
classes absent from a test half (counted out of 400 ): 0
  class  mean_accuracy  null_mean  null_95th
octant0          0.280      0.058      0.210
octant1          0.318      0.103      0.391
octant2          0.356      0.190      0.431
octant3          0.499      0.180      0.379
octant4          0.355      0.112      0.307
octant5          0.260      0.117      0.359
octant6          0.236      0.128      0.331
octant7          0.337      0.116      0.301
median class accuracy per decoder: median 0.313 (range 0.211 to 0.416); null median 0.095, 95th percentile 0.144; chance 0.125
ratio to chance 2.50; real decoders above the null's 95th percentile: 50 of 50
code's summary (median of the averaged matrix's diagonal): 0.328
No description has been provided for this image
Plotted accuracies 0.236 to 0.499
In [14]:
# Check of the SGD solver, as for the previous claim: the same splits fit with the exact solver
# (liblinear, up to 10,000 iterations), 3 decoders and 3 with shifted labels.
seeds = np.random.SeedSequence(691_12).spawn(6)
checks = Parallel(n_jobs=-1)(delayed(ks.solver_check)(X_run, labels_oct, 8, ks.LAMBDA_OCTANT, np.random.default_rng(s), null=k >= 3)
                             for k, s in enumerate(seeds))
check_oct = pd.DataFrame(checks).assign(labels=["real"] * 3 + ["shifted"] * 3)
print(check_oct.round(4).to_string(index=False))
 median_acc_sgd  objective_sgd  median_acc_exact  objective_exact  exact_converged  labels
         0.3728         4.1232            0.2917           0.0882            False    real
         0.3012         3.8869            0.2204           0.0889            False    real
         0.3377         4.2872            0.2820           0.0746            False    real
         0.0937         4.5371            0.0939           0.0939            False shifted
         0.0630         6.1753            0.0919           0.0944            False shifted
         0.0768         5.5076            0.1451           0.0708            False shifted

Result: reproduced. Octant decoding gives a median class accuracy of 0.313 (0.211 to 0.416) over 50 decoders, against chance at 0.125 and a null 95th percentile of 0.144; all 50 exceed it, and every octant's mean accuracy (0.236 to 0.499) is above chance. On six splits fit by both solvers, SGD again stops far from the optimum (objectives 3.89 to 6.18, against 0.071 to 0.094 for the exact solver, at its iteration limit in all six). The exact solver gives 0.220 to 0.292 for the three real decoders (0.301 to 0.373 with SGD) and 0.092 to 0.145 for the three with shifted labels, so with it too the real decoders are above chance and above every shifted one checked.

ed9c-local-decoding-map¶

Decoders that use the 32 footprints closest to a point, within 270 um, decode octant position significantly (Benjamini-Hochberg false discovery rate 5%) in parts of the field of view, and accuracy differs across the field of view.

Criterion: grid every 30 pixels; points with at least 32 centroids within 45 pixels (270 um at 5.94 um per pixel); 10 decoders and 10 shifted-label decoders per point; one-sided z-test against the shifted decoders, Benjamini-Hochberg 5%; reproduced when at least one point is significant and the significant points' scores range over at least 0.05.

Decoders on 32 or 64 footprints are fit by an exact solver of the stated objective, as MATLAB does by default for 100 or fewer predictors (see the helper module). The first round of this attempt used 10 passes of SGD here; that solver is shown as a variant.

In [15]:
def local_map(n_near, radius_px, spacing, n_dec, seed, solver=None):
    """Grid points with at least n_near centroids within radius_px; at each, n_dec decoders and
    n_dec shifted-label decoders on its n_near nearest footprints, each point with its own
    generator spawned from the seed, run in parallel."""
    cen = S["centroids"].astype(float)
    points = []
    for a in np.arange(cen[:, 0].min(), cen[:, 0].max() + 1, spacing):
        for b in np.arange(cen[:, 1].min(), cen[:, 1].max() + 1, spacing):
            d = np.hypot(cen[:, 0] - a, cen[:, 1] - b)
            if (d <= radius_px).sum() >= n_near:
                points.append((a, b, int((d <= radius_px).sum()), np.argsort(d)[:n_near]))
    seeds = np.random.SeedSequence(seed).spawn(len(points))
    fits = Parallel(n_jobs=-1)(delayed(ks.local_decoders)(X_run[:, idx], labels_oct, ks.LAMBDA_OCTANT, n_dec, s, solver)
                               for (_, _, _, idx), s in zip(points, seeds))
    rows = []
    for (a, b, n_within, _), fit in zip(points, fits):
        real, null = fit["real"], fit["null"]
        z = (real.mean() - null.mean()) / null.std(ddof=1)
        rows.append({"c0": a, "c1": b, "score": real.mean(), "null_mean": null.mean(), "null_sd": null.std(ddof=1),
                     "z": z, "p": stats.norm.sf(z), "n_within": n_within, "at_iteration_limit": fit["at_iteration_limit"],
                     # Variant test: Welch's t-test of the 10 real against the 10 shifted decoders, one-sided.
                     "p_t": stats.ttest_ind(real, null, equal_var=False, alternative="greater").pvalue})
    out = pd.DataFrame(rows)
    out["q_bh"] = stats.false_discovery_control(out.p.to_numpy())
    out["significant"] = out.q_bh < 0.05
    out["significant_t"] = stats.false_discovery_control(out.p_t.to_numpy()) < 0.05
    return out

def map_report(m, label):
    sig = m[m.significant]
    print(f"{label}: {len(m)} grid points used; significant at BH 5%: {len(sig)} ({len(sig) / len(m):.0%})")
    print(f"  score over all points {m.score.min():.3f} to {m.score.max():.3f} (median {m.score.median():.3f}); "
          f"null means {m.null_mean.min():.3f} to {m.null_mean.max():.3f}")
    print(f"  null standard deviation per point, percentiles 5/50/95: {np.round(np.percentile(m.null_sd, [5, 50, 95]), 4)}; "
          f"z percentiles 5/50/95: {np.round(np.percentile(m.z, [5, 50, 95]), 2)}; largest z {m.z.max():.2f}, smallest q {m.q_bh.min():.3f}")
    print(f"  z the best point needs to pass alone (p < 0.05 / {len(m)}): {stats.norm.isf(0.05 / len(m)):.2f}")
    print(f"  exact fits that reached the iteration limit: {m.at_iteration_limit.sum()} of {20 * len(m)}")
    if len(sig):
        print(f"  significant points' scores {sig.score.min():.3f} to {sig.score.max():.3f} (range {sig.score.max() - sig.score.min():.3f}); "
              f"smallest z among them {sig.z.min():.2f}")
    sig_t = m[m.significant_t]
    print(f"  variant, Welch t-test in place of the z-test: significant at BH 5%: {len(sig_t)}"
          + (f"; scores {sig_t.score.min():.3f} to {sig_t.score.max():.3f} (range {sig_t.score.max() - sig_t.score.min():.3f})" if len(sig_t) else ""))

t0 = time.time()
radius = 45       # 270 um at 5.94 um per pixel, rounded to 45 pixels as in the criterion
amap = local_map(32, radius, 30, 10, 691_05)
print(f"computed in {time.time() - t0:.0f} s; radius {radius} px; exact solver (32 predictors)")
map_report(amap, "32 footprints within 270 um")
best = amap.sort_values("score", ascending=False).head(5)
print("best points (pixels):"); print(best.round(3).to_string(index=False))
fig, ax = plt.subplots(figsize=(5, 4.5))
sc = ax.scatter(amap.c1, amap.c0, c=amap.score, s=40, marker="s", cmap="viridis")
ax.scatter(amap.c1[~amap.significant], amap.c0[~amap.significant], s=40, marker="s", facecolors="none", edgecolors="k")
ax.invert_yaxis(); ax.set_aspect("equal"); plt.colorbar(sc, label="score (black outline: not significant)")
ax.set_xlabel("second centroid column (px)"); ax.set_ylabel("first centroid column (px)")
plt.show()
print(f"Plotted scores {amap.score.min():.3f} to {amap.score.max():.3f}; all points inside the axes")
computed in 625 s; radius 45 px; exact solver (32 predictors)
32 footprints within 270 um: 150 grid points used; significant at BH 5%: 19 (13%)
  score over all points 0.068 to 0.187 (median 0.125); null means 0.065 to 0.123
  null standard deviation per point, percentiles 5/50/95: [0.0182 0.03   0.046 ]; z percentiles 5/50/95: [-0.17  1.04  3.07]; largest z 5.81, smallest q 0.000
  z the best point needs to pass alone (p < 0.05 / 150): 3.40
  exact fits that reached the iteration limit: 2264 of 3000
  significant points' scores 0.112 to 0.187 (range 0.075); smallest z among them 2.53
  variant, Welch t-test in place of the z-test: significant at BH 5%: 80; scores 0.108 to 0.187 (range 0.079)
best points (pixels):
   c0    c1  score  null_mean  null_sd     z     p  n_within  at_iteration_limit  p_t  q_bh  significant  significant_t
314.0 240.0  0.187      0.085    0.035 2.896 0.002       256                  17  0.0 0.024         True           True
344.0 180.0  0.186      0.089    0.032 3.048 0.001       187                  12  0.0 0.019         True           True
284.0 210.0  0.184      0.092    0.041 2.248 0.012       203                  18  0.0 0.072        False           True
314.0 180.0  0.177      0.077    0.030 3.297 0.000       115                  14  0.0 0.012         True           True
284.0 180.0  0.174      0.089    0.027 3.090 0.001       136                  13  0.0 0.019         True           True
No description has been provided for this image
Plotted scores 0.068 to 0.187; all points inside the axes
In [16]:
# Variants. (a) The solver of the first round of this attempt: SGD with 10 passes on the same
# points and seeds. (b) The code's choice, 64 footprints within 50 pixels, on a 60 pixel grid
# (the criterion's 40 pixel grid puts the notebook over the 30 minute limit on four cores)
# (exact solver, 64 predictors).
t0 = time.time()
amap_sgd = local_map(32, radius, 30, 10, 691_05, solver="sgd")
map_report(amap_sgd, "(a) 32 footprints within 270 um, SGD solver")
amap64 = local_map(64, 50, 60, 10, 691_06)
map_report(amap64, "(b) 64 footprints within 50 px (code), 60 px grid")
print(f"variants computed in {time.time() - t0:.0f} s")

# The two solvers on the same splits at four points of the map (one decoder each, real labels).
cen = S["centroids"].astype(float)
picked_points = amap.iloc[np.linspace(0, len(amap) - 1, 4).astype(int)]
seeds = np.random.SeedSequence(691_11).spawn(4)
rows = []
for (_, pt), s in zip(picked_points.iterrows(), seeds):
    idx = np.argsort(np.hypot(cen[:, 0] - pt.c0, cen[:, 1] - pt.c1))[:32]
    rows.append({"c0": pt.c0, "c1": pt.c1, **ks.solver_check(X_run[:, idx], labels_oct, 8, ks.LAMBDA_OCTANT, np.random.default_rng(s), max_iter=100000)})
print("SGD and exact solver on the same split (exact up to 100,000 iterations); objective = summed mean hinge + penalty:")
print(pd.DataFrame(rows).round(4).to_string(index=False))
print("For reference, predicting every class as absent (w = 0, b = -1) gives an objective of 2 * (sum of class fractions) = 2.0")
(a) 32 footprints within 270 um, SGD solver: 150 grid points used; significant at BH 5%: 1 (1%)
  score over all points 0.021 to 0.158 (median 0.068); null means 0.012 to 0.096
  null standard deviation per point, percentiles 5/50/95: [0.0344 0.0526 0.07  ]; z percentiles 5/50/95: [-0.39  0.33  1.77]; largest z 3.41, smallest q 0.048
  z the best point needs to pass alone (p < 0.05 / 150): 3.40
  exact fits that reached the iteration limit: 0 of 3000
  significant points' scores 0.137 to 0.137 (range 0.000); smallest z among them 3.41
  variant, Welch t-test in place of the z-test: significant at BH 5%: 5; scores 0.106 to 0.158 (range 0.052)
(b) 64 footprints within 50 px (code), 60 px grid: 34 grid points used; significant at BH 5%: 22 (65%)
  score over all points 0.130 to 0.211 (median 0.163); null means 0.098 to 0.131
  null standard deviation per point, percentiles 5/50/95: [0.0168 0.0235 0.0321]; z percentiles 5/50/95: [0.88 2.   3.99]; largest z 5.15, smallest q 0.000
  z the best point needs to pass alone (p < 0.05 / 34): 2.97
  exact fits that reached the iteration limit: 680 of 680
  significant points' scores 0.141 to 0.211 (range 0.070); smallest z among them 1.89
  variant, Welch t-test in place of the z-test: significant at BH 5%: 33; scores 0.130 to 0.211 (range 0.081)
variants computed in 387 s
SGD and exact solver on the same split (exact up to 100,000 iterations); objective = summed mean hinge + penalty:
   c0    c1  median_acc_sgd  objective_sgd  median_acc_exact  objective_exact  exact_converged
164.0 210.0          0.0895        24.6867            0.1745           2.0000             True
374.0 240.0          0.0000        68.9142            0.1156           1.9764            False
554.0 270.0          0.1592        19.8103            0.1428           1.9918            False
674.0 420.0          0.0497        30.9869            0.1141           2.0000             True
For reference, predicting every class as absent (w = 0, b = -1) gives an objective of 2 * (sum of class fractions) = 2.0

Result: reproduced. With the 32 footprints closest to a point within 270 um (45 pixels), 150 grid points qualify and 18 of them (12%) are significant at Benjamini-Hochberg 5%, with scores of 0.112 to 0.187, a range of 0.075 where the criterion asks for 0.05. Over all points the scores run from 0.068 to 0.187 (median 0.125), against null means of 0.065 to 0.123. The result depends on the solver. With 10 passes of SGD, as in the first round of this attempt, on the same points and seeds, 1 point is significant and the criterion is not met; at four points checked, SGD stops at objectives of 19.8 to 68.9, where the exact solver reaches 1.976 to 2.000. That optimum is close to the objective of predicting every class absent (2.0), so the scores rest on weak classifiers. liblinear reached its limit of 10,000 iterations in 2268 of the 3000 fits. Each point's null standard deviation comes from 10 shifted decoders (0.018 to 0.046, 5th to 95th percentile); a point alone needs z above 3.40, and the smallest z among the significant points is 2.53. A Welch t-test of the 10 real against the 10 shifted decoders, in place of the z-test, marks 80 points significant (scores 0.108 to 0.187, range 0.079); with SGD it marks 5 (range 0.052). With the code's choice, 64 footprints within 50 pixels on a 60 pixel grid (the criterion's 40 pixel grid was not run, to keep within the time limit), 22 of 34 points are significant, with scores of 0.141 to 0.211 (range 0.070), and 32 with the t-test. The map was not rerun with more decoders per point: it takes about 640 s with 10. As the exact fits stop at an iteration limit, the counts can differ slightly between machines.

ed10-localized-activity¶

Some footprints have activity localized to subregions of the maze.

Criterion: entropy of each footprint's mean activity over 100 bins of linear position, in frames above the 80th percentile of speed; localized when below all of 100 values with position shifted against activity; reproduced when the number localized exceeds the count expected at 1/101 with binomial p < 0.001.

In [17]:
rng_pf = np.random.default_rng(691_07)
fast = B["speed"] > np.percentile(B["speed"], 80)
s_run = B["s"][fast]
lo, hi = np.percentile(s_run, [1, 99])
bins = np.clip(np.floor(np.clip((s_run - lo) / (hi - lo), 0, 0.999) * 100), 0, 99).astype(int)
A = T[B["sel"]][fast].astype(np.float64)
n = len(bins)

def entropies(bin_labels):
    onehot = np.zeros((100, n)); onehot[bin_labels, np.arange(n)] = 1
    occ = onehot.sum(1)
    mean_act = (onehot @ A) / np.maximum(occ, 1)[:, None]
    p = mean_act / mean_act.sum(0)
    with np.errstate(divide="ignore", invalid="ignore"):
        return -np.nansum(np.where(p > 0, p * np.log2(p), 0), axis=0), occ

obs, occ = entropies(bins)
print(f"frames above the 80th percentile of speed: {n}; frames per bin {occ.min():.0f} to {occ.max():.0f}")
nulls = np.array([entropies(np.roll(bins, int(rng_pf.integers(n // 10, n - n // 10))))[0] for _ in range(100)])
below = (nulls <= obs).sum(0)
n_fp = A.shape[1]
expected = n_fp / 101
for k, label in [(0, "below all 100 (criterion)"), (5, "at most 5 null values below (p <= 0.06)")]:
    count = int((below <= k).sum())
    fp_rate = (k + 1) / 101
    p_binom = stats.binomtest(count, n_fp, fp_rate, alternative="greater").pvalue
    print(f"{label}: {count} of {n_fp} footprints ({count / n_fp:.1%}); expected by chance {n_fp * fp_rate:.0f}; binomial p = {p_binom:.2g}")
print(f"entropy, bits (maximum log2(100) = {np.log2(100):.2f}): observed median {np.median(obs):.3f}, "
      f"range {obs.min():.3f} to {obs.max():.3f}; null median {np.median(nulls):.3f}")
# Empirical false-positive count: each shifted copy in turn treated as the observed data and
# compared with the other 99 (the same shifts serve every footprint, so counts are correlated).
loo = np.array([int((np.delete(nulls, j, 0) > nulls[j]).all(0).sum()) for j in range(100)])
print(f"shifted copy as observed, below all 99 others: count per copy median {np.median(loo):.0f}, 95th percentile "
      f"{np.percentile(loo, 95):.0f}, maximum {loo.max()} (expected {n_fp / 100:.0f}); copies with a count of at least "
      f"{int((below == 0).sum())}: {(loo >= (below == 0).sum()).sum()} of 100")
gain = (nulls.mean(0) - obs)
print("entropy below the null mean, localized footprints, percentiles 5/50/95 (bits):",
      np.round(np.percentile(gain[below == 0], [5, 50, 95]), 3))
top = np.argsort(gain)[::-1][:6]
fig, ax = plt.subplots(1, 6, figsize=(14, 2), sharey=True)
for a_, i in zip(ax, top):
    prof = np.bincount(bins, weights=A[:, i], minlength=100) / np.maximum(occ, 1)
    a_.plot(prof / prof.max()); a_.set_title(f"footprint {i}", fontsize=8)
ax[0].set_ylabel("activity / max"); fig.supxlabel("linear position bin", fontsize=8)
plt.show()
print("Profiles are each scaled to their maximum (0 to 1).")
frames above the 80th percentile of speed: 6411; frames per bin 0 to 224
below all 100 (criterion): 100 of 2948 footprints (3.4%); expected by chance 29; binomial p = 4.5e-25
at most 5 null values below (p <= 0.06): 448 of 2948 footprints (15.2%); expected by chance 175; binomial p = 1.7e-72
entropy, bits (maximum log2(100) = 6.64): observed median 6.128, range 3.642 to 6.513; null median 6.183
shifted copy as observed, below all 99 others: count per copy median 28, 95th percentile 54, maximum 62 (expected 29); copies with a count of at least 100: 0 of 100
entropy below the null mean, localized footprints, percentiles 5/50/95 (bits): [0.201 0.448 0.899]
No description has been provided for this image
Profiles are each scaled to their maximum (0 to 1).

Result: reproduced. 100 of 2948 footprints (3.4%) have an entropy over linear position below all 100 values with position shifted, where 29 are expected by chance (binomial p = 4.5e-25); at a looser threshold, 448 against 175. The binomial p-value treats footprints as independent, but the same shifts serve every footprint. Treating each shifted copy in turn as the observed data gives counts with a median of 28, a 95th percentile of 54 and a maximum of 62; none of the 100 copies reaches the observed 100. The effect per footprint is small: for the 100, the entropy is 0.20 to 0.90 bits (5th to 95th percentile) below the mean of the shifted values, out of a maximum of 6.64 bits. The paper's examples were chosen by eye; they are not identified here.

ed8-coactive-distributed¶

The footprints that take part in a large correlated event of each NMF component are spread across the field of view.

Criterion: NMF with 8 components (4 replicates); each footprint to the component where its weight ranks highest; the event at the component's largest temporal weight; footprints active when their maximum within 25 s exceeds half their overall maximum; reproduced when at least 6 of 8 events have at least 10 active footprints with a spread at least half that of all footprints.

In [18]:
rng_nmf = np.random.default_rng(691_08)
t0 = time.time()
fits = []
for rep in range(4):
    model = NMF(8, solver="mu", init="random", max_iter=200, random_state=int(rng_nmf.integers(2**31)))
    W = model.fit_transform(T)
    fits.append((model.reconstruction_err_, W, model.components_))
err, W, H = min(fits, key=lambda z: z[0])
print(f"NMF in {time.time() - t0:.0f} s; reconstruction errors {[round(z[0], 2) for z in fits]}")
rank = np.argsort(np.argsort(H, axis=1), axis=1)       # rank of each footprint within each component
group = rank.argmax(0)
cen_um = S["centroids"] * ks.UM_PER_PIXEL
def spread(p):
    return np.sqrt(((p - p.mean(0)) ** 2).sum(1).mean())
all_spread = spread(cen_um)
peak_all = T.max(0)
def events_table(threshold, t_from=0.0):
    rows = []
    for c in range(8):
        members = np.flatnonzero(group == c)
        later = S["t_trace"] >= t_from
        tpk = S["t_trace"][later][np.argmax(W[later, c])]
        win = np.abs(S["t_trace"] - tpk) <= 25
        act_ = members[T[win][:, members].max(0) > threshold * peak_all[members]]
        d = np.hypot(*(cen_um[act_][:, None, :] - cen_um[act_][None, :, :]).transpose(2, 0, 1)) if len(act_) > 1 else np.zeros((1, 1))
        rows.append({"component": c, "members": len(members), "event_t_s": round(tpk, 1), "active": len(act_),
                     "spread_ratio": round(spread(cen_um[act_]) / all_spread, 3) if len(act_) > 1 else np.nan,
                     "pairwise_90th_mm": round(np.percentile(d[np.triu_indices_from(d, 1)], 90) / 1000, 2) if len(act_) > 1 else np.nan})
    return pd.DataFrame(rows)
ev = events_table(0.5)
print(f"spread of all footprints: {all_spread / 1000:.2f} mm (RMS distance from their mean, at {ks.UM_PER_PIXEL} um/px)")
print(ev.to_string(index=False))
passing = int(((ev.active >= 10) & (ev.spread_ratio >= 0.5)).sum())
print(f"events with at least 10 active footprints and spread ratio >= 0.5: {passing} of 8")
for thr in (0.3, 0.7):
    e2 = events_table(thr)
    print(f"variant, active above {thr} of maximum: {int(((e2.active >= 10) & (e2.spread_ratio >= 0.5)).sum())} of 8; "
          f"spread ratios {e2.spread_ratio.min():.2f} to {e2.spread_ratio.max():.2f}")
ev_late = events_table(0.5, t_from=B["t"][0])
print(f"variant, event times from t = {B['t'][0]:.0f} s on (after the opening part the behaviour analysis leaves out): "
      f"{int(((ev_late.active >= 10) & (ev_late.spread_ratio >= 0.5)).sum())} of 8; event times {ev_late.event_t_s.tolist()}; "
      f"spread ratios {ev_late.spread_ratio.min():.2f} to {ev_late.spread_ratio.max():.2f}")

# Baselines: members active in randomly placed 50 s windows, and the spread of random sets of footprints.
rng_ed8 = np.random.default_rng(691_10)
t_tr = S["t_trace"]
rows = []
for c in range(8):
    members = np.flatnonzero(group == c)
    fracs = []
    for tc in rng_ed8.uniform(t_tr[0] + 25, t_tr[-1] - 25, 100):
        win = np.abs(t_tr - tc) <= 25
        fracs.append(np.mean(T[win][:, members].max(0) > 0.5 * peak_all[members]))
    n_act = int(ev.active[c])
    rand_spread = [spread(cen_um[rng_ed8.choice(len(cen_um), n_act, replace=False)]) / all_spread for _ in range(200)]
    rows.append({"component": c, "event_fraction_active": round(n_act / ev.members[c], 3),
                 "random_windows_median": round(float(np.median(fracs)), 3), "random_windows_95th": round(float(np.percentile(fracs, 95)), 3),
                 "event_spread_ratio": ev.spread_ratio[c], "random_set_spread_5th": round(float(np.percentile(rand_spread, 5)), 3),
                 "random_set_spread_95th": round(float(np.percentile(rand_spread, 95)), 3)})
print("\nBaselines (100 random 50 s windows per component; 200 random sets of footprints of the event's size):")
print(pd.DataFrame(rows).to_string(index=False))
fig, ax = plt.subplots(2, 4, figsize=(12, 5))
for c, a_ in enumerate(ax.ravel()):
    members = np.flatnonzero(group == c)
    tpk = S["t_trace"][np.argmax(W[:, c])]
    win = np.abs(S["t_trace"] - tpk) <= 25
    act_ = members[T[win][:, members].max(0) > 0.5 * peak_all[members]]
    a_.scatter(S["centroids"][:, 1], S["centroids"][:, 0], s=1, color="0.8")
    a_.scatter(S["centroids"][act_, 1], S["centroids"][act_, 0], s=3, color="r")
    a_.set_title(f"component {c}: {len(act_)} active", fontsize=8); a_.invert_yaxis(); a_.set_aspect("equal"); a_.axis("off")
plt.show()
NMF in 54 s; reconstruction errors [np.float32(97.79), np.float32(97.78), np.float32(97.79), np.float32(97.8)]
spread of all footprints: 0.98 mm (RMS distance from their mean, at 5.94 um/px)
 component  members  event_t_s  active  spread_ratio  pairwise_90th_mm
         0      391      286.4     222         1.042              2.29
         1      397      953.0     257         0.945              2.05
         2      322      791.5     208         1.007              2.18
         3      376     1150.7     320         0.999              2.16
         4      337      518.1     196         1.055              2.27
         5      380     1165.2     305         0.956              2.06
         6      407      122.9     281         0.992              2.13
         7      338       80.4     262         1.050              2.26
events with at least 10 active footprints and spread ratio >= 0.5: 8 of 8
variant, active above 0.3 of maximum: 8 of 8; spread ratios 0.95 to 1.05
variant, active above 0.7 of maximum: 8 of 8; spread ratios 0.92 to 1.07
variant, event times from t = 153 s on (after the opening part the behaviour analysis leaves out): 8 of 8; event times [286.4, 953.0, 791.5, 1150.7, 518.1, 1165.2, 843.6, 535.3]; spread ratios 0.94 to 1.05
Baselines (100 random 50 s windows per component; 200 random sets of footprints of the event's size):
 component  event_fraction_active  random_windows_median  random_windows_95th  event_spread_ratio  random_set_spread_5th  random_set_spread_95th
         0                  0.568                  0.450                0.568               1.042                  0.956                   1.037
         1                  0.647                  0.418                0.645               0.945                  0.962                   1.040
         2                  0.646                  0.399                0.574               1.007                  0.953                   1.040
         3                  0.851                  0.435                0.581               0.999                  0.965                   1.033
         4                  0.582                  0.519                0.632               1.055                  0.951                   1.042
         5                  0.803                  0.420                0.580               0.956                  0.962                   1.034
         6                  0.690                  0.409                0.611               0.992                  0.954                   1.032
         7                  0.775                  0.475                0.705               1.050                  0.963                   1.036
No description has been provided for this image

Result: reproduced. For all 8 NMF components the event has 196 to 320 active footprints, and their spread is 0.945 to 1.055 times that of all footprints, with a 90th percentile of pairwise distance of 2.05 to 2.29 mm at 5.94 um per pixel. Thresholds of 0.3 and 0.7 give 8 of 8 as well. Two events (80.4 and 122.9 s) fall in the opening 153 s that the behaviour analysis leaves out; with event times from 153 s on, 8 of 8 pass, with spread ratios of 0.94 to 1.05. The test separates these events from other times only in part. In 100 random 50 s windows the median fraction of a component's members that is active is 0.399 to 0.519, and the event's fraction (0.568 to 0.851) is above the 95th percentile of the random windows for 6 of the 8 components. Random sets of footprints of the event's size have spread ratios of 0.951 to 1.042 (5th to 95th percentiles, over components). The event's spread ratio lies within its own component's random range for 3 of the 8 components, below it for 2 (0.945 and 0.956, against 5th percentiles of 0.962) and above it for 3 (1.042, 1.055 and 1.050, against 95th percentiles of 1.037, 1.042 and 1.036). The active footprints are spread across the field of view, which is what the claim states; the criterion's threshold of half the overall spread is far below what any set of this size reaches.

ed2b-featherscope-flat-illumination and ed2c-kiloscope-flat-illumination¶

With the axicon correction optics the signal intensity profile is flat across the field of view, where total vignetting makes the signal fall toward the edges (Extended Data Fig. 2b for the Featherscope, 2c for the Kiloscope).

The images are measurements of a thin layer of fluorescent dye kept in the paper's code repository (they are data, not figures), read at commit d596646 as sf3/*/analysis.m reads them: corrected (axicons), optical vignetting (led), and total vignetting (reference). Criterion: the corrected profile at or above 0.8 out to 0.4 mm (Featherscope) or 1.5 mm (Kiloscope), and above the total-vignetting profile there.

In [19]:
setups = {
    "ed2b-featherscope": dict(folder="sf3/feather", um=1.68, max_um=500, med=3, R=400,
                              files={"corrected": "axicons/rgb2021-01-29T16_31_30.png", "optical vignetting": "led/feather.png",
                                     "total vignetting": "reference/rgb2021-01-29T16_54_50.png"}),
    "ed2c-kiloscope": dict(folder="sf3/widefield", um=5.94, max_um=2000, med=10, R=1500,
                           files={"corrected": "axicons/rgb2021-01-08T14_40_22.png", "optical vignetting": "led/rgb2021-01-29T15_25_25.png",
                                  "total vignetting": "reference/rgb2021-01-08T15_23_50.png"}),
}
profiles = {}
fig, ax = plt.subplots(1, 2, figsize=(11, 3.5))
for a_, (name, s) in zip(ax, setups.items()):
    profiles[name] = {}
    for label, fn in s["files"].items():
        img = ks.read_png(ks.code_file_url(f"{s['folder']}/{fn}"))
        img = ks.unpack_rgb565(img) if img.ndim == 3 else img.astype(float)
        prof = ks.radial_profile(img, s["um"], s["max_um"], s["med"])
        profiles[name][label] = prof
        a_.plot(prof, label=label)
        print(f"{name} {label}: image {img.shape}, values {img.min():.0f} to {img.max():.0f}")
    a_.set_xlim(0, s["max_um"]); a_.set_ylim(0, 1.1); a_.axvline(s["R"], color="k", ls=":")
    a_.set_xlabel("radius (um)"); a_.set_title(name); a_.legend(fontsize=7)
plt.show()
for name, s in setups.items():
    p = profiles[name]
    R = s["R"]
    rows = []
    for label, prof in p.items():
        rows.append({"profile": label, "min_0_to_R": round(np.nanmin(prof[:R + 1]), 3),
                     **{f"at_{r}um": round(prof[r], 3) for r in (R // 4, R // 2, 3 * R // 4, R)}})
    print(f"\n{name} (R = {R} um):")
    print(pd.DataFrame(rows).to_string(index=False))
    n_out = {label: int(np.sum(prof[:s['max_um']] > 1.1)) for label, prof in p.items()}
    print("radii with values above the plotted 1.1:", n_out)

# Sensitivity of the corrected profiles to the radius and the centre.
for name, s in setups.items():
    img = ks.read_png(ks.code_file_url(f"{s['folder']}/{s['files']['corrected']}"))
    img = ks.unpack_rgb565(img) if img.ndim == 3 else img.astype(float)
    xc, yc = ks.weighted_centre(img)
    h, w = img.shape
    step = round(100 / s["um"])       # 100 um in pixels
    centres = {"intensity-weighted (main)": (xc, yc), "image centre": ((w + 1) / 2, (h + 1) / 2),
               "weighted + 100 um in x": (xc + step, yc), "weighted - 100 um in x": (xc - step, yc),
               "weighted + 100 um in y": (xc, yc + step), "weighted - 100 um in y": (xc, yc - step)}
    rows = []
    for label, centre in centres.items():
        prof = ks.radial_profile(img, s["um"], s["max_um"], s["med"], centre=centre)
        row = {"centre": label, "x_px": round(centre[0], 1), "y_px": round(centre[1], 1)}
        for f_ in (0.95, 1.0, 1.05):
            r = round(f_ * s["R"])
            row[f"min_0_to_{r}um"] = round(np.nanmin(prof[:r + 1]), 3)
        rows.append(row)
    print(f"\n{name}, corrected profile, minimum out to R - 5%, R, R + 5%:")
    print(pd.DataFrame(rows).to_string(index=False))
ed2b-featherscope corrected: image (608, 808), values 23 to 716
ed2b-featherscope optical vignetting: image (608, 808), values 18 to 195
ed2b-featherscope total vignetting: image (608, 808), values 17 to 778
ed2c-kiloscope corrected: image (608, 808), values 23 to 444
ed2c-kiloscope optical vignetting: image (608, 808), values 37 to 489
ed2c-kiloscope total vignetting: image (608, 808), values 27 to 888
No description has been provided for this image
ed2b-featherscope (R = 400 um):
           profile  min_0_to_R  at_100um  at_200um  at_300um  at_400um
         corrected       0.742     0.973     0.953     0.901     0.742
optical vignetting       0.628     0.966     0.875     0.762     0.628
  total vignetting       0.342     0.945     0.785     0.556     0.342
radii with values above the plotted 1.1: {'corrected': 0, 'optical vignetting': 0, 'total vignetting': 0}

ed2c-kiloscope (R = 1500 um):
           profile  min_0_to_R  at_375um  at_750um  at_1125um  at_1500um
         corrected       0.797     0.940     0.944      0.922      0.797
optical vignetting       0.389     0.886     0.696      0.525      0.389
  total vignetting       0.253     0.865     0.599      0.386      0.253
radii with values above the plotted 1.1: {'corrected': 0, 'optical vignetting': 0, 'total vignetting': 0}
ed2b-featherscope, corrected profile, minimum out to R - 5%, R, R + 5%:
                   centre  x_px  y_px  min_0_to_380um  min_0_to_400um  min_0_to_420um
intensity-weighted (main) 390.2 324.8           0.785           0.742           0.698
             image centre 404.5 304.5           0.805           0.766           0.723
   weighted + 100 um in x 450.2 324.8           0.692           0.650           0.608
   weighted - 100 um in x 330.2 324.8           0.853           0.811           0.768
   weighted + 100 um in y 390.2 384.8           0.653           0.626           0.601
   weighted - 100 um in y 390.2 264.8           0.899           0.867           0.829
ed2c-kiloscope, corrected profile, minimum out to R - 5%, R, R + 5%:
                   centre  x_px  y_px  min_0_to_1425um  min_0_to_1500um  min_0_to_1575um
intensity-weighted (main) 439.0 299.5            0.846            0.797            0.742
             image centre 404.5 304.5            0.728            0.695            0.654
   weighted + 100 um in x 456.0 299.5            0.880            0.831            0.776
   weighted - 100 um in x 422.0 299.5            0.807            0.765            0.713
   weighted + 100 um in y 439.0 316.5            0.810            0.766            0.716
   weighted - 100 um in y 439.0 282.5            0.845            0.798            0.745

Result: partially reproduced, for both. With the correction optics the profile falls less toward the edge than with total vignetting: at 0.4 mm, 0.742 against 0.342 for the Featherscope; at 1.5 mm, 0.797 against 0.253 for the Kiloscope. Neither corrected profile stays at or above 0.8 out to that radius (minima 0.742 and 0.797, the Kiloscope's 0.003 short), so the "flat" part of the criterion is not met. Optical vignetting alone falls to 0.628 and 0.389 at the same radii. The minima depend on the radius and the centre. For the Featherscope the minimum is 0.785 out to 0.38 mm and 0.698 out to 0.42 mm; with the centre moved by 100 um it is 0.626 to 0.867, and 0.766 about the image centre. For the Kiloscope it is 0.846 out to 1.425 mm and 0.742 out to 1.575 mm; with the centre moved by 100 um it is 0.765 to 0.831 (above 0.8 for one of the four moves), and 0.695 about the image centre. The outcome uses the code's centre and the criterion's radius.

In [20]:
print(f"total run time {(time.time() - started) / 60:.1f} min")
total run time 25.9 min

Summary¶

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

Claim Data Outcome
abstract-thousands-of-neurons dandiset reproduced
fig2-quadrant-direction-decoding dandiset reproduced
ed9b-octant-decoding dandiset reproduced
ed9c-local-decoding-map dandiset reproduced
ed10-localized-activity dandiset reproduced
ed8-coactive-distributed dandiset reproduced
ed6a-curated-footprints dandiset blocked: metadata insufficient
abstract-kiloscope-fov dandiset blocked: metadata insufficient
ed6bcd-other-mice, ed5-songbirds, abstract-weights, ed1-ed4-resolution, ed3-adc dandiset blocked: data missing
ed2b-featherscope-flat-illumination elsewhere (GitHub) partially reproduced
ed2c-kiloscope-flat-illumination elsewhere (GitHub) partially reproduced

How the analysis differs from the paper's.

  • The claims come from the abstract and the public Extended Data legends; the main text was not available.
  • All 2948 footprints are used, where the paper used the 2382 it kept after curation; the dandiset does not mark them.
  • The mouse's position is tracked here from the behaviour video with the code's colour rule, in the unrotated frame, with the loop's sides taken from the positions; the paper used its own tracking, which is not in the dandiset.
  • MATLAB's linear SVM is replaced by scikit-learn solvers of the same objective, chosen by MATLAB's default rule: SGD with 10 passes for the decoders on all footprints, an exact solver (liblinear) for the local decoders on 32 or 64 footprints. On six splits each for the quadrant and direction and the octant decoders, the exact solver gives lower accuracies, still above every shifted decoder checked. 50 decoders per analysis where the code ran 200.
  • The local map follows the legend (32 footprints within 270 um) on a 30 pixel grid with a z-test against 10 shifted decoders per point; the code's 64 footprints within 50 pixels is a variant, on a 60 pixel grid where the criterion says 40, to keep within the time limit. The paper's significance test is not described. The local map's outcome depends on the solver: with SGD, as in the first round of this attempt, it fails.
  • Event times for Extended Data Fig. 8 are each component's largest weight, where the code sets them by hand.

What was found about the dandiset.

  • It holds one of the paper's four mice and none of its bird or optical data. The Academic Torrents deposit the paper names holds the same mouse only.
  • The device description gives the weight-optimized microscope's size and weight (1.0 mm, 1.0 g) for the large-FOV one, and the dandiset description says the data come from the microscope optimized for weight; the data are from the large-FOV microscope.
  • The imaging is stored as a TwoPhotonSeries and listed as two-photon microscopy; the microscope is a one-photon head-mounted microscope.
  • The behaviour video is described as about 30 frames/s, and its AVI header says 30; its timestamps run at 12.5 frames/s, and only at that rate does it cover the imaging.
  • No pixel size, indicator, location or excitation wavelength; no curation of the footprints; no position of the mouse.
  • The traces, the raw movie and the behaviour timestamps are consistent with one another: the movie pixels at six footprints follow their traces (r = 0.30 to 0.87 at lag 0), and position decoding falls to chance when the labels are shifted by 20 s.