首页 / 资讯中心 / 文章详情

基于知识图谱与BERT+BiLSTM+CRF的医生推荐系统实现

基于知识图谱与BERT+BiLSTM+CRF的医生推荐系统实现 ★ FEATURED ARTICLE
简介这套基于BERT、BiLSTM与CRF混合模型的医生推荐系统知识图谱项目是一套面向计算机专业毕业设计的完整解决方案也适用于课程设计、期末大作业或项目实战练习。它通过深度学习从医学文本中抽取实体与关系构建包含疾病、症状、科室和医生的推荐图谱并为患者智能匹配合适医生能够解决毕业设计选题难、缺少可运行的完整项目的问题。资源包共114个文件压缩后约40.42MB核心为37个Python源码文件覆盖数据处理、模型训练与推荐模块另有XML/JSON配置、HTML/CSS/JS前端、CSV数据集、pkl模型文件及日志图片等结构清晰方便直接运行与二次开发。目前已有246人学习/下载参考价值明确。除经过严格调试的完整工程外资源还附带项目说明文档详细展示知识图谱构建流程、实体识别与关系抽取的实现思路以及推荐算法设计逻辑可直接作为毕业设计整体框架使用帮助快速掌握BERT、BiLSTM与CRF在实际项目中的配合方式。1. 把医生推荐做成知识图谱问答这套代码到底解决什么问题先别急着看模型这个标题里最值钱的东西其实是「知识图谱」三个字。医生推荐系统如果只靠 BERT 或者 BiLSTM 做文本匹配本质上就是个相似度计算器病人问「头痛耳鸣挂什么科」它顶多给你返回几个包含这些词的科室名至于「神经内科和耳鼻喉科到底哪个更对症」这种跨实体推理纯文本模型根本答不上来。加上知识图谱之后症状、疾病、科室、医生之间的关联关系变成了一张可查询的图推荐动作就从「找相似文本」升级成「沿着图结构做多跳推理」这才是这个项目能写出落地价值的根本原因。这套基于 BERT CRF BiLSTM 知识图谱实现的医生推荐系统适合谁用三类人第一类是正在做垂直领域问答系统、需要一套从数据标注到模型部署完整链路的 NLP 工程师第二类是做医疗信息化、想把导诊功能塞进现有 HIS 系统的后端开发者第三类是研究生拿它当论文的 baseline 或者工程验证。它能解决的问题很具体从病人的自由文本主诉中抽取症状和疾病实体BERTBiLSTMCRF 负责把抽出来的实体映射到知识图谱上实体链接然后在图谱里查询推荐路径科室、医生、擅长领域匹配最后返回推荐结果排序。本文接下来会按数据怎么准备、模型怎么串、图谱怎么查、坑在哪里这条线讲透新手能照着跑通熟手能直接拿去改业务。2. 先搞清楚数据流从病人主诉到推荐结果的四层管线2.1 为什么是 BERT BiLSTM CRF 三层叠而不是只用预训练模型很多第一次接触这个标题的读者会问BERT 自己就能做序列标注为什么还要叠 BiLSTM 和 CRF答案在于标签之间的依赖关系和解码效率。BERT 输出的每个 token 表示是上下文相关的但它对每个位置独立做分类不考虑「B-症状后面不能直接跟 I-症状」这类标签转移约束。BiLSTM 在 BERT 之上再做一次序列建模能把整句话的前后文信息融合进每个位置的隐状态但它依然不做标签层面的约束。CRF 层的价值就在最后这道工序它把「标签序列的整体概率」作为优化目标转移矩阵会学到类似「I-症状 必须跟在 B-症状 后面」「O 标签后面接 I-症状 的概率极低」这种规则。从训练效率上看这个三层结构也比端到端换一个更大的预训练模型划算。医疗文本里专业术语多BERT 底子好但微调成本高BiLSTMCRF 结构简单训练快组合起来既能吃到预训练模型的语义先验又能用 CRF 保证输出序列合法。如果只有 BERT长句子里的实体边界经常被切得乱七八糟如果只上 BiLSTMCRF没有预训练模型的底子小数据集上的泛化能力又不够。这个项目的做法在工业界非常普遍不是炫技是被业务数据量逼出来的务实选择。2.2 数据集长什么样主诉文本、实体标注、医生画像三张表做这个项目之前先要把数据脑补清楚。数据集至少包含三个部分缺一个推荐逻辑就断了。第一张表是病人主诉文本格式是一行一句比如「患者近两周头痛伴恶心夜间视力模糊」每一句要有对应的实体标注标注类别一般是症状、疾病、身体部位、检查项目、药物五类。第二张表是医生画像字段包括科室、职称、擅长领域、从业年限、评分、接诊量这是推荐结果的候选池。第三张表是知识图谱的三元组文件格式是 CSV 或者 JSON存的是「头痛-属于-神经内科疾病」「偏头痛-可用药-布洛芬」「神经内科医生张医生-擅长-偏头痛」这样的关系。如果你没有现成医疗数据可以用公开的 CCKS 竞赛数据或者自己爬公开医院网站做标注。注意标注一致性是个大坑后面避坑章节我会专门讲。这里先给出一个典型的主诉标注文件格式tab 分隔每行一句患者近两周头痛伴恶心夜间视力模糊 B-症状 I-症状 O O O O O B-症状 O O O B-症状这行数据的含义是每个字/词对应一个标签标签是 BIO 格式B 开头是实体首字I 是实体内部O 是非实体。实际项目中一般用 word-level 而不是 char-level中文场景建议按字标注因为医疗术语分词器经常把「偏头痛」切成「偏/头痛」但按字标注就不会丢边界信息。把这三张表放进项目目录数据流就跑通了。2.3 主诉文本的预处理分字、词典构建、BERT tokenizer 三件套文本预处理是整个管线里最容易被低估的一步。常见做法是用 BERT 自带的 tokenizer 做切分然后额外维护一个字符到 id 的映射因为 BiLSTM 层的输入不一定完全等价于 BERT 的 subword 切分。以中文为例BERT 的 tokenizer 会把句子切成字级别但有些特殊符号如「%」「」会被拆成多个 subword此时要给每个 subword 分配实体标签规则是如果原始字是实体开头则第一个 subword 标 B其余标 I。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def align_label(text, labels, tokenizer): # 将 char-level 的 BIO 标签对齐到 BERT subword 级别 tokens tokenizer.tokenize(text) aligned_labels [] idx 0 for token in tokens: if token.startswith(##): # BERT 中文一般没有 ## 后缀但保险起见处理英文 aligned_labels.append(I labels[idx][1:] if labels[idx] ! O else O) else: aligned_labels.append(labels[idx]) idx 1 return tokens, aligned_labels这段代码的逻辑是逐 token 对齐标签。中文场景下 tokenizer 基本是字级别所以 idx 每次加 1 即可如果是英文遇到连续 subword 时要特殊处理。跑不通的情况多半是带标签的字符数和 tokenizer 切出的 token 数不一致解决办法是在预处理时忽略空字符和特殊符号不要硬对齐。预处理完还要统计所有标签类别数量构建 label2id 映射这个映射在训练和推理时都要保持一致。如果你在训练完成后又往数据里加了新实体类型那就要重训模型否则 CRF 层的转移矩阵维度对不上这是新手极容易踩的坑后面会展开。3. 模型怎么串BERT 编码、BiLSTM 增强、CRF 解码的完整训练流程3.1 网络结构图拆解输入层、编码层、解码层的维度怎么对先画一遍维度流转这个搞懂了代码不会写歪。输入是一个句子长度为seq_len每个 token 转成 id 后过 embedding 层得到维度[batch_size, seq_len, hidden_size]。BERT base 的 hidden_size 是 768。接着把 BERT 的最后一层输出拉出来维度还是[batch_size, seq_len, 768]这一步不做池化因为序列标注需要保留每个位置的表示。然后过 BiLSTMLSTM 的隐藏层维度设为 256双向拼接后变成 512。此时输出维度是[batch_size, seq_len, 512]再经过一个线性层映射到标签数量。假设实体类型有 5 类B/I/O 后总共是 11 个标签5 类乘以2再加O线性层输出就是[batch_size, seq_len, 11]。最后进入 CRF 层CRF 不改变维度它计算的是最优标签序列的概率输出仍然是[batch_size, seq_len]的 label ids。关键参数就三个LSTM hidden size、dropout、CRF 的学习率缩放。hidden size 一般取 128 或 256太大会让训练变慢且容易过拟合dropout 在 BERT 输出和 BiLSTM 输出之间各加一层概率取 0.5 强度适中CRF 层的学习率建议比主模型小一个数量级否则转移矩阵振荡太剧烈loss 下不去。待会儿我会给出具体代码。3.2 训练代码PyTorch 实现 BERTBiLSTMCRF 的标准写法先定义一个实体识别模型类把所有层串起来。import torch import torch.nn as nn from torchcrf import CRF from transformers import BertModel class BertBilstmCrf(nn.Module): def __init__(self, bert_model_name, num_tags, lstm_hidden256, dropout0.5): super().__init__() self.bert BertModel.from_pretrained(bert_model_name) self.bilstm nn.LSTM( input_size768, hidden_sizelstm_hidden, num_layers1, bidirectionalTrue, batch_firstTrue, ) self.dropout nn.Dropout(dropout) self.dense nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags) def forward(self, input_ids, attention_mask, labelsNone): bert_outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output bert_outputs.last_hidden_state sequence_output self.dropout(sequence_output) lstm_output, _ self.bilstm(sequence_output) lstm_output self.dropout(lstm_output) emissions self.dense(lstm_output) if labels is not None: loss -self.crf(emissions, labels, maskattention_mask.bool()) return loss else: return self.crf.decode(emissions, maskattention_mask.bool())逻辑说明前向计算分两段训练时返回 CRF 的负对数似然损失推理时返回解码出的标签序列。torchcrf的decode方法内部用了 Viterbi 解码不需要自己写动态规划。这里有个细节mask参数传的是attention_mask.bool()它的作用是把 padding 位置排除在 CRF 转移计算之外如果你的数据里有长句子padding 后不传 maskCRF 会把 padding 位置的标签也纳入转移计算结果一塌糊涂。参数说明lstm_hidden256是双向后的单方向维度所以线性层输入是 512num_tags是标签总数按前面 11 类算就是 11。bert_model_name建议用bert-base-chinese显存不够就换hfl/chinese-bert-wwm-ext效果差不多但词表覆盖更好下载失败时可以用本地路径加载。3.3 训练循环里的三个决定性细节学习率、梯度裁剪、类别不均衡模型定义好了训练循环里还有三个细节直接决定模型能不能收敛。optimizer torch.optim.AdamW(model.parameters(), lr5e-5) # 单独给 CRF 层一个更小的学习率 optimizer_crf torch.optim.AdamW(model.crf.parameters(), lr1e-5) for epoch in range(num_epochs): for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) loss model(input_idsinput_ids, attention_maskattention_mask, labelslabels) total_loss loss sum(p.norm(2) for p in model.parameters() if p.requires_grad) * 0.01 optimizer.zero_grad() optimizer_crf.zero_grad() total_loss.backward() # 梯度裁剪防止 LSTM 梯度爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() optimizer_crf.step()说明LSTM 层和 CRF 层容易梯度爆炸clip_grad_norm_是必须的max_norm5.0是经验值如果你的 loss 在训练前几个 step 变成 NaN先看是不是这个问题。CRF 转移矩阵对学习率很敏感单独给 1e-5 会比较稳。L2 正则加的 0.01 权重不是必须的如果训练集很小可以加上防止过拟合数据集够大直接去掉节省时间。类别不均衡问题在实体识别里很突出O 标签非实体占了绝大多数。如果你不做任何处理模型很容易把所有 token 都预测成 O精确率看着还行但召回率极低。常见做法是给 CRF 的 loss 按标签类别加权torchcrf库里拿 loss 不方便直接加权重我一般会在数据预处理阶段对 O 标签做欠采样或者用 Focal Loss 的思路把难例权重调大。考虑到 Crf 解码的特殊性更省事的方案是在训练时把 batch 按句子的实体数量做排序保证每个 batch 里都有一半带实体的句子这个方法比调损失函数更直观。4. 知识图谱构建三元组怎么设计、Neo4j 怎么存、推荐查询怎么写4.1 本体设计症状、疾病、科室、医生四类实体和六种关系知识图谱不是把三元组随便堆进 Neo4j 就行先设计本体Ontology也就是实体类型和关系类型的约束。这个医疗推荐系统里实体类型最少四类症状、疾病、科室、医生。关系类型我建议定义六种症状-属于-疾病、疾病-属于-科室、医生-擅长-疾病、医生-属于-科室、医生-接诊-症状、科室-包含-症状。前三种是硬关系来自数据集里的标注后三种是推导关系在写入 Neo4j 前要用脚本自动生成不然推荐路径太短全是一跳查询没法叫知识图谱。举个例子如果数据里有「偏头痛-属于-神经内科疾病」和「神经内科疾病-属于-神经内科」还有「张医生-擅长-偏头痛」那推理路径就是病人说「头痛」→ 图谱里找到「头痛」节点 → 沿「属于」关系找到「偏头痛」或「紧张性头痛」类疾病 → 沿「擅长」关系找到医生。这段路径如果只靠 BERT 做文本匹配是找不出来的文本匹配只能算出「头痛」和哪个科室描述更像做不到多跳推理。设计本体时还有个常见误用把「医生」的属性全部塞进关系里比如「张医生-评分80-推荐」这种三元组这其实应该存在医生节点的属性上而不是关系类型。图谱的查询性能跟关系种类的数量强相关关系种类堆得越多多跳查询的路径组合越复杂性能越差设计原则是关系宁少勿多能用属性表达的不要建关系。4.2 构建脚本从 CSV 三元组到 Neo4j 的 import 命令Neo4j 是最常用的图数据库社区版够用。导入方式有两种小数据量用 Cypher 的LOAD CSV大数据量用neo4j-admin import。这个项目的数据规模一般不大LOAD CSV够用而且方便做转换。先准备 CSV 文件entities.csv里每行是一个节点字段是id, label, name, propertiesrelations.csv里每行是一个关系字段是start_id, end_id, type。# entities.csv id:ID,label,name E001,疾病,偏头痛 E002,症状,头痛 E003,科室,神经内科 E004,医生,张医生# relations.csv :START_ID,:END_ID,:TYPE E002,E001,属于 E001,E003,属于 E004,E001,擅长导入命令如下在 Neo4j 的 bin 目录下执行neo4j-admin import \ --databasemedical_kg.db \ --nodesentities.csv \ --relationshipsrelations.csv \ --delimiter, \ --id-typeSTRING逻辑说明neo4j-admin import是批量导入工具要求所有节点和关系分别放在一个文件里且节点文件里必须有一个:ID列关系文件里必须有:START_ID和:END_ID。--id-typeSTRING表示 ID 是字符串而不是整数这样 E001 这种带前缀的 ID 才能被识别。导入完成后需要重启 Neo4j 并切换数据库注意这个命令只能初始化新数据库对已有数据的库执行会覆盖操作前备份是必须的。如果你是第一次搭环境建议直接用 Neo4j Desktop图形界面里创建数据库后把 CSV 放在 import 目录里然后打开 Browser 执行LOAD CSV WITH HEADERS FROM file:///entities.csv AS row CREATE (n:Entity {id: row.id, label: row.label, name: row.name});这段 Cypher 的作用是逐行读取 CSV 并创建节点语法直观适合验证数据格式。区别是LOAD CSV慢几万条数据还能接受几十万条建议走admin import先本地跑通再决定。4.3 推荐查询基于 Cypher 的多跳匹配和排序图谱建好后推荐逻辑本质是一条带条件的多跳查询。假设前端传来一个实体链接的结果「头痛」推荐系统要完成两步先用 Cypher 查出所有关联医生再按医生画像属性排序。一个标准查询长这样MATCH (s:Symptom {name: 头痛})-[:属于]-(d:Disease)-[:擅长]-(doc:Doctor) RETURN doc.name AS doctor_name, doc.department AS department, doc.score AS score, d.name AS disease, s.name AS symptom ORDER BY doc.score DESC LIMIT 10;这段查询的路径是「症状 → 疾病 → 医生」两跳。如果你想让推荐结果更有可解释性可以在返回里加上疾病名称和症状名称这样界面上可以展示「你可能是XX推荐你看YY医生」。参数说明ORDER BY doc.score DESC用的是医生评分属性按业务需要可以换成职称权重乘以接诊量的加权分LIMIT 10是返回条数上限一般 5 到 10 条就够了过多反而让用户选择困难。实际生产里实体链接的结果可能有多个候选比如「头痛」可能同时匹配症状节点「头痛」和疾病节点「偏头痛」这时推荐系统要先把所有候选实体取出来再做一次图谱查询合并结果。这个逻辑不要写在 Cypher 里写在 Python 服务层更清晰后面章节会给出服务代码。5. 避坑/常见问题从数据标注到模型推理的十个血泪经验5.1 标注不一致导致 CRF 学到错误转移规则现象模型训练完成后验证集 F1 值在 70% 左右就上不去了打印预测结果发现所有「B-疾病」后面的词都被预测成 O而不是「I-疾病」。原因数据标注时有人把「偏头痛」整体标为 B-疾病有人标成 B-疾病 I-疾病 I-疾病还有人只标了「偏」字。CRF 的转移矩阵学到的是「I 很少出现在 B 后面」这种统计规律但真实标注规则是「I 必须出现在 B 后面」统计规律压过了规则解码时就把正确的内部连续实体拆断了。解决训练前写一个校验脚本扫描所有标注序列检查每个 I 标签是否紧跟合法的 B 标签不合法的直接报错并输出文件名和行号。数据量小就手动修正数据量大就写规则自动把「B-疾病 O 疾病」这种错误序列里 O 改成 I。这个预处理校验比模型调参重要得多强烈建议放进入口流程。5.2 BERT 模型下载失败或加载缓慢现象在服务器上运行时BertModel.from_pretrained(bert-base-chinese)卡住不动或者直接报连接超时。原因国内网络环境下 HuggingFace 下载不稳定尤其是首次运行要下载约 400MB 的模型文件公司内网经常屏蔽外部域名。解决先用本机浏览器或下载工具手动下载三个文件config.json、pytorch_model.bin、vocab.txt放到项目目录./bert-base-chinese/下然后代码里改成加载本地路径self.bert BertModel.from_pretrained(./bert-base-chinese/)注意文件目录结构要严格匹配pytorch_model.bin不能改名vocab.txt缺失会导致 tokenizer 初始化失败。如果你用的是hfl/chinese-bert-wwm-ext加载方式一样。另外下载时校验一下文件大小如果下载到一半中断加载时会报 unexpected EOF删掉重下即可。5.3 BiLSTM 在长文本上丢失实体边界信息现象短句测试效果还行但病人主诉超过 50 个字时症状实体经常只预测出一半。原因BiLSTM 是顺序模型处理长序列时前面的信息经过多层传递后会衰减尤其当 BERT 对这种长句的注意力分布已经分散时LSTM 的隐状态里症状边界信息会被稀释。解决第一个办法是限制输入长度截断到 128 个 token主诉文本本身不需要全文引入第二个办法是在 BERT 输出后加一层LayerNorm再进 LSTM让特征分布稳定些第三个办法是数据层面训练时把长句子按标点符号切分成短句比如按句号、分号切分保证每段是独立完整的主诉描述。实际业务里第二种和第三种配合用效果提升比换模型明显。5.4 CRF 解码结果全是 O 标签现象模型训练了好几个 epochloss 也降了但预测任何句子都返回全 O。原因训练数据里 O 标签占比过高模型收敛到「全预测 O」这个局部最优解因为这样 loss 也不会太大尤其在 CRF 的转移矩阵初始化不当的时候CRF 学到的转移概率全偏向 O 标签。解决检查训练数据中正样本含实体的句子和负样本没有实体的比例控制在 8:2 以上不要拿大量无实体句子做训练另外把 CRF 初始转移矩阵里「从 O 转移到 O」的值调低一些比如在torchcrf里对转移矩阵加一个很小的负偏置。再不行就把dropout从 0.5 降到 0.2让模型更有拟合能力这个坑我遇到四次基本都是数据问题而不是结构问题。5.5 PyTorch 版本兼容性导致 CRF 层不可用现象使用torchcrf库时报错AttributeError: module torch has no attribute bool或者 forward 时维度对不上。原因torchcrf是老库对最新 PyTorch 2.x 的某些 API 兼容不好尤其mask参数要求torch.bool类型而某些版本默认返回torch.uint8。解决在传入 mask 之前显式转换mask attention_mask.bool()如果还报错改用pytorch-crf这个维护更积极的库接口基本一样导入语句改成from pytorch_crf import CRF。还是不行就锁版本pip install torch1.13.1 torchcrf0.1.0这个组合在大多数环境里稳定。这类兼容性坑很难靠看报错迅速定位建议在项目 README 里注明依赖版本环境重建时能少花半天时间。6. 进阶技巧用 FastAPI 把推荐模型包成一个可调试的本地服务到这里模型训练完了图谱也建好了最后一步是让它能够被业务系统调用。我一般用 FastAPI 起一个轻量服务把实体识别和推荐查询串成一个接口这样前端传一段主诉文本进来后端返回推荐医生列表和推荐理由。这个服务本身还能当调试工具用接口设计得好不好直接决定你后续调参和测试效率。from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class Query(BaseModel): text: str top_k: int 5 app.post(/recommend) def recommend(query: Query): # 1. 实体识别拿到所有症状和疾病实体 tokens, labels ner_model.predict(query.text) entities extract_entities(tokens, labels) # 2. 实体链接把文本实体映射到知识图谱节点 graph_entities link_entities_to_graph(entities) # 3. 图谱查询多跳匹配推荐医生 results query_recommendations(graph_entities, top_kquery.top_k) return {entities: entities, recommendations: results}这段服务的核心价值是把三个步骤拆成独立函数哪个环节出问题可以单独调试。实体识别模型在内存里加载一次不要在接口内部反复from_pretrained否则每次请求都要重新加载 BERT响应时间直接飙到秒级。link_entities_to_graph是实体链接函数要做模糊匹配比如模型输出「头疼」而图谱里是「头痛」需要加一个同义词表或编辑距离匹配。query_recommendations就是上一章的 Cypher 查询封装注意 Neo4j 连接要用连接池驱动neo4j官方 Python 驱动自带连接池每次请求不要新建连接。关于服务层参数调优top_k默认返回 5 条实际推荐系统里我建议返回 10 条然后前端做一个「相关理由」展示下拉框把每条推荐的路径显示出来比如「头痛→偏头痛→张医生擅长偏头痛」这样用户认可度高很多。实体识别模型如果预测出多个科室相关的实体推荐结果要把多个查询结果先合并再排序合并时要给不同路径的得分加权重比如症状实体匹配得分 0.6、疾病实体匹配得分 0.4权重参数也建议暴露在接口里方便调试。最后说一个我的个人习惯对所有接口做回归测试更要在每次改模型或图谱结构后拿固定的 20 条典型主诉文本跑一遍输出对比新旧结果是否有异常变化。这套系统的黑匣子不只 BERT 和 CRF知识图谱的本体变更也会悄悄改变推荐路径。护住这套回归用例能让你少踩一半以上的坑希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站