万字长文讲解文本嵌入及其高阶应用

文摘   2024-08-19 06:58   江苏  
AI算法之道报道

编辑:AIWay 1024


作为人类,我们可以阅读和理解文本(至少是部分文本)。而计算机则是 "数字思维",因此无法自动掌握单词和句子的含义。如果我们想让计算机理解自然语言,就需要将这些信息转换成计算机可以处理的格式--数字向量。
多年前,人们就学会了如何将文本转换成机器可理解的格式(最早的版本之一是 ASCII)。这种方法有助于呈现和传输文本,但并不对文字的含义进行编码。当时,标准的搜索技术是关键词搜索,即查找包含特定词语或 N-grams 的文档。
几十年后,嵌入技术embedding出现了。我们可以计算单词、句子甚至图像的嵌入。嵌入的形式也是数字向量,但它们可以捕捉语义信息。因此,大家可以用它们进行语义搜索,甚至处理不同语言的文档。
在本文中,我们将深入讲解嵌入技术embeddings的演变和发展史。


嵌入技术的演变

我们首先来介绍下文本特征表述的历史。

  • Bag of Words:
将文本转换为向量的最基本方法是词袋法(Bag of words)。让我们看看理查德-P-费曼(Richard P. Feynman)的一句名言:"我们很幸运生活在一个仍在不断发现的时代"。我们将用这句话来说明词袋法的处理过程。

获取词袋向量的第一步是将文本分割成单词(标记tokens),然后将单词还原为其基本形式。例如,"running "将转化为 "run"。这一过程称为词干处理。我们可以使用 NLTK Python 软件包来完成这一过程。

from nltk.stem import SnowballStemmerfrom nltk.tokenize import word_tokenize
text = 'We are lucky to live in an age in which we are still making discoveries'
# tokenization - splitting text into wordswords = word_tokenize(text)print(words)# ['We', 'are', 'lucky', 'to', 'live', 'in', 'an', 'age', 'in', 'which',# 'we', 'are', 'still', 'making', 'discoveries']
stemmer = SnowballStemmer(language = "english")stemmed_words = list(map(lambda x: stemmer.stem(x), words))print(stemmed_words)# ['we', 'are', 'lucki', 'to', 'live', 'in', 'an', 'age', 'in', 'which', # 'we', 'are', 'still', 'make', 'discoveri']

现在,我们已经获取得到了所有单词的列表。下一步就是计算它们出现的频率,从而创建一个向量。

import collectionsbag_of_words = collections.Counter(stemmed_words)print(bag_of_words)# {'we': 2, 'are': 2, 'in': 2, 'lucki': 1, 'to': 1, 'live': 1, # 'an': 1, 'age': 1, 'which': 1, 'still': 1, 'make': 1, 'discoveri': 1}

事实上,如果我们想把文本转换成向量,就不仅要单单考虑文本中出现的单词,还要考虑整个词汇量。

假设我们的词汇中还有 "i"、"you "和 "study",我们针对费曼的这句话创建的一个向量表示如下。

这种方法非常基础,而且没有考虑到单词的语义,因此 "女孩正在学习数据科学 "和 "年轻女子正在学习人工智能和 ML "这两个句子获得的向量在向量空间并不接近

  • TF - IDF:

TF-IDF(词频-反向文档频率)是词袋法的一个略有改进的版本。它是两个指标的乘积。


词频(Term Frequency 简称TF)显示的是该词在文档中出现的频率。最常见的计算方法是用该文档中单词的计数(如词袋中的计数)除以文档中单词的总数。不过,还有许多其他方法,如布尔 "频率 "和不同的归一化方法。

反向文档频率(Inverse Document Frequency)表示单词提供了多少信息。例如,单词 "a "或 "that "不会为大家提供任何有关文档主题的额外信息。相反,"ChatGPT "等词可以帮助大家定义句子所属领域。它的计算方法是文档总数与包含该词的文档总数之比的对数。IDF 越接近 0,说明该词越常见,提供的信息越少。


