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.

Four causes of impossible GPS speeds: zero interval, wrong track identity, position spike and data gap, each with its own fix.
The same symptom, four causes. Filtering by speed treats only one of them.

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"])
A position spike showing a high speed into and out of one point with an enormous implied acceleration, against a data gap with a single high speed.
A spike is fast in and fast out. A gap is fast once. The pattern tells you which.

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.

A filter removing a spike shortening the track against one removing its neighbour and lengthening it.
A filter that increases total distance has removed the wrong points. It is a free correctness check.

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.

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.