算法模型---聚類分析之DBSCAN密度聚類算法

來源
DBSCAN(Density-Based Spatial Clustering of Applications with Noise,基於密度的抗噪聚類方法)。和K-Means,BIRCH這些一般只適用於凸樣本集的聚類相比,DBSCAN既可以適用於凸樣本集,也可以適用於非凸樣本集。

1. 密度聚類原理

DBSCAN是一種基於密度的聚類算法,這類密度聚類算法一般假定類別可以通過樣本分佈的緊密程度決定。同一類別的樣本,他們之間緊密相連,也就是說,在該類別任意樣本週圍不遠處一定有同類別的樣本存在。
通過將緊密相連的樣本劃爲一類,這樣就得到了一個聚類類別。通過將所有各組緊密相連的樣本劃爲各個不同的類別,我們就得到了最終的所有聚類類別結果。

2. DBSCAN密度定義

DBSCAN是基於一組鄰域來描述樣本集的緊密程度的,參數(ϵ , MinPts)用來描述鄰域的樣本分佈緊密程度。其中,ϵ 描述了某一樣本鄰域的距離閾值(與該樣本的距離至少要小於該值才能算該樣本領域中的樣本),MinPts 描述了某一樣本的距離爲ϵ 的鄰域中樣本個數的閾值(與該樣本距離小於ϵ 的樣本個數超過MinPts 纔可能構成一個類)。
假設我的樣本集是D=(x1,x2,,xm) ,則DBSCAN具體的密度描述定義如下:
1)ϵ -鄰域:對於xjD ,其ϵ -鄰域包含樣本集D中與xj 的距離不大於ϵ 的子樣本集,即Nϵ(xj)={xiD|distance(xi,xj)ϵ} , 這個子樣本集的個數記爲|Nϵ(xj)|
2) 核心對象:對於任一樣本xjD ,如果其ϵ -鄰域對應的Nϵ(xj) 至少包含MinPts 個樣本,即如果|Nϵ(xj)|MinPts ,則xj 是核心對象。
3)密度直達:如果xi 位於xjϵ -鄰域中,且xj 是核心對象,則稱xixj 密度直達。注意反之不一定成立,即此時不能說xjxi 密度直達, 除非且xi 也是核心對象。
4)密度可達:對於xixj ,如果存在樣本序列p1,p2,...,pT ,滿足p1=xi,pT=xj , 且pt+1pt ,式中1tT1 密度直達,則稱xjxi 密度可達。也就是說,密度可達滿足傳遞性。此時序列中的傳遞樣本p1,p2,...,pT1 均爲核心對象,因爲只有核心對象才能使其他樣本密度直達。密度可達可以理解爲本來直達不了,但通過一系列中間點架橋的方式到達了,而且是由核心點可達另一個點。發起方一定是核心對象。
注意密度可達也不滿足對稱性,這個可以由密度直達的不對稱性得出。
5)密度相連:對於xixj ,如果存在覈心對象樣本xk ,使xixj 均由xk 密度可達,則稱xixj 密度相連。注意密度相連關係是滿足對稱性的。密度相連可以理解爲密度可達的升級版,xi 通過一系列的核心對象密度可達,xj 也可以通過同一系列對象密度可達,這樣就把xixj 連起來了。

從下圖可以很容易看出上述定義,圖中MinPts=5 ,紅色的點都是核心對象,因爲其ϵ -鄰域至少有5個樣本。黑色的樣本是非核心對象。所有核心對象密度直達的樣本在以紅色核心對象爲中心的超球體內,如果不在超球體內,則不能密度直達。圖中用綠色箭頭連起來的核心對象組成了密度可達的樣本序列。在這些密度可達的樣本序列的ϵ -鄰域內所有的樣本相互都是密度相連的。

3. DBSCAN密度聚類思想

