聚类

聚类

聚类方法在于寻找数据中的集群(clusters),在同一个集群中的数据在某些方面更加相似。这同时也是对数据的一种压缩,因为我们使用了更小的集合—集群—来表示更大的数据。也可以理解为寻找有用特征的一种方式,如果一系列数据可以很好地被集群中心点表示,那么很有可能我们发现了更好的特征。

为了获得聚类算法,我们先要明确数据之间的相似度的概念,一般用数据之间的距离来表示。假设数据分布于特征空间 \(\mathcal X=\mathbb R^D\),那么我们需要一个函数来计算两个数据在该空间的距离,一般使用欧氏距离 \(\vert\vert\pmb x_1-\pmb x_2\vert\vert_2=\sqrt{\sum_{i=1}^D(x_1^{(i)}-x_2^{(i)})^2}\),但对于某些特殊的数据也使用其他距离函数。

K-均值聚类

K-均值聚类是一个迭代算法,简单并且可解释。将所有数据用 k 个类(cluster,或者称为集群)表示,这 k 个集群的中心点用所有属于这个集群的数据的均值表示,这也是为什么它被命名为均值聚类。

推导K-均值算法

\(\lbrace{\pmb\mu_i}\rbrace_{i=1}^k\) 为 k 个中心点,\(\lbrace r_i\rbrace_{i=1}^n\) 为 n 个数据属于哪些类,将损失函数设为所有样本与其所属类的中心的欧氏距离之和。

\[\begin{align*} J(\lbrace\pmb x_i\rbrace_{i=1}^n;\lbrace\pmb\mu_i\rbrace_{i=1}^n,\lbrace r_i\rbrace_{i=1}^n)&=\sum_{i=1}^n\vert\vert\pmb x_i-\pmb\mu_{r_i}\vert\vert^2_2\\ &=\sum_{i=1}^n(\pmb x_i-\pmb\mu_{r_i})^T(\pmb x_i-\pmb\mu_{r_i})\\ \hat r_i&=\underset{r}{\arg\min}\vert\vert\pmb x_i-\pmb\mu_r\vert\vert^2_2\\ \nabla_{\pmb\mu_j}J&=\sum_{i=1}^n\nabla_{\pmb\mu_j}(\pmb x_i-\pmb\mu_{r_i})^T(\pmb x_i-\pmb\mu_{r_i})\\ &=2\sum_{i=1}^nI\lbrace j=r_i\rbrace(\pmb\mu_j-\pmb x_i)=0\\ \hat{\pmb\mu}_j&={\sum_{i=1}^nI\lbrace j=r_i\rbrace\pmb x_i\over\sum_{i=1}^nI\lbrace j=r_i\rbrace} \end{align*} \]

其中 \(I\) 为指示器函数,\(I\lbrace A\rbrace=\begin{cases}1&\text{if }A\\0&\text{else}\end{cases}\)

使得损失函数 \(J(\lbrace\pmb x_i\rbrace_{i=1}^n;\lbrace\pmb\mu_i\rbrace_{i=1}^n,\lbrace r_i\rbrace_{i=1}^n)\) 获得极小值的结果由很多种,这说明如果使用随机初始化每个样本的集群,那么每次获得的聚类结果都很可能不一样。

实现

  1. 初始化 k 个不同的中心点 \(\pmb\mu^{(1)},\pmb\mu^{(2)},\dots,\pmb\mu^{(k)}\)

  2. 重复迭代直到中心点不在变动

    1. 每个样本分配到最近的中心点 \(\pmb\mu^{(i)}\),表明它属于类 \(i\)
    2. 每个中心点 \(\pmb\mu^{(i)}\) 被更新为类 \(i\) 中所有样本的均值。
def k_means(a:np.ndarray, k:int, clusters:np.ndarray = None):
    """take an NxD numpy matrix as input"""
    n, d = a.shape
    if n <= k:
        return np.arange(n), a.copy()
    if clusters is None:
        clusters = np.empty((k, d))
    responses = np.random.randint(k, size=(n, ))

    while True:
        for i in range(k):
            choose = responses == i
            if choose.any():
                clusters[i] = np.average(a[responses == i], axis=0)
        new_responses = (
            (a.reshape(1, *a.shape).transpose(1, 0, 2) - clusters) ** 2
        ).sum(axis=-1).argmin(axis=-1)
        if (new_responses == responses).all():
            break
        responses = new_responses
    return responses, clusters

选择超参数 k

从压缩的角度上看,k 的选择好比压缩率和保真度之间的权衡(trade-off),更高的 k 对原数据的保留程度更高,但是中心点数目更多。

一种简单的方法是通过采用不同的 k 对数据进行聚类之后,选择两项表现综合结果最好的 k 值。如果你想获得更具有代表性的特征的话,那么建议使用更大的 k,如果想要通过聚类观察数据的话应该使用较小的 k。

