NLP基础:检索式问答系统实战

NLP基础:检索式问答系统实战

  • 1. 目的与思路
  • 2. 简单思路的实现
    • 2.1 问题-答案 库的读取
    • 2.2 对数据的相关统计
      • 2.2.1 单词统计
      • 2.2.2 单词频率统计
      • 2.2.3 Top10 单词统计
    • 2.3 对qlist进行预处理
    • 2.4 文本TF-IDF表示
    • 2.5 返回最匹配的TOP5 答案
  • 3. 基于倒排表的优化
    • 3.1 建立倒排表
    • 3.2 利用倒排表进行优化
  • 4. 基于词向量的文本表示
    • 4.1 embedding 获取
    • 4.2 句子的表示
    • 4.3 基于`词向量-倒排表`的问答系统
  • 5. 总结

1. 目的与思路

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

  1. 最简单的思路是:将用户的输入与问题库中每个问题进行比较,找到与输入最相似的问题,并将该问题对应的答案返回给用户即可。这里衡量相似度通过计算输入与问题表示之间的欧式距离、余弦相似度实现。
  2. 上述的思路简单,但是操作复杂度高,因为要计算输入与库中的每一个问题进行相似度计算。因此要考虑优化,引入倒排表,通过层层过滤,将可选的问题范围逐步缩小。比如,可以先筛选出与用户输入有1个公共字符的问题,甚至是2个、3个…条件越严格,那么候选的问题数量就越少,计算量大大减小。
  3. 计算输入与问题的相似度时,需要得到它们的向量表示。在这里,首先采用TF-IDF文本表示,进行计算;其次,采用已经训练好的glove.6B 100维词向量,通过average操作,得到句子的整体向量表示进行计算。

2. 简单思路的实现

将用户的输入与问题库中每个问题进行比较,找到与输入最相似的Top5问题,并将Top5问题对应的答案返回给用户即可。

2.1 问题-答案 库的读取

采用的数据集是机器阅读理解数据集(SQuAD 2.0),一共86821个问题-答案 pair。

#读取数据
# 分数(5)
import json
import matplotlib.pyplot as plt
import numpy as np
from nltk.stem.porter import PorterStemmer
from sklearn.feature_extraction.text import TfidfVectorizer

def read_corpus():
    """
    读取给定的语料库,并把问题列表和答案列表分别写入到 qlist, alist 里面。 在此过程中,不用对字符换做任何的处理(这部分需要在 Part 2.3里处理)
    qlist = ["问题1", “问题2”, “问题3” ....]
    alist = ["答案1", "答案2", "答案3" ....]
    务必要让每一个问题和答案对应起来(下标位置一致)
    """
    qlist = []
    alist = []
    with open("././data/train-v2.0.json") as f:
        all_data = json.load(f)['data']
        for data in all_data:
            paragraphs = data['paragraphs']
            for paragraph in paragraphs:
                for qa in paragraph['qas']:
                    # print(qa['id'])
                    if qa['answers']:
                        qlist.append(qa['question'])
                        alist.append(qa['answers'][0]['text'])
    assert len(qlist) == len(alist)  # 确保长度一样
    print("Load question and answer success. The length :{}".format(len(qlist)))
    return qlist, alist

qlist, alist = read_corpus()

运行结果:

Load question and answer success. The length :86821

2.2 对数据的相关统计

2.2.1 单词统计

# TODO: 统计一下在qlist 总共出现了多少个单词? 总共出现了多少个不同的单词?
#       这里需要做简单的分词,对于英文我们根据空格来分词即可,其他过滤暂不考虑(只需分词)
word_voc = []
for question in qlist:
    question = question.replace('?', ' ?')
    line = question.strip().split()
    word_voc += line
word_voc = set(word_voc)
word_total = len(word_voc)
print("Num of total words:{}".format(word_total))#51930

运行结果:

Num of total words:51930

2.2.2 单词频率统计

# TODO: 统计一下qlist中每个单词出现的频率,并把这些频率排一下序,然后画成plot. 比如总共出现了总共7个不同单词,而且每个单词出现的频率为 4, 5,10,2, 1, 1,1
#       把频率排序之后就可以得到(从大到小) 10, 5, 4, 2, 1, 1, 1. 然后把这7个数plot即可(从大到小)
#       需要使用matplotlib里的plot函数。y轴是词频
word_freq = {}#统计qlist的单词频率
for question in qlist:
    question = question.replace('?', ' ?')
    line = question.strip().split()
    for word in line:
        if word in word_freq:
            word_freq[word] += 1
        else:
            word_freq[word] = 1
sort_word_freq = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)
all_word = []
word_count = []
for word, count in sort_word_freq:
    all_word.append(word)
    word_count.append(count)
