ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

中文电子病历命名实体识别:基于PyTorch的BiLSTM+CRF复现指南

中文电子病历命名实体识别:基于PyTorch的BiLSTM+CRF复现指南 简介一份基于 Python 与 PyTorch 实现的中文电子病历命名实体识别NER项目适合医疗信息处理、NLP 入门及序列标注方向学习者参考。资源聚焦从电子病历文本中提取医疗实体覆盖文本预处理、中文分词、BIO/BIOES 序列标注、实体字典构建及模型评价指标等核心环节。压缩包共 2000 个文件以 1994 个 txt 病历语料为主另含 5 个 Python 源码文件与 1 个 README 说明文档便于快速理解从数据处理到模型训练的整体流程包体仅 11.22MB轻量易用。目前已有 114 人学习下载。通过完整源码与案例可掌握 PyTorch 搭建 NER 模型的方法并了解临床文本处理的实用技巧为后续医疗知识抽取和智能辅助诊疗提供可直接扩展的基础工具。1. 中文电子病历命名实体识别为什么这个 pytorch 项目值得复现一遍中文电子病历命名实体识别NER这几年被反复提起实际动手做的时候大家拿到的往往就是这样一个以 zip 分发的 python 项目包。包里是数据处理、模型和训练脚本但能不能把这个 NER 模型跑通、跑稳还得看你自己对标签体系和 pytorch 训练细节的掌控。病历文本和新闻语料差得远主诉口语化、药名长、检查项缩写多通用 NER 模型拿过来经常边界全乱。对做病案质控、临床科研数据抽取、医保结算结构化的人来说用 pytorch 复现这套项目等于把数据清洗、序列标注、模型调优和部署验证的完整链路重新走了一遍。下面按一个可落地的顺序展开新手能跟着复现老手也能直接对照参数和避坑点。2. 方案定型从标签体系到网络选型动手写代码之前最该先定下来的不是模型用几层而是实体类型和标注规范。电子病历 NER 和通用领域 NER 最大的差别也在这里。2.1 用 BIO 标注把电子病历变成序列标注任务电子病历里最常见的实体类型可以归成六类症状和体征、检查项目、手术操作、药物、疾病诊断、身体部位。有些项目还会拆出“检查结果”或“就诊科室”但拆得越细标注一致性和模型收敛难度越高。我一般建议先把六类做稳再去加第二层标签。BIO 标注的含义很简单每个字要么是实体开头 B要么是实体内部 I要么不是实体 O。以“患者因反复头痛伴恶心3天入院行头颅CT检查未见异常予以布洛芬缓释胶囊治疗出院诊断偏头痛”为例按字符切分后标注成下面这样患 O 者 O 因 O 反 O 复 O 头 B-SIG 痛 I-SIG 伴 O 恶 B-SIG 心 I-SIG 3 O 天 O 入 O 院 O 行 O 头 B-BOD 颅 I-BOD C B-CHE T I-CHE 检 O 查 O 予 O 以 O 布 B-DRU 洛 I-DRU 芬 I-DRU 缓 I-DRU 释 I-DRU 胶 I-DRU 囊 I-DRU 偏 B-DIS 头 I-DIS 痛 I-DIS注意几件事头痛和恶心分别标成两个症状实体而不是合并在一个实体里“头颅”是身体部位和“CT”这个检查实体紧挨着靠 B 和 I 切分开布洛芬缓释胶囊是完整药名不能只标“布洛芬”。实际项目中常见做法是 data 目录下放 train.txt、dev.txt、test.txt 三个文件每行一个“字 标签”空行表示句子结束。训练脚本读这种格式最省事也方便后面对照原文检查边界错误。2.2 基于字符的 BiLSTM 是医疗 NER 的稳妥基线为什么不用分词后再建模中文分词工具在通用文本上表现尚可遇到病历里的缩写药名、中英混排检查项、口语化症状时分词错误会直接传导给 NER造成实体边界永久性错位。基于字符建模天然绕开分词每一个汉字都是一个输入单元实体边界完全由模型学习。这也是中文医疗 NER 社区最主流的做法。模型主体用 BiLSTM原因是电子病历实体对上下文的依赖很强“行头颅CT检查未见异常”里“未见异常”决定了前面 CT 是检查而非诊断“予以布洛芬”里的“予以”提示后面是个药物实体。双向 LSTM 能同时看到左侧动词和右侧结果描述信息量比单向 LSTM 高一截。最后接 CRF 层是为了约束标签之间的转移关系。纯 Softmax 分类可能输出“O 后面直接跟 I-SIG”这种非法序列CRF 在学习阶段会记住合法转移实体必须以 B 开头I 不能自己起头不同实体类型切换必须先回到 O 或重新出现 B。小数据集上这一层通常能带来 2 到 4 个点的实体级 F1 提升代价是训练时每步要计算整条序列的路径概率速度会慢一些。2.3 用 pytorch 搭一个最小可跑环境目录结构先行下载下来的 zip 解压后我习惯先把目录结构理成下面这样再动训练逻辑ner/ data/ train.txt dev.txt test.txt src/ dataset.py model.py train.py predict.py requirements.txtrequirements 里放最小依赖不锁定版本保证在新环境能直接装上torch torchcrf seqeval numpytorchcrf 提供现成的 BiLSTMCRF 的解码和损失计算接口seqeval 用于计算实体级 F1。pytorch 版本注意一点CPU 机器直接pip install torch就能跑通整个流程只是训练会慢有 NVIDIA 显卡时建议先确认本地 CUDA 版本再装对应包装完用两行代码验证 GPU 是否真的可用。import torch print(torch.__version__) print(torch.cuda.is_available())打印True说明当前 pytorch 能调用显卡。项目里所有涉及随机种子的地方统一设置保证复现时结果可对齐。3. 准备一份能动手标注的数据集文本转 BIO 再转张量模型改得再好数据进不去也是白搭。这里把从原始病历文本到 pytorch 张量的完整链路拆开。3.1 先把病历文本切成字符并打好 BIO 标签数据准备阶段最枯燥但最关键。如果手头没有现成标注需要先写一个标注辅助脚本把每段文本逐字打印人工给标签。标注完成后要做一个一致性检查脚本扫三类低级错误def check_bio(labels): for i, tag in enumerate(labels): if tag.startswith(I-): prev labels[i - 1] if i 0 else O if not prev.endswith(tag[2:]) and prev ! tag.replace(I-, B-): return False return True逻辑是I 标签的前一个字要么是同类 B要么是同类 I否则说明前一个实体还没开始就出现了内部标签这类样本进模型只会让 CRF 学到错误转移。常见做法是写一个normalize_text函数把全角符号、多余空格、乱码先清掉再进入标注流程。3.2 构建字符词典和 Dataset 封装数据格式如果是“字 标签”两列按空行切句之后用两个 defaultdict 来构造索引from collections import Counter def build_vocab(sentences, min_freq1, max_size50000): counter Counter() for chars, _ in sentences: counter.update(chars) vocab {pad: 0, unk: 1} for ch, freq in counter.most_common(max_size): if freq min_freq: vocab[ch] len(vocab) return vocabvocab 里pad固定为 0unk固定为 1后续 Embedding 层的padding_idx0会直接复用这个设计。标签侧不需要unk因为所有标签都在训练集里出现过只需要一个label_to_id映射。Dataset 封装的核心是让__getitem__返回定长内容长度信息留给 collate 去处理class NerDataset(Dataset): def __init__(self, file_path, vocab, label_to_id): self.data [] chars, labels [], [] with open(file_path, encodingutf-8) as f: for line in f: line line.strip() if not line: if chars: self.data.append((chars, labels)) chars, labels [], [] continue ch, tag line.split() chars.append(ch) labels.append(label_to_id[tag]) self.vocab vocab def __len__(self): return len(self.data) def __getitem__(self, idx): chars, labels self.data[idx] char_ids [self.vocab.get(c, self.vocab[unk]) for c in chars] return torch.tensor(char_ids), torch.tensor(labels)每一条样本是一个等长的字符序列和等长的标签序列训练时再按 batch 内最大长度做 padding。3.3 用 DataLoader 做 padding 和 maskpytorch 的DataLoader本身不做 padding需要自己写collate_fn。这里的核心任务是构造 mask让模型知道哪些位置是真实字符、哪些位置是补的def collate_fn(batch): char_ids, label_ids zip(*batch) lengths [len(x) for x in char_ids] max_len max(lengths) padded_chars torch.zeros(len(batch), max_len, dtypetorch.long) padded_labels torch.full((len(batch), max_len), -1, dtypetorch.long) mask torch.zeros(len(batch), max_len, dtypetorch.bool) for i, (c_ids, l_ids) in enumerate(zip(char_ids, label_ids)): padded_chars[i, :len(c_ids)] c_ids padded_labels[i, :len(l_ids)] l_ids mask[i, :len(c_ids)] True return padded_chars, padded_labels, mask, torch.tensor(lengths)三个值的用途要分清楚padded_chars 进 Embeddingpadded_labels 在计算损失时用padding 位置填-1而不是0因为0通常代表 O 标签会让模型把无意义的 padding 位置当成真实负样本mask 供 CRF 层判断哪些位置参与计算。参数上max_len不用全量数据统计我一般先看数据分布再定如果 80% 以上的句子在 100 字以内max_len128足够把max_len直接设为 512 会让训练慢好几倍收益却很小。4. 写模型与训练脚本从随机权重到能用的 NER数据和网络结构都准备好之后真正动手写模型。这一章用最小代码量把 BiLSTMCRF 跑成一个可训练的模块并解释每个参数为什么这么设。4.1 用 nn.Module 写出 BiLSTMCRF 网络以下是一个可以直接进训练循环的模型定义import torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, label_size, num_layers2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embedding_dim, padding_idx0) self.lstm nn.LSTM( embedding_dim, hidden_dim // 2, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout if num_layers 1 else 0.0 ) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_dim, label_size) self.crf CRF(label_size, batch_firstTrue) def forward(self, char_ids, mask, label_idsNone): emb self.embedding(char_ids) lstm_out, _ self.lstm(emb) lstm_out self.dropout(lstm_out) logits self.fc(lstm_out) if label_ids is not None: return -self.crf(logits, label_ids, maskmask, reductionmean) return logits def decode(self, char_ids, mask): logits self.forward(char_ids, mask) return self.crf.decode(logits, maskmask)逻辑说明hidden_dim 取 256 时BiLSTM 两个方向各输出 128 维拼接后刚好是 256送进全连接层不浪费也不欠拟合。CRF 接收的 logits 是每个字在每个标签上的得分mask 告诉它哪些位置必须忽略。训练时forward返回负对数似然预测时decode用维特比解码出全局最优标签序列。4.2 带 mask 的 CRF 损失怎么算CRF 的损失和普通交叉熵不同它计算的是整条标签序列的概率负对数。直观理解模型给每个字生成一组成分CRF 在其中找出所有合法标签序列的总概率然后让正确序列的概率最大。因此 mask 的位置必须和 padding 严格对应否则 CRF 会把 padding 位置当成额外标签参与转移概率计算训练出的模型在预测时会对补齐位置产生幻觉。参数选择上embedding_dim 常用 128hidden_dim 常用 256num_layers 设 2。dropout 0.5 在数据量小的时候能明显抑制过拟合。如果验证集 F1 一直在涨但训练集 F1 接近 1说明过拟合把 dropout 调到 0.6 再看一轮。4.3 训练循环、早停与模型保存训练循环的核心代码如下model BiLSTMCRF(len(vocab), 128, 256, len(label_to_id)) optimizer torch.optim.Adam(model.parameters(), lr0.001, weight_decay1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience2 ) for epoch in range(60): model.train() for char_ids, label_ids, mask, _ in loader: optimizer.zero_grad() loss model(char_ids, mask, label_ids) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() val_f1 evaluate(model, dev_loader, label_names) scheduler.step(val_f1) if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), best_model.pt) else: if early_stop 5: break说明几个关键点weight_decay 设 1e-4 相当于 L2 正则配合 dropout 一起防过拟合clip_grad_norm 限制梯度模长不超过 5.0避免 LSTM 在长句子上梯度爆炸。学习率 0.001 是 Adam 的常见起点ReduceLROnPlateau 会在验证 F1 连续两轮不涨时把学习率减半。早停用 5 轮如果验证 F1 连续 5 个 epoch 没有刷新就停止训练加载best_model.pt用于后续预测。这里一定要强调模型保存的触发条件是验证集 F1不是训练集 loss更不是训练集 F1。前者反映了真实泛化能力后者只是在自欺欺人。5. 电子病历 NER 最容易踩的五个坑现象、原因、解决这部分全部来自实际复现过程中的血泪经验。每一条都按“现象 → 原因 → 解决”的顺序展开。5.1 模型预测全是 O实体一个都识别不出来现象训练完成后对测试集预测结果几乎全是 O偶尔吐出几个单字实体几乎没有连续的实体片段。训练过程中 loss 在下降但验证集 F1 一直是 0 或接近 0。原因电子病历里 O 标签占比通常超过 85%症状和药名这类实体只占很小比例。模型随便输出一长串 O 就能把损失压得很低CRF 也发现转移成 O 最保险于是陷入局部最优。解决第一步检查数据里有没有实体类别缺失比如某个实体类型只在训练集出现 20 次模型基本学不到。第二步给损失或 CRF 转移加入类别先验对 O 标签的 logits 乘一个小权重或者提高 B 标签的初始转移分。第三步最简单也最有效的方式训练时对数据做随机丢弃让每个 batch 里含实体的句子比例不低于一半避免模型长期只看到 O 样本。5.2 DataLoader 的 mask 漏传验证集 F1 虚高现象训练时 loss 正常下降验证集 F1 看起来很高但把预测结果打印到原文上发现实体边界乱跑和标注对不上。把 F1 算出来吓人一跳再仔细看发现 padding 位置也被当作 O 标签参与评估了。原因collate_fn 里 label 的 padding 位置填了 0而 0 恰好是 O 标签。模型预测 padding 区域时也输出 O评估代码如果没按 mask 过滤这些位置会被当成正确预测的正样本把 F1 顶高。另外CRF decode 时如果漏传 maskpadding 位置也会参与维特比路径导致末尾出现一堆人为标签。解决padding 标签统一填-1评估时把-1全部过滤掉CRF 的decode或forward必须传 mask。写成一条硬性规则任何用到 mask 的地方都要确认 token 和 label 两侧同时对齐不能只对一边做 mask。5.3 CRF 让训练“肉眼可见”变慢然后盲目调参现象从 Softmax 换成 CRF 后每个 epoch 的耗时变成原来的两到三倍GPU 利用率不高占用却不低。有人为了“省时间”把 max_len 调小结果长实体被截断F1 反而掉了。原因CRF 损失需要计算整条序列所有路径的转移概率复杂度大致是“序列长度 × 标签数平方”。如果序列长度从 128 加到 256耗时增加接近一倍标签类别从 7 类加到 13 类转移矩阵的复杂度也成倍增长。解决不要盲目动 max_len先用数据统计确定 95% 句子的长度阈值。更常见也更好用的办法是动态 batch按句子长度排序把长度相近的样本组成一个 batchpadding 造成的浪费会大幅降低。实践下来在不改模型的前提下动态 batch 通常能让训练提速 40% 到 60%。5.4 CPU 和 GPU 上预测结果不一致部署阶段翻车现象训练在 GPU 上完成导出模型后用 CPU 推理同一句病历得到和训练时不同的实体边界。有时只差一两个字有时整个实体丢一半。原因大概率是推理时忘了把模型切到 eval 模式。dropout 在推理时还在随机丢弃神经元导致每次预测结果都略不一样。另一个原因是训练和推理时用了不同的数据预处理比如 GPU 训练流程里做了全角转半角CPU 推理脚本里忘了做字符序列不同边界自然不同。解决在预测代码里强制加两行model.eval() with torch.no_grad(): pred_ids model.decode(char_ids, mask)另外把文本标准化逻辑抽成一个独立函数训练和推理共用同一个文件里的同一个函数不要各自写一份。也可以固定全局随机种子让 pytorch 的 dropout 行为尽量稳定。5.5 全角空格和病历口语让实体边界“玄学”偏移现象同一份数据训练两次模型对大部分实体都能稳定识别唯独一些包含括号、冒号、全角空格的病例实体落点经常偏一个字。看标注数据看不出毛病但预测结果就是差一个空格的位置。原因电子病历里经常混着全角冒号、全角括号和半角括号同一个实体在不同病历里写成“”和“CT”两种形式。模型把全角空格当成一个正常字符输入实体边界被它顶开另外“予以”“收治”“查体”这类口语化连接词和实体紧贴会影响 LSTM 对边界位置的判断。解决数据清洗阶段统一做映射全角数字和字母转半角全角空格替换为半角空格多个连续空格合并为一个括号统一为半角。还要注意清洗和标注必须用同一份文本不能在标注完成后再做清洗否则标注偏移全乱。这个坑我至少翻过两次车每次都是因为清洗脚本和数据标注顺序不一致。6. 从评估到进阶把模型输出还原成可用的实体表训练完模型只是第一步要投入使用还要解决“怎么算好”和“怎么接进业务”这两个问题。6.1 用十行代码算实体级 F1医疗场景的业务方真正关心的是实体有没有完整识别而不是单个字符分对没有。用 seqeval 按实体级别评估from seqeval.metrics import classification_report true_sequences [[O, B-SIG, I-SIG, O], ...] pred_sequences [[O, B-SIG, I-SIG, O], ...] print(classification_report(true_sequences, pred_sequences))它会把每个实体片段当成一个整体计算精确率、召回率和 F1。和字符级准确率相比实体级 F1 更贴近业务真实观感也是模型选型和参数调整的主要依据。6.2 从 BiLSTMCRF 平滑迁移到 BERTCRF 的衔接点数据量足够大、GPU 资源允许时可以把手写 BiLSTM 换成预训练 BERT。中文 BERT 的 tokenizer 基本按字切分原来按字对齐的标签体系可以原样保留。升级路径是把 Embedding 和 BiLSTM 换成 BERT 输出后面再接 FC 和 CRF训练时先冻结 BERT 参数跑几个 epoch再全量微调。由于 CRF 的结构和代码完全不变从 BiLSTM 到 BERT 的迁移成本主要花在 tokenizer 和显存调优上模型代码改动其实不大。我个人的习惯是在每次训练结束后随机抽 20 条验证集样本把原文、标注、预测结果并排打印出来人工过一遍。这一步能发现 F1 反映不出来的问题比如多个实体紧贴时边界错一位、嵌套实体只识别了外层、药物名拆成两半。准确率指标再高也不如一版看得见的预测结果踏实。希望这套从数据到训练的流程能帮你更快把这个中文电子病历 NER 项目跑通并放进自己的业务里。本文还有配套的精品资源点击获取
返回列表