Java-数据科学-全-
Java 数据科学(全)
零、前言
在本书中,我们研究了数据科学领域中基于 Java 的方法。数据科学是一个广泛的主题,包括数据挖掘、统计分析、音频和视频分析以及文本分析等子主题。许多 Java APIs 都支持这些主题。应用这些特定技术的能力允许创建新的、创新的应用程序,这些应用程序能够处理可用于分析的大量数据。
这本书对数据科学的各个方面采取了广泛而粗略的方法。第一章简要介绍了这一领域。后续章节涵盖数据科学的重要方面,如数据清洗和神经网络的应用。最后一章结合了整本书讨论的主题,以创建一个全面的数据科学应用。
这本书涵盖了什么
第 1 章,数据科学入门,介绍了本书涵盖的技术。给出了每种技术的简要说明,随后是 Java 提供的支持的简短概述和演示。
第 2 章、数据采集,演示了如何从多个来源采集数据,包括 Twitter、Wikipedia 和 YouTube。数据科学应用的第一步是获取数据。
第 3 章、数据清理解释了一旦获得数据,就需要对其进行清理。这可能涉及删除停用词、验证数据和数据转换等活动。
第 4 章、数据可视化表明,虽然数值处理是许多数据科学任务中的关键步骤,但人们通常更喜欢分析结果的可视化描述。本章演示了完成这项任务的各种 Java 方法。
第 5 章,统计数据分析技术,回顾基本的统计技术,包括回归分析,并演示各种 Java APIs 如何提供统计支持。统计分析是许多数据分析任务的关键。
第六章、、机器学习,涵盖了几种机器学习算法,包括决策树和支持向量机。大量的可用数据为应用机器学习技术提供了机会。
第 7 章、神经网络,解释了神经网络可以应用于解决各种数据科学问题。在这一章中,我们将解释它们是如何工作的,并演示几种不同类型的神经网络的使用。
第八章、深度学习表明深度学习算法通常被描述为多级神经网络。Java 在这方面提供了重要的支持,我们将举例说明这种方法的使用。
第九章、文本分析解释了的可用数据集的很大一部分以文本格式存在。自然语言处理领域已经取得了相当大的进步,并且经常用于数据科学应用中。我们展示了用于支持这种类型分析的各种 Java APIs。
第十章、、视觉和听觉分析告诉我们,数据科学并不局限于文本处理。许多社交媒体网站广泛使用视觉数据。本章说明了可用于此类分析的 Java 支持。
第 11 章,数据分析的数学和并行技术,研究为低级数学运算提供的支持,以及如何在多处理器环境中支持它们。数据分析的核心是处理和分析大量数字数据的能力。
第 12 章、将所有这些整合在一起,探讨了如何将本书中介绍的各种技术整合起来,以创建数据科学应用。本章从数据采集开始,结合了后续章节中使用的许多技术来构建一个完整的应用程序。
这本书你需要什么
书中的许多例子都使用了 Java 8 的特性。演示了许多 Java APIs,每一个都是在应用之前介绍的。IDE 不是必需的,但却是理想的。
这本书是给谁的
这本书的目标读者是有经验的 Java 程序员,他们有兴趣更好地理解数据科学领域以及 Java 如何支持底层技术。不需要该领域的先前经验。
习俗
在这本书里,你会发现许多区分不同种类信息的文本样式。下面是这些风格的一些例子和它们的含义的解释。
文本中的代码如下所示:“getResult
方法返回一个保存处理结果的SpeechResult
实例。”数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄如下所示:“KevinVoiceDirectory
包含两种声音:kevin
和kevin16
”
代码块设置如下:
Voice[] voices = voiceManager.getVoices();
for (Voice v : voices) {
out.println(v);
}
任何命令行输入或输出都按如下方式编写:
Name: kevin16
Description: default 16-bit diphone voice
Organization: cmu
Age: YOUNGER_ADULT
Gender: MALE
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中看到的单词,会出现在文本中,如下所示:“选择 Images
类别,然后过滤 Labeled for reuse
。”
注意
警告或重要提示出现在这样的框中。
Tip
提示和技巧是这样出现的。
读者反馈
我们随时欢迎读者的反馈。让我们知道你对这本书的看法——你喜欢或不喜欢什么。读者的反馈对我们来说很重要,因为它有助于我们开发出真正让你受益匪浅的图书。要给我们发送总体反馈,只需发送电子邮件feedback@packtpub.com
,并在邮件主题中提及书名。如果有一个你擅长的主题,并且你有兴趣写一本书或者为一本书投稿,请在www.packtpub.com/authors查看我们的作者指南。
客户支持
既然您已经是 Packt book 的骄傲拥有者,我们有许多东西可以帮助您从购买中获得最大收益。
下载示例代码
你可以从你在http://www.packtpub.com的账户下载本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问 http://www.packtpub.com/support 的并注册,让文件直接通过电子邮件发送给你。
您可以按照以下步骤下载代码文件:
- 使用您的电子邮件地址和密码登录或注册我们的网站。
- 将鼠标指针悬停在顶部的
SUPPORT
标签上。 - 点击
Code Downloads & Errata
。 - 在
Search
框中输入书名。 - 选择您要下载代码文件的书。
- 从下拉菜单中选择您购买这本书的地方。
- 点击
Code Download
。
下载文件后,请确保使用最新版本的解压缩或解压文件夹:
- WinRAR / 7-Zip for Windows
- 适用于 Mac 的 Zipeg / iZip / UnRarX
- 用于 Linux 的 7-Zip / PeaZip
这本书的代码包也托管在 GitHub 上,地址是https://github.com/PacktPublishing/T2【Java-for-Data-Science】T3。我们在 https://github.com/PacktPublishing/的也有丰富的书籍和视频目录中的其他代码包。看看他们!
勘误表
尽管我们已尽一切努力确保内容的准确性,但错误还是会发生。如果您在我们的某本书中发现了一个错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做,你可以让其他读者免受挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误表,请访问http://www.packtpub.com/submit-errata,选择您的图书,点击 Errata Submission Form
链接,并输入勘误表的详细信息。一旦您的勘误表得到验证,您的提交将被接受,该勘误表将被上传到我们的网站或添加到该标题的勘误表部分下的任何现有勘误表列表中。
要查看之前提交的勘误表,请前往https://www.packtpub.com/books/content/support,在搜索栏中输入图书名称。所需信息将出现在 Errata
部分。
盗版
互联网上版权材料的盗版是所有媒体都存在的问题。在 Packt,我们非常重视版权和许可证的保护。如果您在互联网上发现我们作品的任何形式的非法拷贝,请立即向我们提供地址或网站名称,以便我们采取补救措施。
请通过copyright@packtpub.com
联系我们,并提供可疑盗版材料的链接。
我们感谢您帮助保护我们的作者,以及我们为您带来有价值内容的能力。
问题
如果您对本书的任何方面有问题,可以通过questions@packtpub.com
联系我们,我们将尽最大努力解决问题。
一、数据科学入门
数据科学不是一门单一的科学,而是为了分析数据而集成的各种科学学科的集合。这些学科包括各种统计和数学技术,包括:
- 计算机科学
- 数据工程
- 形象化
- 特定领域的知识和方法
随着更便宜的存储技术的出现,越来越多的数据被收集和存储,允许以前不可行的数据处理和分析。随着这种分析,需要各种技术来理解数据。这些大型数据集在用于分析数据并识别趋势和模式时,被称为大数据。
这反过来又催生了云计算和并发技术,如 map-reduce、将分析过程分布在大量处理器上,充分利用了并行处理的能力。
分析大数据的过程并不简单,并且演变为被称为数据科学家的开发人员的专业化。利用无数的技术和专业知识,他们能够分析数据来解决以前没有预见到或难以解决的问题。
早期的大数据应用以搜索引擎的出现为代表,这些搜索引擎比它们的前辈能够进行更强大和更准确的搜索。例如, AltaVista 是早期流行的搜索引擎,最终被谷歌取代。虽然大数据应用不仅限于这些搜索引擎功能,但这些应用为未来的大数据工作奠定了基础。
数据科学这一术语自 1974 年开始使用,并随着时间的推移发展到包括数据的统计分析。数据挖掘和数据分析的概念已经与数据科学联系在一起。大约在 2008 年,数据科学家一词出现,用来描述执行数据分析的人。关于数据科学历史的更深入的讨论可以在http://www . Forbes . com/sites/Gil press/2013/05/28/a-very-short-history-of-data-science/# 3d 9 ea 08369 FD找到。
这本书旨在用 Java 对数据科学有一个广泛的了解,并将简要涉及许多主题。读者可能会找到感兴趣的话题,并独立地更深入地探讨这些话题。然而,本书的目的只是向读者介绍重要的数据科学主题,并说明如何使用 Java 解决这些问题。
数据科学中使用了很多算法。在本书中,我们不试图解释它们是如何工作的,除非是在一个介绍性的水平上。相反,我们更感兴趣的是解释它们如何被用来解决问题。具体来说,我们有兴趣知道它们如何与 Java 一起使用。
利用数据科学解决问题
我们将展示的各种数据科学技术已经被用来解决各种问题。这些技术中有许多是为了获得一些经济利益,但是它们也被用来解决许多紧迫的社会和环境问题。使用这些技术的问题领域包括金融、优化业务流程、了解客户需求、执行 DNA 分析、挫败恐怖阴谋、发现交易之间的关系以检测欺诈,以及许多其他数据密集型问题。
数据挖掘是数据科学的一个热门应用领域。在这项活动中,大量的数据被处理和分析,以收集关于数据集的信息,提供有意义的见解,并得出有意义的结论和预测。它已被用于分析客户行为,检测看似不相关的事件之间的关系,并对未来行为做出预测。
机器学习是数据科学的一个重要方面。这种技术允许计算机解决各种问题,而不需要显式编程。它已经被用于自动驾驶汽车、语音识别和网络搜索。在数据挖掘中,数据被提取和处理。通过机器学习,计算机使用数据采取某种行动。
了解数据科学问题解决方法
数据科学涉及对大量数据的处理和分析,以创建可用于预测或支持特定目标的模型。这个过程通常涉及模型的建立和训练。解决问题的具体方法取决于问题的性质。但是,一般来说,以下是分析过程中使用的高级任务:
-
获取数据:在我们处理数据之前,必须先获取数据。数据经常以各种格式存储,并且将来自广泛的数据源。
-
清理数据:数据一旦被采集,往往需要转换成不同的格式才能使用。此外,还需要对数据进行处理或清理,以便消除错误、解决不一致的地方,或者将数据转换成可供分析的形式。
-
Analyzing the data: This can be performed using a number of techniques including:
-
统计分析:这使用多种统计方法来提供对数据的洞察。它包括简单的技术和更高级的技术,如回归分析。
-
AI analysis: These can be grouped as machine learning, neural networks, and deep learning techniques:
- 机器学习方法的特点是程序可以学习,而无需专门编程来完成特定任务
- 神经网络是围绕模仿大脑神经连接的模型建立的
- 深度学习试图在一组数据中识别更高层次的抽象
-
文本分析:这是一种常见的分析形式,它与自然语言一起识别特征,如人名和地名、文本各部分之间的关系以及文本的隐含意义。
-
数据可视化:这是一个重要的分析工具。通过以视觉形式显示数据,一组难以理解的数字可以更容易理解。
-
视频、图像和音频处理和分析:这是一种更专业的分析形式,随着更好的分析技术的发现和更快的处理器的出现,这种形式变得越来越普遍。这与更常见的文本处理和分析任务形成对比。
-
开发高效的应用程序是对这组任务的补充。采用多处理器和 GPU 的机器极大地促进了最终结果。
虽然使用的确切步骤会因应用程序而异,但了解这些基本步骤为构建许多数据科学问题的解决方案提供了基础。
使用 Java 支持数据科学
Java 及其相关的第三方库为数据科学应用程序的开发提供了一系列支持。有许多核心 Java 功能可以使用,比如基本的字符串处理方法。Java 8 中 lambda 表达式的引入有助于实现构建应用程序的更强大和更具表现力的方法。在后续章节的许多例子中,我们将展示使用 lambda 表达式的替代技术。
为基础数据科学任务提供了充足的支持。这些包括获取数据的多种方式、用于清理数据的库,以及用于自然语言处理和统计分析等任务的多种分析方法。还有无数支持神经网络类型分析的库。
对于数据科学问题,Java 可能是一个非常好的选择。该语言为解决问题提供了面向对象和函数的支持。有一个庞大的开发人员社区可以利用,并且存在多个支持数据科学任务的 API。这些只是为什么应该使用 Java 的几个原因。
本章的剩余部分将概述书中演示的数据科学任务和 Java 支持。每个部分只能提供对主题和可用支持的简要介绍。下一章将更深入地讨论这些话题。
为应用程序获取数据
数据采集是数据分析过程中的一个重要步骤。当数据被获取时,它通常是一种特殊的形式,其内容可能与应用程序的需求不一致或不同。有许多数据来源,可以在互联网上找到。几个例子将在第二章、数据采集中演示。
数据可以以各种格式存储。文本数据的流行格式包括 HTML、逗号分隔值 ( CSV )、 JavaScript 对象符号 ( JSON )和 XML。图像和音频数据以多种格式存储。但是,经常需要将一种数据格式转换成另一种格式,通常是纯文本格式。
例如,JSON(http://www.JSON.org/)使用包含键值对的花括号块来存储。在以下示例中,显示了 YouTube 结果的一部分:
{
"kind": "youtube#searchResult",
"etag": etag,
"id": {
"kind": string,
"videoId": string,
"channelId": string,
"playlistId": string
},
...
}
使用诸如处理直播流、下载压缩文件以及通过屏幕抓取等技术获取数据,在那里提取网页上的信息。Web 爬行是一种技术,程序检查一系列网页,从一个页面移动到另一个页面,获取它需要的数据。
对于许多流行的媒体网站,需要获得用户 ID 和密码才能访问数据。一种常用的技术是 OAuth,,这是一种开放标准,用于对许多不同网站的用户进行身份验证。该技术委托对服务器资源的访问,并在 HTTPS 上工作。一些公司使用 OAuth 2.0,包括 PayPal、脸书、Twitter 和 Yelp。
清洗数据的重要性和过程
一旦获取了数据,就需要对其进行清理。通常,数据会包含错误、重复条目或不一致。通常需要将其转换为更简单的数据类型,如文本。数据清洗通常被称为数据角力、整形、或蒙骗。它们实际上是同义词。
清理数据时,通常需要执行几项任务,包括检查其有效性、准确性、完整性、一致性和统一性。例如,当数据不完整时,可能需要提供替代值。
考虑 CSV 数据。可以用几种方法中的一种来处理。我们可以使用简单的 Java 技术,比如String
class' split
方法。在下面的序列中,假定字符串数组csvArray
保存逗号分隔的数据。split
方法填充第二个数组tokenArray
。
for(int i=0; i<csvArray.length; i++) {
tokenArray[i] = csvArray[i].split(",");
}
更复杂的数据类型需要 API 来检索数据。例如,在第三章、数据清理中,我们将使用杰克森项目(【https://github.com/FasterXML/jackson】T4)从 JSON 文件中检索字段。该示例使用一个文件,该文件包含一个人的 JSON 格式的演示,如下所示:
{
"firstname":"Smith",
"lastname":"Peter",
"phone":8475552222,
"address":["100 Main Street","Corpus","Oklahoma"]
}
下面的代码序列显示了如何提取一个人的字段值。创建一个解析器,它使用getCurrentName
来检索字段名称。如果名称是firstname
,那么getText
方法返回该字段的值。其他字段以类似的方式处理。
try {
JsonFactory jsonfactory = new JsonFactory();
JsonParser parser = jsonfactory.createParser(
new File("Person.json"));
while (parser.nextToken() != JsonToken.END_OBJECT) {
String token = parser.getCurrentName();
if ("firstname".equals(token)) {
parser.nextToken();
String fname = parser.getText();
out.println("firstname : " + fname);
}
...
}
parser.close();
} catch (IOException ex) {
// Handle exceptions
}
该示例的输出如下:
firstname : Smith
简单的数据清理可能包括将文本转换成小写,用空格替换某些文本,以及用一个空格删除多个空白字符。下面显示了这样做的一种方法,其中组合使用了String
class' toLowerCase
、replaceAll
和trim
方法。这里,处理包含脏文本的字符串:
dirtyText = dirtyText
.toLowerCase()
.replaceAll("[\\d[^\\w\\s]]+", "
.trim();
while(dirtyText.contains(" ")){
dirtyText = dirtyText.replaceAll(" ", " ");
}
停用词是诸如、、和或但之类的词,它们并不总是有助于文本分析。删除这些停用词通常可以改善结果并加快处理速度。
LingPipe API 可以用来移除停用词。在下一个代码序列中,使用了一个TokenizerFactory
类实例来标记文本。标记化是返回单个单词的过程。EnglishStopTokenizerFactory
类是一个特殊的记号赋予器,它删除常见的英语停用词。
text = text.toLowerCase().trim();
TokenizerFactory fact = IndoEuropeanTokenizerFactory.INSTANCE;
fact = new EnglishStopTokenizerFactory(fact);
Tokenizer tok = fact.tokenizer(
text.toCharArray(), 0, text.length());
for(String word : tok){
out.print(word + " ");
}
想想下面这段摘自《莫比·迪克》一书的文字:
Call me Ishmael. Some years ago- never mind how long precisely - having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.
输出如下所示:
call me ishmael . years ago - never mind how long precisely - having little money my purse , nothing particular interest me shore , i thought i sail little see watery part world .
这些只是在第 3 章、数据清理中讨论的几个数据清理任务。
可视化数据以增强理解
数据分析通常会产生一系列代表分析结果的数字。然而,对于大多数人来说,这种表达结果的方式并不总是直观的。理解结果的更好方法是创建图形和图表来描述结果以及结果元素之间的关系。
人类的大脑通常善于在视觉表现中看到模式、趋势和异常值。许多数据科学问题中存在的大量数据可以使用可视化技术进行分析。可视化适用于从分析师到高层管理人员到客户的广泛受众。在这一章中,我们将介绍各种可视化技术,并演示 Java 是如何支持这些技术的。
在第 4 章、数据可视化、中,我们展示了如何创建不同类型的图形、曲线图和图表。这些例子使用 JavaFX,使用一个名为【http://trac.erichseifert.de/gral/】()的免费库。
可视化允许用户以提供大量数据中不存在的洞察力的方式来检查大型数据集。可视化工具帮助我们识别潜在的问题或意外的数据结果,并对数据进行有意义的解释。
例如,离群值,即超出正常值范围的值,可能很难从大量数字中识别出来。根据数据创建图表,用户可以快速发现异常值。它还可以帮助快速发现错误,并更容易地对数据进行分类。
例如,下面的图表可能表明上面两个值应该是需要处理的异常值:
数据科学中统计方法的使用
统计分析是许多数据科学任务的关键。它用于多种类型的分析,从简单平均值和中间值的计算到复杂的多元回归分析。第 5 章,统计数据分析技术,介绍了这种类型的分析和可用的 Java 支持。
统计分析并不总是一件容易的事情。此外,高级统计技术通常需要特定的心态才能完全理解,这可能很难学习。幸运的是,许多技术并不难使用,各种库减轻了这些技术固有的复杂性。
特别地,回归分析是一种分析数据的重要技术。该技术试图绘制一条与一组数据相匹配的线。计算表示该线的方程,并可用于预测未来的行为。回归分析有几种类型,包括简单回归和多元回归。它们因所考虑的变量数量而异。
下图显示了一条与代表比利时几十年人口的一组数据点非常匹配的直线:
简单的统计技术,如均值和标准差,可以使用 basic Java 来计算。它们也可以由 Apache Commons 之类的库来处理。例如,为了计算中位数,我们可以使用 Apache Commons DescriptiveStatistics
类。这将在下一个计算 doubles 数组的中值的地方进行说明。这些数字被添加到该类的一个实例中,如下所示:
double[] testData = {12.5, 18.3, 11.2, 19.0, 22.1, 14.3, 16.2,
12.5, 17.8, 16.5, 12.5};
DescriptiveStatistics statTest =
new SynchronizedDescriptiveStatistics();
for(double num : testData){
statTest.addValue(num);
}
getPercentile
方法返回存储在其参数中指定的百分点值。为了找到中间值,我们使用50
的值。
out.println("The median is " + statTest.getPercentile(50));
我们的输出如下:
The median is 16.2
在第五章、统计数据分析技术中,我们将展示如何使用 Apache Commons SimpleRegression
类执行回归分析。
机器学习应用于数据科学
机器学习对于数据科学分析已经变得越来越重要,就像它对于许多其他领域一样。机器学习的一个定义性特征是模型能够根据一组代表性数据进行训练,然后用于解决类似的问题。不需要显式地编写应用程序来解决问题。模型是现实世界对象的表示。
例如,客户购买可以用于训练模型。随后,可以对客户随后可能进行的购买类型进行预测。这允许组织为客户定制广告和优惠券,并可能提供更好的客户体验。
培训可以用几种不同的方法之一来进行:
- 监督学习:用显示相应正确结果的带注释、带标签的数据训练模型
- 无监督学习:数据不包含结果,但是模型被期望自己发现关系
- 半监督:少量标记数据与大量未标记数据相结合
- 强化学习:这类似于监督学习,但是对好的结果提供奖励
有几种方法支持机器学习。在第六章、、机器学习中,我们将举例说明三种技术:
- 决策树:使用问题的特征作为内部节点,结果作为叶子来构建一棵树
- 支持向量机:它通过创建一个超平面来划分数据集,然后进行预测,从而用于分类
- 贝叶斯网络:用于描述事件之间的概率关系
一个支持向量机 ( SVM )主要用于分类类型问题。该方法创建了一个超平面来对数据进行分类,该超平面可以被想象为分隔两个区域的几何平面。在二维空间中,它将是一条线。在三维空间中,它将是一个二维平面。在第 6 章、机器学习、中,我们将使用一组与个人露营倾向相关的数据来演示如何使用该方法。我们将使用 Weka 类SMO
来演示这种类型的分析。
下图描述了一个使用两种数据点分布的超平面。这些线代表分隔这些点的可能的超平面。除了一个异常值,这些线清楚地分隔了数据点。
一旦模型被训练,可能的超平面被考虑,然后可以使用类似的数据进行预测。
在数据科学中使用神经网络
一个人工神经网络 ( 安),我们称之为神经网络,是基于大脑中发现的神经元。一个神经元是一个有树突将其连接到输入源和其他神经元的细胞。根据输入源,分配给源的权重,神经元被激活,然后发射信号沿着树突到另一个神经元。可以训练一组神经元对一组输入信号作出反应。
人工神经元是具有一个或多个输入和单个输出的节点。每个输入都有一个分配给它的权重,该权重可以随时间变化。神经网络可以通过向网络输入输入、调用激活函数并比较结果来进行学习。该函数组合输入并创建输出。如果多个神经元的输出与预期结果匹配,那么网络已经被正确训练。如果它们不匹配,则网络被修改。
如下图所示,可以可视化一个神经网络,其中隐藏层用于增强该过程:
数据集被分成训练集和测试集。读取数据后,将使用方法创建并初始化 MLP 实例,以配置模型的属性,包括模型学习的速度和训练模型所花费的时间。
String trainingFileName = "dermatologyTrainingSet.arff";
String testingFileName = "dermatologyTestingSet.arff";
try (FileReader trainingReader = new FileReader(trainingFileName);
FileReader testingReader =
new FileReader(testingFileName)) {
Instances trainingInstances = new Instances(trainingReader);
trainingInstances.setClassIndex(
trainingInstances.numAttributes() - 1);
Instances testingInstances = new Instances(testingReader);
testingInstances.setClassIndex(
testingInstances.numAttributes() - 1);
MultilayerPerceptron mlp = new MultilayerPerceptron();
mlp.setLearningRate(0.1);
mlp.setMomentum(0.2);
mlp.setTrainingTime(2000);
mlp.setHiddenLayers("3");
mlp.buildClassifier(trainingInstances);
...
} catch (Exception ex) {
// Handle exceptions
}
然后使用测试数据对模型进行评估:
Evaluation evaluation = new Evaluation(trainingInstances);
evaluation.evaluateModel(mlp, testingInstances);
然后可以显示结果:
System.out.println(evaluation.toSummaryString());
此处显示了该示例的截断输出,其中列出了正确和错误识别的疾病数量:
Correctly Classified Instances 73 98.6486 %
Incorrectly Classified Instances 1 1.3514 %
可以调整模型的各种属性来改进模型。在第 7 章、神经网络、中,我们将更深入地讨论这一技术和其他技术。
深度学习方法
深度学习网络通常被描述为使用多个中间层的神经网络。每一层将在前一层的输出上训练,潜在地识别数据集的特征和子特征。特征是指可能感兴趣的数据的那些方面。在第 8 章、深度学习中,我们将考察这些类型的网络,以及它们如何支持几种不同的数据科学任务。
这些网络通常使用非结构化和未标记的数据集,这是当今可用数据的绝大部分。一种典型的方法是获取数据,识别要素,然后使用这些要素及其对应的图层来重构原始数据集,从而验证网络。受限玻尔兹曼机 ( RBM )就是应用这种方法的一个很好的例子。
深度学习网络需要确保结果是准确的,并最大限度地减少任何可能蔓延到过程中的错误。这是通过基于所谓的梯度下降来调整分配给神经元的内部权重来实现的。这代表重量变化的斜率。该方法修改权重以最小化误差,并且还加速了学习过程。
有几种类型的网络被归类为深度学习网络。其中之一是一个自动编码器网络。在这个网络中,各层是对称的,其中输入值的数量与输出值的数量相同,中间层有效地将数据压缩到一个更小的内部层。自动编码器的每一层都是一个 RBM。
下面的例子反映了这种结构,它将提取一组包含手写数字的图像中的数字。这里没有显示完整示例的细节,但是请注意,1,000 个输入和输出值与由 RBM 组成的内部层一起使用。层的大小在nOut
和nIn
方法中指定。
MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(numberOfIterations)
.optimizationAlgo(
OptimizationAlgorithm.LINE_GRADIENT_DESCENT)
.list()
.layer(0, new RBM.Builder()
.nIn(numberOfRows * numberOfColumns).nOut(1000)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(1, new RBM.Builder().nIn(1000).nOut(500)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(2, new RBM.Builder().nIn(500).nOut(250)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(3, new RBM.Builder().nIn(250).nOut(100)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(4, new RBM.Builder().nIn(100).nOut(30)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build()) //encoding stops
.layer(5, new RBM.Builder().nIn(30).nOut(100)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build()) //decoding starts
.layer(6, new RBM.Builder().nIn(100).nOut(250)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(7, new RBM.Builder().nIn(250).nOut(500)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(8, new RBM.Builder().nIn(500).nOut(1000)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(9, new OutputLayer.Builder(
LossFunctions.LossFunction.RMSE_XENT).nIn(1000)
.nOut(numberOfRows * numberOfColumns).build())
.pretrain(true).backprop(true)
.build();
一旦模型经过训练,就可以用于预测和搜索任务。通过搜索,压缩的中间层可以用于匹配其他需要分类的压缩图像。
执行文本分析
自然语言处理 ( NLP )领域用于许多不同的任务,包括文本搜索、语言翻译、情感分析、语音识别和分类等等。由于许多原因,包括自然语言固有的模糊性,处理文本是困难的。
有几种不同类型的处理可以执行,例如:
- 识别停用词:这些是常见的词,可能不需要处理
- 命名实体识别 ( NER ):这是识别文本元素的过程,例如人名、地点或事物
- 词性 ( 词性):这标识了一个句子的语法部分,如名词、动词、形容词等等
- 关系:这里我们关心的是识别文本的各个部分是如何相互关联的,比如句子的主语和宾语
与大多数数据科学问题一样,预处理和清理文本非常重要。在第 9 章、文本分析中,我们考察了 Java 为这一数据科学领域提供的支持。
例如,我们将使用 Apache 的 OpenNLP(https://opennlp.apache.org/)库来查找词性。这只是我们可以使用的几个 NLP APIs 中的一个,包括 LingPipe(【http://alias-i.com/lingpipe/】)、Apache UIMA()和 Standford NLP(【http://nlp.stanford.edu/】)。我们选择 OpenNLP 是因为它在这个例子中很容易使用。
在下面的例子中,在en-pos-maxent.bin
文件中找到了一个用于识别 POS 元素的模型。初始化单词数组并创建 POS 模型:
try (InputStream input = new FileInputStream(
new File("en-pos-maxent.bin"));) {
String sentence = "Let's parse this sentence.";
...
String[] words;
...
list.toArray(words);
POSModel posModel = new POSModel(input);
...
} catch (IOException ex) {
// Handle exceptions
}
向tag
方法传递一个words
数组,并返回一个标签数组。然后显示单词和标签。
String[] posTags = posTagger.tag(words);
for(int i=0; i<posTags.length; i++) {
out.println(words[i] + " - " + posTags[i]);
}
该示例的输出如下:
Let's - NNP
parse - NN
this - DT
sentence. - NN
缩写NNP
和DT
分别代表单数专有名词和限定词。我们在第 9 章、文本分析中考察了其他几种 NLP 技术。
视觉和听觉分析
在第十章、视听分析中,我们展示了几种处理声音和图像的 Java 技术。我们首先演示声音处理技术,包括语音识别和文本到语音转换 API。具体来说,我们将使用 FreeTTS(【http://freetts.sourceforge.net/docs/index.php】)API 将文本转换为语音。我们还展示了用于语音识别的 CMU 斯芬克斯工具包。
Java 语音 API(JSAPI)(【http://www.oracle.com/technetwork/java/index-140170.html】T4)支持语音技术。这个由第三方供应商创建的 API 支持语音识别和语音合成器。FreeTTS 和 Festival(http://www.cstr.ed.ac.uk/projects/festival/)就是支持 JSAPI 的供应商的例子。
在本章的第二部分,我们研究图像处理技术,如面部识别。这个演示包括识别图像中的人脸,使用 OpenCV(http://opencv.org/)很容易完成。
此外,在第十章、、中,我们演示了如何从图像中提取文本,这个过程被称为 OCR 。一个常见的数据科学问题涉及提取和分析图像中嵌入的文本。例如,包含在牌照、路标和方向中的信息可能非常重要。
在下面的例子中,在第 11 章中有更详细的解释,用于数据分析的数学和并行技术使用 Tess4j(http://tess4j.sourceforge.net/)一个用于 Tesseract OCR API 的 Java JNA 包装器来实现 OCR。我们对从维基百科关于 OCR 的文章(https://en . Wikipedia . org/wiki/Optical _ character _ recognition # Applications)中捕获的图像执行 OCR,如下所示:
ITesseract
接口提供了许多 OCR 方法。doOCR
方法获取一个文件并返回一个包含文件中的单词的字符串,如下所示:
ITesseract instance = new Tesseract();
try {
String result = instance.doOCR(new File("OCRExample.png"));
System.out.println(result);
} catch (TesseractException e) {
System.err.println(e.getMessage());
}
下面显示了输出的一部分:
OCR engines nave been developed into many lunds oiobiectorlented OCR applicatlons, sucn as reoeipt OCR, involoe OCR, check OCR, legal billing document OCR
They can be used ior
- Data entry ior business documents, e g check, passport, involoe, bank statement and receipt
- Automatic number plate recognnlon
如您所见,这个示例中有许多错误需要解决。我们在第 11 章数据分析的数学和并行技术中建立了这个例子,讨论了增强功能和注意事项,以确保 OCR 过程尽可能有效。
我们将以 NeurophStudio 的讨论来结束本章,NeurophStudio 是一个基于 Java 的神经网络编辑器,用于对图像进行分类和执行图像识别。我们在这一部分训练一个神经网络来识别和分类人脸。
使用并行技术提高应用性能
在第 11 章、数据分析的数学和并行技术中,我们考虑了一些可用于数据科学应用的并行技术。程序的并发执行可以显著提高性能。与数据科学相关,这些技术包括从低级数学计算到高级 API 特定选项。
本章包括对基本性能增强注意事项的讨论。算法和应用程序架构与增强代码一样重要,当试图集成并行技术时,应该考虑这一点。如果应用程序没有以预期或想要的方式运行,并行优化的任何好处都是无关紧要的。
矩阵运算对于许多数据应用程序和支持 API 是必不可少的。我们将在本章中讨论矩阵乘法,以及如何用各种方法处理它。尽管这些操作通常隐藏在 API 中,但是理解它们是如何被支持的还是很有用的。
我们演示的一种方法利用了 Apache Commons 数学 API(http://commons.apache.org/proper/commons-math/)。这个 API 支持大量的数学和统计操作,包括矩阵乘法。以下示例说明了如何执行矩阵乘法。
我们首先声明并初始化矩阵A
和B
:
double[][] A = {
{0.1950, 0.0311},
{0.3588, 0.2203},
{0.1716, 0.5931},
{0.2105, 0.3242}};
double[][] B = {
{0.0502, 0.9823, 0.9472},
{0.5732, 0.2694, 0.916}};
Apache Commons 使用RealMatrix
类来存储矩阵。接下来,我们使用Array2DRowRealMatrix
构造函数为A
和B
创建相应的矩阵:
RealMatrix aRealMatrix = new Array2DRowRealMatrix(A);
RealMatrix bRealMatrix = new Array2DRowRealMatrix(B);
我们简单地使用multiply
方法执行乘法:
RealMatrix cRealMatrix = aRealMatrix.multiply(bRealMatrix);
最后,我们使用一个for
循环来显示结果:
for (int i = 0; i < cRealMatrix.getRowDimension(); i++) {
System.out.println(cRealMatrix.getRowVector(i));
}
输出如下所示:
{0.02761552; 0.19992684; 0.2131916}
{0.14428772; 0.41179806; 0.54165016}
{0.34857924; 0.32834382; 0.70581912}
{0.19639854; 0.29411363; 0.4963528}
并发处理的另一种方法是使用 Java 线程。当多个 CPU 或 GPU 不可用时,线程由 api(如 Aparapi)使用。
数据科学应用程序通常利用 map-reduce 算法。我们将通过使用 Apache 的 Hadoop 来执行 map-reduce 来演示并行处理。Hadoop 专为大型数据集而设计,可减少大规模数据科学项目的处理时间。我们演示了一种计算大型数据集平均值的技术。
我们还包括支持多处理器的 API 的例子,包括 CUDA 和 OpenCL。使用 Java bindings for CUDA(JCuda)(http://jcuda.org/)来支持 CUDA。我们还将讨论 OpenCL 及其 Java 支持。Aparapi API 为使用多个 CPU 或 GPU 提供了高级支持,并且我们包括了一个 Aparapi 支持矩阵乘法的演示。
组装零件
在本书的最后一章,我们将把前几章探索的许多技术结合在一起。我们将创建一个简单的基于控制台的应用程序,用于从 Twitter 获取数据并执行各种类型的数据操作和分析。我们在本章中的目标是展示一个探索各种数据科学概念的简单项目,并为未来的项目提供见解和考虑。
具体来说,在最后一章中开发的应用程序执行几个高级任务,包括数据采集、数据清理、情感分析和基本的统计数据收集。我们使用 Java 8 流演示这些技术,并尽可能关注 Java 8 方法。
总结
数据科学是一个广泛多样的研究领域,不可能在本书中进行详尽的探讨。我们希望提供对重要数据科学概念的坚实理解,并使读者做好进一步学习的准备。特别是,这本书将为数据科学相关调查的所有阶段提供不同技术的具体例子。这包括从数据采集和清理到详细的统计分析。
因此,让我们从讨论数据采集和 Java 如何支持它开始,如下一章所示。
二、数据采集
使用格式不正确的代码或使用的变量名不能表达其预期目的的代码从来都不是什么有趣的事情。数据也是如此,只是坏数据会导致不准确的结果。因此,数据采集是数据分析中的一个重要步骤。数据可以从许多来源获得,但是在它有用之前必须进行检索和最终处理。它可以从各种来源获得。我们可以在众多的公共数据源中找到简单的文件,也可以在互联网上找到更复杂的形式。在这一章中,我们将演示如何从这些网站中获取数据,包括各种互联网网站和一些社交媒体网站。
我们可以通过下载特定的文件或者通过一个叫做网络抓取的过程从互联网上获取数据,这个过程包括提取网页的内容。我们还探索了一个被称为网络爬行的相关主题,它涉及到应用程序检查一个网站以确定它是否是感兴趣的,然后跟随嵌入的链接以识别其他潜在的相关页面。
我们也可以从社交媒体网站提取数据。如果我们知道如何访问,这些类型的网站通常拥有现成的数据宝库。在本章中,我们将演示如何从几个站点提取数据,包括:
- 推特
- 维基百科(一个基于 wiki 技术的多语言的百科全书协作计划ˌ也是一部用不同语言写成的网络百科全书ˌ 其目标及宗旨是为全人类提供自由的百科全书)ˌ开放性的百科全书
- 闪烁(光)
- 油管(国外视频网站)
从站点提取数据时,可能会遇到许多不同的数据格式。我们将研究三种基本类型:文本、音频和视频。然而,即使在文本、音频和视频数据中,也存在许多格式。单就音频数据来说,在https://en . Wikipedia . org/wiki/Comparison _ of _ audio _ coding _ formats就比较了 45 种音频编码格式。对于文本数据,在 http://fileinfo.com/filetypes/text 的列出了将近 300 种格式。在这一章中,我们将着重于如何下载和提取这些类型的文本作为纯文本,以供最终处理。
我们将简要分析不同的数据格式,然后分析可能的数据源。我们需要这些知识来演示如何使用不同的数据采集技术获取数据。
了解数据科学应用中使用的数据格式
当我们讨论数据格式时,我们指的是内容格式,而不是底层的文件格式,后者对大多数开发人员来说可能是不可见的。由于可用的格式数量巨大,我们无法检查所有可用的格式。相反,我们将处理几种更常见的格式,提供足够的例子来解决最常见的数据检索需求。具体来说,我们将演示如何检索以下列格式存储的数据:
- 超文本标记语言
- 便携文档格式
- CSV/TSV
- 电子表格
- 数据库
- JSON
- 可扩展标记语言
其中一些格式在其他地方得到了很好的支持和记录。例如,XML 已经使用了很多年,并且有几种成熟的技术可以在 Java 中访问 XML 数据。对于这些类型的数据,我们将概述可用的主要技术,并展示一些示例来说明它们是如何工作的。这将为那些不熟悉该技术的读者提供一些对其本质的洞察。
最常见的数据格式是二进制文件。例如,Word、Excel 和 PDF 文档都是以二进制存储的。这些需要特殊的软件来从中提取信息。文本数据也很常见。
CSV 数据概述
逗号分隔值 ( CSV )文件,包含以行列格式组织的表格数据。以明文形式存储的数据按行存储,也称为记录。每条记录都包含用逗号分隔的字段。这些文件也与其他分隔文件密切相关,最显著的是制表符分隔值 ( TSV )文件。以下是一个简单 CSV 文件的一部分,这些数字并不代表任何特定类型的数据:
JURISDICTION NAME,COUNT PARTICIPANTS,COUNT FEMALE,PERCENT FEMALE
10001,44,22,0.5
10002,35,19,0.54
10003,1,1,1
请注意,第一行包含描述后续记录的标题数据。每个值由逗号分隔,并对应于相同位置的标题。在第 3 章、数据清理中,我们将更深入地讨论 CSV 文件,并检查对不同类型分隔符的支持。
电子表格概述
电子表格是表格数据的一种形式,其中信息存储在行和列中,很像二维数组。它们通常包含数字和文本信息,并使用公式来总结和分析其内容。大多数人都熟悉 Excel 电子表格,但它们也是其他产品套件的一部分,如 OpenOffice。
电子表格是一种重要的数据源,因为在过去的几十年中,它们被用于在许多行业和应用中存储信息。它们的表格性质使它们易于处理和分析。了解如何从这个无处不在的数据源中提取数据非常重要,这样我们就可以利用存储在数据源中的大量信息。
对于我们的一些示例,我们将使用一个简单的 Excel 电子表格,它由一系列包含 ID 的行以及最小值、最大值和平均值组成。这些数字并不代表任何特定类型的数据。电子表格如下所示:
| ID | 最小值 | 最大值 | 平均值 |
| 12345
| 45
| 89
| 65.55
|
| 23456
| 78
| 96
| 86.75
|
| 34567
| 56
| 89
| 67.44
|
| 45678
| 86
| 99
| 95.67
|
在第 3 章、数据清理中,我们将学习如何从电子表格中提取数据。
数据库概述
数据可以在数据库管理系统 ( DBMS )中找到,这些系统和电子表格一样,无处不在。Java 为访问和处理 DBMS 中的数据提供了丰富的选项。本节的目的是提供使用 Java 访问数据库的基本介绍。
我们将演示使用 JDBC 连接到数据库、存储信息和检索信息的本质。对于这个例子,我们使用 MySQL 数据库管理系统。但是,它也适用于其他数据库管理系统,只需更改数据库驱动程序。我们在 MySQL 工作台中使用以下命令创建了一个名为example
的数据库和一个名为URLTABLE
的表。还有其他工具可以达到同样的效果:
CREATE TABLE IF NOT EXISTS `URLTABLE` (
`RecordID` INT(11) NOT NULL AUTO_INCREMENT,
`URL` text NOT NULL,
PRIMARY KEY (`RecordID`)
);
我们从处理异常的try
块开始。连接到 DBMS 需要一个驱动程序。在这个例子中,我们使用了com.mysql.jdbc.Driver
。为了连接到数据库,使用了getConnection
方法,传递数据库服务器位置、用户 ID 和密码。这些值取决于所使用的 DBMS:
try {
Class.forName("com.mysql.jdbc.Driver");
String url = "jdbc:mysql://localhost:3306/example";
connection = DriverManager.getConnection(url, "user ID",
"password");
...
} catch (SQLException | ClassNotFoundException ex) {
// Handle exceptions
}
接下来,我们将说明如何向数据库添加信息,然后如何读取它。SQL INSERT
命令是在字符串中构造的。MySQL 数据库的名字是example
。该命令将在数据库的URLTABLE
表中插入值,其中问号是要插入值的占位符:
String insertSQL = "INSERT INTO `example`.`URLTABLE` "
+ "(`url`) VALUES " + "(?);";
PreparedStatement
类表示要执行的 SQL 语句。prepareStatement
方法使用INSERT
SQL 语句创建该类的一个实例:
PreparedStatement stmt = connection.prepareStatement(insertSQL);
然后,我们使用setString
方法和execute
方法向表中添加 URL。setString
方法有两个论点。第一个指定要插入数据的列索引,第二个是要插入的值。execute
方法执行实际的插入。我们在下一个序列中添加两个 URL:
stmt.setString(1, "https://en.wikipedia.org/wiki/Data_science");
stmt.execute();
stmt.setString(1,
"https://en.wikipedia.org/wiki/Bishop_Rock,_Isles_of_Scilly");
stmt.execute();
为了读取数据,我们使用在selectSQL
字符串中声明的 SQL SELECT
语句。这将从URLTABLE
表中返回所有的行和列。createStatement
方法创建了一个Statement
类的实例,用于INSERT
类型的语句。executeQuery
方法执行查询并返回保存表内容的ResultSet
实例:
String selectSQL = "select * from URLTABLE";
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(selectSQL);
下面的序列遍历表,一次显示一行。getString
方法的参数指定我们想要使用结果集的第二列,它对应于 URL 字段:
out.println("List of URLs");
while (resultSet.next()) {
out.println(resultSet.getString(2));
}
该示例在执行时的输出如下:
List of URLs
https://en.wikipedia.org/wiki/Data_science
https://en.wikipedia.org/wiki/Bishop_Rock,_Isles_of_Scilly
如果需要清空表中的内容,请使用以下顺序:
Statement statement = connection.createStatement();
statement.execute("TRUNCATE URLTABLE;");
这是对使用 Java 访问数据库的简单介绍。有许多资源可以提供关于这个主题的更深入的报道。例如,甲骨文在https://docs.oracle.com/javase/tutorial/jdbc/提供了关于这个主题的更深入的介绍。
PDF 文件概述
可移植文档格式 ( PDF )是一种不依赖于特定平台或软件应用的格式。PDF 文档可以保存格式化的文本和图像。PDF 是一种开放标准,使它在许多地方都很有用。
有大量的文档存储为 PDF 格式,这使得它成为一个有价值的数据来源。有几个 Java APIs 允许访问 PDF 文档,包括 Apache POI 和 PDFBox 。从 PDF 文档中提取信息的技术在第 3 章、数据清理中有说明。
JSON 概述
JavaScript 对象符号(JSON)(http://www.JSON.org/)是一种用于交换数据的数据格式。对于人类或机器来说,读和写很容易。JSON 受到许多语言的支持,包括 Java,Java 有几个 JSON 库列在http://www.JSON.org/上。
JSON 实体由一组用花括号括起来的名称-值对组成。我们将在几个例子中使用这种格式。在处理 YouTube 时,我们将使用一个 JSON 对象,下面显示了其中的一部分,它代表了一个 YouTube 视频请求的结果:
{
"kind": "youtube#searchResult",
"etag": "etag",
"id": {
"kind": string,
"videoId": string,
"channelId": string,
"playlistId": string
},
...
}
访问这样一个文档的字段和值并不难,在第 3 章、数据清理中有说明。
XML 概述
可扩展标记语言 ( XML )是一种指定标准文档格式的标记语言。XML 广泛用于应用程序之间和互联网上的通信,由于它相对简单和灵活,所以很受欢迎。用 XML 编码的文档是基于字符的,很容易被机器和人阅读。
XML 文档包含标记和内容字符。这些字符允许解析器对文档中包含的信息进行分类。文档由标签组成,元素存储在标签中。元素还可以包含其他标记标签并形成子元素。此外,元素可能包含存储为名称-值对的属性或特定特征。
XML 文档必须是格式良好的。这意味着它必须遵循一定的规则,比如总是使用结束标签并且只使用一个根标签。其他规则在https://en . Wikipedia . org/wiki/XML # Well-formed ness _ and _ error-handling讨论。
用于 XML 处理的 Java API 由三个用于解析 XML 数据的接口组成。文档对象模型** ( DOM )接口解析 XML 文档并返回描述文档结构的树形结构。DOM 接口将整个文档作为一个整体来解析。或者,XML 的简单 API(SAX)一次解析一个文档元素。当内存使用是个问题时,SAX 是更好的选择,因为 DOM 需要更多的资源来构建树。然而,DOM 比 SAX 更灵活,因为任何元素都可以在任何时间以任何顺序访问。**
第三个 Java API 被称为 XML 的流 API(StAX)。这种流模型旨在通过在不牺牲资源的情况下提供灵活性来容纳 DOM 和 SAX 模型的最佳部分。StAX 表现出更高的性能,代价是一次只能访问文档中的一个位置。如果您已经知道如何处理文档,StAX 是首选技术,但是对于可用内存有限的应用程序,它也很受欢迎。
下面是一个简单的 XML 文件。每个<text>
代表一个标签,标记标签中包含的元素。在这种情况下,我们文件中最大的节点是<music>
,其中包含歌曲数据集。一个<song>
标签中的每个标签描述了另一个对应于那首歌的元素。每个标签最终都会有一个结束标签,比如</song>
。请注意,第一个标记包含应该使用哪个 XML 版本来解析文件的信息:
<?xml version="1.0"?>
<music>
<song id="1234">
<artist>Patton, Courtney</artist>
<name>So This Is Life</name>
<genre>Country</genre>
<price>2.99</price>
</song>
<song id="5678">
<artist>Eady, Jason</artist>
<name>AM Country Heaven</name>
<genre>Country</genre>
<price>2.99</price>
</song>
</music>
还有许多其他与 XML 相关的技术。例如,我们可以使用 DTD 文档或专门为 XML 文档编写的 XML 模式来验证特定的 XML 文档。使用 XLST 可以将 XML 文档转换成不同的格式。
流数据概述
流数据指的是以连续流的形式生成并以顺序、逐段的方式访问的数据。普通互联网用户访问的大部分数据都是流媒体,包括视频和音频频道,或者社交媒体网站上的文本和图像数据。当数据是新的且变化很快时,或者当需要收集大量数据时,流式数据是首选方法。
流式数据通常是数据科学研究的理想选择,因为它通常以大量的原始格式存在。许多公共流数据都是免费的,并得到 Java APIs 的支持。在这一章中,我们将研究如何从流媒体来源获取数据,包括 Twitter、Flickr 和 YouTube。尽管使用了不同的技术和 API,但是您会注意到从这些站点获取数据的技术之间的相似之处。
Java 中的音频/视频/图像概述
有大量的格式用于表示图像、视频和音频。这种类型的数据通常以二进制格式存储。模拟音频流被采样和数字化。图像通常只是代表像素颜色的位的集合。以下链接提供了对其中一些格式的更深入的讨论:
- 音频:https://en.wikipedia.org/wiki/Audio_file_format
- 图片:https://en.wikipedia.org/wiki/Image_file_formats
- 视频:https://en.wikipedia.org/wiki/Video_file_format
通常,这种类型的数据可能非常大,必须进行压缩。当数据被压缩时,使用两种方法。第一种是无损压缩,使用更少的空间并且没有信息损失。第二种是有损,即信息丢失。丢失信息并不总是一件坏事,因为有时这种丢失对人类来说并不明显。
正如我们将在第 3 章、数据清理中演示的那样,这种类型的数据通常会以一种不方便的方式遭到破坏,可能需要清理。例如,录音中可能有背景噪声,或者图像可能需要在处理之前进行平滑处理。图像平滑在第 3 章、数据清理中演示,使用 OpenCV 库。
**# 数据采集技术
在这一节中,我们将说明如何从网页中获取数据。网页包含大量潜在的有用信息。我们将演示如何使用几种技术访问 web 页面,从由HttpUrlConnection
类支持的低级方法开始。为了找到页面,经常使用网络爬虫应用程序。一旦确定了有用的页面,就需要从页面中提取信息。这通常使用 HTML 解析器来执行。提取这些信息非常重要,因为它们通常隐藏在杂乱的 HTML 标记和 JavaScript 代码中。
使用 HttpUrlConnection 类
可以使用HttpUrlConnection
类访问网页的内容。这是一种低级的方法,需要开发人员做大量的工作来提取相关的内容。然而,他或她能够对如何处理内容进行更大的控制。在某些情况下,这种方法可能比使用其他 API 库更好。
我们将演示如何使用这个类下载维基百科数据科学页面的内容。我们从一个try
/ catch
块开始处理异常。URL 对象是使用数据科学 URL 字符串创建的。openConnection
方法将创建一个到维基百科服务器的连接,如下所示:
try {
URL url = new URL(
"https://en.wikipedia.org/wiki/Data_science");
HttpURLConnection connection = (HttpURLConnection)
url.openConnection();
...
} catch (MalformedURLException ex) {
// Handle exceptions
} catch (IOException ex) {
// Handle exceptions
}
用 HTTP GET
命令初始化connection
对象。然后执行connect
方法来连接服务器:
connection.setRequestMethod("GET");
connection.connect();
假设没有遇到错误,我们可以使用getResponseCode
方法确定响应是否成功。一个正常返回值是200
。网页的内容可能会有所不同。例如,getContentType
方法返回一个描述页面内容的字符串。getContentLength
方法返回它的长度:
out.println("Response Code: " + connection.getResponseCode());
out.println("Content Type: " + connection.getContentType());
out.println("Content Length: " + connection.getContentLength());
假设我们得到了一个 HTML 格式的页面,下一个序列说明了如何得到这个内容。创建一个BufferedReader
实例,从 web 站点一次读入一行并附加到一个BufferedReader
实例。然后显示缓冲区:
InputStreamReader isr = new InputStreamReader((InputStream)
connection.getContent());
BufferedReader br = new BufferedReader(isr);
StringBuilder buffer = new StringBuilder();
String line;
do {
line = br.readLine();
buffer.append(line + "\n");
} while (line != null);
out.println(buffer.toString());
这里显示了简短的输出:
Response Code: 200
Content Type: text/html; charset=UTF-8
Content Length: -1
<!DOCTYPE html>
<html lang="en" dir="ltr" class="client-nojs">
<head>
<meta charset="UTF-8"/>
<script>document.documentElement.className =
...
"wgHostname":"mw1251"});});</script>
</body>
</html>
虽然这是可行的,但是有更简单的方法来获取网页的内容。下一节将讨论其中一种技术。
Java 中的网络爬虫
Web 爬行是遍历一系列相互连接的网页并从这些网页中提取相关信息的过程。它通过隔离然后跟随页面上的链接来做到这一点。虽然有许多现成的预编译数据集,但仍有必要直接从互联网上收集数据。一些来源,如新闻网站,不断更新,需要不时地重新访问。
网络爬虫是访问各种站点并收集信息的应用程序。web 爬行过程由一系列步骤组成:
- 选择要访问的 URL
- 获取页面
- 解析页面
- 提取相关内容
- 提取相关网址进行访问
对每个访问的 URL 重复这个过程。
在获取和解析页面时,需要考虑几个问题,例如:
- 页面重要性:我们不想处理不相关的页面。
- 例如,我们通常不会关注图片的链接。
- 蜘蛛陷阱(Spider traps):我们希望绕过那些可能导致无限请求的网站。在动态生成的页面中,一个请求导致另一个请求,就会发生这种情况。
- 重复:避免多次抓取同一页面很重要。
- 礼貌:不要向网站发出过多的请求。观察
robot.txt
文件;它们指定网站的哪些部分不应该被爬网。
创建一个网络爬虫的过程可能是令人生畏的。对于除了最简单的需求之外的所有需求,建议使用几种开源网络爬虫中的一种。部分列表如下:
- 纳特:【http://nutch.apache.org】T2
- 爬虫军:【https://github.com/yasserg/crawler4j】T2
- JSpider:【http://j-spider.sourceforge.net/】T2
- WebSPHINX:【http://www.cs.cmu.edu/~rcm/websphinx/
- 赫莉特里克斯:【https://webarchive.jira.com/wiki/display/Heritrix】T2
我们既可以创建自己的网络爬虫,也可以使用现有的爬虫。在本章中,我们将研究这两种方法。对于专门的处理,最好使用定制的爬行器。我们将演示如何用 Java 创建一个简单的网络爬虫,以便更深入地了解网络爬虫是如何工作的。接下来是对其他网络爬虫的简单讨论。
创建自己的网络爬虫
现在我们对网络爬虫有了基本的了解,我们准备创建自己的爬虫。在这个简单的网络爬虫中,我们将使用ArrayList
实例跟踪被访问的页面。此外,jsoup 将用于解析网页,我们将限制我们访问的页面数量。jsoup(https://jsoup.org/)是一个开源的 HTML 解析器。这个例子演示了一个网络爬虫的基本结构,也强调了创建一个网络爬虫所涉及的一些问题。
我们将使用SimpleWebCrawler
类,如下所示:
public class SimpleWebCrawler {
private String topic;
private String startingURL;
private String urlLimiter;
private final int pageLimit = 20;
private ArrayList<String> visitedList = new ArrayList<>();
private ArrayList<String> pageList = new ArrayList<>();
...
public static void main(String[] args) {
new SimpleWebCrawler();
}
}
实例变量的详细信息如下:
| 变量 | 使用 |
| topic
| 要使页面被接受,页面中需要包含的关键字 |
| startingURL
| 第一页的 URL |
| urlLimiter
| 必须包含在链接中才能被跟踪的字符串 |
| pageLimit
| 要检索的最大页数 |
| visitedList
| 包含已经访问过的页面的ArrayList
|
| pageList
| 一个包含感兴趣页面的 URL 的ArrayList
|
在SimpleWebCrawler
构造函数中,我们初始化实例变量,从维基百科页面开始搜索意大利海岸附近的 Bishop Rock 岛。这是为了最大限度地减少可能检索到的页面数量。正如我们将会看到的,维基百科中关于主教洛克的页面比我们想象的要多得多。
urlLimiter
变量被设置为Bishop_Rock
,这将限制嵌入的链接只跟随那些包含该字符串的链接。每个感兴趣的页面必须包含存储在topic
变量中的值。visitPage
方法执行实际的爬行:
public SimpleWebCrawler() {
startingURL = https://en.wikipedia.org/wiki/Bishop_Rock, "
+ "Isles_of_Scilly";
urlLimiter = "Bishop_Rock";
topic = "shipping route";
visitPage(startingURL);
}
在visitPage
方法中,检查pageList
ArrayList 以查看是否超过了接受页面的最大数量。如果超出了限制,则搜索终止:
public void visitPage(String url) {
if (pageList.size() >= pageLimit) {
return;
}
...
}
如果页面已经被访问过,那么我们忽略它。否则,它将被添加到已访问列表中:
if (visitedList.contains(url)) {
// URL already visited
} else {
visitedList.add(url);
...
}
Jsoup
用于解析页面并返回一个Document
对象。可能会出现许多不同的异常和问题,例如格式错误的 URL、检索超时或简单的坏链接。catch
区块需要处理这些类型的问题。我们将在 Java 的 web 抓取中对 jsoup 进行更深入的解释:
try {
Document doc = Jsoup.connect(url).get();
...
}
} catch (Exception ex) {
// Handle exceptions
}
如果文档包含主题文本,则显示链接并添加到pageList
数组列表中。获取每个嵌入的链接,如果链接包含限制文本,那么递归调用visitPage
方法:
if (doc.text().contains(topic)) {
out.println((pageList.size() + 1) + ": [" + url + "]");
pageList.add(url);
// Process page links
Elements questions = doc.select("a[href]");
for (Element link : questions) {
if (link.attr("href").contains(urlLimiter)) {
visitPage(link.attr("abs:href"));
}
}
}
这种方法只检查那些包含主题文本的页面中的链接。将for
循环移出 if 语句将测试所有页面的链接。
输出如下:
1: [https://en.wikipedia.org/wiki/Bishop_Rock,_Isles_of_Scilly]
2: [https://en.wikipedia.org/wiki/Bishop_Rock_Lighthouse]
3: [https://en.wikipedia.org/w/index.php?title=Bishop_Rock,_Isles_of_Scilly&oldid=717634231#Lighthouse]
4: [https://en.wikipedia.org/w/index.php?title=Bishop_Rock,_Isles_of_Scilly&diff=prev&oldid=717634231]
5: [https://en.wikipedia.org/w/index.php?title=Bishop_Rock,_Isles_of_Scilly&oldid=716622943]
6: [https://en.wikipedia.org/w/index.php?title=Bishop_Rock,_Isles_of_Scilly&diff=prev&oldid=716622943]
7: [https://en.wikipedia.org/w/index.php?title=Bishop_Rock,_Isles_of_Scilly&oldid=716608512]
8: [https://en.wikipedia.org/w/index.php?title=Bishop_Rock,_Isles_of_Scilly&diff=prev&oldid=716608512]
...
20: [https://en.wikipedia.org/w/index.php?title=Bishop_Rock,_Isles_of_Scilly&diff=prev&oldid=716603919]
在本例中,我们没有将爬网结果保存在外部源中。通常这是必要的,可以存储在文件或数据库中。
使用 crawler4j 网络爬虫
这里我们将举例说明爬虫 4j(【https://github.com/yasserg/crawler4j】)网络爬虫的使用。我们将使用基本爬虫的改编版本,该版本位于https://github . com/yasserg/crawler 4j/tree/master/src/test/Java/edu/UCI/ics/crawler 4j/examples/basic。我们将创建两个类:CrawlerController
和SampleCrawler
。前一个类设置爬虫,而后一个类包含控制将处理哪些页面的逻辑。
和我们之前的爬虫一样,我们将抓取维基百科中关于主教岩的文章。使用这个爬虫的结果会更小,因为许多无关的页面会被忽略。
我们先来看一下CrawlerController
类。crawler 使用了几个参数,详情如下:
- 抓取存储文件夹:抓取数据存储的位置
- 爬虫数量:控制用于爬行的线程数量
- 礼貌延迟:请求之间暂停多少秒
- 爬行深度:爬行的深度
- 要读取的最大页数:要读取多少页
- 二进制数据:是否抓取 PDF 文件等二进制数据
这里显示了基本类:
public class CrawlerController {
public static void main(String[] args) throws Exception {
int numberOfCrawlers = 2;
CrawlConfig config = new CrawlConfig();
String crawlStorageFolder = "data";
config.setCrawlStorageFolder(crawlStorageFolder);
config.setPolitenessDelay(500);
config.setMaxDepthOfCrawling(2);
config.setMaxPagesToFetch(20);
config.setIncludeBinaryContentInCrawling(false);
...
}
}
接下来,创建并配置CrawlController
类。注意用于处理robot.txt
文件的RobotstxtConfig
和RobotstxtServer
类。这些文件包含供网络爬虫阅读的指令。它们提供了帮助爬虫做得更好的方向,例如指定站点的哪些部分不应该被爬行。这对自动生成的页面很有用:
PageFetcher pageFetcher = new PageFetcher(config);
RobotstxtConfig robotstxtConfig = new RobotstxtConfig();
RobotstxtServer robotstxtServer =
new RobotstxtServer(robotstxtConfig, pageFetcher);
CrawlController controller =
new CrawlController(config, pageFetcher, robotstxtServer);
爬行器需要从一个或多个页面开始。addSeed
方法添加开始页面。虽然我们在这里只使用了一次该方法,但它可以根据需要多次使用:
controller.addSeed(
"https://en.wikipedia.org/wiki/Bishop_Rock,_Isles_of_Scilly");
start
方法将开始爬行过程:
controller.start(SampleCrawler.class, numberOfCrawlers);
SampleCrawler
类包含两个有趣的方法。第一个是确定页面是否被访问的shouldVisit
方法和实际处理页面的visit
方法。我们从类声明和 Java 正则表达式类Pattern
对象的声明开始。这将是确定一个页面是否会被访问的一种方式。在此声明中,指定了标准图像,并将忽略这些图像:
public class SampleCrawler extends WebCrawler {
private static final Pattern IMAGE_EXTENSIONS =
Pattern.compile(".*\\.(bmp|gif|jpg|png)$");
...
}
向shouldVisit
方法传递了一个对找到该 URL 的页面的引用。如果任何图像匹配,该方法返回false
并且页面被忽略。另外,网址必须以 https://en.wikipedia.org/wiki/的开头。我们添加此内容是为了将我们的搜索限制在维基百科网站:
public boolean shouldVisit(Page referringPage, WebURL url) {
String href = url.getURL().toLowerCase();
if (IMAGE_EXTENSIONS.matcher(href).matches()) {
return false;
}
return href.startsWith("https://en.wikipedia.org/wiki/");
}
向visit
方法传递一个表示被访问页面的Page
对象。在这个实现中,只有那些包含字符串shipping route
的页面才会被处理。这进一步限制了访问的页面。当我们找到这样一个页面时,它的URL
、Text
和Text length
会显示出来:
public void visit(Page page) {
String url = page.getWebURL().getURL();
if (page.getParseData() instanceof HtmlParseData) {
HtmlParseData htmlParseData =
(HtmlParseData) page.getParseData();
String text = htmlParseData.getText();
if (text.contains("shipping route")) {
out.println("\nURL: " + url);
out.println("Text: " + text);
out.println("Text length: " + text.length());
}
}
}
以下是该程序执行时的截断输出:
URL: https://en.wikipedia.org/wiki/Bishop_Rock,_Isles_of_Scilly
Text: Bishop Rock, Isles of Scilly...From Wikipedia, the free encyclopedia ... Jump to: ... navigation, search For the Bishop Rock in the Pacific Ocean, see Cortes Bank. Bishop Rock Bishop Rock Lighthouse (2005)
...
Text length: 14677
请注意,只返回了一页。该网络爬虫能够识别并忽略主网页的先前版本。
我们可以执行进一步的处理,但是这个例子提供了一些关于 API 如何工作的见解。访问一个页面可以获得大量的信息。在这个例子中,我们只使用了 URL 和文本的长度。以下是您可能有兴趣获取的其他数据的示例:
- path
- 父 URL
- 锚
- HTML 文本
- 传出链接
- 文档 ID
Java 中的网页抓取
网页抓取是从网页中提取信息的过程。该页面通常使用一系列 HTML 标记进行格式化。HTML 解析器用于浏览一个页面或一系列页面,并访问页面的数据或元数据。
jsoup(https://jsoup.org/)是一个开源的 Java 库,它使用 HTML 解析器来帮助提取和操作 HTML 文档。它有许多用途,包括 web 抓取、从 HTML 页面中提取特定元素以及清理 HTML 文档。
有几种方法可以获得有用的 HTML 文档。HTML 文档可以从以下位置提取:
- 统一资源定位器
- 线
- 文件
第一种方法在下面举例说明,其中数据科学的维基百科页面被加载到一个Document
对象中。这个Jsoup
对象代表 HTML 文档。connect
方法连接到站点,get
方法检索document
:
try {
Document document = Jsoup.connect(
"https://en.wikipedia.org/wiki/Data_science").get();
...
} catch (IOException ex) {
// Handle exception
}
从文件加载使用如下所示的File
类。重载的parse
方法使用文件来创建document
对象:
try {
File file = new File("Example.html");
Document document = Jsoup.parse(file, "UTF-8", "");
...
} catch (IOException ex) {
// Handle exception
}
Example.html
文件如下:
<html>
<head>
<body>
<p>The body of the document</p>
Interesting Links:
<br>
<a href="https://en.wikipedia.org/wiki/Data_science">Data Science</a>
<br>
<a href="https://en.wikipedia.org/wiki/Jsoup">Jsoup</a>
<br>
Images:
<br>
<img src="eyechart.jpg" alt="Eye Chart">
</body>
</html>
为了从一个字符串创建一个Document
对象,我们将使用下面的序列,其中parse
方法处理复制前面 HTML 文件的字符串:
String html = "<html>\n"
+ "<head>
+ "<body>\n"
+ "<p>The body of the document</p>\n"
+ "Interesting Links:\n"
+ "<br>\n"
+ "<a href="https://en.wikipedia.org/wiki/Data_science">" +
"DataScience</a>\n"
+ "<br>\n"
+ "<a href="https://en.wikipedia.org/wiki/Jsoup">" +
"Jsoup</a>\n"
+ "<br>\n"
+ "Images:\n"
+ "<br>\n"
+ " <img src="eyechart.jpg" alt="Eye Chart"> \n"
+ "</body>\n"
+ "</html>";
Document document = Jsoup.parse(html);
Document
类拥有许多有用的方法。title
方法返回标题。为了获取文档的文本内容,使用了select
方法。此方法使用指定要检索的文档元素的字符串:
String title = document.title();
out.println("Title: " + title);
Elements element = document.select("body");
out.println(" Text: " + element.text());
这里显示了 Wikipedia 数据科学页面的输出。为了节省空间,它被缩短了:
Title: Data science - Wikipedia, the free encyclopedia
Text: Data science From Wikipedia, the free encyclopedia Jump to: navigation, search Not to be confused with information science. Part of a
...
policy About Wikipedia Disclaimers Contact Wikipedia Developers Cookie statement Mobile view
select
方法的参数类型是字符串。通过使用字符串,选择的信息类型很容易改变。在 https://jsoup.org/apidocs/的Selector
类的 jsoup Javadocs 中可以找到关于如何构造这个字符串的细节:
我们可以使用select
方法来检索文档中的图像,如下所示:
Elements images = document.select("img[src$=.png]");
for (Element image : images) {
out.println("\nImage: " + image);
}
这里显示了 Wikipedia 数据科学页面的输出。为了节省空间,它被缩短了:
Image: <img alt="Data Visualization" src="//upload.wikimedia.org/...>
Image: <img alt="" src="//upload.wikimedia.org/wikipedia/commons/thumb/b/ba/...>
如下所示,可以轻松检索链接:
Elements links = document.select("a[href]");
for (Element link : links) {
out.println("Link: " + link.attr("href")
+ " Text: " + link.text());
}
这里显示了Example.html
页面的输出:
Link: https://en.wikipedia.org/wiki/Data_science Text: Data Science
Link: https://en.wikipedia.org/wiki/Jsoup Text: Jsoup
jsoup 拥有许多附加功能。然而,这个例子演示了网页抓取过程。还有其他可用的 Java HTML 解析器。在https://en.wikipedia.org/wiki/Comparison_of_HTML_parsers可以找到 Java HTML 解析器的比较。
使用 API 调用访问常见的社交媒体网站
社交媒体包含大量可以处理的信息,并被许多数据分析应用程序使用。在这一节中,我们将说明如何使用 Java APIs 访问其中的一些资源。它们中的大多数需要某种访问密钥,这通常很容易获得。我们从讨论OAuth
类开始,它提供了一种认证访问数据源的方法。
当使用这种类型的数据源时,请记住数据并不总是公开的,这一点很重要。虽然数据可能是可访问的,但数据的所有者可能是不一定希望信息被共享的个人。大多数 API 都提供了一种方法来决定如何分配数据,这些请求应该被接受。当使用私人信息时,必须获得作者的许可。
此外,这些网站对可以发出的请求数量有限制。从一个站点提取数据时,请记住这一点。如果需要超出这些限制,那么大多数网站都提供了一种方法。
使用 OAuth 认证用户
OAuth 是一种开放标准,用于对许多不同网站的用户进行身份验证。资源所有者有效地委托对服务器资源的访问,而不必共享他们的凭证。它适用于 HTTPS。OAuth 2.0 继承了 OAuth,并且不向后兼容。它为客户端开发人员提供了一种简单的身份验证方式。一些公司使用 OAuth 2.0,包括 PayPal、Comcast 和暴雪娱乐。
OAuth 2.0 提供商列表可在 https://en.wikipedia.org/wiki/List_of_OAuth_providers找到。我们将在讨论中使用其中的几个。
递推特
庞大的数据量和该网站在名人和公众中的受欢迎程度,使 Twitter 成为挖掘社交媒体数据的宝贵资源。Twitter 是一个流行的社交媒体平台,允许用户阅读和发布名为 tweets 的短信。Twitter 为发布和拉取推文提供 API 支持,包括来自所有公共用户的流数据。虽然有一些服务可用于提取整个公共 tweet 数据集,但我们将研究其他选项,这些选项虽然限制了一次检索的数据量,但却是免费的。
我们将关注用于检索流数据的 Twitter API。从特定用户那里检索推文以及向特定账户发布数据还有其他选择,但我们不会在本章中讨论这些。默认访问级别的公共流 API 允许用户提取当前在 Twitter 上流动的公共 tweets 的样本。通过指定参数来跟踪关键字、特定用户和位置,可以细化数据。
对于这个例子,我们将使用 Java HTTP 客户端 HBC。你可以在 https://github.com/twitter/hbc 下载一个示例 HBC 应用程序。如果您喜欢使用不同的 HTTP 客户端,请确保它将返回增量响应数据。Apache HTTP 客户机是一种选择。在创建 HTTP 连接之前,您必须首先创建一个 Twitter 帐户和该帐户中的一个应用程序。若要开始使用该应用程序,请访问 apps.twitter.com。创建应用程序后,将为您分配一个消费者密钥、消费者密码、访问令牌和访问密码令牌。我们也将使用 OAuth,正如本章前面所讨论的。
首先,我们将编写一个方法来执行身份验证并从 Twitter 请求数据。我们方法的参数是创建应用程序时 Twitter 给我们的认证信息。我们将创建一个BlockingQueue
对象来保存我们的流数据。在本例中,我们将默认容量设置为 10,000。我们还将指定端点并关闭停止警告:
public static void streamTwitter(
String consumerKey, String consumerSecret,
String accessToken, String accessSecret)
throws InterruptedException {
BlockingQueue<String> statusQueue =
new LinkedBlockingQueue<String>(10000);
StatusesSampleEndpoint ending =
new StatusesSampleEndpoint();
ending.stallWarnings(false);
...
}
接下来,我们使用OAuth1
创建一个Authentication
对象,它是OAuth
类的变体。然后,我们可以构建我们的连接客户端并完成 HTTP 连接:
Authentication twitterAuth = new OAuth1(consumerKey,
consumerSecret, accessToken, accessSecret);
BasicClient twitterClient = new ClientBuilder()
.name("Twitter client")
.hosts(Constants.STREAM_HOST)
.endpoint(ending)
.authentication(twitterAuth)
.processor(new StringDelimitedProcessor(statusQueue))
.build();
twitterClient.connect();
出于这个例子的目的,我们将简单地读取从流中接收到的消息,并将它们打印到屏幕上。消息以 JSON 格式返回,如何在实际应用程序中处理它们将取决于该应用程序的目的和限制:
for (int msgRead = 0; msgRead < 1000; msgRead++) {
if (twitterClient.isDone()) {
out.println(twitterClient.getExitEvent().getMessage());
break;
}
String msg = statusQueue.poll(10, TimeUnit.SECONDS);
if (msg == null) {
out.println("Waited 10 seconds - no message received");
} else {
out.println(msg);
}
}
twitterClient.stop();
为了执行我们的方法,我们只需将我们的认证信息传递给streamTwitter
方法。为了安全起见,我们在这里更换了我们的私人钥匙。应该始终保护身份验证信息:
public static void main(String[] args) {
try {
SampleStreamExample.streamTwitter(
myKey, mySecret, myToken, myAccess);
} catch (InterruptedException e) {
out.println(e);
}
}
下面是使用上面列出的方法检索的截断样本数据。您的数据会因 Twitter 的实时流而异,但应该类似于以下示例:
{"created_at":"Fri May 20 15:47:21 +0000 2016","id":733685552789098496,"id_str":"733685552789098496","text":"bwisit si em bahala sya","source":"\u003ca href="http:\/\/twitter.com" rel="nofollow"\u003eTwitter Web
...
ntions":[],"symbols":[]},"favorited":false,"retweeted":false,"filter_level":"low","lang":"tl","timestamp_ms":"1463759241660"}
Twitter 还支持为一个特定的用户帐户提取所有数据,以及将数据直接发布到一个帐户。REST API 也是可用的,它通过 search API 为特定的查询提供支持。这些也使用 OAuth 标准,并在 JSON 文件中返回数据。
处理维基百科
维基百科(https://www.wikipedia.org/)是文本和图像类型信息的有用来源。这是一个互联网百科全书,拥有超过 250 种语言的 3800 万篇文章(【https://en.wikipedia.org/wiki/Wikipedia】T2)。因此,了解如何以编程方式访问其内容是很有用的。
MediaWiki 是一个开源的 Wiki 应用程序,支持 wiki 类型的站点。它用于支持维基百科和许多其他网站。MediaWiki API(http://www.mediaWiki.org/wiki/API)通过 HTTP 提供对 wiki 数据和元数据的访问。使用该 API 的应用程序可以登录、读取数据以及向站点发布更改。
有几个 Java APIs 支持对维基站点的编程访问,如在https://www.mediawiki.org/wiki/API:Client_code#Java所列。为了演示 Java 对 wiki 的访问,我们将使用 https://bitbucket.org/axelclk/info.bliki.wiki/wiki/Home的 Bliki。它提供了良好的访问,并且易于用于大多数基本操作。
MediaWiki API 很复杂,有许多特性。本节的目的是说明使用这个 API 从维基百科文章中获取文本的基本过程。这里不可能完全涵盖 API。
我们将使用info.bliki.api
和info.bliki.wiki.model
包中的以下类:
Page
:表示检索到的页面User
:代表一个用户WikiModel
:代表维基
Bliki 的 Javadocs 可以在 http://www.javadoc.io/doc/info.bliki.wiki/bliki-core/3.1.0 找到。
以下例子改编自http://www . integrating stuff . com/2012/04/06/hook-into-Wikipedia-using-Java-and-the-mediawiki-API/。这个例子将访问主题为数据科学的英文维基百科页面。我们首先创建一个User
类的实例。三参数构造函数的前两个参数分别是user ID
和password
。在这种情况下,它们是空字符串。这种组合允许我们在不建立账户的情况下阅读页面。第三个参数是 MediaWiki API 页面的 URL:
User user = new User("", "",
"http://en.wikipedia.org/w/api.php");
user.login();
帐户将使我们能够修改文件。queryContent
方法返回在字符串数组中找到的主题的Page
对象列表。每个字符串应该是一个页面的标题。在本例中,我们访问一个页面:
String[] titles = {"Data science"};
List<Page> pageList = user.queryContent(titles);
每个Page
对象包含一个页面的内容。有几种方法可以返回页面的内容。对于每个页面,使用双参数构造函数创建一个WikiModel
实例。第一个参数是图像基本 URL,第二个参数是链接基本 URL。这些 URL 使用名为image
和title
的维基变量,这些变量将在创建链接时被替换:
for (Page page : pageList) {
WikiModel wikiModel = new WikiModel("${image}",
"${title}");
...
}
render
方法将获取 wiki 页面并将其呈现为 HTML。还有一种将页面呈现为 PDF 文档的方法:
String htmlText = wikiModel.render(page.toString());
然后显示 HTML 文本:
out.println(htmlText);
输出的部分列表如下:
<p>PageID: 35458904; NS: 0; Title: Data science;
Image url:
Content:
{{distinguish}}
{{Use dmy dates}}
{{Data Visualization}}</p>
<p><b>Data science</b> is an interdisciplinary field about processes and systems to extract <a href="Knowledge" >knowledge</a>
...
我们还可以使用如下所示的几种方法之一来获取文章的基本信息:
out.println("Title: " + page.getTitle() + "\n" +
"Page ID: " + page.getPageid() + "\n" +
"Timestamp: " + page.getCurrentRevision().getTimestamp());
还可以获得文章中的参考文献列表和标题列表。这里显示了一个参考列表:
List <Reference> referenceList = wikiModel.getReferences();
out.println(referenceList.size());
for(Reference reference : referenceList) {
out.println(reference.getRefString());
}
下面说明了获取节标题的过程:
ITableOfContent toc = wikiModel.getTableOfContent();
List<SectionHeader> sections = toc.getSectionHeaders();
for(SectionHeader sh : sections) {
out.println(sh.getFirst());
}
维基百科的全部内容都可以下载。这个过程将在https://en.wikipedia.org/wiki/Wikipedia:Database_download进行讨论。
建立自己的维基百科服务器来处理你的请求可能是可取的。
处理 Flickr
Flickr(https://www.flickr.com/)是一款在线照片管理和分享应用。它可能是图像和视频的来源。Flickr 开发者指南(【https://www.flickr.com/services/developer/】T2)是了解 Flickr API 的一个很好的起点。
使用 Flickr API 的第一步是请求一个 API 密钥。这个密钥用于签署您的 API 请求。获取密钥的过程从https://www.flickr.com/services/apps/create/开始。商业密钥和非商业密钥都可用。当你获得一个密钥时,你也将获得一个“秘密”这两者都是使用 API 所必需的。
我们将举例说明从 Flickr 定位和下载图像的过程。该流程包括:
- 创建 Flickr 类实例
- 指定查询的搜索参数
- 执行搜索
- 下载图像
在此过程中可能会抛出FlickrException
或IOException
。有几个 API 支持 Flickr 访问。我们将使用位于 https://github.com/callmeal/Flickr4Java 的 Flickr4Java。Flickr4Java Javadocs 可以在 http://flickrj.sourceforge.net/api/找到。我们将从一个try
块以及apikey
和secret
声明开始:
try {
String apikey = "Your API key";
String secret = "Your secret";
} catch (FlickrException | IOException ex) {
// Handle exceptions
}
接下来创建Flickr
实例,其中apikey
和secret
作为前两个参数被提供。最后一个参数指定了用于访问 Flickr 服务器的传输技术。目前,使用REST
类支持 REST 传输:
Flickr flickr = new Flickr(apikey, secret, new REST());
为了搜索图像,我们将使用SearchParameters
类。这个类支持许多标准,这些标准可以缩小查询返回的图像数量,包括纬度、经度、媒体类型和用户 ID 等标准。在下面的序列中,setBBox
方法指定了搜索的经度和纬度。这些参数是(按顺序):最小经度、最小纬度、最大经度和最大纬度。setMedia
方法指定了媒体的类型。有三个可能的参数— "all"
、"photos"
和"videos"
:
SearchParameters searchParameters = new SearchParameters();
searchParameters.setBBox("-180", "-90", "180", "90");
searchParameters.setMedia("photos");
PhotosInterface
类拥有一个search
方法,该方法使用SearchParameters
实例来检索照片列表。getPhotosInterface
方法返回PhotosInterface
类的一个实例,如下所示。SearchParameters
实例是第一个参数。第二个参数决定每页检索多少张照片,第三个参数是偏移量。返回一个PhotoList
类实例:
PhotosInterface pi = new PhotosInterface(apikey, secret,
new REST());
PhotoList<Photo> list = pi.search(searchParameters, 10, 0);
下一个序列说明了几种方法的使用,以获取有关图像检索的信息。使用get
方法访问每个Photo
实例。将显示标题、图像格式、公共标志和照片 URL:
out.println("Image List");
for (int i = 0; i < list.size(); i++) {
Photo photo = list.get(i);
out.println("Image: " + i +
`"\nTitle: " + photo.getTitle() +
"\nMedia: " + photo.getOriginalFormat() +
"\nPublic: " + photo.isPublicFlag() +
"\nUrl: " + photo.getUrl() +
"\n");
}
out.println();
此处显示了部分列表,其中许多特定值已被修改以保护原始数据:
Image List
Image: 0
Title: XYZ Image
Media: jpg
Public: true
Url: https://flickr.com/photos/7723...@N02/269...
Image: 1
Title: IMG_5555.jpg
Media: jpg
Public: true
Url: https://flickr.com/photos/2665...@N07/264...
Image: 2
Title: DSC05555
Media: jpg
Public: true
Url: https://flickr.com/photos/1179...@N04/264...
这个例子返回的图片列表会有所不同,因为我们使用了一个相当大的搜索范围,而且图片一直在增加。
有两种方法可以用来下载图像。第一个使用图像的 URL,第二个使用一个Photo
对象。图像的 URL 可以从许多来源获得。我们在这个例子中使用了Photo
类getUrl
方法。
在下面的序列中,我们使用其构造函数获得了一个PhotosInterface
的实例,以说明另一种方法:
PhotosInterface pi = new PhotosInterface(apikey, secret,
new REST());
我们从前面的列表中获取第一个Photo
实例,然后用它的getUrl
获取图像的 URL。PhotosInterface
类的getImage
方法返回一个代表图像的BufferedImage
对象,如下所示:
Photo currentPhoto = list.get(0);
BufferedImage bufferedImage =
pi.getImage(currentPhoto.getUrl());
然后使用ImageIO
类将图像保存到一个文件中:
File outputfile = new File("image.jpg");
ImageIO.write(bufferedImage, "jpg", outputfile);
getImage
方法被重载。这里,Photo
实例和所需图像的大小被用作参数来获得BufferedImage
实例:
bufferedImage = pi.getImage(currentPhoto, Size.SMALL);
可以使用前面的技术将图像保存到文件中。
Flickr4Java API 支持许多其他处理 Flickr 图像的技术。
处理 YouTube
YouTube 是一个受欢迎的视频网站,用户可以上传和分享视频(https://www.youtube.com/)。它已经被用来分享幽默视频,提供如何做任何事情的指令,并在其观众之间分享信息。这是一个有用的信息来源,因为它捕捉了不同人群的思想和观点。这为分析和洞察人类行为提供了一个有趣的机会。
YouTube 可以作为视频和视频元数据的有用来源。Java API 可用于访问其内容(https://developers.google.com/youtube/v3/)。API 的详细文档可以在 https://developers.google.com/youtube/v3/docs/的找到。
在本节中,我们将演示如何通过关键字搜索视频并检索感兴趣的信息。我们还将展示如何下载视频。要使用 YouTube API,你需要一个谷歌账户,可以在https://www.google.com/accounts/NewAccount获得。接下来,在谷歌开发者控制台中创建一个账户(【https://console.developers.google.com/】T2)。使用 API 密钥或 OAuth 2.0 凭证支持 API 访问。在https://developers . Google . com/YouTube/registrating _ an _ application # create _ project讨论项目创建过程和关键点。
按关键字搜索
按关键字搜索视频的过程改编自https://developers . Google . com/YouTube/v3/code _ samples/Java # search _ by _ keyword。其他可能有用的代码示例可以在https://developers.google.com/youtube/v3/code_samples/java找到。该过程已经过简化,因此我们可以专注于搜索过程。我们从 try 块和一个YouTube
实例的创建开始。这个类提供了对 API 的基本访问。这个 API 的 Javadocs 可以在https://developers . Google . com/resources/API-libraries/documentation/YouTube/v3/Java/latest/找到。
YouTube.Builder
类用于构造一个YouTube
实例。它的构造函数有三个参数:
Transport
:用于 HTTP 的对象JSONFactory
:用于处理 JSON 对象- 这个例子不需要任何东西
许多 API 响应将以 JSON 对象的形式出现。YouTube 类的'setApplicationName
方法给它一个名字,build
方法创建一个新的 YouTube 实例:
try {
YouTube youtube = new YouTube.Builder(
Auth.HTTP_TRANSPORT,
Auth.JSON_FACTORY,
new HttpRequestInitializer() {
public void initialize(HttpRequest request)
throws IOException {
}
})
.setApplicationName("application_name")
...
} catch (GoogleJSONResponseException ex) {
// Handle exceptions
} catch (IOException ex) {
// Handle exceptions
}
接下来,我们初始化一个字符串来保存感兴趣的搜索词。在这种情况下,我们将查找包含单词cats
的视频:
String queryTerm = "cats";
类YouTube.Search.List
维护一个搜索结果的集合。YouTube
类的search
方法指定了要返回的资源类型。在这种情况下,字符串指定了要返回的搜索结果的id
和snippet
部分:
YouTube.Search.List search = youtube
.search()
.list("id,snippet");
搜索结果是一个 JSON 对象,其结构如下。在https://developers . Google . com/YouTube/v3/docs/playlist items # methods中有更详细的描述。在前面的序列中,只返回搜索的id
和snippet
部分,从而提高了操作效率:
{
"kind": "youtube#searchResult",
"etag": etag,
"id": {
"kind": string,
"videoId": string,
"channelId": string,
"playlistId": string
},
"snippet": {
"publishedAt": datetime,
"channelId": string,
"title": string,
"description": string,
"thumbnails": {
(key): {
"url": string,
"width": unsigned integer,
"height": unsigned integer
}
},
"channelTitle": string,
"liveBroadcastContent": string
}
}
接下来,我们需要指定 API 键和各种搜索参数。指定查询术语以及要返回的媒体类型。在这种情况下,只会返回视频。另外两个选项包括channel
和playlist
:
String apiKey = "Your API key";
search.setKey(apiKey);
search.setQ(queryTerm);
search.setType("video");
此外,我们进一步指定要返回的字段,如下所示。这些对应于 JSON 对象的字段:
search.setFields("items(id/kind,id/videoId,snippet/title," +
"snippet/description,snippet/thumbnails/default/url)");
我们还指定了使用setMaxResults
方法检索的结果的最大数量:
search.setMaxResults(10L);
execute
方法将执行实际的查询,返回一个SearchListResponse
对象。它的getItems
方法返回一个SearchResult
对象列表,每个对象对应一个检索到的视频:
SearchListResponse searchResponse = search.execute();
List<SearchResult> searchResultList =
searchResponse.getItems();
在这个例子中,我们没有遍历每个返回的视频。相反,我们检索第一个视频并显示关于该视频的信息。SearchResult
video 变量允许我们访问 JSON 对象的不同部分,如下所示:
SearchResult video = searchResultList.iterator().next();
Thumbnail thumbnail = video
.getSnippet().getThumbnails().getDefault();
out.println("Kind: " + video.getKind());
out.println("Video Id: " + video.getId().getVideoId());
out.println("Title: " + video.getSnippet().getTitle());
out.println("Description: " +
video.getSnippet().getDescription());
out.println("Thumbnail: " + thumbnail.getUrl());
一种可能的输出如下,其中部分输出已被修改:
Kind: null
Video Id: tntO...
Title: Funny Cats ...
Description: Check out the ...
Thumbnail: https://i.ytimg.com/vi/tntO.../default.jpg
为了简化示例,我们已经跳过了许多错误检查步骤,但是在业务应用程序中实现时,应该考虑这些步骤。
如果我们需要下载视频,最简单的方法之一就是使用 axet/wget 在https://github.com/axet/wget找到。它提供了一种使用视频 ID 下载视频的简单易用的技术。
在下面的示例中,使用视频 ID 创建了一个 URL。您需要提供视频 ID 才能正常工作。文件以视频标题作为文件名保存到当前目录:
String url = "http://www.youtube.com/watch?v=videoID";
String path = ".";
VGet vget = new VGet(new URL(url), new File(path));
vget.download();
GitHub 网站上还有其他更复杂的下载技术。
总结
在这一章中,我们讨论了对数据科学有用的数据类型,这些数据在互联网上很容易获得。这一讨论包括关于最常见类型数据源的文件规范和格式的细节。
我们还研究了 Java APIs 和其他检索数据的技术,并用多个数据源说明了这个过程。我们特别关注基于文本的文档格式和多媒体文件的类型。我们使用网络爬虫来访问网站,然后执行网络抓取来从我们遇到的网站中检索数据。
最后,我们从社交媒体网站提取数据,并检查可用的 Java 支持。我们从 Twitter、Wikipedia、Flickr 和 YouTube 检索数据,并检查可用的 API 支持。**
三、数据清理
真实世界的数据通常是脏的和非结构化的,并且在可用之前必须重新处理。数据可能包含错误、重复条目、格式错误或不一致。解决这些类型问题的过程称为数据清理。数据清洗又被称为数据角力、按摩、整形,或者蒙皮。数据合并是指将来自多个来源的数据进行合并,通常被认为是一种数据清理活动。
我们需要清理数据,因为任何基于不准确数据的分析都会产生误导性的结果。我们希望确保我们处理的数据是高质量的数据。数据质量包括:
- 有效性:确保数据具有正确的形式或结构
- 准确性:数据中的值真正代表数据集
- 完整性:没有缺失的元素
- 一致性:数据的变化是同步的
- 一致性:使用相同的测量单位
有几种用于清理数据的技术和工具。我们将研究以下方法:
- 处理不同类型的数据
- 清理和操作文本数据
- 填补缺失数据
- 验证数据
此外,我们将简要检查几种图像增强技术。
通常有许多方法来完成相同的清洁任务。例如,有许多支持数据清理的 GUI 工具,比如 open refine(http://openrefine.org/)。该工具允许用户读入数据集,并使用各种技术对其进行清理。然而,对于需要清理的每个数据集,它需要用户与应用程序进行交互。它不利于自动化。
我们将关注如何使用 Java 代码清理数据。即便如此,也可能有不同的技术来清理数据。我们将展示多种方法,为读者提供如何做到这一点的见解。有时,这将使用核心 Java 字符串类,而在其他时候,它可能会使用专门的库。
这些库通常更具表现力和效率。但是,有时使用简单的字符串函数就足以解决问题了。展示赞美的技巧会提高读者的技能。
基于文本的基本任务包括:
- 数据转换
- 数据插补(处理缺失数据)
- 子集数据
- 分类数据
- 验证数据
在这一章中,我们感兴趣的是清理数据。然而,这个过程的一部分是从各种数据源中提取信息。数据可以明文或二进制形式存储。在开始清理过程之前,我们需要了解用于存储数据的各种格式。这些格式中的许多已在第 2 章、数据采集中介绍过,但我们将在以下章节中更详细地介绍。
处理数据格式
数据以各种形式出现。我们将研究更常用的格式,并展示如何从各种数据源中提取它们。在我们清理数据之前,需要从数据源(如文件)中提取数据。在本节中,我们将建立在第 2 章、数据采集中的数据格式介绍的基础上,并展示如何提取全部或部分数据集。例如,从一个 HTML 页面中,我们可能只想提取没有标记的文本。或者也许我们只对它的数字感兴趣。
这些数据格式可能相当复杂。本节的目的是说明该数据格式常用的基本技术。对特定数据格式的全面论述超出了本书的范围。具体来说,我们将介绍如何从 Java 处理以下数据格式:
- CSV 数据
- 电子表格
- 可移植文档格式或 PDF 文件
- Javascript 对象符号或 JSON 文件
还有许多其他文件类型在这里没有提到。例如,jsoup 对于解析 HTML 文档很有用。因为我们已经在第二章、数据采集的Java 部分介绍了这是如何完成的,所以我们在此不再赘述。
处理 CSV 数据
分隔信息的常用技术是使用逗号或类似的分隔符。了解如何处理 CSV 数据使我们能够在分析工作中利用这种类型的数据。当我们处理 CSV 数据时,有几个问题,包括转义数据和嵌入的逗号。
我们将研究一些处理逗号分隔数据的基本技术。由于 CSV 数据的行列结构,这些技术将从文件中读取数据,并将数据放在二维数组中。首先,我们将结合使用Scanner
类读入令牌和String
类split
方法来分离数据并将其存储在数组中。接下来,我们将探索使用第三方库 OpenCSV,它提供了一种更有效的技术。
然而,第一种方法可能只适用于快速和肮脏的数据处理。我们将逐一讨论这些技术,因为它们在不同的情况下都很有用。
我们将使用从https://www.data.gov/下载的数据集,其中包含按邮政编码排序的美国人口统计数据。该数据集可在https://catalog . data . gov/dataset/demographic-statistics-by-zip-code-acfc 9下载。出于我们的目的,这个数据集已经存储在文件Demographics.csv
中。在这个特定的文件中,每一行都包含相同数量的列。然而,并不是所有的数据都如此干净,接下来显示的解决方案考虑了交错数组的可能性。
注意
交错数组是指不同行的列数可能不同的数组。例如,行 2 可以具有 5 个元素,而行 3 可以具有 6 个元素。当使用交错数组时,你必须小心你的列索引。
首先,我们使用Scanner
类从数据文件中读入数据。我们将数据暂时存储在一个ArrayList
中,因为我们并不总是知道我们的数据包含多少行。
try (Scanner csvData = new Scanner(new File("Demographics.csv"))) {
ArrayList<String> list = new ArrayList<String>();
while (csvData.hasNext()) {
list.add(csvData.nextLine());
} catch (FileNotFoundException ex) {
// Handle exceptions
}
使用toArray
方法将列表转换为数组。这个版本的方法使用一个String
数组作为参数,这样该方法就知道要创建什么类型的数组。然后创建一个二维数组来保存 CSV 数据。
String[] tempArray = list.toArray(new String[1]);
String[][] csvArray = new String[tempArray.length][];
split
方法用于为每行创建一个由String
组成的数组。这个数组被分配给csvArray
的一行。
for(int i=0; i<tempArray.length; i++) {
csvArray[i] = tempArray[i].split(",");
}
我们的下一项技术将使用第三方库来读入和处理 CSV 数据。有多种选择,但我们将重点关注流行的 OpenCSV(http://opencsv.sourceforge.net)。这个库比我们以前的技术提供了几个优势。我们可以在每行上有任意数量的条目,而不用担心处理异常。我们也不需要担心数据标记中嵌入的逗号或回车。该库还允许我们选择一次读取整个文件还是使用迭代器逐行处理数据。
首先,我们需要创建一个CSVReader
类的实例。注意,第二个参数允许我们指定分隔符,例如,如果我们有由制表符或破折号分隔的类似文件格式,这是一个有用的特性。如果我们想一次读取整个文件,我们使用readAll
方法。
CSVReader dataReader = new CSVReader(new FileReader("Demographics.csv"),',');
ArrayList<String> holdData = (ArrayList)dataReader.readAll();
然后我们可以像上面一样处理数据,通过使用String
类方法将数据分割成一个二维数组。或者,我们可以一次处理一行数据。在下面的示例中,每个标记都是单独打印出来的,但是标记也可以存储在二维数组或其他适当的数据结构中。
CSVReader dataReader = new CSVReader(new FileReader("Demographics.csv"),',');
String[] nextLine;
while ((nextLine = dataReader.readNext()) != null){
for(String token : nextLine){
out.println(token);
}
}
dataReader.close();
我们现在可以清理或处理阵列。
处理电子表格
电子表格已经被证明是一种非常流行的处理数字和文本数据的工具。由于过去几十年来电子表格中存储了大量信息,知道如何从电子表格中提取信息使我们能够利用这一广泛可用的数据源。在本节中,我们将演示如何使用 Apache POI API 来实现这一点。
Open Office 还支持电子表格应用程序。Open Office 文档以 XML 格式存储,这使得使用 XML 解析技术很容易访问它。然而,阿帕奇 ODF 工具包(http://incubator.apache.org/odftoolkit/)提供了一种在不知道 OpenOffice 文档格式的情况下访问文档内数据的方法。这目前是一个孵化器项目,还没有完全成熟。还有许多其他 API 可以帮助处理 OpenOffice 文档,详见面向开发人员的开放文档格式(ODF)(http://www.opendocumentformat.org/developers/)页面。
处理 Excel 电子表格
Apache POI(http://poi.apache.org/index.html)是一组 API,提供对包括 Excel 和 Word 在内的许多微软产品的访问。它由一系列旨在访问特定 Microsoft 产品的组件组成。这些组件的概述可在 http://poi.apache.org/overview.html 的找到。
在本节中,我们将演示如何使用 XSSF 组件读取一个简单的 Excel 电子表格来访问 Excel 2007+电子表格。Apache POI API 的 Javadocs 可以在https://poi.apache.org/apidocs/index.html找到。
我们将使用一个简单的 Excel 电子表格,它由一系列包含 ID 以及最小值、最大值和平均值的行组成。这些数字并不代表任何特定类型的数据。电子表格如下:
| ID | 最小值 | 最大值 | 平均值 |
| 12345
| 45
| 89
| 65.55
|
| 23456
| 78
| 96
| 86.75
|
| 34567
| 56
| 89
| 67.44
|
| 45678
| 86
| 99
| 95.67
|
我们从 try-with-resources 块开始处理任何可能发生的IOExceptions
:
try (FileInputStream file = new FileInputStream(
new File("Sample.xlsx"))) {
...
}
} catch (IOException e) {
// Handle exceptions
}
使用电子表格创建一个XSSFWorkbook
类的实例。由于一个工作簿可能包含多个电子表格,我们使用getSheetAt
方法选择第一个。
XSSFWorkbook workbook = new XSSFWorkbook(file);
XSSFSheet sheet = workbook.getSheetAt(0);
下一步是遍历电子表格的行,然后遍历每一列:
for(Row row : sheet) {
for (Cell cell : row) {
...
}
out.println();
电子表格的每个单元格可以使用不同的格式。我们使用getCellType
方法确定其类型,然后使用适当的方法提取单元格中的数据。在这个例子中,我们只处理数字和文本数据。
switch (cell.getCellType()) {
case Cell.CELL_TYPE_NUMERIC:
out.print(cell.getNumericCellValue() + "\t");
break;
case Cell.CELL_TYPE_STRING:
out.print(cell.getStringCellValue() + "\t");
break;
}
执行时,我们得到以下输出:
ID Minimum Maximum Average
12345.0 45.0 89.0 65.55
23456.0 78.0 96.0 86.75
34567.0 56.0 89.0 67.44
45678.0 86.0 99.0 95.67
POI 支持其他更复杂的类和方法来提取数据。
处理 PDF 文件
有几个 API 支持从 PDF 文件中提取文本。这里我们将使用 PDFBox。Apache PDF box(https://pdfbox.apache.org/)是一个开源 API,允许 Java 程序员处理 PDF 文档。在这一节中,我们将演示如何从 PDF 文档中提取简单的文本。在 https://pdfbox.apache.org/docs/2.0.1/Javadocs/的可以找到 PDFBox API 的 javadocs。
这是一个简单的 PDF 文件。它由几个项目符号组成:
- 第一行
- 第二行
- 第 3 行
这是文件的结尾。
一个try
块用于抓住IOExceptions
。PDDocument
类将代表正在处理的 PDF 文档。它的load
方法将加载到由File
对象指定的 PDF 文件中:
try {
PDDocument document = PDDocument.load(new File("PDF File.pdf"));
...
} catch (Exception e) {
// Handle exceptions
}
一旦加载完毕,PDFTextStripper
类getText
方法将从文件中提取文本。然后显示文本,如下所示:
PDFTextStripper Tstripper = new PDFTextStripper();
String documentText = Tstripper.getText(document);
System.out.println(documentText);
该示例的输出如下。请注意,项目符号以问号的形式返回。
This is a simple PDF file. It consists of several bullets:
? Line 1
? Line 2
? Line 3
This is the end of the document.
这是对 PDFBox 使用的简单介绍。当我们需要提取和操作 PDF 文档时,它是一个非常强大的工具。
处理 JSON
在第 2 章、数据采集中,我们了解到某些 YouTube 搜索会返回 JSON 格式的结果。具体来说,SearchResult
类保存与特定搜索相关的信息。在这一节中,我们说明了如何使用 YouTube 特定的技术来提取信息。在这一节中,我们将说明如何使用 Jackson JSON 实现提取 JSON 信息。
JSON 支持三种数据处理模型:
- 流 API -逐令牌处理 JSON 数据
- 树模型——JSON 数据完全保存在内存中,然后进行处理
- 数据绑定——JSON 数据被转换成 Java 对象
使用 JSON 流 API
我们将说明前两种方法。第一种方法效率更高,在处理大量数据时使用。第二种方法很方便,但是数据不能太大。当使用特定的 Java 类处理数据更方便时,第三种技术很有用。例如,如果 JSON 数据代表一个地址,那么可以定义一个特定的 Java 地址类来保存和处理数据。
有几个 Java 库支持 JSON 处理,包括:
- flex JSON(http://flexjson.sourceforge.net/)
- genson(http://owlike . github . io/genson/
- Google-Gson(https://github.com/google/gson)
- 杰克逊图书馆(https://github.com/FasterXML/jackson)
- JSON-io(https://github . com/jdereg/JSON-io
- JSON-lib(http://json-lib.sourceforge.net/
我们将使用杰克逊项目(https://github.com/FasterXML/jackson)。文件可以在 https://github.com/FasterXML/jackson-docs 找到。我们将使用两个 JSON 文件来演示如何使用它。接下来显示的是第一个文件Person.json
,其中存储了个人数据。它由四个字段组成,其中最后一个字段是位置信息数组。
{
"firstname":"Smith",
"lastname":"Peter",
"phone":8475552222,
"address":["100 Main Street","Corpus","Oklahoma"]
}
下面的代码序列显示了如何提取每个字段的值。在 try-catch 块中,创建了一个JsonFactory
实例,然后基于Person.json
文件创建了一个JsonParser
实例。
try {
JsonFactory jsonfactory = new JsonFactory();
JsonParser parser = jsonfactory.createParser(new File("Person.json"));
...
parser.close();
} catch (IOException ex) {
// Handle exceptions
}
nextToken
方法返回一个token
。然而,JsonParser
对象跟踪当前的令牌。在while
循环中,nextToken
方法返回并让解析器前进到下一个标记。getCurrentName
方法返回token
的字段名称。当到达最后一个令牌时,while
循环终止。
while (parser.nextToken() != JsonToken.END_OBJECT) {
String token = parser.getCurrentName();
...
}
循环体由一系列根据字段名称处理字段的if
语句组成。由于address
字段是一个数组,另一个循环将提取它的每个元素,直到到达结束数组token
。
if ("firstname".equals(token)) {
parser.nextToken();
String fname = parser.getText();
out.println("firstname : " + fname);
}
if ("lastname".equals(token)) {
parser.nextToken();
String lname = parser.getText();
out.println("lastname : " + lname);
}
if ("phone".equals(token)) {
parser.nextToken();
long phone = parser.getLongValue();
out.println("phone : " + phone);
}
if ("address".equals(token)) {
out.println("address :");
parser.nextToken();
while (parser.nextToken() != JsonToken.END_ARRAY) {
out.println(parser.getText());
}
}
此示例的输出如下:
firstname : Smith
lastname : Peter
phone : 8475552222
address :
100 Main Street
Corpus
Oklahoma
然而,JSON 对象通常比前一个例子更复杂。这里一个Persons.json
文件由三个persons
组成:
{
"persons": {
"groupname": "school",
"person":
[
{"firstname":"Smith",
"lastname":"Peter",
"phone":8475552222,
"address":["100 Main Street","Corpus","Oklahoma"] },
{"firstname":"King",
"lastname":"Sarah",
"phone":8475551111,
"address":["200 Main Street","Corpus","Oklahoma"] },
{"firstname":"Frost",
"lastname":"Nathan",
"phone":8475553333,
"address":["300 Main Street","Corpus","Oklahoma"] }
]
}
}
为了处理这个文件,我们使用了与前面所示类似的一组代码。我们创建解析器,然后像以前一样进入一个循环:
try {
JsonFactory jsonfactory = new JsonFactory();
JsonParser parser = jsonfactory.createParser(new File("Person.json"));
while (parser.nextToken() != JsonToken.END_OBJECT) {
String token = parser.getCurrentName();
...
}
parser.close();
} catch (IOException ex) {
// Handle exceptions
}
然而,我们需要找到persons
字段,然后提取它的每个元素。提取并显示groupname
字段,如下所示:
if ("persons".equals(token)) {
JsonToken jsonToken = parser.nextToken();
jsonToken = parser.nextToken();
token = parser.getCurrentName();
if ("groupname".equals(token)) {
parser.nextToken();
String groupname = parser.getText();
out.println("Group : " + groupname);
...
}
}
接下来,我们找到person
字段并调用一个parsePerson
方法来更好地组织代码:
parser.nextToken();
token = parser.getCurrentName();
if ("person".equals(token)) {
out.println("Found person");
parsePerson(parser);
}
接下来的parsePerson
方法与第一个例子中使用的过程非常相似。
public void parsePerson(JsonParser parser) throws IOException {
while (parser.nextToken() != JsonToken.END_ARRAY) {
String token = parser.getCurrentName();
if ("firstname".equals(token)) {
parser.nextToken();
String fname = parser.getText();
out.println("firstname : " + fname);
}
if ("lastname".equals(token)) {
parser.nextToken();
String lname = parser.getText();
out.println("lastname : " + lname);
}
if ("phone".equals(token)) {
parser.nextToken();
long phone = parser.getLongValue();
out.println("phone : " + phone);
}
if ("address".equals(token)) {
out.println("address :");
parser.nextToken();
while (parser.nextToken() != JsonToken.END_ARRAY) {
out.println(parser.getText());
}
}
}
}
输出如下:
Group : school
Found person
firstname : Smith
lastname : Peter
phone : 8475552222
address :
100 Main Street
Corpus
Oklahoma
firstname : King
lastname : Sarah
phone : 8475551111
address :
200 Main Street
Corpus
Oklahoma
firstname : Frost
lastname : Nathan
phone : 8475553333address :
300 Main Street
Corpus
Oklahoma
使用 JSON 树 API
第二种方法是使用树模型。一个ObjectMapper
实例用于使用Persons.json
文件创建一个JsonNode
实例。fieldNames
方法返回Iterator
,允许我们处理文件的每个元素。
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(new File("Persons.json"));
Iterator<String> fieldNames = node.fieldNames();
while (fieldNames.hasNext()) {
...
fieldNames.next();
}
} catch (IOException ex) {
// Handle exceptions
}
由于 JSON 文件包含一个persons
字段,我们将获得一个表示该字段的JsonNode
实例,然后遍历它的每个元素。
JsonNode personsNode = node.get("persons");
Iterator<JsonNode> elements = personsNode.iterator();
while (elements.hasNext()) {
...
}
一次处理一个元素。如果元素类型是字符串,我们假设这是groupname
字段。
JsonNode element = elements.next();
JsonNodeType nodeType = element.getNodeType();
if (nodeType == JsonNodeType.STRING) {
out.println("Group: " + element.textValue());
}
如果元素是一个数组,我们假设它包含一系列人,每个人都由parsePerson
方法处理:
if (nodeType == JsonNodeType.ARRAY) {
Iterator<JsonNode> fields = element.iterator();
while (fields.hasNext()) {
parsePerson(fields.next());
}
}
parsePerson
方法如下所示:
public void parsePerson(JsonNode node) {
Iterator<JsonNode> fields = node.iterator();
while(fields.hasNext()) {
JsonNode subNode = fields.next();
out.println(subNode.asText());
}
}
输出如下:
Group: school
Smith
Peter
8475552222
King
Sarah
8475551111
Frost
Nathan
8475553333
JSON 的内容比我们在这里能够说明的要多得多。但是,这应该会让您了解如何处理这种类型的数据。
清理文本的本质
字符串用于支持文本处理,所以使用一个好的字符串库是很重要的。不幸的是,java.lang.String
类有一些限制。要解决这些限制,您可以根据需要实现自己的特殊字符串函数,也可以使用第三方库。
创建你自己的库是有用的,但是你基本上是在重新发明轮子。编写一个简单的代码序列来实现某些功能可能会更快,但是为了正确地完成任务,您需要对它们进行测试。第三方库已经过测试,并在数百个项目中使用过。它们提供了一种更有效的处理文本的方式。
除了 Java 中的那些之外,还有几个文本处理 API。我们将演示其中的两个:
- Apache common:https://commons . Apache . org/
- 番石榴:【https://github.com/google/guava
Java 为清理文本数据提供了许多支持,包括String
类中的方法。这些方法非常适合简单的文本清理和少量数据,但对于较大的复杂数据集也很有效。我们稍后将演示几个String
类方法。下表总结了一些最有用的String
类方法:
| 方法名 | 返回类型 | 描述 |
| trim
| String
| 删除前导空格和尾随空格 |
| toUpperCase
/ toLowerCase
| String
| 更改整个字符串的大小写 |
| replaceAll
| String
| 替换字符串中出现的所有字符序列 |
| contains
| boolean
| 确定字符串中是否存在给定的字符序列 |
| compareTo``compareToIgnoreCase
| int
| 对两个字符串进行词法比较,并返回表示它们之间关系的整数 |
| matches
| boolean
| 确定字符串是否与给定的正则表达式匹配 |
| join
| String
| 用指定的分隔符组合两个或多个字符串 |
| split
| String[]
| 使用指定的分隔符分隔给定字符串的元素 |
正则表达式的使用简化了许多文本操作。正则表达式使用标准化的语法来表示文本中的模式,这可用于定位和操作与模式匹配的文本。
正则表达式本身就是一个字符串。例如,字符串Hello, my name is Sally
可以用作正则表达式来查找给定文本中的精确单词。这是非常具体的,并不广泛适用,但我们可以使用不同的正则表达式,使我们的代码更有效。Hello, my name is \\w
将匹配任何以Hello, my name is
开头并以单词字符结尾的文本。
我们将使用几个更复杂的正则表达式的例子,下表总结了一些更有用的语法选项。注:在 Java 应用程序中使用时,每个都必须双转义。
| 选项 | 描述 |
| \d
| 任意数字: 0-9 |
| \D
| 任何非数字 |
| \s
| 任何空白字符 |
| \S
| 任何非空白字符 |
| \w
| 任意单词字符(包括数字): A-Z 、 a-z 、 0-9 |
| \W
| 任何非单词字符 |
文本数据的大小和来源因应用程序而异,但用于转换数据的方法是相同的。你可能真的需要从一个文件中读取数据,但是为了简单起见,我们将使用一个包含赫尔曼·梅尔维尔的《莫比·迪克》开头句子的字符串作为本章的几个例子。除非另有说明,否则将假定文本如下所示:
String dirtyText = "Call me Ishmael. Some years ago- never mind how";
dirtyText += " long precisely - having little or no money in my purse,";
dirtyText += " and nothing particular to interest me on shore, I thought";
dirtyText += " I would sail about a little and see the watery part of the world.";
使用 Java 分词器提取单词
通常,将文本数据作为标记进行分析是最有效的。核心 Java 库中有多个可用的记号赋予器,第三方记号赋予器也是如此。我们将在这一章中演示各种记号化器。理想的记号赋予器将取决于单个应用程序的限制和要求。
Java 核心令牌化器
是第一个也是最基本的记号赋予器,从 Java 1 开始就有了。不推荐在新的开发中使用,因为String
类的split
方法被认为更有效。虽然它确实为具有窄定义和集合分隔符的文件提供了速度优势,但它不如其他记号赋予器选项灵活。下面是一个简单的StringTokenizer
类的实现,它在空格上分割一个字符串:
StringTokenizer tokenizer = new StringTokenizer(dirtyText," ");
while(tokenizer.hasMoreTokens()){
out.print(tokenizer.nextToken() + " ");
}
当我们设置dirtyText
变量来保存来自莫比·迪克的文本时,如前所示,我们得到以下截断的输出:
Call me Ishmael. Some years ago- never mind how long precisely...
StreamTokenizer
是另一个核心的 Java 标记器。StreamTokenizer
提供了更多关于检索到的令牌的信息,并允许用户指定要解析的数据类型,但被认为比StreamTokenizer
或split
方法更难使用。String
类split
方法是基于分隔符拆分字符串的最简单方法,但是它不提供解析拆分后的字符串的方法,并且您只能为整个字符串指定一个分隔符。由于这些原因,它不是一个真正的记号赋予器,但是它对于数据清理很有用。
Scanner
类被设计成允许你将字符串解析成不同的数据类型。我们之前在处理 CSV 数据部分使用过它,我们将在移除停用词部分再次处理它。
第三方标记器和库
Apache Commons 由一组开源 Java 类和方法组成。这些提供了补充标准 Java APIs 的可重用代码。公地中包含的一个受欢迎的类是StrTokenizer
。这个类提供了比标准的StringTokenizer
类更高级的支持,特别是更多的控制和灵活性。下面是StrTokenizer
的一个简单实现:
StrTokenizer tokenizer = new StrTokenizer(text);
while (tokenizer.hasNext()) {
out.print(tokenizer.next() + " ");
}
这与StringTokenizer
的操作方式类似,默认情况下解析空格上的标记。构造函数可以指定分隔符以及如何处理数据中包含的双引号。
当我们使用前面显示的来自莫比·迪克的字符串时,第一个记号赋予器实现产生以下截断的输出:
Call me Ishmael. Some years ago- never mind how long precisely - having little or no money in my purse...
我们可以如下修改我们的构造函数:
StrTokenizer tokenizer = new StrTokenizer(text,",");
该实现的输出是:
Call me Ishmael. Some years ago- never mind how long precisely - having little or no money in my purse
and nothing particular to interest me on shore
I thought I would sail about a little and see the watery part of the world.
注意每一行是如何在原文中逗号的地方被分割的。这个定界符可以是一个简单的字符,正如我们已经展示的,也可以是一个更复杂的StrMatcher
对象。
Google Guava 是一组开源的实用 Java 类和方法。与许多 API 一样,Guava 的主要目标是减轻编写基本 Java 实用程序的负担,以便开发人员可以专注于业务流程。我们将在本章中讨论 Guava 中的两个主要工具:Joiner
类和Splitter
类。标记化是在 Guava 中使用其Splitter
类的split
方法完成的。下面是一个简单的例子:
Splitter simpleSplit = Splitter.on(',').omitEmptyStrings().trimResults();
Iterable<String> words = simpleSplit.split(dirtyText);
for(String token: words){
out.print(token);
}
这会将每个标记用逗号分开,并产生类似于我们上一个示例的输出。我们可以修改on
方法的参数来分割我们选择的字符。注意 chaining 方法,它允许我们省略空字符串并修剪前导和尾随空格。由于这些原因以及其他高级功能,Google Guava 被一些人认为是 Java 可用的最好的标记器。
LingPipe 是一个可用于 Java 语言处理的语言工具包。它通过它的TokenizerFactory
接口为文本分割提供了更专业的支持。我们在简单文本清理部分实现了一个 LingPipe IndoEuropeanTokenizerFactory
记号化器。
将数据转换成可用的形式
一旦获得数据,通常需要对其进行清理。数据集通常不一致,缺少信息,并且包含无关信息。在这一节中,我们将研究一些简单的方法来转换文本数据,使其更有用,更易于分析。
简单的文本清理
我们将使用之前显示的来自莫比·迪克的字符串来演示一些基本的String
类方法。注意toLowerCase
和trim
方法的使用。数据集通常有非标准的大小写和额外的前导或尾随空格。这些方法确保了数据集的一致性。我们也使用两次replaceAll
方法。在第一个实例中,我们使用一个正则表达式用一个空格替换所有数字和任何不是单词或空白字符的内容。第二个实例用一个空格替换所有连续的空白字符:
out.println(dirtyText);
dirtyText = dirtyText.toLowerCase().replaceAll("[\\d[^\\w\\s]]+", " ");
dirtyText = dirtyText.trim();
while(dirtyText.contains(" ")){
dirtyText = dirtyText.replaceAll(" ", " ");
}
out.println(dirtyText);
执行时,代码会产生以下截断的输出:
Call me Ishmael. Some years ago- never mind how long precisely -
call me ishmael some years ago never mind how long precisely
我们的下一个例子产生了相同的结果,但是用正则表达式解决了这个问题。在这种情况下,我们首先替换所有的数字和其他特殊字符。然后,我们使用方法链接来标准化我们的大小写,删除前导和尾随空格,并将我们的单词拆分到一个String
数组中。split
方法允许您在给定的分隔符上拆分文本。在这种情况下,我们选择使用正则表达式\\W
,它表示任何不是单词字符的东西:
out.println(dirtyText);
dirtyText = dirtyText.replaceAll("[\\d[^\\w\\s]]+", "");
String[] cleanText = dirtyText.toLowerCase().trim().split("[\\W]+");
for(String clean : cleanText){
out.print(clean + " ");
}
这段代码产生与前面所示相同的输出。
尽管数组对许多应用程序都很有用,但在清理后重新组合文本通常很重要。在下一个例子中,一旦我们清理了单词,我们就使用join
方法来组合它们。我们使用与前面相同的链接方法来清理和分割我们的文本。join
方法连接数组words
中的每个单词,并在每个单词之间插入一个空格:
out.println(dirtyText);
String[] words = dirtyText.toLowerCase().trim().split("[\\W\\d]+");
String cleanText = String.join(" ", words);
out.println(cleanText);
同样,这段代码产生与前面所示相同的输出。使用谷歌番石榴可以获得另一种版本的join
方法。下面是我们之前使用的相同过程的一个简单实现,但是使用了 Guava Joiner
类:
out.println(dirtyText);
String[] words = dirtyText.toLowerCase().trim().split("[\\W\\d]+");
String cleanText = Joiner.on(" ").skipNulls().join(words);
out.println(cleanText);
这个版本提供了额外的选项,包括跳过空值,如前所示。输出保持不变。
删除停用词
文本分析有时需要省略常见的、非特定的单词,如、、和,或但。这些词被称为停用词,有几种工具可以将它们从文本中删除。有各种方法来存储停用词列表,但是对于下面的例子,我们将假设它们包含在一个文件中。首先,我们创建一个新的Scanner
对象来读取我们的停用词。然后,我们使用Arrays
类的asList
方法将我们希望转换的文本存储在ArrayList
中。这里我们假设文本已经被清理和规范化。在使用String
类方法时,考虑大小写是很重要的,因为和不同于和或者和,尽管这三个可能都是您希望消除的停用词:
Scanner readStop = new Scanner(new File("C://stopwords.txt"));
ArrayList<String> words = new ArrayList<String>(Arrays.asList((dirtyText));
out.println("Original clean text: " + words.toString());
我们还创建了一个新的ArrayList
来保存在我们的文本中实际找到的停用词列表。这将允许我们很快使用ArrayList
类removeAll
方法。接下来,我们使用我们的Scanner
来通读我们的停用词文件。注意我们也是如何针对每个停用词调用toLowerCase
和trim
方法的。这是为了确保我们的停用词与文本中的格式相匹配。在这个例子中,我们使用contains
方法来确定我们的文本是否包含给定的停用词。如果是这样,我们将它添加到我们的foundWords
数组列表中。一旦我们处理完所有的停用词,我们调用removeAll
将它们从我们的文本中删除:
ArrayList<String> foundWords = new ArrayList();
while(readStop.hasNextLine()){
String stopWord = readStop.nextLine().toLowerCase();
if(words.contains(stopWord)){
foundWords.add(stopWord);
}
}
words.removeAll(foundWords);
out.println("Text without stop words: " + words.toString());
输出将取决于被指定为停止字的字。如果停用字词文件包含的字词不同于本示例中使用的字词,则输出会略有不同。我们的输出如下:
Original clean text: [call, me, ishmael, some, years, ago, never, mind, how, long, precisely, having, little, or, no, money, in, my, purse, and, nothing, particular, to, interest, me, on, shore, i, thought, i, would, sail, about, a, little, and, see, the, watery, part, of, the, world]
Text without stop words: [call, ishmael, years, ago, never, mind, how, long, precisely
标准 Java 库之外也支持删除停用词。我们来看一个例子,使用 LingPipe。在这个例子中,我们首先确保我们的文本被规范化为小写并被修剪。然后我们创建一个TokenizerFactory
类的新实例。我们将工厂设置为使用默认的英语停用词,然后对文本进行标记。注意,tokenizer
方法使用了一个char
数组,所以我们针对我们的文本调用toCharArray
。第二个参数指定文本中开始搜索的位置,最后一个参数指定结束位置:
text = text.toLowerCase().trim();
TokenizerFactory fact = IndoEuropeanTokenizerFactory.INSTANCE;
fact = new EnglishStopTokenizerFactory(fact);
Tokenizer tok = fact.tokenizer(text.toCharArray(), 0, text.length());
for(String word : tok){
out.print(word + " ");
}
输出如下:
Call me Ishmael. Some years ago- never mind how long precisely - having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.
call me ishmael . years ago - never mind how long precisely - having little money my purse , nothing particular interest me shore , i thought i sail little see watery part world .
请注意我们之前的例子之间的差异。首先,我们没有彻底清理文本,并允许特殊字符(如连字符)保留在文本中。其次,LingPipe 的停用词列表不同于我们在前面的例子中使用的文件。一些单词被删除了,但是 LingPipe 限制更少,允许更多的单词保留在文本中。您使用的停用字词的类型和数量将取决于您的特定应用。
在文本中查找单词
标准 Java 库支持在文本中搜索特定的标记。在前面的例子中,我们已经演示了matches
方法和正则表达式,它们在搜索文本时会很有用。然而,在这个例子中,我们将演示一个简单的技术,使用contains
方法和equals
方法来定位一个特定的字符串。首先,我们规范化我们的文本和我们正在搜索的单词,以确保我们可以找到匹配。我们还创建了一个整数变量来保存单词被找到的次数:
dirtyText = dirtyText.toLowerCase().trim();
toFind = toFind.toLowerCase().trim();
int count = 0;
接下来,我们调用contains
方法来确定这个单词是否存在于我们的文本中。如果是,我们将文本分割成一个数组,然后循环遍历,使用equals
方法来比较每个单词。如果我们遇到这个单词,我们就把计数器加 1。最后,我们显示输出,以显示我们的单词被遇到的次数:
if(dirtyText.contains(toFind)){
String[] words = dirtyText.split(" ");
for(String word : words){
if(word.equals(toFind)){
count++;
}
}
out.println("Found " + toFind + " " + count + " times in the text.");
}
在这个例子中,我们将toFind
设置为字母I
。这产生了以下输出:
Found i 2 times in the text.
我们还可以选择使用Scanner
类来搜索整个文件。一个有用的方法是findWithinHorizon
方法。这使用了一个Scanner
来解析文本直到一个给定的 horizon 规范。如果第二个参数为 0,如下所示,默认情况下将搜索整个Scanner
:
dirtyText = dirtyText.toLowerCase().trim();
toFind = toFind.toLowerCase().trim();
Scanner textLine = new Scanner(dirtyText);
out.println("Found " + textLine.findWithinHorizon(toFind, 10));
这种技术可以更有效地定位特定的字符串,但它确实使确定在哪里找到该字符串以及找到该字符串的次数变得更加困难。
使用BufferedReader
搜索整个文件也会更有效。我们指定要搜索的文件,并使用 try-catch 块来捕获任何 IO 异常。我们从我们的路径创建一个新的BufferedReader
对象,只要下一行不为空,就处理我们的文件:
String path = "C:// MobyDick.txt";
try {
String textLine = "";
toFind = toFind.toLowerCase().trim();
BufferedReader textToClean = new BufferedReader(
new FileReader(path));
while((textLine = textToClean.readLine()) != null){
line++;
if(textLine.toLowerCase().trim().contains(toFind)){
out.println("Found " + toFind + " in " + textLine);
}
}
textToClean.close();
} catch (IOException ex) {
// Handle exceptions
}
我们再次通过在莫比·迪克的第一句话中搜索单词I
来测试我们的数据。截断的输出如下:
Found i in Call me Ishmael...
查找和替换文本
我们经常不仅想找到文本,还想用其他东西替换它。我们开始下一个例子,就像我们之前的例子一样,通过指定我们的文本,我们要定位的文本,并调用contains
方法。如果我们找到了文本,我们调用replaceAll
方法来修改我们的字符串:
text = text.toLowerCase().trim();
toFind = toFind.toLowerCase().trim();
out.println(text);
if(text.contains(toFind)){
text = text.replaceAll(toFind, replaceWith);
out.println(text);
}
为了测试这段代码,我们将toFind
设置为单词I
,将replaceWith
设置为Ishmael
。我们的输出如下:
call me ishmael. some years ago- never mind how long precisely - having little or no money in my purse, and nothing particular to interest me on shore, i thought i would sail about a little and see the watery part of the world.
call me ishmael. some years ago- never mind how long precisely - having little or no money in my purse, and nothing particular to interest me on shore, Ishmael thought Ishmael would sail about a little and see the watery part of the world.
Apache Commons 还提供了一个在StringUtils
类中有几个变体的replace
方法。这个类提供了与String
类相同的功能,但是有更多的灵活性和选项。在下面的例子中,我们使用来自莫比·迪克的字符串,将单词me
的所有实例替换为X
来演示replace
方法:
out.println(text);
out.println(StringUtils.replace(text, "me", "X"));
截断的输出如下:
Call me Ishmael. Some years ago- never mind how long precisely -
Call X Ishmael. SoX years ago- never mind how long precisely -
请注意me
的每个实例是如何被替换的,甚至是那些包含在其他单词中的实例,例如some.
这可以通过在me
周围添加空格来避免,尽管这将忽略任何 me 位于句子末尾的实例,例如 me。我们将在一会儿用谷歌番石榴检查一个更好的选择。
StringUtils
类还提供了一个replacePattern
方法,允许您基于正则表达式搜索和替换文本。在以下示例中,我们用一个空格替换所有非单词字符,如连字符和逗号:
out.println(text);
text = StringUtils.replacePattern(text, "\\W\\s", " ");
out.println(text);
这将产生以下截断的输出:
Call me Ishmael. Some years ago- never mind how long precisely -
Call me Ishmael Some years ago never mind how long precisely
Google Guava 使用CharMatcher
类为匹配和修改文本数据提供了额外的支持。CharMatcher
不仅允许您查找匹配特定字符模式的数据,还提供了如何处理数据的选项。这包括允许您保留数据、替换数据以及从特定字符串中修剪空白。
在这个例子中,我们将使用replace
方法简单地用一个空格替换单词me
的所有实例。这将在我们的文本中产生一系列的空白。然后,我们将使用trimAndCollapseFrom
方法折叠多余的空格,并再次打印我们的字符串:
text = text.replace("me", " ");
out.println("With double spaces: " + text);
String spaced = CharMatcher.WHITESPACE.trimAndCollapseFrom(text, ' ');
out.println("With double spaces removed: " + spaced);
我们的输出被截断如下:
With double spaces: Call Ishmael. So years ago- ...
With double spaces removed: Call Ishmael. So years ago- ...
数据插补
数据插补是指识别和替换给定数据集中缺失数据的过程。在几乎所有的数据分析案例中,缺失数据都是一个问题,需要在正确分析数据之前解决这个问题。试图处理缺失信息的数据就像试图理解一场偶尔会漏掉一个词的对话。有时我们能理解意图是什么。在其他情况下,我们可能完全不知道想要传达什么。
在统计分析人员中,对于如何处理缺失数据存在不同意见,但最常见的方法是用合理的估计值或空值替换缺失数据。
为了防止数据偏斜和错位,许多统计学家主张用代表该数据集平均值或期望值的值来替换缺失的数据。确定代表值并将其分配到数据中某个位置的方法会因数据而异,我们无法在本章中一一举例说明。但是,例如,如果数据集包含某个日期范围内的温度列表,并且某个日期缺少温度,则可以为该日期指定一个温度,该温度是数据集内温度的平均值。
我们将研究一个相当琐碎的例子来说明围绕数据插补的问题。让我们假设变量tempList
包含一年中每个月的平均温度数据。然后,我们执行简单的平均值计算,并打印出结果:
double[] tempList = {50,56,65,70,74,80,82,90,83,78,64,52};
double sum = 0;
for(double d : tempList){
sum += d;
}
out.printf("The average temperature is %1$,.2f", sum/12);
请注意,对于该执行中使用的数字,输出如下:
The average temperature is 70.33
接下来,在计算我们的sum
之前,我们将通过将数组的第一个元素更改为零来模拟缺失数据:
double sum = 0;
tempList[0] = 0;
for(double d : tempList){
sum += d;
}
out.printf("The average temperature is %1$,.2f", sum/12);
这将改变我们输出中显示的平均温度:
The average temperature is 66.17
请注意,虽然这种变化看起来很小,但在统计上却很重要。根据给定数据集内的变化以及平均值与零或其他替代值的差距,统计分析的结果可能会有很大偏差。这并不意味着不应该用零来代替空值或其他无效值,而是应该考虑其他替代值。
一种替代方法是计算数组中值的平均值,排除零或空值,然后用缺失数据替换每个位置的平均值。在做出这些决定时,考虑数据的类型和数据分析的目的是很重要的。例如,在前面的例子中,零是否总是无效的平均温度?如果南极洲的温度是平均的,也许不会。
当需要处理空数据时,Java 的Optional
类提供了有用的解决方案。考虑下面的例子,我们有一个以数组形式存储的名字列表。为了演示这些方法,我们给null
设置了一个值:
String useName = "";
String[] nameList =
{"Amy","Bob","Sally","Sue","Don","Rick",null,"Betsy"};
Optional<String> tempName;
for(String name : nameList){
tempName = Optional.ofNullable(name);
useName = tempName.orElse("DEFAULT");
out.println("Name to use = " + useName);
}
我们首先创建了一个名为useName
的变量来保存我们将实际打印出来的名称。我们还创建了一个名为tempName
的Optional
类的实例。我们将用它来测试数组中的值是否为空。然后我们遍历数组,创建并调用Optional
类ofNullable
方法。这个方法测试一个特定的值是否为空。在下一行,我们调用orElse
方法将数组中的一个值赋给useName
,或者如果元素为空,则赋给DEFAULT
。我们的输出如下:
Name to use = Amy
Name to use = Bob
Name to use = Sally
Name to use = Sue
Name to use = Don
Name to use = Rick
Name to use = DEFAULT
Name to use = Betsy
Optional
类包含其他几个用于处理潜在空数据的方法。尽管有其他方法来处理这样的实例,但是 Java 8 的这一新增功能为常见的数据分析问题提供了更简单、更优雅的解决方案。
子集化数据
处理一整套数据并不总是实际可行的,也不总是可取的。在这些情况下,我们可能希望检索数据的子集,以便处理或从数据集中完全删除。标准 Java 库支持几种方法。首先,我们将使用SortedSet
接口的subSet
方法。我们将从在一个TreeSet
中存储一个数字列表开始。然后我们创建一个新的TreeSet
对象来保存从列表中检索到的子集。接下来,我们打印出原始列表:
Integer[] nums = {12, 46, 52, 34, 87, 123, 14, 44};
TreeSet<Integer> fullNumsList = new TreeSet<Integer>(new
ArrayList<>(Arrays.asList(nums)));
SortedSet<Integer> partNumsList;
out.println("Original List: " + fullNumsList.toString()
+ " " + fullNumsList.last());
subSet
方法有两个参数,它们指定了我们想要检索的数据中的整数范围。第一个参数包含在结果中,而第二个参数是唯一的。在下面的例子中,我们希望检索数组中第一个数字12
和46
之间所有数字的子集:
partNumsList = fullNumsList.subSet(fullNumsList.first(), 46);
out.println("SubSet of List: " + partNumsList.toString()
+ " " + partNumsList.size());
我们的输出如下:
Original List: [12, 14, 34, 44, 46, 52, 87, 123]
SubSet of List: [12, 14, 34, 44]
另一种选择是结合使用stream
方法和skip
方法。stream
方法返回一个 Java 8 流实例,该实例对集合进行迭代。我们将使用与上一个例子相同的numsList
,但是这一次我们将使用skip
方法指定跳过多少个元素。我们还将使用collect
方法创建一个新的Set
来保存新元素:
out.println("Original List: " + numsList.toString());
Set<Integer> fullNumsList = new TreeSet<Integer>(numsList);
Set<Integer> partNumsList = fullNumsList
.stream()
.skip(5)
.collect(toCollection(TreeSet::new));
out.println("SubSet of List: " + partNumsList.toString());
当我们打印出新的子集时,我们得到下面的输出,其中跳过了排序集的前五个元素。因为它是一个SortedSet
,我们实际上将省略五个最低的数字:
Original List: [12, 46, 52, 34, 87, 123, 14, 44]
SubSet of List: [52, 87, 123]
有时,数据会以空行或标题行开始,我们希望从要分析的数据集中删除这些空行或标题行。在我们的最后一个例子中,我们将从文件中读取数据并删除所有的空行。我们使用一个BufferedReader
来读取数据,并使用一个 lambda 表达式来测试空行。如果该行不为空,我们将该行打印到屏幕上:
try (BufferedReader br = new BufferedReader(new FileReader("C:\\text.txt"))) {
br
.lines()
.filter(s -> !s.equals(""))
.forEach(s -> out.println(s));
} catch (IOException ex) {
// Handle exceptions
}
排序文本
有时候在清理过程中需要对数据进行排序。标准 Java 库为完成不同类型的排序提供了一些资源,Java 8 的发布增加了一些改进。在我们的第一个例子中,我们将结合 lambda 表达式使用Comparator
接口。
我们从声明变量compareInts
开始。等号后面的第一组括号包含要传递给我们的方法的参数。在 lambda 表达式中,我们调用compare
方法,它决定哪个整数更大:
Comparator<Integer> compareInts = (Integer first, Integer second) ->
Integer.compare(first, second);
我们现在可以像以前一样调用sort
方法:
Collections.sort(numsList,compareInts);
out.println("Sorted integers using Lambda: " + numsList.toString());
我们的输出如下:
Sorted integers using Lambda: [12, 14, 34, 44, 46, 52, 87, 123]
然后我们用wordsList
模拟这个过程。注意使用了compareTo
方法,而不是compare
:
Comparator<String> compareWords = (String first, String second) -> first.compareTo(second);
Collections.sort(wordsList,compareWords);
out.println("Sorted words using Lambda: " + wordsList.toString());
当执行这段代码时,我们应该会看到以下输出:
Sorted words using Lambda: [boat, cat, dog, house, road, zoo]
在下一个例子中,我们将使用Collections
类对String
和整数数据进行基本排序。在本例中,wordList
和numsList
都是ArrayList
,初始化如下:
List<String> wordsList
= Stream.of("cat", "dog", "house", "boat", "road", "zoo")
.collect(Collectors.toList());
List<Integer> numsList = Stream.of(12, 46, 52, 34, 87, 123, 14, 44)
.collect(Collectors.toList());
首先,我们将打印每个列表的原始版本,然后调用sort
方法。然后我们显示我们的数据,按升序排列:
out.println("Original Word List: " + wordsList.toString());
Collections.sort(wordsList);
out.println("Ascending Word List: " + wordsList.toString());
out.println("Original Integer List: " + numsList.toString());
Collections.sort(numsList);
out.println("Ascending Integer List: " + numsList.toString());
输出如下:
Original Word List: [cat, dog, house, boat, road, zoo]
Ascending Word List: [boat, cat, dog, house, road, zoo]
Original Integer List: [12, 46, 52, 34, 87, 123, 14, 44]
Ascending Integer List: [12, 14, 34, 44, 46, 52, 87, 123]
接下来,我们将在整数数据示例中用Collections
类的reverse
方法替换sort
方法。这个方法简单地获取元素并以相反的顺序存储它们:
out.println("Original Integer List: " + numsList.toString());
Collections.reverse(numsList);
out.println("Reversed Integer List: " + numsList.toString());
输出显示我们新的numsList
:
Original Integer List: [12, 46, 52, 34, 87, 123, 14, 44]
Reversed Integer List: [44, 14, 123, 87, 34, 52, 46, 12]
在下一个例子中,我们使用Comparator
接口来处理排序。我们将继续使用我们的numsList
,并假设排序还没有发生。首先我们创建两个实现Comparator
接口的对象。sort
方法将在比较两个元素时使用这些对象来确定所需的顺序。表达式Integer::compare
是一个 Java 8 方法引用。这可用于使用 lambda 表达式的情况:
out.println("Original Integer List: " + numsList.toString());
Comparator<Integer> basicOrder = Integer::compare;
Comparator<Integer> descendOrder = basicOrder.reversed();
Collections.sort(numsList,descendOrder);
out.println("Descending Integer List: " + numsList.toString());
执行这段代码后,我们将看到以下输出:
Original Integer List: [12, 46, 52, 34, 87, 123, 14, 44]
Descending Integer List: [123, 87, 52, 46, 44, 34, 14, 12]
在最后一个例子中,我们将尝试一个更复杂的排序,包括两个比较。让我们假设有一个包含两个属性name
和age
的Dog
类,以及必要的访问方法。我们将开始向一个新的ArrayList
添加元素,然后打印每个Dog
的名字和年龄:
ArrayList<Dogs> dogs = new ArrayList<Dogs>();
dogs.add(new Dogs("Zoey", 8));
dogs.add(new Dogs("Roxie", 10));
dogs.add(new Dogs("Kylie", 7));
dogs.add(new Dogs("Shorty", 14));
dogs.add(new Dogs("Ginger", 7));
dogs.add(new Dogs("Penny", 7));
out.println("Name " + " Age");
for(Dogs d : dogs){
out.println(d.getName() + " " + d.getAge());
}
我们的输出应该类似于:
Name Age
Zoey 8
Roxie 10
Kylie 7
Shorty 14
Ginger 7
Penny 7
接下来,我们将使用方法链接和双冒号操作符来引用来自Dog
类的方法。我们首先调用comparing
,然后调用thenComparing
,以指定比较发生的顺序。当我们执行代码时,我们希望看到Dog
对象首先按照Name
排序,然后按照Age
排序:
dogs.sort(Comparator.comparing(Dogs::getName).thenComparing(Dogs::getAge));
out.println("Name " + " Age");
for(Dogs d : dogs){
out.println(d.getName() + " " + d.getAge());
}
我们的输出如下:
Name Age
Ginger 7
Kylie 7
Penny 7
Roxie 10
Shorty 14
Zoey 8
现在我们将交换比较的顺序。请注意,在这个版本中,狗的年龄优先于名字:
dogs.sort(Comparator.comparing(Dogs::getAge).thenComparing(Dogs::getName));
out.println("Name " + " Age");
for(Dogs d : dogs){
out.println(d.getName() + " " + d.getAge());
}
我们的输出是:
Name Age
Ginger 7
Kylie 7
Penny 7
Zoey 8
Roxie 10
Shorty 14
数据验证
数据验证是数据科学的重要组成部分。在我们能够分析和操作数据之前,我们需要验证数据是预期的类型。我们已经将代码组织成简单的方法,用于完成非常基本的验证任务。这些方法中的代码可以适用于现有的应用程序。
验证数据类型
有时我们只需要验证一段数据是否属于特定类型,比如整数或浮点数据。我们将在下一个例子中演示如何使用validateIn
t 方法验证整数数据。对于标准 Java 库中支持的其他主要数据类型,包括Float
和Double
,这种技术很容易修改。
我们需要在这里使用一个 try-catch 块来捕捉一个NumberFormatException
。如果抛出异常,我们知道我们的数据不是有效的整数。我们首先将待测试的文本传递给Integer
类的parseInt
方法。如果文本可以被解析为一个整数,我们只需打印出这个整数。如果抛出一个异常,我们会显示相应的信息:
public static void validateInt(String toValidate){
try{
int validInt = Integer.parseInt(toValidate);
out.println(validInt + " is a valid integer");
}catch(NumberFormatException e){
out.println(toValidate + " is not a valid integer");
}
我们将使用以下方法调用来测试我们的方法:
validateInt("1234");
validateInt("Ishmael");
输出如下:
1234 is a valid integer
Ishmael is not a valid integer
Apache Commons 包含一个具有额外有用功能的IntegerValidator
类。在第一个例子中,我们简单地重复之前的过程,但是使用IntegerValidator
方法来实现我们的目标:
public static String validateInt(String text){
IntegerValidator intValidator = IntegerValidator.getInstance();
if(intValidator.isValid(text)){
return text + " is a valid integer";
}else{
return text + " is not a valid integer";
}
}
我们再次使用下面的方法调用来测试我们的方法:
validateInt("1234");
validateInt("Ishmael");
输出如下:
1234 is a valid integer
Ishmael is not a valid integer
IntegerValidator
类还提供了一些方法来确定一个整数是大于还是小于一个特定值,将该数字与一组数字进行比较,并将Number
对象转换为Integer
对象。Apache Commons 有许多其他的验证器类。我们将在本节的剩余部分研究更多的内容。
验证日期
很多时候,我们的数据验证比简单地确定一段数据是否是正确的类型更复杂。例如,当我们想要验证数据是一个日期时,仅仅验证它是由整数组成的是不够的。我们可能需要包括连字符和斜线,或者确保年份是两位数或四位数的格式。
为此,我们创建了另一个简单的方法validateDate
。该方法有两个String
参数,一个保存要验证的日期,另一个保存可接受的日期格式。我们使用参数中指定的格式创建了一个SimpleDateFormat
类的实例。然后我们调用parse
方法将我们的String
日期转换成一个Date
对象。就像我们前面的整数示例一样,如果数据不能被解析为日期,就会抛出一个异常,方法返回。但是,如果可以将String
解析为日期,我们只需将测试日期的格式与我们可接受的格式进行比较,以确定日期是否有效:
public static String validateDate(String theDate, String dateFormat){
try {
SimpleDateFormat format = new SimpleDateFormat(dateFormat);
Date test = format.parse(theDate);
if(format.format(test).equals(theDate)){
return theDate.toString() + " is a valid date";
}else{
return theDate.toString() + " is not a valid date";
}
} catch (ParseException e) {
return theDate.toString() + " is not a valid date";
}
}
我们进行以下方法调用来测试我们的方法:
String dateFormat = "MM/dd/yyyy";
out.println(validateDate("12/12/1982",dateFormat));
out.println(validateDate("12/12/82",dateFormat));
out.println(validateDate("Ishmael",dateFormat));
输出如下:
12/12/1982 is a valid date
12/12/82 is not a valid date
Ishmael is not a valid date
这个例子强调了考虑对数据的限制的重要性。我们的第二个方法调用包含一个合法的日期,但是它不是我们指定的格式。如果我们正在寻找非常特殊格式的数据,这是很好的。但是,如果我们在验证中过于严格,我们也有遗漏有用数据的风险。
验证电子邮件地址
还经常需要验证电子邮件地址。虽然大多数电子邮件地址都有@
符号,并要求符号后至少有一个句点,但也有许多变体。请考虑以下每个示例都可以是有效的电子邮件地址:
myemail@mail.com
MyEmail@some.mail.com
My.Email.123!@mail.net
一种选择是使用正则表达式来尝试捕获所有允许的电子邮件地址。请注意,下面的方法中使用的正则表达式非常长且复杂。这很容易出错,错过有效的电子邮件地址,或者将无效地址视为有效地址。但是一个精心制作的正则表达式可能是一个非常强大的工具。
我们使用Pattern
和Matcher
类来编译和执行我们的正则表达式。如果我们传入的电子邮件模式与我们定义的正则表达式匹配,我们将认为该文本是有效的电子邮件地址:
public static String validateEmail(String email) {
String emailRegex = "^[a-zA-Z0-9.!$'*+/=?^_`{|}~-" +
"]+@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\." +
"[0-9]{1,3}\\])|(([a-zAZ\\-0-9]+\\.)+[a-zA-Z]{2,}))$";
Pattern.compile(emailRegex);
Matcher matcher = pattern.matcher(email);
if(matcher.matches()){
return email + " is a valid email address";
}else{
return email + " is not a valid email address";
}
}
我们调用以下方法来测试我们的数据:
out.println(validateEmail("myemail@mail.com"));
out.println(validateEmail("My.Email.123!@mail.net"));
out.println(validateEmail("myEmail"));
输出如下:
myemail@mail.com is a valid email address
My.Email.123!@mail.net is a valid email address
myEmail is not a valid email address
还有一个用于验证电子邮件地址的标准 Java 库。在这个例子中,我们使用InternetAddress
类来验证给定的字符串是否是有效的电子邮件地址:
public static String validateEmailStandard(String email){
try{
InternetAddress testEmail = new InternetAddress(email);
testEmail.validate();
return email + " is a valid email address";
}catch(AddressException e){
return email + " is not a valid email address";
}
}
当使用与上例相同的数据进行测试时,我们的输出是相同的。但是,请考虑下面的方法调用:
out.println(validateEmailStandard("myEmail@mail"));
尽管不是标准的电子邮件格式,但输出如下:
myEmail@mail is a valid email address
此外,validate
方法默认接受本地电子邮件地址作为有效地址。根据数据的用途,这并不总是可取的。
我们要看的最后一个选项是 Apache Commons EmailValidator
类。这个类的isValid
方法检查一个电子邮件地址,并确定它是否有效。我们之前展示的validateEmail
方法修改如下以使用EmailValidator
:
public static String validateEmailApache(String email){
email = email.trim();
EmailValidator eValidator = EmailValidator.getInstance();
if(eValidator.isValid(email)){
return email + " is a valid email address.";
}else{
return email + " is not a valid email address.";
}
}
验证邮政编码
邮政编码通常根据其所在国家或当地的要求进行格式化。因此,正则表达式非常有用,因为它们可以适应任何所需的邮政编码。下面的示例演示了如何验证标准的美国邮政编码,包括或不包括连字符和最后四位数字:
public static void validateZip(String zip){
String zipRegex = "^[0-9]{5}(?:-[0-9]{4})?$";
Pattern pattern = Pattern.compile(zipRegex);
Matcher matcher = pattern.matcher(zip);
if(matcher.matches()){
out.println(zip + " is a valid zip code");
}else{
out.println(zip + " is not a valid zip code");
}
}
我们调用以下方法来测试我们的数据:
out.println(validateZip("12345"));
out.println(validateZip("12345-6789"));
out.println(validateZip("123"));
输出如下:
12345 is a valid zip code
12345-6789 is a valid zip code
123 is not a valid zip code
验证姓名
名称可能特别难以验证,因为有太多的变体。除了键盘上可用的字符之外,没有行业标准或技术限制。对于这个例子,我们选择在正则表达式中使用 Unicode,因为它允许我们匹配任何语言的任何字符。Unicode 属性\\p{L}
提供了这种灵活性。我们还使用 \\s-'
,允许名称字段中有空格、撇号、逗号和连字符。在尝试匹配名称之前,可以执行字符串清理,如本章前面所讨论的。这将简化所需的正则表达式:
public static void validateName(String name){
String nameRegex = "^[\\p{L}\\s-',]+$";
Pattern pattern = Pattern.compile(nameRegex);
Matcher matcher = pattern.matcher(name);
if(matcher.matches()){
out.println(name + " is a valid name");
}else{
out.println(name + " is not a valid name");
}
}
我们调用以下方法来测试我们的数据:
validateName("Bobby Smith, Jr.");
validateName("Bobby Smith the 4th");
validateName("Albrecht Müller");
validateName("François Moreau");
输出如下:
Bobby Smith, Jr. is a valid name
Bobby Smith the 4th is not a valid name
Albrecht Müller is a valid name
François Moreau is a valid name
注意,Bobby Smith, Jr.
中的逗号和句号是可以接受的,但是4th
中的4
是不可以接受的。另外,François
和Müller
中的特殊字符被认为是有效的。
清洗图像
虽然图像处理是一项复杂的任务,我们将介绍一些技术来清理和提取图像中的信息。这将使读者对图像处理有所了解。我们还将演示如何使用光学字符识别(OCR)从图像中提取文本数据。
有几种技术可用于提高图像质量。其中许多需要调整参数来获得期望的改善。我们将演示如何:
-
增强图像的对比度
-
平滑图像
-
使图像变亮
-
调整图像大小
-
将图像转换为不同的格式
我们将使用 OpenCV(http://opencv.org/),一个用于图像处理的开源项目。我们将使用几个类:
Mat
:这是一个保存图像数据的 n 维数组,比如通道、灰度或颜色值- 拥有许多处理图像的方法
Imgcodecs
:拥有读写图像文件的方法
OpenCV Javadocs 位于http://docs.opencv.org/java/2.4.9/。在下面的例子中,我们将使用维基百科的图片,因为它们可以免费下载。具体来说,我们将使用以下图像:
- 鹦鹉图片:https://en . Wikipedia . org/wiki/gray #/media/File:gray _ 8 bits _ palette _ sample _ image . png
- 猫咪形象:https://en . Wikipedia . org/wiki/Cat #/media/File:kitty ply _ edit 1 . jpg
改变图像的对比度
这里我们将演示如何增强鹦鹉的黑白图像。Imgcodecs
类的imread
方法读入图像。它的第二个参数指定图像使用的颜色类型,在本例中是灰度。使用与原始图像相同的大小和颜色类型为增强图像创建一个新的Mat
对象。
实际工作由equalizeHist
方法执行。这均衡了图像的直方图,具有归一化亮度的效果,并增加了图像的对比度。图像直方图是表示图像色调分布的直方图。色调又称明度。它表示图像中亮度的变化。
最后一步是写出图像。
Mat source = Imgcodecs.imread("GrayScaleParrot.png",
Imgcodecs.CV_LOAD_IMAGE_GRAYSCALE);
Mat destination = new Mat(source.rows(), source.cols(), source.type());
Imgproc.equalizeHist(source, destination);
Imgcodecs.imwrite("enhancedParrot.jpg", destination);
以下是原图:
增强图像如下:
平滑图像
平滑图像,也称为模糊,会使图像的边缘更加平滑。模糊是使图像变得不那么清晰的过程。当我们在相机失焦的情况下拍照时,我们能识别模糊的物体。模糊可以用于特殊效果。在这里,我们将使用它来创建一个图像,然后我们将锐化。
下面的示例加载一幅猫的图像,并对该图像重复应用blur
方法。在该示例中,该过程重复了25
次。增加迭代次数将导致更多的模糊或平滑。
模糊方法的第三个参数是模糊核的大小。内核是一个像素矩阵,在本例中为 3×3,用于卷积。这是将图像的每个元素乘以其邻居的加权值的过程。这允许相邻值影响元素的值:
Mat source = Imgcodecs.imread("cat.jpg");
Mat destination = source.clone();
for (int i = 0; i < 25; i++) {
Mat sourceImage = destination.clone();
Imgproc.blur(sourceImage, destination, new Size(3.0, 3.0));
}
Imgcodecs.imwrite("smoothCat.jpg", destination);
以下是原图:
增强图像如下:
使图像变亮
convertTo
方法提供了一种使图像变亮的方法。原始图像被复制到新图像,在新图像中对比度和亮度被调整。第一个参数是目标图像。第二个指定不应该更改图像的类型。第三和第四个参数分别控制对比度和亮度。第一个值与此值相乘,第二个值与相乘后的值相加:
Mat source = Imgcodecs.imread("cat.jpg");
Mat destination = new Mat(source.rows(), source.cols(),
source.type());
source.convertTo(destination, -1, 1, 50);
Imgcodecs.imwrite("brighterCat.jpg", destination);
增强图像如下:
调整图像大小
有时需要调整图像的大小。接下来显示的resize
方法说明了这是如何实现的。读入图像并创建一个新的Mat
对象。然后应用resize
方法,在Size
对象参数中指定宽度和高度。然后保存调整后的图像:
Mat source = Imgcodecs.imread("cat.jpg");
Mat resizeimage = new Mat();
Imgproc.resize(source, resizeimage, new Size(250, 250));
Imgcodecs.imwrite("resizedCat.jpg", resizeimage);
增强图像如下:
将图像转换成不同的格式
另一个常见的操作是将使用一种格式的图像转换为使用不同格式的图像。在 OpenCV 中,这很容易实现,如下所示。图像被读入,然后立即被写出。imwrite
方法使用文件的扩展名将图像转换成新格式:
Mat source = Imgcodecs.imread("cat.jpg");
Imgcodecs.imwrite("convertedCat.jpg", source);
Imgcodecs.imwrite("convertedCat.jpeg", source);
Imgcodecs.imwrite("convertedCat.webp", source);
Imgcodecs.imwrite("convertedCat.png", source);
Imgcodecs.imwrite("convertedCat.tiff", source);
如果需要,这些图像现在可以用于专门的处理。
总结
很多时候,数据科学中一半的战斗是操纵数据,使它足够干净,可以使用。在这一章中,我们考察了许多获取真实世界中杂乱数据并将其转换成可用数据集的技术。这个过程通常被称为数据清理、争论、整形或蒙戈。我们的重点是核心 Java 技术,但是我们也研究了第三方库。
在清理数据之前,我们需要对数据的格式有一个坚实的理解。我们讨论了 CSV 数据、电子表格、PDF 和 JSON 文件类型,并提供了几个操作文本文件数据的例子。当我们检查文本数据时,我们查看了处理数据的多种方法,包括标记化器、Scanners
和BufferedReaders
。我们展示了执行简单清理操作、删除停用词以及执行查找和替换功能的方法。
本章还讨论了数据估算以及确定和纠正缺失数据情况的重要性。缺失数据会在数据分析过程中引起问题,我们提出了不同的方法来处理这个问题。我们演示了如何检索数据子集以及对数据进行排序。
最后,我们讨论了图像清理,并演示了几种修改图像数据的方法。这包括改变对比度、平滑、增亮和调整信息大小。最后,我们讨论了如何提取图像上的文字。
有了这个背景,我们将在下一章介绍基本的统计方法及其 Java 支持。
四、数据可视化
人类的大脑通常善于在视觉表现中看到模式、趋势和异常值。许多数据科学问题中存在的大量数据可以使用可视化技术进行分析。可视化适合广泛的受众,从分析师到高层管理人员,再到客户。在这一章中,我们将介绍各种可视化技术,并演示 Java 是如何支持这些技术的。
在本章中,我们将说明如何创建不同类型的图形、绘图和图表。大多数例子使用 JavaFX,少数使用名为GRAphing Library(GRAL)的免费库。有几个开源的 Java 绘图库可用。在 https://github.com/eseifert/gral/wiki/comparison 的可以找到这些图书馆的简要对比。我们选择 JavaFX 是因为它被打包成 Java SE 的一部分。
GRAL 用于说明使用 JavaFX 不容易创建的图。GRAL 是一个免费的 Java 库,用于创建各种图表和图形。这个图形库在绘图类型、轴格式和导出选项方面提供了灵活性。http://trac.erichseifert.de/gral/的 GRAL 资源包括示例代码和有用的操作部分。
可视化是数据分析中的一个重要步骤,因为它允许我们以实用和有意义的方式构想大型数据集。我们可以查看小数据集的值,也许可以从我们看到的模式中得出结论,但这是一个势不可挡且不可靠的过程。使用可视化工具有助于我们识别潜在的问题或意想不到的数据结果,以及构建对好数据的有意义的解释。
数据可视化有用性的一个例子是异常值的出现。可视化数据使我们能够快速看到大大超出我们预期的数据结果,并且我们可以选择如何修改数据来构建干净可用的数据集。这个过程允许我们快速发现错误,并在它们成为问题之前处理它们。此外,可视化使我们能够轻松地对信息进行分类,并帮助分析师以最适合其特定数据集的方式组织他们的查询。
理解图形和图表
有许多类型的视觉表达可用于帮助可视化。我们将简要讨论最常见和最有用的表达式,然后演示几种实现这些表达式类型的 Java 技术。图形或其他可视化工具的选择将取决于数据集和应用程序的需求和约束。
一个条形图是一种非常常见的显示数据关系的技术。在这种类型的图表中,数据用沿 X 和 Y 轴放置的垂直或水平条来表示。数据经过缩放,因此每个条形代表的值可以相互比较。下面是一个简单的条形图示例,我们将在中使用国家作为类别部分创建:
当你想要展示一个与更大的集合相关的值时,一个饼图是最有用的。可以把这想象成一种方式,来想象一块饼相对于整个饼有多大。以下是一个简单的饼图示例,显示了选定欧洲国家的人口分布情况:
时间序列图是一种特殊类型的图表,用于显示与时间相关的值。当数据分析需要了解数据在一段时间内是如何变化的时,这是最合适的。在这些图中,纵轴对应于数值,横轴对应于特定的时间点。特别是,这种类型的图表对于识别不同时间的趋势,或者暗示给定时间段内数据值和特定事件之间的相关性非常有用。
例如,股票价格和房屋价格会变化,但是它们的变化率不同。污染水平和犯罪率也会随着时间而变化。有几种技术可以将这种类型的数据可视化。通常,特定的值没有它们随时间变化的趋势重要。
一个指标图也叫折线图。折线图使用 X 和 Y 轴在网格上绘制点。它们可以用来表示时间序列数据。这些点由线连接,这些线用于同时比较多个数据的值。这种比较通常通过沿着 X 轴绘制独立变量(如时间)以及沿着 Y 轴绘制独立变量(如频率或百分比)来实现。
以下是一个简单的指数图表示例,显示了选定欧洲国家的人口分布情况:
当我们希望以紧凑和有用的方式排列大量数据时,我们可以选择茎和叶图。这种类型的可视化表达式允许您以可读的方式演示一个值与多个值的相关性。茎是指一个数据值,叶是对应的数据点。一个常见的例子是火车时刻表。下表列出了列车的发车时间:
| 06:15
| 06 :20
| 06:25
| 06:30
|
| 06:40
| 06:45
| 06:55
| 07:15
|
| 07:20
| 07:25
| 07:30
| 07:40
|
| 07:45
| 07:55
| 08:00
| 08:12
|
| 08:24
| 08:36
| 08:48
| 09:00
|
| 09:12
| 09:24
| 09:36
| 09:48
|
| 10:00
| 10:12
| 10:24
| 10:36
|
| 10:48
| | | |
然而,这个表可能很难阅读。相反,在下面的部分茎和叶图中,茎代表火车可能出发的小时,而叶代表每小时内的分钟:
| 小时 | 分钟 |
| 06 | :15 :20 :25 :30 :40 :45 :55 |
| 07 | :15 :20 :25 :30 :40 :45 :55 |
| 08 | :00 :12 :24 :36 :48 |
| 09 | :00 :12 :24 :36 :48 |
| 10 | :00 :12 :24 :36 :48 |
这更容易阅读和处理。
统计分析中一种非常流行的可视化形式是直方图。直方图允许您使用条形显示数据中的频率,类似于条形图。主要区别在于直方图用于识别数据集中的频率和趋势,而条形图用于比较数据集中的特定数据值。以下是我们将在创建直方图部分创建的直方图示例:
一个散点图仅仅是点的集合,分析技术,如相关或回归,可以用来识别这些类型的图表中的趋势。在下面的散点图中,正如在创建散点图中开发的,沿着 X 轴的人口相对于沿着 Y 轴的十年被绘制:
视觉分析目标
每种类型的视觉表达都适用于不同类型的数据和数据分析目的。数据分析的一个常见目的是数据分类。这包括确定特定数据值属于数据集中的哪个子集。这个过程可能发生在数据分析过程的早期,因为将数据分成可管理的和相关的片段简化了分析过程。通常,分类不是最终目标,而是进行进一步分析之前的一个重要的中间步骤。
回归分析是一种复杂而重要的数据分析形式。它包括研究自变量和因变量以及多个自变量之间的关系。这种类型的统计分析允许分析师确定可接受值或期望值的范围,并确定单个值如何适合更大的数据集。回归分析是机器学习的一个重要部分,我们将在第五章、统计数据分析技术中详细讨论。
聚类允许我们识别特定集合或类别中的数据点组。分类将数据分类到相似类型的数据集,而聚类则关注数据集中的数据。例如,我们可能有一个包含世界上所有猫科动物的大型数据集。然后,我们可以将这些猫科动物分为两组,豹亚科(包含大多数大型猫科动物)和猫亚科(所有其他猫科动物)。聚类包括在这些分类中的一个分类内对相似猫的子集进行分组。例如,所有的老虎都可能是豹亚科中的一个集群。
有时,我们的数据分析需要我们从数据集中提取特定类型的信息。选择要提取的数据的过程被称为属性选择或特征选择。这一过程有助于分析师简化数据模型,并使我们能够解决数据集中冗余或不相关信息的问题。
通过对基本绘图和图表类型的介绍,我们将讨论 Java 对创建这些绘图和图表的支持。
创建索引图表
指数图是一种折线图,显示某事物随时间变化的百分比。通常,这样的图表基于单个数据属性。在下面的例子中,我们将使用 60 年的比利时人口。该数据是在https://ourworldindata.org/grapher/population-by-country?发现的人口数据的子集 tab =数据:
| 十年 | 人口 |
| One thousand nine hundred and fifty | Eight million six hundred and thirty-nine thousand three hundred and sixty-nine |
| One thousand nine hundred and sixty | Nine million one hundred and eighteen thousand seven hundred |
| One thousand nine hundred and seventy | Nine million six hundred and thirty-seven thousand eight hundred |
| One thousand nine hundred and eighty | Nine million eight hundred and forty-six thousand eight hundred |
| One thousand nine hundred and ninety | Nine million nine hundred and sixty-nine thousand three hundred and ten |
| Two thousand | Ten million two hundred and sixty-three thousand six hundred and eighteen |
我们从创建扩展了Application
的MainApp
类开始。我们创建一系列实例变量。XYChart.Series
类代表某个图的一系列数据点。在我们的例子中,这将是几十年和人口,我们将很快初始化。下一个声明是针对CategoryAxis
和NumberAxis
实例的。这些分别代表 X 和 Y 轴。Y 轴的声明包括总体的范围和增量值。这使得图表更具可读性。最后一个声明是国家的字符串变量:
public class MainApp extends Application {
final XYChart.Series<String, Number> series =
new XYChart.Series<>();
final CategoryAxis xAxis = new CategoryAxis();
final NumberAxis yAxis =
new NumberAxis(8000000, 11000000, 1000000);
final static String belgium = "Belgium";
...
}
在 JavaFX 中,main
方法通常使用基类launch
方法启动应用程序。最终,调用了start
方法,我们覆盖了它。在这个例子中,我们调用创建用户界面的simpleLineChart
方法:
public static void main(String[] args) {
launch(args);
}
public void start(Stage stage) {
simpleIndexChart (stage);
}
simpleLineChart
跟在后面,并被传递了一个Stage
类的实例。这表示应用程序窗口的客户区。我们首先为应用程序和折线图设置一个标题。 Y 轴的标签设置完毕。使用 X 和 Y 轴实例初始化LineChart
类的实例。此类表示折线图:
public void simpleIndexChart (Stage stage) {
stage.setTitle("Index Chart");
lineChart.setTitle("Belgium Population");
yAxis.setLabel("Population");
final LineChart<String, Number> lineChart
= new LineChart<>(xAxis, yAxis);
...
}
给该系列一个名称,然后使用addDataItem
辅助方法将每十年的人口添加到该系列中:
series.setName("Population");
addDataItem(series, "1950", 8639369);
addDataItem(series, "1960", 9118700);
addDataItem(series, "1970", 9637800);
addDataItem(series, "1980", 9846800);
addDataItem(series, "1990", 9969310);
addDataItem(series, "2000", 10263618);
接下来是addDataItem
方法,它使用传递给它的String
和Number
值创建一个XYChart.Data
类实例。然后,它将实例添加到系列中:
public void addDataItem(XYChart.Series<String, Number> series,
String x, Number y) {
series.getData().add(new XYChart.Data<>(x, y));
}
simpleLineChart
方法的最后一部分创建了一个代表stage
内容的Scene
类实例。JavaFX 使用舞台和场景的概念来处理应用程序 GUI 的内部。
使用折线图创建scene
,应用程序的大小通过600
像素设置为800
。然后将系列添加到折线图中,并将scene
添加到stage
。show
方法显示应用程序:
Scene scene = new Scene(lineChart, 800, 600);
lineChart.getData().add(series);
stage.setScene(scene);
stage.show();
当应用程序执行时,将显示以下窗口:
创建条形图
条形图使用两个带矩形条的轴,可以垂直或水平放置。条形的长度与它所代表的数值成正比。条形图可用于显示时间序列数据。
在下面的一系列示例中,我们将使用一组欧洲国家三十年的人口,如下表所示。该数据是在https://ourworldindata.org/grapher/population-by-country?发现的人口数据的子集 tab =数据:
| 国家 | 1950 年 | 1960 年 | 1970 年 |
| 比利时 | Eight million six hundred and thirty-nine thousand three hundred and sixty-nine | Nine million one hundred and eighteen thousand seven hundred | Nine million six hundred and thirty-seven thousand eight hundred |
| 法国 | Forty-two million five hundred and eighteen thousand | Forty-six million five hundred and eighty-four thousand | Fifty-one million nine hundred and eighteen thousand |
| 德国 | Sixty-eight million three hundred and seventy-four thousand five hundred and seventy-two | Seventy-two million four hundred and eighty thousand eight hundred and sixty-nine | Seventy-seven million seven hundred and eighty-three thousand one hundred and sixty-four |
| 荷兰 | Ten million one hundred and thirteen thousand five hundred and twenty-seven | Eleven million four hundred and eighty-six thousand | Thirteen million thirty-two thousand three hundred and thirty-five |
| 瑞典 | Seven million fourteen thousand and five | Seven million four hundred and eighty thousand three hundred and ninety-five | Eight million forty-two thousand eight hundred and three |
| 联合王国 | Fifty million one hundred and twenty-seven thousand | Fifty-two million three hundred and seventy-two thousand | Fifty-five million six hundred and thirty-two thousand |
三个条形图中的第一个将使用 JavaFX 构建。我们从一系列国家声明开始,作为扩展Application
类的一部分:
public class MainApp extends Application {
final static String belgium = "Belgium";
final static String france = "France";
final static String germany = "Germany";
final static String netherlands = "Netherlands";
final static String sweden = "Sweden";
final static String unitedKingdom = "United Kingdom";
...
}
接下来,我们声明了一系列表示图形各部分的实例变量。第一个是CategoryAxis
和NumberAxis
实例:
final CategoryAxis xAxis = new CategoryAxis();
final NumberAxis yAxis = new NumberAxis();
人口和国家数据存储在一系列XYChart.Series
实例中。这里,我们声明了六个不同的系列,它们使用了一个字符串和数字对。第一个示例没有使用所有六个系列,但后面的示例会使用。我们首先将一个国家字符串及其相应的人口分配给三个系列。这些序列将代表未来几十年1950
、1960
和1970
的人口:
final XYChart.Series<String, Number> series1 =
new XYChart.Series<>();
final XYChart.Series<String, Number> series2
new XYChart.Series<>();
final XYChart.Series<String, Number> series3 =
new XYChart.Series<>();
final XYChart.Series<String, Number> series4 =
new XYChart.Series<>();
final XYChart.Series<String, Number> series5 =
new XYChart.Series<>();
final XYChart.Series<String, Number> series6 =
new XYChart.Series<>();
我们将从两个简单的条形图开始。第一个将在类别中显示国家,在该类别中, X 轴显示年份变化,在 Y 轴显示人口。第二个将几十年显示为包含县的类别。最后一个例子是堆积条形图。
使用国家作为类别
条形图的元素在simpleBarChartByCountry
方法中设置。设置图表的标题,并使用两个轴创建一个BarChart
类实例。该图表及其 X 轴和 Y 轴也有在此初始化的标签:
public void simpleBarChartByCountry(Stage stage) {
stage.setTitle("Bar Chart");
final BarChart<String, Number> barChart
= new BarChart<>(xAxis, yAxis);
barChart.setTitle("Country Summary");
xAxis.setLabel("Country");
yAxis.setLabel("Population");
...
}
接下来,用一个名称初始化前三个系列,然后是该系列的国家和人口数据。上一节中介绍的助手方法addDataItem
用于向每个系列添加数据:
series1.setName("1950");
addDataItem(series1,belgium, 8639369);
addDataItem(series1,france, 42518000);
addDataItem(series1,germany, 68374572);
addDataItem(series1,netherlands, 10113527);
addDataItem(series1,sweden, 7014005);
addDataItem(series1,unitedKingdom, 50127000);
series2.setName("1960");
addDataItem(series2,belgium, 9118700);
addDataItem(series2,france, 46584000);
addDataItem(series2,germany, 72480869);
addDataItem(series2,netherlands, 11486000);
addDataItem(series2,sweden, 7480395);
addDataItem(series2,unitedKingdom, 52372000);
series3.setName("1970");
addDataItem(series3,belgium, 9637800);
addDataItem(series3,france, 51918000);
addDataItem(series3,germany, 77783164);
addDataItem(series3,netherlands, 13032335);
addDataItem(series3,sweden, 8042803);
addDataItem(series3,unitedKingdom, 55632000);
该方法的最后一部分创建了一个scene
实例。三个系列被添加到scene
上,并且使用setScene
方法将scene
连接到stage
上。一个stage
是一个本质上代表窗口客户区的类:
Scene scene = new Scene(barChart, 800, 600);
barChart.getData().addAll(series1, series2, series3);
stage.setScene(scene);
stage.show();
两个方法中的最后一个是start
方法,当窗口显示时自动调用。它被传递给Stage
实例。在这里,我们称之为simpleBarChartByCountry
法:
public void start(Stage stage) {
simpleBarChartByCountry(stage);
}
main
方法由对Application
类的launch
方法的调用组成:
public static void main(String[] args) {
launch(args);
}
执行应用程序时,会显示以下图形:
以十年为范畴
在下面的例子中,我们将演示如何显示相同的信息,但是我们将按年份组织 X 轴类别。我们将使用simpleBarChartByYear
方法,如下所示。轴和标题的设置方式与之前相同,但标题和标签的值不同:
public void simpleBarChartByYear(Stage stage) {
stage.setTitle("Bar Chart");
final BarChart<String, Number> barChart
= new BarChart<>(xAxis, yAxis);
barChart.setTitle("Year Summary");
xAxis.setLabel("Year");
yAxis.setLabel("Population");
...
}
以下字符串变量被声明为三十年:
String year1950 = "1950";
String year1960 = "1960";
String year1970 = "1970";
数据系列的创建方式与以前相同,只是国家名称用于系列名称,年份用于类别。此外,还使用六个系列,每个国家一个系列:
series1.setName(belgium);
addDataItem(series1, year1950, 8639369);
addDataItem(series1, year1960, 9118700);
addDataItem(series1, year1970, 9637800);
series2.setName(france);
addDataItem(series2, year1950, 42518000);
addDataItem(series2, year1960, 46584000);
addDataItem(series2, year1970, 51918000);
series3.setName(germany);
addDataItem(series3, year1950, 68374572);
addDataItem(series3, year1960, 72480869);
addDataItem(series3, year1970, 77783164);
series4.setName(netherlands);
addDataItem(series4, year1950, 10113527);
addDataItem(series4, year1960, 11486000);
addDataItem(series4, year1970, 13032335);
series5.setName(sweden);
addDataItem(series5, year1950, 7014005);
addDataItem(series5, year1960, 7480395);
addDataItem(series5, year1970, 8042803);
series6.setName(unitedKingdom);
addDataItem(series6, year1950, 50127000);
addDataItem(series6, year1960, 52372000);
addDataItem(series6, year1970, 55632000);
scene
被创建并附加到stage
:
Scene scene = new Scene(barChart, 800, 600);
barChart.getData().addAll(series1, series2,
series3, series4, series5, series6);
stage.setScene(scene);
stage.show();
main
方法没有改变,但是start
方法调用了simpleBarChartByYear
方法:
public void start(Stage stage) {
simpleBarChartByYear(stage);
}
执行应用程序时,会显示以下图形:
创建堆叠图
面积图通过为较大的值分配更多的空间来描述信息。通过将面积图堆叠在一起,我们创建了一个堆叠图,有时称为流图。但是,堆积图不能很好地处理负值,也不能用于求和没有意义的数据,例如温度。如果堆叠了太多图表,那么解释起来会变得很困难。
接下来,我们将展示如何创建堆叠条形图。stackedGraphExample
方法包含创建条形图的代码。我们从熟悉的代码开始设置标题和标签。但是,对于 X 轴,setCategories
方法FXCollections
。<String>observableArrayList
实例用于设置类别。这个构造函数的参数是由Arrays
类的asList
方法创建的字符串数组和国家名称:
public void stackedGraphExample(Stage stage) {
stage.setTitle("Stacked Bar Chart");
final StackedBarChart<String, Number> stackedBarChart
= new StackedBarChart<>(xAxis, yAxis);
stackedBarChart.setTitle("Country Population");
xAxis.setLabel("Country");
xAxis.setCategories(
FXCollections.<String>observableArrayList(
Arrays.asList(belgium, germany, france,
netherlands, sweden, unitedKingdom)));
yAxis.setLabel("Population");
...
}
使用年份作为系列名称和国家对系列进行初始化,并使用 helper 方法addDataItem
添加它们的人口。然后创建了scene
:
series1.setName("1950");
addDataItem(series1, belgium, 8639369);
addDataItem(series1, france, 42518000);
addDataItem(series1, germany, 68374572);
addDataItem(series1, netherlands, 10113527);
addDataItem(series1, sweden, 7014005);
addDataItem(series1, unitedKingdom, 50127000);
series2.setName("1960");
addDataItem(series2, belgium, 9118700);
addDataItem(series2, france, 46584000);
addDataItem(series2, germany, 72480869);
addDataItem(series2, netherlands, 11486000);
addDataItem(series2, sweden, 7480395);
addDataItem(series2, unitedKingdom, 52372000);
series3.setName("1970");
addDataItem(series3, belgium, 9637800);
addDataItem(series3, france, 51918000);
addDataItem(series3, germany, 77783164);
addDataItem(series3, netherlands, 13032335);
addDataItem(series3, sweden, 8042803);
addDataItem(series3, unitedKingdom, 55632000);
Scene scene = new Scene(stackedBarChart, 800, 600);
stackedBarChart.getData().addAll(series1, series2, series3);
stage.setScene(scene);
stage.show();
main
方法没有改变,但是start
方法调用了stackedGraphExample
方法:
public void start(Stage stage) {
stackedGraphExample(stage);
}
执行应用程序时,会显示以下图形:
创建饼图
以下饼图示例基于 2000 年选定欧洲国家的人口,如下所示:
| 国家 | 人口 | 百分比 |
| 比利时 | Ten million two hundred and sixty-three thousand six hundred and eighteen | three |
| 法国 | Sixty-one million one hundred and thirty-seven thousand | Twenty-six |
| 德国 | Eighty-two million one hundred and eighty-seven thousand nine hundred and nine | Thirty-five |
| 荷兰 | Fifteen million nine hundred and seven thousand eight hundred and fifty-three | seven |
| 瑞典 | Eight million eight hundred and seventy-two thousand | four |
| 联合王国 | Fifty-nine million five hundred and twenty-two thousand four hundred and sixty-eight | Twenty-five |
JavaFX 实现使用与前面示例中相同的Application
基类和main
方法。我们不会使用单独的方法来创建 GUI,而是将这段代码放在start
方法中,如下所示:
public class PieChartSample extends Application {
public void start(Stage stage) {
Scene scene = new Scene(new Group());
stage.setTitle("Europian Country Population");
stage.setWidth(500);
stage.setHeight(500);
...
}
public static void main(String[] args) {
launch(args);
}
}
饼图由PieChart
类表示。我们可以使用饼图数据的ObservableList
在构造函数中创建并初始化饼图。该数据由一系列PieChart.Data
实例组成,每个实例包含一个文本标签和一个百分比值。
下一个序列基于前面给出的欧洲人口数据创建了一个ObservableList
实例。FXCollections
类的observableArrayList
方法返回一个带有饼图数据列表的ObservableList
实例:
ObservableList<PieChart.Data> pieChartData =
FXCollections.observableArrayList(
new PieChart.Data("Belgium", 3),
new PieChart.Data("France", 26),
new PieChart.Data("Germany", 35),
new PieChart.Data("Netherlands", 7),
new PieChart.Data("Sweden", 4),
new PieChart.Data("United Kingdom", 25));
然后,我们创建饼图并设置其标题。然后饼状图被添加到scene
,scene
与stage
相关联,然后显示窗口:
final PieChart pieChart = new PieChart(pieChartData);
pieChart.setTitle("Country Population");
((Group) scene.getRoot()).getChildren().add(pieChart);
stage.setScene(scene);
stage.show();
执行应用程序时,会显示以下图形:
创建散点图
散点图也使用 JavaFX 中的XYChart.Series
类。在这个例子中,我们将使用一组欧洲数据,其中包括 1500 年到 2000 年这几十年中以前的欧洲国家及其人口数据。这些信息存储在一个名为EuropeanScatterData.csv
的文件中。该文件的第一部分如下所示:
1500 1400000
1600 1600000
1650 1500000
1700 2000000
1750 2250000
1800 3250000
1820 3434000
1830 3750000
1840 4080000
...
我们从 JavaFX MainApp
类的声明开始,如下所示。main
方法启动应用程序,start
方法创建用户界面:
public class MainApp extends Application {
@Override
public void start(Stage stage) throws Exception {
...
}
public static void main(String[] args) {
launch(args);
}
}
在start
方法中,我们设置标题,创建轴,并创建代表散点图的ScatterChart
的实例。NumberAxis
类的构造函数使用的值比其默认构造函数使用的默认值更匹配数据范围:
stage.setTitle("Scatter Chart Sample");
final NumberAxis yAxis = new NumberAxis(1400, 2100, 100);
final NumberAxis xAxis = new NumberAxis(500000, 90000000,
1000000);
final ScatterChart<Number, Number> scatterChart = new
ScatterChart<>(xAxis, yAxis);
接下来,轴的标签与散点图的标题一起设置:
xAxis.setLabel("Population");
yAxis.setLabel("Decade");
scatterChart.setTitle("Population Scatter Graph");
创建了一个XYChart.Series
类的实例,并命名为:
XYChart.Series series = new XYChart.Series();
使用一个CSVReader
类实例和文件EuropeanScatterData.csv
填充该系列。这个过程在第三章、数据清理中讨论:
try (CSVReader dataReader = new CSVReader(new FileReader("EuropeanScatterData.csv"), ',')) {
String[] nextLine;
while ((nextLine = dataReader.readNext()) != null) {
int decade = Integer.parseInt(nextLine[0]);
int population = Integer.parseInt(nextLine[1]);
series.getData().add(new XYChart.Data(
population, decade));
out.println("Decade: " + decade +
" Population: " + population);
}
}
scatterChart.getData().addAll(series);
JavaFX scene
和stage
被创建,然后显示绘图:
Scene scene = new Scene(scatterChart, 500, 400);
stage.setScene(scene);
stage.show();
执行应用程序时,会显示以下图形:
创建直方图
直方图虽然在外观上类似于条形图,但用于显示数据集中数据项相对于其他项的频率。下面每个使用 GRAL 的例子都将使用DataTable
类来最初保存要显示的数据。在本例中,我们将从名为AgeofMarriage.csv
的样本文件中读取数据。这个以逗号分隔的文件保存了人们第一次结婚的年龄列表。
我们将创建一个名为HistogramExample
的新类,它扩展了JFrame
类,并在其构造函数中包含以下代码。我们首先创建一个DataReader
对象来指定数据是 CSV 格式的。然后我们使用一个 try-catch 块来处理 IO 异常,并调用DataReader
类的read
方法将数据直接放入DataTable
对象中。read
方法的第一个参数是一个FileInputStream
对象,第二个参数指定文件中预期的数据类型:
DataReader readType=
DataReaderFactory.getInstance().get("text/csv");
String fileName = "C://AgeofMarriage.csv";
try {
DataTable histData = (DataTable) readType.read(
New FileInputStream(fileName), Integer.class);
...
}
接下来,我们创建一个Number
数组来指定我们期望获得数据的年龄。在这种情况下,我们预计结婚年龄将在19
到30
之间。我们使用这个数组来创建我们的Histogram
对象。我们包括了之前的DataTable
,并且指定了方向。然后我们创建我们的DataSource
,指定我们的开始年龄,并指定沿着我们的 X 轴的间距:
Number ageRange[] = {19,20,21,22,23,24,25,26,27,28,29,30};
Histogram sampleHisto = new Histogram1D(
histData, Orientation.VERTICAL, ageRange);
DataSource sampleHistData = new EnumeratedData(sampleHisto, 19,
1.0);
我们使用BarPlot
类从前面读入的数据中创建直方图:
BarPlot testPlot = new BarPlot(sampleHistData);
接下来的几个步骤用于格式化直方图的各个方面。我们使用setInsets
方法来指定在窗口内图表的每一边放置多少空间。我们可以为图表提供一个标题,并指定条形宽度:
testPlot.setInsets(new Insets2D.Double(20.0, 50.0, 50.0, 20.0));
testPlot.getTitle().setText("Average Age of Marriage");
testPlot.setBarWidth(0.7);
我们还需要格式化我们的 X 和 Y 轴。我们已经选择将我们的范围设置为 X 轴,以紧密匹配我们的预期年龄范围,但在图表的一侧提供一些空间。因为我们知道样本数据的数量,所以我们将我们的 Y 轴设置为从0
到10
的范围。在业务应用程序中,这些范围将通过检查实际数据集来计算。我们还可以指定是否希望显示刻度线,以及希望轴相交的位置:
testPlot.getAxis(BarPlot.AXIS_X).setRange(18, 30.0);
testPlot.getAxisRenderer(BarPlot.AXIS_X).setTickAlignment(0.0);
testPlot.getAxisRenderer(BarPlot.AXIS_X).setTickSpacing(1);
testPlot.getAxisRenderer(BarPlot.AXIS_X).setMinorTicksVisible(false );
testPlot.getAxis(BarPlot.AXIS_Y).setRange(0.0, 10.0);
testPlot.getAxisRenderer(BarPlot.AXIS_Y).setTickAlignment(0.0);
testPlot.getAxisRenderer(BarPlot.AXIS_Y).setMinorTicksVisible(false );
testPlot.getAxisRenderer(BarPlot.AXIS_Y).setIntersection(0);
我们在图形上显示的颜色和值也有很大的灵活性。在本例中,我们选择显示每个年龄的频率值,并将图表颜色设置为black
:
PointRenderer renderHist =
testPlot.getPointRenderers(sampleHistData).get(0);
renderHist.setColor(GraphicsUtils.deriveWithAlpha(Color.black,
128));
renderHist.setValueVisible(true);
最后,我们为窗口的显示方式设置了几个属性:
InteractivePanel pan = new InteractivePanel(testPlot);
pan.setPannable(false);
pan.setZoomable(false);
add(pan);
setSize(1500, 700);
this.setVisible(true);
执行应用程序时,会显示以下图形:
创建圆环图
圆环图类似于饼图,但它们缺少中间部分(因此得名圆环图)。一些分析师更喜欢圆环图而不是饼图,因为它们不强调图表中每个部分的大小,并且更容易与其他圆环图进行比较。它们还提供了占用更少空间的额外优势,允许在显示中有更多的格式化选项。
在这个例子中,我们将假设我们的数据已经被填充到一个名为ageCount
的二维数组中。数组的第一行包含可能的年龄值,范围也是从19
到30
(包括 T1 和)。第二行包含等于每个年龄的数据值的数量。例如,在我们的数据集中,有六个数据值等于19
,因此ageCount[0][1]
包含数字 6。
我们创建一个DataTable
并使用add
方法将数组中的值相加。请注意,我们正在测试特定年龄的值是否为零。在我们的测试案例中,将有零个数据值等于23
。如果该点没有数据值,我们将选择在圆环图中添加一个空白区域。这是通过使用负数作为add
方法中的第一个参数来实现的。这将设置一个大小为3
的空白空间:
DataTable donutData = new DataTable(Integer.class, Integer.class);
for(int Y = 0; Y < ageCount[0].length; y++){
if(ageCount[1][y] == 0){
donutData.add(-3, ageCount[0][y]);
}else{
donutData.add(ageCount[1][y], ageCount[0][y]);
}
}
接下来,我们使用PiePlot
类创建我们的圆环图。我们设置绘图的基本属性,包括指定图例的值。在这种情况下,我们希望我们的图例反映我们的年龄可能性,所以我们使用setLabelColumn
方法来更改默认标签。我们也像在前面的例子中一样设置我们的 insets:
PiePlot testPlot = new PiePlot(donutData);
((ValueLegend) testPlot.getLegend()).setLabelColumn(1);
testPlot.getTitle().setText("Donut Plot Example");
testPlot.setRadius(0.9);
testPlot.setLegendVisible(true);
testPlot.setInsets(new Insets2D.Double(20.0, 20.0, 20.0, 20.0));
接下来,我们创建一个PieSliceRenderer
对象来设置更高级的属性。因为甜甜圈图本质上基本上是一个饼图,我们将通过调用setInnerRadius
方法来呈现甜甜圈图。我们还指定饼图扇区之间的间隙、使用的颜色以及标签的样式:
PieSliceRenderer renderPie = (PieSliceRenderer)
testPlot.getPointRenderer(donutData);
renderPie.setInnerRadius(0.4);
renderPie.setGap(0.2);
LinearGradient colors = new LinearGradient(
Color.blue, Color.green);
renderPie.setColor(colors);
renderPie.setValueVisible(true);
renderPie.setValueColor(Color.WHITE);
renderPie.setValueFont(Font.decode(null).deriveFont(Font.BOLD));
最后,我们创建面板并设置其大小:
add(new InteractivePanel(testPlot), BorderLayout.CENTER);
setSize(1500, 700);
setVisible(true);
执行应用程序时,会显示以下图形:
创建气泡图
气泡图类似于散点图,只是它们以三维形式表示数据。前两个维度在 X 和 Y 轴上表示,第三个维度由绘制点的大小表示。这有助于确定数据值之间的关系。
我们将再次使用DataTable
类来最初保存要显示的数据。在本例中,我们将从名为MarriageByYears.csv
的样本文件中读取数据。这也是一个 CSV 文件,其中一列代表结婚的年份,第二列代表结婚的年龄,第三列代表婚姻满意度的整数,范围从1
(最不满意)到10
(最满意)。我们创建一个DataSeries
来表示我们想要的数据图类型,然后创建一个XYPlot
对象:
DataReader readType =
DataReaderFactory.getInstance().get("text/csv");
String fileName = "C://MarriageByYears.csv";
try {
DataTable bubbleData = (DataTable) readType.read(
new FileInputStream(fileName), Integer.class,
Integer.class, Integer.class);
DataSeries bubbleSeries = new DataSeries("Bubble", bubbleData);
XYPlot testPlot = new XYPlot(bubbleSeries);
接下来,我们设置图表的基本属性信息。在本例中,我们将设置颜色并关闭垂直和水平网格。在本例中,我们还将使 X 轴和 Y 轴不可见。请注意,我们仍然为轴设置了一个范围,即使它们没有显示:**
testPlot.setInsets(new Insets2D.Double(30.0)); testPlot.setBackground(new Color(0.75f, 0.75f, 0.75f));
XYPlotArea2D areaProp = (XYPlotArea2D) testPlot.getPlotArea();
areaProp.setBorderColor(null);
areaProp.setMajorGridX(false);
areaProp.setMajorGridY(false);
areaProp.setClippingArea(null);
testPlot.getAxisRenderer(XYPlot.AXIS_X).setShapeVisible(false);
testPlot.getAxisRenderer(XYPlot.AXIS_X).setTicksVisible(false);
testPlot.getAxisRenderer(XYPlot.AXIS_Y).setShapeVisible(false);
testPlot.getAxisRenderer(XYPlot.AXIS_Y).setTicksVisible(false);
testPlot.getAxis(XYPlot.AXIS_X).setRange(1940, 2020);
testPlot.getAxis(XYPlot.AXIS_Y).setRange(17, 30);
我们还可以设置与图表上绘制的气泡相关的属性。在这里,我们设置颜色和形状,并指定哪一列数据将用于缩放形状。在这种情况下,将使用第三列,即婚姻满意度等级。我们使用setColumn
方法设置它:
Color color = GraphicsUtils.deriveWithAlpha(Color.black, 96);
SizeablePointRenderer renderBubble = new SizeablePointRenderer();
renderBubble.setShape(new Ellipse2D.Double(-3.5, -3.5, 4.0, 4.0));
renderBubble.setColor(color);
renderBubble.setColumn(2);
testPlot.setPointRenderers(bubbleSeries, renderBubble);
最后,我们创建面板并设置其大小:
add(new InteractivePanel(testPlot), BorderLayout.CENTER);
setSize(new Dimension(1500, 700));
setVisible(true);
执行应用程序时,会显示下图。请注意,点的大小和颜色会根据特定数据点的频率而变化:
总结
在这一章中,我们介绍用于可视化数据的基本图形、曲线图和图表。可视化的过程使分析人员能够以图形方式检查被检查的数据。这更加直观,并且通常有助于快速识别数据中难以从原始数据中提取的异常。
检查了几种视觉表示,包括折线图、各种条形图、饼图、散点图、直方图、环形图和气泡图。这些数据的图形描述中的每一个都提供了被分析数据的不同视角。最合适的技术取决于所用数据的性质。虽然我们没有涵盖所有可能的图形技术,但是这个示例很好地概述了可用的技术。
我们还关心如何使用 Java 来绘制这些图形。许多例子都使用了 JavaFX。这是一个与 Java SE 捆绑在一起的现成工具。但是,还有其他几个可用的库。我们用 GRAL 来说明如何生成一些图形。
在概述了可视化技术之后,我们准备继续讨论其他主题,在这些主题中,可视化将用于更好地传达数据科学技术的本质。在下一章,我们将介绍基本的统计过程,包括线性回归,我们将使用本章介绍的技术。
五、统计数据分析技术
本章的目的不是让读者成为统计技术的专家。相反,它是为了让读者熟悉正在使用的基本统计技术,并演示 Java 如何支持统计分析。虽然有各种各样的数据分析技术,在这一章中,我们将把重点放在更常见的任务上。
这些技术包括从相对简单的平均值计算到复杂的回归分析模型。统计分析可能是一个非常复杂的过程,需要进行大量的研究。我们将从介绍基本的统计分析技术开始,包括计算数据集的均值、中值、众数和标准差。有许多方法可以用来计算这些值,我们将使用标准 Java 和第三方 API 来演示这些方法。我们还将简要讨论样本大小和假设检验。
回归分析是一种重要的数据分析技术。该技术会创建一条尝试匹配数据集的线。代表这条线的方程可以用来预测未来的行为。回归分析有几种类型。在本章中,我们将重点介绍简单线性回归和多元回归。通过简单的线性回归,年龄等单一因素被用来预测一些行为,如外出就餐的可能性。通过多元回归,年龄、收入水平和婚姻状况等多个因素可以用来预测一个人外出就餐的频率。
预测分析,或称分析,是关于预测未来事件的。本书中使用的许多技术都与预测有关。具体来说,本章的回归分析部分预测未来的行为。
在我们看到 Java 如何支持回归分析之前,我们需要讨论基本的统计技术。我们从均值、众数和中位数开始。
在本章中,我们将讨论以下主题:
- 使用平均值、众数和中位数
- 标准偏差和样本量的确定
- 假设检验
- 回归分析
使用平均值、众数和中位数
平均值、中值和众数是描述特征或汇总数据集中信息的基本方法。当第一次遇到一个新的大型数据集时,了解它的基本信息会有助于指导进一步的分析。这些值通常用于以后的分析,以生成更复杂的测量结果和结论。当我们使用数据集的平均值来计算标准偏差时,就会发生这种情况,我们将在本章的标准偏差一节中进行演示。
计算平均值
术语 mean,也称为平均值,计算方法是将列表中的值相加,然后将总和除以值的个数。这种技术对于确定一组数字的总体趋势很有用。它还可以用来填充缺失的数据元素。我们将研究几种使用标准 Java 库和第三方 API 计算给定数据集平均值的方法。
使用简单的 Java 技术寻找平均值
在我们的第一个例子中,我们将演示使用标准 Java 功能计算平均值的基本方法。我们将使用一个名为testData
的double
值数组:
double[] testData = {12.5, 18.7, 11.2, 19.0, 22.1, 14.3, 16.9, 12.5,
17.8, 16.9};
我们创建一个double
变量来保存所有值的总和,创建一个double
变量来保存mean
。循环用于遍历数据并将值相加。接下来,总和除以我们数组的length
(元素总数)来计算mean
:
double total = 0;
for (double element : testData) {
total += element;
}
double mean = total / testData.length;
out.println("The mean is " + mean);
我们的输出如下:
平均值为 16.19
使用 Java 8 技术寻找平均值
Java 8 通过引入可选类提供了额外的功能。在这个例子中,我们将结合使用OptionalDouble
类和Arrays
类的stream
方法。我们将使用与上一个例子中相同的 doubles 数组来创建一个OptionalDouble
对象。如果数组中的任何数字,或者数组中数字的和不是实数,那么OptionalDouble
对象的值也不是实数:
OptionalDouble mean = Arrays.stream(testData).average();
我们使用isPresent
方法来确定我们是否为我们的平均值计算了一个有效的数字。如果我们没有得到一个好的结果,isPresent
方法将返回false
,我们可以处理任何异常:
if (mean.isPresent()) {
out.println("The mean is " + mean.getAsDouble());
} else {
out.println("The stream was empty");
}
我们的输出如下:
The mean is 16.19
另一种更简洁的使用OptionalDouble
类的技术涉及 lambda 表达式和ifPresent
方法。如果mean
是一个有效的OptionalDouble
对象,这个方法执行它的参数:
OptionalDouble mean = Arrays.stream(testData).average();
mean.ifPresent(x-> out.println("The mean is " + x));
我们的输出如下:
The mean is 16.19
最后,如果mean
不是有效的OptionalDouble
对象,我们可以使用orElse
方法打印平均值或替代值:
OptionalDouble mean = Arrays.stream(testData).average();
out.println("The mean is " + mean.orElse(0));
我们的输出是相同的:
The mean is 16.19
在接下来的两个例子中,我们将使用第三方库,并继续使用 doubles 数组,testData
。
用谷歌番石榴查找意思
在这个例子中,我们将使用谷歌番石榴库,在第三章中介绍、数据清理。Stats
类提供了处理数字数据的功能,包括寻找平均值和标准差,我们将在后面演示。为了计算mean
,我们首先使用testData
数组创建一个Stats
对象,然后执行mean
方法:
Stats testStat = Stats.of(testData);
double mean = testStat.mean();
out.println("The mean is " + mean);
请注意本例中输出的默认格式之间的差异。
使用 Apache Commons 查找平均值
在我们最后的例子中,我们使用 Apache Commons 库,也在第 3 章、中介绍了数据清理。我们首先创建一个Mean
对象,然后使用我们的testData
执行evaluate
方法。该方法返回一个double
,表示数组中值的平均值:
Mean mean = new Mean();
double average = mean.evaluate(testData);
out.println("The mean is " + average);
我们的输出如下:
The mean is 16.19
Apache Commons 还提供了一个有用的DescriptiveStatistics
类。稍后我们将使用它来演示中位数和标准差,但首先我们将从计算平均值开始。使用SynchronizedDescriptiveStatistics
类是有利的,因为它是同步的,因此是线程安全的。
我们从创建我们的DescriptiveStatistics
对象statTest
开始。然后,我们循环遍历我们的双数组,并将每一项添加到statTest
。然后我们可以调用getMean
方法来计算mean
:
DescriptiveStatistics statTest =
new SynchronizedDescriptiveStatistics();
for(double num : testData){
statTest.addValue(num);
}
out.println("The mean is " + statTest.getMean());
我们的输出如下:
The mean is 16.19
接下来,我们将讨论相关话题:中位数。
计算中位数
如果数据集包含大量异常值或有偏差,则平均值可能会产生误导。当这种情况发生时,众数和中位数会很有用。术语中值是一系列值中间的值。对于奇数个值,这很容易计算。对于偶数个值,中值计算为中间两个值的平均值。
使用简单的 Java 技术求中位数
在我们的第一个例子中,我们将使用一个基本的 Java 方法来计算中位数。对于这些例子,我们稍微修改了我们的testData
数组:
double[] testData = {12.5, 18.3, 11.2, 19.0, 22.1, 14.3, 16.2, 12.5,
17.8, 16.5};
首先,我们使用Arrays
类对我们的数据进行排序,因为当数据按数字顺序排列时,寻找中值是很简单的:
Arrays.sort(testData);
然后我们处理三种可能性:
- 我们的列表是空的
- 我们的列表有偶数个值
- 我们的列表有奇数个值
下面的代码可能会被缩短,但是我们已经明确地帮助阐明了这个过程。如果我们的列表有偶数个值,我们用列表的长度除以2
。第一个变量mid1
将保存两个中间值中的第一个。第二个变量mid2
将保存第二个中间值。这两个数字的平均值就是我们的中值。寻找具有奇数个值的列表的中值索引的过程更简单,只需要我们将长度除以2
并加上1
:
if(testData.length==0){ // Empty list
out.println("No median. Length is 0");
}else if(testData.length%2==0){ // Even number of elements
double mid1 = testData[(testData.length/2)-1];
double mid2 = testData[testData.length/2];
double med = (mid1 + mid2)/2;
out.println("The median is " + med);
}else{ // Odd number of elements
double mid = testData[(testData.length/2)+1];
out.println("The median is " + mid);
}
使用前面包含偶数个值的数组,我们的输出是:
The median is 16.35
为了测试奇数个元素的代码,我们将把 double 12.5
添加到数组的末尾。我们的新输出如下:
The median is 16.5
使用 Apache Commons 寻找中间值
我们还可以使用在计算平均值一节中演示的 Apache Commons DescriptiveStatistics
类来计算中位数。我们将继续使用具有以下值的testData
数组:
double[] testData = {12.5, 18.3, 11.2, 19.0, 22.1, 14.3, 16.2, 12.5,
17.8, 16.5, 12.5};
我们的代码非常类似于我们用来计算平均值的代码。我们只需创建我们的DescriptiveStatistics
对象并调用getPercentile
方法,该方法返回存储在其参数中指定的百分点值的估计值。为了找到中间值,我们使用50
的值:
DescriptiveStatistics statTest =
new SynchronizedDescriptiveStatistics();
for(double num : testData){
statTest.addValue(num);
}
out.println("The median is " + statTest.getPercentile(50));
我们的输出如下:
The median is 16.2
计算模式
术语模式用于表示数据集中出现频率最高的值。这可以被认为是最受欢迎的结果,或直方图中最高的条。在进行统计分析时,它可能是一条有用的信息,但计算起来可能比第一次出现时更复杂。首先,我们将使用下面的testData
数组演示一个简单的 Java 技术:
double[] testData = {12.5, 18.3, 11.2, 19.0, 22.1, 14.3, 16.2, 12.5,
17.8, 16.5, 12.5};
我们首先初始化变量来保存模式、模式在列表中出现的次数以及一个tempCnt
变量。mode 和modeCount
变量分别用于保存模式值和该值在列表中出现的次数。变量tempCnt
用于统计一个元素在列表中出现的次数:
int modeCount = 0;
double mode = 0;
int tempCnt = 0;
然后,我们使用嵌套的 for 循环将数组中的每个值与数组中的其他值进行比较。当我们找到匹配的值时,我们增加我们的tempCnt
。在比较每个值之后,我们测试看tempCnt
是否大于modeCount
,如果是,我们改变我们的modeCount
和模式以反映新的值:
for (double testValue : testData){
tempCnt = 0;
for (double value : testData){
if (testValue == value){
tempCnt++;
}
}
if (tempCnt > modeCount){
modeCount = tempCnt;
mode = testValue;
}
}
out.println("Mode" + mode + " appears " + modeCount + " times.");
使用这个例子,我们的输出如下:
The mode is 12.5 and appears 3 times.
虽然我们前面的例子看起来简单明了,但它带来了潜在的问题。如下所示修改testData
数组,其中最后一个条目更改为11.2
:
double[] testData = {12.5, 18.3, 11.2, 19.0, 22.1, 14.3, 16.2, 12.5,
17.8, 16.5, 11.2};
当我们这次执行代码时,我们的输出如下:
The mode is 12.5 and appears 2 times.
问题是我们的testData
数组现在包含两个各出现两次的值,12.5
和11.2
。这就是所谓的多模态数据集。我们可以通过基本的 Java 代码和第三方库来解决这个问题,稍后我们将展示这一点。
然而,首先我们将展示两种使用简单 Java 的方法。第一种方法将使用两个ArrayList
实例,第二种方法将使用一个ArrayList
和一个HashMap
实例。
使用数组列表寻找多种模式
在第一种方法中,我们修改了上一个例子中使用的代码,以使用一个ArrayList
类。我们将创建两个ArrayLists
,一个保存数据集中的唯一数字,另一个保存每个数字的计数。我们还需要一个tempMode
变量,我们接下来会用到它:
ArrayList<Integer> modeCount = new ArrayList<Integer>();
ArrayList<Double> mode = new ArrayList<Double>();
int tempMode = 0;
接下来,我们将遍历数组并测试模式列表中的每个值。如果在列表中没有找到该值,我们将它添加到mode
中,并将modeCount
中的相同位置设置为1
。如果找到该值,我们将在modeCount
中的相同位置增加1
:
for (double testValue : testData){
int loc = mode.indexOf(testValue);
if(loc == -1){
mode.add(testValue);
modeCount.add(1);
}else{
modeCount.set(loc, modeCount.get(loc)+1);
}
}
接下来,我们遍历我们的modeCount
列表来找到最大值。这表示数据集中最常见值的模式或频率。这允许我们选择多种模式:
for(int cnt = 0; cnt < modeCount.size(); cnt++){
if (tempMode < modeCount.get(cnt)){
tempMode = modeCount.get(cnt);
}
}
最后,我们再次遍历我们的modeCount
数组,并打印出模式中与包含最大值的modeCount
中的元素相对应的任何元素,或者模式:
for(int cnt = 0; cnt < modeCount.size(); cnt++){
if (tempMode == modeCount.get(cnt)){
out.println(mode.get(cnt) + " is a mode and appears " +
modeCount.get(cnt) + " times.");
}
}
当我们的代码被执行时,我们的输出反映了我们的多模态数据集:
12.5 is a mode and appears 2 times.
11.2 is a mode and appears 2 times.
使用散列表寻找多种模式
第二种方法使用HashMap
。首先,我们创建ArrayList
来保存可能的模式,就像前面的例子一样。我们还创建了我们的HashMap
和一个变量来保存模式:
ArrayList<Double> modes = new ArrayList<Double>();
HashMap<Double, Integer> modeMap = new HashMap<Double, Integer>();
int maxMode = 0;
接下来,我们遍历我们的testData
数组,并计算数组中每个值出现的次数。然后,我们将每个值的计数和值本身添加到HashMap
中。如果值的计数大于我们的maxMode
变量,我们将maxMode
设置为新的最大值:
for (double value : testData) {
int modeCnt = 0;
if (modeMap.containsKey(value)) {
modeCnt = modeMap.get(value) + 1;
} else {
modeCnt = 1;
}
modeMap.put(value, modeCnt);
if (modeCnt > maxMode) {
maxMode = modeCnt;
}
}
最后,我们遍历我们的HashMap
并检索我们的模式,或者计数等于我们的maxMode
的所有值:
for (Map.Entry<Double, Integer> multiModes : modeMap.entrySet()) {
if (multiModes.getValue() == maxMode) {
modes.add(multiModes.getKey());
}
}
for(double mode : modes){
out.println(mode + " is a mode and appears " + maxMode + " times.");
}
当我们执行我们的代码时,我们得到与上一个例子相同的输出:
12.5 is a mode and appears 2 times.
11.2 is a mode and appears 2 times.
使用 Apache Commons 查找多种模式
另一种选择是使用 Apache Commons StatUtils
类。这个类包含了几种统计分析的方法,包括多种平均值的方法,但是我们在这里只研究模式。该方法被命名为mode
,并接受一个 doubles 数组作为其参数。它返回包含数据集所有模式的 doubles 数组:
double[] modes = StatUtils.mode(testData);
for(double mode : modes){
out.println(mode + " is a mode.");
}
一个缺点是我们不能计算我们的模式在这个方法中出现的次数。我们只知道模式是什么,而不知道它出现了多少次。当我们执行我们的代码时,我们得到一个与前面的例子相似的输出:
12.5 is a mode.
11.2 is a mode.
标准偏差
标准偏差是对平均值分布情况的测量。高偏差意味着分布很广,而低偏差意味着值更紧密地围绕平均值分组。如果没有一个焦点或者有许多异常值,这种测量可能会产生误导。
我们首先展示一个使用基本 Java 技术的简单例子。我们使用前面示例中的 testData 数组,在此复制:
double[] testData = {12.5, 18.3, 11.2, 19.0, 22.1, 14.3, 16.2, 12.5,
17.8, 16.5, 11.2};
在计算标准差之前,我们需要找到平均值。我们可以使用在计算平均值部分列出的任何技术,但是为了简单起见,我们将把我们的值相加,然后除以testData
的长度:
int sum = 0;
for(double value : testData){
sum += value;
}
double mean = sum/testData.length;
接下来,我们创建一个变量sdSum
,来帮助我们计算标准偏差。当我们遍历数组时,我们从每个数据值中减去平均值,对该值求平方,并将其添加到sdSum
。最后,我们将sdSum
除以数组的长度,然后对结果求平方:
int sdSum = 0;
for (double value : testData){
sdSum += Math.pow((value - mean), 2);
}
out.println("The standard deviation is " +
Math.sqrt( sdSum / ( testData.length ) ));
我们的输出是我们的标准差:
The standard deviation is 3.3166247903554
我们的下一个技术使用 Google Guava 的Stats
类来计算标准差。我们首先用我们的testData
创建一个Stats
对象。我们然后调用populationStandardDeviation
方法:
Stats testStats = Stats.of(testData);
double sd = testStats.populationStandardDeviation();
out.println("The standard deviation is " + sd);
输出如下所示:
The standard deviation is 3.3943803826056653
此示例计算整个总体的标准差。有时最好计算总体样本子集的标准差,以纠正可能的偏差。为了实现这一点,我们使用了与之前基本相同的代码,但是用sampleStandardDeviation
替换了populationStandardDeviation
方法:
Stats testStats = Stats.of(testData);
double sd = testStats.sampleStandardDeviation();
out.println("The standard deviation is " + sd);
在这种情况下,我们的输出是:
The sample standard deviation is 3.560056179332006
我们的下一个例子使用 Apache Commons DescriptiveStatistics
类,我们在前面的例子中使用它来计算平均值和中值。记住,这种技术的优点是线程安全和同步。在我们创建了一个SynchronizedDescriptiveStatistics
对象之后,我们添加数组中的每个值。我们然后称之为getStandardDeviation
方法。
DescriptiveStatistics statTest =
new SynchronizedDescriptiveStatistics();
for(double num : testData){
statTest.addValue(num);
}
out.println("The standard deviation is " +
statTest.getStandardDeviation());
请注意,该输出与我们上一个示例的输出相匹配。默认情况下,getStandardDeviation
方法返回为样本调整的标准偏差:
The standard deviation is 3.5600561793320065
然而,我们可以继续使用 Apache Commons 来计算任一形式的标准差。StandardDeviation
类允许您计算总体标准偏差或子集标准偏差。为了演示不同之处,请用下面的代码替换前面的代码示例:
StandardDeviation sdSubset = new StandardDeviation(false);
out.println("The population standard deviation is " +
sdSubset.evaluate(testData));
StandardDeviation sdPopulation = new StandardDeviation(true);
out.println("The sample standard deviation is " +
sdPopulation.evaluate(testData));
在第一行,我们创建了一个新的StandardDeviation
对象,并将我们的构造函数的参数设置为false
,这将产生一个总体的标准偏差。第二部分使用值true
,它产生样本的标准偏差。在我们的例子中,我们使用了相同的测试数据集。这意味着我们首先将它视为数据总体的一个子集。在第二个例子中,我们假设数据集是全部数据。实际上,您可能不会对这些方法中的每一种使用相同的数据集。输出如下所示:
The population standard deviation is 3.3943803826056653
The sample standard deviation is 3.560056179332006
首选方案将取决于您的样品和特定的分析需求。
样本量的确定
样本量的确定包括确定进行精确统计分析所需的数据量。处理大型数据集时,并不总是需要使用整个数据集。我们使用样本大小确定来确保我们选择的样本足够小,以方便操作和分析,但又足够大,以准确代表我们的总体数据。
使用数据的一个子集来训练模型,而使用另一个子集来测试模型,这种情况并不少见。这有助于验证数据的准确性和可靠性。样本量确定不当的一些常见后果包括假阳性结果、假阴性结果、在不存在统计显著性的情况下识别统计显著性,或者在实际存在统计显著性的情况下暗示缺乏显著性。网上有很多工具可以用来确定合适的样本量,每种工具的复杂程度都不一样。一个简单的例子是在 https://www.surveymonkey.com/mp/sample-size-calculator/。
假设检验
假设检验用于检验关于数据集的某些假设或前提是否不会偶然发生。如果是这种情况,那么测试的结果被认为是有统计学意义的。
进行假设检验不是一项简单的任务。有许多不同的陷阱需要避免,如安慰剂效应或观察者效应。在前一种情况下,参与者将获得他们认为是预期的结果。在观察者效应中,也被称为霍桑效应,结果是有偏差的,因为参与者知道他们正在被观察。由于人类行为分析的复杂性,某些类型的统计分析特别容易出现偏差或讹误。
进行假设检验的具体方法超出了本书的范围,需要在统计过程和最佳实践方面有扎实的背景知识。Apache Commons 提供了一个包org.apache.commons.math3.stat.inference
,其中包含执行假设检验的工具。这包括执行学生 T 检验、卡方检验和计算 p 值的工具。
回归分析
回归分析对于确定数据的趋势很有用。它表示因变量和自变量之间的关系。自变量决定因变量的值。每个自变量对因变量的值都有或强或弱的影响。线性回归使用散点图中的线条来显示趋势。非线性回归使用某种曲线来描述这种关系。
比如血压和年龄、体重指数()等各种因素都有关系。血压可视为因变量,其他因素可视为自变量。给定包含一组个体的这些因素的数据集,我们可以执行回归分析来查看趋势。
Java 支持几种类型的回归分析。我们将研究简单线性回归和多元线性回归。这两种方法都采用数据集,并推导出最适合数据的线性方程。简单线性回归使用一个因变量和一个自变量。多元线性回归使用多个因变量。
有几个支持简单线性回归的 API,包括:
- ****Apache Commons-http://Commons . Apache . org/proper/Commons-math/javadocs/API-3 . 6 . 1/index . html
- ****Weka-http://Weka . SourceForge . net/doc . dev/Weka/core/matrix/linear regression . html
- ****JFree-http://www . JFree . org/jfreechart/API/javadoc/org/JFree/data/statistics/regression . html
- **迈克尔·托马斯·弗拉纳根的 Java 科学图书馆-【http://www.ee.ucl.ac.uk/~mflanaga/java/Regression.html **
非线性 Java 支持可在以下网址找到:
- **奥丁斯班 / 爪哇最小二乘法-【https://github.com/odinsbane/least-squares-in-java **
- 非线性最小方 ( 并行 Java 库文档)-https://www . cs . rit . edu/~ ark/pj/doc/edu/rit/numeric/非线性最小方
有几个统计数据可以评估分析的有效性。我们将把重点放在基本统计上。
残差是实际数据值和预测值之间的差值。残差平方和** ( RSS )是残差平方和。本质上,它测量数据和回归模型之间的差异。较小的 RSS 表示模型与数据非常匹配。RSS 也被称为预测的残差平方和 ( SSR )或误差平方和 ( SSE )。**
均方误差 ( MSE )是残差平方和除以自由度。自由度的数量是独立观察的数量( N )减去总体参数估计的数量。对于简单的线性回归,这个 N - 2 因为有两个参数。对于多元线性回归,它取决于使用的独立变量的数量。
较小的 MSE 也表明模型非常适合数据集。在讨论线性回归模型时,您会看到这两种统计数据。
相关系数衡量回归模型中两个变量之间的关联。相关系数从 -1 到 +1 不等。值 +1 意味着两个变量完全相关。当一个增加时,另一个也会增加。相关系数为 -1 意味着两个变量负相关。一个增加,另一个减少。值为 0 表示变量之间没有相关性。该系数通常被指定为 r。它通常是平方,因此忽略了关系的符号。通常使用皮尔逊积矩相关系数。
使用简单线性回归
简单线性回归使用最小二乘法,即计算一条线,使点和线之间距离的平方和最小。有时计算直线时不使用 Y 截距项。回归线是一个估计值。我们可以用这条线的方程来预测其他数据点。当我们想根据过去的表现预测未来的事件时,这是很有用的。
在下面的例子中,我们将 Apache Commons SimpleRegression 类与第 4 章、数据可视化中使用的比利时人口数据集一起使用。为了方便起见,这里复制了数据:
| 十年 | 人口 |
| 1950
| 8639369
|
| 1960
| 9118700
|
| 1970
| 9637800
|
| 1980
| 9846800
|
| 1990
| 9969310
|
| 2000
| 10263618
|
虽然我们将演示的应用程序是一个 JavaFX 应用程序,但我们将重点关注应用程序的线性回归方面。我们使用 JavaFX 程序生成一个图表来显示回归结果。
下面是start
方法的主体。输入数据存储在一个二维数组中,如下所示:
double[][] input = {{1950, 8639369}, {1960, 9118700},
{1970, 9637800}, {1980, 9846800}, {1990, 9969310},
{2000, 10263618}};
创建了一个SimpleRegression
类的实例,并使用addData
方法添加了数据:
SimpleRegression regression = new SimpleRegression();
regression.addData(input);
我们将使用该模型来预测几年的行为,如下面的数组中所声明的:
double[] predictionYears = {1950, 1960, 1970, 1980, 1990, 2000,
2010, 2020, 2030, 2040};
我们还将使用下面的NumberFormat
实例格式化我们的输出。一个用于带有 false 参数的setGroupingUsed
方法取消逗号的年份。
NumberFormat yearFormat = NumberFormat.getNumberInstance();
yearFormat.setMaximumFractionDigits(0);
yearFormat.setGroupingUsed(false);
NumberFormat populationFormat = NumberFormat.getNumberInstance();
populationFormat.setMaximumFractionDigits(0);
SimpleRegression
类拥有一个predict
方法,该方法被传递一个值,在本例中是一年,并返回估计的人口。我们在循环中使用该方法,并为每年调用该方法:
for (int i = 0; i < predictionYears.length; i++) {
out.println(nf.format(predictionYears[i]) + "-"
+ nf.format(regression.predict(predictionYears[i])));
}
当程序执行时,我们得到以下输出:
**1950-8,801,975**
**1960-9,112,892**
**1970-9,423,808**
**1980-9,734,724**
**1990-10,045,641**
**2000-10,356,557**
**2010-10,667,474**
**2020-10,978,390**
**2030-11,289,307**
**2040-11,600,223**
为了以图形方式查看结果,我们生成了下面的索引图。该线与实际人口值相当吻合,并显示了未来的预测人口。
**
简单线性回归**
SimpleRegession
类支持许多提供回归附加信息的方法。这些方法总结如下:
| 方法 | 意为 |
| getR
| 返回皮尔逊的乘积矩相关系数 |
| getRSquare
| 返回决定系数(R 平方) |
| getMeanSquareError
| 返回 MSE |
| getSlope
| 直线的斜率 |
| getIntercept
| 截击 |
我们使用助手方法displayAttribute
来显示各种属性值,如下所示:
displayAttribute(String attribute, double value) {
NumberFormat numberFormat = NumberFormat.getNumberInstance();
numberFormat.setMaximumFractionDigits(2);
out.println(attribute + ": " + numberFormat.format(value));
}
我们为我们的模型调用了前面的方法,如下所示:
displayAttribute("Slope",regression.getSlope());
displayAttribute("Intercept", regression.getIntercept());
displayAttribute("MeanSquareError",
regression.getMeanSquareError());
displayAttribute("R", + regression.getR());
displayAttribute("RSquare", regression.getRSquare());
输出如下:
**Slope: 31,091.64**
**Intercept: -51,826,728.48**
**MeanSquareError: 24,823,028,973.4**
**R: 0.97**
**RSquare: 0.94**
如您所见,模型与数据吻合得很好。
使用多元回归
我们的目的不是提供多元线性回归的详细解释,因为这超出了本节的范围。更彻底的治疗可以在 http://www.biddle.com/documents/bcg_comp_chapter4.pdf 找到。相反,我们将解释该方法的基础,并展示我们如何使用 Java 来执行多元回归。
多元回归处理存在多个独立变量的数据。这种情况经常发生。考虑到汽车的燃油效率可能取决于所使用的汽油的辛烷值、发动机的大小、平均巡航速度和环境温度。所有这些因素都会影响燃油效率,有些因素的影响程度比其他因素更大。
自变量通常表示为 Y,其中多个因变量使用不同的 X 表示。使用三个因变量进行回归的简化方程如下,其中每个变量都有一个系数。第一项是截距。这些系数并不代表真实值,而仅用于说明目的。
Y = 11+0.75 X1+0.25 X2 2 X3
截距和系数是使用基于样本数据的多元回归模型生成的。一旦我们有了这些值,我们就可以创建一个方程来预测其他值。
我们将使用 Apache Commons OLSMultipleLinearRegression
类来使用香烟数据执行多元回归。数据改编自http://www . amstat . org/publications/jse/v2 n1/datasets . McIntyre . html。该数据由不同品牌香烟的 25 个条目组成,包含以下信息:
- 商标名称
- 焦油含量(毫克)
- 尼古丁含量(毫克)
- 重量(克)
- 一氧化碳含量(毫克)
数据存储在名为data.csv
的文件中,如以下部分内容列表所示,其中列值与之前列表的顺序相匹配:
Alpine,14.1,.86,.9853,13.6
Benson&Hedges,16.0,1.06,1.0938,16.6
BullDurham,29.8,2.03,1.1650,23.5
CamelLights,8.0,.67,.9280,10.2
...
以下是显示数据关系的散点图:
**
多元回归散点图**
我们将使用 JavaFX 程序来创建散点图并执行分析。我们从如下所示的MainApp
类开始。在本例中,我们将重点关注多元回归代码,不包括用于创建散点图的 JavaFX 代码。完整的程序可以从 http://www.packtpub.com/support 下载。
数据保存在一维数组中,一个NumberFormat
实例将用于格式化这些值。数组大小反映了每个条目的 25 条目和 4 值。在本例中,我们不会使用品牌名称。
public class MainApp extends Application {
private final double[] data = new double[100];
private final NumberFormat numberFormat =
NumberFormat.getNumberInstance();
...
public static void main(String[] args) {
launch(args);
}
}
使用如下所示的CSVReader
实例将数据读入数组:
int i = 0;
try (CSVReader dataReader = new CSVReader(
new FileReader("data.csv"), ',')) {
String[] nextLine;
while ((nextLine = dataReader.readNext()) != null) {
String brandName = nextLine[0];
double tarContent = Double.parseDouble(nextLine[1]);
double nicotineContent = Double.parseDouble(nextLine[2]);
double weight = Double.parseDouble(nextLine[3]);
double carbonMonoxideContent =
Double.parseDouble(nextLine[4]);
data[i++] = carbonMonoxideContent;
data[i++] = tarContent;
data[i++] = nicotineContent;
data[i++] = weight;
...
}
}
Apache Commons 拥有两个执行多元回归的类:
OLSMultipleLinearRegression
- 普通最小二乘(OLS) 回归GLSMultipleLinearRegression
- 广义最小二乘(GLS) 回归
当使用后一种技术时,模型元素之间的相关性会对结果产生负面影响。我们将使用OLSMultipleLinearRegression
类,并从它的实例化开始:
OLSMultipleLinearRegression ols =
new OLSMultipleLinearRegression();
我们将使用newSampleData
方法来初始化模型。这种方法需要数据集中的观测值个数和自变量个数。它可能抛出一个需要处理的IllegalArgumentException
异常。
int numberOfObservations = 25;
int numberOfIndependentVariables = 3;
try {
ols.newSampleData(data, numberOfObservations,
numberOfIndependentVariables);
} catch (IllegalArgumentException e) {
// Handle exceptions
}
接下来,我们将小数点后的位数设置为 2,并调用estimateRegressionParameters
方法。这将为我们的等式返回一组值,然后显示这些值:
numberFormat.setMaximumFractionDigits(2);
double[] parameters = ols.estimateRegressionParameters();
for (int i = 0; i < parameters.length; i++) {
out.println("Parameter " + i +": " +
numberFormat.format(parameters[i]));
}
当执行时,我们将得到以下输出,这为我们的回归方程提供了所需的参数:
**Parameter 0: 3.2**
**Parameter 1: 0.96**
**Parameter 2: -2.63**
**Parameter 3: -0.13**
为了根据一组独立变量预测一个新的依赖值,声明了getY
方法,如下所示。parameters
参数包含生成的方程系数。arguments
参数包含因变量的值。这些用于计算返回的新从属值:
public double getY(double[] parameters, double[] arguments) {
double result = 0;
for(int i=0; i<parameters.length; i++) {
result += parameters[i] * arguments[i];
}
return result;
}
我们可以通过创建一系列独立的值来测试这种方法。这里我们使用了与数据文件中的SalemUltra
条目相同的值:
double arguments1[] = {1, 4.5, 0.42, 0.9106};
out.println("X: " + 4.9 + " y: " +
numberFormat.format(getY(parameters,arguments1)));
这将为我们提供以下值:
**X: 4.9 y: 6.31**
6.31
的返回值与4.9
的实际值不同。然而,使用VirginiaSlims
的值:
double arguments2[] = {1, 15.2, 1.02, 0.9496};
out.println("X: " + 13.9 + " y: " +
numberFormat.format(getY(parameters,arguments2)));
我们得到以下结果:
**X: 13.9 y: 15.03**
这接近于13.9
的实际值。接下来,我们使用一组不同于数据集中的值:
double arguments3[] = {1, 12.2, 1.65, 0.86};
out.println("X: " + 9.9 + " y: " +
numberFormat.format(getY(parameters,arguments3)));
结果如下:
**X: 9.9 y: 10.49**
这些值不同,但仍然很接近。下图显示了与原始数据相关的预测数据:
**
多重回归预测**
OLSMultipleLinearRegression
类还拥有几种方法来评估模型与数据的吻合程度。然而,由于多元回归的复杂性,我们在这里没有讨论它们。
**# 总结
在本章中,我们简要介绍了在数据科学应用中可能遇到的基本统计分析技术。我们从计算一组数字数据的平均值、中值和众数的简单技术开始。标准 Java 和第三方 Java APIs 都用来展示如何计算这些属性。虽然这些技术相对简单,但在计算时需要考虑一些问题。
接下来,我们研究了线性回归。这种技术本质上更具预测性,并试图根据样本数据集计算未来或过去的其他值。我们研究了简单线性回归和多元回归,并使用 Apache Commons 类来执行回归,使用 JavaFX 来绘制图形。
简单线性回归使用单个自变量来预测因变量。多元回归使用一个以上的自变量。这两种技术都有用于评估它们与数据匹配程度的统计属性。
我们演示了如何使用 Apache Commons OLSMultipleLinearRegression
类来使用香烟数据执行多元回归。我们能够使用多种属性来创建一个预测一氧化碳排放量的方程。
有了这些统计技术,我们现在可以在下一章检查基本的机器学习技术。这将包括多层感知器和各种其他神经网络的详细讨论。**
六、机器学习
机器学习是一个广泛的话题,有许多不同的支持算法。它通常关注开发一些技术,这些技术允许应用程序学习,而不需要显式地编程来解决问题。通常,建立模型是为了解决一类问题,然后使用来自问题域的样本数据进行训练。在这一章中,我们将讨论一些数据科学中更常见的问题和模型。
这些技术中的许多使用训练数据来教导模型。数据由问题空间的各种代表性元素组成。一旦该模型被训练,就使用测试数据对其进行测试和评估。然后,使用该模型和输入数据进行预测。
例如,商店顾客的购买可以用来训练模型。随后,可以对具有相似特征的客户进行预测。由于预测客户行为的能力,有可能提供特殊的交易或服务来吸引客户返回或促进他们的访问。
有几种对机器学习技术进行分类的方法。一种方法是根据学习风格对他们进行分类:
- 监督学习:通过监督学习,用将输入特征值与正确输出值相匹配的数据来训练模型
- 无监督学习:在无监督学习中,数据不包含结果,但是模型被期望自己确定关系。
- 半监督:该技术使用少量包含正确答案的标记数据和大量未标记数据。这种结合可以带来更好的结果。
- 强化学习:这类似于监督学习,但是对好的结果提供奖励。
- 深度学习:这种方法使用包含多个处理级别的图来建模高级抽象。
在这一章中,我们将只能触及其中的一些技术。具体来说,我们将举例说明使用监督学习的三种技术:
- 决策树:使用问题的特征作为内部节点,结果作为叶子来构建一棵树
- 支持向量机:通常用于分类,通过创建一个分离数据集的超平面,然后进行预测
- 贝叶斯网络:用于描述环境中事件之间概率关系的模型
对于无监督学习,我们将展示如何使用关联规则学习来发现数据集元素之间的关系。然而,我们不会在这一章中讨论无监督学习。
我们将讨论强化学习的要素,并讨论这种技术的一些具体变化。我们还将提供进一步探索的资源链接。
深度学习的讨论推迟到第八章、深度学习。这项技术建立在神经网络的基础上,这将在第 7 章、神经网络中讨论。
在本章中,我们将讨论以下具体主题:
- 决策树
- 支持向量机
- 贝叶斯网络
- 关联规则学习
- 强化学习
监督学习技术
有大量的监督机器学习算法可用。我们将研究其中的三种:决策树、支持向量机和贝叶斯网络。它们都使用包含属性和正确答案的带注释的数据集。通常,使用训练和测试数据集。
我们从讨论决策树开始。
决策树
机器学习决策树是一种用于进行预测的模型。它有效地将某些观察映射到关于目标的结论。术语树来自反映不同状态或价值的分支。树叶代表结果,树枝代表导致结果的特征。在数据挖掘中,决策树是用于分类的数据描述。例如,我们可以使用决策树来根据收入水平和邮政编码等特定属性来确定个人是否可能购买某件商品。
我们希望创建一个决策树,根据其他变量来预测结果。当目标变量取连续值,如实数时,该树被称为回归树。
树由内部节点和叶子组成。每个内部节点代表模型的一个特征,例如受教育的年数或者一本书是平装本还是精装本。从内部节点引出的边表示这些特征的值。每片叶子被称为一个类,并且有一个相关的概率分布。
例如,我们将使用一个数据集,该数据集根据书籍的装订类型、颜色使用和流派来处理书籍的成功与否。基于该数据集的一个可能的决策树如下:
决策图表
决策树很有用,也很容易理解。即使对于大型数据集,为模型准备数据也很简单。
决策树类型
通过将输入数据集除以特征,可以对树进行训练。这通常以递归方式完成,被称为递归划分或决策树的自顶向下归纳 ( TDIDT )。当节点的值与目标的值都是同一类型或者递归不再增加值时,递归是有界的。
分类回归树 ( 大车)分析是指两种不同类型的决策树类型:
- 分类树分析:叶子对应一个目标特征
- 回归树分析:叶子拥有一个代表特征的实数
在分析过程中,可能会创建多个树。有几种技术可以用来创建树。这些技术被称为集成方法:
- Bagging 决策树:数据被重新采样并经常用于获得基于共识的预测
- 随机森林分类器:用于提高分类率
- 提升树:这可用于回归或分类问题
- 旋转森林:使用一种叫做主成分分析 ( PCA )的技术
对于给定的一组数据,有可能不止一棵树对数据进行建模。例如,树的根可以指定银行是否有 ATM 机,随后的内部节点可以指定出纳员的数量。然而,可以创建这样的树,其中出纳员的数量是根,ATM 的存在是内部节点。树的结构差异可以决定树的效率有多高。
有许多方法可以确定树中节点的顺序。一种技术是选择提供最多信息增益的属性;也就是说,选择一个能更好地帮助快速缩小可能决策范围的属性。
决策树库
有几个 Java 库支持决策树:
- 韦卡:【http://www.cs.waikato.ac.nz/ml/weka/】T2
- Apache Spark:https://Spark . Apache . org/docs/1 . 2 . 0/ml lib-decision-tree . html
- JBoss:【http://jboost.sourceforge.net】T2
- 机器学习语言工具包 ( 木槌):【http://mallet.cs.umass.edu】T4
我们将使用怀卡托知识分析环境 ( Weka )来演示如何用 Java 创建决策树。Weka 是一个具有 GUI 界面的工具,允许对数据进行分析。也可以从命令行或通过我们将使用的 Java API 调用它。
在构建树时,选择一个变量来分割树。有几种方法可以用来选择变量。我们使用哪一个取决于通过选择一个变量获得了多少信息。具体来说,我们将使用 Weka 的J48
类支持的 C4.5 算法。
Weka 使用一个.arff
文件来保存数据集。这个文件是可读的,由两部分组成。第一个是标题部分;它描述了文件中的数据。本节使用&符号来指定数据的关系和属性。第二段是数据段;它由一组逗号分隔的数据组成。
对图书数据集使用决策树
对于这个例子,我们将使用一个名为books.arff
的文件。接下来显示了它,它使用了四个称为属性的特性。这些功能指定了一本书是如何装订的,它是否使用多种颜色,它的流派,以及表明该书是否被购买的结果。标题部分如下所示:
@RELATION book_purchases
@ATTRIBUTE Binding {Hardcover, Paperback, Leather}
@ATTRIBUTE Multicolor {yes, no}
@ATTRIBUTE Genre {fiction, comedy, romance, historical}
@ATTRIBUTE Result {Success, Failure}
数据部分如下,由 13 个书条目组成:
@DATA
Hardcover,yes,fiction,Success
Hardcover,no,comedy,Failure
Hardcover,yes,comedy,Success
Leather,no,comedy,Success
Leather,yes,historical,Success
Paperback,yes,fiction,Failure
Paperback,yes,romance,Failure
Leather,yes,comedy,Failure
Paperback,no,fiction,Failure
Paperback,yes,historical,Failure
Hardcover,yes,historical,Success
Paperback,yes,comedy,Success
Hardcover,yes,comedy,Success
我们将使用下面定义的BookDecisionTree
类来处理这个文件。它使用一个构造函数和三个方法:
BookDecisionTree
:读入教练数据并创建一个用于处理数据的Instance
对象main
:驱动应用程序performTraining
:使用数据集训练模型getTestInstance
:创建一个测试用例
Instances
类保存代表单个数据集元素的元素:
public class BookDecisionTree {
private Instances trainingData;
public static void main(String[] args) {
...
}
public BookDecisionTree(String fileName) {
...
}
private J48 performTraining() {
...
}
private Instance getTestInstance(
...
}
}
构造函数打开一个文件并使用BufferReader
实例创建一个Instances
类的实例。数据集的每个元素要么是要素,要么是结果。setClassIndex
方法指定了结果类的索引。在这种情况下,它是数据集的最后一个索引,对应于成功或失败:
public BookDecisionTree(String fileName) {
try {
BufferedReader reader = new BufferedReader(
new FileReader(fileName));
trainingData = new Instances(reader);
trainingData.setClassIndex(
trainingData.numAttributes() - 1);
} catch (IOException ex) {
// Handle exceptions
}
}
我们将使用J48
类来生成一个决策树。这个类使用 C4.5 决策树算法来生成修剪或未修剪的树。方法指定使用未修剪的树。buildClassifier
方法实际上是基于所使用的数据集创建分类器:
private J48 performTraining() {
J48 j48 = new J48();
String[] options = {"-U"};
try {
j48.setOptions(options);
j48.buildClassifier(trainingData);
} catch (Exception ex) {
ex.printStackTrace();
}
return j48;
}
我们想要测试这个模型,所以我们将为每个测试用例创建一个实现Instance
接口的对象。一个getTestInstance
helper 方法被传递了三个参数,代表一个数据元素的三个特性。DenseInstance
类是一个实现Instance
接口的类。传递的值被分配给实例,并返回实例:
private Instance getTestInstance(
String binding, String multicolor, String genre) {
Instance instance = new DenseInstance(3);
instance.setDataset(trainingData);
instance.setValue(trainingData.attribute(0), binding);
instance.setValue(trainingData.attribute(1), multicolor);
instance.setValue(trainingData.attribute(2), genre);
return instance;
}
main
方法使用前面所有的方法来处理和测试我们的图书数据集。首先,使用图书数据集文件的名称创建一个BookDecisionTree
实例:
public static void main(String[] args) {
try {
BookDecisionTree decisionTree =
new BookDecisionTree("books.arff");
...
} catch (Exception ex) {
// Handle exceptions
}
}
接下来,调用performTraining
方法来训练模型。我们还显示了树:
J48 tree = decisionTree.performTraining();
System.out.println(tree.toString());
执行时,将显示以下内容:
J48 unpruned tree
------------------
Binding = Hardcover: Success (5.0/1.0)
Binding = Paperback: Failure (5.0/1.0)
Binding = Leather: Success (3.0/1.0)
Number of Leaves : 3
Size of the tree : 4
测试图书决策树
我们将用两个不同的测试用例来测试这个模型。两者都使用相同的代码来设置实例。我们使用带有测试用例特定值的getTestInstance
方法,然后使用带有classifyInstance
的实例来获得结果。为了获得更具可读性的内容,我们生成一个字符串,然后显示如下:
Instance testInstance = decisionTree.
getTestInstance("Leather", "yes", "historical");
int result = (int) tree.classifyInstance(testInstance);
String results = decisionTree.trainingData.attribute(3).value(result);
System.out.println(
"Test with: " + testInstance + " Result: " + results);
testInstance = decisionTree.
getTestInstance("Paperback", "no", "historical");
result = (int) tree.classifyInstance(testInstance);
results = decisionTree.trainingData.attribute(3).value(result);
System.out.println(
"Test with: " + testInstance + " Result: " + results);
执行这段代码的结果如下:
Test with: Leather,yes,historical Result: Success
Test with: Paperback,no,historical Result: Failure
这符合我们的预期。这种技术是基于在做出排序决定之前和之后获得的信息量。这可以基于如下计算的熵来测量:
Entropy = -portionPos * log2(portionPos) - portionNeg* log2(portionNeg)
在这个例子中,portionPos
是正的数据部分,portionNeg
是负的数据部分。基于 books 文件,我们可以计算绑定的熵,如下表所示。通过从 1.0 中减去用于结合的熵来计算信息增益:
我们可以用类似的方式计算颜色和类型使用的熵。颜色的信息增益为 0.05 ,流派的信息增益为 0.15 。因此,对树的第一层使用绑定类型更有意义。
由于 C4.5 算法确定剩余的特征不提供任何额外的信息增益,因此该示例的结果树由两层组成。
当选择具有大量值的特征时,例如客户的信用卡号,信息获取可能会有问题。使用这种类型的属性会迅速缩小范围,但它的选择性太强,没有多大价值。
支持向量机
一个支持向量机 ( SVM )是一个监督机器学习算法,用于分类和回归问题。它主要用于分类问题。该方法创建超平面来对训练数据进行分类。超平面可以被想象成分隔两个区域的几何平面。在二维空间中,它将是一条线。在三维空间中,它将是一个二维平面。对于更高的维度,更难概念化,但它们确实存在。
考虑下图,该图描述了两种类型的数据点的分布。这些线代表分隔这些点的可能的超平面。SVM 过程的一部分是为问题数据集寻找最佳超平面。我们将在编码示例中详细阐述这个数字。
超平面示例
支持向量是位于超平面附近的数据点。SVM 模型使用核的概念将输入数据映射到更高阶的维度空间,以使数据更容易结构化。这样做的映射函数可能导致无限维空间;也就是说,可能存在无限数量的可能映射。
然而,所谓的内核技巧,内核函数是一种避免这种映射并避免可能发生的不可行计算的方法。支持向量机支持不同类型的内核。内核列表可以在http://crsouza . com/2010/03/kernel-functions-for-machine-learning-applications/找到。选择合适的内核取决于问题。常用的内核包括:
- 线性:使用一个线性超平面
- 多项式:使用超平面的多项式方程
- 径向基函数(RBF) :使用非线性超平面
- Sigmoid:Sigmoid 核,也称为双曲正切核,来自神经网络领域,相当于一个两层感知器神经网络
这些内核支持不同的数据分析算法。
支持向量机对于人类难以想象的高维空间非常有用。在上图中,两个属性用于预测第三个属性。当存在更多属性时,可以使用 SVM。需要对 SVM 进行训练,对于较大的数据集,这可能需要更长的时间。
我们将使用 Weka 类SMO
来演示 SVM 分析。该类支持 John Platt 的顺序最小优化算法。关于这个算法的更多信息可以在https://www . Microsoft . com/en-us/research/publication/fast-training-of-support-vector-machines-using-sequential-minimal-optimization/找到。
SMO
类支持以下内核,可以在使用该类时指定:
- Puk :基于皮尔逊 VII 函数的通用核
- 多内核:多项式内核
- RBF kernel:RBF 内核
该算法使用训练数据来创建分类模型。然后,测试数据可用于评估模型。我们还可以评估单个数据元素。
使用 SVM 获取露营数据
为了便于说明,我们将使用一个由年龄、收入和某人是否露营组成的数据集。我们希望能够根据年龄和收入预测某人是否倾向于露营。我们使用的数据以.arff
格式存储,并非基于调查,而是为了解释 SVM 进程而创建的。输入数据在camping.txt
文件中找到,如下所示。文件扩展名不必是.arff
:
@relation camping
@attribute age numeric
@attribute income numeric
@attribute camps {1, 0}
@data
23,45600,1
45,65700,1
72,55600,1
24,28700,1
22,34200,1
28,32800,1
32,24600,1
25,36500,1
26,91000,0
29,85300,0
67,76800,0
86,58900,0
56,125300,0
25,125000,0
22,43600,1
78,125700,1
73,56500,1
29,87600,0
65,79300,0
下图显示了数据的分布情况。注意右上角的异常值。生成此图的 JavaFX 代码位于http://www.packtpub.com/support:
野营图
我们将从读入数据和处理异常开始:
try {
BufferedReader datafile;
datafile = readDataFile("camping.txt");
...
} catch (Exception ex) {
// Handle exceptions
}
readDataFile
方法如下:
public BufferedReader readDataFile(String filename) {
BufferedReader inputReader = null;
try {
inputReader = new BufferedReader(
new FileReader(filename));
} catch (FileNotFoundException ex) {
// Handle exceptions
}
return inputReader;
}
Instances
类保存一系列数据实例,其中每个实例都是年龄、收入和露营值。setClassIndex
方法指出哪个属性将被预测。在本例中,它是camps
属性:
Instances data = new Instances(datafile);
data.setClassIndex(data.numAttributes() - 1);
为了训练模型,我们将把数据集分成两组。第一个14
实例用于训练模型,最后一个5
实例用于测试模型。Instances
构造函数的第二个参数指定数据集中的起始索引,最后一个参数指定要包含多少个实例:
Instances trainingData = new Instances(data, 0, 14);
Instances testingData = new Instances(data, 14, 5);
创建一个Evaluation
类实例来评估模型。还创建了一个SMO
类的实例。SMO
类的buildClassifier
方法使用数据集构建分类器:
Evaluation evaluation = new Evaluation(trainingData);
Classifier smo = new SMO();
smo.buildClassifier(data);
evaluateModel
方法使用测试数据评估模型。然后显示结果:
evaluation.evaluateModel(smo, testingData);
System.out.println(evaluation.toSummaryString());
输出如下。请注意一个错误分类的实例。这对应于前面提到的异常值:
Correctly Classified Instances 4 80 %
Incorrectly Classified Instances 1 20 %
Kappa statistic 0.6154
Mean absolute error 0.2
Root mean squared error 0.4472
Relative absolute error 41.0256 %
Root relative squared error 91.0208 %
Coverage of cases (0.95 level) 80 %
Mean rel. region size (0.95 level) 50 %
Total Number of Instances 5
测试个别实例
我们还可以使用classifyInstance
方法测试一个单独的实例。在下面的序列中,我们使用DenseInstance
类创建一个新的实例。然后使用露营数据集的属性对其进行填充:
Instance instance = new DenseInstance(3);
instance.setValue(data.attribute("age"), 78);
instance.setValue(data.attribute("income"), 125700);
instance.setValue(data.attribute("camps"), 1);
需要使用setDataset
方法将实例与数据集相关联:
instance.setDataset(data);
然后将classifyInstance
方法应用于smo
实例,并显示结果:
System.out.println(smo.classifyInstance(instance));
执行时,我们得到以下输出:
1.0
也有替代的测试方法。常见的一种叫做交叉验证折叠。这种方法将数据集分为褶皱、褶皱,这些褶皱是数据集的分区。通常会创建 10 个分区。九个分区用于训练,一个用于测试。每次使用数据集的不同分区重复 10 次,并使用结果的平均值。这个技巧在https://WEKA . wikispaces . com/Generating+cross-validation+folds+(Java+approach)有描述。
我们现在将检查贝叶斯网络的目的和使用。
贝叶斯网络
贝叶斯网络,也称为贝叶斯网或信念网络,是通过描述世界不同属性的状态及其统计关系来反映特定世界或环境的模型。这些模型可以用来展示各种各样的真实场景。在下图中,我们建立了一个系统模型,描述了各种因素与我们上班迟到可能性之间的关系:
贝叶斯网络
图上的每个圆圈代表系统的一个节点或部分,它可以有不同的值和每个值的概率。例如,停电可能是真的或假的——要么停电,要么没有停电。停电的概率会影响你的闹钟不响的概率,你可能会睡过头,从而上班迟到。
图表顶部的节点往往比底部的节点意味着更高层次的因果关系。更高的节点称为父节点,它们可能有一个或多个子节点。贝叶斯网络只涉及具有因果相关性的节点,因此允许更有效地计算概率。与其他模型不同,我们不必存储和分析每个节点的每种可能的状态组合。相反,我们可以计算和存储相关节点的概率。此外,贝叶斯网络很容易适应,并且可以随着关于特定世界的更多知识的获得而增长。
使用贝叶斯网络
为了使用 Java 对这种类型的网络进行建模,我们将使用 JB eyes(https://github.com/vangj/jbayes)来创建一个网络。JBayes 是一个开源库,用于创建一个简单的贝叶斯信念网络 ( BBN )。它可以免费用于个人或商业用途。在我们的下一个例子中,我们将执行近似推理,这是一种被认为不太准确但可以减少计算时间的技术。这种技术经常在处理大数据时使用,因为它可以在合理的时间内生成可靠的模型。我们通过对每个节点进行加权采样来进行近似推理。JBayes 还提供了对精确推理的支持。精确推断最常用于较小的数据集或准确性非常重要的情况。JBayes 使用连接树算法执行精确推理。
为了开始我们的近似推理模型,我们将首先创建我们的节点。我们将使用前面描述影响准时到达的属性的图表来构建我们的网络。在下面的代码示例中,我们使用方法链接来创建节点。其中三个方法带有一个String
参数。name
方法是与每个节点相关联的名称。为了简洁起见,我们只使用首字母,所以 s 代表storms
, t
代表traffic
,以此类推。value
方法允许我们为节点设置值。在每种情况下,我们的节点只能有两个值:t
表示真,或者f
表示假:
Node storms = Node.newBuilder().name("s").value("t").value("f").build();
Node traffic = Node.newBuilder().name("t").value("t").value("f").build();
Node powerOut = Node.newBuilder().name("p").value("t").value("f").build();
Node alarm = Node.newBuilder().name("a").value("t").value("f").build();
Node overslept = Node.newBuilder().name("o").value("t").value("f").build();
Node lateToWork = Node.newBuilder().name("l").value("t").value("f").build();
接下来,我们为每个子节点分配父节点。请注意,storms
是traffic
和powerOut
的父节点。lateToWork
节点有两个父节点,traffic
和overslept
:
traffic.addParent(storms);
powerOut.addParent(storms);
lateToWork.addParent(traffic);
alarm.addParent(powerOut);
overslept.addParent(alarm);
lateToWork.addParent(overslept);
然后,我们为每个节点定义条件概率表 ( CPTs )。这些表基本上是表示每个节点的每个属性的概率的二维数组。如果我们有不止一个父节点,就像在lateToWork
节点的情况下,我们需要为每个节点准备一行。在这个例子中,我们使用了任意的概率值,但是注意每一行的总和必须是1.0
:
storms.setCpt(new double[][] {{0.7, 0.3}});
traffic.setCpt(new double[][] {{0.8, 0.2}});
powerOut.setCpt(new double[][] {{0.5, 0.5}});
alarm.setCpt(new double[][] {{0.7, 0.3}});
overslept.setCpt(new double[][] {{0.5, 0.5}});
lateToWork.setCpt(new double[][] {
{0.5, 0.5},
{0.5, 0.5}
});
最后,我们创建一个Graph
对象,并将每个节点添加到我们的图结构中。然后,我们使用此图进行采样:
Graph bayesGraph = new Graph();
bayesGraph.addNode(storms);
bayesGraph.addNode(traffic);
bayesGraph.addNode(powerOut);
bayesGraph.addNode(alarm);
bayesGraph.addNode(overslept);
bayesGraph.addNode(lateToWork);
bayesGraph.sample(1000);
此时,我们可能对每个事件的概率感兴趣。我们可以使用prob
方法来检查每个节点的True
或False
值的概率:
double[] stormProb = storms.probs();
double[] trafProb = traffic.probs();
double[] powerProb = powerOut.probs();
double[] alarmProb = alarm.probs();
double[] overProb = overslept.probs();
double[] lateProb = lateToWork.probs();
out.println("nStorm Probabilities");
out.println("True: " + stormProb[0] + " False: " + stormProb[1]);
out.println("nTraffic Probabilities");
out.println("True: " + trafProb[0] + " False: " + trafProb[1]);
out.println("nPower Outage Probabilities");
out.println("True: " + powerProb[0] + " False: " + powerProb[1]);
out.println("vAlarm Probabilities");
out.println("True: " + alarmProb[0] + " False: " + alarmProb[1]);
out.println("nOverslept Probabilities");
out.println("True: " + overProb[0] + " False: " + overProb[1]);
out.println("nLate to Work Probabilities");
out.println("True: " + lateProb[0] + " False: " + lateProb[1]);
我们的输出包含每个节点的每个值的概率。例如,风暴发生的概率是 71%,而不发生的概率是 29%:
Storm Probabilities
True: 0.71 False: 0.29
Traffic Probabilities
True: 0.726 False: 0.274
Power Outage Probabilities
True: 0.442 False: 0.558
Alarm Probabilities
True: 0.543 False: 0.457
Overslept Probabilities
True: 0.556 False: 0.444
Late to Work Probabilities
True: 0.469 False: 0.531
注意
请注意,在这个例子中,我们使用了产生上班迟到可能性非常高的数字,大约为 47%。这是因为我们已经将父节点的概率设置得相当高。如果风暴发生的几率较低,或者如果我们也改变了一些其他的子节点,这个数据会有很大的变化。
如果我们想保存有关样本的信息,可以使用以下代码将数据保存到 CSV 文件中:
try {
CsvUtil.saveSamples(bayesGraph, new FileWriter(
new File("C://JBayesInfo.csv")));
} catch (IOException e) {
// Handle exceptions
}
关于监督学习的讨论结束后,我们现在将转向无监督学习。
无监督机器学习
无监督机器学习不使用带注释的数据;也就是说,数据集确实包含预期的结果。虽然有几种无监督学习算法,但我们将展示关联规则学习的使用来说明这种学习方法。
关联规则学习
关联规则学习是一种识别数据项之间关系的技术。这是所谓的市场篮子分析的一部分。当购物者进行购买时,这些购买很可能由不止一个项目组成,并且当它这样做时,有某些项目倾向于一起购买。关联规则学习是识别这些相关项目的一种方法。当发现关联时,可以为其制定规则。
例如,如果顾客购买尿布和乳液,他们也可能购买婴儿湿巾。分析可以发现这些关联,并且可以形成陈述观察结果的规则。该规则将被表达为{尿布、洗液} =>{湿巾} 。能够识别这些购买模式允许商店提供特殊优惠券,安排他们的产品更容易得到,或者实现任何数量的其他市场相关活动。
这种技术的一个问题是存在大量可能的关联。一种常用的有效方法是先验算法。该算法处理由一组项目定义的事务集合。这些项目可以被认为是购买,而交易可以被认为是一起购买的一组项目。该集合通常被称为数据库。
考虑下面的一组交易,其中, 1 表示该物品是作为交易的一部分购买的,而 0 表示该物品没有被购买:
| 交易 ID | 尿布 | 乳液 | 湿巾 | 公式 |
| one | one | one | one | Zero |
| Two | one | one | one | one |
| three | Zero | one | one | Zero |
| four | one | Zero | Zero | Zero |
| five | Zero | one | one | one |
先验模型使用了几个分析术语:
- Support :这是数据库中包含项目子集的项目的比例。在之前的数据库中,{尿不湿,乳液} 项出现 2/5 次或者 20% 。
- 置信度:这是对规则为真的频率的度量。其计算方式为conf(X->Y)= sup(X∪Y)/sup(X)。
- Lift :衡量项目相互依赖的程度。定义为 lift(X->Y)=sup(X∪Y)/(sup(X) sup(Y))*。
- 杠杆率:杠杆率是指在 X 和 Y 相互独立的情况下, X 和 Y 所涵盖的交易数量。高于 0 的值是一个好的指示器。计算方法为 lev(X- > Y) = sup(X,Y) - sup(X) * sup(Y) 。
- 信念:衡量规则做出错误决定的频率。定义为conv(X->Y)= 1-sup(Y)/(1-conf(X->Y))。
这些定义和样本值可以在https://en.wikipedia.org/wiki/Association_rule_learning找到。
利用关联规则学习发现购买关系
我们将使用Apriori
Weka 类来演示 Java 对使用两个数据集的算法的支持。第一个是之前讨论的数据,第二个是关于一个人在徒步旅行中可能携带的物品。
以下是婴儿信息的数据文件babies.arff
:
@relation TEST_ITEM_TRANS
@attribute Diapers {1, 0}
@attribute Lotion {1, 0}
@attribute Wipes {1, 0}
@attribute Formula {1, 0}
@data
1,1,1,0
1,1,1,1
0,1,1,0
1,0,0,0
0,1,1,1
我们从使用一个BufferedReader
实例读入文件开始。这个对象被用作Instances
类的参数,它将保存数据:
try {
BufferedReader br;
br = new BufferedReader(new FileReader("babies.arff"));
Instances data = new Instances(br);
br.close();
...
} catch (Exception ex) {
// Handle exceptions
}
接下来,创建一个Apriori
实例。我们设置要生成的规则数量和规则的最小置信度:
Apriori apriori = new Apriori();
apriori.setNumRules(100);
apriori.setMinMetric(0.5);
buildAssociations
方法使用Instances
变量生成关联。然后显示关联:
apriori.buildAssociations(data);
System.out.println(apriori);
将显示 100 条规则。以下是简短的输出。每个规则后面都有该规则的各种度量:
注意
请注意,规则 8 和 100 反映了前面的例子。
Apriori
=======
Minimum support: 0.3 (1 instances)
Minimum metric <confidence>: 0.5
Number of cycles performed: 14
Generated sets of large itemsets:
Size of set of large itemsets L(1): 8
Size of set of large itemsets L(2): 18
Size of set of large itemsets L(3): 16
Size of set of large itemsets L(4): 5
Best rules found:
1\. Wipes=1 4 ==> Lotion=1 4 <conf:(1)> lift:(1.25) lev:(0.16) [0] conv:(0.8)
2\. Lotion=1 4 ==> Wipes=1 4 <conf:(1)> lift:(1.25) lev:(0.16) [0] conv:(0.8)
3\. Diapers=0 2 ==> Lotion=1 2 <conf:(1)> lift:(1.25) lev:(0.08) [0] conv:(0.4)
4\. Diapers=0 2 ==> Wipes=1 2 <conf:(1)> lift:(1.25) lev:(0.08) [0] conv:(0.4)
5\. Formula=1 2 ==> Lotion=1 2 <conf:(1)> lift:(1.25) lev:(0.08) [0] conv:(0.4)
6\. Formula=1 2 ==> Wipes=1 2 <conf:(1)> lift:(1.25) lev:(0.08) [0] conv:(0.4)
7\. Diapers=1 Wipes=1 2 ==> Lotion=1 2 <conf:(1)> lift:(1.25) lev:(0.08) [0] conv:(0.4)
8\. Diapers=1 Lotion=1 2 ==> Wipes=1 2 <conf:(1)> lift:(1.25) lev:(0.08) [0] conv:(0.4)
...
62\. Diapers=0 Lotion=1 Formula=1 1 ==> Wipes=1 1 <conf:(1)> lift:(1.25) lev:(0.04) [0] conv:(0.2)
...
99\. Lotion=1 Formula=1 2 ==> Diapers=1 1 <conf:(0.5)> lift:(0.83) lev:(-0.04) [0] conv:(0.4)
100\. Diapers=1 Lotion=1 2 ==> Formula=1 1 <conf:(0.5)> lift:(1.25) lev:(0.04) [0] conv:(0.6)
这为我们提供了一个关系列表,我们可以用它来识别购买行为等活动中的模式。
强化学习
强化学习是当前神经网络和机器学习研究前沿的一种学习类型。与无监督和有监督的学习不同,强化学习基于动作的结果做出决策。这是一个以目标为导向的学习过程,类似于世界各地许多家长和教师使用的方法。我们教孩子们学习并在考试中表现出色,这样他们就能得到高分作为奖励。同样,强化学习可以用来教机器做出能带来最高回报的选择。
强化学习有四个主要组成部分:行动者或代理人、状态或场景、选择的行动和奖励。参与者是在应用程序中做出决策的对象或工具。国家是行动者存在的世界。行动者做出的任何决定都发生在国家的参数范围内。动作只是演员在给定一组选项时做出的选择。回报是每一个行动的结果,并影响未来选择特定行动的可能性。
必须指出,行动和行动发生的国家不是独立的。事实上,正确的或回报最高的行为往往取决于行为发生的状态。如果演员试图决定如何穿过水体,如果水体平静且相当小,游泳可能是一个不错的选择。如果演员想横渡太平洋,游泳将是一个可怕的选择。
要处理这个问题,我们可以考虑 Q 函数。该功能是由特定状态到该状态中的动作的映射产生的。Q 函数会将游过太平洋的奖励比游过小河的奖励低。Q 函数不是说游泳是一种低回报的活动,而是允许游泳有时有低回报,而其他时候有更高的回报。
强化学习总是从一张白纸开始。当迭代第一次开始时,参与者不知道最佳路径或决策序列。然而,在通过给定问题的多次迭代之后,考虑每个特定状态-动作对选择的结果,算法改进并学习做出最高回报的选择。
用于实现强化学习的算法包括在一系列复杂的过程和选择中实现回报的最大化。虽然目前正在视频游戏和其他离散环境中进行测试,但最终目标是这些算法在不可预测的现实世界场景中取得成功。在强化学习的主题中,有三种主要风格或类型:时间差异学习、q `-学习和状态-动作-奖励-状态-动作 ( SARSA )。
时间差异学习考虑先前学习的信息,以通知未来的决策。这种类型的学习假设了过去和未来决策之间的相关性。在采取行动之前,会进行预测。在选择行动之前,将该预测与关于环境的其他已知信息和类似决策进行比较。这一过程被称为自举,被认为是创造更准确和有用的结果。
Q-learning 使用上面提到的 Q 函数,不仅选择给定状态下某一特定步骤的最佳动作,而且选择从该点向前将导致最高奖励的动作。这就是所谓的最优策略。Q-learning 提供的一个很大的优势是不需要完整的状态模型就能做出决策。这使得它在行动和奖励随机变化的状态下发挥作用。
SARSA 是另一种用于强化学习的算法。它的名字是不言自明的:Q 值取决于当前的状态,当前选择的动作,该动作的奖励,动作完成后代理将存在的状态,以及在新状态下采取的后续动作。该算法向前看一步,以做出最佳决策。
目前可用于使用 Java 执行强化学习的工具有限。一个流行的工具是用于实现 Q 学习实验的平台 ( Piqle )。这个 Java 框架旨在为快速设计和测试或强化学习实验提供工具。Piqle 可以从 http://piqle.sourceforge.net 的下载。另一个健壮的工具叫做布朗-UMBC 强化学习和规划 ( BURPLAP )。在 http://burlap.cs.brown.edu发现的这个库也是为强化学习的算法和领域的开发而设计的。这种特殊的资源以状态和动作的灵活性而自豪,并支持广泛的规划和学习算法。BURLAP 还包括用于可视化目的的分析工具。
总结
机器学习与开发技术有关,这些技术允许应用程序学习,而不必显式编程来解决问题。这种灵活性允许这种应用程序在几乎不做修改的情况下用于更多样的设置中。
我们看到了如何使用训练数据来创建模型。一旦训练了模型,就使用测试数据来评估该模型。训练数据和测试数据都来自问题域。一旦完成训练,该模型将与其他输入数据一起用于进行预测。
我们学习了如何使用 Weka Java API 来创建决策树。该树由代表问题不同属性的内部节点组成。树叶代表结果。因为有许多方法来构造一棵树,所以决策树的一部分工作就是创建最好的树。
支持向量机将数据集分成多个部分,从而对数据集中的元素进行分类。这种分类基于数据的属性,如年龄、头发颜色或体重。使用该模型,可以根据数据实例的属性预测结果。
贝叶斯网络用于根据节点之间的父子关系进行预测。一个事件的概率直接影响子事件的概率,我们可以使用这些信息来预测复杂现实环境的结果。
在关联规则学习部分,我们学习了如何识别数据集元素之间的关系。更重要的关系允许我们建立规则来解决各种问题。
在我们对强化学习的讨论中,我们讨论了主体、状态、行动和奖励的要素以及它们之间的关系。我们还讨论了强化学习的具体类型,并为进一步的研究提供了资源。
在介绍了机器学习的要素之后,我们现在准备探索神经网络,这将在下一章中找到。
七、神经网络
虽然神经网络已经存在了很多年,但由于改进的算法和更强大的机器,它们变得越来越受欢迎。一些公司正在构建明确模仿神经网络的硬件系统(https://www.wired.com/2016/05/google-tpu-custom-chips/)。使用这种多功能技术来解决数据科学问题的时候到了。
在这一章中,我们将探索神经网络背后的思想和概念,然后演示它们的使用。具体来说,我们将:
-
定义并举例说明神经网络
-
描述他们是如何被训练的
-
检查各种神经网络架构
-
Discuss and demonstrate several different neural networks, including:
- 一个简单的 Java 例子
- 一个多层感知器 ( MLP )网络
- k-最近邻 ( k-NN )算法等
我们称之为神经网络的人工神经网络 ( 安)的想法源于大脑中发现的神经元。一个神经元是一个有树突将其连接到输入源和其他神经元的细胞。它通过树突从多个来源接收刺激。根据源,分配给源的权重,神经元被激活,并且发射信号沿着树突到达另一个神经元。可以训练一组神经元,它们将对一组特定的输入信号做出反应。
人工神经元是具有一个或多个输入和单个输出的节点。每个输入都有一个与之关联的权重。通过加权输入,我们可以放大或缩小输入。
注意
人工神经元被交替称为感知器。
这在下图中有所描述,其中权重被相加,然后被发送到决定输出的激活函数。
神经元以及最终神经元集合以两种模式之一运行:
- 训练模式 -神经元被训练为在接收到某组输入时触发
- 测试模式 -向神经元提供输入,神经元根据训练对一组已知的输入做出响应
数据集通常分为两部分。更大的部分用于训练模型。第二部分用于验证模型。
神经元的输出由加权输入的总和决定。一个神经元是否放电是由一个激活函数决定的。有几种不同类型的激活功能,包括:
- 阶跃函数 -使用加权输入的总和计算该线性函数,如下所示:
f(Net) 表示一个函数的输出。如果网输入大于激活阈值,则为 1 。当这种情况发生时,神经元就会放电。否则它返回 0 并且不触发。该值是基于所有树突输入计算的。
- Sigmoid -这是一个非线性函数,计算如下:
随着神经元被训练,每个输入的权重可以被调整。
与阶跃函数相比,sigmoid 函数是非线性的。这更好地匹配了一些问题域。我们将找到多层神经网络中使用的 sigmoid 函数。
训练一个神经网络
有三种基本的培训方法:
- 监督学习 -通过监督学习,用匹配输入集和输出值的数据训练模型
- 无监督学习 -在无监督学习中,数据不包含结果,但是模型被期望自己确定关系
- 强化学习 -类似于监督学习,但是对好的结果提供奖励
这些数据集包含的信息不同。监督和强化学习包含一组输入的正确输出。无监督学习不包含正确的结果。
神经网络通过将输入输入到网络中并使用激活函数将结果与预期结果进行比较来进行学习(至少使用监督学习)。如果它们匹配,那么网络已经被正确训练。如果它们不匹配,则网络被修改。
当我们修改权重时,我们需要小心不要改变太大。如果变化太大,那么结果可能变化太大,我们可能会错过期望的输出。如果变化太少,那么训练模型将花费太长时间。有些时候,我们可能不想改变一些权重。
一个偏置单元是一个具有恒定输出的神经元。它总是一个,有时被称为假节点。这个神经元类似于一个偏移量,对于大多数网络的正常运行至关重要。你可以将偏差神经元比作斜率截距形式的线性函数的y-截距。正如调整y-截距值会改变线的位置,但不会改变形状/斜率一样,偏置神经元可以在不调整网络形状或功能的情况下改变输出值。您可以调整输出以适应问题的特殊需要。
神经网络架构入门
神经网络通常使用一系列神经元层来创建。通常有一个输入层,一个或多个中间层(隐藏层,以及一个输出层。
以下是前馈网络的描述:
节点和层的数量会有所不同。前馈网络将信息向前传递。也有信息反向传递的反馈网络。需要多个隐藏层来处理大多数分析所需的更复杂的处理。
在本章中,我们将讨论与不同类型的神经网络相关的几种架构和算法。由于需要解释的复杂性和长度,我们将只提供对几个关键网络类型的深入分析。具体来说,我们将演示一个简单的神经网络、MLPs 和自组织映射 ( SOMs )。
但是,我们将提供许多不同选项的概述。适用于任何特定模型的神经网络和算法实现的类型将取决于所解决的问题。
了解静态神经网络
静态神经网络是经过训练或学习阶段,然后在使用时不会改变的人工神经网络。它们不同于动态神经网络,动态神经网络不断学习,并且在初始训练期之后可能经历结构变化。当模型的结果相对容易重现或更容易预测时,静态神经网络非常有用。我们一会儿将看动态神经网络,但是我们将从创建我们自己的基本静态神经网络开始。
一个基本的 Java 例子
在我们研究可用于构建神经网络的各种库和工具之前,我们将使用标准 Java 库实现我们自己的基本神经网络。下一个例子是改编自杰夫·希顿(http://www.informit.com/articles/article.aspx?p=30596)的作品。我们将构建一个前馈反向传播神经网络,并训练它识别 XOR 运算符模式。以下是 XOR 的基本真值表:
| X | Y | 结果 |
| 0
| 0
| 0
|
| 0
| 1
| 1
|
| 1
| 0
| 1
|
| 1
| 1
| 0
|
该网络只需要对应于 X 和 Y 输入和结果的两个输入神经元和一个输出神经元。模型所需的输入和输出神经元的数量取决于手头的问题。隐藏神经元的数量通常是输入和输出神经元数量的总和,但随着训练的进行,确切的数量可能需要改变。
接下来,我们将演示如何创建和训练网络。我们首先给网络提供一个输入,然后观察输出。将输出与预期输出进行比较,然后调整称为weightChanges
的权重矩阵。这种调整确保了随后的输出将更接近预期的输出。重复这个过程,直到我们对网络能够产生足够接近预期输出的结果感到满意。在本例中,我们将输入和输出表示为双精度数组,其中每个输入或输出神经元都是数组的一个元素。
注意
输入和输出有时被称为模式。
首先,我们将创建一个SampleNeuralNetwork
类来实现网络。首先将下面列出的变量添加到该类中。我们将在本节的后面讨论和演示它们的用途。我们的类包含以下实例变量:
double errors;
int inputNeurons;
int outputNeurons;
int hiddenNeurons;
int totalNeurons;
int weights;
double learningRate;
double outputResults[];
double resultsMatrix[];
double lastErrors[];
double changes[];
double thresholds[];
double weightChanges[];
double allThresholds[];
double threshChanges[];
double momentum;
double errorChanges[];
接下来,让我们看看我们的构造函数。我们有四个参数,代表我们网络的输入数量、隐藏层中神经元的数量、输出神经元的数量以及我们希望学习发生的速率和动量。learningRate
是指定训练过程中体重和偏差变化幅度的参数。momentum
参数指定应该添加先前权重的多少部分来创建新的权重。防止在局部最小值或鞍点处收敛是有用的。高动量会加快系统的收敛速度,但如果动量太高,会导致系统不稳定。动量和学习率都应该是在0
和1
之间的值:
public SampleNeuralNetwork(int inputCount,
int hiddenCount,
int outputCount,
double learnRate,
double momentum) {
...
}
在我们的构造函数中,我们初始化所有私有的实例变量。注意totalNeurons
被设置为所有输入、输出和隐藏神经元的总和。这个总和然后被用来设置其他几个变量。还要注意的是,weights
变量是通过找出输入和隐藏神经元的数量的乘积、隐藏神经元和输出的乘积,并将这两个乘积相加而计算出来的。然后用它来创建新的长度权重数组:
learningRate = learnRate;
momentum = momentum;
inputNeurons = inputCount;
hiddenNeurons = hiddenCount;
outputNeurons = outputCount;
totalNeurons = inputCount + hiddenCount + outputCount;
weights = (inputCount * hiddenCount)
+ (hiddenCount * outputCount);
outputResults = new double[totalNeurons];
resultsMatrix = new double[weights];
weightChanges = new double[weights];
thresholds = new double[totalNeurons];
errorChanges = new double[totalNeurons];
lastErrors = new double[totalNeurons];
allThresholds = new double[totalNeurons];
changes = new double[weights];
threshChanges = new double[totalNeurons];
reset();
注意,我们在构造函数的末尾调用了reset
方法。这种方法重置网络,用随机权重矩阵开始训练。它将阈值和结果矩阵初始化为随机值。它还确保用于跟踪变化的所有矩阵被设置回零。使用随机值可确保获得不同的结果:
public void reset() {
int loc;
for (loc = 0; loc < totalNeurons; loc++) {
thresholds[loc] = 0.5 - (Math.random());
threshChanges[loc] = 0;
allThresholds[loc] = 0;
}
for (loc = 0; loc < resultsMatrix.length; loc++) {
resultsMatrix[loc] = 0.5 - (Math.random());
weightChanges[loc] = 0;
changes[loc] = 0;
}
}
我们还需要一个叫做calcThreshold
的方法。阈值值指定了在神经元触发之前,该值与实际激活阈值的接近程度。例如,一个神经元可能具有激活阈值1
。阈值指定诸如0.999
之类的数字是否算作1
。该方法将在后续方法中用于计算单个值的阈值:
public double threshold(double sum) {
return 1.0 / (1 + Math.exp(-1.0 * sum));
}
接下来,我们将添加一个方法,使用一组给定的输入来计算输出。我们的输入参数和方法返回的数据都是由double
值组成的数组。首先,我们需要在循环中使用两个位置变量,loc
和pos
。我们还想根据输入和隐藏神经元的数量来跟踪我们在数组中的位置。隐藏神经元的索引将在输入神经元之后开始,因此它的位置与输入神经元的数量相同。我们输出神经元的位置是我们输入神经元和隐藏神经元的总和。我们还需要初始化我们的outputResults
数组:
public double[] calcOutput(double input[]) {
int loc, pos;
final int hiddenIndex = inputNeurons;
final int outIndex = inputNeurons + hiddenNeurons;
for (loc = 0; loc < inputNeurons; loc++) {
outputResults[loc] = input[loc];
}
...
}
然后,我们根据网络第一层的输入神经元计算输出。注意我们在本节中使用了threshold
方法。在我们将总和放入outputResults
数组之前,我们需要利用threshold
方法:
int rLoc = 0;
for (loc = hiddenIndex; loc < outIndex; loc++) {
double sum = thresholds[loc];
for (pos = 0; pos < inputNeurons; pos++) {
sum += outputResults[pos] * resultsMatrix[rLoc++];
}
outputResults[loc] = threshold(sum);
}
现在我们考虑我们隐藏的神经元。请注意,这个过程与上一节类似,但是我们计算的是隐藏层的输出,而不是输入层的输出。最后,我们返回我们的结果。这个结果是一个双精度数组,包含每个输出神经元的值。在我们的例子中,只有一个输出神经元:
double result[] = new double[outputNeurons];
for (loc = outIndex; loc < totalNeurons; loc++) {
double sum = thresholds[loc];
for (pos = hiddenIndex; pos < outIndex; pos++) {
sum += outputResults[pos] * resultsMatrix[rLoc++];
}
outputResults[loc] = threshold(sum);
result[loc-outIndex] = outputResults[loc];
}
return result;
给定我们的 XOR 表,输出很可能与预期的输出不匹配。为了解决这个问题,我们使用误差计算方法来调整网络的权重,以产生更好的输出。我们将讨论的第一种方法是calcError
方法。每当calcOutput
方法返回一组输出时,就会调用这个方法。它不返回数据,而是修改包含权重和阈值的数组。该方法采用表示每个输出神经元的理想值的双精度数组。请注意,我们像在calcOutput
方法中一样开始,并设置了在整个方法中使用的索引。然后,我们清除任何现有的隐藏层错误:
public void calcError(double ideal[]) {
int loc, pos;
final int hiddenIndex = inputNeurons;
final int outputIndex = inputNeurons + hiddenNeurons;
for (loc = inputNeurons; loc < totalNeurons; loc++) {
lastErrors[loc] = 0;
}
接下来,我们计算预期产量和实际产量之间的差异。这使我们能够确定如何调整重量,以便进一步训练。为此,我们遍历包含预期输出ideal
和实际输出outputResults
的数组。我们还在本节中调整我们的误差和误差变化:
for (loc = outputIndex; loc < totalNeurons; loc++) {
lastErrors[loc] = ideal[loc - outputIndex] -
outputResults[loc];
errors += lastErrors[loc] * lastErrors[loc];
errorChanges[loc] = lastErrors[loc] * outputResults[loc]
*(1 - outputResults[loc]);
}
int locx = inputNeurons * hiddenNeurons;
for (loc = outputIndex; loc < totalNeurons; loc++) {
for (pos = hiddenIndex; pos < outputIndex; pos++) {
changes[locx] += errorChanges[loc] *
outputResults[pos];
lastErrors[pos] += resultsMatrix[locx] *
errorChanges[loc];
locx++;
}
allThresholds[loc] += errorChanges[loc];
}
接下来,我们计算并存储每个神经元的误差变化。我们使用lastErrors
数组来修改errorChanges
数组,它包含总误差:
for (loc = hiddenIndex; loc < outputIndex; loc++) {
errorChanges[loc] = lastErrors[loc] *outputResults[loc]
* (1 - outputResults[loc]);
}
我们还通过修改allThresholds
数组来微调我们的系统。监控误差和阈值的变化很重要,这样网络可以提高其产生正确输出的能力:
locx = 0;
for (loc = hiddenIndex; loc < outputIndex; loc++) {
for (pos = 0; pos < hiddenIndex; pos++) {
changes[locx] += errorChanges[loc] *
outputResults[pos];
lastErrors[pos] += resultsMatrix[locx] *
errorChanges[loc];
locx++;
}
allThresholds[loc] += errorChanges[loc];
}
}
我们还有另一种计算网络误差的方法。getError
方法计算我们整个训练数据集的均方根。这使我们能够确定数据的平均错误率:
public double getError(int len) {
double err = Math.sqrt(errors / (len * outputNeurons));
errors = 0;
return err;
}
既然我们可以初始化我们的网络,计算输出,并计算误差,我们准备好训练我们的网络。我们通过使用train
方法来实现这一点。该方法首先根据前一方法中计算的误差调整权重,然后调整阈值:
public void train() {
int loc;
for (loc = 0; loc < resultsMatrix.length; loc++) {
weightChanges[loc] = (learningRate * changes[loc]) +
(momentum * weightChanges[loc]);
resultsMatrix[loc] += weightChanges[loc];
changes[loc] = 0;
}
for (loc = inputNeurons; loc < totalNeurons; loc++) {
threshChanges[loc] = learningRate * allThresholds[loc] +
(momentum * threshChanges[loc]);
thresholds[loc] += threshChanges[loc];
allThresholds[loc] = 0;
}
}
最后,我们可以创建一个新类来测试我们的神经网络。在另一个类的main
方法中,添加以下代码来表示 XOR 问题:
double xorIN[][] ={
{0.0,0.0},
{1.0,0.0},
{0.0,1.0},
{1.0,1.0}};
double xorEXPECTED[][] = { {0.0},{1.0},{1.0},{0.0}};
接下来,我们要创建新的SampleNeuralNetwork
对象。在下面的例子中,我们有两个输入神经元、三个隐藏神经元、一个输出神经元(XOR 结果)、一个学习速率0.7
和一个动量0.9
。隐藏神经元的数量通常最好通过反复试验来确定。在后续执行中,考虑调整此构造函数中的值,并检查结果的差异:
SampleNeuralNetwork network = new
SampleNeuralNetwork(2,3,1,0.7,0.9);
注意
学习率和动量通常应该在零和一之间。
然后,我们反复调用我们的calcOutput
、calcError
和train
方法,按这个顺序。这允许我们测试我们的输出,计算错误率,调整我们的网络权重,然后再试一次。我们的网络应该显示越来越准确的结果:
for (int runCnt=0;runCnt<10000;runCnt++) {
for (int loc=0;loc<xorIN.length;loc++) {
network.calcOutput(xorIN[loc]);
network.calcError(xorEXPECTED[loc]);
network.train();
}
System.out.println("Trial #" + runCnt + ",Error:" +
network.getError(xorIN.length));
}
执行应用程序,注意错误率随着循环的每次迭代而变化。可接受的错误率将取决于特定的网络及其目的。下面是前面代码的一些输出示例。为简洁起见,我们包括了第一个和最后一个培训输出。请注意,错误率最初高于 50%,但在最后一次运行时降至接近 1%。
Trial #0,Error:0.5338334002845255
Trial #1,Error:0.5233475199946769
Trial #2,Error:0.5229843653785426
Trial #3,Error:0.5226263062497853
Trial #4,Error:0.5226916275713371
...
Trial #994,Error:0.014457034704806316
Trial #995,Error:0.01444865096401158
Trial #996,Error:0.01444028142777395
Trial #997,Error:0.014431926056394229
Trial #998,Error:0.01442358481032747
Trial #999,Error:0.014415257650182488
在这个例子中,我们使用了一个小规模的问题,我们能够相当快地训练我们的网络。在更大规模的问题中,我们将从一组训练数据开始,然后使用额外的数据集进行进一步分析。因为在这个场景中我们实际上只有四个输入,所以我们不会用任何额外的数据来测试它。
此示例演示了神经网络的一些内部工作方式,包括如何计算误差和输出的详细信息。通过探索一个相对简单的问题,我们能够检查神经网络的机制。然而,在接下来的例子中,我们将使用对我们隐藏这些细节的工具,但是允许我们进行稳健的分析。
了解动态神经网络
动态神经网络不同于静态网络,因为它们在训练阶段之后继续学习。它们可以独立于外部修改对其结构进行调整。一种前馈神经网络(FNN) 是最早也是最简单的动态神经网络之一。这种网络,顾名思义,只是向前反馈信息,不形成任何循环。这种类型的网络为后来动态人工神经网络的许多工作奠定了基础。在这一节中,我们将深入展示两种类型的动态网络,MLP 网络和 SOMs。
多层感知器网络
MLP 网络是具有多层的 FNN。该网络使用具有反向传播的监督学习,其中反馈被发送到早期层以帮助学习过程。一些神经元使用模拟生物神经元的非线性激活函数。一层的每个节点都完全连接到下一层。
我们将使用一个名为dermatology.arff
的数据集,可以从http://repository.seasr.org/Datasets/UCI/arff/下载。该数据集包含 366 个用于诊断红斑-鳞状疾病的实例。它使用 34 个属性将疾病分为五个不同的类别。以下是一个示例实例:
2,2,0,3,0,0,0,0,1,0,0,0,0,0,0,3,2,0,0,0,0,0,0,0,0,0,0,3,0,0,0,1,0,55,2
最后一个字段表示疾病类别。这个数据集被分成两个文件:dermatologyTrainingSet.arff
和dermatologyTestingSet.arff
。训练集使用原始集的前 80% (292 个实例),并以第 456 行结束。测试集是最后的 20% (74 个实例),从原始集的第 457 行开始(第 457-530 行)。
建立模型
在我们做出任何预测之前,有必要根据一组有代表性的数据来训练模型。我们将使用 Weka 类MultilayerPerceptron
进行训练,并最终进行预测。首先,我们为文件名的训练和测试声明字符串,并为它们声明相应的FileReader
实例。创建实例,并将最后一个字段指定为用于分类的字段:
String trainingFileName = "dermatologyTrainingSet.arff";
String testingFileName = "dermatologyTestingSet.arff";
try (FileReader trainingReader = new FileReader(trainingFileName);
FileReader testingReader =
new FileReader(testingFileName)) {
Instances trainingInstances = new Instances(trainingReader);
trainingInstances.setClassIndex(
trainingInstances.numAttributes() - 1);
Instances testingInstances = new Instances(testingReader);
testingInstances.setClassIndex(
testingInstances.numAttributes() - 1);
...
} catch (Exception ex) {
// Handle exceptions
}
然后创建了一个MultilayerPerceptron
类的实例:
MultilayerPerceptron mlp = new MultilayerPerceptron();
我们可以设置几个模型参数,如下所示:
| 参数 | 方法 | 描述 |
| 学习率 | setLearningRate
| 影响训练速度 |
| 动力 | setMomentum
| 影响训练速度 |
| 训练时间 | setTrainingTime
| 用于训练模型的训练时期数 |
| 隐藏层 | setHiddenLayers
| 要使用的隐藏层和感知器的数量 |
如前所述,学习率会影响模型的训练速度。较大的值可以提高训练速度。如果学习率太小,那么训练时间可能会太长。如果学习率太大,那么模型可能会移过局部最小值并变得发散。也就是说,如果增量太大,我们可能会跳过一个有意义的值。你可以把它想象成一个图,在图中沿着 Y 轴的一个小的下降被忽略了,因为我们增加了太多的 T2 X T3 值。
动量也通过有效地增加学习率来影响训练速度。除了学习率之外,它还用于增加搜索最优值的动力。在局部最小值的情况下,动量有助于在寻求全局最小值的过程中摆脱最小值。
当模型学习时,它迭代地执行操作。术语历元用于指代迭代次数。希望每个历元遇到的总误差将减少到进一步的历元不再有用的程度。避免太多的纪元是理想的。
神经网络将有一个或多个隐藏层。每一层都有特定数量的感知器。setHiddenLayers
方法使用一个字符串指定层和感知器的数量。例如, 3,5 将指定两个隐藏层,每层分别有三个和五个感知器。
对于本例,我们将使用以下值:
mlp.setLearningRate(0.1);
mlp.setMomentum(0.2);
mlp.setTrainingTime(2000);
mlp.setHiddenLayers("3");
buildClassifier
方法使用训练数据建立模型:
mlp.buildClassifier(trainingInstances);
评估模型
下一步是评估模型。Evaluation
类用于此目的。它的构造器将训练集作为输入,evaluateModel
方法执行实际的评估。下面的代码使用测试数据集说明了这一点:
Evaluation evaluation = new Evaluation(trainingInstances);
evaluation.evaluateModel(mlp, testingInstances);
显示评估结果的一种简单方法是使用toSummaryString
方法:
System.out.println(evaluation.toSummaryString());
这将显示以下输出:
Correctly Classified Instances 73 98.6486 %
Incorrectly Classified Instances 1 1.3514 %
Kappa statistic 0.9824
Mean absolute error 0.0177
Root mean squared error 0.076
Relative absolute error 6.6173 %
Root relative squared error 20.7173 %
Coverage of cases (0.95 level) 98.6486 %
Mean rel. region size (0.95 level) 18.018 %
Total Number of Instances 74
通常,有必要试验这些参数以获得最佳结果。以下是改变感知器数量的结果:
预测其他值
一旦我们训练了一个模型,我们就可以用它来评估其他数据。在之前的测试数据集中,有一个实例失败了。在下面的代码序列中,标识了该实例,并显示了预测结果和实际结果。
测试数据集的每个实例都被用作classifyInstance
方法的输入。这种方法试图预测正确的结果。将此结果与实例中包含实际值的最后一个字段进行比较。对于不匹配,显示预测值和实际值:
for (int i = 0; i < testingInstances.numInstances(); i++) {
double result = mlp.classifyInstance(
testingInstances.instance(i));
if (result != testingInstances
.instance(i)
.value(testingInstances.numAttributes() - 1)) {
out.println("Classify result: " + result
+ " Correct: " + testingInstances.instance(i)
.value(testingInstances.numAttributes() - 1));
...
}
}
对于测试集,我们得到以下输出:
Classify result: 1.0 Correct: 3.0
我们可以使用MultilayerPerceptron
class' distributionForInstance
方法得到预测正确的可能性。将下面的代码放到前面的循环中。它将捕获不正确的实例,这比基于数据集使用的 34 个属性实例化一个实例更容易。distributionForInstance
方法接受这个实例并返回一个双精度的双元素数组。第一个元素是结果为正的概率,第二个元素是结果为负的概率:
Instance incorrectInstance = testingInstances.instance(i);
incorrectInstance.setDataset(trainingInstances);
double[] distribution = mlp.distributionForInstance(incorrectInstance);
out.println("Probability of being positive: " + distribution[0]);
out.println("Probability of being negative: " + distribution[1]);
该实例的输出如下:
Probability of being positive: 0.00350515156929017
Probability of being negative: 0.9683660500711128
这可以为预测的可靠性提供更定量的感觉。
保存和检索模型
我们还可以保存和检索模型以备后用。要保存模型,构建模型,然后使用SerializationHelper
类的静态方法write
,如下面的代码片段所示。第一个参数是保存模型的文件的名称:
SerializationHelper.write("mlpModel", mlp);
要检索模型,使用相应的read
方法,如下所示:
mlp = (MultilayerPerceptron)SerializationHelper.read("mlpModel");
接下来,我们将学习如何使用另一种有用的神经网络方法,SOMs。
学习矢量量化
学习矢量量化()是另一种特殊类型的动态 ANN。SOMs 是 LVQ 网络的副产品,我们一会儿会讨论它。这种类型的网络实现了一种竞争类型的算法,其中获胜的神经元获得权重。这些类型的网络用于许多不同的应用中,并且被认为比其他一些人工神经网络更自然和直观。特别地,LVQ 对于基于文本的数据的分类是有效的。
基本算法首先设置神经元的数量、每个神经元的权重、神经元的学习速度以及输入向量列表。在这种情况下,向量类似于物理学中的向量,表示提供给输入层神经元的值。当训练网络时,使用向量作为输入,选择获胜神经元,并更新获胜神经元的权重。这个模型是迭代的,将继续运行,直到找到一个解决方案。
自组织地图
SOMs 是一种获取多维数据并将其减少到一个或两个维度的技术。这种压缩技术叫做矢量量化。该技术通常包含一个可视化组件,使人们能够更好地了解数据是如何分类的。SOM 在没有监督的情况下学习。
SOM 有利于发现聚类,这不要与分类混淆。对于分类,我们感兴趣的是在预定义的类别中找到最适合数据实例的。对于聚类,我们感兴趣的是对类别未知的实例进行分组。
SOM 使用神经元网格,通常是二维阵列或六边形网格,代表分配了权重的神经元。输入源连接到这些神经元中的每一个。然后,该技术通过几次迭代来调整分配给每个晶格成员的权重,直到找到最佳拟合。完成后,格网成员将会对输入数据集进行分类。可以查看 SOM 结果来识别类别并将新输入映射到所识别的类别之一。
使用 SOM
我们将使用 Weka 来演示 SOM。但是,它不随标准 Weka 一起安装。相反,我们需要从 https://sourceforge.net/projects/wekaclassalgos/files/下载一套 Weka 分类算法,从 http://www.cis.hut.fi/research/som_pak/下载实际的 SOM 类。分类算法包括对 LVQ 的支持。关于分类算法的更多细节可以在 http://wekaclassalgos.sourceforge.net/找到。
要使用名为SelfOrganizingMap
的 SOM 类,源代码需要在您的项目中。这个类的 Javadoc 可以在 http://jsalatas.ictpro.gr/weka/doc/SelfOrganizingMap/ T2 找到。
我们从创建一个SelfOrganizingMap
类的实例开始。接下来是读入数据并创建一个Instances
对象来保存数据的代码。在这个例子中,我们将使用iris.arff
文件,它可以在 Weka 数据目录中找到。请注意,一旦创建了Instances
对象,我们不会像之前的 Weka 示例那样指定类索引,因为 SOM 使用无监督学习:
SelfOrganizingMap som = new SelfOrganizingMap();
String trainingFileName = "iris.arff";
try (FileReader trainingReader =
new FileReader(trainingFileName)) {
Instances trainingInstances = new Instances(trainingReader);
...
} catch (IOException ex) {
// Handle exceptions
} catch (Exception ex) {
// Handle exceptions
}
buildClusterer
方法将使用训练数据集执行 SOM 算法:
som.buildClusterer(trainingInstances);
显示 SOM 结果
我们现在可以显示操作的结果如下:
out.println(som.toString());
iris
数据集使用五个属性:sepallength
、sepalwidth
、petallength
、petalwidth
和class
。前四个属性是数字,第五个属性有三个可能的值:Iris-setosa
、Iris-versicolor
和Iris-virginica
。下面的简短输出的第一部分标识了四个集群以及每个集群中的实例数量。接下来是每个属性的统计数据:
**Self Organized Map**
**==================**
**Number of clusters: 4**
**Cluster**
**Attribute 0 1 2 3**
**(50) (42) (29) (29)**
**==============================================**
**sepallength**
**value 5.0036 6.2365 5.5823 6.9513**
**min 4.3 5.6 4.9 6.2**
**max 5.8 7 6.3 7.9**
**mean 5.006 6.25 5.5828 6.9586**
**std. dev. 0.3525 0.3536 0.3675 0.5046**
**...**
**class**
**value 0 1.5048 1.0787 2**
**min 0 1 1 2**
**max 0 2 2 2**
**mean 0 1.4524 1.069 2**
**std. dev. 0 0.5038 0.2579 0**
这些统计数据可以提供对数据集的深入了解。如果我们想确定在一个集群中找到了哪个数据集实例,我们可以使用getClusterInstances
方法返回按集群对实例进行分组的数组。如下所示,此方法用于按集群列出实例:
Instances[] clusters = som.getClusterInstances();
int index = 0;
for (Instances instances : clusters) {
out.println("-------Custer " + index);
for (Instance instance : instances) {
out.println(instance);
}
out.println();
index++;
}
正如我们在这个序列的简短输出中看到的,不同的iris
类被分组到不同的集群中:
**-------Custer 0**
**5.1,3.5,1.4,0.2,Iris-setosa**
**4.9,3,1.4,0.2,Iris-setosa**
**4.7,3.2,1.3,0.2,Iris-setosa**
**4.6,3.1,1.5,0.2,Iris-setosa**
**...**
**5.3,3.7,1.5,0.2,Iris-setosa**
**5,3.3,1.4,0.2,Iris-setosa**
**-------Custer 1**
**7,3.2,4.7,1.4,Iris-versicolor**
**6.4,3.2,4.5,1.5,Iris-versicolor**
**6.9,3.1,4.9,1.5,Iris-versicolor**
**...**
**6.5,3,5.2,2,Iris-virginica**
**5.9,3,5.1,1.8,Iris-virginica**
**-------Custer 2**
**5.5,2.3,4,1.3,Iris-versicolor**
**5.7,2.8,4.5,1.3,Iris-versicolor**
**4.9,2.4,3.3,1,Iris-versicolor**
**...**
**4.9,2.5,4.5,1.7,Iris-virginica**
**6,2.2,5,1.5,Iris-virginica**
**-------Custer 3**
**6.3,3.3,6,2.5,Iris-virginica**
**7.1,3,5.9,2.1,Iris-virginica**
**6.5,3,5.8,2.2,Iris-virginica**
**...**
可以使用 Weka GUI 界面直观地显示聚类结果。在下面的截图中,我们使用了 Weka 工作台来分析和可视化 SOM 分析的结果:
可以选择、定制和分析图表的单个部分,如下所示:
然而,在使用SOM
类之前,必须使用WekaPackageManager
将SOM
包添加到 Weka 中。这个过程在https://WEKA . wikispaces . com/How+do+I+use+the+package+manager % 3F讨论。
如果需要将一个新实例映射到一个集群,可以使用distributionForInstance
方法,如预测其他值一节所示。
**
其他网络架构和算法
我们已经讨论了一些最常见和最实用的神经网络。在这一点上,我们还想考虑一些专门的神经网络及其在各个研究领域的应用。这些类型的网络并不完全适合一个特定的类别,但可能仍然是令人感兴趣的。
k-最近邻算法
实现 k-NN 算法的人工神经网络类似于 MLP 网络,但是与赢家通吃策略相比,它显著减少了时间。这种类型的网络在设置初始权重后不需要训练算法,并且其神经元之间的连接较少。我们选择不提供这个算法实现的例子,因为它在 Weka 中的使用非常类似于 MLP 的例子。
这种类型的网络最适合分类任务。因为它利用了懒惰学习技术,将所有计算保留到信息被分类之后,所以它被认为是最简单的模型之一。在这个模型中,神经元根据它们与邻居的距离进行加权。邻居的分类是已知的,因此不需要特定的训练。
即时训练的网络
瞬时训练的神经网络 ( ITNNs )是前馈神经网络。它们很特别,因为它们为每一组独特的训练数据添加了一个新的隐藏神经元。这种类型的网络的主要优点是能够对其他问题进行归纳。
ITNNs 在短期学习情况下特别有用。特别是,这种类型的网络对于具有大型数据集的 web 搜索和其他模式识别功能非常有用。这些网络适用于时间序列预测和其他深度学习目的。
脉冲神经网络
一个脉冲神经网络 ( SNN )是一个更复杂的人工神经网络,因为它不仅考虑了神经元和信息传播,还考虑了每个事件的时间。在这些网络中,不是每个神经元在每次信息传播时都会触发,而是只有当特定神经元的膜电位达到特定阈值时才会触发。膜电位是指神经元的激活水平,非常类似于生物神经元的放电方式。
由于紧密模仿生物神经网络,SNNs 特别适合于生物学研究和应用。它们被用来模拟动物和昆虫的神经系统,并用于预测各种刺激的结果。这些网络有能力创建具有重要细节的非常复杂的模型,但是牺牲时间来实现这个目标。
级联神经网络
级联神经网络(CNN)是一种专门的监督学习算法。在这种类型中,网络最初非常小且简单。随着网络的学习,它逐渐增加新的隐藏单元。一旦添加了节点,其输入权重是恒定的,不能更改或移除。
这种类型的神经网络因其快速的学习速度和动态构建自身的能力而受到称赞。这种网络的用户不必担心拓扑设计。此外,这些网络不需要误差信息的反向传播来进行调整。
全息联想记忆
全息联想记忆 ( 哈姆)是一种特殊类型的复杂神经网络。这是一种与人类自然记忆和视觉分析相关的特殊类型的网络。这种网络对于模式识别和联想记忆任务特别有用,并且可以应用于光学计算。
哈姆试图密切模仿人类的视觉化和模式识别。在这个网络中,不需要迭代就可以学习刺激-反应模式,也不需要误差的反向传播。与本章中讨论的其他网络不同,HAM 不表现出相同类型的连接行为。相反,刺激-反应模式可以存储在单个神经元中。
反向传播和神经网络
反向传播算法是另一种用于训练神经网络的监督学习技术。顾名思义,这种算法计算出计算出的输出误差,然后反向改变每个神经元的权重。反向传播主要用于 MLP 网络。需要注意的是,在使用反向传播之前,必须先进行正向传播。
在其最基本的形式中,该算法包括四个步骤:
- 对给定的一组输入执行前向传播。
- 计算每个输出的误差值。
- 根据每个节点的计算误差更改权重。
- 再次执行正向传播。
当输出与预期输出匹配时,该算法完成。
总结
在这一章中,我们已经提供了人工神经网络的广泛概述,以及几个具体实施的详细检查。我们首先讨论了神经网络的基本属性、训练算法和神经网络结构。
接下来,我们提供了一个使用 Java 实现 XOR 问题的简单静态神经网络的例子。此示例详细解释了用于构建和训练网络的代码,包括训练过程中权重调整背后的一些数学计算。然后,我们讨论了动态神经网络,并提供了两个深入的例子,MLP 和 SOM 网络。他们使用 Weka 工具来创建和训练网络。
最后,我们以对其他网络架构和算法的讨论结束了本章。我们选择了一些比较流行的网络来总结和探索每种类型最有用的情况。我们还在这一节中讨论了反向传播。
在下一章中,我们将在这个介绍的基础上展开,并看看神经网络的深度学习。**
八、深度学习
在本章中,我们将重点讨论神经网络,通常称为深度学习网络 ( DLNs )。这种类型的网络被表征为多层神经网络。这些图层中的每一个都基于前一个图层的输出,可能会识别数据集的要素和子要素。以这种方式创建特征层次。
dln 通常处理非结构化和未标记的数据,这些数据构成了当今世界的大部分数据。DLN 将获取这些非结构化数据,识别特征,并尝试重建原始输入。这种方法用受限玻尔兹曼机 ( RBMs )中的受限玻尔兹曼机和深度自动编码器中的自动编码器来说明。自动编码器获取数据集并对其进行有效压缩。然后对其进行解压缩,以重建原始数据集。
DLN 也可以用于预测分析。DLN 的最后一步将使用激活函数来生成由几个类别之一表示的输出。当与新数据一起使用时,模型将尝试基于先前训练的模型对输入进行分类。
DLN 的一项重要任务是确保模型的准确性和误差最小化。与简单的神经网络一样,每一层都使用权重和偏差。随着权重值的调整,可能会引入误差。一种调整权重的技术使用梯度下降。这可以认为是变化的斜率。想法是修改权重以最小化误差。这是一种加速学习过程的优化技术。
在本章的后面,我们将检查卷积神经网络(CNN),并简要讨论递归神经网络 ( RNN )。卷积网络模拟了视觉皮层,因为每个神经元都可以根据某个区域的信息进行交互并做出决定。递归网络不仅基于前一层的输出,还基于前一层中执行的计算来处理信息。
有几个支持深度学习的库,包括:
- 用于 Java 的 N 维数组(ND4J)【http://nd4j.org/】T4:一个用于生产用途的科学计算库
- deep learning 4j(【http://deeplearnin4j.org/】T2):一个开源的分布式深度学习库
- Encog(【http://www.heatonresearch.com/encog/】T2):这个库支持几种深度学习算法
ND4J 是一个较低级别的库,实际上在其他项目中使用,包括 DL4J。Encog 可能不如 DL4J 支持得好,但确实提供了对深度学习的支持。
本章使用的例子都是基于深度学习 Java(DL4J)(http://deeplearning4j.org)API,并有 ND4J 的支持。这个库为许多与深度学习相关的算法提供了很好的支持。因此,下一节将解释许多深度学习算法共有的基本任务,如加载数据、训练模型和测试模型。
Deeplearning4j 架构
在本节中,我们将讨论它的体系结构,并解决使用 API 时执行的几个常见任务。DLN 通常从创建一个MultiLayerConfiguration
实例开始,它定义了网络或模型。网络由多层组成。超参数用于配置网络,是影响学习速度、用于层的激活函数以及如何初始化权重的变量。
与神经网络一样,基本的 DLN 过程包括:
- 获取和操作数据
- 配置和构建模型
- 训练模型
- 测试模型
我们将在接下来的小节中研究这些任务。
注意
本节中的代码示例并不打算在这里输入和执行。相反,这些例子是我们将使用的后来模型的片断。
获取和处理数据
DL4J API 有许多获取数据的技术。我们将重点关注我们将在示例中使用的那些特定技术。DL4J 项目使用的数据集通常使用二值化或归一化进行修改。二进制化将数据转换为 1 和 0。归一化将数据转换为介于 1 和 0 之间的值。
馈送给 DLN 的数据被转换成一组数字。这些数字被称为向量。这些向量由行数可变的一列矩阵组成。创建矢量的过程被称为矢量化。
Canova(【http://deeplearning4j.org/canova.html】T2)是一个支持矢量化的 DL4J 库。它适用于许多不同类型的数据集。它已经与data vec(http://deeplearning4j.org/datavec)、矢量化和提取、转换和加载 ( ETL )库合并。
在本节中,我们将重点介绍如何读入 CSV 数据。
读入 CSV 文件
ND4J 提供了CSVRecordReader
类,这对于读取 CSV 数据很有用。它有三个重载的构造函数。我们要演示的是传递了两个参数。第一个是第一次读取文件时要跳过的行数,第二个是保存用于解析文本的分隔符的字符串。
在下面的代码中,我们创建了类的一个新实例,其中没有跳过任何行,只使用逗号作为分隔符:
RecordReader recordReader = new CSVRecordReader(0, ",");
该类实现了RecordReader
接口。它有一个被传递了一个FileSplit
类实例的initialize
方法。它的一个构造函数被传递了一个引用数据集的File
对象的实例。FileSplit
类帮助分割用于训练和测试的数据。在本例中,我们为一个名为car.txt
的文件初始化阅读器,我们将在准备数据部分使用该文件:
recordReader.initialize(new FileSplit(new File("car.txt")));
为了处理数据,我们需要一个迭代器,比如下面显示的DataSetIterator
实例。这个类拥有大量重载的构造函数。在下面的例子中,第一个参数是RecordReader
实例。接下来是三个论点。第一个是批量大小,即一次检索的记录数量。下一个是记录最后一个属性的索引。最后一个参数是数据集表示的类的数量:
DataSetIterator iterator =
new RecordReaderDataSetIterator(recordReader, 1728, 6, 4);
如果我们使用数据集进行回归,文件记录的最后一个属性将保存一个类值。这正是我们以后使用它的方式。类别参数的数目仅用于回归。
在下一个代码序列中,我们将把数据集分成两组:一组用于训练,一组用于测试。从next
方法开始,这个方法从数据源返回下一个数据集。数据集的大小取决于之前使用的批处理大小。shuffle
方法使输入随机化,而splitTestAndTrain
方法返回SplitTestAndTrain
类的一个实例,我们用它来获得训练和测试数据集。splitTestAndTrain
方法的参数指定了用于训练的数据的百分比。
DataSet dataset = iterator.next();
dataset.shuffle();
SplitTestAndTrain testAndTrain = dataset.splitTestAndTrain(0.65);
DataSet trainingData = testAndTrain.getTrain();
DataSet testData = testAndTrain.getTest();
然后我们可以将这些数据集用于一个模型。
配置和构建模型
DL4J 经常使用MultiLayerConfiguration
类来定义模型的配置,使用MultiLayerNetwork
类来表示模型。这些类提供了一种构建模型的灵活方式。
在下面的例子中,我们将演示这些类的用法。从MultiLayerConfiguration
类开始,我们发现在流畅的风格中使用了几种方法。我们将很快提供关于这些方法的更多细节。但是,请注意,该模型定义了两个层:
MultiLayerConfiguration conf =
new NeuralNetConfiguration.Builder()
.iterations(1000)
.activation("relu")
.weightInit(WeightInit.XAVIER)
.learningRate(0.4)
.list()
.layer(0, new DenseLayer.Builder()
.nIn(6).nOut(3)
.build())
.layer(1, new OutputLayer
.Builder(LossFunctions.LossFunction
.NEGATIVELOGLIKELIHOOD)
.activation("softmax")
.nIn(3).nOut(4).build())
.backprop(true).pretrain(false)
.build();
nIn
和nOut
方法指定一个层的输入和输出数量。
在 ND4J 中使用超参数
构建器类在 DL4J 中很常见。在前面的例子中,使用了NeuralNetConfiguration.Builder
类。这里使用的方法只是众多可用方法中的几种。在下表中,我们描述了其中的几种:
| 方法 | 用途 |
| iterations
| 控制执行优化迭代的次数 |
| activation
| 这是使用的激活功能 |
| weightInit
| 用于初始化模型的初始权重 |
| learningRate
| 控制模型学习的速度 |
| List
| 创建一个NeuralNetConfiguration.ListBuilder
类的实例,这样我们可以添加层 |
| Layer
| 创建新层 |
| backprop
| 当设置为真时,它启用反向传播 |
| pretrain
| 当设置为 true 时,它将预训练模型 |
| Build
| 执行实际的构建过程 |
让我们更仔细地研究一下层是如何创建的。在这个例子中,list
方法返回一个NeuralNetConfiguration.ListBuilder
实例。它的layer
方法有两个参数。第一个是图层的编号,这是一个从零开始的编号方案。第二个是Layer
实例。
这里使用了两个不同的层和两个不同的构建器:一个DenseLayer.Builder
和一个OutputLayer.Builder
实例。DL4J 中有几种类型的层可用。构建器的构造函数的自变量可以是一个损失函数,与输出层的情况一样,接下来将对此进行解释。
在反馈网络中,神经网络的猜测与所谓的基本事实进行比较,这就是误差。该误差用于通过修改权重和偏差来更新网络。损失函数,也称为目标或成本函数,测量差异。
DL4J 支持多种损失函数:
MSE
:在线性回归中,MSE 代表均方误差EXPLL
:在泊松回归中,EXPLL 代表指数对数似然XENT
:在二元分类中,XENT 代表交叉熵- 这代表多类交叉熵
- 这代表 RMSE 交叉熵
- 这代表平方损失
- 这代表重建交叉熵
NEGATIVELOGLIKELIHOOD
:表示负对数似然CUSTOM
:定义自己的损失函数
构建器实例使用的其余方法是激活函数、层的输入和输出数量以及创建层的build
方法。
多层网络的每一层都需要满足以下要求:
- 输入:通常以输入向量的形式
- 权重:也叫系数
- Bias :用于确保一层中至少有一些节点被激活
- 激活功能:决定一个节点是否触发
有许多不同类型的激活功能,每一种都可以解决特定类型的问题。
激活函数用于确定神经元是否触发。支持多种功能,包括relu
(整流线性)tanh``sigmoid``softmax``hardtanh``leakyrelu``maxout``softsign``softplus
。
注意
一个有趣的激活函数列表和图表可以在http://stats . stack exchange . com/questions/115258/comprehensive-list-of-activation-functions-in-neural-networks-with-pros和https://en.wikipedia.org/wiki/Activation_function找到。
实例化网络模型
接下来,使用定义的配置创建一个MultiLayerNetwork
实例。模型被初始化,并且它的监听器被设置。ScoreIterationListener
实例将显示模型火车的信息,我们很快就会看到。其构造函数的参数指定了该信息的显示频率:
MultiLayerNetwork model = new MultiLayerNetwork(conf);
model.init();
model.setListeners(new ScoreIterationListener(100));
我们现在准备训练模型。
训练一个模特
这实际上是一个相当简单的步骤。fit
方法执行训练:
model.fit(trainingData);
当执行时,将使用与模型相关联的任何监听器来生成输出,就像前面的情况一样,其中使用了一个ScoreIterationListener
实例。
如何使用fit
方法的另一个例子是遍历数据集的过程,如下所示。在这个例子中,使用了一系列数据集。这是自动编码器的一部分,其输出旨在匹配输入,如深度自动编码器一节所述。用作fit
方法参数的数据集同时使用输入和预期输出。在这种情况下,它们与由getFeatureMatrix
方法提供的相同:
while (iterator.hasNext()) {
DataSet dataSet = iterator.next();
model.fit(new DataSet(dataSet.getFeatureMatrix(),
dataSet.getFeatureMatrix()));
}
对于较大的数据集,有必要对模型进行多次预训练,以获得准确的结果。这通常是并行执行的,以减少培训时间。该选项通过图层类的pretrain
方法设置。
测试模型
使用Evaluation
类和训练数据集对模型进行评估。使用指定类数量的参数创建一个Evaluation
实例。使用output
方法将测试数据输入模型。eval
方法获取模型的输出,并将其与测试数据类进行比较,以生成统计数据:
Evaluation evaluation = new Evaluation(4);
INDArray output = model.output(testData.getFeatureMatrix());
evaluation.eval(testData.getLabels(), output);
out.println(evaluation.stats());
输出将类似于以下内容:
==========================Scores===================================
Accuracy: 0.9273
Precision: 0.854
Recall: 0.8323
F1 Score: 0.843
这些统计数据详细如下:
- 这是对返回正确答案的频率的测量。
Precision
:这是一个肯定回答是正确的概率的量度。Recall
:这衡量如果给出一个正例,结果被正确分类的可能性。F1 Score
:这是网络结果正确的概率。这是回忆和精确的调和平均值。它的计算方法是将真阳性的数量除以真阳性和假阴性的总和。
我们将使用Evaluation
类来确定我们模型的质量。使用称为 f1 的度量,其值范围从 0 到 1 ,其中 1 代表最佳质量。
深度学习和回归分析
神经网络可用于执行回归分析。然而,其他技术(见前面章节)可能提供更有效的解决方案。使用回归分析,我们希望根据几个输入变量来预测结果。
我们可以使用由单个神经元组成的输出层来执行回归分析,该输出层将加权输入加上前一个隐藏层的偏差相加。因此,结果是代表回归的单个值。
准备数据
我们将使用汽车评估数据库来演示如何根据一系列属性来预测汽车的可接受性。包含我们将使用的数据的文件可以从以下位置下载:http://archive . ics . UCI . edu/ml/machine-learning-databases/car/car . data。它包括汽车数据,如价格、乘客数量和安全信息,以及对其整体质量的评估。这是后一个因素,质量,我们将试图预测。接下来显示了每个属性中以逗号分隔的值,以及替换值。因为模型需要数字数据,所以需要替换:
| 属性 | 原始值 | 替代值 |
| 买价 | vhigh, high, med, low
| 3,2,1,0
|
| 维持价格 | vhigh, high, med, low
| 3,2,1,0
|
| 门的数量 | 2, 3, 4, 5-more
| 2,3,4,5
|
| 座位 | 2, 4, more
| 2,4,5
|
| 货舱 | small, med, big
| 0,1,2
|
| 安全 | low, med, high
| 0,1,2
|
文件中有 1,728 个实例。这些汽车分为四个等级:
| 类 | 实例数量 | 实例百分比 | 原始值 | 替代值 |
| 不能接受的 | 1210
| 70.023%
| unacc
| 0
|
| 可接受的 | 384
| 22.222%
| acc
| 1
|
| 好的 | 69
| 3.99%
| good
| 2
|
| 很好 | 65
| 3.76%
| v-good
| 3
|
设置类别
我们从定义一个CarRegressionExample
类开始,如下所示,在这里创建了一个类的实例,并且在它的默认构造函数中执行工作:
public class CarRegressionExample {
public CarRegressionExample() {
try {
...
} catch (IOException | InterruptedException ex) {
// Handle exceptions
}
}
public static void main(String[] args) {
new CarRegressionExample();
}
}
读取和准备数据
第一项任务是读入数据。我们将使用CSVRecordReader
类来获取数据,正如在读取 CSV 文件中所解释的:
RecordReader recordReader = new CSVRecordReader(0, ",");
recordReader.initialize(new FileSplit(new File("car.txt")));
DataSetIterator iterator = new
RecordReaderDataSetIterator(recordReader, 1728, 6, 4);
有了这个数据集,我们将把数据分成两组。65%的数据用于训练,其余用于测试:
DataSet dataset = iterator.next();
dataset.shuffle();
SplitTestAndTrain testAndTrain = dataset.splitTestAndTrain(0.65);
DataSet trainingData = testAndTrain.getTrain();
DataSet testData = testAndTrain.getTest();
现在需要对数据进行规范化:
DataNormalization normalizer = new NormalizerStandardize();
normalizer.fit(trainingData);
normalizer.transform(trainingData);
normalizer.transform(testData);
我们现在准备构建模型。
建立模型
使用一系列的NeuralNetConfiguration.Builder
方法创建一个MultiLayerConfiguration
实例。下面是用的骰子。我们将讨论代码后面的各个方法。请注意,此配置使用了两层。最后一层使用softmax
激活函数,用于回归分析:
MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
.iterations(1000)
.activation("relu")
.weightInit(WeightInit.XAVIER)
.learningRate(0.4)
.list()
.layer(0, new DenseLayer.Builder()
.nIn(6).nOut(3)
.build())
.layer(1, new OutputLayer
.Builder(LossFunctions.LossFunction
.NEGATIVELOGLIKELIHOOD)
.activation("softmax")
.nIn(3).nOut(4).build())
.backprop(true).pretrain(false)
.build();
创建了两个层。首先是输入层。DenseLayer.Builder
类用于创建这一层。DenseLayer
类是前馈和全连接层。创建的层使用六个汽车属性作为输入。输出由输入输出层的三个神经元组成,为了方便起见,这里复制了三个神经元:
.layer(0, new DenseLayer.Builder()
.nIn(6).nOut(3)
.build())
第二层是用OutputLayer.Builder
类创建的输出层。它使用损失函数作为其构造函数的参数。使用softmax
激活函数是因为我们正在执行回归,如下所示:
.layer(1, new OutputLayer
.Builder(LossFunctions.LossFunction
.NEGATIVELOGLIKELIHOOD)
.activation("softmax")
.nIn(3).nOut(4).build())
接下来,使用配置创建一个MultiLayerNetwork
实例。模型被初始化,它的监听器被设置,然后调用fit
方法来执行实际的训练。ScoreIterationListener
实例将显示模型火车的信息,我们将很快在这个例子的输出中看到。ScoreIterationListener
构造函数的参数指定了信息显示的频率:
MultiLayerNetwork model = new MultiLayerNetwork(conf);
model.init();
model.setListeners(new ScoreIterationListener(100));
model.fit(trainingData);
我们现在准备评估模型。
评估模型
在接下来的代码序列中,我们根据训练数据集评估模型。使用指定有四个类的参数创建一个Evaluation
实例。使用output
方法将测试数据输入模型。eval
方法获取模型的输出,并将其与测试数据类进行比较,以生成统计数据。getLabels
方法返回预期值:
Evaluation evaluation = new Evaluation(4);
INDArray output = model.output(testData.getFeatureMatrix());
evaluation.eval(testData.getLabels(), output);
out.println(evaluation.stats());
下面是训练的输出,它是由ScoreIterationListener
类产生的。但是,您获得的值可能会因数据的选择和分析方式而有所不同。请注意,分数随着迭代而提高,但在大约 500 次迭代后趋于平稳:
12:43:35.685 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 0 is 1.443480901811554
12:43:36.094 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 100 is 0.3259061845624861
12:43:36.390 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 200 is 0.2630572026049783
12:43:36.676 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 300 is 0.24061281470878784
12:43:36.977 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 400 is 0.22955121170274934
12:43:37.292 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 500 is 0.22249920540161677
12:43:37.575 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 600 is 0.2169898450109222
12:43:37.872 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 700 is 0.21271599814600958
12:43:38.161 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 800 is 0.2075677126088741
12:43:38.451 [main] INFO o.d.o.l.ScoreIterationListener - Score at iteration 900 is 0.20047317735870715
接下来是如下所示的stats
方法的结果。第一部分报告示例如何分类,第二部分显示各种统计数据:
Examples labeled as 0 classified by model as 0: 397 times
Examples labeled as 0 classified by model as 1: 10 times
Examples labeled as 0 classified by model as 2: 1 times
Examples labeled as 1 classified by model as 0: 8 times
Examples labeled as 1 classified by model as 1: 113 times
Examples labeled as 1 classified by model as 2: 1 times
Examples labeled as 1 classified by model as 3: 1 times
Examples labeled as 2 classified by model as 1: 7 times
Examples labeled as 2 classified by model as 2: 21 times
Examples labeled as 2 classified by model as 3: 14 times
Examples labeled as 3 classified by model as 1: 2 times
Examples labeled as 3 classified by model as 3: 30 times
==========================Scores===================================Accuracy: 0.9273
Precision: 0.854
Recall: 0.8323
F1 Score: 0.843
===================================================================
回归模型对这个数据集做了合理的工作。
受限玻尔兹曼机器
RBM 经常被用作多层深层信念网络的一部分。RBM 的输出被用作另一层的输入。重复使用 RBM,直到到达最后一层。
注意
深度信念网络 ( DBNs )由几个 RBM 堆叠在一起组成。每个隐藏层为后续层提供输入。在每一层中,节点不能横向通信,它实质上变成了其他单层网络的网络。dbn 特别有助于分类、聚类和识别图像数据。
术语连续受限玻尔兹曼机,指的是使用非整数数值的 RBM。输入数据被标准化为 0 到 1 之间的值。
输入层的每个节点都连接到第二层的每个节点。同一层中没有节点相互连接。也就是说,不存在层内通信。这就是受限的含义。
可见图层的输入节点数取决于所解决的问题。例如,如果我们正在查看一个有 256 个像素的图像,那么我们将需要 256 个输入节点。对于图像,这是图像的行数乘以列数。
隐藏层应该比输入层包含更少的神经元。使用接近相同数量的神经元有时会导致身份函数的构建。过多的神经元可能会导致过度拟合。这意味着具有大量输入的数据集将需要多个图层。较小的输入大小导致需要较少的层。
随机的,即随机的,值被分配给每个节点的权重。节点的值乘以其权重,然后添加到偏差中。该值与来自其他输入节点的组合输入相结合,然后被馈入激活函数,在那里产生输出值。
重建 RBM
RBM 技术经历了一个重建阶段。这是激活被反馈到第一层并乘以用于输入的相同权重的地方。来自第二层的每个节点的这些值的总和,加上另一个偏差,表示原始输入的近似值。想法是训练模型以最小化原始输入值和反馈值之间的差异。
值的差异被视为错误。重复该过程,直到达到误差最小值。您可以将重构视为对原始输入的猜测。这些猜测本质上是原始输入的概率分布。这被称为生成学习,与使用分类技术的鉴别学习相反。
在多层模型中,每一层都可以用来从本质上识别一个特征。在随后的层中,可以识别或生成特征的组合。以这种方式,可以分析看似随机的一组像素值来识别树叶、树叶、树干以及树的叶脉。
RBM 的输出是一个基本上代表百分比的值。如果它不是零,那么机器已经学习了关于输入的一些东西。
配置 RBM
我们将研究两种不同的 RBM 组态。第一个是最小的,我们将在深度自动编码器中再次看到它。第二种方法使用了几种额外的方法,并提供了对其各种配置方式的更多见解。
以下语句使用RBM.Builder
类创建一个新层。基于图像的行数和列数计算输入。输出大,包含1000
个神经元。损失函数是RMSE_XENT
。这种损失函数对某些分类问题更有效:
.layer(0, new RBM.Builder()
.nIn(numRows * numColumns).nOut(1000)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
接下来是一个更复杂的 RBM。我们不会在这里详细介绍这些方法,但会在后面的示例中看到它们的使用:
.layer(new RBM.Builder()
.l2(1e-1).l1(1e-3)
.nIn(numRows * numColumns
.nOut(outputNum)
.activation("relu")
.weightInit(WeightInit.RELU)
.lossFunction(LossFunctions.LossFunction
.RECONSTRUCTION_CROSSENTROPY).k(3)
.hiddenUnit(HiddenUnit.RECTIFIED)
.visibleUnit(VisibleUnit.GAUSSIAN)
.updater(Updater.ADAGRAD)
.gradientNormalization(
GradientNormalization.ClipL2PerLayer)
.build())
单层RBM
并不总是有用的。通常需要多层自动编码器。我们将在下一节研究这个选项。
深度自动编码器
自动编码器用于特征选择和提取。它由两个对称的 dbn 组成。网络的前半部分由几层组成,执行编码。网络的第二部分执行解码。自动编码器的每一层都是一个 RBM。下图对此进行了说明:
编码序列的目的是将原始输入压缩到更小的向量空间中。上图的中间层就是这个压缩层。这些中间向量可以被认为是数据集的可能特征。该编码也被称为预训练半部分。它是中间 RBM 层的输出,不执行分类。
编码器的第一层将使用比数据集更多的输入。这具有扩展数据集特征的效果。sigmoid-belief 单元是每层使用的一种非线性变换形式。该单元不能准确地将信息表示为真实值。然而,使用更多的投入,它能够做得更好。
网络的后半部分执行解码,有效地重构输入。这是一个前馈网络,使用与编码部分中相应层相同的权重。然而,权重是转置的,并且不是随机初始化的。下半年的训练率需要定得低一些。
自动编码器对于数据压缩和搜索非常有用。模型前半部分的输出是压缩的,因此有利于存储和传输。稍后,它可以被解压缩,正如我们将在第 10 章视听分析中演示的。这有时被称为语义散列。
如果一系列输入(如图像或声音)已经被压缩和存储,那么新的输入可以被压缩并与存储的值匹配以找到最佳匹配。自动编码器也可以用于其他信息检索任务。
在 DL4J 中构建自动编码器
这个例子改编自http://deeplearning4j.org/deepautoencoder。我们首先使用一个 try-catch 块来处理可能出现的错误,并使用一些变量声明。这个例子使用了Mnist
(http://yann.lecun.com/exdb/mnist/)数据集,这是一组包含手写数字的图像。每幅图像由28
乘28
像素组成。声明一个迭代器来访问数据:
try {
final int numberOfRows = 28;
final int numberOfColumns = 28;
int seed = 123;
int numberOfIterations = 1;
iterator = new MnistDataSetIterator(
1000, MnistDataFetcher.NUM_EXAMPLES, true);
...
} catch (IOException ex) {
// Handle exceptions
}
配置网络
使用NeuralNetConfiguration.Builder()
类创建网络配置。创建了十层,其中输入层由1000
神经元组成。这大于 28×28 像素输入,并且用于补偿在每一层中使用的 sigmoid 信念单元。
随后的每一层都变小,直到到达第四层。这一层代表编码过程的最后一步。对于第五层,解码过程开始,随后的层变得更大。最后一层使用1000
神经元。
模型的每一层都使用一个 RBM 实例,除了最后一层,它是使用OutputLayer.Builder
类构建的。配置代码如下:
MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(numberOfIterations)
.optimizationAlgo(
OptimizationAlgorithm.LINE_GRADIENT_DESCENT)
.list()
.layer(0, new RBM.Builder()
.nIn(numberOfRows * numberOfColumns).nOut(1000)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(1, new RBM.Builder().nIn(1000).nOut(500)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(2, new RBM.Builder().nIn(500).nOut(250)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(3, new RBM.Builder().nIn(250).nOut(100)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(4, new RBM.Builder().nIn(100).nOut(30)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build()) //encoding stops
.layer(5, new RBM.Builder().nIn(30).nOut(100)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build()) //decoding starts
.layer(6, new RBM.Builder().nIn(100).nOut(250)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(7, new RBM.Builder().nIn(250).nOut(500)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(8, new RBM.Builder().nIn(500).nOut(1000)
.lossFunction(LossFunctions.LossFunction.RMSE_XENT)
.build())
.layer(9, new OutputLayer.Builder(
LossFunctions.LossFunction.RMSE_XENT).nIn(1000)
.nOut(numberOfRows * numberOfColumns).build())
.pretrain(true).backprop(true)
.build();
建立和培训网络
然后创建并初始化模型,并设置分数迭代监听器:
model = new MultiLayerNetwork(conf);
model.init();
model.setListeners(Collections.singletonList(
(IterationListener) new ScoreIterationListener()));
使用fit
方法训练模型:
while (iterator.hasNext()) {
DataSet dataSet = iterator.next();
model.fit(new DataSet(dataSet.getFeatureMatrix(),
dataSet.getFeatureMatrix()));
}
保存和检索网络
保存模型是很有用的,这样它可以用于以后的分析。这是使用ModelSerializer
类的writeModel
方法完成的。它接受model
实例和modelFile
实例,以及一个boolean
参数,该参数指定是否应该保存模型的更新程序。更新器是用于调整某些模型参数的学习算法:
modelFile = new File("savedModel");
ModelSerializer.writeModel(model, modelFile, true);
可以使用以下代码检索模型:
modelFile = new File("savedModel");
MultiLayerNetwork model = ModelSerializer.restoreMultiLayerNetwork(modelFile);
专业自动编码器
自动编码器有专门的版本。当自动编码器使用比输入更多的隐藏层时,它可以学习 identity 函数,该函数总是返回与用作函数输入的值相同的值。为了避免这个问题,使用了对自动编码器、去噪自动编码器的扩展;它随机修改引入噪声的输入。引入的噪声量因输入数据集而异。一个堆叠去噪自动编码器 ( SdA )是一系列去噪自动编码器串在一起。
卷积网络
CNN 是模仿动物视觉皮层的前馈网络。视觉皮层排列着重叠的神经元,因此在这种类型的网络中,神经元也排列在重叠的部分,称为感受野。由于它们的设计模型,它们只需最少的预处理或先验知识就能工作,这种缺少人工干预的特性使它们特别有用。
这种类型的网络经常用于图像和视频识别应用。它们可用于分类、聚类和对象识别。CNN 还可以通过实现光学字符识别 ( OCR )应用于文本分析。CNN 一直是机器学习运动的驱动力,部分原因在于其在实际情况中的广泛适用性。
我们将使用 DL4J 演示一个 CNN。该流程将与我们在 DL4J 部分的构建自动编码器中使用的流程非常相似。我们将再次使用Mnist
数据集。该数据集包含图像数据,因此非常适合卷积网络。
建立模型
首先,我们需要创建一个新的DataSetIterator
来处理数据。MnistDataSetIterator
构造函数的参数是批量大小,在本例中是1000
,以及要处理的样本总数。然后,我们得到下一个数据集,随机排列数据,并分割数据进行测试和训练。正如我们在本章前面所讨论的,我们通常使用 65%的数据来训练数据,剩余的 35%用于测试:
DataSetIterator iter = new MnistDataSetIterator(1000,
MnistDataFetcher.NUM_EXAMPLES);
DataSet dataset = iter.next();
dataset.shuffle();
SplitTestAndTrain testAndTrain = dataset.splitTestAndTrain(0.65);
DataSet trainingData = testAndTrain.getTrain();
DataSet testData = testAndTrain.getTest();
然后我们对两组数据进行归一化处理:
DataNormalization normalizer = new NormalizerStandardize();
normalizer.fit(trainingData);
normalizer.transform(trainingData);
normalizer.transform(testData);
接下来,我们可以建立我们的网络。如前所示,我们将再次使用带有一系列NeuralNetConfiguration.Builder
方法的MultiLayerConfiguration
实例。我们将在下面的代码序列之后讨论各个方法。注意,最后一层再次使用softmax
激活函数进行回归分析:
MultiLayerConfiguration.Builder builder = new
NeuralNetConfiguration.Builder()
.seed(123)
.iterations(1)
.regularization(true).l2(0.0005)
.weightInit(WeightInit.XAVIER)
.optimizationAlgo(OptimizationAlgorithm
.STOCHASTIC_GRADIENT_DESCENT)
.updater(Updater.NESTEROVS).momentum(0.9)
.list()
.layer(0, new ConvolutionLayer.Builder(5, 5)
.nIn(6)
.stride(1, 1)
.nOut(20)
.activation("identity")
.build())
.layer(1, new SubsamplingLayer.Builder(
SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build())
.layer(2, new ConvolutionLayer.Builder(5, 5)
.stride(1, 1)
.nOut(50)
.activation("identity")
.build())
.layer(3, new SubsamplingLayer.Builder(
SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build())
.layer(4, new DenseLayer.Builder().activation("relu")
.nOut(500).build())
.layer(5, new OutputLayer.Builder(
LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.nOut(10)
.activation("softmax")
.build())
.backprop(true).pretrain(false);
第一层,图层0
,为了方便起见,它使用了ConvolutionLayer.Builder
方法。卷积层的输入是图像高度、宽度和通道数的乘积。在标准 RGB 图像中,有三个通道。nIn
方法获取通道的数量。nOut
方法指定了期望的20
输出:
.layer(0, new ConvolutionLayer.Builder(5, 5)
.nIn(6)
.stride(1, 1)
.nOut(20)
.activation("identity")
.build())
层1
和3
都是二次采样层。这些层跟随卷积层,它们本身不进行真正的卷积。它们返回一个值,即该输入区域的最大值:
.layer(1, new SubsamplingLayer.Builder(
SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build())
...
.layer(3, new SubsamplingLayer.Builder(
SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build())
层2
也是像层0
一样的卷积层。请注意,我们没有指定该层中的通道数量:
.layer(2, new ConvolutionLayer.Builder(5, 5)
.nOut(50)
.activation("identity")
.build())
第四层使用了DenseLayer.Builder
类,就像我们前面的例子一样。如前所述,DenseLayer
类是一个前馈和全连接层:
.layer(4, new DenseLayer.Builder().activation("relu")
.nOut(500).build())
层5
是一个OutputLayer
实例,使用softmax
自动化:
.layer(5, new OutputLayer.Builder(
LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.nOut(10)
.activation("softmax")
.build())
.backprop(true).pretrain(false);
最后,我们创建了一个ConvolutionalLayerSetup
类的新实例。我们传递构建器对象和图像的尺寸(28 x 28)。我们还传递通道的数量,在本例中,1
:
new ConvolutionLayerSetup(builder, 28, 28, 1);
我们现在可以配置和适应我们的模型。我们再次使用MultiLayerConfiguration
和MultiLayerNetwork
类来构建我们的网络。我们设置监听器,然后遍历我们的数据。对于每个DataSet
,我们执行fit
方法:
MultiLayerConfiguration conf = builder.build();
MultiLayerNetwork model = new MultiLayerNetwork(conf);
model.init();
model.setListeners(Collections.singletonList((IterationListener)
new ScoreIterationListener(1/5)));
while (iter.hasNext()) {
DataSet next = iter.next();
model.fit(new DataSet(next.getFeatureMatrix(), next.getLabels()));
}
我们现在准备评估我们的模型。
评估模型
为了评估我们的模型,我们使用了Evaluation
类。我们从模型中获取输出,并将其与数据集的标签一起发送给eval
方法。然后,我们执行stats
方法来获取我们网络的统计信息:
Evaluation evaluation = new Evaluation(4);
INDArray output = model.output(testData.getFeatureMatrix());
evaluation.eval(testData.getLabels(), output);
out.println(evaluation.stats());
以下是执行这段代码的输出示例,因为我们只显示了stats
方法的结果。第一部分报告示例如何分类,第二部分显示各种统计数据:
Examples labeled as 0 classified by model as 0: 19 times
Examples labeled as 1 classified by model as 1: 41 times
Examples labeled as 2 classified by model as 1: 4 times
Examples labeled as 2 classified by model as 2: 30 times
Examples labeled as 2 classified by model as 3: 1 times
Examples labeled as 3 classified by model as 2: 1 times
Examples labeled as 3 classified by model as 3: 28 times
==========================Scores===================================Accuracy: 0.3371
Precision: 0.8481
Recall: 0.8475
F1 Score: 0.8478
===================================================================
与我们之前的模型一样,评估证明了我们网络的准确性和成功性。
递归神经网络
RNN 不同于前馈网络,因为它们的输入包括来自前一迭代或步骤的输入。它们仍然处理当前输入,但是使用反馈循环来考虑前一步骤的输入,也称为最近的过去。这一步有效地给了网络内存。一种流行的循环网络涉及长期短期记忆 ( LSTM )。这种类型的存储器提高了网络的处理能力。
rnn 是为处理顺序数据而设计的,尤其适用于文本数据的分析和预测。给定一个单词序列,RNN 可以预测每个单词成为序列中下一个单词的概率。这也允许通过网络生成文本。rnn 是通用的,也能很好地处理图像数据,尤其是图像标记应用。设计和目的的灵活性以及训练的简易性使 RNNs 成为许多数据科学应用的流行选择。DL4J 还为 LSTM 网络和其他 rnn 提供支持。
总结
在这一章中,我们研究了神经网络的深度学习技术。本章中的所有 API 支持都是由 Deeplearning4j 提供的。我们首先演示了如何获取和准备用于深度学习网络的数据。我们讨论了如何配置和构建模型。随后解释了如何通过将数据集分为训练和测试部分来训练和测试模型。
我们的讨论继续进行深度学习和回归分析的检查。我们展示了如何准备数据和类、构建模型以及评估模型。我们使用样本数据和显示输出统计数据来演示我们的模型的相对有效性。
然后检查 RBM 和 DBNs。dbn 由堆叠在一起的 RBM 组成,对于分类和聚类应用程序特别有用。深度自动编码器也是使用 RBM 构建的,有两个对称的 dbn。自动编码器对于特征选择和提取特别有用。
最后,我们演示了一个卷积网络。这个网络模仿视觉皮层,允许网络使用信息区域进行分类。和前面的例子一样,我们构建、训练,然后评估模型的有效性。然后,我们以对递归神经网络的简要介绍结束了这一章。
当我们进入下一章并研究文本分析技术时,我们将进一步阐述这些主题。
九、文本分析
文本分析是一个广泛的话题,通常被称为自然语言处理 ( NLP )。它用于许多不同的任务,包括文本搜索、语言翻译、情感分析、语音识别和分类等等。由于自然语言的特殊性和模糊性,分析的过程可能很困难。然而,在这个领域已经做了大量的工作,并且有几个 Java APIs 支持这项工作。
我们将从介绍 NLP 中使用的基本概念和任务开始。其中包括以下内容:
- 记号化:将文本拆分成单个记号或单词的过程。
- 停用词:这些是常用词,可能不需要处理。它们包括诸如 the、a 和 to 这样的词。
- 名称实体识别 ( NER ):这是识别文本元素的过程,比如人名、地点或事物。
- 词类 ( 词性):这标识了一个句子的语法部分,如名词、动词、形容词等等。
- 关系:这里,我们关心的是识别文本的各个部分是如何相互关联的,比如句子的主语和宾语。
单词、句子和段落的概念是众所周知的。然而,提取和分析这些组件并不总是那么简单。术语语料库通常指文本的集合。
与大多数数据科学问题一样,预处理文本非常重要。通常,这包括处理以下任务:
- 处理 Unicode
- 将文本转换为大写或小写
- 删除停用词
我们在第三章、数据清理中研究了几种用于标记化和移除停用词的技术。在这一章中,我们将关注词性、NER、从句子中提取关系、文本分类和情感分析。
有几个可用的 NLP APIs,包括:
- OpenNLP(【https://opennlp.apache.org/】T2):一个开源的 Apache 项目
- 斯坦福****NLP(【http://nlp.stanford.edu/software/】T4):另一个开源库
- (https://uima.apache.org/):阿帕奇项目配套管道
- LingPipe(【http://alias-i.com/lingpipe/】T2):大量使用管道的库
- ****DL4J(【http://deeplearning4j.org/】T2):Java 深度学习库支持深度学习神经网络的各种类,包括对 NLP 的支持
本章我们将使用 OpenNLP 和 DL4J 来演示文本分析。我们选择这些是因为它们都很有名,并且有很好的出版资源来获得额外的支持。
我们将使用谷歌 Word2Vec 和 Doc2Vec 神经网络来执行文本分类。这包括基于其他单词的特征向量以及使用标记信息来分类文档。最后,我们将讨论情感分析。这种类型的分析试图给文本赋予意义,并使用 Word2Vec 网络。
我们从 NER 开始讨论。
实现命名实体识别
这有时被称为寻找人和事物。给定一个文本片段,我们可能想要识别所有在场人员的姓名。然而,这并不总是容易的,因为像 Rob 这样的名字也可能被用作动词。
在这一节中,我们将演示如何使用 OpenNLP 的TokenNameFinderModel
类来查找文本中的名称和位置。虽然我们可能还想找到其他实体,但这个例子将展示这项技术的基础。我们从名字开始。
大多数名字出现在一行中。我们不希望使用多行,因为像州这样的实体可能会在无意中被错误地识别。考虑下面的句子:
吉姆向北走了。达科塔往南走了。
如果我们忽略这个时期,那么北达科他州可能会被识别为一个位置,而实际上它并不存在。
使用 OpenNLP 执行 NER
我们从处理异常的 try-catch 块开始我们的例子。OpenNLP 使用在不同数据集上训练过的模型。在这个例子中,en-token.bin
和en-ner-person.bin
文件分别包含英语文本的标记化和英语名称元素的模型。这些文件可以从 http://opennlp.sourceforge.net/models-1.5/的下载。然而,这里使用的 IO 流是标准的 Java:
try (InputStream tokenStream =
new FileInputStream(new File("en-token.bin"));
InputStream personModelStream = new FileInputStream(
new File("en-ner-person.bin"));) {
...
} catch (Exception ex) {
// Handle exceptions
}
使用令牌流初始化TokenizerModel
类的一个实例。然后这个实例被用来创建实际的TokenizerME
标记器。我们将用这个例子来修饰我们的句子:
TokenizerModel tm = new TokenizerModel(tokenStream);
TokenizerME tokenizer = new TokenizerME(tm);
TokenNameFinderModel
类用于保存命名实体的模型。它是使用人模型流初始化的。由于我们正在寻找名称,因此使用这个模型创建了一个NameFinderME
类的实例:
TokenNameFinderModel tnfm = new
TokenNameFinderModel(personModelStream);
NameFinderME nf = new NameFinderME(tnfm);
为了演示这个过程,我们将使用下面的句子。然后,我们使用 tokenizer 和tokenizer
方法将它转换成一系列标记:
String sentence = "Mrs. Wilson went to Mary's house for dinner.";
String[] tokens = tokenizer.tokenize(sentence);
Span
类保存关于实体位置的信息。find
方法将返回位置信息,如下所示:
Span[] spans = nf.find(tokens);
这个数组保存在句子中找到的 person 实体的信息。然后,我们显示这些信息,如下所示:
for (int i = 0; i < spans.length; i++) {
out.println(spans[i] + " - " + tokens[spans[i].getStart()]);
}
这个序列的输出如下。请注意,它标识了威尔逊夫人的姓,而不是“夫人”:
**[1..2) person - Wilson**
**[4..5) person - Mary**
一旦这些实体被提取出来,我们就可以使用它们进行专门的分析。
识别位置实体
我们还可以找到其他类型的实体,如日期和位置。在下面的例子中,我们找到了句子中的位置。它与前面的 person 示例非常相似,除了模型使用了一个en-ner-location.bin
文件:
try (InputStream tokenStream =
new FileInputStream("en-token.bin");
InputStream locationModelStream = new FileInputStream(
new File("en-ner-location.bin"));) {
TokenizerModel tm = new TokenizerModel(tokenStream);
TokenizerME tokenizer = new TokenizerME(tm);
TokenNameFinderModel tnfm =
new TokenNameFinderModel(locationModelStream);
NameFinderME nf = new NameFinderME(tnfm);
sentence = "Enid is located north of Oklahoma City.";
String tokens[] = tokenizer.tokenize(sentence);
Span spans[] = nf.find(tokens);
for (int i = 0; i < spans.length; i++) {
out.println(spans[i] + " - " +
tokens[spans[i].getStart()]);
}
} catch (Exception ex) {
// Handle exceptions
}
使用前面定义的句子,模型只能找到第二个城市,如下所示。这可能是由于名字Enid
引起的混淆,它既是一个城市的名字也是一个人的名字:
**[5..7) location - Oklahoma**
假设我们使用下面的句子:
sentence = "Pond Creek is located north of Oklahoma City.";
然后我们得到这个输出:
****[1..2) location - Creek
[6..8) location - Oklahoma****
不幸的是,它错过了Pond Creek
镇。NER 是许多应用程序的有用工具,但像许多技术一样,它并不总是万无一失的。所介绍的 NER 方法以及许多其他 NLP 示例的准确性将根据诸如模型的准确性、所使用的语言以及实体的类型等因素而变化。
我们也可能对文本如何分类感兴趣。我们将在下一节研究一种方法。
**# 文本分类
对文本进行分类是机器学习和数据科学的重要组成部分。我们必须能够为各种应用对文本进行分类,包括文档检索和网络搜索。在我们确定数据对特定应用程序或搜索结果的有用性之前,给数据分配特定的标签通常是很重要的。
在这一章中,我们将展示一种技术,这种技术涉及到使用 DL4J 类的段落向量和标签数据。这个例子允许我们读入文档,并根据文档内部的文本,给文档分配一个标签(或分类)。我们还将展示一个根据相似性对文本进行分类的例子。这意味着我们将匹配具有相似结构的短语和单词。这个例子也将使用 DL4J。
Word2Vec 和 Doc2Vec
我们将在本章的几个例子中使用 Word2Vec 和 Doc2Vec。Word2Vec 是用于文本处理的具有两层的神经网络。给定文本主体,网络将为文本中包含的单词提供特征向量。这些向量是单词特征的简单数学表示,并且可以在数字上与其他向量进行比较。这种比较通常被称为两个单词之间的距离。
Word2Vec 的工作原理是,通过确定两个单词同时出现的概率,可以对单词进行分类。由于这种方法,Word2Vec 不仅可以用于句子的分类。任何可以用文本标签表示的对象或数据都可以用这个网络进行分类。
Doc2Vec 是 Word2Vec 的扩展。这个网络不是像 Word2Vec 那样建立代表单个单词与其他单词相比的特征的向量,而是将单词与给定的标签进行比较。向量被设置来表示文档的主题或整体含义。我们的下一个例子展示了这些特征向量是如何与特定的文档相关联的。
通过标签对文本进行分类
在我们使用 Doc2Vec 的第一个例子中,我们将我们的文档与三个标签相关联:健康、金融和科学。但是在我们可以将数据与标签相关联之前,我们必须定义这些标签,并训练我们的模型来识别这些标签。每个标签代表一段特定文本的含义或分类。
在这个例子中,我们将使用样本文档,每个文档都预先标注了我们的类别:健康、金融或科学。我们将使用这些段落来训练我们的模型,然后像前面的例子一样,使用一组测试数据来测试我们的模型。我们将使用位于https://github . com/deep learning 4j/dl4j-examples/tree/master/dl4j-examples/src/main/resources/para vec的文件。我们基于为 DL4J 编写的示例代码编写了这个示例,可以在https://github . com/deep learning 4j/DL4J-examples/blob/master/DL4J-examples/src/main/Java/org/deep learning 4j/examples/NLP/paragraph vectors/paragraphvectors classifier example . Java找到。
首先,我们需要设置一些实例变量,以便稍后在代码中使用。我们将使用一个ParagraphVectors
对象来创建我们的向量,一个LabelAwareIterator
对象来遍历我们的数据,一个TokenizerFactory
对象来标记我们的数据:
ParagraphVectors pVect;
LabelAwareIterator iter;
TokenizerFactory tFact;
然后我们将设置我们的ClassPathResource
。这指定了我们的项目中包含要分类的数据文件的目录。第一个资源包含我们用于培训目的的标记数据。然后,我们指示迭代器和标记器使用指定为ClassPathResource
的资源。我们还指定将使用CommonPreprocessor
来预处理我们的数据:
ClassPathResource resource = new
ClassPathResource("paravec/labeled");
iter = new FileLabelAwareIterator.Builder()
.addSourceFolder(resource.getFile())
.build();
tFact = new DefaultTokenizerFactory();
tFact.setTokenPreProcessor(new CommonPreprocessor());
接下来,我们构建我们的ParagraphVectors
。这是我们指定学习率、批量大小和训练时期数量的地方。我们还在设置过程中包含了迭代器和标记器。一旦我们构建了我们的ParagraphVectors
,我们调用fit
方法来使用paravec/labeled
目录中的训练数据训练我们的模型:
pVect = new ParagraphVectors.Builder()
.learningRate(0.025)
.minLearningRate(0.001)
.batchSize(1000)
.epochs(20)
.iterate(iter)
.trainWordVectors(true)
.tokenizerFactory(tFact)
.build();
pVect.fit();
现在我们已经训练了我们的模型,我们可以使用我们的未标记数据进行测试。我们为未标记的数据创建一个新的ClassPathResource
,并创建一个新的FileLabelAwareIterator
:
ClassPathResource unlabeledText =
new ClassPathResource("paravec/unlabeled");
FileLabelAwareIterator unlabeledIter =
new FileLabelAwareIterator.Builder()
.addSourceFolder(unlabeledText.getFile())
.build();
下一步涉及遍历我们的未标记数据,并为每个文档识别正确的标签。一般来说,我们可以预期每个文档将属于多个标签,但是每个标签具有不同的权重或匹配百分比。因此,虽然一篇文章可能主要被归类为健康文章,但它可能有足够的信息也被归类为科学文章,只是程度较低。
接下来,我们设置一个MeansBuilder
和LabelSeeker
对象。这些类访问包含单词和标签之间关系的表,我们将在我们的ParagraphVectors
中使用这些表。InMemoryLookupTable
类提供对单词查找的默认表的访问:
MeansBuilder mBuilder =
new MeansBuilder((InMemoryLookupTable<VocabWord>)
pVect.getLookupTable(),tFact);
LabelSeeker lSeeker =
new LabelSeeker(iter.getLabelsSource().getLabels(),
(InMemoryLookupTable<VocabWord>)
pVect.getLookupTable());
最后,我们遍历未标记的文档。对于每个文档,我们将把文档转换成一个向量,并使用我们的LabelSeeker
来获得每个文档的分数。我们记录每个文档的分数,并打印出带有相应标签的分数:
while (unlabeledIter.hasNextDocument()) {
LabelledDocument doc = unlabeledIter.nextDocument();
INDArray docCentroid = mBuilder.documentAsVector(doc);
List<Pair<String, Double>> scores =
lSeeker.getScores(docCentroid);
out.println("Document '" + doc.getLabel() +
"' falls into the following categories: ");
for (Pair<String, Double> score : scores) {
out.println (" " + score.getFirst() + ": " +
score.getSecond());
}
}
我们前面的打印语句的输出如下:
Document 'finance' falls into the following categories:
finance: 0.2889593541622162
health: 0.11753179132938385
science: 0.021202782168984413
Document 'health' falls into the following categories:
finance: 0.059537000954151154
health: 0.27373185753822327
science: 0.07699354737997055
在每一个例子中,我们的文档都被正确分类,正如分配到正确标签类别的百分比较高所证明的那样。这种分类可以与其他数据分析技术结合使用,以得出有关文件中包含的数据的其他结论。通常,文本分类是数据分析过程中的初始或早期步骤,因为文档被分类成组以供进一步分析。
根据相似度对文本进行分类
在下一个例子中,我们将根据结构和相似性来匹配不同的文本样本。我们将仍然使用我们在前面的例子中使用的ParagraphVectors
类。首先,从 GitHub(https://GitHub . com/deep learning 4j/dl4j-examples/tree/master/dl4j-examples/src/main/resources)下载raw_sentences.txt
文件,并将其添加到您的项目中。这个文件包含一个句子列表,我们将读入这些句子,标记它们,然后进行比较。
首先,我们设置我们的ClassPathResource
并分配一个迭代器来处理我们的文件数据。我们在这个例子中使用了一个SentenceIterator
:
ClassPathResource srcFile = new
ClassPathResource("/raw_sentences.txt");
File file = srcFile.getFile();
SentenceIterator iter = new BasicLineIterator(file);
接下来,我们将再次使用TokenizerFactory
来标记我们的数据。我们还想创建一个新的LabelsSource
对象。这允许我们定义句子标签的格式。我们选择在每一行前面加上LINE_
:
TokenizerFactory tFact = new DefaultTokenizerFactory();
tFact.setTokenPreProcessor(new CommonPreprocessor());
LabelsSource labelFormat = new LabelsSource("LINE_");
现在我们已经准备好构建我们的ParagraphVectors
。我们的设置过程包括这些方法:minWordFrequency
,它指定在训练语料库中使用的最小词频,以及iterations
,它指定每个小批量的迭代次数。我们还设置了历元数、层大小和学习速率。此外,我们包括前面定义的LabelsSource
,以及我们的迭代器和标记器。trainWordVectors
方法指定了单词和文档表示是否应该一起构建。最后,sampling
确定是否应该进行二次采样。然后我们调用我们的build
和fit
方法:
ParagraphVectors vec = new ParagraphVectors.Builder()
.minWordFrequency(1)
.iterations(5)
.epochs(1)
.layerSize(100)
.learningRate(0.025)
.labelsSource(labelFormat)
.windowSize(5)
.iterate(iter)
.trainWordVectors(false)
.tokenizerFactory(tFact)
.sampling(0)
.build();
vec.fit();
接下来,我们将包含一些语句来评估我们分类的准确性。值得注意的是,虽然文档本身从1
开始,但是索引过程从0
开始。例如,文档中的行9836
将与标签LINE_9835
相关联。我们将首先比较三个应该归类为有些相似的句子,然后比较两个不相似的句子。similarity
方法获取两个标签,并以double
的形式返回它们之间的相对距离:
double similar1 = vec.similarity("LINE_9835", "LINE_12492");
out.println("Comparing lines 9836 & 12493
('This is my house .'/'This is my world .')
Similarity = " + similar1);
double similar2 = vec.similarity("LINE_3720", "LINE_16392");
out.println("Comparing lines 3721 & 16393
('This is my way .'/'This is my work .')
Similarity = " + similar2);
double similar3 = vec.similarity("LINE_6347", "LINE_3720");
out.println("Comparing lines 6348 & 3721
('This is my case .'/'This is my way .')
Similarity = " + similar3);
double dissimilar1 = vec.similarity("LINE_3720", "LINE_9852");
out.println("Comparing lines 3721 & 9853
('This is my way .'/'We now have one .')
Similarity = " + dissimilar1);
double dissimilar2 = vec.similarity("LINE_3720", "LINE_3719");
out.println("Comparing lines 3721 & 3720
('This is my way .'/'At first he says no .')
Similarity = " + dissimilar2);
我们的打印语句的输出如下所示。比较similarity
方法对三个相似句子和两个不相似句子的结果。特别要注意的是,最后一个例子的similarity
方法结果,两个非常不同的句子,返回了一个负数。这意味着更大的差异:
16:56:15.423 [main] INFO o.d.m.s.SequenceVectors - Epoch: [1]; Words vectorized so far: [3171540]; Lines vectorized so far: [485810]; learningRate: [1.0E-4]
Comparing lines 9836 & 12493 ('This is my house .'/'This is my world .') Similarity = 0.7641470432281494
Comparing lines 3721 & 16393 ('This is my way .'/'This is my work .') Similarity = 0.7246013879776001
Comparing lines 6348 & 3721 ('This is my case .'/'This is my way .') Similarity = 0.8988922834396362
Comparing lines 3721 & 9853 ('This is my way .'/'We now have one .') Similarity = 0.5840312242507935
Comparing lines 3721 & 3720 ('This is my way .'/'At first he says no .') Similarity = -0.6491150259971619
虽然这个例子像我们的第一个分类例子一样使用了ParagraphVectors
,但是这展示了我们方法的灵活性。我们可以使用这些 DL4J 库以多种方式对数据进行分类。
了解标记和位置
词性涉及识别句子中的成分类型。比如这个句子有几个成分,包括动词“has”,几个名词如“example”、“elements”,形容词如“几个”。标记,或者更具体地说词性标记,是将元素类型与单词相关联的过程。
词性标注很有用,因为它增加了关于句子的更多信息。我们可以确定单词之间的关系以及它们的相对重要性。标记的结果通常用于后面的处理步骤。
这项任务可能很困难,因为我们不能依靠简单的词典来确定它们的类型。例如,单词lead
既可以用作名词,也可以用作动词。我们可以在下面两个句子中使用它:
He took the lead in the play.
Lead the way!
词性标注会尝试将正确的标签与句子中的每个单词相关联。
使用 OpenNLP 识别 POS
为了说明这个过程,我们将使用 OpenNLP(https://opennlp.apache.org/)。这是一个开源的 Apache 项目,支持许多其他的 NLP 处理任务。
我们将使用POSModel
类,它可以被训练来识别 POS 元素。在这个例子中,我们将把它与先前基于佩恩树库 标签集(http://www.comp.leeds.ac.uk/ccalas/tagsets/upenn.html)训练的模型一起使用。在http://opennlp.sourceforge.net/models-1.5/可以找到各种经过预训练的模型。我们将使用en-pos-maxent.bin
型号。这已经用所谓的最大熵在英语文本上进行了训练。
最大熵指的是模型中不确定性的数量最大化。对于一个给定的问题,有一组概率描述了数据集的已知情况。这些概率用于建立模型。例如,我们可能知道有 23%的可能性某个特定事件会遵循某个条件。我们不想对未知的概率做任何假设,所以我们避免添加不合理的信息。最大熵方法试图保持尽可能多的不确定性;因此它试图最大化熵。
我们还将使用POSTaggerME
类,它是一个最大熵标记器。这是将进行标签预测的类。对于任何一个句子,都可能有不止一种方式来对其成分进行分类或标记。
我们从代码开始,以获取先前训练的英语标记器模型和要标记的简单句子:
try (InputStream input = new FileInputStream(
new File("en-pos-maxent.bin"));) {
String sentence = "Let's parse this sentence.";
...
} catch (IOException ex) {
// Handle exceptions
}
标记器使用字符串数组,其中每个字符串都是一个单词。下面的序列采用前面的句子并创建一个名为words
的数组。第一部分使用Scanner
类解析句子字符串。如果需要,我们可以使用其他代码从文件中读取数据。之后,List
类的toArray
方法用于创建字符串数组:
List<String> list = new ArrayList<>();
Scanner scanner = new Scanner(sentence);
while(scanner.hasNext()) {
list.add(scanner.next());
}
String[] words = new String[1];
words = list.toArray(words);
然后,使用包含模型的文件构建模型:
POSModel posModel = new POSModel(input);
然后根据模型创建标记:
POSTaggerME posTagger = new POSTaggerME(posModel);
tag
方法完成实际的工作。它被传递一个单词数组并返回一个标签数组。然后显示单词和标签:
String[] posTags = posTagger.tag(words);
for(int i=0; i<posTags.length; i++) {
out.println(words[i] + " - " + posTags[i]);
}
此示例的输出如下:
Let's - NNP
parse - NN
this - DT
sentence. - NN
分析已经确定单词let's
是单数专有名词,而单词parse
和sentence
是单数名词。this
这个词是一个限定词,也就是说,它是一个修饰另一个词的词,有助于识别一个短语是一般的还是特定的。下一节提供了标签列表。
了解 POS 标签
POS 元素返回缩写。在https://www . ling . upenn . edu/courses/Fall _ 2003/ling 001/Penn _ tree bank _ pos . html可以找到 Penn TreeBankPOS 标签列表。以下是该列表的简短版本:
| 标签 | 描述 | 标签 | 描述 |
| DT
| 限定词 | RB
| 副词 |
| JJ
| 形容词 | RBR
| 副词,比较 |
| JJR
| 形容词,比较级 | RBS
| 副词,最高级 |
| JJS
| 形容词,最高级 | RP
| 颗粒 |
| NN
| 名词,单数或复数 | SYM
| 标志 |
| NNS
| Noun, plural | TOP
| 解析树的顶部 |
| NNP
| 专有名词,单数 | VB
| 动词,基本形式 |
| NNPS
| 专有名词,复数 | VBD
| 动词,过去式 |
| POS
| 所有格结尾 | VBG
| 动词、动名词或现在分词 |
| PRP
| 人称代词 | VBN
| 动词,过去分词 |
| PRP$
| 所有格代名词 | VBP
| 动词,非第三人称单数现在时 |
| S
| 简单陈述句 | VBZ
| 动词,第三人称单数现在时 |
如前所述,一个句子可能有不止一组可能的词性赋值。如下所示的topKSequences
方法将返回各种可能的赋值和分数。该方法返回一个由Sequence
对象组成的数组,这些对象的toString
方法返回分数和位置列表:
Sequence sequences[] = posTagger.topKSequences(words);
for(Sequence sequence : sequences) {
out.println(sequence);
}
前一个句子的输出如下,其中最后一个序列被认为是最可能的选择:
-2.3264880694837213 [NNP, NN, DT, NN]
-2.6610271245387853 [NNP, VBD, DT, NN]
-2.6630142638557217 [NNP, VB, DT, NN]
每行输出为句子中的每个单词分配可能的标签。我们可以看到,只有第二个单词parse
被确定为具有其他可能的标签。
接下来,我们将演示如何从文本中提取关系。
从句子中提取关系
在许多分析任务中,了解句子元素之间的关系是很重要的。它有助于评估句子的重要内容并洞察句子的含义。这种类型的分析已经被用于从语法检查到语音识别到语言翻译的任务。
在上一节中,我们演示了一种用于提取词性的方法。使用这种技术,我们能够识别句子中存在的句子成分类型。然而,这些元素之间的关系是缺失的。我们需要解析句子来提取句子元素之间的关系。
使用 OpenNLP 提取关系
有几种技术和 API 可以用来提取这种类型的信息。在这一节中,我们将使用 OpenNLP 来演示一种提取句子结构的方法。演示围绕着ParserTool
类展开,它使用了一个以前训练过的模型。解析过程将返回句子中提取的元素正确的概率。正如许多 NLP 任务一样,通常可能有多个答案。
我们从 try-with-resource 块开始,为模型打开一个输入流。en-parser-chunking.bin
文件包含一个将文本解析成 POS 的模型。在这种情况下,它是为英语而训练的:
try (InputStream modelInputStream = new FileInputStream(
new File("en-parser-chunking.bin"));) {
...
} catch (Exception ex) {
// Handle exceptions
}
在 try 块中,使用输入流创建了一个ParserModel
类的实例。接下来使用ParserFactory
类的create
方法创建实际的解析器:
ParserModel parserModel = new ParserModel(modelInputStream);
Parser parser = ParserFactory.create(parserModel);
我们将使用下面的句子来测试解析器。ParserTool
类的parseLine
方法执行实际的解析并返回一个Parse
对象的数组。这些对象中的每一个都有一个解析选项。parseLine
方法的最后一个参数指定返回多少个备选方案:
String sentence = "Let's parse this sentence.";
Parse[] parseTrees = ParserTool.parseLine(sentence, parser, 3);
下一个序列显示了每种可能性:
for(Parse tree : parseTrees) {
tree.show();
}
本例中 show 方法的输出如下。标签先前已在了解位置标签一节中定义:
(TOP (NP (NP (NNP Let's) (NN parse)) (NP (DT this) (NN sentence.))))
(TOP (S (NP (NNP Let's)) (VP (VB parse) (NP (DT this) (NN sentence.)))))
(TOP (S (NP (NNP Let's)) (VP (VBD parse) (NP (DT this) (NN sentence.)))))
以下示例将最后两个输出重新格式化,以更好地显示关系。他们对动词 parse 的分类不同:
(TOP
(S
(NP (NNP Let's))
(VP (VB parse)
(NP (DT this) (NN sentence.))
)
)
)
(TOP
(S
(NP (NNP Let's))
(VP (VBD parse)
(NP (DT this) (NN sentence.))
)
)
)
当有多个解析选择时,Parse
类的getProb
返回一个概率,该概率反映了模型对选择的置信度。以下序列演示了此方法:
for(Parse tree : parseTrees) {
out.println("Probability: " + tree.getProb());
}
输出如下:
Probability: -3.6810244423259078
Probability: -3.742475884515823
Probability: -4.16148634555491
另一个有趣的 NLP 任务是情感分析,我们将在下面演示。
情感分析
情感分析包括基于单词的上下文、含义和情感含义对单词进行评估和分类。通常,如果我们在字典中查找一个单词,我们会找到该单词的含义或定义,但是,脱离句子的上下文,我们可能无法详细准确地描述该单词的含义。
例如,单词 toast 可以被简单地定义为一片加热并变成褐色的面包。但是在句子的上下文中,他是烤面包!,意思完全变了。情感分析试图根据上下文和用法推导出单词的含义。
值得注意的是,高级情感分析将超越简单的积极或消极分类,并赋予单词详细的情感含义。将单词分为积极或消极要简单得多,但将它们分为快乐、愤怒、冷漠或焦虑要有用得多。
这种类型的分析属于有效计算的范畴,一种对技术工具的情感含义和使用感兴趣的计算。鉴于如今社交媒体网站上可用于分析的受情绪影响的数据越来越多,这种类型的计算尤为重要。
能够确定文本的情感内容使得能够做出更有针对性和适当的反应。例如,能够在客户和技术代表之间的聊天会话中判断情绪反应可以让代表做得更好。当他们之间存在文化或语言差异时,这一点尤为重要。
这种类型的分析也可以应用于视觉图像。它可以用来衡量一个人对新产品的反应,比如在进行品尝测试时,或者判断人们对电影或广告场景的反应。
作为示例的一部分,我们将使用单词袋模型。词袋模型通过包含一组被称为袋的词来简化自然语言处理的词表示,而不考虑语法或词序。单词具有用于分类的特征,最重要的是每个单词的频率。因为有些词,如 the、a 或 and,在任何文本中自然会有较高的出现频率,所以这些词也会被赋予一定的权重。上下文重要性较低的常用词将具有较小的权重,并且在文本分析中的影响较小。
下载并提取 Word2Vec 模型
为了演示情感分析,我们将使用 Google 的 Word2Vec 模型和 DL4J 来简单地根据评论中使用的词将电影评论分为正面或负面。本例改编自 Alex Black 所做的工作(https://github . com/deep learning 4j/dl4j-examples/blob/master/dl4j-examples/src/main/Java/org/deep learning 4j/examples/recurrent/word 2 vessentiment/word 2 vessentimentrnn . Java)。正如本章前面所讨论的,Word2Vec 由两层神经网络组成,经过训练可以从单词的上下文中构建含义。我们还将使用来自 http://ai.stanford.edu/~amaas/data/sentiment/的大量电影评论。
在我们开始之前,你需要从 https://code.google.com/p/word2vec/下载 Word2Vec 数据。基本流程包括:
- 下载并提取电影评论
- 加载 Word2Vec 谷歌新闻矢量
- 加载每个电影评论
评论中的单词被分解成向量,用于训练网络。我们将在五个时期内训练网络,并在每个时期后评估网络的性能。
首先,我们先声明三个最终变量。第一个是检索训练数据的 URL,第二个是存储我们提取的数据的位置,第三个是本地机器上 Google News vectors 的位置。修改第三个变量以反映本地计算机上的位置:
public static final String TRAINING_DATA_URL =
"http://ai.stanford.edu/~amaas/" +
"data/sentiment/aclImdb_v1.tar.gz";
public static final String EXTRACT_DATA_PATH =
FilenameUtils.concat(System.getProperty(
"java.io.tmpdir"), "dl4j_w2vSentiment/");
public static final String GNEWS_VECTORS_PATH =
"C:/YOUR_PATH/GoogleNews-vectors-negative300.bin" +
"/GoogleNews-vectors-negative300.bin";
接下来,我们下载并提取模型数据。接下来的两个方法模仿 DL4J 示例中的代码。我们首先创建一个新方法,getModelData
。接下来完整地示出了该方法。
首先,我们使用之前定义的EXTRACT_DATA_PATH
创建一个新的File
。如果文件不存在,我们创建一个新的目录。接下来,我们再创建两个File
对象,一个用于归档 TAR 文件的路径,一个用于提取数据的路径。在我们尝试提取数据之前,我们检查这两个文件是否存在。如果存档路径不存在,我们从TRAINING_DATA_URL
下载数据,然后提取数据。如果提取的文件不存在,那么我们提取数据:
private static void getModelData() throws Exception {
File modelDir = new File(EXTRACT_DATA_PATH);
if (!modelDir.exists()) {
modelDir.mkdir();
}
String archivePath = EXTRACT_DATA_PATH + "aclImdb_v1.tar.gz";
File archiveName = new File(archivePath);
String extractPath = EXTRACT_DATA_PATH + "aclImdb";
File extractName = new File(extractPath);
if (!archiveName.exists()) {
FileUtils.copyURLToFile(new URL(TRAINING_DATA_URL),
archiveName);
extractTar(archivePath, EXTRACT_DATA_PATH);
} else if (!extractName.exists()) {
extractTar(archivePath, EXTRACT_DATA_PATH);
}
}
为了提取数据,我们将创建另一个名为extractTar
的方法。我们将为该方法提供两个输入,之前定义的archivePath
和EXTRACT_DATA_PATH
。我们还需要定义提取过程中使用的缓冲区大小:
private static final int BUFFER_SIZE = 4096;
我们首先创建一个新的TarArchiveInputStream
。我们使用GzipCompressorInputStream
是因为它提供了对提取.gz
文件的支持。我们还使用BufferedInputStream
来提高提取过程的性能。压缩文件非常大,下载和解压缩可能需要一些时间。
接下来我们创建一个TarArchiveEntry
,并开始使用TarArchiveInputStream
getNextEntry
方法读入数据。当我们处理压缩文件中的条目时,我们首先检查该条目是否是一个目录。如果是,我们将在提取位置创建一个新目录。最后,我们创建一个新的FileOutputStream
和BufferedOutputStream
,并使用write
方法将数据写入提取的位置:
private static void extractTar(String dataIn, String dataOut)
throws IOException {
try (TarArchiveInputStream inStream =
new TarArchiveInputStream(
new GzipCompressorInputStream(
new BufferedInputStream(
new FileInputStream(dataIn))))) {
TarArchiveEntry tarFile;
while ((tarFile = (TarArchiveEntry) inStream.getNextEntry())
!= null) {
if (tarFile.isDirectory()) {
new File(dataOut + tarFile.getName()).mkdirs();
}else {
int count;
byte data[] = new byte[BUFFER_SIZE];
FileOutputStream fileInStream =
new FileOutputStream(dataOut + tarFile.getName());
BufferedOutputStream outStream =
new BufferedOutputStream(fileInStream,
BUFFER_SIZE);
while ((count = inStream.read(data, 0, BUFFER_SIZE))
!= -1) {
outStream.write(data, 0, count);
}
}
}
}
}
构建我们的模型并对文本进行分类
既然我们已经创建了下载和提取数据的方法,我们需要声明和初始化用于控制模型执行的变量。我们的batchSize
指的是我们在每个例子中处理的单词量,在这个例子中是50
。我们的vectorSize
决定了向量的大小。谷歌新闻模型有大小为300
的词向量。nEpochs
指我们尝试运行训练数据的次数。最后,truncateReviewsToLength
指定如果电影评论超过特定长度,出于内存利用的目的,我们是否应该截断电影评论。我们选择截断超过300
个单词的评论:
int batchSize = 50;
int vectorSize = 300;
int nEpochs = 5;
int truncateReviewsToLength = 300;
现在我们可以建立我们的神经网络了。我们将使用一个MultiLayerConfiguration
网络,如第 8 章、深度学习中所讨论的。事实上,我们这里的例子与配置和构建模型中构建的模型非常相似,只是有一些不同。特别是,在这个模型中,我们将在第 0 层使用更快的学习速率和一个GravesLSTM
递归网络。我们将拥有与向量中单词数量相同的输入神经元,在这种情况下,300
。我们还使用gradientNormalization
,这是一种用来帮助我们的算法找到最优解的技术。注意我们正在使用softmax
激活函数,这在第 8 章、深度学习中讨论过。该函数使用回归,特别适用于分类算法:
MultiLayerConfiguration sentimentNN =
new NeuralNetConfiguration.Builder()
.optimizationAlgo(OptimizationAlgorithm
.STOCHASTIC_GRADIENT_DESCENT).iterations(1)
.updater(Updater.RMSPROP)
.regularization(true).l2(1e-5)
.weightInit(WeightInit.XAVIER)
.gradientNormalization(GradientNormalization
.ClipElementWiseAbsoluteValue)
.gradientNormalizationThreshold(1.0)
.learningRate(0.0018)
.list()
.layer(0, new GravesLSTM.Builder()
.nIn(vectorSize).nOut(200)
.activation("softsign").build())
.layer(1, new RnnOutputLayer.Builder()
.activation("softmax")
.lossFunction(LossFunctions.LossFunction.MCXENT)
.nIn(200).nOut(2).build())
.pretrain(false).backprop(true).build();
然后我们可以创建我们的MultiLayerNetwork
,初始化网络,并设置监听器。
MultiLayerNetwork net = new MultiLayerNetwork(sentimentNN);
net.init();
net.setListeners(new ScoreIterationListener(1));
接下来,我们创建一个WordVectors
对象来加载我们的 Google 数据。我们使用一个DataSetIterator
来测试和训练我们的数据。AsyncDataSetIterator
允许我们在一个单独的线程中加载数据,以提高性能。此过程需要大量内存,因此此类改进对于优化性能至关重要:
WordVectors wordVectors = WordVectorSerializer
DataSetIterator trainData = new AsyncDataSetIterator(
new SentimentExampleIterator(EXTRACT_DATA_PATH, wordVectors,
batchSize, truncateReviewsToLength, true), 1);
DataSetIterator testData = new AsyncDataSetIterator(
new SentimentExampleIterator(EXTRACT_DATA_PATH, wordVectors,
100, truncateReviewsToLength, false), 1);
最后,我们准备好训练和评估我们的数据。我们浏览数据nEpochs
次;在这种情况下,我们有五次迭代。每次迭代针对我们的训练数据执行fit
方法,然后使用testData
创建一个新的Evaluation
对象来评估我们的模型。该评估基于大约 25,000 条电影评论,可能需要很长时间来运行。当我们评估数据时,我们创建INDArray
来存储信息,包括来自我们数据的特征矩阵和标签。该数据稍后用于evalTimeSeries
方法的评估。最后,我们打印出我们的评估统计数据:
for (int i = 0; i < nEpochs; i++) {
net.fit(trainData);
trainData.reset();
Evaluation evaluation = new Evaluation();
while (testData.hasNext()) {
DataSet t = testData.next();
INDArray dataFeatures = t.getFeatureMatrix();
INDArray dataLabels = t.getLabels();
INDArray inMask = t.getFeaturesMaskArray();
INDArray outMask = t.getLabelsMaskArray();
INDArray predicted = net.output(dataFeatures, false,
inMask, outMask);
evaluation.evalTimeSeries(dataLabels, predicted, outMask);
}
testData.reset();
out.println(evaluation.stats());
}
最终迭代的输出如下所示。我们归类为0
的示例被视为负面评价,归类为1
的示例被视为正面评价:
Epoch 4 complete. Starting evaluation:
Examples labeled as 0 classified by model as 0: 11122 times
Examples labeled as 0 classified by model as 1: 1378 times
Examples labeled as 1 classified by model as 0: 3193 times
Examples labeled as 1 classified by model as 1: 9307 times
==========================Scores===================================Accuracy: 0.8172
Precision: 0.824
Recall: 0.8172
F1 Score: 0.8206
===================================================================
如果与以前的迭代相比,您应该注意到分数和准确性随着每次评估而提高。随着每次迭代,我们的模型在将电影评论分类为负面或正面方面提高了其准确性。
总结
在本章中,我们介绍了一些 NLP 任务,并展示了它们是如何被支持的。特别是,我们使用了 OpenNLP 和 DL4J 来说明它们是如何执行的。虽然还有许多其他可用的库,但这些示例很好地介绍了这些技术。
我们首先介绍了基本的 NLP 术语和概念,比如命名实体识别、词性以及句子元素之间的关系。命名实体识别涉及查找和标记句子的各个部分,如人、地点和事物。词性将标签与句子的元素联系起来。例如,NN
指名词,VB
指动词。
然后我们讨论了 Word2Vec 和 Doc2Vec 神经网络。这些被用来分类文本,既有标签,也有与其他单词的相似性。我们演示了如何使用 DL4J 资源来创建与标签相关联的文档的特征向量。
虽然这些关联的识别是有趣的,但是当从句子中提取关系时,执行更有用的分析。我们演示了如何使用 OpenNLP 找到关系。POS 与每个单词相关联,单词之间的关系使用一组标签和括号来显示。这种类型的分析可用于更复杂的分析,如语言翻译和语法检查。
最后,我们讨论并展示了情感分析的例子。这个过程包括根据文本的语气或上下文含义对文本进行分类。我们研究了将电影评论分为正面或负面的过程。
在这一章中,我们展示了文本分析和分类的各种技术。在下一章中,我们将研究为视频和音频分析设计的技术。**
十、视觉和听觉分析
声音、图像和视频的使用正在成为我们日常生活中一个更重要的方面。依赖语音命令的电话交谈和设备越来越普遍。人们定期与世界各地的其他人进行视频聊天。照片和视频分享网站已经迅速扩散。利用各种来源的图像、视频和声音的应用程序变得越来越普遍。
在这一章中,我们将展示几种 Java 可以用来处理声音和图像的技术。本章的第一部分讲述声音处理。语音识别和文本到语音(TTS)API 都将被展示。具体来说,我们将使用 FreeTTS(【http://freetts.sourceforge.net/docs/index.php】)API 将文本转换为语音,然后演示 CMU Sphinx 语音识别工具包。
Java 语音 API(JSAPI)(【http://www.oracle.com/technetwork/java/index-140170.html】T4)提供了对语音技术的访问。它不是标准 JDK 的一部分,但受第三方供应商支持。它的目的是支持语音识别和语音合成器。有几个供应商支持 JSAPI,包括 FreeTTS 和 Festival(http://www.cstr.ed.ac.uk/projects/festival/)。
此外,还有几个基于云的语音 API,包括 IBM 通过 Watson Cloud 语音转文本功能提供的支持。
接下来,我们将研究图像处理技术,包括面部识别。这包括识别图像中的人脸。这项技术很容易使用 OpenCV(http://opencv.org/)来实现,我们将在识别人脸部分演示。
我们将以对 Neuroph Studio 的讨论来结束本章,Neuroph Studio 是一个基于 Java 的神经网络编辑器,用于对图像进行分类和执行图像识别。我们将继续使用人脸,并尝试训练一个网络来识别人脸图像。
文本到语音转换
语音合成产生人类语音。TTS 将文本转换成语音,对许多不同的应用都很有用。它被用在许多地方,包括电话帮助台系统和订购系统。TTS 流程通常由两部分组成。第一部分将文本标记和处理成语音单元。第二部分将这些单位转换成语音。
TTS 的两种主要方法使用拼接合成和共振峰合成。拼接合成经常组合预先录制的人类语音来创建所需的输出。共振峰合成不使用人类语音,而是通过创建电子波形来生成语音。
我们将使用自由 TTS(http://freetts.sourceforge.net/docs/index.php)来演示 TTS。最新版本可以从 https://sourceforge.net/projects/freetts/files/的下载。这种方法使用拼接来生成语音。
TTS/FreeTTS 中使用了几个重要术语:
- 话语 -这个概念大致对应于组成一个单词或短语的声音
- 条目 -代表话语部分的特征集(名称/值对)
- Relationship -一个条目列表,FreeTTS 使用它在一个话语中前后迭代
- 电话 -一种独特的声音
- 双音素 -一对相邻的音素
FreeTTS 程序员指南(http://freetts.sourceforge.net/docs/ProgrammerGuide.html)详细介绍了将文本转换为语音的过程。这是一个多步骤的过程,其主要步骤包括:
- 标记化 -从文本中提取标记
- TokenToWords -转换某些单词,如 1910 年到 1910 年
- PartOfSpeechTagger -这一步目前什么也不做,但旨在识别词性
- 短语器 -为话语创建短语关系
- 分段器 -确定音节断开出现的位置
- 暂停生成器(pause generator)-这个步骤在语音中插入暂停,比如在说话之前
- 发音者 -决定口音和音调
- 后置词汇分析器 -这一步修复诸如可用双音素和需要说出的双音素不匹配之类的问题
- 持续时间 -决定音节的持续时间
- ContourGenerator -计算话语的基频曲线,该曲线将频率与时间对应起来,有助于生成音调
- 单元选择器 -将相关的双音素组合成一个单元
- 音高标记生成器 -决定话语的音高
- 单元连接器 -将双音素数据连接在一起
下图来自 FreeTTS 程序员指南,图 11:unit concator处理后的发声,并描绘了流程。这是对 TTS 流程的高度概括,暗示了该流程的复杂性:
使用免费软件
TTS 系统方便了不同声音的使用。例如,这些差异可能存在于语言、说话者的性别或说话者的年龄。
MBROLA 项目的目标是支持尽可能多的语言的语音合成器。MBROLA 是一个语音合成器,可以与 FreeTTS 等 TTS 系统一起使用,以支持 TTS 合成。
从http://tcts.fpms.ac.be/synthesis/mbrola.html下载适用于适当平台的二进制 MBROLA。从同一个页面,下载页面底部找到的任何想要的 MBROLA 声音。对于我们的例子,我们将使用usa1
、usa2
和usa3
。关于设置的更多细节可在http://freetts.sourceforge.net/mbrola/README.html找到。
以下语句说明了访问 MBROLA 声音所需的代码。setProperty
方法指定找到 MBROLA 资源的路径:
System.setProperty("mbrola.base", "path-to-mbrola-directory");
为了演示如何使用 TTS,我们使用下面的语句。我们获得了一个VoiceManager
类的实例,它将提供对各种声音的访问:
VoiceManager voiceManager = VoiceManager.getInstance();
为了使用一个特定的声音,向getVoice
方法传递声音的名称,并返回一个Voice
类的实例。在这个例子中,我们使用了mbrola_us1
,这是一个美国英语,年轻,女性的声音:
Voice voice = voiceManager.getVoice("mbrola_us1");
一旦我们获得了Voice
实例,就使用allocate
方法来加载语音。然后使用speak
方法将传递给该方法的单词合成为一个字符串,如下所示:
voice.allocate();
voice.speak("Hello World");
执行的时候要听到"Hello World"
这几个字。如下一节所述,用其他声音和文本尝试一下,看看哪种组合最适合某个应用。
获取关于声音的信息
VoiceManager
class' getVoices
方法用于获取当前可用的声音数组。这对于向用户提供可供选择的声音列表很有用。我们将使用这里的方法来说明一些可用的声音。在下一个代码序列中,方法返回数组,然后显示数组的元素:
Voice[] voices = voiceManager.getVoices();
for (Voice v : voices) {
out.println(v);
}
输出将类似于以下内容:
CMUClusterUnitVoice
CMUDiphoneVoice
CMUDiphoneVoice
MbrolaVoice
MbrolaVoice
MbrolaVoice
getVoiceInfo
方法提供了潜在的更有用的信息,尽管它有些冗长:
out.println(voiceManager.getVoiceInfo());
输出的第一部分如下:显示VoiceDirectory
目录,随后是语音的详细信息。请注意,目录名包含声音的名称。KevinVoiceDirectory
包含两种声音:kevin
和kevin16
:
VoiceDirectory 'com.sun.speech.freetts.en.us.cmu_time_awb.AlanVoiceDirectory'
Name: alan
Description: default time-domain cluster unit voice
Organization: cmu
Domain: time
Locale: en_US
Style: standard
Gender: MALE
Age: YOUNGER_ADULT
Pitch: 100.0
Pitch Range: 12.0
Pitch Shift: 1.0
Rate: 150.0
Volume: 1.0
VoiceDirectory 'com.sun.speech.freetts.en.us.cmu_us_kal.KevinVoiceDirectory'
Name: kevin
Description: default 8-bit diphone voice
Organization: cmu
Domain: general
Locale: en_US
Style: standard
Gender: MALE
Age: YOUNGER_ADULT
Pitch: 100.0
Pitch Range: 11.0
Pitch Shift: 1.0
Rate: 150.0
Volume: 1.0
Name: kevin16
Description: default 16-bit diphone voice
Organization: cmu
Domain: general
Locale: en_US
Style: standard
Gender: MALE
Age: YOUNGER_ADULT
Pitch: 100.0
Pitch Range: 11.0
Pitch Shift: 1.0
Rate: 150.0
Volume: 1.0
...
Using voices from a JAR file
声音可以存储在 JAR 文件中。VoiceDirectory
类提供了对以这种方式存储的声音的访问。FreeTTs 可用的语音目录位于 lib 目录中,包括以下内容:
cmu_time_awb.jar
cmu_us_kal.jar
语音目录的名称可以从命令提示符处获得:
java -jar fileName.jar
例如,执行以下命令:
java -jar cmu_time_awb.jar
它生成以下输出:
VoiceDirectory 'com.sun.speech.freetts.en.us.cmu_time_awb.AlanVoiceDirectory'
Name: alan
Description: default time-domain cluster unit voice
Organization: cmu
Domain: time
Locale: en_US
Style: standard
Gender: MALE
Age: YOUNGER_ADULT
Pitch: 100.0
Pitch Range: 12.0
Pitch Shift: 1.0
Rate: 150.0
Volume: 1.0
收集语音信息
Voice
类提供了许多允许提取或设置语音特征的方法。正如我们前面所演示的,VoiceManager
class' getVoiceInfo
方法提供了关于当前可用声音的信息。然而,我们可以使用Voice
类来获取关于特定声音的信息。
在下面的例子中,我们将显示关于声音kevin16
的信息。我们首先使用getVoice
方法获得这个voice
的一个实例:
VoiceManager vm = VoiceManager.getInstance();
Voice voice = vm.getVoice("kevin16");
voice.allocate();
接下来,我们调用一些Voice
类的get
方法来获取关于声音的具体信息。这包括以前由getVoiceInfo
方法提供的信息和其他不可用的信息;
out.println("Name: " + voice.getName());
out.println("Description: " + voice.getDescription());
out.println("Organization: " + voice.getOrganization());
out.println("Age: " + voice.getAge());
out.println("Gender: " + voice.getGender());
out.println("Rate: " + voice.getRate());
out.println("Pitch: " + voice.getPitch());
out.println("Style: " + voice.getStyle());
此示例的输出如下:
Name: kevin16
Description: default 16-bit diphone voice
Organization: cmu
Age: YOUNGER_ADULT
Gender: MALE
Rate: 150.0
Pitch: 100.0
Style: standard
这些结果是不言自明的,并让您了解可用信息的类型。还有其他方法可以让您访问通常不感兴趣的关于 TTS 过程的细节。这包括诸如正在使用的音频播放器、特定于话语的数据以及特定电话的功能等信息。
已经演示了如何将文本转换为语音,现在我们将研究如何将语音转换为文本。
理解语音识别
将语音转换为文本是一个重要的应用程序功能。这种能力越来越多地用于各种各样的环境中。仅举几个例子,语音输入用于控制智能电话,作为帮助台应用的一部分自动处理输入,以及帮助残疾人。
语音由复杂的音频流组成。声音可以拆分成个音素,这些音素是相似的声音序列。成对的这些音素被称为双音素。话语由单词和单词间各种类型的停顿组成。
转换过程的本质是通过话语间的沉默来分离声音。然后,将这些话语与听起来最像话语的单词进行匹配。然而,由于许多因素,这可能是困难的。例如,由于单词的上下文、地区方言、声音质量和其他因素,这些差异可能表现为单词发音的差异。
匹配过程相当复杂,并且经常使用多个模型。模型可以用于将声学特征与声音相匹配。可以使用语音模型来匹配音素和单词。另一个模型用于将单词搜索限制到给定的语言。这些模型从来都不是完全准确的,并且会导致识别过程中的不准确性。
我们将使用 CMUSphinx 4 来说明这个过程。
使用 CMUPhinx 将语音转换为文本
CMUSphinx 处理的音频必须是脉码调制 ( PCM )格式。PCM 是一种对模拟数据(如代表语音的模拟波)进行采样并产生数字信号的技术。FFmpeg(【https://ffmpeg.org/】)是一个免费的工具,如果需要可以在音频格式之间转换。
您需要使用 PCM 格式创建样本音频文件。这些文件应该相当短,可以包含数字或单词。建议您使用不同的文件运行示例,看看语音识别的效果如何。
首先,我们通过创建一个处理异常的 try-catch 块来设置转换的基本框架。首先,创建一个Configuration
类的实例。它用于配置识别器以识别标准英语。需要更改配置模型和字典来处理其他语言:
try {
Configuration configuration = new Configuration();
String prefix = "resource:/edu/cmu/sphinx/models/en-us/";
configuration
.setAcousticModelPath(prefix + "en-us");
configuration
.setDictionaryPath(prefix + "cmudict-en-us.dict");
configuration
.setLanguageModelPath(prefix + "en-us.lm.bin");
...
} catch (IOException ex) {
// Handle exceptions
}
然后使用configuration
创建StreamSpeechRecognizer
类。这个类基于输入流处理语音。在下面的代码中,我们从语音文件中创建了一个StreamSpeechRecognizer
类的实例和一个InputStream
:
StreamSpeechRecognizer recognizer = new StreamSpeechRecognizer(
configuration);
InputStream stream = new FileInputStream(new File("filename"));
为了开始语音处理,调用了startRecognition
方法。getResult
方法返回一个保存处理结果的SpeechResult
实例。然后,我们使用SpeechResult
方法来获得最佳结果。我们使用stopRecognition
方法停止处理:
recognizer.startRecognition(stream);
SpeechResult result;
while ((result = recognizer.getResult()) != null) {
out.println("Hypothesis: " + result.getHypothesis());
}
recognizer.stopRecognition();
当这个语句被执行时,我们得到如下结果,假设语音文件包含这个句子:
Hypothesis: mary had a little lamb
当语音被解释时,可能有不止一个可能的单词序列。我们可以使用getNbest
方法获得最佳结果,该方法的参数指定了应该返回多少种可能性。下面演示了这种方法:
Collection<String> results = result.getNbest(3);
for (String sentence : results) {
out.println(sentence);
}
一个可能的输出如下:
<s> mary had a little lamb </s>
<s> marry had a little lamb </s>
<s> mary had a a little lamb </s>
这给了我们基本的结果。然而,我们可能想用实际的语言做些什么。接下来解释获取单词的技术。
获得关于单词的更多细节
可以使用getWords
方法提取结果中的单个单词,如下所示。该方法返回一列WordResult
实例,每个实例代表一个单词:
List<WordResult> words = result.getWords();
for (WordResult wordResult : words) {
out.print(wordResult.getWord() + " ");
}
跟随<sil>
的这个代码序列的输出反映了在讲话开始时发现的沉默:
<sil> mary had a little lamb
我们可以使用WordResult
类的各种方法提取关于单词的更多信息。在下面的序列中,我们将返回与每个单词相关的置信度和时间范围。
getConfidence
方法返回以对数表示的置信度。我们使用SpeechResult
类的getResult
方法来获得Result
类的一个实例。然后使用它的getLogMath
方法获得一个LogMath
实例。向logToLinear
方法传递置信度值,返回值是 0 到 1.0 之间的实数。更大的值反映了更多的信心。
getTimeFrame
方法返回一个TimeFrame
实例。它的toString
方法返回两个整数值,用冒号分隔,反映单词的开始和结束时间:
for (WordResult wordResult : words) {
out.printf("%s\n\tConfidence: %.3f\n\tTime Frame: %s\n",
wordResult.getWord(), result
.getResult()
.getLogMath()
.logToLinear((float)wordResult
.getConfidence()),
wordResult.getTimeFrame());
}
一个可能的输出如下:
<sil>
Confidence: 0.998
Time Frame: 0:430
mary
Confidence: 0.998
Time Frame: 440:900
had
Confidence: 0.998
Time Frame: 910:1200
a
Confidence: 0.998
Time Frame: 1210:1340
little
Confidence: 0.998
Time Frame: 1350:1680
lamb
Confidence: 0.997
Time Frame: 1690:2170
既然我们已经研究了声音是如何被处理的,我们将把注意力转向图像处理。
从图像中提取文本
从图像中提取文字的过程称为OT2【光学字符识别 ( OCR )。当需要处理的文本数据嵌入到图像中时,这非常有用。例如,包含在牌照、路标和方向中的信息有时会非常有用。
我们可以使用 Tess4j(http://tess4j.sourceforge.net/)来执行 OCR,这是一个用于 Tesseract OCR API 的 Java JNA 包装器。我们将使用从维基百科关于 OCR 的文章中捕获的图像来演示如何使用 API(https://en . Wikipedia . org/wiki/Optical _ character _ recognition # Applications)。API 的 Javadoc 可以在 http://tess4j.sourceforge.net/docs/docs-3.0/的找到。我们使用的图像如下所示:
使用 Tess4j 提取文本
ITesseract
接口包含许多 OCR 方法。doOCR
方法获取一个文件并返回一个包含在文件中找到的单词的字符串,如下所示:
ITesseract instance = new Tesseract();
try {
String result = instance.doOCR(new File("OCRExample.png"));
out.println(result);
} catch (TesseractException e) {
// Handle exceptions
}
部分输出如下所示:
OCR engines nave been developed into many lunds oiobiectorlented OCR applicatlons, sucn as reoeipt OCR, involoe OCR, check OCR, legal billing document OCR
They can be used ior
- Data entry ior business documents, e g check, passport, involoe, bank statement and receipt
- Automatic number plate recognnlon
如你所见,这个例子中有许多错误。通常,在正确处理图像之前,需要提高图像的质量。提高输出质量的技术可以在https://github . com/tessera CT-ocr/tessera CT/wiki/improve quality找到。例如,我们可以使用setLanguage
方法来指定处理的语言。此外,该方法通常在 TIFF 图像上效果更好。
在下一个示例中,我们使用了上一幅图像的放大部分,如下所示:
输出要好得多,如下所示:
OCR engines have been developed into many kinds of object-oriented OCR applications, such as receipt OCR,
invoice OCR, check OCR, legal billing document OCR.
They can be used for:
. Data entry for business documents, e.g. check, passport, invoice, bank statement and receipt
. Automatic number plate recognition
这些例子强调了仔细清理数据的必要性。
识别面孔
在许多情况下,识别图像中的人脸是有用的。它可以潜在地将图像分类为包含人的图像,或者在图像中找到人以供进一步处理。我们将使用 OpenCV 3.1(http://opencv.org/opencv-3-1.html)作为例子。
OpenCV(http://opencv.org/)是一个开源的计算机视觉库,支持几种编程语言,包括 Java。它支持许多技术,包括机器学习算法,来执行计算机视觉任务。该库支持诸如人脸检测、跟踪相机运动、提取 3D 模型以及从图像中去除红眼之类的操作。在本节中,我们将演示人脸检测。
使用 OpenCV 检测人脸
下面的例子改编自http://docs . opencv . org/trunk/d9/d52/tutorial _ Java _ dev _ intro . html。首先加载 OpenCV 安装时添加到系统中的本地库。在 Windows 上,这要求有适当的 DLL 文件可用:
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
我们使用一个基本字符串来指定所需 OpenCV 文件的位置。使用绝对路径可以更好地配合许多方法:
String base = "PathToResources";
CascadeClassifier
类用于对象分类。在这种情况下,我们将使用它进行人脸检测。XML 文件用于初始化该类。在下面的代码中,我们使用了lbpcascade_frontalface.xml
文件,它提供了帮助识别对象的信息。OpenCV 下载中有几个文件,如下所示,可用于特定的人脸识别场景:
lbpcascade_frontalcatface.xml
lbpcascade_frontalface.xml
lbpcascade_frontalprofileface.xml
lbpcascade_silverware.xml
下面的语句初始化类以检测人脸:
CascadeClassifier faceDetector =
new CascadeClassifier(base +
"/lbpcascade_frontalface.xml");
加载要处理的图像,如下所示:
Mat image = Imgcodecs.imread(base + "/images.jpg");
对于此示例,我们使用了以下图像:
要找到这张图片,使用术语 people 进行谷歌搜索。选择图像类别,然后过滤掉标记为重复使用的。图片有标签:LyndaSanchez 拍摄的一群正在笑的商务人士的特写肖像。
当检测到人脸时,图像中的位置被存储在一个MatOfRect
实例中。这个类用于保存找到的任何面的向量和矩阵:
MatOfRect faceVectors = new MatOfRect();
此时,我们已经准备好检测人脸。detectMultiScale
方法执行这个任务。图像和保存任何图像位置的MatOfRect
实例被传递给方法:
faceDetector.detectMultiScale(image, faceVectors);
下一条语句显示了检测到的人脸数量:
out.println(faceVectors.toArray().length + " faces found");
我们需要用这些信息来增强图像。这个过程将在每个找到的面周围画出方框,如下所示。为此,使用了Imgproc
class' rectangle
方法。对每个检测到的人脸调用一次该方法。向其传递要修改的图像和表示面部边界的点:
for (Rect rect : faceVectors.toArray()) {
Imgproc.rectangle(image, new Point(rect.x, rect.y),
new Point(rect.x + rect.width, rect.y + rect.height),
new Scalar(0, 255, 0));
}
最后一步使用Imgcodecs
class' imwrite
方法将该图像写入文件:
Imgcodecs.imwrite("faceDetection.png", image);
如下图所示,它能够识别四幅图像:
使用不同的配置文件将更好地适用于其他面部轮廓。
**# 分类可视数据
在本节中,我们将演示一种对可视数据进行分类的技术。我们将使用欧米诺来完成这一任务。Neuroph 是一个基于 Java 的神经网络框架,支持多种神经网络架构。它的开源库为其他应用程序提供支持和插件。在本例中,我们将使用其神经网络编辑器 Neuroph Studio 来创建一个网络。该网络可以被保存并在其他应用中使用。欧米诺工作室可以在这里下载:http://neuroph.sourceforge.net/download.html。我们正在构建这里显示的流程:【http://neuroph.sourceforge.net/image_recognition.htm。
对于我们的例子,我们将创建一个多层感知器 ( MLP )网络。然后我们将训练我们的网络来识别图像。我们可以使用 Neuroph Studio 来训练和测试我们的网络。了解 MLP 网络如何识别和解释图像数据非常重要。每个图像基本上都由三个二维数组表示。每个数组都包含颜色分量的信息:一个数组包含红色的信息,一个包含绿色的信息,一个包含蓝色的信息。数组的每个元素都保存了图像中某个特定像素的信息。然后将这些数组展平为一维数组,用作神经网络的输入。
创建一个用于分类视觉图像的 Neuroph Studio 项目
首先,创建一个新的 Neuroph Studio 项目:
我们将把我们的项目命名为RecognizeFaces
,因为我们将训练神经网络来识别人脸图像:
接下来,我们在项目中创建新文件。有许多类型的项目可供选择,但我们将选择一个图像识别类型:
点击下一个然后点击添加目录。我们在本地机器上创建了一个目录,并添加了一些不同的黑白人脸图像用于训练。这些可以通过搜索谷歌图片或其他搜索引擎找到。从理论上讲,您用来训练的高质量图像越多,您的网络就越好:
点击下一个的后,您将被引导选择一个不可识别的图像。您可能需要根据您想要识别的图像尝试不同的图像。您在此选择的图像将防止错误识别。我们从本地机器上的另一个目录中选择了一个简单的蓝色方块,但是如果您使用其他类型的图像进行训练,其他色块可能会更好:
接下来,我们需要提供网络训练参数。我们还需要标记我们的训练数据集并设置我们的分辨率。高度和宽度20
是一个很好的起点,但是您可能想要更改这些值来改善您的结果。可能会涉及一些尝试和错误。提供该信息的目的是允许图像缩放。当我们将图像缩小时,我们的网络可以更快地处理和学习它们:
最后,我们可以创建我们的网络。我们给我们的网络分配一个标签,定义我们的传递函数。默认功能, Sigmoid ,将适用于大多数网络,但如果你的结果不是最佳的,你可能想尝试 Tanh 。隐层神经元计数的默认数量是12
,这是一个很好的起点。请注意,增加神经元的数量会增加训练网络所需的时间,并降低您将网络推广到其他图像的能力。与我们以前的一些值一样,为了找到给定网络的最佳设置,可能需要进行一些反复试验。完成后选择完成:
训练模型
一旦我们创建了我们的网络,我们需要训练它。首先双击左窗格中的神经网络。这是扩展名为.nnet
的文件。执行此操作时,您将在主窗口中打开网络的可视化表示。然后将文件扩展名为.tsest
的数据集从左侧窗格拖到神经网络的顶部节点。您会注意到节点上的描述更改为数据集的名称。接下来,点击位于窗口左上方的列车按钮:
这将打开一个带有培训设置的对话框。您可以保留最大误差、学习率和动量的默认值。确保勾选了显示误差图框。随着训练过程的继续,您可以看到错误率的提高:
点击 Train 按钮后,您应该会看到类似下图的错误图:
选择标题为图像识别测试的选项卡。然后点击选择测试图像按钮。我们已经加载了一个简单的人脸图像,它不包含在我们的原始数据集中:
找到输出标签。它将位于底部或左侧窗格中,并将显示我们的测试图像与训练集中的每个图像进行比较的结果。数字越大,我们的测试图像与来自我们的训练集的图像越接近。最后一幅图像产生了比前几次比较更大的输出数。如果我们比较这些图像,它们比数据集中的其他图像更相似,因此网络能够对我们的测试图像创建更积极的识别:
我们现在可以保存我们的网络供以后使用。从文件菜单中选择保存,然后您可以在外部应用程序中使用.nnet
文件。下面的代码示例显示了一种通过您预先构建的神经网络运行测试数据的简单技术。NeuralNetwork
类是 Neuroph 核心包的一部分,而load
方法允许你将训练好的网络加载到你的项目中。注意,我们使用了我们的神经网络名称faces_net
。然后,我们检索图像识别文件的插件。接下来,我们用一个新图像调用recognizeImage
方法,它必须处理一个IOException
。我们的结果存储在HashMap
中,并打印到控制台:
NeuralNetwork iRNet = NeuralNetwork.load("faces_net.nnet");
ImageRecognitionPlugin iRFile
= (ImageRecognitionPlugin)iRNet.getPlugin(
ImageRecognitionPlugin.class);
try {
HashMap<String, Double> newFaceMap
= imageRecognition.recognizeImage(
new File("testFace.jpg"));
out.println(newFaceMap.toString());
} catch(IOException e) {
// Handle exceptions
}
这个过程允许我们使用 GUI 编辑器应用程序在更直观的环境中创建我们的网络,然后将训练好的网络嵌入到我们自己的应用程序中。
总结
在这一章中,我们演示了许多处理语音和图像的技术。随着电子设备越来越多地采用这些通信媒介,这种能力变得越来越重要。
使用 FreeTSS 演示了 TTS。这种技术允许计算机以语音而不是文本的形式呈现结果。我们学会了如何控制声音的属性,比如性别和年龄。
识别语音是有用的,有助于弥合人机界面的差距。我们演示了如何使用 CMUSphinx 来识别人类语音。由于语音通常有多种解释方式,我们学习了 API 如何返回各种选项。我们还演示了如何提取单个单词,以及识别正确单词的相对置信度。
图像处理是许多应用的重要方面。我们通过使用 Tess4J 从图像中提取文本开始了对图像处理讨论。这个过程有时被称为 OCR。我们了解到,与许多视频和音频数据文件一样,结果的质量与图像的质量有关。
我们还学习了如何使用 OpenCV 来识别图像中的人脸。关于面的特定视图(如正视图或侧视图)的信息包含在 XML 文件中。这些文件用于在图像中勾勒人脸。一次可以检测多张脸。
对图像进行分类会很有帮助,有时外部工具对这一目的很有用。我们检查了 Neuroph Studio,并创建了一个旨在识别和分类图像的神经网络。然后我们用人脸图像测试我们的网络。
在下一章中,我们将学习如何使用多个处理器来加速常见的数据科学应用。**
十一、数据分析的数学和并行技术
程序的并发执行可以显著提高性能。在这一章中,我们将讨论可用于数据科学应用的各种技术。这些范围从低级的数学计算到高级的特定于 API 的选项。
请始终记住,性能增强始于确保实现正确的应用程序功能集。如果应用程序不能满足用户的期望,那么这些改进就是徒劳的。应用程序的架构和使用的算法也比代码增强更重要。总是使用最有效的算法。然后应该考虑代码增强。我们无法在本章中解决更高层次的优化问题;相反,我们将关注代码增强。
许多数据科学应用程序和支持 API 使用矩阵运算来完成任务。通常这些操作隐藏在 API 中,但是有时候我们可能需要直接使用它们。无论如何,理解这些操作是如何被支持的是有益的。为此,我们将解释如何使用几种不同的方法来处理矩阵乘法。
可以使用 Java 线程实现并发处理。开发人员可以使用线程和线程池来改善应用程序的响应时间。当多个 CPU 或 GPU 不可用时,许多 api 会使用线程,就像 Aparapi 的情况一样。这里我们不举例说明线程的使用。但是,我们假设读者对线程和线程池有基本的了解。
map-reduce 算法广泛用于数据科学应用。我们将介绍一种使用 Apache 的 Hadoop 实现这种并行处理的技术。Hadoop 是一个支持大型数据集操作的框架,可以大大减少大型数据科学项目所需的处理时间。我们将演示一种计算样本数据集平均值的技术。
有几个著名的 API 支持多处理器,包括 CUDA 和 OpenCL。使用用于 CUDA(JCuda)(【http://jcuda.org/】T4)的 Java 绑定来支持 CUDA。我们不会在这里直接演示这种技术。然而,我们将使用的许多 API 确实支持 CUDA,如果它可用的话,比如 DL4J。我们将简要讨论 OpenCL 以及 Java 是如何支持它的。Aparapi API 提供了更高级别的支持,可能使用多个 CPU 或 GPU,这是没有价值的。我们将演示一个支持矩阵乘法的 Aparapi。
在本章中,我们将研究如何利用多个 CPU 和 GPU 来加速数据挖掘任务。我们使用的许多 API 已经利用了多处理器的优势,或者至少提供了一种支持 GPU 使用的方法。我们将在本章中介绍其中的一些选项。
云中也广泛支持并发处理。这里讨论的许多技术都在云中使用。因此,我们不会明确说明如何在云中进行并行处理。
实现基本矩阵运算
有几种不同类型的矩阵运算,包括简单的加法、减法、标量乘法和各种形式的乘法。为了说明矩阵运算,我们将关注所谓的矩阵乘积。这是一种常见的方法,涉及两个矩阵相乘以产生第三个矩阵。
考虑两个矩阵, A 和 B ,其中矩阵 A 有 n 行和 m 列。矩阵 B 将有 m 行和 p 列。 A 和 B 的乘积,写成 AB ,是一个 n 行和 p 列的矩阵。将矩阵 B 列的 m 个条目乘以 A 行的 m 个条目。这一点在此处有更明确的显示,其中:
其中产品定义如下:
我们从矩阵的声明和初始化开始。变量n
、m
、p
代表矩阵的维数。A
矩阵由m
表示n
,B
矩阵由p
表示m
,代表产品的C
矩阵由p
表示n
:
int n = 4;
int m = 2;
int p = 3;
double A[][] = {
{0.1950, 0.0311},
{0.3588, 0.2203},
{0.1716, 0.5931},
{0.2105, 0.3242}};
double B[][] = {
{0.0502, 0.9823, 0.9472},
{0.5732, 0.2694, 0.916}};
double C[][] = new double[n][p];
以下代码序列说明了使用嵌套for
循环的乘法运算:
for (int i = 0; i < n; i++) {
for (int k = 0; k < m; k++) {
for (int j = 0; j < p; j++) {
C[i][j] += A[i][k] * B[k][j];
}
}
}
以下代码序列格式化输出以显示我们的矩阵:
out.println("\nResult");
for (int i = 0; i < n; i++) {
for (int j = 0; j < p; j++) {
out.printf("%.4f ", C[i][j]);
}
out.println();
}
结果如下所示:
Result
0.0276 0.1999 0.2132
0.1443 0.4118 0.5417
0.3486 0.3283 0.7058
0.1964 0.2941 0.4964
稍后,我们将演示执行相同操作的几种替代技术。接下来,我们将讨论如何使用 DL4J 定制对多处理器的支持。
使用 GPU 和 DeepLearning4j
DeepLearning4j 可以与 NVIDIA 提供的 GPU 一起工作。有一些选项可以启用 GPU 的使用,指定应该使用多少 GPU,以及控制 GPU 内存的使用。在本节中,我们将展示如何使用这些选项。这种类型的控件通常可用于其他高级 API。
DL4J 使用 n 维数组进行 Java(ND4J)(【http://nd4j.org/】T4)进行数值计算。这是一个支持 n 维数组对象和其他数值计算的库,例如线性代数和信号处理。它包括对 GPU 的支持,还集成了 Hadoop 和 Spark。
一个向量是一个一维数组,广泛用于神经网络。向量是一种叫做张量的数学结构。张量本质上是一个多维数组。我们可以把一个张量想象成一个三维或者多维的数组,每个维度称为一个秩。
通常需要将一组多维数字映射到一维数组。这是通过使用定义的顺序展平阵列来实现的。例如,对于二维数组,许多系统将按行列顺序分配数组成员。这意味着第一行被添加到向量中,接着是第二个向量,然后是第三个,依此类推。我们将在使用 ND4J API 一节中使用这种方法。
要启用 GPU,需要修改项目的 POM 文件。在 POM 文件的 properties 部分,需要添加或修改nd4j.backend
标记,如下所示:
<nd4j.backend>nd4j-cuda-7.5-platform</<nd4j.backend>
可以使用ParallelWrapper
类并行训练模型。训练任务在可用的 CPU/GPU 之间自动分配。该模型被用作ParallelWrapper
类的Builder
构造函数的参数,如下所示:
ParallelWrapper parallelWrapper =
new ParallelWrapper.Builder(aModel)
// Builder methods...
.build();
当执行时,在每个 GPU 上使用模型的副本。在通过averagingFrequency
方法指定迭代次数之后,模型被平均,然后训练过程继续。
有多种方法可用于配置该类,如下表所示:
| 方法 | 目的 |
| prefetchBuffer
| 指定用于预取数据的缓冲区的大小 |
| workers
| 指定要使用的工人数量 |
| averageUpdaters``averagingFrequency``reportScoreAfterAveraging``useLegacyAveraging
| 控制如何实现平均的各种方法 |
工作线程的数量应该大于可用 GPU 的数量。
与大多数计算一样,使用较低的精度值将加快处理速度。这可以通过setDTypeForContext
方法来控制,如下图所示。在这种情况下,指定了半精度:
DataTypeUtil.setDTypeForContext(DataBuffer.Type.HALF);
这种支持和更多关于优化技术的细节可以在http://deeplearning4j.org/gpu找到。
使用地图缩小
Map-reduce 是一种以并行、分布式方式处理大型数据集的模型。这个模型由一个用于过滤和排序数据的map
方法和一个用于汇总数据的reduce
方法组成。map-reduce 框架非常有效,因为它将数据集的处理分布在多个服务器上,同时对较小的数据块执行映射和缩减。当以多线程方式实现时,Map-reduce 提供了显著的性能改进。在这一节中,我们将使用 Apache 的 Hadoop 实现来演示一项技术。在【使用 Java 8 执行 map-reduce 的 一节中,我们将讨论使用 Java 8 流执行 map-reduce 的技术。
Hadoop 是一个为并行计算提供支持的软件生态系统。Map-reduce 作业可以在 Hadoop 服务器上运行,通常设置为集群,以显著提高处理速度。Hadoop 具有在 Hadoop 集群中的节点上运行 map-reduce 操作的跟踪器。每个节点独立运行,跟踪器监控进程并整合每个节点的输出以生成最终输出。下图位于http://www . developer . com/Java/data/big-data-tool-map-reduce . html,展示了带有跟踪器的基本 map-reduce 模型。
使用 Apache 的 Hadoop 执行 map-reduce
我们将向您展示一个非常简单的地图缩小应用程序示例。在使用 Hadoop 之前,我们需要下载并提取 Hadoop 应用程序文件。最新版本可以在http://hadoop.apache.org/releases.html找到。在这个演示中,我们使用的是版本 2.7.3。
您需要设置您的JAVA_HOME
环境变量。此外,Hadoop 不能容忍长文件路径和路径中的空格,因此请确保将 Hadoop 提取到尽可能简单的目录结构中。
我们将使用一个包含书籍信息的样本文本文件。制表符分隔的文件的每一行都有书名、作者和页数:
Moby Dick Herman Melville 822
Charlotte's Web E.B. White 189
The Grapes of Wrath John Steinbeck 212
Jane Eyre Charlotte Bronte 299
A Tale of Two Cities Charles Dickens 673
War and Peace Leo Tolstoy 1032
The Great Gatsby F. Scott Fitzgerald 275
我们将使用一个map
函数来提取标题和页数信息,然后使用一个reduce
函数来计算数据集中书籍的平均页数。首先,创建一个新的类,AveragePageCount
。我们将在AveragePageCount
中创建两个静态类,一个处理 map 过程,一个处理 reduction。
绘制地图的方法
首先,我们将创建TextMapper
类,它将实现map
方法。这个类继承自Mapper
类,有两个私有实例变量,pages
和bookTitle
。pages
是一个IntWritable
对象,bookTitle
是一个Text
对象。使用IntWritable
和Text
是因为这些对象在被传输到服务器进行处理之前需要被序列化为字节流。这些物体比类似的int
或String
物体占用更少的空间,传输速度更快:
public static class TextMapper
extends Mapper<Object, Text, Text, IntWritable> {
private final IntWritable pages = new IntWritable();
private final Text bookTitle = new Text();
}
在我们的TextMapper
类中,我们创建了map
方法。这个方法有三个参数:key
对象、Text
对象、bookInfo
和Context
。该键允许跟踪器将每个特定的对象映射回正确的作业。对象包含每本书的文本或字符串数据。Context
保存关于整个系统的信息,并允许该方法报告系统内的进度和更新值。
在map
方法中,我们使用split
方法将每条图书信息分解成一个String
对象数组。我们将变量bookTitle
设置为数组的位置0
,并将pages
设置为存储在位置2
中的值,然后将其解析为一个整数。然后,我们可以通过上下文写出书名和页数信息,并更新我们的整个系统:
public void map(Object key, Text bookInfo, Context context)
throws IOException, InterruptedException {
String[] book = bookInfo.toString().split("\t");
bookTitle.set(book[0]);
pages.set(Integer.parseInt(book[2]));
context.write(bookTitle, pages);
}
编写 reduce 方法
接下来,我们将编写我们的AverageReduce
类。这个类扩展了Reducer
类,并将执行归约过程来计算我们的平均页数。我们为这个类创建了四个变量:一个FloatWritable
对象存储我们的平均页数,一个浮点average
保存我们的临时平均值,一个浮点count
计算我们的数据集中有多少本书,一个整数sum
计算总页数:
public static class AverageReduce
extends Reducer<Text, IntWritable, Text, FloatWritable> {
private final FloatWritable finalAvg = new FloatWritable();
Float average = 0f;
Float count = 0f;
int sum = 0;
}
在我们的AverageReduce
类中,我们将创建reduce
方法。这个方法将一个Text
键、一个保存代表页面计数的可写整数的Iterable
对象和Context
作为输入。我们使用迭代器来处理页面计数,并将每个页面计数添加到我们的总和中。然后我们计算平均值并设置finalAvg
的值。该信息与一个Text
对象标签配对,并被写入Context
:
public void reduce(Text key, Iterable<IntWritable> pageCnts,
Context context)
throws IOException, InterruptedException {
for (IntWritable cnt : pageCnts) {
sum += cnt.get();
}
count += 1;
average = sum / count;
finalAvg.set(average);
context.write(new Text("Average Page Count = "), finalAvg);
}
创建并执行新的 Hadoop 作业
我们现在准备在同一个类中创建我们的main
方法,并执行我们的 map-reduce 过程。为此,我们需要创建一个新的Configuration
对象和一个新的Job
。然后,我们设置要在应用程序中使用的重要类。
public static void main(String[] args) throws Exception {
Configuration con = new Configuration();
Job bookJob = Job.getInstance(con, "Average Page Count");
...
}
我们在setJarByClass
方法中设置我们的主类AveragePageCount
。我们分别使用setMapperClass
和setReducerClass
方法指定我们的TextMapper
和AverageReduce
类。我们还使用setOutputKeyClass
和setOutputValueClass
方法指定我们的输出将有一个基于文本的键和一个可写整数:
bookJob.setJarByClass(AveragePageCount.class);
bookJob.setMapperClass(TextMapper.class);
bookJob.setReducerClass(AverageReduce.class);
bookJob.setOutputKeyClass(Text.class);
bookJob.setOutputValueClass(IntWritable.class);
最后,我们使用addInputPath
和setOutputPath
方法创建新的输入和输出路径。这些方法都将我们的Job
对象作为第一个参数,将代表我们的输入和输出文件位置的Path
对象作为第二个参数。我们于是称之为waitForCompletion
。一旦这个调用返回 true,我们的应用程序就退出:
FileInputFormat.addInputPath(bookJob, new Path("C:/Hadoop/books.txt"));
FileOutputFormat.setOutputPath(bookJob, new
Path("C:/Hadoop/BookOutput"));
if (bookJob.waitForCompletion(true)) {
System.exit(0);
}
要执行应用程序,打开命令提示符并导航到包含我们的AveragePageCount.class
文件的目录。然后,我们使用以下命令来执行我们的示例应用程序:
hadoop AveragePageCount
当我们的任务运行时,我们会在屏幕上看到关于流程输出的更新信息。我们的输出示例如下所示:
...
File System Counters
FILE: Number of bytes read=1132
FILE: Number of bytes written=569686
FILE: Number of read operations=0
FILE: Number of large read operations=0
FILE: Number of write operations=0
Map-Reduce Framework
Map input records=7
Map output records=7
Map output bytes=136
Map output materialized bytes=156
Input split bytes=90
Combine input records=0
Combine output records=0
Reduce input groups=7
Reduce shuffle bytes=156
Reduce input records=7
Reduce output records=7
Spilled Records=14
Shuffled Maps =1
Failed Shuffles=0
Merged Map outputs=1
GC time elapsed (ms)=11
Total committed heap usage (bytes)=536870912
Shuffle Errors
BAD_ID=0
CONNECTION=0
IO_ERROR=0
WRONG_LENGTH=0
WRONG_MAP=0
WRONG_REDUCE=0
File Input Format Counters
Bytes Read=249
File Output Format Counters
Bytes Written=216
如果我们打开在本地机器上创建的BookOutput
目录,我们会发现四个新文件。使用文本编辑器打开part-r-00000
。该文件包含使用并行进程计算的平均页数信息。该输出的示例如下:
Average Page Count = 673.0
Average Page Count = 431.0
Average Page Count = 387.0
Average Page Count = 495.75
Average Page Count = 439.0
Average Page Count = 411.66666
Average Page Count = 500.2857
请注意,当每个单独的过程与其他减少过程相结合时,平均值是如何变化的。这与首先计算前两本书的平均值,然后加入第三本书,然后第四本书,以此类推的效果相同。这里的优点当然是平均是以并行方式完成的。如果我们有一个巨大的数据集,我们应该会看到在执行时间上的明显优势。BookOutput
的最后一行反映了所有七个页面计数的正确和最终平均值。
各种数学库
有许多数学库可供 Java 使用。在这一节中,我们将提供几个库的快速和高层次的概述。这些库不一定自动支持多处理器。此外,本节的目的是提供一些关于如何使用这些库的见解。在大多数情况下,它们相对容易使用。
Java 数学库的列表可以在 https://en . Wikipedia . org/wiki/List _ of _ numerical _ libraries # Java 和 https://java-matrix.org/的找到。我们将演示 jblas、Apache Commons Math 和 ND4J 库的使用。
使用 jblas API
jblas API(http://jblas.org/)是一个支持 Java 的数学库。它基于基础线性代数子程序 ( 布拉斯)(http://www.netlib.org/blas/)和线性代数包(LAPACK)(http://www.netlib.org/lapack/),是快速算术计算的标准库。jblas API 提供了这些库的包装器。
下面是如何执行矩阵乘法的演示。我们从矩阵定义开始:
DoubleMatrix A = new DoubleMatrix(new double[][]{
{0.1950, 0.0311},
{0.3588, 0.2203},
{0.1716, 0.5931},
{0.2105, 0.3242}});
DoubleMatrix B = new DoubleMatrix(new double[][]{
{0.0502, 0.9823, 0.9472},
{0.5732, 0.2694, 0.916}});
DoubleMatrix C;
执行乘法的实际语句非常短,如下所示。对A
矩阵执行mmul
方法,其中B
数组作为参数传递:
C = A.mmul(B);
然后显示生成的C
矩阵:
for(int i=0; i<C.getRows(); i++) {
out.println(C.getRow(i));
}
输出应该如下所示:
[0.027616, 0.199927, 0.213192]
[0.144288, 0.411798, 0.541650]
[0.348579, 0.328344, 0.705819]
[0.196399, 0.294114, 0.496353]
这个库非常容易使用,并且支持大量的算术运算。
使用 Apache Commons 数学应用编程接口
Apache Commons math API(http://commons.apache.org/proper/commons-math/)支持大量的数学和统计运算。以下示例说明了如何执行矩阵乘法。
我们从声明和初始化A
和B
矩阵开始:
double[][] A = {
{0.1950, 0.0311},
{0.3588, 0.2203},
{0.1716, 0.5931},
{0.2105, 0.3242}};
double[][] B = {
{0.0502, 0.9823, 0.9472},
{0.5732, 0.2694, 0.916}};
Apache Commons 使用RealMatrix
类来保存一个矩阵。在下面的代码序列中,使用Array2DRowRealMatrix
构造函数创建了A
和B
矩阵的对应矩阵:
RealMatrix aRealMatrix = new Array2DRowRealMatrix(A);
RealMatrix bRealMatrix = new Array2DRowRealMatrix(B);
使用 multiply 方法进行乘法运算非常简单,如下所示:
RealMatrix cRealMatrix = aRealMatrix.multiply(bRealMatrix);
下一个for
循环将显示以下结果:
for (int i = 0; i < cRealMatrix.getRowDimension(); i++) {
out.println(cRealMatrix.getRowVector(i));
}
输出应该如下所示:
{0.02761552; 0.19992684; 0.2131916}
{0.14428772; 0.41179806; 0.54165016}
{0.34857924; 0.32834382; 0.70581912}
{0.19639854; 0.29411363; 0.4963528}
使用 ND4J API
ND4J(【http://nd4j.org/】)是 DL4J 用来进行算术运算的库。该库也可以直接使用。在本节中,我们将演示如何使用A
和B
矩阵执行矩阵乘法。
在执行乘法之前,我们需要将矩阵展平为向量。下面声明并初始化这些向量:
double[] A = {
0.1950, 0.0311,
0.3588, 0.2203,
0.1716, 0.5931,
0.2105, 0.3242};
double[] B = {
0.0502, 0.9823, 0.9472,
0.5732, 0.2694, 0.916};
给定一个向量和维度信息,Nd4j
class' create
方法创建一个INDArray
实例。该方法的第一个参数是向量。第二个参数指定矩阵的维数。最后一个参数指定行和列的布局顺序。这种顺序或者是如c
所示的行列优先顺序,或者是 FORTRAN 使用的行列优先顺序。行列顺序意味着第一行被分配给向量,接着是第二行,依此类推。
在下面的代码序列中,使用A
和B
向量创建了2INDArray
实例。第一个是由第三个参数c
指定的使用行优先顺序的4
行、2
列矩阵。第二个INDArray
实例表示B
矩阵。如果我们想使用行列排序,我们将使用一个f
来代替。
INDArray aINDArray = Nd4j.create(A,new int[]{4,2},'c');
INDArray bINDArray = Nd4j.create(B,new int[]{2,3},'c');
由cINDArray
表示的C
数组随后被声明,并被赋予乘法的结果。mmul
执行操作:
INDArray cINDArray;
cINDArray = aINDArray.mmul(bINDArray);
以下序列使用getRow
方法显示结果:
for(int i=0; i<cINDArray.rows(); i++) {
out.println(cINDArray.getRow(i));
}
输出应该如下所示:
[0.03, 0.20, 0.21]
[0.14, 0.41, 0.54]
[0.35, 0.33, 0.71]
[0.20, 0.29, 0.50]
接下来,我们将提供对 OpenCL API 的概述,该 API 为许多平台上的并发操作提供支持。
使用 OpenCL
开放计算语言(OpenCL)(【https://www.khronos.org/opencl/】T4)支持跨异构平台执行的程序,即可能使用不同厂商和架构的平台。平台可以使用不同的处理单元,包括中央处理器(CPU)图形处理器(GPU)数字信号处理器(DSP)现场可编程门阵列 ( FPGA ,以及其他类型的处理器。
OpenCL 使用基于 C99 的语言对设备进行编程,为编程并发行为提供了标准接口。OpenCL 支持允许用不同语言编写代码的 API。对于 Java,有几个 API 支持开发基于 OpenCL 的语言:
- OpenCL 的 Java 绑定(JOCL)(【http://www.jocl.org/】T4)——这是一个到最初 OpenCL C 实现的绑定,可能会很冗长。
- JavaCl(【https://code.google.com/archive/p/javacl/】T2)——为 JOCL 提供一个面向对象的接口。
- Java OpenCL(【http://jogamp.org/jocl/www/】T2)——也提供了一个面向对象的 JOCL 抽象。它不是供客户端使用的。
- 轻量级 Java 游戏库(LWJGL)(【https://www.lwjgl.org/】T4)——也为 OpenCL 提供支持,面向 GUI 应用。
此外,Aparapi 提供了对 OpenCL 的高级访问,从而避免了创建 OpenCL 应用程序所涉及的一些复杂性。
运行在处理器上的代码被封装在内核中。多个内核将在不同的计算设备上并行执行。OpenCL 支持不同级别的内存。特定设备可能不支持每个级别。这些级别包括:
- 全局内存 -由所有计算单元共享
- 只读存储器 -一般不可写
- 本地存储器 -由一组计算单元共享
- 每个元素的私有内存 -通常是一个寄存器
OpenCL 应用程序需要大量的初始代码才能发挥作用。这种复杂性不允许我们提供其使用的详细示例。然而,Aparapi 部分确实提供了一些 OpenCL 应用程序是如何构造的感觉。
使用 Aparapi
APAR API(https://github.com/aparapi/aparapi)是一个支持并发操作的 Java 库。API 支持在 GPU 或 CPU 上运行的代码。GPU 操作使用 OpenCL 执行,CPU 操作使用 Java 线程。用户可以指定使用哪个计算资源。但是,如果 GPU 支持不可用,Aparapi 将恢复到 Java 线程。
API 将在运行时将 Java 字节代码转换成 OpenCL。这使得 API 很大程度上独立于所使用的显卡。该 API 最初是由 AMD 开发的,但已经作为开源软件发布。这反映在基本包名com.amd.aparari
中。Aparapi 提供了比 OpenCL 更高层次的抽象。
Aparapi 代码位于从Kernel
类派生的类中。它的execute
方法将开始操作。这将导致对一个run
方法的内部调用,该方法需要被覆盖。并发代码放在run
方法中。run
方法在不同的处理器上执行多次。
由于 OpenCL 的限制,我们不能使用继承或方法重载。此外,它不喜欢run
方法中的println
,因为代码可能运行在 GPI 上。Aparapi 只支持一维数组。使用二维或更多维的数组需要展平为一维数组。对双精度值的支持取决于 OpenCL 版本和 GPU 配置。
当使用 Java 线程池时,它为每个 CPU 内核分配一个线程。包含 Java 代码的内核被克隆,每个线程一个副本。这避免了跨线程访问数据的需要。每个线程都可以访问信息,如全局 ID,以帮助代码执行。内核将等待所有线程完成。
Aparapi 下载可以在 https://github.com/aparapi/aparapi/releases 找到。
创建 Aparapi 应用程序
Aparapi 应用程序的基本框架如下所示。它由一个Kernel
派生类组成,其中run
方法被重写。在这个例子中,run
方法将执行标量乘法。这种运算包括将向量的每个元素乘以某个值。
ScalarMultiplicationKernel
扩展了Kernel
类。它拥有两个用于保存输入和输出矩阵的实例变量。构造函数将初始化矩阵。run
方法将执行实际的计算,displayResult
方法将显示乘法的结果:
public class ScalarMultiplicationKernel extends Kernel {
float[] inputMatrix;
float outputMatrix [];
public ScalarMultiplicationKernel(float inputMatrix[]) {
...
}
@Override
public void run() {
...
}
public void displayResult() {
...
}
}
此处显示了构造函数:
public ScalarMultiplicationKernel(float inputMatrix[]) {
this.inputMatrix = inputMatrix;
outputMatrix = new float[this.inputMatrix.length];
}
在run
方法中,我们使用一个全局 ID 来索引矩阵。该代码在每个计算单元上执行,例如 GPU 或线程。为每个计算单元提供唯一的全局 ID,允许代码访问矩阵的特定元素。在这个例子中,输入矩阵的每个元素乘以2
,然后分配给输出矩阵的相应元素:
public void run() {
int globalID = this.getGlobalId();
outputMatrix[globalID] = 2.0f * inputMatrix[globalID];
}
displayResult
方法只是显示outputMatrix
数组的内容:
public void displayResult() {
out.println("Result");
for (float element : outputMatrix) {
out.printf("%.4f ", element);
}
out.println();
}
为了使用这个内核,我们需要为inputMatrix
和它的size
声明变量。size
将用于控制执行多少内核:
float inputMatrix[] = {3, 4, 5, 6, 7, 8, 9};
int size = inputMatrix.length;
然后使用输入矩阵创建内核,接着调用execute
方法。该方法启动流程,并最终基于execute
方法的参数调用Kernel
类的run
方法。该参数被称为通行证 ID。虽然在本例中没有使用,但我们将在下一节中使用它。当流程完成时,显示结果输出矩阵,并调用dispose
方法停止流程:
ScalarMultiplicationKernel kernel =
new ScalarMultiplicationKernel(inputMatrix);
kernel.execute(size);
kernel.displayResult();
kernel.dispose();
当执行该应用程序时,我们将得到以下输出:
6.0000 8.0000 10.0000 12.0000 14.0000 16.0000 18.000
我们可以使用内核类'setExecutionMode
方法指定执行模式,如下所示:
kernel.setExecutionMode(Kernel.EXECUTION_MODE.GPU);
但是,最好让 Aparapi 来决定执行模式。下表总结了可用的执行模式:
| 执行模式 | 意为 |
| Kernel.EXECUTION_MODE.NONE
| 不指定模式 |
| Kernel.EXECUTION_MODE.CPU
| 使用 CPU |
| Kernel.EXECUTION_MODE.GPU
| 使用 GPU |
| Kernel.EXECUTION_MODE.JTP
| 使用 Java 线程 |
| Kernel.EXECUTION_MODE.SEQ
| 使用单循环(用于调试目的) |
接下来,我们将演示如何使用 Aparapi 来执行点积矩阵乘法。
使用 Aparapi 进行矩阵乘法
我们将使用在实现基本矩阵运算部分中使用的矩阵。我们从MatrixMultiplicationKernel
类的声明开始,它包含向量声明、一个构造函数、run
方法和一个displayResults
方法。通过按行列顺序分配矩阵,矩阵A
和B
的向量已被展平为一维数组:
class MatrixMultiplicationKernel extends Kernel {
float[] vectorA = {
0.1950f, 0.0311f, 0.3588f,
0.2203f, 0.1716f, 0.5931f,
0.2105f, 0.3242f};
float[] vectorB = {
0.0502f, 0.9823f, 0.9472f,
0.5732f, 0.2694f, 0.916f};
float[] vectorC;
int n;
int m;
int p;
@Override
public void run() {
...
}
public MatrixMultiplicationKernel(int n, int m, int p) {
...
}
public void displayResults () {
...
}
}
MatrixMultiplicationKernel
构造函数为矩阵的维数赋值,并为存储在vectorC,
中的结果分配内存,如下所示:
public MatrixMultiplicationKernel(int n, int m, int p) {
this.n = n;
this.p = p;
this.m = m;
vectorC = new float[n * p];
}
run 方法使用全局 ID 和通道 ID 来执行矩阵乘法。pass ID 被指定为Kernel
class' execute
方法的第二个参数,我们很快就会看到。这个值允许我们提升vectorC
的列索引。向量索引映射到原始矩阵的相应行和列位置:
public void run() {
int i = getGlobalId();
int j = this.getPassId();
float value = 0;
for (int k = 0; k < p; k++) {
value += vectorA[k + i * m] * vectorB[k * p + j];
}
vectorC[i * p + j] = value;
}
displayResults
方法如下所示:
public void displayResults() {
out.println("Result");
for (int i = 0; i < n; i++) {
for (int j = 0; j < p; j++) {
out.printf("%.4f ", vectorC[i * p + j]);
}
out.println();
}
}
内核的启动方式与上一节相同。向execute
方法传递应该创建的内核数和一个整数,该整数表示要传递的次数。通过次数用于控制进入vectorA
和vectorB
阵列的索引:
MatrixMultiplicationKernel kernel = new MatrixMultiplicationKernel(n, m,
p);kernel.execute(6, 3);kernel.displayResults();
kernel.dispose();
当执行此示例时,您将获得以下输出:
Result
0.0276 0.1999 0.2132
0.1443 0.4118 0.5417
0.3486 0.3283 0.7058
0.1964 0.2941 0.4964
接下来,我们将看到 Java 8 新增功能如何以并行方式帮助解决数学密集型问题。
使用 Java 8 流
Java 8 的发布对该语言进行了大量重要的增强。我们感兴趣的两个增强包括 lambda 表达式和流。lambda 表达式本质上是一个匿名函数,它为 Java 增加了一个函数式编程维度。Java 8 中引入的流的概念不是指 IO 流。相反,您可以将其视为一系列对象,可以使用流畅的编程风格来生成和操作这些对象。这种风格将很快演示。
与大多数 API 一样,程序员必须使用真实的测试用例和环境来仔细考虑他们代码的实际执行性能。如果使用不当,流实际上可能不会提供性能改进。特别是并行流,如果不仔细处理,可能会产生不正确的结果。
我们将从快速介绍 lambda 表达式和流开始。如果你熟悉这些概念,你可以跳过下一节。
理解 Java 8 lambda 表达式和流
lambda 表达式可以用几种不同的形式表示。下面是一个简单的 lambda 表达式,其中符号->
是 lambda 运算符。这将采用某个值e
,并返回乘以 2 的值。e
这个名字没什么特别的。可以使用任何有效的 Java 变量名:
e -> 2 * e
它也可以用其他形式表示,例如:
(int e) -> 2 * e
(double e) -> 2 * e
(int e) -> {return 2 * e;
使用的形式取决于e
的预期值。Lambda 表达式经常被用作方法的参数,我们很快就会看到。
可以使用多种技术创建流。在下面的示例中,流是从数组创建的。IntStream
接口是一种使用整数的流。方法将一个数组转换成一个流:
IntStream stream = Arrays.stream(numbers);
然后,我们可以应用各种stream
方法来执行操作。在下面的语句中,forEach
方法将简单地显示流中的每个整数:
stream.forEach(e -> out.printf("%d ", e));
有各种各样的stream
方法可以应用于一个流。在下面的例子中,mapToDouble
方法将取一个整数,乘以2
,然后返回一个double
。forEach
方法将显示这些值:
stream
.mapToDouble(e-> 2 * e)
.forEach(e -> out.printf("%.4f ", e));
方法调用的级联被称为流畅编程。
使用 Java 8 执行矩阵乘法
这里,我们将说明如何使用流来执行矩阵乘法。矩阵A
、B
和C
的定义与实现基本矩阵运算一节中的定义相同。为了方便起见,这里复制了它们:
double A[][] = {
{0.1950, 0.0311},
{0.3588, 0.2203},
{0.1716, 0.5931},
{0.2105, 0.3242}};
double B[][] = {
{0.0502, 0.9823, 0.9472},
{0.5732, 0.2694, 0.916}};
double C[][] = new double[n][p];
以下序列是矩阵乘法的流实现。代码的详细解释如下:
C = Arrays.stream(A)
.parallel()
.map(AMatrixRow -> IntStream.range(0, B[0].length)
.mapToDouble(i -> IntStream.range(0, B.length)
.mapToDouble(j -> AMatrixRow[j] * B[j][i])
.sum()
).toArray()).toArray(double[][]::new);
第一个map
方法,如下所示,创建了一个表示A
矩阵的4
行的双向量流。range
方法将返回从第一个参数到第二个参数的流元素列表。
.map(AMatrixRow -> IntStream.range(0, B[0].length)
变量i
对应第二个range
方法生成的数字,对应B
矩阵(2
)中的行数。变量j
对应第三个range
方法生成的数字,代表B
矩阵的列数(3
)。
该语句的核心是矩阵乘法,其中的sum
方法计算总和:
.mapToDouble(j -> AMatrixRow[j] * B[j][i])
.sum()
表达式的最后一部分为 C 矩阵创建二维数组。操作符::new
称为方法引用,是调用 new 操作符创建新对象的一种更简短的方式:
).toArray()).toArray(double[][]::new);
displayResult
方法如下:
public void displayResult() {
out.println("Result");
for (int i = 0; i < n; i++) {
for (int j = 0; j < p; j++) {
out.printf("%.4f ", C[i][j]);
}
out.println();
}
}
该序列的输出如下:
Result
0.0276 0.1999 0.2132
0.1443 0.4118 0.5417
0.3486 0.3283 0.7058
0.1964 0.2941 0.4964
使用 Java 8 执行 map-reduce
在下一节中,我们将使用 Java 8 streams 执行一个 map-reduce 操作,类似于在使用 map-reduce 一节中使用 Hadoop 演示的操作。在这个例子中,我们将使用Book
个对象中的Stream
。然后,我们将演示如何使用 Java 8 reduce
和average
方法来获得总页数和平均页数。
我们没有像在 Hadoop 示例中那样从文本文件开始,而是创建了一个带有标题、作者和页数字段的Book
类。在driver
类的main
方法中,我们创建了Book
的新实例,并将它们添加到名为books
的ArrayList
中。我们还创建了一个double
值average
来保存我们的平均值,并将变量totalPg
初始化为零:
ArrayList<Book> books = new ArrayList<>();
double average;
int totalPg = 0;
books.add(new Book("Moby Dick", "Herman Melville", 822));
books.add(new Book("Charlotte's Web", "E.B. White", 189));
books.add(new Book("The Grapes of Wrath", "John Steinbeck", 212));
books.add(new Book("Jane Eyre", "Charlotte Bronte", 299));
books.add(new Book("A Tale of Two Cities", "Charles Dickens", 673));
books.add(new Book("War and Peace", "Leo Tolstoy", 1032));
books.add(new Book("The Great Gatsby", "F. Scott Fitzgerald", 275));
接下来,我们执行映射和归约操作来计算帐套中的总页数。为了以并行的方式实现这一点,我们使用了stream
和parallel
方法。然后我们使用带有 lambda 表达式的map
方法来累计每个Book
对象的所有页面计数。最后,我们使用reduce
方法将我们的页面计数合并成一个最终值,该值将被分配给totalPg
:
totalPg = books
.stream()
.parallel()
.map((b) -> b.pgCnt)
.reduce(totalPg, (accumulator, _item) -> {
out.println(accumulator + " " +_item);
return accumulator + _item;
});
注意,在前面的reduce
方法中,我们选择了打印出关于归约操作的累积值和单个项目的信息。accumulator
代表我们页面计数的集合。_item
表示 map-reduce 流程中在任何给定时刻都在进行缩减的单个任务。
在接下来的输出中,我们将首先看到在处理每个图书项目时,accumulator
值保持为零。逐渐地,accumulator
值增加。最后的操作是减少1223
和2279
的值。这两个数字的总和就是3502
,或者说我们所有书籍的总页数:
0 822
0 189
0 299
0 673
0 212
299 673
0 1032
0 275
1032 275
972 1307
189 212
822 401
1223 2279
接下来,我们将添加代码来计算帐套的平均页数。当我们除以由size
方法返回的整数时,我们将使用 map-reduce 确定的totalPg
值乘以1.0
以防止截断。然后我们打印出average
。
average = 1.0 * totalPg / books.size();
out.printf("Average Page Count: %.4f\n", average);
我们的输出如下:
Average Page Count: 500.2857
我们可以使用 Java 8 streams 直接使用map
方法来计算平均值。将以下代码添加到main
方法中。我们使用parallelStream
和map
方法来同时获得我们每本书的页数。然后,我们使用mapToDouble
来确保我们的数据是正确的类型,以计算我们的平均值。最后,我们使用average
和getAsDouble
方法来计算我们的平均页数:
average = books
.parallelStream()
.map(b -> b.pgCnt)
.mapToDouble(s -> s)
.average()
.getAsDouble();
out.printf("Average Page Count: %.4f\n", average);
然后我们打印出我们的平均值。我们的输出与前面的示例相同,如下所示:
Average Page Count: 500.2857
这些技术利用与 map-reduce 框架相关的 Java 8 功能来解决数值问题。这种类型的过程也可以应用于其他类型的数据,包括基于文本的数据。当这些流程在大大缩短的时间框架内处理极大的数据集时,真正的好处就显现出来了。
总结
数据科学广泛使用数学来分析问题。有许多可用的 Java 数学库,其中许多都支持并发操作。在这一章中,我们介绍了一些库和技术,让我们深入了解如何使用它们来支持和提高应用程序的性能。
我们从讨论如何执行简单的矩阵乘法开始。给出了一个基本的 Java 实现。在后面的小节中,我们使用其他 API 和技术复制了实现。
许多高级 API,如 DL4J,支持许多有用的数据分析技术。在这些 API 之下,通常是对多个 CPU 和 GPU 的并发支持。有时这种支持是可配置的,例如 DL4J。我们简要讨论了如何配置 ND4J 来支持多处理器。
map-reduce 算法在数据科学领域得到了广泛的应用。我们利用这个框架的并行处理能力来计算一组给定值的平均值,即一组书的页数。这种技术使用 Apache 的 Hadoop 来执行 map 和 reduce 函数。
大量的库支持数学技术。许多这些库不直接支持并行操作。然而,了解什么是可用的以及如何使用它们是很重要的。为此,我们演示了如何使用三种不同的 Java API:jblas、Apache Commons Math 和 ND4J。
OpenCL 是一种 API,支持在各种硬件平台、处理器类型和语言上的并行操作。这种支持是相当低的水平。OpenCL 有许多 Java 绑定,我们已经讨论过了。
Aparapi 是对 Java 的高级支持,可以使用 CPU、CUDA 或 OpenCL 来实现并行操作。我们用矩阵乘法的例子演示了这种支持。
我们以对 Java 8 流和 lambda 表达式的介绍结束了我们的讨论。这些语言元素可以支持并行操作,从而提高应用程序的性能。此外,一旦程序员熟悉了这些技术,这通常可以提供更优雅、更易维护的实现。我们还演示了使用 Java 8 执行 map-reduce 的技术。
在下一章,我们将通过举例说明有多少介绍的技术可以用来构建一个完整的应用程序来结束这本书。
十二、将这一切结合在一起
虽然我们已经展示了使用 Java 支持数据科学任务的许多方面,但是仍然需要以集成的方式组合和使用这些技术。孤立地使用这些技术是一回事,以连贯的方式使用它们是另一回事。在本章中,我们将为您提供这些技术的额外经验,以及如何将它们结合使用的见解。
具体来说,我们将创建一个基于控制台的应用程序,分析与用户定义的主题相关的 tweets。使用基于控制台的应用程序使我们能够专注于特定于数据科学的技术,并避免选择可能与我们无关的特定 GUI 技术。它提供了一个公共基础,如果需要,可以从这个基础上创建 GUI 实现。
该应用程序执行并演示了以下高级任务:
-
数据采集
-
Data cleaning, including:
-
删除停用词
-
Cleaning the text
- 情感分析
- 基础数据统计收集
- 结果显示
-
这些步骤中的许多步骤可以使用多种类型的分析。我们将展示更相关的方法,并适当提及其他可能性。我们将尽可能使用 Java 8 的特性。
定义我们应用的目的和范围
该应用程序将提示用户输入一组选择标准,包括主题和子主题区域,以及要处理的 tweets 数量。执行的分析将简单地计算和显示一个主题和子主题的正面和负面推文的数量。我们使用了通用的情感分析模型,这将影响情感分析的质量。但是,可以添加其他模型和更多分析。
我们将使用 Java 8 流来构建 tweet 数据的处理。它是一串TweetHandler
对象,我们将很快描述。
我们在这个应用程序中使用了几个类。它们总结如下:
- 这个类保存原始的 tweet 文本和处理所需的特定字段,包括实际的 tweet、用户名和类似的属性。
TwitterStream
:用于获取应用程序的数据。使用特定的类将数据的获取与处理分开。该类拥有几个控制如何获取数据的字段。ApplicationDriver
:这包含了main
方法、用户提示和控制分析的TweetHandler
流。
这些类中的每一个都将在后面的章节中详细介绍。然而,我们将在接下来的ApplicationDriver
中概述分析过程以及用户如何与应用程序交互。
了解应用程序的架构
每个应用程序都有自己独特的结构或架构。这种架构为应用程序提供了总体的组织或框架。对于这个应用程序,我们在ApplicationDriver
类中使用 Java 8 流来组合这三个类。这个类由三个方法组成:
ApplicationDriver
:包含应用程序的用户输入performAnalysis
:执行分析main
:创建ApplicationDriver
实例
接下来显示了类结构。三个实例变量用于控制处理:
public class ApplicationDriver {
private String topic;
private String subTopic;
private int numberOfTweets;
public ApplicationDriver() { ... }
public void performAnalysis() { ... }
public static void main(String[] args) {
new ApplicationDriver();
}
}
接下来是ApplicationDriver
构造函数。创建一个Scanner
实例并构建情感分析模型:
public ApplicationDriver() {
Scanner scanner = new Scanner(System.in);
TweetHandler swt = new TweetHandler();
swt.buildSentimentAnalysisModel();
...
}
该方法的其余部分提示用户输入,然后调用performAnalysis
方法:
out.println("Welcome to the Tweet Analysis Application");
out.print("Enter a topic: ");
this.topic = scanner.nextLine();
out.print("Enter a sub-topic: ");
this.subTopic = scanner.nextLine().toLowerCase();
out.print("Enter number of tweets: ");
this.numberOfTweets = scanner.nextInt();
performAnalysis();
performAnalysis
方法使用从TwitterStream
实例获得的 Java 8 Stream
实例。TwitterStream
类构造函数使用 tweets 的数量和topic
作为输入。该类在使用 Twitter 的数据采集部分讨论:
public void performAnalysis() {
Stream<TweetHandler> stream = new TwitterStream(
this.numberOfTweets, this.topic).stream();
...
}
该流使用一系列的map
、filter
和一个forEach
方法来执行处理。map
方法修改流的元素。filter
方法从流中移除元素。forEach
方法将终止流并生成输出。
流的各个方法按顺序执行。当从公共 Twitter 流获取 Twitter 信息时,Twitter 信息以 JSON 文档的形式到达,我们首先对其进行处理。这允许我们提取相关的 tweet 信息,并将数据设置到TweetHandler
实例的字段中。接下来,推文的文本被转换成小写。只处理英语推文,并且只处理包含子主题的推文。然后处理推文。最后一步计算统计数据:
stream
.map(s -> s.processJSON())
.map(s -> s.toLowerCase())
.filter(s -> s.isEnglish())
.map(s -> s.removeStopWords())
.filter(s -> s.containsCharacter(this.subTopic))
.map(s -> s.performSentimentAnalysis())
.forEach((TweetHandler s) -> {
s.computeStats();
out.println(s);
});
然后显示处理结果:
out.println();
out.println("Positive Reviews: "
+ TweetHandler.getNumberOfPositiveReviews());
out.println("Negative Reviews: "
+ TweetHandler.getNumberOfNegativeReviews());
我们在周一晚上的一场足球比赛中测试了我们的应用程序,使用的主题是#MNF。 # 符号被称为标签,用于对推文进行分类。通过选择一个流行的推文类别,我们确保了我们将有大量的推文数据来处理。为了简单起见,我们选择了足球副题。对于这个例子,我们还选择只分析 50 条推文。以下是我们的提示、输入和输出的简短示例:
Building Sentiment Model
Welcome to the Tweet Analysis Application
Enter a topic: #MNF
Enter a sub-topic: football
Enter number of tweets: 50
Creating Twitter Stream
51 messages processed!
Text: rt @ bleacherreport : touchdown , broncos ! c . j . anderson punches ! lead , 7 - 6 # mnf # denvshou
Date: Mon Oct 24 20:28:20 CDT 2016
Category: neg
...
Text: i cannot emphasize enough how big td drive . @ broncos offense . needed confidence booster & amp ; just got . # mnf # denvshou
Date: Mon Oct 24 20:28:52 CDT 2016
Category: pos
Text: least touchdown game . # mnf
Date: Mon Oct 24 20:28:52 CDT 2016
Category: neg
Positive Reviews: 13
Negative Reviews: 27
我们打印出每条推文的文本,以及时间戳和类别。请注意,推文的文本并不总是有意义的。这可能是因为 Twitter 数据的缩写性质,但部分原因是因为该文本已被清理,停用词已被删除。我们仍然应该看到我们的主题,#MNF
,尽管由于我们的文本清理,它将是小写的。最后,我们打印出分为正面和负面的推文总数。
推文的分类是通过performSentimentAnalysis
方法完成的。注意,使用情感分析的分类过程并不总是精确的。下面的推文提到了一名丹佛野马队球员触地得分。根据个人对该团队的个人感受,这条推文可以被解释为积极或消极的,但我们的模型将其归类为积极的:
Text: cj anderson td run @ broncos . broncos now lead 7 - 6 . # mnf
Date: Mon Oct 24 20:28:42 CDT 2016
Category: pos
此外,一些推文可能有中性语气,如下图所示,但仍可分为正面或负面。下面这条推文是一个热门体育新闻推特账号的转发,@bleacherreport
:
Text: rt @ bleacherreport : touchdown , broncos ! c . j . anderson punches ! lead , 7 - 6 # mnf # denvshou
Date: Mon Oct 24 20:28:37 CDT 2016
Category: neg
这条推文被归类为负面,但也许可以被认为是中立的。推文的内容只是提供了一场足球比赛的比分信息。这是积极的还是消极的事件将取决于一个人可能支持哪个队。当我们检查分析的整套 tweet 数据时,我们注意到同一条@bleacherreport
tweet 被转发了很多次,每次都被归类为负面。当我们考虑到我们可能有大量分类不当的推文时,这可能会扭曲我们的分析。使用不正确的数据会降低结果的准确性。
根据分析的目的,一种选择可能是排除新闻媒体或其他受欢迎的 Twitter 用户的推文。此外,我们可以排除带有 RT 的推文,这是一个缩写,表示该推文是另一个用户的转发。
在执行这种类型的分析时,还需要考虑其他问题,包括所使用的子主题。如果我们要分析一个星球大战角色的受欢迎程度,那么我们需要小心我们使用的名字。例如,当选择诸如韩 Solo 的角色名称时,推文可能会使用别名。汉·索洛的别名包括维克·德雷戈、里斯多、杰诺斯·伊达尼安、索洛·贾萨尔、神枪手和乔贝克·琼恩,仅举几个例子(T1)。可能会用演员的名字来代替实际的角色,在韩索罗的情况下是哈里森·福特。我们也可以考虑演员的昵称,比如 Harry 代表 Harrison。
使用 Twitter 获取数据
Twitter API 与 HBC 的 HTTP 客户端结合使用来获取推文,如前面第二章、数据采集的处理 Twitter 部分所述。这个过程包括使用默认访问级别的公共流 API 来提取当前在 Twitter 上流动的公共 tweets 的样本。我们将根据用户选择的关键字来提炼数据。
首先,我们声明了TwitterStream
类。它由两个实例变量(numberOfTweets
和topic
)、两个构造函数和一个stream
方法组成。numberOfTweets
变量包含要选择和处理的推文数量,而topic
允许用户搜索与特定主题相关的推文。我们已经设置了默认的构造函数来提取与Star Wars
相关的100
tweets:
public class TwitterStream {
private int numberOfTweets;
private String topic;
public TwitterStream() {
this(100, "Stars Wars");
}
public TwitterStream(int numberOfTweets, String topic) { ... }
}
我们的TwitterStream
类的核心是stream
方法。我们首先使用创建 Twitter 应用程序时 Twitter 提供的信息执行身份验证。然后我们创建一个BlockingQueue
对象来保存我们的流数据。在本例中,我们将设置默认容量1000
。我们在trackTerms
方法中使用topic
变量来指定我们要搜索的推文类型。最后,我们指定我们的endpoint
并关闭失速警告:
String myKey = "mySecretKey";
String mySecret = "mySecret";
String myToken = "myToKen";
String myAccess = "myAccess";
out.println("Creating Twitter Stream");
BlockingQueue<String> statusQueue = new
LinkedBlockingQueue<>(1000);
StatusesFilterEndpoint endpoint = new StatusesFilterEndpoint();
endpoint.trackTerms(Lists.newArrayList("twitterapi", this.topic));
endpoint.stallWarnings(false);
现在我们可以使用OAuth1
创建一个Authentication
对象,这是OAuth
类的一个变种。这允许我们构建我们的连接客户端并完成 HTTP 连接:
Authentication twitterAuth = new OAuth1(myKey, mySecret, myToken,
myAccess);
BasicClient twitterClient = new ClientBuilder()
.name("Twitter client")
.hosts(Constants.STREAM_HOST)
.endpoint(endpoint)
.authentication(twitterAuth)
.processor(new StringDelimitedProcessor(statusQueue))
.build();
twitterClient.connect();
接下来,我们创建两个数组列表,list
保存我们的TweetHandler
对象,twitterList
保存从 Twitter 流出的 JSON 数据。我们将在下一节讨论TweetHandler
对象。我们使用drainTo
方法代替第 2 章、数据采集中演示的poll
方法,因为它对于大量数据更有效:
List<TweetHandler> list = new ArrayList();
List<String> twitterList = new ArrayList();
接下来,我们遍历检索到的消息。我们调用take
方法从BlockingQueue
实例中移除每个字符串消息。然后,我们使用该消息创建一个新的TweetHandler
对象,并将其放入我们的list
中。在我们处理完所有消息并且 for 循环完成之后,我们停止 HTTP 客户端,显示消息的数量,并返回我们的TweetHandler
对象流:
statusQueue.drainTo(twitterList);
for(int i=0; i<numberOfTweets; i++) {
String message;
try {
message = statusQueue.take();
list.add(new TweetHandler(message));
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
twitterClient.stop();
out.printf("%d messages processed!\n",
twitterClient.getStatsTracker().getNumMessages());
return list.stream();
}
我们现在准备清理和分析我们的数据。
了解 TweetHandler 类
TweetHandler
类保存关于特定 tweet 的信息。它获取原始的 JSON tweet,并提取与应用程序需求相关的部分。它还拥有处理 tweet 文本的方法,比如将文本转换成小写,删除不相关的 tweet。该类的第一部分如下所示:
public class TweetHandler {
private String jsonText;
private String text;
private Date date;
private String language;
private String category;
private String userName;
...
public TweetHandler processJSON() { ... }
public TweetHandler toLowerCase(){ ... }
public TweetHandler removeStopWords(){ ... }
public boolean isEnglish(){ ... }
public boolean containsCharacter(String character) { ... }
public void computeStats(){ ... }
public void buildSentimentAnalysisModel{ ... }
public TweetHandler performSentimentAnalysis(){ ... }
}
实例变量显示了从 tweet 中检索并处理的数据类型,如下所示:
jsonText
:原始 JSON 文本text
:处理后的 tweet 的文本date
:推文的日期- 推文的语言
category
:推文分类,正面还是负面userName
:Twitter 用户的名字
该类还使用了其他几个实例变量。以下内容用于创建和使用情感分析模型。分类器静态变量指的是模型:
private static String[] labels = {"neg", "pos"};
private static int nGramSize = 8;
private static DynamicLMClassifier<NGramProcessLM>
classifier = DynamicLMClassifier.createNGramProcess(
labels, nGramSize);
默认构造函数用于提供一个实例来构建情感模型。单参数构造函数使用原始 JSON 文本创建一个TweetHandler
对象:
public TweetHandler() {
this.jsonText = "";
}
public TweetHandler(String jsonText) {
this.jsonText = jsonText;
}
其余的方法将在下面的章节中讨论。
为情感分析模型提取数据
在第九章、文本分析中,我们使用 DL4J 进行了情感分析。在这个例子中,我们将使用 LingPipe 作为前面方法的替代方法。因为我们想要对 Twitter 数据进行分类,所以我们选择了一个包含预先分类的推文的数据集,可以在http://thinknook . com/WP-content/uploads/2012/09/perspective-Analysis-dataset . zip获得。在继续我们的应用程序开发之前,我们必须完成一个一次性的过程,将这些数据提取为我们的模型可以使用的格式。
这个数据集存在于一个大的.csv
文件中,每行有一条 tweet 和分类。推文被分为0
(负面)或1
(正面)。以下是该数据文件中一行的示例:
95,0,Sentiment140, - Longest night ever.. ugh! http://tumblr.com/xwp1yxhi6
第一个元素代表一个惟一的 ID 号,它是原始数据集的一部分,我们将用它作为文件名。第二个元素是分类,第三个是数据集标签(在本项目中被忽略),最后一个元素是实际的 tweet 文本。在将这些数据用于我们的 LingPipe 模型之前,我们必须将每条 tweet 写入一个单独的文件。为此,我们创建了三个字符串变量。根据每条推文的分类,filename
变量将被赋予pos
或neg
,并将用于写操作。我们还使用file
变量保存单个 tweet 文件的名称,使用text
变量保存单个 tweet 文本。接下来,我们使用readAllLines
方法和Paths
类的get
方法将数据存储在List
对象中。我们还需要指定字符集StandardCharsets.ISO_8859_1
:
try {
String filename;
String file;
String text;
List<String> lines = Files.readAllLines(
Paths.get("\\path-to-file\\SentimentAnalysisDataset.csv"),
StandardCharsets.ISO_8859_1);
...
} catch (IOException ex) {
// Handle exceptions
}
现在我们可以循环遍历我们的列表,并使用split
方法将我们的.csv
数据存储在一个字符串数组中。我们将位置1
的元素转换为整数,并确定它是否是一个1
。用1
分类的推文被认为是正面推文,我们将filename
设置为pos
。所有其他推文将filename
设置为neg
。我们从位置0
的元素中提取输出文件名,从元素3
中提取文本。出于本项目的目的,我们忽略位置2
的标签。最后,我们写出我们的数据:
for(String s : lines) {
String[] oneLine = s.split(",");
if(Integer.parseInt(oneLine[1])==1) {
filename = "pos";
} else {
filename = "neg";
}
file = oneLine[0]+".txt";
text = oneLine[3];
Files.write(Paths.get(
path-to-file\\txt_sentoken"+filename+""+file),
text.getBytes());
}
注意,我们在txt_sentoken
目录中创建了neg
和pos
目录。当我们读取文件来构建模型时,这个位置很重要。
建立情感模型
现在我们已经准备好构建我们的模型了。我们遍历包含pos
和neg
的labels
数组,并为每个标签创建一个新的Classification
对象。然后我们使用这个标签创建一个新文件,并使用listFiles
方法创建一个文件名数组。接下来,我们将使用一个for
循环遍历这些文件名:
public void buildSentimentAnalysisModel() {
out.println("Building Sentiment Model");
File trainingDir = new File("\\path to file\\txt_sentoken");
for (int i = 0; i < labels.length; i++) {
Classification classification =
new Classification(labels[i]);
File file = new File(trainingDir, labels[i]);
File[] trainingFiles = file.listFiles();
...
}
}
在for
循环中,我们提取 tweet 数据并将其存储在我们的字符串review
中。然后我们使用review
和classification
创建一个新的Classified
对象。最后我们可以调用handle
方法来分类这个特殊的文本:
for (int j = 0; j < trainingFiles.length; j++) {
try {
String review = Files.readFromFile(trainingFiles[j],
"ISO-8859-1");
Classified<CharSequence> classified = new
Classified<>(review, classification);
classifier.handle(classified);
} catch (IOException ex) {
// Handle exceptions
}
}
对于上一节中讨论的数据集,此过程可能需要相当长的时间。然而,我们认为这种时间权衡是值得的,这种训练数据使分析质量成为可能。
处理 JSON 输入
Twitter 数据是使用 JSON 格式检索的。我们将使用 Twitter 4j(http://twitter4j.org)提取 tweet 的相关部分,并存储在TweetHandler
类的相应字段中。
TweetHandler
类的processJSON
方法执行实际的数据提取。基于 JSON 文本创建了一个JSONObject
的实例。该类拥有几种从对象中提取特定类型数据的方法。我们使用getString
方法来获得我们需要的字段。
接下来显示了processJSON
方法的开始,我们从获取JSONObject
实例开始,我们将使用它来提取 tweet 的相关部分:
public TweetHandler processJSON() {
try {
JSONObject jsonObject = new JSONObject(this.jsonText);
...
} catch (JSONException ex) {
// Handle exceptions
}
return this;
}
首先,我们提取推文的文本,如下所示:
this.text = jsonObject.getString("text");
接下来,我们提取推文的日期。我们使用SimpleDateFormat
类将日期字符串转换成一个Date
对象。它的构造函数被传递一个指定日期字符串格式的字符串。我们使用了字符串"EEE MMM d HH:mm:ss Z yyyy"
,其组成部分将在下面详述。字符串元素的顺序对应于 JSON 实体中的顺序:
EEE
:用三个字符指定的星期几MMM
:月份,三位字符d
:一个月中的某一天HH:mm:ss
:时、分、秒Z
:时区yyyy
:年份
代码如下:
SimpleDateFormat sdf = new SimpleDateFormat(
"EEE MMM d HH:mm:ss Z yyyy");
try {
this.date = sdf.parse(jsonObject.getString("created_at"));
} catch (ParseException ex) {
// Handle exceptions
}
剩余的字段将被提取,如下所示。我们必须提取一个中间 JSON 对象来提取name
字段:
this.language = jsonObject.getString("lang");
JSONObject user = jsonObject.getJSONObject("user");
this.userName = user.getString("name");
获取并提取了文本后,我们现在准备执行清理数据的重要任务。
清理数据以改善我们的结果
数据清理是大多数数据科学问题的关键步骤。没有正确清理的数据可能会有错误,如拼写错误、日期等元素的不一致表示以及无关的单词。
我们可以对 Twitter 数据应用许多数据清理选项。对于这个应用程序,我们执行简单的清理。另外,我们会过滤掉某些推文。
文本到小写字母的转换很容易实现,如下所示:
public TweetHandler toLowerCase() {
this.text = this.text.toLowerCase().trim();
return this;
}
这个过程的一部分是删除某些不需要的推文。例如,下面的代码说明了如何检测推文是否是英文的,以及它是否包含用户感兴趣的子主题。Java 8 流中的filter
方法使用了boolean
返回值,它执行实际的删除:
public boolean isEnglish() {
return this.language.equalsIgnoreCase("en");
}
public boolean containsCharacter(String character) {
return this.text.contains(character);
}
许多其他清理操作可以很容易地添加到该过程中,如删除前导和尾随空白,替换制表符,以及验证日期和电子邮件地址。
删除停用词
停用词是那些无助于理解或处理数据的词。典型的停用词包括 0、and、a 和 or。当它们对数据处理没有贡献时,它们可以被移除以简化处理并使其更有效。
有几种去除停用词的技巧,在第 9 章、文本分析中讨论。对于这个应用程序,我们将使用 LingPipe(http://alias-i.com/lingpipe/)来删除停止字。我们使用EnglishStopTokenizerFactory
类来获得基于IndoEuropeanTokenizerFactory
实例的停用词模型:
public TweetHandler removeStopWords() {
TokenizerFactory tokenizerFactory
= IndoEuropeanTokenizerFactory.INSTANCE;
tokenizerFactory =
new EnglishStopTokenizerFactory(tokenizerFactory);
...
return this;
}
提取一系列不包含停用词的标记,并使用一个StringBuilder
实例创建一个字符串来替换原始文本:
Tokenizer tokens = tokenizerFactory.tokenizer(
this.text.toCharArray(), 0, this.text.length());
StringBuilder buffer = new StringBuilder();
for (String word : tokens) {
buffer.append(word + " ");
}
this.text = buffer.toString();
我们使用的 LingPipe 模型可能不是最适合所有推文的。此外,有人提出,从推文中删除停用词可能不会有成效(http://oro.open.ac.uk/40666/)。可以将选择各种停用词以及是否应该删除停用词的选项添加到流过程中。
执行情感分析
现在,我们可以使用本章“构建情感模型”一节中构建的模型来执行情感分析。我们通过将清理后的文本传递给classify
方法来创建一个新的Classification
对象。然后我们使用bestCategory
方法将我们的文本分为正面或负面。最后,我们将category
设置为结果,并返回TweetHandler
对象:
public TweetHandler performSentimentAnalysis() {
Classification classification =
classifier.classify(this.text);
String bestCategory = classification.bestCategory();
this.category = bestCategory;
return this;
}
我们现在准备分析我们的应用程序的结果。
分析结果
这个应用程序中执行的分析相当简单。一旦推文被分类为正面或负面,总数就会被计算出来。为此,我们使用了两个静态变量:
private static int numberOfPositiveReviews = 0;
private static int numberOfNegativeReviews = 0;
从 Java 8 流中调用computeStats
方法,并增加适当的变量:
public void computeStats() {
if(this.category.equalsIgnoreCase("pos")) {
numberOfPositiveReviews++;
} else {
numberOfNegativeReviews++;
}
}
两种static
方法提供对评论数量的访问:
public static int getNumberOfPositiveReviews() {
return numberOfPositiveReviews;
}
public static int getNumberOfNegativeReviews() {
return numberOfNegativeReviews;
}
此外,还提供了一个简单的toString
方法来显示基本的 tweet 信息:
public String toString() {
return "\nText: " + this.text
+ "\nDate: " + this.date
+ "\nCategory: " + this.category;
}
可以根据需要添加更复杂的分析。这个应用程序的目的是演示一种组合各种数据处理任务的技术。
其他可选配件
可以对应用程序进行许多改进。其中许多是用户偏好,其他的与改进应用程序的结果有关。GUI 界面在许多情况下都很有用。在用户选项中,我们可能希望添加对以下内容的支持:
- 显示个人推文
- 允许空的子主题
- 处理其他 tweet 字段
- 提供主题或子主题列表,供用户选择
- 生成附加统计数据和支持图表
关于过程结果的改进,应考虑以下几点:
- 纠正拼写错误的用户条目
- 删除标点符号周围的空格
- 使用替代的停用字词删除技术
- 使用替代的情感分析技术
这些增强的细节取决于所使用的 GUI 界面以及应用程序的目的和范围。
总结
本章旨在说明如何将各种数据科学任务集成到应用程序中。我们选择了一个处理推文的应用程序,因为它是一个流行的社交媒体,允许我们应用前面章节中讨论的许多技术。
使用了一个简单的基于控制台的界面,以避免特定但可能不相关的 GUI 细节扰乱讨论。应用程序提示用户输入 Twitter 主题、子主题和要处理的 tweets 数量。分析包括确定推文的情绪,以及关于推文积极或消极性质的简单统计。
这个过程的第一步是建立一个情感模型。我们使用 LingPipe 类来构建模型并执行分析。使用了 Java 8 流,它支持流畅的编程风格,可以轻松地添加和删除各个处理步骤。
一旦流被创建,JSON 原始文本被处理并用于初始化一个TweetHandler
类。这个类的实例随后被修改,包括将文本转换成小写,删除非英语 tweet,删除停用词,以及只选择包含子主题的 tweet。然后进行情感分析,接着进行统计数据的计算。
数据科学是一个广泛的主题,利用了大量的统计和计算机科学主题。在本书中,我们简要介绍了这些主题,以及 Java 是如何支持它们的。