【NLP】基于检索的聊天机器人构建
零、前言
这篇文章记录了如何搭建一个基于检索进行实现的聊天机器人
给予检索这种方式可以理解为类数据库查询的方式,即根据给定输入进行特定方式的计算,根据计算结果匹配已有数据库中的问题答案对,找到匹配可能性最大的,返回相对应的答案。
另一种方式是对于给定的训练问答对中对应的词进行训练,找到词与词之间存在的潜在联系,根据潜在联系匹配答案。
本文为初次尝试,因此仅限于第一种方式。
一、基于检索的聊天机器人概述
二、数据准备和下载
1. 数据下载
本文中采用的中文语料库来源于Github桑的开源项目:【codemayq/chinese_chatbot_corpus】
数据文件(raw_chat_corpus)下载自上述项目链接readme页面中的Google Drive链接
根据页面中提供的构建方法进行构建。
2. 数据准备
首先在指定的文件夹中git clone下来整个项目文件
1
git clone https://github.com/codemayq/chinese_chatbot_corpus.git
并将数据文件夹
raw_chat_corpus
放到clone的chinese_chatbot_corpus
目录中在
chinese_chatbot_corpus
目录中启动terminal,使用python运行main.py
文件即可1
python main.py
在目录
chinese_chatbot_coupus\clean_chat_corpus\
中,生成了各种数据清洗后的结果。本文采用该目录下的
xiaohuangji.tsv
文件作为中文语料库进行构建
三、代码编写
0、项目介绍
项目文件树
1
2
3
4/Users/user_name/.../Search_Based
├── clean_chat_corpus
│ └── xiaohuangji.tsv
└── main.py项目开发环境
- DEV: MacMini(2020) M1
- OS: macOS Big [email protected]
- RAM: 16G
- SSD: 1T
- Python-V: 3.8.6 based conda
- IDE: Visual Studio Code
- 版本: 1.55.0-insider
- 日期: 2021-03-22T05:26:20.879Z
- Electron: 11.3.0
- Chrome: 87.0.4280.141
- Node.js: 12.18.3
- V8: 8.7.220.31-electron.0
- OS: Darwin arm64 20.3.0
- Extension: Jupyter Extension
1、数据导入
导入必要的包
1
import pandas as pd
指定文件目录和文件
1
2data_path = "./clean_chat_corpus/" # 数据集路径
train_data_name = "xiaohuangji.tsv"数据优化
1
train_data = pd.read_csv(data_path + train_data_name, sep = '\t', header = None)
划分问题和答案集合
1
2q_list = train_data[0].tolist() # 问题list
a_list = train_data[1].tolist() # 答案list
2、使用jieba分词
对于中文句子计算tf-idf值的时候,由于中文不像英文等拥有天然的空格,因此需要预先分词。
为使用sklearn库中的CountVectorizer函数计算输入句子的矩阵,需要将输入的内容变为类似于英文句子的,使用空格分隔每一个单词的形式。
例句: “今天的天气非常好”
要求传入的内容: “今天 的 天气 非常 好”
因此再使用CountVectorizer函数之前,需要对于输入的内容(即问题集)的每一句内容进行形式转换。
因此可以使用下属两句内容,其中q_split_list
为使用jieba分词后的结果,其形式为["今天", "的", "天气", "非常", "好"]
而变量q_space_list
为使用空格分隔问题集中每一句话,其结果为["今天 的 天气 非常 好"]
1 | q_split_list = [jieba.cut(i) for i in q_list] # 将问题集使用jieba.cut函数进行切词 |
3、使用sklearn计算TF-IDF
在sklearn包中,有CountVectorizer
和TfidfTransform
两个类,前一个方法可以将原始文本转换为特征矩阵,后一个方法可以将特征矩阵转换为tf-idf表示的特征矩阵。
第一个类中的CountVectorizer.fit()
方法可以将原始文本转换为特征矩阵。第二个类中的TfidfTransform.transform()
方法可以将特征矩阵转换为tf-idf表示的特征矩阵。
同时,sklearn中同时提供了一个TfidfVectorizer
类,调用该类中的TfidfVectorizer.fit_transform()
方法来计算原始文本的向量表示,同时转换为tf-idf的表示方式返回。
因此在计算的时候,既可以分别调用前两个类,也可以直接调用第三个类进行学习和拟合。
本文为简化代码,使用合并调用
的方式进行调用
1 | from sklearn.feature_extraction.text import CountVectorizer, TfidfTransform |
1 | from sklearn.feature_extraction.text import TfidfVectorizer |
在上述代码中,由于sklearn中,计算文本特征矩阵的时候,默认只匹配长度为两个字符及以上的单字,因此需要修改函数的token_pattern
参数
根据代码文档,其默认参数为token_pattern=r"(?u)\b\w\w+\b"
,由此可知其匹配方式为正则表达式。
使用网站RegEx(https://regexr.com/)进行匹配验证,结果发现其对应的是长度大于等于2的字符串,因此对此部分进行修改,去除首个\w
的匹配限制,即整体改为r"(?u)\b\w+\b"
,这样即可匹配长度为1的单字。
相对于英语中长度为1的单字大多数为停用词不同,中文中长度为1的单字有可能在文本中起着重要的作用,因此不能够忽视。
4、使用计算好的TFIDF模型进行拟合
使用TfidfVectorizer
训练好的文本特征矩阵来计算新输入的文本的tfidf矩阵值
1 | input_content = [" ".join(i)for i in list(jieba.cut("我喜欢你"))] # 这里的文本即输入的文本 |
5、计算文本的余弦相似度
1 | tmp_cosine_sim = [] # 临时变量用于存储所有的计算好的余弦相似度 |
在上述代码中,首先计算输入句子的文本矩阵tfidf表示与语料库中每一条问句的余弦相似度,并将结果保存在tmp_cosine_sim
列表中
6、找到最匹配的问题和答案
变量max_value_index
为取得计算好的最大的文本相似度的下标,此下标对应着问题集中的下标,因此可以直接通过次下标取得问题集中的问题和答案集中的对应答案。
1 | max_value_index = tmp_cosine_sim.index(max(tmp_cosine_sim)) # 返回相似度最大的矩阵的下标 |
四、代码优化
在使用三
中的代码构建程序之后,给定输入你好
,发现程序久久无法停止计算,遂将程序增加输入查看究竟是卡在那里。
在更高到如下代码块的时候,发现计算长时间卡在此处。
每次计算输入的内容和问题集合的时候需要和问题集合中每一条问题进行计算,因此耗费了过长的时间。
1 | tmp_cosine_sim = [] |
那么有没有一种方式进行优化呢,那便是尝试减小余弦相似度计算的这一块儿部分的时间。
1、稀疏矩阵计算方式
(1. 提前进行转换
计算预想相似度时候,由于是稀疏矩阵,因此是否可以从稀疏矩阵进行优化呢?
首先尝试将每次计算时候的toarray行为进行削弱,即提前将稀疏矩阵进行转换。根据最初的构建方式,如果事先使用toarray将稀疏矩阵整体转换为array的话,会在计算余弦相似度的时候占用过多的内存而导致系统终止运行程序。
因此这里尝试将矩阵的每一行分别转换为array,这样计算的时候可能就不会出现相应的问题。
而在尝试的过程中发现,仍然会出现相应的问题,因此这种优化方式被放弃。
(2. 使用不同的计算方式
经过资料查询,上述代码所采用的计算余弦相似度的cosine_ismilarity
方法的输入不仅限于array方式,可以接受直接传入稀疏矩阵。
因此,代码改成了下述形式
1 | tmp_cosine_sim = [] |
然后使用手动计时的方式,进行时间统计并记录,测算3次相同的代码。
根据测试发现,计算余弦相似度的cosine_ismilarity
方法接受的参数不仅限于相同类型的数组,还可以同时接受两个不同的内容,比如上述的稀疏矩阵和array。
因此经过多次实验和不同的排列组合,最终发现,当输入参数左端为稀疏矩阵,右端为转换后的数组时,所消耗的时间是最短的。因此采用下述代码
1 | tmp_cosine_sim = [] |
(3. 优化余弦相似度的计算
根据分析余弦相似度的计算方式
右下角的的值是永远不变的,而变化的只有剩下的额内容,因此是否可以在循环之前先将这部分的内容计算出来,然后循环的时候直接使用常量进行带入,这样无论是从时间复杂度还是空间复杂度上都可以减少计算。
于是修改代码
1 | tmp_cosine_sim = [] |
经过多次试验相同代码和数据集,计时并记录,其所消耗的平均时间要远远高于无论如何排列组合的给定输入的计算余弦相似度的cosine_ismilarity
方法,因此这种优化思路无效。
2、采用倒排表进行优化
在给定的输入的情况下,上述方式需要遍历计算问题集合中的每一个问题,但是这些问题中,有一些是毫无相关性的。
比如给定输入: "你好"
在循环遍历的过程中,有一个问题是: "讲个笑话"
,这样的问题还有很多。
对于这种问题,显然是和给定输入完全没有关系的,那么有没有一种方法可以将这样子的内容提前过滤掉,这样减小的计算开销相对于针对余弦相似度计算方式和矩阵的计算方式是非常大的。
因此,有一种在搜索引擎中常用的文本处理方法倒排表
,这种方法的原理如下。
假设4个文档
- Doc1:我们,今天,运动
- Doc2:我们昨天运动
- Doc3:你们上课
- Doc4:你们上什么课
词典:[我们, 今天, 运动, 昨天, 上, 课, 什么]
我们:[Doc1, Doc2]
今天:[Doc1]
运动:[Doc1, Doc2]
昨天:[Docq2]
上:[Doc3, Doc4]
课:[Doc3, Doc4]
什么:[Docq2]
例如检索内容为“运动”,此时应返回[Doc1, Doc2]
使用倒排表之后先对问答库进行构建来抽取关键词
对输入的问题进行分词,在倒排表中寻找关键词相关的问题,然后再计算相似度查找最相关的内容
因此对于最初的例子,输入内容为"你好"
的时候,首先根据预先根据问题集合构建的倒排表筛选出与关键词你好
相关的问题,然后将这些问题单独提取出来。
再在初步根据倒排表筛选出来的问题和给定的输入内容进行相似度计算,这样便可以减小计算的次数和各种开销了。
根据本文使用的数据集进行计算的时候,计算次数由45w+的次数骤降到2k+的次数,因此本次优化可以采用。
代码如下
(1. 构建倒排表
1 | dic_index = {} |
遍历使用空格分割好的问题集,对于每一个出现的单词保存期出现的问题的list对应的下标。
最终词典dic_index
即为构建的倒排表,由于遍历时候需要频繁查询倒排表,因此采用字典方式进行存储减小时间开销
(2. 查询倒排表
1 | def get_index(str_list, dic_index): |
其中str_list为给定的输入list,dic_index为预先构建的文本倒排表。
根据每一个单词查询倒排表,并记录倒排表中返回的问题下标,将所有下标保存在tmp_list中,最后返回去重的tmp_list
(3. 修改对应的计算代码
1 | input_content = [sentence_process(input_str)] |
3、倒排表的进一步优化
在使用倒排表进行优化后,经过使用不同内容进行测试,发现,对于文本较短的给定输入,其计算时间有着明显的减少。
但是对于给定的较长的输入,如"今天的天气真的非常好呢"
的时候,由于匹配每一个词的问题都要被遍历来计算余弦相似度,其计算量仍然可以达到20w+的次数。
那么,有没有方法可以解决这种问题呢?
我尝试对于倒排表进一步进行优化。
既然使用单独一个词匹配出的结果太过于多的话,我可以仅仅选择同时可以匹配两个甚至指定数据量的多个单词的时候,才进行记录,否则只匹配到一个单词的时候不进行记录,这样就可以进一步筛选出相关性更高的问题了。
比如今天
对应的问题可能是有5w个,而天气
对应的问题是5w个,取并集时可能会有8w个问题,而取交集的时候只有2w个。而这2w个问题由于包含更多的输入词,其相关性会更高,因此可以采用这种方式。
修改代码如下
(1. 修改查询倒排表方式
1 | def get_index(str_list, dic_index, threshold=1): # Threshold代表限制同时出现多少个单词的情况返回 |
(2. 修改对应的计算代码
1 | input_content = [sentence_process(input_str)] |
采用上述方式优化倒排表后,对于给定的输入"今天的天气真的非常好呢"
,其计算量由原来的20w+骤降到2k+。
由其效果可见该优化方式可以被用于优化。
4、程序片段方法化
由于本实验给予jupyter插件的的方式进行运行,因此每次计算需要手动执行多个代码框方式进行测试,对于开发程序的时候可行,但是对于测试程序的时候未免过于烦琐,因此将部分代码方法化。
(1. 修改文本预处理代码
1 | def sentence_process(input_str): |
(2. 修改对应的计算代码
1 | def get_best_index_list(input_str): |
(3. 增加对应调用的函数
1 | def get_qalist_with_list(input_str, range_int=1, output_q=False): |
代码中方法的参数列表对应功能和含义如下:
- input_str: 输入的待匹配文本
- range_int: 给定输出的数量(匹配前n个)
- output_q: 是否输出匹配到的问题
五、无结果的回答方式(安全回答)
在使用不同文本的测试过程中,发现对于给定的无法匹配的问题,默认没有设置回答,并且由于无法匹配到任何的倒排表中的结果,会导致索引报错。
因此这里需要修改部分代码的索引规则。
而对于安全回答,由于没有给定,因此没有任何输出。
这里需要修改调用函数部分的代码,已达到设置安全回答的目标
代码修改如下
1 | def get_qalist_with_list(input_str, range_int=1, output_q=False): |
在上述代码框中,else部分则匹配着找不到任何结果时候的回答内容。
为简化程序仅用于实验的目的,这里没有丰富多种安全回答。
如果需要增加多种安全回答的时候,可以预设一个安全回答list,并且再else中增添一个随机数发生器,每次从安全回答list中随机抽取一条安全回答语句进行输出。
本文不再对此方法进行赘述。
六、遇到的问题和解决
1、在使用jieba分词时报错
报错内容
1
AttributeError: 'float' object has no attribute 'decode'
解决方法
在导入数据后的数据优化过程中,需要先将dataframe格式转换为str
1
train_data = train_data.astype(str)
参考内容
结巴分词出现AttributeError: ‘float’ object has no attribute 'decode’错误:https://my.oschina.net/u/4336279/blog/3569965
2、在计算余弦相似度时内存溢出
出现问题的效果
在开着Mac的
活动监视器
的时候,可以明显的看到,在做余弦相似度的计算时候,Python3.8进程对应的内存占用以GB为单位,以肉眼可见的速度上升着。最终占用内存接近100GB的时候,由系统终止该进程的继续执行,jupyter进程自动重启,所有临时变量清空,Python进程占用的内存被释放。解决方法
经过资料查询,发现由
TfidfVectorizer
训练好的文本特征矩,其数据类型为csr_matrix
,这种数据类型为稀疏矩阵,以按行压缩的系数矩阵存储方式,由三个一维数组indptr
、indices
、data
组成,这种格式只存储非零位置。由于预先将训练集合的文本特征矩阵转换为array数据类型,因此其在内存中的空间占用已经变大。
在使用
cosine_similarity
方法进行余弦相似度计算的时候,其需要对转换后的维度为6w的两个数组进行计算,而计算后array本身的占用空间无法被释放,是因此会导致内存逐渐被消耗。为解决这一问题,可以先将输入文本的矩阵进行toarray转换,而训练用的问题矩阵的内容不进行转换,待每次计算余弦相似度时进行转换。转换后,由于没有变量进行接收,因此使用后该部分内存就会被python进行自动回收,以减小空间的开销与消耗。
因此,代码可以更改为如下方式
1
2
3
4
5tmp_cosine_sim = []
len_train_array = train_array.shape[0]
str_len_train_array = str(len_train_array)
for index in range(len_train_array): # 计算输入文本与每一条问句的特征矩阵的余弦相似度
tmp_cosine_sim.append(float(cosine_similarity(train_array[index].toarray(), test_array)))
3、在输入的文本中有标点符号时报错
出现问题的效果
在给定输入
"你是谁?"
的时候,程序在检测到?的时候,会导致无法在倒排表中找到相应内容时因索引错误导致程序终止。1
2
3
4
5
6
7
8
9
10
11
12
13def get_index(str_list, dic_index, threshold=1): # Threshold代表限制同时出现多少个单词的情况返回
"""
根据输入的list
和给定的字典倒排表
输出所在的index
"""
input_str = str_list[0]
tmp_list = []
for word in input_str.split():
# print("正在查找单词:"+word)
if word in dic_index:
for index in dic_index[word]: <------ Err:
tmp_list.append(index)解决方法
而在此项目中标点符号因为不是需要关注的部分,因此决定在文本处理的时候直接去除所有标点符号。
根据下边的参考链接,使用其提供的对于str的filter的lambda表达式进行过滤。
其中filterpunt为匿名函数的变量名称,匿名函数使用lambda表示,其中匿名函数中嵌套了一个filter的方法,filter方法的参数列表第一个参数需要传入一个判断函数,这里仍然使用了另一个lambda表达式构建的匿名函数。
增加和修改代码如下
1
2
3
4
5
6
7
8
9
10
11
12punct = set(u''':!),.:;?]}¢'"、。〉》」』】〕〗〞︰︱︳﹐、﹒
﹔﹕﹖﹗﹚﹜﹞!),.:;?|}︴︶︸︺︼︾﹀﹂﹄﹏、~¢
々‖•·ˇˉ―--′’”([{£¥'"‵〈《「『【〔〖([{£¥〝︵︷︹︻
︽︿﹁﹃﹙﹛﹝({“‘-—_…''')
# 对str/unicode
filterpunt = lambda input_str: ''.join(filter(lambda x: x not in punct, input_str))
def sentence_process(input_str):
'''
将传入的中文句子调用jieba分词
返回使用空格拼接的句子
'''
return " ".join(list(jieba.cut(filterpunt(input_str))))参考内容
【Github|fxsjy/jieba】ISSUE 关于标点符号 #169:https://github.com/fxsjy/jieba/issues/169#issuecomment-49504512
七、参考内容
【知乎|马勇强】从产品完整性的角度浅谈chatbot:https://zhuanlan.zhihu.com/p/34927757
【Github|codemayq】chinese_chatbot_corpus:https://github.com/codemayq/chinese_chatbot_corpus
在Python中,在稀疏矩阵数据下计算余弦相似度的最快方法是什么?:https://www.cnpython.com/qa/28413
【CSDN|scipy.sparse稀疏矩阵内积点乘–效率优化!】:https://blog.csdn.net/mantoureganmian/article/details/80612137
【CSDN|Python scipy.sparse稀疏矩阵使用感悟】:https://blog.csdn.net/lishu14/article/details/84963979
【CSDN|【Python学习之路】Scipy 稀疏矩阵的线性代数】:https://blog.csdn.net/weixin_40400177/article/details/103551002
python的稀疏矩陣計算:https://zh.codeprj.com/blog/52ff061.html
【CSDN|基于sklearn TFIDF模型 的文章推荐算法】:https://blog.csdn.net/qq_34333481/article/details/85126228
【CSDN|Python开发 之 Sklearn的模型 和 CountVectorizer 、Transformer 保存 和 使用】:https://blog.csdn.net/u014597198/article/details/103037709
tfidf_CountVectorizer 与 TfidfTransformer 保存和测试:https://my.oschina.net/u/2293326/blog/1838918
【CSDN|sklearn: TfidfVectorizer 中文处理及一些使用参数】:https://blog.csdn.net/blmoistawinde/article/details/80816179
八、完整代码
1 | # ------------------单词切分--------------------- |
九、注释
本文中数据集合来源于网络。
本文数据遵循Apache License 2.0开原许可
本文同时提供python文件的代码和jupyter notebook文件的代码。
本文的数据及和代码均可在【Github|NLPLearning-ChatBot-SearchBased-InvertedIndex】仓库下载
在服务器中住着的AKI娘会检测您的输入内容哦, 如果被判断为垃圾内容是看不到的呢!当然抹茶也会定期检查AKI娘的所作所为的!