因此,最终我们得到的向量中,常见词(如 "I"或 "you")的权重较低,而文档中多次出现的罕见词的权重较高。这种策略会带来更好的结果,但仍然无法捕捉整个句子的语义。
  • Word2Vec:

最著名的密集表示方法之一是 word2vec,它是由谷歌公司于 2013 年在 Mikolov 等人撰写的论文《Efficient Estimation of Word Representations in Vector Space》中提出的。

论文中提到了两种不同的 word2vec 方法:CBOW(我们根据周围的词来预测词)和Skip-gram(相反的任务--我们根据词来预测上下文)。

密集向量表示法的高级理念是训练两个模型:编码器和解码器。例如,在skip-gram的情况下,我们可能会将单词 "christmas "传递给编码器。然后,编码器会生成一个向量,我们将其传递给解码器,期望得到 "merry"、"to "和 "you "等词。

这个模型开始考虑单词的含义,因为它是根据单词的上下文信息进行训练的。但是,它忽略了词形(我们可以从单词部分获得的信息,例如,"-less "表示缺少某种东西)。后来,通过研究 GloVe 中子单词的skip-gram,我们解决了这一缺陷。此外,word2vec 只能处理单词,但我们希望对整个句子进行编码。因此,让我们继续下一步的进化,使用Transformers。

  • Transformers:

下一次演变与论文《Attention Is All You Need》中介绍的Transformer方法有关。Transformers能够产生信息密集的向量,并成为现代语言模型的主流技术。

Transformers允许大家使用相同的 "核心 "模型,并针对不同的用例对其进行微调,而无需重新训练核心模型(这需要花费大量时间和成本)。这导致了预训练模型的兴起。最早流行的模型之一是谷歌人工智能公司推出的 BERT。

从内部来看,BERT 仍在Token层面上运行,类似于 word2vec,但我们仍希望获得句子的嵌入。因此,最简单的方法就是取所有标记Token向量的平均值。遗憾的是,这种方法的效果并不好。

这个问题在 2019 年 Sentence-BERT 发布时得到了解决。它在语义文本相似性任务方面的表现优于以往所有方法,并可计算句子嵌入。




计算嵌入

在本文中,我们将使用 OpenAI 嵌入。我们将试用发布的新模型 text-embedding-3-small。与 text-embedding-ada-002 相比,新模型的性能更好:

  • 广泛使用的多语言检索(MIRACL)基准的平均得分从 31.4% 上升到 44.0%。

  • 英语任务常用基准(MTEB)的平均成绩也有所提高,从 61.0%上升到 62.3%。

作为数据源,我们将使用一小部分 Stack Exchange Data Dump 样本--Stack Exchange 网络上所有用户贡献内容的匿名转储。我选择了一些我认为有趣的主题,并从每个主题中抽取了 100 个问题。主题范围从生成式人工智能到咖啡或自行车,因此我们将看到相当广泛的主题。

首先,我们需要计算所有 Stack Exchange 问题的嵌入。值得做一次,并将结果存储在本地。我们可以使用 OpenAI Python 软件包生成嵌入。
from openai import OpenAIclient = OpenAI()
def get_embedding(text, model="text-embedding-3-small"): text = text.replace("\n", " ") return client.embeddings.create(input = [text], model=model)\ .data[0].embedding
get_embedding("We are lucky to live in an age in which we are still making discoveries.")
结果,我们得到了一个 1536 维的浮点数向量。现在,我们可以对所有数据进行重复运算,并开始分析数值。您可能会提出的首要问题是,这些句子在意义上相互之间的距离有多近。为了找到答案,让我们来讨论一下向量间距离的概念。



向量间距离度量
嵌入实际上是向量。因此,如果我们想了解两个句子之间有多接近,我们可以计算向量之间的距离。距离越小,语义就越接近。
  • 欧氏距离

定义两点(或向量)间距离的最标准方法是欧氏距离或 L2 准则。这种度量方法在日常生活中最常用,例如,当我们谈论两个城镇之间的距离时。

下面是 L2 距离的直观表示和计算公式。