通过损失函数的变化率选择最好的 k 值

不断增大 k,直到增大 k 后减少的损失降低到某一阈值以下后选择这个 k。即选择满足损失函数 \(J_k\le J_{k+1}+threshold\)

通过集群分散度(within-cluster dispersion)选择最好的 k 值

\(D_c\) 为集群 \(c\) 的分散度,是集群中所有样本对的欧氏距离;\(W_k\) 为所有集群的归一化分散度。

\[\begin{align*} D_c&=\sum_{i=1}^n\sum_{j=1}^nI\lbrace r_i=r_j=c\rbrace\vert\vert\pmb x_i-\pmb x_j\vert\vert^2\\ W_k&=\sum_{c=1}^k{D_c\over2\sum_{i=1}^nI\lbrace r_i=c\rbrace}=\sum_{c=1}^k{\sum_{i=1}^n\sum_{j=1}^nI\lbrace r_i=r_j=c\rbrace\vert\vert\pmb x_i-\pmb x_j\vert\vert^2\over2\sum_{i=1}^nI\lbrace r_i=c\rbrace} \end{align*} \]

系数 2 是因为 \(D_c\) 中所有样本对计算了两次。

这一数值体现了每个集群的离散程度。一般随着 k 的增加这一数值会变小,聚类效果越好这一数值也会减小。

但是为了真正地衡量聚类效果,我们需要比较聚类之前的数据和聚类之后的数据之间的差异,即计算间隙统计(gap statistic)

\[\begin{align*} \mathrm{Gap}_N(k)=\mathbb E_{\mathrm{null}}[\ln W_{k,\mathrm{null}}]-\ln W_k \end{align*} \]

选择最佳的 \(k^*\) 满足 \(k^*=\underset{k}{\arg\max}\,\mathrm{Gap}_N(k)\)。其中,\(\mathbb E_{\mathrm{null}}\) 为我们寻找的一个空分布(null distribution,或称为参考分布 reference distribution),这个空分布是用来生成参考数据(reference data)用的。空分布的选择就是当数据中实际上并不存在“不同类”时候选择的分布。

换句话说,我们假设数据中有不同的类,比如水仙花数据中有茎叶长度的数据,通过这一数据有可能分出几个类,为了选择最好的类的个数 k,那么将茎和叶的长度分别归一化为均值 0 方差 1 的数据后,选择空分布为标准二元高斯分布 \(\mathcal N(\pmb0,\pmb I_2)\)。于是前者是隐含类的差异的数据;后者没有(或说只有 1 个类),因为它仅仅是普通的高斯分布。通常会使用多元高斯分布或者多元平均分布,更好的空分布选择请参考 Reference。

image-20220718204948298

图中蓝色数据为要求的样本的数据,可以观察到大约包含三个类;红色数据为选择的空分布产生的数据,没有明显的类的差异。

为了直观化这一方法的合理性,考虑如果数据中真的有 K 个类的话,那么可以想见:\(W_k\) 将在 \(k\le K\) 时下降较快,而在 \(k>K\) 之后下降较慢。对于我们寻找到的空分布则 \(W_{k,\mathrm{null}}\) 会以一个比较平稳的速率关于 \(k\) 下降。那么可以通过寻找 \(\mathrm{Gap}_N(k)\) 的最大值,即两者之间的差异来实现寻找最合适的 \(k\)

选择完空分布之后,从该空分布中选择 \(N\) 个数据再进行聚类得到相应的分散度 \(W_{k,\mathrm{null}}\),求其期望则得到 \(\mathbb E_{\mathrm{null}}[\ln W_{k,\mathrm{null}}]\)。但由于这一期望实际上是不可求得的,可以通过几次抽样和一个调整值来近似计算。用蒙特卡罗方法从空分布中获得数据,令 \(B\) 为抽样次数,\(\mathrm{sd}\) 为结果的标准差,那么获得一个误差 \(s_k\),最终结果需要满足

\[\begin{align*} \hat{\mathbb E}_{\mathrm{null}}[\ln W_{k,\mathrm{null}}]&=\frac1B\sum_{b=1}^B\ln W_{k,\mathrm{null},b}\\ \mathrm{sd}(k)&=\sqrt{\frac1B\sum_{b=1}^B\left(\ln W_{k,\mathrm{null},b}-\hat{\mathbb E}_{\mathrm{null}}[\ln W_{k,\mathrm{null}}]\right)^2}\\ s_k&=\sqrt{{1+B\over B}}\mathrm{sd}(k)\\ \mathrm{Gap}_N(k)&\ge\mathrm{Gap}_N(k+1)-s_{k+1} \end{align*} \]

K-均值聚类++算法

