搭建一个简单的问答系统(Python)

今天下着蒙蒙细雨,天气有点冷,但是是我喜欢的天气。
前言:
此项目需要的数据:

  1. dev-v2.0.json: 这个数据包含了问题和答案的pair, 但是以JSON格式存在,需要编写parser来提取出里面的问题和答案。
  2. glove.6B: 这个文件需要从网上下载,下载地址为:https://nlp.stanford.edu/projects/glove/, 请使用d=100的词向量
检索式的问答系统

问答系统所需要的数据已经提供,对于每一个问题都可以找得到相应的答案,所以可以理解为每一个样本数据是 <问题、答案>。 那系统的核心是当用户输入一个问题的时候,首先要找到跟这个问题最相近的已经存储在库里的问题,然后直接返回相应的答案即可。 举一个简单的例子:

假设我们的库里面已有存在以下几个<问题,答案>:<"人工智能和机器学习的关系什么?", "其实机器学习是人工智能的一个范畴,很多人工智能的应用要基于机器学习的技术"> <"人工智能最核心的语言是什么?", ”Python“> .....

Part 1.1 第一部分: 读取文件,并把内容分别写到两个list里(一个list对应问题集,另一个list对应答案集
import json
def read_corpus(filepath):
    """
    读取给定的语料库,并把问题列表和答案列表分别写入到 qlist, alist 里面。 在此过程中,不用对字符换做任何的处理(
    qlist = ["问题1", “问题2”, “问题3” ....]
    alist = ["答案1", "答案2", "答案3" ....]
    务必要让每一个问题和答案对应起来(下标位置一致)
    """
    with open(filepath) as f:
        line = f.read()
    json_data = json.loads(line)
    # print(json_data)
    data = json_data['data']
    qlist = []
    alist = []
    for eachdata in data:
        for eachqas in eachdata['paragraphs']:
            for qa in eachqas['qas']:
                 if(len(qa['answers']))>0:
#                     有很多问题没有答案,所以需要判断去掉没有答案的数据
                    qlist.append(qa['question'])
                    alist.append(qa['answers'][0]['text'])
    assert len(qlist) == len(alist)  # 确保长度一样
    return qlist, alist
#读取语料库 corpus 是语料库的意思
Part 1.2 理解数据(可视化分析/统计信息)

对数据的理解是任何AI工作的第一步,需要充分对手上的数据有个更直观的理解。

# TODO: 统计一下在qlist 总共出现了多少个单词? 总共出现了多少个不同的单词?
#       这里需要做简单的分词,对于英文我们根据空格来分词即可,其他过滤暂不考虑(只需分词)
#统计出现多少个词
word_total = []
for each in qlist:
    word_total.extend(each.strip(' .!?').split(' ')) 
word_set = set(word_total)
print(len(word_total))
输出:
874076
['When', 'did', 'Beyonce', 'start', 'becoming', 'popular', 'What', 'areas', 'did', 'Beyonce', 'compete', 'in', 'when', 'she', 'was', 'growing', 'up', 'When', 'did', 'Beyonce', 'leave', "Destiny's", 'Child', 'and', 'become', 'a', 'solo', 'singer', 'In', 'what']
51841
# TODO: 统计一下qlist中每个单词出现的频率,并把这些频率排一下序,然后画成plot. 比如总共出现了总共7个不同单词,而且每个单词出现的频率为 4, 5,10,2, 1, 1,1
#       把频率排序之后就可以得到(从大到小) 10, 5, 4, 2, 1, 1, 1. 然后把这7个数plot即可(从大到小)
#       需要使用matplotlib里的plot函数。y轴是词频,rainbow 画出来的目的是想看词频的分布情况
from matplotlib import pyplot as plt
%matplotlib inline
word_dict = {}
for word in word_set:
#     count是从下标为0开始计算的,所以要加1
    word_dict[word] = word_total.count(word)+1
word_sort = sorted(word_dict.values(),reverse = True)
print(word_sort[:200])
plt.plot(word_sort)
image.png
1.3 文本预处理

此部分需要尝试做文本的处理。在这里我们面对的是英文文本,所以任何对英文适合的技术都可以考虑进来。

# TODO: 对于qlist, alist做文本预处理操作。 可以考虑以下几种操作:
#       1. 停用词过滤 (去网上搜一下 "english stop words list",会出现很多包含停用词库的网页,或者直接使用NLTK自带的)   
#       2. 转换成lower_case: 这是一个基本的操作   
#       3. 去掉一些无用的符号: 比如连续的感叹号!!!, 或者一些奇怪的单词。
#       4. 去掉出现频率很低的词:比如出现次数少于10,20....
#       5. 对于数字的处理: 分词完只有有些单词可能就是数字比如44,415,把所有这些数字都看成是一个单词,这个新的单词我们可以定义为 "#number"
#       6. stemming(利用porter stemming): 因为是英文,所以stemming也是可以做的工作
#       7. 其他(如果有的话)
#       请注意,不一定要按照上面的顺序来处理,具体处理的顺序思考一下,然后选择一个合理的顺序
#  hint: 停用词用什么数据结构来存储? 不一样的数据结构会带来完全不一样的效率! 集合存储,时间复杂度试试O(1)
qlist, alist = read_corpus('./data/train-v2.0.json')
#用nltk的操作停用词 stem和分词
from nltk.corpus import stopwords
# 分词
from nltk.tokenize import word_tokenize
# 词干提取 lower_case
from nltk.stem import PorterStemmer
from collections import Counter
import math
# 1、分词,然后对每个词进行lower_case 
stemmer = PorterStemmer()
#加载停用词,去重,防止查询加慢速度
sw = set(stopwords.words('english'))
# 这些词的作用也是非常之大的
sw-={'who', 'when', 'why', 'where', 'how'}
# 这里只是随便去了下符号
sw.update(['\'s', '``', '\'\''])
#去重后的每句话的单词分词后的列表
def text_preprocessing(text):
    seg = []
     #word_tokenize分词
    for word in word_tokenize(text):
        
        word = stemmer.stem(word.lower())
        #判断word是不是全数字,用isdigit,若是则返回true,否则false
        word = '#number' if word.isdigit() else word
        #去掉停用词
        if len(word) > 1 and word not in sw:
            seg.append(word)
    return seg
#得到qlist和alist的方法
def get_update_list(q_a_list):
    
    #转换后最终得到的qlist
    q_a_list_seg = []
    #简单的计数器,返回list的key和value的字典,所以word_cnt是字典类型,则拥有字典的方法和属性
    words_cnt = Counter()
    for text in q_a_list:
        
        seg = text_preprocessing(text)
        q_a_list_seg.append(seg)
        #为了计算词的出现次数,然后去掉一些次数很低的词,所以要获取词和词频进行排序
        words_cnt.update(seg)

    #转换后最终得到的alist

    #去掉出现频率很低的词:比如出现次数少于10,20....
    #先对数据进行降序排序
    value_sort = sorted(words_cnt.values(),reverse=True)
    #在前面步骤里,我们删除了出现次数比较少的单词,那你选择的阈值是多少(小于多少的去掉?), 这个阈值是根据什么来选择的? 算出阈值
    # 根据Zipf定律计算99%覆盖率下的过滤词频,解释见程序下边
    min_tf = value_sort[int(math.exp(0.99 * math.log(len(words_cnt))))]
    for cur in range(len(q_a_list_seg)):
        q_a_list_seg[cur] = [ word for word in q_a_list_seg[cur] if words_cnt[word] > min_tf]
    return q_a_list_seg
#更新后的qlist和alist
# qlist, alist = read_corpus('./data/train-v2.0.json')
qlist = get_update_list(qlist)
# alist = get_update_list(alist)
print(len(qlist))
print(qlist[:11])
1.4 文本表示

当我们做完关键的预处理过程之后,就需要把每一个文本转换成向量。

# TODO: 把qlist中的每一个问题字符串转换成tf-idf向量, 转换之后的结果存储在X矩阵里。 X的大小是: N* D的矩阵。 这里N是问题的个数(样本个数),
#       D是字典库的大小。 
# 使用skearn来转换矩阵
from sklearn.feature_extraction.text import TfidfVectorizer
vectorizer = TfidfVectorizer()# 定义一个tf-idf的vectorizer
X = vectorizer.fit_transform([' '.join(seg) for seg in qlist]) #训练数据得到稀疏矩阵,# 结果存放在X矩阵
print(X.shape)
# TODO: 矩阵X有什么特点? 计算一下它的稀疏度
#稀疏度是表示为0的所占比例,那么就是1-为1所占的比例
#X.nnz活得矩阵非0的个数
def sparsity_radio(X):
    return 1.0-X.nnz/(X.shape[0]*X.shape[1])
sparsity = sparsity_radio(X)
print (sparsity)  # 打印出稀疏度(sparsity)
#由打印的答案可知,稀疏度是非常高的,意味着计算不是很好的,这是一种很差的体验。
输出:0.9995937135512636
1.5 对于用户的输入问题,找到相似度最高的TOP5问题,并把5个潜在的答案做返回
def top5results(input_q):
    """
    给定用户输入的问题 input_q, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 对于用户的输入 input_q 首先做一系列的预处理,然后再转换成tf-idf向量(利用上面的vectorizer)
    2. 计算跟每个库里的问题之间的相似度
    3. 找出相似度最高的top5问题的答案
    """
    #上面训练的向量已经把对应的特征值获取得到,直接transform问题进去,就可以把问题转换成跟数据一样的维度
    q_vector = vectorizer.transform([' '.join(text_preprocessing(input_q))])
    print(q_vector.shape)
    #计算两两句子之间的计算余弦相似度
    #因为vectorizer训练时,已经进行了L2范数归一化,norm=l2,l2 = xi/(√Ex^2)(分母是该点所有值的平方开根号,相乘后正是符合余弦相似度的分母),则
   
    #计算两两句子之间的计算余弦相似度
    #因为vectorizer训练时,已经进行了L2范数归一化,norm=l2,l2 = xi/(√Ex^2)(分母是该点所有值的平方开根号,相乘后正是符合余弦相似度的分母),则
    sim = (X*q_vector.T).toarray()
    sim_list = [sim[cur][0] for cur in range(sim.shape[0])]
    # print(sim_list)
    # sim_ar = np.array(sim_list)
    # #使用numpy的argsort()获取每个值的index
    top_idxs = np.argsort(sim_list)[-5:].tolist()
     # top_idxs存放相似度最高的(存在qlist里的)问题的下标,拿到下表,可以再alist答案列表得出答案
    # hint: 利用priority queue来找出top results. 思考为什么可以这么做? 
    print(top_idxs)
    return [alist[i] for i in top_idxs]
# TODO: 编写几个测试用例,并输出结果
print (top5results("Which airport is closed"))
print (top5results("What government blocked aid after Cyclone Nargis"))
输出:
[14776, 41884, 60978, 77361, 9358]
['Nanjing Dajiaochang Airport', 'After the reunification', 'related', 'aerodrome with facilities for flights to take off and land', 'Plymouth City Airport']
(1, 14548)
[4139, 14394, 3055, 2973, 3060]
['The latent heat of water condensation amplifies convection', 'the British government', '10 days', 'foreign aid', 'Myanmar']
1.6 利用倒排表的优化

判断相似度时,如果一个个与语料库计算,则复杂度太高,不好,那么如何减少时间复杂度呢?
核心思路:层次过滤思想
如果采用层次过滤思想,则需要复杂度是递增的。(复杂度)过滤器1<(复杂度)过滤器2<(复杂度)过滤器3(余弦相似度)
倒排表可以快速找到“词”是属于哪个文章的
然后就可以进行过滤使用(倒排表),然后进行排序。
上面的算法,一个最大的缺点是每一个用户问题都需要跟库里的所有的问题都计算相似度。假设我们库里的问题非常多,这将是效率非常低的方法。 这里面一个方案是通过倒排表的方式,先从库里面找到跟当前的输入类似的问题描述。然后针对于这些candidates问题再做余弦相似度的计算。这样会节省大量的时间。

# TODO: 基于倒排表的优化。在这里,我们可以定义一个类似于hash_map, 比如 inverted_index = {}, 然后存放包含每一个关键词的文档出现在了什么位置,
#       也就是,通过关键词的搜索首先来判断包含这些关键词的文档(比如出现至少一个),然后对于candidates问题做相似度比较。
# 
from collections import defaultdict
#defaultdict 这个比简单的dict好处就是,如果没有该key,则会有一个默认值,如果是普通的dict,不存在则会报错。
inverted_idx = defaultdict(set)# 定一个一个简单的倒排表
#把所有的词对应的文章列出来 ('word':{1,3,5....})
for cur in range(len(qlist)):
    for word in qlist[cur]:
        inverted_idx[word].add(cur)

def top5results_invidx(input_q):
    """
    给定用户输入的问题 input_q, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 利用倒排表来筛选 candidate
    2. 对于用户的输入 input_q 首先做一系列的预处理,然后再转换成tf-idf向量(利用上面的vectorizer)
    3. 计算跟每个库里的问题之间的相似度
    4. 找出相似度最高的top5问题的答案
    """
    #得到分词list ,然后去得到有关的词的句子
    seg = text_preprocessing(input_q)
    candidates = set()
    for word in seg:
        # 取所有包含任意一个词的文档的并集
        candidates = candidates | inverted_idx[word]
    candidates = list(candidates)

    q_vector = vectorizer.transform([' '.join(seg)])
    # 计算余弦相似度,tfidf用的l2范数,所以分母为1;矩阵乘法
    sim = (X[candidates] * q_vector.T).toarray()
    sim_list = [sim[cur][0] for cur in range(sim.shape[0])]

    # sim_ar = np.array(sim_list)
    # #使用numpy的argsort()获取每个值的index
    top_idxs = np.argsort(sim_list)[-5:].tolist()
    #因为获取的是帅筛选后的index,不能按照顺序来获取,要从candidates得到是alist对应哪个值
    test_top = [candidates[i] for i in top_idxs]
    print(test_top)
    return [alist[i] for i in test_top]
    
# TODO: 编写几个测试用例,并输出结果
print (top5results_invidx("Which airport is closed"))
输出:
[14776, 41884, 60978, 77361, 9358]
['Nanjing Dajiaochang Airport', 'After the reunification', 'related', 'aerodrome with facilities for flights to take off and land', 'Plymouth City Airport']
1.7 基于词向量的文本表示

词袋模型 将所有词语装进一个袋子里,不考虑其词法和语序的问题,即每个词语都是独立的 上面所用到的方法论是基于词袋模型(bag-of-words model)。这样的方法论有两个主要的问题:1. 无法计算词语之间的相似度 2. 稀疏度很高。 在2.7里面我们 讲采用词向量作为文本的表示。词向量方面需要下载: https://nlp.stanford.edu/projects/glove/ (请下载glove.6B.zip),并使用d=100的词向量(100维)。

#将glove转换成word2vec
from gensim.models import KeyedVectors
from gensim.scripts.glove2word2vec import glove2word2vec
import numpy as np
#将GloVe转为word2vec
_ = glove2word2vec('./data/glove.6B/glove.6B.100d.txt', './data/glove2word2vec.6B.txt')
# 加载转化后的文件
model = KeyedVectors.load_word2vec_format('./data/glove2word2vec.6B.txt')
print(model)
# print(vetor)
def vector_get(seg):
    #100d向量
    vector = np.zeros((1,100))
    size = len(seg)
    #model.wv[word] 获取词向量 一百维的词向量,获取句子向量,则是用平均法则获取句子向量=词向量之和/size
    for word in seg:
    #     print( model.wv[word])
        try:
            vector += model.wv[word]
        except:
            #若该词不存在词向量,则跳过到下个词
            size = size-1
    #求平均法则
    return vector/size
            
X = np.zeros((len(qlist),100))
for cur in range(len(qlist)):
    X[cur] = vector_get(qlist[cur])
    
#对数据进行l2范数 ||X||
#其中的axis=0表示对矩阵的每一列求范数,axis=1表示对矩阵的每一行求范数, keeptime=True表示结果保留二维特性,keeptime=False表示结果不保留二维特性
X_norm = np.linalg.norm(X,axis=1,keepdims=True)
#方便求取余弦相似度
X = X/X_norm

def top5results_emb(input_q):
    seg = text_preprocessing(input_q)
    candidates = set()
    #调用倒排表,得到文章索引
    for word in seg:
            # 取所有包含任意一个词的文档的并集
        candidates = candidates | inverted_idx[word]
    candidates = list(candidates)
    q_vector = vector_get(seg)
    q_norm = np.linalg.norm(q_vector,axis=1,keepdims=True)
    q_vector = q_vector/q_norm
    #因为是array格式,不能直接相乘,因为维度不一样,或者转换成矩阵,或者用dot实现
    #
    sim = np.dot(X[candidates] ,q_vector.T)
#     sim = (X[candidates] @ q_vector.T)
#     sim = np.dot((X[candidates] * q_vector.T)).toarray()

    sim_list = [sim[cur][0] for cur in range(sim.shape[0])]
    top_idxs = np.argsort(sim_list)[-5:].tolist()
    #因为获取的是帅筛选后的index,不能按照顺序来获取,要从candidates得到是alist对应哪个值
    test_top = [candidates[i] for i in top_idxs]
    return [alist[i] for i in test_top]
    
# TODO: 编写几个测试用例,并输出结果
print (top5results_emb("Which airport is closed"))
print (top5results_emb("Which airport was shut down?"))
输出:
['Dushanbe International Airport', 'southern suburbs of Paris', 'within the departure areas', 'India', 'Plymouth City Airport']
['1967', 'Nanjing Dajiaochang Airport', 'Terminal C', 'Chengdu Shuangliu International Airport', 'Chengdu Shuangliu International Airport']
还算比较准确的,准确率还算很高的。

你可能感兴趣的:(搭建一个简单的问答系统(Python))