多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

中医皮肤病知识图谱构建与辅助诊断:Neo4j与Python实战

中医皮肤病知识图谱构建与辅助诊断:Neo4j与Python实战 简介本资源是一套基于Python实现的真菌性中医皮肤病知识图谱及辅助诊断系统面向计算机、中医药信息化等专业的毕业设计、课程设计与项目开发学习者帮助解决从知识图谱构建到智能问诊落地的完整实践问题。压缩包共20个文件约12.26MB包含6个Python源码文件、6张结果图、4个Excel词典与转置数据、2个JSON疾病与病例数据以及LICENSE和README说明覆盖建图、信息抽取、层次聚类、关联规则、诊断模型与疗效评估等模块。目前已有210人学习下载。读者可获得可直接运行的源码与清晰目录结构参考病因、临床表现、药方、治疗方法的层次聚类树状图理解Neo4j知识图谱搭建、辅助诊断与治疗方案推荐流程并在此基础上延伸开发适合作为完整项目方案与排错参考。1. 真菌性中医皮肤病知识图谱从散落医案到可查询的诊断网络手上有一批中医皮肤科的真菌感染医案几百份格式五花八门——有的是老大夫手写的扫描件有的是Excel里零散记录的症候和方剂还有一部分是论文附录里的结构化数据。想从中快速回答一个问题“湿热下注型的足癣常用哪些中药组合”——靠翻文件半天找不全靠全文检索搜出来的东西没有关联性。这就是我做这个知识图谱的起点。这个系统要解决的核心问题是把中医皮肤病领域里散落的“证型—症状—方剂—中药—真菌类型”这几类实体用图结构串起来再在上面挂一个辅助诊断的推理层。输入一组症状系统沿着图谱的关系路径给出可能的证型排序和对应的方剂参考。适合谁看做中医信息化、医学知识图谱、或者毕业设计选题落在“知识图谱垂直领域”的同学。Python做图数据构建和推理Neo4j做存储和可视化前端给一个简单的查询界面。整套东西不复杂但坑不少下面把我走过的路拆开讲。2. 中医皮肤病知识图谱的数据层实体、关系怎么定本体怎么搭2.1 先定本体六类实体和它们的关系骨架知识图谱的第一步不是写代码是画本体。中医皮肤病这个领域我最终收敛到六类核心实体实体类型说明示例疾病西医病名中医病名足癣脚湿气证型中医辨证分型湿热下注、血虚风燥症状患者表现水疱、糜烂、瘙痒方剂经典方或经验方龙胆泻肝汤中药方剂组成龙胆草、黄芩真菌病原微生物红色毛癣菌、须癣毛癣菌关系定义同样关键。我用的关系类型包括疾病-有证型-证型、证型-表现为-症状、证型-推荐方剂-方剂、方剂-包含中药-中药、疾病-由真菌引起-真菌、中药-归经-经络。注意这里不要贪多。我一开始加了“体质”“季节”“年龄”等实体结果数据稀疏得没法用关系边大量为空。本体建模的原则是先保证核心链路的数据密度再考虑扩展。提示本体设计阶段建议用Protégé或简单的YAML文件先画出来别急着写代码。改本体的成本远低于改代码。2.2 数据清洗从医案文本到结构化三元组原始数据里最麻烦的是症状描述。比如“足趾间浸渍发白基底潮红渗液明显”这是一句话但需要拆成多个症状实体。我的做法是维护一个症状词典用最大正向匹配做初步切分再人工校对。import re import pandas as pd # 症状词典实际项目中约300个词条 symptom_dict [ 浸渍发白, 基底潮红, 渗液, 水疱, 糜烂, 瘙痒, 脱屑, 角化过度, 皲裂, 丘疹 ] def extract_symptoms(text): 从医案描述中抽取症状实体 found [] for sym in symptom_dict: if sym in text: found.append(sym) return list(set(found)) # 读取原始医案数据 df pd.read_csv(raw_cases.csv) df[symptoms] df[description].apply(extract_symptoms) # 过滤掉没有抽到症状的记录 df df[df[symptoms].map(len) 0] print(f有效记录数: {len(df)})这段代码的逻辑很直白遍历词典看哪些症状词出现在描述里。参数方面symptom_dict的质量直接决定抽取效果我建议从领域教材和医案中人工整理不要用通用分词工具硬切——中医术语的边界和普通文本不一样“浸渍发白”被切成“浸渍”和“发白”就丢了语义。2.3 证型与方剂的映射处理一对多和多对多一个证型可能对应多个方剂一个方剂也可能服务于多个证型。这种多对多关系在CSV里不好表达需要拆成独立的关系表。# 证型-方剂关系表构建 syndrome_formula [] for _, row in df.iterrows(): syndromes row[syndrome].split() # 证型可能多个 formulas row[formula].split() for s in syndromes: for f in formulas: syndrome_formula.append({ syndrome: s.strip(), formula: f.strip(), source: row[source] }) sf_df pd.DataFrame(syndrome_formula).drop_duplicates() print(f证型-方剂关系数: {len(sf_df)})这里的关键参数是分隔符。不同来源的数据用的分隔符不一样有的是中文分号有的是顿号有的是逗号。我一般先统计一下所有分隔符的出现频率再决定用哪个做split。drop_duplicates()是必须的因为同一对关系可能从多条医案中重复抽取。3. 用Neo4j构建知识图谱从CSV到图数据库的完整导入流程3.1 Neo4j环境准备与约束建立Neo4j的安装不展开讲社区版足够用。重点说导入前的准备工作——建约束和索引。没有约束的图数据库导入速度慢不说后续查询也容易出重复节点。// 为每类实体的唯一标识字段建约束 CREATE CONSTRAINT disease_name IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT syndrome_name IF NOT EXISTS FOR (s:Syndrome) REQUIRE s.name IS UNIQUE; CREATE CONSTRAINT symptom_name IF NOT EXISTS FOR (sym:Symptom) REQUIRE sym.name IS UNIQUE; CREATE CONSTRAINT formula_name IF NOT EXISTS FOR (f:Formula) REQUIRE f.name IS UNIQUE; CREATE CONSTRAINT herb_name IF NOT EXISTS FOR (h:Herb) REQUIRE h.name IS UNIQUE; CREATE CONSTRAINT fungus_name IF NOT EXISTS FOR (fu:Fungus) REQUIRE fu.name IS UNIQUE;约束建好之后导入时如果遇到重复节点Neo4j会自动走MERGE逻辑而不是CREATE避免图谱里出现两个“湿热下注”。这个坑我踩过——第一次导入没建约束结果同一个证型出现了17次查询结果全乱了。3.2 用Python驱动批量导入节点和关系导入方式有两种LOAD CSV和 Python驱动。我选Python驱动因为数据清洗逻辑已经在Python里了直接衔接更顺。from neo4j import GraphDatabase driver GraphDatabase.driver( bolt://localhost:7687, auth(neo4j, your_password) ) def create_disease_node(tx, name, tcm_name): tx.run( MERGE (d:Disease {name: $name}) SET d.tcm_name $tcm_name, namename, tcm_nametcm_name ) def create_relation(tx, from_label, from_name, rel_type, to_label, to_name): query ( fMATCH (a:{from_label} {{name: $from_name}}) fMATCH (b:{to_label} {{name: $to_name}}) fMERGE (a)-[:{rel_type}]-(b) ) tx.run(query, from_namefrom_name, to_nameto_name) with driver.session() as session: # 导入疾病节点 for _, row in disease_df.iterrows(): session.execute_write( create_disease_node, row[name], row[tcm_name] ) # 导入证型-症状关系 for _, row in ss_df.iterrows(): session.execute_write( create_relation, Syndrome, row[syndrome], MANIFESTS_AS, Symptom, row[symptom] )参数说明MERGE而不是CREATE保证幂等性重复执行不会产生重复节点。关系类型用大写蛇形命名这是Neo4j社区的惯例。execute_write会自动处理事务批量导入时比手动开事务省心。注意数据量超过1万条关系时逐条execute_write会明显变慢。我一般每500条攒一个批次用UNWIND批量写入速度能提升5到8倍。3.3 图谱质量校验三个必查指标导入完成后别急着做上层应用。先跑几个校验查询// 1. 孤立节点检查没有任何关系的节点 MATCH (n) WHERE NOT (n)--() RETURN labels(n), n.name LIMIT 25; // 2. 关系密度每个证型平均关联多少症状 MATCH (s:Syndrome)-[:MANIFESTS_AS]-(sym:Symptom) RETURN s.name, count(sym) AS symptom_count ORDER BY symptom_count DESC LIMIT 10; // 3. 方剂覆盖度有多少证型还没有关联方剂 MATCH (s:Syndrome) WHERE NOT (s)-[:RECOMMENDS]-(:Formula) RETURN count(s) AS uncovered_syndromes;孤立节点通常是数据清洗时漏掉的关系密度反映图谱的信息丰富度方剂覆盖度直接决定辅助诊断能不能给出方剂建议。我一般要求方剂覆盖度不低于85%否则回去补数据。4. 辅助诊断推理层从症状输入到证型排序的实现4.1 基于症状匹配的证型评分算法辅助诊断的核心逻辑是用户输入一组症状系统在图谱中查找哪些证型关联了这些症状按匹配度排序。最简单的做法是Jaccard相似度但中医辨证有个特点——某些症状是“主症”权重应该更高。def score_syndrome(input_symptoms, syndrome_symptoms, main_symptoms): input_symptoms: 用户输入的症状列表 syndrome_symptoms: 该证型关联的全部症状 main_symptoms: 该证型的主症列表权重2.0 input_set set(input_symptoms) syndrome_set set(syndrome_symptoms) main_set set(main_symptoms) # 基础匹配分 match input_set syndrome_set if not match: return 0.0 # 加权主症命中得2分兼症得1分 score 0.0 for sym in match: if sym in main_set: score 2.0 else: score 1.0 # 归一化除以该证型的理论最高分 max_score len(main_set) * 2.0 (len(syndrome_set) - len(main_set)) * 1.0 return score / max_score if max_score 0 else 0.0参数方面主症和兼症的区分需要从数据中标注。我的做法是在医案里统计每个症状在某个证型下出现的频率频率前30%的自动标为主症再人工审核一遍。权重2.0和1.0是经验值实际调参时可以根据诊断准确率做网格搜索。4.2 从Neo4j查询候选证型并排序def diagnose(session, input_symptoms, top_k5): # 查询所有关联了输入症状的证型 query MATCH (s:Syndrome)-[:MANIFESTS_AS]-(sym:Symptom) WHERE sym.name IN $symptoms WITH s, collect(DISTINCT sym.name) AS matched MATCH (s)-[:MANIFESTS_AS]-(all_sym:Symptom) WITH s, matched, collect(DISTINCT all_sym.name) AS all_symptoms RETURN s.name AS syndrome, matched, all_symptoms results session.run(query, symptomsinput_symptoms) scored [] for record in results: syndrome record[syndrome] matched record[matched] all_sym record[all_symptoms] # 这里main_symptoms需要从缓存或配置中获取 main_sym get_main_symptoms(syndrome) score score_syndrome(input_symptoms, all_sym, main_sym) scored.append((syndrome, score, matched)) scored.sort(keylambda x: x[1], reverseTrue) return scored[:top_k]这个查询分两步先找到至少命中一个症状的证型再拉取这些证型的全部症状用于评分。collect(DISTINCT ...)去重是因为一个证型可能通过多条路径关联到同一个症状。返回结果里带上matched列表前端可以高亮显示哪些症状命中了。4.3 方剂推荐与药物组成展开证型排序出来后沿着图谱关系往下走一层拿到方剂和中药。// 给定证型查询推荐方剂及其组成中药 MATCH (s:Syndrome {name: $syndrome})-[:RECOMMENDS]-(f:Formula) OPTIONAL MATCH (f)-[:CONTAINS]-(h:Herb) RETURN f.name AS formula, collect(h.name) AS herbsOPTIONAL MATCH是必须的——有些方剂在数据里没有完整的药物组成用普通MATCH会直接丢掉这些方剂。返回结果里如果herbs为空列表前端标注“组成待补充”而不是不显示。5. 避坑与排查中医知识图谱项目里最容易翻车的五个地方5.1 症状实体粒度不一致导致匹配失败现象用户输入“脚趾缝烂了”系统匹配不到任何证型。原因图谱里的症状实体是“趾间糜烂”用户口语和标准术语之间有鸿沟。解决加一层同义词映射表。我维护了一个synonym_map字典把口语表达映射到标准症状名。这个表需要持续迭代初期可以只覆盖高频的20个口语表达就能解决80%的匹配失败。5.2 Neo4j导入时中文编码问题现象CSV里的中文导入后变成乱码或问号。原因Neo4j默认编码不是UTF-8或者CSV文件本身带了BOM头。解决CSV保存时选“UTF-8无BOM”格式。Python读取时指定encodingutf-8-sig。导入前用file -i yourfile.csv确认编码。5.3 证型-方剂关系出现环形依赖现象查询某个证型的方剂时返回结果里出现了证型本身。原因数据清洗时把“方剂”字段里混入的证型名也当成了方剂实体。解决在导入前加一层实体类型校验。维护一个方剂名词典不在词典里的值标记为待审核不直接入库。这个校验步骤花了我半天时间写但省了后面两周的排查。5.4 辅助诊断结果总是偏向症状多的证型现象不管输入什么症状“湿热下注”永远排第一。原因归一化时没有考虑证型的症状总数差异。“湿热下注”关联了30个症状而“血虚风燥”只关联了8个前者的匹配绝对数天然更高。解决在评分函数里引入TF-IDF思想——症状越常见的证型匹配上的权重应该越低。具体做法是给每个症状算一个逆证型频率乘到匹配分上。5.5 前端可视化加载慢现象图谱节点超过500个时前端页面卡死。原因一次性把所有节点和关系推给前端渲染。解决分页加载或者按查询结果动态渲染。用户查什么证型就只返回该证型周围两跳以内的子图。Neo4j的apoc.path.subgraphNodes可以帮上忙但社区版需要额外装APOC插件。6. 把图谱用起来三个进阶技巧和我的调试习惯第一个技巧是用图谱做方剂配伍规律挖掘。辅助诊断只是最浅层的应用。你可以跑这样的查询找出所有治疗“湿热下注”的方剂中共同出现频率最高的三味药组合。这比单纯的证型推荐更有临床参考价值。MATCH (s:Syndrome {name: 湿热下注})-[:RECOMMENDS]-(f:Formula)-[:CONTAINS]-(h:Herb) WITH h.name AS herb, count(DISTINCT f) AS freq ORDER BY freq DESC LIMIT 10 RETURN collect(herb) AS top_herbs, collect(freq) AS frequencies第二个技巧是给关系加权重。目前图谱里的关系都是布尔型的——存在或不存在。但“龙胆泻肝汤”和“湿热下注”的关联强度显然比某个冷门方剂要高。我一般用医案中出现频次做权重写入关系属性weight查询时按权重排序。第三个技巧是定期做图谱的连通性分析。用apoc.path.expandConfig检查从疾病节点出发平均多少跳能到达中药节点。如果超过4跳说明中间层数据有缺失需要补全。调试习惯方面我每次改完数据导入逻辑都会先在一个测试库上跑一遍用MATCH (n) RETURN count(n)对比节点数用MATCH ()-[r]-() RETURN count(r)对比关系数。数字对不上绝不往生产库导。这个习惯帮我省了至少三次全量重导的麻烦。希望帮到你。本文还有配套的精品资源点击获取
返回列表