Reproducing Fouke et al. (2025) from dandiset 001453¶

Paper. Fouke KE, He Z, Loring MD, Naumann EA (2025). Neural circuits underlying divergent visuomotor strategies of zebrafish and Danionella cerebrum. Current Biology. doi:10.1016/j.cub.2025.04.027. This attempt used the full text of the accepted author manuscript in PubMed Central (PMC12218792); the version of record is not open access.

Dandiset. DANDI:001453, version 0.250518.1950. The paper cites no DANDI version (its data statement points to a GitLab repository that holds code only). This is the only published version. The draft holds the same 37 files (same blobs and sizes); it differs only in metadata added later (authors, keywords, and a relatedResource link to the paper), none of which the analysis uses.

What the dandiset holds. Freely swimming tracking of 25 zebrafish (ZF) and 12 D. cerebrum (DC) larvae during the optomotor protocol of Figure 1 and Figure S1. The two-photon imaging and the head-fixed tail tracking of Figures 2 to 4 are not in it.

Outside inputs. The stimulus codes in the files have no names. The names come from the paper's analysis notebook Fig1-S1.ipynb (GitLab ean26/danionella_omr_neuralcircuits2025, commit 33a5c29) and the protocol file of the stimulus software pandastim (GitHub Naumann-Lab/pandastim, commit 7d9d161). The spatial scale (10 pixels per mm) is the factor in Fig1-S1.ipynb. Claims marked "with inputs" use these and depend on the link claim.

No random numbers are drawn anywhere in this notebook.

Claim Data Status
stimulus-code-link with inputs tested
fig1d-dc-faster-all-codes dandiset tested
fig1d-median-velocity with inputs tested
fig1d-zf-forward-faster-than-static with inputs tested
fig1d-dc-no-modulation with inputs tested
fig1d-dc-faster-oblique with inputs tested
fig1g-follow-direction with inputs tested
fig1h-zf-higher-angular-acceleration with inputs tested
figs1e-dc-respond-less-monocular with inputs tested
fig1c-continuous-vs-bouts dandiset tested
fig1cef-representative-values dandiset blocked: the representative fish and trials cannot be identified
fig2d, fig2g, fig2h, fig2k, fig2l (imaging) dandiset blocked: data missing
fig3e, fig3cd (head-fixed tail tracking) dandiset blocked: data missing
fig4f, fig4hi (imaging with tail tracking) dandiset blocked: data missing
In [1]:
import time
import warnings

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import statsmodels.api as sm
from scipy import stats
from statsmodels.formula.api import ols
from statsmodels.stats.multicomp import pairwise_tukeyhsd

import omr

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

started = time.time()
sessions = omr.load_all()
print(f"loaded {len(sessions)} files from dandiset {omr.DANDISET_ID} version {omr.DANDISET_VERSION} "
      f"in {time.time() - started:.0f} s")
loaded 37 files from dandiset 001453 version 0.250518.1950 in 92 s

Data check¶

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

In [2]:
info = pd.DataFrame([{
    "fish": omr.fish_key(s), "species": s["species"], "subject_id": s["subject_id"], "age": s["age"],
    "session_start": s["session_start"][:19], "frames": s["n_frames"], "tracked": s["n_tracked"],
    "frame_hz": round(s["frame_hz"], 1), "x_max": round(s["x_range"][1]), "y_max": round(s["y_range"][1]),
    "has_dist": s["has_dist"], "same_timestamps": s["same_timestamps"]} for s in sessions])
print("Files per species:")
print(info.species.value_counts().to_string())
print("\nSubject ids used by both species:",
      sorted(set(info[info.species == "Danio rerio"].subject_id) & set(info[info.species != "Danio rerio"].subject_id), key=int))
print("Distinct subject ids:", info.subject_id.nunique(), "; distinct fish (species and id):", info.fish.nunique())
print("Age field values:", info.age.unique().tolist())
print("Paper, Figure 1D legend: N = ZF 26, DC 12 fish.")
print("\nEvery series in a file shares one timestamp array:", bool(info.same_timestamps.all()))
print("\nStated units:", sessions[0]["units"])
print("\n", info.sort_values(["species", "frame_hz"]).to_string(index=False))
Files per species:
species
Danio rerio            25
Danionella cerebrum    12

Subject ids used by both species: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']
Distinct subject ids: 25 ; distinct fish (species and id): 37
Age field values: ['P5D/P9D']
Paper, Figure 1D legend: N = ZF 26, DC 12 fish.

Every series in a file shares one timestamp array: True

Stated units: {'f0_theta': 'radians', 'f0_vtheta': 'radians/s', 'f0_vx': 'pixels/s', 'f0_vy': 'pixels/s', 'f0_x': 'pixels', 'f0_y': 'pixels', 'stim_index': 'a.u.', 'stimulus': 'a.u.'}

      fish             species subject_id     age       session_start  frames  tracked  frame_hz  x_max  y_max  has_dist  same_timestamps
ZF-sub-12         Danio rerio         12 P5D/P9D 2023-06-14T17:10:42  261221   225233      32.5   1120   1112     False             True
ZF-sub-13         Danio rerio         13 P5D/P9D 2023-06-15T10:58:05  336304   177571      32.5   1114   1108     False             True
ZF-sub-14         Danio rerio         14 P5D/P9D 2023-06-14T16:15:58  377090   285286      32.5   1117   1109     False             True
ZF-sub-16         Danio rerio         16 P5D/P9D 2023-07-25T17:51:46  335384   245033      32.5   1118   1120     False             True
ZF-sub-17         Danio rerio         17 P5D/P9D 2023-07-25T17:52:10  257365    90910      32.5   1109   1116     False             True
ZF-sub-18         Danio rerio         18 P5D/P9D 2023-07-25T17:52:21  336498   289231      32.5   1106   1115     False             True
ZF-sub-19         Danio rerio         19 P5D/P9D 2023-07-25T17:52:42  257126   164421      32.5   1108   1122     False             True
 ZF-sub-7         Danio rerio          7 P5D/P9D 2023-06-14T16:15:40  289185   196772      32.5   1125   1114     False             True
 ZF-sub-8         Danio rerio          8 P5D/P9D 2023-06-15T10:57:49  297959   191485      32.5   1116   1111     False             True
ZF-sub-11         Danio rerio         11 P5D/P9D 2023-06-20T11:18:55 2106421  1523624      34.4   1117   1119     False             True
ZF-sub-10         Danio rerio         10 P5D/P9D 2023-06-20T11:16:37  219788   191617      34.6   1116   1110     False             True
ZF-sub-15         Danio rerio         15 P5D/P9D 2023-06-20T11:19:27  499605   261639      34.6   1117   1114     False             True
ZF-sub-22         Danio rerio         22 P5D/P9D 2023-07-25T17:55:47  213430   170901      34.6   1119   1114     False             True
ZF-sub-23         Danio rerio         23 P5D/P9D 2023-07-25T17:56:02  195013   179448      34.6   1115   1121     False             True
ZF-sub-20         Danio rerio         20 P5D/P9D 2023-07-25T17:55:23  260498   193449      34.7   1117   1123     False             True
ZF-sub-21         Danio rerio         21 P5D/P9D 2023-07-25T17:55:36  297073   204713      34.7   1107   1121     False             True
ZF-sub-24         Danio rerio         24 P5D/P9D 2023-07-25T17:56:21  296522   240050      34.7   1107   1119     False             True
 ZF-sub-9         Danio rerio          9 P5D/P9D 2023-06-15T10:57:55  133180   108757      34.7   1116   1110     False             True
 ZF-sub-1         Danio rerio          1 P5D/P9D 2023-06-14T16:31:57  642085   424726     167.2   1113   1106     False             True
 ZF-sub-3         Danio rerio          3 P5D/P9D 2023-06-15T10:57:40  901227   678420     167.2   1107   1124     False             True
 ZF-sub-4         Danio rerio          4 P5D/P9D 2023-06-20T11:16:09 1108079   543629     167.2   1128   1115     False             True
 ZF-sub-5         Danio rerio          5 P5D/P9D 2023-06-20T11:21:23  324257   317560     167.2   1106   1105     False             True
 ZF-sub-6         Danio rerio          6 P5D/P9D 2023-06-14T16:15:31 1709839  1528467     167.2   1123   1118     False             True
 ZF-sub-0         Danio rerio          0 P5D/P9D 2023-06-20T11:08:54 9842588  5765859     170.7   1123   1114     False             True
 ZF-sub-2         Danio rerio          2 P5D/P9D 2023-06-15T10:57:22 8646552  2903548     170.7   1114   1118     False             True
 DC-sub-4 Danionella cerebrum          4 P5D/P9D 2023-10-31T15:50:00   55697    55697      34.1   1113   1113      True             True
DC-sub-11 Danionella cerebrum         11 P5D/P9D 2024-05-22T00:24:07  633139   633139     128.0   1012   1011      True             True
 DC-sub-5 Danionella cerebrum          5 P5D/P9D 2023-10-31T15:58:36   67372    67372     128.0   1456   1490      True             True
 DC-sub-8 Danionella cerebrum          8 P5D/P9D 2024-05-21T16:31:07  775349   775349     128.0   1013   1013      True             True
 DC-sub-0 Danionella cerebrum          0 P5D/P9D 2023-10-31T13:55:10   85373    85373     146.3   1106   1109      True             True
 DC-sub-1 Danionella cerebrum          1 P5D/P9D 2023-10-31T13:55:58  254638   254638     146.3   1107   1105      True             True
DC-sub-10 Danionella cerebrum         10 P5D/P9D 2024-05-03T19:06:31  532103   532103     146.3   1016   1017      True             True
 DC-sub-9 Danionella cerebrum          9 P5D/P9D 2024-05-03T17:13:07  587016   587016     146.3   1012   1010      True             True
 DC-sub-2 Danionella cerebrum          2 P5D/P9D 2023-10-31T15:47:56  143633   143633     170.7   1107   1105      True             True
 DC-sub-3 Danionella cerebrum          3 P5D/P9D 2023-10-31T15:48:51  383384   383384     170.7   1107   1108      True             True
 DC-sub-6 Danionella cerebrum          6 P5D/P9D 2024-05-21T13:07:12  189534   189534     170.7   1009    946      True             True
 DC-sub-7 Danionella cerebrum          7 P5D/P9D 2024-05-21T14:33:06 1961958  1961958     170.7   1011   1011      True             True

The archive's summary says 25 subjects and lists only Danio rerio. The files hold 37 fish: subject ids 0 to 11 are each used for one zebrafish and one D. cerebrum, so subject_id alone does not identify an animal. Fish are named here by species and subject id. The 25 ZF are one fewer than the 26 of the Figure 1D legend; the paper's notebook (healthy_zf_fish) lists 25 ZF and 12 DC, and its ANOVA has 305 residual degrees of freedom, which fits 25 and 12 fish over nine stimuli less ten empty cells. The age is given as a range (P5D/P9D) for every fish.

In [3]:
fast = info.frame_hz > 100
print("Files by frame rate (Methods: tracking at more than 150 Hz):")
print(pd.crosstab(info.species, np.where(fast, "128-171 Hz", "32-35 Hz")).to_string())
dts = pd.DataFrame([{"fish": omr.fish_key(s), **{f"dt_{k}": v for k, v in s["dt_ms"].items()}} for s in sessions])
print("\nTimestamp intervals per file (ms), over all frames; within_10pct is the fraction of intervals within 10% of the median:")
print(dts.sort_values("dt_median").round(3).to_string(index=False))
print("Files whose median interval is below 6.67 ms (above 150 Hz):", int((dts.dt_median < 1000 / 150).sum()), "of", len(dts))

# What f0_vtheta is: compare its sum over a trial with the trial's change of f0_theta.
rows = []
for s in sessions:
    ratio_sum, ratio_int, vx_frame, vx_sec = [], [], [], []
    for number, c, idx in omr.trials(s):
        if len(idx) < 50:
            continue
        th, vth, t = s["f0_theta"][idx], s["f0_vtheta"][idx], s["t"][idx]
        if abs(th[-1] - th[0]) > 0.5:
            ratio_sum.append(vth.sum() / (th[-1] - th[0]))
            ratio_int.append(np.trapezoid(vth, t) / (th[-1] - th[0]))
        dx = np.diff(s["f0_x"][idx])
        vx = s["f0_vx"][idx][1:]
        ok = np.abs(dx) > 0.5
        if ok.sum() > 10:
            vx_frame.append(np.median(vx[ok] / dx[ok]))
            vx_sec.append(np.median(vx[ok] / (dx[ok] / np.diff(t)[ok])))
    rows.append({"fish": omr.fish_key(s), "frame_hz": round(s["frame_hz"]),
                 "sum_vtheta/dtheta": np.median(ratio_sum), "integral_vtheta_dt/dtheta": np.median(ratio_int),
                 "vx/(dx per frame)": np.median(vx_frame), "vx/(dx per s)": np.median(vx_sec)})
units = pd.DataFrame(rows)
print("\nMedian over trials, per file:")
print(units.round(3).to_string(index=False))
print("\nAcross files, median of sum(vtheta)/dtheta:", round(units["sum_vtheta/dtheta"].median(), 3),
      "; of integral(vtheta dt)/dtheta:", round(units["integral_vtheta_dt/dtheta"].median(), 4))
Files by frame rate (Methods: tracking at more than 150 Hz):
col_0                128-171 Hz  32-35 Hz
species                                  
Danio rerio                   7        18
Danionella cerebrum          11         1

Timestamp intervals per file (ms), over all frames; within_10pct is the fraction of intervals within 10% of the median:
     fish  dt_median  dt_p5  dt_p95      dt_max  dt_within_10pct
 ZF-sub-0      5.859  3.906   7.812     656.250            0.427
 DC-sub-6      5.859  3.906  13.672 4088667.969            0.354
 DC-sub-2      5.859  5.859   7.812 2887641.602            0.748
 ZF-sub-2      5.859  3.906   7.812      19.531            0.461
 DC-sub-3      5.859  5.859   7.080 1699309.326            0.770
 DC-sub-7      5.859  4.883   6.836  329480.469            0.634
 ZF-sub-5      5.981  5.005   7.019      14.038            0.714
 ZF-sub-1      5.981  5.005   7.080      16.846            0.720
 ZF-sub-6      5.981  5.127   7.080      13.672            0.712
 ZF-sub-3      5.981  5.005   7.080      17.090            0.717
 ZF-sub-4      5.981  5.005   7.080      16.968            0.721
 DC-sub-0      6.836  5.859   7.812 1412770.020            0.633
DC-sub-10      6.836  5.859   7.812  750478.027            0.485
 DC-sub-1      6.836  5.859   7.812 2814250.000            0.598
 DC-sub-9      6.836  5.859   7.812  687657.227            0.648
DC-sub-11      7.812  3.906   7.812 6377695.312            0.612
 DC-sub-5      7.812  6.836  10.254 2621780.273            0.459
 DC-sub-8      7.812  3.906   7.812 5828884.766            0.614
ZF-sub-24     28.809 27.832  29.907      58.105            0.992
ZF-sub-20     28.809 27.832  30.029      71.777            0.972
 ZF-sub-9     28.809 27.832  29.785      58.105            0.993
ZF-sub-21     28.809 27.832  29.907      59.570            0.992
ZF-sub-10     28.900 27.832  29.907      58.167            0.992
ZF-sub-22     28.915 27.832  29.922      85.938            0.985
ZF-sub-15     28.923 27.832  30.029      74.707            0.988
ZF-sub-23     28.931 27.832  30.029    3762.205            0.962
ZF-sub-11     29.053 27.344  31.250    3786.110            0.991
 DC-sub-4     29.297 27.832  30.273  956103.027            0.984
ZF-sub-19     30.762 29.785  31.738    3717.298            0.991
ZF-sub-18     30.762 29.785  31.860    3748.039            0.989
ZF-sub-17     30.762 29.785  31.860      46.875            0.991
ZF-sub-16     30.762 29.785  31.921    2948.212            0.988
 ZF-sub-7     30.762 29.785  31.738      53.864            0.993
