推薦系統學習(三)——聚類算法

聚類分析

什麼是聚類分析?

聚類 (Clustering) 就是將數據對象分組成爲多個類或者簇 (Cluster),它的目標是:在同一個簇中的對象之間具有較高的相似度,而不同簇中的對象差別較大。所以,在很多應用中,一個簇中的數據對象可以被作爲一個整體來對待,從而減少計算量或者提高計算質量。

其實聚類是一個人們日常生活的常見行爲,即所謂“物以類聚,人以羣分”,核心的思想也就是聚類。人們總是不斷地改進下意識中的聚類模式來學習如何區分各個事物和人。同時,聚類分析已經廣泛的應用在許多應用中,包括模式識別,數據分析,圖像處理以及市場研究。通過聚類,人們能意識到密集和稀疏的區域,發現全局的分佈模式,以及數據屬性之間的有趣的相互關係。

聚類同時也在 Web 應用中起到越來越重要的作用。最被廣泛使用的既是對 Web 上的文檔進行分類,組織信息的發佈,給用戶一個有效分類的內容瀏覽系統(門戶網站),同時可以加入時間因素,進而發現各個類內容的信息發展,最近被大家關注的主題和話題,或者分析一段時間內人們對什麼樣的內容比較感興趣,這些有趣的應用都得建立在聚類的基礎之上。作爲一個數據挖掘的功能,聚類分析能作爲獨立的工具來獲得數據分佈的情況,觀察每個簇的特點,集中對特定的某些簇做進一步的分析,此外,聚類分析還可以作爲其他算法的預處理步驟,簡化計算量,提高分析效率,這也是我們在這裏介紹聚類分析的目的。

不同的聚類問題

對於一個聚類問題,要挑選最適合最高效的算法必須對要解決的聚類問題本身進行剖析,下面我們就從幾個側面分析一下聚類問題的需求。

聚類結果是排他的還是可重疊的

爲了很好理解這個問題,我們以一個例子進行分析,假設你的聚類問題需要得到二個簇:“喜歡詹姆斯卡梅隆電影的用戶”和“不喜歡詹姆斯卡梅隆的用戶”,這其實是一個排他的聚類問題,對於一個用戶,他要麼屬於“喜歡”的簇,要麼屬於不喜歡的簇。但如果你的聚類問題是“喜歡詹姆斯卡梅隆電影的用戶”和“喜歡里奧納多電影的用戶”,那麼這個聚類問題就是一個可重疊的問題,一個用戶他可以既喜歡詹姆斯卡梅隆又喜歡里奧納多。

所以這個問題的核心是,對於一個元素,他是否可以屬於聚類結果中的多個簇中,如果是,則是一個可重疊的聚類問題,如果否,那麼是一個排他的聚類問題。

基於層次還是基於劃分

其實大部分人想到的聚類問題都是“劃分”問題,就是拿到一組對象,按照一定的原則將它們分成不同的組,這是典型的劃分聚類問題。但除了基於劃分的聚類,還有一種在日常生活中也很常見的類型,就是基於層次的聚類問題,它的聚類結果是將這些對象分等級,在頂層將對象進行大致的分組,隨後每一組再被進一步的細分,也許所有路徑最終都要到達一個單獨實例,這是一種“自頂向下”的層次聚類解決方法,對應的,也有“自底向上”的。其實可以簡單的理解,“自頂向下”就是一步步的細化分組,而“自底向上”就是一步步的歸併分組。

簇數目固定的還是無限制的聚類

這個屬性很好理解,就是你的聚類問題是在執行聚類算法前已經確定聚類的結果應該得到多少簇,還是根據數據本身的特徵,由聚類算法選擇合適的簇的數目。

基於距離還是基於概率分佈模型

在本系列的第二篇介紹協同過濾的文章中,我們已經詳細介紹了相似性和距離的概念。基於距離的聚類問題應該很好理解,就是將距離近的相似的對象聚在一起。相比起來,基於概率分佈模型的,可能不太好理解,那麼下面給個簡單的例子。

一個概率分佈模型可以理解是在 N 維空間的一組點的分佈,而它們的分佈往往符合一定的特徵,比如組成一個特定的形狀。基於概率分佈模型的聚類問題,就是在一組對象中,找到能符合特定分佈模型的點的集合,他們不一定是距離最近的或者最相似的,而是能完美的呈現出概率分佈模型所描述的模型。

下面圖 1 給出了一個例子,對同樣一組點集,應用不同的聚類策略,得到完全不同的聚類結果。左側給出的結果是基於距離的,核心的原則就是將距離近的點聚在一起,右側給出的基於概率分佈模型的聚類結果,這裏採用的概率分佈模型是一定弧度的橢圓。圖中專門標出了兩個紅色的點,這兩點的距離很近,在基於距離的聚類中,將他們聚在一個類中,但基於概率分佈模型的聚類則將它們分在不同的類中,只是爲了滿足特定的概率分佈模型(當然這裏我特意舉了一個比較極端的例子)。所以我們可以看出,在基於概率分佈模型的聚類方法裏,核心是模型的定義,不同的模型可能導致完全不同的聚類結果。

圖 1 基於距離和基於概率分佈模型的聚類問題
圖 1 基於距離和基於概率分佈模型的聚類問題

Apache Mahout 中的聚類分析框架

Apache Mahout 是 Apache Software Foundation (ASF) 旗下的一個開源項目,提供一些可擴展的機器學習領域經典算法的實現,旨在幫助開發人員更加方便快捷地創建智能應用程序,並且,在 Mahout 的最近版本中還加入了對 Apache Hadoop 的支持,使這些算法可以更高效的運行在雲計算環境中。

關於 Apache Mahout 的安裝和配置請參考《基於 Apache Mahout 構建社會化推薦引擎》,它是筆者 09 年發表的一篇關於基於 Mahout 實現推薦引擎的 developerWorks 文章,其中詳細介紹了 Mahout 的安裝步驟。

