简介:一份面向电子病历信息抽取场景的基于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_ids,loader.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(config=config) self.num_labels = num_labels def forward(self, input_ids, input_mask, segment_ids): _, pooled = self.bert( input_ids=input_ids, input_mask=input_mask, token_type_ids=segment_ids, return_pooled_output=True) # 取每个 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 会把一个词切成多个 subword,label 就必须跟着 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_len=126): """返回多个片段,每个片段不超过 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_calls=8) dataset = dataset.padded_batch( batch_size, padded_shapes=([max_seq_length], [max_seq_length], [max_seq_length], [max_seq_length]), drop_remainder=True) return dataset这里有个参数值得多说一句:drop_remainder=True只在训练时开,如果测试集最后一批不足 batch_size 会被丢弃,导致评估结果不完整。到了predict.py推理阶段要把这个参数改成False,或者用padded_batch配合动态 pad,否则最后的短文本全部预测不了。
4. 训练与预测:核心参数调优和模型存取
4.1 超参数配置:先从医疗场景标杆值起步
train.py里有一堆可调参数,新手最容易犯的错是照搬官方 BERT 的默认值。官方预训练用 128 万的 batch、学习率 1e-4 没问题,但下游 NER 微调的学习率要从 2e-5 到 5e-5 起调。医疗场景训练集通常只有几千条,我用下来比较稳的起步配置如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| learning_rate | 2e-5 ~ 3e-5 | 太大 CRF 容易不收敛,太小收敛缓慢 |
| train_batch_size | 16 | 显存 11G 以下用 8 |
| num_train_epochs | 3 ~ 5 | 数据量小可加大 epoch,配合 early stopping |
| max_seq_length | 128 ~ 256 | 电子病历句子普遍偏长,建议 256 |
| warmup_proportion | 0.1 | 前 10% 步数线性预热 |
| save_checkpoints_steps | 500 | 便于止损回滚 |
训练启动命令参考:
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_train=True \ --do_eval=True \ --max_seq_length=256 \ --train_batch_size=16 \ --learning_rate=3e-5 \ --num_train_epochs=4特别注意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 到 256,mask 必须是相同的 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( labels=labels, logits=logits, weights=weight_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_length=512配合batch_size=32,BERT 基座模型显存占用直接破 12G。电子病历文本即便再长,包含关键实体的片段通常集中在主诉和现病史段落。解决:max_seq_length降到 256,batch_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 项目,都会先按这套流程把基线模型和评估脚本搭好,再去谈复杂方案——希望这份笔记能帮你少踩几个坑。
本文还有配套的精品资源,点击获取