DBSCAN的聚類定義很簡單:由密度可達關係導出的最大密度相連的樣本集合,即爲我們最終聚類的一個類別,或者說一個簇。
這個DBSCAN的簇裏面可以有一個或者多個核心對象。如果只有一個核心對象,則簇裏其他的非核心對象樣本都在這個核心對象的ϵ -鄰域裏;如果有多個核心對象,則簇裏的任意一個核心對象的ϵ -鄰域中一定有一個其他的核心對象,否則這兩個核心對象無法密度可達。這些核心對象的ϵ -鄰域裏所有的樣本的集合組成的一個DBSCAN 聚類簇。
那麼怎麼才能找到這樣的簇樣本集合呢?DBSCAN使用的方法很簡單,它任意選擇一個沒有類別的核心對象作爲種子,然後找到所有這個核心對象能夠密度可達的樣本集合,即爲一個聚類簇。接着繼續選擇另一個沒有類別的核心對象去尋找密度可達的樣本集合,這樣就得到另一個聚類簇。一直運行到所有核心對象都有類別爲止。

基本上這就是DBSCAN算法的主要內容了,是不是很簡單?但是我們還是有三個問題沒有考慮。
第一個是一些異常樣本點或者說少量遊離於簇外的樣本點,這些點不在任何一個核心對象在周圍,在DBSCAN中,我們一般將這些樣本點標記爲噪音點。
第二個是距離的度量問題,即如何計算某樣本和核心對象樣本的距離。在DBSCAN中,一般採用最近鄰思想,採用某一種距離度量來衡量樣本距離,比如歐式距離。這和KNN分類算法的最近鄰思想完全相同。對應少量的樣本,尋找最近鄰可以直接去計算所有樣本的距離,如果樣本量較大,則一般採用KD樹或者球樹來快速的搜索最近鄰。最近鄰的思想,距離度量,KD樹和球樹可參考原文作者的另一篇文章《K近鄰法(KNN)原理小結》
第三種問題比較特殊,某些樣本可能到兩個核心對象的距離都小於ϵ ,但是這兩個核心對象由於不是密度直達,又不屬於同一個聚類簇,那麼如果界定這個樣本的類別呢?一般來說,此時DBSCAN採用先來後到,先進行聚類的類別簇會標記這個樣本爲它的類別。也就是說BDSCAN的算法不是完全穩定的算法。

4. DBSCAN聚類算法

下面我們對DBSCAN聚類算法的流程做一個總結。
輸入:樣本集D=(x1,x2,...,xm) ,鄰域參數(ϵ,MinPts) , 樣本距離度量方式
輸出: 簇劃分C. 
1)初始化核心對象集合Ω= , 初始化聚類簇數k=0 ,初始化未訪問樣本集合Γ=D , 簇劃分C= (初始化的時候是最壞的結果:沒有核心對象,沒有聚類簇)
2) 對於j=1,2,...m , 按下面的步驟找出所有的核心對象:
a) 通過距離度量方式,找到樣本xjϵ -鄰域子樣本集Nϵ(xj)
b) 如果子樣本集樣本個數滿足|Nϵ(xj)|MinPts , 將樣本xj 加入核心對象樣本集合:Ω=Ω{xj}
3)如果核心對象集合Ω= ,則算法結束,否則轉入步驟4.(沒有核心對象無法聚類)
4)在覈心對象集合Ω 中,隨機選擇一個核心對象o ,初始化當前簇核心對象隊列Ωcur=o , 初始化類別序號k=k+1 ,初始化當前簇樣本集合Ck={o} , 更新未訪問樣本集合Γ=Γ{o} 。(可以從任務一個核心對象開始進行簇集合的搜索)
5)如果當前簇核心對象隊列Ωcur= ,則當前聚類簇Ck 生成完畢, 更新簇劃分C=C1,C2,...,Ck , 更新核心對象集合Ω=ΩCk , 轉入步驟3。
6)在當前簇核心對象隊列Ωcur 中取出一個核心對象o ,通過鄰域距離閾值ϵ 找出所有的ϵ -鄰域子樣本集Nϵ(o) ,令Δ=Nϵ(o)Γ , 更新當前簇樣本集合Ck=CkΔ , 更新未訪問樣本集合Γ=ΓΔ , 更新Ωcur=Ωcur(Nϵ(o)Ω) ,轉入步驟5.
輸出結果爲: 簇劃分C={C1,C2,...,Ck}