Mahout 中提供了常用的多種聚類算法,涉及我們剛剛討論過的各種類型算法的具體實現,下面我們就進一步深入幾個典型的聚類算法的原理,優缺點和實用場景,以及如何使用 Mahout 高效的實現它們。

深入聚類算法

深入介紹聚類算法之前,這裏先對 Mahout 中對各種聚類問題的數據模型進行簡要的介紹。

數據模型

Mahout 的聚類算法將對象表示成一種簡單的數據模型:向量 (Vector)。在向量數據描述的基礎上,我們可以輕鬆的計算兩個對象的相似性,關於向量和向量的相似度計算,本系列的上一篇介紹協同過濾算法的文章中已經進行了詳細的介紹,請參考《“探索推薦引擎內部的祕密”系列 - Part 2: 深入推薦引擎相關算法 -- 協同過濾》。

Mahout 中的向量 Vector 是一個每個域是浮點數 (double) 的複合對象,最容易聯想到的實現就是一個浮點數的數組。但在具體應用由於向量本身數據內容的不同,比如有些向量的值很密集,每個域都有值;有些呢則是很稀疏,可能只有少量域有值,所以 Mahout 提供了多個實現:

  1. DenseVector,它的實現就是一個浮點數數組,對向量裏所有域都進行存儲,適合用於存儲密集向量。
  2. RandomAccessSparseVector 基於浮點數的 HashMap 實現的,key 是整形 (int) 類型,value 是浮點數 (double) 類型,它只存儲向量中不爲空的值,並提供隨機訪問。
  3. SequentialAccessVector 實現爲整形 (int) 類型和浮點數 (double) 類型的並行數組,它也只存儲向量中不爲空的值,但只提供順序訪問。

用戶可以根據自己算法的需求選擇合適的向量實現類,如果算法需要很多隨機訪問,應該選擇 DenseVector 或者 RandomAccessSparseVector,如果大部分都是順序訪問,SequentialAccessVector 的效果應該更好。

