Heterogeneous error regimes: one sensor at a time
Source:vignettes/OUTLIER_heterogeneous_error_regimes.Rmd
OUTLIER_heterogeneous_error_regimes.RmdWhat these functions assume
Outlier detection in this package rests on one assumption: each fix
is a noisy estimate of where the moving device — and therefore the
animal carrying it — was at that moment. Every primitive in
mt_clean_track() (bridge residual, detour ratio, joint
probability, step speed) reads adjacent fixes as samples from one
underlying trajectory and asks which ones do not belong on it. That is
the Lagrangian view: following the moving body.
Sensor streams that natively report fixed-receiver detections — Sigfox-geolocation, LoRa-network triangulation, VHF antenna scans, camera-trap captures, hydrophone events — do not satisfy this assumption. Their raw output describes the receiving infrastructure, not the animal. Exclude these streams from cleaning; they belong in occupancy, detection-radius, or hidden-Markov models downstream.
The distinction is about the representation, not the source sensor. If an Eulerian sensor network has already been processed into a Lagrangian path — a reconstructed trajectory from a camera array with individual re-identification, a chronological sequence of geolocated tourist photographs of a known individual — that reconstructed path is exactly what these functions are designed to clean.
The rest of this vignette assumes your data are Lagrangian and addresses the case where a single track interleaves more than one Lagrangian sensor at very different precisions.
When you need this
Modern animal-tracking deployments increasingly mix Lagrangian
sensors at very different precisions within a single track. A GPS + GLS
deployment delivers metre-scale fixes when the GPS receiver gets a
satellite lock and hundred-kilometre fixes from twilight estimates when
it does not. An Argos-only deployment mixes LC = 3 fixes (a
few hundred metres of error) with LC = B fixes (no useful
accuracy). Combined GPS + Argos Doppler deployments span three orders of
magnitude in nominal positional error from one fix to the next.
The cleaning primitives fit their thresholds from the distribution of events they are handed. On a mixed-Lagrangian track that distribution is multimodal, and any single threshold either lets through the kilometre-scale events (because the threshold respects the broad tail) or rejects the metre-scale tail (because the threshold sits inside the broad bulk). Neither is the answer you want.
The right answer is not to add another knob to
mt_clean_track(). The right answer is to split your
data by sensor type before cleaning, clean each sub-object on its own
terms, and re-merge if your downstream analysis needs the combined
trajectory. The rest of this vignette walks through that
pattern.
How to recognise the case
If your tag reports a sensor_type_id event column with
more than one unique value, you have a mixed-regime track. The most
common signal is two very different orders of magnitude in nominal
horizontal accuracy:
library(move2)
library(dplyr)
# `your_track` is the Movebank object you are inspecting.
table(your_track$sensor_type_id)
#> <sensor_A> <sensor_B>
#> 69860 72949Cross-reference each sensor code against the Movebank attribute dictionary or your study’s metadata to know what is what. Two Lagrangian sensors at very different precisions — say, GPS plus GLS, or GPS plus Argos Doppler — are the case this vignette addresses. A track that mixes a Lagrangian sensor with an Eulerian one (Sigfox-geolocation, LoRa, antenna detection) is not this case; exclude the Eulerian stream entirely per the preceding section before proceeding.
Other signs of a mixed-regime track:
-
argos_lctaking values across3, 2, 1, 0, A, B, Zin the same track. -
eobs_horizontal_accuracy_estimate(or any per-fix accuracy column) showing a multi-modal distribution. - The same animal carrying two devices reporting into the same Movebank study with very different sampling cadence.
Why a single call to mt_clean_track() does the wrong
thing
If you hand a mixed-Lagrangian object to
mt_clean_track() directly, the cascade’s bridge,
joint-probability, detour, and step-speed threshold fitters all see a
step-speed distribution that mixes metre-scale steps from the precise
stream with kilometre- or hundred-kilometre-scale jumps from the coarse
stream. Whatever cap the auto-fitter chooses, it will either sit above
the coarse bulk — so almost no coarse fix gets flagged, even when one is
clearly off — or sit between the two bulks, in which case most coarse
fixes get flagged as outliers, including the merely-imprecise ones.
Neither is a useful answer. The diagnostic plot from
mt_diagnose_clean_track() will typically show the
multi-modal step-speed distribution honestly, and that is the cue to
stop and pre-split.
The pre-split / re-merge pattern
The split is mechanical: filter the move2 object on the per-fix
sensor column, clean each subset with its own call to
mt_clean_track(), then merge the resulting per-event flags
back onto the original object using event_id. Each
sub-object is internally homogeneous, so the cascade’s assumptions hold
within each call.
library(move2utils)
library(move2)
library(dplyr)
# 1. Split by sensor (event-level column).
your_track_A <- your_track[your_track$sensor_type_id == "<sensor_A>", ]
your_track_B <- your_track[your_track$sensor_type_id == "<sensor_B>", ]
# 2. Clean each on its own. Each call sees one error regime; the
# threshold detectors fit a single, well-behaved distribution.
# See "Choosing the speed-cap method" below for guidance on
# whether to pass mass+mode (or hard v_max) to each call.
clean_A <- mt_clean_track(your_track_A, plot = FALSE, remove = FALSE,
silent = TRUE)
clean_B <- mt_clean_track(your_track_B, plot = FALSE, remove = FALSE,
silent = TRUE)
# 3. Re-merge by joining the per-event flag back onto the original
# object using `event_id`. The two sub-objects may share track
# IDs (e.g. when a single deployment had both sensors), so a
# naive rbind() of the move2 objects can error on duplicates.
# An event_id join is the safe pattern.
flags <- rbind(
data.frame(event_id = clean_A$event_id, is_outlier = clean_A$is_outlier),
data.frame(event_id = clean_B$event_id, is_outlier = clean_B$is_outlier)
)
cleaned <- your_track
cleaned$is_outlier <- flags$is_outlier[match(cleaned$event_id, flags$event_id)]The mechanical points worth noting:
-
The split must be on an event-level column.
Track-level columns (everything in
mt_track_data()) describe the whole deployment and cannot resolve a within-track sensor mix. For per-tag splits use the event column that varies per fix (sensor_type_id,argos_lc, a custom column you derived from per-fix accuracy). -
Each sub-object is a valid move2. Subsetting with
[keeps the move2 class, the time column, and the track-id column. -
Use an event_id join, not
rbind, for re-merging. When a single deployment carried both sensors, the two sub-objects share track IDs andrbind()will refuse to combine them. Joining the flag back onto the originalyour_trackbyevent_idsidesteps this and also lets you propagate the flag onto the events from any tracks you filtered upstream (which then carryNAforis_outlier).
Choosing the speed-cap method for each sub-stream
After the split, each sub-stream is cleaned with its own call to
mt_clean_track(). The most consequential per-call decision
is whether to supply a physiological speed cap (mass + mode
for the allometric cap from v_phys_estimate(), or a hard
v_max = in m/s) or to let the cascade’s relative auto-cap
do the work alone. The rule is mechanical once you state the
comparison:
Compare the sensor’s noise floor in step-speed units (
positional_error / sampling_interval) against the animal’s physiological maximum step speed. If the noise floor is well below physiology, supply a physiological cap. If the noise floor is at or above physiology, do not.
Two illustrations:
- GPS, 10 m typical error, 10-minute sampling. Noise floor ≈ 10 m / 600 s ≈ 0.017 m/s. Far below any terrestrial-mammal physiological maximum. A physiological cap (e.g. 14 m/s for a ~700 kg running animal) cleanly separates physically impossible steps from real movement; block-expansion’s absolute cap will catch outliers that the cascade’s relative auto-cap, fitted from the same distribution that contains the outliers, might miss.
- GLS twilight, ~100 km typical positional error, daily fix cadence. Noise floor ≈ 100 km / 86400 s ≈ 1.2 m/s. Already at or above the physiological sustained speed of most species the sensor is fitted to. A physiological cap here would flag the bulk of the GLS distribution as “impossible” when in fact those step speeds are just normal sensor noise on top of slow displacement between daily fixes. The cascade’s relative auto-cap is the right tool: it fits a threshold from the sensor’s own step-speed distribution, so the threshold sits in the right place for the actual noise scale.
The same reasoning applies to other sensor pairs:
- Argos LC 3 (~250 m) on a soaring raptor: noise floor is tiny compared to a raptor’s sustained ground speed; physiology cap is fine.
- Argos LC B (km-scale, sometimes unbounded) on the same raptor: physiology cap will over-flag; rely on the auto-cap.
-
Argos Doppler shift (~1 km typical error, hourly
cadence): borderline. Run with
mt_diagnose_clean_track()and look at the step-speed distribution. If the bulk sits below your expected physiological max, use the physiology. If the bulk straddles or sits above, use the auto-cap.
When in doubt, run both and compare. A physiology cap should only remove fixes that exceed an empirical, defensible biological maximum; if it ends up flagging a substantial fraction of fixes, the cap is wrong for that sub-stream (or the sub-stream’s error regime is more variable than you thought, in which case the auto-cap is the conservative choice).
What if I have multi-individual mixed-sensor data?
This is where nested pool_by enters the picture. Suppose
50 raptors are tagged, each tag reports both GPS and Argos Doppler
fixes, and you want population-level threshold strength while keeping
the union of flags scoped to the individual. The workflow is:
# `your_track` is the multi-individual, mixed-sensor object you
# downloaded from Movebank. Split it by sensor first:
your_track_gps <- your_track[your_track$sensor_type_id == "<gps_code>", ]
your_track_argos <- your_track[your_track$sensor_type_id == "<argos_doppler_code>", ]
# Each homogeneous sub-object is then cleaned with nested pool_by.
# "study_id" supplies the fit distribution (all raptors contribute);
# "individual_local_identifier" is the union scope (a flag added by
# the pool is scoped to that animal's tracks only).
clean_gps <- mt_clean_track(
your_track_gps,
pool_by = c("study_id", "individual_local_identifier"),
plot = FALSE, remove = FALSE
)
clean_argos <- mt_clean_track(
your_track_argos,
pool_by = c("study_id", "individual_local_identifier"),
plot = FALSE, remove = FALSE
)This is what pool_by’s two-element form is for. The
outer column ("study_id") names where the threshold-fitting
distribution comes from; the inner column
("individual_local_identifier") names the scope of the flag
union. Two roles, two columns. (See ?mt_clean_track for the
full semantics.)
A deeper hierarchy — species, population, individual, tag — is not
supported. pool_by has exactly two roles; if your data has
more nesting levels, you pick the pair that captures the scientific
claim you want to make (which level’s distribution do I trust; within
which level should the flag union act). A third level would only earn
its keep under hierarchical / partial-pooling threshold estimation, and
the cascade does not perform that.
Edge cases
Time gaps introduced by splitting. Filtering by sensor naturally introduces longer time gaps in each sub-object (only the rows of that sensor remain). This is fine for the bridge primitive, which is gap-aware by construction. It is also fine for the joint-probability primitive, which normalises by time lag. The detour primitive is time-insensitive and unaffected. The speed-cap primitive sees the sub-object’s step speeds, which are still physically meaningful (steps within one sensor stream).
Sensors that share an error regime. If you have two sensors that share an error regime (e.g. two manufacturers of GPS device with ~10 m accuracy each), there is no reason to split them. The mix is homogeneous in the only sense that matters here. Split when the underlying distribution is bimodal, not when the column is.
A sensor whose error is encoded per-fix (e.g. Argos
LC). You can treat each LC class as a sensor stream and split
four ways. More commonly, you supply the per-fix sigma directly via the
bridge primitive’s location_error = argument; the bridge
primitive then inflates its denominator for high-error fixes and you
keep all classes in one call. The split-by-sensor pattern is the right
move when the error is categorical (which device produced this
fix); the location_error = pattern is the right move when
the error is quantitative (this fix has this sigma).
Summary
-
mt_clean_track()assumes one error regime per call, and assumes that error regime is Lagrangian. - Mixed-Lagrangian tracks should be pre-split by sensor on an event-level column, cleaned independently, and re-merged via an event_id join onto the original object.
- For multi-individual datasets, nested
pool_byafter the split gives you cohort-strength threshold fitting with individual-scoped union. - The two-level cap on
pool_byis deliberate: pool_by has two semantic roles, not many.
Further reading
-
vignette("OUTLIER_1_getting_started", package = "move2utils")— the unifiedmt_clean_track()workflow and a brief tour of all four primitives. -
vignette("OUTLIER_2_diagnose_clean_track", package = "move2utils")— the post-run health check. -
vignette("OUTLIER_3_state_conditional", package = "move2utils")— when the diagnostic flags bimodal behaviour, the recipe for cleaning each behavioural state separately. -
vignette("OUTLIER_4_outlier_bridge", package = "move2utils")— the bridge primitive and the directional error-morphology classifier; for users who want fine-grained control over just one detector. -
vignette("OUTLIER_5_persistence_score", package = "move2utils")— multi-scale annotation that scores how confidently each flag is an outlier; useful as a post-cleaning confidence filter. -
vignette("OUTLIER_example_outlier_whitestork", package = "move2utils")— a full narrated cleaning pipeline on a real high-frequency stork track. -
vignette("OUTLIER_example_leo_migration", package = "move2utils")— outlier detection on irregular, large-scale satellite data.