基于MapReduce实现的Kmeans算法(非调库)

简单基于MapReduce实现了下KMeans。

算法思路

KMeans算法作为一种划分式的聚类算法,利用MapReduce进行实现的主要难点在于满足KMeans每次迭代划分过程的中间结果保存。
因此利用HDFS进行中心点的存储,以实现各节点间的数据共享。
基于MapReduce的KMeans算法流程如下:

  1. 随机分配簇,初始化中心点,存入HDFS。
  2. Mapper中读取数据文件中的每条数据并与中心点进行距离计算,输出key为最近的中心点序号。
  3. Reducer中进行归并,计算新的中心点,存入新的中心文件。
  4. 判断停机条件,不满足则复制新的中心文件到原中心文件,重复2,3步骤。
  5. 输出聚类结果,包括数据点信息与对应簇序号。

初始化中心点

利用Mapper读取每一个元素的向量信息,随机赋值,在Reducer中计算中心点信息。由于中心点的计算与迭代时的计算相同,与迭代计算共用一个reducer类。

CenterRandomMapper

随机赋值的mapper类。

    protected void setup(Context context) throws IOException, InterruptedException {
        // 读取k值
        Configuration configuration = context.getConfiguration();;
        k = configuration.getInt("cluster.k", 3);
    }

setup中读取配置的聚类簇数量。

	protected void map(Object key, Text value, Context context) throws IOException, InterruptedException {
        // 随机分配簇
        int index = (int) (Math.random() * k);
        System.out.println(index);
        context.write(new Text(Integer.toString(index)), value);
    }

map方法中,根据聚类簇数量,对每个元素赋予随机的类簇序号,作为输出的key。value为元素向量,保持不变。

CenterRandomAdapter

初始化中心点的任务配置类,实现一个static方法。

	public static void createRandomCenter(String dataPath, String centerPath, int k){
        Configuration hadoopConfig = new Configuration();
        hadoopConfig.setInt("cluster.k", k);
        try {
            Job job = Job.getInstance(hadoopConfig, "random center task");

            job.setJarByClass(KmeansRun.class);
            job.setMapperClass(CenterRandomMapper.class);
            job.setReducerClass(KmeansReducer.class);

            job.setOutputKeyClass(Text.class);
            job.setOutputValueClass(Text.class);

            job.setMapOutputKeyClass(Text.class);
            job.setMapOutputValueClass(Text.class);

            // 输出为新计算得到的center,已存在则删除
            Path outPath = new Path(centerPath);
            outPath.getFileSystem(hadoopConfig).delete(outPath, true);

            //job执行作业时输入和输出文件的路径
            FileInputFormat.addInputPath(job, new Path(dataPath));
            FileOutputFormat.setOutputPath(job, new Path(centerPath));

            //执行job,直到完成
            job.waitForCompletion(true);
            System.out.println("random center task");
        }
}

该方法包含三个参数,分别为数据文件地址,中心点文件地址以及聚类数。首先在配置中设置聚类数,方便mapper中进行读取。
设置对应的mapper和reducer类以及输入输出格式,需要注意的是reducer使用了KmeansReducer类,即正式迭代时计算中心点的reducer。由于HDFS不能直接进行同名文件的覆盖,所以在每次生成新的中心点文件时,需要判断是否已经存在同名文件,存在则删除。

分配元素对应簇,计算中心点

利用Mapper将所有元素与中心点进行对比,分配到最近的簇中。利用Reducer进行求和并计算新的中心点信息。

KmeansMapper

    private ArrayList<ArrayList<Double>> centers = null;
    @Override
    protected void setup(Context context) throws IOException, InterruptedException {
        // 读一下centers
        // 地址从配置中拿好了
        Configuration configuration = context.getConfiguration();
        String centerPath = configuration.get("cluster.center_path");
        centers = DataUtil.readCenter(centerPath);}
	在mapper执行前,利用setup方法进行中心点的读取。DataUtil为工具类,readCenter()实现对HDFS中center文件夹中所有文件信息的读取。

	public static ArrayList<ArrayList<Double>> readCenter(String centerPath) throws IOException {
        ArrayList<ArrayList<Double>> centers = new ArrayList<ArrayList<Double>>();

        Path path = new Path(centerPath);
        Configuration conf = new Configuration();
        FileSystem fileSystem = path.getFileSystem(conf);

        if(fileSystem.isDirectory(path)){
            // 文件夹,遍历读取
            FileStatus[] listFile = fileSystem.listStatus(path);
            for (FileStatus fileStatus : listFile) {
                LineReader lineReader = getLineReader(fileStatus.getPath().toString());
                readCenterLines(lineReader, centers);
            }
        }else {
            // 普通文件,直接读取
            LineReader lineReader = getLineReader(centerPath);
            readCenterLines(lineReader, centers);
        }
        return centers;
    }

