Skip to content

De-identification

DeidEngine finds protected health information (PHI) in text, transforms it, and returns a DeidReport you can audit. Two things to read first:

What the default configuration does not do

The default recognizer (rule) finds structured identifiers: SSN, MRN, phone and fax numbers, e-mail, URLs, IP addresses, dates, and account / licence / device / plan numbers. It does not detect names or street addresses. Add the ner recognizer for those (pip install "openbtk[text]" and python -m spacy download en_core_web_sm); it finds most synthetic names but also over-flags, trading precision for recall. The benchmarks page has the measured numbers and their limits.

Not a compliance guarantee

Openbtk provides technical controls. It does not make you HIPAA-compliant, and the published figures are on a synthetic corpus, not real clinical text.

Detect, transform, report

import contextlib
import io

with contextlib.redirect_stdout(io.StringIO()):  # registry logs, not errors
    from openbtk.deid import DeidEngine, DeidMode

    engine = DeidEngine(mode=DeidMode.REDACT, recognizers=["rule"])
    result = engine.deidentify(
        "Seen 03/14/2024. Call (555) 010-2345 to confirm.",
        patient_id="hashed-patient-id",
    )

assert result.text == "Seen [REDACTED]. Call [REDACTED] to confirm."
report = result.report
# The report holds categories, counts and offsets -- never the values found.
assert {c.value: n for c, n in report.entity_counts.items()} == {
    "date": 1,
    "phone_number": 1,
}
assert "010-2345" not in report.model_dump_json()

patient_id should already be a hashed or pseudonymous identifier; the engine has no way to enforce that.

Transform modes

Mode Replaces a span with Use when
REDACT [REDACTED] you never need the value again
TAG its category, e.g. [PHONE_NUMBER] a downstream model should know something was there
HASH a keyed hash you need to tell two occurrences apart
SURROGATE realistic fake data, consistent per patient you want text that still reads naturally
DATE_SHIFT each date moved by one per-patient offset (other identifiers are redacted) intervals between events must survive
import contextlib
import io

with contextlib.redirect_stdout(io.StringIO()):
    from openbtk.deid import DeidEngine, DeidMode

    tagged = DeidEngine(mode=DeidMode.TAG).deidentify(
        "Call (555) 010-2345.", patient_id="p1"
    )

assert "[PHONE_NUMBER]" in tagged.text

Measure it on your own data

Detection quality depends on your notes. Score the engine against spans you have labelled, using the same scorer the project's CI gate uses:

import contextlib
import io

with contextlib.redirect_stdout(io.StringIO()):
    from openbtk.deid import DeidEngine, DeidMode
    from openbtk.deid.labelled import LabelledDocument, LabelledSpan
    from openbtk.deid.schemas import PHICategory
    from openbtk.eval.deid import evaluate_deid, format_markdown

    text = "Seen 03/14/2024. Dr Jane Roe agrees."
    doc = LabelledDocument(
        document_id="d1",
        text=text,
        spans=[
            LabelledSpan(category=PHICategory.DATE, start=5, end=15),
            LabelledSpan(category=PHICategory.NAME, start=20, end=28),
        ],
    )
    scores = evaluate_deid(DeidEngine(mode=DeidMode.REDACT), [doc])

# The rule recognizer finds the date and (by design) misses the name.
assert scores.per_category[PHICategory.DATE].true_positives == 1
assert scores.per_category[PHICategory.NAME].false_negatives == 1
assert "date" in format_markdown(scores)

Matching is relaxed: a labelled span counts as found if any detection overlaps it by at least one character, and a binary (any-PHI) view is reported next to the category-aware one. It is a more forgiving rule than boundary-exact scoring, so do not compare its numbers with papers that use a stricter one.

If you hold the i2b2/n2c2 2014 corpus under its data use agreement, python -m openbtk.eval.deid_benchmark --dataset n2c2 --path <dir> runs the same scorer over it. That harness is tested on hand-built files in the corpus's format and has not been run against the real corpus.

Structured data: k-anonymity

Removing names does not make a table of patients anonymous: a birth year, a sex and a ZIP prefix together can single someone out. A table is k-anonymous over the columns you name when every combination of their values is shared by at least k records.

from openbtk.deid.kanonymity import anonymise_to_k, k_anonymity_report

table = [
    {"patient": f"p{i}", "birth_year": 1960 + i % 30, "gender": "f" if i % 2 else "m"}
    for i in range(120)
]
columns = ["birth_year", "gender"]

before = k_anonymity_report(table, columns, k=5)
print(before.k_achieved, before.n_unique, before.satisfied)

result = anonymise_to_k(table, columns, k=5, keep=["patient"])
print(result.report.satisfied, result.levels, result.n_suppressed)
print(result.rows[0])

The report holds counts only, never a value, so it is safe to log. anonymise_to_k generalises the column with the most distinct values one step at a time (a year becomes a 5-year band, then a 10-year band, then *) and, only if a few records are still too rare, suppresses them (max_suppression, default 5%). It returns exactly which steps it took. Only the columns you name, plus any in keep, are exported; an unlisted column cannot leak through. Ages are top-coded at 90, as Safe Harbor requires, and zip_ladder() shortens a ZIP code.

For an EHR cohort, quasi_identifiers builds the table from PatientRecords and guardrail.ehr.k_anonymity checks a cohort directly:

from openbtk.data.ehr.cohort import quasi_identifiers
from openbtk.data.ehr.schemas import Demographics, PatientRecord
from openbtk.guardrails.ehr import CohortKAnonymityGuardrail

cohort = [
    PatientRecord(
        patient_id=f"pt-{i}",
        demographics=Demographics(gender="female" if i % 2 else "male"),
        source_system="fhir-r4",
    )
    for i in range(12)
]
rows = quasi_identifiers(cohort, ["gender"])
print(k_anonymity_report(rows, ["gender"], k=5).satisfied)

check = CohortKAnonymityGuardrail(k=5, quasi_identifiers=["gender"]).check(cohort)
print(check.passed, check.message)

What this does not give you, and you should not claim it does:

  • It protects only against linking on the columns you named. A rare diagnosis or a free-text note that you did not list is not protected.
  • It says nothing about what a group shares: if every patient in a group has the same diagnosis, the diagnosis is disclosed.
  • It does not choose k. Five is a common floor; the right value is a policy decision.
  • The ZIP ladder does not know which three-digit prefixes are too small to publish (Safe Harbor lists them); apply that rule yourself.

It is evidence for an expert to weigh, not a de-identification determination.

From the command line

openbtk deid notes/ --out clean/ de-identifies a directory of notes without a config; see the CLI guide.