介紹了向量的實現,下面我們看看如何將現有的數據建模成向量,術語就是“如何對數據進行向量化”,以便採用 Mahout 的各種高效的聚類算法。

  1. 簡單的整形或浮點型的數據

    這種數據最簡單,只要將不同的域存在向量中即可,比如 n 維空間的點,其實本身可以被描述爲一個向量。

  2. 枚舉類型數據

    這類數據是對物體的描述,只是取值範圍有限。舉個例子,假設你有一個蘋果信息的數據集,每個蘋果的數據包括:大小,重量,顏色等,我們以顏色爲例,設蘋果的顏色數據包括:紅色,黃色和綠色。在對數據進行建模時,我們可以用數字來表示顏色,紅色 =1,黃色 =2,綠色 =3,那麼大小直徑 8cm,重量 0.15kg,顏色是紅色的蘋果,建模的向量就是 <8, 0.15, 1>。

    下面的清單 1 給出了對以上兩種數據進行向量化的例子。

    清單 1. 創建簡單的向量
     // 創建一個二維點集的向量組
     public static final double[][] points = { { 1, 1 }, { 2, 1 }, { 1, 2 }, 
     { 2, 2 }, { 3, 3 },  { 8, 8 }, { 9, 8 }, { 8, 9 }, { 9, 9 }, { 5, 5 }, 
     { 5, 6 }, { 6, 6 }}; 
     public static List<Vector> getPointVectors(double[][] raw) { 
    	 List<Vector> points = new ArrayList<Vector>(); 
    	 for (int i = 0; i < raw.length; i++) { 
    		 double[] fr = raw[i]; 
     // 這裏選擇創建 RandomAccessSparseVector 
    		 Vector vec = new RandomAccessSparseVector(fr.length); 
    		 // 將數據存放在創建的 Vector 中
     vec.assign(fr); 
    		 points.add(vec); 
    	 } 
    	 return points; 
     } 
    
     // 創建蘋果信息數據的向量組
     public static List<Vector> generateAppleData() { 
     List<Vector> apples = new ArrayList<Vector>(); 
     // 這裏創建的是 NamedVector,其實就是在上面幾種 Vector 的基礎上,
     //爲每個 Vector 提供一個可讀的名字
    	 NamedVector apple = new NamedVector(new DenseVector(
    	 new double[] {0.11, 510, 1}), 
    		"Small round green apple"); 
    	 apples.add(apple); 
     apple = new NamedVector(new DenseVector(new double[] {0.2, 650, 3}), 
    		"Large oval red apple"); 
    	 apples.add(apple); 
    	 apple = new NamedVector(new DenseVector(new double[] {0.09, 630, 1}), 
    		"Small elongated red apple"); 
    	 apples.add(apple); 
    	 apple = new NamedVector(new DenseVector(new double[] {0.25, 590, 3}), 
    		"Large round yellow apple"); 
    	 apples.add(apple); 
    	 apple = new NamedVector(new DenseVector(new double[] {0.18, 520, 2}), 
    		"Medium oval green apple"); 
    	 apples.add(apple); 
    	 return apples; 
     }
  3. 文本信息

    作爲聚類算法的主要應用場景 - 文本分類,對文本信息的建模也是一個常見的問題。在信息檢索研究領域已經有很好的建模方式,就是信息檢索領域中最常用的向量空間模型 (Vector Space Model, VSM)。因爲向量空間模型不是本文的重點,這裏給一個簡要的介紹,有興趣的朋友可以查閱參考目錄中給出的相關文檔。

    文本的向量空間模型就是將文本信息建模爲一個向量,其中每一個域是文本中出現的一個詞的權重。關於權重的計算則有很多中:

    • 最簡單的莫過於直接計數,就是詞在文本里出現的次數。這種方法簡單,但是對文本內容描述的不夠精確。
    • 詞的頻率 (Team Frequency, TF):就是將詞在文本中出現的頻率作爲詞的權重。這種方法只是對於直接計數進行了歸一化處理,目的是讓不同長度的文本模型有統一的取值空間,便於文本相似度的比較,但可以看出,簡單計數和詞頻都不能解決“高頻無意義詞彙權重大的問題”,也就是說對於英文文本中,“a”,“the”這樣高頻但無實際意義的詞彙並沒有進行過濾,這樣的文本模型在計算文本相似度時會很不準確。
    • 詞頻 - 逆向文本頻率 (Term Frequency – Inverse Document Frequency, TF-IDF):它是對 TF 方法的一種加強,字詞的重要性隨着它在文件中出現的次數成正比增加,但同時會隨着它在所有文本中出現的頻率成反比下降。舉個例子,對於“高頻無意義詞彙”,因爲它們大部分會出現在所有的文本中,所以它們的權重會大打折扣,這樣就使得文本模型在描述文本特徵上更加精確。在信息檢索領域,TF-IDF 是對文本信息建模的最常用的方法。

    對於文本信息的向量化,Mahout 已經提供了工具類,它基於 Lucene 給出了對文本信息進行分析,然後創建文本向量。下面的清單 2 給出了一個例子,分析的文本數據是路透提供的新聞數據,參考資源裏給出了下載地址。將數據集下載後,放在“clustering/reuters”目錄下。

    清單 2. 創建文本信息的向量
     public static void documentVectorize(String[] args) throws Exception{ 
    	 //1. 將路透的數據解壓縮 , Mahout 提供了專門的方法
     DocumentClustering.extractReuters(); 
     //2. 將數據存儲成 SequenceFile,因爲這些工具類就是在 Hadoop 的基礎上做的,所以首先我們需要將數據寫
     //    成 SequenceFile,以便讀取和計算
    	 DocumentClustering.transformToSequenceFile(); 
     //3. 將 SequenceFile 文件中的數據,基於 Lucene 的工具進行向量化
    	 DocumentClustering.transformToVector(); 	
     } 
    
     public static void extractReuters(){ 
     //ExtractReuters 是基於 Hadoop 的實現,所以需要將輸入輸出的文件目錄傳給它,這裏我們可以直接把它映
     // 射到我們本地的一個文件夾,解壓後的數據將寫入輸出目錄下
    	 File inputFolder = new File("clustering/reuters"); 
    	 File outputFolder = new File("clustering/reuters-extracted"); 
    	 ExtractReuters extractor = new ExtractReuters(inputFolder, outputFolder); 
     extractor.extract(); 
     } 
    	
     public static void transformToSequenceFile(){ 
     //SequenceFilesFromDirectory 實現將某個文件目錄下的所有文件寫入一個 SequenceFiles 的功能
     // 它其實本身是一個工具類,可以直接用命令行調用,這裏直接調用了它的 main 方法
    	 String[] args = {"-c", "UTF-8", "-i", "clustering/reuters-extracted/", "-o",
    	 "clustering/reuters-seqfiles"}; 
             // 解釋一下參數的意義:
     // 	 -c: 指定文件的編碼形式,這裏用的是"UTF-8"
     // 	 -i: 指定輸入的文件目錄,這裏指到我們剛剛導出文件的目錄
     // 	 -o: 指定輸出的文件目錄
    
    	 try { 
    		 SequenceFilesFromDirectory.main(args); 
    	 } catch (Exception e) { 
    		 e.printStackTrace(); 
    	 } 
     } 
    	
     public static void transformToVector(){ 
     //SparseVectorsFromSequenceFiles 實現將 SequenceFiles 中的數據進行向量化。
     // 它其實本身是一個工具類,可以直接用命令行調用,這裏直接調用了它的 main 方法
     String[] args = {"-i", "clustering/reuters-seqfiles/", "-o", 
     "clustering/reuters-vectors-bigram", "-a", 
     "org.apache.lucene.analysis.WhitespaceAnalyzer"
    , "-chunk", "200", "-wt", "tfidf", "-s", "5", 
    "-md", "3", "-x", "90", "-ng", "2", "-ml", "50", "-seq"}; 
     // 解釋一下參數的意義:
     // 	 -i: 指定輸入的文件目錄,這裏指到我們剛剛生成 SequenceFiles 的目錄
     // 	 -o: 指定輸出的文件目錄
     // 	 -a: 指定使用的 Analyzer,這裏用的是 lucene 的空格分詞的 Analyzer 
     // 	 -chunk: 指定 Chunk 的大小,單位是 M。對於大的文件集合,我們不能一次 load 所有文件,所以需要
     // 		對數據進行切塊
     // 	 -wt: 指定分析時採用的計算權重的模式,這裏選了 tfidf 
     // 	 -s:  指定詞語在整個文本集合出現的最低頻度,低於這個頻度的詞彙將被丟掉
     // 	 -md: 指定詞語在多少不同的文本中出現的最低值,低於這個值的詞彙將被丟掉
     // 	 -x:  指定高頻詞彙和無意義詞彙(例如 is,a,the 等)的出現頻率上限,高於上限的將被丟掉
     // 	 -ng: 指定分詞後考慮詞彙的最大長度,例如 1-gram 就是,coca,cola,這是兩個詞,
     // 	      2-gram 時,coca cola 是一個詞彙,2-gram 比 1-gram 在一定情況下分析的更準確。
     // 	 -ml: 指定判斷相鄰詞語是不是屬於一個詞彙的相似度閾值,當選擇 >1-gram 時纔有用,其實計算的是
     // 	      Minimum Log Likelihood Ratio 的閾值
     // 	 -seq: 指定生成的向量是 SequentialAccessSparseVectors,沒設置時默認生成還是
     //       RandomAccessSparseVectors 
    
    	 try { 
    		 SparseVectorsFromSequenceFiles.main(args); 
    	 } catch (Exception e) { 
    		 e.printStackTrace(); 
    	 } 
     }


    這裏補充一點,生成的向量化文件的目錄結構是這樣的:

    圖 2 文本信息向量化
    圖 2 文本信息向量化
    • df-count 目錄:保存着文本的頻率信息
    • tf-vectors 目錄:保存着以 TF 作爲權值的文本向量
    • tfidf-vectors 目錄:保存着以 TFIDF 作爲權值的文本向量
    • tokenized-documents 目錄:保存着分詞過後的文本信息
    • wordcount 目錄:保存着全局的詞彙出現的次數
    • dictionary.file-0 目錄:保存着這些文本的詞彙表
    • frequcency-file-0 目錄 : 保存着詞彙表對應的頻率信息。

