Lina 聊天机器人 - 使用 TF-IDF 文档检索生成响应





5.00/5 (6投票s)
使用 TF-IDF 构建基于检索的聊天机器人。
系列介绍
这将是关于自然语言和机器学习的多个教程系列,我们将手动创建一个聊天机器人表格,以便
- 与用户聊天
- 回答他们的问题
- 提取关于用户的数据
- 执行一些操作(推荐电影,告知时间...)
本系列的目标不仅仅是创建一个聊天机器人,而是展示一个可以使用 NLP 和 ML 实现的实际项目,因为我的主要目标是以有趣的方式(即创建一个聊天机器人)来展示 ML 和 NLP。
在本系列中,我们将介绍一些简单和高级的技术,以达到我们的最终目标。它们是
- 使用 TF-IDF 进行文档检索
- 使用 Word2Vect 进行文档检索
- CKY 解析
- 网络爬取
我希望通过这个系列,您会通过这个有趣的项目爱上 NLP 和 ML。
我们想称我们的聊天机器人为 Lina :D
教程介绍
要构建聊天机器人,有两种主要方法:生成方法和检索方法。对于本系列,我们将使用检索方法,因为它既简单又实用,可以输出非常好的结果
在本教程中,我们将讨论如何使用文档检索让 Lina 与用户聊天,在接下来的教程中,我们将讨论如何使其更具交互性。
使用检索方法构建时,有一些通用步骤
- 定义一些句子及其回复(数据收集步骤),我从一个名为 rDanny 的在线仓库中附上了一个简单的句子回复。
- 使用 TF-IDF(词频-逆文档频率)教您的聊天机器人此数据集。
- 使用余弦相似度比较输入句子和学习到的内容。
附件的 zip 包含代码和数据集。
议程
在本教程中,我们将
- (可选)简要讨论 TF-IDF(词频-逆文档频率)的概念
- (可选)然后讨论什么是 余弦相似度 以及我们为什么使用它
- (您可以跳过技术背景,直接创建您的聊天机器人)然后我们来到有趣的部分,即实际构建 Lina(我们的聊天机器人 :D)
大多数技术细节来自 Dan Jurafsky 和 Chris 的 YouTube 播放列表。
使用的数据集来自 rDanny。
那么,让我们开始吧!
1. TF-IDF
tf-idf 代表词频-逆文档频率,它是一种简单的方法,旨在为与用户查询相似的文档打分。
它建立在两个主要概念之上
- 这个词(在查询中)在每个文档中出现了多少次 ==> 词频
- 这个词的独特性如何 ==> 逆文档频率
1.a 词频
它是一个给定文档中特定词出现的简单计数,我们计算所有给定文档中的所有词 ==> 为文档打分。
示例
这里,在这个例子中,我们有多个文档,这里是莎士比亚的戏剧,我们通过这些文档测试特定词的出现次数,因为这里只关注词频时,我们只关心词计数
现在我们需要只根据词频(词计数)来衡量分数,在接下来的部分中,我们将利用我们计算分数的方式,但是
原始词频不是我们想要的,因为
- 一个包含该词出现 10 次的文档比包含该词出现 1 次的文档更相关。
- 但并非相关 10 倍。
因为相关性不会与词频成比例增加。所以我们将使用对数频率而不是简单的词计数
请记住,`tf` 只是词计数,所以只要 `tf > 0`,我们就会将分数计算为 `1+Log`,否则如果词计数为零 (`tf=0`),分数将为零。所以现在来计算分数。
词频不足以说明问题但是我们必须考虑到某个词与其他词在所有文档中的稀有程度。因此,为了完成 TF-IDF,我们必须考虑文档频率,或者更确切地说,它的倒数,以考虑词的稀有性
1.b 逆文档频率
在这里,我们倾向于衡量词的稀有性,这是通过计算一个词在多少个文档中出现来完成的。一个词在一个文档中出现一次,即使它在该文档中出现多次,我们也只计数一次,因为我们这里的主要目标是计算文档而不是词。
示例
假设有一百万个文档,您查找一个词列表,并查看这些词在多少个文档中出现。
df 在这里代表文档数量。所以这里像“calpurnia”这样的词只在一个文档中出现(非常罕见),所以它会得到高分,所以小的 df(文档计数)得到大的分数,所以我们得到文档频率的倒数
因此,为以上所有术语(词)计算此值后,结果将是
注意正如您在这里看到的,IDF(逆文档频率)独立于查询,因为它只依赖于文档,所以我们不需要为每个新查询计算 idf,因为每个术语都有一个 idf 值。这可以用于优化,在查询之前计算 idf
因此,如果我们只想根据 IDF(逆文档频率)来衡量分数
1.c 将 TF 和 IDF 结合到简单的分数计算中
此分数计算需要
- 增加出现次数 (`TermFreq`)
- 增加词的稀有性(逆文档频率)
我们首先计算特定文档中特定术语的权重。
将其应用于莎士比亚戏剧的先前示例
现在每个文档都由一个实值 TF-IDF 权重向量表示。
现在计算特定查询与特定文档的分数。
t==> 术语(词),q==> 查询,d==> 文档
因此,您计算查询 (q) 中所有术语 (t) 的分数(如果给定文档中未找到此术语,则 tf = 0)。然后将查询中所有术语的分数相加,以获得此文档的分数。
注意现在知道如何计算特定文档的分数后,为了进行文档检索,我们需要对所有文档执行此方法,但我们倾向于使用向量空间的概念,而不仅仅是简单的分数公式来获得相似性,所以我们将查询和文档都表示在一个向量空间中,以便使用相似性计算
1.d 为什么选择向量空间模型??
在前面的例子中,我们可以说每个文档现在都变成了一个实值向量。
以《尤利乌斯·凯撒》为例,它的向量将是 ==>[ 3.18 6.1 2.54 1.54 0 0 0 ]
我们可以将词(术语)视为我们空间的维度,所以现在文档就像空间中的一个点(或一个向量),我们也可以以同样的方式看待查询,将其视为空间中的一个点(向量)。
但是这个空间的维度可能会非常高(术语数量可能达到数千万),因为请记住,我们的维度取决于您拥有的词汇量,所以我们需要一种比运行前面方法更有效的方法
对于我们的 10000 个文档(假设我们的词汇量是 1000 万,并且我们有 10000 个文档),运行 1000 万次,这就需要使用向量空间概念的方法,其中之一可以简单地被视为计算大维度空间中两个向量(特定文档和查询)之间最小距离的方法,这称为欧几里得距离。
在这里,我们看到,这是一个只有两个词(八卦和嫉妒)的微小空间,我们有 3 个文档和 1 个查询,这 3 个文档
- d1 == > 更多与八卦有关
- d3 == > 更多与嫉妒有关
- d2 == > 包含两个术语
这里显示 q(我们的查询)想要一个包含八卦和嫉妒的文档 (d2),但欧几里得距离不会返回 d2,因为从 q(向量的尖端)到 d2(d2 的尖端)的距离大于 d1 和 d3 的距离。
所以我们使用测量角度而不是测量距离的概念。
因此,这里就用到了余弦相似度
2. 余弦相似度
2.a 为什么是余弦相似度?
有许多方法可以使用向量空间的概念,但我们特别使用余弦相似度,因为在使用向量空间时,我们会面临一个问题,即不同长度的文档会导致错误的分数(如前所述),为了解决这个问题,我们必须考虑使用长度归一化的概念,这就是我们使用余弦相似度的原因
2.b 长度归一化
通过将其每个分量除以其长度,可以对向量进行长度归一化——为此,我们使用 L2 范数
现在,长文档和短文档具有可比较的权重。
2.c 为什么要使用余弦函数
我们使用余弦相似度是因为余弦是区间 [0o, 180o] 上的单调递减函数,范围从 1 → -1
以下两个概念是等价的。
- 按查询与文档之间角度的降序对文档进行排名
- 根据余弦函数的本质,按余弦(查询,文档)的升序对文档进行排名
2.d 余弦相似度计算
现在让我们真正使用余弦相似度来计算相似度
请记住,我们为查询和特定文档计算 TF-IDF,然后使用余弦方法获得相似度,因此我们主要需要两个变量 q 和 d
qi 是术语 i 在查询中的 TF-IDF 权重
di 是术语 i 在文档中的 TF-IDF 权重
但是
我们正在处理归一化长度,因此公式可以简化为只有点积(或标量积)
示例
您可以将其形象化为
示例
3. 让我们来构建我们的聊天机器人!
以上所有解释都只是为了了解 TF-IDF 背后的概念,因为已经有很多免费工具实现了 TF-IDF,关键不是重新创建 TF-IDF,而是如何将其有效地利用。
我们的聊天机器人需要一个包含句子及其回复的数据集。
我们的一般步骤是
- 其中每个句子都将被视为一个独立的文档
- 然后当用户输入他的聊天内容时,我们将其视为我们的查询
- 然后我们使用 TF-IDF 和余弦相似度来比较两者,并从数据集中获取与查询最相似的句子并输出回复
那么,让我们开始编码吧!
首先,我们需要导入
- 我们将主要使用 sklearn 和 numpy 库 (TF-IDF 库)
- 我们还将使用其他一些库,它们的功能稍后会出现 (辅助库)
# _____TF-IDF libraries_____
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.feature_extraction.text import TfidfVectorizer
import numpy as np
# _____helper Libraries_____
import pickle # would be used for saving temp files
import csv # used for accessing the dataset
import timeit # to measure time of training
import random # used to get a random number
现在让我们创建一个函数,它接受一个 `string` 作为输入并输出响应,我们所有的工作都将在该函数内部进行。
让我们定义 csv 文件的路径,我们将其放在名为 DataSet 的文件夹中。
def talk_to_lina_primary(test_set_sentence):
csv_file_path = "DataSet/randychat.csv"
为了使用 `sklearn`,我们必须将字符串聊天加载到字典中,所以,我们将创建一个数组来加载 csv 文件,因此我们将创建一个名为 `sentences` 的数组。
def talk_to_lina_primary(test_set_sentence):
csv_file_path = "DataSet/randychat.csv"
i = 0
sentences = []
# enter your test sentence
test_set = (test_set_sentence, "")
我们的代码将分为两个部分,一个用于训练,另一个用于运行代码。我们将使用临时文件保存我们的对象,我们将这些临时文件保存在 DataSet 文件夹中。因此,我们将为此使用 try except 子句,并将运行阶段放在 `try` 部分,以便在没有临时文件时,我们将进入 `except` 部分。
def talk_to_lina_primary(test_set_sentence):
csv_file_path = "DataSet/randychat.csv"
# we would use 2 temp files
tfidf_vectorizer_pikle_path = "DataSet/tfidf_vectorizer.pickle"
tfidf_matrix_train_pikle_path ="DataSet/tfidf_matrix_train.pickle"
i = 0
sentences = []
test_set = (test_set_sentence, "")
# for indexes
sentences.append(" No you.")
sentences.append(" No you.")
try:
##--------------to use------------------#
# ----------------------------------------#
except:
# ---------------to train------------------#
# -----------------------------------------#
对于训练,我们会注意到,
- 识别空间的维度独立于查询,并且
- 学习数据集的 TF-IDF 也独立于查询
所以我们可以学习它们一次,然后将它们保存到两个临时文件中
def talk_to_lina_primary(test_set_sentence):
csv_file_path = "DataSet/randychat.csv"
tfidf_vectorizer_pikle_path = "DataSet/tfidf_vectorizer.pickle"
tfidf_matrix_train_pikle_path ="DataSet/tfidf_matrix_train.pickle"
i = 0
sentences = []
# enter your test sentence
test_set = (test_set_sentence, "")
# for indexes
sentences.append(" No you.")
sentences.append(" No you.")
try:
##--------------to use------------------#
# ----------------------------------------#
except:
# ---------------to train------------------#
# variable to see how much time it took to train
start = timeit.default_timer()
# to load rows of csv into sentences array
with open(csv_file_path, "r") as sentences_file:
reader = csv.reader(sentences_file, delimiter=',')
for row in reader:
sentences.append(row[0])
i += 1
# now we would start on with training
# begin with identing a TfidfVectorizer obj
tfidf_vectorizer = TfidfVectorizer()
# this line does both
# 1- identify dimension of the space
# 2- learn tf-idf of the dataset
tfidf_matrix_train = tfidf_vectorizer.fit_transform(sentences)
# now the training is finished
# now simply record time that it finished training
stop = timeit.default_timer()
print ("training time took was : ")
print stop - start
# then we would save these 2 objs (dimension space and tf-idf to temp files)
# we use pickle lib
# first save dimension space
f = open(tfidf_vectorizer_pikle_path, 'wb')
pickle.dump(tfidf_vectorizer, f)
f.close()
# then save tf-idf of dataset
f = open(tfidf_matrix_train_pikle_path, 'wb')
pickle.dump(tfidf_matrix_train, f)
f.close()
# -----------------------------------------#
然后我们将处理使用(学习后)的部分
def talk_to_lina_primary(test_set_sentence):
csv_file_path = "DataSet/randychat.csv"
tfidf_vectorizer_pikle_path = "DataSet/tfidf_vectorizer.pickle"
tfidf_matrix_train_pikle_path ="DataSet/tfidf_matrix_train.pickle"
i = 0
sentences = []
test_set = (test_set_sentence, "")
# for indexes
sentences.append(" No you.")
sentences.append(" No you.")
try:
##--------------to use------------------#
# first load dimension space
f = open(tfidf_vectorizer_pikle_path, 'rb')
tfidf_vectorizer = pickle.load(f)
f.close()
# then load tf-idf of dataset
f = open(tfidf_matrix_train_pikle_path, 'rb')
tfidf_matrix_train = pickle.load(f)
f.close()
# ----------------------------------------#
except:
# ---------------to train------------------#
start = timeit.default_timer()
# enter jabberwakky sentence
with open(csv_file_path, "r") as sentences_file:
reader = csv.reader(sentences_file, delimiter=',')
for row in reader:
sentences.append(row[0])
i += 1
tfidf_vectorizer = TfidfVectorizer()
tfidf_matrix_train = tfidf_vectorizer.fit_transform(sentences)
stop = timeit.default_timer()
print ("training time took was : ")
print stop - start
f = open(tfidf_vectorizer_pikle_path, 'wb')
pickle.dump(tfidf_vectorizer, f)
f.close()
f = open(tfidf_matrix_train_pikle_path, 'wb')
pickle.dump(tfidf_matrix_train, f)
f.close()
# -----------------------------------------#
使用和训练的两个部分都输出两个对象,然后我们使用这两个对象来实际实现相似度计算。
我们需要
- 使用学习到的维度空间对查询运行 TF-IDF
- 在查询的 TF-IDF 和数据集的 TF-IDF 之间运行余弦相似度
def talk_to_lina_primary(test_set_sentence):
csv_file_path = "DataSet/randychat.csv"
tfidf_vectorizer_pikle_path = "DataSet/tfidf_vectorizer.pickle"
tfidf_matrix_train_pikle_path ="DataSet/tfidf_matrix_train.pickle"
i = 0
sentences = []
# enter your test sentence
test_set = (test_set_sentence, "")
# 3ashan yzabt el indexes
sentences.append(" No you.")
sentences.append(" No you.")
try:
##--------------to use------------------#
f = open(tfidf_vectorizer_pikle_path, 'rb')
tfidf_vectorizer = pickle.load(f)
f.close()
f = open(tfidf_matrix_train_pikle_path, 'rb')
tfidf_matrix_train = pickle.load(f)
f.close()
# ----------------------------------------#
except:
# ---------------to train------------------#
start = timeit.default_timer()
# enter jabberwakky sentence
with open(csv_file_path, "r") as sentences_file:
reader = csv.reader(sentences_file, delimiter=',')
# reader.next()
# reader.next()
for row in reader:
# if i==stop_at_sentence:
# break
sentences.append(row[0])
i += 1
tfidf_vectorizer = TfidfVectorizer()
tfidf_matrix_train = tfidf_vectorizer.fit_transform(sentences) # finds the tfidf score with normalization
# tfidf_matrix_test =tfidf_vectorizer.transform(test_set)
stop = timeit.default_timer()
print ("training time took was : ")
print stop - start
f = open(tfidf_vectorizer_pikle_path, 'wb')
pickle.dump(tfidf_vectorizer, f)
f.close()
f = open(tfidf_matrix_train_pikle_path, 'wb')
pickle.dump(tfidf_matrix_train, f)
f.close()
# -----------------------------------------#
# use the learnt dimension space
# to run TF-IDF on the query
tfidf_matrix_test = tfidf_vectorizer.transform(test_set)
# then run cosine similarity between the 2 tf-idfs
cosine = cosine_similarity(tfidf_matrix_test, tfidf_matrix_train)
cosine = np.delete(cosine, 0)
# then get the max score
max = cosine.max()
response_index = 0
# if score is more than 0.7
if (max > 0.7):
# we can afford to get multiple high score documents to choose from
new_max = max - 0.01
# load them to a list
list = np.where(cosine > new_max)
# choose a random one to return to the user
# this happens to make Lina answers differently to same sentence
response_index = random.choice(list[0])
else:
# else we would simply return the highest score
response_index = np.where(cosine == max)[0][0] + 2
j = 0
# loop to return the next cell on the row , ( the response cell )
with open(csv_file_path, "r") as sentences_file:
reader = csv.reader(sentences_file, delimiter=',')
for row in reader:
j += 1 # we begin with 1 not 0 & j is initialized by 0
if j == response_index:
return row[1], response_index,
break
我们只是在一个简单的界面中调用这个函数。我们创建的函数既返回响应行又返回响应本身,我们只会向用户显示响应字符串
while 1:
sentence = raw_input("talk to Lina : ")
response_primary, line_id_primary = talk_to_lina_primary(sentence)
print response_primary
print
希望这很有趣,等待下一个教程。它会更令人兴奋,我们将使 Lina 能够回答用户的问题,我们将使用
- CKY 解析
- 简单正则表达式
- 使用 BeautifulSoup 进行网页抓取
- DuckDuckGo
- 雅虎问答
历史
- 2017 年 9 月 10 日:初始版本
- 2021 年 3 月 11 日:更新