作者:来自 Elastic Philipp Kahr 及 Vincent Bosc

理解向量从未如此简单。在一个高度偏倚的数据集中,手工构建向量并探索各种技术来找到你的音乐好友。
在第一部分中,我们讨论了如何获取你的 Spotify 数据并进行可视化;在第二部分中,我们讲到了如何处理数据以及可视化方法;第三部分中我们探索了异常检测,以及它如何帮助我们发现有趣的听歌行为;第四部分通过使用 Kibana Graph 揭示了艺术家之间的关系。而在本部分中,我们将介绍如何使用向量来找到你的音乐好友。
用向量发现你的音乐好友
向量是一种具有大小 (幅度)和方向的数学实体。在这里,向量被用来表示数据,例如用户对每位艺术家所听歌曲的数量。向量的大小对应于某位艺术家的播放次数,而方向则由该向量中所有艺术家的播放数量所占的比例共同决定。虽然方向并没有被明确设置或可视化,但它是由向量中的值及其彼此之间的关系隐含定义的。

这个想法很简单:我们创建一个大型数组,采用 key => value 的排序方式。其中,key 是艺术家,value 是被听的歌曲数量。这是一种非常简单的方法,只需几行代码就可以实现。我们会创建这样一个向量:

这非常有趣,因为现在这个向量是按艺术家名称排序的。这样一来,对于用户没有听过、甚至不知道存在的艺术家,我们就得到了值为零的项。
接下来,找到你的音乐匹配对象就变成了一个简单的任务:计算两个向量之间的距离,并找出最接近的匹配对象。可以使用多种方法来实现,比如点积(dot product)、欧几里得距离(euclidean distance)和余弦相似度(cosine similarity)。每种方法的行为都不同,可能会产生不同的结果。因此,尝试并找出最适合你需求的方法非常重要。
余弦相似度、欧几里得距离和点积是如何工作的?
我们不会深入探讨每种方法的数学细节,而是会简要介绍它们的工作原理。为了简化,我们用两个维度来说明:Ariana Grande 和 Taylor Swift。用户 A 听了 100 首 Taylor Swift 的歌,用户 B 听了 300 首 Ariana Grande 的歌,而用户 C 处于中间状态,分别听了 100 首 Taylor Swift 和 100 首 Ariana Grande 的歌。

- 余弦相似度的值越大(角度越小)表示相似度越高,它关注的是向量的方向,忽略大小。在我们的例子中,用户 C 与用户 A 和用户 B 的相似度是一样的,因为他们之间向量的夹角相同(都是 45°)。
- 欧几里得距离衡量的是两个点之间的直线距离,距离越短,相似度越高。这种方法对方向和大小都很敏感。在我们的例子中,用户 C 距离用户 A 更近,因为他们在空间中的位置差异较小。
- 点积通过将两个向量对应位置的数值相乘后求和来计算相似度。这种方法既对大小敏感,也对方向敏感。例如,用户 A 和用户 B 的点积为 0,因为他们的偏好没有任何重叠。而用户 C 与用户 B 的点积为 300 × 100 = 30,000,高于与用户 A 的 100 × 100 = 10,000,这是因为用户 B 的向量幅度更大。这突显了点积对数值规模的敏感性,在向量大小差异明显时可能会导致结果偏移。
更多有关上面距离的介绍,请参阅文章 "Elasticsearch:向量搜索的快速介绍"。
在我们的具体使用场景中,向量的大小不应对相似度结果产生显著影响。这凸显了在使用欧几里得距离或点积等方法之前进行归一化处理的重要性(稍后会详细介绍),以避免因尺度差异而导致比较结果偏差
数据分布
我们数据集的分布是一个关键因素,因为在后续寻找你的最佳音乐匹配时,它将起到重要作用。
User | Count of records | Unique Artists | Unique Titles | Responsible for % of dataset |
---|---|---|---|---|
philipp | 202907 | 14183 | 24570 | 35% |
elisheva | 140906 | 9872 | 23770 | 24% |
stefanie | 70373 | 2647 | 5471 | 12% |
emil | 53568 | 5663 | 14227 | 9% |
karolina | 41232 | 7988 | 12427 | 7% |
iulia | 39598 | 5114 | 8976 | 6% |
chris | 23598 | 6124 | 8654 | 4% |
Summary: 7 | 572182 | 35473 | 77942 | 100% |
关于数据集多样性的更多细节将在 dense_vector 部分的 Dataset Issues 小节中讨论。主要问题在于每个用户所听艺术家的分布。每种颜色代表一个不同的用户,我们可以观察到各种不同的听歌风格:有些用户听的艺术家种类广泛且分布均匀,而其他用户则只关注少数几个艺术家,甚至仅集中在一个或少数几个艺术家上。这些差异突出了在创建向量时考虑用户听歌模式的重要性。

