ARTICLE DETAIL

资讯详情

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

中文电子病历NER实战:BERT-wwm + BiLSTM-CRF完整复现指南

中文电子病历NER实战:BERT-wwm + BiLSTM-CRF完整复现指南 简介一套面向中文电子病历命名实体识别NER的深度学习实验系统基于CCKS2019评测数据构建专注解决医疗文本中疾病、临床表现、治疗方案等医学实体的自动抽取问题适合医疗NLP研究者、算法工程师以及相关方向学生进行模型对比与复现。压缩包共44个文件以26个Python脚本为主体覆盖数据预处理、预训练、模型定义、训练评估及结果可视化等环节并辅以yml配置、SQLite数据库、README说明与若干备份文件整体仅41KB属于轻量级代码工程。目前已有54人浏览学习。系统实现了BERT-wwm预训练语言模型与BiLSTM-CRF序列标注框架同时内置CNN、RNN等经典基线可通过configs目录下的配置文件灵活切换模型和参数便于在统一标准下系统评估不同架构在中文电子病历上的识别性能。附带的训练、评估、绘图脚本和项目说明为复现竞赛任务、构建基准实验或开展课程设计提供了完整可操作的参考。1. 中文电子病历命名实体识别为什么 BERT-wwm 配 BiLSTM-CRF 值得复现处理过医疗文本的人应该都有同一个感受病历里全是缩写和倒装句式“患者三天前无明显诱因出现胸闷、心悸”这种句子里实体边界模糊、术语密度高。直接套通用 NER 模型召回率撑死 60%纯规则词典匹配需要持续维护词表换个科室就失灵。这份资源给了一条从标注、训练到评估的完整链路核心是三件事用 BERT-wwm 做语义编码、用 BiLSTM-CRF 做序列解码、用多模型对比量化每一层模块的真实收益。适合个人学习医学 NLP 的从业者、做课程设计的同学以及想评估电子病历信息抽取方案可行性的工程师。按文中流程整理几百条病历即可启动复现。2. 数据与标注把病历文本切成 BIO 序列2.1 实体类型定义与标注规范中文电子病历里最常见的实体类型是症状、体征、疾病、药物、检查、治疗六类。实体数量不宜定义过多类别越多标注一致性越差最终会拖累模型 F1。课程设计和入门项目里定义 46 类实体是普遍做法超过 8 类时标注边界会迅速失控。标注规范采用 BIO 体系B-xxx 表示实体开始I-xxx 表示实体中间或结尾O 表示非实体。这里刻意不用 BIESO因为 BIO 的标签转移关系对 CRF 已经足够。每类实体对应两个标签比如 B-Symptom / I-Symptom标签总数是 2 类实体数加 1 个 O。电子病历场景有一个关键约定实体边界从症状或疾病名称的本体开始不包含修饰性副词和否定词。例如“无明显诱因出现胸闷”只标注“胸闷”为 B-Symptom、I-Symptom“无明显诱因”保留为 O。这个约定的作用会在 5.1 节体现——边界规则不一致是 F1 卡在 0.8 附近的头号元凶。实体类型示例标签前缀症状胸闷、心悸、乏力B-Symptom / I-Symptom体征体温 38.5℃、血压偏高B-Sign / I-Sign疾病慢性阻塞性肺疾病B-Disease / I-Disease药物阿司匹林、硝酸甘油B-Drug / I-Drug检查心电图、胸部 CTB-Exam / I-Exam治疗溶栓、支架植入B-Treatment / I-Treatment数据组织上推荐先整理成 JSON 格式每条样本包含原始文本和实体列表。实体列表中的 start 和 end 是字符级索引不是分词后的 token 索引。中文按单字切分标点符号也占一个字符位这一点在从标注工具导出数据时最容易出错。2.2 从 JSON 标注转 BIO 序列的脚本拿到一批标注好的 JSON 数据之后需要把它转成 token 序列和标签序列才能喂给模型。写一个转换函数def text_to_bio(text, entities): tags [O] * len(text) for ent in entities: start ent[start] end ent[end] label ent[label] if start 0 or end len(text) or start end: print(f跳过非法实体: {ent}, text: {text}) continue tags[start] B- label for i in range(start 1, end): tags[i] I- label return list(text), tags逻辑不复杂先把每个字符位初始化为 O然后遍历实体列表在起始位置打 B-在中间位置连续打 I-。这里有两个细节值得注意。第一start 和 end 遵循 Python 切片习惯end 是开区间比如文本“出现胸闷”里的“胸闷”索引是 [2, 4)。第二校验逻辑必须保留手工标注难免出现越界或 end 小于 start 的脏数据。转换完成后要做一次全角半角归一化和中英文标点统一。病历文本里经常混着全角逗号、半角括号、不间断空格BERT tokenizer 对全角半角的处理方式不同归一化能减少无意义的字符碎片。我一般先用unicodedata.normalize把全角字符转成半角再把空白字符压缩成单个空格——不是删掉因为空格本身会被 tokenizer 编码。2.3 数据集划分按患者 ID 而不是按句子随机切电子病历数据有一个天然陷阱同一个患者的多条病程记录会出现高度相似的句子。如果直接按句子随机切分训练集和验证集同一患者的一部分句子落在训练集、另一部分落在验证集模型相当于提前看到了答案验证集 F1 会虚高 3 到 5 个点。这是一条血泪经验。做法是给每条记录打上 patient_id按 patient_id 做分组划分import random records load_all_records() patient_ids list({r[patient_id] for r in records}) random.seed(42) random.shuffle(patient_ids) split_point int(len(patient_ids) * 0.8) train_ids set(patient_ids[:split_point]) valid_ids set(patient_ids[split_point:]) train_data [r for r in records if r[patient_id] in train_ids] valid_data [r for r in records if r[patient_id] in valid_ids]样本量较小时可以用 K 折交叉验证但同样要按 patient_id 分组。固定随机种子同样重要——两次训练因为划分顺序不同导致 F1 相差两个点排查了很久才发现是数据集的锅。在数据集没有 patient_id 字段时退而求其次用住院号或身份证号做分组键如果实在没有唯一标识则按文本去重后合并相似记录再划分。3. BERT-wwm 微调用预训练模型换领域特征3.1 为什么选 BERT-wwm 而不是 BERT-baseBERT-wwm 的全称是 BERT with Whole Word Masking即全词掩码预训练。BERT-base 在预训练时随机遮盖单个字比如“肺栓塞”可能只遮盖“栓”模型只需要从上下文猜这一个字学到的更多是局部字符共现。BERT-wwm 会把“肺栓塞”整个词的所有字符同时遮盖模型必须依据上下文推断整个词。这种预训练目标让中文词语边界和专有名词的内部语义表征更完整。电子病历场景恰恰是专业术语密集区。“患者既往有糖尿病史今日突发胸骨后压榨性疼痛”这句话里“胸骨后压榨性疼痛”是一个完整体征描述包含位置修饰和性质修饰。BERT-wwm 在通用中文语料上学习到的词级语义能让模型把它作为一个整体语义单元来理解。实际对比实验中BERT-wwm 相对 BERT-base 在电子病历 NER 上的 F1 通常能高出 1 到 2 个百分点。资源里使用的是 hfl/chinese-bert-wwm-ext 这个 checkpoint它是在更大规模中文语料上继续预训练的版本。如果显存有限可以退而用 ALBERT 之类的小模型但 NER 精度会明显下降个人学习场景下不建议为了省显存牺牲效果——电子病历实体抽取的边界判断非常依赖深层语义。3.2 微调参数配置与 tokenizer 对齐给出一套经过验证的参数组合参数建议值说明max_seq_len128 或 256短门诊记录用 128长病程记录用 256batch_size16 或 812G 显存下 max_seq_len256 时建议降到 8learning_rate5e-5BERT 微调常用范围 2e-5 ~ 5e-5epochs3 ~ 5数据量不足一千条时 3 轮即可warmup_ratio0.1前 10% 训练步数线性预热加载模型和 tokenizer 的代码from transformers import BertForTokenClassification, BertTokenizer tokenizer BertTokenizer.from_pretrained(hfl/chinese-bert-wwm-ext) model BertForTokenClassification.from_pretrained( hfl/chinese-bert-wwm-ext, num_labelslen(label2id) )label2id 是把 BIO 标签映射成整数例如 {O: 0, B-Symptom: 1, I-Symptom: 2}。这里有一个多数人都会踩的坑BERT tokenizer 对中文汉字基本保持单字 token但遇到字典外字符会拆成 unknowntoken 序列长度和标签序列长度不一致。需要在数据预处理阶段把标签序列对齐到 token 序列padding 位置设为 -100这个值会被 CrossEntropyLoss 的 ignore_index 自动忽略。def align_labels_with_tokens(labels, word_ids): aligned [] prev_word_idx None for word_idx in word_ids: if word_idx is None: aligned.append(-100) elif word_idx ! prev_word_idx: aligned.append(labels[word_idx]) else: aligned.append(-100) prev_word_idx word_idx return aligned这段代码的逻辑是对每个 token 找到它对应的原始字符位置只保留第一个子词的标签其余子词位置设为 -100让损失函数跳过它们。3.3 训练循环与损失函数训练主循环可以用 transformers 的 Trainer也可以手写。手写的好处是中间层输出可以直接接 BiLSTM-CRF。给出核心训练片段from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr5e-5, weight_decay0.01) total_steps len(train_dataloader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) criterion torch.nn.CrossEntropyLoss(ignore_index-100) for epoch in range(epochs): for batch in train_dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) outputs model(input_ids, attention_maskattention_mask) logits outputs.logits # (batch, seq_len, num_labels) loss criterion(logits.view(-1, num_labels), labels.view(-1)) loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()注意这里的 logits 展开方式(batch, seq_len, num_labels)展平成(batch * seq_len, num_labels)labels 同步展平。weight_decay0.01 用来抑制过拟合病历数据量只有几千条时特别重要。warmup 的作用是让 BERT 在训练初期用较小的学习率避免破坏预训练权重。3.4 从 BERT 隐层到 BiLSTM 的衔接BERT 的最终隐层输出维度是(batch_size, seq_len, 768)。直接接 CRF 也能工作但 BiLSTM 可以在 BERT 之上继续编码相邻 token 的上下文依赖。BiLSTM 的 hidden_size 通常设 256双向拼接后变成 512 维再经过线性层映射到 num_labelsclass BertBiLSTMCRF(nn.Module): def __init__(self, bert_model, hidden_size, num_labels): super().__init__() self.bert bert_model self.bilstm nn.LSTM( input_size768, hidden_sizehidden_size, num_layers1, bidirectionalTrue, batch_firstTrue ) self.dropout nn.Dropout(0.1) self.fc nn.Linear(hidden_size * 2, num_labels) def forward(self, input_ids, attention_mask): outputs self.bert(input_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state lstm_out, _ self.bilstm(sequence_output) lstm_out self.dropout(lstm_out) logits self.fc(lstm_out) return logitsdropout 放在 BiLSTM 输出之后、线性层之前这是影响 CRF 收敛的关键位置。不加 dropoutCRF 的转移矩阵容易过拟合到训练集的标签共现模式dropout 加到 0.2 以上又会削弱序列特征的连续性0.1 是经验值。4. BiLSTM-CRF 解码序列标注的最后一公里4.1 为什么在 BERT 之上还要叠 BiLSTM-CRFBERT-wwm 已经在每个字符位产出了高质量语义表征但直接对每个位置的 logits 做 argmax会出现一个严重问题预测结果里出现“B-Symptom 后直接接 B-Symptom”或“I-Symptom 前面没有 B-Symptom”的非法序列。原因在于 token 级分类目标没有显式建模相邻标签之间的依赖关系。CRF 层学习的是一个标签转移矩阵矩阵元素表示从标签 i 转移到标签 j 的得分。推理时用维特比算法求全局最优路径。训练语料中如果从未出现过 “O → I-Symptom”CRF 会把该转移得分压得非常低推理时几乎不会输出这种非法序列。BiLSTM 负责编码上下文CRF 负责解码约束分工明确。一个直观经验是直接在 BERT-wwm 输出上接 CRF 已经能拿到不错的结果加 BiLSTM 后的 F1 提升大概 0.51 个点收益不算大。但在训练数据不足时BiLSTM 对短文本和少数类的鲁棒性更好整套结构也更接近论文的基准配置。个人学习场景推荐保留因为跑通一次完整结构比省一个点的训练时间更有价值。4.2 CRF 层与维特比解码标准 CRF 解码可以拆成两部分发射分数和转移分数。发射分数由 BiLSTM 输出的 logits 提供表示每个 token 属于每个标签的原始得分转移分数由 CRF 层内部的 transition 矩阵提供。训练时 CRF 的损失是真实标签序列在所有可能序列中的负对数似然推理时用维特比算法动态规划求解得分最高的标签路径。torchcrf 库封装好了这些逻辑常见用法如下from torchcrf import CRF class NERModel(nn.Module): def __init__(self, bert_model, hidden_size, num_labels): super().__init__() self.bert bert_model self.bilstm nn.LSTM(768, hidden_size, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(hidden_size * 2, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_mask) seq_out outputs.last_hidden_state lstm_out, _ self.bilstm(seq_out) logits self.fc(lstm_out) if labels is not None: mask attention_mask.bool() loss -self.crf(logits, labels, maskmask) return loss decoded self.crf.decode(logits, maskattention_mask.bool()) return decodedmask 参数是 CRF 最容易出错的地方。计算转移代价时必须跳过 padding token、CLS 和 SEP否则这些位置会被当成真实标签参与转移矩阵更新解码结果直接错位。attention_mask 转成 bool 之后传给 CRF 的 mask 参数CLS 和 SEP 位置在 attention_mask 里是 1所以还需要额外把这两个位置的 mask 改成 False。维特比解码返回一个 list of list内层是每个 token 的预测标签索引。后续提取实体时要跳过 CLS 和 SEP 对应的位置这一步经常被忽略。4.3 从标签序列提取实体 span模型输出的标签序列不能直接交付还需要还原成实体文本。写一个提取函数def extract_entities_from_tags(tag_list, tokens, idx2label): entities [] cur_start None cur_label None for i, tag_idx in enumerate(tag_list): tag idx2label[tag_idx] if tag.startswith(B-): if cur_start is not None: entities.append({text: .join(tokens[cur_start:i]), label: cur_label, start: cur_start, end: i}) cur_start, cur_label i, tag[2:] elif tag.startswith(I-) and cur_start is not None: if tag[2:] ! cur_label: entities.append({text: .join(tokens[cur_start:i]), label: cur_label, start: cur_start, end: i}) cur_start, cur_label i, tag[2:] else: if cur_start is not None: entities.append({text: .join(tokens[cur_start:i]), label: cur_label, start: cur_start, end: i}) cur_start None if cur_start is not None: entities.append({text: .join(tokens[cur_start:len(tag_list)]), label: cur_label, start: cur_start, end: len(tag_list)}) return entities核心逻辑是扫描标签序列遇到 B- 时开始新实体遇到连续 I- 时扩展当前实体遇到 O 时结束当前实体。代码额外处理了“B-Symptom 后直接接 I-Drug”这种跨界序列——虽然 CRF 已经大幅抑制了这种情况但一旦发生就强制截断前一个实体并开启新实体避免一个实体内部混入另一个类型的 token。提取完成后要过滤纯符号实体、空串实体以及文本长度小于 2 的碎片。评估阶段尤其要做这个过滤因为它能把一小部分噪声拉低 F1 的问题提前挡住。5. 避坑指南五个最容易翻车的环节5.1 标注边界不一致导致 F1 卡在 0.8 附近现象训练时 loss 正常下降但验证集 F1 在 0.780.82 之间反复横跳调参也上不去。原因标注规范对实体边界定义不严格。例如“患者无明显诱因出现胸闷”有的标注员把“无明显诱因出现”也划进实体前缀有的只标“胸闷”。同一句式在训练集中出现两种标签序列CRF 只能学一个折中的转移矩阵。解决预处理阶段按“症状名词本体不包含修饰词”的规则自动裁剪实体边界然后随机抽 50 条验证集样本人工核对。边界规范化脚本比重新标注的成本低得多做完这两步 F1 往往立刻提升 12 个点。5.2 长病历截断导致尾部实体全部丢失现象测试集中较长的病程记录结尾处实体大面积未被识别。原因max_seq_len128 时超过 128 个字符的句子被直接截断尾部实体自然消失。更隐蔽的是实体从第 127 个字符开始、在第 129 个字符结束被拦腰截断后模型只看到一个不完整的半实体。解决先统计训练集文本长度分布再确定 max_seq_len平均值在 150 左右就直接用 256。另一种常见做法是按句号、分号把长文本拆成短句拆句时保证句内实体完整拆完后每个 chunk 单独进模型。5.3 GPU 显存溢出现象程序跑几个 batch 就 OOM或者训练中途显存爆炸。原因max_seq_len256 加 batch_size32BERT-wwm 需要约 20G 显存12G 显卡必然爆。另一个隐藏因素是把原始文本字符串装进了 batch字符串在 CPU 侧占用显存缓冲。解决batch_size 降到 8max_seq_len 降到 192或者把 BiLSTM 的 hidden_size 降为 128。还可以用梯度累积模拟大 batchaccumulation_steps 4 loss loss / accumulation_steps loss.backward() if global_step % accumulation_steps 0: optimizer.step() optimizer.zero_grad()注意 loss 除以 accumulation_steps 是为了保持等效学习率不变梯度累积之后 optimizer.step() 的频率变成原来的四分之一。5.4 CRF 转移矩阵不收敛现象验证集 F1 在 0.3 到 0.8 之间剧烈震荡每次运行结果差异很大。原因常见的是 BERT 微调学习率过大。5e-5 对 BERT 合适但对 CRF 转移矩阵来说步幅偏大导致转移得分后期来回振荡。另一个原因是 CRF 层前没有 dropout对训练集标签共现模式过拟合。解决把 dropout 设为 0.1并把 CRF 层学习率单独调低optimizer AdamW([ {params: bert_parameters, lr: 5e-5}, {params: crf_parameters, lr: 5e-6} ], weight_decay0.01)检查是否收敛的方法很直观打印预测序列如果某个批次连续输出多个相同标签且与真实分布明显不符说明转移矩阵还没稳定。5.5 数据泄漏导致指标虚高现象验证集 F1 异常高超过 0.95换到新数据上效果明显变差。原因按句子随机划分数据集导致同一患者的记录同时出现在训练集和验证集。模型在训练时见过这个人验证等于开卷考试。解决严格按 patient_id 分组划分。没有 patient_id 时用住院号或身份证号做分组键。我在划分数据前都会检查训练集和验证集的 patient_id 是否重叠这一步已经变成流程里的固定动作。6. 多模型性能评估怎么量化 BERT-wwm BiLSTM-CRF 的真实收益6.1 评估指标与对比表NER 评估不能只看 token 级准确率——O 类占比超过 90%模型全部预测 O 也能拿到 90% 以上的准确率。正确做法是实体级别评估precision、recall、F1 都要求预测实体的文本和标签与真实实体完全一致才算对边界差一个字也算错。资源里附带的多模型评估脚本会输出如下对比表模型PrecisionRecallF1BiLSTM-CRF随机初始化字向量0.7950.7620.778BERT-wwm CRF0.8680.8550.861BERT-wwm BiLSTM CRF0.8810.8690.875以上是以公开中文电子病历数据为基准的参考值实际数据分布下会有浮动。两个规律大概率不变一是 BERT-wwm 相对随机字向量的提升在 810 个点二是 BiLSTM 在 BERT 基础上的增量在 1 个点左右。数据量只有几百条时BiLSTM 的增量可能不明显但 CRF 层仍然必不可少。6.2 验证方法用预测分布反查标注质量训练完成别急着收工先做一轮“预测-抽查-修正”的反向验证。取 100 条验证集样本跑完整预测把预测实体和标注实体的差异可视化输出重点看三类样本模型预测为空但标注有实体的漏召回实体文本重叠但边界错位的漂移以及标签类型错误的误分类。这三类样本的比例直接反映标注质量。漏召回多说明训练数据覆盖不足边界错位多说明标注规范执行不一致类型错误多说明实体类型定义本身存在歧义。处理完数据再重新训练效果通常比直接调参数更扎实。我吃过一次大亏当初跳过这一步用一份表面干净的标注数据训练验证集 F1 到 0.86部署到新病历上直接掉到 0.7逐个排查才发现是“症状”和“体征”的界定本身就模糊。从那以后我每次跑完训练都强制走一遍预测-抽查-再标注的循环宁可多花半天修正数据也不去调那些说不清规律的玄学参数。希望这份资源和这篇拆解能让你少走这一步弯路。本文还有配套的精品资源点击获取
返回列表