7.2 第一个例子:k-means聚类算法

k-means聚类算法将预先未分类的项集分组到预定的K个簇。它在数据挖掘和机器学习领域非常流行,并且在这些领域中用于以无监督方式组织和分类数据。

每一项通常都由一个特征(或者说属性)向量(这里使用向量是作为一个数学概念而非数据结构)定义。所有项都有相同数目的属性。每个簇也由一个含有同样属性数目的向量定义,这些属性描述了所有可分类到该簇的项。该向量叫作centroid。例如,如果这些项是用数值型向量定义的,那么簇就定义为划分到该簇的各项的平均值。

该算法基本上可以分为四个步骤。

  • 初始化:在第一步中,要创建最初代表K个簇的向量,通常,你可以随机初始化这些向量。
  • 指派:然后,你可以将每一项划分到一个簇中。为了选择该簇,可以计算项和每个簇之间的距离。可以使用欧氏距离作为距离度量方式,计算代表项的向量和代表簇的向量之间的距离。之后,你可以将该项分配到与其距离最短的簇中。
  • 更新:一旦对所有项进行分类之后,必须重新计算定义每个簇的向量。如前所述,通常要计算划分到该簇所有项的向量的平均值。
  • 结束:最后,检查是否有些项改变了为其指派的簇。如果存在变化,需要再次转入指派步骤。否则算法结束,所有项都已分类完毕。

该算法有如下两个主要局限。

  • 如前所述,如果随机初始化最初的簇向量,那么对同一项集执行两次分类的结果是不同的。
  • 簇的数目是预先定义好的。从分类的视角来看,如果属性选择得不好将会导致糟糕的结果。

尽管如此,在对不同类型的项做聚类分析时该算法仍然广受欢迎。为了测试我们的算法,需要实现一个应用程序来对某个文档集进行聚类。就文档集而言,第5章已经介绍了有关电影信息的维基百科网页集,本章使用的是该网页集的缩减版,仅从中选取了1000个文档。为了表示每个文档,必须使用向量空间模型表示。在这种表示方法中,每个文档都可以用数值型向量表示,向量的每个维度都代表一个单词或者一个术语,而向量的取值则是一个指标,它定义了该单词或术语在该文档中的重要程度。

使用向量空间模型表示一个文档集时,向量维度的数量和整个文档集中不同单词的数量相同,这样向量中就会有大量为0的值,因为每个文档都不会包含所有单词。你可以使用一种在内存中更优的表示方式来避免表示所有的0值,从而节省内存并提升应用程序的性能。

在我们的例子中,选择了术语频次-逆文档频次(TF-IDF)作为定义每个单词重要性的指标,而且将具有最高TF-IDF值的50个词作为代表每个文档的术语。

我们使用两个文件:movies.words文件和movie.data文件。movies.words文件中存放了向量中用到的所有单词,movie.data中存放了每个文档的表示。movies.data文件的格式如下。

  1. 10000202,rabona:23.039285705435507,1979:8.09314752937111,argentina:7.953798
  2. 614698405,la:5.440565539075689,argentine:4.058577338363469,editor:3.0401515
  3. 284855267,spanish:2.9692083275217134,image_size:1.3701158713905104,narrator
  4. :1.1799670194306195,budget:0.286193223652206,starring:0.25519156764102785,c
  5. ast:0.2540127604060545,writer:0.23904044207902764,distributor:0.20430284744
  6. 786784,cinematography:0.182583823735518,music:0.1675671228903468,caption:0.
  7. 14545085918028047,runtime:0.127767002869991,country:0.12493801913495534,pro
  8. ducer:0.12321749670640451,director:0.11592975672109682,links:0.079255823038
  9. 12376,image:0.07786973207561361,external:0.07764427108746134,released:0.074
  10. 47174080087617,name:0.07214163435745059,infobox:0.06151153983466272,film:0.
  11. 035415118094854446

其中,10000202是文档的标识符,而文件其余的部分都按照“单词:tfxidf值”的格式排列。

与其他例子一样,我们将实现串行版和并发版,并且执行两个版本以验证Fork/Join框架为该算法带来的性能提升。

7.2.1 公共类