这一算法相比标准得的 k-均值聚类可以获得更好的性能

  1. 在样本中随机选择一个中心点;
  2. 选择离现有中心点最远的点作为新的中心点,直到获得 k 个中心点;
  3. 使用标准的 k-均值聚类处理这 k 个中心点。
def k_means_pp(a:np.ndarray, k:int):
    """take an NxD numpy matrix as input"""
    n, d = a.shape
    clusters = np.empty((k, d))
    distances = np.empty((n, ))

    clusters[0] = a[np.random.randint(n, size=(1, ))]
    for i in range(1, k):
        distances = (
            (a - clusters[:i].reshape(1, i, d).transpose(1, 0, 2)) ** 2
        ).sum(axis=-1).min(axis=0)
        clusters[i] = a[distances.argmax()]
    return k_means(a, k, clusters)

从直观上,这一算法使得每个中心点都被初始化得比较分离。

K-中心点算法

def k_medoids(a:np.ndarray, k:int):
    """take an NxD numpy matrix as input"""
    n, d = a.shape
    responses = np.random.randint(k, size=(n, ))
    
    while True:
        for i in range(k):
            chosen = a[responses == i]
            distances = np.abs(
                chosen.reshape(1, *chosen.shape).transpose(1, 0, 2) - chosen
            ).sum(axis=(1, 2))
            clusters[i] = chosen[np.argmin(distances)]
        new_responses = np.abs(
            a.reshape(1, *a.shape).transpose(1, 0, 2) - clusters
        ).sum(axis=-1).argmin(axis=-1)
        if (new_responses == responses).all():
            return responses, clusters
        responses = new_responses

和均值算法的不同在于 for i in range(k) 这一段(更新中心点),主要是中心点的选择方式。

Reference

David Arthur and Sergei Vassilvitskii. k-means++: The advantages of careful seeding. In Proceed-
ings of the Eighteenth Annual ACM-SIAM Symposium on Discrete Algorithms
, pages 1027–1035.
Society for Industrial and Applied Mathematics, 2007.

Robert Tibshirani, Guenther Walther and Trevor Hastie. Estimating the number of clusters in a data set via the gap statistic

Finding the K in K-Means Clustering

层级聚类

前述的聚类方法依赖于超参数 \(k\) 和初始化的 \(k\) 个中心点,即使是优化了初始化的++方法也仍然依赖于超参数,这就为算法带来了不稳定性。层级聚类方法中的凝聚聚类就避免了这两个不稳定因素。

凝聚聚类

层级凝聚聚类没有固定的中心点数量 \(k\),而是用数据构自底向上地建出一个树结构。

  1. 首先将所有数据看作不同的(叶)节点;
  2. 每次选择两个最近的节点合成为一个新的节点,等同于用两个子节点作为左子树和右子树构建新节点的过程;
  3. 重复以上过程直到根节点。

算法的关键是看距离 dist 和新的节点 union 是如何计算的。

class Node:

    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right
        self.count = (left.count + right.count
                      if left and right else 1)

    def __repr__(self):
        return f'Node({self.count}): {repr(self.value)}'

    __str__ = __repr__
    
    def __eq__(self, other):
        return (
            self.count == other.count and
            (self.value == other.value).all() and
            self.left == other.left and
            self.right == other.right
        )

def agglomerative_clustering(a):    

    def update(arr, union, p1, p2):
        arr[p1] = union
        if p2 != (last := arr.shape[0] - 1):
            arr[p2] = arr[last]
        return arr[:last]
    
    a = a.astype(np.float64)
    nodes = np.apply_along_axis(Node, 1, a)
    
    while len(nodes) != 1:
        # calculate distances
        min_dist = np.inf
        for i in reversed(range(1, a.shape[0])):
            dist = ((a[i] - a[:i]) ** 2).sum(axis=-1)
            min_arg = dist.argmin()
            if (d := dist[min_arg]) < min_dist:
                min_dist = d
                min_pos = min_arg, i

        # get nearest two and merge
        p1, p2 = pos = list(min_pos)

        union_datum = np.average(a[pos],
                                 weights=[n.count for n in nodes[pos]],
                                 axis=0)
        union_node = Node(union_datum, nodes[p1], nodes[p2])
        
        a = update(a, union_datum, p1, p2)
        nodes = update(nodes, union_node, p1, p2)
    
    return nodes[0]
    

这里 dist 使用中心点之间的欧氏距离,union 则使用了按中心点所代表的数据量加权取平均。

提取出这两个方法,得到更加普遍的算法