介紹完向量化問題,下面我們深入分析各個聚類算法,首先介紹的是最經典的 K 均值算法。

K 均值聚類算法

K 均值是典型的基於距離的排他的劃分方法:給定一個 n 個對象的數據集,它可以構建數據的 k 個劃分,每個劃分就是一個聚類,並且 k<=n,同時還需要滿足兩個要求:

  • 每個組至少包含一個對象
  • 每個對象必須屬於且僅屬於一個組。

K 均值的基本原理是這樣的,給定 k,即要構建的劃分的數目,

  1. 首先創建一個初始劃分,隨機地選擇 k 個對象,每個對象初始地代表了一個簇中心。對於其他的對象,根據其與各個簇中心的距離,將它們賦給最近的簇。
  2. 然後採用一種迭代的重定位技術,嘗試通過對象在劃分間移動來改進劃分。所謂重定位技術,就是當有新的對象加入簇或者已有對象離開簇的時候,重新計算簇的平均值,然後對對象進行重新分配。這個過程不斷重複,直到沒有簇中對象的變化。

當結果簇是密集的,而且簇和簇之間的區別比較明顯時,K 均值的效果比較好。對於處理大數據集,這個算法是相對可伸縮的和高效的,它的複雜度是 O(nkt),n 是對象的個數,k 是簇的數目,t 是迭代的次數,通常 k<<n,且 t<<n,所以算法經常以局部最優結束。

K 均值的最大問題是要求用戶必須事先給出 k 的個數,k 的選擇一般都基於一些經驗值和多次實驗結果,對於不同的數據集,k 的取值沒有可借鑑性。另外,K 均值對“噪音”和孤立點數據是敏感的,少量這類的數據就能對平均值造成極大的影響。

說了這麼多理論的原理,下面我們基於 Mahout 實現一個簡單的 K 均值算法的例子。如前面介紹的,Mahout 提供了基本的基於內存的實現和基於 Hadoop 的 Map/Reduce 的實現,分別是 KMeansClusterer 和 KMeansDriver,下面給出一個簡單的例子,就基於我們在清單 1 裏定義的二維點集數據。

清單 3. K 均值聚類算法示例
 // 基於內存的 K 均值聚類算法實現
 public static void kMeansClusterInMemoryKMeans(){ 
 // 指定需要聚類的個數,這裏選擇 2 類
 int k = 2; 
 // 指定 K 均值聚類算法的最大迭代次數
 int maxIter = 3; 
 // 指定 K 均值聚類算法的最大距離閾值
 double distanceThreshold = 0.01; 
 // 聲明一個計算距離的方法,這裏選擇了歐幾里德距離
 DistanceMeasure measure = new EuclideanDistanceMeasure(); 
 // 這裏構建向量集,使用的是清單 1 裏的二維點集
 List<Vector> pointVectors = SimpleDataSet.getPointVectors(SimpleDataSet.points); 
 // 從點集向量中隨機的選擇 k 個作爲簇的中心
 List<Vector> randomPoints = RandomSeedGenerator.chooseRandomPoints(pointVectors, k); 
 // 基於前面選中的中心構建簇
 List<Cluster> clusters = new ArrayList<Cluster>(); 
 int clusterId = 0; 
 for(Vector v : randomPoints){ 
	 clusters.add(new Cluster(v, clusterId ++, measure)); 
 } 
 // 調用 KMeansClusterer.clusterPoints 方法執行 K 均值聚類
 List<List<Cluster>> finalClusters = KMeansClusterer.clusterPoints(pointVectors, 
 clusters, measure, maxIter, distanceThreshold); 

 // 打印最終的聚類結果
 for(Cluster cluster : finalClusters.get(finalClusters.size() -1)){ 
	 System.out.println("Cluster id: " + cluster.getId() + 
" center: " + cluster.getCenter().asFormatString()); 
	 System.out.println("       Points: " + cluster.getNumPoints()); 	
 } 
 } 
 // 基於 Hadoop 的 K 均值聚類算法實現
 public static void kMeansClusterUsingMapReduce () throws Exception{ 
 // 聲明一個計算距離的方法,這裏選擇了歐幾里德距離
	 DistanceMeasure measure = new EuclideanDistanceMeasure(); 
	 // 指定輸入路徑,如前面介紹的一樣,基於 Hadoop 的實現就是通過指定輸入輸出的文件路徑來指定數據源的。
	 Path testpoints = new Path("testpoints"); 
	 Path output = new Path("output"); 
	 // 清空輸入輸出路徑下的數據
 HadoopUtil.overwriteOutput(testpoints); 
	 HadoopUtil.overwriteOutput(output); 
	 RandomUtils.useTestSeed(); 
 // 在輸入路徑下生成點集,與內存的方法不同,這裏需要把所有的向量寫進文件,下面給出具體的例子
	 SimpleDataSet.writePointsToFile(testpoints); 
 // 指定需要聚類的個數,這裏選擇 2 類
 int k = 2; 
 // 指定 K 均值聚類算法的最大迭代次數
 int maxIter = 3; 
	 // 指定 K 均值聚類算法的最大距離閾值
 double distanceThreshold = 0.01; 
 // 隨機的選擇 k 個作爲簇的中心
 Path clusters = RandomSeedGenerator.buildRandom(testpoints, 
 new Path(output, "clusters-0"), k, measure); 
 // 調用 KMeansDriver.runJob 方法執行 K 均值聚類算法
 KMeansDriver.runJob(testpoints, clusters, output, measure, 
 distanceThreshold, maxIter, 1, true, true); 
 // 調用 ClusterDumper 的 printClusters 方法將聚類結果打印出來。
 ClusterDumper clusterDumper = new ClusterDumper(new Path(output, 
"clusters-" + maxIter -1), new Path(output, "clusteredPoints")); 
 clusterDumper.printClusters(null); 
 } 
 //SimpleDataSet 的 writePointsToFile 方法,將測試點集寫入文件裏
 // 首先我們將測試點集包裝成 VectorWritable 形式,從而將它們寫入文件
 public static List<VectorWritable> getPoints(double[][] raw) { 
	 List<VectorWritable> points = new ArrayList<VectorWritable>(); 
 for (int i = 0; i < raw.length; i++) { 
		 double[] fr = raw[i]; 
		 Vector vec = new RandomAccessSparseVector(fr.length); 
		 vec.assign(fr); 
 // 只是在加入點集前,在 RandomAccessSparseVector 外加了一層 VectorWritable 的包裝
		 points.add(new VectorWritable(vec)); 
	 } 
 return points; 
 } 
 // 將 VectorWritable 的點集寫入文件,這裏涉及一些基本的 Hadoop 編程元素,詳細的請參閱參考資源裏相關的內容
 public static void writePointsToFile(Path output) throws IOException { 
	 // 調用前面的方法生成點集
	 List<VectorWritable> pointVectors = getPoints(points); 
	 // 設置 Hadoop 的基本配置
	 Configuration conf = new Configuration(); 
	 // 生成 Hadoop 文件系統對象 FileSystem 
	 FileSystem fs = FileSystem.get(output.toUri(), conf); 
 // 生成一個 SequenceFile.Writer,它負責將 Vector 寫入文件中
	 SequenceFile.Writer writer = new SequenceFile.Writer(fs, conf, output, 
	 Text.class,  VectorWritable.class); 
	 // 這裏將向量按照文本形式寫入文件
	 try { 
 for (VectorWritable vw : pointVectors) { 
 writer.append(new Text(), vw); 
		 } 
	 } finally { 
		 writer.close(); 
	 }  
 } 

