Statistical Anomaly Scoring for Claims Fraud Detection

Statistical anomaly scoring assigns every incoming claim a bounded, explainable fraud signal by measuring how far its engineered features deviate from the peers it should resemble; this guide is the scoring stage of the broader Fraud Pattern Detection architecture, and it feeds the referral machinery documented in SIU Referral Orchestration. It is written for the engineers who own that scoring service in production.

Unlike a rules engine that fires on named red flags, an anomaly scorer learns the shape of normal from the book of business itself and flags the claims that do not fit — novel schemes included. That property is exactly what a supervised classifier cannot offer on its own, and it is why anomaly scoring sits upstream of, not in place of, the severity work in Automated Severity Scoring Models.

Why supervised-only detection fails on novel fraud

Permalink to "Why supervised-only detection fails on novel fraud"

A supervised fraud model is trained on labelled outcomes — claims a special investigations unit confirmed as fraudulent versus those it cleared. That framing has two structural weaknesses in a live claims book. First, labels are scarce, lagging, and biased: a claim is only labelled fraudulent after an investigation that itself was triggered by yesterday’s detection logic, so the training set encodes the blind spots of the system that produced it. A ring that never tripped a rule never got investigated, never got labelled, and is therefore invisible to the classifier that learns only from labels. Second, fraud is adversarial and non-stationary. The moment a scheme’s signature is learned, the ring mutates it — new staged-loss geographies, new provider identities, new dollar bands sitting just under a known threshold. A model that has only ever seen last year’s fraud generalizes poorly to next quarter’s.

Unsupervised anomaly detection inverts the assumption. It does not ask “does this claim look like known fraud?” but “does this claim look unlike the overwhelming majority of legitimate claims for its peer group?” Because the vast bulk of any book is legitimate, the density of normal behaviour is well estimated even without labels, and a claim that lands in a sparse region of feature space is anomalous regardless of whether that particular scheme has ever been seen before. The cost is that anomalous is not synonymous with fraudulent — a genuinely catastrophic-but-legitimate total loss is also an outlier — so the score is a triage signal that raises a claim for review, never an adjudication. Keeping that distinction crisp is the single most important design constraint in this whole guide.

Problem framing: what breaks at production scale

Permalink to "Problem framing: what breaks at production scale"

A prototype scorer that runs IsolationForest().fit_predict(X) on a dataframe looks convincing in a notebook and collapses in production for reasons that have nothing to do with the model. Raw claim amounts span four orders of magnitude, so an unscaled model treats every large legitimate property loss as an outlier and drowns investigators in false positives. A 40,000autoclaimisunremarkable;a40,000 auto claim is unremarkable; a 40,000 contents claim on a renters policy is extraordinary — but only a model that scores amount relative to its peer baseline can tell them apart. Feature drift compounds it: inflation, a new product line, or a catastrophe surge shifts the distribution, and a model frozen at training time silently recalibrates its notion of normal against a world that no longer exists.

The engineering answer is to treat the raw model as one component inside a disciplined pipeline: a deterministic feature contract, per-peril and per-geography baselines, a calibration layer that maps the model’s arbitrary internal score onto a bounded, monotone 0.0–1.0 fraud signal, and a drift monitor that tells you when the baselines are stale. This guide builds that pipeline. The two focused guides linked at the end drill into the two features that carry the most signal in practice — outlier claim amounts and submission velocity.

Prerequisites & environment setup

Permalink to "Prerequisites & environment setup"

This service targets Python 3.10+ (for match statements and X | None unions) and pins scikit-learn, NumPy, and structlog so scoring is reproducible across the training host and the online scorer. A frozen model artifact scored on a host with a different scikit-learn minor version can produce subtly different score_samples output, which is unacceptable when the score is an audit input.

python -m venv .venv && source .venv/bin/activate
pip install "scikit-learn==1.5.*" "numpy==2.0.*" "structlog==24.*"

Upstream state required before this stage runs: each claim must already be normalized into a canonical record with the loss amount coerced to Decimal, the peril and region resolved, and the claimant’s prior-claim history joined in. That normalization belongs to the ingestion layer described in Claims Lifecycle Architecture; the scorer never reaches back into the live policy service.

