Reproducing the MICrONS Consortium (2025) from dandiset 000402¶
Paper. The MICrONS Consortium (2025). Functional connectomics spanning multiple areas of mouse
visual cortex. Nature 640, 435-447.
doi:10.1038/s41586-025-08790-w. The claims file was
written from the open access version of record (PMC11981939): main text, figure legends, Methods
and the captions of the Extended Data. The claims file is
claims/000402/10.1038-s41586-025-08790-w.yaml.
Dandiset. DANDI:000402, "MICrONS Two Photon Functional Imaging", version 0.230307.2132 (doi:10.48324/dandi.000402/0.230307.2132). This is the version the paper cites (reference 73, "version 0.230307.2132") and the only published version. The draft lists the same 19 assets and size; it is not used.
What the dandiset holds. 19 NWB files, one per two-photon scan of mouse 17797, each with the raw imaging of every field, the segmentation masks (soma or artifact) and their fluorescence traces, the stimulus video and three stimulus tables (Clip, Monet2, Trippy), treadmill velocity, and pupil position and radii. For some fields the masks carry EM identifiers from CAVE. It does not hold the deconvolved traces, the structural stack, the stack coordinates or visual area of each mask, the oracle scores, the per-period Monet2 directions, the photodiode signal or any EM data.
Which 14 scans. The paper describes 14 of 19 completed scans and lists them in Extended Data
Table 1. The text the claims file was read from holds only that table's caption; its cells were
read from the table's image in PMC11981939 (41586_2025_8790_Tab1_ESM.jpg): sessions and scans
4-7, 5-6, 5-7, 6-2, 6-4, 6-6, 6-7, 7-3, 7-4, 7-5, 8-5, 9-3, 9-4 and 9-6. The analysis uses these 14
and shows the other 5 where they bear on a count (author step 1.6, a detail taken from the paper).
Outside inputs. Two claims whose data are elsewhere (data: elsewhere), nuclei-144120 and
cell-classification, are tested on the MICrONS tables that the BossDB open data bucket serves
without an account (s3://bossdb-open-data/iarpa_microns/minnie/minnie65/). The CAVE tables that the other EM and
co-registration claims name need an account token, which this run does not have. The bucket also
holds exports of the synapse table (versions 1078, 1181 and 1300, about 20 GB each, and a table
of subvolume 35; counted, with the EM sections, in em_volume_counts.ipynb, which also prints the
files cited here with their sizes) and a
connection table with nucleus ids (version 1300), but not the co-registration table or the
proofreading table of version 1181 that those claims use to choose their neurons; its proofreading
release is from 2021, with 600 rows. It also holds a structural stack
(functional_data/two_photon_structural_stacks/); the Fig. 1 legend lists the 2P structural images among
the public data resources. The claims that need the stack are limited to the dandiset by the claims file,
so they are not tested on it here, and a reader is asked about it (questions/000402.yaml).
Random numbers. None are used.
| Claim | Data | Tested here |
|---|---|---|
| scans-14-p75-p81 | dataset | yes |
| scan-configurations | dataset | yes |
| masks-125413-somatic-115372 | dataset | yes |
| unique-neurons-75909 | dataset | blocked: no stack coordinates in the dataset |
| area-split-half-visp | dataset | blocked: no area label per mask |
| z-drift-under-10um | dataset | blocked: no structural stack |
| structural-stack | dataset | blocked: no structural stack |
| stimulus-composition | dataset | yes |
| stimulus-conserved-across-scans | dataset | yes |
| oracle-same-order | dataset | yes |
| spontaneous-periods | dataset | yes |
| monet2-directions | dataset | yes, directions measured from the stimulus video |
| fig3f-oracle-reliability | dataset | yes, on fluorescence |
| fig3g-direction-tuning | dataset | yes, on fluorescence |
| behaviour-recorded | dataset | yes |
| photodiode-89pct | dataset | blocked: no photodiode signal |
| manual-coreg-19181, ed4-matched-higher-signal-correlation, fig6k-target-responses | with_inputs | blocked: CAVE table needs a token; the dataset's own EM identifiers are counted, and the ed4 test is run on them, as variants |
| nuclei-144120, cell-classification | elsewhere | yes, BossDB tables |
| em-sections, synapse-detection | elsewhere | in em_volume_counts.ipynb |
| the other 13 EM and co-registration claims | elsewhere | blocked (see the attempt record) |
import time
import warnings
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import microns
pd.set_option("display.width", 220)
pd.set_option("display.max_columns", 40)
pd.set_option("display.max_rows", 200)
warnings.filterwarnings("ignore", category=RuntimeWarning)
# Extended Data Table 1 (read from its image in PMC11981939): the 14 scans the paper describes.
PAPER_SCANS = [(4, 7), (5, 6), (5, 7), (6, 2), (6, 4), (6, 6), (6, 7), (7, 3), (7, 4), (7, 5), (8, 5),
(9, 3), (9, 4), (9, 6)]
started = time.time()
assets = microns.list_assets()
meta = microns.load_all_metadata(assets.asset_id, workers=19)
M = {(r.session, r.scan): m for (_, r), m in zip(assets.iterrows(), meta)}
AID = {(r.session, r.scan): r.asset_id for _, r in assets.iterrows()}
print(f"dandiset {microns.DANDISET_ID} version {microns.DANDISET_VERSION}: {len(assets)} assets, "
f"{assets['size'].sum() / 1e12:.2f} TB; metadata read in {time.time() - started:.0f} s")
dandiset 000402 version 0.230307.2132: 19 assets, 1.35 TB; metadata read in 10 s
Data check¶
Each check of the description against the values is stated under its output.
rows = []
for k in sorted(M):
m, F = M[k], M[k]["fields"]
d = pd.Timestamp(m["session_start_time"]).date()
rows.append({"session": k[0], "scan": k[1], "in_paper": k in PAPER_SCANS, "session_id": m["session_id"],
"date": str(d), "age_days": microns.age_days(d), "fields": len(F),
"depths_um": sorted(set(F.depth_um.round(1))), "rate_hz": round(F.rate_hz.mean(), 3),
"rate_in_description": F.description.iloc[0].split(" at ")[1].split(" Hz")[0],
"frames": int(F.n_frames.iloc[0]), "rois": int(F.n_rois.sum()), "soma": int(F.n_soma.sum()),
"artifact": int(F.n_artifact.sum()), "fields_with_em_ids": int(F.has_em_columns.sum()),
"rois_with_root_id": int(F.n_with_root_id.fillna(0).sum()) if "n_with_root_id" in F else 0})
scans = pd.DataFrame(rows)
print("Subjects:", sorted({m["subject_id"] for m in meta}), "sex", sorted({m["sex"] for m in meta}),
"age field", sorted({m["age"] for m in meta}))
print("Related publications in the files:", sorted({p for m in meta for p in m["related_publications"]}))
print(f"Files: {len(scans)}; in Extended Data Table 1: {scans.in_paper.sum()}; "
f"not in it: {[(r.session, r.scan) for r in scans[~scans.in_paper].itertuples()]}")
print(scans.to_string(index=False))
allF = pd.concat([M[k]["fields"].assign(session=k[0], scan=k[1]) for k in sorted(M)])
print("\nPer field, every file:")
print(" field of view (um):", sorted(set(zip(allF.fov_x_um.round(1), allF.fov_y_um.round(1)))),
" pixels:", sorted(set(zip(allF.dim_x, allF.dim_y))), " grid spacing (um):",
sorted(set(allF.grid_spacing_um.round(3))))
print(" imaging_rate attribute of the imaging planes:", sorted(set(allF.imaging_rate_attr.astype(str))))
print(" plane location:", sorted(set(allF.location)))
print(" mask types:", sorted({t for ts in allF.mask_types for t in ts}))
print(" trace timestamps equal the imaging timestamps in every field:", bool(allF.trace_same_ts.all()))
print(" ROI ids are 1..n and the traces' rois index them in order:", bool(allF.roi_ids_1_to_n.all() & allF.trace_rois_match.all()))
same_ts = all(all(np.array_equal(t, list(M[k]["frame_ts"].values())[0]) for t in M[k]["frame_ts"].values()) for k in M)
print(" all fields of a scan share one array of frame time stamps:", same_ts)
print(" field origins (x, y um) per scan:", {k: sorted(set(zip(M[k]['fields'].origin_x_um.round(0), M[k]['fields'].origin_y_um.round(0)))) for k in PAPER_SCANS[:3]}, "...")
print(" groups of file 4-7:")
for g, keys in M[(4, 7)]["other_groups"].items():
print(f" {g}: {keys}")
strip = lambda d: {g: sorted({x.rstrip("0123456789") for x in ks if not x.startswith("Video")}) for g, ks in d.items()}
print(" every file holds the same kinds of groups (numbered per field, the stimulus video's name aside):",
all(strip(M[k]["other_groups"]) == strip(M[(4, 7)]["other_groups"]) for k in M))
em = allF[allF.has_em_columns]
print(" fields with EM columns are those with origin x:", sorted(set(em.origin_x_um.round(0))),
"; without:", sorted(set(allF[~allF.has_em_columns].origin_x_um.round(0))))
Subjects: ['17797'] sex ['M'] age field ['P75D/P81D']
Related publications in the files: ['https://doi.org/10.1101/2021.07.28.454025']
Files: 19; in Extended Data Table 1: 14; not in it: [(4, 9), (4, 10), (5, 3), (8, 7), (8, 9)]
session scan in_paper session_id date age_days fields depths_um rate_hz rate_in_description frames rois soma artifact fields_with_em_ids rois_with_root_id
4 7 True 4-scan-7 2018-03-04 75 8 [80.0, 220.0, 360.0, 500.0] 6.298 6.3009 40000 8395 7493 902 4 685
4 9 False 4-scan-9 2018-03-04 75 8 [90.0, 230.0, 370.0, 510.0] 6.298 6.3009 35112 8548 7652 896 0 0
4 10 False 4-scan-10 2018-03-04 75 8 [100.0, 240.0, 380.0, 520.0] 6.297 6.3009 40000 9758 8481 1277 0 0
5 3 False 5-scan-3 2018-03-05 76 8 [180.0, 320.0, 460.0, 600.0] 6.298 6.3009 40000 8790 8220 570 4 571
5 6 True 5-scan-6 2018-03-05 76 8 [150.0, 290.0, 430.0, 570.0] 6.297 6.3009 40000 9254 8592 662 4 836
5 7 True 5-scan-7 2018-03-05 76 8 [140.0, 280.0, 420.0, 560.0] 6.297 6.3009 40000 8862 8138 724 4 747
6 2 True 6-scan-2 2018-03-06 77 8 [130.0, 270.0, 410.0, 550.0] 6.298 6.3009 40000 8756 8158 598 4 786
6 4 True 6-scan-4 2018-03-06 77 8 [115.0, 255.0, 395.0, 535.0] 6.298 6.3009 40000 8826 8221 605 4 891
6 6 True 6-scan-6 2018-03-06 77 8 [110.0, 250.0, 390.0, 530.0] 6.297 6.3009 40000 8551 7971 580 4 702
6 7 True 6-scan-7 2018-03-06 77 8 [100.0, 240.0, 380.0, 520.0] 6.297 6.3009 40000 8602 7887 715 4 543
7 3 True 7-scan-3 2018-03-07 78 8 [190.0, 330.0, 405.0, 470.0] 6.297 6.3009 40000 9296 8618 678 4 490
7 4 True 7-scan-4 2018-03-07 78 8 [130.0, 200.0, 340.0, 480.0] 6.297 6.3009 40000 12675 11201 1474 4 872
7 5 True 7-scan-5 2018-03-07 78 8 [175.0, 215.0, 350.0, 490.0] 6.297 6.3009 40000 9034 8194 840 4 426
8 5 True 8-scan-5 2018-03-08 79 8 [210.0, 220.0, 350.0, 490.0] 6.297 6.3009 40000 10579 9941 638 4 646
8 7 False 8-scan-7 2018-03-08 79 8 [170.0, 180.0, 460.0, 600.0] 6.297 6.3009 40000 7927 7530 397 0 0
8 9 False 8-scan-9 2018-03-08 79 8 [310.0, 320.0, 450.0, 590.0] 6.297 6.3009 40000 8535 7898 637 0 0
9 3 True 9-scan-3 2018-03-09 80 6 [295.0, 305.0, 315.0] 8.613 8.61754 50000 8661 7973 688 3 584
9 4 True 9-scan-4 2018-03-09 80 6 [150.0, 160.0, 170.0] 8.613 8.61754 50000 8415 7855 560 3 640
9 6 True 9-scan-6 2018-03-09 80 4 [155.0, 175.0] 9.616 9.62246 57000 5507 5130 377 2 15
Per field, every file:
field of view (um): [(620.0, 1000.0), (620.0, 1100.0)] pixels: [(440, 248), (600, 372)] grid spacing (um): [1.667, 2.5]
imaging_rate attribute of the imaging planes: [nan]
plane location: ['VISp,VISrl,VISlm,VISal']
mask types: [np.str_('artifact'), np.str_('soma')]
trace timestamps equal the imaging timestamps in every field: True
ROI ids are 1..n and the traces' rois index them in order: True
all fields of a scan share one array of frame time stamps: True
field origins (x, y um) per scan: {(4, 7): [(-485.0, -235.0), (95.0, -235.0)], (5, 6): [(-510.0, -290.0), (70.0, -290.0)], (5, 7): [(-510.0, -290.0), (70.0, -290.0)]} ...
groups of file 4-7:
file root: ['acquisition', 'analysis', 'file_create_date', 'general', 'identifier', 'intervals', 'processing', 'session_description', 'session_start_time', 'specifications', 'stimulus', 'timestamps_reference_time']
acquisition: ['EyeTracking', 'PupilTracking', 'TwoPhotonSeries1', 'TwoPhotonSeries2', 'TwoPhotonSeries3', 'TwoPhotonSeries4', 'TwoPhotonSeries5', 'TwoPhotonSeries6', 'TwoPhotonSeries7', 'TwoPhotonSeries8', 'Video: stimulus_17797_4_7_v4', 'treadmill_velocity']
stimulus/presentation: []
stimulus/templates: []
analysis: []
processing: ['ophys']
processing/ophys: ['Fluorescence', 'ImageSegmentation', 'SegmentationImages1', 'SegmentationImages2', 'SegmentationImages3', 'SegmentationImages4', 'SegmentationImages5', 'SegmentationImages6', 'SegmentationImages7', 'SegmentationImages8']
general: ['devices', 'experiment_description', 'optophysiology', 'related_publications', 'session_id', 'subject']
every file holds the same kinds of groups (numbered per field, the stimulus video's name aside): True
fields with EM columns are those with origin x: [35.0, 55.0, 60.0, 70.0, 95.0, 100.0] ; without: [-545.0, -525.0, -520.0, -510.0, -485.0, -480.0, 55.0, 95.0]
The files hold one subject (17797, male), as the paper describes. The age field is the same range,
P75D/P81D, in every file, so the day of each scan is taken from the session start date. Frame rates
from the time stamps agree with the rates in the field descriptions to within 0.01 Hz; the
imaging_rate attribute of every imaging plane is NaN. The field of view and pixel size agree with
the Methods (620 x 1,100 um at 0.4 pixels per um, and 620 x 1,000 um at 0.6 pixels per um for scan
9-6). Every plane's location lists all four areas, so no field or mask carries an area. All
fields of a scan share one array of time stamps, though the fields are imaged one after another
within a frame. The EM columns (pt_root_id and others) exist only in the fields on one side of
the field of view (the second field of each depth), and in 15 of the 19 files. The listing of file
4-7 shows every group the files hold, and all files hold the same kinds: under acquisition the
two-photon series, the stimulus video, the treadmill and the eye and pupil tracking; under
processing/ophys the fluorescence traces, the segmentation and the summary images;
stimulus and analysis are empty. There is no structural stack, no deconvolved trace and no
photodiode signal.
rows = []
for k in sorted(M):
m = M[k]
ts = m["frame_ts"][1]
stim = pd.concat([m[t][["start_time", "stop_time"]] for t in ("clip", "monet2", "trippy")])
rows.append({"session": k[0], "scan": k[1], "frames_s": f"{ts[0]:.2f}-{ts[-1]:.1f}",
"stimulus_s": f"{stim.start_time.min():.1f}-{stim.stop_time.max():.1f}",
"video_s": f"{m['video']['t0']:.2f}-{m['video']['t1']:.1f}",
"video_hz": round(m["video"]["rate_hz"], 2),
"treadmill_s": f"{m['treadmill']['t0']:.2f}-{m['treadmill']['t1']:.1f}",
"treadmill_hz": round(m["treadmill"]["rate_hz"], 1),
"treadmill_nan_t": m["treadmill"]["n_nan_t"],
"eye_s": f"{m['eye']['t0']:.2f}-{m['eye']['t1']:.1f}", "eye_hz": round(m["eye"]["rate_hz"], 2),
"eye_nan_t": m["eye"]["n_nan_t"], "pupil_fit": round(m["eye"]["pupil_fit_fraction"], 3)})
clocks = pd.DataFrame(rows)
print("Time ranges (s) of each stream, on the file's time base:")
print(clocks.to_string(index=False))
print("\nUnits: treadmill", "m/s (conversion 0.01 in the file)", "; eye position and pupil radii: px")
# Recordings deposited twice: compare frame time stamps, stimulus sequences and ROI counts.
keys = sorted(M)
dup = []
for i, a in enumerate(keys):
for b in keys[i + 1:]:
same_ts = len(M[a]["frame_ts"][1]) == len(M[b]["frame_ts"][1]) and np.allclose(M[a]["frame_ts"][1], M[b]["frame_ts"][1])
same_clip = list(M[a]["clip"].condition_hash) == list(M[b]["clip"].condition_hash)
same_rois = list(M[a]["fields"].n_rois) == list(M[b]["fields"].n_rois)
if same_ts or same_clip or same_rois:
dup.append((a, b, same_ts, same_clip, same_rois))
print("\nPairs of files with identical frame times, clip sequences or ROI counts per field:", dup or "none")
Time ranges (s) of each stream, on the file's time base:
session scan frames_s stimulus_s video_s video_hz treadmill_s treadmill_hz treadmill_nan_t eye_s eye_hz eye_nan_t pupil_fit
4 7 10.15-6360.8 18.9-5089.2 10.14-6360.9 60.0 0.00-6362.4 76.1 0 10.29-5392.9 20.01 0 0.849
4 9 9.92-5584.7 18.7-5089.4 9.92-5585.0 60.0 0.00-5584.9 72.0 57 11.69-5580.8 19.99 16 0.981
4 10 10.03-6361.1 209.7-5280.1 10.02-6361.2 60.0 0.00-6362.6 82.9 0 10.19-3097.3 20.01 0 0.714
5 3 9.95-6360.7 342.3-5412.5 9.94-6360.9 60.0 0.00-6362.7 66.9 0 10.09-1778.4 20.01 0 0.916
5 6 10.69-6362.1 206.9-5277.4 10.69-6362.2 60.0 0.00-6364.8 63.7 140 10.81-6362.3 19.98 39 0.996
5 7 10.10-6361.5 212.2-5282.5 10.09-6361.6 60.0 0.00-6364.2 65.5 190 10.21-6361.8 19.97 57 0.977
6 2 10.35-6360.8 300.8-5373.0 10.34-6360.9 60.0 0.00-6361.8 57.0 25 10.47-6361.1 20.01 6 0.998
6 4 11.39-6362.1 205.4-5276.1 11.39-6362.2 60.0 0.00-6362.7 58.9 101 11.51-6362.3 20.00 29 0.993
6 6 11.61-6362.5 20.4-5091.0 11.60-6362.6 60.0 0.00-6364.3 100.1 37 11.69-6362.7 20.01 9 0.986
6 7 10.45-6361.4 232.8-5303.4 10.44-6361.5 60.0 0.00-6363.3 99.9 58 10.59-6361.6 20.01 12 0.973
7 3 10.94-6361.8 19.7-5090.6 10.93-6361.9 60.0 0.00-6364.4 100.0 0 11.00-6362.0 20.01 0 0.978
7 4 10.13-6361.3 204.7-5275.8 10.13-6361.5 60.0 0.00-6362.9 100.0 0 10.30-6361.7 20.01 0 0.999
7 5 11.60-6363.1 223.2-5294.2 11.59-6363.2 60.0 0.00-6365.6 100.2 0 11.69-6363.3 20.00 0 0.996
8 5 10.12-6361.3 212.6-5285.2 10.12-6361.4 60.0 0.00-6361.7 100.1 13 10.18-6361.4 20.01 2 0.994
8 7 10.91-6362.2 19.7-5091.8 10.90-6362.4 60.0 0.00-6364.4 99.7 13 11.03-2238.2 20.01 0 0.936
8 9 11.15-6362.5 272.5-5343.5 11.14-6362.7 60.0 0.00-6364.4 99.8 12 11.25-6356.4 20.01 3 0.631
9 3 10.42-5814.8 196.9-5266.9 10.41-5814.9 60.0 0.00-5817.5 100.2 0 10.65-5815.0 20.00 0 0.978
9 4 10.90-5815.5 209.2-5280.0 10.89-5815.6 60.0 0.00-5817.8 98.8 7 10.95-5815.5 19.99 4 0.991
9 6 10.65-5937.2 201.4-5272.0 10.64-5937.3 60.0 0.00-5939.3 99.7 0 10.67-5937.3 19.96 0 0.966
Units: treadmill m/s (conversion 0.01 in the file) ; eye position and pupil radii: px
Pairs of files with identical frame times, clip sequences or ROI counts per field: none
The stimulus tables, the stimulus video, the frames, the treadmill and the eye tracking all lie on one time base starting near 0 s: the first imaging frame is at about 10 s, the video spans the imaging period, and the stimulus tables fall inside it. This is consistent with one shared clock; that the traces and the stimulus tables share it is shown below, in the section that reads the traces, by shifting the Monet2 windows in time. Some files have missing (NaN) time stamps in the treadmill and eye series; rates are taken as the median interval, ignoring them. No two files share frame times, stimulus sequences or ROI counts, so no recording was deposited twice.
scans-14-p75-p81¶
The calcium imaging data comprise 14 scans of one mouse, collected between P75 and P81, spanning a volume of about 1,200 x 1,100 x 500 um.
Criterion: count the distinct scans and days; ages from the recording dates against the birth date of 19 December 2017; the claim holds when the dates fall within P75 to P81; 6 or fewer days.
p14 = scans[scans.in_paper]
print("Scans in the dataset:", len(scans), "; distinct (session, scan):", scans[["session", "scan"]].drop_duplicates().shape[0])
print("Scans of Extended Data Table 1 found in the dataset:", len(p14), "of", len(PAPER_SCANS))
print("Days of the 14 scans:", sorted(p14.date.unique()), "->", p14.date.nunique(), "days")
print("Ages (days after 19 December 2017):", sorted(p14.age_days.unique()), "; all within P75-P81:",
bool(p14.age_days.between(75, 81).all()))
print("Days of all 19 files:", scans.date.nunique(), "; ages", sorted(scans.age_days.unique()))
F14 = allF[allF.set_index(["session", "scan"]).index.isin(PAPER_SCANS)]
print("Field width x height per scan (um), origins on each scan's own axes:")
for k in PAPER_SCANS:
F = M[k]["fields"]
print(f" {k[0]}-{k[1]}: {(F.origin_x_um + F.fov_x_um).max() - F.origin_x_um.min():.0f} x "
f"{(F.origin_y_um + F.fov_y_um).max() - F.origin_y_um.min():.0f}, depths {F.depth_um.min():.0f}-{F.depth_um.max():.0f}")
print(f"Field depths over the 14 scans: {F14.depth_um.min():.0f}-{F14.depth_um.max():.0f} um")
Scans in the dataset: 19 ; distinct (session, scan): 19 Scans of Extended Data Table 1 found in the dataset: 14 of 14 Days of the 14 scans: ['2018-03-04', '2018-03-05', '2018-03-06', '2018-03-07', '2018-03-08', '2018-03-09'] -> 6 days Ages (days after 19 December 2017): [np.int64(75), np.int64(76), np.int64(77), np.int64(78), np.int64(79), np.int64(80)] ; all within P75-P81: True Days of all 19 files: 6 ; ages [np.int64(75), np.int64(76), np.int64(77), np.int64(78), np.int64(79), np.int64(80)] Field width x height per scan (um), origins on each scan's own axes: 4-7: 1200 x 1100, depths 80-500 5-6: 1200 x 1100, depths 150-570 5-7: 1200 x 1100, depths 140-560 6-2: 1200 x 1100, depths 130-550 6-4: 1200 x 1100, depths 115-535 6-6: 1200 x 1100, depths 110-530 6-7: 1200 x 1100, depths 100-520 7-3: 1200 x 1100, depths 190-470 7-4: 1200 x 1100, depths 130-480 7-5: 1200 x 1100, depths 175-490 8-5: 1200 x 1100, depths 210-490 9-3: 1200 x 1100, depths 295-315 9-4: 1200 x 1100, depths 150-170 9-6: 1200 x 1000, depths 155-175 Field depths over the 14 scans: 80-570 um
Result: reproduced. The 14 scans of Extended Data Table 1 are all in the dataset, recorded on 6 days from P75 to P80, within P75 to P81. The dataset holds 19 scans, the 19 completed scans of the Methods, and does not mark which 14 the paper describes; they are identified here only from the table's image. Each scan's fields tile 1,200 x 1,100 um (1,200 x 1,000 um for 9-6), at depths from 80 to 570 um; the dataset gives no common frame for the scans, so the extent of the whole volume (about 500 um deep) is not shown.
scan-configurations¶
Eleven scans were imaged at 6.3 Hz with four depths (eight fields), two at 8.6 Hz with three depths (six fields) and one at 9.6 Hz with two depths (four fields); in the 11 scans the four planes span roughly 300 to 400 um.
Criterion: per scan, the rate, number of fields and distinct depths; rate within 0.1 Hz; counts exact; depth span within 270 to 440 um under reading (a) every scan or (b) the median.
from collections import Counter
def group(r):
for hz, nd in ((6.3, 4), (8.6, 3), (9.6, 2)):
if abs(r.rate_hz - hz) <= 0.1 and len(r.depths_um) == nd:
return f"{hz} Hz, {nd} depths"
return "none"
sc = scans.copy()
sc["group"] = sc.apply(group, axis=1)
sc["depth_span_um"] = sc.depths_um.apply(lambda d: max(d) - min(d))
sc["fields_by_depth"] = [dict(Counter(M[(r.session, r.scan)]["fields"].depth_um.round(1))) for r in sc.itertuples()]
print(sc[["session", "scan", "in_paper", "rate_hz", "fields", "depths_um", "fields_by_depth", "group", "depth_span_um"]].to_string(index=False))
print("Scans with exactly two fields at every depth:", int(sc.fields_by_depth.apply(lambda d: set(d.values()) == {2}).sum()), "of", len(sc))
p = sc[sc.in_paper]
print("\nThe 14 scans by group:", p.group.value_counts().to_dict())
print("All 19 files by group:", sc.group.value_counts().to_dict())
four = p[p.group == "6.3 Hz, 4 depths"]
print("Depth span of the 6.3 Hz scans (um):", four.depth_span_um.tolist())
print(f"(a) every span in 270-440 um: {bool(four.depth_span_um.between(270, 440).all())}; "
f"(b) median {four.depth_span_um.median():.0f} um")
print("Smallest gap between depths in the 6.3 Hz scans (um):", four.depths_um.apply(lambda d: min(np.diff(d))).tolist())
session scan in_paper rate_hz fields depths_um fields_by_depth group depth_span_um
4 7 True 6.298 8 [80.0, 220.0, 360.0, 500.0] {80.0: 2, 220.0: 2, 360.0: 2, 500.0: 2} 6.3 Hz, 4 depths 420.0
4 9 False 6.298 8 [90.0, 230.0, 370.0, 510.0] {90.0: 2, 230.0: 2, 370.0: 2, 510.0: 2} 6.3 Hz, 4 depths 420.0
4 10 False 6.297 8 [100.0, 240.0, 380.0, 520.0] {100.0: 2, 240.0: 2, 380.0: 2, 520.0: 2} 6.3 Hz, 4 depths 420.0
5 3 False 6.298 8 [180.0, 320.0, 460.0, 600.0] {180.0: 2, 320.0: 2, 460.0: 2, 600.0: 2} 6.3 Hz, 4 depths 420.0
5 6 True 6.297 8 [150.0, 290.0, 430.0, 570.0] {150.0: 2, 290.0: 2, 430.0: 2, 570.0: 2} 6.3 Hz, 4 depths 420.0
5 7 True 6.297 8 [140.0, 280.0, 420.0, 560.0] {140.0: 2, 280.0: 2, 420.0: 2, 560.0: 2} 6.3 Hz, 4 depths 420.0
6 2 True 6.298 8 [130.0, 270.0, 410.0, 550.0] {130.0: 2, 270.0: 2, 410.0: 2, 550.0: 2} 6.3 Hz, 4 depths 420.0
6 4 True 6.298 8 [115.0, 255.0, 395.0, 535.0] {115.0: 2, 255.0: 2, 395.0: 2, 535.0: 2} 6.3 Hz, 4 depths 420.0
6 6 True 6.297 8 [110.0, 250.0, 390.0, 530.0] {110.0: 2, 250.0: 2, 390.0: 2, 530.0: 2} 6.3 Hz, 4 depths 420.0
6 7 True 6.297 8 [100.0, 240.0, 380.0, 520.0] {100.0: 2, 240.0: 2, 380.0: 2, 520.0: 2} 6.3 Hz, 4 depths 420.0
7 3 True 6.297 8 [190.0, 330.0, 405.0, 470.0] {190.0: 2, 330.0: 2, 405.0: 2, 470.0: 2} 6.3 Hz, 4 depths 280.0
7 4 True 6.297 8 [130.0, 200.0, 340.0, 480.0] {130.0: 2, 200.0: 2, 340.0: 2, 480.0: 2} 6.3 Hz, 4 depths 350.0
7 5 True 6.297 8 [175.0, 215.0, 350.0, 490.0] {175.0: 2, 215.0: 2, 350.0: 2, 490.0: 2} 6.3 Hz, 4 depths 315.0
8 5 True 6.297 8 [210.0, 220.0, 350.0, 490.0] {210.0: 2, 220.0: 2, 350.0: 2, 490.0: 2} 6.3 Hz, 4 depths 280.0
8 7 False 6.297 8 [170.0, 180.0, 460.0, 600.0] {170.0: 2, 180.0: 2, 460.0: 2, 600.0: 2} 6.3 Hz, 4 depths 430.0
8 9 False 6.297 8 [310.0, 320.0, 450.0, 590.0] {310.0: 2, 320.0: 2, 450.0: 2, 590.0: 2} 6.3 Hz, 4 depths 280.0
9 3 True 8.613 6 [295.0, 305.0, 315.0] {295.0: 2, 305.0: 2, 315.0: 2} 8.6 Hz, 3 depths 20.0
9 4 True 8.613 6 [150.0, 160.0, 170.0] {150.0: 2, 160.0: 2, 170.0: 2} 8.6 Hz, 3 depths 20.0
9 6 True 9.616 4 [155.0, 175.0] {155.0: 2, 175.0: 2} 9.6 Hz, 2 depths 20.0
Scans with exactly two fields at every depth: 19 of 19
The 14 scans by group: {'6.3 Hz, 4 depths': 11, '8.6 Hz, 3 depths': 2, '9.6 Hz, 2 depths': 1}
All 19 files by group: {'6.3 Hz, 4 depths': 16, '8.6 Hz, 3 depths': 2, '9.6 Hz, 2 depths': 1}
Depth span of the 6.3 Hz scans (um): [420.0, 420.0, 420.0, 420.0, 420.0, 420.0, 420.0, 280.0, 350.0, 315.0, 280.0]
(a) every span in 270-440 um: True; (b) median 420 um
Smallest gap between depths in the 6.3 Hz scans (um): [140.0, 140.0, 140.0, 140.0, 140.0, 140.0, 140.0, 65.0, 70.0, 40.0, 10.0]
Result: reproduced. Of the 14 scans, 11 are at 6.3 Hz with 4 depths and 8 fields, 2 at 8.6 Hz with 3 depths and 6 fields, and 1 at 9.6 Hz with 2 depths and 4 fields, two fields per depth (the table of fields by depth shows exactly two at each depth in all 19 files). The depth spans of the 11 scans are 280 to 420 um, all inside 270 to 440 um (reading a), median 420 um (reading b). In scan 8-5 two depths are 10 um apart (210 and 220 um).
masks-125413-somatic-115372¶
In total 125,413 masks were segmented across the 14 scans, of which 115,372 were classified as somatic by a trained classifier (8.1% excluded).
Criterion: count all masks over the 14 scans and those marked somatic; reading (a) the dataset holds all masks with a label, (b) only the somatic ones.
tot, soma = int(p14.rois.sum()), int(p14.soma.sum())
print(f"14 scans: {tot} masks, {soma} soma ({100 * (1 - soma / tot):.2f}% artifact)")
print(f"All 19 files: {int(scans.rois.sum())} masks, {int(scans.soma.sum())} soma")
print("Per scan:")
print(p14[["session", "scan", "rois", "soma", "artifact"]].to_string(index=False))
14 scans: 125413 masks, 115372 soma (8.01% artifact)
All 19 files: 168971 masks, 155153 soma
Per scan:
session scan rois soma artifact
4 7 8395 7493 902
5 6 9254 8592 662
5 7 8862 8138 724
6 2 8756 8158 598
6 4 8826 8221 605
6 6 8551 7971 580
6 7 8602 7887 715
7 3 9296 8618 678
7 4 12675 11201 1474
7 5 9034 8194 840
8 5 10579 9941 638
9 3 8661 7973 688
9 4 8415 7855 560
9 6 5507 5130 377
Result: reproduced. The dataset holds every mask with its label (reading a): 125,413 masks over the 14 scans, 115,372 of them somatic, 8.01% excluded, exactly the paper's counts. Over all 19 files the counts are 168,971 and 155,153.
stimulus-composition¶
Each scan's stimulus lasted about 84 min and was made of 6 oracle clips of 10 s repeated 10 times (10 min), 144 unique natural clips of 10 s (24 min), 90 natural clips of 10 s shown twice (30 min), and 20 Trippy and 20 Monet2 seeds of 15 s each shown twice (10 min each).
Criterion: per scan, count clips by class and their repeats from the stimulus tables; total
duration as (a) the sum of trial start to end and (b) the nominal durations; all 14 scans must
match. A clip is identified by its condition_hash; seeds by rng_seed.
rows = []
for k in PAPER_SCANS:
m = M[k]
c, mo, tr = m["clip"], m["monet2"], m["trippy"]
vc = c.condition_hash.value_counts()
cat = c.drop_duplicates("condition_hash").set_index("condition_hash").short_movie_name
stim = pd.concat([c, mo, tr])
rows.append({"scan": f"{k[0]}-{k[1]}", "clip_trials": len(c),
"shown_10x": int((vc == 10).sum()), "shown_2x": int((vc == 2).sum()), "shown_1x": int((vc == 1).sum()),
"other_counts": sorted(set(vc) - {1, 2, 10}),
"monet2_seeds": mo.rng_seed.nunique(), "monet2_repeats": sorted(set(mo.rng_seed.value_counts())),
"trippy_seeds": tr.rng_seed.nunique(), "trippy_repeats": sorted(set(tr.rng_seed.value_counts())),
"clip_s": sorted(set(c.duration)), "param_s": sorted(set(mo.duration) | set(tr.duration)),
"sum_trials_min": round((stim.stop_time - stim.start_time).sum() / 60, 2),
"nominal_min": round(stim.duration.sum() / 60, 2),
"oracle_by_category": dict(cat[vc[vc == 10].index].value_counts()),
"twice_by_category": dict(cat[vc[vc == 2].index].value_counts()),
"once_by_category": dict(cat[vc[vc == 1].index].value_counts())})
comp = pd.DataFrame(rows)
print(comp.drop(columns=["oracle_by_category", "twice_by_category", "once_by_category"]).to_string(index=False))
print("\nClips per category (oracle / twice / once), same in every scan:",
all(r == comp.oracle_by_category[0] for r in comp.oracle_by_category),
all(r == comp.twice_by_category[0] for r in comp.twice_by_category),
all(r == comp.once_by_category[0] for r in comp.once_by_category))
print(comp.oracle_by_category[0], comp.twice_by_category[0], comp.once_by_category[0])
print("Trial durations from the table (start to stop, s):",
np.round(np.percentile(np.concatenate([(M[k]['clip'].stop_time - M[k]['clip'].start_time).values for k in PAPER_SCANS]), [0, 50, 100]), 3))
ok = ((comp.shown_10x == 6) & (comp.shown_2x == 90) & (comp.shown_1x == 144) & (comp.monet2_seeds == 20)
& (comp.trippy_seeds == 20) & comp.monet2_repeats.apply(lambda r: r == [2]) & comp.trippy_repeats.apply(lambda r: r == [2]))
print("Scans matching every count:", int(ok.sum()), "of", len(comp))
print(f"Total duration (a) {comp.sum_trials_min.min()}-{comp.sum_trials_min.max()} min; (b) {comp.nominal_min.unique()} min")
scan clip_trials shown_10x shown_2x shown_1x other_counts monet2_seeds monet2_repeats trippy_seeds trippy_repeats clip_s param_s sum_trials_min nominal_min
4-7 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
5-6 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
5-7 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
6-2 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
6-4 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
6-6 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
6-7 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
7-3 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
7-4 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
7-5 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
8-5 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
9-3 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
9-4 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
9-6 384 6 90 144 [] 20 [2] 20 [2] [10.0] [15.0] 83.76 84.0
Clips per category (oracle / twice / once), same in every scan: True True True
{'Rendered': np.int64(2), 'Cinematic': np.int64(2), 'sports1m': np.int64(2)} {'Rendered': np.int64(30), 'Cinematic': np.int64(30), 'sports1m': np.int64(30)} {'sports1m': np.int64(48), 'Cinematic': np.int64(48), 'Rendered': np.int64(48)}
Trial durations from the table (start to stop, s): [9.9 9.967 9.967]
Scans matching every count: 14 of 14
Total duration (a) 83.76-83.76 min; (b) [84.] min
Result: reproduced. All 14 scans have 6 clips shown 10 times, 90 shown twice, 144 shown once, and 20 Monet2 and 20 Trippy seeds shown twice each, with 10 s clips and 15 s parametric trials. The total is 83.76 min from the trial times (reading a) and 84.0 min nominal (reading b). Each category (cinematic, Sports-1M, rendered) has 2 oracle, 30 repeated and 48 unique clips in every scan, as the Methods give.
stimulus-conserved-across-scans¶
The oracle clips, the 90 twice-repeated clips and 10 of the 20 seeds of each parametric stimulus were the same in every scan; the 144 unique clips and the other 10 seeds differed between scans.
Criterion: identify clips by condition_hash and seeds by rng_seed (and their hash); the 6
oracle and 90 repeated clips, and 10 Monet2 and 10 Trippy seeds, occur in all 14 scans; the rest
each occur in one scan only.
from collections import Counter, defaultdict
def scans_per(ident):
where = defaultdict(set)
for k in PAPER_SCANS:
for x in ident(M[k]):
where[x].add(k)
return where
clip_where = scans_per(lambda m: set(m["clip"].condition_hash))
rep_hash = set.intersection(*[set(M[k]["clip"].condition_hash.value_counts().loc[lambda v: v >= 2].index) for k in PAPER_SCANS])
once_hash = set().union(*[set(M[k]["clip"].condition_hash.value_counts().loc[lambda v: v == 1].index) for k in PAPER_SCANS])
print("Clips: number of scans each hash occurs in:", dict(Counter(len(v) for v in clip_where.values())))
print(" clips shown 2 or 10 times in a scan that occur in all 14 scans:", len(rep_hash))
print(" clips shown once in a scan:", len(once_hash), "; occurring in more than one scan:", sum(len(clip_where[h]) > 1 for h in once_hash))
for name in ("monet2", "trippy"):
w = scans_per(lambda m: set(m[name].rng_seed))
wh = scans_per(lambda m: set(m[name].condition_hash))
counts = Counter(len(v) for v in w.values())
print(f"{name}: seeds by number of scans {dict(counts)}; hashes by number of scans {dict(Counter(len(v) for v in wh.values()))}")
for s, ks in w.items():
if 1 < len(ks) < 14:
hashes = {h for k in ks for h in M[k][name].loc[M[k][name].rng_seed == s, "condition_hash"]}
print(f" seed {s:.0f} occurs in scans {sorted(ks)}, condition hash {hashes}, "
f"times shown {[int((M[k][name].rng_seed == s).sum()) for k in sorted(ks)]}")
# The same comparison over all 19 files
all_where = defaultdict(set)
for k in M:
for h in set(M[k]["clip"].condition_hash):
all_where[h].add(k)
print("Over all 19 files, clip hashes by number of files:", dict(Counter(len(v) for v in all_where.values())))
Clips: number of scans each hash occurs in: {1: 2016, 14: 96}
clips shown 2 or 10 times in a scan that occur in all 14 scans: 96
clips shown once in a scan: 2016 ; occurring in more than one scan: 0
monet2: seeds by number of scans {14: 10, 1: 140}; hashes by number of scans {1: 140, 14: 10}
trippy: seeds by number of scans {1: 138, 14: 10, 2: 1}; hashes by number of scans {1: 138, 14: 10, 2: 1}
seed 6831 occurs in scans [(6, 4), (7, 5)], condition hash {'Xe6/vzamVqYgecKk7y51'}, times shown [2, 2]
Over all 19 files, clip hashes by number of files: {1: 2736, 19: 96}
Result: partially reproduced. The 96 clips shown 2 or 10 times in a scan (6 oracle and 90 repeated) occur in all 14 scans, the 2,016 clips shown once each occur in one scan only, and 10 Monet2 and 10 Trippy seeds occur in all 14 scans. Of the other seeds, the 140 Monet2 seeds each occur in one scan, but one Trippy seed (6831, the same condition hash) is shown in both scans 6-4 and 7-5, so not every remaining seed is unique to its scan, as the criterion requires. Over all 19 files the 96 shared clips occur in every file.
oracle-same-order¶
The six oracle clips were repeated in the same order ten times per scan.
Criterion: per scan, the 6 clips shown 10 times; each repeat is the 6 trials from each onset of the first oracle clip, in trial order (the paper's code, as the claims file gives it); each of the 10 repeats shows the same 6 clips consecutively and in the same order.
rows = []
orders = {}
for k in PAPER_SCANS:
c = M[k]["clip"].sort_values("start_time").reset_index(drop=True)
allstim = pd.concat([M[k][t][["start_time"]] for t in ("clip", "monet2", "trippy")])
oracle = set(microns.oracle_hashes(c))
first, reps = microns.oracle_repeats(c)
seqs = [tuple(r.condition_hash) for r in reps]
orders[k] = seqs[0]
rows.append({"scan": f"{k[0]}-{k[1]}", "oracle_clips": len(oracle), "repeats": len(reps),
"repeats_all_6_oracle": all(set(s) == oracle and len(s) == 6 for s in seqs),
"other_trials_inside_repeats": int(sum(((allstim.start_time > r.start_time.iloc[0]) & (allstim.start_time < r.stop_time.iloc[-1])).sum() - 5 for r in reps)),
"distinct_orders": len(set(seqs)),
"max_gap_within_repeat_s": round(max((r.start_time.values[1:] - r.stop_time.values[:-1]).max() for r in reps), 3),
"repeat_onsets_min": [round(r.start_time.iloc[0] / 60, 1) for r in reps]})
so = pd.DataFrame(rows)
print(so.to_string(index=False))
print("Same oracle clips in every scan:", len({frozenset(o) for o in orders.values()}) == 1,
"; distinct orders across the 14 scans:", len(set(orders.values())))
scan oracle_clips repeats repeats_all_6_oracle other_trials_inside_repeats distinct_orders max_gap_within_repeat_s repeat_onsets_min 4-7 6 10 True 0 1 0.117 [6.3, 13.4, 21.4, 28.5, 35.5, 48.6, 55.6, 63.7, 70.7, 77.8] 5-6 6 10 True 0 1 0.117 [9.5, 16.5, 24.6, 31.6, 38.7, 51.7, 58.8, 66.8, 73.9, 80.9] 5-7 6 10 True 0 1 0.117 [9.6, 16.6, 24.7, 31.7, 38.7, 51.8, 58.9, 66.9, 74.0, 81.0] 6-2 6 10 True 0 1 0.117 [11.1, 18.1, 26.1, 33.2, 40.2, 53.3, 60.4, 68.4, 75.5, 82.5] 6-4 6 10 True 0 1 0.100 [9.5, 16.5, 24.6, 31.6, 38.6, 51.7, 58.8, 66.8, 73.9, 80.9] 6-6 6 10 True 0 1 0.117 [6.4, 13.4, 21.5, 28.5, 35.6, 48.6, 55.7, 63.7, 70.8, 77.8] 6-7 6 10 True 0 1 0.117 [9.9, 17.0, 25.0, 32.0, 39.1, 52.2, 59.2, 67.3, 74.3, 81.3] 7-3 6 10 True 0 1 0.117 [6.4, 13.4, 21.5, 28.5, 35.5, 48.6, 55.7, 63.7, 70.8, 77.8] 7-4 6 10 True 0 1 0.133 [9.4, 16.5, 24.5, 31.6, 38.6, 51.7, 58.8, 66.8, 73.8, 80.9] 7-5 6 10 True 0 1 0.117 [9.8, 16.8, 24.9, 31.9, 38.9, 52.0, 59.1, 67.1, 74.2, 81.2] 8-5 6 10 True 0 1 0.633 [9.6, 16.6, 24.7, 31.7, 38.8, 51.8, 58.9, 66.9, 74.0, 81.0] 9-3 6 10 True 0 1 0.117 [9.3, 16.4, 24.4, 31.4, 38.5, 51.6, 58.6, 66.7, 73.7, 80.7] 9-4 6 10 True 0 1 0.100 [9.5, 16.6, 24.6, 31.7, 38.7, 51.8, 58.8, 66.9, 73.9, 81.0] 9-6 6 10 True 0 1 0.100 [9.4, 16.4, 24.5, 31.5, 38.6, 51.7, 58.7, 66.7, 73.8, 80.8] Same oracle clips in every scan: True ; distinct orders across the 14 scans: 14
Result: reproduced. In every scan the 10 repeats each show the 6 oracle clips consecutively and in one order (one distinct order per scan), with gaps of at most 0.633 s between clips, and no other trial of any stimulus starts inside a repeat. The order differs between scans (14 distinct orders); the claim is about the order within a scan.
spontaneous-periods¶
Each scan was preceded by 0.15 to 5.5 min with the monitor on and followed by 8.3 to 21.2 min with the monitor off, to record spontaneous activity.
Criterion: per scan, the time from the first imaging frame to the first stimulus onset, and from the last stimulus offset to the last imaging frame; every scan within 0.135 to 6.05 min and 7.47 to 23.32 min.
rows = []
for k in sorted(M):
m = M[k]
ts = m["frame_ts"][1]
stim = pd.concat([m[t][["start_time", "stop_time"]] for t in ("clip", "monet2", "trippy")])
rows.append({"scan": f"{k[0]}-{k[1]}", "in_paper": k in PAPER_SCANS,
"before_min": (stim.start_time.min() - ts[0]) / 60, "after_min": (ts[-1] - stim.stop_time.max()) / 60})
sp = pd.DataFrame(rows)
print(sp.round(3).to_string(index=False))
q = sp[sp.in_paper]
print(f"14 scans: before {q.before_min.min():.3f}-{q.before_min.max():.3f} min, after {q.after_min.min():.2f}-{q.after_min.max():.2f} min")
print("All within the widened ranges:", bool(q.before_min.between(0.135, 6.05).all() and q.after_min.between(7.47, 23.32).all()))
# The four bounds against the claims file's expected values and tolerances
exp = [("before, lower", "before_min", min, 0.15, 0.015), ("before, upper", "before_min", max, 5.5, 0.55),
("after, lower", "after_min", min, 8.3, 0.83), ("after, upper", "after_min", max, 21.2, 2.12)]
print("Bounds of the range over scans, against the expected values (tolerance):")
for name, col, f, v, tol in exp:
a, b = f(q[col]), f(sp[col])
print(f" {name}: expected {v} +- {tol}; 14 scans {a:.3f} ({'within' if abs(a - v) <= tol else 'outside'}); "
f"all 19 files {b:.3f} ({'within' if abs(b - v) <= tol else 'outside'})")
scan in_paper before_min after_min 4-7 True 0.145 21.193 4-9 False 0.146 8.255 4-10 False 3.327 18.016 5-3 False 5.539 15.804 5-6 True 3.271 18.077 5-7 True 3.368 17.984 6-2 True 4.841 16.464 6-4 True 3.234 18.100 6-6 True 0.147 21.191 6-7 True 3.705 17.634 7-3 True 0.147 21.187 7-4 True 3.243 18.092 7-5 True 3.527 17.814 8-5 True 3.375 17.934 8-7 False 0.147 21.174 8-9 False 4.356 16.985 9-3 True 3.108 9.133 9-4 True 3.305 8.926 9-6 True 3.179 11.087 14 scans: before 0.145-4.841 min, after 8.93-21.19 min All within the widened ranges: True Bounds of the range over scans, against the expected values (tolerance): before, lower: expected 0.15 +- 0.015; 14 scans 0.145 (within); all 19 files 0.145 (within) before, upper: expected 5.5 +- 0.55; 14 scans 4.841 (outside); all 19 files 5.539 (within) after, lower: expected 8.3 +- 0.83; 14 scans 8.926 (within); all 19 files 8.255 (within) after, upper: expected 21.2 +- 2.12; 14 scans 21.193 (within); all 19 files 21.193 (within)
Result: partially reproduced. Over the 14 scans the period before the first stimulus is 0.145 to 4.84 min and the period after the last is 8.93 to 21.19 min, all inside the widened ranges, so the criterion's test for each scan holds. The bounds of the range over the 14 scans do not all match the paper's: the longest period before the stimulus is 4.84 min, outside 5.5 +- 0.55 min, while the other three bounds are within tolerance. Over all 19 files the four bounds are 0.145, 5.54, 8.26 and 21.19 min, all within tolerance of the paper's 0.15, 5.5, 8.3 and 21.2 min, so the paper's range was probably taken over all 19 recordings. The dataset does not say whether the monitor was on or off in these periods.
behaviour-recorded¶
Treadmill velocity, and the pupil position and diameter from video of the left eye, were recorded throughout each scan.
Criterion: all 14 scans hold treadmill velocity and pupil position and diameter covering the imaging period (first to last imaging frame; gaps allowed), each scan's treadmill rate in 51.3 to 110 Hz and the eye camera rate within 18 to 22 Hz. The share of eye frames with a pupil fit is reported.
rows = []
for k in sorted(M):
m = M[k]
ts = m["frame_ts"][1]
span = ts[-1] - ts[0]
tr, ey = m["treadmill"], m["eye"]
cover = lambda s: (min(s["t1"], ts[-1]) - max(s["t0"], ts[0])) / span
rows.append({"scan": f"{k[0]}-{k[1]}", "in_paper": k in PAPER_SCANS,
"treadmill_hz": round(tr["rate_hz"], 1), "treadmill_cover": round(cover(tr), 4),
"eye_hz": round(ey["rate_hz"], 2), "eye_cover": round(cover(ey), 4),
"eye_start_after_first_frame_s": round(ey["t0"] - ts[0], 2),
"eye_end_before_last_frame_s": round(ts[-1] - ey["t1"], 1),
"eye_max_gap_s": round(ey["max_gap_s"], 2), "pupil_fit": round(ey["pupil_fit_fraction"], 3),
"minor_radius_same_times": ey["minor_ts_same"]})
bh = pd.DataFrame(rows)
print(bh.to_string(index=False))
q = bh[bh.in_paper]
print(f"\n14 scans: treadmill {q.treadmill_hz.min()}-{q.treadmill_hz.max()} Hz, eye {q.eye_hz.min()}-{q.eye_hz.max()} Hz")
print("Treadmill covers the imaging period in", int((q.treadmill_cover >= 0.999).sum()), "of 14;",
"eye covers it (to within 1 s at each end) in",
int(((q.eye_start_after_first_frame_s < 1) & (q.eye_end_before_last_frame_s < 1)).sum()), "of 14")
print("Pupil fit share over the 14 scans:", q.pupil_fit.min(), "to", q.pupil_fit.max())
o = bh[~bh.in_paper]
late = o[(o.eye_start_after_first_frame_s >= 1) | (o.eye_end_before_last_frame_s >= 1)]
print(f"Files outside the 14 where the eye tracking misses the imaging period by 1 s or more at either end: "
f"{len(late)} of {len(o)}")
print(late[["scan", "eye_start_after_first_frame_s", "eye_end_before_last_frame_s", "eye_cover"]].to_string(index=False))
print("Every eye and pupil series in file 4-7 (time stamps, s):")
print(pd.DataFrame(M[(4, 7)]["eye_series"]).round(1).to_string(index=False))
scan in_paper treadmill_hz treadmill_cover eye_hz eye_cover eye_start_after_first_frame_s eye_end_before_last_frame_s eye_max_gap_s pupil_fit minor_radius_same_times
4-7 True 76.1 1.0 20.01 0.8476 0.14 967.9 0.45 0.849 True
4-9 False 72.0 1.0 19.99 0.9990 1.77 3.9 0.67 0.981 True
4-10 False 82.9 1.0 20.01 0.4861 0.16 3263.8 0.45 0.714 True
5-3 False 66.9 1.0 20.01 0.2784 0.14 4582.3 0.32 0.916 True
5-6 True 63.7 1.0 19.98 1.0000 0.12 -0.3 0.55 0.996 True
5-7 True 65.5 1.0 19.97 1.0000 0.11 -0.3 0.57 0.977 True
6-2 True 57.0 1.0 20.01 1.0000 0.12 -0.3 0.72 0.998 True
6-4 True 58.9 1.0 20.00 1.0000 0.12 -0.2 0.63 0.993 True
6-6 True 100.1 1.0 20.01 1.0000 0.08 -0.2 0.50 0.986 True
6-7 True 99.9 1.0 20.01 1.0000 0.14 -0.3 0.50 0.973 True
7-3 True 100.0 1.0 20.01 1.0000 0.06 -0.2 0.20 0.978 True
7-4 True 100.0 1.0 20.01 1.0000 0.17 -0.3 0.30 0.999 True
7-5 True 100.2 1.0 20.00 1.0000 0.09 -0.2 0.20 0.996 True
8-5 True 100.1 1.0 20.01 1.0000 0.06 -0.2 0.52 0.994 True
8-7 False 99.7 1.0 20.01 0.3507 0.12 4124.0 0.41 0.936 True
8-9 False 99.8 1.0 20.01 0.9990 0.10 6.2 0.45 0.631 True
9-3 True 100.2 1.0 20.00 1.0000 0.23 -0.2 0.50 0.978 True
9-4 True 98.8 1.0 19.99 1.0000 0.05 0.1 0.45 0.991 True
9-6 True 99.7 1.0 19.96 1.0000 0.01 -0.1 0.45 0.966 True
14 scans: treadmill 57.0-100.2 Hz, eye 19.96-20.01 Hz
Treadmill covers the imaging period in 14 of 14; eye covers it (to within 1 s at each end) in 13 of 14
Pupil fit share over the 14 scans: 0.849 to 0.999
Files outside the 14 where the eye tracking misses the imaging period by 1 s or more at either end: 5 of 5
scan eye_start_after_first_frame_s eye_end_before_last_frame_s eye_cover
4-9 1.77 3.9 0.9990
4-10 0.16 3263.8 0.4861
5-3 0.14 4582.3 0.2784
8-7 0.12 4124.0 0.3507
8-9 0.10 6.2 0.9990
Every eye and pupil series in file 4-7 (time stamps, s):
series n t0 t1
EyeTracking/eye_position 104227 10.3 5392.9
PupilTracking/pupil_major_radius 104227 10.3 5392.9
PupilTracking/pupil_minor_radius 104227 10.3 5392.9
Result: partially reproduced. All 14 scans hold treadmill velocity (57.0 to 100.2 Hz) and pupil position and radii (19.96 to 20.01 Hz), and the treadmill covers the imaging period in all 14. The eye tracking covers it in 13: in scan 4-7 it ends 968 s (about 16 min) before the last imaging frame. File 4-7 holds one eye position series and two pupil radius series, all ending at 5,392.9 s, so no other series covers the rest of the scan. Elsewhere the eye tracking starts within 0.23 s of the first frame. A pupil was fitted in 85% to 99.9% of eye frames. By the same 1 s rule, the eye tracking misses the imaging period in all 5 files outside the paper's 14: it ends 3,264 to 4,582 s early in 4-10, 5-3 and 8-7, and 3.9 s and 6.2 s early in 4-9 and 8-9 (in 4-9 it also starts 1.77 s late).
monet2-directions¶
Each 15 s Monet2 block holds 16 equal periods of motion, one for each of 16 directions between 0 and 360 degrees, at 42.8 degrees per second.
Criterion: every Monet2 trial has 16 distinct directions in 0 to 360 degrees and periods within tolerance of 15 s / 16 (843.75 to 1,031.25 ms); spacing reported; speed checked only if given.
The Monet2 table gives num_directions and the duration of each trial but not the direction of
each period or its onset. Both are measured here from the stimulus video stored in each file
(microns.monet2_trial_directions): the frame-to-frame shift by phase correlation gives the
boundaries where the motion direction changes, and the period is estimated as the slope of the
boundary times on their index. The direction of each of the 16 equal periods is the direction of
the global optical flow over the period (microns.period_directions_flow, the gradient method of
Lucas and Kanade). The first round of this attempt took the direction from the summed phase
correlation shifts; the sub-pixel peak of the phase correlation is pulled towards whole-pixel
shifts, which bends directions towards the axes and diagonals. It is shown here for comparison. This is a measurement made for this
attempt, not the paper's method, which read the directions from its stimulus tables. All 40 trials
of each of the 14 scans are measured.
started = time.time()
N_SPLIT = 4
jobs, owners = [], []
for k in PAPER_SCANS:
js = microns.monet2_jobs(AID[k], M[k], N_SPLIT)
jobs += js
owners += [k] * len(js)
res = microns.monet2_directions_all(jobs, workers=12)
monet = {}
for k in PAPER_SCANS:
monet[k] = microns.gather_monet2([r for r, o in zip(res, owners) if o == k], N_SPLIT)
print(f"measured {sum(len(v) for v in monet.values())} Monet2 trials in {time.time() - started:.0f} s")
measured 560 Monet2 trials in 353 s
rows = []
for k in PAPER_SCANS:
mo = M[k]["monet2"].sort_values("start_time").reset_index(drop=True)
for i, r in enumerate(monet[k]):
period, offset = microns.boundary_period(r["boundaries"], 15 / 16)
d, dpc = r["directions"], r["directions_pc"]
snapped = np.round(d / 22.5) * 22.5 % 360
snapped_pc = np.round(dpc / 22.5) * 22.5 % 360
rows.append({"scan": f"{k[0]}-{k[1]}", "trial": i, "seed": mo.rng_seed[i], "num_directions": mo.num_directions[i],
"duration_s": mo.duration[i], "video_hz": r["video_rate_hz"], "boundaries": len(r["boundaries"]),
"period_ms": 1000 * period, "first_boundary_offset_ms": 1000 * offset,
"distinct": microns.n_distinct_directions(d), "distinct_on_grid": len(set(snapped)),
"dev_from_grid_deg": np.max(np.abs((d - snapped + 180) % 360 - 180)),
"distinct_pc": microns.n_distinct_directions(dpc), "distinct_on_grid_pc": len(set(snapped_pc)),
"dev_from_grid_deg_pc": np.max(np.abs((dpc - snapped_pc + 180) % 360 - 180))})
md2 = pd.DataFrame(rows)
print("num_directions in the table:", sorted(md2.num_directions.unique()), "; durations:", sorted(md2.duration_s.unique()),
"; video rate:", md2.video_hz.round(2).min(), "-", md2.video_hz.round(2).max(), "Hz")
print("Boundaries found per trial (15 expected inside a trial):", md2.boundaries.value_counts().sort_index().to_dict())
print(f"Period from the boundaries (ms): median {md2.period_ms.median():.1f}, range {md2.period_ms.min():.1f}-{md2.period_ms.max():.1f};"
f" trials within 843.75-1031.25 ms: {int(md2.period_ms.between(843.75, 1031.25).sum())} of {len(md2)}")
for label, sfx in (("optical flow (used)", ""), ("phase correlation (first round)", "_pc")):
print(f"{label}:")
print(f" distinct directions per trial (10 degree tolerance): {md2['distinct' + sfx].value_counts().to_dict()}")
print(f" distinct directions after snapping to the 22.5 degree grid: {md2['distinct_on_grid' + sfx].value_counts().to_dict()}")
dev = md2["dev_from_grid_deg" + sfx]
print(f" largest deviation of a trial's directions from the grid (deg): median over trials {dev.median():.1f}, "
f"95th percentile {dev.quantile(0.95):.1f}, max {dev.max():.1f}")
all_dev = np.concatenate([np.abs((np.asarray(r["directions"]) - np.round(np.asarray(r["directions"]) / 22.5) * 22.5 + 180) % 360 - 180)
for k in PAPER_SCANS for r in monet[k]])
print(f"Optical flow, deviation of each of the {len(all_dev)} periods from the grid (deg): percentiles 50/95/99/100 "
f"{np.round(np.percentile(all_dev, [50, 95, 99, 100]), 1).tolist()}; periods more than 6.25 deg off "
f"(the least that brings two grid directions within 10 deg of each other): {int((all_dev > 6.25).sum())}")
# Every trial that the phase correlation (first round) measured with fewer than 16 directions, by either rule
flag = md2[(md2.distinct_pc < 16) | (md2.distinct_on_grid_pc < 16)]
print(f"Trials the phase correlation measured with fewer than 16 directions: {len(flag)} "
f"({int((md2.distinct_pc < 16).sum())} by the 10 degree rule, {int((md2.distinct_on_grid_pc < 16).sum())} on the grid)")
print(flag[["scan", "trial", "seed", "distinct_pc", "distinct_on_grid_pc", "dev_from_grid_deg_pc",
"distinct", "distinct_on_grid", "dev_from_grid_deg"]].round(1).to_string(index=False))
missing = lambda d: [float(g) for g in sorted(set(np.arange(16) * 22.5) - set(np.round(d / 22.5) * 22.5 % 360))]
for _, f in flag.iterrows():
r = monet[tuple(int(x) for x in f.scan.split("-"))][f.trial]
print(f"scan {f.scan} seed {f.seed}, trial {f.trial}, directions of the 16 periods in order (deg):")
print(" optical flow: ", [round(float(x), 1) for x in r["directions"]])
print(" phase correlation:", [round(float(x), 1) for x in r["directions_pc"]])
print(" grid points not hit, optical flow:", missing(r["directions"]), "; phase correlation:", missing(r["directions_pc"]))
# Same seed, same directions? Each seed is shown twice in a scan, and 10 seeds in every scan.
agree = []
for k in PAPER_SCANS:
mo = M[k]["monet2"].sort_values("start_time").reset_index(drop=True)
for s in mo.rng_seed.unique():
ii = np.where(mo.rng_seed == s)[0]
a, b = (np.round(monet[k][i]["directions"] / 22.5) % 16 for i in ii[:2])
agree.append(np.mean(a == b))
print(f"Repeats of a seed within a scan with the same 16 snapped directions: {np.mean(np.array(agree) == 1):.3f} of {len(agree)}")
print(md2[md2.distinct < 16].to_string(index=False) if (md2.distinct < 16).any() else "optical flow: every trial has 16 distinct directions")
num_directions in the table: [np.int64(16)] ; durations: [np.float64(15.0)] ; video rate: 60.0 - 60.0 Hz
Boundaries found per trial (15 expected inside a trial): {14: 10, 15: 516, 16: 34}
Period from the boundaries (ms): median 937.1, range 929.6-945.4; trials within 843.75-1031.25 ms: 560 of 560
optical flow (used):
distinct directions per trial (10 degree tolerance): {16: 560}
distinct directions after snapping to the 22.5 degree grid: {16: 560}
largest deviation of a trial's directions from the grid (deg): median over trials 4.0, 95th percentile 7.8, max 7.9
phase correlation (first round):
distinct directions per trial (10 degree tolerance): {16: 556, 15: 4}
distinct directions after snapping to the 22.5 degree grid: {16: 554, 15: 6}
largest deviation of a trial's directions from the grid (deg): median over trials 7.0, 95th percentile 9.3, max 11.0
Optical flow, deviation of each of the 8960 periods from the grid (deg): percentiles 50/95/99/100 [1.3, 4.0, 5.0, 7.9]; periods more than 6.25 deg off (the least that brings two grid directions within 10 deg of each other): 36 Trials the phase correlation measured with fewer than 16 directions: 8 (4 by the 10 degree rule, 6 on the grid) scan trial seed distinct_pc distinct_on_grid_pc dev_from_grid_deg_pc distinct distinct_on_grid dev_from_grid_deg 5-6 10 7637.0 15 15 10.1 16 16 4.5 5-6 26 7637.0 15 15 10.1 16 16 4.5 6-7 3 5145.0 16 15 10.9 16 16 3.2 6-7 11 2618.0 16 15 10.6 16 16 4.8 6-7 23 5145.0 16 15 10.9 16 16 3.2 6-7 35 2618.0 16 15 10.6 16 16 4.8 9-6 9 6154.0 15 16 8.4 16 16 5.1 9-6 21 6154.0 15 16 8.4 16 16 5.1 scan 5-6 seed 7637.0, trial 10, directions of the 16 periods in order (deg): optical flow: [319.5, 111.3, 291.4, 336.8, 0.7, 161.4, 224.6, 270.6, 180.1, 20.8, 135.6, 41.2, 200.5, 65.9, 90.4, 244.5] phase correlation: [318.9, 124.9, 299.3, 338.6, 357.5, 154.6, 224.8, 271.1, 180.0, 20.8, 134.1, 47.5, 203.2, 64.3, 88.9, 244.4] grid points not hit, optical flow: [] ; phase correlation: [112.5] scan 5-6 seed 7637.0, trial 26, directions of the 16 periods in order (deg): optical flow: [319.5, 111.3, 291.4, 336.8, 0.7, 161.4, 224.6, 270.6, 180.1, 20.8, 135.6, 41.2, 200.5, 65.9, 90.4, 244.5] phase correlation: [318.9, 124.9, 299.3, 338.6, 357.5, 154.6, 224.8, 271.1, 180.0, 20.8, 134.1, 47.5, 203.2, 64.3, 88.9, 244.4] grid points not hit, optical flow: [] ; phase correlation: [112.5] scan 6-7 seed 5145.0, trial 3, directions of the 16 periods in order (deg): optical flow: [65.3, 202.2, 22.1, 46.8, 1.5, 224.1, 317.3, 290.1, 337.1, 248.8, 138.2, 159.1, 112.2, 91.4, 268.8, 181.0] phase correlation: [63.1, 203.8, 22.0, 41.7, 0.7, 225.8, 319.2, 296.9, 333.0, 238.8, 134.5, 157.2, 124.1, 92.8, 273.8, 179.2] grid points not hit, optical flow: [] ; phase correlation: [112.5] scan 6-7 seed 2618.0, trial 11, directions of the 16 periods in order (deg): optical flow: [160.0, 269.6, 296.1, 20.4, 224.0, 40.2, 336.4, 134.6, 90.2, 67.5, 177.1, 3.4, 245.1, 316.4, 207.0, 114.7] phase correlation: [154.9, 268.9, 304.4, 25.2, 224.0, 39.3, 335.5, 138.7, 89.5, 58.6, 184.2, 1.6, 244.6, 319.0, 203.9, 113.6] grid points not hit, optical flow: [] ; phase correlation: [292.5] scan 6-7 seed 5145.0, trial 23, directions of the 16 periods in order (deg): optical flow: [65.3, 202.2, 22.1, 46.8, 1.5, 224.1, 317.3, 290.1, 337.1, 248.8, 138.2, 159.1, 112.2, 91.4, 268.8, 181.0] phase correlation: [63.1, 203.8, 22.0, 41.7, 0.7, 225.8, 319.2, 296.9, 333.0, 238.8, 134.5, 157.2, 124.1, 92.8, 273.8, 179.2] grid points not hit, optical flow: [] ; phase correlation: [112.5] scan 6-7 seed 2618.0, trial 35, directions of the 16 periods in order (deg): optical flow: [160.0, 269.6, 296.1, 20.4, 224.0, 40.2, 336.4, 134.6, 90.2, 67.5, 177.1, 3.4, 245.1, 316.4, 207.0, 114.7] phase correlation: [154.9, 268.9, 304.4, 25.2, 224.0, 39.3, 335.5, 138.7, 89.5, 58.6, 184.2, 1.6, 244.6, 319.0, 203.9, 113.6] grid points not hit, optical flow: [] ; phase correlation: [292.5] scan 9-6 seed 6154.0, trial 9, directions of the 16 periods in order (deg): optical flow: [158.1, 219.9, 22.1, 313.0, 1.0, 46.1, 177.3, 135.0, 338.5, 247.9, 202.6, 91.3, 112.7, 65.0, 271.3, 293.6] phase correlation: [155.8, 224.7, 26.5, 316.0, 3.0, 39.4, 188.4, 133.7, 336.3, 244.6, 196.8, 93.4, 116.7, 59.8, 270.6, 300.1] grid points not hit, optical flow: [] ; phase correlation: [] scan 9-6 seed 6154.0, trial 21, directions of the 16 periods in order (deg): optical flow: [158.1, 219.9, 22.1, 313.0, 1.0, 46.1, 177.3, 135.0, 338.5, 247.9, 202.6, 91.3, 112.7, 65.0, 271.3, 293.6] phase correlation: [155.8, 224.7, 26.5, 316.0, 3.0, 39.4, 188.4, 133.7, 336.3, 244.6, 196.8, 93.4, 116.7, 59.8, 270.6, 300.1] grid points not hit, optical flow: [] ; phase correlation: [] Repeats of a seed within a scan with the same 16 snapped directions: 1.000 of 280 optical flow: every trial has 16 distinct directions
k = PAPER_SCANS[0]
r = monet[k][0]
fig, ax = plt.subplots(1, 2, figsize=(11, 3.2))
ax[0].step(np.arange(16) * 15 / 16, r["directions"], where="post")
for b in r["boundaries"]:
ax[0].axvline(b, color="0.8", lw=0.8)
ax[0].set(xlabel="time from trial onset (s)", ylabel="measured direction (deg)", title=f"scan {k[0]}-{k[1]}, first Monet2 trial")
ax[1].hist(md2.period_ms, bins=40)
ax[1].set(xlabel="period from boundaries (ms)", ylabel="trials", title=f"{len(md2)} trials, range {md2.period_ms.min():.0f}-{md2.period_ms.max():.0f} ms")
plt.tight_layout()
Result: reproduced. The table gives 16 directions and 15 s for every Monet2 trial. From the video, the period fitted to the direction changes is 929.6 to 945.4 ms (median 937.1), within tolerance in all 560 trials (40 in each of the 14 scans). With the direction of each period taken from the optical flow, all 560 trials have 16 distinct directions under both measures (10 degree tolerance, and after snapping to the 22.5 degree grid). The measured directions lie within 7.9 degrees of the grid (99% of the 8,960 periods within 5.0 degrees), and the two showings of a seed give the same directions in all 280 pairs. The phase correlation estimate of the first round gave 15 distinct directions in 4 trials (10 degree rule) or 6 (grid), with deviations up to 11.0 degrees; these are 8 trials, both showings of seeds 7637 (scan 5-6), 5145 and 2618 (scan 6-7) and 6154 (scan 9-6). In all 8 the optical flow hits all 16 grid directions (in scan 5-6, seed 7637, the period the phase correlation put at 124.9 degrees measures 111.3, next to the missing 112.5). The spacing is the 22.5 degree grid. The speed (42.8 degrees per second) is not given in the dataset and is not tested. This rests on directions measured from the video, a method of this attempt.
Responses to the oracle clips and to Monet2¶
The next two claims use the traces. The dataset holds fluorescence traces only; the deconvolved
traces the paper uses are not in it, so fluorescence takes their place (a deviation). For every
mask of the 14 scans, the cell below reads the frames of the 10 oracle repeats and of the 40 Monet2
trials (microns.field_responses), and keeps per mask: the oracle score under the two readings,
the mean pairwise correlation between single repeats, and the mean response in the central 500 ms
of each direction period, over all 40 trials and over each half of them. Frames are taken from
each field's own time stamps; the per-unit delay within a frame that the paper's code adds is not
in the dataset and is not applied.
To check that the traces and the stimulus tables share a clock, the Monet2 windows are also shifted by -2, -1, -0.5, +0.5, +1 and +2 direction periods, and the split-half reliability of the direction tuning (correlation over the 16 directions between the first and second 20 trials) is computed at each shift. If the clocks agree, it should be highest at a small positive shift (the response delay of GCaMP6s fluorescence) and fall at whole-period shifts, which assign the wrong direction to each window.
started = time.time()
jobs, owners = [], []
for k in PAPER_SCANS:
dirs = np.array([r["directions"] for r in monet[k]])
js = microns.field_jobs(AID[k], M[k], dirs)
jobs += js
owners += [(k, j[1]) for j in js]
out = microns.all_field_responses(jobs, workers=12)
R = {o: x for o, x in zip(owners, out)}
print(f"read {len(out)} fields of {len(PAPER_SCANS)} scans in {time.time() - started:.0f} s; "
f"frames read per field: {min(x['n_frames_read'] for x in out)}-{max(x['n_frames_read'] for x in out)}; "
f"frames per 500 ms window: {sorted(set(f for x in out for f in x['frames_per_window']))}")
units = []
for (k, fl), x in R.items():
F = M[k]["fields"].set_index("field").loc[fl]
n = x["n_rois"]
units.append(pd.DataFrame({"scan": f"{k[0]}-{k[1]}", "session": k[0], "scan_idx": k[1], "field": fl, "roi": np.arange(n),
"score_a": x["score_a"], "score_b": x["score_b"], "pairwise": x["pairwise"],
"score_a_resid": x["score_a_resid"]}))
units = pd.concat(units, ignore_index=True)
print("units (all masks):", len(units))
read 104 fields of 14 scans in 93 s; frames read per field: 7674-11785; frames per 500 ms window: [3, 4, 5] units (all masks): 125413
# mask type of every unit, from the metadata read above (the EM fields keep it; read the others here)
import h5py
def mask_types(asset_id, n_fields):
f = microns.open_asset(asset_id)
out = {i: np.array([microns._str(t) for t in f[f"processing/ophys/ImageSegmentation/PlaneSegmentation{i}/mask_type"][:]])
for i in range(1, n_fields + 1)}
f.close()
return out
mtypes = {k: mask_types(AID[k], len(M[k]["fields"])) for k in PAPER_SCANS}
units["mask_type"] = np.concatenate([mtypes[(s, c)][fl] for (s, c, fl), g in units.groupby(["session", "scan_idx", "field"], sort=False)])
print(units.mask_type.value_counts().to_dict())
lag = pd.DataFrame([{"scan": f"{k[0]}-{k[1]}", "field": fl, **x["lag_reliability"]} for (k, fl), x in R.items()])
lag_cols = [c for c in lag.columns if c not in ("scan", "field")]
print("Clock check: median over masks of the split-half tuning reliability, by shift of the Monet2 windows (s).")
print("Median over the fields of each scan:")
print(lag.groupby("scan", sort=False)[lag_cols].median().round(3).to_string())
best = lag[lag_cols].idxmax(axis=1)
print("Shift with the highest reliability, count of fields:", best.value_counts().to_dict())
{'soma': 115372, 'artifact': 10041}
Clock check: median over masks of the split-half tuning reliability, by shift of the Monet2 windows (s).
Median over the fields of each scan:
-1.875 -0.9375 -0.46875 0.0 0.46875 0.9375 1.875
scan
4-7 0.145 0.180 0.142 0.311 0.487 0.378 0.183
5-6 0.068 0.066 0.066 0.163 0.326 0.242 0.110
5-7 0.078 0.089 0.106 0.210 0.383 0.318 0.172
6-2 0.079 0.090 0.093 0.224 0.392 0.283 0.115
6-4 0.099 0.116 0.105 0.215 0.352 0.247 0.156
6-6 0.011 -0.014 0.017 0.149 0.298 0.197 0.071
6-7 0.051 0.077 0.059 0.116 0.261 0.165 0.083
7-3 0.077 0.078 0.097 0.273 0.425 0.305 0.154
7-4 0.118 0.135 0.140 0.291 0.442 0.336 0.178
7-5 0.019 0.025 0.053 0.165 0.279 0.195 0.104
8-5 0.038 0.059 0.065 0.173 0.332 0.230 0.107
9-3 0.069 0.073 0.069 0.193 0.390 0.308 0.152
9-4 0.100 0.140 0.151 0.244 0.370 0.267 0.122
9-6 0.062 0.049 0.044 0.147 0.229 0.170 0.065
Shift with the highest reliability, count of fields: {0.46875: 104}
The reliability of the direction tuning is highest when the windows are shifted by +0.47 s in every scan, and lower at whole-period shifts either way, which put each window on a neighbouring direction. The lag is the slow rise and decay of GCaMP6s fluorescence. This shows that the traces and the Monet2 table share a clock to within a fraction of a period in every scan, with the stimulus leading the response, as it must.
fig3f-oracle-reliability¶
Neurons differ in how reliably they respond to the repeated oracle clips; units with high oracle scores give repeat-to-repeat responses that are consistent, and units with low scores do not.
Criterion: the oracle score per unit is the jackknife mean of correlations between each oracle trial and the mean of the remaining trials, (a) one trial = one repeat of the 6 clips (10 trials), (b) each clip scored apart over its 10 repeats and the 6 scores averaged. Units at the 99th, 79th and 34th percentiles of the score are selected and their rasters shown. Reproduced when, under both readings, the score can be computed for the units of the 14 scans and the three selected units' scores increase from the 34th to the 99th percentile with the 99th above 0. Variant: the mean pairwise correlation between single repeats of the 100 units nearest each percentile.
Units are the somatic masks (the paper's functional units); all masks are shown too. The score is computed on fluorescence (see above).
soma = units[units.mask_type == "soma"]
PCTS = (34, 79, 99)
for reading, col in (("a", "score_a"), ("b", "score_b")):
for name, u in (("somatic masks", soma), ("all masks", units)):
s = u[col]
print(f"reading ({reading}), {name}: {s.notna().sum()} of {len(u)} units scored; "
f"percentiles 1/34/50/79/99: {np.round(np.nanpercentile(s, [1, 34, 50, 79, 99]), 3).tolist()}; "
f"share below 0: {np.mean(s < 0):.3f}")
picked = {}
for reading, col in (("a", "score_a"), ("b", "score_b")):
s = soma[col]
rows = []
for p in PCTS:
target = np.nanpercentile(s, p)
i = (s - target).abs().idxmin()
near = soma.loc[(s - target).abs().nsmallest(100).index]
rows.append({"percentile": p, "unit": f"{soma.scan[i]} field {soma.field[i]} roi {soma.roi[i]}",
"score": round(s[i], 3), "pairwise_r_of_unit": round(soma.pairwise[i], 3),
"pairwise_r_100_nearest_mean": round(near.pairwise.mean(), 3)})
picked[(reading, p)] = i
t = pd.DataFrame(rows)
print(f"\nreading ({reading}), selected units:")
print(t.to_string(index=False))
sc_ = t.score.values
print(f" increasing from 34th to 99th: {bool(np.all(np.diff(sc_) > 0))}; 99th above 0: {bool(sc_[-1] > 0)}")
# Shared signal: artifact masks alone, the mean trace of each field, and each mask with its fit on
# the field's mean trace removed (fluorescence holds neuropil and any light from the monitor).
art = units[units.mask_type == "artifact"]
print(f"\nreading (a), artifact masks only: {art.score_a.notna().sum()} scored; percentiles 34/79/99: "
f"{np.round(np.nanpercentile(art.score_a, PCTS), 3).tolist()}; somatic: {np.round(np.nanpercentile(soma.score_a, PCTS), 3).tolist()}")
fm = np.array([x["score_field_mean"] for x in R.values()])
print(f"reading (a), score of the mean trace over all masks of a field: median {np.median(fm):.3f}, range {fm.min():.3f}-{fm.max():.3f} ({len(fm)} fields)")
for name, u in (("somatic", soma), ("artifact", art)):
print(f"reading (a), {name} masks with the field's mean trace regressed out: percentiles 34/79/99 "
f"{np.round(np.nanpercentile(u.score_a_resid, PCTS), 3).tolist()}; share below 0: {np.mean(u.score_a_resid < 0):.3f}")
s = soma.score_a_resid
rows = []
for p_ in PCTS:
i = (s - np.nanpercentile(s, p_)).abs().idxmin()
rows.append(round(float(s[i]), 3))
print(f" units at the 34th, 79th and 99th percentiles of the residual score: {rows}; increasing: {bool(np.all(np.diff(rows) > 0))}")
print("\nCorrelation between the two readings over somatic masks:", round(soma[["score_a", "score_b"]].corr().iloc[0, 1], 3))
print("Median score (a) by scan:")
print(soma.groupby("scan", sort=False).score_a.agg(["count", "median", lambda x: np.nanpercentile(x, 99)]).round(3).to_string())
reading (a), somatic masks: 115372 of 115372 units scored; percentiles 1/34/50/79/99: [0.024, 0.208, 0.266, 0.396, 0.702]; share below 0: 0.004
reading (a), all masks: 125413 of 125413 units scored; percentiles 1/34/50/79/99: [0.023, 0.21, 0.268, 0.397, 0.698]; share below 0: 0.004
reading (b), somatic masks: 115372 of 115372 units scored; percentiles 1/34/50/79/99: [0.022, 0.151, 0.19, 0.275, 0.465]; share below 0: 0.003
reading (b), all masks: 125413 of 125413 units scored; percentiles 1/34/50/79/99: [0.022, 0.153, 0.192, 0.277, 0.465]; share below 0: 0.003
reading (a), selected units:
percentile unit score pairwise_r_of_unit pairwise_r_100_nearest_mean
34 5-7 field 1 roi 378 0.208 0.086 0.084
79 9-4 field 3 roi 665 0.396 0.187 0.203
99 6-6 field 5 roi 438 0.702 0.528 0.524
increasing from 34th to 99th: True; 99th above 0: True
reading (b), selected units:
percentile unit score pairwise_r_of_unit pairwise_r_100_nearest_mean
34 7-3 field 5 roi 333 0.151 0.067 0.096
79 6-4 field 5 roi 4 0.275 0.136 0.205
99 5-7 field 1 roi 668 0.465 0.617 0.394
increasing from 34th to 99th: True; 99th above 0: True
reading (a), artifact masks only: 10041 scored; percentiles 34/79/99: [0.235, 0.402, 0.65]; somatic: [0.208, 0.396, 0.702]
reading (a), score of the mean trace over all masks of a field: median 0.499, range 0.161-0.780 (104 fields)
reading (a), somatic masks with the field's mean trace regressed out: percentiles 34/79/99 [0.2, 0.38, 0.665]; share below 0: 0.004
reading (a), artifact masks with the field's mean trace regressed out: percentiles 34/79/99 [0.229, 0.38, 0.613]; share below 0: 0.006
units at the 34th, 79th and 99th percentiles of the residual score: [0.2, 0.38, 0.665]; increasing: True
Correlation between the two readings over somatic masks: 0.804
Median score (a) by scan:
count median <lambda_0>
scan
4-7 7493 0.348 0.715
5-6 8592 0.297 0.782
5-7 8138 0.259 0.707
6-2 8158 0.287 0.704
6-4 8221 0.309 0.745
6-6 7971 0.255 0.750
6-7 7887 0.213 0.633
7-3 8618 0.247 0.673
7-4 11201 0.208 0.515
7-5 8194 0.283 0.724
8-5 9941 0.288 0.709
9-3 7973 0.277 0.671
9-4 7855 0.232 0.629
9-6 5130 0.253 0.606
fig, axes = plt.subplots(2, 3, figsize=(13, 5.5))
for row, reading in enumerate(("a", "b")):
for col, p in enumerate(PCTS[::-1]):
i = picked[(reading, p)]
k = (int(soma.session[i]), int(soma.scan_idx[i]))
on, nfr, _ = microns.oracle_setup(M[k])
tr = microns.oracle_trace(AID[k], int(soma.field[i]), int(soma.roi[i]), M[k]["frame_ts"][int(soma.field[i])], on, nfr)
z = (tr - tr.min(axis=1, keepdims=True)) / np.ptp(tr, axis=1, keepdims=True)
ax = axes[row, col]
ax.imshow(z, aspect="auto", cmap="Greys", interpolation="nearest", vmin=0, vmax=1)
ax.set(title=f"({reading}) {p}th pct, score {soma.loc[i, 'score_' + reading]:.2f}", xlabel="frame in repeat", ylabel="repeat")
plt.suptitle("Oracle rasters, each repeat min-max normalised (fluorescence)")
plt.tight_layout()
Result: reproduced. Under both readings the score is computed for all 115,372 somatic masks of the 14 scans. The units at the 34th, 79th and 99th percentiles have scores 0.208, 0.396 and 0.702 under reading (a) and 0.151, 0.275 and 0.465 under reading (b), increasing and above 0. In the variant, the mean pairwise correlation between single repeats of the 100 units nearest each percentile rises from 0.084 to 0.203 to 0.524 (a) and from 0.096 to 0.205 to 0.394 (b). The criterion is met by any score whose 99th percentile is above 0, so it tests little. The 10,041 artifact masks score as high as the somatic ones (0.235, 0.402 and 0.650 at the three percentiles), and the mean trace of a field scores 0.499 (median over 104 fields). Removing each mask's fit on its field's mean trace barely changes the scores (somatic 0.200, 0.380 and 0.665; artifact 0.229, 0.380 and 0.613), so a signal shared across the field does not drive them. What makes the artifact masks score as high is not shown here; a local neuropil signal or real neurites could account for it. The score on fluorescence therefore cannot separate the reliability of a neuron from that of what else its mask holds. The outcome rests on fluorescence in place of the deconvolved traces.
fig3g-direction-tuning¶
Monet2 drives direction-tuned responses; neurons can be found preferring each of the 16 directions, and their trial-averaged responses peak at their preferred direction.
Criterion: per unit, the response in the central 500 ms of each direction period, averaged over the 40 Monet2 trials; preferred direction (a) the direction with the highest mean response, (b) the peak of a von Mises function fitted to the 16 mean responses. Reproduced when, under both readings, every one of the 16 directions is preferred by at least 5 units. Variants: the 5 units per direction with the highest preference index are shown, and a split-half check (preferred direction from the first 20 trials, response from the last 20) is reported.
Fluorescence has an arbitrary offset, so the ratio of the preferred response to the mean is not defined for it; the preference index used to rank units is the preferred response minus the mean over directions, divided by the standard deviation over directions (a deviation from the claims file's ratio, which only chooses the units shown). The direction of each period comes from the video (monet2-directions). Units are the somatic masks.
order = [(k, fl) for (k, fl) in R] # same order as `units`
by_dir = np.concatenate([R[o]["by_dir"] for o in order], axis=2) # (3, 16, units)
by_late = np.concatenate([R[o]["by_dir_late"] for o in order], axis=2)
is_soma = (units.mask_type == "soma").values
grid = np.arange(16) * 22.5
def preferred(resp):
"""resp (16, n): (a) argmax direction, (b) von Mises peak snapped to the grid."""
ok = np.all(np.isfinite(resp), axis=0)
a = np.where(ok, np.nanargmax(np.where(np.isfinite(resp), resp, -np.inf), axis=0), -1)
mu = microns.von_mises_peak(resp.T)
b = np.where(np.isfinite(mu), np.round(mu / 22.5).astype(int) % 16, -1)
return a, b, ok
counts = {}
for name, arr in (("central 500 ms", by_dir), ("shifted +0.47 s", by_late)):
a, b, ok = preferred(arr[0][:, is_soma])
ca = np.bincount(a[a >= 0], minlength=16)
cb = np.bincount(b[b >= 0], minlength=16)
counts[name] = (ca, cb)
print(f"{name}: {ok.sum()} of {is_soma.sum()} somatic masks with all 16 responses")
print(" (a) units per preferred direction:", dict(zip(grid, ca)))
print(" (b) units per preferred direction:", dict(zip(grid, cb)))
print(f" fewest units on a direction: (a) {ca.min()}, (b) {cb.min()}; every direction >= 5: {bool(ca.min() >= 5 and cb.min() >= 5)}")
# Reference without tuning: the difference between the two halves of the trials cancels the tuning
# both halves share and leaves trial-to-trial variability; its argmax counts per direction.
diff = by_dir[1][:, is_soma] - by_dir[2][:, is_soma]
cn = np.bincount(np.nanargmax(np.where(np.isfinite(diff), diff, -np.inf), axis=0), minlength=16)
print(f"reference, argmax of first-half minus second-half responses: fewest units on a direction {cn.min()}, most {cn.max()}")
central 500 ms: 115372 of 115372 somatic masks with all 16 responses
(a) units per preferred direction: {np.float64(0.0): np.int64(6998), np.float64(22.5): np.int64(6609), np.float64(45.0): np.int64(7352), np.float64(67.5): np.int64(5205), np.float64(90.0): np.int64(8467), np.float64(112.5): np.int64(7821), np.float64(135.0): np.int64(5556), np.float64(157.5): np.int64(6413), np.float64(180.0): np.int64(10606), np.float64(202.5): np.int64(6994), np.float64(225.0): np.int64(6694), np.float64(247.5): np.int64(7736), np.float64(270.0): np.int64(11747), np.float64(292.5): np.int64(5635), np.float64(315.0): np.int64(5471), np.float64(337.5): np.int64(6068)}
(b) units per preferred direction: {np.float64(0.0): np.int64(6799), np.float64(22.5): np.int64(6480), np.float64(45.0): np.int64(6815), np.float64(67.5): np.int64(5142), np.float64(90.0): np.int64(9173), np.float64(112.5): np.int64(8140), np.float64(135.0): np.int64(5495), np.float64(157.5): np.int64(6532), np.float64(180.0): np.int64(11475), np.float64(202.5): np.int64(6664), np.float64(225.0): np.int64(5683), np.float64(247.5): np.int64(8206), np.float64(270.0): np.int64(12231), np.float64(292.5): np.int64(5792), np.float64(315.0): np.int64(5084), np.float64(337.5): np.int64(5661)}
fewest units on a direction: (a) 5205, (b) 5084; every direction >= 5: True
shifted +0.47 s: 115372 of 115372 somatic masks with all 16 responses
(a) units per preferred direction: {np.float64(0.0): np.int64(7351), np.float64(22.5): np.int64(6413), np.float64(45.0): np.int64(6081), np.float64(67.5): np.int64(5031), np.float64(90.0): np.int64(9311), np.float64(112.5): np.int64(7522), np.float64(135.0): np.int64(5073), np.float64(157.5): np.int64(7321), np.float64(180.0): np.int64(11414), np.float64(202.5): np.int64(6948), np.float64(225.0): np.int64(5919), np.float64(247.5): np.int64(7511), np.float64(270.0): np.int64(13239), np.float64(292.5): np.int64(5492), np.float64(315.0): np.int64(5496), np.float64(337.5): np.int64(5250)}
(b) units per preferred direction: {np.float64(0.0): np.int64(7711), np.float64(22.5): np.int64(6413), np.float64(45.0): np.int64(5551), np.float64(67.5): np.int64(4698), np.float64(90.0): np.int64(9709), np.float64(112.5): np.int64(7671), np.float64(135.0): np.int64(5086), np.float64(157.5): np.int64(6964), np.float64(180.0): np.int64(12762), np.float64(202.5): np.int64(6518), np.float64(225.0): np.int64(5105), np.float64(247.5): np.int64(7742), np.float64(270.0): np.int64(13877), np.float64(292.5): np.int64(5697), np.float64(315.0): np.int64(4883), np.float64(337.5): np.int64(4985)}
fewest units on a direction: (a) 5031, (b) 4698; every direction >= 5: True
reference, argmax of first-half minus second-half responses: fewest units on a direction 6153, most 8217
resp = by_dir[0][:, is_soma]
sd = np.nanstd(resp, axis=0)
pi = (np.nanmax(resp, axis=0) - np.nanmean(resp, axis=0)) / sd
a_pref, b_pref, ok = preferred(resp)
sel = []
for d in range(16):
idx = np.where((a_pref == d) & np.isfinite(pi))[0]
sel += list(idx[np.argsort(pi[idx])[::-1][:5]])
sel = np.array(sel)
# split-half: preferred direction from the first 20 trials, response from the last 20
first, second = by_dir[1][:, is_soma], by_dir[2][:, is_soma]
pref1 = np.nanargmax(np.where(np.isfinite(first), first, -np.inf), axis=0)
at_pref = second[pref1, np.arange(second.shape[1])]
others = (np.nansum(second, axis=0) - at_pref) / 15
above = at_pref > others
print(f"Split half, all somatic masks: response at the first-half preferred direction exceeds the mean of the other 15 "
f"in the second half for {np.mean(above[ok]):.3f} of {ok.sum()} (0.5 expected without tuning)")
print(f"Split half, the 80 units shown: {np.mean(above[sel]):.3f}; the same 80 with the preferred direction "
f"of the first half equal to that of all trials: {np.mean(pref1[sel] == a_pref[sel]):.3f}")
# The 80 units shown are chosen on all 40 trials, second half included, so their split-half share is
# raised by the selection. The same choice made on the first half alone is free of that.
pi1 = (np.nanmax(first, axis=0) - np.nanmean(first, axis=0)) / np.nanstd(first, axis=0)
sel1 = []
for d in range(16):
idx = np.where((pref1 == d) & np.isfinite(pi1) & ok)[0]
sel1 += list(idx[np.argsort(pi1[idx])[::-1][:5]])
sel1 = np.array(sel1)
print(f"Split half, 80 units chosen the same way on the first half only: {np.mean(above[sel1]):.3f}")
# A stimulus-locked signal shared by the masks of a field would also pass this test. Two checks:
# the artifact masks, and how many masks of a field share one first-half preferred direction.
fa, sa = by_dir[1][:, ~is_soma], by_dir[2][:, ~is_soma]
ok_a = np.all(np.isfinite(by_dir[0][:, ~is_soma]), axis=0)
pa = np.nanargmax(np.where(np.isfinite(fa), fa, -np.inf), axis=0)
at_a = sa[pa, np.arange(sa.shape[1])]
above_a = at_a > (np.nansum(sa, axis=0) - at_a) / 15
print(f"Split half, artifact masks: {np.mean(above_a[ok_a]):.3f} of {ok_a.sum()}")
fid = (units.scan.astype(str) + " " + units.field.astype(str)).values[is_soma]
mode_share = pd.Series(pref1[ok]).groupby(fid[ok]).agg(lambda x: x.value_counts(normalize=True).iloc[0])
print(f"Share of a field's somatic masks on the field's most common first-half preferred direction: median "
f"{mode_share.median():.3f}, range {mode_share.min():.3f}-{mode_share.max():.3f} over {len(mode_share)} fields "
f"(1/16 = 0.0625 if spread evenly)")
pi_split = (at_pref - others) / sd
print(f"Second-half preference index of the 80 units: median {np.nanmedian(pi_split[sel]):.2f}; of all units: {np.nanmedian(pi_split[ok]):.2f}")
print(f"Preference index (all trials) of the 80 units: {np.nanmin(pi[sel]):.2f}-{np.nanmax(pi[sel]):.2f}")
u_sel = units[is_soma].iloc[sel]
print("Scans of the 80 units:", u_sel.scan.value_counts().to_dict())
z = (resp[:, sel] - np.nanmean(resp[:, sel], axis=0)) / sd[sel]
fig, ax = plt.subplots(1, 2, figsize=(12, 5))
im = ax[0].imshow(z.T, aspect="auto", cmap="viridis", interpolation="nearest")
ax[0].set(xlabel="direction (deg)", ylabel="unit (5 per preferred direction)", title="trial-averaged response, z over directions",
xticks=range(0, 16, 4), xticklabels=[f"{g:.0f}" for g in grid[::4]])
plt.colorbar(im, ax=ax[0])
ax[1].bar(grid, counts["central 500 ms"][0], width=10, label="(a) argmax")
ax[1].bar(grid + 10, counts["central 500 ms"][1], width=10, label="(b) von Mises")
ax[1].set(xlabel="preferred direction (deg)", ylabel="somatic masks", title="units per preferred direction")
ax[1].legend()
plt.tight_layout()
print("z range shown:", np.round(np.nanmin(z), 2), np.round(np.nanmax(z), 2))
Split half, all somatic masks: response at the first-half preferred direction exceeds the mean of the other 15 in the second half for 0.629 of 115372 (0.5 expected without tuning)
Split half, the 80 units shown: 0.912; the same 80 with the preferred direction of the first half equal to that of all trials: 0.912
Split half, 80 units chosen the same way on the first half only: 0.637
Split half, artifact masks: 0.659 of 10041
Share of a field's somatic masks on the field's most common first-half preferred direction: median 0.113, range 0.086-0.177 over 104 fields (1/16 = 0.0625 if spread evenly)
Second-half preference index of the 80 units: median 2.87; of all units: 0.44
Preference index (all trials) of the 80 units: 3.15-3.73
Scans of the 80 units: {'9-4': 12, '6-7': 10, '9-3': 7, '7-4': 7, '6-6': 7, '9-6': 6, '5-7': 5, '5-6': 5, '7-3': 5, '8-5': 5, '4-7': 4, '6-4': 3, '7-5': 2, '6-2': 2}
z range shown: -1.63 3.73
Result: reproduced. Under both readings every one of the 16 directions is preferred by at least 5,084 somatic masks (reading a, at least 5,205), and by at least 4,698 with the windows shifted by +0.47 s. The count tests little with this many units: the argmax of the difference between the two halves of the trials, which holds no shared tuning, also puts thousands of units on every direction (see the reference line above). The split-half variant gives the evidence of tuning: over all somatic masks, the response at the first-half preferred direction exceeds the mean of the other 15 in the second half for 62.9% (0.5 without tuning), and for 63.7% of 80 units chosen on the first half alone. The 80 units shown are chosen on all 40 trials, second half included, so their 91.2% is raised by the selection and is not evidence. The test does not separate the tuning of neurons from a stimulus-locked signal that masks share: the artifact masks pass it as often (65.9% of 10,041), and the most common first-half preferred direction of a field holds a median 11.3% of its somatic masks (range 8.6% to 17.7% over 104 fields), against 6.25% if the preferences were spread evenly. The split-half reliability of the tuning also peaks at a shift of +0.47 s in all 104 fields (clock check above). The outcome rests on fluorescence and on directions measured from the video.
EM identifiers held by the dataset (variant for manual-coreg-19181)¶
The claim manual-coreg-19181 is blocked: its table, CAVE coregistration_manual_v4, needs an
account token. The claims file names the EM identifiers the dataset holds in its plane
segmentation columns as a variant; they are counted here. The description says they were taken
from CAVE on 2023-02-12, and pt_root_id is described as from the "Proofread Segmentation (v343)".
rows = []
roots = []
for k in PAPER_SCANS:
F = M[k]["fields"]
for _, f in F.iterrows():
if f.has_em_columns:
r = f.root_ids[np.isfinite(f.root_ids)]
roots.append(pd.DataFrame({"scan": f"{k[0]}-{k[1]}", "field": f.field, "root": r.astype(np.int64)}))
rows.append({"scan": f"{k[0]}-{k[1]}", "field": f.field, "em_columns": f.has_em_columns,
"rois": f.n_rois, "with_root_id": int(f.n_with_root_id) if f.has_em_columns else 0})
emc = pd.DataFrame(rows)
roots = pd.concat(roots)
print(emc.groupby("scan", sort=False)[["rois", "with_root_id"]].sum().assign(
fields_with_columns=emc.groupby("scan", sort=False).em_columns.sum()).to_string())
print(f"\n14 scans: {int(emc.with_root_id.sum())} ROIs with an EM root id, {roots.root.nunique()} distinct root ids; "
f"fields with EM columns {int(emc.em_columns.sum())} of {len(emc)}; "
f"ROIs in those fields {int(emc[emc.em_columns].rois.sum())} of {int(emc.rois.sum())}")
print("ROIs with root id 0 (no segment):", int((roots.root == 0).sum()))
multi = roots[roots.root > 0].groupby("root").scan.nunique()
print("Root ids other than 0 found in more than one scan:", int((multi > 1).sum()))
print("Paper: 19,181 ROIs matched to 15,439 EM neurons (coregistration_manual_v4).")
rois with_root_id fields_with_columns scan 4-7 8395 685 4 5-6 9254 836 4 5-7 8862 747 4 6-2 8756 786 4 6-4 8826 891 4 6-6 8551 702 4 6-7 8602 543 4 7-3 9296 490 4 7-4 12675 872 4 7-5 9034 426 4 8-5 10579 646 4 9-3 8661 584 3 9-4 8415 640 3 9-6 5507 15 2 14 scans: 8863 ROIs with an EM root id, 8326 distinct root ids; fields with EM columns 52 of 104; ROIs in those fields 60263 of 125413 ROIs with root id 0 (no segment): 2 Root ids other than 0 found in more than one scan: 395 Paper: 19,181 ROIs matched to 15,439 EM neurons (coregistration_manual_v4).
Only 52 of the 104 fields, one of the two fields at each depth, have EM columns; they hold 8,863 ROIs with a root id (8,326 distinct values, among them 0, the id of no segment, in 2 ROIs; 395 non-zero root ids occur in more than one scan). The other half of each scan's fields has no EM identifiers. These counts are therefore not comparable with the paper's 19,181 and 15,439, and decide nothing.
Matched units in two scans (variant for ed4-matched-higher-signal-correlation)¶
The claim ed4-matched-higher-signal-correlation is blocked with manual-coreg-19181, whose table it
needs. As a variant that decides nothing, the test of the claims file is run here on the matches
the dataset itself holds: masks of different scans whose pt_root_id is the same EM neuron. Only
reading (b) of "nearest" can be run (the nearest mask in the same field, by the 2D centroid of the
image masks), as the dataset has no stack coordinates. For every pair of scans in which a root id
occurs, the matched signal correlation is the Pearson correlation between the two masks'
trial-averaged responses to the 6 oracle clips (each clip resampled to a common 0.25 s grid, clips
in one fixed order); the two controls pair each matched mask with the somatic mask nearest to the
other matched mask in its field, among masks without that root id (Methods, Evaluating manual
matches, as the claims file gives it). Signal is fluorescence, and the oracle score is reading (a)
of fig3f. The files store pt_root_id as a 64-bit float, which cannot hold every 18-digit root id
exactly, so the EM cell body points (pt_position) of the two masks of a pair are compared too, and
the results are also given for pairs whose points lie within 10 um, about the size of a cell body (a
standard choice).
from scipy.stats import wilcoxon
started = time.time()
em_keys = [(k, int(fl)) for k in PAPER_SCANS for fl in M[k]["fields"].field[M[k]["fields"].has_em_columns]]
cent = dict(zip(em_keys, microns.all_field_centroids([(AID[k], fl) for k, fl in em_keys], workers=8)))
canon = sorted(orders[PAPER_SCANS[0]]) # one fixed order of the 6 oracle clips
def signal(k, fl, roi):
pos = [orders[k].index(h) for h in canon]
return R[(k, fl)]["clip_means"][pos, :, roi].ravel()
score = units.set_index(["session", "scan_idx", "field", "roi"]).score_a
rows = []
for k, fl in em_keys:
F = M[k]["fields"].set_index("field").loc[fl]
for roi, (root, mt) in enumerate(zip(F.root_ids, F.mask_type_arr)):
if np.isfinite(root) and root > 0:
rows.append({"k": k, "field": fl, "roi": roi, "root": int(root), "soma": mt == "soma", "pos": F.pt_position[roi]})
em_units = pd.DataFrame(rows)
multi_roots = em_units.groupby("root").k.nunique().loc[lambda v: v > 1].index
n_zero = sum(int((M[k]["fields"].set_index("field").loc[fl].root_ids == 0).sum()) for k, fl in em_keys)
print(f"masks with pt_root_id 0 (no segment), left out: {n_zero}")
print("pt_root_id is stored as", sorted({M[k]["fields"].set_index("field").loc[fl].root_id_dtype for k, fl in em_keys}),
"; float64 holds integers exactly only up to 2**53, and root ids near", f"{em_units.root.min():.3g}", "are spaced by",
np.spacing(float(em_units.root.min())), "in float64")
print(f"EM fields {len(em_keys)}; masks with a root id {len(em_units)}; root ids in two or more scans {len(multi_roots)}")
def nearest_other(k, fl, roi, root):
F = M[k]["fields"].set_index("field").loc[fl]
c = cent[(k, fl)]
ok = (F.mask_type_arr == "soma") & ~(F.root_ids == root)
ok[roi] = False
d = np.hypot(*(c - c[roi]).T)
d[~ok] = np.inf
return int(np.argmin(d)), float(d.min())
pairs = []
for root in multi_roots:
g = em_units[em_units.root == root]
for i, a in enumerate(g.itertuples()):
for b in g.iloc[i + 1:].itertuples():
if a.k == b.k:
continue
ca, da = nearest_other(b.k, b.field, b.roi, root) # control in scan B, near the matched mask B
cb, db = nearest_other(a.k, a.field, a.roi, root) # control in scan A, near the matched mask A
sa, sb = signal(a.k, a.field, a.roi), signal(b.k, b.field, b.roi)
pairs.append({"root": root, "A": f"{a.k[0]}-{a.k[1]}", "B": f"{b.k[0]}-{b.k[1]}",
"matched": np.corrcoef(sa, sb)[0, 1],
"control_A": np.corrcoef(sa, signal(b.k, b.field, ca))[0, 1],
"control_B": np.corrcoef(sb, signal(a.k, a.field, cb))[0, 1],
"control_dist_px": max(da, db), "em_pos_dist_um": float(np.linalg.norm((a.pos - b.pos) * [0.004, 0.004, 0.04])),
"score_A": score[(a.k[0], a.k[1], a.field, a.roi)], "score_B": score[(b.k[0], b.k[1], b.field, b.roi)],
"both_soma": a.soma and b.soma})
ed4 = pd.DataFrame(pairs)
print(f"distance between the EM cell body points (pt_position) of the two masks of a pair (um): percentiles 50/75/95/100 "
f"{np.round(np.percentile(ed4.em_pos_dist_um, [50, 75, 95, 100]), 2).tolist()}; pairs more than 10 um apart (about a cell body): "
f"{int((ed4.em_pos_dist_um > 10).sum())} of {len(ed4)}")
print(f"pairs of scans for one root id: {len(ed4)} (roots {ed4.root.nunique()}); both masks somatic in {int(ed4.both_soma.sum())}; "
f"distance to the control (px): median {ed4.control_dist_px.median():.1f}, max {ed4.control_dist_px.max():.1f}; {time.time() - started:.0f} s")
near = ed4.em_pos_dist_um <= 10
high = (ed4.score_A > 0.2) & (ed4.score_B > 0.2)
for name, sub in (("all pairs", ed4), ("both oracle scores above 0.2", ed4[high]),
("EM points within 10 um", ed4[near]), ("EM points within 10 um, both scores above 0.2", ed4[near & high])):
beats = (sub.matched > sub.control_A) & (sub.matched > sub.control_B)
dA, dB = sub.matched - sub.control_A, sub.matched - sub.control_B
print(f"{name}: n = {len(sub)}; matched r median {sub.matched.median():.3f}, controls {sub.control_A.median():.3f} and {sub.control_B.median():.3f}; "
f"matched above both controls in {beats.mean():.3f}; median difference {dA.median():.3f} and {dB.median():.3f}; "
f"Wilcoxon p {wilcoxon(dA).pvalue:.2g} and {wilcoxon(dB).pvalue:.2g}")
masks with pt_root_id 0 (no segment), left out: 2 pt_root_id is stored as ['float64'] ; float64 holds integers exactly only up to 2**53, and root ids near 8.65e+17 are spaced by 128.0 in float64 EM fields 52; masks with a root id 8861; root ids in two or more scans 395
distance between the EM cell body points (pt_position) of the two masks of a pair (um): percentiles 50/75/95/100 [0.84, 2.06, 161.09, 547.46]; pairs more than 10 um apart (about a cell body): 50 of 538 pairs of scans for one root id: 538 (roots 395); both masks somatic in 538; distance to the control (px): median 6.4, max 14.3; 63 s all pairs: n = 538; matched r median 0.585, controls 0.239 and 0.233; matched above both controls in 0.719; median difference 0.273 and 0.273; Wilcoxon p 2e-60 and 1.9e-51 both oracle scores above 0.2: n = 399; matched r median 0.664, controls 0.298 and 0.260; matched above both controls in 0.777; median difference 0.303 and 0.353; Wilcoxon p 2e-50 and 3.3e-44 EM points within 10 um: n = 488; matched r median 0.612, controls 0.270 and 0.242; matched above both controls in 0.752; median difference 0.284 and 0.291; Wilcoxon p 1.4e-59 and 4.3e-54 EM points within 10 um, both scores above 0.2: n = 368; matched r median 0.679, controls 0.318 and 0.270; matched above both controls in 0.804; median difference 0.311 and 0.362; Wilcoxon p 5.2e-50 and 7.1e-47
These matches are the dataset's own EM identifiers, which cover one field at each depth, not the paper's coregistration_manual_v4 table, so the claim stays blocked and these numbers decide nothing. Over 538 pairs of scans (395 root ids), the matched signal correlation (median 0.585) is above both controls (medians 0.239 and 0.233) in 71.9% of pairs, more than half, and the median differences, 0.273 and 0.273, grow to 0.303 and 0.353 when both masks score above 0.2 (77.7% of 399 pairs above both controls; Wilcoxon p below 1e-43 throughout). In 50 of the 538 pairs the two masks' EM cell body points lie more than 10 um apart, so the same root id does not always mark the same cell body; without them the result is the same (75.2% of 488 pairs; 80.4% of 368 with both scores above 0.2). On these matches the claims file's test would pass under reading (b).
nuclei-144120¶
Nuclei were automatically segmented in subvolume 65, 144,120 in all.
Data elsewhere: the nucleus detection table nucleus_detection_v0, as released on the BossDB open
data bucket (iarpa_microns/minnie/minnie65/nucleus_detection/nucleus_detection_v0.csv, ETag
6a269d25a4b065cdc44be1d61b3ff2c4, modified 2021-07-01). The file has no header row; every line is
one detection.
BOSS = "https://bossdb-open-data.s3.amazonaws.com/iarpa_microns/minnie/minnie65/"
nuc = pd.read_csv(BOSS + "nucleus_detection/nucleus_detection_v0.csv", header=None,
names=["id", "valid", "pt_supervoxel_id", "pt_root_id", "x", "y", "z", "volume_um3"])
print("rows (detections):", len(nuc), "; distinct ids:", nuc.id.nunique(), "; valid values:", nuc.valid.value_counts().to_dict())
print("z range (40 nm sections):", nuc.z.min(), "-", nuc.z.max(), "; volume (um3) median", nuc.volume_um3.median().round(1))
print("detections with a root id:", int((nuc.pt_root_id != 0).sum()))
rows (detections): 144120 ; distinct ids: 144120 ; valid values: {'t': 144120}
z range (40 nm sections): 14834 - 27871 ; volume (um3) median 227.8
detections with a root id: 129816
Result: reproduced. The released table holds 144,120 nucleus detections, the paper's count, all in subvolume 65 by their z positions (above section 14,816).
cell-classification¶
An SVM on nucleus features detects neurons with 96.9% precision and 99.6% recall, predicting 82,247 neurons in subvolume 65, of which 69,957 have well-segmented somata; a multilayer perceptron separates excitatory from inhibitory neurons with 97% accuracy (96% precision and 94% recall for inhibitory calls).
Criterion: counts from the released tables; accuracies only if the manual labels are public.
Tables (BossDB, minnie65/cell_types/, modified 2021-07-27): nucleus_neuron_svm.csv (the SVM
calls), allen_soma_ei_class_model_v1.csv (the excitatory and inhibitory calls of the
well-segmented subset) and allen_visp_column_soma_coarse_types_v1.csv (manual labels of the 100
um column). None has a header row.
svm = pd.read_csv(BOSS + "cell_types/nucleus_neuron_svm.csv", header=None,
names=["id", "valid", "pt_supervoxel_id", "pt_root_id", "x", "y", "z", "classification_system", "cell_type"])
ei = pd.read_csv(BOSS + "cell_types/allen_soma_ei_class_model_v1.csv", header=None,
names=["id", "valid", "classification_system", "cell_type", "x", "y", "z"])
col = pd.read_csv(BOSS + "cell_types/allen_visp_column_soma_coarse_types_v1.csv", header=None,
names=["id", "valid", "classification_system", "cell_type", "x", "y", "z"])
print("SVM table:", len(svm), "rows,", svm.id.nunique(), "distinct ids; calls", svm.cell_type.value_counts().to_dict())
svm65 = svm[svm.id.isin(nuc.id)]
print("SVM rows that are detections of nucleus_detection_v0:", len(svm65), "; calls", svm65.cell_type.value_counts().to_dict())
other = svm[~svm.id.isin(nuc.id)]
print("SVM rows not in nucleus_detection_v0:", len(other), "; calls", other.cell_type.value_counts().to_dict())
print("E/I table (well-segmented neurons):", len(ei), "rows; classes", ei.cell_type.value_counts().to_dict(),
"; ids among the SVM neurons:", int(ei.id.isin(svm65[svm65.cell_type == 'neuron'].id).sum()))
print("Column labels:", len(col), "rows;", col.classification_system.value_counts().to_dict())
SVM table: 171815 rows, 171815 distinct ids; calls {'neuron': 89062, 'not-neuron': 82753}
SVM rows that are detections of nucleus_detection_v0: 144117 ; calls {'neuron': 89056, 'not-neuron': 55061}
SVM rows not in nucleus_detection_v0: 27698 ; calls {'not-neuron': 27692, 'neuron': 6}
E/I table (well-segmented neurons): 69957 rows; classes {'excitatory': 61926, 'inhibitory': 8031} ; ids among the SVM neurons: 69957
Column labels: 2204 rows; {'aibs_coarse_excitatory': 1185, 'aibs_coarse_nonneuronal': 679, 'aibs_coarse_error': 172, 'aibs_coarse_inhibitory': 162, 'aibs_coarse_unclear': 3, 'aibs_coarse_neuron': 3}
# SVM against the manual column labels, matched by position (the column points sit on the nucleus
# centroids; distance shown). Neuron = excitatory, inhibitory or neuron; not neuron = nonneuronal;
# error and unclear labels are left out.
from scipy.spatial import cKDTree
nm = lambda df: np.c_[df.x * 4, df.y * 4, df.z * 40] / 1000.0 # um, from 4 x 4 x 40 nm voxels
dist, ix = cKDTree(nm(svm)).query(nm(col))
col = col.assign(svm_call=svm.cell_type.values[ix], match_um=dist)
print("Distance from each column point to the nearest SVM nucleus (um): max", round(dist.max(), 3))
truth = col.classification_system.map({"aibs_coarse_excitatory": True, "aibs_coarse_inhibitory": True,
"aibs_coarse_neuron": True, "aibs_coarse_nonneuronal": False})
use = truth.notna()
t, p = truth[use].astype(bool), (col.svm_call[use] == "neuron")
tp, fp, fn = int((p & t).sum()), int((p & ~t).sum()), int((~p & t).sum())
print(f"{use.sum()} labelled column cells: precision {tp / (tp + fp):.3f}, recall {tp / (tp + fn):.3f} (tp {tp}, fp {fp}, fn {fn})")
# E/I calls of the model against the manual labels of the column (training data of the model)
m_ei = col[col.classification_system.isin(["aibs_coarse_excitatory", "aibs_coarse_inhibitory"])]
dist2, ix2 = cKDTree(nm(ei)).query(nm(m_ei))
near = dist2 < 1.0
pred_inh = ei.cell_type.values[ix2][near] == "inhibitory"
true_inh = (m_ei.classification_system == "aibs_coarse_inhibitory").values[near]
print(f"Column neurons found in the E/I table: {near.sum()} of {len(m_ei)}; agreement {np.mean(pred_inh == true_inh):.3f}; "
f"inhibitory precision {np.sum(pred_inh & true_inh) / max(1, pred_inh.sum()):.3f}, recall {np.sum(pred_inh & true_inh) / max(1, true_inh.sum()):.3f}")
Distance from each column point to the nearest SVM nucleus (um): max 0.0 2029 labelled column cells: precision 0.955, recall 0.996 (tp 1345, fp 63, fn 5) Column neurons found in the E/I table: 1314 of 1347; agreement 0.982; inhibitory precision 0.986, recall 0.868
Result: reproduced. Of the 144,120 nuclei of subvolume 65, the SVM table calls 89,056 neurons (82,247 in the paper, within the 10% tolerance; the full SVM table has 171,815 rows, 27,698 of them not in the nucleus table and nearly all non-neurons). The excitatory and inhibitory table holds 69,957 neurons, the paper's count of well-segmented neurons. Against the manual labels of the column, the SVM has precision 95.5% and recall 99.6% (paper 96.9% and 99.6%). The multilayer perceptron's accuracy was measured by the paper on 350 further cells that are not released; on the column cells, which were its training and validation data, its calls agree with the labels for 98.2%, which is not the paper's test.
Summary¶
| Claim | Data | Outcome |
|---|---|---|
| scans-14-p75-p81 | dataset | reproduced |
| scan-configurations | dataset | reproduced |
| masks-125413-somatic-115372 | dataset | reproduced (exact counts) |
| unique-neurons-75909 | dataset | blocked: data missing (no stack coordinates) |
| area-split-half-visp | dataset | blocked: metadata insufficient (no area per mask) |
| z-drift-under-10um | dataset | blocked: data missing (no structural stack) |
| structural-stack | dataset | blocked: data missing |
| stimulus-composition | dataset | reproduced |
| stimulus-conserved-across-scans | dataset | partially reproduced (one Trippy seed in two scans) |
| oracle-same-order | dataset | reproduced |
| spontaneous-periods | dataset | partially reproduced (every scan within the ranges; the longest period before the stimulus, 4.84 min, falls short of 5.5 min) |
| monet2-directions | dataset | reproduced (directions measured from the video by optical flow) |
| fig3f-oracle-reliability | dataset | reproduced (on fluorescence) |
| fig3g-direction-tuning | dataset | reproduced (on fluorescence) |
| behaviour-recorded | dataset | partially reproduced (eye tracking of scan 4-7 ends 16 min early) |
| photodiode-89pct | dataset | blocked: data missing |
| manual-coreg-19181, ed4-matched-higher-signal-correlation, fig6k-target-responses | with_inputs | blocked: coregistration_manual_v4 is a CAVE table that needs an account token (the dataset's own EM identifiers are counted, and the ed4 test is run on them, above) |
| nuclei-144120 | elsewhere | reproduced |
| cell-classification | elsewhere | reproduced |
| synapse-detection | elsewhere | partially reproduced (both counts within tolerance; precision, recall and partner accuracy not tested; em_volume_counts.ipynb) |
| em-sections | elsewhere | partially reproduced (share of subvolume 35 within tolerance; 19,952 sections with data against 19,974; em_volume_counts.ipynb) |
| 13 other EM and co-registration claims | elsewhere | blocked: the CAVE tables they test, or that choose their neurons, need an account token, and no public copy was found; fiducial-transform-residual names no table |
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.
Every claim tested is reproduced or partially reproduced. The three partial outcomes on the dataset
are about its content: one Trippy seed shown in two scans, eye tracking that stops early in scan 4-7,
and a longest period before the stimulus of 4.84 min over the 14 scans against the paper's 5.5 min
(over all 19 files the four bounds match the paper's).
The two EM claims tested in em_volume_counts.ipynb are partially reproduced: the share of
subvolume 35 matches but the region with data holds 19,952 sections against 19,974, and both synapse
counts match while precision, recall and partner accuracy are not tested.
How the analysis differs from the paper's.
- The 14 scans were identified from the image of Extended Data Table 1; the dataset holds 19 and does not mark them.
- Fluorescence traces take the place of the deconvolved traces, which are not in the dataset; the per-unit delay within a frame is not applied.
- The Monet2 direction of each period was measured from the stimulus video by optical flow, as the dataset does not give it.
- The oracle score on fluorescence does not separate a neuron from what else its mask holds: artifact masks score as high as somatic ones, also after the field's mean trace is regressed out. In the Fig. 3g split-half test, too, artifact masks pass as often as somatic ones.
- The preference index used to choose the 80 units shown in Fig. 3g is a difference over the standard deviation, not the ratio of the claims file, which is undefined for fluorescence.
- The EM claims tested use the 2021 releases of the nucleus and cell type tables on BossDB, not CAVE.
- In
em_volume_counts.ipynb, the first and last sections are read from the imagery on a grid of sampled chunks, and the synapse table of subvolume 65 is the export of version 1181.
What was found about the dataset.
- It holds all 19 completed scans; nothing marks the 14 the paper describes.
- The EM identifiers (
pt_root_idand others) are present only in one of the two fields at each depth (52 of 104 fields of the 14 scans), and in none of 4 files. - The deconvolved traces, the stack coordinates and area of each mask, the oracle scores, the Monet2 directions per period and the photodiode signal are not in it.
- The
imaging_rateof every imaging plane is NaN, every plane'slocationlists all four areas, and the subject age is the range P75D/P81D in every file. - In scan 4-7 the eye tracking ends 968 s before the last imaging frame. In all five files outside the paper's 14 it misses the imaging period too, by 54 to 76 min in three and by seconds in two.
- The treadmill and eye time stamps contain missing (NaN) values in several files.
- On BossDB, the imagery of subvolume 65 ends at section 27,882, 22 sections before the paper's
27,904 (
em_volume_counts.ipynb). pt_root_idis stored as a 64-bit float, which cannot hold every 18-digit root id exactly (spacing 128 near 8.65e17); in 50 of 538 pairs of masks that share a root id across scans, the EM cell body points lie more than 10 um apart.