ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

中文电子病历命名实体识别:BERT-wwm与BiLSTM-CRF的实践

中文电子病历命名实体识别:BERT-wwm与BiLSTM-CRF的实践 简介面向中文医疗文本信息抽取研究者的深度学习命名实体识别实验系统定位在电子病历中的疾病、症状与治疗方案等实体识别任务聚焦CCKS2019评测数据集的模型训练与性能评估。系统以全词掩码策略优化的BERT-wwm为预训练底座集成BiLSTM-CRF序列标注架构并支持CNN、RNN等经典网络对比实验便于系统评估不同模型在医学文本上的表现。配套代码覆盖数据预处理、模型构建、训练评估与可视化环节并提供数据集与环境配置说明可快速复现完整实验流程。包体为zip压缩格式共44个文件以26个Python脚本为主兼有YAML配置文件、Shell启动脚本、SQLite数据库及Markdown文档整体约41KB结构轻量但功能模块清晰。目前已有54人学习下载适合具备一定深度学习基础、开展中文医疗NLP课题研究的开发者作为参考基线。1. 中文电子病历命名实体识别先解决标注边界再谈模型选型医院信息科拿到的出院小结、入院记录、手术记录本质上是半结构化自由文本。想做CDSS、病历质控或科研数据治理第一件事就是把“冠状动脉支架植入术”“左肺上叶结节”这类实体从文本里抽出来。中文电子病历命名实体识别就是这个环节的核心技术。标题里同时出现BERT-wwm和BiLSTM-CRF不是模型秀肌肉而是现实约束下的组合拳数据量少、标注成本高、科室还要求你对每个预测结果给得出解释。BERT-wwm负责拉高精度上限BiLSTM-CRF负责提供一个不依赖外部权重、CPU可推理的对照基线和兜底方案。乳腺外科医生更关心的是“术后第5天引流液颜色性状”心内科医生更关心“左前降支中段狭窄程度”不同科室对实体边界的敏感点完全不一样。通用NER工具直接套在病历上翻车频率极高。这套方案的读者是手里有脱敏病历文本、想自己搭一套可复现流程的算法工程师或医疗信息化从业者。下面按数据准备、两个模型、评估排查的顺序展开代码以PyTorch为主改到自己的数据集上不需要大动。2. 数据预处理与标签体系BIO还是BIOES按病历划分还是按句子划分2.1 BIO与BIOES的选择小样本上BIO更不容易翻车标注体系直接决定模型要学什么。BIO把实体首字标为B其余字标为IBIOES额外引入E和S分别表示实体结尾和单字实体。理论上BIOES对边界更敏感因为它显式告诉模型“这里结束了”但代价是标注难度上升。病历标注通常是医生或研究生兼职做的同一份文本里“高血压病”有时标成“高血压/病”有时整体作为一个实体这种边界抖动在BIOES里会同时污染E和S两个标签错误梯度被放大。我一般会先用BIO理由很朴素BIO对边界错误容忍度高模型只需要判断“是不是实体内部”不需要同时猜“是不是结尾”。等标注规范跑过两轮、双人标注一致性达到85%以上再切BIOES实体F1通常还能再涨0.5到1个点。下面代码里的标签表按BIO设计切BIOES时只需要改标签映射和转换函数。2.2 分词在病历里的干扰为什么我坚持用字粒度中文分词在通用领域已经比较成熟但病历文本是重灾区。“冠状动脉粥样硬化性心脏病”会被切出“冠状/动脉/粥样/硬化/性/心脏/病”这样的碎片任何一个分词器都无法稳定对齐医学实体边界。更麻烦的是病历里大量缩写和检查项名称比如“LAD”左前降支、“LDL-C”分词器会直接当作一个词但实际标注时可能只取其中一部分。所以两个模型都按字粒度处理。BERT-wwm本身是字输入直接给字序列BiLSTM-CRF的embedding也用字向量如果要拼词特征我会用病历语料自己训练word2vec而不是加载通用领域的词向量通用词向量里的“支架”和病历语境里的“冠状动脉支架”根本不是同一个语义粒度。字粒度也简化了对齐逻辑标注文本按字符索引不需要处理BPE分词的偏移问题。2.3 从JSONL到BIO批量转换数据预处理代码与按病历划分标注数据通常导成JSONL一条记录包含病历ID、文本和实体列表。转换脚本要做三件事建立字表、把实体标注转成BIO标签序列、按病历ID划分数据集。先看转换逻辑。# data_prepare.py import json from collections import defaultdict def build_label_id(): # BIO模式O0B-type、I-type按实体类型编号排列 types [手术, 症状, 药物, 检查, 身体部位, 时间] label2id {O: 0} idx 1 for t in types: label2id[B- t] idx label2id[I- t] idx 1 idx 2 return label2id def jsonl_to_bio(jsonl_path, label2id): samples [] with open(jsonl_path, r, encodingutf-8) as f: for line in f: item json.loads(line) text item[text] labels [O] * len(text) for ent in item[entities]: s, e ent[start], ent[end] # 按字符索引 if e len(text): # 越界实体直接丢弃避免污染 continue labels[s] B- ent[type] for i in range(s 1, e): labels[i] I- ent[type] samples.append((item[id], text, labels)) return samples这段代码按字符索引把实体区间填成BIO序列。关键点是end越界要跳过而不是报错标注工具导出的数据里偶尔会有落后一版文本的脏实体直接抛异常会让整批数据处理中断。label2id里每个实体类型占两个数字B和I相邻方便后面模型输出层直接按数字映射。数据集划分上我吃过一次闷亏按句子随机切分训练集和测试集同一个患者的多句话同时出现在两端模型记住“术后”后面常接“手术”测试集F1虚高到92%上线后掉到81%。正确做法是先把所有句子按病历ID聚合再用train_test_split在病历级别划分。from sklearn.model_selection import train_test_split def split_by_case(samples, test_size0.2): case_map defaultdict(list) for case_id, text, labels in samples: case_map[case_id].append((text, labels)) case_ids list(case_map.keys()) train_ids, test_ids train_test_split(case_ids, test_sizetest_size, random_state42) train_samples [(cid, t, l) for cid in train_ids for t, l in case_map[cid]] test_samples [(cid, t, l) for cid in test_ids for t, l in case_map[cid]] return train_samples, test_samples按病历划分会比按句子划分损失一点数量但换来的评估可信度值得。病历文本高度模板化同一科室的出院小结开头可能都是“患者于xxxx年xx月xx日因……”开头不按病历分你评估的是模型背文本的能力不是泛化能力。3. 用BERT-wwm做序列标注微调配置、损失设计、分层学习率3.1 为什么用BERT-wwm而不是原版BERT原版BERT在中文上做掩码语言模型时Mask掉的是单个汉字比如“心脏”可能只盖住“心”模型无法感知这是一个完整词。BERT-wwm的核心改动是Whole Word Masking中文里先把句子切词再对整词的所有字统一Mask让模型学习“这个词被盖住时需要从上下文重建完整语义”。在电子病历这种专业词汇密集、实体边界和词边界高度重合的场景这个改动非常关键。另一个使用理由是权重本身。HFL公开的chinese-bert-wwm-ext在医疗、法律等垂直领域的中文任务上微调效果通常优于原版BERT参数量没变直接替换就行。我自己的经验是在五千到两万句的病历数据上BERT-wwm微调比原版BERT平均高1.5个点左右。这个差距可能来自全词掩码带来的词边界感知也可能来自权重在通用中文语料上更充分的预训练两者叠加的结果。3.2 搭建BERT-wwm序列标注模型代码与损失设计序列标注任务只需要在BERT的输出之上加一个线性分类层下面是完整的PyTorch模型定义。# bert_wwm_ner.py import torch import torch.nn as nn from transformers import AutoModel, AutoTokenizer class BertWwmForToken(nn.Module): def __init__(self, model_name: str, num_labels: int): super().__init__() self.bert AutoModel.from_pretrained(model_name) self.dropout nn.Dropout(p0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) self.num_labels num_labels def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state sequence_output self.dropout(sequence_output) logits self.classifier(sequence_output) # (batch, seq_len, num_labels) loss None if labels is not None: loss_fn nn.CrossEntropyLoss(ignore_index-100) active_logits logits.view(-1, self.num_labels) active_labels labels.view(-1) loss loss_fn(active_logits, active_labels) return logits, lossmodel_name填chinese-bert-wwm-ext对应的transformers模型标识即可。两个设计细节值得说明一是ignore_index-100BERT的输入末尾有[PAD]PAD位置没有标签计算损失时必须忽略否则模型会强行学习PAD位置输出空实体拉低整体F1二是[CLS]和[SEP]两个特殊位置的标签在构造时直接填-100不参与分类它们的语义是聚合信息不是实体字。训练时用分层学习率BERT主体参数用小学习率分类头用大学习率。原因是预训练权重已经具备良好的文本表示能力不需要大幅更新而分类头是随机初始化的需要更快收敛。# train_loop.py from transformers import AdamW, get_linear_schedule_with_warmup def build_optimizer(model, total_steps): no_decay [bias, LayerNorm.weight] bert_param model.bert.named_parameters() head_param model.classifier.named_parameters() optimizer_grouped_parameters [ {params: [p for n, p in bert_param if not any(nd in n for nd in no_decay)], lr: 2e-5, weight_decay: 0.01}, {params: [p for n, p in bert_param if any(nd in n for nd in no_decay)], lr: 2e-5, weight_decay: 0.0}, {params: [p for n, p in head_param if not any(nd in n for nd in no_decay)], lr: 1e-4, weight_decay: 0.01}, {params: [p for n, p in head_param if any(nd in n for nd in no_decay)], lr: 1e-4, weight_decay: 0.0}, ] optimizer AdamW(optimizer_grouped_parameters) scheduler get_linear_schedule_with_warmup(optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps) return optimizer, scheduler这里把bias和LayerNorm的weight_decay去掉是BERT微调的标准做法能减少训练抖动。分类头学习率设到BERT主体的5倍目的是让任务头快速脱离随机初始化状态同时不干扰预训练表示。warmup步数取总步数的10%前10%的步数让学习率从0缓慢爬升可以明显降低训练初期的损失震荡。总步数由epoch乘batch数量算出。3.3 微调参数边界学习率、epoch、max_len怎么配电子病历NER微调我常用的一组参数是max_len128batch_size16learning_rate2e-5epoch5warmup_ratio0.1grad_clip5.0。但由于病历句子往往偏长max_len128时大约有15%的句子会被截断必须关注截断策略。默认截断是保留前128个字符但病历里的实体经常出现在句子后半段比如“建议完善胸部CT、腹部超声于介入科门诊复诊”里的检查项目全在尾部。直接从头截断会把大量阳性实体切掉。一个补救办法是训练时随机截断随机决定保留头段还是尾段让模型两种视角都见到。# tokenize.py def truncate_with_tail(text, max_len128): if len(text) max_len: return text head_len int(max_len * 0.8) return text[:head_len] text[-max_len head_len:]这个策略保留前80%和后20%文本兼顾模板化开头的稳定和实体常在尾部的事实。注意它会把句子中间截掉一段对于NER任务可接受因为实体不会跨越被截掉的位置。如果标注数据里确实有跨长句实体就得改用滑动窗口切分后处理再把窗口结果拼接。epoch方面病历数据量小到几千句时BERT微调3个epoch就能收敛5个epoch后F1可能开始波动。判断依据是验证集F1而不是训练集损失。训练集损失一直下降不能说明模型在变好验证集F1连续两个epoch不涨就早停这是避免在垂直领域小数据上过拟合最直接的手段。4. BiLSTM-CRF基线的正确打开方式字向量、CRF转移约束与训练节奏4.1 为什么还要搭一个不依赖预训练模型的基线有人会问已经有BERT-wwm了为什么还要做BiLSTM-CRF答案不在精度在落地约束。医院内网环境经常不让加载外部预训练权重或者GPU资源有限需要CPU推理还有些场景要求模型权重的训练路径完全可解释不能从一个黑匣子预训练模型上微调出来。BiLSTM-CRF参数少CPU上推理一篇出院小结只需要几十毫秒这是它作为基线的现实价值。更重要的是BiLSTM-CRF是多模型评估里的对照锚点。它能帮你判断BERT-wwm在你这批数据上到底赢了多少。如果两者F1差距只有1个点说明你的标注规范噪声大、实体边界混乱这时候与其调模型不如回头修数据。我见过不止一个项目把精力全花在BERT调参上最后发现是标注规范里“症状”和“检查”两个类型边界没写清楚基线模型也受害。4.2 词向量训练与BiLSTM-CRF模型搭建BiLSTM-CRF不依赖外部预训练权重但它很依赖词向量质量。我会用院内脱敏病历语料用gensim训练128维字向量。训练字向量时窗口设为5min_count设为2语料不足一万句时字向量维度降到64反而更稳。下面给出模型主体代码CRF层用torchcrf库实现这是序列标注常用的PyTorch扩展。# bilstm_crf.py import torch import torch.nn as nn from torchcrf import CRF class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, embed_size128, hidden_size256, num_labels13, num_layers2, dropout0.5): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.bilstm nn.LSTM(embed_size, hidden_size // 2, num_layersnum_layers, bidirectionalTrue, batch_firstTrue) self.dropout nn.Dropout(dropout) self.fc nn.Linear(hidden_size, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, mask, labelsNone): emb self.dropout(self.embedding(input_ids)) lstm_out, _ self.bilstm(emb, None) # 不手动初始化隐层交给PyTorch默认 lstm_out self.dropout(lstm_out) emissions self.fc(lstm_out) # (batch, seq_len, num_labels) if labels is not None: return -self.crf(emissions, mask, tagslabels) return self.crf.decode(emissions, mask)这里有一个关键细节mask必须把PAD位置置为False或0因为CRF的分数计算不能把PAD位置当作实体边界。self.fc的输出是发射分数CRF会在上面加一个可学习的转移矩阵模型输出的是整个序列的负对数似然。padding_idx0意味着字表里索引0固定是PAD字符不允许更新向量这能防止PAD向量在反向传播中被改得偏离原点。LSTM的隐层大小设为256双向后正好是128×2和线性层的输入对齐。4.3 CRF转移矩阵的限制模型到底学到了什么规则CRF层学到的转移矩阵是可以直接打印出来的num_labels × num_labels矩阵。训练收敛后你会看到一些硬性约束在数值上被强化B-xx后面接I-xx的概率极高接B-yy的概率极低O后面接I-xx的概率必须接近0因为实体内部标签不能跳变I-药物后面接B-检查要受罚因为同一段文本里不可能先放药物实体的一半再放另一个实体。这些约束对应的是标注规范里的常识。比如“B-药物”后面不可能直接跟“O”因为药物名至少有两个字单字药物直接用“B-药物”到“O”的跳转。但要注意CRF在训练数据不够时学到的是数据里的统计规律不是规范本身。如果你的标注里恰好有20条“术后”被标成单字时间实体转移矩阵会放大这种噪声。所以CRF模型效果不好时先查转移矩阵能反推标注污染点。4.4 BiLSTM-CRF的训练节奏epoch多、学习率小、早停BiLSTM-CRF不像BERT那样自带丰富的上下文表示训练节奏完全不同。学习率一般取1e-3量级优化器用Adam配合梯度裁剪。epoch需求在20到50之间远比BERT多因为它从随机初始化和字向量开始需要更多轮数拟合序列规律。训练时观察验证集F1每两个epoch记录一次。它在第10个epoch后开始明显上升第25个epoch左右到达平台期如果继续训练到40个epochF1可能小幅下降这是Crf层在过拟合训练集中的转移模式典型的信号是转移矩阵里O-I的惩罚被学得太极端导致解码结果大量输出单字实体。验证集F1开始下降时直接保存当前权重停止训练。BiLSTM-CRF没有warmup初期loss下降很快核心是控制学习率和早停不要照搬BERT的训练节奏。5. 多模型性能评估与原因排查测试集F1为什么会虚高5.1 严格实体级评估为什么token准确率没有意义序列标注任务里最常见的错误评估是计算token级别准确率。模型把某个实体预测对了一部分比如“冠状动脉支架植入术”输出成“冠状动脉”B-手术token准确率可能已经接近99%但实体级F1完全不是这个水平。真正的医疗场景是按完整实体消费的抽出来的手术名称短缺几个字病历质控系统就无法匹配标准术语库。所以评估必须按实体边界严格匹配预测span和真实span完全一致才判对。# evaluate_ner.py def spans_from_labels(labels, id2label): spans set() start -1 label_type None for i, label_id in enumerate(labels): label id2label[label_id] if label.startswith(B-): start, label_type i, label[2:] elif label.startswith(I-) and start ! -1: continue else: if start ! -1: spans.add((start, i, label_type)) start -1 label_type None if start ! -1: spans.add((start, len(labels), label_type)) return spans严格匹配下这个span集合里的开始索引、结束索引、实体类型三个维度全部对齐才算一个TP。逐个实体类型计算精确率、召回率、F1再算macro F1作为总指标。要注意的是有些文章把实体级F1和token F1混着报你复现时如果发现两者差异很大不要慌token F1虚高是正常现象汇报用实体级F1。5.2 常见问题排查训练或评估阶段翻车怎么定位下面这几条是我在不同项目里反复踩过的真实坑每一条都能直接套在你当前的表现异常上。现象BERT-wwm微调后F1反而比BiLSTM-CRF低3个点以上。原因训练数据只有两三千句预训练模型在这个量级上无法稳定学到病历术语分布而且标签噪声被BERT更强的记忆能力放大了。解决先看数据把标注一致率最低的“症状”和“检查”两个类型拎出来重标学习率降到1e-5epoch加到8用early stopping兜底。小数据上BERT未必赢这个结论不丢人。现象预测结果里出现大量“B-检查”后面紧跟“I-药物”的边界错误。原因检查项和药物在病历里经常连写比如“血常规、心电图、阿司匹林”标注员对逗号后的实体边界把握不一致CRF学到错误的转移模式。解决在转换脚本里强制检查这类跨类型跳转写了规则排除后重新训练同时给标注规范补一条逗号分割的两个实体必须分别标注不得合并。现象测试集F1高到90%以上上线后科室反馈说抽出来的字段没法用。原因按句子随机划分数据集同一患者的多句话出现在训练和测试两端模型在测试集上看到的是同一人的模板句。解决改用第2章的按病历ID划分并进一步按时间段划分用前半年的病历训练、后半年的病历测试模拟真实上线后的时间漂移。现象“术后第3天”整体被识别成时间实体。原因标注规范过度吸收修饰语把“术后”和具体天数捆在一起标。这种膨胀让训练数据里“术后”高频出现模型自然学歪。解决收紧时间实体边界只标“第3天”把“术后”作为普通文本规则后处理阶段再加一条遇到“术后第X天”统一修正。现象长度超过8个字的实体模型预测错误率明显升高。原因BERT微调时max_len截断了句尾实体或者CRF学到的高频转移模式偏向短实体对长实体内在一致性建模不足。解决先统计标注数据里的实体长度分布如果长实体数量确实少考虑把长实体按结构拆分比如“左肺上叶结节”拆成“左肺/上叶/结节”用组合规则去聚合而不是让模型一口吃下整个实体。5.3 错误分析把混淆矩阵打印出来再调参我习惯每个迭代版本都跑一次错误分析输出模型在六类实体上的F1混淆情况。核心看两个值混淆矩阵中A类实体被预测成B类的次数以及某个类型F1显著低于整体F1的原因。用一段简单的计数脚本就能定位不需要复杂工具。# error_analysis.py from collections import Counter def analyze_errors(gold_spans, pred_spans): errors Counter() for span in gold_spans: if span not in pred_spans: errors[(span[2], missed)] 1 for span in pred_spans: if span not in gold_spans: errors[(span[2], false)] 1 return errors.most_common(20)这个脚本帮你看出两件事哪个类型老是漏召回哪个类型老是误报。如果是“检查”类误报严重说明模型把“见上、如前”这类回指短语判成了检查如果是“药物”类漏报多半是剂量和药品名连写实体边界被模型砍掉。错误分析之后再去调标注规范或规则后处理比盲目调BERT学习率有效得多。6. 落地前最后一步用小规则后处理修复实体边界模型输出不是终点临床科室要的是干净字段。我见过F1已经做到88%的系统实际用起来还是被嫌弃因为抽取结果里实体边界带着多余的修饰词。最典型的例子模型把“左肺上叶结节”切成“左肺”和“上叶结节”在准确率指标上判错但医生看到输出时第一反应是“这个系统连肺叶都分不清”。规则后处理的意义不在于堆准确率在于让输出符合科室的使用习惯。这个后处理我设计成一个小pipeline不改变模型权重只修正两类高频边界问题一是数字和单位之间的粘连病历里“500mg”经常被切成“500”和“mg”两个部分二是方位词与身体部位实体的合并“左肺上叶”应该合并成一个完整解剖位置。# postprocess.py import re DOSE_UNIT re.compile(r(\d(?:\.\d)?)\s*(ml|mg|g|l|u|万单位|单位), re.I) GEO_PATTERN re.compile(r(左|右)?(肺|肝|肾|脑|胃|结肠)(上|下|左|右)?(叶|段|缘|角|动脉)?) def fix_dose_boundary(text, spans): fixed [] for start, end, etype in spans: chunk text[start:end] m DOSE_UNIT.search(chunk) if m and 药物 in etype: new_end text.find(m.group(2), start) len(m.group(2)) fixed.append((start, new_end, etype)) else: fixed.append((start, end, etype)) return fixed这段代码做了一个假设如果药物实体内部出现了剂量数字把单位也并入实体。现实中这是科室明确提出的需求他们不希望“500mg”被拆成“500”和“mg”两段因为用药剂量是独立的关注点。单位归一化放在后面把“ml、毫升、cc”统一成“ml”方便科研统计。另一个经验是做好后处理之后建议把规则和模型输出分开给科室看。他们需要知道哪些字段是模型直接给出的、哪些是规则修正的这直接关系到临床人员是否信任这套系统。我第一次交付的时候省掉了这一层结果科室对“左肺上叶”这类边界问题非常不满指标再好也没用。后来把模型输出和规则修正做成两个可导出的列科室自己也能校验系统才算真正被接受。希望这个边界处理的习惯能帮你在现场少走一段弯路。本文还有配套的精品资源点击获取
返回列表