A deployable HTTP service that turns clinical notes into ICD-9 / ICD-10 / CPT predictions with evidence spans. Pluggable across local PLM models and hosted LLMs, designed to run as a stateless container behind your existing infrastructure.
A small demo UI ships in this repo for local exploration, but it is a sidecar — the service is the product.
- Predict medical codes from a clinical note via a versioned HTTP API
- Return evidence — token-level spans from the note that support each predicted code
- Pluggable model providers — local PLM (RoBERTa-based, with explainability methods) or hosted LLM (OpenAI)
- De-identification — replace PHI in a note with MIMIC-style placeholders before downstream use
- Stateless — no database, no session state; scale horizontally behind a load balancer
The full OpenAPI spec is served at /docs (Swagger UI) and /openapi.json once the service is running.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/predict |
Predict codes with a local PLM + evidence spans |
| POST | /v1/predict/llm |
Predict codes with a hosted LLM |
| POST | /v1/deidentify |
Replace PHI in a note with placeholders |
| GET | /v1/models |
List configured model logical names |
| GET | /v1/explain-methods |
List supported PLM explanation methods |
| GET | /healthz |
Liveness probe |
| GET | /v1/readyz |
Readiness probe (model loaded) |
Unversioned routes (/predict-explain, /predict-explain-llm, /deidentify, /models, /explain-methods) remain available but are deprecated and will be removed in the next release.
Set API_KEYS=name1:key1,name2:key2 and clients pass X-API-Key: keyN. Set AUTH_REQUIRED=false to disable enforcement in dev. /healthz, /v1/readyz, /docs, and /openapi.json are always public.
All errors return a uniform envelope:
{ "error": { "code": "not_found", "message": "...", "request_id": "..." } }Every response includes an X-Request-ID header (echoed from the request, or generated). Each request emits one structured request log line with request_id, tenant_id, method, path, status, duration_ms.
curl -X POST http://localhost:8084/v1/predict \
-H 'Content-Type: application/json' \
-d '{
"note": "Patient presents with chest pain and shortness of breath...",
"model": "icd10-plm",
"explain_method": "grad_attention",
"confidence_threshold": 0.5
}'The service is a single stateless container. Weights are not baked into the image — mount them at runtime via MODELS_DIR (defaults to /app/models inside the container). The service starts even without local weights; LLM endpoints still work and /v1/readyz reports 503 until a base encoder + DEFAULT_MODEL are available.
docker build -t medical-coding-api .
docker run -d \
--name medical-coding-api \
-p 8084:8084 \
--env-file .env \
-v $(pwd)/models:/app/models:ro \
-e MODEL_REGISTRY="icd10-plm=icd10-supervised/abc123" \
-e DEFAULT_MODEL=icd10-plm \
medical-coding-apicp .env.example .env # add OPENAI_API_KEY if using LLM
# Optional: place weights under ./models/ first (see "Model providers" below)
docker-compose up -d --build- API:
http://localhost:8084(docs at/docs) - Demo UI:
http://localhost:8090
docker-compose.yml mounts ./models into the API container read-only.
docker run -d \
--name medical-coding-api \
-p 8084:8084 \
--env-file .env \
-v $(pwd)/models:/app/models:ro \
michaeldockerlei/explainable-coding-api:latestNote: images published before the runtime-mount change still bake weights into the image; rebuild from source for the lean image.
All configuration is via environment variables.
| Variable | Default | Purpose |
|---|---|---|
OPENAI_API_KEY |
— | Required for any /v1/predict/llm traffic |
LLM_CODING_MODEL |
gpt-5 |
OpenAI model name |
GPT5_DEFAULT_REASONING_EFFORT |
minimal |
Reasoning effort for GPT-5 family |
MODELS_DIR |
./models |
Filesystem root for PLM weights |
MODEL_REGISTRY |
— (falls back to filesystem scan) | Comma-separated name=path entries; paths resolve under MODELS_DIR |
DEFAULT_MODEL |
first registry entry | Logical name used when a request omits model |
API_KEYS |
— | Comma-separated caller_name:key entries (or bare keys) |
AUTH_REQUIRED |
true if API_KEYS set, else false |
Toggle API-key enforcement |
LOG_FORMAT |
json |
Set to anything else to fall back to default formatting |
LOG_LEVEL |
INFO |
Root log level |
PORT |
8084 |
HTTP port |
UPSTREAM_API_BASE |
http://localhost:8084 |
Used by the demo UI to reach the API |
Clients pass logical names ("icd10-plm"), not filesystem paths. Configure the registry via env:
MODELS_DIR=/var/lib/coding/models
MODEL_REGISTRY=icd9-plm=icd9-supervised/abc123,icd10-plm=icd10-supervised/def456
DEFAULT_MODEL=icd10-plmIf MODEL_REGISTRY is unset, the service falls back to scanning MODELS_DIR for the first non-blacklisted subdirectory — preserved for backwards compatibility.
GET /healthz— liveness. Returns 200 whenever the process is up.GET /v1/readyz— readiness. Returns 200 only after the default model has been loaded; 503 with areasonotherwise. Use this for load-balancer health checks and orchestrator readiness probes.
The service expects a base encoder (roberta-base-pm-m3-voc-hf) plus one or more fine-tuned ICD/CPT heads under models/. Currently model directories are discovered by filesystem scan; a config-driven registry is on the roadmap.
Manual download:
wget https://dl.fbaipublicfiles.com/biolm/RoBERTa-base-PM-M3-Voc-hf.tar.gz -P models
tar -xvzf models/RoBERTa-base-PM-M3-Voc-hf.tar.gz -C models
mv models/RoBERTa-base-PM-M3-Voc/RoBERTa-base-PM-M3-Voc-hf models/roberta-base-pm-m3-voc-hf
rm -rf models/RoBERTa-base-PM-M3-Voc models/RoBERTa-base-PM-M3-Voc-hf.tar.gz
gdown --id 15ePOrJPS12TbxqRdKmNKH0igZ8k4Mk3k -O models/temp.tar.gz
tar -xvzf models/temp.tar.gz -C models && rm models/temp.tar.gzLighter download (2 ICD heads instead of 4): replace the gdown ID with
15ePOrJPS12TbxqRdKmNKH0igZ8k4Mk3k.
Set OPENAI_API_KEY in .env and call /predict-explain-llm. No local weights required.
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # add OPENAI_API_KEY if using LLM
python api.py # serves on :8084The UI in demo-ui/ is a small FastAPI app that proxies the coding service. Useful for visualizing evidence spans and reviewing predictions; not required to use the service.
pip install -r demo-ui/requirements.txt
cd demo-ui && uvicorn main:app --reload --port 8090Visit http://localhost:8090. Configure UPSTREAM_API_BASE to point at a non-local API.
tools/eval.py scores model output against MIMIC-style labelled data. Not part of the service; lives in tools/ for reproducibility and is excluded from the Docker image via .dockerignore.
gdown --id 1xqC10tyviXuU3iLVIjp7oH01RrimHW2- -O data/mimic_data/data.tar.gz
tar -xvzf data/mimic_data/data.tar.gz -C data/mimic_data && rm data/mimic_data/data.tar.gz
python tools/eval.pytools/curl_api_test.py is an interactive smoke-test that hits a running API instance.
.
├── api.py # service entry point
├── utils/ # provider implementations (PLM, LLM, deidentify)
├── demo-ui/ # optional UI sidecar
├── tools/ # dev/eval tooling — excluded from the service image
│ ├── eval.py # evaluation harness against MIMIC-style data
│ └── curl_api_test.py # interactive smoke test
├── models/ # weights (mounted at runtime; not committed)
├── data/ # sample notes + code descriptions
├── Dockerfile # service image (no weights baked in)
└── docker-compose.yml # service + demo UI
Dependency slimming note:
requirements.txtcurrently includeswandb,textattack,hydra-core, andgdowneven though they are only needed by training and manual model-download workflows. They're listed because the runtime PLM modules transitively importexplainable_medical_coding.config.factories→trainer.callbacks→wandb. Removing them requires gating those imports behind lazy loading — tracked as a future refactor.
- Slim runtime dependencies: lazy-load trainer modules so the service image can drop
wandb,textattack,hydra-core,gdown - Move
api.py+utils/into anapp/package for clearer service boundaries - Model artifacts from object storage (S3 / HF) instead of host volume mount