一文开启无监督学习之旅

Posted 盼小辉丶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文开启无监督学习之旅相关的知识,希望对你有一定的参考价值。

1. 聚类分析简介

我们知道,机器学习本质上是一类优化问题——获取数据样本和目标函数,并尝试优化目标函数。在监督学习中,目标函数基于数据数据样本的标签,我们的目标是最小化模型预测和实际标签之间的差异,但在无监督学习中,我们并没有数据样本的标签。
本文主要介绍无监督学习中最重要的分枝之一——聚类 (Clustering)。 当我们需要为在不带有标签的数据中挖掘所需的潜在信息时,聚类通常是我们的首选算法。例如,在电子商务网站中,营销团队可能会需要将用户存储在不同组中,以便可以为每个用户组发送不同的定制化消息,而通常这些数以百万级的用户并没有明确的标签,此时聚类就是将这些用户归为不同用户组的唯一方法;同样,在当处理大量文档、视频或网页时,聚类通常也是一种有效的方法。

2. 聚类算法基础概念

聚类算法本质上是将数据样本放入不同簇中 (clusters),其目标是使簇内距离最小化,而使簇间距离最大化。换句话说,我们希望同一簇中的样本尽可能相似,而使不同簇中的样本差异尽可能大。
我们首先考虑最极端的情况,如果我们将每个样本视为一个簇,则簇内距离全为零,而簇间距离最大,但这显然不是我们所需要的聚类算法。因此,为了避免这种解决方案,我们通常在优化函数中添加一个约束。例如,我们可以预先定义所需的簇数量,或者设置每个簇中最小的样本数。
由于数据样本不具标签,因此也决定了聚类算法的具有多种评估指标。测量簇内距离的一种方法是计算簇中每个点与簇质心之间的距离,我们可以把质心理解为簇中所有样本的平均值,也可以称为标准差,我们也可以使用相同的距离度量函数来测量不同簇的质心间的差异。
本文中,我们将介绍 3 种常用的聚类算法,以及用于评估聚类算法性能的方法。

3. K-means 聚类

3.1 K-means 算法原理

我们已经知道可以通过指定所需的簇数量对目标函数施加的约束,K-means 中的 K 就是簇的数量,means 就意味着簇的质心,该算法的原理如下:

  1. 选择 K 个随机点并将它们设置为簇质心
  2. 将每个数据样本分配到离它最近的质心,形成 K 个簇。
  3. 为新形成的簇计算新的质心。
  4. 由于质心已经更新,跳转到第 2 步,根据更新的质心将样本重新分配到新的簇中。如果质心没有太大移动,意味着算法已经收敛,则算法停止。

可以看出,K-Means 是一个迭代算法,它不断迭代直到收敛,同时我们也可以通过设置最大迭代次数 max_iter 超参数来防止算法无限循环。此外,我们也可以通过将 tol 超参数设置为更大的值来限制质心移动的上限,并提前停止算法的运行。有多种可用于初始化簇质心的方法,通过将算法的 init 超参数设置为 k-means++ 可以确保初始质心彼此相距较远。这通常比随机初始化效果更好,K 的选择是通过 n_clusters 超参数设定。

3.2 创建数据集

为了演示 K-Means 算法及不同超参数的作用,我们首先创建一个简单的示例数据集。
使用 make_blobs 函数可以创建一个 blob 数据集,我们将样本数设置为 100,并将它们分成四个簇。每个数据点只有两个特征,便于对其进行可视化,使用 cluster_std 参数来确保每个簇具有不同的标准差,也就是说不同簇的聚集程度不同。该函数还会返回数据样本的标签,可以将其用于验证聚类算法。最后,将生成的数据 xy 放入 DataFrame 中:

from sklearn.datasets import make_blobs
import pandas as pd

x, y = make_blobs(n_samples=100, centers=4, n_features=2, cluster_std=[1, 1.5, 2, 2])
df_blobs = pd.DataFrame(
    
        'x1': x[:,0],
        'x2': x[:,1],
        'y': y
    
)

