# Kmeans聚类过程的动态可视化 By

  1. [Matthew](http://kuailenianhua.github.io/)
  2. <br />
  3. 05/16/2015
  4. 更新日期:05/16/2015

Kmeans聚类过程的动态可视化 | Matthew's blog - 图1 最近在做一个推荐系统的时候,我们采用的方法是基于SVD的K-means聚类协同过滤算法,其中在实现Kmeans聚类算法的时候参考了一篇文章,里面给出了算法代码,并且很有新意的把最终的聚类结果以散点图的形式展示了一下。于是昨天我突发奇想,可不可以把整个Kmeans聚类过程可视化出来,这样还能更好的帮助我们理解Kmeans具体的过程细节,听起来很有意思,有了想法想尽快实现它,在午饭的时候还一直思考着该如何下手。由于很久没用过d3了,有了想法还得一边上网查d3的语法,看来还得好好学一下了。昨天在查阅了d3比例尺坐标轴等资料后,终于在晚上实现了动态可视化,真是太激动了!

Kmeans算法介绍

k-means算法是一种很常见的聚类算法,它的基本思想是:通过迭代寻找k个聚类的一种划分方案,使得用这k个聚类的均值来代表相应各类样本时所得的总体误差最小。
k-means算法的基础是最小误差平方和准则。其代价函数是:
Kmeans代价函数
式中,μc(i)表示第i个聚类的均值。我们希望代价函数最小,直观的来说,各类内的样本越相似,其与该类均值间的误差平方越小,对所有类所得到的误差平方求和,即可验证分为k类时,各聚类是否是最优的。
上式的代价函数无法用解析的方法最小化,只能有迭代的方法。k-means算法是将样本聚类成 k个簇(cluster),其中k是用户给定的,其求解过程非常直观简单,具体算法描述如下:
1、随机选取 k个聚类质心点
2、重复下面过程直到收敛 {
对于每一个样例 i,计算其应该属于的类:
计算样例所属类
对于每一个类 j,重新计算该类的质心:
计算质心
}
下面用文字描述一下Kmeans的伪代码:

  1. 创建k个点作为初始的质心点(随机选择)
  2. 当任意一个点的簇分配结果发生改变时
  3. 对数据集中的每一个数据点
  4. 对每一个质心
  5. 计算质心与数据点的距离
  6. 将数据点分配到距离最近的簇
  7. 对每一个簇,计算簇中所有点的均值,并将均值作为质心

下面上Kmeans聚类用js实现的代码:
在Kmeans聚类中,我们一般使用欧氏距离作为质心与数据点的误差度量,因此我们在定义一个距离函数:

  1. function euclDistance(vector1, vector2) {
  2. var dx = vector1.x - vector2.x;
  3. var dy = vector1.y - vector2.y;
  4. return Math.sqrt(dx*dx + dy*dy);
  5. }

按照第一步,我们首先要随机选取k个质心,k就是我们要聚类的类的个数,data是数据点的数组,会在下一节中提到。下面看代码:

  1. function initCentroids(data, k) {
  2. var centroids = new Array();
  3. var indices = [];
  4. var i = 0;
  5. while(i < k) {
  6. var index = getRandomNum(0, length);
  7. if(contains(indices, index)) {
  8. continue;
  9. } else {
  10. indices.push(index);
  11. i++;
  12. var node = {};
  13. node.x = data[index].x;
  14. node.y = data[index].y;
  15. node.index = index;
  16. centroids.push(node);
  17. }
  18. }
  19. console.log(centroids);
  20. return centroids;
  21. }

这里用到的getRandomNum函数是用来获取0~length-1之间的随机数的,代码见下:

  1. function getRandomNum(min, max) {
  2. var range = max - min;
  3. var rand = Math.random();
  4. return(min + Math.floor(rand * range));
  5. }

并且为了保证在随机选取质心的时候不会重复选择,我们需要确保每次获取的随机数并没使用过,于是我们用一个数据保存已经使用了的随机数,然后每次检查一下数组是否包含了(contains)得到的新随机数:

  1. function contains(arr, obj) {
  2. var i = arr.length;
  3. while (i--) {
  4. if (arr[i] === obj) {
  5. return true;
  6. }
  7. }
  8. return false;
  9. }

第二步,重复计算知道收敛,即所有数据点所属类别不再变化。代码(代码中出现的画图函数将会在下一节中描述):

  1. function kmeans(data, k) {
  2. var centroids = initCentroids(data, k);
  3. var clusterChanged = true;
  4. var clusterAssment = [];
  5. var clusters = [];
  6. showNodes(data); //画数据的散点图
  7. showCentroids(centroids); //画质心
  8. for(var i=0; i<k; i++) {
  9. clusters.push(new Array());
  10. }
  11. for(var i=0; i<length; i++) {
  12. clusterAssment.push(-1);
  13. }
  14. while(clusterChanged) {
  15. console.log("kmeans!!!");
  16. clusters = [];
  17. for(var i=0; i<k; i++) {
  18. clusters.push(new Array());
  19. }
  20. clusterChanged = false;
  21. for(var i=0; i<length; i++) {
  22. var minDistance = 10000;
  23. var cluster = -1;
  24. for(var j=0; j<k; j++) {
  25. var distance = euclDistance(centroids[j], data[i]);
  26. if(distance < minDistance) {
  27. minDistance = distance;
  28. cluster = j;
  29. }
  30. }
  31. if(cluster != clusterAssment[i]) {
  32. clusterChanged = true;
  33. clusterAssment[i] = cluster;
  34. changeCluster(i, cluster); //数据所属类别变化
  35. }
  36. }
  37. for(var i=0; i<length; i++) {
  38. clusters[clusterAssment[i]].push(data[i]);
  39. }
  40. for(var i=0; i<k; i++) {
  41. var sumX = 0;
  42. var sumY = 0;
  43. var len = clusters[i].length;
  44. for(var j=0; j<len; j++) {
  45. sumX += clusters[i][j].x;
  46. sumY += clusters[i][j].y;
  47. }
  48. centroids[i].x = sumX / len;
  49. centroids[i].y = sumY / len;
  50. changeCentroid(centroids, i); //质心改变,重新画
  51. }
  52. }
  53. alert("kmeans will be completed in " + delay/1000 +" seconds!");
  54. setTimeout("alert('Kmeans is completed!')", delay);
  55. }

动态可视化

也是因为好久没用过d3,想借助这个小例子在复习一下,所以可视化部分使用了d3.js。
首先是数据,为了方便可视化,在这里我使用了二维数据data.csv,格式如下:

  1. x,y
  2. 1.658985,4.285136
  3. 3.453687,3.424321
  4. 4.838138,1.151539
  5. 5.379713,3.362104
  6. 0.972564,2.924086
  7. 3.567919,1.531611

可视化第一步,我们首先要把数据加载进来:

  1. d3.csv("data.csv",function(error, data){
  2. }

这样我们得到的data就是一个字典的数组,例如data[0].x就是1.658985啦。
下面正式进入可视化部分,开工!
创建svg,以及比例尺和坐标轴:

  1. var w = 1000;
  2. var h = 600;
  3. var xPadding = 200;
  4. var yPadding = 50;
  5. var xAxisHeight = 500;
  6. var xAxisWidth = 600;
  7. var edge = 15;
  8. var radius = 5;
  9. var delay = 0;
  10. var chart = d3.select("body")
  11. .append("svg")
  12. .attr("width", w)
  13. .attr("height", h);
  14. var colors = ["red", "blue", "yellow", "green"];
  15. var xScale = d3.scale.linear()
  16. .domain([0, d3.max(data, function(d) { return d.x; })])
  17. .range([xPadding, xPadding + xAxisWidth]);//x轴
  18. var yScale = d3.scale.linear()
  19. .domain([0, d3.max(data, function(d) { return d.y; })])
  20. .range([xAxisHeight, yPadding]);//y轴
  21. var xAxis = d3.svg.axis()
  22. .scale(xScale)
  23. .orient("bottom");
  24. var yAxis = d3.svg.axis()
  25. .scale(yScale)
  26. .orient("left");
  27. chart.append("g")
  28. .attr("class","axis")
  29. .attr("transform","translate("+0+","+xAxisHeight+")")
  30. .call(xAxis);
  31. chart.append("g")
  32. .attr("class","axis")
  33. .attr("transform","translate("+xPadding+","+0+")")
  34. .call(yAxis);

接下来我们需要做的就是绘制所有数据点以及初始质心,然后在发生变化的时候用动画的形式重新绘制就可以啦。
画数据点,初始默认都是黑色的小圆点:

  1. function showNodes(data) {
  2. chart.selectAll(".circle")
  3. .data(data)
  4. .enter()
  5. .append('circle')
  6. .attr("class", function(d, i) { return "node"+data[i].index; })
  7. .attr("cx", function(d) {
  8. return xScale(d.x);
  9. })
  10. .attr("cy", function(d) {
  11. return yScale(d.y);
  12. })
  13. .attr("r", function(d) {
  14. return radius;
  15. });
  16. }

画初始随机的质心,不同质心用不同颜色区分,颜色数组见上面变量声明部分:

  1. function showCentroids(centroids) {
  2. chart.selectAll(".circle")
  3. .data(centroids)
  4. .enter()
  5. .append('rect')
  6. .attr("class", function(d, i) { return "cluster"+i; })
  7. .attr("fill", function(d, i) { return colors[i]; })
  8. .attr("x", function(d) {
  9. return xScale(d.x)-edge/2;
  10. })
  11. .attr("y", function(d) {
  12. return yScale(d.y)-edge/2;
  13. })
  14. .attr("width", function(d) {
  15. return edge;
  16. })
  17. .attr("height", function(d) {
  18. return edge;
  19. });
  20. }

本例子中最关键的一步,也就是在发生变化的时候如何动态的展示,先看代码:

  1. function changeCluster(nodeId, clusterId) {
  2. chart.select(".node"+nodeId)
  3. .transition()
  4. .duration(100)
  5. .delay(delay)
  6. .attr("fill", colors[clusterId]);
  7. delay+=100;
  8. }
  9. function changeCentroid(centroids, i) {
  10. chart.select(".cluster"+i)
  11. .transition()
  12. .duration(1000)
  13. .delay(delay)
  14. .attr("x", function() {
  15. return xScale(centroids[i].x)-edge/2;
  16. })
  17. .attr("y", function(d) {
  18. return yScale(centroids[i].y)-edge/2;
  19. });
  20. delay += 1000;
  21. }

其中最关键的一个变量是delay,我们为每个动画设置duration,然后为之后的动画增加delay。
当一个数据点所属类别发生变化时,我们只需要改变这个点的颜色即可,工作量很小,所以我们为它设置duration为0.1s,相应的delay也要增加0.1s,这样便使得之后的动画开始的时间是在该动画结束的那一刻。当一个类的质心发生变化时,为了有更好的显示效果,我们可以让这个质心点在1s的时间间隔里从原来的位置平移到新位置,同理,设置duration为1000,同时delay增加1000.
注:由于数据点顺序随机,这样按照数据点的顺序变换节点的颜色的时候会随机变化,我们可以事先对数据点按照其距离原点的远近进行排序,这样再按照顺序变化节点的颜色时就会出现从左下角逐渐到右上角的趋势,利于观察。

  1. data.sort(sortByNorm);
  2. function sortByNorm(vector1, vector2) {
  3. var norm1 = vector1.x*vector1.x + vector1.y*vector1.y;
  4. var norm2 = vector2.x*vector2.x + vector2.y*vector2.y;
  5. return norm1 - norm2;
  6. }

大功告成!我们终于可以看一下效果啦:

  1. kmeans(data, 4);

可视化效果:
初始时:
Kmeans聚类过程的动态可视化 | Matthew's blog - 图2
聚类完成后:
Kmeans聚类过程的动态可视化 | Matthew's blog - 图3
这是本例的链接:Kmeans聚类过程的动态可视化

参考资料

  1. 机器学习算法与Python实践之(五)k均值聚类(k-means)
  2. 王伟, 杨宁, 李丽华, 等. 基于 SVD 的 K-means 聚类协同过滤算法[J]. 微计算机信息, 2012, 8: 058.