ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

BERT+BiLSTM+CRF联合抽取构建医学知识图谱推荐系统

BERT+BiLSTM+CRF联合抽取构建医学知识图谱推荐系统

简介:本资源是一套高完成度的计算机专业毕业设计项目,面向正在开展毕设、课程设计或期末大作业的学生及知识图谱与NLP方向的学习者,聚焦于医疗领域医生智能推荐问题,融合BERT预训练、BiLSTM序列建模与CRF解码技术构建命名实体识别模块,并基于抽取结果构建疾病-医生知识图谱实现个性化推荐。压缩包共114个文件,含37个核心Python源码(涵盖数据预处理、模型训练、图谱构建与Web接口)、10个JSON配置与结构化数据、18个XML配置及日志、8个PNG/2个JPG可视化图表,以及HTML前端页面、CSV医疗数据集(如disease_gaoxueya.csv、doctors_gaoxueya.csv)等,整体40.42MB,结构清晰、模块解耦明确。已有115人学习下载,提供完整可运行代码、详细文档说明、调试通过的环境配置方案及典型医疗场景数据支撑,开箱即用,显著降低毕设复现门槛与排错成本。

1. 这不是又一个“医生推荐”Demo:它用BERT+CRF+BiLSTM三路联合做实体关系联合抽取,再喂给知识图谱做可解释推荐——毕设答辩时老师盯着问了12分钟推理链

你肯定见过那种“输入症状→返回医生列表”的毕设系统,点开源码一看,后端是if-else规则匹配,前端是Bootstrap套壳,知识图谱?那张Neo4j截图还是从官网Ctrl+C过来的。但这次不一样:这个98分毕业设计真把“知识图谱驱动推荐”落到了实处——它不靠人工写规则,而是用BERT编码医学文本语义,用BiLSTM捕获上下文依赖,再用CRF层强制约束实体边界(比如“高血压3级很高危”必须整体识别为一个疾病实体,不能拆成“高血压”和“3级很高危”),三者联合训练出一个端到端的医学命名实体识别与关系抽取模型;抽出来的(疾病,科室,医生,擅长领域,治疗方案)五元组,自动构建成Neo4j图谱;最后的推荐逻辑不是简单查科室,而是走图遍历:从用户输入的“头晕+视物模糊+血压160/100”,先定位到“高血压3级很高危”节点,再沿“所属科室→关联医生→历史患者评价→出诊时间”多跳路径聚合权重,输出带理由的医生排序。某高校计算机系导师反馈:这是近三年看到的、唯一一个在答辩环节被连续追问“关系抽取怎么避免嵌套实体漏标”“图谱更新如何保证实时性”的毕设项目。适合正在卡在“知识图谱怎么和NLP模型联动”这一关的毕设党,也适合想补全“从文本到图谱再到推荐”全链路实战的算法初学者。


2. 模型结构不是堆叠名词:为什么必须BERT+BiLSTM+CRF三者缺一不可?

2.1 BERT负责语义锚定:解决医学术语歧义与缩写泛化

医学文本里,“ACEI”“ARB”“CCB”全是缩写,“心衰”可能指“心力衰竭”或“心脏衰竭”,单纯用词典匹配会崩。BERT在这里不是当特征提取器用,而是作为整个序列标注任务的底层编码器。项目中加载的是bert-base-chinese,但关键改造在model.py第87行:对[CLS]位置的输出做了特殊处理——它不参与CRF解码,而是单独接一个全连接层,用于预测该句是否含有效疾病实体(二分类)。这个设计解决了真实场景中的噪声过滤问题:门诊记录里大量“无明显不适”“建议随访”等非诊断句,BERT的[CLS]分类器先筛掉,再让BiLSTM-CRF专注处理高价值句子。参数上,max_length=128是硬性限制,因为超过这个长度,BERT的注意力机制会截断长距离依赖,而高血压病历里常出现“患者有高血压病史10年,伴糖尿病5年,2年前行冠脉搭桥术……”这种超长句,所以预处理脚本preprocess.py里做了滑动窗口切分(步长64),并保留窗口重叠部分的实体标签一致性校验逻辑。

2.2 BiLSTM建模上下文依赖:捕捉“高血压”和“3级很高危”的绑定关系

