【Python機器學習實戰】聚類演算法(2)——層次聚類(HAC)和DBSCAN

Uniqe發表於2021-12-16

層次聚類和DBSCAN

  前面說到K-means聚類演算法,K-Means聚類是一種分散性聚類演算法,本節主要是基於資料結構的聚類演算法——層次聚類和基於密度的聚類演算法——DBSCAN兩種演算法。

1.層次聚類

  下面這樣的結構應該比較常見,這就是一種層次聚類的樹結構,層次聚類是通過計算不同類別點的相似度建立一顆有層次的樹結構,在這顆樹中,樹的底層是原始資料點,頂層是一個聚類的根節點。

  建立這樣一棵樹的方法有自底向上和自頂向下兩種方式。

  下面介紹一下如何利用自底向上的方式的構造這樣一棵樹:

  為了便於說明,假設我們有5條資料,對這5條資料構造一棵這樣的樹,如下是5條資料:

  第一步,計算兩兩樣本之間相似度,然後找到最相似兩條資料(假設1、2兩個最相似),然後將其merge起來,成為1條資料:

  現在資料還剩4條,然後同樣計算兩兩之間的相似度,找出最相似的兩條資料(假設前兩條最相似),然後再merge起來:

  現在還剩餘3條資料,然後繼續重複上面的步驟,假設後面兩條資料最相似,那麼:

  然後還剩餘兩條資料,再把這兩條資料merge起來,最終完成一個樹的構建:

  上述就是自底向上聚類樹的構建過程,自頂向下的過程與之相似,只不過初始資料是一個類別,不斷分裂出距離最遠的那個點,知道所有的點都成為葉子結點。

  那麼我們如何根據這棵樹進行聚類呢?

  我們從樹的中間部分切一刀,像下面這樣:

  然後葉子節點被分成兩個類別,也可以像下面這樣切:

  那麼樣本集就被分成3個類別。這個切割的線是由一個閾值“threshold”來決定切在什麼位置,而這個閾值是需要預先給定的

  但在實做過程中,往往不需要先構建一棵樹,再去進行切分,注意看上面切分,切完後,所剩餘的節點數量就是類別個數。

  那麼在建樹的過程中,當達到所指定的類別後,則就可以停止樹的建立了

  下面看一下HAC(自底向上)的實現過程:

import math
import numpy as np


def euler_distance(point1, point2):
    distance = 0.0
    for a, b in zip(point1, point2):
        distance += math.pow(a-b, 2)
    return math.sqrt(distance)


# 定義聚類樹的節點
class ClusterNode:
    def __init__(self, vec, left=None, right=None, distance=-1, id=None, count=1):
        """
        vec: 儲存兩個資料merge後新的中心
        left: 左節點
        right: 右節點
        distance: 兩個節點的距離
        id: 儲存哪個節點是計算過的
        count: 這個節點的葉子節點個數
        """
        self.vec = vec
        self.left = left
        self.right = right
        self.distance = distance
        self.id = id
        self.count = count


# 層次聚類的類
# 不同於文中所說的先構建樹,再進行切分,而是直接根據所需類別數目,聚到滿足條件的節點數量即停止
# 和k-means一樣,也需要指定類別數量
class Hierarchical:
    def __init__(self, k=1):
        assert k > 0
        self.k = k
        self.labels = None

    def fit(self, x):
        # 初始化節點各位等於資料的個數
        nodes = [ClusterNode(vec=v, id=i) for i, v in enumerate(x)]
        distance = {}
        point_num, feature_num = np.shape(x)
        self.labels = [-1] * point_num
        currentclustid = -1
        while len(nodes) > self.k:
            min_dist = np.inf
            # 當前節點的個數
            nodes_len = len(nodes)
            # 最相似的兩個類別
            closest_part = None
            # 當前節點中兩兩距離計算,找出最近的兩個節點
            for i in range(nodes_len-1):
                for j in range(i+1, nodes_len):
                    # 避免重複計算
                    d_key = (nodes[i].id, nodes[j].id)
                    if d_key not in distance:
                        distance[d_key] = euler_distance(nodes[i].vec, nodes[j].vec)
                    d = distance[d_key]
                    if d < min_dist:
                        min_dist = d
                        closest_part = (i, j)
            part1, part2 = closest_part
            node1, node2 = nodes[part1], nodes[part2]
            # 將兩個節點進行合併,即兩個節點所包含的所有資料的平均值
            new_vec = [(node1.vec[i] * node1.count + node2.vec[i] * node2.count) / (node1.count + node2.count)
                       for i in range(feature_num)]
            new_node = ClusterNode(vec=new_vec, left=node1, right=node2, distance=min_dist, id=currentclustid,
                                   count=node1.count + node2.count)
            currentclustid -= 1
            # 刪掉這最近的兩個節點
            del nodes[part2], nodes[part1]
            # 把新的節點新增進去
            nodes.append(new_node)
        # 樹建立完成,這裡要注意,在示例中是最終凝聚為1個節點,而這裡到達所要指定的類別數目即停止,一個node屬於一個類別
        self.nodes = nodes
        # 給每個node以及node包含的資料打上標籤
        self.calc_label()

    def calc_label(self):
        # 調取聚類結果
        for i, node in enumerate(self.nodes):
            self.leaf_traversal(node, i)

    def leaf_traversal(self, node: ClusterNode, label):
        # 遞迴遍歷葉子結點
        if node.left is None and node.right is None:
            self.labels[node.id] = label
        if node.left:
            self.leaf_traversal(node.left, label)
        if node.right:
            self.leaf_traversal(node.right, label)

  通過讀取sklearn自帶的鳶尾花的資料庫,測試一下:

from sklearn.datasets import load_iris
import matplotlib.pyplot as plt
iris = load_iris()

my = Hierarchical(4)
my.fit(iris.data)
data = iris.data
data_0 = data[np.nonzero(np.array(my.labels) == 0)]
data_1 = data[np.nonzero(np.array(my.labels) == 1)]
data_2 = data[np.nonzero(np.array(my.labels) == 2)]
data_3 = data[np.nonzero(np.array(my.labels) == 3)]
plt.scatter(data_0[:, 0], data_0[:, 1])
plt.scatter(data_1[:, 0], data_1[:, 1])
plt.scatter(data_2[:, 0], data_2[:, 1])
plt.scatter(data_3[:, 0], data_3[:, 1])

print(np.array(my.labels))

from sklearn.cluster import KMeans
km = KMeans(4)
km.fit(iris.data)
print(km.labels_)


data_0_ = data[np.nonzero(np.array(km.labels_) == 0)]
data_1_ = data[np.nonzero(np.array(km.labels_) == 1)]
data_2_ = data[np.nonzero(np.array(km.labels_) == 2)]
data_3_ = data[np.nonzero(np.array(km.labels_) == 3)]
plt.figure()
plt.scatter(data_0_[:, 0], data_0_[:, 1])
plt.scatter(data_1_[:, 0], data_1_[:, 1])
plt.scatter(data_2_[:, 0], data_2_[:, 1])
plt.scatter(data_3_[:, 0], data_3_[:, 1])

  可以看到,兩種結果差不多,但是也有些不同。

  其實sklearn中也有層次聚類演算法,上面是為了更好理解層次聚類的演算法過程,下面利用sklearn庫實現層次聚類演算法:

from sklearn.cluster import AgglomerativeClustering
from sklearn.preprocessing import MinMaxScaler
model = AgglomerativeClustering(n_clusters=4, affinity='euclidean', memory=None, connectivity=None,
                                compute_full_tree='auto', linkage='ward', pooling_func='deprecated')
"""
引數:
    n_cluster: 聚類數目
    affinity: 計算距離的方法,'euclidean'為歐氏距離, 'manhattan'曼哈頓距離, 'cosine'餘弦距離, 'precompute'預先計算的affinity matrix;
    memory: None, 給定一個地址,層次聚類的樹快取在相應的地址中;
    linkage: 層次聚類判斷相似度的方法,有三種:
             'ward': 即single-linkage
             'average': 即average-linkage
             'complete': 即complete-linkage
"""
"""
屬性:
    labels_: 每個資料的分類標籤
    n_leaves_:分層樹的葉節點數量
    n_components:連線圖中連通分量的估計值
    children:一個陣列,給出了每個非節點數量
"""

data_array = np.array(load_iris().data[:50, :])
min_max_scalar = MinMaxScaler()
data_scalar = min_max_scalar.fit_transform(data_array)
model.fit(min_max_scalar)


