基于医疗知识图谱的问答系统源码详解

承接上一篇系统搭建和遇到的问题,本篇解析系统功能的实现,并做了部分修改。

项目是找的中科院软件所刘焕勇老师在github上的开源项目,基于知识图谱的医药领域问答项目QABasedOnMedicaKnowledgeGraph。

该项目立足医药领域,以垂直型医药网站为数据来源,以疾病为核心,构建起一个包含7类规模为4.4万的知识实体,11类规模约30万实体关系的知识图谱。
原始数据包含8000多种病,和肝病相关的有200多种病。

原项目地址:https://github.com/liuhuanyong/QASystemOnMedicalKG
修改版项目地址(详细注释):https://github.com/vivianLL/QASystemOnHepatopathyKG

项目介绍:医药领域知识图谱快速及医药问答项目
系统搭建过程参考:医疗知识图谱问答系统探究(一)
系统搭建过程中踩的坑:基于医疗知识图谱的问答实践中遇到的问题

一、项目介绍

该项目的数据来自垂直类医疗网站寻医问药,使用爬虫脚本data_spider.py,以结构化数据为主,构建了以疾病为中心的医疗知识图谱,实体规模4.4万,实体关系规模30万。schema的设计根据所采集的结构化数据生成,对网页的结构化数据进行xpath解析。

项目的数据存储采用Neo4j图数据库,问答系统采用了规则匹配方式完成,数据操作采用neo4j声明的cypher。

项目的不足之处在于疾病的引发原因、预防等以大段文字返回,这块可引入事件抽取,可将原因结构化表示出来。
在这里插入图片描述
项目主要文件目录如下:

├── QASystemOnMedicalKG
    ├── answer_search.py               # 问题查询及返回
    ├── build_medicalgraph.py          # 将结构化json数据导入neo4j
    ├── chatbot_graph.py               # 问答程序脚本
    ├── QASystemOnMedicalKG/data
        ├── hepatopathy.json           # 肝病知识数据
        ├── medical.json               # 全科知识数据
    ├── QASystemOnMedicalKG/dict
        ├── check.txt                  # 诊断检查项目实体库
        ├── deny.txt                   # 否定词库
        ├── department.txt             # 医疗科目实体库
        ├── disease.txt                # 疾病实体库
        ├── drug.txt                   # 药品实体库
        ├── food.txt                   # 食物实体库
        ├── producer.txt               # 在售药品库
        ├── symptom.txt                # 疾病症状实体库
    ├── QASystemOnMedicalKG/prepare_data
        ├── build_data.py              # 数据库操作脚本
        ├── data_spider.py             # 数据采集脚本
        ├── max_cut.py                 # 基于词典的最大前向/后向匹配
    ├── question_classifier.py         # 问句类型分类脚本
    ├── question_parser.py             # 问句解析脚本

二、爬虫部分

爬虫部分我没有实际操作,简单看了一下源码。
数据来源为寻医问药网的疾病百科 http://jib.xywy.com/ 。点入具体的疾病页面如下:
在这里插入图片描述
爬取疾病介绍页的简介、病因、预防、症状、检查、治疗、并发症、饮食保健等详情页的内容。
爬虫模块使用的是urllib库,数据存在MongoDB数据库中。
其中并发症使用了自己写的max_cut匹配脚本中的双向最大向前匹配max_biward_cut

三、知识库部分

知识库包含7类规模为4.4万的知识实体,11类规模约30万实体关系,具体如下:
在这里插入图片描述在这里插入图片描述
(注意:belongs_to包括 科室属于科室 和 疾病属于科室 两种关系)
在这里插入图片描述
(注意:疾病的属性还包括cure_department)
知识库的构建是通过build_medicalgraph.py脚本实现。

build_medicalgraph.py

该脚本构建了一个MedicalGraph类,定义了Graph类的成员变量g和json数据路径成员变量data_path

