1. 程式人生 > >【機器學習】聚類演算法:層次聚類

【機器學習】聚類演算法:層次聚類

本文是“漫談 Clustering 系列”中的第 8 篇,參見本系列的其他文章

系列不小心又拖了好久,其實正兒八經的 blog 也好久沒有寫了,因為比較忙嘛,不過覺得 Hierarchical Clustering 這個話題我能說的東西應該不多,所以還是先寫了吧(我準備這次一個公式都不貼 :D )。Hierarchical Clustering 正如它字面上的意思那樣,是層次化的聚類,得出來的結構是一棵樹,如右圖所示。在前面我們介紹過不少聚類方法,但是都是“平坦”型的聚類,然而他們還有一個更大的共同點,或者說是弱點,就是難以確定類別數。實際上,(在某次不太正式的電話面試裡)我曾被問及過這個問題,就是聚類的時候如何確定類別數。

我能想到的方法都是比較 naive 或者比較不靠譜的,比如:

  • 根據資料的來源使用領域相關的以及一些先驗的知識來進行估計——說了等於沒有說啊……
  • 降維到二維平面上,然後如果資料形狀比較好的話,也許可以直觀地看出類別的大致數目。
  • 通過譜分析,找相鄰特徵值 gap 較大的地方——這個方法我只瞭解個大概,而且我覺得“較大”這樣的詞也讓它變得不能自動化了。

當時對方問“你還有沒有什麼問題”的時候我竟然忘記了問他這個問題到底有沒有什麼更好的解決辦法,事後真是相當後悔啊。不過後來在實驗室裡詢問了一下,得到一些線索,總的來說複雜度是比較高的,待我下次有機會再細說(先自己研究研究)。

不過言歸正傳,這裡要說的 Hierarchical Clustering 從某種意義上來說也算是解決了這個問題,因為在做 Clustering 的時候並不需要知道類別數,而得到的結果是一棵樹,事後可以在任意的地方橫切一刀,得到指定數目的 cluster ,按需取即可。

聽上去很誘人,不過其實 Hierarchical Clustering 的想法很簡單,主要分為兩大類:agglomerative(自底向上)和 divisive(自頂向下)。首先說前者,自底向上,一開始,每個資料點各自為一個類別,然後每一次迭代選取距離最近的兩個類別,把他們合併,直到最後只剩下一個類別為止,至此一棵樹構造完成。

看起來很簡單吧? :D 其實確實也是比較簡單的,不過還是有兩個問題需要先說清除才行:

  1. 如何計算兩個點的距離?這個通常是 problem dependent 的,一般情況下可以直接用一些比較通用的距離就可以了,比如歐氏距離等。
  2. 如何計算兩個類別之間的距離?一開始所有的類別都是一個點,計算距離只是計算兩個點之間的距離,但是經過後續合併之後,一個類別裡就不止一個點了,那距離又要怎樣算呢?到這裡又有三個變種:
    • Single Linkage:又叫做 nearest-neighbor ,就是取兩個集合中距離最近的兩個點的距離作為這兩個集合的距離,容易造成一種叫做 Chaining 的效果,兩個 cluster 明明從“大局”上離得比較遠,但是由於其中個別的點距離比較近就被合併了,並且這樣合併之後 Chaining 效應會進一步擴大,最後會得到比較鬆散的 cluster 。
    • Complete Linkage:這個則完全是 Single Linkage 的反面極端,取兩個集合中距離最遠的兩個點的距離作為兩個集合的距離。其效果也是剛好相反的,限制非常大,兩個 cluster 即使已經很接近了,但是隻要有不配合的點存在,就頑固到底,老死不相合並,也是不太好的辦法。
    • Group Average:這種方法看起來相對有道理一些,也就是把兩個集合中的點兩兩的距離全部放在一起求一個平均值,相對也能得到合適一點的結果。

