恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
医疗智能导诊:BERT+BiLSTM+CRF与知识图谱的实体识别推荐实战
首页
资讯中心
/
医疗智能导诊:BERT+BiLSTM+CRF与知识图谱的实体识别推荐实战
医疗智能导诊:BERT+BiLSTM+CRF与知识图谱的实体识别推荐实战
发布时间:2026/9/28 2:05:27
简介面向计算机专业毕业设计场景这套基于BERTCRFBiLSTM与知识图谱技术的医生推荐系统包含Python源码、文档说明与配套数据集是一套98分毕业设计系统。项目经严格调试可直接用于毕业设计、课程设计或期末大作业适合正在进行相关课题设计的学生参考也可作为自然语言处理与推荐系统方向的项目实战。压缩包共114个文件大小40.42MB以源码与数据文件为主体37个Python脚本负责模型训练与推荐逻辑XML与JSON配置/数据文件支持系统结构与实体信息存储CSV包含高血压等疾病及医生数据集另有HTML/CSS/JS前端页面、日志与模型文件等便于从数据预处理到接口展示完整研究系统架构。资源内容涉及疾病实体识别、知识图谱构建与医生推荐链路实现项目说明文档可辅助理解设计思路。目前已有114人学习使用对需要快速搭建同类系统或理解BERTCRFBiLSTM在医疗推荐场景中应用的同学具有直接参照价值。1. 这个项目为什么值得做给“挂号和找医生”装一个能读懂病历的大脑很多毕业生拿到这个题目第一反应是“三个模型叠在一起再加一个图谱是不是太卷了”。但你真去医院分诊台站半天就会发现患者说“我头晕三天视物旋转还想吐”分诊护士要在一分钟内判断出该去神经内科还是耳鼻喉科靠的是经验。你把这个经验换成BERT、BiLSTM、CRF加知识图谱就得到一套能读主诉、抽实体、查关系、最后推医生的系统。这个标题里没有一个是凑数的——BERT负责把中文句子变成机器能懂的语义向量BiLSTM负责看上下文CRF负责把标签序列捋顺知识图谱负责回答“头晕到底关联哪些疾病、哪些医生擅长”。它能解决的实际问题很简单把自由文本的患者描述变成一条有依据的医生推荐路径。适合正在做NLP方向毕业设计、或者想完整复现“实体识别知识图谱推荐”这条链路的从业者。2. 把这条流水线拆开来看BERT、BiLSTM、CRF和知识图谱各自扛什么活2.1 为什么不直接用一个分类模型先看医生推荐系统的输入长什么样用户输入是“医生我发烧咳嗽三天胸口还有点闷”。这是一个自由文本不是结构化字段。如果你直接拿整个句子做多分类输出只能是“呼吸内科”这个科室标签但推荐系统最终要给的是“哪位医生”。同样是呼吸内科有的医生主攻哮喘有的主攻慢阻肺有的擅长支气管镜。科室分类回答不了这个粒度的问题。所以这条流水线的第一步不是分类而是抽取。从主诉里把“发烧”“咳嗽”“胸闷”这种症状实体抽出来再把可能提到的“高血压”“糖尿病”这种疾病实体抽出来。实体抽取完成之后你手里就有了结构化的事实患者描述里有症状A、症状B、疾病C。后面所有推荐逻辑都建立在实体之上而不是建立在原始句子之上。这也是为什么这个题目把NER放在最前面——没有实体知识图谱无从谈起推荐就是空中楼阁。2.2 BERT、BiLSTM、CRF的职责分配谁做特征、谁做上下文、谁做约束三个模型叠在一起很多人第一反应是“堆技术”。其实它们在流水线上各司其职。BERT承担的是语义特征抽取。它把句子里的每个字转换成动态向量这个向量会根据上下文变化。“出血”的“血”和“血压”的“血”在BERT眼里是两种语义。中文医疗文本里一词多义、简称、口语化描述特别多用一个静态的词向量去处理根本不行。BERT最后一层输出的hidden states就是每个字在当前语境下的特征向量。BiLSTM承接的是序列建模。BERT已经给了每个字一个向量但序列标注任务还要看字与字之间的时序依赖。“反复头晕”和“头晕反复”词的顺序意味着不同的实体边界。LSTM能建模这种顺序关系双向结构让模型同时看到当前字左边和右边的信息。这一步的直观作用是把“病”前面的“心脏”和“糖尿”区分开前者大概率是器官后者是疾病实体的组成部分。CRF做的是标签解码约束。BiLSTM的每个输出位是独立的——它不知道“B-疾病后面不能直接跟I-药物”这种规则也不管一条合法实体标签序列到底是什么样。CRF把整个标签序列当作一个整体来打分学习标签之间的转移概率最后用维特比解码找出全局最优路径。没有CRF模型经常输出“B-症状 I-症状 B-疾病”这种断断续续的非法标签串。实际项目里最常见的串法是BERT输出hidden_states经过一个线性层把维度映射到标签空间然后喂给BiLSTMBiLSTM的输出再经过CRF解码。有人把线性层放在BiLSTM后面两种都能收敛但线性层放中间可以让BiLSTM学到的是“标签空间的特征”而不是降维后的向量效果更稳。2.3 知识图谱在这个系统里的位置它不是花瓶是检索骨架NER输出的是离散的实体词“头晕”“视物旋转”“高血压”。但这些词之间没有关系系统依旧不知道“头晕”跟“高血压”之间存在“症状指向疾病”的语义连接也不知道哪位医生擅长处理这种组合。知识图谱在这里承担两个作用。第一是语义连接通过预定义的实体关系把症状、疾病、药品、科室、医生这些原本孤立的词连成一张网。第二是推理路径推荐结果必须能被解释。你不能只输出“建议张医生”要让评审或者患者看到一条路径——“头晕”指向“高血压”高血压的诊疗科室是心内科心内科里张三医生主攻高血压方向所以推张三。这条路径本身就是知识图谱上的一个子图查询。图谱里不存原始病历文本只存抽取出来的实体和关系。所以图谱的质量取决于NER的质量也取决于本体设计的完整程度。先想清楚“有哪些实体类型、有哪些关系类型”再谈建库这是做知识图谱的铁律。3. 从零跑通实体识别数据准备、模型训练和参数设定3.1 数据从哪来公开医疗NER数据集与自建标注的取舍做医疗NER数据是第一个坎。公开数据集里中文医疗实体识别比较常用的是CMeEE这类竞赛发布的数据集包含疾病、症状、药物、检查、科室等常见实体类型拿来练手没问题。但数据分布和真实主诉有差异——公开数据集里句子相对完整而真实患者主诉往往是短句、省略句、夹杂方言。我一般建议“公开数据集打底、自建数据补偏”。用Doccano这类标注工具自己标两三百条真实主诉重点标那些公开数据里少的口语化表达比如“脑袋嗡嗡的”“心慌气短”。自建数据不需要多但一定要让验证集里出现这些口语样本否则训练出来模型在真实输入面前会立刻翻车。文件命名上单条数据一行JSON或者一行“字标签”都行。我习惯用BIO格式的纯文本文件每行一个字加一个标签空行分隔句子。这样后续接任何模型框架都不用二次转换。3.2 标签体系与BIO格式让模型知道“头晕”是一个完整症状实体类型定了四类症状、疾病、药物、科室。标签体系用BIO三标注B表示实体开始I表示实体内部O表示非实体。注意别一上来就搞BIOES五标注数据集小的时候BIOES的标签更多、训练更难收敛BIO够用。一个句子标注完大概是这样的效果我 O 头 B-症状 晕 I-症状 三 O 天 O O 测 O 血 B-检查 压 I-检查这里“头晕”被标成一个完整症状实体。模型预测的时候每个字输出一个标签概率分布。训练时预测标签序列和真实标签序列做CRF损失计算推理的时候用CRF解码。所以标签体系的设计直接决定模型输出质量一堆同义实体“头晕”“头昏”“眩晕”在标注时建议合并成一个标准词后续图谱查询会省大量事。我在项目里维护了一个小词典标注时凡是“头昏”“脑袋沉”都归一化成“头晕”。3.3 BERTBiLSTMCRF模型骨架一份可以直接改的训练代码模型主体不建议自己从零写基于PyTorch和transformers搭骨架代码量能控制在六十行以内。下面这份代码是我在项目里用过的结构关键注释都标了。import torch import torch.nn as nn from transformers import AutoModel, AutoTokenizer from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_name, num_tags, lstm_hidden256, dropout0.5): super().__init__() # bert_name 填本地模型目录或 transformers 支持的模型名 self.bert AutoModel.from_pretrained(bert_name) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_sizelstm_hidden, num_layers1, batch_firstTrue, bidirectionalTrue ) # BiLSTM 双向输出维度要乘以 2 self.fc nn.Linear(lstm_hidden * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue) self.dropout nn.Dropout(dropout) def forward(self, input_ids, attention_mask, labelsNone): # BERT 最后一层输出形状 (batch, seq_len, hidden_size) outputs self.bert(input_idsinput_ids, attention_maskattention_mask) sequence_output outputs.last_hidden_state lstm_out, _ self.bilstm(sequence_output) lstm_out self.dropout(lstm_out) emissions self.fc(lstm_out) # 每个 token 的标签得分 if labels is not None: # CRF 返回负对数似然损失 loss -self.crf(emissions, labels, maskattention_mask.bool()) return loss # 推理时直接维特比解码得到最优标签序列 predictions self.crf.decode(emissions, maskattention_mask.bool()) return predictions这份代码里三个细节值得说。第一attention_mask在CRF里被当作mask参数传入作用是忽略padding部分的标签预测这个很容易漏漏了以后模型的loss会去计算无效位置的标签指标乱飘。第二BiLSTM的num_layers我只设了1层层数堆到2层以上在数据量不大的时候收益很小反而训练变慢且容易过拟合。第三CRF解码返回的是每个序列的标签索引列表后面拼接实体时需要根据BIO规则把连续片段合并不能直接用原始索引输出。3.4 参数设定学习率、序列长度、梯度裁剪和验证集划分参数设定是玄学最少、经验最重的部分。BERT层的学习率一般设在2e-5到5e-5之间这个范围移动过大会破坏预训练参数BiLSTM和CRF层的学习率可以稍微大一点常见做法是给这两个模块单独设1e-3。用带warmup的AdamW优化器warmup比例0.1。序列长度max_len设成128就够了。医疗主诉一句长的一般不超过五十个字设太长会导致padding占比高、训练浪费显存。如果遇到长病历描述做滑窗截断而不是硬截断。optimizer torch.optim.AdamW([ {params: model.bert.parameters(), lr: 3e-5}, {params: model.bilstm.parameters(), lr: 1e-3}, {params: model.fc.parameters(), lr: 1e-3}, {params: model.crf.parameters(), lr: 1e-3}, ]) # 训练时每个 step 后做梯度裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)梯度裁剪max_norm我习惯设1.0到5.0。CRF的转移矩阵训练初期容易突跳不裁剪的话loss会突然变成NAN。batch_size受显存限制常规设8到32显存不够就先减序列长度而不是batch_size。验证集划分是最容易踩坑的地方。一定要按照“病人维度”划分同一个患者的多次就诊记录不能一条进训练集一条进验证集。否则模型在验证集上的F1会虚高好几个点真实场景一测就露馅。数据读进来后先按患者ID分组再随机分训练和验证。from sklearn.model_selection import GroupShuffleSplit gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) for train_idx, val_idx in gss.split(samples, groupspatient_ids): train_samples [samples[i] for i in train_idx] val_samples [samples[i] for i in val_idx]评估指标看序列级别的F1不只是实体级别的F1。序列级要求预测实体和真实实体在边界、类型上完全一致才算对边界偏了一个字都算错。这个指标才是落地能用的指标。4. 把实体关系沉进Neo4j再取出来做推荐知识图谱构建与查询4.1 本体建模先画图六类节点和五类关系就够了知识图谱设计和本体建模是很多人的噩梦因为不知道该建多少节点、多少关系。工业场景下的知识图谱设计有一条原则从最小可用集合开始不要一上来就想建全宇宙。医生推荐场景下六类节点足够用症状、疾病、药物、检查项目、科室、医生。五类关系把它定死症状指向疾病表示“头晕可能是高血压的症状”、药物治疗疾病、检查确诊疾病、科室诊疗疾病、医生擅长疾病。外加一个医生属于科室的属性关系。这套设计能覆盖绝大多数主诉到医生的推理路径患者描述出症状症状关联到疾病疾病关联到科室和医生。每类节点上带属性属性别贪多。症状节点带一个“标准词”属性疾病节点带“推荐优先级”数值属性医生节点带“职称”“接诊量”这种展示属性。属性越多导入越繁琐而且图谱可视化时容易像蜘蛛网。4.2 用py2neo把NER结果写进图数据库NER模型跑完你得到的是句子和实体列表。接下来要做的就是把实体和关系写入图数据库。常见做法是Neo4j社区版加py2neo驱动。from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) # MERGE 而不是 CREATE避免重复导入 symptom_node Node(Symptom, name头晕, std_name头晕) disease_node Node(Disease, name高血压, priority0.9) rel Relationship(symptom_node, RELATED_TO, disease_node) graph.merge(symptom_node, Symptom, name) graph.merge(disease_node, Disease, name) graph.merge(rel, RELATED_TO, name)代码里两次merge是必要的。Node类型加name属性才能保证唯一性约束不然重复跑脚本就会产生一堆重复节点。关系也走了merge幂等性更好。关系如何从文本里抽出来这是整个图谱构建最需要设计的地方。我当时用了一个笨但可靠的办法把症状和疾病的共现关系作为规则来源统计训练数据里同一个句子内出现的高频症状-疾病对。比如标注数据里“头晕”和“高血压”在同一句话出现的次数超过阈值就自动生成一条RELATED_TO关系。这个办法不需要额外训练模型基于统计就能建起一张关系网。药品、检查、科室的关系先手工补充核心几十条保证主路径是通的然后边用边补。4.3 推荐不是“搜医生”路径匹配加上语义相似度兜底医生推荐系统的核心查询是路径查询。用户输入“头晕三天视物旋转”NER抽出来两个症状实体“头晕”“视物旋转”。在Neo4j里跑一条多跳查询MATCH (s1:Symptom {name: 头晕})-[:RELATED_TO]-(d:Disease)-[:GOOD_AT]-(doc:Doctor) RETURN doc.name AS doctor, d.name AS disease, d.priority AS priority ORDER BY d.priority DESC LIMIT 5这里有一个实际场景必须处理的情况抽取出来的症状实体在图谱里查不到。症状的表达太丰富了刚建库时图谱里可能只有“头晕”用户输入是“天旋地转”NER抽出来“天旋地转”数据库查不到。这时候推荐不能直接返回空必须做兜底。兜底方案是用语义相似度找最接近的已知实体。把用户抽出的实体向量和图谱里所有实体向量做余弦相似度阈值设0.7以上就算匹配。向量可以直接复用BERT输出取这个实体所有字的向量平均图谱实体在入库时就算好向量存进节点属性。这个兜底方案不额外依赖模型效果还比字符串匹配好得多。推荐结果好不好最终要看路径通不通。一张图里如果“头晕”和“高血压”之间压根没有边那推荐系统再聪明也推不出来。所以图谱构建阶段一定要用那一批高频症状-疾病共现数据先把主干路径铺满再去追求实体数量。5. 避坑这五个地方让我的模型和推荐结果翻过车5.1 数据集泄露同一个主诉横跨训练集和验证集指标虚高现象训练时验证集F1到了90%以上觉得模型很能打。等拿真实患者主诉去测F1直接掉到70%。怎么查都查不出模型结构的问题。原因划分训练集和验证集时用的是随机划分同一个患者的多条记录、甚至相似主诉同时落进两边。模型在训练时已经看过验证集里那些句子的实体边界验证分数是被污染的。解决按患者ID做分组划分。先给每条样本打上患者ID用GroupShuffleSplit或手动分组切分。数据增强时要小心别把增强样本同时放进训练集和验证集增强版本只进训练集。5.2 BERT的字符切分吞掉专业实体现象“胃食管反流”这个疾病实体模型只预测出“胃食管”“反流”被切出去了。单独预测“反流”两个字时又经常被标成O。原因BERT用的是WordPiece级别的分词中文虽然按单字切但“胃食管反流”这四个字在预训练语料里未必总是一个连续片段。模型学习到的实体边界有时候会被切分成两个片段。再加上医疗命名实体本身就长单字级别的BiLSTM对长实体边界不敏感。解决训练数据里把长实体的样本比例调高可以在数据增强时人为构造包含长实体上下文的句子。另外推理后处理里做一个片段合并规则如果前一个实体结尾是疾病相关词下一个实体开头紧邻同句同一个实体类型就尝试合并。5.3 CRF在小数据集上转移矩阵过拟合现象训练集loss降得很漂亮验证集F1不涨反降。把CRF的转移矩阵打印出来一看某些标签转移概率直接是0比如“B-症状 - I-疾病”的概率完全没学到。原因数据量太小CRF的转移矩阵参数数量是标签数的平方几十条样本根本撑不起对转移概率的可靠估计。模型记住了训练集里少数几条路径而不是学到了一般性约束。解决给CRF转移矩阵加L2正则或者直接在训练损失里加转移矩阵的惩罚项。更强的解决方式是做标签平滑把BIO里的稀有标签合并。比如先跑一个四类型的粗粒度模型再拆细类型分步训练比一步到位稳得多。5.4 实体正确但推荐结果为空本体设计不完整现象用户输入“发烧咳嗽”NER抽得准准的“发烧”“咳嗽”两个实体都在。但图谱查询返回空推荐页面什么也不显示。原因图谱里只有“发烧”节点没有“咳嗽”节点更不用说“发烧-肺炎-呼吸内科”这条路径。NER做对了是第一步知识图谱里节点和关系的覆盖度不够才是推荐失败的主因。解决先用规则统计法把高频症状-疾病对铺进去铺多少取决于数据里共现出现的次数阈值可以设低一点。我建库时先把共现次数排前200对的关系全部自动生成图谱路径通了再手工修正错误关系。5.5 GPU显存不够用序列长度和batch_size得先算账现象训练刚开始就报CUDA out of memory或者跑着跑着突然OOM。一开始以为是模型太大后来发现是batch_size和序列长度没有配合好。原因BERTBiLSTM的显存占用随序列长度线性增长BiLSTM中间状态还额外存一份。128的序列长度配64的batch_size在消费级显卡上基本必爆。解决先把batch_size降到16再不行就降序列长度到64。如果还是不够把BiLSTM层数从2降到1hidden_size从512降到256。显存优化顺序是先降batch_size、再降序列长度、最后降隐藏维度不要一上来就砍模型层数。6. 端到端验证让模型和图谱一起接受检验6.1 20条主诉的人工核对表训练完模型、建好图谱之后最重要的就是端到端测试。我建议从真实场景里挑20条主诉覆盖常见科室、口语化表达、多症状组合这三类情况逐条跑完整流程。核对表设计成五列记录实体是否正确、图谱路径是否通、推荐是否合理。用户输入识别实体图谱路径推荐医生是否合理头晕三天视物旋转头晕、视物旋转头晕-高血压-心内科张医生合理发烧咳嗽胸闷发烧、咳嗽、胸闷咳嗽-肺炎-呼吸内科李医生合理天旋地转头还疼天旋地转归一查不到 → 语义兜底王医生需核对这个表的作用不只是验收更是给评审老师看的“可解释性证据”。系统运行过程中把每条路径都记下来评审问“为什么推荐这个医生”你直接把路径打印出来。6.2 把推荐路径打印出来让评审老师看到推理过程推荐结果不能只有医生名字要把整条推理链一并展示。我习惯在返回推荐结果的同时输出一段可读的路径说明path_text - .join([f{node[name]}({node[type]}) for node in path]) print(f患者主诉: {raw_text}) print(f抽取实体: {entities}) print(f推理路径: {path_text}) print(f推荐医生: {doctor_name}理由: 擅长治疗{path_disease}) # 示例输出: 推理路径: 天旋地转(症状) - 梅尼埃病(疾病) - 耳鼻喉科(科室) - 李医生(医生)这套输出让系统从“黑匣子”变成了“可解释推荐”这是毕业设计答辩时的加分项。模型预测部分可解释性差是公认的事实但知识图谱路径可以在推荐阶段把推理过程补回来。我自己的习惯是每次改了数据或者调了参数先跑这20条主诉看有没有新的路径断裂再去看指标数字。指标只能告诉你模型学得怎么样路径输出才能告诉你这个系统到底能不能用。希望帮到你。本文还有配套的精品资源点击获取