The scorer is a pure transformation from a canonical claim plus a set of peer baselines to a FraudScore. Nothing in the hot path performs I/O against a policy or claims database — baselines are computed offline on a rolling window and handed to the scorer as an immutable snapshot, exactly as the deductible resolver in Coverage Validation Rules receives a resolved policy snapshot. That purity is what makes a score replayable: given the same record and the same baseline snapshot id, the score is bit-for-bit reproducible months later during an examination.

Anomaly scoring pipeline: canonical claim and peer baselines produce a calibrated bounded fraud score A canonical claim record and an offline-computed peer-baseline snapshot both feed a feature builder that engineers amount-versus-baseline ratios, timing, and claimant-history features into a typed ClaimFeatureVector. The vector is scored by an ensemble of an IsolationForest and robust z-scores, whose raw output passes through a calibration layer that maps it to a bounded zero-to-one fraud signal. The calibrated FraudScore, its contributing feature attributions, and the baseline snapshot id are written to an append-only audit ledger and emitted to the referral stage. A separate drift monitor compares live feature distributions against the baseline and raises a recalibration alert when they diverge. Canonical claim amount · peril · region claimant history Baseline snapshot per peril × region median · MAD · counts Feature builder log-amount · amount/baseline timing · history ratios → ClaimFeatureVector Ensemble scorer IsolationForest + robust z-scores raw anomaly signal Calibration monotone map raw → [0.0, 1.0] bounded signal FraudScore score ∈ [0,1] attributions snapshot id → SIU referral stage Append-only ledger score · attributions snapshot id · model ver Drift monitor compares live feature distribution vs baseline (PSI) raises recalibration alert when divergence exceeds budget Recalibration alert rebuild baselines
A canonical claim and an offline baseline snapshot flow through feature engineering, an ensemble scorer, and a calibration layer to a bounded, audited fraud score.

The three stages — feature engineering, ensemble scoring, calibration — are separable and independently testable, which matters because they fail for different reasons and are tuned on different cadences. Baselines are rebuilt nightly; the model is retrained weekly or on a drift alert; the calibration map is refit whenever the model changes.

The scoring service is built around a typed feature vector so the contract between the feature builder and the model is explicit and cannot silently drift when someone adds a feature. The IsolationForest supplies the multivariate anomaly signal; a set of robust per-feature z-scores (median and median-absolute-deviation, not mean and standard deviation, so a handful of extreme frauds do not inflate the scale) supplies interpretable per-feature attributions that an investigator can read.

from __future__ import annotations

from dataclasses import dataclass, field, asdict
from decimal import Decimal
from datetime import datetime
import math

import numpy as np
from sklearn.ensemble import IsolationForest
import structlog

log = structlog.get_logger("fraud.anomaly_scorer")

FEATURE_ORDER: tuple[str, ...] = (
    "log_amount",
    "amount_vs_peer_ratio",
    "hours_since_policy_inception",
    "claimant_prior_claim_count",
    "claims_in_trailing_30d",
)


class FeatureContractError(ValueError):
    """A feature vector violated the model's expected schema or domain."""


class BaselineUnavailableError(RuntimeError):
    """No peer baseline exists for the claim's (peril, region) cell."""


@dataclass(frozen=True, slots=True)
class PeerBaseline:
    """Offline-computed robust baseline for one (peril, region) cell."""
    peril: str
    region: str
    median_amount: Decimal
    mad_amount: Decimal            # median absolute deviation, in dollars
    sample_count: int
    snapshot_id: str


@dataclass(frozen=True, slots=True)
class ClaimFeatureVector:
    """Typed, ordered feature contract handed to the model."""
    log_amount: float
    amount_vs_peer_ratio: float
    hours_since_policy_inception: float
    claimant_prior_claim_count: float
    claims_in_trailing_30d: float

    def as_row(self) -> list[float]:
        row = [getattr(self, name) for name in FEATURE_ORDER]
        if any(math.isnan(v) or math.isinf(v) for v in row):
            raise FeatureContractError(f"non-finite feature in {row}")
        return row


