Pipeline to Dashboardnihalsulaiman.com

Week 2 · Clean and trust the data › Day 12

Day 12. Duplicates, keys and entity resolution

⏱ 4h · learn 65m · lab 135m · ship 25m · review 15m🏷 QSR, hospitality, retailBefore: Day 11

By the end of today you can

  • Tell true duplicates from repeat lines and corrected versions
  • Keep the latest version per business key with window functions
  • Build surrogate and natural keys deliberately
  • Match messy names (stores, guests, products) with fuzzy matching and know its limits

1. Why this matters

Double-counted revenue is the bug that ends trust in a dashboard. Duplicates come in three kinds and each needs a different fix. Mix them up and you'll either double-count or delete real sales.

2. Three kinds of "duplicate"

Kind Example Fix
Exact re-send POS re-sent a batch; all columns identical distinct on all business columns (keep raw lineage separately)
New version Hotel booking 512004 appears again with status=cancelled Keep the latest version per business key
Genuine repeat Two separate Classic Burgers on one order Not a duplicate. Needs a line number or aggregation
-- latest version per booking (DuckDB / Postgres / T-SQL all support this)
select * from (
  select *, row_number() over (partition by booking_id order by file_received_at desc, src_row desc) rn
  from raw_hotel_bookings
) where rn = 1;

-- DuckDB shortcut
select * from raw_hotel_bookings qualify row_number() over (partition by booking_id order by file_received_at desc) = 1;
Maths and principle

Principle: deduplication picks one representative from each equivalence class of rows.

Equation: define \(r \sim r'\) iff \(K(r) = K(r')\). The deduplicated table is one row from each class in \(T/\!\sim\), chosen by an order (latest received). \(|T/\!\sim| = |\pi_K(T)|\).

Analogy: reducing fractions: 2/4, 3/6 and 1/2 are one class with representative 1/2. Where it breaks: if \(K\) is too coarse (order_id only), you merge rows that aren't equivalent, which is how real sales disappear.

Try it: 1,000 booking rows, 880 distinct booking_ids. After keeping the latest per id, how many rows? (880.)

3. Keys you create

  • Natural key: from the business (booking_id, store_code, sku). Can change, collide across systems, or be reused.
  • Surrogate key: made by you, meaningless, stable. In a warehouse an int identity or a hash of the natural key plus source: md5(source || '|' || booking_id).
  • Line number: when the source has none, create one deterministically: row_number() over (partition by order_id order by src_row). Deterministic means a rerun gives the same numbers.

4. Entity resolution

"Northgate Market #104", "NORTHGATE MKT 104" and "Northgate - Store 104" are the same store in three systems. Steps: normalise (case, punctuation, abbreviations), block (only compare candidates sharing a region or number), score (string similarity), decide with a threshold, and keep a manual mapping table that overrides the algorithm.

from rapidfuzz import process, fuzz
canon = stores["store_name"].tolist()
match, score, idx = process.extractOne("NORTHGATE MKT 104", canon, scorer=fuzz.token_sort_ratio)

For people (guest records across hotels, loyalty members) use a probabilistic matcher like Splink (open source, runs on DuckDB), which learns weights for each field.

The 5-year lens

The manual mapping table is the real deliverable. Algorithms suggest; a person confirms; the table (map_store_alias(alias, store_code, confirmed_by, confirmed_on)) is version-controlled and wins over any score. Nobody ever got blamed for a reviewed mapping. Also: any matching of people raises privacy questions (PIPEDA in Canada). Match on what you're allowed to use, and document it.

Lab

  1. In qsr-clean, find the re-sent POS batches: count rows that are identical on all business columns. Remove them; log how many per restaurant per day.
  2. Prove the "two burgers" case is real: find orders with the same menu_item_id twice that are not part of a re-sent batch (hint: compare against the file's original row positions). Add a deterministic line_no.
  3. Hotel: from all PMS files, build stg_bookings_latest (latest per booking_id). Count how many bookings changed status at least once.
  4. Make a messy alias list for the 12 retail stores (ask Claude Code to generate 40 variants with typos). Resolve them with rapidfuzz, pick a threshold, measure precision and recall against the truth, then write map_store_alias.csv for the misses.
  5. Write tests for each rule.

Ship

feat(clean): dedup rules, deterministic line numbers, store alias resolution.

Check yourself

1. A booking_id appears in three files with statuses confirmed, confirmed, cancelled. What does staging keep?

2. Why must a generated line number be deterministic?

3. Fuzzy matching gives "Northgate 110" a 92% match to store #101. What's the safe process?

My notes