我们可以使用普通 Python 或利用 numpy 函数来计算这一指标。

import numpy as np
sum(list(map(lambda x, y: (x - y) ** 2, vector1, vector2))) ** 0.5# 2.2361
np.linalg.norm((np.array(vector1) - np.array(vector2)), ord = 2)# 2.2361
  • 曼哈顿距离 (L1)

另一个常用的距离是 L1 准则或曼哈顿距离。这个距离是根据曼哈顿岛而命名的。曼哈顿岛的街道呈网格状布局,曼哈顿两点之间的最短路线将是 L1 距离,因为您需要沿着网格走。

我们也可以从头开始实现它,或者使用 numpy 函数。

sum(list(map(lambda x, y: abs(x - y), vector1, vector2)))# 3
np.linalg.norm((np.array(vector1) - np.array(vector2)), ord = 1)# 3.0
  • 向量点积

查看向量间距离的另一种方法是计算点积。这里有一个公式,我们可以很容易地实现它。

代码实现如下:
sum(list(map(lambda x, y: x*y, vector1, vector2)))# 11
np.dot(vector1, vector2)# 11
这个指标的解释有点棘手。一方面,它可以显示矢量是否指向一个方向。另一方面,结果在很大程度上取决于向量的大小。例如,我们来计算两对向量之间的点积:

(1, 1) vs (1, 1) 

(1, 1) 对 (10, 10)

在这两种情况下,向量都是共线的,但在第二种情况下,点积要大十倍:2 VS 20。
  • 余弦相似性

通常使用余弦相似性。余弦相似度是以向量的大小(或法线)归一化的点积。

我们可以自己实现上述公式,或者使用 sklearn 的函数。
dot_product = sum(list(map(lambda x, y: x*y, vector1, vector2)))norm_vector1 = sum(list(map(lambda x: x ** 2, vector1))) ** 0.5norm_vector2 = sum(list(map(lambda x: x ** 2, vector2))) ** 0.5
dot_product/norm_vector1/norm_vector2
# 0.8575
from sklearn.metrics.pairwise import cosine_similarity
cosine_similarity( np.array(vector1).reshape(1, -1), np.array(vector2).reshape(1, -1))[0][0]
# 0.8575

余弦相似度等于两个向量之间的余弦值。两个向量越接近,度量值就越高。

我们甚至可以计算出矢量之间的精确角度(单位:度)。我们得到的结果是 30 度左右,看起来非常合理。

import mathmath.degrees(math.acos(0.8575))
# 30.96



可视化嵌入

了解数据的最佳方法是将其可视化。不幸的是,我们例子中嵌入向量有 1536 个维度,因此直接可视化查看数据非常具有挑战性。不过,还是有办法的:我们可以使用降维技术在二维空间中投射向量。

  • PCA

最基本的降维技术是 PCA(主成分分析)。让我们尝试使用它。

首先,我们需要将嵌入向量转换为二维 numpy 数组,然后将其传递给 sklearn。

import numpy as npembeddings_array = np.array(df.embedding.values.tolist())print(embeddings_array.shape)# (1400, 1536)

然后,我们需要初始化一个 n_components = 2 的 PCA 模型(因为我们想创建一个二维可视化效果),在整个数据上训练模型并预测新值。

from sklearn.decomposition import PCA
pca_model = PCA(n_components = 2)pca_model.fit(embeddings_array)
pca_embeddings_values = pca_model.transform(embeddings_array)print(pca_embeddings_values.shape)# (1400, 2)

结果,我们得到了一个矩阵,每个问题只有两个特征,因此我们可以很容易地将其可视化为散点图。

fig = px.scatter(    x = pca_embeddings_values[:,0],     y = pca_embeddings_values[:,1],    color = df.topic.values,    hover_name = df.full_text.values,    title = 'PCA embeddings', width = 800, height = 600,    color_discrete_sequence = plotly.colors.qualitative.Alphabet_r)
fig.update_layout( xaxis_title = 'first component', yaxis_title = 'second component')fig.show()