判断地址属性,对文件夹进行遍历,利用LineReader进行所有文件信息的读取,最终返回二维double集合形式的中心点信息。

	protected void map(Object key, Text value, Context context) throws IOException, InterruptedException {
        ArrayList<Double> element = DataUtil.splitStringIntoArray(value.toString());
        // 选择最近中心点,将其作为key
        int index = CalUtil.selectNearestCenter(element, centers);
        context.write(new Text(Integer.toString(index)), value);
    }

map方法中进行邻近中心点的选择,将其对应的序号作为key进行输出。CalUtil为计算工具类,selectNearestCenter()实现了最近中心点的获取。

	public static int selectNearestCenter(ArrayList<Double> element, ArrayList<ArrayList<Double>> centers){
        double minDis = 100000;
        int nearstIndex = 0;
        for(int i=0;i<centers.size();i++){
            ArrayList<Double> center = centers.get(i);
            double dis = calDistance(element, center);
            if(dis < minDis){
                minDis = dis;
                nearstIndex = i;
            }
        }
        return nearstIndex;
    }

遍历进行最小值的选取,本此实现中calDistance()实现欧式距离的计算。

KMeansReducer

	protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException {
        ArrayList<Double> sumElement = new ArrayList<Double>();
        int num = 0;
        // 遍历values相加,求新中心点
        for(Text t:values){
            num += 1;
            ArrayList<Double> element = DataUtil.splitStringIntoArray(t.toString());
            if(sumElement.size() <= 0){
                sumElement = new ArrayList<Double>(element);
                continue;
            }
            CalUtil.addElement(sumElement, element);
        }
        CalUtil.calCenter(num, sumElement);
        // 存放新中心点
        context.write(new Text(""), new Text(DataUtil.convertArrayIntoString(sumElement)));
    }

reducer中将所有相同key的元素归并,遍历进行相加。其中CalUtil.addElement()方法将第二个参数添加到第一个参数对应的向量上。利用num计算总的元素个数,利用calCenter方法计算新的中心点向量并输出。

    public static void addElement(ArrayList<Double> element1, ArrayList<Double> element2){
        for(int i=0;i<element1.size();i++) {
            element1.set(i, element1.get(i) + element2.get(i));
        }
    }

    // 计算新中心点
    public static void calCenter(int num, ArrayList<Double> element){
        for(int i=0;i<element.size();i++){
            element.set(i, element.get(i) / num);
        }
    }

相加与计算中心点方法都是对double集合形式的元素向量进行处理。

停机条件判断

KmeansAdapter
该类中实现停机检查方法chekStop()。

      ArrayList<ArrayList<Double>> newCenters = DataUtil.readCenter(newCenterPath);
      ArrayList<ArrayList<Double>> centers = DataUtil.readCenter(centerPath);
            // 获取距离信息
      double distanceSum = CalUtil.calDistanceBetweenCenters(centers, newCenters);
      if(distanceSum == 0){
          // 停机,不做修改
          return true;
       }else{
          // 覆盖原中心文件
          System.out.println("distanceSum=" + distanceSum);
          DataUtil.changeCenters(centerPath, newCenterPath, new Configuration());
          return false;
}

主要是利用CalUtil.calDistanceBetweenCenters计算新旧两组中心点之间的距离差值,因为较难把控阈值信息,直接就等两组中心点完全相同时实现停机,返回true。

	// 计算两次迭代的中心是否有变化,返回距离
    public static double calDistanceBetweenCenters(ArrayList<ArrayList<Double>>oldCenter, ArrayList<ArrayList<Double>>newCenter){
        // 因为data的读入顺序相同,所以最终收敛时聚类中心的顺序也相同
        // 只要遍历计算距离即可,不用考虑中心点本身顺序
        double sum = 0;
        for(int i=0;i<oldCenter.size();i++){
            double singleDistance = calDistance(oldCenter.get(i), newCenter.get(i));
            sum += singleDistance;
        }
        return sum;
    }

简单调用距离计算方法,将其添加到sum变量上并返回。

单次迭代流程

