作为人类,我们可以阅读和理解文本(至少其中一些)。相反,计算机“以数字思考”,因此它们无法自动掌握单词和句子的含义。在本文中,我们将更深入地探讨嵌入主题以及细节。
嵌入的演变
我们将首先简要回顾一下文本表示的历史。
词袋
将文本转换为向量的最基本方法是词袋。让我们看看理查德-P-费曼(Richard P. Feynman)的一句名言:"我们很幸运生活在一个仍在不断发现的时代"。我们将用这句话来说明 "词袋 "方法。
获得词袋向量的第一步是将文本分割成单词(标记),然后将单词还原为其基本形式。例如,"running "将转化为 "run"。这一过程称为词干提取。我们可以使用 NLTK Python 软件包来完成这一过程。
from nltk.stem import SnowballStemmer
from 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 words
words = 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 collections
bag_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",那么我们就可以根据费曼的这句话创建一个向量。
这种方法非常基本,而且没有考虑单词的语义,因此 “the Girl is Study Data Science”和“The young Woman is Learning AI and ML”这两个句子并不接近。
TF-IDF
TF-IDF是词袋法的一个略有改进的版本。它是两个指标的乘积。
因此,最终我们得到的向量中,常见词(如 "我 "或 "你")的权重较低,而文档中多次出现的罕见词的权重较高。这种策略会带来更好的结果,但仍然无法捕捉语义。
这种方法的另一个挑战是,它产生的向量非常稀疏。向量的长度等于语料库的大小。英语(来源)中大约有 470K 个独特的单词,因此我们将获得巨大的向量。由于句子中唯一的单词不会超过 50 个,所以向量中 99.99% 的值都是 0,没有编码任何信息。
词向量
最著名的密集表示方法之一是 word2vec,由谷歌于 2013 年在 Mikolov 等人发表的论文 "Efficient Estimation of Word Representations in Vector Space "中提出。
论文中提到了两种不同的 word2vec 方法: 连续词袋(我们根据周围的词来预测词)和 Skip-gram(相反的任务,我们根据词来预测上下文)。
密集向量表示法的高级理念是训练两个模型:编码器和解码器。例如,在跳格的情况下,我们可能会将单词 "christmas "传递给编码器。然后,编码器会生成一个向量,我们将其传递给解码器,期望得到 "merry"、"to "和 "you "等词。
这个模型开始考虑单词的含义,因为它是根据单词的上下文进行训练的。但是,它忽略了词形(我们可以从单词部分获得的信息,例如,"-less "表示缺少某种东西)。
此外,word2vec 只能处理单词,但我们希望对整个句子进行编码。因此,让我们继续下一步的发展,使用转换器。
转换器和句子嵌入
下一步进化与瓦斯瓦尼等人在《注意力就是你所需要的一切》("Attention Is All You Need")一文中介绍的变换器方法有关。变换器能够产生信息密集向量,并成为现代语言模型的主流技术。
Transformer 允许你使用相同的 "核心 "模型,并根据不同的使用情况对其进行微调,而无需重新训练核心模型(这需要花费大量时间和成本)。这导致了预训练模型的兴起。最早流行的模型之一是谷歌人工智能公司的 BERT(来自Transformer 的双向编码器表征)。
从内部来看,BERT 仍在标记层面上运行,类似于 word2vec,但我们仍希望获得句子嵌入。因此,最简单的方法就是取所有标记向量的平均值。遗憾的是,这种方法的性能并不理想。
我们已经简要介绍了嵌入式的演变,并对该理论有了一个高层次的了解。现在,是时候进入实践阶段,学习如何使用 OpenAI 工具计算嵌入度了。
计算嵌入
在本文中,我们将使用 OpenAI 嵌入。我们将尝试最近发布的新模型 text-embedding-3-small。与 text-embedding-ada-002 相比,新模型的性能更好:
OpenAI 还发布了一个新的大型模型 text-embedding-3-large。现在,这是他们性能最好的嵌入模型。
首先,我们需要计算所有 Stack Exchange 问题的嵌入。值得做一次,并将结果存储在本地(文件或矢量存储器中)。我们可以使用 OpenAI Python 软件包生成嵌入式。
from openai import OpenAI
client = 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 维的浮点数向量。现在,我们可以对所有数据进行重复计算,并开始分析数值。
向量间的距离
嵌入实际上是向量。因此,如果我们想了解两个句子之间的距离,我们可以计算向量之间的距离。距离越小,语义就越接近。
可以使用不同的度量标准来测量两个向量之间的距离:
让我们来讨论一下它们。举个简单的例子,我们将使用两个二维向量。
vector1 = [1, 4]
vector2 = [2, 2]
欧氏距离(L2)
定义两点(或向量)间距离的最标准方法是欧氏距离或 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
这个指标的解释有点棘手。一方面,它可以显示矢量是否指向一个方向。另一方面,结果在很大程度上取决于向量的大小。例如,我们来计算两对向量之间的点积:
在这两种情况下,向量都是平行的,但在第二种情况下,点积要大十倍: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.5
norm_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
函数 cosine_similarity 希望使用二维数组。这就是我们需要重塑 numpy 数组的原因。
让我们来谈谈这个度量的物理意义。余弦相似度等于两个向量之间的余弦值。向量越接近,度量值越高。
我们甚至可以计算出矢量之间的精确角度(单位:度)。我们得到的结果是 30 度左右,看起来非常合理。
import math
math.degrees(math.acos(0.8575))
# 30.96
使用什么度量?
我们已经讨论过计算两个向量间距离的不同方法,你可能会开始考虑使用哪种方法。
你可以使用任何距离来比较你所拥有的嵌入。例如,我计算了不同聚类之间的平均距离。L2 距离和余弦相似度都向我们展示了相似的图片:
然而,对于 NLP 任务,最佳做法通常是使用余弦相似性。原因如下:
OpenAI 嵌入已经进行了规范化处理,因此在这种情况下,点积和余弦相似度是相等的。
你可能会从上面的结果中发现,簇间距离和簇内距离之间的差异并不大。根本原因是我们的向量维度太高。这种效应被称为 "维度灾难":维度越高,向量间的距离分布就越窄。
我想简要地向你展示一下它是如何工作的,以便你获得一些直观的认识。我计算了 OpenAI 嵌入值的分布,并生成了 300 个不同维度的向量集。然后,我计算了所有向量之间的距离,并绘制了直方图。你可以很容易地看到,向量维度的增加会使分布变窄。
我们已经学会了如何测量嵌入之间的相似性。至此,我们完成了理论部分的学习,接下来将进入更实用的部分(可视化和实际应用)。让我们从可视化开始,因为首先看到数据总是更好的。
嵌入式可视化
理解数据的最好方法就是将其可视化。不幸的是,嵌入式有 1536 个维度,因此查看数据非常具有挑战性。不过,还是有办法的:我们可以使用降维技术将向量投影到二维空间中。
PCA
最基本的降维技术是 PCA(主成分分析)。让我们尝试使用它。
首先,我们需要将嵌入数据转换为二维 numpy 数组,然后将其传递给 sklearn。
import numpy as np
embeddings_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 TSNE
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 = 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 "之外,大多数聚类都被分开了。不过,这也在意料之中。
通过这种可视化的方式,我们可以看到,嵌入式在编码语义方面非常出色。
此外,你还可以将其投影到三维空间并将其可视化。我不确定这样做是否实用,但在三维空间中玩转数据会很有启发和吸引力。
tsne_model_3d = TSNE(n_components=3, random_state=42)
tsne_3d_embeddings_values = tsne_model_3d.fit_transform(embeddings_array)
fig = px.scatter_3d(
x = tsne_3d_embeddings_values[:,0],
y = tsne_3d_embeddings_values[:,1],
z = tsne_3d_embeddings_values[:,2],
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,
opacity = 0.7
)
fig.update_layout(xaxis_title = 'first component', yaxis_title = 'second component')
fig.show()
条形码
理解嵌入式的方法是将其中一些嵌入式可视化为条形码,并查看相关性。我选取了三个嵌入的例子:两个最接近,另一个是数据集中最远的例子。
embedding1 = df.loc[1].embedding
embedding2 = df.loc[616].embedding
embedding3 = df.loc[749].embedding
import seaborn as sns
import matplotlib.pyplot as plt
embed_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()
在我们的案例中,由于维度较高,要看清向量之间是否接近并不容易。不过,我还是喜欢这种可视化。在某些情况下,它可能会有所帮助,所以我想与大家分享这个想法。
我们已经学会了如何将嵌入式可视化,并对其把握文本含义的能力不再有任何怀疑。现在,是时候进入最有趣、最吸引人的部分,讨论如何在实践中利用嵌入式技术了。
实际应用
当然,嵌入的主要目的并不是将文本编码成数字向量,也不是为了可视化而可视化。我们可以从捕捉文本含义的能力中获益良多。让我们举几个更实用的例子。
聚类
让我们从聚类开始。聚类是一种无监督学习技术,它允许你在没有任何初始标签的情况下将数据分成若干组。聚类可以帮助你了解数据的内部结构模式。
让我们在 2 到 50 之间尝试 k(簇数)。对于每个 k,我们都将训练一个模型并计算剪影得分。剪影分数越高,聚类效果越好。
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
import 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()
我们可以直观地看到,算法能够很好地定义聚类--它们被很好地分开了。
我们有事实性的主题标签,因此我们甚至可以评估聚类的效果如何。让我们来看看每个聚类的主题混合情况。
df['cluster'] = list(map(lambda x: 'cluster %s' % x, kmeans_labels))
cluster_stats_df = df.reset_index().pivot_table(
index = 'cluster', values = 'id',
aggfunc = 'count', columns = 'topic').fillna(0).applymap(int)
cluster_stats_df = cluster_stats_df.apply(
lambda x: 100*x/cluster_stats_df.sum(axis = 1))
fig = px.imshow(
cluster_stats_df.values,
x = cluster_stats_df.columns,
y = cluster_stats_df.index,
text_auto = '.2f', aspect = "auto",
labels=dict(x="cluster", y="fact topic", color="share, %"),
color_continuous_scale='pubugn',
title = '<b>Share of topics in each cluster</b>', height = 550)
fig.show()
在大多数情况下,分组工作都做得很好。例如,第 5 组几乎只包含有关自行车的问题,而第 6 组则是有关咖啡的问题。但是,它无法区分相近的主题:
在这个例子中,我们只使用了嵌入作为特征,但如果你有任何其他信息(例如,提问用户的年龄、性别或国家),也可以将其纳入模型中。
分类
我们可以将嵌入式用于分类或回归任务。例如,你可以用它来预测客户评论的情感(分类)或 NPS 分数(回归)。
由于分类和回归是有监督的学习,因此需要有标签。幸运的是,我们知道了问题的主题,并可以拟合一个模型来预测它们。
我将使用随机森林分类器。为了正确评估分类模型的性能,我们将把数据集分成训练集和测试集(80% 对 20%)。然后,我们可以在训练集上训练模型,并在测试集(模型以前从未见过的问题)上测量质量。
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
class_model = RandomForestClassifier(max_depth = 10)
# defining features and target
X = embeddings_array
y = df.topic
# splitting data into train and test sets
X_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_matrix
cm = 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%,例如 "自行车 "或 "旅行",而有些主题则很难区分(尤其是 "ai")。
不过,我们的总体准确率达到了 91.8%,相当不错。
查找异常
我们还可以利用嵌入技术来发现数据中的异常情况。例如,在 t-SNE 图中,我们发现有些问题离其群集很远,比如 "旅游 "主题。让我们来看看这个主题,并尝试找出异常点。我们将使用隔离林算法。
from sklearn.ensemble import IsolationForest
topic_df = df[df.topic == 'travel']
topic_embeddings_array = np.array(topic_df.embedding.values.tolist())
clf = IsolationForest(contamination = 0.03, random_state = 42)
topic_df['is_anomaly'] = clf.fit_predict(topic_embeddings_array)
topic_df[topic_df.is_anomaly == -1][['full_text']]
所以,我们来了。我们找到了关于旅游主题最不常见的评论。
Is it safe to drink the water from the fountains found all over
the older parts of Rome?
When I visited Rome and walked around the older sections, I saw many
different types of fountains that were constantly running with water.
Some went into the ground, some collected in basins, etc.
Is the water coming out of these fountains potable? Safe for visitors
to drink from? Any etiquette regarding their use that a visitor
should know about?
由于这条评论是关于水的,因此它的嵌入与咖啡话题很接近,人们也会在咖啡话题中讨论倒咖啡的水。因此,嵌入表示相当合理。
我们可以在 t-SNE 可视化中找到它,并发现它实际上与咖啡群组很接近。
总结
在本文中,我们详细讨论了文本嵌入。希望你现在已经对这一主题有了全面深入的了解。