机器学习入门:K-Means聚类算法

feature

聚类算法是机器学习和数据挖掘领域中的一种常用算法,用于进行数据分类,把不同的数据分到不同的群组,听起来没什么的,但是用途还是挺多的,公司可以对客户资料进行聚类来对不同的客户采用不同的商业模式,电商可以根据聚类来为你推荐相似的商品。学校可以对学生考试成绩聚类来看你是好学生还是差学生。这篇博客将会讲述一种简单的聚类算法,K-Means聚类算法。

  • 问题引出

先来一个简单的问题吧,初中知识,在一个二维坐标系中有很多的点,如下图

看图,我需要吧这些点分为三组,怎么分?这是一道送分题,人眼可以直观的看出些点可以分为三组:

那么问题来了,在知道有三组的点的情况下,如何让计算机去为这些点分组呢?

我传入点的坐标 (1,1),(1,2),(2,4),(10,10),(11,12),(11,13),(80,90),(82,94),(83,89)这九个点给计算机,怎么样设计一个算法得出 (1,1),(1,2),(2,4) 是一个组,(10,10),(11,12)是一个组,(80,90),(82,94),(83,89)是一个组呢?

这个时候就就要用到聚类算法了,首先对之后的关键词进行一下解释,我刚才提到的组,在算法中叫做“聚类”(Cluster)

  • 算法流程

这个算法过程很简单,我要把这堆点分成三组的话,首先生成三个随机的点,这三个随机点叫做质心,然后把各个点分配到离他最近的质心。三个质心最后肯定会形成三个组,每个组都有若干个被分配到的点,分组完成后,对每个组的点求平均位置,这个位置就是新的质心的位置,之后重复上面的步骤,根据新的质心再分组,然后再求平均,若干次后质心的位置可能不会再变了,这个现象称为收敛(Converge),然后分组结束。

算法之所以叫做K-Means算法,K代表要分成K个聚类,Means就是平均啦,刚才也提到了。

用一张图来直观表示一下,这张图是一本书上的,网上也到处都有这个图,我就不重绘了

这张图是吧A,B,C,D,E,5个点分成两个聚类,实心点是两个随机生成的点,迭代了4次完成聚类。

  • 定义距离度量标准

上文提到了,将每个点分配到最近的中心点,但是怎么知道每个点离最近的中心点近呢?假设中心点是(5,5)和(30,30),点(4,5)离哪个中心点近呢?当然是(5,5)啦,怎么算呢?初中知识,两点间距离公式。专业点的话,这个距离叫做欧几里得距离,之前的博客提到过可以去看看推荐系统入门之协作型过滤算法

这个距离叫做紧密度(Closeness),在这个计算点的应用中,欧式距离越小代表两个点越近,紧密度越高。在其他应用中还会有其他的距离度量标准,比如刚才那片博客,根据用户对不同电影的评分寻找相似用户的应用中就采用了皮尔逊相关系数来评估两个用户之间的距离,1代表紧密,-1代表没关系。

一定要注意距离度量的标准不同的应用应该采取不同的方法!

在这个给坐标系中的点分组的应用里,使用欧式距离,不能使用皮尔逊相关系数,即使我上篇博客说他很牛叉,皮尔逊相关系数代表的是趋势。

点(2,5)和(20,50),和(400,100)求皮尔逊相关系数的结果都是1,非常紧密,但实际上这三个点差的远着呢。

  • 程序设计

好了,理论够多了,看看代码怎么写吧,这里出现的所有代码在文章末尾都有Github链接可供下载。

首先是Distance类,集成了几个常用的计算距离的算法:

/**
 * Created by Mike on 12/9/2016.
 */
public class Distance {

    /**
     * 计算欧式距离
     * @param vector1
     * @param vector2
     * @return
     */
    public static double getSimDistance(MyVector vector1, MyVector vector2){

        double sumOfSquare = 0.0;

        for (int i=0;i<vector1.size();i++){

            double vector1Score = vector1.get(i);
            double vector2Score = vector2.get(i);
            sumOfSquare +=  Math.pow(vector1Score - vector2Score,2);

        }

        return Math.sqrt(sumOfSquare);
        //return (1 / (1 + Math.sqrt(sumOfSquare)));
    }


