Skip to content

Fraud Rings: the collusion you can only see in the graph

The data in this project is synthetic — generated locally by data/make_data.py, with no external source. That is deliberate: real fraud-ring data is PII-laden and unlabelled, and you can only score ring detection if you know who truly colludes. No signup, no downloads, no GPU.

Before you start

New to graph-based fraud detection? Start here.

A single fraudster is hard to catch from their own behaviour — they look like a normal customer. But fraud is often a ring: a group of accounts run by the same operation, reusing the same devices, cards, shipping addresses, and IPs among themselves. No single account looks alarming; the giveaway is that they’re connected — they share identifiers with each other far more than chance allows.

The tool for “who is connected to whom” is a graph: accounts are nodes, and an edge means two accounts share an identifier. Ring members form a dense cluster (they share several things with each other); unrelated accounts are isolated, or joined only by an incidental link (two strangers on the same coffee-shop Wi-Fi). Find the dense clusters and you find the rings.

The trap is that per-account rules — “flag accounts with lots of connections,” “flag high transaction counts” — fail. A busy legit account on a shared office IP has many connections; a member of a small ring has few. What separates a ring is structure, not any one number.

What you’ll learn: build a shared-identifier graph with networkx, see why per-account features and raw degree miss rings, weight edges by how many identifiers accounts share, and pull out the rings as connected components. Section 3 lists exactly what you’ll be able to do.

New to Python too? See Start Here — it starts from zero, and shows how to run this in your browser with nothing to install.

1. The Brief

You’re handed a set of accounts and a log of the identifiers they’ve used (devices, cards, addresses, IPs), and asked to find the collusion: which accounts are part of a fraud ring? You’ll show why the obvious per-account screens fail, then use the graph structure to pull the rings out cleanly.

Shared-identifier graphs are exactly how payment processors, marketplaces, and banks surface organized fraud, fake-review farms, and bonus-abuse rings. The value is catching a whole ring at once — including the members who individually look spotless — and the same technique powers any “entities linked by shared attributes” problem. It’s also a clean, laptop-sized use of a graph library on a problem where the graph is the insight.

2. Difficulty & time

Difficulty 6 / 10. It’s a multi-step graph pipeline — build the graph, weight edges by shared-identifier count, filter, take connected components — plus the judgment that structure beats per-account features, and handling the incidental-hub confounder. Calibrated against the ledger (G10): a peer of vendor-dedupe (6), another build that composes several steps and a threshold decision. It sits above hidden-communities and analytics-with-duckdb (both 5) — it is a distinct graph task from hidden-communities (that one finds modularity communities in a social network and scores against ground truth with NMI; this one detects dense rings in a shared-attribute fraud graph and scores flagged accounts with F1). It’s below recommend-from-scratch (7). The networkx calls are short; the difficulty is the modelling choices around them.

Time is separate from difficulty: Part A is about 75 minutes; Part B is 2–3 hours of your own work.

3. What you’ll be able to do after

  • Build a shared-identifier graph from an account/identifier log with networkx.
  • Explain why per-account rules fail — degree and transaction velocity over-flag busy hubs and miss small rings.
  • Weight and filter edges by how many identifiers two accounts share, so incidental single- identifier links drop out and real collusion remains.
  • Extract rings as connected components and flag their members — catching accounts that look innocent on their own.

The finished result

By the end of Part A you’ll have watched three rules run on the same 1,308 accounts — and only the graph structure catches the rings:

per-account velocity: best F1 0.32 (ring 25.1 vs legit 19.9 transactions -- nearly identical)
graph degree (naive): best F1 0.53 (over-flags shared-IP hubs, misses small rings)
weight edges by shared-id count (>=2) -> connected components: F1 1.00 (108 accounts, 12 rings)

Structure flags every ring member — including the ones who look spotless account-by-account. In Part B the same pipeline holds at F1 ~1.0 on 3,255 accounts, where the best per-account degree rule tops out around 0.52.

4. Prereqs & time box

You can use a pandas DataFrame and a for loop; no graph-theory background needed (the primer covers nodes, edges, and components). You’ll use networkx, installed via requirements.txt.

Where to write and run code

Two ways to run this project — pick either:

  • In your browser (no install). If the project page shows a ▶ Run button, click it to run Part A right here — nothing to set up. Part A uses pandas, numpy, and networkx, which run in the browser.
  • On your computer. The one-time setup — installing Python, downloading the code from the course site (no GitHub, no git), opening a terminal, creating a virtual environment, installing the requirements, and running Part A step by step — is covered once in Start Here. Come back here once python -c "import networkx" runs without error. Part B (writing your own solution) needs this local setup.

