一、项目介绍
该项目的数据来自垂直类医疗网站寻医问药,使用爬虫脚本data_spider.py,以结构化数据为主,构建了以疾病为中心的医疗知识图谱,实体规模4.4万,实体关系规模30万。schema的设计根据所采集的结构化数据生成,对网页的结构化数据进行xpath解析。
项目的数据存储采用Neo4j图数据库,问答系统采用了规则匹配方式完成,数据操作采用neo4j声明的cypher。
项目的不足之处在于疾病的引发原因、预防等以大段文字返回,这块可引入事件抽取,可将原因结构化表示出来。
项目主要文件目录如下:
python">├── 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。
python">class MedicalGraph:def __init__(self):cur_dir = '\\'.join(os.path.abspath(__file__).split('\\')[:-1]) # 获取当前绝对路径的上层目录 linux中应用'/'split和joinself.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,如果没有更改过,应该是neo4jpassword="******")
类中的函数如下:
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)。
python"> 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_departmentdepartments += cure_department
drug_details的形式为"drug_detail" : [ "惠普森穿心莲内酯片(穿心莲内酯片)", "北京同仁堂百咳静糖浆(百咳静糖浆)"],即包括药品名和生产厂商,因为字符串和括号的原因,提取药品—在售药品的关系的方式略有不同:
python"> 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中的属性即为节点中的属性。
python">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为具体实体名称的节点。
python"> 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里创建边:
python">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 += 1print(rel_type, count, all)
except Exception as e:print(e)
export_data函数:导出数据到txt
调用read_nodes函数得到存储实体和实体间关系的变量。
逐行写入各变量对应的txt。
四、问答部分
问答系统支持的问答类型
本项目的问答系统完全基于规则匹配实现,通过关键词匹配,对问句进行分类,医疗问题本身属于封闭域类场景,对领域问题进行穷举并分类,然后使用cypher的match去匹配查找neo4j,根据返回数据组装问句回答,最后返回结果。
问答框架的构建是通过chatbot_graph.py、answer_search.py、question_classifier.py、question_parser.py等脚本实现。
chatbot_graph.py
首先从需要运行的chatbot_graph.py文件开始分析。
该脚本构造了一个问答类ChatBotGraph,定义了QuestionClassifier类型的成员变量classifier、QuestionPase类型的成员变量parser和AnswerSearcher类型的成员变量searcher。
python">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查找对应的答案,如果有则返回答案,如果没有则输出模板句式。
python">def chat_main(self, sent):answer = '您好,我是肝病问答小助手,希望可以帮到您。祝您身体棒棒!'res_classify = self.classifier.classify(sent)if not res_classify:return answerres_sql = self.parser.parser_main(res_classify)final_answers = self.searcher.search_main(res_sql)if not final_answers:return answerelse: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等博文。
python"> 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类实体构造 {特征词:特征词对应类型} 词典。
python">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()函数匹配领域词,将有重复字符串的领域词去除短的,取最长的领域词返回。功能为过滤问句中含有的领域词,返回{问句中的领域词:词所对应的实体类型}。
python">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函数
该函数检查问句中是否含有某实体类型内的特征词。
python">def check_words(self, wds, sent):for wd in wds:if wd in sent:return Truereturn False
classify函数
该函数为分类主函数。
首先调用check_medical函数,获取问句中包含的领域词及其所在领域,并收集问句当中所涉及到的实体类型;
接着基于特征词进行分类,即调用check_word函数,看问句中是否包含某领域特征词,以及该领域是否在问句中包含的region_words的实体类型(types)里,以此来判断问句属于哪种类型。(好绕)
示例如下:
python"># 症状
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)
python">#已知食物找疾病
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,调用相应的回复模板。
示例如下:
python">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. 缺失实体填充
在用户连续提问的时候,缺失使用上轮对话的疾病,如:
python">用户:乙肝怎么治
小助手: 乙肝可以尝试如下治疗:药物治疗;支持性治疗;对症治疗
用户:那有什么忌口吗
小助手: 乙肝忌食的食物包括有:咸鱼;咸鸭蛋;鸭血(白鸭);啤酒
这里用户的第二个问题没有疾病实体,默认采用上一轮的疾病实体。
方法是在question_classifier.py的check_medical函数里增加全局变量:
python">global diseases_dict
if final_dict:diseases_dict = final_dict
并在classify函数里判断:
python">if not medical_dict:if 'diseases_dict' in globals(): # 判断是否是首次提问,若首次提问,则diseases_dict无值medical_dict = diseases_dictelse:return {}
2. 增加疾病属性can_eat
增加了一个疾病属性:can_eat,对应增加了一个问题分类:
python"># 推荐食品
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。
六、总结
基于规则的问答系统没有复杂的算法,一般采用模板匹配的方式寻找匹配度最高的答案,回答结果依赖于问句类型、模板语料库的覆盖全面性,面对已知的问题,可以给出合适的答案,对于模板匹配不到的问题或问句类型,经常遇到的有三种回答方式:
- 给出一个无厘头的答案;
- 婉转的回答不知道,提示用户换种方式去问;
- 转移话题,回避问题;
基于知识图谱的问答系统的主要特征是知识图谱,系统依赖一个或多个领域的实体,并基于图谱进行推理或演绎,深度回答用户的问题,基于知识图谱的问答系统更擅长回答知识性问题,与基于模板的聊天机器人有所不同的是它更直接、直观的给用户答案。对于不能回答、或不知道的问题,一般直接返回失败,而不是转移话题避免尴尬。
整个问答系统的优劣依赖于知识图谱中知识的数量与质量。也算是利弊共存吧!知识图谱图谱具有良好的可扩展性,扩展了知识图谱也就是扩展了问答系统的知识库。如果问句在射程范围内,可轻松回答,但如果不幸脱靶,则体验大打折扣。
从知识图谱的角度分析,大多数知识图谱规模不足,主要原因还是数据来源以及技术上知识的抽取与推理困难。