+1 (415) 649-9454

Fraud Detection in 2026: Real-Time Graph Features for ML Models

"Fraud Detection" has been a bullet point on every graph-database vendor's use-case list, including ours, for a decade. The reason is sound: fraud is a pattern of relationships — shared devices, recycled addresses, money that loops — and a graph exposes those patterns where a row store hides them. What has changed by 2026 is how those patterns get into a model. The modern stack computes graph features with Neo4j's Graph Data Science (GDS) library, feeds them to the same ML models your risk team already runs, and does it on live data via Kafka rather than a nightly batch. This post lays out that architecture and the specific algorithms that earn their place in it.

Why graph features beat graph rules

The first generation of graph fraud detection was rules: "flag any account sharing a device with a previously confirmed fraudster." Rules work until fraudsters learn them. The second generation treats the graph as a feature source: for each account, compute numbers that describe its position in the network — how big a connected cluster it sits in, how central it is, how similar its neighbourhood looks to known-bad neighbourhoods — and hand those numbers to a gradient-boosted model alongside the transactional features you already have. The model learns which combinations matter; the graph supplies signals no amount of per-row feature engineering can produce.

The graph

A minimal fraud graph for a payments or lending business:

(:Account)-[:OWNS]->(:Device)
(:Account)-[:USES]->(:Email)
(:Account)-[:REGISTERED_AT]->(:Address)
(:Account)-[:HAS_CARD]->(:Card)
(:Account)-[:SENT {amount, at}]->(:Account)
(:Account)-[:FLAGGED {at, reason}]->(:Case)

The identifier nodes — Device, Email, Address, Card — are the point. Two accounts that share none of each other's transactions but share a device fingerprint are one hop apart in this graph and unrelated in a table.

Load it with the idempotent patterns from our relational import tutorial and put uniqueness constraints on every identifier.

The algorithms that matter

Project the graph into GDS once, then run several algorithms against it:

CALL gds.graph.project(
  'fraud',
  ['Account', 'Device', 'Email', 'Address', 'Card'],
  {
    OWNS:          {orientation: 'UNDIRECTED'},
    USES:          {orientation: 'UNDIRECTED'},
    REGISTERED_AT: {orientation: 'UNDIRECTED'},
    HAS_CARD:      {orientation: 'UNDIRECTED'},
    SENT:          {orientation: 'NATURAL', properties: 'amount'}
  }
);

Weakly Connected Components — "who is in the same ring?"

CALL gds.wcc.write('fraud', {writeProperty: 'componentId'})
YIELD componentCount, componentDistribution;

Then, per account, the component's size and how many confirmed-fraud accounts it contains:

MATCH (a:Account)
WITH a.componentId AS cid, count(a) AS size,
     count { (f:Account {componentId: a.componentId})-[:FLAGGED]->() } AS flagged
MATCH (a:Account {componentId: cid})
SET a.componentSize = size, a.componentFlagged = flagged;

componentSize and componentFlagged are routinely the strongest graph features in the models we have seen. A brand-new account in a 40-node component with three confirmed cases is a different animal from one in a component of size 1.

PageRank on the money flow — "who is a hub?"

CALL gds.pageRank.write('fraud', {
  relationshipTypes: ['SENT'],
  relationshipWeightProperty: 'amount',
  writeProperty: 'moneyRank'
});

Weighted PageRank over SENT surfaces accounts that concentrate inbound value — mule accounts, cash-out points — which are also the accounts whose shutdown does the most damage to a ring.

Node embeddings — "does this neighbourhood look like a bad one?"

FastRP produces a fixed-length vector per node that encodes its structural neighbourhood. The downstream model can learn that certain regions of that space are fraudulent without anyone writing a rule for the shape.

CALL gds.fastRP.write('fraud', {
  embeddingDimension: 64,
  iterationWeights: [0.0, 1.0, 1.0, 0.5],
  writeProperty: 'structEmbedding'
});

Sixty-four dimensions is plenty for a feature; store them on the node and export them with the rest.

Also worth evaluating

  • Louvain / Leiden community detection when components are too large to be informative.
  • Degree centrality per identifier type: how many accounts share this device, this address.
  • Similarity (node similarity / kNN) between a new account and confirmed-fraud accounts, for a direct "nearest known bad" feature.
  • Cycle detection via Cypher for money loops: MATCH p = (a:Account)-[:SENT*3..5]->(a) RETURN p bounded by time window, which is a pattern GDS does not need to be involved in.

The GDS manual documents every algorithm above with its memory estimates; run gds.*.estimate before you write on a large graph.

Exporting features to the model

The ML side does not need to know Neo4j exists. Export a feature table:

MATCH (a:Account)
RETURN a.id AS account_id,
       a.componentSize AS component_size,
       a.componentFlagged AS component_flagged,
       a.moneyRank AS money_rank,
       count { (a)-[:OWNS]->(:Device)<-[:OWNS]-() } AS device_shared_with,
       count { (a)-[:REGISTERED_AT]->(:Address)<-[:REGISTERED_AT]-() } AS address_shared_with,
       a.structEmbedding AS struct_embedding;

Pull it with the Spark connector or the Python driver into the feature store, join on account_id with transactional features, and train as usual. The GDS Python client (graphdatascience on PyPI, docs) lets a data scientist do the projection, algorithm run, and export as pandas DataFrames without touching Cypher.

Aura Graph Analytics: features without the ETL

If your source of truth is a warehouse — Snowflake, BigQuery, Databricks — and you do not want to operate a graph database to get graph features, Aura Graph Analytics (launched May 2025, docs) runs GDS as a serverless session directly against your warehouse tables. You project a graph from a query, run WCC / PageRank / FastRP, and write the results back as columns. Neo4j's phrase for it was "No More ETL", and for the feature-computation use case it is accurate: there is no graph database to load or maintain. Where you still want a persistent graph — for investigation tooling, real-time scoring, or the knowledge-graph-for-AI work described on our GraphRAG page — AuraDB or self-managed Neo4j remains the answer, and the two can coexist.

Real time: Kafka in, scores out

Batch features are stale by the time the fraudulent transaction happens. The real-time layer:

  1. Ingest transactions and account events through the Neo4j Connector for Kafka with MERGE-based sink templates, so the graph is seconds behind production.
  2. Compute cheap features at scoring time in Cypher — the counts above, componentFlagged from the last batch run, a bounded cycle check — inside a single read transaction with a tight timeout.
  3. Recompute the expensive features (WCC, PageRank, FastRP) on a schedule that matches how fast the network changes — hourly for a fast-moving consumer app, daily for B2B lending.
  4. Write the score and the decision back as a (:Account)-[:SCORED {at, score, model}]->(:Decision) edge, so investigators and the next model version can see what happened.

The latency budget for step 2 is usually tens of milliseconds, which a correctly indexed graph on adequate page cache meets comfortably; it is the reason to keep the identifier nodes and their constraints tidy.

What to do first

If you have a transactional fraud model today and no graph: build the identifier graph, run WCC, and add component_size and component_flagged as two features. Measure the lift. In our experience that single step justifies the rest of the programme more persuasively than any slide deck, and it takes a couple of weeks. If you would like help designing the graph, choosing the algorithms, or standing up the streaming layer, contact GraphGuru.