from scipy.cluster.hierarchy import linkage, dendrogram
plt.figure(figsize=(20, 6))
Z = linkage(data_scalar, method='ward', metric='euclidean')
p = dendrogram(Z, 0)
plt.show()

  有關引數已在上面進行註釋,關於類別間的距離計算,有三種:single-linkage、complete-linkage和average-linkage,一個是以最近距離作為類別間的距離,一個是以最遠距離作為類間距離,還有是以各個樣本距離總的平均值為類間距離。

  程式碼後半部分是生成一個開篇說的那種圖的視覺化方式,限於顯示需要,只取前50個資料,生成的樹的結果如圖所示(這裡並沒有分類,而是一種視覺化的形式):

層次聚類的優缺點:

  優點:

    1、距離的定義比較容易,而且比較自由;

    2、有時可以不用指定所需類別個數,就像前面說的,我們可以通過閾值來進行類的劃分;

    3、可以生成非球形的簇,發現層次間的關係。

  缺點:

    1、在建樹過程中要計算每個樣本間的距離,計算複雜度較高;

    2、演算法對於異常值比較敏感,影響聚類效果;

    3、容易形成鏈狀的簇。

2.DBSCAN

  前面說了層次聚類演算法,其實原理比較簡單,但對於噪聲(異常值)比較敏感,且基於距離的演算法只能發現“類圓形”的簇。

  另一種聚類演算法DBSCAN演算法是一種基於密度的聚類演算法,它能夠克服前面說到的基於距離聚類的缺點,且對噪聲不敏感,它可以發現任意形狀的簇

  DBSCAN的主旨思想是只要一個區域中的點的密度大於一定的閾值,就把它加到與之相近的類別當中去

  那麼究竟是如何做呢,我們首先需要了解與DBSCAN有關的幾個概念:

  先看下面一張圖,結合圖來理解下面幾個概念:

  (1)ε-鄰域:一個物件在半徑為ε內的區域,簡單來說就是在給定一個資料為圓心畫一個半徑為ε的圈;

  (2)核心物件:對於給定的一個數值m,在某個物件的鄰域內,至少包含m個點,則稱之為核心,簡單來說就是某個物件的圈內的資料大於m個,則這個物件就是核心;

  (3)直接密度可達:結合上圖,給定一個物件q,如果這個物件的鄰域內有大於m個點,而另一個物件p又在這個鄰域內,則稱之為p是q的直接密度可達;

  (4)間接密度可達:如下一張圖,p1是q的直接密度可達,而p是p1的直接密度可達,那麼p則是q的密度可達;

   (5)密度相連假設一個物件O,是物件p的密度可達,而q是O的密度可達,那麼p和q則是密度相連的。

  (6):基於密度聚類的簇就是最大的密度相連的所有物件的集合;

  (7)噪聲:不屬於任何簇中的物件稱之為噪聲;

  其實上面的概念看似複雜,這裡也進行了簡化,原先的定義更加比較難理解,但結合當前疫情感染情況,我們可以試著對上面概念進行一個類比和解釋:

  例子:

  假設某個區域突然發現1例感染者,那麼防疫人員就要對這個人軌跡進行溯源,就假設這個人的活動區域就是一個圓,那麼這個圓就稱為這個確診者的鄰域

  然後來過該區域內的所有人員進行核酸篩查,假設發現有3個以上的確診者,就算中高風險地區了,通過篩查發現第一個人的鄰域內有5個確診者,那麼第1個病例這裡稱之為A,就是核心

  由於這5個人都到過這個區域,那麼這5個人的任意一個人都是A的直接密度可達

  這裡注意,直接密度可達是一個不對稱的,可以說這5個其中一個是A的直接密度可達,但不能說第1個病例是這5個的直接密度可達,因為這5個人的活動範圍只是與A有交集,但在其各自的活動範圍內,並不一定都有超過3個確診病例;

  在又篩查出來5個以後,防疫人員又要進一步擴大核酸範圍,那麼需要分別對這另外5個人的活動範圍進行排查;

  經排查發現其中一個確診者,這裡稱之為B,B的活動範圍內有3個陽性,那麼這裡B就也是一個核心,其中一個稱之為C,不在A的鄰域內,那麼C是B的直接密度可達C是A的間接密度可達

  這裡注意,間接密度可達同樣也是不對稱的,同樣的道理,可以說C是A的間接密度可達,不能說A是C的間接密度可達;

  接下來,防疫人員又要對C的鄰域進行排查,發現C的活動範圍內也有3個確診者,那麼C也是一個核心,而這3個確診者當中,有一個沒有來過B的活動範圍,稱之為D;

  那麼,D是C的直接密度可達,D是B的間接密度可達,D是A的密度相連。密度相連是一個對稱的概念,因為二者都與C有關;

  然後,上面的這些人的活動區域連線起來,則就構成了整個中高風險地區,也就是一個簇;

  假設在另一個區域又突然發現一名確診者,經排查後,如果這個確診者也作為核心向周圍擴散發現很多確診者,那麼這就形成了一個新的簇

  如果其所在區除了他自己,沒有別的確診病例了,因此,這個就是屬於噪聲點。

  通過上面的舉例,應該可以很好理解有關密度聚類的幾個概念了,而且能夠為後面演算法的理解更容易。

  那麼根據上面簇的概念和所舉的例子,有關DBSCAN的演算法過程就比較簡單理解了:

  下面再舉一個實際的例子,來看一下DNSCAN的演算法處理過程,例子來源於水印。

  假設有一組資料,設定MinPts=3,ε=3,資料如圖所示:

   第一步:

    首先掃描點p1(1,2),以p1為中心:

    (1)p1的鄰域內有點{p1,p2,p3,p13},因此p1是核心點;

    (2)以p1為核心點,建立簇C1;找出所有與p1的密度可達的點;

    (3)p2的鄰域內為{p1,p2,p3,p4,p13},因此p4屬於p1的密度可達,p4屬於簇C1;

    (4)p3的鄰域內為{p1,p2,p3,p4,p13},這些點都已屬於簇C1,繼續;

    (5)p4的鄰域內為{p3,p4,p13},這些點也都屬於簇C1,繼續;

    (6)p13的鄰域內為{p2,p3,p4,p13},也都處理過了

    至此,以p1為核心的密度可達的資料點搜尋完畢,得到簇C1,包含{p1,p2,p3,p13,p4}

  第二步:

    繼續掃描點,到p5,以p5為中心:

    (1)計算p5鄰域內的點{p5,p6,p7,p8},因此p5也是核心點;

    (2)以p5為核心點 ,建立簇C2,找出所有與p5的密度可達的點;

    (3)同第一步中一樣,依次掃描p6、p7、p8;

    得到以p5為核心點的簇C2,包含的點為{p5,p6,p7,p8}。

  第三步:

    繼續掃描點,到點p9,以p9為中心:

    (1)p9的鄰域內的點為{p9},所以p9b不是核心點,進行下一步

  第四步:

    繼續掃描點,到點p10,以p10為中心:

     (1)p10的領域內的點為{p10,p11},所以p10不是核心點,進行下一步。

  第五步:

    繼續掃描到點p11,以p11為中心:

    (1)計算p11鄰域內的點為{p11,p10,p12},所以p11是核心點;

    (2)以p11為核心點建立簇C3,找出所有的密度可達點;

    (3)p10已被處理處理過,繼續掃描;

    (4)掃描p12,p12鄰域內{p12,p11};

    至此,p11的密度可達點都搜尋完畢,形成簇C3,包含的點為{p11,p10,p12}

  第六步:

    繼續掃描點,p12,p13都已被處理過,至此所有點都被處理過,演算法結束。

 下面對DBSCAN進行演算法的實現,首先是演算法的步驟實現,然後再用sklearn進行實現:

