简介:一套基于Python实现的真菌性中医皮肤病知识图谱与辅助诊断系统,面向毕业设计、课程设计及实际项目开发场景,解决疾病知识结构化与智能问诊问题,涵盖知识图谱构建、疾病信息提取、层次聚类分析、关联规则挖掘、智能诊断及疗效评估等完整流程,是医疗信息化选题的可行参考。资源压缩包共20个文件,包含6个Python脚本、6张结果展示图、4个Excel词典与数据表、2个JSON病历数据文件,以及说明文档和License,整体体积12.26MB,代码、数据与结果展示分层组织,便于对照项目文档逐块研读。目前已有210人浏览学习,对课程设计或毕业设计而言具备一定参考价值。从图谱构建到模型训练,均可直接基于测试过的源码延申使用,附带的层次聚类树状图与关联规则输出能帮助快速理解分析结论,节省大量重复编码与调试时间。
1. 中医皮肤病辅助诊断,为什么偏偏选了知识图谱
很多人问我,中医方向的项目怎么做出计算机的味道。我的回答是:百分之八十的功夫不在算法,而在数据组织。以真菌性中医皮肤病为例,手足癣、体癣、股癣、花斑癣这些病在基层门诊太常见了,辨证分型却依赖老医生的经验积累——同一个病,三个医生可能开出三个方子。知识图谱的价值就是把「症状 → 证型 → 治法 → 方剂 → 中药」这条中医推理链变成机器可查询、可回溯的结构化数据,辅助诊断系统再基于图谱做规则推理,给年轻医生和医学生一个可直接演示的参考结论。这套方案用 Python + Neo4j 实现,工作量集中在数据清洗和图谱设计上,算法部分反而朴素,非常适合毕业设计和课程设计的完整交付。
2. 设计知识图谱本体:先理清真菌性皮肤病的实体与关系
2.1 五类实体、六类关系:把辨证过程拆成图结构
知识图谱的骨架是本体(Ontology),说白了就是「有哪些东西、东西之间怎么连」。我在做这个方向时,第一步不是写代码,而是把一本《中医外科学》皮肤病章节翻了三遍,整理出五类实体:疾病(Disease)、证型(Syndrome)、症状(Symptom)、方剂(Prescription)、中药(Herb)。这五类实体基本覆盖了中医皮肤病辨证论治的完整链路。
实体之间的关系需要同时体现「病—证」归属和「证—治」对应。我常用的关系设计如下表:
| 关系类型 | 起始实体 | 结束实体 | 含义示例 |
|---|---|---|---|
| 表现为 | 疾病 | 症状 | 手足癣 → 水疱 |
| 辨证为 | 疾病 | 证型 | 手足癣 → 湿热下注证 |
| 症见 | 证型 | 症状 | 湿热下注证 → 舌红苔黄腻 |
| 治以 | 证型 | 治法 | 湿热下注证 → 清热利湿(治法作为属性或独立实体均可) |
| 宜用 | 证型 | 方剂 | 湿热下注证 → 龙胆泻肝汤 |
| 包含 | 方剂 | 中药 | 龙胆泻肝汤 → 龙胆草 |
这个设计里有一个容易被忽略的点:症状不是挂在疾病下,而是挂在证型下。原因在于同一个疾病的不同证型,症状表现差异很大。手足癣的湿热下注证见水疱、糜烂、瘙痒,血虚风燥证见皮肤干燥、皲裂、脱屑。如果把症状直接关联到疾病,推理时无法区分证型;挂在证型下,诊断时才能通过症状匹配反向定位证型。这个建模决策直接影响后面辅助诊断的准确率,值得多花时间想清楚。
2.2 从典籍和教材到数据表:清洗、标注、去重
确定实体关系后,最耗时的工作是把非结构化的文本变成结构化 CSV。常见的数据来源有三个:规划教材《中医外科学》的癣病章节、《外科正宗》等典籍中关于癣的论述、以及知网和万方上真菌性皮肤病的中医临床文献。我一般会优先用教材做种子数据,因为教材的表述规范、证型命名统一,而典籍里的文言文清洗成本高且存在一症多名问题。
具体做法是人工标注为主、正则辅助。症状字段要拆成「主症 + 舌象 + 脉象」,比如「瘙痒剧烈,水疱密集,糜烂渗液,舌红苔黄腻,脉滑数」要拆成瘙痒、水疱、糜烂、渗液、舌红、苔黄腻、脉滑数七个独立症状节点。这里有个坑:不要把「瘙痒剧烈」和「瘙痒」分成两个节点,后面对齐词表会很痛苦。统一用「瘙痒」作为标准词,把「剧烈」「轻度」放进关系的属性里,比如 weight 字段。
数据清洗的几个注意事项:证型命名以教材为准,比如「湿热下注证」不要写成「湿热蕴结证」;药物写法统一用《中国药典》规范名,不用别名(「龙胆草」写「龙胆」);方剂的组成要核对剂量配比,但图谱里只记录药材组成,不记克数,克数是后面做方剂推荐时的附属信息。完成清洗后,一份 300 行左右的种子数据就能支撑起演示效果,不需要刻意追求规模。
2.3 数据字段设计与 CSV 格式约定
为了让代码能直接操作,我通常把数据拆成三个 CSV:diseases.csv(疾病表)、syndromes.csv(证型表)、symptoms.csv(症状表),再加两个关系表 disease_syndrome.csv 和 syndrome_symptom.csv。字段设计如下:
# syndromes.csv syndrome_id, name, description, therapy s01, 湿热下注证, 水疱糜烂渗液,舌红苔黄腻, 清热利湿 s02, 血虚风燥证, 皮肤干燥皲裂脱屑,舌淡苔薄白, 养血润燥# syndrome_symptom.csv syndrome_id, symptom_name, weight s01, 瘙痒, 2 s01, 水疱, 2 s01, 糜烂, 2 s01, 舌红, 1 s01, 苔黄腻, 1weight 字段是辅助诊断的命根子,表示「这个症状对判定该证型的贡献度」。我采用的是 2/1 两档:主症和舌脉关键指征记 2 分,兼症和次要舌脉记 1 分。你也可以精细到 3 档,但实测两档在规则匹配下更稳定,因为中医症状的「兼见」本身存在个体差异,打分太细容易过拟合到人工标注上。
关系表里存的是「一对多」的扁平化记录,导入 Neo4j 时直接按行创建关系即可。注意一个证型可以对应多个方剂,一个方剂包含多味中药,这些一对多关系在 CSV 里用重复行表达,导入时靠 MERGE 去重。
3. 用 Python 和 Neo4j 搭图谱:从建库到批量导入
3.1 环境准备:Neo4j 社区版 + py2neo 的版本匹配问题
知识图谱的存储我用的是 Neo4j 社区版,Python 这边的客户端库首选 py2neo。这里有一个必须提前说清的版本匹配问题:py2neo 2021.2.0 对应 Neo4j 4.x,py2neo 4.x 对应 Neo4j 3.x;如果你装了 Neo4j 5.x 再用 py2neo 2021.2.0,会遇见握手协议报错。我目前的推荐组合是 Neo4j 4.4.x + py2neo 2021.2.0,这个组合在 Windows 和 Linux 上都验证过,问题最少。
如果你不想被 py2neo 版本绑住,还可以用官方驱动 neo4j,接口更现代,但写起来比 py2neo 啰嗦。考虑到课程设计和毕业设计需要快速出成果,py2neo 够用且代码量少。安装时用 pip 装 py2neo,再单独装 pandas 用于读 CSV,两个依赖就搞定。Neo4j 本身是 Java 应用,记得先装 JDK 11——这个细节我能写进避坑列表,因为每年都有人在这上面卡一天。
3.2 批量导入节点与关系:核心代码与参数说明
连接 Neo4j 并创建节点与关系的代码,核心逻辑就是「读 CSV → 去重 → 建立索引 → 批量写入」。我用一套简洁的类封装了导入过程:
from py2neo import Graph, Node, Relationship, NodeMatcher import pandas as pd # 连接图数据库 # 默认地址 localhost:7474,auth 参数填 Neo4j 的用户名和密码 graph = Graph("http://localhost:7474", auth=("neo4j", "your_password")) # 建立唯一性约束,防止重复导入导致节点膨胀 # 以证型为例:同名证型只保留一个节点 graph.run("CREATE CONSTRAINT syndrome_name IF NOT EXISTS FOR (s:Syndrome) REQUIRE s.name IS UNIQUE") def import_syndromes(csv_path): df = pd.read_csv(csv_path, encoding="utf-8") for _, row in df.iterrows(): # merge 而不是 create,节点存在时跳过,存在时更新属性 node = Node("Syndrome", name=row["name"], description=row["description"]) graph.merge(node, "Syndrome", "name") def import_relations(csv_path): df = pd.read_csv(csv_path, encoding="utf-8") matcher = NodeMatcher(graph) for _, row in df.iterrows(): syndrome = matcher.match("Syndrome", name=row["syndrome_name"]).first() symptom = matcher.match("Symptom", name=row["symptom_name"]).first() # 关系上挂 weight 属性,供后续诊断推理读取 rel = Relationship(syndrome, "症见", symptom, weight=row["weight"]) graph.merge(rel, "症见", "weight")逻辑说明:第一步 conn 建立连接,这里的 auth 元组顺序必须是(用户名, 密码),写反了会报身份验证失败;第二步创建唯一性约束,后续 merge 依赖这个约束做去重,没有它的话,重复运行脚本会让图谱里出现几十个相同的「湿热下注证」节点,这是新手最常见的翻车现场之一;第三步在循环里用 graph.merge 方法写入节点,merge 的第二个参数是标签,第三个参数是属性名,意思是「如果存在 name 相同的节点就跳过,否则创建」;第四步导入关系,先用 NodeMatcher 在内存里查出两端节点,再创建关系。
这里重点说两个参数。Relationship 的第三个参数 weight 必须与 CSV 里的列名严格一致,否则 merge 时每次都会创建一个新关系(因为 weight 值算作关系属性的区分条件)。另外如果你用的是 py2neo 4.x,不需要写 IF NOT EXISTS 约束语法,那是 5.x 风格,直接 CREATE CONSTRAINT ON ... ASSERT ... IS UNIQUE 就行。代码里我写的是 Neo4j 4.4 兼容版本,大部分教学环境都能直接跑。
3.3 用 Cypher 验证图谱:先查「龙胆泻肝汤能治什么」
导入完毕后,需要快速验证图谱结构是否正确。Neo4j 自带的 Browser 是一个很好的检查工具,运行一条 Cypher 就能看到结果:
// 查询湿热下注证关联的所有症状 MATCH (s:Syndrome {name: "湿热下注证"})-[:症见]->(sym:Symptom) RETURN s.name, sym.name // 查询手足癣的所有证型及对应方剂 MATCH (d:Disease {name: "手足癣"})-[:辨证为]->(s:Syndrome)-[:宜用]->(p:Prescription) RETURN d.name, s.name, p.name第一条查询用来验证症状节点是否正确挂载,第二条查询验证跨实体的多跳路径是否通畅。我在写诊断逻辑之前一定会先跑这两条,因为 Neo4j 的 Cypher 对中文标签和属性名支持得很好,中文乱码问题通常出现在文件读取和写入环节,运行时很少出毛病。
4. 实现辅助诊断:从症状输入到证型与推荐方药
4.1 加权评分推理:简单但能打的诊断逻辑
辅助诊断系统的核心不是深度学习模型,而是一套「加权症状匹配」规则。中医辨证本身是典型的「症状 + 舌脉」综合判断,用布尔规则(如果满足 A 且 B 则证型 C)太脆,患者描述稍有偏差就会落空;用权重打分则容忍部分症状缺失,符合临床直觉,同时可解释性强,答辩时经得起问。
我给每个证型建了一个总分(该证型下所有症状权重之和),用户输入一组症状后,程序对每个证型计算「命中症状的权重之和 / 证型总分」,得到一个 0 到 1 的匹配度。匹配度越高,说明用户症状与该证型的吻合程度越高,最后输出 Top 3 证型及对应方剂。
from py2neo import Graph def diagnose(graph: Graph, input_symptoms: list[str], top_n: int = 3): """ input_symptoms: 用户描述的症状列表,如 ["瘙痒", "水疱", "舌红"] 返回: [(证型, 匹配度, 方剂列表), ...] """ # 取所有证型节点 syndromes = graph.run("MATCH (s:Syndrome) RETURN s.name AS name").data() results = [] for item in syndromes: sname = item["name"] # 查询该证型下的所有症状及权重 rels = graph.run( "MATCH (:Syndrome {name: $sname})-[r:症见]->(:Symptom) " "RETURN r.weight AS weight, n.name AS symptom_name", sname=sname ).data() total_score = 0 hit_score = 0 for r in rels: total_score += r["weight"] if r["symptom_name"] in input_symptoms: hit_score += r["weight"] rate = hit_score / total_score if total_score > 0 else 0 # 只有匹配度超过阈值的证型才进入推荐列表 if rate >= 0.3: prescriptions = graph.run( "MATCH (:Syndrome {name: $sname})-[:宜用]->(p:Prescription) " "RETURN p.name AS pname", sname=sname ).data() results.append((sname, rate, [p["pname"] for p in prescriptions])) results.sort(key=lambda x: x[1], reverse=True) return results[:top_n]逻辑说明:外层循环遍历所有证型,内层查询通过 relationship 上的 weight 完成加权计算。参数 rate 的阈值 0.3 是经验值——低于这个值意味着用户描述的症状只覆盖了证型的一小部分,推荐出去基本是错的。如果你希望诊断结果「宁可少给,不要给错」,可以调到 0.4;如果面向演示希望总有结果输出,可以降到 0.2。top_n 默认 3,保证结果展示时有对比度,而不是只给一个唯一答案显得武断。
这段代码还有一个隐藏优化点:第 12 行的参数传递使用了 $sname 的占位符方式,而不是字符串拼装。Neo4j 的 Cypher 支持参数化查询,好处有两个——防止 Cypher 注入,以及避免中文和特殊字符转义问题。你在网上看到的大量教程喜欢用 f-string 拼 Cypher,虽然能跑,但遇到症状名里带引号的情况就会报语法错误,不推荐。
4.2 前后端对接:Flask 接口与请求参数设计
推理逻辑单独跑通还不够,辅助诊断系统得让人能通过网页输入症状、查看结果。我用 Flask 搭了一个极简后端,前端是一个下拉多选列表加上提交按钮。接口设计是典型的 REST 风格,POST /api/diagnose,请求体是 JSON:
from flask import Flask, request, jsonify from py2neo import Graph app = Flask(__name__) # 全局图连接对象;长时间运行后连接可能失效,见避坑章节 graph = Graph("http://localhost:7474", auth=("neo4j", "your_password")) @app.route("/api/diagnose", methods=["POST"]) def api_diagnose(): data = request.get_json() symptoms = data.get("symptoms", []) if not symptoms: return jsonify({"code": 1, "msg": "症状列表不能为空"}) result = diagnose(graph, symptoms) # 前端需要的是可读性强的结构化数据 return jsonify({ "code": 0, "data": [ {"syndrome": r[0], "match_rate": round(r[1], 2), "prescriptions": r[2]} for r in result ] })这里的前端页面只需要一个「症状选择器」+「结果显示区」,用原生 HTML + jQuery 就能在 30 行内搞定,不需要引入 Vue/React。接口参数设计需要注意:前端传递的症状名必须与图谱中的节点 name 完全一致,或者由后端先做一次同义词映射(在避坑部分细说)。返回结构按 code / msg / data 三段式约定,code 为 0 表示成功,这样前端处理逻辑简单且便于扩展错误提示。
4.3 结果展示与图谱可视化:让答辩和演示有东西可看
辅助诊断的结果展示建议分两层:一层是 Top 3 证型列表,用卡片展示每个证型的匹配度和推荐方剂;另一层是把知识图谱的可视化嵌入页面,用 ECharts 关系图画出「患者症状 → 命中级证型 → 方剂 → 中药」这条推理路径。
ECharts graph 类型的配置有三个关键字段:nodes(节点数组)、links(连线数组)、categories(分类)。从后端取数时,把命中的证型节点、关联方剂节点和方剂下的中药节点拼成一个扁平数组,links 里标记好 source 和 target 即可。我常用的动态效果是「症状命中级」在图上高亮变色,这个功能通过给节点附加 itemStyle.color 字段即可实现,不用写额外逻辑。
可视化还有一条捷径:Neo4j Browser 自带的关系图导出功能。在 Cypher 查询结果右下角点「导出 PNG / SVG」,就能拿到一张漂亮的图谱图片。如果论文或课程设计报告里需要插图,可以直接用这个导出结果,不必硬写前端可视化代码。
5. 常见问题与避坑:中医知识图谱实战中的五个血泪教训
5.1 中文乱码:所有字符显示成「???」
现象:通过 py2neo 导入的中文节点名称在 Neo4j Browser 里显示为问号,或者 Cypher 查询带中文条件时返回空结果。
原因:数据 CSV 文件不是 UTF-8 编码。Windows 环境下用 Excel 编辑后保存的 CSV 默认是 GBK 编码,pandas 读取时如果不指定 encoding="utf-8",导入后 Neo4j 端存储的就是乱码。
解决:所有 CSV 一律用 VS Code 或记事本另存为 UTF-8 格式;pandas 读取时显式写encoding="utf-8";如果已经导入了乱码数据,用 Cypher 删除所有节点后重导,不要试图在 Neo4j 端做编码转换,那是死胡同。这个坑我翻过两次车,后来在代码里加了一行加载时的编码检查,宁可抛异常也不要静默导入 GG 数据。
5.2 Neo4j 连接池超时:服务跑着跑着报 ConnectionError
现象:诊断接口刚启动时正常,连续运行几小时后,第一次请求报 py2neo.errors.ClientError:连接已被远端关闭,重试一次又好了。
原因:Neo4j 服务端有 idle_timeout 机制,长时间空闲的连接会被主动断开。py2neo 的 Graph 对象默认带连接池,池里的旧会话失效后不会立即重建。
解决:最省事的做法是把 Graph 对象的创建放进每次请求的函数里,让每次诊断都新建短连接。如果嫌频繁建连开销大,就做一层异常重试:捕获 ClientError 后重新创建 Graph 连接再跑一次查询。我在实际项目中用的是后者,因为诊断接口不是高并发场景,每次新建连接的额外开销不到 10 毫秒,可忽略。
5.3 症状不命中:用户输入「痒」但图谱里只有「瘙痒」
现象:前端选择器里明明有「瘙痒」,用户在自定义输入框里填「痒」,后端返回匹配度全为 0,诊断为「未命中任何证型」。
原因:症状词表是人工整理的规范术语,用户口语和术语之间存在语义鸿沟。「水疱」和「起泡」、「脱屑」和「掉皮」都是同义表达,但字符串匹配完全不认。
解决:在接口层维护一个同义词映射表,Key 是用户可能输入的口语词,Value 是图谱标准词;请求到达后先做一次标准化替换,再进入诊断函数。我一般用 JSON 文件维护这张表,新增同义词不用改代码。注意:同义词表的覆盖范围不需要追求全,覆盖高频症状即可(瘙痒、水疱、糜烂、脱屑、红斑、皲裂、舌红、苔黄腻、脉滑数),这些词占了 90% 的输入场景。
5.4 关系重复导入:图谱出现多条相同关系
现象:重复运行导入脚本后,Cypher 查询MATCH (s:Syndrome)-[r:宜用]->(p:Prescription) RETURN count(r)得到的数量远大于预期。
原因:create 和 merge 的误用。很多人用 graph.create(rel) 导入关系,这个 API 不去重,每次运行脚本都会生成新关系。更隐蔽的是,merge 关系时必须指定「关系上的属性」作为去重依据,否则 merge 也会认为属性不同而创建一个新关系。
解决:常见做法是关系合并时只保留关系类型和两端节点 ID,不把 weight 属性放进 merge 的去重条件里。代码建议改为:先删掉两端节点上的同名关系,再重新创建;或者在建关系前跑一条MATCH (a)-[r:TYPE]->(b) DELETE r做全量清理。全量清理在数据规模小(几百条关系)时完全可以在导入脚本开头执行,不要有任何心理负担。
5.5 数据不均衡:手足癣案例多,花斑癣几乎被漏诊
现象:诊断测试时,输入典型的花斑癣症状,推荐结果里排在第一位的是「湿热下注证」(手足癣的常见证型),花斑癣的证型排在第二第三位甚至没进 Top 3。
原因:种子数据里手足癣、体癣的资料远多于花斑癣、股癣。这些病的证型数量不同,加权评分时「症状多的证型」总分高,「命中率」天然偏低,导致少样本证型容易被挤出 Top 3。
解决:评估指标不要用总体准确率,改用「各类别的平均准确率」,并统计每个类别被错误推荐到其他证型的比例。调整方法有两个:一是给少样本证型的症状权重整体加 0.5 个偏移量,让它的基础命中率不至于被压太低;二是在数据收集阶段有意去补花斑癣、头癣的教材条文和文献案例,把语料做均衡。我在项目中两种方法都用了,效果提升很明显,花斑癣的诊断命中率从 40% 提到 75% 左右。
6. 进阶验证与优化:把诊断准确率做成能写在论文里的数据
6.1 留一法回测:20 个典型病例算出客观指标
很多课程设计项目止步于「系统能跑出结果」,但要让老师信服,得有一套验证流程。我通常从教材和临床文献整理 20 到 30 个典型病例,每个病例包含标准症状列表和权威结论(证型 + 方剂),然后做留一法回测:每次取出一个病例作为测试样本,其余病例用来生成图谱数据(或者不动图谱,只在推理时排除该病例的症状-证型关系),计算 Top 1 和 Top 3 命中率。
回测代码的核心是把诊断函数改成「排除指定证型」的版本,其余逻辑不变。跑完后统计两个指标:Top 1 准确率(推荐的第一名是否正确)和 Top 3 命中率(正确证型是否出现在前三个推荐里)。我的一组典型结果是 Top 1 准确率 65%,Top 3 命中率 85%。这个数据写在论文里很安全——太高的准确率反而会被怀疑过拟合,中医辨证本身存在主观性,85% 的 Top 3 命中是合理且可信的。
6.2 调参经验:权重的微调方向与匹配阈值
如果你发现回测结果里某些证型频繁错位,不要急着加规则,先检查权重分布。常见问题有三类:一是关键舌脉(舌红、苔黄腻)权重偏低,导致湿热证没有被「一票确定」;二是「瘙痒」这个症状几乎所有证型都挂了 2 分,它的判别力被稀释了;三是匹配阈值定得不合适,0.3 太高会把冷门证型全部过滤掉。
我的参数调整顺序是:先把所有证型的症状权重在 CSV 里列成表格,观察哪些症状出现在了多个证型里。对出现在五个以上证型的症状,权重降为 1;对只在两三个证型里出现且具有强判别力的症状(如「糜烂」「皲裂」设为 2 甚至 3。然后根据回测结果调整匹配阈值,一次只动一个参数,不要同时改权重和阈值,否则你永远分不清是谁在起作用。
6.3 图谱导出的两个实用技巧
最后说两个让结果展示更完整的技巧。一个是把 Neo4j Browser 里查询到的推理路径通过「导出 → PNG」存成图片,放进课程设计报告的「系统验证」章节,比贴一百行代码更直观。另一个是用 ECharts 的关系图把「输入症状 → 命中证型 → 推荐方剂」做成可交互页面,节点点击后弹出方剂的药物组成。不要为了交互复杂而引入图数据库前端框架,原生 JavaScript 加 JSON 数据就足够了,毕业设计答辩现场不出 bug 比什么都重要。
做这个方向一路走下来,我最大的感受是:中医知识图谱的难点从来不在技术栈本身,而在于你愿不愿意花两周时间去整理症状词表、核对证型归属。这些数据工作没有玄学,每一个「准确」背后都是词表里多一条同义词、权重列里多一个数字。把数据做扎实,辅助诊断的推理逻辑就算用最简单的加权求和,也能让答辩老师觉得你在认真地解决一个真实问题。希望帮到你。
本文还有配套的精品资源,点击获取