纯BERT对局部依存建模偏弱。比如“继发性高血压”中,“继发性”修饰“高血压”,但BERT的自注意力可能更关注“高血压”与句尾“需排查嗜铬细胞瘤”的远距离关联。BiLSTM层(双向各128维隐藏层)就补上了这个缺口:前向LSTM记住“继发性→”的修饰方向,后向LSTM记住“→高血压”的被修饰对象,二者拼接后,模型能明确学到“继发性”必须和紧邻的疾病名组成复合实体。代码里体现在model.py的BiLSTMEncoder类:num_layers=1是刻意为之——层数加到2以上,梯度消失会导致“继发性”这类前置修饰词的隐状态衰减严重;而dropout=0.3则放在LSTM输出后(非内部),防止过拟合到训练集里高频出现的“原发性高血压”样本。这里有个血泪经验:某同学把dropout加在LSTM内部,结果模型在验证集上F1值虚高2.3%,但一跑真实病历就崩,原因就是内部dropout破坏了时序状态传递的稳定性。

2.3 CRF层强制结构约束:杜绝“高血压3级”被拆成两个实体

没有CRF的序列标注,就像没装ABS的汽车——模型可能输出“高/B-DISEASE,血/I-DISEASE,压/I-DISEASE,3/B-LEVEL,级/I-LEVEL”,这完全违反医学实体规范。CRF通过定义转移分数矩阵,显式禁止“B-DISEASE→I-LEVEL”这类非法转移。项目中CRF实现基于torchcrf库,但关键修改在crf.py第42行:手动冻结了B-DISEASE→B-SYMPTOM的转移分数(设为-1e4),因为临床中“高血压”和“头痛”是共现关系,不是嵌套关系。另外,CRF的pad_idx必须严格对应数据集里的填充标签ID(项目中是0),否则训练时会把填充位也计入损失——这是新手翻车最高频的坑,现象是loss降不下去,原因就是CRF在算log_sum_exp时把padding当成了有效转移路径。

2.4 三者联合训练的Loss设计:不是简单加权,而是分阶段收敛

整个模型的loss不是α*BERT_loss + β*BiLSTM_loss + γ*CRF_loss,而是单目标CRF loss主导,但BERT和BiLSTM的梯度回传受CRF约束。具体实现见trainer.py的compute_loss()函数:先用CRF的forward_alg算所有路径总分,再用score_sentence算真实路径分,二者相减得loss。但反向传播时,BERT和BiLSTM的参数更新量会被动态缩放——当CRF层的转移矩阵标准差<0.05时(说明模型已学会稳定约束),自动将BERT学习率降低至原来的0.3倍,逼它专注微调语义表征而非强行改变结构。这个机制让模型在第32个epoch后进入稳定收敛,比纯CRF baseline快17个epoch。


3. 知识图谱构建不是导出CSV再导入:从抽取结果到Neo4j的四步原子化落地

3.1 实体标准化:用规则引擎兜底BERT的医学术语幻觉

BERT抽取的“高血丫”“高血呀”“高血症”等错别字,不能直接进图谱。项目用entity_normalizer.py做二级校验:先查内置医学术语库(data/medical_terms.json),匹配失败时启动编辑距离+拼音相似度双阈值过滤(编辑距离≤2且拼音相似度≥0.85才接受)。例如“高血丫”→拼音“gaoxueya”与“高血压”拼音“gaoxueya”完全一致,直接修正;而“高血症”→拼音“gaoxuezheng”与“高血压”差异过大,触发人工审核队列(存入data/manual_review.csv)。这个设计让实体准确率从89.2%提升到97.6%,代价是增加0.8秒/千条的处理延迟——但毕设场景可接受,毕竟图谱构建是一次性离线任务。

3.2 关系三元组生成:用依存句法树约束关系方向性

光有(疾病,医生)不够,必须明确(疾病,-主治→,医生)。项目用ltp工具包解析病历句法树,关键逻辑在relation_extractor.py:对每个被BERT标记为“疾病”的token,向上遍历依存树找最近的动词节点(如“由…主治”“由…负责”),再根据动词的依存关系类型(如ATT表示定中关系,ADV表示状中关系)确定关系方向。例如“张医生主治高血压”中,“主治”是核心动词,“张医生”是主语(SBV关系),“高血压”是宾语(VOB关系),于是生成三元组(高血压,-主治→,张医生)。这里有个玄学细节:ltp对“王教授擅长治疗高血压”这类句式会把“擅长”误判为主谓关系,导致关系方向颠倒。解决方案是在relation_extractor.py第156行加了后处理规则——当动词为“擅长”“专长”“精于”时,强制将宾语作为关系起点,主语作为终点。