串行版和并发版有一些共同特征,如下所示。

  • VocabularyLoader类:这个类用于加载文档集中构成词汇表的单词列表。
  • Word类、Document类和DocumentLoader类:这三个类用于加载有关文档的信息。在串行版和并行版算法中,这些类几乎没有差别。
  • DistanceMeasure类:该类用于计算两个向量之间的欧氏距离。
  • DocumentCluster类:该类用于存储有关簇的信息。

下面详细说明一下这些类。

  • VocabularyLoader

如前所述,数据存放在两个文件中。其中一个是movies.words文件。该文件存放的列表含有文档中用到的所有词。VocabularyLoader类会将该文件转换成HashMap。该HashMap的键是整个单词,而其值为一个代表单词在列表中索引位置的整数值。我们使用该索引来判定单词在表示文档的向量空间模型中的位置。

该类只有一个方法,即load(),该方法接收文件路径为参数,并且返回该HashMap

  1. public class VocabularyLoader {
  2. public static Map<String, Integer> load (Path path) throws
  3. IOException {
  4. int index=0;
  5. HashMap<String, Integer> vocIndex=new HashMap<String,
  6. Integer>();
  7. try(BufferedReader reader = Files.newBufferedReader(path)){
  8. String line = null;
  9. while ((line = reader.readLine()) != null) {
  10. vocIndex.put(line,index );
  11. index++;
  12. }
  13. }
  14. return vocIndex;
  15. }
  16. }
  • Word类、Document类和DocumentLoader

这些类存储了将在算法中用到的文档的所有信息。首先,Word类存放了一个文档中某个单词的信息,这包括该单词的索引及其在文档中的TF-IDF值。该类仅包括这些属性(分别是int型和double型),并且实现了Comparable接口来根据两个单词的TF-IDF值对它们进行排序。这里不再介绍该类的源代码。

Document类存放了有关文档的所有信息。首先,该类有一个存放文档中单词的Word对象数组,它是向量空间模型的表示形式。为了节省内存空间,我们仅存储文档中用到的单词。然后,该类还有一个String变量,用于表示存放文档的文件名。最后,该类还有一个DocumentCluster对象,用于表示与该文档相关的簇。该类还包括一个构造函数,用于初始化这些属性和方法以获取和设置其取值。我们仅给出setCluster()方法的代码。在本例中,该方法将返回一个布尔值,这个值表明属性的新值是与旧值相同,还是一个新值。我们将用该值判定是否应该停止算法。

  1. public boolean setCluster(DocumentCluster cluster) {
  2. if (this.cluster == cluster) {
  3. return false;
  4. } else {
  5. this.cluster = cluster;
  6. return true;
  7. }
  8. }

最后,DocumentLoader类用于加载有关文档的信息。它含有一个静态方法load(),该方法接收文件路径和存放词汇表的HashMap作为参数,返回一个Document对象的数组。该方法逐行加载文件,并且将每一行都转换成为一个Document对象。代码如下:

  1. public static Document[] load(Path path, Map<String, Integer>
  2. vocIndex) throws IOException{
  3. List<Document> list = new ArrayList<Document>();
  4. try(BufferedReader reader = Files.newBufferedReader(path)) {
  5. String line = null;
  6. while ((line = reader.readLine()) != null) {
  7. Document item = processItem(line, vocIndex);
  8. list.add(item);
  9. }
  10. }
  11. Document[] ret = new Document[list.size()];
  12. return list.toArray(ret);
  13. }

为了将文本文件的某一行转换成一个Document对象,可以使用processItem()方法。

  1. private static Document processItem(String line,Map<String,
  2. Integer> vocIndex) {
  3. String[] tokens = line.split(",");
  4. int size = tokens.length - 1;
  5. Document document = new Document(tokens[0], size);
  6. Word[] data = document.getData();
  7. for (int i = 1; i < tokens.length; i++) {
  8. String[] wordInfo = tokens[i].split(":");
  9. Word word = new Word();
  10. word.setIndex(vocIndex.get(wordInfo[0]));
  11. word.setTfidf(Double.parseDouble(wordInfo[1]));
  12. data[i - 1] = word;
  13. }
  14. Arrays.sort(data);
  15. return document;
  16. }

