可视化文本数据中的意外见解
https://medium.com/@crackalamoo?source=post_page---byline--224c524b5769--------------------------------https://towardsdatascience.com/?source=post_page---byline--224c524b5769-------------------------------- Harys Dalvi
·发表于Towards Data Science ·阅读时间:14 分钟·2024 年 7 月 12 日
–
在开始使用新数据集时,通常从一些探索性数据分析(EDA)入手是个不错的选择。在训练任何复杂模型之前,花时间了解你的数据有助于你理解数据集的结构,识别任何明显的问题,并运用领域特定的知识。
你可以在各种形式中看到 EDA,从房价到数据科学行业中的高级应用。但我仍然没有看到它应用于当前最火的新数据集:词向量,这也是我们最优秀的大型语言模型的基础。那么,为什么不尝试一下呢?
在这篇文章中,我们将将 EDA 应用于 GloVe 词向量,使用协方差矩阵、聚类、PCA 和向量数学等技术。这将帮助我们理解词向量的结构,为我们在此数据基础上构建更强大的模型提供一个有用的起点。当我们探索这个结构时,我们会发现它并不总是表面看起来的那样,某些意想不到的偏差隐藏在语料库中。
你将需要:
-
基本的线性代数、统计学和向量数学知识
-
Python 包:
numpy、sklearn和matplotlib -
约 3 GB 的空闲磁盘空间
数据集
要开始,请下载数据集,链接为:huggingface.co/stanfordnlp/glove/resolve/main/glove.6B.zip[1]。此文件包含三个文本文件,每个文件包含一组单词及其向量表示。我们将使用300 维的表示(glove.6B.300d.txt)。
简单说明一下这个数据集的来源:基本上,这是从维基百科和各种新闻来源中获取的 60 亿个标记的共现数据生成的词嵌入列表。使用共现的一个有用副作用是,意思相近的词往往会聚集在一起。例如,由于“the red bird”和“the blue bird”都是有效句子,我们可能会预期“red”和“blue”的向量会彼此接近。更多技术信息,可以参考原始的 GloVe 论文[1]。
需要明确的是,这些不是为大型语言模型训练的词嵌入。它们是一种完全无监督的技术,基于大量语料库。然而,它们展示了许多与语言模型嵌入相似的特性,且本身也很有趣。
这个文本文件的每一行由一个单词和该单词对应的 300 个向量分量组成,分量之间以空格分开。我们可以用 Python 将其加载进来。(为了减少噪音并加速处理,我这里使用了完整数据集的前 10% //10,但如果你愿意的话,可以调整。)
import numpy as np
embeddings = {}
with open(f"glove.6B/glove.6B.300d.txt", "r") as f:
glove_content = f.read().split('\n')
for i in range(len(glove_content)//10):
line = glove_content[i].strip().split(' ')
if line[0] == '':
continue
word = line[0]
embedding = np.array(list(map(float, line[1:])))
embeddings[word] = embedding
print(len(embeddings))
那么我们现在加载了 40,000 个嵌入向量。
相似度度量
我们可能会问的一个自然问题是:向量通常是否与意义相似的其他向量接近? 作为后续问题,我们如何量化这个?
我们将量化向量之间相似度的主要方式有两种:一种是欧几里得距离,这就是我们熟悉的自然的毕达哥拉斯定理距离。另一种是余弦相似度,它衡量两个向量之间的角度的余弦值。一个向量与自身的余弦相似度为 1,与相反的向量为-1,与正交向量为 0。
让我们在 NumPy 中实现这些:
def cos_sim(a, b):
return np.dot(a,b)/(np.linalg.norm(a) * np.linalg.norm(b))
def euc_dist(a, b):
return np.sum(np.square(a - b)) # no need for square root since we are just ranking distances
现在我们可以找到与给定单词或嵌入向量最接近的所有向量!我们将按升序进行操作。
def get_sims(to_word=None, to_e=None, metric=cos_sim):
# list all similarities to the word to_word, OR the embedding vector to_e
assert (to_word is not None) ^ (to_e is not None) # find similarity to a word or a vector, not both
sims = []
if to_e is None:
to_e = embeddings[to_word] # get the embedding for the word we are looking at
for word in embeddings:
if word == to_word:
continue
word_e = embeddings[word]
sim = metric(word_e, to_e)
sims.append((sim, word))
sims.sort()
return sims
现在我们可以写一个函数来显示最相似的 10 个词。最好能加上一个反向选项,这样我们就能显示最不相似的词。
def display_sims(to_word=None, to_e=None, n=10, metric=cos_sim, reverse=False, label=None):
assert (to_word is not None) ^ (to_e is not None)
sims = get_sims(to_word=to_word, to_e=to_e, metric=metric)
display = lambda sim: f'{sim[1]}: {sim[0]:.5f}'
if label is None:
label = to_word.upper() if to_word is not None else ''
print(label) # a heading so we know what these similarities are for
if reverse:
sims.reverse()
for i, sim in enumerate(reversed(sims[-n:])):
print(i+1, display(sim))
return sims
最后,我们可以进行测试了!
display_sims(to_word='red')
# yellow, blue, pink, green, white, purple, black, colored, sox, bright
看起来波士顿红袜队在这里意外亮相了。不过除此之外,这大致符合我们的预期。
也许我们可以尝试一些动词,而不仅仅是名词和形容词?怎么样,试试像“share”这样一个友好且温暖的动词?
display_sims(to_word='share')
# shares, stock, profit, percent, shared, earnings, profits, price, gain, cents
我猜“share”在这个数据集中作为动词使用的情况不多。唉,没关系。
我们还可以尝试一些更传统的例子:
display_sims(to_word='cat')
# dog, cats, pet, dogs, feline, monkey, horse, pets, rabbit, leopard
display_sims(to_word='frog')
# toad, frogs, snake, monkey, squirrel, species, rodent, parrot, spider, rat
display_sims(to_word='queen')
# elizabeth, princess, king, monarch, royal, majesty, victoria, throne, lady, crown
类比推理
词嵌入的一个迷人特性是,类比是通过向量数学内建的。GloVe 论文中的例子是king - queen = man - woman。换句话说,重新排列这个方程,我们预期会得到king = man - woman + queen。这是真的吗?
display_sims(to_e=embeddings['man'] - embeddings['woman'] + embeddings['queen'], label='king-queen analogy')
# queen, king, ii, majesty, monarch, prince...
并不完全正确:与man - woman + queen最接近的向量实际上是queen(余弦相似度 0.78),接下来是king(余弦相似度 0.66),不过差距有点远。受到这段精彩的3Blue1Brown 视频的启发,我们可以试试用aunt和uncle代替:
display_sims(to_e=embeddings['aunt'] - embeddings['woman'] + embeddings['man'], label='aunt-uncle analogy')
# aunt, uncle, brother, grandfather, grandmother, cousin, uncles, grandpa, dad, father
这个结果更好(余弦相似度 0.7348 对比 0.7344),但仍然不完美。不过我们可以尝试改用欧几里得距离。现在我们需要设置reverse=True,因为较高的欧几里得距离实际上代表了较低的相似度。
display_sims(to_e=embeddings['aunt'] - embeddings['woman'] + embeddings['man'], metric=euc_dist, reverse=True, label='aunt-uncle analogy')
# uncle, aunt, grandfather, brother, cousin, grandmother, newphew, dad, grandpa, cousins
现在我们明白了。但似乎类比数学可能没有我们希望的那样完美,至少在我们目前采用的这种简单方法中。
幅度
余弦相似度完全与向量之间的角度有关。那么,向量的幅度是否也很重要呢?
我们可以通过将幅度表示为与零向量的欧几里得距离来重用现有的代码。让我们看看哪些单词具有最大和最小的幅度:
zero_vec = np.zeros_like(embeddings['the'])
display_sims(to_e=zero_vec, metric=euc_dist, label='largest magnitude')
# republish, nonsubscribers, hushen, tael, www.star, stoxx, 202-383-7824, resend, non-families, 225-issue
display_sims(to_e=zero_vec, metric=euc_dist, reverse=True, label='smallest magnitude')
# likewise, lastly, interestingly, ironically, incidentally, moreover, conversely, furthermore, aforementioned, wherein
看起来大幅度向量的意义似乎没有什么规律,但它们似乎都有非常具体(有时甚至令人困惑)的含义。另一方面,最小幅度的向量往往是一些非常常见的单词,可以在各种语境中找到。
幅度之间有巨大的差异:从最小的向量大约 2.6 到最大的向量大约 17。这个分布看起来是怎样的呢?我们可以绘制一个直方图来更好地展示这一点。
import matplotlib.pyplot as plt
def plot_magnitudes():
words = [w for w in embeddings]
magnitude = lambda word: np.linalg.norm(embeddings[word])
magnitudes = list(map(magnitude, words))
plt.hist(magnitudes, bins=40)
plt.show()
plot_magnitudes()
我们的词嵌入的幅度直方图
这个分布看起来大致呈正态分布。如果我们想进一步测试这一点,可以使用Q-Q 图。但就目前而言,这样已经足够了。
数据集偏差
事实证明,向量嵌入中的方向和子空间可以编码多种不同的概念,通常是有偏的。这篇论文[2]研究了这种情况如何与性别偏见相关。
我们也可以在 GloVe 嵌入中复制这个概念。首先,让我们找到“男性气质”概念的方向。我们可以通过计算向量之间的差异来完成这个任务,比如he 和 she、man 和 woman,等等:
gender_pairs = [('man', 'woman'), ('men', 'women'), ('brother', 'sister'), ('he', 'she'),
('uncle', 'aunt'), ('grandfather', 'grandmother'), ('boy', 'girl'),
('son', 'daughter')]
masc_v = zero_vec
for pair in gender_pairs:
masc_v += embeddings[pair[0]]
masc_v -= embeddings[pair[1]]
现在我们可以找到“最具男性气质”和“最具女性气质”的向量,这些都是根据嵌入空间的判断来确定的。
display_sims(to_e=masc_v, metric=cos_sim, label='masculine vecs')
# brother, colonel, himself, uncle, gen., nephew, brig., brothers, son, sir
display_sims(to_e=masc_v, metric=cos_sim, reverse=True, label='feminine vecs')
# actress, herself, businesswoman, chairwoman, pregnant, she, her, sister, actresses, woman
现在,我们可以进行一个简单的测试,检测数据集中的偏见:计算nurse与man和woman之间的相似度。从理论上讲,这两个值应该大致相等:nurse 不是一个性别化的词汇。这是真的吗?
print("nurse - man", cos_sim(embeddings['nurse'], embeddings['man'])) # 0.24
print("nurse - woman", cos_sim(embeddings['nurse'], embeddings['woman'])) # 0.45
这是一个相当大的差异!(请记住,余弦相似度的范围是 -1 到 1,其中正相关值在 0 到 1 之间。)作为参考,0.45 也接近于cat 和 leopard 之间的余弦相似度。
聚类
让我们看看能否使用k-均值聚类来聚类具有相似含义的词语。使用scikit-learn包可以轻松实现这一点。我们将使用 300 个聚类,虽然听起来很多,但相信我:几乎所有聚类都非常有趣,你可以仅通过解释它们写一篇完整的文章!
from sklearn.cluster import KMeans
def get_kmeans(n=300):
kmeans = KMeans(n_clusters=n, n_init=1)
X = np.array([embeddings[w] for w in embeddings])
kmeans.fit(X)
return kmeans
def display_kmeans(kmeans):
# print all clusters and 5 associated words for each
words = np.array([w for w in embeddings])
X = np.array([embeddings[w] for w in embeddings])
y = kmeans.predict(X) # get the cluster for each word
for cluster in range(kmeans.cluster_centers_.shape[0]):
print(f'KMeans {cluster}')
cluster_words = words[y == cluster] # get all words in each cluster
for i, w in enumerate(cluster_words[:5]):
print(i+1, w)
kmeans = get_kmeans()
display_kmeans(kmeans)
这里有很多内容可以查看。我们有涉及不同主题的聚类,如纽约市(manhattan, n.y., brooklyn, hudson, borough)、分子生物学(protein, proteins, enzyme, beta, molecules)和印度名字(singh, ram, gandhi, kumar, rao)。
但是有时这些聚类并不像看起来那样简单。让我们编写代码来显示包含给定词语的聚类中的所有词汇,以及最近和最远的聚类。
def get_kmeans_cluster(kmeans, word=None, cluster=None):
# given a word, find the cluster of that word. (or start with a cluster index.)
# then, get all words of that cluster.
assert (word is None) ^ (cluster is None)
if cluster is None:
cluster = kmeans.predict([embeddings[word]])[0]
words = np.array([w for w in embeddings])
X = np.array([embeddings[w] for w in embeddings])
y = kmeans.predict(X)
cluster_words = words[y == cluster]
return cluster, cluster_words
def display_cluster(kmeans, word):
cluster, cluster_words = get_kmeans_cluster(kmeans, word=word)
# print all words in the cluster
print(f"Full KMeans ({word}, cluster {cluster})")
for i, w in enumerate(cluster_words):
print(i+1, w)
# rank all clusters (excluding this one) by Euclidean distance of their centers from this cluster's center
distances = np.concatenate([kmeans.cluster_centers_[:cluster], kmeans.cluster_centers_[cluster+1:]], axis=0)
distances = np.sum(np.square(distances - kmeans.cluster_centers_[cluster]), axis=1)
nearest = np.argmin(distances, axis=0)
_, nearest_words = get_kmeans_cluster(kmeans, cluster=nearest)
print(f"Nearest cluster: {nearest}")
for i, w in enumerate(nearest_words[:5]):
print(i+1, w)
farthest = np.argmax(distances, axis=0)
print(f"Farthest cluster: {farthest}")
_, farthest_words = get_kmeans_cluster(kmeans, cluster=farthest)
for i, w in enumerate(farthest_words[:5]):
print(i+1, w)
现在让我们尝试这段代码。
display_cluster(kmeans, 'animal')
# species, fish, wild, dog, bear, males, birds...
display_cluster(kmeans, 'dog')
# same as 'animal'
display_cluster(kmeans, 'birds')
# same again
display_cluster(kmeans, 'bird')
# spread, bird, flu, virus, tested, humans, outbreak, infected, sars....?
你可能不会每次都得到完全相同的结果:聚类算法是非确定性的。但大多数情况下,“鸟类”会与疾病相关词汇而非动物词汇关联。似乎原始数据集倾向于在疾病传播媒介的语境中使用“鸟”这个词。
这里有成百上千个聚类等待你探索它们的内容。我发现一些有趣的聚类包括“伊利诺伊州”和“成吉思汗”。
主成分分析
**主成分分析(PCA)**是我们用来找出数据集在向量空间中方差最大的方向的工具。让我们试试它。和聚类一样,sklearn使得这一过程非常简单。
from sklearn.decomposition import PCA
def get_pca_vecs(n=10): # get the first 10 principal components
pca = PCA()
X = np.array([embeddings[w] for w in embeddings])
pca.fit(X)
principal_components = list(pca.components_[:n, :])
return pca, principal_components
pca, pca_vecs = get_pca_vecs()
for i, vec in enumerate(pca_vecs):
# display the words with the highest and lowest values for each principal component
display_sims(to_e=vec, metric=cos_sim, label=f'PCA {i+1}')
display_sims(to_e=vec, metric=cos_sim, label=f'PCA {i+1} negative', reverse=True)
就像我们的k-均值实验一样,这些 PCA 向量中有很多非常有趣的内容。例如,让我们来看看主成分 9:
PCA 9
1 featuring: 0.38193
2 hindi: 0.37217
3 arabic: 0.36029
4 sung: 0.35130
5 che: 0.34819
6 malaysian: 0.34474
7 ka: 0.33820
8 video: 0.33549
9 bollywood: 0.33347
10 counterpart: 0.33343
PCA 9 negative
1 suffolk: -0.31999
2 cumberland: -0.31697
3 northumberland: -0.31449
4 hampshire: -0.30857
5 missouri: -0.30771
6 calhoun: -0.30749
7 erie: -0.30345
8 massachusetts: -0.30133
9 counties: -0.29710
10 wyoming: -0.29613
看起来,组件 9 的正值与中东、南亚和东南亚的术语相关,而负值则与北美和英国的术语相关。
另一个有趣的成分是成分 3。所有的正值都是十进制数,显然这是这个模型中的一个显著特征。成分 8 也展示了类似的模式。
PCA 3
1 1.8: 0.57993
2 1.6: 0.57851
3 1.2: 0.57841
4 1.4: 0.57294
5 2.3: 0.57019
6 2.6: 0.56993
7 2.8: 0.56966
8 3.7: 0.56660
9 1.9: 0.56424
10 2.2: 0.56063
降维
PCA 的一个主要优点是,它允许我们将一个高维数据集(在此案例中为 300 维)通过投影到前几个组件,仅用两维或三维展示。让我们尝试做一个二维图,看是否能从中提取出任何信息。我们还会使用k-均值进行按聚类的颜色编码。
def plot_pca(pca_vecs, kmeans):
words = [w for w in embeddings]
x_vec = pca_vecs[0]
y_vec = pca_vecs[1]
X = np.array([np.dot(x_vec, embeddings[w]) for w in words])
Y = np.array([np.dot(y_vec, embeddings[w]) for w in words])
colors = kmeans.predict([embeddings[w] for w in words])
plt.scatter(X, Y, c=colors, cmap='spring') # color by cluster
for i in np.random.choice(len(words), size=100, replace=False):
# annotate 100 randomly selected words on the graph
plt.annotate(words[i], (X[i], Y[i]), weight='bold')
plt.show()
plot_pca(pca_vecs, kmeans)
我们的嵌入数据集的第一(X 轴)和第二(Y 轴)主成分的图表
不幸的是,这个图表完全乱了!从中学到的信息非常有限。看起来仅凭两个维度来解释 300 个维度的数据集,至少在这个数据集的情况下,并不是很容易。
有两个例外。首先,我们看到名字通常会聚集在图表的顶部。其次,在左下角有一个突出的小部分,像个伤疤一样。这一区域似乎与数字相关,尤其是十进制数字。
协方差
了解输入特征之间的协方差往往很有帮助。在这种情况下,我们的输入特征只是难以解释的抽象向量方向。然而,协方差矩阵可以告诉我们这些信息实际上被利用了多少。如果我们看到较高的协方差,意味着某些维度之间高度相关,或许我们可以稍微减少维度。
def display_covariance():
X = np.array([embeddings[w] for w in embeddings]).T # rows are variables (components), columns are observations (words)
cov = np.cov(X)
cov_range = np.maximum(np.max(cov), np.abs(np.min(cov))) # make sure the colorbar is balanced, with 0 in the middle
plt.imshow(cov, cmap='bwr', interpolation='nearest', vmin=-cov_range, vmax=cov_range)
plt.colorbar()
plt.show()
display_covariance()
我们数据集中所有 300 个向量组件的协方差矩阵
当然,主对角线有一条明显的线,表示每个组件与自身有很强的相关性。除此之外,这个图并没有什么特别有趣的地方。大部分区域看起来几乎是空白的,这其实是一个好兆头。
如果仔细观察,你会发现有一个例外:组件 9 和 276 似乎有较强的相关性(协方差为 0.308)。
聚焦于组件 9 和 276 的协方差矩阵。观察这里有一个稍微明亮的红点,以及沿着行和列的奇怪行为。
让我们通过打印与组件 9 和 276 最相关的向量来进一步调查。这个操作相当于计算与一个全零基向量的余弦相似度,除了相关组件的位置为 1。
e9 = np.zeros_like(zero_vec)
e9[9] = 1.0
e276 = np.zeros_like(zero_vec)
e276[276] = 1.0
display_sims(to_e=e9, metric=cos_sim, label='e9')
# grizzlies, supersonics, notables, posey, bobcats, wannabe, hoosiers...
display_sims(to_e=e276, metric=cos_sim, label='e276')
# pehr, zetsche, steadied, 202-887-8307, bernice, goldie, edelman, kr...
这些结果很奇怪,并且信息量不大。
等一下:如果一个向量组件中具有非常负值的词汇在另一个组件中也倾向于具有非常负的值,那么这些组件之间也可能存在正协方差。让我们试着反转相似性的方向。
display_sims(to_e=e9, metric=cos_sim, label='e9', reverse=True)
# therefore, that, it, which, government, because, moreover, fact, thus, very
display_sims(to_e=e276, metric=cos_sim, label='e276', reverse=True)
# they, instead, those, hundreds, addition, dozens, others, dozen, only, outside
看起来这两个组件都与基本功能词和数字相关,这些词可以在许多不同的语境中找到。这有助于解释它们之间的协方差,至少比正协方差的情况更能解释这一点。
结论
在本文中,我们对一个包含 300 维的GloVe 词向量数据集应用了各种**探索性数据分析(EDA)**技术。我们使用余弦相似度来衡量词汇意义之间的相似性,使用聚类将词汇分组为相关群体,并通过主成分分析(PCA)来识别对词向量模型最重要的向量空间方向。
我们通过主成分分析(PCA)在视觉上观察到输入特征之间的协方差非常小。我们尝试使用 PCA 将所有 300 维的数据投影到二维空间中,但结果还是有点混乱。
我们还测试了数据集中的假设和偏差。通过比较nurse与man和woman的余弦相似度,我们识别出了数据集中的性别偏见。我们尝试使用向量数学表示类比(例如,“king”与“queen”的关系就像“man”与“woman”),并取得了一定的成功。通过减去指代男性和女性的向量示例,我们能够发现与性别相关的向量方向,并展示数据集中“最男性化”和“最女性化”的向量。
你可以在词向量数据集上尝试更多的 EDA,但我希望这能作为一个良好的起点,帮助你理解一般的 EDA 技术以及词向量的结构。如果你想查看与本文相关的完整代码及一些额外的示例,可以访问我的 GitHub:crackalamoo/glove-embeddings-eda。感谢阅读!
参考文献
[1] J. Pennington, R. Socher 和 C. Manning,GloVe:用于词表示的全局向量(2014 年),斯坦福大学自然语言处理(公开领域数据集)
[2] T. Bolukbasi, K. Chang, J. Zou, V. Saligrama 和 A. Kalai,人类和计算机程序员的关系,就像女人和家庭主妇的关系?去偏见词向量嵌入(2016 年),微软研究院新英格兰分院
所有图片均由作者使用 Matplotlib 制作。
5657

被折叠的 条评论
为什么被折叠?



