如果说世界上只能存在一种基于密度的聚类算法的话,那么它必须是DBSCAN(Density-based spatial clustering of applications with noise)。DBSCAN作为基于密度聚类算法的典型,相对于Kmeans,最大优点是可以自己决定聚类数量,同时可以过滤一些噪点,但相对的,对传入的参数较为敏感,并且参数调优全靠经验。
一、算法
对于算法部分只做一些”感性“的分析,具体算法的理论证明以及更精确的形式化描述参考Wiki:http://en.wikipedia.org/wiki/DBSCAN
DBSCAN算法相对于简单,只要弄清几个概念,算法本身是水到渠成的。
(1)几个变量
领域半径e,最小数目minOpt
(2)几个名词
核心对象:若一个对象其领域半径e内的对象数量大于等于minOpt,则称该对象为核心对象。
直接密度可达:若一个核心对象p,其领域半径内有若干点q,则对于每一个q有q从对象p直接密度可达。
(3)算法流程
主流程:输入e,minOpt以及对象集合n
I、找到一个未标记的核心对象k,并设此对象为已标记,若找不到核心对象直接退出
II、扩展此核心对象,expand(k)
III、若所有对象均已标记,则退出,否则转I
expand流程:输入核心对象k
I、初始化一个集合S,放入k
II、遍历该集合元素,对于集合中每一个核心对象,找到其所有未标记的的密度可达对象,放入集合S,并设为已标记
III、若II没有加入任何新对象,则退出,否则转II
在分析Weka的实现时,除了代码本身,着重关心以下几点:
(1)是否使用了特殊的数据结构来提高效率
(2)缺失值的处理
(3)噪声的处理
(4)其它实现技巧
(5)和原始DBSCAN不同之处
二、SequentialDatabase类
在分析具体的buildClusterer方法之前,先分析SequentialDatabase类,该类是DBSCAN方法用到的一个辅助类,封装一个instance并暴露一些定制的查询操作。
(1)epsilonRangeQuery,该函数用于查找离一个给定对象queryDataObject距离epsilon之内的所有对象
public List epsilonRangeQuery(double epsilon, DataObject queryDataObject) { ArrayList epsilonRange_List = new ArrayList(); Iterator iterator = dataObjectIterator(); while (iterator.hasNext()) { DataObject dataObject = (DataObject) iterator.next(); double distance = queryDataObject.distance(dataObject);//默认的,距离计算器是欧式距离 if (distance < epsilon) { epsilonRange_List.add(dataObject); } } return epsilonRange_List; }可以看出该函数遍历了所有的对象,因此时间复杂度为O(n)
public List k_nextNeighbourQuery(int k, double epsilon, DataObject dataObject) { Iterator iterator = dataObjectIterator(); List return_List = new ArrayList(); List nextNeighbours_List = new ArrayList(); List epsilonRange_List = new ArrayList(); PriorityQueue priorityQueue = new PriorityQueue(); while (iterator.hasNext()) { DataObject next_dataObject = (DataObject) iterator.next(); double dist = dataObject.distance(next_dataObject); if (dist <= epsilon) epsilonRange_List.add(new EpsilonRange_ListElement(dist, next_dataObject)); if (priorityQueue.size() < k) { priorityQueue.add(dist, next_dataObject); } else { if (dist < priorityQueue.getPriority(0)) { priorityQueue.next(); //把最大距离的移除,来实现一个固定长度的队列 priorityQueue.add(dist, next_dataObject); } } } while (priorityQueue.hasNext()) { nextNeighbours_List.add(0, priorityQueue.next());//将优先队列写到list中,每次都添加到index0可以看出这个List是个升序list。 } return_List.add(nextNeighbours_List); return_List.add(epsilonRange_List); return return_List; }这个函数的设计必须吐槽:第一基于约定的编程,约定了Index0和index1的数据,并且还约定了其中的list所存储的对象,还约定了优先队列中元素升序排列,使得这个函数重用性及其之低。第二和epsilonRangeQuery相比有部分重复的地方(但又不能调用epsilonRangeQuery,因为调用了相当于所有对象遍历两次)。
(3)coreDistance,该函数不仅返回了上面函数的list,还添加了index3为离得最远的并且小于epsilon的对象。
public List coreDistance(int minPoints, double epsilon, DataObject dataObject) { List list = k_nextNeighbourQuery(minPoints, epsilon, dataObject); if (((List) list.get(1)).size() < minPoints) { list.add(new Double(DataObject.UNDEFINED)); return list; } else { List nextNeighbours_List = (List) list.get(0); PriorityQueueElement priorityQueueElement = (PriorityQueueElement) nextNeighbours_List.get(nextNeighbours_List.size() - 1); if (priorityQueueElement.getPriority() <= epsilon) { list.add(new Double(priorityQueueElement.getPriority())); return list; } else { list.add(new Double(DataObject.UNDEFINED)); return list; } } }
三、buildClusterer
接着从buildClusterer说起,该函数是所有聚类器的入口,用于使用已知样本训练一个聚类器。
函数本身是比较简单的。
public void buildClusterer(Instances instances) throws Exception { // 先测一下这个Instance能否用dbscan进行聚类,dbscan几乎可处理所有的类型(枚举、日期、数值、missingValue) getCapabilities().testWithFail(instances); long time_1 = System.currentTimeMillis(); processed_InstanceID = 0; numberOfGeneratedClusters = 0; clusterID = 0; replaceMissingValues_Filter = new ReplaceMissingValues(); replaceMissingValues_Filter.setInputFormat(instances); Instances filteredInstances = Filter.useFilter(instances, replaceMissingValues_Filter); database = databaseForName(getDatabase_Type(), filteredInstances); for (int i = 0; i < database.getInstances().numInstances(); i++) { DataObject dataObject = dataObjectForName(getDatabase_distanceType(), database.getInstances().instance(i), Integer.toString(i), database); database.insert(dataObject);//插入到数据库 } database.setMinMaxValues(); Iterator iterator = database.dataObjectIterator(); while (iterator.hasNext()) {//对于所有节点进行迭代并不是最高效的,如果使用一个变量记录当前unclassfied的数量,当为0的时候直接退出更为高效一些,虽然时间复杂度没有变化。 DataObject dataObject = (DataObject) iterator.next(); if (dataObject.getClusterLabel() == DataObject.UNCLASSIFIED) { if (expandCluster(dataObject)) {//如果某个点未标记,则尝试进行扩展 clusterID++; numberOfGeneratedClusters++; } } } long time_2 = System.currentTimeMillis(); elapsedTime = (double) (time_2 - time_1) / 1000.0;//很奇怪,weka的实现具有不同的编程风格,起码以往的聚类器或者分类器,并没有直接在训练函数中来计算所用时间。 }
扩展核心节点为一个簇的主函数,若成功扩展返回true,否则返回false,如下:
private boolean expandCluster(DataObject dataObject) { List seedList = database.epsilonRangeQuery(getEpsilon(), dataObject);//该函数寻找给定对象距离epsilon以内的对象 if (seedList.size() < getMinPoints()) { dataObject.setClusterLabel(DataObject.NOISE);//如果是非核心对象,暂时设置为noise,之后如果不能被核心对象聚类到就一直是noise了。 return false; } //走到这里都是核心对象 for (int i = 0; i < seedList.size(); i++) { DataObject seedListDataObject = (DataObject) seedList.get(i); seedListDataObject.setClusterLabel(clusterID);//所有seedList里的对象都从属于clusterID,这个clusterID是一个自增量 if (seedListDataObject.equals(dataObject)) { seedList.remove(i);//注意epsilonRangeQueryList会把参数对象本身也放进去,所以这里要移除 i--; } } for (int j = 0; j < seedList.size(); j++) { DataObject seedListDataObject = (DataObject) seedList.get(j); List seedListDataObject_Neighbourhood = database.epsilonRangeQuery(getEpsilon(), seedListDataObject); //对于seedList中每一个元素都寻找其领域内的元素 if (seedListDataObject_Neighbourhood.size() >= getMinPoints()) { for (int i = 0; i < seedListDataObject_Neighbourhood.size(); i++) {//走到这个循环内说明是核心对象 DataObject p = (DataObject) seedListDataObject_Neighbourhood.get(i); if (p.getClusterLabel() == DataObject.UNCLASSIFIED || p.getClusterLabel() == DataObject.NOISE) {<span style="white-space:pre"> </span> if (p.getClusterLabel() == DataObject.UNCLASSIFIED) { seedList.add(p);//如果是未分类的,就加到seedList中,这里使用了unclassified来保证不会添加重复,而且nosie不添加是因为noise肯定不是核心对象(本函数开头逻辑保证)这也算是一个trick,使用了一个list加下标起到了set的效果,如果让我来实现估计我会直接用set吧 } p.setClusterLabel(clusterID);//设置成相应的聚类 } } } seedList.remove(j);//不是很明白这里为啥要remove,按理说遍历之后不会再访问没必要删除了,或许为了节省内存,也或许是作者强迫症(这段代码的作者貌似不喜欢用迭代器,并且多次使用基于下标的删除,在java中这并不是一个很优雅的编程方式,虽然我也经常这么用) j--; } return true; }
buildClusterer函数主循环为n,expandCluster函数对list中每个元素调用eplisonRangeQuery,因此是n^2,总结来看是整个算法是n^3,并不是很高效。
优化点:
buildClusterer并不能产生优于O(n)的优化,但可以使用计数器记录未标记的数量来提高一些效率,expandCluster也没什么优化点,但eplisonRangeQuery起码有两个地方可以优化,第一个是使用KDTree(就像Xmean算法一样,参见之前的博客)来更有效寻找离给定点距离最近的距离,其次是使用Cache来缓存一些给定点对的距离,因为考虑到相同的点在程序中其实是被计算了多次的。
六、clusterInstance
这个函数接收一个instance作为参数,理应返回该instance从属的cluster,但DBSCAN貌似并没有这么做。
public int clusterInstance(Instance instance) throws Exception { if (processed_InstanceID >= database.size()) processed_InstanceID = 0; int cnum = (database.getDataObject(Integer.toString(processed_InstanceID++))).getClusterLabel(); if (cnum == DataObject.NOISE) throw new Exception(); else return cnum; }依次返回的是id为0,1,2的用例的下标,不知道这么做的用意何在。
而且如果是个noise直接抛出异常,并且根本就不说明为啥抛这个异常。
整个函数意义不明。
七、总结
如果非要写个总结的话,那么我个人对于这段代码是比较失望的,无论是一些函数抽象的设计,数据结构的设计,Java代码风格,都有一种浓浓的”业余“的味道,和之前分类器整洁的代码相比完全是判若两人(好吧本来也不是一个人写的)。
除此之外最后的clusterInstance的行为和注释完全不符,不知道是个bug还是feature还是其它什么原因导致的。