如前所述,一行中的第一项是文档的标识符。从tokens[0]中获取该标识符并且将其传送给Document类的构造函数。然后,对于tokens字符串数组中剩下的值,将其再次分割,进而获得每个单词的信息,包括整个单词及其TF-IDF值。

  • DistanceMeasurer

该类计算文档与簇(用向量表示)之间的欧氏距离。经过排序之后,单词数组中的单词将以与质心数组同样的顺序存放,但是会缺少其中一些单词。对于缺少的这些单词,假设其TF-IDF值为0,这样其距离就是质心数组中对应值的平方。

  1. public class DistanceMeasurer {
  2. public static double euclideanDistance(Word[] words, double[]
  3. centroid) {
  4. double distance = 0;
  5. int wordIndex = 0;
  6. for (int i = 0; i < centroid.length; i++) {
  7. if ((wordIndex < words.length) (words[wordIndex].getIndex()
  8. == i)) {
  9. distance += Math.pow( (words[wordIndex].getTfidf()
  10. centroid[i]), 2);
  11. wordIndex++;
  12. } else {
  13. distance += centroid[i] * centroid[i];
  14. }
  15. }
  16. return Math.sqrt(distance);
  17. }
  18. }
  • DocumentCluster

该类存放算法生成的每个簇的相关信息。这些信息包括与该簇相关联的所有文档构成的列表,以及代表簇的向量的质心。在本例中,该向量的维度数目与词汇表中单词的数目相同。该类含有两个属性,一个对这两个属性进行初始化的构造函数,以及获取和设置这两个属性值的方法。该类还有两个非常重要的方法,第一个是calculateCentroid()方法,该方法计算簇的质心作为向量的平均值,而这些向量代表所有与该簇相关的文档。代码如下:

  1. public void calculateCentroid() {
  2. Arrays.fill(centroid, 0);
  3. for (Document document : documents) {
  4. Word vector[] = document.getData();
  5. for (Word word : vector) {
  6. centroid[word.getIndex()] += word.getTfidf();
  7. }
  8. }
  9. for (int i = 0; i < centroid.length; i++) {
  10. centroid[i] /= documents.size();
  11. }
  12. }

第二个方法是initialize()方法,该方法接收一个Random对象作为参数,并且采用随机数来初始化簇向量的质心。如下所示:

  1. public void initialize(Random random) {
  2. for (int i = 0; i < centroid.length; i++) {
  3. centroid[i] = random.nextDouble();
  4. }
  5. }

7.2.2 串行版本

既然已经讲述了应用程序的公共特性,下面看看如何实现k-means聚类算法的串行版本。我们将用到两个类:用于实现算法的SerialKMeans类,以及实现main()方法来执行算法的SerialMain类。

  • SerialKMeans