    /**计算皮尔逊相关系数
     * 返回 -1 ~ 1
     * -1-0为正相关
     * 0-1为正相关
     */

    public static double getPearsonDistance(MyVector vector1, MyVector vector2){

        double sum1 = 0.0;
        double sum2 = 0.0;
        double sum1Sq = 0.0;
        double sum2Sq = 0.0;
        double pSum = 0.0;

        for (int i =0;i<vector1.size();i++){
            sum1 += vector1.get(i);
            sum2 += vector2.get(i);

            sum1Sq += Math.pow(vector1.get(i),2);
            sum2Sq += Math.pow(vector2.get(i),2);

            pSum += vector1.get(i) * vector2.get(i);

        }
        int n = vector1.size();
        double num = pSum - ((sum1*sum2)/n);
        double den = Math.sqrt( (sum1Sq - Math.pow(sum1,2)/n) * (sum2Sq - Math.pow(sum2,2)/n) );

        if (den ==0){
            return 0;
        }else {
            return num/den;
        }

    }

}

Cluster类

这个类代表了一个聚类的抽象

维护了一个Vector的列表,这里面我用MyVector类重新封装了Java的Vector类,其实一个MyVector就是一个向量,当这个向量长度为2时,就是一个点啦。这样设计可以用来适配之后的应用,而不是只能计算点。

/**
 * 聚类
 * Created by Mike on 12/9/2016.
 */
public class Cluster {

    String tag = "";

    private ArrayList<MyVector> vectors = new ArrayList<>();
    private int eachVectorSize = 0;

    public ArrayList<MyVector> getVectors(){
        return vectors;
    }

    public void addToCluster(MyVector vector){
        this.vectors.add(vector);
        this.eachVectorSize = vector.size();
    }

    public void printCluster(boolean withTag){
        if (withTag){
            System.out.println(tag);
        }
        for (int i=0;i<vectors.size();i++){
            MyVector vector = vectors.get(i);
            System.out.print("Vector "+i+": ");
            vector.printVector();
        }
    }

    public void clearCluster(){
        this.vectors.clear();
    }

    /**
     * 计算簇的中点
     * @return
     */
    public MyVector getCenterVector(){
        //预先初始化定长Vector
        MyVector vector = new MyVector(eachVectorSize);

        for (MyVector tempVector:vectors){
            for (int i=0; i < tempVector.size();i++){
                Double original = vector.get(i);
                vector.set(i,original+tempVector.get(i));
            }
        }

        for (int i=0;i<vector.size();i++){
            vector.set(i,vector.get(i)/this.vectors.size());
        }

        return vector;
    }

}

然后是用来实现K-Means算法的类:

K-Means类

这个类看起来长一点实际上也很好理解

centers储存的是所有的中心点,vectors是所有的点,clusters用来储存所有的聚类。

第一次生成中心点的时候我没有随机生成点,而是选择了几个先有的点。

调用startClustering()函数开始聚类,这个函数中的lastCenters是用来储存上一次的中心点,每次生成新的中心点后会与lastCenters作比较,如果中心点两次完全一样的话代表收敛了,可以直接结束程序,聚类已经完成了。

import java.util.ArrayList;
import java.util.List;

/**
 * KMeans聚类算法
 * Created by Mike on 12/9/2016.
 */
public class KMeans {

    private List<MyVector> vectors = new ArrayList<>();
    private List<MyVector> centers = new ArrayList<>(); //质心

    private int numberOfCluster = 0;
    private List<Cluster> clusters = new ArrayList<>(); //储存所有的族

    private int numberOfIteration = 100;

    public KMeans(List<MyVector> vectors,int numberOfCluster){
        this.vectors = vectors;
        this.numberOfCluster = numberOfCluster;
        initCenters();
        initClusters();
    }

    public List<Cluster> getClusters(){
        return clusters;
    }

    private void initClusters(){
        clusters.clear();
        //预先初始化所有的簇
        for (int i=0;i<numberOfCluster;i++){
            clusters.add(new Cluster());
        }
    }


    public void setNumberOfIteration(int numberOfIteration){
        if (numberOfIteration > 0){
            this.numberOfIteration = numberOfIteration;
        }else {
            System.out.println("numberOfIteration should be greater than 0");
        }
    }


