De-identification¶
openbtk.deid is cross-modal by design and has zero heavy dependencies in
its default configuration (its built-in RuleRecognizer is stdlib re
only) — importing it eagerly is safe.
from openbtk.deid import DeidEngine, DeidMode
engine = DeidEngine(mode=DeidMode.SURROGATE, recognizers=["rule"])
result = engine.deidentify(text, patient_id="hashed-123")
openbtk.deid.engine.DeidEngine ¶
DeidEngine(*, mode: DeidMode = DeidMode.SURROGATE, recall_bias: RecallBias = 'high', recognizers: Sequence[str] = _DEFAULT_RECOGNIZERS, consistency_key: bytes | None = None, consistency_store: ConsistencyStore | None = None)
See module docstring.
Source code in src/openbtk/deid/engine.py
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | |
deidentify ¶
deidentify(text: str, *, patient_id: str, document_id: str | None = None) -> DeidResult
Detect, merge, filter, transform, and report -- one document.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
The document text to de-identify. |
required |
patient_id
|
str
|
Stable identifier used for SURROGATE/DATE_SHIFT consistency. Should already be a hashed/pseudonymous value by the time it reaches here -- this engine has no way to enforce that. |
required |
document_id
|
str | None
|
Report identifier. Auto-generated (a random UUID) if not given -- never derived from the text itself, which would risk encoding content into an identifier. |
None
|
Returns:
| Type | Description |
|---|---|
DeidResult
|
The de-identified text plus its |
Source code in src/openbtk/deid/engine.py
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | |
openbtk.deid.schemas.DeidMode ¶
Bases: StrEnum
How a detected span is transformed. See openbtk.deid.transforms.
openbtk.deid.schemas.DeidReport ¶
Bases: BaseModel
Per-document audit output of one DeidEngine.deidentify() call.
Safe to archive, forward to a privacy officer, or attach to a run manifest -- see this module's docstring for why no field here can ever hold document content.
Example
report = DeidReport( ... document_id="doc-1", ... entity_counts={PHICategory.SSN: 1}, ... detections=[], ... residual_risk=RiskEstimate(level="low", rationale="clean"), ... engine_config_hash="abc123", ... ) report.entity_counts[PHICategory.SSN] 1
openbtk.deid.schemas.DeidResult ¶
Bases: BaseModel
The return value of DeidEngine.deidentify(): transformed text plus
its audit report.
Example
report = DeidReport( ... document_id="doc-1", ... entity_counts={}, ... detections=[], ... residual_risk=RiskEstimate(level="low", rationale="clean"), ... engine_config_hash="abc123", ... ) DeidResult(text="hello", report=report).text 'hello'
openbtk.deid.schemas.DeidStatus ¶
Bases: StrEnum
What state a record's PHI is in -- lives here, not in any one
modality's own schemas module, because both clinical_text and
ehr need it (docs/05_DATA_MODALITY_SPEC.md sections 1.1 and 2.1)
and the layering rule (docs/03_ARCHITECTURE.md section 2) keeps the two
modalities independent of each other -- a shared concept belongs in a
module both already depend on downward, not in either one's own
package.
Making PHI state part of the type (docs/05_DATA_MODALITY_SPEC.md
section 1.1) is cheap and prevents a whole class of accident: a
pipeline step can refuse to send a RAW record to an off-site
provider by checking one field, rather than trusting that de-id
happened somewhere upstream.
openbtk.deid.schemas.Detection ¶
Bases: BaseModel
One PHI span found by a recognizer or the merger.
Example
from openbtk.core.schemas import TextSpan d = Detection( ... category=PHICategory.SSN, ... span=TextSpan(start=10, end=21, label="ssn", confidence=0.99), ... confidence=0.99, ... method="rule", ... ) d.category
Note
span.confidence and confidence are kept equal by every
producer in this codebase -- TextSpan is a shared generic type
(also used by guardrails and entity linking) that always carries its
own confidence field, not a second, independent estimate. Keeping
one authoritative value and mirroring it into the span avoids two
numbers silently drifting apart.
openbtk.deid.schemas.PHICategory ¶
Bases: StrEnum
The 18 HIPAA Safe Harbor identifier categories (45 CFR 164.514(b)(2)) -- a public, regulatory enumeration, not something specific to any patient or institution.
Two categories cannot appear in text at all and are included here only for completeness of the legal enumeration, not because a text recognizer can ever populate them:
FULL_FACE_PHOTO: an imaging artefact. Detecting it is out of scope foropenbtk.deidon text input, and out of scope for Openbtk entirely under the scope gate (CLAUDE.md rule 13) until an imaging modality ships.BIOMETRIC_IDENTIFIER: fingerprints, retinal scans, voiceprints -- likewise not a text-detectable category.
A recognizer that claims to detect either of these on text input should be treated as a bug, not a feature.
openbtk.deid.schemas.RiskEstimate ¶
Bases: BaseModel
A coarse, explainable residual-risk judgement for one document.
Not specified beyond a bare type reference in docs/04_API_DESIGN.md
section 5 -- designed from scratch. Deliberately a bounded category plus
a human-readable reason rather than a numeric score: a privacy officer
reading a DeidReport needs "why", and a single float invites false
precision this milestone has no calibration data to back up.
Example
RiskEstimate(level="low", rationale="No low-confidence spans.").level 'low'
Structured data: k-anonymity¶
openbtk.deid.kanonymity.k_anonymity_report ¶
k_anonymity_report(rows: Iterable[Mapping[str, object]], quasi_identifiers: Sequence[str], *, k: int) -> KAnonymityReport
Measure k-anonymity of rows over quasi_identifiers.
Streams the rows: memory is one counter entry per distinct combination, not one per record. A missing value counts as its own value ("unknown"), so records with the same gaps are in the same class.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rows
|
Iterable[Mapping[str, object]]
|
The table. |
required |
quasi_identifiers
|
Sequence[str]
|
The columns an outsider could link on. |
required |
k
|
int
|
The threshold to report against. |
required |
Example
rows = [{"sex": "f", "yob": 1980}] * 3 + [{"sex": "m", "yob": 1975}] report = k_anonymity_report(rows, ["sex", "yob"], k=3) report.k_achieved, report.n_below_k, report.satisfied (1, 1, False)
Source code in src/openbtk/deid/kanonymity.py
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 | |
openbtk.deid.kanonymity.KAnonymityReport ¶
Bases: BaseModel
How k-anonymous a table is. Counts only: no value from the data appears here.
Example
rows = [{"sex": "f"}, {"sex": "f"}, {"sex": "m"}] report = k_anonymity_report(rows, ["sex"], k=2) (report.k_achieved, report.n_unique, report.satisfied) (1, 1, False)
openbtk.deid.kanonymity.anonymise_to_k ¶
anonymise_to_k(rows: Iterable[Mapping[str, object]], quasi_identifiers: Sequence[str], *, k: int, ladders: Mapping[str, Sequence[Generaliser]] | None = None, max_suppression: float = 0.05, keep: Sequence[str] = ()) -> AnonymisationResult
Generalise (and if need be suppress) rows until they are k-anonymous.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rows
|
Iterable[Mapping[str, object]]
|
The table. Materialised once, as the algorithm needs every record. |
required |
quasi_identifiers
|
Sequence[str]
|
The columns to generalise. |
required |
k
|
int
|
The class size to reach. |
required |
ladders
|
Mapping[str, Sequence[Generaliser]] | None
|
Per column, a sequence of levels: level 0 first (usually the value as
it is), each next one coarser, ending in |
None
|
max_suppression
|
float
|
The fraction of records that may be removed instead of
generalising further. |
0.05
|
keep
|
Sequence[str]
|
Extra columns copied through untouched (a pseudonymous id, say). Nothing else is exported. |
()
|
Returns:
| Type | Description |
|---|---|
AnonymisationResult
|
The exported rows and an account of the levels reached and records suppressed. |
Example
rows = [{"yob": 1980 + i % 3, "sex": "f"} for i in range(9)] result = anonymise_to_k(rows, ["yob", "sex"], k=3) result.report.satisfied, result.n_suppressed (True, 0)
Source code in src/openbtk/deid/kanonymity.py
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 | |
openbtk.deid.kanonymity.AnonymisationResult ¶
Bases: BaseModel
The exported table and an account of how it was made.
rows holds the generalised quasi-identifiers and any keep columns, and
nothing else: columns that were not named are dropped, so an unlisted column cannot
leak through.
openbtk.deid.kanonymity.band ¶
band(width: int, *, top: int | None = None) -> Generaliser
Group integers into bands of width (1987 -> "1985-1989" for 5).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
width
|
int
|
Band width, at least 2. |
required |
top
|
int | None
|
Values at or above this are written |
None
|
Non-integers (including None) pass through, so an unknown stays unknown rather
than being placed in a band.
Example
band(5)(1987) '1985-1989' band(10, top=90)(93) '90+'
Source code in src/openbtk/deid/kanonymity.py
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | |
openbtk.deid.kanonymity.top_code ¶
top_code(limit: int) -> Generaliser
Write every integer at or above limit as "<limit>+" (HIPAA's 90+).
Example
top_code(90)(93), top_code(90)(41) ('90+', 41)
Source code in src/openbtk/deid/kanonymity.py
90 91 92 93 94 95 96 97 98 99 100 101 102 103 | |
openbtk.deid.kanonymity.prefix ¶
prefix(digits: int) -> Generaliser
Keep the first digits characters, mask the rest ("90210" -> "902**").
Example
prefix(3)("90210") '902**'
Source code in src/openbtk/deid/kanonymity.py
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | |
openbtk.deid.kanonymity.zip_ladder ¶
zip_ladder(length: int = 5) -> tuple[Generaliser, ...]
A ladder for a ZIP code: the full code, then ever shorter prefixes, then *.
Example
levels = [level("90210") for level in zip_ladder(5)] levels ['90210', '9021', '902', '90', '9*', '']
Source code in src/openbtk/deid/kanonymity.py
154 155 156 157 158 159 160 161 162 | |
openbtk.deid.kanonymity.identity ¶
identity(value: object) -> Hashable
Level 0: the value as it is (dates as ISO strings).
Example
identity("female") 'female'
Source code in src/openbtk/deid/kanonymity.py
69 70 71 72 73 74 75 76 | |
openbtk.deid.kanonymity.star ¶
star(value: object) -> Hashable
The top level: every value becomes *, so the attribute stops distinguishing.
Example
star("anything") '*'
Source code in src/openbtk/deid/kanonymity.py
79 80 81 82 83 84 85 86 87 | |
Extending: recognizers¶
A recognizer finds candidate PHI spans; the engine merges them, filters by
confidence and transforms them. Implement BaseRecognizer and name it in
DeidEngine(recognizers=[...]) to add your own detector.
openbtk.deid.recognizers.base.BaseRecognizer ¶
Bases: Component
Detect PHI spans in a single document's text.
Unlike BaseLoader/BaseChunker, detect() returns a plain
list, not an Iterator: a single document produces a small,
bounded number of PHI spans (tens, not millions), so there is no
O(corpus) memory hazard here for ADR-0004's streaming rule to guard
against -- the corpus-level streaming boundary is the loader's job, one
document at a time.
method
class-attribute
¶
method: Literal['rule', 'ner', 'llm_verifier']
Stamped onto every Detection this recognizer produces
(docs/04_API_DESIGN.md section 5). "ensemble" is reserved for
SpanMerger's own output and is never a single recognizer's value.
requires_model_download
class-attribute
¶
requires_model_download: bool = False
True for a recognizer whose detect() needs a downloaded model
(e.g. NERRecognizer), as opposed to one that's ready the moment
it's constructed (RuleRecognizer). Lets test infrastructure (the
contract suite, CI) skip exercising detect() for such a recognizer
unless OPENBTK_SLOW_TESTS=1 is set, without needing to know about
each concrete recognizer by name. Constructing an instance is always
cheap regardless -- CLAUDE.md rule 11 still applies; this flag is about
detect(), never about __init__.
detect
abstractmethod
¶
detect(text: str) -> list[Detection]
Find every PHI span this recognizer can find in text.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
The document text to scan. Never logged, never placed in a raised exception's message or context. |
required |
Returns:
| Type | Description |
|---|---|
list[Detection]
|
Zero or more |
list[Detection]
|
Confidence is this recognizer's own estimate; final confidence |
list[Detection]
|
combination is |
Raises:
| Type | Description |
|---|---|
DeidError
|
On a recognizer-internal failure. Never returns a silently-partial result -- a caller needs to know detection was incomplete, not trust an accidentally-short list. |
Source code in src/openbtk/deid/recognizers/base.py
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | |
openbtk.deid.recognizers.rule.RuleRecognizer ¶
Bases: BaseRecognizer
High-precision, regex-based PHI detection. See module docstring for exactly which categories this does and does not attempt.
Example
r = RuleRecognizer() hits = r.detect("SSN: 123-45-6789") hits[0].category