5. DBSCAN小結

DBSCAN的主要優點有:
1) 可以對任意形狀的稠密數據集進行聚類,相對的,K-Means之類的聚類算法一般只適用於凸數據集。
2) 可以在聚類的同時發現異常點,對數據集中的異常點不敏感。
3) 聚類結果沒有偏倚,相對的,K-Means之類的聚類算法初始值對聚類結果有很大影響。

DBSCAN的主要缺點有:
1)如果樣本集的密度不均勻、聚類間距差相差很大時,聚類質量較差,這時用DBSCAN聚類一般不適合。
2) 如果樣本集較大時,聚類收斂時間較長,此時可以對搜索最近鄰時建立的KD樹或者球樹進行規模限制來改進。
3) 調參相對於傳統的K-Means之類的聚類算法稍複雜,主要需要對距離閾值ϵ ,鄰域樣本數閾值MinPts 聯合調參,不同的參數組合對最後的聚類效果有較大影響。

6. DBSCAN Python實現

來源
計算過程

Created with Raphaël 2.1.2開始讀入數據找一未分類點擴散是否還有未分類數據輸出結果結束yesno

樣本樣例

/* 788points.txt */
15.55,28.65
14.9,27.55
14.45,28.35
14.15,28.8
13.75,28.05
13.35,28.45
13,29.15
13.45,27.5
13.6,26.5
12.8,27.35
12.4,27.85
12.3,28.4
12.2,28.65
13.4,25.1
12.95,25.95

代碼實現

# -*- coding: utf-8 -*-
__author__ = 'Wsine'

import numpy as np
import matplotlib.pyplot as plt
import math
import time
import sys

UNCLASSIFIED = False
NOISE = 0

def loadDataSet(fileName, splitChar='\t'):
    """
    輸入:文件名
    輸出:數據集
    描述:從文件讀入數據集
    """
    dataSet = []
    with open(fileName) as fr:
        for line in fr.readlines():
            curline = line.strip().split(splitChar)
            fltline = list(map(float, curline))
            dataSet.append(fltline)
    return dataSet

def dist(a, b):
    """
    輸入:向量A, 向量B
    輸出:兩個向量的歐式距離
    """
    return math.sqrt(np.power(a - b, 2).sum())

def eps_neighbor(a, b, eps):
    """
    輸入:向量A, 向量B
    輸出:是否在eps範圍內
    """
    return dist(a, b) < eps

def region_query(data, pointId, eps):
    """
    輸入:數據集, 查詢點id, 半徑大小
    輸出:查詢點eps範圍內的點的其他點id(包括查詢點本身)
    """
    nPoints = data.shape[1]
    seeds = []
    for i in range(nPoints):
        if eps_neighbor(data[:, pointId], data[:, i], eps):#如果距離小於eps即被記錄
            if i !=pointId:
                seeds.append(i)
    return seeds