import numpy as np
import random
import matplotlib.pyplot as plt
import copy
from sklearn import datasets


# 搜尋鄰域內的點
def find_neighbor(j, x, eps):
    """
    :param j: 核心點的索引
    :param x: 資料集
    :param eps:鄰域半徑
    :return:
    """
    temp = np.sum((x - x[j]) ** 2, axis=1) ** 0.5
    N = np.argwhere(temp <= eps).flatten().tolist()
    return N


def DBSCAN(X, eps, MinPts):
    k = -1
    # 儲存每個資料的鄰域
    neighbor_list = []
    # 核心物件的集合
    omega_list = []
    # 初始化,所有的點記為未處理
    gama = set([x for x in range(len(X))])
    cluster = [-1 for _ in range(len(X))]

    for i in range(len(X)):
        neighbor_list.append(find_neighbor(i, X, eps))
        if len(neighbor_list[-1]) >= MinPts:
            omega_list.append(i)

    omega_list = set(omega_list)
    while len(omega_list) > 0:
        gama_old = copy.deepcopy(gama)
        # 隨機選取一個核心點
        j = random.choice(list(omega_list))
        # 以該核心點建立簇Ck
        k = k + 1
        Q = list()
        # 選取的核心點放入Q中處理,Q中只有一個物件
        Q.append(j)
        # 選取核心點後,將核心點從核心點列表中刪除
        gama.remove(j)
        # 處理核心點,找出核心點所有密度可達點
        while len(Q) > 0:
            q = Q[0]
            # 將核心點移出,並開始處理該核心點
            Q.remove(q)
            # 第一次判定為True,後面如果這個核心點密度可達的點還有核心點的話
            if len(neighbor_list[q]) >= MinPts:
                # 核心點鄰域內的未被處理的點
                delta = set(neighbor_list[q]) & gama
                delta_list = list(delta)
                # 開始處理未被處理的點
                for i in range(len(delta)):
                    # 放入待處理列表中
                    Q.append(delta_list[i])
                    # 將已處理的點移出標記列表
                    gama = gama - delta
        # 本輪中被移除的點就是屬於Ck的點
        Ck = gama_old - gama
        Cklist = list(Ck)
        # 依次按照索引放入cluster結果中
        for i in range(len(Ck)):
            cluster[Cklist[i]] = k
        omega_list = omega_list - Ck
    return cluster


