☰
基于知识图谱的高血压医生智能推荐系统设计与实现
2026/10/18 9:39:44 网站建设 项目流程

简介:本资源是一套高完成度的计算机专业毕业设计项目,面向正在开展毕设、课程设计或期末大作业的学生及知识图谱与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. 为什么医生推荐不能只靠“科室+评分”?——一套98分毕设如何用知识图谱把“高血压患者该找谁”讲清楚

很多同学做医生推荐系统,第一反应是爬医院官网、建个MySQL表存医生姓名/科室/评分/简介,再写个关键词搜索框——答辩时导师一问“张三有高血压合并糖尿病肾病,又在吃华法林,该挂心内科还是肾内科?要不要避开正在用ACEI类药的医生?”当场卡壳。这套基于BERT+CRF+BiLSTM构建知识图谱的医生推荐系统,就是为解决这个“语义断层”而生:它不把医生当静态标签堆砌,而是把“疾病-症状-用药-禁忌-医生专长-既往案例”全打碎重连成网,让推荐逻辑可追溯、可解释、可验证。项目源码已跑通全流程,含真实高血压领域数据集(disease_gaoxueya.csv、doctors_gaoxueya.csv等)、完整文档(含架构图、ER图、接口说明)、Scrapy爬虫配置(scrapy.cfg + CSS选择器),适合计算机专业学生直接用于毕设、课设或大作业。它不是玩具Demo,而是按工业级数据流设计的闭环系统:从原始网页文本→实体识别→关系抽取→图谱构建→路径推理→个性化推荐,每一步都有对应模块和可调试代码。


2. 总体架构设计:四层流水线与三个核心数据通道

整套系统采用清晰的分层架构,避免常见毕设“所有代码塞一个py文件”的翻车现场。四层分别是:数据采集层 → NLP处理层 → 图谱构建层 → 推荐服务层。其中,数据不是单向灌入,而是形成三条关键通道:①结构化通道(CSV数据集直入Neo4j);②非结构化通道(Scrapy爬取网页→BERT-CRF-BiLSTM联合识别→生成三元组);③反馈校验通道(用户点击/停留行为反哺实体权重)。这种设计让系统既有学术严谨性(可复现论文模型),又有工程落地感(能接真实数据源)。

2.1 数据采集层:Scrapy爬虫配置与领域CSS选择器实录

本项目聚焦高血压垂直领域,爬虫目标明确:某三甲医院高血压专科页面、卫健委公开指南页、权威医学论坛病例帖。scrapy.cfg中已配置好基础参数,重点在spiders/gaoxueya_spider.py的CSS选择器——这不是通用模板,而是针对高血压页面反复调试出的精准路径:

# spiders/gaoxueya_spider.py 关键片段 def parse(self, response): # 医生卡片区域(适配多版式) doctor_cards = response.css('div.doctor-card, article.doctor-profile, .doctor-item') for card in doctor_cards: item = DoctorItem() item['name'] = card.css('h3.name::text, .doctor-name::text').get(default='未知').strip() item['title'] = card.css('.title::text, .position::text').get(default='').strip() # 高血压专属字段:擅长方向显式提取 expertise_text = card.css('.expertise::text, .specialty::text').get(default='') item['hypertension_expertise'] = '高血压' in expertise_text or '高血' in expertise_text # 病例描述中的症状-用药隐含关系(为后续NER准备) case_desc = card.css('.case-desc p::text, .description::text').getall() item['raw_case_text'] = ' '.join([t.strip() for t in case_desc if t.strip()]) yield item

提示:disease_gaoxueya.csv和doctors_gaoxueya.csv是人工清洗后的高质量种子数据,用于冷启动图谱。而gaoxueya-30992.csv是Scrapy爬取的3万+条原始网页快照(含HTML片段),供NER模型训练时做上下文增强——这点常被忽略,但恰恰是提升实体识别准确率的关键。

2.2 NLP处理层:BERT+CRF+BiLSTM三模型协同的实体识别链

传统CRF或BiLSTM单独用,在医学文本上F1值常卡在82%左右。本项目采用级联策略:BiLSTM先捕获局部序列特征 → BERT提供深层语义表征 → CRF全局约束标签转移。模型输入不是原始句子,而是经医学词典增强的分词结果(使用pkuseg加载自定义高血压术语库),输出为BIOES格式标签。