# scale_ls = range(len(all_word))
# plt.plot(scale_ls, word_count)
# plt.xticks(scale_ls, all_word)
plt.bar(range(20), word_count[:20], color='rgb', tick_label=all_word[:20])#画出前20个词
plt.show()

运行结果:
NLP基础:检索式问答系统实战_第1张图片
从上面的图中可以看, 这样的一个图的走势跟反比例函数形状很类似,也就是学术界的 Zipf’s law:第二常见的频率是最常见频率的出现次数的½,第三常见的频率是最常见的频率的1/3,第n常见的频率是最常见频率出现次数的1/n

2.2.3 Top10 单词统计

# TODO: 在qlist和alist里出现次数最多的TOP 10单词分别是什么?
word_freq_ = {}#统计alist的单词频率
for answer in alist:
    answer = answer.replace('.', ' .')
    line = answer.strip().split()
    for word in line:
        if word in word_freq_:
            word_freq_[word] += 1
        else:
            word_freq_[word] = 1
sort_word_freq_ = list(sorted(word_freq_.items(), key=lambda x: x[1], reverse=True))

print("qlist top 10 单词分别是:")
for x in range(10):
    print(all_word[x])

print("alist top 10 单词分别是:")
for y in range(10):
    print(sort_word_freq_[y][0])

运行结果:

qlist top 10 单词分别是:
?
the
What
of
in
to
was
is
did
what
alist top 10 单词分别是:
the
of
and
to
a
in
.
The
or
for

2.3 对qlist进行预处理


# TODO: 对于qlist做文本预处理操作。 可以考虑以下几种操作:
#       1. 停用词过滤 (去网上搜一下 "english stop words list",会出现很多包含停用词库的网页,或者直接使用NLTK自带的)
#       2. 转换成lower_case: 这是一个基本的操作
#       3. 去掉一些无用的符号: 比如连续的感叹号!!!, 或者一些奇怪的单词。
#       4. 去掉出现频率很低的词:比如出现次数少于10,20....
#       5. 对于数字的处理: 分词完只有有些单词可能就是数字比如44,415,把所有这些数字都看成是一个单词,这个新的单词我们可以定义为 "#number"
#       6. stemming(利用porter stemming): 因为是英文,所以stemming也是可以做的工作
#       7. 其他(如果有的话)
#       请注意,不一定要按照上面的顺序来处理,具体处理的顺序思考一下,然后选择一个合理的顺序
#  hint: 停用词用什么数据结构来存储? 不一样的数据结构会带来完全不一样的效率!
new_qlist = []
new_alist = []
stopwords =[]
porter_stemmer = PorterStemmer()
##加载停止词
with open("././data/stopwords") as f1:
    lines = f1.readlines()
    for line in lines:
        stopwords.append(line.strip())
stopwords = set(stopwords)

for question in qlist:
    tmp = ''
    for sign in ['.', '?', '/', '#', '$', '@', '^', '*', '!', '(', ')']:
        question = question.replace(sign, '')
    # question = question.replace('?', ' ?')
    line = question.strip().split()
    for word in line:
        try:
            if word_freq[word] <= 20:#筛选出频率大于20的词
                continue
            word = word.lower()
        except:
            pass
        if word in stopwords:
            continue
        for num in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]:#将含有数字的字符统一设置为1
            if str(num) in word:
                word = '1'
        word = porter_stemmer.stem(word)# 进行stemming 操作
        tmp = tmp + word + " "
    new_qlist.append(tmp[:-1])
    
qlist = new_qlist# 更新后的#得到更新后问题列表
print("预处理完成!")

2.4 文本TF-IDF表示

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

# TODO: 把qlist中的每一个问题字符串转换成tf-idf向量, 转换之后的结果存储在X矩阵里。 X的大小是: N* D的矩阵。 这里N是问题的个数(样本个数),
#       D是字典库的大小。

vectorizer = TfidfVectorizer(use_idf=True, smooth_idf=True, norm='l2') # 定义一个tf-idf的vectorizer

tf_idf_model = vectorizer.fit(qlist)
X = tf_idf_model.transform(qlist)
X = X.toarray()
print(len(vectorizer.get_feature_names()))

# TODO: 矩阵X有什么特点? 计算一下它的稀疏度
sparsity = 1.0 - np.count_nonzero(X)/X.size
print(sparsity)  # 打印出稀疏度(sparsity)

运行结果:

2688
0.9985813766871741

TF-IDF的维度(词典库的大小)为2688,qlist的稀疏度为0.99

2.5 返回最匹配的TOP5 答案

