Local-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 1,000+ medical models, 12 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0
pip install openmed
Your Data. Your Model. Your Hardware.
Turn clinical text into structured, de-identified insight, with nothing uploaded.
OpenMed extracts biomedical entities and removes 55+ PHI types entirely on the
hardware you control, so your data never leaves the device. The same 2,000+ open models run from a
phone to a GPU server, fully offline: iOS, iPadOS, and Android via OpenMedKit, React Native,
plain CPUs, Apple Silicon, NVIDIA GPUs, the browser, and REST/gRPC services.
No cloud. No vendor lock-in. No patient data leaving your network.
2,000+ models · 21 model-backed PII languages · 600+ PII checkpoints · 100% on-device · Apache-2.0
English · 简体中文 · Español · Français · Deutsch · Italiano · Português · Nederlands · العربية · हिन्दी · తెలుగు · 日本語 · Türkçe · فارسی
See it in action
OpenMed runs entirely on the device; clinical text never leaves it. Here it is on iPhone, fully offline:
On iPhone via OpenMedKit: scan a clinical note, de-identify it, and extract clinical signals, all locally with Apple MLX. Nothing is uploaded.
Real-time PII de-identification: the Nemotron Privacy Filter redacting names, addresses, IDs, and billing data from a clinical discharge packet, entirely on-device. (All values shown are synthetic.)
30-second example
from openmed import analyze_text
result = analyze_text(
"Patient started on imatinib for chronic myeloid leukemia.",
model_name="disease_detection_superclinical",
)
for entity in result.entities:
print(f"{entity.label:<12} {entity.text:<28} {entity.confidence:.2f}")
# DISEASE chronic myeloid leukemia 0.98
# DRUG imatinib 0.95
A state-of-the-art clinical NER model running locally: no API key, no network call.
Why OpenMed?
| OpenMed | Cloud medical APIs | |
|---|---|---|
| Runs on your device / servers | ✅ | ❌ |
| Patient data leaves your network | Never | Sent to the vendor |
| Cost | Free & open-source | Per-call pricing |
| Specialized medical models | 2,000+ | Limited |
| Model-backed PII languages | 21 | Varies |
| Offline / air-gapped | ✅ | ❌ |
| Apple Silicon (MLX) acceleration | ✅ | n/a |
| Native iOS / macOS apps | ✅ via OpenMedKit | ❌ |
| Browser/WebGPU token classification | ✅ via Transformers.js | Varies |
| Vendor lock-in | None (Apache-2.0) | Yes |
- Specialized models: 2,000+ curated biomedical & clinical models, many outperforming proprietary stacks.
- HIPAA-aware de-identification: all 18 Safe Harbor identifiers, smart entity merging, format-preserving fakes.
- Runs everywhere: CPU, CUDA, Apple Silicon (MLX), iOS/macOS via OpenMedKit, Android/Kotlin, React Native, REST/gRPC services, and browser/WebGPU bundles via Transformers.js.
- One-line deployment: Python API, Dockerized REST service, or batch pipelines.
- Zero lock-in: Apache-2.0, your infrastructure, your data.
On-device on Apple: Swift, MLX & iOS
OpenMed is built to run where your data already lives. On Apple hardware it accelerates with MLX, and it ships straight into iPhone, iPad, and Mac apps through OpenMedKit: so PII detection and clinical extraction happen fully offline, on the device.
// Add OpenMedKit to your app
dependencies: [
.package(url: "https://github.com/maziyarpanahi/openmed.git", from: "1.9.1"),
]
Expected result: Swift Package Manager resolves OpenMedKit and makes
import OpenMedKit available to your app target.
- MLX runtime for PII token classification, the Privacy Filter family, experimental GLiNER-family zero-shot tasks, and Python MLX-LM text generation with Laneformer; includes a CoreML fallback path for supported token-classification artifacts.
- One model name, every platform: MLX model names automatically fall back to the matching PyTorch checkpoint on non-Apple hardware.
- Python on Apple Silicon too:
pip install --upgrade "openmed[mlx]".
Guides: MLX backend · OpenMedKit (Swift) · CoreML export
MLX on Apple Silicon: 24–33× faster than CPU PyTorch for the Privacy Filter: median latency per inference step, lower is better.
On-device on Android — Kotlin & ONNX Runtime Mobile
OpenMedKit also ships as a native Android/Kotlin library for local document
intake, OCR handoff, PII redaction, and token-classification inference through
ONNX Runtime Mobile. Mobile model repositories include stable tensor names,
dynamic sequence axes, tokenizer files, labels, and Android-ready fp32, fp16,
INT8, and optional .ort outputs.
Add the scoped JitPack repository in settings.gradle.kts:
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven {
url = uri("https://jitpack.io")
content { includeGroup("com.github.maziyarpanahi") }
}
}
}
Then use the immutable OpenMed v1.9.1 release:
dependencies {
implementation("com.github.maziyarpanahi:openmed:v1.9.1")
}
See the Android installation guide for local builds and publishing details.
val model = OpenMedKit.fromDirectory(modelDir)
val entities = model.analyzeText("Patient Alice Nguyen was seen in cardiology.")
- Android ONNX profile emits
model.onnx,model_fp16.onnx,model_int8.onnx, tokenizer assets, labels, andopenmed-onnx.json. - ORT Mobile support records the minimal-build operator configuration when ONNX Runtime conversion tooling is installed.
- Kotlin parity tests keep tokenizer offsets, span boundaries, and decoder output aligned with the Python runtime.
Guides: Android ONNX export · Android span parity · OpenMedKit Android
The same ONNX model on Python CPU
from openmed import OnnxModel
model = OnnxModel.from_pretrained(
"OpenMed/OpenMed-PII-ClinicalE5-Small-33M-v1-onnx-android"
)
entities = model("Patient Alice Nguyen was seen in cardiology.")
The same ONNX model in the browser
npm install openmed @huggingface/transformers
import { loadOnnxModel } from "openmed";
const model = await loadOnnxModel(
"OpenMed/OpenMed-PII-ClinicalE5-Small-33M-v1-onnx-android",
);
const entities = await model("Patient Alice Nguyen was seen in cardiology.");
How it works
flowchart LR
A["Clinical text"] --> B["OpenMed<br/>(100% on-device)"]
B --> C["Medical entities"]
B --> D["PII detected"]
B --> E["De-identified text"]
style B fill:#0D6E6E,stroke:#0A5656,stroke-width:2px,color:#ffffff
style C fill:#D6EBEB,stroke:#0D6E6E,color:#0E1116
style D fill:#F7DCD8,stroke:#C5453A,color:#0E1116
style E fill:#F5E27A,stroke:#A9A088,color:#0E1116
Rendered result: a local clinical-text pipeline that returns medical entities, PII findings, and de-identified text without sending data to a cloud API.
Quick start
# Core + Hugging Face runtime (Linux, macOS, Windows; CPU or CUDA)
pip install --upgrade "openmed[hf]"
# Add the REST service
pip install --upgrade "openmed[hf,service]"
# Apple Silicon acceleration (MLX)
pip install --upgrade "openmed[mlx]"
Expected result:
Successfully installed openmed-...
Python API
Example output:
|
REST service
Example output:
|
Batch
Example output:
|
Browser / WebGPU
Package ONNX token-classification exports for in-browser inference through Transformers.js:
python -m openmed.onnx.convert \
--model dslim/bert-base-NER \
--output dist/example-onnx \
--include-transformersjs
Example output:
Exported Transformers.js bundle to dist/example-onnx
import { pipeline } from "@huggingface/transformers";
const detector = await pipeline(
"token-classification",
"/models/openmed-pii/transformersjs",
{ device: "webgpu" },
);
const entities = await detector("Patient Casey Example called 212-555-0198.");
console.log(entities.slice(0, 2));
Example output:
[
{ entity: "NAME", word: "Casey Example", score: 0.99 },
{ entity: "PHONE", word: "212-555-0198", score: 0.98 },
]
Offline / air-gapped? Point model_name (or model_id) at a local directory and OpenMed loads it without contacting the Hugging Face Hub:
from openmed import OpenMedConfig, analyze_text
result = analyze_text(
"Patient presents with chronic myeloid leukemia and Type 2 diabetes.",
model_id="./models/OpenMed-NER-DiseaseDetect-SuperClinical-434M",
config=OpenMedConfig(device="cpu"),
)
for entity in result.entities:
print(f"{entity.label:<12} {entity.text:<28} {entity.confidence:.2f}")
Example output:
DISEASE chronic myeloid leukemia 0.98
DISEASE Type 2 diabetes 0.96
Because model_id points to a local directory, this example does not contact
the Hugging Face Hub or any external model provider.
Models
A curated registry of specialized medical NER models; browse the full catalog.
| Model | Specialization | Entity types | Size |
|---|---|---|---|
disease_detection_superclinical |
Disease & conditions | DISEASE, CONDITION, DIAGNOSIS | 434M |
pharma_detection_superclinical |
Drugs & medications | DRUG, MEDICATION, TREATMENT | 434M |
pii_superclinical_large |
PII & de-identification | NAME, DATE, SSN, PHONE, EMAIL, ADDRESS | 434M |
anatomy_detection_electramed |
Anatomy & body parts | ANATOMY, ORGAN, BODY_PART | 109M |
gene_detection_genecorpus |
Genes & proteins | GENE, PROTEIN | 109M |
Privacy: PII detection & de-identification
from openmed import extract_pii, deidentify
text = "Patient: John Doe, DOB: 01/15/1970, SSN: 123-45-6789"
# Extract PII with smart merging (prevents tokenization fragmentation)
result = extract_pii(text, model_name="pii_superclinical_large", use_smart_merging=True)
print([(e.label, e.text) for e in result.entities])
# De-identify with the method you need
print(deidentify(text, method="mask").deidentified_text)
print(deidentify(text, method="replace").deidentified_text)
print(deidentify(text, method="hash").deidentified_text)
print(deidentify(text, method="shift_dates", date_shift_days=180).deidentified_text)
Example output:
[('NAME', 'John Doe'), ('DATE', '01/15/1970'), ('SSN', '123-45-6789')]
Patient: [NAME], DOB: [DATE], SSN: [SSN]
Patient: Emily Chen, DOB: 03/22/1985, SSN: 456-78-9012
Patient: 6b8f...c4a1, DOB: 48b1...91de, SSN: 3f13...e912
Patient: John Doe, DOB: 07/14/1970, SSN: 123-45-6789
- Smart entity merging keeps
01/15/1970whole instead of fragmenting it. - Policy-aware pipelines add HIPAA/GDPR/research profiles, calibrated thresholds, signed audit reports, redaction previews, and minimum-necessary action selection.
- Faker-backed obfuscation with custom clinical-ID providers (CPF, CNPJ, BSN, NIR, Codice Fiscale, NIE, Aadhaar, Steuer-ID, NPI).
- HIPAA: all 18 Safe Harbor identifiers, configurable confidence thresholds.
- Batch and streaming PII: extract or de-identify across many documents with
BatchProcessor(operation="extract_pii" | "deidentify", batch_size=16)or incremental streaming helpers.
Batch processing: up to 3.3× higher throughput on CPU and 2.2× on MLX vs. one document at a time.
Complete PII notebook · Smart merging · Anonymization quickstart
Privacy Filter family: three model families on the OpenAI Privacy Filter architecture
Same model code (gpt-oss-style sparse-MoE transformer with local attention, sink tokens, RoPE+YaRN, tiktoken o200k_base), different training data. All route through the same extract_pii() / deidentify() API; only model_name= changes.
openai/privacy-filter is a Hugging Face model identifier for local weights;
using it here does not call the OpenAI API.
| Variant | PyTorch (CPU + CUDA) | MLX (Apple Silicon) | MLX 8-bit |
|---|---|---|---|
| OpenAI Privacy Filter | openai/privacy-filter |
OpenMed/privacy-filter-mlx |
…-mlx-8bit |
| Nemotron-PII fine-tune | OpenMed/privacy-filter-nemotron |
…-nemotron-mlx |
…-nemotron-mlx-8bit |
| OpenMed Multilingual | OpenMed/privacy-filter-multilingual |
…-multilingual-mlx |
…-multilingual-mlx-8bit |
from openmed import extract_pii
text = "Patient Sarah Connor (DOB: 03/15/1985) at MRN 4471882."
variants = {
"baseline": extract_pii(text, model_name="openai/privacy-filter"),
"nemotron": extract_pii(text, model_name="OpenMed/privacy-filter-nemotron"),
"mlx": extract_pii(text, model_name="OpenMed/privacy-filter-mlx"),
}
print([(e.label, e.text) for e in variants["baseline"].entities])
Example output:
[('NAME', 'Sarah Connor'), ('DATE', '03/15/1985'), ('ID', '4471882')]
On non-Apple-Silicon hosts, MLX model names are automatically substituted with the matching PyTorch checkpoint (with a one-time warning): ship one model name, run anywhere. See Privacy Filter architecture & backend routing.
Multilingual PII (22 supported languages)
Extraction and de-identification support 22 supported PII language codes:
am, ar, de, en, es, fr, he, hi, id, it, ja, ko, nl, pt, ro, sw, te, th, tr, xh, zh, and zu, with 600+ PII checkpoints in total.
Chinese routing currently uses the documented multilingual default-model
placeholder while dedicated Chinese model weights remain separate.
OpenMed also includes validator-backed national-ID coverage for additional
ID-only locales such as Polish, Latvian, Slovak, Malay, Filipino, and Danish.
See the per-language guide for each code's default PII model, Faker locale, and a before/after de-identification example.
python -c "from openmed import extract_pii; print([(e.label, e.text) for e in extract_pii('Dr. Pedro Almeida, CPF: 123.456.789-09, email: pedro@hospital.pt', lang='pt').entities])"
Example output:
[('NAME', 'Pedro Almeida'), ('ID', '123.456.789-09'), ('EMAIL', 'pedro@hospital.pt')]
Show per-language examples (Portuguese, Dutch, Hindi, Arabic, Japanese, Turkish)
from openmed import extract_pii
portuguese = extract_pii("Paciente: Pedro Almeida, CPF: 123.456.789-09, telefone: +351 912 345 678", lang="pt", use_smart_merging=True)
dutch = extract_pii("Patiënt: Eva de Vries, BSN: 123456782, telefoon: +31 6 12345678", lang="nl", use_smart_merging=True)
hindi = extract_pii("रोगी: अनीता शर्मा, फोन: +91 9876543210, पता: नई दिल्ली 110001", lang="hi", use_smart_merging=True)
arabic = extract_pii("المريضة ليلى حسن، الهاتف +20 10 1234 5678، الرقم القومي 29801011234567.", lang="ar", use_smart_merging=True)
japanese = extract_pii("患者 佐藤 花子、電話 +81 90 1234 5678、マイナンバー 1234 5678 9012.", lang="ja", use_smart_merging=True)
turkish = extract_pii("Hasta Ayşe Yılmaz, telefon +90 532 123 45 67, TCKN 10000000146.", lang="tr", use_smart_merging=True)
for r in (portuguese, dutch, hindi, arabic, japanese, turkish):
print([(e.label, e.text) for e in r.entities])
Example output:
[('NAME', 'Pedro Almeida'), ('ID', '123.456.789-09'), ('PHONE', '+351 912 345 678')]
[('NAME', 'Eva de Vries'), ('ID', '123456782'), ('PHONE', '+31 6 12345678')]
[('NAME', 'अनीता शर्मा'), ('PHONE', '+91 9876543210'), ('ADDRESS', 'नई दिल्ली 110001')]
[('NAME', 'ليلى حسن'), ('PHONE', '+20 10 1234 5678'), ('ID', '29801011234567')]
[('NAME', '佐藤 花子'), ('PHONE', '+81 90 1234 5678'), ('ID', '1234 5678 9012')]
[('NAME', 'Ayşe Yılmaz'), ('PHONE', '+90 532 123 45 67'), ('ID', '10000000146')]
REST API
A Docker-friendly FastAPI service with request validation, shared pipeline preload, and unified error envelopes.
pip install --upgrade "openmed[hf,service]"
uvicorn openmed.service.app:app --host 0.0.0.0 --port 8080
# or with Docker
docker build -t openmed:local .
docker run --rm -p 8080:8080 -e OPENMED_PROFILE=prod openmed:local
Example output:
INFO: Uvicorn running on http://0.0.0.0:8080
curl -X POST http://127.0.0.1:8080/pii/extract \
-H "Content-Type: application/json" \
-d '{"text":"Paciente: Maria Garcia, DNI: 12345678Z","lang":"es"}'
Abbreviated example response:
{
"text": "Paciente: Maria Garcia, DNI: 12345678Z",
"entities": [
{"text": "Maria Garcia", "label": "NAME", "confidence": 0.99, "start": 10, "end": 22},
{"text": "12345678Z", "label": "ID", "confidence": 0.98, "start": 29, "end": 38}
],
"model_name": "OpenMed/privacy-filter-multilingual"
}
Model lifecycle and service controls: free memory on demand with
GET /models/loaded, POST /models/unload, and a keep_alive idle window;
v1.8 also includes API-key/JWT auth, no-PHI request logging, tracing, gRPC,
async jobs, webhooks, warm pools, dynamic batching, request coalescing, rate
and concurrency limits, /livez, /readyz, and opt-in metrics:
OPENMED_SERVICE_KEEP_ALIVE=10m uvicorn openmed.service.app:app --host 0.0.0.0 --port 8080
curl -X POST http://127.0.0.1:8080/models/unload -H "Content-Type: application/json" -d '{"all":true}'
Example response:
{
"unloaded": true,
"released": {"models": 1, "tokenizers": 1, "pipelines": 1},
"active_models": {}
}
See the full REST service guide.
Documentation
Full guides at openmed.life/docs.
AI agents can load the curated llms.txt index or the inlined llms-full.txt feed. Both are regenerated from the current documentation during every strict MkDocs build.
Meet the mascot
OpenMed's guardian is a fluffy Persian cat styled as a tiny Avicenna (Ibn Sina): the great Persian physician whose Canon of Medicine was the world's standard medical text for some 600 years. He keeps watch over the open book of medical knowledge, in a palette built around Persian turquoise (fīrūza): a local-first guardian for your most private data.
Contributing
Contributions welcome: bug reports, feature requests, and PRs alike. Please read the Contributing guide and our Code of Conduct first.
- Open an issue
- Contributing guide · Code of Conduct · Security policy
- Translations welcome: help complete the other-language READMEs linked in the switcher at the top.
Security
Found a vulnerability? OpenMed redacts PHI, so a redaction bypass or PHI/PII leak is a security issue: please report it privately, never as a public issue. See SECURITY.md for the responsible-disclosure policy and the private reporting form. Never include real patient data in a report.
Credits
OpenMed builds on excellent open-source work: particular thanks to OpenAI (the Privacy Filter architecture), NVIDIA (the Nemotron PII dataset), Hugging Face (transformers, Transformers.js & the model ecosystem), Apple (MLX), and the Faker maintainers.
License
Released under the Apache-2.0 License. Third-party asset notices are recorded in NOTICE.
Citation
@misc{panahi2025openmedneropensourcedomainadapted,
title={OpenMed NER: Open-Source, Domain-Adapted State-of-the-Art Transformers for Biomedical NER Across 12 Public Datasets},
author={Maziyar Panahi},
year={2025},
eprint={2508.01630},
archivePrefix={arXiv},
primaryClass={cs.CL},
url={https://arxiv.org/abs/2508.01630},
}
Expected result: BibTeX-compatible citation metadata for referencing OpenMed in papers, posters, and derived documentation.
Star History
If OpenMed is useful to you, a star helps others discover it.
Built by the OpenMed team
Website · Docs · X / Twitter · LinkedIn