# models/ner_model.py 核心结构 class BERT_BiLSTM_CRF(nn.Module): def __init__(self, bert_path, tagset_size, lstm_hidden=256, dropout=0.5): super().__init__() self.bert = BertModel.from_pretrained(bert_path) # 使用chinese-bert-wwm-ext self.lstm = nn.LSTM( input_size=768, # BERT last hidden size hidden_size=lstm_hidden, num_layers=2, batch_first=True, dropout=dropout, bidirectional=True ) self.hidden2tag = nn.Linear(lstm_hidden * 2, tagset_size) self.crf = CRF(tagset_size, batch_first=True) def forward(self, input_ids, attention_mask, tags=None): bert_out = self.bert(input_ids, attention_mask)[0] # [batch, seq_len, 768] lstm_out, _ = self.lstm(bert_out) # [batch, seq_len, 512] emissions = self.hidden2tag(lstm_out) # [batch, seq_len, tagset_size] if tags is not None: loss = -self.crf(emissions, tags, mask=attention_mask.bool(), reduction='mean') return loss else: decoded = self.crf.decode(emissions, mask=attention_mask.bool()) return decoded

关键参数说明:bert_path指向本地chinese-bert-wwm-ext模型目录(已包含在资源包中);tagset_size=5对应B-Disease/I-Disease/B-Symptom/I-Symptom/O;lstm_hidden=256是经网格搜索确定的平衡点——再大易过拟合,再小则丢失时序信息。训练时使用data16.csv(16类高血压相关实体标注样本)做微调,epoch=30,学习率2e-5,早停patience=5。

2.3 图谱构建层:从三元组到Neo4j的Schema设计与批量导入

NER输出的是(实体1, 关系, 实体2)三元组,但直接导入图数据库会混乱。本项目定义了严格的高血压领域Schema:

  • 节点类型:Disease(高血压、肾病)、Symptom(头晕、蛋白尿)、Drug(氨氯地平、华法林)、Doctor(张三)、Hospital(某三甲)
  • 关系类型:HAS_SYMTOM、CONTRAINDICATED_WITH、TREATS、WORKS_AT、EXPERT_IN
  • 关键约束:Doctor节点必有expertise_score属性(0-100),由其历史病例中高血压相关实体共现频次加权计算

批量导入脚本scripts/import_to_neo4j.py使用neo4j-driver==4.4.1,核心逻辑是分批提交(防内存溢出)并启用事务:

# scripts/import_to_neo4j.py 片段 def import_triples_batch(driver, triples_batch): with driver.session() as session: # 先创建唯一约束(仅首次运行) session.run("CREATE CONSTRAINT ON (d:Doctor) ASSERT d.id IS UNIQUE") session.run("CREATE CONSTRAINT ON (ds:Disease) ASSERT ds.name IS UNIQUE") # 批量创建节点(去重) session.run(""" UNWIND $triples AS t MERGE (n1:Disease {name: t.entity1}) MERGE (n2:Symptom {name: t.entity2}) CREATE (n1)-[:HAS_SYMTOM]->(n2) """, triples=triples_batch)

注意:gaoxueya-1.csv是人工校验后的高质量三元组样本(含1276条),用于验证图谱逻辑正确性;而disease_gaoxueya.csv中的疾病属性(如“原发性高血压分级”“靶器官损害类型”)被映射为Disease节点的属性,而非独立节点——这是避免图谱过度碎片化的关键设计。

2.4 推荐服务层:基于图路径的个性化排序与可解释性输出

推荐不是简单查MATCH (d:Doctor)-[r:TREATS]->(dis:Disease {name:'高血压'}) RETURN d。本系统采用双路径加权策略:

  • 主路径:患者症状 → 相关疾病 → 医生专长(权重0.6)
  • 辅路径:患者用药 → 禁忌疾病 → 医生规避领域(权重0.4,负向)
# services/recommender.py 核心查询 def get_doctor_recommendations(patient_symptoms, patient_drugs): # 主路径:症状→疾病→医生 main_query = """ MATCH (s:Symptom) WHERE s.name IN $symptoms MATCH (s)-[r1:CAUSED_BY]->(d:Disease) MATCH (doc:Doctor)-[r2:TREATS]->(d) WITH doc, COUNT(*) AS score_main // 辅路径:用药→禁忌→医生规避 OPTIONAL MATCH (dr:Drug) WHERE dr.name IN $drugs OPTIONAL MATCH (dr)-[r3:CONTRAINDICATED_WITH]->(dis:Disease) OPTIONAL MATCH (doc)-[r4:EXPERT_IN]->(dis) WITH doc, score_main, COUNT(r4) AS conflict_count RETURN doc.name AS doctor_name, doc.title AS title, doc.expertise_score AS expertise_score, score_main AS main_score, conflict_count AS conflict_score, score_main * 0.6 - conflict_count * 0.4 AS final_score ORDER BY final_score DESC LIMIT 10 """ with driver.session() as session: result = session.run(main_query, symptoms=patient_symptoms, drugs=patient_drugs) return [record.data() for record in result]

