Spark-深度学习实用指南-全-
Spark 深度学习实用指南(全)
原文:
annas-archive.org/md5/ddd311fd2802b8b875714761e8af3c7e
译者:飞龙
前言
深度学习是机器学习的一个子集,基于多层神经网络,能够解决自然语言处理、图像分类等领域中的特别难以处理的大规模问题。本书讲解了技术和分析部分的复杂性,以及如何在 Apache Spark 上实现深度学习解决方案的速度。
本书从解释 Apache Spark 和深度学习的基本原理开始(如何为深度学习设置 Spark、分布式建模的原则以及不同类型的神经网络)。然后,讲解在 Spark 上实现一些深度学习模型,如 CNN、RNN 和 LSTM。读者将通过实践体验所需的步骤,并对所面临的复杂性有一个总体的了解。在本书的过程中,将使用流行的深度学习框架,如 DeepLearning4J(主要)、Keras 和 TensorFlow,来实现和训练分布式模型。
本书的使命如下:
-
创建一本关于实现可扩展、高效的 Scala(在某些情况下也包括 Python)深度学习解决方案的实践指南
-
通过多个代码示例让读者对使用 Spark 感到自信
-
解释如何选择最适合特定深度学习问题或场景的模型
本书的目标读者
如果你是 Scala 开发人员、数据科学家或数据分析师,想要学习如何使用 Spark 实现高效的深度学习模型,那么这本书适合你。了解核心机器学习概念并具备一定的 Spark 使用经验将有所帮助。
本书内容
第一章,Apache Spark 生态系统,提供了 Apache Spark 各模块及其不同部署模式的全面概述。
第二章,深度学习基础,介绍了深度学习的基本概念。
第三章,提取、转换、加载,介绍了 DL4J 框架,并展示了来自多种来源的训练数据 ETL 示例。
第四章,流式处理,展示了使用 Spark 和 DL4J DataVec 进行数据流处理的示例。
第五章,卷积神经网络,深入探讨了 CNN 的理论及通过 DL4J 实现模型。
第六章,循环神经网络,深入探讨了 RNN 的理论及通过 DL4J 实现模型。
第七章,在 Spark 中训练神经网络,解释了如何使用 DL4J 和 Spark 训练 CNN 和 RNN。
第八章,监控和调试神经网络训练,讲解了 DL4J 提供的在训练时监控和调整神经网络的功能。
第九章,解释神经网络输出,介绍了一些评估模型准确性的技术。
第十章,在分布式系统上部署,讲解了在配置 Spark 集群时需要考虑的一些事项,以及在 DL4J 中导入和运行预训练 Python 模型的可能性。
第十一章,NLP 基础,介绍了自然语言处理(NLP)的核心概念。
第十二章,文本分析与深度学习,涵盖了通过 DL4J、Keras 和 TensorFlow 进行 NLP 实现的一些示例。
第十三章,卷积,讨论了卷积和物体识别策略。
第十四章,图像分类,深入讲解了一个端到端图像分类 Web 应用程序的实现。
第十五章,深度学习的未来,尝试概述未来深度学习的前景。
为了最大程度地利用本书
本书的实践部分需要具备 Scala 编程语言的基础知识。具备机器学习的基本知识也有助于更好地理解深度学习的理论。对于 Apache Spark 的初步知识或经验并不是必要的,因为第一章涵盖了关于 Spark 生态系统的所有内容。只有在理解可以在 DL4J 中导入的 Keras 和 TensorFlow 模型时,需要对 Python 有较好的了解。
为了构建和执行本书中的代码示例,需要 Scala 2.11.x、Java 8、Apache Maven 和你选择的 IDE。
下载示例代码文件
你可以通过你的账户在www.packt.com下载本书的示例代码文件。如果你在其他地方购买了本书,你可以访问www.packt.com/support并注册,代码文件将通过邮件直接发送给你。
你可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入书名,并按照屏幕上的指示操作。
下载文件后,请确保使用最新版本的以下工具解压或提取文件:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Deep-Learning-with-Apache-Spark
。如果代码有更新,将会在现有的 GitHub 仓库中进行更新。
我们还有来自丰富书籍和视频目录的其他代码包,您可以访问 github.com/PacktPublishing/
。快去看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色版本。您可以在此下载:www.packtpub.com/sites/default/files/downloads/9781788994613_ColorImages.pdf
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。示例如下:“将下载的 WebStorm-10*.dmg
磁盘镜像文件挂载为系统中的另一个磁盘。”
一段代码的设置如下:
val spark = SparkSession
.builder
.appName("StructuredNetworkWordCount")
.master(master)
.getOrCreate()
当我们希望引起您对某一代码块中特定部分的注意时,相关的行或项会以粗体显示:
-------------------------------------------
Time: 1527457655000 ms
-------------------------------------------
(consumer,1)
(Yet,1)
(another,1)
(message,2)
(for,1)
(the,1)
任何命令行输入或输出如下所示:
$KAFKA_HOME/bin/kafka-server-start.sh $KAFKA_HOME/config/server.properties
粗体:表示一个新术语、重要单词或您在屏幕上看到的词汇。例如,菜单或对话框中的单词会像这样出现在文本中。示例如下:“从管理面板中选择系统信息。”
警告或重要提示以这种方式出现。
提示和技巧以这种方式出现。
与我们联系
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在您的邮件主题中注明书名,并通过customercare@packtpub.com
联系我们。
勘误:虽然我们已经尽力确保内容的准确性,但错误还是会发生。如果您在本书中发现错误,我们将不胜感激,恳请您向我们报告。请访问 www.packt.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并填写相关信息。
盗版:如果您在互联网上遇到任何非法的我们作品的复制品,无论其形式如何,我们将不胜感激,若您能提供具体位置或网站名称。请通过copyright@packt.com
与我们联系,并附上相关链接。
如果您有兴趣成为作者:如果您在某个领域有专长并且有兴趣撰写或贡献一本书,请访问 authors.packtpub.com。
评论
请留下评论。阅读并使用本书后,为什么不在您购买书籍的网站上留下评价呢?潜在读者可以看到并利用您的公正意见来做出购买决策,Packt 可以了解您对我们产品的看法,作者也能看到您对他们书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packt.com。
第一章:Apache Spark 生态系统
Apache Spark (spark.apache.org/
) 是一个开源的快速集群计算平台。最初由加利福尼亚大学伯克利分校的 AMPLab 创建,后来其源代码被捐赠给了 Apache 软件基金会 (www.apache.org/
)。Spark 因其计算速度非常快而广受欢迎,因为数据被加载到分布式内存(RAM)中,分布在集群中的各台机器上。数据不仅可以快速转换,还可以根据需要进行缓存,适用于多种用例。与 Hadoop MapReduce 相比,当数据能够放入内存时,Spark 的程序运行速度快达 100 倍,或者在磁盘上快 10 倍。Spark 支持四种编程语言:Java、Scala、Python 和 R。本书仅涵盖 Scala (www.scala-lang.org/
) 和 Python (www.python.org/
) 的 Spark API(以及深度学习框架)。
本章将涵盖以下主题:
-
Apache Spark 基础知识
-
获取 Spark
-
Resilient Distributed Dataset(RDD)编程
-
Spark SQL、Datasets 和 DataFrames
-
Spark Streaming
-
使用不同管理器的集群模式
Apache Spark 基础知识
本节将介绍 Apache Spark 的基础知识。在进入下一个章节之前,熟悉这里呈现的概念非常重要,因为后续我们将探索可用的 API。
如本章导言所述,Spark 引擎在集群节点的分布式内存中处理数据。以下图表显示了一个典型 Spark 作业处理信息的逻辑结构:
图 1.1
Spark 以以下方式执行作业:
图 1.2
Master控制数据的分区方式,并利用数据局部性,同时跟踪所有在Slave机器上的分布式数据计算。如果某台 Slave 机器不可用,该机器上的数据会在其他可用机器上重新构建。在独立模式下,Master 是单点故障。章节中关于使用不同管理器的集群模式部分涵盖了可能的运行模式,并解释了 Spark 中的容错机制。
Spark 包含五个主要组件:
图 1.3
这些组件如下:
-
核心引擎。
-
Spark SQL:结构化数据处理模块。
-
Spark Streaming:这是对核心 Spark API 的扩展。它允许实时数据流处理。其优势包括可扩展性、高吞吐量和容错性。
-
MLib:Spark 机器学习库。
-
GraphX:图形和图并行计算算法。
Spark 可以访问存储在不同系统中的数据,如 HDFS、Cassandra、MongoDB、关系型数据库,还可以访问如 Amazon S3 和 Azure Data Lake Storage 等云存储服务。
获取 Spark
现在,让我们动手实践 Spark,以便深入了解核心 API 和库。在本书的所有章节中,我将引用 Spark 2.2.1 版本,然而,这里展示的多个示例应适用于 2.0 版本及更高版本。我会在示例仅适用于 2.2+ 版本时做出说明。
首先,你需要从官方网站下载 Spark(spark.apache.org/downloads.html
)。下载页面应该是这样的:
图 1.4
你需要安装 JDK 1.8+ 和 Python 2.7+ 或 3.4+(如果你需要使用这些语言进行开发)。Spark 2.2.1 支持 Scala 2.11。JDK 需要在你的用户路径系统变量中存在,或者你可以设置用户的 JAVA_HOME
环境变量指向 JDK 安装目录。
将下载的压缩包内容解压到任何本地目录。然后,进入 $SPARK_HOME/bin
目录。在那里,你会找到 Scala 和 Python 的交互式 Spark shell。它们是熟悉这个框架的最佳方式。在本章中,我将展示你可以通过这些 shell 运行的示例。
你可以使用以下命令运行一个 Scala shell:
$SPARK_HOME/bin/spark-shell.sh
如果没有指定参数,Spark 会假设你在本地以独立模式运行。以下是控制台的预期输出:
Spark context Web UI available at http://10.72.0.2:4040
Spark context available as 'sc' (master = local[*], app id = local-1518131682342).
Spark session available as 'spark'.
Welcome to
____ __
/ __/__ ___ _____/ /__
_\ \/ _ \/ _ `/ __/ '_/
/___/ .__/\_,_/_/ /_/\_\ version 2.2.1
/_/
Using Scala version 2.11.8 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_91)
Type in expressions to have them evaluated.
Type :help for more information.
scala>
Web 用户界面可以通过以下 URL 访问:http://<host>:4040
。
它将给你以下输出:
图 1.5
在这里,你可以查看作业和执行器的状态。
从控制台启动的输出中,你会注意到有两个内建变量,sc
和spark
是可用的。sc
表示SparkContext
(spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.SparkContext
),在 Spark < 2.0 中,这是每个应用的入口点。通过 Spark 上下文(及其专用版本),你可以从数据源获取输入数据,创建和操作 RDD(spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.rdd.RDD
),并获取 2.0 之前 Spark 的主要抽象。RDD 编程部分将详细介绍这一主题和其他操作。从 2.0 版本开始,引入了一个新的入口点SparkSession
(spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.sql.SparkSession
),以及一个新的主数据抽象——Dataset(spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.sql.Dataset
)。后续章节将介绍更多细节。SparkContext
仍然是 Spark API 的一部分,确保与不支持 Spark 会话的现有框架兼容,但项目的方向是将开发重点转向使用SparkSession
。
这是一个如何读取和操作文本文件,并使用 Spark shell 将其放入 Dataset 的例子(该例子使用的文件是 Spark 分发包中示例资源的一部分):
scala> spark.read.textFile("/usr/spark-2.2.1/examples/src/main/resources/people.txt")
res5: org.apache.spark.sql.Dataset[String] = [value: string]
结果是一个包含文件行的 Dataset 实例。你可以对这个 Dataset 进行多种操作,比如统计行数:
scala> res5.count()
res6: Long = 3
你还可以获取 Dataset 的第一行:
scala> res5.first()
res7: String = Michael, 29
在这个例子中,我们使用了本地文件系统中的路径。在这种情况下,所有工作节点都应该能从相同路径访问该文件,因此你需要将文件复制到所有工作节点,或者使用一个网络挂载的共享文件系统。
要关闭一个 shell,你可以输入以下命令:
:quit
要查看所有可用的 shell 命令列表,输入以下命令:
scala> :help
所有命令都可以缩写,例如,使用:he
代替:help
。
以下是命令列表:
命令 | 目的 |
---|---|
:edit <id>|<line> |
编辑历史记录 |
:help [command] |
打印总结或命令特定的帮助 |
:history [num] |
显示历史记录(可选的num 是要显示的命令数量) |
:h? <string> |
搜索历史记录 |
:imports [name name ...] |
显示导入历史,标识名称的来源 |
:implicits [-v] |
显示作用域中的implicits |
:javap <path|class> |
反汇编文件或类名 |
:line <id>|<line> |
将行放置在历史记录的末尾 |
:load <path> |
解释文件中的行 |
:paste [-raw] [path] |
进入粘贴模式或粘贴文件 |
:power |
启用高级用户模式 |
:quit |
退出解释器 |
:replay [options] |
重置repl 和所有先前命令上的replay |
:require <path> |
将jar 添加到类路径 |
:reset [options] |
将repl 重置为初始状态,忘记所有会话条目 |
:save <path> |
将可重放会话保存到文件中 |
:sh <command line> |
运行 Shell 命令(结果为implicitly => List[String] ) |
:settings <options> |
更新编译器选项,如果可能的话;参见reset |
:silent |
禁用或启用自动打印结果 |
:type [-v] <expr> |
显示表达式的类型而不评估它 |
:kind [-v] <expr> |
显示表达式的种类 |
:warnings |
显示最近一行的抑制警告 |
与 Scala 一样,Python 也有交互式 shell。您可以使用以下命令运行它:
$SPARK_HOME/bin/pyspark.sh
内置变量名为spark
,代表SparkSession
可用。您可以像 Scala shell 一样做同样的事情:
>>> textFileDf = spark.read.text("/usr/spark-2.2.1/examples/src/main/resources/people.txt")
>>> textFileDf.count()
3
>>> textFileDf.first()
Row(value='Michael, 29')
与 Java 和 Scala 不同,Python 更加动态,且类型不是强制的。因此,在 Python 中,DataSet
是DataSet[Row]
,但您可以称之为 DataFrame,以便与 Pandas 框架的 DataFrame 概念保持一致(pandas.pydata.org/
)。
要关闭 Python shell,您可以输入以下内容:
quit()
在 Spark 中运行代码的选择不仅限于交互式 shell。也可以实现独立的应用程序。以下是在 Scala 中读取和操作文件的示例:
import org.apache.spark.sql.SparkSession
object SimpleApp {
def main(args: Array[String]) {
val logFile = "/usr/spark-2.2.1/examples/src/main/resources/people.txt"
val spark = SparkSession.builder.master("local").appName("Simple Application").getOrCreate()
val logData = spark.read.textFile(logFile).cache()
val numAs = logData.filter(line => line.contains("a")).count()
val numBs = logData.filter(line => line.contains("b")).count()
println(s"Lines with a: $numAs, Lines with b: $numBs")
spark.stop()
}
}
应用程序应该定义一个main()
方法,而不是扩展scala.App
。注意创建SparkSession
的代码:
val spark = SparkSession.builder.master("local").appName("Simple Application").getOrCreate()
它遵循生成器工厂设计模式。
在结束程序执行之前,始终显式关闭会话:
spark.stop()
要构建应用程序,您可以使用您选择的构建工具(Maven
、sbt
或Gradle
),添加来自 Spark 2.2.1 和 Scala 2.11 的依赖项。生成 JAR 文件后,您可以使用$SPARK_HOME/bin/spark-submit
命令执行它,指定 JAR 文件名、Spark 主 URL 和一系列可选参数,包括作业名称、主类、每个执行器使用的最大内存等。
同样的独立应用也可以在 Python 中实现:
from pyspark.sql import SparkSession
logFile = "YOUR_SPARK_HOME/README.md" # Should be some file on your system
spark = SparkSession.builder().appName(appName).master(master).getOrCreate()
logData = spark.read.text(logFile).cache()
numAs = logData.filter(logData.value.contains('a')).count()
numBs = logData.filter(logData.value.contains('b')).count()
print("Lines with a: %i, lines with b: %i" % (numAs, numBs))
spark.stop()
这可以保存在.py
文件中,并通过相同的$SPARK_HOME/bin/spark-submit
命令提交以执行。
RDD 编程
通常,每个 Spark 应用程序是一个驱动程序,运行为其实现的逻辑并在集群上执行并行操作。根据前面的定义,核心 Spark 框架提供的主要抽象是 RDD。它是一个不可变的分布式数据集合,数据在集群中的机器上进行分区。对 RDD 的操作可以并行执行。
对 RDD 有两种类型的操作:
-
转换
-
行动
转换(transformation)是对 RDD 的操作,产生另一个 RDD,而 行动(action)则是触发某些计算的操作,并将结果返回给主节点,或将结果持久化到存储系统中。转换是惰性执行的——直到调用行动才会执行。这里就是 Spark 的强大之处——Spark 的主节点和驱动程序都记住了已经应用于 RDD 的转换操作,因此如果一个分区丢失(例如,一个从节点宕机),它可以很容易地在集群的其他节点上重新构建。 |
以下表格列出了 Spark 支持的常见转换:
转换 | 目的 |
---|---|
map(func) |
通过对源 RDD 的每个数据元素应用 func 函数,返回一个新的 RDD。 |
filter(func) |
通过选择那些 func 函数返回 true 的数据元素,返回一个新的 RDD。 |
flatMap(func) |
这个转换类似于 map :不同之处在于,每个输入项可以映射到零个或多个输出项(应用的 func 函数应该返回一个 Seq )。 |
union(otherRdd) |
返回一个新的 RDD,包含源 RDD 和 otherRdd 参数中元素的并集。 |
distinct([numPartitions]) |
返回一个新的 RDD,仅包含源 RDD 中的唯一元素。 |
groupByKey([numPartitions]) |
当对一个包含 (K, V) 对的 RDD 调用时,它返回一个 (K, IterablenumPartitions 参数来设置不同的分区数。 |
reduceByKey(func, [numPartitions]) |
当对一个包含 (K, V) 对的 RDD 调用时,它返回一个 (K, V) 对的 RDD,其中每个键的值通过给定的 func 函数进行聚合,该函数的类型必须为 (V, V) => V。与 groupByKey 转换相同,reduce 操作的分区数可以通过可选的 numPartitions 第二个参数进行配置。 |
sortByKey([ascending], [numPartitions]) |
当对一个包含 (K, V) 对的 RDD 调用时,它返回一个按键排序的 (K, V) 对的 RDD,排序顺序根据布尔值 ascending 参数来指定(升序或降序)。输出 RDD 的分区数量可以通过可选的 numPartitions 第二个参数进行配置。 |
join(otherRdd, [numPartitions]) |
当应用于类型为 (K, V) 和 (K, W) 的 RDD 时,它返回一个 (K, (V, W)) 对的 RDD,为每个键提供所有元素对。它支持左外连接、右外连接和全外连接。输出 RDD 的分区数量可以通过可选的 numPartitions 参数进行配置。 |
以下表格列出了 Spark 支持的一些常见操作:
操作 | 目的 |
---|---|
reduce(func) |
使用给定函数 func 聚合 RDD 的元素(此函数接受两个参数并返回一个结果)。为了确保计算时的正确并行性,reduce 函数 func 必须是交换律和结合律成立的。 |
collect() |
返回 RDD 中所有元素作为一个数组传递给驱动程序。 |
count() |
返回 RDD 中元素的总数。 |
first() |
返回 RDD 中的第一个元素。 |
take(n) |
返回一个包含 RDD 中前 n 个元素的数组。 |
foreach(func) |
对 RDD 中的每个元素执行 func 函数。 |
saveAsTextFile(path) |
将 RDD 的元素以文本文件的形式写入指定目录(通过 path 参数指定绝对路径),支持本地文件系统、HDFS 或其他 Hadoop 支持的文件系统。此功能仅适用于 Scala 和 Java。 |
countByKey() |
这个操作仅适用于类型为 (K, V) 的 RDD——它返回一个 (K, Int) 对的哈希映射,其中 K 是源 RDD 的键,值是该键 K 的计数。 |
现在,让我们通过一个示例理解转换和操作的概念,该示例可以在 Scala shell 中执行——它找到输入文本文件中最常用的 N 个单词。以下图示展示了这一问题的潜在实现:
图 1.6
让我们将其转换为代码。
首先,让我们将文本文件的内容加载到一个字符串类型的 RDD 中:
scala> val spiderman = sc.textFile("/usr/spark-2.2.1/tests/spiderman.txt")
spiderman: org.apache.spark.rdd.RDD[String] = /usr/spark-2.2.1/tests/spiderman.txt MapPartitionsRDD[1] at textFile at <console>:24
然后,我们将应用必要的转换和操作:
scala> val topWordCount = spiderman.flatMap(str=>str.split(" ")).filter(!_.isEmpty).map(word=>(word,1)).reduceByKey(_+_).map{case(word, count) => (count, word)}.sortByKey(false)
topWordCount: org.apache.spark.rdd.RDD[(Int, String)] = ShuffledRDD[9] at sortByKey at <console>:26
这里,我们有以下内容:
-
flatMap(str=>str.split(" "))
: 将每一行拆分为单个单词 -
filter(!_.isEmpty)
: 移除空字符串 -
map(word=>(word,1))
: 将每个单词映射为键值对 -
reduceByKey(_+_)
: 聚合计数 -
map{case(word, count) => (count, word)}
: 反转(word, count)
对为(count, word)
-
sortByKey(false)
: 按降序排序
最后,将输入内容中使用频率最高的五个单词打印到控制台:
scala> topWordCount.take(5).foreach(x=>println(x))
(34,the)
(28,and)
(19,of)
(19,in)
(16,Spider-Man)
相同的功能也可以在 Python 中通过以下方式实现:
from operator import add
spiderman = spark.read.text("/usr/spark-2.2.1/tests/spiderman.txt")
lines = spiderman.rdd.map(lambda r: r[0])
counts = lines.flatMap(lambda x: x.split(' ')) \
.map(lambda x: (x, 1)) \
.reduceByKey(add) \
.map(lambda x: (x[1],x[0])) \
.sortByKey(False)
结果当然与 Scala 示例相同:
>> counts.take(5)
[(34, 'the'), (28, 'and'), (19, 'in'), (19, 'of'), (16, 'Spider-Man')]
Spark 可以在执行操作时将 RDD(以及数据集)持久化到内存中。在 Spark 中,持久化和缓存是同义词。持久化 RDD 时,集群中的每个节点会将需要计算的 RDD 分区存储在内存中,并在对相同的 RDD(或通过一些转换从其派生的 RDD)进行进一步操作时重用这些分区。这就是为什么后续操作执行得更快的原因。可以通过调用 RDD 的 persist()
方法来标记一个 RDD 进行持久化。当第一次对它执行操作时,它会被保存在集群节点的内存中。Spark 缓存是容错的——这意味着,如果由于某种原因丢失了 RDD 的所有分区,它将通过创建它的转换重新计算这些分区。持久化的 RDD 可以使用不同的存储级别来存储。可以通过将一个 StorageLevel
对象传递给 RDD 的 persist()
方法来设置级别。以下表格列出了所有可用的存储级别及其含义:
存储级别 | 用途 |
---|---|
MEMORY_ONLY |
这是默认的存储级别。它将 RDD 存储为反序列化的 Java 对象在内存中。在 RDD 无法完全放入内存的情况下,一些分区不会被缓存,需要时会动态重新计算。 |
MEMORY_AND_DISK |
它首先将 RDD 存储为反序列化的 Java 对象在内存中,但当 RDD 无法完全放入内存时,它会将部分分区存储在磁盘上(这是与 MEMORY_ONLY 之间的主要区别),并在需要时从磁盘中读取。 |
MEMORY_ONLY_SER |
它将 RDD 存储为序列化的 Java 对象。与 MEMORY_ONLY 相比,这种方式更节省空间,但在读取操作时 CPU 占用更高。仅适用于 JVM 语言。 |
MEMORY_AND_DISK_SER |
类似于 MEMORY_ONLY_SER (将 RDD 存储为序列化的 Java 对象),主要区别在于,对于无法完全放入内存的分区,它将其存储到磁盘中。仅适用于 JVM 语言。 |
DISK_ONLY |
仅将 RDD 分区存储在磁盘中。 |
MEMORY_ONLY_2 、MEMORY_AND_DISK_2 等 |
与前两种级别(MEMORY_ONLY 和 MEMORY_AND_DISK )相同,但每个分区会在两个集群节点上复制。 |
OFF_HEAP |
类似于 MEMORY_ONLY_SER ,但它将数据存储在堆外内存中(假设启用了堆外内存)。使用此存储级别时需要小心,因为它仍处于实验阶段。 |
当一个函数被传递给 Spark 操作时,它会在远程集群节点上执行,该节点将处理函数中使用的所有变量的单独副本。执行完成后,这些变量将被复制到每台机器上。当变量传回驱动程序时,远程机器上的变量不会被更新。支持跨任务的一般读写共享变量是低效的。
然而,在 Spark 中有两种有限类型的共享变量可供使用,适用于两种常见的使用模式——广播变量和累加器。
Spark 编程中最常见的操作之一是对 RDD 执行连接操作,根据给定的键整合数据。在这些情况下,可能会有大量的数据集被发送到执行分区的从属节点进行连接。可以很容易地理解,这种情况会导致巨大的性能瓶颈,因为网络 I/O 的速度比内存访问慢 100 倍。为了解决这个问题,Spark 提供了广播变量,可以将其广播到从属节点。节点上的 RDD 操作可以快速访问广播变量的值。Spark 还尝试使用高效的广播算法来分发广播变量,以减少通信开销。广播变量是通过调用SparkContext.broadcast(v)
方法从变量v创建的。广播变量是v的一个封装,其值可以通过调用value
方法获取。以下是一个可以通过 Spark shell 运行的 Scala 示例:
scala> val broadcastVar = sc.broadcast(Array(1, 2, 3))
broadcastVar: org.apache.spark.broadcast.Broadcast[Array[Int]] = Broadcast(0)
scala> broadcastVar.value
res0: Array[Int] = Array(1, 2, 3)
在创建后,广播变量broadcastVar
可以在集群中执行的任何函数中使用,但初始值v除外,因为这可以防止v被多次发送到所有节点。为了确保所有节点都能获得相同的广播变量值,v在broadcastVar
广播后不能被修改。
下面是相同示例的 Python 代码:
>>> broadcastVar = sc.broadcast([1, 2, 3])
<pyspark.broadcast.Broadcast object at 0x102789f10>
>>> broadcastVar.value
[1, 2, 3]
为了在 Spark 集群的执行器之间聚合信息,应该使用accumulator
变量。由于它们通过一个结合性和交换性的操作进行添加,因此可以有效支持并行计算。Spark 本地支持数值类型的累加器——可以通过调用SparkContext.longAccumulator()
(用于累加Long
类型的值)或SparkContext.doubleAccumulator()
(用于累加Double
类型的值)方法来创建它们。
然而,也可以通过编程方式为其他类型提供支持。任何在集群上运行的任务都可以使用add
方法向累加器添加值,但它们无法读取累加器的值——这个操作只允许驱动程序执行,它可以使用累加器的value
方法。以下是 Scala 中的代码示例:
scala> val accum = sc.longAccumulator("First Long Accumulator")
accum: org.apache.spark.util.LongAccumulator = LongAccumulator(id: 0, name: Some
(First Long Accumulator), value: 0)
scala> sc.parallelize(Array(1, 2, 3, 4)).foreach(x => accum.add(x))
[Stage 0:> (0 + 0) / 8]
scala> accum.value
res1: Long = 10
在这种情况下,已经创建了一个累加器,并给它分配了一个名称。可以创建没有名称的累加器,但具有名称的累加器将在修改该累加器的阶段的 Web UI 中显示:
图 1.7
这对于理解运行阶段的进度很有帮助。
相同的 Python 示例如下:
>>> accum = sc.accumulator(0)
>>> accum
Accumulator<id=0, value=0>
>>> sc.parallelize([1, 2, 3, 4]).foreach(lambda x: accum.add(x))
>>> accum.value
10
Python 不支持在 Web UI 中跟踪累加器。
请注意,Spark 仅在action内部更新累加器。当重新启动任务时,累加器只会更新一次。对于transformation,情况则不同。
Spark SQL、Datasets 和 DataFrames
Spark SQL 是用于结构化数据处理的 Spark 模块。这个 API 和 RDD API 的主要区别在于,提供的 Spark SQL 接口可以更好地了解数据和执行计算的结构。这些额外的信息被 Spark 内部利用,通过 Catalyst 优化引擎进行额外的优化,这个执行引擎无论使用哪种 API 或编程语言,都是相同的。
Spark SQL 通常用于执行 SQL 查询(即使这不是唯一的使用方式)。无论使用 Spark 支持的哪种编程语言来封装 SQL 代码,查询的结果都会以Dataset的形式返回。Dataset 是一个分布式数据集合,它作为接口在 Spark 1.6 中被引入。它结合了 RDD 的优势(如强类型和应用有用的 lambda 函数的能力)与 Spark SQL 优化执行引擎(Catalyst,databricks.com/blog/2015/04/13/deep-dive-into-spark-sqls-catalyst-optimizer.html
)的优势。你可以通过从 Java/Scala 对象开始并通过常规的函数式转换来操作 Dataset。Dataset API 在 Scala 和 Java 中可用,而 Python 不支持它。然而,由于 Python 语言的动态特性,很多 Dataset API 的优势在 Python 中已经可以使用。
从 Spark 2.0 开始,DataFrame 和 Dataset API 已合并为 Dataset API,因此DataFrame只是一个已经被组织成命名列的 Dataset,在概念上等同于 RDBMS 中的表,但其底层优化更佳(作为 Dataset API 的一部分,Catalyst 优化引擎也在幕后为 DataFrame 工作)。你可以从不同的数据源构建 DataFrame,例如结构化数据文件、Hive 表、数据库表和 RDD 等。与 Dataset API 不同,DataFrame API 可以在任何 Spark 支持的编程语言中使用。
让我们开始动手实践,以便更好地理解 Spark SQL 背后的概念。我将展示的第一个完整示例是基于 Scala 的。启动一个 Scala Spark shell,以交互方式运行以下代码。
让我们使用people.json
作为数据源。作为此示例的资源之一,包含在 Spark 分发包中的文件可用于创建一个 DataFrame,这是一个行的 Dataset(spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.sql.Row
):
val df = spark.read.json("/opt/spark/spark-2.2.1-bin-hadoop2.7/examples/src/main/resources/people.json")
你可以将 DataFrame 的内容打印到控制台,检查它是否符合你的预期:
scala> df.show()
+----+-------+
| age| name|
+----+-------+
|null|Michael|
| 30| Andy|
| 19| Justin|
+----+-------+
在执行 DataFrame 操作之前,你需要导入隐式转换(例如将 RDD 转换为 DataFrame),并使用$
符号:
import spark.implicits._
现在,你可以以树状格式打印 DataFrame 模式:
scala> df.printSchema()
root
|-- age: long (nullable = true)
|-- name: string (nullable = true)
选择单一列(例如name
):
scala> df.select("name").show()
+-------+
| name|
+-------+
|Michael|
| Andy|
| Justin|
+-------+
过滤数据:
scala> df.filter($"age" > 27).show()
+---+----+
|age|name|
+---+----+
| 30|Andy|
+---+----+
然后添加一个groupBy
子句:
scala> df.groupBy("age").count().show()
+----+-----+
| age|count|
+----+-----+
| 19| 1|
|null| 1|
| 30| 1|
+----+-----+
选择所有行并递增一个数字字段:
scala> df.select($"name", $"age" + 1).show()
+-------+---------+
| name|(age + 1)|
+-------+---------+
|Michael| null|
| Andy| 31|
| Justin| 20|
+-------+---------+
你可以通过SparkSession
的sql
函数以编程方式运行 SQL 查询。该函数返回查询结果的 DataFrame,在 Scala 中是Dataset[Row]
。让我们考虑与前面示例相同的 DataFrame:
val df = spark.read.json("/opt/spark/spark-2.2.1-bin-hadoop2.7/examples/src/main/resources/people.json")
你可以将其注册为 SQL 临时视图:
df.createOrReplaceTempView("people")
然后,你可以在此处执行 SQL 查询:
scala> val sqlDF = spark.sql("SELECT * FROM people")
sqlDF: org.apache.spark.sql.DataFrame = [age: bigint, name: string]
scala> sqlDF.show()
+----+-------+
| age| name|
+----+-------+
|null|Michael|
| 30| Andy|
| 19| Justin|
+----+-------+
在 Python 中也可以执行相同的操作:
>>> df = spark.read.json("/opt/spark/spark-2.2.1-bin-hadoop2.7/examples/src/main/resources/people.json")
结果如下:
>> df.show()
+----+-------+
| age| name|
+----+-------+
|null|Michael|
| 30| Andy|
| 19| Justin|
+----+-------+
>>> df.printSchema()
root
|-- age: long (nullable = true)
|-- name: string (nullable = true)
>>> df.select("name").show()
+-------+
| name|
+-------+
|Michael|
| Andy|
| Justin|
+-------+
>>> df.filter(df['age'] > 21).show()
+---+----+
|age|name|
+---+----+
| 30|Andy|
+---+----+
>>> df.groupBy("age").count().show()
+----+-----+
| age|count|
+----+-----+
| 19| 1|
|null| 1|
| 30| 1|
+----+-----+
>>> df.select(df['name'], df['age'] + 1).show()
+-------+---------+
| name|(age + 1)|
+-------+---------+
|Michael| null|
| Andy| 31|
| Justin| 20|
+-------+---------+
>>> df.createOrReplaceTempView("people")
>>> sqlDF = spark.sql("SELECT * FROM people")
>>> sqlDF.show()
+----+-------+
| age| name|
+----+-------+
|null|Michael|
| 30| Andy|
| 19| Justin|
+----+-------+
Spark SQL 和 Datasets 的其他功能(数据源、聚合、自包含应用等)将在第三章中介绍,提取、转换、加载。
Spark Streaming
Spark Streaming 是另一个 Spark 模块,扩展了核心 Spark API,并提供了一种可扩展、容错和高效的方式来处理实时流数据。通过将流数据转换为微批次,Spark 的简单批处理编程模型也可以应用于流式用例中。这个统一的编程模型使得将批处理和交互式数据处理与流式处理相结合变得容易。支持多种数据摄取源(例如 Kafka、Kinesis、TCP 套接字、S3 或 HDFS 等流行源),并且可以使用 Spark 中任何高级函数来处理从这些源获取的数据。最终,处理过的数据可以持久化到关系数据库、NoSQL 数据库、HDFS、对象存储系统等,或通过实时仪表板进行消费。没有什么可以阻止其他高级 Spark 组件(如 MLlib 或 GraphX)应用于数据流:
图 1.8
以下图表展示了 Spark Streaming 的内部工作原理——它接收实时输入数据流并将其划分为批次;这些批次由 Spark 引擎处理,生成最终的批量结果:
图 1.9
Spark Streaming 的高级抽象是DStream(Discretized Stream的简称),它是对连续数据流的封装。从内部来看,DStream 是作为一系列 RDD 的序列来表示的。DStream 包含它依赖的其他 DStream 的列表、将输入 RDD 转换为输出 RDD 的函数,以及调用该函数的时间间隔。DStream 可以通过操作现有的 DStream 来创建,例如应用 map 或 filter 函数(这分别内部创建了MappedDStreams
和FilteredDStreams
),或者通过从外部源读取数据(在这些情况下,基类是InputDStream
)。
让我们实现一个简单的 Scala 示例——一个流式单词计数自包含应用程序。此类所用的代码可以在与 Spark 发行版一起捆绑的示例中找到。要编译和打包它,你需要将 Spark Streaming 的依赖项添加到你的Maven
、Gradle
或sbt
项目描述文件中,还需要添加来自 Spark Core 和 Scala 的依赖项。
首先,我们需要从SparkConf
和StreamingContext
(它是任何流式功能的主要入口点)开始创建:
import org.apache.spark.SparkConf
import org.apache.spark.streaming.{Seconds, StreamingContext}
val sparkConf = new SparkConf().setAppName("NetworkWordCount").setMaster("local[*]")
val ssc = new StreamingContext(sparkConf, Seconds(1))
批处理间隔已设置为 1 秒。表示来自 TCP 源的流数据的 DStream 可以通过ssc
流上下文创建;我们只需要指定源的主机名和端口,以及所需的存储级别:
val lines = ssc.socketTextStream(args(0), args(1).toInt, StorageLevel.MEMORY_AND_DISK_SER)
返回的lines
DStream 是将从服务器接收到的数据流。每条记录将是我们希望分割为单个单词的单行文本,从而指定空格字符作为分隔符:
val words = lines.flatMap(_.split(" "))
然后,我们将对这些单词进行计数:
val words = lines.flatMap(_.split(" "))
val wordCounts = words.map(x => (x, 1)).reduceByKey(_ + _)
wordCounts.print()
words
DStream 被映射(一个一对一的转换)为一个(word, 1)对的 DStream,随后通过减少操作得到每批数据中单词的频率。最后的命令会每秒打印生成的计数。DStream 中的每个 RDD 包含来自某个时间间隔的数据——对 DStream 应用的任何操作都转换为对底层 RDD 的操作:
图 1.10
在设置好所有转换后,使用以下代码开始处理:
ssc.start()
ssc.awaitTermination()
在运行此示例之前,首先需要运行netcat
(一个在大多数类 Unix 系统中找到的小工具)作为数据服务器:
nc -lk 9999
然后,在另一个终端中,你可以通过传递以下参数来启动示例:
localhost 9999
任何在终端中输入并通过netcat
服务器运行的行都会被计数,并且每秒在应用程序屏幕上打印一次。
无论nc
是否在运行此示例的系统中不可用,你都可以在 Scala 中实现自己的数据服务器:
import java.io.DataOutputStream
import java.net.{ServerSocket, Socket}
import java.util.Scanner
object SocketWriter {
def main(args: Array[String]) {
val listener = new ServerSocket(9999)
val socket = listener.accept()
val outputStream = new DataOutputStream(socket.getOutputStream())
System.out.println("Start writing data. Enter close when finish");
val sc = new Scanner(System.in)
var str = ""
/**
* Read content from scanner and write to socket.
*/
while (!(str = sc.nextLine()).equals("close")) {
outputStream.writeUTF(str);
}
//close connection now.
outputStream.close()
listener.close()
}
}
相同的自包含应用程序在 Python 中可能如下所示:
from __future__ import print_function
import sys
from pyspark import SparkContext
from pyspark.streaming import StreamingContext
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: network_wordcount.py <hostname> <port>", file=sys.stderr)
exit(-1)
sc = SparkContext(appName="PythonStreamingNetworkWordCount")
ssc = StreamingContext(sc, 1)
lines = ssc.socketTextStream(sys.argv[1], int(sys.argv[2]))
counts = lines.flatMap(lambda line: line.split(" "))\
.map(lambda word: (word, 1))\
.reduceByKey(lambda a, b: a+b)
counts.pprint()
ssc.start()
ssc.awaitTermination()
DStreams 支持 RDD 大部分可用的转换功能。这意味着输入 DStream 中的数据可以像 RDD 中的数据一样被修改。下表列出了 Spark DStreams 支持的一些常见转换:
转换 | 用途 |
---|---|
map(func) |
返回一个新的 DStream。func 映射函数应用于源 DStream 的每个元素。 |
flatMap(func) |
与map 相同。唯一的区别是新 DStream 中的每个输入项可以映射到 0 个或多个输出项。 |
filter(func) |
返回一个新的 DStream,只包含源 DStream 中func 过滤函数返回 true 的元素。 |
repartition(numPartitions) |
用于通过创建不同数量的分区来设置并行度。 |
union(otherStream) |
返回一个新的 DStream。它包含源 DStream 和输入的 otherDStream DStream 中元素的并集。 |
count() |
返回一个新的 DStream。它包含通过计算每个 RDD 中元素的数量而得到的单一元素 RDD。 |
reduce(func) |
返回一个新的 DStream。它包含通过应用 func 函数(该函数应该是结合性和交换性的,以便支持正确的并行计算)在源中每个 RDD 上聚合得到的单一元素 RDD。 |
countByValue() |
返回一个新的 DStream,包含 (K, Long) 对,其中 K 是源元素的类型。每个键的值表示其在源中每个 RDD 中的频率。 |
reduceByKey(func, [numTasks]) |
返回一个新的 DStream,包含 (K, V) 对(对于源 DStream 中的 (K, V) 对)。每个键的值通过应用 func 函数来进行聚合。为了进行分组,此转换使用 Spark 默认的并行任务数(在本地模式下是 2,而在集群模式下由 config 属性 spark.default.parallelism 确定),但可以通过传递可选的 numTasks 参数来更改此数值。 |
join(otherStream, [numTasks]) |
返回一个新的 DStream,包含 (K, (V, W)) 对,当它分别应用于两个 DStream,其中一个包含 (K, V) 对,另一个包含 (K, W) 对时。 |
cogroup(otherStream, [numTasks]) |
返回一个新的 DStream,包含 (K, Seq[V], Seq[W]) 元组,当它分别应用于两个 DStream,其中一个包含 (K, V) 对,另一个包含 (K, W) 对时。 |
transform(func) |
返回一个新的 DStream。它将 RDD 到 RDD 的 func 函数应用于源中的每个 RDD。 |
updateStateByKey(func) |
返回一个新的状态 DStream。新 DStream 中每个键的状态通过将输入函数 func 应用于先前的状态和该键的新值来更新。 |
窗口计算由 Spark Streaming 提供。如以下图所示,它们允许你在滑动数据窗口上应用转换:
图 1.11
当一个窗口在源 DStream 上滑动时,所有在该窗口内的 RDD 都会被考虑并转换,生成返回的窗口化 DStream 的 RDD。看一下上面图示的具体例子,基于窗口的操作应用于三个时间单位的数据,并且它以两为滑动步长。任何使用的窗口操作都需要指定两个参数:
-
窗口长度:窗口的持续时间
-
滑动间隔:窗口操作执行的间隔时间
这两个参数必须是源 DStream 批次间隔的倍数。
让我们看看这个方法如何应用于本节开始时介绍的应用场景。假设你想要每隔 10 秒钟生成一次过去 60 秒数据的字数统计。需要在过去 60 秒的 DStream 中对(word, 1)对应用 reduceByKey
操作。这可以通过 reduceByKeyAndWindow
操作来实现。转换为 Scala 代码如下:
val windowedWordCounts = pairs.reduceByKeyAndWindow((a:Int,b:Int) => (a + b), Seconds(60), Seconds(10))
对于 Python,代码如下:
windowedWordCounts = pairs.reduceByKeyAndWindow(lambda x, y: x + y, lambda x, y: x - y, 60, 10)
以下表列出了 Spark 为 DStreams 支持的一些常见窗口操作:
转换 | 目的 |
---|---|
window(windowLength, slideInterval) |
返回一个新的 DStream。它基于源数据的窗口化批次。 |
countByWindow(windowLength, slideInterval) |
返回源 DStream 中元素的滑动窗口计数(基于 windowLength 和 slideInterval 参数)。 |
reduceByWindow(func, windowLength, slideInterval) |
返回一个新的单元素 DStream。它是通过在滑动时间间隔内聚合源 DStream 中的元素,并应用 func 减少函数来创建的(为了支持正确的并行计算,func 必须是结合性和交换性的)。 |
reduceByKeyAndWindow(func, windowLength, slideInterval, [numTasks]) |
返回一个新的由(K, V)对组成的 DStream(与源 DStream 相同的 K 和 V)。每个键的值通过在滑动窗口中对批次(由 windowLength 和 slideInterval 参数定义)应用 func 输入函数来聚合。并行任务的数量在本地模式下为 2(默认),而在集群模式下由 Spark 配置属性 spark.default.parallelism.numTask 给出,这是一个可选参数,用于指定自定义任务数量。 |
reduceByKeyAndWindow(func, invFunc, windowLength, slideInterval, [numTasks]) |
这是 reduceByKeyAndWindow 转换的一个更高效版本。这次,当前窗口的减少值是通过使用前一个窗口的减少值逐步计算出来的。通过减少进入窗口的新数据,同时对离开窗口的旧数据进行逆减少,来实现这一点。请注意,这种机制只有在 func 函数有相应的逆减少函数 invFunc 时才能工作。 |
countByValueAndWindow(windowLength, slideInterval, [numTasks]) |
返回一个由(K, Long)对组成的 DStream(与源 DStream 组成的(K, V)对相同)。返回的 DStream 中每个键的值是其在给定滑动窗口内的频率(由 windowLength 和 slideInterval 参数定义)。numTask 是一个可选参数,用于指定自定义任务数量。 |
使用不同管理器的集群模式
以下图示展示了 Spark 应用程序在集群上如何运行。它们是由SparkContext
对象在Driver Program中协调的独立进程集。SparkContext
连接到Cluster Manager,后者负责在各个应用程序之间分配资源。一旦SparkContext连接,Spark 将在集群节点上获取执行器。
执行器是执行计算并存储给定 Spark 应用程序数据的进程。SparkContext将应用程序代码(Scala 的 JAR 文件或 Python 的.py 文件)发送到执行器。最后,它将运行任务发送到执行器:
图 1.12
为了将不同的应用程序彼此隔离,每个 Spark 应用程序都会获得自己的执行进程。这些进程会在整个应用程序的运行期间保持活跃,并以多线程模式运行任务。缺点是无法在不同的 Spark 应用程序之间共享数据——为了共享数据,数据需要持久化到外部存储系统。
Spark 支持不同的集群管理器,但它与底层类型无关。
执行时,驱动程序必须能从工作节点的网络地址访问,因为它必须监听并接受来自执行器的连接请求。由于它负责在集群上调度任务,因此应尽量将其执行在接近工作节点的地方,即在同一个局域网中(如果可能)。
以下是当前在 Spark 中支持的集群管理器:
-
Standalone:一种简单的集群管理器,便于设置集群。它包含在 Spark 中。
-
Apache Mesos:一个开源项目,用于管理计算机集群,开发于加利福尼亚大学伯克利分校。
-
Hadoop YARN:从 Hadoop 2 版本开始提供的资源管理器。
-
Kubernetes:一个开源平台,提供面向容器的基础设施。Spark 中的 Kubernetes 支持仍处于实验阶段,因此可能尚未准备好用于生产环境。
独立模式
对于独立模式,您只需要将编译版本的 Spark 放置在集群的每个节点上。所有集群节点需要能够解析其他集群成员的主机名,并且能够相互路由。Spark 主节点的 URL 可以在所有节点的$SPARK_HOME/conf/spark-defaults.conf
文件中进行配置:
spark.master spark://<master_hostname_or_IP>:7077
然后,需要在所有节点的$SPARK_HOME/conf/spark-env.sh
文件中指定 Spark 主节点的主机名或 IP 地址,如下所示:
SPARK_MASTER_HOST, <master_hostname_or_IP>
现在,可以通过执行以下脚本启动一个独立的主服务器:
$SPARK_HOME/sbin/start-master.sh
一旦主节点完成,Web UI 将可以通过http://<master_hostname_or_IP>:8080
URL 访问。从这里可以获得用于启动工作节点的主节点 URL。现在可以通过执行以下脚本启动一个或多个工作节点:
$SPARK_HOME/sbin/start-slave.sh <master-spark-URL>
每个工作节点启动后都会有自己的 Web UI,其 URL 为 http://<worker_hostname_or_IP>:8081
。
工作节点的列表,以及它们的 CPU 数量和内存等信息,可以在主节点的 Web UI 中找到。
这样做的方法是手动运行独立集群。也可以使用提供的启动脚本。需要创建一个 $SPARK_HOME/conf/slaves
文件作为初步步骤。该文件必须包含所有要启动 Spark 工作节点的机器的主机名,每行一个。在 Spark 主节点与 Spark 从节点之间需要启用无密码SSH(即安全外壳)以允许远程登录,从而启动和停止从节点守护进程。然后,可以使用以下 shell 脚本启动或停止集群,这些脚本位于 $SPARK_HOME/sbin
目录中:
-
start-master.sh
:启动一个主节点实例 -
start-slaves.sh
:在conf/slaves
文件中指定的每台机器上启动一个从节点实例 -
start-slave.sh
:启动单个从节点实例 -
start-all.sh
:同时启动主节点和多个从节点 -
stop-master.sh
:停止通过sbin/start-master.sh
脚本启动的主节点 -
stop-slaves.sh
:停止conf/slaves
文件中指定节点上的所有从节点实例 -
stop-all.sh
:停止主节点及其从节点
这些脚本必须在将运行 Spark 主节点的机器上执行。
可以通过以下方式运行一个交互式 Spark shell 对集群进行操作:
$SPARK_HOME/bin/spark-shell --master <master-spark-URL>
可以使用 $SPARK_HOME/bin/spark-submit
脚本将已编译的 Spark 应用程序提交到集群。Spark 当前支持独立集群的两种部署模式:客户端模式和集群模式。在客户端模式下,驱动程序和提交应用程序的客户端在同一进程中启动,而在集群模式下,驱动程序从其中一个工作进程启动,客户端进程在提交应用程序后立即退出(无需等待应用程序完成)。
当通过 spark-submit
启动应用程序时,其 JAR 文件会自动分发到所有工作节点。应用程序依赖的任何附加 JAR 文件应通过 jars
标志指定,并使用逗号作为分隔符(例如,jars
, jar1
, jar2
)。
如在Apache Spark 基础知识一节中提到的,独立模式下,Spark 主节点是单点故障。这意味着如果 Spark 主节点宕机,Spark 集群将停止工作,所有当前提交或正在运行的应用程序将失败,并且无法提交新的应用程序。
可以使用 Apache ZooKeeper (zookeeper.apache.org/
) 来配置高可用性,ZooKeeper 是一个开源且高度可靠的分布式协调服务,或者可以通过 Mesos 或 YARN 部署为集群,这部分将在接下来的两节中讨论。
Mesos 集群模式
Spark 可以在由 Apache Mesos(mesos.apache.org/
)管理的集群上运行。Mesos 是一个跨平台、云提供商无关、集中式且容错的集群管理器,专为分布式计算环境设计。其主要特性包括资源管理和隔离,以及跨集群的 CPU 和内存调度。它可以将多个物理资源合并为单个虚拟资源,这与传统的虚拟化不同,传统虚拟化将单个物理资源分割为多个虚拟资源。使用 Mesos,可以构建或调度诸如 Apache Spark 之类的集群框架(尽管不仅限于此)。下图展示了 Mesos 的架构:
图 1.13
Mesos 由主守护程序和框架组成。主守护程序管理每个集群节点上运行的代理守护程序,而 Mesos 框架在代理上运行任务。主守护程序通过提供资源来实现对框架的细粒度资源共享(包括 CPU 和 RAM)。它根据给定的组织策略决定向每个框架提供多少可用资源。为了支持各种策略集,主使用模块化架构通过插件机制轻松添加新的分配模块。一个 Mesos 框架由两个组件组成 — 调度程序注册自己以接收主提供的资源,执行程序在代理节点上启动以执行框架的任务。尽管主决定向每个框架提供多少资源,但框架的调度程序负责选择要使用的提供的资源。框架一旦接受提供的资源,就会向 Mesos 传递它想要在这些资源上执行的任务的描述。Mesos 随后在相应的代理上启动这些任务。
使用 Mesos 部署 Spark 集群以取代 Spark Master Manager 的优势包括以下几点:
-
在 Spark 和其他框架之间进行动态分区
-
在多个 Spark 实例之间进行可扩展分区
Spark 2.2.1 设计用于与 Mesos 1.0.0+配合使用。在本节中,我不会描述部署 Mesos 集群的步骤 — 我假设 Mesos 集群已经可用并正在运行。在 Mesos 主节点的 Web UI 上,端口为5050
,验证 Mesos 集群准备好运行 Spark:
图 1.14
检查 Agents 标签中是否存在所有预期的机器。
要从 Spark 使用 Mesos,需要在 Mesos 本身可以访问的位置提供 Spark 二进制包,并且需要配置 Spark 驱动程序程序以连接到 Mesos。或者,也可以在所有 Mesos 从节点上安装 Spark,然后配置 spark.mesos.executor.home
属性(默认值为 $SPARK_HOME
)以指向该位置。
Mesos 主节点的 URL 形式为 mesos://host:5050
,对于单主节点的 Mesos 集群,或者对于使用 Zookeeper 的多主节点 Mesos 集群,形式为 mesos://zk://host1:2181,host2:2181,host3:2181/mesos
。
以下是如何在 Mesos 集群上启动 Spark shell 的示例:
$SPARK_HOME/bin/spark-shell --master mesos://127.0.0.1:5050 -c spark.mesos.executor.home=`pwd`
一个 Spark 应用程序可以按以下方式提交到 Mesos 管理的 Spark 集群:
$SPARK_HOME/bin/spark-submit --master mesos://127.0.0.1:5050 --total-executor-cores 2 --executor-memory 3G $SPARK_HOME/examples/src/main/python/pi.py 100
YARN 集群模式
YARN (hadoop.apache.org/docs/stable/hadoop-yarn/hadoop-yarn-site/YARN.html
),它是在 Apache Hadoop 2.0 中引入的,带来了在可扩展性、高可用性和对不同范式的支持方面的显著改进。在 Hadoop 版本 1 的 MapReduce 框架中,作业执行由几种类型的进程控制——一个名为 JobTracker
的单一主进程协调集群中运行的所有作业,并将 map
和 reduce
任务分配给 TaskTrackers
,这些是运行分配任务的从属进程,并定期将进度报告给 JobTracker
。拥有一个单一的 JobTracker
成为可扩展性的瓶颈。最大集群规模略超过 4000 个节点,并且并发任务数限制为 40,000。此外,JobTracker
是单点故障,并且唯一可用的编程模型是 MapReduce。
YARN 的基本思想是将资源管理和作业调度或监控的功能拆分为独立的守护进程。其思路是拥有一个全局的ResourceManager和每个应用程序的ApplicationMaster(App Mstr)。一个应用程序可以是一个单独的作业,也可以是作业的有向无环图(DAG)。以下是 YARN 架构的示意图:
图 1.15
ResourceManager 和 NodeManager 组成了 YARN 框架。ResourceManager 决定所有运行应用程序的资源使用,而 NodeManager 是运行在集群中任何机器上的代理,负责通过监控容器的资源使用(包括 CPU 和内存)并向 ResourceManager 报告。ResourceManager 由两个组件组成——调度器和 ApplicationsManager。调度器是负责分配资源给各种正在运行的应用程序的组件,但它不对应用程序状态进行监控,也不提供重启失败任务的保证。它是根据应用程序的资源需求来进行调度的。
ApplicationsManager 接受作业提交,并提供在任何故障时重新启动 App Mstr 容器的服务。每个应用程序的 App Mstr 负责与调度程序协商适当的资源容器,并监视其状态和进度。YARN 作为通用调度程序,支持用于 Hadoop 集群的非 MapReduce 作业(如 Spark 作业)。
在 YARN 上提交 Spark 应用程序
要在 YARN 上启动 Spark 应用程序,需要设置 HADOOP_CONF_DIR
或 YARN_CONF_DIR
环境变量,并指向包含 Hadoop 集群客户端配置文件的目录。这些配置用于连接到 YARN ResourceManager 和写入 HDFS。此配置分发到 YARN 集群,以便 Spark 应用程序使用的所有容器具有相同的配置。在 YARN 上启动 Spark 应用程序时,有两种部署模式可用:
-
Cluster mode:在此情况下,Spark Driver 在由 YARN 在集群上管理的应用程序主进程内运行。客户端在启动应用程序后可以完成其执行。
-
Client mode:在此情况下,Driver 和客户端在同一个进程中运行。应用程序主进程仅用于从 YARN 请求资源的目的。
与其他模式不同,在 YARN 模式中,Master 的地址是从 Hadoop 配置中检索的 ResourceManager 的地址。因此,master
参数的值始终为 yarn
。
您可以使用以下命令在集群模式下启动 Spark 应用程序:
$SPARK_HOME/bin/spark-submit --class path.to.your.Class --master yarn --deploy-mode cluster [options] <app jar> [app options]
在集群模式下,由于 Driver 运行在与客户端不同的机器上,SparkContext.addJar
方法无法使用客户端本地的文件。唯一的选择是使用 launch
命令中的 jars
选项包含它们。
在客户端模式下启动 Spark 应用程序的方法相同——deploy-mode
选项值需要从 cluster 更改为 client。
Kubernetes 集群模式
Kubernetes (kubernetes.io/
) 是一个开源系统,用于自动化部署、扩展和管理容器化应用程序。它最初由 Google 实施,于 2014 年开源。以下是 Kubernetes 的主要概念:
-
Pod:这是可以创建和管理的最小计算可部署单元。Pod 可以看作是一个或多个共享网络和存储空间的容器组,还包含如何运行这些容器的规范。
-
Deployment:这是一个抽象层,其主要目的是声明应该同时运行多少个 Pod 的副本。
-
Ingress:这是与在 Pod 中运行的服务通信的开放通道。
-
Node:这是集群中单个机器的表示。
-
持久卷:它提供一个文件系统,可以挂载到集群,而不与任何特定节点关联。这是 Kubernetes 持久化信息(数据、文件等)的方法。
以下图(来源:d33wubrfki0l68.cloudfront.net/518e18713c865fe67a5f23fc64260806d72b38f5/61d75/images/docs/post-ccm-arch.png
)展示了 Kubernetes 架构:
图 1.16
Kubernetes 架构的主要组件如下:
-
云控制器管理器:它运行 Kubernetes 控制器
-
控制器:共有四个——节点、路由、服务和 PersistentVolumeLabels
-
Kubelets:运行在节点上的主要代理
提交 Spark 作业到 Kubernetes 集群可以通过 spark-submit
直接完成。Kubernetes 要求我们提供可以部署到 pod 中容器的 Docker (www.docker.com/
) 镜像。从 2.3 版本开始,Spark 提供了一个 Dockerfile ($SPARK_HOME/kubernetes/dockerfiles/Dockerfile
,也可以根据特定应用需求进行定制) 和一个脚本 ($SPARK_HOME/bin/docker-image-tool.sh
),用于构建和发布将用于 Kubernetes 后端的 Docker 镜像。以下是通过提供的脚本构建 Docker 镜像的语法:
$SPARK_HOME/bin/docker-image-tool.sh -r <repo> -t my-tag build
以下是使用相同脚本将镜像推送到 Docker 仓库的语法:
$SPARK_HOME/bin/docker-image-tool.sh -r <repo> -t my-tag push
作业可以通过以下方式提交:
$SPARK_HOME/bin/spark-submit \
--master k8s://https://<k8s_hostname>:<k8s_port> \
--deploy-mode cluster \
--name <application-name> \
--class <package>.<ClassName> \
--conf spark.executor.instances=<instance_count> \
--conf spark.kubernetes.container.image=<spark-image> \
local:///path/to/<sparkjob>.jar
Kubernetes 要求应用程序名称仅包含小写字母数字字符、连字符和点,并且必须以字母数字字符开头和结尾。
以下图展示了提交机制的工作方式:
图 1.17
以下是发生的事情:
-
Spark 创建了一个在 Kubernetes pod 中运行的驱动程序
-
驱动程序创建执行器,执行器也运行在 Kubernetes pod 中,然后连接到它们并执行应用程序代码
-
执行结束时,执行器 pod 会终止并被清理,而驱动程序 pod 会继续保留日志,并保持完成状态(意味着它不再使用集群的计算或内存资源),在 Kubernetes API 中(直到最终被垃圾回收或手动删除)
总结
在本章中,我们熟悉了 Apache Spark 及其主要模块。我们开始使用可用的 Spark shell,并使用 Scala 和 Python 编程语言编写了第一个自包含的应用程序。最后,我们探索了在集群模式下部署和运行 Spark 的不同方法。到目前为止,我们学到的所有内容都是理解从第三章 提取、转换、加载 及之后主题所必需的。如果你对所呈现的任何主题有疑问,我建议你在继续之前回过头再阅读一遍本章。
在下一章,我们将探索深度学习(DL)的基础知识,重点介绍多层神经网络的某些具体实现。
第二章:深度学习基础
在本章中,我将介绍深度学习(DL)的核心概念,它与机器学习(ML)和人工智能(AI)的关系,各种类型的多层神经网络,以及一些现实世界中的实际应用。我将尽量避免数学公式,并保持描述的高层次,不涉及代码示例。本章的目的是让读者了解深度学习的真正含义以及它能做什么,而接下来的章节将更详细地讲解这一内容,并提供大量 Scala 和 Python 中的实际代码示例(这些编程语言可以使用)。
本章将涵盖以下主题:
-
深度学习概念
-
深度神经网络 (DNNs)
-
深度学习的实际应用
介绍深度学习
深度学习是机器学习(ML)的一个子集,可以解决特别困难和大规模的问题,应用领域包括自然语言处理 (NLP)和图像分类。DL 这个术语有时与 ML 和 AI 互换使用,但 ML 和 DL 都是 AI 的子集。AI 是更广泛的概念,它通过 ML 来实现。DL 是实现 ML 的一种方式,涉及基于神经网络的算法:
图 2.1
人工智能(AI)被认为是机器(它可以是任何计算机控制的设备或机器人)执行通常与人类相关的任务的能力。该概念于 20 世纪 50 年代提出,目的是减少人类的互动,从而让机器完成所有工作。这个概念主要应用于开发那些通常需要人类智力过程和/或从过去经验中学习的系统。
机器学习(ML)是一种实现人工智能(AI)的方法。它是计算机科学的一个领域,使计算机系统能够从数据中学习,而不需要显式编程。基本上,它使用算法在数据中寻找模式,然后使用能够识别这些模式的模型对新数据进行预测。下图展示了训练和构建模型的典型过程:
图 2.2
机器学习可以分为三种类型:
-
有监督学习算法,使用标注数据
-
无监督学习算法,从未标注数据中发现模式
-
半监督学习,使用两者的混合(标注数据和未标注数据)
截至写作时,有监督学习是最常见的机器学习算法类型。有监督学习可以分为两类——回归和分类问题。
下图展示了一个简单的回归问题:
图 2.3
如你所见,图中有两个输入(或特征),大小和价格,它们被用来生成曲线拟合线,并对房产价格进行后续预测。
以下图表展示了一个监督分类的示例:
图 2.4
数据集标记了良性(圆圈)和恶性(叉号)肿瘤,针对乳腺癌患者。一个监督分类算法通过拟合一条直线将数据分为两类。然后,基于该直线分类,未来的数据将被分类为良性或恶性。前述图表中的情况只有两个离散输出,但也有可能存在超过两种分类的情况。
在监督学习中,带标签的数据集帮助算法确定正确答案,而在无监督学习中,算法提供未标记的数据集,依赖于算法本身来发现数据中的结构和模式。在以下图表中(右侧的图表可以在 leonardoaraujosantos.gitbooks.io/artificial-inteligence/content/
Images/supervised_unsupervised.png 查看),没有提供关于每个数据点的含义信息。我们要求算法以独立于监督的方式发现数据中的结构。一个无监督学习算法可能会发现数据中有两个不同的簇,然后在它们之间进行直线分类:
图 2.5
深度学习(DL)是指多层神经网络的名称,这些网络由输入和输出之间的多个隐藏层节点组成。DL 是人工神经网络(ANNs)的细化版,模拟了人类大脑的学习方式(尽管并不完全相同)以及解决问题的方法。ANNs 由一个互联的神经元群体组成,类似于人脑中神经元的工作方式。以下图示表示 ANN 的通用模型:
图 2.6
神经元是人工神经网络(ANN)的基本单元。它接收一定数量的输入(x[i]),对其进行计算,然后最终将输出发送到同一网络中的其他神经元。权重(w[j]),或参数,表示输入连接的强度——它们可以是正值或负值。网络输入可以按以下公式计算:
y[in] = x[1] X w[1] + x[2] X w[2] + x[3] X w[3] + … + x[n] X w[n]
输出可以通过对网络输入应用激活函数来计算:
y = f(y[in])
激活函数使人工神经网络(ANN)能够建模复杂的非线性模式,而简单的模型可能无法正确表示这些模式。
以下图示表示一个神经网络:
图 2.7
第一层是输入层——这是将特征输入网络的地方。最后一层是输出层。任何不属于输入层或输出层的中间层都是隐藏层。之所以称为 DL,是因为神经网络中存在多个隐藏层,用来解决复杂的非线性问题。在每一层中,任何单个节点都会接收输入数据和一个权重,并将一个置信度分数输出给下一层的节点。这个过程会一直进行,直到到达输出层。这个分数的误差会在该层计算出来。然后,误差会被发送回去,调整网络的权重,从而改进模型(这被称为反向传播,并发生在一个叫做梯度下降的过程中,我们将在第六章中讨论,循环神经网络)。神经网络有许多变种——更多内容将在下一部分介绍。
在继续之前,最后一个观察点。你可能会想,为什么 AI、ML 和 DL 背后的大多数概念已经存在了几十年,但在过去的 4 到 5 年才被炒作起来?有几个因素加速了它们的实施,并使其从理论走向现实应用:
-
更便宜的计算:在过去几十年中,硬件一直是 AI/ML/DL 的制约因素。近期硬件(结合改进的工具和软件框架)以及新计算模型(包括围绕 GPU 的模型)的进步,加速了 AI/ML/DL 的采用。
-
更大的数据可用性:AI/ML/DL 需要大量的数据来进行学习。社会的数字化转型正在提供大量原始材料,推动快速发展。大数据如今来自多种来源,如物联网传感器、社交和移动计算、智能汽车、医疗设备等,这些数据已经或将被用于训练模型。
-
更便宜的存储:可用数据量的增加意味着需要更多的存储空间。硬件的进步、成本的降低和性能的提高使得新存储系统的实现成为可能,而这一切都没有传统关系型数据库的限制。
-
更先进的算法:更便宜的计算和存储使得更先进的算法得以开发和训练,这些算法在解决特定问题时,如图像分类和欺诈检测,展现了令人印象深刻的准确性。
-
更多、更大规模的投资:最后但同样重要的是,人工智能的投资不再仅仅局限于大学或研究机构,而是来自许多其他实体,如科技巨头、政府、初创公司和各行各业的大型企业。
DNN 概述
如前一节所述,DNN 是一种在输入层和输出层之间具有多个隐藏层的人工神经网络(ANN)。通常,它们是前馈网络,其中数据从输入层流向输出层,不会回传,但 DNN 有不同的变种——其中,最具实际应用的是卷积神经网络(CNNs)和递归神经网络(RNNs)。
CNNs
CNNs 最常见的应用场景都与图像处理相关,但并不限于其他类型的输入,无论是音频还是视频。一个典型的应用场景是图像分类——网络接收图像输入,以便对数据进行分类。例如,当你给它一张狮子图片时,它输出狮子,当你给它一张老虎图片时,它输出老虎,依此类推。之所以使用这种网络进行图像分类,是因为它相对于同领域的其他算法来说,预处理工作量较小——网络学习到的滤波器,传统算法是人工设计的。
作为一个多层神经网络,CNN 由输入层、输出层以及多个隐藏层组成。隐藏层可以是卷积层、池化层、全连接层和归一化层。卷积层对输入进行卷积运算(en.wikipedia.org/wiki/Convolution
),然后将结果传递给下一个层。这个操作模拟了个体物理神经元对视觉刺激的响应生成方式。每个卷积神经元仅处理其感受野中的数据(感受野是指个体感官神经元的感官空间中,环境变化会改变该神经元的放电情况的特定区域)。池化层负责将一个层中神经元群的输出合并成下一层的单一神经元。池化有不同的实现方式——最大池化,使用来自前一层每个群体的最大值;平均池化,使用前一层任何神经元群的平均值;等等。全连接层则顾名思义,将一层中的每个神经元与另一层中的每个神经元连接起来。
CNN 并不会一次性解析所有训练数据,但它们通常从某种输入扫描器开始。例如,考虑一张 200 x 200 像素的图像作为输入。在这种情况下,模型没有一个包含 40,000 个节点的层,而是一个 20 x 20 的扫描输入层,该层使用原始图像的前 20 x 20 像素(通常从左上角开始)。一旦我们处理完该输入(并可能用它进行训练),我们就会使用下一个 20 x 20 像素输入(这一过程将在第五章,卷积神经网络中更详细地解释;这个过程类似于扫描仪的移动,每次向右移动一个像素)。请注意,图像并不是被分解成 20 x 20 的块,而是扫描仪在其上移动。然后,这些输入数据会通过一个或多个卷积层。每个卷积层的节点只需要与其邻近的节点工作——并不是所有的节点都互相连接。网络越深,它的卷积层越小,通常遵循输入的可分因子(如果我们从 20 的层开始,那么下一个层很可能是 10,接下来是 5)。通常使用 2 的幂作为可分因子。
以下图(由 Aphex34 制作,CC BY-SA 4.0,commons.wikimedia.org/w/index.php?curid=45679374
)展示了 CNN 的典型架构:
图 2.8
RNN(循环神经网络)
RNNs 主要因许多 NLP 任务而流行(即使它们目前也被用于不同的场景,我们将在第六章,循环神经网络中讨论)。RNN 的独特之处是什么?它们的特点是单元之间的连接形成一个沿着序列的有向图。这意味着 RNN 可以展示给定时间序列的动态时间行为。因此,它们可以使用内部状态(记忆)来处理输入序列,而在传统神经网络中,我们假设所有输入和输出彼此独立。这使得 RNN 适用于某些场景,例如当我们想要预测句子中的下一个词时——知道它前面的词肯定更有帮助。现在,你可以理解为什么它们被称为“循环”——每个序列元素都执行相同的任务,且输出依赖于之前的计算。
RNNs 中有循环结构,允许信息保持,如下所示:
图 2.9
在前面的图示中,神经网络的一部分,H,接收一些输入,x,并输出一个值,o。一个循环允许信息从网络的一个步骤传递到下一个步骤。通过展开图中的 RNN,形成一个完整的网络(如以下图所示),它可以被看作是多个相同网络的副本,每个副本将信息传递给后续步骤:
图 2.10
在这里,x[t] 是时间步 t 的输入,H[t] 是时间步 t 的隐藏状态(代表网络的记忆),而 o[t] 是时间步 t 的输出。隐藏状态捕捉了所有前一步骤中发生的事情的信息。给定步骤的输出仅基于时间 t 的记忆进行计算。RNN 在每个步骤中共享相同的参数——这是因为每个步骤执行的是相同的任务,只是输入不同——大大减少了它需要学习的总参数数量。每个步骤的输出不是必需的,因为这取决于当前的任务。同样,并非每个时间步都需要输入。
RNN 最早在 1980 年代开发,直到最近才有了许多新的变种。以下是其中一些架构的列表:
-
全递归:每个元素与架构中的每个其他元素都有一个加权的单向连接,并且与自身有一个单一的反馈连接。
-
递归:相同的权重集在结构中递归地应用,这种结构类似于图形结构。在此过程中,结构会按拓扑排序进行遍历(
en.wikipedia.org/wiki/Topological_sorting
)。 -
霍普菲尔德网络:所有的连接都是对称的。这种网络不适用于需要处理模式序列的场景,因为它只需要静态输入。
-
埃尔曼网络:这是一个三层网络,横向排列,外加一组所谓的上下文单元。中间的隐藏层与所有这些单元连接,权重固定为 1。在每个时间步,输入被前馈,然后应用一个学习规则。由于反向连接是固定的,隐藏单元的前一值会被保存在上下文单元中。这样,网络就能保持状态。正因如此,这种类型的 RNN 允许你执行一些标准多层神经网络无法完成的任务。
-
长短期记忆(LSTM):这是一种深度学习方法,防止反向传播的错误消失或梯度爆炸(这一点将在第六章,递归神经网络中详细讲解)。错误可以通过(理论上)无限数量的虚拟层向后流动。也就是说,LSTM 可以学习需要记住可能发生在几个时间步之前的事件的任务。
-
双向:通过连接两个 RNN 的输出,可以预测有限序列中的每个元素。第一个 RNN 从左到右处理序列,而第二个 RNN 则以相反的方向进行处理。
-
递归多层感知器网络:由级联子网络组成,每个子网络包含多个节点层。除最后一层(唯一可以有反馈连接的层)外,其他子网络都是前馈的。
第五章,卷积神经网络,以及第六章,递归神经网络,将详细讲解 CNN 和 RNN。
深度学习的实际应用
前两部分所介绍的深度学习概念和模型不仅仅是纯理论——实际上,已经有许多应用基于这些概念和模型得以实现。深度学习擅长识别非结构化数据中的模式;大多数应用场景与图像、声音、视频和文本等媒体相关。如今,深度学习已经应用于多个商业领域的众多场景,包括以下几种:
-
计算机视觉:在汽车工业、面部识别、动作检测和实时威胁检测等方面的应用。
-
自然语言处理(NLP):社交媒体情感分析、金融和保险中的欺诈检测、增强搜索和日志分析。
-
医学诊断:异常检测、病理识别。
-
搜索引擎:图像搜索。
-
物联网(IoT):智能家居、基于传感器数据的预测分析。
-
制造业:预测性维护。
-
营销:推荐引擎、自动化目标识别。
-
音频分析:语音识别、语音搜索和机器翻译。
还有许多内容在后续章节中会进一步介绍。
总结
本章介绍了深度学习(DL)的基础知识。这个概述保持了较高的层次,以帮助那些刚接触这一话题的读者,并为他们准备好迎接接下来章节中更详细和实践性的内容。
第三章:提取、转换、加载
训练和测试深度学习模型需要数据。数据通常存储在不同的分布式和远程存储系统中。你需要连接到数据源并执行数据检索,以便开始训练阶段,同时可能需要做一些准备工作再将数据输入模型。本章介绍了应用于深度学习的提取、转换、加载(ETL)过程的各个阶段。它涵盖了使用 DeepLearning4j 框架和 Spark 的若干用例,这些用例与批量数据摄取有关。数据流处理将在下一章介绍。
本章将涵盖以下主题:
-
通过 Spark 摄取训练数据
-
从关系型数据库中摄取数据
-
从 NoSQL 数据库摄取数据
-
从 S3 摄取数据
通过 Spark 摄取训练数据
本章的第一部分介绍了 DeepLearning4j 框架,并展示了使用该框架和 Apache Spark 从文件中摄取训练数据的一些用例。
DeepLearning4j 框架
在进入第一个示例之前,我们先简单介绍一下 DeepLearning4j(deeplearning4j.org/
)框架。它是一个开源的(基于 Apache 2.0 许可证发布,www.apache.org/licenses/LICENSE-2.0
)分布式深度学习框架,专为 JVM 编写。自最早版本起,DeepLearning4j 便与 Hadoop 和 Spark 集成,利用这些分布式计算框架加速网络训练。该框架用 Java 编写,因此与任何其他 JVM 语言(当然也包括 Scala)兼容,而底层计算则使用低级语言(如 C、C++和 CUDA)编写。DL4J 的 API 提供了在构建深度神经网络时的灵活性。因此,可以根据需要将不同的网络实现组合在一起,部署到基于分布式 CPU 或 GPU 的生产级基础设施上。DL4J 可以通过 Keras(keras.io/
)导入来自大多数主流机器学习或深度学习 Python 框架(包括 TensorFlow 和 Caffe)的神经网络模型,弥合 Python 与 JVM 生态系统之间的差距,尤其是为数据科学家提供工具,同时也适用于数据工程师和 DevOps。Keras 代表了 DL4J 的 Python API。
DL4J 是模块化的。以下是构成该框架的主要库:
-
Deeplearning4j:神经网络平台核心
-
ND4J:JVM 版 NumPy(
www.numpy.org/
) -
DataVec:用于机器学习 ETL 操作的工具
-
Keras 导入:导入在 Keras 中实现的预训练 Python 模型
-
Arbiter:用于多层神经网络超参数优化的专用库
-
RL4J:JVM 版深度强化学习实现
我们将从本章开始,探索 DL4J 及其库的几乎所有功能,并贯穿本书的其他章节。
本书中的 DL4J 参考版本是 0.9.1。
通过 DataVec 获取数据并通过 Spark 进行转化
数据可以来自多个来源,并且有多种类型,例如:
-
日志文件
-
文本文件
-
表格数据
-
图像
-
视频
在使用神经网络时,最终目标是将每种数据类型转换为多维数组中的数值集合。数据在被用于训练或测试神经网络之前,可能还需要预处理。因此,在大多数情况下,需要进行 ETL 过程,这是数据科学家在进行机器学习或深度学习时面临的一个有时被低估的挑战。这时,DL4J DataVec 库就发挥了重要作用。通过该库的 API 转换后的数据,转换为神经网络可理解的格式(向量),因此 DataVec 可以快速生成符合开放标准的向量化数据。
DataVec 开箱即用地支持所有主要类型的输入数据(文本、CSV、音频、视频、图像)及其特定的输入格式。它可以扩展以支持当前 API 版本中未涵盖的专业输入格式。你可以将 DataVec 的输入/输出格式系统与 Hadoop MapReduce 中的 InputFormat
实现进行类比,用于确定逻辑的 InputSplits 和 RecordReaders
实现的选择。它还提供 RecordReaders
来序列化数据。这个库还包括特征工程、数据清理和归一化的功能。它们可以处理静态数据和时间序列数据。所有可用的功能都可以通过 DataVec-Spark 模块在 Apache Spark 上执行。
如果你想了解更多关于前面提到的 Hadoop MapReduce 类的信息,你可以查看以下官方在线 Javadocs:
让我们来看一个 Scala 中的实际代码示例。我们想从一个包含电子商店交易的 CSV 文件中提取数据,并且该文件包含以下列:
-
DateTimeString
-
CustomerID
-
MerchantID
-
NumItemsInTransaction
-
MerchantCountryCode
-
TransactionAmountUSD
-
FraudLabel
然后,我们对这些数据进行一些转化操作。
首先,我们需要导入所需的依赖项(Scala、Spark、DataVec 和 DataVec-Spark)。以下是 Maven POM 文件的完整列表(当然,您也可以使用 SBT 或 Gradle):
<properties>
<scala.version>2.11.8</scala.version>
<spark.version>2.2.1</spark.version>
<dl4j.version>0.9.1</dl4j.version>
<datavec.spark.version>0.9.1_spark_2</datavec.spark.version>
</properties>
<dependencies>
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>${scala.version}</version>
</dependency>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_2.11</artifactId>
<version>${spark.version}</version>
</dependency>
<dependency>
<groupId>org.datavec</groupId>
<artifactId>datavec-api</artifactId>
<version>${dl4j.version}</version>
</dependency>
<dependency>
<groupId>org.datavec</groupId>
<artifactId>datavec-spark_2.11</artifactId>
<version>${datavec.spark.version}</version>
</dependency>
</dependencies>
在 Scala 应用程序中的第一步是定义输入数据模式,如下所示:
val inputDataSchema = new Schema.Builder()
.addColumnString("DateTimeString")
.addColumnsString("CustomerID", "MerchantID")
.addColumnInteger("NumItemsInTransaction")
.addColumnCategorical("MerchantCountryCode", List("USA", "CAN", "FR", "MX").asJava)
.addColumnDouble("TransactionAmountUSD", 0.0, null, false, false) //$0.0 or more, no maximum limit, no NaN and no Infinite values
.addColumnCategorical("FraudLabel", List("Fraud", "Legit").asJava)
.build
如果输入数据是数值的并且格式正确,那么可以使用CSVRecordReader
(deeplearning4j.org/datavecdoc/org/datavec/api/records/reader/impl/csv/CSVRecordReader.html
)。然而,如果输入数据包含非数值字段,则需要进行模式转换。DataVec 使用 Apache Spark 执行转换操作。一旦我们有了输入模式,我们可以定义要应用于输入数据的转换。这个例子中描述了一些转换。例如,我们可以删除一些对我们的网络不必要的列:
val tp = new TransformProcess.Builder(inputDataSchema)
.removeColumns("CustomerID", "MerchantID")
.build
过滤MerchantCountryCode
列以获取仅与美国和加拿大相关的记录,如下所示:
.filter(new ConditionFilter(
new CategoricalColumnCondition("MerchantCountryCode", ConditionOp.NotInSet, new HashSet(Arrays.asList("USA","CAN")))))
在此阶段,仅定义了转换,但尚未应用(当然,我们首先需要从输入文件中获取数据)。到目前为止,我们仅使用了 DataVec 类。为了读取数据并应用定义的转换,需要使用 Spark 和 DataVec-Spark API。
让我们首先创建SparkContext
,如下所示:
val conf = new SparkConf
conf.setMaster(args[0])
conf.setAppName("DataVec Example")
val sc = new JavaSparkContext(conf)
现在,我们可以读取 CSV 输入文件并使用CSVRecordReader
解析数据,如下所示:
val directory = new ClassPathResource("datavec-example-data.csv").getFile.getAbsolutePath
val stringData = sc.textFile(directory)
val rr = new CSVRecordReader
val parsedInputData = stringData.map(new StringToWritablesFunction(rr))
然后执行先前定义的转换,如下所示:
val processedData = SparkTransformExecutor.execute(parsedInputData, tp)
最后,让我们本地收集数据,如下所示:
val processedAsString = processedData.map(new WritablesToStringFunction(","))
val processedCollected = processedAsString.collect
val inputDataCollected = stringData.collect
输入数据如下所示:
处理后的数据如下所示:
这个示例的完整代码包含在书籍附带的源代码中。
使用 Spark 从数据库中进行训练数据摄取
有时数据之前已被其他应用程序摄入并存储到数据库中,因此您需要连接到数据库以便用于训练或测试目的。本节描述了如何从关系数据库和 NoSQL 数据库获取数据。在这两种情况下,都将使用 Spark。
从关系数据库中摄取数据
假设数据存储在 MySQL (dev.mysql.com/
) 架构名称为 sparkdb
的表 sparkexample
中。这是该表的结构:
mysql> DESCRIBE sparkexample;
+-----------------------+-------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+-----------------------+-------------+------+-----+---------+-------+
| DateTimeString | varchar(23) | YES | | NULL | |
| CustomerID | varchar(10) | YES | | NULL | |
| MerchantID | varchar(10) | YES | | NULL | |
| NumItemsInTransaction | int(11) | YES | | NULL | |
| MerchantCountryCode | varchar(3) | YES | | NULL | |
| TransactionAmountUSD | float | YES | | NULL | |
| FraudLabel | varchar(5) | YES | | NULL | |
+-----------------------+-------------+------+-----+---------+-------+
7 rows in set (0.00 sec)
它包含与 使用 Spark 进行训练数据摄取 中相同的数据,如下所示:
mysql> select * from sparkexample;
+-------------------------+------------+------------+-----------------------+---------------------+----------------------+------------+
| DateTimeString | CustomerID | MerchantID | NumItemsInTransaction | MerchantCountryCode | TransactionAmountUSD | FraudLabel |
+-------------------------+------------+------------+-----------------------+---------------------+----------------------+------------+
| 2016-01-01 17:00:00.000 | 830a7u3 | u323fy8902 | 1 | USA | 100 | Legit |
| 2016-01-01 18:03:01.256 | 830a7u3 | 9732498oeu | 3 | FR | 73.2 | Legit |
|... | | | | | | |
添加到 Scala Spark 项目中的依赖项如下所示:
-
Apache Spark 2.2.1
-
Apache Spark SQL 2.2.1
-
用于 MySQL 数据库发布的特定 JDBC 驱动程序
现在我们来实现 Scala 中的 Spark 应用程序。为了连接到数据库,我们需要提供所有必要的参数。Spark SQL 还包括一个数据源,可以使用 JDBC 从其他数据库读取数据,因此所需的属性与通过传统 JDBC 连接到数据库时相同;例如:
var jdbcUsername = "root"
var jdbcPassword = "secretpw"
val jdbcHostname = "mysqlhost"
val jdbcPort = 3306
val jdbcDatabase ="sparkdb"
val jdbcUrl = s"jdbc:mysql://${jdbcHostname}:${jdbcPort}/${jdbcDatabase}"
我们需要检查 MySQL 数据库的 JDBC 驱动是否可用,如下所示:
Class.forName("com.mysql.jdbc.Driver")
我们现在可以创建一个 SparkSession
,如下所示:
val spark = SparkSession
.builder()
.master("local[*]")
.appName("Spark MySQL basic example")
.getOrCreate()
导入隐式转换,如下所示:
import spark.implicits._
最终,你可以连接到数据库并将 sparkexample
表中的数据加载到 DataFrame,如下所示:
val jdbcDF = spark.read
.format("jdbc")
.option("url", jdbcUrl)
.option("dbtable", s"${jdbcDatabase}.sparkexample")
.option("user", jdbcUsername)
.option("password", jdbcPassword)
.load()
Spark 会自动从数据库表中读取模式,并将其类型映射回 Spark SQL 类型。对 DataFrame 执行以下方法:
jdbcDF.printSchema()
它返回与表 sparkexample
相同的模式;例如:
root
|-- DateTimeString: string (nullable = true)
|-- CustomerID: string (nullable = true)
|-- MerchantID: string (nullable = true)
|-- NumItemsInTransaction: integer (nullable = true)
|-- MerchantCountryCode: string (nullable = true)
|-- TransactionAmountUSD: double (nullable = true)
|-- FraudLabel: string (nullable = true)
一旦数据被加载到 DataFrame 中,就可以使用特定的 DSL 执行 SQL 查询,如下例所示:
jdbcDF.select("MerchantCountryCode", "TransactionAmountUSD").groupBy("MerchantCountryCode").avg("TransactionAmountUSD")
可以通过 JDBC 接口增加读取的并行性。我们需要根据 DataFrame 列值提供拆分边界。有四个选项可用(columnname
,lowerBound
,upperBound
和 numPartitions
),用于指定读取时的并行性。它们是可选的,但如果提供其中任何一个,必须全部指定;例如:
val jdbcDF = spark.read
.format("jdbc")
.option("url", jdbcUrl)
.option("dbtable", s"${jdbcDatabase}.employees")
.option("user", jdbcUsername)
.option("password", jdbcPassword)
.option("columnName", "employeeID")
.option("lowerBound", 1L)
.option("upperBound", 100000L)
.option("numPartitions", 100)
.load()
尽管本节中的示例参考了 MySQL 数据库,但它们适用于任何具有 JDBC 驱动的商业或开源关系型数据库。
从 NoSQL 数据库中获取数据
数据也可以来自 NoSQL 数据库。在本节中,我们将探讨实现代码,以便从 MongoDB (www.mongodb.com/
) 数据库中消费数据。
sparkmdb
数据库中的 sparkexample
集合包含与 通过 DataVec 获取数据和通过 Spark 转换 以及 从关系型数据库获取数据 部分中的示例相同的数据,但以 BSON 文档的形式;例如:
/* 1 */
{
"_id" : ObjectId("5ae39eed144dfae14837c625"),
"DateTimeString" : "2016-01-01 17:00:00.000",
"CustomerID" : "830a7u3",
"MerchantID" : "u323fy8902",
"NumItemsInTransaction" : 1,
"MerchantCountryCode" : "USA",
"TransactionAmountUSD" : 100.0,
"FraudLabel" : "Legit"
}
/* 2 */
{
"_id" : ObjectId("5ae3a15d144dfae14837c671"),
"DateTimeString" : "2016-01-01 18:03:01.256",
"CustomerID" : "830a7u3",
"MerchantID" : "9732498oeu",
"NumItemsInTransaction" : 3,
"MerchantCountryCode" : "FR",
"TransactionAmountUSD" : 73.0,
"FraudLabel" : "Legit"
}
...
需要添加到 Scala Spark 项目的依赖项如下:
-
Apache Spark 2.2.1
-
Apache Spark SQL 2.2.1
-
Spark 2.2.0 的 MongoDB 连接器
我们需要创建一个 Spark 会话,如下所示:
val sparkSession = SparkSession.builder()
.master("local")
.appName("MongoSparkConnectorIntro")
.config("spark.mongodb.input.uri", "mongodb://mdbhost:27017/sparkmdb.sparkexample")
.config("spark.mongodb.output.uri", "mongodb://mdbhost:27017/sparkmdb.sparkexample")
.getOrCreate()
指定连接到数据库的方式。在创建会话后,可以使用它通过 com.mongodb.spark.MongoSpark
类从 sparkexample
集合加载数据,如下所示:
val df = MongoSpark.load(sparkSession)
返回的 DataFrame 具有与 sparkexample
集合相同的结构。使用以下指令:
df.printSchema()
它会打印出以下输出:
当然,检索到的数据就是数据库集合中的数据,如下所示:
df.collect.foreach { println }
它返回如下内容:
[830a7u3,2016-01-01 17:00:00.000,Legit,USA,u323fy8902,1,100.0,[5ae39eed144dfae14837c625]]
[830a7u3,2016-01-01 18:03:01.256,Legit,FR,9732498oeu,3,73.0,[5ae3a15d144dfae14837c671]]
...
还可以在 DataFrame 上运行 SQL 查询。我们首先需要创建一个 case 类来定义 DataFrame 的模式,如下所示:
case class Transaction(CustomerID: String,
MerchantID: String,
MerchantCountryCode: String,
DateTimeString: String,
NumItemsInTransaction: Int,
TransactionAmountUSD: Double,
FraudLabel: String)
然后我们加载数据,如下所示:
val transactions = MongoSpark.loadTransaction
我们必须为 DataFrame 注册一个临时视图,如下所示:
transactions.createOrReplaceTempView("transactions")
在我们执行 SQL 语句之前,例如:
val filteredTransactions = sparkSession.sql("SELECT CustomerID, MerchantID FROM transactions WHERE TransactionAmountUSD = 100")
使用以下指令:
filteredTransactions.show
它返回如下内容:
+----------+----------+
|CustomerID|MerchantID|
+----------+----------+
| 830a7u3|u323fy8902|
+----------+----------+
从 S3 获取数据
如今,训练和测试数据很可能托管在某些云存储系统中。在本节中,我们将学习如何通过 Apache Spark 从对象存储(如 Amazon S3(aws.amazon.com/s3/
)或基于 S3 的存储(如 Minio,www.minio.io/
))摄取数据。Amazon 简单存储服务(更常被称为 Amazon S3)是 AWS 云服务的一部分,提供对象存储服务。虽然 S3 可用于公共云,Minio 是一个高性能分布式对象存储服务器,兼容 S3 协议和标准,专为大规模私有云基础设施设计。
我们需要在 Scala 项目中添加 Spark 核心和 Spark SQL 依赖项,以及以下内容:
groupId: com.amazonaws
artifactId: aws-java-sdk-core
version1.11.234
groupId: com.amazonaws
artifactId: aws-java-sdk-s3
version1.11.234
groupId: org.apache.hadoop
artifactId: hadoop-aws
version: 3.1.1
它们是 AWS Java JDK 核心库和 S3 库,以及用于 AWS 集成的 Apache Hadoop 模块。
对于这个示例,我们需要已经在 S3 或 Minio 上创建了一个现有的存储桶。对于不熟悉 S3 对象存储的读者,存储桶类似于文件系统目录,用户可以在其中存储对象(数据及其描述的元数据)。然后,我们需要在该存储桶中上传一个文件,Spark 将需要读取该文件。此示例使用的文件通常可以从 MonitorWare 网站下载(www.monitorware.com/en/logsamples/apache.php
)。它包含以 ASCII 格式记录的 HTTP 请求日志条目。为了这个示例,我们假设存储桶的名称是 dl4j-bucket
,上传的文件名是 access_log
。在我们的 Spark 程序中,首先要做的是创建一个 SparkSession
,如下所示
val sparkSession = SparkSession
.builder
.master(master)
.appName("Spark Minio Example")
.getOrCreate
为了减少输出中的噪音,让我们将 Spark 的日志级别设置为 WARN
,如下所示
sparkSession.sparkContext.setLogLevel("WARN")
现在 SparkSession
已创建,我们需要设置 S3 或 Minio 端点和凭据,以便 Spark 访问它,并设置其他一些属性,如下所示:
sparkSession.sparkContext.hadoopConfiguration.set("fs.s3a.endpoint", "http://<host>:<port>")
sparkSession.sparkContext.hadoopConfiguration.set("fs.s3a.access.key", "access_key")
sparkSession.sparkContext.hadoopConfiguration.set("fs.s3a.secret.key", "secret")
sparkSession.sparkContext.hadoopConfiguration.set("fs.s3a.path.style.access", "true")
sparkSession.sparkContext.hadoopConfiguration.set("fs.s3a.connection.ssl.enabled", "false")
sparkSession.sparkContext.hadoopConfiguration.set("fs.s3a.impl", "org.apache.hadoop.fs.s3a.S3AFileSystem")
这是为最小配置设置的属性的含义:
-
fs.s3a.endpoint
:S3 或 Minio 端点。 -
fs.s3a.access.key
:AWS 或 Minio 访问密钥 ID。 -
fs.s3a.secret.key
:AWS 或 Minio 秘密密钥。 -
fs.s3a.path.style.access
:启用 S3 路径风格访问,同时禁用默认的虚拟主机行为。 -
fs.s3a.connection.ssl.enabled
:指定是否在端点启用了 SSL。可能的值是true
和false
。 -
fs.s3a.impl
:所使用的S3AFileSystem
实现类。
现在我们已经准备好从 S3 或 Minio 存储桶中读取access_log
文件(或任何其他文件),并将其内容加载到 RDD 中,如下所示:
val logDataRdd = sparkSession.sparkContext.textFile("s3a://dl4j-bucket/access_log")
println("RDD size is " + logDataRdd.count)
还可以将 RDD 转换为 DataFrame,并按如下方式显示输出内容:
import sparkSession.implicits._
val logDataDf = logDataRdd.toDF
logDataDf.show(10, false)
这将提供以下输出:
一旦从存储在 S3 或 Minio 桶中的对象加载数据,就可以使用 Spark 对 RDD 和数据集的任何操作。
使用 Spark 进行原始数据转换
数据来自一个来源时,通常是原始数据。当我们谈论原始数据时,指的是那些无法直接用于训练或测试模型的数据格式。因此,在使用之前,我们需要将其整理。清理过程通过一个或多个转换步骤完成,才能将数据作为输入提供给特定的模型。
为了数据转换的目的,DL4J 的 DataVec 库和 Spark 提供了多种功能。此部分描述的一些概念已在通过 DataVec 进行数据摄取并通过 Spark 进行转换部分中探讨过,但现在我们将添加一个更复杂的使用案例。
为了理解如何使用 Datavec 进行数据转换,我们来构建一个用于网站流量日志分析的 Spark 应用程序。所使用的数据集可以从 MonitorWare 网站下载(www.monitorware.com/en/logsamples/apache.php
)。这些数据是 ASCII 格式的 HTTP 请求日志条目,每个请求一行,包含以下列:
-
发出请求的主机。可以是主机名或互联网地址
-
一个时间戳,格式为DD/Mon/YYYY:HH:MM:SS,其中DD是日,Mon是月的名称,YYYY是年份,HH:MM:SS是使用 24 小时制的时间。时区为-0800
-
引号中的 HTTP 请求
-
HTTP 回复代码
-
回复中的字节总数
这是一个示例日志内容:
64.242.88.10 - - [07/Mar/2004:16:05:49 -0800] "GET /twiki/bin/edit/Main/Double_bounce_sender?topicparent=Main.ConfigurationVariables HTTP/1.1" 401 12846
64.242.88.10 - - [07/Mar/2004:16:06:51 -0800] "GET /twiki/bin/rdiff/TWiki/NewUserTemplate?rev1=1.3&rev2=1.2 HTTP/1.1" 200 4523
64.242.88.10 - - [07/Mar/2004:16:10:02 -0800] "GET /mailman/listinfo/hsdivision HTTP/1.1" 200 6291
64.242.88.10 - - [07/Mar/2004:16:11:58 -0800] "GET /twiki/bin/view/TWiki/WikiSyntax HTTP/1.1" 200 7352
在我们的应用程序中,首先要做的是定义输入数据的模式,如下所示:
val schema = new Schema.Builder()
.addColumnString("host")
.addColumnString("timestamp")
.addColumnString("request")
.addColumnInteger("httpReplyCode")
.addColumnInteger("replyBytes")
.build
启动一个 Spark 上下文,如下所示:
val conf = new SparkConf
conf.setMaster("local[*]")
conf.setAppName("DataVec Log Analysis Example")
val sc = new JavaSparkContext(conf)
加载文件,如下所示:
val directory = new ClassPathResource("access_log").getFile.getAbsolutePath
一个网页日志文件可能包含一些无效的行,这些行不符合前述的模式,因此我们需要加入一些逻辑来丢弃那些对我们的分析没有用的行,例如:
var logLines = sc.textFile(directory)
logLines = logLines.filter { (s: String) =>
s.matches("(\\S+) - - \\[(\\S+ -\\d{4})\\] \"(.+)\" (\\d+) (\\d+|-)")
}
我们应用正则表达式来过滤出符合预期格式的日志行。现在我们可以开始使用 DataVec 的RegexLineRecordReader
(deeplearning4j.org/datavecdoc/org/datavec/api/records/reader/impl/regex/RegexLineRecordReader.html
)来解析原始数据。我们需要定义一个regex
来格式化这些行,如下所示:
val regex = "(\\S+) - - \\[(\\S+ -\\d{4})\\] \"(.+)\" (\\d+) (\\d+|-)"
val rr = new RegexLineRecordReader(regex, 0)
val parsed = logLines.map(new StringToWritablesFunction(rr))
通过 DataVec-Spark 库,还可以在定义转换之前检查数据质量。我们可以使用AnalyzeSpark
(deeplearning4j.org/datavecdoc/org/datavec/spark/transform/AnalyzeSpark.html
)类来实现这一目的,如下所示:
val dqa = AnalyzeSpark.analyzeQuality(schema, parsed)
println("----- Data Quality -----")
println(dqa)
以下是数据质量分析产生的输出:
----- Data Quality -----
idx name type quality details
0 "host" String ok StringQuality(countValid=1546, countInvalid=0, countMissing=0, countTotal=1546, countEmptyString=0, countAlphabetic=0, countNumerical=0, countWordCharacter=10, countWhitespace=0, countApproxUnique=170)
1 "timestamp" String ok StringQuality(countValid=1546, countInvalid=0, countMissing=0, countTotal=1546, countEmptyString=0, countAlphabetic=0, countNumerical=0, countWordCharacter=0, countWhitespace=0, countApproxUnique=1057)
2 "request" String ok StringQuality(countValid=1546, countInvalid=0, countMissing=0, countTotal=1546, countEmptyString=0, countAlphabetic=0, countNumerical=0, countWordCharacter=0, countWhitespace=0, countApproxUnique=700)
3 "httpReplyCode" Integer ok IntegerQuality(countValid=1546, countInvalid=0, countMissing=0, countTotal=1546, countNonInteger=0)
4 "replyBytes" Integer FAIL IntegerQuality(countValid=1407, countInvalid=139, countMissing=0, countTotal=1546, countNonInteger=139)
从中我们注意到,在139
行(共1546
行)中,replyBytes
字段并不是预期的整数类型。以下是其中几行:
10.0.0.153 - - [12/Mar/2004:11:01:26 -0800] "GET / HTTP/1.1" 304 -
10.0.0.153 - - [12/Mar/2004:12:23:11 -0800] "GET / HTTP/1.1" 304 -
因此,第一个要进行的转换是清理replyBytes
字段,将所有非整数条目替换为0
。我们使用TransformProcess
类,方法与通过 DataVec 进行数据摄取和通过 Spark 进行转换部分中的示例相同,如下所示:
val tp: TransformProcess = new TransformProcess.Builder(schema)
.conditionalReplaceValueTransform("replyBytes", new IntWritable(0), new StringRegexColumnCondition("replyBytes", "\\D+"))
然后,我们可以应用其他任何转换,例如按主机分组并提取汇总指标(计算条目数量、计算唯一请求和 HTTP 回复代码的数量、对replyBytes
字段的值求和);例如:
.reduce(new Reducer.Builder(ReduceOp.CountUnique)
.keyColumns("host")
.countColumns("timestamp")
.countUniqueColumns("request", "httpReplyCode")
.sumColumns("replyBytes")
.build
)
重命名若干列,如下所示:
.renameColumn("count", "numRequests")
筛选出所有请求的字节总数少于 100 万的主机,如下所示:
.filter(new ConditionFilter(new LongColumnCondition("sum(replyBytes)", ConditionOp.LessThan, 1000000)))
.build
我们现在可以执行转换,如下所示:
val processed = SparkTransformExecutor.execute(parsed, tp)
processed.cache
我们还可以对最终数据进行一些分析,如下所示:
val finalDataSchema = tp.getFinalSchema
val finalDataCount = processed.count
val sample = processed.take(10)
val analysis = AnalyzeSpark.analyze(finalDataSchema, processed)
最终数据模式如下所示:
idx name type meta data
0 "host" String StringMetaData(name="host",)
1 "count(timestamp)" Long LongMetaData(name="count(timestamp)",minAllowed=0)
2 "countunique(request)" Long LongMetaData(name="countunique(request)",minAllowed=0)
3 "countunique(httpReplyCode)" Long LongMetaData(name="countunique(httpReplyCode)",minAllowed=0)
4 "sum(replyBytes)" Integer IntegerMetaData(name="sum(replyBytes)",)
以下显示结果计数为二:
[10.0.0.153, 270, 43, 3, 1200145]
[64.242.88.10, 452, 451, 2, 5745035]
以下代码显示了分析结果:
----- Analysis -----
idx name type analysis
0 "host" String StringAnalysis(minLen=10,maxLen=12,meanLen=11.0,sampleStDevLen=1.4142135623730951,sampleVarianceLen=2.0,count=2)
1 "count(timestamp)" Long LongAnalysis(min=270,max=452,mean=361.0,sampleStDev=128.69343417595164,sampleVariance=16562.0,countZero=0,countNegative=0,countPositive=2,countMinValue=1,countMaxValue=1,count=2)
2 "countunique(request)" Long LongAnalysis(min=43,max=451,mean=247.0,sampleStDev=288.4995667241114,sampleVariance=83232.0,countZero=0,countNegative=0,countPositive=2,countMinValue=1,countMaxValue=1,count=2)
3 "countunique(httpReplyCode)" Long LongAnalysis(min=2,max=3,mean=2.5,sampleStDev=0.7071067811865476,sampleVariance=0.5,countZero=0,countNegative=0,countPositive=2,countMinValue=1,countMaxValue=1,count=2)
4 "sum(replyBytes)" Integer IntegerAnalysis(min=1200145,max=5745035,mean=3472590.0,sampleStDev=3213722.538746928,sampleVariance=1.032801255605E13,countZero=0,countNegative=0,countPositive=2,countMinValue=1,countMaxValue=1,count=2)
摘要
本章探讨了通过 DeepLearning4j DataVec 库和 Apache Spark(核心模块和 Spark SQL 模块)框架从文件、关系型数据库、NoSQL 数据库和基于 S3 的对象存储系统摄取数据的不同方式,并展示了一些如何转换原始数据的示例。所有呈现的示例代表了批处理方式的数据摄取和转换。
下一章将专注于摄取和转换数据,以在流模式下训练或测试您的 DL 模型。
第四章:流处理
在上一章中,我们学习了如何使用批量 ETL 方法摄取和转换数据,以训练或评估模型。在大多数情况下,你会在训练或评估阶段使用这种方法,但在运行模型时,需要使用流式摄取。本章将介绍使用 Apache Spark、DL4J、DataVec 和 Apache Kafka 框架组合来设置流式摄取策略。与传统 ETL 方法不同,流式摄取框架不仅仅是将数据从源移动到目标。通过流式摄取,任何格式的进入数据都可以被同时摄取、转换和/或与其他结构化数据和先前存储的数据一起丰富,以供深度学习使用。
本章将涵盖以下主题:
-
使用 Apache Spark 进行流数据处理
-
使用 Kafka 和 Apache Spark 进行流数据处理
-
使用 DL4J 和 Apache Spark 进行流数据处理
使用 Apache Spark 进行流数据处理
在第一章《Apache Spark 生态系统》中,详细介绍了 Spark Streaming 和 DStreams。结构化流处理作为 Apache Spark 2.0.0 的 Alpha 版本首次推出,它最终从 Spark 2.2.0 开始稳定。
结构化流处理(基于 Spark SQL 引擎构建)是一个容错、可扩展的流处理引擎。流处理可以像批量计算一样进行,也就是说,在静态数据上进行计算,我们在第一章《Apache Spark 生态系统》中已经介绍过。正是 Spark SQL 引擎负责增量地和持续地运行计算,并在数据持续流入时最终更新结果。在这种情况下,端到端、精确一次和容错的保证是通过预写日志(WAL)和检查点实现的。
传统的 Spark Streaming 和结构化流处理编程模型之间的差异,有时不容易理解,尤其是对于第一次接触这个概念的有经验的 Spark 开发者来说。描述这种差异的最好方式是:你可以把它当作一种处理实时数据流的方式,将其看作一个持续追加的表(表可以被视为一个 RDBMS)。流计算被表达为一个标准的类批量查询(就像在静态表上发生的那样),但是 Spark 对这个无界表进行增量计算。
它的工作原理如下:输入数据流可以看作是输入表。每个到达数据流的数据项就像是向表中追加了一行新数据:
图 4.1:作为无界表的数据流
针对输入的查询会生成结果表。每次触发时,新的间隔行会追加到输入表中,然后更新结果表(如下图所示)。每当结果表更新时,更改后的结果行可以写入外部接收器。写入外部存储的输出有不同的模式:
-
完整模式:在这种模式下,整个更新后的结果表会被写入外部存储。如何将整个表写入存储系统取决于特定的连接器配置或实现。
-
追加模式:只有追加到结果表中的新行会被写入外部存储系统。这意味着可以在结果表中的现有行不期望更改的情况下应用此模式。
-
更新模式:只有在结果表中更新过的行会被写入外部存储系统。这种模式与完整模式的区别在于,它仅发送自上次触发以来发生变化的行:
图 4.2:结构化流处理的编程模型
现在,让我们实现一个简单的 Scala 示例——一个流式单词计数自包含应用程序,这是我们在第一章中使用的相同用例,Apache Spark 生态系统,但这次是针对结构化流处理。用于此类的代码可以在与 Spark 发行版捆绑的示例中找到。我们首先需要做的是初始化一个SparkSession
:
val spark = SparkSession
.builder
.appName("StructuredNetworkWordCount")
.master(master)
.getOrCreate()
然后,我们必须创建一个表示从连接到host:port
的输入行流的 DataFrame:
val lines = spark.readStream
.format("socket")
.option("host", host)
.option("port", port)
.load()
lines
DataFrame 表示无界表格。它包含流式文本数据。该表的内容是一个值,即一个包含字符串的单列。每一行流入的文本都会成为一行数据。
让我们将行拆分为单词:
val words = lines.as[String].flatMap(_.split(" "))
然后,我们需要统计单词数量:
val wordCounts = words.groupBy("value").count()
最后,我们可以开始运行查询,将运行计数打印到控制台:
val query = wordCounts.writeStream
.outputMode("complete")
.format("console")
.start()
我们会继续运行,直到接收到终止信号:
query.awaitTermination()
在运行此示例之前,首先需要运行 netcat 作为数据服务器(或者我们在第一章中用 Scala 实现的数据服务器,Apache Spark 生态系统):
nc -lk 9999
然后,在另一个终端中,你可以通过传递以下参数来启动示例:
localhost 9999
在运行 netcat 服务器时,终端中输入的任何一行都会被计数并打印到应用程序屏幕上。将会出现如下输出:
hello spark
a stream
hands on spark
这将产生以下输出:
-------------------------------------------
Batch: 0
-------------------------------------------
+------+-----+
| value|count|
+------+-----+
| hello| 1|
| spark| 1|
+------+-----+
-------------------------------------------
Batch: 1
-------------------------------------------
+------+-----+
| value|count|
+------+-----+
| hello| 1|
| spark| 1|
| a| 1|
|stream| 1|
+------+-----+
-------------------------------------------
Batch: 2
-------------------------------------------
+------+-----+
| value|count|
+------+-----+
| hello| 1|
| spark| 2|
| a| 1|
|stream| 1|
| hands| 1|
| on| 1|
+------+-----+
事件时间定义为数据本身所嵌入的时间。在许多应用场景中,例如物联网(IoT)环境,当每分钟设备生成的事件数量需要被检索时,必须使用数据生成的时间,而不是 Spark 接收到它的时间。在这种编程模型中,事件时间自然地被表达——每个来自设备的事件就是表中的一行,而事件时间是该行中的列值。这种范式使得基于窗口的聚合成为对事件时间列的特殊聚合类型。这样可以保证一致性,因为基于事件时间和基于窗口的聚合查询可以在静态数据集(例如设备事件日志)和流式数据上以相同方式进行定义。
根据前面的考虑,显而易见,这种编程模型自然地处理了基于事件时间的数据,这些数据可能比预期的到达时间晚。由于是 Spark 本身更新结果表,因此它可以完全控制在有迟到数据时如何更新旧的聚合,以及通过清理旧的聚合来限制中间数据的大小。从 Spark 2.1 开始,还支持水印(watermarking),它允许你指定迟到数据的阈值,并允许底层引擎相应地清理旧状态。
使用 Kafka 和 Spark 进行流式数据处理
使用 Spark Streaming 与 Kafka 是数据管道中常见的技术组合。本节将展示一些使用 Spark 流式处理 Kafka 的示例。
Apache Kafka
Apache Kafka (kafka.apache.org/
) 是一个用 Scala 编写的开源消息代理。最初由 LinkedIn 开发,但它于 2011 年作为开源发布,目前由 Apache 软件基金会维护。
以下是你可能更倾向于使用 Kafka 而不是传统 JMS 消息代理的一些原因:
-
它很快:单个运行在普通硬件上的 Kafka 代理能够处理来自成千上万客户端的每秒数百兆字节的读写操作
-
出色的可扩展性:可以轻松且透明地进行扩展,且不会产生停机时间
-
持久性与复制:消息会被持久化存储在磁盘上,并在集群内进行复制,以防止数据丢失(通过设置适当的配置参数,你可以实现零数据丢失)
-
性能:每个代理能够处理数 TB 的消息而不会影响性能
-
它支持实时流处理
-
它可以轻松与其他流行的开源大数据架构系统(如 Hadoop、Spark 和 Storm)进行集成
以下是你应该熟悉的 Kafka 核心概念:
-
主题:这些是发布即将到来的消息的类别或源名称
-
生产者:任何发布消息到主题的实体
-
消费者:任何订阅主题并从中消费消息的实体
-
Broker:处理读写操作的服务
下图展示了典型的 Kafka 集群架构:
图 4.3:Kafka 架构
Kafka 在后台使用 ZooKeeper (zookeeper.apache.org/
) 来保持其节点的同步。Kafka 提供了 ZooKeeper,因此如果主机没有安装 ZooKeeper,可以使用随 Kafka 捆绑提供的 ZooKeeper。客户端和服务器之间的通信通过一种高性能、语言无关的 TCP 协议进行。
Kafka 的典型使用场景如下:
-
消息传递
-
流处理
-
日志聚合
-
指标
-
网站活动跟踪
-
事件溯源
Spark Streaming 和 Kafka
要将 Spark Streaming 与 Kafka 配合使用,您可以做两件事:要么使用接收器,要么直接操作。第一种选择类似于从其他来源(如文本文件和套接字)进行流式传输——从 Kafka 接收到的数据会存储在 Spark 执行器中,并通过 Spark Streaming 上下文启动的作业进行处理。这不是最佳方法——如果发生故障,可能会导致数据丢失。这意味着,直接方式(在 Spark 1.3 中引入)更好。它不是使用接收器来接收数据,而是定期查询 Kafka 以获取每个主题和分区的最新偏移量,并相应地定义每个批次处理的偏移量范围。当处理数据的作业执行时,Kafka 的简单消费者 API 会被用来读取定义的偏移量范围(几乎与从文件系统读取文件的方式相同)。直接方式带来了以下优点:
-
简化的并行性:不需要创建多个输入 Kafka 流然后努力将它们统一起来。Spark Streaming 会根据 Kafka 分区的数量创建相应数量的 RDD 分区,这些分区会并行地从 Kafka 读取数据。这意味着 Kafka 和 RDD 分区之间是 1:1 映射,易于理解和调整。
-
提高效率:按照接收器方式,为了实现零数据丢失,我们需要将数据存储在 WAL 中。然而,这种策略效率低,因为数据实际上被 Kafka 和 WAL 各自复制了一次。在直接方式中,没有接收器,因此也不需要 WAL——消息可以从 Kafka 中恢复,只要 Kafka 保留足够的时间。
-
精确一次语义:接收器方法使用 Kafka 的高级 API 将消费的偏移量存储在 ZooKeeper 中。尽管这种方法(结合 WAL)可以确保零数据丢失,但在发生故障时,某些记录可能会被重复消费,这有一定的可能性。数据被 Spark Streaming 可靠接收和 ZooKeeper 跟踪的偏移量之间的不一致导致了这一点。采用直接方法时,简单的 Kafka API 不使用 ZooKeeper——偏移量由 Spark Streaming 本身在其检查点内进行跟踪。这确保了即使在发生故障时,每条记录也能被 Spark Streaming 确切地接收一次。
直接方法的一个缺点是它不更新 ZooKeeper 中的偏移量——这意味着基于 ZooKeeper 的 Kafka 监控工具将不会显示任何进度。
现在,让我们实现一个简单的 Scala 示例——一个 Kafka 直接词频统计。该示例适用于 Kafka 版本 0.10.0.0 或更高版本。首先要做的是将所需的依赖项(Spark Core、Spark Streaming 和 Spark Streaming Kafka)添加到项目中:
groupId = org.apache.spark
artifactId = spark-core_2.11
version = 2.2.1
groupId = org.apache.spark
artifactId = spark-streaming_2.11
version = 2.2.1
groupId = org.apache.spark
artifactId = spark-streaming-kafka-0-10_2.11
version = 2.2.1
此应用程序需要两个参数:
-
一个以逗号分隔的 Kafka 经纪人列表
-
一个以逗号分隔的 Kafka 主题列表,用于消费:
val Array(brokers, topics) = args
我们需要创建 Spark Streaming 上下文。让我们选择一个 5
秒的批次间隔:
val sparkConf = new SparkConf().setAppName("DirectKafkaWordCount").setMaster(master)
val ssc = new StreamingContext(sparkConf, Seconds(5))
现在,让我们创建一个包含给定经纪人和主题的直接 Kafka 流:
val topicsSet = topics.split(",").toSet
val kafkaParams = MapString, String
val messages = KafkaUtils.createDirectStreamString, String, StringDecoder, StringDecoder
我们现在可以实现词频统计,也就是从流中获取行,将其拆分成单词,统计单词数量,然后打印:
val lines = messages.map(_._2)
val words = lines.flatMap(_.split(" "))
val wordCounts = words.map(x => (x, 1L)).reduceByKey(_ + _)
wordCounts.print()
最后,让我们启动计算并保持其运行,等待终止信号:
ssc.start()
ssc.awaitTermination()
要运行此示例,首先需要启动一个 Kafka 集群并创建一个主题。Kafka 的二进制文件可以从官方网站下载(kafka.apache.org/downloads
)。下载完成后,我们可以按照以下指示操作。
首先启动一个 zookeeper
节点:
$KAFKA_HOME/bin/zookeeper-server-start.sh $KAFKA_HOME/config/zookeeper.properties
它将开始监听默认端口,2181
。
然后,启动一个 Kafka 经纪人:
$KAFKA_HOME/bin/kafka-server-start.sh $KAFKA_HOME/config/server.properties
它将开始监听默认端口,9092
。
创建一个名为 packttopic
的主题:
$KAFKA_HOME/bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic packttopic
检查主题是否已成功创建:
$KAFKA_HOME/bin/kafka-topics.sh --list --zookeeper localhost:2181
主题名称 packttopic
应该出现在打印到控制台输出的列表中。
我们现在可以开始为新主题生成消息了。让我们启动一个命令行生产者:
$KAFKA_HOME/bin/kafka-console-producer.sh --broker-list localhost:9092 --topic packttopic
在这里,我们可以向生产者控制台写入一些消息:
First message
Second message
Third message
Yet another message for the message consumer
让我们构建 Spark 应用程序,并通过 $SPARK_HOME/bin/spark-submit
命令执行,指定 JAR 文件名、Spark 主 URL、作业名称、主类名称、每个执行器使用的最大内存和作业参数(localhost:9092
和 packttopic
)。
每条被 Spark 作业消费的消息行输出将类似于以下内容:
-------------------------------------------
Time: 1527457655000 ms
-------------------------------------------
(consumer,1)
(Yet,1)
(another,1)
(message,2)
(for,1)
(the,1)
使用 DL4J 和 Spark 流式处理数据
在本节中,我们将应用 Kafka 和 Spark 进行数据流处理,以 DL4J 应用程序的使用情况场景为例。我们将使用的 DL4J 模块是 DataVec。
让我们考虑我们在Spark Streaming 和 Kafka部分中提出的示例。我们想要实现的是使用 Spark 进行直接 Kafka 流,并在数据到达后立即对其应用 DataVec 转换,然后在下游使用它。
让我们首先定义输入模式。这是我们从 Kafka 主题消费的消息所期望的模式。该模式结构与经典的Iris
数据集(en.wikipedia.org/wiki/Iris_flower_data_set
)相同:
val inputDataSchema = new Schema.Builder()
.addColumnsDouble("Sepal length", "Sepal width", "Petal length", "Petal width")
.addColumnInteger("Species")
.build
让我们对其进行转换(我们将删除花瓣字段,因为我们将基于萼片特征进行一些分析):
val tp = new TransformProcess.Builder(inputDataSchema)
.removeColumns("Petal length", "Petal width")
.build
现在,我们可以生成新的模式(在对数据应用转换之后):
val outputSchema = tp.getFinalSchema
此 Scala 应用程序的下一部分与Spark Streaming 和 Kafka部分中的示例完全相同。在这里,创建一个流上下文,使用5
秒的批处理间隔和直接的 Kafka 流:
val sparkConf = new SparkConf().setAppName("DirectKafkaDataVec").setMaster(master)
val ssc = new StreamingContext(sparkConf, Seconds(5))
val topicsSet = topics.split(",").toSet
val kafkaParams = MapString, String
val messages = KafkaUtils.createDirectStreamString, String, StringDecoder, StringDecoder
让我们获取输入行:
val lines = messages.map(_._2)
lines
是一个DStream[String]
。我们需要对每个 RDD 进行迭代,将其转换为javaRdd
(DataVec 读取器所需),使用 DataVec 的CSVRecordReader
,解析传入的逗号分隔消息,应用模式转换,并打印结果数据:
lines.foreachRDD { rdd =>
val javaRdd = rdd.toJavaRDD()
val rr = new CSVRecordReader
val parsedInputData = javaRdd.map(new StringToWritablesFunction(rr))
if(!parsedInputData.isEmpty()) {
val processedData = SparkTransformExecutor.execute(parsedInputData, tp)
val processedAsString = processedData.map(new WritablesToStringFunction(","))
val processedCollected = processedAsString.collect
val inputDataCollected = javaRdd.collect
println("\n\n---- Original Data ----")
for (s <- inputDataCollected.asScala) println(s)
println("\n\n---- Processed Data ----")
for (s <- processedCollected.asScala) println(s)
}
}
最后,我们启动流上下文并保持其活动状态,等待终止信号:
ssc.start()
ssc.awaitTermination()
要运行此示例,我们需要启动一个 Kafka 集群,并创建一个名为csvtopic
的新主题。步骤与Spark Streaming 和 Kafka部分描述的示例相同。主题创建完成后,我们可以开始在其上生产逗号分隔的消息。让我们启动一个命令行生产者:
$KAFKA_HOME/bin/kafka-console-producer.sh --broker-list localhost:9092 --topic csvtopic
现在,我们可以将一些消息写入生产者控制台:
5.1,3.5,1.4,0.2,0
4.9,3.0,1.4,0.2,0
4.7,3.2,1.3,0.2,0
4.6,3.1,1.5,0.2,0
让我们构建 Spark 应用程序,并通过$SPARK_HOME/bin/spark-submit
命令执行它,指定 JAR 文件名、Spark 主 URL、作业名称、主类名、每个执行程序可使用的最大内存以及作业参数(localhost:9092
和csvtopic
)。
每消费一行消息后,Spark 作业打印的输出将类似于以下内容:
4.6,3.1,1.5,0.2,0
---- Processed Data ----
4.6,3.1,0
此示例的完整代码可以在本书捆绑的源代码中找到,链接为github.com/PacktPublishing/Hands-On-Deep-Learning-with-Apache-Spark
。
概要
为了完成我们在第三章中探索之后在训练、评估和运行深度学习模型时的数据摄入可能性的概述,提取、转换、加载,在本章中,我们探讨了在执行数据流处理时可用的不同选项。
本章总结了对 Apache Spark 特性的探讨。从下一章开始,重点将转向 DL4J 和其他一些深度学习框架的特性。这些特性将在不同的应用场景中使用,并将在 Spark 上进行实现。
第五章:卷积神经网络
在第二章《深度学习基础》中,我们学习了关于卷积神经网络(CNN)的一个非常高层次的概述。在这一章,我们将深入了解这种类型的 CNN,更加详细地探讨它们各层的可能实现,并且我们将开始通过 DeepLearning4j 框架动手实现 CNN。本章最后也会涉及到使用 Apache Spark 的示例。CNN 的训练与评估策略将在第七章《用 Spark 训练神经网络》、第八章《监控与调试神经网络训练》以及第九章《解释神经网络输出》中讲解。在不同层的描述中,我尽量减少了数学概念和公式的使用,以便让没有数学或数据科学背景的开发者和数据分析师能够更容易地阅读和理解。因此,你会看到更多关于 Scala 代码实现的内容。
本章涵盖以下主题:
-
卷积层
-
池化层
-
全连接层
-
权重
-
GoogleNet Inception V3 模型
-
动手实践 CNN 与 Spark
卷积层
由于卷积神经网络(CNN)部分已经在第二章《深度学习基础》中讲解过,你应该知道 CNN 通常在哪些场景下使用。在该章节中,我们提到过同一 CNN 的每一层可以有不同的实现方式。本章的前三部分详细描述了可能的层实现,从卷积层开始。但首先,让我们回顾一下 CNN 如何感知图像的过程。CNN 将图像视为体积(3D 物体),而非二维画布(仅有宽度和高度)。原因如下:数字彩色图像采用红-蓝-绿(RGB)编码,正是这些颜色的混合产生了人眼能够感知的光谱。这也意味着 CNN 将图像作为三个颜色层分别处理,层与层之间是叠加的。这转化为以矩形框的形式接收彩色图像,宽度和高度可以用像素来度量,且有三个层(称为通道)的深度,每个通道对应一个 RGB 颜色。简而言之,输入图像被 CNN 看作一个多维数组。我们来举个实际的例子。如果我们考虑一个 480 x 480 的图像,网络会将其看作一个 480 x 480 x 3 的数组,其中每个元素的值在 0 到 255 之间。这些值描述了图像某一点的像素强度。这里是人眼与机器之间的主要区别:这些数组值是机器唯一的输入。接收到这些数值作为输入的计算机输出将是其他数字,描述图像属于某个类别的概率。CNN 的第一层总是卷积层。假设输入是一个 32 x 32 x 3 的像素值数组,我们试着想象一个具体的可视化,清楚简洁地解释卷积层的作用。我们可以将其想象为一个手电筒照射在图像的左上部分。
下图展示了手电筒的照射范围,覆盖了一个 5 x 5 的区域:
图 5.1:5 x 5 滤波器
然后,虚拟的滤光器开始滑动覆盖图像的其他区域。适当的术语是滤波器(或神经元或卷积核),而被照亮的图像区域被称为感受野。用数学术语来说,滤波器是一个数字数组(称为权重或参数)。滤波器的深度必须与输入的深度匹配。参考本节的示例,我们有一个维度为 5 x 5 x 3 的滤波器。滤波器覆盖的第一个位置(如前面图示所示)是输入图像的左上角。当滤波器在图像上滑动或进行卷积(来自拉丁动词convolvere,意为包裹)时,它会将其值与原始图像像素值相乘。所有的乘法结果会相加(在我们的示例中,总共有 75 次乘法)。最终的结果是一个数字,表示滤波器仅位于输入图像的左上角时的值。这个过程会在输入图像的每个位置重复。与第一次一样,每个唯一的位置都会产生一个数字。一旦滤波器完成在图像所有位置上的滑动过程,结果将是一个 28 x 28 x 1(假设输入图像为 32 x 32,5 x 5 的滤波器可以适应 784 个不同的位置)的数字数组,称为激活图(或特征图)。
池化层
在实践中(正如你将在本章的代码示例以及第七章《使用 Spark 训练神经网络》中看到的那样),通常会在 CNN 模型的连续卷积层之间定期插入池化层。这种层的作用是逐步减少网络的参数数量(这意味着显著降低计算成本)。事实上,空间池化(在文献中也被称为下采样或子采样)是一种减少每个特征图维度的技术,同时保留最重要的信息。存在不同类型的空间池化。最常用的是最大池化、平均池化、求和池化和 L2 范数池化。
以最大池化为例,这种技术需要定义一个空间邻域(通常是一个 2 × 2 的窗口);然后从经过修正的特征图中取出该窗口内的最大元素。平均池化策略则要求取窗口内所有元素的平均值或和。一些论文和实际应用表明,最大池化已经证明能够比其他空间池化技术产生更好的结果。
下图展示了最大池化操作的一个示例(这里使用了一个 2 × 2 的窗口):
图 5.2:使用 2 × 2 窗口的最大池化操作
全连接层
全连接层是卷积神经网络(CNN)的最后一层。全连接层在给定输入数据的情况下,输出一个多维向量。输出向量的维度与特定问题的类别数相匹配。
本章及本书中的其他章节展示了一些 CNN 实现和训练的例子,用于数字分类。在这些情况下,输出向量的维度为 10(可能的数字是 0 到 9)。10 维输出向量中的每个数字表示某个类别(数字)的概率。以下是一个用于数字分类推断的输出向量:
[0 0 0 .1 .75 .1 .05 0 0 0]
我们如何解读这些值?网络告诉我们,它认为输入图像是一个四,概率为 75%(在本例中是最高的),同时图像是三的概率为 10%,图像是五的概率为 10%,图像是六的概率为 5%。全连接层会查看同一网络中前一层的输出,并确定哪些特征与某一特定类别最相关。
不仅仅是在数字分类中发生这种情况。在图像分类的一个通用使用案例中,如果一个使用动物图像训练的模型预测输入图像是例如马,它将在表示特定高级特征的激活图中具有较高的值,比如四条腿或尾巴,仅举几个例子。类似地,如果该模型预测图像是另一种动物,比如鱼,它将在表示特定高级特征的激活图中具有较高的值,比如鳍或鳃。我们可以说,全连接层会查看与某一特定类别最相关的高级特征,并拥有特定的权重:这确保了在计算了权重与前一层的乘积后,能够获得每个不同类别的正确概率。
权重
CNN 在卷积层中共享权重。这意味着在一层中的每个感受野使用相同的滤波器,并且这些复制的单元共享相同的参数(权重向量和偏置),并形成一个特征图。
以下图示展示了一个网络中属于同一特征图的三个隐藏单元:
图 5.3:隐藏单元
前述图中较深灰色的权重是共享且相同的。这种复制使得无论特征在视觉场景中的位置如何,都能够进行特征检测。权重共享的另一个结果是:学习过程的效率通过大幅减少需要学习的自由参数数量得到显著提高。
GoogleNet Inception V3 模型
作为卷积神经网络(CNN)的具体实现,在这一部分,我将介绍 Google 的 GoogleNet 架构(ai.google/research/pubs/pub43022
)及其 Inception 层。该架构已在ImageNet 大规模视觉识别挑战赛 2014(ILSVRC2014,www.image-net.org/challenges/LSVRC/2014/
)上展示。无需多说,它赢得了那场比赛。这个实现的显著特点如下:增加了深度和宽度,同时保持了恒定的计算预算。提高计算资源的利用率是网络设计的一部分。
下面的图表总结了在该上下文中提出的网络实现的所有层:
图 5.4:GoogleNet 层
该网络有 22 层参数(不包括池化层;如果包括池化层,总共有 27 层),其参数数量几乎是过去几届同一比赛获胜架构的 12 分之一。这个网络的设计考虑了计算效率和实用性,使得推理过程也能够在有限资源的单个设备上运行,尤其是那些内存占用较低的设备。所有卷积层都使用修正线性单元(ReLU)激活函数。感受野的大小为 224 × 224,使用的是 RGB 颜色空间(均值为零)。通过前面的图表中的表格来看,#3 × 3和#5 × 5的减少数量是位于 3 × 3 和 5 × 5 卷积层之前的 1 × 1 滤波器数量。这些减少层的激活函数同样是 ReLU。
在user-images.githubusercontent.com/32988039/33234276-86fa05fc-d1e9-11e7-941e-b3e62771716f.png
中的示意图展示了网络的结构。
在这种架构中,来自前一层的每个单元都对应输入图像的一个区域——这些单元被分组到滤波器组中。在接近输入的层中,相关的单元集中在局部区域。这导致许多聚集在单一区域的簇,因此可以通过下一个层中的 1 × 1 卷积来覆盖它们。然而,也可能有较少的、更空间分散的簇,由较大块的卷积覆盖,而且随着区域增大,块的数量会减少。为了防止这些补丁对齐问题,inception 架构的实现被限制为只能使用 1 × 1、3 × 3 和 5 × 5 滤波器。建议的架构是将多个层的输出滤波器组聚合为一个单一的输出向量,这个向量代表了下一阶段的输入。此外,在每个阶段并行添加一个替代的池化路径可能会有进一步的有益效果:
图 5.5:简单版本的 inception 模块
从前面的图中可以看出,就计算成本而言,对于具有大量滤波器的层,5 × 5 卷积可能太昂贵(即使卷积数量不多)。当然,随着添加更多池化单元,这个问题会变得更严重,因为输出滤波器的数量等于前一阶段的滤波器数量。显然,将池化层的输出与卷积层的输出合并,可能不可避免地导致越来越多的输出从一个阶段传递到下一个阶段。因此,提出了 inception 架构的第二个、更具计算效率的想法。这个新想法是在计算需求可能增加过多的地方进行维度降低。但需要注意的是:低维嵌入可能包含大量关于大图块的信息,但它们以压缩形式表示这些信息,这使得处理起来变得困难。因此,一个好的折衷方法是保持表示尽可能稀疏,同时仅在真正需要大量聚合信号时,才对信号进行压缩。为此,在进行任何昂贵的 3 × 3 和 5 × 5 卷积之前,使用 1 × 1 卷积 来进行维度降低。
下图展示了考虑到上述问题后的新模块:
图 5.6:具有维度减少的 inception 模块
使用 Spark 实现的 CNN
在本章前面的部分,我们已经讨论了 CNN 的理论和 GoogleNet 架构。如果这是你第一次阅读这些概念,可能会对实现 CNN 模型、训练和评估时 Scala 代码的复杂性感到困惑。通过采用像 DL4J 这样的高层次框架,你将发现它自带了许多功能,且实现过程比预期的更简单。
在本节中,我们将通过使用 DL4J 和 Spark 框架,探索 CNN 配置和训练的真实示例。所使用的训练数据来自MNIST
数据库(yann.lecun.com/exdb/mnist/
)。它包含手写数字的图像,每张图像都由一个整数进行标记。该数据库用于评估 ML 和 DL 算法的性能。它包含 60,000 个训练样本和 10,000 个测试样本。训练集用于教算法预测正确的标签,即整数,而测试集则用于检查训练后的网络在进行预测时的准确性。
对于我们的示例,我们下载并在本地解压 MNIST
数据。会创建一个名为 mnist_png
的目录,它包含两个子目录:training
,其中包含训练数据,以及 testing
,其中包含评估数据。
我们先只使用 DL4J(稍后会将 Spark 添加到堆栈中)。我们需要做的第一件事是将训练数据向量化。我们使用 ImageRecordReader
(deeplearning4j.org/datavecdoc/org/datavec/image/recordreader/ImageRecordReader.html
) 作为读取器,因为训练数据是图像,而使用 RecordReaderDataSetIterator
(javadox.com/org.deeplearning4j/deeplearning4j-core/0.4-rc3.6/org/deeplearning4j/datasets/canova/RecordReaderDataSetIterator.html
) 来遍历数据集,方法如下:
val trainData = new ClassPathResource("/mnist_png/training").getFile
val trainSplit = new FileSplit(trainData, NativeImageLoader.ALLOWED_FORMATS, randNumGen)
val labelMaker = new ParentPathLabelGenerator(); // parent path as the image label
val trainRR = new ImageRecordReader(height, width, channels, labelMaker)
trainRR.initialize(trainSplit)
val trainIter = new RecordReaderDataSetIterator(trainRR, batchSize, 1, outputNum)
让我们对像素值进行最小-最大缩放,将其从 0-255 缩放到 0-1,方法如下:
val scaler = new ImagePreProcessingScaler(0, 1)
scaler.fit(trainIter)
trainIter.setPreProcessor(scaler)
对测试数据也需要进行相同的向量化处理。
让我们按照以下方式配置网络:
val channels = 1
val outputNum = 10
val conf = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(iterations)
.regularization(true)
.l2(0.0005)
.learningRate(.01)
.weightInit(WeightInit.XAVIER)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.updater(Updater.NESTEROVS)
.momentum(0.9)
.list
.layer(0, new ConvolutionLayer.Builder(5, 5)
.nIn(channels)
.stride(1, 1)
.nOut(20)
.activation(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(Activation.IDENTITY)
.build)
.layer(3, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build)
.layer(4, new DenseLayer.Builder()
.activation(Activation.RELU)
.nOut(500)
.build)
.layer(5, new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.nOut(outputNum)
.activation(Activation.SOFTMAX).build)
.setInputType(InputType.convolutionalFlat(28, 28, 1))
.backprop(true).pretrain(false).build
然后,可以使用生成的 MultiLayerConfiguration
对象 (deeplearning4j.org/doc/org/deeplearning4j/nn/conf/MultiLayerConfiguration.html
) 来初始化模型 (deeplearning4j.org/doc/org/deeplearning4j/nn/multilayer/MultiLayerNetwork.html
),方法如下:
val model: MultiLayerNetwork = new MultiLayerNetwork(conf)
model.init()
我们现在可以训练(和评估)模型,方法如下:
model.setListeners(new ScoreIterationListener(1))
for (i <- 0 until nEpochs) {
model.fit(trainIter)
println("*** Completed epoch {} ***", i)
...
}
现在让我们将 Apache Spark 引入其中。通过 Spark,可以在集群的多个节点上并行化内存中的训练和评估过程。
和往常一样,首先创建 Spark 上下文,如下所示:
val sparkConf = new SparkConf
sparkConf.setMaster(master)
.setAppName("DL4J Spark MNIST Example")
val sc = new JavaSparkContext(sparkConf)
然后,在将训练数据向量化后,通过 Spark 上下文将其并行化,如下所示:
val trainDataList = mutable.ArrayBuffer.empty[DataSet]
while (trainIter.hasNext) {
trainDataList += trainIter.next
}
val paralleltrainData = sc.parallelize(trainDataList)
测试数据也需要进行相同的处理。
配置和初始化模型后,您可以按如下方式配置 Spark 进行训练:
var batchSizePerWorker: Int = 16
val tm = new ParameterAveragingTrainingMaster.Builder(batchSizePerWorker)
.averagingFrequency(5)
.workerPrefetchNumBatches(2)
.batchSizePerWorker(batchSizePerWorker)
.build
创建 Spark 网络,如下所示:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
最后,用以下代码替换之前的训练代码:
var numEpochs: Int = 15
var i: Int = 0
for (i <- 0 until numEpochs) {
sparkNet.fit(paralleltrainData)
println("Completed Epoch {}", i)
}
完成后,不要忘记删除临时训练文件,如下所示:
tm.deleteTempFiles(sc)
完整示例是书籍随附的源代码的一部分。
总结
本章中,我们首先深入了解了 CNN 的主要概念,并探索了 Google 提供的 CNN 架构中最流行和表现最好的一个例子。接着,我们开始使用 DL4J 和 Spark 实现一些代码。
在下一章,我们将沿着类似的路线更深入地探讨 RNN。
第六章:循环神经网络
在本章中,我们将进一步了解循环神经网络(RNNs),它们最常见的应用场景概述,以及最后通过使用 DeepLearning4j 框架进行实际操作的可能实现。本章的代码示例还涉及到 Apache Spark。如同前一章关于 CNNs 的内容所述,RNN 的训练和评估策略将在第七章,使用 Spark 训练神经网络,第八章,监控与调试神经网络训练,以及第九章,解释神经网络输出中详细介绍。
在本章中,我尽量减少了数学概念和公式的使用,以便让没有数学或数据科学背景的开发人员和数据分析师能够更容易地阅读和理解。
本章涵盖以下主题:
-
长短期记忆(LSTM)
-
应用场景
-
实战 RNN 与 Spark
LSTM
RNN 是多层神经网络,用于识别数据序列中的模式。这里的数据序列可以是文本、手写字、数字时间序列(例如来自传感器的数据)、日志条目等。涉及的算法也具有时间维度:它们会考虑时间(这与 CNN 的主要区别)和序列。为了更好地理解为什么需要 RNN,我们首先要看一下前馈网络的基础。与 RNN 类似,这些网络通过一系列数学操作在网络的节点上处理信息,但它们是将信息直接传递,且每个节点不会被重复访问。网络接收输入示例,然后将其转化为输出:简而言之,它们将原始数据映射到类别。训练过程发生在有标签的输入上,直到猜测输入类别时所犯的错误最小化。这是网络学习如何对它从未见过的新数据进行分类的方式。前馈网络没有时间顺序的概念:它仅考虑当前输入,并不一定会改变它如何分类下一个输入。RNN 则接收当前示例以及它之前感知到的任何信息作为输入。可以将 RNN 视为多个前馈神经网络,将信息从一个网络传递到另一个网络。
在 RNN 的应用场景中,一个序列可能是有限的或无限的、相互依赖的数据流。CNN 在这些情况下表现不好,因为它们没有考虑前一个和下一个输入之间的相关性。根据第五章,你已经了解到,CNN 接收输入并根据训练的模型进行输出。对于给定数量的不同输入,任何一个都不会受到之前输出的影响。但如果考虑到本章最后几节提出的情况(一个句子生成的案例),其中所有生成的单词都依赖于之前生成的单词,那么就一定需要根据之前的输出进行偏置。这时,RNN 就派上用场了,因为它们能记住数据序列中之前发生的事情,这有助于它们获取上下文。理论上,RNN 可以无限回顾所有前一步骤,但实际上,出于性能考虑,它们只能回顾最后几步。
让我们深入了解 RNN 的细节。为了进行这个解释,我将从多层感知机(MLP)开始,它是一个前馈人工神经网络(ANN)类别。MLP 的最小实现至少有三层节点。但对于输入节点,每个节点是一个使用非线性激活函数的神经元。输入层当然是接收输入的部分。第一个隐藏层进行激活操作,将信息传递给下一个隐藏层,以此类推。最终,信息到达输出层,负责提供输出。所有隐藏层的行为不同,因为每一层都有不同的权重、偏置和激活函数。为了使这些层能够合并并简化这一过程,所有层需要替换为相同的权重(以及相同的偏置和激活函数)。这是将所有隐藏层合并成一个单一的循环层的唯一方法。它们开始看起来如下图所示。
图 6.1
根据前面的图示,网络H接收一些输入x并产生输出o。信息通过循环机制从网络的一个步骤传递到下一个步骤。在每个步骤中,输入会被提供给网络的隐藏层。RNN 的任何神经元都会存储它在所有前一步骤中接收到的输入,然后可以将这些信息与当前步骤传递给它的输入进行合并。这意味着在时间步t-1做出的决策会影响在时间步t做出的决策。
让我们用一个例子重新表述前面的解释:假设我们想预测在一系列字母之后,接下来的字母是什么。假设输入的单词是 pizza,它由五个字母组成。当网络尝试推测第五个字母时,会发生什么呢?前四个字母已经输入到网络中。对于隐藏层来说,会进行五次迭代。如果我们展开网络,它将变成一个五层网络,每一层对应输入单词的一个字母(参考 第二章,深度学习基础,图 2.11)。我们可以将它看作是一个重复多次(5)的普通神经网络。展开的次数与网络能够记住多远的过去有直接关系。回到 pizza 的例子,输入数据的词汇表是 {p, i, z, a}。隐藏层或 RNN 会对当前输入和前一个状态应用一个公式。在我们的例子中,单词 pizza 中的字母 p 作为第一个字母,它前面没有任何字母,所以什么也不做,然后我们可以继续处理下一个字母 i。在字母 i 和前一个状态(字母 p)之间,隐藏层应用公式。如果在某个时刻 t,输入是 i,那么在时刻 t-1,输入是 p。通过对 p 和 i 应用公式,我们得到一个新的状态。计算当前状态的公式可以写成如下:
h[t] = f(h[t-1], x[t])
其中 h[t] 是新的状态,h[t-1] 是前一个状态,x[t] 是当前输入。从之前的公式可以理解,当前状态是前一个输入的函数(输入神经元对前一个输入进行了变换)。任何连续的输入都会作为时间步长。在这个 pizza 的例子中,我们有四个输入进入网络。在每个时间步长,都会应用相同的函数和相同的权重。考虑到 RNN 的最简单实现,激活函数是 tanh,即双曲正切函数,其值范围在 -1 到 1 之间,这是 MLP 中最常见的 S 型激活函数之一。因此,公式如下:
h[t] = tanh(W[hh]h[t-1] + W[xh]x[t])
这里 W[hh] 是递归神经元的权重,W[xh] 是输入神经元的权重。这个公式意味着递归神经元会考虑到前一个状态。当然,前面的公式可以在更长的序列情况下涉及多个状态,而不仅仅是 pizza。一旦计算出最终状态,就可以通过以下方式获得输出 y[t]:
y[t] = W[hy]h[t]
关于误差的最后一点说明。误差通过将输出与实际输出进行比较来计算。一旦计算出误差,就通过反向传播将其传播到网络中,以更新网络的权重。
反向传播通过时间(BPTT)
为 RNN 提出了多种变体架构(其中一些已在第二章,深度学习基础,循环神经网络一节中列出)。在详细介绍 LSTM 实现之前,需要先简要讨论一下之前描述的通用 RNN 架构的问题。对于神经网络,一般使用前向传播技术来获得模型的输出并检查其是否正确。同样,反向传播是一种通过神经网络向后传播,找出误差对权重的偏导数的技术(这使得可以从权重中减去找到的值)。这些偏导数随后被梯度下降算法使用,梯度下降算法以迭代的方式最小化一个函数,然后对权重进行上下调整(调整的方向取决于哪个方向能减少误差)。在训练过程中,反向传播是调整模型权重的方式。BPTT 只是定义在展开的 RNN 上执行反向传播过程的一种方法。参考第二章,深度学习基础,图 2.11,在执行 BPTT 时,必须进行展开的公式化,即某一时间步的误差依赖于前一个时间步。在 BPTT 技术中,误差是从最后一个时间步反向传播到第一个时间步,同时展开所有时间步。这使得可以为每个时间步计算误差,从而更新权重。请注意,在时间步数较多的情况下,BPTT 可能会计算非常耗时。
RNN 问题
影响 RNN 的两个主要问题是梯度爆炸和梯度消失。当算法在没有理由的情况下给模型权重赋予过高的重要性时,我们称之为梯度爆炸。但解决这个问题的方法很简单,只需要截断或压缩梯度即可。我们称之为梯度消失,是指梯度的值非常小,以至于它导致模型停止学习或学习速度过慢。如果与梯度爆炸相比,这是一个主要问题,但现在已经通过LSTM(长短期记忆)神经网络得到了解决。LSTM 是一种特殊类型的 RNN,能够学习长期依赖关系,1997 年由 Sepp Hochreiter(en.wikipedia.org/wiki/Sepp_Hochreiter
)和 Juergen Schmidhuber(en.wikipedia.org/wiki/J%C3%BCrgen_Schmidhuber
)提出。
它们明确设计为具有默认的长期记忆能力。之所以能够实现这一点,是因为 LSTM 会在一个内存中保持信息,这个内存的功能类似于计算机的内存:LSTM 可以从中读取、写入和删除信息。LSTM 的内存可以被视为一个带门单元:它决定是否存储或删除信息(是否打开门),这取决于它对给定信息的重要性赋予了多少权重。赋予重要性的过程通过权重进行:因此,网络随着时间的推移学习哪些信息需要被认为是重要的,哪些不重要。LSTM 有三个门:输入门、遗忘门和输出门。输入门决定是否让新输入进入,遗忘门删除不重要的信息,输出门影响网络当前时间步的输出,如下图所示:
图 6.2:LSTM 的三个门
你可以将这三个门看作是传统的人工神经元,就像在前馈神经网络(MNN)中一样:它们计算一个加权和的激活(使用激活函数)。使得 LSTM 门能够进行反向传播的原因在于它们是模拟的(sigmoid 函数,其范围从零到一)。这种实现解决了梯度消失的问题,因为它保持了足够陡峭的梯度,从而使得训练能够在相对较短的时间内完成,同时保持较高的准确性。
使用案例
RNN 有多个使用场景。以下是最常见的几种:
-
语言建模与文本生成:这是一种尝试,根据一系列单词预测下一个单词的概率。这对于语言翻译非常有用:最有可能的句子通常是正确的句子。
-
机器翻译:这是一种尝试将文本从一种语言翻译成另一种语言的方法。
-
时间序列中的异常检测:研究表明,特别是 LSTM 网络非常适合学习包含未知长度的长期模式的序列,因为它们能够保持长期记忆。由于这一特性,它们在时间序列中的异常或故障检测中非常有用。实际应用案例包括日志分析和传感器数据分析。
-
语音识别:这是一种基于输入声波预测语音片段,然后形成单词的尝试。
-
语义解析:将自然语言表达转换为逻辑形式——一种机器可理解的意义表示。实际应用包括问答系统和编程语言代码生成。
-
图像描述:这通常涉及 CNN 和 RNN 的组合。CNN 进行图像分割,RNN 则利用 CNN 分割后的数据来重建描述。
-
视频标注:RNN 可以用于视频搜索,当进行逐帧视频图像说明时,RNN 可以发挥作用。
-
图像生成:这是一个将场景的各部分独立生成并逐步改进大致草图的过程,最终生成的图像在肉眼下无法与真实数据区分。
使用 Spark 动手实践 RNN
现在让我们开始动手使用 RNN。本节分为两部分——第一部分是关于使用 DL4J 实现网络,第二部分将介绍使用 DL4J 和 Spark 实现同样目标的方法。与 CNN 一样,借助 DL4J 框架,许多高级功能都可以开箱即用,因此实现过程比你想象的要容易。
使用 DL4J 的 RNN
本章展示的第一个示例是一个 LSTM,经过训练后,当学习字符串的第一个字符作为输入时,它将会复述接下来的字符。
这个示例的依赖项如下:
-
Scala 2.11.8
-
DL4J NN 0.9.1
-
ND4J Native 0.9.1 以及你运行该模型的机器操作系统专用分类器
-
ND4J jblas 0.4-rc3.6
假设我们有一个通过不可变变量LEARNSTRING
指定的学习字符串,接下来我们开始创建一个由它生成的可能字符列表,如下所示:
val LEARNSTRING_CHARS: util.LinkedHashSet[Character] = new util.LinkedHashSet[Character]
for (c <- LEARNSTRING) {
LEARNSTRING_CHARS.add(c)
}
LEARNSTRING_CHARS_LIST.addAll(LEARNSTRING_CHARS)
让我们开始配置网络,如下所示:
val builder: NeuralNetConfiguration.Builder = new NeuralNetConfiguration.Builder
builder.iterations(10)
builder.learningRate(0.001)
builder.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
builder.seed(123)
builder.biasInit(0)
builder.miniBatch(false)
builder.updater(Updater.RMSPROP)
builder.weightInit(WeightInit.XAVIER)
你会注意到,我们正在使用与上一章中 CNN 示例相同的NeuralNetConfiguration.Builder
类。这个抽象类用于任何你需要通过 DL4J 实现的网络。使用的优化算法是随机梯度下降(en.wikipedia.org/wiki/Stochastic_gradient_descent
)。其他参数的意义将在下一章中进行讲解,该章将重点介绍训练过程。
现在让我们定义这个网络的各层。我们实现的模型基于 Alex Graves 的 LSTM RNN(en.wikipedia.org/wiki/Alex_Graves_(computer_scientist)
)。在决定它们的总数并将一个值分配给不可变变量HIDDEN_LAYER_CONT
后,我们可以定义网络的隐藏层,如下所示:
val listBuilder = builder.list
for (i <- 0 until HIDDEN_LAYER_CONT) {
val hiddenLayerBuilder: GravesLSTM.Builder = new GravesLSTM.Builder
hiddenLayerBuilder.nIn(if (i == 0) LEARNSTRING_CHARS.size else HIDDEN_LAYER_WIDTH)
hiddenLayerBuilder.nOut(HIDDEN_LAYER_WIDTH)
hiddenLayerBuilder.activation(Activation.TANH)
listBuilder.layer(i, hiddenLayerBuilder.build)
}
激活函数是tanh
(双曲正切)。
然后我们需要定义outputLayer
(选择 softmax 作为激活函数),如下所示:
val outputLayerBuilder: RnnOutputLayer.Builder = new RnnOutputLayer.Builder(LossFunction.MCXENT)
outputLayerBuilder.activation(Activation.SOFTMAX)
outputLayerBuilder.nIn(HIDDEN_LAYER_WIDTH)
outputLayerBuilder.nOut(LEARNSTRING_CHARS.size)
listBuilder.layer(HIDDEN_LAYER_CONT, outputLayerBuilder.build)
在完成配置之前,我们必须指定该模型没有经过预训练,并且我们使用反向传播,如下所示:
listBuilder.pretrain(false)
listBuilder.backprop(true)
网络(MultiLayerNetwork
)可以从上述配置开始创建,如下所示:
val conf = listBuilder.build
val net = new MultiLayerNetwork(conf)
net.init()
net.setListeners(new ScoreIterationListener(1))
一些训练数据可以通过编程方式从学习字符串字符列表生成,如下所示:
val input = Nd4j.zeros(1, LEARNSTRING_CHARS_LIST.size, LEARNSTRING.length)
val labels = Nd4j.zeros(1, LEARNSTRING_CHARS_LIST.size, LEARNSTRING.length)
var samplePos = 0
for (currentChar <- LEARNSTRING) {
val nextChar = LEARNSTRING((samplePos + 1) % (LEARNSTRING.length))
input.putScalar(ArrayInt, samplePos), 1)
labels.putScalar(ArrayInt, samplePos), 1)
samplePos += 1
}
val trainingData: DataSet = new DataSet(input, labels)
该 RNN 训练的过程将在下一章中介绍(代码示例将在那里完成)——本节的重点是展示如何使用 DL4J API 配置和构建 RNN 网络。
使用 DL4J 和 Spark 进行 RNN 训练
本节中展示的示例是一个 LSTM 模型,它将被训练以一次生成一个字符的文本。训练通过 Spark 进行。
该示例的依赖项如下:
-
Scala 2.11.8
-
DL4J NN 0.9.1
-
ND4J Native 0.9.1 和适用于运行环境操作系统的特定分类器。
-
ND4J jblas 0.4-rc3.6
-
Apache Spark Core 2.11,版本 2.2.1
-
DL4J Spark 2.11,版本 0.9.1_spark_2
我们像往常一样通过 NeuralNetConfiguration.Builder
类开始配置网络,具体如下:
val rng = new Random(12345)
val lstmLayerSize: Int = 200
val tbpttLength: Int = 50
val nSamplesToGenerate: Int = 4
val nCharactersToSample: Int = 300
val generationInitialization: String = null
val conf = new NeuralNetConfiguration.Builder()
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.iterations(1)
.learningRate(0.1)
.rmsDecay(0.95)
.seed(12345)
.regularization(true)
.l2(0.001)
.weightInit(WeightInit.XAVIER)
.updater(Updater.RMSPROP)
.list
.layer(0, new GravesLSTM.Builder().nIn(SparkLSTMCharacterExample.CHAR_TO_INT.size).nOut(lstmLayerSize).activation(Activation.TANH).build())
.layer(1, new GravesLSTM.Builder().nIn(lstmLayerSize).nOut(lstmLayerSize).activation(Activation.TANH).build())
.layer(2, new RnnOutputLayer.Builder(LossFunction.MCXENT).activation(Activation.SOFTMAX)
.nIn(lstmLayerSize).nOut(SparkLSTMCharacterExample.nOut).build) //MCXENT + softmax for classification
.backpropType(BackpropType.TruncatedBPTT).tBPTTForwardLength(tbpttLength).tBPTTBackwardLength(tbpttLength)
.pretrain(false).backprop(true)
.build
关于 RNNs with DL4J 部分中展示的示例,这里使用的 LSTM RNN 实现是 Alex Graves 的版本。所以配置、隐藏层和输出层与前一个示例非常相似。
现在,Spark 开始发挥作用了。让我们设置 Spark 配置和上下文,如下所示:
val sparkConf = new SparkConf
sparkConf.setMaster(master)
sparkConf.setAppName("LSTM Character Example")
val sc = new JavaSparkContext(sparkConf)
假设我们已经获得了一些训练数据,并从中创建了一个名为 trainingData
的 JavaRDD[DataSet]
,我们需要为数据并行训练进行设置。特别是,我们需要设置 TrainingMaster
(deeplearning4j.org/doc/org/deeplearning4j/spark/api/TrainingMaster.html
)。
它是一个抽象,控制学习如何在 Spark 上执行,并允许使用多种不同的训练实现与 SparkDl4jMultiLayer
(deeplearning4j.org/doc/org/deeplearning4j/spark/impl/multilayer/SparkDl4jMultiLayer.html
)一起使用。为数据并行训练设置如下:
val averagingFrequency: Int = 5
val batchSizePerWorker: Int = 8
val examplesPerDataSetObject = 1
val tm = new ParameterAveragingTrainingMaster.Builder(examplesPerDataSetObject)
.workerPrefetchNumBatches(2)
.averagingFrequency(averagingFrequency)
.batchSizePerWorker(batchSizePerWorker)
.build
val sparkNetwork: SparkDl4jMultiLayer = new SparkDl4jMultiLayer(sc, conf, tm)
sparkNetwork.setListeners(Collections.singletonListIterationListener))
目前,DL4J 框架仅实现了一个 TrainingMaster
,即 ParameterAveragingTrainingMaster
(deeplearning4j.org/doc/org/deeplearning4j/spark/impl/paramavg/ParameterAveragingTrainingMaster.html
)。我们在当前示例中为其设置的参数如下:
-
workerPrefetchNumBatches
:能够异步预取的 Spark 工作节点数量;一组 mini-batches(数据集对象),以避免等待数据加载。将该参数设置为0
表示禁用预取。将其设置为2
(如我们的示例中)是一个较好的折中方案(在不过度使用内存的情况下,合理的默认值)。 -
batchSizePerWorker
:这是每个 Spark 工作节点在每次参数更新时使用的示例数量。 -
averagingFrequency
:控制参数平均和重新分发的频率,以batchSizePerWorker
大小的迷你批次数量为单位。设置较低的平均周期可能效率较低,因为网络通信和初始化开销相较于计算较高,而设置较大的平均周期可能导致性能较差。因此,良好的折衷方案是将其值保持在5
到10
之间。
SparkDl4jMultiLayer
需要的参数包括 Spark 上下文、Spark 配置和TrainingMaster
。
现在可以开始通过 Spark 进行训练。训练过程将在下一章中详细介绍(并将在那里完成此代码示例)——本节的重点是展示如何使用 DL4J 和 Spark API 配置和构建 RNN 网络。
加载多个 CSV 用于 RNN 数据管道
在本章结束前,这里有一些关于如何加载多个 CSV 文件的注意事项,每个文件包含一个序列,用于 RNN 训练和测试数据。我们假设有一个由多个 CSV 文件组成的数据集,这些文件存储在集群中(可以是 HDFS 或像 Amazon S3 或 Minio 这样的对象存储),每个文件表示一个序列,文件中的每一行仅包含一个时间步的值,各个文件的行数可能不同,头行可能存在也可能缺失。
参考保存在 S3 基础对象存储中的 CSV 文件(更多细节请参考第三章,提取、转换、加载,从 S3 加载数据),Spark 上下文已如下创建:
val conf = new SparkConf
conf.setMaster(master)
conf.setAppName("DataVec S3 Example")
val sparkContext = new JavaSparkContext(conf)
Spark 作业配置已设置为访问对象存储(如第三章,提取、转换、加载中所述),我们可以如下获取数据:
val origData = sparkContext.binaryFiles("s3a://dl4j-bucket")
(dl4j-bucket
是包含 CSV 文件的存储桶)。接下来,我们创建一个 DataVec CSVSequenceRecordReader
,并指定所有 CSV 文件是否有头行(如果没有头行,使用值0
;如果有头行,使用值1
),以及值分隔符,如下所示:
val numHeaderLinesEachFile = 0
val delimiter = ","
val seqRR = new CSVSequenceRecordReader(numHeaderLinesEachFile, delimiter)
最后,我们通过对seqRR
中的原始数据应用map
转换来获取序列,如下所示:
val sequencesRdd = origData.map(new SequenceRecordReaderFunction(seqRR))
在使用非序列 CSV 文件进行 RNN 训练时也非常相似,使用dl4j-spark
的DataVecDataSetFunction
类并指定标签列的索引和分类的标签数,如下所示:
val labelIndex = 1
val numClasses = 4
val dataSetRdd = sequencesRdd.map(new DataVecSequenceDataSetFunction(labelIndex, numClasses, false))
小结
在本章中,我们首先深入探讨了 RNN 的主要概念,然后了解了这些特定神经网络在许多实际应用中的使用案例,最后,我们开始动手实践,使用 DL4J 和 Spark 实现一些 RNN。
下一章将重点介绍 CNN 和 RNN 模型的训练技巧。训练技巧在第三章中已经提到,或者从提取、转换、加载跳过到本章,因为迄今为止,主要的目标是理解如何获取和准备训练数据,以及如何通过 DL4J 和 Spark 实现模型。
第七章:使用 Spark 训练神经网络
在前两章中,我们学习了如何使用DeepLearning4j(DL4J)API 在 Scala 中编程配置和构建卷积神经网络(CNNs)和递归神经网络(RNNs)。在那里提到了这些网络的训练实现,但并没有提供详细的解释。本章最终详细讲解了如何实现这两种网络的训练策略。本章还解释了为什么 Spark 在训练过程中至关重要,以及从性能角度看,DL4J 的基本作用。
本章的第二和第三部分分别聚焦于 CNN 和 RNN 的具体训练策略。第四部分还提供了关于如何正确配置 Spark 环境的建议、技巧和窍门。最后一部分介绍了如何使用 DL4J 的 Arbiter 组件进行超参数优化。
下面是本章将涵盖内容的总结:
-
使用 Spark 和 DL4J 进行 CNN 分布式训练
-
使用 Spark 和 DL4J 进行 RNN 分布式训练
-
性能考虑事项
-
超参数优化
使用 Spark 和 DeepLearning4j 进行分布式网络训练
多层神经网络(MNNs)的训练计算量大—它涉及庞大的数据集,并且需要尽可能快速地完成训练过程。在第一章《Apache Spark 生态系统》中,我们学习了 Apache Spark 如何在进行大规模数据处理时实现高性能。这使得 Spark 成为执行训练的完美选择,能够充分利用其并行特性。但仅有 Spark 是不够的—尽管 Spark 在 ETL 或流处理方面的性能表现优秀,但在 MNN 训练的计算背景下,一些数据转换或聚合需要使用低级语言(如 C++)来完成。
这时,DL4J 的 ND4J 模块(nd4j.org/index.html
)发挥了作用。无需学习和编程 C++,因为 ND4J 提供了 Scala API,而这些正是我们需要使用的。底层的 C++库对于使用 ND4J 的 Scala 或 Java 开发者是透明的。下面是一个使用 ND4J API 的 Scala 应用程序的简单示例(内联注释解释了代码的功能):
object Nd4JScalaSample {
def main (args: Array[String]) {
// Create arrays using the numpy syntax
var arr1 = Nd4j.create(4)
val arr2 = Nd4j.linspace(1, 10, 10)
// Fill an array with the value 5 (equivalent to fill method in numpy)
println(arr1.assign(5) + "Assigned value of 5 to the array")
// Basic stats methods
println(Nd4j.mean(arr1) + "Calculate mean of array")
println(Nd4j.std(arr2) + "Calculate standard deviation of array")
println(Nd4j.`var`(arr2), "Calculate variance")
...
ND4J 为 JVM 带来了一个开源、分布式、支持 GPU 的直观科学库,填补了 JVM 语言与 Python 程序员之间在强大数据分析工具可用性方面的空白。DL4J 依赖于 Spark 进行并行模型训练。大型数据集被分区,每个分区可供独立的神经网络使用,每个神经网络都在其自己的核心中运行—DL4J 会反复平均它们在中央模型中生成的参数。
为了完整信息,无论训练是否仅要求 DL4J,若在同一服务器上运行多个模型,则应使用 ParallelWrapper
(deeplearning4j.org/api/v1.0.0-beta2/org/deeplearning4j/parallelism/ParallelWrapper.html
)。但请注意,这个过程特别昂贵,服务器必须配备大量的 CPU(至少 64 个)或多个 GPU。
DL4J 提供了以下两个类,用于在 Spark 上训练神经网络:
-
SparkDl4jMultiLayer
(deeplearning4j.org/api/v1.0.0-beta2/org/deeplearning4j/spark/impl/multilayer/SparkDl4jMultiLayer.html
),是MultiLayerNetwork
的封装类(这是在前几章中一些示例中使用的类)。 -
SparkComputationGraph
(deeplearning4j.org/api/v1.0.0-beta2/org/deeplearning4j/spark/impl/graph/SparkComputationGraph.html
),是ComputationGraph
(deeplearning4j.org/api/v1.0.0-beta2/org/deeplearning4j/nn/graph/ComputationGraph.html
) 的封装类,ComputationGraph
是一种具有任意连接结构(DAG)的神经网络,且可以有任意数量的输入和输出。
这两个类是标准单机类的封装类,因此网络配置过程在标准训练和分布式训练中是相同的。
为了通过 DL4J 在 Spark 集群上训练一个网络,你需要遵循这个标准的工作流程:
-
通过
MultiLayerConfiguration
(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/nn/conf/MultiLayerConfiguration.html
) 类或ComputationGraphConfiguration
(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/nn/conf/ComputationGraphConfiguration.html
) 类指定网络配置 -
创建一个
TrainingMaster
实例 (static.javadoc.io/org.deeplearning4j/dl4j-spark_2.11/0.9.1_spark_2/org/deeplearning4j/spark/api/TrainingMaster.html
),以控制分布式训练的执行方式 -
使用网络配置和之前创建的
TrainingMaster
对象,创建SparkDl4jMultiLayer
或SparkComputationGraph
实例 -
加载训练数据
-
在
SparkDl4jMultiLayer
(或SparkComputationGraph
)实例上调用适当的 fit 方法 -
保存训练好的网络
-
为 Spark 任务构建 JAR 文件
-
提交 JAR 文件以执行
第五章中展示的代码示例,卷积神经网络,和第六章中展示的代码示例,递归神经网络,让你了解如何配置和构建 MNN;第三章中展示的代码示例,提取、转换、加载,以及第四章中展示的代码示例,流处理,让你了解不同方式加载训练数据的思路,而第一章中介绍的内容,Apache Spark 生态系统,则让你了解如何执行 Spark 任务。接下来,让我们在接下来的章节中专注于理解如何实现缺失的部分:网络训练。
目前,为了训练网络,DL4J 提供了一种单一的方法——参数平均化(arxiv.org/abs/1410.7455
)。以下是这一过程的概念性步骤:
-
Spark 主节点开始使用网络配置和参数
-
根据
TrainingMaster
的配置,数据被划分为多个子集 -
对于每个子集:
-
配置和参数从主节点分发到每个工作节点
-
每个工作节点在其自身的分区上执行 fit 操作
-
计算参数的平均值,然后将结果返回给主节点
-
-
训练完成后,已训练的网络副本会保存在主节点中
使用 Spark 和 DL4J 进行 CNN 分布式训练
让我们回到在第五章中介绍的例子,卷积神经网络,Spark 实战 CNN,关于手写数字图像分类的MNIST
数据集。为了方便起见,下面是该处使用的网络配置的提醒:
val channels = 1
val outputNum = 10
val conf = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(iterations)
.regularization(true)
.l2(0.0005)
.learningRate(.01)
.weightInit(WeightInit.XAVIER)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.updater(Updater.NESTEROVS)
.momentum(0.9)
.list
.layer(0, new ConvolutionLayer.Builder(5, 5)
.nIn(channels)
.stride(1, 1)
.nOut(20)
.activation(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(Activation.IDENTITY)
.build)
.layer(3, new
SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build)
.layer(4, new DenseLayer.Builder()
.activation(Activation.RELU)
.nOut(500)
.build)
.layer(5, new
OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.nOut(outputNum)
.activation(Activation.SOFTMAX).build)
.setInputType(InputType.convolutionalFlat(28, 28, 1))
.backprop(true).pretrain(false).build
我们使用该MultiLayerConfiguration
对象来初始化模型。拥有模型和训练数据后,可以开始训练。如前一节所述,训练通过 Spark 进行。因此,接下来的步骤是创建 Spark 上下文,如下所示:
val sparkConf = new SparkConf
sparkConf.setMaster(master)
.setAppName("DL4J Spark MNIST Example")
val sc = new JavaSparkContext(sparkConf)
然后,我们将训练数据加载到内存中后进行并行化处理,如下所示:
val trainDataList = mutable.ArrayBuffer.empty[DataSet]
while (trainIter.hasNext) {
trainDataList += trainIter.next
}
val paralleltrainData = sc.parallelize(trainDataList)
现在是时候创建TrainingMaster
实例,如下所示:
var batchSizePerWorker: Int = 16
val tm = new ParameterAveragingTrainingMaster.Builder(batchSizePerWorker)
.averagingFrequency(5)
.workerPrefetchNumBatches(2)
.batchSizePerWorker(batchSizePerWorker)
.build
我们可以使用当前唯一可用的TrainingMaster
接口实现——ParameterAveragingTrainingMaster
(static.javadoc.io/org.deeplearning4j/dl4j-spark_2.11/0.9.1_spark_2/org/deeplearning4j/spark/impl/paramavg/ParameterAveragingTrainingMaster.html
)。在上述示例中,我们只使用了该TrainingMaster
实现的三个配置选项,但还有更多选项:
-
dataSetObjectSize
:指定每个DataSet
中的示例数量。 -
workerPrefetchNumBatches
:Spark 工作节点能够异步预取一定数量的DataSet
对象,以避免等待数据加载。通过将此属性设置为零,可以禁用预取。如果将其设置为二(如我们的示例所示),则是一个很好的折衷方案(既不过度使用内存,又能保证合理的默认设置)。 -
*rddTrainingApproach*
:DL4J 在从 RDD 训练时提供了两种方法——RDDTrainingApproach.Export
和RDDTrainingApproach.Direct
(static.javadoc.io/org.deeplearning4j/dl4j-spark_2.11/0.9.1_spark_2/org/deeplearning4j/spark/api/RDDTrainingApproach.html
)。Export
是默认方法;它首先将RDD<DataSet>
以批量序列化的形式保存到磁盘上。然后,执行器异步加载所有DataSet
对象。选择Export
方法还是Direct
方法,取决于数据集的大小。对于不适合放入内存的大型数据集以及多轮训练,Export
方法更为优选——在这种情况下,Direct
方法的拆分和重分区操作开销并不适用,而且内存消耗较小。 -
exportDirectory
:临时数据文件存储的位置(仅限Export
方法)。 -
storageLevel
:仅在使用Direct
方法并从RDD<DataSet>
或RDD<MultiDataSet>
进行训练时适用。DL4J 持久化RDD时的默认存储级别是StorageLevel.MEMORY_ONLY_SER
。 -
storageLevelStreams
:仅在使用fitPaths(RDD<String>)
方法时适用。DL4J 持久化RDD<String>
时的默认存储级别是StorageLevel.MEMORY_ONLY
*。 -
repartitionStrategy
:指定应如何进行重分区操作的策略。可能的值为Balanced
(默认,DL4J 定义的自定义重分区策略)和SparkDefault
(Spark 使用的标准重分区策略)。
这里可以找到完整的列表及其含义:
deeplearning4j.org/docs/latest/deeplearning4j-spark-training
一旦定义了TrainingMaster
配置和策略,就可以创建一个SparkDl4jMultiLayer
实例,如下所示:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
然后可以开始训练,选择适当的fit
方法,如下所示:
var numEpochs: Int = 15
var i: Int = 0
for (i <- 0 until numEpochs) {
sparkNet.fit(paralleltrainData)
println("Completed Epoch {}", i)
}
第八章,监控和调试神经网络训练,以及第九章,解释神经网络输出,将解释如何监控、调试和评估网络训练的结果。
使用 Spark 和 DL4J 进行 RNN 分布式训练
让我们重新考虑在第六章中提出的例子,递归神经网络,DL4J 和 Spark 中的 RNN部分,关于一个 LSTM 模型的训练,该模型将逐个字符地生成文本。为了方便起见,让我们回顾一下那里使用的网络配置(这是 Alex Graves 提出的模型的 LSTM RNN 实现):
val rng = new Random(12345)
val lstmLayerSize: Int = 200
val tbpttLength: Int = 50
val nSamplesToGenerate: Int = 4
val nCharactersToSample: Int = 300
val generationInitialization: String = null
val conf = new NeuralNetConfiguration.Builder()
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.iterations(1)
.learningRate(0.1)
.rmsDecay(0.95)
.seed(12345)
.regularization(true)
.l2(0.001)
.weightInit(WeightInit.XAVIER)
.updater(Updater.RMSPROP)
.list
.layer(0, new GravesLSTM.Builder().nIn(SparkLSTMCharacterExample.CHAR_TO_INT.size).nOut(lstmLayerSize).activation(Activation.TANH).build())
.layer(1, new GravesLSTM.Builder().nIn(lstmLayerSize).nOut(lstmLayerSize).activation(Activation.TANH).build())
.layer(2, new RnnOutputLayer.Builder(LossFunction.MCXENT).activation(Activation.SOFTMAX)
.nIn(lstmLayerSize).nOut(SparkLSTMCharacterExample.nOut).build) //MCXENT + softmax for classification
.backpropType(BackpropType.TruncatedBPTT).tBPTTForwardLength(tbpttLength).tBPTTBackwardLength(tbpttLength)
.pretrain(false).backprop(true)
.build
在使用 Spark 和 DeepLearning4j 进行 CNN 分布式训练中提到的所有关于TrainingMaster
实例创建和配置的考虑事项,同样适用于SparkDl4jMultiLayer
实例的创建和配置,因此不再重复。SparkDl4jMultiLayer
的不同之处在于,在这种情况下,我们必须为模型指定IteratorListeners
(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/optimize/api/IterationListener.html
),这对于监控和调试尤其有用,正如在下一章中所解释的那样。按如下方式指定迭代器监听器:
val sparkNetwork: SparkDl4jMultiLayer = new SparkDl4jMultiLayer(sc, conf, tm)
sparkNetwork.setListeners(Collections.singletonListIterationListener))
以下是此情况下训练的一种可能方式。定义训练的轮数,如下所示:
val numEpochs: Int = 10
然后,对于每个,使用sparkNetwork
应用适当的fit
方法并采样一些字符,如下所示:
(0 until numEpochs).foreach { i =>
//Perform one epoch of training. At the end of each epoch, we are returned a copy of the trained network
val net = sparkNetwork.fit(trainingData)
//Sample some characters from the network (done locally)
println("Sampling characters from network given initialization \"" +
(if (generationInitialization == null) "" else generationInitialization) + "\"")
val samples = ... // Implement your own sampling method
samples.indices.foreach { j =>
println("----- Sample " + j + " -----")
println(samples(j))
}
}
最后,由于我们选择了Export
训练方式,完成后需要删除临时文件,如下所示:
tm.deleteTempFiles(sc)
第八章,监控和调试神经网络训练,以及第九章,解释神经网络输出,将解释如何监控、调试和评估该网络训练的结果。
性能考虑
本节将介绍一些建议,以便在 Spark 上训练时最大化 DL4J 的性能。我们从内存配置的一些考虑因素开始。首先,了解 DL4J 如何管理内存非常重要。该框架是基于 ND4J 科学库(用 C++ 编写)构建的。ND4J 使用堆外内存管理——这意味着为 INDArrays
分配的内存不在 JVM 堆上,如 Java 对象那样,而是分配在 JVM 外部。这种内存管理方式允许高效使用高性能本地代码进行数值操作,并且在运行于 GPU 时也对 CUDA 操作(developer.nvidia.com/cuda-zone
)至关重要。
通过这种方式,可以清楚地看到额外的内存和时间开销——在 JVM 堆上分配内存要求每次需要先将数据从那里复制出来,然后进行计算,最后将结果复制回去。ND4J 只是传递指针进行数值计算。堆内存(JVM)和堆外内存(通过 JavaCPP 的 ND4J (github.com/bytedeco/javacpp
)) 是两个独立的内存池。在 DL4J 中,这两者的内存限制是通过 Java 命令行参数,通过以下系统属性进行控制的:
-
Xms
:JVM 堆在应用程序启动时可以使用的内存 -
Xmx
:JVM 堆可以使用的最大内存限制 -
org.bytedeco.javacpp.maxbytes
:堆外最大内存限制 -
org.bytedeco.javacpp.maxphysicalbytes
:通常设置为与maxbytes
属性相同的值
第十章,在分布式系统上的部署,(该章节专注于训练或运行神经网络的分布式系统部署)将详细介绍内存管理。
提高性能的另一个好方法是配置 Spark 的本地性设置。这是一个可选的配置,但可以在这方面带来好处。本地性指的是数据相对于可以处理它的位置。在执行时,每当数据必须通过网络复制到空闲执行器进行处理时,Spark 需要在等待具有本地访问数据的执行器变为空闲状态和执行网络传输之间做出选择。Spark 的默认行为是在通过网络将数据传输到空闲执行器之前稍作等待。
使用 DL4J 训练神经网络计算量大,因此每个输入 DataSet
的计算量相对较高。因此,Spark 的默认行为并不适合最大化集群的利用率。在 Spark 训练过程中,DL4J 确保每个执行器只有一个任务——因此,最好是立即将数据传输到空闲的执行器,而不是等待另一个执行器变为空闲。计算时间会变得比任何网络传输时间都更为重要。告诉 Spark 不必等待,而是立即开始传输数据的方法很简单——提交配置时,我们需要将 spark.locality.wait
属性的值设置为 0
。
Spark 在处理具有大堆外组件的 Java 对象时存在问题(例如,在 DL4J 中,DataSet
和 INDArray
对象可能会遇到此问题),特别是在缓存或持久化它们时。从第一章《Apache Spark 生态系统》一章中你了解到,Spark 提供了不同的存储级别。在这些存储级别中,MEMORY_ONLY
和 MEMORY_AND_DISK
持久化可能会导致堆外内存问题,因为 Spark 无法正确估算 RDD 中对象的大小,从而导致内存溢出问题。因此,持久化 RDD<DataSet>
或 RDD<INDArray>
时,采用 MEMORY_ONLY_SER
或 MEMORY_AND_DISK_SER
是一种好的做法。
让我们详细探讨一下这个问题。Spark 根据估算的块大小来丢弃部分 RDD。它根据选择的持久化级别来估算块的大小。在 MEMORY_ONLY
或 MEMORY_AND_DISK
的情况下,估算是通过遍历 Java 对象图来完成的。问题在于,这个过程没有考虑到 DL4J 和 ND4J 使用的堆外内存,因此 Spark 低估了对象的真实大小,比如 DataSets
或 INDArrays
。
此外,在决定是否保留或丢弃块时,Spark 仅考虑了堆内存的使用情况。DataSet
和 INDArray
对象的堆内存占用非常小,因此 Spark 会保留太多此类对象,导致堆外内存耗尽,进而出现内存溢出问题。在 MEMORY_ONLY_SER
或 MEMORY_AND_DISK_SER
的情况下,Spark 将以序列化形式将块存储在 JVM 堆中。由于序列化的对象没有堆外内存,因此它们的大小可以被 Spark 准确估算——当需要时,Spark 会丢弃块,从而避免内存溢出问题。
Spark 提供了两个序列化库——Java(默认序列化)和 Kryo(github.com/EsotericSoftware/kryo
)。默认情况下,它使用 Java 的 ObjectOutputStream
进行对象序列化(docs.oracle.com/javase/8/docs/api/java/io/ObjectOutputStream.html
),并且可以与任何实现了序列化接口的类一起工作(docs.oracle.com/javase/8/docs/api/java/io/Serializable.html
)。然而,它也可以使用 Kryo 库,Kryo 的序列化速度显著快于 Java 序列化,而且更紧凑。
缺点是 Kryo 并不支持所有的可序列化类型,并且与 ND4J 的堆外数据结构不兼容。因此,如果你希望在 Spark 上使用 Kryo 序列化与 ND4J 配合使用,就需要设置一些额外的配置,以跳过由于某些 INDArray
字段的序列化不正确而导致的潜在 NullPointerExceptions
。要使用 Kryo,你需要将依赖项添加到项目中(以下示例是针对 Maven 的,但你也可以使用 Gradle 或 sbt 以这些构建工具特有的语法导入相同的依赖项),如下所示:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-kryo_2.11</artifactId>
<version>0.9.1</version>
</dependency>
然后配置 Spark 使用 Nd4J Kryo 注册器,如下所示:
val sparkConf = new SparkConf
sparkConf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
sparkConf.set("spark.kryo.registrator", "org.nd4j.Nd4jRegistrator")
超参数优化
在任何训练开始之前,一般的机器学习技术,尤其是深度学习技术,都有一组必须选择的参数。这些被称为超参数。专注于深度学习时,我们可以说,其中一些(如层数及其大小)定义了神经网络的架构,而其他一些则定义了学习过程(如学习率、正则化等)。超参数优化尝试通过使用专用软件应用一些搜索策略来自动化这个过程(该过程对训练神经网络所取得的结果有显著影响)。DL4J 提供了一个名为 Arbiter 的工具,用于神经网络的超参数优化。这个工具并未完全自动化这个过程——数据科学家或开发人员需要手动介入,以指定搜索空间(超参数的有效值范围)。请注意,当前的 Arbiter 实现并不会在那些没有很好地手动定义搜索空间的情况下,阻止寻找好的模型失败。接下来的部分将介绍如何以编程方式使用 Arbiter 的详细信息。
需要将 Arbiter 依赖项添加到需要进行超参数优化的 DL4J Scala 项目中,如下所示:
groupId: org.deeplearning4j
artifactId: arbiter-deeplearning4j
version: 0.9.1
设置和执行超参数优化的步骤顺序始终是相同的,如下所示:
-
定义超参数搜索空间
-
定义该超参数搜索空间的候选生成器
-
定义数据源
-
定义模型保存器
-
选择一个评分函数
-
选择一个终止条件。
-
使用之前定义的数据源、模型保存器、评分函数和终止条件来构建优化配置。
-
使用优化运行器执行该过程。
现在让我们来看看如何以编程方式实现这些步骤。超参数配置空间的设置与在 DL4J 中配置 MNN 非常相似。它通过MultiLayerSpace
类(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/MultiLayerSpace.html
)实现。ParameterSpace<P>
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/api/ParameterSpace.html
)是 Arbiter 类,通过它可以定义给定超参数的可接受值范围。以下是一些示例:
val learningRateHyperparam = new ContinuousParameterSpace(0.0001, 0.1)
val layerSizeHyperparam = new IntegerParameterSpace(16, 256)
在ParameterSpace
构造函数中指定的上下边界值包含在区间内。区间值将在给定边界之间均匀地随机生成。然后,可以构建超参数空间,如以下示例所示:
val hyperparameterSpace = new MultiLayerSpace.Builder
.weightInit(WeightInit.XAVIER)
.l2(0.0001)
.updater(new SgdSpace(learningRateHyperparam))
.addLayer(new DenseLayerSpace.Builder
.nIn(784)
.activation(Activation.LEAKYRELU)
.nOut(layerSizeHyperparam)
.build())
.addLayer(new OutputLayerSpace.Builder
.nOut(10)
.activation(Activation.SOFTMAX)
.lossFunction(LossFunctions.LossFunction.MCXENT)
.build)
.numEpochs(2)
.build
在 DL4J 中,有两个类,MultiLayerSpace
和ComputationGraphSpace
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/ComputationGraphSpace.html
),可用于设置超参数搜索空间(它们代表MultiLayerConfiguration
和ComputationGraphConfiguration
在 MNN 配置中的作用)。
下一步是定义候选生成器。它可以是随机搜索,如以下代码行所示:
val candidateGenerator:CandidateGenerator = new RandomSearchGenerator(hyperparameterSpace, null)
或者,它可以是网格搜索。
为了定义数据源(即用于训练和测试不同候选者的数据来源),Arbiter 中提供了DataSource
接口(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/api/data/DataSource.html
),并且需要实现它(它需要一个无参构造函数)以适应给定的来源。
目前我们需要定义保存将要生成和测试的模型的位置。Arbiter 支持将模型保存到磁盘或将结果存储在内存中。以下是使用FileModelSaver
类(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/saver/local/FileModelSaver.html
)保存到磁盘的示例:
val baseSaveDirectory = "arbiterOutput/"
val file = new File(baseSaveDirectory)
if (file.exists) file.delete
file.mkdir
val modelSaver: ResultSaver = new FileModelSaver(baseSaveDirectory)
我们必须选择一个评分函数。Arbiter 提供了三种不同的选择——EvaluationScoreFunction
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/scoring/impl/EvaluationScoreFunction.html
),ROCScoreFunction
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/scoring/impl/ROCScoreFunction.html
),和 RegressionScoreFunction
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/scoring/impl/RegressionScoreFunction.html
)。
有关评估、ROC 和回归的更多细节将在 第九章,解读神经网络输出 中讨论。以下是使用 EvaluationScoreFunction
的示例:
val scoreFunction:ScoreFunction = new EvaluationScoreFunction(Evaluation.Metric.ACCURACY)
最后,我们指定了一组终止条件。当前的 Arbiter 实现只提供了两个终止条件,MaxTimeCondition
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/api/termination/MaxTimeCondition.html
) 和 MaxCandidatesCondition
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/api/termination/MaxCandidatesCondition.html
)。当超参数空间满足其中一个指定的终止条件时,搜索会停止。在以下示例中,搜索会在 15 分钟后或在达到 20 个候选项后停止(具体取决于哪个条件先满足)。
发生的第一个条件是:
val terminationConditions = Array(new MaxTimeCondition(15, TimeUnit.MINUTES), new MaxCandidatesCondition(20))
现在,所有选项都已设置完毕,可以构建 OptimizationConfiguration
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/config/OptimizationConfiguration.html
),如下所示:
val configuration: OptimizationConfiguration = new OptimizationConfiguration.Builder
.candidateGenerator(candidateGenerator)
.dataSource(dataSourceClass,dataSourceProperties)
.modelSaver(modelSaver)
.scoreFunction(scoreFunction)
.terminationConditions(terminationConditions)
.build
然后通过 IOptimizationRunner
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/runner/IOptimizationRunner.html
) 运行它,如以下示例所示:
val runner = new LocalOptimizationRunner(configuration, new MultiLayerNetworkTaskCreator())
runner.execute
执行结束时,应用程序会将生成的候选项存储在模型保存器初步指定的基础保存目录中的不同子目录里。每个子目录都以递增的数字命名。
参考本节的示例,对于第一个候选者,它将是./arbiterOutput/0/
,对于第二个候选者,将是./arbiterOutput/1/
,以此类推。还会生成模型的 JSON 表示(如下图所示),并且可以将其存储以便进一步重复使用:
图 7.1:Arbiter 中的候选者 JSON 序列化
Arbiter UI
要获取超参数优化的结果,必须等待过程执行结束,然后使用 Arbiter API 来检索它们,如以下示例所示:
val indexOfBestResult: Int = runner.bestScoreCandidateIndex
val allResults = runner.getResults
val bestResult = allResults.get(indexOfBestResult).getResult
val bestModel = bestResult.getResult
println("Configuration of the best model:\n")
println(bestModel.getLayerWiseConfigurations.toJson)
但是,具体情况可能不同,这个过程可能会很长,甚至需要几个小时才能结束,并且结果才会变得可用。幸运的是,Arbiter 提供了一个 Web UI,可以在运行时监控它,并获取潜在问题的洞察和优化配置的调优提示,无需在等待过程中浪费时间。为了开始使用这个 Web UI,需要将以下依赖项添加到项目中:
groupId: org.deeplearning4j
artifactId: arbiter-ui_2.11
version: 1.0.0-beta3
在IOptimizationRunner
开始之前,需要配置管理 Web UI 的服务器,如下所示:
val ss: StatsStorage = new FileStatsStorage(new File("arbiterUiStats.dl4j"))
runner.addListeners(new ArbiterStatusListener(ss))
UIServer.getInstance.attach(ss)
在前面的示例中,我们正在将 Arbiter 统计信息持久化到文件。一旦优化过程开始,Web UI 可以通过以下 URL 访问,如下所示:
http://:9000/arbiter
它有一个单一视图,在顶部显示正在进行的优化过程的摘要,如下图所示:
图 7.2:超参数优化过程的实时总结
在其中央区域,它展示了优化设置的总结,如下图所示:
图 7.3:超参数优化设置的摘要
在底部,它显示了结果列表,如下图所示:
图 7.4:超参数优化过程的实时结果总结
通过点击一个结果 ID,显示该特定候选者的额外细节、附加图表以及模型配置,如下图所示:
图 7.5:Arbiter Web UI 中的候选者详情
Arbiter UI 使用与 DL4J UI 相同的实现和持久化策略来监控训练过程。有关这些细节将在下一章中介绍。
摘要
在本章中,你已了解了如何使用 DL4J、ND4J 和 Apache Spark 训练 CNN 和 RNN。你现在也了解了内存管理,改进训练过程性能的若干技巧,以及如何使用 Arbiter 进行超参数优化的细节。
下一章将重点讨论如何在 CNN 和 RNN 的训练阶段监控和调试它们。
第八章:监控和调试神经网络训练
前一章重点介绍了多层神经网络(MNNs)的训练,并特别展示了 CNN 和 RNN 的代码示例。本章将介绍如何在训练过程中监控网络,以及如何利用这些监控信息来调整模型。DL4J 提供了用于监控和调整的 UI 功能,这将是本章的重点。这些功能也可以在 DL4J 和 Apache Spark 的训练环境中使用。将提供两种情况的示例(仅使用 DL4J 训练和 DL4J 与 Spark 结合使用)。同时,本章还将讨论一些潜在的基础步骤或网络训练的技巧。
在神经网络训练阶段进行监控和调试
在第五章,卷积神经网络,和第七章,使用 Spark 训练神经网络之间,提供了一个完整的例子,涉及 CNN 模型的配置和训练。这是一个图像分类的示例。所使用的训练数据来自MNIST
数据库。训练集包含 60,000 个手写数字的例子,每张图片都带有一个整数标签。我们将使用相同的例子来展示 DL4J 提供的可视化工具,以便在训练时监控和调试网络。
在训练结束时,你可以通过编程方式将生成的模型保存为 ZIP 压缩包,并调用ModelSerializer
类的writeModel
方法(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/util/ModelSerializer.html
):
ModelSerializer.writeModel(net, new File(System.getProperty("user.home") + "/minist-model.zip"), true)
生成的压缩包包含三个文件:
-
configuration.json
:以 JSON 格式表示的模型配置 -
coefficients.bin
:估算的系数 -
updaterState.bin
:更新器的历史状态
例如,可以使用 JDK 的 JavaFX 功能(en.wikipedia.org/wiki/JavaFX
)来实现一个独立的 UI,用于测试在训练网络后构建的模型。查看以下截图:
图 8.1:手写数字分类 CNN 示例的测试 UI
然而,这对于监控目的几乎没什么用处,因为在实际应用中,你可能希望实时查看当前网络状态和训练进展。DL4J 训练 UI 将满足你所有的监控需求,我们将在本章接下来的两节中详细介绍这一功能。上面截图中显示的测试 UI 的实现细节将在下一章中讨论,该章节将讲解网络评估——在阅读过这一部分之后,你会更容易理解这些实现。
8.1.1 DL4J 训练 UI
DL4J 框架提供了一个网页用户界面,用于实时可视化当前网络状态和训练进展。它用于帮助理解如何调整神经网络。在本节中,我们将讨论一个仅使用 DL4J 进行 CNN 训练的用例。下一节将展示通过 DL4J 和 Spark 进行训练时的不同之处。
我们首先需要做的是将以下依赖添加到项目中:
groupId = org.deeplearning4j
artifactId = deeplearning4j-ui_2.11
version = 0.9.1
然后,我们可以开始添加必要的代码。
让我们为 UI 初始化后端:
val uiServer = UIServer.getInstance()
配置在训练过程中为网络生成的信息:
val statsStorage:StatsStorage = new InMemoryStatsStorage()
在前面的示例中,我们选择将信息存储在内存中。也可以选择将其存储在磁盘上,以便稍后加载使用:
val statsStorage:StatsStorage = new FileStatsStorage(file)
添加监听器(deeplearning4j.org/api/latest/org/deeplearning4j/ui/stats/StatsListener.html
),这样你可以在网络训练时收集信息:
val listenerFrequency = 1
net.setListeners(new StatsListener(statsStorage, listenerFrequency))
最后,为了实现可视化,将StatsStorage
(deeplearning4j.org/api/latest/org/deeplearning4j/ui/storage/InMemoryStatsStorage.html
)实例附加到后端:
uiServer.attach(statsStorage)
当训练开始时(执行fit
方法),可以通过网页浏览器访问 UI,网址为:
http://localhost:<ui_port>/
默认监听端口为9000
。可以通过org.deeplearning4j.ui.port
系统属性选择不同的端口,例如:
-Dorg.deeplearning4j.ui.port=9999
UI 的登陆页面是概览页面:
图 8.2:DL4J UI 的概览页面
如前面的截图所示,页面上有四个不同的部分。在页面的左上方是“得分与迭代”图表,展示了当前小批量的损失函数。在右上方是有关模型及其训练的信息。在左下方,有一个展示所有网络中参数更新比率(按层)的图表,称为“权重与迭代”。该图表中的值以对数底数 10 展示。在右下方是一个图表,展示了更新、梯度和激活的标准差。该图表的值同样以对数底数 10 展示。
UI 的另一个页面是模型页面:
图 8.3:DL4J UI 的模型页面
它展示了神经网络的图形表示。通过点击图中的某一层,可以显示该层的详细信息:
图 8.4:DL4J UI 模型页面中的单层详细信息
在页面的右侧部分,我们可以找到一个包含所选层详细信息的表格,以及一个展示此层参数更新比例的图表(根据概述页面)。向下滚动,我们还可以在同一部分找到其他图表,展示层激活随时间变化的情况、参数的直方图,以及每种参数类型和学习率与时间的更新。
UI 的第三页是系统页面:
图 8.5:DL4J UI 的系统页面
它展示了每台进行训练的机器的系统信息(JVM 和堆外内存利用率百分比、硬件和软件详情)。
UI 的左侧菜单呈现了第四个选项,语言,它列出了此 UI 所支持的所有语言翻译:
图 8.6:DL4J UI 支持的语言列表
8.1.2 DL4J 训练 UI 和 Spark
当在技术栈中训练并包括 Spark 时,也可以使用 DL4J UI。与仅使用 DL4J 的情况相比,主要区别在于:一些冲突的依赖关系要求 UI 和 Spark 运行在不同的 JVM 上。这里有两个可能的替代方案:
-
在运行时收集并保存相关的训练统计信息,然后稍后离线可视化它们。
-
执行 DL4J UI 并在不同的 JVM(服务器)中使用远程 UI 功能。数据随后会从 Spark 主节点上传到 UI 服务器。
让我们看看如何实现步骤 1的替代方案。
一旦 Spark 网络创建完成,让我们参考我们在第五章中展示的 CNN 示例,卷积神经网络部分,在基于 Spark 的 CNN 实战章节中:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
我们需要创建一个FileStatsStorage
对象,以便将结果保存到文件中并为 Spark 网络设置监听器:
val ss:StatsStorage = new FileStatsStorage(new File("NetworkTrainingStats.dl4j"))
sparkNet.setListeners(ss, Collections.singletonList(new StatsListener(null)))
接下来,我们可以通过实现以下步骤离线加载并显示已保存的数据:
val statsStorage:StatsStorage = new FileStatsStorage("NetworkTrainingStats.dl4j")
val uiServer = UIServer.getInstance()
uiServer.attach(statsStorage)
现在,让我们探索一下步骤 2的替代方案。
如前所述,UI 服务器需要在单独的 JVM 上运行。从那里,我们需要启动 UI 服务器:
val uiServer = UIServer.getInstance()
然后,我们需要启用远程监听器:
uiServer.enableRemoteListener()
我们需要设置的依赖项与我们在DL4J 训练 UI部分中展示的示例相同(DL4J UI):
groupId = org.deeplearning4j
artifactId = deeplearning4j-ui_2.11
version = 0.9.1
在 Spark 应用程序中(我们仍然指的是第五章中展示的 CNN 示例,卷积神经网络),在创建了 Spark 网络之后,我们需要创建一个RemoteUIStatsStorageRouter
的实例(static.javadoc.io/org.deeplearning4j/deeplearning4j-core/0.9.1/org/deeplearning4j/api/storage/impl/RemoteUIStatsStorageRouter.html
),该实例会异步地将所有更新推送到远程 UI,并最终将其设置为 Spark 网络的监听器:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
val remoteUIRouter:StatsStorageRouter = new RemoteUIStatsStorageRouter("http://UI_HOST_IP:UI_HOST_PORT")
sparkNet.setListeners(remoteUIRouter, Collections.singletonList(new StatsListener(null)))
UI_HOST_IP
是 UI 服务器运行的机器的 IP 地址,UI_HOST_PORT
是 UI 服务器的监听端口。
为了避免与 Spark 的依赖冲突,我们需要将此应用程序的依赖项添加到依赖列表中,而不是整个 DL4J UI 模型:
groupId = org.deeplearning4j
artifactId = deeplearning4j-ui-model
version = 0.9.1
选择步骤 2的替代方案时,网络的监控发生在训练过程中,并且是实时的,而不是在训练执行完成后离线进行。
DL4J UI 页面和内容与没有 Spark 的网络训练场景中展示的相同(本章DL4J 训练 UI部分)。
8.1.3 使用可视化调优网络
现在,让我们看看如何解读 DL4J UI 中展示的可视化结果,并利用它们来调优神经网络。我们从概览页面开始。模型得分与迭代图表展示了当前小批量的损失函数,应该随着时间推移而下降(如图 8.2中的示例所示)。无论观察到的得分是否应持续增加,学习率可能设得太高。在这种情况下,应降低学习率,直到得分变得更稳定。得分不断增加也可能表明存在其他问题,比如数据归一化不正确。另一方面,如果得分平稳或下降非常缓慢,则表示学习率可能设置得太低,或者优化很困难。在这种情况下,应尝试使用不同的更新器重新进行训练。
在DL4J 训练 UI一节中展示的示例中,使用了 Nesterov 动量更新器(见图 8.4),并取得了良好的结果(见图 8.2)。你可以通过NeuralNetConfiguration.Builder
类的updater
方法来更改更新器:
val conf = new NeuralNetConfiguration.Builder()
...
.updater(Updater.NESTEROVS)
在这张折线图中,应该预期会有一些噪声,但如果分数在不同的运行之间变化较大,那就成了一个问题。根本原因可能是我们之前提到的一些问题(学习率、归一化)或数据洗牌。另外,将小批量大小设置为非常小的样本数量也会增加图表中的噪声——这也可能导致优化困难。
在训练过程中,其他有助于理解如何调整神经网络的重要信息来自于结合概述页和模型页的一些细节。参数(或更新)的平均幅度是指在给定时间步长下,它们绝对值的平均值。在训练运行时,平均幅度的比率由概述页(对于整个网络)和模型页(对于特定层)提供。当选择学习率时,我们可以使用这些比率值。通常的规则是,大多数网络的比率应接近 0.001(1:1000),在log[10]图表(如概述页和模型页中的图表)中,该比率对应于-3。当比率显著偏离此值时,意味着网络参数可能不稳定,或者它们变化太慢,无法学习到有用的特征。通过调整整个网络或一个或多个层的学习率,可以改变平均幅度的比率。
现在,让我们探索模型页中其他有助于调整过程的有用信息。
模型页中的层激活图(见下图)可以用来检测梯度消失或爆炸现象。理想情况下,这个图应该随着时间的推移趋于稳定。激活值的标准差应介于 0.5 和 2.0 之间。
值显著超出此范围表明可能存在数据未归一化、高学习率或权重初始化不当等问题:
图 8.7:模型页的层激活图
模型页中层参数直方图图(权重和偏置,见下图)仅显示最新迭代的结果,提供了其他常见的洞察:
图 8.8:层参数直方图图(权重)
在训练过程中,经过一段时间后,这些权重的直方图应该呈现近似的高斯正态分布,而偏置通常从 0 开始,并最终趋于高斯分布。参数向+/-无穷大发散通常是学习率过高或网络正则化不足的良好指示。偏置变得非常大意味着类别分布非常不平衡。
模型页中的层更新直方图图(权重和偏置,见下图)也仅显示最新迭代的结果,与层参数直方图一样,提供了其他常见的信息:
图 8.9:层更新直方图图(权重)
这与参数图相同——经过一段时间后,它们应该呈现大致的高斯正态分布。非常大的值表示网络中的梯度爆炸。在这种情况下,根本原因可能出在权重初始化、输入或标签数据的归一化,或是学习率上。
总结
在本章中,我们已经了解了 DL4J 为神经网络训练时的监控和调优提供的 UI 细节。我们还学习了在使用 DL4J 进行训练时,尤其是在 Apache Spark 参与的情况下,如何使用该 UI。最后,我们理解了从 DL4J UI 页面上展示的图表中可以获得哪些有用的见解,以识别潜在问题以及一些解决方法。
下一章将重点介绍如何评估神经网络,以便我们能理解模型的准确性。在深入探讨通过 DL4J API 和 Spark API 实现的实际示例之前,我们将介绍不同的评估技术。
第九章:解释神经网络输出
在上一章中,详细描述了如何使用 DL4J UI 来监控和调试多层神经网络(MNN)。上一章的最后部分也解释了如何解读和使用 UI 图表中的实时可视化结果来调整训练。本章将解释如何在模型训练完成后、投入生产之前评估模型的准确性。对于神经网络,存在多种评估策略。本章涵盖了主要的评估策略及其所有实现,这些实现由 DL4J API 提供。
在描述不同的评估技术时,我尽量减少数学和公式的使用,尽量集中讲解如何使用 DL4J 和 Spark 进行 Scala 实现。
在本章中,我们将覆盖以下主题:
-
解释神经网络的输出
-
使用 DL4J 的评估技术,包括以下内容:
-
分类评估
-
在 Spark 环境下的分类评估
-
DL4J 支持的其他类型评估
-
使用 DL4J 的评估技术
在训练时以及在部署 MNN 之前,了解模型的准确性并理解其性能非常重要。在上一章中,我们了解到,在训练阶段结束时,模型可以保存为 ZIP 归档文件。从那里,可以通过实现自定义 UI 来运行并测试模型,正如图 8.1所示(它是通过 JavaFX 功能实现的,示例代码是本书随附的源代码的一部分)。但是,可以利用更为重要的策略来进行评估。DL4J 提供了一个 API,可以用来评估二分类器和多分类器的性能。
本节及其子节涵盖了如何进行分类评估的所有细节(DL4J 和 Spark),而下一节则概述了其他可以进行的评估策略,所有这些策略都依赖于 DL4J API。
分类评估
实现评估时,核心的 DL4J 类叫做evaluation(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/eval/Evaluation.html
,是 DL4J NN 模块的一部分)。
本小节所展示的示例所用的数据集是鸢尾花数据集(可以在 archive.ics.uci.edu/ml/datasets/iris
下载)。这是一个多变量数据集,由英国统计学家和生物学家 Ronald Fisher(en.wikipedia.org/wiki/Ronald_Fisher
)于 1936 年引入。该数据集包含 150 条记录——来自三种鸢尾花(Iris setosa、Iris virginica 和 Iris versicolor)的 50 个样本。每个样本测量了四个属性(特征)——萼片和花瓣的长度和宽度(单位:厘米)。该数据集的结构在 第四章 流式数据 的 使用 DL4J 和 Spark 处理流式数据 部分的示例中使用过。以下是该数据集中包含的一个样本数据:
sepal_length,sepal_width,petal_length,petal_width,species
5.1,3.5,1.4,0.2,0
4.9,3.0,1.4,0.2,0
4.7,3.2,1.3,0.2,0
4.6,3.1,1.5,0.2,0
5.0,3.6,1.4,0.2,0
5.4,3.9,1.7,0.4,0
...
通常,对于像这样的监督学习情况,数据集会被分为两部分:70% 用于训练,30% 用于计算误差并在必要时修改网络。这对于本节的示例也是如此——我们将使用 70% 的数据集进行网络训练,其余 30% 用于评估。
我们需要做的第一件事是使用 CSVRecordReader
获取数据集(输入文件是一个由逗号分隔的记录列表):
val numLinesToSkip = 1
val delimiter = ","
val recordReader = new CSVRecordReader(numLinesToSkip, delimiter)
recordReader.initialize(new FileSplit(new ClassPathResource("iris.csv").getFile))
现在,我们需要将将用于神经网络的数据进行转换:
val labelIndex = 4
val numClasses = 3
val batchSize = 150
val iterator: DataSetIterator = new RecordReaderDataSetIterator(recordReader, batchSize, labelIndex, numClasses)
val allData: DataSet = iterator.next
allData.shuffle()
输入文件的每一行包含五个值——四个输入特征,后跟一个整数标签(类别)索引。这意味着标签是第五个值(labelIndex
是 4
)。数据集有三种类别,代表三种鸢尾花类型。它们的整数值分别为零(setosa)、一(versicolor)或二(virginica)。
如前所述,我们将数据集分成两部分——70% 的数据用于训练,其余部分用于评估:
val iterator: DataSetIterator = new RecordReaderDataSetIterator(recordReader, batchSize, labelIndex, numClasses)
val allData: DataSet = iterator.next
allData.shuffle()
val testAndTrain: SplitTestAndTrain = allData.splitTestAndTrain(0.70)
val trainingData: DataSet = testAndTrain.getTrain
val testData: DataSet = testAndTrain.getTest
数据集的拆分通过 ND4J 的 SplitTestAndTrain
类 (deeplearning4j.org/api/latest/org/nd4j/linalg/dataset/SplitTestAndTrain.html
) 完成。
我们还需要使用 ND4J 的 NormalizeStandardize
类 (deeplearning4j.org/api/latest/org/nd4j/linalg/dataset/api/preprocessor/NormalizerStandardize.html
) 对输入数据(包括训练集和评估集)进行归一化处理,以便我们得到零均值和标准差为一:
val normalizer: DataNormalization = new NormalizerStandardize
normalizer.fit(trainingData)
normalizer.transform(trainingData)
normalizer.transform(testData)
我们现在可以配置并构建模型(一个简单的前馈神经网络):
val conf = new NeuralNetConfiguration.Builder()
.seed(seed)
.activation(Activation.TANH)
.weightInit(WeightInit.XAVIER)
.l2(1e-4)
.list
.layer(0, new DenseLayer.Builder().nIn(numInputs).nOut(3)
.build)
.layer(1, new DenseLayer.Builder().nIn(3).nOut(3)
.build)
.layer(2, new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.activation(Activation.SOFTMAX)
.nIn(3).nOut(outputNum).build)
.backprop(true).pretrain(false)
.build
以下截图显示了本示例中网络的图形表示:
MNN 可以通过从前面的配置开始创建:
val model = new MultiLayerNetwork(conf)
model.init()
model.setListeners(new ScoreIterationListener(100))
如果我们使用为训练预留的输入数据集的部分(70%),则可以开始训练:
for(idx <- 0 to 2000) {
model.fit(trainingData)
}
在训练结束时,可以使用输入数据集的保留部分(30%)进行评估:
val eval = new Evaluation(3)
val output = model.output(testData.getFeatureMatrix)
eval.eval(testData.getLabels, output)
println(eval.stats)
传递给评估类构造函数的值是评估中需要考虑的类别数——这里是3
,因为数据集中有3
个花卉类别。eval
方法将测试数据集中的标签数组与模型生成的标签进行比较。评估的结果最终会打印到输出中:
默认情况下,Evaluation
类的stats
方法会显示混淆矩阵条目(每行一个条目),准确度、精确度、召回率和 F1 分数,但也可以显示其他信息。让我们来谈谈这些stats
是什么。
混淆矩阵是用于描述分类器在测试数据集上表现的表格,其中真实值是已知的。我们来考虑以下示例(对于二分类器):
预测数量 = 200 | 预测为否 | 预测为是 |
---|---|---|
实际: 否 | 55 | 5 |
实际: 是 | 10 | 130 |
这些是从上述矩阵中得到的见解:
-
预测类别有两种可能,“是”和“否”
-
分类器总共做出了 200 个预测
-
在这 200 个案例中,分类器预测为是 135 次,预测为否 65 次
-
实际上,样本中有 140 个案例是“是”,60 个是“否”
当这些被翻译成适当的术语时,见解如下:
-
真阳性 (TP):这些是预测为“是”且实际上是“是”的情况
-
真阴性 (TN):预测为否,且实际上是否
-
假阳性 (FP):预测为是,但实际上是否
-
假阴性 (FN):预测为否,但实际上是是
让我们考虑以下示例:
预测为否 | 预测为是 | |
---|---|---|
实际: 否 | 真阴性 | 假阳性 |
实际: 是 | 假阴性 | 真阳性 |
这是通过数字来完成的。可以从混淆矩阵中计算出一组比率。参考本节中的代码示例,它们如下所示:
-
准确度:表示分类器正确的频率:(TP+TN)/总数。
-
精确度:表示分类器预测为正的观测值时,分类器的正确率。
-
召回率:评估数据集中所有类别(标签)的平均召回率:TP/(TP+FN)。
-
F1 分数:这是精确度和召回率的加权平均值。它考虑了假阳性和假阴性:2 * TP / (2TP + FP + FN)。
Evaluation
类还可以显示其他信息,例如 G-measure 或 Matthew 相关系数等等。混淆矩阵也可以显示其完整形式:
println(eval.confusionToString)
上述命令返回以下输出:
混淆矩阵也可以直接访问并转换为 CSV 格式:
eval.getConfusionMatrix.toCSV
上述命令返回以下输出:
它也可以转换为 HTML 格式:
eval.getConfusionMatrix.toHTML
上述命令返回以下输出:
分类评估 – Spark 示例
让我们来看另一个分类评估的示例,但在一个涉及 Spark 的上下文中(分布式评估)。我们将完成在 第五章,卷积神经网络,在 使用 Spark 的 CNN 实战 部分,第七章,使用 Spark 训练神经网络,在 使用 Spark 和 DL4J 的 CNN 分布式训练 部分,以及 第八章,监控和调试神经网络训练,在 DL4J 训练 UI 和 Spark 部分展示的示例。记住,这个示例是基于 MNIST
数据集训练的手写数字图像分类。
在这些章节中,我们只使用了 MNIST
数据集的一部分进行训练,但下载的归档文件还包括一个名为 testing
的单独目录,包含了保留用于评估的数据集部分。评估数据集也需要像训练数据集一样进行向量化:
val testData = new ClassPathResource("/mnist_png/testing").getFile
val testSplit = new FileSplit(testData, NativeImageLoader.ALLOWED_FORMATS, randNumGen)
val testRR = new ImageRecordReader(height, width, channels, labelMaker)
testRR.initialize(testSplit)
val testIter = new RecordReaderDataSetIterator(testRR, batchSize, 1, outputNum)
testIter.setPreProcessor(scaler)
我们需要在评估时将其加载到内存之前进行此操作,并将其并行化:
val testDataList = mutable.ArrayBuffer.empty[DataSet]
while (testIter.hasNext) {
testDataList += testIter.next
}
val paralleltesnData = sc.parallelize(testDataList)
然后,可以通过 Evaluation
类进行评估,这正是我们在前一部分示例中所做的:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
var numEpochs: Int = 15
var i: Int = 0
for (i <- 0 until numEpochs) {
sparkNet.fit(paralleltrainData)
val eval = sparkNet.evaluate(parallelTestData)
println(eval.stats)
println("Completed Epoch {}", i)
trainIter.reset
testIter.reset
}
Evaluation
类的 stas
方法生成的输出与通过 DL4J 训练和评估的任何其他网络实现相同。例如:
也可以使用 SparkDl4jMultiLayer
类的 doEvaluation
方法在同一轮次中执行多次评估。该方法需要三个输入参数:要评估的数据(以 JavaRDD<org.nd4j.linalg.dataset.DataSet>
的形式),一个空的 Evaluation
实例,以及表示评估批量大小的整数。它返回填充后的 Evaluation
对象。
其他类型的评估
通过 DL4J API 还可以进行其他评估。此部分列出了它们。
也可以通过 RegressionEvaluation
类评估执行回归的网络(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/1.0.0-alpha/org/deeplearning4j/eval/RegressionEvaluation.html
,DL4J NN)。参考我们在 分类评估 部分中使用的示例,回归评估可以按以下方式进行:
val eval = new RegressionEvaluation(3)
val output = model.output(testData.getFeatureMatrix)
eval.eval(testData.getLabels, output)
println(eval.stats)
stats
方法的输出包括 MSE、MAE、RMSE、RSE 和 R²:
ROC(即接收者操作特征,en.wikipedia.org/wiki/Receiver_operating_characteristic
)是另一种常用的分类器评估指标。DL4J 为 ROC 提供了三种不同的实现:
-
ROC
:deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/ROC.html
,适用于二分类器的实现 -
ROCBinary
:deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/ROCBinary.html
,适用于多任务二分类器 -
ROCMultiClass
:deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/ROCMultiClass.html
,适用于多类分类器
前面提到的三个类都有计算ROC 曲线(AUROC)和精确度-召回曲线(AUPRC)下的面积的能力,计算方法分别是calculateAUC
和calculateAUPRC
。这三种 ROC 实现支持两种计算模式:
-
阈值化:它使用更少的内存,并近似计算 AUROC 和 AUPRC。这适用于非常大的数据集。
-
精确:这是默认设置。它精确,但需要更多的内存。不适合非常大的数据集。
可以将 AUROC 和 AUPRC 导出为 HTML 格式,以便使用网页浏览器查看。需要使用EvaluationTools
类的exportRocChartsToHtmlFile
方法(deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/evaluation/EvaluationTools.html
)进行此导出。此方法需要 ROC 实现和一个 File 对象(目标 HTML 文件)作为参数。它会将两条曲线保存在一个 HTML 文件中。
要评估具有二分类输出的网络,可以使用EvaluationBinary
类(deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/EvaluationBinary.html
)。该类为每个输出计算典型的分类指标(准确率、精确度、召回率、F1 得分等)。该类的语法如下:
val size:Int = 1
val eval: EvaluationBinary = new EvaluationBinary(size)
那么,时间序列评估(在 RNN 的情况下)如何呢?它与我们在本章中描述的分类评估方法非常相似。对于 DL4J 中的时间序列,评估是针对所有未被掩盖的时间步进行的。那什么是 RNN 的掩盖?RNN 要求输入具有固定长度。掩盖是一种用于处理这种情况的技术,它标记了缺失的时间步。与之前介绍的其他评估情况的唯一区别是掩盖数组的可选存在。这意味着,在许多时间序列的情况下,你可以直接使用 MultiLayerNetwork
类的 evaluate
或 evaluateRegression
方法——无论是否存在掩盖数组,它们都能正确处理。
DL4J 还提供了一种分析分类器校准的方法——EvaluationCalibration
类(deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/EvaluationCalibration.html
)。它提供了一些工具,例如:
-
每个类别的标签和预测数量
-
每个类别的概率直方图
使用此类对分类器的评估与其他评估类的方式类似。可以通过 EvaluationTools
类的 exportevaluationCalibrationToHtmlFile
方法将其图表和直方图导出为 HTML 格式。此方法需要传入 EvaluationCalibration
实例和文件对象(目标 HTML 文件)作为参数。
总结
在本章中,我们已经学习了如何使用 DL4J API 提供的不同工具来以编程方式评估模型的效率。我们已经完整地了解了使用 DL4J 和 Apache Spark 实现、训练和评估 MNN 的全过程。
下一章将为我们提供有关分发环境部署、导入和执行预训练的 Python 模型的见解,并对 DL4J 与其他 Scala 编程语言的替代深度学习框架进行比较。
第十章:在分布式系统上部署
本书接下来的章节将展示我们迄今为止学到的内容,以便实现一些实际的、现实世界中的 CNN 和 RNN 用例。但在此之前,我们先考虑一下 DL4J 在生产环境中的应用。本章分为四个主要部分:
-
关于 DL4J 生产环境设置的一些考虑,特别关注内存管理、CPU 和 GPU 设置以及训练作业的提交
-
分布式训练架构细节(数据并行性和 DL4J 中实现的策略)
-
在基于 DL4J(JVM)的生产环境中导入、训练和执行 Python(Keras 和 TensorFlow)模型的实际方法
-
DL4J 与几种替代的 Scala 编程语言 DL 框架的比较(特别关注它们在生产环境中的就绪性)
配置一个分布式环境与 DeepLearning4j
本节解释了一些设置 DL4J 神经网络模型训练和执行的生产环境时的技巧。
内存管理
在第七章,使用 Spark 训练神经网络章节中的性能考虑部分,我们学习了在训练或运行模型时,DL4J 如何处理内存。由于它依赖于 ND4J,它不仅使用堆内存,还利用堆外内存。作为堆外内存,它位于 JVM 的垃圾回收(GC)机制管理的范围之外(内存分配在 JVM 外部)。在 JVM 层面,只有指向堆外内存位置的指针;这些指针可以通过 Java 本地接口(JNI, docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/jniTOC.html
)传递给 C++代码,用于 ND4J 操作。
在 DL4J 中,可以使用两种不同的方法来管理内存分配:
-
JVM 垃圾回收(GC)和弱引用跟踪
-
内存工作空间
本节将涵盖这两种方法。它们的思想是相同的:一旦INDArray
不再需要,应该释放与其相关的堆外内存,以便可以重复使用。两种方法之间的区别如下:
-
JVM 垃圾回收(GC):当
INDArray
被垃圾回收器回收时,它的堆外内存会被释放,假设该内存不会在其他地方使用 -
内存工作空间:当
INDArray
离开工作空间范围时,它的堆外内存可以被重用,而无需进行释放和重新分配
请参考第七章,使用 Spark 训练神经网络章节中的性能考虑部分,了解如何配置堆内存和堆外内存的限制。
内存工作空间的方式需要更多解释。与 JVM 垃圾回收方法相比,它在循环工作负载的性能方面提供了最佳的结果。在工作空间内,任何操作都可以与 INDArrays
一起进行。然后,在工作空间循环结束时,内存中所有 INDArrays
的内容都会失效。是否需要在工作空间外部使用 INDArray
(当需要将结果移出工作空间时,可能会出现这种情况),可以使用 INDArray
本身的 detach
方法来创建它的独立副本。
从 DL4J 1.0.0-alpha 版本开始,工作空间默认启用。对于 DL4J 0.9.1 或更早版本,使用工作空间需要先进行激活。在 DL4J 0.9.1 中,在网络配置时,工作空间可以这样激活(用于训练):
val conf = new NeuralNetConfiguration.Builder()
.trainingWorkspaceMode(WorkspaceMode.SEPARATE)
或者在推理时,可以按以下方式激活:
val conf = new NeuralNetConfiguration.Builder()
.inferenceWorkspaceMode(WorkspaceMode.SINGLE)
SEPARATE
工作空间较慢,但使用的内存较少,而 SINGLE
工作空间较快,但需要更多的内存。选择 SEPARATE
和 SINGLE
之间的权衡,取决于你对内存占用和性能的平衡。在启用工作空间时,训练过程中使用的所有内存都会被重用并进行跟踪,且不受 JVM 垃圾回收的干扰。只有 output
方法,内部使用工作空间来进行前向传播循环,才是例外,但它会将生成的 INDArray
从工作空间中分离出来,这样它就可以由 JVM 垃圾回收器处理。从 1.0.0-beta 版本开始,SEPARATE
和 SINGLE
模式已被弃用,现有的模式是 ENABLED
(默认)和 NONE
。
请记住,当训练过程使用工作空间时,为了最大限度地利用这种方法,需要禁用定期的垃圾回收调用,具体如下:
Nd4j.getMemoryManager.togglePeriodicGc(false)
或者它们的频率需要减少,具体如下:
val gcInterval = 10000 // In milliseconds
Nd4j.getMemoryManager.setAutoGcWindow(gcInterval)
该设置应在调用模型的 fit
方法进行训练之前进行。工作空间模式也适用于 ParallelWrapper
(在仅要求 DL4J 进行训练的情况下,在同一服务器上运行多个模型)。
在某些情况下,为了节省内存,可能需要释放在训练或评估期间创建的所有工作空间。这可以通过调用 WorkspaceManager
的以下方法来完成:
Nd4j.getWorkspaceManager.destroyAllWorkspacesForCurrentThread
它会销毁调用线程中创建的所有工作空间。可以使用相同的方法在不再需要的外部线程中销毁创建的工作空间。
在 DL4J 1.0.0-alpha 版本及以后版本中,使用 nd4j-native
后端时,还可以使用内存映射文件代替 RAM。虽然这比较慢,但它允许以一种使用 RAM 无法实现的方式进行内存分配。这种选项主要适用于那些 INDArrays
无法放入 RAM 的情况。以下是如何以编程方式实现:
val mmap = WorkspaceConfiguration.builder
.initialSize(1000000000)
.policyLocation(LocationPolicy.MMAP)
.build
try (val ws = Nd4j.getWorkspaceManager.getAndActivateWorkspace(mmap, "M2")) {
val ndArray = Nd4j.create(20000) //INDArray
}
在这个例子中,创建了一个 2 GB 的临时文件,映射了一个工作空间,并在该工作空间中创建了 ndArray
INDArray
。
CPU 和 GPU 设置
正如本书前面所提到的,任何通过 DL4J 实现的应用程序都可以在 CPU 或 GPU 上执行。要从 CPU 切换到 GPU,需要更改 ND4J 的应用程序依赖。以下是 CUDA 9.2 版本(或更高版本)和支持 NVIDIA 硬件的示例(该示例适用于 Maven,但相同的依赖关系也可以用于 Gradle 或 sbt),如下所示:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-cuda-9.2</artifactId>
<version>0.9.1</version>
</dependency>
这个依赖替代了nd4j-native
的依赖。
当你的系统中有多个 GPU 时,是否应该限制它们的使用并强制在一个 GPU 上执行,可以通过nd4j-cuda
库中的CudaEnvironment
助手类(deeplearning4j.org/api/latest/org/nd4j/jita/conf/CudaEnvironment.html
)以编程方式进行更改。以下代码行需要作为 DL4J 应用程序入口点的第一条指令执行:
CudaEnvironment.getInstance.getConfiguration.allowMultiGPU(true)
在第 10.1.1 节中,我们已经学习了如何在 DL4J 中配置堆内存和堆外内存。在 GPU 执行时需要考虑一些问题。需要明确的是,命令行参数org.bytedeco.javacpp.maxbytes
和org.bytedeco.javacpp.maxphysicalbytes
定义了 GPU 的内存限制,因为对于INDArrays
,堆外内存被映射到 GPU 上(使用的是nd4j-cuda
)。
同样,在 GPU 上运行时,JVM 堆内存的使用量通常较少,而堆外内存的使用量较多,因为所有的INDArrays
都存储在堆外内存中。如果将太多内存分配给 JVM 堆内存,可能会导致堆外内存不足的风险。在进行适当设置时,在某些情况下,执行可能会导致以下异常:
RuntimeException: Can't allocate [HOST] memory: [memory]; threadId: [thread_id];
这意味着我们的堆外内存已经用完。在这种情况下(特别是在训练过程中),我们需要考虑使用WorkspaceConfiguration
来处理INDArrays
的内存分配(如在内存管理部分所学)。如果不这样做,INDArrays
及其堆外资源将通过 JVM GC 机制回收,这可能会显著增加延迟,并产生其他潜在的内存不足问题。
设置内存限制的命令行参数是可选的。如果没有指定,默认情况下,堆内存的限制为总系统内存的 25%,而堆外内存的限制是堆内存的两倍。我们需要根据实际情况找到最佳平衡,特别是在 GPU 执行时,考虑INDArrays
所需的堆外内存。
通常,CPU 内存大于 GPU 内存。因此,需要监控多少内存被用作堆外内存。DL4J 会在 GPU 上分配与通过上述命令行参数指定的堆外内存相等的内存。为了提高 CPU 和 GPU 之间的通信效率,DL4J 还会在 CPU 内存上分配堆外内存。这样,CPU 就可以直接访问 INDArray
中的数据,而无需每次都从 GPU 获取数据。
然而,有一个警告:如果 GPU 的内存少于 2 GB,那么它可能不适合用于深度学习(DL)生产工作负载。在这种情况下,应使用 CPU。通常,深度学习工作负载至少需要 4 GB 的内存(推荐在 GPU 上使用 8 GB 的内存)。
另一个需要考虑的因素是:使用 CUDA 后端并通过工作区,也可以使用 HOST_ONLY
内存。从编程角度来看,可以通过以下示例进行设置:
val basicConfig = WorkspaceConfiguration.builder
.policyAllocation(AllocationPolicy.STRICT)
.policyLearning(LearningPolicy.FIRST_LOOP)
.policyMirroring(MirroringPolicy.HOST_ONLY)
.policySpill(SpillPolicy.EXTERNAL)
.build
这样会降低性能,但在使用 INDArray
的 unsafeDuplication
方法时,它可以作为内存缓存对来使用,unsafeDuplication
方法能够高效地(但不安全地)进行 INDArray
复制。
构建一个作业并提交给 Spark 进行训练
在这个阶段,我假设你已经开始浏览并尝试本书相关的 GitHub 仓库中的代码示例(github.com/PacktPublishing/Hands-On-Deep-Learning-with-Apache-Spark
)。如果是这样,你应该已经注意到所有 Scala 示例都使用 Apache Maven(maven.apache.org/
)进行打包和依赖管理。在本节中,我将使用这个工具来构建一个 DL4J 作业,然后将其提交给 Spark 来训练模型。
一旦你确认开发的作业已经准备好在目标 Spark 集群中进行训练,首先要做的是构建 uber-JAR 文件(也叫 fat JAR 文件),它包含 Scala DL4J Spark 程序类和依赖项。检查项目 POM 文件中的 <dependencies>
块,确保所有必需的 DL4J 依赖项都已列出。确保选择了正确版本的 dl4j-Spark 库;本书中的所有示例都旨在与 Scala 2.11.x 和 Apache Spark 2.2.x 一起使用。代码应如下所示:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>dl4j-spark_2.11</artifactId>
<version>0.9.1_spark_2</version>
</dependency>
如果你的项目 POM 文件以及其他依赖项包含对 Scala 和/或任何 Spark 库的引用,请将它们的作用域声明为 provided
,因为它们已经在集群节点上可用。这样,uber-JAR 文件会更轻。
一旦检查了正确的依赖项,你需要在 POM 文件中指示如何构建 uber-JAR。构建 uber-JAR 有三种技术:unshaded、shaded 和 JAR of JARs。对于本案例,最好的方法是使用 shaded uber-JAR。与 unshaded 方法一样,它适用于 Java 默认的类加载器(因此无需捆绑额外的特殊类加载器),但它的优点是跳过某些依赖版本冲突,并且在多个 JAR 中存在相同路径的文件时,可以对其应用追加转换。Shading 可以通过 Maven 的 Shade 插件实现(maven.apache.org/plugins/maven-shade-plugin/
)。该插件需要在 POM 文件的<plugins>
部分注册,方法如下:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.1</version>
<configuration>
<!-- put your configurations here -->
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
当发出以下命令时,本插件会执行:
mvn package -DskipTests
在打包过程结束时,插件的最新版本会用 uber-JAR 替换精简版 JAR,并将其重命名为原始文件名。对于具有以下坐标的项目,uber-JAR 的名称将为rnnspark-1.0.jar
:
<groupId>org.googlielmo</groupId>
<artifactId>rnnspark</artifactId>
<version>1.0</version>
精简版 JAR 依然会被保留,但它会被重命名为original-rnnspark-1.0.jar
。它们都可以在项目根目录的target
子目录中找到。
然后,JAR 可以通过spark-submit
脚本提交到 Spark 集群进行训练,与提交任何其他 Spark 作业的方式相同,如下所示:
$SPARK_HOME/bin/spark-submit --class <package>.<class_name> --master <spark_master_url> <uber_jar>.jar
Spark 分布式训练架构细节
第七章中的分布式网络训练与 Spark 和 DeepLearning4J部分,使用 Spark 训练神经网络,解释了为什么将 MNNs(神经网络模型)以分布式方式在集群中进行训练是重要的,并指出 DL4J 采用了参数平均方法进行并行训练。本节详细介绍了分布式训练方法的架构细节(参数平均和梯度共享,后者从 DL4J 框架的 1.0.0-beta 版本开始替代了参数平均方法)。虽然 DL4J 的分布式训练方式对开发者是透明的,但了解它仍然是有益的。
模型并行性和数据并行性
并行化/分布式训练计算可以通过模型并行性或数据并行性来实现。
在模型并行性(见下图)中,集群的不同节点负责单个 MNN(神经网络模型)中不同部分的计算(例如,每一层网络分配给不同的节点):
图 10.1:模型并行性
在数据并行性(见下图)中,不同的集群节点拥有网络模型的完整副本,但它们获取不同子集的训练数据。然后,来自每个节点的结果会被合并,如下图所示:
图 10.2:数据并行性
这两种方法也可以结合使用,它们并不相互排斥。模型并行性在实践中效果很好,但对于分布式训练,数据并行性是首选;实施、容错和优化集群资源利用等方面(仅举几例)在数据并行性中比在模型并行性中更容易实现。
数据并行性方法需要某种方式来合并结果并同步各工作节点的模型参数。在接下来的两个小节中,我们将探讨 DL4J 中已实现的两种方法(参数平均化和梯度共享)。
参数平均化
参数平均化按以下方式进行:
-
主节点首先根据模型配置初始化神经网络参数
-
然后,它将当前参数的副本分发给每个工作节点
-
每个工作节点使用其自己的数据子集开始训练
-
主节点将全局参数设置为每个工作节点的平均参数
-
在需要处理更多数据的情况下,流程将从步骤 2重新开始
下图展示了从步骤 2到步骤 4的表示:
图 10.3:参数平均化
在此图中,W表示网络中的参数(权重和偏置)。在 DL4J 中,这一实现使用了 Spark 的 TreeAggregate(umbertogriffo.gitbooks.io/apache-spark-best-practices-and-tuning/content/treereduce_and_treeaggregate_demystified.html
)。
参数平均化是一种简单的方法,但它带来了一些挑战。最直观的平均化方法是每次迭代后直接对参数进行平均。虽然这种方法是可行的,但增加的开销可能非常高,网络通信和同步成本可能会抵消通过增加额外节点来扩展集群的任何好处。因此,参数平均化通常会在平均周期(每个工作节点的最小批次数量)大于一时实现。如果平均周期过于稀疏,每个工作节点的局部参数可能会显著偏离,导致模型效果不佳。合适的平均周期通常是每个工作节点每 10 到 20 次最小批次中进行一次。另一个挑战与优化方法(DL4J 的更新方法)相关。已有研究表明,这些方法(ruder.io/optimizing-gradient-descent/
)能够改善神经网络训练过程中的收敛性。但它们有一个内部状态,也可能需要进行平均化。这将导致每个工作节点的收敛速度更快,但代价是网络传输的大小翻倍。
异步随机梯度共享
异步随机梯度共享是最新版本的 DL4J(以及未来版本)所选择的方法。异步随机梯度共享和参数平均的主要区别在于,在异步随机梯度共享中,更新而不是参数被从工作者传递到参数服务器。从架构角度来看,这与参数平均类似(参见下图):
图 10.4:异步随机梯度共享架构
不同之处在于计算参数的公式:
在这里, 是缩放因子。通过允许将更新
在计算完成后立即应用到参数向量,从而得到异步随机梯度共享算法。
异步随机梯度共享的主要优点之一是,它可以在分布式系统中获得更高的吞吐量,而无需等待参数平均步骤完成,从而使工作者可以花更多时间进行有用的计算。另一个优点是:与同步更新的情况相比,工作者可以更早地结合来自其他工作者的参数更新。
异步随机梯度共享的一个缺点是所谓的陈旧梯度问题。梯度(更新)的计算需要时间,当一个工作者完成计算并将结果应用到全局参数向量时,参数可能已经更新了不止一次(这个问题在参数平均中看不出来,因为参数平均是同步的)。为了解决陈旧梯度问题,已经提出了几种方法。其中一种方法是根据梯度的陈旧程度,针对每次更新单独调整值 。另一种方法称为软同步:与其立即更新全局参数向量,参数服务器会等待收集来自任何学习者的一定数量的更新。然后,通过该公式更新参数:
在这里,s 是参数服务器等待收集的更新数量, 是与陈旧程度相关的标量缩放因子。
在 DL4J 中,尽管参数平均实现一直是容错的,但从 1.0.0-beta3 版本开始,梯度共享实现已完全具备容错能力。
将 Python 模型导入到 JVM 中使用 DL4J
在上一章中,我们已经学习了在配置、构建和训练多层神经网络模型时,DL4J API 是如何强大且易于使用的。仅依靠这个框架,在 Scala 或 Java 中实现新模型的可能性几乎是无穷无尽的。
但是,让我们来看一下 Google 的以下搜索结果;它们是关于网络上可用的 TensorFlow 神经网络模型:
图 10.5:关于 TensorFlow 神经网络模型的 Google 搜索结果
你可以看到,从结果来看,这是一个相当令人印象深刻的数字。而这只是一个原始搜索。将搜索精炼到更具体的模型实现时,数字会更高。但什么是 TensorFlow?TensorFlow (www.tensorflow.org/
) 是一个功能强大且全面的开源框架,专为机器学习(ML)和深度学习(DL)开发,由 Google Brain 团队研发。目前,它是数据科学家最常用的框架。因此,它拥有庞大的社区,许多共享的模型和示例可以使用。这也解释了这些庞大的数字。在这些模型中,找到一个符合你特定使用场景需求的预训练模型的几率是很高的。那么,问题在哪里呢?TensorFlow 主要是 Python。
它也支持其他编程语言,比如 Java(适用于 JVM),但是它的 Java API 目前还处于实验阶段,并且不包含在 TensorFlow 的 API 稳定性保证中。此外,TensorFlow 的 Python API 对于没有 Python 开发经验的开发者和没有或只有基础数据科学背景的软件工程师来说,学习曲线较为陡峭。那么,他们如何能从这个框架中受益呢?我们如何在基于 JVM 的环境中复用现有的有效模型?Keras (keras.io/
) 来解救了我们。它是一个开源的高层神经网络库,用 Python 编写,可以用来替代 TensorFlow 的高层 API(下图展示了 TensorFlow 框架架构):
图 10.6:TensorFlow 架构
相较于 TensorFlow,Keras 更加轻量,且更易于原型开发。它不仅可以运行在 TensorFlow 之上,还可以运行在其他后端 Python 引擎上。而且,Keras 还可以用于将 Python 模型导入到 DL4J。Keras 模型导入 DL4J 库提供了导入通过 Keras 框架配置和训练的神经网络模型的功能。
以下图示展示了一旦模型被导入到 DL4J 后,可以使用完整的生产堆栈来使用它:
图 10.7:将 Keras 模型导入 DL4J
现在,我们来详细了解这一过程。对于本节中的示例,我假设你已经在机器上安装了 Python 2.7.x 和 pip
(pypi.org/project/pip/
)包管理器。为了在 Keras 中实现模型,我们必须先安装 Keras 并选择一个后端(此处示例选择 TensorFlow)。必须首先安装 TensorFlow,如下所示:
sudo pip install tensorflow
这仅适用于 CPU。如果你需要在 GPU 上运行,需要安装以下内容:
sudo pip install tensorflow-gpu
现在,我们可以安装 Keras,如下所示:
sudo pip install keras
Keras 使用 TensorFlow 作为默认的张量操作库,因此如果我们选择 TensorFlow 作为后端,就无需采取额外的操作。
我们从简单的开始,使用 Keras API 实现一个 MLP 模型。在进行必要的导入后,输入以下代码:
from keras.models import Sequential
from keras.layers import Dense
我们创建一个 Sequential
模型,如下所示:
model = Sequential()
然后,我们通过 Sequential
的 add
方法添加层,如下所示:
model.add(Dense(units=64, activation='relu', input_dim=100))
model.add(Dense(units=10, activation='softmax'))
该模型的学习过程配置可以通过 compile
方法完成,如下所示:
model.compile(loss='categorical_crossentropy',
optimizer='sgd',
metrics=['accuracy'])
最后,我们将模型序列化为 HDF5 格式,如下所示:
model.save('basic_mlp.h5')
层次数据格式 (HDF) 是一组文件格式(扩展名为 .hdf5 和 .h5),用于存储和管理大量数据,特别是多维数字数组。Keras 使用它来保存和加载模型。
保存这个简单程序 basic_mlp.py
并运行后,模型将被序列化并保存在 basic_mlp.h5
文件中:
sudo python basic_mlp.py
现在,我们准备好将此模型导入到 DL4J 中。我们需要将通常的 DataVec API、DL4J 核心和 ND4J 依赖项,以及 DL4J 模型导入库添加到 Scala 项目中,如下所示:
groupId: org.deeplearning4j
artifactId: deeplearning4j-modelimport
version: 0.9.1
将 basic_mlp.h5
文件复制到项目的资源文件夹中,然后通过编程方式获取其路径,如下所示:
val basicMlp = new ClassPathResource("basic_mlp.h5").getFile.getPath
然后,通过 KerasModelImport
类的 importKerasSequentialModelAndWeights
方法(static.javadoc.io/org.deeplearning4j/deeplearning4j-modelimport/1.0.0-alpha/org/deeplearning4j/nn/modelimport/keras/KerasModelImport.html
)将模型加载为 DL4J 的 MultiLayerNetwork
,如下所示:
val model = KerasModelImport.importKerasSequentialModelAndWeights(basicMlp)
生成一些模拟数据,如下所示:
val input = Nd4j.create(256, 100)
var output = model.output(input)
现在,我们可以像往常一样在 DL4J 中训练模型,如下所示:
model.fit(input, output)
在 第七章,《使用 Spark 训练神经网络》,第八章,《监控与调试神经网络训练》,和 第九章,《解释神经网络输出》中关于训练、监控和评估 DL4J 的内容,也适用于这里。
当然,也可以在 Keras 中训练模型(如下示例):
model.fit(x_train, y_train, epochs=5, batch_size=32)
在这里,x_train
和 y_train
是 NumPy (www.numpy.org/
) 数组,并在保存为序列化格式之前进行评估,方法如下:
loss_and_metrics = model.evaluate(x_test, y_test, batch_size=128)
你可以像之前所解释的那样,导入预训练模型,然后直接运行它。
与 Sequential
模型导入一样,DL4J 也允许导入 Keras 的 Functional
模型。
最新版本的 DL4J 还允许导入 TensorFlow 模型。假设你想要导入这个(github.com/tensorflow/models/blob/master/official/mnist/mnist.py
)预训练模型(一个用于MNIST
数据库的 CNN 估算器)。在 TensorFlow 中进行训练后,你可以将模型保存为序列化格式。TensorFlow 的文件格式基于协议缓冲区(developers.google.com/protocol-buffers/?hl=en
),它是一种语言和平台中立的可扩展序列化机制,用于结构化数据。
将序列化的 mnist.pb
文件复制到 DL4J Scala 项目的资源文件夹中,然后通过编程方式获取并导入模型,方法如下:
val mnistTf = new ClassPathResource("mnist.pb").getFile
val sd = TFGraphMapper.getInstance.importGraph(mnistTf)
最后,给模型输入图像并开始进行预测,方法如下:
for(i <- 1 to 10){
val file = "images/img_%d.jpg"
file = String.format(file, i)
val prediction = predict(file) //INDArray
val batchedArr = Nd4j.expandDims(arr, 0) //INDArray
sd.associateArrayWithVariable(batchedArr, sd.variables().get(0))
val out = sd.execAndEndResult //INDArray
Nd4j.squeeze(out, 0)
...
}
Scala 编程语言的 DL4J 替代方案
DL4J 并不是唯一为 Scala 编程语言提供的深度学习框架,还有两个开源替代方案。在本节中,我们将详细了解它们,并与 DL4J 进行对比。
BigDL
BigDL (bigdl-project.github.io/0.6.0/
) 是一个开源的分布式深度学习框架,适用于 Apache Spark,由英特尔 (www.intel.com
) 实现。它使用与 DL4J 相同的 Apache 2.0 许可证。它是用 Scala 实现的,并暴露了 Scala 和 Python 的 API。它不支持 CUDA。虽然 DL4J 允许在独立模式(包括 Android 移动设备)和分布式模式(有或没有 Spark)下跨平台执行,但 BigDL 仅设计为在 Spark 集群中执行。现有的基准测试表明,训练/运行此框架比最流行的 Python 框架(如 TensorFlow 或 Caffe)要快,因为 BigDL 使用英特尔数学核心库(MKL,software.intel.com/en-us/mkl
),前提是它运行在基于英特尔处理器的机器上。
它为神经网络提供了高级 API,并且支持从 Keras、Caffe 或 Torch 导入 Python 模型。
尽管它是用 Scala 实现的,但在编写本章时,它仅支持 Scala 2.10.x。
从这个框架的最新发展来看,英特尔似乎将提供更多对导入通过其他框架实现的 Python 模型的支持(并且也开始支持一些 TensorFlow 操作)以及 Python API 的增强,而不是 Scala API。
那么,社区和贡献方面呢?BigDL 由英特尔支持和驱动,特别关注这个框架在基于其微处理器的硬件上的使用情况。因此,在其他生产硬件环境中采用这个框架可能存在潜在风险。而 DL4J 由 Skymind(skymind.ai/
)支持,该公司由 Adam Gibson 拥有,Adam Gibson 是该框架的作者之一。就未来发展而言,DL4J 的愿景并不局限于公司的业务。目标是使框架在功能上更全面,并尝试进一步缩小 JVM 语言与 Python 在可用数值计算和深度学习工具/功能方面的差距。同时,DL4J 的贡献者、提交和版本发布数量都在增加。
与 Scala BigDL API 相比,DL4J 对 Scala(和 Java)的 API 更高层次(某种程度的领域特定语言),这对于第一次接触深度学习的 Scala 开发者特别有帮助,因为它加快了熟悉框架的过程,并且让程序员可以更多地关注正在训练和实现的模型。
如果你的计划是留在 JVM 世界,我绝对认为 DL4J 比 BigDL 更合适。
DeepLearning.scala
DeepLearning.scala (deeplearning.thoughtworks.school/
) 是来自 ThoughtWorks (www.thoughtworks.com/
) 的深度学习框架。该框架用 Scala 实现,自开始以来,其目标就是最大化地利用函数式编程和面向对象编程范式。它支持 GPU 加速的 N 维数组。该框架中的神经网络可以通过数学公式构建,因此可以计算公式中权重的导数。
这个框架支持插件,因此可以通过编写自定义插件来扩展它,这些插件可以与开箱即用的插件集共存(目前在模型、算法、超参数、计算功能等方面有一套相当丰富的插件)。
DeepLearning.scala 应用程序可以作为独立程序在 JVM 上运行,作为 Jupyter (jupyter.org/
) 笔记本运行,或者作为 Ammonite (ammonite.io/
) 脚本运行。
数值计算通过 ND4J 进行,与 DL4J 相同。
它不支持 Python,也没有导入通过 Python 深度学习框架实现的模型的功能。
这个框架与其他框架(如 DL4J 和 BigDL)之间的一个大区别如下:神经网络的结构在运行时动态确定。所有的 Scala 语言特性(函数、表达式、控制流等)都可以用于实现。神经网络是 Scala 单子(Monads),因此可以通过组合高阶函数来创建,但这不是 DeepLearning.scala 中唯一的选项;该框架还提供了一种类型类 Applicative
(通过 Scalaz 库,eed3si9n.com/learning-scalaz/Applicative.html
),它允许并行计算多个任务。
本章撰写时,该框架并未提供对 Spark 或 Hadoop 的原生支持。
在不需要 Apache Spark 分布式训练的环境下,DeepLearning.scala 可以是 DL4J 的一个不错替代选择,特别是在你希望使用纯 Scala 实现的情况下。在该编程语言的 API 方面,它比 DL4J 更遵循纯 Scala 编程原则,而 DL4J 的目标是所有在 JVM 上运行的语言(从 Java 开始,然后扩展到 Scala、Clojure 等,甚至包括 Android)。
这两个框架的最初愿景也不同:DL4J 开始时针对的是软件工程师,而 DeepLearning.scala 的方法则更多地面向数据科学家。它在生产环境中的稳定性和性能仍待验证,因为它比 DL4J 更年轻,并且在实际使用案例中的采用者较少。缺乏对从 Python 框架导入现有模型的支持也可能是一个限制,因为你需要从头开始构建和训练模型,而无法依赖于现有的 Python 模型,后者可能非常适合你的特定用例。在社区和发布方面,目前它当然无法与 DL4J 和 BigDL 相提并论(尽管它有可能在不久的将来增长)。最后但同样重要的是,官方文档和示例仍然有限,且尚未像 DL4J 那样成熟和全面。
总结
本章讨论了将 DL4J 移动到生产环境时需要考虑的一些概念。特别是,我们理解了堆内存和非堆内存管理的设置方式,了解了 GPU 配置的额外考虑因素,学习了如何准备要提交给 Spark 进行训练的作业 JAR 文件,并看到如何将 Python 模型导入并集成到现有的 DL4J JVM 基础设施中。最后,介绍了 DL4J 与另外两个针对 Scala 的深度学习框架(BigDL 和 DeepLearning.scala)之间的比较,并详细阐述了为什么从生产角度来看,DL4J 可能是一个更好的选择。
在下一章,将解释自然语言处理(NLP)的核心概念,并详细介绍使用 Apache Spark 及其 MLLib(机器学习库)实现 NLP 的完整 Scala 实现。我们将在第十二章中,文本分析与深度学习,介绍使用 DL4J 和/或 Keras/TensorFlow 实现相同的解决方案,并探讨这种方法的潜在局限性。
第十一章:自然语言处理基础
在前一章中,涉及了在 Spark 集群中进行深度学习分布式训练的多个主题。那里介绍的概念适用于任何网络模型。从本章开始,将首先讨论 RNN 或 LSTM 的具体应用场景,接着介绍 CNN 的应用。本章开始时,将介绍以下自然语言处理(NLP)的核心概念:
-
分词器
-
句子分割
-
词性标注
-
命名实体提取
-
词块分析
-
语法分析
上述概念背后的理论将被详细讲解,最后将呈现两个完整的 Scala 例子,一个使用 Apache Spark 和斯坦福核心 NLP 库,另一个使用 Spark 核心和Spark-nlp
库(该库构建在 Apache Spark MLLib 之上)。本章的目标是让读者熟悉 NLP,然后进入基于深度学习(RNN)的实现,使用 DL4J 和/或 Keras/Tensorflow 结合 Spark,这将是下一章的核心内容。
自然语言处理(NLP)
自然语言处理(NLP)是利用计算机科学和人工智能处理与分析自然语言数据,使机器能够像人类一样理解这些数据的领域。在 1980 年代,当这个概念开始受到关注时,语言处理系统是通过手动编写规则来设计的。后来,随着计算能力的增加,一种主要基于统计模型的方法取代了原来的方法。随后的机器学习(ML)方法(最初是监督学习,目前也有半监督或无监督学习)在这一领域取得了进展,例如语音识别软件和人类语言翻译,并且可能会引领更复杂的场景,例如自然语言理解和生成。
下面是 NLP 的工作原理。第一个任务,称为语音转文本过程,是理解接收到的自然语言。一个内置模型执行语音识别,将自然语言转换为编程语言。这个过程通过将语音分解为非常小的单元,然后与之前输入的语音单元进行比较来实现。输出结果确定最可能被说出的单词和句子。接下来的任务,称为词性标注(POS)(在一些文献中也称为词类消歧),使用一组词汇规则识别单词的语法形式(名词、形容词、动词等)。完成这两个阶段后,机器应该能够理解输入语音的含义。NLP 过程的第三个任务可能是文本转语音转换:最终,编程语言被转换为人类可以理解的文本或语音格式。这就是 NLP 的最终目标:构建能够分析、理解并自然生成语言的软件,使计算机能够像人类一样进行交流。
给定一段文本,在实现 NLP 时,有三件事需要考虑和理解:
-
语义信息:单个词的具体含义。例如,考虑单词pole,它可能有不同的含义(磁铁的一端、一根长棍等)。在句子极右和极左是政治系统的两个极端中,为了理解正确的含义,了解极端的相关定义非常重要。读者可以很容易地推断出它指的是哪种含义,但机器在没有机器学习(ML)或深度学习(DL)的情况下无法做到这一点。
-
语法信息:短语结构。考虑句子William 加入了拥有丰富国际经验的足球队。根据如何解读,它有不同的含义(可能是威廉拥有丰富的国际经验,也可能是足球队拥有丰富的国际经验)。
-
上下文信息:词语或短语出现的上下文。例如,考虑形容词low。它在便利的上下文中通常是积极的(例如,这款手机价格很低),但在谈到供应时几乎总是消极的(例如,饮用水供应不足)。
以下小节将解释 NLP 监督学习的主要概念。
分词器
分词意味着在 NLP 机器学习算法中定义一个词是什么。给定一段文本,分词任务是将其切分成片段,称为tokens,同时去除特定字符(如标点符号或分隔符)。例如,给定以下英语输入句子:
To be, or not to be, that is the question
分词的结果将产生以下 11 个 tokens:
To be or or not to be that is the question
分词的一个大挑战是如何确定正确的 tokens。在前一个示例中,决定是很容易的:我们去除了空格和所有标点符号字符。但如果输入文本不是英语呢?例如中文等其他语言,没有空格,前述规则就不起作用了。因此,任何针对 NLP 的机器学习或深度学习模型训练都应考虑到特定语言的规则。
但即使仅限于单一语言,比如英语,也可能出现棘手的情况。考虑以下示例句子:
David Anthony O'Leary 是一位爱尔兰足球经理和前球员
如何处理撇号?在这种情况下,O'Leary
有五种可能的分词方式,分别如下:
-
leary
-
oleary
-
o'leary
-
o' leary
-
o leary
那么,哪个是期望的结果呢?一个快速想到的简单策略可能是把句子中的所有非字母数字字符去掉。因此,获取o
和leary
这些 tokens 是可以接受的,因为用这些 tokens 进行布尔查询搜索会匹配五个案例中的三个。但以下这个句子呢?
Michael O'Leary 批评了在爱尔兰航空罢工的机组人员,称“他们没有被像西伯利亚盐矿工一样对待”。
对于aren't,有四种可能的词元拆分方式,如下:
-
aren't
-
arent
-
are n't
-
aren t
再次强调,虽然o
和leary
的拆分看起来没问题,但aren
和t
的拆分怎么样呢?最后这个拆分看起来不太好;用这些词元做布尔查询搜索,只有四种情况中的两种能匹配。
词元化的挑战和问题是语言特定的。在这种情况下,需要深入了解输入文档的语言。
句子分割
句子分割是将文本拆分成句子的过程。从定义来看,这似乎是一个简单的过程,但也可能会遇到一些困难,例如,存在可能表示不同含义的标点符号:
Streamsets 公司发布了新的 Data Collector 3.5.0 版本。新功能之一是 MongoDB 查找处理器。
看一下前面的文本,你会发现同一个标点符号(.
)被用来表示三种不同的意思,而不仅仅是作为句子的分隔符。某些语言,比如中文,拥有明确的句尾标记,而其他语言则没有。因此,必须制定一个策略。在像前面例子中这种情况下,找到句子结束的位置的最快且最粗暴的方法是:
-
如果是句号,那么它表示一句话的结束。
-
如果紧接句号的词元出现在预先编译的缩写词列表中,那么句号不表示句子的结束。
-
如果句号后的下一个词元是大写字母开头的,那么句号表示一句话的结束。
这个方法能正确处理超过 90%的句子,但可以做得更智能一些,比如使用基于规则的边界消歧技术(自动从标记过句子断句的输入文档中学习一组规则),或者更好的是,使用神经网络(这可以达到超过 98%的准确率)。
词性标注
词性标注是自然语言处理中根据单词的定义和上下文标记文本中每个单词为相应词性的过程。语言有九大类词性——名词、动词、形容词、冠词、代词、副词、连词、介词和感叹词。每一类都有子类。这个过程比词元化和句子分割更复杂。词性标注不能是通用的,因为根据上下文,相同的单词在同一文本中的句子中可能具有不同的词性标签,例如:
请锁好门,并且不要忘记把钥匙留在锁里。
在这里,单词 lock
在同一句话中被两次使用,且含义不同(作为动词和名词)。不同语言之间的差异也应该考虑在内。因此,这是一个无法手动处理的过程,应该由机器来完成。使用的算法可以是基于规则的,也可以是基于随机的。基于规则的算法,为了给未知(或至少模糊的)单词分配标签,利用上下文信息。通过分析单词的不同语言特征,如前后的单词,可以实现歧义消解。基于规则的模型从一组初始规则和数据开始训练,尝试推断出 POS 标注的执行指令。随机标注器涉及不同的方法;基本上,任何包含概率或频率的模型都可以这样标记。一种简单的随机标注器可以仅通过单词与特定标签发生的概率来消除歧义。当然,更复杂的随机标注器效率更高。最流行的之一是隐藏马尔可夫模型(en.wikipedia.org/wiki/Hidden_Markov_model
),这是一种统计模型,其中被建模的系统被假设为具有隐藏状态的马尔可夫过程(en.wikipedia.org/wiki/Markov_chain
)。
命名实体识别(NER)
NER 是 NLP 的一个子任务,其目标是在文本中定位和分类命名实体,并将其划分为预定义的类别。让我们举个例子。我们有以下句子:
Guglielmo 正在为 Packt Publishing 写一本书,出版时间为 2018 年。
对其进行 NER 处理后,得到以下注释文本:
[Guglielmo][人名] 正在为 [Packt Publishing][组织] 写一本书,出版时间为 [2018][时间] 。
已经检测到三个实体,一个人,Guglielmo
,一个由两个标记组成的组织,Packt Publishing
,以及一个时间表达,2018
。
传统上,NER 应用于结构化文本,但最近,非结构化文本的使用案例数量有所增加。
自动化实现此过程的挑战包括大小写敏感性(早期算法经常无法识别例如 Guglielmo Iozzia 和 GUGLIELMO IOZZIA 是同一个实体)、标点符号的不同使用以及缺失的分隔符。NER 系统的实现使用了基于语言学语法的技术、统计模型和机器学习。基于语法的系统可以提供更高的精度,但在经验丰富的语言学家工作数月的成本上有很大开销,而且召回率较低。基于机器学习的系统具有较高的召回率,但需要大量手动标注的数据来进行训练。无监督方法正在崭露头角,旨在大幅减少数据标注的工作量。
这个过程的另一个挑战是上下文领域——一些研究表明,为一个领域开发的命名实体识别(NER)系统(在该领域达到较高的性能)通常在其他领域表现不佳。例如,一个已经在 Twitter 内容上训练过的 NER 系统,不能简单地应用到医疗记录中,并期望它能达到同样的性能和准确性。这适用于基于规则和统计/机器学习的系统;在新的领域中调整 NER 系统以达到在原始领域成功训练时的同样性能,需要付出相当大的努力。
分块
自然语言处理中的分块(Chunking)是从文本中提取短语的过程。使用分块的原因是,因为简单的词语可能无法代表所分析文本的真实含义。举个例子,考虑短语Great Britain;虽然这两个单独的词语有意义,但更好的做法是将Great Britain作为一个整体来使用。分块通常建立在词性标注(POS tagging)的基础上;通常,词性标注是输入,而分块则是它的输出。这个过程与人类大脑将信息分块以便于处理和理解的方式非常相似。想一想你记忆数字序列(例如借记卡密码、电话号码等)的方式;你通常不会把它们当作单独的数字来记,而是试图将它们分组,以便更容易记住。
分块可以向上或向下进行。向上分块更倾向于抽象化;向下分块则更倾向于寻找更具体的细节。举个例子,考虑在一个票务销售和分发公司的电话中发生的场景。接线员问:“您想购买哪种类型的票?”顾客的回答是:“音乐会票”,这属于向上分块,因为它更倾向于一个更高层次的抽象。然后,接线员提出更多问题,如:“哪种类型”,“哪位艺术家或团体”,“哪个日期和地点”,“多少人”,“哪个区域”等等,以获得更多细节并满足顾客的需求(这就是向下分块)。最终,你可以将分块视为一种集合的层次结构。对于一个特定的上下文,总是有一个更高层次的集合,它有子集,每个子集又可以有其他子集。例如,可以考虑编程语言作为一个更高层次的子集;然后你可以得到以下情况:
编程语言
Scala(编程语言的子集)
Scala 2.11(Scala 的子集)
特性(Scala 的特定概念之一)
迭代器(Scala 的核心特性之一)
解析
NLP 中的解析是确定文本句法结构的过程。它通过分析文本的组成词汇进行工作,并基于文本所在语言的基础语法。解析的结果是输入文本每个句子的解析树。解析树是一个有序的、带根的树,表示句子的句法结构,依据的是某种上下文无关语法(描述给定形式语言中所有可能字符串的规则集)。我们来举个例子。考虑英语语言和以下的语法示例:
sentence -> 名词短语,动词短语
noun-phrase -> 专有名词
noun-phrase -> 决定词,名词
verb-phrase -> 动词,名词短语
考虑短语 Guglielmo wrote a book
,并对其应用解析过程。输出将是这样的解析树:
目前,自动化机器解析的方法主要是统计的、概率的或机器学习(ML)方法。
使用 Spark 进行 NLP 实践
在本节中,将详细介绍在 Apache Spark 中实现 NLP(以及前述核心概念)的几个示例。这些示例不包括 DL4J 或其他深度学习框架,因为多层神经网络的 NLP 将是下一章的主要内容。
虽然 Spark 的核心组件之一,MLLib,是一个机器学习库,但它并未提供 NLP 的相关功能。因此,您需要在 Spark 上使用其他 NLP 库或框架。
使用 Spark 和 Stanford Core NLP 进行 NLP 实践
本章的第一个示例涉及使用 Scala 包装的 Stanford Core NLP (github.com/stanfordnlp/CoreNLP
) 库,它是开源的,并以 GNU 通用公共许可证 v3 发布 (www.gnu.org/licenses/gpl-3.0.en.html
)。它是一个 Java 库,提供一套自然语言分析工具。其基本分发版提供了用于分析英语的模型文件,但该引擎也兼容其他语言的模型。它稳定且适用于生产环境,广泛应用于学术和工业领域。Spark CoreNLP (github.com/databricks/spark-corenlp
) 是 Stanford Core NLP Java 库的 Apache Spark 封装。它已用 Scala 实现。Stanford Core NLP 注释器已作为 Spark DataFrame 封装。
spark-corenlp 库的当前版本包含一个 Scala 类函数,提供了所有高级封装方法,如下所示:
-
cleanXml
:输入一个 XML 文档,并移除所有 XML 标签。 -
tokenize
:将输入句子分割成单词。 -
ssplit
:将输入文档分割成句子。 -
pos
:生成输入句子的词性标签。 -
lemma
:生成输入句子的词形还原。 -
ner
:生成输入句子的命名实体标签。 -
depparse
:生成输入句子的语义依赖关系。 -
coref
:生成输入文档的coref
链。 -
natlog
:生成输入句子中每个词元的自然逻辑极性。可能的返回值有:up(上升)、down(下降)或 flat(平稳)。 -
openie
:生成一组开放的 IE 三元组,表示为扁平的四元组。 -
sentiment
:测量输入句子的情感。情感评分的范围是从零(强烈负面)到四(强烈正面)。
首先要做的是设置此示例的依赖项。它依赖于 Spark SQL 和 Stanford core NLP 3.8.0(需要通过 Models
分类器显式指定模型的导入),如下所示:
groupId: edu.stanford.nlp
artifactId: stanford-corenlp
version: 3.8.0
groupId: edu.stanford.nlp
artifactId: stanford-corenlp
version: 3.8.0
classifier: models
当您只需要处理某一种语言时,例如西班牙语,您也可以选择仅导入该语言的模型,通过其特定的分类器,方式如下:
groupId: edu.stanford.nlp
artifactId: stanford-corenlp
version: 3.8.0
classifier: models-spanish
在 Maven 中央库中没有可用的 spark-corenlp
库。因此,您必须从 GitHub 源代码构建其 JAR 文件,然后将其添加到您的 NLP 应用程序的类路径中,或者如果您的应用程序依赖于某个工件库(例如 JFrog Artifactory(jfrog.com/artifactory/
)、Apache Archiva(archiva.apache.org/index.cgi
)或 Sonatype Nexus OSS(www.sonatype.com/nexus-repository-oss
)),请将 JAR 文件存储在那里,并按照与 Maven 中央库中任何其他依赖项相同的方式,将其依赖关系添加到您的项目构建文件中。
我之前提到过,spark-corenlp
将 Stanford core NLP 注释器封装为 DataFrame。因此,源代码中需要做的第一件事是创建一个 SparkSession
,如下所示:
val sparkSession = SparkSession
.builder()
.appName("spark-corenlp example")
.master(master)
.getOrCreate()
现在,创建一个 Sequence
(www.scala-lang.org/api/current/scala/collection/Seq.html
)来表示输入文本内容(XML 格式),然后将其转换为 DataFrame,如下所示:
import sparkSession.implicits._
val input = Seq(
(1, "<xml>Packt is a publishing company based in Birmingham and Mumbai. It is a great publisher.</xml>")
).toDF("id", "text")
给定这个输入,我们可以使用 functions
可用的方法执行不同的 NLP 操作,例如从标签中清理输入的 XML(包含在 input
DataFrame 的 text
字段中)、将每个句子拆分成单词、生成每个句子的命名实体标签,并测量每个句子的情感等操作:
val output = input
.select(cleanxml('text).as('doc))
.select(explode(ssplit('doc)).as('sen))
.select('sen, tokenize('sen).as('words), ner('sen).as('nerTags), sentiment('sen).as('sentiment))
最后,我们打印这些操作的输出(output
本身是一个 DataFrame),如下所示:
output.show(truncate = false)
最后,我们需要停止并销毁 SparkSession
,如下所示:
sparkSession.stop()
执行此示例时,输出如下:
XML 内容已经从标签中清除,句子已按照预期拆分成单个单词,对于某些单词(如Birmingham
、Mumbai
),已生成命名实体标签(LOCATION
)。并且,对于输入的两句话,情感分析结果为积极(3
)!
这种方法是开始使用 Scala 和 Spark 进行 NLP 的好方式;该库提供的 API 简单且高层次,能够帮助人们快速理解 NLP 的核心概念,同时利用 Spark DataFrames 的强大功能。但它也有缺点。当需要实现更复杂和定制化的 NLP 解决方案时,现有的 API 过于简单,难以应对。此外,如果你的最终系统不仅仅是内部使用,而是计划销售并分发给客户,则可能会出现许可问题;斯坦福核心 NLP 库和spark-corenlp
模型依赖于并且在完整的 GNU GPL v3 许可下发布,禁止将其作为专有软件的一部分重新分发。下一节介绍了一个更可行的 Scala 和 Spark 替代方案。
使用 Spark NLP 进行实践操作
另一个可与 Apache Spark 集成以进行 NLP 的替代库是 John Snow Labs 的spark-nlp
(nlp.johnsnowlabs.com/
)(www.johnsnowlabs.com/
)。它是开源的,并且在 Apache 许可证 2.0 下发布,因此与spark-corenlp
不同,它的许可模式使得可以将其作为商业解决方案的一部分重新分发。它是在 Scala 上实现的,基于 Apache Spark ML 模块,并且可以在 Maven 中央仓库中找到。它为机器学习流水线提供了 NLP 注释,这些注释既易于理解和使用,又具有出色的性能,并且能够在分布式环境中轻松扩展。
我在本节中提到的版本是 1.6.3(本书写作时的最新版本)。
spark-nlp
的核心概念是 Spark ML 流水线(spark.apache.org/docs/2.2.1/api/java/org/apache/spark/ml/Pipeline.html
)。一个流水线由一系列阶段组成。每个阶段可以是一个变换器(spark.apache.org/docs/2.2.1/api/java/org/apache/spark/ml/Transformer.html
)或一个估算器(spark.apache.org/docs/2.2.1/api/java/org/apache/spark/ml/Estimator.html
)。变换器将输入数据集转换为另一个数据集,而估算器则将模型拟合到数据上。当流水线的拟合方法被调用时,其各个阶段会按顺序执行。现有三种类型的预训练流水线:基础型、进阶型和情感型。该库还提供了多个预训练的 NLP 模型和多个标注器。但为了澄清 spark-nlp
的核心概念,让我们从一个简单的示例开始。我们尝试实现一个基于 ML 的命名实体标签提取的基本流水线。
以下示例依赖于 Spark SQL 和 MLLib 组件以及spark-nlp
库:
groupId: com.johnsnowlabs.nlp
artifactId: spark-nlp_2.11
version: 1.6.3
我们需要首先启动一个SparkSession
,如下所示:
val sparkSession: SparkSession = SparkSession
.builder()
.appName("Ner DL Pipeline")
.master("local[*]")
.getOrCreate()
在创建管道之前,我们需要实现其各个阶段。第一个阶段是com.johnsnowlabs.nlp.DocumentAssembler
,用于指定应用程序输入的列以进行解析,并指定输出列名称(该列将作为下一个阶段的输入列),如下所示:
val document = new DocumentAssembler()
.setInputCol("text")
.setOutputCol("document")
下一个阶段是Tokenizer
(com.johnsnowlabs.nlp.annotators.Tokenizer
),如下所示:
val token = new Tokenizer()
.setInputCols("document")
.setOutputCol("token")
在此阶段之后,任何输入的句子应该已经被拆分成单个词语。我们需要清理这些词语,因此下一个阶段是normalizer
(com.johnsnowlabs.nlp.annotators.Normalizer
),如下所示:
val normalizer = new Normalizer()
.setInputCols("token")
.setOutputCol("normal")
现在我们可以使用spaek-nlp
库中的一个预训练模型来生成命名实体标签,如下所示:
val ner = NerDLModel.pretrained()
.setInputCols("normal", "document")
.setOutputCol("ner")
这里我们使用了NerDLModel
类(com.johnsnowlabs.nlp.annotators.ner.dl.NerDLModel
),它背后使用的是一个 TensorFlow 预训练模型。该模型生成的命名实体标签采用 IOB 格式(en.wikipedia.org/wiki/Inside%E2%80%93outside%E2%80%93beginning_(tagging)
),因此我们需要将它们转换为更易读的格式。我们可以使用NerConverter
类(com.johnsnowlabs.nlp.annotators.ner.NerConverter
)来实现这一点,如下所示:
val nerConverter = new NerConverter()
.setInputCols("document", "normal", "ner")
.setOutputCol("ner_converter")
最后一个阶段是最终化管道的输出,如下所示:
val finisher = new Finisher()
.setInputCols("ner", "ner_converter")
.setIncludeMetadata(true)
.setOutputAsArray(false)
.setCleanAnnotations(false)
.setAnnotationSplitSymbol("@")
.setValueSplitSymbol("#")
为此,我们使用了Finisher
转换器(com.johnsnowlabs.nlp.Finisher
)。
现在我们可以使用目前创建的阶段来构建管道,如下所示:
val pipeline = new Pipeline().setStages(Array(document, token, normalizer, ner, nerConverter, finisher))
你可能已经注意到,每个阶段的输出列是下一个阶段输入列的输入。这是因为管道的各个阶段会按它们在setStages
方法的输入Array
中列出的顺序依次执行。
现在让我们给应用程序输入一些句子,如下所示:
val testing = Seq(
(1, "Packt is a famous publishing company"),
(2, "Guglielmo is an author")
).toDS.toDF( "_id", "text")
与前一节中spaek-corenlp
的示例相同,我们为输入文本内容创建了一个Sequence
,然后将其转换为 Spark DataFrame。
通过调用pipeline
的fit
方法,我们可以执行所有阶段,如下所示:
val result = pipeline.fit(Seq.empty[String].toDS.toDF("text")).transform(testing)
然后,我们得到如下的结果 DataFrame 输出:
result.select("ner", "ner_converter").show(truncate=false)
这将产生以下输出:
当我们仔细观察时,情况如下所示:
已为单词Packt
生成了一个ORGANIZATION
命名实体标签,为单词Guglielmo
生成了一个PERSON
命名实体标签。
spark-nlp
还提供了一个类,com.johnsnowlabs.util.Benchmark
,用于执行管道执行的基准测试,例如:
Benchmark.time("Time to convert and show") {result.select("ner", "ner_converter").show(truncate=false)}
最后,我们在管道执行结束时停止SparkSession
,如下所示:
sparkSession.stop
现在让我们做一些更复杂的事情。这个第二个示例中的管道使用 n-grams 进行分词(en.wikipedia.org/wiki/N-gram
),它是从给定的文本或语音中提取的n个标记(通常是单词)的序列。此示例的依赖项与本节前面展示的示例相同——Spark SQL、Spark MLLib 和spark-nlp
。
创建SparkSession
并配置一些 Spark 属性,如下所示:
val sparkSession: SparkSession = SparkSession
.builder()
.appName("Tokenize with n-gram example")
.master("local[*]")
.config("spark.driver.memory", "1G")
.config("spark.kryoserializer.buffer.max","200M")
.config("spark.serializer","org.apache.spark.serializer.KryoSerializer")
.getOrCreate()
管道的前三个阶段与之前的示例相同,如下所示:
val document = new DocumentAssembler()
.setInputCol("text")
.setOutputCol("document")
val token = new Tokenizer()
.setInputCols("document")
.setOutputCol("token")
val normalizer = new Normalizer()
.setInputCols("token")
.setOutputCol("normal")
在使用 n-gram 阶段之前添加一个finisher
阶段,如下所示:
val finisher = new Finisher()
.setInputCols("normal")
n-gram 阶段使用了 Spark MLLib 中的NGram
类(spark.apache.org/docs/2.2.1/api/scala/index.html#org.apache.spark.ml.feature.NGram
),如下所示:
val ngram = new NGram()
.setN(3)
.setInputCol("finished_normal")
.setOutputCol("3-gram")
NGram
是一个特征变换器,它将输入的字符串数组转换为 n-grams 数组。在这个示例中,选择的n值是3
。现在,我们需要一个额外的DocumentAssembler
阶段来处理 n-gram 的结果,如下所示:
val gramAssembler = new DocumentAssembler()
.setInputCol("3-gram")
.setOutputCol("3-grams")
让我们实现管道,如下所示:
val pipeline = new Pipeline().setStages(Array(document, token, normalizer, finisher, ngram, gramAssembler))
现在,用与之前示例相同的输入句子来运行应用程序:
import sparkSession.implicits._
val testing = Seq(
(1, "Packt is a famous publishing company"),
(2, "Guglielmo is an author")
).toDS.toDF( "_id", "text")
然后执行管道的各个阶段,如下所示:
val result = pipeline.fit(Seq.empty[String].toDS.toDF("text")).transform(testing)
将结果打印到屏幕上:
result.show(truncate=false)
这会生成以下输出:
最后,我们停止SparkSession
,如下所示:
sparkSession.stop
最后的示例是使用 Vivek Narayanan(github.com/vivekn
)模型进行的机器学习情感分析。情感分析是自然语言处理的一个实际应用,它是通过计算机识别和分类文本中表达的意见,以确定其作者/讲述者对某一产品或话题的态度是积极的、消极的,还是中立的。特别地,在这个示例中,我们将训练并验证电影评论的模型。此示例的依赖项与往常一样——Spark SQL、Spark MLLib 和spark-nlp
。
如往常一样,创建一个SparkSession
(同时配置一些 Spark 属性),如下所示:
val spark: SparkSession = SparkSession
.builder
.appName("Train Vivek N Sentiment Analysis")
.master("local[*]")
.config("spark.driver.memory", "2G")
.config("spark.kryoserializer.buffer.max","200M")
.config("spark.serializer","org.apache.spark.serializer.KryoSerializer")
.getOrCreate
然后我们需要两个数据集,一个用于训练,一个用于测试。为了简便起见,我们将训练数据集定义为一个Sequence
,然后将其转换为 DataFrame,其中列为评论文本和相关情感,如下所示:
import spark.implicits._
val training = Seq(
("I really liked it!", "positive"),
("The cast is horrible", "negative"),
("Never going to watch this again or recommend it", "negative"),
("It's a waste of time", "negative"),
("I loved the main character", "positive"),
("The soundtrack was really good", "positive")
).toDS.toDF("train_text", "train_sentiment")
While the testing data set could be a simple Array:
val testing = Array(
"I don't recommend this movie, it's horrible",
"Dont waste your time!!!"
)
我们现在可以定义管道的各个阶段。前面三个阶段与之前示例管道中的完全相同(DocumentAssembler
、Tokenizer
和Normalizer
),如下所示:
val document = new DocumentAssembler()
.setInputCol("train_text")
.setOutputCol("document")
val token = new Tokenizer()
.setInputCols("document")
.setOutputCol("token")
val normalizer = new Normalizer()
.setInputCols("token")
.setOutputCol("normal")
我们现在可以使用com.johnsnowlabs.nlp.annotators.sda.vivekn.ViveknSentimentApproach
注解器,如下所示:
val vivekn = new ViveknSentimentApproach()
.setInputCols("document", "normal")
.setOutputCol("result_sentiment")
.setSentimentCol("train_sentiment")
And finally we use a Finisher transformer as last stage:
val finisher = new Finisher()
.setInputCols("result_sentiment")
.setOutputCols("final_sentiment")
使用之前定义的各个阶段创建管道:
val pipeline = new Pipeline().setStages(Array(document, token, normalizer, vivekn, finisher))
然后开始训练,如下所示:
val sparkPipeline = pipeline.fit(training)
一旦训练完成,我们可以使用以下测试数据集进行测试:
val testingDS = testing.toSeq.toDS.toDF("testing_text")
println("Updating DocumentAssembler input column")
document.setInputCol("testing_text")
sparkPipeline.transform(testingDS).show()
输出结果如下:
测试数据集中的两个句子已经被正确标记为负面。
当然,也可以通过spark-nlp
的Benchmark
类来进行情感分析的基准测试,如下所示:
Benchmark.time("Spark pipeline benchmark") {
val testingDS = testing.toSeq.toDS.toDF("testing_text")
println("Updating DocumentAssembler input column")
document.setInputCol("testing_text")
sparkPipeline.transform(testingDS).show()
}
在本节结束时,我们可以说明spak-nlp
提供了比spark-corenlp
更多的功能,且与 Spark MLLib 集成良好,并且得益于其许可模型,在应用程序/系统的分发上不会出现相同的问题。它是一个稳定的库,适用于 Spark 环境中的生产环境。不幸的是,它的大部分文档缺失,现有的文档非常简略且维护不善,尽管项目仍在积极开发中。
为了理解某个功能如何工作以及如何将它们结合在一起,你必须浏览 GitHub 中的源代码。该库还使用通过 Python 框架实现的现有 ML 模型,并提供了一个 Scala 类来表示它们,将底层的模型实现细节隐藏起来,避免开发人员接触。这在多个使用场景中都能有效,但为了构建更强大和高效的模型,你可能需要自己实现神经网络模型。只有 DL4J 才能在 Scala 中为开发和训练提供那种自由度。
总结
本章中,我们了解了 NLP 的主要概念,并开始动手使用 Spark,探索了两个潜在有用的库,spark-corenlp
和spark-nlp
。
在下一章,我们将看到如何通过实现复杂的 NLP 场景在 Spark 中实现相同或更好的结果,主要是基于 RNN 的深度学习。我们将通过使用 DL4J、TensorFlow、Keras、TensorFlow 后端以及 DL4J + Keras 模型导入来探索不同的实现方式。
第十二章:文本分析与深度学习
在上一章中,我们了解了自然语言处理(NLP)的核心概念,然后我们通过 Scala 和 Apache Spark 中的一些实现示例,学习了两个开源库的应用,并了解了这些解决方案的优缺点。本章将通过实际案例展示使用 DL 进行 NLP 的实现(使用 Scala 和 Spark)。以下四个案例将被覆盖:
-
DL4J
-
TensorFlow
-
Keras 与 TensorFlow 后端
-
DL4J 和 Keras 模型导入
本章涵盖了关于每种 DL 方法的优缺点的考虑,以便读者可以了解在何种情况下某个框架比其他框架更受青睐。
动手实践 NLP 与 DL4J
我们将要检查的第一个示例是电影评论的情感分析案例,与上一章中展示的最后一个示例(动手实践 NLP with Spark-NLP部分)相同。不同之处在于,这里我们将结合 Word2Vec(en.wikipedia.org/wiki/Word2vec
)和 RNN 模型。
Word2Vec 可以看作是一个只有两层的神经网络,它接受一些文本内容作为输入,然后返回向量。它不是一个深度神经网络,但它用于将文本转换为深度神经网络能够理解的数字格式。Word2Vec 非常有用,因为它可以在向量空间中将相似词汇的向量聚集在一起。它通过数学方式实现这一点。它在没有人工干预的情况下,创建了分布式的单词特征的数值表示。表示单词的向量被称为神经词嵌入。Word2Vec 训练词汇与输入文本中的邻近词汇之间的关系。它通过上下文来预测目标词汇(连续词袋模型(CBOW))或使用一个词汇来预测目标上下文(跳字模型)。研究表明,当处理大型数据集时,第二种方法能够产生更精确的结果。如果分配给某个单词的特征向量不能准确预测它的上下文,那么该向量的组成部分就会发生调整。每个单词在输入文本中的上下文变成了“教师”,通过反馈错误进行调整。这样,通过上下文被认为相似的单词向量就会被推得更近。
用于训练和测试的数据集是大型电影评论数据集,可以在ai.stanford.edu/~amaas/data/sentiment/
下载,且免费使用。该数据集包含 25,000 条热门电影评论用于训练,另外还有 25,000 条用于测试。
本示例的依赖项包括 DL4J NN、DL4J NLP 和 ND4J。
像往常一样,使用 DL4J 的NeuralNetConfiguration.Builder
类来设置 RNN 配置,如下所示:
val conf: MultiLayerConfiguration = new NeuralNetConfiguration.Builder
.updater(Updater.ADAM)
.l2(1e-5)
.weightInit(WeightInit.XAVIER)
.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
.gradientNormalizationThreshold(1.0)
.list
.layer(0, new GravesLSTM.Builder().nIn(vectorSize).nOut(256)
.activation(Activation.TANH)
.build)
.layer(1, new RnnOutputLayer.Builder().activation(Activation.SOFTMAX)
.lossFunction(LossFunctions.LossFunction.MCXENT).nIn(256).nOut(2).build)
.pretrain(false).backprop(true).build
该网络由一个 Graves LSTM RNN 构成(更多细节请参见第六章,《递归神经网络》),加上 DL4J 特定的 RNN 输出层RnnOutputLayer
。该输出层的激活函数是 SoftMax。
现在我们可以使用前面设置的配置来创建网络,如下所示:
val net = new MultiLayerNetwork(conf)
net.init()
net.setListeners(new ScoreIterationListener(1))
在开始训练之前,我们需要准备训练集,以使其准备好供使用。为此,我们将使用 Alex Black 的dataset iterator
,该迭代器可以在 DL4J 的 GitHub 示例中找到(github.com/deeplearning4j/dl4j-examples/blob/master/dl4j-examples/src/main/java/org/deeplearning4j/examples/recurrent/word2vecsentiment/SentimentExampleIterator.java
)。它是用 Java 编写的,因此已经被改编为 Scala 并添加到本书的源代码示例中。它实现了DataSetIterator
接口(static.javadoc.io/org.nd4j/nd4j-api/1.0.0-alpha/org/nd4j/linalg/dataset/api/iterator/DataSetIterator.html
),并且专门针对 IMDB 评论数据集。它的输入是原始的 IMDB 数据集(可以是训练集或测试集),以及一个wordVectors
对象,然后生成准备好用于训练/测试的数据集。这个特定的实现使用了 Google News 300 预训练向量作为wordVectors
对象;可以从github.com/mmihaltz/word2vec-GoogleNews-vectors/
GitHub 库中免费下载 GZIP 格式的文件。需要解压缩后才能使用。一旦提取,模型可以通过WordVectorSerializer
类的loadStaticModel
方法加载(static.javadoc.io/org.deeplearning4j/deeplearning4j-nlp/1.0.0-alpha/org/deeplearning4j/models/embeddings/loader/WordVectorSerializer.html
),如下所示:
val WORD_VECTORS_PATH: String = getClass().getClassLoader.getResource("GoogleNews-vectors-negative300.bin").getPath
val wordVectors = WordVectorSerializer.loadStaticModel(new File(WORD_VECTORS_PATH))
现在可以通过自定义数据集迭代器SentimentExampleIterator
准备训练和测试数据:
val DATA_PATH: String = getClass.getClassLoader.getResource("aclImdb").getPath
val train = new SentimentExampleIterator(DATA_PATH, wordVectors, batchSize, truncateReviewsToLength, true)
val test = new SentimentExampleIterator(DATA_PATH, wordVectors, batchSize, truncateReviewsToLength, false)
然后,我们可以在 DL4J 和 Spark 中测试和评估模型,具体内容参见第六章,《递归神经网络》、第七章,《使用 Spark 训练神经网络》,以及第八章,《监控和调试神经网络训练》。请注意,本文中使用的 Google 模型非常大(约 3.5 GB),因此在训练该示例中的模型时,需考虑所需的资源(特别是内存)。
在这个第一个代码示例中,我们使用了 DL4J 主模块的常用 API,这些 API 通常用于不同用例场景中的不同 MNN。我们还在其中明确使用了 Word2Vec。无论如何,DL4J API 还提供了一些针对 NLP 的基础设施,这些设施是基于 ClearTK(cleartk.github.io/cleartk/
)构建的,ClearTK 是一个开源的机器学习(ML)和自然语言处理(NLP)框架,适用于 Apache UIMA(uima.apache.org/
)。在本节接下来展示的第二个示例中,我们将使用这些设施。
该第二个示例的依赖项是 DataVec、DL4J NLP 和 ND4J。尽管它们已通过 Maven 或 Gradle 正确加载为传递性依赖项,但以下两个库需要明确声明在项目依赖项中,以避免在运行时发生NoClassDefFoundError
:
groupId: com.google.guava
artifactId: guava
version: 19.0
groupId: org.apache.commons
artifactId: commons-math3
version: 3.4
一个包含大约 100,000 个通用句子的文件已作为此示例的输入。我们需要将其加载到我们的应用程序中,操作如下:
val filePath: String = new ClassPathResource("rawSentences.txt").getFile.getAbsolutePath
DL4J NLP 库提供了SentenceIterator
接口(static.javadoc.io/org.deeplearning4j/deeplearning4j-nlp/1.0.0-alpha/org/deeplearning4j/text/sentenceiterator/SentenceIterator.html
)以及多个实现。在这个特定的示例中,我们将使用BasicLineIterator
实现(static.javadoc.io/org.deeplearning4j/deeplearning4j-nlp/1.0.0-alpha/org/deeplearning4j/text/sentenceiterator/BasicLineIterator.html
),以便去除输入文本中每个句子开头和结尾的空格,具体操作如下:
val iter: SentenceIterator = new BasicLineIterator(filePath)
我们现在需要进行分词操作,将输入文本切分成单个词语。为此,我们使用DefaultTokenizerFactory
实现(static.javadoc.io/org.deeplearning4j/deeplearning4j-nlp/1.0.0-alpha/org/deeplearning4j/text/tokenization/tokenizerfactory/DefaultTokenizerFactory.html
),并设置CommomPreprocessor
(static.javadoc.io/org.deeplearning4j/deeplearning4j-nlp/1.0.0-alpha/org/deeplearning4j/text/tokenization/tokenizer/preprocessor/CommonPreprocessor.html
)作为分词器,去除标点符号、数字和特殊字符,并将所有生成的词元强制转换为小写,具体操作如下:
val tokenizerFactory: TokenizerFactory = new DefaultTokenizerFactory
tokenizerFactory.setTokenPreProcessor(new CommonPreprocessor)
模型现在可以构建,如下所示:
val vec = new Word2Vec.Builder()
.minWordFrequency(5)
.iterations(1)
.layerSize(100)
.seed(42)
.windowSize(5)
.iterate(iter)
.tokenizerFactory(tokenizerFactory)
.build
如前所述,我们使用的是 Word2Vec,因此模型是通过 Word2Vec.Builder
类 (static.javadoc.io/org.deeplearning4j/deeplearning4j-nlp/1.0.0-alpha/org/deeplearning4j/models/word2vec/Word2Vec.Builder.html
) 构建的,设置为先前创建的分词器工厂。
让我们开始模型拟合:
vec.fit()
完成后,可以将词向量保存在文件中,具体如下:
WordVectorSerializer.writeWordVectors(vec, "wordVectors.txt")
WordVectorSerializer
工具类 (static.javadoc.io/org.deeplearning4j/deeplearning4j-nlp/1.0.0-alpha/org/deeplearning4j/models/embeddings/loader/WordVectorSerializer.html
) 处理词向量的序列化和持久化。
可以通过以下方式测试模型:
val lst = vec.wordsNearest("house", 10)
println("10 Words closest to 'house': " + lst)
生成的输出如下:
GloVe (en.wikipedia.org/wiki/GloVe_(machine_learning)
),与 Word2Vec 类似,是一种分布式词表示模型,但采用了不同的方法。Word2Vec 从一个旨在预测相邻词语的神经网络中提取嵌入,而 GloVe 则直接优化嵌入。这样,两个词向量的乘积等于这两个词在一起出现次数的对数。例如,如果词语 cat 和 mouse 在文本中一共出现了 20 次,那么 (vec(cat) * vec(mouse)) = log(20)。DL4J NLP 库也提供了 GloVe 模型的实现,GloVe.Builder
(static.javadoc.io/org.deeplearning4j/deeplearning4j-nlp/1.0.0-alpha/org/deeplearning4j/models/glove/Glove.Builder.html
)。因此,这个示例可以适配到 GloVe 模型。与 Word2Vec 示例相同的包含约 100,000 个通用句子的文件作为新的输入。SentenceIterator
和分词方法没有变化(与 Word2Vec 示例相同)。不同之处在于构建的模型,如下所示:
val glove = new Glove.Builder()
.iterate(iter)
.tokenizerFactory(tokenizerFactory)
.alpha(0.75)
.learningRate(0.1)
.epochs(25)
.xMax(100)
.batchSize(1000)
.shuffle(true)
.symmetric(true)
.build
我们可以通过调用其 fit
方法来拟合模型,具体如下:
glove.fit()
拟合过程完成后,我们可以使用模型执行多项操作,例如查找两个词之间的相似度,具体如下:
val simD = glove.similarity("old", "new")
println("old/new similarity: " + simD)
或者,找到与给定词语最相似的 n 个词:
val words: util.Collection[String] = glove.wordsNearest("time", 10)
println("Nearest words to 'time': " + words)
产生的输出将如下所示:
在看到这最后两个例子后,你可能会想知道到底哪个模型更好,是 Word2Vec 还是 GloVe。其实没有绝对的赢家,这完全取决于数据。你可以选择一个模型并以某种方式训练它,使得最终编码的向量变得特定于模型工作所在的用例场景的领域。
用 TensorFlow 进行实践中的 NLP
在本节中,我们将使用 TensorFlow(Python)进行深度学习情感分析,使用与上一节第一个示例相同的大型电影评论数据集。本示例的前提是 Python 2.7.x、PIP 包管理器和 TensorFlow。在 JVM 中导入 Python 模型与 DL4J一节位于第十章的部署到分布式系统部分,涵盖了设置所需工具的详细信息。我们还将使用 TensorFlow Hub 库(www.tensorflow.org/hub/
),这是为可重用的机器学习模块而创建的。需要通过pip
安装,如下所示:
pip install tensorflow-hub
该示例还需要pandas
(pandas.pydata.org/
)数据分析库,如下所示:
pip install pandas
导入必要的模块:
import tensorflow as tf
import tensorflow_hub as hub
import os
import pandas as pd
import re
接下来,我们定义一个函数,将所有文件从输入目录加载到 pandas DataFrame 中,如下所示:
def load_directory_data(directory):
data = {}
data["sentence"] = []
data["sentiment"] = []
for file_path in os.listdir(directory):
with tf.gfile.GFile(os.path.join(directory, file_path), "r") as f:
data["sentence"].append(f.read())
data["sentiment"].append(re.match("\d+_(\d+)\.txt", file_path).group(1))
return pd.DataFrame.from_dict(data)
然后,我们定义另一个函数来合并正面和负面评论,添加一个名为polarity
的列,并进行一些随机打乱,如下所示:
def load_dataset(directory):
pos_df = load_directory_data(os.path.join(directory, "pos"))
neg_df = load_directory_data(os.path.join(directory, "neg"))
pos_df["polarity"] = 1
neg_df["polarity"] = 0
return pd.concat([pos_df, neg_df]).sample(frac=1).reset_index(drop=True)
实现第三个函数来下载电影评论数据集,并使用load_dataset
函数创建以下训练集和测试集 DataFrame:
def download_and_load_datasets(force_download=False):
dataset = tf.keras.utils.get_file(
fname="aclImdb.tar.gz",
origin="http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz",
extract=True)
train_df = load_dataset(os.path.join(os.path.dirname(dataset),
"aclImdb", "train"))
test_df = load_dataset(os.path.join(os.path.dirname(dataset),
"aclImdb", "test"))
return train_df, test_df
这个函数在第一次执行代码时会下载数据集。然后,除非你删除它们,否则后续执行将从本地磁盘获取它们。
这两个 DataFrame 是通过这种方式创建的:
train_df, test_df = download_and_load_datasets()
我们还可以将训练数据集的前几行漂亮地打印到控制台,以检查一切是否正常,如下所示:
print(train_df.head())
示例输出如下:
现在我们已经有了数据,可以定义模型了。我们将使用Estimator API(www.tensorflow.org/guide/estimators
),这是 TensorFlow 中的一个高级 API,旨在简化机器学习编程。Estimator提供了一些输入函数,作为 pandas DataFrame 的封装。所以,我们定义如下函数:train_input_fn
,以在整个训练集上进行训练,并且不限制训练轮次:
train_input_fn = tf.estimator.inputs.pandas_input_fn(
train_df, train_df["polarity"], num_epochs=None, shuffle=True)
predict_train_input_fn
对整个训练集进行预测,执行以下操作:
predict_train_input_fn = tf.estimator.inputs.pandas_input_fn(
train_df, train_df["polarity"], shuffle=False)
然后我们使用predict_test_input_fn
对测试集进行预测:
predict_test_input_fn = tf.estimator.inputs.pandas_input_fn(
test_df, test_df["polarity"], shuffle=False)
TensorFlow hub 库提供了一个特征列,它会对给定的输入文本特征应用一个模块,该特征的值是字符串,然后将模块的输出传递到下游。在这个示例中,我们将使用nnlm-en-dim128
模块(tfhub.dev/google/nnlm-en-dim128/1
),该模块已经在英文 Google News 200B 语料库上进行了训练。我们在代码中嵌入和使用该模块的方式如下:
embedded_text_feature_column = hub.text_embedding_column(
key="sentence",
module_spec="https://tfhub.dev/google/nnlm-en-dim128/1")
出于分类目的,我们使用 TensorFlow hub 库提供的DNNClassifier
(www.tensorflow.org/api_docs/python/tf/estimator/DNNClassifier
)。它扩展了Estimator
(www.tensorflow.org/api_docs/python/tf/estimator/Estimator
),并且是 TensorFlow DNN 模型的分类器。所以,在我们的示例中,Estimator
是这样创建的:
estimator = tf.estimator.DNNClassifier(
hidden_units=[500, 100],
feature_columns=[embedded_text_feature_column],
n_classes=2,
optimizer=tf.train.AdagradOptimizer(learning_rate=0.003))
请注意,我们将embedded_text_feature_column
指定为特征列。两个隐藏层分别具有500
和100
个节点。AdagradOptimizer
是DNNClassifier
的默认优化器。
模型的训练可以通过一行代码实现,方法是调用我们的Estimator
*的train
方法,如下所示:
estimator.train(input_fn=train_input_fn, steps=1000);
由于这个示例使用的训练数据集大小为 25 KB,1,000 步相当于五个 epoch(使用默认的批量大小)。
训练完成后,我们可以对训练数据集进行预测,具体如下:
train_eval_result = estimator.evaluate(input_fn=predict_train_input_fn)
print("Training set accuracy: {accuracy}".format(**train_eval_result))
测试数据集如下:
test_eval_result = estimator.evaluate(input_fn=predict_test_input_fn)
print("Test set accuracy: {accuracy}".format(**test_eval_result))
这是应用程序的输出,显示了两种预测的准确性:
我们还可以对模型进行评估,正如在第九章中所解释的,解释神经网络输出,在分类评估部分中,计算混淆矩阵以了解错误分类的分布。首先让我们定义一个函数来获取预测值,具体如下:
def get_predictions(estimator, input_fn):
return [x["class_ids"][0] for x in estimator.predict(input_fn=input_fn)]
现在,从训练数据集开始创建混淆矩阵,具体如下:
with tf.Graph().as_default():
cm = tf.confusion_matrix(train_df["polarity"],
get_predictions(estimator, predict_train_input_fn))
with tf.Session() as session:
cm_out = session.run(cm)
然后,将其归一化,使每行的总和等于1
,具体如下:
cm_out = cm_out.astype(float) / cm_out.sum(axis=1)[:, np.newaxis]
屏幕上显示的混淆矩阵输出将如下所示:
然而,你也可以使用你选择的 Python 图表库以更优雅的方式呈现它。
你可能已经注意到,尽管这段代码很简洁且不需要高级的 Python 知识,但它并不是机器学习(ML)和深度学习(DL)初学者的易入门点,因为 TensorFlow 隐式要求对 ML 概念有一定了解,才能理解其 API。与 DL4J API 比较时,你可以明显感觉到这种差异。
使用 Keras 和 TensorFlow 后端进行实践 NLP
如第十章《在分布式系统上部署》中所述,在使用 DL4J 在 JVM 中导入 Python 模型部分,当在 Python 中进行深度学习时,TensorFlow 的替代方案是 Keras。它可以作为一个高层 API,在 TensorFlow 的支持下使用。在本节中,我们将学习如何在 Keras 中进行情感分析,最后,我们将比较此实现与之前 TensorFlow 中的实现。
我们将使用与前面通过 DL4J 和 TensorFlow 实现相同的 IMDB 数据集(25,000 个训练样本和 25,000 个测试样本)。此示例的先决条件与 TensorFlow 示例相同(Python 2.7.x,PIP 包管理器和 TensorFlow),当然还需要 Keras。Keras 代码模块内置了该数据集:
from keras.datasets import imdb
所以,我们只需要设置词汇表的大小并从那里加载数据,而不是从其他外部位置加载,如下所示:
vocabulary_size = 5000
(X_train, y_train), (X_test, y_test) = imdb.load_data(num_words = vocabulary_size)
下载完成后,您可以打印下载评论的样本以供检查,如下所示:
print('---review---')
print(X_train[6])
print('---label---')
print(y_train[6])
输出结果如下所示:
您可以看到,在这个阶段,评论已作为整数序列存储,这些整数是预先分配给单个单词的 ID。另外,标签是一个整数(0 表示负面,1 表示正面)。不过,您仍然可以通过使用imdb.get_word_index()
方法返回的字典,将下载的评论映射回它们原始的单词,如下所示:
word2id = imdb.get_word_index()
id2word = {i: word for word, i in word2id.items()}
print('---review with words---')
print([id2word.get(i, ' ') for i in X_train[6]])
print('---label---')
print(y_train[6])
在前面的截图中,您可以看到输入评论中使用的单词的返回字典。我们将使用 RNN 模型进行此示例。为了向模型输入数据,所有输入数据的长度必须相同。通过查看下载评论的最大和最小长度(以下是获取此信息的代码及其输出):
print('Maximum review length: {}'.format(
len(max((X_train + X_test), key=len))))
print('Minimum review length: {}'.format(
len(min((X_test + X_test), key=len))))
输出结果如下所示:
我们可以看到它们的长度不完全相同。因此,我们需要将最大评论长度限制为 500 个单词,比如通过截断较长的评论,并用零填充较短的评论。这可以通过sequence.pad_sequences
Keras 函数实现,如下所示:
from keras.preprocessing import sequence
max_words = 500
X_train = sequence.pad_sequences(X_train, maxlen=max_words)
X_test = sequence.pad_sequences(X_test, maxlen=max_words)
让我们设计 RNN 模型,如下所示:
from keras import Sequential
from keras.layers import Embedding, LSTM, Dense, Dropout
embedding_size=32
model=Sequential()
model.add(Embedding(vocabulary_size, embedding_size, input_length=max_words))
model.add(LSTM(100))
model.add(Dense(1, activation='sigmoid'))
这是一个简单的 RNN 模型,包含三层:嵌入层、LSTM 层和全连接层,如下所示:
此模型的输入是一个最大长度为500
的整数单词 ID 序列,输出是一个二进制标签(0
或1
)。
此模型的学习过程配置可以通过其compile
方法来完成,如下所示:
model.compile(loss='binary_crossentropy',
optimizer='adam',
metrics=['accuracy'])
在设置好批量大小和训练周期数之后,如下所示:
batch_size = 64
num_epochs = 3
我们可以开始训练,如下所示:
X_valid, y_valid = X_train[:batch_size], y_train[:batch_size]
X_train2, y_train2 = X_train[batch_size:], y_train[batch_size:]
model.fit(X_train2, y_train2, validation_data=(X_valid, y_valid), batch_size=batch_size, epochs=num_epochs)
当训练完成后,我们可以使用测试数据集评估模型的准确性,方法如下:
scores = model.evaluate(X_test, y_test, verbose=0)
print('Test accuracy:', scores[1])
查看这个示例的代码,你应该已经注意到,相较于之前的 TensorFlow 示例,这个示例更高层次,开发时的重点主要放在特定问题模型的实现细节上,而不是其背后的机器学习/深度学习机制。
使用 Keras 模型导入到 DL4J 的实战 NLP
在第十章,在分布式系统上部署、在 JVM 中使用 DL4J 导入 Python 模型部分,我们学习了如何将现有的 Keras 模型导入到 DL4J 中,并在 JVM 环境中使用它们进行预测或重新训练。
这适用于我们在使用 Keras 和 TensorFlow 后端的实战 NLP部分中实现并训练的模型,我们使用 Keras 和 TensorFlow 作为后端。我们需要修改该示例的代码,通过以下方式将模型序列化为 HDF5 格式:
model.save('sa_rnn.h5')
生成的sa_rnn.h5
文件需要被复制到 Scala 项目的资源文件夹中。项目的依赖项包括 DataVec API、DL4J 核心、ND4J 以及 DL4J 模型导入库。
我们需要按照第 12.1 节中解释的方式导入并转换大型电影评论数据库,如果我们希望通过 DL4J 重新训练模型。然后,我们需要按如下方式编程导入 Keras 模型:
val saRnn = new ClassPathResource("sa_rnn.h5").getFile.getPath
val model = KerasModelImport.importKerasSequentialModelAndWeights(saRnn)
最后,我们可以通过调用model
(这是MultiLayerNetwork
的实例,和在 DL4J 中的常见做法一样)的predict
方法,传入输入数据作为 ND4J DataSet(static.javadoc.io/org.nd4j/nd4j-api/1.0.0-alpha/org/nd4j/linalg/dataset/api/DataSet.html
)来进行预测。
总结
本章结束了对 Scala 实现过程的 NLP 解释。在本章和前一章中,我们评估了这种编程语言的不同框架,并详细列出了每种框架的优缺点。本章的重点主要放在了深度学习方法(DL)在 NLP 中的应用。为此,我们介绍了一些 Python 的替代方案,并强调了这些 Python 模型在 JVM 环境中与 DL4J 框架的潜在集成。此时,读者应该能够准确评估出哪些方案最适合他/她的特定 NLP 应用案例。
从下一章开始,我们将深入学习卷积和卷积神经网络(CNN)如何应用于图像识别问题。通过展示不同框架(包括 DL4J、Keras 和 TensorFlow)的不同实现,将解释图像识别。
第十三章:卷积
前两章介绍了通过 RNN/LSTM 在 Apache Spark 中进行的 NLP 实际用例实现。在本章及接下来的章节中,我们将做类似的事情,探讨 CNN 如何应用于图像识别和分类。本章特别涉及以下主题:
-
从数学和深度学习的角度快速回顾卷积是什么
-
现实问题中物体识别的挑战与策略
-
卷积在图像识别中的应用,以及通过深度学习(卷积神经网络,CNN)实践中图像识别用例的实现,采用相同的方法,但使用以下两种不同的开源框架和编程语言:
-
Keras(使用 TensorFlow 后端)在 Python 中的实现
-
DL4J(及 ND4J)在 Scala 中的实现
-
卷积
第五章,卷积神经网络,介绍了 CNN 的理论,当然卷积也是其中的一部分。在进入物体识别之前,让我们从数学和实际的角度回顾一下这个概念。在数学中,卷积是对两个函数的操作,生成一个第三个函数,该函数是前两个函数的乘积积分结果,其中一个函数被翻转:
卷积在 2D 图像处理和信号过滤中被广泛应用。
为了更好地理解幕后发生了什么,这里是一个简单的 Python 代码示例,使用 NumPy 进行 1D 卷积(www.numpy.org/
):
import numpy as np
x = np.array([1, 2, 3, 4, 5])
y = np.array([1, -2, 2])
result = np.convolve(x, y)
print result
这会产生如下结果:
让我们看看x
和y
数组之间的卷积如何产生该结果。convolve
函数首先做的事情是水平翻转y
数组:
[1, -2, 2]
变为[2, -2, 1]
然后,翻转后的y
数组滑动在x
数组上:
这就是如何生成result
数组[ 1 0 1 2 3 -2 10]
的。
2D 卷积使用类似机制发生。以下是一个简单的 Python 代码示例,使用 NumPy:
import numpy as np
from scipy import signal
a = np.matrix('1 3 1; 0 -1 1; 2 2 -1')
print(a)
w = np.matrix('1 2; 0 -1')
print(w)
f = signal.convolve2d(a, w)
print(f)
这次,使用了 SciPy(www.scipy.org/
)中的signal.convolve2d
函数来执行卷积。前面代码的结果如下:
当翻转后的矩阵完全位于输入矩阵内部时,结果被称为valid
卷积。通过这种方式计算 2D 卷积,只获取有效结果,如下所示:
f = signal.convolve2d(a, w, 'valid')
这将产生如下输出:
以下是这些结果的计算方式。首先,w
数组被翻转:
变为
然后,和 1D 卷积相同,a
矩阵的每个窗口与翻转的 w
矩阵逐元素相乘,结果最终按如下方式求和:
(1 x -1) + (0 x 3) + (0 x 2) + (-1 x 1) = -2
(3 x -1) + (1 x 0) + (-1 x 2) + (1 x 1) = -4
依此类推。
物体识别策略
本节介绍了在数字图像中实现自动物体识别时使用的不同计算技术。首先,我们给出物体识别的定义。简而言之,它是任务:在场景的 2D 图像中找到并标记对应场景内物体的部分。以下截图展示了由人类用铅笔手动执行的物体识别示例:
图 13.1:手动物体检测示例
图像已被标记和标签化,显示可以识别为香蕉和南瓜的水果。这与计算物体识别过程完全相同;可以简单地认为它是绘制线条、勾画图像区域的过程,最后为每个结构附上与其最匹配的模型标签。
在物体识别中,必须结合多种因素,如场景上下文的语义或图像中呈现的信息。上下文在图像解读中特别重要。让我们先看看以下截图:
图 13.2:孤立物体(无上下文)
几乎不可能单独识别图像中心的物体。现在让我们看看接下来的截图,其中同一物体出现在原始图像中的位置:
图 13.3:图 13.2 中物体的原始上下文
如果不提供进一步的信息,仍然很难识别该物体,但相比于图 13.2,难度要小一些。给定前面截图中图像是电路板的上下文信息,初始物体更容易被识别为一个极化电容器。文化背景在正确解读场景中起着关键作用。
现在我们考虑第二个示例(如下截图所示),一个显示楼梯间的一致性 3D 图像:
图 13.4:显示楼梯间的一致性 3D 图像
通过改变该图像中的光线,最终结果可能使得眼睛(以及计算机)更难看到一致的 3D 图像(如下图所示):
图 13.5:在图 13.4 中应用不同光线后的结果
与原始图像(图 13.3)相比,它的亮度和对比度已经被修改(如以下截图所示):
图 13.6:图 13.3 中的图像,具有改变的亮度和对比度
眼睛仍然能够识别三维的阶梯。然而,使用与原始图像不同的亮度和对比度值,图像如下所示:
图 13.7:图 13.3 中的图像,具有不同的亮度和对比度
几乎无法识别出相同的图像。我们学到的是,尽管前面截图中的修饰图像保留了原始图像中的重要视觉信息(图 13.3),但图 13.4和前面的截图中的图像由于修饰去除了三维细节,变得更加难以解读。所提供的例子证明,计算机(就像人眼一样)需要合适的上下文模型才能成功完成物体识别和场景解读。
物体识别的计算策略可以根据其对复杂图像数据或复杂模型的适用性进行分类。数字图像中的数据复杂度对应于其信噪比。具有语义歧义的图像对应于复杂(或嘈杂)的数据。图像中包含完美轮廓的模型实例数据被称为简单数据。具有较差分辨率、噪声或其他类型异常数据,或容易混淆的虚假模型实例,被称为复杂数据。模型复杂度通过图像中数据结构的细节级别以及确定数据形式所需的技术来表示。如果一个模型通过简单的标准定义(例如,单一形状模板或优化一个隐式包含形状模型的单一函数),那么可能不需要其他上下文来将模型标签附加到给定的场景中。但是,在许多原子模型组件必须组合或某种方式按层次关系建立以确认所需模型实例的存在时,需要复杂的数据结构和非平凡的技术。
基于前面的定义,物体识别策略可以分为四大类,如下所示:
-
特征向量分类:这依赖于对象图像特征的一个简单模型。通常,它仅应用于简单数据。
-
拟合模型到光度数据:当简单的模型足够用,但图像的光度数据存在噪声和歧义时,应用此方法。
-
拟合模型到符号结构:当需要复杂的模型时应用,但可以通过简单数据准确推断可靠的符号结构。这些方法通过匹配表示全局对象部件之间关系的数据结构,来寻找对象的实例。
-
组合策略:在数据和所需模型实例都很复杂时应用。
本书中详细介绍的主要开源框架所提供的用于构建和训练 CNNs(卷积神经网络)进行对象识别的可用 API 实现时,已考虑到这些因素和策略。尽管这些 API 是非常高层次的,但在选择模型的适当隐藏层组合时,应该采取相同的思维方式。
卷积在图像识别中的应用
在这一部分,我们将通过实现一个图像识别模型来动手实践,同时考虑本章第一部分中讨论的相关事项。我们将使用两种不同的框架和编程语言实现相同的用例。
Keras 实现
我们将要实现的第一个对象识别是在 Python 中使用 Keras 框架进行的。为了训练和评估模型,我们将使用一个名为 CIFAR-10 的公共数据集 (www.cs.toronto.edu/~kriz/cifar.html
)。它包含 60,000 张(50,000 张用于训练,10,000 张用于测试)小的(32 x 32 像素)彩色图像,分为 10 类(飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船和卡车)。这 10 个类别是互斥的。CIFAR-10 数据集(163 MB)可以从 www.cs.toronto.edu/~kriz/cifar-10-python.tar.gz
免费下载。
实现此功能的前提条件是 Python 2.7.x、Keras、TensorFlow(作为 Keras 的后端使用)、NumPy 以及 scikit-learn
(scikit-learn.org/stable/index.html
),这是一个用于机器学习的开源工具。第十章,在分布式系统上部署,涵盖了为 Keras 和 TensorFlow 设置 Python 环境的详细信息。scikit-learn
可以按以下方式安装:
sudo pip install scikit-learn
首先,我们需要导入所有必要的 NumPy、Keras 和 scikit-learn
命名空间和类,如下所示:
import numpy as np
from keras.models import Sequential
from keras.layers import Dense
from keras.layers import Dropout
from keras.layers import Flatten
from keras.constraints import maxnorm
from keras.optimizers import SGD
from keras.layers.convolutional import Conv2D
from keras.layers.convolutional import MaxPooling2D
from keras.utils import np_utils
from keras.datasets import cifar10
from keras import backend as K
from sklearn.model_selection import train_test_split
现在,我们需要加载 CIFAR-10 数据集。不需要单独下载,Keras 提供了一个可以通过编程方式下载它的功能,如下所示:
K.set_image_dim_ordering('th')
(X_train, y_train), (X_test, y_test) = cifar10.load_data()
load_data
函数在第一次执行时会下载数据集。后续的运行将使用已下载到本地的数据集。
我们通过常量值初始化 seed
,以确保结果是可重复的,如下所示:
seed = 7
np.random.seed(seed)
输入数据集的像素值范围为 0 到 255(每个 RGB 通道)。我们可以通过将值除以 255.0
来将数据归一化到 0 到 1 的范围,然后执行以下操作:
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train = X_train / 255.0
X_test = X_test / 255.0
我们可以使用独热编码(one-hot encoding)将输出变量转换为二进制矩阵(因为它们被定义为整数向量,范围在 0 到 1 之间,针对每个 10 类),如下所示:
y_train = np_utils.to_categorical(y_train)
y_test = np_utils.to_categorical(y_test)
num_classes = y_test.shape[1]
让我们开始实现模型。首先实现一个简单的 CNN,验证它的准确性,必要时我们将使模型更加复杂。以下是可能的第一次实现:
model = Sequential()
model.add(Conv2D(32,(3,3), input_shape = (3,32,32), padding = 'same', activation = 'relu'))
model.add(Dropout(0.2))
model.add(Conv2D(32,(3,3), padding = 'same', activation = 'relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Conv2D(64,(3,3), padding = 'same', activation = 'relu'))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Flatten())
model.add(Dropout(0.2))
model.add(Dense(512,activation='relu',kernel_constraint=maxnorm(3)))
model.add(Dropout(0.2))
model.add(Dense(num_classes, activation='softmax'))
你可以在训练开始前在控制台输出中看到模型层的详细信息(请参见以下截图):
该模型是一个Sequential
模型。从前面的输出可以看到,输入层是卷积层,包含 32 个大小为 3 x 3 的特征图,并使用修正线性单元(ReLU)激活函数。为了减少过拟合,对输入应用了 20% 的 dropout,接下来的层是第二个卷积层,具有与输入层相同的特征。然后,我们设置了一个大小为 2 x 2 的最大池化层。接着,添加了第三个卷积层,具有 64 个大小为 3 x 3 的特征图和 ReLU 激活函数,并设置了第二个大小为 2 x 2 的最大池化层。在第二个最大池化层后,我们加入一个 Flatten 层,并应用 20% 的 dropout,然后将输出传递到下一个层,即具有 512 个单元和 ReLU 激活函数的全连接层。在输出层之前,我们再应用一次 20% 的 dropout,输出层是另一个具有 10 个单元和 Softmax 激活函数的全连接层。
现在我们可以定义以下训练属性(训练轮数、学习率、权重衰减和优化器,在此特定情况下已设置为随机梯度下降(SGD)):
epochs = 25
lrate = 0.01
decay = lrate/epochs
sgd = SGD(lr=lrate, momentum=0.9, decay=decay, nesterov=False)
配置模型的训练过程,如下所示:
model.compile(loss='categorical_crossentropy', optimizer=sgd, metrics=['accuracy'])
现在可以使用 CIFAR-10 训练数据开始训练,如下所示:
model.fit(X_train, y_train, validation_data=(X_test, y_test), epochs=epochs, batch_size=32)
当完成时,可以使用 CIFAR-10 测试数据进行评估,如下所示:
scores = model.evaluate(X_test,y_test,verbose=0)
print("Accuracy: %.2f%%" % (scores[1]*100))
该模型的准确率大约为75%
,如以下截图所示:
结果不是很好。我们已经在 25 个训练轮次上执行了训练,轮数比较少。因此,当训练轮次增多时,准确率会有所提高。不过,首先让我们看看通过改进 CNN 模型、使其更深,能否提高结果。添加以下两个额外的导入:
from keras.layers import Activation
from keras.layers import BatchNormalization
对之前实现的代码的唯一更改是网络模型。以下是新的模型:
model = Sequential()
model.add(Conv2D(32, (3,3), padding='same', input_shape=x_train.shape[1:]))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(Conv2D(32, (3,3), padding='same'))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.2))
model.add(Conv2D(64, (3,3), padding='same'))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(Conv2D(64, (3,3), padding='same'))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.3))
model.add(Conv2D(128, (3,3), padding='same'))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(Conv2D(128, (3,3), padding='same'))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.4))
model.add(Flatten())
model.add(Dense(num_classes, activation='softmax'))
基本上,我们所做的就是重复相同的模式,每种模式使用不同数量的特征图(32、64 和 128)。添加层的优势在于每一层都会学习不同抽象级别的特征。在我们的例子中,训练一个 CNN 来识别物体时,我们可以看到第一层会训练自己识别基本特征(例如,物体的边缘),接下来的层会训练自己识别形状(可以认为是边缘的集合),然后的层会训练自己识别形状集合(以 CIFAR-10 数据集为例,这些可能是腿、翅膀、尾巴等),接下来的层会学习更高阶的特征(物体)。多个层更有利于因为它们能够学习从输入(原始数据)到高层分类之间的所有中间特征:
再次运行训练并进行该新模型的评估,结果是80.57%
:
与先前的模型相比,这是一个合理的改进,考虑到我们目前只进行了 25 个 epoch 的训练。但现在,让我们看看是否可以通过图像数据增强来进一步提升性能。通过查看训练数据集,我们可以看到图像中的物体位置发生了变化。通常情况下,数据集中的图像会有不同的条件(例如亮度、方向等)。我们需要通过使用额外修改过的数据来训练神经网络来应对这些情况。考虑以下简单示例:一个仅包含两类的汽车图像训练数据集,大众甲壳虫和保时捷 Targa。假设所有的大众甲壳虫汽车都排列在左侧,如下图所示:
图 13.8:大众甲壳虫训练图像
然而,所有的保时捷 Targa 汽车都排列在右侧,如下图所示:
图 13.9:保时捷 Targa 训练图像
在完成训练并达到较高准确率(90%或 95%)后,输入以下截图所示的图像进行模型预测:
图 13.10:大众甲壳虫输入图像
这里存在一个具体的风险,可能会将这辆车误分类为 Porsche Targa。为了避免这种情况,我们需要减少训练数据集中无关特征的数量。以这辆车为例,我们可以做的一件事是将训练数据集中的图像水平翻转,使它们朝向另一侧。经过再次训练神经网络并使用这个新数据集后,模型的表现更有可能符合预期。数据增强可以在离线(适用于小数据集)或在线(适用于大数据集,因为变换应用于喂给模型的小批次数据)进行。让我们尝试在本节示例的最新模型实现中,使用 Keras 中的 ImageDataGenerator
类进行程序化的在线数据增强,方法如下:
from keras.preprocessing.image import ImageDataGenerator
datagen = ImageDataGenerator(
rotation_range=15,
width_shift_range=0.1,
height_shift_range=0.1,
horizontal_flip=True,
)
datagen.fit(X_train)
然后在拟合模型时使用它,如下所示:
batch_size = 64
model.fit_generator(datagen.flow(X_train, y_train, batch_size=batch_size),\
steps_per_epoch=X_train.shape[0] // batch_size,epochs=125,\
verbose=1,validation_data=(X_test,y_test),callbacks=[LearningRateScheduler(lr_schedule)])
在开始训练之前,还需要做一件事,那就是对模型的卷积层应用一个核正则化器(keras.io/regularizers/
),如下所示:
weight_decay = 1e-4
model = Sequential()
model.add(Conv2D(32, (3,3), padding='same', kernel_regularizer=regularizers.l2(weight_decay), input_shape=X_train.shape[1:]))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(Conv2D(32, (3,3), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.2))
model.add(Conv2D(64, (3,3), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(Conv2D(64, (3,3), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.3))
model.add(Conv2D(128, (3,3), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(Conv2D(128, (3,3), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.4))
model.add(Flatten())
model.add(Dense(num_classes, activation='softmax'))
正则化器允许我们在网络优化过程中对层参数应用惩罚(这些惩罚会被纳入损失函数中)。
在这些代码更改后,使用相对较小的训练轮次(64)和基本的图像数据增强来训练模型。下图显示,准确率提高到了接近 84%:
通过训练更多的轮次,模型的准确率可能会增加到大约 90% 或 91%。
DL4J 实现
我们要做的第二个物体识别实现是基于 Scala,并涉及到 DL4J 框架。为了训练和评估模型,我们仍然使用 CIFAR-10 数据集。本项目的依赖项包括 DataVec 数据图像、DL4J、NN 和 ND4J,以及 Guava 19.0 和 Apache commons math 3.4。
如果你查看 CIFAR-10 数据集下载页面(见下图),你会发现有专门为 Python、MatLab 和 C 编程语言提供的归档文件,但没有针对 Scala 或 Java 的归档文件:
图 13.11:CIFAR-10 数据集下载页面
我们不需要单独下载并转换数据集用于 Scala 应用;DL4J 数据集库提供了 org.deeplearning4j.datasets.iterator.impl.CifarDataSetIterator
迭代器,可以编程获取训练集和测试集,如下所示:
val trainDataSetIterator =
new CifarDataSetIterator(2, 5000, true)
val testDataSetIterator =
new CifarDataSetIterator(2, 200, false)
CifarDataSetIterator
构造函数需要三个参数:批次数、样本数以及一个布尔值,用于指定数据集是用于训练(true
)还是测试(false
)。
现在我们可以定义神经网络了。我们实现一个函数来配置模型,如下所示:
def defineModelConfiguration(): MultiLayerConfiguration =
new NeuralNetConfiguration.Builder()
.seed(seed)
.cacheMode(CacheMode.DEVICE)
.updater(new Adam(1e-2))
.biasUpdater(new Adam(1e-2*2))
.gradientNormalization(GradientNormalization.RenormalizeL2PerLayer)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.l1(1e-4)
.l2(5 * 1e-4)
.list
.layer(0, new ConvolutionLayer.Builder(Array(4, 4), Array(1, 1), Array(0, 0)).name("cnn1").convolutionMode(ConvolutionMode.Same)
.nIn(3).nOut(64).weightInit(WeightInit.XAVIER_UNIFORM).activation(Activation.RELU)
.biasInit(1e-2).build)
.layer(1, new ConvolutionLayer.Builder(Array(4, 4), Array(1, 1), Array(0, 0)).name("cnn2").convolutionMode(ConvolutionMode.Same)
.nOut(64).weightInit(WeightInit.XAVIER_UNIFORM).activation(Activation.RELU)
.biasInit(1e-2).build)
.layer(2, new SubsamplingLayer.Builder(PoolingType.MAX, Array(2,2)).name("maxpool2").build())
.layer(3, new ConvolutionLayer.Builder(Array(4, 4), Array(1, 1), Array(0, 0)).name("cnn3").convolutionMode(ConvolutionMode.Same)
.nOut(96).weightInit(WeightInit.XAVIER_UNIFORM).activation(Activation.RELU)
.biasInit(1e-2).build)
.layer(4, new ConvolutionLayer.Builder(Array(4, 4), Array(1, 1), Array(0, 0)).name("cnn4").convolutionMode(ConvolutionMode.Same)
.nOut(96).weightInit(WeightInit.XAVIER_UNIFORM).activation(Activation.RELU)
.biasInit(1e-2).build)
.layer(5, new ConvolutionLayer.Builder(Array(3,3), Array(1, 1), Array(0, 0)).name("cnn5").convolutionMode(ConvolutionMode.Same)
.nOut(128).weightInit(WeightInit.XAVIER_UNIFORM).activation(Activation.RELU)
.biasInit(1e-2).build)
.layer(6, new ConvolutionLayer.Builder(Array(3,3), Array(1, 1), Array(0, 0)).name("cnn6").convolutionMode(ConvolutionMode.Same)
.nOut(128).weightInit(WeightInit.XAVIER_UNIFORM).activation(Activation.RELU)
.biasInit(1e-2).build)
.layer(7, new ConvolutionLayer.Builder(Array(2,2), Array(1, 1), Array(0, 0)).name("cnn7").convolutionMode(ConvolutionMode.Same)
.nOut(256).weightInit(WeightInit.XAVIER_UNIFORM).activation(Activation.RELU)
.biasInit(1e-2).build)
.layer(8, new ConvolutionLayer.Builder(Array(2,2), Array(1, 1), Array(0, 0)).name("cnn8").convolutionMode(ConvolutionMode.Same)
.nOut(256).weightInit(WeightInit.XAVIER_UNIFORM).activation(Activation.RELU)
.biasInit(1e-2).build)
.layer(9, new SubsamplingLayer.Builder(PoolingType.MAX, Array(2,2)).name("maxpool8").build())
.layer(10, new DenseLayer.Builder().name("ffn1").nOut(1024).updater(new Adam(1e-3)).biasInit(1e-3).biasUpdater(new Adam(1e-3*2)).build)
.layer(11,new DropoutLayer.Builder().name("dropout1").dropOut(0.2).build)
.layer(12, new DenseLayer.Builder().name("ffn2").nOut(1024).biasInit(1e-2).build)
.layer(13,new DropoutLayer.Builder().name("dropout2").dropOut(0.2).build)
.layer(14, new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.name("output")
.nOut(numLabels)
.activation(Activation.SOFTMAX)
.build)
.backprop(true)
.pretrain(false)
.setInputType(InputType.convolutional(height, width, channels))
.build
与在Keras 实现部分中实现的模型完全相同的考虑因素也适用于此。因此,我们跳过所有中间步骤,直接实现一个复杂的模型,如下图所示:
图 13.12:本节示例的模型图示
下面是模型的详细信息:
层类型 | 输入大小 | 层大小 | 参数数量 | 权重初始化 | 更新器 | 激活函数 |
---|---|---|---|---|---|---|
输入层 | ||||||
卷积层 | 3 | 64 | 3,136 | XAVIER_UNIFORM | Adam | ReLU |
卷积层 | 64 | 64 | 65,600 | XAVIER_UNIFORM | Adam | ReLU |
下采样(最大池化) | ||||||
卷积层 | 64 | 96 | 98,400 | XAVIER_UNIFORM | Adam | ReLU |
卷积层 | 96 | 96 | 147,552 | XAVIER_UNIFORM | Adam | ReLU |
卷积层 | 96 | 128 | 110,720 | XAVIER_UNIFORM | Adam | ReLU |
卷积层 | 128 | 128 | 147,584 | XAVIER_UNIFORM | Adam | ReLU |
卷积层 | 128 | 256 | 131,328 | XAVIER_UNIFORM | Adam | ReLU |
卷积层 | 256 | 256 | 262,400 | XAVIER_UNIFORM | Adam | ReLU |
下采样(最大池化) | ||||||
全连接层 | 16,384 | 1,024 | 16,778,240 | XAVIER | Adam | Sigmoid |
Dropout | 0 | 0 | 0 | Sigmoid | ||
全连接层 | 1,024 | 1,024 | 1,049,600 | XAVIER | Adam | Sigmoid |
Dropout | 0 | 0 | 0 | Sigmoid | ||
输出层 | 1,024 | 10 | 10,250 | XAVIER | Adam | Softmax |
那么我们接下来初始化模型,如下所示:
val conf = defineModelConfiguration
val model = new MultiLayerNetwork(conf)
model.init
然后,开始训练,如下所示:
val epochs = 10
for(idx <- 0 to epochs) {
model.fit(trainDataSetIterator)
}
最后,评估它,如下所示:
val eval = new Evaluation(testDataSetIterator.getLabels)
while(testDataSetIterator.hasNext) {
val testDS = testDataSetIterator.next(batchSize)
val output = model.output(testDS.getFeatures)
eval.eval(testDS.getLabels, output)
}
println(eval.stats)
我们在这里实现的神经网络具有相当多的隐藏层,但按照前一节的建议(增加更多层,做数据增强,以及训练更多的 epoch),可以大幅提高模型的准确度。
训练当然可以使用 Spark 完成。前述代码中需要的更改,正如在第七章中详细介绍的那样,使用 Spark 训练神经网络,涉及 Spark 上下文初始化、训练数据并行化、TrainingMaster
创建,以及使用 SparkDl4jMultiLayer
实例执行训练,如下所示:
// Init the Spark context
val sparkConf = new SparkConf
sparkConf.setMaster(master)
.setAppName("Object Recognition Example")
val sc = new JavaSparkContext(sparkConf)
// Parallelize data
val trainDataList = mutable.ArrayBuffer.empty[DataSet]
while (trainDataSetIterator.hasNext) {
trainDataList += trainDataSetIterator.next
}
val paralleltrainData = sc.parallelize(trainDataList)
// Create the TrainingMaster
var batchSizePerWorker: Int = 16
val tm = new
ParameterAveragingTrainingMaster.Builder(batchSizePerWorker)
.averagingFrequency(5)
.workerPrefetchNumBatches(2)
.batchSizePerWorker(batchSizePerWorker)
.build
// Training
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
for (i <- 0 until epochs) {
sparkNet.fit(paralleltrainData)
println("Completed Epoch {}", i)
}
总结
在回顾了卷积概念和物体识别策略分类之后,本章中,我们以实践的方式,使用不同的语言(Python 和 Scala)以及不同的开源框架(第一种情况下使用 Keras 和 TensorFlow,第二种情况下使用 DL4J、ND4J 和 Apache Spark),实现并训练了卷积神经网络(CNN)进行物体识别。
在下一章中,我们将实现一个完整的图像分类 web 应用程序,其背后使用了 Keras、TensorFlow、DL4J、ND4J 和 Spark 的组合。
第十四章:图像分类
在前一章中,我们简要回顾了卷积的概念,并通过 Python(Keras)和 Scala(DL4J)的示例深入学习了物体识别的策略及更多实现细节。本章将介绍如何实现一个完整的图像分类 web 应用程序或 web 服务。这里的目标是向你展示如何将上一章的概念应用到端到端的分类系统中。
完成这一目标的步骤如下:
-
选择一个合适的 Keras(带 TensorFlow 后端)预训练 CNN 模型
-
在 DL4J(和 Spark)中加载并测试它
-
了解如何在 Apache Spark 上重新训练 Python 模型
-
实现一个使用该模型的图像分类 web 应用程序
-
实现一个使用该模型的替代图像分类 web 服务
在前几章中,我们学习使用 DL 场景时遇到的所有开源技术,都在这里的实现过程中得到了应用。
实现一个端到端的图像分类 web 应用程序
使用我们在本书前几章学到的所有知识,现在我们应该能够实现一个实际的 web 应用程序,允许用户上传图像并对其进行正确的分类。
选择一个合适的 Keras 模型
我们将使用一个现有的、预训练的 Python Keras CNN 模型。Keras 应用程序(keras.io/applications/
)是一组包含预训练权重的 DL 模型,作为框架的一部分提供。其中的模型包括 VGG16,这是一个由牛津大学视觉几何组在 2014 年实现的 16 层 CNN。该模型兼容 TensorFlow 后端,并且已经在 ImageNet 数据库(www.image-net.org/
)上进行了训练。ImageNet 数据集是一个非常适合一般图像分类的优秀训练集,但它不适合面部识别模型的训练。下面是加载和使用 Keras 中的 VGG16 模型的方法。我们使用 TensorFlow 后端。让我们导入该模型:
from keras.applications.vgg16 import VGG16
然后,我们需要导入其他必要的依赖(包括 NumPy 和 Pillow):
from keras.preprocessing import image
from keras.applications.vgg16 import preprocess_input
import numpy as np
from PIL import Image
现在,我们可以创建模型的实例:
model = VGG16(weights='imagenet', include_top=True)
预训练的权重将在第一次运行该应用程序时自动下载。后续运行将从本地 ~/.keras/models/
目录中加载权重。
这是模型的架构:
我们可以通过加载一张图像来测试模型:
img_path = 'test_image.jpg'
img = image.load_img(img_path, target_size=(224, 224))
我们可以将其准备好作为模型的输入(通过将图像像素转换为 NumPy 数组并进行预处理):
x = image.img_to_array(img)
x = np.expand_dims(x, axis=0)
x = preprocess_input(x)
然后,我们可以进行预测:
features = model.predict(x)
最后,我们保存模型配置(以 JSON 格式):
model_json = model.to_json()
with open('vgg-16.json', 'w') as json_file:
json_file.write(model_json)
我们还可以保存模型的权重,以便导入到 DL4J 中:
model.save_weights("vgg-16.h5")
然后,我们将以下图像作为输入传递给模型:
该图像被正确分类为虎斑猫,可能性接近 64%。
在 DL4J 中导入并测试模型
在第十章中,在分布式系统上部署,我们学习了如何将预训练的 Keras 模型导入到 DL4J 中。现在我们在这里应用相同的过程。
Scala 项目的依赖项包括 DL4J DataVec、NN、模型导入、动物园和 ND4J,以及 Apache common math 3。
我们需要做的第一件事是将模型配置(来自vgg-16.json
文件)和权重(来自vgg-16.h5
文件)复制到项目的资源文件夹中。然后,我们可以通过KerasModelImport
类的importKerasModelAndWeights
方法加载它们:
val vgg16Json = new ClassPathResource("vgg-16.json").getFile.getPath
val vgg16 = new ClassPathResource("vgg-16.h5").getFile.getPath
val model = KerasModelImport.importKerasModelAndWeights(vgg16Json, vgg16, false)
传递给方法的第三个参数是一个布尔值;如果为false
,则表示该预训练模型仅用于推理,不会重新训练。
让我们使用前面截图中的图像来测试模型。我们需要将它复制到应用程序的资源目录中。然后,我们可以加载它,并将其调整为所需的大小(224 × 224 像素):
val testImage = new ClassPathResource("test_image.jpg").getFile
val height = 224
val width = 224
val channels = 3
val loader = new NativeImageLoader(height, width, channels)
为此,我们使用的是 DataVec 图像 API 中的NativeImageLoader
类(jar-download.com/javaDoc/org.datavec/datavec-data-image/1.0.0-alpha/org/datavec/image/loader/NativeImageLoader.html
)。
然后,我们需要将图像转换为 NDArray 并进行预处理:
val image = loader.asMatrix(testImage)
val scaler = new VGG16ImagePreProcessor
scaler.transform(image)
之后,我们需要通过模型进行推理:
val output = model.output(image)
为了以人类可读的格式消费结果,我们使用org.deeplearning4j.zoo.util.imagenet.ImageNetLabels
类,它在 DL4J 的动物园库中可用。该类decodePredictions
方法的输入是从模型的output
方法返回的 NDArray 数组:
val imagNetLabels = new ImageNetLabels
val predictions = imagNetLabels.decodePredictions(output(0))
println(predictions)
以下截图展示了前面代码的输出。它呈现了上传图像的预测结果(按降序排列)。根据模型的预测,最高概率(大约 53.3%)是输入图像中的主要物体是一只虎斑猫(这是正确的):
你应该注意到,一旦模型被导入,通过 DL4J API 加载图像并进行推理的步骤与我们在上一节中展示的 Keras 示例相同。
在模型经过测试后,最好通过ModelSerializer
类将其保存:
val modelSaveLocation = new File("Vgg-16.zip")
ModelSerializer.writeModel(model, modelSaveLocation, true)
然后,我们可以通过相同的类加载它,因为与从 Keras 加载相比,这样的资源消耗更少。
在 Apache Spark 中重新训练模型
为了提高我们在本章使用案例中考虑的 Keras VGG16 预训练模型的准确性,我们还可以决定对其进行再训练,并应用我们从上一章学到的所有最佳实践(运行更多的 epochs、图像增强等等)。一旦模型导入到 DL4J 中,其训练可以按照 第七章《使用 Spark 训练神经网络》(使用 DL4J 和 Apache Spark 进行训练)中解释的方式进行。在加载后,会创建一个 org.deeplearning4j.nn.graph.ComputationGraph
实例,因此,训练多层网络的相同原则在这里同样适用。
为了信息的完整性,你需要知道,Keras 模型也可以在 Apache Spark 上以并行模式进行训练。这可以通过 dist-keras
Python 框架实现(github.com/cerndb/dist-keras/
),该框架是为 分布式深度学习 (DDL) 创建的。可以通过 pip
安装该框架:
sudo pip install dist-keras
它需要 TensorFlow(将作为后端使用)并且需要设置以下变量:
export SPARK_HOME=/usr/lib/spark
export PYTHONPATH="$SPARK_HOME/python/:$SPARK_HOME/python/lib/py4j-0.9-src.zip:$PYTHONPATH"
让我们快速看一下使用 dist-keras
进行分布式训练的典型流程。以下代码不是完整的工作示例;这里的目标是让你了解如何设置数据并行训练。
首先,我们需要导入 Keras、PySpark、Spark MLLib 和 dist-keras
所需的所有类。我们将首先导入 Keras:
from keras.optimizers import *
from keras.models import Sequential
from keras.layers.core import Dense, Dropout, Activation
然后,我们可以导入 PySpark:
from pyspark import SparkContext
from pyspark import SparkConf
然后,我们导入 Spark MLLib:
from pyspark.ml.feature import StandardScaler
from pyspark.ml.feature import VectorAssembler
from pyspark.ml.feature import StringIndexer
from pyspark.ml.evaluation import MulticlassClassificationEvaluator
from pyspark.mllib.evaluation import BinaryClassificationMetrics
最后,我们导入 dist-keras
:
from distkeras.trainers import *
from distkeras.predictors import *
from distkeras.transformers import *
from distkeras.evaluators import *
from distkeras.utils import *
然后,我们需要创建 Spark 配置,如下所示:
conf = SparkConf()
conf.set("spark.app.name", application_name)
conf.set("spark.master", master)
conf.set("spark.executor.cores", num_cores)
conf.set("spark.executor.instances", num_executors)
conf.set("spark.locality.wait", "0")
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer");
然后我们可以使用它来创建一个 SparkSession
:
sc = SparkSession.builder.config(conf=conf) \
.appName(application_name) \
.getOrCreate()
数据集现在如下所示:
raw_dataset = sc.read.format('com.databricks.spark.csv') \
.options(header='true', inferSchema='true').load("data/some_data.csv")
我们可以使用此数据集通过 Spark 核心和 Spark MLLib 提供的 API 执行数据预处理和标准化(策略取决于数据集的性质,因此在此无法展示代码)。一旦完成此阶段,我们可以使用 Keras API 来定义我们的模型。
这是一个简单的 Sequential
模型的示例:
model = Sequential()
model.add(Dense(500, input_shape=(nb_features,)))
model.add(Activation('relu'))
model.add(Dropout(0.4))
model.add(Dense(500))
model.add(Activation('relu'))
model.add(Dense(nb_classes))
model.add(Activation('softmax'))
最后,你可以通过选择 dist-keras
提供的多个优化算法之一来启动训练过程:
-
顺序训练器
-
ADAG
-
动态 SDG
-
AEASGD
-
AEAMSGD
-
DOWNPOUR
-
集成训练
-
模型平均
虽然列表中的后面几种方法性能更好,但第一个 SingleTrainer
,通常作为基准 trainer
使用,在数据集过大无法完全加载到内存时,可能是一个不错的 trainer
选择。以下是使用 SingleTrainer
进行训练的代码示例:
trainer = SingleTrainer(keras_model=model, worker_optimizer=optimizer,
loss=loss, features_col="features_normalized",
label_col="label", num_epoch=1, batch_size=32)
trained_model = trainer.train(training_set)
实现 Web 应用程序
让我们回到主要任务,开始实现一个允许用户上传图片的网页应用程序,然后使用序列化的 VGG16 模型对其进行推断。JVM 上有多个框架可以用来实现网页应用程序。在这种情况下,为了最小化我们的工作量,我们将使用 SparkJava(sparkjava.com/
,不要与 Apache Spark 混淆),这是一个为 JVM 编程语言设计的微框架,旨在快速原型开发。与其他网页框架相比,它的模板代码最少。SparkJava 不仅仅是为网页应用程序设计的;也可以用非常少的代码行来实现 REST API(它将在下一节中用于实现我们的图像分类网页服务)。
我们必须将 SparkJava 添加到 Java 项目的依赖项列表中:
groupId: com.sparkjava
artifactId: spark-core
version: 2.7.2
本示例的参考版本为2.7.2
(在写这本书时是最新版本)。
在最简单的实现中,一个 SparkJava 网页应用程序只需在main
方法中写一行代码:
get("/hello", (req, res) -> "Hello VGG16");
运行应用程序后,hello
页面可以通过以下 URL 从网页浏览器访问:
http://localhost:4567/hello
4567
是 SparkJava 网页应用程序的默认端口。
SparkJava 应用程序的主要构建块是路由。路由由三部分组成:一个动词(get
、post
、put
、delete
、head
、trace
、connect
和options
是可用的动词)、一个路径(在前面的代码示例中是/hello
)和一个回调(request
或response
)。SparkJava API 还包括用于会话、Cookie、过滤器、重定向和自定义错误处理的类。
让我们开始实现我们的网页应用程序。项目的其他依赖项包括 DL4J 核心、DataVec、NN、模型导入和动物园(zoo),以及 ND4J。我们需要将 DL4J 的序列化模型(Vgg-16.zip
文件)添加到项目的资源中。然后,可以通过ModelSerializer
类在程序中加载该模型:
ClassLoader classLoader = getClass().getClassLoader();
File serializedModelFile = new File(classLoader.getResource("Vgg-16.zip").getFile());
ComputationGraph vgg16 = ModelSerializer.restoreComputationGraph(serializedModelFile);
我们需要创建一个目录,用于存放用户上传的图片:
File uploadDir = new File("upload");
uploadDir.mkdir();
下一步是创建一个表单,让用户可以上传图片。在 SparkJava 中,可以为网页使用自定义样式。在这个例子中,我们将添加响应式的 Foundation 6 框架(foundation.zurb.com/
)和 CSS。我们将最小的 Foundation CSS 库(foundation-float.min.css
)添加到项目资源文件夹下的一个名为public
的子目录中。这样,网页应用程序就可以在类路径中访问它。静态文件的位置可以通过编程方式注册:
staticFiles.location("/public");
Foundation CSS 和其他静态 CSS 文件可以在页面的头部注册。这里是为此示例实现的方法:
private String buildFoundationHeader() {
String header = "<head>\n"
+ "<link rel='stylesheet' href='foundation-float.min.css'>\n"
+ "</head>\n";
return header;
}
我们现在实现一个名为buildUploadForm
的方法,它返回该表单的 HTML 内容:
private String buildUploadForm() {
String form =
"<form method='post' action='getPredictions' enctype='multipart/form-data'>\n" +
" <input type='file' name='uploadedFile'>\n" +
" <button class='success button'>Upload picture</button>\n" +
"</form>\n";
return form;
}
然后我们在定义上传页面路由时使用这个方法:
String header = buildFoundationHeader();
String form = buildUploadForm();
get("Vgg16Predict", (req, res) -> header + form);
现在我们可以定义post
请求:
post("/doPredictions", (req, res)
我们这样做是为了处理图像上传和分类。在此post
请求的主体中,我们需要执行以下操作:
-
将图像文件上传到
upload
目录 -
将图像转换为 NDArray
-
删除文件(转换后不需要将其保留在 Web 服务器磁盘上)
-
预处理图像
-
执行推理
-
显示结果
当转换成 Java 时,代码如下所示:
// Upload the image file
Path tempFile = Files.createTempFile(uploadDir.toPath(), "", "");
req.attribute("org.eclipse.jetty.multipartConfig", new MultipartConfigElement("/temp"));
try (InputStream input = req.raw().getPart("uploadedFile").getInputStream()) {
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
}
// Convert file to INDArray
File file = tempFile.toFile();
NativeImageLoader loader = new NativeImageLoader(224, 224, 3);
INDArray image = loader.asMatrix(file);
// Delete the physical file
file.delete();
// Pre-processing the image to prepare it for the VGG-16 model
DataNormalization scaler = new VGG16ImagePreProcessor();
scaler.transform(image);
// Do inference
INDArray[] output = vgg16.output(false,image);
// Get the predictions
ImageNetLabels imagNetLabels = new ImageNetLabels();
String predictions = imagNetLabels.decodePredictions(output[0]);
// Return the results
return buildFoundationHeader() + "<h4> '" + predictions + "' </h4>" +
"Would you like to try another image?" +
form;
你会注意到,通过 DL4J 进行的图像准备和推理部分与独立应用程序中的完全相同。
启动应用程序后,可以通过以下 URL 访问它:
http://localhost:4567/Vgg16Predict
可以通过编程方式设置不同的监听端口:
port(8998);
以下截图展示了上传页面的布局:
以下截图展示了我们上传所需图像:
结果如下所示:
实现 Web 服务
正如我们在前一节中提到的,SparkJava 可以快速实现 REST API。我们在前一节中实现的示例 Web 应用程序是单体的,但回顾其源代码,我们可以注意到将前端与后端分离并将其移至 REST API 会变得非常容易。
提供图像提交表单的前端客户端可以通过任何 Web 前端框架实现。客户端然后会调用通过 SparkJava 实现的 REST 服务,后者使用 VGG16 模型进行推理,最终返回 JSON 格式的预测结果。让我们看看从现有的 Web 应用程序代码开始,实现这个服务有多么简单。
Web 服务是一个带有主方法作为入口点的 Java 类。我们来定义一个自定义监听端口:
port(8998);
现在我们已经完成了这一步,我们需要定义upload
端点:
post("/upload", (req, res) -> uploadFile(req));
我们需要将原始post
体中的代码移到uploadFile
方法中(唯一的区别是返回值,它只是预测内容,而不是完整的 HTML 内容):
private String uploadFile(Request req) throws IOException, ServletException {
// Upload the image file
Path tempFile = Files.createTempFile(uploadDir.toPath(), "", "");
req.attribute("org.eclipse.jetty.multipartConfig", new MultipartConfigElement("/temp"));
try (InputStream input = req.raw().getPart("file").getInputStream()) {
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
}
// Convert file to INDArray
File file = tempFile.toFile();
NativeImageLoader loader = new NativeImageLoader(224, 224, 3);
INDArray image = loader.asMatrix(file);
// Delete the physical file
file.delete();
// Pre-processing the image to prepare it for the VGG-16 model
DataNormalization scaler = new VGG16ImagePreProcessor();
scaler.transform(image);
// Do inference
INDArray[] output = vgg16.output(false,image);
// Get the predictions
ImageNetLabels imagNetLabels = new ImageNetLabels();
String predictions = imagNetLabels.decodePredictions(output[0]);
// Return the results
return predictions;
}
运行应用程序后,你可以通过简单的curl
(curl.haxx.se/
)命令进行测试:
curl -s -X POST http://localhost:8998/upload -F 'file=@/home/guglielmo/dlws2/vgg16/src/main/resources/test_image-02.jpg'
输出将如下所示:
如果我们希望以 JSON 格式返回输出,这是我们需要对 Web 服务代码进行的唯一更改:
Gson gson = new Gson();
post("/upload", (req, res) -> uploadFile(req), gson::toJson);
我们只需要创建一个com.google.gson.Gson
实例,并将其作为最后一个参数传递给post
方法。我们的示例输出将如下所示:
总结
在本章中,我们通过结合本书前几章中学习的多个开源框架,成功实现了我们的第一个端到端图像分类 web 应用程序。读者现在应该掌握了构建块的所有知识,可以开始使用 Scala 和/或 Python 以及 DL4J 和/或 Keras 或 TensorFlow 开发自己的 DL 模型或应用程序。
本章结束了本书的实践部分。接下来的最后一章将讨论 DL 和 AI 的未来,重点讨论 DL4J 和 Apache Spark。
第十五章:深度学习的下一步是什么?
本章的最后将尝试概述深度学习(DL)的未来,以及更广泛的人工智能的未来。
本章将涵盖以下主题:
-
深度学习(DL)与人工智能(AI)
-
热点话题
-
Spark 和 强化学习 (RL)
-
生成对抗网络(GANs)在 DL4J 中的支持
技术的快速进步不仅加速了现有人工智能理念的实施,还在这一领域创造了新的机会,这些机会在一两年前是不可想象的。人工智能日复一日地在各个领域发现新的实际应用,并且正在彻底改变我们在这些领域中的业务方式。因此,覆盖所有的新场景是不可能的,所以我们将专注于一些特定的领域/情境,那里我们直接或间接地有所参与。
对深度学习和人工智能的未来期望
如前所述,技术每天都在进步,同时计算能力的提升变得更加普及,且变得更加廉价,数据的可得性也在增加,这一切都推动着更深层次和更复杂模型的实现。因此,深度学习和人工智能的边界似乎没有上限。尝试理解我们对这些领域的期望,可能有助于我们清晰地了解短期内(2-3 年)会发生什么,但接下来可能发生的事情则较为不可预测,因为在这一领域中,任何新的想法都带来了其他的想法,并且正在推动多个行业的业务方式发生根本性的变化。因此,我将在本节中描述的是关于近期未来的内容,而非长期的变化。
深度学习在塑造人工智能的未来中发挥了关键作用。在某些领域,如图像分类和识别、物体检测和自然语言处理(NLP)中,深度学习已经超越了机器学习(ML),但这并不意味着机器学习算法已经过时。对于一些特定问题,深度学习可能有些过于复杂,因此机器学习仍然足够。在一些更复杂的情况下,深度学习与非深度学习算法的结合已经取得了显著成果;一个完美的例子是 DeepMind 团队的 AlphaGo 系统(deepmind.com/research/alphago/
),它使用蒙特卡洛树搜索(MCTS):mcts.ai/about/
,结合深度学习网络来快速寻找制胜的棋步。深度学习的这一巨大进展也促成了其他更复杂和更先进的技术,如强化学习和生成对抗网络,这些将在本章的最后两节中讨论。
然而,虽然算法和模型正在取得令人惊人的快速进展,仍然存在许多障碍需要显著的人工干预(和额外的时间),才能在将数据提取并转化为机器智能之前消除它们。正如谷歌研究小组在论文Hidden Technical Debt in Machine Learning Systems(papers.nips.cc/paper/5656-hidden-technical-debt-in-machine-learning-systems.pdf
)中讨论的那样,在 DL 和 ML 系统中,数据依赖的成本难以检测,并且可能轻易高于代码依赖的成本。以下图表取自同一篇谷歌研究论文,显示了 ML 或 DL 系统中 ML 或 DL 代码相对于其他依赖的比例:
图 15.1:现实世界中的大部分 ML/DL 系统只有一个很小的部分(图像中央的黑色矩形)由 ML/DL 代码组成
正如您从上述图表中可以看到的那样,诸如数据收集、设置和维护服务基础设施等事项比模型的实施和训练更为耗时和花费。因此,我期望在自动化这些任务时会有显著的改进。
需关注的话题
在过去的几个月中,关于所谓的可解释 AI,即不是黑匣子类型(我们只理解其基础数学原理)并且其行动或决策可以被人类轻松理解的 AI,引发了一场新的辩论。批评也已经开始(一般针对 AI,但特别是 DL),关于模型生成的结果不符合GDPR(即通用数据保护条例)的问题:ec.europa.eu/commission/priorities/justice-and-fundamental-rights/data-protection/2018-reform-eu-data-protection-rules_en
,涉及欧盟公民的数据,或其他可能在全球其他地区定义的数据法规,这些法规要求有权要求解释,以防止基于不同因素的歧视效应。
尽管这是一个非常热门且不容忽视的话题,而且已有一些有趣的分析和提案(例如来自都柏林科技学院的 Luca Longo 博士的www.academia.edu/18088836/Defeasible_Reasoning_and_Argument-Based_Systems_in_Medical_Fields_An_Informal_Overview
以及ie.linkedin.com/in/drlucalongo
的研究),我(以及本书的读者们大概也是)有机会听取了其他一些人对深度学习(DL)未来不佳的预测观点,认为 DL 的应用将仅限于非商业性应用和游戏。在本节中,我不会对这种观点做评论,因为它通常更多是基于意见而非事实,而且有时由那些没有完全参与 DL 或机器学习(ML)领域的生产或研究项目的人提出。相反,我更愿意展示一份关于仍然有效且可以持续一段时间的实际 DL 应用列表。
医疗保健是 AI 和 DL 实际应用数量较多的行业之一。Optum(www.optum.com/
),作为 UnitedHealth Group 的一部分,已经在其整体战略中取得了显著成果,特别是在将自然语言处理(NLP)应用于多个商业用例中。AI 理解结构化和非结构化数据的能力在医疗记录审查中起着至关重要的作用(大多数数据都是非结构化的)。Optum 的所谓临床智能 NLP 能够解锁非结构化内容,以获取结构化数据元素,如诊断、治疗过程、药物、实验室检查等,进而形成完整且准确的临床文档。
来自非结构化来源的数据通过 NLP 技术自动提取,与通过更传统临床模型和规则引擎获得的结构化数据互补。这种自动化水平能够准确识别诊断、相关疾病和治疗过程,以实施提供的护理,但它也有助于定义适当的报销、质量措施以及其他关键的医疗保健操作。然而,理解记录中已经记录了什么,仅仅是 NLP 在医疗领域价值的一个方面。临床智能 NLP 技术还能够识别文档中的空白;它不仅能理解记录中有什么,还能理解缺少了什么。通过这种方式,临床医生可以获得有价值的反馈,从而改善文档记录。Optum 在 AI 方面的其他显著应用还包括支付完整性、简化的人口分析和呼叫中心等。
人工智能的另一个热门话题是机器人技术。从技术角度讲,机器人学是一个独立的领域,但它与人工智能有很多交集。深度学习(DL)和强化学习(RL)的进展为机器人技术中的多个问题提供了解决方案。机器人定义为首先能够感知,然后计算传感器的输入,最后根据这些计算结果采取行动。人工智能的介入使得机器人摆脱了工业化的“步步重复”模式,使它们变得更加智能。
在这一方向上,一个成功的用户案例是德国初创公司 Kewazo(www.kewazo.com/
)。他们实施了一种智能机器人脚手架运输系统,解决了人手不足、效率低下、高成本、耗时工作和工人安全等问题。人工智能使得他们能够实现一个机器人系统,通过实时传递关于整体脚手架组装过程的数据,实现持续控制和显著优化或调优。人工智能还帮助 Kewazo 的工程师识别出其他应用场景,例如屋顶或太阳能面板安装,机器人可以在这些场景中工作并帮助实现与脚手架组装相同的结果。
物联网(IoT)是人工智能日益普及的另一个领域。物联网的基本概念是日常使用的物理设备通过互联网连接,并能够相互通信以交换数据。这些收集到的数据可以被智能处理,从而使设备变得更加智能。随着连接设备数量的急剧增加(以及由这些设备生成的数据),人工智能和物联网的应用场景也在不断增长。
在这些应用场景中,我想提到人工智能在智能建筑中的潜力。过去五年,由信息技术、银行、金融和制药等行业推动的爱尔兰经济迅速增长,导致我目前工作的地区发生了根本性变化,即都柏林市中心的 Docklands 和 Grand Canal Dock 之间。为了应对新兴或扩张中的公司对办公空间日益增长的需求,数百座新建筑应运而生(还有更多在建)。所有这些新建的建筑都使用了一些人工智能技术,结合物联网,使建筑变得更智能。在以下领域取得了显著成果:
-
让建筑对人类更加舒适
-
让建筑对人类更加安全
-
改善能源节约(并有助于环保)
传统的控制器(例如温度、灯光、门等)使用有限数量的传感器自动调整设备以实现恒定的最终结果。这个范式以前忽视了一个重要的因素:建筑物是由人类居住的,但无论是否有人在场,建筑物的控制方式都是一样的。这意味着像让人们感到舒适或节省能源之类的问题,根本没有被考虑在内。物联网与人工智能相结合,可以填补这个关键的空白。因此,建筑物可以有优先级,而不仅仅是遵循严格的编程范式。
物联网和人工智能的另一个有趣的实际应用场景是农业。农业部门(特别是乳制品)是爱尔兰国内生产总值的重要组成部分,也是爱尔兰出口中不可忽视的一部分。农业面临着新的和旧的挑战(例如在相同的土地面积上生产更多的食物、满足严格的排放要求、保护种植园免受害虫侵害、考虑气候及全球气候变化、控制水流、监控大规模果园、抗击火灾、监控土壤质量、监测动物健康等等)。这意味着农民不能仅仅依赖传统的做法。人工智能、物联网和物联网支持的传感器正在帮助他们解决我们之前提到的挑战以及更多的问题。爱尔兰已经在多个实际应用中部署了智能农业(其中一些在 2018 年 Predict 大会上展示过:www.tssg.org/projects/precision-dairy/
),预计 2019 年还会有更多应用落地。
说到人工智能和物联网,边缘分析是另一个热门话题。边缘分析是传统的大数据分析的替代方案,传统大数据分析通常是在集中式方式下执行的,而边缘分析则是在系统中的某个非中心点(例如连接设备或传感器)分析数据。目前,边缘分析在工业 4.0 领域中已有多个实际应用(但不限于此)(en.wikipedia.org/wiki/Industry_4.0
)。在数据生成的同时进行分析,可以减少决策过程中的延迟,尤其是在连接设备上。
例如,假设在某制造系统中,传感器数据指示某个特定部件可能会故障;内置在机器学习(ML)或深度学习(DL)算法中的规则可以自动在网络边缘解读这些数据,进而关闭机器并向维修经理发送警报,以便及时更换该部件。与将数据传输到集中数据位置进行处理和分析相比,这可以节省大量时间,并减少甚至避免计划外机械停机的风险。
边缘分析还带来了可伸缩性方面的好处。在那些组织中,连接设备数量增加(以及生成和收集的数据量也增加)的情况下,通过将算法推送到传感器和网络设备,可以减轻企业数据管理和集中分析系统的处理压力。在这个领域有一些值得关注的有前途的开源项目。DL4J 本身就是其中之一;其移动特性允许在 Android 设备上定义多层神经网络模型、训练和推理(由于 Android 是 JVM 框架的自然选择,其他移动平台不支持)。TensorFlow Lite (www.tensorflow.org/lite/
) 可以在几种移动操作系统(包括 Android、iOS 和其他)和嵌入式设备上实现低延迟、小二进制大小的设备端 ML 推理。StreamSets 数据收集器边缘的最新版本(streamsets.com/products/sdc-edge
)允许在设备上触发高级分析和 ML(TensorFlow)(Linux、Android、iOS、Windows 和 MacOS 是其支持的操作系统)。我期待在这个领域会有更多来自开源世界的发展。
DL 的崛起促使研究人员开发出可以直接实现神经网络架构的硬件芯片。它们设计为在硬件级别模仿人脑。在传统芯片中,数据需要在 CPU 和存储块之间传输,而在神经形态芯片中,数据既在芯片内处理又存储,并且在需要时可以生成突触。这种第二种方法不会产生时间开销,并且节省能量。因此,未来的人工智能很可能更多地基于神经形态芯片而不是基于 CPU 或 GPU。人类大脑中大约有 1000 亿个神经元密集地打包在一个小体积内,使用非常少的能量就能以闪电般的速度处理复杂计算。在过去几年中,出现了受大脑启发的算法,可以做到识别人脸、模仿声音、玩游戏等。但软件只是更大图景的一部分。我们现代的计算机实际上无法运行这些强大的算法。这就是神经形态计算进入游戏的地方。
本节中展示的场景确实证实了,在考虑到 GDPR 或其他数据法规时,深度学习和人工智能绝对不会局限于无用的应用。
Spark 准备好接受 RL 了吗?
在本书中,我们已经理解了深度学习(DL)如何解决计算机视觉、自然语言处理和时间序列预测中的多个问题。将 DL 与强化学习(RL)结合起来,可以解决更复杂的问题,并带来更惊人的应用。那么,什么是强化学习(RL)呢?它是机器学习(ML)中的一个特定领域,在这个领域中,代理必须采取行动,以最大化给定环境中的奖励。强化学习这个术语来源于这种学习过程与孩子们通过糖果获得激励的相似性;当 RL 算法做出正确决策时会获得奖励,做出错误决策时会受到惩罚。RL 与监督学习不同,后者中的训练数据本身就带有答案,然后通过正确的答案来训练模型。在 RL 中,代理决定该做什么来完成任务,如果没有训练数据集可用,它们只能通过自己的经验来学习。
强化学习的一个主要应用领域是计算机游戏(其中最优秀且最受欢迎的成果之一是来自 Alphabet 公司 DeepMind 团队的 AlphaGo,详见deepmind.com/research/alphago/
),但它也可以应用于其他领域,如机器人技术、工业自动化、聊天机器人系统、自动驾驶汽车、数据处理等。
在了解 Apache Spark 中对 RL 的支持以及它可能的发展之前,让我们先看一下强化学习的基本概念。
下面是主要的概念:
-
代理:它是执行动作的算法。
-
动作:它是代理可以采取的可能行动之一。
-
折扣因子:它量化了即时奖励与未来奖励在重要性上的差异。
-
环境:它是代理所处的世界。环境以代理的当前状态和动作作为输入,返回代理的奖励和下一个状态作为输出。
-
状态:它是代理所处的具体情境。
-
奖励:它是衡量代理行动成功或失败的反馈(该行动使得从一个状态到另一个状态的转变)。
-
策略:它是一个代理根据当前状态来决定其下一步动作的策略。
-
价值:它是当前状态下,在给定策略下的预期长期回报。
-
Q 值:它类似于价值,但还考虑了当前的动作。
-
轨迹:它是影响状态和动作的状态与动作序列。
我们可以总结强化学习(RL)如下:
图 15.2:强化学习反馈回路
一个很好的例子来解释这些概念的是流行的吃豆人视频游戏,详见en.wikipedia.org/wiki/Pac-Man
;请看下面的截图:
图 15.3:吃豆人视频游戏
在这里,代理是吃豆人角色,目标是在迷宫中吃掉所有食物,同时避开一些试图杀死它的鬼怪。迷宫是代理的环境。它吃到食物会获得奖励,被鬼怪杀死时则会受到惩罚(游戏结束)。状态是代理在迷宫中的位置。总累计奖励是代理赢得游戏并进入下一个关卡。开始探索后,吃豆人(代理)可能会发现迷宫四个角落附近的四颗能量豆(使它对鬼怪免疫),并决定花费所有时间利用这一发现,不断绕着迷宫的这小块区域转,永远不深入迷宫的其他部分去追求更大的奖励。为了构建一个最优策略,代理面临一个两难选择:一方面是探索新的状态,另一方面是最大化其奖励。这样,它可能错过了最终奖励(进入下一个关卡)。这被称为探索与利用的权衡。
最流行的 RL 算法是马尔可夫决策过程(MDP):en.wikipedia.org/wiki/Markov_decision_process
,Q 学习 (en.wikipedia.org/wiki/Q-learning
),和A3C (arxiv.org/pdf/1602.01783.pdf
)。
Q 学习广泛应用于游戏(或类似游戏的)领域。它可以用以下方程式概括(源代码来自 Q 学习的 Wikipedia 页面):
在这里,s[t] 是时刻 t 的状态,a[t] 是代理采取的行动,r[t] 是时刻 t 的奖励,s[t+1] 是新的状态(时刻 t+1), 是学习率 (
),而
是折扣因子。最后一个参数决定了未来奖励的重要性。如果它为零,代理会变得目光短浅,因为它只会考虑当前的奖励。如果其值接近 1,代理则会努力实现长期的高奖励。如果折扣因子值为 1 或更高,则行动值可能会发散。
Apache Spark 的 MLLib 组件目前没有针对 RL 的任何功能,而且在编写本书时,似乎没有计划在未来的 Spark 版本中实现对此的支持。然而,确实有一些与 Spark 集成的开源稳定 RL 项目。
DL4J 框架提供了一个专门的 RL 模块——RL4J,最初是一个独立的项目。和所有其他 DL4J 组件一样,它完全与 Apache Spark 集成。它实现了 DQN(深度 Q 学习与双 DQN)和 AC3 RL 算法。
英特尔的杨宇豪(www.linkedin.com/in/yuhao-yang-8a150232
)做了有趣的实现,促成了 Analytics Zoo 计划的启动(github.com/intel-analytics/analytics-zoo
)。这是他在 2018 年 Spark-AI 峰会上的演讲链接(databricks.com/session/building-deep-reinforcement-learning-applications-on-apache-spark-using-bigdl
)。Analytics Zoo 提供了一个统一的分析和 AI 平台,可以将 Spark、TensorFlow、Keras 和 BigDL 程序无缝集成到一个可以扩展到大规模 Spark 集群进行分布式训练或推理的管道中。
虽然 RL4J 作为 DL4J 的一部分,为 JVM 语言(包括 Scala)提供 API,而 BigDL 则为 Python 和 Scala 提供 API,但 Facebook 提供了一个仅支持 Python 的端到端开源平台,用于大规模强化学习。这个平台的名称是 Horizon(github.com/facebookresearch/Horizon
)。Facebook 自己也在生产环境中使用它来优化大规模环境中的系统。它支持离散动作 DQN、参数化动作 DQN、双 DQN、DDPG(arxiv.org/abs/1509.02971
)和 SAC(arxiv.org/abs/1801.01290
)算法。该平台中的工作流和算法都是建立在开源框架(PyTorch 1.0、Caffe2 和 Apache Spark)上的。目前尚不支持与其他流行的 Python 机器学习框架(如 TensorFlow 和 Keras)一起使用。
RISELab(rise.cs.berkeley.edu/
)的 Ray 框架(ray-project.github.io/
)值得特别提及。尽管 DL4J 和我们之前提到的其他框架是在 Apache Spark 之上以分布式模式工作,但在伯克利的研究人员眼中,Ray 是 Spark 本身的替代品,他们认为 Spark 更通用,但并不完全适合某些实际的 AI 应用。Ray 是用 Python 实现的,完全兼容最流行的 Python 深度学习框架,包括 TensorFlow 和 PyTorch;并且它允许在同一应用中使用多个框架的组合。
在强化学习(RL)的特定情况下,Ray 框架还提供了一个专门的库 RLLib(ray.readthedocs.io/en/latest/rllib.html
),它实现了 AC3、DQN、进化策略(en.wikipedia.org/wiki/Evolution_strategy
)和 PPO(blog.openai.com/openai-baselines-ppo/
)算法。写这本书时,我不知道任何真实世界的 AI 应用正在使用这个框架,但我相信值得关注它如何发展以及行业的采用程度。
DeepLearning4J 未来对 GAN 的支持
生成对抗网络(GANs)是包括两个互相对抗的网络的深度神经网络架构(这也是名称中使用对抗一词的原因)。GAN 算法用于无监督机器学习。GAN 的主要焦点是从零开始生成数据。在 GAN 的最流行应用场景中,包括从文本生成图像、图像到图像的翻译、提高图像分辨率以制作更真实的图片,以及对视频的下一帧进行预测。
如前所述,GAN 由两个深度网络组成,生成器和判别器;第一个生成候选数据,第二个评估这些候选数据。让我们从很高的层次来看生成性和判别性算法是如何工作的。判别性算法尝试对输入数据进行分类,因此它们预测输入数据属于哪个标签或类别。它们唯一关心的是将特征映射到标签。生成性算法则不同,它们在给定某个标签时尝试预测特征,而不是像判别性算法那样预测标签。实际上,它们做的事情正好与判别性算法相反。
下面是 GAN 的工作原理。生成器生成新的数据实例,而判别器评估这些数据以判断其真实性。使用本书中多次举例的相同 MNIST 数据集(yann.lecun.com/exdb/mnist/
),让我们通过一个场景来明确 GAN 中发生的过程。假设我们有一个生成器生成像手写数字这样的 MNIST 数据集,然后将它们传递给判别器。生成器的目标是生成看起来像手写数字的图像,而不被发现;而判别器的目标是识别出来自生成器的这些图像是假手写数字。参考下面的图示,GAN 的步骤如下:
-
生成器网络接受一些随机数字作为输入,然后返回一张图像。
-
生成的图像被用来喂给判别器网络,同时传入其他从训练数据集中获取的图像流。
-
判别器在接收真实和伪造图像时,会返回概率值,这些概率值介于零和一之间。零代表伪造的预测,而一代表真实性的预测:
图 15.4:MNIST 示例 GAN 的典型流程
在实现方面,判别器网络是一个标准的卷积神经网络(CNN),可以对输入的图像进行分类,而生成器网络是一个反向卷积神经网络(CNN)。这两个网络在零和博弈中优化不同且相对立的损失函数。该模型本质上是一个演员-评论员模型(cs.wmich.edu/~trenary/files/cs5300/RLBook/node66.html
),其中判别器网络会改变其行为,生成器网络也是如此,反之亦然。
在撰写本书时,DL4J 并未提供任何直接支持生成对抗网络(GAN)的 API,但它允许你导入现有的 Keras(如你可以在github.com/eriklindernoren/Keras-GAN
找到的那样,这是我们的 GitHub 仓库)或 TensorFlow(如这个:github.com/aymericdamien/TensorFlow-Examples/blob/master/examples/3_NeuralNetworks/gan.py
)的 GAN 模型,然后在 JVM 环境中(包括 Spark)使用 DL4J API 重新训练它们和/或进行预测,正如第十章《在分布式系统上的部署》和第十四章《图像分类》所解释的那样。目前,DL4J 没有针对 GAN 的直接功能,但导入 Python 模型是训练和推理的有效方法。
总结
本章总结了本书的内容。在本书中,我们熟悉了 Apache Spark 及其组件,随后我们开始探索深度学习(DL)的基础知识,并开始实际操作。我们通过理解如何从不同的数据源(无论是批处理还是流模式)导入训练和测试数据,并通过 DataVec 库将其转化为向量,开始了我们的 Scala 实操之旅。接着,我们探索了卷积神经网络(CNN)和递归神经网络(RNN)的细节,以及通过 DL4J 实现这些网络模型的方法,如何在分布式和基于 Spark 的环境中训练它们,如何使用 DL4J 的可视化工具监控它们并获取有用的见解,以及如何评估它们的效率并进行推理。
我们还学习了一些配置生产环境进行训练时应遵循的技巧和最佳实践,以及如何将已经在 Keras 和/或 TensorFlow 中实现的 Python 模型导入并使其在基于 JVM 的环境中运行(或重新训练)。在本书的最后部分,我们将之前学到的知识应用于先使用深度学习实现自然语言处理(NLP)应用场景,再到实现一个端到端的图像分类应用。
我希望所有阅读完本书所有章节的读者都达成了我的初衷目标:他们已经掌握了所有的构建块,可以开始在分布式系统(如 Apache Spark)中,使用 Scala 和/或 Python 处理他们自己特定的深度学习(DL)应用场景。
附录 A:Scala 中的函数式编程
Scala 将函数式编程和面向对象编程结合在一个高级语言中。该附录包含了有关 Scala 中函数式编程原则的参考。
函数式编程(FP)
在函数式编程中,函数是第一类公民——这意味着它们像其他值一样被对待,可以作为参数传递给其他函数,或者作为函数的返回结果。在函数式编程中,还可以使用所谓的字面量形式来操作函数,无需为其命名。让我们看一下以下的 Scala 示例:
val integerSeq = Seq(7, 8, 9, 10)
integerSeq.filter(i => i % 2 == 0)
i => i % 2 == 0
是一个没有名称的函数字面量。它检查一个数字是否为偶数。它可以作为另一个函数的参数传递,或者可以作为返回值使用。
纯度
函数式编程的支柱之一是纯函数。一个纯函数是类似于数学函数的函数。它仅依赖于其输入参数和内部算法,并且对于给定的输入始终返回预期的结果,因为它不依赖于外部的任何东西。(这与面向对象编程的方法有很大的不同。)你可以很容易理解,这使得函数更容易测试和维护。一个纯函数不依赖外部的任何内容,这意味着它没有副作用。
纯粹的函数式程序在不可变数据上进行操作。与其修改现有的值,不如创建修改后的副本,而原始值则被保留。这意味着它们可以在旧副本和新副本之间共享,因为结构中未改变的部分无法被修改。这样的行为带来的一个结果是显著的内存节省。
在 Scala(以及 Java)中,纯函数的例子包括List
的size
方法(docs.oracle.com/javase/8/docs/api/java/util/List.html
)或者String
的lowercase
方法(docs.oracle.com/javase/8/docs/api/java/lang/String.html
)。String
和List
都是不可变的,因此它们的所有方法都像纯函数一样工作。
但并非所有抽象都可以直接通过纯函数实现(例如读取和写入数据库或对象存储,或日志记录等)。FP 提供了两种方法,使开发人员能够以纯粹的方式处理不纯抽象,从而使最终代码更加简洁和可维护。第一种方法在某些其他 FP 语言中使用,但在 Scala 中没有使用,即通过将语言的纯函数核心扩展到副作用来实现。然后,避免在只期望纯函数的情况下使用不纯函数的责任就交给开发人员。第二种方法出现在 Scala 中,它通过引入副作用来模拟纯语言中的副作用,使用monads(www.haskell.org/tutorial/monads.html
)。这样,虽然编程语言保持纯粹且具有引用透明性,但 monads 可以通过将状态传递到其中来提供隐式状态。编译器不需要了解命令式特性,因为语言本身保持纯粹,而通常实现会出于效率原因了解这些特性。
由于纯计算具有引用透明性,它们可以在任何时间执行,同时仍然产生相同的结果,这使得计算值的时机可以延迟,直到真正需要时再进行(懒惰计算)。这种懒惰求值避免了不必要的计算,并允许定义和使用无限数据结构。
通过像 Scala 一样仅通过 monads 允许副作用,并保持语言的纯粹性,使得懒惰求值成为可能,而不会与不纯代码的副作用冲突。虽然懒惰表达式可以按任何顺序进行求值,但 monad 结构迫使这些副作用按正确的顺序执行。
递归
递归在函数式编程(FP)中被广泛使用,因为它是经典的,也是唯一的迭代方式。函数式语言的实现通常会包括基于所谓的尾递归(alvinalexander.com/scala/fp-book/tail-recursive-algorithms
)的优化,以确保重度递归不会对内存消耗产生显著或过度的影响。尾递归是递归的一个特例,其中函数的返回值仅仅是对自身的调用。 以下是一个使用 Scala 语言递归计算斐波那契数列的例子。第一段代码表示递归函数的实现:
def fib(prevPrev: Int, prev: Int) {
val next = prevPrev + prev
println(next)
if (next > 1000000) System.exit(0)
fib(prev, next)
}
另一段代码表示同一函数的尾递归实现:
def fib(x: Int): BigInt = {
@tailrec def fibHelper(x: Int, prev: BigInt = 0, next: BigInt = 1): BigInt = x match {
case 0 => prev
case 1 => next
case _ => fibHelper(x - 1, next, (next + prev))
}
fibHelper(x)
}
虽然第一个函数的返回行包含对自身的调用,但它还对输出做了一些处理,因此返回值并不完全是递归调用的返回值。第二个实现是一个常规的递归(特别是尾递归)函数。
附录 B:Spark 图像数据准备
卷积神经网络(CNN)是本书的主要话题之一。它们被广泛应用于图像分类和分析的实际应用中。本附录解释了如何创建一个 RDD<DataSet>
来训练 CNN 模型进行图像分类。
图像预处理
本节描述的图像预处理方法将文件分批处理,依赖于 ND4J 的 FileBatch
类(static.javadoc.io/org.nd4j/nd4j-common/1.0.0-beta3/org/nd4j/api/loader/FileBatch.html
),该类从 ND4J 1.0.0-beta3 版本开始提供。该类可以将多个文件的原始内容存储在字节数组中(每个文件一个数组),包括它们的原始路径。FileBatch
对象可以以 ZIP 格式存储到磁盘中。这可以减少所需的磁盘读取次数(因为文件更少)以及从远程存储读取时的网络传输(因为 ZIP 压缩)。通常,用于训练 CNN 的原始图像文件会采用一种高效的压缩格式(如 JPEG 或 PNG),这种格式在空间和网络上都比较高效。但在集群中,需要最小化由于远程存储延迟问题导致的磁盘读取。与 minibatchSize
的远程文件读取相比,切换到单次文件读取/传输会更快。
将图像预处理成批次会带来以下限制:在 DL4J 中,类标签需要手动提供。图像应存储在以其对应标签命名的目录中。我们来看一个示例——假设我们有三个类,即汽车、卡车和摩托车,图像目录结构应该如下所示:
imageDir/car/image000.png
imageDir/car/image001.png
...
imageDir/truck/image000.png
imageDir/truck/image001.png
...
imageDir/motorbike/image000.png
imageDir/motorbike/image001.png
...
图像文件的名称并不重要。重要的是根目录下的子目录名称必须与类的名称一致。
策略
在 Spark 集群上开始训练之前,有两种策略可以用来预处理图像。第一种策略是使用 dl4j-spark
中的 SparkDataUtils
类在本地预处理图像。例如:
import org.datavec.image.loader.NativeImageLoader
import org.deeplearning4j.spark.util.SparkDataUtils
...
val sourcePath = "/home/guglielmo/trainingImages"
val sourceDir = new File(sourcePath)
val destinationPath = "/home/guglielmo/preprocessedImages"
val destDir = new File(destinationPath)
val batchSize = 32
SparkDataUtils.createFileBatchesLocal(sourceDir, NativeImageLoader.ALLOWED_FORMATS, true, destDir, batchSize)
在这个示例中,sourceDir
是本地图像的根目录,destDir
是保存预处理后图像的本地目录,batchSize
是将图像放入单个 FileBatch
对象中的数量。createFileBatchesLocal
方法负责导入。一旦所有图像都被预处理,目标目录 dir
的内容可以被复制或移动到集群中用于训练。
第二种策略是使用 Spark 对图像进行预处理。在原始图像存储在分布式文件系统(如 HDFS)或分布式对象存储(如 S3)的情况下,仍然使用 SparkDataUtils
类,但必须调用一个不同的方法 createFileBatchesLocal
,该方法需要一个 SparkContext 作为参数。以下是一个示例:
val sourceDirectory = "hdfs:///guglielmo/trainingImages";
val destinationDirectory = "hdfs:///guglielmo/preprocessedImages";
val batchSize = 32
val conf = new SparkConf
...
val sparkContext = new JavaSparkContext(conf)
val filePaths = SparkUtils.listPaths(sparkContext, sourceDirectory, true, NativeImageLoader.ALLOWED_FORMATS)
SparkDataUtils.createFileBatchesSpark(filePaths, destinationDirectory, batchSize, sparkContext)
在这种情况下,原始图像存储在 HDFS 中(通过sourceDirectory
指定位置),预处理后的图像也保存在 HDFS 中(位置通过destinationDirectory
指定)。在开始预处理之前,需要使用 dl4j-spark 的SparkUtils
类创建源图像路径的JavaRDD<String>
(filePaths
)。SparkDataUtils.createFileBatchesSpark
方法接受filePaths
、目标 HDFS 路径(destinationDirectory
)、放入单个FileBatch
对象的图像数量(batchSize
)以及 SparkContext(sparkContext
)作为输入。只有所有图像都经过 Spark 预处理后,训练才能开始。
训练
无论选择了哪种预处理策略(本地或 Spark),以下是使用 Spark 进行训练的步骤。
首先,创建 SparkContext,设置TrainingMaster
,并使用以下实例构建神经网络模型:
val conf = new SparkConf
...
val sparkContext = new JavaSparkContext(conf)
val trainingMaster = ...
val net:ComputationGraph = ...
val sparkNet = new SparkComputationGraph(sparkContext, net, trainingMaster)
sparkNet.setListeners(new PerformanceListener(10, true))
之后,需要创建数据加载器,如以下示例所示:
val imageHeightWidth = 64
val imageChannels = 3
val labelMaker = new ParentPathLabelGenerator
val rr = new ImageRecordReader(imageHeightWidth, imageHeightWidth, imageChannels, labelMaker)
rr.setLabels(new TinyImageNetDataSetIterator(1).getLabels())
val numClasses = TinyImageNetFetcher.NUM_LABELS
val loader = new RecordReaderFileBatchLoader(rr, minibatch, 1, numClasses)
loader.setPreProcessor(new ImagePreProcessingScaler)
输入图像具有分辨率为 64 x 64 像素(imageHeightWidth
)和三个通道(RGB,imageChannels
)。加载器通过ImagePreProcessingScaler
类将 0-255 值像素缩放到 0-1 的范围内(deeplearning4j.org/api/latest/org/nd4j/linalg/dataset/api/preprocessor/ImagePreProcessingScaler.html
)。
训练可以从以下示例开始:
val trainPath = "hdfs:///guglielmo/preprocessedImages"
val pathsTrain = SparkUtils.listPaths(sc, trainPath)
val numEpochs = 10
for (i <- 0 until numEpochs) {
println("--- Starting Training: Epoch {} of {} ---", (i + 1), numEpochs)
sparkNet.fitPaths(pathsTrain, loader)
}