承接上一篇系统搭建和遇到的问题,本篇解析系统功能的实现,并做了部分修改。
项目是找的中科院软件所刘焕勇老师在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.py
、answer_search.py
、question_classifier.py
、question_parser.py
等脚本实现。
chatbot_graph.py
首先从需要运行的chatbot_graph.py
文件开始分析。
该脚本构造了一个问答类ChatBotGraph
,定义了QuestionClassifier
类型的成员变量classifier
、QuestionPase
类型的成员变量parser
和AnswerSearcher
类型的成员变量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.py
的check_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、转移话题,回避问题;
基于知识图谱的问答系统的主要特征是知识图谱,系统依赖一个或多个领域的实体,并基于图谱进行推理或演绎,深度回答用户的问题,基于知识图谱的问答系统更擅长回答知识性问题,与基于模板的聊天机器人有所不同的是它更直接、直观的给用户答案。对于不能回答、或不知道的问题,一般直接返回失败,而不是转移话题避免尴尬。
整个问答系统的优劣依赖于知识图谱中知识的数量与质量。也算是利弊共存吧!知识图谱图谱具有良好的可扩展性,扩展了知识图谱也就是扩展了问答系统的知识库。如果问句在射程范围内,可轻松回答,但如果不幸脱靶,则体验大打折扣。
从知识图谱的角度分析,大多数知识图谱规模不足,主要原因还是数据来源以及技术上知识的抽取与推理困难。