class MedicalGraph:
    def __init__(self):
        cur_dir = '\\'.join(os.path.abspath(__file__).split('\\')[:-1])   # 获取当前绝对路径的上层目录 linux中应用'/'split和join
        self.data_path = os.path.join(cur_dir, 'data\hepatopathy.json')   # 获取json文件路径
        self.g = Graph(
            host="127.0.0.1",  # neo4j 搭载服务器的ip地址,ifconfig可获取到
            http_port=7474,  # neo4j 服务器监听的端口号
            user="neo4j",  # 数据库user name,如果没有更改过,应该是neo4j
            password="******")

类中的函数如下:

read_nodes函数: 读取数据文件
  • 定义节点变量(list类型)
    disease_infos包含了所有的疾病信息,为元素为disease_dict(dict类型)的list

  • 定义节点实体关系变量(list类型)

  • 逐行读取json数据,每行一个disease_dict(dict类型),包含疾病的各种属性(注意:除上述8种属性还有cure_department和symptom两种实体也列入疾病dict里)

    • 对于json里的字典键,如果是疾病的属性,则加入disease_dict中:

      disease_dict['desc'] = data_json['desc']
      
    • 如果和疾病有关系,则加入对应的关系列表:

       for acompany in data_json['acompany']:
       	rels_acompany.append([disease, acompany])
      
    • 如果是某个其他实体,则加入对应的实体列表:

      check = data_json['check']
      checks += check
      

    注意

    • symptoms既是疾病的属性,又有疾病—症状的关系。

    • cure_department在json中有两种形式,除了添加cure_department属性到disease_dict实体字典里和departments实体列表里。还需要提取关系,如果只有一个科室则直接提取疾病—科室关系(rels_category),如果有两个科室,还需要提取科室—科室关系(rels_department)。

              if 'cure_department' in data_json:
                  cure_department = data_json['cure_department']
                  if len(cure_department) == 1:
                       rels_category.append([disease, cure_department[0]])
                  if len(cure_department) == 2:
                      big = cure_department[0]
                      small = cure_department[1]
                      rels_department.append([small, big])      # 提取科室——科室关系
                      rels_category.append([disease, small])
      
                  disease_dict['cure_department'] = cure_department
                  departments += cure_department
      
    • drug_details的形式为"drug_detail" : [ "惠普森穿心莲内酯片(穿心莲内酯片)", "北京同仁堂百咳静糖浆(百咳静糖浆)"],即包括药品名和生产厂商,因为字符串和括号的原因,提取药品—在售药品的关系的方式略有不同:

              if 'drug_detail' in data_json:
                  drug_detail = data_json['drug_detail']
                  producer = [i.split('(')[0] for i in drug_detail]
                  rels_drug_producer += [[i.split('(')[0], i.split('(')[-1].replace(')', '')] for i in drug_detail]
                  producers += producer
      
  • 函数返回set去重后的所有实体、疾病属性信息和实体间关系。

create_graphnodes函数:创建知识图谱实体节点类型schema

首先调用read_nodes函数得到存储实体和实体间关系的变量。
知识图谱中主要包含两类节点,一类为中心疾病节点,包含各种疾病属性;一类为普通实体节点,即药品、食物等,不包含属性,分别调用以下两个函数创建:

create_diseases_nodes函数:创建知识图谱中心疾病的节点

对每一条disease_infos,调用py2neo库中Graph类的create函数,在neo4j中创建label为"Disease"的Node,disease_dict中的属性即为节点中的属性。

node = Node("Disease", name=disease_dict['name'], desc=disease_dict['desc'],
                        prevent=disease_dict['prevent'] ,cause=disease_dict['cause'],
                        easy_get=disease_dict['easy_get'],cure_lasttime=disease_dict['cure_lasttime'],
                        cure_department=disease_dict['cure_department']
                        ,cure_way=disease_dict['cure_way'] , cured_prob=disease_dict['cured_prob'])
self.g.create(node)
create_node函数:创建普通实体节点模块

对每一类实体,在neo4j中创建label为实体类别,name为具体实体名称的节点。

        for node_name in nodes:
            node = Node(label, name=node_name)
            self.g.create(node)
create_graphrels函数:创建实体关系边

同样调用read_nodes函数得到存储实体和实体间关系的变量。
再对模块函数create_relationship传入不同的变量参数,创建11类实体关系边。

create_relationship函数:创建实体关联边模块