Time box

  • Part A — Guided Build: 60–90 minutes. Hard cap.
  • Part B — Your Turn: 2–4 hours. Hard cap.

5. Setup & data

The data is synthetic and generated locally — see the banner above and SOURCES.md for full provenance. One command creates everything:

Terminal window
python data/make_data.py

That writes four CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical because the seed is fixed):

  • Part Aaccounts_a.csv (1,308 accounts; account_id, n_transactions, signup_days_ago) and shared_ids_a.csv (account_id, id_type, id_value).
  • Part Baccounts_b.csv (3,255 accounts) and shared_ids_b.csv.

Which accounts belong to a ring is not in the files — the grader recovers it from the seed.

6. Part A — Guided Build

You’ll work the 1,308-account Part A data. Run the whole thing, or (better) one # %% cell at a time — see Start Here:

Terminal window
python part_a/find_rings.py

Prefer the browser? If this page has a ▶ Run button, click it instead of the command above — Part A runs in your browser with nothing to install. The STEP output and every checkpoint below are identical either way.

The full, commented script is in part_a/find_rings.py. The spine:

Step 1 — Per-account features barely differ. Compare ring vs legit transaction counts; try every velocity threshold.

Checkpoint: ring members transact only slightly more (25 vs 20); the best transaction- count threshold scores F1 ~0.32. One at a time, ring members look ordinary.

Step 2 — Shared-identifier graph + degree (the naive graph rule). Edge = share ≥1 identifier; flag high-degree accounts.

Checkpoint: better, but the best degree threshold caps at F1 ~0.53 — it over-flags public-IP hubs and misses small rings.

Step 3 — Weight edges by shared-identifier count. Keep only edges where two accounts share ≥2 identifiers.

Checkpoint: most edges vanish (here ~3,364 → ~414) — the incidental single-identifier links go, the dense ring links stay.

Step 4 — Connected components → rings. Flag accounts in components of ≥4.

Checkpoint: F1 ~1.0. Structure finds what per-account features and raw degree could not.

Step 5 — Inspect a ring and save to part_a/rings_a.csv.

Why this and not that

  • Why the graph, not per-account features. A ring member’s own numbers are unremarkable by design — the operator makes them look normal. The signal is relational: who they share identifiers with. Only a graph exposes it.
  • Why weight by shared-identifier count (≥2), not just connectivity. A single shared IP links strangers (a shared office, a VPN). Requiring two or more shared identifiers keeps the dense, deliberate reuse that defines a ring and drops the coincidences — the difference between finding rings and flagging a whole coffee shop.
  • Why connected components, not degree. Degree is a per-node number and inherits the hub problem. Components capture the group: a ring is a set of accounts all reachable through shared identifiers, whatever each member’s individual degree.

Functions you’ll meet (and where to read more)

FunctionWhat it does hereDocs
df.groupby("id_value")Find the accounts that share each identifiergroupby
itertools.combinations(xs, 2)Enumerate account pairs within a shared identifiercombinations
nx.Graph() / add_edges_fromBuild the account graphGraph
nx.connected_components(G)Pull out the rings as connected clustersconnected_components
G.degree()The per-account degree (the naive rule, to beat)degree

If something looks off

  • I expected a fraud score for each account — where is it? There isn’t one, and that’s the lesson. A ring member’s own numbers look ordinary: the best per-account transaction rule scores only F1 ~0.32 (Step 1) and the best raw-degree rule ~0.53 (Step 2). Rings surface only as dense components of the shared-identifier graph (Step 4), so the verdict is per-group — an account is flagged for the tight cluster it sits in, not for any number it carries alone.
  • A checkpoint number doesn’t match. The data is seeded, so the numbers are exact. Re-run python data/make_data.py (you may have skipped or edited it), then re-run Part A.
  • Setup problems (Python not found, pip, FileNotFoundError, re-activating the venv): see the troubleshooting list in Start Here.

7. Part B — Your Turn

Part B is the optional “your turn” half — a guided fill-in-the-blank, not a blank page. You get a working starter (part_b/starter.py) that already handles the easy version; your job is to finish the # TODO it marks out. More open than Part A’s step-by-step, but you are never staring at an empty file. If you finished Part A and want to prove the skill, this is where it happens. New to Python? It’s completely fine to stop after Part A and come back later.

