
简介这套基于BERT、CRF与BiLSTM并结合知识图谱技术实现的医生推荐系统毕业设计资料面向计算机相关专业正在准备毕业设计或期末项目的高校学生也适合需要项目实战练习的学习者可用于课程设计、期末大作业或直接作为毕业设计项目。项目以Python编程语言为主完整包含模型训练源码、详细文档说明、配套数据集与运行配置代码经过严格调试确保可以运行能够支撑从数据处理到医生推荐的完整智能系统。整个资源包共114个文件包含37个Python源码文件、18个xml与10个json配置文件、7个html页面以及配套的css和js前端资源、多个csv疾病与医生数据集、pkl模型文件、png及jpg图片和log日志等压缩包整体大小40.42MB目录结构完整兼顾后端模型、数据处理与前端展示。从内容预览中可以看到高血压等疾病数据、医生信息csv文件以及Scrapy爬虫配置便于理解从数据采集、实体识别、关系抽取、知识图谱构建到医生推荐的完整流程。目前已有114人学习适合作为毕业设计选题参考也可以作为课程设计或期末大作业中的综合性项目案例。1. 基于BERTCRFBiLSTM知识图谱的医生推荐系统它能落地成什么样子你先想一个场景用户输入一句「最近两周头晕、心悸晚上睡不好」普通的检索系统只能按关键词模糊匹配大概率把神经内科、心内科的医生全都返回去排序基本靠运气。而基于BERTCRFBiLSTM知识图谱实现的医生推荐系统会把这句话里的「头晕、心悸、睡不好」先抽成症状实体再去知识图谱里查这些症状对应哪些疾病、哪些医生擅长治这些病最后按匹配度把医生排出来。这套项目源码不只是毕业设计它实际上把「命名实体识别 知识图谱查询 推荐排序」完整跑通了一遍数据层用Scrapy采集医生信息模型层用BERT负责语义、BiLSTM抓序列特征、CRF保证标签约束知识层用图数据库承载“医生-疾病-症状”的关联。适合想用一份完整项目练手NLP和图数据库的从业者也适合需要快速落地一个毕设、课程设计或期末大作业的学生克隆复现。2. 数据底座Scrapy爬虫与CSV数据集预处理三个必须较真的环节2.1 项目文件清单里的数据流转路线拆开这套项目的文件看实际是三层结构。scrapy.cfg是Scrapy工程的配置文件它声明了爬虫项目的模块路径和默认设置box.css、index.css是前端推荐页面用的样式文件也就是说这套系统最后是有一个可视化界面的剩下的五个CSV文件是整套系统的语料来源和知识底座。disease_gaoxueya.csv、gaoxueya-30992.csv、doctors_gaoxueya.csv、gaoxueya-1.csv、data16.csv从文件名可以直接判断这是一套围绕高血压专病组织的数据集。其中doctors_gaoxueya.csv应该存储了医生的姓名、医院、科室、职称、擅长领域等原始字段disease_gaoxueya.csv是高血压相关的疾病与症状字典另外几个CSV是从不同页面导出的明细数据可能是列表页、详情页、搜索页的原始快照。我第一次拿到这类项目时不会直接去跑模型而是先做两件事一是用pandas打开每个CSV看字段名和行数确认哪份是原始数据、哪份是清洗后的数据二是把CSV字段和代码里的实体类型对应起来确认模型训练用的标注语料是如何从CSV生成的。很多同学把这套项目跑不起来问题恰恰出在这——模型代码没报错但CSV里的字段和代码里读的列名对不上一运行就KeyError。2.2 从医生页面到结构化CSVScrapy采集的标准写法先用采集端说。假设我们要抓取一个医疗网站的医生列表页Scrapy的标准做法是定义item和spider两个文件。下面是这份项目数据采集端最常见的实现方式# doctors_spider.py import scrapy class DoctorSpider(scrapy.Spider): name doctors allowed_domains [example-health.com] start_urls [ https://example-health.com/hypertension/doctors?page1, https://example-health.com/hypertension/doctors?page2 ] def parse(self, response): # 每个医生卡片对应一个 dict for card in response.css(div.doctor-card): yield { name: card.css(h3.name::text).get(), hospital: card.css(p.hospital::text).get(), department: card.css(p.department::text).get(), title: card.css(p.title::text).get(), specialty: card.css(div.specialty li::text).getall(), }这段代码的核心逻辑是按CSS选择器把医生卡片里的字段抽出来getall返回列表get只取第一个值。注意specialty字段我用了getall后面清洗的时候要对这个字段做拆分。字段顺序和命名最好和doctors_gaoxueya.csv保持一致这样后面用pandas合并原始数据时就不用改列名。实际做这类爬虫时有两个容易被忽略的地方。一是页面编码有些医疗网站是GBK编码而Scrapy默认按UTF-8处理需要在请求头里显式带上编码信息。二是反爬策略最常见的反爬手段是校验User-Agent和请求频率通常做法是在settings.py里配置DOWNLOAD_DELAY 1.5再把User-Agent池随机切换而不是在spider代码里硬编码一个UA。2.3 把CSV清洗成标准结构化数据爬下来或者直接使用项目带的数据集之后清洗环节我一般用pandas处理。以下是清洗脚本的核心逻辑import pandas as pd df pd.read_csv(doctors_gaoxueya.csv) print(df.columns.tolist()) print(df.shape) # 拆分解多个擅长领域统一分隔符 def split_specialty(s): for sep in [、, ,, , |, ;, ]: s s.replace(sep, \t) return [x.strip() for x in s.split(\t) if x.strip()] df[specialty_list] df[specialty].apply(split_specialty) # 医院名和科室名去空格 df[hospital] df[hospital].str.replace( , ) df[department] df[department].str.replace( , ) # 去除完全重复的行 df.drop_duplicates(subset[name, hospital, department], inplaceTrue) # 保存清洗结果 df.to_csv(doctors_gaoxueya_clean.csv, indexFalse)这里每一步都有明确目的。split_specialty是把擅长领域拆成列表后面知识图谱里做「医生-疾病」关系的时候每个擅长词都能独立挂到医生节点上去空格是因为爬取数据里经常有 或者全角空格drop_duplicates是防止同一个医生在列表页和详情页重复入库。去重时我用的subset是「姓名医院科室」三个字段因为不同医院可能存在同名医生只按姓名去重会误删。清洗完的CSV行数大约会在几百到一两千之间这个量级做毕业设计足够。接下来要把数据转成BERTCRFBiLSTM能消费的标注语料这是下一章的重点。3. 实体识别用BERTCRFBiLSTM从问诊文本中提取实体的完整实现3.1 为什么是BERTBiLSTMCRF而不是只用BERT先回答一个谁都绕不开的问题这个项目为什么要把三个模型串起来直接用BERT做序列标注不行吗行但效果会打折扣。BERT虽然能捕捉上下文语义但它在输出层对标签之间的依赖关系是无约束的也就是说它可能把「头晕」识别成“B-Symptom I-Disease”这种不合理的标签序列。CRF层干的事情就是给标签序列加约束保证B后面只能跟I或O不能从一个实体直接跳到另一个实体的内部。BiLSTM夹在BERT和CRF中间角色是进一步建模局部序列信息。BERT输出的每个字符向量是768维BiLSTM对双向信息做压缩和重组合把序列依赖再强化一遍。换句话说BERT负责“这个字在这个语境下是什么意思”BiLSTM负责“这个字和历史字、未来字的关系”CRF负责“整条标签序列合不合法”。这套结构在中文医疗文本上的收益非常明显。医疗问诊文本里大量出现「头晕头痛」「心悸胸闷」这种极短的症状描述单靠BERT容易把两个连续症状切成一个整体BiLSTM和CRF的加入能把边界切得更准。3.2 PyTorch实现模型主体与CRF解码下面是模型主体实现用PyTorch和transformers、torchcrf两个库搭起来import torch import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, bert_path, hidden_size128, num_tags7): super().__init__() self.bert BertModel.from_pretrained(bert_path) self.bilstm nn.LSTM( input_size768, hidden_sizehidden_size, num_layers2, bidirectionalTrue, batch_firstTrue, dropout0.3 ) self.fc nn.Linear(hidden_size * 2, num_tags) self.crf CRF(num_tags, batch_firstTrue) def forward(self, input_ids, attention_mask): bert_out self.bert(input_ids, attention_maskattention_mask) seq_out bert_out.last_hidden_state lstm_out, _ self.bilstm(seq_out) emissions self.fc(lstm_out) return emissions def compute_loss(self, emissions, tags, attention_mask): return -self.crf(emissions, tags, maskattention_mask.bool()) def decode(self, emissions, attention_mask): return self.crf.decode(emissions, maskattention_mask.bool())几个关键参数要重点说明。hidden_size取128是平衡效果和显存的选择取256效果略好但显存占用明显上升。num_tags是我定义的标签数量用BIO标注法时有7种标签B-Symptom、I-Symptom、B-Disease、I-Disease、B-Department、I-Department、O如果你还想抽「身体部位」这类实体就往上加标签。dropout0.3是BERT后面加的一个正则化防止在几千条医疗样本上过拟合。CRF用的是torchcrf库它内部实现了维特比解码。在compute_loss里取负对数似然作为损失CRF的loss本身就是可导的可以直接接在神经网络后面反向传播。训练的时候不需要手写解码逻辑decode函数会返回当前batch里每句话的最优标签序列。3.3 训练参数学习率、批次与序列长度的取舍这段是这套系统最容易翻车的部分。我把训练循环写出来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}, ]) scheduler torch.optim.lr_scheduler.LinearLR( optimizer, start_factor0.1, total_iters2 ) EPOCHS 8 BATCH_SIZE 8 MAX_LEN 128 model.train() for epoch in range(EPOCHS): total_loss 0.0 for step, batch in enumerate(train_loader): input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) emissions model(input_ids, attention_mask) loss model.compute_loss(emissions, labels, attention_mask) optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() print(fepoch {epoch1}, loss {total_loss/len(train_loader):.4f}) scheduler.step()这里给BERT单独设了3e-5的学习率给BiLSTM、fc和CRF设了1e-3。原因很直接BERT是预训练模型权重已经收敛得比较好学习率太大会把语义表征冲坏而这个项目的标注数据量往往只有几千条更经不起大学习率震荡。BiLSTM和CRF是从零开始训练的1e-3是正常范围。AdamW里参数分组就是这么做的不分组的话整体用3e-5会让后三层收敛非常慢。批次大小取8不是随便定的。BERT-base有12层每层768维一个中文句子按128长度截断batch_size8的话前向过程显存占用大约在10GB到12GB之间。如果你的显卡只有6GB显存把batch_size降到4MAX_LEN降到64。序列长度这块还有个小技巧训练集里的问诊文本其实都很短大部分不超过20个字用128做上限已经很够不要为了装长文本把MAX_LEN拉到512那会让显存直接爆炸。clip_grad_norm_里的5.0是梯度裁剪阈值这个参数在NER里特别重要。因为BiLSTM和CRF的梯度有时会出现异常尖峰不裁剪的话loss会突然从0.1跳到几千训练直接废掉。我习惯固定写5.0如果发现验证集loss震荡降到1.0再试。4. 知识图谱落地Neo4j建模与从症状到医生的推荐链路4.1 图谱里的节点与关系该怎么设计实体识别做完模型输出的是「症状」「疾病」这类实体标签但它们还是零散的要形成推荐链路必须把这些实体放进知识图谱。这套项目用的是Neo4j这是目前做毕设和中小型知识图谱最顺手的图数据库。以高血压专病场景为例我给的建模方案是四类节点、五类关系。节点是Doctor医生、Disease疾病、Symptom症状、Department科室关系是Doctor-TREAT-Disease擅长治疗、Doctor-BELONGS_TO-Department所属科室、Doctor-WORKS_AT-Hospital所属医院、Disease-HAS_SYMPTOM-Symptom疾病有症状。这个设计的核心依据是推荐场景。用户输入症状文本NER抽出来的是Symptom节点然后通过Disease反查医生。设计关系时有两个必须较真的点一是「医生-疾病」关系来自清洗后的specialty_list字段一个医生可能TREAT多个疾病二是「疾病-症状」关系来自disease_gaoxueya.csv里的症状字段这个关系是不是完整直接决定推荐匹配的成功率。4.2 用py2neo把清洗后的CSV写进Neo4j把清洗后的CSV导入Neo4j我一般用py2neo写一个导入脚本。代码如下from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) # 先清空之前的旧数据保证幂等 graph.delete_all() # 导入科室和疾病节点 for dep in departments: node Node(Department, namedep) graph.merge(node, Department, name) for dis in disease_list: node Node(Disease, namedis) graph.merge(node, Disease, name) # 导入医生节点和关系 for _, row in df.iterrows(): doc Node(Doctor, namerow[name], titlerow[title], hospitalrow[hospital]) graph.merge(doc, Doctor, name) dep_node graph.nodes.match(Department, namerow[department]).first() if dep_node: graph.merge(Relationship(doc, BELONGS_TO, dep_node)) for spec in row[specialty_list]: dis_node graph.nodes.match(Disease, namespec).first() if dis_node: graph.merge(Relationship(doc, TREAT, dis_node))merge和create的区别要重点说明。merge走的是查找-存在则跳过-不存在则创建的路径create是无脑创建。这里对医生节点用merge主键是name第二遍跑脚本时不会产生重复医生。如果你用create每跑一次脚本图谱里就多一倍的医生节点推荐结果直接乱套。杀销量最高的坑是特殊字符。CSV里医院名如果带有引号、反斜杠、转义符py2neo会直接报ParseError或者默默丢失节点。我一般会在导入前统一对全字段做一次清洗把、\、\n全替换成空字符。4.3 推荐链路从症状到医生的Cypher查询图谱建好之后推荐逻辑就是一条Cypher查询链。用户输入「最近头晕、心悸晚上睡不好」NER模型抽出「头晕、心悸、睡不好」三个症状实体查询如下MATCH (s:Symptom)-[:HAS_SYMPTOM]-(d:Disease)-[:TREAT]-(doc:Doctor) WHERE s.name IN [头晕, 心悸, 睡不好] WITH doc, count(DISTINCT d) AS disease_hit, collect(DISTINCT d.name) AS diseases RETURN doc.name AS doctor, doc.title AS title, doc.hospital AS hospital, diseases ORDER BY disease_hit DESC, CASE doc.title WHEN 主任医师 THEN 3 WHEN 副主任医师 THEN 2 ELSE 1 END DESC LIMIT 5这条查询的逻辑拆开看是两跳匹配。第一跳从症状节点找到相关的疾病节点第二跳从疾病节点反查医生节点。count(DISTINCT d)的意义是统计这个医生命中了几种疾病比如一个医生同时命中「原发性高血压」和「心律失常」说明用户的两类症状他都能处理排序就会靠前。然后按职称做二次排序。我把职称用CASE表达式映射成加权分——主任医师3分、副主任医师2分、主治1分。这个权重的设计依据是医疗场景里用户对医生资质的普遍偏好但具体要不要调整看你要给用户哪种推荐风格。如果你想做更细的推荐可以再加一个distance字段限制医院离用户的地理距离这在真实产品里是必要的毕设版本里做好命中数加职称排序就够了。查询结果直接渲染到前端页面那套box.css和index.css作用就在这规则就是推荐结果卡片上的医生姓名、职称、医院、擅长疾病列表。5. 避坑指南跑通这套系统绕不开的五个常见坑5.1 坑一CRF标签编号错位模型loss下降但解码结果全乱现象训练时loss正常下降但decode出来的标签序列全是O或者连续输出不存在的I标签。原因最常见的是label2id编码和训练标签不一致。比如代码里把label2id定义为{O:0, B-Symptom:1, ...}但生成训练集时用了一个新的dict重新把标签映射了一遍顺序变了。CRF对标签编号极其敏感编号一错维特比解码出来的序列就是乱的。解决在训练前写一行断言assert sorted(label2id.values()) list(range(len(label2id)))你还需要打印两条训练数据的标签序列人工检查B和I的顺序。血的教训是不要在dataloader转成tensor之后再排标签顺序应该在CSV预处理阶段统一编码然后只传id进模型。5.2 坑二显存不足batch_size8直接OOM现象训练刚开始CudaOutOfMemoryError程序退出。原因BERT-base在batch_size8、MAX_LEN128的情况下需要约11GB显存。很多人的笔记本显卡只有6GB直接跑这个参数就是爆。解决把batch_size降到4MAX_LEN降到64。如果还不行就是把BERT的gradient_checkpointing打开model.bert.gradient_checkpointing_enable()这会用计算换显存训练速度慢约15%但显存能再省一半。毕设场景下推荐顺序是先降batch_size再降MAX_LEN最后开gradient checkpointing。5.3 坑三CSV里有大量NULL字段知识图谱导入时丢节点现象图谱里医生的TREAT关系数量明显少于CSV行数。原因doctors_gaoxueya.csv里有些行的specialty字段是空的或者写了「暂无」这种占位符。导入脚本里对空列表不处理关系自然就丢了。解决清洗阶段就对空值做显式处理。我一般用df[specialty].fillna()先填成空字符串然后拆分时把「暂无」「不详」「待补充」这类噪音词过滤掉。导入时打印一下连接成功的医生数和总行数如果连接数低于95%就回头查清洗逻辑。5.4 坑四Neo4j社区版多实例冲突端口被占用现象运行导入脚本报Failed to establish connection浏览器里Neo4j Manager打开正常但Python连不上。原因本机装了多个版本的Neo4j或者旧实例还在运行bolt端口7687被旧实例占着新实例只开了7474浏览器端口。解决先用netstat -ano | grep 7687看是哪个进程占的端口确定本机只有一个实例在跑。统一用bolt://localhost:7687连接别用http协议。如果系统里有多个版本建议卸载干净只留下一个Neo4j 5.x版本。5.5 坑五反向标签O占比过高模型学成懒汉现象验证集precision很高recall很低模型几乎把所有词都预测成O。原因问诊文本里实体往往只占20%到30%的字数剩下的全是O标签。如果训练时不处理类别不平衡模型学到的策略就是「全部预测O反正正确率也高」。解决给CRF的loss加标签权重。torchcrf虽然不直接支持权重参数但我常见做法是在监督信号上做副本把O类标签的损失权重降下来更狠的做法是过滤掉全是O标签的训练样本。那之后我每次训练前都会数一遍标签分布Counter(all_labels)要是O占比超过85%就强制做样本重采样。6. 运行验证与调优技巧让推荐结果可解释、可复现6.1 用两个维度验证NER效果跑完训练不能只看loss还要对验证集做两个层面的评估。第一层是标签级的precision、recall、F1这个直接按标签算就行第二层是实体级别的正确率规则是预测的实体边界和真实实体边界完全一致才算对。毕设答辩时老师问「模型效果怎么样」你要说的是实体级F1而不是标签级F1。我见过很多项目写「F10.9」其实是标签级的留下问一句就露馅。验证脚本里加一个实体匹配函数把连续的B-I标签拼成完整实体再和真实实体列表比对def extract_entities(label_list): entities [] cur_entity [] for idx, label in enumerate(label_list): if label.startswith(B): cur_entity [idx, label.split(-)[1]] elif label.startswith(I) and cur_entity: cur_entity.append(idx) elif label O and cur_entity: entities.append(tuple(cur_entity)) cur_entity [] return {entity[0]: entity[1] for entity in entities}用边界索引做字典key比对比直接比对标签序列要准确得多。6.2 二阶段微调策略这套项目我调优时用的一个技巧是二阶段训练。第一轮用原始问诊语料把BERTBiLSTMCRF整体训到收敛freeze住BERT层只微调后三层第二轮再解冻BERT用3e-5的低学习率做完整微调。这个流程能让模型的损失曲线平稳很多而且能避免微小标注噪声在预训练参数上放大。你要注意这个技巧的前提是你已经有第一轮收敛的模型权重。6.3 查询结果缓存推荐系统最烦人的是每次查询都去Neo4j跑一遍Cypher用户请求量一大图查询的响应时间就会拖到几百毫秒。毕设项目优化到够用的做法是把「症状组合推荐结果」做一层内存缓存用一个dict存缓存的键值对过期时间设在十分钟。实现起来也就几行代码。那之后我每次调推荐系统都强制走一遍「训练-验证-抽查Cypher」的流程。无论是跑模型还是调图谱先把最笨的手工样本测一遍再谈优化。这套习惯帮我少踩了很多隐蔽的坑希望帮到你。本文还有配套的精品资源点击获取