Detecting Outlier Claim Amounts with Isolation Forests

This guide is the amount-feature deep dive under Statistical Anomaly Scoring: it shows how to turn a raw loss amount into features an IsolationForest can reason about, fit the model, and convert its score_samples output into the calibrated 0.0–1.0 fraud signal the parent scoring service consumes.

The loss amount is the single most information-dense field on a claim, and it is also the one that breaks a naive anomaly model most reliably. Raw amounts span from a 180windshieldchiptoa180 windshield chip to a 2.4M total-loss fire — four orders of magnitude — so a model fed unscaled dollars learns almost nothing except “big numbers are rare”, flagging every legitimate large property loss and missing the fraud that hides inside an ordinary dollar band. Worse, a fixed dollar cutoff is meaningless across peril and product: $40,000 is unremarkable on a collision claim and extraordinary on a renters contents claim, yet a single global threshold treats them identically.

Three specific defects follow from feeding raw amounts to IsolationForest. The heavy right tail dominates every split, so the tree isolates large legitimate claims first and wastes its depth budget there. The model has no notion of relative magnitude, so it cannot see that a claim is anomalous for its peer group rather than anomalous in absolute terms. And the raw score_samples output is unbounded and oriented backwards — higher for normal claims — so wiring it straight into a threshold produces a detector that fires on exactly the wrong claims. This guide fixes all three: log and peer-relative features, a forest fit on those features, and an explicit calibration step.

From raw loss amount to a calibrated outlier signal A raw loss amount and its policy coverage limit and peer baseline feed a feature step that produces log-amount, an amount-to-coverage ratio, and a robust amount-versus-peer z-score. Those features enter a fitted IsolationForest whose score_samples output is unbounded and higher for inliers. A calibration step negates and logistically squashes that raw value into a bounded zero-to-one outlier signal, which is persisted with audit context: the coverage ratio, the peer z-score, and the baseline snapshot id. Raw inputs loss amount coverage limit peer baseline Feature step log1p(amount) amount / coverage limit (amount − median) / MAD peer-relative, scale-stable IsolationForest isolates sparse points score_samples(row) raw: unbounded, inliers high Calibrate negate + logistic → [0.0, 1.0] outlier high Audit persist coverage ratio peer z-score snapshot id signal ∈ [0,1]
A raw amount becomes log, coverage-ratio, and peer-relative features, is scored by the forest, then calibrated to a bounded outlier signal.

This pattern targets Python 3.10+ and pins scikit-learn and NumPy so a model artifact scored online matches the one fit during training — a scikit-learn minor-version mismatch can shift score_samples output enough to change a verdict at the boundary.

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

Pipeline state required: each training and scoring claim must already carry a Decimal loss amount, its policy’s coverage limit, and the per-peril, per-region peer baseline (median and median-absolute-deviation) produced by the offline baseline job described in the parent Statistical Anomaly Scoring guide. Never accept a float amount into the feature builder; coerce at ingestion.

Step 1 — Engineer scale-stable, peer-relative amount features

Permalink to "Step 1 — Engineer scale-stable, peer-relative amount features"

Three features carry the amount signal without letting raw magnitude dominate. log1p(amount) compresses the four-order-of-magnitude tail; the amount-to-coverage ratio catches claims that consume an implausible fraction of the limit; and the robust peer z-score measures how far above the peer median the loss sits in units of typical spread.

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
import math


class AmountFeatureError(ValueError):
    """A loss amount could not be turned into valid features."""


@dataclass(frozen=True, slots=True)
class AmountFeatures:
    log_amount: float
    amount_to_coverage: float
    peer_z: float

    def as_row(self) -> list[float]:
        row = [self.log_amount, self.amount_to_coverage, self.peer_z]
        if any(math.isnan(v) or math.isinf(v) for v in row):
            raise AmountFeatureError(f"non-finite amount feature: {row}")
        return row


def build_amount_features(
    *,
    amount: Decimal,
    coverage_limit: Decimal,
    peer_median: Decimal,
    peer_mad: Decimal,
) -> AmountFeatures:
    if not isinstance(amount, Decimal):
        raise AmountFeatureError("amount must be Decimal; coerce at ingestion")
    amount_f = float(amount)
    limit_f = float(coverage_limit)
    if limit_f <= 0:
        raise AmountFeatureError("coverage_limit must be positive")
    median = float(peer_median)
    # Floor MAD so a degenerate cell cannot divide by zero.
    mad = float(peer_mad) or median * 0.05 or 1.0
    return AmountFeatures(
        log_amount=math.log1p(max(amount_f, 0.0)),
        amount_to_coverage=min(amount_f / limit_f, 5.0),  # clamp reporting errors
        peer_z=(amount_f - median) / mad,
    )

Step 2 — Fit the IsolationForest on historical amount features

Permalink to "Step 2 — Fit the IsolationForest on historical amount features"

Fit the forest on a representative window of legitimate-heavy history. Set contamination from the line’s confirmed-fraud rate rather than the library default, and pin random_state so the artifact is reproducible. The model is fit on the feature rows, never on raw dollars.

import numpy as np
from sklearn.ensemble import IsolationForest