3.3 Neo4j批量导入:用cypher+batch commit规避内存爆炸

直接用py2neo逐条创建节点会慢到崩溃。项目采用neo4j-driver的session.run()配合UNWIND批量插入。核心脚本graph_builder.py生成的cypher语句类似:

UNWIND $nodes AS node CREATE (n:Doctor {name: node.name, title: node.title, dept: node.dept}) WITH n, node UNWIND node.expertise AS exp MATCH (d:Disease {name: exp}) CREATE (n)-[:TREATS]->(d)

其中$nodes是Python构造的字典列表,每批500条。注意WITH子句的使用——它把节点创建和关系建立拆成两步,避免单条cypher过长。实测表明,批大小设为500时吞吐量最高(1270节点/秒);设为1000时Neo4j GC压力剧增,CPU占用率飙升至92%;设为100则网络往返开销占比过大。这个参数值是某开发者在32G内存服务器上暴力测试27次得出的最优解。

3.4 图谱质量校验:用Cypher查询模板自动检测常见缺陷

导入后不校验=埋雷。项目附带graph_validator.py,执行5类预设查询:

  • MATCH (d:Disease) WHERE NOT (d)-[:BELONGS_TO]->(:Department) RETURN d.name→ 检查疾病是否都挂载科室
  • MATCH (d:Doctor)-[r:TREATS]->(dis:Disease) WHERE r.confidence < 0.6 RETURN d.name, dis.name→ 检查低置信度关系
  • MATCH (d:Doctor) WHERE size((d)-[:TREATS]->()) = 0 RETURN d.name→ 检查零关系医生(可能是抽取失败)
  • MATCH (d:Disease) WHERE size((d)<-[:TREATS]-()) = 0 RETURN d.name→ 检查零覆盖疾病(可能是冷门病)
  • MATCH (d:Doctor) WHERE d.title CONTAINS "主任" AND NOT (d)-[:LEADS]->(:Department) RETURN d.name→ 检查主任医师未挂科室

每次构建图谱后运行此脚本,生成validation_report.md,把问题节点导出为CSV供人工复核。这是某导师要求的硬性交付物,也是答辩时展示工程严谨性的关键证据。


4. 推荐模块不是KNN查表:基于图神经网络的多跳路径权重聚合

4.1 用户Query向量化:不用BERT全文编码,而用实体关键词加权

用户输入“头晕+视物模糊+血压160/100”,如果直接用BERT编码整句,会混入大量无关信息(如“今天天气不错”)。项目采用轻量级方案:先用训练好的NER模型抽取出实体(头晕/B-SYMPTOM,视物模糊/B-SYMPTOM,血压160/100/B-EXAM),再查data/symptom_embedding.npz(预训练好的症状词向量),对每个实体向量按TF-IDF加权求和。query_encoder.py里关键代码:

def encode_query(query_str): entities = ner_model.predict(query_str) # 返回[(text, label), ...] vecs = [] for ent_text, ent_label in entities: if ent_label in ['SYMPTOM', 'DISEASE', 'EXAM']: # 从npz文件加载预训练向量,未命中则用fasttext兜底 vec = load_embedding(ent_text) or fasttext_model[ent_text] # TF-IDF权重:症状词在训练集文档频率越低,权重越高 weight = 1.0 / (train_corpus_freq.get(ent_text, 1) + 1) vecs.append(vec * weight) return np.mean(vecs, axis=0) if vecs else np.zeros(768)

这个设计让Query向量聚焦在临床关键信息上,比全文BERT编码的MRR@10提升11.3%。

4.2 多跳路径发现:用Cypher的variable-length pattern matching替代图遍历

传统GNN需要把整个图载入内存,但毕设环境不可能。项目用Neo4j原生能力:MATCH p=(q:Query)-[*1..3]-(d:Doctor) WHERE q.id=$qid RETURN p, length(p)。重点在[*1..3]——限定最多3跳,因为临床路径存在强领域约束:Query→Disease→Department→Doctor(3跳)是合理路径;Query→Disease→Drug→Doctor(4跳)则引入噪音。length(p)用于后续排序,跳数越少路径越直接。实测表明,限定3跳使平均响应时间从2.1s降至0.38s,且召回率仅下降0.7%(因真实场景中>3跳的优质路径极少)。

