Anomaly Scoring for Unexpected Fleet Stops

Confidence scoring for stop detection answers a narrower question than most dispatchers actually care about. It tells you a candidate GPS window really is a stop rather than GPS jitter or a red light — but a confirmed stop can still be completely unremarkable, or it can be the first sign of a stolen trailer, an unauthorized detour, or a driver taking a three-hour break at an unscheduled location. Anomaly scoring for unexpected stops sits downstream of confidence scoring: it takes stops that are already confirmed real and asks whether each one deviates enough from the vehicle’s plan and its own historical pattern to justify a human look.

This page gives a self-contained Python class that scores each confirmed stop using four orthogonal features — a duration z-score against the vehicle’s own history, distance from the nearest planned point of interest, how unusual the time of day is, and whether the stop fell off the planned route corridor — combined into a single 0-100 anomaly score with configurable weights.

Anomaly scoring feature aggregation Four feature boxes — duration z-score, POI distance, time-of-day, off-route flag — each with an arrow into a central weighted-sum box, which then routes to one of three outcome boxes based on score_threshold. Duration z-score POI distance Time of day Off-route flag Weighted Sum Σ w_i · f_i → 0-100 Auto-clear Review queue Alert dispatch

Compatibility and Configuration

Requirement Minimum version / value Notes
Python 3.10 dataclasses, statistics module usage
pandas 2.0 rolling-window history aggregation
numpy 1.24 vectorised z-score and clipping
scipy 1.11 optional — scipy.stats.zscore as an alternative to the manual implementation below
Input stop record must carry vehicle_id, dwell_seconds, centroid_lat/centroid_lon, start_time, off_route typically the output of DBSCAN stop clustering or geofence-intersection dwell detection
History store append-only table of past (vehicle_id, dwell_seconds) pairs a rolling history_window_days slice must be queryable per vehicle at score time

The Scorer

from __future__ import annotations

import math
from dataclasses import dataclass
from datetime import datetime
from typing import Optional

import numpy as np
import pandas as pd


@dataclass
class AnomalyWeights:
    """Feature weights for the anomaly score. Must sum to 1.0."""

    duration: float = 0.35
    poi_distance: float = 0.30
    time_of_day: float = 0.15
    off_route: float = 0.20

    def validate(self) -> None:
        total = self.duration + self.poi_distance + self.time_of_day + self.off_route
        if not math.isclose(total, 1.0, abs_tol=1e-6):
            raise ValueError(f"Anomaly weights must sum to 1.0, got {total:.4f}")