執行結果
 KMeans Clustering In Memory Result 
 Cluster id: 0 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector",
"vector":"{\"values\":{\"table\":[0,1,0],\"values\":[1.8,1.8,0.0],\"state\":[1,1,0],
\"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,\"highWaterMark\":1,
\"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},\"size\":2,\"lengthSquared\":-1.0}"} 
       Points: 5 
 Cluster id: 1 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector",
 "vector":"{\"values\":{\"table\":[0,1,0],
 \"values\":[7.142857142857143,7.285714285714286,0.0],\"state\":[1,1,0],
 \"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,\"highWaterMark\":1,
 \"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},\"size\":2,\"lengthSquared\":-1.0}"} 
       Points: 7 

 KMeans Clustering Using Map/Reduce Result 
	 Weight:  Point: 
	 1.0: [1.000, 1.000] 
	 1.0: [2.000, 1.000] 
	 1.0: [1.000, 2.000] 
	 1.0: [2.000, 2.000] 
	 1.0: [3.000, 3.000] 
	 Weight:  Point: 
	 1.0: [8.000, 8.000] 
	 1.0: [9.000, 8.000] 
	 1.0: [8.000, 9.000] 
	 1.0: [9.000, 9.000] 
	 1.0: [5.000, 5.000] 
	 1.0: [5.000, 6.000] 
	 1.0: [6.000, 6.000]

介紹完 K 均值聚類算法,我們可以看出它最大的優點是:原理簡單,實現起來也相對簡單,同時執行效率和對於大數據量的可伸縮性還是較強的。然而缺點也是很明確的,首先它需要用戶在執行聚類之前就有明確的聚類個數的設置,這一點是用戶在處理大部分問題時都不太可能事先知道的,一般需要通過多次試驗找出一個最優的 K 值;其次就是,由於算法在最開始採用隨機選擇初始聚類中心的方法,所以算法對噪音和孤立點的容忍能力較差。所謂噪音就是待聚類對象中錯誤的數據,而孤立點是指與其他數據距離較遠,相似性較低的數據。對於 K 均值算法,一旦孤立點和噪音在最開始被選作簇中心,對後面整個聚類過程將帶來很大的問題,那麼我們有什麼方法可以先快速找出應該選擇多少個簇,同時找到簇的中心,這樣可以大大優化 K 均值聚類算法的效率,下面我們就介紹另一個聚類方法:Canopy 聚類算法。

Canopy 聚類算法

Canopy 聚類算法的基本原則是:首先應用成本低的近似的距離計算方法高效的將數據分爲多個組,這裏稱爲一個 Canopy,我們姑且將它翻譯爲“華蓋”,Canopy 之間可以有重疊的部分;然後採用嚴格的距離計算方式準確的計算在同一 Canopy 中的點,將他們分配與最合適的簇中。Canopy 聚類算法經常用於 K 均值聚類算法的預處理,用來找合適的 k 值和簇中心。

下面詳細介紹一下創建 Canopy 的過程:初始,假設我們有一組點集 S,並且預設了兩個距離閾值,T1,T2(T1>T2);然後選擇一個點,計算它與 S 中其他點的距離(這裏採用成本很低的計算方法),將距離在 T1 以內的放入一個 Canopy 中,同時從 S 中去掉那些與此點距離在 T2 以內的點(這裏是爲了保證和中心距離在 T2 以內的點不能再作爲其他 Canopy 的中心),重複整個過程直到 S 爲空爲止。

對 K 均值的實現一樣,Mahout 也提供了兩個 Canopy 聚類的實現,下面我們就看看具體的代碼例子。