@dataclass(frozen=True, slots=True)
class FraudScore:
    score: float                          # calibrated, bounded [0.0, 1.0]
    attributions: dict[str, float]        # per-feature robust z-scores
    snapshot_id: str
    model_version: str

The feature builder turns a canonical claim and its baseline into the vector. Every amount feature is expressed relative to the peer baseline so the model never sees a raw dollar figure — that is what makes a 40,000contentsclaimanda40,000 contents claim and a 40,000 auto claim land in different regions of feature space.

def build_features(
    *,
    amount: Decimal,
    peril: str,
    region: str,
    policy_inception: datetime,
    loss_reported_at: datetime,
    claimant_prior_claim_count: int,
    claims_in_trailing_30d: int,
    baseline: PeerBaseline,
) -> ClaimFeatureVector:
    if baseline.sample_count < 30:
        # Too few peers to trust the baseline: fail closed, do not guess.
        raise BaselineUnavailableError(
            f"baseline for ({peril},{region}) has {baseline.sample_count} samples"
        )
    amount_f = float(amount)
    median = float(baseline.median_amount)
    # Robust ratio: how many "typical spreads" above the peer median this loss sits.
    mad = float(baseline.mad_amount) or median * 0.05 or 1.0
    ratio = (amount_f - median) / mad
    hours = (loss_reported_at - policy_inception).total_seconds() / 3600.0
    vec = ClaimFeatureVector(
        log_amount=math.log1p(max(amount_f, 0.0)),
        amount_vs_peer_ratio=ratio,
        hours_since_policy_inception=max(hours, 0.0),
        claimant_prior_claim_count=float(claimant_prior_claim_count),
        claims_in_trailing_30d=float(claims_in_trailing_30d),
    )
    vec.as_row()  # validate finiteness eagerly
    return vec

The scorer wraps a fitted IsolationForest, converts its raw score_samples output into a bounded signal, and attaches robust z-score attributions. score_samples returns higher values for inliers and lower (more negative) for outliers; we negate and squash it so a higher FraudScore.score always means “more anomalous”, which is the only orientation an on-call human should ever have to remember.

class AnomalyScorer:
    def __init__(
        self,
        model: IsolationForest,
        *,
        model_version: str,
        raw_offset: float,
        raw_scale: float,
    ) -> None:
        self._model = model
        self._model_version = model_version
        # Calibration constants fit offline on a holdout (see next section).
        self._raw_offset = raw_offset
        self._raw_scale = raw_scale

    def _calibrate(self, raw: float) -> float:
        # Logistic squash of the negated raw score -> monotone [0,1].
        z = (-raw - self._raw_offset) / self._raw_scale
        return 1.0 / (1.0 + math.exp(-z))

    def score(
        self, vec: ClaimFeatureVector, baseline: PeerBaseline
    ) -> FraudScore:
        row = np.asarray([vec.as_row()], dtype=np.float64)
        raw = float(self._model.score_samples(row)[0])
        calibrated = self._calibrate(raw)
        attributions = _robust_attributions(vec)
        score = FraudScore(
            score=round(calibrated, 4),
            attributions=attributions,
            snapshot_id=baseline.snapshot_id,
            model_version=self._model_version,
        )
        log.info(
            "fraud.scored",
            score=score.score,
            peril=baseline.peril,
            region=baseline.region,
            snapshot_id=baseline.snapshot_id,
            top_feature=max(attributions, key=lambda k: abs(attributions[k])),
        )
        return score


def _robust_attributions(vec: ClaimFeatureVector) -> dict[str, float]:
    row = asdict(vec)
    return {name: round(row[name], 4) for name in FEATURE_ORDER}

The AnomalyScorer is a pure object once constructed: every call is deterministic in the model artifact, the vector, and the calibration constants. That is the property the compliance section below depends on.

Three knobs govern behaviour, and all three are environment-driven so a carrier can override them without a code change. The contamination parameter passed to IsolationForest at fit time sets the expected outlier fraction; set it from the historical confirmed-fraud rate for the line of business (often 0.5–2%), never left at the library default, because it directly shapes the decision boundary. The calibration constants raw_offset and raw_scale are fit after training by taking the model’s raw score_samples on a labelled holdout and choosing the logistic parameters that make the calibrated score track the empirical fraud rate — so a score of 0.8 genuinely corresponds to a claim in the top decile of anomaly, not an arbitrary internal number.

