Batch processing clinical text
openmed.processing runs OpenMed over many documents efficiently, with progress
tracking, per-item error isolation, and streaming. It runs fully on-device:
the corpus, the model, and the output never leave the host. This skill shows a
resumable runner — sharded, checkpointed, append-only JSONL — that you can
restart without reprocessing.
When to use this skill
For corpora, folders, or datasets — anything beyond a handful of notes. For a
single note, just call openmed.analyze_text / deidentify directly
(extracting-clinical-entities, deidentifying-clinical-text). For an
always-on HTTP service, see serving-openmed-rest-api.
Quick start
from openmed import process_batch
texts = ["Patient has type 2 diabetes.", "No acute distress. BP 120/80."]
result = process_batch(texts, model_name="disease_detection_superclinical")
print(result.summary()) # PHI-safe counts + timing
print(result.successful_items, "/", result.total_items)
for item in result.get_successful_results():
print(item.id, item.result.to_dict()["entities"]) # spans only; avoid raw text in logs
process_batch(...) is a thin wrapper over BatchProcessor. Real signatures
(openmed/processing/batch.py):
process_batch(texts, model_name="disease_detection_superclinical", ids=None, config=None, progress_callback=None, **kwargs) -> BatchResult
BatchProcessor(model_name=..., operation="analyze_text", batch_size=8, continue_on_error=True, **analyze_kwargs)
with operation ∈ {"analyze_text", "extract_pii", "deidentify"}.
BatchItem(id, text, source=None, metadata=None)
BatchResult — .items, .total_items, .successful_items, .failed_items,
.success_rate, .average_processing_time, .summary(), .to_dict(),
.get_successful_results(), .get_failed_results().
BatchItemResult — .id, .result (a PredictionResult/DeidentificationResult),
.error, .processing_time, .source, .success, .to_dict().
Choosing the operation
from openmed import BatchProcessor
# NER (default)
ner = BatchProcessor(model_name="disease_detection_superclinical") # operation="analyze_text"
# Detect PHI spans
pii = BatchProcessor(operation="extract_pii", model_name="OpenMed/OpenMed-PII-SuperClinical-Small-44M-v1")
# De-identify (rewrites text)
deid = BatchProcessor(operation="deidentify", method="mask", confidence_threshold=0.7)
BatchProcessor reuses one model loader across items (and a cached privacy
filter for PII ops), so a single processor over many texts is far cheaper than
many one-off calls.
Streaming + PHI-safe progress
from openmed.processing import BatchProcessor, BatchProgress
proc = BatchProcessor(operation="deidentify", method="mask", batch_size=16)
def on_progress(p: BatchProgress) -> None: # frozen record: completed/total/current_index/elapsed
if p.completed % 500 == 0:
print(f"{p.completed}/{p.total} ({p.elapsed:.1f}s)") # NO PHI
for item in proc.iter_process(texts, ids=doc_ids,
write_jsonl(item) # one result at a time — constant memory for huge corpora
on_progress receives only counts/timing (never text), so it is safe to log.
iter_process yields BatchItemResults without holding the whole corpus.
Workflow
- De-identify upstream if the corpus has PHI, or key everything by stable
internal
ids so logs/output never carry identifiers.
- Pick the operation + model — one
BatchProcessor per
operation (analyze_text / extract_pii / deidentify); reuse it across
all items so the loader is shared.
- Shard the corpus into independent partitions writing to separate JSONL
files; run shards as separate processes for parallelism.
- Stream with
iter_process and append each BatchItemResult to JSONL
(flush per item) — that append-only file is your checkpoint.
- Track progress with the PHI-safe
on_progress callback (counts/timing
only).
- Re-run to resume: skip ids already present in the output; with
continue_on_error=True, failures are recorded, not raised.
- Reconcile via
result.get_failed_results() / a failures pass, then hand
JSONL to the downstream consumer.
A resumable batch runner
import json
from pathlib import Path
from openmed.processing import BatchProcessor
def run_resumable(texts, ids, out_path: Path, *, operation="deidentify", **kw):
out_path.parent.mkdir(parents=True, exist_ok=True)
# 1) checkpoint = ids already written (append-only JSONL is the source of truth)
done = set()
if out_path.exists():
with out_path.open() as f:
done = {json.loads(line)["id"] for line in f if line.strip()}
todo = [(i, t) for i, t in zip(ids, texts) if i not in done]
if not todo:
return
pending_ids, pending_texts = zip(*todo)
proc = BatchProcessor(operation=operation, continue_on_error=True, **kw)
# 2) append each result as it completes -> safe to kill/restart anytime
with out_path.open("a") as f:
for item in proc.iter_process(list(pending_texts), ids=list(pending_ids)):
record = {"id": item.id, "ok": item.success,
"result": item.result.to_dict() if item.success else None,
"error": item.error} # item.error is a message, keep PHI out
f.write(json.dumps(record) + "\n")
f.flush()
Re-running skips finished ids (continue_on_error=True keeps one bad doc from
killing the run; failures are recorded, not raised). Shard a corpus by writing
to out/shard_000.jsonl, shard_001.jsonl, … and run shards in separate
processes.
Chunking long documents
BatchProcessor does not split single documents. For notes longer than the
model's max sequence length, pre-split into sentences/windows
(openmed.processing.sentences) — or for analyze_text, rely on built-in
sentence handling — then re-stitch entities by adding the chunk offset back to
each entity's start/end so spans point into the original document.
Hand-off to / from OpenMed
- Per-item engine: each operation calls the same
openmed.analyze_text /
extract_pii / deidentify you'd call directly — same results, batched.
- Downstream: JSONL feeds
building-patient-timelines, etl-to-omop-cdm,
and exporting-to-fhir. De-id JSONL feeds evaluating-with-leakage-gates.
- Service vs batch: for request/response use
serving-openmed-rest-api; for
corpora use this batch path.
Edge cases & gotchas
- No PHI in logs/JSONL keys. Use stable
ids (BatchItem.id), offsets, and
labels. BatchItemResult.error is a message — keep raw text out of inputs to
exceptions you log.
continue_on_error=True is the default and recommended for corpora; check
result.get_failed_results() afterward. Set False only when one failure
should abort everything.
batch_size is a throughput dial, not correctness — tune to memory/CPU.
Larger isn't always faster on CPU.
- Append-only is the checkpoint. Don't buffer results in memory and write at
the end; you lose progress on a crash. Append + flush per item.
process_files/process_directory read files for you and set
BatchItem.source; unreadable files become failed items (not crashes) under
continue_on_error.
- Mixed languages: pass
lang= per run for PII ops; don't run an English
PII model across other languages (deidentifying-multilingual-text).
Standards & references
1---2name: batch-processing-clinical-text3description: Run large-scale batch NER, PII extraction, or de-identification over many clinical notes on-device with OpenMed, with sharding, checkpointing, resumability, and append-only JSONL output. Use when the user needs to process a corpus or folder of notes, de-identify a dataset, run NER over thousands of documents, build a resumable batch pipeline, or stream results to JSONL without holding everything in memory. Covers process_batch / BatchProcessor / BatchItem / BatchResult, the operation= selector (analyze_text | extract_pii | deidentify), iter_process streaming, the PHI-safe on_progress callback, chunking long documents, and no-PHI logging. Produces a resumable batch runner over an OpenMed model.4license: Apache-2.05---67# Batch processing clinical text89`openmed.processing` runs OpenMed over many documents efficiently, with progress10tracking, per-item error isolation, and streaming. It runs **fully on-device**:11the corpus, the model, and the output never leave the host. This skill shows a12**resumable** runner — sharded, checkpointed, append-only JSONL — that you can13restart without reprocessing.1415## When to use this skill1617For corpora, folders, or datasets — anything beyond a handful of notes. For a18single note, just call `openmed.analyze_text` / `deidentify` directly19(`extracting-clinical-entities`, `deidentifying-clinical-text`). For an20always-on HTTP service, see `serving-openmed-rest-api`.2122## Quick start2324```python25from openmed import process_batch2627texts = ["Patient has type 2 diabetes.", "No acute distress. BP 120/80."]28result = process_batch(texts, model_name="disease_detection_superclinical")2930print(result.summary()) # PHI-safe counts + timing31print(result.successful_items, "/", result.total_items)32for item in result.get_successful_results():33 print(item.id, item.result.to_dict()["entities"]) # spans only; avoid raw text in logs34```3536`process_batch(...)` is a thin wrapper over `BatchProcessor`. Real signatures37(`openmed/processing/batch.py`):3839- `process_batch(texts, model_name="disease_detection_superclinical", ids=None, config=None, progress_callback=None, on_progress=None, **kwargs) -> BatchResult`40- `BatchProcessor(model_name=..., operation="analyze_text", batch_size=8, continue_on_error=True, **analyze_kwargs)`41 with `operation ∈ {"analyze_text", "extract_pii", "deidentify"}`.42- `BatchItem(id, text, source=None, metadata=None)`43- `BatchResult` — `.items`, `.total_items`, `.successful_items`, `.failed_items`,44 `.success_rate`, `.average_processing_time`, `.summary()`, `.to_dict()`,45 `.get_successful_results()`, `.get_failed_results()`.46- `BatchItemResult` — `.id`, `.result` (a `PredictionResult`/`DeidentificationResult`),47 `.error`, `.processing_time`, `.source`, `.success`, `.to_dict()`.4849## Choosing the operation5051```python52from openmed import BatchProcessor5354# NER (default)55ner = BatchProcessor(model_name="disease_detection_superclinical") # operation="analyze_text"56# Detect PHI spans57pii = BatchProcessor(operation="extract_pii", model_name="OpenMed/OpenMed-PII-SuperClinical-Small-44M-v1")58# De-identify (rewrites text)59deid = BatchProcessor(operation="deidentify", method="mask", confidence_threshold=0.7)60```6162`BatchProcessor` reuses one model loader across items (and a cached privacy63filter for PII ops), so a single processor over many texts is far cheaper than64many one-off calls.6566## Streaming + PHI-safe progress6768```python69from openmed.processing import BatchProcessor, BatchProgress7071proc = BatchProcessor(operation="deidentify", method="mask", batch_size=16)7273def on_progress(p: BatchProgress) -> None: # frozen record: completed/total/current_index/elapsed74 if p.completed % 500 == 0:75 print(f"{p.completed}/{p.total} ({p.elapsed:.1f}s)") # NO PHI7677for item in proc.iter_process(texts, ids=doc_ids, on_progress=on_progress):78 write_jsonl(item) # one result at a time — constant memory for huge corpora79```8081`on_progress` receives only counts/timing (never text), so it is safe to log.82`iter_process` yields `BatchItemResult`s without holding the whole corpus.8384## Workflow85861. **De-identify upstream** if the corpus has PHI, or key everything by stable87 internal `ids` so logs/output never carry identifiers.882. **Pick the operation + model** — one `BatchProcessor` per89 `operation` (`analyze_text` / `extract_pii` / `deidentify`); reuse it across90 all items so the loader is shared.913. **Shard the corpus** into independent partitions writing to separate JSONL92 files; run shards as separate processes for parallelism.934. **Stream with `iter_process`** and append each `BatchItemResult` to JSONL94 (flush per item) — that append-only file is your checkpoint.955. **Track progress** with the PHI-safe `on_progress` callback (counts/timing96 only).976. **Re-run to resume:** skip ids already present in the output; with98 `continue_on_error=True`, failures are recorded, not raised.997. **Reconcile** via `result.get_failed_results()` / a failures pass, then hand100 JSONL to the downstream consumer.101102## A resumable batch runner103104```python105import json106from pathlib import Path107from openmed.processing import BatchProcessor108109def run_resumable(texts, ids, out_path: Path, *, operation="deidentify", **kw):110 out_path.parent.mkdir(parents=True, exist_ok=True)111 # 1) checkpoint = ids already written (append-only JSONL is the source of truth)112 done = set()113 if out_path.exists():114 with out_path.open() as f:115 done = {json.loads(line)["id"] for line in f if line.strip()}116 todo = [(i, t) for i, t in zip(ids, texts) if i not in done]117 if not todo:118 return119 pending_ids, pending_texts = zip(*todo)120121 proc = BatchProcessor(operation=operation, continue_on_error=True, **kw)122 # 2) append each result as it completes -> safe to kill/restart anytime123 with out_path.open("a") as f:124 for item in proc.iter_process(list(pending_texts), ids=list(pending_ids)):125 record = {"id": item.id, "ok": item.success,126 "result": item.result.to_dict() if item.success else None,127 "error": item.error} # item.error is a message, keep PHI out128 f.write(json.dumps(record) + "\n")129 f.flush()130```131132Re-running skips finished ids (`continue_on_error=True` keeps one bad doc from133killing the run; failures are recorded, not raised). Shard a corpus by writing134to `out/shard_000.jsonl`, `shard_001.jsonl`, … and run shards in separate135processes.136137## Chunking long documents138139`BatchProcessor` does not split single documents. For notes longer than the140model's max sequence length, pre-split into sentences/windows141(`openmed.processing.sentences`) — or for `analyze_text`, rely on built-in142sentence handling — then re-stitch entities by adding the chunk offset back to143each entity's `start`/`end` so spans point into the original document.144145## Hand-off to / from OpenMed146147- **Per-item engine:** each operation calls the same `openmed.analyze_text` /148 `extract_pii` / `deidentify` you'd call directly — same results, batched.149- **Downstream:** JSONL feeds `building-patient-timelines`, `etl-to-omop-cdm`,150 and `exporting-to-fhir`. De-id JSONL feeds `evaluating-with-leakage-gates`.151- **Service vs batch:** for request/response use `serving-openmed-rest-api`; for152 corpora use this batch path.153154## Edge cases & gotchas155156- **No PHI in logs/JSONL keys.** Use stable `ids` (`BatchItem.id`), offsets, and157 labels. `BatchItemResult.error` is a message — keep raw text out of inputs to158 exceptions you log.159- **`continue_on_error=True` is the default** and recommended for corpora; check160 `result.get_failed_results()` afterward. Set `False` only when one failure161 should abort everything.162- **`batch_size` is a throughput dial**, not correctness — tune to memory/CPU.163 Larger isn't always faster on CPU.164- **Append-only is the checkpoint.** Don't buffer results in memory and write at165 the end; you lose progress on a crash. Append + flush per item.166- **`process_files`/`process_directory`** read files for you and set167 `BatchItem.source`; unreadable files become failed items (not crashes) under168 `continue_on_error`.169- **Mixed languages:** pass `lang=` per run for PII ops; don't run an English170 PII model across other languages (`deidentifying-multilingual-text`).171172## Standards & references173174- JSON Lines (newline-delimited JSON): https://jsonlines.org/175- HIPAA de-identification (when batching PHI removal), 45 CFR 164.514(b):176 https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/index.html177- OpenMed source: `openmed/processing/batch.py` (`process_batch`,178 `BatchProcessor`, `BatchItem`, `BatchItemResult`, `BatchResult`,179 `BatchProgress`, `iter_process`).