Patient Matching and Master Patient Index (MPI) Strategies for Clinical ETL

The United States has no national patient identifier, so every health system that ingests records from more than one source must decide, on its own, whether two demographic records describe the same human being — and it must decide with enough precision that it never merges two different patients, and enough recall that it never fragments one patient into many charts. That decision is the job of a Master Patient Index (MPI), or an Enterprise MPI (EMPI) when it spans organizations: a service that assigns and defends a stable enterprise identifier across every feed. Within HIPAA De-identification & Patient Matching for Clinical ETL, this page is the engineering reference for the matching engine itself — the deterministic and probabilistic algorithms, the blocking that makes them tractable, the survivorship policy that resolves a merge, and the audit trail that makes every link reversible. It is written to be lifted into a Python service and tested against a labeled record-pair set in isolation.

Two failure modes frame everything below. An over-match (false positive) fuses two patients into one enterprise ID: the second patient inherits the first patient’s allergies, medications, and history — a patient-safety event and, because one patient’s PHI now appears in another’s chart, a HIPAA disclosure. An under-match (false negative) splits one patient across multiple IDs: allergy checks miss, duplicate orders fire, and the longitudinal record is silently incomplete. The entire design is a controlled trade between these two, tuned deliberately rather than accidentally.

Prerequisites & Context

An MPI is only as good as the data feeding it. Matching operates on demographics after parsing and normalization, not on raw wire bytes, so confirm the following are in place before implementing anything here:

If any of these is missing, resolve it first. In particular, a matching engine run without a survivorship policy and a review UI will produce enterprise IDs that no downstream system can safely consume.

Concept & Spec Detail: Deterministic vs Probabilistic Linkage

Why there is no shortcut

Because there is no national patient identifier — the funding for one has been prohibited by congressional appropriations riders since 1998 — an MRN is only unique within its assigning authority, and an SSN is optional, frequently absent, often shared within a family by data-entry error, and legally constrained in how it may be used. That leaves demographics: name, date of birth, sex, and address. None of these is individually unique or stable, so matching is fundamentally a statistical inference over a vector of noisy field comparisons.

Deterministic matching

Deterministic matching declares a match when a fixed rule over normalized fields is satisfied exactly. A rule is a boolean conjunction — for example, SSN equal AND date of birth equal, or surname equal AND given-name equal AND DOB equal AND sex equal. Deterministic rules are high precision, low recall: when a strong rule fires you can trust it, but any typo, nickname, transposition, or missing field defeats the exact comparison and the true match is missed. Deterministic passes are cheap, explainable, and ideal as a first stage that clears the unambiguous majority of pairs before the expensive scorer runs. Their weakness is brittleness: real registration data is full of Jon/John, transposed birth-date digits, and blank SSNs.

Probabilistic matching (Fellegi–Sunter)

Probabilistic record linkage, formalized by Fellegi and Sunter in 1969, replaces the boolean rule with a weighted score. For each comparison field it estimates two probabilities:

  • The m-probability is P(field agrees | the pair is a true match) — one minus the field’s error/change rate. A reliable field such as date of birth has a high m (agreement is expected when the records truly match); a volatile field such as address has a lower m.
  • The u-probability is P(field agrees | the pair is a non-match) — the chance two different people agree on the field by coincidence. It is roughly the probability mass of the most common value: sex has u ≈ 0.5 (two random people share a sex half the time), while a full nine-digit SSN has a u near 1e-7.

Each field then contributes a log-likelihood weight, summed across fields into a total match score (a log-likelihood ratio):

  • Agreement weight = log2(m / u) — added when the field agrees. Large and positive when m is high and u is tiny (SSN agreement is powerful evidence).
  • Disagreement weight = log2((1 - m) / (1 - u)) — added (it is negative) when the field disagrees. Disagreement on a high-m field like DOB is strong evidence against a match.

The summed score is compared against two thresholds. Above the upper threshold, auto-link. Below the lower threshold, auto-reject as a non-match. Between them lies the clerical-review band: pairs the math cannot resolve confidently, routed to a human. This three-way routing is the defining structural feature that separates a real MPI from a naive scorer.

Blocking: making the comparison tractable