def expand_cluster(data, clusterResult, pointId, clusterId, eps, minPts):
    """
    輸入:數據集, 分類結果, 待分類點id, 簇id, 半徑大小, 最小點個數
    輸出:能否成功分類
    #這個核心函數中,會判斷某個點是不同是否是核心對象,如果不是,暫時將其判斷噪聲點,可能會誤判,但會在其他點的判斷中得到糾正
    #如果是核心對象,則會以此點爲基礎生成一個聚類,並將其周圍eps距離內的點標識爲同一類;在此基礎上,尋找該核心對象eps距離內的其他核心對象,將另一核心對象及另一核心對象周圍點劃分原始
    #核心對象同一類,並不判斷擴展,直至找不到核心對象
    """
    seeds = region_query(data, pointId, eps)
    if len(seeds) < minPts: # 不滿足minPts條件的爲噪聲點(應該是非核心對象)
        clusterResult[pointId] = NOISE#某點不是核心對象,暫時判其爲噪聲點(類別用0來表示),但如果該點雖然自己不是核心對象,但在其他點判斷時,如果其他點是核心對象,
        # 而它又在另一點的eps距離內,它仍然會被重新分到另一類中,因而這裏不用擔心被誤判
        return False
    else:
        clusterResult[pointId] = clusterId # 劃分到該簇(由核心對象來代表該簇)
        for seedId in seeds:
            clusterResult[seedId] = clusterId#將周圍的點一同劃分到該簇

        while len(seeds) > 0: # 通過判斷周圍的點是否爲核心對象,持續擴張
            currentPoint = seeds[0]
            if clusterResult[currentPoint]!=0:
                queryResults = region_query(data, currentPoint, eps)
                #這裏可以優化,因爲如果之前已經判斷爲非核心對象則對應clusterResult爲0,沒必要再算一次,從來沒判斷過的其實是False,兩者還是有區別的
                #同是在seed中應該排除原始核心對象,否則在擴展時會重複算;同時利他其他核心對象擴展時,也就排除其他核心對象。修改的方法應該是region_query函數的返回值就不應該包括查詢點本身。
                if len(queryResults) >= minPts:#eps距離內的某一點是核心對象
                    for i in range(len(queryResults)):
                        resultPoint = queryResults[i]
                        if clusterResult[resultPoint] == UNCLASSIFIED:#因爲兩個距離在eps內的核心對象會將彼此周圍的點連成一片,即距離可達,因而這些點被判斷爲原始核心對象同一類
                            #並且將這些點也歸到原始核心對象的周圍點中去,從而實現不同擴展,這是整個程序中最巧妙和關鍵的地方。
                            seeds.append(resultPoint)
                            clusterResult[resultPoint] = clusterId
                        elif clusterResult[resultPoint] == NOISE:#將另一核心對象周圍的非核心對象標識爲原始核心對象同一類,
                            clusterResult[resultPoint] = clusterId
            seeds = seeds[1:]#不斷更新,已經判斷過點會被移除,直至seed爲空;即原始核心對象eps距離內的點都進行了是否爲核心對象的判斷。
        return True

def dbscan(data, eps, minPts):
    """
    輸入:數據集, 半徑大小, 最小點個數
    輸出:每個樣本的類別,和總的類別數
    """
    clusterId = 1#類別號從1開始,用什麼來標識類型本來是無所謂的,但如果從1開始,得到聚類結果的同時也就得到簇的個數。
    nPoints = data.shape[1]
    clusterResult = [UNCLASSIFIED] * nPoints#初始每個樣本的類別爲False,即還未參考聚類過程,最終聚類後的結果爲類別標識,即上面的clusterId
    for pointId in range(nPoints):#逐個點進行類別判定
        point = data[:, pointId]
        if clusterResult[pointId] == UNCLASSIFIED:#如果還未被聚到某一類(雖然是逐個點判斷,但只要之前已經生成了該點類別就會跳過去)
            if expand_cluster(data, clusterResult, pointId, clusterId, eps, minPts):#這裏沒有定義全局變量,expand_cluster也只返回True和False但clusterResult卻得到了更新
                clusterId = clusterId + 1#以某一點爲核心對象進行擴展,連成一片,之後就是一個類別自然就加1了。
    return clusterResult, clusterId - 1

def plotFeature(data, clusters, clusterNum):
    nPoints = data.shape[1]
    matClusters = np.mat(clusters).transpose()
    fig = plt.figure()
    scatterColors = ['black', 'blue', 'green', 'yellow', 'red', 'purple', 'orange', 'brown']
    ax = fig.add_subplot(111)
    for i in range(clusterNum + 1):
        colorSytle = scatterColors[i % len(scatterColors)]#如果類別很多,就可能存在顏色重複
        subCluster = data[:, np.nonzero(matClusters[:, 0].A == i)]
        ax.scatter(subCluster[0, :].flatten().A[0], subCluster[1, :].flatten().A[0], c=colorSytle, s=50)

