More than 80% of medical data is in text — clinical notes, lab reports, discharge summaries. This article is the key to exploiting that treasure.
1. Why is Clinical NLP Different from Regular NLP?
Clinical documents:
"Bn nam 58t, tiền sử THA, ĐTĐ typ 2, vào viện vì ĐAU NGỰC trái lan lên vai,
kèm khó thở, không sốt. SpO2 94%, M 110 bpm, ECG bình thường.
TnI 0.8 ng/mL (ref < 0.04)."
Problem:
- Dense abbreviations: Hypertension, ECG, ECG, TnI
- Negation: "no fever" — negation needs to be detected
- Temporality: "past" vs "current"
- Trailing context: "normal" in ECG ≠ normal SpO2
2. NER Health with BioBERT
2.1. Domain models
| Model | Training data | Best for |
|---|---|---|
| BioBERT | PubMed + PMC | Research NER |
| PubMedBERT | PubMed (scratch) | Best on BioNLP |
| ClinicalBERT | MIMIC-III | Clinical notes |
| Bio_ClinicalBERT | PubMed + MIMIC | General medical NLP |
2.2. Token Classification (NER)
from transformers import (
AutoTokenizer, AutoModelForTokenClassification,
TrainingArguments, Trainer, DataCollatorForTokenClassification
)
import torch
LABEL_LIST = [
"O",
"B-DISEASE", "I-DISEASE",
"B-DRUG", "I-DRUG",
"B-SYMPTOM", "I-SYMPTOM",
"B-ANATOMICAL", "I-ANATOMICAL",
"B-LAB_VALUE", "I-LAB_VALUE",
]
LABEL2ID = {l: i for i, l in enumerate(LABEL_LIST)}
ID2LABEL = {i: l for l, i in LABEL2ID.items()}
MODEL_NAME = "dmis-lab/biobert-base-cased-v1.2"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForTokenClassification.from_pretrained(
MODEL_NAME,
num_labels=len(LABEL_LIST),
id2label=ID2LABEL,
label2id=LABEL2ID
)
def tokenize_and_align_labels(examples):
"""Align NER labels với BERT wordpieces."""
tokenized = tokenizer(
examples["tokens"],
truncation=True,
is_split_into_words=True,
max_length=512
)
all_labels = []
for i, label_ids in enumerate(examples["ner_tags"]):
word_ids = tokenized.word_ids(batch_index=i)
previous_word_idx = None
label_ids_aligned = []
for word_idx in word_ids:
if word_idx is None:
label_ids_aligned.append(-100)
elif word_idx != previous_word_idx:
label_ids_aligned.append(label_ids[word_idx])
else:
# Wordpiece continuation: dùng I- label
current_label = LABEL_LIST[label_ids[word_idx]]
if current_label.startswith("B-"):
inside_label = "I-" + current_label[2:]
label_ids_aligned.append(LABEL2ID.get(inside_label, -100))
else:
label_ids_aligned.append(-100)
previous_word_idx = word_idx
all_labels.append(label_ids_aligned)
tokenized["labels"] = all_labels
return tokenized
2.3. Handling Negation and Temporality
NEGATION_TERMS = [
"không", "chưa", "phủ nhận", "không ghi nhận", "âm tính",
"no", "not", "without", "denies", "absent", "negative", "ruled out"
]
TEMPORAL_PATTERNS = {
"past": ["tiền sử", "trước đây", "cũ", "đã", "history of", "prior"],
"family": ["gia đình", "bố mẹ", "family history"],
"current": ["hiện tại", "hiện", "currently", "presenting with"],
}
def detect_negation_scope(text: str, entity_start: int, window: int = 5) -> bool:
"""Check 5 words trước entity xem có negation term không."""
tokens = text[:entity_start].split()
window_tokens = [t.lower() for t in tokens[-window:]]
return any(neg in window_tokens for neg in NEGATION_TERMS)
def classify_temporality(text: str, entity_start: int) -> str:
before = text[:entity_start].lower()[-80:]
for category, patterns in TEMPORAL_PATTERNS.items():
if any(p in before for p in patterns):
return category
return "current"
def extract_entities_with_context(text: str, ner_pipeline) -> list[dict]:
"""NER + negation + temporality."""
raw_entities = ner_pipeline(text, aggregation_strategy="max")
enriched = []
for e in raw_entities:
enriched.append({
"text": e["word"],
"label": e["entity_group"],
"confidence": round(e["score"], 3),
"is_negated": detect_negation_scope(text, e["start"]),
"temporality": classify_temporality(text, e["start"]),
})
return enriched
3. ICD-10 Automatic Coding
from transformers import AutoModelForSequenceClassification, pipeline
class ICD10AutoCoder:
"""
Multi-label classification: discharge summary → ICD-10 codes.
Dataset: MIMIC-III (top 50 codes)
Metric: micro-AUC, P@8, R@8
"""
def __init__(self, model_path: str, icd_list: list[str]):
self.tokenizer = AutoTokenizer.from_pretrained(model_path)
self.model = AutoModelForSequenceClassification.from_pretrained(model_path)
self.icd_list = icd_list
self.model.eval()
def predict(self, discharge_summary: str, top_k: int = 10) -> list[dict]:
inputs = self.tokenizer(
discharge_summary,
return_tensors="pt",
max_length=4096, # Dùng Longformer/BigBird cho long docs
truncation=True,
)
with torch.no_grad():
logits = self.model(**inputs).logits
probs = torch.sigmoid(logits).squeeze()
top_k_indices = torch.topk(probs, k=min(top_k, len(self.icd_list))).indices
return [
{
"icd_code": self.icd_list[i],
"confidence": round(float(probs[i]), 3)
}
for i in top_k_indices.tolist()
]
4. NER assessment
from seqeval.metrics import classification_report, f1_score
def evaluate_ner(all_true_labels: list, all_pred_labels: list):
"""
seqeval dùng entity-level F1 (strict span match).
Khác với token-level accuracy — entity-level khắt khe hơn nhiều.
"""
print(classification_report(all_true_labels, all_pred_labels))
micro_f1 = f1_score(all_true_labels, all_pred_labels, average="micro")
macro_f1 = f1_score(all_true_labels, all_pred_labels, average="macro")
print(f"Micro F1: {micro_f1:.4f} | Macro F1: {macro_f1:.4f}")
5. Exercises
- Download I2B2 2010 NER dataset. Fine-tune ClinicalBERT and PubMedBERT. Compare F1-score for each entity type (disease/drug/treatment).
- Implement negation evaluation: annotate 100 sentences, counting False Positive (predicted negation but not) and False Negative.
- Build ICD-10 top-50 classifier on MIMIC-III. Target Precision@5 > 0.70.
Lesson 8: Building Medical Q&A Chatbot with RAG + LLM.