清單 4. Canopy 聚類算法示例
 //Canopy 聚類算法的內存實現
 public static void canopyClusterInMemory () { 
	 // 設置距離閾值 T1,T2 
 double T1 = 4.0; 
	 double T2 = 3.0; 
 // 調用 CanopyClusterer.createCanopies 方法創建 Canopy,參數分別是:
	 // 	 1. 需要聚類的點集
	 // 	 2. 距離計算方法
	 // 	 3. 距離閾值 T1 和 T2 
	 List<Canopy> canopies = CanopyClusterer.createCanopies( 
 SimpleDataSet.getPointVectors(SimpleDataSet.points), 
		 new EuclideanDistanceMeasure(), T1, T2); 
	 // 打印創建的 Canopy,因爲聚類問題很簡單,所以這裏沒有進行下一步精確的聚類。
	 // 有必須的時候,可以拿到 Canopy 聚類的結果作爲 K 均值聚類的輸入,能更精確更高效的解決聚類問題
 for(Canopy canopy : canopies) { 
		 System.out.println("Cluster id: " + canopy.getId() + 
" center: " + canopy.getCenter().asFormatString()); 
		 System.out.println("       Points: " + canopy.getNumPoints()); 	
	 } 
 } 

 //Canopy 聚類算法的 Hadoop 實現
 public static void canopyClusterUsingMapReduce() throws Exception{ 
	 // 設置距離閾值 T1,T2 
 double T1 = 4.0; 
	 double T2 = 3.0; 
	 // 聲明距離計算的方法
	 DistanceMeasure measure = new EuclideanDistanceMeasure(); 
	 // 設置輸入輸出的文件路徑
	 Path testpoints = new Path("testpoints"); 
	 Path output = new Path("output"); 
	 // 清空輸入輸出路徑下的數據
	 HadoopUtil.overwriteOutput(testpoints); 
	 HadoopUtil.overwriteOutput(output); 
	 // 將測試點集寫入輸入目錄下
 SimpleDataSet.writePointsToFile(testpoints); 

 // 調用 CanopyDriver.buildClusters 的方法執行 Canopy 聚類,參數是:
	 // 	 1. 輸入路徑,輸出路徑
	 // 	 2. 計算距離的方法
	 // 	 3. 距離閾值 T1 和 T2 
	 new CanopyDriver().buildClusters(testpoints, output, measure, T1, T2, true); 
	 // 打印 Canopy 聚類的結果
	 List<List<Cluster>> clustersM = DisplayClustering.loadClusters(output);
	 	 List<Cluster> clusters = clustersM.get(clustersM.size()-1); 
	 if(clusters != null){ 
 for(Cluster canopy : clusters) { 
    System.out.println("Cluster id: " + canopy.getId() + 
" center: " + canopy.getCenter().asFormatString()); 
   System.out.println("       Points: " + canopy.getNumPoints());
   		 } 
	 } 
 } 

執行結果
 Canopy Clustering In Memory Result 
 Cluster id: 0 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector",
 "vector":"{\"values\":{\"table\":[0,1,0],\"values\":[1.8,1.8,0.0],
 \"state\":[1,1,0],\"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,
 \"highWaterMark\":1,\"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},
 \"size\":2,\"lengthSquared\":-1.0}"} 
       Points: 5 
 Cluster id: 1 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector",
 "vector":"{\"values\":{\"table\":[0,1,0],\"values\":[7.5,7.666666666666667,0.0],
 \"state\":[1,1,0],\"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,
 \"highWaterMark\":1,\"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},\"size\":2,
 \"lengthSquared\":-1.0}"} 
       Points: 6 
 Cluster id: 2 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector",
 "vector":"{\"values\":{\"table\":[0,1,0],\"values\":[5.0,5.5,0.0],
 \"state\":[1,1,0],\"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,
 \"highWaterMark\":1,\"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},\"size\":2,
 \"lengthSquared\":-1.0}"} 
       Points: 2 

 Canopy Clustering Using Map/Reduce Result 
 Cluster id: 0 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector", 
 "vector":"{\"values\":{\"table\":[0,1,0],\"values\":[1.8,1.8,0.0],
 \"state\":[1,1,0],\"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,
 \"highWaterMark\":1,\"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},
 \"size\":2,\"lengthSquared\":-1.0}"} 
       Points: 5 
 Cluster id: 1 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector",
 "vector":"{\"values\":{\"table\":[0,1,0],\"values\":[7.5,7.666666666666667,0.0],
 \"state\":[1,1,0],\"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,
 \"highWaterMark\":1,\"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},\"size\":2,
 \"lengthSquared\":-1.0}"} 
       Points: 6 
 Cluster id: 2 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector", 
 "vector":"{\"values\":{\"table\":[0,1,0], 
 \"values\":[5.333333333333333,5.666666666666667,0.0],\"state\":[1,1,0],
 \"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,\"highWaterMark\":1,
 \"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},\"size\":2,\"lengthSquared\":-1.0}"} 
       Points: 3

模糊 K 均值聚類算法

模糊 K 均值聚類算法是 K 均值聚類的擴展,它的基本原理和 K 均值一樣,只是它的聚類結果允許存在對象屬於多個簇,也就是說:它屬於我們前面介紹過的可重疊聚類算法。爲了深入理解模糊 K 均值和 K 均值的區別,這裏我們得花些時間瞭解一個概念:模糊參數(Fuzziness Factor)。

與 K 均值聚類原理類似,模糊 K 均值也是在待聚類對象向量集合上循環,但是它並不是將向量分配給距離最近的簇,而是計算向量與各個簇的相關性(Association)。假設有一個向量 v,有 k 個簇,v 到 k 個簇中心的距離分別是 d1,d2… dk,那麼 V 到第一個簇的相關性 u1可以通過下面的算式計算:

Figure xxx. Requires a heading

