☰
中文电子病历NER实战:BiLSTM-CRF模型原理与完整源码工程实践
2026/10/31 10:11:10 网站建设 项目流程

简介:基于BiLSTM-CRF网络的中文电子病历命名实体识别项目,是一份可直接运行的Python源码与项目说明集合,面向自然语言处理、医学信息抽取方向的开发者与学生,可作为计算机、数学、电子信息等专业的课程设计、期末大作业或毕设参考资料。包内共999个文件,压缩包大小约84.55MB,文件以txt语料与说明、py源码、模型checkpoint和评估记录等类型为主:txt提供标注语料及项目文档,py为训练和预测脚本,checkpoint为训练好的模型权重,result_metric记录各轮评估指标。项目核心模型为BiLSTM-CRF序列标注架构,用于处理电子病历中的症状、疾病、药物等命名实体识别任务,语料按病史特点、诊疗经过、出院情况等模块组织,便于理解医学文本结构化信息抽取过程。已有127人浏览学习,适合需要参考完整NLP实践流程、并对医学文本挖掘感兴趣的读者;通过阅读代码与实验记录,可掌握从数据预处理、模型训练到效果评估的完整链路,并可在自带语料上复现实验或迁移至其他电子病历数据。

1. 中文电子病历命名实体识别:一个能直接跑的 BiLSTM-CRF 基线

电子病历里的命名实体识别是个很现实的活儿——诊断、症状、手术、药物、解剖部位,全挤在非结构化的文本里,Rule-based 规则换一个医院就崩一次。我最初接触 BiLSTM-CRF 就是在做病案首页质量抽查,需要把“患者因反复胸闷、气促 1 月余入院”这句自然语言拆成带标签的实体序列,而这份资源恰好补上了从语料整理到模型训练的完整一环。它不是调包侠式的一行调用,而是一个能训练、能保存 checkpoint、能加载模型做推理的 Python 工程源码包,适合正在做课设、毕设,或者想踩一遍 NER 管线细节的从业者。下面我按“原理 → 数据 → 源码 → 训练推理 → 踩坑 → 验证”的顺序讲透,建议你下载后对照项目说明一起看。

2. 中文电子病历 NER 的建模选择:BiLSTM 为何要与 CRF 搭配

2.1 序列标注任务里 LSTM 与 CRF 的分工

中文电子病历命名实体识别本质上是一个序列标注任务,给句子里的每个字打一个标签,比如 B-Disease、I-Disease、O。BiLSTM 做的事情是看完整句话的正向和反向信息,把每个字映射成一个高维特征向量——它擅长捕捉“上腹部疼痛”里的“上腹部”是部位、而不是手术名这种上下文语义。但 BiLSTM 有个弱点:它输出的是每个位置独立的标签概率,无法老老实实保证“I-Disease 前面必须是 B-Disease”,或者整句话里实体边界闭合的合理性。

CRF 层的角色就是把这些约束焊死在解码路径上。它学一个转移矩阵,记录“上一个标签是 B-Disease,当前标签是 I-Disease”的合法程度,再用维特比算法解码出全局最优的标签序列。我通常是这么解释给新手听的:BiLSTM 看懂了字,CRF 管住了语法和边界的规矩,两件事加起来,电子病历里“诊断”这类嵌套实体的识别精度才拉得上去。

BERT 类模型这些年确实吊打传统结构,但 BERT 对硬件的胃口也让不少课程设计和毕设项目翻车。BiLSTM-CRF 在 CPU 上跑一天也能出结果,参数量小、代码好调试,这个资源选它做基线很合理,后续你想换成 BERT+CRF,也只需要替换编码层,CRF 部分可以原样保留。

2.2 项目语料是怎么组织的

解压 zip 之后,第一件事不是直接跑模型,而是先看懂语料目录。从项目正文的文件名能明确看出数据是按电子病历的小节切好的,我把它整理成如下表格:

文件/目录名内容推测在训练中的用途
._01-一般项目-format2患者基本信息(性别、年龄、入院科室等),统一转成 format2 格式抽取实体时可作为上下文先验
._02-病史特点-format2现病史、既往史文本,包含症状、疾病、药物实体最多核心训练集
._04-诊疗经过-format2治疗措施、手术操作、检查记录的文本补充手术、诊疗实体样本
._05-出院情况-format2出院诊断、出院医嘱验证和测试时使用
_test dataset测试集,用于最终评估模型泛化能力单独进测试流程
电子病历语料库2.bin经过预处理的词表、标签集或序列化语料加载后直接喂给模型
model-*.data-00000-of-00001TensorFlow checkpoint 保存的模型参数权重恢复模型做推理或继续训练

我一般会建议你把 format2 格式理解成一种保守的统一标注规范:字符之间以特定分隔符切好。训练前必须确认文本里没有半角/全角混杂、空行被省略这些小事,否则切词和分句会静默出错,模型就学歪了。