def main():
    filePath="%s/788points.txt"%(sys.path[0])
    dataSet = loadDataSet(filePath, splitChar=',')
    dataSet = np.mat(dataSet).transpose()#將數據變成一列一個樣本
    # print(dataSet)
    clusters, clusterNum = dbscan(dataSet, 2, 14)
    print("cluster Numbers = ", clusterNum)
    # print(clusters)
    plotFeature(dataSet, clusters, clusterNum)


if __name__ == '__main__':
    start = time.clock()
    main()
    end = time.clock()
    print('finish all in %s' % str(end - start))
    plt.show()

結果如下:
這裏寫圖片描述

7、scikit-learn中的DBSCAN類

來源
官網
在scikit-learn中,DBSCAN算法類爲sklearn.cluster.DBSCAN。要熟練的掌握用DBSCAN類來聚類,除了對DBSCAN本身的原理有較深的理解以外,還要對最近鄰的思想有一定的理解。集合這兩者,就可以玩轉DBSCAN了。

7.1、DBSCAN類重要參數

DBSCAN類的重要參數也分爲兩類,一類是DBSCAN算法本身的參數,一類是最近鄰度量的參數,下面我們對這些參數做一個總結。

  • eps: DBSCAN算法參數,即我們的ϵ -鄰域的距離閾值,和樣本距離超過ϵ的樣本點不在ϵ -鄰域內。默認值是0.5。一般需要通過在多組值裏面選擇一個合適的閾值。eps過大,則更多的點會落在覈心對象的ϵ -鄰域,此時我們的類別數可能會減少, 本來不應該是一類的樣本也會被劃爲一類。反之則類別數可能會增大,本來是一類的樣本卻被劃分開。

  • min_samples: DBSCAN算法參數,即樣本點要成爲核心對象所需要的ϵ -鄰域的樣本數閾值。默認值是5. 一般需要通過在多組值裏面選擇一個合適的閾值。通常和eps一起調參。在eps一定的情況下,min_samples過大,則核心對象會過少,此時簇內部分本來是一類的樣本可能會被標爲噪音點,類別數也會變多。反之min_samples過小的話,則會產生大量的核心對象,可能會導致類別數過少。

  • metric:最近鄰距離度量參數。可以使用的距離度量較多,一般來說DBSCAN使用默認的歐式距離(即p=2的閔可夫斯基距離)就可以滿足我們的需求。可以使用的距離度量參數有:

    • a) 歐式距離 “euclidean”: i=1n(xiyi)2
    • b) 曼哈頓距離 “manhattan”:i=1n|xiyi|
    • c) 切比雪夫距離“chebyshev”:max|xiyi|(i=1,2,...n)
    • d) 閔可夫斯基距離 “minkowski”: i=1n|xiyi|pp ,p=1爲曼哈頓距離, p=2爲歐式距離。
    • e) 帶權重閔可夫斯基距離 “wminkowski”: i=1nw|xiyi|pp ,其中w 爲特徵權重
    • f) 標準化歐式距離 “seuclidean”: 即對於各特徵維度做了歸一化以後的歐式距離。此時各樣本特徵維度的均值爲0,方差爲1.
    • g) 馬氏距離“mahalanobis”:(xy)TS1(xy) ,其中,S1 爲樣本協方差矩陣的逆矩陣。當樣本分佈獨立時, S爲單位矩陣,此時馬氏距離等同於歐式距離。
    • 還有一些其他不是實數的距離度量,一般在DBSCAN算法用不上,這裏也就不列了。
  • algorithm:最近鄰搜索算法參數,算法一共有三種,第一種是蠻力實現,第二種是KD樹實現,第三種是球樹實現。這三種方法在K近鄰法(KNN)原理小結中都有講述,如果不熟悉可以去複習下。對於這個參數,一共有4種可選輸入,‘brute’對應第一種蠻力實現,‘kd_tree’對應第二種KD樹實現,‘ball_tree’對應第三種的球樹實現, ‘auto’則會在上面三種算法中做權衡,選擇一個擬合最好的最優算法。需要注意的是,如果輸入樣本特徵是稀疏的時候,無論我們選擇哪種算法,最後scikit-learn都會去用蠻力實現‘brute’。個人的經驗,一般情況使用默認的 ‘auto’就夠了。 如果數據量很大或者特徵也很多,用”auto”建樹時間可能會很長,效率不高,建議選擇KD樹實現‘kd_tree’,此時如果發現‘kd_tree’速度比較慢或者已經知道樣本分佈不是很均勻時,可以嘗試用‘ball_tree’。而如果輸入樣本是稀疏的,無論你選擇哪個算法最後實際運行的都是‘brute’。

  • leaf_size:最近鄰搜索算法參數,爲使用KD樹或者球樹時, 停止建子樹的葉子節點數量的閾值。這個值越小,則生成的KD樹或者球樹就越大,層數越深,建樹時間越長,反之,則生成的KD樹或者球樹會小,層數較淺,建樹時間較短。默認是30. 因爲這個值一般隻影響算法的運行速度和使用內存大小,因此一般情況下可以不管它。
  • p: 最近鄰距離度量參數。只用於閔可夫斯基距離和帶權重閔可夫斯基距離中p值的選擇,p=1爲曼哈頓距離, p=2爲歐式距離。如果使用默認的歐式距離不需要管這個參數。