總的來說,一般都不太用 Single Linkage 或者 Complete Linkage 這兩種過於極端的方法。整個 agglomerative hierarchical clustering 的演算法就是這個樣子,描述起來還是相當簡單的,不過計算起來複雜度還是比較高的,要找出距離最近的兩個點,需要一個雙重迴圈,而且 Group Average 計算距離的時候也是一個雙重迴圈。

另外,需要提一下的是本文一開始的那個樹狀結構圖,它有一個專門的稱呼,叫做 Dendrogram ,其實就是一種二叉樹,畫的時候讓子樹的高度和它兩個後代合併時相互之間的距離大小成比例,就可以得到一個相對直觀的結構概覽。不妨再用最開始生成的那個三個 Gaussian Distribution 的資料集來舉一個例子,我採用 Group Average 的方式來計算距離,agglomerative clustering 的程式碼很簡單,沒有做什麼優化,就是直接的雙重迴圈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def do_clustering(nodes):
    # make a copy, do not touch the original list
    nodes = nodes[:]
    while len(nodes) > 1:
        print "Clustering [%d]..." % len(nodes)
        min_distance = float('inf')
        min_pair = (-1, -1)
        for i in range(len(nodes)):
            for j in range(i+1, len(nodes)):
                distance = nodes[i].distance(nodes[j])
                if distance < min_distance:
                    min_distance = distance
                    min_pair = (i, j)
        i, j = min_pair
        node1 = nodes[i]
        node2 = nodes[j]
        del nodes[j] # note should del j first (j > i)
        del nodes[i]
        nodes.append(node1.merge(node2, min_distance))
 
    return nodes[0]

資料點又一千多個,畫出來的 dendrogram 非常大,為了讓結果看起來更直觀一點,我把每個葉節點用它本身的 label 來染色,並且向上合併的時候按照權重混合一下顏色,最後把圖縮放一下得到這樣的一個結果(點選檢視原圖):

tree_scaled

或者可以把所有葉子節點全部拉伸一下看,在右邊對齊,似乎起來更加直觀一點:

tree_full_depth_scaled

從這個圖上可以很直觀地看出來聚類的結果,形成一個層次,而且也在總體上把上個大類分開來了。由於這裡我把圖橫過來畫了,所以在需要具體的 flat cluster 劃分的時候,直觀地從圖上可以看成豎著劃一條線,打斷之後得到一片“森林”,再把每個子樹裡的所有元素變成一個“扁平”的集合即可。完整的 Python 程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
from scipy.linalg import norm
from PIL import Image, ImageDraw
 
def make_list(obj):
    if isinstance(obj, list):
        return obj
    return [obj]
 
class Node(object):
    def __init__(self, fea, gnd, left=None, right=None, children_dist=1):
        self.__fea = make_list(fea)
        self.__gnd = make_list(gnd)
        self.left = left
        self.right = right
        self.children_dist = children_dist
 
        self.depth = self.__calc_depth()
        self.height = self.__calc_height()
 
    def to_dendrogram(self, filename):
        height_factor = 3
        depth_factor = 20
        total_height = int(self.height*height_factor)
        total_depth = int(self.depth*depth_factor) + depth_factor
        im = Image.new('RGBA', (total_depth, total_height))
        draw = ImageDraw.Draw(im)
        self.draw_dendrogram(draw, depth_factor, total_height/2,
                             depth_factor, height_factor, total_depth)
        im.save(filename)
 
 
    def draw_dendrogram(self,draw,x,y,depth_factor,height_factor,total_depth):
        if self.is_terminal():
            color_self = ((255,0,0), (0,255,0), (0,0,255))[int(self.__gnd[0])]
            draw.line((x, y, total_depth, y), fill=color_self)
            return color_self
        else:
            y1 = int(y-self.right.height*height_factor/2)
            y2 = int(y+self.left.height*height_factor/2)
            xc = int(x + self.children_dist*depth_factor)
            color_left = self.left.draw_dendrogram(draw, xc, y1, depth_factor,
                                                   height_factor, total_depth)
            color_right = self.right.draw_dendrogram(draw, xc, y2, depth_factor,
                                                     height_factor, total_depth)
 
            left_depth = self.left.depth
            right_depth = self.right.depth
            sum_depth = left_depth + right_depth
            if sum_depth == 0:
                sum_depth = 1
                left_depth = 0.5
                right_depth = 0.5
            color_self = tuple([int((a*left_depth+b*right_depth)/sum_depth)
                                for a, b in zip(color_left, color_right)])
            draw.line((xc, y1, xc, y2), fill=color_self)
            draw.line((x, y, xc, y), fill=color_self)
            return color_self
 
 
    # use Group Average to calculate distance
    def distance(self, other):
        return sum([norm(x1-x2)
                    for x1 in self.__fea
                    for x2 in other.__fea]) \
                / (len(self.__fea)*len(other.__fea))
 
    def is_terminal(self):
        return self.left is None and self.right is None
 
    def __calc_depth(self):
        if self.is_terminal():
            return 0
        return max(self.left.depth, self.right.depth) + self.children_dist
 
    def __calc_height(self):
        if self.is_terminal():
            return 1
        return self.left.height + self.right.height
 
    def merge(self, other, distance):
        return Node(self.__fea + other.__fea,
                    self.__gnd + other.__gnd,
                    self, other, distance)
 
 
