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.

Entity graph construction, community detection, and ring scoring feeding the fraud score Claim records are decomposed into typed nodes — claims, claimants, providers, adjusters, addresses, phones — connected by edges wherever a claim references an entity. A worked example shows a dense cluster: three claimants each linked to two shared providers and a common phone number, forming a near-complete subgraph, contrasted with a sparse legitimate provider connected diffusely to many unrelated claimants. A pipeline processes the graph in three stages: build_graph from claim records, community detection to partition nodes into candidate rings, and a ring-scoring function computing density, distinct-claimant count, betweenness centrality, and shared-identifier reuse. The resulting structural features are emitted as a fraud-score contribution and the flagged subgraph is persisted with audit context. Dense subgraph · candidate ring C1 C2 C3 P P 3 claimants · 2 shared providers · 1 shared phone Sparse · legitimate provider many unrelated claimants · low internal density claimant provider phone/address claim build_graph() claim records → typed nodes + edges networkx MultiGraph detect_communities() greedy modularity partition → candidate rings score_ring() density · betweenness distinct claimants · reuse → ring_score fraud-score contribution structural features per claim community_id · ring_score centrality · shared-id count → statistical anomaly scoring persisted flagged subgraph node/edge set · ring_score snapshot id · rule_version decided_at (ISO 8601) → SIU referral

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.

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.

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.