返回结果含final_score和各分项,答辩时可直观展示:“为什么推荐张医生?因他处理‘头晕+蛋白尿’组合病例最多(主路径得分高),且未在历史案例中出现‘华法林+ACEI’联用(冲突得分为0)”。


3. 模块实现详解:从数据预处理到API封装的完整链路

每个模块都经过真实调试,不是概念演示。这里拆解最易出错的三个环节:数据清洗的医学特异性、模型训练的收敛控制、API服务的健壮封装。

3.1 数据预处理:高血压领域文本清洗的四个硬规则

医学文本含大量干扰符号(如“↑↓→”箭头、“mmHg”单位、“±”范围符),通用清洗会误删关键信息。本项目定义四条硬规则:

  1. 保留临床符号:↑(升高)、↓(降低)、±(波动)、~(约)、/(分隔,如“140/90”)
  2. 标准化单位:"mmHg"→"mmHg"(不转小写,因大小写敏感)、"mg/dL"→"mg/dL"
  3. 保留括号语义:"肌酐(Cr)"不拆为“肌酐 Cr”,因“Cr”是缩写,需整体识别
  4. 数字归一化:"140/90"→"140/90"(不转为浮点数,因血压值是离散对)
# utils/data_cleaner.py def clean_hypertension_text(text): # 规则1:保留临床符号(正则预编译提升速度) clinical_symbols = re.compile(r'[↑↓→±~]') text = clinical_symbols.sub(lambda m: f' {m.group()} ', text) # 规则2:单位标准化(精确匹配,防误伤) units = {'mmHg': 'mmHg', 'mg/dL': 'mg/dL', 'μmol/L': 'μmol/L'} for raw, std in units.items(): text = re.sub(rf'\b{re.escape(raw)}\b', std, text, flags=re.IGNORECASE) # 规则3:括号内缩写保护(用占位符暂存) text = re.sub(r'(\w+)\s*\((\w+)\)', r'\1_XX_\2_XX_', text) # 临时标记 # 规则4:血压值保护(匹配"数字/数字"模式) text = re.sub(r'(\d{2,3})/(\d{2,3})', r'BP_\1_\2', text) # 统一空格并还原 text = re.sub(r'\s+', ' ', text).strip() text = text.replace('_XX_', '(').replace('BP_', '').replace('_', '/') return text

血泪经验:曾有同学用text.lower()导致“Cr”变“cr”,被NER模型误判为“car”,最终图谱里出现“医生擅长修车”——高血压领域必须关闭通用NLP的“友好默认值”。

3.2 模型训练:防止BERT微调崩溃的三个checkpoint策略

BERT微调极易OOM或梯度爆炸。本项目采用三层防护:

  • 梯度裁剪:torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
  • 学习率热身:前10% step线性升至2e-5,后90%逐步衰减
  • 关键checkpoint保存:不仅存best_model.bin,还存epoch_10.bin、epoch_20.bin、last.bin
# train.py 片段 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps ) for epoch in range(num_epochs): model.train() total_loss = 0 for step, batch in enumerate(train_dataloader): outputs = model(**batch) loss = outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 防梯度爆炸 optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() # 三层checkpoint:保底+中期+最终 if epoch == 10: torch.save(model.state_dict(), 'checkpoints/epoch_10.bin') if epoch == 20: torch.save(model.state_dict(), 'checkpoints/epoch_20.bin') if epoch == num_epochs - 1: torch.save(model.state_dict(), 'checkpoints/last.bin') # 早停逻辑:验证集loss连续5轮不降 val_loss = evaluate(model, val_dataloader) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'checkpoints/best_model.bin') patience = 0 else: patience += 1 if patience >= 5: break

3.3 API服务:Flask接口的健壮封装与错误兜底

app.py不是简单@app.route,而是封装了请求校验、超时控制、降级响应:

# app.py from flask import Flask, request, jsonify import time from services.recommender import get_doctor_recommendations from utils.error_handler import handle_api_error app = Flask(__name__) @app.route('/recommend', methods=['POST']) def recommend_doctor(): try: # 1. 请求校验(强制字段) data = request.get_json() if not data or 'symptoms' not in data or 'drugs' not in data: return jsonify({'error': '缺少必要参数:symptoms 或 drugs'}), 400 symptoms = [s.strip() for s in data['symptoms'] if s.strip()] drugs = [d.strip() for d in data['drugs'] if d.strip()] if not symptoms: return jsonify({'error': 'symptoms不能为空列表'}), 400 # 2. 超时控制(图谱查询最长5秒) start_time = time.time() recommendations = get_doctor_recommendations(symptoms, drugs) elapsed = time.time() - start_time if elapsed > 4.5: # 接近超时,记录慢查询 app.logger.warning(f"慢查询警告:{elapsed:.2f}s, symptoms={symptoms}") # 3. 降级响应(若图谱无结果,返回默认专家池) if not recommendations: recommendations = get_fallback_doctors() # 从doctors_gaoxueya.csv取top5 return jsonify({ 'status': 'success', 'recommendations': recommendations, 'query_time_ms': int(elapsed * 1000) }) except Exception as e: return handle_api_error(e) # 统一错误处理,不暴露内部异常 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False) # 生产环境禁用debug

注意:handle_api_error会将ConnectionRefusedError(Neo4j未启动)转为503,将KeyError(字段缺失)转为400,避免前端看到Internal Server Error——这是答辩时体现工程素养的关键细节。


4. 常见问题排查:这五个坑我带过三届学生都踩过

做知识图谱类毕设,90%的调试时间花在以下五个点。它们看似琐碎,但直接决定能否跑通、能否答辩。

4.1 现象:Scrapy爬到的医生姓名全是“undefined”或空字符串

原因:CSS选择器写的.name在目标网站实际是.doctor-name,或网站启用了JS渲染(静态HTML无内容)
解决:先用scrapy fetch --nolog "URL"保存原始HTML,用浏览器开发者工具的Elements面板右键“Copy selector”,再粘贴到Scrapy Shell中验证:

scrapy shell "http://example.com" >>> response.css('.doctor-name::text').get() '张三'

若返回None,说明选择器失效,需换用XPath或启用Splash(本项目已预置scrapy-splash配置,见settings.py)。

4.2 现象:NER模型训练loss不下降,始终在2.5左右震荡

原因:data16.csv中的标签格式不统一(如有的用B-DISEASE,有的用B-Disease),或tag_to_ix字典未按训练集实际标签构建
解决:运行scripts/check_labels.py检查:

# scripts/check_labels.py import pandas as pd df = pd.read_csv('data16.csv') print("标签分布:", df['label'].value_counts()) # 应只有5种:B-Disease, I-Disease... print("标签是否全大写:", df['label'].str.isupper().all()) # 必须True

若发现小写,用df['label'] = df['label'].str.upper()修复。

4.3 现象:Neo4j导入后MATCH (n) RETURN count(n)返回0,但日志显示“Imported 1276 rows”

原因:CSV文件含BOM头(Windows记事本保存的UTF-8),Neo4j CSV解析器无法识别首列
解决:用VS Code打开CSV,右下角看编码,若显示“UTF-8 with BOM”,点击切换为“UTF-8”并保存。或用命令行清除:

# Linux/Mac sed -i '1s/^\xEF\xBB\xBF//' gaoxueya-1.csv # Windows PowerShell (Get-Content gaoxueya-1.csv -Raw) -replace "\xEF\xBB\xBF", "" | Set-Content gaoxueya-1.csv

4.4 现象:Flask API返回500 Internal Server Error,日志显示Driver is closed

原因:neo4j-driver连接未复用,每次请求新建driver,超出Neo4j最大连接数(默认100)
解决:在app.py顶部全局初始化driver,并用@app.teardown_appcontext确保关闭:

# app.py 顶部 from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) @app.teardown_appcontext def close_driver(error): if driver is not None: driver.close()

4.5 现象:推荐结果中出现“王五医生:擅长治疗高血压,但其历史病例中从未处理过糖尿病肾病”

原因:图谱构建时未关联Disease节点的并发症关系,导致TREATS关系孤立
解决:在scripts/build_comorbidity_graph.py中补充并发症边:

# 从disease_gaoxueya.csv读取comorbidity字段 df = pd.read_csv('disease_gaoxueya.csv') for _, row in df.iterrows(): if pd.notna(row['comorbidity']): # 创建"高血压"-[:COMPLICATES]->"糖尿病肾病"关系 session.run( "MATCH (d1:Disease {name: $d1}), (d2:Disease {name: $d2}) " "CREATE (d1)-[:COMPLICATES]->(d2)", d1=row['name'], d2=row['comorbidity'] )

5. 答辩验收指南:从演示脚本到答辩话术的实战清单

答辩不是代码朗诵,而是证明你理解问题、掌控方案、预见风险。我带过的98分毕设,核心就三点:能跑通、能讲清、能答住。下面这份清单,是我给A同学最后一次模拟答辩前划的重点,他最终拿了学院最高分。

5.1 演示脚本:5分钟精准呈现系统价值(附逐字稿)