Comparing every incoming record against every existing record is O(n²) — infeasible against a master index of tens of millions. Blocking generates candidate pairs cheaply: records are bucketed by a coarse blocking key, and full field-by-field comparison runs only within a block. A key such as Soundex(surname) + birth_year places Smith/Smyth born in 1984 into the same bucket while excluding the vast majority of the index from comparison. Blocking trades a small recall loss (a true match split across two blocks by an error in both key components is missed) for orders-of-magnitude fewer comparisons. Production systems run multiple blocking passes with different keys (one on name+DOB, one on SSN, one on DOB+sex+ZIP) and union the candidate sets so no single key’s blind spot drops a true pair.

The MPI matching pipeline from normalization to enterprise ID assignment An inbound demographic record is normalized, then a blocking key restricts comparison to a small candidate set from the master index. A deterministic pass auto-links unambiguous exact-rule agreements. Remaining candidate pairs are scored by a Fellegi–Sunter engine that sums per-field log-likelihood weights into a total score. Threshold routing sends scores above the upper threshold to auto-match, scores below the lower threshold to non-match (a new enterprise ID), and scores in the clerical-review band to a human queue. Auto-match and confirmed-review outcomes assign or merge the enterprise ID under the survivorship policy; every decision is written to the append-only audit log. Inbound record name·DOB·SSN·sex Normalize casefold·ISO date Block Soundex(surname) + birth_year Master index candidates only Deterministic exact-rule pass SSN+DOB agree? exact → link else Fellegi–Sunter Σ log2(m/u) agree Σ log2((1-m)/(1-u)) = total score Threshold routing Auto-match score > upper Clerical review between thresholds Non-match new enterprise ID Assign / merge enterprise ID · survivorship confirmed link Append-only audit log
The MPI pipeline: normalize, block to a candidate set, clear exact pairs deterministically, score the rest with Fellegi–Sunter, and route by threshold to auto-match, clerical review, or a new enterprise ID — every decision logged.

Deterministic vs probabilistic at a glance

Property Deterministic Probabilistic (Fellegi–Sunter)
Decision basis Boolean agreement of a fixed rule Sum of per-field log-likelihood weights vs thresholds
Precision / recall High precision, low recall Tunable; recovers matches through typos and missing fields
Missing / noisy fields Rule fails → true match missed Field simply contributes its disagreement/absent weight
Explainability Trivial — the rule that fired Field weights + total score (still auditable, less obvious)
Tuning surface Add/remove rules m/u per field + upper/lower thresholds
Human review None (binary) Built-in clerical-review band
Best role First-pass, high-confidence auto-links Second pass over everything the rules could not settle

Example field weighting

Estimated from a labeled pair set, the weights below show why identifiers dominate and why a single field is never decisive alone. Agreement weight is log2(m/u); disagreement weight is log2((1-m)/(1-u)).

Field m-probability u-probability Agreement weight (bits) Disagreement weight (bits)
SSN (9-digit) 0.85 0.0000001 +23.0 −2.74
Date of birth 0.95 0.0004 +11.2 −4.32
Surname 0.90 0.005 +7.49 −3.28
Given name 0.88 0.02 +5.46 −3.02
Sex 0.98 0.5 +0.97 −5.64
Address (ZIP+street) 0.72 0.008 +6.49 −1.79

Read the table operationally. SSN agreement contributes +23 bits — enough to auto-link almost by itself — but SSN agrees on true matches only 85% of the time (blanks and family sharing), so it cannot be a required field. Sex agreement is worth under 1 bit (half of everyone agrees by chance) yet sex disagreement costs −5.64 bits, so it acts as a tie-breaker and a veto, not primary evidence. A match on surname + given + DOB but a blank SSN still clears a well-set upper threshold; that is precisely the recall a deterministic SSN rule would have thrown away.

Implementation

The engine decomposes into four ordered stages: normalize fields, derive a blocking key, run a deterministic rule, then score and route the survivors. Each stage carries a validation gate.

Step 1 — Normalize the comparison fields

Every comparison operates on canonical forms so that MARÍA, maria, and Maria compare equal and dates compare on a single representation. Normalize once, store the result, never mutate the source value.

import re
import unicodedata
from datetime import date

_NICKNAMES = {"bob": "robert", "bill": "william", "jim": "james",
              "liz": "elizabeth", "peggy": "margaret", "jon": "john"}


