Skip to content
Lubo Bali

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

See the code on GitHub

806D31C80$14,43580D0D5F80€12,31980EB13650$15,47180E0555C0$16,5874 ordinary transfers1 laundering ring
A real ring from the data (simulated by IBM AMLworld). Every hop looks normal; the shape is the crime.

Before and after

Sort by amount0 of 168 rings
  1. Look at each transfer aloneOne row at a timepain point
  2. Flag the biggest amountsRings use ordinary amountspain point
  3. Work a noisy queueAbout 1 real case in 1,000pain point
Rank by shape127 of 168 rings

GraphGuard-AI

  1. Read how money moves between accounts
  2. Rank accounts by risk, with a reason
  3. 332 real cases in the top 1,000
  4. 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.)

  1. 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.

  2. 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.

  3. 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.

Random order1 real of 1,000
GraphGuard-AI's order332 real of 1,000

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:

  1. Random order0 of 168 rings caught
  2. Biggest amounts first0 of 168 rings caught
  3. Neural network, no graph34 of 168 rings caught
  4. Graph neural network60 of 168 rings caught
  5. XGBoost, with graph features127 of 168 rings 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

  1. 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.

  2. 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.

  3. 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

  1. Done

    Data, rules and models

    Phases 0–4: the data, the honest test bench, the baseline and the graph model.

  2. In progress

    Serving it live

    A live scoring service on Kubernetes, with a goal of answering in under 50 ms.

  3. Planned

    A stream of transfers

    Replay the simulated transfers as a live stream.

  4. Planned

    Bruce's screen

    The alert queue, each ring drawn, and a slider for the team's daily capacity.

  5. 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:

  1. Data
    • IBM AMLworld · 5M transfers
    • Polars
    • Pandera data contracts
  2. Rules
    • Time-based split, frozen + checksummed
    • 3 leakage guards in pre-commit + CI
  3. Models
    • XGBoost (winner)
    • GraphSAGE · PyTorch Geometric
    • Optuna · 20 trials each
  4. 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.

Tell me your problem. I'll find a way to build the solution.

Available now

Book a free 30-min call (opens Calendly in a new tab)

No obligation. Pick a time that suits you.

Prefer email?data@lubobali.com