Recalibrating anomaly thresholds for seasonal travel spikes

Conference season, fiscal year-end, and annual sales kickoffs push travel spend three to five times above its quiet-month baseline, so a threshold tuned on a rolling average either over-flags every legitimate September submission or, worse, absorbs the spike and lets real outliers ride through unchecked. This page owns the seasonal-adjustment path delegated by the Dynamic Threshold Tuning stage inside the broader Automated Policy Validation & Anomaly Flagging framework: how to build a rolling-window baseline that expects the spike, resists being poisoned by the very spend it measures, and refuses to relax a compliance ceiling while it adapts.

The parent stage computes a single per-group percentile band; here we own the harder case where that band must breathe with a known calendar rhythm without letting an unreviewed burst of holiday spend redraw its own limit.

Seasonally adjusted rolling baseline resisting a poisoned spike A twelve-month timeline plots two curves against a monthly spend axis. The raw rolling mean climbs steeply through the conference-season months of September and October, dragged upward by unreviewed spend. A trimmed, seasonally adjusted baseline instead multiplies a stable trimmed median by a fixed per-month seasonal index, so it rises in an expected step for September and October and returns afterward, never chasing the poisoned peak. Three routing points sit on the axis: a normal submission below both curves auto-approves, a September submission that sits under the seasonal baseline but above the flat annual mean is correctly approved, and a genuine outlier above the seasonal baseline is flagged for manager review. A baseline that expects the season, not one that chases it Trimmed median × fixed seasonal index rises in an expected step; the raw rolling mean is dragged up by the spike. high low Jul Aug Sep Oct Nov Dec normal · approve Sep in-season · approve true outlier · review raw mean seasonal baseline

Why Standard Approaches Fail

A rolling-window mean is the default adaptive baseline, and it collapses in three specific ways once real travel seasonality enters the window. Each is a cadence or contamination problem, not a modelling one.

  • Baseline poisoning by the spike itself. A trailing mean recomputed per transaction ingests September’s conference spend and lifts the ceiling in real time, so the tenth inflated hotel bill widens the very band that should have flagged the first. Because the mean is not resistant, a single mis-keyed $40,000 charge — an MCC mistag or a duplicate that slipped Duplicate Receipt Detection — drags the baseline for the whole group.
  • Seasonal over-flagging. A baseline built on the trailing quiet months treats every legitimate conference-week airfare as an outlier, so finance ops drowns in false positives exactly when submission volume peaks. The reviewers stop trusting the queue and start bulk-approving, which defeats the control.
  • Amplitude bleed across corridors. Sales kickoff inflates one department while another stays flat; a global seasonal factor over-relaxes the quiet group and under-relaxes the busy one. Seasonality is a property of the group, not the ledger.

The remedy is a baseline anchored on a resistant statistic, scaled by a fixed seasonal index computed from prior years rather than the live window, and clamped so no adjustment can lift the effective threshold above the deterministic hard cap.

Architecture & Algorithm

The design separates three concerns that a naive rolling mean conflates: the level of normal spend (a trimmed statistic that ignores the extreme tails), the shape of the calendar (a per-month seasonal index derived from historical ratios), and the ceiling (a fixed policy cap the adjustment can never breach). Money is handled in integer minor units throughout; the seasonal index is unitless. The trimmed median and trimmed standard deviation resist poisoning because dropping the top and bottom decile discards the injected outliers before the level is computed.

from __future__ import annotations

import logging
from dataclasses import dataclass

import numpy as np

logger = logging.getLogger("expense.seasonal_threshold")


@dataclass(frozen=True)
class SeasonalConfig:
    """Tunables for a seasonally adjusted, poison-resistant baseline."""
    trim_fraction: float = 0.10          # drop top/bottom decile before stats
    sigma_multiplier: float = 3.0        # band width above the trimmed level
    min_samples: int = 40                # cold-start guardrail per group
    floor_minor_units: int = 5_00        # $5.00 absolute floor
    hard_cap_minor_units: int = 5_000_00  # $5,000.00 deterministic ceiling


def trimmed_stats(samples: np.ndarray, trim_fraction: float) -> tuple[float, float]:
    """Return the trimmed median and trimmed std, resistant to tail poisoning.

    Sorting and slicing off both tails discards injected outliers before the
    level is measured, so an unreviewed spike cannot inflate the baseline.
    """
    ordered = np.sort(samples)
    k = int(len(ordered) * trim_fraction)
    core = ordered[k: len(ordered) - k] if len(ordered) - 2 * k > 0 else ordered
    return float(np.median(core)), float(np.std(core))


def seasonal_threshold(
    samples: np.ndarray,
    seasonal_index: float,
    config: SeasonalConfig,
) -> int:
    """Compose a seasonally scaled, clamped effective threshold (minor units).

    level = trimmed_median; band = level + k*sigma; the band is multiplied by
    a FIXED per-month seasonal index (from prior years, not the live window)
    and clamped into [floor, hard_cap] so adaptation never relaxes the ceiling.
    """
    if samples.size < config.min_samples:
        return config.floor_minor_units  # cold start: defer to the floor
    median, sigma = trimmed_stats(samples, config.trim_fraction)
    band = median + config.sigma_multiplier * sigma
    scaled = band * seasonal_index
    clamped = int(min(max(scaled, config.floor_minor_units), config.hard_cap_minor_units))
    logger.info(
        "seasonal_baseline_built median=%.0f sigma=%.0f index=%.3f effective=%d",
        median, sigma, seasonal_index, clamped,
    )
    return clamped

The seasonal index itself must be computed from settled, reviewed history so it encodes the expected shape of the calendar rather than this year’s noise. A robust index is the ratio of each month’s trimmed-median spend to the trailing twelve-month trimmed median, averaged across as many prior years as the retention window holds.