def fit_amount_forest(
    training_features: list[AmountFeatures],
    *,
    contamination: float,
    random_state: int = 42,
) -> IsolationForest:
    if len(training_features) < 500:
        raise ValueError("need >=500 rows for a stable amount forest")
    matrix = np.asarray([f.as_row() for f in training_features], dtype=np.float64)
    model = IsolationForest(
        n_estimators=200,
        contamination=contamination,
        max_samples="auto",
        random_state=random_state,
        n_jobs=-1,
    )
    model.fit(matrix)
    return model

Step 3 — Calibrate score_samples into a bounded outlier signal

Permalink to "Step 3 — Calibrate score_samples into a bounded outlier signal"

score_samples is unbounded and higher for inliers. Fit two logistic constants on a labelled holdout so the calibrated signal is monotone, bounded, and oriented so that higher means more anomalous — then scoring is a pure function that persists its audit context.

from dataclasses import dataclass
import structlog

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


@dataclass(frozen=True, slots=True)
class OutlierSignal:
    signal: float                 # calibrated [0.0, 1.0]
    peer_z: float
    amount_to_coverage: float
    snapshot_id: str


def fit_calibration(
    model: IsolationForest, holdout: list[AmountFeatures]
) -> tuple[float, float]:
    raw = -model.score_samples(np.asarray([f.as_row() for f in holdout]))
    offset = float(np.median(raw))
    scale = float(np.std(raw)) or 1.0
    return offset, scale


def score_amount(
    features: AmountFeatures,
    model: IsolationForest,
    *,
    offset: float,
    scale: float,
    snapshot_id: str,
) -> OutlierSignal:
    raw = float(-model.score_samples(np.asarray([features.as_row()]))[0])
    z = (raw - offset) / scale
    signal = 1.0 / (1.0 + math.exp(-z))
    result = OutlierSignal(
        signal=round(signal, 4),
        peer_z=round(features.peer_z, 4),
        amount_to_coverage=round(features.amount_to_coverage, 4),
        snapshot_id=snapshot_id,
    )
    log.info("fraud.amount_scored", signal=result.signal,
             peer_z=result.peer_z, snapshot_id=snapshot_id)
    return result

Prove the two properties that matter: a claim far above its peer baseline scores higher than an on-baseline claim, and calibration keeps the signal inside [0, 1]. The peer z-score gives a deterministic handle even though the forest itself is stochastic.

from decimal import Decimal


def _feat(amount: str) -> AmountFeatures:
    return build_amount_features(
        amount=Decimal(amount),
        coverage_limit=Decimal("300000"),
        peer_median=Decimal("8000"),
        peer_mad=Decimal("2500"),
    )


def test_peer_z_monotone_in_amount() -> None:
    assert _feat("50000").peer_z > _feat("8000").peer_z
    assert _feat("8000").peer_z == 0.0


def test_signal_is_bounded_and_ordered() -> None:
    train = [_feat(str(a)) for a in range(4000, 12000, 15)]
    model = fit_amount_forest(train, contamination=0.02)
    offset, scale = fit_calibration(model, train)
    normal = score_amount(_feat("8000"), model, offset=offset,
                          scale=scale, snapshot_id="snap-1")
    extreme = score_amount(_feat("250000"), model, offset=offset,
                           scale=scale, snapshot_id="snap-1")
    assert 0.0 <= normal.signal <= 1.0
    assert 0.0 <= extreme.signal <= 1.0
    assert extreme.signal > normal.signal

Run under python -m pytest -q. Pinning random_state in fit_amount_forest makes the ordering assertion stable across runs; without it the forest is non-deterministic and the test would flake.

An outlier amount signal can push a claim toward investigation, so it falls under the same adverse-action and unfair-claims-settlement traceability the parent guide describes. The OutlierSignal deliberately carries its own explanation: the peer_z and amount_to_coverage values let an investigator state in plain language why the claim surfaced — “the loss was 6.4 typical spreads above the peer median and consumed 83% of the coverage limit” — without exposing model internals. Persist the signal together with its baseline snapshot_id and the fitted offset/scale and model version to the append-only, hash-chained ledger in Audit Log Schema Design; because scoring is pure, that record reproduces the exact signal during a market-conduct examination, and the score never stands alone as a denial reason.

  • Every large legitimate claim scores high. Symptom: total losses flood the queue. Cause: the model was fit on raw amounts, or the peer baseline is global rather than per peril and region. Fix: fit on the peer-relative features from Step 1 and rebuild baselines per (peril, region) cell.
  • Signal is inverted. Symptom: the calmest claims score near 1.0. Cause: score_samples was used without negation, so inliers came out high. Fix: negate before calibration exactly as fit_calibration and score_amount do; never expose the raw value.
  • AmountFeatureError on a subset of claims. Symptom: non-finite feature crashes. Cause: a zero or negative coverage limit, or a degenerate peer MAD, produced a NaN or infinity. Fix: the builder floors MAD and rejects non-positive limits; if it still fires, the malformed record must be rejected at ingestion, not silenced.
  • Verdicts flip between environments. Symptom: the same claim scores differently in staging and production. Cause: a scikit-learn or NumPy minor-version drift shifted score_samples. Fix: pin the versions from the prerequisites and score against the exact artifact and calibration constants recorded in the ledger.
  • Coverage ratio pegged at the clamp. Symptom: many claims show amount_to_coverage == 5.0. Cause: reporting errors or wrong-limit joins, not fraud. Fix: treat the clamp as a data-quality alert on the upstream join, and never let a single saturated feature by itself drive a referral.