Skip to content

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 hides
INV-2025-00123 2025-03-01 4,200.00 the original
inv 2025 00123 2025-03-08 4,200.00 reformatted number, later date
REF88213 2025-03-01 4,200.00 re-keyed under a manual reference

What 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 the subset and keep arguments 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 twice

The 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 once python -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:

Terminal window
python data/make_data.py

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

  • Part Ainvoices_a.csv (1,384 rows). Duplicates are exact or reformatted only.
  • Part Binvoices_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:

Terminal window
python part_a/find_duplicates.py

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

▶ 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 default keep='first'. The default marks only the extra copies as duplicates; keep=False marks every row in a duplicate group. For an audit you want to see both the original and the copy side by side, so keep=False is 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)

FunctionWhat it does hereDocs
df.duplicated(subset, keep=False)Flag every row in a duplicate groupduplicated
s.map(fn)Normalize each invoice numbermap
str.isalnum()Keep only letters and digits when normalizingstr.isalnum
df.groupby(keys)[c]Group duplicate rows to total the recoverable amountgroupby
boolean mask df[mask]Select the flagged rowsboolean indexing

If something looks off

  • “368 flagged rows” but only ~$1.9M recoverable — why not more? keep=False flags 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 # TODO it marks out. More open than Part A’s step-by-step, but you are never staring at an empty file. If you finished Part A and want to prove the skill, this is where it happens. New to Python? It’s completely fine to stop after Part A and come back later.

Same core skill, 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 column invoice_id, every row you flag as a duplicate.
  • report.json — with keys rows, flagged, achieved_metric (you can leave achieved_metric null; the grader scores you).

Then:

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

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

You’re done when the tests pass: your flags reach F1 ≥ 0.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=False so 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:

  • pytest reports 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.py reproduces 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.

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

Synthetic data teaches the mechanic; it does not prove a finding about the real world. The 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