ZF-sub-12     30.762 29.785  31.738      46.387            0.996
ZF-sub-13     30.762 29.785  31.738      47.607            0.995
ZF-sub-14     30.762 29.785  31.738      46.875            0.992
 ZF-sub-8     30.762 29.785  31.738      45.898            0.995
Files whose median interval is below 6.67 ms (above 150 Hz): 11 of 37
Median over trials, per file:
     fish  frame_hz  sum_vtheta/dtheta  integral_vtheta_dt/dtheta  vx/(dx per frame)  vx/(dx per s)
 ZF-sub-0       171              0.987                      0.006              0.991          0.006
 DC-sub-0       146              0.889                      0.005              0.764          0.005
 ZF-sub-1       167              0.933                      0.006              0.990          0.006
 DC-sub-1       146              0.612                      0.006              0.727          0.005
ZF-sub-10        35              0.988                      0.029              1.069          0.031
DC-sub-10       146              0.508                      0.007              0.837          0.006
ZF-sub-11        34              0.903                      0.026              1.066          0.031
DC-sub-11       128              0.090                      0.005              0.823          0.005
ZF-sub-12        33              0.953                      0.029              1.057          0.032
ZF-sub-13        33              0.998                      0.031              1.068          0.033
ZF-sub-14        33              0.997                      0.031              1.069          0.033
ZF-sub-15        35              0.980                      0.029              1.084          0.031
ZF-sub-16        33              0.958                      0.029              0.974          0.030
ZF-sub-17        33              0.476                      0.015              1.046          0.032
ZF-sub-18        33              0.996                      0.031              0.927          0.028
ZF-sub-19        33              0.991                      0.030              1.008          0.031
 ZF-sub-2       171              0.919                      0.006              0.981          0.006
 DC-sub-2       171              0.469                      0.005              0.733          0.004
ZF-sub-20        35              0.980                      0.028              1.087          0.032
ZF-sub-21        35              0.997                      0.029              1.110          0.032
ZF-sub-22        35              0.624                      0.023              1.073          0.031
ZF-sub-23        35              0.998                      0.029              1.056          0.031
ZF-sub-24        35              0.994                      0.029              1.035          0.030
 ZF-sub-3       167              0.984                      0.006              1.000          0.006
 DC-sub-3       171              0.794                      0.004              0.733          0.005
 ZF-sub-4       167              0.843                      0.005              0.983          0.006
 DC-sub-4        34              0.729                      0.019              0.985          0.028
 ZF-sub-5       167              0.938                      0.006              1.002          0.006
 DC-sub-5       128              0.488                      0.006              0.807          0.006
 ZF-sub-6       167              0.933                      0.006              1.000          0.006
 DC-sub-6       171              0.512                      0.007              0.810          0.004
 ZF-sub-7        33             -0.050                      0.003              1.098          0.034
 DC-sub-7       171              0.877                      0.005              0.867          0.005
 ZF-sub-8        33              0.982                      0.030              1.084          0.033
 DC-sub-8       128              0.322                      0.004              0.633          0.004
 ZF-sub-9        35              0.998                      0.029              1.021          0.029
 DC-sub-9       146              0.889                      0.007              0.816          0.005

Across files, median of sum(vtheta)/dtheta: 0.933 ; of integral(vtheta dt)/dtheta: 0.0072

f0_vtheta is stated in radians/s. Summed over a trial's frames it gives the trial's change of f0_theta (median ratio near 1 in most files), while its time integral gives about 1/frame rate of it. It is the heading change per frame, in radians. The paper's code uses it that way (it sums it to get the cumulative angle), so the analysis is unaffected, but the stated unit is wrong. f0_vx and f0_vy are stated in pixels/s; the ratio to the per-frame displacement, printed above, is the check: per frame it is 0.63 to 1.11, per second about the frame interval, so these are pixels per frame. The paper's code converts them with the frame interval, which treats them as per frame. They are not used below.

18 of 25 ZF files and 1 of 12 DC files are sampled at 32 to 35 Hz, against the Methods' "more than 150 Hz". The raw timestamp intervals show these are the rates of acquisition: in those files the median interval is 28.8 to 30.8 ms and 96% to 99.6% of intervals are within 10% of it, and their trials last a median 34.96 s (printed below), as the 35 s protocol has, so the timestamps are in seconds. The other files have median intervals of 5.9 to 7.8 ms (128 to 171 Hz), quantised in steps of about 1 ms, and only 11 of 37 files have a median interval below 6.67 ms (above 150 Hz). The largest intervals in DC files (minutes to hours) are the gaps between trials. The ratio of f0_vtheta's sum to the change of f0_theta is lower in most DC files (0.09 to 0.89) than in most ZF files, so in DC files the two series do not agree over a trial; the analysis uses f0_vtheta, as the paper's code does. The measures below are reported by frame-rate group where it could matter.

In [4]:
# Spatial scale: trials start when the fish is in the central area, and end when it leaves the
# 45 mm active area. Positions are in pixels; no scale is given in the files.
rows = []
for s in sessions:
    starts = np.array([idx[0] for _, c, idx in omr.trials(s) if c != 20])
    inside = np.concatenate([idx for _, c, idx in omr.trials(s) if c != 20])
    x, y = s["f0_x"], s["f0_y"]
    rows.append({"fish": omr.fish_key(s),
                 "start_x_5_95": np.ptp(np.percentile(x[starts], [5, 95])),
                 "start_y_5_95": np.ptp(np.percentile(y[starts], [5, 95])),
                 "trial_x_1_99": np.ptp(np.percentile(x[inside], [1, 99])),
                 "trial_y_1_99": np.ptp(np.percentile(y[inside], [1, 99])),
                 "x_max": s["x_range"][1], "y_max": s["y_range"][1]})
extent = pd.DataFrame(rows).round(0)
print(extent.to_string(index=False))
print("\nMedian extent of trial start positions (px):", extent[["start_x_5_95", "start_y_5_95"]].median().to_dict())
print("Median extent of positions in trials (px):", extent[["trial_x_1_99", "trial_y_1_99"]].median().to_dict())
print("Files with any position beyond 1200 px:", extent[(extent.x_max > 1200) | (extent.y_max > 1200)].fish.tolist())
     fish  start_x_5_95  start_y_5_95  trial_x_1_99  trial_y_1_99  x_max  y_max
 ZF-sub-0         541.0         516.0         957.0         888.0 1123.0 1114.0
 DC-sub-0         454.0         516.0         879.0        1025.0 1106.0 1109.0
 ZF-sub-1         517.0         481.0         981.0         879.0 1113.0 1106.0
 DC-sub-1         518.0         523.0         957.0         935.0 1107.0 1105.0
ZF-sub-10         526.0         517.0         919.0         900.0 1116.0 1110.0
DC-sub-10         533.0         519.0         951.0         944.0 1016.0 1017.0
ZF-sub-11         511.0         515.0         895.0         886.0 1117.0 1119.0
DC-sub-11         527.0         537.0         952.0         956.0 1012.0 1011.0
ZF-sub-12         847.0         850.0         833.0         840.0 1120.0 1112.0
ZF-sub-13         514.0         533.0         918.0         844.0 1114.0 1108.0
ZF-sub-14         921.0         781.0         994.0         965.0 1117.0 1109.0
ZF-sub-15         784.0         769.0         946.0         972.0 1117.0 1114.0
ZF-sub-16         527.0         469.0         975.0         827.0 1118.0 1120.0
ZF-sub-17         537.0         504.0         919.0        1003.0 1109.0 1116.0
ZF-sub-18         479.0         471.0         663.0         819.0 1106.0 1115.0
ZF-sub-19         529.0         537.0         910.0         712.0 1108.0 1122.0
 ZF-sub-2         532.0         481.0         911.0         887.0 1114.0 1118.0
 DC-sub-2         474.0         491.0         641.0         643.0 1107.0 1105.0
ZF-sub-20         541.0         449.0         963.0         982.0 1117.0 1123.0
ZF-sub-21         545.0         515.0         886.0         874.0 1107.0 1121.0
ZF-sub-22         944.0         779.0        1020.0         970.0 1119.0 1114.0
ZF-sub-23         518.0         524.0         889.0         768.0 1115.0 1121.0
ZF-sub-24         522.0         403.0         786.0         826.0 1107.0 1119.0
 ZF-sub-3         536.0         499.0         906.0         943.0 1107.0 1124.0
 DC-sub-3         537.0         506.0         805.0         839.0 1107.0 1108.0
 ZF-sub-4         537.0         498.0         958.0         871.0 1128.0 1115.0
 DC-sub-4         538.0         538.0         965.0         990.0 1113.0 1113.0
 ZF-sub-5         534.0         399.0         924.0         746.0 1106.0 1105.0
 DC-sub-5         497.0         548.0        1205.0        1156.0 1456.0 1490.0
 ZF-sub-6         485.0         394.0         879.0         749.0 1123.0 1118.0
 DC-sub-6         530.0         511.0         825.0         788.0 1009.0  946.0
 ZF-sub-7        1039.0         962.0        1051.0        1037.0 1125.0 1114.0
 DC-sub-7         543.0         541.0         937.0         892.0 1011.0 1011.0
 ZF-sub-8        1007.0        1021.0        1043.0        1041.0 1116.0 1111.0
 DC-sub-8         513.0         521.0         868.0         813.0 1013.0 1013.0
 ZF-sub-9         541.0         468.0         828.0         824.0 1116.0 1110.0
 DC-sub-9         532.0         522.0         904.0         905.0 1012.0 1010.0

Median extent of trial start positions (px): {'start_x_5_95': 532.0, 'start_y_5_95': 516.0}
Median extent of positions in trials (px): {'trial_x_1_99': 919.0, 'trial_y_1_99': 887.0}
Files with any position beyond 1200 px: ['DC-sub-5']

Trial start positions spread over about 500 pixels, and positions in trials over about 900 pixels, with a maximum near 1100 in almost every file. At the code's 10 pixels per mm this is a start area of about 50 mm and a field of about 110 mm, close to the 45 mm active area and the 100 mm dish of the Methods. The scale is consistent with the data, not shown by it. One DC file has positions up to about 1490 pixels, so its camera field, and possibly its scale, differs from the others.

In [5]:
trial_rows = []
for s in sessions:
    for number, c, idx in omr.trials(s):
        t = s["t"][idx]
        trial_rows.append({"fish": omr.fish_key(s), "species": omr.fish_key(s)[:2], "code": c,
                           "trial": number, "duration_s": t[-1] - t[0]})
trial_table = pd.DataFrame(trial_rows)
print("Stimulus codes present:", sorted(trial_table.code.unique()))
counts = trial_table.pivot_table(index="fish", columns="code", values="trial", aggfunc="count", fill_value=0)
print("\nTrials per fish and stimulus code:")
print(counts.to_string())
print("\nFish without code 20 (static):", counts.index[counts[20] == 0].tolist())
blocks = pd.DataFrame([{"fish": omr.fish_key(s), "static_blocks": s["static_blocks"], "static_minutes": s["static_s"] / 60}
                       for s in sessions if s["static_blocks"]])
print("Code 20: trial numbers (stim_index) per fish:", blocks.static_blocks.value_counts().to_dict(),
      "; length of the block (min):", blocks.static_minutes.min().round(1), "to", blocks.static_minutes.max().round(1))
print("\nTrial duration (s) by species, codes 0-19 (code 20 is cut to its first 60 s on loading):")
print(trial_table[trial_table.code != 20].groupby("species").duration_s.describe().round(1).to_string())
zf_rate = trial_table[(trial_table.species == "ZF") & (trial_table.code != 20)].fish.map(lambda f: "128-171 Hz" if info.set_index("fish").frame_hz[f] > 100 else "32-35 Hz")
print("\nZF trial duration (s) by frame rate of the file (protocol: 35 s per trial):")
print(trial_table[(trial_table.species == "ZF") & (trial_table.code != 20)].groupby(zf_rate).duration_s.describe().round(2).to_string())
print("\nFraction of trial frames tracked (positions present), by species:")
print(info.assign(sp=info.fish.str[:2], frac=info.tracked / info.frames).groupby("sp").frac.describe().round(3).to_string())
print("\nFiles with a `dist` series:", info.groupby(info.fish.str[:2]).has_dist.sum().to_dict())
Stimulus codes present: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9), np.int64(10), np.int64(11), np.int64(12), np.int64(13), np.int64(14), np.int64(15), np.int64(16), np.int64(17), np.int64(18), np.int64(19), np.int64(20)]

Trials per fish and stimulus code:
code       0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15  16  17  18  19  20
fish                                                                                         
DC-sub-0    2   1   2   3   3   2   3   2   2   3   3   2   2   1   2   2   3   2   3   3   0
DC-sub-1    3   5   4   5   5   4   5   3   4   4   4   5   5   3   4   5   5   3   5   5   0
DC-sub-10   6   7   7   7   7   7   7   5   7   5   8   7   7   7   7   6   6   6   7   8   1
DC-sub-11   7   5   7   8   5   6   8   9   7   8   6   8   7   8   7   3   4   4   7   6   1
DC-sub-2    3   2   4   3   3   5   5   4   4   4   1   3   3   5   3   4   2   2   3   2   1
DC-sub-3    5   6   6   7   6   5   7   6   6   7   7   6   7   6   6   5   5   6   5   6   0
DC-sub-4    5   5   6   5   5   5   5   5   5   5   5   5   5   6   5   5   5   6   5   5   1
DC-sub-5    1   2   1   1   2   1   2   1   1   2   2   1   1   1   2   1   1   1   1   1   1
DC-sub-6    3   7   4   6   5   8   5   6   7   6   4   7   3   8   8   5   6   4   8   4   1
DC-sub-7    8  10  10  10  10  10  10  10   9  10  10  10  10  10  10   9  10   9  10  10   1
DC-sub-8    7   6   8   8   7   8   6   8   7   6   9   6   7   7   6   5   8   6   4   8   1
DC-sub-9    6   7   7   8   7   7   7   7   7   6   8   7   7   7   7   7   7   7   7   8   1
ZF-sub-0    9   9   9  10   9   9   8   9   9   9   9   9  10   9   9   9  10   9   9  10   1
ZF-sub-1    4   1   3   2   6   4   5   4   5   4   3   3   4   4   3   3   3   4   5   2   0
ZF-sub-10   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-11  20  21  20  19  19  19  20  20  18  20  19  21  20  17  19  21  19  19  21  21   1
ZF-sub-12   7   7   7   6   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-13   5   5   5   6   6   6   6   6   6   6   6   6   6   6   6   6   6   5   6   6   0
ZF-sub-14   7   7   7   7   7   7   6   6   7   6   7   7   7   7   7   7   7   7   7   6   1
ZF-sub-15   7   7   7   6   6   7   5   6   7   6   6   7   7   7   6   7   6   7   7   7   1
ZF-sub-16   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-17   2   3   2   3   3   2   2   2   2   2   3   3   3   2   3   3   3   2   2   3   0
ZF-sub-18   7   7   7   7   7   7   6   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-19   5   5   5   5   5   5   5   5   5   5   5   5   5   5   5   5   5   5   5   5   0
ZF-sub-2    5   4   5   7   4   7   4   5   7   6   5   5   6   4   5   8   4   9   7   6   1
ZF-sub-20   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-21   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-22   7   7   6   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-23   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-24   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   7   1
ZF-sub-3    6   7   6   6   6   7   6   7   7   7   6   6   5   7   7   7   7   6   7   3   1
ZF-sub-4    3   4   4   5   5   6   5   4   6   3   3   4   4   6   4   3   4   4   5   3   1
ZF-sub-5    1   2   1   2   2   1   2   1   1   1   2   2   2   1   2   2   2   1   1   2   0
ZF-sub-6    7   7   7   6   7   7   6   7   6   5   6   6   6   6   7   7   6   7   7   7   1
ZF-sub-7    4   6   5   5   5   5   4   5   5   5   5   5   5   5   5   6   6   4   5   4   0
ZF-sub-8    6   6   5   6   5   4   6   6   5   6   7   7   6   6   6   6   7   6   5   7   0
ZF-sub-9    3   2   3   3   3   3   3   3   3   3   3   3   3   3   2   3   3   3   3   3   1

