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
# 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"]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
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
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
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?
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.
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.