    public void startClustering(){

        System.out.println("开始聚类");

        int counter = 0;
        List<MyVector> lastCenters = new ArrayList<>();

        boolean converged = false;

        while (!converged && counter < numberOfIteration){

            System.out.println("第"+counter+"次迭代");
            
            double[][] distanceMatrix = new double[vectors.size()][numberOfCluster];

            //生成距离矩阵
            for (int i=0;i<vectors.size();i++){
                for (int j=0;j<centers.size();j++){
                    MyVector currentVector = vectors.get(i);
                    MyVector centerVector = centers.get(j);
                    double distance = Distance.getSimDistance(centerVector,currentVector);
                    distanceMatrix[i][j] = distance;
                }
            }

            //add vectors to different clusters
            for (int i=0;i<distanceMatrix.length;i++){
                double[] centerDistance = distanceMatrix[i];
                MyVector vector = vectors.get(i);
                int index = getMinDistanceIndex(centerDistance);
                clusters.get(index).addToCluster(vector);
            }

            counter++;


            lastCenters.clear();
            lastCenters.addAll(centers);

           // printClusters();

            //Refresh centers
            for (int i=0;i<numberOfCluster;i++){
                MyVector vector = clusters.get(i).getCenterVector();
                centers.set(i,vector);
            }

            converged = isConverged(lastCenters);
            if (!converged && counter != numberOfIteration){
                initClusters();
            }


        }

        System.out.println("聚类完成\n迭代次数"+counter);

      //  printClusters();

    }

    /**
     * 检测是否收敛(中心点和上次比是否变化)
     * @param lastCenters
     * @return
     */
    private boolean isConverged(List<MyVector> lastCenters){

        if (lastCenters.size() != numberOfCluster){
            return false;
        }

        boolean converge = true;

        for (int i=0;i<this.centers.size();i++){

            MyVector thisVector = this.centers.get(i);
            MyVector thatVector = lastCenters.get(i);

            if (!thisVector.isSameVector(thatVector)){
                converge = false;
            }

        }

        if (converge){
            System.out.println("检测到收敛");
        }

        return converge;

    }

    /**
     * 得到数组中最小值的下标
     * @param arr
     * @return
     */
    private int getMinDistanceIndex(double[] arr){

        double min = 99999999999999999999999.0;
        int index = 0;
        for (int i=0;i<arr.length;i++){
            if (arr[i] < min){
                index = i;
                min = arr[i];
            }
        }

        return index;
    }

    /**
     * 打印二维数组
     * @param mat
     */
    private void printMatrix(double[][] mat){

        int height = mat.length;
        int width = mat[0].length;

        for (int i= 0;i< height;i++){
            for (int j=0;j<width;j++){
                System.out.print(mat[i][j] + "\t");
            }
            System.out.println();
        }
    }

    /**
     * 打印中心点
     */
    private void printCenters(){
        System.out.println("Printing Centers");
        for (MyVector vector:centers){
            vector.printVector();
        }
    }

    /**
     * 打印所有聚类
     */
    private void printClusters(){
        for (Cluster cluster:clusters){
            cluster.printCluster(false);
            System.out.println();
        }

    }

    /**
     * 初始质心
     * 从所有的向量中挑选 numberOfCluster 个
     */
    private void initCenters(){

        int sizeOfVectors = vectors.size();

        for (int i=0;i<numberOfCluster;i++){
            int index= (int)(Math.random()*sizeOfVectors);
            centers.add(vectors.get(index));
        }

    }

}

 

好了现在来试一试效果吧:

简单粗暴的加入这么多点,需要生成3组聚类;

接下来运行:

可能不太明显,但是还是能看出程序在不断迭代来逼近正确的分组。

  • 优点

优点嘛,速度快,而且整个算法很简单。

  • 缺点

说下这个算法的缺点吧,第一个当然就是聚类个数要人为规定,这是最大的一个缺点,因为在大数据中很多时候人们是不知道有多少个聚类的,所以就得乱猜。不过已经有K-Means+算法能解决这个问题。

第二个就是有可能会聚类失败,看上面的例子,一开始随机选择的点中,有两个是一组的,1个是另外一组的,如果一开始的三个点全都是一组的可能会造成聚类失败。