Fish without code 20 (static): ['DC-sub-0', 'DC-sub-1', 'DC-sub-3', 'ZF-sub-1', 'ZF-sub-13', 'ZF-sub-17', 'ZF-sub-19', 'ZF-sub-5', 'ZF-sub-7', 'ZF-sub-8']
Code 20: trial numbers (stim_index) per fish: {1: 25, 2: 2} ; length of the block (min): 0.2 to 787.3

Trial duration (s) by species, codes 0-19 (code 20 is cut to its first 60 s on loading):
          count  mean    std  min   25%   50%   75%      max
species                                                     
DC       1297.0  67.4  817.5  1.7   7.0  13.3  32.4  14890.8
ZF       3198.0  26.3   11.0  0.3  15.8  35.0  35.0     41.2
ZF trial duration (s) by frame rate of the file (protocol: 35 s per trial):
             count   mean    std   min    25%    50%    75%    max
fish                                                              
128-171 Hz   740.0  24.19  11.67  3.00  12.65  29.35  35.01  35.04
32-35 Hz    2458.0  26.98  10.67  0.28  17.30  34.96  34.99  41.22

Fraction of trial frames tracked (positions present), by species:
    count   mean    std    min    25%    50%    75%    max
sp                                                        
DC   12.0  1.000  0.000  1.000  1.000  1.000  1.000  1.000
ZF   25.0  0.706  0.167  0.336  0.639  0.731  0.817  0.979

Files with a `dist` series: {'DC': 12, 'ZF': 0}

All files use codes 0 to 19, and 27 of 37 also code 20. The protocol file of the stimulus software has 140 motion trials (20 stimuli, 7 repeats, each with a 3 s stationary phase and 35 s in all) followed by a "blank" block of 4 hours (the files hold 0.2 to 787.3 min of it), which is code 20: the "static" condition of Figure 1D is the start of that block, one period per fish, after all motion trials (25 fish have one trial number for code 20, two have two). Fig1-S1.ipynb handles code 20 like any trial number: its pass_criteria keeps the first 35 s, and the measures use 3 to 35 s. The loader's 60 s cut keeps that window, so static here is measured over the same frames as in the code, apart from the code's filter on the DC vel column. 10 fish have no code 20. The trials of DC are shorter than those of ZF (DC more often leave the active area, which ends a trial). ZF files hold every frame, with untracked frames as NaN and frames between trials; DC files hold only tracked trial frames, and carry an extra dist series. The two species were evidently exported differently.

In [6]:
# Recordings deposited twice: compare frame counts, the sequence of stimulus codes, and the first
# tracked positions of every pair of files.
sig = []
for s in sessions:
    seq = tuple(c for _, c, _ in omr.trials(s))
    xs = s["f0_x"][~np.isnan(s["f0_x"])][:500]
    sig.append((omr.fish_key(s), s["n_frames"], seq, xs))
pairs = []
for i in range(len(sig)):
    for j in range(i + 1, len(sig)):
        same_n = sig[i][1] == sig[j][1]
        same_seq = sig[i][2] == sig[j][2]
        n = min(len(sig[i][3]), len(sig[j][3]))
        same_x = n > 0 and np.allclose(sig[i][3][:n], sig[j][3][:n])
        if same_n or same_x or (same_seq and len(sig[i][2]) > 20):
            pairs.append((sig[i][0], sig[j][0], same_n, same_seq, same_x))
print("Pairs sharing frame count, stimulus sequence or first positions:", pairs if pairs else "none")
print("Distinct stimulus sequences:", len({s[2] for s in sig}), "of", len(sig))
Pairs sharing frame count, stimulus sequence or first positions: [('ZF-sub-10', 'ZF-sub-16', False, True, False), ('ZF-sub-10', 'ZF-sub-20', False, True, False), ('ZF-sub-10', 'ZF-sub-21', False, True, False), ('ZF-sub-10', 'ZF-sub-23', False, True, False), ('ZF-sub-10', 'ZF-sub-24', False, True, False), ('ZF-sub-16', 'ZF-sub-20', False, True, False), ('ZF-sub-16', 'ZF-sub-21', False, True, False), ('ZF-sub-16', 'ZF-sub-23', False, True, False), ('ZF-sub-16', 'ZF-sub-24', False, True, False), ('ZF-sub-20', 'ZF-sub-21', False, True, False), ('ZF-sub-20', 'ZF-sub-23', False, True, False), ('ZF-sub-20', 'ZF-sub-24', False, True, False), ('ZF-sub-21', 'ZF-sub-23', False, True, False), ('ZF-sub-21', 'ZF-sub-24', False, True, False), ('ZF-sub-23', 'ZF-sub-24', False, True, False)]
Distinct stimulus sequences: 32 of 37

No two files share a frame count or their first positions. The pairs listed share only the sequence of stimulus codes: six ZF files ran the full fixed protocol of the stimulus software in the same order. No recording appears to be deposited twice.

Clock of the stimulus labels. The stimulus code and trial number are series on the same timestamps as the positions, so they share one clock by construction. Whether the labels are aligned with what the fish saw is checked with a contrast: turning to code 3 minus turning to code 2 (right and left motion, by the outside mapping; the check itself does not need the names), in 0.5 s bins from the trial start. If the labels are aligned, the difference should be near zero in the 3 s stationary phase and grow once the grating moves. With each trial given the code of the trial before it, the difference should vanish.

In [7]:
bins = np.arange(0, 20.5, 0.5)


def turning_course(shift):
    out = {}
    for s in sessions:
        tr = list(omr.trials(s))
        for k, (number, c, idx) in enumerate(tr):
            label = tr[k - shift][1] if k - shift >= 0 else None
            if label not in (2, 3):
                continue
            t = s["t"][idx] - s["t"][idx][0]
            deg = np.degrees(s["f0_vtheta"][idx])
            h = np.histogram(t, bins, weights=deg)[0] / 0.5
            out.setdefault((omr.fish_key(s)[:2], label), []).append(h)
    return out


rows = []
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5), sharey=True)
for ax, shift in zip(axes, (0, 1)):
    course = turning_course(shift)
    for sp, colour in (("ZF", "0.4"), ("DC", "k")):
        diff = np.mean(course[(sp, 3)], axis=0) - np.mean(course[(sp, 2)], axis=0)
        ax.plot(bins[:-1] + 0.25, diff, color=colour, label=sp)
        rows.append({"labels": "true" if shift == 0 else "previous trial", "species": sp,
                     "0-3 s": diff[bins[:-1] < 3].mean(), "3-20 s": diff[bins[:-1] >= 3].mean(),
                     "min": diff.min(), "max": diff.max()})
    ax.axvline(3, ls="--", color="0.6")
    ax.axhline(0, color="0.8")
    ax.set_title("true labels" if shift == 0 else "labels of the previous trial")
    ax.set_xlabel("time from trial start (s)")
axes[0].set_ylabel("turning, code 3 minus code 2 (deg/s)")
axes[0].legend()
plt.tight_layout()
plt.show()
print("Mean difference in turning (deg/s), code 3 minus code 2; axes autoscaled, all points shown:")
print(pd.DataFrame(rows).round(2).to_string(index=False))
course = turning_course(0)
zf_diff = np.mean(course[("ZF", 3)], axis=0) - np.mean(course[("ZF", 2)], axis=0)
print("\nZF, true labels, by 0.5 s bin from 0 to 6 s:", np.round(zf_diff[:12], 1).tolist())
No description has been provided for this image
Mean difference in turning (deg/s), code 3 minus code 2; axes autoscaled, all points shown:
        labels species  0-3 s    3-20 s        min       max
          true      ZF  13.16 34.810001   2.580000 49.560001
          true      DC   9.81  4.560000 -15.790000 29.840000
previous trial      ZF   7.85  6.730000   0.220000 16.700001
previous trial      DC  14.98  0.120000 -17.889999 43.450001
ZF, true labels, by 0.5 s bin from 0 to 6 s: [16.799999237060547, 18.0, 16.5, 19.100000381469727, 2.5999999046325684, 5.900000095367432, 14.899999618530273, 19.0, 19.700000762939453, 40.20000076293945, 32.79999923706055, 36.29999923706055]

For ZF the contrast with the true labels is 13.2 deg/s in the stationary phase and 34.8 deg/s from 3 to 20 s; in 0.5 s bins it falls to 2.6 and 5.9 deg/s between 2 and 3 s and rises from 3 s on, to 40.2 deg/s at 4.5 to 5 s; with the labels of the previous trial it is 7.9 and 6.7 deg/s. The offset before 3 s is consistent with turning carried over from the previous trial (in the fixed protocol, right motion follows medial-right motion, and the trials after a code 3 trial start with a positive offset too). For ZF the stimulus labels are consistent with the timing of the response. For DC the contrast is small (4.6 deg/s from 3 to 20 s with true labels, 0.1 with shifted labels) and noisier in the stationary phase (9.8 and 15.0 deg/s); the timing of the DC labels could not be established by this check beyond that difference.

Trial measures¶

