ARTICLE DETAIL

资讯详情

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

中文电子病历命名实体识别实战:BiLSTM-CRF模型全解析

中文电子病历命名实体识别实战:BiLSTM-CRF模型全解析 简介这是一份面向中文电子病历的命名实体识别实战源码基于开源评测数据构建适合自然语言处理初学者、医学文本挖掘研究者以及想快速上手模型的开发者。项目完整覆盖数据预处理、模型设计、训练与测评流程针对医疗实体识别中的术语边界与类别不平衡问题给出了可运行的解决方案。压缩包共包含17个文件以Python脚本和txt数据文件为主另有赛题描述文档、JSON格式测试集和可视化结果图整体大小约1.5MB结构清晰便于按模块学习已有2986人学习下载。通过这套源码读者可以了解如何将中文医疗文本转换为模型输入掌握BERT预训练向量与双向LSTM模型结合的常见实现方式并利用附带的训练集、验证集和测试集复现实验效果。同时代码注释详细简单易读适合作为课题实验、毕业设计或竞赛复现的起点。 中文电子病历里的命名实体识别NER是NLP领域特别接地气的一类实战方向。它要解决的痛点很明确让模型能从一段半结构化的中文病历文本里自动抽取出“症状、疾病、药物、检查、治疗”等信息。相比通用新闻文本医疗文本里全是口语化表达、缩写、嵌套实体和不规范写法做起来比想象中难。这个项目之所以值得拿来练手是因为它覆盖了从数据标注、序列建模到模型评估的完整链路源码可以直接改改用于类似任务特别适合对NLP有基础、想动手做行业落地方案的人。1. 项目整体设计与需求拆解1.1 为什么拿中文电子病历练手很多人第一次接触NER都是做新闻实体抽取、金融舆情分析之类数据干净、实体类型也相对规整。换成中文电子病历后情况完全不一样。病历里的文本风格高度口语化比如“患者3天前无明显诱因出现上腹部隐痛伴反酸、烧心”这句话里“上腹部隐痛”“反酸”“烧心”都是症状实体但文本本身并不遵循严格句式再比如“左肺上叶见磨玻璃影”这里的“左肺上叶”是身体部位“磨玻璃影”是影像学所见类型判断需要医学常识。这个项目把目标定在电子病历NER是为了解决一个很现实的问题非结构化的病历文本无法直接被临床决策、科研检索、质控系统使用。你要先把它转成结构化信息才能做疾病图谱、不良事件监测、辅助编码这些下游任务。所以项目中涉及的实体类型、标注粒度、评估方式都和医疗场景强绑定。你在跑通源码的同时其实是在理解一个行业应用的完整架构。1.2 实体类型、标注体系与标签体系设计做NER之前第一步一定要先定义清楚“识别什么”。这个项目里我建议先按5类实体起步身体部位、症状、疾病诊断、检查检验、治疗药物。你也可以参考CCKS竞赛中的标注方案把实体类型进一步细分成“检查项目”“检查结果”“手术操作”等但实体类型过多会显著增加标注难度和模型混淆度初版控制在5到6类比较合适。标注体系上行业里最常用的是BIO和BIOES两套。BIO把每个实体首字标为B其余字标为I非实体字标为OBIOES则在BIO基础上增加E实体结尾和S单字实体。这个项目我建议直接用BIOES因为对单字实体的约束更强模型在推理时不容易把一个字判成独立实体也不容易漏掉边界。以下面这句话为例患者因胸痛、气短入院心电图提示心肌缺血。对应标注应该是患/O 者/O 因/O **胸/B-SYMP **痛/I-SYMP **、/O **气/B-SYMP **短/I-SYMP **入/O 院/O ... 心/B-CHECK 电/I-CHECK 图/I-CHECK 提/O 示/O 心/B-DISEASE 肌/I-DISEASE 缺/I-DISEASE 血/I-DISEASE。这个例子建议作为首条验证用例写进测试集后面无论怎么调参这条都不能出错。实战中很多模型跑起来指标能看但单抽这句话就是崩多半就是标签体系不一致或者边界预测有问题。1.3 项目源码结构与运行环境这个项目的源码不能只是堆一个train.py我整理了一套比较清晰的目录结构你拿到之后可以直接“抄作业”. ├── README.md ├── requirements.txt ├── config.py # 全局配置路径、超参数、实体/标签映射 ├── preprocess.py # 原始文本 - 字/词序列 - 标签序列 ├── dataset.py # Dataset/Dataloader、padding、mask ├── model.py # 模型定义嵌入层 BiLSTM CRF ├── train.py # 训练循环、验证、模型保存 ├── predict.py # 加载模型对单条文本做预测 └── utils.py # 指标计算、维特比解码、工具函数环境方面Python 3.8PyTorch 1.10以上或者TensorFlow 2.x都可以。依赖主要是numpy、pandas、sklearn、tqdm如果做BERT版本再加transformers。CPU也能训练这个基线模型只是时间会稍长有GPU更好显存6GB以上基本就够用。2. 核心技术方案与模型选型2.1 字级别特征与词级别特征怎么选中文NER绕不开一个选择按字切还是按词切我的建议是对于电子病历场景默认按字级别建模这一点非常重要。原因是医疗文本里分词工具的表现并不好。比如“心肌缺血”可能被切分成“心肌/缺血”也可能被切分成“心肌缺/血”一旦分词错了词边界信息就直接污染了实体边界。字级别方案把“心”“肌”“缺”“血”当成四个独立输入模型自己学习组合规律虽然少了词蕴含的语义但鲁棒性更好。如果你手上有一份领域词典比如标准诊断名词、药品名录可以在字级别模型外面加一层词典特征也就是把匹配到的实体候选词向量拼进对应位置的输入里。项目初版可以不接词典特征但保留词典接口是有价值的后面迭代时很容易加。这个点对后续在病历上的效果提升很关键因为病历里的实体缩写和别名非常多纯靠模型很难全部记忆。2.2 BiLSTM-CRF为什么它是基线首选选BiLSTM-CRF作为主模型有几个实际理由。第一它不需要大规模预训练模型就能跑出可用效果对设备要求低。第二LSTM天然适合变长序列对病历里长短不一的句子比较友好。第三CRF层能显式建模标签之间的约束关系比如“B-DISEASE后面不可能直接跟I-SYMP”“I标签前面必须有对应的B标签”这个约束对于实体边界稳定非常重要。具体来说BiLSTM负责给每个字输出一个上下文相关的隐层向量然后经过线性层映射到所有标签的得分形成“发射分数”。CRF层再负责学习标签之间的“转移分数”。训练时目标函数是gold序列的路径得分最大化的对数似然推理时用维特比算法在所有路径里解码得分最高的标签序列。如果你拿纯交叉熵逐字预测作对比差距会很明显。纯逐字预测在短实体上表现尚可但到了“胃窦部黏膜充血水肿”这种较长且边界模糊的实体上经常会出现前面识别对、后面少收几个字的情况而CRF的转移矩阵会在训练里持续“纠正”这种错误。2.3 什么时候可以直接上BERTBERT这类预训练模型在NER任务上效果通常优于BiLSTM-CRF但这次项目的主代码我用的是非预训练方案原因有三个。第一医疗领域需要领域预训练模型比如基于中文医学语料继续训练的BERT版本这类模型体积大、推理慢在线上服务里往往是最后一步才考虑引入。第二对于学习型项目用BiLSTM-CRF从零训练你能更直观地理解嵌入、序列建模、解码的每个环节比直接调用transformers更能建立对模型的掌控感。第三很多实际比赛和工业场景里数据量不大直接微调预训练模型的收益未必比一个精心调过的BiLSTM-CRF高多少。如果你后续想对比项目里可以在model.py里加一个BertForTokenClassification的选项配合transformers库使用。但初版不建议混合模型先跑通基线再引入预训练。3. 数据准备与预处理实战3.1 数据集来源与格式转换训练数据是项目的命脉。这里不要自己手工标注几百条就开始用效果会很差。我建议优先使用公开的医疗实体识别数据集比如CCKS相关评测任务提供的中文电子病历实体标注数据数据内容经过脱敏处理可以用于研究。如果没有拿到官方数据也可以用一些开放的中文医疗NER数据但要注意数据规模和实体类型是否和你的任务对齐。项目里最常见的数据格式是“文本标签”的逐行对照。比如原始文件可能是患者3天前无明显诱因出现上腹部隐痛 O O O O O O O O B-SYMP I-SYMP I-SYMP I-SYMP也可能是JSON结构{text: 患者3天前无明显诱因出现上腹部隐痛, label: [O,O,O,O,O,O,O,O,B-SYMP,I-SYMP,I-SYMP,I-SYMP]}preprocess.py要做的事情就是把这些格式统一转换成语料集、标签索引并输出到内存或者中间文件。注意编码统一使用UTF-8避免Windows环境下编码问题。3.2 字表构建与序列Label对齐数据处理中最容易出问题的地方就是“字”和“标签”无法对齐。中文一句话分词之后不同分词工具的结果不一样你很难保证每个词正好对应一个标签块。所以项目采用“字级别”标注后对齐就简单了一句话有多少个字符就有多少个标签一一对应。构建字表时要把这些特殊标记都考虑进去[PAD]、[UNK]、[CLS]、[SEP]。其中[PAD]用于批内长度对齐[UNK]用于处理没有见过的字。label2id里也要给padding预留一个标签一般直接复用O标签或者在loss计算时把padding位置的标签忽略掉。推荐后者更明确因为我用torch.nn.CrossEntropyLoss时直接设置ignore_index-100这样padding位置不但不会参与loss还能防止模型学出“把PAD预测成O”这种无意义行为。数据划分按6:2:2拆成训练集、验证集、测试集。划分时要按“病历文档”级别切分而不是按句子切分防止同一份病历的上下文信息泄露到训练集和测试集导致指标虚高。这个细节很多人会漏掉。3.3 构建Dataloader与Mask策略PyTorch的Dataloader处理变长序列时必须做两件事padding和mask。一个batch里句子长度不一短的补PAD到和最长的一样长mask标记哪些位置是真实字符哪些是PAD。BiLSTM部分要用到pack_padded_sequence和pad_packed_sequence来跳过PAD位置避免模型把PAD当成有效信息学习。CRF层的实现比BiLSTM更敏感。如果mask没有正确传入CRFPAD位置也会被当成“O”标签参与转移概率计算这会在训练时引入大量噪声导致收敛变慢、指标偏低。一个稳妥的做法是所有标签序列长度超过模型设置的max_len时直接截断不足时用-100填充并在CRF的forward计算里跳过这些位置。下面这段是项目dataset.py里的核心逻辑def collate_fn(batch): texts, labels, lengths zip(*batch) max_len max(lengths) padded_texts torch.zeros(len(batch), max_len, dtypetorch.long) padded_labels torch.full((len(batch), max_len), -100, dtypetorch.long) masks torch.zeros(len(batch), max_len, dtypetorch.bool) for i, (text, label, length) in enumerate(zip(texts, labels, lengths)): padded_texts[i, :length] torch.tensor(text) padded_labels[i, :length] torch.tensor(label) masks[i, :length] True return padded_texts, padded_labels, masks, torch.tensor(lengths)这段代码能避免很多初学者踩到“标签错位”的坑。训练时如果发现loss不下降或非常低优先检查mask和label是否对齐。4. 模型搭建与训练细节4.1 embedding、BiLSTM与CRF的代码结构模型结构不复杂但每一层都要理解清楚。model.py中主要包含三部分。先是Embedding层输入是字索引输出是字向量。字向量可以用随机初始化也可以用预训练中文词向量初始化比如腾讯开源的词向量里也包含了字向量。初版不建议把所有精力花在预训练向量上随机初始化加足够多的训练轮次也能达到相对稳定的效果。其次是BiLSTM层。输入是batch中每句话的字向量序列输出是两个方向隐层状态拼接后的向量。这里有一个关键参数hidden_size。我建议设成128或256太小拟合能力不够太大容易过拟合。如果训练数据量在几千句级别hidden_size128就够数据量超过几万句再考虑256。LSTM层数建议设1层或2层超过2层在医疗文本这种小规模数据上容易过拟合。最后是CRF层。CRF层需要维护一个标签间转移矩阵。在训练阶段计算所有可能标签路径的log-sum-exp和gold路径的分数两者相减得到loss在推理阶段使用维特比算法解码最优路径。下面是核心片段class CRF(nn.Module): def __init__(self, num_tags): super().__init__() self.num_tags num_tags self.trans nn.Parameter(torch.randn(num_tags, num_tags)) def forward_loss(self, emissions, tags, mask): # emissions: [batch, seq_len, num_tags] # tags: [batch, seq_len] # mask: [batch, seq_len] bool score self._gold_score(emissions, tags, mask) norm self._log_norm(emissions, mask) return (norm - score).mean()需要特别注意CRF中标签索引的起始和结束位。如果标签集中没有START/END标记需要在转移矩阵中额外定义两个状态分别表示序列开始和结束。否则序列起点的标签转移概率不受约束模型容易在句首位置预测错实体标签。4.2 训练策略与超参数训练流程按我的习惯可以分成三个阶段。第一阶段是热身试跑用小数据集和少量epoch比如2到3轮验证代码能跑通、loss在下降第二阶段是正式训练用完整数据集设定较大epoch数第三阶段是早停监控验证集F1连续若干个epoch不提升就停止保存最优模型。优化器用Adam基础学习率1e-3。如果加了BERT学习率要调到2e-5到5e-5。batch_size和max_len有关一般batch_size设为32或64max_len设为128或256。病历句子长超过max_len的可以直接截断但要保证截断后的实体不跨边界否则会损失一部分标注样本。LSTM层之间和Embedding之后加Dropoutdropout_rate0.5。如果发现验证集F1和训练集F1差距过大把dropout提高到0.5以上或者把hidden_size调小。梯度裁剪也是必须的clip_grad_norm_(model.parameters(), max_norm5.0)可以防止CRF训练时的梯度爆炸尤其是训练初期。4.3 评估指标与实体级F1NER任务的评估不能用普通的token-level accuracy因为它会把非实体位置也算进去大量O标签一算准确率虚高。正确做法是entity-level的precision、recall和F1。也就是把预测结果拆成一个一个的实体比如“胸痛”是一个实体和gold文件里的“胸痛”做完整匹配完全一致才算对边界不一致就算错。具体计算时可以先把预测标签序列转换回实体列表每个实体格式为(实体类型, 起始位置, 结束位置, 实体文本)然后和gold列表做精确匹配。用sklearn没有现成函数自己写也不难。项目utils.py里写了一个通用的extract_entities函数def extract_entities(label_ids, id2label): entities [] start -1 prev_label O for i, lab_id in enumerate(label_ids): label id2label[lab_id] if label.startswith(B-): if start ! -1: entities.append((prev_label.split(-)[-1], start, i - 1)) start i prev_label label elif label.startswith(I-): if start -1: start i prev_label label else: prev_label label else: if start ! -1: entities.append((prev_label.split(-)[-1], start, i - 1)) start -1 prev_label O if start ! -1: entities.append((prev_label.split(-)[-1], start, len(label_ids) - 1)) return entities如果F1卡在某个数值上不去很大概率不是模型问题而是实体边界识别不完整。这时优先去检查预测结果里常见错误类型比如“左肺上叶结节”被识别成“左肺上叶”“结节”两个实体还是在标注阶段就把边界标准定义清楚。5. 常见问题与排查技巧实录5.1 标签错位与序列长度不一致这种问题最常见的表现是训练loss能降但evaluation时F1特别低而且日志里有index out of range或者length mismatch报错。根源就是padding之后标签和输入序列没有对齐。我遇到过多次最后定位到是collate_fn里把padded_labels的padding值设成了O的索引而不是-100导致模型在padding位置学习了大量“O”特征干扰真实标签。排查方法很简单把一个batch的输入和标签输出到控制台人工检查最后一个有效字对应的标签以及mask边界处是否出现非-100标签。如果有改掉padding标签如果没有再检查CRF里是否把mask用到了正确的位置。这个坑只要踩过一次以后写所有序列标注任务都会主动避开了。5.2 实体边界预测不稳定边界问题表现为实体类型全对但多一个字或少一个字。比如“上腹部隐痛”被识别为“腹部隐痛”。这类问题处理起来要从两个方向同时入手。一个是数据层面检查标注是否一致尽量保证同一种实体表达在所有样本里都按同一个粒度标注比如“上腹部隐痛”要么整体标要么标“上腹部”作为部位、“隐痛”作为症状不能混着来。另一个是模型层面试试把BIO改成BIOES。BIOES里的E标签会强制模型学“实体的最后一个字”对边界识别有明显帮助。还可以在BiLSTM输出后加入一个CRF层的额外特征“B-xxx后必须跟I-xxx或E-xxx”这种约束能够在解码阶段直接过滤掉不合理的边界。如果仍然不稳定可以考虑给损失函数加一个边界惩罚项但初版不用做这么复杂。5.3 类别不均衡与长尾实体病历数据里“症状”和“检查”类实体通常占比很高“疾病诊断”和“药物”相对较少。如果模型对所有实体类型一概而论长尾类型容易学不到。最简单的手段是用实体级F1而非token级准确率来选模型避免模型偏向高频类型。另外可以在数据采样时对包含长尾实体的句子提高采样权重。用一个简单策略如果句子中包含“药物”或“疾病诊断”这类低频实体就把它在DataLoader里多采样几遍。这本质上是过采样实现成本低效果也不错。如果数据量允许也可以给CRF的转移矩阵或者loss加类别权重但初版先不做避免引入额外参数难以调试。5.4 推理阶段如何解码与输出结构化结果训练好模型后predict.py做的事情就是输入一句话转成字索引padding后进入模型拿到发射分数再和CRF转移矩阵一起做维特比解码得到标签序列。关键点是推理时不能直接用argmax必须走维特比。下面是预测函数的骨架def viterbi_decode(emissions, trans, mask): batch_size, seq_len, num_tags emissions.shape score torch.full((batch_size, num_tags), -1e4) score[:, 0] emissions[:, 0, 0] backpointers torch.zeros(batch_size, seq_len, num_tags, dtypetorch.long) for t in range(1, seq_len): for b in range(batch_size): if not mask[b, t]: continue next_score, bp torch.max(score[b] trans, dim-1) score[b] next_score emissions[b, t] backpointers[b, t] bp best_path [] best_score, best_tag torch.max(score, dim-1) for b in range(batch_size): seq [best_tag[b].item()] for t in range(seq_len - 1, 0, -1): seq.append(backpointers[b, t, seq[-1]].item()) seq.reverse() best_path.append(seq) return best_path预测完成后把标签序列映射回文本位置调用extract_entities就能拿到结构化实体列表。之后你可以很方便地输出JSON或者写入数据库供下游系统使用。到这一步一个可用性很强的病历实体抽取pipeline就算完整闭环了。这个项目做下来我的一个直接体会是中文电子病历NER的难点并不全在模型数据质量和标签一致性才是决定效果上限的最大因素。同一个症状有人写“上腹部隐痛”有人写“上腹隐痛”还有人写“剑突下疼痛”如果标注时没有统一规范化模型再强也学不会。源码本身只是提供了骨架真正让模型变好用的是反复修正标注规范、补充领域词典和不断跑case复盘的过程。建议你跑通基线后找两三百条真实病历文本自己尝试标注、预测、迭代再回头对比第一次的结果你会对这个领域有非常直观的理解。本文还有配套的精品资源点击获取
返回列表