SerialKMeans类实现了k-means聚类算法的串行版本。该类的主要方法是calculate()方法,它接收如下参数。

  • Document对象数组,它存放了有关文档的信息。
  • 要生成的簇的数目。
  • 词汇表的大小。
  • 用于随机数生成器的“种子”。
    该方法返回一个DocumentCluster对象数组。每个簇都有一个与其相关的文档列表。首先,文档可以创建一个由clusterCount参数确定的簇的数组,并且使用initialize()方法和Random对象对其初始化,如下所示:
  1. public class SerialKMeans {
  2. public static DocumentCluster[] calculate(Document[] documents,
  3. int clusterCount, int vocSize, int seed) {
  4. DocumentCluster[] clusters = new DocumentCluster[clusterCount];
  5. Random random = new Random(seed);
  6. for (int i = 0; i < clusterCount; i++) {
  7. clusters[i] = new DocumentCluster(vocSize);
  8. clusters[i].initialize(random);
  9. }

然后,重复指派和更新阶段,直到所有文档对应的簇都不再变化为止。最后,返回描述了文档最终组织情况的簇数组,如下述代码所示:

  1. boolean change = true;
  2. int numSteps = 0;
  3. while (change) {
  4. change = assignment(clusters, documents);
  5. update(clusters);
  6. numSteps++;
  7. }
  8. System.out.println("Number of steps: "+numSteps);
  9. return clusters;
  10. }

指派阶段的工作在assignment()方法中实现。该方法接收Document对象数组和DocumentCluster对象数组作为参数。对于每个文档,该方法都计算其与所有簇之间的欧氏距离,并且将该文档指派到距离最短的簇。该方法返回一个布尔值,该值表明从当前位置到下一位置是否有一个或多个文档改变了为其指派的簇。如以下代码所示:

  1. private static boolean assignment(DocumentCluster[] clusters, Document[]
  2. documents) {
  3. boolean change = false;
  4. for (DocumentCluster cluster : clusters) {
  5. cluster.clearClusters();
  6. }
  7. int numChanges = 0;
  8. for (Document document : documents) {
  9. double distance = Double.MAX_VALUE;
  10. DocumentCluster selectedCluster = null;
  11. for (DocumentCluster cluster : clusters) {
  12. double curDistance = DistanceMeasurer.euclideanDistance
  13. (document.getData(), cluster.getCentroid());
  14. if (curDistance < distance) {
  15. distance = curDistance;
  16. selectedCluster = cluster;
  17. }
  18. }
  19. selectedCluster.addDocument(document);
  20. boolean result = document.setCluster(selectedCluster);
  21. if (result)
  22. numChanges++;
  23. }
  24. System.out.println("Number of Changes: " + numChanges);
  25. return numChanges > 0;
  26. }

更新阶段在update()方法中实现。该方法接收带有簇信息的DocumentCluster对象数组作为参数,并且直接重新计算每个簇的质心。

  1. private static void update(DocumentCluster[] clusters) {
  2. for (DocumentCluster cluster : clusters) {
  3. cluster.calculateCentroid();
  4. }
  5. }
  6. }
  • SerialMain

SerialMain类中含有main()方法,可以启动对k-means算法的测试。首先,它从文件加载数据(单词和文档)。

  1. public class SerialMain {
  2. public static void main(String[] args) {
  3. Path pathVoc = Paths.get("data", "movies.words");
  4. Map<String, Integer> vocIndex=VocabularyLoader.load(pathVoc);
  5. System.out.println("Voc Size: "+vocIndex.size());
  6. Path pathDocs = Paths.get("data", "movies.data");
  7. Document[] documents = DocumentLoader.load(pathDocs,
  8. vocIndex);
  9. System.out.println("Document Size: "+documents.length);

然后,它初始化要生成的簇数以及随机数生成器的“种子”。如果它们并未作为main()方法的参数进行传递,可以使用如下的默认值。

  1. if (args.length != 2) {
  2. System.err.println("Please specify K and SEED");
  3. return;
  4. }
  5. int K = Integer.valueOf(args[0]);
  6. int SEED = Integer.valueOf(args[1]);
  7. }

最后,启动算法,度量其执行时间,并且输出为每个簇分配的文档数。

  1. Date start, end;
  2. start=new Date();
  3. DocumentCluster[] clusters = SerialKMeans.calculate(documents,
  4. K ,vocIndex.size(), SEED);
  5. end=new Date();
  6. System.out.println("K: "+K+"; SEED: "+SEED);
  7. System.out.println("Execution Time: "+(end.getTime()-
  8. start.getTime()));
  9. System.out.println(Arrays.stream(clusters)
  10. .map (DocumentCluster::getDocumentCount)
  11. .sorted (Comparator.reverseOrder())
  12. .map(Object::toString)
  13. .collect( Collectors.joining(", ", "Cluster sizes: ", "")));
  14. }
  15. }

7.2.3 并发版本

为实现该算法的并发版本,我们运用了Fork/Join框架。我们已经基于RecursiveAction类实现了两种任务。如前所述,当希望使用Fork/Join框架处理不返回结果的任务时,可以使用RecursiveAction任务。将指派阶段和更新阶段的工作作为在Fork/Join框架中执行的任务来实现。

为实现k-means算法的并发版本,将修改一些公共类以便使用并发数据结构。然后,要实现两个任务。最后,还将实现ConcurrentKMeans类和ConcurrentMain类。其中,ConcurrentKMeans类实现了算法的并发版本,而ConcurrentMain类用于测试算法的并发版本。

  • 面向Fork/Join框架的两个任务:AssignmentTaskUpdateTask