def agglomerative_clustering(a):
    
    def distance(n1, n2):
        return ((n1.value - n2.value) ** 2).sum()

    def update(nodes, pos):
        p1, p2 = pos
        nearest_two = nodes[[p1, p2]]
        value = np.average([n.value for n in nearest_two],
                           weights=[n.count for n in nearest_two],
                           axis=0)
        nodes[p1] = Node(value, nearest_two[0], nearest_two[1])
        if p2 != (last := nodes.size - 1):
            nodes[p2] = nodes[last]

        return nodes[:last]
    
    nodes = np.apply_along_axis(Node, 1, a)
    
    while len(nodes) != 1:
        min_dist = np.inf
        for i in reversed(range(1, nodes.size)):
            for j in reversed(range(i)):
                dist = distance(nodes[i], nodes[j])
                if dist < min_dist:
                    min_dist = dist
                    min_pos = j, i

        nodes = update(nodes, min_pos)
        print(nodes.size)
    
    return nodes[0]

计算距离

使用中心点之间的欧氏距离和中心点所代表的数据量来计算 dist, union 是一种比较直觉但粗糙的方法。

如果每个节点不是加权取平均获得新的中心点,而是将所有子节点的数据进行保存,可以获得其他新的距离计算方法。包括 single_link, complete_linkage, average_linkage, centroid。此时不必保持节点的左右子树了。

linkage

class Node:
    def __init__(self, value):
        self.value = value

def update(nodes, pos):
    p1, p2 = pos
    nodes[p1].value = np.vstack((
        nodes[p1].value,
        nodes[p2].value
    ))
    if p2 != (last := nodes.size - 1):
        nodes[p2] = nodes[last]

    return nodes[:last]


def single_link(n1, n2):
    v1, v2 = n1.value, n2.value
    v2 = v2.reshape(1, *v2.shape).transpose(1, 0, 2)
    dist = np.sqrt(
        ((v1 - v2) ** 2).sum(axis=-1)
    ).min()
    return dist

def complete_linkage(n1, n2):
    v1, v2 = n1.value, n2.value
    v2 = v2.reshape(1, *v2.shape).transpose(1, 0, 2)
    dist = np.sqrt(
        ((v1 - v2) ** 2).sum(axis=-1)
    ).max()
    return dist

def average_linkage(n1, n2):
    v1, v2 = n1.value, n2.value
    n = v1.shape[0] + v2.shape[0]
    v2 = v2.reshape(1, *v2.shape).transpose(1, 0, 2)
    dist = np.sqrt(
        ((v1 - v2) ** 2).sum(axis=-1)
    ).sum() / n
    return dist

def centroid(n1, n2):
    v1 = np.average(n1.value, axis=0)
    v2 = np.average(n2.value, axis=0)
    dist = np.sqrt(((v1 - v2) ** 2).sum())
    return dist

这些方法即使计算距离的方法,又是选择新的中心点的方法,因为节点的更新只是将两个子节点的数据进行结合。其中 single_linkage 选择两个类中最近的两个点,意味着边缘比较接近的两个类更容易融合为新的类;complete_linkage 选择最远的两个点,聚集在一起的类会比较容易融合(欧氏距离下表现为超球体);而其余两个方法都是更加折衷的。

single_linkage 选择边缘的、局部的数据,因此对于一些分布诡吊的数据会更适应。

single linkage and complete linkage

complete_linkage 看重整体的距离,因此对于形状普通的数据更适应,就好像有一个超球体在衡量两个类的聚集程度(欧氏距离下)。

分裂聚类

层次聚类方法中的分裂聚类与凝聚聚类相反,采用的是自顶向下的过程,每层使用一般的平面聚类方法(比如 K-均值聚类),也就是对已经分类的数据再一次分类。因此具有平面聚类方法同样的不稳定性。

def divisive_cluster(a, flat_cluster, k):
    def sub_divide(sub_a):
        r, c = flat_cluster(sub_a, k)
        responses = r.reshape(-1, 1).tolist()
        clusters = [[cluster] for cluster in c]
        for i in range(k):
            choose = r == i
            chosen = sub_a[choose]
            if chosen.shape[0] not in [0, 1]:
                sub_r, sub_c = sub_divide(chosen)
                for pos, sub_responses in zip(np.argwhere(choose).ravel(),
                                              sub_r):
                    responses[pos] += sub_responses
                clusters[i].append(sub_c)
        return responses, clusters
    return sub_divide(a)

数据的分类自然也是(自顶向下)分层的:responses 是每个数据的分类,responses[i][j] 为第 i 个数据的第 j 级分类;clusters 为每个分类的中心点,clusters[k][j] 为第 k 个中心点。获得第 index 个数据的中心点的函数为

def response_clusters(index, responses, clusters):
    result = []
    *r, last = responses[index]
    for i in r:
        c, clusters = clusters[i]
        result.append(c)
    result.append(clusters[last][0])
    return result
posted @ 2022-10-27 14:48  Violeshnv  阅读(37)  评论(0编辑  收藏  举报