首先对存储实体关系的list变量进行去重,因为实体关系为形如[[“a”,“b”],[“c”,“d”]]的嵌套list,无法直接用set去重,所以先将嵌套内层的list转为字符串,再用set。
去重后调用py2neo库中Graph类的run函数,使用Cypher语言直接执行Neo4j CQL语句,对每一对实体关系在neo4j里创建边:

query = "match(p:%s),(q:%s) where p.name='%s'and q.name='%s' create (p)-[rel:%s{name:'%s'}]->(q)" % (
                start_node, end_node, p, q, rel_type, rel_name)
try:
    self.g.run(query)
    count += 1
    print(rel_type, count, all)
except Exception as e:
    print(e)
export_data函数:导出数据到txt

调用read_nodes函数得到存储实体和实体间关系的变量。
逐行写入各变量对应的txt。

四、问答部分

问答系统支持的问答类型

在这里插入图片描述在这里插入图片描述
本项目的问答系统完全基于规则匹配实现,通过关键词匹配,对问句进行分类,医疗问题本身属于封闭域类场景,对领域问题进行穷举并分类,然后使用cypher的match去匹配查找neo4j,根据返回数据组装问句回答,最后返回结果。
问答框架的构建是通过chatbot_graph.pyanswer_search.pyquestion_classifier.pyquestion_parser.py等脚本实现。

chatbot_graph.py

首先从需要运行的chatbot_graph.py文件开始分析。
该脚本构造了一个问答类ChatBotGraph,定义了QuestionClassifier类型的成员变量classifierQuestionPase类型的成员变量parserAnswerSearcher类型的成员变量searcher

class ChatBotGraph:
    def __init__(self):
        self.classifier = QuestionClassifier()
        self.parser = QuestionPaser()
        self.searcher = AnswerSearcher()

该问答类的成员函数仅有一个chat_main函数

chat_main函数

首先传入用户输入问题,调用self.classifier.classify进行问句分类,如果没有对应的分类结果,则输出模板句式。如果有分类结果,则调用self.parser.parser_main对问句进行解析,再调用self.searcher.search_main查找对应的答案,如果有则返回答案,如果没有则输出模板句式。

def chat_main(self, sent):
    answer = '您好,我是肝病问答小助手,希望可以帮到您。祝您身体棒棒!'
    res_classify = self.classifier.classify(sent)
    if not res_classify:
        return answer
    res_sql = self.parser.parser_main(res_classify)
    final_answers = self.searcher.search_main(res_sql)
    if not final_answers:
        return answer
    else:
        return '\n'.join(final_answers)

由此可以看出,问答框架包含问句分类、问句解析、查询结果三个步骤,具体一步步分析。

首先是问句分类,是通过question_classifier.py脚本实现的。

question_classifier.py

该脚本构造了一个问题分类的类QuestionClassifier,定义了特征词路径、特征词、领域actree、词典、问句疑问词等成员变量。
特征词除了7类实体还包括由全部7类实体词构成的领域词region_words、否定词库deny_words。
构建领域actree通过调用self.build_actree实现。
构建词典通过调用self.build_wdtype_dict()实现。
问句疑问词包含了疾病的属性和边相关的问题词,参考上文中问答系统支持的问答类型。

build_actree函数

该函数构建领域actree,加速过滤。通过python的ahocorasick库实现。
ahocorasick是一种字符串匹配算法,由两种数据结构实现:trie和Aho-Corasick自动机。
Trie是一个字符串索引的词典,检索相关项时时间和字符串长度成正比。
AC自动机能够在一次运行中找到给定集合所有字符串。AC自动机其实就是在Trie树上实现KMP,可以完成多模式串的匹配。
具体ahocorasick用法非本文重点,可参考https://blog.csdn.net/pirage/article/details/51657178等博文。

    def build_actree(self, wordlist):
        actree = ahocorasick.Automaton()         # 初始化trie树
        for index, word in enumerate(wordlist):
            actree.add_word(word, (index, word))     # 向trie树中添加单词
        actree.make_automaton()    # 将trie树转化为Aho-Corasick自动机
        return actree
build_wdtype_dict函数

该函数根据7类实体构造 {特征词:特征词对应类型} 词典。