如前所述,将指派阶段和更新阶段作为在Fork/Join框架中执行的任务实现。

指派阶段指派一个文档至与该文档欧氏距离最短的簇。这样就必须要处理所有文档并且计算所有文档和所有簇之间的欧氏距离。我们将使用一个任务中的文档数作为指标,以便决定是否必须将该任务分割。从要处理所有文档的任务开始分割,直到任务要处理的文档数低于预定规模为止。

AssignmentTask类有以下几个属性。

  • 含有有关簇数据的ConcurrentDocumentCluster对象数组。
  • 含有有关文档数据的ConcurrentDocument对象数组。
  • 两个整型属性startend,它们决定了任务要处理的文档数。
  • AtomicInteger属性numChanges,它存放的是从上一轮执行到当前执行的过程中改变了为其指派的簇的文档数。
  • 整型属性maxSize,它存放的是一个任务所能处理的最大文档数。
    我们实现了一个构造函数来初始化所有属性,以及用于获取和设置这些属性值的方法。

这些任务(对每个任务都是如此)的主方法是compute()方法。首先,检查任务必须处理的文档数。如果该值小于或等于maxSize属性的值,那么处理这些文档。计算每个文档和所有簇之间的欧氏距离,并且为文档选择距离最短的簇。如果必要,可以使用incrementAndGet()方法增加numChanges原子变量的值。该原子变量可以同时由多个线程更新且无须同步机制,而且不会导致任何内存不一致问题。相关代码如下:

  1. protected void compute() {
  2. if (end - start <= maxSize) {
  3. for (int i = start; i < end; i++) {
  4. ConcurrentDocument document = documents[i];
  5. double distance = Double.MAX_VALUE;
  6. ConcurrentDocumentCluster selectedCluster = null;
  7. for (ConcurrentDocumentCluster cluster : clusters) {
  8. double curDistance = DistanceMeasurer.euclideanDistance
  9. (document.getData(), cluster.getCentroid());
  10. if (curDistance < distance) {
  11. distance = curDistance;
  12. selectedCluster = cluster;
  13. }
  14. }
  15. selectedCluster.addDocument(document);
  16. boolean result = document.setCluster(selectedCluster);
  17. if (result) {
  18. numChanges.incrementAndGet();
  19. }
  20. }

如果该任务要处理的文档数量太多,那么将该集合分割成两个部分,并且创建两个新的任务来分别处理这两个部分,如下所示:

  1. } else {
  2. int mid = (start + end) / 2;
  3. AssignmentTask task1 = new AssignmentTask(clusters, documents,
  4. start, mid, numChanges, maxSize);
  5. AssignmentTask task2 = new AssignmentTask(clusters, documents,
  6. mid, end, numChanges, maxSize);
  7. invokeAll(task1, task2);
  8. }
  9. }

为了在Fork/Join池中执行上述任务,使用了invokeAll()方法。该方法在任务结束其执行后返回。

更新阶段重新计算每个簇的质心作为该簇中所有文档的平均值。如此便必须处理所有簇。我们将使用一个任务要处理的簇数作为指标来控制是否要对任务进行分割。从一个需要处理所有簇的任务开始,对其进行分割,直到任务要处理的簇比预定规模小为止。

UpdateTask类有如下属性。

  • 含有有关簇数据的ConcurrentDocumentCluster对象数组。
  • 两个整型属性startend,它们确定了任务要处理的簇数。
  • 整型属性maxSize,存储了一个任务可处理的最大簇数。
    我们实现了一个初始化上述属性的构造函数,以及用于获取和设置这些属性值的方法。

compute()方法首先检查任务要处理的簇数。如果该数值小于或者等于maxSize属性的值,则该方法将处理这些簇并且更新其质心。

  1. @Override
  2. protected void compute() {
  3. if (end - start <= maxSize) {
  4. for (int i = start; i < end; i++) {
  5. ConcurrentDocumentCluster cluster = clusters[i];
  6. cluster.calculateCentroid();
  7. }

如果任务要处理的簇数量太大,那么将任务要处理的簇集合划分成两个部分,并且创建两个任务来分别处理每一半集合,如下所示:

  1. } else {
  2. int mid = (start + end) / 2;
  3. UpdateTask task1 = new UpdateTask(clusters, start, mid,
  4. maxSize);
  5. UpdateTask task2 = new UpdateTask(clusters, mid, end,
  6. maxSize);
  7. invokeAll(task1, task2);
  8. }
  9. }
  • ConcurrentKMeans

ConcurrentKMeans类实现了k-means聚类算法的并发版本。和串行版本一样,该类的主方法是calculate()方法。该方法接收如下参数。

  • 存放有关文档信息的ConcurrentDocument对象数组。
  • 想要生成的簇的数目。
  • 词汇表的大小。
  • 用于随机数生成器的“种子”。
  • 在不将Fork/Join任务分割成其他任务的前提下,该任务所要处理的最大项数。
    calculate()方法返回一个存放簇信息的ConcurrentDocumentCluster对象数组。每个簇都有与之相关的文档列表。首先,基于文档创建由numberClusters参数指定数目的簇数组,并且使用initialize()方法和一个Random对象来初始化这些簇。
  1. public class ConcurrentKMeans {
  2. public static ConcurrentDocumentCluster[] calculate
  3. (ConcurrentDocument[] documents int numberCluster
  4. int vocSize, int seed, int maxSize) {
  5. ConcurrentDocumentCluster[] clusters = new
  6. ConcurrentDocumentCluster[numberClusters];
  7. Random random = new Random(seed);
  8. for (int i = 0; i < numberClusters; i++) {
  9. clusters[i] = new ConcurrentDocumentCluster(vocSize);
  10. clusters[i].initialize(random);
  11. }

然后,重复指派阶段和更新阶段,直到所有文档所属的簇都不再改变为止。在进入循环之前,创建ForkJoinPool对象来执行该任务及其所有子任务。一旦循环完成,与其他Executor对象一样,必须对Fork/Join池使用shutdown()方法以结束其执行。最后,返回含有文档最终组织结果的簇数组。

  1. boolean change = true;
  2. ForkJoinPool pool = new ForkJoinPool();
  3. int numSteps = 0;
  4. while (change) {
  5. change = assignment(clusters, documents, maxSize, pool);
  6. update(clusters, maxSize, pool);
  7. numSteps++;
  8. }
  9. pool.shutdown();
  10. System.out.println("Number of steps: "+numSteps);
  11. return clusters;
  12. }

指派阶段在assignment()方法中实现。该方法接收簇数组、文档数组和maxSize属性作为参数。首先,删除所有簇的关联文档列表。

  1. private static boolean assignment(ConcurrentDocumentCluster[]
  2. clusters, ConcurrentDocument[] documents,
  3. int maxSize, ForkJoinPool pool) {
  4. boolean change = false;
  5. for (ConcurrentDocumentCluster cluster : clusters) {
  6. cluster.clearDocuments();
  7. }

然后,初始化必要的对象:用于存放已指派簇发生变化的文档数的AtomicInteger对象,以及用于启动处理过程的AssignmentTask对象。AtomicInteger类支持原子操作。也就是说,其他线程无法通过中间状态查看该操作。对于剩余线程来说,该操作可执行也可不执行。这两个对象还在set()操作和随后的get()操作之间建立了happens-before关系。使用AtomicInteger对象确保所有线程都可以以线程安全的方式更新其值。

  1. AtomicInteger numChanges = new AtomicInteger(0);
  2. AssignmentTask task = new AssignmentTask(clusters, documents, 0,
  3. documents.length, numChanges, maxSize);
  4. ForkJoinPool pool = new ForkJoinPool();

然后,使用ForkJoinPoolexecute()方法以异步方式执行池中的任务,并且使用AssignmentTask对象的join()方法等待其结束,如下所示:

  1. pool.execute(task);
  2. task.join();

最后,检查已改变指派簇的文档数。如果存在发生改变的文档,将返回true值,否则返回false值。该代码如下所示:

  1. System.out.println("Number of Changes: " + numChanges);
  2. return numChanges.get() > 0;
  3. }

更新阶段在update()方法中实现。该方法接收簇数组和maxSize作为参数。首先,创建一个UpdateTask对象来更新所有簇。然后,执行ForkJoinPool对象(该方法作为参数接收)中的任务,如下所示:

  1. private static void update(ConcurrentDocumentCluster[] clusters,
  2. int maxSize, ForkJoinPool pool) {
  3. UpdateTask task = new UpdateTask(clusters, 0, clusters.length,
  4. maxSize, ForkJoinPool pool);
  5. pool.execute(task);
  6. task.join();
  7. }
  8. }
  • ConcurrentMain

