一、基本思想
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中刪除(不過在在新的mahout採用的 不加入新的Collection 這樣後面處理的時候就不包含點P),這一步是認爲點P此時與這個Canopy已經夠近了,因此它不可以再做其它Canopy的中心了;
(4)、重複步驟2、3,直到list爲空結束
三、並行策略
並行點是比較明顯的,就是生成Canopy的過程可以並行,第一階段,各個slave可以依據存儲在本地的數據,各自在本地用上述算法生成若干Canopy(這個地方感覺使各個不同slave的Canopy中心距離儘可能大於T2,效果會更好些),最後在master機器將這些Canopy用相同算法彙總後得到最終的Canopy集合,第二階段聚類操作就利用最終的Canopy集合進行。
用map-reduce描述就是:datanode在map階段,利用上述算法在本地生成若干Canopy,之後通過reduce操作得到最終的Canopy集合。
四、Mahout的Canopy Clustering(這裏對原文進行了簡化)
mahout實現了一個Canopy Clustering,大致思路與前兩節用的方法一樣,用了兩個map操作和一個reduce操作,首先用一個map和一個reduce生成全局Canopy集合,最後用一個map操作進行聚類。
1.數據結構模型
Mahout聚類算法將對象以Vector的方式表示,它同時支持dense vector和sparse vector,一共有三種表示方式(它們擁有共同的基類AbstractVector,裏面實現了有關Vector的很多操作):
(1)、DenseVector
它實現的時候用一個double數組表示Vector(private double[] values), 對於dense data可以使用它;
(2)、RandomAccessSparseVector
它用來表示一個可以隨機訪問的sparse vector,只存儲非零元素,數據的存儲採用hash映射:OpenIntDoubleHashMap;
關於OpenIntDoubleHashMap,其key爲int類型,value爲double類型,解決衝突的方法是double hashing,
(3)、SequentialAccessSparseVector
它用來表示一個順序訪問的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,我想對照下面這幅圖來理解:
-------------------------------------------橫線中間內容,詳見《Hadoop權威指南》-----------------------------------------
(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壓縮在一起
-----------------------------------------------------------------------------------------------------------------------------------------------------
(4)代碼全分析
CanopyMapper部分
class CanopyMapper extends Mapper<WritableComparable<?>, VectorWritable, Text, VectorWritable> { private final Collection<Canopy> canopies = new ArrayList<Canopy>(); private CanopyClusterer canopyClusterer; @Override protected void map(WritableComparable<?> key, VectorWritable point, Context context) throws IOException, InterruptedException { canopyClusterer.addPointToCanopies(point.get(), canopies); } @Override protected void setup(Context context) throws IOException, InterruptedException { super.setup(context); canopyClusterer = new CanopyClusterer(context.getConfiguration()); } @Override protected void cleanup(Context context) throws IOException, InterruptedException { for (Canopy canopy : canopies) { context.write(new Text("centroid"), new VectorWritable(canopy.computeCentroid())); } super.cleanup(context); } }
CanopyMapper類裏面定義了一個Canopy集合,用來存儲通過map操作得到的本地Canopy。
setup方法在map操作執行前進行必要的初始化工作;
它的map操作很直白,就是將傳來的(Key,Value) pair(以後就叫“點”吧,少寫幾個字)按照某種策略加入到某個Canopy中,這個策略在CanopyClusterer類裏說明;
在map操作執行完後,調用cleanup操作,將中間結果寫入上下文,注意這裏的Key是一個固定的字符串“centroid”,將來reduce操作接收到的數據就只有這個Key,寫入的value是所有Canopy的中心點(是個Vector)。
------這部分操作後,在各個slave初步形成局部的Canopy,其中很多與本地中心的距離L在T2<L<T1之間的,並沒有劃到本地Canopy,這些點將在Reduce階段被全局的條件下劃分,當然這裏沒有更新Canopy參數。
Combiner部分(這裏面,我所見的mahout代碼沒有設置,但是一般來說,可以Combiner有助於減少Hadoop系統中的傳輸負載)
可以看做是一個local的reduce操作,接受前面map的結果,處理完後發出結果,可以使用reduce類或者自己定義新類,這裏的彙總操作有時候是很有意義的,因爲它們都是在本地執行,最後發送出得數據量比直接發出map結果的要小,減少網絡帶寬的佔用,對將來shuffle操作也有益。在Canopy Clustering中不需要這個操作。
Partitioner & Shuffle 部分(詳見Hadoop權威指南或Hadoop源代碼)
當有多個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了。
CanopyReducer部分
public class CanopyReducer extends Reducer<Text, VectorWritable, Text, Canopy> { private final Collection<Canopy> canopies = new ArrayList<Canopy>(); private CanopyClusterer canopyClusterer; CanopyClusterer getCanopyClusterer() { return canopyClusterer; } @Override protected void reduce(Text arg0, Iterable<VectorWritable> values, Context context) throws IOException, InterruptedException { for (VectorWritable value : values) { Vector point = value.get(); canopyClusterer.addPointToCanopies(point, canopies); } for (Canopy canopy : canopies) { canopy.computeParameters(); context.write(new Text(canopy.getIdentifier()), canopy); } } @Override protected void setup(Context context) throws IOException, InterruptedException { super.setup(context); canopyClusterer = new CanopyClusterer(context.getConfiguration()); canopyClusterer.useT3T4(); } }
CanopyReducer 類裏面同樣定義了一個Canopy集合,用來存儲全局Canopy。
setup方法在reduce操作執行前進行必要的初始化工作,這裏與mapper不同的地方是可以對閾值T1、T2(T1>T2)重新設置(這裏用T3、T4表示),也就是說map階段的閾值可以與reduce階段的不同;
reduce操作用於map操作一樣的策略將局部Canopy的中心點做重新劃分,最後更新各個全局Canopy的numPoints、center、radius的信息,將(Canopy標示符,Canopy對象) Pair寫入上下文中。
------Reduce階段將前面Map階段中距離:T2<L<T1的點進行全局條件的劃分,是這些點能夠跨slave劃分(例如slavei的點劃分在slavej上Canopy上更合理,Map階段的本地性沒有劃分,這是就需要Reduce階段去完成這個任務),同時更新Canopy參數。
OutputFormat部分
它與InputFormat類似,Hadoop會利用OutputFormat的實例把文件寫在本地磁盤或HDFS上,它們都是繼承自FileOutputFormat類。各個reducer會把結果寫在HDFS某個目錄下的單獨的文件內,命名規則是part-r-xxxxx,這個是依據hadoop自動命名的,此外還會在同一目錄下生成一個_SUCCESS文件,輸出文件夾用FileOutputFormat.setOutputPath() 設置。
到此爲止構建Canopy的job結束。即CanopyMapper –> CanopyReducer 階段結束。
ClusterMapper部分
public class ClusterMapper extends Mapper<WritableComparable<?>, VectorWritable, IntWritable, WeightedVectorWritable> { private CanopyClusterer canopyClusterer; @Override protected void map(WritableComparable<?> key, VectorWritable point, Context context) throws IOException, InterruptedException { canopyClusterer.emitPointToClosestCanopy(point.get(), canopies, context); } private final Collection<Canopy> canopies = new ArrayList<Canopy>(); /** * Configure the mapper by providing its canopies. Used by unit tests. */ public void config(Collection<Canopy> canopies) { this.canopies.clear(); this.canopies.addAll(canopies); } @Override protected void setup(Context context) throws IOException, InterruptedException { super.setup(context); canopyClusterer = new CanopyClusterer(context.getConfiguration()); Configuration conf = context.getConfiguration(); String clustersIn = conf.get(CanopyConfigKeys.CANOPY_PATH_KEY); // filter out the files if (clustersIn != null && clustersIn.length() > 0) { Path clusterPath = new Path(clustersIn, "*"); FileSystem fs = clusterPath.getFileSystem(conf); Path[] paths = FileUtil.stat2Paths(fs.globStatus(clusterPath, PathFilters.partFilter())); for (FileStatus file : fs.listStatus(paths, PathFilters.partFilter())) { for (Canopy value : new SequenceFileValueIterable<Canopy>(file .getPath(), conf)) { canopies.add(value); } } if (canopies.isEmpty()) { throw new IllegalStateException("Canopies are empty!"); } } } public boolean canopyCovers(Canopy canopy, Vector point) { return canopyClusterer.canopyCovers(canopy, point); } }
最後聚類階段比較簡單,只有一個map操作,以上一階段輸出的Sequence File爲輸入,setup方法做一些初始化工作並從上一階段輸出目錄讀取文件(見 CanopyDriver中的代碼),重建Canopy集合信息並存儲在一個Canopy集合(Collection)中,map操作就調用CanopyClusterer的emitPointToClosestCanopy方法實現聚類,將最終結果輸出到一個Sequence File中。
這部分將所有點進行了劃分,包括前面沒有劃分的點;
CanopyClusterer部分(這部分主要實現了一些前面做需要用到的函數)
這個類是實現Canopy算法的核心,其中:
1)、addPointToCanopies代碼實現
public void addPointToCanopies(Vector point, Collection<Canopy> canopies) { boolean pointStronglyBound = false; for (Canopy canopy : canopies) { double dist = measure.distance(canopy.getCenter().getLengthSquared(), canopy.getCenter(), point); if (dist < t1) { log.debug("Added point: {} to canopy: {}", AbstractCluster.formatVector(point, null), canopy.getIdentifier()); canopy.observe(point); } pointStronglyBound = pointStronglyBound || dist < t2; } if (!pointStronglyBound) { log.debug("Created new Canopy:{} at center:{}", nextCanopyId, AbstractCluster.formatVector(point, null)); canopies.add(new Canopy(point, nextCanopyId++, measure)); } }
addPointToCanopies方法用來決定當前點應該加入到哪個Canopy中,在CanopyMapper和CanopyReducer 中用到,流程如下:
2)、emitPointToClosestCanopy代碼實現
public void emitPointToClosestCanopy(Vector point, Iterable<Canopy> canopies, Mapper<?,?,IntWritable,WeightedVectorWritable>.Context context) throws IOException, InterruptedException { Canopy closest = findClosestCanopy(point, canopies); context.write(new IntWritable(closest.getId()), new WeightedVectorWritable(1, point)); context.setStatus("Emit Closest Canopy ID:" + closest.getIdentifier()); } protected Canopy findClosestCanopy(Vector point, Iterable<Canopy> canopies) { double minDist = Double.MAX_VALUE; Canopy closest = null; // find closest canopy for (Canopy canopy : canopies) { double dist = measure.distance(canopy.getCenter().getLengthSquared(), canopy.getCenter(), point); if (dist < minDist) { minDist = dist; closest = canopy; } } return closest; }
emitPointToClosestCanopy方法查找與當前點距離最近的Canopy,並將(Canopy的標示符,當前點Vector表示)輸出,這個方法在聚類階段ClusterMapper中用到。
CanopyDriver部分(buildClustersMR、clusterDataMR代碼略)
public static void run(Configuration conf, Path input, Path output, DistanceMeasure measure, double t1, double t2, double t3, double t4, boolean runClustering, boolean runSequential) throws IOException, InterruptedException, ClassNotFoundException, InstantiationException, IllegalAccessException { Path clustersOut = buildClusters(conf, input, output, measure, t1, t2, t3, t4, runSequential); if (runClustering) { clusterData(conf, input, clustersOut, output, measure, t1, t2, runSequential); } }
其中buildClusters調用buildClustersMR,clusterData調用clusterDataMR,buildClustersMR和clusterDataMR負責定義必要的參數,傳入必要conf文件配置選項等等
一般都會定義這麼一個driver,用來定義和配置job,組織job執行,同時提供單機版和MR版。job執行順序是:buildClusters –> clusterData
概括總結:第一階段:buildCluster階段 ---map首先根據T1、T2標識出部分各點應所屬的Canopy,並在map結束貼上的CanopyID標籤,reduce蒐集map輸出的各點,並更新各個Canopy的參數。第二階段:ClusterData階段-利用一個map將所有點進行(包括第一階段沒有處理的的點)進行聚類運算,找出每個點距離最近的Canopy,並存在一個Canopy集合(Collection)中,同時輸出結果。