以上就是DBSCAN類的主要參數介紹,其實需要調參的就是兩個參數eps和min_samples,這兩個值的組合對最終的聚類效果有很大的影響。

7.2、scikit-learn DBSCAN聚類實例

生成一組隨機數據,爲了體現DBSCAN在非凸數據的聚類優點,我們生成了三簇數據,兩組是非凸的。代碼如下:

import numpy as np
import matplotlib.pyplot as plt
from sklearn import datasets
X1, y1=datasets.make_circles(n_samples=5000, factor=.6,noise=.05)
X2, y2 = datasets.make_blobs(n_samples=1000, n_features=2, centers=[[1.2,1.2]], cluster_std=[[.1]],random_state=9)

X = np.concatenate((X1, X2))
plt.scatter(X[:, 0], X[:, 1], marker='o')
plt.show()

可以直觀看看我們的樣本數據分佈輸出:

首先我們看看K-Means的聚類效果,代碼如下:

from sklearn.cluster import KMeans
y_pred = KMeans(n_clusters=3, random_state=9).fit_predict(X)
plt.scatter(X[:, 0], X[:, 1], c=y_pred)
plt.show()

K-Means對於非凸數據集的聚類表現不好,從上面代碼輸出的聚類效果圖可以明顯看出,輸出圖如下:

那麼如果使用DBSCAN效果如何呢?我們先不調參,直接用默認參數,看看聚類效果,代碼如下:

from sklearn.cluster import DBSCAN
y_pred = DBSCAN().fit_predict(X)
plt.scatter(X[:, 0], X[:, 1], c=y_pred)
plt.show()

發現輸出讓我們很不滿意,DBSCAN居然認爲所有的數據都是一類!輸出效果圖如下:

怎麼辦?看來我們需要對DBSCAN的兩個關鍵的參數eps和min_samples進行調參!從上圖我們可以發現,類別數太少,我們需要增加類別數,那麼我們可以減少ϵ-鄰域的大小,默認是0.5,我們減到0.1看看效果。代碼如下:

y_pred = DBSCAN(eps = 0.1).fit_predict(X)
plt.scatter(X[:, 0], X[:, 1], c=y_pred)
plt.show()


可以看到聚類效果有了改進,至少邊上的那個簇已經被發現出來了。此時我們需要繼續調參增加類別,有兩個方向都是可以的,一個是繼續減少eps,另一個是增加min_samples。我們現在將min_samples從默認的5增加到10,代碼如下:

y_pred = DBSCAN(eps = 0.1, min_samples = 10).fit_predict(X)
plt.scatter(X[:, 0], X[:, 1], c=y_pred)
plt.show()

輸出的效果圖如下:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章