ConcurrentMain类中含有main()方法,可启动对k-means算法的测试。它的代码与SerialMain类相当,只是将串行类改写为了并发类。

7.2.4 对比解决方案

为了对比两种解决方案,我们更改三个参数的取值并多次执行试验。

  • 参数K确定了要生成的簇数。将其值分别设置为5、10、15和20来测试算法。
  • 随机数生成器的“种子”。该“种子”确定了初始质心的位置。将其设置为1和13来测试算法。
  • 对于并发算法来说,maxSize参数确定了一个任务在不分割的前提下所能处理的最大项(文档或者簇)数。将其设置为1、20和400来测试算法。

采用JMH框架执行这些示例,可以在Java中实现微型基准测试。使用面向基准测试的框架是比较好的解决方案,它直接用currentTimeMillis()方法或者nanoTime()方法度量时间。在两种不同的架构上分别执行这些示例10次。

  • 一台计算机配置了Intel Core i5-5300处理器、Windows 7操作系统和16GB的RAM。该处理器有两个核,且每个核可以执行两个线程,这样就有四个并行线程。
  • 另一台计算机配置了AMD A8-640处理器、Windows 10操作系统和8GB的RAM。该处理器有四个核。

下面是得到的执行时间(单位:毫秒)。首先,给出在AMD架构上得到的结果。

AMD架构
串行版并发版
K种子maxSize=1maxSize=20maxSize=400
518647.1294919.9243795.233754.424
1019419.1453665.8963474.1823456.362
15116 324.9316320.1745477.7555543.474
20125 707.5898360.4859280.4598362.34
5135122.6812754.9472262.4262254.837
101312 629.0984919.3144593.7054579.875
151316 261.686838.7535606.0745474.2
201323 626.9837605.6168114.5826694.77

下面是在Intel架构上得到的结果。

Intel架构
串行版并发版
K种子maxSize=1maxSize=20maxSize=400
514049.5795112.7284111.2754141.222
1014290.914617.7933966.8483957.214
1517155.9344211.4876358.5526493.285
20111 444.90310 405.5315949.08310 009.849
5132437.5332893.4852444.8742489.087
10135702.2725637.9965165.3335206.648
15137110.7324115.0916348.2886445.648
201310 495.4059509.2175995.6385371.75

可以得出下述结论。

  • “种子”对于执行时间有着重要且不可预测的影响。有时使用“种子”13的执行时间会低一些,但有时使用“种子”1的执行时间会低一些。
  • 增加簇的数目时,执行时间也会增加。
  • maxSize参数对于执行时间的影响不大。参数K或者“种子”对于执行时间的影响较大。如果增大了maxSize参数的值,将会获得更好的性能。1和20之间的差别比20和400之间的差别更大。
  • 在所有情况下,算法的并发版本的性能都比串行版本更好。只有在Intel架构上,当簇的数目较少时,串行版的结果才会比并发版更好。

例如,如果用加速比来比较参数为K=20且seed=13的串行算法和参数为K=20、seed=13且maxSize=400的并行算法的执行速度,就会得到下面的结果。

\begin{aligned}&S_{{\rm AMD}}=\frac{T_{{\rm serial}}}{T_{{\rm concurrent}}}=\frac{23~626.983}{6694.77}=3.529\\&S_{{\rm Intel}}=\frac{T_{{\rm serial}}}{T_{{\rm concurrent}}}=\frac{10~495.405}{5371.75}=1.95\end{aligned}