结果如下:

  • t-SNE

PCA 是一种线性算法,而现实生活中的大多数关系都是非线性的。因此,由于非线性的原因,我们可能无法分离聚类。让我们尝试使用非线性算法 t-SNE,看看它是否能显示出更好的结果。

代码几乎完全相同。我只是使用了 t-SNE 模型,而不是 PCA。

from sklearn.manifold import TSNEtsne_model = TSNE(n_components=2, random_state=42)tsne_embeddings_values = tsne_model.fit_transform(embeddings_array)
fig = px.scatter( x = tsne_embeddings_values[:,0], y = tsne_embeddings_values[:,1], color = df.topic.values, hover_name = df.full_text.values, title = 't-SNE embeddings', width = 800, height = 600, color_discrete_sequence = plotly.colors.qualitative.Alphabet_r)
fig.update_layout( xaxis_title = 'first component', yaxis_title = 'second component')fig.show()

t-SNE 的结果看起来要好得多。除了 "genai"、"datascience "和 "ai "之外,大多数聚类都被分开了。

  • Barcodes

理解嵌入向量的方法是将其中几个嵌入向量可视化为条形码,并查看相关性。我选取了三个嵌入的例子:两个最接近,另一个是数据集中最远的例子。

embedding1 = df.loc[1].embeddingembedding2 = df.loc[616].embeddingembedding3 = df.loc[749].embedding
import seaborn as snsimport matplotlib.pyplot as pltembed_len_thr = 1536
sns.heatmap(np.array(embedding1[:embed_len_thr]).reshape(-1, embed_len_thr), cmap = "Greys", center = 0, square = False, xticklabels = False, cbar = False)plt.gcf().set_size_inches(15,1)plt.yticks([0.5], labels = ['AI'])plt.show()
sns.heatmap(np.array(embedding3[:embed_len_thr]).reshape(-1, embed_len_thr), cmap = "Greys", center = 0, square = False, xticklabels = False, cbar = False)plt.gcf().set_size_inches(15,1)plt.yticks([0.5], labels = ['AI'])plt.show()
sns.heatmap(np.array(embedding2[:embed_len_thr]).reshape(-1, embed_len_thr), cmap = "Greys", center = 0, square = False, xticklabels = False, cbar = False)plt.gcf().set_size_inches(15,1)plt.yticks([0.5], labels = ['Bioinformatics'])plt.show()
运行结果如下:

在我们的案例中,由于维度较高,要看清向量之间是否接近并不容易。不过,我还是喜欢这种可视化方法。在某些情况下,它可能会有所帮助,所以我把这个想法与大家分享。



相关应用
当然,嵌入的主要目的不是将文本编码成数字向量,也不是为了将文本可视化而将其可视化。我们可以从捕捉文本含义的能力中获益良多。让我们举几个更实际的例子。
  • 聚类

让我们从聚类开始。聚类是一种无监督学习技术,可以在没有任何初始标签的情况下将数据分成若干组。聚类可以帮助你了解数据的内部结构模式。

我们将使用最基本的聚类算法之一--K-means。对于 K-means 算法,我们需要指定簇的数量。我们可以使用剪影得分来确定最佳聚类数目。
from sklearn.cluster import KMeansfrom sklearn.metrics import silhouette_scoreimport tqdm
silhouette_scores = []for k in tqdm.tqdm(range(2, 51)): kmeans = KMeans(n_clusters=k, random_state=42, n_init = 'auto').fit(embeddings_array) kmeans_labels = kmeans.labels_ silhouette_scores.append( { 'k': k, 'silhouette_score': silhouette_score(embeddings_array, kmeans_labels, metric = 'cosine') } )
fig = px.line(pd.DataFrame(silhouette_scores).set_index('k'), title = '<b>Silhouette scores for K-means clustering</b>', labels = {'value': 'silhoutte score'}, color_discrete_sequence = plotly.colors.qualitative.Alphabet)fig.update_layout(showlegend = False)
在我们的案例中,当 k = 11 时,得分达到最大值。因此,我们的最终模型就使用这个簇数。

