Repository navigation
Statistics and Pseudoreplication
Applies to: Track2Data v0.1.0 and later.
Track2Data computes the numbers; it cannot choose your unit of replication. This page is about the decision that most often invalidates a behavioural-tracking analysis: treating frames, or animals sharing a tank, as independent observations.
The short version of the traps is in
examples/README.md,
and both starter scripts in
examples/analysis/
implement the model described below. This page is the longer form: which unit applies to
which metric family, and what to do about it.
frame → individual (individual_id) → session (session_id) → treatment / batch / day
Frames are never the unit of replication. They are autocorrelated by construction — an animal's position at frame t+1 is mostly its position at frame t — so a per-frame test on 20,000 frames reports a precision you do not have. Every summary metric already aggregates over frames; use the summary.
Animals within a session are not independent either. They see each other, share water, and share every disturbance. For group-living species that is not a nuisance term, it is the biology.
| Family | What a row is | Unit of replication | Model |
|---|---|---|---|
| IL-* individual locomotion | one per animal per session | the animal, nested in session | metric ~ treatment + (1 | session_id) |
| Z-3 … Z-9 zone metrics that follow a slot | one per animal per zone | the animal, nested in session; zone is a within-animal factor |
+ (1 | session_id), zone as a fixed factor or analysed per zone |
| Z-1, Z-2, Z-8 occupancy | per animal, or pooled on identity-free sessions | the animal, or the session when pooled | check for an individual_id; pooled rows are session-level |
| GL-* group metrics | one per session | the session | ordinary regression on session-level rows; n = number of sessions |
| D-* diagnostics | per animal or per session | not an outcome — a covariate or a filter | use for weighting and exclusion, not as a dependent variable |
The row that catches people out is GL-*. A tank of eight fish yields one nearest-neighbour distance, one polarisation, one cohesion index. Eight animals do not buy eight observations of a group property, so a shoaling study needs replicate tanks, not replicate fish.
With time bins switched on, bins are repeated measures within a session, not new sessions:
add bin_index as a fixed effect (or a smooth term) and keep the session random effect.
Two differences make sessions incomparable rather than merely variable:
- Frame rate scales speed, acceleration and path length.
- Group size changes every group metric by construction.
sessions.csv carries fps, duration_s, n_frames, n_animals and is_calibrated so
this is checkable in one line, and both starter scripts warn before modelling. The engine
also flags sessions whose idtracker.ai segmentation parameters, resolution_reduction or
id_image_size differ — body length and area are defined by those, and identity matching
across sessions needs them equal. Read PROJECT_SUMMARY.md from the run before you pool.
if (n_distinct(sessions$fps) > 1) stop("speed and path length are not comparable")path_length_*, visit_count, freezing_bout_count, zone transitions and every other
count or total scale with how long the session ran. A session 20 % longer produces a 20 %
larger value for identical behaviour.
- Prefer a rate (
mean_speed_*,*_fraction,*_pct) when one exists. - For counts, model the total with an exposure offset rather than dividing:
glmmTMB::glmmTMB(visit_count ~ treatment + offset(log(duration_s)) + (1 | session_id),
family = poisson, data = individual)time_pct, every frac_* column, active_fraction, freezing_fraction and the
polarisation means live in [0, 1]; jacobs_d lives in [−1, 1]. A Gaussian model on these
predicts impossible values near the bounds and understates the variance of extreme cases.
Use beta regression (glmmTMB(family = beta_family()), betareg), or a binomial model on
the underlying frame counts when you have them, or logit-transform and say so.
A fraction computed from few frames is far noisier than one computed from many, and the two
look identical in a table. Join D-11 and either weight by n_frames_used or exclude
individuals below a coverage threshold you state in your methods:
quality = long[long.metric_id == "D-11"].pivot_table(
index=["session_id", "individual_id"], columns="column", values="value"
).reset_index()
usable = quality.loc[quality.frac_frames_used >= 0.80, ["session_id", "individual_id"]]Pick the threshold before you look at the outcome, not after.
heading_rad and the IL-11 circular statistics are angles. Do not take arithmetic means,
SDs or ordinary regressions of them: the mean of 350° and 10° is 0°, not 180°. Use circular
statistics (circular in R, scipy.stats.circmean, pingouin.circ_*); IL-11 already
reports resultant_length and rayleigh_p for exactly this reason.
Smoothing window, max_gap_frames and whether identity-switch correction ran all change
the numbers — on the shipped example, the freezing-bout count moves 6.7 % across a
preprocessing grid while path length moves 1.2 %. They are recorded in manifest.json;
report them in your methods, and if a result sits near your significance threshold, check
whether it survives a different window:
track2data sensitivity project.t2d.json -o sensitivity/That writes a per-column coefficient of variation across a grid of smoothing windows and
max_gap_frames values. It deliberately prints no robust/not-robust verdict: where that
line sits depends on the effect size you are claiming.
The long table makes it trivial to test 50 metrics against one treatment, and equally
trivial to find a significant one by chance. Decide which metrics are primary before you
run the analysis, state the rest as exploratory, and correct within families of related
outcomes. metric_id is in every row, so pre-registering a list of ids is enough.
State, in the methods: Track2Data app_version and project_hash from manifest.json;
the preprocessing settings; the calibration mode and whether values are in cm or body
lengths; the coverage threshold and how many individuals it excluded; the bout criterion
actually applied (bout_criterion_effective, min_bout_frames_used); the random-effects
structure; and the unit of replication with n at that level — not the number of animals,
unless that is genuinely the level you analysed.
Source · Changelog · Issues · Report an error in this page
These pages describe the latest release unless a page says otherwise; version-pinned
documentation (the user guide, the metrics specification) lives in the repository and ships
with each release. Pages are published from docs/wiki/ — edit there, by pull request.
© 2026 Martina Bellio · MIT licence
Using it
Analysing the output
Reference
Developing