The referral threshold — the score above which a claim is handed to investigators — is deliberately not baked into the scorer. It lives in configuration alongside the per-jurisdiction rules the routing layer already loads, exactly as Dynamic Threshold Tuning treats severity cutoffs, so the same claim can clear in one state’s book and refer in another’s without retraining. Per-peril and per-region baselines are the fourth lever: catastrophe claims after a declared storm legitimately spike in amount and velocity, so their baselines must be computed on catastrophe-window data or every genuine total loss floods the queue.

import os

SCORER_CONTAMINATION = float(os.environ.get("FRAUD_CONTAMINATION", "0.01"))
REFERRAL_THRESHOLD = float(os.environ.get("FRAUD_REFERRAL_THRESHOLD", "0.85"))
MIN_BASELINE_SAMPLES = int(os.environ.get("FRAUD_MIN_BASELINE_SAMPLES", "30"))

An anomaly score can influence how a claim is handled, so it is subject to unfair-claims-settlement and adverse-action scrutiny under the NAIC model acts and state analogues. Two obligations follow directly. First, the score is never a decision: it raises a claim for human review, and the record must make that unmistakable. A verdict of “referred to SIU” is defensible; “denied because the model said so” is not. Second, every score must be reconstructable. Persist the FraudScore — the bounded value, the per-feature attributions, the baseline snapshot_id, and the model_version — to the same append-only, hash-chained ledger described in Audit Log Schema Design before any downstream action is published. Because the scorer is pure, that record plus the frozen model artifact reproduces the exact score during an examination, and the robust z-score attributions give an investigator a plain-language reason (“loss amount sat 6.4 typical spreads above the peer median for this peril and region”) without exposing the model internals. The referral hand-off itself, and the human-review gate it triggers, are the subject of SIU Referral Orchestration.

Failure modes & troubleshooting

Permalink to "Failure modes & troubleshooting"
  • False-positive flood after a catastrophe. Symptom: the review queue triples the day after a declared storm. Cause: claims are being scored against a blue-sky baseline, so every legitimate total loss reads as an outlier. Fix: switch the affected peril and region cells to a catastrophe-window baseline snapshot keyed to the event declaration, mirroring the event-declared re-evaluation in Coverage Validation Rules.
  • Score orientation confusion. Symptom: investigators chase the lowest-scoring claims. Cause: a code path used raw score_samples (inliers high) instead of the calibrated FraudScore (anomalous high). Fix: never expose the raw score; only the calibrated [0,1] value crosses a module boundary.
  • Silent drift. Symptom: precision decays over weeks with no error. Cause: inflation and product-mix shift moved the feature distribution away from the baseline. Fix: run the population-stability drift monitor and rebuild baselines when the index exceeds its budget; treat a stale baseline as an incident, not a nuisance.
  • BaselineUnavailableError on a new product. Symptom: a launch line throws on every claim. Cause: fewer than MIN_BASELINE_SAMPLES peers exist yet. Fix: fall the new cell back to a broader parent baseline (peril-only) and flag it for review rather than fabricating a baseline from a handful of points.
  • Non-finite feature crash. Symptom: FeatureContractError on a subset of claims. Cause: a zero MAD or a negative time delta produced a NaN or infinity. Fix: the builder already floors MAD and clamps negative hours; if it still fires, the upstream normalization emitted a malformed record — reject it at ingestion, do not silence it in the scorer.

Focused guides in this section

Permalink to "Focused guides in this section"

Two concrete engineering problems dominate anomaly scoring in practice, and each has its own step-by-step guide. Detecting Outlier Claim Amounts with Isolation Forests drills into the single highest-signal feature family — how to engineer amount-versus-coverage and amount-versus-peer features, fit the forest, and calibrate score_samples into the bounded signal this guide consumes. Velocity Checks for Rapid-Fire Claim Submissions covers the deterministic side of the ensemble: sliding-window counters over claimant, policy, bank account, and device that catch bust-out and staged-loss rings the density model can miss because each individual claim looks ordinary.