Case study · open source, built solo
GraphGuard-AI: Catching money laundering by its shape
Laundered money hides in ordinary transfers. Looked at one at a time, nothing seems wrong.
GraphGuard ranks which accounts a bank's team should check first, with a reason for each one. A person decides; the model only sets the order.
- 332 real cases in the top 1,000 alerts (random: about 1)
- 76% of complete laundering rings found
- 5M simulated bank transfers
Before and after
- Look at each transfer alonepain point
- Flag the biggest amountspain point
- Work a noisy queuepain point
GraphGuard-AI
- Read how money moves between accounts
- Rank accounts by risk, with a reason
- 332 real cases in the top 1,000
- A person decides
A day with Bruce
Bruce investigates money laundering at a bank. Bruce's team can check about 1,000 alerts a day, so the order of those alerts decides everything. (An example person; the counts are measured on the simulated data.)
- Morning
The queue is ranked
Instead of the biggest amounts first, Bruce gets the accounts whose money moves in the most suspicious shapes, each with a reason.
- Late morning
A ring shows up
Four accounts, four ordinary transfers, and the money ends where it started. One transfer at a time, Bruce would never look twice. As a shape, it stands out.
- End of the day
332 real cases, not 1
Of the 1,000 alerts, 332 were real laundering. A random queue finds about 1. Bruce still decides every case; GraphGuard only chose the order.
Each dot is one of Bruce's 1,000 alerts, in the order they are worked (left to right, top to bottom). Lit = real laundering. The counts are measured; where each lit dot sits is drawn for illustration.
The shapes it learns to see
The data holds 370 laundering rings in 8 shapes. Three of them:
Cycle
Money leaves an account, passes through others and comes back.
54 rings in the data
Fan-out
One account splits money into many ordinary transfers.
48 rings in the data
Scatter-gather
Money spreads across middlemen, then gathers again in one account.
44 rings in the data
The contest
Five ways to order the queue, the same data and the same test for each. How many of the 168 laundering rings each one caught:
Every model got the same features, the same frozen data split and the same 20 tuning trials. The simpler model won, and that is the result I report.
The full build, by the numbers
- 5,078,345 simulated bank transfers
- 515,080 accounts
- 370 labelled laundering rings
- 180+ automated tests
- 3 leakage guards in CI
- 8 findings written down
- 20 tuning trials per model
- 1 engineer: me
3 hard problems I solved
The data disagreed with its own documentation
The dataset says it covers 10 days, but it runs 18, and the last 8 days are mostly laundering. Left in, any score would look great. I cut them and wrote down what that cost.
Scores you can trust, not scores that look good.
Three features were learning the simulator, not the crime
One of them alone did 45% of the model's work, only because IBM's simulator never sends laundering by wire. I removed all three.
I quote the lower score: 0.28, not 0.36.
The fancy graph model lost
A graph neural network against XGBoost: same features, same data split, same 20 tuning trials. XGBoost won by far. The graph itself still adds 3.3× over the same model without it.
Reported as a finding, not tuned away.
What's next
- Done
Data, rules and models
Phases 0–4: the data, the honest test bench, the baseline and the graph model.
- In progress
Serving it live
A live scoring service on Kubernetes, with a goal of answering in under 50 ms.
- Planned
A stream of transfers
Replay the simulated transfers as a live stream.
- Planned
Bruce's screen
The alert queue, each ring drawn, and a slider for the team's daily capacity.
- Planned
Watching itself
Monitoring, automatic retraining and a model card.
- My role
- Solo, open source
- When
- Built solo, 2026
- Built with
- Python · Polars · XGBoost · PyTorch Geometric · Optuna · MLflow
For engineers: how it is built
A research-grade ML pipeline with the honesty rules turned into build failures. The decisions that matter:
- Data
- IBM AMLworld · 5M transfers
- Polars
- Pandera data contracts
- Rules
- Time-based split, frozen + checksummed
- 3 leakage guards in pre-commit + CI
- Models
- XGBoost (winner)
- GraphSAGE · PyTorch Geometric
- Optuna · 20 trials each
- Tracking
- MLflow on its own Postgres
- Forgejo CI on an isolated runner
- precision@k, not AUC: at a 0.1% base rate a model can score 0.99 AUC and still hand investigators a useless queue. k = how many alerts a team can work in a day.
- Temporal split, frozen once and checksummed. The test window has one approved load site and has never been opened.
- Point-in-time features: every history feature takes a time cutoff and excludes the current row. Pinned by test.
- The baseline was built to win: hand-built graph features and the same tuning budget as the graph model.
- One number, two model classes: pos_weight 1,326 is right for XGBoost and wrecked the graph model. Optuna found 6.34, an 8× gain.
- Pinned toolchain inside the repo and fixed seeds: reruns are byte-identical.
Want a plan like this for your business? Get your free AI blueprint in about a minute.
