LLMを活用した臨床記録からの情報抽出:非構造化EHRを構造化JSONに変換するための実践的なパイプライン
臨床記録は、医師が記述した自由形式のテキストであり、症状、投薬、検査、診断などの情報が含まれています。しかし、この情報は、統計分析、検索、研究に直接利用できる形式ではありません。本記事では、この非構造化テキストから構造化されたJSONを抽出するための、実践的なパイプラインを紹介します。
📚 推奨される前提知識(強く推奨します)
本記事は、AI×Bioハードコア上級シリーズの一部です。始める前に、DryBenchの以下の記事を事前に確認することを強くお勧めします。
- DryBench ai-native #7 プロンプトエンジニアリング
- DryBench ai-native #8 RAGとコンテキスト
- DryBench ai-native #11 ハルシネーションとアライメント
前提知識を事前に確認しないと、本記事の実際のコードを理解することが難しくなります。なぜなら、プロンプト設計の原則、構造化された出力の強制、ハルシネーションに対する検証などを再説明せずに進めるからです。
DryBenchで学んだこと
DryBench ai-native #7では、プロンプトは単にLLMと「対話」する方法であるだけでなく、LLMの出力分布を調整するためのツールであることを学びました。#8では、RAGは、モデルのパラメータ外から取得した知識でコンテキストを豊かにするアプローチであることを学びました。そして、#11では、ハルシネーションは決して消えることはなく、常に外部からの検証によって対処する必要があることを学びました。
しかし、これらの3つの原則を組み合わせて、実際の病院のEHR(電子カルテ)で毎日生成される何千もの自由形式の記録を、研究、統計、意思決定支援に利用できるようにするにはどうすればよいでしょうか。本記事では、この組み合わせに対する実践的なアプローチを紹介します。プロンプトを使用して構造化された出力を強制し、正規表現によるフォールバックの二重の防御層を提供し、UMLS標準コードに対して検証します。また、このパイプラインが失敗する可能性のある現実世界の例も検討します。
ハードコアな問題定義
典型的な病院の救急部門では、1日に平均200〜500件の臨床記録が生成されます。各記録は、400〜2000文字の自由形式のテキストで構成されており、記述スタイルは医師によって異なります。略語(TB = 結核または総ビリルビン、MI = 心筋梗塞または僧帽弁閉鎖不全症)がよく使用され、文脈からのみ理解できます。以下の4つのフィールドを構造化されたJSON形式で抽出する必要があります。
symptoms: 症状のリスト(例:胸痛、呼吸困難)medications: 投薬のリスト(薬剤名 + 用量 + 頻度)labs: 検査結果のリスト(検査名 + 値 + 正常範囲)diagnoses: 診断(ICD-10コードまたは自然言語)
目標メトリック:各フィールドに対してF1 ≥ 0.85(n2c2 2018ベンチマークにおけるトップレベルのパフォーマンス)。また、再現性、拡張性、および規制(HIPAA)への準拠を確保する必要があります。
既存のアプローチの制限:
- ルールベース(SciSpacy、cTAKES):精度F1は0.6〜0.75、語彙を拡張するには多くの人的努力が必要です。
- BERTのファインチューニング(BioBERT、ClinicalBERT):F1は0.80〜0.87、数千のラベル付きデータポイントが必要です。
- LLM構造化出力:F1は0.83〜0.90(Med-Geminiベンチマーク[1]に基づく)、ゼロショットまたはフューショット、最小限のラベル付きデータが必要です。重要なのは、プロンプトと検証パイプラインを設計することです。
本記事では、この3番目のアプローチをハードコアな方法で構築します。
ツールスタックとインフラストラクチャ要件
| ツール | 役割 | ライセンス |
|---|---|---|
| Anthropic Claude API(構造化出力) | 構造化JSONを抽出 | 商用(使用量に応じた料金) |
SciSpacy en_core_sci_lg | ルールベースのフォールバック + エンティティ認識 | Apache 2.0 |
Python re(標準) | HIPAA Safe Harbor個人識別情報(PII)のマスキング | PSF |
| UMLS Metathesaurus | 診断および症状の標準コードマッピング | UMLSライセンス(無料、登録が必要) |
| MIMIC-IV(PhysioNet) | トレーニングおよび検証データセット | PhysioNet Credentialedライセンス(無料、登録が必要) |
インフラストラクチャ要件:GPUなしで実行できます(API呼び出しベース)。ローカルCPUで4コア、少なくとも8GBのRAMが必要です。SciSpacyモデルのダウンロードサイズは約800MBです。
学習者のための概算コスト:Claude APIで1000件の臨床記録を処理すると、約5〜15米ドルかかります(Anthropicの公式価格[2]に基づいて計算)。MIMIC-IVとUMLSは無料です(登録が必要です)。
実際のパイプラインの実装
全体のフロー:
ステップ1:HIPAAセーフハーバーによる匿名化処理
米国HHSは、臨床データの再識別リスクを軽減するために、18種類の識別子の削除を義務付けています[3]。規制に準拠せずに臨床記録を外部APIに送信することは、明らかに違反行為です。
import refrom typing import Dict, List
# HIPAAセーフハーバーの18種類の識別子。正規表現で処理できる主要なパターンが含まれています。HIPAA_PATTERNS: Dict[str, str] = { "SSN": r"\b\d{3}-\d{2}-\d{4}\b", "PHONE": r"\b\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b", "EMAIL": r"\b[\w.-]+@[\w.-]+\.\w+\b", "MRN": r"\bMRN[:\s]*\d{6,10}\b", "DATE_FULL": r"\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b", "DATE_MDY": r"\b\d{1,2}[-/]\d{1,2}[-/]\d{2,4}\b", "AGE_90PLUS": r"\b(?:aged?\s+)?(9[0-9]|1[0-2]\d)\s*(?:years?|yrs?|yo)\b", "ZIP": r"\b\d{5}(?:-\d{4})?\b",}
def deidentify(text: str) -> str: """HIPAAセーフハーバーに準拠するためのマスキング処理。
正規表現で処理できない項目(患者名、地名、組織名)は、SciSpacy NERを同時に使用する必要があります。 """ masked = text for label, pattern in HIPAA_PATTERNS.items(): masked = re.sub(pattern, f"[{label}]", masked, flags=re.IGNORECASE) return masked正規表現だけでは、名前、地名、組織名などの固有表現を識別することはできません。SciSpacy en_ner_bc5cdr_mdまたはen_core_sci_lg NERを同時に使用して、PERSON、GPE(地名)、およびORGラベルをマスキングする必要があります。本番環境にデプロイする場合は、Microsoft Presidio [4]などの専用ライブラリを導入することをお勧めします。
ステップ2:Claudeの構造化された出力を使用してJSONを抽出
Anthropicの公式ドキュメント[5]によると、プロンプトでスキーマを指定し、tool_useまたはJSONモードを使用します。
import jsonimport anthropic
client = anthropic.Anthropic()
EXTRACTION_SCHEMA = { "name": "extract_clinical_fields", "description": "臨床記録から4つのフィールドを抽出し、構造化されたJSONに変換します。", "input_schema": { "type": "object", "properties": { "symptoms": { "type": "array", "items": {"type": "string"}, "description": "患者が報告した症状(例:胸痛、呼吸困難)。", }, "medications": { "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "dose": {"type": "string"}, "frequency": {"type": "string"}, }, "required": ["name"], }, }, "labs": { "type": "array", "items": { "type": "object", "properties": { "test": {"type": "string"}, "value": {"type": "string"}, "unit": {"type": "string"}, }, "required": ["test", "value"], }, }, "diagnoses": { "type": "array", "items": {"type": "string"}, "description": "確認または疑わしい診断(ICD-10コードまたは自然言語)。", }, }, "required": ["symptoms", "medications", "labs", "diagnoses"], },}
SYSTEM_PROMPT = """あなたは臨床自然言語処理の専門家です。与えられた臨床記録から4つのフィールド(症状、投薬、検査、診断)を抽出します。
原則:1. 記録にない情報を推測または作成しないでください(ハルシネーションは行わないでください)。2. 文脈に応じて略語を正確に解釈してください(例:心臓の文脈では、MI = 心筋梗塞)。3. マスキングされたトークン([DATE_FULL]、[MRN]など)は抽出の対象ではありません。4. 不確かな診断は除外し、症状に含めます。"""
def extract_fields(deidentified_note: str) -> dict: """Claude APIを呼び出して、構造化されたJSONを抽出します。""" response = client.messages.create( model="claude-sonnet-4-5", max_tokens=2048, system=SYSTEM_PROMPT, tools=[EXTRACTION_SCHEMA], tool_choice={"type": "tool", "name": "extract_clinical_fields"}, messages=[{"role": "user", "content": deidentified_note}], ) for block in response.content: if block.type == "tool_use": return block.input return {}ステップ3:正規表現によるフォールバック二重防御
LLMは、特定の記録に対して構造化された出力を強制されても、空のリストを返す場合があります。この場合、ルールベースのフォールバックを使用して、少なくとも最小限のフィールドを埋めることで、下流のパイプラインが停止しないようにします。
LAB_PATTERN = re.compile( r"(?P<test>[A-Z][a-zA-Z\s]{2,20})\s*[:=]\s*" r"(?P<value>\d+\.?\d*)\s*(?P<unit>[a-zA-Z/%]+)?")
def regex_fallback_labs(note: str) -> List[dict]: """LLMが検査を空にした場合、少なくとも最小限を正規表現で抽出します。""" labs = [] for m in LAB_PATTERN.finditer(note): labs.append({ "test": m.group("test").strip(), "value": m.group("value"), "unit": m.group("unit") or "", }) return labs
def merge_with_fallback(llm_output: dict, note: str) -> dict: """LLMの結果 + 正規表現フォールバック。""" result = dict(llm_output) if not result.get("labs"): result["labs"] = regex_fallback_labs(note) return resultステップ4:UMLS CUIマッピング(検証レイヤー)
抽出された診断と症状を、UMLS Metathesaurus REST API [6]を使用して、標準のCUI(Concept Unique Identifier)にマッピングします。このステップは、下流の統計および研究における標準的な用語の使用を強制する検証レイヤーです。
import requests
UMLS_BASE = "https://uts-ws.nlm.nih.gov/rest"
def map_to_umls_cui(term: str, api_key: str) -> str | None: """自然言語の概念を、UMLS REST APIを使用してCUIにマッピングします。
api_keyは、UMLSに登録後に発行される値です。無料登録。 """ resp = requests.get( f"{UMLS_BASE}/search/current", params={"string": term, "apiKey": api_key, "pageSize": 1}, timeout=10, ) if resp.status_code != 200: return None results = resp.json().get("result", {}).get("results", []) return results[0].get("ui") if results else Noneステップ5:F1評価
n2c2 2018ベンチマークのスタイルで、フィールドごとのF1を計算します。Gold = 専門家が構造化およびラベル付けしたグランドトゥルース。
from typing import Set
def f1_score(pred: Set[str], gold: Set[str]) -> float: if not pred and not gold: return 1.0 tp = len(pred & gold) if tp == 0: return 0.0 precision = tp / len(pred) recall = tp / len(gold) return 2 * precision * recall / (precision + recall)
def evaluate_extraction(pred_json: dict, gold_json: dict) -> Dict[str, float]: """各フィールドのF1スコアを返します。""" scores = {} for field in ["symptoms", "diagnoses"]: pred = {s.lower().strip() for s in pred_json.get(field, [])} gold = {s.lower().strip() for s in gold_json.get(field, [])} scores[field] = f1_score(pred, gold) for field in ["medications", "labs"]: pred = {json.dumps(x, sort_keys=True) for x in pred_json.get(field, [])} gold = {json.dumps(x, sort_keys=True) for x in gold_json.get(field, [])} scores[field] = f1_score(pred, gold) return scores統合パイプライン
def process_note(raw_note: str, umls_key: str | None = None) -> dict: """単一の臨床記録を5つのステップのパイプラインで処理します。""" deidentified = deidentify(raw_note) llm_output = extract_fields(deidentified) merged = merge_with_fallback(llm_output, deidentified) if umls_key: merged["diagnoses_cui"] = [ map_to_umls_cui(d, umls_key) for d in merged.get("diagnoses", []) ] return merged
## パフォーマンス、コスト、および既知の失敗事例
### パフォーマンスの指標(公開ベンチマークを使用)
| アプローチ | データセット | F1スコア(フィールド平均) | 出典 ||---|---|:---:|---|| 正規表現 + SciSpacy | n2c2 2018 | 0.68 | Weissman et al., JAMIA 2021 [7] || BioBERTのファインチューニング | n2c2 2018 | 0.83 | Lee et al., Bioinformatics 2020 [8] || GPT-4 ゼロショット | MIMIC-III | 0.79~0.85 | Agrawal et al., NEJM AI 2024 [9] || Med-Gemini 構造化モデル | MedQA + Clinical IE | 0.87~0.91 | Google Research 2024 [1] || Claude 構造化モデル(本稿における近似) | 同様のベンチマーク | 0.83~0.89(推定) | Anthropic 公式ケーススタディ [2] |
### 学習者向けに推定される再現コスト(Claude APIの価格に基づいて計算)
- 1つのノートあたり、平均で800トークンの入力と300トークンの出力。- Claude Sonnet 4.5を使用。入力は100万トークンあたり3 USD、出力は100万トークンあたり15 USD(Anthropic 公式価格 [2])。- 1000件のノートを処理する場合、約 (0.8 × 3) + (0.3 × 15) = 6.9 USD のコストがかかります。- プロンプトのキャッシュを活用することで、コストを半分以下に削減できます。
### 3つの既知の失敗事例(コミュニティや論文から収集)
1. **略語の文脈の誤った解釈(MI = 心筋梗塞 vs. 僧帽弁閉鎖不全)** 症状:心臓病に関するノートにおいて、MIが心筋梗塞として抽出されたが、実際には僧帽弁閉鎖不全を意味していた。 原因:コンテキストウィンドウが短すぎ、モデルが前後の文脈における心臓弁に関する記述を見逃した。 解決策:ドメイン固有の略語辞書をシステムプロンプトに注入し、few-shot学習を使用する。また、ノート全体をコンテキストとして使用する。 出典:OpenAI Developer Forum、Clinical NLPスレッド [10]。
2. **空の構造化出力応答(JSONスキーマへの準拠の失敗)** 症状:特定のノートについて、`tool_use`は空のリストのみを返す。 原因:ノート内の特定のUnicode文字(例:ゼロ幅スペース)が、パーサーの失敗を引き起こす。 解決策:入力の前処理中に非印刷文字を削除し、正規表現によるフォールバックを実装する。 出典:Anthropic Cookbook GitHub Issues、`tool_use`のコーナーケース [11]。
3. **脱識別処理の不備による再識別化のリスク** 症状:HIPAA 18の識別子正規表現のみを使用すると、名前、地名、組織名がそのまま残ってしまう。 原因:正規表現が、名前や地名のパターン自体を認識できない。 解決策:SciSpacy NERまたはMicrosoft Presidioを組み合わせて使用することが不可欠である。 出典:JAMIA「自動化された方法による臨床テキストの脱識別」レビュー [12]。
## 拡張のアイデア
- **BioBERT + LLM アンサンブル:** BioBERTを通常のフィールドに使用し、LLMをコンテキストに依存するフィールドに使用する。これにより、F1スコア0.90以上を達成できる可能性がある。- **韓国語のEHR:** 韓国で使用されている韓国語の臨床ノート(SNUHやソウル大学病院など)に拡張する。韓国語のUMLSマッピング(KOSTOM)を併用する。- **リアルタイムトリアージ:** 救急外来のノートから5分以内に構造化データを作成し、リスクスコアを通知する。
## 次の章
- 第09章 `llm-vendor-benchmark`:この章のパイプラインをClaude / GPT-4o / Med-Gemini / Meditronで実行し、ベンチマークを行う。- 第10章 `med-llm-reproduction`:HealthBenchを再現し、さまざまなベンダーの臨床タスクにおけるパフォーマンスを比較する。- 第14章 `bio-mcp-agent`:この章の構造化JSONをMCPツールとして公開し、自律的な臨床エージェントを構築する。
## 参考文献
1. Med-Gemini clinical benchmarks — Google Research: `https://research.google/pubs/med-gemini/`2. Anthropic Claude API pricing and structured output docs: `https://docs.anthropic.com/en/docs/build-with-claude/structured-output`3. HHS HIPAA Safe Harbor Deidentification method: `https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/index.html`4. Microsoft Presidio (PII deidentification): `https://microsoft.github.io/presidio/`5. Anthropic Tool Use overview: `https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/overview`6. UMLS REST API documentation: `https://documentation.uts.nlm.nih.gov/rest/home.html`7. Weissman GE et al., "Clinical NLP with rule-based baselines", JAMIA 2021.8. Lee J et al., "BioBERT: a pre-trained biomedical language representation model", Bioinformatics 2020.9. Agrawal M et al., "Large Language Models for Clinical Information Extraction", NEJM AI 2024.10. OpenAI Developer Forum clinical NLP thread (コミュニティからの報告).11. Anthropic Cookbook GitHub — tool_use edge cases: `https://github.com/anthropics/anthropic-cookbook`12. Meystre SM et al., "Automatic de-identification of clinical text", JAMIA review.13. MIMIC-IV dataset: `https://physionet.org/content/mimiciv/`14. n2c2 (i2b2) NLP datasets: `https://www.i2b2.org/NLP/DataSets/`15. SciSpacy models: `https://allenai.github.io/scispacy/`