Case study: the same doctor, two federal registries, two different addresses
If you work with US provider data, you already know this problem — you just may not have named it yet. The same physician is in NPPES because they have an NPI, and in CMS Open Payments because a device manufacturer bought them lunch. Both records are self-reported. Both carry an address a human typed on a different day, into a different form, under different validation rules. Neither is wrong, exactly. They just don't match.
NPPES: JOHN Q SAMPLE · 4510 MEDICAL CENTER DR STE 200, MCALLEN, TX 78503
Open Payments: John Sample · 4510 Medical Ctr Drive, McAllen, TX 785031234
Join on name + address and you lose this pair. Fuzzy-match the strings and you drown in false positives from the medical building next door. So the join gets done by hand, or it doesn't get done — and a compliance team ends up with two provider universes that never reconcile.
Geocode-first move
Mailwoman's record matcher doesn't try to make the strings agree. It resolves each record to a place — parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. the address with the neural parser, geocode it, take the coordinate and the canonical keycanonical keyA deterministic, normalized string representation of an address produced by @mailwoman/formatter. Lowercase, abbreviation-expanded, punctuation-stripped — so '123 Main St' and '123 MAIN STREET' produce the same key. Used for blocking in the matcher. — and then asks a smaller, better question: of the records at (or near) this place, which are the same entity? BlockingblockingThe first stage of entity resolution: generate candidate record pairs using cheap, high-recall keys (geo cell, canonical address, phone) instead of comparing every record to every other (O(n²)). The matcher only scores pairs that survive blocking. happens on the map, not on the text, so "STE 200" versus no suite, "Dr" versus "Drive", ZIP versus ZIP+4 stop mattering before the comparison even starts.
What's left is a scoring problem: same place, similar-enough name — one provider or two? For within-registry dedup we ship a learned scorer trained on NPI-grouped records. But cross-registry linking is the opposite objective: the dedup scorer learned that "same address, names disagree" usually means two co-located providers, so it rejects exactly the drifted-text pair a cross-registry join wants to keep. Objectives, not thresholds — a lesson we measured the hard way before fixing it properly.
Training on truth nobody had to label
This part is worth stealing. The two registries share the NPI column — an external key the matcher's featuresfeatureAn input signal a model conditions on. Beyond the raw tokens, Mailwoman feeds soft features — gazetteer-membership channels and the postcode anchor — that inform predictions without overriding them. never see. Join NPPES to Open Payments on NPI and you get thousands of ground-truth "same provider, independently typed text" pairs, for free, with no annotation budget. Train a scorer on those labelscomponent tagOne of the 33 labels in Mailwoman's address schema — street, locality, region, postcode, house_number, unit, po_box, country, venue, intersection, and others. Each parsed span carries exactly one component tag. and it learns the cross-registry objective directly: address drift and name drift are what agreement looks like across sources.
Both files are public domain. The whole pipelinestaged pipelineMailwoman's runtime architecture: a sequence of pure-function stages (normalize → query-shape → locale-gate → kind-classifier → phrase-grouper → classifier → decoder) connected by typed handoffs. Each stage is published as its own npm package. — download, parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates., geocode, block, train — runs on one machine, with no API keys and no per-row geocodinggeocodingThe process of converting an address into geographic coordinates (latitude and longitude). Mailwoman geocodes in a multi-tier cascade: exact address-point match → street interpolation → locality centroid. Each tier is progressively coarser but more widely available. invoice.
Numbers
Texas, one afternoon, one machine. 1,954 practitioners present in both registries (the NPI join), 3,908 records — 100% geocoded. BlockingblockingThe first stage of entity resolution: generate candidate record pairs using cheap, high-recall keys (geo cell, canonical address, phone) instead of comparing every record to every other (O(n²)). The matcher only scores pairs that survive blocking. produced 175,200 cross-registry candidate pairs, 1.1% of them true same-provider pairs by the NPI key.
| held-out (unseen NPIs) | value |
|---|---|
| recallrecallOf the spans whose gold label is a given tag, the fraction the model found. High recall means few misses. Paired with precision to compute F1. at the ≥95%-precisionprecisionOf the spans the model labeled as a given tag, the fraction it got right. High precision means few false positives. Paired with recall to compute F1. operating point | 100.0% |
| F1 at the F1-max threshold | 99.2% |
| annotation cost | $0 — the NPI join labeled everything |
Every drifted pair like the one at the top of this page links; the co-located stranger next door doesn't. That's the whole job.
One boundary, because we measure these things: this scorer is trained on practitioners — people, with given and family names. Pointed at organization-level cross-dataset linking (clinic rosters, funding filings), its person-name featuresfeatureAn input signal a model conditions on. Beyond the raw tokens, Mailwoman feeds soft features — gazetteer-membership channels and the postcode anchor — that inform predictions without overriding them. go dark and it over-links; the classical Fellegi–Sunter baseline stays pinned there until an org-level labelcomponent tagOne of the 33 labels in Mailwoman's address schema — street, locality, region, postcode, house_number, unit, po_box, country, venue, intersection, and others. Each parsed span carries exactly one component tag. source exists. The lesson generalizes: a learned matcher is only as portable as its labelscomponent tagOne of the 33 labels in Mailwoman's address schema — street, locality, region, postcode, house_number, unit, po_box, country, venue, intersection, and others. Each parsed span carries exactly one component tag., so we scope it to where its labelscomponent tagOne of the 33 labels in Mailwoman's address schema — street, locality, region, postcode, house_number, unit, po_box, country, venue, intersection, and others. Each parsed span carries exactly one component tag. came from — and say so.
Run it yourself
# The public source files (both public domain):
# NPPES registry — https://download.cms.gov/nppes/NPI_Files.html
# Open Payments covered-recipient profile — https://openpaymentsdata.cms.gov
# Train the cross-source scorer on NPI-joined pairs:
node scripts/record-matcher/train-cross-gbt.ts --state TX --npis 2000
# Grade it against the FS baseline (cross-source links + the phone-corroboration proxy):
node scripts/record-matcher/cross-source-threshold-sweep.ts \
--candidate registry/models/crosssource-gbt-en-us.ts
The same recipe extends to any registry pair with a shared key — PECOS by NPI, SAM.gov by UEI, IRS BMF by EIN. The key gives you labelscomponent tagOne of the 33 labels in Mailwoman's address schema — street, locality, region, postcode, house_number, unit, po_box, country, venue, intersection, and others. Each parsed span carries exactly one component tag.; the geocoder gives you the blockingblockingThe first stage of entity resolution: generate candidate record pairs using cheap, high-recall keys (geo cell, canonical address, phone) instead of comparing every record to every other (O(n²)). The matcher only scores pairs that survive blocking.; the scorer learns the rest. See Geocode-first record matching for the architecture and the dedup yardstick for how we grade it.