Clinical text¶
openbtk.data.clinical_text must be imported explicitly — it registers its
loaders, preprocessors and chunkers as a side effect. None of them need
their optional dependency (medspaCy, spaCy, pandas) merely to be defined,
only to actually run.
Schemas¶
openbtk.data.clinical_text.schemas.ClinicalTextRecord ¶
Bases: BaseModel
One clinical note, as loaded -- before or after de-identification.
Example
record = ClinicalTextRecord( ... record_id="note-1", ... source="synthea", ... text="Chief Complaint: chest pain.", ... ) record.deid_status
openbtk.data.clinical_text.schemas.ClinicalTextChunk ¶
Bases: BaseModel
One retrieval- or embedding-ready unit of a ClinicalTextRecord.
Example
chunk = ClinicalTextChunk( ... chunk_id="note-1:0", ... record_id="note-1", ... text="Chief Complaint: chest pain.", ... span=TextSpan(start=0, end=29, label="chunk", confidence=1.0), ... token_count=7, ... ) chunk.token_count 7
Loaders¶
openbtk.data.clinical_text.loaders.PlainTextLoader ¶
PlainTextLoader(*, note_type: str | None = None, encoding: str = 'utf-8')
Bases: BaseLoader[str, ClinicalTextRecord]
Load every *.txt file in a directory as one record each.
Example
import contextlib import io import tempfile from pathlib import Path with tempfile.TemporaryDirectory() as d: ... _ = (Path(d) / "note1.txt").write_text("Chief Complaint: pain.") ... loader = PlainTextLoader() ... with contextlib.redirect_stdout(io.StringIO()): # logging noise ... records = list(loader.load(d)) records[0].record_id 'note1' records[0].source 'plain_text'
Source code in src/openbtk/data/clinical_text/loaders.py
49 50 51 52 53 | |
load ¶
load(source: str) -> Iterator[ClinicalTextRecord]
Yield one record per *.txt file in the directory source,
in sorted filename order (deterministic, not filesystem-order-dependent).
Raises:
| Type | Description |
|---|---|
LoaderError
|
If |
Source code in src/openbtk/data/clinical_text/loaders.py
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | |
openbtk.data.clinical_text.loaders.JSONLLoader ¶
Bases: BaseLoader[str, ClinicalTextRecord]
Load records from a JSON Lines file, one ClinicalTextRecord per
line.
Each line must be a JSON object with at least record_id and
text; any other ClinicalTextRecord field may be present. A
line with no source key defaults to "jsonl".
Example
import contextlib import io import tempfile from pathlib import Path with tempfile.NamedTemporaryFile( ... mode="w", suffix=".jsonl", delete=False ... ) as f: ... _ = f.write('{"record_id": "n1", "text": "chest pain"}\n') ... path = f.name with contextlib.redirect_stdout(io.StringIO()): # logging noise ... records = list(JSONLLoader().load(path)) records[0].text 'chest pain' Path(path).unlink()
load ¶
load(source: str) -> Iterator[ClinicalTextRecord]
LoaderError: If source cannot be opened, or a line is not
valid JSON or does not satisfy ClinicalTextRecord.
Source code in src/openbtk/data/clinical_text/loaders.py
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | |
openbtk.data.clinical_text.loaders.MIMICNotesLoader ¶
MIMICNotesLoader(*, chunk_size: int = 1000)
Bases: BaseLoader[str, ClinicalTextRecord]
Load a MIMIC-III/IV NOTEEVENTS-shaped CSV, chunked via pandas so
peak memory stays O(chunk_size), not O(corpus) (ADR-0004).
Expects (a subset of) MIMIC's real column names: ROW_ID, TEXT,
and optionally SUBJECT_ID, HADM_ID, CATEGORY, CHARTDATE.
MIMIC-IV is itself a de-identified, IRB-released research dataset --
SUBJECT_ID is already a study-assigned pseudonymous identifier, not
real PHI, so it is passed through as patient_ref unchanged.
Source code in src/openbtk/data/clinical_text/loaders.py
177 178 | |
load ¶
load(source: str) -> Iterator[ClinicalTextRecord]
LoaderError: If source cannot be read, or lacks the required
ROW_ID/TEXT columns.
Source code in src/openbtk/data/clinical_text/loaders.py
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | |
Preprocessing¶
openbtk.data.clinical_text.preprocessing.SectionSegmenter ¶
SectionSegmenter(*, backend: Literal['rule', 'medspacy'] = 'rule')
Bases: BasePreprocessor[ClinicalTextRecord]
Populate record.sections from record.text. See module
docstring for the two backends.
Example
record = ClinicalTextRecord( ... record_id="n1", ... source="synthea", ... text="Chief Complaint:\nchest pain\nPlan:\nadmit", ... ) result = SectionSegmenter().process(record) sorted(result.sections) ['chief_complaint', 'plan']
Source code in src/openbtk/data/clinical_text/preprocessing.py
198 199 | |
requires_model_download
class-attribute
¶
requires_model_download: bool = False
Only true for the (non-default) "medspacy" backend -- see
_get_medspacy_pipeline. No single class-level flag can express
"sometimes"; callers who need to know should check self._backend.
process ¶
process(record: ClinicalTextRecord) -> ClinicalTextRecord
ProcessingError: If the "medspacy" backend's model is not downloaded. The "rule" backend never raises.
Source code in src/openbtk/data/clinical_text/preprocessing.py
201 202 203 204 205 206 207 208 209 210 | |
openbtk.data.clinical_text.preprocessing.DeidPreprocessor ¶
DeidPreprocessor(*, mode: DeidMode = DeidMode.REDACT, recall_bias: RecallBias = 'high', recognizers: Sequence[str] = ('rule',))
Bases: BasePreprocessor[ClinicalTextRecord]
Wrap openbtk.deid.DeidEngine as a pipeline preprocessing step:
de-identifies record.text, updating deid_status to match.
Registered under the "general" scope, not "clinical_text": the
underlying engine has no modality dependency at all (openbtk.deid's
own docstring: "cross-modal by design"). Only this ADAPTER currently
targets ClinicalTextRecord -- the one modality record shape that
exists pre-v0.5 (CLAUDE.md rule 13, the scope gate); a future ehr
adapter would be a second, separate registration when that modality
actually exists, not a generalisation forced onto this one ahead of a
real second caller.
The full DeidReport (identifiers and counts only, never PHI values
-- see openbtk.deid.schemas's own module docstring) is preserved
under record.metadata["deid_report"] rather than discarded:
docs/04_API_DESIGN.md section 5 says a report is "safe to ... attach to
a run manifest", and this preprocessing step is the only place that
report is ever produced -- a future pipeline stage collecting it into
a RunManifest (task 3.7) reads it from there.
Example
import contextlib import io record = ClinicalTextRecord( ... record_id="n1", ... source="synthea", ... text="Patient SSN: 123-45-6789.", ... ) with contextlib.redirect_stdout(io.StringIO()): # logging noise ... result = DeidPreprocessor(mode=DeidMode.REDACT).process(record) result.text 'Patient SSN: [REDACTED].' result.deid_status
Source code in src/openbtk/data/clinical_text/preprocessing.py
251 252 253 254 255 256 257 258 259 260 261 | |
process ¶
process(record: ClinicalTextRecord) -> ClinicalTextRecord
De-identify record.text.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
record
|
ClinicalTextRecord
|
A loaded (optionally section-segmented) record. |
required |
Returns:
| Type | Description |
|---|---|
ClinicalTextRecord
|
A new record with |
ClinicalTextRecord
|
and the audit report attached under |
ClinicalTextRecord
|
|
Note
DeidEngine.deidentify requires a patient_id for
SURROGATE/DATE_SHIFT consistency. When record.patient_ref
is absent, record.record_id is used instead -- a real,
disclosed narrowing to per-record (not per-patient)
consistency in that case, not a silent one.
Source code in src/openbtk/data/clinical_text/preprocessing.py
263 264 265 266 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 293 294 295 296 297 298 299 300 301 | |
Chunking¶
The one place Openbtk writes genuinely new logic, not a wrapped library — generic chunkers destroy clinical meaning by splitting mid-section.
openbtk.data.clinical_text.chunking.SectionAwareChunker ¶
SectionAwareChunker(*, max_tokens: int = 512, overlap_tokens: int = 0, count_tokens: Callable[[str], int] = count_tokens_approximate)
Bases: BaseChunker[ClinicalTextRecord, ClinicalTextChunk]
Section-respecting chunks -- see module docstring for the full
contract. Degrades to FixedTokenChunker when record.sections
is empty or None.
Example
record = ClinicalTextRecord( ... record_id="n1", ... source="synthea", ... text="Chief Complaint:\nchest pain.\nPlan:\nadmit.", ... sections={ ... "chief_complaint": TextSpan( ... start=17, end=29, label="s", confidence=1.0 ... ), ... "plan": TextSpan(start=35, end=42, label="s", confidence=1.0), ... }, ... ) chunks = list(SectionAwareChunker(max_tokens=50).chunk(record)) [c.section for c in chunks] == ["chief_complaint", "plan"] True
Source code in src/openbtk/data/clinical_text/chunking.py
318 319 320 321 322 323 324 325 326 327 | |
openbtk.data.clinical_text.chunking.FixedTokenChunker ¶
FixedTokenChunker(*, max_tokens: int = 512, overlap_tokens: int = 0, count_tokens: Callable[[str], int] = count_tokens_approximate)
Bases: BaseChunker[ClinicalTextRecord, ClinicalTextChunk]
Fixed-size, token-bounded chunks -- no sentence or section
awareness. SectionAwareChunker composes this directly for its own
"no sections" degenerate case (spec point 5), so behaviour matches
exactly rather than being reimplemented and risking drift.
Example
from openbtk.data.clinical_text.tokenization import ( ... count_tokens_approximate, ... ) record = ClinicalTextRecord( ... record_id="n1", source="synthea", text="one two three four" ... ) chunker = FixedTokenChunker(max_tokens=2) [c.text for c in chunker.chunk(record)] == ["one two ", "three four"] True
Source code in src/openbtk/data/clinical_text/chunking.py
267 268 269 270 271 272 273 274 275 276 | |
Tokenization¶
openbtk.data.clinical_text.tokenization.count_tokens_approximate ¶
count_tokens_approximate(text: str) -> int
Whitespace-based approximation: number of non-whitespace runs.
A documented undercount relative to a real subword tokenizer (roughly 30% on clinical text, per docs/05_DATA_MODALITY_SPEC.md section 1.3) -- exists as the zero-dependency fallback, never a silent claim of exactness.
Source code in src/openbtk/data/clinical_text/tokenization.py
125 126 127 128 129 130 131 132 133 | |
openbtk.data.clinical_text.tokenization.count_tokens_exact ¶
count_tokens_exact(text: str, *, model_name: str = DEFAULT_EXACT_MODEL, revision: str | None = None) -> int
Exact subword count using model_name's real tokenizer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
text
|
str
|
The text to count. Never logged. |
required |
model_name
|
str
|
A HuggingFace model id. Its tokenizer is downloaded and cached on first use for that id. |
DEFAULT_EXACT_MODEL
|
revision
|
str | None
|
The Hub commit to load. The default model is always pinned;
for any other model, omitting this follows the repo's moving
default branch and logs a |
None
|
Raises:
| Type | Description |
|---|---|
MissingDependencyError
|
If the |
Source code in src/openbtk/data/clinical_text/tokenization.py
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | |