精通-Java-数据科学-全-

精通 Java 数据科学(全)

零、前言

如今,数据科学已经成为组织的一个非常重要的工具:他们已经收集了大量的数据,为了能够很好地利用这些数据,他们需要数据科学——关于从数据中提取知识的方法的学科。每天都有越来越多的公司意识到他们可以从数据科学中受益,并更有效、更有利地利用他们生产的数据。

对于 It 公司来说尤其如此,他们已经有了生成和处理数据的系统和基础设施。这些系统通常是用 Java 编写的,Java 是世界上许多大公司和小公司的首选语言。这并不奇怪,Java 提供了一个非常坚实和成熟的库生态系统,经过时间的考验和可靠,所以许多人信任 Java 并使用它来创建他们的应用程序。

因此,它也是许多数据处理应用程序的自然选择。由于现有的系统已经在 Java 中,因此对数据科学使用相同的技术堆栈是有意义的,并将机器学习模型直接集成到应用程序的生产代码库中。

这本书将涵盖这一点。我们将首先了解如何利用 Java 的工具箱来处理小型和大型数据集,然后研究如何进行初步的勘探数据分析。接下来,我们将回顾实现分类、回归、聚类和降维问题的通用机器学习模型的 Java 库。然后我们将进入更高级的技术,并讨论信息检索和自然语言处理、XGBoost、深度学习和用于处理大数据集的大规模工具,如 Apache Hadoop 和 Apache Spark。最后,我们还将了解如何评估和部署生产的模型,以便其他服务可以使用它们。

我们希望你会喜欢这本书。快乐阅读!

这本书涵盖的内容

第 1 章,使用 Java 的数据科学,概述了 Java 中现有的可用工具,并介绍了处理数据科学项目的方法,CRISP-DM。在这一章中,我们还介绍了我们正在运行的例子,构建一个搜索引擎。

第 2 章,数据处理工具箱,回顾标准 Java 库:用于在内存中存储数据的集合 API,用于读写数据的 IO API,以及用于组织数据处理管道的便捷方式的流 API。我们将研究标准库的扩展,如 Apache Commons Lang、Apache Commons IO、Google Guava 和 AOL Cyclops React。然后,我们将介绍存储数据的最常见方式——文本和 CSV 文件、HTML、JSON 和 SQL 数据库,并讨论如何从这些数据源获取数据。在本章的最后,我们将讨论如何为正在运行的搜索引擎收集数据,以及如何为此准备数据。

第 3 章,探索性数据分析,用 Java 执行数据的初步分析:我们看看如何计算常见的统计数据,如最小值和最大值、平均值和标准偏差。我们还谈了一点交互式分析,看看有哪些工具可以让我们在构建模型之前直观地检查数据。对于本章中的插图,我们使用我们为搜索引擎收集的数据。

第四章监督学习——分类和回归,从机器学习开始,然后看 Java 中执行监督学习的模型。其中,我们看看如何使用下面的库——Smile、JSAT、LIBSVM、LIBLINEAR 和 Encog,我们看看如何使用这些库来解决分类和回归问题。我们在这里使用两个例子,首先,我们使用搜索引擎数据来预测一个 URL 是否会出现在结果的第一页,我们使用它来说明分类问题。其次,我们预测在给定硬件特性的情况下,两个矩阵相乘需要多少时间,并通过这个例子说明回归问题。

第五章无监督学习——聚类和降维,探讨 Java 中可用的降维方法,我们将学习如何应用 PCA 和随机投影来降低这些数据的维度。上一章的硬件性能数据集说明了这一点。我们还研究了不同的数据聚类方法,包括凝聚聚类、K-Means 和 DBSCAN,并使用客户投诉数据集作为示例。

第 6 章处理文本——自然语言处理和信息检索,讲述如何在数据科学应用中使用文本,我们还将学习如何为我们的搜索引擎提取更多有用的特征。我们还研究了 Apache Lucene,一个用于全文索引和搜索的库,以及 Stanford CoreNLP,一个用于执行自然语言处理的库。接下来,我们看看如何将单词表示为向量,并学习如何从共现矩阵中构建这样的嵌入,以及如何使用现有的嵌入,如 GloVe。我们还看了如何将机器学习用于文本,我们用一个情感分析问题来说明它,其中我们应用 LIBLINEAR 来分类评论是正面还是负面。

第 7 章极限梯度提升,讲述了如何在 Java 中使用 XGBoost,并尝试将其应用于我们之前遇到的两个问题,分类 URL 是否出现在第一页,预测两个矩阵相乘的时间。此外,我们看看如何用 XGBoost 解决学习排序问题,并再次使用我们的搜索引擎示例作为说明。

第八章用 DeepLearning4j 进行深度学习,涵盖了深度神经网络和 DeepLearning4j,一个用 Java 构建和训练这些网络的库。特别是,我们谈论卷积神经网络,看看我们如何使用它们进行图像识别-预测它是一只狗还是一只猫的图片。此外,我们讨论了数据扩充——生成更多数据的方法,还提到了我们如何使用 GPU 来加速训练。我们通过描述如何在 Amazon AWS 上租用 GPU 服务器来结束这一章。

第 9 章扩展数据科学,讲述 Java、Apache Hadoop 和 Apache Spark 中可用的大数据工具。我们通过查看如何处理常见的抓取(互联网的副本)并计算每个文档的 TF-IDF 来说明这一点。此外,我们查看了 Apache Spark 中可用的图形处理工具,并为科学家建立了一个推荐系统,我们为下一篇可能的论文推荐了一位合著者。

第 10 章部署数据科学模型,着眼于我们如何以一种可用的方式向外界公开模型。在这里,我们涵盖了 Spring Boot,并讨论了如何使用我们开发的搜索引擎模型对普通抓取的文章进行排序。最后,我们讨论了在线环境中评估模型性能的方法,并讨论了 A/B 测试和多武装匪徒。

这本书你需要什么

你需要拥有至少 2GB 内存和 Windows 7 /Ubuntu 14.04/Mac OS X 操作系统的最新系统。此外,您需要安装 Java 1.8.0 或更高版本以及 Maven 3.0.0 或更高版本。

这本书是给谁的

本书面向那些熟悉 Java 应用程序开发并熟悉数据科学基本概念的软件工程师。此外,对于那些还不了解 Java,但希望或需要学习它的数据科学家来说,它也很有用。

约定

在这本书里,你会发现许多区分不同种类信息的文本样式。下面是这些风格的一些例子和它们的含义的解释。

文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、伪 URL、用户输入和 Twitter 句柄如下所示:“这里,我们创建SummaryStatistics对象并添加所有正文内容长度。”

代码块设置如下:

SummaryStatistics statistics = new SummaryStatistics(); data.stream().mapToDouble(RankedPage::getBodyContentLength)
    .forEach(statistics::addValue); 
System.out.println(statistics.getSummary());

任何命令行输入或输出都按如下方式编写:

mvn dependency:copy-dependencies -DoutputDirectory=lib 
mvn compile

新术语重要词汇以粗体显示。你在屏幕上看到的单词,例如,在菜单或对话框中,出现在文本中,如下所示:“相反,如果我们的模型输出一些分数,使得分数值越高,项目越有可能是肯定的,那么二元分类器被称为排名分类器。”

警告或重要提示出现在这样的框中。

提示和技巧是这样出现的。

读者反馈

我们随时欢迎读者的反馈。让我们知道你对这本书的看法——你喜欢或不喜欢什么。读者的反馈对我们来说很重要,因为它有助于我们开发出真正让你受益匪浅的图书。

要给我们发送总体反馈,只需发送电子邮件feedback@packtpub.com,并在邮件主题中提及书名。

如果有一个你擅长的主题,并且你有兴趣写一本书或者为一本书投稿,请查看我们在www.packtpub.com/authors的作者指南。

客户支持

既然您已经是 Packt book 的骄傲拥有者,我们有许多东西可以帮助您从购买中获得最大收益。

下载示例代码

你可以从你在http://www.packtpub.com的账户下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问 http://www.packtpub.com/support并注册,让文件直接通过电子邮件发送给你。

您可以按照以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。
  2. 将鼠标指针悬停在顶部的支持选项卡上。
  3. 点击代码下载和勘误表。
  4. 在搜索框中输入图书的名称。
  5. 选择您要下载代码文件的书。
  6. 从下拉菜单中选择您购买这本书的地方。
  7. 点击代码下载。

下载文件后,请确保使用最新版本的解压缩或解压文件夹:

  • WinRAR / 7-Zip for Windows
  • 适用于 Mac 的 Zipeg / iZip / UnRarX
  • 用于 Linux 的 7-Zip / PeaZip

该书的代码包也托管在 GitHub 的 https://GitHub . com/packt publishing/Mastering-Java-for-Data-Science 上。我们在 https://github.com/PacktPublishing/的也有来自我们丰富的书籍和视频目录的其他代码包。看看他们!

下载这本书的彩色图片

我们还为您提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。彩色图像将帮助您更好地理解输出中的变化。你可以从https://www . packtpub . com/sites/default/files/downloads/MasteringJavaforDataScience _ color images . pdf下载这个文件。

正误表

尽管我们已尽一切努力确保内容的准确性,但错误还是会发生。如果您在我们的某本书中发现了一个错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做,你可以让其他读者免受挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误表,请通过访问http://www.packtpub.com/submit-errata,选择您的图书,点击勘误表提交表格链接,并输入勘误表的详细信息来报告。一旦您的勘误表得到验证,您的提交将被接受,该勘误表将被上传到我们的网站或添加到该标题的勘误表部分下的任何现有勘误表列表中。

要查看之前提交的勘误表,请前往https://www.packtpub.com/books/content/support并在搜索栏中输入图书名称。所需信息将出现在勘误表部分。

海盗行为

互联网上版权材料的盗版是所有媒体都存在的问题。在 Packt,我们非常重视版权和许可证的保护。如果您在互联网上发现我们作品的任何形式的非法拷贝,请立即向我们提供地址或网站名称,以便我们采取补救措施。

请通过copyright@packtpub.com联系我们,并提供可疑盗版材料的链接。

我们感谢您帮助保护我们的作者,以及我们为您带来有价值内容的能力。

问题

如果您对本书的任何方面有问题,可以通过questions@packtpub.com联系我们,我们将尽最大努力解决问题。

一、使用 Java 的数据科学

这本书是关于使用 Java 语言构建数据科学应用程序的。在本书中,我们将涵盖实现项目的所有方面,从数据准备到模型部署。

假设本书的读者以前接触过 Java 和数据科学,本书将有助于将这些知识提升到一个新的水平。这意味着学习如何有效地解决特定的数据科学问题,并最大限度地利用可用数据。

这是一个介绍性的章节,我们将在这里为所有其他章节奠定基础。在这里,我们将讨论以下主题:

  • 什么是机器学习和数据科学?
  • 数据挖掘的跨行业标准流程 ( CRIPS-DM ),一种进行数据科学项目的方法论
  • 面向大中型数据科学应用的 Java 机器学习库

在本章结束时,你将知道如何着手一个数据科学项目,以及使用什么样的 Java 库来做这件事。

数据科学

数据科学是从各种形式的数据中提取可操作知识的学科。数据科学这个名字最近才出现——它是由 DJ Patil 和 Jeff Hammerbacher 发明的,并在 2012 年的文章数据科学家:21 世纪最性感的工作中得到推广。但是这个学科本身已经存在了很长一段时间,之前以其他名字为人所知,如数据挖掘预测分析。数据科学,像它的前辈一样,建立在统计和机器学习算法的基础上,用于知识提取和模型构建。

术语数据科学科学部分并非巧合——如果我们查阅科学,它的定义可以概括为以可测试的解释和预测为术语的知识的系统组织。这正是数据科学家所做的,通过从可用数据中提取模式,他们可以对未来的未知数据进行预测,并确保预测事先得到验证。

如今,数据科学应用于许多领域,包括(但不限于):

  • 银行业:风险管理(例如,信用评分)、欺诈检测、交易
  • 保险:索赔管理(例如,加快索赔审批)、风险和损失评估,以及欺诈检测
  • 保健:预测疾病(如中风、糖尿病、癌症)和复发
  • 零售 电子商务:购物篮分析(识别搭配良好的产品)、推荐引擎、产品分类和个性化搜索

本书涵盖了以下实际使用案例:

  • 预测 URL 是否可能出现在搜索引擎的第一页
  • 在给定硬件规格的情况下,预测操作完成的速度
  • 为搜索引擎排列文本文档
  • 检查图片上是猫还是狗
  • 在社交网络中推荐朋友
  • 在计算机集群上处理大规模文本数据

在所有这些情况下,我们将使用数据科学从数据中学习,并使用学到的知识来解决特定的业务问题。

我们还将在整本书中使用一个运行示例,构建一个搜索引擎。我们将使用它来说明许多数据科学概念,如监督机器学习、降维、文本挖掘和学习排序模型。

机器学习

机器学习是计算机科学的一部分,是数据科学的核心。数据本身,尤其是大量的数据,几乎没有什么用处,但是数据里面隐藏着非常有价值的模式。在机器学习的帮助下,我们可以识别这些隐藏的模式,提取它们,然后将学习到的信息应用到新的看不见的项目上。

例如,给定一个动物的图像,机器学习算法可以说出图片是狗还是猫;或者,考虑到银行客户的历史,它会说客户违约的可能性有多大,即无法偿还债务。

通常,机器学习模型被视为黑盒,它接受数据点并输出对它的预测。在这本书里,我们将看看这些黑盒里有什么,看看如何以及何时最好地使用它们。

机器学习解决的典型问题可以分为以下几组:

  • 监督学习:对于每个数据点,我们都有一个标签- 额外信息,描述我们想要学习的结果。在猫对狗的情况下,数据点是动物的图像;标签描述的是狗还是猫。
  • 无监督学习:我们只有原始数据点,没有标签信息可用。例如,我们有一组电子邮件,我们希望根据它们的相似程度对它们进行分组。没有与电子邮件相关联的明确标签,这使得该问题无人监管。
  • 半监督学习:只对一部分数据给出标签。
  • 强化学习:我们没有标签,有奖励;模型通过与它运行的环境互动得到的东西。基于奖励,它可以适应并最大化它。比如,一个学习下棋的模型,每吃掉对手一个图形就获得一个正奖励,每输一个图形就获得一个负奖励;而报酬与数字的价值成正比。

监督学习

正如我们之前讨论的,对于监督学习,我们有一些信息附加到每个数据点,标签,我们可以训练一个模型来使用它并从中学习。例如,如果我们想建立一个模型,告诉我们一张图片上是狗还是猫,那么图片就是数据点,是狗还是猫的信息就是标签。再比如预测房子的价格——房子的描述就是数据点,价格就是标签。

我们可以根据这些信息的性质将监督学习的算法分为分类和回归算法。

分类问题中,标签来自于某个固定的有限类集合,比如{猫、狗}、{默认、非默认},或者{办公室、美食、娱乐、家居}。根据类的数量不同,分类问题可以是二元(只有两个可能的类)或者多类(几个类)。

分类算法的例子有朴素贝叶斯、逻辑回归、感知器、支持向量机()等等。我们将在第四章第一部分监督学习-分类和回归中更详细地讨论分类算法。

回归**问题中,标号是实数。例如,一个人的年薪可以从 0 美元到几十亿美元不等。因此,预测工资是一个回归问题。

回归算法的例子有线性回归、LASSO、支持向量回归 ( SVR )等。这些算法将在第二部分第四章监督学习-分类和回归中详细描述。

一些监督学习方法是通用的,可以应用于分类和回归问题。例如,决策树、随机森林和其他基于树的方法可以处理这两种类型。我们将在第七章、中讨论一个这样的算法,梯度提升机器。

神经网络还可以同时处理分类和回归问题,我们会在第八章用 DeepLearning4J 进行深度学习中讲到。** **

无监督学习

无监督学习涵盖了我们没有标签可用,但仍然希望找到隐藏在数据中的一些模式的情况。有几种类型的无监督学习,我们将研究聚类分析,或聚类和无监督降维。

使聚集

通常,当人们谈论无监督学习时,他们会谈论聚类分析聚类。聚类分析算法获取一组数据点,并尝试将它们分类成组,使得相似的项目属于同一组,而不同的项目不属于同一组。有许多方法可以使用它,例如,在客户细分或文本分类。

客户细分是聚类的一个例子。给定客户的一些描述,我们尝试将他们分组,使得一个组中的客户具有相似的简档并以相似的方式行为。这些信息可以用来了解这些群体中的人们想要什么,并且这可以用来为他们提供更好的广告和其他促销信息。

再比如文本分类。给定一个文本集合,我们希望在这些文本中找到共同的主题,并根据这些主题排列文本。例如,给定一个电子商务商店中的一组投诉,我们可能希望将谈论类似事情的投诉放在一起,这应该有助于系统用户更容易地浏览投诉。

聚类分析算法的例子有层次聚类、k-means、带噪声的应用的基于密度的空间聚类 ( DBSCAN )等等。我们会在第五章第一部分无监督学习——聚类与降维中详细讲聚类。

降维

另一组无监督学习算法是降维算法。这组算法压缩数据集,只保留最有用的信息。如果我们的数据集包含太多信息,机器学习算法很难同时使用所有这些信息。算法处理所有数据可能需要太长时间,我们希望压缩数据,因此处理时间会更短。

有多种算法可以降低数据的维数,包括主成分分析 ( PCA )、局部线性嵌入和 t-SNE。所有这些算法都是无监督降维技术的例子。

不是所有的降维算法都是无监督的;他们中的一些人可以使用标签来更好地降低维度。例如,许多特征选择算法依赖于标签来查看哪些特征是有用的,哪些是无用的。

我们将在第五章、无监督学习-聚类和降维中详细讨论这一点。

自然语言处理

处理自然语言文本是非常复杂的,它们的结构不是很好,需要大量的清理和规范化。然而,我们周围的文本信息量是巨大的:每分钟都产生大量的文本数据,很难从中检索有用的信息。使用数据科学和机器学习对文本问题也很有帮助;它们让我们找到正确的文本,处理它,并提取有价值的信息。

我们可以通过多种方式使用文本信息。一个例子是信息检索,或者简单地说,文本搜索——给定一个用户查询和一组文档,我们希望在语料库中找到与该查询最相关的文档,并将它们呈现给用户。其他应用包括情感分析——预测产品评论是积极的、中立的还是消极的,或者根据评论如何谈论产品来对评论进行分组。

我们将在第六章中更多地讨论信息检索、自然语言处理 ( NLP )和文本处理——自然语言处理和信息检索中的文本处理。此外,我们将在第 9 章缩放数据科学中了解如何处理大量文本数据。

我们可以用于机器学习和数据科学的方法非常重要。同样重要的是我们创造它们,然后将它们用于生产系统的方式。数据科学流程模型帮助我们使其更有组织性和系统性,这也是我们接下来将讨论它们的原因。

数据科学过程模型

应用数据科学不仅仅是选择合适的机器学习算法并将其用于数据。记住机器学习只是项目的一小部分总是好的;还有其他部分,如了解问题、收集数据、测试解决方案和部署到生产环境中。

当从事任何项目时,不仅仅是数据科学项目,将它分解成更小的可管理的部分并逐个完成它们是有益的。对于数据科学,有描述如何以最佳方式完成的最佳实践,它们被称为流程模型。有多个型号,包括 CRISP-DM 和 OSEMN。

在本章中,CRISP-DM 被解释为获取、擦洗、探索、建模和解释 ( OSEMN ),它更适合于数据分析任务,并在较小的程度上解决了许多重要步骤。

CRISP-DM

数据挖掘的跨行业标准过程 ( CRISP-DM )是一种开发数据挖掘应用的过程方法论。它是在术语数据科学变得流行之前创建的,它是可靠的,并且经过了几代分析的时间考验。这些实践现在仍然有用,并且很好地描述了任何分析项目的高级步骤。

图片来源:https://en . Wikipedia . org/wiki/File:CRISP-DM _ Process _ diagram . png

CRISP-DM 方法将项目分解为以下步骤:

  • 商业理解
  • 数据理解
  • 数据准备
  • 建模
  • 估价
  • 部署

方法本身定义的不仅仅是这些步骤,但是通常了解步骤是什么以及每个步骤发生了什么对于一个成功的数据科学项目来说已经足够了。让我们分别看一下这些步骤。

第一步业务理解。这一步旨在了解企业存在什么样的问题,以及他们希望通过解决这些问题来实现什么。为了取得成功,数据科学应用程序必须对业务有用。这一步的结果是我们想要解决的问题的公式化,以及项目期望的结果是什么。

第二步是数据理解。在这一步,我们试图找出哪些数据可以用来解决问题。我们还需要找出我们是否已经有了数据;如果没有,我们需要思考如何才能得到它。根据我们找到(或没有找到)的数据,我们可能想要改变最初的目标。

当数据被收集后,我们需要探索它。审查数据的过程通常被称为探索性数据分析,它是任何数据科学项目不可或缺的一部分。它有助于理解创建数据的过程,并且已经可以提出解决问题的方法。这一步的结果是了解解决问题需要哪些数据源。我们将在第三章探索性数据分析中详细讲述这一步。

CRISP-DM 的第三步是数据准备。为了使数据集有用,需要对其进行清理并将其转换为表格形式。表格形式意味着每行恰好对应一个观察值。如果我们的数据不是这种形状,它就不能被大多数机器学习算法使用。因此,我们需要准备数据,以便最终可以将其转换为矩阵形式并提供给模型。

此外,可能存在包含所需信息的不同数据集,并且它们可能不是同质的。这意味着我们需要将这些数据集转换成某种通用格式,以便模型能够读取。

这一步还包括特征工程——创建最能提供问题信息并以最佳方式描述数据的特征的过程。

许多数据科学家表示,在构建数据科学应用程序时,他们将大部分时间花在这一步上。我们将在第二章数据处理工具箱以及整本书中谈到这一步。

第四步建模。在这一步中,数据已经处于正确的形状,我们将它馈送给不同的机器学习算法。这一步还包括参数调整、特性选择和选择最佳模型。

从机器学习的角度评估模型的质量发生在这个步骤中。要检查的最重要的事情是归纳的能力,这通常是通过交叉验证来完成的。在这一步中,我们可能还想回到上一步,做额外的清理和功能工程。结果是一个模型,它可能对解决步骤 1 中定义的问题有用。

第五步评估。它包括从商业角度评估模型——而不是从机器学习的角度。这意味着我们需要对迄今为止的结果进行严格审查,并计划下一步行动。模型达到我们想要的了吗?此外,一些发现可能会导致重新考虑最初的问题。在这一步之后,我们可以转到部署步骤或重复流程。

最后的第六步是模型部署。在这个步骤中,生产的模型被添加到产品中,因此结果是模型被集成到活动系统中。我们将在第 10 章部署数据科学模型中介绍这一步骤。

通常,评估是困难的,因为并不总是能够说出模型是否达到了预期的结果。在这些情况下,可以将评估和部署步骤合并为一个步骤,只对部分用户部署和应用模型,然后收集用于评估模型的数据。我们还将在本书的最后一章简要介绍他们的做法,如 A/B 测试和多臂土匪。

连续的例子

整本书会有很多实际的用例,有时每章会有几个。但是我们也会有一个运行的例子,构建一个搜索引擎。这个问题很有趣,原因有很多:

  • 这很有趣
  • 几乎任何领域的企业都可以从搜索引擎中受益
  • 许多企业已经有了文本数据;通常它没有被有效地使用,并且它的使用可以被改进
  • 处理文本需要很大的努力,学会有效地处理文本是很有用的

我们将尽量保持简单,但是,通过这个例子,我们将在整本书中触及数据科学过程的所有技术部分:

  • 数据理解:哪些数据可以对问题有用?我们如何获得这些数据?
  • 数据准备:数据一旦获得,我们该如何处理?如果是 HTML,我们如何从中提取文本?我们如何从文本中提取单个的句子和单词?
  • 建模:根据文档与查询的相关性对文档进行排序是一个数据科学问题,我们将讨论如何实现这个问题。
  • 评估:可以对搜索引擎进行测试,看它对解决业务问题是否有用。
  • 部署:最后,引擎可以作为 REST 服务部署,或者直接集成到实时系统中。

我们将在第二章数据处理工具箱中获取并准备数据,在第三章探索性数据分析中理解数据,在第四章监督机器学习-分类和回归中构建简单模型并进行评估,在第六章中查看如何处理文本使用文本-自然语言处理和信息检索, 在第 9 章、扩展数据科学中了解如何将它应用于数百万个网页,最后,在第 10 章、部署数据科学模型中了解我们如何部署它。

Java 中的数据科学

在本书中,我们将使用 Java 进行数据科学项目。乍一看,Java 似乎不是数据科学的好选择,不像 Python 或 R,它的数据科学和机器学习库更少,更冗长,缺乏交互性。另一方面,它有很多优点,如下所示:

  • Java 是一种静态类型的语言,这使得维护代码库更容易,更难犯愚蠢的错误——编译器可以检测到其中的一些错误。
  • 数据处理的标准库非常丰富,甚至还有更丰富的外部库。
  • Java 代码通常比通常用于数据科学的脚本语言(如 R 或 Python)的代码更快。
  • Maven 是 Java 世界中依赖性管理的事实上的标准,它使得向项目中添加新的库和避免版本冲突变得非常容易。
  • 大多数用于可扩展数据处理的大数据框架都是用 Java 或 JVM 语言编写的,如 Apache Hadoop、Apache Spark 或 Apache Flink。
  • 生产系统通常是用 Java 编写的,用其他语言构建模型会增加不必要的复杂性。用 Java 创建模型使得将它们集成到产品中变得更加容易。

接下来,我们将看看 Java 中可用的数据科学库。

数据科学图书馆

虽然与 R 相比,Java 中的数据科学库不多,但也不少。此外,通常可以使用用其他 JVM 语言编写的机器学习和数据挖掘库,如 Scala、Groovy 或 Clojure。因为这些语言共享运行时环境,所以很容易导入用 Scala 编写的库,并直接在 Java 代码中使用它们。

我们可以将图书馆分为以下几类:

  • 数据处理库
  • 数学和统计库
  • 机器学习和数据挖掘库
  • 文本处理库

现在,我们将详细了解它们。

数据处理库

标准 Java 库非常丰富,提供了很多数据处理工具,比如集合、I/O 工具、数据流和并行任务执行的手段。

标准库有非常强大的扩展,例如:

我们将在第 2 章数据处理工具箱中介绍数据处理的标准 API 及其扩展。在本书中,我们将使用 Maven 来包含外部库,如 Google Guava 或 Apache Commons IO。它是一个依赖管理工具,允许用几行 XML 代码指定外部依赖。比如添加谷歌番石榴,在pom.xml中声明如下依赖关系就足够了:

<dependency> 
 <groupId>com.google.guava</groupId> 
 <artifactId>guava</artifactId> 
 <version>19.0</version> 
</dependency>

当我们这样做时,Maven 将转到 Maven 中央存储库并下载指定版本的依赖项。找到pom.xml(比如上一个)的依赖片段的最好方法是在https://mvnrepository.com或者你最喜欢的搜索引擎上搜索。

Java 提供了一种通过 Java 数据库连接 ( JDBC )访问数据库的简单方法——一种统一的数据库访问协议。JDBC 使得连接几乎任何支持 SQL 的关系数据库成为可能,比如 MySQL、MS SQL、Oracle、PostgreSQL 等等。这允许将数据操作从 Java 转移到数据库端。

当无法使用数据库处理表格数据时,我们可以使用 DataFrame 库直接在 Java 中处理。DataFrame 是一种最初来自 R 的数据结构,它允许在程序中轻松地操作文本数据,而无需借助外部数据库。

例如,使用数据帧,可以根据某些条件过滤行,对列的每个元素应用相同的操作,按某些条件分组或与另一个数据帧连接。此外,一些数据框库可以轻松地将表格数据转换为矩阵形式,以便机器学习算法可以使用这些数据。

Java 中有一些数据框库。其中一些如下:

我们还将在第 2 章数据处理工具箱中介绍数据库和数据框架,我们将在整本书中使用数据框架。

还有更复杂的数据处理库比如 Spring Batch(http://projects.spring.io/spring-batch/)。它们允许创建复杂的数据管道(从提取-转换-加载称为 ETL)并管理它们的执行。

此外,还有用于分布式数据处理的库,例如:

我们将在第 9 章扩展数据科学中讨论分布式数据处理。

数学和统计库

标准 Java 库中的数学支持相当有限,只包括计算对数的log、计算指数的exp等基本方法。

有更丰富的数学支持的外部库。例如:

此外,许多机器学习库带有一些额外的数学功能,通常是线性代数、统计和优化。

机器学习和数据挖掘库

有相当多的机器学习和数据挖掘库可用于 Java 和其他 JVM 语言。其中一些如下:

有几个专门研究神经网络的库:

我们将在整本书中介绍其中的一些库。

文本处理

可以只使用标准 Java 库进行简单的文本处理,该库包含诸如StringTokenizerjava.text包或正则表达式之类的类。

除此之外,还有各种各样的文本处理框架可用于 Java,如下所示:

大多数 NLP 库具有非常相似的功能和算法覆盖范围,这就是为什么选择使用哪一个通常是一个习惯或品味的问题。它们通常都有标记化、解析、词性标注、命名实体识别和其他文本处理算法。其中有些(比如 StanfordNLP)支持多种语言,有些只支持英语。

我们将在第 6 章、中介绍这些库,使用文本自然语言处理和信息检索

摘要

在本章中,我们简要讨论了数据科学以及机器学习在其中扮演的角色。然后我们谈到做一个数据科学项目,以及什么方法论对它有用。我们讨论了其中的一个,CRISP-DM,它定义的步骤,这些步骤是如何关联的,以及每个步骤的结果。

最后,我们谈到了为什么用 Java 做数据科学项目是一个好主意,它是静态编译的,它很快,而且通常现有的生产系统已经在 Java 中运行。我们还提到了使用 Java 语言成功完成数据科学项目的库和框架。

有了这个基础,我们现在将进入数据科学项目中最重要(也是最耗时)的步骤——数据准备。**

二、数据处理工具箱

在前一章中,我们讨论了处理数据科学问题的最佳实践。我们看了 CRISP-DM,它是处理数据挖掘项目的方法论,其中第一步是数据预处理。在这一章中,我们将仔细看看如何在 Java 中做到这一点。

具体来说,我们将涵盖以下主题:

  • 标准 Java 库
  • 标准库的扩展
  • 从不同来源读取数据,比如文本、HTML、JSON 和数据库
  • 用于操作表格数据的数据框架

最后,我们会把所有东西放在一起,为搜索引擎准备数据。

本章结束时,你将能够处理数据,使其可用于机器学习和进一步分析。

标准 Java 库

标准 Java 库非常丰富,提供了许多数据操作工具,包括:

  • 用于在内存中组织数据的集合
  • 用于读写数据的 I/O
  • 简化数据转换的流式 API

在本章中,我们将详细了解所有这些工具。

收集

数据是数据科学最重要的部分。当处理数据时,它需要被有效地存储和处理,为此我们使用数据结构。数据结构描述了有效存储数据以解决特定问题的方法,Java 集合 API 是数据结构的标准 Java API。这个 API 提供了在实际数据科学应用中有用的各种各样的实现。

我们不会详细描述集合 API,而是集中在最有用和最重要的部分——列表、集合和映射接口。

列表是集合,其中每个元素都可以通过其索引来访问。List接口的 g0-to 实现是ArrayList,它应该在 99%的情况下使用,可以如下使用:

List<String> list = new ArrayList<>(); 
list.add("alpha"); 
list.add("beta"); 
list.add("beta"); 
list.add("gamma"); 
System.out.println(list);

还有其他的List接口、LinkedListCopyOnWriteArrayList的实现,但是它们很少被用到。

Set 是集合 API 中的另一个接口,它描述了一个不允许重复的集合。如果我们插入元素的顺序不重要,那么首选实现是HashSet,如果顺序重要,那么首选实现是LinkedHashSet。我们可以如下使用它:

Set<String> set = new HashSet<>(); 
set.add("alpha"); 
set.add("beta"); 
set.add("beta"); 
set.add("gamma"); 
System.out.println(set);

ListSet都实现了Iterable接口,这使得它们可以使用for-each循环:

for (String el : set) { 
    System.out.println(el); 
}

Map接口允许将键映射到值,在其他语言中有时被称为字典或关联数组。g0-to 实现是HashMap:

Map<String, String> map = new HashMap<>(); 
map.put("alpha", "α"); 
map.put("beta", "β"); 
map.put("gamma", "γ"); 
System.out.println(map);

如果需要保持插入顺序,可以使用LinkedHashMap;如果你知道map接口将被多线程访问,使用ConcurrentHashMap

Collections类提供了几个助手方法来处理集合,比如排序,或者提取max或者min元素:

String min = Collections.min(list); 
String max = Collections.max(list); 
System.out.println("min: " + min + ", max: " + max); 
Collections.sort(list); 
Collections.shuffle(list);

还有其他集合,比如QueueDequeStack、线程安全集合等等。它们不太常用,对数据科学也不是很重要。

输入/输出

数据科学家经常使用文件和其他数据源。I/O 是从数据源读取和写回结果所必需的。Java I/O API 为此提供了两种主要的抽象类型:

  • InputStreamOutputStream为二进制数据
  • ReaderWriter为文本数据

典型的数据科学应用处理文本而不是原始的二进制数据——数据通常以 TXT、CSV、JSON 和其他类似的文本格式存储。这就是为什么我们将集中讨论第二部分。

读取输入数据

能够读取数据是数据科学家最重要的技能,这些数据通常是文本格式,可以是 TXT、CSV 或任何其他格式。在 Java I/O API 中,Reader类的子类处理读取文本文件。

假设我们有一个包含一些句子的text.txt文件(这些句子可能有意义,也可能没有意义):

  • 我的狗也喜欢吃香肠
  • 除了盈余外,马达还能接受
  • 每一个有能力的斜线成功与世界范围的指责
  • 持续的任务在内疚的吻周围咳嗽

如果需要将整个文件作为字符串列表读取,通常的 Java I/O 方式是使用BufferedReader:

List<String> lines = new ArrayList<>(); 

try (InputStream is = new FileInputStream("data/text.txt")) { 
    try (InputStreamReader isReader = new InputStreamReader(is,
               StandardCharsets.UTF_8)) { 
        try (BufferedReader reader = new BufferedReader(isReader)) { 
            while (true) { 
                String line = reader.readLine(); 
                if (line == null) { 
                    break; 
                } 
                lines.add(line); 
            } 

            isReader.close(); 
        } 
    } 
}

提供字符编码很重要——这样,Reader就知道如何将字节序列转换成合适的String对象。除了 UTF 8,还有 UTF-16,ISO-8859(这是基于 ASCII 的英语文本编码)和许多其他标准。

直接获取文件的BufferedReader有一个快捷方式:

Path path = Paths.get("data/text.txt"); 
try (BufferedReader reader = Files.newBufferedReader(path,
        StandardCharsets.UTF_8)) { 
    // read line-by-line 
}

即使使用这种快捷方式,您也可以看到,对于从文件中读取一系列行这样简单的任务来说,这是非常冗长的。您可以将它封装在一个助手函数中,或者使用 Java NIO API,它提供了一些助手方法来简化这项任务:

Path path = Paths.get("data/text.txt"); 
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8); 
System.out.println(lines);

Java NIO 快捷方式仅适用于文件。稍后,我们将讨论适用于任何 InputStream 对象的快捷方式,而不仅仅是文件。

写入输出数据

在数据被读取和处理后,我们通常希望将它放回磁盘。对于文本,这通常使用Writer对象来完成。

假设我们从text.txt中读取句子,我们需要将每一行转换成大写,并将它们写回一个新文件output.txt;编写文本数据最方便的方式是通过PrintWriter类:

try (PrintWriter writer = new PrintWriter("output.txt", "UTF-8")) { 
    for (String line : lines) { 
        String upperCase = line.toUpperCase(Locale.US); 
        writer.println(upperCase); 
    } 
}

在 Java NIO API 中,它看起来像这样:

Path output = Paths.get("output.txt"); 
try (BufferedWriter writer = Files.newBufferedWriter(output,     
           StandardCharsets.UTF_8)) { 
    for (String line : lines) { 
        String upperCase = line.toUpperCase(Locale.US); 
        writer.write(upperCase); 
        writer.newLine(); 
    } 
}

两种方法都是正确的,你应该选择你喜欢的那一种。然而,记住总是包括编码是很重要的;否则,它可能会使用一些依赖于平台的默认值,有时是任意的。

流式 API

Java 8 是 Java 语言历史上的一大进步。在其他特性中,有两个重要的东西——流和 Lambda 表达式。

在 Java 中,流是一个对象序列,Streams API 提供了函数式的操作来转换这些序列,比如 map、filter 和 reduce。流的源可以是包含元素的任何东西,例如数组、集合或文件。

例如,让我们创建一个简单的Word类,它包含一个令牌及其词性:

public class Word { 
    private final String token; 
    private final String pos; 
    // constructor and getters are omitted 
}

为了简洁起见,我们将总是省略这种数据类的构造函数和 getters,但是用注释指出这一点。

现在,我们来考虑一句话我的狗也喜欢吃香肠。使用这个类,我们可以将其表示如下:

Word[] array = { new Word("My", "RPR"), new Word("dog", "NN"), 
     new Word("also", "RB"), new Word("likes", "VB"), 
     new Word("eating", "VB"), new Word("sausage", "NN"), 
     new Word(".", ".") };

这里,我们使用 Penn Treebank POS 符号,其中NN表示名词,或者VB表示动词。

现在,我们可以使用Arrays.stream实用程序方法将这个数组转换成一个流:

Stream<Word> stream = Arrays.stream(array);

可以使用stream方法从集合中创建流:

List<Word> list = Arrays.asList(array); 
Stream<Word> stream = list.stream();

流上的操作被链接在一起,形成了美观易读的数据处理管道。对流最常见的操作是映射和过滤操作:

  • Map 对每个元素应用相同的变换函数
  • 给定一个谓词函数,Filter 过滤掉不满足它的元素

在管道的末端,您使用收集器收集结果。Collectors类提供了几个实现,比如toListtoSettoMap等等。

假设我们只想保留名词标记。使用 Streams API,我们可以如下操作:

List<String> nouns = list.stream() 
        .filter(w -> "NN".equals(w.getPos())) 
        .map(Word::getToken) 
        .collect(Collectors.toList()); 
System.out.println(nouns);

或者,我们可能想要检查流中有多少唯一的 POS 标签。为此,我们可以使用toSet收集器:

Set<String> pos = list.stream() 
        .map(Word::getPos) 
        .collect(Collectors.toSet()); 
System.out.println(pos);

在处理文本时,我们有时可能希望将一系列字符串连接在一起:

String rawSentence = list.stream() 
        .map(Word::getToken) 
        .collect(Collectors.joining(" ")); 
System.out.println(rawSentence);

或者,我们可以根据单词的POS标签对它们进行分组:

Map<String, List<Word>> groupByPos = list.stream() 
        .collect(Collectors.groupingBy(Word::getPos)); 
System.out.println(groupByPos.get("VB")); 
System.out.println(groupByPos.get("NN"));

此外,还有一个有用的toMap收集器,它可以使用一些字段对集合进行索引。例如,如果我们想获得从令牌到Word对象的映射,可以使用下面的代码来实现:

Map<String, Word> tokenToWord = list.stream() 
        .collect(Collectors.toMap(Word::getToken, Function.identity())); 
System.out.println(tokenToWord.get("sausage"));

除了对象流,Streams API 还提供了原语流——整型、双精度型和其他原语流。这些流有用于统计计算的有用方法,例如summaxminaverage。可以使用mapToIntmapToDouble等函数将普通流转换为原始流。

例如,这就是我们如何找到句子中所有单词的最大长度:

int maxTokenLength = list.stream() 
        .mapToInt(w -> w.getToken().length()) 
        .max().getAsInt(); 
System.out.println(maxTokenLength);

流操作易于并行化;它们分别应用于每一项,因此多线程可以做到这一点,而不会相互干扰。因此,通过将工作分散到多个处理器上并并行执行所有任务,可以大大加快这些操作的速度。

Java 利用了这一点,并提供了一种简单而富于表现力的方法来创建并行代码;对于集合,您只需要调用parallelStream方法:

int[] firstLengths = list.parallelStream() 
        .filter(w -> w.getToken().length() % 2 == 0) 
        .map(Word::getToken) 
        .mapToInt(String::length) 
        .sequential() 
        .sorted() 
        .limit(2) 
        .toArray(); 
System.out.println(Arrays.toString(firstLengths));

在这个例子中,过滤和映射是并行完成的,但是随后流被转换成顺序流,被排序,并且最上面的两个元素被提取到一个数组中。虽然这个例子不是很有意义,但是它展示了使用流可以做多少事情。

最后,标准 Java I/O 库提供了一些方便的方法。例如,可以使用Files.lines方法将文本文件表示为一串行:

Path path = Paths.get("text.txt"); 
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) { 
    double average = lines 
        .flatMap(line -> Arrays.stream(line.split(" "))) 
        .map(String::toLowerCase) 
        .mapToInt(String::length) 
        .average().getAsDouble(); 
    System.out.println("average token length: " + average); 
}

流是处理数据的一种表达性强、功能强大的方式,掌握这个 API 对于用 Java 做数据科学非常有帮助。稍后,我们将经常使用 Stream API,因此您将看到更多如何使用它的示例。

标准库的扩展

标准 Java 库非常强大,但是有些东西需要花很长时间来编写,或者根本就没有。标准库有许多扩展,最突出的库是 Apache Commons(一组库)和 Google Guava。它们使得使用标准 API 或扩展它变得更加容易,例如,通过添加新的集合或实现。

首先,我们将简要介绍这些库的最相关部分,稍后我们将看到它们在实践中是如何使用的。

Apache common(Apache 公共)

Apache Commons 是 Java 开源库的集合,目标是创建可重用的 Java 组件。有很多,包括 Apache Commons Lang、Apache Commons IO、Apache Commons Collections 等等。

公共语言

Apache Commons Lang 是一组扩展了java.util包的实用程序类,它们通过提供许多解决常见问题并节省大量时间的小方法,使 Java 开发人员的生活变得更加轻松。

为了在 Java 中包含外部库,我们通常使用 Maven,这使得管理依赖关系变得非常容易。有了 Maven,Apache Commons Lang 库可以使用下面的dependency片段来包含:

<dependency> 
  <groupId>org.apache.commons</groupId> 
  <artifactId>commons-lang3</artifactId> 
  <version>3.4</version> 
</dependency>

该库包含了许多对通用 Java 编程有用的方法,比如使实现对象的equalshashCode方法、序列化助手和其他方法变得更加容易。总的来说,它们并不特别适用于数据科学,但是有一些辅助函数非常有用。举个例子,

  • 用于生成数据的RandomUtilsRandomStringUtils
  • StringEscapeUtilsLookupTranslator用于转义和不转义字符串
  • EqualsBuilderHashCodeBuilder用于快速执行equalshashCode方法
  • StringUtilsWordUtils了解有用的字符串操作方法
  • Pair

如需了解更多信息,您可以阅读位于 https://commons.apache.org/lang 的文档。

查看可用内容的最佳方式是下载该包并查看其中的可用代码。每个 Java 开发者都会发现很多有用的东西。

公共 IO

像 Apache Commons Lang 扩展了java.util标准包,Apache Commons IO 扩展了java.io;这是一个 Java 实用程序库,用于协助 Java 中的 I/O,正如我们之前了解到的,它可能非常冗长。

要在您的项目中包含该库,请将dependency片段添加到pom.xml文件中:

<dependency> 
  <groupId>commons-io</groupId> 
  <artifactId>commons-io</artifactId> 
  <version>2.5</version> 
</dependency>

我们已经从 Java NIO 中了解了Files.lines。虽然它很方便,但我们并不总是处理文件,有时需要从其他InputStream获取行,例如,一个网页或一个 web 套接字。

为此,Commons IO 提供了一个非常有用的实用程序类IOUtils。使用它,将整个输入流读入字符串或字符串列表非常容易:

try (InputStream is = new FileInputStream("data/text.txt")) { 
    String content = IOUtils.toString(is, StandardCharsets.UTF_8); 
    System.out.println(content); 
} 

try (InputStream is = new FileInputStream("data/text.txt")) { 
    List<String> lines = IOUtils.readLines(is, StandardCharsets.UTF_8); 
    System.out.println(lines); 
}

虽然我们在这个例子中使用了FileInputStream对象,但是它可以是任何其他的流。第一种方法IOUtils.toString特别有用,我们稍后将使用它来抓取网页和处理来自 web 服务的响应。

在这个库中有很多更有用的 I/O 方法,为了得到一个好的概述,你可以参考在https://commons.apache.org/io获得的文档。

公共收藏

Java Collections API 非常强大,它为 Java 中的数据结构定义了一组很好的抽象。Commons 集合使用这些抽象,并用新的实现和新的集合来扩展标准集合 API。要包含该库,请使用以下代码片段:

<dependency> 
  <groupId>org.apache.commons</groupId> 
  <artifactId>commons-collections4</artifactId> 
  <version>4.1</version> 
</dependency>

该库中一些有用的收藏有:

  • Bag:这是可以多次保存同一元素的集合的接口
  • BidiMap:这代表双向地图。它可以从键映射到值,也可以从值映射到键

它与 Google Guava 中的集合有一些重叠,这将在下一节课中解释,但是它还有一些没有实现的附加集合。举个例子,

  • LRUMap:用于实现缓存
  • PatriciaTrie:用于快速字符串前缀查找

其他公共模块

Commons Lang、IO 和 Collections 是众多 Commons 库中的几个。还有其他对数据科学有用的公共模块:

  • Commons compress 用于读取压缩文件,例如,bzip2(用于读取维基百科转储)、gzip7z
  • Commons CSV 用于读取和写入 CSV 文件(我们将在后面使用它)
  • Commons math 用于统计计算和线性代数(我们稍后也会用到)

你可以参考https://commons.apache.org/的完整列表。

谷歌番石榴

谷歌番石榴很像阿帕奇 Commons 它是一组实用程序,扩展了标准的 Java API,使生活变得更加简单。但是与 Apache Commons 不同的是,Google Guava 是一个同时涵盖许多领域的库,包括集合和 I/O。

要将其包含在项目中,请使用dependency:

<dependency> 
  <groupId>com.google.guava</groupId> 
  <artifactId>guava</artifactId> 
  <version>19.0</version> 
</dependency>

我们将从 Guava I/O 模块开始。为了举例说明,我们将使用一些生成的数据。我们已经使用了word类,它包含一个令牌及其词性标签,这里我们将生成更多的单词。要做到这一点,我们可以使用数据生成工具,如 http://www.generatedata.com/和 T2。让我们定义下面的模式,如下面的屏幕截图所示:

之后,可以将生成的数据保存为 CSV 格式,将分隔符设置为制表符(t),并将其保存到words.txt。我们已经为您生成了一个文件;你可以在chapter2库中找到它。

Guava 定义了一些处理 I/O 的抽象。其中之一是CharSource,它是对任何基于字符的数据源的抽象,在某种意义上,它与标准的Reader类非常相似。此外,与 Commons IO 类似,还有一个用于处理文件的实用程序类。它被称为Files(不要与java.nio.file.Files混淆),并且包含帮助函数,使文件 I/O 更容易。使用这个类,可以读取文本文件的所有行,如下所示:

File file = new File("data/words.txt"); 
CharSource wordsSource = Files.asCharSource(file, StandardCharsets.UTF_8); 
List<String> lines = wordsSource.readLines();

Google Guava Collections 遵循与 Commons Collections 相同的理念;它建立在标准集合 API 的基础上,提供了新的实现和抽象。有一些实用程序类,比如用于列表的Lists,用于集合的Sets,等等。

Lists中的一个方法是transform,它就像流中的map,它应用于列表中的每个元素。结果列表的元素被延迟评估;仅当需要元素时,才触发函数的计算。让我们用它将文本文件中的行转换成一列Word对象:

List<Word> words = Lists.transform(lines, line -> { 
    String[] split = line.split("t"); 
    return new Word(split[0].toLowerCase(), split[1]); 
});

这与 Streams API 中的 map 的主要区别在于,transform 会立即返回一个列表,因此不需要首先创建一个流,调用map函数,最后将结果收集到列表中。

与 Commons 集合类似,Java API 中也有新的集合不可用。对数据科学最有用的集合是MultisetMultimapTable

Multisets 是同一元素可以多次存储的集合,通常用于计数。当我们想要计算每个术语出现的次数时,这个类对于文本处理特别有用。

让我们看看我们读到的单词,并计算每个pos标签出现的次数:

Multiset<String> pos = HashMultiset.create(); 
for (Word word : words) { 
    pos.add(word.getPos()); 
}

如果我们想输出按计数排序的结果,有一个特殊的实用函数:

Multiset<String> sortedPos = Multisets.copyHighestCountFirst(pos); 
System.out.println(sortedPos);

Multimap 是一个映射,每个键可以有多个值。多地图有几种类型。两种最常见的地图如下:

  • ListMultimap:这将一个键与一个值列表相关联,类似于Map<Key, List<Value>>
  • SetMultimap:这将一个键与一组值相关联,类似于Map<Key, Set<Value>>

这对于实现group by逻辑非常有用。让我们看看每个POS标签的平均长度:

ArrayListMultimap<String, String> wordsByPos = ArrayListMultimap.create();
for (Word word : words) {
    wordsByPos.put(word.getPos(), word.getToken());
}

可以将多地图视为集合地图:

Map<String, Collection<String>> wordsByPosMap = wordsByPos.asMap(); 
wordsByPosMap.entrySet().forEach(System.out::println);

最后,Table集合可以看作是map接口的二维扩展;现在,不是一个键,每个条目由两个键索引,row键和column键。除此之外,还可以使用column键获得整列,或者使用row键获得一行。

例如,我们可以计算每个(单词、词性)对在数据集中出现的次数:

Table<String, String, Integer> table = HashBasedTable.create(); 
for (Word word : words) { 
    Integer cnt = table.get(word.getPos(), word.getToken()); 
    if (cnt == null) { 
        cnt = 0; 
    } 
    table.put(word.getPos(), word.getToken(), cnt + 1); 
}

将数据放入表中后,我们可以分别访问行和列:

Map<String, Integer> nouns = table.row("NN"); 
System.out.println(nouns); 

String word = "eu"; 
Map<String, Integer> posTags = table.column(word); 
System.out.println(posTags);

像在 Commons Lang 中一样,Guava 也包含用于处理原语的实用程序类,比如用Ints处理int原语,用Doubles处理double原语,等等。例如,它可用于将原始包装的集合转换为原始数组:

Collection<Integer> values = nouns.values(); 
int[] nounCounts = Ints.toArray(values); 
int totalNounCount = Arrays.stream(nounCounts).sum(); 
System.out.println(totalNounCount);

最后,Guava 为排序数据提供了一个很好的抽象- Ordering,它扩展了标准的Comparator接口。它为创建比较器提供了清晰流畅的界面:

Ordering<Word> byTokenLength =  
        Ordering.natural().<Word> onResultOf(w -> w.getToken().length()).reverse(); 
List<Word> sortedByLength = byTokenLength.immutableSortedCopy(words); 
System.out.println(sortedByLength);

由于Ordering实现了Comparator接口,它可以用于任何需要比较器的地方。例如,对于Collections.sort:

List<Word> sortedCopy = new ArrayList<>(words); 
Collections.sort(sortedCopy, byTokenLength);

除此之外,它还提供了其他方法,如提取 top-k 或 bottom-k 元素:

List<Word> first10 = byTokenLength.leastOf(words, 10); 
System.out.println(first10); 
List<Word> last10 = byTokenLength.greatestOf(words, 10); 
System.out.println(last10);

它与先排序,然后取第一个或最后一个 k 元素相同,但效率更高。

还有其他有用的类:

  • 可定制的哈希实现,如杂音哈希和其他
  • Stopwatch用于测量时间

更多见解,可以参考https://github.com/google/guavahttps://github.com/google/guava/wiki

你可能已经注意到了,番石榴和阿帕奇共有地有很多共同点。选择使用哪一个是个人喜好的问题——两个库都经过了很好的测试,并在许多生产系统中得到积极的应用。但是番石榴的开发更加积极,新功能出现的频率也更高,所以如果你只想用其中的一种,那么番石榴可能是更好的选择。

美国在线独眼巨人反应

正如我们已经了解的,Java Streams API 是以函数方式处理数据的一种非常强大的方式。Cyclops React 库通过在流上添加新的操作来扩展这个 API,并允许对流执行进行更多的控制。要包含该库,将其添加到pom.xml文件:

<dependency> 
  <groupId>com.aol.simplereact</groupId> 
  <artifactId>cyclops-react</artifactId> 
  <version>1.0.0-RC4</version> 
</dependency>

它添加的一些方法是zipWithIndex和 cast 以及便利收集器,如toListtoSettoMap。更重要的是,它为并行执行提供了更多的控制,例如,可以提供一个自定义执行器,用于处理数据或以声明方式拦截异常。

同样,使用这个库,很容易从迭代器创建并行流——使用标准库很难做到这一点。

例如,我们以words.txt为例,从中提取所有的 POS 标签,然后创建一个映射,将每个标签与一个惟一的索引相关联。对于读取数据,我们将使用来自 Commons IO 的LineIterator,否则仅使用标准 Java APIs 很难并行化。此外,我们创建一个定制的执行器,它将用于并行执行流操作:

LineIterator it = FileUtils.lineIterator(new File("data/words.txt"), "UTF-8"); 
ExecutorService executor = Executors.newCachedThreadPool(); 
LazyFutureStream<String> stream =  
        LazyReact.parallelBuilder().withExecutor(executor).from(it); 

Map<String, Integer> map = stream 
        .map(line -> line.split("t")) 
        .map(arr -> arr[1].toLowerCase()) 
        .distinct() 
        .zipWithIndex() 
        .toMap(Tuple2::v1, t -> t.v2.intValue()); 

System.out.println(map); 
executor.shutdown(); 
it.close();

这是一个非常简单的例子,并没有描述这个库中所有可用的功能。欲了解更多信息,请参考他们的文档,可在https://github.com/aol/cyclops-react找到。我们还会在后面章节的其他例子中用到它。

访问数据

到目前为止,我们已经花了很多时间来描述如何读写数据。但是还有更多的内容:数据通常以不同的格式出现,比如 CSV、HTML 或 JSON,或者可以存储在数据库中。了解如何访问和处理这些数据对于数据科学非常重要,现在我们将详细描述如何对最常见的数据格式和数据源进行访问和处理。

文本数据和 CSV

我们已经详细讨论过读取文本数据,例如,可以使用 NIO API 中的Files helper 类或 Commons IO 中的IOUtils来完成。

CSV(逗号分隔值)是在纯文本文件中组织表格数据的常用方法。虽然手动解析 CSV 文件是可能的,但是有一些极限情况,这使得它有点麻烦。幸运的是,有很好的库可以实现这个目的,其中之一就是 Apache Commons CSV:

<dependency> 
  <groupId>org.apache.commons</groupId> 
  <artifactId>commons-csv</artifactId> 
  <version>1.4</version> 
</dependency>

为了说明如何使用这个库,让我们再次生成一些随机数据。这次我们也可以使用http://www.generatedata.com/并定义以下模式:

现在我们可以创建一个特殊的类来保存这些数据:

public static class Person { 
    private final String name; 
    private final String email; 
    private final String country; 
    private final int salary; 
    private final int experience; 
    // constructor and getters are omitted  
}

然后,要读取 CSV 文件,您可以执行以下操作:

List<Person> result = new ArrayList<>(); 

Path csvFile = Paths.get("data/csv-example-generatedata_com.csv"); 
try (BufferedReader reader = Files.newBufferedReader(csvFile, StandardCharsets.UTF_8)) { 
    CSVFormat csv = CSVFormat.RFC4180.withHeader(); 
    try (CSVParser parser = csv.parse(reader)) { 
        Iterator<CSVRecord> it = parser.iterator(); 
        it.forEachRemaining(rec -> { 
            String name = rec.get("name"); 
            String email = rec.get("email"); 
            String country = rec.get("country"); 
            int salary = Integer.parseInt(rec.get("salary").substring(1)); 
            int experience = Integer.parseInt(rec.get("experience")); 
            Person person = new Person(name, email, country, salary, experience); 
            result.add(person); 
        }); 
    } 
}

前面的代码创建了一个CSVRecord对象的迭代器,我们从每个这样的对象中提取值,并将它们传递给一个数据对象。当 CSV 文件非常大,可能无法完全容纳在可用内存中时,创建迭代器非常有用。

如果文件不太大,也可以一次读取整个 CSV 并将结果放入一个列表中:

List<CSVRecord> records = parse.getRecords();

最后,制表符分隔的文件可以看作是 CSV 的一个特例,也可以使用这个库来读取。为此,您只需使用TDF格式进行解析:

CSVFormat tsv = CSVFormat.TDF.withHeader();

Web 和 HTML

现在互联网上有大量的数据,能够访问这些数据并将其转换成机器可读的东西对于数据科学家来说是一项非常重要的技能。

从互联网上获取数据有多种方式。幸运的是,标准 Java API 提供了一个特殊的类来做这件事,URL。使用URL,可以打开一个InputStream,它将包含响应体。对于网页,通常是它的 HTML 内容。使用 Commons IO 的IOUtils,这样做很简单:

try (InputStream is = new URL(url).openStream()) { 
    return IOUtils.toString(is, StandardCharsets.UTF_8); 
}

这段代码相当有用,所以把它放入某个 helper 方法,比如UrlUtils.request,会很有帮助。

这里我们假设网页的内容总是 UTF-8。它可能在很多情况下都有效,尤其是对于英文页面,但偶尔也会失败。对于更复杂的爬虫,可以使用来自 Apache Tika(https://tika.apache.org/)的编码检测。

前面的方法返回原始的 HTML 数据,这本身是没有用的;大多数时候,我们感兴趣的是文本内容,而不是标记。有一些用于处理 HTML 的库,其中之一是 Jsoup:

<dependency> 
  <groupId>org.jsoup</groupId> 
  <artifactId>jsoup</artifactId> 
  <version>1.9.2</version> 
</dependency>

让我们考虑一个例子。Kaggle.com 是一个举办数据科学竞赛的网站,每个竞赛都有一个排行榜,显示每个参与者的表现。假设你有兴趣从https://www . ka ggle . com/c/avito-duplicate-ads-detection/leader board中提取这些信息,如下图截图所示:

这些信息包含在一个表中,为了从这个表中提取数据,我们需要找到一个唯一指向这个表的锚。为此,你可以使用检查器来查看页面(在 Mozilla Firefox 或 Google Chrome 中按下 F12 将打开检查器窗口):

使用 Inspector,我们可以注意到表的 ID 是leaderboard-table,为了在 Jsoup 中获得这个表,我们可以使用下面的 CSS 选择器#leaderboard-table。因为我们实际上对表格的行感兴趣,所以我们将使用#leaderboard-table tr

关于团队名称的信息包含在列表表格的第三列中。因此,要提取它,我们需要获取第三个<td>标签,然后查看它的<a>标签。同样,为了提取分数,我们获取第四个<td>标签的内容。

执行此操作的代码如下:

Map<String, Double> result = new HashMap<>(); 

String rawHtml = UrlUtils.request("https://www.kaggle.com/c/avito-duplicate-ads-detection/leaderboard"); 
Document document = Jsoup.parse(rawHtml); 
Elements tableRows = document.select("#leaderboard-table tr"); 
for (Element tr : tableRows) { 
    Elements columns = tr.select("td"); 
    if (columns.isEmpty()) { 
        continue; 
    } 

    String team = columns.get(2).select("a.team-link").text(); 
    double score = Double.parseDouble(columns.get(3).text()); 
    result.put(team, score); 
} 

Comparator<Map.Entry<String, Double>> byValue = Map.Entry.comparingByValue(); 
result.entrySet().stream() 
        .sorted(byValue.reversed()) 
        .forEach(System.out::println);

这里我们重用UrlUtils.request函数来获取我们之前定义的 HTML,然后用 Jsoup 处理它。

Jsoup 利用 CSS 选择器来访问解析后的 HTML 文档中的条目。要了解更多,你可以阅读相关的文档,可以在https://jsoup.org/cookbook/extracting-data/selector-syntax找到。

JSON

JSON 作为 web 服务之间的通信方式越来越受欢迎,逐渐取代了 XML 和其他格式。知道如何处理它可以让你从互联网上各种各样的数据源中提取数据。

有相当多的 JSON 库可用于 Java。Jackson 就是其中之一,它有一个简化版本,叫做jackson-jr,适用于大多数简单的情况,我们只需要从 JSON 中快速提取数据。要添加它,请使用以下命令:

<dependency> 
  <groupId>com.fasterxml.jackson.jr</groupId> 
  <artifactId>jackson-jr-all</artifactId> 
  <version>2.8.1</version> 
</dependency>

为了说明这一点,让我们考虑一个返回 JSON 的简单 API。我们可以使用http://www.jsontest.com/,它提供了许多虚拟的 web 服务。其中之一是 http://md5.jsontest.com的 MD5 服务;给定一个字符串,它返回它的 MD5 散列。

以下是它的输出示例:

{ 
  "original": "mastering java for data science", 
  "md5": "f4c8637d7274f13b58940ff29f669e8a" 
}

让我们使用它:

String text = "mastering java for data science"; 
String json = UrlUtils.request("http://md5.jsontest.com/?text=" + text.replace(' ', '+')); 

Map<String, Object> map = JSON.std.mapFrom(json); 
System.out.println(map.get("original")); 
System.out.println(map.get("md5"));

在这个例子中,web 服务的 JSON 响应非常简单。然而,列表和嵌套对象有更复杂的情况。比如,www.github.com提供了很多 API,其中一个就是https://api.github.com/users/alexeygrigorev/repos。对于给定的用户,它返回他们所有的存储库。它有一个对象列表,每个对象都有一个嵌套对象。

在具有动态类型的语言中,比如 Python,这很简单——这种语言并不强迫您拥有特定的类型,对于这种特殊情况来说,这很好。然而,在 Java 中,静态类型系统要求定义一个类型;每次需要提取东西的时候,都需要做铸造。

例如,如果我们想获得第一个对象的元素 ID,我们需要做这样的事情:

String username = "alexeygrigorev"; 
String json = UrlUtils.request("https://api.github.com/users/" + username + "/repos"); 

List<Map<String, ?>> list = (List<Map<String, ?>>) JSON.std.anyFrom(json); 
String name = (String) list.get(0).get("name"); 
System.out.println(name);

如您所见,我们需要进行大量的类型转换,代码很快变得非常混乱。一个解决方案可能是使用一种类似于 Xpath 的查询语言,称为 JsonPath。可在https://github.com/jayway/JsonPath访问 Java 的实现。要使用它,请添加以下内容:

<dependency> 
  <groupId>com.jayway.jsonpath</groupId> 
  <artifactId>json-path</artifactId> 
  <version>2.2.0</version> 
</dependency>

如果我们想要检索所有用 Java 编写的存储库,并且至少有一个 start,那么下面的查询就可以做到:

ReadContext ctx = JsonPath.parse(json); 
String query = "$..[?(@.language=='Java' && @.stargazers_count > 0)]full_name"; 
List<String> javaProjects = ctx.read(query);

这肯定会为过滤等简单的数据操作节省一些时间,但不幸的是,对于更复杂的事情,您可能仍然需要进行大量的强制转换来进行手动转换。

对于更复杂的查询(例如,发送 POST 请求),最好使用特殊的库,比如 Apache http components(https://hc.apache.org/)。

数据库

在组织中,数据通常保存在关系数据库中。Java 将 Java 数据库连接 ( JDBC )定义为访问任何支持 SQL 的数据库的抽象。

在我们的例子中,我们将使用 MySQL,它可以从https://www.mysql.com/下载,但原则上它可以是任何其他数据库,如 PostgreSQL、Oracle、MS SQL 和许多其他数据库。要连接到 MySQL 服务器,我们可以使用 JDBC MySQL 驱动程序:

<dependency> 
  <groupId>mysql</groupId> 
  <artifactId>mysql-connector-java</artifactId> 
  <version>5.1.39</version> 
</dependency>

如果你想使用不同的数据库,那么你可以使用你最喜欢的搜索引擎,找到合适的 JDBC 驱动程序。交互代码将保持不变,如果您使用标准 SQL,查询代码应该也不会改变。

例如,我们将使用为 CSV 示例生成的相同数据。首先,我们将它加载到数据库中,然后进行简单的选择。

让我们定义以下模式:

CREATE SCHEMA &grave;people&grave; DEFAULT CHARACTER SET utf8 ;  
CREATE TABLE &grave;people&grave;.&grave;people&grave; (  
  &grave;person_id&grave; INT UNSIGNED NOT NULL AUTO_INCREMENT,  
  &grave;name&grave; VARCHAR(45) NULL,  
  &grave;email&grave; VARCHAR(100) NULL,  
  &grave;country&grave; VARCHAR(45) NULL,  
  &grave;salary&grave; INT NULL,  
  &grave;experience&grave; INT NULL,  
  PRIMARY KEY (&grave;person_id&grave;));

现在,为了连接到数据库,我们通常使用DataSource抽象。MySQL 驱动提供了一个实现:MysqlDataSource:

MysqlDataSource datasource = new MysqlDataSource(); 
datasource.setServerName("localhost"); 
datasource.setDatabaseName("people"); 
datasource.setUser("root"); 
datasource.setPassword("");

现在使用DataSource对象,我们可以加载数据。做这件事有两种方法:简单的方法是,当我们单独插入每个对象时,以及批处理模式,其中我们首先准备一个批处理,然后插入一个批处理的所有对象。批处理模式选项通常工作得更快。

我们先来看看通常的模式:

try (Connection connection = datasource.getConnection()) { 
    String sql = "INSERT INTO people (name, email, country, salary, experience) VALUES (?, ?, ?, ?, ?);"; 
    try (PreparedStatement statement = connection.prepareStatement(sql)) { 
        for (Person person : people) { 
            statement.setString(1, person.getName()); 
            statement.setString(2, person.getEmail()); 
            statement.setString(3, person.getCountry()); 
            statement.setInt(4, person.getSalary()); 
            statement.setInt(5, person.getExperience()); 
            statement.execute(); 
        } 
    } 
}

注意,在 JDBC 中,索引的枚举从 1 开始,而不是从 0 开始。

批次很相似。为了准备批处理,我们首先使用来自 Guava 的Lists.partition函数,并将所有数据放入 50 个对象的批处理中。然后使用addBatch函数将块中的每个对象添加到一个批处理中:

List<List<Person>> chunks = Lists.partition(people, 50); 

try (Connection connection = datasource.getConnection()) { 
    String sql = "INSERT INTO people (name, email, country, salary, experience) VALUES (?, ?, ?, ?, ?);"; 
    try (PreparedStatement statement = connection.prepareStatement(sql)) { 
        for (List<Person> chunk : chunks) { 
            for (Person person : chunk) { 
                statement.setString(1, person.getName()); 
                statement.setString(2, person.getEmail()); 
                statement.setString(3, person.getCountry()); 
                statement.setInt(4, person.getSalary()); 
                statement.setInt(5, person.getExperience()); 
                statement.addBatch(); 
            } 
            statement.executeBatch(); 
        } 
    } 
}

批处理模式比通常的数据处理方式更快,但需要更多的内存。如果您需要处理大量数据,并且关心速度,那么批处理模式是一个更好的选择,但是它会使代码变得更加复杂。因此,使用更简单的方法可能更好。

现在,当数据被加载时,我们可以查询数据库。例如,让我们选择一个国家的所有人:

String country = "Greenland"; 
try (Connection connection = datasource.getConnection()) { 
    String sql = "SELECT name, email, salary, experience FROM people WHERE country = ?;"; 
    try (PreparedStatement statement = connection.prepareStatement(sql)) { 
        List<Person> result = new ArrayList<>(); 

        statement.setString(1, country); 
        try (ResultSet rs = statement.executeQuery()) { 
            while (rs.next()) { 
                String name = rs.getString(1); 
                String email = rs.getString(2); 
                int salary = rs.getInt(3); 
                int experience = rs.getInt(4); 
                Person person = new Person(name, email, country, salary, experience); 
                result.add(person); 
            } 
        } 
    } 
}

这样,我们就可以执行任何想要的 SQL 查询,并将结果放入 Java 对象中进行进一步处理。

你可能已经注意到在 JDBC 有很多样板代码。样板文件可以用 Spring JDBC 模板库来简化(见http://www.springframework.org)。

DataFrames

数据帧是在内存中表示表格数据的一种便捷方式。最初 DataFrames 来自 R 编程语言,但现在在其他语言中也很常见;例如,在 Python 中,pandas 库提供了一个类似于 R 的 DataFrame 数据结构。

Java 中存储数据的通常方式是列表、映射和其他对象集合。我们可以把这些集合想象成表,但是我们只能通过行来评估数据。然而,对于数据科学来说,操作列同样重要,这也是数据框架有用的地方。

例如,它们允许您对同一列的所有值应用相同的函数,或者查看值的分布。

在 Java 中,没有太多成熟的实现,但是有一些实现具有所有需要的功能。在我们的例子中,我们将使用joinery:

<dependency> 
  <groupId>joinery</groupId> 
  <artifactId>joinery-dataframe</artifactId> 
  <version>1.7</version> 
</dependency>

遗憾的是,joinery在 Maven Central 上不可用;因此,要将它包含到 Maven 项目中,您需要指向另一个 Maven 存储库bintray。为此,将这个repository添加到pom文件的存储库部分:

<repository> 
  <id>bintray</id> 
  <url>http://jcenter.bintray.com</url> 
</repository>

细木工取决于Apache POI,所以你也需要加上:

<dependency> 
  <groupId>org.apache.poi</groupId> 
  <artifactId>poi</artifactId> 
  <version>3.14</version> 
</dependency>

使用细木工技术,读取数据非常容易:

DataFrame<Object> df = DataFrame.readCsv("data/csv-example-generatedata_com.csv");

一旦数据被读取,我们不仅可以访问数据帧的每一行,还可以访问每一列。给定一个列名,Joinery 返回一个存储在列中的值的List,我们可以用它对它进行各种转换。

例如,假设我们希望将我们拥有的每个国家与一个唯一的索引相关联。我们可以这样做:

List<Object> country = df.col("country"); 
Map<String, Long> map = LazyReact.sequentialBuilder() 
        .from(country) 
        .cast(String.class) 
        .distinct() 
        .zipWithIndex() 
        .toMap(Tuple2::v1, Tuple2::v2); 

List<Object> indexes = country.stream().map(map::get).collect(Collectors.toList());

之后,我们可以删除带有country的旧列,并包含新的索引列:

df = df.drop("country"); 
df.add("country_index", indexes); 
System.out.println(df);

Joinery 可以做更多的事情——分组、连接、旋转和为机器学习模型创建设计矩阵。我们将在以后的几乎所有章节中再次使用它。同时,你可以在 https://cardillo.github.io/joinery/阅读更多关于细木工的内容。

搜索引擎-准备数据

在第一章中,我们介绍了运行示例,构建一个搜索引擎。搜索引擎是这样一种程序,给定用户的查询,它返回按照与该查询的相关性排序的结果。在本章中,我们将执行第一步-获取和处理数据。

假设我们在一个门户网站上工作,用户生成了很多内容,但是他们很难找到其他人所创建的内容。为了克服这个问题,我们建议建立一个搜索引擎,产品管理部门已经确定了用户会提出的典型查询。

例如,“中国食物”,“自制披萨”,“如何学习编程”是这个列表中的典型查询。

现在我们需要收集数据。幸运的是,互联网上已经有搜索引擎可以接受查询并返回他们认为相关的 URL 列表。我们可以用它们来获取数据。你可能已经知道这样的引擎——谷歌或必应,仅举两例。

因此,我们可以应用我们在这一章中学到的知识,使用 JSoup 解析来自 Google、Bing 或任何其他搜索引擎的数据。或者,也可以使用诸如https://flow-app.com/这样的服务来帮你提取,但是需要注册。

最后,我们需要的是一个查询和一个最相关的 URL 列表。提取相关的 URL 作为一个练习,但是我们已经准备了一些结果,如果你愿意的话可以使用:对于每个查询,从搜索结果的前三页有 30 个最相关的页面。此外,您可以在代码包中找到对爬行有用的代码。

现在,当我们有了 URL,我们感兴趣的是检索它们并保存它们的 HTML 代码。为此,我们需要一个比我们已经有的UrlUtils.request更智能的爬虫。

我们必须添加的一个特别的东西是超时:一些页面需要很长时间来加载,因为它们要么很大,要么服务器遇到一些问题,需要一段时间来响应。在这些情况下,当一个页面在 30 秒内无法下载时,放弃是有意义的。

在 Java 中,这可以用Executors来完成。首先,让我们创建一个Crawler类,并声明executor字段:

int numProc = Runtime.getRuntime().availableProcessors();
executor = Executors.newFixedThreadPool(numProc);

然后,我们可以如下使用这个执行程序:

try { 
    Future<String> future = executor.submit(() -> UrlUtils.request(url)); 
    String result = future.get(30, TimeUnit.SECONDS); 
    return Optional.of(result); 
} catch (TimeoutException e) { 
    LOGGER.warn("timeout exception: could not crawl {} in {} sec", url, timeout); 
    return Optional.empty(); 
}

这段代码将删除花费太长时间检索的页面。

我们需要将抓取的 HTML 页面存储在某个地方。有几个选择:文件系统上的一堆 HTML 文件、关系存储(如 MySQL)或键值存储。键值存储看起来是最好的选择,因为我们有一个键,URL,和值,HTML。为此,我们可以使用 MapDB,这是一个实现了Map接口的纯 Java 键值存储。本质上,它是一个由磁盘上的文件支持的Map

因为它是纯 Java,所以使用它所需要做的就是包含它的依赖项:

<dependency> 
  <groupId>org.mapdb</groupId> 
  <artifactId>mapdb</artifactId> 
  <version>3.0.1</version> 
</dependency>

现在我们可以使用它:

DB db = DBMaker.fileDB("urls.db").closeOnJvmShutdown().make(); 
HTreeMap<?, ?> htreeMap = db.hashMap("urls").createOrOpen(); 
Map<String, String> urls = (Map<String, String>) htreeMap;

因为它实现了Map接口,所以它可以被当作普通的Map来处理,我们可以把 HTML 放在那里。因此,让我们读取相关的 URL,下载它们的 HTML,并保存到地图:

Path path = Paths.get("data/search-results.txt"); 
List<String> lines = FileUtils.readLines(path.toFile(), StandardCharsets.UTF_8); 

lines.parallelStream() 
    .map(line -> line.split("t")) 
    .map(split -> "http://" + split[2]) 
    .distinct() 
    .forEach(url -> { 
        try { 
            Optional<String> html = crawler.crawl(url); 
            if (html.isPresent()) { 
                LOGGER.debug("successfully crawled {}", url); 
                urls.put(url, html.get()); 
            } 
        } catch (Exception e) { 
            LOGGER.error("got exception when processing url {}", url, e); 
        } 
    });

这里我们在parallelStream中这样做是为了加快执行速度。超时将确保它在合理的时间内完成。

首先,让我们从页面中提取一些非常简单的内容,如下所示:

  • URL 的长度
  • 标题的长度
  • 无论查询是否包含在标题中
  • 正文中整个文本的长度
  • <h1> - <h6>标签的数量
  • 链接数量

为了保存这些信息,我们可以创建一个特殊的类,RankedPage

public class RankedPage { 
    private String url; 
    private int position; 
    private int page; 
    private int titleLength; 
    private int bodyContentLength; 
    private boolean queryInTitle; 
    private int numberOfHeaders; 
    private int numberOfLinks; 
    // setters, getters are omitted 
}

现在,让我们为每个页面创建一个这个类的对象。我们使用flatMap是因为有些 URL 没有 HTML 数据。

Stream<RankedPage> pages = lines.parallelStream().flatMap(line -> { 
    String[] split = line.split("t"); 
    String query = split[0]; 
    int position = Integer.parseInt(split[1]); 
    int searchPageNumber = 1 + (position - 1) / 10; // converts position to a page number 
    String url = "http://" + split[2]; 
    if (!urls.containsKey(url)) { // no crawl available 
        return Stream.empty(); 
    } 

    RankedPage page = new RankedPage(url, position, searchPageNumber); 
    String html = urls.get(url); 
    Document document = Jsoup.parse(html); 
    String title = document.title(); 
    int titleLength = title.length(); 
    page.setTitleLength(titleLength); 

    boolean queryInTitle = title.toLowerCase().contains(query.toLowerCase()); 
    page.setQueryInTitle(queryInTitle); 

    if (document.body() == null) { // no body for the document 
        return Stream.empty(); 
    } 
    int bodyContentLength = document.body().text().length(); 
    page.setBodyContentLength(bodyContentLength); 

    int numberOfLinks = document.body().select("a").size(); 
    page.setNumberOfLinks(numberOfLinks); 

    int numberOfHeaders = document.body().select("h1,h2,h3,h4,h5,h6").size(); 
    page.setNumberOfHeaders(numberOfHeaders); 

    return Stream.of(page); 
});

在这段代码中,我们为每个页面查找它的 HTML。如果没有被抓取,我们跳过这个页面;然后我们解析 HTML 并检索前面的基本特性。

这只是我们可以计算的可能页面特征的一小部分。稍后,我们将在此基础上添加更多功能。

在这个例子中,我们得到了一个页面流。我们可以对这个流做任何我们想做的事情,例如,将它保存到 JSON 或转换成 DataFrame。本书附带的代码包中有一些例子,展示了如何进行这些类型的转换。例如,从 Java 对象列表到 Joinery DataFrame的转换在BeanToJoinery实用程序类中可用。

摘要

处理任何数据科学问题都有几个步骤,而数据准备步骤是第一步。标准的 Java API 有大量的工具使这项任务成为可能,并且有许多库使它变得容易得多。

在这一章中,我们讨论了其中的许多,包括对 Java API 的扩展,如 Google Guava 我们讨论了从文本、HTML 和数据库等不同来源读取数据的方法;最后,我们讨论了 DataFrame,这是一种用于操作表格数据的有用结构。

在下一章中,我们将仔细查看本章中提取的数据,并执行探索性数据分析。

三、探索性数据分析

在前一章中,我们讨论了数据处理,这是将数据转换成可用于分析的形式的重要步骤。在本章中,我们在清理和查看数据之后进行下一个逻辑步骤。这一步被称为探索性数据分析 ( EDA ),它由汇总数据和创建可视化组成。

在本章中,我们将讨论以下主题:

  • 使用 Apache Commons Math 和 Joinery 进行汇总统计
  • Java 和 JVM 中 EDA 的交互式 shells

在本章结束时,你将知道如何计算汇总统计数据和用 Joinery 创建简单的图表。

Java 中的探索性数据分析

探索性数据分析是指获取数据集并从中提取最重要的信息,这样就有可能了解数据的样子。这包括两个主要部分:总结和可视化。

汇总步骤对于理解数据非常有帮助。对于数值变量,在这一步我们计算最重要的样本统计数据:

  • 极值(最小值和最大值)
  • 平均值或样本平均值
  • 标准差,描述了数据的分布

我们通常会考虑其他统计数据,如中位数和四分位数(25%和 75%)。

正如我们在前一章已经看到的,Java 提供了一套很好的数据准备工具。相同的工具集可以用于 EDA,尤其是创建摘要。

搜索引擎数据集

在这一章中,我们将使用我们正在运行的例子——构建一个搜索引擎。在第二章数据处理工具箱中,我们从搜索引擎返回的 HTML 页面中提取了一些数据。这个数据集包括一些数字特征,比如标题的长度和内容的长度。

为了存储这些功能,我们创建了以下类:

public class RankedPage { 
    private String url; 
    private int position; 
    private int page; 
    private int titleLength; 
    private int bodyContentLength; 
    private boolean queryInTitle; 
    private int numberOfHeaders; 
    private int numberOfLinks; 
    // setters, getters are omitted 
}

看看这些信息对搜索引擎是否有用是很有趣的。例如,给定一个 URL,我们可能想知道它是否可能出现在引擎输出的第一页。通过 EDA 查看数据将有助于我们了解这是否可行。

此外,真实世界的数据很少是干净的。我们将使用 EDA 来尝试发现一些奇怪或有问题的观察结果。

让我们开始吧。我们将数据保存为 JSON 格式,现在我们可以使用 streams 和 Jackson 读回它:

Path path = Paths.get("./data/ranked-pages.json"); 
try (Stream<String> lines = Files.lines(path)) { 
    return lines.map(line -> parseJson(line)).collect(Collectors.toList());
}

这是返回一系列RankedPage对象的函数体。我们从ranked-page.json文件中读取它们。然后我们使用parseJson函数将 JSON 转换成 Java 类:

JSON.std.beanFrom(RankedPage.class, line);

看完数据,我们就可以分析了。通常,分析的第一步是查看汇总统计数据,我们可以使用 Apache Commons Math 来实现这一点。

阿帕奇公共数学

一旦我们读取了数据,我们就可以计算统计数据。正如我们前面提到的,我们通常对诸如最小值、最大值、平均值、标准偏差等汇总感兴趣。我们可以使用 Apache Commons 数学库。我们把它包含在pom.xml里吧:

<dependency>
  <groupId>org.apache.commons</groupId> 
  <artifactId>commons-math3</artifactId> 
  <version>3.6.1</version> 
</dependency>

有一个用于计算汇总的SummaryStatistics类。让我们用它来计算一些关于我们抓取的页面的正文内容长度分布的统计数据:

SummaryStatistics statistics = new SummaryStatistics(); data.stream().mapToDouble(RankedPage::getBodyContentLength)
    .forEach(statistics::addValue); 
System.out.println(statistics.getSummary());

这里,我们创建了SummaryStatistics对象,并添加了所有的正文内容长度。之后,我们可以调用一个getSummary方法来一次获得所有的汇总统计数据。这将打印以下内容:

StatisticalSummaryValues: 
n: 4067 
min: 0.0 
max: 8675779.0 
mean: 14332.239242685007 
std dev: 144877.54551111493 
variance: 2.0989503193325176E10 
sum: 5.8289217E7

DescriptiveStatistics方法是这个库中另一个有用的类。它允许获得更多的值,如中位数和百分位数,以及百分位数;更好地展示数据的样子:

double[] dataArray = data.stream()
        .mapToDouble(RankedPage::getBodyContentLength)
        .toArray();
DescriptiveStatistics desc = new DescriptiveStatistics(dataArray); 
System.out.printf("min: %9.1f%n", desc.getMin()); 
System.out.printf("p05: %9.1f%n", desc.getPercentile(5)); 
System.out.printf("p25: %9.1f%n", desc.getPercentile(25)); System.out.printf("p50: %9.1f%n", desc.getPercentile(50)); System.out.printf("p75: %9.1f%n", desc.getPercentile(75)); System.out.printf("p95: %9.1f%n", desc.getPercentile(95)); System.out.printf("max: %9.1f%n", desc.getMax());

这将产生以下输出:

min: 0.0 
p05: 527.6 
p25: 3381.0 
p50: 6612.0 
p75: 11996.0 
p95: 31668.4 
max: 8675779.0

从输出中,我们可以注意到最小长度为零,这很奇怪;最有可能的是,存在数据处理问题。此外,最大值非常高,这表明存在异常值。稍后,从我们的分析中排除这些值是有意义的。

内容长度为零的页面可能是爬行错误。我们来看看这几页的比例:

double proportion = data.stream()
    .mapToInt(p -> p.getBodyContentLength() == 0 ? 1 : 0)
    .average().getAsDouble(); 
System.*out*.printf("proportion of zero content length: %.5f%n", proportion);

我们看到没有多少页面是零长度的,所以删除它们是相当安全的。

稍后,在下一章中,我们将尝试预测一个 URL 是否来自第一个搜索页面结果。如果一些特征对于每个页面具有不同的值,那么机器学习模型将能够看到这种差异,并将其用于更准确的预测。让我们看看不同页面的内容长度值是否相似。

为此,我们可以按页面对 URL 进行分组,并计算平均内容长度。正如我们已经知道的,Java 流可以用来做到这一点:

Map<Integer, List<RankedPage>> byPage = data.stream() 
    .filter(p -> p.getBodyContentLength() != 0)  
    .collect(Collectors.groupingBy(RankedPage::getPage));

请注意,我们为空白页面添加了一个过滤器,因此它们不会出现在组中。现在,我们可以使用组来计算平均值:

List<DescriptiveStatistics> stats = byPage.entrySet().stream()   
    .sorted(Map.Entry.comparingByKey()) 
    .map(e -> calculate(e.getValue(), RankedPage::getBodyContentLength)) 
    .collect(Collectors.toList());

这里,calculate是一个函数,它接受一个集合,计算每个元素上提供的函数(在本例中使用getBodyContentLength,并从中创建一个DescriptiveStatistics对象:

private static DescriptiveStatistics calculate(List<RankedPage> data, 
            ToDoubleFunction<RankedPage> getter) {
    double[] dataArray = data.stream().mapToDouble(getter).toArray(); 
    return new DescriptiveStatistics(dataArray); 
}

现在,在列表中,您将拥有每个组的描述性统计数据(在本例中为页面)。然后,我们可以用任何我们想要的方式展示它们。考虑下面的例子:

Map<String, Function<DescriptiveStatistics, Double>> functions = new LinkedHashMap<>();
functions.put("min", d -> d.getMin()); 
functions.put("p05", d -> d.getPercentile(5)); 
functions.put("p25", d -> d.getPercentile(25)); 
functions.put("p50", d -> d.getPercentile(50)); 
functions.put("p75", d -> d.getPercentile(75)); 
functions.put("p95", d -> d.getPercentile(95)); 
functions.put("max", d -> d.getMax()); 
System.out.print("page"); 

for (Integer page : byPage.keySet()) {
    System.out.printf("%9d ", page); 
}
System.out.println();

for (Entry<String, Function<DescriptiveStatistics, Double>> pair : functions.entrySet()) { 
    System.out.print(pair.getKey()); 
    Function<DescriptiveStatistics, Double> function = pair.getValue();    
    System.out.print(" "); 
    for (DescriptiveStatistics ds : stats) {
        System.out.printf("%9.1f ", function.apply(ds)); 
    }
    System.out.println(); 
}

这会产生以下输出:

page 0 1 2 
min 5.0 1.0 5.0 
p05 1046.8 900.6 713.8 
p25 3706.0 3556.0 3363.0 
p50 7457.0 6882.0 6383.0 
p75 13117.0 12067.0 11309.8 
p95 42420.6 30557.2 27397.0 
max 390583.0 8675779.0 1998233.0

输出表明,内容长度的分布在来自搜索引擎结果的不同页面的 URL 之间是不同的。因此,在预测给定 URL 的搜索页码时,这可能很有用。

细木工制品

您可能会注意到,我们刚刚编写的代码非常冗长。当然,可以把它放在一个 helper 函数中,在需要时调用它,但是还有另一种更简洁的方法来计算这些统计数据——使用 joinery 及其数据框架。

在 Joinery 中,DataFrame对象有一个名为describe的方法,它创建一个包含汇总统计信息的新数据框架:

List<RankedPage> pages = Data.readRankedPages(); 
DataFrame<Object> df = BeanToJoinery.convert(pages, RankedPage.class); 
df = df.retain("bodyContentLength", "titleLength", "numberOfHeaders"); DataFrame<Object> describe = df.describe(); 
System.out.println(describe.toString());

在前面的代码中,Data.readRankedPages是一个 helper 方法,它读取 JSON 数据并将其转换为一组 Java 对象,BeanToJoinery.convert是一个 helper 类,它将一组 Java 对象转换为一个DataFrame

然后,我们只保留三列,删除其他所有内容。以下是输出:

body   contentLength   numberOfHeaders  titleLength
count  4067.00000000   4067.00000000    4067.00000000
mean   14332.23924269  25.25325793      46.17334645
std    144877.5455111  32.13788062      27.72939822
var    20989503193.32  1032.84337047    768.91952552
max    8675779.000000  742.00000000     584.00000000
min    0.00000000      0.00000000       0.00000000

我们还可以查看不同组的平均值,例如,不同页面的平均值。为此,我们可以使用groupBy方法:

DataFrame<Object> meanPerPage = df.groupBy("page").mean()
    .drop("position") 
    .sortBy("page")
    .transpose(); 
System.out.println(meanPerPage);

除了在groupBy之后应用 mean 之外,我们还删除了一个列位置,因为我们已经知道位置对于不同页面是不同的。此外,我们在最后应用转置操作;这是一个技巧,当有许多列时,使输出适合一个屏幕。这会产生以下输出:

page 0 1 2 
bodyContentLength 12577 18703 11286 
numberOfHeaders 30 23 21 
numberOfLinks 276 219 202 
queryInTitle 0 0 0 
titleLength 46 46 45

我们可以看到,一些变量的平均值差异很大。对于其他变量,如queryInTitle,似乎没有任何区别。但是,请记住这是一个布尔变量,因此平均值介于 0 和 1 之间。出于某种原因,Joinery 决定不在这里显示小数部分。

现在,我们知道如何在 Java 中计算一些简单的汇总统计数据,但是要做到这一点,我们首先需要编写一些代码,编译它,然后运行它。这不是最方便的过程,有更好的交互方式,即避免编译并立即得到结果。接下来,我们将看到如何在 Java 中实现它。

Java 中的交互式探索性数据分析

Java 是一种静态类型的编程语言,用 Java 编写的代码需要编译。虽然 Java 适合开发复杂的数据科学应用程序,但它使得交互式地探索数据变得更加困难;每次,我们都需要重新编译源代码,重新运行分析脚本来查看结果。这意味着,如果我们需要读取一些数据,我们将不得不一遍又一遍地这样做。如果数据集很大,程序需要更多的时间来启动。

因此很难与数据交互,这使得在 Java 中进行 EDA 比在其他语言中更困难。特别是读取-评估-打印循环 ( REPL )这个交互 shell,对于做 EDA 来说是相当重要的一个特性。

不幸的是,Java 8 没有 REPL,但是有几个替代方案:

  • 其他交互式 JVM 语言,如 JavaScript、Groovy 或 Scala
  • 带有 jshell 的 Java 9
  • 完全不同的平台,如 Python 或 R

在这一章中,我们将着眼于前两个选项——JVM 语言和 Java 9 的 REPL。

JVM 语言

你大概知道,Java 平台不仅是 Java 编程语言,而且 Java 虚拟机 (JVM)可以运行其他 JVM 语言的代码。有很多运行在 JVM 上的语言都有 REPL,比如 JavaScript、Scala、Groovy、JRuby 和 Jython。还有很多。所有这些语言都可以访问任何用 Java 编写的代码,而且它们有交互式控制台。

例如,Groovy 与 Java 非常相似,在 Java 8 之前,几乎所有用 Java 编写的代码都可以在 Groovy 中运行。但是,对于 Java 8 来说,情况就不再是这样了。Groovy 不支持 lambda 表达式和函数接口的新 Java 语法,所以我们不能在那里运行本书的大部分代码。

Scala 是另一种流行的函数式 JVM 语言,但是它的语法与 Java 非常不同。对于数据处理来说,它是一种非常强大和富有表现力的语言,它有一个漂亮的交互式外壳,并且有许多用于进行数据分析和数据科学的库。

此外,有几个 JavaScript 实现可用于 JVM。其中一个是 Nashorn,它自带 Java 8 开箱即用;没有必要将它作为一个独立的依赖项包含进来。Joinery 还带有一个内置的交互式控制台,它利用了 JavaScript,在本章的后面,我们将看到如何使用它。虽然所有这些语言都很好,但它们超出了本书的范围。你可以从这些书中了解更多:

  • 动作麻利迪克·科尼格,曼宁
  • Scala 数据分析食谱, 阿伦·马尼瓦南,帕克特出版社

交互式 Java

说 Java 是一种 100%非交互式语言是不公平的;有一些扩展直接为 Java 提供了 REPL 环境。

一个这样的环境是看起来完全像 Java 的脚本语言(BeanShell)。但是,它太旧了,并且不支持新的 Java 8 语法,所以对于进行交互式数据分析来说,它不是很有用。

更有趣的是 Java 9,它附带了一个名为 JShell 的集成 REPL,支持 tab 上的自动补全、Java 流和 lambda 表达式的 Java 8 语法。在撰写本文时,Java 9 只作为早期访问版本提供。你可以从 https://jdk9.java.net/download/.下载

启动 shell 很容易:

$ jshell

但是通常你想访问一些库,因此它们需要在类路径中。通常,我们使用 Maven 来管理依赖项,所以我们可以运行下面的代码将在pom文件中指定的所有jar库复制到我们选择的目录中:

mvn dependency:copy-dependencies -DoutputDirectory=lib 
mvn compile

完成后,我们可以像这样运行 shell:

jshell -cp lib/*:target/classes

如果您在 Windows 上,请用分号替换冒号:

jshell -cp lib/*;target/classes

然而,我们的实验表明,不幸的是,JShell 还很原始,有时会崩溃。在撰写本文时,计划在 2017 年 3 月底发布。现在,我们不会更详细地讨论 JShell,但是本章前半部分的所有代码都应该可以在这个控制台上运行,不需要额外的配置。此外,我们应该能够立即看到输出。

到目前为止,我们已经使用 Joinery 几次了,它也支持执行简单的 EDA。接下来,我们将看看如何用细木工板壳进行分析。

细木工外壳

细木工已经多次被证明对数据处理和简单的 EDA 很有用。它有一个交互式的外壳,你可以立即得到答案。

如果数据已经是 CSV 格式,那么可以从系统控制台调用 Joinery shell:

$ java joinery.DataFrame shell

你可以在https://github.com/cardillo/joinery.看到例子,所以如果你的数据已经在 CSV 中,你就可以开始了,只需按照那里的指示。

在本书中,当数据帧不是 CSV 格式时,我们将看一个更复杂的例子。在我们的例子中,数据是 JSON 格式的,而 Joinery shell 不支持这种格式,所以我们需要先做一些预处理。

我们能做的是在 Java 代码中创建一个 DataFrame 对象,然后创建交互式 shell 并将 DataFrame 传递到那里。让我们看看如何能做到这一点。

但是在我们这样做之前,我们需要添加一些依赖项来使之成为可能。第一,Joinery shell 使用 JavaScript,但不使用 JVM 附带的 Nashorn engin 相反,它使用的是 Mozilla 的名为 Rhino 的引擎。因此,我们需要将它包含到我们的pom:

<dependency> 
  <groupId>rhino</groupId> 
  <artifactId>js</artifactId>  
  <version>1.7R2</version> 
</dependency>

第二,它依赖于一个特殊的自动补全库jline。我们也来补充一下:

<dependency> 
  <groupId>jline</groupId> 
  <artifactId>jline</artifactId> 
  <version>2.14.2</version> 
</dependency>

使用 Maven 给了你很大的灵活性;它更简单,不需要您手动下载所有的库,并从源代码构建 Joinery 来执行 shell。所以,我们让 Maven 来处理。

现在我们可以使用它了:

List<RankedPage> pages = Data.readRankedPages(); 
DataFrame<Object> dataFrame = BeanToJoinery.convert(pages, 'RankedPage.class); 
Shell.repl(Arrays.asList(dataFrame));

让我们将这段代码保存到一个chapter03.JoineryShell类中。之后,我们可以用下面的 Maven 命令运行它:

mvn exec:java -Dexec.mainClass="chapter03.JoineryShell"

这将把我们带到细木工外壳:

# DataFrames for Java -- null, 1.7-8e3c8cf
# Java HotSpot(TM) 64-Bit Server VM, Oracle Corporation, 1.8.0_91
# Rhino 1.7 release 2 2009 03 22
>

我们在 Java 中传递给 shell 对象的所有数据帧都可以在 Shell 中的 Frames 变量中找到。所以,为了得到DataFrame,我们可以这样做:

> var df = frames[0]

要查看DataFrame的内容,只需写下它的名字:

> df

你会看到前几排DataFrame。请注意,自动完成功能按预期工作:

> df.<tab>

您将看到选项列表。

我们可以使用这个 shell 调用数据帧上的相同方法,就像我们在普通的 Java 应用程序中使用的方法一样。例如,您可以按如下方式计算平均值:

> df.mean().transpose()

我们将看到以下输出:

bodyContentLength 14332.23924269
numberOfHeaders 25.25325793
numberOfLinks 231.16867470
page 1.03221047
position 18.76518318
queryInTitle 0.59822965
titleLength 46.17334645

或者。我们可以执行相同的groupBy示例:

> df.drop('position').groupBy('page').mean().sortBy('page').transpose()

这将产生以下输出:

page 0 1 2
bodyContentLength 12577 18703 11286
numberOfHeaders 30 23 21
numberOfLinks 276 219 202
queryInTitle 0 0 0
titleLength 46 46 45

最后,用细木工也可以创造一些简单的情节。为此,我们需要使用一个额外的库。对于绘图,细木工使用xchart。让我们把它包括进来:

<dependency> 
  <groupId>com.xeiam.xchart</groupId> 
  <artifactId>xchart</artifactId> 
  <version>2.5.1</version> 
</dependency>

并再次运行控制台。现在我们可以使用plot函数:

> df.retain('titleLength').plot(PlotType.SCATTER)

我们会看到这个:

这里,我们看到一个标题长度超过 550 个字符的异常值。让我们把 200 以上的都去掉,再看一下图片。另外,记住有一些零长度的内容页面,所以我们也可以把它们过滤掉。

为了只保留那些满足某些条件的行,我们使用了select方法。它采用一个函数,应用于每一行;如果函数返回true,则保留该行。

我们可以这样使用它:

> df.retain('titleLength') 
 .select(function(list) { return list.get(0) <= 200; }) 
 .select(function(list) { return list.get(0) > 0;}) 
 .plot(PlotType.SCATTER)

前面代码中的换行符是为了提高可读性而添加的,但是它们在控制台中不起作用,所以不要使用它们。

现在,我们有了一个更清晰的画面:

遗憾的是,joinery 的绘图能力相当有限,使用xchart制作图形需要很大的努力。

正如我们已经知道的,在细木工中,很容易计算不同组之间的统计数据;我们只需要使用groupBy方法。然而,不可能容易地使用这种方法来绘制数据,以便容易地比较每组的分布。

还有其他工具也可以用于 EDA:

  • 用 Java 编写的 Weka 是一个用于执行数据挖掘的库。它有一个用于执行 EDA 的 GUI 界面。
  • 另一个 Java 库 Smile 有一个 Scala shell 和一个 smile-plot 库,用于创建可视化效果。

不幸的是,Java 通常不是执行 EDA 的理想选择,有其他更适合的动态语言。例如,R 和 Python 对于这个任务来说是理想的,但是介绍它们超出了本书的范围。你可以从以下书籍中了解更多信息:

  • 通过 R,Gergely Daroczi 掌握数据分析
  • Python 机器学习,塞巴斯蒂安·拉什卡

摘要

在这一章中,我们讨论了探索性数据分析,简称 EDA。我们讨论了如何用 Java 进行 EDA,包括创建摘要和简单的可视化。

在本章中,我们使用了我们的搜索引擎示例,并分析了我们之前收集的数据。我们的分析表明,对于来自搜索引擎结果的不同页面的 URL,一些变量的分布看起来是不同的。这表明,可以利用这些差异来建立一个模型,预测 URL 是否来自第一页。

在下一章,我们将看看如何做到这一点,并讨论监督机器学习算法,如分类和回归。

四、监督学习——分类和回归

在前几章中,我们学习了如何在 Java 中预处理数据,以及如何进行探索性的数据分析。现在,我们已经打下了基础,我们准备开始创建机器学习模型。

首先,我们从监督学习开始。在有监督的设置中,我们有一些信息附加在每个观察上,叫做标签,我们想从中学习,对没有标签的观察进行预测。

有两种类型的标签:第一种是离散和有限的,如真/假或买/卖,第二种是连续的,如工资或温度。这些类型对应于两种类型的监督学习:分类和回归。我们将在本章中讨论它们。

本章包括以下几点:

  • 分类问题
  • 回归问题
  • 每种类型的评估指标
  • Java 中可用实现的概述

到本章结束时,你将知道如何使用 Smile、LIBLINEAR 和其他 Java 库来构建有监督的机器学习模型。

分类

在机器学习中,分类问题处理具有有限组可能值的离散目标。这意味着有一组可能的结果,给定一些特征,我们想要预测结果。

二元分类是最常见的分类问题类型,因为target变量只能有两个可能的值,比如True / FalseRelevant / Not RelevantDuplicate / Not DuplicateCat / Dog等等。

有时目标变量可以有两个以上的结果,例如颜色、物品类别、汽车型号等等,我们称之为多类分类。通常,每个观察只能有一个标签,但在某些设置中,可以为一个观察分配多个值。多类分类可以转化为一组二分类问题,这就是为什么我们将主要集中于二分类。

二元分类模型

正如我们已经讨论过的,二元分类模型处理只有两种可能结果需要预测的情况。通常,在这些设置中,我们有正类项目(存在某种效果)和负类项目(不存在某种效果)。

例如,积极的标签可以是相关的、重复的、不能偿还债务的等等。正类的实例通常被赋予目标值 1。此外,我们还有负面的实例,如不相关、不重复、偿还债务,它们被赋予目标值0

这种分为积极和消极两类的做法有些人为,在某些情况下并不真正有意义。例如,如果我们有猫和狗的图像,即使只有两个类,说Cat是正类而Dog是负类也有点牵强。但这对于模型来说并不重要,所以我们仍然可以这样分配标签:Cat1,而Dog0

一旦我们训练了一个模型,我们通常不会对硬预测感兴趣,比如正面效应在那里,或者这是一只猫。更有趣的是积极或消极影响的程度,这通常是通过预测概率来实现的。例如,如果我们想建立一个模型来预测一个客户是否会无法偿还债务,那么说这个客户有 30%的违约这个客户不会违约更有用。

有许多模型可以解决二元分类问题,不可能涵盖所有的模型。我们将简要介绍实践中最常用的方法。它们包括以下内容:

  • 逻辑回归
  • 支持向量机
  • 决策树
  • 神经网络

我们假设您已经熟悉这些方法,并且至少对它们的工作原理有所了解。不要求非常熟悉,但要了解更多信息,您可以查阅以下书籍:

  • 统计学习入门, G .詹姆斯D .威滕T .哈斯蒂R .蒂布拉尼斯普林格
  • Python 机器学习S .拉什卡Packt 出版

说到库,我们将涉及 Smile、JSAT、LIBSVM、LIBLINEAR 和 Encog。让我们从微笑开始。

微笑

统计机器智能和学习引擎 ( 微笑)是一个拥有大量分类和其他机器学习算法的库。对我们来说,最有趣的是逻辑回归、SVM 和随机森林,但你可以在 https://github.com/haifengl/smile的 GitHub 官方页面上看到可用算法的完整列表。

该库可以在 Maven Central 上获得,在撰写本文时的最新版本是 1.1.0。若要将它包含到项目中,请添加以下依赖项:

<dependency>  
  <groupId>com.github.haifengl</groupId>  
  <artifactId>smile-core</artifactId>  
  <version>1.1.0</version>  
</dependency>

正在积极开发中;新功能和错误修复经常被添加,但发布的频率并不高。我们建议使用 Smile 的最新可用版本,要获得它,您需要从源代码构建它。为此:

sbt core/publishM2

微笑库由几个子模块组成,如smile-coresmile-nlpsmile-plot等。出于本章的目的,我们只需要核心包,前面的命令将只构建核心包。在撰写本文时,GitHub 上的当前版本是 1.2.0。因此,在构建它之后,将下面的依赖项添加到 pom 中:

<dependency>  
  <groupId>com.github.haifengl</groupId>  
  <artifactId>smile-core</artifactId>  
  <version>1.2.0</version>  
</dependency>

Smile 的模型期望数据是双精度的二维数组形式,标签信息是整数的一维数组形式。对于二进制模型,值应为01。Smile 中的一些模型可以处理多类分类问题,所以有可能有更多的标签,不仅仅是01,还有23等等。

在 Smile 中,模型是使用builder模式构建的;你创建一个特殊的类,设置一些参数,最后它返回它构建的对象。这个builder类通常被称为Trainer,所有的模型都应该有一个Trainer对象。

例如,考虑训练一个 RandomForest 模型:

double[] X = ... // training data 
int[] y = ... // 0 and 1 labels 
RandomForest model = new RandomForest.Trainer() 
    .setNumTrees(100)  
    .setNodeSize(4) 
    .setSamplingRates(0.7) 
    .setSplitRule(SplitRule.ENTROPY) 
    .setNumRandomFeatures(3) 
    .train(X, y);

RandomForest.Trainer类接受一组参数和训练数据,最终生成训练好的RandomForest模型。来自 Smile 的 RandomForest 的实现具有以下参数:

  • 这是模型中要训练的树的数量
  • nodeSize:这是叶节点中的最小项目数
  • samplingRate:这是用于生长每棵树的训练数据的比率
  • splitRule:这是用于选择最佳分割的杂质测量
  • numRandomFeatures:这是模型为选择最佳分割随机选择的特征数量

类似地,逻辑回归训练如下:

LogisticRegression lr = new LogisticRegression.Trainer() 
        .setRegularizationFactor(lambda) 
        .train(X, y);

一旦我们有了一个模型,我们就可以用它来预测以前看不见的商品的标签。为此,我们使用predict方法:

double[] row = // data  
int prediction = model.predict(row);

这段代码输出给定项目最可能的类。但是,我们往往更感兴趣的不是标签本身,而是拥有标签的概率。如果一个模型实现了SoftClassifier接口,那么有可能得到这样的概率:

double[] probs = new double[2]; 
model.predict(row, probs);

运行这段代码后,probs数组将包含概率。

JSAT

Java 统计分析工具 ( JSAT )是另一个 Java 库,里面包含了很多常用机器学习算法的实现。您可以在 https://github.com/EdwardRaff/JSAT/wiki/Algorithms查看已实施车型的完整列表。

要将 JSAT 添加到 Java 项目中,请将下面的代码片段添加到pom:

<dependency>  
  <groupId>com.edwardraff</groupId>  
  <artifactId>JSAT</artifactId>  
  <version>0.0.5</version>  
</dependency>

与 Smile 模型不同,它只需要一个带有特征信息的 doubles 数组,JSAT 需要一个特殊的数据包装类。如果我们有一个数组,它被转换成 JSAT 表示,如下所示:

double[][] X = ... // data 
int[] y = ... // labels 

// change to more classes for more classes for multi-classification 
CategoricalData binary = new CategoricalData(2);  

List<DataPointPair<Integer>> data = new ArrayList<>(X.length); 
for (int i = 0; i < X.length; i++) { 
    int target = y[i]; 
    DataPoint row = new DataPoint(new DenseVector(X[i])); 
    data.add(new DataPointPair<Integer>(row, target)); 
} 

ClassificationDataSet dataset = new ClassificationDataSet(data, binary);

一旦我们准备好数据集,我们就可以训练一个模型。让我们再次考虑随机森林分类器:

RandomForest model = new RandomForest(); 
model.setFeatureSamples(4); 
model.setMaxForestSize(150); 
model.trainC(dataset);

首先,我们为模型设置一些参数,然后,在最后,我们调用trainC方法(这意味着训练一个分类器)。

在 JSAT 实现中,与 Smile 相比,Random Forest 的调整选项较少,只有可供选择的要素数量和要生长的树的数量。

此外,JSAT 包含逻辑回归的几个实现。通常的逻辑回归模型没有任何参数,它是这样训练的:

LogisticRegression model = new LogisticRegression(); 
model.trainC(dataset);

如果我们想要一个正则化的模型,那么我们需要使用LogisticRegressionDCD类。对偶坐标下降 ( DCD )是用于训练逻辑回归的最优化方法。我们这样训练它:

LogisticRegressionDCD model = new LogisticRegressionDCD(); 
model.setMaxIterations(maxIterations); 
model.setC(C); 
model.trainC(fold.toJsatDataset());

在该代码中,C是正则化参数,C的值越小,对应的正则化效果越强。

最后,为了输出概率,我们可以做以下事情:

double[] row = // data 
DenseVector vector = new DenseVector(row); 
DataPoint point = new DataPoint(vector); 
CategoricalResults out = model.classify(point); 
double probability = out.getProb(1);

CategoricalResults类包含大量信息,包括每个类的概率和最可能的标签。

LIBSVM 和 LIBLINEAR

接下来,我们考虑两个类似的库,LIBSVM 和 LIBLINEAR。

这两个库来自同一个研究小组,并且具有非常相似的接口。我们将从 LIBSVM 开始。

LIBSVM 是一个库,它实现了许多不同的 SVM 算法。它是用 C++实现的,并且有官方支持的 Java 版本。它可以在 Maven Central 上获得:

<dependency> 
  <groupId>tw.edu.ntu.csie</groupId> 
  <artifactId>libsvm</artifactId> 
  <version>3.17</version> 
</dependency>

注意,LIBSVM 的 Java 版本不如 C++版本更新频繁。尽管如此,前面的版本是稳定的,不应该包含错误,但它可能比它的 C++版本慢。

要使用 LIBSVM 中的 SVM 模型,首先需要指定参数。为此,您创建一个svm_parameter类。在内部,您可以指定许多参数,包括:

  • 内核类型(RBFPOLYLINEAR)
  • 正则化参数C
  • probability你可以设置为1来得到概率
  • svm_type应设置为C_SVC;这表明模型应该是一个分类器

回想一下,SVM 模型可以有不同的核,根据我们使用的核,我们有不同的模型和不同的参数。这里,我们将考虑最常用的内核;线性(或无核)、多项式和径向基函数 ( RBF ),又称高斯核)。
首先,我们从线性内核开始。首先,我们创建一个svm_paramter对象,其中我们将内核类型设置为LINEAR,并要求它输出概率:

svm_parameter param = new svm_parameter(); 
param.svm_type = svm_parameter.C_SVC; 
param.kernel_type = svm_parameter.LINEAR; 
param.probability = 1; 
param.C = C; 

// default parameters 
param.cache_size = 100; 
param.eps = 1e-3; 
param.p = 0.1; 
param.shrinking = 1;

接下来,我们有一个多项式核。回想一下,多项式内核由以下公式指定:

它有三个附加参数,即控制内核的gammacoef0degree,还有C -正则化参数。我们可以这样配置POLY SVM 的svm_parameter类:

svm_parameter param = new svm_parameter(); 
param.svm_type = svm_parameter.C_SVC; 
param.kernel_type = svm_parameter.POLY; 
param.C = C; 
param.degree = degree; 
param.gamma = 1; 
param.coef0 = 1; 
param.probability = 1; 
// plus defaults from the above

最后,高斯核(或 RBF)具有以下公式:

因此有一个参数gamma,它控制高斯曲线的宽度。我们可以像这样指定带有RBF内核的模型:

svm_parameter param = new svm_parameter(); 
param.svm_type = svm_parameter.C_SVC; 
param.kernel_type = svm_parameter.RBF; 
param.C = C; 
param.gamma = gamma; 
param.probability = 1; 
// plus defaults from the above

一旦我们创建了配置对象,我们需要将数据转换成正确的格式。该库希望数据以稀疏格式表示。对于单个数据行,到所需格式的转换如下:

double[] dataRow = // single row vector 
svm_node[] svmRow = new svm_node[dataRow.length]; 

for (int j = 0; j < dataRow.length; j++) { 
    svm_node node = new svm_node(); 
    node.index = j; 
    node.value = dataRow[j]; 
    svmRow[j] = node; 
}

因为我们通常有一个矩阵,而不仅仅是一行,所以我们将前面的代码应用于这个矩阵的每一行:

double[][] X = ... // data 
int n = X.length; 
svm_node[][] nodes = new svm_node[n][]; 

for (int i = 0; i < n; i++) { 
    nodes[i] = wrapAsSvmNode(X[i]); 
}

这里,wrapAsSvmNode是一个函数,它将一个向量包装成一个由svm_node对象组成的数组。

现在,我们可以将数据和标签一起放入svm_problem对象:

double[] y = ... // labels  
svm_problem prob = new svm_problem(); 
prob.l = n; 
prob.x = nodes; 
prob.y = y;

最后,我们可以使用参数和问题规范来训练 SVM 模型:

svm_model model = svm.svm_train(prob, param);

一旦模型被训练,我们就可以用它来分类看不见的数据。获取概率的方法如下:

double[][] X = // test data 
int n = X.length; 
double[] results = new double[n]; 
double[] probs = new double[2]; 

for (int i = 0; i < n; i++) { 
    svm_node[] row = wrapAsSvmNode(X[i]); 
    svm.svm_predict_probability(model, row, probs); 
    results[i] = probs[1]; 
}

由于我们使用了param.probability = 1,我们可以使用svm.svm_predict_probability方法来预测概率。与 Smile 一样,该方法接受一个 doubles 数组,并将输出写入其中。在这个操作之后,它将包含这个数组中的概率。

最后,在训练的时候,LIBSVM 在控制台上输出很多东西。如果我们对这个输出不感兴趣,我们可以用下面的代码片段禁用它:

svm.svm_set_print_string_function(s -> {});

只需将它添加到代码的开头,就再也看不到调试信息了。

下一个库是 LIBLINEAR,它提供了非常快速和高性能的线性分类器,如具有线性核的 SVM 和逻辑回归。它可以轻松扩展到数千万甚至数亿个数据点。它的界面与 LIBSVM 非常相似,我们需要指定参数和数据,然后训练一个模型。

与 LIBSVM 不同,LIBLINEAR 没有官方的 Java 版本,但是在 http://liblinear.bwaldvogel.de/有一个非官方的 Java 端口。要使用它,请包括以下内容:

<dependency>
 <groupId>de.bwaldvogel</groupId>
 <artifactId>liblinear</artifactId>
 <version>1.95</version>
</dependency>

该接口与 LIBSVM 非常相似。首先,我们定义参数:

SolverType solverType = SolverType.L1R_LR; 
double C = 0.001; 
double eps = 0.0001;  
Parameter param = new Parameter(solverType, C, eps);

在本例中,我们指定了三个参数:

  • solverType:定义将要使用的模型
  • C:这是正则化的量,C 越小,正则化越强
  • epsilon:这是停止训练过程的容忍度;合理的默认值是0.0001

对于分类问题,以下是我们可以使用的解决方案:

  • 逻辑回归 : L1R_LRL2R_LR
  • SVM : L1R_L2LOSS_SVCL2R_L2LOSS_SVC

这里,我们有两个模型:逻辑回归和 SVM,以及两种正规化类型,L1 和 L2。我们如何决定选择哪种模型和使用哪种正则化?根据官方常见问题解答(可以在这里找到:https://www.csie.ntu.edu.tw/~cjlin/liblinear/FAQ.html),我们应该:

  • 与逻辑回归相比,我更喜欢 SVM,因为它训练速度更快,而且通常精度更高
  • 首先尝试 L2 正则化,除非你需要一个稀疏的解决方案,在这种情况下使用 L1

接下来,我们需要准备我们的数据。如前所述,我们需要将其包装成某种特殊的格式。首先,让我们看看如何包装单个数据行:

double[] row = // data 
int m = row.length; 
Feature[] result = new Feature[m]; 

for (int i = 0; i < m; i++) { 
    result[i] = new FeatureNode(i + 1, row[i]); 
}

注意,我们将1添加到索引中。0是偏置项,所以实际特性要从1开始。

我们可以将这段代码放入一个wrapRow函数中,然后将整个数据集包装如下:

double[][] X = // data 
int n = X.length; 
Feature[][] matrix = new Feature[n][]; 
for (int i = 0; i < n; i++) { 
    matrix[i] = wrapRow(X[i]); 
}

现在,我们可以用数据和标签创建Problem类:

double[] y = // labels 

Problem problem = new Problem(); 
problem.x = wrapMatrix(X); 
problem.y = y; 
problem.n = X[0].length + 1; 
problem.l = X.length;

注意,这里我们还需要提供数据的维度,也就是特征的数量加 1。我们需要增加一个,因为它包含了偏差项。

现在我们准备训练模型:

Model model = LibLinear.train(fold, param);

当模型被训练后,我们可以用它来分类看不见的数据。在下面的例子中,我们将输出概率:

double[] dataRow = // data 
Feature[] row = wrapRow(dataRow); 
Linear.predictProbability(model, row, probs); 
double result = probs[1];

前面的代码适用于逻辑回归模型,但不适用于 SVM,SVM 无法输出概率,因此前面的代码将为L1R_L2LOSS_SVC等求解器抛出错误。我们可以做的是获得原始输出:

double[] values = new double[1]; 
Feature[] row = wrapRow(dataRow); 
Linear.predictValues(model, row, values); 
double result = values[0];

在这种情况下,结果将不会包含概率,而是一些真实值。当该值大于零时,模型预测该类为正。

如果我们想将这个值映射到[0, 1]范围,我们可以使用sigmoid函数:

public static double[] sigmoid(double[] scores) { 
    double[] result = new double[scores.length]; 

    for (int i = 0; i < result.length; i++) { 
        result[i] = 1 / (1 + Math.exp(-scores[i])); 
    } 

    return result; 
}

最后,和 LIBSVM 一样,LIBLINEAR 也将很多东西输出到标准输出。如果您不希望看到它,可以使用以下代码将其静音:

PrintStream devNull = new PrintStream(new NullOutputStream()); 
Linear.setDebugOutput(devNull);

这里,我们使用 Apache IO 中的NullOutputStream,它什么也不做,所以屏幕保持干净。

想知道什么时候用 LIBSVM,什么时候用 LIBLINEAR?对于大型数据集,通常不可能使用任何内核方法。在这种情况下,您应该更喜欢 LIBLINEAR。此外,LIBLINEAR 特别适合文本处理,比如文档分类。我们将在第六章、中更详细地介绍这些案例——自然语言处理和信息检索

Encog

到目前为止,我们已经介绍了许多模型,即逻辑回归、SVM 和 RandomForest,并且我们已经查看了实现它们的多个库。但是我们还没有涉及神经网络。在 Java 中,有一个专门处理神经网络的特殊库——Encog。它可以在 Maven Central 上获得,并且可以通过以下代码片段进行添加:

<dependency>  
  <groupId>org.encog</groupId>  
  <artifactId>encog-core</artifactId>  
  <version>3.3.0</version>  
</dependency>

在包括库之后,第一步是指定神经网络的架构。我们可以这样做:

BasicNetwork network = new BasicNetwork(); 
network.addLayer(new BasicLayer(new ActivationSigmoid(), true, noInputNeurons)); 
network.addLayer(new BasicLayer(new ActivationSigmoid(), true, 30)); 
network.addLayer(new BasicLayer(new ActivationSigmoid(), true, 1)); 
network.getStructure().finalizeStructure(); 
network.reset();

这里,我们创建一个网络,它有一个输入层,一个内层有 30 个神经元,一个输出层有 1 个神经元。在每一层中,我们使用 sigmoid 作为激活函数,并添加偏置输入(true参数)。最后,reset方法随机初始化网络中的权重。

对于输入和输出,Encog 期望二维双数组。在二进制分类的情况下,我们通常有一个一维数组,所以我们需要转换它:

double[][] X = // data 
double[] y = // labels 
double[][] y2d = new double[y.length][]; 

for (int i = 0; i < y.length; i++) { 
    y2d[i] = new double[] { y[i] }; 
}

一旦数据被转换,我们就把它包装成一个特殊的包装类:

MLDataSet dataset = new BasicMLDataSet(X, y2d);

然后,该数据集可用于训练:

MLTrain trainer = new ResilientPropagation(network, dataset); 
double lambda = 0.01; 
trainer.addStrategy(new RegularizationStrategy(lambda)); 

int noEpochs = 101; 
for (int i = 0; i < noEpochs; i++) { 
    trainer.iteration(); 
}

我们不会在这里详细介绍 Encog,但我们会在第 8 章、中回到神经网络,用 DeepLearning4j 进行深度学习,在那里我们会看到一个不同的库——Deep Learning 4j。

Java 中还有很多其他的机器学习库。例如威卡、H2O、贾瓦尔等。这是不可能涵盖所有的,但你也可以尝试一下,看看你是否喜欢他们比我们已经涵盖的。

接下来,我们将看到如何评估分类模型。

估价

我们已经介绍了许多机器学习库,其中许多实现了相同的算法,如随机森林或逻辑回归。此外,每个单独的模型可以具有许多不同的参数,逻辑回归具有正则化系数,SVM 通过设置核及其参数来配置。

我们如何从这么多可能的变体中选择最佳的单一模型?

为此,我们首先定义一些评估指标,然后选择根据该指标实现最佳性能的模型。对于二进制分类,我们可以使用许多指标进行比较,最常用的指标如下:

  • 准确度和误差
  • 精确度、召回率和 F1
  • AUC(澳大利亚)

我们使用这些指标来观察模型对新的未知数据的概括能力。因此,当数据对模型来说是新的时,对这种情况建模是很重要的。这通常是通过将数据分成几个部分来完成的。因此,我们还将涵盖以下内容:

  • 结果评估
  • k 倍交叉验证
  • 培训、验证和测试

让我们从最直观的评估指标——准确性开始。

准确(性)

准确性是评估分类器最直接的方式:我们进行预测,查看预测的标签,然后将其与实际值进行比较。如果价值观一致,那么模型是正确的。然后,我们可以对我们所有的数据都这样做,看看正确预测的例子的比率;这正是准确性所描述的。因此,准确性告诉我们有多少例子模型预测了正确的标签。计算它是微不足道的:

int n = actual.length; 
double[] proba = // predictions; 

double[] prediction = Arrays.stream(proba).map(p -> p > threshold ? 1.0 : 0.0).toArray(); 
int correct = 0; 

for (int i = 0; i < n; i++) { 
    if (actual[i] == prediction[i]) { 
        correct++; 
    } 
} 

double accuracy = 1.0 * correct / n;

准确性是最简单的评估标准,很容易向任何人解释,甚至是非技术人员。

然而,有时,准确性并不是模型性能的最佳衡量标准。接下来我们来看看它的问题是什么,用什么来代替。

精确度、召回率和 F1

在某些情况下,精度值是有欺骗性的:它们表明分类器是好的,尽管它不是。例如,假设我们有一个不平衡的数据集:只有 1%的例子是正面的,其余的(99%)是负面的。然后,一个总是预测为负的模型在 99%的情况下是正确的,因此将具有 0.99 的准确度。但是这个模型并没有用。

除了准确性,还有其他方法可以解决这个问题。精确度和召回率都在这些指标中,因为它们都着眼于模型正确识别的积极项目的比例。所以,如果我们有大量的反面例子,我们仍然可以对模型进行一些有意义的评估。

可以使用混淆矩阵来计算精度和召回率,混淆矩阵是一个总结了二元分类器性能的表:

当我们使用二元分类模型来预测某个数据项的实际值时,有四种可能的结果:

  • 真正 ( TP ):实际类为正,我们预测为正
  • 真负 ( TN ):实际类为负,我们预测为负
  • 假阳性 ( FP ):实际类是阴性,我们却说是阳性
  • 假阴性 ( FN ):实际类是阳性,我们却说是阴性

前两种情况(TP 和 TN)是正确的预测,实际值和预测值是相同的。最后两种情况(FP 和 FN)是不正确的分类,因为我们无法预测正确的标签。

现在,假设我们有一个带有已知标签的数据集,并对其运行我们的模型。然后,设TP为真正例数,TN为真反例数,以此类推。

然后我们可以使用这些值来计算精度和召回率:

  • 精度是模型预测为阳性的所有项目中正确预测为阳性的项目所占的比例。就混淆矩阵而言,精度是TP / (TP + FP)
  • 回忆是正确预测的阳性项目在实际阳性项目中所占的比例。利用来自混淆矩阵的值,回忆是TP / (TP + FN)
  • 通常很难决定是应该优化精确度还是召回率。但是还有另一个将精确度和召回率结合成一个数字的指标,它被称为 F1 分数

为了计算精度和召回率,我们首先需要计算混淆矩阵单元的值:

int tp = 0, tn = 0, fp = 0, fn = 0; 

for (int i = 0; i < actual.length; i++) { 
    if (actual[i] == 1.0 && proba[i] > threshold) { 
        tp++; 
    } else if (actual[i] == 0.0 && proba[i] <= threshold) { 
        tn++; 
    } else if (actual[i] == 0.0 && proba[i] > threshold) { 
        fp++; 
    } else if (actual[i] == 1.0 && proba[i] <= threshold) { 
        fn++; 
    } 
}

然后,我们可以使用这些值来计算精度和召回率:

double precision = 1.0 * tp / (tp + fp); 
double recall = 1.0 * tp / (tp + fn);

最后,f1可以用下面的公式计算:

double f1 = 2 * precision * recall / (precision + recall);

当数据集不平衡时,这些指标非常有用。

ROC 和 AU ROC (AUC)

前面的度量对于产生硬输出的二进制分类器是好的;它们只告诉类是否应该被分配一个积极的标签或消极的。相反,如果我们的模型输出一些分数,使得分数的值越高,项目越有可能是正面的,那么二元分类器被称为排序分类器

大多数模型可以输出属于某一类的概率,我们可以用它来对例子进行排序,这样积极的东西可能会排在第一位。

ROC 曲线直观地告诉我们一个分级分类器从负面例子中分离正面例子有多好。ROC 曲线的构建方式如下:

  • 根据分数对观察值进行排序,然后从原点开始
  • 如果观察值为正,则向上;如果观察值为负,则向右。

这样,在理想情况下,我们首先总是向上,然后总是向右,这将产生最佳的 ROC 曲线。在这种情况下,我们可以说,正例与反例的分离是完美的。如果分离不完美,但仍然 OK ,曲线将上升为正例,但有时会在错误分类发生时右转。最后,一个糟糕的分类器将不能区分正例与反例,曲线将在向上和向右之间交替。

让我们看一些例子:

图上的对角线代表基线——随机分类器将达到的性能。曲线离基线越远越好。

不幸的是,在 Java 中没有 ROC 曲线的易用实现。我们自己实现代码并不难。在这里,我们将概述如何做到这一点,你会发现在代码库一章的实现。

所以绘制 ROC 曲线的算法如下:

  • 设 POS 为阳性标记的数量,NEG 为阴性标记的数量
  • 按分数降序排列数据
  • 从(0,0)开始
  • 对于排序顺序中的每个示例,
    • o 如果示例为正,则在图中上移 1 / POS,
    • o 否则,在图表中向右移动 1 /负。

这是一个简化的算法,并假设分数是不同的。如果分数不明显,并且同一个分数有不同的实际标签,就需要做一些调整。

它是在RocCurve类中实现的,您可以在源代码中找到。您可以按如下方式使用它:

RocCurve.plot(actual, prediction);

调用它将创建一个类似于这个的情节:

曲线下的面积表示正例与反例之间的分离程度。如果分离度很好,那么面积会接近 1。但如果分类器不能区分正反例,曲线会绕着随机基线曲线走,面积会接近 0.5

曲线下的面积通常缩写为 AUC,或者有时缩写为 AU ROC,以强调该曲线是 ROC 曲线。

AUC 有一个非常好的解释——AUC 的值对应于随机选择的阳性样本得分高于随机选择的阴性样本的概率。自然地,如果这个概率很高,我们的分类器在分离正面和负面例子方面做得很好。

这使得 AUC 成为许多情况下的一种评估指标,特别是当数据集不平衡时,因为一个类别的示例比另一个类别的多得多。

幸运的是,Java 中有 AUC 的实现。例如,它在 Smile 中实现。你可以这样使用它:

double[] predicted = ...  // 
int[] truth = ... //
double auc = AUC.measure(truth, predicted);

现在,当我们讨论可能的评估指标时,我们需要应用它们来测试我们的模型。我们需要小心处理。如果我们对用于训练的相同数据进行评估,那么评估结果将过于乐观。接下来,我们将看到什么是正确的做法。

结果验证

当从数据中学习时,总是有过度拟合的危险。当模型开始学习数据中的噪声而不是检测有用的模式时,就会发生过度拟合。检查模型是否过度拟合总是很重要的,否则当应用于看不见的数据时,它将是无用的。

检查模型是否过拟合的典型且最实用的方法是模拟看不见的数据,也就是说,取一部分可用的标记数据,不使用它进行训练。

这种技术被称为保留,我们保留一部分数据,仅用于评估。

我们还在分割前打乱原始数据集。在许多情况下,我们会做一个简化的假设,即数据的顺序并不重要,也就是说,一个观察值对另一个观察值没有影响。在这种情况下,在拆分之前打乱数据将会消除项目顺序可能产生的影响。另一方面,如果数据是时间序列数据,那么打乱它不是一个好主意,因为观察值之间存在一些相关性。

那么,让我们实现保持分离。我们假设我们拥有的数据已经用X表示了——一个具有特征的双精度二维数组和y——一个标签一维数组。

首先,我们创建一个助手类来保存数据:

public class Dataset { 
    private final double[][] X; 
    private final double[] y; 
    // constructor and getters are omitted 
}

分割我们的数据集应该产生两个数据集,所以我们也为其创建一个类:

public class Split { 
    private final Dataset train; 
    private final Dataset test; 
    // constructor and getters are omitted 
}

现在,假设我们想把数据分成两部分:训练和测试。我们还想指定训练集的大小,我们将使用一个testRatio参数:应该进入测试集的项目的百分比。

我们做的第一件事是生成一个带索引的数组,然后根据testRatio对其进行拆分:

int[] indexes = IntStream.range(0, dataset.length()).toArray(); 
int trainSize = (int) (indexes.length * (1 - testRatio)); 
int[] trainIndex = Arrays.copyOfRange(indexes, 0, trainSize); 
int[] testIndex = Arrays.copyOfRange(indexes, trainSize, indexes.length);

如果需要,我们也可以打乱索引:

Random rnd = new Random(seed); 

for (int i = indexes.length - 1; i > 0; i--) { 
    int index = rnd.nextInt(i + 1); 
    int tmp = indexes[index]; 
    indexes[index] = indexes[i]; 
    indexes[i] = tmp; 
}

然后,我们可以为训练集选择实例,如下所示:

int trainSize = trainIndex.length; 
double[][] trainX = new double[trainSize][]; 
double[] trainY = new double[trainSize]; 
for (int i = 0; i < trainSize; i++) { 
    int idx = trainIndex[i]; 
    trainX[i] = X[idx]; 
    trainY[i] = y[idx]; 
}

最后,将它包装到我们的Dataset类中:

Dataset train = new Dataset(trainX, trainY);

如果我们对测试集重复同样的操作,我们可以将训练集和测试集放入一个Split对象中:

Split split = new Split(train, test);

现在我们可以使用 train fold 进行训练,使用 test fold 测试模型。

如果我们把前面所有的代码放到Dataset类的一个函数中,例如trainTestSplit,我们可以如下使用它:

Split split = dataset.trainTestSplit(0.2); 

Dataset train = split.getTrain();
// train the model using train.getX() and train.getY()

Dataset test = split.getTest(); 
// test the model using test.getX(); test.getY();

这里,我们在train数据集上训练一个模型,然后在test集上计算评估度量。

k 倍交叉验证

只提供一部分数据并不总是最好的选择。相反,我们可以做的是将它分成 K 个部分,然后只对第 1/K 个数据测试模型。

这叫做 k 倍交叉验证;它不仅给出了性能估计,而且给出了误差的可能传播。通常,我们感兴趣的是能提供良好和稳定性能的模型。K-fold 交叉验证有助于我们选择这样的模型。

接下来,我们准备用于 k 倍交叉验证的数据,如下所示:

  • 首先,将数据分成 K 个部分
  • 然后,对于这些零件中的每一个:
    • 取一部分作为验证集
    • 将剩余的 K-1 零件作为训练集

如果我们把它翻译成 Java,第一步会是这样的:

int[] indexes = IntStream.range(0, dataset.length()).toArray(); 
int[][] foldIndexes = new int[k][]; 

int step = indexes.length / k; 
int beginIndex = 0; 

for (int i = 0; i < k - 1; i++) { 
    foldIndexes[i] = Arrays.copyOfRange(indexes, beginIndex, beginIndex + step); 
    beginIndex = beginIndex + step; 
} 

foldIndexes[k - 1] = Arrays.copyOfRange(indexes, beginIndex, indexes.length);

这为每个 K 折叠创建了一个索引数组。我们也可以像前面一样打乱索引数组。

现在,我们可以从每个折叠创建拆分:

List<Split> result = new ArrayList<>(); 

for (int i = 0; i < k; i++) { 
    int[] testIdx = folds[i]; 
    int[] trainIdx = combineTrainFolds(folds, indexes.length, i); 
    result.add(Split.fromIndexes(dataset, trainIdx, testIdx)); 
}

在前面的代码中,我们有两个额外的方法:

  • combineTrainFolds:这个函数接收带有索引的 K-1 个数组,并将它们组合成一个
  • Split.fromIndexes:这将创建一个训练和测试索引的分割。

当我们创建一个简单的保持测试集时,我们已经讨论了第二个功能。

第一个函数combineTrainFolds是这样实现的:

private static int[] combineTrainFolds(int[][] folds, int totalSize, int excludeIndex) { 
    int size = totalSize - folds[excludeIndex].length; 
    int result[] = new int[size]; 

    int start = 0; 
    for (int i = 0; i < folds.length; i++) { 
        if (i == excludeIndex) { 
            continue; 
        } 
        int[] fold = folds[i]; 
        System.arraycopy(fold, 0, result, start, fold.length); 
        start = start + fold.length; 
    } 

    return result; 
}

同样,我们可以将前面的代码放入Dataset类的函数中,并像下面这样调用它:

List<Split> folds = train.kfold(3);

现在,当我们有了一个Split对象的列表时,我们可以创建一个特殊的函数来执行交叉验证:

public static DescriptiveStatistics crossValidate(List<Split> folds,  
        Function<Dataset, Model> trainer) { 
    double[] aucs = folds.parallelStream().mapToDouble(fold -> { 
        Dataset foldTrain = fold.getTrain(); 
        Dataset foldValidation = fold.getTest(); 
        Model model = trainer.apply(foldTrain); 
        return auc(model, foldValidation); 
    }).toArray(); 

    return new DescriptiveStatistics(aucs); 
}

这个函数的作用是,获取一个折叠列表和一个回调函数,并创建一个模型。在模型被训练之后,我们计算它的 AUC。

此外,我们利用 Java 的并行循环能力,同时在每个折叠上训练模型。

最后,我们将在每个折叠上计算的 AUC 放入一个DescriptiveStatistics对象中,该对象稍后可用于返回 AUC 的平均值和标准差。您可能还记得,DescriptiveStatistics类来自 Apache Commons 数学库。

让我们考虑一个例子。假设我们想要使用来自LIBLINEAR的逻辑回归,并为正则化参数C选择最佳值。我们可以这样使用前面的函数:

double[] Cs = { 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0 }; 

for (double C : Cs) { 
    DescriptiveStatistics summary = crossValidate(folds, fold -> { 
        Parameter param = new Parameter(SolverType.L1R_LR, C, 0.0001); 
        return LibLinear.train(fold, param); 
    }); 

    double mean = summary.getMean(); 
    double std = summary.getStandardDeviation(); 
    System.out.printf("L1 logreg C=%7.3f, auc=%.4f &pm; %.4f%n", C, mean, std); 
}

这里,LibLinear.train是一个助手方法,它接受一个Dataset对象和一个Parameter对象,然后训练一个 LIBLINEAR 模型。这将打印所有提供的C值的 AUC,因此您可以看到哪一个是最好的,并选择具有最高平均 AUC 的一个。

培训、验证和测试

当进行交叉验证时,仍然存在过度拟合的危险。由于我们在同一个验证集上尝试了许多不同的实验,我们可能会意外地选择在验证集上表现良好的模型——但它可能稍后无法推广到看不见的数据。

这个问题的解决方案是在最开始的时候拿出一个测试集,在我们选择出我们认为最好的模型之前,不要碰它。我们只用它来评估最终的模型。

那么,我们如何选择最佳模型呢?我们能做的就是对剩下的训练数据做交叉验证。它可以被保持或 k-fold 交叉验证。一般来说,您应该更喜欢进行 k-fold 交叉验证,因为它还可以提供性能分布,您也可以在模型选择中使用它。

下图说明了该过程:

根据图表,典型的数据科学工作流应该如下所示:

  • 0 :选择一些指标进行验证,例如,准确度或 AUC
  • 1 :将所有数据分成训练集和测试集
  • 2 :进一步拆分训练数据,保留一个验证数据集,或者拆分成 k 个折叠
  • 3 :使用验证数据进行选型和参数优化
  • 4 :根据验证集选择最佳模型,并对照坚持测试集进行评估

避免过于频繁地查看测试集是很重要的,它应该很少使用,并且只用于最终评估,以确保所选模型不会过度拟合。如果认证方案设置正确,认证分数应与最终测试分数一致。如果发生这种情况,我们可以肯定模型不会过度拟合,并且能够推广到看不见的数据。

使用我们之前创建的类和代码,它可以转换成下面的 Java 代码:

Dataset data = new Dataset(X, y); 
Dataset train = split.getTrain(); 
List<Split> folds = train.kfold(3); 
// now use crossValidate(folds, ...) to select the best model 

Dataset test = split.getTest(); 
// do final evaluation of the best model on test

有了这些信息,我们准备做一个关于二元分类的项目。

案例研究-页面预测

现在我们将继续我们运行的例子,搜索引擎。这里我们想做的是尝试预测一个 URL 是否来自搜索引擎结果的第一页。所以,是时候使用我们在这一章中已经介绍过的材料了。

第二章数据处理工具箱中,我们创建了以下对象来存储关于页面的信息:

public class RankedPage { 
    private String url; 
    private int position; 
    private int page; 
    private int titleLength; 
    private int bodyContentLength; 
    private boolean queryInTitle; 
    private int numberOfHeaders; 
    private int numberOfLinks; 
}

首先,我们可以从向该对象添加一些方法开始,如下所示:

  • isHttps:这将告诉我们该 URL 是否是 HTTPS,是否可以用url.startsWith("https://")实现
  • isComDomain:这应该告诉我们 URL 是否属于 COM 域,以及我们是否可以用url.contains(".com")来实现它
  • isOrgDomainisNetDomain:与上一个相同,但分别针对 ORG 和 NET
  • numberOfSlashes:这是 URL 中斜杠字符的个数,可以用番石榴的CharMatcher : CharMatcher.*is*('/').countIn(url)实现

这些模型描述了我们得到的每个 URL,所以我们称之为特征方法,我们可以在我们的机器学习模型中使用这些方法的结果。

如前所述,我们有一个读取 JSON 数据并从中创建 Joinery 数据帧的方法:

List<RankedPage> pages = RankedPageData.readRankedPages(); 
DataFrame<Object> dataframe = BeanToJoinery.convert(pages, RankedPage.class);

有了数据后,第一步是提取目标变量的值:

List<Object> page = dataframe.col("page"); 
double[] target = page.stream() 
                      .mapToInt(o -> (int) o) 
                      .mapToDouble(p -> (p == 0) ? 1.0 : 0.0) 
                      .toArray();

为了得到特征矩阵X,我们可以使用 Joinery 为我们创建一个二维数组。首先,我们需要删除一些变量,即目标变量、URL 以及位置,因为位置显然与页面相关。我们可以这样做:

dataframe = dataframe.drop("page", "url", "position"); 
double[][] X = dataframe.toModelMatrix(0.0);

接下来,我们可以使用我们在本章中创建的Dataset类,并将它分成训练和测试部分:

Dataset dataset = new Dataset(X, target); 
Split split = dataset.trainTestSplit(0.2); 
Dataset train = split.getTrain(); 
Dataset test = split.getTest();

此外,对于某些算法,对要素进行标准化很有帮助,这样它们的平均值和单位标准差为零。这样做的原因是为了帮助优化算法更快地收敛。

为此,我们计算矩阵中每一列的平均值和标准偏差,然后从每个值中减去平均值,再除以标准偏差。为了简洁起见,我们在这里省略了这个函数的代码,但是您可以在代码仓库一章中找到它。

下面的代码可以做到这一点:

preprocessor = StandardizationPreprocessor.train(train); 
train = preprocessor.transform(train); 
test = preprocessor.transform(test);

现在我们准备开始训练不同的模型。让我们先从 Smile 开始尝试逻辑回归实现。我们将使用 k-fold 交叉验证来选择其正则化参数λ的最佳值。

List<Fold> folds = train.kfold(3); 
double[] lambdas = { 0, 0.5, 1.0, 5.0, 10.0, 100.0, 1000.0 }; 
for (double lambda : lambdas) { 
    DescriptiveStatistics summary = Smile.crossValidate(folds, fold -> { 
        return new LogisticRegression.Trainer() 
                .setRegularizationFactor(lambda) 
                .train(fold.getX(), fold.getYAsInt()); 
    }); 

    double mean = summary.getMean(); 
    double std = summary.getStandardDeviation(); 
    System.out.printf("logreg, λ=%8.3f, auc=%.4f &pm; %.4f%n", lambda, mean, std); 
}

注意这里的Dataset类有一个新方法getYAsInt,它简单地返回表示为整数数组的目标变量。当我们运行它时,它会产生以下输出:

logreg, λ=   0.000, auc=0.5823 &pm; 0.0041 
logreg, λ=   0.500, auc=0.5822 &pm; 0.0040 
logreg, λ=   1.000, auc=0.5820 &pm; 0.0037 
logreg, λ=   5.000, auc=0.5820 &pm; 0.0030 
logreg, λ=  10.000, auc=0.5823 &pm; 0.0027 
logreg, λ= 100.000, auc=0.5839 &pm; 0.0009 
logreg, λ=1000.000, auc=0.5859 &pm; 0.0036

它显示了λ的值,我们得到的该值的 AUC,以及跨不同折叠的 AUC 的标准偏差。

我们看到我们得到的 AUC 相当低。这不应该是一个惊喜:仅使用我们现在拥有的信息显然不足以完全逆向工程搜索引擎的排名算法。在接下来的章节中,我们将学习如何从页面中提取更多的信息,这些技术将有助于大大增加 AUC。

我们可以注意到的另一件事是,不同λ值的 AUC 非常相似,但其中一个具有最低的标准偏差。在这种情况下,我们应该总是选择方差最小的模型。

我们还可以尝试更复杂的分类器,如 RandomForest:

DescriptiveStatistics rf = Smile.crossValidate(folds, fold -> { 
    return new RandomForest.Trainer() 
            .setNumTrees(100) 
            .setNodeSize(4) 
            .setSamplingRates(0.7) 
            .setSplitRule(SplitRule.ENTROPY) 
            .setNumRandomFeatures(3) 
            .train(fold.getX(), fold.getYAsInt()); 
}); 

System.out.printf("random forest auc=%.4f &pm; %.4f%n", rf.getMean(), rf.getStandardDeviation());

这将创建以下输出:

random forest auc=0.6093 &pm; 0.0209

这个分类器平均比逻辑回归分类器好 2%,但是我们也可以注意到标准偏差相当高。因为它高得多,我们可以怀疑,在测试数据上,该模型的表现可能比逻辑回归模型差得多。

接下来,我们也可以尝试训练其他模型。但是,让我们假设我们这样做了,最后我们得出结论,使用lambda=100的逻辑回归给出了最佳性能。然后,我们可以对整个训练数据集进行再训练,然后使用测试集进行最终评估:

LogisticRegression logregFinal = new LogisticRegression.Trainer() 
        .setRegularizationFactor(100.0) 
        .train(train.getX(), train.getYAsInt()); 

double auc = Smile.auc(logregFinal, test); 
System.out.printf("final logreg auc=%.4f%n", auc);

该代码产生以下输出:

final logreg auc=0.5807

因此,事实上,我们可以看到,所选模型产生的 AUC 与我们交叉验证中的相同。这是一个很好的迹象,表明该模型可以很好地概括,不会过度填充。

出于好奇,我们还可以检查 RandomForest 模型在训练集上的表现。由于它具有较高的方差,因此它的表现可能比逻辑回归差,但也可能好得多。让我们在整个列车上重新训练它:

RandomForest rfFinal = new RandomForest.Trainer() 
        .setNumTrees(100) 
        .setNodeSize(4) 
        .setSamplingRates(0.7) 
        .setSplitRule(SplitRule.ENTROPY) 
        .setNumRandomFeatures(3) 
        .train(train.getX(), train.getYAsInt()); 

double auc = Smile.auc(rfFinal, test); 
System.out.printf("final rf auc=%.4f%n", finalAuc);

它打印以下内容:

final rf auc=0.5778

因此,事实上,模型的高方差导致测试分数低于交叉验证分数。这不是一个好的迹象,这样的模型不应该是首选。

因此,对于这样的数据集,表现最好的模型是逻辑回归。

如果你想知道如何使用其他机器学习库来解决这个问题,可以查看本章的代码库。在那里,我们为 JSAT、JavaML、LIBSVM、LIBLINEAR 和 Encog 创建了一些例子。

至此,我们结束了本章关于分类的部分,接下来我们将研究另一个被称为回归的监督学习问题。

回归

在机器学习中,回归问题处理标签信息连续的情况。这可以是预测明天的气温、股票价格、一个人的工资或者电子商务网站上一件商品的评级。

有许多模型可以解决衰退问题:

  • 普通最小二乘法 ( OLS )就是通常的线性回归
  • 岭回归和套索是 OLS 的正则化变体
  • 基于树的模型,如 RandomForest
  • 神经网络

处理回归问题与处理分类问题非常相似,总体框架保持不变:

  • 首先,您选择一个评估指标
  • 然后,您将数据分为训练和测试
  • 您在训练中训练模型,使用交叉验证调整参数,并使用保留的测试集进行最终验证。

用于回归的机器学习库

我们已经讨论了许多可以处理分类问题的机器学习库。通常,这些库也有回归模型。让我们简单回顾一下。

微笑

Smile 是一个通用的机器学习库,所以它也有回归模型。你可以看看模特名单,这里:https://github.com/haifengl/smile

例如,这是创建简单线性回归的方法:

OLS ols = new OLS(data.getX(), data.getY());

对于正则化回归,可以使用脊或套索:

double lambda = 0.01; 
RidgeRegression ridge = new RidgeRegression(data.getX(), data.getY(), lambda); 
LASSO lasso = new LASSO(data.getX(), data.getY(), lambda);

使用 RandomForest 与分类情况非常相似:

int nbtrees = 100; 
RandomForest rf = new RandomForest.Trainer(nbtrees) 
        .setNumRandomFeatures(15) 
        .setMaxNodes(128) 
        .setNodeSize(10) 
        .setSamplingRates(0.6) 
        .train(data.getX(), data.getY());

预测也与分类情况相同。我们需要做的只是使用predict方法:

double result = model.predict(row);

JSAT

JSAT 也是一个通用库,包含许多解决回归问题的实现。

与分类一样,它需要一个用于数据的包装类和一个用于回归的特殊包装:

double[][] X = ... // 
double[] y = ... // 
List<DataPointPair<Double>> data = new ArrayList<>(X.length); 

for (int i = 0; i < X.length; i++) { 
    DataPoint row = new DataPoint(new DenseVector(X[i])); 
    data.add(new DataPointPair<Double>(row, y[i])); 
} 

RegressionDataSet dataset = new RegressionDataSet(data);

一旦数据集被包装在正确的类中,我们就可以像这样训练模型:

MultipleLinearRegression linreg = new MultipleLinearRegression(); 
linreg.train(dataset);;

前面的代码训练通常的 OLS 线性回归。

与 Smile 不同,当矩阵病态时,OLS 不会产生稳定的解,也就是说,它有一些线性相关的解。在这种情况下,使用正则化模型。

可以使用以下代码来训练正则化线性回归:

RidgeRegression ridge = new RidgeRegression(); 
ridge.setLambda(lambda); 
ridge.train(dataset);

然后,为了预测,我们还需要做一些转换:

double[] row = .. . // 
DenseVector vector = new DenseVector(row); 
DataPoint point = new DataPoint(vector); 
double result = model.regress(point);

其他图书馆

我们之前提到的其他库也有解决回归问题的模型。

例如,在 LIBSVM 中,可以通过将svm_type参数设置为EPSILON_SVRNU_SVR来进行回归,而代码的其余部分几乎与分类情况相同。同样,在 LIBLINEAR 中,回归问题通过选择L2R_L2LOSS_SVRL2R_L2LOSS_SVR_DUAL模型来解决。

也可以用神经网络解决回归问题,例如在 Encog 中。您唯一需要更改的是损失函数:您应该使用回归损失函数,比如均方差,而不是最小化分类损失函数(比如logloss)。

因为大部分代码都非常相似,所以没有必要详细介绍。一如既往,我们在章节代码库中准备了一些代码示例,请随意查看。

估价

与分类一样,我们也需要评估模型的结果。有一些指标有助于做到这一点,并选择最佳模型。先来过两个最流行的:均方误差 ( MSE )和平均绝对误差 ( MAE )。

均方误差(mean square error)

均方误差 ( MSE )是实际值和预测值的平方差之和。用 Java 计算它很容易:

double[] actual, predicted;  

int n = actual.length; 
double sum = 0.0; 
for (int i = 0; i < n; i++) { 
    diff = actual[i] - predicted[i]; 
    sum = sum + diff * diff; 
} 

double mse = sum / n;

通常,MSE 的值很难解释,这就是为什么我们经常取 MSE 的平方根;这叫做均方根误差 ( RMSE )。它更容易解释,因为它与目标变量使用相同的单位。

double rmse = Math.sqrt(mse);

平均绝对误差

平均绝对误差 ( MAE ),是评估性能的替代指标。它不取误差的平方,而只取实际值和预测值之差的绝对值。我们可以这样计算:

double sum = 0.0; 
for (int i = 0; i < n; i++) { 
    sum = sum + Math.abs(actual[i] - predicted[i]); 
} 

double mae = sum / n;

有时我们会在数据中发现异常值——非常不规则的值。如果我们有很多离群值,我们应该选择 MAE 而不是 RMSE,因为它对他们更稳健。如果我们没有很多离群值,那么 RMSE 应该是首选。

还有其他指标,如 MAPE 或 RMSE,但它们使用频率较低,因此我们不会涉及它们。

虽然我们只是简单地浏览了一下解决回归问题的库,但是有了从解决分类问题的概述中获得的基础,做一个回归项目就足够了。

案例研究-硬件性能

在这个项目中,我们将尝试预测在不同的计算机上将两个矩阵相乘需要多少时间。

这个项目的数据集最初来自西德涅夫和格尔格尔(2014)的论文自动选择最快的算法实现,并在 Mail.RU 组织的一次机器学习比赛上提供。您可以在 http://mlbootcamp.ru/championship/7/的查看详细信息。

内容是俄语的,所以如果你不会说俄语,最好使用有翻译支持的浏览器。

你会找到数据集的副本以及本章的代码。

该数据集包含以下数据:

  • mkn表示矩阵的维度,m*k为矩阵A的维度,k*n为矩阵B的维度
  • 硬件特征,如 CPU 速度、内核数量、是否启用超高速缓存以及 CPU 类型
  • 操作系统

这个问题的解决方案对研究非常有用,当选择硬件来运行实验时。那样的话。您可以使用该模型来选择应该产生最佳性能的构建。

因此,目标是在给定大小和环境特征的情况下,预测两个矩阵相乘需要多少秒。虽然本文使用 MAPE 作为评估指标,但我们将使用 RMSE,因为它更易于实施和解释。

首先,我们需要读取数据。有两个文件,一个包含特征,一个包含标签。我们先来读一下目标:

DataFrame<Object> targetDf = DataFrame.readCsv("data/performance/y_train.csv"); 
List<Double> targetList = targetDf.cast(Double.class).col("time"); 
double[] target = Doubles.toArray(targetList);

接下来,我们来读一下特写:

DataFrame<Object> dataframe = DataFrame.readCsv("data/performance/x_train.csv");

如果我们查看数据,我们会注意到有时缺失的值被编码为一个字符串None。我们需要把它转换成真正的 Java null。为此,我们可以定义一个特殊的函数:

private static List<Object> noneToNull(List<Object> memfreq) { 
    return memfreq.stream() 
            .map(s -> isNone(s) ? null : Double.parseDouble(s.toString())) 
            .collect(Collectors.toList()); 
}

现在,使用它来处理原始列,然后删除它们,并添加转换后的列:

List<Object> memfreq = noneToNull(dataframe.col("memFreq")); 
List<Object> memtRFC = noneToNull(dataframe.col("memtRFC")); 
dataframe = dataframe.drop("memFreq", "memtRFC"); 
dataframe.add("memFreq", memfreq); 
dataframe.add("memtRFC", memtRFC);

数据集中有一些分类变量。我们可以看看它们。首先,让我们创建一个数据帧,它包含原始帧的类型:

List<Object> types = dataframe.types().stream() 
             .map(c -> c.getSimpleName()) 
             .collect(Collectors.toList()); 
List<Object> columns = new ArrayList<>(dataframe.columns()); 
DataFrame<Object> typesDf = new DataFrame<>(); 
typesDf.add("column", columns); 
typesDf.add("type", types);

因为我们只对分类值感兴趣,所以我们需要选择类型为String的特性:

DataFrame<Object> stringTypes = typesDf.select(p -> p.get(1).equals("String"));

分类变量在机器学习问题中经常使用的方式被称为虚拟编码,或一种热编码。在这种编码方案中:

  • 只要有可能的值,我们就创建尽可能多的列
  • 对于每个观察值,我们为该列加上数字1,它对应于分类变量的值,其余的列得到0

细木工可以为我们自动完成这种转换:

double[][] X = dataframe.toModelMatrix(0.0);

前面的代码将对所有分类变量应用一个热编码方案。

然而,对于我们现有的数据,分类变量的一些值只出现一次或几次。通常,我们对这种不常出现的值不感兴趣,所以我们可以用一些人工值(如OTHER)来替换它们。

在细木工领域,我们是这样做的:

  • DataFrame中删除所有分类列
  • 对于每一列,我们计算这些值出现的次数,并用OTHER替换不频繁

让我们把它翻译成 Java 代码。这样我们就得到分类变量:

Object[] columns = stringTypes.col("column").toArray(); 
DataFrame<Object> categorical = dataframe.retain(columns); 
dataframe = dataframe.drop(stringTypes.col("column").toArray());

为了计数,我们可以使用来自番石榴的Multiset集合。然后,我们用OTHER替换不常用的,并将结果放回数据帧:

for (Object column : categorical.columns()) { 
    List<Object> data = categorical.col(column); 
    Multiset<Object> counts = HashMultiset.create(data); 

    List<Object> cleaned = data.stream() 
            .map(o -> counts.count(o) >= 50 ? o : "OTHER") 
            .collect(Collectors.toList()); 

    dataframe.add(column, cleaned); 
}

在此处理之后,我们可以将数据帧转换成矩阵,并将其放入我们的Dataset对象:

double[][] X = dataframe.toModelMatrix(0.0); 
Dataset dataset = new Dataset(X, target);

现在我们准备开始训练模型。同样,我们将使用 Smile 来实现机器学习算法,其他库的代码可在章节代码库中找到。

我们已经决定使用 RMSE 作为评估指标。现在我们需要建立交叉验证方案,并为最终评估提供数据:

Split trainTestSplit = dataset.shuffleSplit(0.3); 
Dataset train = trainTestSplit.getTrain(); 
Dataset test = trainTestSplit.getTest(); 
List<Split> folds = train.shuffleKFold(3);

我们可以重用我们为分类情况编写的函数,并稍微修改它以适应回归情况:

public static DescriptiveStatistics crossValidate(List<Split> folds, 
        Function<Dataset, Regression<double[]>> trainer) { 
    double[] aucs = folds.parallelStream().mapToDouble(fold -> { 
        Dataset train = fold.getTrain(); 
        Dataset validation = fold.getTest(); 
        Regression<double[]> model = trainer.apply(train); 
        return rmse(model, validation); 
    }).toArray(); 

    return new DescriptiveStatistics(aucs); 
}

在前面的代码中,我们首先训练一个回归模型,然后在验证数据集上评估它的 RMSE。

在开始建模之前,让我们先来看一个简单的基线解决方案。在回归的情况下,总是预测平均值可以是这样的基线:

private static Regression<double[]> mean(Dataset data) { 
    double meanTarget = Arrays.stream(data.getY()).average().getAsDouble(); 
    return x -> meanTarget; 
}

让我们将它用作基线计算的交叉验证函数:

DescriptiveStatistics baseline = crossValidate(folds, data -> mean(data)); 
System.out.printf("baseline: rmse=%.4f &pm; %.4f%n", baseline.getMean(), baseline.getStandardDeviation());

它将以下内容打印到控制台:

baseline: rmse=25.1487 &pm; 4.3445

我们的基线解平均误差为 25 秒,误差范围为 4.3 秒。

现在我们可以尝试训练一个简单的 OLS 回归:

DescriptiveStatistics ols = crossValidate(folds, data -> { 
    return new OLS(data.getX(), data.getY()); 
}); 

System.out.printf("ols: rmse=%.4f &pm; %.4f%n", ols.getMean(), ols.getStandardDeviation());

我们应该注意到,Smile 给了我们一个警告,即矩阵不是满秩的,它将使用奇异值分解 ( SVD )来解决 OLS 问题。我们可以忽略它,或者明确地告诉它使用 SVD:

new OLS(data.getX(), data.getY(), true);

在任一情况下,它都会将以下内容打印到控制台:

ols: rmse=15.8679 &pm; 3.4587

当我们使用正则化模型时,我们通常不担心相关列。让我们用不同的lambda值来尝试套索:

double[] lambdas = { 0.1, 1, 10, 100, 1000, 5000, 10000, 20000 }; 
for (double lambda : lambdas) { 
    DescriptiveStatistics summary = crossValidate(folds, data -> { 
        return new LASSO(data.getX(), data.getY(), lambda); 
    }); 

    double mean = summary.getMean(); 
    double std = summary.getStandardDeviation(); 
    System.out.printf("lasso λ=%9.1f, rmse=%.4f &pm; %.4f%n", lambda, mean, std); 
}

它产生以下输出:

lasso λ=      0.1, rmse=15.8679 &pm; 3.4587 
lasso λ=      1.0, rmse=15.8678 &pm; 3.4588 
lasso λ=     10.0, rmse=15.8650 &pm; 3.4615 
lasso λ=    100.0, rmse=15.8533 &pm; 3.4794 
lasso λ=   1000.0, rmse=15.8650 &pm; 3.5905 
lasso λ=   5000.0, rmse=16.1321 &pm; 3.9813 
lasso λ=  10000.0, rmse=16.6793 &pm; 4.3830 
lasso λ=  20000.0, rmse=18.6088 &pm; 4.9315

请注意,Smile 版本 1.1.0 中的 LASSO 实现对该数据集有问题,因为存在线性相关的列。为了避免这种情况,您应该使用 1.2.0 版本,在编写本文时,Maven Central 还没有提供该版本,如果您想使用它,您需要自己构建它。我们已经讨论过如何做到这一点。

我们也可以尝试 RidgeRegression,但它的性能与 OLS 和拉索非常相似,所以我们在这里将省略它。

看起来 OLS 的结果与套索没有太大的不同,所以我们选择它作为最终模型并使用它,因为它是最简单的模型:

OLS ols = new OLS(train.getX(), train.getY(), true); 
double testRmse = rmse(lasso, test); 
System.out.printf("final rmse=%.4f%n", testRmse);

这为我们提供了以下输出:

final rmse=15.0722

因此,所选模型的性能与我们的交叉验证一致,这意味着该模型能够很好地推广到未知数据。

摘要

在这一章中,我们谈到了监督机器学习和两个常见的监督问题:分类和回归。我们还介绍了常用算法库,实现了它们,并学习了如何评估这些算法的性能。

还有另一类不需要标签信息的机器学习算法;这些方法被称为无监督学习——在下一章,我们将会谈到它们。

五、无监督学习——聚类和降维

在前一章中,介绍了 Java 中的机器学习,并讨论了在提供标签信息的情况下如何处理监督学习问题。

然而往往没有标签信息,我们有的只是一些数据。在这种情况下,仍然可以使用机器学习,这类问题称为无监督学习;没有标签,因此没有监督。聚类分析属于这些算法中的一种。给定一些数据集,目标是从那里对项目进行分组,以便将相似的项目放入同一个组中。

此外,当有标签信息时,一些无监督学习技术可能是有用的。

例如,降维算法试图压缩数据集,以便保留大部分信息,并且数据集可以用较少的特征来表示。此外,降维对于执行聚类分析也是有用的,并且聚类分析可以用于执行降维。

我们将在本章中看到如何做到这一切。具体来说,我们将涵盖以下主题:

  • 无监督的降维方法,如 PCA 和 SVD
  • 聚类分析算法,如 k-means
  • Java 中可用的实现

到本章结束时,你将知道如何对你拥有的数据进行聚类,以及如何使用 Smile 和其他 Java 库在 Java 中进行降维。

降维

顾名思义,降维就是降低数据集的维数。也就是说,这些技术试图压缩数据集,以便只保留最有用的信息,而丢弃其余的信息。

数据集的维度是指数据集的特征数量。当维数很高时,即有太多的特征时,由于以下原因,它可能是不好的:

  • 如果特征多于数据集的项目,问题就变得难以定义,一些线性模型,如普通最小二乘法 ( OLS )回归无法处理这种情况
  • 一些特征可能是相关的,并导致训练和解释模型的问题
  • 一些特征可能会变得嘈杂或不相关,并使模型混乱
  • 在高维空间中,距离开始变得不那么有意义了——这个问题通常被称为维数灾难
  • 处理大量的特征在计算上可能是昂贵的

在高维数的情况下,我们感兴趣的是降低维数,使其变得易于管理。有几种方法可以做到这一点:

  • 监督降维方法,如特征选择:我们使用关于标签的信息来帮助我们决定哪些特征是有用的,哪些是无用的
  • 无监督的维数减少,例如特征提取:我们不使用关于标签的信息(或者因为我们没有或者不愿意这样做),并试图将大的特征集压缩成较小的特征集

在这一章中,我们将讨论第二种类型,即无监督降维,特别是特征提取。

无监督降维

特征提取算法背后的主要思想是,它们接受一些高维度的数据集,对其进行处理,并返回一个包含更小的新特征集的数据集。

注意,返回的特征是新的,它们是从数据中提取的学习的。但是这种提取是以这样一种方式进行的,即数据的新表示尽可能多地保留来自原始特征的信息。换句话说,它获取用旧要素表示的数据,对其进行转换,然后返回一个包含全新要素的新数据集。

有许多用于降维的特征提取算法,包括:

  • 主成分分析 ( PCA )和奇异值分解 ( SVD )
  • 非负矩阵分解 ( NNMF
  • 随机投影
  • 局部线性嵌入 ( LLE )
  • t 雪

在这一章中,我们将讨论主成分分析、奇异值分解和随机投影。其他技术不太流行,在实践中也不常用,所以我们不会在本书中讨论它们。

主成分分析

主成分分析 ( PCA )是最著名的特征提取算法。PCA 学习的新特征表示是原始特征的线性组合,使得原始数据内的变化被尽可能地保留。

让我们来看看这个算法的运行情况。首先,我们将考虑我们已经使用的性能预测数据集。对于这个问题,特征的数量比较大;在使用 one-hot-encoding 对分类变量进行编码后,有超过 1000 个特征,而只有 5000 个观察值。显然,1000 个特征对于这样小的样本量来说是相当多的,这可能会在建立机器学习模型时造成问题。

让我们看看是否可以在不损害模型性能的情况下降低数据集的维度。

但首先,让我们回忆一下 PCA 是如何工作的。通常需要完成以下步骤:

  1. 首先,对数据集执行均值归一化——转换数据集,使每一列的平均值为零。
  2. 然后,计算协方差或相关矩阵。
  3. 之后,进行协方差/相关矩阵的特征值分解(【EVD】)或奇异值分解 ( SVD )。
  4. 结果是一组主成分,每个主成分解释了部分方差。主成分通常是有序的,第一个成分解释了大部分的差异,最后一个成分解释了很少的差异。
  5. 在最后一步中,我们丢弃那些没有方差的成分,只保留方差大的第一主成分。为了选择要保留的成分数量,我们通常使用解释方差与总方差的累积比率。
  6. 我们使用这些组件通过在由这些组件形成的基础上执行原始数据的投影来压缩原始数据集。
  7. 完成这些步骤后,我们得到了一个包含较少要素的数据集,但原始数据集的大部分信息都保留了下来。

在 Java 中有很多方法可以实现 PCA,但是我们可以使用其中一个库,比如 Smile,它提供了现成的实现。在 Smile 中,PCA 已经执行了均值归一化,然后计算协方差矩阵,并自动决定是使用 EVD 还是奇异值分解。我们只需要给它一个数据矩阵,剩下的事情它会做。

通常,对协方差矩阵执行 PCA,但有时,当一些原始要素处于不同的比例时,解释方差的比率可能会产生误导。

例如,如果我们拥有的一个要素是以千米为单位的距离,另一个是以毫秒为单位的时间,那么第二个要素将具有更大的方差,因为第二个要素中的数字要大得多。因此,该特征将在最终组件中占主导地位。

为了克服这个问题,我们可以使用相关矩阵代替协方差矩阵,并且由于相关系数是无单位的,因此 PCA 结果不会受到不同尺度的影响。或者,我们可以对数据集中的要素进行标准化,实际上,计算协方差与计算相关性是一样的。

因此,首先我们将使用之前编写的StandardizationPreprocessor来标准化数据:

StandardizationPreprocessor preprocessor = StandardizationPreprocessor.train(dataset); 
dataset = preprocessor.transform(dataset)

然后,我们可以对转换后的数据集运行 PCA,并查看累积方差:

PCA pca = new PCA(dataset.getX(), false); 
double[] variance = pca.getCumulativeVarianceProportion(); 
System.out.println(Arrays.toString(variance));

如果我们获取输出并绘制前一百个组件,我们将看到下图:

我们可以看到,主成分解释了大约 67%的方差,累积解释率在小于 40 成分时很快达到 95%,在 61 成分时达到 99%,在 80 成分时几乎达到 100%。这意味着,如果我们只取第一个 80 分量,就足以捕获数据集中几乎所有的方差。这意味着我们应该能够安全地将 1000 多个维度的数据集压缩到 80 个维度。

我们来测试一下。首先,让我们试着不用 PCA 做 OLS。我们将采用上一章的代码:

Dataset train = trainTestSplit.getTrain(); 

List<Split> folds = train.shuffleKFold(3); 
DescriptiveStatistics ols = crossValidate(folds, data -> { 
    return new OLS(data.getX(), data.getY()); 
});

这将打印以下输出:

ols: rmse=15.8679 &pm; 3.4587

现在,让我们尝试将主成分的数量限制在 95%、99%和 99.9%的水平,并看看错误会发生什么:

double[] ratios = { 0.95, 0.99, 0.999 }; 

for (double ratio : ratios) { 
    pca = pca.setProjection(ratio); 
    double[][] projectedX = pca.project(train.getX()); 
    Dataset projected = new Dataset(projectedX, train.getY()); 

    folds = projected.shuffleKFold(3); 
    ols = crossValidate(folds, data -> { 
        return new OLS(data.getX(), data.getY()); 
    }); 

    double mean = ols.getMean(); 
    double std = ols.getStandardDeviation() 
    System.out.printf("ols (%.3f): rmse=%.4f &pm; %.4f%n", ratio, mean, std); 
}

这会产生以下输出:

ols (0.950): rmse=18.3331 &pm; 3.6308 
ols (0.990): rmse=16.0702 &pm; 3.5046 
ols (0.999): rmse=15.8656 &pm; 3.4625

正如我们所见,保持 99.9%的 PCA 方差给出了与原始数据集上的 OLS 回归拟合相同的性能。对于该数据集,99.9%的方差仅由 84 个主成分解释,而原始数据集中有 1070 个特征。因此,我们设法在不损失任何性能的情况下,通过仅保留原始数据大小的 7.8%来减少数据的维度。

然而有时候,从性能上来说,Smile 和其他类似包的 PCA 实现并不是最好的。接下来,我们将看到为什么以及如何处理它。

截断奇异值分解

前面的代码(在本例中,使用 Smile)通过完整的 SVD 或 EVD 执行完整的 PCA。这里, full 是指它计算所有的特征值和特征向量,可能计算量很大,特别是当我们只需要前 7.8%的主成分时。然而,我们不必总是计算完整的 PCA,而是可以使用截断的 SVD。截断 SVD 只计算指定数量的主分量,通常比完整版本快得多。

Smile 还提供了截断 SVD 的实现。但是在使用之前,我们先快速修改一下 SVD。

矩阵 X 的 SVD 计算 X 的行和列的基底,使得:

***XV = US ***

这里,该等式解释如下:

  • V 的列形成了 X 的行的基础
  • U 的列形成了 X 的行的基础
  • S 是奇异值为 X 的对角矩阵

通常,SVD 是这样写的:

于是,SVD 将矩阵 X 分解成三个矩阵 US、V

当 SVD 截断到维数 K 时,矩阵 UV 只有 K 列,我们只计算 K 奇异值。如果我们随后将原始矩阵 X 乘以截断的 V ,或者将 S 乘以 U ,我们将获得 X 的行到这个新的 SVD 基的缩减投影。

这将把原始矩阵带到新的约简空间,我们可以使用结果作为特征而不是原始的特征。

现在,我们准备应用它。在微笑中,它看起来像这样:

double[][] X = ... // X is mean-centered 
Matrix matrix = new Matrix(X); 
SingularValueDecomposition svd = SingularValueDecomposition.decompose(matrix, 100);

这里,Matrix是一个来自 Smile 的类,用于存储密集矩阵。矩阵 US、V 作为二维双精度数组返回到SingularValueDecomposition对象内, UV 作为一维双精度数组返回到 S

现在,我们需要得到数据矩阵 X 的简化表示。正如我们前面讨论的,有两种方法可以做到这一点:

  • 通过计算
  • 通过计算

首先,我们来看看计算

在 Smile 中,SingularValueDecompositiondecompose方法将返回为一个双精度一维数组,因此我们需要将其转换为矩阵形式。我们可以利用 S 是对角线的这一事实,用它来加速乘法运算。

让我们使用公共数学图书馆。对角矩阵有一个特殊的实现,所以我们将使用它,通常的数组支持矩阵用于 U

DiagonalMatrix S = new DiagonalMatrix(svd.getSingularValues()); 
Array2DRowRealMatrix U = new Array2DRowRealMatrix(svd.getU(), false);

现在我们可以将这两个矩阵相乘:

RealMatrix result = S.multiply(U.transpose()).transpose(); 
double[][] data = result.getData();

注意,我们不是将 U 乘以 S ,而是反方向进行,然后转置:这利用了 S 是对角线的优势,使得矩阵乘法快了很多。最后,我们提取要在 Smile 中使用的 doubles 数组。

如果我们把这个代码用于预测性能的问题,用时不到 4 秒,这还包括矩阵乘法部分。相对于完整的 PCA 版本,这是一个很大的速度提高,在我们的笔记本电脑上,需要 1 分多钟。

另一种计算投影的方法是计算。让我们再一次使用公地数学:

Array2DRowRealMatrix X = new Array2DRowRealMatrix(dataX, false); 
Array2DRowRealMatrix V = new Array2DRowRealMatrix(svd.getV(), false); 
double[][] data = X.multiply(V).getData();

这比计算花费的时间稍多,因为两个矩阵都不是对角矩阵。然而,速度上的差异只是微不足道的:对于性能预测问题,以这种方式计算 SVD 和降低维数花费的时间不到 5 秒。

当你使用 SVD 对训练数据进行降维时,这两种方法没有区别。然而,我们不能将方法应用于新的未知数据,因为 US 都是为矩阵 X、产生的,我们为其训练 SVD。相反,我们使用方法。注意,在这种情况下, X 将是包含测试数据的新矩阵,而不是我们用于训练 SVD 的同一个 X

在代码中,它看起来像这样:

double[] trainX = ...;
double[] testX = ...;

Matrix matrix = new Matrix(trainX);
SingularValueDecomposition svd = SingularValueDecomposition.decompose(matrix, 100);

double[][] trainProjected = mmult(trainX, svd.getV());
double[][] testProjected = mmult(testX, svd.getV());

这里,mmult是将矩阵 X 乘以矩阵 V 的方法。

还有另一个实现细节:在 Smile 的 PCA 实现中,我们使用解释方差的比率来确定所需的维数。回想一下,我们通过在PCA对象上调用getCumulativeVarianceProportion来实现这一点,并且通常保持足够高的组件数量,以获得至少 95%或 99%的方差。

但是,由于我们直接使用 SVD,所以我们现在不知道这个比值。这意味着为了能够选择正确的维度,我们需要自己实现它。幸运的是,做起来并不复杂;首先,我们需要计算数据集的总体方差,然后计算所有主成分的方差。后者可以从奇异值中获得(矩阵 S )。奇异值对应于标准差,所以要得到方差,我们只需要对它们求平方。最后,求比值很简单,我们只需要一个除以另一个。

让我们看看它在代码中的样子。首先,我们使用 Commons Math 来计算总方差:

Array2DRowRealMatrix matrix = new Array2DRowRealMatrix(dataset.getX(), false); 
int ncols = matrix.getColumnDimension(); 

double totalVariance = 0.0; 
for (int col = 0; col < ncols; col++) { 
    double[] column = matrix.getColumn(col); 
    DescriptiveStatistics stats = new DescriptiveStatistics(column); 
    totalVariance = totalVariance + stats.getVariance(); 
}

现在,我们可以根据奇异值计算累积比率:

int nrows = X.length; 
double[] singularValues = svd.getSingularValues(); 
double[] cumulatedRatio = new double[singularValues.length]; 

double acc = 0.0; 
for (int i = 0; i < singularValues.length; i++) { 
    double s = singularValues[i]; 
    double ratio = (s * s / nrows) / totalVariance; 
    acc = acc + ratio; 
    cumulatedRatio[i] = acc; 
}

运行这段代码后,cumulatedRatio数组将包含所需的比率。结果应该与来自pca.getCumulativeVarianceProportion()的 Smile 的 PCA 实现完全相同。

分类和稀疏数据的截断奇异值分解

对于包含许多分类变量的数据集,降维非常有用,尤其是当这些变量中的每一个都有许多可能的值时。

当我们有非常高维的稀疏矩阵时,计算全奇异值分解通常是非常昂贵的。因此,截断 SVD 特别适合这种情况,在这里我们将看到如何使用它。在下一章的后面,我们会看到这对于文本数据也是非常有用的,我们将在下一章讨论这种情况。现在,我们将看看如何将它用于分类变量。

为此,我们将使用来自 Kaggle 的客户投诉数据集。你可以从这里下载:https://www.kaggle.com/cfpb/us-consumer-finance-complaints

该数据集包含银行和其他金融机构的客户提交的投诉,还包含有关这些投诉的其他信息,如下所示:

  • 投诉的产品可以是按揭贷款助学贷款讨债、等。有 11 种产品。
  • 关于产品的举报问题,如不正确信息虚假陈述、等。共有 95 种问题。
  • 被投诉的公司,3000 多家。
  • submitted_via是投诉的发送方式,6 个可能选项,例如,网络和 电子邮件。
  • 州和邮政编码分别是 63 和 27,000 个可能值。
  • consumer_complaint_narrative是问题的自由文本描述。

我们看到在这个数据集中有大量的分类变量。正如我们在前面章节中已经讨论过的,编码分类变量的典型方式是一次热编码(也称为虚拟编码)。这个想法是,对于一个变量的每个可能的值,我们创建一个单独的特性,如果一个项目有这个特定的值,就把值1放在那里。所有其他可能值的列都有0

实现这一点的最简单的方法是使用特性散列,这有时被称为散列技巧。

按照以下步骤可以很容易地做到这一点:

  • 我们预先指定稀疏矩阵的维数,为此我们取一个相当大的数
  • 然后,对于每个值,我们计算这个值的散列
  • 使用散列,我们计算稀疏矩阵中的列数,并将该列的值设置为1

所以,让我们试着去实现它。首先,我们加载数据集并只保留分类变量:

DataFrame<Object> categorical = dataframe.retain("product", "sub_product", "issue", 
        "sub_issue", "company_public_response", "company", 
        "state", "zipcode", "consumer_consent_provided", 
        "submitted_via");

现在,让我们实现特性散列来编码它们:

int dim = 50_000; 
SparseDataset result = new SparseDataset(dim); 

int ncolOriginal = categorical.size(); 
ListIterator<List<Object>> rows = categorical.iterrows(); 

while (rows.hasNext()) { 
    int rowIdx = rows.nextIndex(); 
    List<Object> row = rows.next(); 
    for (int colIdx = 0; colIdx < ncolOriginal; colIdx++) { 
        Object val = row.get(colIdx); 
        String stringValue = colIdx + "_" + Objects.toString(val); 
        int targetColIdx = Math.abs(stringValue.hashCode()) % dim; 

        result.set(rowIdx, targetColIdx, 1.0); 
    } 
}

这里发生的事情是,我们首先创建一个SparseDataset——一个来自 Smile 的类,用于保存基于行的稀疏矩阵。接下来,我们说矩阵应该具有由变量dim指定的维度。dim的值应该足够高,这样碰撞的几率就不会很高。然而,通常情况下,如果有冲突,也没什么大不了的。

如果您将 dim 的值设置为一个非常大的数字,那么当我们稍后分解矩阵时,可能会出现一些性能问题。

特征散列是一种非常简单的方法,并且在实践中经常非常有效。还有另一种方法,实现起来更复杂,但它确保没有哈希冲突。为此,我们构建一个从所有可能的值到列索引的映射,然后构建稀疏矩阵。

构建地图将如下所示:

Map<String, Integer> valueToIndex = new HashMap<>(); 
List<Object> columns = new ArrayList<>(categorical.columns()); 

int ncol = 0; 

for (Object name : columns) { 
    List<Object> column = categorical.col(name); 
    Set<Object> distinct = new HashSet<>(column); 
    for (Object val : distinct) { 
        String stringValue = Objects.toString(name) + "_" + Objects.toString(val); 
        valueToIndex.put(stringValue, ncol); 
        ncol++; 
    } 
}

ncol变量包含列数,这是我们未来稀疏矩阵的维数。现在我们可以构建实际的矩阵。这与我们之前的内容非常相似,但是我们现在在映射中查找索引,而不是散列:

SparseDataset result = new SparseDataset(ncol); 

ListIterator<List<Object>> rows = categorical.iterrows(); 
while (rows.hasNext()) { 
    int rowIdx = rows.nextIndex(); 
    List<Object> row = rows.next(); 
    for (int colIdx = 0; colIdx < columns.size(); colIdx++) { 
        Object name = columns.get(colIdx); 
        Object val = row.get(colIdx); 
        String stringValue = Objects.toString(name) + "_" + Objects.toString(val); 
        int targetColIdx = valueToIndex.get(stringValue); 

        result.set(rowIdx, targetColIdx, 1.0); 
    } 
}

这样做之后,我们有了一个SparseDataset对象,它包含基于行格式的数据。接下来,我们需要能够将它放到 SVD 求解器中,为此我们需要将它转换成不同的基于列的格式。这是在SparseMatrix类中实现的。幸运的是,SparseDataset类中有一个特殊的方法来完成转换,所以我们使用它:

SparseMatrix matrix = dataset.toSparseMatrix(); 
SingularValueDecomposition svd = SingularValueDecomposition.decompose(matrix, 100);

分解相当快;计算特征散列矩阵的 SVD 花费了大约 28 秒,而通常的一次热编码花费了大约 24 秒。记住这个数据集中有 50 万行,所以速度相当不错。据我们所知,SVD 的其他 Java 实现不能提供同样的性能。

现在,当计算 SVD 时,我们需要将原始矩阵投影到缩减的空间,就像我们之前在密集矩阵的情况下所做的那样。

由于 US 都是密集的,所以投影可以完全像以前一样进行。但是 X 是稀疏的,我们需要找到一种高效地将稀疏的 X 和密集的 X 相乘的方法。

不幸的是,Smile 和 Commons Math 都没有合适的实现。因此,我们需要使用另一个库,这个问题可以用Matrix Java Toolkit(MTJ)来解决。这个库基于 netlib-java,它是 BLAS、LAPACK 和 ARPACK 等低级高性能库的包装器。你可以在它的 GitHub 页面上了解更多:【https://github.com/fommil/matrix-toolkits-java

由于我们使用 Maven,它将负责下载二进制依赖项并将它们链接到项目。我们需要做的只是指定以下依赖关系:

<dependency> 
  <groupId>com.googlecode.matrix-toolkits-java</groupId> 
  <artifactId>mtj</artifactId> 
  <version>1.0.2</version> 
</dependency>

我们需要用两个矩阵相乘, XV,,条件是 X 稀疏而 V 稠密。由于 X 位于乘法运算符的左侧,存储 X 的值的最有效方式是基于行的稀疏矩阵表示。对于 V,最有效的表示是基于列的密集矩阵。

但是在我们这样做之前,我们首先需要将 Smile 的SparseDataset转换成 MTJ 的稀疏矩阵。为此,我们使用了一个特殊的构建器:FlexCompRowMatrix类,它适合于用值填充矩阵,但不太适合乘法。一旦我们构建了矩阵,我们就把它转换成CompRowMatrix,它有一个更有效的内部表示,并且更适合于乘法目的。

我们是这样做的:

SparseDataset dataset = ... // 
int ncols = dataset.ncols(); 
int nrows = dataset.size(); 
FlexCompRowMatrix builder = new FlexCompRowMatrix(nrows, ncols); 

SparseArray[] array = dataset.toArray(new SparseArray[0]); 
for (int rowIdx = 0; rowIdx < array.length; rowIdx++) { 
    Iterator<Entry> row = array[rowIdx].iterator(); 
    while (row.hasNext()) { 
        Entry entry = row.next(); 
        builder.set(rowIdx, entry.i, entry.x); 
    } 
} 

CompRowMatrix X = new CompRowMatrix(builder);

第二步是创建一个密集的矩阵。这一步更简单:

DenseMatrix V = new DenseMatrix(svd.getV());

在内部,MTJ 按列存储密集矩阵,这对于我们的目的来说是理想的。

接下来,我们需要创建一个矩阵对象,它将包含结果,然后我们将 X 乘以 V :

DenseMatrix XV = new DenseMatrix(X.numRows(), V.numColumns()); 
X.mult(V, XV);

最后,我们需要从结果矩阵中提取双数组数据。出于性能考虑,MTJ 将数据存储为一维双数组,因此我们需要将其转换为传统的表示形式。我们这样做:

double[] data = XV.getData(); 
int nrows = XV.numRows(); 
int ncols = XV.numColumns(); 
double[][] result = new double[nrows][ncols]; 

for (int col = 0; col < ncols; col++) { 
    for (int row = 0; row < nrows; row++) { 
        result[row][col] = data[row + col * nrows]; 
    } 
}

最后,我们得到了结果数组,它捕获了原始数据集的大部分可变性,并且我们可以将其用于需要小的密集矩阵的情况。

这种转换对于本章的第二个主题:集群特别有用。通常,我们使用距离来聚类数据点,但当涉及到高维空间时,距离不再有意义,这种现象被称为维度的曲线。然而,在缩减的 SVD 空间中,距离仍然有意义,并且当我们应用聚类分析时,结果通常更好。

这对于处理自然语言文本也是一种非常有用的方法,因为通常文本被表示为非常高维和非常稀疏的矩阵。我们将在第六章、处理文本-自然语言处理和信息检索中回到这个话题。

注意,与通常的 PCA 情况不同,我们在这里不执行均值居中。这有几个原因:

  • 如果我们这样做,矩阵将变得密集,并将占用太多的内存,因此不可能在合理的时间内处理它
  • 在稀疏矩阵中,平均值已经非常接近于零,因此没有必要执行平均值归一化

接下来,我们来看一种不同的降维技术,这种技术非常简单,不需要学习,而且速度非常快。

随机投影

主成分分析试图在数据中找到某种结构,并利用它来降低维数;它找到了这样一个基础,在这个基础上,原始方差的大部分被保留下来。但是,有一种替代方法,而不是试图学习基础,只是随机生成它,然后将原始数据投影到它上面。

令人惊讶的是,这个简单的想法在实践中非常有效。原因是这种变换保持了距离。这意味着,如果我们在原始空间中有两个彼此靠近的物体,那么,当我们应用投影时,它们仍然保持靠近。同样地,如果物体彼此远离,那么它们将在新的缩减空间中保持远离。

Smile 已经实现了随机投影,它接受输入维度和期望的输出维度:

double[][] X = ... // data 
int inputDimension = X[0].length; 
int outputDimension = 100; 
smile.math.Math.setSeed(1); 
RandomProjection rp = new RandomProjection(inputDimension, outputDimension);

注意,我们为随机数生成器显式设置了种子;由于随机投影的基础是随机生成的,我们希望确保可重复性。

只有在版本 1.2.1 中才可以设置种子,在撰写本文时,Maven Central 上还没有这个功能。

它通过以下方式在 Smile 中实现:

  • 首先,从高斯分布中抽取一组随机向量
  • 然后,通过 Gram-Schmidt 算法使向量正交,也就是说,首先使它们正交,然后将长度归一化为 1
  • 投影是在这个标准正交基上进行的

让我们用它来进行性能预测,然后拟合通常的 OLS:

double[][] X = dataset.getX(); 
int inputDimension = X[0].length; 
int outputDimension = 100; 
smile.math.Math.setSeed(1); 
RandomProjection rp = new RandomProjection(inputDimension, outputDimension); 

double[][] projected = rp.project(X); 
dataset = new Dataset(projected, dataset.getY()); 

Split trainTestSplit = dataset.shuffleSplit(0.3); 
Dataset train = trainTestSplit.getTrain(); 

List<Split> folds = train.shuffleKFold(3); 
DescriptiveStatistics ols = crossValidate(folds, data -> { 
    return new OLS(data.getX(), data.getY()); 
}); 

System.out.printf("ols: rmse=%.4f &pm; %.4f%n", ols.getMean(), ols.getStandardDeviation());

它非常快(在我们的笔记本电脑上不到一秒钟),并且该代码产生以下结果:

ols: rmse=15.8455 &pm; 3.3843

结果与平原 OLS 或 OLS 的 PCA 结果非常相似,方差为 99.9%。

然而,来自 Smile 的实现只适用于密集矩阵,在撰写本文时还不支持稀疏矩阵。因为这个方法非常简单,所以我们自己实现它并不困难。让我们实现一个生成随机基的简化版本。

为了生成基,我们从均值为零且标准差等于1 / new_dimensionality的高斯分布中采样,其中new_dimensionality是新的缩减空间的期望维度。

让我们用公地数学来计算:

NormalDistribution normal = new NormalDistribution(0.0, 1.0 / outputDimension); 
normal.reseedRandomGenerator(seed); 
double[][] result = new double[inputDimension][]; 

for (int i = 0; i < inputDimension; i++) { 
    result[i] = normal.sample(outputDimension); 
}

这里,我们有以下参数:

  • inputDimension:这是我们要投影的矩阵的维数,也就是这个矩阵的列数
  • outputDimension:这是期望的投影维度
  • seed:这是用于再现性的随机数发生器种子

首先,让我们检查实现的合理性,并将其应用于相同的性能问题。尽管它很密集,但对于测试目的来说已经足够了:

double[][] X = dataset.getX(); 
int inputDimension = X[0].length; 
int outputDimension = 100; 
int seed = 1; 
double[][] basis = Projections.randomProjection(inputDimension, outputDimension, seed); 
double[][] projected = Projections.project(X, basis); 
dataset = new Dataset(projected, dataset.getY()); 

Split trainTestSplit = dataset.shuffleSplit(0.3); 
Dataset train = trainTestSplit.getTrain(); 

List<Split> folds = train.shuffleKFold(3); 
DescriptiveStatistics ols = crossValidate(folds, data -> { 
    return new OLS(data.getX(), data.getY()); 
}); 

System.out.printf("ols: rmse=%.4f &pm; %.4f%n", ols.getMean(), ols.getStandardDeviation());

这里我们有两种方法:

  • 这产生了我们之前实现的随机基础。
  • Projections.project:将矩阵 X 投影到基底上,通过将矩阵 X 乘以基底的矩阵来实现。

运行代码后,我们会看到以下输出:

ols: rmse=15.8771 &pm; 3.4332

这表明我们的实现已经通过了健全性检查,结果是有意义的,并且方法被正确地实现了。

现在我们需要改变投影方法,使其可以应用于稀疏矩阵。我们已经完成了,但是让我们再来看一下大纲:

  • 将稀疏矩阵放入RompRowMatrix压缩行存储 ( CRS 矩阵中
  • 将基础放入DenseMatrix
  • 将矩阵相乘,并将结果写入DenseMatrix
  • 将来自DenseMatrix的底层数据展开成一个二维双数组

对于投诉数据集中的分类示例,如下所示:

DataFrame<Object> categorical = ... // data 
SparseDataset sparse = OHE.hashingEncoding(categorical, 50_000); 
double[][] basis = Projections.randomProjection(50_000, 100, 0); 
double[][] proj = Projections.project(sparse, basis);

这里,我们创建了一些助手方法:

  • OHE.hashingEncoding:这将对来自分类数据DataFrame的分类数据进行一次热编码
  • Projections.randomProjection:生成一个随机的基础
  • Projections.project:这在这个生成的基础上投射我们的稀疏矩阵

我们之前已经为这些方法编写了代码,这里为了方便起见,我们将它们放在了 helper 方法中。当然,像往常一样,您可以在为本章提供的代码包中看到完整的代码。到目前为止,我们只讨论了无监督学习降维的一组技术。还有聚类分析,我们将在接下来讨论。有趣的是,聚类也可以用于降低数据集的维度,很快我们就会看到如何实现。

聚类分析

聚类或聚类分析是另一种无监督学习算法。聚类的目标是将数据组织成簇,使得相似的项目出现在同一个簇中,而不相似的项目出现在不同的簇中。

执行聚类分析有许多不同的算法系列,它们在元素分组方式上有所不同。

最常见的系列如下:

  • Hierarchical :这将数据集组织成一个层次结构,例如凝聚和分裂聚类。结果通常是一个树状图。
  • 分割:这将数据集分割成 K 个不相交的类——K通常是预先指定的——例如, K 意味着。
  • 基于密度:基于密度区域组织项目;如果在一些密集的区域中有许多项目,它们形成一个簇,例如 DBSCAN。
  • 基于图形的:这将项目之间的关系表示为图形,并应用图论中的分组算法,例如,连接组件和最小生成树。

分层方法

分层方法被认为是最简单的聚类算法;它们很容易理解和解释。聚类方法有两个家族,它们属于等级家族:

  • 分裂聚类算法
  • 凝聚聚类算法

在分裂法中,我们将所有数据项放入一个簇中,在每一步中,我们选取一个簇,然后将其分成两半,直到每个元素都是自己的簇。因此,这种方法有时被称为自顶向下聚类

凝聚聚类方法是相反的;开始的时候,每个数据点都属于自己的聚类,然后在每一步,我们选择两个最接近的聚类进行合并,直到只剩下一个大的聚类。这也叫做自下而上的方法。

尽管有两种类型的层次聚类算法,但当人们说层次聚类时,他们通常指的是聚集聚类,这些算法更常见。所以让我们仔细看看它们。

在凝聚聚类中,在每一步,我们合并两个最接近的聚类,但是根据我们如何定义最接近,结果可能会有很大的不同。

合并两个集群的过程通常被称为链接链接描述了两个集群之间的距离是如何计算的。

链接有多种类型,最常见的如下:

  • 单链:两个簇之间的距离是最近的两个元素之间的距离。
  • 完全连锁:两个集群之间的距离是两个最远元素之间的距离。
  • 平均连锁(有时也称为 UPGMA 连锁):聚类之间的距离是质心之间的距离,其中质心是该聚类所有项目的平均值。

这些方法通常适用于较小规模的数据集,并且非常适用。但是对于较大的数据集,它们通常不太有用,并且要花很多时间才能完成。尽管如此,它甚至可以用于更大的数据集,但我们需要一些可管理规模的样本。

让我们看看例子。我们可以使用之前使用的投诉数据集,通过 One-Hot-Encoding 对分类变量进行编码。如果你还记得的话,我们然后通过使用 SVD 将带有分类变量的稀疏矩阵转化为更小维度的密集矩阵。数据集非常大,很难处理,所以我们先从其中抽取 10,000 条记录作为样本:

double[] data = ... // our data in the reduced SVD space 
int size = 10000; // sample size 
long seed = 0; // seed number for reproducibility 
Random rnd = new Random(seed); 

int[] idx = rnd.ints(0, data.length).distinct().limit(size).toArray(); 
double[][] sample = new double[size][]; 
for (int i = 0; i < size; i++) { 
    sample[i] = data[idx[i]]; 
} 

data = sample;

我们在这里做的是从随机数生成器中获取一个不同的整数流,然后将其限制为 10,000。然后我们用这些整数作为样本的索引。
在准备好数据并提取样本后,我们可以尝试对该数据集进行凝聚聚类分析。我们之前讨论的大多数机器学习库都有聚类算法的实现,所以我们可以使用它们中的任何一个。因为我们已经广泛使用了 Smile,所以在本章中,我们还将使用 Smile 的实现。
当我们使用它时,首先需要指定的是联动。为了指定链接并创建一个Linkage对象,我们首先需要计算一个邻近矩阵——一个包含数据集中每对对象之间距离的矩阵。我们可以使用任何距离度量,但我们将采用最常用的一种,欧几里德距离。回想一下,欧几里得距离是两个向量之差的范数。为了有效地计算它,我们可以使用下面的分解:

我们把距离的平方表示成内积,然后分解。接下来,我们认识到这是各个向量的范数之和减去它们的乘积:

这是我们可以用来有效计算邻近矩阵的公式,邻近矩阵是每对项目之间的距离矩阵。在这个公式中,我们有对的内积,它可以通过使用矩阵乘法来有效地计算。让我们看看如何将这个公式翻译成代码。前两个部分是单独的标准,让我们来计算它们:

int nrow = data.length; 

double[] squared = new double[nrow]; 
for (int i = 0; i < nrow; i++) { 
    double[] row = data[i]; 

    double res = 0.0; 
    for (int j = 0; j < row.length; j++) { 
        res = res + row[j] * row[j]; 
    } 

    squared[i] = res; 
}

当涉及到内积时,它只是数据矩阵与其转置的矩阵乘法。我们可以用 Java 中的任何数学软件包来计算它。例如,使用 Commons Math:

Array2DRowRealMatrix m = new Array2DRowRealMatrix(data, false); 
double[][] product = m.multiply(m.transpose()).getData();

最后,我们将这些组件放在一起计算邻近矩阵:

double[][] dist = new double[nrow][nrow]; 

for (int i = 0; i < nrow; i++) { 
    for (int j = i + 1; j < nrow; j++) { 
        double d = squared[i] - 2 * product[i][j] + squared[j]; 
        dist[i][j] = dist[j][i] = d; 
    } 
}

因为距离矩阵是对称的,所以我们可以节省时间,只在一半的索引上循环。i == j的时候不用盖案子。

我们还可以使用其他的距离度量:这对于Linkage类来说无关紧要。比如不用欧氏距离,我们可以取另一个,比如余弦距离。

余弦距离是两个向量之间相异度的另一种度量,它基于余弦相似度。余弦相似度在几何上对应于两个向量之间的角度,它是使用内积计算的:

这里的内积除以每个向量的范数。但是如果向量已经归一化了,也就是有范数,等于 1,那么余弦就是内积。如果余弦相似度等于 1,则向量完全相同。

余弦距离与余弦相似度相反:当向量相同时,它应该等于零,因此我们可以通过从 1 中减去它来计算它:

因为这里有内积,所以使用矩阵乘法很容易计算这个距离。

我们来实施吧。首先,我们对数据矩阵的每个行向量进行单位归一化:

int nrow = data.length; 
double[][] normalized = new double[nrow][]; 

for (int i = 0; i < nrow; i++) { 
    double[] row = data[i].clone(); 
    normalized[i] = row; 
    double norm = new ArrayRealVector(row, false).getNorm(); 
    for (int j = 0; j < row.length; j++) { 
        row[j] = row[j] / norm; 
    } 
}

现在,我们可以将归一化矩阵相乘,以获得余弦相似度:

Array2DRowRealMatrix m = new Array2DRowRealMatrix(normalized, false); 
double[][] cosine = m.multiply(m.transpose()).getData();

最后,我们通过从 1:

for (int i = 0; i < nrow; i++) { 
    double[] row = cosine[i]; 
    for (int j = 0; j < row.length; j++) { 
        row[j] = 1 - row[j]; 
    } 
}

现在,可以将计算出的矩阵传递给Linkage实例。正如我们提到的,任何距离度量都可以与层次聚类一起使用,这是一个很好的属性,是其他聚类方法通常所缺乏的。

现在让我们使用计算出的距离矩阵进行聚类:

double[][] proximity = calcualateSquaredEuclidean(data); 
Linkage linkage = new UPGMALinkage(proximity); 
HierarchicalClustering hc = new HierarchicalClustering(linkage);

在凝聚聚类中,我们将两个最相似的聚类合并,然后重复这个过程,直到只剩下一个聚类。这个合并过程可以用树状图来形象化。为了用 Java 绘制它,我们可以使用 Smile 自带的绘图库。

为了说明如何做到这一点,让我们首先只对几个项目进行采样并应用聚类。然后我们可以得到类似下图的东西:

在底部的 x 轴上,我们有被合并到集群中的项目。在 y 轴上,我们有聚类合并的距离。

为了创建绘图,我们使用以下代码:

Frame frame = new JFrame("Dendrogram"); 
frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); 

PlotCanvas dendrogram = Dendrogram.plot(hc.getTree(), hc.getHeight()); 
frame.add(dendrogram); 

frame.setSize(new Dimension(1000, 1000)); 
frame.setLocationRelativeTo(null); 
frame.setVisible(true);

当我们需要分析生成的集群时,这种可视化非常有用。因为我们知道合并完成的距离(在 y 轴上),我们可以知道从数据中提取多少个集群是有意义的。例如,在大约 21 之后,合并变得彼此相当遥远,这可能暗示有 5 个集群。

为了得到这些聚类,我们可以在某个距离阈值处切割树状图。如果一些元素在低于阈值的距离处被合并,则它们保持在相同的聚类内。否则,如果它们在阈值以上的距离处合并,它们将被视为单独的聚类。

对于前面的树状图,如果我们在 23 的高度切割,我们应该得到 5 个独立的聚类。我们可以这样做:

double height = 23.0; 
int[] labels = hc.partition(height);

或者,我们可以要求特定数量的集群:

int k = 5; 
int[] labels = hc.partition(k);

分层集群有几个优点:

  • 它可以与任何距离函数一起工作,它所需要的只是一个距离矩阵,所以任何函数都可以用来创建矩阵
  • 通过这种聚类,很容易得出聚类的数量

然而,也有一些缺点:

  • 它不适用于数据集中的大量项目——距离矩阵很难适应内存。
  • 它通常比其他方法慢,尤其是当使用一些链接时。

还有另一种非常流行的方法,它非常适合大型数据集,接下来我们将讨论它。

k 均值

正如我们之前提到的,凝聚聚类方法对于小数据集非常有效,但是对于大数据集却有一些问题。K -means 是另一种流行的聚类技术,它没有这个问题。

K -means 是一种聚类方法,属于聚类算法的划分家族:给定簇数 KK -Means 将数据分割成 K 个不相交的组。
使用质心将项目分组为簇。质心代表一个聚类的“中心”,对于每个项目,我们将其分配到与其最近的质心的组中。聚类的质量通过失真来衡量——每个项目与其质心之间的距离之和。

与凝聚式集群一样,Java 中有多种实现方式可以使用 K -Means,和前面一样,我们将使用来自 Smile 的实现方式。不幸的是,它不支持稀疏矩阵,只能处理密集矩阵。如果我们想将其用于稀疏数据,我们要么需要将其转换为稠密矩阵,要么用奇异值分解或随机投影来降低其维数。

让我们再次使用投诉的分类数据集,并用 SVD 将其投射到30组件:

SingularValueDecomposition svd = SingularValueDecomposition.decompose(sparse.toSparseMatrix(), 30); 
double[][] proj = Projections.project(sparse, svd.getV());

正如我们在这里看到的, K -means 在 Smile 中的实现接受四个参数:

  • 我们要聚类的矩阵
  • 我们想要找到的集群的数量
  • 要运行的迭代次数
  • 选择最佳方案之前的试验次数

K -means 优化数据集的失真,这个目标函数有很多局部最优。这意味着,根据初始配置,您可能会得到完全不同的结果,有些结果可能会比其他结果更好。这个问题可以通过多次运行 K-means 来缓解,每次都从不同的起始位置开始,然后选择具有最佳值的聚类。这就是为什么我们需要最后的参数,试验次数。

现在,让我们跑K——意思是在微笑:

int k = 10; 
int maxIter = 100; 
int runs = 3; 
KMeans km = new KMeans(proj, k, maxIter, runs);

虽然 Smile 的实现只能处理密集矩阵,但是 JSAT 的实现没有这个限制,它可以处理任何矩阵,不管是密集的还是稀疏的。

我们在 JSAT 的做法如下:

SimpleDataSet ohe = JsatOHE.oneHotEncoding(categorical); 
EuclideanDistance distance = new EuclideanDistance(); 
Random rand = new Random(1); 
SeedSelection seedSelection = SeedSelection.RANDOM; 
KMeans km = new ElkanKMeans(distance, rand, seedSelection); 

List<List<DataPoint>> clustering = km.cluster(ohe);

在这段代码中,我们使用了 One-Hot-Encoding 的另一个实现,它生成稀疏的 JSAT 数据集。它非常接近我们对 Smile 的实现。关于细节,你可以看看本章代码库中的代码。

在 JSAT,K-的意思有多种实现方式。其中一个实现是ElkanKMeans,我们之前用过。来自 JSAT 的ElkanKMeans参数与 Smile 版本大相径庭:

  • 首先,它采用距离函数,通常是欧几里德距离函数
  • 它创建 random 类的一个实例以确保可再现性
  • 它创建了为聚类选择初始种子的算法,随机是最快的,KPP(它是K-意味着++)在成本函数方面是最优的

对于稀疏矩阵,JSAT 实现太慢,所以它不适合我们的问题。对于密集矩阵,JSAT 实现产生的结果与 Smile 相当,但它也需要相当多的时间。

K-means 有一个参数 K,这是我们想要的聚类数。通常,想出一个好的 K 值是具有挑战性的,接下来我们将看看如何选择它。

在 K-Means 中选择 K

K -means 有一个缺点:我们需要指定集群的数量 K 。有时 K 可以从我们试图解决的领域问题中得知。例如,如果我们知道有 10 种类型的客户端,我们可能想要查找 10 个集群。

然而,我们往往没有这种领域知识。在这种情况下,我们可以使用一种通常被称为肘法的方法:

  • 尝试不同的 K 值,记录每个值的失真
  • 绘制每个 K 的失真
  • 试着找出拐点,图中误差停止快速下降并开始缓慢下降的部分

你可以用下面的方法来做:

PrintWriter out = new PrintWriter("distortion.txt"); 

for (int k = 3; k < 50; k++) { 
    int maxIter = 100; 
    int runs = 3; 
    KMeans km = new KMeans(proj, k, maxIter, runs); 
    out.println(k + "/t" + km.distortion()); 
} 

out.close();

然后,你可以用你喜欢的绘图库来绘制distortion.txt文件的内容,结果是这样的:

在这里,我们可以看到它最初下降很快,但在 15-20 左右,它开始缓慢下降。所以我们可以从这个区域中选取 K ,比如取 K = 17。

另一个解决方案是对少量数据进行采样,然后用层次聚类构建一个树状图。通过查看树状图,可以清楚地知道什么是最佳的聚类数 K

这两种方法都需要人的判断,很难形式化。但是还有另外一个选择——让机器学习为我们选择最好的 K 。为此,我们可以使用 X-Means,它是对 K -Means 算法的扩展。X-Means 试图使用贝叶斯信息标准 ( BIC )得分自动选择最佳 K

Smile 已经包含了 X-Means 的一个实现,名为XMeans,运行它很简单,如下所示:

int kmax = 300; 
XMeans km = new XMeans(data, kmax); 
System.out.println("selected number of clusters: " + km.getNumClusters());

这将根据 BIC 输出最佳数量的集群。JSAT 也有一个XMeans的实现,它的工作方式类似。

从来都不清楚哪种方法更好,所以您可能需要尝试每种方法,并为特定问题选择最佳方法。

除了凝聚聚类和 K -Means 之外,还有其他聚类方法,这些方法有时在实践中也是有用的。接下来,我们现在来看看其中的一个- DBSCAN。

基于密度的噪声应用空间聚类

DBSCAN 是另一种非常流行的集群技术。DBSCAN 属于基于密度的算法家族,与 K -Means 不同,它不需要事先知道聚类的数量 K

简而言之,DBSCAN 的工作方式如下:在每一步中,它都需要一个项目在其周围生成一个集群。

当我们从一个高密度区域中取出一个项目时,那么在当前项目附近有许多其他数据点,并且所有这些项目都被添加到聚类中。然后,对集群的每个新添加的元素重复该过程。然而,如果该区域不够密集,附近又没有那么多点,那么我们就不能形成一个聚类,并说这个项目是一个异常值。

因此,为了使 DBSCAN 工作,我们需要提供以下参数:

  • 计算两个项目接近程度的距离度量
  • 半径内继续增长集群的最小邻居数
  • 每个点周围的半径

正如我们所看到的,我们不需要为 DBSCAN 预先指定 K 。此外,它自然会处理异常值,这可能会给像 K -Means 这样的方法带来严重的问题。
在 Smile 中有一个 DBSCAN 的实现,下面是我们如何使用它:

double[] X = ... // data 
EuclideanDistance distance = new EuclideanDistance(); 
int minPts = 5; 
double radius = 1.0; 
DBScan<double[]> dbscan = new DBScan<>(X, distance, minPts, radius); 

System.out.println(dbscan.getNumClusters()); 
int[] assignment = dbscan.getClusterLabel();

在这段代码中,我们指定了以下三个参数:距离、一个项目周围被认为是一个集群的最小点数以及半径。

完成后,我们就可以使用getClusterLabel方法来分配聚类标签。因为 DBSCAN 处理离群值,所以它们有一个特殊的集群 ID,Integer.MAX_VALUE

凝聚聚类、 K -Means 和 DBSCAN 是最常用的聚类方法之一,当我们需要对共享某种模式的项目进行分组时,它们非常有用。然而,我们也可以使用聚类进行降维,接下来我们将看到如何进行降维。

监督学习的聚类

像降维一样,聚类也可以用于监督学习。

我们将讨论以下案例:

  • 聚类作为创建额外特征的特征工程技术
  • 聚类作为一种降维技术
  • 作为简单分类或回归方法的聚类

作为特征的聚类

聚类可以被视为特征工程的一种方法,聚类的结果可以作为一组附加特征添加到监督模型中。

使用聚类结果的一次热编码的最简单方法如下:

  • 首先,您运行一个聚类算法,结果,您将数据集分组到 K 个聚类中
  • 然后,使用集群 ID 将每个数据点表示为它所属的集群
  • 最后,您将 IDs 视为一个分类特征,并对其应用一次性编码。

代码看起来非常简单:

KMeans km = new KMeans(X, k, maxIter, runs); 
int[] labels = km.getClusterLabel(); 

SparseDataset sparse = new SparseDataset(k); 

for (int i = 0; i < labels.length; i++) { 
    sparse.set(i, labels[i], 1.0); 
}

运行后,稀疏对象将包含集群 id 的一次热编码。接下来,我们可以将它添加到现有的特征中,并在其上运行常用的监督学习技术。

聚类作为降维

聚类可以被看作是一种特殊的降维。例如,如果您将数据分组到 K 个簇中,那么您可以将其压缩到 K 个质心中。一旦我们做到了这一点,每个数据点就可以表示为到每个质心的距离矢量。如果 K 小于你数据的维度,可以看作是一种降维的方式。

让我们实现这一点。首先,让我们在一些数据上运行一个 K -means。我们可以使用之前使用的性能数据集。

我们将再次使用 Smile,我们已经知道如何运行 K -means。代码如下:

double[][] X = ...; // data 
int k = 60; 
int maxIter = 10; 
int runs = 1; 
KMeans km = new KMeans(X, k, maxIter, runs);

一旦完成,就可以提取每个簇的质心。它们按行存储在二维数组中:

double[][] centroids = km.centroids();

这将返回一个由 K 行(在我们的例子中, K = 60)组成的数据集,其列数等于数据集中的要素数。

接下来,对于每个观察值,我们可以计算它离每个质心有多远。我们已经讨论了如何通过矩阵乘法有效地实现欧几里德距离,但是之前我们需要计算同一集合中每个元素之间的成对距离。然而,现在我们需要计算数据集的每个项目和每个质心之间的距离,因此我们有两组数据点。我们将稍微修改代码,以便它可以处理这种情况。

回想一下公式:

我们需要分别计算每个向量的平方范数,然后计算所有项之间的内积。

因此,如果我们将每个集合中的所有项目都作为两个矩阵 AB 的行,那么我们可以使用这个公式通过矩阵乘法来计算两个矩阵之间的成对距离。

首先,我们计算规范和乘积:

double[] squaredA = squareRows(A); 
double[] squaredB = squareRows(B); 

Array2DRowRealMatrix mA = new Array2DRowRealMatrix(A, false); 
Array2DRowRealMatrix mB = new Array2DRowRealMatrix(B, false); 
double[][] product = mA.multiply(mB.transpose()).getData();

这里,squareRows函数计算矩阵的每个行向量的平方范数:

public static double[] squareRows(double[][] data) { 
    int nrow = data.length; 

    double[] squared = new double[nrow]; 
    for (int i = 0; i < nrow; i++) { 
        double[] row = data[i]; 

        double res = 0.0; 
        for (int j = 0; j < row.length; j++) { 
            res = res + row[j] * row[j]; 
        } 

        squared[i] = res; 
    } 

    return squared; 
}

现在,我们可以使用前面代码中的公式来计算距离:

int nrow = product.length; 
int ncol = product[0].length; 
double[][] distances = new double[nrow][ncol]; 
for (int i = 0; i < nrow; i++) { 
    for (int j = 0; j < ncol; j++) { 
        double dist = squaredA[i] - 2 * product[i][j] + squaredB[j]; 
        distances[i][j] = Math.sqrt(dist); 
    } 
}

如果我们把它包装成一个函数,例如,distance,我们可以这样使用它:

double[][] centroids = km.centroids(); 
double[][] distances = distance(X, centroids);

现在我们可以使用距离数组代替原始数据集X,例如,像这样:

OLS model = new OLS(distances, y);

请注意,它不一定必须用作降维技术。相反,我们可以用它来设计额外的功能,并将这些新功能添加到现有的功能中。

通过聚类的监督学习

非监督学习可以用作监督学习的模型,根据我们所拥有的监督问题,它可以是通过聚类的分类,或者是通过聚类回归。

这种方法相对简单。首先,将每个项目与某个集群 ID 相关联,然后:

  • 对于二元分类问题,您输出在聚类中看到正类的概率
  • 对于回归,输出整个分类的平均值

让我们来看看如何进行回归。开始时,我们照常在原始数据上运行K-意味着:

int k = 250; 
int maxIter = 10; 
int runs = 1; 

KMeans km = new KMeans(X, k, maxIter, runs);

通常选择一个相对较大的 K 是有意义的,最佳值,正如我们通常所做的,应该通过交叉验证来确定。

接下来,我们从训练数据中计算每个聚类的平均目标值。为此,我们首先按群集 ID 分组,然后计算每个组的平均值:

double[] y = ... // target variable 
int[] labels = km.getClusterLabel(); 

Multimap<Integer, Double> groups = ArrayListMultimap.create(); 
for (int i = 0; i < labels.length; i++) { 
    groups.put(labels[i], y[i]); 
} 

Map<Integer, Double> meanValue = new HashMap<>(); 
for (int i = 0; i < k; i++) { 
    double mean = groups.get(i).stream()
                        .mapToDouble(d -> d)
                        .average().getAsDouble(); 
    meanValue.put(i, mean); 
}

现在,如果我们想把这个模型应用到测试数据中,我们可以用下面的方法。首先,对于每个看不见的数据项,我们找到最接近的集群 ID,然后,使用这个 ID,我们查找平均目标值。

在代码中,它看起来像这样:

double[][] testX = ... // test data 
double[] testY = ... // test target 
int[] testLabels = Arrays.stream(testX).mapToInt(km::predict).toArray(); 

double[] testPredict = Arrays.stream(testLabels)
                             .mapToDouble(meanValue::get)
                             .toArray();

现在,testPredict数组包含了来自测试数据的每个观察的预测。

此外,如果不是回归,而是有一个二进制分类问题,并且将标签保存在双精度数组中,前面的代码将输出属于基于聚类的类的概率,而不做任何更改!而testPredict数组将包含预测的概率。

估价

无监督学习最复杂的部分是评估模型的质量。很难客观地判断一个聚类是好的还是一个结果比另一个好。

有几种方法可以解决这个问题:

  • 人工评估
  • 使用标签信息(如果有)
  • 无监督度量

人工评估

手动评估意味着手动查看结果,并使用领域专业知识来评估集群的质量以及它们是否有意义。

手动检查通常以下列方式完成:

  • 对于每个集群,我们采样相同的数据点
  • 然后,我们看着他们,看看他们是否应该在一起

在查看数据时,我们想问自己以下问题:

  • 这些物品看起来相似吗?
  • 把这些物品放在同一个组里有意义吗?

如果两个问题的答案都是肯定的,那么聚类结果是好的。此外,我们采集数据的方式也很重要。例如,在 K -means 的情况下,我们应该采样一些靠近质心的项目,以及一些远离质心的项目。然后,我们可以比较近的和远的。如果我们观察它们,仍然可以发现它们之间的一些相似之处,那么聚类就是好的。

即使我们使用其他种类的集群验证技术,这种评估也总是有意义的,并且,如果可能的话,应该总是对模型进行健全性检查。例如,如果我们将它应用于客户分离,我们总是应该手动查看两个客户在聚类中是否确实相似,否则模型结果将是无用的。

然而,很明显,这种方法非常主观,不可重复,并且不可扩展。不幸的是,有时这是唯一好的选择,并且对于许多问题,没有合适的方法来评估模型质量。然而,对于一些问题,其他更自动化的方法可以提供良好的结果,接下来我们将研究一些这样的方法。

监督评估

手动检查输出总是好的,但是可能相当麻烦。通常会有一些额外的数据,我们可以使用这些数据以更自动化的方式评估我们的聚类结果。

例如,如果我们使用聚类进行监督学习,那么我们就有了标签。例如,如果我们解决了分类问题,那么我们可以使用类别信息来测量所发现的聚类有多纯(或同质)。也就是说,我们可以看到集群中多数类与其余类的比率是多少。

如果我们拿投诉数据集来说,有一些变量我们没有用于聚类,例如:

  • 及时响应:这是一个二元变量,表示公司是否及时响应投诉。
  • 公司对消费者的回应:说明公司对投诉的回应。
  • 消费者有争议:表示消费者是否同意响应。

潜在地,我们可能对预测这些变量感兴趣,所以我们可以使用它们作为聚类质量的指示。

例如,假设我们对预测公司的反应感兴趣。所以我们执行聚类:

int maxIter = 100; 
int runs = 3; 
int k = 15; 
KMeans km = new KMeans(proj, k, maxIter, runs);

现在想看看它对预测反应有多大用处。让我们计算每个集群内的结果比率。

为此,我们首先按集群 ID 分组,然后计算比率:

int[] assignment = km.getClusterLabel(); 
List<Object> resp = data.col("company_response_to_consumer"); 
Multimap<Integer, String> respMap = ArrayListMultimap.create(); 

for (int i = 0; i < assignment.length; i++) { 
    int cluster = assignment[i]; 
    respMap.put(cluster, resp.get(i).toString()); 
}

现在我们可以打印它,按照最频繁的值对集群中的值进行排序:

List<Integer> keys = Ordering.natural().sortedCopy(map.keySet()); 

for (Integer c : keys) { 
    System.out.print(c + ": "); 

    Collection<String> values = map.get(c); 
    Multiset<String> counts = HashMultiset.create(values); 
    counts = Multisets.copyHighestCountFirst(counts); 

    int totalSize = values.size(); 
    for (Entry<String> e : counts.entrySet()) { 
        double ratio = 1.0 * e.getCount() / totalSize; 
        String element = e.getElement(); 
        System.out.printf("%s=%.3f (%d), ", element, ratio, e.getCount()); 
    } 

    System.out.println(); 
}

这是第一对集群的输出:

0: Closed with explanation=0.782 (12383), Closed with non-monetary relief=0.094 (1495)... 
1: Closed with explanation=0.743 (19705), Closed with non-monetary relief=0.251 (6664)... 
2: Closed with explanation=0.673 (18838), Closed with non-monetary relief=0.305 (8536)...

我们可以看到,聚类并不是真正的纯的:有一个主导类,并且在各个聚类中纯度或多或少是相同的。另一方面,我们看到类在集群中的分布是不同的。例如,在分类 2 中,30%的项目是以非货币救济结束的,而在分类 1 中,只有 9%的项目。

即使多数类本身可能没有用,但是如果我们将它用作一个特征,每个聚类内的分布对于分类模型可能是有用的。

这给我们带来了不同的评估方法;如果我们将聚类作为一种特征工程技术来使用,我们可以通过聚类提供多少性能增益来评估聚类的质量,并通过挑选增益最大的一个来选择最佳聚类。

这就给我们带来了下一个评估方法。如果我们在一些受监督的设置中使用聚类的结果(比方说,通过将它作为一种特征工程技术使用),那么我们可以通过查看它给出多少性能来评估聚类的质量。

例如,我们有一个模型,在没有任何聚类功能的情况下具有 85%的准确性。然后我们使用两种不同的聚类算法并从中提取特征,并将它们包含到模型中。来自第一个算法的特征将分数提高了 2%,而第二个算法给出了 3%的提高。那么,第二种算法更好。

最后,有一些特殊的度量标准,我们可以使用它们来评估对于一个提供的标签来说,聚类有多好。一个这样的度量是 Rand 指数和互信息。这些指标在 JSAT 实现,你可以在jsat.clustering.evaluation包中找到它们。

无监督评估

最后,当标签未知时,存在用于评估聚类质量的无监督评估分数。

我们已经提到过一个这样的度量:失真,这是每个项目和它最近的质心之间的距离的总和。还有其他指标,例如:

  • 聚类内最大成对距离
  • 平均成对距离
  • 成对距离的平方和

这些和其他一些指标也在 JSAT 实施,你可以在jsat.clustering.evaluation.intra包中找到它们。

摘要

在这一章中,我们讨论了无监督机器学习和两个常见的无监督学习问题,维度缩减和聚类分析。我们讨论了每种类型中最常见的算法,包括 PCA 和 K-means。我们还讨论了这些算法在 Java 中的现有实现,并自己实现了其中的一些。此外,我们还讨论了一些重要的技术,比如 SVD,这在一般情况下非常有用。

前一章和这一章已经给了我们相当多的信息。通过这些章节,我们为如何使用机器学习和数据科学算法处理文本数据打下了良好的基础,这也是我们将在下一章中讨论的内容。

六、使用文本——自然语言处理和信息检索

在前两章中,我们讨论了机器学习的基础知识:我们谈到了监督和非监督问题。

在这一章中,我们将看看如何使用这些方法来处理文本信息,我们将用我们正在运行的例子来说明我们的大部分想法:构建一个搜索引擎。这里,我们将最终使用来自 HTML 的文本信息,并将其包含到机器学习模型中。

首先,我们将从自然语言处理的基础开始,自己实现一些基本思想,然后研究 NLP 库中可用的高效实现。

本章涵盖以下主题:

  • 信息检索基础
  • 使用 Apache Lucene 进行索引和搜索
  • 自然语言处理基础
  • 文本的无监督模型——降维、聚类和单词嵌入
  • 文本的监督模型——文本分类和排序学习

本章结束时,你将学会如何为机器学习做简单的文本预处理,如何使用 Apache Lucene 进行索引,如何将单词转换成向量,以及如何对文本进行聚类和分类。

自然语言处理和信息检索

自然语言处理 ( NLP )是计算机科学和计算语言学的一部分,处理文本数据。对于计算机来说,文本是非结构化的,自然语言处理有助于找到结构并从中提取有用的信息。

信息检索 ( IR )是一门研究在大型非结构化数据集中搜索的学科。典型地,这些数据集是文本,并且 IR 系统帮助用户找到他们想要的。像 Google 或 Bing 这样的搜索引擎就是这种 IR 系统的例子:它们接受一个查询,并提供一个根据与该查询的相关性排序的文档集合。

通常,信息检索系统使用自然语言处理来理解文档的内容——因此,当用户需要时,可以检索这些文档。在这一章中,我们将复习用于信息检索的文本处理的基础知识。

向量空间模型-单词袋和 TF-IDF

对计算机来说,文本只是一串没有特定结构的字符。因此,我们称文本为非结构化数据。然而,对人类来说,文本当然有一个结构,我们用它来理解内容。IR 和 NLP 模型试图做的事情是相似的:它们找到文本中的结构,用它来提取那里的信息,并理解文本是关于什么的。

实现它的最简单的可能方式被称为单词包:我们获取一个文本,将其分割成单个单词(我们称之为记号,然后将该文本表示为一个无序的记号集合以及与每个记号相关联的一些权重。

让我们考虑一个例子。如果我们取一个文档,它由一个句子组成(我们使用 Java 进行数据科学,因为我们喜欢 Java) ,它可以表示如下:

(because, 1), (data, 1), (for, 1), (java, 2), (science, 1), (use, 1), (we, 2)

在这里,句子中的每个单词都根据该单词出现的次数进行加权。

现在,当我们能够以这种方式表示文档时,我们可以用它来比较一个文档和另一个文档。

例如,如果我们取另一个句子如 Java 是好的企业开发,我们可以表示如下:

(development, 1), (enterprise, 1), (for, 1), (good, 1), (java, 1)

我们可以看到这两个文档之间有一些交集,这可能意味着这两个文档是相似的,交集越高,文档越相似。

现在,如果我们认为单词是某个向量空间中的维度,权重是这些维度的值,那么我们可以将文档表示为向量:

如果我们采用这种矢量表示,我们可以用两个矢量之间的内积作为相似性的度量。的确,如果两个文档有很多共同的词,它们之间的内积就会很高,如果它们没有共享文档,内积就是零。

这个想法被称为向量空间模型,这是在许多信息检索系统中使用的:所有文档以及用户查询都被表示为向量。一旦查询和文档在同一个空间,我们可以把查询和文档之间的相似性看作它们之间的相关性。因此,我们根据文档与用户查询的相似性对文档进行排序。

从原始文本到矢量包含几个步骤。通常,它们如下:

  • 首先,我们对文本进行标记化,也就是说,将它转换成单个标记的集合。
  • 然后,我们去掉 is、will、to 等虚词。它们通常仅用于链接目的,没有任何重要意义。这些词被称为停用词。
  • 有时我们也会将令牌转换成某种范式。例如,我们可能希望将 cat 和 cats 映射到 cat,因为这两个不同单词背后的概念是相同的。这是通过词干化或词汇化实现的。
  • 最后,我们计算每个标记的权重,并将它们放入向量空间。

以前,我们使用出现的次数来加权术语;这被称为术语频率加权。然而,有些词比其他词更重要,术语频率并不总是能捕捉到这一点。

比如锤子可以比工具更重要,因为它更具体。逆文档频率是一种不同的加权方案,它惩罚一般的单词而支持特定的单词。在内部,它基于包含该术语的文档的数量,其思想是更具体的术语出现在比一般术语更少的文档中。

最后是词频和逆文档频的组合,缩写为 TF-IDF。顾名思义,令牌t的权重由两部分组成:TF 和 IDF:

weight(t) = tf(t) * idf(t)

下面是对前面等式中提到的术语的解释:

  • tf(t):这是令牌t在文本中出现次数的函数
  • idf(t):这是包含令牌的文档数量的函数

定义这些函数有多种方法,但最常见的是使用以下定义:

  • tf(t):这是t在文档中出现的次数
  • idf(t) = log(N / df(t)):此处df(t)为文件数,包含tN为文件总数

以前,我们建议可以使用内积来度量文档之间的相似性。这种方法有一个问题:它是无界的,这意味着它可以接受任何正值,这使得它更难解释。此外,较长的文档往往与其他所有文档具有更高的相似性,因为它们包含更多的单词。

这个问题的解决方案是对向量内部的权重进行归一化,使其范数变为 1。然后,计算内积总会得到一个介于 0 和 1 之间的有界值,较长的文档影响较小。归一化向量之间的内积通常称为余弦相似度,因为它对应于这两个向量在向量空间中形成的角度的余弦。

向量空间模型实现

现在我们已经有了足够的背景信息,可以开始编写代码了。

首先,假设我们有一个文本文件,其中每一行都是一个文档,我们希望索引这个文件的内容并能够查询它。比如我们可以从的 https://OCW . MIT . edu/ans 7870/6/6.006/s08/lecture notes/files/t8 . Shakespeare . txt中取一些文本保存到simple-text.txt

那么我们可以这样理解:

Path path = Paths.get("data/simple-text.txt");
List<List<String>> documents = Files.lines(path, StandardCharsets.UTF_8)
     .map(line -> TextUtils.tokenize(line))
     .map(line -> TextUtils.removeStopwords(line))
     .collect(Collectors.toList());

我们使用标准库中的Files类,然后使用两个函数:

  • 第一个是TextUtils.tokenize,它接受一个字符串并生成一个令牌列表
  • 第二个是TextUtils.removeStopwords,删除了 a、The 等功能词

实现标记化的一个简单而天真的方法是根据正则表达式拆分字符串:

public static List<String> tokenize(String line) {
     Pattern pattern = Pattern.compile("W+");
     String[] split = pattern.split(line.toLowerCase());
     return Arrays.stream(split)
             .map(String::trim)
             .filter(s -> s.length() > 2)
             .collect(Collectors.toList());
 }

表达式W+的意思是在所有非拉丁字符上分割字符串。当然,它将无法处理包含非拉丁字符的语言,但这是实现标记化的一种快速方法。此外,它对英语也很有效,并且可以适用于其他欧洲语言。

这里的另一件事是丢弃小于两个字符的短标记——这些标记通常是停用词,所以丢弃它们是安全的。第二个函数获取一个标记列表,并从中删除所有停用词。下面是它的实现:

Set<String> EN_STOPWORDS = ImmutableSet.of("a", "an", "and", "are", "as", "at", "be", ... 
public static List<String> removeStopwords(List<String> line) {
     return line.stream()
             .filter(token -> !EN_STOPWORDS.contains(token))
             .collect(Collectors.toList());
 }

这非常简单:我们保存一组英语停用词,然后对于每个标记,我们只需检查它是否在这个集合中。你可以从 http://www.ranks.nl/stopwords 那里得到一份很好的英语停用词清单。

向这个管道中添加令牌规范化也很容易。现在,我们将跳过它,但是我们将在本章的后面回到它。

现在我们已经标记了文本,所以下一步是在向量空间中表示标记。让我们为它创建一个特殊的类。我们称之为CountVectorizer

名称CountVectorizer的灵感来自 scikit-learn 中一个具有类似功能的类,sci kit-learn 是一个用 Python 进行机器学习的优秀包。如果你熟悉这个库,你可能会注意到我们有时会借用那里的名字(比如方法的名字fit()transform())。

因为我们不能直接创建一个向量空间,它的维度由单词索引,我们将首先把所有文本中的所有不同的标记映射到某个列号。

此外,在这一步计算文档频率是有意义的,并使用它来丢弃只出现在少数文档中的标记。通常,这样的术语是拼写错误、不存在的单词或者过于罕见而对结果没有任何影响。

在代码中,它看起来像这样:

Multiset<String> df = HashMultiset.create();
documents.forEach(list -> df.addAll(Sets.newHashSet(list)));
Multiset<String> docFrequency = Multisets.filter(df, p -> df.count(p) >= minDf);

List<String> vocabulary = Ordering.natural().sortedCopy(docFrequency.elementSet());
Map<String, Integer> tokenToIndex = new HashMap<>(vocabulary.size());

for (int i = 0; i < vocabulary.size(); i++) {
    tokenToIndex.put(vocabulary.get(i), i);
}

我们使用来自 Guava 的一个Multiset来计算文档频率,然后我们应用过滤,其中minDf是一个参数,它指定了最小的文档频率。在丢弃不常用的令牌后,我们将一个列号与每个剩余的相关联,并将其放入一个Map

现在,我们可以使用文档频率来计算 IDF:

int numDocuments = documents.size();
double numDocumentsLog = Math.log(numDocuments + 1);
double[] idfs = new double[vocabulary.size()];

for (Entry<String> e : docFrequency.entrySet()) {
    String token = e.getElement();
    double idfValue = numDocumentsLog - Math.log(e.getCount() + 1);
    idfs[tokenToIndex.get(token)] = idfValue;
}

在执行之后,idfs数组将包含我们词汇表中所有标记的 IDF 部分权重。

现在我们准备将标记化的文档放入向量空间:

int ncol = vocabulary.size();
SparseDataset tfidf = new SparseDataset(ncol);

for (int rowNo = 0; rowNo < documents.size(); rowNo++) {
    List<String> doc = documents.get(rowNo);
    Multiset<String> row = HashMultiset.create(doc);
    for (Entry<String> e : row.entrySet()) {
        String token = e.getElement();
        double tf = e.getCount();
        int colNo = tokenToIndex.get(token);
        double idf = idfs[colNo];
        tfidf.set(rowNo, colNo, tf * idf);
    }
}

tfidf.unitize();

由于结果向量非常稀疏,我们使用 Smile 中的SparseDataset来存储它们。然后,对于文档中的每个令牌,我们计算它的 TF 并乘以 IDF,以获得 TF-IDF 权重。

代码的最后一行将长度规范化应用于文档向量。这样,计算向量之间的内积将得到余弦相似性得分,这是一个介于 0 和 1 之间的有界值。

现在,让我们将代码放入一个类中,这样我们可以在以后重用它:

public class CountVectorizer {
    void fit(List<List<String>> documents);
    SparseDataset tranform(List<List<String>> documents);
}

我们定义的函数执行以下操作:

  • fit创建从令牌到列号的映射,并计算 IDF
  • 将文档集合转换成稀疏矩阵
  • 构造函数应该使用minDf,它指定了一个令牌的最小文档频率。

现在我们可以用它来矢量化我们的数据集:

List<List<String>> documents = Files.lines(path, StandardCharsets.UTF_8)
         .map(line -> TextUtils.tokenize(line))
         .map(line -> TextUtils.removeStopwords(line))
         .collect(Collectors.toList());

int minDf = 5;
CountVectorizer cv = new CountVectorizer(minDf);
cv.fit(documents);
SparseDataset docVectors = cv.transform(documents);

现在假设我们作为用户想要查询这个文档集合。为了能够做到这一点,我们需要实现以下内容:

  1. 首先,在相同的向量空间中表示一个查询:也就是说,对文档应用完全相同的过程(标记化、停用词删除等等)。
  2. 然后,计算查询和每个文档之间的相似度。
  3. 最后,使用相似性得分对文档进行排序,从最高到最低。

假设我们的查询是the probabilistic interpretation of tf-idf。然后,以类似的方式将其映射到向量空间:

List<String> query = TextUtils.tokenize("the probabilistic interpretation of tf-idf");
query = TextUtils.removeStopwords(query);
SparseDataset queryMatrix = vectorizer.transfrom(Collections.singletonList(query));
SparseArray queryVector = queryMatrix.get(0).x;

我们之前创建的方法接受文档的集合,而不是单个文档,所以首先我们将它包装到一个列表中,然后获得包含结果的矩阵的第一行。

我们现在拥有的是docVector,它是一个包含我们的文档集合的稀疏矩阵,还有queryVector,一个包含查询的稀疏向量。这样,获得相似性就很容易了:我们只需要将矩阵与向量相乘,结果将包含相似性得分。

和上一章一样,我们将利用Matrix Java Toolkit(MTJ)来解决这个问题。因为我们在做矩阵向量乘法,矩阵在左边,所以存储值的最好方式是基于行的表示。我们已经编写了一个实用方法,用于将 Smile 的SparseDataset转换为 MTJ 的CompRowMatrix

又来了:

public static CompRowMatrix asRowMatrix(SparseDataset dataset) {
    int ncols = dataset.ncols();
    int nrows = dataset.size();

    FlexCompRowMatrix X = new FlexCompRowMatrix(nrows, ncols);
    SparseArray[] array = dataset.toArray(new SparseArray[0]);

    for (int rowIdx = 0; rowIdx < array.length; rowIdx++) {
        Iterator<Entry> row = array[rowIdx].iterator();
        while (row.hasNext()) {
            Entry entry = row.next();
            X.set(rowIdx, entry.i, entry.x);
        }
    }

    return new CompRowMatrix(X);
}

现在我们还需要从 MTJ 将一个SparseArray对象转换成一个SparseVector对象。

让我们也为此创建一个方法:

public static SparseVector asSparseVector(int dim, SparseArray vector) {
    int size = vector.size();
    int[] indexes = new int[size];
    double[] values = new double[size];

    Iterator<Entry> iterator = vector.iterator();
    int idx = 0;

    while (iterator.hasNext()) {
        Entry entry = iterator.next();
        indexes[idx] = entry.i;
        values[idx] = entry.x;
        idx++;
    }

    return new SparseVector(dim, indexes, values, false);
}

注意,我们还必须将结果向量的维数传递给这个方法。这是由于SparseArray的限制,它不存储关于它的信息。

现在我们可以使用这些方法来计算相似性:

CompRowMatrix X = asRowMatrix(docVectors);
SparseVector v = asSparseVector(docVectors.ncols(), queryVector);
DenseVector result = new DenseVector(X.numRows());
X.mult(v, result);
double[] scores = result.getData();

scores 数组现在包含每个文档的查询的余弦相似性得分。该数组的索引对应于原始文档集合的索引。也就是说,为了查看查询和第 10 个文档之间的相似性,我们查看数组的第 10 个元素。因此,我们需要根据分数对数组进行排序,同时保留原始索引。

让我们首先为它创建一个类:

public class ScoredIndex implements Comparable<ScoredIndex> {
    private final int index;
    private final double score;

    // constructor and getters omitted

    @Override
    public int compareTo(ScoredIndex that) {
        return -Double.compare(this.score, that.score);
    }
 }

这个类实现了Comparable接口,所以现在我们可以把这个类的所有对象放到一个集合中,然后进行排序。最后,集合中的第一个元素得分最高。让我们这样做:

double minScore = 0.2;
List<ScoredIndex> scored = new ArrayList<>(scores.length);

for (int idx = 0; idx < scores.length; idx++) {
    double score = scores[idx];
    if (score >= minScore) {
        scored.add(new ScoredIndex(idx, score));
    }
}

Collections.sort(scored);

我们还添加了一个 0.2 的相似性阈值来对更少的元素进行排序:我们假设低于这个分数的元素是不相关的,所以我们忽略它们。

最后,我们可以迭代结果并查看最相关的文档:

for (ScoredIndex doc : scored) {
    System.out.printf("> %.4f ", doc.getScore());
    List<String> document = documents.get(doc.getIndex());
    System.out.println(String.join(" ", document));
}

这样,我们自己实现了一个简单的 IR 系统,完全从零开始。但是,实现相当幼稚。在现实中,有相当多的候选文档,因此用它们中的每一个来计算查询的余弦相似性是不可行的。有一种特殊的数据结构叫做倒排索引,可以用来解决这个问题,现在我们来看看它的一个实现:Apache Lucene。

索引和 Apache Lucene

之前,我们研究了如何实现一个简单的搜索引擎,但是它不能很好地适应文档的数量。

首先,它需要将查询与我们集合中的每一个文档进行比较,随着文档的增长,这变得非常耗时。然而,大多数文档与查询不相关,只有一小部分与查询相关。我们可以有把握地假设,如果一个文档与一个查询相关,那么它应该包含至少一个来自该查询的单词。这是倒排索引数据结构背后的思想:对于每个单词,它跟踪包含它的文档。当给定一个查询时,它可以快速找到至少包含一个术语的文档。

还有一个内存问题:在某些时候,文档将不再适合内存,我们需要能够将它们存储在磁盘上,并在需要时进行检索。

Apache Lucene 解决了这些问题:它实现了一个持久的倒排索引,在速度和存储方面都非常高效,并且经过了高度优化和时间验证。在第二章数据处理工具箱中我们收集了一些原始的 HTML 数据,所以让我们用 Lucene 为它建立一个索引。

首先,我们需要将库包含到 pom 中:

<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-core</artifactId>
  <version>6.2.1</version>
</dependency>
<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-analyzers-common</artifactId>
  <version>6.2.1</version>
</dependency>
<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-queryparser</artifactId>
  <version>6.2.1</version>
</dependency>

Lucene 是非常模块化的,可以只包含我们需要的东西。在我们的例子中,这是:

  • 包:我们在使用 Lucene 时总是需要它
  • analyzers-common模块:它包含了文本处理的公共类
  • queryparser:这是用于解析查询的模块

Lucene 提供了几种类型的索引,包括内存索引和文件系统索引。我们将使用文件系统:

File index = new File(INDEX_PATH);
FSDirectory directory = FSDirectory.open(index.toPath());

接下来,我们需要定义一个分析器:这是一个完成所有文本处理步骤的类,包括标记化、停用词移除和规范化。

StandardAnalyzer是一个基本的Analyzer,它删除了一些英语停用词,但不执行任何词干化或词汇化。它对英文文本非常有效,所以让我们用它来建立索引:

StandardAnalyzer analyzer = new StandardAnalyzer();
IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(analyzer))

现在我们准备索引文档了!

让我们来看看之前浏览过的 URL,并对它们的内容进行索引:

UrlRepository urls = new UrlRepository();
Path path = Paths.get("data/search-results.txt");
List<String> lines = 
        FileUtils.readLines(path.toFile(), StandardCharsets.UTF_8);

for (String line : lines) {
    String[] split = line.split("t");
    String url = split[3];
    Optional<String> html = urls.get(url);
    if (!html.isPresent()) {
        continue;
    }

    org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html.get());
    Element body = jsoupDoc.body();
    if (body == null) {
        continue;
    }

    Document doc = new Document();
    doc.add(new Field("url", url, URL_FIELD));
    doc.add(new Field("title", jsoupDoc.title(), URL_FIELD));
    doc.add(new Field("content", body.text(), BODY_FIELD));
    writer.addDocument(doc);
}

writer.commit();
writer.close();
directory.close();

让我们仔细看看这里的一些东西。首先,UrlRepository是一个类,存储我们在第二章数据处理工具箱中创建的一些 URL 的抓取的 HTML 内容。给定一个 URL,它返回一个Optional对象,如果存储库有它的数据,它就包含响应;否则它返回一个空的Optional

然后我们用 JSoup 解析原始 HTML 并提取标题和正文。现在我们有了文本数据,我们把它放入 Lucene Document

Lucene 中的一个Document由字段组成,每个Field对象存储一些关于文档的信息。一个Field有一些属性,比如:

  • 无论我们是否将值存储在索引中。如果我们这样做,那么以后我们可以提取内容。
  • 无论我们是否将价值指数化。如果它被索引,那么它就变得可搜索,我们可以查询它。
  • 不管是不是分析出来的。如果是,我们将分析器应用于内容,这样我们就可以查询单个令牌。否则只有精确匹配是可能的。

这些和其他属性保存在FieldType对象中。

例如,下面是我们如何指定URL_FIELD的属性:

FieldType field = new FieldType();
field.setTokenized(false);
field.setStored(true);
field.freeze();

这里我们说我们不想对它进行标记化,而是想将值存储在索引中。freeze()方法确保一旦我们指定了属性,它们就不能再被更改。

下面是我们如何指定BODY_FIELD:

FieldType field = new FieldType();
field.setStored(false);
field.setTokenized(true);
field.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
field.freeze();

在这种情况下,我们只分析它,但不存储字段的确切内容。通过这种方式,仍然可以对其进行查询,但是由于没有存储内容,该字段在索引中占用的空间较少。

它非常快速地处理我们的数据集,并在执行后在文件系统中创建一个索引,我们可以查询它。让我们开始吧。

String userQuery = "cheap used cars";

File index = new File(INDEX_PATH);
FSDirectory directory = FSDirectory.open(index.toPath());
DirectoryReader reader = DirectoryReader.open(directory);
IndexSearcher searcher = new IndexSearcher(reader);

StandardAnalyzer analyzer = new StandardAnalyzer();
AnalyzingQueryParser parser = new AnalyzingQueryParser("content", analyzer);
Query query = parser.parse(userQuery);

TopDocs result = searcher.search(query, 10);
ScoreDoc[] scoreDocs = result.scoreDocs;

for (ScoreDoc scored : scoreDocs) {
    int docId = scored.doc;
    float luceneScore = scored.score;
    Document doc = searcher.doc(docId);
    System.out.println(luceneScore + " " + doc.get("url") + " " + doc.get("title"));
}

在这段代码中,我们首先打开索引,然后指定用于处理查询的分析器。使用这个分析器,我们解析查询,并使用解析后的查询从索引中提取前 10 个匹配的文档。我们存储了 URL 和标题,所以现在我们可以在查询时检索这些信息并呈现给用户。

自然语言处理工具

自然语言处理是计算机科学和计算语言学中处理文本的一个领域。正如我们之前看到的,信息检索使用简单的 NLP 技术来索引和检索文本信息。

但是 NLP 可以做得更多。有相当多的主要 NLP 任务,如文本摘要或机器翻译,但我们不会涵盖它们,只讨论基本的任务:

  • 句子分割:给定文本,我们把它分割成句子
  • 标记化:给定一个句子,将其拆分成单独的标记
  • 引理化:给定一个令牌,我们想求出它的引理。例如,对于单词,词条是猫。
  • 词性标注(POS Tagging) :给定一系列标记,目标是确定每个标记的词性。例如,它意味着将标记动词与标记 like 相关联,或者将标记名词与标记 laptop 相关联。
  • 命名实体识别(NER) :在一系列记号中,找出与命名实体相对应的记号,例如城市、国家、其他地理名称、人名等等。例如,它应该将 Paul McCartney 标记为人名,将德国标记为国名。

让我们看看实现这些基本方法的一个库:Stanford CoreNLP。

斯坦福·科伦普

Java 中有相当多成熟的 NLP 库。比如斯坦福 CoreNLP,OpenNLP,还有 GATE。我们之前介绍过的许多库都有一些 NLP 模块,例如,Smile 或 JSAT。

在本章中,我们将使用斯坦福 CoreNLP。没有特别的原因,如果需要,应该可以在任何其他库中复制这些示例。

让我们从在pom.xml中指定以下依赖关系开始:

<dependency>
  <groupId>edu.stanford.nlp</groupId>
  <artifactId>stanford-corenlp</artifactId>
  <version>3.6.0</version>
</dependency>
<dependency>
  <groupId>edu.stanford.nlp</groupId>
  <artifactId>stanford-corenlp</artifactId>
  <version>3.6.0</version>
  <classifier>models</classifier>
</dependency>

有两个依赖项:第一个是 NLP 包本身,第二个包含第一个模块使用的模型。这些模型适用于英语,但也存在适用于其他欧洲语言(如德语或西班牙语)的模型。

这里的主要抽象是一个 StanfordCoreNLP 类,它充当处理管道。它指定了应用于原始文本的一系列步骤。

考虑下面的例子:

Properties props = new Properties();
props.put("annotators", "tokenize, ssplit, pos, lemma");
StanfordCoreNLP pipeline = new StanfordCoreNLP(props);

在这里,我们创建一个管道,它获取文本,对文本进行标记,将文本拆分成句子,对每个标记应用 POS 模型,然后找到它的词条。

我们可以这样使用它:

String text = "some text";

Annotation document = new Annotation(text);
pipeline.annotate(document);
List<Word> results = new ArrayList<>();

List<CoreLabel> tokens = document.get(TokensAnnotation.class);
for (CoreLabel tokensInfo : tokens) {
    String token = tokensInfo.get(TextAnnotation.class);
    String lemma = tokensInfo.get(LemmaAnnotation.class);
    String pos = tokensInfo.get(PartOfSpeechAnnotation.class);
    results.add(new Word(token, lemma, pos));
}

在这段代码中,Word是我们的类,它保存了关于标记的信息:表面形式(出现在文本中的形式)、引理(规范化的形式)和词性。

很容易修改管道来添加额外的步骤。例如,如果我们希望添加 NER,那么我们首先要做的是将NER添加到管道中:

Properties props = new Properties();
props.put("annotators", "tokenize, ssplit, pos, lemma, ner");
StanfordCoreNLP pipeline = new StanfordCoreNLP(props);

然后,对于每个令牌,提取相关的NER标签:

String ner = tokensInfo.get(NamedEntityTagAnnotation.class);

尽管如此,前面的代码仍然需要一些手工清理;如果我们运行它,我们可能会注意到它还输出标点符号和停用词。通过在循环中添加一些额外的检查,很容易解决这个问题:

for (CoreLabel tokensInfo : tokens) {
    String token = tokensInfo.get(TextAnnotation.class);
    String lemma = tokensInfo.get(LemmaAnnotation.class);
    String pos = tokensInfo.get(PartOfSpeechAnnotation.class);
    String ner = tokensInfo.get(NamedEntityTagAnnotation.class);

    if (isPunctuation(token) || isStopword(token)) {
        continue;
    } 

    results.add(new Word(token, lemma, pos, ner));
}

isStopword方法的实现很简单:我们只需检查令牌是否在停用字词集中。检查标点符号也不难:

public static boolean isPunctuation(String token) {
    char first = token.charAt(0);
    return !Character.isAlphabetic(first) && !Character.isDigit(first);
}

我们只是验证String的第一个字符不是字母也不是数字。如果是这样,那么一定是标点符号。

NER 还有另一个问题,我们可能需要解决:它没有将同类的连续单词连接到一个令牌中。考虑这个例子:我叫贾斯汀比伯,我住在纽约。它将产生以下 NER 标签分配:

  • 贾斯汀->人
  • 比伯->人
  • 新建->位置
  • 约克->位置
  • 其他令牌被映射到O

我们可以用下面的代码片段连接标有相同NER标记的连续令牌:

String prevNer = "O";

List<List<Word>> groups = new ArrayList<>();
List<Word> group = new ArrayList<>();

for (Word w : words) {
    String ner = w.getNer();
    if (prevNer.equals(ner) && !"O".equals(ner)) {
        group.add(w);
        continue;
    }

    groups.add(group);
    group = new ArrayList<>();
    group.add(w);

    prevNer = ner;
}

groups.add(group);

所以我们简单地检查序列,看看当前标签是否与前一个标签相同。如果是这样,那么我们停止一个组,开始下一个组。如果我们看到O,那么我们总是假设它是下一组。之后,我们只需要过滤空的组,如果需要的话,将文本字段合并成一个。

虽然这对人来说似乎没什么大不了的,但对于像纽约这样的地名来说可能很重要:这些标记合在一起与单独的标记 New 和 York 具有完全不同的含义,因此将它们作为单个标记对待可能对 IR 系统很有用。

接下来,我们将看到如何在 Apache Lucene 中利用 NLP 工具,如 Stanford CoreNLP。

定制 Apache Lucene

Apache Lucene 是一个古老且非常强大的搜索库。它写于 1999 年,从那以后,许多用户不仅采用了它,还为这个库创建了许多不同的扩展。

尽管如此,有时 Lucene 内置的 NLP 功能还不够,还需要一个专门的 NLP 库。

例如,如果我们想在标记中包含 POS 标签,或者查找命名实体,那么我们需要像 Stanford CoreNLP 这样的东西。在 Lucene 工作流中包含这样的外部专用 NLP 库并不困难,这里我们将看到如何实现。

让我们使用 StanfordNLP 库和我们在上一节中实现的标记器。我们可以用一个方法tokenize将其命名为StanfordNlpTokenizer,在这里我们将放入之前编写的用于标记化的代码。

我们可以使用这个类来标记抓取的 HTML 数据的内容。和以前一样,我们使用 JSoup 从 HTML 中提取文本,但是现在,我们不是将标题和正文直接放入文档,而是首先使用 CoreNLP 管道自己对其进行预处理。我们可以通过创建以下实用程序方法来实现,然后使用它来标记标题和正文:

public static String tokenize(StanfordNlpTokenizer tokenizer, String text) {
    List<Word> tokens = tokenizer.tokenize(text);
    return tokens.stream()
                .map(Word::getLemma)
                .map(String::toLowerCase)
                .collect(Collectors.joining(" "));
}

请注意,这里我们使用了引理,而不是令牌本身,最后我们再次将所有内容放回到一个字符串中。

通过这个修改,我们可以使用 Lucene 的WhitespaceAnalyzer。与StandardAnalyzer相反,它非常简单,它所做的就是用一个空白字符分割文本。在我们的例子中,字符串已经由 CoreNLP 准备和处理,所以 Lucene 以期望的形式索引内容。

完整的修改版本将如下所示:

Analyzer analyzer = new WhitespaceAnalyzer();
IndexWriter writer = 
        new IndexWriter(directory, new IndexWriterConfig(analyzer));
StanfordNlpTokenizer tokenizer = new StanfordNlpTokenizer();

for (String line : lines) {
    String[] split = line.split("t");
    String url = split[3];
    Optional<String> html = urls.get(url);
    if (!html.isPresent()) {
        continue;
    }

    org.jsoup.nodes.Document jsoupDoc = Jsoup.parse(html.get());
    Element body = jsoupDoc.body();
    if (body == null) {
        continue;
    }

    String titleTokens = tokenize(tokenizer, jsoupDoc.title());
    String bodyTokens = tokenize(tokenizer, body.text());

    Document doc = new Document();
    doc.add(new Field("url", url, URL_FIELD));
    doc.add(new Field("title", titleTokens, URL_FIELD));
    doc.add(new Field("content", bodyTokens, BODY_FIELD)); 
    writer.addDocument(doc);
}

可以对某些字段使用 Lucene 的StandardAnalyzer,对其他字段使用经过定制预处理的WhitespaceAnalyzer。为此,我们需要使用PerFieldAnalyzerWrapper,在这里我们可以为每个字段指定一个特定的Analyzer

这为我们如何预处理和分析文本提供了很大的灵活性,但是它不允许我们改变排序公式:Lucene 用来排序文档的公式。在本章的后面,我们也将看到如何做到这一点,但首先我们将看看如何在文本分析中使用机器学习。

文本的机器学习

机器学习在文本处理中起着重要的作用。它允许更好地理解隐藏在文本中的信息,并提取隐藏在那里的有用知识。我们已经从前面的章节中熟悉了机器学习模型,事实上,我们甚至已经将其中的一些用于文本,例如,来自斯坦福 CoreNLP 的 POS tagger 和 NER 都是基于机器学习的模型。

第 4 章监督学习-分类和回归第 5 章非监督学习-聚类和降维中,我们涵盖了监督和非监督机器学习问题。就文本而言,两者都在帮助组织文本或提取有用信息方面发挥着重要作用。在本节中,我们将看到如何将它们应用于文本数据。

文本的无监督学习

正如我们所知,无监督机器学习处理没有提供标签信息的情况。对于文本,这意味着只让它处理大量的文本数据,而没有关于内容的额外信息。尽管如此,它可能经常是有用的,现在我们将看到如何对文本使用降维和聚类。

潜在语义分析

潜在语义分析 ( LSA ),也称为潜在语义索引 ( LSI ),是无监督降维技术对文本数据的应用。

LSA 试图解决的问题是:

  • 同义词:这意味着多个单词具有相同的意思
  • 多义性:这意味着一个词有多个意思

基于术语的浅层技术(如单词袋)无法解决这些问题,因为它们只查看术语的精确原始形式。例如,像 help 和 assist 这样的词将被分配到向量空间的不同维度,即使它们在语义上非常接近。

为了解决这些问题,LSA 将文档从通常的词汇向量空间转移到其他一些语义空间,在这些空间中,意义相近的词对应于同一维度,多义词的值跨维度拆分。

这是通过查看术语-术语共现矩阵来实现的。假设是这样的:如果两个词经常在同一个语境中使用,那么它们是同义的,反之,如果一个词是多义的,那么它将在不同的语境中使用。降维技术可以检测这种共现模式,并将它们压缩到更小维度的向量空间中。

一种这样的降维技术是奇异值分解 ( SVD )。如果 X 是一个文档术语矩阵,比如我们从CountVectorizer得到的矩阵,那么 X 的 SVD 是:

XV = US

上述等式中的各项解释如下:

  • V 是在术语-术语共现矩阵 X ^T X 上计算的术语的基础
  • U 是在文档-文档共现矩阵 XX ^T 上计算的文档的基础

因此,通过对 X 应用截断的 SVD,我们降低了术语-术语共现矩阵 X ^T X 的维数,然后可以使用这个新的简化基 V 来表示我们的文档。

我们的文档矩阵存储在SparseDataset中。如果您还记得,我们已经在这样的对象上使用了 SVD:首先,我们将 SparseDataset 转换成基于列的SparseMatrix,然后对它应用 SVD:

SparseMatrix matrix = data.toSparseMatrix();
SingularValueDecomposition svd = SingularValueDecomposition.decompose(matrix, n);
double[][] termBasis = svd.getV();

然后下一步是把我们的矩阵投射到这个新的项基上。在前一章中,我们已经使用以下方法做到了这一点:

public static double[][] project(SparseDataset dataset, double[][] Vd) {
    CompRowMatrix X = asRowMatrix(dataset);
    DenseMatrix V = new DenseMatrix(Vd);
    DenseMatrix XV = new DenseMatrix(X.numRows(), V.numColumns());
    X.mult(V, XV);
    return to2d(XV);
}

这里,asRowMatrixSparseDataset转换成来自 MTJ 的CompRowMatrixto2d将来自 MTJ 的密集矩阵转换成双精度的二维数组。

一旦我们将原始数据投影到 LSA 空间,它就不再是归一化的了。我们可以通过实现以下方法来解决这个问题:

public static double[][] l2RowNormalize(double[][] data) {
    for (int i = 0; i < data.length; i++) {
        double[] row = data[i];
        ArrayRealVector vector = new ArrayRealVector(row, false);
        double norm = vector.getNorm();
        if (norm != 0) {
            vector.mapDivideToSelf(norm);
            data[i] = vector.getDataRef();
        }
    }

    return data;
}

这里,我们对输入矩阵的每一行应用长度规范化,为此我们使用 Apache Commons Math 中的ArrayRealVector

为了方便起见,我们可以为 LSA 创建一个特殊的类。姑且称之为TruncatedSVD,它会有如下签名:

public class TruncatedSVD {
    void fit(SparseDataset data);
    double[][] transform(SparseDataset data);
}

它有以下方法:

  • fit学习新学期基础
  • transform通过将数据投射到已学习的基础上来减少数据的维度
  • 构造函数应该有两个参数:n,期望的维数和结果是否应该被规范化

我们可以将 LSA 应用到我们的 IR 系统中:现在,代替单词袋空间中的余弦相似度,我们进入 LSA 空间并在那里计算余弦。为此,我们首先需要在索引期间将文档映射到这个空间,然后在查询期间,我们对用户查询执行相同的转换。然后,计算余弦只是一个矩阵乘法。

所以,让我们先来看看我们之前使用的代码:

List<List<String>> documents = Files.lines(path, StandardCharsets.UTF_8)
        .map(line -> TextUtils.tokenize(line))
        .map(line -> TextUtils.removeStopwords(line))
        .collect(Collectors.toList());

int minDf = 5;
CountVectorizer cv = new CountVectorizer(minDf);
cv.fit(documents);
SparseDataset docVectors = cv.transform(documents);

现在,我们使用刚刚创建的TruncatedSVD类将docVectors映射到 LSA 空间:

int n = 150;
boolean normalize = true;
TruncatedSVD svd = new TruncatedSVD(n, normalize);
svd.fit(docVectors);
double[][] docsLsa = svd.transform(docVectors);

我们重复同样的查询:

List<String> query = TextUtils.tokenize("cheap used cars");
query = TextUtils.removeStopwords(query);
SparseDataset queryVectors = vectorizer.transfrom(Collections.singletonList(query));
double[] queryLsa = svd.transform(queryVectors)[0];

像前面一样,我们将查询包装到一个列表中,然后提取结果的第一行。然而,这里我们有一个密集的向量,而不是稀疏的。现在,剩下的是计算相似性,这只是一个矩阵向量乘法:

DenseMatrix X = new DenseMatrix(docsLsa);
DenseVector v = new DenseVector(vector);
DenseVector result = new DenseVector(X.numRows());
X.mult(v, result);
double[] scores = result.getData();

执行之后,scores 数组将包含相似性,我们可以使用 ScoredIndex 类根据这个分数对文档进行排序。这是非常有用的,所以让我们把它变成一个实用方法:

public static List<ScoredIndex> wrapAsScoredIndex(double[] scores, double minScore) {
    List<ScoredIndex> scored = new ArrayList<>(scores.length);

    for (int idx = 0; idx < scores.length; idx++) {
        double score = scores[idx];
        if (score >= minScore) {
            scored.add(new ScoredIndex(idx, score));
        }
    }

    Collections.sort(scored);
    return scored;
}

最后,我们从列表中取出第一个元素,并像以前一样将它们呈现给用户。

文本聚类

第 5 章无监督学习——聚类和降维中,我们介绍了降维和聚类。我们已经讨论了如何对文本进行降维,但还没有谈到聚类。

文本聚类对于理解什么是文档集合也是一种有用的技术。当我们想要聚类文本时,目标类似于非文本情况:我们想要找到具有许多共同点的文档组:例如,这种组中的文档应该是关于同一主题的。在某些情况下,这对于 IR 系统是有用的。例如,如果一个主题是不明确的,我们可能希望对搜索引擎结果进行分组。

means 是一个简单而强大的聚类算法,它非常适合文本。让我们使用抓取的文本,并尝试使用 K -means 在其中找到一些话题。首先,我们加载文档并将它们矢量化。我们将使用来自 Smile 的 K -Means 实现,如果你还记得的话,它不支持稀疏矩阵,所以我们还需要降低维数。为此,我们将使用 LSA。

List<List<String>> documents = ... // read the crawl data

int minDf = 5;
CountVectorizer cv = new CountVectorizer(minDf);
cv.fit(documents);

SparseDataset docVectors = cv.transform(documents);
int n = 150;
boolean normalize = true;
TruncatedSVD svd = new TruncatedSVD(n, normalize);
svd.fit(docVectors);

double[][] docsLsa = svd.transform(docVectors);

数据是准备好的,所以我们可以应用K-意思是:

int maxIter = 100;
int runs = 3;
int k = 100;
KMeans km = new KMeans(docsLsa, k, maxIter, runs);

这里,k,你应该还记得上一章的内容,是我们想要寻找的集群的数量。这里对K的选择是相当随意的,所以可以随意试验并选择K的任何其他值。

一旦它完成了,我们可以看看结果的质心。然而,这些质心在 LSA 空间中,而不在原始项空间中。为了让他们回来,我们需要反转 LSA 变换。

为了从原始空间到 LSA 空间,我们使用了由基项构成的矩阵。因此,为了进行逆变换,我们需要该矩阵的逆。因为基是正交的,所以它的逆与转置相同,我们将用它来求 LSA 变换的逆。代码看起来是这样的:

double[][] centroids = km.centroids();
double[][] termBasis = svd.getTermBasis();
double[][] centroidsOriginal = project(centroids, t(termBasis));

以下是t方法计算转置的方式:

public static double[][] t(double[][] M) {
    Array2DRowRealMatrix matrix = new Array2DRowRealMatrix(M, false);
    return matrix.transpose().getData();
}

而投影法只是计算矩阵-矩阵乘法。

现在,当质心在原始空间时,我们找到每个质心最重要的项。

为此,我们取一个质心,看看最大的维度是多少:

List<String> terms = vectorizer.vocabulary();
for (int centroidId = 0; centroidId < k; centroidId++) {
    double[] centroid = centroidsOriginal[centroidId];
    List<ScoredIndex> scored = wrapAsScoredIndex(centroid, 0.0);
    for (int i = 0; i < 20; i++) {
        ScoredIndex scoredTerm = scored.get(i);
        int position = scoredTerm.getIndex();
        String term = terms.get(position);
        System.out.print(term + ", ");
    }
    System.out.println();
}

这里,terms 是包含来自CountVectorizer,的维度名称的列表,而wrapAsScoredIndex是我们之前编写的函数;它接受一个双精度数组,创建一个ScoredIndex对象列表,并对其进行排序。

当您运行它时,您可能会看到类似于这些集群的内容:

| 集群 1 | 集群 2 | 集群 3 |
| 血压低血压低症状心脏病的治疗 | 惠普打印机打印机打印 laserjet 支持 officejet 打印墨水软件 | 汽车汽车丰田福特本田二手宝马雪佛兰汽车日产 |

我们只取了前三组,它们显然是有意义的。也有一些聚类不太有意义,这表明该算法可以进一步调整:我们可以调整中的KK-LSA 的均值和维数。

单词嵌入

到目前为止,我们已经介绍了如何对文本数据应用降维和聚类。还有另一种类型的无监督学习,它特定于文本:单词嵌入。你可能听说过 Word2Vec,就是这样一种算法。

单词嵌入试图解决的问题是如何将单词嵌入到低维向量空间中,使得语义上接近的单词在这个空间中是接近的,而不同的单词是远离的。

例如,猫和狗应该离得很近,但是笔记本电脑和天空应该离得很远。

这里,我们将实现一个基于共现矩阵的单词嵌入算法。它建立在 LSA 的思想之上:在那里,我们可以用术语所包含的文档来表示它们。所以,如果两个单词包含在同一个文档中,它们应该是相关的。然而,文档对于一个单词来说是一个相当宽泛的上下文,所以我们可以把它缩小到一个句子,或者缩小到感兴趣的单词前后的几个单词。

例如,考虑下面的句子:

我们使用 Java 进行数据科学,因为我们喜欢 Java。Java 有利于企业开发。

然后,我们将文本标记化,分成句子,删除停用词,得到以下内容:

  • “我们”、“使用”、“java”、“数据”、“科学”、“我们”、“喜欢”、“java”
  • “java”、“好”、“企业”、“开发”

现在,假设对于这里的每个单词,我们想看看前面的两个单词和后面的两个单词是什么。这会给我们每个单词的上下文。对于本例,它将是:

  • 我们->使用 java
  • 用-> we;java,数据
  • java -> we,使用;数据,科学
  • 数据->使用,java 科学,我们
  • java ->我们,喜欢
  • java ->好,企业
  • 好的-> Java;企业,发展
  • 企业-> java,不错;发展
  • 发展->好,企业

然后,我们可以建立一个共现矩阵,在这个矩阵中,每当一个单词出现在另一个单词的上下文中时,我们就将它置 1。所以,对于“我们”,我们会在“使用”和“java”上加+1,以此类推。

最后,每个单元格将显示一个单词 w [1] (来自矩阵的行)在另一个单词 w [2] (来自矩阵的列)的上下文中出现了多少次。接下来,如果我们用奇异值分解降低这个矩阵的维数,我们已经比普通的 LSA 方法有了很大的改进。

但是我们可以更进一步,用点态互信息 ( PMI )代替计数。

PMI 是衡量两个随机变量之间相关性的指标。它最初来自信息论,但在计算语言学中经常用于测量两个词之间的关联程度。它的定义如下:

它检查两个单词 w 和 v 是否偶然同时出现。如果它们是偶然发生的,那么联合概率 p(w,v) 应该等于边际概率 p(w) p(v) 的乘积,所以 PMI 为 0。但是,如果两个单词之间确实存在关联,PMI 会得到高于 0 的值,因此值越高,关联越强。

我们通常通过浏览文本并计数来估计这些概率:

  • 对于边际概率,我们只计算令牌出现的次数
  • 对于连接概率,我们看共生矩阵

我们使用以下公式:

  • p(w) = c(w) / N,其中c(w)w在体内出现的次数,N为令牌总数
  • p(w, v) = c(w, v) / N,其中c(w, v)是来自共生矩阵的值,N也是令牌的数量

然而,在实践中,c(w, v)c(w)c(v)的小值会扭曲概率,因此通常通过添加一些小数字λ来平滑它们:

  • p(w) = [c(w) + λ] / [N + Kλ],其中K是语料库中唯一标记的数量
  • p(w, v) = [c(w, v) + λ] / [N + Kλ]

如果我们替换前面等式中的 PMI 公式,我们会得到以下公式:

PMI(w, v) = log [c(w, v) + λ] + log [N + Kλ] - log [c(w) + λ] - log [c(v) + λ]

所以我们能做的只是用 PMI 替换共现矩阵中的计数,然后计算这个矩阵的 SVD。在这种情况下,得到的嵌入将具有更好的质量。

现在,让我们实现它。首先,您可能已经注意到,我们需要有句子,而以前我们只有一串标记,没有句子边界的检测。我们知道,斯坦福 CoreNLP 可以做到,所以让我们创建一个管道:

Properties props = new Properties();
props.put("annotators", "tokenize, ssplit, pos, lemma");
StanfordCoreNLP pipeline = new StanfordCoreNLP(props);

我们将使用句子分割器来检测句子,然后我们将采用单词的引理而不是表面形式。

但是让我们首先创建一些有用的类。之前我们用List<List<String>>说我们传递一个文档集合,每个文档都是一个令牌序列。现在我们把每个文档拆分成句子,再把每个句子拆分成令牌,就变成了List<List<List<String>>>,有点难以理解。我们可以用一些有意义的类来代替,比如DocumentSentence:

public class Document {
    private List<Sentence> sentences;
    // getter, setter and constructor is omitted
}

public class Sentence {
    private List<String> tokens;
    // getter, setter and constructor is omitted
}

尽可能创建这样的小班。尽管在开始时看起来有些冗长,但它对以后阅读代码和理解意图非常有帮助。

现在,让我们用它们来标记一个文档。我们可以用下面的方法创建一个Tokenizer类:

public Document tokenize(String text) {
    Annotation document = new Annotation(text);
    pipeline.annotate(document);

    List<Sentence> sentencesResult = new ArrayList<>();
    List<CoreMap> sentences = document.get(SentencesAnnotation.class);

    for (CoreMap sentence : sentences) {
        List<CoreLabel> tokens = sentence.get(TokensAnnotation.class);
        List<String> tokensResult = new ArrayList<>();

        for (CoreLabel tokensInfo : tokens) {
            String token = tokensInfo.get(TextAnnotation.class);
            String lemma = tokensInfo.get(LemmaAnnotation.class);
            if (isPunctuation(token) 
                    || isStopword(token) 
                    || lemma.length() <= 2) {
                continue;
            }

            tokensResult.add(lemma.toLowerCase());
        }

        if (!tokensResult.isEmpty()) {
            sentencesResult.add(new Sentence(tokensResult));
        }
    }

    return new Document(sentencesResult);
}

因此,我们在这里将句子拆分器应用于文本,然后,对于每个句子,收集标记。我们已经看到了isPunctuationisStopword方法——这里它们的实现和前面的一样。

然后我们可以再次使用抓取的 HTML 数据集,并对用 JSoup 提取的内容应用标记器。为了简洁起见,我们将省略这一部分。现在,我们准备从这些数据中构建共现矩阵。

CountVectorizer中一样,第一步是应用文档频率过滤器来丢弃不常用的标记,然后构建一个将标记与某个整数相关联的映射:得到的稀疏矩阵的列数。我们已经知道怎么做了,所以我们可以跳过这一部分。

然后,为了估计p(w)p(v),我们需要知道每个令牌出现的次数:

Multiset<String> counts = HashMultiset.create();
for (Document doc : documents) {
    for (Sentence sentence : doc.getSentences()) {
        counts.addAll(sentence.getTokens());
    }
}

现在,我们可以开始计算共生矩阵。为此,我们可以使用 Guava 的Table类:

Table<String, String, Integer> coOccurrence = HashBasedTable.create();
for (Document doc : documents) {
    for (Sentence sentence : doc.getSentences()) {
        processWindow(sentence, window, coOccurrence);
    }
}

这里,我们用以下内容定义processWindow函数:

List<String> tokens = sentence.getTokens();

for (int idx = 0; idx < tokens.size(); idx++) {
    String token = tokens.get(idx);

    Map<String, Integer> tokenRow = coOccurrence.row(token);

    for (int otherIdx = idx - window; 
            otherIdx <= idx + window; 
            otherIdx++) {

        if (otherIdx < 0 
                || otherIdx >= tokens.size() 
                || otherIdx == idx) {
            continue;
        }

        String other = tokens.get(otherIdx);
        int currentCnt = tokenRow.getOrDefault(other, 0);
        tokenRow.put(other, currentCnt + 1);
    }
}

这里我们在文档的每个句子上滑动一个指定大小的窗口。然后,对于该窗口中心的单词,我们查看前后的单词,并且对于它们中的每一个,将同现计数增加 1。

下一步是根据这些数据创建一个 PMI 值矩阵。像以前一样,我们将使用 Smile 的SparseDataset类来保存这些值:

int vocabularySize = vocabulary.size();

double logTotalNumTokens = Math.log(counts.size() + vocabularySize * smoothing);
SparseDataset result = new SparseDataset(vocabularySize);

for (int rowIdx = 0; rowIdx < vocabularySize; rowIdx++) {
    String token = vocabulary.get(rowIdx);
    double logMainTokenCount = Math.log(counts.count(token) + smoothing);
    Map<String, Integer> tokenCooc = coOccurrence.row(token);

    for (Entry<String, Integer> otherTokenEntry : tokenCooc.entrySet()) {
        String otherToken = otherTokenEntry.getKey();
        double logOtherTokenCount = Math.log(counts.count(otherToken) + smoothing);
        double logCoOccCount = Math.log(otherTokenEntry.getValue() + smoothing);

        double pmi = logCoOccCount + logTotalNumTokens 
                   - logMainTokenCount - logOtherTokenCount;

        if (pmi > 0) {
            int colIdx = tokenToIndex.get(otherToken);
            result.set(rowIdx, colIdx, pmi);
        }
    }

}

在这段代码中,我们只是将 PMI 公式应用于我们拥有的同现计数。最后,我们对这个矩阵执行 SVD,为此我们只需使用我们之前创建的TruncatedSVD类。

现在,我们可以看看我们训练的嵌入是否有意义。为此,我们可以选择一些术语,并为每个术语找到最相似的术语。这可以通过以下方式实现:

  • 首先,对于给定的令牌,我们查找它的向量表示
  • 然后,我们计算这个记号与其余向量的相似度。我们知道,这可以通过矩阵向量乘法来实现
  • 最后,我们按分数对乘法的结果进行排序,并显示分数最高的记号。

到目前为止,我们已经完成了几次完全相同的过程,所以我们可以跳过代码。当然,它可以在本章的代码包中找到。

但是让我们看看结果。我们选取了几个词:笔记本电脑,以下是最相似的几个词,根据我们刚刚训练的嵌入:

| | 德国 | 笔记本电脑 |
| 0.835 宠物 | 0.829 国家 | 0.882 笔记本 |
| 0.812 狗 | 0.815 移民 | 0.869 英寸超极本 |
| 0.796 小猫 | 0.813 联合 | 0.866 桌面 |
| 0.793 搞笑 | 0.808 个国家 | 0.865 专业版 |
| 0.788 小狗 | 0.802 巴西 | 0.845 触摸屏 |
| 0.762 动物 | 0.789 加拿大 | 0.842 联想 |
| 0.742 庇护所 | 0.777 德语 | 0.841 游戏 |
| 0.727 朋友 | 0.776 澳大利亚 | 0.836 片 |
| 0.727 救援 | 0.760 欧洲 | 0.834 华硕 |
| 0.726 图片 | 0.759 外国 | 0.829 macbook |

即使不理想,结果还是有意义的。通过在更多的文本数据上训练这些嵌入,或者微调诸如 SVD 的维数、最小文档频率和平滑量之类的参数,可以进一步改进它。

当训练单词嵌入时,获取更多的数据总是一个好主意。维基百科是一个很好的文本资料来源;它有多种语言版本,他们定期在 https://dumps.wikimedia.org/发布垃圾信息。如果维基百科还不够,你可以使用普通抓取(http://commoncrawl.org/),他们抓取互联网上的所有内容,并免费提供给任何人。我们还会在第 9 章缩放数据科学中谈到常见的抓取。

最后,互联网上有很多经过预先训练的单词嵌入。

比如,你可以看看这里的收藏:https://github.com/3Top/word2vec-api。从那里加载嵌入非常容易。

为此,让我们首先创建一个类来存储向量:

public class WordEmbeddings {
    private final double[][] embeddings;
    private final List<String> vocabulary;
    private final Map<String, Integer> tokenToIndex;
    // constructor and getters are omitted

    List<ScoredToken> mostSimilar(String top, int topK, double minSimilarity);
    Optional<double[]> representation(String token);
}

该类具有以下字段和方法:

  • embeddings:这是存储向量的数组
  • 这是所有令牌的列表
  • tokenToIndex:这是从令牌到存储向量的索引的映射
  • mostSimilar:这将返回与所提供的令牌最相似的前 K 个令牌
  • representation:返回一个术语的向量表示,如果没有向量,则可选。不存在

当然,我们可以把基于 PMI 的嵌入放在那里。但是让我们看看如何从前面的链接中加载现有的 GloVe 和 Word2Vec 向量。

对于 Word2Vec 和 GloVe 来说,存储向量的文本文件格式非常相似,所以我们只能介绍其中一种。GloVe 稍微简单一点,我们按如下方式使用它:

存储格式很简单;每一行都有一个标记,后面跟着一系列数字。这些数字显然是令牌的嵌入向量。让我们来读一读:

List<Pair<String, double[]>> pairs =
        Files.lines(file.toPath(), StandardCharsets.UTF_8)
             .parallel()
             .map(String::trim)
             .filter(StringUtils::isNotEmpty)
             .map(line -> parseGloveTextLine(line))
             .collect(Collectors.toList());

List<String> vocabulary = new ArrayList<>(pairs.size());
double[][] embeddings = new double[pairs.size()][];

for (int i = 0; i < pairs.size(); i++) {
    Pair<String, double[]> pair = pairs.get(i);
    vocabulary.add(pair.getLeft());
    embeddings[i] = pair.getRight();
}

embeddings = l2RowNormalize(embeddings);
WordEmbeddings result = new WordEmbeddings(embeddings, vocabulary);

在这里,我们解析文本文件的每一行,然后创建词汇表并标准化向量的长度。parseGloveTextLine有以下内容:

List<String> split = Arrays.asList(line.split(" "));
String token = split.get(0);
double[] vector = split.subList(1, split.size()).stream()
        .mapToDouble(Double::parseDouble).toArray();
Pair<String, double[]> result = ImmutablePair.of(token, vector);

这里,ImmutablePair是 Apache Commons Lang 中的一个对象。

让我们用同样的词,看看他们的邻居使用这些手套嵌入。这是结果:

| | 德国 | 笔记本电脑 |
| - 0.682 狗

  • 0.682 猫
  • 0.587 宠物
  • 0.541 狗
  • 0.490 猫
  • 0.488 猴
  • 0.473 马
  • 0.463 宠物
  • 0.461 兔
  • 0.459 豹 | - 0.749 德国
  • 0.663 奥地利
  • 0.646 柏林
  • 0.597 欧洲
  • 0.586 慕尼黑
  • 0.579 波兰
  • 0.577 瑞士
  • 0.575 德国
  • 0.559 丹麦
  • 0.557 法国 | - 0.796 台笔记本电脑
  • 0.673 台电脑
  • 0.599 台手机
  • 0.596 台电脑
  • 0.580 台便携式
  • 0.562 台台式
  • 0.547 台手机
  • 0.546 台笔记本
  • 0.544 台
  • 0.529 台手机 |

结果确实有意义,而且在某些情况下,它比我们训练自己的嵌入更好。

正如我们提到的,word2vec 向量的文本格式与 GloVe 向量非常相似,因此只需稍加修改就可以阅读它们。然而,有一种存储 word2vec 嵌入的二进制格式。它有点复杂,但是如果你想知道如何阅读它,看看本章的代码包。

在这一章的后面,我们将看到如何应用单词嵌入来解决监督学习问题。

文本的监督学习

有监督的机器学习方法对于文本数据也相当有用。像在通常的设置中一样,这里有标签信息,我们可以用它来理解文本中的信息。

将监督学习应用于文本的一个非常常见的例子是垃圾邮件检测:每当你点击电子邮件客户端的垃圾邮件按钮时,这些数据就会被收集起来,然后放入分类器中。然后,训练该分类器来区分垃圾邮件和非垃圾邮件。

在本节中,我们将通过两个例子来研究如何对文本使用监督方法:首先,我们将构建一个情感分析模型,然后我们将使用一个排名分类器对搜索结果进行重新排名。

文本分类

文本分类是一个问题,其中给定一组文本和标签,它训练一个模型,该模型可以为新的看不见的文本预测这些标签。这里的设置是监督学习的常用设置,只是现在我们有了文本数据。

有许多可能的分类问题,如下所示:

  • 垃圾邮件检测:预测电子邮件是否是垃圾邮件
  • 情感分析:预测文本的情感是正面还是负面
  • 语言检测:给定一个文本,检测它的语言

几乎在所有情况下,文本分类的一般工作流程都是相似的:

  • 我们对文本进行标记和矢量化
  • 然后,我们拟合一个线性分类器,将每个记号视为一个特征

众所周知,如果我们对文本进行矢量化,得到的向量非常稀疏。这就是为什么使用线性模型是一个好主意:它们非常快,可以轻松处理文本数据的稀疏性和高维数。

所以让我们来解决其中一个问题。

比如我们可以拿一个情感分析问题,建立一个模型,这个模型预测文本的极性,也就是文本是正面的还是负面的。

我们可以从这里取数据:http://ai.stanford.edu/~amaas/data/sentiment/。这个数据集包含从 IMDB 中提取的 50.000 个带标签的电影评论,作者提供了预定义的训练测试分割。为了从那里存储评论,我们可以为它创建一个类:

public class SentimentRecord {
    private final String id;
    private final boolean train;
    private final boolean label;
    private final String content;
    // constructor and getters omitted
}

我们不会详细讨论从归档文件中读取数据的代码,但是像往常一样,欢迎您查看代码包。

至于模型,我们将使用 LIBLINEAR——正如你已经从第四章中知道的——监督学习——分类和回归。这是一个快速实现线性分类器的库,如逻辑回归和线性 SVM。

现在,让我们来读数据:

List<SentimentRecord> data = readFromTagGz("data/aclImdb_v1.tar.gz");

List<SentimentRecord> train = data.stream()
        .filter(SentimentRecord::isTrain)
        .collect(Collectors.toList());

List<List<String>> trainTokens = train.stream()
        .map(r -> r.getContent())
        .map(line -> TextUtils.tokenize(line))
        .map(line -> TextUtils.removeStopwords(line))
        .collect(Collectors.toList());

在这里,我们从归档中读取数据,然后对训练数据进行标记。接下来,我们对文本进行矢量化处理:

int minDf = 10;
CountVectorizer cv = new CountVectorizer(minDf);
cv.fit(trainTokens);
SparseDataset trainData = cv.transform(trainTokens);

到目前为止,没什么新发现。但是现在我们需要将SparseDataset转换成 LIBLINEAR 格式。让我们为此创建几个实用方法:

public static Feature[][] wrapX(SparseDataset dataset) {
    int nrow = dataset.size();
    Feature[][] X = new Feature[nrow][];

    int i = 0;
    for (Datum<SparseArray> inrow : dataset) {
        X[i] = wrapRow(inrow);
        i++;
    }

    return X;
}

public static Feature[] wrapRow(Datum<SparseArray> inrow) {
    SparseArray features = inrow.x;

    int nonzero = features.size();
    Feature[] outrow = new Feature[nonzero];
    Iterator<Entry> it = features.iterator();

    for (int j = 0; j < nonzero; j++) {
        Entry next = it.next();
        outrow[j] = new FeatureNode(next.i + 1, next.x);
    }

    return outrow;
}

第一个方法wrapX,采用一个SparseDataset并创建一个二维数组的Feature对象。这是 LIBLINEAR 存储数据的格式。第二个方法是wrapRow,它获取一个特定的SparseDataset行,并将其包装成一个由Feature对象组成的一维数组。

现在,我们需要提取标签信息并创建一个描述数据的Problem类的实例:

double[] y = train.stream().mapToDouble(s -> s.getLabel() ? 1.0 : 0.0).toArray();
Problem problem = new Problem();
problem.x = wrapX(dataset);
problem.y = y;
problem.n = dataset.ncols() + 1;
problem.l = dataset.size();

然后,我们定义参数并训练模型:

Parameter param = new Parameter(SolverType.L1R_LR, 1, 0.001);
Model model = Linear.train(problem, param);

这里,我们用 L1 正则化和成本参数C=1指定一个逻辑回归模型。

线性分类器,如逻辑回归或带 L1 正则化的 SVM,非常适合处理高稀疏性问题,如文本分类。L1 惩罚确保模型收敛得非常快,此外,它还迫使解变得稀疏:也就是说,它执行特征选择,只保留最有信息的单词。

为了预测概率,我们可以创建另一个实用方法,它采用一个模型和一个测试数据集,并返回一个一维概率数组:

public static double[] predictProba(Model model, SparseDataset dataset) {
    int n = dataset.size();
    double[] results = new double[n];
    double[] probs = new double[2];
    int i = 0;

    for (Datum<SparseArray> inrow : dataset) {
        Feature[] row = wrapRow(inrow);
        Linear.predictProbability(model, row, probs);
        results[i] = probs[1];
        i++;
    }

    return results;
}

现在我们可以测试模型了。因此,我们获取测试数据,对其进行标记化和矢量化,然后调用 predictProba 方法来检查输出。最后,我们可以使用一些评估指标(如 AUC)来评估性能。在这种特殊情况下,AUC 为 0.89,对于该数据集来说,这是相当好的性能。

学习信息检索排序

学习排序是一系列处理排序数据的算法。这个家庭是监督机器学习的一部分;为了对数据进行排序,我们需要知道哪些项目更重要,需要首先显示。

学习排名通常用于构建搜索引擎的环境中;基于一些相关性评估,我们建立了一个模型,试图将相关项目的排名高于不相关的项目。在无监督排序的情况下,例如 TF-IDF 权重上的余弦,我们通常只有一个特征,通过该特征我们对文档进行排序。然而,可能会有更多的特性,我们可能希望将它们包含在模型中,并让它以最佳的方式组合它们。

学习给模型排序有几种类型。其中一些被称为“逐点”——它们被单独应用于每个文档,并与其他训练数据隔离开来考虑。尽管这是一个严格的假设,但这些算法很容易实现,并且在实践中运行良好。通常,这相当于使用分类或回归模型,然后根据分数对项目进行排序。

让我们回到构建搜索引擎的运行示例,并在其中加入更多的特性。以前是无人监管的;我们只是根据一个特征,余弦,对项目进行了排序。但是我们可以增加更多的特征,使之成为一个监督学习的问题。

然而,为此,我们需要知道标签。我们已经有了肯定的标签:对于一个查询,我们知道大约 30 个相关的文档。但是我们不知道负面标签:我们使用的搜索引擎只返回相关的页面。所以我们需要得到反面的例子,然后就有可能训练一个二进制分类器来区分相关和不相关的页面。

有一种技术我们可以用来获得负数据,它被称为负抽样。这种想法是基于这样一种假设,即语料库中的大多数文档是不相关的,因此如果我们从那里随机抽取一些文档并说它们是不相关的,那么我们在大多数情况下都是正确的。如果一个被采样的文档变得相关,那么不会发生任何不好的事情;这只是一个嘈杂的观察,不应该影响整体结果。

因此,我们采取以下措施:

  • 首先,我们读取排名数据,并根据查询对文档进行分组
  • 然后,我们将查询分成两个不重叠的组:一个用于训练,一个用于验证
  • 接下来,在每个组中,我们进行一个查询并随机抽取 9 个负面例子。来自否定查询的这些 URL 被分配了否定标签
  • 最后,我们基于这些标记的文档/查询对训练一个模型

在阴性取样步骤中,重要的是为了训练,我们不从验证组中取阴性样本,反之亦然。如果我们只在训练/验证组中采样,那么我们可以确定我们的模型可以很好地推广到看不见的查询和文档。

负采样很容易实现,所以让我们开始吧:

private static List<String> negativeSampling(String positive, List<String> collection,
                int size, Random rnd) {
    Set<String> others = new HashSet<>(collection);
    others.remove(positive);
    List<String> othersList = new ArrayList<>(others);
    Collections.shuffle(othersList, rnd);
    return othersList.subList(0, size);
}

想法如下:首先,我们得到整个查询集合,并删除我们当前正在考虑的一个。然后,我们洗牌并从中挑选前 N 个。

既然我们有正面和负面的例子,我们需要提取特征,我们将把这些特征放入模型中。让我们创建一个QueryDocumentPair类,它将包含关于用户查询的信息以及关于文档的数据:

public class QueryDocumentPair {
    private final String query;
    private final String url;
    private final String title;
    private final String bodyText;
    private final String allHeaders;
    private final String h1;
    private final String h2;
    private final String h3;
    // getters and constructor omitted
}

这个类的对象可以通过用 JSoup 解析 HTML 内容并提取标题、正文、所有标题文本(h1-h6)以及 h1、h2、h3 标题来创建。

我们将使用这些字段来计算特征。

例如,我们可以计算以下各项:

  • 查询和所有其他文本字段之间的单词包 TF-IDF 相似度
  • 查询和所有其他文本字段之间的 LSA 相似性
  • 嵌入查询和标题以及 h1、h2 和 h3 标题之间的相似性。

我们已经知道如何计算前两种类型的特征:

  • 我们使用CountVectorizer分别对每个字段进行矢量化,并使用转换方法对查询进行矢量化
  • 对于 LSA,我们以同样的方式使用TruncatedSVD类;我们在文本字段上训练它,然后将其应用于查询
  • 然后,我们在单词袋和 LSA 空间中计算文本字段和查询之间的余弦相似度

然而,我们没有在这里讨论最后一个,使用单词嵌入。想法如下:

  • 对于查询,获取每个令牌的向量,并将它们放入一个矩阵中
  • 对于标题(或其他文本字段),执行相同的操作
  • 通过矩阵乘法计算每个查询向量与每个标题向量的相似度
  • 查看相似性的分布,并获取该分布的一些特征,如最小值、平均值、最大值和标准偏差。我们可以使用这些值作为特征
  • 此外,我们可以获取平均查询向量和平均标题向量,并计算它们之间的相似性

让我们实现这一点。首先,创建一个方法来获取令牌集合的向量:

public static double[][] wordsToVec(WordEmbeddings we, List<String> tokens) {
    List<double[]> vectors = new ArrayList<>(tokens.size());
    for (String token : tokens) {
        Optional<double[]> vector = we.representation(token);
        if (vector.isPresent()) {
            vectors.add(vector.get());
        }
    }

    int nrows = vectors.size();
    double[][] result = new double[nrows][];
    for (int i = 0; i < nrows; i++) {
        result[i] = vectors.get(i);
    }

    return result;
}

这里,我们使用之前创建的WordsEmbeddings类,然后对于每个令牌,我们查找它的表示,如果它存在,我们就把它放入一个矩阵中。

然后,获得所有相似性只是两个嵌入矩阵的乘法运算:

private static double[] similarities(double[][] m1, double[][] m2) {
    DenseMatrix M1 = new DenseMatrix(m1);
    DenseMatrix M2 = new DenseMatrix(m2);
    DenseMatrix M1M2 = new DenseMatrix(M1.numRows(), M2.numRows());
    M1.transBmult(M2, M1M2);
    return M1M2.getData();
}

众所周知,MTJ 将矩阵的值按列存储在一维数据数组中,之前,我们将其转换为二维数组。在这种情况下,我们并不真的需要这样做,所以我们按原样取这些值。

现在,给定一个查询列表和一个来自其他字段(例如,title)的标记列表,我们计算分布特征:

int size = query.size();

List<Double> mins = new ArrayList<>(size);
List<Double> means = new ArrayList<>(size);
List<Double> maxs = new ArrayList<>(size);
List<Double> stds = new ArrayList<>(size);

for (int i = 0; i < size; i++) {
    double[][] queryEmbed = wordsToVec(glove, query.get(i));
    double[][] textEmbed = wordsToVec(glove, text.get(i));
    double[] similarities = similarities(queryEmbed, textEmbed);

    DescriptiveStatistics stats = new DescriptiveStatistics(similarities);
    mins.add(stats.getMin());
    means.add(stats.getMean());
    maxs.add(stats.getMax());
    stds.add(stats.getStandardDeviation());
}

当然,在这里我们可以添加更多的特性,比如 25 或 75 个百分点,但是现在这四个特性已经足够了。请注意,有时 queryEmbed 或 textEmbed 可以为空,我们需要通过向每个列表添加多个NaN实例来处理这种情况。

我们还提到了另一个有用的特征,平均向量之间的相似性。我们以类似的方式计算:

List<Double> avgCos = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
    double[] avgQuery = averageVector(wordsToVec(glove, query.get(i)));
    double[] avgText = averageVector(wordsToVec(glove, text.get(i)));
    avgCos.add(dot(avgQuery, avgText));
}

这里,点是两个向量的内积,averageVector是这样实现的:

private static double[] averageVector(double[][] rows) {
    ArrayRealVector acc = new ArrayRealVector(rows[0], true);
    for (int i = 1; i < rows.length; i++) {
        ArrayRealVector vec = new ArrayRealVector(rows[0], false);
        acc.combineToSelf(1.0, 1.0, vec);
    }

    double norm = acc.getNorm();
    acc.mapDivideToSelf(norm);
    return acc.getDataRef();
}

一旦我们计算了所有这些特征,我们就可以把它们放入一个 doubles 数组中,并用它来训练一个分类器。有许多可能的型号可供我们选择。

例如,我们可以使用 Smile 中的随机森林分类器:通常,基于树的方法非常善于发现特征之间的复杂交互,这些方法对于学习任务排序非常有效。

还有一件事我们还没有讨论:如何评价排名结果。对于排名模型有特殊的评估指标,如平均精度 ( )或归一化贴现累计增益 ( NDCG ),但对于我们目前的情况 AUC 绰绰有余。回想一下,对 AUC 的一种可能解释是,它对应于随机选择的阳性样本的排名高于随机选择的阴性样本的概率。

因此,AUC 非常适合这项任务,在我们的实验中,随机森林模型实现了 98%的 AUC。在这一节中,我们省略了一些代码,但是和往常一样,完整的代码可以在代码包中找到,您可以更详细地浏览特征提取管道。

用 Lucene 重新排序

在这一章中,我们已经提到 Lucene 是可以定制的,我们已经了解了如何在 Lucene 之外进行预处理,然后将结果无缝集成到 Lucene 工作流中。

当涉及到对搜索结果重新排序时,情况或多或少是相似的。常见的方法是按原样获取 Lucene 排名,并检索前 100 个(或更多)结果。然后,我们获取这些已经检索到的文档,并将排序模型应用于此以进行重新排序。

如果我们有这样一个重新排序的模型,我们需要确保我们存储了所有用于训练的数据。在我们的例子中,它是一个QueryDocumentPair类,我们从中提取相关性特征。所以让我们创建一个索引:

FSDirectory directory = FSDirectory.open(index);
WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(analyzer));

List<HtmlDocument> docs = // read documents for indexing

for (HtmlDocument htmlDoc : docs.) {
    String url, title, bodyText, ... // extract the field values
    Document doc = new Document();
    doc.add(new Field("url", url, URL_FIELD));
    doc.add(new Field("title", title, TEXT_FIELD));
    doc.add(new Field("bodyText", bodyText, TEXT_FIELD));
    doc.add(new Field("allHeaders", allHeaders, TEXT_FIELD));
    doc.add(new Field("h1", h1, TEXT_FIELD));
    doc.add(new Field("h2", h2, TEXT_FIELD));
    doc.add(new Field("h3", h3, TEXT_FIELD));

    writer.addDocument(doc);
}

writer.commit();
writer.close();
directory.close();

在这段代码中,HtmlDocument是一个存储文档细节的类——它们的标题、正文、标题等等。我们遍历所有的文档,并将这些信息放入 Lucene 的索引中。

在本例中,所有字段都被存储,因为稍后在查询时,我们将需要检索这些值并使用它们来计算特性。

这样,索引就建立起来了,现在,让我们来查询它:

RandomForest rf = load("project/random-forest-model.bin");

FSDirectory directory = FSDirectory.open(index);
DirectoryReader reader = DirectoryReader.open(directory);
IndexSearcher searcher = new IndexSearcher(reader);

WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
AnalyzingQueryParser parser = new AnalyzingQueryParser("bodyText", analyzer);

String userQuery = "cheap used cars";
Query query = parser.parse(userQuery);

TopDocs result = searcher.search(query, 100);

List<QueryDocumentPair> data = wrapResults(userQuery, searcher, result);
double[][] matrix = extractFeatures(data);
double[] probs = predict(rf, matrix);

List<ScoredIndex> scored = wrapAsScoredIndex(probs);
for (ScoredIndex idx : scored) {
    QueryDocumentPair doc = data.get(idx.getIndex());
    System.out.printf("%.4f: %s, %s%n", idx.getScore(), doc.getTitle(), doc.getUrl());
}

在这段代码中,我们首先读取之前训练和保存的模型,然后读取索引。接下来,用户给出一个查询,我们解析它,并从索引中检索前 100 个结果。我们需要的所有值都存储在索引中,所以我们获取它们并将它们放入QueryDocumentPair+——这是在wrapResults方法中发生的事情。然后,我们提取特征,应用随机森林模型,并在将结果呈现给用户之前,使用分数对结果进行重新排序。

在特征提取步骤,遵循我们用于训练的完全相同的程序是非常重要的。否则,模型结果可能是无意义的或误导的。实现这一点的最佳方法是创建一个特殊的方法来提取特征,并在训练模型和查询时使用它。如果你需要返回 100 个以上的结果,你可以对 Lucene 返回的前 100 个条目进行重新排序,但是对 100 个以上的条目保持原来的顺序。实际上,用户很少会超过第一页,所以到达第 100 个条目的可能性很小,所以我们通常不需要麻烦地在那里重新排序文档。

让我们仔细看看wrapResults方法的内容:

List<QueryDocumentPair> data = new ArrayList<>();

for (ScoreDoc scored : result.scoreDocs) {
    int docId = scored.doc;
    Document doc = searcher.doc(docId);

    String url = doc.get("url");
    String title = doc.get("title");
    String bodyText = doc.get("bodyText");
    String allHeaders = doc.get("allHeaders");
    String h1 = doc.get("h1");
    String h2 = doc.get("h2");
    String h3 = doc.get("h3");

    QueryDocumentPair pair = new QueryDocumentPair(userQuery, 
            url, title, bodyText, allHeaders, h1, h2, h3);
    data.add(pair);
}

因为所有的字段都被存储了,所以我们可以从索引中获取它们并构建QueryDocumentPair对象。然后,我们只需应用完全相同的程序进行特征提取,并将它们放入我们的模型中。

这样,我们就创建了一个基于 Lucene 的搜索引擎,然后使用机器学习模型对查询结果进行重新排序。还有很大的进一步改进空间:可以添加更多功能或获得更多训练数据,也可以尝试使用不同的模型。在下一章,我们将讨论 XGBoost,它也可以用于学习任务排序。

摘要

在这一章中,我们涵盖了信息检索和自然语言处理领域的许多基础知识,包括信息检索的基础知识以及如何将机器学习应用于文本。在这样做的时候,我们首先实现了一个简单的搜索引擎,然后在 Apache Lucene 的基础上使用了一种学习排序的方法来实现一个工业级的 IR 模型。

在下一章,我们将看看梯度提升机器,以及 XGBoost,这种算法的一种实现。这个库为许多数据科学问题提供了最先进的性能,包括分类、回归和排序。

七、极限梯度提升

到目前为止,我们应该已经非常熟悉 Java 中的机器学习和数据科学:我们已经讨论了监督学习和非监督学习,还考虑了机器学习在文本数据中的应用。

在这一章中,我们继续讨论监督机器学习,并将讨论一个在许多监督任务中提供最先进性能的库:XGBoost 和极限梯度提升。我们将会看到一些熟悉的问题,比如预测一个 URL 是否在第一页排名,性能预测,以及搜索引擎的排名,但是这次我们将使用 XGBoost 来解决这个问题。

本章的大纲如下:

  • 梯度增压机和 XGBoost
  • 安装 XGBoost
  • 分类的 XGBoost
  • XGBoost 用于回归
  • XGBoost 用于学习排名

在本章结束时,您将学习如何从源代码构建 XGBoost,并使用它来解决数据科学问题。

梯度增压机和 XGBoost

梯度提升机 ( GBM )是一种集合算法。GBM 背后的主要思想是采用一些基本模型,然后将这个模型一遍又一遍地与数据相适应,从而逐步提高性能。它与随机森林模型不同,因为 GBM 试图在每一步改进结果,而随机森林建立多个独立的模型并取其平均值。

GBM 背后的主要思想可以用一个线性回归的例子来很好地说明。要对数据进行几个线性回归,我们可以执行以下操作:

  1. 将基础模型与原始数据进行拟合。
  2. 取目标值和第一个模型的预测值之间的差(我们称之为步骤 1 的残差),并用它来训练第二个模型。
  3. 取步骤 1 的残差和步骤 2 的预测之间的差(这是步骤 2 的残差)并拟合第三个模型。
  4. 继续,直到你训练出 N 个模型。
  5. 对于预测,将所有单个模型的预测相加。

因此,正如你所看到的,在算法的每一步,模型都试图改善前一步的结果,到最后,它会将所有模型合并到最终的模型中。

本质上,任何模型都可以作为基础模型,不仅仅是线性回归。例如,它可以是逻辑回归或决策树。通常,基于树的模型是非常好的,并且在各种问题上表现出优异的性能。当我们在 GBM 中使用树时,整个模型通常被称为梯度增强树,根据树的类型,它可以是梯度增强回归树梯度增强分类树

极限梯度提升,简称 XGBoost ,或 XGB ,是梯度提升机器的一种实现,它提供了一些基础模型,包括决策树。基于树的 XGBoost 模型非常强大:它们不对数据集及其特性中的值的分布做任何假设,它们自然地处理丢失的值,
,并且它们非常快,可以有效地利用所有可用的 CPU。

XGBoost 可以达到优秀的性能,可以从数据中尽可能的压榨。如果你知道举办数据科学竞赛的网站https://www.kaggle.com/,那么你可能已经听说过 XGBoost。这是获胜者在他们的解决方案中经常使用的库。当然,它在 Kaggle 之外的表现也一样好,并帮助了许多数据科学家的日常工作。

这个库最初是用 C++编写的,但是也有其他语言的绑定,比如 R 和 Python。最近,Java 的一个包装器也被创建了,在这一章中,我们将看到如何在我们的 Java 应用程序中使用它。这个包装器库叫做 XGBoost4j,,它是通过 Java 本地接口 ( JNI )绑定实现的,所以它在底层使用 C++。但是在我们使用它之前,我们需要能够构建和安装它。现在我们来看看如何做到这一点。

安装 XGBoost

正如我们已经提到的,XGBoost 是用 C++编写的,有一个 Java 库允许通过 JNI 在 Java 中使用 XGBoost。不幸的是,在撰写本文时,XGBoost4J 在 Maven Central 上不可用,这意味着它需要在本地构建,然后发布到本地 Maven 存储库。有计划将这个库发布到中央存储库,你可以在 https://github.com/dmlc/xgboost/issues/1807 看到进展。

即使当它发布到 Maven Central 时,知道如何构建它以获得具有最新更改和错误修复的最新版本仍然是有用的。因此,让我们看看如何构建 XGBoost 库本身,然后如何为它构建 Java 包装器。关于这一点,你可以遵循 https://xgboost.readthedocs.io/en/latest/build.html 的官方指示,这里我们将给出一个非官方的总结。

XGBoost 主要针对 Linux 系统,所以在 Linux 上构建它是很简单的:

git clone --recursive https://github.com/dmlc/xgboost
cd xgboost
make -j4

通过执行前面的命令,我们安装了基本的 XGBoost 库,但是现在我们需要安装 XGBoost4J 绑定。为此,请执行以下步骤:

  • 首先,确保设置了JAVA_HOME环境变量,并且它指向您的 JDK
  • 然后,转到jvm-packages目录
  • 最后,在这里运行mvn -DskipTests install

最后一个命令构建 XGBoost4J JNI 绑定,然后编译 Java 代码并将所有内容发布到本地 Maven 存储库。

现在,为了在我们的项目中使用 XGBoost4J,我们需要做的就是包含以下依赖项:

<dependency>
  <groupId>ml.dmlc</groupId>
  <artifactId>xgboost4j</artifactId>
  <version>0.7</version>
</dependency>

OS X 的安装过程非常相似。然而,当涉及到 Windows 时,情况就更复杂了。

要为 Windows 构建它,我们需要首先从 https://sourceforge.net/projects/mingw-w64/下载 64 位 GCC 编译器。安装时,选择x86_64架构而不是i686很重要,因为 XGBoost 只支持 64 位平台。如果由于某种原因,安装程序不工作,我们可以直接从https://goo.gl/CVcb8d下载带有二进制文件的x86_64-6.2.0-release-posix-seh-rt_v5-rev1.7z档案,然后解压。

在 Windows 上构建 XGBoost 时,避免在目录名中使用空格是很重要的。因此,最好在根目录下创建一个文件夹,例如C:/soft,并从那里执行所有的安装。

接下来,我们克隆 XGBoost 并make它。这里我们假设您使用 Git Windows 控制台:

git clone --recursive https://github.com/dmlc/xgboost
PATH=/c/soft/mingw64/bin/:$PATH
alias make='mingw32-make'
cp make/mingw64.mk config.mk
make -j4

最后,我们需要构建 XGBoost4J JNI 二进制文件。你需要 JDK 的内容。但是,在 Windows 中有一个问题:默认情况下,JDK 安装到Program Files文件夹中,其中有一个空格,这将在安装过程中导致问题。一个可能的解决方案是将 JDK 复制到其他地方。

完成这些之后,我们就可以开始构建库了:

export JAVA_HOME=/c/soft/jdk1.8.0_102
make jvm
cd jvm-packages
mvn -DskipTests install

如果您的 Maven 抱怨样式并中止构建,您可以通过传递-Dcheckstyle.skip标志来禁用它:

mvn -DskipTests -Dcheckstyle.skip install

成功执行这一步后,XGBoost4J 库应该发布到本地 maven 存储库中,我们可以使用之前使用的依赖项。

要测试库是否构建正确,请尝试执行这行代码:

Class<Booster> boosterClass = Booster.class;

如果您看到代码正确终止,那么您就准备好了。然而,如果你得到类似于xgboost4j.dll: Can't find dependent libraries的消息UnsatisfiedLinkError,那么确保mingw64/bin文件夹在系统变量PATH上。

实践中的 XGBoost

在我们成功地构建并安装了这个库之后,我们可以用它来创建机器学习模型,在这一章中,我们将讨论三种情况:二进制分类、回归和学习对模型进行排序。我们还将讨论熟悉的用例:预测一个 URL 是否在第一页或搜索引擎结果中,预测计算机的性能,以及为我们自己的搜索引擎排名。

分类的 XGBoost

现在让我们最后用它来解决一个分类问题!
第四章监督学习-分类和回归中,我们试图预测一个 URL 是否有可能出现在搜索结果的第一页。之前,我们创建了一个特殊的对象来保存这些特性:

public class RankedPage {
    private String url;
    private int position;
    private int page;
    private int titleLength;
    private int bodyContentLength;
    private boolean queryInTitle;
    private int numberOfHeaders;
    private int numberOfLinks;
    public boolean isHttps();
    public boolean isComDomain();
    public boolean isOrgDomain();
    public boolean isNetDomain();
    public int getNumberOfSlashes();
}

如您所见,有许多功能,但没有一个真正涉及到文本。如果你还记得的话,有了这些特性,我们在一个测试集上实现了大约 0.58 的 AUC。
首先,让我们试着用 XGBoost 重现这个结果。因为这是一个二元分类,我们将目标参数设置为binary:logistic,由于我们上次使用 AUC 进行评估,我们将坚持这一选择,并将eval_metric设置为auc。我们通过地图设置参数:

Map<String, Object> params = new HashMap<>();
params.put("objective", "binary:logistic");
params.put("eval_metric", "logloss");
params.put("nthread", 8);
params.put("seed", 42);
params.put("silent", 1);

// default values:
params.put("eta", 0.3);
params.put("gamma", 0);
params.put("max_depth", 6);
params.put("min_child_weight", 1);
params.put("max_delta_step", 0);
params.put("subsample", 1);
params.put("colsample_bytree", 1);
params.put("colsample_bylevel", 1);
params.put("lambda", 1);
params.put("alpha", 0);
params.put("tree_method", "approx");

这里,除了物镜、eval_metricnthreadseedsilent之外,大多数参数都被设置为默认值。

如您所见,XGBoost 是梯度增强机器算法的一个非常可配置的实现,有许多参数我们可以更改。我们不会在这里包括所有的参数;完整列表可以参考https://github . com/dmlc/xgboost/blob/master/doc/parameter . MD的官方文档。在本章中,我们将只使用基于树的方法,所以让我们回顾一下它们的一些参数:

| 参数名称 | 范围 | 描述 |
| nthread | 1 及以上 | 这是构建树时要使用的线程数 |
| eta | 从 0 到 1 | 这是集合中每个模型的权重 |
| max_depth | 1 及以上 | 这是每棵树的最大深度 |
| min_child_weight | 1 及以上 | 这是每片叶子的最小观察值 |
| subsample | 从 0 到 1 | 这是每一步要用的观察值的一部分 |
| colsample_bytree | 从 0 到 1 | 这是每一步要使用的功能的一部分 |
| objective | | 这定义了任务(回归或分类) |
| eval_metric | | 这是任务的评估指标 |
| seed | 整数 | 这为再现性埋下了种子 |
| silent | 0 或 1 | 这里,1 在训练期间关闭调试输出 |

然后,我们读取数据并创建训练集、验证集和测试集。我们已经为此准备了特殊的函数,我们也将在这里使用:

Dataset dataset = readData();

Split split = dataset.trainTestSplit(0.2);
Dataset trainFull = split.getTrain();
Dataset test = split.getTest();

Split trainSplit = trainFull.trainTestSplit(0.2);
Dataset train = trainSplit.getTrain();
Dataset val = trainSplit.getTest();

之前,我们将标准化(或 Z 分数转换)应用于我们的数据。对于基于树的算法,包括 XGBoost,这是不需要的:这些方法对这种单调变换不敏感,所以我们可以跳过这一步。

接下来,我们需要将数据集包装成 XGBoost 的内部格式:DMatrix。让我们为此创建一个实用方法:

public static DMatrix wrapData(Dataset data) throws XGBoostError {
    int nrow = data.length();
    double[][] X = data.getX();
    double[] y = data.getY();

    List<LabeledPoint> points = new ArrayList<>();

    for (int i = 0; i < nrow; i++) {
        float label = (float) y[i];
        float[] floatRow = asFloat(X[i]);
        LabeledPoint point = LabeledPoint.fromDenseVector(label, floatRow);
        points.add(point);
    }

    String cacheInfo = "";
    return new DMatrix(points.iterator(), cacheInfo);
}

现在我们可以用它来包装数据集:

DMatrix dtrain = XgbUtils.wrapData(train);
DMatrix dval = XgbUtils.wrapData(val);

XGBoost 为我们提供了一种便捷的方式,通过所谓的监视列表来监控模型的性能。本质上,这类似于学习曲线,我们可以看到评估指标在每一步是如何发展的。如果在培训过程中,我们看到培训和评估指标的值明显不同,那么这可能表明我们可能会过度适应。同样,如果在某个步骤中,验证指标开始下降,而训练指标值持续增加,那么我们会过度拟合。

观察列表是通过映射定义的,在映射中,我们将某个名称与我们感兴趣的每个数据集相关联:

Map<String, DMatrix> watches = ImmutableMap.of("train", dtrain, "val", dval);

现在我们准备训练一个 XGBoost 模型:

int nrounds = 30;
IObjective obj = null;
IEvaluation eval = null;
Booster model = XGBoost.train(dtrain, params, nrounds, watches, obj, eval);

可以在 XGBoost 中提供定制的目标和评估函数,但是因为我们只使用标准的,所以这些参数被设置为 null。

正如我们所讨论的,可以通过观察列表来监控训练过程,这是您将在训练过程中看到的内容:在每一步,它将对所提供的数据集计算评估函数,并将值输出到控制台:

[0]    train-auc:0.735058    val-auc:0.533165
[1]    train-auc:0.804517    val-auc:0.576641
[2]    train-auc:0.842617    val-auc:0.561298
[3]    train-auc:0.860178    val-auc:0.567264
[4]    train-auc:0.875294    val-auc:0.570171
[5]    train-auc:0.888918    val-auc:0.575836
[6]    train-auc:0.896271    val-auc:0.573969
[7]    train-auc:0.904762    val-auc:0.577094
[8]    train-auc:0.907462    val-auc:0.580005
[9]    train-auc:0.911556    val-auc:0.580033
[10]    train-auc:0.922488    val-auc:0.575021
[11]    train-auc:0.929859    val-auc:0.579274
[12]    train-auc:0.934084    val-auc:0.580852
[13]    train-auc:0.941198    val-auc:0.577722
[14]    train-auc:0.951749    val-auc:0.582231
[15]    train-auc:0.952837    val-auc:0.579925

如果在训练期间,我们想要构建大量的树,那么消化从控制台输出的文本是很困难的,而可视化这些曲线通常会有所帮助。然而,在我们的例子中,我们只有 30 次迭代,所以可以对性能做出一些判断。如果我们仔细观察,我们可能会注意到,在步骤 8 中,验证分数停止增加,而训练分数仍然越来越好。我们可以由此得出的结论是,在某个点上,它开始过度拟合。为了避免这种情况,我们在进行预测时只能使用前九棵树:

boolean outputMargin = true;
int treeLimit = 9;
float[][] res = model.predict(dval, outputMargin, treeLimit);

请注意两点:

  • 如果我们将outputMargin设置为 false,那么将返回未标准化的值,而不是概率。将其设置为 true 会将逻辑转换应用于值,并确保结果看起来像概率。
  • 结果是一个二维的 floats 数组,而不是一维的 doubles 数组。

让我们写一个将这些结果转换成双精度的效用函数:

public static double[] unwrapToDouble(float[][] floatResults) {
    int n = floatResults.length;
    double[] result = new double[n];
    for (int i = 0; i < n; i++) {
        result[i] = floatResults[i][0];
    }
    return result;
}

现在,我们可以使用之前开发的其他方法,例如,检查 AUC 的方法:

double[] predict = XgbUtils.unwrapToDouble(res);
double auc = Metrics.auc(val.getY(), predict);
System.out.printf("auc: %.4f%n", auc);

如果我们没有在 predict 中指定树的数量,那么默认情况下,它会使用所有可用的树并执行值的规范化:

float[][] res = model.predict(dval);
double[] predict = unwrapToDouble(res);
double auc = Metrics.auc(val.getY(), predict);
System.out.printf("auc: %.4f%n", auc);

在前面的章节中,我们已经为 K-Fold 交叉验证创建了一些代码。我们也可以在这里使用它:

int numFolds = 3;
List<Split> kfold = trainFull.kfold(numFolds);
double aucs = 0;

for (Split cvSplit : kfold) {
    DMatrix dtrain = XgbUtils.wrapData(cvSplit.getTrain());

    Dataset validation = cvSplit.getTest();
    DMatrix dval = XgbUtils.wrapData(validation);
    Map<String, DMatrix> watches = ImmutableMap.of("train", dtrain, "val", dval);

    Booster model = XGBoost.train(dtrain, params, nrounds, watches, obj, eval);
    float[][] res = model.predict(dval);
    double[] predict = unwrapToDouble(res);

    double auc = Metrics.auc(validation.getY(), predict);
    System.out.printf("fold auc: %.4f%n", auc);
    aucs = aucs + auc;
}

aucs = aucs / numFolds;
System.out.printf("cv auc: %.4f%n", aucs);

然而,XGBoost 具有执行交叉验证的内置功能:我们所需要做的就是提供DMatrix,然后它将分割数据并自动运行评估。下面是我们如何使用它:

DMatrix dtrainfull = wrapData(trainFull);
int nfold = 3;
String[] metric = {"auc"};
XGBoost.crossValidation(dtrainfull, params, nrounds, nfold, metric, obj, eval);

我们将看到以下评估日志:

[0]    cv-test-auc:0.556261    cv-train-auc:0.714733
[1]    cv-test-auc:0.578281    cv-train-auc:0.762113
[2]    cv-test-auc:0.584887    cv-train-auc:0.792096
[3]    cv-test-auc:0.592273    cv-train-auc:0.824534
[4]    cv-test-auc:0.593516    cv-train-auc:0.841793
[5]    cv-test-auc:0.593855    cv-train-auc:0.856439
[6]    cv-test-auc:0.593967    cv-train-auc:0.875119
[7]    cv-test-auc:0.588910    cv-train-auc:0.887434
[8]    cv-test-auc:0.592887    cv-train-auc:0.897417
[9]    cv-test-auc:0.589738    cv-train-auc:0.906296
[10]   cv-test-auc:0.588782    cv-train-auc:0.915271
[11]   cv-test-auc:0.586081    cv-train-auc:0.924716
[12]   cv-test-auc:0.586461    cv-train-auc:0.935201
[13]   cv-test-auc:0.584988    cv-train-auc:0.940725
[14]   cv-test-auc:0.586363    cv-train-auc:0.945656
[15]   cv-test-auc:0.585908    cv-train-auc:0.951073

在我们选择了最佳参数(本例中为树的数量)后,我们可以对整个训练数据部分重新训练模型,然后在测试中对其进行评估:

int bestNRounds = 9;
Map<String, DMatring> watches = Collections.singletonMap("dtrainfull", dtrainfull);

Booster model = XGBoost.train(dtrainfull, params, bestNRounds, watches, obj, eval);

DMatrix dtest = XgbUtils.wrapData(test);
float[][] res = model.predict(dtest);
double[] predict = XgbUtils.unwrapToDouble(res);

double auc = Metrics.auc(test.getY(), predict);
System.out.printf("final auc: %.4f%n", auc);

最后,我们可以保存模型并在以后使用它:

Path path = Paths.get("xgboost.bin");
try (OutputStream os = Files.newOutputStream(path)) {
    model.saveModel(os);
}

读取保存的模型也很简单:

Path path = Paths.get("xgboost.bin");
try (InputStream is = Files.newInputStream(path)) {
     Booster model = XGBoost.loadModel(is);
}

这些模型与其他 XGBoost 绑定兼容。因此,我们可以用 Python 或 R 训练一个模型,然后将其导入 Java——或者反过来。

这里,我们仅使用默认参数,这些参数通常并不理想。让我们看看如何修改它们以获得最佳性能。

参数调谐

到目前为止,我们已经讨论了使用 XGBoost 执行交叉验证的三种方法:保留数据集、手动 K 折叠和 XGBoost K 折叠。这些方法中的任何一种都可以用来选择最佳性能。

来自 XGBoost 的实现通常更适合这个任务,因为它们可以显示每一步的性能,并且一旦发现学习曲线偏离太多,可以手动停止训练过程。

如果您的数据集相对较大(例如,超过 100k 个示例),那么简单地选择一个拒绝的数据集可能是最好和最快的选择。另一方面,如果您的数据集较小,执行 K -Fold 交叉验证可能是个好主意。

一旦我们决定了验证方案,我们就可以开始调整模型了。由于 XGBoost 有很多参数,这是一个相当复杂的问题,因为尝试所有可能的组合在计算上是不可行的。然而,有一些方法可能有助于获得相当好的性能。

一般的方法是一次改变一个参数,然后使用观察列表运行训练过程。这样做时,我们会密切监视验证值,并记录最大值。最后,我们选择给出最佳验证性能的参数组合。如果两个组合提供了可比较的性能,那么我们应该选择更简单的一个(例如,深度较浅,树叶中有更多实例,等等)。

下面是一个调整参数的算法:

  1. 首先,为树的数量选择一个非常大的值,比如 2,000 或 3,000。当您看到验证分数停止增长或开始下降时,千万不要增长所有这些树并停止训练过程。
  2. 采用默认参数,一次更改一个参数。
  3. 如果你的数据集很小,在开始时选择一个更小的eta可能是有意义的,例如 0.1。如果数据集足够大,那么默认值就可以了。
  4. 首先,我们调整depth参数。使用默认值(6)训练模型,然后尝试使用小值(3)和大值(10)。根据哪个表现更好,往合适的方向走。
  5. 一旦确定了树的深度,尝试改变subsample参数。首先,尝试默认值(1),然后尝试将其减少到 0.8、0.6 和 0.4,然后将其移动到适当的方向。通常,0.6-0.7 左右的值相当好。
  6. 接下来,调colsample_bytree。该方法与二次抽样的方法相同,0.6-0.7 左右的值也能很好地工作。
  7. 现在,我们调整min_child_weight.你可以尝试 10,20,50 这样的值,然后移动到合适的方向。
  8. 最后,将eta设置为某个小值(比如 0.01、0.05 或 0.1,这取决于数据集的大小),并查看验证性能停止增长的迭代次数。使用此数字选择最终模型的迭代次数。

有其他方法可以做到这一点。例如:

  • depth初始化为 10,eta初始化为 0.1,min_child_weight初始化为 5
  • 如前所述,首先通过尝试更小和更大的值来找到最佳的depth
  • 然后,调整subsample参数
  • 之后,调min_child_weight
  • 最后一个要调整的参数是colsample_bytree
  • 最后,我们将eta设置为一个较小的数字,并观察验证性能来选择树的数量

这些都是简单的试探法,不涉及许多可用的参数,但它们仍然可以给出一个相当好的模型。您还可以调整正则化参数,如gammaalphabeta。例如,对于较高的depth值(大于 10),您可能希望稍微增加gamma参数,看看会发生什么。

不幸的是,这些算法都不能 100%保证找到最佳解决方案,但你应该尝试一下,找到你个人最喜欢的一个——它可能是这些算法的组合,甚至可能是完全不同的。

如果您没有大量数据,并且不想手动调整参数,那么可以尝试随机设置参数,重复多次,并基于交叉验证选择最佳模型。这被称为随机搜索参数优化:它不需要手动调整,并且在实践中通常工作良好。

开始的时候可能看起来很困难,所以不要担心。在做了几次之后,你会对这些参数如何相互依赖以及什么是调整它们的最佳方式有一些直觉。

文本特征

在前一章中,我们学习了很多可以应用于文本数据的东西,并在构建搜索引擎时使用了一些想法。让我们将这些特征纳入我们的模型,看看我们的 AUC 如何变化。

回想一下,我们之前创建了这些功能:

  • 查询和文档的文本字段(如标题、正文内容以及 h1、h2 和 h3 标题)之间的 TF-IDF 空间中的余弦相似性
  • 查询和所有其他文本字段之间的 LSA 相似性

我们在上一章中也使用了手套功能,但是我们在这里将跳过它们。此外,我们不会在本章中包括前面功能的实现。有关如何操作的信息,请参考第 6 章使用文本-自然语言处理和信息检索

一旦我们添加了特性,我们就可以对参数进行一些调整了。例如,我们最终可以使用这些参数:

Map<String, Object> params = XgbUtils.defaultParams();
params.put("eval_metric", "auc");
params.put("colsample_bytree", 0.5);
params.put("max_depth", 3);
params.put("min_child_weight", 30);
params.put("subsample", 0.7);
params.put("eta", 0.01);

这里,XgbUtils.defaultParams()是一个 helper 函数,它创建一个 map,其中的一些参数设置为它们的默认值,然后我们可以修改其中的一些参数。例如,由于性能不是很好,这里很容易过度拟合,我们生长深度为 3 的较小的树,并要求在叶节点中至少有 30 个观察值。最后,我们将学习率参数eta设置为一个小值,因为数据集不是很大。

有了这些特征,我们现在可以实现 64.4%的 AUC。这离好的性能还差得很远,但是比没有任何特性的前一个版本提高了 5%,这是一个相当大的进步。

为了避免重复,我们省略了许多代码。如果你觉得有点迷茫,随时欢迎查看章节的代码包了解详情。

特征重要性

最后,我们还可以看到哪些特性对模型的贡献最大,哪些不太重要,并根据它们的性能对我们的特性进行排序。XGBoost 实现了一个这样的特性的重要度量,称为 FScore,,它是一个特性被模型使用的次数。

要提取 FScore,我们首先需要创建一个特性映射:一个包含特性名称的文件:

List<String> featureNames = columnNames(dataframe);
String fmap = "feature_map.fmap";
try (PrintWriter printWriter = new PrintWriter(fileName)) {
    for (int i = 0; i < featureNames.size(); i++) {
        printWriter.print(i);
        printWriter.print('t');
        printWriter.print(featureNames.get(i));
        printWriter.print('t');
        printWriter.print("q");
        printWriter.println();
    }
}

在这段代码中,我们首先调用函数columnNames(此处不存在),它从 joinery 数据帧中提取列名。然后,我们创建一个文件,在每一行我们首先打印特性名称,然后是一个字母q,这意味着该特性是定量的,而不是一个i指标。

然后,我们调用一个名为getFeatureScore的方法,该方法获取特征映射文件并返回特征在映射中的重要性。得到它后,我们可以根据它们的值对地图条目进行排序,这将产生一个按重要性排序的要素列表:

Map<String, Integer> scores = model.getFeatureScore(fmap);
Comparator<Map.Entry<String, Integer>> byValue = Map.Entry.comparingByValue();
scores.entrySet().stream().sorted(byValue.reversed()).forEach(System.out::println);

对于具有文本特征的分类模型,它将产生以下输出:

numberOfLinks=17
queryBodyLsi=15
queryTitleLsi=14
bodyContentLength=13
numberOfHeaders=10
queryBodySimilarity=10
urlLength=7
queryTitleSimilarity=6
https=3
domainOrg=1
numberOfSlashes=1

我们看到这些新特性对模型非常重要。我们也看到像domainOrgnumberOfSlashes这样的特性很少被使用,我们包含的许多特性甚至不在这个列表中。这意味着我们可以安全地从我们的模型中排除这些特征,并在没有它们的情况下重新训练模型。

FScore 不是基于树的方法唯一可用的特性重要性度量,但是 XGBoost 库只提供这个分数。有一些外部库,比如 XGBFI(https://github.com/Far0n/xgbfi),它们可以使用模型转储来计算增益、加权 FScore 等指标,而且这些分数通常会提供更多信息。

XGBoost 不仅在分类方面很好,在回归方面也很出色。接下来,我们将看到如何使用 XGBoost。

XGBoost 用于回归

梯度提升是一个非常通用的模型:它可以处理分类和回归任务。要使用它来解决回归问题,我们需要做的就是改变目标和评估标准。

对于二进制分类,我们使用了binary:logistic目标,但是对于回归,我们只是将其更改为reg:linear。谈到评估,有以下内置评估指标:

  • 均方根误差(设置eval_metricrmse
  • 平均绝对偏差(设置eval_metricmae

除了这些变化之外,基于树的模型的其他参数是完全相同的!我们可以遵循相同的方法来调整参数,只是现在我们将监视一个不同的指标。

第 4 章监督学习-分类和回归中,我们使用了矩阵乘法性能数据来说明回归问题。让我们再次使用同一个数据集,这次使用 XGBoost 来构建模型。

为了加快速度,我们可以从第五章无监督学习-聚类和降维中获取缩减的数据集。然而,在第 6 章处理文本自然语言处理和信息检索中,我们为 SVD 创建了一个特殊的类:TruncatedSVD。所以,让我们用它来降低这个数据集的维数:

Dataset dataset = ... // read the data
StandardizationPreprocessor preprocessor = StandardizationPreprocessor.train(dataset);
dataset = preprocessor.transform(dataset);

Split trainTestSplit = dataset.shuffleSplit(0.3);
Dataset allTrain = trainTestSplit.getTrain();
Split split = allTrain.trainTestSplit(0.3);
Dataset train = split.getTrain();
Dataset val = split.getTest();

TruncatedSVD svd = new TruncatedSVD(100, false)
svd.fit(train);

train = dimred(train, svd);
val = dimred(val, svd);

你应该还记得第 5 章无监督学习-聚类和降维中的内容,如果我们要通过 SVD 用 PCA 降低数据集的维度,我们需要在此之前标准化数据,下面是我们读取数据后发生的情况。我们进行通常的训练-验证-测试分离,并减少所有数据集的维度。dimred函数只是包装从 SVD 调用transform方法,然后将结果放回一个Dataset类。

现在,让我们使用 XGBoost:

DMatrix dtrain = XgbUtils.wrapData(train);
DMatrix dval = XgbUtils.wrapData(val);
Map<String, DMatrix> watches = ImmutableMap.of("train", dtrain, "val", dval);
IObjective obj = null;
IEvaluation eval = null;

Map<String, Object> params = XgbUtils.defaultParams();
params.put("objective", "reg:linear");
params.put("eval_metric", "rmse");
int nrounds = 100;

Booster model = XGBoost.train(dtrain, params, nrounds, watches, obj, eval);

在这里,我们将数据集包装到DMatrix中,然后创建一个观察列表,最后将objectiveeval_metric参数设置为合适的值。现在我们可以训练模型了。

让我们来看看监视列表的输出(为了简洁起见,我们将只显示每 10 条记录):

[0]    train-rmse:21.223036    val-rmse:18.009176
[9]    train-rmse:3.584128    val-rmse:5.860992
[19]    train-rmse:1.430081    val-rmse:5.104758
[29]    train-rmse:1.117103    val-rmse:5.004717
[39]    train-rmse:0.914069    val-rmse:4.989938
[49]    train-rmse:0.777749    val-rmse:4.982237
[59]    train-rmse:0.667336    val-rmse:4.976982
[69]    train-rmse:0.583321    val-rmse:4.967544
[79]    train-rmse:0.533318    val-rmse:4.969896
[89]    train-rmse:0.476646    val-rmse:4.967906
[99]    train-rmse:0.422991    val-rmse:4.970358

我们可以看到,验证误差在第 50 棵树附近停止下降,然后又开始增加。因此,让我们将模型限制为 50 棵树,并将此模型应用于测试数据:

DMatrix dtrainall = XgbUtils.wrapData(allTrain);
watches = ImmutableMap.of("trainall", dtrainall);
nrounds = 50;
model = XGBoost.train(dtrainall, params, nrounds, watches, obj, eval);

然后,我们可以将该模型应用于测试数据,并查看最终性能:

Dataset test = trainTestSplit.getTest();
double[] predict = XgbUtils.predict(model, test);
double testRmse = rmse(test.getY(), predict);
System.out.printf("test rmse: %.4f%n", testRmse);

这里,XgbUtils.predict将一个数据集转换成DMatrix,然后调用 predict 方法,最后将 floats 数组转换成 doubles。执行代码后,我们将看到以下内容:

test rmse: 4.2573

回想一下,以前它大约是 15,所以使用 XGBoost 比使用线性回归好三倍多!

注意,在原始数据集中有分类变量,当我们使用 One-Hot-Encoding(通过来自 joinery 数据框的toModelMatrix方法)时,得到的矩阵是稀疏的。此外,我们然后用 PCA 压缩这个数据。但是,XGBoost 也可以处理稀疏数据,所以我们用这个例子来说明如何做到这一点。

第 5 章无监督学习——聚类和维度缩减中,我们创建了一个用于执行 One-Hot-Encoding 的类:我们使用它将分类变量从 Smile 转换为SparseDataset类的对象。现在我们可以使用这个方法来创建这样的SparseDataset,然后从它为 XGBoost 构造一个DMatrix对象。

因此,让我们创建一个将SparseDataset转换成DMatrix的方法:

public static DMatrix wrapData(SparseDataset data) {
    int nrow = data.size();
    List<LabeledPoint> points = new ArrayList<>();

    for (int i = 0; i < nrow; i++) {
        Datum<SparseArray> datum = data.get(i);
        float label = (float) datum.y;
        SparseArray array = datum.x;

        int size = array.size();
        int[] indices = new int[size];
        float[] values = new float[size];

        int idx = 0;
        for (Entry e : array) {
            indices[idx] = e.i;
            values[idx] = (float) e.x;
            idx++;
        }

        LabeledPoint point = 
                LabeledPoint.fromSparseVector(label, indices, values);
        points.add(point);
    }

    String cacheInfo = "";
    return new DMatrix(points.iterator(), cacheInfo);
}

这里,代码与我们用于密集矩阵的代码非常相似,但是现在我们调用fromSparseVector工厂方法而不是fromDenseVector。为了使用它,我们将SparseDataset的每一行转换成一个索引数组和值数组,然后用它们创建一个LabeledPoint实例,我们用它来创建一个DMatrix实例。

转换之后,我们在其上运行 XGBoost 模型:

SparseDataset sparse = readData();
DMatrix dfull = XgbUtils.wrapData(sparse);

Map<String, Object> params = XgbUtils.defaultParams();
params.put("objective", "reg:linear");
params.put("eval_metric", "rmse");

int nrounds = 100;
int nfold = 3;
String[] metric = {"rmse"};
XGBoost.crossValidation(dfull, params, nrounds, nfold, metric, null, null);

当我们运行这个时,我们看到 RMSE 达到 17.549534,之后就再也没有下降。这是意料之中的,因为我们只有一小部分功能;这些特征都是绝对的,而且并不是所有的特征都能提供很多信息。不过,这很好地说明了我们如何将 XGBoost 用于稀疏数据集。

除了分类和回归,XGBoost 还为创建排名模型提供了特殊的支持,现在我们将看看如何使用它。

XGBoost 用于学习排名

我们的搜索引擎变得非常强大。以前,我们使用 Lucene 来快速检索文档,然后使用机器学习模型来重新排序它们。通过这样做,我们解决了一个排名问题。在给出一个查询和一组文档之后,我们需要对所有文档进行排序,使得与查询最相关的文档具有最高的等级。

以前,我们将这个问题作为分类来处理:我们建立了一个二元分类模型来分离相关和不相关的文档,并使用文档相关的概率来进行排序。这种方法在实践中相当有效,但是有一个限制:它一次只考虑一个元素,并且将其他文档完全隔离。换句话说,当决定一个文档是否相关时,我们只看这个特定文档的特征,而不看其他文档的特征。

相反,我们可以做的是查看文档相对于彼此的位置。然后,对于每个查询,我们可以形成一个文档组,我们考虑这个特定的查询,并优化所有这些组内的排名。

LambdaMART 是运用这一理念的一款车型的名字。它查看文档对,并考虑文档对中文档的相对顺序。如果顺序是错误的(一个不相关的文档比一个相关的文档排名更高),那么模型引入一个惩罚,并且在训练期间我们想要使这个惩罚尽可能小。

LambdaMART 中的 MART 代表多元可加回归树,所以是基于树的方法。XGBoost 也实现了这个算法。要使用它,我们将目标设置为rank:pairwise,然后将评估标准设置为以下之一:

  • ndcg:表示归一化贴现累计收益
  • ndcg@n:在 N 的 NDCG 是列表的第一个 N 元素,并在上面评估 NDCG
  • map:表示平均精度
  • map@n:这是在每个组的第一个 N 个元素处评估的地图

出于我们的目的,详细了解这些指标做什么并不重要;现在,知道一个指标的值越高越好就足够了。然而,这两种度量之间有一个重要的区别:MAP 只能处理二进制(0/1)标签,而 NDCG 可以处理序数(0,1,2,...)标签。

当我们构建分类器时,我们只有两个标签:阳性(1)和阴性(0)。扩展标签以包括更多相关度可能是有意义的。例如,我们可以按以下方式分配标签:

  • 首先,3 个 URL 的相关性为 3
  • 第一页上的其他 URL 的相关性为 2
  • 第二页和第三页上剩余的相关 URL 的相关性为 1
  • 所有不相关的文档都标有 0

正如我们已经提到的,NDCG 可以处理这样的序数标签,所以我们将使用它进行评估。为了实现这个相关性赋值,我们可以使用之前使用的RankedPage类,并创建以下方法:

private static int relevanceLabel(RankedPage page) {
    if (page.getPage() == 0) {
        if (page.getPosition() < 3) {
            return 3;
        } else {
            return 2;
        }
    }

    return 1;
}

我们可以对一个查询中的所有文档使用这种方法,而对所有其他文档,我们只需指定相关性为 0。除了这个方法之外,用于创建和提取特征的其余代码保持不变,因此为了简洁起见,我们将省略这些代码。

一旦数据准备好了,我们就将Dataset包装到DMatrix中。当这样做时,我们需要指定组,在每个组中我们将优化排名。在我们的例子中,我们通过查询对数据进行分组。

XGBoost 希望属于同一个组的对象顺序连续,所以它需要一个组大小的数组。例如,假设我们的数据集中有 12 个对象:4 个来自组 1,3 个来自组 2,5 个来自组 3:

然后,size 数组应该包含这些组的大小:[4, 3, 5]

这里,qid是查询的 ID:一个整数,我们将它与每个查询关联起来:

让我们首先创建一个用于计算数组大小的效用函数:

private static int[] groups(List<Integer> queryIds) {
    Multiset<Integer> groupSizes = LinkedHashMultiset.create(queryIds);
    return groupSizes.entrySet().stream().mapToInt(e -> e.getCount()).toArray();
}

这个方法接受一个查询 ID 列表,然后计算每个 ID 出现的次数。为此,我们使用了来自番石榴的 multiset。multiset 的这种特殊实现记住了元素插入的顺序,因此当取回计数时,顺序被保留。

现在,我们可以为两个数据集指定组大小:

DMatrix dtrain = XgbUtils.wrapData(trainDataset);
int[] trainGroups = queryGroups(trainFeatures.col("queryId"));
dtrain.setGroup(trainGroups);

DMatrix dtest = XgbUtils.wrapData(testDataset);
int[] testGroups = queryGroups(testFeatures.col("queryId"));
dtest.setGroup(testGroups);

我们准备训练一个模型:

Map<String, DMatrix> watches = ImmutableMap.of("train", dtrain, "test", dtest);
IObjective obj = null;
IEvaluation eval = null;

Map<String, Object> params = XgbUtils.defaultParams();
params.put("objective", "rank:pairwise");
params.put("eval_metric", "ndcg@30");

int nrounds = 500;
Booster model = XGBoost.train(dtrain, params, nrounds, watches, obj, eval);

在这里,我们将目标改为rank:pairwise,因为我们对解决排名问题感兴趣。我们还将评估指标设置为ndcg@30,这意味着我们只想查看前 30 个文档的 NDCG,并不真正关心 30 个之后的文档。其原因是搜索引擎的用户很少查看搜索结果的第二页和第三页,并且他们很可能会越过第三页,因此我们只考虑搜索结果的前三页。也就是说,我们只对前 30 个文档感兴趣,所以我们只查看 30 个文档中的 NDCG。

正如我们之前所做的,我们从默认参数开始,并经历与分类或回归相同的参数调整过程。

我们可以对其进行一些调整,例如,使用以下参数:

Map<String, Object> params = XgbUtils.defaultParams();
params.put("objective", "rank:pairwise");
params.put("eval_metric", "ndcg@30");
params.put("colsample_bytree", 0.5);
params.put("max_depth", 4);
params.put("min_child_weight", 30);
params.put("subsample", 0.7);
params.put("eta", 0.02);

有了这组参数,我们看到,在大约第 220 次迭代时达到了保留数据的 0.632 的最佳 NDCG@30,因此我们不应该生长超过 220 棵树。

现在我们可以用 XGBoost 模型转储器保存模型,并在 Lucene 中使用它。为此,我们需要使用和以前一样的代码,几乎不做任何改动;我们唯一需要改变的是模型。也就是说,唯一的区别是,我们需要加载 XGBoost 模型,而不是加载随机的森林模型。之后,我们只需遵循相同的过程:用 Lucene 检索前 100 个文档,并用新的 XGBoost 模型对它们重新排序。

因此,使用 XGBoost,我们能够考虑每个查询组中文档的相对顺序,并使用这些信息进一步改进模型。

摘要

在这一章中,我们学习了极限梯度提升——梯度提升机器的一种实现。我们学习了如何安装库,然后我们申请解决各种监督学习问题:分类、回归和排序。

当数据结构化时,XGBoost 大放异彩:当有可能从我们的数据中提取好的特征并将这些特征放入表格格式时。然而,在某些情况下,数据很难结构化。比如在处理图像或者声音的时候,需要付出很大的努力来提取有用的特征。但是,我们不一定要自己进行特征提取,相反,我们可以使用神经网络模型,它可以自己学习最佳特征。

在下一章,我们将看看 deep learning 4j——一个面向 Java 的深度学习库。

八、使用 DeepLearning4J 的深度学习

在前一章中,我们介绍了极限梯度增强(XGBoost)——一个实现梯度增强机器算法的库。这个库为许多监督机器学习问题提供了最先进的性能。然而,XGBoost 只有在数据已经结构化并且有很好的手工特性时才会大放异彩。

功能工程过程通常非常复杂,需要付出大量努力,尤其是在涉及图像、声音或视频等非结构化信息时。这是深度学习算法通常优于其他算法的领域,包括 XGBoost 他们不需要手工制作的特性,并且能够自己学习数据的结构。

在这一章中,我们将研究 Java 的深度学习库——deep learning 4j。这个库允许我们轻松地指定能够处理图像等非结构化数据的复杂神经网络架构。特别是,我们将研究卷积神经网络——一种非常适合图像的特殊神经网络。

本章将涵盖以下内容:

  • DeepLearning4J 背后的引擎
  • 用于手写数字识别的简单神经网络
  • 用于数字识别的具有卷积层的深度网络
  • 一种用于对带有狗和猫的图像进行分类的模型

本章结束时,你将学习如何运行 DeepLearning4J,将其应用于图像识别问题,并使用 AWS 和 GPU 加速。

神经网络和深度学习 4J

神经网络通常是在结构化数据集上提供合理性能的良好模型,但它们不一定比其他模型更好。然而,在处理非结构化数据时,它们通常是最好的。

在本章中,我们将研究一个用于设计深度神经网络的 Java 库,名为 DeepLearning4j。但在我们这样做之前,我们首先将研究它的后端-ND4J,它完成所有的数字计算和繁重的工作。

用于 Java 的 ND4J - N 维数组

DeepLearning4j 依赖 ND4J 来执行线性代数运算,如矩阵乘法。以前,我们讨论过很多这样的库,例如,Apache Commons Math 或 Matrix Toolkit Java。为什么我们还需要另一个线性代数库?

这有两个原因。首先,这些库通常只处理向量和矩阵,但对于深度学习,我们需要张量。一个张量是向量和矩阵向多维的推广;我们可以把向量看成一维张量,把矩阵看成二维张量。对于深度学习来说,这很重要,因为我们有图像,图像是三维的;它们不仅有高度和宽度,还有多个通道。

ND4J 还有一个相当重要的原因是它的 GPU 支持;所有的运算都可以在图形处理器上执行,图形处理器被设计成并行处理大量复杂的线性代数运算,这对于加速神经网络的训练非常有帮助。

因此,在进入 DeepLearning4j 之前,让我们快速浏览一下 ND4J 的一些基础知识,即使知道深度神经网络是如何实现的细节并不重要,但它对于其他目的也是有用的。

像往常一样,我们首先需要包含对pom文件的依赖:

<dependency>
  <groupId>org.nd4j</groupId>
  <artifactId>nd4j-native-platform</artifactId>
  <version>0.7.1</version>
</dependency>

这将根据您的平台下载 Linux、MacOS 或 Windows 的 CPU 版本。请注意,对于 Linux,您可能需要安装 OpenBLAS。这通常非常容易,例如,对于 Ubuntu Linux,您可以通过执行以下命令来安装它:

sudo apt-get install libopenblas-dev

在将这个库包含到pom文件并安装了依赖项之后,我们就可以开始使用它了。

ND4J 的接口很大程度上受 NumPy 的启发,NumPy 是 Python 的一个数值库。如果你已经知道 NumPy,你会很快认出 ND4J 中的熟悉之处。

让我们从创建 ND4J 阵列开始。假设,我们想要创建一个用 1(或 0)填充的5 x 10数组。这很简单,为此,我们可以使用Nd4j类中的10实用程序方法:

INDArray ones = Nd4j.ones(5, 10);
INDArray zeros = Nd4j.zeros(5, 10);

如果我们已经有了一个 doubles 数组,那么将它们包装成Nd4j就很容易了:

Random rnd = new Random(10);
double[] doubles = rnd.doubles(100).toArray();
INDArray arr1d = Nd4j.create(doubles);

创建数组时,我们可以指定结果形状。假设我们想把这个有100个元素的数组放到一个10 x 10矩阵中。我们需要做的就是在创建数组时指定形状:

INDArray arr2d = Nd4j.create(doubles, new int[] { 10, 10 });

或者,我们可以在创建数组后对其进行整形:

INDArray reshaped = arr1d.reshape(10, 10);

任何维度的任何数组都可以用reshape方法重新整形为一维数组:

INDArray reshaped1d = reshaped.reshape(1, -1);

注意,我们这里用的是-1;这样我们要求 ND4J 自动推断元素的正确数量。

如果我们有一个双精度的二维 Java 数组,那么有一个特殊的语法将它们包装到 ND4J 中:

double[][] doubles = new double[3][];
doubles[0] = rnd.doubles(5).toArray();
doubles[1] = rnd.doubles(5).toArray();
doubles[2] = rnd.doubles(5).toArray();
INDArray arr2d = Nd4j.create(doubles);

同样,我们可以从 doubles 创建一个三维 ND4J 数组:

double[] doubles = rnd.doubles(3 * 5 * 5).toArray();
INDArray arr3d = Nd4j.create(doubles, new int[] { 3, 5, 5 });

到目前为止,我们使用 Java 的Random类来生成随机数,但是我们可以使用 ND4J 的方法:

int seed = 0;
INDArray rand = Nd4j.rand(new int[] { 5, 5 }, seed);

此外,我们还可以指定一个分布,从中抽取值:

double mean = 0.5;
double std = 0.2;
INDArray rand = Nd4j.rand(new int[] { 3, 5, 5 }, new NormalDistribution(mean, std));

正如我们前面提到的,三维张量对于表示图像很有用。通常,一个图像是一个三维数组,其中维数是channels * height * width的个数,值的范围通常是从 0 到 255。

让我们用三个通道生成一个类似图像的大小为2 * 5的数组:

double[] picArray = rnd.doubles(3 * 2 * 5).map(d -> Math.round(d * 255)).toArray();
INDArray pic = Nd4j.create(picArray).reshape(3, 2, 5);

如果我们打印这个数组,我们将看到如下所示的内容:

[[[51.00, 230.00, 225.00, 146.00, 244.00],
  [64.00, 147.00, 25.00, 12.00, 230.00]],
[[145.00, 160.00, 57.00, 202.00, 143.00],
  [170.00, 91.00, 181.00, 94.00, 92.00]],
[[193.00, 43.00, 248.00, 211.00, 27.00],
  [68.00, 139.00, 115.00, 44.00, 97.00]]]

这里,输出首先按通道分组,内部我们分别有每个通道的像素值信息。要获得一个特定的频道,我们可以使用get方法:

for (int i = 0; i < 3; i++) {
    INDArray channel = pic.get(NDArrayIndex.point(i));
    System.out.println(channel);
}

或者,如果我们对列从 2 ^第到 3 ^第的第 0 ^个通道的所有行感兴趣,我们可以使用get方法以这种方式访问数组的这个特定部分:

INDArray slice = pic.get(NDArrayIndex.point(0), NDArrayIndex.all(), NDArrayIndex.interval(2, 4));
System.out.println(slice);

以下是输出:

[[225.00, 146.00],
 [25.00, 12.00]]

这个库有更多的东西,比如点积、矩阵乘法等等。这个功能与我们已经详细介绍过的类似库非常相似,所以我们在这里不再重复。

现在,让我们从神经网络开始!

深度学习中的神经网络

在学习了 ND4J 的一些基础知识后,我们现在准备开始使用 DeepLearning4j,并用它创建神经网络。

你可能已经知道,神经网络是我们将单个神经元分层堆叠的模型。在预测阶段,每个神经元获得一些输入,对其进行处理,并将结果转发给下一层。我们从接收原始数据的输入层开始,逐渐将值向前推至输出层,输出层将包含给定输入的模型预测。

具有一个隐藏层的神经网络可能如下所示:

DeepLearning4J 让我们可以轻松设计这样的网络。如果我们采用上图中的网络,并尝试用 DeepLearning4j 实现它,我们可能会得到如下结果:

DenseLayer input = new DenseLayer.Builder().nIn(n).nOut(6).build();
nnet.layer(0, input);
OutputLayer output = new OutputLayer.Builder().nIn(6).nOut(k).build();
nnet.layer(1, output);

如你所见,阅读和理解并不难。所以,让我们使用它;为此,我们首先需要指定它对pom.xml文件的依赖性:

<dependency>
  <groupId>org.deeplearning4j</groupId>
  <artifactId>deeplearning4j-core</artifactId>
  <version>0.7.1</version>
</dependency>

注意,DeepLearning4j 和 ND4J 的版本必须相同。

为了便于说明,我们将使用 MNIST 数据集;该数据集包含从 0 到 9 的手写数字图像,目标是预测图像中给出的数字:

这个数据集非常有名。创建一个识别数字的模型通常可以作为神经网络和深度学习的 Hello World

本章从一个只有一个内层的简单网络开始。由于所有图像都是28 * 28像素,输入层应该有28 * 28个神经元(图片是灰度的,所以只有一个通道)。为了能够将图片输入到网络中,我们首先需要展开成一个一维数组:

我们已经知道,使用 ND4J,这是非常容易做到的;我们只是调用reshape(1, -1)。然而,我们不需要这样做;DeepLearning4J 会自动处理,为我们重塑输入。

接下来,我们创建一个内层,我们可以从 1000 个神经元开始。既然有 10 个数字,那么输出层的神经元数应该等于 10。

现在,让我们在 DeepLearning4J 中实现这个网络。由于 MNIST 是一个非常受欢迎的数据集,库已经为它提供了一个方便的加载器,所以我们需要做的就是使用下面的代码:

int batchSize = 128;
int seed = 1;
DataSetIterator mnistTrain = new MnistDataSetIterator(batchSize, true, seed);
DataSetIterator mnistTest = new MnistDataSetIterator(batchSize, false, seed);

对于训练部分,有 50000 个标记的例子,有 10000 个测试的例子。为了迭代它们,我们使用 DeepLearning4j 的抽象- DataSetIterator。它在这里做的是获取整个数据集,洗牌,然后将它分成 128 张图片的批次。

我们准备批次的原因是神经网络通常使用随机梯度下降 ( SGD )进行训练,并且训练是分批进行的;我们取一批,在上面训练一个模型,更新权重,然后取下一批。取一个批次并在其上训练一个模型被称为迭代,迭代所有可用的训练批次被称为时期

获得数据后,我们可以指定网络的训练配置:

NeuralNetConfiguration.Builder config = new NeuralNetConfiguration.Builder();
config.seed(seed);
config.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT);
config.learningRate(0.005);
config.regularization(true).l2(0.0001);

在这段代码中,我们说我们希望使用 SGD 进行训练,学习率为0.005,L2 正则化为0.0001。SGD 是一个合理的默认值,你应该坚持使用它。

学习率是最重要的训练配置参数。如果我们把它设置得太高,那么训练过程将会发散,如果它太小,在收敛之前将会花费很多时间。为了选择最佳的学习速率,我们通常为诸如 0.1、0.01、0.001,...,0.000001,看看神经网络什么时候停止发散。

我们在这里使用的另一个东西是 L2 正则化。L1 和 L2 正则化的工作方式与逻辑回归等线性模型完全相同——它们通过减小权重来避免过度拟合,而 L1 则确保了解的稀疏性。

然而,有专门针对神经网络的正则化策略——dropout 和 dropconnect,它们在每次训练迭代中使网络的随机部分静音。我们可以在配置中为整个网络指定它们:

config.dropOut(0.1);

但是更好的方法是在每一层指定它们——我们将在后面看到如何做。

一旦我们完成了训练配置,我们就可以继续指定网络的架构,也就是说,比如它的层和每层中神经元的数量。

为此我们得到了一个ListBuilder类的对象:

ListBuilder architecture = config.list();

现在,让我们添加第一层:

DenseLayer.Builder innerLayer = new DenseLayer.Builder();
innerLayer.nIn(28 * 28);
innerLayer.nOut(1000);
innerLayer.activation("tanh");
innerLayer.weightInit(WeightInit.UNIFORM);
architecture.layer(0, innerLayer.build());

正如我们之前讨论的,输入层中神经元的数量应该等于图像的大小,即 28 乘以 28。由于内层有 1000 个神经元,所以这一层的输出是 1000 个。

此外,我们在这里指定激活函数和权重初始化策略。

激活函数是应用于每个神经元输出的非线性变换。可以有几种激活功能:

| 激活 | Plot |
| 线性:无激活 | |
| 乙状结肠:[0, 1]范围 | |
| tanh: [-1, 1]范围 | |
| 备注:t0]范围 | |
| leaky 指出:[-infinity, infinity] | |

对于这个例子,我们使用了tanh,这是深度学习之前的浅层网络的默认选项。然而,对于深层网络,ReLU 激活通常应该是优选的,因为它们解决了消失梯度问题。

Vanishing gradient is a problem that occurs during the training of neural networks. For training, we calculate the gradient--the direction which we need to follow, and update the weights based on that. This problem occurs when we use sigmoid or tanh activations in deep networks--the first layers (processed last during optimization) have a very small gradient and do not get updated at all.

不过 ReLU 有时候也会有一个问题,叫做死 ReLU ,可以使用 LeakyReLU 等其他激活函数解决。

如果 ReLU 函数的输入为负,那么输出正好为零,这意味着在许多情况下神经元没有被激活。此外,在训练导数时,在这种情况下,is 为零,因此跟随梯度可能永远不会更新权重。这就是所谓的死亡问题,许多神经元从未被激活并死亡。这个问题可以使用 LeakyReLU 激活来解决,它不是总是输出负值的零,而是输出非常小的东西,所以仍然可以计算梯度。

我们在这里指定的另一件事是权重初始化。通常,在我们训练一个网络之前,我们需要初始化参数,一些初始化比另一些更好,但是,通常,这是特定于情况的,并且通常我们需要尝试几种方法,然后选择一种特定的方法。

| 权重初始化方法 | 评论 |
| WeightInit.ZERO | 在这里,所有的权重都被设置为零。不建议这样做。 |
| WeightInit.UNIFORM | 这里,权重被设置为[-a, a]范围内的统一值,其中a取决于神经元的数量。 |
| WeightInit.XAVIER | 这是有方差的高斯分布,它取决于神经元的数量。如果有疑问,请使用此初始化。 |
| WeightInit.RELU | 这是比XAVIER中方差更高的高斯分布。它有助于解决垂死的 ReLU 问题。 |
| WeightInit.DISTRIBUTION | 这允许您指定将从中对权重进行采样的任何分布。在这种情况下,分布是这样设置的:layer.setDist(new NormalDistribution(0, 0.01));。 |
| 其他人 | 还有其他权重初始化策略,参见WeightInit类的 JavaDocs。 |

UNIFORMXAVIER方法通常是很好的起点;先试试它们,看看它们是否能产生好的结果。如果没有,那就尝试实验,选择一些其他的方法。

如果你遇到了将死的 ReLU 问题,那么最好使用WeightInit.RELU初始化方法。否则,使用WeightInit.XAVIER

接下来,我们指定输出层:

architecture.layer(1, outputLayer.build());

对于输出层,我们需要指定loss函数——训练时我们希望用网络优化的函数。有多种选择,但最常见的如下:

  • LossFunction.NEGATIVELOGLIKELIHOOD,也就是LogLoss。用这个来分类。
  • LossFunction.MSE,即均方误差。用它来回归。

你可能已经注意到,这里我们使用了一个不同的激活函数- softmax,我们以前没有涉及过这个激活。这是将sigmoid函数推广到多个类。如果我们有一个二元分类问题,并且我们只想预测一个值,属于正类的概率,那么我们使用一个sigmoid。但是如果我们的问题是多类的,或者我们为二分类问题输出两个值,那么我们需要使用 softmax。如果我们解决回归问题,那么我们使用线性激活函数。

| 输出激活 | 何时使用 |
| sigmoid | 二元分类 |
| softmax | 多类分类 |
| linear | 回归 |

现在,当我们建立了体系结构后,我们就可以从中构建网络了:

MultiLayerNetwork nn = new MultiLayerNetwork(architecture.build());
nn.init();

监控训练进度并将分数视为模型训练通常是有用的,为此我们可以使用ScoreIterationListener——它订阅模型,并在每次迭代后输出新的训练分数:

nn.setListeners(new ScoreIterationListener(1));

现在我们准备训练网络:

int numEpochs = 10;
for (int i = 0; i < numEpochs; i++) {
    nn.fit(mnistTrain);
}

在这里,我们对网络进行 10 个时期的训练,也就是说,我们对整个训练数据集迭代 10 次,如果你记得的话,每个时期由许多 128 大小的批次组成。

一旦训练完成,我们就可以在测试中评估模型的性能。为此,我们创建一个特殊的类型为Evaluation的对象,然后我们迭代测试集的批次,并将模型应用于每一批次。每次我们这样做的时候,我们都会更新Evaluation对象,它跟踪整体性能。

一旦训练完成,我们就可以评估模型的性能。为此,我们创建一个类型为Evaluation的特殊对象,然后迭代验证数据集,并将模型应用于每一批。结果由Evaluation类记录,最后我们可以看到结果:

while (mnistTest.hasNext()) {
    DataSet next = mnistTest.next();
    INDArray output = nn.output(next.getFeatures());
    eval.eval(next.getLabels(), output);
}

System.out.println(eval.stats());

如果我们运行它 10 个时期,它将产生这个:

Accuracy:        0.9
Precision:       0.8989
Recall:          0.8985
F1 Score:        0.8987

因此,性能并不令人印象深刻,为了提高性能,我们可以修改架构,例如,添加另一个内层:

DenseLayer.Builder innerLayer1 = new DenseLayer.Builder();
innerLayer1.nIn(numrow * numcol);
innerLayer1.nOut(1000);
innerLayer1.activation("tanh");
innerLayer1.dropOut(0.5);
innerLayer1.weightInit(WeightInit.UNIFORM);
architecture.layer(0, innerLayer1.build());

DenseLayer.Builder innerLayer2 = new DenseLayer.Builder();
innerLayer2.nIn(1000);
innerLayer2.nOut(2000);
innerLayer2.activation("tanh");
innerLayer2.dropOut(0.5);
innerLayer2.weightInit(WeightInit.UNIFORM);
architecture.layer(1, innerLayer2.build());

LossFunction loss = LossFunction.NEGATIVELOGLIKELIHOOD;
OutputLayer.Builder outputLayer = new OutputLayer.Builder(loss);
outputLayer.nIn(2000);
outputLayer.nOut(10);
outputLayer.activation("softmax");
outputLayer.weightInit(WeightInit.UNIFORM);
architecture.layer(2, outputLayer.build());

正如你所看到的,这里我们在第一层和输出层之间添加了一个额外的层,带有2000神经元。我们还为每个图层添加了 dropout,以实现正则化。

通过这种设置,我们可以获得稍好的精度:

Accuracy:        0.9124
Precision:       0.9116
Recall:          0.9112
F1 Score:        0.9114

当然,改善只是边缘性的,网络还远远没有调好。为了改善它,我们可以使用 ReLU 激活,内斯特罗夫的动量 0.9 左右的更新程序,以及 XAVIER 的权重初始化。这应该给出高于 95%的准确度。事实上,在来自官方 DeepLearning4j 知识库的示例中,您可以找到一个非常好的网络;寻找名为MLPMnistSingleLayerExample.java的类。

在我们的例子中,我们使用经典的神经网络;它们相当浅(也就是说,它们没有很多层),并且所有层都是完全连接的。虽然对于小规模的问题,这可能已经足够好了,但通常最好使用卷积神经网络来执行图像识别任务,这些网络考虑到了图像结构,可以实现更好的性能。

卷积神经网络

正如我们已经多次提到的,神经网络可以自己完成特征工程部分,这对于图像尤其有用。现在我们将最终看到这一点。为此,我们将使用卷积神经网络,它们是一种特殊的神经网络,使用特殊的卷积层。它们非常适合图像处理。

在通常的神经网络中,各层是完全连接的,这意味着一层的每个神经元都连接到前一层的所有神经元。对于 MNIST 的数字图像来说,这没什么大不了的,但是对于更大的图像来说,这就成问题了。想象一下,我们需要处理大小为300 x 300的图像;在这种情况下,输入层将有 90,000 个神经元。那么,如果下一层也有 9 万个神经元,那么这两层之间就会有90000 x 90000连接,这显然是很多的。

然而,在图像中,每个像素只有一小部分是重要的。因此,前面的问题可以通过只考虑每个像素的小邻域来解决,这正是卷积层所做的;在里面,他们保存了一套小尺寸的过滤器。然后,我们在图像上滑动一个窗口,并计算窗口中的内容与每个过滤器的相似性:

过滤器是这些卷积层中的神经元,它们是在训练阶段学习的,与通常的全连接情况类似。

当我们在图像上滑动窗口时,我们计算内容与过滤器的相似性,这是它们之间的点积。对于每个窗口,我们将结果写入输出。当所考虑的区域与过滤器相似时,我们说过滤器被激活。显然,如果相似,点积将倾向于产生更高的值。

由于图像通常有多个通道,我们实际上处理的是维度的体积(或 3D 张量):通道数、高度、宽度和宽度。当图像通过卷积层时,每个滤波器被依次应用,作为输出,我们有维度体积滤波器数量乘以高度乘以宽度。当我们将这样的层堆叠在彼此之上时,我们得到一系列的体积:

除了卷积层之外,另一种层类型对于卷积网络也很重要,即下采样层或汇集层。这一层的目的是降低输入的维数,通常每边降低 2 倍,所以总共降低 4 倍。通常,我们使用最大池,在缩减采样时保持最大值:

我们这样做的原因是为了减少我们网络的参数数量,这使得训练速度大大加快。

当这样的层获得体积时,它仅改变高度和宽度,而不改变过滤器的数量。通常,我们将池层放在卷积层之后,并且通常组织架构,使两个卷积层之后跟随一个池层:

然后,在某种程度上,在我们添加了足够的卷积层之后,我们切换到全连接层,这与我们在常见网络中的层类型相同。最后,我们有了输出层,就像之前一样。

让我们继续 MNIST 的例子,但这次让我们训练一个卷积神经网络来识别数字。对于这个任务,有一个著名的架构叫做 LeNet(由 Yann LeCun 研究员创建),让我们来实现它。我们将基于他们的资源库中可用的官方 DeepLearning4j 示例来给出我们的示例。

该架构如下所示:

  • 5 x 5带 20 个滤波器的卷积层
  • 最大池化
  • 5 x 5带 50 个滤波器的卷积层
  • 最大池化
  • 具有 500 个神经元的完全连接的层
  • 使用 softmax 输出图层

所以这个网络有六层。

像以前一样,首先,我们指定网络的训练配置:

NeuralNetConfiguration.Builder config = new NeuralNetConfiguration.Builder();
config.seed(seed);
config.regularization(true).l2(0.0005);
config.learningRate(0.01);
config.weightInit(WeightInit.XAVIER);
config.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT);
config.updater(Updater.NESTEROVS).momentum(0.9);

这里几乎没有什么新东西,除了Updater;我们使用内斯特罗夫的更新,动量设置为0.9。这样做的目的是加快收敛。

现在我们可以创建架构了:

ListBuilder architect = config.list();

首先,卷积层:

ConvolutionLayer cnn1 = new ConvolutionLayer.Builder(5, 5)
        .name("cnn1")
        .nIn(nChannels)
        .stride(1, 1)
        .nOut(20)
        .activation("identity")
        .build();
architect.layer(0, cnn1);

这里,在构建器的构造函数中,我们指定了过滤器的维数,即5 x 5。然后将nIn参数设置为输入图像的通道数,对于 MNIST 为 1,它们都是灰度图像。nOut参数指定了该图层的滤镜数量。stride 参数指定了我们在图像上滑动窗口的步骤,通常设置为1。最后,该层不使用任何激活。

架构中的下一层是池层:

SubsamplingLayer pool1 = new SubsamplingLayer.Builder(PoolingType.MAX)
        .name("pool1")
        .kernelSize(2, 2)
        .stride(2, 2)
        .build();
architect.layer(1, pool1);

当我们创建这一层时,我们首先指定我们想要缩减采样的方式,我们使用MAX,因为我们对最大池感兴趣。还有其他选项,如AVG平均值和SUM,但它们在实践中并不常用。

这一层有两个参数——kernelSize参数,它是我们在图片上滑动的窗口的大小,以及 stride 参数,它是我们在滑动窗口时采取的步骤。通常,这些值被设置为2

然后,我们添加下一个卷积层和一个池层:

ConvolutionLayer cnn2 = new ConvolutionLayer.Builder(5, 5)
        .name("cnn2")
        .stride(1, 1)
        .nOut(50)
        .activation("identity")
        .build();
architect.layer(2, cnn2);
SubsamplingLayer pool2 = new SubsamplingLayer.Builder(PoolingType.MAX)
        .name("pool2")
        .kernelSize(2, 2)
        .stride(2, 2)
        .build();
architect.layer(3, pool2);

最后,我们创建全连接层和输出层:

DenseLayer dense1 = new DenseLayer.Builder()
        .name("dense1")
        .activation("relu")
        .nOut(500)
        .build();
architect.layer(4, dense1);
OutputLayer output = new OutputLayer.Builder(LossFunction.NEGATIVELOGLIKELIHOOD)
        .name("output")
        .nOut(outputNum)
        .activation("softmax")
        .build();
architect.layer(5, output);

对于最后两层,对我们来说没有什么新的,我们不使用任何新的参数。

最后,在训练之前,我们需要告诉优化器输入是一幅图片,这是通过指定输入类型来完成的:

architect.setInputType(InputType.convolutionalFlat(height, width, nChannels));

有了这个,我们就可以开始训练了:

for (int i = 0; i < nEpochs; i++) {
    model.fit(mnistTrain);
    Evaluation eval = new Evaluation(outputNum);

    while (mnistTest.hasNext()) {
        DataSet ds = mnistTest.next();
        INDArray out = model.output(ds.getFeatureMatrix(), false);
        eval.eval(ds.getLabels(), out);
    }

    System.out.println(eval.stats());
    mnistTest.reset();
}

对于这种架构,网络在一个历元之后可以达到的准确度是 97%,这明显好于我们之前的尝试。但训练它 10 个纪元后,准确率达到 99%。

猫和狗的深度学习

虽然 MNIST 是一个非常好的教育数据集,但它非常小。我们来看一个不同的图像识别问题:给定一张图片,我们想预测图片上是猫还是狗。

为此,我们将使用 kaggle 上一场比赛中的猫狗图片数据集,该数据集可以从https://www.kaggle.com/c/dogs-vs-cats下载。

我们先从读取数据开始。

读取数据

对于狗对猫的比赛,有两个数据集;训练,用 25000 张狗和猫的图片,各占 50%,测试。出于本章的目的,我们只需要下载训练数据集。下载完成后,在某个地方解压。

文件名如下所示:

| dog.9993.jpg
dog.9994.jpg
| cat.10000.jpg
cat.10001.jpg
|
| | |

标签(dogcat)被编码到文件名中。

如您所知,我们通常做的第一件事是将数据分成训练集和验证集。因为我们这里所有的都是文件的集合,所以我们只是得到所有的文件名,然后把它们分成两部分——训练和验证。

为此,我们可以使用这个简单的脚本:

File trainDir = new File(root,  "train");
double valFrac = 0.2;
long seed = 1;

Iterator<File> files = FileUtils.iterateFiles(trainDir, new String[] { "jpg" }, false);
List<File> all = Lists.newArrayList(files);
Random random = new Random(seed);
Collections.shuffle(all, random);

int trainSize = (int) (all.size() * (1 - valFrac));
List<File> train = all.subList(0, trainSize);
copyTo(train, new File(root, "train_cv"));

List<File> val = all.subList(trainSize, all.size());
copyTo(val, new File(root, "val_cv"));

在代码中,我们使用 Apache Commons IO 中的FileUtils.iterateFiles方法迭代训练目录中的所有.jpg文件。然后我们把所有这些文件放到一个列表里,洗牌,把它们分成 80%和 20%的部分。

copyTo方法只是将文件复制到指定的目录中:

private static void copyTo(List<File> pics, File dir) {
    for (File pic : pics) {
        FileUtils.copyFileToDirectory(pic, dir);
    }
}

在这里,FileUtils.copyFileToDirectory方法也来自 Apache Commons IO。

为了使用这些数据来训练网络,我们需要做很多事情。它们如下:

  • 获取每张图片的路径
  • 获取标签(文件名中的dogcat)
  • 调整输入的大小,使每张图片都具有相同的大小
  • 对图像应用一些标准化
  • 从中创建DataSetIterator

获取每个图片的路径很容易,我们已经知道如何做,我们可以像以前一样在 Commons IO 中使用相同的方法。但是现在我们需要为每个文件获取URI,因为 DeepLearning4j 数据集迭代器期望的是文件的URI,而不是文件本身。为此,我们创建了一个助手方法:

private static List<URI> readImages(File dir) {
    Iterator<File> files = FileUtils.iterateFiles(dir, 
                                 new String[] { "jpg" }, false);
    List<URI> all = new ArrayList<>();

    while (files.hasNext()) {
        File next = files.next();
        all.add(next.toURI());
    }

    return all;
}

从文件名中获取类名(dogcat)是通过实现PathLabelGenerator接口来完成的:

private static class FileNamePartLabelGenerator implements PathLabelGenerator {

    @Override
    public Writable getLabelForPath(String path) {
        File file = new File(path);
        String name = file.getName();
        String[] split = name.split(Pattern.quote("."));
        return new Text(split[0]);
    }

    @Override
    public Writable getLabelForPath(URI uri) {
        return getLabelForPath(new File(uri).toString());
    }
}

在里面,我们只是用.分割文件名,然后取结果的第一个元素。

最后,我们创建一个方法,它接受一个列表URI并创建一个DataSetIterator:

private static DataSetIterator datasetIterator(List<URI> uris) 
                     throws IOException {
    CollectionInputSplit train = new CollectionInputSplit(uris);
    PathLabelGenerator labelMaker = new FileNamePartLabelGenerator();

    ImageRecordReader trainRecordReader = new ImageRecordReader(HEIGHT, WIDTH, CHANNELS, labelMaker);
    trainRecordReader.initialize(train);

    return new RecordReaderDataSetIterator(trainRecordReader, BATCH_SIZE, 1, NUM_CLASSES);
}

该方法使用一些常量,我们用以下值对其进行初始化:

HEIGHT = 128;
WIDTH = 128;
CHANNELS = 3;
BATCH_SIZE = 30;
NUM_CLASSES = 2;

ImageRecordReader将使用HEIGHTWIDTH参数将图像调整到指定的形式,如果是灰度,它将人为地为其创建 RGB 通道。BATCH_SIZE指定了在训练过程中我们将一次考虑多少张图像。

在线性模型中,规范化起着重要的作用,有助于模型更快地收敛。对于神经网络也是如此,所以我们需要对图像进行归一化。为此,我们可以使用一个特殊的内置类ImagePreProcessingScalerDataSetIterator可以有一个预处理器,所以我们把这个定标器放在那里:

DataSetIterator dataSet = datasetIterator(valUris);
ImagePreProcessingScaler preprocessor = new ImagePreProcessingScaler(0, 1);
dataSet.setPreProcessor(preprocessor);

这样,数据准备工作就完成了,我们可以继续创建模型。

创建模型

对于模型的架构,我们将使用 VGG 网络的变体。这个架构取自论坛的一个公开可用的脚本(https://www . ka ggle . com/jeffd 23/dogs-vs-cats-redux-kernels-edition/catdognet-keras-conv net-starter),这里我们将把这个例子改编成 DeepLearning4j。

VGG 是在 2014 年 image net 挑战赛中获得第二名的模型,它仅使用 3 x 3 和 2 x 2 卷积滤波器。

使用现有的架构总是一个好主意,因为它可以解决很多时间——自己想出一个好的架构是一项具有挑战性的任务。

我们将使用的架构如下:

  • 两层3 x 3卷积与 32 个滤波器
  • 最大池化
  • 两层3 x 3卷积与 64 个滤波器
  • 最大池化
  • 两层3 x 3卷积用 128 个滤波器
  • 最大池化
  • 具有 512 个神经元的全连接层
  • 一个有 256 个神经元的全连接层
  • 激活 softmax 的输出层

对于我们的例子,我们将对所有卷积和全连接层使用 ReLU 激活。为了避免垂死的 ReLU 问题,我们将使用WeightInit.RELU权重初始化方案。在我们的实验中,不使用它,网络倾向于产生相同的结果,不管它接收什么输入。

首先,我们从配置开始:

NeuralNetConfiguration.Builder config = new NeuralNetConfiguration.Builder();
config.seed(SEED);
config.weightInit(WeightInit.RELU);
config.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT);
config.learningRate(0.001);
config.updater(Updater.RMSPROP);
config.rmsDecay(0.99);

有些参数现在应该已经很熟悉了,但是这里有两个新东西——RMSPROP更新器和rmsDecay参数。使用它们可以让我们在训练时自适应地改变学习速度。开始时,学习率较大,我们向最小值迈出较大的步伐,但当我们训练并接近最小值时,它会降低学习率,我们迈出较小的步伐。

通过尝试不同的值如 0.1、0.001 和 0.0001 并观察网络何时停止发散来选择学习率。这很容易发现,因为当发散时,训练误差变化很大,然后开始输出无穷大或NaN

现在我们指定架构。

首先,我们创建卷积层和池层:

int l = 0;
ListBuilder network = config.list();

ConvolutionLayer cnn1 = new ConvolutionLayer.Builder(3, 3)
        .name("cnn1")
        .stride(1, 1)
        .nIn(3).nOut(32)
        .activation("relu").build();
network.layer(l++, cnn1);

ConvolutionLayer cnn2 = new ConvolutionLayer.Builder(3, 3)
        .name("cnn2")
        .stride(1, 1)
        .nIn(32).nOut(32)
        .activation("relu").build();
network.layer(l++, cnn2);

SubsamplingLayer pool1 = new SubsamplingLayer.Builder(PoolingType.MAX)
        .kernelSize(2, 2)
        .stride(2, 2) 
        .name("pool1").build();
network.layer(l++, pool1);

ConvolutionLayer cnn3 = new ConvolutionLayer.Builder(3, 3)
        .name("cnn3")
        .stride(1, 1)
        .nIn(32).nOut(64)
        .activation("relu").build();
network.layer(l++, cnn3);

ConvolutionLayer cnn4 = new ConvolutionLayer.Builder(3, 3)
        .name("cnn4")
        .stride(1, 1)
        .nIn(64).nOut(64)
        .activation("relu").build();
network.layer(l++, cnn4);

SubsamplingLayer pool2 = new SubsamplingLayer.Builder(PoolingType.MAX)
        .kernelSize(2, 2)
        .stride(2, 2)
        .name("pool2").build();
network.layer(l++, pool2);

ConvolutionLayer cnn5 = new ConvolutionLayer.Builder(3, 3)
        .name("cnn5")
        .stride(1, 1)
        .nIn(64).nOut(128)
        .activation("relu").build();
network.layer(l++, cnn5);

ConvolutionLayer cnn6 = new ConvolutionLayer.Builder(3, 3)
        .name("cnn6")
        .stride(1, 1)
        .nIn(128).nOut(128)
        .activation("relu").build();
network.layer(l++, cnn6);

SubsamplingLayer pool3 = new SubsamplingLayer.Builder(PoolingType.MAX)
        .kernelSize(2, 2)
        .stride(2, 2)
        .name("pool3").build();
network.layer(l++, pool3);

这里对我们来说应该没有什么新鲜的。然后我们创建完全连接的层和输出:

DenseLayer dense1 = new DenseLayer.Builder()
        .name("ffn1")
        .nOut(512).build();
network.layer(l++, dense1);

DenseLayer dense2 = new DenseLayer.Builder()
        .name("ffn2")
        .nOut(256).build();
network.layer(l++, dense2);

OutputLayer output = new OutputLayer.Builder(LossFunction.NEGATIVELOGLIKELIHOOD)
        .name("output")
        .nOut(2)
        .activation("softmax").build();
network.layer(l++, output);

最后,如前所述,我们指定输入大小:

network.setInputType(InputType.convolutionalFlat(HEIGHT, WIDTH, CHANNELS));

现在,我们从该架构创建模型,并指定用于列车监控目的的分数监听器:

MultiLayerNetwork model = new MultiLayerNetwork(network.build())
ScoreIterationListener scoreListener = new ScoreIterationListener(1);
model.setListeners(scoreListener);

至于训练,发生的方式与我们之前所做的完全相同——我们训练模型几个时期:

List<URI> trainUris = readImages(new File(root, "train_cv"));
DataSetIterator trainSet = datasetIterator(trainUris);
trainSet.setPreProcessor(preprocessor);

for (int epoch = 0; epoch < 10; epoch++) {
    model.fit(trainSet);
}

ModelSerializer.writeModel(model, new File("model.zip"), true);

最后,我们还将模型保存到一个 ZIP 存档中,其中有三个文件——模型的系数,配置(参数和架构)在一个.json文件中,以及更新器的配置——以防我们希望在未来继续训练模型(最后一个参数true告诉我们保存它,使用false我们不能继续训练)。

然而,这里的性能监控非常原始,我们只观察训练错误,根本不观察验证错误。接下来,我们将了解更多性能监控选项。

监控性能

我们之前为监视所做的是添加监听器,它在每次迭代后输出模型的训练分数:

MultiLayerNetwork model = new MultiLayerNetwork(network.build())
ScoreIterationListener scoreListener = new ScoreIterationListener(1);
model.setListeners(scoreListener);

这将使您对模型的性能有所了解,但仅限于训练数据,但我们通常需要更多的数据——至少了解验证集的性能对于了解我们是否开始过度拟合是有用的。

那么,让我们来看看验证数据集:

DataSetIterator valSet = datasetIterator(valUris);
valSet.setPreProcessor(preprocessor);

为了训练,以前我们只是将数据集迭代器传递给 fit 函数。我们可以通过获取所有训练数据,在每个时期之前对其进行洗牌,并将其分成多个部分来改进这一过程,每个部分等于 20 个批次。在每个块上的训练完成后,我们可以迭代验证集,并查看模型的当前验证性能。

在代码中,它看起来像这样:

for (int epoch = 0; epoch < 20000; epoch++) {
    ArrayList<URI> uris = new ArrayList<>(trainUris);
    Collections.shuffle(uris);
    List<List<URI>> partitions = Lists.partition(uris, BATCH_SIZE * 20);

    for (List<URI> set : partitions) {
        DataSetIterator trainSet = datasetIterator(set);
        trainSet.setPreProcessor(preprocessor);
        model.fit(trainSet);
        showTrainPredictions(trainSet, model);
        showLogloss(model, valSet, epoch);
    }

    saveModel(model, epoch);
}

所以在这里,我们将URI进行洗牌,并将其划分为 20 个批次的列表。对于分区,我们使用 Google Guava 的Lists.partition方法。从每个这样的分区,我们创建一个数据集迭代器,并使用它来训练模型,然后,在每个块之后,我们查看验证分数,以确保网络不会过度拟合。

此外,查看网络对其刚刚接受训练的数据的预测是有帮助的,尤其是检查网络是否正在学习任何东西。我们在showTrainPredictions方法内部做这件事。如果不同的输入有不同的预测,那么这是一个好现象。此外,您可能希望了解预测与实际标签的接近程度。

此外,我们在每个时期结束时保存模型,以防出错,我们可以训练流程。如果您注意到了,我们将历元的数量设置为一个很高的数字,因此在某些时候我们可以停止训练(例如,当我们从日志中看到我们开始过度拟合时),并只采用最后一个好的模型。

让我们看看这些方法是如何实现的:

private static void showTrainPredictions(DataSetIterator trainSet, 
            MultiLayerNetwork model) {
    trainSet.reset();
    DataSet ds = trainSet.next();
    INDArray pred = model.output(ds.getFeatureMatrix(), false);
    pred = pred.get(NDArrayIndex.all(), NDArrayIndex.point(0));
    System.out.println("train pred: " + pred);
}

showLogLoss方法很简单,但是由于迭代器的原因有点冗长。它执行以下操作:

  • 检查认证数据集中的所有批次
  • 记录每批的预测和真实标签
  • 将所有预测放在一个双数组中,并对实际标签进行同样的操作
  • 使用我们在第 4 章监督学习-分类和回归中编写的代码计算测井曲线损失。

为了简洁起见,我们在这里省略了确切的代码,但是欢迎您查看代码包。

保存模型很简单,我们已经知道如何做了。这里我们只是在文件名中添加了一些关于纪元编号的额外信息:

private static void saveModel(MultiLayerNetwork model, int epoch) throws IOException {
    File locationToSave = new File("models", "cats_dogs_" + epoch + ".zip");
    boolean saveUpdater = true;
    ModelSerializer.writeModel(model, locationToSave, saveUpdater);
}

现在,当我们有大量信息要监控时,从日志中理解所有信息变得相当困难。为了让我们的生活更轻松,DeepLearning4j 附带了一个特殊的图形仪表盘来进行监控。

这是仪表板的外观:

让我们将它添加到代码中。首先,我们需要向我们的pom添加一个额外的依赖项:

<dependency>
  <groupId>org.deeplearning4j</groupId>
  <artifactId>deeplearning4j-ui_2.10</artifactId>
  <version>0.7.1</version>
</dependency>

它是用 Scala 写的,这也是为什么结尾有_2.10后缀的原因,它告诉我们这个版本是用 Scala 2.10 写的。因为我们在 Java 中使用它,所以这对我们来说无关紧要,所以我们可以选择任何我们想要的版本。

接下来,我们可以创建 UI 服务器的实例,并为网络创建一个特殊的侦听器,它将订阅网络的更新:

UIServer uiServer = UIServer.getInstance();
StatsStorage statsStorage = new InMemoryStatsStorage();
uiServer.attach(statsStorage);
StatsListener statsListener = new StatsListener(statsStorage);

我们以与使用ScoreIterationListener相同的方式使用它,我们通过setListeners方法将它添加到模型中:

MultiLayerNetwork model = createNetwork();
ScoreIterationListener scoreListener = new ScoreIterationListener(1);
model.setListeners(scoreListener, statsListener);

有了这些改变,当我们运行代码的时候,它就启动了 UI 服务器,我们打开浏览器去http://localhost:9000就能看到;这将显示前面代码中的仪表板。

这些图表很有用。最有用的是显示每次迭代的模型得分的图表。这是训练分数,与我们在ScoreIterationListener的日志中看到的分数相同,看起来像这样:

根据这个图表,我们可以了解模型在训练过程中的行为——训练过程是否稳定,或者模型是否在学习任何东西。理想情况下,我们应该看到如前面截图所示的下降趋势。如果分数没有下降,那么可能是网络配置有问题,比如学习率太小,不好好初始化权重或者正则化太多。如果分数有提高,那么最有可能的问题就是学习率过大。

其他图表也允许监控训练过程。“参数比率”图表以对数标度显示了每次迭代之间的参数变化(即,-3.0 对应于 0.001 次迭代之间的变化)。如果你看到变化太低,例如低于-6.0,那么,很可能,网络没有学到任何东西。

最后,有一个图表显示了所有激活的标准偏差。我们可能需要这样做的原因是为了检测所谓的消失爆发激活:

消失激活问题与消失渐变问题相关。对于一些激活,输入结果的变化几乎没有输出的变化,梯度几乎为零,所以神经元没有更新,所以它的激活消失。爆炸式激活则相反,激活分数不断增长,直至达到无穷大。

在此界面中,我们还可以在 Models 选项卡上看到完整的网络。这是我们模型的一部分:

如果我们单击每个单独的层,我们可以看到该特定层的一些图表。

使用这些工具,我们可以密切监控模型的性能,并在发现异常情况时调整训练过程和参数。

数据扩充

对于这个问题,我们只有 25000 个训练样本。对于深度学习模型来说,这个数据量通常不足以捕捉所有细节。无论我们的网络有多复杂,我们花了多少时间来调整它,在某些时候 25,000 个例子都不足以进一步提高性能。

通常,获取更多数据非常昂贵,或者根本不可能。但是我们能做的是从我们已经拥有的数据中产生更多的数据,这被称为数据扩充。通常,我们通过执行以下一些转换来生成新数据:

  • 旋转图像
  • 翻转图像
  • 随机裁剪图像
  • 切换颜色通道(例如,更改红色和蓝色通道)
  • 更改颜色饱和度、对比度和亮度
  • 添加噪声

在这一章中,我们将会看到前三种变换——旋转、翻转和裁剪。为此,我们将使用Scalr -一个用于图像操作的库。让我们将它添加到pom文件中:

<dependency>
  <groupId>org.imgscalr</groupId>
  <artifactId>imgscalr-lib</artifactId>
  <version>4.2</version>
</dependency>

它非常简单,只是扩展了标准的 Java API,就像 Apache Commons Lang 所做的一样——通过围绕标准功能提供有用的实用方法。

对于旋转和翻转,我们只需使用Scalr.rotate方法:

File image = new File("cat.10000.jpg");
BufferedImage src = ImageIO.read(image);
Rotation rotation = Rotation.CW_90;
BufferedImage rotated = Scalr.rotate(src, rotation);
File outputFile = new File("cat.10000_cw_90.jpg");
ImageIO.write(rotated, "jpg", outputFile);

如你所见,这很容易使用,也很直观。我们需要做的就是传递一个BufferedImage和期望的RotationRotation是一个具有以下值的枚举:

  • Rotation.CW_90:顺时针旋转 90 度
  • Rotation.CW_180:顺时针旋转 180 度
  • Rotation.CW_270:顺时针旋转 270 度
  • 这包括水平翻转图像
  • 这包括垂直翻转图像

裁剪也不难,它是通过Scalr.crop方法完成的,该方法接受四个参数——裁剪开始的位置(xy坐标)和裁剪的大小(高度和宽度)。对于我们的问题,我们可以做的是在图像的左上角随机选择一个坐标,然后随机选择作物的高度和宽度。我们可以这样做:

int width = src.getWidth();
int x = rnd.nextInt(width / 2);
int w = (int) ((0.7 + rnd.nextDouble() / 2) * width / 2);

int height = src.getHeight();
int y = rnd.nextInt(height / 2);
int h = (int) ((0.7 + rnd.nextDouble() / 2) * height / 2);

if (x + w > width) {
    w = width - x;
}

if (y + h > height) {
    h = height - y;
}

BufferedImage crop = Scalr.crop(src, x, y, w, h);

这里,我们首先随机选择xy坐标,然后选择宽度和高度。在代码中,我们选择重量和高度,使它们至少占图像的 35%——但可以达到图像的 60%。当然,您可以随意使用这些参数,将它们更改为更有意义的值。

然后,我们还检查我们是否没有克服图像边界,也就是说,作物总是停留在图像内;最后我们调用crop方法。或者,我们也可以在最后旋转或翻转裁剪后的图像。

因此,对于所有文件,它可能看起来像这样:

for (File f : all) {
    BufferedImage src = ImageIO.read(f);
    for (Rotation rotation : Rotation.values()) {
        BufferedImage rotated = Scalr.rotate(src, rotation);
        String rotatedFile = f.getName() + "_" + rotation.name() + ".jpg";
        File outputFile = new File(outputDir, rotatedFile);
        ImageIO.write(rotated, "jpg", outputFile);

        int width = src.getWidth();
        int x = rnd.nextInt(width / 2);
        int w = (int) ((0.7 + rnd.nextDouble() / 2) * width / 2);

        int height = src.getHeight();
        int y = rnd.nextInt(height / 2);
        int h = (int) ((0.7 + rnd.nextDouble() / 2) * height / 2);

        if (x + w > width) {
            w = width - x;
        }

        if (y + h > height) {
            h = height - y;
        }

        BufferedImage crop = Scalr.crop(src, x, y, w, h);
        rotated = Scalr.rotate(crop, rotation);

        String cropppedFile = f.getName() + "_" + x + "_" + w + "_" +
                    y + "_" + h + "_" + rotation.name() + ".jpg";

        outputFile = new File(outputDir, cropppedFile);
        ImageIO.write(rotated, "jpg", outputFile);
    }
}

在这段代码中,我们迭代了所有的训练文件,然后我们将所有的旋转应用到图像本身,并从该图像中随机裁剪。这段代码应该从每个源图像生成 10 个新图像。例如,对于下面的一只猫的图像,将生成如下 10 个图像:

我们只是简单地列出了可能的增强,如果你还记得的话,最后一个是添加随机噪声。这通常很容易实现,因此这里有一些关于您可以做什么的想法:

  • 用 0 或一些随机值替换一些像素值
  • 从所有值中加上或减去同一个小数字
  • 生成一些具有小方差的高斯噪声,并将其添加到所有通道中
  • 仅将噪声添加到图像的一部分
  • 反转图像的一部分
  • 向图像中添加某种随机颜色的填充正方形;颜色可以有 alpha 通道(也就是说,它可以有点透明),也可以没有
  • 对图像应用强 JPG 编码

这样,您就可以虚拟地生成无限数量的数据样本来进行训练。当然,您可能不需要这么多样本,但通过使用这些技术,您可以扩充任何影像数据集,并显著提高基于此数据训练的模型的性能。

在 GPU 上运行 DeepLearning4J

正如我们之前提到的,DeepLearning4j 依赖 ND4J 进行数值计算。ND4J 是一个接口,有多种可能的实现。到目前为止,我们使用的是基于 OpenBLAS 的版本,但还有其他版本。我们还提到,ND4J 可以利用一个图形处理单元 ( GPU ),对于矩阵乘法等神经网络中使用的典型线性代数运算,它比 CPU 快得多。要使用它,我们需要获得 CUDA ND4J 后端。

CUDA 是一个用于在 NVidia 的 GPU 上执行计算的接口,它支持广泛的图形卡。在内部,ND4J 使用 CUDA 在 GPU 上运行数值计算。

如果你以前通过 BLAS 在 CPU 上执行过所有的代码,你一定注意到它有多慢。将 ND4J 后端切换到 CUDA 应该会将性能提高几个数量级。

这是通过在pom文件中包含以下依赖项来实现的:

<dependency>
  <groupId>org.nd4j</groupId>
  <artifactId>nd4j-cuda-7.5</artifactId>
  <version>0.7.1</version>
</dependency>

这种依赖性假设您已经安装了 CUDA 7.5。

对于 CUDA 8.0,你应该把 7.5 换成 8.0:ND4J 支持 CUDA 7.5 和 CUDA 8.0。

如果您已经有一个安装了所有驱动程序的 GPU,只需添加这种依赖关系就足以使用 GPU 来训练网络,当您这样做时,您将看到性能的大幅提升。

更重要的是,您可以使用 UI 仪表板来监控 GPU 内存使用情况,如果您发现它很低,您可以尝试更好地利用它,例如,通过增加批处理大小。您可以在系统选项卡上找到该图表:

如果你没有图形处理器,但不想等待你的 CPU 处理数据,你可以很容易地租一台图形处理器计算机。有些云提供商,比如亚马逊 AWS,可以让你立即获得一台带有 GPU 的服务器,哪怕只是几个小时。

如果你从未在亚马逊 AWS 上租用过服务器,我们已经准备了简单的说明,告诉你如何在那里开始培训。

在租用服务器之前,让我们先准备好我们需要的一切;代码和数据。

对于数据,我们只需将所有文件(包括扩充的文件)放入一个归档文件中:

zip -r all-data.zip  train_cv/  val_cv/

然后,我们需要构建代码,这样我们就有了所有的.jar文件,并且它们之间存在依赖关系。这是通过 Maven 的插件maven-dependency-plugin完成的。我们之前已经在第三章、探索性数据分析中使用过这个插件,所以我们将省略需要添加到我们的pom.xml文件中的 XML 配置。

现在我们使用 Maven 来编译我们的代码,并将其放入一个.jar文件中:

mvn package

在我们的例子中,项目名为chapter-08-dl4j,所以用 Maven 执行包目标会在target文件夹中创建一个chapter-08-dl4j-0.0.1-SNAPSHOT.jar文件。但是因为我们也使用了依赖插件,它创建了一个libs文件夹,在那里你可以找到所有的依赖。让我们把一切都放入一个.zip文件中:

zip -r code.zip chapter-08-dl4j-0.0.1-SNAPSHOT.jar libs/

执行准备步骤后,我们将有两个 ZIP 文件,all-data.zipcode.zip

现在,当我们准备好程序和数据后,我们可以去aws.amazon.com登录控制台,或者创建一个帐户(如果你还没有)。进入后,选择 EC2,这将带您进入 EC2 仪表板。接下来,您可以选择您感兴趣的地区。你可以选择地理位置相近的或者最便宜的。通常,北弗吉尼亚和美国西俄勒冈相对于其他地方来说是相当便宜的。

然后,找到启动实例按钮并点击它。

如果您只在几个小时内需要一台 GPU 计算机,您可以选择创建一个 spot 实例——它们比通常的实例便宜,但它们的价格是动态的,在某些时候,如果有人愿意为您正在使用的实例支付更多费用,这样的实例可能会消亡。在启动它的时候,你可以设置一个价格阈值,如果你在那里选择了$1 这样的东西,那么这个实例应该会持续很长时间。

创建实例时,可以使用现有的 AMI,它是预安装了某些软件的系统的映像。这里最好的选择是寻找 CUDA,它会给你官方的 NVidia CUDA 7.5 映像,但你可以自由选择你想要的任何其他映像。

注意,有些阿美族不是免费的,选择时要慎重。此外,选择您可以信任的 AMI 提供者,因为有时可能会有恶意图像,它们会将计算资源用于您的任务之外的其他事情。如果有疑问,请使用 NVidia 官方图像,或者自己从头创建一个图像。

一旦选择了图像,就可以选择实例类型。对于我们的目的来说,g2.2.xlarge实例已经足够了,但是如果您愿意,还有更大更强大的实例。

接下来,你需要选择存储类型;我们不需要任何东西,可以跳过这一步。但是接下来很重要,我们在这里设置安全规则。由于 UI 仪表板运行在端口 9,000 上,我们需要打开它,这样就可以从外部访问它。然后我们可以添加一个定制的 TCP 规则,并在那里写入9000

在这一步之后,我们就完成了,可以在查看详细信息之前启动实例了。

接下来,它会要求您为实例指定 ssh 的密钥对(.pem),如果您没有密钥对,可以创建并下载一个新的密钥对。让我们创建一个名为dl4j的密钥对,并将其保存到主文件夹中。

现在,实例已经启动,可以使用了。要访问它,请转到仪表板并找到该实例的公共 DNS,这是您可以用来从您的机器访问服务器的名称。让我们将它放入一个环境变量中:

EC2_HOST=ec2-54-205-18-41.compute-1.amazonaws.com

从现在开始,我们将假设您在 Linux 上使用 bash shell,但是它应该可以在 MacOS 或 Windows 上与 cygwin 或 MinGW 一起很好地工作。

现在,我们可以上传之前构建的.jar文件和数据。为此,我们将使用sftp。使用pem文件连接sftp客户端是这样完成的:

sftp -o IdentityFile=~/dl4j.pem ec2-user@$EC2_HOST

请注意,您应该位于包含数据和程序档案的文件夹中。然后,您可以通过执行以下命令来上传它们:

put code.zip
put all-data.zip

数据已经上传,所以现在我们可以对实例应用ssh来运行程序:

ssh -i "~/dl4j.pem" ec2-user@$EC2_HOST

我们要做的第一件事是打开档案:

unzip code.zip
unzip all-data.zip

如果由于某种原因,您的主文件夹中没有剩余的可用空间,运行df -h命令来查看是否有剩余空间。必须有其他具有可用空间的磁盘,您可以在其中存储数据。

到目前为止,我们已经打开了所有的文件,并准备好执行代码。但如果你用的是英伟达的 CUDA 7.5 AMI,它只有 Java 7 支持。因为我们使用 Java 8 编写代码,所以我们需要安装 Java 8:

sudo yum install java-1.8.0-openjdk.x86_64

当我们离开 ssh 会话时,我们不希望执行停止,所以最好在那里创建screen(如果您愿意,也可以使用tmux):

screen -R dl4j

现在我们在那里运行代码:

java8 -cp chapter-08-dl4j.jar:libs/* chapter08.catsdogs.VggCatDog ~/data

一旦你看到模型开始训练,你可以通过按下 Ctrl + A 然后按下 d 来分离屏幕。现在你可以关闭终端并使用 UI 来观看训练过程。为此,只需将EC2_HOST:9000放到浏览器中,其中EC2_HOST是实例的公共 DNS。

就是这样,现在你只需要等待一段时间,直到你的模型收敛。

一路上可能会有一些问题。

如果它说找不到openblas二进制文件,那么你有几个选择。您可以从libs文件夹中删除dl4j-nativejar,或者安装 openblas。第一个选项可能更好,因为我们不需要使用 CPU。

您可能遇到的另一个问题是缺少 NVCC 可执行文件,这是 dj4j 的 CUDA 7.5 库所需要的。解决这个问题很简单,您只需要将 CUDA 二进制文件的路径添加到 path 变量中:

PATH=/usr/local/cuda-7.5/bin:$PATH

摘要

在这一章中,我们看了如何在 Java 应用程序中使用深度学习,学习了 DeepLearning4j 库的基础知识,然后尝试将其应用于一个图像识别问题,我们希望将图像分类为狗和猫。

在下一章,我们将介绍 Apache Spark——一个用于在机器集群上分发数据科学算法的库。

九、扩展数据科学

到目前为止,我们已经讲述了许多关于数据科学的材料,我们学习了如何在 Java 中进行监督和非监督学习,如何执行文本挖掘,使用 XGBoost 和训练深度神经网络。然而,到目前为止,我们使用的大多数方法和技术都是在假设所有数据都可以存储在内存中的情况下,设计在单台机器上运行的。您应该已经知道,这是经常发生的情况:有非常大的数据集是不可能用传统的技术在典型的硬件上处理的。

在这一章中,我们将看到如何处理这样的数据集——我们将看到允许在几台机器上处理数据的工具。我们将讨论两个用例:一个是来自普通抓取的大规模 HTML 处理——网页的拷贝,另一个是社交网络的链接预测。

我们将讨论以下主题:

  • Apache Hadoop MapReduce
  • 通用爬网处理
  • 阿帕奇火花
  • 链接预测
  • Spark GraphFrame 和 MLlib 库
  • Apache Spark 上的 XGBoost

在本章结束时,你将学会如何使用 Hadoop 从普通抓取中提取数据,如何使用 Apache Spark 进行链接预测,以及如何在 Spark 中使用 XGBoost。

Apache Hadoop

Apache Hadoop 是一套工具,允许您将数据处理管道扩展到数千台机器。它包括:

  • Hadoop MapReduce :这是一个数据处理框架
  • HDFS: 这是一个分布式文件系统,允许我们在多台机器上存储数据
  • YARN: 这是 MapReduce 等作业的执行程序

我们将只讨论 MapReduce,因为它是 Hadoop 的核心,并且与数据处理相关。我们不会讨论其余的内容,也不会讨论如何设置或配置 Hadoop 集群,因为这已经超出了本书的范围。如果你有兴趣了解更多,由汤姆·怀特撰写的 Hadoop:权威指南是一本深入学习这个主题的优秀书籍。

在我们的实验中,我们将使用本地模式,也就是说,我们将模拟集群,但仍然在本地机器上运行代码。这对于测试非常有用,一旦我们确定它能够正常工作,就可以将其部署到集群中,无需任何更改。

Hadoop MapReduce

正如我们已经说过的,Hadoop MapReduce 是一个库,它允许您以可扩展的方式处理数据。

MapReduce 框架中有两个主要的抽象:Map 和 Reduce。这个想法最初来自函数式编程范例,其中mapreduce是高级函数:

  • map:它接受一个函数和一系列元素,并依次将函数应用于每个元素。结果是一个新的序列。
  • reduce:它也接受一个函数和一个序列,并使用这个函数处理序列,最终返回一个元素。

在本书中,我们已经相当广泛地使用了来自 Java Stream API 的 map 函数,从第二章、数据处理工具箱开始,所以你现在一定对它相当熟悉了。

在 Hadoop MapReduce 中,mapreduce函数与其前辈有些不同:

  • Map 接受一个元素并返回许多键值对。它可以不返回任何东西,也可以返回一个或几个这样的对,所以它比mapflatMap
  • 然后通过排序将输出按关键字分组
  • 最后,reduce接受一个组,并为每个组输出一些键-值对

通常,MapReduce 以单词计数为例进行说明:给定一个文本,我们希望计算每个单词在文本中出现的次数。解决方案如下:

  • map接收文本,然后将其标记化,并为每个标记输出一对(token, 1),其中token是密钥,1是关联值。
  • reducer对所有 1 求和,这是最终计数。

我们将实现类似的东西:我们将为语料库中的每个标记创建 TF-IDF 向量,而不仅仅是计数单词。但是首先,我们需要从某个地方获取大量的文本数据。我们将使用公共爬网数据集,它包含网站的副本。

普通爬行

通用抓取(http://commoncrawl.org/)是过去七年从互联网上抓取的数据的储存库。它非常大,而且每个人都可以下载和分析。

当然,我们不可能全部使用它:即使一小部分也是如此之大,以至于需要一个大而强大的集群来处理它。在这一章中,将从 2016 年底的几个档案中,摘录文字 ting TF-IDF。

下载数据并不复杂,你可以在http://commoncrawl.org/the-data/get-started/找到说明。这些数据已经存在于 S3 的存储中,因此 AWS 用户可以很容易地访问它们。然而,在这一章中,我们将通过 HTTP 下载一部分通用爬网,而不使用 AWS。

在撰写本文时,最近的数据来自 2016 年 12 月,位于s3://commoncrawl/crawl-data/CC-MAIN-2016-50。按照说明,我们首先需要获得本月各个归档文件的所有路径,它们存储在一个warc.paths.gz文件中。所以,在我们的例子中,我们对s3://commoncrawl/crawl-data/CC-MAIN-2016-50/warc.paths.gz感兴趣。

因为我们不打算使用 AWS,所以我们需要将它转换成可以通过 HTTP 下载的路径。为此,我们将s3://commoncrawl/替换为https://commoncrawl.s3.amazonaws.com:

wget https://commoncrawl.s3.amazonaws.com/crawl-data/CC-MAIN-2016-50/warc.paths.gz

让我们看看文件:

zcat warc.paths.gz | head -n 3

你会看到很多这样的行(为了简洁省略了后缀):

.../CC-MAIN-20161202170900-00000-ip-10-31-129-80.ec2.internal.warc.gz
.../CC-MAIN-20161202170900-00001-ip-10-31-129-80.ec2.internal.warc.gz
.../CC-MAIN-20161202170900-00002-ip-10-31-129-80.ec2.internal.warc.gz

为了通过 HTTP 下载它,我们再次需要将https://commoncrawl.s3.amazonaws.com/附加到这个文件的每一行。这可以通过 awk 轻松实现:

zcat warc.paths.gz 
  | head 
  | awk '{ print "https://commoncrawl.s3.amazonaws.com/" $0}' 
  > files.txt

现在我们有了这个文件的前 10 个 URL,所以我们可以下载它们:

for url in $(cat files.txt); do
  wget $url;
done

为了加快速度,我们可以用 gnu-parallel 并行下载文件:

cat files.txt | parallel --gnu "wget {}"

现在我们已经下载了一些较大的数据:大约 10 个文件,每个 1GB。请注意,路径文件中大约有 50,000 行,仅 12 月份就有大约 50,000 GBs 的数据。这是大量的数据,每个人都可以在任何时候使用它!我们不会用光所有的文件,只会集中在我们已经下载的 10 个文件上。让我们用 Hadoop 来处理它们。

第一步很正常:我们需要在.pom文件中指定对 Hadoop 的依赖:

<dependency>
  <groupId>org.apache.hadoop</groupId>
  <artifactId>hadoop-client</artifactId>
  <version>2.7.3</version>
</dependency>
<dependency>
  <groupId>org.apache.hadoop</groupId>
  <artifactId>hadoop-common</artifactId>
  <version>2.7.3</version>
</dependency>

通用爬网使用 WARC 来存储 HTML 数据:这是一种存储爬网数据的特殊格式。为了能够处理它,我们需要添加一个特殊的库来读取它:

<dependency>
  <groupId>org.netpreserve.commons</groupId>
  <artifactId>webarchive-commons</artifactId>
  <version>1.1.2</version>
</dependency>

接下来,我们需要告诉 Hadoop 如何使用这样的文件。为此,程序员通常需要提供FileRecordReaderFileImportFormat类的实现。幸运的是,有开源的实现,我们可以复制并粘贴到我们的项目中。其中一个在org.commoncrawl.warc包装的https://github.com/Smerity/cc-warc-examples有售。所以我们只是从那里复制WARCFileInputFormatWARCFileRecordReader到我们的项目中。该代码也包含在本书的代码包中,以防存储库被删除。

有了这些,我们就可以开始编码了。首先,我们需要创建一个Job类:它指定将使用哪个映射器和缩减器类来运行作业,并允许我们配置如何执行该作业。所以,让我们创建一个WarcPreparationJob类,它扩展了Configured类并实现了Tool接口:

public class WarcPreparationJob extends Configured implements Tool {
    public static void main(String[] args) throws Exception {
        int res = ToolRunner.run(new Configuration(), 
              new WarcPreparationJob(), args);
        System.exit(res);
    }

    public int run(String[] args) throws Exception {
        // implementation goes here
    }
}

用于Tool接口的 Java 文档信息丰富,详细描述了如何实现这样一个Job类:它覆盖了run方法,在这里它应该指定输入和输出路径以及映射器和缩减器类。

我们将稍微修改这段代码:首先,我们将有一个只有地图的作业,所以我们不需要一个缩减器。此外,因为我们正在处理文本,所以压缩输出是有用的。所以,让我们用下面的代码创建run方法。首先,我们创建一个Job类:

Job job = Job.getInstance(getConf());

现在我们来看看输入及其格式(在我们的例子中是 WARC):

Path inputPath = new Path(args[0]);
FileInputFormat.addInputPath(job, inputPath);
job.setInputFormatClass(WARCFileInputFormat.class);

接下来,我们指定输出,它是 gzipped 文本:

Path outputPath = new Path(args[1];
TextOutputFormat.setOutputPath(job, outputPath);
TextOutputFormat.setCompressOutput(job, true);
TextOutputFormat.setOutputCompressorClass(job, GzipCodec.class);
job.setOutputFormatClass(TextOutputFormat.class);

通常,输出是键-值对,但是因为我们只想处理 WARC 并从中提取文本,所以我们只输出一个键,没有值:

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

最后,我们指定了映射器类,并说将没有缩减器:

job.setMapperClass(WarcPreparationMapper.class);
job.setNumReduceTasks(0);

现在,当我们指定了作业后,我们可以实现映射器类- WarcPreparationMapper。这个类应该扩展Mapper类。所有的映射器都应该实现map方法,所以我们的映射器应该有如下的轮廓:

public class WarcPreparationMapper extends 
        Mapper<Text, ArchiveReader, Text, NullWritable> {

   @Override
   protected void map(Text input, ArchiveReader archive, Context context) 
             throws IOException, InterruptedException {
       // implementation goes here
   }

}

map方法接收一个带有记录集合的 WARC archive,所以我们想要处理所有记录。因此,我们把下面的map法:

for (ArchiveRecord record : archive) {
    process(record, context);
}

process 方法执行以下操作:从记录中提取 HTML,然后从 HTML 中提取文本,对其进行标记,最后将结果写入输出。在代码中,它看起来像这样:

String url = record.getHeader().getUrl();
String html = TextUtils.extractHtml(record);
String text = TextUtils.extractText(html);
List<String> tokens = TextUtils.tokenize(text);
String result = url + "t" + String.join(" ", tokens);
context.write(new Text(result), NullWritable.get());

在里面我们使用了三个助手函数:extractHtmlextractTexttokenize。后两个(exctractHtmltokenize)我们已经用过几次了,所以省略它们的实现;参考第 6 章处理文本-自然语言处理和信息检索

第一个是extractHtml,包含以下代码:

byte[] rawData = IOUtils.toByteArray(r, r.available());
String rawContent = new String(rawData, "UTF-8");
String[] split = rawContent.split("(r?n){2}", 2);
String html = split[1].trim();

它使用 UTF-8 编码(有时可能不理想,因为不是互联网上的所有页面都使用 UTF-8 编码)将来自存档的数据转换为String,然后删除响应头,只保留剩余的 HTML。

最后,为了运行这些类,我们可以使用下面的代码:

String[] args = { /data/cc_warc", "/data/cc_warc_processed" };
ToolRunner.run(new Configuration(), new WarcPreparationJob(), args);

这里,我们手动指定“命令行”参数(在 main 方法中获得的参数),并将它们传递给ToolRunner类,该类可以在本地模型中运行 Hadoop 作业。

结果可能有色情内容。由于 Common Crawl 是对 Web 的复制,而且互联网上有大量的色情网站,所以很有可能你会在处理后的结果中看到一些色情文字。通过保留一个特殊的色情关键词列表,并丢弃所有包含这些词的文档,可以很容易地将其过滤掉。

运行此作业后,您将看到结果中有大量不同的语言。如果我们对特定的语言感兴趣,那么我们可以自动检测文档的语言,并且只保留那些我们感兴趣的语言的文档。

几个可以进行语言检测的 Java 库。其中之一是 language-detector,它可以通过下面的依赖片段包含在我们的项目中:

<dependency>
  <groupId>com.optimaize.languagedetector</groupId>
  <artifactId>language-detector</artifactId>
  <version>0.5</version>
</dependency>

毫不奇怪,这个库使用机器学习来检测语言。因此,要使用它,我们需要做的第一件事是加载模型:

List<LanguageProfile> languageProfiles =
        new LanguageProfileReader().readAllBuiltIn();
LanguageDetector detector = LanguageDetectorBuilder.create(NgramExtractors.standard())
        .withProfiles(languageProfiles)
        .build();

我们可以这样使用它:

Optional<LdLocale> result = detector.detect(text);
String language = "unk";
if (result.isPresent()) {
    language = result.get().getLanguage();
}

这样,我们可以只保留英语(或任何其他语言)的文章,而丢弃其他的。因此,让我们从下载的文件中提取文本:

String lang = detectLanguage(text.get());
if (lang.equals("en")) {
    // process the data
}

这里,detectLanguage是一个方法,它包含了检测文本语言的代码:我们之前写了这个代码。

一旦我们处理了 WARC 文件并从中提取了文本,我们就可以为语料库中的每个令牌计算 IDF。为此,我们需要首先计算测向文档频率。这与字数统计示例非常相似:

  • 首先,我们需要一个为文档中每个不同的单词输出1mapper
  • 然后reducer将所有的数字相加,得出最终的数字

该作业将处理我们刚刚从通用爬网解析的文档。

让我们创建映射器。它将在map方法中包含以下代码:

String doc = value.toString();
String[] split = doc.split("t");
String joinedTokens = split[1];
Set<String> tokens = Sets.newHashSet(joinedTokens.split(" "));
LongWritable one = new LongWritable(1);

for (String token : tokens) {
    context.write(new Text(token), one);
}

映射器的输入是一个Text对象(名为value,它包含 URL 和令牌。我们使用HashSet分割令牌,只保留不同的令牌。最后,对于每个不同的令牌,我们编写1

为了计算 IDF,我们通常需要知道 N :我们语料库中的文档数量。有两种方法可以得到它。首先,我们可以使用计数器:创建一个计数器,并为每个成功处理的文档递增。这很容易做到。

第一步是用我们希望在应用程序中使用的可能计数器创建一个特殊的enum。因为我们只需要一种类型的计数器,所以我们创建了一个只有一个元素的enum:

public static enum Counter {
    DOCUMENTS;
}

第二步是使用context.getCounter()方法并递增计数器:

context.getCounter(Counter.DOCUMENTS).increment(1);

一旦作业结束,我们可以用下面的代码获得计数器的值:

Counters counters = job.getCounters();
long count = counters.findCounter(Counter.DOCUMENTS).getValue();

但是还有另一种选择:我们可以选择一个大的数字,并将其用作文档的数量。通常不需要精确,因为所有令牌的 IDF 共享相同的 N

现在,让我们继续进行reducer。由于映射器输出Text和一个 long(通过LongWritable),缩减器得到一个Text和一个LongWritable类上的 iterable 这是令牌和一堆 1。我们能做的只是对它们求和:

long sum = 0;
for (LongWritable cnt : values) {
    sum = sum + cnt.get();
}

为了只保留频繁出现的单词,我们可以添加一个过滤器,丢弃所有不常用的单词,使结果明显变小:

if (sum > 100) {
    context.write(key, new LongWritable(sum));
}

然后,我们的job类中运行它的代码将如下所示:

job.setInputFormatClass(TextInputFormat.class);
job.setOutputFormatClass(TextOutputFormat.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(LongWritable.class);

job.setMapperClass(DocumentFrequencyMapper.class);
job.setCombinerClass(DocumentFrequencyReducer.class);
job.setReducerClass(DocumentFrequencyReducer.class);

请注意,我们不仅设置了 mapper 和 reducer,还指定了一个合并器:这允许我们预聚合一些我们在 mapper 中输出的1,并且花费更少的时间对数据进行排序并在网络中发送结果。

最后,要将文档转换为 TF-IDF,我们可以创建第三个作业,再次使用 reduce-less,它将读取第一个作业的结果(在那里我们处理了 WARC 文件),并应用第二个作业的 IDF 权重。

我们希望第二个作业的输出应该非常小,以适合内存,所以我们可以做的是将文件发送到所有的映射器,在初始化期间读取它,然后检查已处理的 WARC 和先前的行。

job类的主要部分是一样的:我们输入,输出是Text——输出被压缩,reducer 任务数是0

现在我们需要将df任务的结果发送给所有的映射器。这是通过缓存文件完成的:

Path dfInputPath = new Path(args[3]);
job.addCacheFile(new URI(dfInputPath.toUri() + "#df"));

因此,我们在这里指定了df作业结果的路径,然后将其放入缓存文件。注意最后的#df:这是我们稍后访问文件时使用的别名。

在映射器中,我们可以将所有结果读入一个映射(在设置方法中):

dfs = new HashMap<>();
File dir = new File("./df");
for (File file : dir.listFiles()) {
    try (FileInputStream is = FileUtils.openInputStream(file)) {
        LineIterator lines = IOUtils.lineIterator(is, StandardCharsets.UTF_8);
        while (lines.hasNext()) {
            String line = lines.next();
            String[] split = line.split("t");
            dfs.put(split[0], Integer.parseInt(split[1]));
        }
    }
}

这里,df是我们赋予结果文件的别名,它实际上是一个文件夹,而不是一个文件。因此,为了得到结果,我们需要检查文件夹中的每个文件,逐行读取它们,并将结果放入一个映射中。然后,我们可以在应用 IDF 权重的 map 方法中使用计数字典:

String doc = value.toString();
String[] split = doc.split("t");
String url = split[0];
List<String> tokens = Arrays.asList(split[1].split(" "));
Multiset<String> counts = HashMultiset.create(tokens);
String tfIdfTokens = counts.entrySet().stream()
        .map(e -> toTfIdf(dfs, e))
        .collect(Collectors.joining(" "));
Text output = new Text(url + "t" + tfIdfTokens);
context.write(output, NullWritable.get());

在这里,我们使用标记并使用Multiset来计算术语频率。接下来,我们在toTfIdf函数中将 TF 乘以 IDF:

String token = e.getElement();
int tf = e.getCount();
int df = dfs.getOrDefault(token, 100);
double idf = LOG_N - Math.log(df);
String result = String.format("%s:%.5f", token, tf * idf);

在这里,我们获得了Multiset的每个输入条目的 DF(文档频率),如果该标记不在我们的字典中,我们假设它相当罕见,因此我们为它指定默认 of 100。接下来我们算 IDF,最后算tf*idf。为了计算 IDF,我们使用LOG_N,它是我们设置为Math.log(1_000_000)的常数。

对于这个例子,一百万被选作文档的数量。即使文档的实际数量更少(大约 5k),但对于所有的令牌来说都是一样的。更重要的是,如果我们决定向索引中添加更多的文档,我们仍然可以使用相同的 N 而不用担心重新计算所有的内容。

这会产生如下所示的输出:

http://url.com/        flavors:9.21034 gluten:9.21034 specialty:14.28197 salad:18.36156 ...

您一定注意到了,每个作业的输出都保存在磁盘上。如果我们有多个任务,就像我们以前做的那样,我们需要读取数据,处理数据,然后保存回来。I/O 的开销很大,所以其中一些步骤是中间的,我们不需要保存结果。在 Hadoop 中,这是无法避免的,这就是为什么它有时会非常慢,并且会产生大量 I/O 开销。

幸运的是,有另一个库可以解决这个问题:Apache Spark。

阿帕奇火花

Apache Spark 是一个用于可伸缩数据处理的框架。它被设计得比 Hadoop 更好:它试图在内存中处理数据,而不是将中间结果保存在磁盘上。此外,它有更多的操作,不仅仅是 map 和 reduce,因此有更丰富的 API。

Apache Spark 中的主要抽象单元是弹性分布式数据集 ( RDD ),它是元素的分布式集合。与通常的集合或流的关键区别在于,rdd 可以在多台机器上并行处理,处理 Hadoop 作业的方式也是如此。

我们可以对 rdd 应用两种类型的操作:转换和操作。

  • 转换:顾名思义,它只是把数据从一种形式变成另一种形式。作为输入,它们接收一个 RDD,同时也输出一个 RDD。诸如 map、flatMap 或 filter 之类的操作是变换操作的示例。
  • 动作:这些接受一个 RDD 并产生其他东西,例如一个值、一个列表或一个地图,或者保存结果。动作的例子是计数和减少。

像在 Java Steam API 中一样,转换是懒惰的:它们不是立即执行的,而是链接在一起并一次性计算,不需要将中间结果保存到磁盘。在 Steam API 中,链是通过收集流来触发的,在 Spark 中也是如此:当我们执行一个动作时,这个特定动作所需的所有转换都会被执行。另一方面,如果一些转换是不需要的,那么它们将永远不会被执行,这就是为什么它们被称为懒惰。

所以让我们从 Spark 开始,首先将它的依赖项包含到.pom文件中:

<dependency>
  <groupId>org.apache.spark</groupId>
  <artifactId>spark-core_2.11</artifactId>
  <version>2.1.0</version>
</dependency>

在本节中,我们将使用它来计算 TF-IDF:因此我们将尝试重现我们刚刚为 Hadoop 编写的算法。

第一步是创建配置和上下文:

SparkConf conf = new SparkConf().setAppName("tfidf").setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(conf);

这里,我们指定了 Spark 应用程序的名称以及它将连接到的服务器 URL。因为我们在本地模式下运行 Spark,所以我们把local[*]。这意味着我们建立一个本地服务器,并创建尽可能多的本地工人。

Spark 依赖于一些 Hadoop 实用程序,这使得它更难在 Windows 上运行。如果在 Windows 下运行,可能会遇到找不到 Spark 和 Hadoop 需要的winutils.exe文件的问题。要解决这个问题,请执行以下操作:

System.setProperty("hadoop.home.dir", "c:/tmp/hadoop");

这应该能解决问题。

下一步是读取文本文件,这是我们在处理了常见的爬网文件后用 Hadoop 创建的。我们来读一下:

JavaRDD<String> textFile = sc.textFile("C:/tmp/warc");

要查看文件,我们可以使用 take 函数,该函数从 RDD 中返回前 10 个元素,然后我们将每一行打印到 stdout:

textFile.take(10).forEach(System.out::println);

现在,我们可以逐行读取文件,对于每个文档,输出它具有的所有不同的标记:

JavaPairRDD<String, Integer> dfRdd = textFile
    .flatMap(line -> distinctTokens(line))
   .mapToPair(t -> new Tuple2<>(t, 1))
   .reduceByKey((a, b) -> a + b)
   .filter(t -> t._2 >= 100);

这里,distinctToken是一个函数,它拆分行并将所有的记号放入一个集合中,只保留不同的记号。下面是它的实现方式:

private static Iterator<String> distinctTokens(String line) {
    String[] split = line.split("t");
   Set<String> tokens = Sets.newHashSet(split[1].split(" "));
   return tokens.iterator();
}

flatMap函数需要返回一个迭代器,所以我们在最后调用集合上的迭代器方法。

接下来,我们将每一行转换成一个元组:需要这一步来告诉 Spark 我们有了键值对,所以像reduceByKeygroupByKey这样的函数是可用的。最后,我们用与之前在 Hadoop 中相同的实现来调用reduceByKey方法。在转换链的末尾,我们应用过滤器只保留足够频繁的令牌。

您可能已经注意到,与我们之前编写的 Hadoop 相比,这段代码非常简单。

现在我们可以将所有的结果放入一个Map中:因为我们应用了过滤,所以我们期望字典应该很容易放入一个内存中,即使是在普通的硬件上。我们通过使用collectAsMap函数来实现:

Map<String, Integer> dfs = dfRdd.collectAsMap();

最后,我们再次检查所有文档,这次将 TF-IDF 加权方案应用于所有令牌:

JavaRDD<String> tfIdfRdd = textFile.map(line -> {
    String[] split = line.split("t");
    String url = split[0];
    List<String> tokens = Arrays.asList(split[1].split(" "));
    Multiset<String> counts = HashMultiset.create(tokens);

    String tfIdfTokens = counts.entrySet().stream()
           .map(e -> toTfIdf(dfs, e))
           .collect(Collectors.joining(" "));

    return url + "t" + tfIdfTokens;
});

我们像以前一样解析输入,并使用来自 Guava 的Multiset来计算 TF。toTfIdf函数与之前完全相同:它从Multiset接收一个条目,通过 IDF 对其进行加权,并输出一个token:weight格式的字符串。

为了查看结果,我们可以从 RDD 中取出前 10 个令牌,并将其打印到 stdout:

tfIdfRdd.take(10).forEach(System.out::println);

最后,我们使用saveAsTextFile方法将结果保存到文本文件中:

tfIdfRdd.saveAsTextFile("c:/tmp/warc-tfidf");

正如我们所见,我们可以用少得多的代码在 Spark 中做同样的事情。更重要的是,它的效率也更高:它不需要在每一步之后都将结果保存到磁盘上,而是动态地应用所有需要的转换。这使得 Spark 在许多应用程序上比 Hadoop 快得多。

但是,也有 Hadoop 优于 Spark 的情况。Spark 试图将所有东西都保存在内存中,有时会因为这个和OutOfMemoryException一起失败。Hadoop 要简单得多:它所做的只是写入文件,然后对数据执行大型分布式合并排序。也就是说,一般来说,你应该更喜欢 Apache Spark 而不是 Hadoop MapReduce,因为 Hadoop 速度较慢且相当冗长。

在下一节中,我们将看到如何使用 Apache Spark 及其图形处理和机器学习库来解决链接预测问题。

链接预测

链路预测是预测网络中会出现哪些链路的问题。例如,我们可以在脸书或另一个社交网络中有一个友谊图,像你可能认识的人这样的功能是链接预测的一个应用。因此,我们可以看到链接预测是一个社交网络推荐系统。

*对于这个问题,我们需要找到一个数据集,其中包含一个随时间演变的图。然后,我们可以考虑这样一个图在其演变过程中的某个时刻,计算现有链接之间的一些特征,并以此为基础,预测接下来可能会出现哪些链接。因为对于这样的图,我们知道未来,我们可以使用这种知识来评估我们的模型的性能。

有许多有趣的数据集可用,但不幸的是,它们中的大多数都没有与边相关联的时间,因此不可能看到这些图表是如何随着时间的推移而发展的。这使得测试方法更加困难,但是,当然,没有时间维度也是可能的。

幸运的是,有一些带有时间戳边的数据集。对于这一章,我们将使用基于 DBLP(http://dblp.uni-trier.de/)数据的合著图,这是一个索引计算机科学论文的搜索引擎。该数据集可从http://projects.csail.mit.edu/dnd/DBLP/(dblp_coauthorship.json.gz文件)获得,它包括 1938 年至 2015 年的论文。它已经以图表的形式出现了:每条边是一对一起发表论文的作者,每条边还包含他们发表论文的年份。

该文件的前几行如下所示:

[
["Alin Deutsch", "Mary F. Fernandez", 1998],
["Alin Deutsch", "Daniela Florescu", 1998],
["Alin Deutsch", "Alon Y. Levy", 1998],
["Alin Deutsch", "Dan Suciu", 1998],
["Mary F. Fernandez", "Daniela Florescu", 1998],

让我们使用这个数据集来建立一个模型,该模型将预测谁很可能在未来成为合著者。这种模型的应用之一可以是推荐系统:对于每个作者,它可以建议可能的合作作者。* *

阅读 DBLP 图表

从这个项目开始,我们首先需要读取图形数据,为此,我们将使用 Apache Spark 和它的一些库。第一个库是 Spark Data frames,它类似于 R data frames、pandas 或 joinery,只是它们是分布式的,基于 RDDs。

让我们来看看这个数据集。第一步是创建一个特殊的类Edge来存储数据:

public class Edge implements Serializable {
    private final String node1;
    private final String node2;
    private final int year;
    // constructor and setters omitted
}

现在,让我们来读数据:

SparkConf conf = new SparkConf().setAppName("graph").setMaster("local[*]");
JavaSparkContext sc = new JavaSparkContext(conf);
JavaRDD<String> edgeFile = sc.textFile("/data/dblp/dblp_coauthorship.json.gz");

JavaRDD<Edge> edges = edgeFile.filter(s -> s.length() > 1).map(s -> {
    Object[] array = JSON.std.arrayFrom(s);

    String node1 = (String) array[0];
    String node2 = (String) array[1];
    Integer year = (Integer) array[2];

    if (year == null) {
        return new Edge(node1, node2, -1);
    }

    return new Edge(node1, node2, year);
});

设置完上下文后,我们从一个文本文件中读取数据,然后对每一行应用一个 map 函数,将其转换为Edge。为了解析 JSON,我们像前面一样使用 Jackson-Jr 库,所以确保将它添加到 pom 文件中。

注意,我们在这里还包含了一个filter:第一行和最后一行分别包含了[],所以我们需要跳过它们。

为了检查我们是否成功地解析了数据,我们可以使用 take 方法:它获取 RDD 的头部并将其放入一个List,我们可以将它打印到控制台:

edges.take(5).forEach(System.out::println);

这将产生以下输出:

Edge [node1=Alin Deutsch, node2=Mary F. Fernandez, year=1998]
Edge [node1=Alin Deutsch, node2=Daniela Florescu, year=1998]
Edge [node1=Alin Deutsch, node2=Alon Y. Levy, year=1998]
Edge [node1=Alin Deutsch, node2=Dan Suciu, year=1998]
Edge [node1=Mary F. Fernandez, node2=Daniela Florescu, year=1998]

成功转换数据后,我们会将其放入数据框中。为此,我们将使用 Spark DataFrame,它是 Spark-SQL 包的一部分。我们可以通过以下依赖关系来包含它:

<dependency>
  <groupId>org.apache.spark</groupId>
  <artifactId>spark-sql_2.11</artifactId>
  <version>2.1.0</version>
</dependency>

为了从我们的RDD创建一个DataFrame,我们首先创建一个 SQL 会话,然后使用它的createDataFrame方法:

SparkSession sql = new SparkSession(sc.sc());
Dataset<Row> df = sql.createDataFrame(edges, Edge.class);

数据集中有相当多的论文。我们可以通过将它限制为仅在1990.发表的论文来缩小它,为此我们可以使用filter方法:

df = df.filter("year >= 1990");

接下来,很多作者可以有多篇论文在一起,我们感兴趣的是最早的一篇。我们可以使用min函数得到它:

df = df.groupBy("node1", "node2")
       .min("year")
       .withColumnRenamed("min(year)", "year");

当我们应用 min 函数时,该列被重命名为min(year),所以我们通过用withColumnRenamed函数将该列重命名回year来修复它。

为了建立任何机器学习模型,我们总是需要指定一个训练/测试分割。这个案例也不例外,所以我们把 2013 年之前的所有数据作为训练部分,之后的所有论文作为测试:

Dataset<Row> train = df.filter("year <= 2013");
Dataset<Row> test = df.filter("year >= 2014");

现在,我们可以开始提取一些将用于创建模型的特征。

从图中提取特征

我们需要提取一些特征,然后放入机器学习模型进行训练。对于这个数据集,我们拥有的所有信息就是图表本身,仅此而已:我们没有任何外部信息,比如作者的从属关系。当然,如果我们有,加到模型里也没问题。所以我们来讨论一下,单独从图中可以提取哪些特征。

对于图模型,可以有两种特征:节点特征(作者)和边特征(合著关系)。

我们可以从图节点中提取许多可能的特征。例如,除其他外,我们可以考虑以下情况:

  • :这是这个作者拥有的合著者数量。
  • 页面排名:这是一个节点的重要性。

让我们看看下图:

这里我们有两个连接的组件,节点上的数字指定了节点的度数,或者它有多少个连接。

在某种程度上,程度衡量一个节点的重要性(或中心性):它拥有的连接越多,重要性就越高。页面排名(也称为特征向量中心性)是另一种衡量重要性的方法。你可能听说过 Page Rank——它被 Google 用作排名公式的组成部分之一。网页排名背后的主要思想是,如果一个网页链接到其他重要的网页,它也必须是重要的。有时使用 Chei Rank 也是有意义的,它是页面等级的反向——它不看进来的边,而是看出去的边。

然而,我们的图是无向的:如果 AB 是合著者,那么 BA 也是合著者。所以,在我们的例子中,Page Rank 和 Chei Rank 是完全相同的。

还有其他一些重要的度量,比如接近中心性或中间中心性,但我们不会在本章中考虑它们。

此外,我们可以查看节点的连通分量:如果两个节点来自不同的连通分量,通常很难预测它们之间是否会有链接。(当然,如果我们包括其他特征,而不仅仅是我们可以从图中提取的特征,这就变得可能了。)

图也有边,我们可以从中提取很多信息来构建我们的模型。例如,我们可以考虑以下特征:

  • 共同好友:这是共同作者的数量
  • 好友总数:这是两位作者拥有的不同合著者的总数
  • Jaccard 相似度:这是合著者集合的 Jaccard 相似度
  • 基于节点的特征这是每个节点的页面排名的差异,最小和最大程度,等等

当然,我们可以包括许多其他特征,例如,两个作者之间的最短路径的长度应该是一个很强的预测因素,但是计算它通常需要很多时间。

节点特征

让我们首先集中于我们可以用来计算图的节点的特征。为此,我们将需要一个图形库,让我们能够轻松地计算图形特征,如程度或页面排名。对于 Apache Spark,这样的库就是 GraphX。然而,目前这个库只支持 Scala:它使用了很多 Scala 特有的特性,这使得从 Java 中使用它变得非常困难(而且经常是不可能的)。

然而,还有另一个名为 GraphFrames 的库,它试图将 GraphX 与 DataFrames 结合起来。幸运的是,它支持 Java。这个包在 Maven Central 上不可用,要使用它,我们首先需要将下面的存储库添加到我们的pom.xml:

<repository>
  <id>bintray-spark</id>
  <url>https://dl.bintray.com/spark-packages/maven/</url>
</repository>

接下来,让我们包括库:

<dependency>
  <groupId>graphframes</groupId>
  <artifactId>graphframes</artifactId>
  <version>0.3.0-spark2.0-s_2.11</version>
</dependency>

我们还需要添加 GraphX 依赖项,因为 GraphFrames 依赖于它:

<dependency>
  <groupId>org.apache.spark</groupId>
  <artifactId>spark-graphx_2.11</artifactId>
  <version>2.1.0</version>
</dependency>

GraphFrames 库目前正在积极开发中,将来很可能会更改一些方法名。如果本章中的例子停止工作,请参考 http://graphframes.github.io/的官方文档。

然而,在我们实际使用 GraphFrames 之前,我们需要准备我们的数据,因为它期望数据帧遵循特定的约定。首先,它假设图是有向的,但在我们的例子中不是这样。为了克服这个问题,我们需要添加反向链接。也就是说,我们的数据集中有一个记录(A, B),我们需要添加它的反向记录(B, A)

为此,我们可以重命名数据帧副本的列,然后对原始数据帧使用 union 函数:

Dataset<Row> dfReversed = df
        .withColumnRenamed("node1", "tmp")
        .withColumnRenamed("node2", "node1")
        .withColumnRenamed("tmp", "node2")
        .select("node1", "node2", "year")
Dataset<Row> edges = df.union(dfReversed);

接下来,我们需要为节点创建一个特殊的DataFrame。我们可以通过选择 edges 数据集的node1列,然后调用 distinct 函数来实现这一点:

Dataset<Row> nodes = edges.select("node1")
        .withColumnRenamed("node1", "node")
        .distinct();

GraphFrame 在接受一个带有节点的DataFrame时,希望它有一个id列,这是我们必须手动创建的。第一种选择是将node列重命名为id,并将其传递给 GraphFrame。另一种选择是创建代理 id,我们将在这里完成:

nodes = nodes.withColumn("id", functions.monotonicallyIncreasingId());

对于前面的代码,我们需要添加以下导入:

import org.apache.spark.sql.functions;

这个functions是一个实用程序类,有很多有用的 DataFrame 函数。

要查看所有这些转换后我们的数据帧是什么样子,我们可以使用show方法:

nodes.show();

它将产生以下输出(在此处和所有示例中,被截断为前六行):

+--------------------+---+
|                node| id|
+--------------------+---+
|         Dan Olteanu|  0|
|        Manjit Borah|  1|
|    Christoph Elsner|  2|
|         Sagnika Sen|  3|
|          Jerome Yen|  4|
|        Anand Kudari|  5|
|              M. Pan|  6|
+--------------------+---+

现在,让我们准备边缘。GraphFrames 要求数据帧有两个特殊的列:srcdst(分别是目的地)。要获得这些列的值,让我们将它们与节点数据帧连接起来,并获得数字 id:

edges = edges.join(nodes, edges.col("node2").equalTo(nodes.col("node")));
edges = edges.drop("node").withColumnRenamed("id", "dst");
edges = edges.join(nodes, edges.col("node1").equalTo(nodes.col("node")));
edges = edges.drop("node").withColumnRenamed("id", "src");

它将创建一个包含以下内容的数据帧:

+-------------+--------------------+----+-------------+----+
|        node1|               node2|year|          dst| src|
+-------------+--------------------+----+-------------+----+
|A. A. Davydov| Eugene V. Shilnikov|2013|  51539612101|2471|
|A. A. Davydov|      S. V. Sinitsyn|2011| 326417520647|2471|
|A. A. Davydov|      N. Yu. Nalutin|2011| 335007452466|2471|
|A. A. Davydov|        A. V. Bataev|2011| 429496733302|2471|
|A. A. Davydov|Boris N. Chetveru...|2013|1486058685923|2471|
| A. A. Sawant|          M. K. Shah|2011| 231928238662|4514|
| A. A. Sawant|      A. V. Shingala|2011| 644245100670|4514|
+-------------+--------------------+----+-------------+----+

最后,我们可以从节点和边数据帧创建GraphFrame:

GraphFrame gf = GraphFrame.apply(nodes, edges);

GraphFrame类允许我们使用很多图形算法。例如,计算页面排名非常简单,如下所示:

GraphFrame pageRank = gf.pageRank().resetProbability(0.1).maxIter(7).run();
Dataset<Row> pageRankNodes = pageRank.vertices();
pageRankNodes.show();

它将创建一个包含以下各列的DataFrame:

+----+-------------------+
|  id|           pagerank|
+----+-------------------+
|  26| 1.4394843416065657|
|  29|  1.012233852957335|
| 474| 0.7774103396731716|
| 964| 0.4443614094552203|
|1677|  0.274044687604839|
|1697|  0.493174385163372|
+----+-------------------+

计算度数甚至更简单:

Dataset<Row> degrees = gf.degrees();

这行代码将创建一个DataFrame与每个节点的度数:

+-------------+------+
|           id|degree|
+-------------+------+
| 901943134694|    86|
| 171798692537|     4|
|1589137900148|   114|
|   8589935298|    86|
| 901943133299|    74|
| 292057778121|    14|
+-------------+------+

计算连接的组件也是如此:

Dataset<Row> cc = gf.connectedComponents().run();

它将创建一个DataFrame,对于每个节点,我们将获得它所属的连接组件的 ID:

+----+---------+
|  id|component|
+----+---------+
|  26|        0|
|  29|        0|
| 474|        0|
| 964|      964|
|1677|        0|
|1697|        0|
+----+---------+

正如我们在这里看到的,6 个第一部分中有 5 个是第 0 部分。我们来看看这些组件的大小——可能 0th 是最大的,几乎包含了所有的东西?

为此,我们可以计算每个组件出现的次数:

Dataset<Row> cc = connectedComponents.groupBy("component").count();
cc.orderBy(functions.desc("count")).show();

我们使用groupBy函数,然后调用count方法。之后,我们按照 count 列的值以降序对数据帧进行排序。让我们看看输出:

+------------+-------+
|   component|  count|
+------------+-------+
|           0|1173137|
| 60129546561|     32|
| 60129543093|     30|
|         722|     29|
| 77309412270|     28|
| 34359740786|     28|
+------------+-------+

正如我们所看到的,大多数节点确实来自同一个组件。因此,在这种情况下,关于组件的信息不是很有用:几乎总是组件是0

现在,在计算完节点特征之后,我们需要继续处理边特征。但在此之前,我们首先需要对将要计算这些特征的边进行采样。

负采样

在我们计算另一组特征,即边特征之前,我们需要首先指定我们想要为此取哪些边。因此,我们需要选择一组候选边,然后我们将在它们的基础上训练一个模型来预测一个边是否应该属于该图。换句话说,我们首先需要准备一个数据集,其中存在的边被视为正例,不存在的边被视为反例。

获得正面的例子很简单:我们只取所有的边,并给它们分配标签1

对于反面例子来说,就更复杂了:在任何现实生活的图中,正面例子的数量都比反面例子的数量少很多。因此,我们需要找到一种方法来采样负面的例子,以便训练一个模型变得易于管理。

通常,对于链接预测问题,我们考虑两种类型的否定候选:简单的和困难的。简单的只是从相同的连接组件中采样,但困难的是一两跳之遥。因为对于我们的问题,大多数作者来自同一个组件,所以我们可以放松它,从整个图中抽取简单的否定,而不局限于同一个连接组件:

如果我们考虑前面的图,肯定的例子是容易的:我们只得到存在的边(1, 3)(2, 3)(3, 6),等等。对于消极的,有两种类型:simplehard.简单的只是从所有可能不存在的边的集合中采样。这里可以是(1, 8)(2, 7),也可以是(1, 9)。硬负边缘仅一跳之遥:(1, 2)(1, 6)(7, 9)是硬负的可能例子。

在我们的数据集中,我们有大约 600 万个正面的例子。为了保持训练数据或多或少的平衡,我们可以采样大约 1200 万个简单否定和大约 600 万个硬否定。那么正例与反例的比例将是 1/4。

创建正面示例非常简单:我们只需从图表中提取边,并为它们分配1.0目标:

Dataset<Row> pos = df.drop("year");
pos = pos.join(nodes, pos.col("node1").equalTo(nodes.col("node")));
pos = pos.drop("node", "node1").withColumnRenamed("id", "node1");

pos = pos.join(nodes, pos.col("node2").equalTo(nodes.col("node")));
pos = pos.drop("node", "node2").withColumnRenamed("id", "node2");

pos = pos.withColumn("target", functions.lit(1.0));

这里我们做了一些连接,用作者的 id 替换他们的名字。结果如下:

+-------------+-----+------+
|        node1|node2|target|
+-------------+-----+------+
|  51539612101| 2471|   1.0|
| 429496733302| 2471|   1.0|
|1486058685923| 2471|   1.0|
|1254130450702| 4514|   1.0|
|  94489280742|  913|   1.0|
|1176821039357|  913|   1.0|
+-------------+-----+------+

接下来,我们对容易消极的人进行取样。为此,我们首先从节点的数据帧中抽取两次替换样本,一次针对node1,一次针对node2。然后,将这些列放在一个数据框中。我们可以这样取样:

Dataset<Row> nodeIds = nodes.select("id");
long nodesCount = nodeIds.count();
double fraction = 12_000_000.0 / nodesCount;

Dataset<Row> sample1 = nodeIds.sample(true, fraction, 1);
sample1 = sample1.withColumn("rnd", functions.rand(1))
                 .orderBy("rnd")
                 .drop("rnd");

Dataset<Row> sample2 = nodeIds.sample(true, fraction, 2);
sample2 = sample2.withColumn("rnd", functions.rand(2))
                 .orderBy("rnd")
                 .drop("rnd");

这里,fraction 参数指定样本应该包含的DataFrame的分数。因为我们想得到 1200 万个例子,所以我们用 1200 万除以我们拥有的节点数。然后,我们通过向每个样本添加一个具有随机数的列来对其进行混洗,并使用它来对DataFrame进行排序。在排序完成后,我们不再需要该列,因此可以将其删除。

两个样本可能具有不同的大小,因此我们需要选择最小的一个,然后将两个样本都限制在此大小,这样它们就可以连接起来:

long sample1Count = sample1.count();
long sample2Count = sample2.count();

int minSize = (int) Math.min(sample1Count, sample2Count);

sample1 = sample1.limit(minSize);
sample2 = sample2.limit(minSize);

接下来,我们要将这两个样本放在一个数据帧中。对于 DataFrame API 来说,没有简单的方法可以做到这一点,所以我们需要使用 rdd。为此,我们将DataFrame转换成JavaRDDzip它们放在一起,然后将结果转换回单个DataFrame:

JavaRDD<Row> sample1Rdd = sample1.toJavaRDD();
JavaRDD<Row> sample2Rdd = sample2.toJavaRDD();
JavaRDD<Row> concat = sample1Rdd.zip(sample2Rdd).map(t -> {
    long id1 = t._1.getLong(0);
    long id2 = t._2.getLong(0);
    return RowFactory.create(id1, id2);
});

StructField node1Field = DataTypes.createStructField("node1", DataTypes.LongType, false);
StructField node2Field = DataTypes.createStructField("node2", DataTypes.LongType, false);
StructType schema = DataTypes.createStructType(Arrays.asList(node1Field, node2Field));
Dataset<Row> negSimple = sql.createDataFrame(concat, schema);

为了将RDD转换成DataFrame,我们需要指定一个模式,前面的代码显示了如何做。

最后,我们将目标列添加到这个DataFrame:

negSimple = negSimple.withColumn("target", functions.lit(0.0));

这将产生以下DataFrame:

+-------------+-------------+------+
|        node1|        node2|target|
+-------------+-------------+------+
| 652835034825|1056561960618|   0.0|
| 386547056678| 446676601330|   0.0|
| 824633725362|1477468756129|   0.0|
|1529008363870| 274877910417|   0.0|
| 395136992117| 944892811576|   0.0|
|1657857381212|1116691503444|   0.0|
+-------------+-------------+------+

有可能通过这种方式采样,我们意外地生成了恰好在正例中的配对。但是,这样的概率相当低,可以丢弃。

我们还需要创造一些有力的反面例子——这些例子之间只有一步之遥:

在这个图中,正如我们已经讨论过的,(1, 2)(1, 6)(7, 9)对是硬否定例子的例子。

为了得到这样的对子,让我们首先从逻辑上阐明这个想法。我们需要对所有可能的对(B, C)进行采样,使得存在一些节点A和边(A, B)(A, C)都存在,但是没有边(B, C)

当我们以这种方式表述这个采样问题时,用 SQL 来表达就变得很容易了:我们需要做的只是一个自连接,并选择具有相同源但不同目的地的边。考虑下面的例子:

SELECT e1.dst as node1, e2.dst as node2
  FROM Edges e1, Edges e2
 WHERE e1.src = e2.src ANDe1.dst <> e2.dst;

让我们把它翻译成 Spark DataFrame API。为了进行自连接,我们首先需要创建边DataFrame的两个别名,并重命名其中的列:

Dataset<Row> e1 = edges.drop("node1", "node2", "year")
        .withColumnRenamed("src", "e1_src")
        .withColumnRenamed("dst", "e1_dst")
        .as("e1");
Dataset<Row> e2 = edges.drop("node1", "node2", "year")
        .withColumnRenamed("src", "e2_src")
        .withColumnRenamed("dst", "e2_dst")
        .as("e2");

现在,我们在dst不同,但src相同的条件下执行连接,然后重命名列,使它们与前面的示例一致:

Column diffDest = e1.col("e1_dst").notEqual(e2.col("e2_dst"));
Column sameSrc = e1.col("e1_src").equalTo(e2.col("e2_src"));
Dataset<Row> hardNeg = e1.join(e2, diffDest.and(sameSrc));
hardNeg = hardNeg.select("e1_dst", "e2_dst")
        .withColumnRenamed("e1_dst", "node1")
        .withColumnRenamed("e2_dst", "node2");

接下来,我们需要获取前 600 万条生成的边,并将其称为硬样本。然而,Spark 将这个DataFrame中的值以某种特定的顺序排列,这可能会在我们的模型中引入偏差。为了降低偏差的危害,让我们在采样过程中增加一些随机性:生成一个包含随机值的列,并只选取值大于某个数字的那些边:

hardNeg = hardNeg.withColumn("rnd", functions.rand(0));
hardNeg = hardNeg.filter("rnd >= 0.95").drop("rnd");
hardNeg = hardNeg.limit(6_000_000);
hardNeg = hardNeg.withColumn("target", functions.lit(0.0));

之后,我们只取前 6m 边,并添加target列。结果遵循与我们之前的示例相同的模式:

+------------+-------------+------+
|       node1|        node2|target|
+------------+-------------+------+
| 34359740336| 970662610852|   0.0|
| 34359740336| 987842479409|   0.0|
| 34359740336|1494648621189|   0.0|
| 34359740336|1554778161775|   0.0|
| 42949673538| 326417515499|   0.0|
|266287973882| 781684049287|   0.0|
+------------+-------------+------+

union函数将它们放在一起:

Dataset<Row> trainEdges = pos.union(negSimple).union(hardNeg);

最后,让我们将 ID 与每条边相关联:

trainEdges = trainEdges.withColumn("id", functions.monotonicallyIncreasingId());

这样,我们就准备好了可以计算边缘特征的边缘。

边缘特征

我们可以计算许多边缘特征:共同朋友的数量,两个人都有的不同朋友的总数,等等。

让我们从共同的朋友这个特征开始,对于我们的问题来说,这是两位作者拥有的共同合著者的数量。为了得到它们,我们需要把我们选择的边和所有的边连接起来(两次),然后按 ID 分组,并计算每组有多少个元素。在 SQL 中,它看起来像这样:

  SELECT train.id, COUNT(*)
    FROM Sample train, Edges e1, Edges e2
   WHERE train.node1 = e1.src AND
         train.node2 = e2.src AND
         e1.dst = e2.dst
GROUP BY train.id;

让我们把这个翻译成 DataFrame API。首先,我们使用连接:

Dataset<Row> join = train.join(e1,
        train.col("node1").equalTo(e1.col("e1_src")));
join = join.join(e2,
        join.col("node2").equalTo(e2.col("e2_src")).and(
        join.col("e1_dst").equalTo(e2.col("e2_dst"))));

这里,我们重用来自负采样子部分的数据帧e1e2

然后,我们最后按id分组并计数:

Dataset<Row> commonFriends = join.groupBy("id").count();
commonFriends = commonFriends.withColumnRenamed("count", "commonFriends");

结果DataFrame将包含以下内容:

+-------------+-------------+
|           id|commonFriends|
+-------------+-------------+
|1726578522049|          116|
|        15108|            1|
|1726581250424|          117|
|        17579|            4|
|         2669|           11|
|         3010|           73|
+-------------+-------------+

现在我们计算朋友总数,这是两位作者拥有的不同合著者的数量。在 SQL 中,它比前面的特性简单一点:

  SELECT train.id, COUNT DISTINCT (e.dst)
    FROM Sample train, Edges e
   WHERE train.node1 = e.src
GROUP BY train.id

现在让我们把它翻译成 Spark:

Dataset<Row> e = edges.drop("node1", "node2", "year", "target");
Dataset<Row> join = train.join(e,
        train.col("node1").equalTo(edges.col("src")));
totalFriends = join.select("id", "dst")
        .groupBy("id")
        .agg(functions.approxCountDistinct("dst").as("totalFriendsApprox"));

这里,我们使用了近似的非重复计数,因为它速度更快,并且通常给出相当准确的值。当然,有一个选项可以使用 exact count distinct:为此,我们需要使用functions.countDistinct函数。此步骤的输出如下表所示:

+-------------+------------------+
|           id|totalFriendsApprox|
+-------------+------------------+
|1726580872911|                 4|
| 601295447985|                 4|
|1726580879317|                 1|
| 858993461306|                11|
|1726578972367|               296|
|1726581766707|               296|
+-------------+------------------+

接下来,我们计算两组合著者之间的 Jaccard 相似度。我们首先为每个作者创建一个带有集合的DataFrame,然后连接并计算 jaccard。对于这个特性,没有直接的方式用 SQL 来表达,所以我们从 Spark API 开始。

为每个作者创建一组合著者很容易:我们只需使用groupBy函数,然后将functions.collect_set应用于每个组:

Dataset<Row> coAuthors = e.groupBy("src")
        .agg(functions.collect_set("dst").as("others"))
        .withColumnRenamed("src", "node");

现在我们加入我们的训练数据:

Dataset<Row> join = train.drop("target");

join = join.join(coAuthors, join.col("node1").equalTo(coAuthors.col("node")));
join = join.drop("node").withColumnRenamed("others", "others1");

join = join.join(coAuthors, join.col("node2").equalTo(coAuthors.col("node")));
join = join.drop("node").withColumnRenamed("others", "others2");

join = join.drop("node1", "node2");

最后,join 列有边的 ID 和每个边的合著者数组。接下来,我们检查该数据帧的每个记录,并计算 Jaccard 相似性:

JavaRDD<Row> jaccardRdd = join.toJavaRDD().map(r -> {
    long id = r.getAs("id");
    WrappedArray<Long> others1 = r.getAs("others1");
    WrappedArray<Long> others2 = r.getAs("others2");

    Set<Long> set1 = Sets.newHashSet((Long[]) others1.array());
    Set<Long> set2 = Sets.newHashSet((Long[]) others2.array());

    int intersection = Sets.intersection(set1, set2).size();
    int union = Sets.union(set1, set2).size();

    double jaccard = intersection / (union + 1.0);
    return RowFactory.create(id, jaccard);
});

这里我们使用了正则化的 Jaccard 相似度:我们不是仅仅用交集除以并集,而是在分母上增加一个小的正则化因子。

这样做的原因是为了给非常小的集合更少的分数:假设每个集合都有相同的元素,那么 Jaccard 就是 1.0。通过正则化,小批量的相似性被罚分,对于这个例子,它将等于 0.5。

因为我们在这里使用了 rdd,所以我们需要将其转换回数据帧:

StructField node1Field = DataTypes.createStructField("id", DataTypes.LongType, false);
StructField node2Field = DataTypes.createStructField("jaccard", DataTypes.DoubleType, false);
StructType schema = DataTypes.createStructType(Arrays.asList(node1Field, node2Field));
Dataset<Row> jaccard = sql.createDataFrame(jaccardRdd, schema);

执行之后,我们得到一个类似这样的表:

+-------------+---------+
|           id|  jaccard|
+-------------+---------+
|1726581480054|    0.011|
|1726578955032|    0.058|
|1726581479913|    0.037|
|1726581479873|     0.05|
|1726581479976|      0.1|
|         1667|      0.1|
+-------------+---------+

请注意,前面的方法非常通用,我们可以遵循相同的方法来计算普通朋友总朋友特征:分别是交集和并集的大小。更重要的是,它可以和 Jaccard 一起一次性计算。

我们的下一步是从我们已经计算的节点特征中去除边缘特征。除其他外,我们可以包括以下内容:

  • 最小度数,最大度数
  • 优先连接分数:节点 1 的度数乘以节点 2 的度数
  • 页面等级的乘积
  • 页面等级的绝对差异
  • 相同的连接组件

为此,我们首先将所有节点功能连接在一起:

Dataset<Row> nodeFeatures = pageRank.join(degrees, "id")
                                    .join(connectedComponents, "id");
nodeFeatures = nodeFeatures.withColumnRenamed("id", "node_id");

接下来,我们将刚刚创建的节点特征DataFrame与我们准备用于训练的边连接起来:

Dataset<Row> join = train.drop("target");

join = join.join(nodeFeatures,
                join.col("node1").equalTo(nodeFeatures.col("node_id")));
join = join.drop("node_id")
        .withColumnRenamed("pagerank", "pagerank_1")
        .withColumnRenamed("degree", "degree_1")
        .withColumnRenamed("component", "component_1");

join = join.join(nodeFeatures,
                join.col("node2").equalTo(nodeFeatures.col("node_id")));
join = join.drop("node_id")
        .withColumnRenamed("pagerank", "pagerank_2")
        .withColumnRenamed("degree", "degree_2")
        .withColumnRenamed("component", "component_2");

join = join.drop("node1", "node2");

现在,让我们来计算特性:

join = join
    .withColumn("pagerank_mult", join.col("pagerank_1").multiply(join.col("pagerank_2")))
    .withColumn("pagerank_max", functions.greatest("pagerank_1", "pagerank_2"))
    .withColumn("pagerank_min", functions.least("pagerank_1", "pagerank_2"))
    .withColumn("pref_attachm", join.col("degree_1").multiply(join.col("degree_2")))
    .withColumn("degree_max", functions.greatest("degree_1", "degree_2"))
    .withColumn("degree_min", functions.least("degree_1", "degree_2"))
    .withColumn("same_comp", join.col("component_1").equalTo(join.col("component_2")));
join = join.drop("pagerank_1", "pagerank_2");
join = join.drop("degree_1", "degree_2");
join = join.drop("component_1", "component_2");

这将创建一个具有 edge ID 和七个特征的DataFrame:最小和最大页面等级,两个页面等级的乘积,最小和最大程度,两个程度的乘积(优先附件),最后,两个节点是否属于同一个组件。

现在我们已经计算完了我们想要的所有特性,所以是时候把它们都加入到一个单独的DataFrame中了:

Dataset<Row> join = train.join(commonFriends, "id")
     .join(totalFriends, "id")
     .join(jaccard, "id")
     .join(nodeFeatures, "id");

到目前为止,我们创建了带有标注的数据集,并计算了一组要素。现在我们终于准备在它上面训练一个机器学习模型了。

使用 MLlib 和 XGBoost 进行链接预测

现在,当所有的数据都准备好并放入合适的形状时,我们可以训练一个模型,它将预测两个作者是否有可能成为合著者。为此,我们将使用二元分类器模型,该模型将被训练来预测该边在图中存在的概率。

Apache Spark 附带了一个库,它提供了几种机器学习算法的可伸缩实现。这个库叫做 MLlib。让我们把它添加到我们的pom.xml:

<dependency>
  <groupId>org.apache.spark</groupId>
  <artifactId>spark-mllib_2.11</artifactId>
  <version>2.1.0</version>
</dependency>

我们可以使用许多模型,包括逻辑回归、随机森林和梯度提升树。但是在我们训练任何模型之前,让我们将训练数据集分成训练集和验证集:

features = features.withColumn("rnd", functions.rand(1));
Dataset<Row> trainFeatures = features.filter("rnd < 0.8").drop("rnd");
Dataset<Row> valFeatures = features.filter("rnd >= 0.8").drop("rnd");

为了能够使用它来训练机器学习模型,我们需要将我们的数据转换为LabeledPoint对象的 RDD。为此,我们首先将数据帧转换为 RDD,然后将每一行转换为DenseVector:

List<String> columns = Arrays.asList("commonFriends", "totalFriendsApprox",
        "jaccard", "pagerank_mult", "pagerank_max", "pagerank_min",
        "pref_attachm", "degree_max", "degree_min", "same_comp");

JavaRDD<LabeledPoint> trainRdd = trainFeatures.toJavaRDD().map(r -> {
    Vector vec = toDenseVector(columns, r);
    double label = r.getAs("target");
    return new LabeledPoint(label, vec);
});

columns按照我们希望将它们放入DenseVector的顺序存储我们希望用作特性的所有列名。toDenseVector函数有如下实现:

private static DenseVector toDenseVector(List<String> columns, Row r) {
    int featureVecLen = columns.size();
    double[] values = new double[featureVecLen];
    for (int i = 0; i < featureVecLen; i++) {
        Object o = r.getAs(columns.get(i));
        values[i] = castToDouble(o);
    }
    return new DenseVector(values);
}

因为在我们的 DataFrame 中有多种类型的数据,包括intdoubleboolean,我们需要能够将它们全部转换成 double。这就是castToDouble功能的作用:

private static double castToDouble(Object o) {
    if (o instanceof Number) {
        Number number = (Number) o;
        return number.doubleValue();
    }

    if (o instanceof Boolean) {
        Boolean bool = (Boolean) o;
        if (bool) {
            return 1.0;
        } else {
            return 0.0;
        }
    }

    throw new IllegalArgumentException();
}

现在我们终于可以训练逻辑回归模型了:

LogisticRegressionModel logreg = new LogisticRegressionWithLBFGS()
            .run(JavaRDD.toRDD(trainRdd));

完成后,我们可以评估模型有多好。

让我们浏览整个验证数据集,并对其中的每个元素进行预测:

logreg.clearThreshold();

JavaRDD<Pair<Double, Double>> predRdd = valFeatures.toJavaRDD().map(r -> {
    Vector v = toDenseVector(columns, r);
    double label = r.getAs("target");
    double predict = logreg.predict(v);
    return ImmutablePair.of(label, predict);
});

注意,我们首先需要调用clearThreshold方法——如果我们不这样做,那么模型将输出硬预测(只有 0.0 和 1.0),这将增加评估的难度。

现在,我们可以将预测和真实标签放入单独的双数组中,并使用任何二元分类评估函数,我们在第 4 章监督学习-分类和回归中讨论过这些函数。例如,我们可以使用logLoss:

List<Pair<Double, Double>> pred = predRdd.collect();
double[] actual = pred.stream().mapToDouble(Pair::getLeft).toArray();
double[] predicted = pred.stream().mapToDouble(Pair::getRight).toArray();
double logLoss = Metrics.logLoss(actual, predicted);
System.out.printf("log loss: %.4f%n", logLoss);

这会产生以下输出:

log loss: 0.6528

这不是一个很好的性能:如果我们总是输出0.5作为预测值,那么logLoss将会是0.7,所以我们的模型比它好一点。我们可以尝试 MLlib 中的其他模型,如线性 SVM 或随机森林,看看它们是否能提供更好的性能。

但是还有另一个选择:如果你还记得第七章,极限梯度提升 XGBoost,也可以以并行模式运行,并且它可以使用 Apache Spark 来实现。所以这个问题我们试着用一下。要了解如何构建 XGBoost,请参考第七章极限梯度增强

为了将 Spark 版本包含到我们的项目中,我们将下面的依赖声明添加到项目中:

<dependency>
  <groupId>ml.dmlc</groupId>
  <artifactId>xgboost4j-spark</artifactId>
  <version>0.7</version>
</dependency>

作为输入,XGBoost 也接受Vector对象的RDD。除此之外,它使用与运行在单台机器上的 XGBoost 相同的参数:模型参数、要构建的树的数量等等。代码看起来是这样的:

Map<String, Object> params = xgbParams();
int nRounds = 20;
int numWorkers = 4;
ObjectiveTrait objective = null;
EvalTrait eval = null;
boolean externalMemoryCache = false;
float nanValue = Float.NaN;
RDD<LabeledPoint> trainData = JavaRDD.toRDD(trainRdd);

XGBoostModel model = XGBoost.train(trainData, params,
        nRounds, numWorkers, objective, eval, externalMemoryCache,
        nanValue);

这里,xgbParams函数返回我们用于训练的 XGBoost 模型参数的Map

注意 XGBoost Spark 包装器是用 Scala 写的,不是 Java,所以Map对象实际上是scala.collection.immutable.Map,不是java.util.Map。因此,我们还需要将一个普通的HashMap转换成 Scala Map:

HashMap<String, Object> params = new HashMap<String, Object>();
params.put("eta", 0.3);
params.put("gamma", 0);
params.put("max_depth", 6);
// ... other parameters
Map<String, Object> res = toScala(params);

这里,toScala实用程序方法是这样实现的:

private static <K, V> Map<K, V> toScala(HashMap<K, V> params) {
    return JavaConversions.mapAsScalaMap(params)
            .toMap(Predef.<Tuple2<K, V>>conforms());
}

它看起来有点奇怪,因为它使用了一些 Scala 特有的特性。但是我们不需要赘述,可以照原样使用。

有了这个,我们将能够训练一个分布式的 XGBoost。然而,对于评估,我们不能遵循与逻辑回归相同的方法。也就是说,我们不能将每一行转换成一个向量,然后针对这个向量运行模型,如果这样做,XGBoost 将抛出一个异常。这样做的原因是这样的操作是相当昂贵的,因为它将试图为每个向量构建DMatrix,并且它将导致显著的速度减慢。

由于我们的验证数据集不是很大,我们可以将整个RDD转换成一个DMatrix:

JavaRDD<LabeledPoint> valRdd = valFeatures.toJavaRDD().map(r -> {
    float[] vec = rowToFloatArray(columns, r);
    double label = r.getAs("target");
    return LabeledPoint.fromDenseVector((float) label, vec);
});

List<LabeledPoint> valPoints = valRdd.collect();
DMatrix data = new DMatrix(valPoints.iterator(), null);

这里,我们遍历DataFrame的行,并将每一行转换成一个LabeledPoint类(来自ml.dmlc.xgboost4j包——不要与org.apache.spark.ml.feature.LabeledPoint混淆)。然后,我们将所有东西收集到一个列表中,并从中创建一个DMatrix

接下来,我们获得经过训练的模型,并将其应用于这个DMatrix:

Booster xgb = model._booster();
float[][] xgbPred = xgb.predict(new ml.dmlc.xgboost4j.scala.DMatrix(data), false, 20);

然后,我们用于logLoss计算的方法期望得到 double 作为输入,所以让我们将结果转换成 double 的数组:

double[] actual = floatToDouble(data.getLabel());
double[] predicted = unwrapToDouble(xgbPred);

我们已经在第七章极限梯度提升中使用了这些函数,它们非常简单:floatToDouble只是将一个浮点数组转换成一个双数组,unwrapToDouble将一个列的二维浮点数组转换成一维双数组。

最后,我们可以计算分数:

double logLoss = Metrics.logLoss(actual, predicted);
System.out.printf("log loss: %.4f%n", logLoss);

它显示分数是 0.497,比我们之前的分数有了很大的提高。这里,我们使用了带有默认参数的模型,这些参数通常不是最佳的。我们可以进一步调整模型,你可以在第 7 章极限梯度提升中找到如何调整 XGBoost 的策略。

选择这里仅仅是因为它的简单性,当涉及到推荐系统时,它通常很难解释。选择一个评估指标通常是非常具体的,我们可以使用 F1 分数、 MAP ( 平均精度)、 NDCG ( 归一化折现累积收益)等分数。除此之外,我们还可以使用在线评估指标,比如用户接受了多少建议链接。

接下来,我们将看到这个模型如何用于建议链接,以及我们如何更好地评估它。

链接建议

到目前为止,我们已经详细讨论了如何为链接预测模型构建特征并训练这些模型。现在我们需要能够使用这样的模型来提出建议。再次,想想你可能认识的脸书上的横幅——这里我们希望有类似的东西,比如作者你应该用写一篇论文。除此之外,我们将看到如何评估模型,以便在这种情况下评估结果更加直观和清晰。

第一步是在整个训练集上重新训练 XGBoost 模型,不进行训练验证拆分。这很容易做到:在进行分割之前,我们只需根据数据再次拟合模型。

接下来,我们需要处理测试数据集。如果你还记得的话,测试数据集包含了 2014 年和 2015 年发表的所有论文。为了使事情更简单,我们将选择测试用户的子集,并且只向他们提供建议。我们可以这样做:

Dataset<Row> fullTest = df.filter("year >= 2014");
Dataset<Row> testNodes = fullTest.sample(true, 0.05, 1)
                                 .select("node1")
                                 .dropDuplicates();
Dataset<Row> testEdges = fullTest.join(testNodes, "node1");

在这里,我们首先选择测试集,然后从中抽取节点样本——这给出了我们选择进行测试的作者列表。换句话说,只有这些作者会收到推荐。接下来,我们执行完整测试集与所选节点的连接,以获得测试期间建立的所有实际链接。我们稍后将使用这些链接进行评估。

接下来,我们用 id 替换作者的名字。我们用和以前一样的方法来做——用节点DataFrame连接它:

Dataset<Row> join = testEdges.drop("year");
join = join.join(nodes, join.col("node1").equalTo(nodes.col("node")));
join = join.drop("node", "node1").withColumnRenamed("id", "node1");
join = join.join(nodes, join.col("node2").equalTo(nodes.col("node")));
join = join.drop("node", "node2").withColumnRenamed("id", "node2");
Dataset<Row> selected = join;

接下来,我们需要选择我们将应用该模型的候选人。候选人名单应包含最有可能成为潜在合著者的作者名单(即在网络中形成链接)。选择这样的候选人最明显的方法是选择彼此相距一个跳跃点的作者——以同样的方式,我们对硬负面链接进行采样:

Dataset<Row> e1 = selected.select("node1").dropDuplicates();
Dataset<Row> e2 = edges.drop("node1", "node2", "year")
        .withColumnRenamed("src", "e2_src")
        .withColumnRenamed("dst", "e2_dst")
        .as("e2");
Column diffDest = e1.col("node1").notEqual(e2.col("e2_dst"));
Column sameSrc = e1.col("node1").equalTo(e2.col("e2_src"));
Dataset<Row> candidates = e1.join(e2, diffDest.and(sameSrc));
candidates = candidates.select("node1", "e2_dst")
                       .withColumnRenamed("e2_dst", "node2");

代码几乎是相同的,除了我们不考虑所有可能的硬否定,而只考虑那些与我们预先选择的节点相关的硬否定。

我们假设这些候选人在测试期间没有成为合著者,所以我们添加了带有0.0的目标列:

candidates = candidates.withColumn("target", functions.lit(0.0));

而一般来说,这种假设是不成立的,因为我们只关注从训练阶段获得的联系,很可能其中一些联系实际上是在测试阶段形成的。

让我们通过手动添加阳性候选项,然后删除重复项来解决这个问题:

selected = selected.withColumn("target", functions.lit(1.0));
candidates = selected.union(candidates).dropDuplicates("node1", "node2");

现在,正如我们之前所做的,我们为每个候选边分配一个 id:

candidates = candidates.withColumn("id", functions.monotonicallyIncreasingId());

为了将模型应用于这些候选人,我们需要计算特征。为此,我们只需重用之前编写的代码。首先,我们计算节点特征:

Dataset<Row> nodeFeatures = nodeFeatures(sql, pageRank, connectedComponents, degrees, candidates);

这里,nodeFeatures方法接收我们在训练数据上计算的pageRankconnectedComponentsdegreeDataFrame s,并为我们在candidatesDataFrame中经过的边计算所有基于节点的特征。

接下来,我们计算候选对象的基于边缘的特征:

Dataset<Row> commonFriends = calculateCommonFriends(sql, edges, candidates);
Dataset<Row> totalFriends = calculateTotalFriends(sql, edges, candidates);
Dataset<Row> jaccard = calculateJaccard(sql, edges, candidates);

我们将这些特性的实际计算放在效用方法中,并使用一组不同的边来调用它们。

最后,我们只是把所有东西连接在一起:

Dataset<Row> features = candidates.join(commonFriends, "id")
        .join(totalFriends, "id")
        .join(jaccard, "id")
        .join(nodeFeatures, "id");

现在我们准备将 XGBoost 模型应用于这些特性。为此,我们将使用mapPartition函数:它类似于通常的map,但是它不是只接受一个项目,而是同时接受多个项目。这样,我们将同时为多个对象创建一个DMatrix,这将节省时间。

这是我们的做法。首先,我们创建一个特殊的类ScoredEdge,用于保存关于边的节点的信息、由模型分配的分数以及实际的标签:

public class ScoredEdge implements Serializable {
    private long node1;
    private long node2;
    private double score;
    private double target;
    // constructor, getter and setters are omitted
}

现在我们对候选边进行评分:

JavaRDD<ScoredEdge> scoredRdd = features.toJavaRDD().mapPartitions(rows -> {
    List<ScoredEdge> scoredEdges = new ArrayList<>();
    List<LabeledPoint> labeled = new ArrayList<>();

    while (rows.hasNext()) {
        Row r = rows.next();
        long node1 = r.getAs("node1");
        long node2 = r.getAs("node2");
        double target = r.getAs("target");
        scoredEdges.add(new ScoredEdge(node1, node2, target));

        float[] vec = rowToFloatArray(columns, r);
        labeled.add(LabeledPoint.fromDenseVector(0.0f, vec));
    }

    DMatrix data = new DMatrix(labeled.iterator(), null);
    float[][] xgbPred =
        xgb.predict(new ml.dmlc.xgboost4j.scala.DMatrix(data), false, 20);

    for (int i = 0; i < scoredEdges.size(); i++) {
        double pred = xgbPred[i][0];
        ScoredEdge edge = scoredEdges.get(i);
        edge.setScore(pred);
    }

    return scoredEdges.iterator();
});

mapPartition函数中的代码执行以下操作:首先,我们检查所有输入行,并为每一行创建一个ScoredEdge类。除此之外,我们还从每一行中提取特征(与我们之前所做的完全相同)。然后我们把所有东西放进DMatrix,用 XGBoost 模型给这个矩阵的每一行打分。最后,我们给ScoredEdge对象打分。作为这一步的结果,我们得到了一个RDD,其中每个候选边都由模型评分。

接下来,我们将为每位用户推荐 10 位潜在的合著者。为此,我们根据ScoredEdge类中的node1进行分组,然后根据每个组中的分数进行排序,只保留前 10 个:

JavaPairRDD<Long, List<ScoredEdge>> topSuggestions = scoredRdd
        .keyBy(s -> s.getNode1())
        .groupByKey()
        .mapValues(es -> takeFirst10(es));

这里,takeFirst10可以这样实现:

private static List<ScoredEdge> takeFirst10(Iterable<ScoredEdge> es) {
    Ordering<ScoredEdge> byScore =
            Ordering.natural().onResultOf(ScoredEdge::getScore).reverse();
    return byScore.leastOf(es, 10);
}

如果你还记得的话,Ordering这里有一个来自 Google Guava 的类,它根据分数进行排序,然后取前 10 条边。

最后看看建议有多好。为此,我们检查了所有的建议,并计算了在测试期间实际形成的这些链接的数量。然后我们取所有组的平均值:

double mp10 = topSuggestions.mapToDouble(es -> {
    List<ScoredEdge> es2 = es._2();
    double correct = es2.stream().filter(e -> e.getTarget() == 1.0).count();
    return correct / es2.size();
}).mean();

System.out.println(mp10);

它所做的是计算每个组的Precision@10(正确分类的边在前 10 个中的部分),然后取其平均值。

当我们运行它时,我们看到分数约为 30%。这不是一个很差的结果:这意味着实际上形成了 30%的推荐边。

尽管如此,这个分数还远远不够理想,还有很多方法可以提高它。在现实生活中的社交网络中,我们通常会使用额外的信息来构建模型。例如,在合著者图表的情况下,我们可以使用来自论文摘要、会议和发表论文的期刊的隶属关系、标题和文本,以及许多其他内容。如果社交图来自脸书这样的网络社交网络,我们可以使用地理信息、团体和社区,最后还有喜欢。通过包含这些信息,我们应该能够实现更好的性能。

摘要

在这一章中,我们看了处理大量数据的方法和特殊工具,如 Apache Hadoop MapReduce 和 Apache Spark。我们看到了如何使用它们来处理常见的抓取-互联网的副本,并从中计算一些有用的统计数据。最后,我们创建了一个推荐合著者的链接预测模型,并以分布式方式训练了一个 XGBoost 模型。

在下一章中,我们将探讨如何将数据科学模型部署到生产系统中。*

十、部署数据科学模型

到目前为止,我们已经涵盖了许多数据科学模型,我们谈到了许多监督和非监督学习方法,包括深度学习和 XGBoost,并讨论了我们如何将这些模型应用于文本和图形数据。

就 CRISP-DM 方法而言,到目前为止,我们主要讨论了建模部分。但是还有其他重要的部分我们还没有讨论:测评部署。这些步骤在应用程序生命周期中非常重要,因为我们创建的模型应该对业务有用并带来价值,实现这一点的唯一方法是将它们集成到应用程序中(部署部分),并确保它们确实有用(评估部分)。

在本书的最后一章中,我们将详细介绍这些缺失的部分——我们将了解如何部署数据科学模型,以便应用程序的其他服务可以使用它们。除此之外,我们还将了解如何对已经部署的模型进行在线评估。

特别是,我们将涵盖以下内容:

  • Spring Boot 在爪哇的微服务
  • 使用 A/B 测试和多支武装匪徒进行模型评估

在本章结束时,你将学会如何用数据科学模型创建简单的 web 服务,以及如何以一种易于测试的方式设计它们。

微服务

Java 是跨许多领域运行许多应用程序的生产代码的一个非常常见的平台选择。当数据科学家为现有应用程序创建模型时,Java 是一个自然的选择,因为它可以无缝地集成到代码中。这种情况很简单,您创建一个单独的包,在那里实现您的模型,并确保其他包使用它。另一个可能的选择是将代码打包到一个单独的 JAR 文件中,并将其作为 Maven 依赖项包含进来。

但是有一种不同的架构方法来组合一个大型系统的多个组件——微服务架构。主要思想是系统应该由小的独立单元组成,它们有自己的生命周期——它们的开发、测试和部署周期独立于所有其他组件。

这些微服务通常通过基于 HTTP 的 REST API 进行通信。它基于四种 HTTP 方法- GETPOSTPUTDELETE。前两个是最常用的:

  • GET:从服务中获取一些信息
  • POST:向服务提交一些信息

有相当多的库允许用 Java 的 REST API 创建 web 服务,其中之一是 Spring Boot,它是基于 Spring 框架的。接下来,我们将研究如何使用它为数据科学模型服务。

Spring Boot

Spring 是一个非常古老而强大的 Java 库。核心 Spring 模块实现了依赖注入 ( DI )模式,这允许开发松耦合、可测试和可靠的应用程序。Spring 有围绕核心构建的其他模块,其中之一是 Spring MVC,这是一个用于创建 web 应用程序的模块。

简而言之,DI 模式认为你应该将应用程序逻辑放在所谓的服务中,然后将这些服务注入到 web 模块中。

正如我们已经提到的,Spring MVC 用于开发 web 服务。它运行在 Servlet API 之上,Servlet API 是 Java 处理 web 请求和生成 web 响应的方式。Servlet 容器实现 Servlet API。因此,为了能够将您的应用程序用作 web 服务,您需要将它部署到 servlet 容器中。最流行的是 Apache Tomcat 和 Eclipse Jetty。然而,该 API 使用起来相当麻烦。Spring MVC 构建在 Servlet API 之上,但是隐藏了它所有的复杂性。

此外,Spring Boot 库允许我们快速开始开发一个 Spring 应用程序,而不需要进入大量的配置细节,比如设置 Apache Tomcat、Spring 应用程序上下文等等。它附带了一组很好的预定义参数,预计可以很好地工作,所以我们可以开始使用它,并专注于应用程序逻辑,而不是配置 servlet 容器。

现在让我们看看如何使用 Spring Boot 和 Spring MVC 来服务机器学习模型。

搜索引擎服务

让我们最后回到我们正在运行的例子——构建一个搜索引擎。在第七章极限梯度提升中,我们创建了一个排名模型,可以用来对搜索引擎结果进行重新排序,让最相关的内容获得更高的位置。

在前一章第九章缩放数据科学中,我们从常见的抓取中提取了大量的文本数据。我们现在能做的就是把这些都放在一起——用 Apache Lucene 索引来自 Common Crawl 的数据,然后搜索它的内容,用 XGBoost 排名模型得到最好的结果。

我们已经知道如何使用 Hadoop MapReduce 从常见的抓取中提取文本信息。然而,如果你记得,我们的排名模型需要的不仅仅是文本——除了正文,它还需要知道标题和头。我们可以修改现有的 MapReduce 作业来提取我们需要的部分,或者不使用 Hadoop 来处理它,直接用 Lucene 索引它。让我们看看第二种方法。

首先,我们将再次使用HtmlDocument类,它有以下字段:

public class HtmlDocument implements Serializable {
    private final String url;
    private final String title;
    private final ArrayListMultimap<String, String> headers;
    private final String bodyText;
    // constructors and getters are omitted
}

然后,我们还将重用将 HTML 转换为这个HtmlDocument对象的方法,但会稍微修改它,以便它可以从通用爬网中读取 WARC 记录:

private static HtmlDocument extractText(ArchiveRecord record) {
    String html = TextUtils.extractHtml(record);
    Document document = Jsoup.parse(html);
    String title = document.title();
    Element body = document.body();
    String bodyText = body.text();

    Elements headerElements = body.select("h1, h2, h3, h4, h5, h6");
    ArrayListMultimap<String, String> headers = ArrayListMultimap.create();
    for (Element htag : headerElements) {
        String tagName = htag.nodeName().toLowerCase();
        headers.put(tagName, htag.text());
    }

    return new HtmlDocument(url, title, headers, bodyText);
}

这里的extractHtml是上一章中的一个方法,它从 WARC 记录中提取 HTML 内容,其余的与第六章中的相同,使用文本自然语言处理和信息检索

接下来,我们需要检查 WARC 档案的每个记录,并将其转换成一个HtmlDocument类的对象。由于档案足够大,我们不希望一直将所有HtmlDocument对象的内容保存在内存中。相反,我们可以不紧不慢地做这件事:读取下一个 WARC 记录,将其转换为HtmlDocument,这是 Lucene 的一个索引。下一张唱片再做一次。

为了能够轻松地做到这一点,我们将使用 Google Guava 的AbstractIterator类:

public static Iterator<HtmlDocument> iterator(File commonCrawlFile) {
    ArchiveReader archive = WARCReaderFactory.get(commonCrawlFile);
    Iterator<ArchiveRecord> records = archive.iterator();

    return new AbstractIterator<HtmlDocument>() {
        protected HtmlDocument computeNext() {
            while (records.hasNext()) {
                ArchiveRecord record = records.next();
                return extractText(record);
            }
            return endOfData();
        }
    };
}

首先,我们打开 WARC 档案并将其传递给我们的AbstractIterator类的实例。在内部,当仍然有记录时,我们使用我们的extractText函数转换它们。一旦我们完成了处理,我们就通过调用endOfData方法发出信号。

现在我们可以用 Lucene 索引所有的 WARC 文件:

FSDirectory directory = FSDirectory.open("lucene-index");
WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
IndexWriter writer = new IndexWriter(directory, new IndexWriterConfig(analyzer));

for (File warc : warcFolder.listFiles()) {
    Iterator<HtmlDocument> iterator = CommonCrawlReader.iterator(warc);

    while (iterator.hasNext()) {
        HtmlDocument htmlDoc = iterator.next();
        Document doc = toLuceneDocument(htmlDoc);
        writer.addDocument(doc);
    }
}

在这段代码中,我们首先创建一个文件系统 Lucene 索引,然后检查来自warcFolder目录的所有 WARC 文件。对于每个这样的文件,我们使用我们刚刚编写的方法获得迭代器,然后用 Lucene 索引这个 WARC 文件的每个记录。从第六章、中我们应该已经熟悉了toLuceneDocument方法,它处理文本-自然语言处理和信息检索;它将HtmlDocument转换成一个 Lucene 文档,并包含以下代码:

String url = htmlDoc.getUrl();
String title = htmlDoc.getTitle();
String bodyText = htmlDoc.getBodyText();
ArrayListMultimap<String, String> headers = htmlDoc.getHeaders();

String allHeaders = String.join(" ", headers.values());
String h1 = String.join(" ", headers.get("h1"));
String h2 = String.join(" ", headers.get("h2"));
String h3 = String.join(" ", headers.get("h3"));

Document doc = new Document();
doc.add(new Field("url", url, URL_FIELD));
doc.add(new Field("title", title, TEXT_FIELD));
doc.add(new Field("bodyText", bodyText, TEXT_FIELD));
doc.add(new Field("allHeaders", allHeaders, TEXT_FIELD));
doc.add(new Field("h1", h1, TEXT_FIELD));
doc.add(new Field("h2", h2, TEXT_FIELD));
doc.add(new Field("h3", h3, TEXT_FIELD));

可以参考第六章处理文本-自然语言处理和信息检索了解更多详情。

有了这段代码,我们可以非常快速地索引普通爬行的一部分。在我们的实验中,我们只从 2016 年 12 月开始收集了 3 个 WARC 档案,其中包含大约 50 万个文档。

现在,在我们索引数据之后,我们需要得到我们的 ranker。让我们重用我们在第 6 章处理文本-自然语言处理和信息检索第 7 章极限梯度提升:特征提取器和 XGBoost 模型。

如果您还记得,特征提取器执行以下操作:标记查询和每个文档的主体、标题和标题;将它们全部放入 TF-IDF 向量空间,并计算查询和所有文本特征之间的相似性。除此之外,我们还查看了 LSA 空间(使用 SVD 缩减的空间)中的相似性,以及手套空间中查询和标题之间的相似性。请参考第六章处理文本-自然语言处理和信息检索,了解更多详情。

所以让我们使用这些类来实现我们的 ranker。但是首先,我们需要对所有排名函数进行适当的抽象,为此,我们可以创建Ranker接口:

public interface Ranker {
    SearchResults rank(List<QueryDocumentPair> inputList);
}

虽然在这一步创建接口似乎是多余的,但它将确保我们创建的服务易于扩展和替换,这对于能够进行模型评估非常重要。

它唯一的方法 rank 接受一组QueryDocumentPair对象,并产生一个SearchResults对象。我们在第 6 章中创建了QueryDocumentPair类,处理文本-自然语言处理和信息检索,它包含查询以及文档的文本特征:

public static class QueryDocumentPair {
    private final String query;
    private final String url;
    private final String title;
    private final String bodyText;
    private final String allHeaders;
    private final String h1;
    private final String h2;
    private final String h3;
    // constructor and getters are omitted
}

SearchResults对象只包含一个重新排序的SearchResult对象列表:

public class SearchResults {
    private List<SearchResult> list;
    // constructor and getters are omitted
}

SearchResult是另一个只保存页面标题和 URL 的对象:

public class SearchResult {
    private String url;
    private String title;
    // constructor and getters are omitted
}

现在让我们创建这个接口的一个实现,并将其命名为XgbRanker。首先,我们指定构造函数,它接受FeatureExtractor对象和保存的 XGBoost 模型的路径:

public XgbRanker(FeatureExtractor featureExtractor, String pathToModel) {
    this.featureExtractor = featureExtractor;
    this.booster = XGBoost.loadModel(pathToModel);
}

并且rank方法通过以下方式实现:

@Override
public SearchResults rank(List<QueryDocumentPair> inputList) {
    DataFrame<Double> featuresDf = featureExtractor.transform(inputList);
    double[][] matrix = featuresDf.toModelMatrix(0.0);

    double[] probs = XgbUtils.predict(booster, matrix);
    List<ScoredIndex> scored = ScoredIndex.wrap(probs);

    List<SearchResult> result = new ArrayList<>(inputList.size());

    for (ScoredIndex idx : scored) {
        QueryDocumentPair doc = inputList.get(idx.getIndex());
        result.add(new SearchResult(doc.getUrl(), doc.getTitle());
    }

    return new SearchResutls(result);
}

这里我们只是把我们在第 6 章处理文本-自然语言处理和信息检索第 7 章极限 渐变增强中写的代码放在里面,特征提取器创建一个带有特征的DataFrame。然后,我们使用实用程序类XgbUtils将 XGBoost 模型应用于来自DataFrame的数据,最后,我们使用来自模型的分数对输入列表进行重新排序。最后,它只是将QueryDocumentPair对象转换成SearchResult对象并返回。

为了创建这个类的一个实例,我们可以首先加载我们训练提取并保存的特征以及模型:

FeatureExtractor fe = FeatureExtractor.load("project/feature-extractor.bin");
Ranker ranker = new XgbRanker(fe, "project/xgb_model.bin");

这里的load方法只是来自 Commons Lang 的SerializationUtils的包装器。

现在我们有了 ranker,我们可以用它来创建搜索引擎服务。在内部,它应该接受 Lucene 的IndexSearcher作为通用抓取索引,以及我们的 ranker。

当我们有了一个排名器,让我们创建一个搜索服务。它应该接受 Lucene 的IndexSearcher和我们的Ranker

然后我们用用户查询创建search方法;它解析查询,从 Lucene 索引中获取前 100 个文档,并用 ranker 对它们进行重新排序:

public SearchResults search(String userQuery) {
    Query query = parser.parse(userQuery);
    TopDocs result = searcher.search(query, 100);
    List<QueryDocumentPair> data = wrapResultsToObject(userQuery, searcher, result)
    return ranker.rank(data);
}

这里我们再次重用了来自第 6 章的一个函数,处理文本-自然语言处理和信息检索:将 Lucene 结果转换成QueryDocumentPair对象的wrapResultsToObject:

private static List<QueryDocumentPair> wrapResultsToObject(String userQuery, 
              IndexSearcher searcher, TopDocs result) throws IOException {
    List<QueryDocumentPair> data = new ArrayList<>();

    for (ScoreDoc scored : result.scoreDocs) {
        int docId = scored.doc;
        Document doc = searcher.doc(docId);

        String url = doc.get("url");
        String title = doc.get("title");
        String bodyText = doc.get("bodyText");
        String allHeaders = doc.get("allHeaders");
        String h1 = doc.get("h1");
        String h2 = doc.get("h2");
        String h3 = doc.get("h3");

        data.add(new QueryDocumentPair(userQuery, url, title, 
              bodyText, allHeaders, h1, h2, h3));
    }

    return data;
}

我们的搜索引擎服务已经准备好了,所以我们终于可以把它放到一个微服务中了。如前所述,一个简单的方法是通过 Spring Boot。

为此,第一步是将 Spring Boot 纳入我们的项目。这有点不寻常:我们不只是指定依赖项,而是使用下面的代码片段,您需要将它放在依赖项部分之后:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>1.3.0.RELEASE</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

然后在通常的地方出现下面的依赖关系:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>

注意,这里缺少了版本部分:Maven 从我们刚刚添加的依赖关系管理部分中提取了它。我们的 web 服务将使用 JSON 对象进行响应,因此我们还需要添加一个 JSON 库。我们将使用 Jackson,因为 Spring Boot 已经提供了一个内置的 JSON 处理程序。让我们将它纳入我们的pom.xml:

<dependency>
  <artifactId>jackson-databind</artifactId>
  <groupId>com.fasterxml.jackson.core</groupId>
</dependency>

现在所有的依赖项都已经添加了,所以我们可以创建一个 web 服务了。在 Spring 术语中,它们被称为Controller(或RestController)。让我们创建一个SearchController类:

@RestController
@RequestMapping("/")
public class SearchController {
private final SearchEngineService service;

    @Autowired
    public SearchController(SearchEngineService service) {
        this.service = service;
    }

    @RequestMapping("q/{query}")
    public SearchResults contentOpt(@PathVariable("query") String query) {
        return service.search(query);
    }

}

这里我们使用了 Spring 的一些注释:

  • 告诉 Spring 这个类是一个 REST 控制器
  • @Autowired告诉 Spring 应该将SearchEngineService的实例注入控制器
  • @RequestMapping("q/{query}")指定服务的 URL

注意,这里我们使用了@Autowired注释来注入SearchEngineService。但是 Spring 不知道这样一个服务应该如何被实例化,所以我们需要创建一个容器来自己完成。让我们这样做:

@Configuration
public class Container {

    @Bean
    public XgbRanker xgbRanker() throws Exception {
        FeatureExtractor fe = load("project/feature-extractor.bin");
        return new XgbRanker(fe, "project/xgb_model.bin");
    }

    @Bean
    public SearchEngineService searchEngineService(XgbRanker ranker) 
             throws IOException {
        File index = new File("project/lucene-rerank");
        FSDirectory directory = FSDirectory.open(index.toPath());
        DirectoryReader reader = DirectoryReader.open(directory);
        IndexSearcher searcher = new IndexSearcher(reader);
        return new SearchEngineService(searcher, ranker);
    }

    private static <E> E load(String filepath) throws IOException {
        Path path = Paths.get(filepath);
        try (InputStream is = Files.newInputStream(path)) {
            try (BufferedInputStream bis = new BufferedInputStream(is)) {
                return SerializationUtils.deserialize(bis);
            }
        }
    }
}

这里我们首先创建一个XgbRanker类的对象,通过使用@Bean注释,我们告诉 Spring 将这个类放入容器中。接下来,我们创建依赖于XgbRankerSearchEngineService,因此我们初始化它的方法将它作为一个参数。Spring 将此视为一种依赖,并将XgbRanker对象传递到那里,以便满足依赖关系。

最后一步是创建应用程序,该应用程序将监听8080端口的传入请求,并用 JSON 进行响应:

@SpringBootApplication
public class SearchRestApp {
    public static void main(String[] args) {
        SpringApplication.run(SearchRestApp.class, args);
    }
}

一旦我们运行了这个类,我们就可以通过向http://localhost:8080/q/query发送一个GET请求来查询我们的服务,其中query可以是任何东西。

例如,如果我们想找到关于廉价二手车的所有页面,那么我们向http://localhost:8080/q/cheap%20used%20cars发送一个GET请求。如果我们在 web 浏览器中这样做,我们应该能够看到 JSON 响应:

正如我们所见,只需几个简单的步骤,就可以创建一个简单的微服务来服务数据科学模型。接下来,我们将看到如何在线评估我们的模型的性能,也就是说,在模型被部署并且用户已经开始使用它之后。

在线评估

当我们进行交叉验证时,我们对我们的模型进行离线评估,我们根据过去的数据训练模型,然后保留一些数据,仅用于测试。了解模型在实际用户身上是否表现良好非常重要,但往往还不够。这就是为什么我们需要不断地在线监控我们的模型的性能——当用户实际使用它的时候。可能会发生这样的情况,一个在离线测试中表现很好的模型,在在线评估中实际上并没有表现得很好。这可能有很多原因——过度拟合、交叉验证不佳、过于频繁地使用测试集来检查性能,等等。

因此,当我们提出一个新模型时,我们不能因为它的离线性能更好就认为它会更好,所以我们需要在真实用户身上进行测试。

对于在线测试模型,我们通常需要想出一个合理的方法来衡量性能。我们可以获取许多指标,包括简单的指标,如点击数、在网站上花费的时间等。这些指标通常被称为关键绩效指标(KPI)。一旦我们决定了要监控哪些指标,我们就可以将所有的用户分成两组,看看哪里的指标更好。这种方法被称为 A/B 测试,这是一种流行的在线模型评估方法。

A/B 测试

A/B 测试是对系统用户进行受控实验的一种方式。通常,我们有两个系统——原始版本的系统(控制系统)和新的改进版本(处理系统)。

A/B 测试是对系统在线用户进行受控实验的一种方式。在这些实验中,我们有两个系统——原始版本(对照)和新版本(处理)。为了测试新版本是否比原来的版本更好,我们将系统的用户分成两组(控制处理),每组获得各自系统的输出。当用户与系统交互时,我们捕获我们感兴趣的 KPI,当实验完成时,我们看到整个治疗组的 KPI 是否与对照组有显著差异。如果不是(或者更差),那么测试表明新版本实际上并不比现有版本更好。

通常使用t-测试进行比较,我们查看每组的平均值,并执行双边(有时是单边)测试,这将告诉我们一组的平均值是否明显优于另一组,或者差异是否仅归因于数据的随机波动。

假设我们已经有了一个搜索引擎,它使用 Lucene 排名公式,并且不执行任何重新排序。然后我们提出 XGBoost 模型,想看看它是否更好。为此,我们决定测量用户的点击量

选择这个 KPI 是因为它实现起来非常简单,并且是一个很好的例子。但是对于评估搜索引擎来说,它不是一个很好的 KPI:例如,如果一个算法比其他算法获得更多的点击,这可能意味着用户无法找到他们正在寻找的东西。所以,实际上,你应该选择其他的评估指标。为了更好地了解现有的选择,你可以参考 K. Hoffman 的论文信息检索在线评估

让我们为我们的例子实现它。首先,我们创建一个特殊的类ABRanker,它实现了Ranker接口。在构造函数中,需要两个排序器和随机的seed(为了重现性):

public ABRanker(Ranker aRanker, Ranker bRanker, long seed) {
    this.aRanker = aRanker;
    this.bRanker = bRanker;
    this.random = new Random(seed);
}

接下来,我们实现rank方法,这应该非常简单;我们只是随机选择是使用aRanker还是bRanker:

public SearchResults rank(List<QueryDocumentPair> inputList) {
    if (random.nextBoolean()) {
        return aRanker.rank(inputList);
    } else {
        return bRanker.rank(inputList);
    }
}

让我们也修改一下SearchResults类,在那里包含两个额外的字段,结果的 ID 以及生成它的算法的 ID:

public class SearchResults {
    private String uuid = UUID.randomUUID().toString();
    private String generatedBy = "na";
    private List<SearchResult> list;
}

我们需要它来追踪。接下来,我们修改XGBRanker,使其将generatedBy字段设置为xgb——这一变化微不足道,因此我们在此省略。此外,我们需要创建 Lucene ranker 的实现。这也很简单——这个实现所要做的就是原样返回给定的列表,而不重新排序,并将generatedBy字段设置为lucene

接下来,我们修改我们的容器。我们需要创建两个排名器,给每个排名器分配一个名称(通过使用@Bean注释的name参数),然后最终创建ABRanker:

@Bean(name = "luceneRanker")
public DefaultRanker luceneRanker() throws Exception {
    return new DefaultRanker();
}

@Bean(name = "xgbRanker")
public XgbRanker xgbRanker() throws Exception {
    FeatureExtractor fe = load("project/feature-extractor.bin");
    return new XgbRanker(fe, "project/xgb_model.bin");
}

@Bean(name = "abRanker")
public ABRanker abRanker(@Qualifier("luceneRanker") DefaultRanker lucene,
        @Qualifier("xgbRanker") XgbRanker xgb) {
    return new ABRanker(lucene, xgb, 0L);
}

@Bean
public SearchEngineService searchEngineService(@Qualifier("abRanker") Ranker ranker)
        throws IOException {
    // content of this method stays the same
}

当我们创建ABRankerSearchEngineService时,在参数中我们提供了@Qualifier——这是 bean 的名称。因为我们现在有相当多的 rankers,我们需要能够区分它们,所以它们需要有名字。

一旦我们完成了,我们就可以重新启动我们的 web 服务。从现在开始,一半的请求将由 Lucene 默认排序器处理,不进行重新排序,另一半由 XGBoost 排序器处理,根据我们模型的分数进行重新排序。

下一步是获取用户的反馈并存储起来。在我们的例子中,反馈是点击,所以我们可以在SearchController中创建下面的 HTTP 端点来捕获这些信息:

@RequestMapping("click/{algorithm}/{uuid}")
public void click(@PathVariable("algorithm") String algorithm,
        @PathVariable("uuid") String uuid) throws Exception {
    service.registerClick(algorithm, uuid);
}

当我们接收到对click/{algorithm}/{uuid}路径的GET请求时,这个方法将被调用,其中{algorithm}{uuid}都是占位符。在这个方法中,我们将调用转发给SearchEngineService类。

现在让我们稍微重新组织一下我们的抽象,创建另一个接口FeedbackRanker,它扩展了Ranker接口并提供了registerClick方法:

public interface FeedbackRanker extends Ranker {
    void registerClick(String algorithm, String uuid);
}

我们可以让SearchEngineService依赖于它,而不是简单的Ranker,这样我们就可以收集反馈。除此之外,我们还可以将呼叫转发给实际的排名者:

public class SearchEngineService {
    private final FeedbackRanker ranker;

    public SearchEngineService(IndexSearcher searcher, FeedbackRanker ranker) {
        this.searcher = searcher;
        this.ranker = ranker;
    }

    public void registerClick(String algorithm, String uuid) {
        ranker.registerClick(algorithm, uuid);
    }

    // other fields and methods are omitted
}

最后,我们让我们的ABRanker实现这个接口,并将捕获逻辑放在registerClick方法中。

例如,我们可以进行以下修改:

public class ABRanker implements FeedbackRanker {
    private final List<String> aResults = new ArrayList<>();
    private final List<String> bResults = new ArrayList<>();
    private final Multiset<String> clicksCount = ConcurrentHashMultiset.create();

    @Override
    public SearchResults rank(List<QueryDocumentPair> inputList) 
                     throws Exception {
        if (random.nextBoolean()) {
            SearchResults results = aRanker.rank(inputList);
            aResults.add(results.getUuid());
            return results;
        } else {
            SearchResults results = bRanker.rank(inputList);
            bResults.add(results.getUuid());
            return results;
        }
    }

    @Override
    public void registerClick(String algorithm, String uuid) {
        clicksCount.add(uuid);
    }

    // constructor and other fields are omitted
}

在这里,我们创建了两个数组列表,其中填充了已创建结果的UUID和来自 Guava 的一个Multiset,后者计算了每个算法收到的点击次数。我们在这里使用集合只是为了举例说明,实际上,您应该将结果写入数据库或某个日志。

最后,让我们假设系统运行了一段时间,我们能够从用户那里收集一些反馈。现在是时候检查新算法是否比旧算法更好了。这是通过t-测试完成的,我们可以从 Apache Commons Math 中获取。

最简单的实现方法如下:

public void tTest() {
    double[] sampleA = aResults.stream().mapToDouble(u -> clicksCount.count(u)).toArray();
    double[] sampleB = bResults.stream().mapToDouble(u -> clicksCount.count(u)).toArray();

    TTest tTest = new TTest();
    double p = tTest.tTest(sampleA, sampleB);

    System.out.printf("P(sample means are same) = %.3f%n", p);
}

执行之后,它将报告【p】t测试的值**,或者,拒绝两个样本具有相同均值的零假设的概率。如果这个数字很小,那么差别就很大,或者换句话说,有强有力的证据表明一种算法比另一种算法好。

有了这个简单的想法,我们可以对我们的机器学习算法进行在线评估,并确保离线改进确实导致了在线改进。在下一节中,我们将讨论一个类似的想法,多臂土匪,它允许我们在运行时选择最佳性能的算法。

多武装匪徒

A/B 测试是评估一些想法的很好的工具。但有时没有更好的模式,对于一个特定的案例,有时一个更好,有时另一个更好。为了在这个特定的时刻选择一个更好的,我们可以使用在线学习。

我们可以把这个问题公式化为一个强化学习问题——我们有个代理(我们的搜索引擎和排名器),他们与环境(搜索引擎的用户),并获得一些奖励(点击)。然后,我们的系统通过采取动作(选择排名者),观察反馈并基于反馈选择最佳策略,从交互中学习。

如果我们试图在这个框架中制定 A/B 测试,那么 A/B 测试的行为是随机选择排名者,奖励是点击。但是对于 A/B 测试,当我们建立实验时,我们要等到实验结束。然而,在在线学习环境中,我们不需要等到最后,我们已经可以根据目前收到的反馈选择最佳排名。
这个问题被称为 bandit 问题,被称为多臂 bandit 的算法帮助我们解决了这个问题——它可以在执行实验的同时选择最佳模型。主要思想是有两种行动——探索,你尝试采取未知性能的行动,和开发,你使用最佳性能的模型。

其实现方式如下:我们预先定义一些概率e(ε),用它我们在勘探和开采之间进行选择。用概率 e 我们随机选择任何可用的行动,用概率 1 - e 我们利用经验上的最佳行动。对于我们的问题,这意味着如果我们有几个排名器,我们使用概率为 1 - e 的最佳排名器,并使用概率为 e 的随机选择的排名器对结果进行重新排序。在运行期间,我们监控 KPI 以了解哪个排名者当前是最好的,并在我们获得更多反馈时更新统计数据。

这个想法有一个小缺点,当我们刚刚开始运行 bandit 时,我们没有足够的数据来选择哪个算法是最好的。这可以通过一系列预热来解决,例如,前 1000 个结果可能只在探索模式下获得。也就是说,对于前 1000 个结果,我们只是随机选择排名。之后,我们应该收集足够的数据,然后如上所述以概率 e 在开采和勘探之间进行选择。

所以让我们为此创建一个新类,我们称之为BanditRanker,它将实现我们为ABRanker定义的FeedbackRanker接口。

构造函数将获取一个Ranker的映射,其中包含与每个排名器相关的名称、epsilon参数和随机的seed:

public BanditRanker(Map<String, Ranker> rankers, double epsilon, long seed) {
    this.rankers = rankers;
    this.rankerNames = new ArrayList<>(rankers.keySet());
    this.epsilon = epsilon;
    this.random = new Random(seed);
}

在内部,我们还将保留一个供内部使用的排名表。

接下来,我们实现rank函数:

@Override
public SearchResults rank(List<QueryDocumentPair> inputList) throws Exception {
    if (count.getAndIncrement() < WARM_UP_ROUNDS) {
        return rankByRandomRanker(inputList);
    }

    double rnd = random.nextDouble();
    if (rnd > epsilon) {
        return rankByBestRanker(inputList);
    }

    return rankByRandomRanker(inputList);
}

这里我们总是首先随机选择排名者,然后或者探索(通过rankByRandomRanker方法随机选择排名者)或者利用(通过rankByBestRanker方法选择最佳排名者)。

现在让我们看看如何实现这些方法,首先,rankByRandomRanker方法是以如下方式实现的:

private SearchResults rankByRandomRanker(List<QueryDocumentPair> inputList) {
    int idx = random.nextInt(rankerNames.size());
    String rankerName = rankerNames.get(idx);
    Ranker ranker = rankers.get(rankerName);
    SearchResults results = ranker.rank(inputList);
    explorationResults.add(results.getUuid().hashCode());
    return results;
}

这非常简单:我们从rankerName列表中随机选择一个名字,然后根据名字获得排名,并用它来重新排列结果。最后,我们还将生成结果的UUID保存到一个HashSet(或者更确切地说,是保存 RAM 的散列)。

rankByBestRanker方法有如下实现:

private SearchResults rankByBestRanker(List<QueryDocumentPair> inputList)  {
    String rankerName = bestRanker();
    Ranker ranker = rankers.get(rankerName);
    return ranker.rank(inputList);
}

private String bestRanker() {
    Comparator<Multiset.Entry<String>> cnp =
            (e1, e2) -> Integer.compare(e1.getCount(), e2.getCount());
    Multiset.Entry<String> entry = counts.entrySet().stream().max(cnp).get();
    return entry.getElement();
}

这里我们保存了Multiset<String>,它存储了每个算法收到的点击次数。然后,我们选择基于这个数字的算法,并使用它来重新排列结果。

最后,这是我们如何实现registerClick函数:

@Override
public void registerClick(String algorithm, String uuid) {
    if (explorationResults.contains(uuid.hashCode())) {
        counts.add(algorithm);
    }
}

我们不是只计算点击次数,而是首先过滤掉在利用阶段生成的结果的点击,这样它们就不会扭曲统计数据。

这样,我们实现了最简单的多臂强盗版本,您可以使用它来选择部署最好的模型。为了将它包含到我们的工作 web 服务中,我们需要修改container类,但是修改是琐碎的,所以我们在这里省略了它。

摘要

在本书中,我们涵盖了大量的材料,从 data 中可用的数据科学库开始,然后探索监督和非监督学习模型,并讨论文本、图像和图形。在最后一章中,我们谈到了非常重要的一步:如何将这些模型部署到生产中,并在真实用户身上进行评估。

posted @ 2025-10-26 08:58  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报