使用 dense_vector 类型
首先,我们已经创建了上面的向量,现在我们可以将其存储在 dense_vector 类型的字段中。我们将在 Python 代码中根据向量的长度自动创建所需的维度。
markdown
`
1. elasticsearch.indices.create(
2. index="spotify-dense-vector",
3. mappings={
4. "properties": {
5. "artists": {
6. "type": "dense_vector",
7. "dims": len(unique_artists)
8. },
9. "user": {
10. "type": "keyword"
11. }
12. }
13. },
14. )
`AI写代码
哎呀,出现了错误,错误信息如下:Error: BadRequestError(400, 'mapper_parsing_exception', 'The number of dimensions should be in the range [1, 4096] but was [33806]')。这意味着我们的艺术家向量太大了,长度为 33806 项。这是个有趣的问题,我们需要找到一种方法来减少它。这个数字 33806 代表的是艺术家的基数。基数是唯一性的另一种说法,它表示数据集中唯一值的数量。在我们的情况下,它表示的是所有用户中唯一艺术家的数量。
最简单的方法之一是重新构建向量。让我们专注于排名前 1000 的常用艺术家,这将把向量的大小减少到 1000。我们也可以将其增加到 4096,然后看看是否还会出现其他问题。
arduino
`
1. GET spotify-history/_search?size=0
2. {
3. "aggs": {
4. "user": {
5. "terms": {
6. "field": "user"
7. },
8. "aggs": {
9. "artists": {
10. "terms": {
11. "field": "artist",
12. "size": 1000
13. }
14. }
15. }
16. }
17. }
18. }
`AI写代码
这种聚合方法给我们每个用户的前 1000 名艺术家。然而,这可能会导致一些问题。例如,如果有 7 个用户,而前 1000 名艺术家没有任何重叠,我们最终会得到一个 7000 维的向量。当测试这个方法时,我们遇到了以下错误:Error: BadRequestError(400, 'mapper_parsing_exception', 'The number of dimensions should be in the range [1, 4096] but was [4456]')。这表明我们的向量维度过大。
为了解决这个问题,有几种选择。一种简单的方法是将前 1000 名艺术家减少到 950、900、800 等,直到符合 4096 维度的限制。将每个用户的前 n 名艺术家减少到适应 4096 维度的限制可能会暂时解决问题,但每次添加新用户时,由于他们的独特艺术家会增加整体向量的维度,这种问题会再次出现。这使得这种方法在长期扩展系统时不可持续。我们已经意识到,需要找到一种不同的解决方案。
arduino
`
1. GET spotify-history/_search?size=0
2. {
3. "aggs": {
4. "artists": {
5. "terms": {
6. "field": "artist",
7. "size": 1000
8. },
9. "aggs": {
10. "user": {
11. "terms": {
12. "field": "user"
13. }
14. }
15. }
16. }
17. }
18. }
`AI写代码
数据集问题
我们通过调整聚合方法,从计算每个用户的前 1000 名艺术家,改为计算整体的前 1000 名艺术家,然后再按用户拆分结果。这样可以确保向量长度恰好为 1000 名艺术家。然而,这一调整并没有解决我们数据集中的一个重大问题:它在某些艺术家上存在严重偏倚,单个用户可能会对结果产生不成比例的影响。
如前所示,Philipp 占据了大约 35% 的所有数据,极大地扭曲了结果。这可能导致像 Chris 这样较小贡献者的前 1000 名艺术家被排除在外,甚至在更大的向量中,前 4096 名艺术家也可能无法包括他们的艺术家。此外,像 Stefanie 这样反复听同一艺术家的极端用户也会进一步扭曲结果。
为了更好地说明这一点,我们将 JSON 响应转换为表格,以提高可读性。
Artist | Total | Count | User |
---|---|---|---|
Casper | 15100 | ||
14924 | stefanie | ||
170 | philipp | ||
4 | emil | ||
2 | chris | ||
Taylor Swift | 12961 | ||
9557 | elisheva | ||
2240 | stefanie | ||
664 | iulia | ||
409 | philipp | ||
53 | karolina | ||
23 | chris | ||
15 | emil | ||
Ariana Grande | 7247 | ||
3508 | philipp | ||
1873 | elisheva | ||
1525 | iulia | ||
210 | stefanie | ||
107 | karolina | ||
24 | chris | ||
K.I.Z | 6683 | ||
6653 | stefanie | ||
23 | philipp | ||
7 | emil |
数据集中显然存在问题。例如,Casper 和 K.I.Z,两个德国艺术家,都出现在前 5 名中,但 Casper 的排名受 Stefanie 的强烈影响,Stefanie 占据了该艺术家约 99% 的所有听歌数据。这个偏差过大的情况将 Casper 排在了第一位,尽管这可能并不代表整个数据集的情况。
为了在仍然使用 4096 名艺术家的密集向量时解决这个问题,我们可以应用一些数据处理技术。例如,我们可以考虑使用 diversified_sampler 或像 softmax 这样的方法来计算每个艺术家的相对重要性。然而,如果我们想避免过度的数据处理,可以选择使用稀疏向量(sparse_vector)来采取不同的方法。
使用 sparse_vector 类型
我们尝试将每个位置代表一个艺术家的向量压缩到 dense_vector 字段中,但显然这并不是最好的选择。我们受限于 4096 名艺术家的数量,最终得到了一个包含大量空值的大数组。例如,Philipp 可能永远不会听 Pink Floyd,但在密集向量方法中,Pink Floyd 却占据了一个位置,并且值为 0。本质上,我们使用了密集向量格式来处理本质上稀疏的数据。幸运的是,Elasticsearch 支持通过 sparse_vector 类型表示稀疏向量。接下来,让我们探索它是如何工作的!
markdown
`
1. {
2. "artists": {
3. "Casper": 1334,
4. "Fred again..": 22561,
5. "Ariana Grande": 10234
6. }
7. }
`AI写代码
我们将不再创建一个大的数组,而是创建一个键值(key => value)对,并将艺术家的名字与收听次数存储在一起。这是一种更高效的数据存储方式,可以让我们存储更高的基数。稀疏向量中的键值对数量实际上没有限制。虽然在某些情况下,性能可能会下降,但那是另一个话题。任何空值对都会被跳过。
那么,搜索会是怎样的呢?我们将所有 artists 的内容放入查询向量(query_vector)中,并使用 sparse_vector 查询类型,只返回 user 和得分。
markdown
`
1. GET spotify-sparse-vector-total_count/_search
2. {
3. "fields": [
4. "user"
5. ],
6. "query": {
7. "sparse_vector": {
8. "field": "artists",
9. "query_vector": {
10. "Fred again..": 4096
11. "Ariana Grande": 3508,
12. "Harry Styles": 2535,
13. ...
14. }
15. }
16. }
17. }
`AI写代码
归一化
使用 sparse_vector 让我们能够更高效地存储数据,并处理更高的基数而不会遇到维度限制。然而,权衡是我们只能使用点积(dot product)进行相似度计算,这意味着我们无法直接使用诸如余弦相似度(cosine similarity)或欧几里得距离(Euclidean distance)等方法。如我们之前所见,点积受到向量幅度的强烈影响。为了最小化或避免这种影响,我们首先需要对数据进行归一化处理。
我们提供完整的稀疏向量来识别我们的 "音乐最佳伙伴"。这种直接的方法已经产生了一些有趣的结果,如此处所示。然而,我们仍然遇到了类似之前的问题:向量幅度的影响。尽管这种影响与密集向量方法相比较轻微,但数据集的分布仍然会产生不平衡。例如,Philipp 可能会因为他听的艺术家数量庞大而与许多用户匹配得过于不成比例。
这引出了一个重要的问题:如果你听一个艺术家 100 次、500 次、10,000 次还是 25,000 次,这真的有区别吗?答案是否定的 ------ 关键是偏好分布的相对差异。为了解决这个问题,我们可以使用像 Softmax 这样的归一化函数来归一化数据,它将原始值转换为概率。它对每个值进行指数化,并将其除以所有值的指数之和,确保所有输出都在 0 和 1 之间,并且总和为 1。
你可以直接在 Elasticsearch 中使用 normalize 聚合进行归一化,或者在 Python 中通过 Numpy 编程实现归一化。通过这个归一化步骤,每个用户都由一个包含艺术家及其归一化值的文档来表示。最终在 Elasticsearch 中的文档看起来是这样的:
markdown
`
1. {
2. "user": "philipp",
3. "artists": {
4. "Fred again..": 0.013858094401620936,
5. "Ariana Grande": 0.0073373738573697155,
6. "Harry Styles": 0.002186232187442565,
7. "Too Many Zooz": 0.0019208388258044266,
8. "Kraftklub": 0.001862001480735705,
9. "Jamie xx": 0.0015681941691341277,
10. "Billy Talent": 0.001156093556076037,
11. "Billie Eilish": 0.0008427954862422548,
12. "Lizzo": 0.000744181016015583,
13. ...
14. }
15. }
`AI写代码
寻找你的音乐匹配其实非常简单。我们会获取 Philipp 的整个文档,因为我们要将他与其他所有用户进行匹配。搜索过程如下所示:
arduino
`
1. GET spotify-sparse-vector-softmax/_search
2. {
3. "fields": [
4. "user"
5. ],
6. "query": {
7. "sparse_vector": {
8. "field": "artists",
9. "query_vector": {
10. "Fred again..": 0.013858094401620936,
11. "Ariana Grande": 0.0073373738573697155,
12. "Harry Styles": 0.002186232187442565,
13. "Too Many Zooz": 0.0019208388258044266,
14. "Kraftklub": 0.001862001480735705,
15. "Jamie xx": 0.0015681941691341277,
16. "Billy Talent": 0.001156093556076037,
17. ...
18. }
19. }
20. }
21. }
`AI写代码
响应是以 JSON 格式返回的,包含了得分和用户信息;我们将其转换成表格,以提高可读性,然后将得分乘以 1.000 来去掉前导零。
User | Score |
---|---|
philipp | 0.36043773 |
karolina | 0.050112092 |
stefanie | 0.04934514 |
iulia | 0.048445952 |
chris | 0.039548675 |
elisheva | 0.037409707 |
emil | 0.036741032 |
在未调优且开箱即用的 Softmax 中,我们看到 Philipp 的最佳音乐伙伴是 Karolina,得分为 0.050...,紧随其后的是 Stefanie,得分为 0.049...,而 Emil 与 Philipp 的口味最远。从第二篇博客中的仪表盘比较 Karolina 和 Philipp 的数据来看,这看起来有点奇怪。让我们深入探讨得分是如何计算的。问题在于,在未调优的 Softmax 中,排名第一的艺术家可能会得到接近 1 的值,而第二个艺术家已经只有 0.001...,这进一步强调了你的顶级艺术家。这一点很重要,因为用于识别你最接近匹配的点积计算是这样工作的:
markdown
`
1. Karolina: Dua Lipa: 1
2. Philipp: Dua Lipa: 0.33
`AI写代码
当我们计算点积时,我们进行 1 * 0.33 = 0.33,这大大提升了我与 Karolina 的兼容性。当 Philipp 在与其他任何用户的顶级艺术家没有比 0.33 更高的值时,Karolina 就是我的最佳音乐伙伴,尽管我们可能几乎没有其他共同点。为了说明这一点,下面是我们前五名艺术家的并排对比表格。数字表示在前五名艺术家中的排名。
Artist | Karolina | Philipp |
---|---|---|
Fred Again .. | 1 | |
Ariana Grande | 2 | |
Harry Styles | 3 | |
Too Many Zooz | 4 | |
Kraftklub | 5 | |
Dua Lipa | 1 | 15 |
David Guetta | 2 | 126 |
Calvin Harris | 3 | 32 |
Jax Jones | 4 | 378 |
Ed Sheeran | 5 | 119 |
我们可以观察到 Philipp 与 Karolina 的前五名艺术家有所重叠。尽管这些艺术家的排名在 Philipp 的前五名中分别是第 15、32、119、126 和 378,但 Karolina 的任何值都会乘以 Philipp 的排名。在这种情况下,Karolina 的前五名艺术家的顺序比 Philipp 的排名更重要。通过调整温度(temperature)和光滑度(smoothness),我们可以修正 Softmax。通过试验一些温度和光滑度的值,最终得到了这个结果(得分乘以 1.000 来去掉前导零)。较高的温度描述了 Softmax 如何急剧分配概率,它使数据分布更加均匀,而较低的温度则强调少数几个主导值,并且随着值的下降变化急剧。
User | Score |
---|---|
philipp | 3.59 |
stefanie | 0.50 |
iulia | 0.484 |
karolina | 0.481 |
chris | 0.395 |
elisheva | 0.374 |
emil | 0.367 |
添加温度(temperature)和光滑度(smoothness)后,结果发生了变化。现在 Stefanie 成为 Philipp 的最佳匹配,而不是 Karolina。看到调整计算艺术家重要性的方法如何对搜索结果产生重大影响,确实很有趣。
构建艺术家值的方式有很多其他选择。我们可以考虑每个用户在数据集中表示的艺术家的总百分比。这可能比 Softmax 更好地分配值,并确保像上面提到的 Karolina 和 Philipp 之间的点积计算(例如在 Dua Lipa 上)不会再具有那么大的重要性。另一个选择是考虑总的收听时间,而不仅仅是歌曲的数量或它们的百分比。这将帮助处理发布较长歌曲的艺术家,尤其是那些长度超过 5-6 分钟的歌曲。例如,Fred Again.. 的一首歌可能只有 2:30,这意味着 Philipp 可以比其他人听到两倍的歌曲数量。listened_to_ms
是以毫秒为单位,我们也会围绕是否应该使用 sum()
的方法展开类似的讨论,类似于歌曲播放次数的统计。它是一个绝对数值,当该数值增大时,较高的数值所占的权重应该变小。我们也可以考虑收听完成度,可以使用 listened_to_pct
,并且可以对数据进行预过滤,只保留用户至少完成 80% 的歌曲。为什么要关注那些在前几秒或几分钟就跳过的歌曲呢?收听百分比会惩罚那些听大量随机艺术家的歌曲(例如使用每日推荐播放列表的用户),同时强调那些喜欢反复听同一艺术家的用户。实际上,有很多方法可以调整和修改数据集,以获得最佳结果。这些方法都需要时间,并且每种方法都有不同的缺点。
结论
在这篇博客中,我们带领你一起探索了如何识别你的音乐朋友。我们从对 Elasticsearch 的了解有限开始,认为密集向量是答案,进而深入研究了我们的数据集并转向稀疏向量。在这个过程中,我们探讨了提升搜索质量的几个优化方法,并讨论了如何减少偏差。最终,我们找到了最适合我们的方法 ------ 使用带有百分比的稀疏向量。稀疏向量也是 ELSER 的核心;不同的是,ELSER 处理的是单词,而不是艺术家。
想获得 Elastic 认证吗?查看下一期 Elasticsearch 工程师培训课程的时间!
Elasticsearch 充满了新功能,帮助你为自己的用例构建最佳的搜索解决方案。深入探索我们的样本笔记本,了解更多,开始免费的云试用,或者立即在本地机器上尝试 Elastic。
原文:Finding your best music friend with vectors: Spotify Wrapped, part 5 - Elasticsearch Labs