def norm_name(raw: str) -> str:
    """Casefold, strip accents, drop non-letters, resolve common nicknames."""
    if not raw:
        return ""
    decomposed = unicodedata.normalize("NFKD", raw)
    ascii_only = decomposed.encode("ascii", "ignore").decode("ascii")
    cleaned = re.sub(r"[^a-z]", "", ascii_only.lower())
    return _NICKNAMES.get(cleaned, cleaned)


def norm_dob(raw: str) -> str | None:
    """Coerce a date of birth to ISO YYYY-MM-DD; reject known placeholders."""
    if not raw:
        return None
    digits = re.sub(r"[^0-9]", "", raw)
    if len(digits) != 8:
        return None
    iso = f"{digits[0:4]}-{digits[4:6]}-{digits[6:8]}"
    if iso in ("1900-01-01", "0001-01-01"):  # registration placeholders
        return None
    try:
        date.fromisoformat(iso)
    except ValueError:
        return None
    return iso


def norm_ssn(raw: str) -> str | None:
    """Strip punctuation; reject impossible/placeholder SSNs."""
    if not raw:
        return None
    digits = re.sub(r"[^0-9]", "", raw)
    if len(digits) != 9 or digits in ("000000000", "123456789", "999999999"):
        return None
    if digits[0:3] == "000" or digits[3:5] == "00" or digits[5:9] == "0000":
        return None
    return digits

Validation gate: assert that normalization is idempotent — norm_name(norm_name(x)) == norm_name(x) — and that every placeholder DOB and structurally invalid SSN returns None rather than a comparable value. A placeholder that survives normalization becomes a false agreement that inflates every score in its block.

Step 2 — Derive the blocking key

The blocking key must be cheap, stable under the errors it is meant to tolerate, and coarse enough to keep blocks small. Soundex on the surname absorbs spelling variants; birth year absorbs day/month transpositions while still partitioning the index.

def soundex(name: str) -> str:
    """Classic Soundex: first letter + 3 encoded consonant digits."""
    if not name:
        return "0000"
    name = name.upper()
    codes = {"B": "1", "F": "1", "P": "1", "V": "1",
             "C": "2", "G": "2", "J": "2", "K": "2", "Q": "2",
             "S": "2", "X": "2", "Z": "2", "D": "3", "T": "3",
             "L": "4", "M": "5", "N": "5", "R": "6"}
    first, encoded, prev = name[0], "", codes.get(name[0], "")
    for ch in name[1:]:
        digit = codes.get(ch, "")
        if digit and digit != prev:
            encoded += digit
        if ch not in "HW":
            prev = digit
    return (first + encoded + "000")[:4]


def blocking_keys(record: dict) -> list[str]:
    """Multiple blocking passes; union of candidate sets covers each key's blind spot."""
    keys = []
    surname, dob, ssn = record["surname"], record["dob"], record.get("ssn")
    if surname and dob:
        keys.append(f"NAME:{soundex(surname)}|{dob[:4]}")          # Soundex + birth year
    if ssn:
        keys.append(f"SSN:{ssn}")                                   # exact SSN pass
    if dob and record.get("zip"):
        keys.append(f"GEO:{dob}|{record['sex']}|{record['zip']}")   # DOB + sex + ZIP pass
    return keys

Validation gate: measure blocking recall against the labeled set — the fraction of true-match pairs that share at least one blocking key. A key so tight that it drops true pairs before scoring caps the engine’s achievable recall; if pair-completeness falls below your target, add a pass rather than loosening the scorer.

Step 3 — Run the deterministic pass

Before spending a score, clear the unambiguous pairs with an exact rule. A high-precision conjunction auto-links or auto-rejects without a scorer call and gives the review UI an explainable reason.

def deterministic_match(a: dict, b: dict) -> str | None:
    """Return 'match' on a strong exact rule, else None (defer to scoring).

    Rule A: SSN present, equal, AND DOB equal -> match.
    Rule B: surname, given, DOB, sex all present and equal -> match.
    """
    if a.get("ssn") and a["ssn"] == b.get("ssn") and a["dob"] and a["dob"] == b.get("dob"):
        return "match"
    strong = ("surname", "given", "dob", "sex")
    if all(a.get(f) and a.get(f) == b.get(f) for f in strong):
        return "match"
    return None

Validation gate: on the labeled set the deterministic pass must return zero false positives — every rule it fires must be a true match. If a rule mislabels even one pair, tighten the conjunction (add a field) rather than accepting the error; the deterministic tier’s entire value is that its output needs no review.

Step 4 — Score with Fellegi–Sunter and route by threshold

