Hello Model Services
← Guides

Anomaly detection · 4 min read

How to flag unusual expense claims

Point your finance team at the few expense claims worth a second look, out of thousands, when you don’t yet have examples of what a problem looks like.

What you’ll build

  • Simple rules for problems you already know about, such as duplicate claims
  • An oddness score for every claim, from a model that learns what normal looks like
  • A short review list with a reason for each claim, and labels from every review

Tools

Python, pandas, scikit-learn

Step 1: Start from the review budget

Nobody can check every claim, and most are fine. Ask the finance team how many claims they can look at closely each month, say 50. That number is the goal: a list of 50 claims with as many real problems in it as possible, each with a reason someone can check in a minute.

Also be clear about what the list means. A flagged claim is unusual, not wrong. Most unusual claims have a good explanation, such as a conference abroad or a one-off equipment purchase. The list is for a closer look, never an accusation.

Step 2: Get the claims and describe them

Export one row per claim: who claimed it, when, the category, the merchant and the amount. Then describe each claim in ways that make problems stand out. Raw amounts alone don’t: $300 is normal for a hotel night and very odd for a taxi.

CodeLoad claims and build features
pythonRun in Colab
# pip install pandas scikit-learn
import numpy as np
import pandas as pd

exp = pd.read_csv("expenses.csv", parse_dates=["date"])   # claim_id, employee_id, date, category, merchant, amount

exp["log_amount"] = np.log1p(exp.amount)
exp["vs_category"] = exp.amount / exp.groupby("category").amount.transform("median")
exp["vs_own_usual"] = exp.amount / exp.groupby(["employee_id", "category"]).amount.transform("median")
exp["weekend"] = (exp.date.dt.dayofweek >= 5).astype(int)
exp["round_amount"] = (exp.amount % 10 == 0).astype(int)
exp["same_day_repeats"] = exp.groupby(["employee_id", "merchant", "date", "amount"]).claim_id.transform("count") - 1
features = ["log_amount", "vs_category", "vs_own_usual", "weekend", "round_amount", "same_day_repeats"]
For a one-off audit of past claims, medians over the whole history are fine. For a monthly review, compute each person’s usual amounts from earlier months only, so a new claim is compared with what came before it, not with itself.

Step 3: Write rules for what you already know

Some problems need no model at all: the same claim submitted twice, an amount above the policy limit, a receipt dated on a weekend for a weekday-only category. Rules catch these exactly and explain themselves. They’re also your baseline: the model is only worth adding if it finds problems the rules miss.

CodeRules first
pythonRun in Colab
LIMITS = {"meals": 100, "taxi": 150, "hotel": 400}   # your expense policy

rules = pd.DataFrame({
    "duplicate": exp.same_day_repeats > 0,
    "over_limit": exp.amount > exp.category.map(LIMITS).fillna(np.inf),
})
exp["rule_hit"] = rules.any(axis=1)
print(rules.sum())

Step 4: Score every claim with Isolation Forest

Without labeled examples of problems, use unsupervised learning: learn what normal claims look like, and score how far each one is from normal. Isolation Forest does this by trying to separate each claim from the rest with random splits. Ordinary claims sit in crowds and take many splits to isolate; unusual ones stand apart and take few.

CodeAn oddness score for every claim
pythonRun in Colab
from sklearn.ensemble import IsolationForest

iso = IsolationForest(n_estimators=300, random_state=0).fit(exp[features])
exp["oddness"] = -iso.score_samples(exp[features])        # higher = more unusual

REVIEW_BUDGET = 50
review = exp[~exp.rule_hit].nlargest(max(REVIEW_BUDGET - int(exp.rule_hit.sum()), 0), "oddness")
print(review[["claim_id", "employee_id", "category", "amount", "oddness"]].head(10))

Rule hits go to the top of the list, because they’re certain. The rest of the budget goes to the most unusual claims the rules didn’t catch.

Step 5: Give a reason for every flag

A reviewer needs to know why a claim was flagged, or they’ll waste time hunting. A simple, useful reason: the feature where this claim is most extreme compared with typical claims.

CodeThe main reason each claim stands out
pythonRun in Colab
typical = exp[features].median()
spread = (exp[features].quantile(0.75) - exp[features].quantile(0.25)).replace(0, 1)
z = (exp[features] - typical) / spread

labels = {"log_amount": "large amount", "vs_category": "high for its category",
          "vs_own_usual": "high for this person", "weekend": "on a weekend",
          "round_amount": "round amount", "same_day_repeats": "repeated the same day"}
review = review.assign(reason=z.loc[review.index].abs().idxmax(axis=1).map(labels))
print(review[["claim_id", "category", "amount", "reason"]].head(10))

Step 6: Check it against problems you already found

You may have no labels, but you probably have a few known cases: claims from past audits or ones that were rejected. Check how many of those land in the review list. If none do, the features aren’t capturing what problems look like in your company, and that’s worth knowing before anyone relies on the list.

CodeDo known problems make the list?
pythonRun in Colab
known = pd.read_csv("past_findings.csv").claim_id      # claims found in earlier audits
flagged = set(exp[exp.rule_hit].claim_id) | set(review.claim_id)
caught = known.isin(flagged).mean()
print(f"Known problems in this month's review list: {caught:.0%}")

This is recall at your review budget, measured on a small set. With a handful of known cases it’s a rough check, not a precise score, but it quickly shows whether the approach works at all.

Step 7: Turn every review into a label

Ask reviewers to record a short outcome for every claim they check: fine, policy mistake, duplicate, or needs follow-up. After a few months you’ll have a few hundred labeled claims. That changes the problem: you can now measure precision (how many flagged claims were real problems), tune the features, and eventually train a classifier on the labels, as in the fraud detection guide.

Tip Also record the claims that turned out fine, and why: “annual conference,” “approved exception.” They teach you which kinds of unusual are normal for your company, and which features to adjust.

Step 8: Run it every month

A monthly batch job is all the infrastructure you need: load the month’s claims, build features from earlier months, apply the rules, score the rest, and send the list with reasons to the finance team. Spending changes with new policies, new offices and new suppliers. That’s data drift, so retrain the Isolation Forest each month on the most recent year of claims, and check that known problems still make the list.

Common pitfalls

  • Treating a flag as proof. Most unusual claims are legitimate; the list is for a closer look.
  • Scoring raw amounts only, so every hotel bill is flagged and every unusual taxi fare is missed.
  • Comparing a claim with averages that include the claim itself, or later months.
  • Skipping rules for problems you already know how to describe exactly, such as duplicates.
  • Never recording review outcomes, so the system can’t learn and nobody can tell if it works.

Check what you learned

4 quick questions. Choose an answer to see why each option is right or wrong.

  1. What does a flagged claim mean?

  2. Why compare a claim with its category and with the person’s usual amounts, not just the raw amount?

  3. Why write rules before training a model?

  4. You have no labeled problems. How can you check the review list is useful?

Want this tailored to your data, team and budget? Create a plan for your project

More on Anomaly detection · Next guide: How to label data for machine learning

Create a plan