from __future__ import annotations

import numpy as np


def build_seasonal_index(
    monthly_medians: dict[int, list[float]],
    trailing_median: float,
) -> dict[int, float]:
    """Map calendar month (1-12) to a fixed seasonal multiplier.

    Each month's multiplier is its historical trimmed-median spend divided by
    the trailing annual trimmed median, so a factor of 1.0 means "typical" and
    1.8 means "expect 80% more". Months with no history default to 1.0 (neutral).
    """
    index: dict[int, float] = {}
    for month in range(1, 13):
        history = monthly_medians.get(month, [])
        if not history or trailing_median <= 0:
            index[month] = 1.0
            continue
        month_level = float(np.median(history))
        index[month] = round(month_level / trailing_median, 3)
    return index

Because the index is a frozen artifact rebuilt on a fixed cadence, an auditor can reconstruct exactly which multiplier applied to a September decision, and a live spike cannot silently rewrite the calendar shape mid-month.

Step-by-Step Integration

  1. Rebuild the seasonal index offline, on a fixed cadence. Recompute build_seasonal_index quarterly from settled, reviewed history — never per transaction. A per-transaction refresh reintroduces exactly the poisoning the trimmed statistic was chosen to prevent.

  2. Feed only validated samples into the level. The historical window must contain only records that already cleared Duplicate Receipt Detection and date validation, so the trimmed median measures real spend, not re-submissions.

  3. Compose the effective threshold per group. For each submission, look up its group’s samples and its month’s seasonal index, then call seasonal_threshold to get a clamped ceiling. The clamp guarantees no season can lift the band past the hard cap owned by the parent stage.

  4. Verify the guardrails before wiring in enforcement. Assert that a poisoned window and an out-of-season outlier both behave:

    import numpy as np
    
    cfg = SeasonalConfig(min_samples=5, hard_cap_minor_units=500_00)
    # A poisoned window: 20 normal rows plus 4 injected 400k spikes.
    window = np.array([120_00] * 20 + [400_000] * 4, dtype=np.int64)
    in_season = seasonal_threshold(window, seasonal_index=1.8, config=cfg)
    assert in_season == 500_00           # clamp holds despite the poison + season
    quiet = seasonal_threshold(window, seasonal_index=1.0, config=cfg)
    assert quiet <= in_season            # off-season band is never wider
  5. Emit an audit record carrying the index. Every decision must log the group, the month, the seasonal index applied, and the effective threshold, so the routing owned by Dynamic Threshold Tuning stays replayable.

  6. Alert on index drift, not just flag volume. Page when a live month’s realized median diverges sharply from its stored index — that gap is the early signal of either a genuine policy shift or an upstream contamination that the trim is silently absorbing.

Edge Cases & Gotchas

Edge condition What breaks Mitigation
Spike larger than the trim fraction A contamination burst exceeding 10% of samples survives the trim and lifts the level Raise trim_fraction for volatile groups, or gate the window on an upstream review flag before it feeds the level
First-year seasonality No prior-year history means every month indexes to 1.0 and the season is unmodelled Seed the index from a comparable department or a hand-set calendar until a full year settles
Moving holidays A conference that shifts weeks year to year smears its spike across two month buckets Index on ISO week or a named-event calendar rather than calendar month for event-driven groups
Index applied to a stale level A frozen index times a drifted trimmed median over-relaxes a shrinking group Rebuild level and index on the same cadence; never pair a fresh index with a year-old level
Clamp masking a real breach A genuine six-figure outlier clamps to the cap and looks like a routine cap hit Log the pre-clamp scaled value so post-cap analysis can still surface the extreme
Currency-mixed group FX swings look like seasonality and distort the index Compute baselines per currency after normalization; align drift bands with Merchant Category Code Routing grouping

FAQ

Why use a trimmed median instead of a rolling mean for the baseline?

A mean has a breakdown point of zero: one large enough value moves it arbitrarily, so a single mis-keyed or duplicate charge in the window poisons the baseline. A trimmed median discards a fixed fraction of both tails before measuring the level, so injected outliers are removed before they can influence it. That resistance is the whole point when the window contains the same seasonal spend you are trying to police.

How is the seasonal index different from just widening the window?

Widening the window blends quiet and busy months into one flat average, which under-flags in quiet season and over-flags in peak. A seasonal index keeps the level narrow and applies a per-month multiplier derived from prior years, so the band rises in September because history says September is expensive — not because this September’s spend happened to arrive. The adjustment is expected and reproducible rather than reactive.

Won’t the seasonal adjustment let fraud hide inside the conference-season spike?

It would if the multiplier were unbounded, which is why every seasonally scaled band is clamped to the deterministic hard cap and the pre-clamp value is logged. A submission can ride the expected seasonal rise, but it can never exceed the fixed compliance ceiling, and the trimmed level means a run of inflated claims does not widen the band for the rest of the group. Genuine outliers still route to review.

How often should the seasonal index be rebuilt?

Rebuild it on a slow, fixed cadence — quarterly is typical — from settled and reviewed history only. Rebuilding per transaction or even daily reintroduces poisoning, because the live spike feeds back into the multiplier. Treat the index as a versioned artifact: snapshot it, hash it, and pin it alongside the config that produced it so any past decision replays exactly.

What sample size does a group need before seasonal tuning is trustworthy?

Below the min_samples guardrail the engine ignores the adaptive level entirely and defers to the deterministic floor, because a percentile or trimmed statistic on a thin sample is noise. For the seasonal index specifically you want at least one full prior year per group; until then, seed the month multipliers from a comparable group or a hand-set calendar so a new corridor is not left with a flat, season-blind band.