Duplicate Invoices: catch the payments that went out twice
The data in this project is synthetic — generated locally by
data/make_data.py, with no external source. That is deliberate: you can only score a duplicate-payment screen if you know which rows are truly the same bill, and labelled AP data is exactly what companies never share. No signup, no downloads, no GPU.
Before you start
New to duplicate detection? Start here.
A duplicate payment is the same invoice paid twice — a genuinely expensive, genuinely
common accounts-payable error. The trouble is that the second payment rarely looks identical
to the first. The invoice number gets re-typed with different punctuation (INV-2025-00123
vs inv 2025 00123), or the bill gets entered under a completely different reference number
on a different day. So a plain “find rows where the invoice number repeats” misses most of the
money.
The craft is choosing the right matching keys. A key is the set of columns you treat as “the same invoice if these agree.” Match on too little and you flag unrelated invoices; match on the wrong column and you miss real duplicates. This project walks through three keys, each catching a kind of duplicate the previous one couldn’t.
A tiny example — three rows, all the same real bill:
invoice_number date amount how it hidesINV-2025-00123 2025-03-01 4,200.00 the originalinv 2025 00123 2025-03-08 4,200.00 reformatted number, later dateREF88213 2025-03-01 4,200.00 re-keyed under a manual referenceWhat you’ll learn: catch duplicates by an exact key, then a normalized key (so formatting stops hiding them), then a second key on vendor + amount + date (so a re-keyed number can’t hide them either) — and put a recoverable-dollars figure on the result. 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 year of accounts-payable rows and asked the question that pays for itself: which of these invoices did we pay twice, and how much can we claw back? You’ll build a screen that catches duplicates hiding behind reformatted and re-keyed invoice numbers, and report the recoverable amount.
Duplicate payments are a standard audit and recovery target — there’s an entire industry (“recovery audit”) built on finding them after the fact. The reason they survive is that each looks like a normal, separate invoice; only by matching on the right combination of fields do they surface. Getting the keys right is the whole job, and it’s the same skill whether the records are invoices, customers, or transactions.
2. Difficulty & time
Difficulty 3 / 10. One tool (pandas), one concept (duplicate detection by keys), and a
fully specified path — no algorithm, no modelling. Calibrated against the ledger (G10): a peer
of silent-row-loss, idempotent-loads, and benford-fraud (all 3) — single-mechanic,
key-and-count builds where the lesson is a specific data-handling judgment, not a pipeline. It
sits below maverick-spend (4), which layers a conditional rule and a second detection
type into one screen. It stays above the 1–2 band because the naive first cut genuinely fails
and the fix (normalized + multi-column keys) is a real technique, not a one-liner.
Time is separate from difficulty: Part A is about an hour; Part B is 2–3 hours of your own work. The scripts run in under a second — the time is comprehension.
3. What you’ll be able to do after
- Detect exact duplicates with
duplicated(keep=False)and explain what thesubsetandkeeparguments actually change. - Normalize a key so reformatting (punctuation, case, spacing) stops hiding duplicates.
- Match on a composite key (vendor + amount + date) to catch duplicates that were re-keyed under a different identifier entirely.
- Reconcile and dollarize — count the redundant payments and total the recoverable amount.
The finished result
By the end of Part A you’ll have a screen that catches more duplicates with each better key, ending in a figure an auditor can act on:
exact-match key ............. 198 rows flagged+ normalized number ......... 368 rows flagged (recovered the reformatted re-entries)recoverable amount .......... about $1.9M paid twiceThe catch nearly doubles once formatting stops hiding duplicates — and “$1.9M we paid twice” is the deliverable, not the row count.
4. Prereqs & time box
You can write a function and use a pandas DataFrame (groupby, duplicated, boolean masks).
No accounts-payable background needed — the primer above covers it.
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 only pandas, which runs 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 pandas"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 both files:
python data/make_data.pyThat writes two CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical
because the seed is fixed):
- Part A —
invoices_a.csv(1,384 rows). Duplicates are exact or reformatted only. - Part B —
invoices_b.csv(3,479 rows). Adds re-keyed duplicates.
Each row is invoice_id, vendor, invoice_number, invoice_date, amount. Whether a row is a
duplicate is not in the file — the grader recovers it from the seed.
6. Part A — Guided Build
You’ll screen the 1,384-row invoices_a.csv. Run the whole thing, or (better) one # %% cell
at a time — see Start Here:
python part_a/find_duplicates.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.
▶ Try it now — run the real Part A right here in your browser. This one button makes the data and runs Part A for you — you do not need to install anything, and you do not need to type any of the python … commands anywhere on this page (those are only for running on your own computer instead). First run loads Python + this project’s libraries: usually ~10–20 s, a little longer for machine-learning projects.
The full, commented script is in part_a/find_duplicates.py.
The spine:
Step 1 — The ledger. How many rows, how much paid, how many vendors.
Checkpoint: 1,384 rows, ~$14M paid.
Step 2 — Exact match on (vendor, invoice_number). duplicated(keep=False) flags every row
that shares its key.
Checkpoint: 198 rows flagged. But re-entries whose invoice number was formatted differently slip past — exact match sees a different string.
Step 3 — Normalize the invoice number, then match. Strip to letters and digits, lower-case,
and match on (vendor, normalized number).
Checkpoint: 368 rows — nearly double. Normalizing recovered the reformatted re-entries.
Step 4 — Dollarize. For each duplicate group, every copy after the first is recoverable.
Checkpoint: ~$1.9M recoverable across the duplicate rows. Writes
part_a/flagged_a.csv.
Why this and not that
- Why
keep=False, not the defaultkeep='first'. The default marks only the extra copies as duplicates;keep=Falsemarks every row in a duplicate group. For an audit you want to see both the original and the copy side by side, sokeep=Falseis the honest view. - Why normalize the key instead of trusting the invoice number. The same number typed with
a dash vs. a space is two different strings to
==. Normalizing to letters-and-digits makes the key reflect the number, not its formatting — the single change that doubles the catch. - Why report dollars, not a row count. “368 duplicate rows” doesn’t authorize a recovery; “$1.9M we paid twice” does. The dollar figure is the deliverable.
Functions you’ll meet (and where to read more)
| Function | What it does here | Docs |
|---|---|---|
df.duplicated(subset, keep=False) | Flag every row in a duplicate group | duplicated |
s.map(fn) | Normalize each invoice number | map |
str.isalnum() | Keep only letters and digits when normalizing | str.isalnum |
df.groupby(keys)[c] | Group duplicate rows to total the recoverable amount | groupby |
boolean mask df[mask] | Select the flagged rows | boolean indexing |
If something looks off
- “368 flagged rows” but only ~$1.9M recoverable — why not more?
keep=Falseflags every row in a duplicate group, so the 368 includes the originals and their copies. Only the copies after the first are money you can claw back, which is why the dollar figure is smaller than the row count suggests. That’s correct, not a mistake. - 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, bigger file, and a duplicate that hides behind a different number. Screen
the 3,479-row invoices_b.csv and flag every row that is part of a duplicate payment. The
starter (part_b/starter.py) uses Part A’s normalized-invoice-number key and scores ~0.83 —
below the floor.
The transfer: some duplicates were re-keyed — entered under a completely different
invoice-number string (a manual reference). No amount of normalizing the number will match
them, because the number genuinely differs. They do share (vendor, amount, invoice_date) —
a second key you didn’t need in Part A.
Acceptance criteria (checkable)
Your solution writes into part_b/out/:
flagged.csv— one columninvoice_id, every row you flag as a duplicate.report.json— with keysrows,flagged,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.90 against the true duplicate set (the reference reaches ~1.00). Invoice-number-only scores ~0.83 and fails — the second key is what clears the floor.
Constraints — what must be true of your solution, not how to get there
- Catch all three kinds: exact, reformatted-number, and re-keyed duplicates.
- Use
keep=Falseso you flag every row in a duplicate group, not just the second copy. - Don’t over-flag: your flagged count should be near the true ~960, not the whole file.
Hints
Hint 1 — reuse, then extend
Start from Part A’s normalized-invoice-number key. Run it on invoices_b.csv and note it
plateaus below the floor — those missing rows are the re-keyed duplicates.
Hint 2 — the second key
A re-keyed duplicate shares the vendor, the amount, and the date with its original.
df.duplicated(subset=["vendor", "amount", "invoice_date"], keep=False) finds them.
Hint 3 — combine, don't replace (still not the code)
A row is a duplicate if it’s caught by either key. Combine the two boolean masks with |
(logical OR) and flag every row that’s True in the union.
8. Self-check
You don’t need the answer key. You’re done when all of these hold:
pytestreports F1 ≥ 0.90 (aim for ~1.00 — the keys are exact once you have both).- Your flagged count is clearly above the invoice-number-only result (you added the second key) and not wildly above ~960 (you’re not over-matching).
- Re-running from
python data/make_data.pyreproduces the same numbers — the data is seeded.
And the tell-tale: if adding the second key didn’t raise your flagged count, you haven’t actually applied it — the re-keyed duplicates are invisible to the invoice-number key.
9. Stretch
- Rank the recoveries. Report the recoverable dollars by vendor, so the biggest claw-backs are top of the list.
- Tighten the second key.
(vendor, amount, date)can occasionally collide for two genuinely different invoices. Add a tolerance or a tie-breaker and measure whether precision improves without hurting recall. - (Genuinely hard) Rounding duplicates. Introduce copies whose amount differs by a few cents (a keying error) and extend the amount key to match within a tolerance — the start of fuzzy numeric matching.
10. Ship it
Put this in a portfolio repo and it counts. In that repo’s README:
- State the finding in one sentence a controller gets: “we paid ~$X twice; here’s the screen that found it and the vendors to recover from.”
- Show the catch climbing across the three keys — exact, normalized, and vendor+amount+date — and the recoverable total.
- Include the combined-key function and one paragraph on why one key is never enough. That paragraph is the part that signals you’ve actually done duplicate detection.
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 accounts-payable ledger with duplicate payments (Part A 1,384, Part B 3,479 rows) |
| 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 duplicate patterns were injected on purpose — recovering them demonstrates the technique, not a claim about any real company’s payments.
Next up
Finished this one? Continue the Data Analytics track:
Silent Row Loss: the join that quietly drops (and duplicates) your data → · Browse all courses