def top5results(input_q):
    """
    给定用户输入的问题 input_q, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 对于用户的输入 input_q 首先做一系列的预处理,然后再转换成tf-idf向量(利用上面的vectorizer)
    2. 计算跟每个库里的问题之间的相似度
    3. 找出相似度最高的top5问题的答案
    """
    #预处理
    tmp = ''
    for sign in ['.', '?', '/', '#', '$', '@', '^', '*', '!', '(', ')']:
        question = input_q.replace(sign, '')
    # question = question.replace('?', ' ?')
    line = question.strip().split()
    for word in line:
        try:
            if word_freq[word] <= 20:
                continue
            word = word.lower()
        except:
            pass
        if word in stopwords:
            continue
        for num in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]:
            if str(num) in word:
                word = '1'
        word = porter_stemmer.stem(word)
        tmp = tmp + word + " "
    input_str = [tmp[:-1]]
    input_str = tf_idf_model.transform(input_str).toarray()[0]

    simlarity = [0]*len(X)
    for index in range(len(X)):
        cos_sim = np.dot(input_str, X[index]) / (np.linalg.norm(input_str)*np.linalg.norm(X[index])+1)
        simlarity[index] = cos_sim

    top_idxs = []# top_idxs存放相似度最高的(存在qlist里的)问题的下表
    for _ in range(5):
        index = simlarity.index(max(simlarity))
        top_idxs.append(index)
        del simlarity[index]

    # hint: 利用priority queue来找出top results. 思考为什么可以这么做?

    return [alist[indx] for indx in top_idxs]# 返回相似度最高的问题对应的答案,作为TOP5答案

# TODO: 编写几个测试用例,并输出结果
print(top5results("What areas did Beyonce compete in when she was growing up?"))

运行结果:

[‘singing and dancing’,
‘2003’,
‘racial minorities and white liberals. Because of this, the area has consistently voted as one of the most Democratic areas of the state’,
‘crown’,
‘feminism and female empowerment’]

3. 基于倒排表的优化

基于倒排表的优化。在这里,我们可以定义一个类似于hash_map, 比如 inverted_index = {}, 然后存放包含每一个关键词的文档出现在了什么位置。也就是,通过关键词的搜索首先来判断包含这些关键词的文档(比如出现至少一个),然后对于candidates问题做相似度比较。

3.1 建立倒排表

得到一个倒排表,key为word,value为含有该word的文档索引,这里使用的qlist是最原始的qlist,并不是预处理后的qlist,因为只是需要进行筛选,没必要预处理

def generate_inverted_idx():
    """
    return : 返回一个倒排表,key为word,value为含有该word的文档索引
    """
    inverted_idx = {}  # 定义一个简单的倒排表

    for i in range(len(qlist)):
        quest = qlist[i].replace('?', ' ?')
        line = quest.strip().split()
        for word in line:
            if word in inverted_idx:
                inverted_idx[word].append(i)
            else:
                inverted_idx[word] = [i]

    return inverted_idx
inverted_idx = generate_inverted_idx()

3.2 利用倒排表进行优化

返回最匹配的TOP2 答案

def top5results_invidx(input_q, k):
    """
    param :input_q :输入的问题
    param :k:要包含问题的前K个字符,依次来对问题库进行过滤, k 越大要求越严格
    return : 返回相似度Top2的答案
    给定用户输入的问题 input_q, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 利用倒排表来筛选 candidate
    2. 对于用户的输入 input_q 首先做一系列的预处理,然后再转换成tf-idf向量(利用上面的vectorizer)
    3. 计算跟每个库里的问题之间的相似度
    4. 找出相似度最高的top5问题的答案
    """
    #利用倒排表,筛选出候选问题的索引 doc_lst 
    quest = input_q.replace('?', ' ?')
    line = quest.strip().split()
    doc_lst = range(len(qlist))
	# 得到包含用户输入的前 k 个字符的 candidates
    for j in range(k):
        word = line[j]
        doc = inverted_idx[word]
        doc_lst = list(set(doc_lst) & set(doc))

    # 预处理
    tmp = ''
    for sign in ['.', '?', '/', '#', '$', '@', '^', '*', '!', '(', ')']:
        question = input_q.replace(sign, '')
    # question = question.replace('?', ' ?')
    line = question.strip().split()
    for word in line:
        try:
            if word_freq[word] <= 20:
                continue
            word = word.lower()
        except:
            pass
        if word in stopwords:
            continue
        for num in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]:
            if str(num) in word:
                word = '1'
        word = porter_stemmer.stem(word)
        tmp = tmp + word + " "
    input_str = [tmp[:-1]]
    
	#获得用户输入的tf-idf表示
    input_str = tf_idf_model.transform(input_str).toarray()[0]

    simlarity = {}
    for index in doc_lst:
        cos_sim = np.dot(input_str, X[index]) / (np.linalg.norm(input_str) * np.linalg.norm(X[index]) + 1)
        simlarity[index] = cos_sim

    top_idxs = []  # top_idxs存放相似度最高的(存在qlist里的)问题的下表

    simlarity = list(sorted(simlarity.items(), key=lambda x: x[1], reverse=True))

    for _ in range(2):
        index, cos = simlarity[_]
        top_idxs.append(index)
    # hint: 利用priority queue来找出top results. 思考为什么可以这么做?

    return [alist[indx] for indx in top_idxs]  # 返回相似度最高的问题对应的答案,作为TOP5答案