wd_dict = dict()
for wd in self.region_words:
    wd_dict[wd] = []
    if wd in self.disease_wds:
        wd_dict[wd].append('disease')
        ...
check_medical函数

通过ahocorasick库的iter()函数匹配领域词,将有重复字符串的领域词去除短的,取最长的领域词返回。功能为过滤问句中含有的领域词,返回{问句中的领域词:词所对应的实体类型}。

def check_medical(self, question):
    region_wds = []
    for i in self.region_tree.iter(question):   # ahocorasick库 匹配问题  iter返回一个元组,i的形式如(3, (23192, '乙肝'))
        wd = i[1][1]      # 匹配到的词
        region_wds.append(wd)
    stop_wds = []
    for wd1 in region_wds:
        for wd2 in region_wds:
            if wd1 in wd2 and wd1 != wd2:
                stop_wds.append(wd1)       # stop_wds取重复的短的词,如region_wds=['乙肝', '肝硬化', '硬化'],则stop_wds=['硬化']
    final_wds = [i for i in region_wds if i not in stop_wds]     # final_wds取长词
    final_dict = {i:self.wdtype_dict.get(i) for i in final_wds}
    return final_dict
check_word函数

该函数检查问句中是否含有某实体类型内的特征词。

def check_words(self, wds, sent):
    for wd in wds:
        if wd in sent:
            return True
    return False
classify函数

该函数为分类主函数。
首先调用check_medical函数,获取问句中包含的领域词及其所在领域,并收集问句当中所涉及到的实体类型;
接着基于特征词进行分类,即调用check_word函数,看问句中是否包含某领域特征词,以及该领域是否在问句中包含的region_words的实体类型(types)里,以此来判断问句属于哪种类型。(好绕)
示例如下:

# 症状
if self.check_words(self.symptom_qwds, question) and ('disease' in types):
    question_type = 'disease_symptom'
    question_types.append(question_type)

if self.check_words(self.symptom_qwds, question) and ('symptom' in types):
    question_type = 'symptom_disease'
    question_types.append(question_type)
#已知食物找疾病
if self.check_words(self.food_qwds+self.cure_qwds, question) and 'food' in types:
    deny_status = self.check_words(self.deny_words, question)
    if deny_status:
        question_type = 'food_not_disease'
    else:
        question_type = 'food_do_disease'
    question_types.append(question_type)

如果没有查到若没有查到相关的外部查询信息,且类型为疾病,那么则将该疾病的描述信息返回(question_types = ['disease_desc']);若类型为症状,那么则将该症状的对应的疾病信息返回(question_types = ['symptom_disease'])。
然后将分类结果进行合并处理,组装成一个字典返回。
注意

  • 食物相关的问题需要检查否定词self.deny_words来判断是do_eat还是not_eat。
  • 已知食物找疾病和已知检查项目查相应疾病的时候,check_words需要加上self.cure_qwds

question_parser.py

问句分类后需要对问句进行解析。
该脚本创建一个QuestionPaser类,该类包含三个成员函数。

build_entitydict函数

例如:从分类结果的{'args': {'头痛': ['disease', 'symptom']}, 'question_types': ['disease_cureprob']}中获取args,返回{'disease': ['头痛'], 'symptom': ['头痛']}的形式。

sql_transfer函数

该函数真的不同的问题类型,转换为Cypher查询语言并返回。
例如:

# 查询疾病的原因
if question_type == 'disease_cause':
    sql = ["MATCH (m:Disease) where m.name = '{0}' return m.name, m.cause".format(i) for i in entities]
# 查询疾病的忌口
elif question_type == 'disease_not_food':
    sql = ["MATCH (m:Disease)-[r:no_eat]->(n:Food) where m.name = '{0}' return m.name, r.name, n.name".format(i) for i in entities]

注意

  • 查询可能为查询中心疾病节点的属性,也可能为查询关联边。
  • 疾病的并发症需要双向查询。
  • 建议吃的东西包括do_eat和recommand_eat两种关联边。
  • 查询药品相关记得扩充药品别名,包括common_drug和recommand_durg两种关联边。
parser_main函数

