简介:一套面向毕业设计场景的基于BERT+CRF+BiLSTM知识图谱医生推荐系统源码包,包含完整的Python项目、说明文档与配套数据集。资源针对计算机相关专业正在准备毕设或需要项目实战练习的同学,既可作为毕业论文核心系统,也能用于课程设计或期末大作业。包内共114个文件,以Python源码为主,同时涵盖XML配置、JSON与CSV数据、HTML前端页面、PNG/JPG图片、日志及模型pkl文件等,整体约40.42MB,结构清晰便于直接导入运行与二次开发。系统涉及知识图谱构建、实体识别与语义匹配等关键环节,BERT、CRF、BiLSTM三者结合的方式对意图理解与命名实体抽取有较好的工程示范意义。目前已有245人学习或下载。经过严格调试确保可跑通,附带的说明文档可帮助理解项目架构、数据流与调参细节,适合需要快速搭建完整系统的学习者使用。
1. 从“咳嗽一周”到“找哪位医生”:BERT+CRF+BiLSTM 与知识图谱要解决的问题
病人输入“咳嗽一周,有痰,晚上加重”,传统搜索引擎给的是疾病百科和医院列表,却不会告诉他该挂哪个科,更不会告诉他同城哪位副主任医师擅长慢性咳嗽。这个场景就是医生推荐系统的典型入口:把非结构化的主诉文本转成结构化实体,再结合医生擅长领域和患者口碑,给出可解释的推荐结果。标题里的 BERT+BiLSTM+CRF 负责从问诊文本中抽出症状、疾病、检查、科室实体,知识图谱负责存储医生与这些实体的语义关系,医生推荐则是图结构上的路径排序问题。这个系统适合两类读者:一类是做医疗 NLP 或知识图谱落地的工程师,想找一个从标注、训练到上线的完整套路;另一类是刚入门“实体识别+推荐系统”的人,想搞清楚为什么不能只用其中一个模型。下面这套方案就是我在本地复现类似项目时的完整做法,不依赖任何私有数据,使用的数据集格式和训练代码都能直接改成自己的语料。
2. BERT+BiLSTM+CRF 选型理由与知识图谱本体设计
2.1 为什么是 BERT+BiLSTM+CRF 而不是“BERT 一把梭”
很多人会问:BERT 自己带 Transformer 上下文编码,直接加一个 Dense 层做序列标注不行吗?答案是可以,但误标率偏高。BERT 输出的每个 token 向量已经包含了整句话的上下文,但它没有显式学习相邻标签之间的转移约束。在“患/者/出/现/咳嗽/,/伴/胸/闷”这种序列里,B-Disease 后面跟一个 I-Disease 很合理,但跟着一个 B-Drug 就不合理。CRF 层的作用就是给这些标签转移加上约束,把“疾病实体不会紧跟在药品实体内部”这类规则变成可训练的参数。
BiLSTM 夹在 BERT 和 CRF 之间,主要解决两个问题:一是 BERT 的最后一层向量维度通常较大,直接输入 CRF 会让转移矩阵不易收敛,BiLSTM 先把 BERT 输出压缩成更低维的时序特征;二是 BiLSTM 的双向结构能再次强化“边界信息”,让实体开头和结尾的识别更稳定。常见的替代方案是去掉 BiLSTM,只保留 BERT+CRF,参数量更小,但在我处理过的医疗长句中,BiLSTM 的中间特征层明显减少了对实体边界的误判,代价是训练时间多出不到 10%。对于医生推荐这种需要解释因果关系的场景,用完整三层结构更可靠。
2.2 面向医生推荐的图谱本体:节点、关系与属性
知识图谱不是简单的三元组堆积,先要定义本体,否则后面推荐查询会越写越乱。我把医生推荐涉及的实体分成五类节点,推荐系统的核心关系全部围绕这些节点展开:
| 节点类型 | 属性示例 | 对应推荐作用 |
|---|---|---|
| Patient(患者) | age, gender, chief_complaint | 只做查询起点,不存隐私 |
| Disease(疾病) | icd_code, treatment_method | 决定科室归属 |
| Symptom(症状) | body_part, duration | 从主诉抽取的首个实体 |
| Department(科室) | hospital_id, doctor_ids | 中间过渡节点,可用编号 |
| Doctor(医生) | title, years, service_count, score | 推荐末端节点,含排序特征 |
关系设计要满足“从症状能一路走到医生”的查询需求。常见做法是定义 Is-Symptom-Of(症状-疾病)、Treats(医生-疾病)、Belongs-To(疾病-科室)、Works-In(医生-医院)这四条关系,再额外加一条 Transformed(患者描述意图-实体)用于存放原始文本。不要把“擅长”和“治疗”混成同一种关系,“擅长”是医生自述或简介里的关键词,“治疗”则要求有真实处方记录,二者在推荐排序里权重不同。
2.3 数据标注格式与 BIO 约定
训练 NER 模型需要把原始文本转成 BIO 序列。我常用的标注格式是每行一个 token,空行分隔句子,B- 表示实体开始,I- 表示实体内部,O 表示非实体。实体类型包括 Disease、Symptom、Drug、Surgery 和 Department 五类,其中 Department 实体在后续图谱映射中很重要。示例格式如下:
咳 O 嗽 O 一 O 周 O , O 伴 O 胸 O 闷 B-Symptom , O 无 O 发 O 热 O数据规模方面,我做过的医疗问诊项目一般要准备一万到三万条标注句子,每个句子平均十个 token。如果只有几百条,可以先用一个预训练医疗 BERT 做远距离监督,再人工修正边界,否则 CRF 层学不到合理的标签转移规则。标注好的数据可以存成 JSON 数组,每条包含 tokens 和 labels 字段,方便后续加载。
3. 用 Python 实现 NER 训练与关系抽取
3.1 最小可用的 BERT+BiLSTM+CRF 模型结构
下面是一段可直接运行的 PyTorch 模型定义,去掉了预先处理 tokenizer 的细节,只保留核心三层。实际使用时要把bert_path换成预训练模型的本地路径,例如hfl/chinese-medical-electra-small或bert-base-chinese。
import torch import torch.nn as nn from transformers import BertModel class BertBilstmCrf(nn.Module): def __init__(self, bert_path, num_tags, hidden_dim=128, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.lstm = nn.LSTM( input_size=self.bert.config.hidden_size, hidden_size=hidden_dim, num_layers=1, bidirectional=True, batch_first=True ) self.fc = nn.Linear(hidden_dim * 2, num_tags) self.dropout = nn.Dropout(dropout) # 这里用 torchcrf 库,也可以自己实现维特比解码 from torchcrf import CRF self.crf = CRF(num_tags, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): bert_out = self.bert(input_ids=input_ids, attention_mask=attention_mask)[0] lstm_out, _ = self.lstm(bert_out) lstm_out = self.dropout(lstm_out) emissions = self.fc(lstm_out) if labels is not None: # torchcrf 默认忽略 -1 位置,padding 部分用 -1 处理 return -self.crf(emissions, labels, mask=attention_mask.bool()) return self.crf.decode(emissions, mask=attention_mask.bool())逻辑说明:BERT 输出的序列向量先经过双向 LSTM 压缩,再通过全连接产生每个 token 的发射分数,CRF 层计算标签转移概率。训练时用负对数似然作为 loss,预测时直接调crf.decode返回每个句子的最优标签序列。注意attention_mask必须传入 CRF,否则 padding 位置会被解码出无意义标签。
3.2 训练脚本的 4 个关键超参数
训练这类模型的超参数比模型结构更影响效果。我一般先用四组默认值跑一遍验证集,再根据日志调整,而不是直接上大模型。下表是我在医疗文本上调试出的常用初始值:
| 参数名 | 初始值 | 调整依据 | 为什么这么设 |
|---|---|---|---|
| batch_size | 16 | 显存小于 11G 时降到 8 | 医疗长句经 BERT 后显存占用大,批量过大会 OOM |
| learning_rate | 2e-5 | BERT 层用 2e-5,BiLSTM 用 1e-3 | 这是业界从 BERT Fine-tuning 继承来的经验,防止灾难性遗忘 |
| max_length | 128 | 超过 128 的样本先切句 | BERT 对长文本二次方复杂度,直接拉长会让训练时间失控 |
| crf_dropout | 0.1 | 数据量小时增大到 0.2 | 防止 CRF 层过拟合标签组合 |
训练循环里有一条很实用的规则:BERT 层和 BiLSTM/CRF 层要使用不同的优化器分组。常见做法是给 BERT 参数单独建一个参数组,学习率设为 2e-5;给 LSTM 和全连接层的学习率设为 1e-3,否则 BiLSTM 学得太慢,CRF 的转移矩阵也很快就会震荡。完整的训练循环会在每 100 个 batch 打印一次当前 loss 和标签序列样例,便于快速判断边界识别是否有问题。
3.3 从训练日志看收敛:loss 和实体级 F1
训练 NER 模型不能只盯 loss。CRF 的 loss 是负对数似然,数值通常较大,也不能直接和普通分类 loss 对比。我在验证集上算的是实体级 MUC 评测指标,也就是分别计算每个实体类型的精确率、召回率和 F1。训练到第三个 epoch 时,如果实体级 F1 还不到 0.8,要先检查标注一致性而不是调参。最常见的错误是不同标注员对“胸闷伴胸痛”是否算一个实体意见不一致,这类边界问题在医疗文本里非常多。
关系抽取部分不依赖训练新模型。我会先用规则从实体上下文中捕获关键词,比如“诊断为”“表现为”“予以”,再把这些规则触发的关系加上实体对,存储为补全三元组。对没有规则命中的实体对,采用一个轻量方法:如果两个实体在同一个标准医疗术语集里面属于同一疾病树,就把它们的关系标记为 Related。这种做法在数据不足时远好于训练一个小规模关系分类模型。
4. 基于知识图谱的医生推荐路径与排序
4.1 从实体到医生节点的图谱扩展路径
NER 模型输出的实体不是推荐系统的终点。病人说“咳嗽一周”,系统需要从“咳嗽”这个症状节点出发,先沿Is-Symptom-Of关系找到候选疾病列表,再从疾病节点沿Belongs-To找到科室,最后从科室或疾病直接沿Treats关系到达医生节点。这个路径就是推荐候选集。在 Neo4j 里用一条 Cypher 就能完成三步扩展:
MATCH (s:Symptom {name: '咳嗽'})-[:IS_SYMPTOM_OF]->(d:Disease) WITH d MATCH (d)-[:BELONGS_TO]->(dep:Department) MATCH (dep)<-[:BELONGS_TO]-(doc:Doctor) WHERE d.treatable = true RETURN doc.doctor_id AS doctor_id, doc.title AS title, doc.score AS score, d.name AS disease_name ORDER BY doc.score DESC LIMIT 10逻辑说明:第一个 MATCH 把用户主诉抽出的症状映射到疾病节点,WITH d保留疾病变量供后续使用。第二个 MATCH 关联科室,第三个 MATCH 找到科室下的医生。这里的关键是不要直接从症状跳医生,否则会把大量不相关医生拉进候选集。treatable = true是图谱里的业务属性,用于过滤那些没有有效治疗手段的疾病,避免推荐结果给患者错误期望。
4.2 冷启动场景下的标签与语义兜底
当病人描述的实体没有命中图谱节点时,推荐候选集会为空。典型场景是问句使用“嗓子像刀割一样疼”这类口语化表达,NER 实体是“刀割样痛”,但图谱中只有“咽痛”。我的做法分为两层:第一层在实体链接阶段用 BERT 的句子向量计算相似度,把相似度超过 0.75 的图谱节点作为映射目标;第二层如果仍然为空,就退回科室标签规则,先用关键词“疼”“痛”定位身体部位,再把身体部位映射到候选科室。这个兜底方案不依赖图谱完整度,至少能保证推荐列表不为空。
4.3 排序打分:图深度、医生服务量和 BERT 相似度
候选集确定以后不能直接用历史评分排序。新医生没有足够服务记录,老医生评分又可能因为患者基数大而偏低。我设计的打分函数由三部分加权组合:路径深度得分、医生专业度得分和文本语义相似度。前三部分是图谱排序算法的直接输出,第三部分需要把病人的主诉和医生擅长简介同时编码成向量。
def recommend_score(patient_text, doctor_text, path_score, service_cnt, alpha=0.4, beta=0.3, gamma=0.3): # alpha 路径得分权重,beta 专业度权重,gamma 语义相似度权重 bert_model.eval() with torch.no_grad(): vec1 = bert_encode(patient_text) vec2 = bert_encode(doctor_text) cosine_sim = torch.cosine_similarity(vec1, vec2, dim=0).item() norm_service = 1 - 1 / (service_cnt + 1) total = alpha * path_score + beta * norm_service + gamma * cosine_sim return total参数说明:path_score是图谱扩展时经过的中间节点数倒数,深度越短得分越高;norm_service对服务量做平滑,避免新医生永远排最后;cosine_sim来自 BERT 编码的语义匹配,解决“症状描述一致但医生自述用词不同”的问题。三个权重可以根据业务优先度调整:如果更看重医生服务量,把 beta 提到 0.5,如果更看重语义匹配,把 gamma 提到 0.4。
5. BERT+BiLSTM+CRF 模型部署与调优:显存、延迟和验证
5.1 ONNX 转换解决推理显存与延迟问题
训练完成后,BERT 层还是会造成 GPU 显存占用过高。常见做法是把模型导出成 ONNX 再用 ONNX Runtime 进行 CPU 推理。转换的重点是固定max_length为 64 或 128,因为动态长度会让 ONNX 图的维度推断变得非常不稳定。转换时不要带 CRF 层,而是单独保存标签 id 列表,推理时先用 ONNX 拿到 CRF 发射分数,再在 CPU 上执行维特比解码。这样单个句子 CPU 推理耗时能控制在 100ms 以内,显存可以完全释放。
5.2 长文本截断的折中:先切句再聚合实体
病人主诉超过 128 个 token 时,直接截断会丢掉关键信息。我的方案是先把文本按句号、逗号分成短句,再依次送入模型抽取实体,最后按实体在原文中的偏移量排序去重。这个做法的代价是跨句实体(例如“咳嗽”在前一句、“痰”在后一句)会被识别成两个独立实体,但医生推荐场景对实体完整性要求不高,反而能保留更多候选症状。注意去重时不要按实体字符串完全相等去重,要保留同义词组映射,例如“胸痛”和“胸口疼”应该映射到同一图谱节点。
5.3 离线验证不只看 F1,还要看推荐命中率
NER 的 F1 提升并不直接等于推荐质量提升。更有效的验证方式是构造一批带标准答案的病人主诉:每个主诉标注了“真实就诊医生”或“真实科室”。系统推荐结果命中答案时的比例就是推荐命中率,这个指标比 F1 更贴近业务目标。我一般建议线下同时跑两组实验,第一组只用图谱路径得分排序,第二组加入语义相似度权重,通过命中率差异判断语义模块是否值得继续优化。实际项目中,F1 提升两个点可能对命中率几乎没有影响,因为推荐系统真正依赖的是图谱链接完整性和排序权重的稳定性。
本文还有配套的精品资源,点击获取