X1, y1 = datasets.make_circles(n_samples=2000, factor=.6, noise=.02)
X2, y2 = datasets.make_blobs(n_samples=400, n_features=2, centers=[[1.2, 1.2]], cluster_std=[[.1]], random_state=9)
X = np.concatenate((X1, X2))
eps = 0.08
min_Pts = 10
C = DBSCAN(X, eps, min_Pts)
plt.figure()
plt.scatter(X[:, 0], X[:, 1], c=C)
plt.show()

執行結果如圖所示:

 然後就是利用sklearn中的DBSCAN類進行實現:

from sklearn.cluster import DBSCAN

model = DBSCAN(eps=0.08, min_samples=10, metric='euclidean', algorithm='auto')
"""
eps: 鄰域半徑
min_samples:對應MinPts
metrics: 鄰域內距離計算方法,之前在層次聚類中已經說過,可選有: 
        歐式距離:“euclidean”
        曼哈頓距離:“manhattan”
        切比雪夫距離:“chebyshev” 
        閔可夫斯基距離:“minkowski”
        帶權重的閔可夫斯基距離:“wminkowski”
        標準化歐式距離: “seuclidean”
        馬氏距離:“mahalanobis”
algorithm:最近鄰搜尋演算法引數,演算法一共有三種,
        第一種是蠻力實現‘brute’,
        第二種是KD樹實現‘kd_tree’,
        第三種是球樹實現‘ball_tree’, 
        ‘auto’則會在上面三種演算法中做權衡
leaf_size:最近鄰搜尋演算法引數,為使用KD樹或者球樹時, 停止建子樹的葉子節點數量的閾值
p: 最近鄰距離度量引數。只用於閔可夫斯基距離和帶權重閔可夫斯基距離中p值的選擇,p=1為曼哈頓距離, p=2為歐式距離。

"""
model.fit(X)
plt.figure()
plt.scatter(X[:, 0], X[:, 1], c=model.labels_)
plt.show()

  上面一些引數是需要調的,如eps和MinPts,基於密度聚類對這兩個引數敏感。

關於DBSCAN的優缺點:

  優點

    1、不必指定聚類的類別數量;

    2、可以形成任意形狀的簇,而K-means只適用於凸資料集;

    3、對於異常值不敏感;

  缺點:

    1、計算量較大,對於樣本數量和維度巨大的樣本,計算速度慢,收斂時間長,這時可以採用KD樹進行改進;

    2、對於eps和MinPts敏感,調參複雜,需要聯合調參;

    3、樣本集的密度不均勻、聚類間距差相差很大時,聚類質量較差,這時用 DBSCAN 演算法一般不適合;

    4、樣本同採用一組引數聚類,有時不同的簇的密度不一樣,有人提出OPTICS聚類演算法(有空會把這一演算法補上);

    5、由於對噪聲不敏感,在一些領域,如異常檢測不適用。

本文參考資料:

        https://blog.csdn.net/xyisv/article/details/88918448

        https://blog.csdn.net/hansome_hong/article/details/107596543

        https://www.cnblogs.com/pinard/p/6217852.html


上面即為層次聚類和DBSCAN兩個演算法的內容,總體來說,演算法的思想相對較為簡單,但是有時資料處理是一個麻煩的過程,有時資料維度過大,導致記憶體不足等情況。後面可能會再總結一些有關聚類的內容,就在這裡進行補充吧,有關聚類的內容就先完結了。

相關文章