该函数为问句解析主函数。
首先传入问句分类结果,获取问句中领域词及其实体类型。
接着调用build_entitydict函数,返回形如{'实体类型':['领域词'],...}的entity_dict字典。
然后对问句分类返回值中[‘question_types’]的每一个question_type,调用sql_transfer函数转换为neo4j的Cypher语言。
最后组合每种question_type转换后的sql查询语句。

answer_search.py

问句解析之后需要对解析后的结果进行查询。
该脚本创建了一个AnswerSearcher类。与build_medicalgraph.py类似,该类定义了Graph类的成员变量g和返回答案列举的最大个数num_list
该类的成员函数有两个,一个查询主函数一个回复模块。

search_main函数

传入问题解析的结果sqls,将保存在queries里的[‘question_type’]和[‘sql’]分别取出。
首先调用self.g.run(query).data()函数执行[‘sql’]中的查询语句得到查询结果,
再根据[‘question_type’]的不同调用answer_prettify函数将查询结果和答案话术结合起来。
最后返回最终的答案。

answer_prettify函数

该函数根据对应的qustion_type,调用相应的回复模板。
示例如下:

elif question_type == 'disease_cause':
    desc = [i['m.cause'] for i in answers]
    subject = answers[0]['m.name']
    final_answer = '{0}可能的成因有:{1}'.format(subject, ';'.join(list(set(desc))[:self.num_limit]))

五、改进

1. 缺失实体填充

在用户连续提问的时候,缺失使用上轮对话的疾病,如:

用户:乙肝怎么治
小助手: 乙肝可以尝试如下治疗:药物治疗;支持性治疗;对症治疗
用户:那有什么忌口吗
小助手: 乙肝忌食的食物包括有:咸鱼;咸鸭蛋;鸭血(白鸭);啤酒

这里用户的第二个问题没有疾病实体,默认采用上一轮的疾病实体。
方法是在question_classifier.pycheck_medical函数里增加全局变量:

global diseases_dict
if final_dict:
    diseases_dict = final_dict

并在classify函数里判断:

if not medical_dict:
    if 'diseases_dict' in globals():    # 判断是否是首次提问,若首次提问,则diseases_dict无值
        medical_dict = diseases_dict
    else:
        return {}
2. 增加疾病属性can_eat

增加了一个疾病属性:can_eat,对应增加了一个问题分类:

# 推荐食品
if self.check_words(self.food_qwds, question) and 'disease' in types:
    deny_status = self.check_words(self.deny_words, question)
    if deny_status:
        question_type = 'disease_not_food'
    else:
        question_type = 'disease_do_food'
    if self.check_words(['能吃','能喝','可以吃','可以喝'], question):
        question_types.append('disease_can_eat')
    print(question_type)
    question_types.append(question_type)

从构建知识图谱到问句分类、问句解析、查询结果也需作出相应修改。

3.补充个别问句疑问词

使覆盖的问句更全,详见修改版github。

六、总结

基于规则的问答系统没有复杂的算法,一般采用模板匹配的方式寻找匹配度最高的答案,回答结果依赖于问句类型、模板语料库的覆盖全面性,面对已知的问题,可以给出合适的答案,对于模板匹配不到的问题或问句类型,经常遇到的有三种回答方式:
1、给出一个无厘头的答案;
2、婉转的回答不知道,提示用户换种方式去问;
3、转移话题,回避问题;

基于知识图谱的问答系统的主要特征是知识图谱,系统依赖一个或多个领域的实体,并基于图谱进行推理或演绎,深度回答用户的问题,基于知识图谱的问答系统更擅长回答知识性问题,与基于模板的聊天机器人有所不同的是它更直接、直观的给用户答案。对于不能回答、或不知道的问题,一般直接返回失败,而不是转移话题避免尴尬。

整个问答系统的优劣依赖于知识图谱中知识的数量与质量。也算是利弊共存吧!知识图谱图谱具有良好的可扩展性,扩展了知识图谱也就是扩展了问答系统的知识库。如果问句在射程范围内,可轻松回答,但如果不幸脱靶,则体验大打折扣。

从知识图谱的角度分析,大多数知识图谱规模不足,主要原因还是数据来源以及技术上知识的抽取与推理困难。

评论 93
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值