让我们按照之前的方法,使用 t-SNE 进行降维,将聚类可视化。
tsne_model = TSNE(n_components=2, random_state=42)tsne_embeddings_values = tsne_model.fit_transform(embeddings_array)
fig = px.scatter( x = tsne_embeddings_values[:,0], y = tsne_embeddings_values[:,1], color = list(map(lambda x: 'cluster %s' % x, kmeans_labels)), hover_name = df.full_text.values, title = 't-SNE embeddings for clustering', width = 800, height = 600, color_discrete_sequence = plotly.colors.qualitative.Alphabet_r)fig.update_layout( xaxis_title = 'first component', yaxis_title = 'second component')fig.show()
我们可以直观地看到,该算法能够很好地定义聚类--它们被很好地分开。
  •  分类

我们可以将嵌入用于分类或回归任务。例如,您可以用它来预测客户评论的情感(分类)或 NPS 分数(回归)。由于分类和回归属于监督学习,因此需要有标签。幸运的是,我们知道了问题的主题,并可以拟合一个模型来预测它们。

为了正确评估分类模型的性能,我们将把数据集分成训练集和测试集(80% 对 20%)。然后,我们可以在训练集上训练随机森林模型,并在测试集(模型以前从未见过的问题)上测试模型效果。

from sklearn.ensemble import RandomForestClassifierfrom sklearn.model_selection import train_test_splitclass_model = RandomForestClassifier(max_depth = 10)
# defining features and targetX = embeddings_arrayy = df.topic
# splitting data into train and test setsX_train, X_test, y_train, y_test = train_test_split( X, y, random_state = 42, test_size=0.2, stratify=y)
# fit & predict class_model.fit(X_train, y_train)y_pred = class_model.predict(X_test)

为了评估模型的性能,我们来计算一下混淆矩阵。在理想情况下,所有非对角元素都应为 0。

from sklearn.metrics import confusion_matrixcm = confusion_matrix(y_test, y_pred)
fig = px.imshow( cm, x = class_model.classes_, y = class_model.classes_, text_auto='d', aspect="auto", labels=dict( x="predicted label", y="true label", color="cases"), color_continuous_scale='pubugn', title = '<b>Confusion matrix</b>', height = 550)
fig.show()
运行结果如下:

我们可以看到与聚类类似的结果:有些主题很容易分类,准确率达到 100%,例如 "自行车 "或 "旅行",而有些主题则很难区分。.不过,我们的总体准确率达到了 91.8%,相当不错。
  • RAG
随着 LLM 近来越来越受欢迎,嵌入技术也被广泛应用于 RAG 使用案例中。当我们有大量文件式,无法将它们一次全部传递给 LLM 时,我们就需要检索增强生成,因为:
  • LLM 对上下文大小有限制(目前,GPT-4 Turbo 为 128K)
  • 我们支付的是Tokens,所以一直传递所有信息的成本更高。
为了能够利用广泛的知识库,我们可以采用 RAG 方法:
  • 计算所有文档的嵌入,并将其存储在向量数据库中。
  • 当我们收到一个用户请求时,我们可以计算出它的嵌入,并从向量数据库中检索出该请求的相关文档。
  • 只将相关文件交给LLM,以获得最终答复。




总结
在本文中,我们详细讨论了文本嵌入。希望现在你已经对这一主题有了全面深入的了解。下面是我们的历程回顾:

首先,我们回顾了文本处理方法的演变。

然后,我们讨论了如何理解文本之间是否有相似的含义。
之后,我们看到了不同文本嵌入可视化的方法。

最后,我们尝试在聚类、分类和 RAG 等不同的实际任务中使用嵌入作为特征。

希望大家通过本文,可以加深对文本嵌入的深入理解。



© THE END 

转载请联系本公众号获得授权




AI算法之道
一个专注于深度学习、计算机视觉和自动驾驶感知算法的公众号,涵盖视觉CV、神经网络、模式识别等方面,包括相应的硬件和软件配置,以及开源项目等。
 最新文章