Most machine learning tutorials end with an accuracy number. In insurance claims, that number is rarely the thing that decides whether a model goes live. The question a claims manager, an auditor or a regulator asks first is simpler: why did this claim get routed this way?
If your system cannot answer that in one sentence per claim, it will struggle to leave the pilot stage. This article walks through a deliberately simple design for a claims-triage scorer that puts explanations first. The code is illustrative, not production-ready, and the features and weights are made up for teaching purposes only.
What triage is (and isn't)
Claims triage is about routing, not deciding. A new claim comes in, and the system suggests a lane:
- Fast track: simple, low-complexity claims that can move straight to settlement checks.
- Standard review: the default handler queue.
- Specialist review: claims with signals that deserve an experienced adjuster's eye.
The model never denies a claim. A human always owns the outcome. That framing matters for design: you are building a prioritisation aid, so being transparent is worth more than squeezing out the last bit of predictive power.
Design principle 1: start with a points-based scorecard
Before reaching for gradient boosting, try a scorecard. Each feature adds or subtracts points, and the total maps to a lane. Actuaries and credit-risk teams have used this style of model for decades because anyone can read it.
from dataclasses import dataclass, field
# Illustrative rules only: features, thresholds and points are invented for this example.
RULES = [
# (reason_code, description, test, points)
("R01", "Claim reported long after the loss date",
lambda c: c["days_to_report"] > 30, 25),
("R02", "Claimed amount high relative to sum insured",
lambda c: c["claim_amount"] / c["sum_insured"] > 0.8, 20),
("R03", "Policy started shortly before the loss",
lambda c: c["policy_age_days"] < 60, 20),
("R04", "Supporting documents incomplete",
lambda c: not c["docs_complete"], 15),
("R05", "Low-value claim with complete documents",
lambda c: c["claim_amount"] < 2000 and c["docs_complete"], -20),
]
LANES = [(40, "specialist_review"), (10, "standard_review")] # else fast_track
@dataclass
class TriageResult:
claim_id: str
score: int
lane: str
reasons: list = field(default_factory=list)
def triage(claim: dict) -> TriageResult:
score, reasons = 0, []
for code, text, test, points in RULES:
if test(claim):
score += points
reasons.append((code, text, points))
lane = next((name for cutoff, name in LANES if score >= cutoff), "fast_track")
# Sort so the strongest drivers are listed first
reasons.sort(key=lambda r: abs(r[2]), reverse=True)
return TriageResult(claim["id"], score, lane, reasons)
claim = {"id": "C-1001", "days_to_report": 45, "claim_amount": 9000,
"sum_insured": 10000, "policy_age_days": 400, "docs_complete": True}
result = triage(claim)
print(result.lane, result.score)
for code, text, pts in result.reasons:
print(f" {code} ({pts:+d}): {text}")
The output tells a handler exactly what happened: which rules fired, how many points each added, and which lane the total landed in. There is no post-hoc explanation layer to maintain, because the model is the explanation.
Design principle 2: reason codes are a product feature
Treat reason codes as a first-class output, not a debugging aid:
-
Stable identifiers.
R01should mean the same thing next year. If the logic changes, issue a new code and retire the old one. Downstream reports and audit trails depend on it. -
Plain-language text. Write the description for a claims handler, not a data scientist. "Policy started shortly before the loss" beats
policy_age_days_lt_60. - Top-N only. Show the two or three strongest drivers. A list of fourteen contributing factors is not an explanation.
- Neutral wording. A reason code points out something worth checking. It doesn't accuse anyone. Avoid words like "suspicious" in user-facing text.
Design principle 3: enforce monotonic behaviour
Stakeholders have intuitions, and your model should respect the sensible ones. If reporting a claim later always increases the score, a handler can reason about it. If the score goes up at 31 days, down at 50 and up again at 90, trust erodes quickly.
With a scorecard this is easy to guarantee. If you later move to a tree-based model, libraries such as XGBoost and LightGBM support monotonic constraints per feature. Either way, write a test:
def test_reporting_delay_is_monotonic():
base = {"id": "T", "claim_amount": 5000, "sum_insured": 10000,
"policy_age_days": 400, "docs_complete": True}
scores = [triage({**base, "days_to_report": d}).score for d in range(0, 120, 5)]
assert scores == sorted(scores), "Score must not fall as reporting delay grows"
Tests like this turn business expectations into something your CI pipeline checks on every change.
Design principle 4: log everything a reviewer would ask for
For each triage decision, store the input snapshot, the rule-set version, the score, the lane, the reason codes, and the final human decision. That last field is the most valuable one. Over time, comparing suggested lanes with what adjusters actually did shows you which rules help and which ones just add noise. You can only learn from it if you capture it from day one.
Design principle 5: keep a human override, and measure it
Every lane suggestion should be easy to override with a short reason. Track the override rate by rule and by lane. A rule that is overridden most of the time is telling you something. Maybe the threshold is wrong, maybe the data feeding it is unreliable, or maybe the rule never reflected how experienced handlers actually work.
When to graduate beyond a scorecard
A scorecard has limits. It struggles with interactions between features and with unstructured inputs like adjuster notes or photos. When you outgrow it, a sensible path is:
- Keep the scorecard as a baseline and fallback.
- Introduce a more flexible model, with explanation tools such as SHAP mapped back to your existing reason codes, so users keep the same vocabulary.
- Run both in shadow mode and compare their suggestions before switching.
- Check outcomes across customer segments for unintended bias, and document what you checked.
The goal is to make the system more capable without making it any harder to explain.
Practical checklist
- [ ] The model routes claims and never makes the final decision
- [ ] Every output includes stable, plain-language reason codes
- [ ] Monotonic expectations are written as automated tests
- [ ] Rule-set versions are logged with each decision
- [ ] Human overrides are captured and reviewed regularly
- [ ] A simple baseline exists before any complex model ships
Closing thought
In regulated domains, explainability isn't a layer you bolt on at the end. It shapes the data you log, the tests you write and the interface handlers see. Start with something a claims manager can read line by line. Earn the right to add complexity later.
Yeshwanth Vepachadu is an Insurance & AI strategist based in Dubai with 20+ years of experience and an MSc in Actuarial Science. He writes at Substack and WordPress.
Top comments (0)