How to Detect Procurement Fraud Rings in Python With NetworkX and SQLite and Measure Each Rule’s Precision and Recall
Build four fraud detectors in Python with SQLite and NetworkX (duplicate invoices, vendors linked by shared details, mule accounts and loops of money) on a synthetic ledger that hides the fraud and...
A single payment can look perfectly normal and still be one move in a scheme. Twenty-five vendors that each bill just under the approval limit and all get paid into one bank account. A loop of transfers that sends the same money back to where it started. An invoice submitted twice with its number retyped. No single record trips an alarm, because the evidence is not in any one record. It is in how the records connect.
Table Of Content
- The ideas you need first
- What you will build, and what you will not
- Prerequisites
- Step 1: Build a ledger with the fraud hidden in it
- What is hidden, and what only looks suspicious
- The generator
- Step 2: Find duplicate invoices with SQL, because not every pattern needs a graph
- Three rules, from naive to careful
- Run the three rules against the answer key
- Reading the result
- Gotchas for this step
- Step 3: Link vendors that share a phone number, an address or a bank account
- How it works: a bipartite graph and its components
- Run it and watch one popular value ruin the result
- Reading the result
- Gotchas for this step
- Step 4: Spot accounts that many vendors funnel money into
- Reading the result
- Gotchas for this step
- Step 5: Find money that comes back
- First attempt: look for cycles by shape
- Reading the result
- Better: require timing and matching amounts
- What the two limits each buy, and why refunds are not loops
- Why a triangle of shared accounts is not a money loop
- Gotchas for this step
- Step 6: Put the four detectors together
- Reading the result
- Step 7: Test the rules, plant bugs, and change the seed
- Hand-built test cases
- Break the code on purpose
- Try other seeds
- Common mistakes and how to avoid them
- Check that everything works end to end
- Where to go next
- Sources and further reading
In this tutorial you will build four small detectors in Python, one for each of those patterns, and run them on a synthetic ledger where you already know every answer. The ledger holds the fraud and also its innocent look-alikes: a weekly cleaning contract that bills the same amount every week, 60 firms that use the same registered-agent address, a payment platform that pays out to 45 legitimate vendors. You will score each rule on both sides with precision (how much of what it flagged was real) and recall (how much of the real fraud it flagged), because a rule that catches everything and buries the reviewer in false alarms is not a good rule.
The idea comes from a freeCodeCamp walkthrough published on October 30, 2026, How to Detect Financial Fraud Patterns with Knowledge Graphs: A Production Guide. It models accounts-payable data as a graph in Neo4j, hides five known patterns in a generated dataset and reports how many of them its queries find. This tutorial borrows that five-pattern frame and differs in two ways. It needs no Docker, because everything runs in memory with SQLite and NetworkX. And it adds the half of the evaluation that decides whether a reviewer will trust the output. The article’s validator prints recall, and its sample output for the vendor-collusion query reads “89 high-risk vendors flagged” in a dataset the article describes as 100 vendors with one injected cluster of 4. I did not run the article’s repository, so I cannot say how many of those 89 are fraud. The point is that recall alone cannot tell you.
This is also a chance to see where a graph earns its place and where it does not. Of the four detectors you will build, two need a graph (linking vendors through shared details, and finding loops of money), one is a plain SQL aggregate (accounts that many vendors pay into) and one is a SQL self-join (duplicate invoices). Knowing which is which keeps a fraud system simple.
The ideas you need first
A few terms, defined before you meet them in code:
- Graph. A set of nodes (things) and edges (links between things). Here a node is a vendor, a bank account, a phone number or an address, and an edge says “this vendor uses this account”.
- Bipartite graph. A graph with two kinds of nodes where edges only run between the kinds. Vendors sit on one side and the details they use (account, phone, address) on the other. Two vendors are connected when they are joined to the same detail node.
- Degree. The number of edges a node has. For a phone-number node it is the number of vendors that list that number.
- Connected component. A group of nodes that can all reach each other by following edges. Vendors in one component are linked directly or through a chain of shared details.
- Directed graph and cycle. A directed graph gives every edge a direction, such as “account A sent money to account B”. A cycle is a path that ends where it began. The NetworkX documentation defines a simple cycle as “a closed path where no node appears twice”.
- Supernode. A node with so many edges that it connects everything. A registered-agent address used by 60 unrelated firms is one. The freeCodeCamp article describes the same failure and suggests degree limits and allow-lists.
- Ground truth. The answer key. The generator in this tutorial writes down which records are fraud, so every rule can be graded.
- True positive, false positive, false negative. A flag on real fraud, a flag on innocent activity, and real fraud nobody flagged. Precision is true positives divided by everything flagged. Recall is true positives divided by all real fraud. The scikit-learn documentation gives the same two definitions.
What you will build, and what you will not
By the end you will have a small Python project that:
- generates a seeded ledger of 554 vendors, 6,184 invoices and payments and 4,091 bank transfers, with fraud and look-alikes hidden in it, plus an answer key;
- finds duplicate invoices with three SQL rules and shows why only the third is any good;
- links vendors that share an account, phone number or address using connected components, and survives a supernode;
- flags accounts that many vendors funnel money into, using fan-in and the shape of the amounts;
- finds loops of money with a time-aware cycle search, after showing how many loops a plain cycle search drowns you in;
- prints one report with precision and recall, and comes with 26 tests, 12 planted bugs to prove the tests notice, and a sweep over 8 random seeds.
Be clear about the limits before you start. The data is synthetic, and I wrote both the disguises and the rules, so a perfect score for the careful duplicate rule shows that it matches the disguises I thought of, not that real fraud looks like them. Real recall will be lower. A flag is also a lead for a human reviewer, not a verdict: the lab includes two sister companies that legitimately share a phone number, and the cluster rule flags them. Finally, the article’s fifth pattern, approvals outside a department’s authority, is a CASE expression that checks the approver’s department against a list of allowed invoice categories. It needs no graph and I did not build it.
Prerequisites
- Python 3.13. Everything here ran on Windows 11 with Python 3.13.14 and the SQLite 3.50.4 that ships with it. I did not test older versions. The code uses only the standard library plus NetworkX, so it should run unchanged on Linux and macOS, but the outputs below were produced on Windows and the timings will differ on your machine.
- Packages:
networkx3.7 andpytest9.2.0. - Basic Python (functions, loops, dictionaries) and basic SQL (
SELECT,JOIN,GROUP BY). No Docker, database server or cloud account is needed, and the whole lab runs in about a minute.
Create a folder, a virtual environment and install the two packages. On Windows (PowerShell):
mkdir fraud_lab
cd fraud_lab
python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install networkx==3.7 pytest==9.2.0
On Linux or macOS the activation line is source .venv/bin/activate. Every file below goes in this one folder, every file starts with a comment that names it, and every command runs from that folder with the environment active.
Step 1: Build a ledger with the fraud hidden in it
Real payment data is private, and even if you had it you would not know which records are fraud. A generator solves both problems: it hides patterns you chose, writes an answer key, and lets you rerun the whole experiment from a seed. The ledger has four tables in SQLite: vendors (who gets paid, with an address, a phone number and a bank account), invoices, payments (which account each invoice was paid into) and transfers (a sample of bank-to-bank movements, the kind of feed a bank or transaction-monitoring system would give you). Amounts are stored as whole cents, the same habit our guide to handling money in Python explains. All names, phone numbers and addresses are invented.
What is hidden, and what only looks suspicious
| In the ledger | How many | Why it is there |
|---|---|---|
| Duplicate invoices | 20 pairs | 6 exact copies, 6 with the number reformatted, 4 with a slightly different amount, 4 with a new number but the same purchase order |
| Ring A, vendors linked by shared details | 5 vendors | A chain: a shared phone, then a shared account, then a shared address, then a second shared phone |
| Ring B, vendors sharing an account | 4 vendors | One bank account, and all four are registered at the same agent address as many honest firms |
| Ring M, mule shells | 25 vendors | All paid into one account, each invoice between $4,600 and $4,999 against a $5,000 sign-off limit |
| Round trips of money | 3 loops of 3, 4 and 5 accounts | Each loop runs 2 or 3 times, one day per hop, with 1 percent skimmed at every hop |
| Look-alike: agent address | 60 firms | Unrelated honest firms that share one registered-agent address |
| Look-alike: co-working space | 12 firms | Honest firms in one shared office |
| Look-alike: payment platform | 45 vendors | Honest vendors that all get paid through one platform account |
| Look-alike: sister companies | 2 vendors | Two related honest firms that share a phone number |
| Look-alike: recurring bills | 6 weekly and 10 monthly vendors | The same amount on a schedule, with a new invoice number each time |
| Look-alike: refunds | 30 pairs | Account A pays B, and B returns the same amount a few days later |
The generator
Save the code below as gen.py. It is long, so it comes in four pieces that you paste one after another into the same file. Read each piece and the explanation after it, because the choices it makes are the things the detectors will be tested against.
The first piece sets the constants, a seeded random generator and the vendors. Notice how the look-alike groups and the three fraud rings are built by overriding one detail of an otherwise ordinary vendor: address=AGENT puts a vendor at the shared agent address, account=PLATFORM routes its payments through the platform account, and the ring A lines copy one phone, account or address from one vendor to the next.
# gen.py: build fraud.db and truth.json for the lab. Usage: python gen.py [seed]
import json
import random
import sqlite3
import sys
from datetime import date, timedelta
from pathlib import Path
HERE = Path(__file__).parent
SEED = int(sys.argv[1]) if len(sys.argv) > 1 else 7
START = date(2026, 1, 1)
DAYS = 300 # the lab covers 300 days
AGENT = "100 Agent Way, Dover, DE" # a registered-agent address many legitimate firms use
COWORK = "7 Commons Row, Austin, TX" # a co-working space
PLATFORM = "ACC-PLATFORM-01" # a payout account run by a payment platform
MULE = "ACC-MULE-01" # the account the fraud ring funnels money into
rng = random.Random(SEED)
WORDS = ["Alder", "Birch", "Cedar", "Delta", "Ember", "Falcon", "Granite", "Harbor", "Iris", "Juniper",
"Kestrel", "Lumen", "Maple", "Nimbus", "Onyx", "Pioneer", "Quartz", "Ridge", "Summit", "Tidal",
"Union", "Vertex", "Willow", "Zenith"]
KINDS = ["Supply", "Logistics", "Consulting", "Systems", "Services", "Partners", "Works", "Group"]
STREETS = ["Oak", "Mill", "Lake", "Hill", "Pine", "Cherry", "Main", "Park", "River", "Union"]
CITIES = ["Dallas, TX", "Denver, CO", "Boston, MA", "Seattle, WA", "Atlanta, GA", "Phoenix, AZ",
"Chicago, IL", "Miami, FL"]
used = {"name": set(), "address": set(), "phone": set()}
def unique(kind, make):
while True:
value = make()
if value not in used[kind]:
used[kind].add(value)
return value
vendors, invoices, payments = [], [], []
account_count = [0]
po_count = [0]
def new_account():
account_count[0] += 1
return f"ACC-V{account_count[0]:04d}"
def new_vendor(**override):
vendor = {
"vendor_id": f"V{len(vendors) + 1:04d}",
"name": unique("name", lambda: f"{rng.choice(WORDS)} {rng.choice(WORDS)} {rng.choice(KINDS)}"),
"tax_id": f"{rng.randint(10, 99)}-{rng.randint(1000000, 9999999)}",
"address": unique("address", lambda: f"{rng.randint(10, 9899)} {rng.choice(STREETS)} St, {rng.choice(CITIES)}"),
"phone": unique("phone", lambda: f"+1-555-{rng.randint(1000000, 9999999)}"),
"account": new_account(),
"created": (START + timedelta(days=rng.randint(0, 60))).isoformat(),
}
vendor.update(override)
vendors.append(vendor)
return vendor
def add_invoice(vendor, when, cents, number, po=None):
po_count[0] += 1
invoice = {"invoice_id": len(invoices) + 1, "vendor_id": vendor["vendor_id"], "inv_no": number,
"po_ref": po or f"PO-{po_count[0]:06d}", "amount_cents": cents, "inv_date": when.isoformat()}
invoices.append(invoice)
return invoice
def initials(vendor):
return "".join(word[0] for word in vendor["name"].split()).upper()
def money():
dollars = rng.lognormvariate(8.0, 0.8) # median about $3,000
return round(dollars) * 100 if rng.random() < 0.4 else round(dollars * 100)
def ordinary_invoices(vendor, count=None):
count = count or rng.randint(4, 18)
seq = rng.randint(100, 700)
for day in sorted(rng.randrange(DAYS) for _ in range(count)):
seq += rng.randint(1, 5)
add_invoice(vendor, START + timedelta(days=day), money(), f"{initials(vendor)}-{seq:05d}")
# ---- vendors: 520 legitimate (with three look-alike groups) and 34 in three fraud rings
plain = [new_vendor() for _ in range(520 - 60 - 12 - 45)]
agent_group = [new_vendor(address=AGENT) for _ in range(60)]
cowork_group = [new_vendor(address=COWORK) for _ in range(12)]
platform_group = [new_vendor(account=PLATFORM) for _ in range(45)]
plain[1]["phone"] = plain[0]["phone"] # two sister companies share one phone
weekly, monthly = plain[2:8], plain[8:18]
ring_a = [new_vendor() for _ in range(5)] # a chain: phone, account, address, phone
shared_phone_1, shared_phone_2 = "+1-555-0100101", "+1-555-0100202"
shared_account_1, shared_address = new_account(), "88 Quiet Rd, Suite 4, Reno, NV"
ring_a[0]["phone"] = ring_a[1]["phone"] = shared_phone_1
ring_a[1]["account"] = ring_a[2]["account"] = shared_account_1
ring_a[2]["address"] = ring_a[3]["address"] = shared_address
ring_a[3]["phone"] = ring_a[4]["phone"] = shared_phone_2
account_b = new_account()
ring_b = [new_vendor(account=account_b, address=AGENT) for _ in range(4)] # shells at the agent address
ring_m = [new_vendor(account=MULE, created=(date(2026, 2, 1) + timedelta(days=rng.randint(0, 30))).isoformat())
for _ in range(25)]
The second piece writes invoices. Most vendors get 4 to 18 invoices with log-normally distributed amounts (a median of about $3,000), which looks roughly like real spend: many small bills and a long tail of large ones. The weekly and monthly vendors bill an identical amount on a fixed rhythm, each with its own new invoice number. The 25 mule shells bill just under the limit.
# gen.py (continued)
# ---- invoices
for vendor in plain[18:] + agent_group + cowork_group + platform_group + ring_a + ring_b:
ordinary_invoices(vendor)
for vendor in plain[:2]:
ordinary_invoices(vendor)
for vendor in weekly: # legitimate: the same amount every 7 days
cents, seq = round(rng.uniform(900, 2400)) * 100, rng.randint(100, 700)
for week in range(DAYS // 7):
seq += 1
add_invoice(vendor, START + timedelta(days=week * 7), cents, f"{initials(vendor)}-{seq:05d}")
for vendor in monthly: # legitimate: the same amount every 30 days
cents, seq = round(rng.uniform(1500, 9000)) * 100, rng.randint(100, 700)
for month in range(DAYS // 30):
seq += 1
add_invoice(vendor, START + timedelta(days=month * 30), cents, f"{initials(vendor)}-{seq:05d}")
for vendor in ring_m: # shells bill just under the $5,000 sign-off limit
seq = rng.randint(100, 700)
for day in sorted(rng.randrange(40, DAYS) for _ in range(rng.randint(3, 6))):
seq += 1
add_invoice(vendor, START + timedelta(days=day), rng.randint(460_000, 499_900), f"{initials(vendor)}-{seq:05d}")
The third piece plants the duplicates and writes a payment for every invoice. A duplicate copies a real invoice from a vendor and disguises it in one of four ways. Only the “rekeyed” kind keeps the purchase-order reference, which is how a re-keyed copy can still be recognized.
# gen.py (continued)
# ---- duplicate invoices: 6 exact, 6 re-formatted number, 4 amount drift, 4 re-keyed number with the same PO
duplicates = []
kinds = ["exact"] * 6 + ["format"] * 6 + ["amount"] * 4 + ["rekeyed"] * 4
victims = rng.sample(plain[18:], 20)
for vendor, kind in zip(victims, kinds):
original = rng.choice([i for i in invoices if i["vendor_id"] == vendor["vendor_id"]])
when = date.fromisoformat(original["inv_date"]) + timedelta(days=rng.randint(3, 10 if kind == "rekeyed" else 20))
prefix, digits = original["inv_no"].split("-")
number, cents, po = original["inv_no"], original["amount_cents"], None
if kind == "format":
number = rng.choice([f"{prefix.lower()}{int(digits)}", f"{prefix} {int(digits)}",
f"{prefix}-{int(digits)}", f"{prefix}-{int(digits):07d}"])
elif kind == "amount":
cents = round(cents * (1 + rng.uniform(0.002, 0.009)))
elif kind == "rekeyed":
number, po = f"{prefix}-{rng.randint(90000, 99999)}", original["po_ref"]
copy = add_invoice(vendor, when, cents, number, po)
duplicates.append([original["invoice_id"], copy["invoice_id"], kind])
# ---- payments: each invoice is paid to the vendor's account 20 to 40 days later
by_id = {v["vendor_id"]: v for v in vendors}
for invoice in invoices:
paid = date.fromisoformat(invoice["inv_date"]) + timedelta(days=rng.randint(20, 40))
payments.append({"payment_id": len(payments) + 1, "invoice_id": invoice["invoice_id"],
"account": by_id[invoice["vendor_id"]]["account"], "amount_cents": invoice["amount_cents"],
"paid_date": paid.isoformat()})
The fourth piece builds the bank feed and writes everything out. Background transfers favour a few busy accounts, because real money movement is lumpy. Then it adds the three round-trip rings and 30 refunds, creates the database, and saves truth.json, the answer key every later step reads.
# gen.py (continued)
# ---- a bank feed: random transfers between 700 accounts, 3 round-trip rings and 30 refunds
background = [f"ACC-T{i:04d}" for i in range(700)]
weights = [1 / (i + 1) ** 0.7 for i in range(700)] # a few busy accounts, many quiet ones
transfers = []
while len(transfers) < 4000:
src, dst = rng.choices(background, weights=weights, k=2)
if src != dst:
transfers.append((src, dst, rng.randrange(DAYS), round(rng.lognormvariate(7.5, 1.3) * 100)))
ring_accounts = rng.sample(background[100:], 12)
loops = [ring_accounts[0:3], ring_accounts[3:7], ring_accounts[7:12]]
for loop, laps in zip(loops, (3, 3, 2)):
for _ in range(laps):
first_day, base = rng.randrange(20, DAYS - 10), rng.randint(2_000_000, 6_000_000)
for hop in range(len(loop)): # one day per hop, 1 percent skimmed per hop
transfers.append((loop[hop], loop[(hop + 1) % len(loop)], first_day + hop, round(base * 0.99 ** hop)))
refunds = []
for _ in range(30): # legitimate: money goes out and comes back
a, b = rng.sample(background, 2)
day, cents = rng.randrange(DAYS - 5), round(rng.lognormvariate(7.5, 1.3) * 100)
transfers.append((a, b, day, cents))
transfers.append((b, a, day + rng.randint(1, 4), cents))
refunds.append([a, b])
transfers.sort(key=lambda t: (t[2], t[0], t[1]))
# ---- write the database and the answer key
db = HERE / "fraud.db"
db.unlink(missing_ok=True)
conn = sqlite3.connect(db)
conn.executescript("""
CREATE TABLE vendors(vendor_id TEXT PRIMARY KEY, name TEXT, tax_id TEXT, address TEXT, phone TEXT, account TEXT, created TEXT);
CREATE TABLE invoices(invoice_id INTEGER PRIMARY KEY, vendor_id TEXT, inv_no TEXT, po_ref TEXT, amount_cents INTEGER, inv_date TEXT);
CREATE TABLE payments(payment_id INTEGER PRIMARY KEY, invoice_id INTEGER, account TEXT, amount_cents INTEGER, paid_date TEXT);
CREATE TABLE transfers(transfer_id INTEGER PRIMARY KEY, src TEXT, dst TEXT, day INTEGER, amount_cents INTEGER);
CREATE INDEX invoices_vendor ON invoices(vendor_id);
CREATE INDEX payments_account ON payments(account);
""")
conn.executemany("INSERT INTO vendors VALUES(:vendor_id,:name,:tax_id,:address,:phone,:account,:created)", vendors)
conn.executemany("INSERT INTO invoices VALUES(:invoice_id,:vendor_id,:inv_no,:po_ref,:amount_cents,:inv_date)", invoices)
conn.executemany("INSERT INTO payments VALUES(:payment_id,:invoice_id,:account,:amount_cents,:paid_date)", payments)
conn.executemany("INSERT INTO transfers(src,dst,day,amount_cents) VALUES(?,?,?,?)", transfers)
conn.commit()
truth = {
"seed": SEED,
"duplicates": duplicates,
"rings": {"A": [v["vendor_id"] for v in ring_a], "B": [v["vendor_id"] for v in ring_b],
"M": [v["vendor_id"] for v in ring_m]},
"mule_account": MULE, "platform_account": PLATFORM,
"loops": loops, "refunds": refunds,
}
(HERE / "truth.json").write_text(json.dumps(truth, indent=1), encoding="utf-8")
edges = conn.execute("SELECT COUNT(*) FROM (SELECT DISTINCT src, dst FROM transfers)").fetchone()[0]
print(f"seed {SEED}: {len(vendors)} vendors, {len(invoices)} invoices, {len(payments)} payments, "
f"{len(transfers)} transfers ({edges} distinct account pairs)")
print(f"hidden in it: {len(duplicates)} duplicate invoices, {len(ring_a) + len(ring_b)} ring vendors in rings A and B, "
f"{len(ring_m)} mule shells, {len(loops)} money loops")
print(f"look-alikes: {len(agent_group)} firms at one agent address, {len(cowork_group)} in one co-working space, "
f"{len(platform_group)} paid through one platform account, {len(weekly)} weekly and {len(monthly)} monthly "
f"vendors, {len(refunds)} refunds")
Every rule below needs two small helpers: one that opens the database and answer key, and one that computes precision and recall. Save them as common.py.
# common.py: helpers shared by every step.
import json
import sqlite3
from pathlib import Path
HERE = Path(__file__).parent
def connect(path=HERE / "fraud.db"):
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
return conn
def load_truth(path=HERE / "truth.json"):
return json.loads(Path(path).read_text(encoding="utf-8"))
def score(flagged, positives):
"""Precision and recall of a flagged set against the set of true positives."""
flagged, positives = set(flagged), set(positives)
hits = len(flagged & positives)
return {"flagged": len(flagged), "hits": hits,
"precision": hits / len(flagged) if flagged else 0.0,
"recall": hits / len(positives) if positives else 0.0}
def show(label, result):
print(f"{label:<36} flagged {result['flagged']:>5} true {result['hits']:>3} "
f"precision {result['precision']:>6.1%} recall {result['recall']:>6.1%}")
Now generate the ledger:
python gen.py
You should see exactly this, because the seed is fixed:
seed 7: 554 vendors, 6184 invoices, 6184 payments, 4091 transfers (3853 distinct account pairs)
hidden in it: 20 duplicate invoices, 9 ring vendors in rings A and B, 25 mule shells, 3 money loops
look-alikes: 60 firms at one agent address, 12 in one co-working space, 45 paid through one platform account, 6 weekly and 10 monthly vendors, 30 refunds
Check the step before you move on: the folder should now hold fraud.db and truth.json. If gen.py ever fails with a permission error while deleting fraud.db, another program still has the database open; on Windows an open file cannot be deleted. Close it and run the generator again.
Step 2: Find duplicate invoices with SQL, because not every pattern needs a graph
A duplicate invoice is the same bill paid twice. Fraudsters and honest mistakes both produce them, and the disguises are mundane: the vendor resubmits the invoice with the number typed differently (ACM-00417, acm417, ACM 417), a freight line changes the total by a fraction of a percent, or someone keys a brand-new number for the same purchase order. This pattern needs no graph. It is a self-join: pair up the invoices of one vendor and keep the pairs that look alike.
Three rules, from naive to careful
Save the next file as dupes.py. It holds one helper and three rules written as SQL conditions.
# dupes.py: three ways to find duplicate invoices, from naive to careful.
import re
def normalize_invoice_no(raw):
"""'ACM-00417', 'acm417' and 'ACM 417' all become 'ACM417'."""
letters_and_digits = re.sub(r"[^A-Z0-9]", "", raw.upper())
return re.sub(r"^([A-Z]*)0+(?=[0-9])", r"\1", letters_and_digits)
PAIR_SQL = """
SELECT a.invoice_id AS first_id, b.invoice_id AS second_id
FROM invoices a JOIN invoices b
ON a.vendor_id = b.vendor_id AND a.invoice_id < b.invoice_id
WHERE {condition}
"""
# Rule 1: what a basic duplicate check does. Same vendor, same number, same amount.
EXACT = "a.inv_no = b.inv_no AND a.amount_cents = b.amount_cents"
# Rule 2: the logic of the Cypher query in the freeCodeCamp article, written in SQL.
# Same vendor, identical amount, dates at most :days apart.
SAME_AMOUNT = ("a.amount_cents = b.amount_cents "
"AND ABS(julianday(b.inv_date) - julianday(a.inv_date)) <= :days")
# Rule 3: normalized number or same purchase order, amount within a tolerance, dates close.
CAREFUL = ("ABS(julianday(b.inv_date) - julianday(a.inv_date)) <= :days "
"AND ABS(a.amount_cents - b.amount_cents) <= :tolerance * a.amount_cents "
"AND (norm_no(a.inv_no) = norm_no(b.inv_no) OR a.po_ref = b.po_ref)")
def find_pairs(conn, condition, days=30, tolerance=0.01):
"""Return the set of (first_id, second_id) invoice pairs that match the rule."""
conn.create_function("norm_no", 1, normalize_invoice_no, deterministic=True)
rows = conn.execute(PAIR_SQL.format(condition=condition), {"days": days, "tolerance": tolerance})
return {(row["first_id"], row["second_id"]) for row in rows}
Here is what each part does and why it is there:
normalize_invoice_noupper-cases the number, removes everything that is not a letter or a digit, then strips the zeros between the letters and the digits. All four spellings above becomeACM417, whileACM-00417andACM-00471stay different.PAIR_SQLjoins the invoices table to itself. It only pairs invoices of the same vendor, anda.invoice_id < b.invoice_idmakes each pair appear once instead of twice. The condition changes from rule to rule.- Rule 1, EXACT. Same number and same amount. This is what a basic duplicate check does.
- Rule 2, SAME_AMOUNT. Same vendor, identical amount, dates at most
:daysapart, invoice numbers ignored. It is the logic of the Cypher query printed in the freeCodeCamp article, translated into SQL by me (the article’s query also puts the earlier invoice first). The article’s table describes duplicates as having a “near-identical amount, close dates”, yet the query it prints requires an identical amount. - Rule 3, CAREFUL. Dates close, amounts within a tolerance (1 percent by default), and either the normalized numbers match or the purchase-order reference is the same.
Two SQL details are worth knowing. SQLite’s julianday() turns a date into a number of days, so subtracting two of them gives the days between. The SQLite documentation says it returns “the fractional number of days since noon in Greenwich on November 24, 4714 B.C.”. And create_function lets SQL call your Python function; marking it deterministic=True tells SQLite that the same input always gives the same output, which, in the words of the Python documentation, “allows SQLite to perform additional optimizations”.
Run the three rules against the answer key
This script runs each rule, scores it against the 20 hidden pairs, and also breaks the result down by how the copy was disguised. Save it as demo2_dupes.py.
# demo2_dupes.py: compare three duplicate-invoice rules against the answer key.
from collections import Counter
from common import connect, load_truth, score, show
from dupes import CAREFUL, EXACT, SAME_AMOUNT, find_pairs, normalize_invoice_no
conn = connect()
truth = load_truth()
positives = {(first, second) for first, second, _ in truth["duplicates"]}
kind_of = {(first, second): kind for first, second, kind in truth["duplicates"]}
print("normalize_invoice_no:", [normalize_invoice_no(s) for s in ("ACM-00417", "acm417", "ACM 417", "ACM-0417")])
print()
rules = [("1 exact number and amount", EXACT, 30),
("2 same amount within 14 days", SAME_AMOUNT, 14),
("2 same amount within 31 days", SAME_AMOUNT, 31),
("3 careful rule, 30 days", CAREFUL, 30)]
found = {}
for label, condition, days in rules:
found[label] = find_pairs(conn, condition, days=days)
show(label, score(found[label], positives))
print()
print("duplicates found, by how the copy was disguised (found / hidden)")
kinds = ["exact", "format", "amount", "rekeyed"]
print(f"{'rule':<30}" + "".join(f"{kind:>10}" for kind in kinds))
totals = Counter(kind_of.values())
for label, pairs in found.items():
hits = Counter(kind_of[pair] for pair in pairs & positives)
print(f"{label:<30}" + "".join(f"{hits[kind]:>7} /{totals[kind]:>2}" for kind in kinds))
print()
wrong = found["2 same amount within 14 days"] - positives
by_vendor = Counter(conn.execute("SELECT vendor_id FROM invoices WHERE invoice_id = ?", (first,)).fetchone()[0]
for first, _ in wrong)
print("false alarms of rule 2 (14 days), by vendor:", by_vendor.most_common(3))
vendor = by_vendor.most_common(1)[0][0]
count, cents = conn.execute("SELECT COUNT(*), MIN(amount_cents) FROM invoices WHERE vendor_id = ?", (vendor,)).fetchone()
print(f"{vendor} sent {count} invoices in 300 days, the smallest is ${cents / 100:,.2f}")
rows = conn.execute("SELECT inv_no, inv_date, amount_cents FROM invoices WHERE vendor_id = ? ORDER BY inv_date LIMIT 3", (vendor,))
for row in rows:
print(" ", row["inv_no"], row["inv_date"], f"${row['amount_cents'] / 100:,.2f}")
python demo2_dupes.py
Expected output:
normalize_invoice_no: ['ACM417', 'ACM417', 'ACM417', 'ACM417']
1 exact number and amount flagged 6 true 6 precision 100.0% recall 30.0%
2 same amount within 14 days flagged 496 true 10 precision 2.0% recall 50.0%
2 same amount within 31 days flagged 1054 true 16 precision 1.5% recall 80.0%
3 careful rule, 30 days flagged 20 true 20 precision 100.0% recall 100.0%
duplicates found, by how the copy was disguised (found / hidden)
rule exact format amount rekeyed
1 exact number and amount 6 / 6 0 / 6 0 / 4 0 / 4
2 same amount within 14 days 4 / 6 2 / 6 0 / 4 4 / 4
2 same amount within 31 days 6 / 6 6 / 6 0 / 4 4 / 4
3 careful rule, 30 days 6 / 6 6 / 6 4 / 4 4 / 4
false alarms of rule 2 (14 days), by vendor: [('V0006', 81), ('V0007', 81), ('V0004', 81)]
V0006 sent 42 invoices in 300 days, the smallest is $1,034.00
GPW-00477 2026-01-01 $1,034.00
GPW-00478 2026-01-08 $1,034.00
GPW-00479 2026-01-15 $1,034.00
Reading the result
- Rule 1 is precise but blind. Every pair it flags is real (precision 100 percent), but it finds only 6 of 20, the exact copies. The other 14 slip through because the number was retyped, the total drifted, or the copy was re-keyed under a new number.
- Rule 2 buries you. Within 14 days it flags 496 pairs and only 10 are real: precision 2.0 percent. All 486 false alarms come from the six weekly vendors, 81 each. Look at the vendor printed at the bottom: it bills $1,034.00 every seven days, and each invoice is within 14 days of the next two, so a schedule looks like a duplicate. Widening the window to 31 days to catch the late copies makes it worse: 1,054 flagged, 16 real (1.5 percent), because the monthly vendors join in. Even then it finds 0 of the 4 amount-drift copies, since it demands identical amounts.
- Rule 3 uses the evidence each disguise leaves behind. The normalized number catches the reformatted copies and the amount-drift copies, the tolerance absorbs the drift, and the purchase-order test catches the copy with a new number. The table shows it finds all four kinds: 20 of 20 with nothing else flagged.
Be careful with that last result. Rule 3 scores 100 percent on both measures because I planted exactly the four disguises it knows about. Treat it as proof that the rule does what it says, not as a promise about real data. Step 7 reruns everything on eight different seeds.
Gotchas for this step
- Blanket purchase orders. In the lab every invoice carries its own PO. If your company bills many invoices against one blanket PO, the PO test would match all of them. Use it only for POs that are single-use, or drop it.
- Corrected invoices. A vendor that cancels and reissues an invoice with the same number is flagged by rule 3. That is a lead worth a glance, not necessarily fraud.
- Stay inside one vendor. Two different vendors may legitimately use invoice number 1001. The join is limited to one vendor on purpose.
- A recurring schedule is not a duplicate. The six weekly vendors are the reason rule 2 fails. Any rule built on amount and date alone will fail the same way on your real recurring contracts.
Step 3: Link vendors that share a phone number, an address or a bank account
Now the first job that really needs a graph. Shell companies are cheap to create and hard to tell apart one at a time, but they often leak: the same bank account, the same phone, the same office. One overlap proves little, yet a chain of overlaps links vendors that look unrelated. In the lab, ring A is exactly that chain: vendor 1 shares a phone with vendor 2, vendor 2 shares an account with vendor 3, vendor 3 shares an address with vendor 4, and vendor 4 shares a second phone with vendor 5. A SQL join finds one link at a time. Connected components find the whole chain at once.
How it works: a bipartite graph and its components
Imagine three vendors. V1 and V2 list the same phone P, and V2 and V3 list the same account A. Draw a node for each vendor and each detail, and an edge from every vendor to each detail it lists: V1 to P, V2 to P, V2 to A, V3 to A. Follow the edges from V1 and you can reach P, then V2, then A, then V3. All five nodes form one connected component, so the three vendors are linked even though V1 and V3 share nothing directly. Save the next file as links.py.
# links.py: link vendors that share a bank account, a phone number or an address.
import networkx as nx
KINDS = ("account", "phone", "address")
def attribute_graph(conn):
"""A bipartite graph: one node per vendor, one node per distinct account, phone or address.
A vendor is joined to each of its three attribute nodes, so two vendors that share
a value are both joined to the same attribute node.
"""
graph = nx.Graph()
for row in conn.execute("SELECT vendor_id, account, phone, address FROM vendors"):
graph.add_node(row["vendor_id"], kind="vendor")
for kind in KINDS:
attribute = f"{kind}:{row[kind]}"
graph.add_node(attribute, kind=kind)
graph.add_edge(row["vendor_id"], attribute)
return graph
def vendor_clusters(graph, max_shared=None):
"""Groups of two or more vendors joined through shared values.
Values shared by more than max_shared vendors are removed first (None keeps everything).
"""
kept = graph.copy()
if max_shared is not None:
too_common = [node for node, data in graph.nodes(data=True)
if data["kind"] != "vendor" and graph.degree(node) > max_shared]
kept.remove_nodes_from(too_common)
clusters = []
for component in nx.connected_components(kept):
vendors = sorted(node for node in component if kept.nodes[node]["kind"] == "vendor")
if len(vendors) >= 2:
clusters.append(vendors)
return sorted(clusters, key=len, reverse=True)
attribute_graphreads the vendors table and builds exactly that graph. Each detail becomes a node named by its kind and value, such asaccount:ACC-MULE-01, and akindlabel records whether a node is a vendor, an account, a phone or an address.vendor_clustersoptionally removes every detail shared by more thanmax_sharedvendors, then returns the connected components that still hold two or more vendors. It works on a copy, so you can try many limits on the same graph. The limit exists because of the supernode problem you are about to see.
Run it and watch one popular value ruin the result
This script prints the most shared values, then clusters the vendors with several different limits and scores each against the 34 vendors in rings A, B and M. Save it as demo3_links.py.
# demo3_links.py: cluster vendors by shared details and watch one popular value ruin the result.
from common import connect, load_truth, score, show
from links import attribute_graph, vendor_clusters
conn = connect()
truth = load_truth()
ring_of = {vendor: name for name, members in truth["rings"].items() for vendor in members}
positives = set(ring_of) # 34 vendors in rings A, B and M
graph = attribute_graph(conn)
vendors = [node for node, data in graph.nodes(data=True) if data["kind"] == "vendor"]
print(f"graph: {graph.number_of_nodes()} nodes ({len(vendors)} vendors), {graph.number_of_edges()} edges")
shared = sorted(((graph.degree(node), node) for node, data in graph.nodes(data=True)
if data["kind"] != "vendor" and graph.degree(node) > 1), reverse=True)
print("values shared by 2 or more vendors:", len(shared))
for degree, node in shared[:5]:
print(f" {degree:>3} vendors share {node}")
print()
print(f"{'limit':>5} {'clusters':>8} {'largest':>7} {'flagged':>7} {'true':>4} {'precision':>9} {'recall':>7}")
for limit in (None, 2, 6, 12, 25, 45, 64):
clusters = vendor_clusters(graph, limit)
flagged = {vendor for cluster in clusters for vendor in cluster}
result = score(flagged, positives)
print(f"{'none' if limit is None else limit:>5} {len(clusters):>8} {max(map(len, clusters), default=0):>7} "
f"{result['flagged']:>7} {result['hits']:>4} {result['precision']:>9.1%} {result['recall']:>7.1%}")
print()
print("clusters left with a limit of 6:")
for cluster in vendor_clusters(graph, 6):
rings = sorted({ring_of.get(vendor, "none") for vendor in cluster})
print(f" {len(cluster):>2} vendors, ring {', '.join(rings)}: {', '.join(cluster[:4])}{' ...' if len(cluster) > 4 else ''}")
python demo3_links.py
Expected output:
graph: 2066 nodes (554 vendors), 1662 edges
values shared by 2 or more vendors: 10
64 vendors share address:100 Agent Way, Dover, DE
45 vendors share account:ACC-PLATFORM-01
25 vendors share account:ACC-MULE-01
12 vendors share address:7 Commons Row, Austin, TX
4 vendors share account:ACC-V0527
limit clusters largest flagged true precision recall
none 6 64 153 34 22.2% 100.0%
2 2 5 7 5 71.4% 14.7%
6 3 5 11 9 81.8% 26.5%
12 4 12 23 9 39.1% 26.5%
25 5 25 48 34 70.8% 100.0%
45 6 45 93 34 36.6% 100.0%
64 6 64 153 34 22.2% 100.0%
clusters left with a limit of 6:
5 vendors, ring A: V0521, V0522, V0523, V0524 ...
4 vendors, ring B: V0526, V0527, V0528, V0529
2 vendors, ring none: V0001, V0002
Reading the result
- The graph is small, and a handful of values dominate it. 554 vendors with three details each give 1,662 edges, and only 10 values are shared by two or more vendors. The five biggest are the agent address (64 vendors: 60 honest firms plus the 4 shells of ring B), the platform account (45), the mule account (25), the co-working address (12) and ring B’s account (4).
- With no limit, one popular value decides everything. Six clusters, the largest with 64 vendors, 153 vendors flagged, precision 22.2 percent. Ring B’s shells are inside the agent-address cluster, hidden among 60 honest firms. That is the supernode problem.
- A limit of 2 or 6 gives clean clusters. At 2, only values used by exactly two vendors survive. At 6, ring B’s account (4 vendors) survives too, leaving the clusters printed at the bottom: ring A (5 vendors), ring B (4) and the two honest sister companies. That is 11 vendors flagged, 9 of them real, so precision is 81.8 percent. The recall column reads 26.5 percent only because the 25 mule shells are part of the 34, and this limit drops their account. Against rings A and B alone, the 9 vendors this detector is meant to find, recall is 100 percent (Step 6 shows it).
- Raising the limit trades precision for recall, and no value is clean. At 12 the co-working space’s 12 honest firms join the output (39.1 percent). At 25 the mule account joins and recall reaches 100 percent, but the 12 co-working firms are still flagged (70.8 percent). At 45 the platform account joins (36.6 percent), and at 64 the agent address merges ring B into the 64-vendor blob (22.2 percent).
It is tempting to pick 25, which has the best-looking row. Do not. Twenty-five is the size of the mule ring that I planted, so choosing it means tuning the rule to the answer key. On real data you do not know how big the next ring is. The honest design is the one the table points to: use a low limit for this detector, and give the high-fan-in case its own detector with its own evidence, which is Step 4.
This is also why the lab does not link vendors by city. The article’s own sample output lists 387 SHARED_LOCATION relationships among its 100 vendors. A link that common carries almost no information, and as the article itself says, “Sharing a city is common in large commercial hubs”.
Gotchas for this step
- Weak keys create giant clusters. City, country, bank routing prefix and similar low-cardinality fields connect almost everyone. Link on details that are specific to one business.
- Count vendors per value before you trust a cluster. Sort the values by degree, as the script does, and look at the top. If an address you do not recognize connects 60 vendors, it is a registry, not a ring.
- A cluster is a lead, not a ring. The sister companies are real and honest and are flagged for good reason. A reviewer needs to see which detail links the vendors, not only that a cluster exists.
- Do not tune the limit against your own answer key. Pick it from the degree table and your knowledge of the business, then use the answer key to measure the result.
Step 4: Spot accounts that many vendors funnel money into
A money mule ring is the opposite of the sister companies: many separate-looking vendors, one account that collects the money. Each shell sends small invoices, none of which stands out, and the evidence is the fan-in, the number of different vendors paid into one account. The introduction of the freeCodeCamp article uses this exact example:“A shell company invoicing $4,800 to stay under a $5,000 dual sign-off threshold looks completely fine in isolation.”
You could count fan-in with a graph, but it is a one-table aggregate, so plain SQL does it better. The interesting part is what you count besides the fan-in. The lab includes an honest account with a large fan-in, the payment platform that pays out to 45 vendors, and a rule that only looks at fan-in cannot tell the two apart. Save the next file as mules.py.
# mules.py: score destination accounts by how many vendors feed them and how the amounts look.
SQL = """
SELECT p.account,
COUNT(DISTINCT i.vendor_id) AS vendors,
COUNT(*) AS payments,
SUM(p.amount_cents) AS total_cents,
AVG(CASE WHEN p.amount_cents >= :low AND p.amount_cents < :limit THEN 1.0 ELSE 0.0 END) AS near_limit_share
FROM payments p JOIN invoices i ON i.invoice_id = p.invoice_id
GROUP BY p.account
HAVING vendors >= :min_vendors
ORDER BY vendors DESC
"""
def account_features(conn, limit_cents=500_000, band=0.08, min_vendors=5):
"""One row per account paid on behalf of at least min_vendors vendors.
near_limit_share is the fraction of payments inside the band just under the
approval limit: with a $5,000 limit and an 8 percent band, $4,600.00 up to $4,999.99.
"""
params = {"low": round(limit_cents * (1 - band)), "limit": limit_cents, "min_vendors": min_vendors}
return [dict(row) for row in conn.execute(SQL, params)]
def flag_mules(rows, min_vendors=10, min_near_limit_share=None):
"""Accounts with many feeding vendors and, optionally, amounts that hug the limit."""
flagged = [row for row in rows if row["vendors"] >= min_vendors]
if min_near_limit_share is not None:
flagged = [row for row in flagged if row["near_limit_share"] >= min_near_limit_share]
return flagged
account_featuresgroups payments by destination account. It counts the distinct vendors behind each account, the number and total of the payments, andnear_limit_share: the fraction of payments that fall in a band just under the approval limit. With a $5,000 limit and an 8 percent band, that is $4,600.00 up to but not including $5,000.00. TheHAVINGclause keeps only accounts with at leastmin_vendorsvendors behind them.flag_mulesapplies the thresholds. Fan-in alone is the naive rule. Addingmin_near_limit_shareadds the second signal.
Now compare the two rules. Save this as demo4_mules.py.
# demo4_mules.py: which accounts take money from many vendors, and which of those hug the approval limit?
from common import connect, load_truth, score, show
from mules import account_features, flag_mules
conn = connect()
truth = load_truth()
rows = account_features(conn)
print(f"{'account':<18}{'vendors':>8}{'payments':>9}{'total $':>13}{'near limit':>12}")
for row in rows:
print(f"{row['account']:<18}{row['vendors']:>8}{row['payments']:>9}{row['total_cents'] / 100:>13,.0f}"
f"{row['near_limit_share']:>12.1%}")
print()
positives = {truth["mule_account"]}
for label, options in [("fan-in of 10 vendors or more", {"min_vendors": 10}),
("fan-in 10+ and 50% near the limit", {"min_vendors": 10, "min_near_limit_share": 0.5})]:
flagged = {row["account"] for row in flag_mules(rows, **options)}
show(label, score(flagged, positives))
python demo4_mules.py
Expected output:
account vendors payments total $ near limit
ACC-PLATFORM-01 45 497 2,089,339 5.0%
ACC-MULE-01 25 104 499,068 100.0%
fan-in of 10 vendors or more flagged 2 true 1 precision 50.0% recall 100.0%
fan-in 10+ and 50% near the limit flagged 1 true 1 precision 100.0% recall 100.0%
Reading the result
- Fan-in alone flags the honest platform too. Forty-five vendors is more than the mule ring’s 25, so a rule of “10 or more vendors” flags both accounts: precision 50 percent.
- A bigger total does not help. The article suggests adding a threshold on total received to “minimize false positives”, with the example
total_received > 50000. Here the platform account passes it easily, with $2,089,339 against $499,068 for the mule account, so the honest account is flagged either way. - The shape of the amounts does help. Every one of the mule account’s payments sits just under the limit (100 percent), against 5.0 percent for the platform, whose amounts are spread the way real bills are. With both signals the rule flags exactly one account, the right one.
Notice how this connects back to Step 3. The vendor-clustering detector used a limit of 6, so it removed the mule account (25 vendors) on purpose. The two detectors are not redundant. One looks for a few vendors linked by rare details, the other for many vendors feeding one account.
Gotchas for this step
- The band is a guess. An 8 percent band under a $5,000 limit worked on this data. A fraudster who knows the limit can vary the amounts or split them, so treat near-limit share as one signal among several, not as the definition of a mule.
- Allow-lists cut noise and create blind spots. The article suggests tagging known high-volume institutions and excluding them from scans. That works, but an account that gets onto the list is invisible from then on, so review the list.
- Fan-in is only as good as your vendor key. Counting
DISTINCT vendor_idassumes each shell is a separate vendor record. A fraudster who reuses one vendor record for many invoices has fan-in 1 and needs a different rule.
Step 5: Find money that comes back
The fourth pattern is the one that needs direction. In a round trip, money leaves account A, passes through B and C, and returns to A, which inflates turnover or hides where the money came from. Your accounts-payable ledger cannot show this, because it only records who you paid. You need a feed of account-to-account transfers, and the lab’s transfers table plays that role: 4,091 transfers among 696 accounts, with 3 planted loops of 3, 4 and 5 accounts, and 30 honest refunds that also go out and come back.
First attempt: look for cycles by shape
NetworkX can list every simple cycle in a directed graph, and its simple_cycles function takes an optional length_bound. The documentation says that “If length_bound is an int, generate all simple cycles of G with length at most length_bound. Otherwise, generate all simple cycles of G.” Save the first part of loops.py now. It loads the transfers, builds the directed graph (one edge for each pair of accounts that ever sent money), rotates a cycle to a standard starting point so the same ring always looks the same, and wraps simple_cycles.
# loops.py: find money that leaves an account and comes back through other accounts.
import bisect
import time
from collections import defaultdict
from itertools import combinations
import networkx as nx
def load_transfers(conn):
"""Rows of (transfer_id, src, dst, day, amount_cents), oldest first."""
rows = conn.execute("SELECT transfer_id, src, dst, day, amount_cents FROM transfers ORDER BY day, transfer_id")
return [tuple(row) for row in rows]
def transfer_graph(transfers):
"""A directed graph: an edge from src to dst for every pair of accounts with at least one transfer."""
graph = nx.DiGraph()
graph.add_edges_from((src, dst) for _, src, dst, _, _ in transfers)
return graph
def canonical(accounts):
"""Rotate a cycle so it starts at its smallest account; the same ring then always looks the same."""
start = accounts.index(min(accounts))
return tuple(accounts[start:] + accounts[:start])
def structural_cycles(graph, bound):
"""Every directed cycle of 3 to `bound` accounts, looking only at shape and direction.
A generator: millions of cycles are normal here, so count them as they stream past.
"""
for cycle in nx.simple_cycles(graph, length_bound=bound):
if len(cycle) >= 3:
yield canonical(cycle)
def cycles_within_budget(graph, seconds):
"""Count cycles of any length for at most `seconds`; return (count, finished)."""
started, count = time.perf_counter(), 0
for _ in nx.simple_cycles(graph):
count += 1
if time.perf_counter() - started > seconds:
return count, False
return count, True
The wrapper is a generator on purpose. The next script counts cycles as they stream past, because the numbers are about to get large. Save it as demo5_loops.py. It also contains the other measurements from this step, which the next sections explain.
# demo5_loops.py: money that comes back. Shape alone, then shape plus timing and amounts.
import time
from common import connect, load_truth, score, show
from loops import (canonical, cycles_within_budget, find_round_trips, load_transfers,
shared_account_triangles, structural_cycles, transfer_graph)
conn = connect()
truth = load_truth()
transfers = load_transfers(conn)
graph = transfer_graph(transfers)
rings = {canonical(ring) for ring in truth["loops"]}
ring_accounts = {account for ring in truth["loops"] for account in ring}
print(f"{len(transfers)} transfers between {graph.number_of_nodes()} accounts, {graph.number_of_edges()} account pairs")
print("\n-- shape only: every directed cycle of 3 to N accounts")
print(f"{'N':>3} {'cycles':>8} {'seconds':>8} {'rings among them':>16}")
for bound in (3, 4, 5, 6):
started = time.perf_counter()
total, hits = 0, set()
for cycle in structural_cycles(graph, bound):
total += 1
if cycle in rings:
hits.add(cycle)
seconds = time.perf_counter() - started
print(f"{bound:>3} {total:>8,} {seconds:>8.2f} {len(hits):>11} of {len(rings)}")
count, finished = cycles_within_budget(graph, 10)
print(f"no length limit: {count:,} cycles counted in 10 seconds, finished: {finished}")
print("\n-- shape plus timing plus amounts (3 days between hops, 10 percent amount tolerance)")
found = find_round_trips(transfers)
for ring, laps in sorted(found.items(), key=lambda item: (-item[1], item[0])):
print(f" {laps} laps: {' > '.join(ring)} > {ring[0]}")
show("accounts in round trips", score({a for ring in found for a in ring}, ring_accounts))
print(f"rings found: {len(set(found) & rings)} of {len(rings)}, other cycles reported: {len(set(found) - rings)}")
print("\n-- allow 2-account loops")
with_pairs = find_round_trips(transfers, min_len=2)
pairs = [ring for ring in with_pairs if len(ring) == 2]
show("accounts in round trips", score({a for ring in with_pairs for a in ring}, ring_accounts))
refund_pairs = {frozenset(pair) for pair in truth["refunds"]}
print(f"2-account loops reported: {len(pairs)}, of which known refunds: {sum(frozenset(p) in refund_pairs for p in pairs)}")
print("\n-- what the two limits each buy (3 to 6 accounts)")
print(f"{'days':>5} {'amounts':>9} {'rings':>5} {'other cycles':>12} {'seconds':>7}")
for window in (3, 14, 60):
for tolerance in (0.10, None):
started = time.perf_counter()
found = find_round_trips(transfers, window=window, tolerance=tolerance)
seconds = time.perf_counter() - started
print(f"{window:>5} {'within 10%' if tolerance else 'any':>9} {len(set(found) & rings):>5} "
f"{len(set(found) - rings):>12,} {seconds:>7.2f}")
print("\n-- the shared-account triangle test")
total, per_account = shared_account_triangles(conn)
print(f"triangles of vendors that share a payment account: {total:,}")
for account, triangles in sorted(per_account.items(), key=lambda item: -item[1])[:3]:
print(f" {account}: {triangles:,}")
python demo5_loops.py
This takes about a minute, mostly the 6-account count. The first part of the output is the shape-only search (the seconds and the 10-second count vary from run to run):
4091 transfers between 696 accounts, 3853 account pairs
-- shape only: every directed cycle of 3 to N accounts
N cycles seconds rings among them
3 1,431 0.39 1 of 3
4 17,823 0.82 2 of 3
5 204,759 3.88 3 of 3
6 2,362,140 38.39 3 of 3
no length limit: 772,930 cycles counted in 10 seconds, finished: False
Reading the result
- The count explodes. With the limit at 3 accounts there are 1,431 cycles; at 4, 17,823; at 5, 204,759; at 6, 2,362,140. Each extra account multiplies the count by about 11 to 12. I stopped at 6 because it already takes 30 to 40 seconds here. Another step at the same rate would be roughly 27 million cycles, but that is an extrapolation, not a measurement.
- Without a limit it never finishes. In 10 seconds the search counted hundreds of thousands of cycles (the exact figure depends on the machine) and was still going. The NetworkX documentation ties the cost to the number of cycles, so a dense graph is a problem for any rule that enumerates cycles.
- The real rings are buried. The 3-account ring is one of 1,431 cycles at limit 3. The 4-account ring appears at limit 4. The 5-account ring only appears at limit 5, among 204,759 cycles. Shape alone gives a reviewer nothing to work with.
Better: require timing and matching amounts
Almost all of those millions of cycles are coincidences: money that happened to leave one account in March and arrive at another in August. A round trip is different, because the money returns quickly and in nearly the same amount. So the search should only follow a transfer if it leaves within a few days of the one before and carries about the same amount as the first hop. That constraint prunes almost everything before the search can explode. Add the second part to loops.py.
# loops.py (continued)
def find_round_trips(transfers, window=3, tolerance=0.10, min_len=3, max_len=6):
"""Cycles in which every hop leaves within `window` days of the hop before it
and carries about the same amount as the first hop (tolerance None skips the amount test).
Returns {canonical account tuple: number of separate laps}.
"""
leaving = defaultdict(list) # account -> its transfers, oldest first
for transfer in transfers:
leaving[transfer[1]].append(transfer)
days = {account: [t[3] for t in rows] for account, rows in leaving.items()}
laps = defaultdict(set) # canonical ring -> sets of transfer ids
def extend(path):
start, first_amount = path[0][1], path[0][4]
_, _, here, last_day, _ = path[-1]
rows = leaving.get(here, [])
for transfer in rows[bisect.bisect_left(days.get(here, []), last_day):]:
if transfer[3] > last_day + window:
break
if tolerance is not None and abs(transfer[4] - first_amount) > tolerance * first_amount:
continue
if transfer[2] == start:
if len(path) + 1 >= min_len:
accounts = [hop[1] for hop in path] + [transfer[1]]
laps[canonical(accounts)].add(frozenset(hop[0] for hop in path + [transfer]))
elif transfer[2] not in {hop[1] for hop in path} | {here} and len(path) + 1 < max_len:
extend(path + [transfer])
for first in transfers:
extend([first])
return {ring: len(found) for ring, found in laps.items()}
How it works, step by step:
leavingmaps each account to its outgoing transfers, oldest first, anddaysholds just their dates sobisect_leftcan jump to the first transfer on or after a given day.- The search starts from every transfer in turn.
extendlooks at what the receiving account sent onward: it skips transfers that are too late (break, because the list is sorted) and ones whose amount is not withintoleranceof the first hop. - If a transfer goes back to the starting account, the loop is complete and is recorded, provided it has at least
min_lenaccounts. Otherwise the search continues from that transfer, but never through an account already on the path and never beyondmax_lenaccounts. - Rotations of the same ring are one ring.
canonicalturns every rotation into one standard form, and a set of transfer ids per ring counts how many separate laps were found.
Here is the second part of the output, from the same demo5_loops.py run:
-- shape plus timing plus amounts (3 days between hops, 10 percent amount tolerance)
3 laps: ACC-T0104 > ACC-T0158 > ACC-T0250 > ACC-T0467 > ACC-T0104
3 laps: ACC-T0155 > ACC-T0386 > ACC-T0241 > ACC-T0155
2 laps: ACC-T0129 > ACC-T0497 > ACC-T0680 > ACC-T0230 > ACC-T0465 > ACC-T0129
accounts in round trips flagged 12 true 12 precision 100.0% recall 100.0%
rings found: 3 of 3, other cycles reported: 0
Three days between hops and a 10 percent amount tolerance find all three planted rings (3, 3 and 2 laps) and nothing else: 12 accounts flagged, 12 real. The search that was hopeless a moment ago now takes a fraction of a second, because the two limits cut the number of paths to follow.
What the two limits each buy, and why refunds are not loops
Two more experiments show why both limits are needed and why the loop must have at least three accounts:
-- allow 2-account loops
accounts in round trips flagged 54 true 12 precision 22.2% recall 100.0%
2-account loops reported: 23, of which known refunds: 23
-- what the two limits each buy (3 to 6 accounts)
days amounts rings other cycles seconds
3 within 10% 3 0 0.00
3 any 3 3 0.00
14 within 10% 3 0 0.00
14 any 3 81 0.02
60 within 10% 3 1 0.00
60 any 3 11,550 1.86
- Allowing loops of two accounts flags 23 more pairs and drops precision to 22.2 percent. All 23 are the honest refunds, where money goes out and comes back. Refunds are normal, so require three or more accounts here and handle two-account returns with a separate rule, such as the refund rate per counterparty.
- Timing alone is not enough, and neither are amounts alone. With 3 days and any amount, 3 extra cycles are reported. With 60 days and any amount, there are 11,550 and the search takes about two seconds. With 60 days and a 10 percent tolerance, one coincidental cycle gets through. The two limits work together, and they should reflect how your rings actually behave: if real rings take weeks, widen the window and keep the amounts tight.
Why a triangle of shared accounts is not a money loop
The article’s second pattern is called circular invoicing, and its table describes it as vendors invoicing each other in a closed loop. The query it prints does something else. The article says that “cycle detection is simplified to finding three-node triangles in the vendor sub-graph”, and that sub-graph is built from SHARED_ACCOUNT_WITH, a relationship the article defines as linking vendors “that share a destination bank account”. That relationship records no transfer between the vendors. The article stores it as a single edge from the lower internal node id to the higher one, only to avoid duplicates, and its triangle query ignores direction, so any three vendors that are paid into one account form a triangle.
The arithmetic follows directly. An account that n vendors are paid into produces n × (n − 1) × (n − 2) ÷ 6 triangles: 2,300 for 25 vendors, 14,190 for 45. I did not run the article’s code, and it does not print how many triangles its query returned, since its validator reports “DETECTED 1 of 1 rings”. What I can show is what that logic matches in this lab. Add the last part to loops.py:
# loops.py (continued)
def shared_account_triangles(conn):
"""The triangle test from the article: vendors that pay into one account are linked,
and every group of three linked vendors counts. Returns (triangles, triangles per account)."""
by_account = defaultdict(set)
for row in conn.execute("SELECT DISTINCT i.vendor_id AS vendor, p.account AS account "
"FROM payments p JOIN invoices i ON i.invoice_id = p.invoice_id"):
by_account[row["account"]].add(row["vendor"])
linked = nx.Graph()
for vendors in by_account.values():
linked.add_edges_from(combinations(sorted(vendors), 2))
total = sum(nx.triangles(linked).values()) // 3
per_account = {account: len(vendors) * (len(vendors) - 1) * (len(vendors) - 2) // 6
for account, vendors in by_account.items() if len(vendors) >= 3}
return total, per_account
The last part of the demo5_loops.py output has the result:
-- the shared-account triangle test
triangles of vendors that share a payment account: 16,494
ACC-PLATFORM-01: 14,190
ACC-MULE-01: 2,300
ACC-V0527: 4
There are 16,494 triangles, 14,190 of them from the honest platform account, 2,300 from the mule account and 4 from ring B’s account, and not one is a loop of money. A triangle of shared accounts is a useful clue about who controls what, and it belongs with Steps 3 and 4. A cycle of transfers is movement of money, and it needs direction, dates and amounts.
Gotchas for this step
- Never enumerate cycles without a limit. Always pass a length bound, or follow a constrained search like
find_round_trips. - The amount test is relative to the first hop. A ring that skims more than 10 percent in total is missed. Raise
toleranceif your rings pay fees along the way. - Dates here are day numbers. With real timestamps, convert them to a common unit, and decide whether a transfer at 23:59 and another at 00:01 count as one day apart.
- One ring is not many rings. The same loop is found from each of its starting points, which is why the code rotates every loop to a standard form and counts laps with a set of transfer ids.
Step 6: Put the four detectors together
A fraud team does not read four separate scripts. It reads one queue. This script runs every detector once, prints precision and recall for each, then asks the question that matters to the business: of all the vendors that were flagged by any detector, how many are the ones you planted, and how many items would a reviewer have to open? The vendor-level answer key is the 34 ring vendors plus the 20 vendors that submitted a duplicate. Save it as demo6_report.py.
# demo6_report.py: run every detector once and print one table plus the size of the review queue.
from common import connect, load_truth, score, show
from dupes import CAREFUL, find_pairs
from links import attribute_graph, vendor_clusters
from loops import find_round_trips, load_transfers
from mules import account_features, flag_mules
conn = connect()
truth = load_truth()
rings = truth["rings"]
pairs = find_pairs(conn, CAREFUL, days=30)
show("duplicate invoice pairs", score(pairs, {(first, second) for first, second, _ in truth["duplicates"]}))
clusters = vendor_clusters(attribute_graph(conn), 6)
cluster_vendors = {vendor for cluster in clusters for vendor in cluster}
show("vendors in shared-detail clusters", score(cluster_vendors, set(rings["A"] + rings["B"])))
mules = {row["account"] for row in flag_mules(account_features(conn), min_vendors=10, min_near_limit_share=0.5)}
show("mule accounts", score(mules, {truth["mule_account"]}))
trips = find_round_trips(load_transfers(conn))
show("accounts in round trips", score({a for ring in trips for a in ring}, {a for ring in truth["loops"] for a in ring}))
marks = ",".join("?" * len(mules))
mule_vendors = {row[0] for row in conn.execute(
f"SELECT DISTINCT i.vendor_id FROM payments p JOIN invoices i ON i.invoice_id = p.invoice_id "
f"WHERE p.account IN ({marks})", sorted(mules))}
pair_marks = ",".join("?" * len(pairs))
duplicate_vendors = {row[0] for row in conn.execute(
f"SELECT DISTINCT vendor_id FROM invoices WHERE invoice_id IN ({pair_marks})", sorted(p[1] for p in pairs))}
problem_vendors = set(rings["A"] + rings["B"] + rings["M"]) | {
row[0] for row in conn.execute(
f"SELECT DISTINCT vendor_id FROM invoices WHERE invoice_id IN ({','.join('?' * len(truth['duplicates']))})",
[second for _, second, _ in truth["duplicates"]])}
print()
show("vendors flagged by any detector", score(cluster_vendors | mule_vendors | duplicate_vendors, problem_vendors))
total_vendors = conn.execute("SELECT COUNT(*) FROM vendors").fetchone()[0]
queue = len(pairs) + len(clusters) + len(mules) + len(trips)
print(f"review queue: {len(pairs)} duplicate pairs + {len(clusters)} clusters + {len(mules)} account + {len(trips)} loops "
f"= {queue} items, for {total_vendors} vendors")
python demo6_report.py
Expected output:
duplicate invoice pairs flagged 20 true 20 precision 100.0% recall 100.0%
vendors in shared-detail clusters flagged 11 true 9 precision 81.8% recall 100.0%
mule accounts flagged 1 true 1 precision 100.0% recall 100.0%
accounts in round trips flagged 12 true 12 precision 100.0% recall 100.0%
vendors flagged by any detector flagged 56 true 54 precision 96.4% recall 100.0%
review queue: 20 duplicate pairs + 3 clusters + 1 account + 3 loops = 27 items, for 554 vendors
Reading the result
- Each detector scores 100 percent recall on the patterns it was built for. The cluster detector is graded against rings A and B only (9 vendors), and its 81.8 percent precision is the two sister companies.
- Combined, the detectors flag 56 vendors, 54 of them planted: precision 96.4 percent, recall 100 percent. The two extra vendors are the honest sisters, who deserve a quick look and a note, not an investigation.
- The review queue is 27 items for 554 vendors, 6,184 invoices and 4,091 transfers: 20 duplicate pairs, 3 vendor clusters, 1 account and 3 loops. That is the figure to compare against any rule you are tempted to add. The same-amount duplicate rule from Step 2 would have added 496 items by itself.
Step 7: Test the rules, plant bugs, and change the seed
A fraud rule that is quietly wrong is worse than none, because people stop looking. Three checks guard against that.
Hand-built test cases
The tests use tiny databases built by hand, so each case can show exactly one rule firing or staying quiet: a reformatted invoice number, a weekly schedule, a popular address, a band edge, a hop that lands on the last allowed day, a refund. Save this as test_fraudlab.py.
# test_fraudlab.py: small, hand-built cases for every rule. Run with: python -m pytest -q
import sqlite3
import pytest
from dupes import CAREFUL, EXACT, SAME_AMOUNT, find_pairs, normalize_invoice_no
from links import attribute_graph, vendor_clusters
from loops import canonical, find_round_trips, shared_account_triangles, structural_cycles, transfer_graph
from mules import account_features, flag_mules
SCHEMA = """
CREATE TABLE vendors(vendor_id TEXT PRIMARY KEY, name TEXT, tax_id TEXT, address TEXT, phone TEXT, account TEXT, created TEXT);
CREATE TABLE invoices(invoice_id INTEGER PRIMARY KEY, vendor_id TEXT, inv_no TEXT, po_ref TEXT, amount_cents INTEGER, inv_date TEXT);
CREATE TABLE payments(payment_id INTEGER PRIMARY KEY, invoice_id INTEGER, account TEXT, amount_cents INTEGER, paid_date TEXT);
"""
def make_db():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
return conn
def add_invoices(conn, rows):
"""rows: (vendor, inv_no, po, cents, date); invoice ids follow the order given."""
conn.executemany("INSERT INTO invoices(vendor_id, inv_no, po_ref, amount_cents, inv_date) VALUES(?,?,?,?,?)", rows)
def add_vendors(conn, rows):
"""rows: (vendor, account, phone, address)."""
conn.executemany("INSERT INTO vendors VALUES(?, 'n', 't', ?, ?, ?, '2026-01-01')",
[(v, address, phone, account) for v, account, phone, address in rows])
@pytest.mark.parametrize("raw", ["ACM-00417", "acm417", "ACM 417", "ACM-0417", " acm_00417 "])
def test_invoice_numbers_normalize_to_one_form(raw):
assert normalize_invoice_no(raw) == "ACM417"
def test_normalize_keeps_different_numbers_apart():
assert normalize_invoice_no("ACM-00417") != normalize_invoice_no("ACM-00471")
assert normalize_invoice_no("A-100") != normalize_invoice_no("B-100")
def test_exact_rule_misses_a_reformatted_number_and_careful_rule_finds_it():
conn = make_db()
add_invoices(conn, [("V1", "ACM-00417", "PO-1", 100_000, "2026-03-01"), ("V1", "acm417", "PO-2", 100_000, "2026-03-05")])
assert find_pairs(conn, EXACT) == set()
assert find_pairs(conn, CAREFUL) == {(1, 2)}
def test_same_po_catches_a_re_keyed_invoice_number():
conn = make_db()
add_invoices(conn, [("V1", "ACM-00417", "PO-9", 100_000, "2026-03-01"), ("V1", "ACM-90412", "PO-9", 100_000, "2026-03-04")])
assert find_pairs(conn, CAREFUL) == {(1, 2)}
def test_weekly_invoices_trip_the_same_amount_rule_but_not_the_careful_rule():
conn = make_db()
add_invoices(conn, [("V1", f"CLN-{n:05d}", f"PO-{n}", 185_000, f"2026-03-{1 + 7 * n:02d}") for n in range(3)])
assert len(find_pairs(conn, SAME_AMOUNT, days=14)) == 3
assert find_pairs(conn, CAREFUL) == set()
def test_amount_tolerance_is_enforced():
conn = make_db()
add_invoices(conn, [("V1", "X-1", "PO-1", 100_000, "2026-03-01"), ("V1", "X-1", "PO-2", 100_500, "2026-03-02"),
("V2", "Y-1", "PO-3", 100_000, "2026-03-01"), ("V2", "Y-1", "PO-4", 103_000, "2026-03-02")])
assert find_pairs(conn, CAREFUL, tolerance=0.01) == {(1, 2)}
def test_duplicates_are_only_looked_for_within_one_vendor():
conn = make_db()
add_invoices(conn, [("V1", "X-1", "PO-1", 100_000, "2026-03-01"), ("V2", "X-1", "PO-2", 100_000, "2026-03-02")])
assert find_pairs(conn, CAREFUL) == set()
def test_clusters_follow_different_kinds_of_shared_detail():
conn = make_db()
add_vendors(conn, [("V1", "A1", "P1", "S1"), ("V2", "A2", "P1", "S2"), ("V3", "A2", "P3", "S3"), ("V4", "A4", "P4", "S4")])
assert vendor_clusters(attribute_graph(conn)) == [["V1", "V2", "V3"]]
def test_limit_removes_values_that_too_many_vendors_share():
conn = make_db()
add_vendors(conn, [(f"V{n}", f"A{n}", f"P{n}", "AGENT") for n in range(5)] + [("V8", "A8", "P8", "S8"), ("V9", "A8", "P9", "S9")])
graph = attribute_graph(conn)
assert [len(c) for c in vendor_clusters(graph)] == [5, 2]
assert [len(c) for c in vendor_clusters(graph, max_shared=3)] == [2]
def test_a_popular_value_merges_unrelated_groups_into_one_cluster():
conn = make_db()
add_vendors(conn, [("V1", "A1", "P1", "AGENT"), ("V2", "A2", "P2", "AGENT"), ("V3", "A3", "P2", "S3"), ("V4", "A4", "P4", "AGENT")])
assert [len(c) for c in vendor_clusters(attribute_graph(conn))] == [4]
assert vendor_clusters(attribute_graph(conn), max_shared=2) == [["V2", "V3"]]
def test_near_limit_band_includes_the_low_edge_and_excludes_the_limit():
conn = make_db()
add_invoices(conn, [(f"V{n}", f"I-{n}", f"PO-{n}", cents, "2026-03-01") for n, cents in enumerate([459_999, 460_000, 499_999, 500_000])])
conn.executemany("INSERT INTO payments(invoice_id, account, amount_cents, paid_date) VALUES(?, 'ACC-X', ?, '2026-04-01')",
[(n + 1, cents) for n, cents in enumerate([459_999, 460_000, 499_999, 500_000])])
(row,) = account_features(conn, min_vendors=4)
assert row["vendors"] == 4 and row["near_limit_share"] == 0.5
def test_mule_flag_needs_fan_in_and_amounts_near_the_limit():
rows = [{"account": "platform", "vendors": 45, "near_limit_share": 0.05},
{"account": "mule", "vendors": 25, "near_limit_share": 1.0},
{"account": "small", "vendors": 6, "near_limit_share": 1.0}]
assert [r["account"] for r in flag_mules(rows, min_vendors=10)] == ["platform", "mule"]
assert [r["account"] for r in flag_mules(rows, min_vendors=10, min_near_limit_share=0.5)] == ["mule"]
def ring(amounts, days, accounts=("A", "B", "C")):
"""Transfers around a ring: (id, src, dst, day, cents)."""
return [(n + 1, accounts[n], accounts[(n + 1) % len(accounts)], days[n], amounts[n]) for n in range(len(accounts))]
def test_round_trip_with_close_timing_and_similar_amounts_is_found():
found = find_round_trips(ring([100_000, 99_000, 98_000], [10, 11, 12]))
assert found == {("A", "B", "C"): 1}
def test_the_window_includes_its_last_day():
assert find_round_trips(ring([100_000, 99_000, 98_000], [10, 13, 16])) == {("A", "B", "C"): 1}
assert find_round_trips(ring([100_000, 99_000, 98_000], [10, 14, 18])) == {}
def test_round_trip_with_a_slow_hop_is_ignored():
assert find_round_trips(ring([100_000, 99_000, 98_000], [10, 11, 20])) == {}
def test_round_trip_with_a_very_different_amount_is_ignored():
assert find_round_trips(ring([100_000, 20_000, 98_000], [10, 11, 12])) == {}
assert find_round_trips(ring([100_000, 20_000, 98_000], [10, 11, 12]), tolerance=None) == {("A", "B", "C"): 1}
def test_two_laps_are_counted_separately():
transfers = sorted(ring([100_000, 99_000, 98_000], [10, 11, 12]) + [(t[0] + 3, *t[1:3], t[3] + 40, t[4]) for t in ring([100_000, 99_000, 98_000], [10, 11, 12])],
key=lambda t: t[3])
assert find_round_trips(transfers) == {("A", "B", "C"): 2}
def test_a_refund_is_a_two_account_loop_that_needs_min_len_2():
transfers = [(1, "A", "B", 5, 50_000), (2, "B", "A", 7, 50_000)]
assert find_round_trips(transfers) == {}
assert find_round_trips(transfers, min_len=2) == {("A", "B"): 1}
def test_a_loop_may_not_visit_an_account_twice():
transfers = [(1, "A", "B", 1, 100), (2, "B", "C", 1, 100), (3, "C", "B", 2, 100), (4, "B", "A", 3, 100)]
assert find_round_trips(transfers, min_len=2) == {("B", "C"): 1, ("A", "B"): 1}
def test_canonical_form_does_not_depend_on_where_the_ring_starts():
assert canonical(["C", "A", "B"]) == canonical(["B", "C", "A"]) == ("A", "B", "C")
def test_structural_cycles_see_shape_only():
graph = transfer_graph([(1, "A", "B", 1, 1), (2, "B", "C", 400, 1), (3, "C", "A", 900, 1), (4, "A", "D", 1, 1)])
assert list(structural_cycles(graph, 3)) == [("A", "B", "C")]
def test_triangle_count_grows_with_the_cube_of_the_group():
conn = make_db()
add_invoices(conn, [(f"V{n}", f"I-{n}", f"PO-{n}", 1000, "2026-03-01") for n in range(6)])
conn.executemany("INSERT INTO payments(invoice_id, account, amount_cents, paid_date) VALUES(?, 'ACC-X', 1000, '2026-04-01')",
[(n + 1,) for n in range(6)])
total, per_account = shared_account_triangles(conn)
assert total == 20 and per_account == {"ACC-X": 20}
python -m pytest -q
Expected output (the time varies):
.......................... [100%]
26 passed in 0.19s
Break the code on purpose
Passing tests only prove something if they would fail when the code is wrong. This script copies the project, changes one line at a time (drops the tolerance, lets a loop revisit an account, makes the window exclude its last day and so on), and runs the tests against each broken copy. Save it as mutations.py.
# mutations.py: break the code on purpose, one line at a time, and check that the tests notice.
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).parent
FILES = ["common.py", "dupes.py", "links.py", "loops.py", "mules.py", "test_fraudlab.py"]
MUTANTS = [
("dupes.py", '"AND ABS(a.amount_cents - b.amount_cents) <= :tolerance * a.amount_cents "', '"AND 1 = 1 "', "amount tolerance dropped"),
("dupes.py", "ON a.vendor_id = b.vendor_id AND a.invoice_id < b.invoice_id", "ON a.invoice_id < b.invoice_id", "duplicates compared across vendors"),
("dupes.py", " OR a.po_ref = b.po_ref)", ")", "purchase order match dropped"),
("dupes.py", 'return re.sub(r"^([A-Z]*)0+(?=[0-9])", r"\\1", letters_and_digits)', "return letters_and_digits", "leading zeros kept"),
("mules.py", "AND p.amount_cents < :limit", "AND p.amount_cents <= :limit", "limit itself counts as near the limit"),
("mules.py", "if min_near_limit_share is not None:", "if False:", "amount test ignored"),
("links.py", "graph.degree(node) > max_shared", "graph.degree(node) >= max_shared", "limit off by one"),
("loops.py", "if transfer[3] > last_day + window:", "if transfer[3] >= last_day + window:", "window excludes its last day"),
("loops.py", "elif transfer[2] not in {hop[1] for hop in path} | {here} and len(path) + 1 < max_len:",
"elif len(path) + 1 < max_len:", "loop may revisit an account"),
("loops.py", "> tolerance * first_amount:", "> tolerance * first_amount * 10:", "amount tolerance ten times too loose"),
("loops.py", "if len(path) + 1 >= min_len:", "if len(path) + 1 >= 2:", "min_len ignored"),
("loops.py", "start = accounts.index(min(accounts))", "start = 0", "ring not rotated to a standard start"),
]
def run_tests(folder):
done = subprocess.run([sys.executable, "-m", "pytest", "-q", "-x", "test_fraudlab.py"], cwd=folder,
capture_output=True, text=True)
return done.returncode
caught = 0
with tempfile.TemporaryDirectory() as scratch:
for number, (name, old, new, label) in enumerate(MUTANTS, 1):
folder = Path(scratch) / f"m{number}"
folder.mkdir()
for file in FILES:
shutil.copy(HERE / file, folder / file)
text = (folder / name).read_text(encoding="utf-8")
assert text.count(old) == 1, f"mutant {number}: {old!r} found {text.count(old)} times"
(folder / name).write_text(text.replace(old, new), encoding="utf-8")
failed = run_tests(folder) != 0
caught += failed
print(f"mutant {number:>2} {'caught ' if failed else 'SURVIVED'} {label}")
baseline = Path(scratch) / "baseline"
baseline.mkdir()
for file in FILES:
shutil.copy(HERE / file, baseline / file)
print(f"unchanged code passes: {run_tests(baseline) == 0}")
print(f"{caught} of {len(MUTANTS)} planted bugs caught")
python mutations.py
mutant 1 caught amount tolerance dropped
mutant 2 caught duplicates compared across vendors
mutant 3 caught purchase order match dropped
mutant 4 caught leading zeros kept
mutant 5 caught limit itself counts as near the limit
mutant 6 caught amount test ignored
mutant 7 caught limit off by one
mutant 8 caught window excludes its last day
mutant 9 caught loop may revisit an account
mutant 10 caught amount tolerance ten times too loose
mutant 11 caught min_len ignored
mutant 12 caught ring not rotated to a standard start
unchanged code passes: True
12 of 12 planted bugs caught
All 12 planted bugs are caught. One lesson came from writing this. My first tests had no case where a hop lands exactly on the last allowed day, so the bug “window excludes its last day” could not have been caught. I added the boundary test before running the script, and it is the one that kills that mutant. Check the edges of every threshold.
Try other seeds
Everything so far used seed 7. This script rebuilds the data with seeds 1 to 8 and prints precision and recall (as precision/recall) for each rule, then restores the seed 7 data. Save it as seeds.py.
# seeds.py: do the results depend on the random seed? Rebuild the data with seeds 1 to 8 and score each rule.
import subprocess
import sys
from common import connect, load_truth, score
from dupes import CAREFUL, SAME_AMOUNT, find_pairs
from links import attribute_graph, vendor_clusters
from loops import find_round_trips, load_transfers
from mules import account_features, flag_mules
def pr(result):
return f"{result['precision']:.0%}/{result['recall']:.0%}"
print(f"{'seed':>4} {'same amount':>12} {'careful':>9} {'no limit':>9} {'limit 6':>9} {'mule rule':>10} {'loops':>9}")
for seed in range(1, 9):
subprocess.run([sys.executable, "gen.py", str(seed)], check=True, capture_output=True)
conn, truth = connect(), load_truth()
duplicates = {(first, second) for first, second, _ in truth["duplicates"]}
rings = truth["rings"]
everyone = set(rings["A"] + rings["B"] + rings["M"])
graph = attribute_graph(conn)
flagged = lambda limit: {v for cluster in vendor_clusters(graph, limit) for v in cluster}
mules = {row["account"] for row in flag_mules(account_features(conn), min_vendors=10, min_near_limit_share=0.5)}
trips = {a for ring in find_round_trips(load_transfers(conn)) for a in ring}
print(f"{seed:>4} {pr(score(find_pairs(conn, SAME_AMOUNT, days=14), duplicates)):>12} "
f"{pr(score(find_pairs(conn, CAREFUL, days=30), duplicates)):>9} "
f"{pr(score(flagged(None), everyone)):>9} {pr(score(flagged(6), set(rings['A'] + rings['B']))):>9} "
f"{pr(score(mules, {truth['mule_account']})):>10} "
f"{pr(score(trips, {a for ring in truth['loops'] for a in ring})):>9}")
conn.close() # Windows will not let gen.py delete a database that is still open
subprocess.run([sys.executable, "gen.py"], check=True, capture_output=True) # put the default seed-7 data back
python seeds.py
seed same amount careful no limit limit 6 mule rule loops
1 3%/70% 100%/100% 22%/100% 82%/100% 100%/100% 100%/100%
2 2%/55% 100%/100% 22%/100% 82%/100% 100%/100% 100%/100%
3 2%/60% 100%/100% 22%/100% 82%/100% 100%/100% 100%/100%
4 3%/75% 100%/100% 22%/100% 82%/100% 100%/100% 100%/100%
5 3%/75% 100%/100% 22%/100% 82%/100% 100%/100% 100%/100%
6 2%/45% 100%/100% 22%/100% 82%/100% 100%/100% 80%/100%
7 2%/50% 100%/100% 22%/100% 82%/100% 100%/100% 100%/100%
8 2%/60% 100%/100% 22%/100% 82%/100% 100%/100% 100%/100%
The picture holds on every seed. The same-amount rule has a precision of 2 to 3 percent, clustering without a limit is stuck at 22 percent, the limit-6 clusters hold at 82 percent precision with full recall (the sister companies every time), and the careful duplicate rule and the mule rule are perfect on all eight. The round-trip search is perfect on seven seeds and reports 80 percent precision on seed 6, where a coincidental cycle slipped through. That is a useful reminder that these rules produce leads, even in a lab built to suit them.
Common mistakes and how to avoid them
- Reporting recall without precision. A rule can look respectable on recall and still be useless: the same-amount rule at 14 days raised 486 false alarms to find 10 real pairs. Always score both, and also count the items a reviewer must open.
- Letting one popular value decide. A registered-agent address, a payment platform or a co-working space can turn a clean analysis into one giant cluster. Count vendors per value before you cluster.
- Tuning a threshold against the answer key. A limit of 25 looked best because the planted mule ring has 25 vendors. Choose thresholds from your business knowledge, then measure.
- Enumerating cycles by shape. Millions of cycles are normal in a connected graph. Constrain by time and amount, and always bound the length.
- Calling a clique a cycle. Vendors that share an account form a triangle without any money moving between them. Name the two ideas correctly and use each for what it shows.
- Treating a flag as a verdict. Even the careful rules produce honest hits: the cluster rule flags the sister companies on every seed, and the round-trip search flagged a coincidental cycle on seed 6. Weaker variants flagged the payment platform, the weekly vendors and the refunds. Linking people and companies through phone numbers and addresses also touches personal data, so check your privacy obligations and keep a human in the loop.
Check that everything works end to end
With every file in one folder, this script runs the generator, all five demos, the tests, the mutation check and the seed sweep, and saves each output to an out folder. Save it as run_all.py.
# run_all.py: run every step and save its output to out/
import os
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).parent
OUT = HERE / "out"
OUT.mkdir(exist_ok=True)
STEPS = [("gen", ["gen.py"]), ("demo2", ["demo2_dupes.py"]), ("demo3", ["demo3_links.py"]),
("demo4", ["demo4_mules.py"]), ("demo5", ["demo5_loops.py"]), ("demo6", ["demo6_report.py"]),
("pytest", ["-m", "pytest", "-q", "test_fraudlab.py"]), ("mutations", ["mutations.py"]),
("seeds", ["seeds.py"])] # seeds.py rebuilds the data, so it runs last and restores seed 7
env = dict(os.environ, COLUMNS="170", PYTHONIOENCODING="utf-8")
for name, args in STEPS:
done = subprocess.run([sys.executable, *args], cwd=HERE, env=env, capture_output=True, text=True, encoding="utf-8")
with open(OUT / f"{name}.txt", "w", encoding="utf-8", newline="\n") as handle:
handle.write(done.stdout + done.stderr)
print(f"{name}: exit {done.returncode}")
python run_all.py
It takes about a minute. You should see one line per step, each ending in exit 0:
gen: exit 0
demo2: exit 0
demo3: exit 0
demo4: exit 0
demo5: exit 0
demo6: exit 0
pytest: exit 0
mutations: exit 0
seeds: exit 0
Then open the files in out and compare them with this page. The outputs should match line for line except the timing figures (seconds, and the number of cycles counted in 10 seconds). If a count differs, check that you generated the data with the default seed 7: seeds.py restores it at the end, but a run that you interrupt halfway leaves a different seed in place, and running python gen.py puts it back.
Where to go next
- Move to a graph database when the graph outgrows memory. NetworkX is ideal for learning and for graphs that fit in RAM. The freeCodeCamp article shows the same patterns as Cypher queries in Neo4j, and our Neo4j and Python tutorial walks through setting it up. I did not benchmark either at scale.
- Weight a shared detail by how rare it is instead of cutting it off with a hard limit, so a phone number used by two vendors counts for much more than an address used by 60. I did not test this here.
- Link on fuzzier keys. Real shells write “100 Agent Way” and “100 Agent Wy”. Our MinHash and locality-sensitive hashing tutorial shows how to find near-duplicate text at scale.
- Close the feedback loop. Record what reviewers decide for every flag, and use it to set thresholds and to build labelled data. The article says the same: the output of a detection query is “a hypothesis, not a final verdict”.
- Use the graph as a feature store. Degree, betweenness and PageRank per vendor are standard inputs to a classifier. NetworkX computes all three on the graph from Step 3.
- Stop duplicates at the door. Detection finds duplicates after they are paid. Idempotency keys stop a repeated request from creating a second charge in the first place.
Sources and further reading
- Kamal Kishore, How to Detect Financial Fraud Patterns with Knowledge Graphs: A Production Guide, freeCodeCamp, October 30, 2026: the five patterns, the Cypher queries and the validator output quoted above.
- NetworkX,
simple_cycles: thelength_boundoption and the cost of enumerating cycles. - NetworkX,
connected_components. - scikit-learn, Precision-Recall: definitions of precision and recall.
- Python, sqlite3, including
create_function, and SQLite, date and time functions.








No Comment! Be the first one.