现在我们已经创建了相关数据,接下来,我们创建可视化函数来直观的查看这些数据。

3.3 数据集可视化

plot_2d_clusters 函数将数据 x 和标签 y 绘制到散点图中。我们为图指定一个标题,并使用 y 作为簇标签,标记每个数据样本:

def plot_2d_clusters(x, y, ax):
    y_uniques = pd.Series(y).unique()
    for y_unique_item in y_uniques:
        x[
            y == y_unique_item
        ].plot(title=f'len(y_uniques) Clusters',
            kind='scatter',
            x='x1', y='x2',
            marker=f'$y_unique_item$',
            ax=ax,
        )

我们可以使用 plot_2d_clusters() 函数可视化数据样本,如下所示:

from matplotlib import pyplot as plt
fig, ax = plt.subplots(1, 1, figsize=(10, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, ax)
plt.show()


如上图所示,每个数据样本都根据其给定的标签进行标记。 接下来,我们使用 K-means 算法来查看能否将数据样本正确聚类,需要注意的是,在算法训练过程中我们并不会使用这些标签。

3.4 使用 K-means 聚类

在没有任何标签的情况下,我们如何判断 K 值大小,即 n_clusters 超参数?答案是,我们并不能准确判断。我们首先使用随机数字作为 K 值,之后,我们将学习如何为 n_clusters 找到最佳值。让首先将其设置为 5,其他超参数保持在默认值,并训练算法:

from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=5)
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
y_pred = kmeans.fit_predict(x)

现在我们已经预测了新标签,我们可以使用 plot_2d_clusters() 函数将我们的预测与原始标签进行比较:

