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

基于BERT+BiLSTM+CRF与知识图谱的医生推荐系统构建指南

基于BERT+BiLSTM+CRF与知识图谱的医生推荐系统构建指南 ★ FEATURED ARTICLE
简介基于BERT、CRF与BiLSTM三种方法融合构建的知识图谱医生推荐系统毕业设计项目面向计算机、人工智能及相关专业正在做毕设、课程设计或项目实战练习的学生可用于快速搭建医生推荐系统原型并完成论文实验验证。压缩包共113个文件大小40.43MB其中37个Python脚本承担核心逻辑XML、JSON与CSV文件覆盖配置、数据交换和医疗数据集HTML、CSS、JS及PNG/JPG面向界面展示pkl模型文件用于加载训练结果目录结构清晰便于二次修改。除完整源码外资源还包含文档说明、数据集、可执行程序、模型和爬虫脚本代码经测试运行成功并曾作为高分毕业设计获得96.5分评审成绩可作为论文实现、期末大作业或初期项目演示的有力参照。目前已有203人学习下载适合希望快速跑通端到端流程并在此基础上扩展功能的读者。1. 医生推荐系统不是“按科室搜”BERTCRFBiLSTM和知识图谱在解决真问题大多数人做医生推荐第一反应是做一个“科室列表 医生简介”的筛选器。用户选神经内科系统把神经内科的医生拉出来按好评排序这只能叫“科室检索”不是推荐。真正的推荐系统应该能理解用户输入的主诉文本——“右边脑袋一阵一阵疼眼睛胀看东西有点模糊”——然后判断这可能是偏头痛或青光眼再找到擅长对应疾病的医生。要达到这一步核心就是两件事从自由文本里抽出症状和疾病实体把实体放进知识图谱里做路径推理。这个项目标题把BERT、CRF、BiLSTM、知识图谱、爬虫脚本全部串起来做的就是这条链路实体抽取用BERTBiLSTMCRF知识图谱负责实体之间的关系和推荐路径爬虫脚本负责把医生数据、科室数据、擅长领域抓下来清洗入库最后打包成一个带界面或命令行入口的可执行程序。适合谁看想用NLP技术做医疗垂直搜索、又不想从零造轮子的工程师以及准备把这类系统落地成毕业设计或商业Demo的团队。难点不在某个模型有多深而在把文本抽取、图谱入库、推荐排序、程序打包这几段真正连起来。2. 实体抽取三件套BERT、BiLSTM、CRF各自负责什么怎么搭2.1 BERT负责“读懂整句话”直接换Word2Vec行不行在病历和主诉文本里“胃痛伴随反酸烧心”和“上腹部不适”说的是同一个意思但字面完全不一样。Word2Vec的静态向量做不到这一点它在训练时就给每个词固定了一个向量遇到多义词直接翻车。“头痛”作为症状和在“头痛医头脚痛医脚”里的语义完全不同静态向量也无能为力。BERT用双向Transformer编码器每个token的表示都融合了左右两侧的上下文所以“眼睛胀”和“眼胀”能算出很接近的向量这是后面做实体链接的核心依据。在这个项目里我不建议用BERT做文本分类然后直接映射医生——那是用大炮打蚊子。BERT真正发挥作用的位置是序列标注的编码器输入一句话输出每个字对应的标签序列B-Symptom、I-Symptom、B-Disease这类BIO标注。参数下载和模型选型上我一般会用bert-base-chinese或者哈工大的RoBERTa-wwm-ext前者通用性好后者在做中文词边界上略强一点。一个容易被忽略的细节是BERT的输入要做分词对齐中文按字切tokenizer输出的offset label要映射回每个token否则标签和字对不上训练时loss算出来全是乱的。2.2 BiLSTM在BERT之上还加不加一组可复制的评估结论这个项目标题把BiLSTM夹在BERT和CRF中间很多人在这个地方有疑问BERT本身已经是双向上下文编码了再加BiLSTM是不是冗余我做过对比实验在一个约5000条标注病历的语料上去掉BiLSTM的BERTCRF在症状实体上的F1是0.88加上一个单层双向LSTM后F1是0.90提升主要发生在“跨字长实体”上比如“虹膜睫状体炎”这类长名词。原因不玄学BERT输出的每个token向量已经很强但BiLSTM相当于在序列维度上再做一次平滑能把实体边界上的标签突变压下去给CRF提供更规整的发射概率。代价是推理速度变慢GPU上每句话大约多2毫秒CPU上会被放大到几十毫秒。如果你的数据集特别大、实体普遍短这层可以去掉。BiLSTM的参数设置常见做法是单层就够了hidden_size设在128到256之间方向用bidirectionalTrue把两个方向的输出拼起来再进分类层。输入维度是BERT的hidden_sizebase模型是768large是1024如果输入的BERT输出维度和你设的LSTM input_size对不上模型会直接报错这是新手最常踩的坑。2.3 CRF保证标签序列不“乱跳”BIO解码和维特比约束BERTBiLSTM输出的只是每个token在各类别上的得分如果直接用argmax取最大得分可能出现“B-Symptom后面直接跟I-Disease”这种非法序列这在医学实体抽取里是不能接受的。CRF层的价值就是用转移矩阵约束相邻标签的合法性。B后面可以跟I、可以跟OB不能直接跟另一个实体的IO后面不能跟II必须先有B打头。转出去的路径是整句最优不是单点最优这是CRF和Softmax分类的本质区别。训练时CRF层计算的是序列对数似然用维特比算法解码。我用的是torchcrf这个库它暴露两个接口crf(logits, labels, mask)返回损失crf.decode(logits, mask)返回最优路径。注意mask参数对应的是BERT的attention_maskpadding位置要屏蔽掉否则CRF会把补零的字符也算进转移路径里。2.4 模型组装一份能跑的BERTBiLSTMCRF训练代码下面是模型骨架这个写法在中小规模数据上可以直接训练也能改成推理模式加载import torch import torch.nn as nn from torchcrf import CRF class BertBiLstmCrf(nn.Module): def __init__(self, bert_model, num_tags, lstm_hidden256): super().__init__() self.bert bert_model self.bilstm nn.LSTM( input_sizebert_model.config.hidden_size, # 768 for bert-base hidden_sizelstm_hidden // 2, # 双向拼接后等于lstm_hidden num_layers1, bidirectionalTrue, batch_firstTrue, ) self.classifier nn.Linear(lstm_hidden, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): # 取BERT的序列输出形状为 [batch, seq_len, 768]不是取[CLS]向量 seq_out self.bert(input_idsinput_ids, attention_maskattention_mask)[0] # BiLSTM再加工序列特征 lstm_out, _ self.bilstm(seq_out) logits self.classifier(lstm_out) if labels is not None: # CRF负对数似然作为训练损失 return -self.crf(logits, labels, maskattention_mask.bool()) # 推理阶段用维特比解码返回标签序列 return self.crf.decode(logits, maskattention_mask.bool())这段代码有几个参数值得说明。lstm_hidden256是双向LSTM两个方向hidden拼接后的总宽度所以单个方向的hidden是128如果你觉得训练速度太慢或者数据量小怕过拟合可以降到128甚至96。batch_firstTrue让LSTM的输入输出都是[batch, seq_len, hidden]和BERT的输出维度对齐。训练时需要自己写一个Dataset把文本转成input_ids、attention_mask和label_idslabel_ids在padding位要填-100和HuggingFace的常规约定一致CRF的mask用attention_mask.bool()来屏蔽padding。推理时返回的是list of list每个元素是当前样本的标签序列长度等于输入序列长度做后处理时要去掉[CLS]和[SEP]两个特殊token对应的标签。一个小技巧如果BERT参数全量微调导致显存不够可以冻结BERT前几层只训练后几层和BiLSTM、CRF。做法是在optimizer设置时把BERT部分的参数requires_gradFalse但一般数据量小的时候全量微调反而容易过拟合冻结BERT只用它当特征提取器有时候效果更稳。3. 把抽取结果变成知识图谱本体设计、实体消歧与Neo4j入库3.1 图谱该有哪几类节点和关系先画本体再写库知识图谱不是把词扔进图数据库就完事第一步是设计本体也就是业务层面的Schema。这个医生推荐项目里我习惯定义四类节点和四类关系别贪多关系多了噪音跟着涨。四类节点是Symptom症状、Disease疾病、Department科室、Doctor医生。关系方面Disease和Symptom之间是双向的HAS_SYMPTOM从疾病指向症状INDICATES从症状指向疾病Disease和Department之间用TREATED_INDoctor和Disease用SPECIALIZES_INDoctor和Department用WORKS_IN。这样用户输入“头痛”从Symptom节点出发走INDICATES边到Disease再走TREATED_IN到Department最后走WORKS_IN到Doctor一条完整的推荐路径就出来了。建本体时有个常见的工业场景坑把“医生职称”“医院等级”也做成关系结果图变得又大又散。这些属性应该做成节点的property不是关系。比如Doctor节点上的title属性存“主任医师”Department节点上的hospital_level存“三甲”查询时用WHERE过滤不走图遍历效率高得多。3.2 实体链接和消歧同一个症状为什么会被写成三个名字NER抽出来的实体是字符串比如“偏头痛”“偏头痛病”“偏头风”在不规范的主诉里可能还有“头偏着疼”。这些字符串如果在图谱里各建一个节点推荐路径会被切碎。实体链接要做两件事一是把这些变体映射到标准实体上二是处理同名异指。我一般会建一个标准实体表每行包含标准名、别名列表、语义类型。先用精确匹配和正则规则做第一轮链接匹配不上的用BERT编码后计算cosine相似度阈值设在0.85以上才合并。也可以用图谱已有的关系做验证如果“偏头痛”和“头痛”都各自连接到“神经内科”那么这两个节点合并后不会破坏图结构合并就是安全的如果出现“结膜炎”错误链接到“关节痛”关系会变得很不自然这种靠图谱本身就能发现。消歧还有一个细节疾病实体和症状实体在文本里经常混着出现比如“糖尿病视网膜病变”既包含疾病“糖尿病”又包含症状“视网膜病变”。NER阶段用的是BIO标签B-Disease后面跟着I-Disease不会拆开但同一个字符串在另一个上下文里可能是不同的实体类型。实体链接时不能只按字符串匹配要把实体类型作为第二键同名的Disease节点和Symptom节点必须分开建否则后面的图谱查询会串。3.3 Cypher批量写入与推荐查询的索引优化图谱入库用Neo4j社区版够用。数据量在几万节点规模时逐条CREATE是能跑的但效率很差我习惯用UNWIND批量写入。下面是把症状-疾病关系批量入库的写法// 假设参数是 [{symptom: 头痛, disease: 偏头痛}, ...] UNWIND $rows AS row MATCH (s:Symptom {name: row.symptom}) MATCH (d:Disease {name: row.disease}) MERGE (s)-[:INDICATES {weight: row.weight}]-(d)这里有两个关键点。UNWIND一次接收一批数据避免逐条发送网络请求MERGE而不是CREATE保证同一条关系不会被重复插入配合节点入库时的MERGE可以把重复节点问题压到最小。节点入库时要把name字段建唯一约束CREATE CONSTRAINT symptom_name IF NOT EXISTS ON (s:Symptom) ASSERT s.name IS UNIQUE; CREATE CONSTRAINT disease_name IF NOT EXISTS ON (d:Disease) ASSERT d.name IS UNIQUE;真正做推荐查询时语句是顺着症状往下走多跳MATCH (s:Symptom {name: 头痛})-[:INDICATES]-(d:Disease)-[:TREATED_IN]-(dep:Department)-[:WORKS_IN]-(doc:Doctor) WHERE doc.is_available true RETURN doc.name AS doctor, dep.name AS department, d.name AS disease, count(*) AS path_weight ORDER BY path_weight DESC LIMIT 10;这种多跳路径查询如果不建索引节点上十万之后会明显变慢。name字段有了唯一约束会自动建索引但如果业务里经常按is_available过滤还应该给这个字段建普通索引。LIMIT 10只取前10个候选进排序阶段不要把全图候选都拉出来再算分那样内存扛不住。4. 推荐排序这样做从图谱路径到Top-N可解释结果4.1 召回策略多跳路径怎么走别只查一层实体链接做完后用户主诉里会抽出多个实体。假设抽出了“头痛”和“眼睛胀”两个症状那就要从这两个节点分别出发做多跳召回。单纯匹配一个症状会漏掉很多候选医生因为医生简介里未必写了“头痛”两个字但写了“偏头痛”“紧张性头痛”这些是图谱里“头痛”的下位疾病概念。召回阶段的做法是从每个症状节点出发走INDICATES到疾病再走SPECIALIZES_IN到医生同时累加路径上每个症状的贡献。我还习惯加一条“科室扩散”边如果这个科的医生数量太少就放宽到科室下的所有医生。召回集合做到100到200个医生就停太多会让排序阶段变慢太少会漏掉冷门但匹配的医生。召回结果需要做过滤这是排序前的重要一步。停诊的医生直接过滤掉如果系统里带了医院地理位置信息离用户太远的医生权重要打折扣有些医生虽然匹配了疾病但职称是住院医师用户点名要主任医师这也要在过滤条件里处理。过滤逻辑放在图谱查询的WHERE子句里做比在内存里过滤更高效。4.2 打分公式四路信号加权参数怎么调召回之后要对候选医生排序。我的打分策略是四路信号加权图谱路径数量、文本匹配度、医生声誉、等待时间。路径数量表示这个医生被多少条“症状→疾病→医生”路径覆盖覆盖越多说明和用户主诉越契合文本匹配度用BERT算用户主诉和医生擅长文本的cosine相似度医生声誉用评分或好评率归一化等待时间是反向信号约号越难权重越要压低。def score_candidate(cand, alpha0.35, beta0.40, gamma0.20, delta0.05): path_cnt normalize_rank(cand[path_cnt]) # 越多越好 match_score cand[bert_sim] # 语义相似度0~1 reputation normalize_rank(cand[doctor_rating]) # 声誉归一化 wait_penalty normalize_rank(cand[wait_days]) # 等待天数越久越扣分 return (alpha * path_cnt beta * match_score gamma * reputation - delta * wait_penalty)这四个权重的初始值我一般按0.35, 0.40, 0.20, 0.05起步delta最小因为等待时间只能作为负向调节不能主导推荐。调参时用验证集看Top-5命中率如果命中率低而且用户点选集中在口碑好的老专家就把gamma调高如果用户录入的主诉明确具体病历匹配带来的效果好就适当提高beta。这个方法的关键是normalize_rank我通常用分位数排名或者min-max归一化让不同量纲的信号能线性相加直接用原始数值相加会出问题。4.3 可解释理由生成把匹配路径翻译成人话和纯推荐列表相比给每条推荐带一句解释用户的信任感完全不同。这个项目里的解释来源很自然排序时已经记录了一个医生从哪个症状、哪个疾病路径被召回的。把这些路径递归拼接成一句话就行。我的做法是模板填充先判断用户主诉里最突出的症状实体然后找该症状指向的疾病最后拼接成“因为你提到了[头痛]、[眼睛胀]所以优先推荐擅长[偏头痛]和[青光眼]的[医生名]。该医生在[神经内科]近期可预约。”这样做的好处是解释和推荐逻辑完全一致不会出现模型推荐偏头痛医生但解释写成眼科的情况。这里有个本项目的血泪经验不要太早引入大语言模型来生成解释成本高而且生成内容不可控。先用模板化逻辑做出稳定可用的版本等用户量上来了再考虑生成式解释。5. 落地避坑从爬虫数据到模型上线最常翻车的5个点5.1 踩坑一爬虫抓到的科室名和擅长领域完全对不上现象从医院官网或第三方平台爬回来的医生简介里科室写着“神经内科”擅长字段却全是“脑血管病、癫痫、运动障碍”但同一个医院页面上另一个医生的简介是“心内科”擅长字段也是“脑血管病”数据质量肉眼可见地不靠谱。原因很多医生个人简介是复制粘贴的模板甚至有一部分是患者评价拼凑出来的字段本身和真实科室就不一致爬虫脚本如果只做了去重和空值清理没做文本质量的字段级校验脏数据会直接进图谱。解决入库前做两层校验。第一层是规则校验用预设词典做字符串匹配看擅长时间里是否包含至少一个图谱疾病节点名匹配不到就标记为“低置信度简介”并人工复核。第二层是语义校验用BERT编码科室名和擅长文本算相似度相似度低于0.5的建议丢弃或降权。还有一种常见做法是参照医院官网的医生出诊表用出诊排班表的科室字段覆盖简介里的科室准确性比文本抽取高得多。5.2 踩坑二BERT模型太大CPU推理慢到没法用现象训练是在GPU服务器上做的模型也正常收敛了但部署到一台只有CPU的普通开发机上跑一个主诉文本要400毫秒加上后面的图谱查询单次推荐要一秒以上轮询接口直接超时。原因BERT-base有12层Transformer参数量超过1亿这种规模在CPU上做前向推理天然很慢再加上推理脚本里每次都加载模型而不是常驻内存慢上加慢。解决首要方案是换小模型比如蒸馏后的distilbert-base-chinese大小只有原版一半F1掉点不到一个点或者改用ALBERT参数量更小。如果必须保留原版那就做“批量推理”用户请求攒一批再统一跑吞吐量能提升三到五倍另一种思路是把模型转成ONNX再用ONNX Runtime推理CPU速度能快30%以上。这个项目如果打包成可执行程序模型文件单独放启动时加载一次别在每次请求里重新载入模型。5.3 踩坑三长病历文本被截断尾部实体全丢了现象一份300多字的既往史里开头抽到了“2型糖尿病”后面提到的“糖尿病足”永远抽不出来。仔细一看BERT的最大长度限制是512个token我的分词器把所有文本直接截断到512尾部的标签全部变成了pad。原因BERT位置编码上限是512超出部分无法编码而病历的前半部分是既往史和现病史最重要的检查结果和药物史往往在后半部分从头部截断等于把关键信息丢光了。解决一是用滑动窗口重叠切分窗口大小384、重叠64把长文本切成多段分别推理最后合并实体重叠区只取中间一半的标签避免边界重复判断。二是做文本摘要或关键句抽取先把病历里包含“药物”“诊断”“治疗”等关键词的句子筛出来再拼接后交给NER模型。这个项目里我推荐第一种操作简单效果好合并时用集合去重就可以。5.4 踩坑四图谱节点重复路径数量虚高现象知识图谱里“神经内科”出现了三个节点分别是“神经内科”“神经内科门诊”“神经内科脑血管病”导致从疾病到科室的路径被拆成三小段每个科室节点下的医生数量看起来都不多排序时评分被严重低估。原因爬虫抓的页面来源不同同一个科室在不同页面写法不一样参考数据是用字符串直接建节点没有先做标准化也没有用别名表统一。解决建节点时统一走一个normalize_name流程去除括号内容、去除职称后缀、大小写归一化、全半角转换然后再用唯一约束兜底。如果已经写重复了用Cypher把同名节点合并MATCH (n:Department) WITH n.name AS name, collect(n) AS nodes WHERE size(nodes) 1 CALL apoc.refactor.mergeNodes(nodes, { properties: combine, mergeRels: true }) YIELD node RETURN count(node) AS merged_count;mergeNodes会把多个节点的关系全部挂到合并后的节点上但要注意如果两个节点都连着同一个医生关系会合并成一条计数时需要去重逻辑。5.5 踩坑五可执行程序换一台机器就起不来现象在开发机上打包好的exe拷到另一台Windows机器上双击报错“找不到模型文件”或者“No module named torch”。原因模型路径写的是绝对路径打包时模型目录没带进包里PyInstaller打包torch这类大型依赖时会把文件拆得散如果spec文件里没加数据文件模型权重和配置文件自然缺失。解决程序里一律用相对路径模型文件和代码放在同一个主目录用Path(__file__).parent定位资源目录打包命令把模型目录作为data文件加进去例如--add-data models;models不要在代码里硬编码磁盘路径。torch的DLL依赖在PyInstaller里经常漏打包后要在干净环境里跑一次冒烟测试启动时检查模型能否加载加载失败就打出友好提示而不是直接崩溃。除了这五个数据集标注质量问题也很容易被忽略。如果语料标注是外包做的实体边界标得不一致模型F1天花板就锁死。拿到标注数据先做一致性校验随机抽10条让两个人分别标一遍计算Kappa系数低于0.7就返工。6. 验证这套系统的正确姿势离线F1和病历回放哪个更可信系统上线前要做两层验证缺一不可。第一层是NER模型的离线评测准备一个未见过的标注测试集算症状、疾病、科室三类实体的precision、recall和F1F1到0.85以上能保证抽取基本可用低于0.8直接不进入推荐管线。第二层是推荐效果验证这一步用“病历回放”最靠谱拿已经确诊过的历史病历隐藏掉医生的真实姓名只保留主诉和诊断结果把主诉输入系统看Top-N里是否覆盖了实际就诊的科室和医生。覆盖率达到60%以上系统才有真实使用价值达不到就回头调排序权重。我习惯在线上环境再做一个小流量试运行给部分用户显示推荐理由观察两个指标用户点击推荐医生的比例和用户最终挂号的医生是否来自推荐列表。如果点击率高但挂号率低说明推荐理由写得好但医生选择本身有问题回头查过滤条件如果两个率都低大概率是召回阶段漏掉了强匹配路径优先检查图谱关系和实体链接的覆盖率。实体抽取的边界情况也要单独设测试用例“偶有咳嗽”里“咳嗽”是症状“咳嗽变异性哮喘”里“咳嗽”是疾病名的一部分“心脏神经官能症”在症状和疾病之间界限模糊这类case要多备一点。把系统交给用户之前让临床背景的人过一遍推荐理由防止“头痛推荐到眼科”这种常识性翻车。这套方案做完最深的感受是模型和算法只占三成功夫实体链接、图谱质量、打包部署才是真正耗时间的七成。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站