計算 v 到其他簇的相關性只需將 d1替換爲對應的距離。

從上面的算式,我們看出,當 m 近似 2 時,相關性近似 1;當 m 近似 1 時,相關性近似於到該簇的距離,所以 m 的取值在(1,2)區間內,當 m 越大,模糊程度越大,m 就是我們剛剛提到的模糊參數。

講了這麼多理論的原理,下面我們看看如何使用 Mahout 實現模糊 K 均值聚類,同前面的方法一樣,Mahout 一樣提供了基於內存和基於 Hadoop Map/Reduce 的兩種實現 FuzzyKMeansClusterer 和 FuzzyMeansDriver,分別是清單 5 給出了一個例子。

清單 5. 模糊 K 均值聚類算法示例
 public static void fuzzyKMeansClusterInMemory() { 
 // 指定聚類的個數
 int k = 2; 
 // 指定 K 均值聚類算法的最大迭代次數
 int maxIter = 3; 
 // 指定 K 均值聚類算法的最大距離閾值
 double distanceThreshold = 0.01; 
 // 指定模糊 K 均值聚類算法的模糊參數
 float fuzzificationFactor = 10; 
 // 聲明一個計算距離的方法,這裏選擇了歐幾里德距離
 DistanceMeasure measure = new EuclideanDistanceMeasure(); 
 // 構建向量集,使用的是清單 1 裏的二維點集
	 List<Vector> pointVectors = SimpleDataSet.getPointVectors(SimpleDataSet.points); 
 // 從點集向量中隨機的選擇 k 個作爲簇的中心
	 List<Vector> randomPoints = RandomSeedGenerator.chooseRandomPoints(points, k); 
	 // 構建初始簇,這裏與 K 均值不同,使用了 SoftCluster,表示簇是可重疊的
	 List<SoftCluster> clusters = new ArrayList<SoftCluster>(); 
	 int clusterId = 0; 
	 for (Vector v : randomPoints) { 
		 clusters.add(new SoftCluster(v, clusterId++, measure)); 
	 } 
 // 調用 FuzzyKMeansClusterer 的 clusterPoints 方法進行模糊 K 均值聚類
	 List<List<SoftCluster>> finalClusters = 
	 FuzzyKMeansClusterer.clusterPoints(points, 
 clusters, measure, distanceThreshold, maxIter, fuzzificationFactor); 
	 // 打印聚類結果
	 for(SoftCluster cluster : finalClusters.get(finalClusters.size() - 1)) { 
		 System.out.println("Fuzzy Cluster id: " + cluster.getId() + 
" center: " + cluster.getCenter().asFormatString()); 
	 } 
 } 

 public class fuzzyKMeansClusterUsingMapReduce { 
 // 指定模糊 K 均值聚類算法的模糊參數
	 float fuzzificationFactor = 2.0f; 
 // 指定需要聚類的個數,這裏選擇 2 類
	 int k = 2; 
 // 指定最大迭代次數
	 int maxIter = 3; 
 // 指定最大距離閾值
	 double distanceThreshold = 0.01; 
 // 聲明一個計算距離的方法,這裏選擇了歐幾里德距離
	 DistanceMeasure measure = new EuclideanDistanceMeasure(); 
 // 設置輸入輸出的文件路徑
	 Path testpoints = new Path("testpoints"); 
	 Path output = new Path("output"); 
 // 清空輸入輸出路徑下的數據
	 HadoopUtil.overwriteOutput(testpoints); 
	 HadoopUtil.overwriteOutput(output); 
 // 將測試點集寫入輸入目錄下
	 SimpleDataSet.writePointsToFile(testpoints); 
 // 隨機的選擇 k 個作爲簇的中心
	 Path clusters = RandomSeedGenerator.buildRandom(testpoints, 
 new Path(output, "clusters-0"), k, measure); 
	 FuzzyKMeansDriver.runJob(testpoints, clusters, output, measure, 0.5, maxIter, 1, 
 fuzzificationFactor, true, true, distanceThreshold, true); 
 // 打印模糊 K 均值聚類的結果
	 ClusterDumper clusterDumper = new ClusterDumper(new Path(output, "clusters-" + 
 maxIter ),new Path(output, "clusteredPoints")); 
	 clusterDumper.printClusters(null); 
 } 

執行結果
 Fuzzy KMeans Clustering In Memory Result 
 Fuzzy Cluster id: 0 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector",
 "vector":"{\"values\":{\"table\":[0,1,0],
 \"values\":[1.9750483367699223,1.993870669568863,0.0],\"state\":[1,1,0],
 \"freeEntries\":1,\"distinct\":2,\"lowWaterMark\":0,\"highWaterMark\":1,
 \"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},\"size\":2,\"lengthSquared\":-1.0}"} 
 Fuzzy Cluster id: 1 
 center:{"class":"org.apache.mahout.math.RandomAccessSparseVector",
 "vector":"{\"values\":{\"table\":[0,1,0], 
 \"values\":[7.924827516566109,7.982356511917616,0.0],\"state\":[1,1,0],
 \"freeEntries\":1, \"distinct\":2,\"lowWaterMark\":0,\"highWaterMark\":1,
 \"minLoadFactor\":0.2,\"maxLoadFactor\":0.5},\"size\":2,\"lengthSquared\":-1.0}"} 

 Funzy KMeans Clustering Using Map Reduce Result 
 Weight:  Point: 
	 0.9999249428064162: [8.000, 8.000] 
	 0.9855340718746096: [9.000, 8.000] 
	 0.9869963781734195: [8.000, 9.000] 
	 0.9765978701133124: [9.000, 9.000] 
	 0.6280999013864511: [5.000, 6.000] 
	 0.7826097471578298: [6.000, 6.000] 
	 Weight:  Point: 
	 0.9672607354172386: [1.000, 1.000] 
	 0.9794914088151625: [2.000, 1.000] 
	 0.9803932521191389: [1.000, 2.000] 
	 0.9977806183197744: [2.000, 2.000] 
	 0.9793701109946826: [3.000, 3.000] 
	 0.5422929338028506: [5.000, 5.000]

