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 oncepython -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:
python data/make_data.pyThat writes four CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical
because the seed is fixed):
- Part A —
accounts_a.csv(1,308 accounts;account_id, n_transactions, signup_days_ago) andshared_ids_a.csv(account_id, id_type, id_value). - Part B —
accounts_b.csv(3,255 accounts) andshared_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:
python part_a/find_rings.pyPrefer 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)
| Function | What it does here | Docs |
|---|---|---|
df.groupby("id_value") | Find the accounts that share each identifier | groupby |
itertools.combinations(xs, 2) | Enumerate account pairs within a shared identifier | combinations |
nx.Graph() / add_edges_from | Build the account graph | Graph |
nx.connected_components(G) | Pull out the rings as connected clusters | connected_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# TODOit 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 keysaccounts,achieved_metric(you can leaveachieved_metricnull; the grader scores you).
Then:
python part_b/starter.pypytest part_b/test_solution.py -qIf
pytestreports everything asskipped, it just means the output files don’t exist yet — runpython part_b/starter.pyfirst (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:
pytestreports 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.pyreproduces 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.
| Field | Value |
|---|---|
| Dataset | Synthetic account/identifier graph with injected fraud rings (Part A 1,308, Part B 3,255 accounts) |
| Type | Synthetic |
| Generated by | data/make_data.py (this repo), seeds 20260718 (A) and 20260719 (B) |
| Source URL | Not applicable — generated, not fetched |
| License | Not applicable — synthetic, no third-party rights |
| Access / generation date | 2026-07-18 |
| Redistribution | Permitted — synthetic; output regenerated from the seeded script, never committed |
| robots.txt checked | Not applicable — no scraping |
| ToS URL | Not 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