Same core skill, more accounts, and now blind and scored. Flag the ring accounts in the 3,255-account Part B data. There’s no truth file — the grader has it. The starter (part_b/starter.py) uses the per-account degree rule and scores ~0.5, below the floor.

The transfer: in Part A you could confirm the method against the labels. Here you can’t peek — you build the graph, keep the dense links, take components, and hand in the flags. (Note: the ground-truth helper regenerates the dataset, so if you use it, call it once, not per row.)

Acceptance criteria (checkable)

Your solution writes into part_b/out/:

  • flagged.csv — one row per account: account_id, flagged_ring (1 if in a ring).
  • report.json — with keys accounts, achieved_metric (you can leave achieved_metric null; the grader scores you).

Then:

Terminal window
python part_b/starter.py
pytest part_b/test_solution.py -q

If pytest reports everything as skipped, it just means the output files don’t exist yet — run python part_b/starter.py first (once your solution fills in the TODOs), then re-run.

You’re done when the tests pass: your flags reach F1 ≥ 0.80 against the true ring members (the reference reaches ~1.0). The per-account degree rule tops out ~0.5 and fails.

Constraints — what must be true of your solution, not how to get there

  • Use the graph structure, not a per-account threshold (degree or velocity).
  • Weight edges by shared-identifier count so incidental single-identifier links don’t merge strangers into a “ring.”
  • Flag whole components, and emit a verdict for every account.

Hints

Hint 1 — count shared identifiers per pair

Group the links by id_value; every group is a set of accounts sharing that identifier. Count, per account pair, how many identifiers they share.

Hint 2 — keep only the dense links

Build the graph from pairs that share ≥2 identifiers. That drops the single-shared-IP links between strangers and keeps the ring interconnections.

Hint 3 — components are the rings (still not the code)

nx.connected_components gives you the clusters. Flag the accounts in any component of ≥4 — a size a coincidence rarely reaches but a ring does.

8. Self-check

You don’t need the answer key. You’re done when all of these hold:

  • pytest reports F1 ≥ 0.80 (aim for ~1.0 — the rings separate cleanly once you weight edges).
  • Your flagged accounts form a handful of tight clusters, not a scatter of high-degree hubs.
  • Re-running from python data/make_data.py reproduces the same numbers — the data is seeded.

And the tell-tale: if your flagged set is dominated by accounts sharing one popular IP, you’re catching a hub, not a ring — raise the shared-identifier requirement.

9. Stretch

  • Density, not just size. Some large components may be loose. Score each component’s density (edges vs possible edges) and flag by density, not only size — closer to how real systems rank suspicious clusters.
  • Bipartite projection. Model accounts and identifiers as a bipartite graph and use networkx’s bipartite projection to build the account graph; compare to the pairwise-count approach.
  • (Genuinely hard) Weight by rarity. An identifier shared by thousands (a default user-agent) is weak evidence; one shared by three accounts is strong. Down-weight common identifiers (an IDF-style weight) and show it separates rings from hubs without the hard ≥2 rule.

10. Ship it

Put this in a portfolio repo and it counts. In that repo’s README:

  • State the finding in one sentence a risk lead gets: “these N accounts are three rings sharing devices and cards — here’s the graph that shows it, and why velocity screens missed them.”
  • Show the progression: velocity F1, degree F1, and component F1 on the same data — the jump is the story.
  • Include the graph-building code and one paragraph on the public-IP hub trap and why you weight edges. That paragraph is what signals you understand graph fraud detection, not just networkx.

Keep make_data.py and the tests in the repo so anyone can reproduce your numbers from zero.

11. Sources

Rendered from SOURCES.md.

FieldValue
DatasetSynthetic account/identifier graph with injected fraud rings (Part A 1,308, Part B 3,255 accounts)
TypeSynthetic
Generated bydata/make_data.py (this repo), seeds 20260718 (A) and 20260719 (B)
Source URLNot applicable — generated, not fetched
LicenseNot applicable — synthetic, no third-party rights
Access / generation date2026-07-18
RedistributionPermitted — synthetic; output regenerated from the seeded script, never committed
robots.txt checkedNot applicable — no scraping
ToS URLNot applicable — no external source

Synthetic data teaches the mechanic; it does not prove a finding about the real world. The rings were injected on purpose — recovering them demonstrates the technique, not a claim about any real accounts. Shared identifiers have innocent explanations too (see SOURCES.md).

Next up

Finished this one? Continue the Open-Source Libraries track:

Property-Based Testing: let Hypothesis find the input you didn’t think of  ·  Browse all courses