Mahout學習——Canopy Clustering
聚類是機器學習裏很重要的一類方法,基本原則是將“性質相似”(這裏就有相似的標準問題,比如是基於概率分佈模型的相似性又或是基於距離的相似性)的對象儘可能的放在一個Cluster中而不同Cluster中對象儘可能不相似。對聚類算法而言,有三座大山需要爬過去:(1)、a large number of clusters,(2)、a high feature dimensionality,(3)、a large number of data points。在這三種情況下,尤其是三種情況都存在時,聚類的計算代價是非常高的,有時候聚類都無法進行下去,於是出現一種簡單而又有效地方法:Canopy Method,說簡單是因爲它不用什麼高深的理論或推導就可以理解,說有效是因爲它的實際表現確實可圈可點。
一、基本思想
1、基於Canopy Method的聚類算法將聚類過程分爲兩個階段
Stage1、聚類最耗費計算的地方是計算對象相似性的時候,Canopy Method在第一階段選擇簡單、計算代價較低的方法計算對象相似性,將相似的對象放在一個子集中,這個子集被叫做Canopy ,通過一系列計算得到若干Canopy,Canopy之間可以是重疊的,但不會存在某個對象不屬於任何Canopy的情況,可以把這一階段看做數據預處理;
Stage2、在各個Canopy 內使用傳統的聚類方法(如K-means),不屬於同一Canopy 的對象之間不進行相似性計算。
從這個方法起碼可以看出兩點好處:首先,Canopy 不要太大且Canopy 之間重疊的不要太多的話會大大減少後續需要計算相似性的對象的個數;其次,類似於K-means這樣的聚類方法是需要人爲指出K的值的,通過Stage1得到的Canopy 個數完全可以作爲這個K值,一定程度上減少了選擇K的盲目性。
2、聚類精度
對傳統聚類來說,例如K-means、Expectation-Maximization、Greedy Agglomerative Clustering,某個對象與Cluster的相似性是該點到Cluster中心的距離,那麼聚類精度能夠被很好保證的條件是:
對於每個Cluster都存在一個Canopy,它包含所有屬於這個Cluster的元素。
如果這種相似性的度量爲當前點與某個Cluster中離的最近的點的距離,那麼聚類精度能夠被很好保證的條件是:
對於每個Cluster都存在若干個Canopy,這些Canopy之間由Cluster中的元素連接(重疊的部分包含Cluster中的元素)。
數據集的Canopy劃分完成後,類似於下圖:
二、單機生成Canopy的算法
(1)、將數據集向量化得到一個list後放入內存,選擇兩個距離閾值:T1和T2,其中T1 > T2,對應上圖,實線圈爲T1,虛線圈爲T2,T1和T2的值可以用交叉校驗來確定;
(2)、從list中任取一點P,用低計算成本方法快速計算點P與所有Canopy之間的距離(如果當前不存在Canopy,則把點P作爲一個Canopy),如果點P與某個Canopy距離在T1以內,則將點P加入到這個Canopy;
(3)、如果點P曾經與某個Canopy的距離在T2以內,則需要把點P從list中刪除,這一步是認爲點P此時與這個Canopy已經夠近了,因此它不可以再做其它Canopy的中心了;
(4)、重複步驟2、3,直到list爲空結束。
三、並行策略
並行點是比較明顯的,就是生成Canopy的過程可以並行,第一階段,各個slave可以依據存儲在本地的數據,各自在本地用上述算法生成若干Canopy,最後在master機器將這些Canopy用相同算法彙總後得到最終的Canopy集合,第二階段聚類操作就利用最終的Canopy集合進行。
用map-reduce描述就是:datanode在map階段,利用上述算法在本地生成若干Canopy,之後通過reduce操作得到最終的Canopy集合。
四、Mahout源碼安裝
正式使用Mahout之前需要做以下準備工作:
1、在http://mahout.apache.org/下載最新的Mahout 0.5源碼包;
2、安裝mvn,可以在終端輸入:sudo apt-get install maven2具體方法可以參照:http://www.mkyong.com/maven/how-to-install-maven-in-ubuntu/;
3、安裝Mahout源碼,可以參照這裏的方法進行:https://cwiki.apache.org/confluence/display/MAHOUT/BuildingMahout;
4、打開eclipse,在“Help”菜單下單擊“Install New Software...”,在地址欄添加:http://m2eclipse.sonatype.org/sites/m2e,之後把複選框勾上,然後一路Next即可。
5、最後在eclipse的“File”菜單單擊“Import...”,選擇“Existing Maven Projects”,Next後選擇Mahout源碼所在目錄,將感興趣的項目勾上,最後完成步驟即可。mahout-core、mahout-examples和mahout-math是下一步我們需要的。
五、Mahout的Canopy Clustering
mahout實現了一個Canopy Clustering,大致思路與前兩節用的方法一樣,用了兩個map操作和一個reduce操作,首先用一個map和一個reduce生成全局Canopy集合,最後用一個map操作進行聚類。可以在mahout-core下的src/main/java中的package:org.apache.mahout.clustering.canopy中找到相關代碼:
1、數據模型
Mahout聚類算法將對象以Vector的方式表示,它同時支持dense vector和sparse vector,一共有三種表示方式(它們擁有共同的基類AbstractVector,裏面實現了有關Vector的很多操作):
(1)、DenseVector
位於mahout-math文件夾下的src/main/java中的package:org.apache.mahout.clustering.math中,它實現的時候用一個double數組表示Vector(private double[] values), 對於dense data可以使用它;
(2)、RandomAccessSparseVector
位於mahout-math文件夾下的src/main/java中的package:org.apache.mahout.clustering.math中,它用來表示一個可以隨機訪問的sparse vector,只存儲非零元素,數據的存儲採用hash映射:OpenIntDoubleHashMap;
關於OpenIntDoubleHashMap,其key爲int類型,value爲double類型,解決衝突的方法是double hashing,可能是我獲取的源碼問題,沒有在0.5中找到它的source code,可以從http://grepcode.com/file/repo1.maven.org/maven2/org.apache.mahout/mahout-collections/0.3/org/apache/mahout/math/map/OpenIntDoubleHashMap.java#OpenIntDoubleHashMap.indexOfInsertion%28int%29中查看0.3中代碼和較詳細註釋;
(3)、SequentialAccessSparseVector
位於mahout-math文件夾下的src/main/java中的package:org.apache.mahout.clustering.math中,它用來表示一個順序訪問的sparse vector,同樣只存儲非零元素,數據的存儲採用順序映射:OrderedIntDoubleMapping;
關於OrderedIntDoubleMapping,其key爲int類型,value爲double類型,存儲的方式讓我想起了Libsvm數據表示的形式:非零元素索引:非零元素的值,這裏用一個int數組存儲indices,用double數組存儲非零元素,要想讀寫某個元素,需要在indices中查找offset,由於indices應該是有序的,所以查找操作用的是二分法。
2、如何抽象Canopy?
可以從Canopy.java文件及其父類中找到答案,Mahout在實現時候還是很巧妙的,一個Canopy包含的字段信息主要有:
1)、private int id; #Canopy的id
2)、private long numPoints; #Canopy中包含點的個數,這裏的點都是Vector
3)、private Vector center; #Canopy的重心
4)、private Vector Radius; #Canopy的半徑,這個半徑是各個點的標準差,反映組內個體間的離散程度,它的計算依賴下面要說的s0、s1和s2。
它並不會真的去用一個list去存儲其包含的點,因爲將來的計算並不關心這些點是什麼,而是與由這些點得到的三個值有關,這裏用三個變量來表示:
5)、private double s0; #表示Canopy包含點的權重之和,
6)、private Vector s1; #表示各點的加權和,
7)、private Vector s2; #表示各點平方的加權和,
以下是它的核心操作:
8)、public void computeParameters(); #根據s0、s1、s2計算numPoints、center和Radius,其中numPoints=(int)s0,center=s1/s0,Radius=sqrt(s2*s0-s1*s1)/s0,簡單點來,假設所有點權重都是1,那麼:
,其中
,其中
9)、public void observe(VectorWritable x, double weight); #每當有一個新的點加入當前Canopy時都需要更新s0、s1、s2的值,這個比較簡單。
3、Canopy Clustering的Map-Reduce實現
Canopy Clustering的實現包含單機版和MR兩個版本,單機版就不多說了,MR版用了兩個map操作和一個reduce操作,當然是通過兩個不同的job實現的,map和reduce階段執行順序是:CanopyMapper –> CanopyReducer –> ClusterMapper,我想對照下面這幅圖來理解:
(1)、首先是InputFormat,這是從HDFS讀取文件後第一個要考慮的問題,mahout中提供了三種方式,都繼承於FileInputFormat<K,V>:
Format | Description | Key | Value |
TextInputFormat | Default format; reads lines of text files (默認格式,按行讀取文件且不進行解析操作,基於行的文件比較有效) | The byte offset of the line(行的字節偏移量) | The line contents (整個行的內容) |
KeyValueInputFormat | Parses lines into key, val pairs (同樣是按照行讀取,但會搜尋第一個tab字符,把行拆分爲(Key,Value) pair) | Everything up to the first tab character(第一個tab字符前的所有字符) | The remainder of the line (該行剩下的內容) |
SequenceFileInputFormat | A Hadoop-specific high-performance binary format (Hadoop定義的高性能二進制格式) | user-defined (用戶自定義) | user-defined (用戶自定義) |
在這裏,由於使用了很多自定義的類型,如:表示vector的VectorWritable類型,表示canopy的canopy類型,且需要進行高效的數據處理,所以輸入輸出文件選擇SequenceFileInputFormat格式。由job對象的setInputFormatClass方法來設置,如:job.setInputFormatClass(SequenceFileInputFormat.class),一般在執行聚類算法前需要調用一個job專門處理原始文件爲合適的格式,比如用InputDriver,這點後面再說。
(2)、Split
一個Split塊爲一個map任務提供輸入數據,它是InputSplit類型的,默認情況下hadoop會把文件以64MB爲基數拆分爲若干Block,這些Block分散在各個節點上,於是一個文件就可以被多個map並行的處理,也就是說InputSplit定義了文件是被如何切分的。
(3)、RR
RecordReader類把由Split傳來的數據加載後轉換爲適合mapper讀取的(Key,Value) pair,RecordReader實例是由InputFormat決定,RR被反覆調用直到Split數據處理完,RR被調用後接着就會調用Mapper的map()方法。
“RecordReader實例是由InputFormat決定”這句話怎麼理解呢?比如,在Canopy Clustering中,使用的是SequenceFileInputFormat,它會提供一個 SequenceFileRecordReader類型,利用SequenceFile.Reader將Key和Value讀取出來,這裏Key和Value的類型對應Mapper的map函數的Key和Value的類型,Sequence File的存儲根據不同壓縮策略分爲:NONE:不壓縮、RECORD:僅壓縮每一個record中的value值、BLOCK:將一個block中的所有records壓縮在一起,有以下存儲格式:
Uncompressed SequenceFile
Header
Record
Record length
Key length
Key
Value
A sync-marker every few 100 bytes or so.
Record-Compressed SequenceFile
Header
Record
Record length
Key length
Key
Compressed Value
A sync-marker every few 100 bytes or so.
Block-Compressed SequenceFile Format
Header
Record Block
Compressed key-lengths block-size
Compressed key-lengths block
Compressed keys block-size
Compressed keys block
Compressed value-lengths block-size
Compressed value-lengths block
Compressed values block-size
Compressed values block
A sync-marker every few 100 bytes or so.
具體可參見:51cto.comarticle-18673-1.html
(4)、CanopyMapper
1: class CanopyMapper extends Mapper<WritableComparable<?>, VectorWritable, Text, VectorWritable> {
2:
3: private final Collection<Canopy> canopies = new ArrayList<Canopy>();
4:
5: private CanopyClusterer canopyClusterer;
6:
7: @Override
8: protected void map(WritableComparable<?> key, VectorWritable point, Context context)
9: throws IOException, InterruptedException {
10: canopyClusterer.addPointToCanopies(point.get(), canopies);
11: }
12:
13: @Override
14: protected void setup(Context context) throws IOException, InterruptedException {
15: super.setup(context);
16: canopyClusterer = new CanopyClusterer(context.getConfiguration());
17: }
18:
19: @Override
20: protected void cleanup(Context context) throws IOException, InterruptedException {
21: for (Canopy canopy : canopies) {
22: context.write(new Text("centroid"), new VectorWritable(canopy.computeCentroid()));
23: }
24: super.cleanup(context);
25: }
26: }
CanopyMapper類裏面定義了一個Canopy集合,用來存儲通過map操作得到的本地Canopy。
setup方法在map操作執行前進行必要的初始化工作;
它的map操作很直白,就是將傳來的(Key,Value) pair(以後就叫“點”吧,少寫幾個字)按照某種策略加入到某個Canopy中,這個策略在CanopyClusterer類裏說明;
在map操作執行完後,調用cleanup操作,將中間結果寫入上下文,注意這裏的Key是一個固定的字符串“centroid”,將來reduce操作接收到的數據就只有這個Key,寫入的value是所有Canopy的中心點(是個Vector哦)。
(5)、Combiner
可以看做是一個local的reduce操作,接受前面map的結果,處理完後發出結果,可以使用reduce類或者自己定義新類,這裏的彙總操作有時候是很有意義的,因爲它們都是在本地執行,最後發送出得數據量比直接發出map結果的要小,減少網絡帶寬的佔用,對將來shuffle操作也有益。在Canopy Clustering中不需要這個操作。
(6)、Partitioner & Shuffle
當有多個reducer的時候,partitioner決定由mapper或combiner傳來的(Key,Value) Pair會被髮送給哪個reducer,接着Shuffle操作會把所有從相同或不同mapper或combiner傳來的(Key,Value) Pair按照Key進行分組,相同Key值的點會被放在同一個reducer中,我覺得如何提高Shuffle的效率是hadoop可以改進的地方。在Canopy Clustering中,因爲map後的數據只有一個Key值,也就沒必要有多個reducer了,也就不用partition了。關於Partitioner可以參考:http://blog.oddfoo.net/2011/04/17/mapreduce-partition分析-2/
(7)、CanopyReducer
1: public class CanopyReducer extends Reducer<Text, VectorWritable, Text, Canopy> {
2:
3: private final Collection<Canopy> canopies = new ArrayList<Canopy>();
4:
5: private CanopyClusterer canopyClusterer;
6:
7: CanopyClusterer getCanopyClusterer() {
8: return canopyClusterer;
9: }
10:
11: @Override
12: protected void reduce(Text arg0, Iterable<VectorWritable> values,
13: Context context) throws IOException, InterruptedException {
14: for (VectorWritable value : values) {
15: Vector point = value.get();
16: canopyClusterer.addPointToCanopies(point, canopies);
17: }
18: for (Canopy canopy : canopies) {
19: canopy.computeParameters();
20: context.write(new Text(canopy.getIdentifier()), canopy);
21: }
22: }
23:
24: @Override
25: protected void setup(Context context) throws IOException,
26: InterruptedException {
27: super.setup(context);
28: canopyClusterer = new CanopyClusterer(context.getConfiguration());
29: canopyClusterer.useT3T4();
30: }
31:
32: }
CanopyReducer 類裏面同樣定義了一個Canopy集合,用來存儲全局Canopy。
setup方法在reduce操作執行前進行必要的初始化工作,這裏與mapper不同的地方是可以對閾值T1、T2(T1>T2)重新設置(這裏用T3、T4表示),也就是說map階段的閾值可以與reduce階段的不同;
reduce操作用於map操作一樣的策略將局部Canopy的中心點做重新劃分,最後更新各個全局Canopy的numPoints、center、radius的信息,將(Canopy標示符,Canopy對象) Pair寫入上下文中。
(8)、OutputFormat
它與InputFormat類似,Hadoop會利用OutputFormat的實例把文件寫在本地磁盤或HDFS上,它們都是繼承自FileOutputFormat類。各個reducer會把結果寫在HDFS某個目錄下的單獨的文件內,命名規則是part-r-xxxxx,這個是依據hadoop自動命名的,此外還會在同一目錄下生成一個_SUCCESS文件,輸出文件夾用FileOutputFormat.setOutputPath() 設置。
到此爲止構建Canopy的job結束。即CanopyMapper –> CanopyReducer 階段結束。
(9)、ClusterMapper
最後聚類階段比較簡單,只有一個map操作,以上一階段輸出的Sequence File爲輸入,setup方法做一些初始化工作並從上一階段輸出目錄讀取文件,重建Canopy集合信息並存儲在一個Canopy集合中,map操作就調用CanopyClusterer的emitPointToClosestCanopy方法實現聚類,將最終結果輸出到一個Sequence File中。
(10)、CanopyClusterer
這個類是實現Canopy算法的核心,其中:
1)、addPointToCanopies方法用來決定當前點應該加入到哪個Canopy中,在CanopyMapper和CanopyReducer 中用到,流程如下:
2)、emitPointToClosestCanopy方法查找與當前點距離最近的Canopy,並將(Canopy的標示符,當前點Vector表示)輸出,這個方法在聚類階段ClusterMapper中用到。
3)、createCanopies方法用於單機生成Canopy,算法一樣,實現也較簡單,就不多說了。
(11)、CanopyDriver
一般都會定義這麼一個driver,用來定義和配置job,組織job執行,同時提供單機版和MR版。job執行順序是:buildClusters –> clusterData。
4、其它
CanopyMapper的輸入需要是(WritableComparable<?>, VectorWritable) Pair,因此,一般情況下,需要對數據集進行處理以得到相應的格式,比如,在源碼的/mahout-examples目錄下的package org.apache.mahout.clustering.syntheticcontrol.canopy中有個Job.java文件提供了對Canopy Clustering的一個版本:
1: private static void run(Path input, Path output, DistanceMeasure measure,
2: double t1, double t2) throws IOException, InterruptedException,
3: ClassNotFoundException, InstantiationException, IllegalAccessException {
4: Path directoryContainingConvertedInput = new Path(output,
5: DIRECTORY_CONTAINING_CONVERTED_INPUT);
6: InputDriver.runJob(input, directoryContainingConvertedInput,
7: "org.apache.mahout.math.RandomAccessSparseVector");
8: CanopyDriver.run(new Configuration(), directoryContainingConvertedInput,
9: output, measure, t1, t2, true, false);
10: // run ClusterDumper
11: ClusterDumper clusterDumper = new ClusterDumper(new Path(output,
12: "clusters-0"), new Path(output, "clusteredPoints"));
13: clusterDumper.printClusters(null);
14: }
利用InputDriver對數據集進行處理,將(Text, VectorWritable) Pair 以sequence file形式存儲,供CanopyDriver使用。InputDriver中的作業配置如下:
1: public static void runJob(Path input, Path output, String vectorClassName)
2: throws IOException, InterruptedException, ClassNotFoundException {
3: Configuration conf = new Configuration();
4: conf.set("vector.implementation.class.name", vectorClassName);
5: Job job = new Job(conf, "Input Driver running over input: " + input);
6:
7: job.setOutputKeyClass(Text.class);
8: job.setOutputValueClass(VectorWritable.class);
9: job.setOutputFormatClass(SequenceFileOutputFormat.class);
10: job.setMapperClass(InputMapper.class);
11: job.setNumReduceTasks(0);
12: job.setJarByClass(InputDriver.class);
13:
14: FileInputFormat.addInputPath(job, input);
15: FileOutputFormat.setOutputPath(job, output);
16:
17: job.waitForCompletion(true);
18: }
5、實例說明
可以用源碼生成相關Jar文件,例如:
(1)、準備若干數據集data,要求不同feature之間用空格隔開;
(2)、在master的終端敲入命令:hadoop namenode –format;start-all.sh;用於初始化namenode和啓動hadoop;
(3)、在HDFS上建立testdata文件夾,聚類算法會去這個文件夾加載數據集,在終端輸入:hadoop dfs –mkdir testdata;
(4)、然後將各個datanode上的數據集data上傳到HDFS,在終端輸入hadoop dfs –put data testdata/
(5)、進入mahout的那些Jar文件所在路徑,在終端敲入:hadoop jar mahout-examples-0.5-job.jar org.apache.mahout.clustering.syntheticcontrol.canopy.Job;
(6)、在localhost:50030查看作業執行情況,例如:
可以看到,第一個作業由InputDriver發起,輸入目錄是testdata,一共做了一個map操作但沒有做reduce操作,第二個作業由CanopyDriver發起,做了一對mapreduce操作,這裏對應Canopy生成過程,最後一個作業也由CanopyDriver發起,做了一個map操作,對應Canopy Clustering過程。
(7)、將執行結果抓到本地文件夾,在終端執行:hadoop dfs –get output output,得到目錄如下:
其中聚類結果保存在第一個文件夾中,當然,結果是Sequence File,不能直接雙擊打開來看。
6、總結
Mahout中對Canopy Clustering的實現是比較巧妙的,整個聚類過程用2個map操作和1個reduce操作就完成了,Canopy構建的過程可以概括爲:遍歷給定的點集S,設置兩個閾值:T1、T2且T1>T2,選擇一個點,用低成本算法計算它與其它Canpoy中心的距離,如果距離小於T1則將該點加入那個Canopy,如果距離小於T2則該點不會成爲某個Canopy的中心,重複整個過程,直到S爲空。
六、參考資料
2、https://cwiki.apache.org/MAHOUT/canopy-clustering.html
3、http://developer.yahoo.com/hadoop/tutorial/
4、http://www.ibm.com/developerworks/cn/web/1103_zhaoct_recommstudy3/