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.
Problem Statement
Permalink to "Problem Statement"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 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.
Prerequisites
Permalink to "Prerequisites"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-by-Step Implementation
Permalink to "Step-by-Step Implementation"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
Verification & Testing
Permalink to "Verification & Testing"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.
Compliance & Audit Note
Permalink to "Compliance & Audit Note"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.
Troubleshooting Checklist
Permalink to "Troubleshooting Checklist"- 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_sampleswas used without negation, so inliers came out high. Fix: negate before calibration exactly asfit_calibrationandscore_amountdo; never expose the raw value. AmountFeatureErroron 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.
Related
Permalink to "Related"- Statistical Anomaly Scoring — the parent scoring service this amount signal feeds
- Velocity Checks for Rapid-Fire Claim Submissions — the deterministic counter that complements this density signal
- SIU Referral Orchestration — where a high outlier signal is routed to investigators
- Automated Severity Scoring Models — the parallel triage signal that also reads loss amount
- Audit Log Schema Design — where the signal and its calibration are persisted for replay