4.3 路径权重聚合:用可学习的Attention机制融合多源信号

每条路径p包含多个节点和关系,项目定义路径得分公式:
Score(p) = Σ w_i * f_i(p)
其中f_i(p)是路径特征函数,w_i是可学习权重。recommender.py实现的5类特征:

特征类型计算方式示例
跳数衰减0.9^length(p)2跳路径权重=0.81,3跳=0.729
关系置信度avg(r.confidence for r in p.relationships)“主治”关系置信度0.95 > “参与会诊”0.62
医生热度log(1 + d.patient_count)接诊量1000人→log1001≈3.0
科室匹配度cosine_sim(q_vec, dept_vec)Query向量与科室向量余弦相似度
时间新鲜度1/(1 + days_since_last_update)该医生信息更新于3天前→1/4=0.25

权重w_i在train_recommender.py中用1000条人工标注的Query-Doctor对训练,最终收敛到[0.22, 0.31, 0.18, 0.20, 0.09],证明关系置信度和跳数衰减是核心因子。

4.4 可解释性输出:自动生成推荐理由的模板引擎

答辩时老师最看重“为什么推这个医生”。项目用规则模板+抽取结果生成自然语言理由:

def generate_reason(path): disease_node = [n for n in path.nodes if n.has_label('Disease')][0] doctor_node = [n for n in path.nodes if n.has_label('Doctor')][0] dept_rel = [r for r in path.relationships if r.type == 'BELONGS_TO'][0] treat_rel = [r for r in path.relationships if r.type == 'TREATS'][0] # 模板库 templates = [ "该医生在{dept}科室,主治{disease},与您的症状高度匹配", "您描述的症状指向{disease},{doctor}医生在该领域有{years}年临床经验", "根据{disease}的诊疗指南,推荐{dept}科室的{doctor}医生" ] return random.choice(templates).format( dept=dept_rel['name'], disease=disease_node['name'], doctor=doctor_node['name'], years=doctor_node.get('experience', 5) )

所有模板都确保引用图谱中真实存在的属性,杜绝“幻觉理由”。


5. 避坑:这五个坑踩过三个,你的毕设就大概率卡在答辩前夜

5.1 现象:训练时CRF loss不下降,始终在12.5左右震荡

原因:数据集中存在大量“O”标签(非实体)样本,而CRF的allowed_transitions未禁用O→B-*以外的转移。默认CRF允许任意标签间转移,导致模型学会永远预测“O”来最小化loss。
解决:在crf.py初始化时显式定义合法转移:

allowed_transitions = [ ('O', 'O'), ('O', 'B-DISEASE'), ('O', 'B-SYMPTOM'), ('B-DISEASE', 'I-DISEASE'), ('I-DISEASE', 'I-DISEASE'), ('B-SYMPTOM', 'I-SYMPTOM'), ('I-SYMPTOM', 'I-SYMPTOM'), # ... 其他实体类型 ] self.crf = CRF(num_tags=len(tag_to_ix), allowed_transitions=allowed_transitions)

5.2 现象:Neo4j导入后查询超时,MATCH (d:Doctor) RETURN count(d)要等2分钟

原因:未在Doctor和Disease节点的name属性上创建索引。Neo4j默认不建索引,全表扫描百万级节点必然超时。
解决:导入数据后立即执行:

CREATE INDEX doctor_name_index ON :Doctor(name); CREATE INDEX disease_name_index ON :Disease(name); CREATE INDEX symptom_name_index ON :Symptom(name);

执行后查询速度从127s降至0.02s。这个操作必须在graph_builder.py最后一步调用,否则后续所有推荐查询都失效。

5.3 现象:用户输入“高血压”,推荐列表里出现一堆心内科医生,但没有肾内科医生(高血压肾病需肾内就诊)

原因:实体关系抽取时,“高血压肾病”被错误拆分为“高血压”和“肾病”两个独立疾病,导致图谱中缺失“高血压肾病→肾内科”的边。
解决:在preprocess.py中增强嵌套实体识别逻辑:

# 对每个被识别的DISEASE实体,检查其前后2个字符是否构成复合词前缀 if entity.text.startswith(('高血压', '糖尿病', '冠心病')) and len(entity.text) > 4: # 尝试合并后缀:“高血压肾病”、“糖尿病足” merged = entity.text[:4] + next_word # next_word从分词结果获取 if merged in medical_term_dict: # 查预置复合词库 entity.text = merged entity.label = 'B-DISEASE_COMPOUND'

5.4 现象:pip install -r requirements.txt报错torch 1.13.1 requires typing-extensions>=4.2.0,但系统已装4.5.0

原因:项目requirements.txt中typing-extensions==4.2.0版本锁死,与新torch冲突。
解决:删除requirements.txt中typing-extensions行,改用pip install torch==1.13.1+cpu torchvision==0.14.1+cpu -f https://download.pytorch.org/whl/torch_stable.html安装官方编译版,它自带兼容的typing-extensions。

5.5 现象:前端页面显示“推荐医生:[]”,控制台报错Failed to load resource: the server responded with a status of 404 (Not Found)

原因:Flask后端路由/api/recommend未正确注册,因app.py第23行@app.route('/api/recommend', methods=['POST'])写成了@app.route('/api/recommend/', methods=['POST'])(多了斜杠),而前端axios请求的是无斜杠路径。
解决:统一路由风格,在app.py中所有API路由去掉末尾斜杠,并在static/js/main.js中axios baseURL设为/api,请求时用/recommend而非/recommend/。


6. 答辩加分技巧:用“图谱演化对比图”直观展示系统价值

6.1 构建两个时序图谱快照:基线版 vs 本系统版

很多同学只展示最终图谱,但老师更想看“你带来了什么改变”。我的做法是:用同一份原始病历数据(data16.csv),分别跑两个流程:

  • 基线版:仅用规则匹配(正则提取“科室:心内科”“医生:张XX”)构建图谱
  • 本系统版:用BERT+BiLSTM+CRF抽取后构建图谱

然后用Neo4j Bloom导出两个图谱的可视化快照,重点标注三类差异:

  • 节点增长:基线版有127个医生节点,本系统版有203个(新增76个,因CRF识别出“心衰专科门诊”“高血压亚专科”等细分科室下的医生)
  • 关系密度:基线版平均节点度=1.8,本系统版=4.3(因抽出了“擅长治疗”“参与多学科会诊”“发表高血压指南”等新关系类型)
  • 路径连通性:基线版中“视物模糊”到“眼科医生”需4跳(症状→疾病→科室→医生),本系统版因识别出“高血压视网膜病变”复合疾病,压缩至2跳(症状→复合疾病→眼科医生)

我把这两张图放在答辩PPT第3页,标题就叫《图谱不是静态快照,而是临床认知的演化进程》,老师当场点头说“这个视角很到位”。

6.2 设计一个“可证伪”的对比实验:让老师亲手验证推荐质量

答辩时最怕被问“你这推荐准不准?”。我的应对是准备一个Excel表格(demo/verification_sheet.xlsx),里面预置10个真实临床场景:

序号用户Query黄金标准医生(三甲医院指南推荐)本系统Top1医生是否匹配理由截图
1“孕32周,血压150/100,尿蛋白++”妇产科+高血压专科联合门诊李医生(妇产科主任,高血压亚专科)✓

演示时邀请老师随机选一行,我现场运行python demo_verify.py --query "孕32周...",10秒内输出结果并打开截图。所有10个案例中,本系统匹配率80%(黄金标准本身有20%是多学科会诊,无法单医生覆盖),而基线规则系统仅匹配30%。这个设计让抽象的“效果好”变成可触摸的证据。

6.3 把“调试过程”转化为技术亮点:展示你如何驯服CRF的随机性

CRF训练有随机性,不同seed可能F1差2个百分点。我在trainer.py里加了--seed参数,并做了10次seed遍历(1-10),记录每次的F1和loss曲线。最终在答辩PPT里放了一张图:X轴是seed值,Y轴是F1,画出波动带(±0.8%),并标注“本报告所有指标取10次均值,标准差<0.3%”。老师看到这个,立刻明白你不是运气好,而是掌握了稳定训练的方法论。后来某同学没做这个,答辩时被问“如果换台机器结果变差怎么办”,当场卡壳。

从那以后我每次做NLP项目,都强制走一遍10-seed稳定性测试,哪怕毕设只要求跑一次。因为真正的工程能力,不在于跑通,而在于知道它为什么能稳定跑通。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进