Skip to content
About
Main content

How many rows must you sample, and what does blocking cost?

Simulate a modelNeeds Python with NumPy

Problem

Sampling finds a defect only if the sample happens to include a defective row. For a p, a sample of n rows contains at least one with probability 1 − (1 − p)ⁿ, and finding it 95% of the time takes about n ≈ 3/p rows (the rule of three). Rare defects therefore need large samples, and the rarest are often the costliest. The same "you cannot find what you never compare" applies to duplicate search: decides which pairs are ever compared, and its keys cap .

Part (a): create 1,000,000 rows with a defect rate p of 10⁻³, 10⁻⁴, 10⁻⁵. For sample sizes 300, 3,000, 30,000, 300,000, run 1,000 trials and measure the fraction of trials that find at least one defect. Compare with 1 − (1 − p)ⁿ and with the rule n ≈ 3.0 ÷ p for 95%. Part (b): take 100,000 synthetic customer names with typos in 3% of the postcodes. Block by postcode, then by postcode plus a second key (phone prefix). Measure the recall of true duplicate pairs and the number of candidate pairs for each blocking plan.

The question. How often does a sample of each size find a defect at each rate, and how do recall and candidate pairs change when blocking adds a second key?


Predict

At a defect rate of 1 in 100,000, how often does a 300,000-row sample (3/p) find a defect?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    how-many-rows-must-you-sample.py63 linesDownload
    View the code
    how-many-rows-must-you-sample.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """How many rows must you sample?
    
    (a) 1,000,000 rows with a defect rate p; for sample sizes from 300 to
    300,000, the share of 1,000 trials that find at least one defect, against
    1 - (1 - p)^n and the rule n = 3 / p for 95%. (b) 100,000 customers with 3%
    postcode typos, blocked by postcode, then by postcode or phone prefix.
    """
    import argparse, json, random
    from collections import defaultdict
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=1_000_000, help="Rows in the table.")
    p.add_argument("--rates", type=lambda s: [float(v) for v in s.split(",")], default=[1e-3, 1e-4, 1e-5], help="Defect rates p.")
    p.add_argument("--samples", type=lambda s: [int(v) for v in s.split(",")], default=[300, 3000, 30000, 300000], help="Sample sizes n.")
    p.add_argument("--trials", type=int, default=1000, help="Trials per (p, n).")
    p.add_argument("--customers", type=int, default=100_000, help="Part (b): customers.")
    p.add_argument("--typo", type=float, default=0.03, help="Part (b): share of duplicates with a postcode typo.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    rng = np.random.default_rng(args.seed); random.seed(args.seed)
    
    rows = []
    for rate in args.rates:
        defects = rng.random(args.rows) < rate
        for n in args.samples:
            found = np.mean([defects[rng.integers(0, args.rows, n)].any() for _ in range(args.trials)])
            rows.append({"p": rate, "n": n, "found_pct": float(found * 100), "formula_pct": (1 - (1 - rate) ** n) * 100})
        print(f"p = {rate:g}: " + "   ".join(f"n={r['n']:,}: {r['found_pct']:5.1f}% (formula {r['formula_pct']:5.1f}%)"
                                             for r in rows if r["p"] == rate) + f"   rule 3/p = {3 / rate:,.0f}")
    
    # (b) blocking caps recall
    people = [{"id": i, "pc": f"{random.randrange(5000):04d}", "phone": f"{random.randrange(1000):03d}"} for i in range(args.customers)]
    dups = []
    for r in random.sample(people, args.customers // 10):
        pc = r["pc"] if random.random() > args.typo else f"{random.randrange(5000):04d}"
        dups.append({"id": r["id"], "pc": pc, "phone": r["phone"]})
    recs = people + dups
    plans = {"postcode": [("pc",)], "postcode, then phone prefix": [("pc",), ("phone",)]}
    blocking = []
    for name, passes in plans.items():
        cand = set()
        for keys in passes:
            groups = defaultdict(list)
            for i, r in enumerate(recs): groups[tuple(r[k] for k in keys)].append(i)
            for g in groups.values():
                for a in range(len(g)):
                    for b in range(a + 1, len(g)): cand.add((g[a], g[b]))
        true = sum(1 for a, b in cand if recs[a]["id"] == recs[b]["id"])
        blocking.append({"plan": name, "recall_pct": true / len(dups) * 100, "candidate_pairs": len(cand)})
        print(f"blocking by {name:<28} recall {true / len(dups):6.1%}   candidate pairs {len(cand):,}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"sampling": rows, "blocking": blocking}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run how-many-rows-must-you-sample.py
    uv run how-many-rows-must-you-sample.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows1,000,000 Rows in the table.
--rates0.001, 0.0001, 1e-05 Defect rates p.
--samples300, 3000, 30,000, 300,000 Sample sizes n.
--trials1000 Trials per (p, n).
--customers100,000 Part (b): customers.
--typo0.03 Part (b): share of duplicates with a postcode typo.
--seed0 Random seed.

Our run

Share of 1,000 trials that found a defect

Share of 1,000 trials that found a defect
Defect rate pSample nFound1 − (1 − p)ⁿ
0.00130026.2%25.9%
0.0013K94.3%95%
0.00130K100%100%
0.001300K100%100%
03004.2%2.96%
03K29.2%25.9%
030K96.3%95%
0300K100%100%
03000%0.3%
03K1.6%2.96%
030K19%25.9%
0300K87.9%95%

Duplicate search on 100,000 customers

Duplicate search on 100,000 customers
BlockingRecallCandidate pairs
postcode96.9%1.22M
postcode, then phone prefix100%7.26M
Reference runApple M2 · 8 cores · 8 GB
Output · uv run how-many-rows-must-you-sample.py
p = 0.001: n=300:  26.2% (formula  25.9%)   n=3,000:  94.3% (formula  95.0%)   n=30,000: 100.0% (formula 100.0%)   n=300,000: 100.0% (formula 100.0%)   rule 3/p = 3,000
p = 0.0001: n=300:   4.2% (formula   3.0%)   n=3,000:  29.2% (formula  25.9%)   n=30,000:  96.3% (formula  95.0%)   n=300,000: 100.0% (formula 100.0%)   rule 3/p = 30,000
p = 1e-05: n=300:   0.0% (formula   0.3%)   n=3,000:   1.6% (formula   3.0%)   n=30,000:  19.0% (formula  25.9%)   n=300,000:  87.9% (formula  95.0%)   rule 3/p = 300,000
blocking by postcode                     recall  96.9%   candidate pairs 1,217,865
blocking by postcode, then phone prefix  recall 100.0%   candidate pairs 7,262,836
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What we found

Detection followed the formula: at p = 10⁻³ a sample of 3,000 (= 3/p) found a defect 94.3% of the time, at p = 10⁻⁴ a sample of 30,000 did 96.3%, and at p = 10⁻⁵ the 300,000-row sample managed 87.9%, below the formula's 95% because a million rows at that rate hold only about ten defects. Blocking by postcode alone reached a recall of 96.9%; adding a pass on phone prefix raised it to 100%, and raised the candidate pairs from 1.22M to 7.26M, as expected.


Read it

Sampling finds defects at a rate of about 1 in n, and severity usually grows as 1 over prevalence, so sample by exposure, not by row: along whatever defects correlate with, and verify the few rows that hold most of the value. For duplicate search, run several blocking passes on independent keys, so that one typo cannot hide a pair.