leadmatch
Fast, standalone lead/contact deduplication (identity resolution) for messy CRM data — with a reproducible benchmark proving the speedup over naive all-pairs fuzzy matching is algorithmic, not implementation slop.
Why
Leads arrive from web forms, ad campaigns, calls, and SMS. The same person
shows up as Bob Smith / bob.smith+promo@gmail.com and
Robert Smith / bobsmith@gmail.com / (555) 123-4567. Every duplicate lead
wastes ad spend on re-targeting, splits outreach history across records, and
double-counts pipeline. Deduplicating naively means comparing every record
with every other — 1.25 billion pairs at 50k records — which stops scaling
long before a real lead database does.
leadmatch fixes the asymptotics with blocking: candidate pairs come from
cheap indexes (normalized email, phone, phonetic name keys, MinHash LSH), and
only those candidates get scored. It has no external services and makes no
network calls, so it can run in a batch job, a worker, or a microservice.
Install
pip install leadmatch # from PyPI
# or from a checkout:
pip install -e .
# runtime deps: rapidfuzz, datasketch, faker, typer, pandas, numpy
Quickstart
# 1. Generate a synthetic dataset with ground truth (or bring your own CSV)
leadmatch gen --n 50000 --dup-rate 0.3 --seed 42 --out leads.csv
# 2. Deduplicate: one canonical record per cluster
leadmatch run --input leads.csv --out merged.csv --clusters-out clusters.csv
# 3. Reproduce the benchmark below
leadmatch benchmark --sizes 10000,50000 --seed 42 --out BENCHMARK.md
Input CSV columns (all optional, but each record needs at least one of
email / phone / full name):
first_name, last_name, email, phone, company, city, zip, source, created_at.
As a library:
from leadmatch import Lead, MatchConfig, dedupe_leads
leads = [
Lead("a", first_name="Bob", last_name="Smith", email="bob.smith+ads@gmail.com"),
Lead("b", first_name="Robert", last_name="Smith", email="bobsmith@gmail.com"),
]
result = dedupe_leads(leads, MatchConfig(threshold=0.85))
result.canonical # one merged record per cluster
result.assignment # record_id -> cluster_id
How it works
- Normalize — emails are lowercased,
+tagsuffixes stripped, Gmail dots removed; phones reduced to digits with US country code dropped; names lowercased, punctuation-stripped, nicknames canonicalized via a built-in ~190-entry map (bob→robert,liz/beth→elizabeth, …), and Double Metaphone codes computed by a from-scratch implementation. - Block — candidate pairs are the union of: exact normalized email,
phone (last 7 digits),
(metaphone(last), zip),(metaphone(last), city),(first_initial, metaphone(last)), and MinHash LSH (viadatasketch) over character 3-gram shingles of"first last email phone"at Jaccard ≈ 0.5 as a fuzzy catch-all. - Score — only candidate pairs, vectorized with
rapidfuzz: Jaro-Winkler name similarity on canonicalized full names (max over swapped first/last), structure-aware email similarity (digit runs in the local part are identity-bearing:jsmith12≠jsmith87), exact phone, and company+city similarity, combined as a weighted composite (email 0.35, phone 0.25, name 0.30, company+city 0.10 — renormalized over fields present in both records). Exact email auto-matches; exact phone with a strong name auto-matches; otherwise composite ≥ threshold (default 0.85) with a minimum-evidence floor so a shared common name + city alone never merges two different people. - Cluster — matched pairs feed a from-scratch union-find (path compression + union by rank); each cluster is merged into one canonical record using the most complete value per field.
Benchmark
Latest committed run (see BENCHMARK.md for the full report and
results.json for machine-readable numbers):
| records | pipeline | wall time (s) | pairs scored | peak mem (MB) | precision | recall | F1 |
|---|---|---|---|---|---|---|---|
| 10,000 | naive | 52.8 | 49,995,000 | 747 | 0.9857 | 0.9525 | 0.9688 |
| 10,000 | blocked | 3.2 | 41,481 | 228 | 0.9858 | 0.9493 | 0.9672 |
| 50,000 | naive | ~2,762.0 (estimated) | 1,249,975,000 | 480 | — | — | — |
| 50,000 | blocked | 18.6 | 953,066 | 897 | 0.9661 | 0.9437 | 0.9548 |
- 10,000 records: blocking scores 1,205× fewer pairs and is 16.6× faster end-to-end, at the same accuracy as naive (F1 0.9672 vs 0.9688, identical precision).
- 50,000 records: blocking scores 1,312× fewer pairs and is ~148× faster (the naive run was projected past the time cap and its wall time extrapolated from a same-scorer random pair sample, clearly flagged above).
Machine: Linux x86_64, Python 3.11, 4 CPU cores; seed 42, dup rate 0.3, threshold 0.85, naive time cap 900 s.
Regenerate with leadmatch benchmark --sizes 10000,50000 --seed 42.
Methodology & honesty notes
The benchmark is designed so the delta can only come from the algorithm:
- Same scorer both paths. The naive baseline and the blocked pipeline call the same normalizer, the same rapidfuzz scoring functions, and the same thresholds. The only difference is candidate generation: naive scores all O(n²) pairs; blocked scores pairs produced by blocking + LSH. The wall-time and pairs-scored gaps are purely algorithmic.
- Seeded synthetic data. Datasets come from
leadmatch gen, which is fully deterministic per seed and ships in this repo, including the hiddenentity_idground-truth column used for pairwise precision/recall/F1. - Honest timing. Each benchmark leg runs in its own subprocess: wall time
is measured around the end-to-end pipeline, and peak memory is the
process-lifetime
ru_maxrssof that leg alone. - Time-capped naive runs are labeled. If a naive run is projected (from a random 1M-pair sample scored with the same scorer) to exceed the time cap (default 30 min), its wall time is extrapolated and clearly marked estimated; accuracy metrics are not reported for extrapolated runs.
- Reproduce it:
pip install -e . && leadmatch benchmark --sizes 10000,50000 --seed 42.
Limitations
- US-centric phone handling. Normalization assumes NANP numbers (drops a
leading
1, matches on the last 7–10 digits). International numbers work only incidentally. - Synthetic ≠ production. The generator's corruption distribution (typos, nicknames, tag/domain changes, reformatting) is realistic but stylized; real lead streams have different noise. Treat the benchmark as a controlled comparison of candidate-generation strategies, not a production accuracy claim.
- Precision-first design choices. Records sharing only a name and
geography never merge (common-name homonyms outnumber true matches there),
and a shared phone with two clearly different names is treated as a
household/office line, not a duplicate. Tune
MatchConfigif your data says otherwise. - English nickname map. The built-in canonicalization covers common English given names only.
- Blocking is approximate by construction: pairs sharing no block key and falling under the LSH threshold are never scored. The benchmark quantifies the resulting recall gap against the naive baseline (≈0.3% of gold pairs at 10k in the committed run).
Development
pip install -e ".[dev]"
ruff check .
pytest
CI runs lint, the full test suite, and a 5k-record mini-benchmark on every push.
License
Metadata
Release files for leadmatch 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| leadmatch-0.1.0.tar.gz | 72.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| leadmatch-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 108.8 kB
Release files / leadmatch-0.1.0.tar.gz
| Download URL | leadmatch-0.1.0.tar.gz |
|---|---|
| Size | 72.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3cecacbe502e776e8720ef1805855e34fb3ab17a0eab9e27f459dd7bdd03bffe
|
|
BLAKE2b-256 checksum How to use checksums |
897f549c90b37c0613c12d15450e16da5716600cde1432534f021f7a42c4fb68
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.1
|
Release files / leadmatch-0.1.0-py3-none-any.whl
| Download URL | leadmatch-0.1.0-py3-none-any.whl |
|---|---|
| Size | 36.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
17b6493d7d8a8129f58aeb659275cb00d6c5d43cb4d004360f5884e6d141a583
|
|
BLAKE2b-256 checksum How to use checksums |
6a59a49f326396b0939a6dcffb1de26f036b1b37987d6d3ccb60aab2b726fd55
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.1
|