Pairs the deterministic pass leaves undecided are scored field by field. Each field contributes its agreement weight when it agrees and its (negative) disagreement weight when it disagrees; an absent field on either side contributes zero. The summed score routes the pair.

import math
from dataclasses import dataclass


@dataclass(frozen=True)
class FieldWeight:
    m: float  # P(agree | true match)
    u: float  # P(agree | non-match by chance)

    @property
    def agree(self) -> float:
        return math.log2(self.m / self.u)

    @property
    def disagree(self) -> float:
        return math.log2((1 - self.m) / (1 - self.u))


FIELD_WEIGHTS = {
    "ssn":     FieldWeight(m=0.85, u=0.0000001),
    "dob":     FieldWeight(m=0.95, u=0.0004),
    "surname": FieldWeight(m=0.90, u=0.005),
    "given":   FieldWeight(m=0.88, u=0.02),
    "sex":     FieldWeight(m=0.98, u=0.5),
    "zip":     FieldWeight(m=0.72, u=0.008),
}

UPPER_THRESHOLD = 14.0   # bits: auto-link at or above
LOWER_THRESHOLD = -3.0   # bits: auto-reject at or below


def score_pair(a: dict, b: dict) -> float:
    """Sum log-likelihood weights across comparable fields (total match score)."""
    score = 0.0
    for field, w in FIELD_WEIGHTS.items():
        va, vb = a.get(field), b.get(field)
        if not va or not vb:
            continue          # field absent on a side: no evidence either way
        score += w.agree if va == vb else w.disagree
    return score


def route(score: float) -> str:
    """Three-way threshold routing into the matching decision."""
    if score >= UPPER_THRESHOLD:
        return "auto_match"
    if score <= LOWER_THRESHOLD:
        return "non_match"
    return "clerical_review"


def match_decision(a: dict, b: dict) -> dict:
    """Full pipeline for one candidate pair: deterministic first, then scored."""
    if deterministic_match(a, b) == "match":
        return {"decision": "auto_match", "method": "deterministic", "score": None}
    s = score_pair(a, b)
    return {"decision": route(s), "method": "probabilistic", "score": round(s, 2)}

Validation gate: replay the labeled set through match_decision and measure precision at the upper threshold and recall across auto-match plus confirmed review. Tune UPPER_THRESHOLD, LOWER_THRESHOLD, and the per-field m/u — never in production, always against the labeled set — until precision meets the safety bar (over-matches are the costly error) while the review band stays small enough for staff to clear. Record the threshold values and the evaluation metrics as a versioned artifact tied to each release.

Edge Cases & Vendor Deviations

The algorithm is clean; production feeds are adversarial. The cases below are the ones a labeled set rarely covers and that cause the most damage in the field.

Scenario Why it breaks matching Mitigation
Identical-demographic twins Same surname, DOB, sex, address, often sequential MRNs; every demographic field agrees so the score screams match Require a distinguishing identifier (SSN or MRN) before auto-linking pediatric records; hold twin-suspect pairs (same DOB + surname + address) for clerical review regardless of score
Maiden-name / surname change Marriage or divorce changes surname; a strong surname-anchored block and rule both fail Keep a name-history (alias) table and block/compare against all historical surnames; weight given+DOB+SSN so a match survives a surname change
Shared or typo’d family SSN A child registered with a parent’s SSN, or a transposed digit shared across siblings, creates false SSN agreement worth +23 bits Never let SSN alone clear the upper threshold; require SSN agreement to be corroborated by name or DOB; flag one SSN mapped to many DOBs
Epic identifier namespacing Epic emits multiple identifiers (enterprise EPI, facility MRN, CID) each in its own assigning authority; a bare MRN compared cross-namespace is meaningless Compare identifiers only within matching assigning-authority OIDs; treat the Epic enterprise ID as an identifier, not the truth, and still confirm demographics
Cerner (Oracle Health) namespacing Millennium person aliases carry a person-alias-type and alias-pool; the same numeric MRN can recur across pools Key every identifier by its alias pool / authority; never match two MRNs equal without equal namespaces
Newborns with temporary names Registered as BABY BOY SMITH / BB GIRL with a placeholder given name and often the mother’s demographics Detect the newborn name pattern; suppress given-name weight, lean on DOB + mother linkage; re-run matching when the legal name is filed