class StopAnomalyScorer:
    """
    Score confirmed fleet stops for anomaly relative to a vehicle's own
    history and its planned route.

    Parameters
    ----------
    weights : AnomalyWeights
        Relative importance of each feature. Defaults favour duration
        and POI distance, the two strongest predictors of a genuinely
        unplanned stop in last-mile and long-haul fleets alike.
    history_window_days : int
        How many days of past stops to include when computing a
        vehicle's duration baseline. 30 days balances recency against
        having enough samples; shorten to 14 for fast-changing routes,
        lengthen to 60-90 for low-frequency fleets (e.g. weekly runs).
    min_history_stops : int
        Below this many historical stops, fall back to a fleet-wide
        baseline instead of a per-vehicle one to avoid unstable
        z-scores computed on a handful of samples. Default 10.
    distance_threshold_m : float
        Distance from the nearest planned POI beyond which the
        distance feature saturates at maximum anomaly. Default 400 m;
        tighten to 150-200 m for dense urban delivery, loosen to
        800-1000 m for rural or long-haul networks with sparse POIs.
    score_threshold : float
        Score at or above which a stop routes to the review queue or
        an alert. Default 70 on the 0-100 scale. See the Threshold
        section below for how to split review vs. immediate alert.
    """

    def __init__(
        self,
        weights: Optional[AnomalyWeights] = None,
        history_window_days: int = 30,
        min_history_stops: int = 10,
        distance_threshold_m: float = 400.0,
        score_threshold: float = 70.0,
    ):
        self.weights = weights or AnomalyWeights()
        self.weights.validate()
        self.history_window_days = history_window_days
        self.min_history_stops = min_history_stops
        self.distance_threshold_m = distance_threshold_m
        self.score_threshold = score_threshold

    def _duration_zscore_feature(
        self,
        dwell_seconds: float,
        vehicle_history: pd.Series,
        fleet_history: pd.Series,
    ) -> float:
        """
        Compute a normalised [0, 1] anomaly contribution from dwell
        duration. Falls back to fleet_history when vehicle_history has
        fewer than min_history_stops samples (cold start).
        """
        history = (
            vehicle_history
            if len(vehicle_history) >= self.min_history_stops
            else fleet_history
        )
        if len(history) < 2:
            return 0.0  # no usable baseline at all; treat as non-anomalous

        mu = history.mean()
        sigma = history.std(ddof=1)
        if sigma == 0 or np.isnan(sigma):
            return 0.0

        z = abs((dwell_seconds - mu) / sigma)
        # Clip at z=4: beyond four standard deviations, additional
        # deviation no longer increases operational concern linearly.
        return float(np.clip(z / 4.0, 0.0, 1.0))

    def _poi_distance_feature(self, distance_m: float) -> float:
        """Normalise POI distance to [0, 1]; farther = more anomalous."""
        return float(np.clip(distance_m / self.distance_threshold_m, 0.0, 1.0))

    def _time_of_day_feature(
        self, stop_hour: int, vehicle_hour_history: pd.Series
    ) -> float:
        """
        Score how unusual an hour-of-day is for this vehicle using a
        simple circular distance to the historical mode hour. Falls
        back to 0.0 (neutral) when there is no hour history yet.
        """
        if vehicle_hour_history.empty:
            return 0.0
        mode_hour = vehicle_hour_history.mode().iloc[0]
        raw_diff = abs(stop_hour - mode_hour)
        circular_diff = min(raw_diff, 24 - raw_diff)  # wrap around midnight
        return float(np.clip(circular_diff / 12.0, 0.0, 1.0))

    def _off_route_feature(self, off_route: bool) -> float:
        return 1.0 if off_route else 0.0

    def score_stop(
        self,
        dwell_seconds: float,
        poi_distance_m: float,
        stop_time: datetime,
        off_route: bool,
        vehicle_history: pd.Series,
        fleet_history: pd.Series,
        vehicle_hour_history: pd.Series,
    ) -> float:
        """
        Compute a single stop's anomaly score on a 0-100 scale.

        vehicle_history / fleet_history are Series of past dwell_seconds
        values already filtered to history_window_days by the caller.
        vehicle_hour_history is a Series of past stop hours (0-23) for
        the same vehicle, over the same window.
        """
        f_duration = self._duration_zscore_feature(
            dwell_seconds, vehicle_history, fleet_history
        )
        f_distance = self._poi_distance_feature(poi_distance_m)
        f_time = self._time_of_day_feature(stop_time.hour, vehicle_hour_history)
        f_route = self._off_route_feature(off_route)

        score = (
            self.weights.duration * f_duration
            + self.weights.poi_distance * f_distance
            + self.weights.time_of_day * f_time
            + self.weights.off_route * f_route
        )
        return round(float(np.clip(score * 100, 0, 100)), 2)

    def score_batch(
        self,
        stops: pd.DataFrame,
        history: pd.DataFrame,
    ) -> pd.DataFrame:
        """
        Score a batch of confirmed stops.

        stops columns required: vehicle_id, dwell_seconds, poi_distance_m,
        start_time (tz-aware datetime), off_route (bool).
        history columns required: vehicle_id, dwell_seconds, start_time,
        covering at least history_window_days before the earliest stop
        in `stops`.

        Returns stops with an added 'anomaly_score' column and a
        boolean 'flagged' column at score_threshold.
        """
        scores = []
        cutoff_days = pd.Timedelta(days=self.history_window_days)

        for row in stops.itertuples(index=False):
            window_start = row.start_time - cutoff_days
            hist_slice = history[
                (history["start_time"] >= window_start)
                & (history["start_time"] < row.start_time)
            ]
            vehicle_hist = hist_slice.loc[
                hist_slice["vehicle_id"] == row.vehicle_id, "dwell_seconds"
            ]
            fleet_hist = hist_slice["dwell_seconds"]
            vehicle_hour_hist = hist_slice.loc[
                hist_slice["vehicle_id"] == row.vehicle_id, "start_time"
            ].dt.hour

            scores.append(
                self.score_stop(
                    dwell_seconds=row.dwell_seconds,
                    poi_distance_m=row.poi_distance_m,
                    stop_time=row.start_time,
                    off_route=row.off_route,
                    vehicle_history=vehicle_hist,
                    fleet_history=fleet_hist,
                    vehicle_hour_history=vehicle_hour_hist,
                )
            )

        result = stops.copy()
        result["anomaly_score"] = scores
        result["flagged"] = result["anomaly_score"] >= self.score_threshold
        return result

