Graph Neural Networks for Real-Time Fraud Detection at Scale
Rules don’t catch rings
Rule-based fraud detection works until it doesn’t. It catches the obvious stuff — a transaction from an unusual country, a sudden spike in spending, a card used at two locations simultaneously. But the fraud that costs real money in 2026 isn’t some stolen card number getting tested with a $1 charge. It’s coordinated fraud rings: networks of synthetic identities, mule accounts, and layered transactions designed to look perfectly normal individually.
That’s the problem our client — a Series D fintech processing $8.2B annually — brought to us. Their rule-based system caught 70% of individual fraud attempts. But it was blind to the other 30%: transactions that only looked fraudulent when you examined the relationships between accounts.
Why graphs
A transaction isn’t just an event. It’s a connection. Account A sends money to Account B, which sends to Account C, which converts to crypto. Each transaction is clean. The pattern is fraud.
Traditional ML models — gradient boosted trees, logistic regression — look at each transaction independently. They see features like amount, time, merchant category, device fingerprint. They’re good at catching outliers. But they can’t see that Accounts A, B, and C were all created from the same IP range, funded from the same source, and started transacting within 72 hours of each other.
Graph neural networks can. A GNN takes a graph of transactions and accounts as input, learns structural features (how many hops to a known fraudulent account? does this cluster have unusual symmetry?), and classifies nodes or edges based on both local features and global topology.
The architecture
Source DBs → Kafka Streams (AWS MSK) → Flink CEP (enrichment + patterns) →
→ Memgraph (live graph) / Kùzu (analyst graph) →
→ Temporal GNN inference (PyTorch Geometric + DGL 2.x) →
→ Risk Score → Decision Engine → Block/Flag/Pass
Graph construction
We model the transaction network as a heterogeneous graph:
- Nodes: accounts, devices, IP addresses, merchants
- Edges: transactions (with amount, timestamp, type), logins (account → device), registrations (account → IP)
We run two graph stores rather than one, and the split is deliberate. Memgraph holds the live graph for streaming inference, updated continuously from Kafka Streams — it has to be fresh enough to score on the edge that just arrived. Kùzu holds the same data for analyst queries, where the access pattern is long, exploratory, read-heavy Cypher over historical windows. Putting both workloads on one store meant analyst queries competing with the scoring path for the same memory, and we watched a single ad-hoc investigation add 200ms to p99 scoring latency before we separated them.
When a new transaction arrives, Flink enriches it with account age, historical velocity and device metadata, then writes the transaction as a new edge. Flink CEP runs alongside, catching velocity spikes, geographic anomalies and ring-like transfer chains before the money settles — those are cheap, deterministic patterns and there is no reason to spend a GNN forward pass on them.
GNN model
We use a temporal graph neural network — TGN with TGAT attention, on PyTorch Geometric plus DGL 2.x — trained on 6 months of labelled fraud data (~14M transactions, ~80K confirmed fraud). The model learns 128-dimensional embeddings for each account node that capture the account’s own features, its neighbourhood structure, and critically the order and timing of the edges around it.
The temporal part is the whole point, and we did not start there. Our first model was a static 3-layer GraphSAGE, which learned neighbourhood structure but treated a transfer chain as an unordered set of edges. A ring is not a shape; it is a shape that forms inside a time window. Five accounts that transferred to each other over eighteen months are a business network. The same five doing it inside ninety minutes are a ring. A static GNN cannot express that difference, and ours kept scoring both identically. Moving to TGN gave the model access to edge timestamps as first-class input, and ring recall improved materially without touching precision.
We kept GraphSAGE-style k-hop neighbour sampling for the inference path, though — that part of the static approach was right, and it is what keeps the forward pass inside the latency budget.
At inference time, when a new transaction arrives:
- Flink enriches the transaction and writes the new edge to Memgraph
- A subgraph of 2-hop neighbours around the involved accounts gets extracted (typically 50-200 nodes)
- The GNN scores the transaction based on the subgraph embeddings
- The risk score feeds into the decision engine alongside the traditional rule-based score
The two-model approach was deliberate. The GNN catches patterns that rules miss (coordinated rings). Rules catch obvious fraud that doesn’t require graph context (stolen card used in a new country). Combining both gives us 99.6% precision at 94% recall — significantly better than either model alone.
Explainability was a deployment requirement, not a nice-to-have
The risk team would not have accepted the model without it, and they were right not to. A rule-based decline has a human-readable reason attached: velocity threshold exceeded, country mismatch. A GNN score is a number, and “the embedding looked wrong” is not something an analyst can act on or defend to a customer who calls in.
We wired GNNExplainer and PGM-Explainer into the analyst console so every block ships with the sub-graph that drove it — the specific accounts, transfers and timings that pushed the score over the line. That changed the adoption conversation completely. Analysts stopped treating the score as an oracle they had to trust or override wholesale, and started using it as a pointer to the part of the graph worth reading.
It also caught model problems we would otherwise have missed. Twice in the first quarter an explainer sub-graph showed the model keying on a merchant node that had nothing to do with the fraud pattern — an artefact of how we were sampling neighbours. We would not have found that from aggregate metrics.
Latency engineering
The hardest constraint was latency. Payment processors require a fraud decision within their authorization window — typically under 2 seconds. Our budget was 800ms from transaction arrival to risk score.
Breaking it down:
- Kafka delivery: ~20ms
- Flink enrichment: ~50ms
- Memgraph subgraph extraction: ~150ms (the bottleneck)
- GNN inference: ~80ms (ONNX-optimized, batched)
- Decision engine: ~10ms
- Network overhead: ~100ms
Subgraph extraction was the pain point. Our first approach — Cypher queries for 2-hop neighbours — took 400ms+ for highly connected accounts, and the distribution is brutal: a normal account has a handful of counterparties, while a payment aggregator has tens of thousands, so the tail is orders of magnitude worse than the median. We switched to a pre-computed neighbourhood cache in Redis, updated asynchronously. The cache serves 95% of requests in under 30ms. Cache misses fall back to Memgraph with a slightly higher latency budget.
The training pipeline
Fraud models go stale fast. Fraudsters adapt, new attack patterns emerge, and the data distribution shifts monthly. We retrain the GNN weekly on a rolling 6-month window.
The tricky part is labelling. Confirmed fraud labels arrive days or weeks after the transaction (when the customer disputes a charge or an investigation concludes). We handle this with a two-stage approach:
- Preliminary labels from the rule-based system and customer reports (available within 24-48 hours)
- Confirmed labels from investigations (available within 2-4 weeks)
We train on preliminary labels for rapid model updates and validate against confirmed labels to measure true accuracy. The gap between preliminary and confirmed accuracy is our “label noise” metric — when it exceeds 5%, we investigate whether the rule-based system is mislabelling.
How we actually cut over
We ran the GNN in shadow mode against the existing rules for four weeks before it decided anything. Every transaction got both scores; only the rule-based score was acted on. What we watched was not accuracy in aggregate but the disagreements — the transactions where the two systems reached different conclusions, because those are the only cases where switching changes an outcome.
For the first fortnight the disagreements were genuinely split, and several of them were the GNN being wrong in an expensive way. By week four they consistently favoured the GNN, and we could show the risk team a concrete list rather than a benchmark. We cut over with zero downtime.
Four weeks of shadow running felt slow at the time and it was the best decision on the project. A fraud model that goes live on a benchmark number and then blocks real customers costs you more in churn than the fraud it stops.
Results
After 14 weeks:
- 99.6% precision at 94% recall — meaning only 0.4% of flagged transactions are false positives
- Sub-800ms end-to-end latency for 99th percentile
- $47M in annual fraud prevented (up from $33M with rules-only)
- 2.3M transactions scored daily without a single missed SLA
- 62% fewer false positives, which took the analyst alert queue from ~3,000 a day to ~400
The last number is the one the client cares about most, and it is easy to miss why. Precision going up is not just a model metric — it is 2,600 fewer alerts a day that nobody has to read. The old queue was large enough that analysts triaged it by sampling, which meant real fraud sat in it unread. A 400-item queue gets worked completely.
The most satisfying metric was ring detection. In the first month the GNN identified 23 coordinated rings that the rule-based system had scored as completely clean; one involved 47 accounts and $3.2M over six weeks. Across the first quarter that came to roughly $12M of ring fraud the rules had missed entirely.
What didn’t work
End-to-end GNN training. We tried training the GNN to output binary fraud/not-fraud directly. It worked on the test set but had terrible precision in production — too many false positives on legitimate but unusual transaction patterns (e.g., a small business doing a large bulk purchase). The risk-score approach, where the GNN outputs a continuous score that feeds into a decision engine with adjustable thresholds, gave us the control we needed.
Full graph inference. Running GNN inference on the entire transaction graph is computationally impractical at our scale. The subgraph extraction approach — pulling 2-hop neighbours for each transaction — is an approximation. We lose some long-range patterns (3+ hop fraud chains), but the latency trade-off is worth it.
Real-time retraining. We tried online learning — updating the model with each new confirmed fraud label. The model destabilised after a week. Weekly batch retraining with proper validation is slower but far more reliable.
References
- Temporal Graph Networks for Deep Learning on Dynamic Graphs — Rossi et al., arXiv:2006.10637, revised 9 October 2020
- GNNExplainer: Generating Explanations for Graph Neural Networks — Ying et al., arXiv:1903.03894, revised 13 November 2019
- PyTorch Geometric documentation — PyG project, accessed August 2026
- Deep Graph Library — DGL project, accessed August 2026
- Apache Flink CEP documentation — Apache Software Foundation, accessed August 2026
- Memgraph documentation — Memgraph, accessed August 2026
We build real-time ML systems as part of our AI & Machine Learning Engineering practice. Read the full case study or talk to us about fraud detection at scale.