def do_clustering(nodes):
    # make a copy, do not touch the original list
    nodes = nodes[:]
    while len(nodes) > 1:
        print "Clustering [%d]..." % len(nodes)
        min_distance = float('inf')
        min_pair = (-1, -1)
        for i in range(len(nodes)):
            for j in range(i+1, len(nodes)):
                distance = nodes[i].distance(nodes[j])
                if distance < min_distance:
                    min_distance = distance
                    min_pair = (i, j)
        i, j = min_pair
        node1 = nodes[i]
        node2 = nodes[j]
        del nodes[j] # note should del j first (j > i)
        del nodes[i]
        nodes.append(node1.merge(node2, min_distance))
 
    return nodes[0]

agglomerative clustering 差不多就這樣了,再來看 divisive clustering ,也就是自頂向下的層次聚類,這種方法並沒有 agglomerative clustering 這樣受關注,大概因為把一個節點分割為兩個並不如把兩個節點結合為一個那麼簡單吧,通常在需要做 hierarchical clustering 但總體的 cluster 數目又不太多的時候可以考慮這種方法,這時可以分割到符合條件為止,而不必一直分割到每個資料點一個 cluster 。

總的來說,divisive clustering 的每一次分割需要關注兩個方面:一是選哪一個 cluster 來分割;二是如何分割。關於 cluster 的選取,通常採用一些衡量鬆散程度的度量值來比較,例如 cluster 中距離最遠的兩個資料點之間的距離,或者 cluster 中所有節點相互距離的平均值等,直接選取最“鬆散”的一個 cluster 來進行分割。而分割的方法也有多種,比如,直接採用普通的 flat clustering 演算法(例如 k-means)來進行二類聚類,不過這樣的方法計算量變得很大,而且像 k-means 這樣的和初值選取關係很大的演算法,會導致結果不穩定。另一種比較常用的分割方法如下:

  • 待分割的 cluster 記為 G ,在 G 中取出一個到其他點的平均距離最遠的點 x ,構成新 cluster H;
  • 在 G 中選取這樣的點 x’ ,x’ 到 G 中其他點的平均距離減去 x’ 到 H 中所有點的平均距離這個差值最大,將其歸入 H 中;
  • 重複上一個步驟,直到差值為負。

到此為止,我的 hierarchical clustering 介紹就結束了。總的來說,在我個人看來,hierarchical clustering 演算法似乎都是描述起來很簡單,計算起來很困難(計算量很大)。並且,不管是 agglomerative 還是 divisive 實際上都是貪心演算法了,也並不能保證能得到全域性最優的。而得到的結果,雖然說可以從直觀上來得到一個比較形象的大局觀,但是似乎實際用處並不如眾多 flat clustering 演算法那麼廣泛。