狄利克雷聚類算法

前面介紹的三種聚類算法都是基於劃分的,下面我們簡要介紹一個基於概率分佈模型的聚類算法,狄利克雷聚類(Dirichlet Processes Clustering)。

首先我們先簡要介紹一下基於概率分佈模型的聚類算法(後面簡稱基於模型的聚類算法)的原理:首先需要定義一個分佈模型,簡單的例如:圓形,三角形等,複雜的例如正則分佈,泊松分佈等;然後按照模型對數據進行分類,將不同的對象加入一個模型,模型會增長或者收縮;每一輪過後需要對模型的各個參數進行重新計算,同時估計對象屬於這個模型的概率。所以說,基於模型的聚類算法的核心是定義模型,對於一個聚類問題,模型定義的優劣直接影響了聚類的結果,下面給出一個簡單的例子,假設我們的問題是將一些二維的點分成三組,在圖中用不同的顏色表示,圖 A 是採用圓形模型的聚類結果,圖 B 是採用三角形模型的聚類結果。可以看出,圓形模型是一個正確的選擇,而三角形模型的結果既有遺漏又有誤判,是一個錯誤的選擇。

圖 3 採用不同模型的聚類結果
圖 3 採用不同模型的聚類結果

Mahout 實現的狄利克雷聚類算法是按照如下過程工作的:首先,我們有一組待聚類的對象和一個分佈模型。在 Mahout 中使用 ModelDistribution 生成各種模型。初始狀態,我們有一個空的模型,然後嘗試將對象加入模型中,然後一步一步計算各個對象屬於各個模型的概率。下面清單給出了基於內存實現的狄利克雷聚類算法。

清單 6. 狄利克雷聚類算法示例
 public static void DirichletProcessesClusterInMemory() { 
 // 指定狄利克雷算法的 alpha 參數,它是一個過渡參數,使得對象分佈在不同模型前後能進行光滑的過渡
	 double alphaValue = 1.0; 
 // 指定聚類模型的個數
	 int numModels = 3; 
 // 指定 thin 和 burn 間隔參數,它們是用於降低聚類過程中的內存使用量的
	 int thinIntervals = 2; 
	 int burnIntervals = 2; 
 // 指定最大迭代次數
	 int maxIter = 3; 
	 List<VectorWritable> pointVectors = 
	 SimpleDataSet.getPoints(SimpleDataSet.points); 
 // 初始階段生成空分佈模型,這裏用的是 NormalModelDistribution 
	 ModelDistribution<VectorWritable> model = 
 new NormalModelDistribution(new VectorWritable(new DenseVector(2))); 
 // 執行聚類
	 DirichletClusterer dc = new DirichletClusterer(pointVectors, model, alphaValue, 
 numModels, thinIntervals, burnIntervals); 
	 List<Cluster[]> result = dc.cluster(maxIter); 
 // 打印聚類結果
	 for(Cluster cluster : result.get(result.size() -1)){ 
		 System.out.println("Cluster id: " + cluster.getId() + " center: " + 
 cluster.getCenter().asFormatString()); 
		 System.out.println("       Points: " + cluster.getNumPoints()); 	
	 } 
 } 

執行結果
 Dirichlet Processes Clustering In Memory Result 
 Cluster id: 0 
 center:{"class":"org.apache.mahout.math.DenseVector",
 "vector":"{\"values\":[5.2727272727272725,5.2727272727272725],
 \"size\":2,\"lengthSquared\":-1.0}"} 
       Points: 11 
 Cluster id: 1 
 center:{"class":"org.apache.mahout.math.DenseVector",
 "vector":"{\"values\":[1.0,2.0],\"size\":2,\"lengthSquared\":-1.0}"} 
       Points: 1 
 Cluster id: 2 
 center:{"class":"org.apache.mahout.math.DenseVector",
 "vector":"{\"values\":[9.0,8.0],\"size\":2,\"lengthSquared\":-1.0}"} 
       Points: 0

Mahout 中提供多種概率分佈模型的實現,他們都繼承 ModelDistribution,如圖 4 所示,用戶可以根據自己的數據集的特徵選擇合適的模型,詳細的介紹請參考 Mahout 的官方文檔。

圖 4 Mahout 中的概率分佈模型層次結構
圖 4 Mahout 中的概率分佈模型層次結構

Mahout 聚類算法總結

前面詳細介紹了 Mahout 提供的四種聚類算法,這裏做一個簡要的總結,分析各個算法優缺點,其實,除了這四種以外,Mahout 還提供了一些比較複雜的聚類算法,這裏就不一一詳細介紹了,詳細信息請參考 Mahout Wiki 上給出的聚類算法詳細介紹。

表 1 Mahout 聚類算法總結
算法 內存實現 Map/Reduce 實現 簇個數是確定的 簇是否允許重疊
K 均值 KMeansClusterer KMeansDriver Y N
Canopy CanopyClusterer CanopyDriver N N
模糊 K 均值 FuzzyKMeansClusterer FuzzyKMeansDriver Y Y
狄利克雷 DirichletClusterer DirichletDriver N Y

總結

聚類算法被廣泛的運用於信息智能處理系統。本文首先簡述了聚類概念與聚類算法思想,使得讀者整體上了解聚類這一重要的技術。然後從實際構建應用的角度出發,深入的介紹了開源軟件 Apache Mahout 中關於聚類的實現框架,包括了其中的數學模型,各種聚類算法以及在不同基礎架構上的實現。通過代碼示例,讀者可以知道針對他的特定的數據問題,怎麼樣向量化數據,怎麼樣選擇各種不同的聚類算法。

發佈了48 篇原創文章 · 獲贊 40 · 訪問量 14萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章