Entity Network Analysis for Organized Claims Fraud
Entity network analysis models claims, claimants, providers, adjusters, addresses, and phone numbers as nodes in a single graph, so that collusion invisible to any per-claim check becomes a visible structure: a repair shop that services an implausible share of a small group of claimants, a phone number shared across a dozen unrelated losses, a staged-accident ring whose members never appear in the same claim but are two hops apart through a common medical provider. It is one detector in the broader Fraud Pattern Detection domain, and where single-claim scoring and duplicate claim detection look at one record or one pair, this layer looks at the shape of the whole book.
What Single-Claim Scoring Cannot See
Permalink to "What Single-Claim Scoring Cannot See"Organized fraud is a relational property, not a per-record one. Each individual claim in a staged-accident ring can be perfectly ordinary — a plausible loss, a covered peril, an in-range amount — and pass every gate in the claims triage and routing engines pipeline. The fraud only exists in the aggregate: the same three phone numbers, the same two clinics, the same tow operator recurring across claims that have no claimant in common. A row-by-row model, even a good statistical anomaly scoring model, is structurally blind to it because the signal is in the edges between records, not the fields within them.
Three failure classes push teams toward a graph model. The first is the shared-identifier blind spot: a fraud ring reuses infrastructure — a garage, a billing address, a burner phone — and reuse is an edge, not a column. The second is the multi-hop path: two claimants who never co-occur on a claim are still connected through a provider they both used, and collusion often hides exactly two or three hops away where no JOIN in a claims table reaches. The third is the density signal: a legitimate provider connects to many unrelated claimants diffusely, while a fraud ring forms a dense, near-complete subgraph — a community whose internal connection count is wildly higher than chance. None of these are expressible as a threshold on a single claim; all of them are natural graph computations.
The discipline, then, is to build a well-typed graph from claim records, run community detection and centrality over it, and turn the structural features — community density, betweenness of a broker node, the count of distinct claimants funnelling through one provider — into features that feed the fraud score, rather than trying to bolt relational reasoning onto a row-oriented model.
Prerequisites & Environment Setup
Permalink to "Prerequisites & Environment Setup"This component targets Python 3.10+ and uses networkx for the graph model and algorithms, with structlog for audit lines and pydantic for the claim-record schema. networkx is the right first choice: it is pure-Python, expressive, and handles books up to a few million edges in memory; only when the graph outgrows a single host do you migrate the same algorithms to a distributed engine. Pin the versions:
python -m venv .venv && source .venv/bin/activate
pip install "networkx==3.*" "pydantic==2.*" "structlog==24.*"
Infrastructure dependencies:
- A claims feature store or read replica exposing, per claim, the associated claimant, provider(s), assigned adjuster, loss and billing addresses, and contact phone numbers — the raw material for the edges. Entity resolution (deciding that two spellings of a provider are the same node) should run upstream; a graph built on unresolved identifiers fragments a real ring into disconnected pieces.
- A graph build cadence. The graph is rebuilt on a schedule (nightly) or incrementally as claims settle, not per FNOL, because ring structure is a slowly-moving property of the book. Score contributions are cached against each claim so the synchronous triage path reads a precomputed feature rather than traversing the graph inline.
- An append-only audit store — the audit log schema design backbone — where every flagged subgraph is persisted with the snapshot id and rule version that produced it.
Set the environment-driven thresholds before writing logic: the community-density flag (RING_DENSITY_THRESHOLD, default 0.6), the minimum ring size worth scoring (RING_MIN_NODES, default 4), and the rule version (GRAPH_RULE_VERSION, default v0.9).
Architecture: Records to Graph to Score
Permalink to "Architecture: Records to Graph to Score"The pipeline is three stages, and separating them keeps each testable. Construction decomposes each claim record into typed nodes and the edges that connect them: a claim node links to its claimant, to each provider, to the assigned adjuster, and to shared identifiers like a phone number or billing address. Modelling identifiers as first-class nodes — rather than attributes on a claim — is the key move, because it is precisely the sharing of an identifier across otherwise-unrelated claims that turns into an edge path a ring detector can find.
Community detection partitions the graph into groups whose members are more densely connected to each other than to the rest of the book. A legitimate provider sits at the centre of a diffuse star; a fraud ring forms a tight, near-complete cluster where claimants, providers, and shared phones all interconnect. Greedy modularity maximization is a solid default — deterministic given a fixed node ordering, and fast enough for book-scale graphs — and yields candidate communities to score.
Ring scoring computes structural features over each candidate community and reduces them to a single suspiciousness score: internal edge density (how close to complete the subgraph is), the count of distinct claimants funnelling through a shared provider or identifier, and betweenness centrality to spot a broker node that many otherwise-separate claims route through. Those features are emitted as a contribution to the per-claim fraud score consumed by statistical anomaly scoring, and a high-scoring subgraph is persisted for escalation into SIU referral orchestration.
Core Implementation: Graph Construction and Ring Scoring
Permalink to "Core Implementation: Graph Construction and Ring Scoring"The implementation below builds a typed networkx graph from claim records and scores a community. Nodes are namespaced by type so a claimant and a provider can never collide on a shared raw id, and every function is deterministic given its inputs.
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
import networkx as nx
import structlog
log = structlog.get_logger("fraud.graph")
class NodeType(str, Enum):
CLAIM = "claim"
CLAIMANT = "claimant"
PROVIDER = "provider"
ADJUSTER = "adjuster"
ADDRESS = "address"
PHONE = "phone"
class GraphBuildError(Exception):
"""Raised when a claim record lacks the identifiers needed to place edges."""
@dataclass(frozen=True, slots=True)
class ClaimRecord:
claim_id: str
claimant_id: str
provider_ids: tuple[str, ...]
adjuster_id: str
addresses: tuple[str, ...] # resolved, canonical
phones: tuple[str, ...] # E.164 normalized
def _node(kind: NodeType, raw_id: str) -> str:
"""Namespace ids by type so a claimant id never collides with a provider id."""
if not raw_id:
raise GraphBuildError(f"empty id for node type {kind.value}")
return f"{kind.value}:{raw_id}"
def build_graph(records: list[ClaimRecord]) -> nx.MultiGraph:
"""Decompose claim records into a typed entity graph.
Every claim becomes a node linked to its claimant, providers, adjuster,
and shared identifiers. Shared identifiers are first-class nodes, so reuse
across unrelated claims manifests as a traversable path.
"""
g: nx.MultiGraph = nx.MultiGraph()
for rec in records:
claim = _node(NodeType.CLAIM, rec.claim_id)
g.add_node(claim, ntype=NodeType.CLAIM.value)
for kind, raw in [
(NodeType.CLAIMANT, rec.claimant_id),
(NodeType.ADJUSTER, rec.adjuster_id),
]:
n = _node(kind, raw)
g.add_node(n, ntype=kind.value)
g.add_edge(claim, n, rel=kind.value)
for pid in rec.provider_ids:
n = _node(NodeType.PROVIDER, pid)
g.add_node(n, ntype=NodeType.PROVIDER.value)
g.add_edge(claim, n, rel="provider")
for addr in rec.addresses:
n = _node(NodeType.ADDRESS, addr)
g.add_node(n, ntype=NodeType.ADDRESS.value)
g.add_edge(claim, n, rel="address")
for phone in rec.phones:
n = _node(NodeType.PHONE, phone)
g.add_node(n, ntype=NodeType.PHONE.value)
g.add_edge(claim, n, rel="phone")
log.info("graph.built", nodes=g.number_of_nodes(), edges=g.number_of_edges())
return g
Community detection and ring scoring operate on the constructed graph. Scoring counts distinct claimants rather than raw edges, because a ring’s defining feature is many separate people converging on shared infrastructure:
from networkx.algorithms.community import greedy_modularity_communities
@dataclass(frozen=True, slots=True)
class RingScore:
community_id: str
node_count: int
distinct_claimants: int
density: float
top_broker: str | None
broker_betweenness: float
ring_score: float
def detect_communities(g: nx.MultiGraph, *, min_nodes: int) -> list[frozenset[str]]:
"""Partition the graph; keep only communities large enough to be a ring."""
simple = nx.Graph(g) # collapse parallel edges for modularity
communities = greedy_modularity_communities(simple)
return [frozenset(c) for c in communities if len(c) >= min_nodes]
def score_ring(g: nx.MultiGraph, community: frozenset[str], *, community_id: str) -> RingScore:
sub = nx.Graph(g.subgraph(community))
claimants = [n for n in community if g.nodes[n]["ntype"] == NodeType.CLAIMANT.value]
density = round(nx.density(sub), 4)
bc = nx.betweenness_centrality(sub) if sub.number_of_nodes() > 2 else {}
top_broker, broker_bc = (max(bc.items(), key=lambda kv: kv[1]) if bc else (None, 0.0))
# weighted structural score: dense + many distinct claimants + a strong broker
ring_score = round(
0.5 * density
+ 0.3 * min(len(claimants) / 10.0, 1.0)
+ 0.2 * broker_bc,
4,
)
log.info("graph.ring_scored", community_id=community_id, nodes=len(community),
distinct_claimants=len(claimants), density=density, ring_score=ring_score)
return RingScore(community_id, len(community), len(claimants), density,
top_broker, round(broker_bc, 4), ring_score)
Because build_graph, detect_communities, and score_ring are each pure functions of their inputs (given a fixed node ordering into the modularity step), a nightly rebuild on the same claim snapshot reproduces the same communities and the same scores — which is what makes a ring flag replayable when an investigator or regulator asks how it was reached.
Configuration & Tuning
Permalink to "Configuration & Tuning"The thresholds are configuration, not code, and the density flag is the one that most affects the false-positive rate. A busy but legitimate regional body shop can produce a moderately dense community; the discriminating signal is density combined with a high distinct-claimant count and a strong broker node, which is why the score is a weighted blend rather than a single density cutoff.
| Setting | Env var | Default | Notes |
|---|---|---|---|
| Density flag | RING_DENSITY_THRESHOLD |
0.6 |
Community subgraph density above this contributes strongly |
| Minimum ring size | RING_MIN_NODES |
4 |
Smaller communities are noise, not a ring |
| Broker centrality floor | RING_BROKER_BC_MIN |
0.25 |
Betweenness below this is not treated as a broker node |
| Rule/weight version | GRAPH_RULE_VERSION |
v0.9 |
Pinned per rebuild; recorded on every flagged subgraph |
Carrier and line-of-business overrides layer on top of the base weights — a personal-injury book weights the shared-provider and phone signals more heavily than a property book — resolved at graph-build time into an immutable scoring configuration. Never tune the weights inside the scoring loop; a mid-run change makes two communities in the same rebuild incomparable.
Compliance Integration
Permalink to "Compliance Integration"A ring flag is a serious signal that can trigger investigation, so its provenance must be fully reconstructable. Persist the flagged subgraph — its node and edge set, the RingScore, the graph snapshot id, and the rule version — to append-only storage, so an examiner or an investigator can see exactly which claims, providers, and shared identifiers formed the ring and what structural features scored it. This is the same discipline the core architecture and compliance mapping domain applies platform-wide, and it matters especially here because a network flag can implicate people who share nothing but a coincidental identifier. The persisted subgraph, not a bare score, is what lets a human confirm or clear the flagged ring before any adverse action, satisfying the fair-treatment expectations that govern automated fraud referral.
Fail-closed here means a graph-build error or a missing-identifier record never silently drops a claim from the graph — that would hide a ring, not surface one. A GraphBuildError routes the record to a data-quality queue for identifier resolution, and the affected claim is scored conservatively until it can be placed.
Failure Modes & Troubleshooting
Permalink to "Failure Modes & Troubleshooting"Unresolved identifiers fragment a real ring
Two spellings of the same provider become two nodes, so a single ring splits into disconnected pieces that each fall below the minimum size and go unflagged.
Fix: run entity resolution upstream so provider, address, and phone identifiers are canonical before the graph is built; monitor the rate of near-duplicate entity nodes as a data-quality signal.
A hub provider swallows the whole graph into one community
A dominant legitimate provider connects to so many claims that modularity lumps a huge fraction of the book into a single community that is meaningless to score.
Fix: cap or down-weight ultra-high-degree provider nodes before community detection, or project the graph onto the claimant layer so the community structure reflects claimant clustering rather than one hub’s reach.
Non-deterministic communities across rebuilds
The same snapshot yields different communities on two runs because the modularity algorithm’s result depended on input node ordering.
Fix: build the graph from a sorted, stable record ordering and pin the algorithm and library version, so a rebuild on identical input reproduces identical communities and the flag is replayable.
Density flag fires on a busy but legitimate shop
A high-volume regional repairer produces a moderately dense community and is flagged as a ring.
Fix: score density in combination with the distinct-claimant count and broker betweenness rather than on density alone, and calibrate the weights against known-legitimate high-volume providers.
Inline graph traversal blows the synchronous latency budget
Computing centrality per FNOL in the triage hot path pushes evaluation past its SLO.
Fix: rebuild the graph and cache per-claim structural features on a schedule; the synchronous path reads a precomputed feature, never traverses the graph inline.
Focused Guides in This Section
Permalink to "Focused Guides in This Section"The construction step rewards a concrete, runnable treatment. The guide on building claim-provider graphs with NetworkX works end to end through a bipartite claim-to-provider graph, its projection onto the provider layer, and the degree, betweenness, and connected-component computations that flag provider rings — with the pytest patterns that pin the projection and the persistence of a flagged subgraph with its audit context. Read it when you are implementing the build_graph and scoring steps against a real provider network.
Related
Permalink to "Related"- Fraud Pattern Detection — the parent domain this network detector belongs to
- Building Claim-Provider Graphs with NetworkX — the concrete bipartite-graph construction and projection recipe
- Duplicate Claim Detection at Ingestion Scale — where split-claim edges originate before feeding the graph
- Statistical Anomaly Scoring — consumes the structural features as fraud-score inputs
- SIU Referral Orchestration — where a high-scoring ring is escalated to investigators
- Claims Triage & Routing Engines — the pipeline whose per-claim scores this layer enriches