ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

BERT-Clinical-NER源码解析:电子病历命名实体识别与CRF落地实践

BERT-Clinical-NER源码解析:电子病历命名实体识别与CRF落地实践 简介一份面向电子病历信息抽取场景的基于BERT的命名实体识别项目源码适合自然语言处理研究者、医疗信息化开发者及相关专业学生参考学习。资源共38个文件压缩包仅395KB以21个Python源码文件为核心覆盖数据预处理、模型定义、训练、预测和评估全流程另含6个文本说明、2个Markdown文档、3个XML配置、Git忽略文件及Jupyter Notebook示例目录结构清晰便于阅读与二次开发。已有320人学习下载。项目基于BERT预训练模型专门识别疾病名称、药物名称、治疗手段、医疗设备等关键实体代码中model.py、utils.py、data_utils.py、loader.py、train.py、predict.py等模块分工明确并附带readme.txt使用说明和requirements.txt依赖清单可帮助读者快速搭建运行环境理解医疗NER任务从数据准备到模型推理的完整链路适合作为医疗AI领域入门与实践的参考资料无论是学习研究还是实际项目交付都能提供有力支撑。1. 临床文本里的“实体检索”这套 BERT-Clinical-NER 源码如何落地刚接触医疗 NLP 的工程师大多会有个错觉电子病历命名实体识别Clinical NER跟新闻领域的 NER 差不多无非是把人名地名换成疾病药物。真上手做一版才会发现电子病历里到处都是“初步诊断肺部感染待查”“否认高血压、糖尿病史”“术后恢复良好”这类夹杂否定前缀、模糊表述和大量医学缩写的句子通用 NER 模型在这上面基本是“开盲盒”——F1 值能差出十几个点。这篇笔记要拆的源码包是一套完整的 BERT-Clinical-NER 项目核心链路是 BERT 预训练模型做文本编码、CRF 层做序列解码覆盖了数据预处理、模型定义、训练、预测到 conlleval 评估的全流程。它更适合两类人一是要用电子病历 / 检查报告做信息抽取的 NLP 工程师二是想拿 NER 练手、但不想从零搭 BERT 微调工程的算法学习者。项目源码一共 39 个文件核心 Python 文件 21 个监督数据、配置、评估脚本都齐下面从工程骨架开始逐层拆。2. 源码地形图这 39 个文件各自在做什么2.1 目录结构与模块职责拿到压缩包先别急着跑训练把文件按职责分个类。整个项目的顶层结构大致如下BERT-Clinical-NER/ ├── bert/ # 官方 BERT 基础库 │ ├── modeling.py # Transformer 编码器结构 │ ├── tokenization.py # WordPiece 分词器 │ ├── optimization.py # AdamW warmup 优化器 │ └── run_classifier.py # 官方分类微调参考可作模板 ├── model.py # 自定义 NER 模型BERT 序列标注头 ├── data_utils.py # 数据读取与 BIO 标签映射 ├── loader.py # DataLoader / TFRecord 批次构建 ├── train.py # 训练入口含超参数配置 ├── predict.py # 推理预测脚本 ├── conlleval.py # NER 标准评估工具转换自 conlleval.pl ├── utils.py # 通用工具 ├── rnncell.py # 可能用到的 RNN 单元实现 ├── data/ │ ├── train.txt # 训练集BIO 标注格式 │ ├── dev.txt # 验证集 │ └── test.txt # 测试集 └── requirements.txt # 依赖清单从上往下看这个工程的数据流并不复杂data/*.txt是标注语料data_utils.py把原始文本转成 BERT 能吃的 input_ids / input_mask / segment_idsloader.py负责按 batch 喂给model.py训练过程由train.py驱动验证和测试阶段用conlleval.py计算评价指标。2.2 模型选型为什么不是单纯的 BERT Softmax很多开源的 NER 项目在图省事直接用 BERT 输出层的每个 token 向量过一层 softmax 做标签分类。这个项目在model.py里加入 CRF 层这一设计的工程价值很大。CRF 层的作用是显式建模相邻标签之间的转移约束比如“I-药物”前面必须是“B-药物”或“I-药物”而不太可能直接跟在“O”后面。对电子病历这种实体边界本身就模糊的文本CRF 带来的约束能显著减少预测出来的“实体碎片”。代码实现大致是# model.py 中的核心片段简化 from bert import modeling import tensorflow as tf class BertClinicalNer: def __init__(self, config, num_labels): self.bert modeling.BertModel(configconfig) self.num_labels num_labels def forward(self, input_ids, input_mask, segment_ids): _, pooled self.bert( input_idsinput_ids, input_maskinput_mask, token_type_idssegment_ids, return_pooled_outputTrue) # 取每个 token 的最后一层隐层输出 output_layer self.bert.get_sequence_output() hidden_size output_layer.shape[-1] # 线性层映射到标签空间 with tf.variable_scope(ner_output): logits tf.layers.dense(output_layer, self.num_labels) # 这一步通常交给 CRF 解码而不是直接 argmax return logits这里logits的 shape 是[batch_size, seq_length, num_labels]每个 token 都有独立的标签置信度。后续要么用 CRF 动态规划求最优路径要么退而求其次用 softmax 加argmax——两者在简单句子上差异不大但遇到“患者因冠心病入院否认高血压”这种嵌套否定、实体密集的句子时CRF 的全局最优路径明显更稳。2.3 预处理链从 BIO 标注到 BERT 输入data_utils.py的核心任务是把train.txt里形如下面格式的文本转成input_ids序列患 O 者 O 因 B-疾病 冠 I-疾病 心 I-疾病 病 I-疾病 入 O 院 OBERT 的 tokenizer 先把每个字转成词表 id再在句首插入[CLS]、句尾插入[SEP]。这里有个深坑中文 BERT 的 WordPiece 切分基本是单字切分所以字符级 BIO 标注比较容易对齐一旦实体中包含英文或数字如“T3N1M0 分期”WordPiece 会把一个词切成多个 subwordlabel 就必须跟着 subword 重复对齐。数据清洗时我会先把实体内空格压缩成下划线避免实体中间意外断开。3. 数据准备阶段构造电子病历专用的 BIO 训练集3.1 标注格式约定与文件清单这套源码自带的训练数据是字符级别 BIO 标签实体类别包括疾病、药物、检查、手术等。数据结构虽然简单但有几个容易踩的隐性约定文件必须是无 BOM 的 UTF-8不能有\r结尾。每行一个字符 一个标签中间用空格或制表符隔开。空行表示句子边界conlleval.py按空行切分句子句子不能跨文件合并。标签的B-表示实体起始I-表示实体内部O表示非实体。如果需要自定义实体类别直接改data_utils.py里的label2id映射# data_utils.py 中的标签定义 label2id { O: 0, B-疾病: 1, I-疾病: 2, B-药物: 3, I-药物: 4, B-检查: 5, I-检查: 6, B-手术: 7, I-手术: 8 }这里id必须从 0 连续递增CRF 层的标签转移矩阵大小是num_labels * num_labels如果 id 有空洞矩阵索引会越界。另外我一般建议加两个特殊标签[CLS]和[SEP]对应的位置在计算 loss 时用input_mask屏蔽掉不参与训练。3.2 数据增强与样本切割策略公开的医疗标注语料很少电子病历数据更是不好找。如果手里只有几百条标注数据我通常会做两步处理。第一步是实体替换把“冠心病”换成“心肌梗死”把“阿司匹林”换成“氯吡格雷”这类同义替换不改变 BIO 结构能增加实体形态多样性。第二步是句级截断BERT 最大序列长度限制是 512 个 token电子病历里的现病史经常一大段是一个“句子”按句号、分号切分后再丢进训练集既避免了截断导致的实体断裂也提高了 batch 内的训练效率。切分时要注意保持 BIO 标注同步更新# 按标点切分长句并保持标签对齐 import re def split_sentence_with_labels(tokens, labels, max_len126): 返回多个片段每个片段不超过 max_len segments, cur_t, cur_l [], [], [] for token, label in zip(tokens, labels): cur_t.append(token) cur_l.append(label) if token in 。; or len(cur_t) max_len: segments.append((cur_t, cur_l)) cur_t, cur_l [], [] if cur_t: segments.append((cur_t, cur_l)) return segments切分逻辑不复杂但特别容易出问题实体中间如果碰巧有标点比如“左肺占位”切开会直接把实体截断标注就废了。稳妥做法是切分时判断当前 token 的 label 是B-还是I-如果是I-且前一个 token 不在当前片段里就往前顺延一个 token保证实体完整性。3.3 loader 实现与训练批次构建loader.py做的事情是把数据转成tf.data.Dataset并做 padding、shuffle、repeat。BERT 输入要求每个 batch 内序列长度一致多余部分补 0并靠attention_mask标记有效位置。以下是一个常见实现# loader.py 中构建数据集的逻辑 def input_fn(input_file, max_seq_length, batch_size, is_training): dataset tf.data.Dataset.from_generator( lambda: read_ner_examples(input_file), output_types(tf.int32, tf.int32, tf.int32, tf.int32), output_shapes((None,), (None,), (None,), (None,))) if is_training: dataset dataset.shuffle(1000).repeat() dataset dataset.map( lambda input_ids, input_mask, segment_ids, labels: (input_ids, input_mask, segment_ids, labels), num_parallel_calls8) dataset dataset.padded_batch( batch_size, padded_shapes([max_seq_length], [max_seq_length], [max_seq_length], [max_seq_length]), drop_remainderTrue) return dataset这里有个参数值得多说一句drop_remainderTrue只在训练时开如果测试集最后一批不足 batch_size 会被丢弃导致评估结果不完整。到了predict.py推理阶段要把这个参数改成False或者用padded_batch配合动态 pad否则最后的短文本全部预测不了。4. 训练与预测核心参数调优和模型存取4.1 超参数配置先从医疗场景标杆值起步train.py里有一堆可调参数新手最容易犯的错是照搬官方 BERT 的默认值。官方预训练用 128 万的 batch、学习率 1e-4 没问题但下游 NER 微调的学习率要从 2e-5 到 5e-5 起调。医疗场景训练集通常只有几千条我用下来比较稳的起步配置如下参数推荐值说明learning_rate2e-5 ~ 3e-5太大 CRF 容易不收敛太小收敛缓慢train_batch_size16显存 11G 以下用 8num_train_epochs3 ~ 5数据量小可加大 epoch配合 early stoppingmax_seq_length128 ~ 256电子病历句子普遍偏长建议 256warmup_proportion0.1前 10% 步数线性预热save_checkpoints_steps500便于止损回滚训练启动命令参考python train.py \ --data_dir./data \ --bert_config_file./bert/bert_config.json \ --init_checkpoint./bert_model/bert_model.ckpt \ --vocab_file./bert_model/vocab.txt \ --output_dir./output \ --do_trainTrue \ --do_evalTrue \ --max_seq_length256 \ --train_batch_size16 \ --learning_rate3e-5 \ --num_train_epochs4特别注意init_checkpoint指向的是预训练权重BERT 官方下载的chinese_L-12_H-768_A-12压缩包解压后有三个核心文件bert_model.ckpt.data-00000-of-00001、bert_model.ckpt.index、bert_model.ckpt.meta。路径必须写到.ckpt前缀TensorFlow 会自动找对应分片。4.2 训练过程可视化与止损训练日志里重点看两个值loss和eval_f1。如果 loss 在 3 个 epoch 后仍在 1.0 以上高位震荡大概率是标签对齐出了问题或者学习率偏大。我第一次跑这套源码时就遇到 loss 降不下去后来排查发现是data_utils.py里把input_mask当成了attention_mask之外的东西——BERT 的input_ids如果 padding 到 256mask 必须是相同的 256 长度我这边 mask 长度不一致导致 attention 算到了 padding 区域相当于模型被迫关注空白 token。训练过程中的 checkpoint 会自动存到output_dir包含模型权重和优化器状态。如果想从断点续训把init_checkpoint指向上一次保存的 checkpoint并保持max_seq_length一致。如果改了序列长度优化器状态和位置编码维度对不上权重加载会直接报错。4.3 推理脚本改造从一次一条到批量预测predict.py默认做法是读取一条文本输出实体列表。实际生产里通常要批量处理整份病历我改造时加了一个--input_file参数批量读入# predict.py 批量推理示例 def batch_predict(texts, model, tokenizer, label_map): results [] for text in texts: tokens tokenizer.tokenize(text) input_ids tokenizer.convert_tokens_to_ids([[CLS]] tokens [[SEP]]) segment_ids [0] * len(input_ids) input_mask [1] * len(input_ids) pred_ids model.predict([input_ids], [input_mask], [segment_ids]) entities decode_entities(tokens, pred_ids[0], label_map) results.append(entities) return results这里decode_entities要做两个转换一是把 CRF 解码出的标签 id 映射回标签名二是把[CLS]和[SEP]位置的输出丢弃。这个细节很关键——如果不丢弃句首[CLS]可能会被标记为实体起始导致所有句子第一个实体前面多一个“幽灵实体”。5. 避坑手册临床 NER 最常见的翻车现场5.1 预训练权重缺失导致启动即崩溃现象运行train.py报错NotFoundError: Key bert/embeddings/word_embeddings not found in checkpoint。原因源码目录里没有 BERT 预训练权重下载地址在readme.txt中标注这是开源项目常见做法——代码归代码、权重归权重权重文件动辄三四百兆不会直接打进 zip。解决从官方渠道下载chinese_L-12_H-768_A-12模型包解压后把三个.ckpt*文件放到bert_model/目录vocab.txt必须与预训练权重匹配不能用其他版本的词表替代。5.2 CRF 解码输出全为 O 标签现象训练正常loss 也在下降但 dev 集 F1 为 0预测结果全部是O。原因大概率是数据里实体标签占比太低模型把所有 token 都判为非实体反而让 loss 最小。事件概率很低但我确实遇到过。解决先统计训练集中B-和I-标签的占比低于 5% 就要考虑三类手段——降低O类的权重、加入实体词典特征、或用class_weight给少数类加权。简单做法是在 loss 计算里给非O标签乘上 2~3 的系数# 修改 model.py 的 loss 部分给实体类加权 class_weights tf.constant([1.0] [2.5] * (num_labels - 1)) weight_map tf.gather(class_weights, labels) loss tf.losses.sparse_softmax_cross_entropy( labelslabels, logitslogits, weightsweight_map)5.3 conlleval 评估结果与训练 loss 不符现象训练 loss 降到很低但conlleval.py输出的 F1 值始终在 60 分附近晃。原因conlleval.py对格式极其敏感。它要求输入文件是“token 正确标签 预测标签”三列格式中间用空格或 tab 分隔。如果predict.py输出的标签是中文名如B-疾病而正确标签是 id如1评估器直接不认。解决先检查生成样本的格式用命令行直接看前 20 行head -20 output/pred.txt对照标准格式O必须大写、B-/I-后的实体类别必须和训练数据完全一致。另外注意 conlleval 内部做了实体边界合并如果预测结果里出现I-疾病之前没有B-疾病会被判定为非实体。5.4 GPU 显存不足与 OOM现象ResourceExhaustedError: OOM when allocating tensor with shape [16,256,768]。原因max_seq_length512配合batch_size32BERT 基座模型显存占用直接破 12G。电子病历文本即便再长包含关键实体的片段通常集中在主诉和现病史段落。解决max_seq_length降到 256batch_size降到 8。如果必须处理长文本可以用滑窗切成长度为 256 的片段实体边界按重叠区域做后处理合并。我一般把主诉、现病史、既往史分开处理这样既控制长度又保持语义完整。5.5 同一实体在不同病历中标注不一致现象训练集里“冠状动脉粥样硬化性心脏病”有的标成“冠心病”有的标成完整名模型预测时对简称和全称的实体边界产生混淆。原因标注规范不统一。医疗数据标注是多人协作如果没有实体词典或标注手册面对同一疾病的不同表述很容易出现边界差异。解决在预处理阶段加入实体归一化映射把简称统一转为标准名再标注或者在 BIO 标签之外再加一层“实体类型”预测让模型学习不同表述之间的对齐关系。最省事的方式是直接用utils.py里的normalize_entity()函数统一实体表述。6. 评估与进阶读懂 conlleval 指标迭代出更好的模型6.1 conlleval 输出指标的工程含义用conlleval.py跑一次测试集输出通常是这样的格式processed 5047 tokens with 238 phrases; found: 231 phrases; correct: 207. accuracy: 96.70%; precision: 89.61%; recall: 86.97%; FB1: 88.27 disease: precision: 90.12%; recall: 87.50%; FB1: 88.79 98 drug: precision: 88.89%; recall: 85.11%; FB1: 86.96 47 surgery: precision: 75.00%; recall: 60.00%; FB1: 66.67 5逐行解读第一行是整体 token 层面的统计后面按实体类别分别列出。对临床场景我更看重recall——漏检一个药物名称可能直接影响用药安全所以调参时不会盲目追FB1而是先在 dev 集上把 recall 提到 90% 以上再回头提高 precision。整个评估环节最好固定在 dev 集上做不要用测试集反复调参否则测试集过拟合后真实业务场景的效果会明显下滑。6.2 失败 case 复盘常见错误类型与修复路径把预测错误的 case 按类型归类能明显看出模型问题出在哪里。我通常分成三类。实体边界错误即“冠心病”预测成“冠心”常见原因是训练数据里I-疾病标签在实体尾部缺失修复方法是通过脚本自动检查标注序列里是否存在B-后连续O的情况自动把中间缺的I-补上。实体类型混淆如把“阿司匹林”识别为“疾病”通常是因为训练数据中药物实体数量太少这时要增加数据或者引入医学词典做远程监督。否定实体遗漏即“否认高血压”里的“高血压”没有被识别因为前面有否定前缀。这种 case 光靠序列模型很难解决。变通做法是在标注阶段就把“否认”等否定词纳入实体上下文——把“否认”标为B-否定“高血压”仍标为B-疾病后续通过规则做否定关联合并。6.3 更进一步的调优从 BERT 到 BERT 领域特征如果评估结果稳定在 85 分上不去下一步我不会继续调参而是加特征。常见做法是在 BERT 输出层拼接三个额外特征字符级 bigram embedding、词性标注 embedding、词典匹配特征。医疗场景下词典特征最有效——把药品名、疾病名、检查项目名做成词典预测时如果某个词命中词典就给该 token 的 embedding 加一个可学习的偏移向量。这样模型即使没见过某个实体的变体写法也能借词典特征获得先验信号。另外rnncell.py也提示项目可以做 BERT BiLSTM CRF 的堆叠把 BERT 的 sequence output 过一层双向 LSTM 再进 CRF。这种结构在实体密集、上下文纠缠的电子病历上有帮助代价是训练时间增加 50% 左右。我的习惯是先在纯 BERT CRF 上拿到基线再逐步加 BiLSTM 和词典特征每加一个模块都在 dev 集上做对比不能一上来就堆全套。6.4 模型上线前的最后一道检查模型训练完我会跑一遍全量测试集并导出每条样本的预测边界人工抽检 100 条重点看三类病历上的表现有大量缩写的检验报告、包含数字分期的肿瘤病历、有否定前缀的既往史段。抽检通过后再把模型固化导出为SavedModel格式方便用 TensorFlow Serving 部署。这段流程走完整套源码才算是从“能跑”变成“能交付”。从项目结构到训练参数从 conlleval 评估到错误 case 复盘这套 BERT-Clinical-NER 源码把临床文本实体识别的完整链路都打通了。之后我每次接手新的医疗 NLP 项目都会先按这套流程把基线模型和评估脚本搭好再去谈复杂方案——希望这份笔记能帮你少踩几个坑。本文还有配套的精品资源点击获取
返回列表