fig, axs = plt.subplots(1, 2, figsize=(14, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, axs[0])
plot_2d_clusters(x, y_pred, axs[1])
axs[0].set_title(f'Actuals: axs[0].get_title()')
axs[1].set_title(f'KMeans: axs[1].get_title()')
plt.show()

生成的簇显示如下:


K 设置为 5 后,其中有一个簇被分成两个。簇的标签数值是任意的,算法将标签为 2 的原始簇标记为 3,但这些值并没有任何意义,只要簇中具有相同的成员就足以表明算法成功运行。在评估聚类算法时,我们也并不会考虑这这些标签值。
但我们如何确定 K 的值?目前,只能使用不同数量的簇多次运行算法并选择效果最好的一个。接下来,我们我们循环 3 个不同的 n_clusters 值。我们还可以获取最终的质心,这些质心是在算法收敛后计算得到的,使用这些质心可以了解算法如何将每个数据点分配到相应簇中,在代码的最后,我们使用三角形标记簇的质心:

n_clusters_options = [4, 5, 6]
fig, axs = plt.subplots(1, len(n_clusters_options), figsize=(16, 6))
for i, n_clusters in enumerate(n_clusters_options):
    x, y = df_blobs[['x1', 'x2']], df_blobs['y']
    kmeans = KMeans(n_clusters=n_clusters)
    y_pred = kmeans.fit_predict(x)
    plot_2d_clusters(x, y_pred, axs[i])
    axs[i].plot(
        kmeans.cluster_centers_[:,0], kmeans.cluster_centers_[:,1], 'k^', ms=12, alpha=0.75
    )
plt.show()

以下是使用不同 K 值得到的 K-means 聚类效果:


通过上图可以看出,选择 4 个簇是合适的选择,但需要注意的是,我们可以直观的查看 2D 数据点,但如果我们的数据样本包含两个以上的特征,那么可视化就会变得及其困难。因此,在下一节中,我们将介绍轮廓系数来选择最佳数量的聚类簇,而无需可视化聚类效果后再进行选择。

3.5 轮廓系数

轮廓系数 (silhouette score) 是衡量一个样本与其自己簇中的样本和其他簇中的样本相比有多相似的度量。对于每个样本,我们首先计算该样本与同一簇中所有其他样本之间的平均距离,称这个平均距离为 A。然后,计算同一样本与最近簇中所有其他样本之间的平均距离,将此平均距离称为 B。那么,轮廓系数的计算公式可以定义如下:
s i l h o u e t t e = B − A m a x ( A , B ) silhouette=\\frac B-A max(A,B) silhouette=max(A,B)BA
我们可以循环遍历多个 n_clusters 值,并在每次迭代后存储轮廓系数用于查找最合适的聚类簇数量 Ksilhouette_score 有两个参数,分别为数据点 (x) 和预测的聚类标签 (y_pred):

from sklearn.metrics import silhouette_score
n_clusters_options = [2, 3, 4, 5, 6, 7, 8]
silhouette_scores = []
for i, n_clusters in enumerate(n_clusters_options):
    x, y = df_blobs[['x1', 'x2']], df_blobs['y']
    kmeans = KMeans(n_clusters=n_clusters)
    y_pred = kmeans.fit_predict(x)
    silhouette_scores.append(silhouette_score(x, y_pred))

然后,我们可以选择轮廓系数最高的 n_clusters 值,我们将计算出的轮廓系数放入 DataFrame 中,并使用条形图进行比较:

fig, ax = plt.subplots(1, 1, figsize=(12, 6), sharey=False)
pd.DataFrame(
    
        'n_clusters': n_clusters_options,
        'silhouette_score': silhouette_scores,
    
).set_index('n_clusters').plot(
    title='KMeans: Silhouette Score vs # Clusters chosen',
    kind='bar',
    ax=ax
)
plt.show()

得到的结果如下图所示,证实了 4 个是簇数是最佳选择:


除了选择簇的数量外,算法的初始质心选择也会影响其准确性。错误的初始选择可能会导致 K-means 算法收敛于局部最小值。在下一节中,我们将研究初始质心对算法性能的影响。

3.6 初始质心的选择

默认情况下,Scikit-learn 中实现的 K-means 算法会选择彼此相距较远的随机初始质心,它还尝试使用多个初始质心并选择能提供最佳结果的质心。我们也可以手动设置初始质心,接下里,我们将比较两个不同的初始质心设置以查看它们对最终结果的影响:

import numpy as np
initial_centroid_options = np.array([
    [(-10,5), (0, 5), (10, 0), (-10, 0)],
    [(0,0), (0.1, 0.1), (0, 0), (0.1, 0.1)],
])
fig, axs = plt.subplots(1, 2, figsize=(16, 6))
for i, initial_centroids in enumerate(initial_centroid_options):
    x, y = df_blobs[['x1', 'x2']], df_blobs['y']
    kmeans = KMeans(
        init=initial_centroids, max_iter=500, n_clusters=4
    )
    y_pred = kmeans.fit_predict(x)
    plot_2d_clusters(x, y_pred, axs[i])
    axs[i].plot(
        kmeans.cluster_centers_[:,0], kmeans.cluster_centers_[:,1], 'k^'
    )
plt.show()

下图显示了算法收敛后的结果簇:


显然,第一个初始化的质心能够获得更好的聚类效果,而第二个初始质心产生的聚类效果较差,因此,我们必须考虑算法的初始化,因为它的结果是不确定。
K-means 算法相比,层次聚类是一种结果确定性的算法,它不依赖任何初始选择,我们将下一节介绍层次聚类。

4. 层次聚类

4.1 层次聚类原理

K-means 聚类算法中,我们从一开始就需要指定 K 个簇,在每次迭代中,一些样本可能会改变他们的所属的簇,一些簇也可能会改变它们的质心,但最终,簇的数量是从一开始就定义好的。
而在层次聚类 (agglomerative clustering) 中,一开始并不指定簇数量,一开始,每个样本都属于自己的簇。也就是说,开始时有多少数据样本就有多少个簇。然后,我们找到两个最接近的样本并将它们聚合到一个簇中。之后,我们通过组合下一个最近的两个样本、两个簇或一个样本和一个簇来继续迭代。每次迭代后,簇的数量都会递减一个,直到我们将所有的样本都加入一个簇中,将所有样本放在一个簇中并非我们的目标。因此,我们可以选择在任何迭代中停止算法,具体取决于我们需要的最终簇数量。
了解了层次聚类的原理后,我们学习如何使用层次聚类算法。要让聚类算法终止于聚集任务,我们需要通过 n_clusters 超参数设定的最终簇数量,我们还要研究如何计算簇间的距离。我们首先将簇数量设置为 4 ,使用层次聚类算法:

from sklearn.cluster import AgglomerativeClustering
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
agglo = AgglomerativeClustering(n_clusters=4)
y_pred = agglo.fit_predict(x)

由于我们将簇数设置为 4,因此预测的 y_pred 具有 4 个不同的值。但实质上,层次聚类算法并没有在簇数为 4 时停止,它继续聚合簇,并使用内部树结构跟踪哪些簇是属于更大簇的成员。当我们指定 4 个簇时,它会重新访问这个内部树并相应地推断簇标签。在下一节中,我们将学习如何访问算法的内部层次结构并跟踪层次聚类算法所构建的树。

4.2 追踪层次聚类的孩子

我们已经知道,每个样本或簇是另一个簇的成员,而另一个簇又是更大簇的成员,依此类推。此层次结构存储在算法的 children_ 属性中,此属性保存为列表形式。列表的成员数为数据样本数量减 1,每个列表成员由两个数字组成。我们可以列出 children_ 属性的最后五个成员,如下所示:

print(agglo.children_[-5:])

打印出的列表最后五个成员如下:

[[186 190]
 [193 194]
 [184 195]
 [191 196]
 [192 197]]

列表的最后一个元素是树的根,它有两个孩子,192197,这些数字是这个根节点的孩子的 ID。大于或等于数据样本数的 ID 为聚类 ID,而小于样本数的 ID 指的是单个样本,簇 ID 中减去数据样本的数量可以得到子列表的位置,在子列表中可以获取该簇的成员。接下来,编写递归函数 get_children,该函数接受子节点列表和数据样本数,并返回所有簇及其成员的嵌套树,如下所示:

def get_children(node, n_samples):
    if node[0] >= n_samples:
        child_cluster_id = node[0] - n_samples
        left = get_children(
            agglo.children_[child_cluster_id],
            n_samples
        )
    else:
        left = node[0]
    if node[1] >= n_samples:
        child_cluster_id = node[1] - n_samples
        right = get_children(
            agglo.children_[child_cluster_id],
            n_samples
        )
    else:
        right = node[1]
    return [left, right]

调用 get_children 函数:

root = agglo.children_[-1]
n_samples = df_blobs.shape[0]
tree = get_children(root, n_samples)

此时,tree[0]tree[1] 包含树左侧和右侧样本的 ID,它们是两个最大簇的成员。如果我们的目标是将样本分成四个簇,我们可以使用 tree[0][0]tree[0][1]tree[1][0]tree[1][1] 获取,以 tree[0][0] 为例,输出其值如下:

[[3, 37], [35, [20, 46]]]

这种嵌套方式可以获取我们所需要的簇数,并相应地检索簇中的成员。我们也可以使用以下代码展平列表,以便于查看:

def flatten(sub_tree, flat_list):
    if type(sub_tree) is not list:
        flat_list.append(sub_tree)
    else:
        r, l = sub_tree
        flatten(r, flat_list)
        flatten(l, flat_list)

调用 flatten 函数,可以获得 tree[0][0] 的成员,如下所示:

flat_list = []
flatten(tree[0][0], flat_list)
print(flat_list)
# 输出
# [3, 37, 35, 20, 46]

接下来,我们仿照 fit_predict 的输出构建我们自己的预测标签。我们将所构建的树的不同分支成员分配不同标签,我们将预测标签命名为 y_pred_dash

n_samples = x.shape[0]
y_pred_dash = np.zeros(n_samples)
for i, j, label in [(0,0,0), (0,1,1), (1,0,2), (1,1,3)]:
    flat_list = []
    flatten(tree[i][j], flat_list)
    for sample_index in flat_list:
        y_pred_dash[sample_index] = label

需要注意的是,y_pred_dash 中的值应与上一节中 y_pred 中的值匹配。但由于,我们对标签的选择是任意的,因此,考虑到标签名称可能不同,我们需要更完善的评价标准来比较两个预测。下一节中,我们将介绍调整兰德系数评价聚类性能。

4.3 调整兰德系数

调整兰德系数 (adjusted Rand index) 在分类方面与准确率类似。它计算两个标签列表之间的一致性水平,但它解决了准确率的缺陷:

  • 调整兰德系数并不关心实际的标签,只要需要两个簇中的成员相同
  • 与分类不同,我们最终可能会拥有较多簇,在将每个样本作为一个簇的极端情况下,如果我们忽略标签的名称,任何两个簇列表都会彼此一致。因此,调整兰特系数还需要降低了两个簇偶然达成一致的可能性。

Scikit-learn 中,我们可以使用 adjusted_rand_score 调用调整兰德系数比较 y_predy_pred_dash。调整兰德系数是对称的,所以在调用此函数时,参数顺序并不重要:

from sklearn.metrics import adjusted_rand_score
print(adjusted_rand_score(y_pred, y_pred_dash))

每次迭代中,该算法结合了两个最接近的簇,很容易计算两个样本之间的距离,我们已经使用了不同的距离度量,例如欧几里得距离和曼哈顿距离。然而,簇并不是一个点,我们又该如何测量距离?是使用簇的质心还是使用在每个簇中选择一个特定的数据点来计算距离?这些选择都可以使用 linkage 超参数来指定。

4.4 选择簇的超参数 linkage

默认情况下,使用欧几里得距离衡量哪些簇对之间彼此最接近,可以使用 affinity 超参数更改此默认指标,我们可以使用不同的距离度量指标,如余弦距离或曼哈顿距离。在计算两个簇之间的距离时,由于一个簇中通常包含多个数据样本,linkage 参数决定了如何测量距离。参数 linkage 值为 complete 时,使用两个簇中所有数据点之间的最大距离;而当 linkage 值为 single 时,使用最小距离;当 linkage 值为 average 时,取所有样本对之间所有距离的平均值。当 linkage 值为 ward 时,如果两个簇中每个数据点与合并簇的质心之间的平均欧几里德距离最小,则合并两个簇,只有欧几里得距离可以与 ward linkage 一起使用。
为了能够比较上述不同 linkage 参数,我们创建一个新数据集,数据点以两个同心圆的形式排列。 make_circles 函数指定要生成的样本数 (n_samples)、两个圆的距离 (factor) 以及数据的所含噪声的数量 (noise):

from sklearn.datasets import make_circles
x, y = make_circles(n_samples=150, factor=0.5, noise=0.05)
df_circles = pd.DataFrame('x1': x[:,0], 'x2': x[:,1], 'y': y)

首先,我们使用层次聚类算法对新数据样本进行聚类,并使用不同的 linkage 参数值,同时使用参数 affinity 指定曼哈顿距离 manhattan

from sklearn.cluster import AgglomerativeClustering
linkage_options = ['complete', 'single']
fig, axs = plt.subplots(1, len(linkage_options) + 1, figsize=(14, 6))
x, y = df_circles[['x1', 'x2']], df_circles['y']
plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'axs[0].get_title()\\nActuals')
for i, linkage in enumerate(linkage_options, 1):
    y_pred = AgglomerativeClustering(n_clusters=2, affinity='manhattan', linkage=linkage).fit_predict(x)
    plot_2d_clusters(x, y_pred, axs[i])
    axs[i].set_title(干货分享 一文简述多种无监督聚类算法的Python实现

一文开启深度学习之旅

用一文简述多种无监督聚类算法的Python实现

教程 | 一文简述多种无监督聚类算法的Python实现

算法笔记 | 一文读懂K-means聚类算法

一文弄懂什么是对比学习(Contrastive Learning)