My Track Has Impossible Speeds or Teleports
Problem statement
A derived speed column contains 630 km/h on a walking dataset. A track jumps 200 m sideways and back. A journey's distance is twice what the odometer says.
These have four distinct causes, and they need different fixes. Guessing wrongly makes it worse β deleting "outliers" that are really a broken identity column silently removes the fastest genuine travel.
Measured on 13 real GPS traces with a median speed of 4.7 km/h:
p50 1.32 m/s 4.7 km/h
p99 21.10 m/s 76.0 km/h
p99.9 61.68 m/s 222.0 km/h
max 175.14 m/s 630.5 km/h
Only 13 intervals out of 6,404 exceeded 200 km/h. The extreme tail is tiny and it is not noise around a mean β it is a different phenomenon.
Quick answer
Diagnose before filtering:
import numpy as np
def diagnose_speeds(df, id_col="track_id"):
g = df.sort_values([id_col, "time"]).groupby(id_col)
dt = g["time"].diff().dt.total_seconds()
dist = np.hypot(g["x"].diff(), g["y"].diff())
speed = dist / dt.replace(0, np.nan)
print(f" zero intervals {int((dt == 0).sum()):6,} -> infinite speed")
print(f" negative intervals {int((dt < 0).sum()):6,} -> unsorted or naive tz")
print(f" big jump, small dt "
f"{int(((dist > 500) & (dt < 10)).sum()):6,} -> position spike")
print(f" big jump, big dt "
f"{int(((dist > 500) & (dt > 300)).sum()):6,} -> data gap, not a spike")
print(f" speed > 56 m/s {int((speed > 56).sum()):6,}")
return speed
Each line points at a different fix.
Step-by-step solution
1. Cause one: a zero time interval
Two fixes in the same second give dist / 0, which is inf, or 0/0, which is NaN with a warning.
This is common: a real 60,000-point download contained 1,388 exact duplicate rows, 2.3%.
df = df.drop_duplicates(subset=["track_id", "time", "x", "y"])
df["speed"] = df["dist"] / df["dt"].replace(0, np.nan)
2. Cause two: the track identity is wrong
If consecutive rows belong to different journeys, the "speed" between them is the distance between two unrelated places divided by whatever time separates them.
The signature is that impossible speeds cluster at group boundaries rather than being scattered. The check:
first_rows = df.groupby("track_id").head(1).index
share = df.loc[df["speed"] > 56].index.isin(first_rows).mean()
print(f"{share:.0%} of impossible speeds are at a track boundary")
If that number is high, the fix is the identity column, not a filter. Downloading public traces from the OpenStreetMap API produced 68 segments of which 53 contained roughly ten interleaved traces each β every one of which would generate impossible speeds throughout.
3. Cause three: a genuine position spike
One badly displaced fix, usually from multipath or a poor satellite geometry. It produces a high speed into the point and a high speed out of it, and an enormous implied acceleration.
spike = (speed > 56) & (speed.shift(-1) > 56)
Remove the point, not its neighbours, and iterate β one spike can hide another.
4. Cause four: a data gap
A long interval with a large displacement is not an error at all. The receiver was off; the movement happened.
gaps over 300 s: 3 of 6,404 intervals (0.05%)
The largest gap in the measured traces was 2,989 seconds β nearly fifty minutes. The average speed across it is meaningless, and the fix is to split the track rather than to filter the value.
5. Cause five, the invisible one: unsorted or timezone-naive timestamps
Negative intervals mean the rows are not in time order. Two causes: the table was never sorted, or naive local timestamps crossed a daylight-saving boundary and an hour of data sorts into the wrong place.
df["time"] = pd.to_datetime(df["time"], utc=True)
df = df.sort_values(["track_id", "time"])
Code examples
Example 1 β a diagnostic that names the cause
import numpy as np
import pandas as pd
def explain_impossible(df, id_col="track_id", max_speed_ms=56.0):
"""Classify each impossible speed rather than deleting it."""
df = df.sort_values([id_col, "time"]).reset_index(drop=True).copy()
g = df.groupby(id_col)
df["dt"] = g["time"].diff().dt.total_seconds()
df["dist"] = np.hypot(g["x"].diff(), g["y"].diff())
df["speed"] = df["dist"] / df["dt"].replace(0, np.nan)
bad = df["speed"] > max_speed_ms
df["cause"] = pd.NA
df.loc[df["dt"] == 0, "cause"] = "zero interval"
df.loc[df["dt"] < 0, "cause"] = "unsorted or naive timezone"
df.loc[bad & (df["dt"] > 300), "cause"] = "data gap"
df.loc[bad & (df["dt"] <= 300) &
(df["speed"].shift(-1) > max_speed_ms), "cause"] = "position spike"
df.loc[bad & df["cause"].isna(), "cause"] = "unexplained"
counts = df["cause"].value_counts(dropna=True)
for cause, count in counts.items():
print(f" {cause:28} {count:6,}")
if "unexplained" in counts:
print(" -> check the identity column: unexplained cases cluster "
"at track boundaries when identity is wrong")
return df
Example 2 β checking whether identity is the real problem
import numpy as np
def identity_check(df, id_col="track_id", max_speed_ms=56.0):
"""Do the impossible speeds sit at group boundaries?"""
df = df.sort_values([id_col, "time"]).reset_index(drop=True)
g = df.groupby(id_col)
dt = g["time"].diff().dt.total_seconds()
speed = np.hypot(g["x"].diff(), g["y"].diff()) / dt.replace(0, np.nan)
zero_frac = g["time"].apply(
lambda s: float((s.sort_values().diff().dt.total_seconds() == 0).mean()))
suspect = zero_frac[zero_frac > 0.1]
print(f" {int((speed > max_speed_ms).sum()):,} impossible speeds")
print(f" {len(suspect)} of {len(zero_frac)} tracks have >10% zero intervals")
if len(suspect):
print(" -> those tracks contain several interleaved traces; "
"fix identity before filtering speed")
return suspect
55 impossible speeds
53 of 68 tracks have >10% zero intervals
-> those tracks contain several interleaved traces; fix identity before filtering speed
Example 3 β a filter whose cost you can see
import numpy as np
def filter_with_cost(df, id_col="track_id", thresholds=(28, 56, 100)):
"""Apply each threshold and report what it removes."""
g = df.groupby(id_col)
dt = g["time"].diff().dt.total_seconds()
dist = np.hypot(g["x"].diff(), g["y"].diff())
speed = dist / dt.replace(0, np.nan)
base_km = dist.sum() / 1000
print(f" unfiltered {base_km:.3f} km over {len(df):,} points")
for t in thresholds:
keep = (speed <= t) | speed.isna()
sub = df[keep]
gg = sub.groupby(id_col)
km = np.hypot(gg["x"].diff(), gg["y"].diff()).sum() / 1000
print(f" <= {t:3d} m/s: -{int((~keep).sum()):4,} points, "
f"{km:8.3f} km ({km / base_km - 1:+.2%})")
unfiltered 37.860 km over 6,417 points
<= 28 m/s: -42 points, 37.267 km (-1.57%)
<= 56 m/s: -13 points, 37.693 km (-0.44%)
<= 100 m/s: -3 points, 37.916 km (+0.15%)
The last row is instructive: a very loose filter can increase measured distance, because removing a spike's partner leaves a longer straight segment. That is a sign the threshold is not removing what you think.
Explanation
Why the extreme tail is a different phenomenon
The distribution measured above is not a bell curve with a long tail. It is two populations: real movement up to about 20 m/s, and errors from 60 m/s upward, with very little between.
That separation is what makes a threshold workable. If the two populations overlapped, no threshold could separate them and you would need external information β a road network, a mode classifier β instead.
Plot a histogram of log speed before choosing a threshold. A clear valley means a clean cut exists; a smooth decline means it does not.
Why filtering by speed alone is not enough
Speed is a property of a pair of points. When one point is bad, two speeds are wrong, and a speed filter cannot tell which point caused it.
Acceleration resolves this: the displaced point has an enormous implied acceleration because the speed reverses across it. A vehicle genuinely reaching 30 m/s does so over several seconds, with acceleration under 5 m/sΒ².
Why a broken identity column looks like noise
With ten traces merged into one element, consecutive rows are simultaneous positions of ten different people. Every derived speed is the distance between two of them divided by a fraction of a second.
The result is a dense population of impossible speeds β not a few outliers. A speed filter applied to that data removes most of the rows and leaves a track that is still nonsense, because the remaining rows are still from different journeys.
The diagnostic is the fraction of zero intervals, which was about 90% in the 53 affected segments β the fingerprint of about ten merged traces.
Why removing points can lengthen the track
Deleting a point joins its neighbours directly. If the point was on the inside of a curve, the straight join is shorter; if the point was a detour, removing it shortens the track; but if you remove a point adjacent to a spike rather than the spike itself, the remaining straight line to the spike can be longer than before.
That is why the cost table above shows a +0.15% row. A filter that increases distance is removing the wrong points.
Edge cases or notes
- Drop duplicates before deriving speed, or zero intervals give infinities.
- Negative intervals mean unsorted rows or naive timezones.
- Impossible speeds clustered at group boundaries are an identity problem, not an outlier problem.
- A long gap is not a spike. Split the track instead of filtering the value.
- Use acceleration to identify which point is bad.
- Iterate: removing one spike can reveal another.
- A filter that increases total distance is removing the wrong points.
- Check the histogram for a valley before trusting any threshold.
Internal links
- How to clean a GPS track: outliers, duplicates and jumps β the full cleaning sequence
- GPS error explained: why your track wanders β where spikes come from
- How to load GPS tracks into Python as trajectories β fixing the identity column
- Trajectories explained β why identity matters so much
- Sampling rate and gaps explained β handling gaps rather than filtering them
- My time column sorts or joins wrongly β negative intervals
- How to calculate speed, distance and direction along a track β computing the columns correctly
- How to map-match a GPS track to a street network β when the positions are wrong but plausible
FAQ
Why does my GPS track show impossible speeds?
Four common causes: a zero time interval from duplicate rows, a wrong track identity so consecutive rows are different journeys, a single displaced position, or a long data gap. Diagnose before filtering.
How do I tell a spike from a gap?
A spike has a short interval and a large displacement, with high speed both into and out of the point. A gap has a long interval, and only one high speed.
What speed threshold should I use?
One justified by the mode of transport. On real traces a 200 km/h cap removed 13 points and 0.44% of distance; tighter caps removed real vehicle travel.
Why do I have infinite speeds?
Duplicate rows or two fixes in the same second give a zero time interval. Drop exact duplicates and replace zero intervals with NaN.
Why are my speeds negative?
Negative time differences, from unsorted rows or from naive local timestamps crossing a daylight-saving boundary. Parse with utc=True and sort by track and time.
My whole file is full of impossible speeds β what now?
Almost certainly a broken identity column. Check what fraction of intervals are zero; around 90% means roughly ten traces merged into one group.
Should I delete the bad points?
Flag them first, measure what each threshold costs in points and distance, and delete once at the end. A filter that increases total distance is removing the wrong points.