2.3 特征表示与标签体系的选择

这份资源用的是字级别的特征表示。做中文电子病历 NER,我强烈建议优先走字级别而不是词级别——因为分词工具在医学文本上表现很不稳定,“胃窦”可能会被切成“胃 / 窦”,但实体标注里它是完整的解剖部位。字级别天然规避了分词误差传播。

标签体系按 BIO 标准来划分:B 表示实体开头,I 表示实体中间或结尾,O 表示非实体。如果你想把实体分得更细,可以扩成 BMES 四标签体系,但那是后话。实际项目中最稳的标签列表一般是:

label_list = ["O", "B-Disease", "I-Disease", "B-Symptom", "I-Symptom", "B-Surgery", "I-Surgery"]

这套标签的好处是每个实体类别只维护开头的 B 和延续的 I,对初学的代码实现很友好。后面你可以在 CRF 层直接看到转移矩阵,验证“B 后面不接 I”这种约束是否被模型学到了。

3. 源码使用前的准备:Python 环境、目录结构与 checkpoint 恢复

3.1 zip 解压与 Python 环境检查

下载下来的 zip 包解压后,我先检查根目录下有没有requirements.txt或project_说明.md这类文件。这个项目按惯例应该是 TensorFlow 系的工程。配置环境时最容易出问题的是 Python 版本与 TensorFlow 版本不匹配,因此我有几个强制动作:

python --version # 确认 Python 版本,建议 3.6 到 3.8 区间 pip install tensorflow==1.15.0 # 如果代码用 tf 1.x API,装这个版本 pip install numpy pandas sklearn pip install h5py pyyaml

注意这里不要盲升版本。BiLSTM-CRF 的老代码大量使用tf.nn.dynamic_rnn这类 API,TensorFlow 2.x 里已经移除或改名,代码直接报AttributeError。我吃过一次亏:看到报错就升级到 TensorFlow 2.5,结果 load checkpoint 时变量名全对不上,白白浪费了整个下午,最后还是回退到 1.15 重跑。如果项目里model-xxx.data的命名风格是 TF1.x 的 checkpoint,你就老老实实装 1.15 或 2.0 的兼容模式。

3.2 主流程拆解:从语料到输出的完整链路

这个资源里的源码路径一般会分成数据处理、模型构建、训练和预测几个模块。我拿常见项目的写法还原一下主文件的调用链,你先对照项目说明确认你的入口是train.py还是run.py还是main.py。典型实现如下:

# main.py 主流程示意 from data_loader import load_data from model import BiLSTM_CRF from train import train_model from predict import predict_text corpus_path = "电子病历语料库2.bin" # 加载已经编码好的语料和标签 train_data, dev_data, vocab, tag_vocab = load_data(corpus_path) # 构建模型,指定字向量维度和 LSTM 隐藏层维度 model = BiLSTM_CRF(vocab_size=len(vocab), tag_size=len(tag_vocab), embedding_dim=128, lstm_dim=128, use_crf=True) # 进入训练循环 train_model(model, train_data, dev_data, epochs=30)

这里的BiLSTM_CRF类通常是继承tf.keras.Model或沿用tf.contrib.crf的 TensorFlow 1.x 写法。参数embedding_dim决定每个字向量映射到多少维,lstm_dim决定双向 LSTM 每向隐藏层大小。这两个值越大,模型拟合能力越强,但在小规模医疗语料上极易过拟合,128 是一个相对稳当的起步值。

3.3 用 checkpoint 恢复模型参数做预测

资源里给出的model-5626.data-00000-of-00001这类文件,就是训练中途保存的参数快照,省去了你自己重新训练几小时的时间。恢复过程分三个动作:加载计算图、加载权重、喂数据。参考代码如下:

import tensorflow as tf def load_trained_model(checkpoint_path, meta_graph_path): sess = tf.Session() # 加载 checkpoint 对应的图结构 saver = tf.train.import_meta_graph(meta_graph_path) # 恢复所有变量权重 saver.restore(sess, checkpoint_path) return sess # 实际使用时传入下载的 checkpoint 路径 sess = load_trained_model("model-5626.data-00000-of-00001", "model-5626.meta")

逻辑不复杂:.meta文件保存图结构,.data文件保存变量值,saver.restore把权重交给当前 Session。有个细节要注意——checkpoint 的恢复必须与创建模型时的变量名完全一致。如果运行的是另一个目录下的改版代码,常常报NotFoundError: Key model/bilstm/... not found,那基本可以断定是模型定义代码被改过或变量作用域没对齐。

4. 训练与推理参数详解:把数据喂进 BiLSTM-CRF 的正确姿势

4.1 语料预处理与词表构建