# TODO: 编写几个测试用例,并输出结果
print (top5results_invidx("What areas did Beyonce compete in when she was growing up?", 3))

运行结果:

[‘singing and dancing’, ‘local customs and traditions’]

4. 基于词向量的文本表示

上述进行相似度的计算,都是基于句子的tf-idf表示,现在换成word embeding进行句子表示,得到基于词向量的返回结果。

4.1 embedding 获取

采取的是glove.6B 100维向量,直接去官网下载速度较慢,下载请参考glove.6B下载(百度云下载)。

emb = {}  # 读取每一个单词的嵌入.key为单词,value 为相应的词向量
with open("././data/glove.6B.100d.txt", encoding='utf-8') as f2:
    all_lines = f2.readlines()
    for one_line in all_lines:
        line_splits = one_line.strip().split()
        emb[line_splits[0]] = [np.float(x) for x in line_splits[1:]]

4.2 句子的表示

基于词向量,对一个句子的所有词的词向量取平均值,作为句子的向量表示。

def get_sentence_emb(input_str):
    """
    param: input_str :输入的句子
    return : 句子的表示
    """
    emb_list = []
    #句子的预处理
    # 其中,最简单的方式 句子向量 = 词向量的平均(出现在问句里的), 如果给定的词没有出现在词典库里,则忽略掉这个词。
    quest = input_str.replace('?', ' ?')
    line = quest.strip().split()
    for word in line:
        if word.lower() in emb:
            emb_list.append(emb[word.lower()])
        else:
            pass
    result = np.mean(np.array(emb_list), axis=0)
    return result

4.3 基于词向量-倒排表的问答系统

def top5results_emb(input_q, k):
    """
    param :input_q :输入的问题
    param :k:要包含问题的前K个字符,依次来对问题库进行过滤, k 越大要求越严格
    return : 返回相似度Top2的答案
    给定用户输入的问题 input_q, 返回最有可能的TOP 5问题。这里面需要做到以下几点:
    1. 利用倒排表来筛选 candidate
    2. 对于用户的输入 input_q,转换成句子向量
    3. 计算跟每个库里的问题之间的相似度
    4. 找出相似度最高的top5问题的答案
    """

    #通过过滤,得到少量候选问题
    quest = input_q.replace('?', ' ?')
    line = quest.strip().split()
    doc_lst = range(len(qlist))
    for j in range(k):
        word = line[j]
        doc = inverted_idx[word]
        doc_lst = list(set(doc_lst) & set(doc))


    simlarity = {}
    for index in doc_lst:
        input_emb = get_sentence_emb(input_q)
        doc_emd = get_sentence_emb(qlist[index])
        cos_sim = np.dot(input_emb, doc_emd) / (np.linalg.norm(input_emb) * np.linalg.norm(doc_emd) + 1)
        simlarity[index] = cos_sim

    top_idxs = []  # top_idxs存放相似度最高的(存在qlist里的)问题的下表

    simlarity = list(sorted(simlarity.items(), key=lambda x: x[1], reverse=True))

    for _ in range(2):
        index, cos = simlarity[_]
        top_idxs.append(index)
    # hint: 利用priority queue来找出top results. 思考为什么可以这么做?
    return [alist[indx] for indx in top_idxs]  # 返回相似度最高的问题对应的答案,作为TOP2答案

print (top5results_emb("What areas did Beyonce compete in when she was growing up?", 3))

运行结果:

[‘singing and dancing’, ‘local customs and traditions’]

5. 总结

  1. 倒排表这个概念有了进一步的理解。
  2. 了解numpy 里面范数的计算,比如np.linalg.norm计算向量的二范数。
  3. 基于搜索的问答还是太简单,不能够满足日常需要,现在的问答应该都是基于深度语义的,可是作为NLP的基础,可以通过此任务了解tf-idf的向量表示numpy基本数学操作倒排表英文文本处理流程、可以通过哪些方法得到词向量(Skip-gram、CBOW 、Glove、RNN/LSTM、Matrix Factorization、Gaussian Embedding)等等。

你可能感兴趣的:(NLP基础,python,nlp,自然语言处理)