Every trial is processed as in Fig1-S1.ipynb (see the helper's docstring). The counts of trials dropped, by reason, follow.

In [8]:
started = time.time()
measures = pd.concat([omr.trial_measures(s) for s in sessions], ignore_index=True)
measures["name"] = measures.code.map(omr.STIMULUS_NAMES)
hz = {omr.fish_key(s): s["frame_hz"] for s in sessions}
measures["rate"] = np.where(measures.fish.map(hz) > 100, "fast", "slow")
print(f"{len(measures)} trials measured in {time.time() - started:.0f} s")
print("\nTrials kept and dropped, by species and reason:")
print(measures.assign(reason=measures.reason.fillna("").replace("", "kept")).groupby(["species", "reason"]).size().to_string())
print("\nPassing trials per species:", measures[measures.passes].groupby("species").size().to_dict())
ok = measures[measures.passes]
print("Range of trial speed (mm/s) in passing trials:", ok.speed_mm_s.min().round(2), "to", ok.speed_mm_s.max().round(2))
print("Range of trial angular velocity (deg/s) in passing trials:", ok.angvel_deg_s.min().round(1), "to", ok.angvel_deg_s.max().round(1))
fast_trials = ok[ok.speed_mm_s > omr.MAX_SPEED_MM_S]
print("Passing trials whose measured speed exceeds 100 mm/s (the code's check uses 0 to 35 s and, for DC, "
      "the stored `dist`):", fast_trials.groupby("species").size().to_dict())
print("\nThose trials: the code's check speed from `dist`, the same check from smoothed positions, and the "
      "measured speed (3 to 35 s) and the length of the motion phase it is measured over (duration_s, s), mm/s:")
print(fast_trials[["fish", "code", "name", "trial", "n_frames", "duration_s", "check_speed_mm_s", "check_speed_pos_mm_s", "speed_mm_s"]]
      .round(1).to_string(index=False))
# Variant rejection: the speed check from smoothed positions in DC files too.
measures["passes_pos"] = measures.speed_mm_s.notna() & (measures.cum10_deg <= omr.MAX_CUM_ANGLE_10S) & \
    (measures.cum35_deg <= omr.MAX_CUM_ANGLE_35S) & (measures.check_speed_pos_mm_s <= omr.MAX_SPEED_MM_S)
print("\nTrials passing with the speed check from positions, by species:", measures[measures.passes_pos].groupby("species").size().to_dict(),
      "; passing one check and not the other:", measures[measures.passes != measures.passes_pos].groupby("species").size().to_dict())
dc_m = measures[(measures.species == "DC") & measures.check_speed_mm_s.notna()]
ratio_check = dc_m.check_speed_mm_s / dc_m.check_speed_pos_mm_s
print("DC trials, ratio of the `dist` check speed to the position check speed: median",
      round(ratio_check.median(), 3), "; 5th to 95th percentile", ratio_check.quantile([0.05, 0.95]).round(3).tolist())
4522 trials measured in 3 s

Trials kept and dropped, by species and reason:
species  reason         
DC       criteria             27
         kept               1264
         no motion phase      15
ZF       criteria              2
         kept               3194
         no motion phase      16
         too short             4

Passing trials per species: {'DC': 1264, 'ZF': 3194}
Range of trial speed (mm/s) in passing trials: 0.01 to 1057.13
Range of trial angular velocity (deg/s) in passing trials: -422.5 to 764.6
Passing trials whose measured speed exceeds 100 mm/s (the code's check uses 0 to 35 s and, for DC, the stored `dist`): {'DC': 11, 'ZF': 1}

Those trials: the code's check speed from `dist`, the same check from smoothed positions, and the measured speed (3 to 35 s) and the length of the motion phase it is measured over (duration_s, s), mm/s:
     fish  code             name  trial  n_frames  duration_s  check_speed_mm_s  check_speed_pos_mm_s  speed_mm_s
 ZF-sub-0    16 forward_backward    102       633         0.9              26.3                  26.3       100.6
 DC-sub-1     5    forward_right     14       368         0.0              14.2                  31.4       694.7
DC-sub-10     2             left     37       154         0.0               8.0                  33.1       163.5
DC-sub-11     8        diverging     53       124         0.4               4.8                  35.4       114.8
DC-sub-11     5    forward_right     54       156         0.0               5.0                  41.7      1057.1
DC-sub-11    14        forward_x     68       168         0.1               5.0                  32.1       179.2
DC-sub-11     0          forward     79        97         0.2               4.9                  42.9       203.8
DC-sub-11    11    lateral_right     91       176         0.1               6.3                  31.5       248.4
 DC-sub-2     3            right     81       440         0.7              13.1                  50.1       176.7
 DC-sub-2    16 forward_backward     82      1332         7.3               8.5                  96.5       132.1
 DC-sub-2    17 backward_forward    138       423         0.4               8.6                  50.8       109.1
 DC-sub-2    20           static    140       332        14.6              21.9                  92.5       106.5

Trials passing with the speed check from positions, by species: {'DC': 1264, 'ZF': 3194} ; passing one check and not the other: {}
DC trials, ratio of the `dist` check speed to the position check speed: median 1.112 ; 5th to 95th percentile [0.312, 3.792]

Twelve passing trials have a measured speed above 100 mm/s, 11 of them DC. For DC the code's check uses the stored dist, which does not agree with the positions (the ratio of the two check speeds has a median of 1.11 and a 5th to 95th percentile of 0.31 to 3.79). Computing the check from positions does not change which trials pass: the 11 DC trials have position-based check speeds of 31.4 to 96.5 mm/s, under the 100 mm/s limit. Nine of them have a motion phase under 1 s, so their speed is measured over a short stretch. Two do not: DC-sub-2 forward_backward, 132.1 mm/s over 7.3 s, and DC-sub-2 static, 106.5 mm/s over 14.6 s. The code's check is taken over 0 to 35 s, not over the 3 to 35 s the speed is measured on, and with dist or with positions it does not catch these trials.

stimulus-code-link¶

The dandiset's stimulus codes can be linked to the stimuli the paper names, using the paper's analysis code and the protocol file of its stimulus software, and the values computed through that link agree with those the paper's code printed.

Reference values: the per-stimulus medians printed by Fig1-S1.ipynb (commit 33a5c29), cells for Figure 1D (speed) and Figure 1G (angular velocity).

In [9]:
CODE_PRINTED_SPEED = {  # mm/s, Fig1-S1.ipynb output, codes in WHOLE_FIELD order
    "DC": [11.9, 9.7, 10.0, 10.5, 10.5, 10.4, 8.8, 9.4, 9.0],
    "ZF": [5.1, 8.8, 8.4, 6.7, 4.9, 4.3, 5.2, 6.4, 8.3]}
CODE_PRINTED_ANGVEL = {  # deg/s
    "DC": [-1.8, -2.0, 1.8, 8.7, 3.4, 5.0, -2.3, 0.3, 1.5],
    "ZF": [2.3, 0.6, 6.6, 17.2, 7.7, 0.0, -21.5, -22.3, -1.4]}

used = [0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 13, 20]
present = set(trial_table.code.unique())
print("Codes the analysis uses that are missing from the dandiset:", sorted(set(used) - present) or "none")
print("Fish per species here:", info.fish.str[:2].value_counts().to_dict(),
      "; in Fig1-S1.ipynb's lists: ZF 25, DC 12; in the paper: ZF 26, DC 12")

speed_fish = omr.fish_medians(measures, "speed_mm_s")
angvel_fish = omr.fish_medians(measures, "angvel_deg_s")
rows = []
for sp in ("ZF", "DC"):
    for k, c in enumerate(omr.WHOLE_FIELD):
        sv = speed_fish[(speed_fish.species == sp) & (speed_fish.code == c)].speed_mm_s
        av = angvel_fish[(angvel_fish.species == sp) & (angvel_fish.code == c)].angvel_deg_s
        rows.append({"species": sp, "code": c, "name": omr.STIMULUS_NAMES[c], "n_fish": len(sv),
                     "speed_here": sv.median(), "speed_code": CODE_PRINTED_SPEED[sp][k],
                     "angvel_here": av.median(), "angvel_code": CODE_PRINTED_ANGVEL[sp][k]})
link = pd.DataFrame(rows)
link["speed_ratio"] = link.speed_here / link.speed_code
link["within_10pct"] = (link.speed_ratio - 1).abs() <= 0.10
print(link.round(2).to_string(index=False))
n_within = int(link.within_10pct.sum())
print(f"\nSpeed medians within 10% of the code's printed values: {n_within} of {len(link)}")
print("Median absolute difference in angular velocity (deg/s):",
      round((link.angvel_here - link.angvel_code).abs().median(), 2))
# Variant: Fig1-S1.ipynb sets fish medians of exactly 0 to NaN before its statistics and printed medians.
nz = speed_fish[speed_fish.speed_mm_s != 0]
print("\nFish x stimulus speed medians of exactly 0 (whole-field stimuli):",
      speed_fish[(speed_fish.speed_mm_s == 0) & speed_fish.code.isin(omr.WHOLE_FIELD)][["fish", "code"]].values.tolist())
link_nz = link[["species", "code", "name", "speed_code"]].copy()
link_nz["speed_here_no_zeros"] = [nz[(nz.species == r.species) & (nz.code == r.code)].speed_mm_s.median() for r in link_nz.itertuples()]
link_nz["within_10pct"] = (link_nz.speed_here_no_zeros / link_nz.speed_code - 1).abs() <= 0.10
print("With zero medians left out, speed medians within 10% of the printed values:", int(link_nz.within_10pct.sum()), "of 18;",
      "DC static", round(link_nz[(link_nz.species == "DC") & (link_nz.code == 20)].speed_here_no_zeros.iloc[0], 2), "mm/s")
Codes the analysis uses that are missing from the dandiset: none
Fish per species here: {'ZF': 25, 'DC': 12} ; in Fig1-S1.ipynb's lists: ZF 25, DC 12; in the paper: ZF 26, DC 12
species  code           name  n_fish  speed_here  speed_code  angvel_here  angvel_code  speed_ratio  within_10pct
     ZF    20         static      18        4.84         5.1         2.33          2.3         0.95          True
     ZF     0        forward      25        8.78         8.8         0.62          0.6         1.00          True
     ZF     5  forward_right      25        8.38         8.4         6.56          6.6         1.00          True
     ZF     3          right      25        6.71         6.7        17.16         17.2         1.00          True
     ZF     4 backward_right      25        4.84         4.9         7.73          7.7         0.99          True
     ZF     1       backward      25        4.33         4.3         0.03          0.0         1.01          True
     ZF     7  backward_left      25        5.16         5.2       -21.51        -21.5         0.99          True
     ZF     2           left      25        6.39         6.4       -22.30        -22.3         1.00          True
     ZF     6   forward_left      25        8.23         8.3        -1.42         -1.4         0.99          True
     DC    20         static       9       10.52        11.9        -1.81         -1.8         0.88         False
     DC     0        forward      12        9.69         9.7        -1.98         -2.0         1.00          True
     DC     5  forward_right      12       10.02        10.0         1.79          1.8         1.00          True
     DC     3          right      12       10.29        10.5         8.67          8.7         0.98          True
     DC     4 backward_right      12       10.42        10.5         3.39          3.4         0.99          True
     DC     1       backward      12       10.33        10.4         5.05          5.0         0.99          True
     DC     7  backward_left      12        8.81         8.8        -2.28         -2.3         1.00          True
     DC     2           left      12        9.36         9.4         0.25          0.3         1.00          True
     DC     6   forward_left      12        8.97         9.0         1.51          1.5         1.00          True

Speed medians within 10% of the code's printed values: 17 of 18
Median absolute difference in angular velocity (deg/s): 0.02

Fish x stimulus speed medians of exactly 0 (whole-field stimuli): []
With zero medians left out, speed medians within 10% of the printed values: 17 of 18; DC static 10.52 mm/s
In [10]:
# Codes 9 and 11: the figure code calls 9 lateral right, the protocol conversion calls 11 lateral right.
rows = []
for c in (10, 13, 9, 11, 3, 2):
    for sp in ("ZF", "DC"):
        v = angvel_fish[(angvel_fish.species == sp) & (angvel_fish.code == c)].angvel_deg_s
        rows.append({"code": c, "protocol name": omr.STIMULUS_NAMES[c],
                     "figure-code name": omr.LATERAL_FROM_FIGURE_CODE.get(c, omr.STIMULUS_NAMES[c]),
                     "species": sp, "n_fish": len(v), "median_deg_s": v.median(),
                     "fish_turning_right": int((v > 0).sum())})
print(pd.DataFrame(rows).round(2).to_string(index=False))
 code protocol name figure-code name species  n_fish  median_deg_s  fish_turning_right
   10  medial_right     medial_right      ZF      25         17.94                  23
   10  medial_right     medial_right      DC      12          3.26                   8
   13   medial_left      medial_left      ZF      25        -11.18                   3
   13   medial_left      medial_left      DC      12         -2.85                   2
    9  lateral_left    lateral_right      ZF      25          0.02                  14
    9  lateral_left    lateral_right      DC      12         -0.49                   5
   11 lateral_right     lateral_left      ZF      25          1.59                  16
   11 lateral_right     lateral_left      DC      12          1.12                   9
    3         right            right      ZF      25         17.16                  23
    3         right            right      DC      12          8.67                   8
    2          left             left      ZF      25        -22.30                   3
    2          left             left      DC      12          0.25                   6
In [11]:
# Codes 9 and 11 against the medial codes 10 and 13: turning (deg/s) per fish, mean over trials in
# 4 s bins from the trial start (0-3 s is the stationary phase), passing trials only.
tbins = np.array([0, 3, 7, 11, 15, 19, 23, 27, 31, 35])
course_rows = []
passing_trials = set(zip(measures[measures.passes].path, measures[measures.passes].trial))
for s in sessions:
    for number, c, idx in omr.trials(s):
        if c not in (9, 10, 11, 13) or (s["path"], number) not in passing_trials:
            continue
        t = s["t"][idx] - s["t"][idx][0]
        deg = np.degrees(s["f0_vtheta"][idx])
        summed = np.histogram(t, tbins, weights=deg)[0]
        course_rows.append({"fish": omr.fish_key(s), "species": omr.fish_key(s)[:2], "code": c,
                            **{f"{a}-{b}s": v / (b - a) for a, b, v in zip(tbins[:-1], tbins[1:], summed)}})
courses = pd.DataFrame(course_rows)
cols = [c for c in courses.columns if c.endswith("s") and "-" in c]
per_fish_course = courses.groupby(["species", "fish", "code"])[cols].mean()
print("Species mean of fish means, by code (deg/s; positive is rightward):")
print(per_fish_course.groupby(["species", "code"]).mean().round(1).to_string())
sustained = per_fish_course[cols[1:]].mean(axis=1).unstack("code")
print("\nPer fish, mean turning from 3 to 35 s (deg/s), codes 9, 10, 11, 13:")
print(sustained.round(1).to_string())
for sp in ("ZF", "DC"):
    d = (sustained.loc[sp][9] - sustained.loc[sp][11]).dropna()
    print(f"{sp}: code 9 minus code 11, median {d.median():.2f} deg/s over {len(d)} fish, {int((d > 0).sum())} positive, "
          f"Wilcoxon p = {stats.wilcoxon(d).pvalue:.3g}; code 10 minus 13, median "
          f"{(sustained.loc[sp][10] - sustained.loc[sp][13]).dropna().median():.2f}")
Species mean of fish means, by code (deg/s; positive is rightward):
              0-3s  3-7s  7-11s  11-15s  15-19s  19-23s  23-27s  27-31s  31-35s
species code                                                                   
DC      9      1.3  -2.4   -4.3    -0.0    -3.0    -5.6    -1.3     0.0    -0.0
        10     1.1   3.6    4.0     1.7     1.6    -0.3    -1.4    -2.6    -1.4
        11     5.6   6.0    3.3     3.6     7.0     3.4     0.4     0.8     3.7
        13    10.7  -3.1   -1.1     0.6    -0.2    -1.0     0.4    -0.2     0.5
ZF      9      1.6   0.2   -1.7    -1.7    -0.7    -1.2    -2.0    -0.6    -2.0
        10     3.2   8.5   15.4    13.1    12.9    11.1    12.9    10.7     8.0
        11     2.4   2.7    3.5     2.8     3.3     1.1     0.6     1.2     2.2
        13     0.1  -9.0   -9.0   -10.0   -13.1   -13.9   -11.9    -8.8    -8.3

Per fish, mean turning from 3 to 35 s (deg/s), codes 9, 10, 11, 13:
code                 9     10    11    13
species fish                             
DC      DC-sub-0  -10.1   1.6   0.8  -0.8
        DC-sub-1   -0.1  -1.5   7.1   7.0
        DC-sub-10  -2.4   1.5   6.6   0.4
        DC-sub-11   1.0   2.0  -0.2  -0.1
        DC-sub-2   -1.7  -0.8   0.9  -6.3
        DC-sub-3   -2.2  -1.7   5.8  -1.5
        DC-sub-4   -4.5   2.1  -0.0  -1.2
        DC-sub-5    5.9  11.0   0.1  -1.0
        DC-sub-6   -1.6  -0.1   2.6  -0.2
        DC-sub-7    4.6   0.7   7.6   8.7
        DC-sub-8   -4.5  -4.5   3.3  -7.7
        DC-sub-9   -9.5  -2.4   7.9  -3.6
ZF      ZF-sub-0    0.6  14.5  -0.1 -14.1
        ZF-sub-1    1.7   5.9   2.5 -12.9
        ZF-sub-10  -6.7  26.1   5.5 -22.1
        ZF-sub-11  -5.9  12.6   5.5  -9.2
        ZF-sub-12 -11.5  16.9   8.7 -15.7
        ZF-sub-13  -4.1  24.0  -5.4 -38.8
        ZF-sub-14   6.5   1.9   5.3  -8.0
        ZF-sub-15   3.0  24.0  -4.0 -13.7
        ZF-sub-16  -1.2   2.7   0.4  -5.4
        ZF-sub-17   5.8   6.6  -9.5  -5.0
        ZF-sub-18  -0.9  11.4   3.3  -9.8
        ZF-sub-19  -1.4  11.0  -1.0 -10.2
        ZF-sub-2    0.0   5.4  -2.7  -3.7
        ZF-sub-20   0.8   4.1   3.6 -11.1
        ZF-sub-21   2.5  14.0  12.0  -1.3
        ZF-sub-22   5.5   2.9   2.8   1.3
        ZF-sub-23  -3.5  17.7   9.5 -22.2
        ZF-sub-24  -1.6  21.2   1.1 -15.5
        ZF-sub-3  -20.6  13.1  11.0  -8.2
        ZF-sub-4   -4.2   7.4  14.0  -9.8
        ZF-sub-5    8.3   2.1  -7.6   7.0
        ZF-sub-6    0.4  25.5   6.5 -16.4
        ZF-sub-7    0.4  -2.0   1.2  -1.5
        ZF-sub-8   -3.8  -2.8   1.8   2.9
        ZF-sub-9   -0.6  22.9 -10.7 -18.9
ZF: code 9 minus code 11, median -1.66 deg/s over 25 fish, 9 positive, Wilcoxon p = 0.141; code 10 minus 13, median 21.16
DC: code 9 minus code 11, median -5.82 deg/s over 12 fish, 2 positive, Wilcoxon p = 0.00928; code 10 minus 13, median 1.66

Result: reproduced. Every code the analysis uses is in the dandiset. Through the link, 17 of 18 species and stimulus speed medians are within 10% of the values printed by the paper's code (the exception is DC static, 10.52 against 11.9 mm/s), and the angular velocities differ from the printed ones by a median of 0.02 deg/s. The fish counts (25 ZF, 12 DC) match the code's lists, not the paper's 26 ZF. Fish cannot be matched one by one. No fish x stimulus speed median is exactly 0, so the code's exclusion of zero medians changes nothing, and DC static stays at 10.52 mm/s. The static window is the same as the code's (see the data check), which leaves the code's filter on the DC vel column as the remaining difference. On codes 9 and 11, the ZF medians for both are near zero (0.02 and 1.59 deg/s), while the medial codes 10 and 13 give 17.94 and -11.18 deg/s. Over 3 to 35 s, DC turn more to the right for code 11 than for code 9 (median difference 5.82 deg/s, 10 of 12 fish, Wilcoxon p = 0.0093); ZF go the same way but not significantly (1.66 deg/s, 16 of 25 fish, p = 0.141). If the fish turn with lateral motion as they do with medial motion, this points to code 11 being lateral right, as the protocol file has it. The data do not show that they do, so the assignment is not settled.

fig1d-dc-faster-all-codes¶

D. cerebrum (DC) swim at a higher median velocity than zebrafish (ZF) across the visual stimuli.

Tested without the stimulus names, over all 21 codes, in pixels per second (no scale needed).

In [12]:
def two_way(df, value, factor="code"):
    model = ols(f"{value} ~ C(species) + C({factor}) + C(species):C({factor})", data=df).fit()
    return sm.stats.anova_lm(model, typ=2)


all_codes = speed_fish.assign(speed_px_s=speed_fish.speed_mm_s * omr.PX_PER_MM)
table = two_way(all_codes, "speed_px_s")
print(table.to_string(float_format=lambda v: f"{v:.4g}"))
med = all_codes.groupby("species").speed_px_s.median()
print("\nMedian over fish and code values (px/s):", med.round(1).to_dict(),
      "; n values:", all_codes.groupby("species").size().to_dict())
per_fish = all_codes.groupby(["species", "fish"]).speed_px_s.median()
print("\nPer fish, median over codes (px/s):")
print(per_fish.round(1).to_string())
p_fish = stats.mannwhitneyu(per_fish.loc["DC"], per_fish.loc["ZF"]).pvalue
print(f"\nOne value per fish (median over codes): DC {per_fish.loc['DC'].median():.1f} against ZF {per_fish.loc['ZF'].median():.1f} px/s, "
      f"Mann-Whitney p = {p_fish:.2g} (the ANOVA above treats each fish x code value as independent)")
                      sum_sq  df     F    PR(>F)
C(species)         2.694e+05   1 78.44 6.272e-18
C(code)            1.405e+05  20 2.045  0.004612
C(species):C(code)  9.16e+04  20 1.333    0.1495
Residual            2.49e+06 725   NaN       NaN

Median over fish and code values (px/s): {'DC': 94.0, 'ZF': 55.2} ; n values: {'DC': 249, 'ZF': 518}

Per fish, median over codes (px/s):
species  fish     
DC       DC-sub-0      76.4
         DC-sub-1      61.1
         DC-sub-10    123.5
         DC-sub-11    137.8
         DC-sub-2      44.7
         DC-sub-3      69.9
         DC-sub-4     106.6
         DC-sub-5     120.5
         DC-sub-6      27.1
         DC-sub-7     115.3
         DC-sub-8      28.0
         DC-sub-9      98.4
ZF       ZF-sub-0      56.9
         ZF-sub-1      69.0
         ZF-sub-10     48.0
         ZF-sub-11     41.7
         ZF-sub-12     52.9
         ZF-sub-13     43.2
         ZF-sub-14     40.1
         ZF-sub-15     52.0
         ZF-sub-16     32.1
         ZF-sub-17     72.6
         ZF-sub-18      1.4
         ZF-sub-19     46.6
         ZF-sub-2      39.6
         ZF-sub-20     76.0
         ZF-sub-21     76.3
         ZF-sub-22     64.3
         ZF-sub-23     44.5
         ZF-sub-24     45.0
         ZF-sub-3      74.8
         ZF-sub-4      56.7
         ZF-sub-5      58.0
         ZF-sub-6      60.2
         ZF-sub-7      73.5
         ZF-sub-8      55.1
         ZF-sub-9      52.8

One value per fish (median over codes): DC 87.4 against ZF 52.9 px/s, Mann-Whitney p = 0.02 (the ANOVA above treats each fish x code value as independent)
In [13]:
# Variants: speed from positions resampled to 30 Hz; ZF split by frame rate.
s30 = omr.fish_medians(measures, "speed30_mm_s").assign(speed_px_s=lambda d: d.speed30_mm_s * omr.PX_PER_MM)
t30 = two_way(s30, "speed_px_s")
print("30 Hz variant, species effect: F =", round(t30.loc["C(species)", "F"], 2), "p =", f"{t30.loc['C(species)', 'PR(>F)']:.2g}",
      "; medians (px/s):", s30.groupby("species").speed_px_s.median().round(1).to_dict())
rate = all_codes.fish.map(lambda f: "fast" if hz[f] > 100 else "slow")
print("\nMedian over fish and code values (px/s) by species and frame rate:")
print(all_codes.assign(rate=rate).groupby(["species", "rate"]).speed_px_s.agg(["median", "size"]).round(1).to_string())
print("\nThe same with 30 Hz resampled positions:")
print(s30.assign(rate=s30.fish.map(lambda f: "fast" if hz[f] > 100 else "slow")).groupby(["species", "rate"]).speed_px_s.agg(["median", "size"]).round(1).to_string())
30 Hz variant, species effect: F = 33.13 p = 1.3e-08 ; medians (px/s): {'DC': 95.2, 'ZF': 53.8}

Median over fish and code values (px/s) by species and frame rate:
              median  size
species rate              
DC      fast    86.4   228
        slow   106.6    21
ZF      fast    59.9   145
        slow    52.9   373

The same with 30 Hz resampled positions:
              median  size
species rate              
DC      fast    89.4   228
        slow   112.4    21
ZF      fast    59.3   145
        slow    51.6   373

Result: reproduced. Over all 21 codes DC swim faster than ZF: median 94.0 against 55.2 pixels/s over fish and code values, species effect F = 78.4, p = 6.3e-18. With positions resampled to 30 Hz the result is the same (95.2 against 53.8 pixels/s, p = 1.3e-08), and DC are faster within each frame-rate group (fast files 86.4 against 59.9, slow files 106.6 against 52.9). The ANOVA treats each fish x code value as independent; with one value per fish (the median over codes), DC 87.4 against ZF 52.9 pixels/s, Mann-Whitney p = 0.02.

fig1d-median-velocity¶

Across the whole-field stimuli DC have a higher median velocity than ZF (9.8 against 6.4 mm/s), with a significant main effect of species in a two-way ANOVA.

In [14]:
wf = speed_fish[speed_fish.code.isin(omr.WHOLE_FIELD)].copy()
wf["name"] = wf.code.map(omr.STIMULUS_NAMES)
wide = wf.pivot_table(index=["species", "fish"], columns="name", values="speed_mm_s")
print("Per fish median speed (mm/s), whole-field stimuli:")
print(wide.round(1).to_string())


def readings(fish_values, trials_values, column):
    out = {}
    for sp in ("DC", "ZF"):
        f = fish_values[fish_values.species == sp]
        out[sp] = {
            "a: median of stimulus medians": f.groupby("code")[column].median().median(),
            "b: median of fish x stimulus values": f[column].median(),
            "c: median over fish of fish medians": f.groupby("fish")[column].median().median(),
            "d: median of passing trials": trials_values[trials_values.species == sp][column].median()}
    return pd.DataFrame(out)


wf_trials = measures[measures.passes & measures.code.isin(omr.WHOLE_FIELD)]
read = readings(wf, wf_trials, "speed_mm_s")
read["DC within 9.8 +/- 0.98"] = (read.DC - 9.8).abs() <= 0.98
read["ZF within 6.4 +/- 0.64"] = (read.ZF - 6.4).abs() <= 0.64
print("\nOverall medians (mm/s) under each reading:")
print(read.round(2).to_string())
anova_wf = two_way(wf, "speed_mm_s")
print("\nTwo-way ANOVA (type II) on fish x stimulus medians:")
print(anova_wf.to_string(float_format=lambda v: f"{v:.4g}"))
Per fish median speed (mm/s), whole-field stimuli:
name               backward  backward_left  backward_right  forward  forward_left  forward_right  left  right  static
species fish                                                                                                         
DC      DC-sub-0       11.0            8.5             9.9      6.3          13.3            7.2   7.9    6.9     NaN
        DC-sub-1        8.5            5.3             6.9      5.4           4.2           10.2   5.6    6.5     NaN
        DC-sub-10      11.1            9.0            12.3     12.2          11.3           12.2  16.8   12.8    19.8
        DC-sub-11      19.1            9.9            18.2      6.8          15.8           18.5   5.4   10.2     0.0
        DC-sub-2        6.5            0.5             1.2      8.9           4.4            1.0   0.2   10.4   106.5
        DC-sub-3        3.8           13.0             4.9     10.5           8.7            9.8  17.1    7.8     NaN
        DC-sub-4        9.7           11.7            12.7     12.3          10.8           12.1  12.3   10.5    10.5
        DC-sub-5       12.1           17.5            12.7     19.3           8.8           22.8  24.2   17.7    11.9
        DC-sub-6        3.4            1.2             3.9      2.1           3.9            2.7   0.7    7.1     0.0
        DC-sub-7       11.0           13.3            11.0     11.0          13.4           12.8  10.8   13.4    13.9
        DC-sub-8        4.4            4.6             1.9     10.6           5.0            2.6   2.8    4.9     4.8
        DC-sub-9       12.0            8.6            12.3      7.8           9.2            7.8  12.8   13.6     9.5
ZF      ZF-sub-0        4.6            4.2             5.2      8.8           8.8            8.0   5.8    7.2     5.3
        ZF-sub-1        6.5            6.0             6.0      9.2          10.8            8.4   8.4    7.3     NaN
        ZF-sub-10       4.2            3.8             4.3     10.4           8.2           10.4   4.9    5.9     4.2
        ZF-sub-11       3.8            3.1             4.2     10.3           9.1           10.2   5.3    7.0     5.6
        ZF-sub-12       4.1            5.4             4.8      5.5           8.2            8.7  10.4    8.6     5.0
        ZF-sub-13       4.1            5.8             4.7      7.4           9.6            6.9   6.4    6.3     NaN
        ZF-sub-14       2.5            3.0             4.8      4.4           3.2            4.7   5.4    3.3     4.5
        ZF-sub-15       4.3            5.7             5.8      5.7           4.7            5.9   3.7    3.8     2.1
        ZF-sub-16       1.6            1.7             1.6     12.1          11.3           10.6   4.9    3.2     1.2
        ZF-sub-17       5.6            7.1            10.1     12.7          10.8            7.8   6.4    8.1     NaN
        ZF-sub-18       0.1            1.2             0.1      2.6           0.2            0.6   0.8    0.7     0.9
        ZF-sub-19       0.0            3.5             0.0      8.7           5.1            8.6   4.7    6.5     NaN
        ZF-sub-2        4.0            2.6             2.2      8.9           6.6            5.8   3.9    4.6     4.5
        ZF-sub-20       7.4            8.5             7.4     11.6           8.9            9.9   9.1    9.1     7.9
        ZF-sub-21       5.7            6.0             7.3     11.3           9.7            9.9   9.2    9.8     4.5
        ZF-sub-22       5.8            6.8             7.0      6.4           6.7            7.0   5.6    5.9     6.9
        ZF-sub-23       3.5            4.4             4.2      9.7           8.3            9.8   7.4    6.4     3.3
        ZF-sub-24       3.3            3.2             3.3      8.7           7.5            7.8   6.1    6.7     4.7
        ZF-sub-3        7.5            6.8             8.9     11.3          12.8            9.3  10.7    7.9    12.4
        ZF-sub-4        5.5            4.9             4.4      9.4           8.6           13.0   7.0    7.1     8.3
        ZF-sub-5        5.4            3.6             6.7      7.2           6.2            6.5   7.2    2.8     NaN
        ZF-sub-6       10.0            9.8             9.0      7.9           4.2            4.9   4.6    6.9    47.9
        ZF-sub-7        8.1           10.8             7.3      7.2           5.6            8.5   7.3    7.0     NaN
        ZF-sub-8        5.8            5.5             5.5      7.3           8.1            5.3   6.7    5.3     NaN
        ZF-sub-9        4.1            5.2             4.4     12.4           9.1           11.2   7.1    8.0     6.6

Overall medians (mm/s) under each reading:
                                        DC    ZF  DC within 9.8 +/- 0.98  ZF within 6.4 +/- 0.64
a: median of stimulus medians        10.02  6.39                    True                    True
b: median of fish x stimulus values   9.87  6.41                    True                    True
c: median over fish of fish medians   9.36  6.33                    True                    True
d: median of passing trials           9.88  6.28                    True                    True

Two-way ANOVA (type II) on fish x stimulus medians:
                      sum_sq  df     F    PR(>F)
C(species)               966   1 20.78 7.477e-06
C(code)                715.4   8 1.923   0.05616
C(species):C(code)     572.5   8 1.539    0.1429
Residual           1.418e+04 305   NaN       NaN
In [15]:
# Variants: 30 Hz speeds; no trial rejection; by ZF frame-rate group.
wf30 = s30[s30.code.isin(omr.WHOLE_FIELD)]
r30 = readings(wf30, measures[measures.passes & measures.code.isin(omr.WHOLE_FIELD)], "speed30_mm_s")
a30 = two_way(wf30, "speed30_mm_s")
print("30 Hz positions: overall medians (mm/s)")
print(r30.round(2).to_string())
print("species effect p =", f"{a30.loc['C(species)', 'PR(>F)']:.2g}")

no_reject = measures.copy()
no_reject["passes"] = no_reject.speed_mm_s.notna()
nr = omr.fish_medians(no_reject, "speed_mm_s")
nr = nr[nr.code.isin(omr.WHOLE_FIELD)]
print("\nWithout the code's trial rejection (trials with a measure:", int(no_reject.passes.sum()), "):")
print(readings(nr, no_reject[no_reject.passes & no_reject.code.isin(omr.WHOLE_FIELD)], "speed_mm_s").round(2).to_string())
print("species effect p =", f"{two_way(nr, 'speed_mm_s').loc['C(species)', 'PR(>F)']:.2g}")

wf_rate = wf.assign(rate=wf.fish.map(lambda f: "fast" if hz[f] > 100 else "slow"))
print("\nReading (b) by species and frame rate (mm/s):")
print(wf_rate.groupby(["species", "rate"]).speed_mm_s.agg(["median", "size"]).round(2).to_string())
30 Hz positions: overall medians (mm/s)
                                        DC    ZF
a: median of stimulus medians         9.80  6.07
b: median of fish x stimulus values   9.79  6.08
c: median over fish of fish medians   9.43  6.30
d: median of passing trials          10.15  6.12
species effect p = 0.0011
Without the code's trial rejection (trials with a measure: 4487 ):
                                        DC    ZF
a: median of stimulus medians        10.02  6.39
b: median of fish x stimulus values   9.87  6.41
c: median over fish of fish medians   9.36  6.33
d: median of passing trials           9.85  6.30
species effect p = 6.7e-06

Reading (b) by species and frame rate (mm/s):
              median  size
species rate              
DC      fast    9.09    96
        slow   11.69     9
ZF      fast    6.98    61
        slow    5.88   157
In [16]:
fig, ax = plt.subplots(figsize=(10, 3.5))
for k, c in enumerate(omr.WHOLE_FIELD):
    for dx, sp, colour in ((-0.15, "ZF", "0.5"), (0.15, "DC", "k")):
        v = wf[(wf.species == sp) & (wf.code == c)].speed_mm_s
        ax.scatter(np.full(len(v), k + dx), v, s=10, color=colour, label=sp if k == 0 else None)
        ax.hlines(v.median(), k + dx - 0.1, k + dx + 0.1, color="r")
ax.set_xticks(range(len(omr.WHOLE_FIELD)), [omr.STIMULUS_NAMES[c] for c in omr.WHOLE_FIELD], rotation=30)
ax.set_ylabel("fish median speed (mm/s)")
ax.legend()
plt.tight_layout()
plt.show()
print("Plotted range (mm/s):", wf.speed_mm_s.min().round(2), "to", wf.speed_mm_s.max().round(2), "; axes autoscaled, all points shown")
No description has been provided for this image
Plotted range (mm/s): 0.01 to 106.46 ; axes autoscaled, all points shown

Result: reproduced. Under every reading both overall medians are within tolerance of 9.8 and 6.4 mm/s: reading (a) gives DC 10.02 and ZF 6.39, (b) 9.87 and 6.41, (c) 9.36 and 6.33, (d) 9.88 and 6.28. The species effect of the two-way ANOVA is F = 20.8, p = 7.5e-06. With positions resampled to 30 Hz, DC give 9.43 to 10.15 and ZF 6.07 to 6.30, still within tolerance (p = 0.0011); without the code's trial rejection (4487 trials in place of 4458) the readings are unchanged except (d), DC 9.85 and ZF 6.30, and p = 6.7e-06. ZF from 32 to 35 Hz files are slower (5.88 mm/s, reading b) than ZF from 167 to 171 Hz files (6.98).

fig1d-zf-forward-faster-than-static¶

ZF modulate their velocity with the direction of motion; forward motion increases their median velocity (static 5.0 mm/s, forward 8.8 mm/s).

In [17]:
for c in (20, 0):
    v = wf[(wf.species == "ZF") & (wf.code == c)].speed_mm_s
    print(f"ZF {omr.STIMULUS_NAMES[c]:8s} median {v.median():.2f} mm/s over {len(v)} fish "
          f"(interquartile {v.quantile(0.25):.2f} to {v.quantile(0.75):.2f})")
paired = wide.loc["ZF"][["static", "forward"]].dropna()
print(f"\nZF fish with both: {len(paired)}; forward faster in {(paired.forward > paired.static).sum()}; "
      f"median of forward minus static {np.median(paired.forward - paired.static):.2f} mm/s; "
      f"Wilcoxon p = {stats.wilcoxon(paired.forward, paired.static).pvalue:.3g}")
print("\nBy ZF frame-rate group (median mm/s, n fish):")
print(wf_rate[(wf_rate.species == "ZF") & wf_rate.code.isin([20, 0])].groupby(["rate", "code"]).speed_mm_s.agg(["median", "size"]).round(2).to_string())
v30 = wf30[(wf30.species == "ZF")].groupby("code").speed30_mm_s.median()
print("\n30 Hz variant, ZF static and forward (mm/s):", round(v30[20], 2), round(v30[0], 2))
ZF static   median 4.84 mm/s over 18 fish (interquartile 4.29 to 6.84)
ZF forward  median 8.78 mm/s over 25 fish (interquartile 7.25 to 10.37)

ZF fish with both: 18; forward faster in 14; median of forward minus static 3.67 mm/s; Wilcoxon p = 0.00769

By ZF frame-rate group (median mm/s, n fish):
           median  size
rate code              
fast 0       8.94     7
     20      8.31     5
slow 0       8.69    18
     20      4.50    13

30 Hz variant, ZF static and forward (mm/s): 4.64 8.65

Result: reproduced. ZF median speed is 4.84 mm/s for static (18 fish) and 8.78 mm/s for forward (25 fish), against 5.0 and 8.8. Of 18 fish with both, 14 are faster to forward motion (median difference 3.67 mm/s, Wilcoxon p = 0.0077). The static value depends on frame rate: 8.31 mm/s in the 5 fish from fast files, 4.50 in the 13 from slow files; forward does not (8.94 and 8.69).

fig1d-dc-no-modulation¶

DC lack the direction-dependent velocity modulation seen in ZF.

The paper's test: Tukey's HSD over all 18 species and stimulus groups of fish medians.

In [18]:
groups = wf.species + "_" + wf.name
tukey = pairwise_tukeyhsd(wf.speed_mm_s, groups, alpha=0.05)
res = pd.DataFrame(tukey.summary().data[1:], columns=tukey.summary().data[0])
dc_pairs = res[res.group1.str.startswith("DC") & res.group2.str.startswith("DC")]
zf_pairs = res[res.group1.str.startswith("ZF") & res.group2.str.startswith("ZF")]
print(f"DC pairs: {len(dc_pairs)}, significant: {int(dc_pairs.reject.sum())}; smallest adjusted p {dc_pairs['p-adj'].min():.3f}")
print(f"ZF pairs: {len(zf_pairs)}, significant: {int(zf_pairs.reject.sum())}; smallest adjusted p {zf_pairs['p-adj'].min():.3f}")
print(res[res.reject].to_string(index=False) if res.reject.any() else "no significant pair")
dc_med = wf[wf.species == "DC"].groupby("name").speed_mm_s.median()
zf_med = wf[wf.species == "ZF"].groupby("name").speed_mm_s.median()
print(f"\nRange of DC per-stimulus medians: {dc_med.min():.2f} to {dc_med.max():.2f} mm/s (ratio {dc_med.max() / dc_med.min():.2f})")
print(f"Range of ZF per-stimulus medians: {zf_med.min():.2f} to {zf_med.max():.2f} mm/s (ratio {zf_med.max() / zf_med.min():.2f})")
for sp in ("DC", "ZF"):
    sub = wf[wf.species == sp]
    print(f"{sp}: one-way ANOVA across the nine stimuli, p = {stats.f_oneway(*[g.speed_mm_s for _, g in sub.groupby('code')]).pvalue:.3g}; "
          f"Friedman over fish with all nine: ", end="")
    w = sub.pivot_table(index="fish", columns="code", values="speed_mm_s").dropna()
    print(f"{len(w)} fish, p = {stats.friedmanchisquare(*[w[c] for c in w.columns]).pvalue:.3g}" if len(w) > 2 else "too few")
print("\nDC static, per fish (mm/s):", wf[(wf.species == 'DC') & (wf.code == 20)].set_index('fish').speed_mm_s.round(1).to_dict())
DC pairs: 36, significant: 3; smallest adjusted p 0.030
ZF pairs: 36, significant: 0; smallest adjusted p 0.835
           group1            group2  meandiff  p-adj    lower   upper  reject
 DC_backward_left         DC_static   11.0686 0.0297   0.4873 21.6499    True
DC_backward_right         DC_static   10.6616 0.0460   0.0803 21.2429    True
  DC_forward_left         DC_static   10.6049 0.0488   0.0236 21.1863    True
        DC_static       ZF_backward  -14.9510 0.0000 -24.2791 -5.6230    True
        DC_static  ZF_backward_left  -14.5063 0.0000 -23.8344 -5.1783    True
        DC_static ZF_backward_right  -14.4723 0.0000 -23.8004 -5.1443    True
        DC_static        ZF_forward  -10.9704 0.0057 -20.2984 -1.6424    True
        DC_static   ZF_forward_left  -11.9582 0.0012 -21.2862 -2.6301    True
        DC_static  ZF_forward_right  -11.6658 0.0020 -20.9938 -2.3378    True
        DC_static           ZF_left  -13.2870 0.0001 -22.6150 -3.9589    True
        DC_static          ZF_right  -13.4435 0.0001 -22.7715 -4.1154    True
        DC_static         ZF_static  -12.1059 0.0025 -21.9023 -2.3095    True

Range of DC per-stimulus medians: 8.81 to 10.52 mm/s (ratio 1.19)
Range of ZF per-stimulus medians: 4.33 to 8.78 mm/s (ratio 2.03)
DC: one-way ANOVA across the nine stimuli, p = 0.479; Friedman over fish with all nine: 9 fish, p = 0.622
ZF: one-way ANOVA across the nine stimuli, p = 0.00082; Friedman over fish with all nine: 18 fish, p = 3.76e-10

DC static, per fish (mm/s): {'DC-sub-10': 19.8, 'DC-sub-11': 0.0, 'DC-sub-2': 106.5, 'DC-sub-4': 10.5, 'DC-sub-5': 11.9, 'DC-sub-6': 0.0, 'DC-sub-7': 13.9, 'DC-sub-8': 4.8, 'DC-sub-9': 9.5}
In [19]:
CODE_PRINTED_TUKEY_DC = {  # Fig1-S1.ipynb, Tukey over the 18 groups: DC pairs with adjusted p < 0.05
    ("backward_left", "static"): 0.0236, ("backward_right", "static"): 0.0373, ("forward_left", "static"): 0.0397}
here = {tuple(sorted((r.group1[3:], r.group2[3:]))): r["p-adj"] for _, r in dc_pairs[dc_pairs.reject].iterrows()}
print("DC pairs significant in Fig1-S1.ipynb's printed output:", CODE_PRINTED_TUKEY_DC)
print("DC pairs significant here:", {k: round(v, 4) for k, v in here.items()})


def tukey_counts(sub):
    t = pairwise_tukeyhsd(sub.speed_mm_s, sub.species + "_" + sub.name)
    r = pd.DataFrame(t.summary().data[1:], columns=t.summary().data[0])
    dcp = r[r.group1.str.startswith("DC") & r.group2.str.startswith("DC")]
    zfp = r[r.group1.str.startswith("ZF") & r.group2.str.startswith("ZF")]
    return (f"DC pairs significant {int(dcp.reject.sum())} of {len(dcp)} (smallest p {dcp['p-adj'].min():.3f}); "
            f"ZF pairs significant {int(zfp.reject.sum())} of {len(zfp)} (smallest p {zfp['p-adj'].min():.3f})")


# Variants: the eight motion directions only; fish-stimulus values above 100 mm/s left out; fish medians
# of exactly 0 left out, as Fig1-S1.ipynb does; the speed check computed from positions in DC files too.
pos = omr.fish_medians(measures.assign(passes=measures.passes_pos), "speed_mm_s")
pos = pos[pos.code.isin(omr.WHOLE_FIELD)].assign(name=lambda d: d.code.map(omr.STIMULUS_NAMES))
for label, sub in (("directions only", wf[wf.code != 20]), ("values above 100 mm/s left out", wf[wf.speed_mm_s <= 100]),
                   ("zero medians left out", wf[wf.speed_mm_s != 0]), ("speed check from positions", pos)):
    print(f"\n{label}: {tukey_counts(sub)}")
print("\nDC static, per fish, speed check from positions (mm/s):", pos[(pos.species == 'DC') & (pos.code == 20)].set_index('fish').speed_mm_s.round(1).to_dict())
DC pairs significant in Fig1-S1.ipynb's printed output: {('backward_left', 'static'): 0.0236, ('backward_right', 'static'): 0.0373, ('forward_left', 'static'): 0.0397}
DC pairs significant here: {('backward_left', 'static'): 0.0297, ('backward_right', 'static'): 0.046, ('forward_left', 'static'): 0.0488}
directions only: DC pairs significant 0 of 28 (smallest p 0.999); ZF pairs significant 3 of 28 (smallest p 0.009)
values above 100 mm/s left out: DC pairs significant 0 of 36 (smallest p 1.000); ZF pairs significant 0 of 36 (smallest p 0.110)
zero medians left out: DC pairs significant 3 of 36 (smallest p 0.030); ZF pairs significant 0 of 36 (smallest p 0.835)
speed check from positions: DC pairs significant 3 of 36 (smallest p 0.030); ZF pairs significant 0 of 36 (smallest p 0.835)

DC static, per fish, speed check from positions (mm/s): {'DC-sub-10': 19.8, 'DC-sub-11': 0.0, 'DC-sub-2': 106.5, 'DC-sub-4': 10.5, 'DC-sub-5': 11.9, 'DC-sub-6': 0.0, 'DC-sub-7': 13.9, 'DC-sub-8': 4.8, 'DC-sub-9': 9.5}

Result: not reproduced. By the paper's test, Tukey's HSD over the 18 groups, 3 of 36 DC pairs differ (static against backward left, backward right and forward left, adjusted p 0.030 to 0.049), so the test finds a difference within DC. All three involve static, whose DC values come from 9 fish and include one at 106.5 mm/s and two near 0 (shown as 0.0 when rounded). Among the eight motion directions no DC pair differs (smallest adjusted p 0.999); the DC per-stimulus medians span 8.81 to 10.52 mm/s (ratio 1.19, against 2.03 in ZF), and a one-way ANOVA across the nine DC stimuli gives p = 0.479 (ZF p = 0.00082). With values above 100 mm/s left out, no DC pair differs. The same Tukey test finds no ZF pair over the 18 groups (smallest adjusted p 0.835), and 3 of 28 among the directions alone. The output of Fig1-S1.ipynb itself shows the same three DC pairs as significant (adjusted p 0.0236, 0.0373 and 0.0397), so the paper's own test disagrees with the claim, and the result here matches the paper's computation. Computing the speed check from positions, or leaving out zero medians as the code does, gives the same 3 pairs. The DC-sub-2 static value of 106.5 mm/s comes from one trial over a 14.6 s motion phase in a 100 mm dish, which is likely a tracking artefact (see the trial measures), and without values above 100 mm/s no DC pair differs. The outcome is a property of the paper's test on this data; it is not evidence that DC modulate their speed with stimulus direction.

fig1d-dc-faster-oblique¶

DC swim at a significantly higher velocity than ZF for oblique stimuli.

In [20]:
rows = []
for c in omr.WHOLE_FIELD:
    sub = wf[wf.code == c]
    t = pairwise_tukeyhsd(sub.speed_mm_s, sub.species)
    rows.append({"code": c, "name": omr.STIMULUS_NAMES[c],
                 "DC_median": sub[sub.species == "DC"].speed_mm_s.median(),
                 "ZF_median": sub[sub.species == "ZF"].speed_mm_s.median(),
                 "mean_DC_minus_ZF": sub[sub.species == "DC"].speed_mm_s.mean() - sub[sub.species == "ZF"].speed_mm_s.mean(),
                 "p": t.pvalues[0], "n_DC": int((sub.species == "DC").sum()), "n_ZF": int((sub.species == "ZF").sum())})
per_stim = pd.DataFrame(rows)
per_stim["significant_at_0.05/9"] = per_stim.p < 0.05 / 9
print(per_stim.round(4).to_string(index=False))
reading_a = ["forward_right", "forward_left", "backward_right", "backward_left"]
reading_b = ["right", "left", "backward_right", "backward_left"]
for label, names in (("a, intercardinal", reading_a), ("b, 90 degrees or more", reading_b)):
    sel = per_stim[per_stim.name.isin(names)]
    n = int((sel["significant_at_0.05/9"] & (sel.mean_DC_minus_ZF > 0)).sum())
    print(f"Reading {label}: {n} of 4 stimuli with DC significantly faster")
 code           name  DC_median  ZF_median  mean_DC_minus_ZF      p  n_DC  n_ZF  significant_at_0.05/9
   20         static    10.5207     4.8415           12.1059 0.1633     9    18                  False
    0        forward     9.6858     8.7799            0.7501 0.5140    12    25                  False
    5  forward_right    10.0216     8.3811            1.9967 0.1838    12    25                  False
    3          right    10.2944     6.7082            3.9483 0.0002    12    25                   True
    4 backward_right    10.4239     4.8425            3.8107 0.0047    12    25                   True
    1       backward    10.3299     4.3323            4.6720 0.0002    12    25                   True
    7  backward_left     8.8098     5.1609            3.4377 0.0071    12    25                  False
    2           left     9.3607     6.3879            3.3426 0.0411    12    25                  False
    6   forward_left     8.9690     8.2308            1.3532 0.2411    12    25                  False
Reading a, intercardinal: 1 of 4 stimuli with DC significantly faster
Reading b, 90 degrees or more: 2 of 4 stimuli with DC significantly faster

Result: reproduced. Under reading (b) DC are significantly faster than ZF for right (p = 0.0002) and backward right (p = 0.0047), 2 of 4; under reading (a) for 1 of 4 (backward right). Backward motion, which is not oblique, also differs (p = 0.0002).

fig1g-follow-direction¶

Both species follow the direction of optic flow, turning right (positive angular velocity) to rightward motion and left to leftward motion.

In [21]:
awf = angvel_fish[angvel_fish.code.isin(omr.WHOLE_FIELD)].copy()
awf["name"] = awf.code.map(omr.STIMULUS_NAMES)
print("Species medians of fish median angular velocity (deg/s), and fish turning right:")
summary = awf.groupby(["name", "species"]).angvel_deg_s.agg(["median", "size", lambda v: int((v > 0).sum())])
summary.columns = ["median", "n_fish", "n_positive"]
print(summary.round(2).unstack("species").to_string())

aw = awf.pivot_table(index=["species", "fish"], columns="code", values="angvel_deg_s")
score = (aw[omr.RIGHTWARD].mean(axis=1) - aw[omr.LEFTWARD].mean(axis=1)).rename("right_minus_left")
print("\nPer fish, mean over rightward minus mean over leftward stimuli (deg/s):")
print(score.round(2).to_string())
for sp in ("ZF", "DC"):
    v = score.loc[sp].dropna()
    p = stats.wilcoxon(v, alternative="greater").pvalue
    print(f"{sp}: {len(v)} fish, median {v.median():.2f} deg/s, {int((v > 0).sum())} positive, one-sided Wilcoxon p = {p:.3g}")
Species medians of fish median angular velocity (deg/s), and fish turning right:
               median        n_fish     n_positive    
species            DC     ZF     DC  ZF         DC  ZF
name                                                  
backward         5.05   0.03     12  25         11  14
backward_left   -2.28 -21.51     12  25          3   3
backward_right   3.39   7.73     12  25          9  22
forward         -1.98   0.62     12  25          5  17
forward_left     1.51  -1.42     12  25          8   9
forward_right    1.79   6.56     12  25          8  21
left             0.25 -22.30     12  25          6   3
right            8.67  17.16     12  25          8  23
static          -1.81   2.33      9  18          4  10

Per fish, mean over rightward minus mean over leftward stimuli (deg/s):
species  fish     
DC       DC-sub-0     -2.07
         DC-sub-1      5.38
         DC-sub-10    -2.88
         DC-sub-11     1.39
         DC-sub-2     12.33
         DC-sub-3     30.94
         DC-sub-4     44.08
         DC-sub-5     42.24
         DC-sub-6     -6.55
         DC-sub-7     -4.07
         DC-sub-8     -0.44
         DC-sub-9      0.38
ZF       ZF-sub-0     33.05
         ZF-sub-1     26.01
         ZF-sub-10    49.14
         ZF-sub-11    28.37
         ZF-sub-12    45.92
         ZF-sub-13    40.85
         ZF-sub-14     1.48
         ZF-sub-15    14.03
         ZF-sub-16    23.02
         ZF-sub-17     4.28
         ZF-sub-18     7.80
         ZF-sub-19    35.78
         ZF-sub-2     37.73
         ZF-sub-20    43.98
         ZF-sub-21    48.41
         ZF-sub-22    -6.59
         ZF-sub-23    47.79
         ZF-sub-24    52.60
         ZF-sub-3     29.51
         ZF-sub-4     38.79
         ZF-sub-5     -2.87
         ZF-sub-6     22.85
         ZF-sub-7      5.63
         ZF-sub-8      2.30
         ZF-sub-9     39.47
ZF: 25 fish, median 29.51 deg/s, 23 positive, one-sided Wilcoxon p = 9.83e-07
DC: 12 fish, median 0.88 deg/s, 7 positive, one-sided Wilcoxon p = 0.151
In [22]:
# Variant: by frame-rate group, and the binocular pair alone (right minus left).
rate_aw = score.reset_index().assign(rate=lambda d: d.fish.map(lambda f: "fast" if hz[f] > 100 else "slow"))
print(rate_aw.groupby(["species", "rate"]).right_minus_left.agg(["median", "size"]).round(2).to_string())
pair = (aw[3] - aw[2]).rename("right_minus_left_binocular")
for sp in ("ZF", "DC"):
    v = pair.loc[sp].dropna()
    print(f"{sp}, codes 3 minus 2 only: median {v.median():.2f} deg/s over {len(v)} fish, p = {stats.wilcoxon(v, alternative='greater').pvalue:.3g}")
              median  size
species rate              
DC      fast    0.38    11
        slow   44.08     1
ZF      fast   29.51     7
        slow   32.08    18
ZF, codes 3 minus 2 only: median 45.08 deg/s over 25 fish, p = 2.62e-06
DC, codes 3 minus 2 only: median 3.10 deg/s over 12 fish, p = 0.133

Result: partially reproduced. ZF follow the direction of motion: the rightward minus leftward difference is positive in 23 of 25 fish (median 29.51 deg/s, one-sided Wilcoxon p = 9.8e-07). DC do not by this test: 7 of 12 fish are positive, median 0.88 deg/s, p = 0.151, and the DC median for left motion is +0.25 deg/s. Codes 3 minus 2 alone give the same (ZF 45.08 deg/s, p = 2.6e-06; DC 3.10 deg/s, p = 0.133). DC turn right to right motion (median 8.67 deg/s), and four DC fish have differences above 12 deg/s.

fig1h-zf-higher-angular-acceleration¶

Across directional stimuli ZF have higher angular acceleration than DC, and the distributions of the two species differ for every stimulus.

In [23]:
started = time.time()
passing = set(zip(measures[measures.passes].path, measures[measures.passes].trial))
accel = pd.concat([omr.acceleration_frames(s, passing) for s in sessions], ignore_index=True)
accel = accel[accel.code.isin(omr.WHOLE_FIELD)]
print(f"angular acceleration frames computed in {time.time() - started:.0f} s; frames kept:")
print(accel.groupby(["kind", "species"]).size().to_string())


def accel_tests(kind):
    rows = []
    for c in omr.WHOLE_FIELD:
        sub = accel[(accel.kind == kind) & (accel.code == c)]
        z, d = sub[sub.species == "ZF"].accel, sub[sub.species == "DC"].accel
        fz = sub[sub.species == "ZF"].groupby("fish").accel.median()
        fd = sub[sub.species == "DC"].groupby("fish").accel.median()
        rows.append({"name": omr.STIMULUS_NAMES[c], "ZF_median": z.median(), "DC_median": d.median(),
                     "n_ZF": len(z), "n_DC": len(d), "kruskal_p": stats.kruskal(z, d).pvalue,
                     "ZF_fish_median": fz.median(), "DC_fish_median": fd.median(),
                     "fish_mannwhitney_p": stats.mannwhitneyu(fz, fd).pvalue})
    out = pd.DataFrame(rows)
    out["zf_higher_and_sig"] = (out.ZF_median > out.DC_median) & (out.kruskal_p < 0.05 / 9)
    return out


main = accel_tests("methods")
print("\nMethods definition (deg/s^2, heading at 30 Hz), frames 10 to 1000:")
print(main.to_string(index=False, float_format=lambda v: f"{v:.3g}"))
print(f"\nStimuli with ZF higher and Kruskal-Wallis p < 0.05/9: {int(main.zf_higher_and_sig.sum())} of 9")
print(f"Per-fish variant, ZF fish median higher and Mann-Whitney p < 0.05/9: "
      f"{int(((main.ZF_fish_median > main.DC_fish_median) & (main.fish_mannwhitney_p < 0.05 / 9)).sum())} of 9")
angular acceleration frames computed in 18 s; frames kept:
kind     species
code     DC         471552
         ZF         869982
methods  DC          36895
         ZF         246834
theta    DC          32619
         ZF         188787
Methods definition (deg/s^2, heading at 30 Hz), frames 10 to 1000:
          name  ZF_median  DC_median  n_ZF  n_DC  kruskal_p  ZF_fish_median  DC_fish_median  fish_mannwhitney_p  zf_higher_and_sig
        static       55.4        179  3720   823          0            58.1             188            5.12e-06              False
       forward       57.3        183 14729  3490          0            55.5             171            3.23e-06              False
 forward_right       56.5        143 20871  3890          0            59.4             156            7.01e-06              False
         right       66.4        187 37350  5597          0            63.8             204            1.45e-06              False
backward_right       63.2        175 39015  5155          0            64.2             174            1.71e-06              False
      backward       58.4        170 36839  4647          0            59.7             199            2.76e-06              False
 backward_left       62.4        188 38625  5429          0            60.8             164            2.76e-06              False
          left       64.7        191 38207  4131          0            68.5             187               2e-06              False
  forward_left       58.2        180 17478  3733          0            57.8             177            3.23e-06              False

Stimuli with ZF higher and Kruskal-Wallis p < 0.05/9: 0 of 9
Per-fish variant, ZF fish median higher and Mann-Whitney p < 0.05/9: 0 of 9
In [24]:
coded = accel_tests("code")
print("Variant: Fig1-S1.ipynb's quantity (per-frame heading change / file mean frame interval):")
print(coded.to_string(index=False, float_format=lambda v: f"{v:.3g}"))
print(f"\nStimuli with ZF higher and p < 0.05/9: {int(coded.zf_higher_and_sig.sum())} of 9")
ratio = pd.DataFrame([{"fish": omr.fish_key(s), "frame_interval_median_ms": 1000 / s["frame_hz"], "code_dt_ms": 1000 * s["code_dt"]} for s in sessions])
ratio["code_dt_over_frame_interval"] = ratio.code_dt_ms / ratio.frame_interval_median_ms
fast_fish = {f for f, v in hz.items() if v > 100}
sub = accel[(accel.kind == "methods") & accel.fish.isin(fast_fish)]
fm = sub.groupby(["species", "fish"]).accel.median()
print("\nMethods definition, files at 128-171 Hz only: per-fish median over all whole-field frames (deg/s^2):",
      f"ZF {fm.loc['ZF'].median():.1f} (n {len(fm.loc['ZF'])}), DC {fm.loc['DC'].median():.1f} (n {len(fm.loc['DC'])}),",
      f"Mann-Whitney p = {stats.mannwhitneyu(fm.loc['ZF'], fm.loc['DC']).pvalue:.3g}")
print("\nThe code's mean interval against the median frame interval, by species:")
print(ratio.groupby(ratio.fish.str[:2]).code_dt_over_frame_interval.describe().round(2).to_string())
# Noise floor: the stationary phase (0 to 3 s, grating still) against the motion phase, and the
# measure from the unwrapped f0_theta in place of the cumulative sum of f0_vtheta.
floor = accel[accel.kind.isin(["methods", "theta"])].groupby(["kind", "species", "stationary"]).accel.agg(["median", "size"])
print("\nMedian absolute angular acceleration (deg/s^2) by phase (stationary = first 3 s), frames 10 to 1000:")
print(floor.round(1).to_string())
fish_floor = accel[accel.kind == "methods"].groupby(["species", "fish", "stationary"]).accel.median().unstack()
fish_floor.columns = ["motion", "stationary"]
print("\nPer fish (Methods definition): median in the stationary phase against the motion phase:")
print(fish_floor.round(1).to_string())
for sp in ("ZF", "DC"):
    f = fish_floor.loc[sp].dropna()
    print(f"{sp}: stationary over motion, median ratio {(f.stationary / f.motion).median():.2f} over {len(f)} fish; "
          f"Wilcoxon p = {stats.wilcoxon(f.stationary, f.motion).pvalue:.3g}")
theta = accel_tests("theta")
print("\nVariant: heading from the unwrapped f0_theta:")
print(theta[["name", "ZF_median", "DC_median", "kruskal_p", "ZF_fish_median", "DC_fish_median", "fish_mannwhitney_p"]]
      .to_string(index=False, float_format=lambda v: f"{v:.3g}"))
print(f"Stimuli with ZF higher and p < 0.05/9: {int(theta.zf_higher_and_sig.sum())} of 9")
Variant: Fig1-S1.ipynb's quantity (per-frame heading change / file mean frame interval):
          name  ZF_median  DC_median   n_ZF  n_DC  kruskal_p  ZF_fish_median  DC_fish_median  fish_mannwhitney_p  zf_higher_and_sig
        static       29.5       33.5  11123  8724     0.0148            21.7            19.1               0.338              False
       forward       27.2       22.1  62566 47939          0              18            16.7                0.14               True
 forward_right       24.4       21.8  81283 48494          0            18.8            16.9               0.046               True
         right       26.1       22.4 120287 68546          0            22.2              17             0.00242               True
backward_right         28       24.3 135332 70136          0            22.5            16.6             0.00453               True
      backward       28.2       24.2 124936 63830          0            22.5            16.5              0.0143               True
 backward_left       28.6       26.7 131434 62142   1.02e-20            24.6            16.7             0.00332               True
          left       26.7       25.7 121937 58593      0.724            22.7            16.6             0.00744              False
  forward_left       26.3       21.9  81084 43148          0            19.4            15.8              0.0577               True

Stimuli with ZF higher and p < 0.05/9: 7 of 9

Methods definition, files at 128-171 Hz only: per-fish median over all whole-field frames (deg/s^2): ZF 84.9 (n 7), DC 178.6 (n 11), Mann-Whitney p = 6.28e-05

The code's mean interval against the median frame interval, by species:
      count  mean   std   min   25%   50%    75%    max
fish                                                   
DC     12.0  10.9  9.25  1.83  6.52  8.73  13.59  37.31
ZF     25.0   1.5  0.51  1.05  1.20  1.37   1.54   3.12
Median absolute angular acceleration (deg/s^2) by phase (stationary = first 3 s), frames 10 to 1000:
                                median    size
kind    species stationary                    
methods DC      False       171.300003   28478
                True        205.000000    8417
        ZF      False        60.799999  215075
                True         66.900002   31759
theta   DC      False       235.800003   25357
                True        270.200012    7262
        ZF      False        66.400002  162345
                True         83.599998   26442

Per fish (Methods definition): median in the stationary phase against the motion phase:
                       motion  stationary
species fish                             
DC      DC-sub-0   188.800003  235.600006
        DC-sub-1   320.799988  271.799988
        DC-sub-10  214.899994  232.300003
        DC-sub-11  108.199997  169.399994
        DC-sub-2   120.500000  210.199997
        DC-sub-3   169.800003  213.300003
        DC-sub-4   137.399994  122.800003
        DC-sub-5   155.600006  210.399994
        DC-sub-6   100.300003  291.899994
        DC-sub-7   249.399994  282.799988
        DC-sub-8   169.500000  196.199997
        DC-sub-9   171.300003  212.399994
ZF      ZF-sub-0    62.000000   81.800003
        ZF-sub-1    97.199997  116.300003
        ZF-sub-10   61.200001   61.299999
        ZF-sub-11   45.700001   49.299999
        ZF-sub-12   58.200001   62.400002
        ZF-sub-13   54.799999   54.900002
        ZF-sub-14   42.700001   48.500000
        ZF-sub-15   43.500000   50.900002
        ZF-sub-16   39.599998   47.200001
        ZF-sub-17   52.799999   54.500000
        ZF-sub-18   31.700001   37.200001
        ZF-sub-19   65.000000   65.500000
        ZF-sub-2    68.900002  116.500000
        ZF-sub-20   76.000000   81.699997
        ZF-sub-21   81.599998   86.900002
        ZF-sub-22   70.800003   65.900002
        ZF-sub-23   76.500000   62.599998
        ZF-sub-24   72.500000   74.800003
        ZF-sub-3   123.300003  132.000000
        ZF-sub-4    78.599998  129.500000
        ZF-sub-5    62.099998   68.099998
        ZF-sub-6    83.800003  116.500000
        ZF-sub-7    53.500000   57.299999
        ZF-sub-8    52.500000   58.200001
        ZF-sub-9    63.299999   64.500000
ZF: stationary over motion, median ratio 1.07 over 25 fish; Wilcoxon p = 0.00012
DC: stationary over motion, median ratio 1.24 over 12 fish; Wilcoxon p = 0.0161
Variant: heading from the unwrapped f0_theta:
          name  ZF_median  DC_median  kruskal_p  ZF_fish_median  DC_fish_median  fish_mannwhitney_p
        static       61.8        278          0            63.1             253             0.00268
       forward       77.3        233          0              78             222            2.76e-06
 forward_right       66.4        218          0            67.6             225               2e-06
         right       73.3        239          0            70.9             219            1.23e-06
backward_right       66.3        261          0            69.6             250            1.23e-06
      backward       64.5        251          0            64.1             263            2.76e-06
 backward_left       67.4        222          0            67.8             202            3.78e-06
          left         69        252          0            74.5             243            1.23e-06
  forward_left       71.5        273          0            75.8             283               2e-06
Stimuli with ZF higher and p < 0.05/9: 0 of 9
In [25]:
# Noise floor, direct test: angular acceleration (Methods definition) in frames where the fish is
# still, by species. Speed is from raw positions at 30 Hz with the 7-sample rolling median.
m = accel[accel.kind == "methods"].dropna(subset=["speed_mm_s"])
bands = pd.cut(m.speed_mm_s, [0, 1, 2, 5, np.inf], right=False, labels=["<1", "1-2", "2-5", ">=5"])
print("Median absolute angular acceleration (deg/s^2) by speed band (mm/s), with frames and fish:")
print(m.groupby(["species", bands], observed=True).agg(median=("accel", "median"), frames=("accel", "size"),
                                                       fish=("fish", "nunique")).round(1).to_string())
print("\nFraction of frames below 1 mm/s:", m.assign(still=m.speed_mm_s < 1).groupby("species").still.mean().round(3).to_dict())
for limit in (1, 2):
    still = m[m.speed_mm_s < limit].groupby(["species", "fish"]).accel.median()
    z, d = still.loc["ZF"], still.loc["DC"]
    print(f"Still below {limit} mm/s, per-fish medians: ZF {z.median():.1f} (n {len(z)}), DC {d.median():.1f} (n {len(d)}), "
          f"Mann-Whitney p = {stats.mannwhitneyu(z, d).pvalue:.3g}")
Median absolute angular acceleration (deg/s^2) by speed band (mm/s), with frames and fish:
                        median  frames  fish
species speed_mm_s                          
DC      <1           70.900002    4824    12
        1-2         143.899994    2323    12
        2-5         210.500000    4399    12
        >=5         207.300003   25349    12
ZF      <1           34.099998   68177    25
        1-2          59.299999   46200    25
        2-5          84.500000   70500    25
        >=5          85.800003   61957    25

Fraction of frames below 1 mm/s: {'DC': 0.131, 'ZF': 0.276}
Still below 1 mm/s, per-fish medians: ZF 34.9 (n 25), DC 72.4 (n 12), Mann-Whitney p = 0.000138
Still below 2 mm/s, per-fish medians: ZF 43.4 (n 25), DC 96.8 (n 12), Mann-Whitney p = 4.67e-05

Result: not reproduced. With angular acceleration as the Methods define it, DC are higher than ZF for all nine stimuli (medians 143 to 191 against 55.4 to 66.4 deg/s², Kruskal-Wallis p below 0.05/9 for each), the reverse of the claim, so 0 of 9 stimuli meet the criterion. Per-fish medians give the same, and so do the files at 128 to 171 Hz alone (ZF 84.9, DC 178.6 deg/s², p = 6.3e-05). The quantity Fig1-S1.ipynb computes gives ZF higher with p below 0.05/9 for 7 of 9 stimuli. That quantity divides each frame's heading change by the file's mean frame interval, which in DC files is a median 8.73 times the frame interval (ZF 1.37), because DC files hold only trial frames and the gaps between trials enter the mean.

The DC values may be partly tracking noise. In the stationary phase (first 3 s, grating still) the DC median is 205 deg/s², higher than the 171 during motion (per fish, a median ratio of 1.24, Wilcoxon p = 0.016); ZF are also higher at rest, 66.9 against 60.8 (ratio 1.07, p = 0.00012), so this comparison does not separate the species. In frames where the fish is still (speed below 1 mm/s), DC are still higher than ZF (per-fish medians 72.4 against 34.9 deg/s², Mann-Whitney p = 0.00014; below 2 mm/s, 96.8 against 43.4). In both species the values rise with speed, and DC are higher in every speed band (moving at 5 mm/s or more, 207 against 85.8). So DC heading is noisier when still, and DC are also higher when moving; these outputs cannot say whether the higher values in moving DC are noise or turning during continuous swimming. With the heading from the unwrapped f0_theta in place of the summed f0_vtheta, DC are higher still (218 to 278 against ZF 61.8 to 77.3 deg/s², 0 of 9 stimuli meet the criterion). Both heading series give DC the higher values, and neither shows a DC response to motion, so the reversal should not be read as evidence against the paper.

figs1e-dc-respond-less-monocular¶

DC generally respond less than ZF to monocular stimuli, suggesting that they need more visual motion input to drive the optomotor response.

In [26]:
def monocular_table(lateral_right, lateral_left):
    cats = {"static": [(20, 1)], "medial": [(10, 1), (13, -1)],
            "lateral": [(lateral_right, 1), (lateral_left, -1)], "binocular": [(3, 1), (2, -1)]}
    rows = []
    for cat, members in cats.items():
        for c, sign in members:
            sub = angvel_fish[angvel_fish.code == c]
            for _, r in sub.iterrows():
                rows.append({"category": cat, "species": r.species, "fish": r.fish, "code": c,
                             "toward_deg_s": sign * r.angvel_deg_s})
    return pd.DataFrame(rows)


def monocular_tests(df):
    model = ols("toward_deg_s ~ C(species) + C(category) + C(species):C(category)", data=df).fit()
    table = sm.stats.anova_lm(model, typ=2)
    rows = []
    for cat in ("static", "medial", "lateral", "binocular"):
        sub = df[df.category == cat]
        t = pairwise_tukeyhsd(sub.toward_deg_s, sub.species)
        rows.append({"category": cat, "ZF_median": sub[sub.species == "ZF"].toward_deg_s.median(),
                     "DC_median": sub[sub.species == "DC"].toward_deg_s.median(),
                     "n_ZF": int((sub.species == "ZF").sum()), "n_DC": int((sub.species == "DC").sum()),
                     "p": t.pvalues[0], "significant_at_0.05/4": t.pvalues[0] < 0.05 / 4})
    return table, pd.DataFrame(rows)


mono = monocular_table(9, 11)
table, per_cat = monocular_tests(mono)
print("Figure-code assignment (9 lateral right, 11 lateral left). Two-way ANOVA (type II):")
print(table.to_string(float_format=lambda v: f"{v:.4g}"))
print()
print(per_cat.round(4).to_string(index=False))
print("\nPer fish, angular velocity toward the stimulus (deg/s), mean of the two sides:")
print(mono.groupby(["species", "fish", "category"]).toward_deg_s.mean().unstack().round(1).to_string())
Figure-code assignment (9 lateral right, 11 lateral left). Two-way ANOVA (type II):
                          sum_sq  df     F    PR(>F)
C(species)                  3411   1 23.26 2.508e-06
C(category)            1.308e+04   3 29.73 2.166e-16
C(species):C(category)      1745   3 3.966  0.008737
Residual               3.534e+04 241   NaN       NaN

 category  ZF_median  DC_median  n_ZF  n_DC      p  significant_at_0.05/4
   static     2.3291    -1.8061    18     9 0.2046                  False
   medial    13.4811     3.2584    50    24 0.0003                   True
  lateral    -0.1256    -1.0730    50    24 0.9586                  False
binocular    21.8698     2.8671    50    24 0.0002                   True

Per fish, angular velocity toward the stimulus (deg/s), mean of the two sides:
category           binocular  lateral  medial  static
species fish                                         
DC      DC-sub-0         0.6     -4.6    13.3     NaN
        DC-sub-1         2.5     -0.1    -4.4     NaN
        DC-sub-10       -4.8     -6.0     2.5   -17.9
        DC-sub-11        2.7      4.2     2.7     0.5
        DC-sub-2         8.4      2.7     3.5    -6.6
        DC-sub-3        13.0     -6.0     2.2     NaN
        DC-sub-4        17.7     -6.6     9.3     2.3
        DC-sub-5        24.0      3.8    11.6   -53.2
        DC-sub-6        -1.3      0.3     0.8    -1.8
        DC-sub-7       -11.4     -7.4    -4.4    27.5
        DC-sub-8        -1.4     -0.3     4.6     4.8
        DC-sub-9        -0.2     -4.5     6.4   -48.9
ZF      ZF-sub-0        20.9      0.5    19.5    -4.1
        ZF-sub-1        31.2      4.7    12.7     NaN
        ZF-sub-10       36.4     -8.1    29.4     7.2
        ZF-sub-11       24.2     -2.8    14.9    -5.7
        ZF-sub-12       32.8    -12.1    19.2     7.4
        ZF-sub-13       33.9     -1.1    31.7     NaN
        ZF-sub-14        0.8      2.0     3.8    -3.9
        ZF-sub-15        9.2      2.4    18.0     6.7
        ZF-sub-16       12.5     -0.1     5.7     3.4
        ZF-sub-17        9.6      7.4     7.9     NaN
        ZF-sub-18        4.0     -0.2    13.5     9.3
        ZF-sub-19       30.9      0.1    10.8     NaN
        ZF-sub-2        14.8      0.5     6.2   -16.5
        ZF-sub-20       23.6      2.0     8.7     5.0
        ZF-sub-21       30.9     -8.4    11.4    12.6
        ZF-sub-22       -2.6      1.5     3.0    -5.3
        ZF-sub-23       30.9     -8.2    20.4     1.3
        ZF-sub-24       34.9     -1.6    18.4    17.3
        ZF-sub-3        26.1    -25.0    12.7    24.7
        ZF-sub-4        22.5    -12.7    17.8   -25.6
        ZF-sub-5       -11.1      7.9    -0.5     NaN
        ZF-sub-6        19.3     -2.6    20.8   -18.6
        ZF-sub-7        -0.1      1.6    -1.2     NaN
        ZF-sub-8         1.8     -3.5    -5.3     NaN
        ZF-sub-9        24.5      2.2    19.2   -23.5
In [27]:
swapped = monocular_table(11, 9)
t2, p2 = monocular_tests(swapped)
print("Variant, protocol-file assignment (11 lateral right, 9 lateral left):")
print("species effect p =", f"{t2.loc['C(species)', 'PR(>F)']:.3g}")
print(p2.round(4).to_string(index=False))
Variant, protocol-file assignment (11 lateral right, 9 lateral left):
species effect p = 2.11e-06
 category  ZF_median  DC_median  n_ZF  n_DC      p  significant_at_0.05/4
   static     2.3291    -1.8061    18     9 0.2046                  False
   medial    13.4811     3.2584    50    24 0.0003                   True
  lateral     0.1256     1.0730    50    24 0.9586                  False
binocular    21.8698     2.8671    50    24 0.0002                   True

Result: reproduced. The species effect is significant (F = 23.3, p = 2.5e-06), and DC turn less toward the stimulus than ZF for medial stimuli (median 3.26 against 13.48 deg/s, p = 0.0003) and binocular ones (2.87 against 21.87 deg/s, p = 0.0002). For lateral stimuli DC are below ZF only nominally (-1.07 against -0.13 deg/s, p = 0.96): neither species turns toward lateral motion, and the criterion's lateral part holds by 0.95 deg/s. With the protocol file's assignment of codes 9 and 11 the lateral medians change sign (ZF 0.13, DC 1.07 deg/s), DC are no longer below ZF there, and that variant would be partially reproduced. The DC turning time courses (under the link claim) lean toward the protocol file's assignment if the fish turn with lateral motion, so the lateral part of this result is fragile.

fig1c-continuous-vs-bouts¶

ZF swim in discrete bouts separated by glides, while DC swim continuously at a relatively constant velocity.

Measure fixed in the claims file: the ratio of median to mean instantaneous speed (positions at 30 Hz, 3 to 35 s), per fish the median over all trials of all codes.

In [28]:
steady = measures[measures.passes].groupby(["species", "fish"]).steadiness.median()
print("Per fish median steadiness (median / mean instantaneous speed):")
print(steady.round(3).to_string())
z, d = steady.loc["ZF"].dropna(), steady.loc["DC"].dropna()
p = stats.mannwhitneyu(z, d, alternative="two-sided").pvalue
print(f"\nZF median {z.median():.3f} over {len(z)} fish; DC median {d.median():.3f} over {len(d)} fish; Mann-Whitney p = {p:.3g}")
by_rate = steady.reset_index().assign(rate=lambda df: df.fish.map(lambda f: "fast" if hz[f] > 100 else "slow"))
print("\nBy frame rate of the file:")
print(by_rate.groupby(["species", "rate"]).steadiness.agg(["median", "size"]).round(3).to_string())
zs = by_rate[(by_rate.species == "ZF") & (by_rate.rate == "fast")].steadiness
ds = by_rate[(by_rate.species == "DC") & (by_rate.rate == "fast")].steadiness
print(f"Fast files only: ZF {zs.median():.3f} (n {len(zs)}) against DC {ds.median():.3f} (n {len(ds)}), p = {stats.mannwhitneyu(zs, ds).pvalue:.3g}")
fwd = measures[measures.passes & (measures.code == 0)].groupby(["species", "fish"]).steadiness.median()
print(f"Forward trials only (code 0): ZF {fwd.loc['ZF'].median():.3f}, DC {fwd.loc['DC'].median():.3f}, p = {stats.mannwhitneyu(fwd.loc['ZF'].dropna(), fwd.loc['DC'].dropna()).pvalue:.3g}")
Per fish median steadiness (median / mean instantaneous speed):
species  fish     
DC       DC-sub-0     0.958
         DC-sub-1     0.917
         DC-sub-10    0.974
         DC-sub-11    0.884
         DC-sub-2     0.848
         DC-sub-3     0.898
         DC-sub-4     0.999
         DC-sub-5     0.896
         DC-sub-6     0.140
         DC-sub-7     0.985
         DC-sub-8     0.741
         DC-sub-9     0.968
ZF       ZF-sub-0     0.298
         ZF-sub-1     0.392
         ZF-sub-10    0.292
         ZF-sub-11    0.306
         ZF-sub-12    0.273
         ZF-sub-13    0.302
         ZF-sub-14    0.371
         ZF-sub-15    0.293
         ZF-sub-16    0.276
         ZF-sub-17    0.267
         ZF-sub-18    0.580
         ZF-sub-19    0.435
         ZF-sub-2     0.244
         ZF-sub-20    0.359
         ZF-sub-21    0.365
         ZF-sub-22    0.426
         ZF-sub-23    0.320
         ZF-sub-24    0.339
         ZF-sub-3     0.414
         ZF-sub-4     0.339
         ZF-sub-5     0.263
         ZF-sub-6     0.283
         ZF-sub-7     0.281
         ZF-sub-8     0.342
         ZF-sub-9     0.302

ZF median 0.306 over 25 fish; DC median 0.908 over 12 fish; Mann-Whitney p = 5.36e-05

By frame rate of the file:
              median  size
species rate              
DC      fast   0.898    11
        slow   0.999     1
ZF      fast   0.298     7
        slow   0.313    18
Fast files only: ZF 0.298 (n 7) against DC 0.898 (n 11), p = 0.00283
Forward trials only (code 0): ZF 0.376, DC 0.956, p = 1.23e-06

Result: reproduced. The median-to-mean ratio of instantaneous speed is 0.306 in ZF (25 fish, 0.244 to 0.580) and 0.908 in DC (12 fish, 11 of them above 0.74 and one at 0.140), Mann-Whitney p = 5.4e-05. The difference holds in the files at 128 to 171 Hz alone (0.298 against 0.898, p = 0.0028) and in forward trials alone (0.376 against 0.956, p = 1.2e-06).

Summary¶

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

Claim Data Outcome
stimulus-code-link with inputs reproduced
fig1d-dc-faster-all-codes dandiset reproduced
fig1d-median-velocity with inputs reproduced
fig1d-zf-forward-faster-than-static with inputs reproduced
fig1d-dc-no-modulation with inputs not reproduced
fig1d-dc-faster-oblique with inputs reproduced
fig1g-follow-direction with inputs partially reproduced
fig1h-zf-higher-angular-acceleration with inputs not reproduced
figs1e-dc-respond-less-monocular with inputs reproduced
fig1c-continuous-vs-bouts dandiset reproduced
fig1cef-representative-values dandiset blocked: metadata insufficient
fig2d, fig2g, fig2h, fig2k, fig2l, fig3e, fig3cd, fig4f, fig4hi dandiset blocked: data missing

How the analysis differs from the paper's. The stimulus names come from the paper's code and the stimulus software, not from the dandiset. The trial processing follows the paper's notebook Fig1-S1.ipynb, except that DC files have no vel column, so the code's filter on it is not applied. The dandiset has 25 ZF, not 26. Angular acceleration follows the Methods' definition, on heading resampled to 30 Hz; the code's quantity is a variant. The test of "following the direction" and the measure of continuous swimming are choices made here, as the paper gives no test for the first and bases the second on bout files that are not in the dandiset.

What was found about the dandiset.

  • It holds only the freely swimming behaviour of Figure 1 and Figure S1. The imaging and head-fixed tail tracking of Figures 2 to 4 are not in it, and the GitLab repository the paper names for "all behavioral and imaging data" holds notebooks only.
  • The stimulus codes 0 to 20 have no names or description in the files or the metadata. The paper's code and the stimulus software disagree on which of codes 9 and 11 is lateral right.
  • Subject ids 0 to 11 are each used for one ZF and one DC; the archive's summary counts 25 subjects and lists only Danio rerio.
  • f0_vtheta is a heading change per frame, not radians/s as stated; in most DC files its sum does not match the change of f0_theta.
  • 18 of 25 ZF files and 1 DC file are at 32 to 35 Hz, not above 150 Hz as the Methods say; their timestamp intervals and trial lengths show these are the real acquisition rates.
  • No spatial scale is given; positions in pixels are consistent with the code's 10 pixels per mm, except one DC file whose positions reach 1490 pixels.
  • ZF and DC files were exported differently: ZF files keep untracked and between-trial frames, DC files hold only tracked trial frames and an extra dist series.
  • The paper's own Tukey output finds three DC pairs significant (static against backward left, backward right and forward left), as here, while the paper states that DC do not modulate speed.
  • The stored dist series of DC files does not agree with the positions. The code's speed check uses it, but the check from positions passes the same trials; 11 DC trials above 100 mm/s pass because the check covers 0 to 35 s, not the 3 to 35 s the speed is measured on.
  • The paper's code computes "angular acceleration" as heading change per frame divided by the file's mean frame interval, which the gaps between trials inflate in DC files; the Figure 1H result depends on it. With the Methods' definition, DC values are higher than ZF at every speed, including when the fish is still (72.4 against 34.9 deg/s² below 1 mm/s), so part of the DC values may be tracking noise; the outputs cannot say how much.