KmeansAdapter
该类中实现单次迭代任务设置方法start()。

    // map读取中心,分类,reduce计算新中心,存储
    // 比较两次中心差距,存储新中心点
    public static void start(String dataPath, String centerPath, String newCenterPath){
        // 设置原中心点
        Configuration hadoopConfig = new Configuration();
        hadoopConfig.set("cluster.center_path", centerPath);

        try {
            Job job = Job.getInstance(hadoopConfig, "one round cluster task");

            job.setJarByClass(KmeansRun.class);
            job.setMapperClass(KmeansMapper.class);
            job.setReducerClass(KmeansReducer.class);

            job.setOutputKeyClass(Text.class);
            job.setOutputValueClass(Text.class);

            job.setMapOutputKeyClass(Text.class);
            job.setMapOutputValueClass(Text.class);

            // 输出为新计算得到的center,已存在则删除
            Path outPath = new Path(newCenterPath);
            outPath.getFileSystem(hadoopConfig).delete(outPath, true);

            //job执行作业时输入和输出文件的路径
            FileInputFormat.addInputPath(job, new Path(dataPath));
            FileOutputFormat.setOutputPath(job, new Path(newCenterPath));

            //执行job,直到完成
            job.waitForCompletion(true);
            System.out.println("finish one round cluster task");
        } catch (IOException | InterruptedException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

该方法拥有三个参数,分别为数据集地址,旧中心点文件地址与新中心点文件地址。设置任务mapper为KmeansMapper,reducer为KmeansReducer。在configuration中设置旧中心点文件地址,方便mapper读取。输入文件为数据集,输出文件地址设置为新中心点文件地址。

生成聚类结果

KmeansAdapter
该类中实现聚类结果输出任务设置方法createClusterResult()。
聚类结果即为KmeansMapper的输出结果,故只要调用mapper并输出结果即可。

public static void createClusterResult(String dataPath, String centerPath, String clusterResultPath){
        // 设置原中心点
        Configuration hadoopConfig = new Configuration();
        hadoopConfig.set("cluster.center_path", centerPath);

        try {
            Job job = Job.getInstance(hadoopConfig, "cluster result task");

            job.setJarByClass(KmeansRun.class);
            // 无reducer
            job.setMapperClass(KmeansMapper.class);

            job.setMapOutputKeyClass(Text.class);
            job.setMapOutputValueClass(Text.class);

            // 输出为新计算得到的center,已存在则删除
            Path outPath = new Path(clusterResultPath);
            outPath.getFileSystem(hadoopConfig).delete(outPath, true);

            //job执行作业时输入和输出文件的路径
            FileInputFormat.addInputPath(job, new Path(dataPath));
            FileOutputFormat.setOutputPath(job, new Path(clusterResultPath));

            //执行job,直到完成
            job.waitForCompletion(true);
            System.out.println("cluster result task finished");

        } catch (IOException | InterruptedException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

该方法包括三个参数,分别为数据集地址,中心点地址以及聚类结果输出地址。设置MapperClass即可。

总流程实现

KmeansRun

该类中实现需要调用的main方法。

	public static void main(String[] args){
        // 命令行参数为数据集名称与聚类数
        String dataName = args[0];
        int k = Integer.parseInt(args[1]);

        String centerPath = DataUtil.HDFS_OUTPUT + "/centers.txt";
        String newCenterPath = DataUtil.HDFS_OUTPUT + "/new_centers.txt";
        String dataPath = DataUtil.HDFS_INPUT + "/" + dataName;
        String clusterResultPath = DataUtil.HDFS_OUTPUT + "/kmeans_cluster_result.txt";

        // 初始化随机中心点
        CenterRandomAdapter.createRandomCenter(dataPath, centerPath, k);
        // 默认1000次,中途停退出
        for(int i=0;i<1000;i++){
            System.out.println("round " + i);
            KmeansAdapter.start(dataPath, centerPath, newCenterPath);
            if(KmeansAdapter.checkStop(centerPath, newCenterPath))
                break;
        }
        KmeansAdapter.createClusterResult(dataPath, centerPath, clusterResultPath);
    }

将数据集名称与聚类数作为命令行输入参数。在主方法中循环调用start与checkStop方法实现KMeans聚类,最终调用createClusterResult方法输出结果即可。

结果示例

集群用的是在虚拟机里用docker-compose拉起来的hadoop集群。docker-compose有现成镜像的话拉集群还蛮快的。有空写篇讲一下简单配置过程。

数据集

实验数据选取自美国zillow房地产评估2017年房产数据,选取其中的经纬度信息进行聚类操作,方便可视化。数据经处理转移到txt格式,并存入HDFS中进行实验。
基于MapReduce实现的Kmeans算法(非调库)_第1张图片

每行数据经度与维度信息,其中经纬度信息都乘以1e6,方便计算距离。

输出结果

实验中,将聚类数设置为30。
jar运行命令:./bin/hadoop jar /root/build/hadoop_kmeans_1.jar com.huiluczP.KmeansRun new_data_test.txt 30
基于MapReduce实现的Kmeans算法(非调库)_第2张图片
基于MapReduce实现的Kmeans算法(非调库)_第3张图片

由于采用的是mapper输出的结果,聚类结果会自动按照簇序号进行排序。

项目地址

项目已经上传至github,感兴趣可以看一看。
https://github.com/huiluczP/hadoop_kmeans

总结

有个比较大的问题就是如果出现空簇,我写的是直接把空簇去除,最后可能导致聚类数目变少,效果就比较差。以后有时间改一改变成随机选新中心好了,不过这样效率可能会低一点,头疼。

你可能感兴趣的:(java,hadoop,java,大数据,hadoop,kmeans算法)