Two cross-cutting notes. First, the deterministic tier deserves its own careful construction — the exact key that clears the unambiguous majority is engineered in building a deterministic patient match key in Python, including how to compose and test the conjunctions that must never yield a false positive. Second, the probabilistic tier’s m/u estimation, threshold calibration, and expectation-maximization training over unlabeled pairs are covered in depth in probabilistic patient matching with Fellegi–Sunter.

Compliance Note: A Wrong Merge Is a Reportable Event

Patient matching is not a best-effort convenience — under HIPAA it is a regulated operation because a bad decision moves PHI. A false merge places one patient’s diagnoses, medications, and history into another patient’s chart: that is an impermissible disclosure of PHI under 45 CFR §164.502 and a patient-safety event, because the clinician now treats against a contaminated record. A false split hides an allergy or an active problem behind a duplicate chart. Both must be engineered against with the same rigor as de-identification, governed by the same PHI audit logging and access controls that protect the rest of the warehouse.

Three constraints apply specifically to the MPI. First, every matching decision must be auditable: the append-only log records the pair, the method (deterministic rule vs score), the score and the thresholds in force, the field-level agreement vector, and — for review-band pairs — the reviewer identity and timestamp. An auditor must be able to reconstruct why two records were linked. Second, every merge must be reversible: store links as an association between source records and an enterprise ID, never by destructively overwriting one record’s identifiers into another. An unmerge must restore both records to their pre-link state, which is only possible if the survivorship policy recorded which value came from which source. Third, apply minimum necessary in the clerical-review UI: reviewers adjudicating identity need name, DOB, sex, and partial identifiers to decide a match — they do not need the clinical record, so the review screen must not expose diagnoses, notes, or medications. Over-exposing the chart in a review queue turns a matching tool into an unnecessary PHI access surface and a finding waiting to happen.

Troubleshooting

Should I use deterministic or probabilistic matching for our MPI?

Use both, in sequence. Run a high-precision deterministic pass first to auto-link the unambiguous majority cheaply and with a fully explainable reason, then run probabilistic scoring over everything the rules could not settle to recover matches lost to typos, nicknames, and missing fields. Deterministic alone has poor recall; probabilistic alone spends compute on pairs a single exact rule would have decided instantly. The layered design gets the precision of rules and the recall of scoring.

What are good starting values for the m and u probabilities?

Estimate them from a labeled set of adjudicated pairs, not from intuition. The m-probability is one minus the field’s observed error or change rate among true matches (date of birth around 0.95, address lower because people move). The u-probability is the chance two different people agree by coincidence, roughly the frequency of the most common value: about 0.5 for sex, a few thousandths for a surname, and near 1e-7 for a full nine-digit SSN. Where you have no labels, seed u from value frequencies in your own data and refine m and u with expectation-maximization over unlabeled pairs, then validate on whatever labeled pairs you can adjudicate.

Why does blocking matter if the scorer is accurate?

Because without blocking the scorer never runs at scale. Comparing every inbound record against a master index of millions is O(n squared) and computationally infeasible. Blocking buckets records by a coarse, error-tolerant key such as Soundex of the surname plus birth year and scores only pairs within a bucket, cutting comparisons by orders of magnitude. The cost is a small recall loss when a true pair is split across blocks, which you mitigate by running several blocking passes on different keys and taking the union of their candidate sets.

How do I stop two records with the same SSN from being force-merged?

Never let a single field clear the upper threshold on its own, however strong its weight. SSNs are shared within families by data-entry error and transposed across siblings, so require SSN agreement to be corroborated by name or date of birth before an auto-link, and raise a data-quality flag whenever one SSN maps to multiple distinct dates of birth. Twins with identical demographics need the same guard: hold same-DOB, same-surname, same-address pairs for clerical review regardless of score, and require a genuinely distinguishing identifier before linking.

We merged two different patients. How do we recover?

You recover only if you designed for reversibility before the merge. Store links as associations between source records and an enterprise ID under a recorded survivorship policy, never by overwriting one record’s identifiers into another, so an unmerge can restore both records to their pre-link state. Then treat the incident as a reportable event: a false merge is an impermissible PHI disclosure and a patient-safety issue, so follow your breach-assessment process, use the append-only audit log to identify every downstream system that consumed the merged record, and correct them. If your links were destructive, the merge is effectively unrecoverable, which is why reversibility is a hard design requirement rather than a nice-to-have.