拿到 format2 原始文本,需要先做清洗和编码。我一般会固定走这几个步骤:

# 1. 读出文本,统一转成小写并去除不可见字符 def clean_text(content): content = content.replace("\u3000", "").strip() return content # 2. 按段落切分,再按字符序列化 def build_vocab(all_text, min_count=1): char_count = {} for text in all_text: for char in text: char_count[char] = char_count.get(char, 0) + 1 vocab = {"PAD": 0, "UNK": 1} for char, count in char_count.items(): if count >= min_count: vocab[char] = len(vocab) return vocab # 3. 句子对齐:同一 batch 内按最长句补 PAD def pad_sequence(seqs, max_len=None): if max_len is None: max_len = max(len(s) for s in seqs) padded = [s + [0] * (max_len - len(s)) for s in seqs] return padded

这里最容易被忽略的是参数min_count。电子病历里会有大量单字人名、生僻部位名,出现频次极低,如果不设最低频次过滤,词表会膨胀到几万甚至十几万,训练时间和内存都吃不消。我通常设min_count=1也可以,但如果语料够多,建议设到 2 或 3,能明显降低噪音。

4.2 训练超参数的推荐配置与作用

训练 BiLSTM-CRF 时,超参数直接决定收敛速度和最终分数。结合这个项目的数据规模,我把常用参数整理成一张表,方便你在源码里对照修改:

参数名推荐值作用与调整方向
batch_size16 或 32太大容易显存溢出,太小 loss 震荡严重
epochs20~30医疗语料量不大,30 轮足够收敛
learning_rate0.001 到 0.005Adam 优化器下推荐 0.001,不降就调小
embedding_dim128可尝试 100,对小于 10 万字符的语料够用
lstm_dim128老代码喜欢设 200,但 CPU 训练会慢很多
clip_norm5.0梯度裁剪,防止 CRF 层梯度爆炸
dropout_rate0.5BiLSTM 输出层之后加,强防过拟合

训练中我会强烈建议你开启验证集每一轮评估一次。不要等 30 轮跑完才看效果,很多模型在 15 轮之后验证集 F1 就开始回落。读到资源里如果有model-5626、model-5723、model-5820这几个 checkpoint 序号,说明源码作者每隔几百步就做一次保存,这个习惯值得保留。

4.3 对新病历文本做实体识别推理

训练好或加载好 checkpoint 后,预测函数要做的事是:把新文本转成 ID 序列,过模型得到 CRF 解码路径,再把 ID 映射回标签,最后按 BIO 规则切出实体。参考代码:

def predict_entities(model, text, char_to_id, id_to_tag): # 文本转 ID 序列 ids = [char_to_id.get(c, char_to_id["UNK"]) for c in text] # 模型输入需要 batch 维度 logits, trans = model.predict([ids]) # 用维特比解码得到最优标签路径 import tensorflow as tf viterbi_seq, _ = tf.contrib.crf.viterbi_decode(logits[0], trans) tags = [id_to_tag[idx] for idx in viterbi_seq] # 把连续标签拼成实体 entity_list = [] cur_entity = "" cur_type = None for char, tag in zip(text, tags): if tag.startswith("B-"): if cur_entity: entity_list.append((cur_type, cur_entity)) cur_entity = char cur_type = tag[2:] elif tag.startswith("I-") and cur_entity: cur_entity += char else: if cur_entity: entity_list.append((cur_type, cur_entity)) cur_entity = "" cur_type = None if cur_entity: entity_list.append((cur_type, cur_entity)) return entity_list

这段代码有两个地方容易翻车。第一个是viterbi_decode里的trans矩阵,它必须是 CRF 层训练得到的转移矩阵,形状为[tag_size, tag_size];如果你的模型是自己写的 CRF,需要把转移参数单独取出来,不能从logits里猜。第二个是 BIO 合并逻辑——推理时模型偶尔会输出没有 B 开头的连续 I 标签,这种解析会漏掉实体,所以我在上面对I-做了and cur_entity的保护,宁可丢掉这种错误标签也不拼出脏实体。

5. 避坑与常见问题:六个让我反复返工的真实踩坑记录

5.1 现象:词表里全是<UNK>,模型预测结果像乱码

原因:读取电子病历语料库2.bin时用了错误的字符编码。bin 文件可能是 UTF-8,也可能是 GBK。Python 里文件默认编码不对,中文全部变成乱码,词表构建出来的字典和文本库完全对不上。

解决:读 bin 文件时逐字节检测或直接尝试多种编码打开:

for enc in ["utf-8", "gbk", "gb18030"]: try: with open(path, "r", encoding=enc) as f: corpus = f.readlines() break except UnicodeDecodeError: continue

我后来每次拿到新语料,第一步就是写这个自动探测逻辑,以后再没在这个坑上翻过车。

