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.
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
-
Rebuild the seasonal index offline, on a fixed cadence. Recompute
build_seasonal_indexquarterly from settled, reviewed history — never per transaction. A per-transaction refresh reintroduces exactly the poisoning the trimmed statistic was chosen to prevent. -
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.
-
Compose the effective threshold per group. For each submission, look up its group’s samples and its month’s seasonal index, then call
seasonal_thresholdto get a clamped ceiling. The clamp guarantees no season can lift the band past the hard cap owned by the parent stage. -
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 -
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.
-
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.
Related
- Dynamic Threshold Tuning — the parent guide whose adaptive band this seasonal path extends
- Duplicate Receipt Detection — the upstream gate that keeps re-submissions out of the baseline window
- Automated Policy Validation & Anomaly Flagging — the framework this component routes decisions back into