Evaluation
Why evaluation matters
A model always looks good on data it has already seen. It can memorise instead of learning.
So the data is split three ways:
| Split | Used for |
|---|---|
| Train | Fitting the models |
| Validation | Choosing weights, calibration, and thresholds |
| Test | The final check, read once |
The test split is read in exactly one file, after everything else is frozen. Nothing is retrained or re-tuned against it.
If you tune against the test data, the test score stops predicting how the system will behave on new data. It just describes your tuning.
Metrics
Say the system flagged 100 transactions and 63 were really fraud, while 20 real frauds slipped through.
Precision. Of the transactions we flagged, how many were really fraud. Higher precision means fewer annoyed real customers.
Recall. Of all the fraud, how much we caught. Higher recall means less money lost.
These two fight each other. Flag everything and recall is perfect but precision is terrible. Flag nothing and the reverse.
F1. One number balancing precision and recall.
False positive. A normal transaction we wrongly flagged. Costs a sale and a customer's goodwill.
False negative. Real fraud we missed. Costs the money.
FPR (false positive rate). Out of all the normal transactions, what share did we wrongly flag.
FNR (false negative rate). Out of all the fraud, what share did we miss.
PR-AUC. How well the model sorts fraud above normal, measured across every possible threshold. Good when fraud is rare, because it ignores the large number of easy normal cases.
ROC-AUC. How well the model separates the two classes overall. Easier to score well on than PR-AUC when classes are imbalanced.
Brier score. How close the predicted probabilities are to reality. Lower is better.
Expected cost. What running the system would cost in money, adding up wrong blocks, missed fraud, and review time.
The cost model
Accuracy alone does not tell you whether a fraud system is worth running. The cost model does.
false positive 25.0 for each good order wrongly blocked
missed fraud the full transaction amount, plus 15.0
manual review 3.0 each, and it catches 80% of the fraud sent to it
Two choices worth explaining:
Missed fraud scales with the amount. Losing a 5,000 payment costs far more than losing a 50 one. Treating them the same, which is the common shortcut, picks the wrong threshold.
Review is neither free nor perfect. It costs money, and 20% of the fraud sent to review still gets through. Counting review as a save is the easiest way to make these numbers look better than they are.
Change the costs with environment variables and retrain:
MS_FALSE_POSITIVE_COST=50 python -m ml.training.train
Results
Held-out test split. 5,100 labeled transactions, 43.9% fraud.
By split
| Split | Rows | Fraud rate | PR-AUC | ROC-AUC | Brier |
|---|---|---|---|---|---|
| Train | 20,806 | 0.1237 | 0.6359 | 0.9395 | 0.0670 |
| Validation | 3,737 | 0.1191 | 0.5213 | 0.8823 | 0.0736 |
| Test | 5,100 | 0.4388 | 0.9151 | 0.9473 | 0.1834 |
The test split has a much higher fraud rate because the ring operates there. That is why its PR-AUC looks higher: PR-AUC depends on how common fraud is, so these numbers are not directly comparable across splits.
By model
| Model | Train PR-AUC | Validation PR-AUC | Test PR-AUC |
|---|---|---|---|
| Tree | 0.9140 | 0.4578 | 0.7042 |
| Graph | 0.5036 | 0.5195 | 0.9380 |
| Behavioural | 0.1731 | 0.2028 | 0.7398 |
| Velocity | 0.1843 | 0.1871 | 0.8618 |
| Combined | 0.6359 | 0.5213 | 0.9151 |
The tree model looks best during training and is the weaker of the two learned models on the held-out test. If I had reported training performance, I would have drawn the opposite conclusion.
Threshold settings, on test
| Setting | Block at | Precision | Recall | F1 | FPR | Works on test |
|---|---|---|---|---|---|---|
| balanced | 0.1402 | 0.6299 | 0.9911 | 0.7703 | 0.4553 | yes |
| high_precision | 0.5135 | 0.3333 | 0.0004 | 0.0009 | 0.0007 | no |
| high_recall | 0.1402 | 0.6299 | 0.9911 | 0.7703 | 0.4553 | yes |
The high precision setting does not work here. Its threshold was chosen correctly on the validation data, but almost nothing in the test window scores that high, so it fires on 3 out of 5,100 transactions. The evaluation prints this as a failure instead of reporting precision on an almost empty set.
Confusion matrix, balanced setting
| Predicted fraud | Predicted normal | |
|---|---|---|
| Actually fraud | 2,218 | 20 |
| Actually normal | 1,303 | 1,559 |
Cost, balanced setting
| Value | |
|---|---|
| Loss if there were no system | 55,778.38 |
| Prevented loss | 54,170.31 |
| Loss still getting through | 1,608.07 |
| Total cost of running the system | 34,468.07 |
| Cost per 1,000 transactions | 6,758.45 |
| Net benefit | 21,310.31 |
Ring detection
Ring detection uses no fraud labels. The alert threshold was chosen on validation. Precision is checked against the real labels afterwards.
| Value | |
|---|---|
| Precision | 0.9189 |
| Recall of test fraud | 0.8963 |
| Lift over base rate | 2.09x |
| Rings alerted | 3 |
| Confirmed transactions covered | 2,183 |
Stress test: new merchants
| Slice | Rows | Fraud rate | PR-AUC | Precision | Recall |
|---|---|---|---|---|---|
| Merchants barely seen | 341 | 0.2287 | 0.6091 | 0.2885 | 0.9359 |
| Familiar merchants | 4,759 | 0.4539 | 0.9248 | 0.6564 | 0.9931 |
This is the honest stress test. History features cannot help on a merchant the system has not seen before, and the gap shows exactly how much the system leans on knowing an entity already.
Score drift
PSI between validation and test scores: 0.855, which counts as a real shift.
PSI compares two score distributions. Below 0.10 is stable, 0.10 to 0.25 is worth watching, above 0.25 means it moved.
This is why the high precision setting failed to carry over. In real use you would recalibrate when this number rises.
Reproducing
python -m ml.training.train
python -m ml.evaluation.evaluate
Results are also written to reports/evaluation.json.
The graph model uses CPU float maths that is not perfectly repeatable, so these numbers move by about 0.001 between retrains. Everything else is exact.
Guard against cheating
There is a test that fails the build if the held-out PR-AUC goes above 0.999.
On messy, drifting real data, a near perfect score almost always means a bug: something is leaking future information into the features. Better to fail loudly than to publish it.