5.2 现象:TensoFlow 版本更新后,tf.contrib.crf直接报模块不存在

原因:tf.contrib是新版本里被移除的遗留模块,凡是 TensorFlow 2.0 之后的官方版都不再内置。

解决:两种路线——一是回退安装tensorflow==1.15,代码一行不用改;二是用社区维护的tensorflow-addons包,把import tf.contrib.crf换成:

from tensorflow_addons.text.crf import crf_log_likelihood, viterbi_decode

但这要求模型定义部分也改写,工作量不小。我自己的习惯是直接建一个独立的 conda 环境装 1.15,与当前 Python 生态隔离,免去其他包冲突。

5.3 现象:加载 checkpoint 报错KeyError或者变量找不到

原因:模型定义的代码被改动过,变量名和作用域跟 checkpoint 保存时的名字对不上。比如从variable_scope("bilstm")改成了variable_scope("bilstm_v2")。

解决:先在 TensorFlow 里打印变量名,确认 checkpoint 里的键名再决定:

from tensorflow.python.tools.inspect_checkpoint import print_tensors_in_checkpoint_file print_tensors_in_checkpoint_file("model-5626.data-00000-of-00001", None, True)

输出里会列出所有变量的形状和名称,跟你的模型trainable_variables逐个比对,哪个名字不匹配就改哪个。这一步能省掉大量瞎猜时间。

5.4 现象:训练几轮后 loss 变成 NaN

原因:learning rate 过大加上 CRF 层的 log 运算出现异常值,常见于标签里有生僻实体类别导致的越界分数。

解决:把 learning rate 从 0.005 降到 0.001;同时在优化器上加梯度裁剪:

optimizer = tf.train.AdamOptimizer(learning_rate=0.001) grads, vars = zip(*optimizer.compute_gradients(loss)) grads, _ = tf.clip_by_global_norm(grads, 5.0) train_op = optimizer.apply_gradients(zip(grads, vars))

我习惯在代码里保留 clip 逻辑,哪怕初期觉得没必要。医疗数据集总有脏标签,NaN 出现一次就够你折腾半天。

5.5 现象:训练速度极慢,CPU 跑一轮好几小时

原因:lstm_dim或embedding_dim设得过大,本质是小语料配了大模型;更隐蔽的原因是没有 padding 好而被迫用动态 RNN 展开。

解决:把维度降到 128 以下,检查pad_sequence是否对 batch 内所有句子做了长度截断。另外可以用tf.nn.dynamic_rnn传入真实长度参数sequence_length,节省无效位置的网络计算。血泪经验是刚开始不要想着一步到位,先在一个 batch 上验证链路能通,再放大数据量。

5.6 现象:预测结果实体边界多了一个字或少了一个字

原因:CRF 转移矩阵学到的是统计约束,对医学里的“症状描述里嵌疾病名”这类复杂边界还是不够强。这本质是模型上限问题,不是代码 bug。

解决:在 BIO 标签体系下,我通常给 CRF 增加一步后处理——对切出来的实体做词典匹配修正。比如预测出“慢支”但病历里常见表述是“慢性支气管炎”,用一个同义词表把缩写归并到标准实体。这类小修小补能轻松提 1~2 个点的 F1,比继续调超参划算得多。

6. 验证模型效果与进阶调优:从 F1 指标开始向生产靠拢

模型训练完,不能拍脑袋说效果好。我会单独写一个评估脚本,加载_test dataset,逐条预测,再算出精确率、召回率和 F1。计算公式很简单:

from sklearn.metrics import precision_recall_fscore_support y_true = [] y_pred = [] # 对所有测试句子做预测并收集标签序列 for sample in test_samples: y_true.extend(sample.true_tags) y_pred.extend(predict_tags(sample.text)) precision, recall, f1, _ = precision_recall_fscore_support(y_true, y_pred, average="micro") print(f"P={precision:.4f}, R={recall:.4f}, F1={f1:.4f}")

注意这里用的是 micro 平均,它按全体 token 统计,不会被长句子主导,更适合医学文本的评估。

如果你决定把这套代码往论文或落地项目里推,我建议你做两件事。第一,把 CRF 层换成一种更细的“转移矩阵可视化”——直接打印学到的转移权重,看模型是不是真的学会了“B 后面必须接 I”。第二,对比不同的标签粒度:把“疾病”“症状”合并成一个“实体”类别,看 F1 能涨多少;有些课设为了分数好看,会刻意用粗粒度标签,这就看你自己的目标了。

这个项目我已经拆过不下十次了,每次拿到新病历数据,我都会强制自己先跑一遍predict_entities去手动核对 10 条完整病历文本的抽取结果,再回去调参。只有让真实病历先走通,后面的指标都是水到渠成的事。希望这份源码包能让你少走几段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询