不要从python app.py开始!开场直击痛点,用真实场景代入:

“老师好,我做的不是另一个医生黄页。请看这个场景:一位65岁男性,收缩压170mmHg,伴有头晕、夜尿增多,正在服用氨氯地平和阿托伐他汀。传统系统会推荐‘高血压科医生’,但我的系统发现——他同时有糖尿病肾病风险(夜尿增多是早期信号),而氨氯地平在肾功能不全时需减量。因此,系统优先推荐张医生(肾内科背景+高血压专长),并标注‘已处理127例高血压合并CKD病例’。现在,我演示从输入到推荐的全过程。”

演示步骤(严格计时):

  1. 0:00-0:45启动服务:cd project_root && python app.py→ 访问http://localhost:5000显示欢迎页(证明服务正常)
  2. 0:45-2:00Postman发送请求(提前存好Collection):
    POST /recommend { "symptoms": ["头晕", "夜尿增多"], "drugs": ["氨氯地平", "阿托伐他汀"] }
    展示返回JSON,重点指final_score、conflict_score=0、expertise_score=92
  3. 2:00-3:30Neo4j Browser执行查询:
    MATCH (s:Symptom)-[r:CAUSED_BY]->(d:Disease)<-[r2:TREATS]-(doc:Doctor) WHERE s.name IN ['头晕','夜尿增多'] RETURN doc.name, d.name, r2.weight
    展示图谱可视化,圈出张医生节点及关联的Disease、Symptom
  4. 3:30-5:00切换到docs/architecture.png,用激光笔指出四层架构,强调“Scrapy爬虫→NER→图谱→推荐”数据流向,说:“每一层输出都是下一层输入,没有黑匣子。”

提示:提前录好30秒GIF(Postman+Neo4j Browser同屏),万一现场网络故障可应急播放——这比手忙脚乱重装环境强十倍。

5.2 答辩话术:预判导师必问的三个问题及应答逻辑

导师不会考倒你,但会验证你是否真懂。以下是高频问题及回答心法:

导师问题错误答法(扣分点)正确答法(得分点)底层逻辑
“为什么用BERT+CRF+BiLSTM,而不是单纯BERT微调?”“因为论文这么写的”(无思考)“单纯BERT微调在医学短文本上易过拟合,F1仅83%;加入BiLSTM捕捉局部序列依赖,CRF约束标签转移(如B-Disease后不能接O),三者结合F1达91.2%,我在results/ner_f1.csv中有对比实验。”用数据说话,展示你做过消融实验
“知识图谱规模才1276条三元组,算‘知识图谱’吗?”“够用了”(回避本质)“图谱质量重于数量。这1276条来自人工校验的gaoxueya-1.csv,覆盖高血压核心路径(症状→疾病→用药→禁忌→医生)。我们更关注关系的临床合理性,比如‘华法林’与‘ACEI’的CONTRAINDICATED_WITH关系,是依据《高血压防治指南》第3.2条设定的。”将技术术语锚定到领域规范,体现专业性
“如果患者输入‘头疼’,系统怎么区分是高血压引起还是偏头痛?”“NER会识别出来”(未解耦)“这是典型的歧义消解问题。当前版本通过上下文强化:若‘头疼’与‘血压160/100’同句出现,权重+0.3;若与‘视觉先兆’同现,则触发偏头痛分支。未来可接入ICD编码映射,但毕设聚焦高血压主路径,已满足需求。”承认局限,但说明设计取舍依据

5.3 文档交付物检查清单(答辩前最后一遍核对)

你的文档不是附件,而是答辩的延伸证据。务必确认以下文件存在且内容准确:

文件名必含内容检查要点作用
docs/architecture.png四层架构图(含数据流向箭头)箭头是否标注CSV、HTML、Triplets、Cypher?节点是否用不同颜色区分?证明你理解系统全景
docs/er_diagram.pngNeo4j节点/关系ER图Doctor节点是否含expertise_score属性?CONTRAINDICATED_WITH关系是否有source_guideline属性?体现数据建模深度
docs/api_spec.md/recommend接口的Request/Response示例是否包含curl命令?Response JSON是否标注每个字段含义(如conflict_score)?证明工程规范性
docs/evaluation.mdNER模型F1值、图谱查询平均耗时、推荐准确率是否注明测试集来源(data16.csv的20%)?是否对比基线(如纯关键词匹配)?用数据支撑结论

从那以后我每次带毕设,都强制学生在答辩前3天,用这份清单逐项打钩,缺一项不许进答辩室。不是苛刻,而是让你们在真正面对导师时,底气足一点,手抖少一点。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询