第三点是可能会受到单独点的干扰,比如有个点是(1000,1000)这个点是唯一的,没有点离他近,算法可能无法判断然后分到其他组中。

  • 应用

好了,写了这么多,是不是觉得给坐标点聚类太过于无聊,那咱们来玩个有聊的,给你一张图片,提取他的主色,这个应用也挺广泛的。比如iTunes11的专辑列表:

好了话不多说咱们开始思考怎么弄吧:

当然是聚类了,可是这次不再是二维坐标点了,使用每个像素颜色的R,G,B值生成一个三维向量,然后在计算每个向量的相似度作为距离比较,这里使用欧氏距离还是合适的,当然也可以使用其他度量标准,比如,余弦相似度:

其实就是高中的余弦定理公式,用来求两个向量夹角的,夹角越小当然向量相似度越高啦,上面的公式吧向量拓展到了n维度。在这里只是提一下。

所以这个应用完全可以使用刚才的代码,初始化MyVector的时候传入三个元素就代表一个三维向量了。

ThemeColorPicker类

这个类用来读取一个图片,然后读取每个像素的R,G,B值然后生成一个MyVector的列表,然后使用刚才设计的KMeans类进行聚类就行啦,完事之后我还写了个GUI来测试。

package ColorPicker;

import Algo.Cluster;
import Algo.KMeans;
import Algo.MyVector;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
 * Created by Mike on 12/9/2016.
 */
public class ThemeColorPicker {

    private BufferedImage image;

    List<MyVector> vectors;

    private String path;

    public void setColorNumber(int colorNumber) {
        this.colorNumber = colorNumber;
    }

    private int colorNumber = 5;

    public ThemeColorPicker(String path){
        image = FileUtil.loadImg(path);
        vectors = generateColorVectors();
        this.path = path;
    }

    public void getThemeColor(){
        
        KMeans kMeans = new KMeans(vectors,colorNumber);
        kMeans.startClustering();
        List<Cluster> clusters = kMeans.getClusters();

        List<MyVector> vectors = new ArrayList<>();


        for (Cluster cluster:clusters){
            MyVector vector  =  cluster.getCenterVector();
            vectors.add(vector);
            System.out.println(rgbToHex(vector));
        }

        new PaletteGUI(vectors,path);

    }

    private String rgbToHex(MyVector vector){
        int r = vector.get(0).intValue();
        int g = vector.get(1).intValue();
        int b = vector.get(2).intValue();
        String hex = String.format("#%02x%02x%02x", r, g, b);
        return hex;
    }

    private List<MyVector> generateColorVectors(){
        List<MyVector> vectors = new ArrayList<>();

        for (int i = 0;i<image.getWidth();i++){
            for (int j=0;j<image.getHeight();j++){
                double r = getR(i,j);
                double g = getG(i,j);
                double b = getB(i,j);

                vectors.add(new MyVector(new double[]{r,g,b}));

            }
        }

        return vectors;

    }


    private double getR(int x,int y){
        int rgb = image.getRGB(x, y);
        int r = (rgb & 0xff0000) >> 16;
        return r;
    }

    private double getG(int x,int y){
        int rgb = image.getRGB(x, y);
        int g = (rgb & 0xff00) >> 8;
        return g;
    }

    private double getB(int x,int y){
        int rgb = image.getRGB(x, y);
        int b = (rgb & 0xff);
        return b;
    }

    public static void main(String[] args){
        ThemeColorPicker picker = new ThemeColorPicker("/Users/Mike/Desktop/1.jpg");
        picker.setColorNumber(5);
        picker.getThemeColor();
    }

}

class FileUtil{
    public static BufferedImage loadImg(String path){
        File imgFile = new File(path);
        try {
            return ImageIO.read(imgFile);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

运行以下代码:

设置生成5个主色

看看控制台输出:

迭代了30次收敛,当设置了需要更多聚类的时候迭代次数会增加的,而且迭代次数是不固定的。

看看效果:

所有的代码可以在我的Github中下载到:

https://github.com/Yigang0622/K-Means

 

我的博客MikeTech app现已登陆iPhone和Android

iPhone版下载
Android版下载

打赏

Leave a Reply

Your email address will not be published. Required fields are marked *