Execution and Tuning Guidelines

Call score_batch once per scoring run (typically hourly or nightly) against a stops DataFrame produced by your stop detection pipeline and a history DataFrame pulled from the same table, filtered to the relevant lookback window before the call — the method itself re-slices per row to respect each stop’s own point-in-time history, which prevents future data from leaking into a score.

  • weights — the four weights must sum to 1.0 (validate() raises otherwise). Duration and POI distance are the strongest signals in most fleets and default to a combined 0.65; if your fleet has unreliable POI data, shift weight from poi_distance toward duration and off_route rather than leaving a noisy feature at full weight.
  • history_window_days — shorter windows react faster to genuine behavior changes (a new route assignment) but produce noisier baselines; longer windows are more stable but slower to adapt after a legitimate schedule change. 30 days is a reasonable starting point for daily-delivery fleets.
  • distance_threshold_m — the distance at which the POI feature saturates. Set this from the actual spread of your POI-to-stop distances for known-good stops, not a guess — plot the distribution and pick roughly the 90th percentile of legitimate visits as the saturation point.
  • score_threshold — the line between “no action” and “flagged.” Many teams use two thresholds instead of one: 50-69 routes to a passive review queue, 70+ triggers an active dispatcher alert. Layer a second AnomalyWeights-scored threshold on top of score_batch’s output if you need that split.

Common Pitfalls

Cold start: new vehicles or routes have no history to score against

Cause: a newly onboarded vehicle, or a vehicle newly assigned to a route, has fewer than min_history_stops historical stops, so the per-vehicle duration z-score is either undefined or wildly unstable.

Symptom: anomaly scores for new vehicles cluster near the extremes — either everything scores near zero (the fallback returns a neutral non-anomalous value) or a handful of early stops produce nonsensical z-scores from a two- or three-sample standard deviation.

Fix: the scorer above falls back to fleet_history once vehicle_history is below min_history_stops. Confirm the fallback threshold is high enough — 10 stops is a reasonable floor — and monitor a used_fallback flag during the first few weeks of any new vehicle or route assignment so dispatchers know to discount early scores.

Seasonal drift makes a stable baseline stale

Cause: dwell durations shift systematically with the season — holiday delivery volumes extend loading-dock stops, winter weather adds idle time at fuel stops — and a fixed history_window_days baseline computed before the shift flags every stop as anomalous once the new pattern becomes the norm.

Symptom: flagged-stop volume spikes fleet-wide at a predictable calendar boundary (start of peak season, first cold snap) rather than being isolated to individual vehicles or routes.

Fix: keep history_window_days short enough to adapt within one to two weeks of a genuine seasonal shift, or maintain separate baselines per season/quarter and select the appropriate one at score time. Track the fleet-wide flagged rate as a monitoring metric — a sustained jump indicates the baseline needs recalibration, not that the fleet suddenly developed a problem.

A single dominant feature drowns out the others

Cause: one feature — commonly the off-route flag — is binary and swings from 0 to 1 with no gradation, so a high weight on that feature alone can push routine, approved deviations (a detour around road construction) over score_threshold regardless of how normal the duration and distance features look.

Symptom: nearly every flagged stop shares the same dominant feature at its maximum value, while the other three features are unremarkable; dispatcher feedback reports a high false-positive rate specifically on approved detours or known road closures.

Fix: cap any single feature’s weight well below 0.5 so no one signal alone can cross score_threshold, and consider adding a suppression rule — if off_route is True but the vehicle is on a route with an active, dispatcher-approved detour flag, zero out that feature’s contribution for the duration of the detour.


Up: Confidence Scoring for Stop Detection | Stop Detection & Dwell Time Analytics