Java-深度学习项目-全-

Java 深度学习项目(全)

原文:annas-archive.org/md5/d89c1af559e5f85f6b80756c31400c3f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

数据的持续增长以及对基于这些数据做出越来越复杂决策的需求,正在带来巨大的障碍,阻碍组织通过传统的分析方法及时获取见解。

为了寻找有意义的价值和见解,深度学习得以发展,深度学习是基于学习多个抽象层次的机器学习算法分支。神经网络作为深度学习的核心,被广泛应用于预测分析、计算机视觉、自然语言处理、时间序列预测以及执行大量其他复杂任务。

至今,大多数深度学习书籍都是以 Python 编写的。然而,本书是为开发者、数据科学家、机器学习从业者和深度学习爱好者设计的,旨在帮助他们利用 Deeplearning4j(一个基于 JVM 的深度学习框架)的强大功能构建强大、稳健和准确的预测模型,并结合其他开源 Java API。

在本书中,你将学习如何使用前馈神经网络、卷积神经网络、递归神经网络、自编码器和因子分解机开发实际的 AI 应用。此外,你还将学习如何在分布式环境下通过 GPU 进行深度学习编程。

完成本书后,你将熟悉机器学习技术,特别是使用 Java 进行深度学习,并能够在研究或商业项目中应用所学知识。总之,本书并非从头到尾逐章阅读。你可以跳到某一章,选择与你所要完成的任务相关的内容,或者是一个激发你兴趣的章节。

祝阅读愉快!

本书的读者群体

本书对于希望通过利用基于 JVM 的 Deeplearning4j (DL4J)、Spark、RankSys 以及其他开源库的强大功能来开发实际深度学习项目的开发者、数据科学家、机器学习从业者和深度学习爱好者非常有用。需要具备一定的 Java 基础知识。然而,如果你有一些关于 Spark、DL4J 和基于 Maven 的项目管理的基本经验,将有助于更快速地掌握这些概念。

本书内容

第一章,深度学习入门,解释了机器学习和人工神经网络作为深度学习核心的一些基本概念。然后简要讨论了现有的和新兴的神经网络架构。接着,介绍了深度学习框架和库的各种功能。随后,展示了如何使用基于 Spark 的多层感知器(MLP)解决泰坦尼克号生还预测问题。最后,讨论了与本项目及深度学习领域相关的一些常见问题。

第二章,使用递归类型网络进行癌症类型预测,展示了如何开发一个深度学习应用程序,用于从高维基因表达数据集中进行癌症类型分类。首先,它执行必要的特征工程,以便数据集能够输入到长短期记忆(LSTM)网络中。最后,讨论了一些与该项目和 DL4J 超参数/网络调整相关的常见问题。

第三章,使用卷积神经网络进行多标签图像分类,演示了如何在 DL4J 框架上,使用 CNN 处理多标签图像分类问题的端到端项目。它讨论了如何调整超参数以获得更好的分类结果。

第四章,使用 Word2Vec 和 LSTM 网络进行情感分析,展示了如何开发一个实际的深度学习项目,将评论文本分类为正面或负面情感。将使用大规模电影评论数据集来训练 LSTM 模型,并且 Word2Vec 将作为神经网络嵌入。最后,展示了其他评论数据集的示例预测。

第五章,图像分类的迁移学习,展示了如何开发一个端到端项目,利用预训练的 VGG-16 模型解决猫狗图像分类问题。我们将所有内容整合到一个 Java JFrame 和 JPanel 应用程序中,以便让整个流程更容易理解,进行示例的对象检测。

第六章,使用 YOLO、JavaCV 和 DL4J 进行实时对象检测,展示了如何开发一个端到端项目,在视频片段连续播放时,从视频帧中检测对象。预训练的 YOLO v2 模型将作为迁移学习使用,而 JavaCV API 将用于视频帧处理,基于 DL4J 进行开发。

第七章,使用 LSTM 网络进行股价预测,展示了如何开发一个实际的股票开盘、收盘、最低、最高价格或交易量预测项目,使用 LSTM 在 DL4J 框架上进行训练。将使用来自实际股市数据集的时间序列来训练 LSTM 模型,并且模型仅预测 1 天后的股价。

第八章,云端分布式深度学习——使用卷积 LSTM 网络进行视频分类,展示了如何开发一个端到端项目,使用结合 CNN 和 LSTM 网络在 DL4J 上准确分类大量视频片段(例如 UCF101)。训练将在 Amazon EC2 GPU 计算集群上进行。最终,这个端到端项目可以作为从视频中进行人体活动识别的入门项目。

第九章,使用深度强化学习玩GridWorld 游戏,专注于设计一个由批评和奖励驱动的机器学习系统。接着,展示了如何使用 DL4J、RL4J 和神经网络 Q 学习开发一个 GridWorld 游戏,Q 函数由该网络担任。

第十章,使用因式分解机开发电影推荐系统,介绍了使用因式分解机开发一个样例项目,用于预测电影的评分和排名。接着,讨论了基于矩阵因式分解和协同过滤的推荐系统的理论背景,然后深入讲解基于 RankSys 库的因式分解机的项目实现。

第十一章,讨论、当前趋势与展望,总结了所有内容,讨论了已完成的项目以及一些抽象的收获。然后提供了一些改进建议。此外,还涵盖了其他现实生活中的深度学习项目的扩展指南。

为了最大限度地发挥本书的价值

所有示例都已使用 Deeplearning4j 和一些 Java 开源库实现。具体来说,以下 API/工具是必需的:

  • Java/JDK 版本 1.8

  • Spark 版本 2.3.0

  • Spark csv_2.11 版本 1.3.0

  • ND4j 后端版本为 nd4j-cuda-9.0-platform(用于 GPU),否则为 nd4j-native

  • ND4j 版本 >=1.0.0-alpha

  • DL4j 版本 >=1.0.0-alpha

  • Datavec 版本 >=1.0.0-alpha

  • Arbiter 版本 >=1.0.0-alpha

  • Logback 版本 1.2.3

  • JavaCV 平台版本 1.4.1

  • HTTP 客户端版本 4.3.5

  • Jfreechart 1.0.13

  • Jcodec 0.2.3

  • Eclipse Mars 或 Luna(最新版本)或 IntelliJ IDEA

  • Maven Eclipse 插件(2.9 或更高版本)

  • Eclipse 的 Maven 编译插件(2.3.2 或更高版本)

  • Eclipse 的 Maven assembly 插件(2.4.1 或更高版本)

关于操作系统:推荐使用 Linux 发行版(包括 Debian、Ubuntu、Fedora、RHEL、CentOS)。具体来说,例如,对于 Ubuntu,建议安装 14.04(LTS)64 位(或更高版本)的完整安装,或使用 VMWare player 12 或 Virtual box。你也可以在 Windows(XP/7/8/10)或 Mac OS X(10.4.7 及以上版本)上运行 Spark 作业。

关于硬件配置:需要一台配备 Core i5 处理器、约 100GB 磁盘空间和至少 16GB 内存的机器或服务器。此外,如果你希望在 GPU 上进行训练,还需要安装 Nvidia GPU 驱动程序,并配置 CUDA 和 CuDNN。如果要运行大型作业,需要足够的存储空间(具体取决于你处理的数据集大小),最好至少有 50GB 的空闲磁盘存储(用于独立作业和 SQL 数据仓库)。

下载示例代码文件

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

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

  1. 登录或注册 www.packtpub.com

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,并按照屏幕上的指示操作。

文件下载完成后,请确保使用最新版本的工具解压或提取文件夹:

  • 适用于 Windows 的 WinRAR/7-Zip

  • 适用于 Mac 的 Zipeg/iZip/UnRarX

  • 适用于 Linux 的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Java-Deep-Learning-Projects。如果代码有更新,将会在现有的 GitHub 仓库中进行更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,您可以在 github.com/PacktPublishing/ 上查看。快去看看吧!

下载彩色图片

我们还提供了一个包含本书中使用的屏幕截图/图表的彩色图片的 PDF 文件。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/JavaDeepLearningProjects_ColorImages.pdf

使用的约定

本书中使用了一些文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“然后,我解压并将每个 .csv 文件复制到一个名为 label 的文件夹中。”

代码块如下所示:

<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <java.version>1.8</java.version>
</properties>

当我们希望引起您对代码块中特定部分的注意时,相关行或项将以粗体显示:

<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <java.version>1.8</java.version>
</properties>

粗体:表示新术语、重要词汇或在屏幕上看到的单词。例如,菜单或对话框中的词汇会以这种方式出现在文本中。示例如下:“我们随后读取并处理图像,生成 PhotoID | 向量地图。”

警告或重要说明将以如下形式显示。

提示和技巧会以如下形式出现。

与我们联系

我们始终欢迎读者的反馈。

一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中注明书名。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 与我们联系。

勘误:虽然我们已尽一切努力确保内容的准确性,但仍可能会有错误。如果您在本书中发现错误,请向我们报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并输入详细信息。

盗版:如果您在互联网上发现任何形式的非法复制品,我们将非常感激您提供该位置地址或网站名称。请通过 copyright@packtpub.com 与我们联系,并附上链接。

如果您有兴趣成为作者:如果您在某个领域具有专长,并且有兴趣写作或参与编写一本书,请访问 authors.packtpub.com

评论

请留下您的评论。阅读并使用本书后,为什么不在您购买本书的网站上留下评论呢?潜在读者可以看到并参考您的公正意见来做出购买决策,我们 Packt 可以了解您对我们产品的看法,作者们也可以看到您对其书籍的反馈。谢谢!

欲了解有关 Packt 的更多信息,请访问 packtpub.com

第一章:深度学习入门

在本章中,我们将解释一些基础的机器学习ML)和深度学习(DL)概念,这些概念将在后续的所有章节中使用。我们将从简要介绍机器学习开始。接下来,我们将讲解深度学习,它是机器学习的一个新兴分支。

我们将简要讨论一些最著名和广泛使用的神经网络架构。接下来,我们将了解深度学习框架和库的各种特性。然后,我们将学习如何准备编程环境,在此基础上使用一些开源深度学习库,如DeepLearning4J (DL4J)进行编程。

然后我们将解决一个非常著名的机器学习问题:泰坦尼克号生存预测。为此,我们将使用基于 Apache Spark 的多层感知器MLP)分类器来解决这个问题。最后,我们将看到一些常见问题解答,帮助我们将深度学习的基本理解推广到更广泛的应用。简而言之,以下主题将被覆盖:

  • 机器学习的简单介绍

  • 人工神经网络(ANNs)

  • 深度神经网络架构

  • 深度学习框架

  • 从灾难中学习深度学习——使用 MLP 进行泰坦尼克号生存预测

  • 常见问题解答(FAQ)

机器学习的简单介绍

机器学习方法基于一组统计和数学算法,以执行诸如分类、回归分析、概念学习、预测建模、聚类和挖掘有用模式等任务。因此,通过使用机器学习,我们旨在改善学习体验,使其变得自动化。结果,我们可能不需要完全的人类互动,或者至少我们可以尽可能减少这种互动的程度。

机器学习算法的工作原理

我们现在引用 Tom M. Mitchell 的经典机器学习定义(《机器学习,Tom Mitchell,McGraw Hill》),他从计算机科学的角度解释了学习真正意味着什么:

“如果一个计算机程序在经验 E 的基础上,在某些任务类别 T 和性能度量 P 的衡量下,其在任务 T 上的表现通过经验 E 得到提升,那么我们就说该程序从经验中学习。”

根据这个定义,我们可以得出结论:计算机程序或机器可以执行以下任务:

  • 从数据和历史中学习

  • 通过经验提升

  • 迭代优化一个可以用于预测问题结果的模型

由于它们是预测分析的核心,几乎我们使用的每个机器学习算法都可以视为一个优化问题。这涉及到找到最小化目标函数的参数,例如,像成本函数和正则化这样的加权和。通常,一个目标函数有两个组成部分:

  • 一个正则化器,用来控制模型的复杂性

  • 损失,衡量模型在训练数据上的误差。

另一方面,正则化参数定义了在最小化训练误差与模型复杂度之间的权衡,以避免过拟合问题。如果这两个组件都是凸的,那么它们的和也是凸的;否则,它是非凸的。更详细地说,在使用机器学习(ML)算法时,目标是获取能够在预测时返回最小误差的函数的最佳超参数。因此,使用凸优化技术,我们可以最小化该函数,直到它收敛到最小误差。

由于问题是凸的,通常更容易分析算法的渐进行为,这展示了当模型观察到越来越多的训练数据时,它的收敛速度如何。机器学习的挑战在于使模型训练能够识别复杂模式,并且不仅能以自动化的方式做出决策,还能尽可能地智能地做出决策。整个学习过程需要输入数据集,这些数据集可以被拆分(或已经提供)为三种类型,具体如下:

  • 训练集是来自历史或实时数据的知识库,用于拟合机器学习算法的参数。在训练阶段,机器学习模型利用训练集来找到网络的最佳权重,并通过最小化训练误差来达到目标函数。在这里,使用反向传播规则(或其他更高级的优化器与适当的更新器;稍后会讨论)来训练模型,但所有超参数必须在学习过程开始之前设置

  • 验证集是一组用于调整机器学习模型参数的示例。它确保模型经过良好的训练,并能很好地泛化,从而避免过拟合。一些机器学习实践者也将其称为开发集dev 集

  • 测试集用于评估训练模型在未见过的数据上的表现。此步骤也称为模型推理。在对测试集中的最终模型进行评估后(即,当我们对模型的表现完全满意时),我们不需要进一步调整模型,训练好的模型可以部署到生产环境中。

一个常见的做法是将输入数据(在必要的预处理和特征工程后)拆分为 60%的训练数据,10%的验证数据和 20%的测试数据,但这实际上取决于具体使用案例。此外,有时我们需要根据数据集的可用性和质量对数据进行上采样或下采样。

此外,学习理论使用的是源自概率论和信息论的数学工具。将简要讨论三种学习范式:

  • 有监督学习

  • 无监督学习

  • 强化学习

以下图表总结了三种学习类型及其所解决的问题:

学习类型及相关问题

监督学习

监督学习是最简单且最著名的自动学习任务。它基于一组预定义的示例,其中每个输入所属的类别已经知道。图 2展示了监督学习的典型工作流程。

一位参与者(例如,机器学习实践者、数据科学家、数据工程师、机器学习工程师等)执行提取转换加载ETL)及必要的特征工程(包括特征提取、选择等),以获得具有特征和标签的适当数据。然后他执行以下操作:

  1. 将数据拆分为训练集、开发集和测试集

  2. 使用训练集训练机器学习模型

  3. 验证集用于验证训练是否过拟合以及正则化

  4. 然后,他在测试集上评估模型的表现(即未见过的数据)

  5. 如果性能不令人满意,他可以进行额外的调优,以通过超参数优化获得最佳模型

  6. 最后,他将最佳模型部署到生产环境中

监督学习的实际应用

在整个生命周期中,可能会有多个参与者参与(例如,数据工程师、数据科学家或机器学习工程师),他们独立或协作执行每个步骤。

监督学习的任务包括分类回归;分类用于预测数据点属于哪个类别(离散值),而回归用于预测连续值。换句话说,分类任务用于预测类属性的标签,而回归任务则用于对类属性进行数值预测。

在监督学习的背景下,不平衡数据指的是分类问题,其中不同类别的实例数量不平衡。例如,如果我们有一个仅针对两个类别的分类任务,平衡数据意味着每个类别都有 50%的预先分类示例。

如果输入数据集稍微不平衡(例如,某一类占 60%,另一类占 40%),学习过程将要求将输入数据集随机拆分为三个子集,其中 50%用于训练集,20%用于验证集,剩余 30%用于测试集。

无监督学习

无监督学习中,训练阶段将输入集提供给系统。与监督学习不同,输入对象没有被标记其类别。对于分类任务,我们假设给定一个正确标记的数据集。然而,在现实世界中收集数据时,我们不总是拥有这种优势。

例如,假设你在硬盘的一个拥挤且庞大的文件夹中有一大堆完全合法的、没有盗版的 MP3 文件。在这种情况下,如果我们无法直接访问它们的元数据,我们如何可能将歌曲归类呢?一种可能的方法是混合各种机器学习技术,但聚类往往是最好的解决方案。

那么,假设你能构建一个聚类预测模型,帮助自动将相似的歌曲分组,并将它们组织成你最喜欢的类别,如乡村说唱摇滚等。简而言之,无监督学习算法通常用于聚类问题。下图给我们展示了应用聚类技术解决此类问题的思路:

聚类技术 —— 一种无监督学习的例子

尽管数据点没有标签,但我们仍然可以进行必要的特征工程和对象分组,将属于同一组的对象(称为聚类)聚集在一起。这对于人类来说并不容易。标准方法是定义两个对象之间的相似度度量,然后寻找任何比其他聚类中的对象更相似的对象群集。一旦我们完成了数据点(即 MP3 文件)的聚类并完成验证,我们就能知道数据的模式(即,哪些类型的 MP3 文件属于哪个组)。

强化学习

强化学习是一种人工智能方法,专注于通过与环境的交互来学习系统。在强化学习中,系统的参数会根据从环境中获得的反馈进行调整,而环境又会对系统做出的决策提供反馈。下图展示了一个人在做决策,以便到达目的地。

让我们以你从家到工作地点的路线为例。在这种情况下,你每天都走相同的路线。然而,某天你突然好奇,决定尝试另一条路线,目的是寻找最短的路径。这个尝试新路线与坚持走最熟悉路线之间的两难困境,就是探索与利用的一个例子:

一个智能体始终尝试到达目的地

我们可以看一个更多的例子,假设有一个系统模拟一个棋手。为了提高其表现,系统利用先前动作的结果;这样的系统被称为强化学习系统。

将机器学习任务整合在一起

我们已经了解了机器学习算法的基本工作原理。接下来我们了解了基本的机器学习任务,以及它们如何形成特定领域的问题。现在让我们来看一下如何总结机器学习任务以及一些应用,如下图所示:

来自不同应用领域的机器学习任务及一些应用案例

然而,前面的图表只列出了使用不同机器学习任务的一些应用案例。在实践中,机器学习在许多应用场景中都有广泛应用。我们将在本书中尽量涵盖其中的一些案例。

深入了解深度学习

以往在常规数据分析中使用的简单机器学习方法已经不再有效,应该被更强大的机器学习方法所替代。尽管传统的机器学习技术允许研究人员识别相关变量的组或聚类,但随着大规模和高维数据集的增加,这些方法的准确性和有效性逐渐降低。

这里出现了深度学习,它是近年来人工智能领域最重要的进展之一。深度学习是机器学习的一个分支,基于一套算法,旨在尝试对数据中的高级抽象进行建模。

深度学习是如何将机器学习提升到一个新水平的?

简而言之,深度学习算法大多是一些人工神经网络(ANN),它们能够更好地表示大规模数据集,从而建立能够深入学习这些表示的模型。如今,这不仅仅局限于人工神经网络,实际上,理论上的进展以及软件和硬件的改进都是我们能够走到今天这一步的必要条件。在这方面,Ian Goodfellow 等人(《深度学习》,MIT 出版社,2016 年)将深度学习定义如下:

“深度学习是一种特定类型的机器学习,通过学习将世界表示为一个嵌套的概念层级结构,在这个层级中,每个概念都是相对于更简单的概念来定义的,更抽象的表示是通过较不抽象的表示来计算的,从而实现了巨大的能力和灵活性。”

让我们举个例子;假设我们想开发一个预测分析模型,比如一个动物识别器,在这种情况下,我们的系统需要解决两个问题:

  • 用于判断一张图片是猫还是狗

  • 用于将狗和猫的图片进行聚类。

如果我们使用典型的机器学习方法来解决第一个问题,我们必须定义面部特征(如耳朵、眼睛、胡须等),并编写一个方法来识别在分类特定动物时哪些特征(通常是非线性的)更为重要。

然而,与此同时,我们无法解决第二个问题,因为用于图像聚类的传统机器学习算法(例如k-means)无法处理非线性特征。深度学习算法将把这两个问题提升一个层次,最重要的特征将在确定哪些特征对分类或聚类最为重要后自动提取。

相比之下,当使用传统的机器学习算法时,我们必须手动提供这些特征。总结来说,深度学习的工作流程如下:

  • 深度学习算法首先会识别在聚类猫或狗时最相关的边缘。接着,它会尝试以层级方式找到各种形状和边缘的组合。这个步骤叫做 ETL(提取、转换、加载)。

  • 经过多次迭代后,复杂概念和特征的层级识别被执行。然后,基于已识别的特征,深度学习算法自动决定哪些特征在统计上对分类动物最为重要。这个步骤叫做特征提取。

  • 最后,算法去掉标签列,使用自编码器AEs)进行无监督训练,以提取潜在特征,再将这些特征分配给 k-means 进行聚类。

  • 然后,聚类分配硬化损失(CAH 损失)和重建损失被联合优化,以实现最优的聚类分配。深度嵌入聚类(详见arxiv.org/pdf/1511.06335.pdf)就是这种方法的一个例子。我们将在第十一章中讨论基于深度学习的聚类方法,讨论、当前趋势与展望

到目前为止,我们看到深度学习系统能够识别图像代表的是什么。计算机看图像的方式与我们不同,因为它只知道每个像素的位置和颜色。通过深度学习技术,图像被分解成多个分析层次。

在较低的层次上,软件分析例如一小块像素网格,任务是检测某种颜色或其不同的色调。如果它发现了什么,它会通知下一层,下一层会检查该颜色是否属于更大的形态,比如一条线。这个过程会一直持续到更高层次,直到你理解图像展示的内容。下图展示了我们在图像分类系统中讨论的内容:

在处理狗与猫分类问题时的深度学习系统工作原理

更准确地说,前述的图像分类器可以逐层构建,如下所示:

  • 第一层:算法开始识别原始图像中的暗像素和亮像素。

  • 第二层:算法接着识别边缘和形状。

  • 第三层:接下来,算法识别更复杂的形状和物体。

  • 第四层:算法接着学习哪些物体定义了人脸。

尽管这只是一个非常简单的分类器,但能够进行这些操作的软件如今已经非常普及,广泛应用于面部识别系统,或例如在 Google 中通过图像进行搜索的系统。这些软件是基于深度学习算法的。

相反,通过使用线性机器学习(ML)算法,我们无法构建这样的应用程序,因为这些算法无法处理非线性的图像特征。而且,使用机器学习方法时,我们通常只处理少数几个超参数。然而,当神经网络进入这个领域时,事情变得复杂了。在每一层中,都有数百万甚至数十亿个超参数需要调整,以至于成本函数变得非凸。

另一个原因是隐藏层中使用的激活函数是非线性的,因此成本是非凸的。我们将在后续章节中更详细地讨论这一现象,但先快速了解一下人工神经网络(ANNs)。

人工神经网络(ANNs)

人工神经网络(ANNs)基于深度学习的概念。它们通过多个神经元之间的相互通信,代表了人类神经系统的工作方式,这些神经元通过轴突相互联系。

生物神经元

人工神经网络(ANNs)的工作原理受到人脑工作的启发,如图 7所示。受体接收来自内部或外部世界的刺激;然后将信息传递给生物神经元进行进一步处理。除了另一个长的延伸部分称为轴突外,还有许多树突。

在其末端,有一些微小的结构称为突触末端,用于将一个神经元连接到其他神经元的树突。生物神经元接收来自其他神经元的短电流冲动,称为信号,并以此触发自己的信号:

生物神经元的工作原理

我们可以总结出,神经元由一个细胞体(也称为胞体)、一个或多个树突用于接收来自其他神经元的信号,以及一个轴突用于传递神经元产生的信号。

当神经元向其他神经元发送信号时,它处于激活状态。然而,当它接收来自其他神经元的信号时,它处于非激活状态。在空闲状态下,神经元会积累所有接收到的信号,直到达到一定的激活阈值。这一现象促使研究人员提出了人工神经网络(ANN)。

人工神经网络(ANNs)的简史

受到生物神经元工作原理的启发,沃伦·麦卡洛克和沃尔特·皮茨于 1943 年提出了第一个人工神经元模型,作为神经活动的计算模型。这个简单的生物神经元模型,也称为人工神经元(AN),有一个或多个二进制(开/关)输入,只有一个输出。

一个人工神经元只有在它的输入中有超过一定数量的输入处于活动状态时,才会激活它的输出。例如,在这里我们看到几个执行各种逻辑运算的人工神经网络(ANNs)。在这个例子中,我们假设一个神经元只有在至少有两个输入处于活动状态时才会被激活:

执行简单逻辑计算的人工神经网络(ANNs)

这个例子听起来过于简单,但即使是如此简化的模型,也能构建一个人工神经网络。然而,这些网络也可以组合在一起计算复杂的逻辑表达式。这个简化模型启发了约翰·冯·诺依曼、马文·明斯基、弗兰克·罗森布拉特等人在 1957 年提出了另一个模型——感知器

感知器是过去 60 年里我们见过的最简单的人工神经网络架构之一。它基于一种略有不同的人工神经元——线性阈值单元LTU)。唯一的区别是输入和输出现在是数字,而不是二进制的开/关值。每个输入连接都与一个权重相关联。LTU 计算其输入的加权和,然后对该和应用一个阶跃函数(类似于激活函数的作用),并输出结果:

左图表示一个线性阈值单元(LTU),右图显示一个感知器

感知器的一个缺点是其决策边界是线性的。因此,它们无法学习复杂的模式。它们也无法解决一些简单的问题,比如异或XOR)。然而,后来通过堆叠多个感知器(称为 MLP),感知器的局限性在某种程度上得到了消除。

人工神经网络是如何学习的?

基于生物神经元的概念,人工神经元的术语和思想应运而生。与生物神经元类似,人工神经元由以下部分组成:

  • 一个或多个输入连接,用于从神经元聚合信号

  • 一个或多个输出连接,用于将信号传递到其他神经元

  • 一个激活函数,用于确定输出信号的数值

神经网络的学习过程被配置为迭代过程,即对权重优化(更多内容见下一节)。在每一轮训练中,权重都会被更新。一旦训练开始,目标是通过最小化损失函数来生成预测。然后,网络的性能将在测试集上进行评估。

现在我们知道了人工神经元的简单概念。然而,单单生成一些人工信号并不足以学习复杂的任务。尽管如此,一个常用的监督学习算法是反向传播算法,它被广泛用于训练复杂的人工神经网络。

人工神经网络和反向传播算法

反向传播算法旨在最小化当前输出与期望输出之间的误差。由于网络是前馈型的,激活流总是从输入单元向输出单元前进。

成本函数的梯度会反向传播,网络权重会被更新;该方法可以递归地应用于任何数量的隐藏层。在这种方法中,两种阶段之间的结合是非常重要的。简而言之,训练过程的基本步骤如下:

  1. 用一些随机(或更先进的 XAVIER)权重初始化网络

  2. 对于所有训练样本,按照接下来的步骤执行前向和后向传播

前向和后向传播

在前向传播中,会执行一系列操作来获得一些预测或评分。在这个操作中,创建一个图,将所有依赖操作按从上到下的方式连接起来。然后计算网络的误差,即预测输出与实际输出之间的差异。

另一方面,反向传播主要涉及数学运算,比如为所有微分操作(即自动微分方法)创建导数,从上到下(例如,测量损失函数以更新网络权重),对图中的所有操作进行处理,然后应用链式法则。

在这个过程中,对于所有层,从输出层到输入层,展示了网络层的输出与正确的输入(误差函数)。然后,调整当前层的权重以最小化误差函数。这是反向传播的优化步骤。顺便说一下,自动微分方法有两种类型:

  1. 反向模式:关于所有输入的单个输出的导数

  2. 前向模式:关于一个输入的所有输出的导数

反向传播算法以这样的方式处理信息,使得网络在学习迭代过程中减少全局误差;然而,这并不保证能够到达全局最小值。隐藏单元的存在和输出函数的非线性意味着误差的行为非常复杂,具有许多局部最小值。

这个反向传播步骤通常会执行成千上万次,使用许多训练批次,直到模型参数收敛到能最小化代价函数的值。当验证集上的误差开始增大时,训练过程结束,因为这可能标志着过拟合阶段的开始。

权重和偏置

除了神经元的状态外,还考虑突触权重,它影响网络中的连接。每个权重都有一个数值,表示为 W[ij],它是连接神经元 i 和神经元 j 的突触权重。

突触权重:这个概念源自生物学,指的是两个节点之间连接的强度或幅度,在生物学中对应于一个神经元的激活对另一个神经元的影响程度。

对于每个神经元(也叫单元) i,可以定义一个输入向量 x[i] = (x[1], x[2], ... x[n]),并且可以定义一个权重向量 w[i] = (w[i1], w[i2], ... w[in])。现在,根据神经元的位置,权重和输出函数决定了单个神经元的行为。然后,在前向传播过程中,隐藏层中的每个单元都会接收到以下信号:

然而,在权重中,还有一种特殊类型的权重叫做偏置单元b。从技术上讲,偏置单元并不与任何前一层连接,因此它没有真正的活动。但偏置b的值仍然可以让神经网络将激活函数向左或向右平移。现在,考虑到偏置单元后,修改后的网络输出可以表示如下:

上述方程表示,每个隐藏单元都会得到输入的总和乘以相应的权重——求和节点。然后,求和节点的结果通过激活函数,激活函数会压缩输出,如下图所示:

人工神经元模型

现在,有一个棘手的问题:我们该如何初始化权重?如果我们将所有的权重初始化为相同的值(例如 0 或 1),每个隐藏神经元都会得到完全相同的信号。让我们试着分析一下:

  • 如果所有的权重都初始化为 1,那么每个单元将收到等于输入总和的信号

  • 如果所有的权重都为 0,那么情况就更糟了,隐藏层中的每个神经元都会收到零信号

对于网络权重初始化,目前广泛使用 Xavier 初始化方法。它类似于随机初始化,但通常效果更好,因为它可以根据输入和输出神经元的数量自动确定初始化的规模。

有兴趣的读者可以参考这篇出版物以获取详细信息:Xavier Glorot 和 Yoshua Bengio,理解训练深度前馈神经网络的难度:2010 年第 13 届国际人工智能与统计学会议(AISTATS)论文集,地点:意大利撒丁岛 Chia Laguna 度假村;JMLR 卷 9:W&CP。

你可能会想,是否可以在训练普通的 DNN(例如 MLP 或 DBN)时摆脱随机初始化。最近,一些研究人员提到过随机正交矩阵初始化,这种方法比任何随机初始化更适合用于训练 DNN。

在初始化偏置时,我们可以将其初始化为零。但将所有偏置设置为一个小的常数值,例如 0.01,可以确保所有修正线性单元ReLU)单元能够传播一些梯度。然而,这种方法并不表现良好,也没有展现出一致的改善。因此,推荐将其保持为零。

权重优化

在训练开始之前,网络的参数是随机设置的。然后,为了优化网络权重,使用一种叫做梯度下降法GD)的迭代算法。通过 GD 优化,我们的网络根据训练集计算代价梯度。然后,通过迭代过程,计算误差函数E的梯度G

在下图中,误差函数E的梯度G提供了当前值的误差函数最陡坡度的方向。由于最终目标是减少网络误差,梯度下降法沿着相反方向-G前进,采取小步走。这一迭代过程执行多次,使得误差E逐渐下降,向全局最小值*移动。这样,最终目标是达到G = 0的点,表示无法再进行优化:

在搜索误差函数 E 的最小值时,我们朝着误差函数 E 的梯度 G 最小的方向移动

缺点是收敛速度太慢,导致无法满足处理大规模训练数据的需求。因此,提出了一种更快的梯度下降法,称为随机梯度下降法SGD),这也是深度神经网络(DNN)训练中广泛使用的优化器。在 SGD 中,每次迭代我们只使用来自训练集的一个训练样本来更新网络参数。

我并不是说 SGD 是唯一可用的优化算法,但现在有很多先进的优化器可供选择,例如 Adam、RMSProp、ADAGrad、Momentum 等。或多或少,它们大多数都是 SGD 的直接或间接优化版本。

顺便说一下,术语随机源于基于每次迭代中单个训练样本的梯度是对真实代价梯度的随机近似。

激活函数

为了让神经网络学习复杂的决策边界,我们在某些层上应用非线性激活函数。常用的函数包括 Tanh、ReLU、softmax 及其变种。从技术上讲,每个神经元接收的输入信号是与其连接的神经元的突触权重和激活值的加权和。为了实现这个目的,最广泛使用的函数之一是所谓的Sigmoid 函数。它是逻辑函数的一个特例,定义如下公式:

该函数的定义域包括所有实数,值域为(0, 1)。这意味着神经元输出的任何值(根据其激活状态的计算)都将始终在零和一之间。如下图所示,Sigmoid 函数提供了对神经元饱和度的解释,从不激活(= 0)到完全饱和,发生在预定的最大值(= 1)时。

另一方面,双曲正切(tanh)是另一种激活函数。Tanh 将实值数字压缩到范围[-1, 1]内。特别地,数学上,tanh 激活函数可以表示为以下公式:

上述公式可以通过以下图形表示:

Sigmoid 与 tanh 激活函数

通常,在前馈神经网络(FFNN)的最后一层,会应用 softmax 函数作为决策边界。这是一个常见的情况,尤其是在解决分类问题时。在概率论中,softmax 函数的输出会被压缩为 K 种不同可能结果的概率分布。然而,softmax 函数也被用于多类别分类方法中,使得网络的输出在各类别之间分布(即,类别的概率分布),并且具有 -1101 之间的动态范围。

对于回归问题,我们不需要使用任何激活函数,因为网络生成的是连续值——概率。然而,近年来我看到一些人开始在回归问题中使用恒等(IDENTITY)激活函数。我们将在后续章节中讨论这一点。

总结来说,选择合适的激活函数和网络权重初始化是两个决定网络性能的重要问题,能帮助获得良好的训练效果。我们将在后续章节中进一步讨论,探讨如何选择合适的激活函数。

神经网络架构

神经网络有多种架构类型。我们可以将深度学习架构分为四大类:深度神经网络(DNN)卷积神经网络(CNN)递归神经网络(RNN)涌现架构(EA)

如今,基于这些架构,研究人员提出了许多针对特定领域的变种,以应对不同的研究问题。接下来的章节将简要介绍这些架构,更多的详细分析和应用实例将在本书的后续章节中讨论。

深度神经网络

DNN(深度神经网络)是一种具有复杂和更深层次架构的神经网络,每层包含大量的神经元,并且连接众多。每一层的计算会将后续层的表示转化为更抽象的表示。然而,我们将使用“DNN”这一术语专指多层感知机(MLP)、堆叠自编码器(SAE)深度信念网络(DBN)

SAE 和 DBN 使用 AE 和受限玻尔兹曼机(RBM)作为架构的构建模块。这些与 MLP 的主要区别在于,训练过程分为两个阶段:无监督预训练和有监督微调。

SAE 和 DBN 分别使用 AE 和 RBM

在无监督预训练中,如前图所示,各层被按顺序堆叠,并以逐层的方式进行训练,类似于使用无标记数据的 AE 或 RBM。之后,在有监督的微调过程中,堆叠一个输出分类层,并通过使用标记数据重新训练整个神经网络来进行优化。

多层感知机

如前所述,单一感知器甚至无法逼近 XOR 函数。为了克服这一限制,将多个感知器堆叠在一起形成 MLP,其中各层作为有向图连接。通过这种方式,信号沿着一个方向传播,从输入层到隐藏层再到输出层,如下图所示:

一个具有输入层、两个隐藏层和输出层的 MLP 架构

从根本上讲,MLP 是最简单的前馈神经网络(FFNN),至少有三层:输入层、隐藏层和输出层。MLP 最早在 1980 年代使用反向传播算法进行训练。

深度信念网络

为了克服 MLP 中的过拟合问题,Hinton 等人提出了 DBN。它使用贪心的逐层预训练算法,通过概率生成模型初始化网络权重。

DBN 由一个可见层和多个层—隐藏单元组成。顶部两层之间有无向对称连接,形成关联记忆,而较低层则从前一层接收自上而下的有向连接。DBN 的构建模块是 RBM,如下图所示,多个 RBM 一个接一个地堆叠在一起形成 DBN:

用于半监督学习的 DBN 配置

单个 RBM 由两层组成。第一层由可见神经元组成,第二层由隐藏神经元组成。图 16展示了一个简单 RBM 的结构,其中神经元按照对称的二分图排列:

RBM 架构

在 DBN 中,首先用输入数据训练 RBM,这被称为无监督预训练,隐藏层代表了通过称为监督微调的贪心学习方法学习到的特征。尽管 DBN 取得了众多成功,但它们正被 AE 所取代。

自动编码器

AE 是一个具有三层或更多层的网络,其中输入层和输出层的神经元数量相同,而中间的隐藏层神经元数量较少。网络被训练以在输出端重现每个输入数据的相同活动模式。

AE 的常见应用包括数据去噪和用于数据可视化的降维。下图展示了 AE 的典型工作原理。它通过两个阶段重建接收到的输入:编码阶段,通常对应于原始输入的降维;解码阶段,能够从编码(压缩)表示中重建原始输入:

自动编码器(AE)的编码和解码阶段

卷积神经网络

CNN 已在计算机视觉(例如,图像识别)领域取得了巨大成功,并被广泛采用。在 CNN 网络中,定义卷积层(conv)的连接方案与 MLP 或 DBN 有显著不同。

重要的是,DNN 对像素如何组织没有先验知识;它并不知道邻近的像素是接近的。CNN 的架构嵌入了这种先验知识。低层通常识别图像中小区域的特征,而高层则将低级特征组合成更大的特征。这在大多数自然图像中效果良好,使得 CNN 在 DNN 之前占据了决定性优势:

常规 DNN 与 CNN

仔细观察前面的图示;左侧是一个常规的三层神经网络,右侧是一个将神经元以三维(宽度、高度和深度)排列的 CNN。在 CNN 架构中,几个卷积层以级联方式连接,每一层后面跟着一个 ReLU 层,然后是池化层,再接几个卷积层(+ReLU),再接另一个池化层,依此类推。

每个卷积层的输出是一组由单个内核滤波器生成的特征图。这些特征图随后可以作为下一层的新输入。CNN 网络中的每个神经元都会产生一个输出,并跟随一个激活阈值,该阈值与输入成正比且没有限制。这种类型的层称为卷积层。以下图示为用于面部识别的 CNN 架构示意图:

用于面部识别的 CNN 架构示意图

递归神经网络

递归神经网络RNN)是一类人工神经网络ANN),其中单元之间的连接形成有向循环。RNN 架构最初由 Hochreiter 和 Schmidhuber 于 1997 年提出。RNN 架构有标准的 MLP,并且加上了循环(如下面的图所示),因此它们能够利用 MLP 强大的非线性映射能力;并且具有某种形式的记忆:

RNN 架构

上面的图像展示了一个非常基础的 RNN,包含输入层、两个递归层和一个输出层。然而,这个基础的 RNN 存在梯度消失和爆炸问题,无法建模长期依赖性。因此,设计了更先进的架构,利用输入数据的顺序信息,并在各个构建模块(如感知器)之间使用循环连接。这些架构包括长短期记忆网络LSTM)、门控递归单元GRUs)、双向 LSTM等变体。

因此,LSTM 和 GR 可以克服常规 RNN 的缺点:梯度消失/爆炸问题以及长期短期依赖问题。我们将在第二章中详细讨论这些架构。

新兴架构

许多其他新兴的深度学习架构已经被提出,例如 深度时空神经网络 (DST-NNs)、多维递归神经网络 (MD-RNNs),以及 卷积自编码器 (CAEs)。

然而,还有一些新兴的网络,如 CapsNets(CNN 的改进版本,旨在消除常规 CNN 的缺点)、用于图像识别的 RNN,以及用于简单图像生成的 生成对抗网络 (GANs)。除了这些,个性化的因式分解机和深度强化学习也被广泛应用。

残差神经网络

由于有时涉及数百万甚至数十亿个超参数和其他实际因素,训练更深的神经网络非常困难。为了解决这个问题,Kaiming He 等人(见 arxiv.org/abs/1512.03385v1)提出了一种残差学习框架,简化了训练比以前更深的网络。

他们还明确地将层次结构重新定义为学习参考层输入的残差函数,而不是学习无参考的函数。通过这种方式,这些残差网络更容易优化,并且可以从显著增加的深度中获得更高的准确性。

不利的一面是,简单堆叠残差块来构建网络不可避免地会限制其优化能力。为了克服这一局限性,Ke Zhang 等人还提出了使用多层次残差网络(arxiv.org/abs/1608.02908)。

生成对抗网络

GANs 是深度神经网络架构,由两个相互对抗的网络组成(因此得名“对抗”)。Ian Goodfellow 等人在一篇论文中介绍了 GANs(详情见 arxiv.org/abs/1406.2661v1)。在 GANs 中,两个主要组件是 生成器判别器

生成对抗网络(GANs)的工作原理

生成器将尝试从特定的概率分布中生成数据样本,这些样本与实际对象非常相似。判别器则会判断其输入是来自原始训练集还是来自生成器部分。

胶囊网络

CNNs 在图像分类方面表现优异。然而,如果图像有旋转、倾斜或其他不同的方向,CNNs 的表现会相对较差。即使是 CNN 中的池化操作,也无法在位置不变性方面提供太多帮助。

CNN 中的这一问题促使了 CapsNet 的最新进展,相关论文为 胶囊之间的动态路由(详情见 arxiv.org/abs/1710.09829),由 Geoffrey Hinton 等人提出。

与常规的 DNN 不同,在 CapsNets 中,核心思想是将更多的层添加到单一层内部。这样,CapsNet 就是一个嵌套的神经网络层集合。我们将在 第十一章 中详细讨论,讨论、当前趋势与展望

深度学习框架和云平台

在本节中,我们将介绍一些最流行的深度学习框架。然后,我们将讨论一些可以部署/运行深度学习应用程序的云平台。简而言之,几乎所有的库都提供了使用图形处理器加速学习过程的可能性,都是开源发布的,并且是大学研究小组的成果。

深度学习框架

TensorFlow 是一个数学软件,也是一个用于机器智能的开源软件库。由 Google Brain 团队于 2011 年开发,并在 2015 年开源。TensorFlow 最新版本(本书写作时为 v1.8)提供的主要功能包括更快的计算速度、灵活性、可移植性、易于调试、统一的 API、透明的 GPU 计算支持、易于使用和可扩展性。一旦你构建了神经网络模型,并进行了必要的特征工程后,你可以通过绘图或 TensorBoard 轻松地进行交互式训练。

Keras 是一个深度学习库,位于 TensorFlow 和 Theano 之上,提供了一个直观的 API,灵感来源于 Torch。它可能是现存最好的 Python API。DeepLearning4J 将 Keras 作为其 Python API,并通过 Keras 从 Theano 和 TensorFlow 导入模型。

Theano 也是一个用 Python 编写的深度学习框架。它允许使用 GPU,速度是单个 CPU 的 24 倍。在 Theano 中,定义、优化和评估复杂的数学表达式非常直接。

Neon 是一个基于 Python 的深度学习框架,由 Nirvana 开发。Neon 的语法类似于 Theano 的高级框架(例如 Keras)。目前,Neon 被认为是基于 GPU 实现的最快工具,尤其适用于 CNN。但其基于 CPU 的实现相比大多数其他库较为逊色。

PyTorch 是一个庞大的机器学习生态系统,提供大量的算法和功能,包括深度学习和处理各种类型的多媒体数据,特别专注于并行计算。Torch 是一个高度可移植的框架,支持多种平台,包括 Windows、macOS、Linux 和 Android。

Caffe伯克利视觉与学习中心BVLC)主要开发,是一个因其表达性、速度和模块化而突出的框架。

MXNet *(mxnet.io/)是一个支持多种语言的深度学习框架,诸如 R、Python、C++ 和 Julia。这个特点很有帮助,因为如果你掌握了这些语言中的任何一种,你将无需走出舒适区就能训练你的深度学习模型。它的后端使用 C++ 和 CUDA 编写,能够像 Theano 一样管理自己的内存。

Microsoft Cognitive ToolkitCNTK)是微软研究院推出的统一深度学习工具包,旨在简化多 GPU 和服务器上的流行模型类型训练与组合。CNTK 实现了高效的 CNN 和 RNN 训练,适用于语音、图像和文本数据。它支持 cuDNN v5.1 进行 GPU 加速。

DeepLearning4J 是首批为 Java 和 Scala 编写的商业级开源分布式深度学习库之一。它还提供了对 Hadoop 和 Spark 的集成支持。DeepLearning4J 旨在用于分布式 GPU 和 CPU 环境中的企业应用。

DeepLearning4J 旨在成为前沿技术且即插即用,更注重约定而非配置,这使得非研究人员能够快速原型开发。以下库可以与 DeepLearning4J 集成,无论你是用 Java 还是 Scala 开发机器学习应用,都将让你的 JVM 使用体验更加便捷。

ND4J 就像是 JVM 上的 NumPy,提供了诸如矩阵创建、加法和乘法等线性代数的基本操作。而 ND4S 是一个用于线性代数和矩阵操作的科学计算库,支持 JVM 语言中的 n 维数组。

总结如下图所示,展示了过去一年来关于不同深度学习框架的 Google 趋势:

不同深度学习框架的趋势。TensorFlow 和 Keras 是最具主导性的框架,而 Theano 的人气正在下降。另一方面,DeepLearning4J 正在成为 JVM 上的新兴选择。

基于云平台的深度学习

除了前述的库之外,最近在云端也有一些深度学习的倡议。其理念是将深度学习能力引入到拥有数百万、数十亿数据点和高维数据的大数据中。例如,Amazon Web ServicesAWS)、Microsoft Azure、Google Cloud Platform 和 NVIDIA GPU CloudNGC)都提供原生于其公共云平台的机器学习和深度学习服务。

2017 年 10 月,AWS 发布了针对 Amazon Elastic Compute CloudEC2)P3 实例的深度学习 Amazon Machine ImagesAMIs)。这些 AMI 预装了深度学习框架,如 TensorFlow、Gluon 和 Apache MXNet,且已针对 Amazon EC2 P3 实例内的 NVIDIA Volta V100 GPU 进行了优化。

微软认知工具包是 Azure 的开源深度学习服务。与 AWS 提供的服务类似,它专注于可以帮助开发者构建和部署深度学习应用程序的工具。

另一方面,NGC 为 AI 科学家和研究人员提供了 GPU 加速的容器(见www.nvidia.com/en-us/data-center/gpu-cloud-computing/)。NGC 提供了如 TensorFlow、PyTorch、MXNet 等容器化的深度学习框架,这些框架经过 NVIDIA 的调优、测试和认证,可以在最新的 NVIDIA GPU 上运行。

现在我们对可用的深度学习库、框架和云平台有了基本的了解,能够运行和部署我们的深度学习应用程序,我们可以开始编写代码了。首先,我们将通过解决著名的泰坦尼克号生存预测问题来入手。不过,我们不会使用之前列出的框架;我们将使用 Apache Spark ML 库。由于我们将结合其他深度学习库使用 Spark,了解一点 Spark 知识会帮助我们在接下来的章节中更好地掌握相关内容。

深度学习与灾难——泰坦尼克号生存预测

在本节中,我们将解决 Kaggle 上的著名泰坦尼克号生存预测问题(见www.kaggle.com/c/titanic/data)。任务是使用机器学习算法完成对哪些人群可能生还的分析。

问题描述

在开始编写代码之前,让我们先看看问题的简短描述。以下段落直接引用自 Kaggle 泰坦尼克号生存预测页面:

“RMS 泰坦尼克号沉没事件是历史上最臭名昭著的海难之一。1912 年 4 月 15 日,泰坦尼克号在她的处女航中与冰山相撞沉没,造成 2224 名乘客和船员中 1502 人丧生。这场震惊国际社会的悲剧促使各国对船舶安全规定进行了改进。沉船导致如此大量的生命损失,其中一个原因是乘客和船员没有足够的救生艇。虽然幸存者在沉船过程中有一些运气成分,但某些群体比其他群体更有可能幸存,比如女性、儿童和上层阶级。在这个挑战中,我们要求你完成对哪些人群更可能生还的分析。特别是,我们要求你运用机器学习工具来预测哪些乘客在这场灾难中幸存下来。”

现在,在深入之前,我们需要了解泰坦尼克号灾难中乘客的数据,以便我们可以开发出可用于生存分析的预测模型。数据集可以从github.com/rezacsedu/TitanicSurvivalPredictionDataset下载。数据集中有两个.csv文件:

  • 训练集 (train.csv): 可用于构建你的机器学习模型。此文件还包括每位乘客的标签,作为训练集的真实标签

  • 测试集 (test.csv): 可以用来查看你的模型在未见数据上的表现。然而,对于测试集,我们没有为每个乘客提供实际结果。

简而言之,对于测试集中的每个乘客,我们必须使用训练好的模型预测他们是否能在泰坦尼克号沉没中幸存。表 1 显示了训练集的元数据:

Variable 定义

| survival | 两个标签:

  • 0 = 否

  • 1 = 是

|

pclass 这是乘客的社会经济地位SES)的代理,分为上层、中层和下层。具体来说,1 = 1^(st), 2 = 2^(nd), 3 = 3^(rd)
sex 男性或女性。
Age 年龄(单位:年)。

| sibsp | 这表示家庭关系,如下所示:

  • Sibling = 兄弟,姐妹,继兄,继姐

  • Spouse = 丈夫,妻子(情妇和未婚夫未考虑在内)

|

| parch | 在数据集中,家庭关系定义如下:

  • Parent = 母亲,父亲

  • Child = 女儿,儿子,继女,继子

有些孩子是单独和保姆一起旅行的,因此对于他们来说,parch=0

ticket 票号。
fare 乘客票价。
cabin 舱号。

| embarked | 三个港口:

  • C = 瑟堡

  • Q = 皇后镇

  • S = 南安普顿

|

现在问题是:使用这些标注数据,我们能否得出一些直接的结论?比如说,女性、头等舱以及儿童是能够提高乘客在这场灾难中幸存几率的因素。

为了解决这个问题,我们可以从基本的 MLP 开始,MLP 是最古老的深度学习算法之一。为此,我们使用基于 Spark 的 MultilayerPerceptronClassifier。此时,你可能会想,既然 Spark 不是深度学习库,为什么我要讲 Spark?不过,Spark 有一个 MLP 实现,这足以满足我们的目标。

接下来的章节中,我们将逐步开始使用更强大的 DNN,通过使用 DeepLearning4J,一个基于 JVM 的深度学习应用开发框架。所以我们来看看如何配置我们的 Spark 环境。

配置编程环境

我假设你的机器上已经安装了 Java,且 JAVA_HOME 也已设置。还假设你的 IDE 安装了 Maven 插件。如果是这样,那么只需创建一个 Maven 项目,并按照以下方式添加项目属性:

<properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
        <jdk.version>1.8</jdk.version>
        <spark.version>2.3.0</spark.version>
</properties>

在前面的标签中,我指定了 Spark(即 2.3.0),但你可以进行调整。然后在 pom.xml 文件中添加以下依赖项:

<dependencies>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-core_2.11</artifactId>
            <version>${spark.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-sql_2.11</artifactId>
            <version>${spark.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-mllib_2.11</artifactId>
            <version>${spark.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-graphx_2.11</artifactId>
            <version>${spark.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-yarn_2.11</artifactId>
            <version>${spark.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-network-shuffle_2.11</artifactId>
            <version>${spark.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-streaming-flume_2.11</artifactId>
            <version>${spark.version}</version>
        </dependency>
        <dependency>
            <groupId>com.databricks</groupId>
            <artifactId>spark-csv_2.11</artifactId>
            <version>1.3.0</version>
        </dependency>
</dependencies>

然后如果一切顺利,所有 JAR 文件都会作为 Maven 依赖下载到项目目录下。好了!接下来我们可以开始编写代码了。

特征工程和输入数据集准备

在本小节中,我们将看到一些基本的特征工程和数据集准备,它们可以输入到 MLP 分类器中。那么让我们从创建 SparkSession 开始,它是访问 Spark 的门户:

SparkSession spark = SparkSession
                     .*builder*()
                     .master("local[*]")
                     .config("spark.sql.warehouse.dir", "/tmp/spark")
                     .appName("SurvivalPredictionMLP")
                     .getOrCreate();

然后让我们读取训练集并看一看它的概况:

Dataset<Row> df = spark.sqlContext()
                .read()
                .format("com.databricks.spark.csv")
                .option("header", "true")
                .option("inferSchema", "true")
                .load("data/train.csv");
df.show();

数据集的快照如下所示:

泰坦尼克号生存数据集快照

现在我们可以看到,训练集同时包含了分类特征和数值特征。此外,一些特征并不重要,例如PassengerIDTicket等。同样,Name特征也不重要,除非我们手动基于标题创建一些特征。然而,我们还是保持简单。不过,一些列包含了空值,因此需要大量的考虑和清理。

我忽略了PassengerIdNameTicket列。除此之外,Sex列是分类变量,因此我根据malefemale对乘客进行了编码。然后,Embarked列也被编码了。我们可以将S编码为0C编码为1Q编码为2

对于这一点,我们也可以编写名为normSexnormEmbarked的用户定义函数(UDF),分别对应SexEmbarked。让我们看看它们的签名:

private static UDF1<String,Option<Integer>> *normEmbarked*=(String d) -> {
        if (null == d)
            return Option.*apply*(null);
        else {
            if (d.equals("S"))
                return Some.apply(0);
            else if (d.equals("C"))
                return Some.apply(1);
            else
                return Some.apply(2);
        }
    };

因此,这个 UDF 接收一个String类型,并将其编码为整数。normSex UDF 的工作方式也类似:

private static UDF1<String, Option<Integer>> normSex = (String d) -> {
      if (null == d)
          return Option.apply(null);
      else {
        if (d.equals("male"))
            return Some.apply(0);
        else
            return Some.apply(1);
      }
    };

所以我们现在可以只选择有用的列,但对于SexEmbarked列,我们需要使用上述 UDF:

Dataset<Row> projection = df.select(
                col("Survived"),
                col("Fare"),
                callUDF("normSex", col("Sex")).alias("Sex"),
                col("Age"),
                col("Pclass"),
                col("Parch"),
                col("SibSp"),
                 callUDF("normEmbarked",
                col("Embarked")).alias("Embarked"));
projectin.show();

现在我们已经能够将一个分类列转换为数值型;然而,正如我们所看到的,仍然存在空值。那么,我们该怎么办呢?我们可以选择直接删除null值,或者使用一些null填补技术,用该列的均值进行填充。我认为第二种方法更好。

现在,再次针对这个空值填补,我们也可以编写用户定义函数(UDF)。不过,为此我们需要了解一些关于数值列的统计信息。不幸的是,我们不能对 DataFrame 执行汇总统计。因此,我们必须将 DataFrame 转换为JavaRDD<Vector>。另外,我们在计算时也忽略了null值:

JavaRDD<Vector> statsDf =projection.rdd().toJavaRDD().map(row -> Vectors.*dense*( row.<Double>getAs("Fare"),
               row.isNullAt(3) ? 0d : row.Double>getAs("Age")
                  ));

现在,让我们计算多变量统计summarysummary统计将进一步用于计算这两个特征对应的缺失值的meanAgemeanFare

MultivariateStatisticalSummary summary = Statistics.*colStats*(statsRDD.rdd());
double meanFare = summary.mean().apply(0);
double meanAge = summary.mean().apply(1); 

现在,让我们为AgeFare列创建两个 UDF 来进行空值填补:

UDF1<String, Option<Double>> normFare = (String d) -> {
            if (null == d) {
                return Some.apply(meanFare);
            }
            else
                return Some.apply(Double.parseDouble(d));
        };

因此,我们定义了一个 UDF,如果数据没有条目,它会填充meanFare值。现在,让我们为Age列创建另一个 UDF:

UDF1<String, Option<Double>> normAge = (String d) -> {
          if (null == d)
              return Some.apply(meanAge);
          else
              return Some.apply(Double.parseDouble(d));
        };

现在我们需要按照如下方式注册 UDF:

spark.sqlContext().udf().register("normFare", normFare, DataTypes.DoubleType);
spark.sqlContext().udf().register("normAge", normAge, DataTypes.DoubleType);

因此,让我们应用前面的 UDF 进行null填补:

Dataset<Row> finalDF = projection.select(
                *col*("Survived"),
                *callUDF*("normFare",
                *col*("Fare").cast("string")).alias("Fare"),
                *col*("Sex"),
                *callUDF*("normAge",
                *col*("Age").cast("string")).alias("Age"),
                *col*("Pclass"),
                *col*("Parch"),
                *col*("SibSp"),
                *col*("Embarked"));
finalDF.show();

太棒了!我们现在可以看到,null值已经被AgeFare列的均值所替代。然而,数值仍然没有经过缩放。因此,最好对它们进行缩放。但是,为此,我们需要计算均值和方差,然后将它们存储为模型,以便以后进行缩放:

Vector stddev = Vectors.dense(Math.sqrt(summary.variance().apply(0)), Math.sqrt(summary.variance().apply(1)));

Vector mean = Vectors.dense(summary.mean().apply(0), summary.mean().apply(1));
StandardScalerModel scaler = new StandardScalerModel(stddev, mean);

然后,我们需要一个用于数值值的编码器(即Integer;可以是BINARYDouble):

Encoder<Integer> integerEncoder = Encoders.INT();
Encoder<Double> doubleEncoder = Encoders.DOUBLE();
Encoders.BINARY();

Encoder<Vector> vectorEncoder = Encoders.kryo(Vector.class);
Encoders.tuple(integerEncoder, vectorEncoder);
Encoders.tuple(doubleEncoder, vectorEncoder);

然后我们可以创建一个VectorPair,由标签(即Survived)和特征组成。这里的编码基本上是创建一个缩放后的特征向量:

JavaRDD<VectorPair> scaledRDD = trainingDF.toJavaRDD().map(row -> {
                VectorPair vectorPair = new VectorPair();
                vectorPair.setLable(new
                Double(row.<Integer> getAs("Survived")));

                vectorPair.setFeatures(Util.*getScaledVector*(
                                row.<Double>getAs("Fare"),
                                row.<Double>getAs("Age"),
                                row.<Integer>getAs("Pclass"),
                                row.<Integer>getAs("Sex"),
                                row.isNullAt(7) ? 0d :
                                row.<Integer>getAs("Embarked"),
                                scaler));
                return vectorPair;
        });

在上面的代码块中,getScaledVector()方法执行了缩放操作。该方法的函数签名如下所示:

public static org.apache.spark.mllib.linalg.Vector getScaledVector(double fare, 
 double age, double pclass,  double sex, double embarked, StandardScalerModel scaler) {
        org.apache.spark.mllib.linalg.Vector scaledContinous = scaler.transform(Vectors.dense(fare, age));
        Tuple3<Double, Double, Double> pclassFlat = flattenPclass(pclass);
        Tuple3<Double, Double, Double> embarkedFlat = flattenEmbarked(embarked);
        Tuple2<Double, Double> sexFlat = flattenSex(sex);

        return Vectors.dense(
                scaledContinous.apply(0),
                scaledContinous.apply(1),
                sexFlat._1(),
                sexFlat._2(),
                pclassFlat._1(),
                pclassFlat._2(),
                pclassFlat._3(),
                embarkedFlat._1(),
                embarkedFlat._2(),
                embarkedFlat._3());
    }

由于我们计划使用基于 Spark ML 的分类器(即 MLP 实现),我们需要将这个 RDD 向量转换为 ML 向量:

Dataset<Row> scaledDF = spark.createDataFrame(scaledRDD, VectorPair.class);

最后,让我们看看结果 DataFrame 的样子:

scaledDF.show();

到目前为止,我们已经能够准备好特征了。不过,这仍然是一个基于 MLlib 的向量,因此我们需要进一步将其转换为 ML 向量:

Dataset<Row> scaledData2 = MLUtils.convertVectorColumnsToML(scaledDF);

太棒了!现在我们几乎完成了准备一个可以供 MLP 分类器使用的训练集。由于我们还需要评估模型的性能,因此可以随机拆分训练数据为训练集和测试集。我们将 80%分配给训练,20%分配给测试。它们将分别用于训练模型和评估模型:

Dataset<Row> data = scaledData2.toDF("features", "label");
Dataset<Row>[] datasets = data.randomSplit(new double[]{0.80, 0.20}, 12345L);

Dataset<Row> trainingData = datasets[0];
Dataset<Row> validationData = datasets[1];

好的。现在我们已经有了训练集,可以对 MLP 模型进行训练了。

训练 MLP 分类器

在 Spark 中,MLP 是一个包含多层的分类器。每一层都与网络中的下一层完全连接。输入层的节点表示输入数据,而其他节点通过线性组合输入、节点的权重和偏置,并应用激活函数,将输入映射到输出。

有兴趣的读者可以查看spark.apache.org/docs/latest/ml-classification-regression.html#multilayer-perceptron-classifier

那么让我们为 MLP 分类器创建层。对于这个示例,考虑到我们的数据集维度并不高,让我们构建一个浅层网络。

假设第一隐藏层只有 18 个神经元,第二隐藏层有8个神经元就足够了。请注意,输入层有10个输入,因此我们设置10个神经元,输出层设置2个神经元,因为我们的 MLP 只会预测2个类别。有一件事非常重要——输入的数量必须等于特征向量的大小,输出的数量必须等于标签的总数:

int[] layers = new int[] {10, 8, 16, 2};

然后我们用训练器实例化模型,并设置其参数:

MultilayerPerceptronClassifier mlp = new MultilayerPerceptronClassifier()
                                          .setLayers(layers)
                                          .setBlockSize(128)
                                          .setSeed(1234L)
                                          .setTol(1E-8)
                                          .setMaxIter(1000);

正如你所理解的,前面的MultilayerPerceptronClassifier()是基于 MLP 的分类器训练器。除了输出层使用 softmax 激活函数外,每一层都使用 sigmoid 激活函数。需要注意的是,基于 Spark 的 MLP 实现仅支持小批量梯度下降(minibatch GD)和 LBFGS 优化器。

简而言之,我们不能在隐藏层使用其他激活函数,如 ReLU 或 tanh。除此之外,其他高级优化器也不被支持,批量归一化等也无法使用。这是该实现的一个严重限制。在下一章中,我们将尝试用 DL4J 克服这个问题。

我们还将迭代的收敛容差设置为非常小的值,这样可以通过更多的迭代获得更高的准确性。我们设置了块大小,以便在矩阵中堆叠输入数据,从而加速计算。

如果训练集的大小很大,那么数据会在分区内堆叠。如果块大小超过了分区中剩余的数据量,那么它会调整为该数据的大小。推荐的块大小在 10 到 1,000 之间,但默认的块大小为 128。

最后,我们计划将训练迭代 1,000 次。那么让我们开始使用训练集来训练模型:

MultilayerPerceptronClassificationModel model = mlp.fit(trainingData);

评估 MLP 分类器

当训练完成后,我们计算测试集上的预测结果,以评估模型的鲁棒性:

Dataset<Row> predictions = model.transform(validationData);

那么,如何看一些样本预测呢?让我们观察真实标签和预测标签:

predictions.show();

我们可以看到一些预测是正确的,但也有一些是错误的。然而,以这种方式很难猜测性能。因此,我们可以计算精确度、召回率和 F1 值等性能指标:

MulticlassClassificationEvaluator evaluator = new MulticlassClassificationEvaluator()
                                              .setLabelCol("label")
                                              .setPredictionCol("prediction");

MulticlassClassificationEvaluator evaluator1 = evaluator.setMetricName("accuracy");
MulticlassClassificationEvaluator evaluator2 = evaluator.setMetricName("weightedPrecision");
MulticlassClassificationEvaluator evaluator3 = evaluator.setMetricName("weightedRecall");
MulticlassClassificationEvaluator evaluator4 = evaluator.setMetricName("f1");

现在让我们计算分类的准确度精确度召回率F1值以及测试数据上的错误率:

double accuracy = evaluator1.evaluate(predictions);
double precision = evaluator2.evaluate(predictions);
double recall = evaluator3.evaluate(predictions);
double f1 = evaluator4.evaluate(predictions);

// Print the performance metrics
System.*out*.println("Accuracy = " + accuracy);
System.*out*.println("Precision = " + precision);
System.*out*.println("Recall = " + recall);
System.*out*.println("F1 = " + f1);

System.*out*.println("Test Error = " + (1 - accuracy));
<q>>>></q> Accuracy = 0.7796476846282568
 Precision = 0.7796476846282568
 Recall = 0.7796476846282568
 F1 = 0.7796476846282568
 Test Error = 0.22035231537174316

做得很好!我们已经能够达到一个相当高的准确率,即 78%。不过,我们依然可以通过额外的特征工程进行改进。更多提示将在下一节给出!现在,在结束本章之前,让我们尝试利用训练好的模型对测试集进行预测。首先,我们读取测试集并创建 DataFrame:

Dataset<Row> testDF = Util.getTestDF();

然而,即使你查看测试集,你会发现其中有一些空值。所以我们需要对AgeFare列进行空值填充。如果你不想使用 UDF,你可以创建一个 MAP,包含你的填充方案:

Map<String, Object> m = new HashMap<String, Object>();
m.put("Age", meanAge);
m.put("Fare", meanFare);

Dataset<Row> testDF2 = testDF.na().fill(m);

然后,我们再次创建一个包含特征和标签(目标列)的vectorPair的 RDD:

JavaRDD<VectorPair> testRDD = testDF2.javaRDD().map(row -> {
            VectorPair vectorPair = new VectorPair();
            vectorPair.setLable(row.<Integer>getAs("PassengerId"));
            vectorPair.setFeatures(Util.*getScaledVector*(
                    row.<Double>getAs("Fare"),
                    row.<Double>getAs("Age"),
                    row.<Integer>getAs("Pclass"),
                    row.<Integer>getAs("Sex"),
                    row.<Integer>getAs("Embarked"),
                    scaler));
            return vectorPair;
        });

然后我们创建一个 Spark DataFrame:

Dataset<Row> scaledTestDF = spark.createDataFrame(testRDD, VectorPair.class);

最后,让我们将 MLib 向量转换为基于 ML 的向量:

Dataset<Row> finalTestDF = MLUtils.convertVectorColumnsToML(scaledTestDF).toDF("features", "PassengerId");

现在,让我们执行模型推理,即为PassengerId列创建预测并展示示例prediction

Dataset<Row> resultDF = model.transform(finalTestDF).select("PassengerId", "prediction"); 
resultDF.show();

最后,让我们将结果写入 CSV 文件:

resultDF.write().format("com.databricks.spark.csv").option("header", true).save("result/result.csv");

常见问题解答(FAQs)

现在,我们已经以可接受的准确率解决了泰坦尼克号生存预测问题,但这个问题以及深度学习现象中其他实际问题也需要考虑。在这一节中,我们将看到一些你可能已经在想的常见问题。答案可以在附录 A中找到。

  1. 使用原始人工神经元绘制一个计算 XOR 操作的人工神经网络:AB。将这个问题正式描述为一个分类问题。为什么简单的神经元无法解决这个问题?多层感知器(MLP)是如何通过堆叠多个感知器来解决这个问题的?

  2. 我们简要回顾了人工神经网络的历史。那么在深度学习的时代,最重要的里程碑是什么?我们能否用一张图来解释时间线?

  3. 我可以使用其他深度学习框架更灵活地解决这个泰坦尼克号生存预测问题吗?

  4. 我可以在代码中使用Name作为 MLP 中的一个特征吗?

  5. 我理解输入层和输出层的神经元数量。那么我应该为隐藏层设置多少个神经元?

  6. 我们不能通过交叉验证和网格搜索技术来提高预测准确性吗?

总结

在本章中,我们介绍了一些深度学习的基本主题。我们从对机器学习的基本但全面的介绍开始。然后,我们逐步过渡到深度学习和不同的神经网络结构。接着,我们对最重要的深度学习框架进行了简要概述。最后,我们看了一些与深度学习和泰坦尼克号生存预测问题相关的常见问题。

在下一章中,我们将开始深入学习深度学习,通过使用多层感知器(MLP)解决泰坦尼克号生存预测问题。然后,我们将开始开发一个端到端的项目,用于使用循环 LSTM 网络进行癌症类型分类。我们将使用一个非常高维的基因表达数据集来训练和评估模型。

常见问题解答

问题 1 的答案:解决这个问题的方法有很多:

  1. AB= (A ∨ ¬ B)∨ (¬ A ∧ B)

  2. AB = (A ∨ B) ∧ ¬(A ∨ B)

  3. AB = (A ∨ B) ∧ (¬ A ∨ ∧ B),依此类推

如果我们采用第一种方法,得到的人工神经网络将如下所示:

现在,从计算机科学文献中,我们知道 XOR 操作仅与两个输入组合和一个输出相关联。对于输入(0, 0)或(1, 1),网络输出 0;对于输入(0, 1)或(1, 0),网络输出 1。因此,我们可以正式地将前述真值表表示如下:

X0 X1 Y
0 0 0
0 1 1
1 0 1
1 1 0

在这里,每个模式被分类为两个类之一,这两个类可以通过一条单独的直线L分开。它们被称为线性可分模式,如下所示:

问题 2 的答案:人工神经网络和深度学习的最重要进展可以通过以下时间线描述。我们已经看到,人工神经元和感知器分别在 1943 年和 1958 年为基础提供了支持。然后,1969 年,Minsky 等人将 XOR 问题公式化为一个线性不可分的问题。但是,后来在 1974 年,Werbos 等人展示了用于训练感知器的反向传播算法。

然而,最重要的进展发生在 1980 年代,当时 John Hopfield 等人于 1982 年提出了 Hopfield 网络。然后,神经网络和深度学习的奠基人之一 Hinton 和他的团队于 1985 年提出了玻尔兹曼机。然而,可能最重要的进展发生在 1986 年,当时 Hinton 等人成功训练了 MLP,而 Jordan 等人提出了 RNN。同年,Smolensky 等人也提出了改进版的 RBM。

在 1990 年代,最重要的一年是 1997 年。Lecun 等人于 1990 年提出了 LeNet,而 Jordan 等人则在 1997 年提出了 RNN。同年,Schuster 等人提出了改进版的 LSTM 和改进版的原始 RNN,称为 双向 RNN

尽管计算能力有了显著的进展,但从 1997 年到 2005 年,我们并没有经历太多的突破,直到 2006 年 Hinton 再次提出了 DBN——通过堆叠多个 RBM。然后在 2012 年,Hinton 又发明了 dropout,这大大改善了 DNN 中的正则化和过拟合问题。

之后,Ian Goodfellow 等人引入了 GAN,这是图像识别领域的一个重要里程碑。2017 年,Hinton 提出了 CapsNets 来克服常规 CNN 的局限性——迄今为止,这是最重要的里程碑之一。

问题 3 的答案:是的,你可以使用深度学习框架部分中描述的其他深度学习框架。然而,由于本书是关于使用 Java 进行深度学习的,我建议使用 DeepLearning4J。我们将在下一章中看到如何灵活地通过堆叠输入层、隐藏层和输出层来创建网络,使用 DeepLearning4J。

问题 4 的答案:是的,你可以,因为乘客的名字包含不同的称呼(例如,先生、夫人、小姐、少爷等等)也可能很重要。例如,我们可以想象,作为女性(即夫人)和作为一个年轻人(例如,少爷)可能有更高的生存机会。

甚至,在看完著名电影《泰坦尼克号》(1997)后,我们可以想象,如果一个女孩处于一段关系中,她可能有更好的生存机会,因为她的男朋友会尝试救她!不过,这只是想象而已,所以不要太当真。现在,我们可以编写一个用户定义的函数,使用 Apache Spark 来编码这个过程。让我们来看一下以下的 Java 中的 UDF:

private static final UDF1<String, Option<String>> getTitle = (String name) ->      {
    if(name.contains("Mr.")) { // If it has Mr.
        return Some.apply("Mr.");
    } else if(name.contains("Mrs.")) { // Or if has Mrs.
        return Some.apply("Mrs.");
    } else if(name.contains("Miss.")) { // Or if has Miss.
        return Some.apply("Miss.");
    } else if(name.contains("Master.")) { // Or if has Master.
        return Some.apply("Master.");
    } else{ // Not any.
        return Some.apply("Untitled");
    }
};

接下来,我们可以注册 UDF。然后我必须按如下方式注册前面的 UDF:

spark.sqlContext().udf().register("getTitle", getTitle, DataTypes.StringType);

Dataset<Row> categoricalDF = df.select(callUDF("getTitle", col("Name")).alias("Name"), col("Sex"), 
                                       col("Ticket"), col("Cabin"), col("Embarked"));
categoricalDF.show();

结果列看起来如下所示:

问题 5 的答案:对于许多问题,你可以从只有一到两个隐藏层开始。使用两个隐藏层(具有相同总神经元数量,稍后阅读时你会了解神经元数量)并且训练时间大致相同,这个设置就能很好地工作。现在让我们来看看关于设置隐藏层数量的一些简单估算:

  • 0:只能表示线性可分函数

  • 1:可以近似任何包含从一个有限空间到另一个有限空间的连续映射的函数

  • 2:可以以任意精度表示任意的决策边界

然而,对于更复杂的问题,你可以逐渐增加隐藏层的数量,直到开始过拟合训练集。不过,你也可以尝试逐步增加神经元的数量,直到网络开始过拟合。这意味着不会导致过拟合的隐藏神经元的上限是:

在上述方程中:

  • N[i] = 输入神经元的数量

  • N[o] = 输出神经元的数量

  • N[s] = 训练数据集中的样本数量

  • α = 任意的缩放因子,通常为2-10

请注意,上述方程并非来源于任何研究,而是来自我个人的工作经验。

问题 6 的答案:当然可以。我们可以对训练进行交叉验证,并创建网格搜索技术来寻找最佳超参数。让我们试试看。

首先,我们定义了各层。不幸的是,我们无法对各层进行交叉验证。这可能是一个 bug,或者是 Spark 团队故意为之。所以我们坚持使用单层结构:

int[] layers = new int[] {10, 16, 16, 2};

然后我们创建训练器,并只设置层和种子参数:

MultilayerPerceptronClassifier mlp = new MultilayerPerceptronClassifier()
                     .setLayers(layers)
                     .setSeed(1234L);

我们在 MLP 的不同超参数中搜索最佳模型:

ParamMap[] paramGrid = new ParamGridBuilder() 
                    .addGrid(mlp.blockSize(), new int[] {32, 64, 128})
                    .addGrid(mlp.maxIter(), new int[] {10, 50})
                    .addGrid(mlp.tol(), new double[] {1E-2, 1E-4, 1E-6})
                    .build();
MulticlassClassificationEvaluator evaluator = new MulticlassClassificationEvaluator()
          .setLabelCol("label")
          .setPredictionCol("prediction");

接着,我们设置交叉验证器,并执行 10 折交叉验证:

int numFolds = 10;
CrossValidator crossval = new CrossValidator()
          .setEstimator(mlp)
          .setEvaluator(evaluator)
          .setEstimatorParamMaps(paramGrid)
          .setNumFolds(numFolds);

然后,我们使用交叉验证后的模型进行训练:

CrossValidatorModel cvModel = crossval.fit(trainingData);

最后,我们对测试集上的交叉验证模型进行评估,如下所示:

Dataset<Row> predictions = cvModel.transform(validationData);

现在我们可以计算并显示性能指标,类似于我们之前的示例:

double accuracy = evaluator1.evaluate(predictions);
double precision = evaluator2.evaluate(predictions);
double recall = evaluator3.evaluate(predictions);
double f1 = evaluator4.evaluate(predictions);

// Print the performance metrics
System.out.println("Accuracy = " + accuracy);
System.out.println("Precision = " + precision);
System.out.println("Recall = " + recall);
System.out.println("F1 = " + f1);
System.out.println("Test Error = " + (1 - accuracy));
>>>Accuracy = 0.7810132575757576
 Precision = 0.7810132575757576
 Recall = 0.7810132575757576
 F1 = 0.7810132575757576
 Test Error = 0.21898674242424243

第二章:使用递归类型网络进行癌症类型预测

大规模的癌症基因组学数据通常以多平台和异构形式存在。这些数据集在生物信息学方法和计算算法方面带来了巨大的挑战。许多研究人员提出利用这些数据克服多个挑战,将经典的机器学习算法作为癌症诊断和预后的主要方法或辅助元素。

本章中,我们将使用一些深度学习架构来进行癌症类型分类,数据来自 The Cancer Genome Atlas(TCGA)中整理的高维数据集。首先,我们将描述该数据集并进行一些预处理,使得数据集可以输入到我们的网络中。然后,我们将学习如何准备编程环境,接下来使用一个开源深度学习库Deeplearning4jDL4J)进行编码。首先,我们将再次回顾 Titanic 生存预测问题,并使用 DL4J 中的多层感知器MLP)实现。

然后,我们将使用一种改进的递归神经网络RNN)架构,称为长短期记忆LSTM),进行癌症类型预测。最后,我们将了解一些与此项目及 DL4J 超参数/网络调优相关的常见问题。

简而言之,本章将学习以下内容:

  • 癌症基因组学中的深度学习

  • 癌症基因组学数据集描述

  • 开始使用 Deeplearning4j

  • 使用 LSTM-RNN 开发癌症类型预测模型

  • 常见问题

癌症基因组学中的深度学习

生物医学信息学包括与生物系统研究相关的数据分析、数学建模和计算仿真技术的开发。近年来,我们见证了生物计算的巨大飞跃,结果是大量信息丰富的资源已可供我们使用。这些资源涵盖了诸如解剖学、建模(3D 打印机)、基因组学和药理学等多个领域。

生物医学信息学最著名的成功案例之一来自基因组学领域。人类基因组计划HGP)是一个国际研究项目,旨在确定人类 DNA 的完整序列。这个项目是计算生物学中最重要的里程碑之一,并为其他项目提供了基础,包括致力于对人类大脑进行基因组测序的人类大脑计划。本文所使用的数据也是 HGP 的间接成果。

大数据时代大约从过去十年开始,标志着数字信息的激增,相比其模拟对手。仅在 2016 年,16.1 泽字节的数字数据被生成,预计到 2025 年将达到每年 163 泽字节。虽然这是一则好消息,但仍然存在一些问题,尤其是在数据存储和分析方面。对于后者,简单的机器学习方法在常规数据分析中的应用已不再有效,应被深度神经网络学习方法所取代。深度学习通常被认为能非常有效地处理这些类型的大型复杂数据集。

与其他重要领域一样,生物医学领域也受到了大数据现象的影响。最主要的大型数据来源之一是诸如基因组学、代谢组学和蛋白质组学等 omics 数据。生物医学技术和设备的创新,如 DNA 测序和质谱分析,导致了 -omics 数据的巨大积累。

通常,-omics 数据充满了真实性、变异性和高维度性。这些数据集来源于多个,甚至有时是不兼容的数据平台。这些特性使得这些类型的数据适合应用深度学习方法。对 -omics 数据的深度学习分析是生物医学领域的主要任务之一,因为它有可能成为个性化医疗的领导者。通过获取一个人 omics 数据的信息,可以更好地应对疾病,治疗可以集中于预防措施。

癌症通常被认为是世界上最致命的疾病之一,主要是由于其诊断和治疗的复杂性。它是一种涉及多种基因突变的遗传性疾病。随着癌症治疗中遗传学知识重要性的逐渐受到重视,最近出现了多个记录癌症患者遗传数据的项目。其中最著名的项目之一是癌症基因组图谱TCGA)项目,该项目可在 TCGA 研究网络上找到:cancergenome.nih.gov/

如前所述,生物医学领域,包括癌症研究,已经有许多深度学习应用。在癌症研究中,大多数研究者通常使用 -omics 或医学影像数据作为输入。多个研究工作聚焦于癌症分析。其中一些使用组织病理图像或 PET 图像作为数据来源。大多数研究集中于基于这些图像数据的分类,采用卷积神经网络CNNs)。

然而,许多研究使用-omics 数据作为其数据来源。Fakoor 等人使用患者的基因表达数据对各种类型的癌症进行了分类。由于每种癌症类型的数据维度不同,他们首先使用主成分分析PCA)来减少微阵列基因表达数据的维度。

主成分分析(PCA)是一种统计技术,用于强调数据的变化并提取数据集中最显著的模式;主成分是基于真实特征向量的最简单的多元分析方法。PCA 通常用于使数据探索更易于可视化。因此,PCA 是数据探索分析和构建预测模型中最常用的算法之一。

然后,他们应用稀疏和堆叠自编码器对多种癌症进行分类,包括急性髓性白血病、乳腺癌和卵巢癌。

有关详细信息,请参阅以下文献:《使用深度学习增强癌症诊断与分类》,作者:R. Fakoor 等人,发表于 2013 年国际机器学习会议论文集中。

另一方面,Ibrahim 等人使用了来自六种癌症的基因/miRNA 特征选择的 miRNA 表达数据。他们提出了一种新的多级特征选择方法,名为MLFS多级基因/miRNA 特征选择的简称),该方法基于深度置信网络(DBN)和无监督主动学习。

您可以在以下文献中阅读更多内容:《使用深度置信网络和主动学习的多级基因/miRNA 特征选择》,作者:R. Ibrahim 等人,发表于 2014 年 36 届国际工程医学生物学学会年会(EMBC)论文集,页 3957-3960,IEEE,2014。

最后,Liang 等人使用多平台基因组学和临床数据对卵巢癌和乳腺癌患者进行了聚类。卵巢癌数据集包含 385 名患者的基因表达、DNA 甲基化和 miRNA 表达数据,这些数据从癌症基因组图谱(TCGA)下载。

您可以在以下文献中阅读更多内容:《多平台癌症数据的集成数据分析与多模态深度学习方法》,作者:M. Liang 等人,发表于《分子药学》期刊,卷 12,页 928-937,IEEE/ACM 计算生物学与生物信息学学报,2015。

乳腺癌数据集包括基因表达数据和相应的临床信息,如生存时间和复发时间数据,这些数据由荷兰癌症研究所收集。为了处理这些多平台数据,他们使用了多模态深度置信网络mDBN)。

首先,他们为每种数据实现了一个深度置信网络(DBN)以获取其潜在特征。然后,另一个用于执行聚类的深度置信网络使用这些潜在特征作为输入。除了这些研究人员外,还有大量研究正在进行,旨在为癌症基因组学、识别和治疗提供重要推动。

癌症基因组学数据集描述

基因组学数据涵盖与生物体 DNA 相关的所有数据。尽管在本论文中我们还将使用其他类型的数据,如转录组数据(RNA 和 miRNA),但为了方便起见,所有数据将统称为基因组数据。人类基因组学的研究在最近几年取得了巨大的突破,这得益于 HGP(1984-2000)在测序人类 DNA 全序列方面的成功。

受此影响最大的领域之一是与遗传学相关的所有疾病的研究,包括癌症。通过对 DNA 进行各种生物医学分析,出现了各种类型的-组学或基因组数据。以下是一些对癌症分析至关重要的-组学数据类型:

  • 原始测序数据:这对应于整个染色体的 DNA 编码。一般来说,每个人体内的每个细胞都有 24 种染色体,每条染色体由 4.6 亿至 2.47 亿个碱基对组成。每个碱基对可以用四种不同的类型进行编码,分别是腺嘌呤A)、胞嘧啶C)、鸟嘌呤G)和胸腺嘧啶T)。因此,原始测序数据由数十亿个碱基对数据组成,每个碱基对都用这四种类型之一进行编码。

  • 单核苷酸多态性SNP)数据:每个人都有不同的原始序列,这会导致基因突变。基因突变可能导致实际的疾病,或者仅仅是外貌上的差异(如发色),也可能什么都不发生。当这种突变仅发生在单个碱基对上,而不是一段碱基对序列时,这被称为单核苷酸多态性SNP)。

  • 拷贝数变异CNV)数据:这对应于发生在碱基对序列中的基因突变。突变可以有多种类型,包括碱基对序列的缺失、碱基对序列的倍增以及碱基对序列在染色体其他部位的重排。

  • DNA 甲基化数据:这对应于染色体上某些区域发生的甲基化量(甲基基团连接到碱基对上)。基因启动子区域的甲基化量过多可能会导致基因沉默。DNA 甲基化是我们每个器官表现出不同功能的原因,尽管它们的 DNA 序列是相同的。在癌症中,这种 DNA 甲基化被破坏。

  • 基因表达数据:这对应于某一时刻从基因中表达的蛋白质数量。癌症的发生通常是由于致癌基因(即引发肿瘤的基因)表达过高、抑癌基因(即防止肿瘤的基因)表达过低,或两者兼有。因此,基因表达数据的分析有助于发现癌症中的蛋白质生物标志物。我们将在本项目中使用这种数据。

  • miRNA 表达数据:对应于在特定时间内表达的微小 RNA 的数量。miRNA 在 mRNA 阶段起到蛋白质沉默的作用。因此,基因表达数据的分析有助于发现癌症中的 miRNA 生物标志物。

有多个基因组数据集的数据库,其中可以找到上述数据。它们中的一些专注于癌症患者的基因组数据。这些数据库包括:

这些基因组数据通常伴随着患者的临床数据。临床数据可以包括一般的临床信息(例如,年龄或性别)以及他们的癌症状态(例如,癌症的位置或癌症的分期)。所有这些基因组数据本身具有高维度的特点。例如,每个患者的基因表达数据是基于基因 ID 构建的,达到约 60,000 种类型。

此外,一些数据本身来自多个格式。例如,70%的 DNA 甲基化数据来自乳腺癌患者,剩余 30%则是来自不同平台的整理数据。因此,这个数据集有两种不同的结构。因此,为了分析基因组数据并处理其异质性,研究人员通常采用强大的机器学习技术,甚至是深度神经网络。

现在让我们看看一个可以用于我们目的的实际数据集。我们将使用从 UCI 机器学习库下载的基因表达癌症 RNA-Seq 数据集(有关更多信息,请参见archive.ics.uci.edu/ml/datasets/gene+expression+cancer+RNA-Seq#)。

泛癌症分析项目的数据收集流程(来源:“Weinstein, John N., et al. 'The cancer genome atlas pan-cancer analysis project.' Nature Genetics 45.10 (2013): 1113-1120”)

这个数据集是从以下论文中报告的另一个数据集的随机子集:Weinstein, John N., et al. The cancer genome atlas pan-cancer analysis project. Nature Genetics 45.10 (2013): 1113-1120。前面的图示展示了泛癌症分析项目的数据收集流程。

项目的名称是泛癌症分析项目。该项目汇集了来自不同部位发生原发肿瘤的数千名患者的数据。它涵盖了 12 种肿瘤类型(见前面图示的左上角面板),包括:

  • 胶质母细胞瘤GBM

  • 急性淋巴细胞白血病AML

  • 头颈部鳞状细胞癌HNSC

  • 肺腺癌LUAD

  • 肺鳞状细胞癌 (LUSC)

  • 乳腺癌 (BRCA)

  • 肾脏透明细胞癌 (KIRC)

  • 卵巢癌 (OV)

  • 膀胱癌 (BLCA)

  • 结肠腺癌 (COAD)

  • 子宫颈和子宫内膜癌 (UCEC)

  • 直肠腺癌 (READ)

这组数据是 RNA-Seq (HiSeq) PANCAN 数据集的一部分。它是从患有不同类型肿瘤的患者(如 BRCA、KIRC、COAD、LUAD 和 PRAD)中随机提取的基因表达数据。

该数据集是从 801 名癌症患者中随机收集的,每名患者有 20,531 个属性。样本(实例)按行存储。每个样本的变量(属性)是通过 illumina HiSeq 平台测量的 RNA-Seq 基因表达水平。每个属性都被赋予一个虚拟名称(gene_XX)。属性的顺序与原始提交一致。例如,sample_0上的gene_1的基因表达水平显著且有差异,数值为2.01720929003

当你下载数据集时,你会看到有两个 CSV 文件:

  • data.csv 包含每个样本的基因表达数据

  • labels.csv 与每个样本相关的标签

让我们来看一下处理过的数据集。请注意,由于高维度性,我们只会看到一些选择的特征,以下截图中第一列表示样本 ID(即匿名患者 ID)。其余列表示某些基因在患者肿瘤样本中的表达情况:

样本基因表达数据集

现在看一下图 3中的标签。在这里,id包含样本 ID,Class表示癌症标签:

样本被分类为不同的癌症类型

现在你可以理解为什么我选择了这个数据集。尽管我们没有太多样本,但这个数据集仍然是非常高维的。此外,这种高维数据集非常适合应用深度学习算法。

好的。那么,如果给定了特征和标签,我们能否根据特征和真实标签对这些样本进行分类呢?为什么不呢?我们将尝试使用 DL4J 库解决这个问题。首先,我们需要配置我们的编程环境,以便开始编写代码。

准备编程环境

在本节中,我们将讨论如何在开始编写代码之前配置 DL4J、ND4s、Spark 和 ND4J。使用 DL4J 时需要的前提条件如下:

  • Java 1.8+(仅限 64 位)

  • 用于自动构建和依赖管理的 Apache Maven

  • IntelliJ IDEA 或 Eclipse IDE

  • 用于版本控制和 CI/CD 的 Git

以下库可以与 DJ4J 集成,以增强你在开发机器学习应用时的 JVM 体验:

  • DL4J:核心神经网络框架,提供了许多深度学习架构和底层功能。

  • ND4J:可以被认为是 JVM 的 NumPy。它包括一些基本的线性代数操作,例如矩阵创建、加法和乘法。

  • DataVec:这个库在执行特征工程的同时,支持 ETL 操作。

  • JavaCPP:这个库充当 Java 与本地 C++之间的桥梁。

  • Arbiter:这个库为深度学习算法提供基本的评估功能。

  • RL4J:为 JVM 提供深度强化学习。

  • ND4S:这是一个科学计算库,并且它也支持 JVM 语言中的 n 维数组。

如果你在你喜欢的 IDE 中使用 Maven,我们可以在pom.xml文件中定义项目属性来指定版本:

<properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
        <nd4j.version>1.0.0-alpha</nd4j.version>
        <dl4j.version>1.0.0-alpha</dl4j.version>
        <datavec.version>1.0.0-alpha</datavec.version>
        <arbiter.version>1.0.0-alpha</arbiter.version>
        <logback.version>1.2.3</logback.version>
        <dl4j.spark.version>1.0.0-alpha_spark_2</dl4j.spark.version>
</properties>

然后使用以下依赖项,这些依赖项是 DL4J、ND4S、ND4J 等所需要的:

<dependencies>
    <dependency>
        <groupId>org.nd4j</groupId>
        <artifactId>nd4j-native</artifactId>
        <version>${nd4j.version}</version>
    </dependency>
    <dependency>
        <groupId>org.deeplearning4j</groupId>
        <artifactId>dl4j-spark_2.11</artifactId>
        <version>1.0.0-alpha_spark_2</version>
    </dependency>
    <dependency>
        <groupId>org.nd4j</groupId>
        <artifactId>nd4j-native</artifactId>
        <version>1.0.0-alpha</version>
        <type>pom</type>
    </dependency>
    <dependency>
        <groupId>org.deeplearning4j</groupId>
        <artifactId>deeplearning4j-core</artifactId>
        <version>${dl4j.version}</version>
    </dependency>
    <dependency>
        <groupId>org.deeplearning4j</groupId>
        <artifactId>deeplearning4j-nlp</artifactId>
        <version>${dl4j.version}</version>
    </dependency>
    <dependency>
        <groupId>org.deeplearning4j</groupId>
        <artifactId>deeplearning4j-zoo</artifactId>
        <version>${dl4j.version}</version>
    </dependency>
    <dependency>
        <groupId>org.deeplearning4j</groupId>
        <artifactId>arbiter-deeplearning4j</artifactId>
        <version>${arbiter.version}</version>
    </dependency>
    <dependency>
        <groupId>org.deeplearning4j</groupId>
        <artifactId>arbiter-ui_2.11</artifactId>
        <version>${arbiter.version}</version>
    </dependency>
    <dependency>
        <artifactId>datavec-data-codec</artifactId>
        <groupId>org.datavec</groupId>
        <version>${datavec.version}</version>
    </dependency>
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        <version>4.3.5</version>
    </dependency>
    <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>${logback.version}</version>
        </dependency>
</dependencies>

顺便说一下,DL4J 随 Spark 2.1.0 一起提供。此外,如果你的机器上没有配置本地系统 BLAS,ND4J 的性能会降低。当你执行 Scala 编写的简单代码时,你将看到以下警告:

****************************************************************
 WARNING: COULD NOT LOAD NATIVE SYSTEM BLAS
 ND4J performance WILL be reduced
 ****************************************************************

然而,安装和配置 BLAS(如OpenBLASIntelMKL)并不难;你可以花些时间去完成它。更多细节可以参考以下网址:nd4j.org/getstarted.html#open

干得好!我们的编程环境已经准备好用于简单的深度学习应用开发。现在是时候动手写一些示例代码了。

使用 DL4J 重新审视泰坦尼克号生存预测

在前一章中,我们使用基于 Spark 的 MLP 解决了泰坦尼克号生存预测问题。我们还看到,通过使用基于 Spark 的 MLP,用户几乎无法了解层次结构的使用情况。此外,超参数等的定义也不够明确。

因此,我所做的是使用训练数据集,并进行了预处理和特征工程。然后,我将预处理后的数据集随机分为训练集和测试集(具体来说,70%用于训练,30%用于测试)。首先,我们按照如下方式创建 Spark 会话:

SparkSession spark = SparkSession.builder()
                  .master("local[*]")
                  .config("spark.sql.warehouse.dir", "temp/")// change accordingly
                  .appName("TitanicSurvivalPrediction")
                  .getOrCreate();

在本章中,我们看到有两个 CSV 文件。然而,test.csv没有提供任何实际的标签。因此,我决定只使用training.csv文件,以便我们可以比较模型的性能。所以我们通过 Spark 的read() API 读取训练数据集:

Dataset<Row> df = spark.sqlContext()
                .read()
                .format("com.databricks.spark.csv")
                .option("header", "true") // Use first line of all files as header
                .option("inferSchema", "true") // Automatically infer data types
                .load("data/train.csv");

我们在第一章《深度学习入门》中看到,AgeFare列有许多空值。因此,在这里,我直接用这些列的均值来替换缺失值,而不是为每一列编写UDF

Map<String, Object> m = new HashMap<String, Object>();
m.put("Age", 30);
m.put("Fare", 32.2);
Dataset<Row> trainingDF1 = df2.na().fill(m);  

要深入了解如何处理缺失/空值和机器学习,感兴趣的读者可以阅读 Boyan Angelov 的博客,链接如下:towardsdatascience.com/working-with-missing-data-in-machine-learning-9c0a430df4ce

为了简化,我们还可以删除一些列,例如“PassengerId”、“Name”、“Ticket”和“Cabin”:

Dataset<Row> trainingDF2 = trainingDF1.drop("PassengerId", "Name", "Ticket", "Cabin");

现在,进入难点了。类似于基于 Spark ML 的估计器,基于 DL4J 的网络也需要数字形式的训练数据。因此,我们现在必须将类别特征转换为数值。为此,我们可以使用StringIndexer()转换器。我们要做的是为“Sex”和“Embarked”列创建两个StringIndexer

StringIndexer sexIndexer = new StringIndexer()
                                    .setInputCol("Sex")
                                    .setOutputCol("sexIndex")
                                    .setHandleInvalid("skip");//// we skip column having nulls

StringIndexer embarkedIndexer = new StringIndexer()
                                    .setInputCol("Embarked")
                                    .setOutputCol("embarkedIndex")
                                    .setHandleInvalid("skip");//// we skip column having nulls

然后我们将它们串联成一个管道。接下来,我们将执行转换操作:

Pipeline pipeline = new Pipeline().setStages(new PipelineStage[] {sexIndexer, embarkedIndexer});

接着,我们将拟合管道,转换数据,并删除“Sex”和“Embarked”列,以获取转换后的数据集:

Dataset<Row> trainingDF3 = pipeline.fit(trainingDF2).transform(trainingDF2).drop("Sex", "Embarked");

然后,我们的最终预处理数据集将只包含数值特征。请注意,DL4J 将最后一列视为标签列。这意味着 DL4J 会将“Pclass”、“Age”、“SibSp”、“Parch”、“Fare”、“sexIndex”和“embarkedIndex”视为特征。因此,我将“Survived”列放在了最后:

Dataset<Row> finalDF = trainingDF3.select("Pclass", "Age", "SibSp","Parch", "Fare",                                                                   
                                           "sexIndex","embarkedIndex", "Survived");
finalDF.show();

然后,我们将数据集随机拆分为 70%训练集和 30%测试集。即,我们使用 70%数据进行训练,剩余的 30%用于评估模型:

Dataset<Row>[] splits = finalDF.randomSplit(new double[] {0.7, 0.3}); 
Dataset<Row> trainingData = splits[0]; 
Dataset<Row> testData = splits[1];

最后,我们将两个 DataFrame 分别保存为 CSV 文件,供 DL4J 使用:

trainingData
      .coalesce(1)// coalesce(1) writes DF in a single CSV
      .write() 
      .format("com.databricks.spark.csv")
      .option("header", "false") // don't write the header
      .option("delimiter", ",") // comma separated
      .save("data/Titanic_Train.csv"); // save location

testData
      .coalesce(1)// coalesce(1) writes DF in a single CSV
      .write() 
      .format("com.databricks.spark.csv")
      .option("header", "false") // don't write the header
      .option("delimiter", ",") // comma separated
      .save("data/Titanic_Test.csv"); // save location

此外,DL4J 不支持训练集中的头信息,因此我故意跳过了写入头信息。

多层感知器网络构建

正如我在前一章中提到的,基于 DL4J 的神经网络由多个层组成。一切从MultiLayerConfiguration开始,它组织这些层及其超参数。

超参数是一组决定神经网络学习方式的变量。有很多参数,例如:更新模型权重的次数和频率(称为epoch),如何初始化网络权重,使用哪种激活函数,使用哪种更新器和优化算法,学习率(即模型学习的速度),隐藏层有多少层,每层有多少神经元等等。

现在,我们来创建网络。首先,创建层。类似于我们在第一章中创建的 MLP,深度学习入门,我们的 MLP 将有四层:

  • 第 0 层:输入层

  • 第 1 层:隐藏层 1

  • 第 2 层:隐藏层 2

  • 第 3 层:输出层

更技术性地讲,第一层是输入层,然后将两层作为隐藏层放置。对于前三层,我们使用 Xavier 初始化权重,激活函数为 ReLU。最后,输出层放置在最后。这一设置如下图所示:

泰坦尼克号生存预测的多层感知器输入层

我们已经指定了神经元(即节点),输入和输出的数量相等,并且输出的神经元数量是任意的。考虑到输入和特征非常少,我们设置了一个较小的值:

DenseLayer input_layer = new DenseLayer.Builder()
                .weightInit(WeightInit.XAVIER)
                .activation(Activation.RELU)
                .nIn(numInputs)
                .nOut(16)
                .build();

隐藏层 1

输入层的神经元数量等于输入层的输出。然后输出的数量是任意值。我们设置了一个较小的值,考虑到输入和特征非常少:

DenseLayer hidden_layer_1 = new DenseLayer.Builder()
                .weightInit(WeightInit.XAVIER)
                .activation(Activation.RELU)
                .nIn(16).nOut(32)
                .build();

隐藏层 2

输入层的神经元数量等于隐藏层 1 的输出。然后输出的数量是一个任意值。再次考虑到输入和特征非常少,我们设置了一个较小的值:

 DenseLayer hidden_layer_2 = new DenseLayer.Builder()
                .weightInit(WeightInit.XAVIER)
                .activation(Activation.RELU)
                .nIn(32).nOut(16)
                .build();

输出层

输入层的神经元数量等于隐藏层 1 的输出。然后输出的数量等于预测标签的数量。再次考虑到输入和特征非常少,我们设置了一个较小的值。

我们使用了 Softmax 激活函数,它为我们提供了一个类的概率分布(输出的总和为 1.0),并且在二分类(XNET)中使用交叉熵作为损失函数,因为我们想将输出(概率)转换为离散类别,即零或一:

OutputLayer output_layer = new OutputLayer.Builder(LossFunction.XENT) // XENT for Binary Classification
                .weightInit(WeightInit.XAVIER)
                .activation(Activation.SOFTMAX)
                .nIn(16).nOut(numOutputs)
                .build();

XNET 用于二分类的逻辑回归。更多信息可以查看 DL4J 中的 LossFunctions.java 类。

现在我们通过指定 NeuralNetConfiguration 来创建一个 MultiLayerConfiguration,然后进行训练。使用 DL4J 时,我们可以通过调用 NeuralNetConfiguration.Builder() 上的 layer 方法来添加一层,指定其在层的顺序中的位置(以下代码中的零索引层是输入层):

MultiLayerConfiguration MLPconf = new NeuralNetConfiguration.Builder().seed(seed)
                .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
                .weightInit(WeightInit.XAVIER)
                .updater(new Adam(0.0001))
                .list()
                    .layer(0, input_layer)
                    .layer(1, hidden_layer_1)
                    .layer(2, hidden_layer_2)
                    .layer(3, output_layer)
                .pretrain(false).backprop(true).build();// no pre-traning required    

除了这些之外,我们还指定了如何设置网络的权重。例如,如前所述,我们使用 Xavier 作为权重初始化,并使用 随机梯度下降SGD)优化算法,Adam 作为更新器。最后,我们还指定不需要进行任何预训练(通常在 DBN 或堆叠自编码器中是需要的)。然而,由于 MLP 是一个前馈网络,我们将反向传播设置为 true。

网络训练

首先,我们使用之前的 MultiLayerConfiguration 创建一个 MultiLayerNetwork。然后我们初始化网络并开始在训练集上训练:

MultiLayerNetwork model = new MultiLayerNetwork(MLPconf);
model.init();
log.info("Train model....");
for( int i=0; i<numEpochs; i++ ){
    model.fit(trainingDataIt);
        }

在前面的代码块中,我们通过调用 model.fit() 在训练集(在我们案例中为 trainingDataIt)上开始训练模型。现在我们来讨论一下如何准备训练集和测试集。好吧,对于读取训练集或测试集格式不正确的数据(特征为数值,标签为整数),我创建了一个名为 readCSVDataset() 的方法:

private static DataSetIterator readCSVDataset(String csvFileClasspath, int batchSize, 
               int labelIndex, int numClasses) throws IOException, InterruptedException {
        RecordReader rr = new CSVRecordReader();
        File input = new File(csvFileClasspath);
        rr.initialize(new FileSplit(input));
        DataSetIterator iterator = new RecordReaderDataSetIterator(rr, batchSize, labelIndex, numClasses);
        return iterator;
    }

如果你看前面的代码块,你会发现它基本上是一个包装器,用来读取 CSV 格式的数据,然后 RecordReaderDataSetIterator() 方法将记录读取器转换为数据集迭代器。从技术上讲,RecordReaderDataSetIterator() 是分类的主要构造函数。它接受以下参数:

  • RecordReader:这是提供数据来源的 RecordReader

  • batchSize:输出 DataSet 对象的批量大小(即,示例数量)

  • labelIndex:由recordReader.next()获取的标签索引可写值(通常是IntWritable

  • numPossibleLabels:分类的类别数量(可能的标签)

这将把输入的类别索引(在 labelIndex 位置,整数值为 0numPossibleLabels-1,包括)转换为相应的 one-hot 输出/标签表示。接下来让我们看看如何继续。首先,我们展示训练集和测试集的路径:

String trainPath = "data/Titanic_Train.csv";
String testPath = "data/Titanic_Test.csv";

int labelIndex = 7; // First 7 features are followed by the labels in integer 
int numClasses = 2; // number of classes to be predicted -i.e survived or not-survived
int numEpochs = 1000; // Number of training eopich

int seed = 123; // Randome seed for reproducibilty
int numInputs = labelIndex; // Number of inputs in input layer
int numOutputs = numClasses; // Number of classes to be predicted by the network 

int batchSizeTraining = 128;         

现在,让我们准备要用于训练的数据:

DataSetIterator trainingDataIt = *readCSVDataset*(trainPath, batchSizeTraining, labelIndex, numClasses);

接下来,让我们准备要分类的数据:

int batchSizeTest = 128;
DataSetIterator testDataIt = *readCSVDataset*(testPath, batchSizeTest, labelIndex, numClasses);

太棒了!我们已经成功准备好了训练和测试的DataSetIterator。记住,我们在为其他问题准备训练和测试集时,将几乎采用相同的方法。

评估模型

一旦训练完成,接下来的任务是评估模型。我们将在测试集上评估模型的性能。对于评估,我们将使用Evaluation();它创建一个包含两种可能类别(存活或未存活)的评估对象。从技术上讲,Evaluation 类计算评估指标,如精确度、召回率、F1、准确率和马修斯相关系数。最后一个用于评估二分类器。现在让我们简要了解这些指标:

准确率是正确预测样本与总样本的比例:

精确度是正确预测的正样本与总预测正样本的比例:

召回率是正确预测的正样本与实际类别中所有样本的比例——是的:

F1 分数是精确度和召回率的加权平均值(调和均值):

马修斯相关系数MCC)是衡量二分类(两类)质量的指标。MCC 可以通过混淆矩阵直接计算,计算公式如下(假设 TP、FP、TN 和 FN 已经存在):

与基于 Apache Spark 的分类评估器不同,在使用基于 DL4J 的评估器解决二分类问题时,应该特别注意二分类指标,如 F1、精确度、召回率等。

好的,我们稍后再讨论这些。首先,让我们对每个测试样本进行迭代评估,并从训练好的模型中获取网络的预测。最后,eval()方法将预测结果与真实类别进行对比:

*log*.info("Evaluate model...."); 
Evaluation eval = new Evaluation(2) // for class 1 

while(testDataIt.hasNext()){
DataSet next = testDataIt.next(); 
INDArray output = model.output(next.getFeatureMatrix());
eval.eval(next.getLabels(), output);
}
*log*.info(eval.stats());
*log*.info("****************Example finished********************");
>>>
 ==========================Scores========================================
 # of classes: 2
 Accuracy: 0.6496
 Precision: 0.6155
 Recall: 0.5803
 F1 Score: 0.3946
 Precision, recall & F1: reported for positive class (class 1 - "1") only
 =======================================================================

哎呀!不幸的是,我们在类别 1 的分类准确率上没有取得很高的成绩(即 65%)。现在,我们将为这个二分类问题计算另一个指标,叫做 MCC。

// Compute Matthews correlation coefficient 
EvaluationAveraging averaging = EvaluationAveraging.*Macro*; 
double MCC = eval.matthewsCorrelation(averaging); 
System.*out*.println("Matthews correlation coefficient: "+ MCC);
>>>
 Matthews's correlation coefficient: 0.22308172619187497

现在让我们根据 Matthews 论文(详情请见 www.sciencedirect.com/science/article/pii/0005279575901099)来解释这个结果,论文中描述了以下属性:C = 1 表示完全一致,C = 0 表示预测与随机预测一样,没有任何改善,而 C = -1 表示预测与观察结果完全不一致。

接下来,我们的结果显示出一种弱的正相关关系。好吧!尽管我们没有获得很好的准确率,但你们仍然可以尝试调整超参数,甚至更换其他网络,比如 LSTM,这是我们在下一部分将讨论的内容。但我们会为解决癌症预测问题而进行这些工作,这也是本章的主要目标。所以请继续关注我!

使用 LSTM 网络进行癌症类型预测

在前一部分中,我们已经看到了我们的数据(即特征和标签)是什么样的。现在,在这一部分中,我们尝试根据标签对这些样本进行分类。然而,正如我们所看到的,DL4J 需要数据以一个明确的格式,以便用于训练模型。所以让我们进行必要的数据预处理和特征工程。

数据集准备用于训练

由于我们没有任何未标记的数据,我想随机选择一些样本用于测试。还有一点是,特征和标签分为两个独立的文件。因此,我们可以先进行必要的预处理,然后将它们合并在一起,以便我们的预处理数据包含特征和标签。

然后剩余的部分将用于训练。最后,我们将训练集和测试集保存在单独的 CSV 文件中,以供以后使用。首先,让我们加载样本并查看统计信息。顺便说一下,我们使用 Spark 的 read() 方法,但也指定了必要的选项和格式:

Dataset<Row> data = spark.read()
                .option("maxColumns", 25000)
                .format("com.databricks.spark.csv")
                .option("header", "true") // Use first line of all files as header
                .option("inferSchema", "true") // Automatically infer data types
                .load("TCGA-PANCAN-HiSeq-801x20531/data.csv");// set your path accordingly

然后我们看到一些相关的统计信息,例如特征数量和样本数量:

int numFeatures = data.columns().length;
long numSamples = data.count();
System.*out*.println("Number of features: " + numFeatures);
System.*out*.println("Number of samples: " + numSamples);
>>>
 Number of features: 20532
 Number of samples: 801

因此,数据集中有来自 801 名不同患者的 801 个样本,且数据集的维度过高,共有 20532 个特征。此外,在 图 2 中,我们看到 id 列仅表示患者的匿名 ID,因此我们可以直接删除它:

Dataset<Row> numericDF = data.drop("id"); // now 20531 features left

然后我们使用 Spark 的 read() 方法加载标签,并指定必要的选项和格式:

Dataset<Row> labels = spark.read()
                .format("com.databricks.spark.csv")
                .option("header", "true") // Use first line of all files as header
                .option("inferSchema", "true") // Automatically infer data types
                .load("TCGA-PANCAN-HiSeq-801x20531/labels.csv");
labels.show(10);

我们已经看到标签数据框是什么样子的。我们将跳过 id 列。然而,Class 列是类别型的。正如我所说,DL4J 不支持对类别标签进行预测。因此,我们需要将其转换为数字型(更具体地说是整数型);为此,我将使用 Spark 的 StringIndexer()

首先,创建一个 StringIndexer();我们将索引操作应用于 Class 列,并将其重命名为 label。另外,我们会跳过空值条目:

StringIndexer indexer = new StringIndexer()
                        .setInputCol("Class")
                        .setOutputCol("label")
                        .setHandleInvalid("skip");// skip null/invalid values

然后我们通过调用 fit()transform() 操作来执行索引操作,如下所示:

Dataset<Row> indexedDF = indexer.fit(labels)
                         .transform(labels)
                         .select(col("label")
                         .cast(DataTypes.IntegerType));// casting data types to integer

现在让我们看一下索引化后的 DataFrame:

indexedDF.show();

太棒了!现在我们所有的列(包括特征和标签)都是数字类型。因此,我们可以将特征和标签合并成一个单一的 DataFrame。为此,我们可以使用 Spark 的join()方法,如下所示:

Dataset<Row> combinedDF = numericDF.join(indexedDF);

现在我们可以通过随机拆分combindedDF来生成训练集和测试集,如下所示:

Dataset<Row>[] splits = combinedDF.randomSplit(newdouble[] {0.7, 0.3});//70% for training, 30% for testing
Dataset<Row> trainingData = splits[0];
Dataset<Row> testData = splits[1];

现在让我们查看每个数据集中的样本数量:

System.out.println(trainingData.count());// number of samples in training set
System.out.println(testData.count());// number of samples in test set
>>>
 561
 240

因此,我们的训练集有561个样本,测试集有240个样本。最后,将这两个数据集保存为单独的 CSV 文件,供以后使用:

trainingData.coalesce(1).write()
                .format("com.databricks.spark.csv")
                .option("header", "false")
                .option("delimiter", ",")
                .save("data/TCGA_train.csv");

testData.coalesce(1).write()
                .format("com.databricks.spark.csv")
                .option("header", "false")
                .option("delimiter", ",")
                .save("data/TCGA_test.csv");

现在我们已经有了训练集和测试集,我们可以用训练集训练网络,并用测试集评估模型。考虑到高维度,我更愿意尝试一个更好的网络,比如 LSTM,它是 RNN 的改进变体。此时,关于 LSTM 的一些背景信息将有助于理解其概念。

循环神经网络和 LSTM 网络

如在第一章《深入浅出深度学习》中讨论的那样,RNN 利用来自过去的信息;它们可以在具有高度时间依赖性的数据中进行预测。更明确的架构可以在下图中找到,其中时间共享的权重w2(用于隐藏层)必须与w1(用于输入层)和w3(用于输出层)一起学习。从计算角度来看,RNN 处理许多输入向量来生成输出向量。想象一下,以下图中每个矩形都有一个向量深度和其他特殊的隐藏特性:

一个 RNN 架构,其中所有层的权重都必须随着时间学习。

然而,我们通常只需要查看最近的信息来执行当前任务,而不是存储的信息或很久以前到达的信息。这在 NLP 中的语言建模中经常发生。让我们来看一个常见的例子:

如果相关信息之间的间隔较小,RNN 可以学会利用过去的信息。

假设我们想开发一个基于深度学习的自然语言处理(NLP)模型,来预测基于前几个词的下一个词。作为人类,如果我们试图预测“Berlin is the capital of...”中的最后一个词,在没有更多上下文的情况下,下一个词最可能是Germany。在这种情况下,相关信息与位置之间的间隔较小。因此,RNN 可以轻松地学会使用过去的信息。

然而,考虑一个稍长的例子:“Reza grew up in Bangladesh. He studied in Korea. He speaks fluent...” 现在要预测最后一个词,我们需要更多的上下文。在这个句子中,最新的信息告诉网络,下一个词很可能是某种语言的名称。然而,如果我们将焦点缩小到语言层面,孟加拉国(前面的话语中的信息)的背景将是必需的。

如果相关信息与所需位置之间的间隙更大,RNN 无法学习使用过去的信息

在这里,信息之间的间隔比之前的例子要大,因此 RNN 无法学习映射这些信息。然而,深层网络中的梯度是通过多层网络中激活函数的多个梯度相乘(即乘积)来计算的。如果这些梯度非常小或接近零,梯度将容易消失。另一方面,当它们大于 1 时,可能会导致梯度爆炸。因此,计算和更新变得非常困难。让我们更详细地解释这些问题。

RNN 的这两个问题被统称为 梯度消失-爆炸 问题,直接影响模型的性能。实际上,反向传播时,RNN 会展开,形成 一个非常深 的前馈神经网络。RNN 无法获得长期上下文的原因正是这个现象;如果在几层内梯度消失或爆炸,网络将无法学习数据之间的高时间距离关系。

因此,RNN 无法处理长期依赖关系、梯度爆炸和梯度消失问题是其严重缺点。此时,LSTM 就作为救世主出现了。

正如名字所示,短期模式不会在长期中被遗忘。LSTM 网络由相互连接的单元(LSTM 块)组成。每个 LSTM 块包含三种类型的门:输入门、输出门和遗忘门。它们分别实现对单元记忆的写入、读取和重置功能。这些门不是二元的,而是模拟的(通常由一个 sigmoid 激活函数管理,映射到 [0, 1] 范围内,其中零表示完全抑制,1 表示完全激活)。

我们可以将 LSTM 单元看作一个基本的单元,但它的训练会更快收敛,并且能够检测数据中的长期依赖关系。现在问题是:LSTM 单元是如何工作的?基本 LSTM 单元的架构如下图所示:

LSTM 单元的框图

现在,让我们来看一下这个架构背后的数学符号。如果我们不看 LSTM 盒子内部的内容,LSTM 单元本身看起来与常规内存单元完全相同,只是它的状态被分为两个向量,h(t)c(t)

  • c 是单元

  • h(t) 是短期状态

  • c(t) 是长期状态

现在,让我们打开这个“盒子”!关键思想是网络能够学习以下内容:

  • 存储什么在长期状态中

  • 丢弃什么

  • 阅读内容

用更简化的话来说,在 STM 中,原始 RNN 的所有隐藏单元都被内存块替代,每个内存块包含一个内存单元,用于存储输入历史信息,并且有三个门用于定义如何更新信息。这些门是输入门、遗忘门和输出门。

这些门的存在使得 LSTM 单元能够记住信息并维持无限期。实际上,如果输入门低于激活阈值,单元将保持前一个状态;如果当前状态启用,它将与输入值相结合。顾名思义,遗忘门重置单元的当前状态(当其值被清零时),而输出门决定是否执行单元的值。

尽管长期状态会被复制并通过 tanh 函数传递,但在 LSTM 单元内部,需要在两个激活函数之间进行整合。例如,在下面的图示中,tanh 决定了哪些值需要加入状态,而这依赖于 sigmoid 门的帮助:

LSTM 单元结构的内部组织

现在,由于本书并不打算讲授理论,我想在这里停止讨论,但有兴趣的读者可以在 DL4J 网站上找到更多细节:deeplearning4j.org/lstm.html

数据集准备

在上一节中,我们准备了训练集和测试集。然而,我们需要做一些额外的工作,使它们能够被 DL4J 使用。更具体地说,DL4J 期望训练数据是数字类型,且最后一列是标签列,剩下的列是特征。

现在,我们将尝试按此方式准备我们的训练集和测试集。首先,我们展示保存训练集和测试集的文件:

String trainPath = "data/TCGA_train.csv"; // training set
String testPath = "data/TCGA_test.csv"; // test set

然后,我们定义所需的参数,如特征数量、类别数量和批量大小。在这里,我使用128作为batchSize,但根据需要进行调整:

int labelIndex = 20531;// number of features
int numClasses = 5; // number of classes to be predicted
int batchSize = 128; // batch size (feel free to adjust)

这个数据集用于训练:

DataSetIterator trainingDataIt = readCSVDataset(trainPath, batchSize, labelIndex, numClasses);

这是我们想要分类的数据:

DataSetIterator testDataIt = *readCSVDataset*(testPath, batchSize, labelIndex, numClasses);

如果你看到前面两行,你可以意识到readCSVDataset()本质上是一个读取 CSV 格式数据的包装器,然后RecordReaderDataSetIterator()方法将记录读取器转换为数据集迭代器。更多详细信息,请参见 使用 DL4J 重新审视泰坦尼克号生存预测 部分。

LSTM 网络构建

如在泰坦尼克号生存预测部分讨论的那样,一切从MultiLayerConfiguration开始,它组织这些层及其超参数。我们的 LSTM 网络由五层组成。输入层后面是三层 LSTM 层。最后一层是 RNN 层,也是输出层。

更技术性地说,第一层是输入层,然后有三层作为 LSTM 层。对于 LSTM 层,我们使用 Xavier 初始化权重。我们使用 SGD 作为优化算法,Adam 更新器,激活函数是 tanh。

最后,RNN 输出层有一个 softmax 激活函数,它为我们提供了一个类别的概率分布(即输出之和为1.0),并且 MCXENT 是多类别交叉熵损失函数。这个设置如图所示:

用于泰坦尼克号生存预测的多层感知机。它采用 20,531 个特征和固定的偏置(即 1),并生成多类别的输出。

为了创建 LSTM 层,DL4J 提供了 LSTM 和 GravesLSTM 类。后者是一个基于有监督序列标注的循环神经网络(详见 www.cs.toronto.edu/~graves/phd.pdf)的 LSTM 递归网络。

GravesLSTM 与 CUDA 不兼容。因此,建议在 GPU 上进行训练时使用 LSTM。否则,GravesLSTM 比 LSTM 更快。

现在,在我们开始创建网络之前,让我们定义所需的超参数,例如输入/隐藏/输出节点(神经元)的数量:

// Network hyperparameters
int numInputs = labelIndex; // number of input features
int numOutputs = numClasses; // number of classes to be predicted
int numHiddenNodes = 5000; // too many features, so 5000 sounds good

我们现在创建一个网络配置并进行网络训练。使用 DL4J,你可以通过在NeuralNetConfiguration.Builder()上调用layer来添加一个层,并指定它在层的顺序中的位置(以下代码中的零索引层是输入层):

// Create network configuration and conduct network training
MultiLayerConfiguration LSTMconf = new NeuralNetConfiguration.Builder()
            .seed(seed)    //Random number generator seed for improved repeatability. Optional.
            .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
            .weightInit(WeightInit.XAVIER)
            .updater(new Adam(0.001))
            .list()
            .layer(0, new LSTM.Builder()
                        .nIn(numInputs)
                        .nOut(numHiddenNodes)
                        .activation(Activation.RELU)
                        .build())
            .layer(1, new LSTM.Builder()
                        .nIn(numHiddenNodes)
                        .nOut(numHiddenNodes)
                        .activation(Activation.RELU)
                        .build())
            .layer(2, new LSTM.Builder()
                        .nIn(numHiddenNodes)
                        .nOut(numHiddenNodes)
                        .activation(Activation.RELU)
                        .build())
            .layer(3, new RnnOutputLayer.Builder()
                        .activation(Activation.SOFTMAX)
                        .lossFunction(LossFunction.MCXENT)
                        .nIn(numHiddenNodes)
                        .nOut(numOutputs)
                        .build())
            .pretrain(false).backprop(true).build();

最后,我们还指定了我们不需要进行任何预训练(这通常在 DBN 或堆叠自编码器中是必要的)。

网络训练

首先,我们使用之前的MultiLayerConfiguration创建一个MultiLayerNetwork。然后,我们初始化网络并开始在训练集上进行训练:

MultiLayerNetwork model = new MultiLayerNetwork(LSTMconf);
model.init();

log.info("Train model....");
for(int i=0; i<numEpochs; i++ ){
    model.fit(trainingDataIt);
 }

通常,这种类型的网络有许多超参数。让我们打印网络中的参数数量(以及每一层的参数数量):

Layer[] layers = model.getLayers();
int totalNumParams = 0;
for( int i=0; i<layers.length; i++ ){
         int nParams = layers[i].numParams();
        System.*out*.println("Number of parameters in layer " + i + ": " + nParams);
       totalNumParams += nParams;
}
System.*out*.println("Total number of network parameters: " + totalNumParams);
>>>
 Number of parameters in layer 0: 510655000
 Number of parameters in layer 1: 200035000
 Number of parameters in layer 2: 200035000
 Number of parameters in layer 3: 25005
 Total number of network parameters: 910750005

正如我所说,我们的网络有 9.1 亿个参数,这非常庞大。在调整超参数时,这也提出了很大的挑战。然而,我们将在常见问题解答部分看到一些技巧。

评估模型

一旦训练完成,接下来的任务就是评估模型。我们将评估模型在测试集上的表现。对于评估,我们将使用Evaluation()方法。这个方法创建一个评估对象,包含五个可能的类别。首先,我们将遍历每个测试样本,并从训练好的模型中获取网络的预测。最后,eval()方法会检查预测结果与真实类别的匹配情况:

*log*.info("Evaluate model....");
Evaluation eval = new Evaluation(5) // for 5 classes
while(testDataIt.hasNext()){
        DataSet next = testDataIt.next();
        INDArray output = model.output(next.getFeatureMatrix());
        eval.eval(next.getLabels(), output);
}
*log*.info(eval.stats());
*log*.info("****************Example finished********************");
>>>
 ==========================Scores========================================
  # of classes:    5
  Accuracy:        0.9950
  Precision:       0.9944
  Recall:          0.9889
  F1 Score:        0.9915
 Precision, recall & F1: macro-averaged (equally weighted avg. of 5 classes)
 ========================================================================
 ****************Example finished********************

哇!不可思议!我们的 LSTM 网络准确地分类了这些样本。最后,让我们看看分类器在每个类别中的预测情况:

Predictions labeled as 0 classified by model as 0: 82 times
 Predictions labeled as 1 classified by model as 1: 17 times
 Predictions labeled as 1 classified by model as 2: 1 times
 Predictions labeled as 2 classified by model as 2: 35 times
 Predictions labeled as 3 classified by model as 3: 31 times
 Predictions labeled as 4 classified by model as 4: 35 times

使用 LSTM 进行癌症类型预测的预测准确率异常高。我们的网络是不是欠拟合了?有没有办法观察训练过程?换句话说,问题是为什么我们的 LSTM 神经网络显示 100% 的准确率。我们将在下一节尝试回答这些问题。请继续关注!

常见问题解答(FAQ)

既然我们已经以一个可接受的准确度解决了泰坦尼克号生存预测问题,还有一些实际的方面需要考虑,这些方面不仅涉及此问题本身,也涉及整体的深度学习现象。在本节中,我们将看到一些可能已经在你脑海中的常见问题。这些问题的答案可以在附录 A 中找到。

  1. 我们不能使用 MLP 来解决癌症类型预测问题,处理这么高维的数据吗?

  2. RNN 类型的网络可以使用哪些激活函数和损失函数?

  3. 递归神经网络的最佳权重初始化方法是什么?

  4. 应该使用哪种更新器和优化算法?

  5. 在泰坦尼克号生存预测问题中,我们的准确率不高。可能的原因是什么?我们如何提高准确率?

  6. 使用 LSTM 进行癌症类型预测的预测准确率异常高。我们的网络是不是欠拟合了?有没有办法观察训练过程?

  7. 我应该使用哪种类型的 RNN 变种,也就是 LSTM 还是 GravesLSTM?

  8. 为什么我的神经网络会抛出 nan 分数值?

  9. 如何配置/更改 DL4J UI 端口?

总结

在本章中,我们学习了如何基于从 TCGA 收集的高维基因表达数据集对癌症患者进行肿瘤类型分类。我们的 LSTM 架构成功地达到了 100% 的准确率,这是非常出色的。然而,我们也讨论了很多与 DL4J 相关的方面,这些内容将对后续章节非常有帮助。最后,我们还解答了一些关于该项目、LSTM 网络和 DL4J 超参数/网络调优的常见问题。

在下一章中,我们将看到如何开发一个端到端项目,使用基于 Scala 和 DL4J 框架的 CNN 来处理多标签(每个实体可以属于多个类别)的图像分类问题,数据集来自真实的 Yelp 图像数据。我们还将在开始之前讨论一些 CNN 的理论方面。然而,我们会讨论如何调整超参数,以获得更好的分类结果。

问题的答案

问题 1 的答案:答案是肯定的,但不太舒适。这意味着像深度 MLP 或 DBN 这样的非常深的前馈网络可以通过多次迭代进行分类。

然而,坦率地说,MLP 是最弱的深度架构,对于像这样的高维数据并不理想。此外,自 DL4J 1.0.0-alpha 版本以来,DL4J 已弃用了 DBN。最后,我仍然想展示一个 MLP 网络配置,以防你想尝试。

// Create network configuration and conduct network training
MultiLayerConfiguration MLPconf = new NeuralNetConfiguration.Builder().seed(seed)
                .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
                .updater(new Adam(0.001)).weightInit(WeightInit.XAVIER).list()
                .layer(0,new DenseLayer.Builder().nIn(numInputs).nOut(32)
                        .weightInit(WeightInit.XAVIER)
                        .activation(Activation.RELU).build())
                .layer(1,new DenseLayer.Builder().nIn(32).nOut(64).weightInit(WeightInit.XAVIER)
                        .activation(Activation.RELU).build())
                .layer(2,new DenseLayer.Builder().nIn(64).nOut(128).weightInit(WeightInit.XAVIER)
                        .activation(Activation.RELU).build())
                .layer(3, new OutputLayer.Builder(LossFunction.XENT).weightInit(WeightInit.XAVIER)
                        .activation(Activation.SOFTMAX).weightInit(WeightInit.XAVIER).nIn(128)
                        .nOut(numOutputs).build())
                .pretrain(false).backprop(true).build();    

然后,只需将代码行 MultiLayerNetwork model = new MultiLayerNetwork(LSTMconf); 更改为 **MultiLayerNetwork** model = **new** **MultiLayerNetwork**(MLPconf);。读者可以在 CancerPreddictionMLP.java 文件中看到完整的源代码。

问题 2 的答案: 关于激活函数的选择,有两个方面需要注意。

隐藏层的激活 函数: 通常,ReLU 或 leakyrelu 激活函数是不错的选择。其他一些激活函数(如 tanh、sigmoid 等)更容易出现梯度消失问题。然而,对于 LSTM 层,tanh 激活函数仍然是常用的选择。

这里有个注意点:一些人不想使用修正线性单元(ReLU)的原因是,它在与平滑的非线性函数(例如在 RNN 中使用的 sigmoid)相比,表现得似乎不太好(更多内容请参见 arxiv.org/pdf/1312.4569.pdf)。即使 tanh 在 LSTM 中的效果也要好得多。因此,我在 LSTM 层使用了 tanh 作为激活函数。

输出层的激活 函数: 对于分类问题,建议使用 Softmax 激活函数并结合负对数似然/MCXENT。但是,对于回归问题,“IDENTITY”激活函数是一个不错的选择,损失函数使用 MSE。简而言之,选择取决于具体应用。

问题 3 的答案: 好吧,我们需要确保网络的权重既不太大也不太小。我不推荐使用随机初始化或零初始化;通常,Xavier 权重初始化是一个不错的选择。

问题 4 的答案: 除非 SGD 收敛得很好,否则 momentum/rmsprop/adagrad 优化器是一个不错的选择。然而,我常常使用 Adam 作为更新器,并且也观察到了良好的表现。

问题 5 的答案: 好吧,这个问题没有明确的答案。实际上,可能有几个原因。例如,可能我们没有选择合适的超参数。其次,数据量可能不足。第三,我们可能在使用其他网络,如 LSTM。第四,我们可能没有对数据进行标准化。

好吧,对于第三种方法,你当然可以尝试使用类似的 LSTM 网络;我在癌症类型预测中使用了它。对于第四种方法,标准化数据总是能带来更好的分类准确率。现在问题是:你的数据分布是什么样的?你是否正确地进行缩放?连续值必须在 -1 到 1、0 到 1 的范围内,或者是均值为 0、标准差为 1 的正态分布。

最后,我想给你一个关于 Titanic 示例中的数据标准化的具体例子。为此,我们可以使用 DL4J 的 NormalizerMinMaxScaler()。一旦我们创建了训练数据集迭代器,就可以实例化一个 NormalizerMinMaxScaler() 对象,然后通过调用 fit() 方法对数据进行标准化。最后,使用 setPreProcessor() 方法执行转换,如下所示:

NormalizerMinMaxScaler preProcessor = new NormalizerMinMaxScaler();
preProcessor.fit(trainingDataIt);
trainingDataIt.setPreProcessor(preProcessor);

现在,对于测试数据集迭代器,我们应用相同的标准化方法以获得更好的结果,但不调用fit()方法:

testDataIt.setPreProcessor(preProcessor);

更详细地说,NormalizerMinMaxScaler ()作为数据集的预处理器,将特征值(以及可选的标签值)标准化到最小值和最大值之间(默认情况下,是 0 到 1 之间)。读者可以在CancerPreddictionMLP.java文件中查看完整的源代码。在此标准化之后,我在类 1 上获得了稍微更好的结果,如下所示(你也可以尝试对类 0 做相同的操作):

==========================Scores========================================
 # of classes: 2
 Accuracy: 0.6654
 Precision: 0.7848
 Recall: 0.5548
 F1 Score: 0.2056
 Precision, recall & F1: reported for positive class (class 1 - "1") only
 ========================================================================

问题 6 的回答: 在实际情况中,神经网络达到 100%准确率是很罕见的。然而,如果数据是线性可分的,那么是有可能的!请查看以下散点图,图中黑线清楚地将红点和深蓝点分开:

非常清晰且线性可分的数据点

更技术性地说,由于神经元的输出(在通过激活函数之前)是输入的线性组合,因此由单个神经元组成的网络可以学习到这个模式。这意味着,如果我们的神经网络将这条线划对了,实际上是有可能达到 100%准确率的。

现在,回答第二部分:可能不是。为了证明这一点,我们可以通过观察训练损失、得分等,在 DL4J UI 界面上查看训练过程,DL4J UI 是用于在浏览器中实时可视化当前网络状态和训练进度的界面。

UI 通常用于帮助调整神经网络的参数,也就是选择超参数以获得良好的网络性能。这些内容已经包含在CancerPreddictionLSTM.java文件中,所以不用担心,继续进行即可。

步骤 1:将 DL4J 的依赖项添加到你的项目中

在下面的依赖标签中,_2.11后缀用于指定 Scala 版本,以便与 Scala Play 框架一起使用。你应该相应地进行设置:

<dependency>
    <groupId>org.deeplearning4j</groupId>
    <artifactId>deeplearning4j-ui_2.11</artifactId>
    <version>${dl4j.version}</version>
</dependency>

步骤 2:在项目中启用 UI

这相对简单。首先,你需要按照以下方式初始化用户界面后端:

UIServer uiServer = UIServer.*getInstance*();

接着,你需要配置网络信息存储的位置。然后可以添加 StatsListener 来收集这些信息:

StatsStorage statsStorage = new InMemoryStatsStorage();

最后,我们将 StatsStorage 实例附加到 UI 界面:

uiServer.attach(statsStorage); 
int listenerFrequency = 1;
model.setListeners(new StatsListener(statsStorage, listenerFrequency));

步骤 3:通过调用 fit()方法开始收集信息 当你在网络上调用fit方法时,信息将会被收集并传送到 UI 界面。

步骤 4:访问 UI 配置完成后,可以在localhost:9000/train访问 UI。现在,回答“我们的网络是否欠拟合?有没有方法观察训练过程?”我们可以在概览页面上观察到模型得分与迭代图表。如在deeplearning4j.org/visualization的模型调优部分所建议,我们得到了以下观察结果

  • 总体分数与迭代次数应随时间下降

  • 分数没有持续增长,而是在迭代过程中急剧下降

问题可能在于折线图中没有噪声,而这实际上是理想的情况(也就是说,折线应该在一个小范围内上下波动)。

现在为了解决这个问题,我们可以再次对数据进行归一化,并重新训练以查看性能差异。好吧,我希望大家自己去试试。还有一个提示是遵循我们在问题 5 中讨论的相同数据归一化方法。

LSTM 模型的得分随迭代次数变化

现在,还有一个观察结果值得提及。例如,梯度直到最后才消失,这从下图中可以看得更清楚:

LSTM 网络在不同迭代之间的梯度

最终,激活函数始终如一地发挥了作用,从下图中可以更清楚地看到这一点:

LSTM 网络的激活函数在不同层之间始终如一地发挥着作用

关键是还有许多因素需要考虑。然而,实际上,调整神经网络往往更多的是一种艺术而非科学,而且正如我所说,我们还没有考虑到许多方面。不过,不要担心;我们将在接下来的项目中看到它们。所以坚持住,让我们继续看下一个问题。

问题 7 的答案: LSTM 支持 GPU/CUDA,但 GravesLSTM 仅支持 CUDA,因此目前不支持 CuDNN。不过,如果你想要更快的训练和收敛,建议使用 LSTM 类型。

问题 8 的答案: 在训练神经网络时,反向传播涉及在非常小的梯度上进行乘法操作。这是由于在表示实数时有限精度的问题;非常接近零的值无法表示。

它引入了算术下溢问题,这种情况通常发生在像 DBN、MLP 或 CNN 这样的深度网络中。此外,如果你的网络抛出 NaN,那么你需要重新调整网络,以避免非常小的梯度。

问题 9 的答案: 你可以通过使用 org.deeplearning4j.ui.port 系统属性来设置端口。更具体地说,例如,要使用端口 9001,在启动时将以下内容传递给 JVM:

-Dorg.deeplearning4j.ui.port=9001

第三章:使用卷积神经网络进行多标签图像分类

在上一章中,我们开发了一个基于 LSTM 网络准确分类癌症患者的项目。这个问题在生物医学信息学中具有挑战性。不幸的是,当涉及到分类多媒体对象(如图像、音频或视频)时,线性机器学习模型和其他常规深度神经网络DNN)模型,如多层感知器MLP)或深度置信网络DBN),常常无法学习或建模图像中的非线性特征。

另一方面,卷积神经网络CNNs)可以用来克服这些限制。在 CNN 中,神经元之间的连接模式受到人类视觉皮层的启发,这种连接方式更准确地模拟了人类视觉,因此非常适合图像处理相关任务。因此,CNN 在多个领域取得了杰出的成功:计算机视觉、自然语言处理(NLP)、多媒体分析、图像搜索等。

考虑到这一动机,在本章中,我们将看到如何基于 Scala 和Deeplearning4jDL4J)框架,在真实的 Yelp 图像数据集上,开发一个端到端的项目来处理多标签(即每个实体可以属于多个类别)图像分类问题。在正式开始之前,我们还将讨论一些 CNN 的理论方面内容。尽管如此,我们也会讨论如何调整超参数,以获得更好的分类结果。简而言之,在整个端到端项目中,我们将学习以下主题:

  • 常规 DNN 的缺点

  • CNN 架构:卷积操作和池化层

  • 使用卷积神经网络(CNN)进行大规模图像分类

  • 常见问题(FAQ)

图像分类及深度神经网络(DNN)的缺点

在本项目中,我们将展示一个逐步的示例,展示如何使用 Scala 和 CNN 开发真实生活中的机器学习(ML)图像分类项目。一个这样的图像数据源是 Yelp,那里有很多照片和许多用户上传的照片。这些照片提供了跨类别的丰富本地商业信息。因此,使用这些照片,理解照片的背景并开发机器学习应用并非易事。我们将看到如何使用 DL4j 平台在 Java 中实现这一点。但是,在正式开始之前,了解一些理论背景是必要的。

在我们开始使用 CNN 开发端到端的图像分类项目之前,先来看看常规 DNN 的缺点。尽管常规 DNN 对于小图像(例如,MNIST 和 CIFAR-10)工作正常,但对于大规模和高质量图像,它因为需要大量的超参数而无法处理。例如,一张 200 × 200 的图像有 40,000 个像素,如果第一层只有 2,000 个神经元,那么仅第一层就会有 8000 万个不同的连接。因此,如果网络非常深,可能会有数十亿个参数。

CNN 通过使用部分连接层来解决这个问题。因为连续层只部分连接,而且由于其权重被大量复用,CNN 的参数远少于全连接的 DNN,这使得训练速度更快,减少了过拟合的风险,并且需要的训练数据大大减少。

此外,当 CNN 学会了可以检测特定特征的卷积核时,它可以在图像的任何位置检测该特征。相比之下,当 DNN 在某个位置学习到一个特征时,它只能在该特定位置检测到该特征。由于图像通常具有非常重复的特征,CNN 在图像处理任务(如分类)中能够比 DNN 更好地进行泛化,且需要的训练样本更少。

重要的是,DNN 并没有关于像素如何组织的先验知识:它不知道邻近的像素是相近的。CNN 的架构则嵌入了这种先验知识。较低的层通常识别图像中小区域的特征,而较高的层则将低级特征组合成更大的特征。这对于大多数自然图像来说效果很好,使得 CNN 相比 DNN 在处理图像时具有决定性的优势:

常规 DNN 与 CNN 的对比,其中每一层的神经元以 3D 排列

例如,在前面的图示中,左侧展示了一个常规的三层神经网络。右侧的 ConvNet 则将其神经元排列成三维(宽度、高度和深度),如图中某一层所示。CNN的每一层都将 3D 结构转化为神经元激活的 3D 输出结构。红色的输入层包含图像,因此其宽度和高度就是图像的尺寸,而深度则为三(红色、绿色和蓝色通道)。

因此,我们所看到的所有多层神经网络都由一长串神经元组成,我们必须在将图像输入网络之前,将其展平为 1D。然而,直接将 2D 图像输入 CNN 是可能的,因为 CNN 中的每一层都是以 2D 的形式表示的,这使得将神经元与其对应的输入进行匹配变得更加容易。我们将在接下来的部分中看到这方面的例子。

另一个重要的事实是,特征图中的所有神经元共享相同的参数,因此大大减少了模型中的参数数量。更重要的是,一旦 CNN 学会了在一个位置识别某个模式,它也可以在其他位置做到相同的事情。

CNN 架构

在 CNN 网络中,层与层之间的连接方式与 MLP 或 DBN 显著不同。卷积conv)层是 CNN 中的主要层类型,其中每个神经元都与输入图像的某个区域相连,这个区域称为感受野

更具体地说,在卷积神经网络(CNN)架构中,几个卷积层以级联方式连接:每个卷积层后面跟着一个整流线性单元ReLU)层,再是一个池化层,然后是更多的卷积层(+ReLU),接着是另一个池化层,依此类推。每个卷积层的输出是一组由单个核过滤器生成的特征图,然后这些特征图作为新的输入传递到下一层。在全连接层中,每个神经元生成一个输出,并跟随一个激活层(即 Softmax 层):

卷积神经网络(CNN)的概念架构

如前图所示,池化层通常放置在卷积层之后(即两个卷积层之间)。池化层将卷积区域划分为子区域,然后,使用最大池化或平均池化技术选择一个代表性值,以减少后续层的计算时间。通过这种方式,卷积神经网络(CNN)可以被视为一个特征提取器。为了更清晰地理解这一点,请参考以下图示:

卷积神经网络(CNN)是一个端到端的网络,既作为特征提取器,又作为分类器。通过这种方式,它可以在(给定足够的训练数据的条件下)准确识别给定输入图像的标签。例如,它可以分类输入图像为一只老虎。

特征对其空间位置的鲁棒性也得到了增强。更具体来说,当特征图作为图像属性并通过灰度图像时,它随着网络的推进逐渐变小,但它通常也会变得越来越深,因为会添加更多的特征图。卷积操作为这个问题提供了解决方案,因为它减少了自由参数的数量,使得网络可以更深而参数更少。

卷积操作

卷积是一个数学运算,它将一个函数滑动到另一个函数上并测量它们逐点相乘的完整性。卷积层可能是卷积神经网络中最重要的构建模块。对于第一个卷积层,神经元并不是连接到输入图像中的每一个像素,而是只连接到它们感受野中的像素(参考前面的图示),而第二个卷积层中的每个神经元仅连接到第一层中位于小矩形内的神经元:

每个卷积神经元仅处理其感受野内的数据

在 第二章《使用递归类型网络进行癌症类型预测》中,我们已经看到所有的多层神经网络(例如,MLP)都有由大量神经元组成的层,并且我们必须在将输入图像喂入网络之前将其展平为 1D。而在 CNN 中,每一层是 2D 表示的,这使得将神经元与其关联输入匹配变得更容易。

感受野用于通过强制相邻层之间的局部连接模式来利用空间局部性。

这种架构使得网络能够在第一个隐藏层集中处理低级特征,然后在下一个隐藏层将它们组合成更高级的特征,依此类推。这种分层结构在现实世界的图像中很常见,这也是 CNN 在图像识别中表现如此出色的原因之一。

池化和填充操作

一旦你理解了卷积层的工作原理,池化层就很容易掌握。池化层通常在每个输入通道上独立工作,因此输出深度与输入深度相同。或者,你可以对深度维度进行池化,正如我们接下来会看到的那样,在这种情况下,图像的空间维度(例如,高度和宽度)保持不变,但通道数会减少。让我们从 TensorFlow API 文档中看看池化层的正式定义(详细信息请参见 github.com/petewarden/tensorflow_makefile/blob/master/tensorflow/python/ops/nn.py):

“池化操作对输入张量进行矩形窗口扫描,为每个窗口计算一个归约操作(平均值、最大值或带有 argmax 的最大值)。每个池化操作使用一个称为 ksize 的矩形窗口,窗口之间通过偏移步幅进行分隔。例如,如果步幅都为 1,则使用每个窗口;如果步幅都为 2,则每个维度中使用每隔一个窗口,依此类推。”

类似于卷积层,池化层中的每个神经元与前一层中位于小矩形感受野内的有限数量的神经元相连接。然而,必须定义大小、步幅和填充类型。因此,总结来说,池化层的输出可以按以下方式计算:

output[i] = reduce(value[strides * i:strides * i + ksize])  

其中索引也会考虑在内,与填充值一起使用。换句话说,使用池化的目标是对输入图像进行子采样,以减少计算负载、内存使用和参数数量。这有助于避免训练阶段的过拟合。

池化神经元没有权重。因此,它只使用聚合函数(如最大值或均值)聚合输入。

卷积操作的空间语义依赖于所选择的填充方案。填充是增加输入数据大小的操作:

  • 对于 1D 输入:仅仅是一个数组附加一个常数,例如,c

  • 对于二维输入:一个矩阵被 c 包围

  • 对于多维输入(即 nD 输入):nD 超立方体被 c 包围

现在,问题是,常数 c 是什么?在大多数情况下(但并非总是如此),c 是零,称为 零填充。这一概念可以进一步分解为两种类型的填充,分别叫做 VALIDSAME,具体说明如下:

  • VALID 填充:仅丢弃最右侧的列(或最底部的行)。

  • SAME 填充:在这种方案中,填充均匀地应用于左侧和右侧。然而,如果需要添加的列数是奇数,则会额外在右侧添加一列。

我们在下面的图中图示了前面的定义。如果我们希望某一层与前一层具有相同的高度和宽度,通常会在输入周围添加零。这称为 SAME 或零填充。

SAME 这个术语意味着输出特征图与输入特征图具有相同的空间维度。

另一方面,零填充被引入以根据需要使形状匹配,填充均匀地应用于输入图的每一侧。而 VALID 填充表示没有填充,只丢弃最右侧的列(或最底部的行):

SAMEVALID 填充在 CNN 中的比较

在下面的图中,我们使用一个 2 × 2 池化核,步幅为 2 且没有填充。只有每个池化核中的最大输入值才会传递到下一层,其他的输入则被丢弃(我们稍后会看到这一点):

使用最大池化的示例,即下采样

全连接层(密集层)

在网络的最上层,添加了一个常规的全连接层(前馈神经网络或密集层),它的作用类似于一个可能由几个全连接层(+ReLU)组成的 MLP,最终层输出预测结果:通常使用 Softmax 层,它会输出多类分类的估计类概率。

到目前为止,我们已经具备了关于 CNN 和它们在图像分类中的架构的最基本理论知识。接下来是做一个动手项目,涉及大规模 Yelp 图像的分类。在 Yelp 上,有许多照片和用户上传的照片,这些照片提供了丰富的本地商业信息,涵盖多个类别。教会计算机理解这些照片的背景并不是一项容易的任务。

Yelp 的工程师们正在公司内部从事基于深度学习的图像分类项目(更多内容请见 engineeringblog.yelp.com/2015/10/how-we-use-deep-learning-to-classify-business-photos-at-yelp.html)。

使用 CNN 进行多标签图像分类

在本节中,我们将展示一个系统化的例子,介绍如何开发实际的机器学习项目来进行图像分类。然而,我们首先需要了解问题描述,以便知道需要进行什么样的图像分类。此外,在开始之前,了解数据集是必要的。

问题描述

如今,食物自拍和以照片为中心的社交叙事正成为社交趋势。因此,大量包含食物的自拍照和餐厅照片被上传到社交媒体和网站上。在许多情况下,食物爱好者还会提供书面评论,这些评论可以显著提升商家的知名度(例如餐厅)。

例如,数百万独立访客访问了 Yelp 网站,并写下了超过 1.35 亿条评论。此外,许多照片和用户正在上传照片。然而,商家可以发布照片并与客户交流。通过这种方式,Yelp 通过向本地商家出售广告赚钱。

一个有趣的事实是,这些照片提供了跨类别的丰富本地商户信息。因此,开发深度学习应用来理解这些照片的背景将是一项有用的任务。请查看以下截图以获取一些洞察:

从 Yelp 数据集中挖掘一些关于商家的见解

因此,如果我们得到属于某个商家的照片,我们需要构建一个模型,使其能够自动为餐厅的用户上传照片打上多个标签,以预测商家的属性。最终,该项目的目标是将 Yelp 照片转化为文字。

数据集描述

这个有趣项目的 Yelp 数据集是从 www.kaggle.com/c/yelp-restaurant-photo-classification 下载的。我们已获得 Yelp 的许可,前提是这些图片不会被重新分发。不过,您需要从 www.yelp.com/dataset 获取使用许可。

提交评论是很棘手的。当 Yelp 用户想要提交评论时,他们必须手动从 Yelp 社区注释的九个不同标签中选择餐厅的标签,这些标签与数据集相关联。具体如下:

  • 0适合午餐

  • 1适合晚餐

  • 2接受预订

  • 3户外座位

  • 4餐厅价格昂贵

  • 5提供酒水

  • 6有桌面服务

  • 7环境优雅

  • 8适合孩子

因此,这是一个多标签多类别分类问题,其中每个商家可以有一个或多个之前列出的九个特征。因此,我们必须尽可能准确地预测这些标签。数据集中有六个文件,如下所示:

  • train_photos.tgz:用于训练集的照片(234,842 张图片)

  • test_photos.tgz:将用作测试集的照片(237,152 张图像)

  • train_photo_to_biz_ids.csv:提供照片 ID 与商业 ID 之间的映射(234,842 行)

  • test_photo_to_biz_ids.csv:提供照片 ID 与商业 ID 之间的映射(1,190,225 行)

  • train.csv:这是主要的训练数据集,包括商业 ID 和其对应的标签(2000 行)

  • sample_submission.csv:一个示例提交文件——参考正确的格式来提交你的预测结果,包括 business_id 和相应的预测标签

删除无效图像

我不知道为什么,但每个图像文件夹(训练集和测试集)中也包含一些临时图像,这些图像的名称模式为 _*.jpg,但并不是真正的图像。因此,我使用以下 UNIX 命令将其删除:

$ find . -type f -name "._*.jpg" -exec rm -f {} ;

然后,我解压并将每个 .csv 文件复制到名为 label 的文件夹中。此外,我将训练图像和测试图像分别移动到 traintest 文件夹(即在 images 文件夹内)。简而言之,经过提取和复制后,我们项目中使用的文件夹结构如下。因此,最终的结构将如下所示:

大型电影评论数据集中的文件夹结构

整体项目的工作流程

既然我们已经知道这是一个多标签多分类图像分类问题,我们就必须处理多个实例问题 由于 DL4J 没有提供如何解决多标签多分类图像分类问题的示例,我找到 Andrew Brooks 的博客文章(见 brooksandrew.github.io/simpleblog/articles/convolutional-neural-network-training-with-dl4j/)为此项目提供了动机

我只是将餐厅的标签应用到所有与之相关的图像,并将每个图像当作一个单独的记录。更技术性一点来说,我将每个类别当作一个单独的二分类问题来处理。然而,在项目的开始部分,我们将看到如何在 Java 中将 .jpg 格式的图像读取为矩阵表示。接着,我们将进一步处理并准备这些图像,以便它们能够被卷积神经网络(CNN)接受。此外,由于图像的形状和大小并不统一,我们需要进行几轮图像预处理操作,例如将每张图像调整为统一的尺寸,再应用灰度滤镜:

卷积神经网络(CNN)在图像分类中的概念化视图

然后,我们在每个类别的训练数据上训练九个 CNN。一旦训练完成,我们会保存训练好的模型、CNN 配置和参数,以便之后可以恢复它们。接着,我们应用一个简单的聚合函数为每个餐厅分配类别,每个餐厅都有多个与之关联的图片,每张图片都有一个属于九个类别的概率向量。接下来,我们对测试数据进行评分,最后,我们使用测试图像评估模型。

现在,让我们看看每个 CNN 的结构。每个网络将有两个卷积层、两个子采样层、一个全连接层和一个输出层作为全连接层。第一层是卷积层,接着是一个子采样层,然后是另一个卷积层,接下来是一个子采样层,然后是一个全连接层,最后是一个输出层。我们稍后会看到每一层的具体结构。简而言之,Java 类(YelpImageClassifier.java)的工作流程如下:

  1. 我们从train.csv文件中读取所有的商家标签

  2. 然后,我们读取并创建一个从图像 ID 到商家 ID 的映射,格式为 imageID | busID

  3. 然后,我们从photoDir目录中生成一个图像列表进行加载和处理,这有助于我们检索某些数量图像的图像 ID

  4. 然后,我们读取并处理图像,生成 photoID | 向量的映射

  5. 我们将步骤 3 和步骤 4 的输出连接起来,以对齐商家特征、图像 ID 和标签 ID,从而提取图像特征

  6. 然后,我们为多标签设置构建九个 CNN,分别对应九个可能的标签

  7. 然后,我们训练所有的 CNN,并指定模型保存位置

  8. 步骤 2步骤 6会多次重复,以从测试集提取特征

  9. 最后,我们评估模型并将预测结果保存在 CSV 文件中

现在,让我们看看前面的步骤在高级图示中的样子,如下所示:

DL4j 图像处理管道,用于图像分类

内容太多了吗?别担心;我们现在将详细查看每个步骤。如果你仔细查看前面的步骤,你会发现步骤 1 到步骤 5 是图像处理和特征构造。然后,步骤 6 是训练九个 CNN,接着在步骤 7 中,我们保存训练好的 CNN,以便在结果提交时恢复它们。

图像预处理

当我尝试开发这个应用程序时,我发现照片的形状和大小各不相同:有些图片是高的,有些是宽的,有些是外景的,有些是室内的,而且大部分是食物。此外,图像的形状也各不相同(尽管大多数图像大致是方形的),像素数量也不同,其中许多图像的尺寸正好是 500 x 375:

调整大小后的图(左侧是原始的高图,右侧是方形图)

我们已经看到,卷积神经网络(CNN)无法处理形状和大小各异的图像。虽然有很多强大且高效的图像处理技术可以提取出感兴趣区域ROI),但说实话,我并不是图像处理方面的专家,所以我决定将这一步骤保持简单。简单来说,我将所有图像都做成正方形,但仍然尽量保持它们的质量。问题在于,ROI 在大多数情况下是集中在图像中心的。所以,仅捕捉每个图像的中间正方形并不是一项简单的任务。不过,我们还需要将每个图像转换为灰度图像。让我们将不规则形状的图像裁剪为正方形。看看下面的图像,左侧是原始图像,右侧是裁剪后的正方形图像。

我们已经生成了一个正方形图像,但我们是如何做到的呢?首先,我检查了图像的高度和宽度是否相同,然后对图像进行了调整大小。在另外两种情况下,我裁剪了图像的中心区域。以下方法完成了这个任务(但是请随时执行SquaringImage.java脚本以查看输出):

private static BufferedImage makeSquare(BufferedImage img) {
        int w = img.getWidth();
        int h = img.getHeight();
        int dim = Math.min(w, h);

        if (w == h) {
            return img;
        } else if (w > h) {
            return Scalr.crop(img, (w - h) / 2, 0, dim, dim);
        } else {
            return Scalr.crop(img, 0, (h - w) / 2, dim, dim);
        }
    }

做得很好!现在我们所有的训练图像都已经变成正方形,接下来的步骤是使用导入预处理任务来调整它们的大小。我决定将所有图像的大小调整为 128 x 128。让我们看看调整大小后的图像是什么样子的(原始图像):

图像调整大小(分别为 256 x 256、128 x 128、64 x 64 和 32 x 32)

以下方法完成了这个任务(但是请随时执行imageUtils.java脚本以查看演示):

// resize pixels
    public static BufferedImage resizeImg(BufferedImage img, int width, int height) {
        return Scalr.resize(img, Scalr.Method.BALANCED, width, height);
    }

顺便说一下,关于图像的调整大小和裁剪正方形,我使用了一些内置的图像读取包和一些第三方处理包:

import javax.imageio.ImageIO;
import org.imgscalr.Scalr;

要使用上述包,请在 Maven 友好的pom.xml文件中添加以下依赖项(有关依赖项的完整列表,请参阅本章提供的pom.xml文件):

<dependency>
      <groupId>org.imgscalr</groupId>
      <artifactId>imgscalr-lib</artifactId>
      <version>4.2</version>
</dependency>
<dependency>
      <groupId>org.datavec</groupId>
      <artifactId>datavec-data-image</artifactId>
      <version>${dl4j.version}</version>
</dependency>

处理彩色图像更具趣味性且效果更好,基于 DL4J 的 CNN 也可以处理彩色图像。然而,为了简化计算,使用灰度图像更为理想。尽管如此,这种方法可以使整体表示更加简单且节省空间。

我们来举个例子:我们将每个 256 x 256 像素的图像调整大小,这样它的特征数就变成了 16,384,而不是像彩色图像那样是 16,384 x 3(因为彩色图像有三个 RGB 通道)(执行GrayscaleConverter.java来查看演示)。让我们看看转换后的图像会是什么样子:

左侧是原始图像,右侧是经过 RGB 均值化处理的灰度图像

上述转换是通过两个方法实现的,分别是pixels2Gray()makeGray()。前者将 RGB 像素转换为相应的灰度像素。让我们来看一下这个方法的签名:

private static int pixels2Gray(int R, int G, int B) {
        return (R + G + B) / 3;
    }
private static BufferedImage makeGray(BufferedImage testImage) {
        int w = testImage.getWidth();
        int h = testImage.getHeight();
        for (int w1 = 0; w1 < w; w1++) {
            for (int h1 = 0; h1 < h; h1++) {
                int col = testImage.getRGB(w1, h1);
                int R = (col & 0xff0000) / 65536;
                int G = (col & 0xff00) / 256;
                int B = (col & 0xff);
                int graycol = pixels2Gray(R, G, B);
                testImage.setRGB(w1, h1, new Color(graycol, graycol, graycol).getRGB());
            }
        }
        return testImage;
    }

那么,背后发生了什么?我们将所有之前的三步操作链接在一起:先将所有图像变成正方形,然后将它们转换为 256 x 256 的大小,最后将调整大小后的图像转换为灰度图像(假设x是待转换的图像):

convertedImage = ImageIO.read(new File(x))
          .makeSquare()
          .resizeImg(resizeImgDim, resizeImgDim) // (128, 128)
         .image2gray();

因此,总结来说,现在所有图像都是灰度图,但只有在平方和调整大小之后。以下图像可以给我们一些关于转换步骤的概念:

调整大小的图像(左边是原始的高图,右边是平方后的图)

之前的链式操作也带来了一些额外的工作。现在,将这三步代码整合在一起,我们终于可以准备好所有图像:

//imageUtils.java
public class imageUtils {
    // image 2 vector processing
    private static Integer pixels2gray(Integer red, Integer green, Integer blue){
        return (red + green + blue) / 3;
    }
    private static List<Integer> pixels2color(Integer red, Integer green, Integer blue) {
        return Arrays.asList(red, green, blue);
    }

private static <T> List<T> image2vec(BufferedImage img, Function<Triple<Integer, Integer, Integer>, T> f) {
        int w = img.getWidth();
        int h = img.getHeight();

        ArrayList<T> result = new ArrayList<>();
        for (int w1 = 0; w1 < w; w1++ ) {
            for (int h1 = 0; h1 < h; h1++) {
                int col = img.getRGB(w1, h1);
                int red =  (col & 0xff0000) / 65536;
                int green = (col & 0xff00) / 256;
                int blue = (col & 0xff);
                result.add(f.apply(new Triple<>(red, green, blue)));
            }
        }
        return result;
    }

    public static List<Integer> image2gray(BufferedImage img) {
        return image2vec(img, t -> pixels2gray(t.getFirst(), t.getSecond(), t.getThird()));
    }

    public static List<Integer> image2color(BufferedImage img) {
        return image2vec(img, t -> pixels2color(t.getFirst(), t.getSecond(), t.getThird()))
                .stream()
                .flatMap(l -> l.stream())
                .collect(Collectors.toList());
    }

    // make image square
    public static BufferedImage makeSquare(BufferedImage img) {
        int w = img.getWidth();
        int h = img.getHeight();
        int dim = Math.min(w, h);

        if (w == h) {
            return img;
        } else if (w > h) {
            return Scalr.crop(img, (w-h)/2, 0, dim, dim);
        } else  {
            return Scalr.crop(img, 0, (h-w)/2, dim, dim);
        }
    }

    // resize pixels
public static BufferedImage resizeImg(BufferedImage img, int width, int height) {
        return Scalr.resize(img, Scalr.Method.BALANCED, width, height);
    }
}

提取图像元数据

到目前为止,我们已经加载并预处理了原始图像。然而,我们还不了解添加到图像上的元数据,而这些元数据是 CNN 学习所需要的。因此,是时候加载那些包含每个图像元数据的 CSV 文件了。

我写了一个名为readMetadata()的方法,用于以 CSV 格式读取这些元数据,以便它能被两个其他方法readBusinessLabelsreadBusinessToImageLabels使用。这三个方法在CSVImageMetadataReader.java脚本中定义。以下是readMetadata()方法的签名:

public static List<List<String>> readMetadata(String csv, List<Integer> rows) throws IOException {
        boolean defaultRows = rows.size() == 1 && rows.get(0) == -1;
        LinkedList<Integer> rowsCopy = null;
        if (!defaultRows) {
            rowsCopy = new LinkedList<>(rows);
        }
        try(BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(new File(csv))))) {
            ArrayList<List<String>> arrayList = new ArrayList<>();
            String line = bufferedReader.readLine();
            int i = 0;
            while (line != null) {
                if (defaultRows || rowsCopy.getFirst() == i) {
                    if (!defaultRows) {
                        rowsCopy.removeFirst();
                    }
                    arrayList.add(Arrays.asList(line.split(",")));
                }
                line = bufferedReader.readLine();
                i++;
            }
            return arrayList;
        }
    }

readBusinessLabels()方法将业务 ID 映射到标签,形式为 businessID | Set(labels):

public static Map<String, Set<Integer>> readBusinessLabels(String csv) throws IOException {
        return readBusinessLabels(csv, DEFAULT_ROWS);
    }

public static Map<String, Set<Integer>> readBusinessLabels(String csv, List<Integer> rows) throws IOException {
        return readMetadata(csv, rows).stream()
                .skip(1)
                .map(l -> parseBusinessLabelsKv(l))
                .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
    }

readBusinessToImageLabels()方法将图像 ID 映射到业务 ID,形式为 imageID | businessID:

public static Map<Integer, String> readBusinessToImageLabels(String csv) throws IOException {
        return readBusinessToImageLabels(csv, DEFAULT_ROWS);
    }

public static Map<Integer, String> readBusinessToImageLabels(String csv, List<Integer> rows) throws IOException {
        return readMetadata(csv, rows).stream()
                .skip(1)
                .map(l -> parseBusinessToImageLabelsKv(l))
                .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue(), useLastMerger()));
    }

图像特征提取

到目前为止,我们已经看到了如何预处理图像并通过将它们与原始图像链接来提取图像元数据。现在,我们需要从这些预处理过的图像中提取特征,以便将它们输入到 CNN 中。

我们需要进行特征提取的映射操作,用于业务、数据和标签。这三个操作将确保我们不会丢失任何图像的来源(参见imageFeatureExtractor.java脚本):

  • 业务 ID | businessID 的业务映射

  • 形式为 imageID | 图像数据的数据映射

  • 业务 ID | 标签的标签映射

首先,我们必须定义一个正则表达式模式,从CSVImageMetadataReaderclass中提取 jpg 名称,该模式用于与训练标签进行匹配:

public static Pattern patt_get_jpg_name = Pattern.compile("[0-9]");

然后,我们提取与各自业务 ID 关联的所有图像 ID:

public static List<Integer> getImgIdsFromBusinessId(Map<Integer, String> bizMap, List<String> businessIds) {
        return bizMap.entrySet().stream().filter(x -> 
                 businessIds.contains(x.getValue())).map(Map.Entry::getKey)
                .collect(Collectors.toList());
    }

现在,我们需要加载并处理所有已经预处理过的图像,通过将它们与之前示例中提取的业务 ID 进行映射,来提取图像 ID:

public static List<String> getImageIds(String photoDir, Map<Integer, String> businessMap, 
                                       List<String> businessIds) {
        File d = new File(photoDir);
        List<String> imgsPath = Arrays.stream(d.listFiles()).map(f -> 
                                f.toString()).collect(Collectors.toList());
        boolean defaultBusinessMap = businessMap.size() == 1 && businessMap.get(-1).equals("-1");
        boolean defaultBusinessIds = businessIds.size() == 1 && businessIds.get(0).equals("-1");
        if (defaultBusinessMap || defaultBusinessIds) {
            return imgsPath;
        } else {
            Map<Integer, String> imgsMap = imgsPath.stream()
                    .map(x -> new AbstractMap.SimpleEntry<Integer, String>(extractInteger(x), x))
                    .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
            List<Integer> imgsPathSub = imageFeatureExtractor.getImgIdsFromBusinessId(
                                        businessMap, businessIds);
            return imgsPathSub.stream().filter(x -> imgsMap.containsKey(x)).map(x -> imgsMap.get(x))
                    .collect(Collectors.toList());
        }
    }

在上面的代码块中,我们从 photoDir 目录(原始图像所在的位置)获取了一张图像列表。ids参数是一个可选参数,用于从 photoDir 加载的图像中进行子集选择。到目前为止,我们已经成功提取出所有与至少一个业务相关的图像 ID。接下来的步骤是读取并处理这些图像,将它们转换为图像 ID → 向量映射:

public static Map<Integer, List<Integer>> processImages(List<String> imgs, int resizeImgDim, int nPixels) {
        Function<String, AbstractMap.Entry<Integer, List<Integer>>> handleImg = x -> {
            BufferedImage img = null;
            try {
                img = ImageIO.read(new File(x));
            } catch (IOException e) {
                e.printStackTrace();
            }
            img = makeSquare(img);
            img = resizeImg(img, resizeImgDim, resizeImgDim);
            List<Integer> value = image2gray(img);
            if(nPixels != -1) {
                value = value.subList(0, nPixels);
            }
            return new AbstractMap.SimpleEntry<Integer, List<Integer>>(extractInteger(x), value);
        };

        return imgs.stream().map(handleImg).filter(e -> !e.getValue().isEmpty())
                .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
    }

在前面的代码块中,我们将图像读取并处理成了 photoID → 向量映射。processImages() 方法接受以下参数:

  • imagesgetImageIds() 方法中的图像列表

  • resizeImgDim:重新调整方形图像的尺寸

  • nPixels:用于采样图像的像素数,以显著减少测试特征时的运行时间

干得好!我们距离提取训练 CNN 所需的数据只差一步。特征提取的最后一步是提取像素数据,该数据由四个对象组成,用来跟踪每个图像——即 imageID、businessID、标签和像素数据:

图像数据表示

因此,如前面的图示所示,主数据结构是由四种数据类型(即四个元组)构成——imgIDbusinessID像素数据向量标签

因此,我们应该有一个包含这些对象所有部分的类。别担心,我们所需要的一切都在 FeatureAndDataAligner.java 脚本中定义。一旦我们通过在 YelpImageClassifier.java 脚本(在主方法下)中使用以下代码行实例化 FeatureAndDataAligner,就能提供 businessMapdataMaplabMap

FeatureAndDataAligner alignedData = new FeatureAndDataAligner(dataMap, businessMap, Optional.*of*(labMap));

在这里,由于在测试数据上评分时没有这些信息,因此使用了 labMap 的选项类型——也就是说,它是可选的。现在,让我们来看一下我是如何实现的。我们从正在使用的类的构造函数开始,初始化前述数据结构:

private Map<Integer, List<Integer>> dataMap;
private Map<Integer, String> bizMap;
private Optional<Map<String, Set<Integer>>> labMap;
private List<Integer> rowindices;

public FeatureAndDataAligner(Map<Integer, List<Integer>> dataMap, Map<Integer, String> bizMap, Optional<Map<String, Set<Integer>>> labMap) {
        this(dataMap, bizMap, labMap, dataMap.keySet().stream().collect(Collectors.toList()));
    }

现在,我们通过 FeatureAndDataAligner.java 类的构造函数初始化这些值,如下所示:

public FeatureAndDataAligner(Map<Integer, List<Integer>> dataMap, Map<Integer, String> bizMap, Optional<Map<String, Set<Integer>>> labMap,List<Integer> rowindices) {
        this.dataMap = dataMap;
        this.bizMap = bizMap;
        this.labMap = labMap;
        this.rowindices = rowindices;
    }

现在,在对齐数据时,如果 labMap 为空——也就是没有提供训练数据——也可以使用以下方法:

public FeatureAndDataAligner(Map<Integer, List<Integer>> dataMap, Map<Integer, String> bizMap) {
        this(dataMap, bizMap, Optional.empty(), dataMap.keySet().stream().collect(Collectors.toList()));
    }

现在,我们需要将图像 ID 和图像数据与业务 ID 对齐。为此,我编写了 BusinessImgageIds() 方法:

public List<Triple<Integer, String, List<Integer>>> alignBusinessImgageIds(Map<Integer, List<Integer>> dataMap, Map<Integer, String> bizMap) {
        return alignBusinessImgageIds(dataMap, bizMap, dataMap.keySet().stream().collect(Collectors.toList()));
    }   

实际实现位于以下重载方法中,如果图像没有业务 ID,则返回可选值:

public List<Triple<Integer, String, List<Integer>>> alignBusinessImgageIds(Map<Integer, List<Integer>> dataMap, Map<Integer, String> bizMap, List<Integer> rowindices) {
        ArrayList<Triple<Integer, String, List<Integer>>> result = new ArrayList<>();
        for (Integer pid : rowindices) {
            Optional<String> imgHasBiz = Optional.ofNullable(bizMap.get(pid));
            String bid = imgHasBiz.orElse("-1");
            if (dataMap.containsKey(pid) && imgHasBiz.isPresent()) {
               result.add(new ImmutableTriple<>(pid, bid, dataMap.get(pid)));
            }
        }
        return result;
    }

最后,如前面的图示所示,我们现在需要对齐标签,它是一个由 dataMapbizMaplabMaprowindices 组成的四元组列表:

private List<Quarta<Integer, String, List<Integer>, Set<Integer>>> alignLabels(Map<Integer, List<Integer>>   
                                                                   dataMap, Map<Integer, String>             
                                                                   bizMap,Optional<Map<String, 
 Set<Integer>>> labMap,  
                                                                   List<Integer> rowindices) {
        ArrayList<Quarta<Integer, String, List<Integer>, Set<Integer>>> result = new ArrayList<>();
        List<Triple<Integer, String, List<Integer>>> a1 = alignBusinessImgageIds(dataMap, 
                                                                                 bizMap, rowindices);
        for (Triple<Integer, String, List<Integer>> p : a1) {
            String bid = p.getMiddle();
            Set<Integer> labs = Collections.emptySet();
            if (labMap.isPresent() && labMap.get().containsKey(bid)) {
                 labs = labMap.get().get(bid);
            }
            result.add(new Quarta<>(p.getLeft(), p.getMiddle(), p.getRight(), labs));
        }
        return result;
    }

在前面的代码块中,Quarta 是一个帮助我们维护所需数据结构的 case 类,如下所示:

public static class Quarta <A, B, C, D> {
        public final A a;
        public final B b;
        public final C c;
        public final D d;

        public Quarta(A a, B b, C c, D d) {
            this.a = a;
            this.b = b;
            this.c = c;
            this.d = d;
        }
    }

最后,我们预先计算并保存数据,这样该方法在每次调用时就不需要重新计算了:

 private volatile List<Quarta<Integer, String, List<Integer>, Set<Integer>>> _data = null;
// pre-computing and saving data as a val so method does not need to re-compute each time it is called.
public List<Quarta<Integer, String, List<Integer>, Set<Integer>>> data() {
        if (_data == null) {
            synchronized (this) {
                if (_data == null) {
                    _data = alignLabels(dataMap, bizMap, labMap, rowindices);
                }
            }
        }
        return _data;
    }

最后,如前面代码块中所使用的,我们现在创建了一些获取方法,以便在每次调用时,我们可以轻松地为每个业务获取 图像 ID业务 ID、业务标签和图像:

// getter functions
public List<Integer> getImgIds() {
        return data().stream().map(e -> e.a).collect(Collectors.toList());
    }
public List<String> getBusinessIds() {
        return data().stream().map(e -> e.b).collect(Collectors.toList());
    }
public List<List<Integer>> getImgVectors() {
        return data().stream().map(e -> e.c).collect(Collectors.toList());
    }
public List<Set<Integer>> getBusinessLabels() {
        return data().stream().map(e -> e.d).collect(Collectors.toList());
    }
public Map<String, Integer> getImgCntsPerBusiness() {
        return getBusinessIds().stream().collect(Collectors.groupingBy(Function.identity())).entrySet()
                .stream().map(e -> new AbstractMap.SimpleEntry<>(e.getKey(), e.getValue().size()))
                .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue()));
    }

很好!到目前为止,我们已经成功提取了训练 CNN 所需的特征。然而,问题是当前形式下的特征仍然不适合直接输入到 CNN 中。这是因为我们只有特征向量而没有标签。因此,它需要另一个中间转换。

准备 ND4J 数据集

如前所述,我们需要进行中间转换,以准备包含特征向量和标签的训练集:图像中的特征,但标签来自业务标签。

为此,我们有makeND4jDataSets类(有关详细信息,请参见makeND4jDataSets.java)。该类从List[(imgID, bizID, labels, pixelVector)]数据结构中通过alignLables函数创建 ND4J 数据集对象。首先,我们使用makeDataSet()方法如下所示来准备数据集:

public static DataSet makeDataSet(FeatureAndDataAligner alignedData, int bizClass) {
        INDArray alignedXData = makeDataSetTE(alignedData);
        List<Set<Integer>> labels = alignedData.getBusinessLabels();
        float[][] matrix2 = labels.stream().map(x -> (x.contains(bizClass) ? new float[]{1, 0} 
                             : new float[]{0, 1})).toArray(float[][]::new);
        INDArray alignedLabs = toNDArray(matrix2);
        return new DataSet(alignedXData, alignedLabs);
    }

然后,我们还需要将前面的数据结构转换为INDArray,这样它就可以被 CNN 使用:

public static INDArray makeDataSetTE(FeatureAndDataAligner alignedData) {
        List<List<Integer>> imgs = alignedData.getImgVectors();
        double[][] matrix = new double[imgs.size()][];
        for (int i = 0; i < matrix.length; i++) {
            List<Integer> img = imgs.get(i);
            matrix[i] = img.stream().mapToDouble(Integer::doubleValue).toArray();
        }
        return toNDArray(matrix);
    }

在前面的代码块中,toNDArray()方法用于将 double 或 float 矩阵转换为INDArray格式:

// For converting floar matrix to INDArray
private static INDArray toNDArray(float[][] matrix) {
          return Nd4j.*create*(matrix);
             }
// For converting double matrix to INDArray
private static INDArray toNDArray(double[][] matrix) {
            return Nd4j.*create*(matrix);
                  }

太棒了!我们成功地从图像中提取了所有元数据和特征,并将训练数据准备成了 ND4J 格式,现在可以被基于 DL4J 的模型使用。然而,由于我们将使用 CNN 作为模型,我们仍然需要在网络构建过程中通过使用convolutionalFlat操作将这个二维对象转换为四维。无论如何,我们将在下一节中看到这一点。

训练、评估和保存训练好的 CNN 模型

到目前为止,我们已经看到了如何准备训练集。现在,我们面临更具挑战性的部分,因为我们必须用 234,545 张图像来训练我们的卷积神经网络(CNN),尽管测试阶段可以通过有限数量的图像来简化,例如,500 张图像。因此,最好使用 DL4j 的MultipleEpochsIterator以批量模式训练每个 CNN,这个数据集迭代器可以对数据集进行多次遍历。

MultipleEpochsIterator是一个数据集迭代器,用于对数据集进行多次遍历。更多信息请参见deeplearning4j.org/doc/org/deeplearning4j/datasets/iterator/MultipleEpochsIterator.html

网络构建

以下是重要超参数及其详细信息的列表。在这里,我将尝试构建一个五层的 CNN,如下所示:

  • 第一层有一个ConvolutionLayer,具有 6 x 6 卷积核、一个通道(因为它们是灰度图像)、步长为 2 x 2,并且有 20 个特征图,其中 ReLU 是激活函数:
ConvolutionLayer layer_0 = new ConvolutionLayer.Builder(6,6)
            .nIn(nChannels)
            .stride(2,2) // default stride(2,2)
            .nOut(20) // # of feature maps
            .dropOut(0.7) // dropout to reduce overfitting
            .activation(Activation.*RELU*) // Activation: rectified linear units
            .build();
  • 第一层有SubsamplingLayer最大池化,步长为 2x2。因此,通过使用步长,我们按 2 的因子进行下采样。注意,只有 MAX、AVG、SUM 和 PNORM 是支持的。这里,卷积核的大小将与上一个ConvolutionLayer的滤波器大小相同。因此,我们不需要显式定义卷积核的大小:
SubsamplingLayer layer_1 = new SubsamplingLayer
                .Builder(SubsamplingLayer.PoolingType.*MAX*)
                .stride(2, 2)
                .build();
  • 第二层是一个具有 6 x 6 卷积核、一个通道(因为它们是灰度图像)、步长为 2 x 2、并且有 20 个输出神经元的ConvolutionLayer,其激活函数为 RELU。我们将使用 Xavier 进行网络权重初始化:
ConvolutionLayer layer_2= new ConvolutionLayer.Builder(6, 6)
            .stride(2, 2) // nIn need not specified in later layers
            .nOut(50)
            .activation(Activation.*RELU*) // Activation: rectified linear units
            .build();
  • 层 3 有 SubsamplingLayer 最大池化,并且步幅为 2 x 2。因此,使用步幅,我们将数据下采样 2 倍。请注意,只有 MAX、AVG、SUM 和 PNORM 被支持。这里,卷积核大小将与上一层 ConvolutionLayer 的滤波器大小相同。因此,我们不需要显式地定义卷积核大小:
SubsamplingLayer layer_3 = new SubsamplingLayer
           .Builder(SubsamplingLayer.PoolingType.*MAX*)
           .stride(2, 2)
           .build();
  • 层 4 有一个 DenseLayer,即一个完全连接的前馈层,通过反向传播进行训练,具有 50 个神经元,并且使用 ReLU 作为激活函数。需要注意的是,我们不需要指定输入神经元的数量,因为它假定输入来自前面的 ConvolutionLayer
DenseLayer layer_4 = new DenseLayer.Builder() // Fully connected layer
               .nOut(500)
               .dropOut(0.7) // dropout to reduce overfitting
              .activation(Activation.*RELU*) // Activation: rectified linear units 
             .build();
  • 层 5 是一个 OutputLayer,具有两个输出神经元,由 softmax 激活驱动(即,对类别的概率分布)。我们使用 XENT(即二分类的交叉熵)作为损失函数来计算损失:
OutputLayer layer_5 = new OutputLayer.Builder(LossFunctions.LossFunction.*XENT*)
          .nOut(outputNum) // number of classes to be predicted
          .activation(Activation.*SOFTMAX*)
          .build();

除了这些层,我们还需要执行图像展平——即,将一个 2D 对象转换为一个 4D 可消耗的对象,使用 CNN 层通过调用以下方法:

convolutionalFlat(numRows, numColumns, nChannels))

因此,总结来说,使用 DL4J,我们的 CNN 将如下所示:

MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
           .seed(seed)a
           .miniBatch(true) // for MultipleEpochsIterator
           .optimizationAlgo(OptimizationAlgorithm.*STOCHASTIC_GRADIENT_DESCENT*)
           .updater(new Adam(0.001)) // Aama for weight updater
           .weightInit(WeightInit.*XAVIER*) //Xavier weight init
           .list()
                    .layer(0, layer_0)
                    .layer(1, layer_1)
                    .layer(2, layer_2)
                    .layer(3, layer_3)
                    .layer(4, layer_4)
                   .layer(5, layer_5)
            .setInputType(InputType.*convolutionalFlat*(numRows, numColumns, nChannels))
            .backprop(true).pretrain(false)
            .build();

与训练相关的其他重要方面如下所示:

  • 样本数量:如果你在没有 GPU 的情况下训练所有图像,也就是使用 CPU,那么可能需要几天时间。当我尝试使用 50,000 张图像时,在一台配备 Core i7 处理器和 32 GB 内存的机器上,花费了整整一天。现在,你可以想象整个数据集训练需要多长时间。此外,即使你以批处理模式进行训练,它也需要至少 256 GB 的内存。

  • 训练轮数:这是指对所有训练记录进行迭代的次数。由于时间限制,我进行了 10 轮训练。

  • 批次数量:这是每个批次中的记录数,例如,32、64 和 128。我使用了 128。

现在,使用前面的超参数,我们可以开始训练我们的 CNN。以下代码完成了这个任务。首先,我们准备训练集,然后定义所需的超参数,接着,我们对数据集进行标准化,使得 ND4j 数据框架被编码,以便将所有被认为是正确的标签标记为 1,其他标签标记为 0。然后,我们对编码后的数据集进行行和标签的洗牌。

然后,我们需要分别使用 ListDataSetIteratorMultipleEpochsIterator 为数据集创建迭代器。一旦数据集转换为批处理模型,我们就可以开始训练构建的 CNN:

log.info("Train model....");
for( int i=0; i<nepochs; i++ ){
      model.fit(epochitTr);
}

一旦训练完成,我们可以在测试集上评估模型:

log.info("Evaluate model....");
Evaluation eval = new Evaluation(outputNum)

while (epochitTe.hasNext()) {
       DataSet testDS = epochitTe.next(nbatch);
       INDArray output = model.output(testDS.getFeatureMatrix());
       eval.eval(testDS.getLabels(), output);
}

当评估完成后,我们现在可以检查每个 CNN 的结果(运行 YelpImageClassifier.java 脚本):

System.*out*.println(eval.stats())
>>>
 ==========================Scores========================================
 Accuracy: 0.5600
 Precision: 0.5584
 Recall: 0.5577
 F1 Score: 0.5926
 Precision, recall & F1: reported for positive class (class 1 - "1") only
 ========================================================================

哎呀!不幸的是,我们没有看到好的准确率。然而,别担心,因为在 FAQ 部分,我们将看到如何改善这个问题。最后,我们可以保存逐层网络配置和网络权重,以便以后使用(即,在提交前进行评分):

if (!saveNN.isEmpty()) {
      // model config
      FileUtils.write(new File(saveNN + ".json"), model.getLayerWiseConfigurations().toJson());
      // model parameters
      DataOutputStream dos = new DataOutputStream(Files.*newOutputStream*(Paths.*get*(saveNN + ".bin")));
      Nd4j.*write*(model.params(), dos);
         }
    log.info("****************Example finished********************");
}

在之前的代码中,我们还保存了一个包含所有网络配置的 JSON 文件,以及一个保存所有 CNN 权重和参数的二进制文件。这是通过saveNN()loadNN()两个方法实现的,两个方法定义在NetwokSaver.java脚本中。首先,让我们看一下saveNN()方法的签名,如下所示:

public void saveNN(MultiLayerNetwork model, String NNconfig, String NNparams) throws IOException {
       // save neural network config
       FileUtils.write(new File(NNconfig), model.getLayerWiseConfigurations().toJson());

       // save neural network parms
      DataOutputStream dos = new  DataOutputStream(Files.*newOutputStream*(Paths.*get*(NNparams)));        
      Nd4j.*write*(model.params(), dos);
  }

这个思路既有远见又很重要,因为正如我之前所说的,你不会重新训练整个网络来评估新的测试集。例如,假设你只想测试一张图像。关键是,我们还提供了另一个名为loadNN()的方法,它可以读取我们之前创建的.json.bin文件并将其加载到MultiLayerNetwork中,这样就可以用来对新的测试数据进行评分。这个方法如下所示:

public static MultiLayerNetwork loadNN(String NNconfig, String NNparams) throws IOException {
        // get neural network config
        MultiLayerConfiguration confFromJson = MultiLayerConfiguration
                .fromJson(FileUtils.readFileToString(new File(NNconfig)));

        // get neural network parameters
        DataInputStream dis = new DataInputStream    (new FileInputStream(NNparams));
        INDArray newParams = Nd4j.read(dis);

        // creating network object
        MultiLayerNetwork savedNetwork = new MultiLayerNetwork(confFromJson);
        savedNetwork.init();
        savedNetwork.setParameters(newParams);

        return savedNetwork;
    }

对模型进行评分

我们将使用的评分方法很简单。它通过对图像级预测结果求平均,来为业务级标签分配标签。我是以简单的方式实现的,但你也可以尝试使用更好的方法。我做的是:如果所有图像属于类别0的概率平均值大于某个阈值(比如 0.5),则将该业务分配为标签0

public static INDArray scoreModel(MultiLayerNetwork model, INDArray ds) {
        return model.output(ds);
    }

然后,我从scoreModel()方法中收集了模型的预测结果,并将其与alignedData合并:

/** Take model predictions from scoreModel and merge with alignedData*/
public static List<Pair<String, Double>> aggImgScores2Business(INDArray scores,
                                         FeatureAndDataAligner alignedData) {
        assert(scores.size(0) == alignedData.data().size());
        ArrayList<Pair<String, Double>> result = new ArrayList<Pair<String, Double>>();

        for (String x : alignedData.getBusinessIds().stream().distinct().collect(Collectors.toList())) {
            //R irows = getRowIndices4Business(alignedData.getBusinessIds(), x);
            List<String> ids = alignedData.getBusinessIds();
            DoubleStream ret = IntStream.range(0, ids.size())
                    .filter(i -> ids.get(i).equals(x))
                    .mapToDouble(e -> scores.getRow(e).getColumn(1).getDouble(0,0));
            double mean = ret.sum() / ids.size();
            result.add(new ImmutablePair<>(x, mean));
        }
        return result;
    }

最后,我们可以恢复训练和保存的模型,重新加载它们,并生成 Kaggle 的提交文件。关键是,我们需要将每个模型的图像预测结果汇总为业务评分。

提交文件生成

为此,我编写了一个名为ResultFileGenerator.java的类。根据 Kaggle 网页的要求,我们需要以business_ids, labels格式写入结果。在这里,business_id是对应业务的 ID,标签是多标签预测。让我们看看我们如何轻松实现这一点。

首先,我们将每个模型的图像预测结果聚合为业务评分。然后,我们将前面的数据结构转换为一个列表,每个bizID对应一个元组(bizid, List[Double]),其中Vector[Double]是概率向量:

public static List<Pair<String, List<Double>>> SubmitObj(FeatureAndDataAligner alignedData,
                                               String modelPath,
                                               String model0,
                                               String model1,
                                               String model2,
                                               String model3,
                                               String model4,
                                               String model5,
                                               String model6,
                                               String model7,
                                               String model8) throws IOException {
        List<String> models = Arrays.asList(model0, model1, 
                                            model2, model3, 
                                            model4, model5, 
                                            model6, model7, model8);
        ArrayList<Map<String, Double>> big = new ArrayList<>();
        for (String m : models) {
            INDArray ds = makeND4jDataSets.makeDataSetTE(alignedData);
            MultiLayerNetwork model = NetworkSaver.loadNN(modelPath + m + ".json", 
                                                          modelPath + m + ".bin");
            INDArray scores = ModelEvaluation.scoreModel(model, ds);
            List<Pair<String, Double>> bizScores = ModelEvaluation.
                                                   aggImgScores2Business(scores, alignedData);
            Map<String, Double> map = bizScores.stream().collect(Collectors.toMap(
                                                                 e -> e.getKey(), e -> e.getValue()));
            big.add(map);
              }

        // transforming the data structure above into a List for each bizID containing a Tuple (bizid, 
           List[Double]) where the Vector[Double] is the the vector of probabilities: 
        List<Pair<String, List<Double>>> result = new ArrayList<>();
        Iterator<String> iter = alignedData.data().stream().map(e -> e.b).distinct().iterator();
        while (iter.hasNext()) {
            String x = iter.next();
            result.add(new MutablePair(x, big.stream().map(x2 -> 
                                       x2.get(x)).collect(Collectors.toList())));
        }
        return result;
    }

因此,一旦我们从每个模型中汇总结果后,就需要生成提交文件:

public static void writeSubmissionFile(String outcsv, List<Pair<String, List<Double>>> phtoObj, double thresh) throws FileNotFoundException {
        try (PrintWriter writer = new PrintWriter(outcsv)) {
            writer.println("business_ids,labels");
            for (int i = 0; i < phtoObj.size(); i++) {
                Pair<String, List<Double>> kv = phtoObj.get(i);
                StringBuffer sb = new StringBuffer();
                Iterator<Double> iter = kv.getValue().stream().filter(x -> x >= thresh).iterator();
                for (int idx = 0; iter.hasNext(); idx++) {
                    iter.next();
                    if (idx > 0) {
                        sb.append(' ');
                    }
                    sb.append(Integer.toString(idx));
                }
                String line = kv.getKey() + "," + sb.toString();
                writer.println(line);
            }
        }
    }

现在我们已经完成了到目前为止的所有操作,接下来我们可以结束并生成 Kaggle 的样本预测和提交文件。为了简便起见,我随机选取了 20,000 张图像以节省时间。感兴趣的读者也可以尝试为所有图像构建 CNN。然而,这可能需要几天时间。不过,我们将在常见问题部分提供一些性能调优的建议。

通过执行 main()方法来完成所有操作

让我们通过查看程序化方式来总结整体讨论(参见主YelpImageClassifier.java类):

public class YelpImageClassifier {
    public static void main(String[] args) throws IOException {
        Map<String, Set<Integer>> labMap = readBusinessLabels("Yelp/labels/train.csv");        
        Map<Integer, String> businessMap = readBusinessToImageLabels("Yelp/labels
                                                                      /train_photo_to_biz_ids.csv");
        List<String> businessIds = businessMap.entrySet().stream().map(e -> 
                                                    e.getValue()).distinct().collect(Collectors.toList());
        // 100 images
        List<String> imgs = getImageIds("Yelp/images/train/", businessMap, businessIds).subList(0, 100); 
        System.out.println("Image ID retreival done!");

        Map<Integer, List<Integer>> dataMap = processImages(imgs, 64);
        System.out.println("Image processing done!");

        FeatureAndDataAligner alignedData = new FeatureAndDataAligner(dataMap, 
                                                                      businessMap, Optional.of(labMap));
        //System.out.println(alignedData.data());
        System.out.println("Feature extraction done!");

        // Training one model for one class at a time
        CNNEpochs.trainModelEpochs(alignedData, 0, "results/models/model0"); 
        CNNEpochs.trainModelEpochs(alignedData, 1, "results/models/model1");
        CNNEpochs.trainModelEpochs(alignedData, 2, "results/models/model2");
        CNNEpochs.trainModelEpochs(alignedData, 3, "results/models/model3");
        CNNEpochs.trainModelEpochs(alignedData, 4, "results/models/model4");
        CNNEpochs.trainModelEpochs(alignedData, 5, "results/models/model5");
        CNNEpochs.trainModelEpochs(alignedData, 6, "results/models/model6");
        CNNEpochs.trainModelEpochs(alignedData, 7, "results/models/model7");
        CNNEpochs.trainModelEpochs(alignedData, 8, "results/models/model8");

        // processing test data for scoring
        Map<Integer, String> businessMapTE = readBusinessToImageLabels("Yelp/labels
                                                                        /test_photo_to_biz.csv");
        List<String> imgsTE = getImageIds("Yelp/images/test/", businessMapTE,                                     
                                  businessMapTE.values().stream()
                                  .distinct().collect(Collectors.toList()))
                                  .subList(0, 100);

        Map<Integer, List<Integer>> dataMapTE = processImages(imgsTE, 64); // make them 64x64
        FeatureAndDataAligner alignedDataTE = new FeatureAndDataAligner(dataMapTE, 
                                                  businessMapTE, Optional.empty());

        // creating csv file to submit to kaggle (scores all models)
        List<Pair<String, List<Double>>> Results = SubmitObj(alignedDataTE, "results/models/", 
                                                             "model0", "model1", "model2", 
                                                             "model3", "model4", "model5", 
                                                             "model6", "model7", "model8");
        writeSubmissionFile("results/kaggleSubmission/kaggleSubmitFile.csv", Results, 0.50);

       // example of how to score just model
        INDArray dsTE = makeND4jDataSets.makeDataSetTE(alignedDataTE);
        MultiLayerNetwork model = NetworkSaver.loadNN("results/models/model0.json", 
                                                      "results/models/model0.bin");
        INDArray predsTE = ModelEvaluation.scoreModel(model, dsTE);
        List<Pair<String, Double>> bizScoreAgg = ModelEvaluation
                                                .aggImgScores2Business(predsTE, alignedDataTE);
        System.out.println(bizScoreAgg);
    }
}

确实,我们还没有实现出色的分类精度。然而,我们仍然可以尝试通过调优超参数来提高效果。接下来的部分将提供一些见解。

常见问题(FAQ)

尽管我们已经解决了这个多标签分类问题,但我们得到的准确率仍然不尽如人意。因此,在本节中,我们将看到一些常见问题FAQ),这些问题可能已经浮现在你的脑海中。了解这些问题的答案可能有助于提高我们训练的 CNN 的准确性。这些问题的答案可以在附录中找到:

  1. 在实现此项目时,我可以尝试调优哪些超参数?

  2. 我的机器在运行这个项目时遇到了 OOP 错误。我该怎么办?

  3. 在使用完整图像训练网络时,我的 GPU 出现了 OOP 错误。我该怎么办?

  4. 我理解使用 CNN 在这个项目中的预测准确性仍然非常低。我们的网络是过拟合还是欠拟合?有没有办法观察训练过程的情况?

  5. 我非常感兴趣将这个项目实现为 Scala 版本。我该怎么做?

  6. 对于这个需要处理大规模图像的项目,我应该使用哪种优化器?

  7. 我们有多少个超参数?我还想查看每一层的超参数。

总结

本章中,我们已经看到如何使用 DL4J 框架上的 CNNs 开发一个实际应用。我们已经了解了如何通过九个 CNN 和一系列复杂的特征工程与图像处理操作来解决多标签分类问题。尽管我们未能实现更高的准确性,但我们鼓励读者在代码中调整超参数,并尝试在相同的数据集上使用相同的方法。

此外,推荐使用所有图像训练 CNN,以便网络可以获得足够的数据来学习 Yelp 图像中的特征。还有一个建议是改进特征提取过程,以便 CNN 能够获得更多的高质量特征。

在下一章中,我们将看到如何实现并部署一个实用的深度学习项目,该项目根据包含的单词将评论文本分类为正面或负面。将使用包含 50,000 条评论(训练和测试)的电影评论数据集。

将使用结合了 Word2Vec(即广泛应用于 NLP 的词嵌入技术)和 LSTM 网络的建模方法:将使用预训练的 Google 新闻向量模型作为神经词嵌入。然后,将训练向量和标签输入 LSTM 网络,以对其进行负面或正面情感的分类。此方法将评估在测试集上训练的模型。

问题解答

问题 1 的答案:以下超参数非常重要,必须进行调优以获得优化结果:

  • Dropout 用于随机关闭某些神经元(即特征检测器),以防止过拟合。

  • 学习率优化—Adagrad 可用于特征特定的学习率优化。

  • 正则化—L1 和/或 L2 正则化

  • 梯度归一化和裁剪

  • 最后,应用批量归一化以减少训练中的内部协方差偏移。

现在,对于 dropout,我们可以在每个卷积层和密集层中添加 dropout,如果出现过拟合,模型会特别调整以适应训练数据集,因此不会用于泛化。因此,尽管它在训练集上表现良好,但在测试数据集和随后的测试中的表现较差,因为它缺乏泛化能力。

无论如何,我们可以在 CNN 和 DenseLayer 上应用 dropout。现在,为了更好的学习率优化,可以使用 Adagrad 进行特征特定的学习率优化。然后,为了更好的正则化,我们可以使用 L1 和/或 L2。因此,考虑到这一点,我们的网络配置应该如下所示:

ConvolutionLayer layer_0 = new ConvolutionLayer.Builder(6, 6)
                .nIn(nChannels)
                .stride(2, 2) // default stride(2,2)
                .nOut(20) // # of feature maps
                .dropOut(0.7) // dropout to reduce overfitting
                .activation(Activation.RELU) // Activation: rectified linear units
                .build();
        SubsamplingLayer layer_1 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
                .stride(2, 2)
                .build();
        ConvolutionLayer layer_2 = new ConvolutionLayer.Builder(6, 6)
                .stride(2, 2) // nIn need not specified in later layers
                .nOut(50)
                .activation(Activation.RELU) // Activation: rectified linear units
                .build();
        SubsamplingLayer layer_3 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
                .stride(2, 2)
                .build();
        DenseLayer layer_4 = new DenseLayer.Builder() // Fully connected layer
                .nOut(500)
                .dropOut(0.7) // dropout to reduce overfitting
                .activation(Activation.RELU) // Activation: rectified linear units
                .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
                .gradientNormalizationThreshold(10)
                .build();
        OutputLayer layer_5 = new OutputLayer.Builder(LossFunctions.LossFunction.XENT)
                .nOut(outputNum) // number of classes to be predicted
                .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
                .gradientNormalizationThreshold(10)
                .activation(Activation.SOFTMAX)
                .build();
        MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder().seed(seed).miniBatch(true)
                .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT
                .l2(0.001) // l2 reg on all layers
                .updater(new AdaGrad(0.001))
                .weightInit(WeightInit.XAVIER) // Xavier weight init
                .list()
                        .layer(0, layer_0)
                        .layer(1, layer_1)
                        .layer(2, layer_2)
                        .layer(3, layer_3)
                        .layer(4, layer_4)
                         .layer(5, layer_5)
                .setInputType(InputType.convolutionalFlat(numRows, numColumns, nChannels))
                .backprop(true).pretrain(false) // Feedforward hence no pre-train.
                .build();

问题 2 的答案:由于分层架构的角度和卷积层的影响,训练 CNN 需要大量的 RAM。这是因为反向传播的反向传递需要所有在前向传播过程中计算出的中间值。幸运的是,在推理阶段,当下一个层计算完成时,当前层所占的内存会被释放。

同样,正如前面所述,DL4J 建立在 ND4J 之上,而 ND4J 利用了堆外内存管理。这使得我们可以控制堆外内存的最大使用量。我们可以设置org.bytedeco.javacpp.maxbytes系统属性。例如,对于单次 JVM 运行,您可以传递-Dorg.bytedeco.javacpp.maxbytes=1073741824来将堆外内存限制为 1GB。

问题 3 的答案:正如我之前提到的,用 Yelp 的 50,000 张图像训练 CNN 需要一天时间,使用的是一台拥有 i7 处理器和 32GB RAM 的机器。自然地,对所有图像进行此操作可能需要一周时间。因此,在这种情况下,使用 GPU 训练显然更为合理。

幸运的是,我们已经看到,DL4J 可以在分布式 GPU 上运行,也可以在本地运行。为此,它有我们所称的反向传播,或者它能够运行的不同硬件类型。最后,有一个有趣的问题是:如果我们的 GPU 内存不足该怎么办?好吧,如果在训练 CNN 时 GPU 内存不足,下面有五种方法可以尝试解决这个问题(除了购买更大内存的 GPU):

  • 减小小批量大小

  • 通过增大一个或多个层的步幅来减少维度,但不要使用 PCA 或 SVD

  • 除非必须使用非常深的网络,否则可以移除一层或多层。

  • 使用 16 位浮点数代替 32 位浮点数(但需要牺牲一定的精度)

  • 将 CNN 分布到多个设备(即 GPU/CPU)

欲了解更多关于使用 DL4J 在 GPU 上进行分布式训练的信息,请参考第八章,分布式深度学习 - 使用卷积 LSTM 网络进行视频分类

问题 4 的回答:确实,我们并没有获得良好的准确率。然而,有几个原因解释为什么我们没有进行超参数调优。其次,我们没有用所有图像来训练网络,因此我们的网络没有足够的数据来学习 Yelp 图像。最后,我们仍然可以从以下图表中看到模型与迭代得分及其他参数,因此我们可以看到我们的模型并没有过拟合:

模型与迭代得分及 LSTM 情感分析器的其他参数

问题 5 的回答:是的,这是可能的,因为 Scala 也是一种 JVM 语言,所以将这个 Java 项目转换成 Scala 不会太难。尽管如此,我的其中一本书也在 Scala 中解决了这个相同的问题。

这是参考资料:Md. Rezaul Karim,Scala 机器学习项目,Packt Publishing Ltd.,2018 年 1 月。请注意,在那本书中,我使用的是旧版本的 ND4J 和 DL4J,但我相信你可以通过遵循这个项目来进行升级。

问题 6 的回答:由于在 CNN 中,目标函数之一是最小化评估的成本,我们必须定义一个优化器。DL4j 支持以下优化器:

  • SGD(仅学习率)

  • Nesterov 的动量

  • Adagrad

  • RMSProp

  • Adam

  • AdaDelta

如需更多信息,感兴趣的读者可以参考 DL4J 页面上关于可用更新器的内容:deeplearning4j.org/updater

问题 7 的回答:只需在网络初始化后立即使用以下代码:

//Print the number of parameters in the network (and for each layer)
Layer[] layers = model.getLayers();
int totalNumParams = 0;
for( int i=0; i<layers.length; i++ ){
          int nParams = layers[i].numParams();
          System.*out*.println("Number of parameters in layer " + i + ": " + nParams);
          totalNumParams += nParams;
        }
System.*out*.println("Total number of network parameters: " + totalNumParams);
>>>
 Number of parameters in layer 0: 740
 Number of parameters in layer 1: 0
 Number of parameters in layer 2: 36050
 Number of parameters in layer 3: 0
 Number of parameters in layer 4: 225500
 Number of parameters in layer 5: 1002
 Total number of network parameters: 263292

这也告诉我们,子采样层没有任何超参数。尽管如此,如果你想创建一个 MLP 或 DBN,我们将需要数百万个超参数。然而,在这里,我们可以看到我们只需要 263,000 个超参数。

第四章:使用 Word2Vec 和 LSTM 网络进行情感分析

情感分析是一种系统化的方式,用于识别、提取、量化并研究情感状态和主观信息。这在自然语言处理NLP)、文本分析和计算语言学中广泛应用。本章演示了如何实现并部署一个实践性的深度学习项目,该项目基于文本中的词汇将评论文本分类为积极或消极。将使用一个包含 50k 评论(训练加测试)的电影评论大数据集。

将应用结合使用 Word2Vec(即 NLP 中广泛使用的词嵌入技术)和长短期记忆LSTM)网络的建模方法:将使用预训练的 Google 新闻向量模型作为神经词嵌入。然后,将训练向量与标签一起输入 LSTM 网络,分类为消极或积极情感。最后,对测试集进行已训练模型的评估。

此外,它还展示了如何应用文本预处理技术,如分词器、停用词移除和词频-逆文档频率TF-IDF)以及Deeplearning4jDL4J)中的词嵌入操作。

然而,它还展示了如何保存已训练的 DL4J 模型。之后,保存的模型将从磁盘恢复,并对来自 Amazon Cell、Yelp 和 IMDb 的其他小规模评论文本进行情感预测。最后,它还解答了与项目相关的一些常见问题及可能的前景。

以下主题将在本端到端项目中覆盖:

  • NLP 中的情感分析

  • 使用 Word2Vec 进行神经词嵌入

  • 数据集收集与描述

  • 使用 DL4J 保存和恢复预训练模型

  • 使用 Word2Vec 和 LSTM 开发情感分析模型

  • 常见问题解答(FAQ)

情感分析是一项具有挑战性的任务

自然语言处理中的文本分析旨在处理和分析大规模的结构化和非结构化文本,以发现隐藏的模式和主题,并推导出上下文的意义和关系。文本分析有很多潜在的应用场景,如情感分析、主题建模、TF-IDF、命名实体识别和事件提取。

情感分析包括许多示例应用场景,如分析人们在 Facebook、Twitter 等社交媒体上的政治观点。同样,分析 Yelp 上的餐厅评论也是情感分析的另一个优秀例子。通常使用像 OpenNLP 和 Stanford NLP 这样的 NLP 框架和库来实现情感分析。

然而,在使用文本分析情感时,尤其是分析非结构化文本时,我们必须找到一种强大且高效的特征工程方法,将文本转换为数字。然而,在模型训练之前,数据的转换可能经历多个阶段,然后再进行部署并最终执行预测分析。此外,我们应当预期对特征和模型属性进行进一步优化。我们甚至可以探索一种完全不同的算法,作为新工作流的一部分,重复整个任务流程。

当你看一行文本时,我们会看到句子、短语、单词、名词、动词、标点符号等等,这些东西组合在一起具有一定的意义和目的。人类在理解句子、单词、俚语、注解和上下文方面非常擅长。这是经过多年的练习和学习如何阅读/书写规范的语法、标点、感叹词等的结果。

例如,两个句子:DL4J 使预测分析变得简单预测分析使 DL4J 变得简单,可能导致相同的句子向量具有相同的长度,这个长度等于我们选择的词汇的大小。第二个问题是,“is”和“DL4J”这两个词的数值索引值都是 1,但我们的直觉告诉我们,“is”与“DL4J”相比并不重要。再来看第二个例子:当你在 Google 上搜索字符串hotels in Berlin时,我们希望获得与bnbmotellodgingaccommodation等相关的柏林的结果。

当普通词汇出现时,自然语言学习变得更加复杂。以“银行”(bank)这个词为例。它既与金融机构相关,也与水边的陆地相关。现在,如果一个自然句子中包含“bank”一词,并且与金融、金钱、国库和利率等词语一同出现,我们可以理解它的含义是前者。然而,如果它周围的词语是水、海岸、河流、湖泊等,那么它指的就是后者。那么,问题来了:我们是否可以利用这一概念来处理歧义和同义词,并使我们的模型学习得更好?

经典机器学习与基于深度学习的自然语言处理(NLP)

然而,自然语言句子也包含模糊词汇、俚语、琐碎的词语以及特殊字符,这些都使得整体理解和机器学习变得复杂。

我们已经看到如何使用独热编码(one-hot encoding)或字符串索引器(StringIndexer)技术将分类变量(甚至单词)转换为数字形式。然而,这类程序通常无法理解复杂句子中的语义,特别是对于长句子或甚至单个单词。因此,人类的词汇并没有天然的相似性概念。因此,我们自然不会尝试复制这种能力,对吧?

我们如何构建一个简单、可扩展、更快的方法来处理常规的文本或句子,并推导出一个词与其上下文词之间的关系,然后将它们嵌入到数十亿个词中,从而在数值向量空间中产生极好的词表示,以便机器学习模型可以使用它们呢?让我们通过 Word2Vec 模型来找到答案。

使用 Word2Vec 进行神经网络词嵌入。

Word2Vec 是一个两层的神经网络,它处理文本并将其转化为数值特征。通过这种方式,Word2Vec 的输出是一个词汇表,其中每个词都被嵌入到向量空间中。结果向量可以输入到神经网络中,以更好地理解自然语言。小说家 EL Doctorow 在他的书《Billy Bathgate》中以诗意的方式表达了这个思想:

“这就像数字是语言,就像语言中的所有字母都被转化为数字,因此它是每个人都以相同方式理解的东西。你失去了字母的声音,无论它们是咔嚓声、啪嗒声、触碰上腭的声音,还是发出‘哦’或‘啊’的声音,任何可能被误读的东西,或是它通过音乐或图像欺骗你心智的东西,全都消失了,连同口音一同消失,你获得了一种完全新的理解,一种数字的语言,一切变得像墙上的文字一样清晰。所以,正如我所说,有一个特定的时刻,是时候去读这些数字了。”

在使用 BOW 和 TF-IDF 时,所有词都被投射到相同的位置,并且它们的向量被平均化:我们考虑了词的重要性,但没有考虑在文档集合或单个文档中词序的重要性。

由于历史中词的顺序不会影响投影,BOW 和 TF-IDF 都没有可以处理这个问题的特征。Word2Vec 通过使用上下文预测目标词(使用连续词袋法CBOW))或使用一个词来预测目标上下文(这就是所谓的连续跳字法)来将每个词编码成一个向量。

  • N-gram 与跳字法(skip-gram):词是一次一个地读入向量,并在一定范围内来回扫描。

  • CBOW:CBOW 技术使用一个连续分布的上下文表示。

  • 连续跳字法(Continuous skip-gram):与 CBOW 不同,这种方法尝试最大化基于同一句话中的另一个词来分类当前词。

我曾经经历过,增加范围可以提高结果词向量的质量,但也会增加计算复杂度。由于距离较远的词通常与当前词的关系不如近距离的词密切,因此我们通过在训练样本中从这些远离的词中采样较少,来减少对它们的权重。由于模型构建和预测,所需的时间也会增加。

从架构的角度来看,可以通过以下图示看到对比分析,其中架构根据上下文预测当前单词,而 skip-gram 根据当前单词预测周围的单词:

CBOW 与 skip-gram(来源:Tomas Mikolov 等人,《高效估计向量空间中的词表示》,https://arxiv.org/pdf/1301.3781.pdf)

数据集和预训练模型说明

我们将使用大型电影评论数据集来训练和测试模型。此外,我们还将使用带有情感标签的句子数据集来对产品、电影和餐厅的评论进行单一预测。

用于训练和测试的大型电影评论数据集

前者是一个用于二元情感分类的数据集,包含的数据量远超过之前的基准数据集。该数据集可以从ai.stanford.edu/~amaas/data/sentiment/下载。或者,我使用了来自 DL4J 示例的 Java 方法,该方法也可以下载并提取此数据集。

我想特别感谢以下出版物:Andrew L. Maas, Raymond E. Daly, Peter T. Pham, Dan Huang, Andrew Y. Ng, 和 Christopher Potts。(2011),用于情感分析的词向量学习,《第 49 届计算语言学协会年会(ACL 2011)》。

该数据集包含 50,000 条电影评论及其对应的二元情感极性标签。评论被平均分配为 25,000 条用于训练集和测试集。标签的总体分布是平衡的(25,000 条正面评论和 25,000 条负面评论)。我们还包含了额外的 50,000 条未标记的文档,用于无监督学习。在标记的训练/测试集中,如果评论得分<=4 分(满分 10 分),则视为负面评论,而得分>=7 分则视为正面评论。然而,评分较为中立的评论未包含在数据集中。

数据集的文件夹结构

数据集中有两个文件夹,分别是traintest,用于训练集和测试集。每个文件夹下有两个子文件夹,分别是posneg,其中包含带有二进制标签(pos, neg)的评论。评论以名为id_rating.txt的文本文件存储,其中id是唯一的 ID,rating是 1-10 分的星级评分。查看以下图示可以更清楚地了解目录结构:

大型电影评论数据集中的文件夹结构

例如,test/pos/200_8.txt文件是一个正面标签的测试集示例,具有唯一 ID 200 和 IMDb 评分为 8/10。train/unsup/目录中的所有评分均为零,因为该部分数据集省略了评分。让我们看一个来自 IMDb 的示例正面评论:

"《布罗姆威尔高中》是一部卡通喜剧。它与其他一些关于学校生活的节目同时播放,比如《教师》。我在教育行业工作了 35 年,我认为《布罗姆威尔高中》的讽刺性比《教师》更贴近现实。为了生存而拼命挣扎的财务问题、那些能透彻看穿他们可悲老师虚伪的有洞察力的学生、整个情况的琐碎性,都让我想起了我所知道的学校和它们的学生。当我看到一集中有个学生一再试图烧掉学校时,我立刻回想起……在……高中。经典台词:督察:我来是为了开除你们的一位老师。学生:欢迎来到布罗姆威尔高中。我想我的很多同龄人可能觉得《布罗姆威尔高中》太夸张了。真遗憾,这并不夸张!"

因此,从前面的评论文本中,我们可以理解到,相应的观众给《布罗姆威尔高中》(一部关于位于伦敦南部的英国高中、英加合制的成人动画剧集,更多信息可以见 en.wikipedia.org/wiki/Bromwell_High)给出了积极的评价,即积极的情感。

情感标注数据集描述

情感标注句子数据集是从 UCI 机器学习库下载的,网址是 archive.ics.uci.edu/ml/datasets/Sentiment+Labelled+Sentences。这个数据集是 Kotzias 的研究成果,并且在以下出版物中使用:从群体到个体标签使用深度特征,Kotzias 等,KDD' 2015。

该数据集包含标注为积极或消极情感的句子,这些句子来源于产品、电影和餐馆的评论。评论是一个制表符分隔的文件,其中包含评论句子和得分,得分为 1(表示积极)或 0(表示消极)。我们来看一个来自 Yelp 的示例评论及其标签:

"我感到恶心,因为我几乎可以肯定那是人类的头发。"

在前面的评论文本中,得分为 0,因此它是一个负面评论,表达了客户的负面情感。另一方面,有 500 个积极句子和 500 个消极句子。

这些是从更大的评论数据集中随机选择的。作者试图选择那些有明确积极或消极含义的句子;目标是没有选择中立句子。这些评论句子来自三个不同的网站/领域,具体如下:

Word2Vec 预训练模型

与其从头开始生成一个新的 Word2Vec 模型,不如使用 Google 预训练的新闻词向量模型,它提供了 CBOW 和 skip-gram 架构的高效实现,用于计算单词的向量表示。这些表示随后可以用于许多 NLP 应用和进一步的研究。

可以从 code.google.com/p/word2vec/ 手动下载模型。Word2Vec 模型以文本语料库为输入,输出词向量。它首先从训练文本数据构建词汇表,然后学习单词的向量表示。

有两种方法可以实现 Word2Vec 模型:使用连续词袋模型(CBOW)和连续跳字模型(skip-gram)。Skip-gram 速度较慢,但对不常见的单词效果更好,而 CBOW 速度较快。

生成的词向量文件可以作为许多自然语言处理和机器学习应用中的特征。

使用 Word2Vec 和 LSTM 进行情感分析

首先,让我们定义问题。给定一条电影评论(原始文本),我们需要根据评论中的单词将其分类为正面或负面,即情感分析。我们通过结合 Word2Vec 模型和 LSTM 来实现:评论中的每个单词都通过 Word2Vec 模型向量化,然后输入到 LSTM 网络中。如前所述,我们将在大型电影评论数据集中训练数据。现在,以下是整体项目的工作流程:

  • 首先,我们下载电影/产品评论数据集

  • 然后我们创建或重用一个现有的 Word2Vec 模型(例如,Google News 词向量)

  • 然后我们加载每条评论文本,并将单词转换为向量,将评论转换为向量序列

  • 然后我们创建并训练 LSTM 网络

  • 然后我们保存训练好的模型

  • 然后我们在测试集上评估模型

  • 然后我们恢复训练好的模型,并评估情感标注数据集中的一条评论文本

现在,让我们看看如果我们遵循前面的工作流程,main() 方法会是什么样子:

public static void main(String[] args) throws Exception {
       Nd4j.getMemoryManager().setAutoGcWindow(10000);// see more in the FAQ section
       wordVectors = WordVectorSerializer.loadStaticModel(new File(WORD_VECTORS_PATH)); // Word2vec path   
       downloadAndExtractData(); // download and extract the dataset
       networkTrainAndSaver(); // create net, train and save the model
       networkEvaluator(); // evaluate the model on test set
       sampleEvaluator(); // evaluate a simple review from text/file.
}

让我们将前面的步骤分解成更小的步骤。我们将从使用 Word2Vec 模型的 dataset 准备开始。

使用 Word2Vec 模型准备训练集和测试集

现在,为了准备训练和测试数据集,首先我们必须下载以下三个文件:

  • 一个 Google 训练的 Word2Vec 模型

  • 一个大型电影评论数据集

  • 一个情感标注数据集

预训练的 Word2Vec 可从 code.google.com/p/word2vec/ 下载,然后我们可以手动设置 Google News 向量的位置:

public static final String WORD_VECTORS_PATH = "/Downloads/GoogleNews-vectors-negative300.bin.gz";

然后,我们将从以下 URL 下载并提取大型电影评论数据集。

public static final String DATA_URL = "http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz";

现在,让我们设置保存位置并提取训练/测试数据:

public static final String DATA_PATH = FilenameUtils.concat(System.getProperty("java.io.tmpdir"), "dl4j_w2vSentiment/");

现在,我们可以手动下载或在我们喜欢的位置提取数据集,或者使用以下方法以自动化方式完成。请注意,我对原始的 DL4J 实现做了些许修改:

public static void downloadAndExtractData() throws Exception {
  //Create directory if required
  File directory = new File(DATA_PATH);

  if(!directory.exists()) directory.mkdir();
  //Download file:
  String archizePath = DATA_PATH + "aclImdb_v1.tar.gz";
  File archiveFile = new File(archizePath);
  String extractedPath = DATA_PATH + "aclImdb";
  File extractedFile = new File(extractedPath);

  if( !archiveFile.exists() ){
    System.out.println("Starting data download (80MB)...");
    FileUtils.copyURLToFile(new URL(DATA_URL), archiveFile);
    System.out.println("Data (.tar.gz file) downloaded to " + archiveFile.getAbsolutePath());

    //Extract tar.gz file to output directory
    DataUtilities.extractTarGz(archizePath, DATA_PATH);
  } else {
    //Assume if archive (.tar.gz) exists, then data has already been extracted
    System.out.println("Data (.tar.gz file) already exists at " + archiveFile.getAbsolutePath());

    if( !extractedFile.exists()){
    //Extract tar.gz file to output directory
      DataUtilities.extractTarGz(archizePath, DATA_PATH);
    } else {
      System.out.println("Data (extracted) already exists at " + extractedFile.getAbsolutePath());
    }
  }
}

在前述方法中,使用 HTTP 协议从我提到的 URL 下载数据集,然后将数据集解压到我们提到的位置。为此,我使用了 Apache Commons 的TarArchiveEntryTarArchiveInputStreamGzipCompressorInputStream工具。感兴趣的读者可以在commons.apache.org/查看更多细节。

简而言之,我提供了一个名为DataUtilities.java的类,其中有两个方法,downloadFile()extractTarGz(),用于下载和解压数据集。

首先,downloadFile()方法接受远程 URL(即远程文件的 URL)和本地路径(即下载文件的位置)作为参数,如果文件不存在,则下载远程文件。现在,让我们看看签名是怎样的:

public static boolean downloadFile(String remoteUrl, String localPath) throws IOException {
  boolean downloaded = false;

  if (remoteUrl == null || localPath == null)
       return downloaded;

  File file = new File(localPath);
  if (!file.exists()) {
    file.getParentFile().mkdirs();
    HttpClientBuilder builder = HttpClientBuilder.create();
    CloseableHttpClient client = builder.build();
    try (CloseableHttpResponse response = client.execute(new HttpGet(remoteUrl))) {
      HttpEntity entity = response.getEntity();
      if (entity != null) {
        try (FileOutputStream outstream = new FileOutputStream(file)) {
          entity.writeTo(outstream);
          outstream.flush();
          outstream.close();
        }
      }
    }
    downloaded = true;
  }
  if (!file.exists())
  throw new IOException("File doesn't exist: " + localPath);
  return downloaded;
}

其次,extractTarGz()方法接受输入路径(即ism输入文件路径)和输出路径(即输出目录路径)作为参数,并将tar.gz文件解压到本地文件夹。现在,让我们看看签名是怎样的:

public static void extractTarGz(String inputPath, String outputPath) throws IOException {
  if (inputPath == null || outputPath == null)
       return;

  final int bufferSize = 4096;
  if (!outputPath.endsWith("" + File.separatorChar))
      outputPath = outputPath + File.separatorChar;

  try (TarArchiveInputStream tais = new TarArchiveInputStream( new GzipCompressorInputStream(new BufferedInputStream(
                                      new FileInputStream(inputPath))))) {
    TarArchiveEntry entry;
    while ((entry = (TarArchiveEntry) tais.getNextEntry()) != null) {
      if (entry.isDirectory()) {
        new File(outputPath + entry.getName()).mkdirs();
      } else {
        int count;
        byte data[] = newbyte[bufferSize];
        FileOutputStream fos = new FileOutputStream(outputPath + entry.getName());
        BufferedOutputStream dest = new BufferedOutputStream(fos, bufferSize);
        while ((count = tais.read(data, 0, bufferSize)) != -1) {
              dest.write(data, 0, count);
        }
        dest.close();
      }
    }
  }
}

现在,要使用前述方法,您必须导入以下包:

import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;

顺便提一下,Apache Commons 是一个专注于可重用 Java 组件各个方面的 Apache 项目。更多信息请见commons.apache.org/

最后,可以从archive.ics.uci.edu/ml/machine-learning-databases/00331/下载情感标签数据集。完成这些步骤后,接下来的任务是准备训练集和测试集。为此,我编写了一个名为SentimentDatasetIterator的类,它是一个专门为我们项目中使用的 IMDb 评论数据集定制的DataSetIterator。不过,它也可以应用于任何用于自然语言处理文本分析的文本数据集。这个类是SentimentExampleIterator.java类的一个小扩展,该类是 DL4J 示例提供的。感谢 DL4J 团队让我们的工作变得更轻松。

SentimentDatasetIterator类从情感标签数据集的训练集或测试集中获取数据,并利用 Google 预训练的 Word2Vec 生成训练数据集。另一方面,使用一个单独的类别(负面或正面)作为标签,预测每个评论的最终时间步。除此之外,由于我们处理的是不同长度的评论,并且只有在最终时间步有一个输出,我们使用了填充数组。简而言之,我们的训练数据集应该包含以下项,即 4D 对象:

  • 从每个评论文本中提取特征

  • 标签为 1 或 0(即,分别表示正面和负面)

  • 特征掩码

  • 标签掩码

那么,让我们从以下构造函数开始,它用于以下目的:

private final WordVectors wordVectors;
private final int batchSize;
private final int vectorSize;
private final int truncateLength;
private int cursor = 0;
private final File[] positiveFiles;
private final File[] negativeFiles;
private final TokenizerFactory tokenizerFactory;

public SentimentDatasetIterator(String dataDirectory, WordVectors wordVectors, 
                                 int batchSize, int truncateLength, boolean train) throws IOException {
  this.batchSize = batchSize;
  this.vectorSize = wordVectors.getWordVector(wordVectors.vocab().wordAtIndex(0)).length;
  File p = new File(FilenameUtils.concat(dataDirectory, "aclImdb/" + (train ? "train" : "test") 
                                         + "/pos/") + "/");
  File n = new File(FilenameUtils.concat(dataDirectory, "aclImdb/" + (train ? "train" : "test")
                                         + "/neg/") + "/");
  positiveFiles = p.listFiles();
  negativeFiles = n.listFiles();

  this.wordVectors = wordVectors;
  this.truncateLength = truncateLength;
  tokenizerFactory = new DefaultTokenizerFactory();
  tokenizerFactory.setTokenPreProcessor(new CommonPreprocessor());
}

在前面的构造函数签名中,我们使用了以下目的:

  • 用于跟踪 IMDb 评论数据集中正面和负面评论文件

  • 将评论文本分词,去除停用词和未知词

  • 如果最长的评论超过truncateLength,只取前truncateLength个词

  • Word2Vec 对象

  • 批量大小,即每个小批量的大小,用于训练

一旦初始化完成,我们将每个评论测试加载为字符串。然后,我们在正面和负面评论之间交替:

List<String> reviews = new ArrayList<>(num);
boolean[] positive = newboolean[num];

for(int i=0; i<num && cursor<totalExamples(); i++ ){
  if(cursor % 2 == 0){
    //Load positive review
    int posReviewNumber = cursor / 2;
    String review = FileUtils.readFileToString(positiveFiles[posReviewNumber]);
    reviews.add(review);
    positive[i] = true;
  } else {
    //Load negative review
    int negReviewNumber = cursor / 2;
    String review = FileUtils.readFileToString(negativeFiles[negReviewNumber]);
    reviews.add(review);
    positive[i] = false;
  }
  cursor++;
}

然后,我们将评论分词,并过滤掉未知词(即不包含在预训练的 Word2Vec 模型中的词,例如停用词):

List<List<String>> allTokens = new ArrayList<>(reviews.size());
int maxLength = 0;

for(String s : reviews){
  List<String> tokens = tokenizerFactory.create(s).getTokens();
  List<String> tokensFiltered = new ArrayList<>();
 for(String t : tokens ){
 if(wordVectors.hasWord(t)) tokensFiltered.add(t);
  }
  allTokens.add(tokensFiltered);
  maxLength = Math.*max*(maxLength,tokensFiltered.size());
}

然后,如果最长评论超过阈值truncateLength,我们只取前truncateLength个词:

if(maxLength > truncateLength) 
    maxLength = truncateLength;

然后,我们创建用于训练的数据。在这里,由于我们有两个标签,正面或负面,因此我们有reviews.size()个不同长度的示例:

INDArray features = Nd4j.create(newint[]{reviews.size(), vectorSize, maxLength}, 'f');
INDArray labels = Nd4j.create(newint[]{reviews.size(), 2, maxLength}, 'f');

现在,由于我们处理的是不同长度的评论,并且在最终时间步只有一个输出,我们使用填充数组,其中掩码数组在该时间步对该示例的数据存在时为 1,如果数据只是填充则为 0:

INDArray featuresMask = Nd4j.*zeros*(reviews.size(), maxLength);
INDArray labelsMask = Nd4j.*zeros*(reviews.size(), maxLength);

需要注意的是,为特征和标签创建掩码数组是可选的,并且也可以为空。然后,我们获取第i^(th)文档的截断序列长度,获取当前文档的所有词向量,并将其转置以适应第二和第三个特征形状。

一旦我们准备好词向量,我们将它们放入特征数组的三个索引位置中,该位置等于NDArrayIndex.interval(0, vectorSize),包括 0 和当前序列长度之间的所有元素。然后,我们为每个存在特征的位置分配 1,也就是在 0 和序列长度之间的区间。

现在,涉及标签编码时,我们将负面评论文本设置为[0, 1],将正面评论文本设置为[1, 0]。最后,我们指定此示例在最终时间步有输出:

for( int i=0; i<reviews.size(); i++ ){
  List<String> tokens = allTokens.get(i);
  int seqLength = Math.min(tokens.size(), maxLength);
  final INDArray vectors = wordVectors.getWordVectors(tokens.subList(0, seqLength)).transpose();
  features.put(new INDArrayIndex[] {
      NDArrayIndex.point(i), NDArrayIndex.all(), NDArrayIndex.interval(0, seqLength)
    }, vectors);

  featuresMask.get(new INDArrayIndex[] {NDArrayIndex.point(i), NDArrayIndex.interval(0,      
                   seqLength)}).assign(1);
  int idx = (positive[i] ? 0 : 1);
  int lastIdx = Math.min(tokens.size(),maxLength);

  labels.putScalar(newint[]{i,idx,lastIdx-1},1.0);
  labelsMask.putScalar(newint[]{i,lastIdx-1},1.0);
}

请注意,限制 NLP 中 dropout 应用的主要问题是它不能应用于循环连接,因为聚合的 dropout 掩码会随着时间的推移有效地将嵌入值归零——因此,前面的代码块中使用了特征掩码。

到此为止,所有必要的元素都已准备好,因此最后,我们返回包含特征、标签、featuresMasklabelsMaskNDArray(即 4D)数据集:

return new DataSet(features,labels,featuresMask,labelsMask);

更详细地说,使用DataSet,我们将创建一个具有指定输入INDArray和标签(输出)INDArray的数据集,并(可选地)为特征和标签创建掩码数组。

最后,我们将使用以下调用方式获取训练集:

SentimentDatasetIterator train = new SentimentDatasetIterator(DATA_PATH, wordVectors, 
                                                              batchSize, truncateReviewsToLength, true);

太棒了!现在我们可以在下一步中通过指定层和超参数来创建我们的神经网络。

网络构建、训练和保存模型

如《Titanic 生存预测》部分所讨论的那样,一切从MultiLayerConfiguration开始,它组织这些层及其超参数。我们的 LSTM 网络由五层组成。输入层后跟三层 LSTM 层。然后,最后一层是 RNN 层,也是输出层。

更技术性地讲,第一层是输入层,接着三层作为 LSTM 层。对于 LSTM 层,我们使用 Xavier 初始化权重,使用 SGD 作为优化算法,并配合 Adam 更新器,我们使用 Tanh 作为激活函数。最后,RNN 输出层具有 Softmax 激活函数,给出类别的概率分布(也就是说,它输出的总和为 1.0)以及 MCXENT,这是多类交叉熵损失函数。

为了创建 LSTM 层,DL4J 提供了 LSTM 和GravesLSTM类。后者是一个基于 Graves 的 LSTM 循环网络,但没有 CUDA 支持:使用 RNN 进行监督序列标注(详情请参见www.cs.toronto.edu/~graves/phd.pdf)。现在,在开始创建网络之前,首先让我们定义所需的超参数,如输入/隐藏/输出节点的数量(即神经元):

// Network hyperparameters: Truncate reviews with length greater than this
static int truncateReviewsToLength = 30;
static int numEpochs = 10; // number of training epochs
static int batchSize = 64; //Number of examples in each minibatch
static int vectorSize = 300; //Size of word vectors in Google Word2Vec
static int seed = 12345; //Seed for reproducibility
static int numClasses = 2; // number of classes to be predicted
static int numHiddenNodes = 256;

现在我们将创建一个网络配置并进行网络训练。使用 DL4J,你通过调用NeuralNetConfiguration.Builder()上的layer方法来添加一层,并指定它在层中的顺序(下面代码中的零索引层是输入层):

MultiLayerConfiguration LSTMconf = new NeuralNetConfiguration.Builder()
     .seed(seed)
     .updater(new Adam(1e-8)) // Gradient updater with Adam
     .l2(1e-5) // L2 regularization coefficient for weights
     .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
     .weightInit(WeightInit.XAVIER)
     .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
     .gradientNormalizationThreshold(1.0)     
     .trainingWorkspaceMode(WorkspaceMode.SEPARATE).inferenceWorkspaceMode(WorkspaceMode.SEPARATE)
     .list()
     .layer(0, new LSTM.Builder()
           .nIn(vectorSize)
           .nOut(numHiddenNodes)
           .activation(Activation.TANH)
           .build())
     .layer(1, new LSTM.Builder()
           .nIn(numHiddenNodes)
           .nOut(numHiddenNodes)
           .activation(Activation.TANH)
           .build())
     .layer(2, new RnnOutputLayer.Builder()
          .activation(Activation.SOFTMAX)
          .lossFunction(LossFunction.XENT)
          .nIn(numHiddenNodes)
          .nOut(numClasses)
          .build())
    .pretrain(false).backprop(true).build();

最后,我们还指定不需要进行任何预训练(这通常在深度信念网络或堆叠自编码器中需要)。然后,我们初始化网络并开始在训练集上进行训练:

MultiLayerNetwork model = new MultiLayerNetwork(LSTMconf);
model.init();

通常,这种类型的网络有很多超参数。让我们打印出网络中的参数数量(以及每一层的参数):

Layer[] layers = model.getLayers();
int totalNumParams = 0;
for(int i=0; i<layers.length; i++ ){
  int nParams = layers[i].numParams();
  System.out.println("Number of parameters in layer " + i + ": " + nParams);
  totalNumParams += nParams;
}
System.out.println("Total number of network parameters: " + totalNumParams);

>>
 Number of parameters in layer 0: 570,368
 Number of parameters in layer 1: 525,312
 Number of parameters in layer 2: 514
 Total number of network parameters: 1,096,194

如我所说,我们的网络有 100 万参数,这是非常庞大的。这在调整超参数时也带来了很大的挑战。不过,我们将在常见问题解答部分看到一些技巧。

MultiLayerNetwork net = new MultiLayerNetwork(LSTMconf);
net.init();
net.setListeners(new ScoreIterationListener(1));
for (int i = 0; i < numEpochs; i++) {
  net.fit(train);
  train.reset();
  System.out.println("Epoch " + (i+1) + " finished ...");
}
System.out.println("Training has been completed");

训练完成后,我们可以保存训练好的模型,以便模型持久化和后续重用。为此,DL4J 通过ModelSerializer类的writeModel()方法提供对训练模型的序列化支持。此外,它还提供了通过restoreMultiLayerNetwork()方法恢复已保存模型的功能。

我们将在接下来的步骤中看到更多内容。不过,我们也可以保存网络更新器,即动量、RMSProp、Adagrad 等的状态:

File locationToSave = new File(modelPath); //location and file format
boolean saveUpdater = true; // we save the network updater too
ModelSerializer.writeModel(net, locationToSave, saveUpdater);

恢复训练好的模型并在测试集上进行评估

一旦训练完成,下一步任务就是评估模型。我们将在测试集上评估模型的表现。为了评估,我们将使用Evaluation(),它创建一个评估对象,包含两个可能的类。

首先,让我们对每个测试样本进行迭代评估,并从训练好的模型中获得网络的预测结果。最后,eval()方法将预测结果与真实类别进行比对:

public static void networkEvaluator() throws Exception {
      System.out.println("Starting the evaluation ...");
      boolean saveUpdater = true;

      //Load the model
      MultiLayerNetwork restoredModel = ModelSerializer.restoreMultiLayerNetwork(modelPath, saveUpdater);
      //WordVectors wordVectors = getWord2Vec();
      SentimentDatasetIterator test = new SentimentDatasetIterator(DATA_PATH, wordVectors, batchSize,   
                                                                   truncateReviewsToLength, false);
      Evaluation evaluation = restoredModel.evaluate(test);
      System.out.println(evaluation.stats());
      System.out.println("----- Evaluation completed! -----");
}

>>>
 ==========================Scores========================================
 # of classes: 2
 Accuracy: 0.8632
 Precision: 0.8632
 Recall: 0.8632
 F1 Score: 0.8634
 Precision, recall, and F1: Reported for positive class (class 1 -"negative") only
 ========================================================================

使用 LSTM 进行情感分析的预测准确率约为 87%,考虑到我们没有专注于超参数调优,这个结果还是不错的!现在,让我们看看分类器在每个类别上的预测情况:

Predictions labeled as positive classified by model as positive: 10,777 times
 Predictions labeled as positive classified by model as negative: 1,723 times
 Predictions labeled as negative classified by model as positive: 1,696 times
 Predictions labeled as negative classified by model as negative: 10,804 times

类似于第二章,使用递归类型网络预测癌症类型,我们现在将计算一个称为马修斯相关系数的度量,用于这个二分类问题:

// Compute Matthews correlation coefficient
EvaluationAveraging averaging = EvaluationAveraging.*Macro*;
double MCC = eval.matthewsCorrelation(averaging);
System.*out*.println("Matthews correlation coefficient: "+ MCC);

>>
 Matthews's correlation coefficient: 0.22308172619187497

这显示了一个弱正相关,表明我们的模型表现相当不错。接下来,我们将使用训练好的模型进行推理,也就是对样本评论文本进行预测。

对样本评论文本进行预测

现在,让我们来看看我们的训练模型如何泛化,也就是说,它在来自情感标注句子数据集的未见过的评论文本上的表现如何。首先,我们需要从磁盘中恢复训练好的模型:

System.*out*.println("Starting the evaluation on sample texts ...");
boolean saveUpdater = true;

MultiLayerNetwork restoredModel = ModelSerializer.*restoreMultiLayerNetwork*(*modelPath*, saveUpdater);
SentimentDatasetIterator test = new SentimentDatasetIterator(*DATA_PATH*, *wordvectors*, *batchSize*, 
                                                             *truncateReviewsToLength*, false);

现在,我们可以随机提取两条来自 IMDb、Amazon 和 Yelp 的评论文本,其中第一条表示正面情感,第二条表示负面情感(根据已知标签)。然后,我们可以创建一个包含评论字符串和标签的 HashMap:

String IMDb_PositiveReview = "Not only did it only confirm that the film would be unfunny and generic, but 
                              it also managed to give away the ENTIRE movie; and I'm not exaggerating - 
                              every moment, every plot point, every joke is told in the trailer";

String IMDb_NegativeReview = "One character is totally annoying with a voice that gives me the feeling of 
                              fingernails on a chalkboard.";

String Amazon_PositiveReview = "This phone is very fast with sending any kind of messages and web browsing 
                                is significantly faster than previous phones i have used";

String Amazon_NegativeReview = "The one big drawback of the MP3 player is that the buttons on the phone's 
                             front cover that let you pause and skip songs lock out after a few seconds.";

String Yelp_PositiveReview = "My side Greek salad with the Greek dressing was so tasty, and the pita and 
                              hummus was very refreshing.";

String Yelp_NegativeReview = "Hard to judge whether these sides were good because we were grossed out by 
                              the melted styrofoam and didn't want to eat it for fear of getting sick.";

然后,我们创建一个包含前面字符串的数组:

String[] reviews = {IMDb_PositiveReview, IMDb_NegativeReview, Amazon_PositiveReview, 
                    Amazon_NegativeReview, Yelp_PositiveReview, Yelp_NegativeReview};

String[] sentiments = {"Positive", "Negative", "Positive", "Negative", "Positive", "Negative"};
Map<String, String> reviewMap = new HashMap<String, String>();

reviewMap.put(reviews[0], sentiments[0]);
reviewMap.put(reviews[1], sentiments[1]);
reviewMap.put(reviews[2], sentiments[2]);
reviewMap.put(reviews[3], sentiments[3]);

然后,我们遍历这个映射并使用预训练的模型进行样本评估,如下所示:

System.out.println("Starting the evaluation on sample texts ...");         
for (Map.Entry<String, String> entry : reviewMap.entrySet()) {
            String text = entry.getKey();
            String label = entry.getValue();

            INDArray features = test.loadFeaturesFromString(text, truncateReviewsToLength);
            INDArray networkOutput = restoredModel.output(features);

            int timeSeriesLength = networkOutput.size(2);
            INDArray probabilitiesAtLastWord = networkOutput.get(NDArrayIndex.point(0), 
                              NDArrayIndex.all(), NDArrayIndex.point(timeSeriesLength - 1));

            System.out.println("-------------------------------");
            System.out.println("\n\nProbabilities at last time step: ");
            System.out.println("p(positive): " + probabilitiesAtLastWord.getDouble(0));
            System.out.println("p(negative): " + probabilitiesAtLastWord.getDouble(1));

            Boolean flag = false;
            if(probabilitiesAtLastWord.getDouble(0) > probabilitiesAtLastWord.getDouble(1))
                flag = true;
            else
                flag = false;
            if (flag == true) {
                System.out.println("The text express a positive sentiment, actually it is " + label);
            } else {
                System.out.println("The text express a negative sentiment, actually it is " + label);
            }
        }
    System.out.println("----- Sample evaluation completed! -----");
    }

如果仔细查看前面的代码块,你会发现我们通过提取特征将每条评论文本转化为时间序列。然后,我们计算了网络输出(即概率)。接着,我们比较概率,也就是说,如果概率是正面情感的概率,我们就设置标志为真,否则为假。这样,我们就做出了最终的类别预测决定。

我们还在前面的代码块中使用了loadFeaturesFromString()方法,它将评论字符串转换为INDArray格式的特征。它接受两个参数,reviewContents,即要向量化的评论内容,以及maxLength,即评论文本的最大长度。最后,它返回给定输入字符串的features数组:

public INDArray loadFeaturesFromString(String reviewContents, int maxLength){
        List<String> tokens = tokenizerFactory.create(reviewContents).getTokens();
        List<String> tokensFiltered = new ArrayList<>();
        for(String t : tokens ){
            if(wordVectors.hasWord(t)) tokensFiltered.add(t);
        }
        int outputLength = Math.max(maxLength,tokensFiltered.size());
        INDArray features = Nd4j.create(1, vectorSize, outputLength);

        for(int j=0; j<tokens.size() && j<maxLength; j++ ){
            String token = tokens.get(j);
            INDArray vector = wordVectors.getWordVectorMatrix(token);
            features.put(new INDArrayIndex[]{NDArrayIndex.point(0), 
                          NDArrayIndex.all(), NDArrayIndex.point(j)}, vector);
        }
        return features;
    }

如果你不想截断,只需使用Integer.MAX_VALUE

现在,让我们回到原来的讨论。令人捧腹的是,我们使其更具人性化,也就是说,没有使用激活函数。最后,我们打印每条评论文本及其相关标签的结果:

> Probabilities at last time step:
 p(positive): 0.003569001331925392
 p(negative): 0.9964309930801392
 The text express a negative sentiment, actually, it is Positive

p(positive): 0.003569058608263731
 p(negative): 0.9964308738708496
 The text express a negative sentiment, actually, it is Negative
 -------------------------------
 Probabilities at last time step:
 p(positive): 0.003569077467545867
 p(negative): 0.9964308738708496
 The text express a negative sentiment, actually, it is Negative

p(positive): 0.003569045104086399
 p(negative): 0.9964308738708496
 The text express a negative sentiment, actually, it is Positive
 -------------------------------
 Probabilities at last time step:
 p(positive): 0.003570008557289839
 p(negative): 0.996429979801178
 The text express a negative sentiment, actually, it is Positive

p(positive): 0.0035690285731106997
 p(negative): 0.9964309930801392
 The text express a negative sentiment, actually, it is Negative

----- Sample evaluation completed! -----

所以,我们的训练模型做出了 50%的错误预测,尤其是它总是将正面评论预测为负面评论。简而言之,它在泛化到未知文本时表现得不好,这可以通过 50%的准确率看出来。

现在,可能会有一个愚蠢的问题浮现出来。我们的网络是否出现了欠拟合?有没有办法观察训练过程?换句话说,问题是:为什么我们的 LSTM 神经网络没有显示出更高的准确性?我们将在下一节中尝试回答这些问题。所以请继续关注!

常见问题(FAQ)

现在我们已经通过可接受的准确度解决了情感分析问题,但该问题以及整体深度学习现象中还有其他实际方面需要考虑。在本节中,我们将看到一些可能已经在你脑海中的常见问题。问题的答案可以在附录 A 中找到:

  1. 我理解使用 LSTM 进行情感分析的预测准确度仍然是合理的。然而,它在情感标注数据集上的表现并不理想。我们的网络是否出现了过拟合?有没有办法观察训练过程?

  2. 考虑到大量的评论文本,我们可以在 GPU 上进行训练吗?

  3. 关于问题 2,我们是否可以完全使用 Spark 来执行整个过程?

  4. 我在哪里可以获取更多的情感分析训练数据集?

  5. 我们是否可以使用extractTarGz()方法,而不是手动以.zip格式下载训练数据?

  6. 我的机器内存有限。能否给我一个关于 DL4J 中内存管理和垃圾回收工作的提示?

总结

在本章中,我们已经看到如何实现并部署一个实际的深度学习项目,该项目基于评论文本的内容将其分类为正面或负面。我们使用了一个包含 50,000 条评论(训练和测试)的大规模电影评论数据集。应用了结合 Word2Vec(即在 NLP 中广泛使用的词嵌入技术)和 LSTM 网络的建模方法:使用了预训练的 Google 新闻向量模型作为神经网络词嵌入。

然后,将训练向量与标签一起输入 LSTM 网络,该网络成功地将它们分类为负面或正面情感。接着,它在测试集上评估了训练好的模型。此外,我们还看到了如何在 DL4J 中应用基于文本的预处理技术,如分词器、停用词去除和 TF-IDF,以及词嵌入操作。

在下一章中,我们将看到一个完整的示例,展示如何使用 DL4J 迁移学习 API 开发一个深度学习项目来分类图像。通过这个应用,用户将能够修改现有模型的架构,微调现有模型的学习配置,并在训练过程中保持指定层的参数不变,这也被称为冻结。

问题的答案

问题 1 的答案:我们已经看到我们的训练模型在测试集上的表现相当不错,准确率为 87%。现在,如果我们查看模型与迭代分数以及以下图表中的其他参数,我们可以看到我们的模型没有过拟合:

LSTM 情感分析器的模型与迭代得分及其他参数

现在,对于情感标注的句子,训练好的模型表现不佳。可能有几个原因。比如,我们的模型只用电影评论数据集进行训练,但在这里,我们尝试强迫模型在不同类型的数据集上进行表现,例如 Amazon 和 Yelp。然而,我们没有仔细调整超参数。

问题 2 的答案:是的,实际上,这将非常有帮助。为此,我们必须确保我们的编程环境已经准备好。换句话说,首先,我们必须在机器上配置 CUDA 和 cuDNN。

然而,确保你的机器已安装并配置了具有足够内存和 CUDA 计算能力的 NVIDIA GPU。如果你不知道如何配置这些前提条件,请参考此 URL:docs.nvidia.com/deeplearning/sdk/cudnn-install/。一旦你的机器安装了 CUDA/cuDNN,在pom.xml文件中,你需要添加两个条目:

  • 项目属性中的后端

  • CUDA 作为平台依赖

对于第 1 步,属性现在应如下所示:

<properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
        <nd4j.backend>nd4j-cuda-9.0-platform</nd4j.backend>
        <nd4j.version>1.0.0-alpha</nd4j.version>
        <dl4j.version>1.0.0-alpha</dl4j.version>
        <datavec.version>1.0.0-alpha</datavec.version>
        <arbiter.version>1.0.0-alpha</arbiter.version>
        <logback.version>1.2.3</logback.version>
 </properties>

现在,对于第 2 步,在pop.xml文件中添加以下依赖项(即,在 dependencies 标签内):

<dependency>
         <groupId>org.nd4j</groupId>
         <artifactId>nd4j-cuda-9.0-platform</artifactId>
         <version>${nd4j.version}</version>
</dependency>

然后,更新 Maven 项目,所需的依赖项将自动下载。现在,除非我们在多个 GPU 上执行训练,否则不需要进行任何更改。然而,只需再次运行相同的脚本来执行训练。然后,你将在控制台上看到以下日志:

17:03:55.317 [main] INFO org.nd4j.linalg.factory.Nd4jBackend - Loaded [JCublasBackend] backend
 17:03:55.360 [main] WARN org.reflections.Reflections - given scan urls are empty. set urls in the configuration
 17:04:06.410 [main] INFO org.nd4j.nativeblas.NativeOpsHolder - Number of threads used for NativeOps: 32
 17:04:08.118 [main] DEBUG org.nd4j.jita.concurrency.CudaAffinityManager - Manually mapping thread [18] to device [0], out of [1] devices...
 17:04:08.119 [main] DEBUG org.nd4j.jita.concurrency.CudaAffinityManager - Manually mapping thread [19] to device [0], out of [1] devices...
 17:04:08.119 [main] DEBUG org.nd4j.jita.concurrency.CudaAffinityManager - Manually mapping thread [20] to device [0], out of [1] devices...
 17:04:08.119 [main] DEBUG org.nd4j.jita.concurrency.CudaAffinityManager - Manually mapping thread [21] to device [0], out of [1] devices...
 17:04:08.119 [main] DEBUG org.nd4j.jita.concurrency.CudaAffinityManager - Manually mapping thread [22] to device [0], out of [1] devices...
 17:04:08.119 [main] DEBUG org.nd4j.jita.concurrency.CudaAffinityManager - Manually mapping thread [23] to device [0], out of [1] devices...
 17:04:08.123 [main] INFO org.nd4j.nativeblas.Nd4jBlas - Number of threads used for BLAS: 0
 17:04:08.127 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Backend used: [CUDA]; OS: [Windows 10]
 17:04:08.127 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Cores: [8]; Memory: [7.0GB];
 17:04:08.127 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Blas vendor: [CUBLAS]
 17:04:08.127 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [GeForce GTX 1050]; CC: [6.1]; Total/free memory: [4294967296]

然而,在第八章,分布式深度学习 - 使用卷积 LSTM 网络进行视频分类,我们将看到如何在多个 GPU 上使一切变得更快并且可扩展。

问题 3 的答案:是的,实际上,这将非常有帮助。为此,我们必须确保我们的编程环境已经准备好。换句话说,首先,我们必须在机器上配置 Spark。一旦你的机器安装了 CUDA/cuDNN,我们只需配置 Spark。在pom.xml文件中,你需要添加两个条目:

  • 项目属性中的后端

  • Spark 依赖

对于第 1 步,属性现在应如下所示:

<properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
        <nd4j.backend>nd4j-cuda-9.0-platform</nd4j.backend>
        <nd4j.version>1.0.0-alpha</nd4j.version>
        <dl4j.version>1.0.0-alpha</dl4j.version>
        <datavec.version>1.0.0-alpha</datavec.version>
        <arbiter.version>1.0.0-alpha</arbiter.version>
        <dl4j.spark.version>1.0.0-alpha_spark_2</dl4j.spark.version>
        <logback.version>1.2.3</logback.version>
 </properties>

现在,对于第 2 步,在pop.xml文件中添加以下依赖项(即,在 dependencies 标签内):

<dependency>
      <groupId>org.deeplearning4j</groupId>
      <artifactId>dl4j-spark_2.11</artifactId>
      <version>1.0.0-alpha_spark_2</version>
</dependency>

然后,更新 Maven 项目,所需的依赖项将自动下载。现在,除非我们在多个 GPU 上执行训练,否则不需要进行任何更改。然而,我们需要将训练/测试数据集转换为 Spark 兼容的 JavaRDD。

我已在SentimentAnalyzerSparkGPU.java文件中编写了所有步骤,可以用于查看整体步骤如何工作。一般警告是,如果你在 Spark 上执行训练,DL4J UI 将无法正常工作,因为与 Jackson 库存在交叉依赖。为此,我们必须首先通过调用sparkSession()方法创建JavaSparkContext,如下所示:

public static JavaSparkContext *spark*;
static int *batchSizePerWorker* = 16;

public static JavaSparkContext getJavaSparkContext () {
                    SparkConf sparkConf = new SparkConf();
                    sparkConf.set("spark.locality.wait", "0");
                    sparkConf.setMaster("local[*]").setAppName("DL4J Spark");
 *spak* = new JavaSparkContext(sparkConf);
 return *spark*;
}

然后,我们需要将情感训练数据集迭代器转换为 JavaRDD 数据集。首先,我们创建一个数据集列表,然后按如下方式将每个训练样本添加到列表中:

List<DataSet> trainDataList = new ArrayList<>();
while(train.hasNext()) {
       trainDataList.add(train.next());
    }

然后,我们通过调用sparkSession()方法创建JavaSparkContext,如下所示:

spark = createJavaSparkContext();

最后,我们利用 Spark 的parallelize()方法创建数据集的 JavaRDD,随后可以用它通过 Spark 执行训练:

JavaRDD<DataSet> trainData = *spark*.parallelize(trainDataList);

然后,Spark 的TrainingMaster使用ParameterAveragingTrainingMaster,它帮助通过 Spark 执行训练。更多细节请参阅第八章,分布式深度学习 – 使用卷积 LSTM 网络进行视频分类

TrainingMaster<?, ?> tm = (TrainingMaster<?, ?>) new ParameterAveragingTrainingMaster
               .Builder(*batchSizePerWorker*)             
               .averagingFrequency(5).workerPrefetchNumBatches(2)
               .batchSizePerWorker(*batchSizePerWorker*).build();

接着,我们创建SparkDl4jMultiLayer,而不是像之前那样仅创建MultilayerNetwork

SparkDl4jMultiLayer sparkNet = new SparkDl4jMultiLayer(*spark*, LSTMconf, tm);

然后,我们创建一个训练监听器,按如下方式记录每次迭代的分数:

sparkNet.setListeners(Collections.<IterationListener>*singletonList*(new ScoreIterationListener(1)));
sparkNet.setListeners(new ScoreIterationListener(1));

最后,我们按如下方式开始训练:

for (int i = 0; i < *numEpochs*; i++) {
         sparkNet.fit(trainData);
         System.*out*.println("Epoch " + (i+1) + " has been finished ...");
       }

然而,使用这种方法有一个缺点,即我们不能像这样直接保存训练后的模型,而是必须首先使用训练数据来拟合网络,并将输出收集为MultiLayerNetwork,如下所示:

MultiLayerNetwork outputNetwork = sparkNet.fit(trainData);

//Save the model
File locationToSave = new File(*modelPath*);

boolean saveUpdater = true;
ModelSerializer.*writeModel*(outputNetwork, locationToSave, saveUpdater);

问题 4 的答案:你可以从许多来源获得情感分析数据集。以下是其中的一些:

问题 5 的答案:答案是否定的,但稍加努力我们可以使其工作。为此,我们可以使用 Apache commons 中的ZipArchiveInputStreamGzipCompressorInputStream类,代码如下:

public static void extractZipFile(String inputPath, String outputPath) 
               throws IOException { if (inputPath == null || outputPath == null)
 return;
 final int bufferSize = 4096;
 if (!outputPath.endsWith("" + File.*separatorChar*))
                            outputPath = outputPath + File.*separatorChar*; 
 try (ZipArchiveInputStream tais = new ZipArchiveInputStream(new 
                         GzipCompressorInputStream(
                             new BufferedInputStream(new FileInputStream(inputPath))))) {
                             ZipArchiveEntry entry;
 while ((entry = (ZipArchiveEntry) tais.getNextEntry()) != null) {
 if (entry.isDirectory()) {
 new File(outputPath + entry.getName()).mkdirs();
                               } else {
                                int count; 
                                byte data[] = newbyte[bufferSize];
                                FileOutputStream fos = new FileOutputStream(outputPath + entry.getName());
                                BufferedOutputStream dest = new BufferedOutputStream(fos, bufferSize);
                                while ((count = tais.read(data, 0, bufferSize)) != -1) {
                                       dest.write(data, 0, count);
                                       }
                            dest.close();
                       }
                 }
           }
}

问题 6 的答案:嗯,这个问题只有在你的机器内存不足时才需要关注。对于这个应用程序,当我在我的 32 GB 内存的笔记本上运行该项目时,我并未遇到任何面向对象的类型问题。

除了这一步,我们还可以选择使用 DL4J 的垃圾回收,尤其是因为你的端口内存受限。DL4J 提供了一种名为getMemoryManager()的方法,它返回一个特定于后端的MemoryManager实现,用于低级内存管理。此外,我们还必须启用周期性的System.gc()调用,并设置调用之间的最小时间(以毫秒为单位)。让我们来看一个例子:

Nd4j.getMemoryManager().setAutoGcWindow(10000); // min 10s between calls

然而,只需将windowMillis设置为0,即可禁用此选项。

第五章:图像分类的迁移学习

在第三章,多标签 图像分类使用卷积神经网络,我们展示了如何使用基于 Java 的卷积神经网络(CNN)和Deeplearning4JDL4J)框架,在实际的 Yelp 图像数据集上开发一个端到端的项目来处理多标签图像分类问题。为此,我们从头开始开发了一个 CNN 模型。

不幸的是,从零开始开发这样的模型是非常耗时的,并且需要大量的计算资源。其次,有时我们甚至可能没有足够的数据来训练如此深的网络。例如,ImageNet 是目前最大的图像数据集之一,拥有数百万张带标签的图像。

因此,我们将开发一个端到端的项目,使用已通过 ImageNet 训练的预训练 VGG-16 模型来解决狗与猫的图像分类问题。最后,我们将把所有内容打包成一个 Java JFrame 和 JPanel 应用程序,以便让整体流程更加易懂。简而言之,整个端到端项目将帮助我们学习以下内容:

  • 图像分类的迁移学习

  • 使用迁移学习开发图像分类器

  • 数据集的收集与描述

  • 开发一个狗与猫检测器用户界面

  • 常见问题解答 (FAQs)

使用预训练的 VGG16 进行图像分类

目前机器学习领域最有用且新兴的应用之一是使用迁移学习技术;它提供了不同框架和平台之间的高可移植性。

一旦你训练好一个神经网络,你得到的就是一组训练好的超参数值。例如,LeNet-5有 60k 个参数值,AlexNet有 6000 万个,VGG-16有大约 1.38 亿个参数。这些架构的训练使用了从 1000 张到数百万张图像,通常这些架构非常深,拥有数百层,这些层都贡献了大量的超参数。

现在有很多开源社区成员甚至是科技巨头,他们已经公开了这些预训练模型,供研究(以及行业)使用,大家可以恢复并重用这些模型来解决类似问题。例如,假设我们想要将新图像分类到 AlexNet 的 1000 个类别之一,或者 LeNet-5 的 10 个类别中。我们通常不需要处理这么多参数,而只需关注一些选择出来的参数(我们很快就会看到一个例子)。

简而言之,我们不需要从头开始训练如此深的网络,而是重用现有的预训练模型;我们仍然能够实现可接受的分类准确率。从技术上讲,我们可以使用该预训练模型的权重作为特征提取器,或者直接用它初始化我们的架构,然后进行微调以适应我们的新任务。

在这方面,使用迁移学习技术来解决自己的问题时,可能有三种选择:

  • 将深度 CNN 用作固定特征提取器:如果我们不再关心 ImageNet 中它的 1,000 个类别,我们可以通过移除输出层来重用预训练的 ImageNet,它具有一个完全连接的层。这样,我们可以将其他所有层视为特征提取器。即使在使用预训练模型提取了特征之后,你也可以将这些特征输入到任何线性分类器中,例如 softmax 分类器,甚至是线性 SVM!

  • 微调深度 CNN:尝试微调整个网络,甚至大多数层,可能会导致过拟合。因此,需要额外的努力,使用反向传播在新任务上微调预训练的权重。

  • 重用带检查点的预训练模型:第三种广泛使用的场景是下载互联网上公开的检查点。如果你没有足够的计算能力从头训练模型,你可以选择这个场景,只需使用已发布的检查点初始化模型,然后进行少量微调。

在这一点上,你可能会产生一个有趣的问题:传统的机器学习和使用迁移学习的机器学习有什么区别?嗯,在传统的机器学习中,你不会将任何知识或表示转移到其他任务中,而迁移学习则不同。

与传统机器学习不同,源任务和目标任务或领域不必来自相同的分布,但它们必须是相似的。此外,你可以在训练样本较少或没有足够计算能力的情况下使用迁移学习。

传统机器学习与迁移学习

DL4J 和迁移学习

现在,让我们看看 DL4J 是如何通过其迁移学习 API 为我们提供这些功能的。DL4J 的迁移学习 API 使用户能够(更多信息请见deeplearning4j.org/transfer-learning):

  • 修改现有模型的架构

  • 微调现有模型的学习配置

  • 在训练过程中保持指定层的参数(也叫冻结层)不变

这些功能在下图中有所体现,我们通过迁移学习技术解决任务 B(与任务 A 相似):

迁移学习的工作原理

在下一节中,我们将深入探讨如何使用 DL4J 与预训练模型来帮助我们进行迁移学习。

使用迁移学习开发图像分类器

在下一节中,我们将展示如何根据狗和猫的原始图像进行区分。我们还将看到如何实现我们的第一个 CNN 模型来处理具有三通道的原始彩色图像。

这个项目深受(但进行了大量扩展)Klevis Ramo 的文章《Java 图像猫与狗识别与深度神经网络》启发(ramok.tech/)。

code文件夹包含三个包,每个包中有一些 Java 文件。它们的功能如下:

  • com.packt.JavaDL.DogvCatClassification.Train

    • TrainCatvsDogVG16.java:用于训练网络,并将训练好的模型保存到用户指定的位置。最后,它输出结果。

    • PetType.java:包含一个enum类型,指定宠物类型(即,猫、狗和未知)。

    • VG16CatvDogEvaluator.java:恢复由TrainCatvsDogVG16.java类保存到指定位置的训练模型。然后它在测试集和验证集上进行评估。最后,输出结果。

  • com.packt.JavaDL.DogvCatClassification.Classifier

    • PetClassfier.java:为用户提供上传样本图像的机会(即,狗或猫)。然后,用户可以通过高级用户界面进行检测。
  • com.packt.JavaDL.DogvCatClassification.UI

    • ImagePanel.java:通过扩展 Java 的 JPanel 类,作为图像面板使用

    • UI.java:创建上传图像的用户界面并显示结果

    • ProgressBar.java:显示进度条

我们将一步步进行探讨。首先,让我们看看数据集的描述。

数据集收集与描述

对于这个端到端项目,我们将使用微软提供的狗与猫数据集,该数据集曾作为臭名昭著的“狗与猫分类问题”的竞赛平台。数据集可以从www.microsoft.com/en-us/download/details.aspx?id=54765.下载。

训练文件夹包含 25k 张狗和猫的图像,其中标签是文件名的一部分。然而,测试文件夹包含 12.5k 张根据数字 ID 命名的图像。现在让我们看看从这 25k 张图像中随机选取的一些样本:

显示随机选择的图像的真实标签

对于测试集中的每一张图像,我们必须预测该图像是否包含一只狗(1 = 狗,0 = 猫)。简而言之,这是一个二分类问题。

架构选择与采纳

如前所述,我们将重用 VGG-16 的预训练模型,该模型已使用来自 ImageNet 的不同猫狗品种图像进行了训练(请参阅这里的列表)。原始的 VGG-16 模型有 1,000 个要预测的图像类别,如下图所示:

原始 VGG-16 模型架构

幸运的是,训练好的模型和网络权重已经可以在 DL4J 网站上找到(参见 blob.deeplearning4j.org/models/vgg16_dl4j_inference.zip),大小约为 500 MB。

你可以手动下载和恢复,或者更好的方式是采用 DL4J 的方式,只需指定预训练类型(直到 DL4J 1.0.0 alpha 版本时,只有四种预训练类型可用,如 ImageNet、CIFAR、MNIST 和 VGG-Face)。

后者非常简单;只需使用以下几行代码,训练好的模型将自动下载(但这需要根据网络速度花费一些时间):

ZooModel zooModel = new VGG16();
LOGGER.info(" VGG16 model is getting downloaded...");
ComputationGraph preTrainedNet = (ComputationGraph) zooModel.initPretrained(PretrainedType.IMAGENET);

在前面的代码片段中,ComputationGraph 类被用来实例化一个计算图,它是一个具有任意(即有向无环)连接结构的神经网络。这个图结构也可以有任意数量的输入和输出。

LOGGER.info(preTrainedNet.summary());

现在,让我们来看一下网络架构,包括进出神经元的数量、参数形状和参数数量:

VGG-16 模型架构作为计算图

现在我们已经有了预训练的模型,利用它,我们可以预测最多 1,000 个类别。而可训练的参数数量等于总参数数量:1.38 亿。训练这么多参数是件很困难的事。

然而,由于我们只需要预测两个类别,因此我们需要稍微修改模型架构,使其仅输出两个类别,而不是 1,000 个。所以我们保持其他部分不变。修改后的 VGG-16 网络将如下所示:

从输入层到最后一个全连接层(即 fc2)被冻结

在前面的图示中,我们冻结了直到最后一个池化层并使用初始权重。绿色部分是我们希望训练的主题,因此我们只训练最后一层,针对两个类别。换句话说,在我们的案例中,我们将从输入层到最后一个全连接层(即fc2)冻结。也就是说,featurizeExtractionLayer 变量的值将是 fc2

然而,在此之前,让我们定义一些属性,比如种子、类别数量以及我们想冻结到哪一层:

private static final long seed = 12345;
private static final String FREEZE_UNTIL_LAYER = "fc2";
private static final int NUM_CLASS = 2;

然后我们实例化微调的配置,这将覆盖所有非冻结层的值,并使用此处设置的值:

FineTuneConfiguration fineTuneConf = new FineTuneConfiguration.Builder()    
         .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
         .updater(new Adam(0.001))
         .seed(seed)
         .build();

FineTuneConfiguration 是微调的配置。在此配置中设置的值将覆盖每个非冻结层中的值。有兴趣的读者可以查看 deeplearning4j.org/doc/org/deeplearning4j/nn/transferlearning/FineTuneConfiguration.html

然后,我们创建一个配置图,它将完成这项工作:它将作为转移学习器,使用预训练的 VGG-16 模型:

ComputationGraph vgg16Transfer = new TransferLearning.GraphBuilder(preTrainedNet)
       .fineTuneConfiguration(fineTuneConf)
       .setFeatureExtractor(FREEZE_UNTIL_LAYER)
       .removeVertexKeepConnections("predictions")
       .setWorkspaceMode(WorkspaceMode.SEPARATE)
       .addLayer("predictions", new OutputLayer
                  .Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
                  .nIn(4096).nOut(NUM_CLASS)
                  .weightInit(WeightInit.XAVIER)
                  .activation(Activation.SOFTMAX).build(), FREEZE_UNTIL_LAYER)
       .build();
vgg16Transfer.setListeners(new ScoreIterationListener(5));
LOGGER.info(vgg16Transfer.summary());

以下截图展示了前一个代码片段的输出:

冻结网络仅有 8,194 个可训练参数

在前面的代码中,我们移除了之前计算的预测,改用了我们的方法,使得修改后的网络仅通过重新添加一个新的预测层来预测两个类别。

此外,setFeatureExtractor 方法通过指定一个层顶点作为特征提取器来冻结权重。然后,指定的层顶点及其路径上的层(从输入顶点到该层的路径)将被冻结,参数保持不变。

因此,我们将只训练 8,192 个参数(在 1.38 亿个参数中),从最后一层到两个输出;另外两个参数是两个类别的偏置。简而言之,通过冻结至 fc2 层,现在可训练参数从 1.38 亿减少至 8,194(即 8,192 个网络参数 + 2 个偏置参数)。

训练集和测试集准备

现在我们已经创建了一个 ComputationGraph,接下来需要为微调阶段准备训练集和测试集。但在此之前,我们需要定义一些参数,例如允许的格式和数据路径:

public static final Random RAND_NUM_GEN = new Random(*seed*);
public static final String[] ALLOWED_FORMATS = BaseImageLoader.*ALLOWED_FORMATS*;
public static ParentPathLabelGenerator *LABEL_GENERATOR_MAKER* = new ParentPathLabelGenerator();
public static BalancedPathFilter *PATH_FILTER* = new BalancedPathFilter(RAND_NUM_GEN, ALLOWED_FORMATS, LABEL_GENERATOR_MAKER);

简要讨论一下 MultiLayerNetworkComputationGraph 之间的区别。在 DL4J 中,有两种类型的网络由多个层组成:

  • MultiLayerNetwork:我们至今使用的神经网络层堆栈。

  • ComputationGraph:允许构建具有以下特性的网络:多个网络输入数组和多个网络输出(适用于分类和回归)。在这种网络类型中,层通过有向无环图连接结构相互连接。

无论如何,进入正题。设置完参数后,接下来的任务是定义文件路径。读者应该在训练时遵循此路径或提供准确的路径:

public static String DATA_PATH = "data/DoG_CaT/data";
public static final String TRAIN_FOLDER = DATA_PATH + "/train";
public static final String *TEST_FOLDER* = DATA_PATH + "/test";
File trainData = new File(TRAIN_FOLDER);

接着,我们将使用基于 JavaCV 库的 NativeImageLoader 类来加载图像,允许的格式包括 .bmp.gif.jpg.jpeg.jp2.pbm.pgm.ppm.pnm.png.tif.tiff.exr.webp

JavaCV 使用来自 JavaCPP 预设的多个计算机视觉库的封装(例如 OpenCV 和 FFmpeg)。更多详细信息请访问 github.com/bytedeco/javacv

FileSplit train = new FileSplit(trainData, NativeImageLoader.ALLOWED_FORMATS, RAND_NUM_GEN);

一旦从图像中提取特征,我们将特征空间随机划分为 80%用于训练,剩余 20%用于验证训练过程,以防止过拟合:

private static final int TRAIN_SIZE = 80;
InputSplit[] sample = train.sample(*PATH_FILTER*, TRAIN_SIZE, 100 - TRAIN_SIZE);

此外,我们的 DL4J 网络无法直接处理这种格式的数据,但我们需要将其转换为 DataSetIterator 格式:

DataSetIterator trainIterator = getDataSetIterator(sample[0]);
DataSetIterator devIterator = getDataSetIterator(sample[1]);

在之前的代码行中,我们通过getDataSetIterator()方法将训练集和验证集都转换为DataSetIterator。该方法的签名如下:

public static DataSetIterator getDataSetIterator(InputSplit sample) throws IOException {
    ImageRecordReader imageRecordReader = new ImageRecordReader(224, 224, 3, *LABEL_GENERATOR_MAKER*);
    imageRecordReader.initialize(sample);

    DataSetIterator iterator = new RecordReaderDataSetIterator(imageRecordReader, 
                               BATCH_SIZE, 1, NUM_CLASS);
    iterator.setPreProcessor(new VGG16ImagePreProcessor());
    return iterator;
}

太棒了!到目前为止,我们已经成功地准备好了训练集。不过,请记住,这个过程可能需要一些时间,因为需要处理 12,500 张图像。

现在我们可以开始训练了。不过,你可能会好奇为什么我们没有提到测试集。嗯,没错!我们肯定也需要使用测试集。不过,让我们在网络评估步骤中再讨论这个问题。

网络训练与评估

既然训练集和测试集已经准备好,我们就可以开始训练了。不过,在此之前,我们需要定义一些数据集准备的超参数:

private static final int EPOCH = 100;
private static final int BATCH_SIZE = 128;
private static final int SAVING_INTERVAL = 100;

此外,我们还指定了训练好的模型将保存的路径,以便未来重复使用:

private static final String SAVING_PATH = "bin/CatvsDog_VG16_TrainedModel_Epoch100_v1.zip";

现在我们可以开始训练网络了。我们将进行综合训练,使得训练使用训练集,而验证则使用验证集进行。最后,网络将使用测试集评估网络性能。因此,我们还需要准备测试集:

File testData = new File(TEST_FOLDER);
FileSplit test = new FileSplit(testData, NativeImageLoader.ALLOWED_FORMATS, RAND_NUM_GEN);
DataSetIterator testIterator = *getDataSetIterator*(test.sample(*PATH_FILTER*, 1, 0)[0]);

然后,我们开始训练;我们使用了 128 的批量大小和 100 个 epoch。因此,第一次while循环将执行 100 次。接着,第二个内部while循环将执行 196 次(25,000 张猫狗图像/128):

int iEpoch = 0;
int i = 0;
while (iEpoch < EPOCH) {
 while (trainIterator.hasNext()) {
        DataSet trained = trainIterator.next();
        vgg16Transfer.fit(trained);
 if (i % SAVING_INTERVAL == 0 && i != 0) {
            ModelSerializer.*writeModel*(vgg16Transfer, new File(SAVING_PATH), false);
 *evaluateOn*(vgg16Transfer, devIterator, i);
        }
        i++;
    }
    trainIterator.reset();
    iEpoch++;
    evaluateOn(vgg16Transfer, testIterator, iEpoch);
}

这样,我们已经尝试使训练变得更快,但仍然可能需要几个小时甚至几天,具体取决于设置的 epoch 数量。而且,如果训练是在 CPU 上进行而不是 GPU,那么可能需要几天时间。对我来说,100 个 epoch 花了 48 小时。顺便提一下,我的机器配备的是 Core i7 处理器、32GB 内存和 GeForce GTX 1050 GPU。

时代与迭代

一个 epoch 是对数据的完全遍历,而一个迭代是对指定批量大小的一次前向传播和一次反向传播。

无论如何,一旦训练完成,训练好的模型将保存在之前指定的位置。现在让我们看看训练的结果如何。为此,我们将查看验证集上的表现(如前所述,我们使用了总训练集的 15%作为验证集,也就是 5,000 张图像):

>>>
 Cat classified by model as cat: 2444 times
 Cat classified by model as dog: 56 times
 Dog classified by model as cat: 42 times
 Dog classified by model as dog: 2458 times
 ==========================Scores==========================
 # of classes: 2
 Accuracy: 0.9800
 Precision: 0.9804
 Recall: 0.9806
 F1 Score: 0.9800
 ========================================================

然后,当我们在完整测试集(即 12,500 张图像)上评估模型时,我得到了以下的性能指标:

>>>
 Cat classified by model as cat: 6178 times
 Cat classified by model as dog: 72 times
 Dog classified by model as cat: 261 times
 Dog classified by model as dog: 5989 times
 ==========================Scores===================
 # of classes: 2
 Accuracy: 0.9693
 Precision: 0.9700
 Recall: 0.9693
 F1 Score: 0.9688
 ==================================================

恢复训练好的模型并进行推理

既然我们已经看过了模型的表现,值得探索一下恢复已训练模型的可行性。换句话说,我们将恢复训练好的模型,并在验证集和测试集上评估网络性能:

private staticfinal String TRAINED_PATH_MODEL = "bin/CatvsDog_VG16_TrainedModel_Epoch100_v1.zip";
ComputationGraph computationGraph = ModelSerializer.restoreComputationGraph(new File(TRAINED_PATH_MODEL));

VG16CatvDogEvaluator().runOnTestSet(computationGraph);
VG16CatvDogEvaluator().runOnValidationSet(computationGraph);

在前面一行代码中,首先,我们从磁盘恢复了训练好的模型;然后,我们在测试集(完整测试集)和验证集(训练集的 20%)上进行了评估。

现在,让我们看一下runOnTestSet()方法的签名,它很简单,因为我们在前面的子节中已经描述了类似的工作流程:

private void runOnTestSet(ComputationGraph computationGraph) throws IOException {
        File trainData = new File(TrainCatvsDogVG16.TEST_FOLDER);
        FileSplit test = new FileSplit(trainData, NativeImageLoader.ALLOWED_FORMATS,             
                                       TrainCatvsDogVG16.RAND_NUM_GEN);

        InputSplit inputSplit = test.sample(TrainCatvsDogVG16.*PATH_FILTER*, 100, 0)[0];
        DataSetIterator dataSetIterator = TrainCatvsDogVG16.getDataSetIterator(inputSplit);
        TrainCatvsDogVG16.evaluateOn(computationGraph, dataSetIterator, 1);
}

现在,让我们看一下runOnValidationSet方法的签名:

private void runOnValidationSet(ComputationGraph computationGraph) throws IOException {
        File trainData = new File(TrainCatvsDogVG16.TRAIN_FOLDER);
        FileSplit test = new FileSplit(trainData, NativeImageLoader.ALLOWED_FORMATS,     
                                       TrainCatvsDogVG16.RAND_NUM_GEN);

        InputSplit inputSplit = test.sample(TrainCatvsDogVG16.*PATH_FILTER*, 15, 80)[0];
        DataSetIterator dataSetIterator = TrainCatvsDogVG16.getDataSetIterator(inputSplit);
        TrainCatvsDogVG16.evaluateOn(computationGraph, dataSetIterator, 1);
}

进行简单推理

现在我们已经看到,我们训练的模型在测试集和验证集上都表现出色。那么,为什么不开发一个 UI 来帮助我们简化操作呢?如前所述,我们将开发一个简单的 UI,它将允许我们上传一张样本图片,然后我们应该能够通过按下一个按钮来检测它。这部分是纯 Java 实现的,所以我在这里不讨论细节。

如果我们运行PetClassifier.java类,它首先加载我们训练的模型,并作为后台部署该模型。然后它调用UI.java类来加载用户界面,界面如下所示:

猫狗识别器的 UI

在控制台中,你应该看到以下日志/消息:

19:54:52.496 [pool-1-thread-1] INFO org.nd4j.linalg.factory.Nd4jBackend - Loaded [CpuBackend] backend
19:54:52.534 [pool-1-thread-1] WARN org.reflections.Reflections - given scan urls are empty. set urls in the configuration
19:54:52.865 [pool-1-thread-1] INFO org.nd4j.nativeblas.NativeOpsHolder - Number of threads used for NativeOps: 4
19:54:53.249 [pool-1-thread-1] INFO org.nd4j.nativeblas.Nd4jBlas - Number of threads used for BLAS: 4
19:54:53.252 [pool-1-thread-1] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Backend used: [CPU]; OS: [Windows 10]
19:54:53.252 [pool-1-thread-1] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Cores: [8]; Memory: [7.0GB];
19:54:53.252 [pool-1-thread-1] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Blas vendor: [OPENBLAS]
19:55:09.015 [pool-1-thread-1] DEBUG org.reflections.Reflections - going to scan these urls:
 ...
9:55:13.394 [pool-1-thread-1] INFO org.deeplearning4j.nn.graph.ComputationGraph - Starting ComputationGraph with WorkspaceModes set to [training: NONE; inference: SEPARATE]
19:55:13.394 [pool-1-thread-1] DEBUG org.reflections.Reflections - going to scan these urls:
19:55:13.779 [pool-1-thread-1] INFO com.packt.JavaDL.DogvCatClassification.UI.UI - Model loaded successfully!

现在,让我们上传一些来自测试集的照片(这更有意义,因为我们正在重新使用训练好的模型,而该模型只训练了训练集,因此测试集中的图片仍然是未见过的):

我们的猫狗识别器能够识别具有不同形状和颜色的狗的图片

因此,我们训练好的模型能够识别不同形状、尺寸和颜色的狗的图片。现在,让我们尝试上传几张猫的图片,看看它是否能正常工作:

我们的猫狗识别器能够识别具有不同形状和颜色的猫的图片

常见问题解答(FAQ)

现在,我们已经通过卓越的准确性解决了猫狗分类问题,但转移学习和深度学习现象的其他实际方面也需要考虑。在本节中,我们将看到一些你可能已经在脑海中的常见问题,答案可以在附录 A 找到。

  1. 我可以用自己的动物图片来训练模型吗?

  2. 使用所有图片进行训练太慢了。我该怎么做?

  3. 我可以将这个应用程序打包成一个 Web 应用吗?

  4. 我可以使用 VGG-19 来完成这个任务吗?

  5. 我们有多少个超参数?我还想查看每一层的超参数。

总结

在本章中,我们使用转移学习技术解决了一个有趣的猫狗分类问题。我们使用了一个预训练的 VGG16 模型及其权重,然后通过使用来自 Kaggle 的现实生活猫狗数据集进行微调训练。

训练完成后,我们保存了训练好的模型,以便于模型的持久化和后续复用。我们看到,训练好的模型能够成功地检测并区分具有不同尺寸、质量和形状的猫狗图片。

即使是经过训练的模型/分类器,也可以用于解决现实生活中的猫狗问题。总结来说,这种技术通过一些最小的努力可以扩展,并用于解决类似的图像分类问题,适用于二分类和多分类问题。

在下一章中,我们将展示如何开发一个端到端的项目,在视频片段持续播放时从视频帧中检测物体。我们还将学习如何利用预训练的 TinyYOLO 模型,它是原始 YOLOv2 模型的一个小型变体。

此外,我们还将讨论一些典型的图像和视频中的物体检测挑战。然后,我们将展示如何使用边界框和非最大抑制技术来解决这些问题。最后,我们将展示如何使用 JavaCV 库和 DL4J 库处理视频片段。最后,我们还将解答一些常见问题,这些问题对于采纳和扩展这个项目非常有帮助。

问题解答

问题 1 的回答:是的,当然可以。不过,请注意,你必须提供足够数量的图像,最好每种动物类型至少提供几千张图像。否则,模型将无法训练得很好。

问题 2 的回答:一个可能的原因是你尝试一次性喂入所有图像,或者你在使用 CPU 训练(而你的机器配置不佳)。前者可以通过简单的方式解决;我们可以采用批量模式进行训练,这也是深度学习时代推荐的方式。

后者的情况可以通过将训练从 CPU 迁移到 GPU 来解决。不过,如果你的机器没有 GPU,你可以尝试迁移到 Amazon GPU 实例,支持单个(p2.xlarge)或多个 GPU(例如,p2.8xlarge)。

问题 3 的回答:提供的应用程序应该足够帮助你理解应用的有效性。不过,这个应用程序仍然可以包装成一个 Web 应用程序,在后台提供训练好的模型。

我经常使用 Spring Boot 框架(更多信息请参见 projects.spring.io/spring-boot/)来完成这项工作。除此之外,Java CUBA Studio 也可以使用(请参见 www.cuba-platform.com/)。

如本章前面提到的,VGG-16 是 VGG-19 的一个小型变体。不幸的是,无法直接使用 VGG-19。不过,读者可以尝试使用 Keras 导入 VGG-19。

问题 6 的回答:只需在网络初始化后立即使用以下代码:

//Print the number of parameters in the network (and for each layer)
Layer[] layers = model.getLayers();
int totalNumParams = 0;

for( int i=0; i<layers.length; i++ ){
         int nParams = layers[i].numParams();
         System.*out*.println("Number of parameters in layer " + i + ": " + nParams);
         totalNumParams += nParams;
}
System.*out*.println("Total number of network parameters: " + totalNumParams);
>>>
 Number of parameters in layer 0: 1792
 Number of parameters in layer 1: 36928
 Number of parameters in layer 2: 0
 Number of parameters in layer 3: 73856
 Number of parameters in layer 4: 147584
 Number of parameters in layer 5: 0
 Number of parameters in layer 6: 295168
 Number of parameters in layer 7: 590080
 Number of parameters in layer 8: 590080
 Number of parameters in layer 9: 0
 Number of parameters in layer 10: 1180160
 Number of parameters in layer 11: 2359808
 Number of parameters in layer 12: 2359808
 Number of parameters in layer 13: 0
 Number of parameters in layer 14: 2359808
 Number of parameters in layer 15: 2359808
 Number of parameters in layer 16: 2359808
 Number of parameters in layer 17: 0
 Number of parameters in layer 18: 102764544
 Number of parameters in layer 19: 16781312
 Number of parameters in layer 20: 8194
 Total number of network parameters: 134268738

第六章:使用 YOLO、JavaCV 和 DL4J 进行实时物体检测

Deep Convolutional Neural Networks (DCNN) have been used in computer vision—for example, image classification, image feature extraction, object detection, and semantic segmentation. Despite such successes of state-of-the-art approaches for object detection from still images, detecting objects in a video is not an easy job.

考虑到这一缺点,在本章中,我们将开发一个端到端的项目,该项目将在视频片段连续播放时从视频帧中检测物体。我们将利用训练好的 YOLO 模型进行迁移学习,并在Deeplearning4jDL4J)的基础上使用 JavaCV 技术来实现。简而言之,以下主题将贯穿整个端到端的项目:

  • 物体检测

  • 视频中物体检测的挑战

  • 使用 YOLO 与 DL4J

  • 常见问题解答(FAQ)

图像与视频中的物体检测

深度学习已广泛应用于各种计算机视觉任务,如图像分类、物体检测、语义分割和人体姿态估计。当我们打算解决图像中的物体检测问题时,整个过程从物体分类开始。接着我们进行物体定位,最后进行物体检测。

本项目深受 Klevis Ramo 的《Java 自动驾驶——汽车检测》一文的启发(ramok.tech/)。同时,部分理论概念(但在此需求下已显著扩展)已获得作者的授权。

物体分类、定位与检测

在物体分类问题中,给定一张图像(或视频片段),我们关心的是它是否包含感兴趣的区域(ROI)或物体。更正式地说,就是“图像包含一辆车”与“图像不包含任何车”。为了解决这个问题,在过去的几年中,ImageNet 和 PASCAL VOC(详见host.robots.ox.ac.uk/pascal/VOC/)被广泛使用,并且基于深度卷积神经网络(CNN)架构。

此外,当然,最新的技术进展(即软硬件的进步)也推动了性能提升,达到了新的水平。尽管如此,尽管最先进的方法在静态图像中的成功,视频中的物体检测依然不容易。然而,从视频中进行物体检测引出了许多新的问题、可能性和挑战,如何有效且稳健地解决视频中的物体检测问题。

解答这个问题并不容易。首先,让我们一步步地尝试解决这个问题。首先,让我们先尝试解决静态图像的情况。好吧,当我们想要使用 CNN 来预测图像是否包含特定物体时,我们需要在图像中定位物体的位置。为此,我们需要指定物体在图像中的位置,并与分类任务一起完成。这通常通过用矩形框标记物体来完成,矩形框通常被称为边界框(bounding box)。

现在,边界框的概念是,在视频的每一帧中,算法需要对每个类别的物体标注边界框和置信度分数。为了清楚地理解这一点,我们来看一下什么是边界框。边界框通常由中心 (b^x, b^y)、矩形高度 (b^h) 和矩形宽度 (b^w) 来表示,如下图所示:

边界框表示

现在我们知道如何表示这样的边界框,我们可以理解在我们的训练数据中定义这些信息所需的内容,针对每张图像中的每个物体。只有这样,网络才能输出以下内容:

  • 图像类别的概率(例如,20% 的概率是汽车,60% 的概率是公交车,10% 的概率是卡车,或 10% 的概率是火车)

  • 此外,定义物体边界框的四个变量

仅知道这些信息还不够。有趣的是,凭借关于边界框点的最小上下文信息(即中心、宽度和高度),我们的模型仍然能够进行预测,并为我们提供更详细的内容视图。换句话说,采用这种方法,我们可以解决物体定位问题,但它仍然仅适用于单一物体。

因此,我们甚至可以进一步推进,不仅仅定位单一物体,而是定位图像中的多个或所有物体,这将帮助我们向物体检测问题迈进。尽管原始图像的结构保持不变,但我们需要在单张图像中处理多个边界框。

现在,为了解决这个问题,一种最先进的技术是将图像划分为较小的矩形。我们已经看到的五个额外变量(P^c, b^x, b^y, b^h, b^w)以及每个边界框的正常预测概率,仍然适用。

这个想法听起来很简单,但它在实际中如何运作呢?如果我们只需要处理一个静态图像分类问题,事情会变得更简单。使用一种朴素方法,即从成千上万张汽车图像中裁剪出每一张汽车图像,然后训练一个卷积神经网络(例如,VGG-19)来使用所有这些图像训练模型(尽管每张图像的大小可能不同)。

典型的高速公路交通

现在,为了处理这种情况,我们可以使用滑动矩形窗口扫描图像,每次让我们的模型预测其中是否有汽车。正如我们所看到的,通过使用不同大小的矩形,我们可以为汽车及其位置推测出非常不同的形状。

尽管这种方法在检测汽车方面效果很好,但假设一个更实际的问题,比如开发自动驾驶应用。在典型的高速公路上,城市甚至郊区,会有许多汽车、公交车、卡车、摩托车、自行车和其他交通工具。此外,还会有行人、交通标志、桥梁、隔离带和路灯等其他物体。这些因素会使场景变得更加复杂。然而,实际图像的尺寸会与裁剪图像的尺寸差异很大(即,实际图像要大得多)。此外,在前方,许多汽车可能正在接近,因此需要手动调整大小、特征提取,然后进行手工训练。

另一个问题是训练算法的慢速,因此它不能用于实时视频目标检测。这个应用将被构建出来,以便大家可以学到一些有用的知识,从而将相同的知识扩展并应用到新兴的应用中,比如自动驾驶。

不管怎样,让我们回到最初的讨论。当矩形(向右、向左、向上和向下)移动时,许多共享的像素可能无法被重用,而是被反复重新计算。即使使用非常精确和不同大小的边界框,这种方法也无法非常准确地标出物体的边界框。

因此,模型可能无法非常准确地输出车辆的类别,因为框中可能只包含物体的一部分。这可能导致自动驾驶汽车容易发生事故——即,可能会与其他车辆或物体发生碰撞。为了摆脱这一限制,当前最先进的一个方法是使用卷积滑动窗口CSW)解决方案,这在 YOLO 中被广泛使用(我们稍后会看到)。

卷积滑动窗口(CSW)

在前面的子章节中,我们看到基于朴素滑动窗口的方法存在严重的性能缺陷,因为这种方法无法重用已经计算出的许多值。

然而,当每个独立的窗口移动时,我们需要为所有像素执行数百万个超参数计算才能得到预测。实际上,通过引入卷积,大部分计算可以被重用(参见第五章,使用迁移学习的图像分类,了解更多关于使用预训练的深度卷积神经网络(DCNN)架构进行图像分类的迁移学习)。这一点可以通过两种增量方式实现:

  • 通过将全连接 CNN 层转化为卷积

  • 使用 CSW

我们已经看到,无论人们使用的是哪种 DCNN 架构(例如,DarkNet、VGG-16、AlexNet、ImageNet、ResNet 和 Inception),无论其大小和配置如何,最终它们都被用来喂入全连接神经网络,具有不同数量的层,并根据类别输出多个预测结果。

此外,这些深度架构通常有非常多的层,以至于很难很好地解释它们。因此,选择一个更小的网络听起来是一个合理的选择。在以下图示中,网络以一个 32 x 32 x 3 的彩色图像(即 RGB)作为输入。然后,它使用相同的卷积,这使得前两个维度(即宽度 x 高度)保持不变,仍为 3 x 3 x 64,以获得输出 32 x 32 x 64。通过这种方式,第三维度(即 64)与卷积矩阵保持一致。

然后,放置一个最大池化层来减少宽度和高度,但保持第三维度不变,仍为 16 x 16 x 64。之后,将减少后的层输入到一个密集层,该层有两个隐藏层,每个隐藏层包含 256 和 128 个神经元。最后,网络使用 Softmax 层输出五个类别的概率。

现在,让我们看看如何将全连接FC)层替换为卷积层,同时保持输入的线性函数为 16 x 16 x 64,如下图所示:

在前面的图示中,我们只是将 FC 层替换为卷积滤波器。实际上,一个 16 x 16 x 256 的卷积滤波器相当于一个 16 x 16 x 64 x 256 的矩阵。在这种情况下,第三维度 64 总是与输入的第三维度 16 x 16 x 64 相同。因此,它可以通过省略 64 来表示为 16 x 16 x 256 的卷积滤波器,这实际上相当于对应的 FC 层。以下的数学公式可以解答这个问题:

输出: 1 x 1 x 256 = 输入: [16 x 16 x 64] * 卷积: [16 x 16 x 64 x 256]

上述数学公式意味着输出 1 x 1 x 256 的每个元素都是输入 16 x 16 x 64 中相应元素的线性函数。我们将 FC 层转换为卷积层的原因是,它将为我们提供更多的灵活性来生成网络的输出:对于 FC 层,我们将始终得到相同的输出大小,也就是类别的数量。

现在,为了看到将 FC 层替换为卷积滤波器的效果,我们需要使用一个更大的输入图像,比如 36 x 36 x 3。如果我们使用简单的滑动窗口技术,步长为 2 并且有 FC,我们需要将原始图像的大小移动九次才能覆盖所有区域,因此也需要执行九次模型。因此,采用这种方法显然没有意义。相反,让我们尝试将这个新的更大的矩阵作为输入,应用到我们只包含卷积层的新模型中。

现在,我们可以看到输出已经从 1 x 1 x 5 变成了 3 x 3 x 5,这与全连接(FC)进行对比时有所不同。再回想一下基于 CSW 的方法,我们必须将滑动窗口移动九次以覆盖所有图像,这巧妙地与卷积的 3 x 3 输出相等,不过,每个 3 x 3 的单元代表了一个 1 x 1 x 5 类别的滑动窗口的概率预测结果!因此,最终输出不再是仅有的一个 1 x 1 x 5 并经过 9 次滑动窗口移动,而是通过一次操作得到的 3 x 3 x 5。

现在,采用基于 CSW 的方法,我们已经能够解决图像中的物体检测问题。然而,这种方法并不十分准确,但仍然能在精度稍逊的情况下产生一个可接受的结果。不过,当涉及到实时视频时,情况变得更加复杂。我们将在本章稍后看到 YOLO 是如何解决剩余的局限性的。现在,先让我们试着理解从视频片段中检测物体的底层复杂性。

从视频中进行物体检测

在深入研究之前,让我们先思考一个简单的情境。假设我们有一个视频片段,包含一只猫或狼在森林中的移动。现在,我们希望在每个时间点上检测到这个移动中的动物。

下图展示了这种情境下的挑战。红色框是实际标注。图像上半部分(即a)显示静态图像物体检测方法在帧间存在较大的时间波动,甚至在实际标注的边界框上。波动可能是由运动模糊、视频失焦、部分遮挡或姿态问题引起的。相邻帧中同一物体的框的信息需要被利用,以便在视频中进行物体检测。

另一方面,(b)显示了跟踪能够关联同一物体的框。然而,由于遮挡、外观变化和姿态变化,跟踪框可能会漂移到非目标物体上。物体检测器应与跟踪算法结合,以便在漂移发生时持续开始新的跟踪。

从视频中进行物体检测的挑战(来源:Kai Kang 等,《基于卷积神经网络的视频管道物体检测》)

有一些方法可以解决这个问题。然而,大多数方法侧重于检测一种特定类别的物体,比如行人、汽车或有动作的人类。

幸运的是,类似于静态图像中的物体检测能够协助图像分类、定位和物体检测等任务,准确检测视频中的物体也可能提升视频分类的性能。通过定位视频中的物体,也可以更清晰地描述视频的语义含义,从而增强视频任务的鲁棒性。

换句话说,现有的通用目标检测方法无法有效解决这个问题。它们的性能可能会受到视频中物体外观变化较大的影响。例如,在上面的图(a)中,如果猫最初面对相机然后转身,背部的图像可能无法有效地被识别为猫,因为它包含的纹理信息很少,而且不太可能包含在训练数据中。然而,这是一个更简单的场景,我们只需要检测一个物体(即动物)。

当我们想为自动驾驶汽车开发应用程序时,我们将不得不处理许多物体和考虑因素。无论如何,由于我们无法在本章中涵盖所有方面,我们就以最基本的知识来解决这个问题。

此外,从零开始实现和训练这些类型的应用程序既耗时又具有挑战性。因此,如今,迁移学习技术正在成为流行且可行的选择。通过利用已经训练好的模型,我们可以更轻松地进行开发。一个这样的训练过的目标检测框架是 YOLO,它是最先进的实时目标检测系统之一。这些挑战和像 YOLO 这样的框架激励我以最小的努力开发这个项目。

只看一次(YOLO)

尽管我们已经通过引入卷积滑动窗口解决了静态图像中的目标检测问题,但即便使用了多个边界框尺寸,我们的模型可能仍然无法输出非常准确的边界框。让我们看看 YOLO 是如何很好地解决这个问题的:

使用边界框规格,我们查看每张图像并标记我们想要检测的目标

我们需要以特定的方式标记训练数据,以便 YOLO 算法能够正确工作。YOLO V2 格式要求边界框的尺寸为bx*、*bybh*、*bw,这些尺寸必须相对于原始图像的宽度和高度。

首先,我们通常会查看每一张图像,并标记我们想要检测的目标。之后,每张图像会被分割成更小的矩形(框),通常是 13 x 13 个矩形,但在这里为了简化,我们使用 8 x 9 个矩形。边界框(蓝色)和目标可以属于多个框(绿色),因此我们只将目标和边界框分配给包含目标中心的框(黄色框)。

通过这种方式,我们用四个附加变量(除了识别目标是汽车外)训练我们的模型(bx*、*byb^hbw*),并将这些变量分配给拥有目标中心的框*bxb^y。由于神经网络是用这些标记过的数据训练的,它也会预测这四个变量(除了目标是什么)的值或边界框。

我们不再使用预定义的边界框大小进行扫描并尝试拟合物体,而是让模型学习如何用边界框标记物体。因此,边界框现在是灵活的。这无疑是一种更好的方法,边界框的准确性更高、更灵活。

让我们看看现在如何表示输出,考虑到我们在类别(例如 1-汽车,2-行人)旁边添加了四个变量(bx*,*byb^hb^w)。实际上,还添加了另一个变量 P^c,它简单地告诉我们图像是否包含我们想要检测的任何物体。

  • P^c =1(red): 这意味着至少有一个物体存在,所以值得关注概率和边界框。

  • P^c =0(red): 该图像没有我们想要的任何物体,因此我们不关心概率或边界框规格。

结果预测,b[w,]b[h],是通过图像的高度和宽度进行归一化的。(训练标签是这样选择的。)因此,如果包含汽车的边界框预测 b[x]b[y] 为 (0.3, 0.8),那么在 13 x 13 特征图上的实际宽度和高度为 (13 x 0.3, 13 x 0.8)。

YOLO 预测的边界框是相对于拥有物体中心的框(黄色)来定义的。框的左上角从 (0, 0) 开始,右下角是 (1, 1)。由于该点位于框内,因此在这种情况下,sigmoid 激活函数确保中心 (b[x] , b[y]) 的值在 0 到 1 之间,如下图所示:

Sigmoid 激活函数确保中心点 (b[x] , b[y]) 的值在 0 到 1 之间。

虽然 b[h]b[^w] 是按照框的 wh 值(黄色)按比例计算的,值可以大于 1(对于正值使用指数)。从图中我们可以看到,边界框的宽度 b[w] 几乎是框宽度 w 的 1.8 倍。类似地,b[h] 约为框高度 h 的 1.6 倍,如下图所示:

现在,问题是物体包含在边界框中的概率是多少?要回答这个问题,我们需要知道物体得分,它表示物体被包含在边界框中的概率。对于红色及邻近的网格,它应该接近 1,而对于角落的网格来说,几乎是 0。以下公式描述了网络输出如何被转换以获取边界框预测:

在前面的公式中,b[x]b[y]b[w]b[h] 分别是我们预测的 xy 中心坐标、宽度和高度。另一方面,t[x]t[y]t[w]t[h] 是网络输出的值。此外,c[x]c[y] 是网格的左上坐标。最后,p[w]p[h] 是框的锚点尺寸。

物体性得分也通过 sigmoid 函数进行处理,因为它需要被解释为概率。然后,使用类别置信度表示检测到的物体属于特定类别的概率。预测后,我们查看预测框与最初标记的真实边界框的交集程度。我们试图最大化它们之间的交集,因此理想情况下,预测的边界框与标记的边界框完全重叠。

简而言之,我们提供足够的带有边界框的标注数据(bx*,*bybh*,*bw),然后将图像分割并分配给包含中心的框,使用 CSW 网络进行训练并预测物体及其位置。所以,首先我们进行分类,然后本地化物体并检测它。

到目前为止,我们已经能够克服大部分障碍,使用 YOLO 解决了问题。然而,实际上还有两个小问题需要解决。首先,尽管在训练时物体被分配到一个框中(即包含物体中心的框),但在推理时,训练好的模型会假设有多个框(即黄色框)包含物体的中心(即红色框)。因此,这种混淆为同一物体引入了额外的边界框。

幸运的是,非最大抑制算法可以解决这个问题:首先,算法选择一个具有最大P^c概率的预测框,这样它的值介于 0 和 1 之间,而不是二进制的 0 或 1 值。然后,移除与该框交集超过某个阈值的每个框。重复相同的逻辑,直到没有更多的边界框剩余。其次,由于我们预测多个物体(如汽车、火车、公共汽车等),两个或更多物体的中心可能位于一个框内。这个问题可以通过引入锚框来解决:

一个锚框规范

通过锚框,我们选择几种经常用于检测目标的边界框形状。然后,通过对输出应用对数空间变换,预测边界框的维度,再将其乘以锚框。

开发一个实时物体检测项目

在这一部分,我们将使用预训练的 YOLO 模型(即迁移学习)、DL4J 和 OpenCV 开发一个视频物体分类应用程序,可以检测视频帧中的标签,如汽车和树木。坦率地说,这个应用程序实际上是将图像检测问题扩展到视频检测。所以让我们开始吧。

步骤 1 - 加载一个预训练的 YOLO 模型

自 Alpha 版本 1.0.0 以来,DL4J 通过 ZOO 提供了一个 Tiny YOLO 模型。为此,我们需要在 Maven 友好的pom.xml文件中添加一个依赖项:

<dependency>
  <groupId>org.deeplearning4j</groupId>
  <artifactId>deeplearning4j-zoo</artifactId>
  <version>${dl4j.version}</version>
</dependency>

除此之外,如果可能的话,确保你通过添加以下依赖项来使用 CUDA 和 cuDNN(更多细节请参见第二章,使用递归类型网络进行癌症类型预测):

<dependency>
  <groupId>org.nd4j</groupId>
  <artifactId>nd4j-cuda-9.0-platform</artifactId>
  <version>${nd4j.version}</version>
</dependency>
<dependency>
  <groupId>org.deeplearning4j</groupId>
  <artifactId>deeplearning4j-cuda-9.0</artifactId>
  <version>${dl4j.version}</version>
</dependency>

然后,我们准备加载预训练的 Tiny YOLO 模型作为ComputationGraph,代码如下:

private ComputationGraph model; 
private TinyYoloModel() { 
        try { 
            model = (ComputationGraph) new TinyYOLO().initPretrained(); 
            createObjectLabels(); 
        } catch (IOException e) { 
            throw new RuntimeException(e); 
        } 
    }  

在前面的代码片段中,createObjectLabels()方法指的是用于训练 YOLO 2 模型的 PASCAL 视觉物体类(PASCAL VOC)数据集中的标签。该方法的签名如下所示:

private HashMap<Integer, String> labels;  
void createObjectLabels() { 
        if (labels == null) { 
            String label = "aeroplanen" + "bicyclen" + "birdn" + "boatn" + "bottlen" + "busn" + "carn" + 
                    "catn" + "chairn" + "cown" + "diningtablen" + "dogn" + "horsen" + "motorbiken" + 
                    "personn" + "pottedplantn" + "sheepn" + "sofan" + "trainn" + "tvmonitor"; 
            String[] split = label.split("\n"); 
            int i = 0; 
            labels = new HashMap<>(); 
            for(String label1 : split) { 
                labels.put(i++, label1); 
            } 
        } 
    } 

现在,让我们创建一个 Tiny YOLO 模型实例:

 static final TinyYoloModel yolo = new TinyYoloModel(); 
    public static TinyYoloModel getPretrainedModel() { 
        return yolo; 
    } 

现在,出于好奇,让我们看看模型架构以及每一层的超参数数量:

TinyYoloModel model = TinyYoloModel.getPretrainedModel(); 
System.out.println(TinyYoloModel.getSummary()); 

预训练 Tiny YOLO 模型的网络总结和层结构

因此,我们的 Tiny YOLO 模型在其 29 层网络中大约有 160 万个参数。然而,原始 YOLO 2 模型的层数更多。有兴趣的读者可以查看原始 YOLO 2,链接地址为github.com/yhcc/yolo2/blob/master/model_data/model.png

步骤 2 - 从视频片段生成帧

现在,为了处理实时视频,我们可以使用视频处理工具或框架,如 JavaCV 框架,它可以将视频拆分为单独的帧,并获取图像的高度和宽度。为此,我们需要在pom.xml文件中包含以下依赖项:

<dependency>
  <groupId>org.bytedeco</groupId>
  <artifactId>javacv-platform</artifactId>
  <version>1.4.1</version>
</dependency>

JavaCV 使用 JavaCPP 预设的库包装器,这些库通常被计算机视觉领域的研究人员使用(例如,OpenCV 和 FFmpeg),并提供了有用的工具类,使它们的功能在 Java 平台上(包括 Android)更容易使用。更多细节请参见github.com/bytedeco/javacv

对于这个项目,我收集了两段视频片段(每段 1 分钟),它们应该能让你对自动驾驶汽车有一个初步了解。我从 YouTube 上下载了以下链接的数据集:

从 YouTube 下载后(或其他方式),我将它们重命名如下:

  • SelfDrivingCar_Night.mp4

  • SelfDrivingCar_Day.mp4

现在,如果你播放这些视频片段,你会看到德国人开车时时速达到 160 km/h 甚至更快。现在,让我们解析视频(首先使用白天 1)并查看一些属性,了解视频质量的硬件要求:

String videoPath = "data/SelfDrivingCar_Day.mp4"; 
FFmpegFrameGrabber frameGrabber = new FFmpegFrameGrabber(videoPath); 
frameGrabber.start(); 

Frame frame; 
double frameRate = frameGrabber.getFrameRate(); 
System.out.println("The inputted video clip has " + frameGrabber.getLengthInFrames() + " frames"); 
System.out.println("Frame rate " + framerate + "fps"); 
>>>
 The inputted video clip has 1802 frames.
 The inputted video clip has frame rate of 29.97002997002997.

然后,我们抓取每一帧,并使用Java2DFrameConverter;它帮助我们将帧转换为 JPEG 图片:

Java2DFrameConverter converter = new Java2DFrameConverter(); 
// grab the first frame 
frameGrabber.setFrameNumber(1); 
frame = frameGrabber.grab(); 
BufferedImage bufferedImage = converter.convert(frame); 
System.out.println("First Frame" + ", Width: " + bufferedImage.getWidth() + ", Height: " + bufferedImage.getHeight()); 

// grab the second frame 
frameGrabber.setFrameNumber(2); 
frame = frameGrabber.grab(); 
bufferedImage = converter.convert(frame); 
System.out.println("Second Frame" + ", Width: " + bufferedImage.getWidth() + ", Height: " + bufferedImage.getHeight()); 
>>>
 First Frame: Width-640, Height-360
 Second Frame: Width-640, Height-360

这样,前面的代码将生成 1,802 张 JPEG 图片,与相同数量的帧一一对应。我们来看看生成的图片:

从视频片段到视频帧再到图像

因此,这段 1 分钟长的视频片段包含了相当数量的帧(即 1,800 帧),并且每秒 30 帧。简而言之,这段视频的分辨率为 720p。所以,你可以理解,处理这个视频需要较好的硬件配置,特别是配置 GPU 会有帮助。

第三步 – 将生成的帧输入 Tiny YOLO 模型

现在我们知道了片段的一些属性,可以开始生成帧并传递给 Tiny YOLO 预训练模型。首先,让我们看看一种不太重要但透明的方法:

private volatile Mat[] v = new Mat[1]; 
private String windowName = "Object Detection from Video"; 
try { 
    for(int i = 1; i < frameGrabber.getLengthInFrames();     
    i+ = (int)frameRate) { 
                frameGrabber.setFrameNumber(i); 
                frame = frameGrabber.grab(); 
                v[0] = new OpenCVFrameConverter.ToMat().convert(frame); 
                model.markObjectWithBoundingBox(v[0], frame.imageWidth, 
                                               frame.imageHeight, true, windowName); 
                imshow(windowName, v[0]); 

                char key = (char) waitKey(20); 
                // Exit on escape: 
                if (key == 27) { 
                    destroyAllWindows(); 
                    break; 
                } 
            } 
        } catch (IOException e) { 
            e.printStackTrace(); 
        } finally { 
            frameGrabber.stop(); 
        } 
        frameGrabber.close(); 

在前面的代码块中,我们将每一帧传送到模型。然后,我们使用Mat类以 n 维、密集、数值化的多通道(即 RGB)数组表示每一帧。

要了解更多信息,请访问docs.opencv.org/trunk/d3/d63/classcv_1_1Mat.html#details

换句话说,我们将视频片段分割成多个帧,并逐一传入 Tiny YOLO 模型进行处理。通过这种方式,我们对整张图像应用了一个神经网络。

第四步 – 从图像帧中进行物体检测

Tiny YOLO 从每帧中提取特征,生成一个 n 维的、密集的、数值化的多通道数组。然后将每张图像分割成较少数量的矩形(边界框):

public void markObjectWithBoundingBox(Mat file, int imageWidth, int imageHeight, boolean newBoundingBOx,
                                      String winName) throws Exception { 
        // parameters matching the pretrained TinyYOLO model
        int W = 416; // width of the video frame  
        int H = 416; // Height of the video frame 
        int gW = 13; // Grid width 
        int gH = 13; // Grid Height 
        double dT = 0.5; // Detection threshold 

        Yolo2OutputLayer outputLayer = (Yolo2OutputLayer) model.getOutputLayer(0); 
        if (newBoundingBOx) { 
            INDArray indArray = prepareImage(file, W, H); 
            INDArray results = model.outputSingle(indArray); 
            predictedObjects = outputLayer.getPredictedObjects(results, dT); 
            System.out.println("results = " + predictedObjects); 
            markWithBoundingBox(file, gW, gH, imageWidth, imageHeight); 
        } else { 
            markWithBoundingBox(file, gW, gH, imageWidth, imageHeight); 
        } 
        imshow(winName, file); 
    }

在前面的代码中,prepareImage()方法将视频帧作为图像传入,使用NativeImageLoader类进行解析,进行必要的预处理,并提取图像特征,进一步转换成INDArray格式,供模型使用:

INDArray prepareImage(Mat file, int width, int height) throws IOException { 
        NativeImageLoader loader = new NativeImageLoader(height, width, 3); 
        ImagePreProcessingScaler imagePreProcessingScaler = new ImagePreProcessingScaler(0, 1); 
        INDArray indArray = loader.asMatrix(file); 
        imagePreProcessingScaler.transform(indArray); 
        return indArray; 
    } 

然后,markWithBoundingBox()方法将在有多个边界框的情况下用于非最大抑制。

第五步 – 在有多个边界框的情况下进行非最大抑制

由于 YOLO 每个物体可能预测多个边界框,因此需要实施非最大抑制;它将所有属于同一物体的检测结果合并。因此,我们不再使用bx*、*bybh*和*bw,**而是可以使用左上角和右下角的坐标。gridWidthgridHeight是我们将图像分割成的小框数量。在我们的情况下,这个值是 13 x 13,其中wh分别是原始图像帧的宽度和高度:

void markObjectWithBoundingBox(Mat file, int gridWidth, int gridHeight, int w, int h, DetectedObject obj) {  
        double[] xy1 = obj.getTopLeftXY(); 
        double[] xy2 = obj.getBottomRightXY(); 
        int predictedClass = obj.getPredictedClass(); 
        int x1 = (int) Math.round(w * xy1[0] / gridWidth); 
        int y1 = (int) Math.round(h * xy1[1] / gridHeight); 
        int x2 = (int) Math.round(w * xy2[0] / gridWidth); 
        int y2 = (int) Math.round(h * xy2[1] / gridHeight); 
        rectangle(file, new Point(x1, y1), new Point(x2, y2), Scalar.RED); 
        putText(file, labels.get(predictedClass), new Point(x1 + 2, y2 - 2), 
                                 FONT_HERSHEY_DUPLEX, 1, Scalar.GREEN); 
    } 

最后,我们去除那些与最大抑制框重叠的物体,具体操作如下:

static void removeObjectsIntersectingWithMax(ArrayList<DetectedObject> detectedObjects, 
                                             DetectedObject maxObjectDetect) { 
        double[] bottomRightXY1 = maxObjectDetect.getBottomRightXY(); 
        double[] topLeftXY1 = maxObjectDetect.getTopLeftXY(); 
        List<DetectedObject> removeIntersectingObjects = new ArrayList<>(); 
        for(DetectedObject detectedObject : detectedObjects) { 
            double[] topLeftXY = detectedObject.getTopLeftXY(); 
            double[] bottomRightXY = detectedObject.getBottomRightXY(); 
            double iox1 = Math.max(topLeftXY[0], topLeftXY1[0]); 
            double ioy1 = Math.max(topLeftXY[1], topLeftXY1[1]); 

            double iox2 = Math.min(bottomRightXY[0], bottomRightXY1[0]); 
            double ioy2 = Math.min(bottomRightXY[1], bottomRightXY1[1]); 

            double inter_area = (ioy2 - ioy1) * (iox2 - iox1); 

            double box1_area = (bottomRightXY1[1] - topLeftXY1[1]) * (bottomRightXY1[0] - topLeftXY1[0]); 
            double box2_area = (bottomRightXY[1] - topLeftXY[1]) * (bottomRightXY[0] - topLeftXY[0]); 

            double union_area = box1_area + box2_area - inter_area; 
            double iou = inter_area / union_area;  

            if(iou > 0.5) { 
                removeIntersectingObjects.add(detectedObject); 
            } 
        } 
        detectedObjects.removeAll(removeIntersectingObjects); 
    } 

在第二个代码块中,我们将每张图像缩放为 416 x 416 x 3(即,W x H x 3 RGB 通道)。然后,这张缩放后的图像被传递给 Tiny YOLO 进行预测,并标记边界框,操作如下:

我们的 Tiny YOLO 模型预测图像中检测到的物体的类别

一旦markObjectWithBoundingBox()方法执行完成,下面的日志将被生成并显示在控制台上,包括预测的类别、bx*、*bybh*、*bw以及置信度(即检测阈值):

[4.6233e-11]], predictedClass=6),
DetectedObject(exampleNumber=0,
centerX=3.5445247292518616, centerY=7.621537864208221,
width=2.2568163871765137, height=1.9423424005508423,
confidence=0.7954192161560059,
classPredictions=[[ 1.5034e-7], [ 3.3064e-9]...

第六步 – 整合所有步骤并运行应用程序

既然到目前为止我们已经知道了我们方法的整体工作流程,现在可以将所有内容汇总,看看它是否真的有效。然而,在此之前,让我们先看一下不同 Java 类的功能:

  • FramerGrabber_ExplorartoryAnalysis.java:此类展示了如何从视频片段中抓取帧并将每一帧保存为 JPEG 图像。此外,它还展示了一些视频片段的探索性属性。

  • TinyYoloModel.java:此类实例化 Tiny YOLO 模型并生成标签。它还创建并标记带有边界框的对象。然而,它展示了如何处理每个对象的多个边界框的非最大抑制。

  • ObjectDetectorFromVideo.java:主类。它持续抓取帧并将其传递给 Tiny YOLO 模型(即,直到用户按下Esc键)。然后,它成功预测每个对象的相应类别,这些对象被检测到,并位于正常或重叠的边界框内,使用非最大抑制(如果需要)。

简而言之,首先,我们创建并实例化 Tiny YOLO 模型。然后我们抓取每一帧,并将每一帧视为单独的 JPEG 图像。接着,我们将所有图像传递给模型,模型根据之前概述的方式进行处理。整个工作流程现在可以通过以下 Java 代码进行描述:

// ObjectDetectorFromVideo.java
public class ObjectDetectorFromVideo{ 
    private volatile Mat[] v = new Mat[1]; 
    private String windowName; 

    public static void main(String[] args) throws java.lang.Exception { 
        String videoPath = "data/SelfDrivingCar_Day.mp4"; 
        TinyYoloModel model = TinyYoloModel.getPretrainedModel(); 

        System.out.println(TinyYoloModel.getSummary()); 
        new ObjectDetectionFromVideo().startRealTimeVideoDetection(videoPath, model); 
    } 

    public void startRealTimeVideoDetection(String videoFileName, TinyYoloModel model) 
           throws java.lang.Exception { 
        windowName = "Object Detection from Video"; 
        FFmpegFrameGrabber frameGrabber = new FFmpegFrameGrabber(videoFileName); 
        frameGrabber.start(); 

        Frame frame; 
        double frameRate = frameGrabber.getFrameRate(); 
        System.out.println("The inputted video clip has " + frameGrabber.getLengthInFrames() + " frames"); 
        System.out.println("The inputted video clip has frame rate of " + frameRate); 

        try { 
            for(int i = 1; i < frameGrabber.getLengthInFrames(); i+ = (int)frameRate) { 
                frameGrabber.setFrameNumber(i); 
                frame = frameGrabber.grab(); 
                v[0] = new OpenCVFrameConverter.ToMat().convert(frame); 
                model.markObjectWithBoundingBox(v[0], frame.imageWidth, frame.imageHeight, 
                                                true, windowName); 
                imshow(windowName, v[0]); 

                char key = (char) waitKey(20); 
                // Exit on escape: 
                if(key == 27) { 
                    destroyAllWindows(); 
                    break; 
                } 
            } 
        } catch (IOException e) { 
            e.printStackTrace(); 
        } finally { 
            frameGrabber.stop(); 
        } 
        frameGrabber.close(); 
    } 
} 

一旦执行前述类,应用程序应加载预训练模型,并且用户界面应加载,显示每个被分类的对象:

我们的 Tiny YOLO 模型可以同时从视频片段中预测多辆汽车(白天)

现在,为了查看我们的模型在夜间模式下的有效性,我们可以对夜间数据集进行第二次实验。为此,只需在main()方法中更改一行,如下所示:

String videoPath = "data/SelfDrivingCar_Night.mp4";

执行前述类时,应用程序应该加载预训练模型,并且用户界面应该加载,显示每个被分类的对象:

我们的 Tiny YOLO 模型可以同时从视频片段中预测多辆汽车(夜间)

此外,为了查看实时输出,可以执行给定的屏幕录制片段,展示应用程序的输出。

常见问题解答(FAQs)

在本节中,我们将看到一些可能已经出现在你脑海中的常见问题。有关这些问题的答案,请参阅附录 A。

  1. 我们不能从头开始训练 YOLO 吗?

  2. 我在想是否可以使用 YOLO v3 模型。

  3. 我需要对代码做哪些更改才能使其适用于我自己的视频片段?

  4. 提供的应用程序可以检测视频片段中的汽车和其他车辆。然而,处理并不流畅,似乎停滞不前。我该如何解决这个问题?

  5. 我可以扩展这个应用程序,使其能够处理来自摄像头的实时视频吗?

总结

在本章中,我们了解了如何开发一个端到端的项目,该项目可以在视频剪辑连续播放时从视频帧中检测对象。我们学习了如何利用预训练的 Tiny YOLO 模型,它是原始 YOLO v2 模型的一个更小的变种。

此外,我们还讨论了从静态图像和视频中进行目标检测时常见的一些挑战,并介绍了如何使用边界框和非最大抑制技术解决这些问题。我们学习了如何利用 JavaCV 库在 DL4J 之上处理视频剪辑。最后,我们还回顾了一些常见问题,这些问题对于实现和扩展这个项目非常有帮助。

在下一章中,我们将学习如何开发异常检测系统,这在金融公司(如银行、保险公司和信用合作社)的欺诈分析中非常有用。这是一个推动业务增长的重要任务。我们将使用无监督学习算法,如变分自编码器和重构概率。

问题的答案

问题 1 的回答:我们可以从头开始训练一个 YOLO 网络,但那会需要大量的工作(以及昂贵的 GPU 计算时间)。作为工程师和数据科学家,我们希望尽可能利用现成的库和机器学习模型,因此我们将使用一个预训练的 YOLO 模型,以便更快、更便宜地将我们的应用程序投入生产。

问题 2 的回答:也许可以,但最新的 DL4J 发布版本仅提供 YOLO v2。然而,当我与他们的 Gitter(见deeplearning4j.org/)进行交流时,他们告诉我,通过一些额外的努力,你可以让它工作。我的意思是,你可以通过 Keras 导入导入 YOLO v3。不幸的是,我尝试过,但没能完全实现。

问题 3 的回答:你应该能够直接输入你自己的视频。不过,如果无法正常工作,或者抛出任何不必要的异常,那么视频的属性,如帧率、宽度和每一帧的高度,应该与边界框的规格一致。

问题 4 的回答:嗯,我已经说明过,你的机器应该有良好的硬件配置,处理过程不应该造成任何延迟。例如,我的机器有 32GB 的内存,Core i7 处理器,GeForce GTX 1050 GPU,4GB 的主内存,应用程序运行得非常流畅。

问题 5 的回答:也许可以。在这种情况下,视频的主要来源应该是直接来自网络摄像头。根据github.com/bytedeco/javacv提供的文档,JavaCV 还带有硬件加速的全屏图像显示、易于使用的方法来在多个核心上并行执行代码、以及对摄像头、投影仪等设备的用户友好的几何和颜色校准。

第七章:使用 LSTM 网络进行股票价格预测

股票市场价格预测是最具挑战性的任务之一。一个主要原因是噪声和这种类型数据集的波动特性。因此,如何准确预测股价走势仍然是现代交易世界中的一个未解问题。然而,经典的机器学习算法,如支持向量机、决策树和树集成算法(例如,随机森林和梯度提升树),在过去十年中已被广泛应用。

然而,股市价格存在严重的波动性和历史视角,这使得它们适合进行时间序列分析。这也对经典算法提出了挑战,因为这些算法无法利用长期依赖关系。考虑到这些挑战和现有算法的局限性,在本章中,我们将学习如何利用 LSTM 并基于 DL4J 库开发一个真实的股票开盘或收盘价预测模型。

将使用从真实股市数据集生成的时间序列数据集来训练 LSTM 模型,该模型将用于预测一次仅一天的股票价格。简而言之,我们将在整个端到端的项目中学习以下内容:

  • 股票价格预测与在线交易

  • 数据收集与描述

  • 使用 LSTM 进行股票价格预测

  • 常见问题解答(FAQ)

先进的自动化股票交易

通常,在证券交易所,交易所会维护所有买卖订单的订单簿,包括它们的数量和价格,并在买方和卖方匹配时执行这些订单。此外,交易所还会保持并提供关于状态交易的统计数据,这些数据通常以OHCL(即开盘-最高-最低-收盘)和交易对货币的成交量形式呈现。

顺便提一下,柱状图用于展示开盘价、最高价、最低价和收盘价。与线形图不同,OHLC 图表使得技术分析师能够评估日内波动性,并查看价格的开盘和收盘情况。看看这个图表:

OHLC 定价模型展示了某一时间段的开盘价、最高价、最低价和收盘价(来源:en.tradimo.com/tradipedia/ohlc-chart/

这些数据以某些时间段的聚合形式展示,从秒到天,甚至是几个月。专门的服务器在为专业交易员和机构收集这些数据。虽然你不能指望所有订单数据都可以免费获取,但其中一部分是对公众开放的,并且可以使用。第一组数据是历史股市交易数据(OHLC),第二组数据包含股市交易的技术指标。

例如,比特币作为最早的加密货币之一,吸引了投资者和交易员的关注。这是因为以下原因:

  • 使用比特币,可以开始进行交易

  • 比特币让你保持伪匿名状态

  • 在比特币的历史中,曾经历过剧烈的增长(见下图的一些统计数据),这吸引了长期投资者

  • 存在高度的波动性,这吸引了日内交易者

难以预测比特币的长期价值,因为比特币背后的价值较为抽象,其价格主要反映市场认知,并且高度依赖于新闻、法规、政府与银行的合作、平台的技术问题,如交易费用和区块大小、机构投资者是否将比特币纳入其投资组合等。看看这个截图:

比特币及其在 2017 年 9 月之前的剧烈价格上涨(来源:http://www.bitcoin2040.com/bitcoin-price-history/)

现在的问题是如何以自动化的方式分析这个数据集,帮助投资者或在线货币交易者。好吧,在传统证券世界中,比如公司的股票,过去是由人来做分析,预测股价并进行交易。目前,比特币的交易量相较于传统交易所来说还是较低的。造成这种情况的两个原因是股市的高波动性和加密货币的监管问题。看看这个图表:

比特币的买卖订单数据(BTC/USD 对,截止 2018 年 6 月 18 日,来源:https://cex.io/trade#)

所以,现在人们主要通过购买和出售比特币进行交易,而这一切都伴随着与此相关的非理性行为,但也有一些尝试将比特币交易自动化的努力。最著名的尝试之一是麻省理工学院和斯坦福大学研究人员于 2014 年发布的一篇论文。

许多事情已经发生了变化,考虑到过去三年比特币价格的大幅上涨,任何只买入并持有的人都会对结果感到满意。显然,一些交易者使用机器学习ML)进行交易,这类应用看起来很有前景。直到现在,仍然有几种最佳的可能方法。

对于训练,使用订单簿数据,而不是衍生的OHLC + 成交量数据。因此,训练和预测时,使用以下方式的数据:

  • 将数据拆分为某一大小的时间序列(其中大小是一个可以调整的参数)。

  • 将时间序列数据聚类为K个集群,其中K是唯一需要调节的参数。假设某些具有自然趋势的集群会出现(如价格的急剧下跌/上涨等)。

  • 对每个集群,训练回归/分类器,分别预测价格和价格变化。

对于推理和评估,这种方法考虑了最新的时间序列,并使用特定窗口的大小来训练模型。然后,它会按如下方式对数据进行分类:

  • 它会采用用于训练的窗口大小的最新时间序列,并对其进行分类——它属于哪个集群?

  • 它使用机器学习模型来预测价格和价格变动的聚类

这个解决方案来源于 2014 年,但仍然具有一定的鲁棒性。由于需要识别多个参数,并且没有可用的订单簿历史数据,在本项目中我们使用了一种更简单的方法和数据集。

开发股票价格预测模型

如前所述,股市价格具有较大的波动性和历史视角,这使得它非常适合时间序列分析。这也对经典算法构成了挑战,因为这些算法无法处理长期依赖关系。

如下图所示,首先我们收集历史财务数据。数据经过必要的预处理和特征工程后,转换成时间序列。最终生成的时间序列数据被输入到 LSTM 中进行训练。下图展示了这一过程:

本项目原型的高层数据管道

因此,我们将使用 LSTM 模型,不仅因为它优于经典算法,还因为它能够解决长期依赖问题。因此,我们的项目将包括以下步骤:

  1. 加载并预处理数据,并将其划分为训练集和测试集

  2. 使用数据训练LSTM模型

  3. 在测试数据上评估模型

  4. 可视化模型的表现

我们将详细讲解每一步。但在此之前,了解数据集是必须的。

数据收集与探索性分析

如前所述,我们将使用历史股票数据来训练我们的 LSTM 网络。数据集包含来自 506 只不同证券的每分钟 OHLC 数据,时间跨度为 2016 年 1 月至 2016 年 12 月。让我们来看一下我们将使用的数据:

//DataPreview.java
SparkSession spark = SparkSession.*builder*().master("local").appName("StockPricePredictor").getOrCreate();
spark.conf().set("spark.sql.crossJoin.enabled", "true");//enables cross joining across Spark DataFrames

// load data from csv file
String filename = "data/prices-split-adjusted.csv"; 
Dataset<Row> data = spark.read().option("inferSchema", false).option("header", true)
       .format("csv").load(filename)
             .withColumn("openPrice", functions.*col*("open").cast("double")).drop("open")
             .withColumn("closePrice", functions.*col*("close").cast("double")).drop("close")
             .withColumn("lowPrice", functions.*col*("low").cast("double")).drop("low")
             .withColumn("highPrice", functions.*col*("high").cast("double")).drop("high")
             .withColumn("volumeTmp", functions.*col*("volume").cast("double")).drop("volume")
             .toDF("date", "symbol", "open", "close", "low", "high", "volume");
data.show(10);

以下快照展示了该代码的输出:

本项目使用的历史数据集快照

如前面的截图所示,我们的数据集包含七个特征,具体如下:

  • date:2016 年 1 月到 2016 年 12 月之间的时间

  • symbol:506 只不同证券的股票代码

  • open:时间区间开始时的开盘价

  • close:时间区间结束时的收盘价

  • high:该时间区间内所有订单执行时的最高价格

  • low:同样的,但为最低价格

  • volume:该时间段内所有转手的股票数量

现在让我们看看一些股票代码(更多内容请见securities.csv文件):

data.createOrReplaceTempView("stock");
spark.sql("SELECT DISTINCT symbol FROM stock GROUP BY symbol").show(10);

这是上一段代码的输出快照:

本项目使用的部分股票符号及其价格数据

如果我们需要了解证券,以下表格可以为我们提供一些信息:

本项目使用的部分证券及其详细信息

然后,我们决定查看所有个别证券的四个类别的平均价格——开盘价、收盘价、最低价和最高价。看看这个代码:

spark.sql("SELECT symbol, avg(open) as avg_open, "
                + "avg(close) as avg_close, "
                + "avg(low) as avg_low, "
                + "avg(high) as avg_high "
                + "FROM stock GROUP BY symbol")
                .show(10); 

这个快照展示了之前代码的输出:

开盘价、收盘价、最低价和最高价的平均价格。

然而,前面的表格并没有提供太多的见解,除了平均价格这一点。因此,知道最小值和最大值价格可以让我们了解股票市场是否真的有很高的波动性。看看这个代码:

spark.sql("SELECT symbol, "
                + "MIN(open) as min_open, MAX(open) as max_open, "
                + "MIN(close) as min_close, MAX(close) as max_close, "
                + "MIN(low) as min_low, MAX(low) as max_low, "
                + "MIN(high) as min_high, MAX(high) as max_high "
                + "FROM stock GROUP BY symbol")
                .show(10);   

这个快照展示了代码的输出:

开盘价、收盘价、最低价和最高价的平均最大和最小价格。

这个表格展示了例如最小开盘价和收盘价并没有显著的差异。然而,最大开盘价甚至收盘价差异很大。这是时间序列数据的特点,它促使我选择通过将数据转换为时间序列来使用 LSTM

准备训练集和测试集。

数据科学流程中最重要的部分之一,在数据收集(从某种意义上讲是外包的——我们使用了别人收集的数据)之后,就是数据预处理,即清理数据集并将其转换为适应我们需求的格式。

所以,我们的目标是预测价格变化的方向,基于实际的美元价格随时间的变化。为了做到这一点,我们定义了像 filesymbolbatchSizesplitRatioepochs 这样的变量。你可以在代码中的内联注释中看到每个变量的解释:

// StockPricePrediction.java
String file = "data/prices-split-adjusted.csv";
String symbol = "GRMN"; // stock name
int batchSize = 128; // mini-batch size
double splitRatio = 0.8; // 80% for training, 20% for testing
int epochs = 100; // training epochs

我们使用 StockDataSetIterator 构造函数变量来为模型准备数据集。在这里,我们为模型准备了一个按序列格式的输入数据集,category = PriceCategory.ALL,这意味着我们将预测所有五个价格类别(开盘价、收盘价、最低价、最高价和成交量)。看看这个代码:

//StockPricePrediction.java
System.*out*.println("Creating dataSet iterator...");
PriceCategory category = PriceCategory.*ALL*; // CLOSE: predict close price

*iterator* = new StockDataSetIterator(file, symbol, batchSize, *exampleLength*, splitRatio, category);
System.*out*.println("Loading test dataset...");
List<Pair<INDArray, INDArray>> test = *iterator*.getTestDataSet();

在前面的代码块中,我们使用的 PriceCategory 构造函数具有以下签名:

public enum PriceCategory {
      OPEN, CLOSE, LOW, HIGH, VOLUME, ALL}

在同一行中,以下选项也是有效的:

PriceCategory category = PriceCategory.OPEN; // OPEN: predict open price
PriceCategory category = PriceCategory.CLOSE; // CLOSE: predict close price
PriceCategory category = PriceCategory.LOW; // LOW: predict low price
PriceCategory category = PriceCategory.HIGH; // HIGH: predict high price.

而在内部,StockDataSetIterator 类的构造函数具有以下功能:

  • 我们从文件中读取股票数据,对于每个符号,我们创建一个列表。

  • 我们将 miniBatchSizeexampleLengthcategory 变量设置为类的属性。

  • 然后,split 变量是根据 splitRation 变量计算得出的。

  • 我们将 stockDataList 分成两部分:训练集和测试集。

  • 然后,股票数据被分割成训练集和测试集。

  • 我们调用函数 initializeOffsets() 来初始化 exampleStartOffsets 数组的值。

接下来,StockDataSetIterator() 构造函数具有以下签名,它将生成一个 List<Pair<INDArray, INDArray>> 类型的测试数据集:

//StockDataSetIterator.java
/** stock dataset for training */
private List<StockData> train;

在下面的代码中,StockData 是一个 case 类,提供了从输入的 CSV 文件中提取或准备数据集的结构:

//StockData.java
private String date; // date
private String symbol; // stock name

private double open; // open price
private double close; // close price
private double low; // low price
private double high; // high price
private double volume; // volume

public StockData () {}

public StockData (String date, String symbol, double open, double close, double low, double high, double volume) {
        this.date = date;
        this.symbol = symbol;
        this.open = open;
        this.close = close;
        this.low = low;
        this.high = high;
        this.volume = volume;
    }

然后,我们有以下的 getter 和 setter 方法,用于上述变量,如下所示:

public String getDate() { return date; }
public void setDate(String date) { this.date = date; }

public String getSymbol() { return symbol; }
public void setSymbol(String symbol) { this.symbol = symbol; }

public double getOpen() { return open; }
public void setOpen(double open) { this.open = open; }

public double getClose() { return close; }
public void setClose(double close) { this.close = close; }

public double getLow() { return low; }
public void setLow(double low) { this.low = low; }

public double getHigh() { return high; }
public void setHigh(double high) { this.high = high; }

public double getVolume() { return volume; }
public void setVolume(double volume) { this.volume = volume; }

现在我们已经看过StockData.java类的签名,是时候创建测试数据集作为StockDataSetIterator了:

/** adjusted stock dataset for testing */
private List<Pair<INDArray, INDArray>> test;

public StockDataSetIterator (String filename, String symbol, int miniBatchSize, int exampleLength, 
        double splitRatio, PriceCategory category) {
        List<StockData> stockDataList = readStockDataFromFile(filename, symbol);

        this.miniBatchSize = miniBatchSize;
        this.exampleLength = exampleLength;
        this.category = category;

        int split = (int) Math.round(stockDataList.size() * splitRatio);
        train = stockDataList.subList(0, split);
        test = generateTestDataSet(stockDataList.subList(split, stockDataList.size()));
        initializeOffsets();
    }

在前面的代码中,调用了initializeOffsets()方法来初始化小批量的偏移量:

private void initializeOffsets() {
        exampleStartOffsets.clear();
        int window = exampleLength + predictLength;
        for(int i = 0; i < train.size() - window; i++) {
              exampleStartOffsets.add(i); 
                }
    }

实际读取是通过readStockDataFromFile()方法完成的。在构造函数内部,首先,我们调用函数readStockDataFromFile()从文件中读取数据并加载到stockDataList中。然后,我们初始化StockDataList列表,以包含从csv文件中读取的数据。

接下来,我们用Double.MIN_VALUEDouble.MAX_VALUE初始化最大值和最小值数组。然后,逐行读取CSV文件中的五个值。接着将这些值依次插入到StockData对象的构造函数中,并将该对象添加到StockDataList中。此外,如果出现任何异常,我们会抛出异常。最后,方法返回StockDataList。方法的签名如下:

private List<StockData> readStockDataFromFile (String filename, String symbol) {
        List<StockData> stockDataList = new ArrayList<>();
        try {
            for(int i = 0; i < maxArray.length; i++) { // initialize max and min arrays
                maxArray[i] = Double.MIN_VALUE;
                minArray[i] = Double.MAX_VALUE;
            }
            List<String[]> list = new CSVReader(new FileReader(filename)).readAll();//load as a list
            for(String[] arr : list) {
                if(!arr[1].equals(symbol)) continue;
                double[] nums = new double[VECTOR_SIZE];

                for(int i = 0; i < arr.length - 2; i++) {
                    nums[i] = Double.valueOf(arr[i + 2]);

                    if(nums[i] > maxArray[i]) maxArray[i] = nums[i];
                    if(nums[i] < minArray[i]) minArray[i] = nums[i];
                }
                stockDataList.add(new StockData(arr[0], arr[1], nums[0], nums[1], 
                                  nums[2], nums[3], nums[4]));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return stockDataList;
    }

然后,generateTestDataSet()方法实际上生成仅能由LSTM模型消费的特征,格式为List<Pair<INDArray, INDArray>>,其中排序设置为f以便更快构建:

private List<Pair<INDArray, INDArray>> generateTestDataSet (List<StockData> stockDataList) {
        int window = exampleLength + predictLength;
        List<Pair<INDArray, INDArray>> test = new ArrayList<>();

        for (int i = 0; i < stockDataList.size() - window; i++) {
            INDArray input = Nd4j.create(new int[] {exampleLength, VECTOR_SIZE}, 'f');

            for (int j = i; j < i + exampleLength; j++) {
                StockData stock = stockDataList.get(j);
                input.putScalar(new int[] {j - i, 0}, (stock.getOpen() - minArray[0]) / (maxArray[0] - 
                     minArray[0]));
                input.putScalar(new int[] {j - i, 1}, (stock.getClose() - minArray[1]) / (maxArray[1] -    
                     minArray[1]));
                input.putScalar(new int[] {j - i, 2}, (stock.getLow() - minArray[2]) / (maxArray[2] - 
                     minArray[2]));
                input.putScalar(new int[] {j - i, 3}, (stock.getHigh() - minArray[3]) / (maxArray[3] - 
                     minArray[3]));
                input.putScalar(new int[] {j - i, 4}, (stock.getVolume() - minArray[4]) / (maxArray[4] - 
                       minArray[4]));
            }
            StockData stock = stockDataList.get(i + exampleLength);
            INDArray label;

            if (category.equals(PriceCategory.ALL)) {
                label = Nd4j.create(new int[]{VECTOR_SIZE}, 'f'); // ordering is set faster construct
                label.putScalar(new int[] {0}, stock.getOpen());
                label.putScalar(new int[] {1}, stock.getClose());
                label.putScalar(new int[] {2}, stock.getLow());
                label.putScalar(new int[] {3}, stock.getHigh());
                label.putScalar(new int[] {4}, stock.getVolume());
            } else {
                label = Nd4j.create(new int[] {1}, 'f');
                switch (category) {
                    case OPEN: label.putScalar(new int[] {0}, stock.getOpen()); break;
                    case CLOSE: label.putScalar(new int[] {0}, stock.getClose()); break;
                    case LOW: label.putScalar(new int[] {0}, stock.getLow()); break;
                    case HIGH: label.putScalar(new int[] {0}, stock.getHigh()); break;
                    case VOLUME: label.putScalar(new int[] {0}, stock.getVolume()); break;
                    default: throw new NoSuchElementException();
                }
            }
            test.add(new Pair<>(input, label));
        }
        return test;
    }

在前面的代码块中,我们将miniBatchSizeexampleLengthcategory变量保存为类属性。然后,根据splitRation变量计算split变量。接着,我们将stockDataList分为两部分:

  • 从开始到split的索引属于训练集。

  • split+1到列表末尾的索引属于测试集。

生成的测试数据与训练数据集差异很大。调用函数generatedTestDataSet()来设置测试数据集。首先,我们通过示例长度和预测长度设置一个窗口变量。然后,我们从 0 开始循环,直到测试数据的长度减去窗口大小。请考虑以下内容:

  • 读取五个输入变量:开盘价、收盘价、最低价、最高价和交易量。

  • 基于category的值,读取标签值。如果category等于ALL,则读取五个变量,如输入变量。否则,仅通过category的值读取一个变量。

在前面的代码块中,标签是通过feedLabel()方法传递的,具体如下:

private double feedLabel(StockData data) {
        double value;

        switch(category) {
            case OPEN: value = (data.getOpen() - minArray[0]) / (maxArray[0] - minArray[0]); break;
            case CLOSE: value = (data.getClose() - minArray[1]) / (maxArray[1] - minArray[1]); break;
            case LOW: value = (data.getLow() - minArray[2]) / (maxArray[2] - minArray[2]); break;
            case HIGH: value = (data.getHigh() - minArray[3]) / (maxArray[3] - minArray[3]); break;
            case VOLUME: value = (data.getVolume() - minArray[4]) / (maxArray[4] - minArray[4]); break;
            default: throw new NoSuchElementException();
        }
        return value;
    }

在前面的代码块中,我们初始化变量value。然后检查变量category的值,计算出的变量value的值可以用数学符号表示如下:

value = (data.getOpen() - minArray[0]) / (maxArray[0] - minArray[0])

然后,特征和标签都被用来准备数据集。看看这段代码:

public DataSet next(int num) {
        if(exampleStartOffsets.size() == 0) throw new NoSuchElementException();
        int actualMiniBatchSize = Math.min(num, exampleStartOffsets.size());

        INDArray input = Nd4j.create(new int[] {actualMiniBatchSize, VECTOR_SIZE, exampleLength}, 'f');
        INDArray label;

        if(category.equals(PriceCategory.ALL)) 
            label = Nd4j.create(new int[] {actualMiniBatchSize, VECTOR_SIZE, exampleLength}, 'f');
        else 
            label = Nd4j.create(new int[] {actualMiniBatchSize, predictLength, exampleLength}, 'f');

        for(int index = 0; index < actualMiniBatchSize; index++) {
            int startIdx = exampleStartOffsets.removeFirst();
            int endIdx = startIdx + exampleLength;

            StockData curData = train.get(startIdx);
            StockData nextData;

            for(int i = startIdx; i < endIdx; i++) {
                int c = i - startIdx;
                input.putScalar(new int[] {index, 0, c}, (curData.getOpen() - minArray[0]) 
                                 / (maxArray[0] - minArray[0]));
                input.putScalar(new int[] {index, 1, c}, (curData.getClose() - minArray[1]) 
                                 / (maxArray[1] - minArray[1]));
                input.putScalar(new int[] {index, 2, c}, (curData.getLow() - minArray[2]) 
                                 / (maxArray[2] - minArray[2]));
                input.putScalar(new int[] {index, 3, c}, (curData.getHigh() - minArray[3]) 
                                 / (maxArray[3] - minArray[3]));
                input.putScalar(new int[] {index, 4, c}, (curData.getVolume() - minArray[4]) 
                                 / (maxArray[4] - minArray[4]));
                nextData = train.get(i + 1);

                if(category.equals(PriceCategory.ALL)) {
                    label.putScalar(new int[] {index, 0, c}, (nextData.getOpen() - minArray[1]) 
                                    / (maxArray[1] - minArray[1]));
                    label.putScalar(new int[] {index, 1, c}, (nextData.getClose() - minArray[1]) 
                                   / (maxArray[1] - minArray[1]));
                    label.putScalar(new int[] {index, 2, c}, (nextData.getLow() - minArray[2]) 
                                   / (maxArray[2] - minArray[2]));
                    label.putScalar(new int[] {index, 3, c}, (nextData.getHigh() - minArray[3]) 
                                   / (maxArray[3] - minArray[3]));
                    label.putScalar(new int[] {index, 4, c}, (nextData.getVolume() - minArray[4]) 
                                   / (maxArray[4] - minArray[4]));
                } else {
                    label.putScalar(new int[]{index, 0, c}, feedLabel(nextData));
                }
                curData = nextData;
            }
            if(exampleStartOffsets.size() == 0) break;
        }
        return new DataSet(input, label);
    }

在前面的代码块中,我们循环epochs次数,对于每次循环,直到获取到数据,我们通过iterator.next()函数将数据拟合到网络中。请考虑以下内容:

  • 我们初始化两个变量:input使用actualMinibatchSizelabel使用category

  • 然后我们从 0 循环到actualMiniBatchSize。每次循环时,我们创建两个额外的变量:curData,它是当前时间点的StockData数据。然后我们将它们的值放入input列表中。类似地,nextData变量也是一天的StockData数据,它是curData数据的后一天。最后,我们将nextData的值放入label列表中。

LSTM 网络构建

如前所述,我编写了一个名为RecurrentNets.java的类来构建 LSTM 网络。我们创建了一个MultilayerNetwork LSTM 网络,包含一个输入层、四个 LSTM 层、三个密集层和一个输出层。输入由基因变异的序列组成。

我们使用BuildBuildLstmNetworks()方法,传入两个参数——输入层的输入数量和输出层的输出数量,如下所示:

private static final int lstmLayer1Size = 128;
private static final int lstmLayer2Size = 128;
private static final int denseLayerSize = 32;
private static final double dropoutRatio = 0.5;
private static final int truncatedBPTTLength = 22;

现在,在我们开始创建和构建网络之前,先来看看我们的模型将是什么样子:

股票价格 LSTM 网络

然后,使用createAndBuildLstmNetworks()方法根据前面的参数设置创建并构建网络:

public static MultiLayerNetwork createAndBuildLstmNetworks(int nIn, int nOut) {
        // Creating MultiLayerConfiguration 
        MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
                .seed(123456)// for the reproducibility
                .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)//optimizer
                .updater(new Adam(0.001)) // Adam updater with SGD
                .l2(1e-4)// l2 regularization
                .weightInit(WeightInit.XAVIER)// network weight initialization
                .activation(Activation.RELU)// ReLU as activation
                .list()
                .layer(0, new LSTM.Builder()//LSTM layer 1
                        .nIn(nIn)
                        .nOut(lstmLayer1Size)
                        .activation(Activation.TANH)
                        .gateActivationFunction(Activation.HARDSIGMOID)// Segment-wise linear       
                                                                       // approximation of sigmoid
                        .dropOut(dropoutRatio)// keeping drop-out ratio
                        .build())
                .layer(1, new LSTM.Builder()// LSTM layer 2
                        .nIn(lstmLayer1Size)
                        .nOut(lstmLayer2Size)
                        .activation(Activation.TANH)
                        .gateActivationFunction(Activation.HARDSIGMOID)
                        .dropOut(dropoutRatio)//kee drop-out ratio
                        .build())
                .layer(2, new LSTM.Builder()//LSTM layer 3
                        .nIn(lstmLayer1Size)
                        .nOut(lstmLayer2Size)
                        .activation(Activation.TANH)
                        .gateActivationFunction(Activation.HARDSIGMOID)
                        .dropOut(dropoutRatio)// keep drop-out ratio
                        .build())
                .layer(3, new DenseLayer.Builder()// FC layer 1
                        .nIn(lstmLayer2Size)
                        .nOut(denseLayerSize)
                        .activation(Activation.RELU)
                        .build())
                .layer(4, new DenseLayer.Builder()//FC layer 2
                        .nIn(denseLayerSize)
                        .nOut(denseLayerSize)
                        .activation(Activation.RELU)
                        .build())
                .layer(5, new DenseLayer.Builder()//FC layer 3
                        .nIn(denseLayerSize)
                        .nOut(denseLayerSize)
                        .activation(Activation.RELU)
                        .build())
                .layer(6, new RnnOutputLayer.Builder() // RNN output layer
                        .nIn(denseLayerSize)
                        .nOut(nOut)
                        .activation(Activation.IDENTITY)// Regression with MSE as the loss function
                        .lossFunction(LossFunctions.LossFunction.MSE)
                        .build())
                .backpropType(BackpropType.TruncatedBPTT)// Back propagation with time
                .tBPTTForwardLength(truncatedBPTTLength)
                .tBPTTBackwardLength(truncatedBPTTLength)
                .pretrain(false).backprop(true)//no pretraining necessary
                .build();

        // Creating MultiLayerNetwork using the above MultiLayerConfig
        MultiLayerNetwork net = new MultiLayerNetwork(conf);
        net.init(); // initilize the MultiLayerNetwork
        net.setListeners(new ScoreIterationListener(100));// shows score in each 100th iteration/epoch
        return net; // return the MultiLayerNetwork
    }

由于我们在本章中多次创建并使用了LSTM网络,我决定不讨论它的详细内容。不过,这里有一个重要的点是使用了IDENTITY激活函数,并结合均方根误差(RMSE),它通常用于回归问题。

简而言之,要在 DL4J 中执行回归分析,你需要设置一个多层神经网络,并在末尾添加一个输出层,具有如下属性,如前所示:

//Create output layer
    .layer()
    .nIn($NumberOfInputFeatures)
    .nOut(1)// regression hence, only a single output
    .activation(Activation.IDENTITY)//Regression with RMSE as the loss function
    .lossFunction(LossFunctions.LossFunction.RMSE)

有关使用 DL4J 进行回归分析的更多信息,感兴趣的读者可以访问deeplearning4j.org/evaluation#Regression

网络训练,以及保存训练好的模型

现在,既然我们的网络以及训练和测试集已经准备好,我们就可以开始训练网络了。为此,我们再次使用 DL4J 提供的fit()方法。我们循环epochs次数,每次循环直到获得数据。我们在每个时间步中使用miniBatchSize数量的数据来拟合网络,如下所示:

// StockPricePrediction.java
System.out.println("Training LSTM network...");
for(int i = 0; i < epochs; i++) {
            while(iterator.hasNext()) net.fit(iterator.next()); // fit model using mini-batch data
            iterator.reset(); // reset iterator
            net.rnnClearPreviousState(); // clear previous state
        }

>>
 Creating dataSet iterator...
 Loading test dataset...
 Building LSTM networks...
 Training LSTM network...

训练完成后,我们将训练好的模型保存到磁盘(在data目录中)。这里我指定了一个示例名称StockPriceLSTM_+ category name + .zip,如下所示:

# StockPricePrediction.java
System.*out*.println("Saving model...");
File locationToSave = new File("data/StockPriceLSTM_".concat(String.*valueOf*(category)).concat(".zip"));

// saveUpdater: i.e., state for Momentum, RMSProp, Adagrad etc. Save this to train your network in future
ModelSerializer.*writeModel*(net, locationToSave, true);

现在让我们来看一下每层的参数数量:

//Print the  number of parameters in the network (and for each layer)
Layer[] layers_before_saving = net.getLayers();
              int totalNumParams_before_saving = 0;

              for(int i=0; i<layers_before_saving.length; i++ ){
                  int nParams = layers_before_saving[i].numParams();
                  System.out.println("Number of parameters in layer " + i + ": " + nParams);
                  totalNumParams_before_saving += nParams;
              }
System.out.println("Total number of network parameters: " + totalNumParams_before_saving);
>>>
 Saving model...
 Number of parameters in layer 0: 68608
 Number of parameters in layer 1: 131584
 Number of parameters in layer 2: 131584
 Number of parameters in layer 3: 4128
 Number of parameters in layer 4: 1056
 Number of parameters in layer 5: 1056
 Number of parameters in layer 6: 165
 Total number of network parameters: 338181

尽管如此,我们启用了 DL4J UI 以查看训练进度和参数,如下所示:

//Initialize the user interface backend
UIServer uiServer = UIServer.*getInstance*();

//Configure where the network information (gradients, activations, score vs. time etc) is to be stored. //Then add the StatsListener to collect this information from the network, as it trains:
StatsStorage statsStorage = new InMemoryStatsStorage();

//Alternative: new FileStatsStorage(File) - see UIStorageExample. Attach the StatsStorage instance to the //UI: this allows the contents of the StatsStorage to be visualized:
uiServer.attach(statsStorage);

int listenerFrequency = 1;
net.setListeners(new StatsListener(statsStorage, listenerFrequency));

以下截图显示了输出:

用户界面上的网络参数

图表看起来似乎没有进行正则化,可能是因为我们没有足够的训练数据。

恢复已保存的模型进行推断

现在我们已经完成了训练,并且训练好的模型已在手,我们可以直接使用该训练模型进行推断,或者从磁盘恢复已保存的模型,或者开始推理。看看这段代码:

System.*out*.println("Restoring model...");
net = ModelSerializer.*restoreMultiLayerNetwork*(locationToSave);

//print the score with every 1 iteration
net.setListeners(new ScoreIterationListener(1));

//Print the number of parameters in the network (and for each layer)
Layer[] layers = net.getLayers(); 

int totalNumParams = 0;
for( int i=0; i<layers.length; i++ ){
        int nParams = layers[i].numParams(); 
       System.*out*.println("Number of parameters in layer " + i + ": " + nParams);
       totalNumParams += nParams;
}
System.*out*.println("Total number of network parameters: " + totalNumParams);
>>>
 Restoring model...
 Number of parameters in layer 0: 68608
 Number of parameters in layer 1: 131584
 Number of parameters in layer 2: 131584
 Number of parameters in layer 3: 4128
 Number of parameters in layer 4: 1056
 Number of parameters in layer 5: 1056
 Number of parameters in layer 6: 165
 Total number of network parameters: 338181

评估模型

参数的数量与我们在磁盘上保存的一样。这意味着我们的训练模型没有受到污染,因此我们是安全的。接下来,我们开始在测试集上评估模型。但是,正如前面所说,我们将对模型进行双向评估。首先,我们预测某只股票的一个特征,提前一天,如下所示:

/** Predict one feature of a stock one-day ahead */
private static void predictPriceOneAhead (MultiLayerNetwork net, List<Pair<INDArray, INDArray>> testData, double max, double min, PriceCategory category) {
        double[] predicts = new double[testData.size()];
        double[] actuals = new double[testData.size()];

        for (int i = 0; i < testData.size(); i++) {
            predicts[i] = net.rnnTimeStep(testData.get(i).getKey()).getDouble(exampleLength - 1) 
                          * (max - min) + min;
            actuals[i] = testData.get(i).getValue().getDouble(0);
        }

        RegressionEvaluation eval = net.evaluateRegression(iterator);   
        System.out.println(eval.stats());

        System.out.println("Printing predicted and actual values...");
        System.out.println("Predict, Actual");

        for (int i = 0; i < predicts.length; i++) 
            System.out.println(predicts[i] + "," + actuals[i]);

        System.out.println("Plottig...");
        PlotUtil.plot(predicts, actuals, String.valueOf(category));
    }

在前面的代码块中,我们对单一类别进行训练,例如,设置以下任意选项:

PriceCategory category = PriceCategory.OPEN; // OPEN: predict open price
PriceCategory category = PriceCategory.CLOSE; // CLOSE: predict close price
PriceCategory category = PriceCategory.LOW; // LOW: predict low price
PriceCategory category = PriceCategory.HIGH; // HIGH: predict high price

我们可以同时对所有类别进行评估,方法是设置PriceCategory category = PriceCategory.***ALL***; // **ALL**: 预测收盘价

因此,我们预测了所有股票特征(开盘价、收盘价、最低价、最高价和交易量)的一天后值。对一个类别的评估过程在所有类别中都是相同的。唯一不同的是:我们需要使用PlotUtil循环遍历多个类别,绘制XY折线图,如下所示:

/** Predict all the features (open, close, low, high prices and volume) of a stock one-day ahead */
private static void predictAllCategories (MultiLayerNetwork net, List<Pair<INDArray, INDArray>> testData, INDArray max, INDArray min) {
        INDArray[] predicts = new INDArray[testData.size()];
        INDArray[] actuals = new INDArray[testData.size()];
        for(int i = 0; i < testData.size(); i++) {
            predicts[i] = net.rnnTimeStep(testData.get(i).getKey()).getRow(exampleLength - 1)
                          .mul(max.sub(min)).add(min);
            actuals[i] = testData.get(i).getValue();
        }

        System.out.println("Printing predicted and actual values...");
        System.out.println("Predict, Actual");

        for(int i = 0; i < predicts.length; i++) 
            System.out.println(predicts[i] + "\t" + actuals[i]);
        System.out.println("Plottig...");

        RegressionEvaluation eval = net.evaluateRegression(iterator);   
        System.out.println(eval.stats());

        for(int n = 0; n < 5; n++) {
            double[] pred = new double[predicts.length];
            double[] actu = new double[actuals.length];

            for(int i = 0; i < predicts.length; i++) {
                pred[i] = predicts[i].getDouble(n);
                actu[i] = actuals[i].getDouble(n);
            }
            String name;
            switch(n) {
                case 0: name = "Stock OPEN Price"; break;
                case 1: name = "Stock CLOSE Price"; break;
                case 2: name = "Stock LOW Price"; break;
                case 3: name = "Stock HIGH Price"; break;
                case 4: name = "Stock VOLUME Amount"; break;
                default: throw new NoSuchElementException();
            }
            PlotUtil.plot(pred, actu, name);
        }
    }

在前面的代码块中,我们进入函数predictAllCategories(),查看在所有类别中的评估过程。接下来,我们创建两个数组,predictsactuals,分别用于存储预测结果和实际结果。然后我们遍历测试数据。接着我们执行以下操作:

  • 调用函数net.rnnTimeStep(),参数为第 i 行的键,并将结果附加到predicts列表中

  • 实际值来自测试数据行i^(th)的值

  • 打印预测值和实际值

最后,我们遍历五个类别;我们使用PlotUtil.java来绘制预测值与实际值之间的XY折线图。请考虑以下内容:

  • 最初的两个双精度数组分别命名为predactu,其大小与预测的长度相等。

  • 遍历predictsactuals数组,获取每个列表中每个元素的双精度值。

  • 每个n的值有四个从 0 到 4 的值。将变量name设置为Y列的边缘。

  • 调用PlotUtil函数来绘制XY折线图。

顺便提一下,PlotUtil.java类用于绘制预测值与实际值的XY折线图,代码如下:

public static void plot(double[] predicts, double[] actuals, String name) {
        double[] index = new double[predicts.length];
        for(int i = 0; i < predicts.length; i++)
            index[i] = i;

        int min = minValue(predicts, actuals);
        int max = maxValue(predicts, actuals);

        final XYSeriesCollection dataSet = new XYSeriesCollection();
        addSeries(dataSet, index, predicts, "Predicted");
        addSeries(dataSet, index, actuals, "Actual");

        final JFreeChart chart = ChartFactory.createXYLineChart(
                "Predicted vs Actual", // chart title
                "Index", // x axis label
                name, // y axis label
                dataSet, // data
                PlotOrientation.VERTICAL,
                true, // include legend
                true, // tooltips
                false // urls
              );

        XYPlot xyPlot = chart.getXYPlot();

        // X-axis
        final NumberAxis domainAxis = (NumberAxis) xyPlot.getDomainAxis();
        domainAxis.setRange((int) index[0], (int) (index[index.length - 1] + 2));
        domainAxis.setTickUnit(new NumberTickUnit(20));
        domainAxis.setVerticalTickLabels(true);

        // Y-axis
        final NumberAxis rangeAxis = (NumberAxis) xyPlot.getRangeAxis();
        rangeAxis.setRange(min, max);
        rangeAxis.setTickUnit(new NumberTickUnit(50));

        final ChartPanel panel = new ChartPanel(chart);
        final JFrame f = new JFrame();
        f.add(panel);
        f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        f.pack();
        f.setVisible(true);
    }

在前面的代码块中,addSeries()方法用于添加XY系列,代码如下:

private static void addSeries (final XYSeriesCollection dataSet, double[] x, double[] y, final String label){
        final XYSeries s = new XYSeries(label);
        for(int j = 0; j < x.length; j++ ) s.add(x[j], y[j]);
        dataSet.addSeries(s);
    }

除了这些,找出我们在前面代码中使用的predictedactual值的最小值和最大值,过程如下:

  • 寻找最小值:首先,我们将变量min设置为MAX_VALUE。然后我们遍历predictedactual数组,如果min大于任何元素,则将min重置为当前元素。接着我们取min值的最接近下界的整数:
private static int minValue (double[] predicts, double[] actuals) {
        double min = Integer.MAX_VALUE;

        for(int i = 0; i < predicts.length; i++) {
            if(min > predicts[i]) min = predicts[i];
            if(min > actuals[i]) min = actuals[i];
        }
        return (int) (min * 0.98);
    }
  • 查找最大值:首先,我们将变量 max 设置为 MIN_VALUE。然后,我们循环遍历 predictsactual 数组,如果 max 小于某个元素,则将 max 重置为该元素。接着,我们取 max 值上限最接近的整数,如下所示:
private static int maxValue (double[] predicts, double[] actuals) {
        double max = Integer.MIN_VALUE;

        for(int i = 0; i < predicts.length; i++) {
            if(max < predicts[i]) max = predicts[i];
            if(max < actuals[i]) max = actuals[i];
        }
        return (int) (max * 1.02);
    }

最后,我们使用 addSeries() 方法在绘制图表时将一个系列添加到数据集。然而,由于这是一个回归任务,我们也会展示回归指标,例如 MSEMAER2 等。

现在,基于前述计划和变量 category 的值,我们有两种方法来评估模型。如果类别是 ALL,那么网络将预测所有类别;否则,网络只会处理一个类别。首先,对于单一类别,比如 OPEN,请查看以下代码:

System.out.println("Evaluating...");
if(category.equals(PriceCategory.OPEN)) {
            INDArray max = Nd4j.create(iterator.getMaxArray());
            INDArray min = Nd4j.create(iterator.getMinArray());
            predictAllCategories(net, test, max, min);
} else {
            double max = iterator.getMaxNum(category);
            double min = iterator.getMinNum(category);
            predictPriceOneAhead(net, test, max, min, category);
 }
System.out.println("Done...");
>>>
 Evaluating...
 Printing predicted and actual values...
 Predict, Actual
 ---------------------------------------
 29.175033326034814,35.61000061035156
 29.920153324534823,35.70000076293945
 30.84457991629533,35.9900016784668
 31.954761620513793,36.150001525878906
 33.171770076832885,36.79999923706055
 34.42622247035372,36.150001525878906
 35.63831635695636,36.41999816894531
 36.79695794284552,36.04999923706055
 37.79222186089784,35.9900016784668
 38.45504267616927,35.470001220703125
 38.837315702846766,35.66999816894531

然后回归指标将会如下所示(尽管你的结果可能略有不同):

Column MSE MAE RMSE RSE PC R²
 -------------------------------------------------------------------------------------------
 col_0 3.27134e-02 1.14001e-01 1.80868e-01 5.53901e-01 7.17285e-01 4.46100e-01

最后,我们观察到以下截图,展示了预测价格与实际 OPEN 类别价格的对比:

OPEN 类别的预测与实际价格对比

然后,对于仅 **ALL** 类别,我们运行类似的代码,唯一不同的是使用了 PriceCategory.ALL,如下所示:

System.out.println("Evaluating...");
if(category.equals(PriceCategory.ALL)) {
            INDArray max = Nd4j.create(iterator.getMaxArray());
            INDArray min = Nd4j.create(iterator.getMinArray());
            predictAllCategories(net, test, max, min);
} else {
            double max = iterator.getMaxNum(category);
            double min = iterator.getMinNum(category);
            predictPriceOneAhead(net, test, max, min, category);
   }
System.out.println("Done...");
>>>
 Evaluating...
 Printing predicted and actual values...
 Predict, Actual
 ------------ ---------------------------------------------------------------
 [[27.8678,27.1462,27.0535,27.9431, 9.7079e5]] [[35.6100,35.8900,35.5500,36.1100, 1.5156e6]]
 [[28.3925,27.2648,27.2769,28.4423, 1.2579e6]] [[35.7000,35.8100,35.6500,36.1000,8.623e5]]
 [[29.0413,27.4402,27.6015,29.1540, 1.6014e6]] [[35.9900,36.1400,35.9000,36.3200, 1.0829e6]]
 [[29.9264,27.6811,28.0419,30.1133, 2.0673e6]] [[36.1500,36.7100,36.0700,36.7600, 1.0635e6]]
 [[30.9201,27.9385,28.5584,31.2908, 2.5381e6]] [[36.8000,36.5700,36.4600,37.1600, 1.0191e6]]
 [[32.0080,28.2469,29.1343,32.6514, 3.0186e6]] [[36.1500,36.2300,35.9300,36.7600, 1.8299e6]]
 [[33.1358,28.5809,29.7641,34.1525, 3.4644e6]] [[36.4200,36.5400,36.1800,36.8900,8.774e5]]
 [[45.2637,31.2634,39.5828,53.1128, 5.0282e6]] [[50.3600,49.2200,49.1700,50.4500,9.415e5]]
 [[45.1651,31.2336,39.5284,52.9815, 4.9879e6]] [[49.1700,49.0100,48.8100,49.4400,9.517e5]]

然后回归指标将会如下所示(尽管你的结果可能略有不同):

Column MSE MAE RMSE RSE PC R²
 -------------------------------------------------------------------------------------------------
 col_0 4.52917e-02 1.35709e-01 2.12819e-01 7.49715e-01 6.60401e-01 2.50287e-01
 col_1 1.52875e-01 3.27669e-01 3.90993e-01 2.54384e+00 6.61151e-01 -1.54384e+00
 col_2 8.46744e-02 2.19064e-01 2.90989e-01 1.41381e+00 6.01910e-01 -4.13806e-01
 col_3 6.05071e-02 1.93558e-01 2.45982e-01 9.98581e-01 5.95618e-01 1.41977e-03
 col_4 2.34488e-02 1.17289e-01 1.53130e-01 9.97561e+00 5.59983e-03 -8.97561e+00

现在看看以下图表,展示了预测价格与实际 ALL 类别价格的对比:

ALL 类别的预测与实际价格对比

从图表中我们可以看到,OPENHIGH 的价格表现得较为匹配,而 LOW 的表现则稍微较好。遗憾的是,CLOSEVOLUME 的匹配程度非常令人失望(请参见前面的回归结果表)。一个可能的原因是数据不足。另外,使用的超参数完全没有进行超参数调优。不过,大部分超参数是天真地选择的。

常见问题解答 (FAQs)

在这一部分,我们将看到一些可能已经浮现在你脑海中的常见问题。答案可以在附录 A 中找到:

  1. 我可以将这个项目扩展用于比特币价格预测吗?如果可以,如何做以及在哪里可以获得这样的数据集?

  2. 如果你将预测值作为输入进行下一次预测,会发生什么?

  3. 我理解这是一个回归问题,但我如何预测价格是会上涨还是下跌?

  4. 我想扩展这个应用并部署一个 Web 应用程序。我该怎么做?

  5. 我想将这个应用扩展,不仅用于价格预测,还用于价格的异常检测。我该怎么做?

  6. 我可以使用类似的技术进行股票价格推荐吗?

总结

在本章中,我们展示了如何开发一个示范项目,用于预测五个类别的股票价格:OPEN(开盘价)、CLOSE(收盘价)、LOW(最低价)、HIGH(最高价)和VOLUME(成交量)。然而,我们的方法不能生成实际的信号。尽管如此,它仍然提供了使用 LSTM 的一些思路。我知道这种方法存在一些严重的缺点。然而,我们并没有使用足够的数据,这可能限制了该模型的性能。

在下一章中,我们将看到如何将深度学习方法应用于视频数据集。我们将描述如何处理和提取来自大量视频片段的特征。然后,我们将通过在多个设备(CPU 和 GPU)上分布式训练,并进行并行运行,使整个流程更加可扩展和高效。

我们将看到如何开发一个深度学习应用的完整示例,该应用能够准确地分类大规模视频数据集,如UCF101,使用结合 CNN-LSTM 网络与 DL4J。这克服了独立 CNN 或 LSTM 网络的局限性。训练将在 Amazon EC2 GPU 计算集群上进行。最终,这个端到端的项目可以作为视频中人类活动识别的入门指南。

问题的答案

问题 1 的答案: 一些历史比特币数据可以从 Kaggle 下载,例如,www.kaggle.com/mczielinski/bitcoin-historical-data/data

下载数据集后,尝试提取最重要的特征,并将数据集转换为时间序列,这样就可以输入到 LSTM 模型中。然后,模型可以通过每个时间步的时间序列进行训练。

问题 2 的答案: 我们的示例项目只计算那些实际股价已给出的股票的股价,而不是第二天的股价。它显示的是实际预测,但是第二天的股价应仅包含预测。如果我们将预测值作为输入进行下一次预测,就会出现这种情况:

预测与实际价格对比,针对所有类别,预测值作为下一次预测的输入

问题 3 的答案: 好的,那么这个任务将是一个二分类问题。为了实现这一点,您需要进行两个更改:

  • 转换数据集,使其包含两个标签

  • IDENTITY 激活函数和 RMSE 损失函数替换为交叉熵损失函数

问题 4 的答案: 这是个很好的主意。你可以尝试通过问题 1 和 2 来改进建模。然后,你可以将模型保存到磁盘,以便后续推理。最后,你可以像前面章节建议的那样,将这个模型作为 web 应用提供服务。

回答第 5 题: 在这样的数据集中应用异常检测非常具有挑战性,我不确定是否可行,因为市场波动性非常大。因此,时间序列有时会经历非常多的波动,这是股市的本质。这有助于训练好的模型识别出这种异常波动。

回答第 6 题: 是的,你可以。你可以尝试使用基于机器学习的 ZZAlpha 有限公司股票推荐 2012-2014 数据集。该数据集可以从UCI ML 仓库下载,网址是archive.ics.uci.edu/ml/datasets/Machine+Learning+based+ZZAlpha+Ltd.+Stock+Recommendations+2012-2014。仓库中还描述了问题和数据集的详细信息。

第八章:分布式深度学习 – 使用卷积 LSTM 网络进行视频分类

到目前为止,我们已经看到如何在数字和图像上开发基于深度学习的项目。然而,将类似的技术应用于视频片段,例如从视频中进行人类活动识别,并不是一件简单的事。

在本章中,我们将看到如何将深度学习方法应用于视频数据集。我们将描述如何处理和提取大量视频片段的特征。然后,我们将通过在多个设备(CPU 和 GPU)上分配训练,并使其并行运行,从而使整体管道变得可扩展且更快。

我们将看到一个完整的示例,展示如何使用Deeplearning4jDL4J)开发一个深度学习应用程序,准确地分类大型视频数据集(如 UCF101 数据集)。该应用程序结合了 CNN 和 LSTM 网络,克服了独立 CNN 或 RNN 长短时记忆LSTM)网络的局限性。

训练将在 Amazon EC2 GPU 计算集群上进行。最终,这个端到端项目可以作为从视频中进行人类活动识别的入门指南。简而言之,我们将在整个端到端项目中学习以下主题:

  • 在多个 GPU 上进行分布式深度学习

  • 数据集收集与描述

  • 使用卷积-LSTM 网络开发视频分类器

  • 常见问题解答(FAQ)

在多个 GPU 上进行分布式深度学习

如前所述,我们将看到一个系统的示例,展示如何使用卷积-LSTM 网络对UCF101数据集中的大量视频片段进行分类。然而,首先我们需要知道如何将训练分配到多个 GPU 上。在之前的章节中,我们讨论了多种先进技术,如网络权重初始化、批量归一化、更快的优化器、适当的激活函数等,这些无疑有助于网络更快地收敛。然而,单机训练一个大型神经网络可能需要数天甚至数周。因此,这种方法不适用于处理大规模数据集。

理论上,神经网络的分布式训练主要有两种方法:数据并行和模型并行。DL4J 依赖于数据并行,称为具有参数平均的分布式深度学习。然而,多媒体分析通常会使事情变得更加复杂,因为从一个视频片段中,我们可以看到成千上万的帧和图像等等。为了避免这个问题,我们将首先在一台机器上的多个设备上分配计算,然后在多个机器的多个设备上进行分布式训练,具体如下:

在多个设备上并行执行 DL4J Java 应用程序

例如,你通常可以在一台机器上使用八个 GPU 训练神经网络,而不必使用跨多台机器的 16 个 GPU,原因很简单——在多机器设置中,网络通信带来的额外延迟。下图显示了如何配置 DL4J 来使用 CUDA 和 cuDNN 控制 GPU 并加速 DNN:

DL4J 使用 CUDA 和 cuDNN 来控制 GPU 并加速 DNN。

在 GPU 上使用 DL4J 进行分布式训练

DL4J 既支持分布式 GPU,也支持本地(即有 CPU 后端的)GPU。它允许用户在单个 GPU 上本地运行,比如 Nvidia Tesla、Titan 或 GeForce GTX,也可以在 Nvidia GRID GPU 上的云端运行。我们还可以在安装了多个 GPU 的 Amazon AWS EC2 GPU 集群上进行训练。

为了在 GPU 上训练神经网络,你需要对根目录下的 pom.xml 文件进行一些更改,例如属性设置和依赖管理,以拉取 DL4J 团队提供的必需依赖。首先,我们处理项目属性,如下所示:

<properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
        <jdk.version>1.8</jdk.version>
        <nd4j.backend>nd4j-cuda-9.0-platform</nd4j.backend>
        <nd4j.version>1.0.0-alpha</nd4j.version>
        <dl4j.version>1.0.0-alpha</dl4j.version>
        <datavec.version>1.0.0-alpha</datavec.version>
        <arbiter.version>1.0.0-alpha</arbiter.version>
        <logback.version>1.2.3</logback.version>
</properties>

在前面的 <properties> 标签中,如条目所示,我们将使用 DL4J 1.0.0-alpha 版本,并以 CUDA 9.0 平台作为后端。此外,我们计划使用 Java 8。不过,还定义了一个额外的 logback 属性。

Logback 是流行的 log4j 项目的继任者,承接了 log4j 的发展。Logback 的架构足够通用,能够在不同情况下应用。目前,logback 被划分为三个模块:logback-core、logback-classic 和 logback-access。欲了解更多信息,请参阅 logback.qos.ch/

我假设你已经配置好了 CUDA 和 cuDNN,并且相应地设置了路径。一旦我们定义了项目属性,接下来重要的任务是定义与 GPU 相关的依赖,如下所示:

<dependency>
     <groupId>org.nd4j</groupId>
     <artifactId>nd4j-cuda-9.0-platform</artifactId>
     <version>${nd4j.version}</version>
</dependency>
<dependency>
      <groupId>org.deeplearning4j</groupId>
      <artifactId>deeplearning4j-cuda-9.0</artifactId>
      <version>${dl4j.version}</version>
</dependency>

其中,ND4J 是支持 DL4J 的数值计算引擎,充当其后端,或者说是它工作的不同硬件类型。如果你的系统安装了多个 GPU,你可以在数据并行模式下训练模型,这被称为 多 GPU 数据并行。DL4J 提供了一个简单的封装器,可以实例化,类似于这样:

// ParallelWrapper will take care of load balancing between GPUs. ParallelWrapper wrapper = new ParallelWrapper.Builder(YourExistingModel)
     .prefetchBuffer(24)
     .workers(8)
     .averagingFrequency(1)
     .reportScoreAfterAveraging(true)
     .useLegacyAveraging(false)
     .build();

更具体的示例如下所示:

ParallelWrapper wrapper = new ParallelWrapper.Builder(net)            
            .prefetchBuffer(8)// DataSets prefetching options. Set to number of actual devices
            .workers(8)// set number of workers equal to number of available devices 
            .averagingFrequency(3)// rare averaging improves performance, but reduce accuracy           
            .reportScoreAfterAveraging(true) // if set TRUE, on every averaging model's score reported
            .build();

ParallelWrapper 将现有模型作为主要参数,并通过将工作者数量保持为等于或大于机器上 GPU 数量的方式进行并行训练。

ParallelWrapper 内,初始模型将被复制,每个工作者将训练自己的模型。在 averagingFrequency(X) 中的每 N 次迭代后,所有模型将被平均,并继续训练。现在,要使用此功能,请在 pom.xml 文件中使用以下依赖:

<dependency>
      <groupId>org.deeplearning4j</groupId>
      <artifactId>deeplearning4j-parallel-wrapper_2.11</artifactId>
      <version>${dl4j.version}</version>
</dependency>

对于最新的文档,感兴趣的读者可以查看以下链接:deeplearning4j.org/gpu

现在我们已经对如何在多个 GPU 之间分配基于深度学习的训练有了理论理解。在接下来的部分中,我们将很快看到一个动手示例。

使用卷积 – LSTM 进行视频分类

在本节中,我们将开始结合卷积、最大池化、全连接和递归层来对每一帧视频进行分类。具体来说,每个视频包含多个持续多帧的人的活动(尽管它们在帧之间移动),并且可能离开画面。首先,让我们更详细地了解我们将用于此项目的数据集。

UCF101 – 动作识别数据集

UCF101是一个真实动作视频的动作识别数据集,收集自 YouTube,包含 101 个动作类别,涵盖了 13,320 个视频。视频收集时考虑到了摄像机运动、物体外观与姿势、物体尺度、视角、杂乱背景和光照条件的变化。

101 个动作类别的视频进一步被聚类成 25 个组(每个组中的剪辑具有共同的特征,例如背景和视角),每个组包含四到七个同一动作的视频。共有五个动作类别:人类与物体交互、仅身体动作、人类与人类交互、演奏乐器和体育运动。

关于该数据集的更多事实:

  • UCF101 视频包含不同的帧长度,每个视频剪辑的帧数范围在 100 到 300 帧之间。

  • UCF101使用XVID压缩标准(即.avi格式)

  • UCF101数据集的图片大小为 320 x 240

  • UCF101数据集包含不同视频文件中的不同类别。

数据集的高层次概览如下:

来自UCF50数据集的一些随机剪辑(来源:crcv.ucf.edu/data/UCF50.php

预处理和特征工程

处理视频文件是一项非常具有挑战性的任务,尤其是当涉及到通过处理和互操作不同的编码来读取视频剪辑时;这是一个繁琐的工作。此外,视频剪辑可能包含失真帧,这在提取高质量特征时是一个障碍。

考虑到这些问题,在本小节中,我们将看到如何通过处理视频编码问题来预处理视频剪辑,并详细描述特征提取过程。

解决编码问题

在 Java 中处理视频数据是一项繁琐的工作(因为我们没有像 Python 那样多的库),尤其是当视频采用旧的.avi格式时。我在 GitHub 上看到一些博客和示例,使用 JCodec Java 库版本 0.1.5(或 0.2.3)来读取和解析 MP4 格式的UCF101视频剪辑。

即使 DL4J 也依赖于 datavec-data-codec,它依赖于旧版的 JCodec API,且与新版本不兼容。不幸的是,即使是新版的 JCodec 也无法读取UCF101视频。因此,我决定使用 FFmpeg 来处理 MP4 格式的视频。这属于 JavaCV 库,之前的章节中已经讨论过了。总之,要使用这个库,只需在pom.xml文件中添加以下依赖:

<dependency>
       <groupId>org.bytedeco</groupId>
       <artifactId>javacv-platform</artifactId>
       <version>1.4.1</version>
</dependency>

由于UCF101.avi格式,我在使用 JCodec 或 FFmpeg 库处理时遇到了困难。因此,我手动将视频转换为MP4格式。

为此,我编写了一个 Python 脚本(名为prepare.py,可以在本章的代码库中找到)。这个 Python 脚本会下载、解压和解码完整的UCF101数据集,但根据硬件配置和互联网速度,可能需要几个小时。尽管将 Python 代码放在此书中并不相关,我还是把它放在这里,以便大家能够了解整个过程,因此请看一下这个代码:

import os

ROOT = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(ROOT, 'VideoData')
UCF_RAW = os.path.join(ROOT, 'VideoData', 'UCF101')
UCF_MP4 = os.path.join(ROOT, 'VideoData', 'UCF101_MP4')

if not os.path.isdir(UCF_MP4):
    print("Start converting UCF101 dataset to MP4...")
    filepaths = []

    for label_dir in os.listdir(os.path.join(UCF_RAW)):
        for file in os.listdir(os.path.join(UCF_RAW, label_dir)):
            filepath = (UCF_RAW, label_dir, file)
            filepaths.append(filepath)
    files_len = len(filepaths)
    os.mkdir(UCF_MP4)

    for i, (_, label_dir, file_avi) in enumerate(filepaths):
        if file_avi.endswith('.avi'):
            file_mp4 = file_avi.rstrip('.avi') + '.mp4'
            input_filepath = os.path.join(UCF_RAW, label_dir, file_avi)
            output_filepath = os.path.join(UCF_MP4, label_dir, file_mp4)

            if not os.path.isfile(output_filepath):
                output_dir = os.path.join(UCF_MP4, label_dir)
                if not os.path.isdir(output_dir):
                    os.mkdir(output_dir)
                os.system('ffmpeg -v error -i %s -strict -2 %s' % (input_filepath, output_filepath))
        print("%d of %d files converted" % (i+1, files_len))
print("Dataset ready")

如代码所示,你只需从crcv.ucf.edu/data/UCF101.php下载UCF101数据集,并将其放入VideoData/UCF101文件夹中。然后,Python 使用内置的 FFmpeg 包将所有.avi文件转换为.mp4格式,并在执行$ python3 prepare.py命令后保存到VideoData/UCF101_MP4目录。

数据处理工作流

一旦文件转换为 MP4 格式,我们就可以开始提取特征。现在,为了处理UCF101数据集并提取特征,我编写了另外三个 Java 类,具体如下:

  • UCF101Reader.java这是视频文件读取、解码和转换为 ND4J 向量的主要入口点。它接收数据集的完整路径并创建神经网络所需的DataSetIterator。此外,它还生成所有类的列表,并为每个类分配顺序整数。

  • UCF101ReaderIterable.java:该类读取所有视频片段并使用 JCodec 进行解码。

  • RecordReaderMultiDataSetIterator.java:这与 DL4J 提供的类似,但这是一个改进版,在新版本的 JCodec 上表现良好。

然后,为了准备训练和测试集,使用了UCF101Reader.getDataSetIterator()方法。该方法读取每个视频片段,但首先,根据参数和偏移值决定读取多少个示例(视频文件)。这些参数然后传递给UCF101ReaderIterable。该方法的签名如下:

public UCF101Reader(String dataDirectory) {
        this.dataDirectory = dataDirectory.endsWith("/") ? dataDirectory : dataDirectory + "/";
          }

public DataSetIterator getDataSetIterator(int startIdx, int nExamples, int miniBatchSize) throws Exception {
    ExistingDataSetIterator iter = new ExistingDataSetIterator(createDataSetIterable(startIdx, 
                                                               nExamples, miniBatchSize));
        return new AsyncDataSetIterator(iter,1);
    }

在此方法中,ExistingDataSetIterator作为一个封装器,提供了一个DataSetIterator接口,用于现有的 Java Iterable<DataSet>Iterator<DataSet>。然后,使用UCF101Reader.UCF101ReaderIterable()方法创建标签映射(类名到整数索引)和逆标签映射,如下所示:

private UCF101RecordIterable createDataSetIterable(int startIdx, int nExamples, int miniBatchSize) 
                                                   throws IOException {
        return new UCF101RecordIterable(dataDirectory, labelMap(), V_WIDTH, V_HEIGHT,startIdx, nExamples);
                  }

如您所见,dataDirectory 是 MP4 格式视频的目录,(V_WIDTH, V_HEIGHT) 表示视频帧的大小,而 labelMap() 则提供每个视频剪辑的映射:

public static final int V_WIDTH = 320;
public static final int V_HEIGHT = 240;
public static final int V_NFRAMES = 100;
private final String dataDirectory;
private volatile Map<Integer, String> _labelMap;

因此,labelMap() 的签名如下:

public Map<Integer, String> labelMap() throws IOException {
        if(_labelMap == null) {
            synchronized (this) {
                if(_labelMap == null) {
                    File root = new File(dataDirectory);
                    _labelMap = Files.list(root.toPath()).map(f -> f.getFileName().toString())
                            .sorted().collect(HashMap::new, (h, f) -> h.put(h.size(), f), (h, o) -> {});
                }
            }
        }
        return _labelMap;
    }

然后,UCF101ReaderIterable.iterator() 用于创建网络所需的 DataSet 迭代器。此迭代器传递给 ExistingDataSetIterator,以符合神经网络 API 所需的形式,如下所示:

// The @NotNull Annotation ensures iterator() method des not return null.
@NotNull
@Override
public Iterator<DataSet> iterator() {
        return rowsStream(dataDirectory).skip(this.skip).limit(this.limit).flatMap(p -> 
               dataSetsStreamFromFile(p.getKey(), p.getValue())).iterator();
    }

此外,AsyncDataSetIterator 用于在单独的线程中进行所有数据处理。而 UCF101ReaderIterable.rowStream() 则列出所有数据集文件,并创建文件和相应类标签的序列,如下所示:

public static Stream<Pair<Path, String>> rowsStream(String dataDirectory) {
        try {
            List<Pair<Path, String>> files = Files.list(Paths.get(dataDirectory)).flatMap(dir -> {
                try {
                    return Files.list(dir).map(p -> Pair.of(p, dir.getFileName().toString()));
                } catch (IOException e) {
                    e.printStackTrace();
                    return Stream.empty();
                }
            }).collect(Collectors.toList());
            Collections.shuffle(files, new Random(43));
            return files.stream();
        } catch (IOException e) {
            e.printStackTrace();
            return Stream.empty();
        }
    }

接下来,使用 UCF101ReaderIterable.dataSetStreamFromFile() 方法将基础迭代器转换为 Java 流。这只是将迭代器转换为流的技术步骤。因为在 Java 中,通过流更方便地过滤一些元素并限制流中的元素数量。看一下这段代码!

private Stream<DataSet> dataSetsStreamFromFile(Path path, String label) {
        return StreamSupport.stream(Spliterators.spliteratorUnknownSize(dataSetsIteratorFromFile(path, 
                                    label), Spliterator.ORDERED), false);
    }

UCF101ReaderIterable.dataSetIteratorFromFile() 方法接收视频文件路径,然后创建帧读取器(FrameGrab—JCodec 类)。最后,将帧读取器传递给 RecordReaderMultiDataSetIterator.nextDataSet,如下所示:

private Iterator<DataSet> dataSetsIteratorFromFile(Path path, String label) {
        FileChannelWrapper _in = null;
        try {
            _in = NIOUtils.readableChannel(path.toFile());
            MP4Demuxer d1 = MP4Demuxer.createMP4Demuxer(_in);
            SeekableDemuxerTrack videoTrack_ = (SeekableDemuxerTrack)d1.getVideoTrack();
            FrameGrab fg = new FrameGrab(videoTrack_, new AVCMP4Adaptor(videoTrack_.getMeta()));

            final int framesTotal = videoTrack_.getMeta().getTotalFrames();
            return Collections.singleton(recordReaderMultiDataSetIterator.nextDataSet(_in, framesTotal, 
                  fg, labelMapInversed.get(label), labelMap.size())).iterator();
        } catch(IOException | JCodecException e) {
            e.printStackTrace();
            return Collections.emptyIterator();
        }
    }

在上述代码块中,使用 RecordReaderMultiDataSetIterator.nextDataSet() 方法将每个视频帧转换为与 DL4J 兼容的 DataSetDataSet 是从帧生成的特征向量和使用单热编码生成的标签向量的组合。

嗯,这个逻辑基于 DL4J 的 RecordReaderMultiDataSetIterator 类,但必要的支持来自最新的 JCodec API。然后我们使用 UCF101RecordIterable.labelToNdArray() 方法将标签编码为 ND4J 的 INDArray 格式:

private INDArray labelToNdArray(String label) {
 int maxTSLength = 1; // frames per dataset
 int labelVal = labelMapInversed.get(label);
          INDArray arr = Nd4j.*create*(new int[]{1, classesCount}, 'f');
          arr.put(0, labelVal, 1f);
 return arr;
}

前面提到的工作流程步骤可以在以下图表中描述:

特征提取过程中的数据流

检查视频帧的简易 UI

我开发了一个简单的 UI 应用程序,使用 Java Swing 来测试代码是否正确处理帧。此 UI 读取 MP4 格式的输入视频文件,并像简单的视频播放器一样逐帧显示给读者。该 UI 应用程序名为 JCodecTest.java

JCodecTest.java 类中,testReadFrame() 方法利用 FrameGrab 类的 getFrameFromFile() 方法(即来自 JavaCV 库),检查每个视频剪辑的帧提取过程是否正常工作。这是方法签名:

private void testReadFrame(Consumer<Picture> consumer) throws IOException, JCodecException {
        // Read the clip sequentially one by one
        next:
        for(Iterator<Pair<Path, String>> iter = rowsStream().iterator(); iter.hasNext(); ) {
            Pair<Path, String> pair = iter.next();
            Path path = pair.getKey();
            pair.getValue();

            for(int i = 0; i < 100; i++) {
                try {
                    // Hold video frames as pictures
                    Picture picture = FrameGrab.getFrameFromFile(path.toFile(), i);
                    consumer.accept(picture);
                } catch (Throwable ex) {
                    System.out.println(ex.toString() + " frame " + i + " " + path.toString());
                    continue next;
                }
            }
            System.out.println("OK " + path.toString());
        }
    }

在上述代码块中,rowsStream() 方法如下所示:

private Stream<Pair<Path, String>> rowsStream() {
        try {
            return Files.list(Paths.get(dataDirectory)).flatMap(dir -> {
                try {
                    return Files.list(dir).map(p -> Pair.of(p, dir.getFileName().toString()));
                } catch (IOException e) {
                    e.printStackTrace();
                    return Stream.empty();
                }
            });
        } catch (IOException e) {
            e.printStackTrace();
            return Stream.empty();
        }
    }

要查看此方法的有效性,读者可以执行包含 main() 方法的 JCodecTest.java 类,如下所示:

private String dataDirectory = "VideoData/UCF101_MP4/";
public static void main(String[] args) throws IOException, JCodecException {
        JCodecTest test = new JCodecTest();
        test.testReadFrame(new FxShow());
}

一旦执行,您将体验以下输出,如此屏幕截图所示:

JCodecTest.java 类检查每个视频片段的帧提取是否正常工作

准备训练集和测试集

如前所述,UCF101Reader.java 类用于提取特征并准备训练集和测试集。首先,我们设置并展示 Java 中 MP4 文件的路径,如下所示:

String dataDirectory = "VideoData/UCF101_MP4/";// Paths to video dataset

需要注意的是,使用视频片段训练网络花费了我大约 45 小时,使用的是 EC2 p2.8xlarge 机器。然而,我第二次没有那样的耐心,因此,我只利用了包含 1,112 个视频片段的视频类别来进行训练:

UCF101 数据集目录结构(MP4 版本)

然后我们定义了用于准备训练集和测试集的迷你批次大小。对于我们的情况,我设置了 128,如下所示:

private static int *miniBatchSize* = 128;
private static int *NUM_EXAMPLE* = 10;
UCF101Reader reader = new UCF101Reader(dataDirectory);

我们定义了提取过程从哪个文件开始:

int examplesOffset = 0; // start from N-th file

然后我们决定使用多少个视频片段来训练网络,而 UCF101Reader.fileCount() 方法返回 UCF101_MP4 目录中视频片段的数量。看看这行代码:

int nExamples = Math.*min*(NUM_*EXAMPLE*, reader.fileCount());

接下来,我们计算测试集的起始索引。我们使用 80% 的数据进行训练,其余 20% 用于测试。让我们看看这段代码:

int testStartIdx = examplesOffset + Math.*max*(2, (int) (0.8 * nExamples)); //80% in train, 20% in test 
int nTest = nExamples - testStartIdx + examplesOffset;
System.*out*.println("Dataset consist of " + reader.fileCount() + " video clips, use " 
                    + nExamples + " of them");

现在我们准备训练集。为此,getDataSetIterator() 方法会返回一个 DataSetIterator,包含所有视频片段,除了那些计划用于测试集的片段。请查看这段代码:

System.*out*.println("Starting training...");
DataSetIterator trainData = reader.getDataSetIterator(examplesOffset, nExamples - nTest, *miniBatchSize*);

然后我们准备测试集。为此,同样 getDataSetIterator() 方法会返回一个 DataSetIterator,包含所有视频片段,除了那些计划用于测试集的片段。请查看这段代码:

System.out.println("Use " + String.*valueOf*(nTest) + " video clips for test"); 
DataSetIterator testData = reader.getDataSetIterator(testStartIdx, nExamples, *miniBatchSize*);

太棒了!到目前为止,我们已经能够准备好训练集和测试集。接下来的步骤是创建网络并进行训练。

网络创建与训练

现在,我们开始通过结合卷积层、最大池化层、全连接层(前馈)和递归层(LSTM)来创建网络,对每一帧视频进行分类。首先,我们需要定义一些超参数和必要的实例化,如下所示:

private static MultiLayerConfiguration *conf*;
private static MultiLayerNetwork *net*; 
private static String *modelPath* = "bin/ConvLSTM_Model.zip";
private static int *NUM_CLASSES*;
private static int *nTrainEpochs* = 100;

这里,NUM_CLASSESUCF101 数据集中的类别数量,计算方法是数据集根目录下目录的数量:

*NUM_CLASSES* = reader.labelMap().size();

然后,我们通过调用 networkTrainer() 方法开始训练。正如我之前所说,我们将结合卷积层、最大池化层、全连接层(前馈)和递归层(LSTM)来对视频片段的每一帧进行分类。训练数据首先输入到卷积层(层 0),然后经过子采样(层 1),再输入到第二个卷积层(层 2)。接着,第二个卷积层将数据传递到全连接层(层 3)。

需要注意的是,对于第一个 CNN 层,我们有 CNN 预处理器输入宽度/高度为 13 x 18,这反映了 320 x 240 的图片大小。这样,密集层作为 LSTM 层(层 4)的输入层(但你也可以使用常规的 LSTM)。然而,重要的是要注意,密集层的输入大小为 2,340(即 13 * 18 * 10)。

然后,递归反馈连接到 RNN 输出层,该层使用 softmax 激活函数来进行类别的概率分布。我们还使用梯度归一化来处理梯度消失和梯度爆炸问题,最后一层的反向传播使用截断 BPTT。除此之外,我们还使用了一些其他超参数,这些参数不言而喻。以下图显示了该网络设置:

网络架构

现在,从编码的角度来看,networkTrainer()方法具有以下网络配置:

//Set up network architecture:
 conf = new NeuralNetConfiguration.Builder()
                .seed(12345)
                .l2(0.001) //l2 regularization on all layers
                .updater(new Adam(0.001)) // we use Adam as updater
                .list()
                .layer(0, new ConvolutionLayer.Builder(10, 10)
                        .nIn(3) //3 channels: RGB
                        .nOut(30)
                        .stride(4, 4)
                        .activation(Activation.RELU)
                        .weightInit(WeightInit.RELU)
                        .build()) //Output: (130-10+0)/4+1 = 31 -> 31*31*30
                .layer(1, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
                        .kernelSize(3, 3)
                        .stride(2, 2).build()) //(31-3+0)/2+1 = 15
                .layer(2, new ConvolutionLayer.Builder(3, 3)
                        .nIn(30)
                        .nOut(10)
                        .stride(2, 2)
                        .activation(Activation.RELU)
                        .weightInit(WeightInit.RELU)
                        .build()) //Output: (15-3+0)/2+1 = 7 -> 7*7*10 = 490
                .layer(3, new DenseLayer.Builder()
                        .activation(Activation.RELU)
                        .nIn(2340) // 13 * 18 * 10 = 2340, see CNN layer width x height
                        .nOut(50)
                        .weightInit(WeightInit.RELU)
                        .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
                        .gradientNormalizationThreshold(10)
                        .updater(new AdaGrad(0.01))// for faster convergence
                        .build())
                .layer(4, new LSTM.Builder()
                        .activation(Activation.SOFTSIGN)
                        .nIn(50)
                        .nOut(50)
                        .weightInit(WeightInit.XAVIER)
                        .updater(new AdaGrad(0.008))
                        .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
                        .gradientNormalizationThreshold(10)
                        .build())
                .layer(5, new RnnOutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
                        .activation(Activation.SOFTMAX)
                        .nIn(50)
                        .nOut(NUM_CLASSES)    
                        .weightInit(WeightInit.XAVIER)
                        .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
                        .gradientNormalizationThreshold(10)
                        .build())
                .inputPreProcessor(0, new RnnToCnnPreProcessor(UCF101Reader.V_HEIGHT, 
                                   UCF101Reader.V_WIDTH, 3))
                .inputPreProcessor(3, new CnnToFeedForwardPreProcessor(13, 18, 10))
                .inputPreProcessor(4, new FeedForwardToRnnPreProcessor())
                .pretrain(false).backprop(true)
                .backpropType(BackpropType.TruncatedBPTT)
                .tBPTTForwardLength(UCF101Reader.V_NFRAMES / 5)
                .tBPTTBackwardLength(UCF101Reader.V_NFRAMES / 5)
                .build();

接下来,根据前述的网络配置设置,我们创建并初始化了一个MultiLayerNetwork,如下所示:

*net* = new MultiLayerNetwork(*conf*);
*net*.init();
*net*.setListeners(new ScoreIterationListener(1));

然后,我们可以观察每一层的参数数量,如下所示:

System.*out*.println("Number of parameters in network: " + *net*.numParams());
for(int i=0; i<*net*.getnLayers(); i++){
    System.*out*.println("Layer " + i + " nParams = " + *net*.getLayer(i).numParams());
}

>>> 网络中的参数数量:149599

第零层 nParams = 9030

第一层 nParams = 0

第二层 nParams = 2710

第三层 nParams = 117050

第四层 nParams = 20350

第五层 nParams = 459

最后,我们使用这个训练集开始训练:

for (int i = 0; i < *nTrainEpochs*; i++) {
         int j = 0;
         while(trainData.hasNext()) {
               long start = System.*nanoTime*();
               DataSet example = trainData.next();
               *net*.fit(example);
               System.*out*.println(" Example " + j + " processed in " 
                                 + ((System.*nanoTime*() - start) / 1000000) + " ms");
               j++;
              }
       System.*out*.println("Epoch " + i + " complete");
}

我们使用saveConfigs()方法保存训练好的网络和视频配置,该方法的签名非常直接,正如你所看到的:

private static void saveConfigs() throws IOException {
         Nd4j.*saveBinary*(*net*.params(),new File("bin/videomodel.bin"));
         FileUtils.*writeStringToFile*(new File("bin/videoconf.json"), *conf*.toJson());
  }

然后,我们使用saveNetwork()方法保存训练好的模型,以便以后进行推理;其代码如下:

privates tatic void saveNetwork() throws IOException {
         File locationToSave = new File(*modelPath*);
 boolean saveUpdater = true;
         ModelSerializer.*writeModel*(*net*, locationToSave, saveUpdater);
}

性能评估

为了评估网络性能,我编写了evaluateClassificationPerformance()方法,该方法接受测试集和evalTimeSeries评估,如下所示:

private static void evaluateClassificationPerformance(MultiLayerNetwork net, int testStartIdx, 
                     int nExamples, DataSetIterator testData) throws Exception {
          Evaluation evaluation = new Evaluation(*NUM_CLASSES*);
 while(testData.hasNext()) {
                DataSet dsTest = testData.next();
                INDArray predicted = net.output(dsTest.getFeatureMatrix(), false);
                INDArray actual = dsTest.getLabels(); 
                evaluation.evalTimeSeries(actual, predicted);
                 }
          System.*out*.println(evaluation.stats());
}
>>>
 Predictions labeled as 0 classified by model as 0: 493 times
 Predictions labeled as 0 classified by model as 7: 3 times
 Predictions labeled as 1 classified by model as 6: 287 times
 Predictions labeled as 1 classified by model as 7: 1 times
 Predictions labeled as 2 classified by model as 6: 758 times
 Predictions labeled as 2 classified by model as 7: 3 times
 Predictions labeled as 3 classified by model as 6: 111 times
 Predictions labeled as 3 classified by model as 7: 1 times
 Predictions labeled as 4 classified by model as 6: 214 times
 Predictions labeled as 4 classified by model as 7: 2 times
 Predictions labeled as 5 classified by model as 6: 698 times
 Predictions labeled as 5 classified by model as 7: 3 times
 Predictions labeled as 6 classified by model as 6: 128 times
 Predictions labeled as 6 classified by model as 5: 1 times
 Predictions labeled as 7 classified by model as 7: 335 times
 Predictions labeled as 8 classified by model as 8: 209 times
 Predictions labeled as 8 classified by model as 7: 2 times
 ==========================Scores===================
 # of classes: 9
 Accuracy: 0.4000
 Precision: 0.39754
 Recall: 0.4109
 F1 Score: 0.4037
 Precision, recall & F1: macro-averaged (equally weighted avg. of 9 classes)
 ======================================================

现在,为了更清晰地遵循上述步骤,以下是包含这些步骤的main()方法:

public static void main(String[] args) throws Exception {        
        String dataDirectory = "VideoData/UCF101_MP4/";
        UCF101Reader reader = new UCF101Reader(dataDirectory); 
        NUM_CLASSES = reader.labelMap().size();        

        int examplesOffset = 0; // start from N-th file
        int nExamples = Math.min(NUM_EXAMPLE, reader.fileCount()); // use only "nExamples" for train/test
        int testStartIdx = examplesOffset + Math.max(2, (int) (0.9 * nExamples)); //90% train, 10% in test
        int nTest = nExamples - testStartIdx + examplesOffset;
        System.out.println("Dataset consist of " + reader.fileCount() + " images, use "
                           + nExamples + " of them");        

        //Conduct learning
        System.out.println("Starting training...");       
        DataSetIterator trainData = reader.getDataSetIterator(examplesOffset, 
                                    nExamples - nTest, miniBatchSize);        
        networkTrainer(reader, trainData);

        //Save network and video configuration
        saveConfigs();

        //Save the trained model
        saveNetwork();

        //Evaluate classification performance:
        System.out.println("Use " + String.valueOf(nTest) + " images for validation");
        DataSetIterator testData = reader.getDataSetIterator(testStartIdx, nExamples, miniBatchSize);
        evaluateClassificationPerformance(net,testStartIdx,nTest, testData);        
    }

我们尚未达到更高的准确率。可能有许多原因导致这种情况。例如,我们只使用了少数类别(即仅使用了 9 个类别中的 9 个)。因此,我们的模型没有足够的训练数据来学习。此外,大多数超参数设置过于简单。

在 AWS 深度学习 AMI 9.0 上的分布式训练

到目前为止,我们已经看到如何在单个 GPU 上进行训练和推理。然而,为了以并行和分布式的方式加速训练,拥有一台或服务器上有多个 GPU 是一个可行的选择。实现这一点的简单方法是使用 AMAZON EC2 GPU 计算实例。

例如,P2 非常适合用于分布式深度学习框架,这些框架预安装了最新的深度学习框架(MXNet、TensorFlow、Caffe、Caffe2、PyTorch、Keras、Chainer、Theano 和 CNTK)的二进制文件,并分别在虚拟环境中运行。

更大的优势在于,它们已经完全配置了 NVidia CUDA 和 cuDNN。有兴趣的读者可以查看aws.amazon.com/ec2/instance-types/p2/。以下是 P2 实例配置和定价的简要概览:

P2 实例详情

对于这个项目,我决定使用p2.8xlarge。你也可以创建它,但请确保你已经提交了至少一个实例的限制增加请求,这可能需要三天时间。如果你不知道怎么做,直接在 AWS 上创建一个帐户并完成验证;然后进入 EC2 管理控制台。在左侧面板中,点击“限制”标签,它会带你到一个页面,在这里你可以通过点击“请求限制增加”链接来提交增加限制的请求。

无论如何,我假设你知道这些简单的内容,所以我将继续创建一个p2.8xlarge类型的实例。在左侧面板中,点击实例菜单,它应该会带你进入以下页面:

选择一个深度学习 AMI

一个简单的选项是创建一个已经配置了 CUDA 和 cuDNN 的深度学习 AMI(Ubuntu)版本 9.0,该版本可以在八个 GPU 上使用。另一个好处是它具有 32 个计算核心和 488GB 的内存;这对我们的数据集也足够。因此,除了使用只有九个类别的视频片段外,我们还可以使用完整的数据集进行训练。

然而,注意,由于我们将使用基于 JVM 的 DL4J,因此必须安装并配置 Java(需要设置JAVA_HOME)。首先,通过 SSH 或使用 SFTP 客户端连接到您的实例。然后,在 Ubuntu 上,我们可以通过以下几个命令来完成,具体如下面所示:

$ sudo apt-get install python-software-properties
$ sudo apt-get update
$ sudo add-apt-repository ppa:webupd8team/java
$ sudo apt-get update

然后,根据您要安装的版本,执行以下其中一个命令:

$ sudo apt-get install oracle-java8-installer

安装后,别忘了设置 Java home。只需应用以下命令(假设 Java 已安装在/usr/lib/jvm/java-8-oracle):

$ echo "export JAVA_HOME=/usr/lib/jvm/java-8-oracle" >> ~/.bashrc
$ echo "export PATH=$PATH:$JAVA_HOME/bin" >> ~/.bashrc
$ source ~/.bashrc

现在让我们来看一下Java_HOME,如下所示:

$ echo $JAVA_HOME

现在,您应该在终端看到以下结果:

/usr/lib/jvm/java-8-oracle

最后,我们通过执行以下命令来检查 Java 是否已成功安装(您可能会看到最新版本!):

$ java -version
>>>
 java version "1.8.0_121"
 Java(TM) SE Runtime Environment (build 1.8.0_121-b15)
 Java HotSpot(TM) 64-Bit Server VM (build 25.121-b15, mixed mode)

太棒了!我们已经能够在我们的实例上设置并配置 Java 了。接下来,让我们通过在终端发出nvidia-smi命令,查看 GPU 驱动是否已配置:

显示 Tesla K80 GPU 的 p2.8xlarge 实例

如我们所见,最初没有使用 GPU,但它清楚地指出,在该实例上已安装并配置了八个 Tesla K80 GPU。现在我们的 GPU 和机器已经完全配置好,我们可以专注于项目。我们将使用与之前差不多的代码,但做一些最小的修改。我们需要进行的第一个更改是在 main() 方法的开头添加以下代码:

CudaEnvironment.getInstance().getConfiguration()
       .allowMultiGPU(true) // key option enabled
       .setMaximumDeviceCache(2L * 1024L * 1024L * 1024L) // large cache
       .allowCrossDeviceAccess(true); // cross-device access for faster model averaging over a piece

然后我们使用 ParallelWrapper 在八个 GPU 上进行训练,它负责 GPU 之间的负载均衡。网络构建与之前相同,如下所示:

*net* = new MultiLayerNetwork(*conf*);
*net*.init(); 

ParallelWrapper wrapper = new ParallelWrapper.Builder(net)
       .prefetchBuffer(8)// DataSets prefetching options. Set this with respect to number of devices
       .workers(8)// set number of workers equal to number of available devices -i.e. 8 for p2.8xlarge 
       .averagingFrequency(3)// rare averaging improves performance, but might reduce model accuracy           
       .reportScoreAfterAveraging(true) // if set TRUE, on every avg. model score will be reported 
       .build();

现在我们通过拟合完整的测试集来开始训练,如下所示:

for (int i = 0; i < nTrainEpochs; i++) {
     wrapper.fit(trainData);
     System.out.println("Epoch " + i + " complete"); 
    }

这就是我们需要做的全部。然而,请确保在 VideoClassificationExample.java 文件的开头导入以下内容,以便使用 CudaEnvironmentParallelWrapper,如下面所示:

import org.nd4j.jita.conf.CudaEnvironment; 
import org.deeplearning4j.parallelism.ParallelWrapper;

尽管如此,我仍然认为展示 main() 方法和 networkTrainer() 方法的代码会很有帮助。此外,为了避免可能的混淆,我编写了两个 Java 类,分别用于单个和多个 GPU:

  • VideoClassificationExample.java: 用于单个 GPU 或 CPU

  • VideoClassificationExample_MUltipleGPU.java:用于 AWS EC2 实例上的多个 GPU

因此,后者类有一个方法,networkTrainer(),用于创建一个用于分布式训练的网络,如下所示:

private static void networkTrainer(UCF101Reader reader, DataSetIterator trainData) throws Exception {        
    //Set up network architecture:
    conf = new NeuralNetConfiguration.Builder()
                .seed(12345)
                .l2(0.001) //l2 regularization on all layers
                .updater(new Adam(0.001))
                .list()
                .layer(0, new ConvolutionLayer.Builder(10, 10)
                        .nIn(3) //3 channels: RGB
                        .nOut(30)
                        .stride(4, 4)
                        .activation(Activation.RELU)
                        .weightInit(WeightInit.RELU)
                        .build())   //Output: (130-10+0)/4+1 = 31 -> 31*31*30
                .layer(1, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
                        .kernelSize(3, 3)
                        .stride(2, 2).build())   //(31-3+0)/2+1 = 15
                .layer(2, new ConvolutionLayer.Builder(3, 3)
                        .nIn(30)
                        .nOut(10)
                        .stride(2, 2)
                        .activation(Activation.RELU)
                        .weightInit(WeightInit.RELU)
                        .build())   //Output: (15-3+0)/2+1 = 7 -> 7*7*10 = 490
                .layer(3, new DenseLayer.Builder()
                        .activation(Activation.RELU)
                        .nIn(2340) // 13 * 18 * 10 = 2340, see CNN layer width x height
                        .nOut(50)
                        .weightInit(WeightInit.RELU)
                        .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
                        .gradientNormalizationThreshold(10)
                        .updater(new AdaGrad(0.01))
                        .build())
                .layer(4, new LSTM.Builder()
                        .activation(Activation.SOFTSIGN)
                        .nIn(50)
                        .nOut(50)
                        .weightInit(WeightInit.XAVIER)
                        .updater(new AdaGrad(0.008))
                        .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
                        .gradientNormalizationThreshold(10)
                        .build())
                .layer(5, new RnnOutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
                        .activation(Activation.SOFTMAX)
                        .nIn(50)
                        .nOut(NUM_CLASSES)    
                        .weightInit(WeightInit.XAVIER)
                        .gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
                        .gradientNormalizationThreshold(10)
                        .build())
                .inputPreProcessor(0, new RnnToCnnPreProcessor(UCF101Reader.V_HEIGHT, 
                                   UCF101Reader.V_WIDTH, 3))
                .inputPreProcessor(3, new CnnToFeedForwardPreProcessor(13, 18, 10))
                .inputPreProcessor(4, new FeedForwardToRnnPreProcessor())
                .pretrain(false).backprop(true)
                .backpropType(BackpropType.TruncatedBPTT)
                .tBPTTForwardLength(UCF101Reader.V_NFRAMES / 5)
                .tBPTTBackwardLength(UCF101Reader.V_NFRAMES / 5)
                .build();

        net = new MultiLayerNetwork(conf);
        net.init();
        net.setListeners(new ScoreIterationListener(1));

        System.out.println("Number of parameters in network: " + net.numParams());
        for( int i=0; i<net.getnLayers(); i++ ){
            System.out.println("Layer " + i + " nParams = " + net.getLayer(i).numParams());
        }

    // ParallelWrapper will take care of load balancing between GPUs.
    ParallelWrapper wrapper = new ParallelWrapper.Builder(net)            
            .prefetchBuffer(8)// DataSets prefetching options. Set value with respect to number of devices
            .workers(8)// set number of workers equal to number of available devices 
            .averagingFrequency(3)// rare avg improves performance, but might reduce accuracy           
            .reportScoreAfterAveraging(true) // if set TRUE, on every avg. model score will be reported
            .build();

   for (int i = 0; i < nTrainEpochs; i++) {
                wrapper.fit(trainData);
                System.out.println("Epoch " + i + " complete");
        }
    }

现在 main() 方法如下所示:

public static void main(String[] args) throws Exception {  
        // Workaround for CUDA backend initialization
        CudaEnvironment.getInstance()
                .getConfiguration()
                .allowMultiGPU(true)
                .setMaximumDeviceCache(2L * 1024L * 1024L * 1024L)
                .allowCrossDeviceAccess(true);   

        String dataDirectory = "/home/ubuntu/UCF101_MP4/";
        UCF101Reader reader = new UCF101Reader(dataDirectory); 
        NUM_CLASSES = reader.labelMap().size();        

        int examplesOffset = 0; // start from N-th file
        int nExamples = Math.min(NUM_EXAMPLE, reader.fileCount()); // use only "nExamples" for train/test
        int testStartIdx = examplesOffset + Math.max(2, (int) (0.9 * nExamples)); //90% train, 10% in test
        int nTest = nExamples - testStartIdx + examplesOffset;

        System.out.println("Dataset consist of " + reader.fileCount() + " images, use " 
                          + nExamples + " of them");        

        //Conduct learning
        System.out.println("Starting training...");       
        DataSetIterator trainData = reader.getDataSetIterator(examplesOffset, 
                                    nExamples - nTest, miniBatchSize);        
        networkTrainer(reader, trainData);

        //Save network and video configuration
        saveConfigs();

        //Save the trained model
        saveNetwork();

        //Evaluate classification performance:
        System.out.println("Use " + String.valueOf(nTest) + " images for validation");
        DataSetIterator testData = reader.getDataSetIterator(testStartIdx, nExamples, 10);
        evaluateClassificationPerformance(net,testStartIdx,nTest, testData);        
    }

这是我们在执行 VideoClassificationExample_MUltipleGPU.java 类之前所需要的一切。还应该注意,从终端运行独立的 Java 类并不是一个好主意。因此,我建议创建一个 fat .jar 文件并包含所有依赖项。为此,使用任何 SFTP 客户端将代码移到实例上。然后安装 maven

$sudo apt-get install maven

一旦安装了 maven,我们可以开始创建包含所有依赖项的 fat JAR 文件,如下所示:

$ sudo mvn clean install

然后,过了一段时间,一个 fat JAR 文件将在目标目录中生成。我们移动到该目录并执行 JAR 文件,如下所示:

$ cd target/
$ java -Xmx30g -jar VideoClassifier-0.0.1-SNAPSHOT-jar-with-dependencies.jar

此时,请确保您已正确设置所有路径并具有必要的权限。好吧,我假设一切都设置好了。那么,执行前面的命令将迫使 DL4J 选择 BLAS、CUDA 和 cuDNN,并执行训练和其他步骤。大致上,您应该在终端上看到如下日志:

ubuntu@ip-172-31-40-27:~/JavaDeepLearningDL4J/target$ java -Xmx30g -jar VideoClassifier-0.0.1-SNAPSHOT-jar-with-dependencies.jar

前面的命令应该开始训练,您应该在终端/命令行中观察到以下日志:

Dataset consist of 1112 images, use 20 of them
Starting training...
18:57:34.815 [main] INFO org.nd4j.linalg.factory.Nd4jBackend - Loaded [JCublasBackend] backend
18:57:34.844 [main] WARN org.reflections.Reflections - given scan urls are empty. set urls in the configuration
18:57:47.447 [main] INFO org.nd4j.nativeblas.NativeOpsHolder - Number of threads used for NativeOps: 32
18:57:51.433 [main] DEBUG org.nd4j.jita.concurrency.CudaAffinityManager - Manually mapping thread [28] to device [0], out of [8] devices...
18:57:51.441 [main] INFO org.nd4j.nativeblas.Nd4jBlas - Number of threads used for BLAS: 0
18:57:51.447 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Backend used: [CUDA]; OS: [Linux]
18:57:51.447 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Cores: [32]; Memory: [26.7GB];
18:57:51.447 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Blas vendor: [CUBLAS]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
 18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.697 [main] DEBUG org.nd4j.jita.handler.impl.CudaZeroHandler - Creating bucketID: 1
18:57:51.706 [main] DEBUG org.nd4j.jita.handler.impl.CudaZeroHandler - Creating bucketID: 2
18:57:51.711 [main] DEBUG org.reflections.Reflections - going to scan these urls:
jar:file:/home/ubuntu/JavaDeepLearningDL4J/target/VideoClassifier-0.0.1-SNAPSHOT-jar-with-dependencies.jar!/.
...

然后训练应该开始。现在让我们检查一下 DL4J 是否正在利用所有的 GPU。要确认这一点,再次在终端执行 nvidia-smi 命令,它应该显示如下内容:

显示在 p2.8 xlarge 实例上的 Tesla K80 GPU 的资源使用情况

由于视频片段较多,训练需要几个小时。训练完成后,代码应提供相似或稍微更好的分类准确率。

常见问题解答(FAQs)

现在我们已经解决了视频分类问题,但准确率较低。这个问题及整体深度学习现象还有其他实际方面需要考虑。在本节中,我们将看到一些可能出现在你脑海中的常见问题。答案可以在附录 A 中找到。

  1. 我的机器上安装了多块 GPU(例如,两个),但 DL4J 只使用一个。我该如何解决这个问题?

  2. 我已经在 AWS 上配置了一个 p2.8 xlarge EC2 GPU 计算实例。然而,在安装和配置 CUDA 和 cuDNN 时,显示磁盘空间不足。如何解决这个问题?

  3. 我了解如何在 AWS EC2 AMI 实例上进行分布式训练。然而,我的机器有一块低端 GPU,且经常出现 GPU OOP 错误。我该如何解决这个问题?

  4. 我可以将这个应用程序视为从视频中进行人体活动识别吗?

总结

在本章中,我们开发了一个完整的深度学习应用程序,利用 UCF101 数据集对大量视频数据集进行分类。我们应用了结合 CNN 和 LSTM 网络的 DL4J,克服了单独使用 CNN 或 RNN LSTM 网络的局限性。

最后,我们展示了如何在多个设备(CPU 和 GPU)上并行和分布式地进行训练。总的来说,这个端到端的项目可以作为从视频中进行人体活动识别的入门教程。虽然我们在训练后没有取得高准确率,但在具有完整视频数据集和超参数调优的网络中,准确率肯定会提高。

下一章将介绍如何设计一个由批评和奖励驱动的机器学习系统。我们将看到如何使用 DL4J、RL4J 和神经网络 Q 学习来开发一个演示版 GridWorld 游戏,其中 Q 学习起到 Q 函数的作用。我们将从强化学习及其理论背景开始,帮助更容易理解这些概念。

问题答案

问题 1 的答案: 这意味着训练没有分布式进行,也就是说系统强制你使用单个 GPU。现在,为了解决这个问题,只需在 main() 方法的开头添加以下代码:

CudaEnvironment.getInstance().getConfiguration().allowMultiGPU(true);

问题 2 的答案: 这个问题显然与 AWS EC2 相关。不过,我会提供一个简短的解释。如果你查看默认的启动设备,它只分配了 7.7 GB 的空间,但大约 85% 的空间被分配给了 udev 设备,如下所示:

显示 p2.8xlarge 实例上的存储

为了消除这个问题,在创建实例时,你可以在启动设备中指定足够的存储,如下所示:

增加 p2.8xlarge 实例默认启动设备上的存储

问题 3 的答案:好吧,如果是这种情况,你可能可以在 CPU 上进行训练,而不是 GPU。然而,如果必须在 GPU 上进行训练,我建议使用 HALF 数据类型。

如果你的机器和代码能够支持使用半精度数学运算,你可以将其作为数据类型启用。这将确保 DL4J 使用的 GPU 内存减少一半。要启用此功能,只需将以下代码行添加到 main() 方法的开头(即使是在多 GPU 允许的代码之前):

DataTypeUtil.setDTypeForContext(DataBuffer.Type.HALF);

使用 HALF 数据类型将强制你的网络压缩精度,低于 floatdouble 类型。然而,调优网络可能会更困难。

问题 4 的答案:我们尚未成功达到良好的准确率。这是本章端到端的主要目标。因此,在使用完整的视频数据集进行训练并调优超参数后,准确率肯定会提高。

最后,老实说,如果你想将一个应用程序投入生产,Java 可能不是完美的选择。我之所以这么说,是因为许多从视频片段提取高级特征的库都是用 Python 编写的,而且那些库也可以使用。

第九章:使用深度强化学习玩 GridWorld 游戏

作为人类,我们通过经验学习。我们并非一夜之间或偶然变得如此迷人。多年的赞美与批评都帮助塑造了今天的我们。我们通过反复尝试不同的肌肉运动,直到学会骑自行车。当你执行动作时,有时会立刻获得奖励,这就是 强化学习RL)。

本章将专注于设计一个由批评和奖励驱动的机器学习系统。我们将看到如何使用 Deeplearning4jDL4J)、reinforcement learning 4jRL4J)和作为 Q 函数的神经网络 Q 学习开发一个示范性 GridWorld 游戏。我们将从强化学习及其理论背景开始,以便更容易理解这个概念。简而言之,本章将涵盖以下主题:

  • 强化学习中的符号表示、策略和效用

  • 深度 Q 学习算法

  • 使用深度 Q 学习开发 GridWorld 游戏

  • 常见问题解答(FAQ)

强化学习的符号表示、策略和效用

监督学习和无监督学习看似位于两个极端,而强化学习则介于二者之间。它不是监督学习,因为训练数据来自于算法在探索与利用之间做出的决策。

此外,它也不是无监督学习,因为算法会从环境中获取反馈。只要你处于一个执行某个动作后能够获得奖励的状态,你就可以使用强化学习来发现一系列能够获得最大期望奖励的动作。强化学习代理的目标是最大化最终获得的总奖励。第三个主要子元素是价值函数。

奖励决定了状态的即时可取性,而价值则表示了状态的长期可取性,考虑到可能跟随的状态和这些状态中的可用奖励。价值函数是相对于所选策略来指定的。在学习阶段,代理会尝试那些能够确定具有最高价值状态的动作,因为这些动作最终会带来最好的奖励。

强化学习技术已经被应用到许多领域。目前正在追求的一个总体目标是创建一个只需要任务描述的算法。当这种表现得以实现时,它将被广泛应用于各个领域。

强化学习中的符号表示

你可能注意到,强化学习的术语涉及将算法化身为在特定情境中采取动作以获取奖励。事实上,算法通常被称为一个与环境互动的代理。

你可以将其视为一个通过传感器感知并通过执行器与环境互动的智能硬件代理。因此,强化学习理论在机器人技术中的广泛应用也就不足为奇了。现在,为了进一步展开讨论,我们需要了解一些术语:

  • 环境:这是一个具有多个状态和在状态之间转换机制的系统。例如,在 GridWorld 游戏中,代理的环境就是网格空间本身,定义了状态以及代理如何通过奖励到达目标。

  • 代理:这是一个与环境互动的自主系统。例如,在我们的 GridWorld 游戏中,代理就是玩家。

  • 状态:环境中的状态是一组完全描述环境的变量。

  • 目标:它也是一个状态,提供比任何其他状态更高的折扣累计奖励。在我们的 GridWorld 游戏中,目标状态是玩家最终希望达到的状态,但通过积累尽可能高的奖励。

  • 动作:动作定义了不同状态之间的转换。因此,在执行一个动作后,代理可以从环境中获得奖励或惩罚。

  • 策略:它定义了一组基于动作的规则,用于在给定状态下执行和实施动作。

  • 奖励:这是对好坏动作/移动的正负量度(即得分)。最终,学习的目标是通过最大化得分(奖励)来达到目标。因此,奖励本质上是训练代理的训练集。

  • 回合(也称为试验):这是从初始状态(即代理的位置)到达目标状态所需的步骤数。

我们将在本节稍后讨论更多关于策略和效用的内容。下图展示了状态、动作和奖励之间的相互作用。如果你从状态 s[1] 开始,可以执行动作 a[1] 来获得奖励 r (s[1], a[1])。箭头表示动作,状态由圆圈表示:

当代理执行一个动作时,状态会产生奖励。

机器人执行动作以在不同状态之间转换。但它如何决定采取哪种动作呢?实际上,这完全依赖于使用不同的或具体的策略。

政策

在强化学习中,策略是一组规则或一种战略。因此,学习的一个目标是发现一种良好的策略,能够观察到每个状态下动作的长期后果。所以,从技术上讲,策略定义了在给定状态下要采取的行动。下图展示了在任何状态下的最优动作:

策略定义了在给定状态下要采取的动作。

短期后果很容易计算:它只是奖励。尽管执行一个动作会得到即时奖励,但贪心地选择最大奖励的动作并不总是最好的选择。根据你的强化学习问题的定义,可能会有不同类型的策略,如下所述:

  • 当一个代理总是通过执行某个动作来追求最高的即时奖励时,我们称之为贪心策略

  • 如果一个动作是任意执行的,则该策略称为随机策略

  • 当神经网络通过反向传播和来自环境的明确反馈更新权重来学习选择动作的策略时,我们称之为策略梯度

如果我们想要制定一个健壮的策略来解决强化学习问题,我们必须找到一个在表现上优于随机策略和贪心策略的最优策略。在这一章中,我们将看到为什么策略梯度更加直接和乐观。

效用

长期奖励是效用。为了决定采取什么动作,代理可以选择产生最高效用的动作,并以贪心方式进行选择。执行一个动作 a 在状态 s 时的效用表示为函数 Q(s, a),称为效用函数。效用函数根据由状态和动作组成的输入,预测即时奖励和最终奖励,正如以下图所示:

使用效用函数

神经网络 Q 学习

大多数强化学习算法可以归结为三个主要步骤:推断、执行和学习。在第一步中,算法使用到目前为止所获得的知识,从给定的状态 s 中选择最佳动作 a。接下来,它执行该动作,以找到奖励 r 和下一个状态 s'

然后,它使用新获得的知识 (s, r, a, s') 来改进对世界的理解。这些步骤甚至可以通过 Q 学习算法进行更好的公式化,Q 学习算法或多或少是深度强化学习的核心。

Q 学习简介

使用 (s, r, a, s') 计算获得的知识只是计算效用的一种简单方法。因此,我们需要找到一种更健壮的方式来计算它,使得我们通过递归地考虑未来动作的效用来计算特定状态-动作对 (s, a) 的效用。当前动作的效用不仅受到即时奖励的影响,还受到下一个最佳动作的影响,如下式所示,这称为Q 函数

在前面的公式中,s' 表示下一个状态,a' 表示下一个动作,执行动作 a 在状态 s 时的奖励表示为 r(s, a)。其中,γ 是一个超参数,称为折扣因子。如果 γ0,则代理选择一个特定的动作,最大化即时奖励。较高的 γ 值将使代理更重视考虑长期后果。

在实践中,我们需要考虑更多这样的超参数。例如,如果期望吸尘机器人快速学习解决任务,但不一定要求最优解,那么我们可能会设置一个更高的学习速率。

另外,如果允许机器人有更多的时间去探索和利用,我们可能会降低学习速率。我们将学习速率称为α,并将我们的效用函数更改如下(请注意,当α = 1时,这两个方程是相同的):

总结来说,一个 RL 问题可以通过了解这个Q(s, a)函数来解决。这促使研究人员提出了一种更先进的QLearning算法,称为神经 Q 学习,它是一种用于计算状态-动作值的算法,属于时序差分TD)算法类别,意味着它涉及到动作执行和奖励获得之间的时间差异。

神经网络作为 Q 函数

现在我们知道了状态和需要执行的动作。然而,QLearning智能体需要了解形如(状态 x 动作)的搜索空间。下一步是创建图形或搜索空间,它是负责任何状态序列的容器。QLSpace类定义了QLearning算法的搜索空间(状态 x 动作),如下面的图所示:

状态转移矩阵与 QLData(Q 值、奖励、概率)

拥有状态和动作列表的最终用户可以提供搜索空间。或者,它可以通过提供状态数来自动创建,具体通过以下参数:

  • 状态:Q 学习搜索空间中定义的所有可能状态的序列

  • 目标:一系列表示目标状态的标识符

然而,传统的这种搜索空间(或查找表)表示方式有时效率不高;因为在大多数有趣的问题中,我们的状态-动作空间太大,无法存储在表中,例如吃豆人游戏。相反,我们需要进行泛化,并在状态之间进行模式匹配。换句话说,我们需要我们的 Q 学习算法能够说,这种状态的值是 X,而不是说,这个特定、超具体的状态的值是 X

这里可以使用基于神经网络的 Q 学习,而不是查找表作为我们的Q(s, a),它接受状态s和动作a,并输出该状态-动作对的值。然而,正如我之前提到的,神经网络有时包含数百万个与之相关的参数,这些参数就是权重。因此,我们的Q函数实际上看起来像Q(s, a, θ),其中θ是一个参数向量。

我们将通过反复更新神经网络的θ参数来代替反复更新表格中的值,从而使其学会为我们提供更好的状态-动作值估计。顺便提一下,我们可以像训练其他神经网络一样,使用梯度下降(反向传播)来训练这样的深度 Q 学习网络。

例如,如果状态(搜索空间)通过图像表示,神经网络可以对智能体的可能动作进行排名,从而预测可能的奖励。例如,向左跑返回五分,向上跳返回七分,向下跳返回两分,而向左跑则不返回任何奖励。

使用神经网络进行基于强化学习的游戏

为了实现这一点,我们不需要为每个动作都运行网络,而是只需在我们需要获取max Qs′,a′)时运行它,也就是在新状态s'下对每个可能的动作获取max Q值。

我们将看到如何使用MultiLayerNetwork和 DL4J 的MultiLayerConfiguration配置创建这样的深度 Q 学习网络。因此,神经网络将充当我们的 Q-函数。现在,我们已经对强化学习(RL)和 Q 学习有了最基本的理论了解,是时候开始编写代码了。

使用深度 Q 网络开发 GridWorld 游戏

现在我们将深入了解深度 Q 网络DQN),以训练一个智能体玩 GridWorld,这是一个简单的基于文本的游戏。游戏中有一个 4x4 的方格,放置了四个物体:一个智能体(玩家)、一个陷阱、一个目标和一堵墙。

GridWorld 项目结构

项目具有以下结构:

  • DeepQNetwork.java:提供 DQN 的参考架构

  • Replay.java:生成 DQN 的重放记忆,确保深度网络的梯度稳定,不会在多个回合中发散

  • GridWorld.java:用于训练 DQN 和玩游戏的主类。

顺便提一下,我们在 GPU 和 cuDNN 上执行训练,以加快收敛速度。如果您的机器没有 GPU,您也可以使用 CPU 后端。

生成网格

我们将开发一个简单的游戏,每次初始化一个完全相同的网格。游戏从智能体(A)、目标(+)、陷阱(-)和墙(W)开始。每场游戏中,所有元素都被随机放置在网格上。这样,Q 学习只需要学习如何将智能体从已知的起始位置移动到已知的目标位置,而不碰到陷阱(这会带来负面奖励)。请看这张截图:

一个显示游戏元素(即智能体、目标、陷阱和墙)的 GridWorld 游戏网格

简而言之,游戏的目标是到达目标点,在那里智能体会获得一个数值奖励。为了简化,我们将避免陷阱;如果智能体落入陷阱,它将被处罚,获得负奖励。

墙壁也能阻挡代理人的路径,但它不会提供奖励或惩罚,所以我们可以放心。由于这是定义状态的一种简单方式,代理人可以执行以下动作(即,行为):

  • 向上

  • 向下

  • 向左

  • 向右

这样,动作 a 可以定义为如下:a ∈ A {up, down, left, right}。现在让我们看看,基于前面的假设,网格会是什么样子的:

// Generate the GridMap
int size = 4;
float[][] generateGridMap() {
        int agent = rand.nextInt(size * size);
        int goal = rand.nextInt(size * size);

        while(goal == agent)
            goal = rand.nextInt(size * size);
        float[][] map = new float[size][size];

        for(int i = 0; i < size * size; i++)
            map[i / size][i % size] = 0;
        map[goal / size][goal % size] = -1;
        map[agent / size][agent % size] = 1;

        return map;
    }

一旦网格构建完成,可以按如下方式打印出来:

void printGrid(float[][] Map) {
        for(int x = 0; x < size; x++) {
            for(int y = 0; y < size; y++) {
                System.out.print((int) Map[x][y]);
            }
            System.out.println(" ");
        }
        System.out.println(" ");
    }

计算代理人和目标位置

现在,代理人的搜索空间已经准备好。接下来,让我们计算代理人和目标的初始位置。首先,我们计算代理人在网格中的初始位置,如下所示:

// Calculate the position of agent
int calcAgentPos(float[][] Map) {
        int x = -1;
        for(int i = 0; i < size * size; i++) {
            if(Map[i / size][i % size] == 1)
                return i;
        }
        return x;
    }

然后我们计算目标的位置,如下所示:

// Calculate the position of goal. The method takes the grid space as input
int calcGoalPos(float[][] Map) {
        int x = -1;// start from the initial position

        // Then we loop over the grid size say 4x4 times
        for(int i = 0; i < size * size; i++) {
            // If the mapped position is the initial position, we update the position 
            if(Map[i / size][i % size] == -1)
                return i;
        }
        return x; // agent cannot move to any other cell
    }

现在,生成的网格可以视为四个独立的网格平面,每个平面代表每个元素的位置。在下图中,代理人当前的网格位置是 (3, 0),墙壁位于 (0, 0),陷阱位于 (0, 1),目标位于 (1, 0),这也意味着所有其他元素为 0:

生成的网格可以视为四个独立的网格平面

因此,我们设计了网格,使得某些物体在相同的 xy 位置(但不同的 z 位置)包含一个 1,这表示它们在网格上的位置相同。

计算动作掩码

在这里,我们将所有输出设置为 0,除了我们实际看到的动作对应的输出,这样网络就可以根据与独热编码动作对应的掩码来乘以输出。然后我们可以将 0 作为所有未知动作的目标,这样我们的神经网络应该能够很好地执行。当我们想要预测所有动作时,可以简单地传递一个全为 1 的掩码:

// Get action mask
int[] getActionMask(float[][] CurrMap) {
        int retVal[] = { 1, 1, 1, 1 };

        int agent = calcAgentPos(CurrMap); //agent current position
        if(agent < size) // if agent's current pos is less than 4, action mask is set to 0
            retVal[0] = 0;
        if(agent >= (size * size - size)) // if agent's current pos is 12, we set action mask to 0 too
            retVal[1] = 0;
        if(agent % size == 0) // if agent's current pos is 0 or 4, we set action mask to 0 too
            retVal[2] = 0;
        if(agent % size == (size - 1))// if agent's current pos is 7/11/15, we set action mask to 0 too
            retVal[3] = 0;

        return retVal; // finally, we return the updated action mask. 
    }

提供指导动作

现在代理人的行动计划已经确定。接下来的任务是为代理人提供一些指导,使其从当前的位置朝着目标前进。例如,并非所有的动作都是准确的,也就是说,某些动作可能是无效的:

// Show guidance move to agent 
float[][] doMove(float[][] CurrMap, int action) {
        float nextMap[][] = new float[size][size];
        for(int i = 0; i < size * size; i++)
            nextMap[i / size][i % size] = CurrMap[i / size][i % size];

        int agent = calcAgentPos(CurrMap);
        nextMap[agent / size][agent % size] = 0;

        if(action == 0) {
            if(agent - size >= 0)
                nextMap[(agent - size) / size][agent % size] = 1;
            else {
                System.out.println("Bad Move");
                System.exit(0);
            }
        } else if(action == 1) {
            if(agent + size < size * size)
                nextMap[(agent + size) / size][agent % size] = 1;
            else {
                System.out.println("Bad Move");
                System.exit(0);
            }
        } else if (action == 2) {
            if((agent % size) - 1 >= 0)
                nextMap[agent / size][(agent % size) - 1] = 1;
            else {
                System.out.println("Bad Move");
                System.exit(0);
            }
        } else if(action == 3) {
            if((agent % size) + 1 < size)
                nextMap[agent / size][(agent % size) + 1] = 1;
            else {
                System.out.println("Bad Move");
                System.exit(0);
            }
        }
        return nextMap;
    }

在前面的代码块中,我们将动作编码为如下:0代表向上,1代表向下,2代表向左,3代表向右。否则,我们将该动作视为无效操作,代理人将受到惩罚。

计算奖励

现在,代理人已经获得了一些指导——强化信号——接下来的任务是计算代理人执行每个动作的奖励。看看这段代码:

// Compute reward for an action 
float calcReward(float[][] CurrMap, float[][] NextMap) {
        int newGoal = calcGoalPos(NextMap);// first, we calculate goal position for each map
        if(newGoal == -1) // if goal position is the initial position (i.e. no move)
            return (size * size + 1); // we reward the agent to 4*4+ 1 = 17 (i.e. maximum reward)
        return -1f; // else we reward -1.0 for each bad move 
    }

为输入层展平输入

然后我们需要将网络的输出转换为一个 1D 特征向量,供 DQN 使用。这个展平过程获取了网络的输出;它将所有结构展平,形成一个单一的长特征向量,供全连接层使用。看看这段代码:

INDArray flattenInput(int TimeStep) {
        float flattenedInput[] = new float[size * size * 2 + 1];

        for(int a = 0; a < size; a++) {
            for(int b = 0; b < size; b++) {
                if(FrameBuffer[a][b] == -1)
                    flattenedInput[a * size + b] = 1;
                else
                    flattenedInput[a * size + b] = 0;
                if(FrameBuffer[a][b] == 1)
                    flattenedInput[size * size + a * size + b] = 1;
                else
                    flattenedInput[size * size + a * size + b] = 0;
            }
        }
        flattenedInput[size * size * 2] = TimeStep;
        return Nd4j.create(flattenedInput);
    }

到目前为止,我们仅创建了 GridWorld 的逻辑框架。因此,我们在开始游戏之前创建了 DQN

网络构建与训练

正如我所说,我们将使用 MultiLayerNetwork 和 DL4J 的 MultiLayerConfiguration 配置创建一个 DQN 网络,它将作为我们的 Q 函数。因此,第一步是通过定义 MultiLayerConfiguration 创建一个 MultiLayerNetwork。由于状态有 64 个元素—4 x 4 x 4—我们的网络需要一个包含 64 个单元的输入层,两个隐藏层,分别有 164 和 150 个单元,以及一个包含 4 个单元的输出层,用于四种可能的动作(上、下、左、右)。具体如下:

DQN 网络的结构,显示输入层、两个隐藏层和输出层

然而,我们将使用经验回放内存来训练我们的 DQN,它将帮助我们存储智能体观察到的转换。这将允许 DQN 在后续使用这些数据。通过从中随机采样,构建一个批次的转换可以去相关。研究表明,这大大稳定并改善了 DQN 的训练过程。按照前述配置,以下代码可用于创建这样的 MultiLayerConfiguration

int InputLength = size * size * 2 + 1;
int HiddenLayerCount = 150;

MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
                .seed(12345)    //Random number generator seed for improved repeatability. Optional.
                .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
                .weightInit(WeightInit.XAVIER)
                .updater(new Adam(0.001))
                .l2(0.001) // l2 regularization on all layers
                .list()
                .layer(0, new DenseLayer.Builder()
                        .nIn(InputLength)
                        .nOut(HiddenLayerCount)
                        .weightInit(WeightInit.XAVIER)
                        .activation(Activation.RELU)
                        .build())
                .layer(1, new DenseLayer.Builder()
                        .nIn(HiddenLayerCount)
                        .nOut(HiddenLayerCount)
                        .weightInit(WeightInit.XAVIER)
                        .activation(Activation.RELU)
                        .build())
                .layer(2,new OutputLayer.Builder(LossFunction.MSE)
                        .nIn(HiddenLayerCount)
                        .nOut(4) // for 4 possible actions
                        .weightInit(WeightInit.XAVIER)
                        .activation(Activation.IDENTITY)
                        .weightInit(WeightInit.XAVIER)
                        .build())
                .pretrain(false).backprop(true).build();

然后,我们使用这个配置创建一个 DQN:

DeepQNetwork RLNet = new DeepQNetwork(conf, 100000, .99f, 1d, 1024, 500, 1024, InputLength, 4);

我们稍后会讨论参数,但在此之前,我们先看看如何创建这样一个深度架构。首先,我们定义一些参数:

int ReplayMemoryCapacity;
List<Replay> ReplayMemory;
double Epsilon;
float Discount;

MultiLayerNetwork DeepQ; // Initial DeepQNet
MultiLayerNetwork TargetDeepQ; // Target DeepQNet

int BatchSize;
int UpdateFreq;
int UpdateCounter;
int ReplayStartSize;
Random r;

int InputLength;
int NumActions;

INDArray LastInput;
int LastAction;

然后,我们定义构造函数来初始化这些参数:

DeepQNetwork(MultiLayerConfiguration conf, int replayMemoryCapacity, float discount, double epsilon, int batchSize, int updateFreq, int replayStartSize, int inputLength, int numActions){
        // First, we initialize both the DeepQNets
 DeepQ = new MultiLayerNetwork(conf);
        DeepQ.init();

        TargetDeepQ = new MultiLayerNetwork(conf);
        TargetDeepQ.init();

        // Then we initialize the target DeepQNet's params
        TargetDeepQ.setParams(DeepQ.params());
        ReplayMemoryCapacity = replayMemoryCapacity;

        Epsilon = epsilon;
        Discount = discount;

        r = new Random();
        BatchSize = batchSize;
        UpdateFreq = updateFreq;
        UpdateCounter = 0;

        ReplayMemory = new ArrayList<Replay>();
        ReplayStartSize = replayStartSize;
        InputLength = inputLength;
        NumActions = numActions;
    }

以下是该算法主循环的实现:

  1. 我们设置了一个 for 循环,直到游戏进行完毕。

  2. 我们运行 Q 网络的前向传播。

  3. 我们使用的是 epsilon-贪心策略,因此在时间 t 时,以 ϵ 的概率,智能体 选择一个随机行动。然而,以 1−ϵ 的概率,执行来自我们神经网络的最大 Q 值对应的行动。

  4. 然后,智能体采取一个行动 a,该行动在前一步骤中已确定;我们观察到一个新的状态 s′ 和奖励 r[t][+1]。

  5. 然后,使用 s′ 执行 Q 网络的前向传播,并存储最高的 Q 值(maxQ)。

  6. 然后,计算智能体的目标值作为奖励 + (gamma * maxQ),用于训练网络,其中 gamma 是一个参数(0<=γ<=1)。

  7. 我们的目标是更新与我们刚刚采取的行动相关联的四种可能输出的输出。在这里,智能体的目标输出向量与第一次执行时的输出向量相同,唯一不同的是与行动相关联的输出 奖励 + (gamma * maxQ)

上述步骤是针对一个回合的,然后循环会根据用户定义的回合数进行迭代。此外,首先构建网格,然后计算并保存每个动作的下一个奖励。简而言之,上述步骤可以表示如下:

GridWorld grid = new GridWorld();
grid.networkConstruction();

// We iterate for 100 episodes
for(int m = 0; m < 100; m++) {
            System.out.println("Episode: " + m);
            float CurrMap[][] = grid.generateGridMap();

            grid.FrameBuffer = CurrMap;
            int t = 0;
            grid.printGrid(CurrMap);

            for(int i = 0; i < 2 * grid.size; i++) {
                int a = grid.RLNet.getAction(grid.flattenInput(t), grid.getActionMask(CurrMap));

                float NextMap[][] = grid.doMove(CurrMap, a);
                float r = grid.calcReward(CurrMap, NextMap);
                grid.addToBuffer(NextMap);
                t++;

                if(r == grid.size * grid.size + 1) {
                    grid.RLNet.observeReward(r, null, grid.getActionMask(NextMap));
                    break;
                }

                grid.RLNet.observeReward(r, grid.flattenInput(t), grid.getActionMask(NextMap));
                CurrMap = NextMap;
            }
}

在前面的代码块中,网络计算每个迷你批次的扁平化输入数据所观察到的奖励。请看一下:

void observeReward(float Reward, INDArray NextInputs, int NextActionMask[]){
        addReplay(Reward, NextInputs, NextActionMask);

        if(ReplayStartSize <  ReplayMemory.size())
            networkTraining(BatchSize);
        UpdateCounter++;
        if(UpdateCounter == UpdateFreq){
            UpdateCounter = 0;
            System.out.println("Reconciling Networks");
            reconcileNetworks();
        }
    }    

上述奖励被计算出来,用于估算最优的未来值:

int getAction(INDArray Inputs , int ActionMask[]){
        LastInput = Inputs;
        INDArray outputs = DeepQ.output(Inputs);

        System.out.print(outputs + " ");
        if(Epsilon > r.nextDouble()) {
             LastAction = r.nextInt(outputs.size(1));
             while(ActionMask[LastAction] == 0)
                 LastAction = r.nextInt(outputs.size(1));
             System.out.println(LastAction);
             return LastAction;
        }        
        LastAction = findActionMax(outputs , ActionMask);
        System.out.println(LastAction);
        return LastAction;
    }

在前面的代码块中,通过取神经网络输出的最大值来计算未来的奖励。来看一下这个:

int findActionMax(INDArray NetOutputs , int ActionMask[]){
        int i = 0;
        while(ActionMask[i] == 0) i++;

        float maxVal = NetOutputs.getFloat(i);
        int maxValI = i;

        for(; i < NetOutputs.size(1) ; i++){
            if(NetOutputs.getFloat(i) > maxVal && ActionMask[i] == 1){
                maxVal = NetOutputs.getFloat(i);
                maxValI = i;
            }
        }
        return maxValI;
    }    

如前所述,观察到的奖励是在网络训练开始后计算的。组合输入的计算方式如下:

INDArray combineInputs(Replay replays[]){
        INDArray retVal = Nd4j.create(replays.length , InputLength);
        for(int i = 0; i < replays.length ; i++){
            retVal.putRow(i, replays[i].Input);
        }
        return retVal;
    }

然后,网络需要计算下一次传递的组合输入。来看一下这段代码:

INDArray combineNextInputs(Replay replays[]){
        INDArray retVal = Nd4j.create(replays.length , InputLength);
        for(int i = 0; i < replays.length ; i++){
            if(replays[i].NextInput != null)
                retVal.putRow(i, replays[i].NextInput);
        }
        return retVal;
    }

在之前的代码块中,每个时间步的地图通过addToBuffer()方法保存,如下所示:

void addToBuffer(float[][] nextFrame) { 
          FrameBuffer = nextFrame;
}

然后,DQNet 将输入展平后以批量的方式输入每一回合,开始训练。然后根据当前输入和目标输入,通过最大化奖励来计算当前和目标输出。来看一下这个代码块:

void networkTraining(int BatchSize){
        Replay replays[] = getMiniBatch(BatchSize);
        INDArray CurrInputs = combineInputs(replays);
        INDArray TargetInputs = combineNextInputs(replays);

        INDArray CurrOutputs = DeepQ.output(CurrInputs);
        INDArray TargetOutputs = TargetDeepQ.output(TargetInputs);

        float y[] = new float[replays.length];
        for(int i = 0 ; i < y.length ; i++){
            int ind[] = { i , replays[i].Action };
            float FutureReward = 0 ;
            if(replays[i].NextInput != null)
                FutureReward = findMax(TargetOutputs.getRow(i) , replays[i].NextActionMask);
            float TargetReward = replays[i].Reward + Discount * FutureReward ;
            CurrOutputs.putScalar(ind , TargetReward ) ;
        }
        //System.out.println("Avgerage Error: " + (TotalError / y.length) );

        DeepQ.fit(CurrInputs, CurrOutputs);
    }

在前面的代码块中,通过最大化神经网络输出的值来计算未来的奖励,如下所示:

float findMax(INDArray NetOutputs , int ActionMask[]){
        int i = 0;
        while(ActionMask[i] == 0) i++;

        float maxVal = NetOutputs.getFloat(i);
        for(; i < NetOutputs.size(1) ; i++){
            if(NetOutputs.getFloat(i) > maxVal && ActionMask[i] == 1){
                maxVal = NetOutputs.getFloat(i);
            }
        }
        return maxVal;
    }

正如我之前所说,这是一个非常简单的游戏,如果智能体采取动作 2(即,向左),一步就能到达目标。因此,我们只需保持所有其他输出与之前相同,改变我们所采取动作的输出。所以,实现经验回放是一个更好的主意,它在在线学习方案中给我们提供了小批量更新。

它的工作方式是我们运行智能体收集足够的过渡数据来填充回放记忆,而不进行训练。例如,我们的记忆大小可能为 10,000。然后,在每一步,智能体将获得一个过渡;我们会将其添加到记忆的末尾,并弹出最早的一个。来看一下这段代码:

void addReplay(float reward , INDArray NextInput , int NextActionMask[]){
        if(ReplayMemory.size() >= ReplayMemoryCapacity )
            ReplayMemory.remove( r.nextInt(ReplayMemory.size()) );

        ReplayMemory.add(new Replay(LastInput , LastAction , reward , NextInput , NextActionMask));
    }

然后,从记忆中随机抽取一个小批量的经验,并在其上更新我们的 Q 函数,类似于小批量梯度下降。来看一下这段代码:

Replay[] getMiniBatch(int BatchSize){
        int size = ReplayMemory.size() < BatchSize ? ReplayMemory.size() : BatchSize ;
        Replay[] retVal = new Replay[size];

        for(int i = 0 ; i < size ; i++){
            retVal[i] = ReplayMemory.get(r.nextInt(ReplayMemory.size()));
        }
        return retVal;        
    }

玩 GridWorld 游戏

对于这个项目,我没有使用任何可视化来展示状态和动作,而是采用了一种基于文本的游戏,正如我之前提到的那样。然后你可以运行GridWorld.java类(包含主方法),使用以下方式调用:

DeepQNetwork RLNet = new DeepQNetwork(conf, 100000, .99f, 1d, 1024, 500, 1024, InputLength, 4);

在这个调用中,以下是参数描述:

  • conf:这是用于创建 DQN 的MultiLayerConfiguration

  • 100000:这是回放记忆的容量。

  • .99f:折扣因子

  • 1d:这是 epsilon

  • 1024:批量大小

  • 500:这是更新频率;第二个 1,024 是回放开始的大小

  • InputLength:这是输入的长度,大小为 x x 2 + 1 = 33(考虑到 size=4)

  • 4:这是智能体可以执行的可能动作的数量。

我们将 epsilon(ϵ贪婪动作选择)初始化为 1,并且在每一回合后会减少一个小量。这样,最终它会降到 0.1 并保持不变。基于之前的设置,应该开始训练,训练过程会开始生成一个表示每个时间戳的地图网格,并输出 DQN 对于上/下/左/右顺序的结果,接着是最高值的索引。

我们没有用于图形表示游戏的模块。所以,在前面的结果中,0、1、-1 等数字代表了每五个回合中每个时间戳的地图。括号中的数字只是 DQN 的输出,后面跟着最大值的索引。看看这个代码块:

Scanner keyboard = new Scanner(System.in);
for(int m = 0; m < 10; m++) {
            grid.RLNet.SetEpsilon(0);
            float CurrMap[][] = grid.generateGridMap();
            grid.FrameBuffer = CurrMap;

            int t = 0;
            float tReward = 0;

            while(true) {
                grid.printGrid(CurrMap);
                keyboard.nextLine();

                int a = grid.RLNet.getAction(grid.flattenInput(t), grid.getActionMask(CurrMap));
                float NextMap[][] = grid.doMove(CurrMap, a);
                float r = grid.calcReward(CurrMap, NextMap);

                tReward += r;
                grid.addToBuffer(NextMap);
                t++;
                grid.RLNet.observeReward(r, grid.flattenInput(t), grid.getActionMask(NextMap));

                if(r == grid.size * grid.size + 1)
                    break;
                CurrMap = NextMap;
            }
            System.out.println("Net Score: " + (tReward));
        }
        keyboard.close();
    }

>>>
 Episode: 0
 0000
 01-10
 0000
 0000
 [[ 0.2146, 0.0337, -0.0444, -0.0311]] 2
 [[ 0.1105, 0.2139, -0.0454, 0.0851]] 0
 [[ 0.0678, 0.3976, -0.0027, 0.2667]] 1
 [[ 0.0955, 0.3379, -0.1072, 0.2957]] 3
 [[ 0.2498, 0.2510, -0.1891, 0.4132]] 0
 [[ 0.2024, 0.4142, -0.1918, 0.6754]] 2
 [[ 0.1141, 0.6838, -0.2850, 0.6557]] 1
 [[ 0.1943, 0.6514, -0.3886, 0.6868]] 0
 Episode: 1
 0000
 0000
 1000
 00-10
 [[ 0.0342, 0.1792, -0.0991, 0.0369]] 0
 [[ 0.0734, 0.2147, -0.1360, 0.0285]] 1
 [[ 0.0044, 0.1295, -0.2472, 0.1816]] 3
 [[ 0.0685, 0.0555, -0.2153, 0.2873]] 0
 [[ 0.1479, 0.0558, -0.3495, 0.3527]] 3
 [[ 0.0978, 0.3776, -0.4362, 0.4475]] 0
 [[ 0.1010, 0.3509, -0.4134, 0.5363]] 2
 [[ 0.1611, 0.3717, -0.4411, 0.7929]] 3
 ....
 Episode: 9
 0000
 1-100
 0000
 0000
 [[ 0.0483, 0.2899, -0.1125, 0.0281]] 3
 0000
 0000
 0-101
 0000
 [[ 0.0534, 0.2587, -0.1539, 0.1711]] 1
 Net Score: 10.0

因此,代理已经能够获得总分 10(即正分)。

常见问题解答(FAQs)

既然我们已经解决了 GridWorld 问题,那么在强化学习和整体深度学习现象中还有其他实际方面需要考虑。在本节中,我们将看到一些你可能已经想过的常见问题。答案可以在附录中找到。

  1. 什么是 Q 学习中的 Q?

  2. 我理解我们在 GPU 和 cuDNN 上进行了训练以加速收敛。然而,我的机器上没有 GPU。我该怎么办?

  3. 没有可视化,因此很难跟踪代理朝向目标的移动。

  4. 给出更多强化学习的例子。

  5. 我如何调和我们获得的小批处理处理结果?

  6. 我如何调和 DQN?

  7. 我想保存已训练的网络。我可以做到吗?

  8. 我想恢复已保存(即已训练)的网络。我可以做到吗?

总结

在本章中,我们展示了如何使用 DL4J、RL4J 和神经 Q 学习开发一个演示版 GridWorld 游戏,其中 Q 学习充当 Q 函数。我们还提供了开发深度 Q 学习网络以玩 GridWorld 游戏所需的一些基本理论背景。然而,我们没有开发任何模块来可视化代理在整个回合中的移动。

在下一章,我们将开发一个非常常见的端到端电影推荐系统项目,但使用神经因式分解机FM)算法。该项目将使用 MovieLens 100 万数据集。我们将使用 RankSys 和基于 Java 的 FM 库来预测用户的电影评分和排名。尽管如此,Spark ML 将用于对数据集进行探索性分析。

问题的答案

问题 1 的回答: 不要将 Q 学习中的 Q 与我们在前面部分讨论的 Q 函数混淆。Q 函数始终是接受状态和动作并返回该状态-动作对值的函数名称。强化学习方法涉及 Q 函数,但不一定是 Q 学习算法。

问题 2 的回答: 不用担心,你也可以在 CPU 后端进行训练。在这种情况下,只需从 pom.xml 文件中删除与 CUDA 和 cuDNN 相关的条目,并用 CPU 版本替换它们。相应的属性为:

<properties>
       <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
       <java.version>1.8</java.version>
        <nd4j.version>1.0.0-alpha</nd4j.version>
       <dl4j.version>1.0.0-alpha</dl4j.version>
      <datavec.version>1.0.0-alpha</datavec.version>
       <arbiter.version>1.0.0-alpha</arbiter.version>
      <logback.version>1.2.3</logback.version>
</properties>

不要使用这两个依赖项:

<dependency>
       <groupId>org.nd4j</groupId>
        <artifactId>nd4j-cuda-9.0-platform</artifactId>
        <version>${nd4j.version}</version>
</dependency>
<dependency>
       <groupId>org.deeplearning4j</groupId>
       <artifactId>deeplearning4j-cuda-9.0</artifactId>
       <version>${dl4j.version}</version>
</dependency>

只使用一个,如下所示:

<dependency>
     <groupId>org.nd4j</groupId>
     <artifactId>nd4j-native</artifactId>
     <version>${nd4j.version}</version>
</dependency>

那么你已经准备好使用 CPU 后端了。

问题 3 的答案: 如前所述,最初的目标是开发一个简单的基于文本的游戏。然而,通过一些努力,所有的动作也可以进行可视化。我希望读者自行决定。然而,可视化模块将在不久后添加到 GitHub 仓库中。

问题 4 的答案: 好吧,DL4J GitHub 仓库中有一些 RL4J 的基本示例,地址是github.com/deeplearning4j/dl4j-examples/。欢迎尝试扩展它们以满足你的需求。

问题 5 的答案: 处理每个小批次可以为该小批次使用的输入提供最佳的权重/偏置结果。这个问题涉及到几个子问题:i) 我们如何调和所有小批次得到的结果?ii) 我们是否取平均值来得出训练网络的最终权重/偏置?

因此,每个小批次包含单独错误梯度的平均值。如果你有两个小批次,你可以取这两个小批次的梯度更新的平均值来调整权重,以减少这些样本的误差。

问题 6 的答案: 参考问题 5 以获得理论理解。然而,在我们的示例中,使用来自 DL4J 的setParams()方法,它有助于调和网络:

void reconcileNetworks(){
     TargetDeepQ.setParams(DeepQ.params());
    }

现在问题是:我们在哪些地方使用这种调和方法呢?答案是,在计算奖励时(参见observeReward()方法)。

问题 7 的答案: 保存 DQN 与保存其他基于 DL4J 的网络类似。为此,我编写了一个名为saveNetwork()的方法,它将网络参数作为单个 ND4J 对象以 JSON 格式保存。看一下这个:

public boolean saveNetwork(String ParamFileName , String JSONFileName){
        //Write the network parameters for later use:
        try(DataOutputStream dos = new DataOutputStream(Files.newOutputStream(Paths.get(ParamFileName)))){
            Nd4j.write(DeepQ.params(),dos);
        } catch(IOException e) {
            System.out.println("Failed to write params");
            return false;
        }

        //Write the network configuration:
        try{
            FileUtils.write(new File(JSONFileName), DeepQ.getLayerWiseConfigurations().toJson());
        } catch (IOException e) {
            System.out.println("Failed to write json");
            return false;
        }
        return true;
    }

问题 8 的答案: 恢复 DQN 与保存其他基于 DL4J 的网络类似。为此,我编写了一个名为restoreNetwork()的方法,它调和参数并将保存的网络重新加载为MultiLayerNetwork。如下所示:

public boolean restoreNetwork(String ParamFileName , String JSONFileName){
        //Load network configuration from disk:
        MultiLayerConfiguration confFromJson;
        try{
            confFromJson = MultiLayerConfiguration.fromJson(FileUtils.readFileToString(new 
                                                            File(JSONFileName)));
        } catch(IOException e1) {
            System.out.println("Failed to load json");
            return false;
        }

        //Load parameters from disk:
        INDArray newParams;
        try(DataInputStream dis = new DataInputStream(new FileInputStream(ParamFileName))){
            newParams = Nd4j.read(dis);
        } catch(FileNotFoundException e) {
            System.out.println("Failed to load parems");
            return false;
        } catch (IOException e) {
            System.out.println("Failed to load parems");
            return false;
        }
        //Create a MultiLayerNetwork from the saved configuration and parameters 
        DeepQ = new MultiLayerNetwork(confFromJson); 
        DeepQ.init(); 

        DeepQ.setParameters(newParams); 
        reconcileNetworks();
        return true;        
    }   

第十章:使用因式分解机开发电影推荐系统

因式分解机FM)是一组通过引入第二阶特征交互来增强线性模型性能的算法,这些交互在矩阵分解MF)算法中是缺失的,并且这种增强方式是监督式的。因此,相比于经典的协同过滤CF)方法,因式分解机非常稳健,并且因其能够用于发现两种不同实体之间交互的潜在特征,在个性化和推荐系统中越来越受欢迎。

在本章中,我们将开发一个样例项目,用于预测评分和排名,以展示其有效性。尽管如此,在使用基于 RankSys 库的 FM 实现项目之前,我们将看到一些关于使用 MF 和 CF 的推荐系统的理论背景。总的来说,本章将涵盖以下主题:

  • 推荐系统

  • 矩阵分解与协同过滤方法

  • 开发基于 FM 的电影推荐系统

  • 常见问题解答(FAQ)

推荐系统

推荐技术本质上是信息代理,它们尝试预测用户可能感兴趣的物品,并向目标用户推荐最合适的物品。这些技术可以根据它们使用的信息来源进行分类。例如,用户特征(年龄、性别、收入、地点)、物品特征(关键词、类型)、用户-物品评分(显式评分、交易数据)以及其他对推荐过程有用的用户和物品信息。

因此,推荐系统,也称为推荐引擎RE),是信息过滤系统的一个子类,帮助预测基于用户提供的评分或偏好来推荐物品。近年来,推荐系统变得越来越流行。

推荐方法

开发推荐引擎(RE)以生成推荐列表有几种方法,例如,协同过滤、基于内容的过滤、基于知识的推荐或基于个性的方法。

协同过滤方法

通过使用 CF 方法,可以基于用户的过去行为构建推荐引擎(RE)。对于已消费的物品会给出数值评分。有时,推荐也可以基于其他用户做出的决策,这些用户也购买了相同的物品,并使用一些广泛应用的数据挖掘算法,如 Apriori 或 FP-growth。以下图示展示了不同推荐系统的一些概念:

不同推荐系统的比较视图

尽管这些是成功的推荐系统,基于 CF 的方法往往会面临以下三大问题:

  • 冷启动: 有时,当需要大量关于用户的数据来做出更准确的推荐系统时,它们可能会卡住。

  • 可扩展性: 使用一个包含数百万用户和商品的数据集进行推荐计算时,通常需要大量的计算能力。

  • 稀疏性: 这通常发生在众包数据集上,特别是当大量商品在主要电商网站上出售时。所有推荐数据集在某种意义上都是众包的。这是几乎所有推荐系统的普遍问题,尤其是当系统需要为大量商品提供服务,并且有足够多的用户时,并不仅限于电商网站。

在这种情况下,活跃用户可能只对所有销售的物品中的一个小子集进行评分,因此即使是最受欢迎的物品也只有很少的评分。因此,用户与物品的矩阵变得非常稀疏。换句话说,处理一个大规模的稀疏矩阵在计算上是非常具有挑战性的。

为了克服这些问题,某种类型的协同过滤算法采用矩阵分解,这是一个低秩矩阵近似技术。我们将在本章稍后看到一个示例。

基于内容的过滤方法

使用基于内容的过滤方法时,利用物品的一系列离散特征来推荐具有相似属性的其他物品。有时,这些方法基于物品的描述和用户偏好的个人资料。这些方法试图推荐与用户过去喜欢的物品相似的物品,或者是用户当前正在使用的物品。

基于内容的过滤方法的一个关键问题是系统是否能够根据用户对一个内容源的行为来学习其偏好,并将这些偏好应用到其他内容类型上。当这种类型的推荐引擎(RE)被部署时,就可以用来预测用户可能感兴趣的物品或物品的评分。

混合推荐系统

如你所见,使用协同过滤和基于内容的过滤方法各有优缺点。因此,为了克服这两种方法的局限性,近年来的趋势表明,混合方法可以更有效且准确。有时,像矩阵分解SVD)这样的因子化方法被用来增强其鲁棒性。

基于模型的协同过滤

协同过滤方法分为基于记忆的,如基于用户的算法和基于模型的协同过滤(推荐使用核映射)。在基于模型的协同过滤技术中,用户和产品通过一组小的因子来描述,这些因子也叫做潜在因子LFs)。然后使用这些潜在因子来预测缺失的条目。交替最小二乘法ALS)算法用于学习这些潜在因子。

与基于记忆的方法相比,基于模型的方法可以更好地处理原始矩阵的稀疏性。它还具有可扩展性、更快的速度,并且能够避免过拟合问题。然而,它缺乏灵活性和适应性,因为很难向模型中添加数据。现在,让我们来看一下协同过滤方法中的一个重要元素——效用矩阵。

效用矩阵

在基于协同过滤的推荐系统中,存在两类实体:用户和物品(物品指的是产品,如电影、游戏和歌曲)。作为用户,你可能对某些物品有偏好。因此,这些偏好必须从关于物品、用户或评分的数据中推导出来。这些数据通常表现为效用矩阵,例如用户-物品对。此类值可以表示用户对某个物品的偏好程度。

下表展示了一个示例效用矩阵,表示用户对电影的评分,评分范围为 1 到 5,其中 5 为最高评分。HP1HP2HP3分别是哈利·波特 IIIIII的缩写,TW代表暮光之城SW1SW2SW3分别代表星球大战 IIIIII。字母ABCD代表用户:

效用矩阵(用户与电影的矩阵)

在用户-电影对中有许多空白项。这意味着用户尚未对这些电影进行评分,这增加了稀疏性。使用此矩阵的目标是预测效用矩阵中的空白项。假设我们好奇是否用户A会喜欢SW2。由于矩阵中没有太多数据,这个预测是困难的。

因此,关于电影的其他属性,如制片人、导演、主演,甚至是它们名字的相似性,都可以用来计算电影SW1SW2的相似度。这种相似性会引导我们得出结论,由于A不喜欢SW1,他们也不太可能喜欢SW2

然而,对于更大的数据集,这种方法可能不起作用。因此,当数据量更大时,我们可能会观察到评分SW1SW2的用户倾向于给它们相似的评分。最终,我们可以得出结论,A也会给SW2一个低评分,类似于ASW1的评分。然而,这种方法有一个严重的缺点,称为冷启动问题

协同过滤方法中的冷启动问题

冷启动问题这个词听起来有些滑稽,但正如其名字所示,它源自汽车。在推荐引擎中,冷启动问题只是意味着一种尚未达到最佳状态的情况,导致引擎无法提供最理想的结果。

在协同过滤方法中,推荐系统会识别与当前用户有相似偏好的用户,并推荐那些志同道合的用户喜欢的物品。由于冷启动问题,这种方法无法考虑没有人在社区中评分的物品。

使用基于协同过滤(CF)方法的推荐引擎根据用户的行为推荐每个物品。物品的用户行为越多,越容易判断哪些用户可能对该物品感兴趣,以及哪些其他物品与之相似。随着时间的推移,系统将能够提供越来越准确的推荐。当新的物品或用户被添加到用户-物品矩阵时,以下问题就会发生:

用户与物品矩阵有时会导致冷启动问题

在这种情况下,推荐引擎对这个新用户或新物品的了解还不够。基于内容的过滤方法,类似于因子分解机(FM),是一种可以结合使用以缓解冷启动问题的方法。

推荐系统中的因子分解机

在实际生活中,大多数推荐问题假设我们拥有一个由(用户、物品和评分)元组构成的评分数据集。然而,在许多应用中,我们有大量的物品元数据(标签、类别和类型),这些可以用来做出更好的预测。

这就是使用因子分解机(FMs)与特征丰富数据集的好处之一,因为模型中可以自然地加入额外的特征,且可以使用维度参数建模高阶交互。

一些最近的研究类型展示了哪些特征丰富的数据集能够提供更好的预测:

  • Xiangnan He 和 Tat-Seng Chua,Neural Factorization Machines for Sparse Predictive Analytics。发表于 SIGIR '17 会议,东京新宿,日本,2017 年 8 月 07-11 日

  • Jun Xiao, Hao Ye, Xiangnan He, Hanwang Zhang, Fei Wu 和 Tat-Seng Chua(2017)。Attentional Factorization Machines: Learning the Weight of Feature Interactions via Attention Networks IJCAI,澳大利亚墨尔本,2017 年 8 月 19-25 日

这些论文解释了如何将现有数据转化为特征丰富的数据集,并且如何在该数据集上实现因子分解机(FMs)。因此,研究人员正试图使用因子分解机(FMs)来开发更准确和更强大的推荐引擎(REs)。在接下来的章节中,我们将开始开发基于因子分解机(FMs)的电影推荐项目。为此,我们将使用 Apache Spark 和 RankSys 库。

现有的推荐算法需要一个由(用户、物品、评分)元组构成的消费(产品)或评分(电影)数据集。这些类型的数据集主要被协同过滤(CF)算法的变种使用。协同过滤(CF)算法已被广泛采用,并且证明能够产生良好的结果。

然而,在许多情况下,我们也有大量的物品元数据(标签、类别和类型),这些可以用来做出更好的预测。不幸的是,协同过滤(CF)算法并未使用这些类型的元数据。

FM 可以利用这些特征丰富(元)数据集。FM 可以消耗这些额外的特征来建模更高阶的交互,并指定维度参数d。最重要的是,FM 也经过优化,可以处理大规模的稀疏数据集。因此,二阶 FM 模型就足够了,因为没有足够的信息来估计更复杂的交互:

一个示例训练数据集,表示一个个性化问题,特征向量 x 和目标 y。在这里,行代表电影,而列包括导演、演员和类型等信息

假设预测问题的数据集由设计矩阵X ∈ ℝn*(xp)描述。在前面的图中,i^(th)行,x[i]* ∈ ℝ^(p;)X中的一个案例,描述了一个包含p个实值变量的情况,且y[i]是第i个案例的预测目标。或者,我们可以将这个集合描述为一组元组(x,y)的集合,其中(再次)x ∈ ℝ^p是特征向量,y是其对应的目标或标签。

换句话说,在图 7 中,每一行表示一个特征向量x[i]及其对应的目标y[i]。为了更容易理解,特征被分组为:活动用户(蓝色)、活动商品(红色)、相同用户评分的其他电影(橙色)、时间(月)(绿色)以及最后评分的电影(棕色)。

然后,FM 算法使用以下因子化的交互参数,建模p个输入变量在x中的所有嵌套交互(最多到 d*阶):

在这个方程中,vs代表与每个变量(用户和商品)相关的k维潜在向量,而括号操作符表示内积。许多机器学习方法中都使用这种数据矩阵和特征向量的表示方式,例如线性回归或支持向量机(SVM)。

然而,如果你熟悉矩阵分解(MF)模型,那么前面的公式应该看起来很熟悉:它包含一个全局偏差,以及用户/商品特定的偏差,并且包括用户与商品的交互。现在,如果我们假设每个x(j)向量仅在位置ui处非零,我们得到经典的 MF 模型:

然而,FM 也可以用于分类或回归,并且在处理大规模稀疏数据集时,比传统算法(如线性回归)计算效率更高。正是因为这个特点,FM 被广泛用于推荐系统:用户数和商品数通常非常大,尽管实际的推荐数量非常少(用户并不会对所有可用商品进行评分!)。

使用因子分解机(FM)开发电影推荐系统

在本项目中,我们将展示如何从 MovieLens 1M 数据集中进行排名预测。首先,我们将准备数据集。然后,我们将训练 FM 算法,最终预测电影的排名和评分。项目代码的结构如下:

电影评分和排名预测项目结构

总结来说,该项目的结构如下:

  • EDA: 该包用于对 MovieLens 1M 数据集进行探索性分析。

  • 工具、FMCore 和 DataUtils: 这些是核心的 FM 库。为了这个项目,我使用了(但进行了扩展)RankSys 库(可以查看 GitHub 仓库:github.com/RankSys/RankSys)。

  • 预处理: 这个包用于将 MovieLens 1M 数据集转换为 LibFM 格式。

  • 预测: 这个包用于电影评分和排名预测。

  • GraphUtil: 该包用于在迭代过程中可视化一些性能指标。

我们将一步步讲解所有这些包。不过,了解数据集是必须的。

数据集描述和探索性分析

MovieLens 1M 小型数据集是从 MovieLens 网站下载(并已获得必要的许可)并使用的,网址为:grouplens.org/datasets/movielens/。我由衷感谢 F. Maxwell Harper 和 Joseph A. Konstan 提供的这个数据集。该数据集已发表于 MovieLens 数据集:历史与背景,ACM 交互智能系统事务(TiiS)5(4),文章 19(2015 年 12 月),共 19 页。

数据集包含三个文件:movies.datratings.datusers.dat,分别与电影、评分和用户相关。文件中包含 1,000,209 个匿名评分,涵盖约 3,900 部电影,评分由 6,040 名于 2000 年加入 MovieLens 的用户提供。所有评分都存储在 ratings.dat 文件中,格式如下:

UserID::MovieID::Rating::Timestamp

描述如下:

  • 用户 ID:范围在 1 到 6,040 之间

  • 电影 ID:范围在 1 到 3,952 之间

  • 评分:这些评分采用 5 星制

  • 时间戳:这是以秒为单位表示的

请注意,每个用户至少评价了 20 部电影。电影信息则保存在 movies.dat 文件中,格式如下:

MovieID::Title::Genres

描述如下:

  • 标题:这些与 IMDb 提供的标题相同(包括上映年份)

  • 类别:这些是用逗号(,)分隔的,每部电影被分类为动作、冒险、动画、儿童、喜剧、犯罪、剧情、战争、纪录片、奇幻、黑色电影、恐怖、音乐、悬疑、浪漫、科幻、惊悚和西部

最后,用户信息保存在 users.dat 文件中,格式如下:

UserID::Gender::Age::Occupation::Zip-code 

所有的个人信息是用户自愿提供的,且没有经过准确性检查。只有那些提供了部分个人信息的用户才会被包含在此数据集中。M 代表男性,F 代表女性,性别由此标识。年龄选择以下范围:

  • 1: 未满 18 岁

  • 18: 18-24

  • 25: 25-34

  • 35: 35-44

  • 45: 45-49

  • 50: 50-55

  • 56: 56 岁以上

职业从以下选项中选择:

  • 0: 其他,或未指定

  • 1: 学术/教育工作者

  • 2: 艺术家

  • 3: 文员/行政

  • 4: 大学/研究生学生

  • 5: 客服

  • 6: 医生/医疗保健

  • 7: 高管/经理

  • 8: 农民

  • 9: 家庭主妇

  • 10: K-12 学生

  • 11: 律师

  • 12: 程序员

  • 13: 退休

  • 14: 销售/市场营销

  • 15: 科学家

  • 16: 自雇

  • 17: 技术员/工程师

  • 18: 工匠/技工

  • 19: 失业

  • 20: 作家

现在我们了解了数据集,接下来可以开始进行探索性分析。首先,我们将创建一个 Spark 会话,作为 Spark 程序的入口:

SparkSession spark = new Builder()
                  .master("local[*]")
                  .config("spark.sql.warehouse.dir", "temp/")// change accordingly
                  .appName("MovieRecommendation")
                  .getOrCreate();

然后,我们将加载并解析rating.dat文件,进行一些探索性分析。以下代码行应该返回数据框 rating:

// Read RatingsFile
Dataset<Row> df1 = spark.read()
                .format("com.databricks.spark.csv")
                .option("inferSchemea", "true")
                .option("header", "true")
                .load(ratingsFile);

Dataset<Row> ratingsDF = df1.select(df1.col("userId"), df1.col("movieId"),
                df1.col("rating"), df1.col("timestamp"));
ratingsDF.show(10);

输出结果如下:

接下来,我们将加载movies.dat并准备电影数据框:

// Read MoviesFile
Dataset<Row> df2 = spark.read()
                .format("com.databricks.spark.csv")
                .option("inferSchema", "true")
                .option("header", "true")
                .load(movieFile);

Dataset<Row> moviesDF = df2.select(df2.col("movieId"), df2.col("title"), df2.col("genres"));
moviesDF.show(10);

输出结果如下:

然后,我们将注册两个数据框作为临时表,以便更容易进行查询。要注册这两个数据集,以下代码行需要被使用:

ratingsDF.createOrReplaceTempView("ratings");
moviesDF.createOrReplaceTempView("movies");

请注意,这将通过在内存中创建一个临时视图作为表,帮助加快内存查询的速度。然后,我们将选择探索一些与评分和电影相关的统计信息:

long numberOfRatings = ratingsDF.count();
long numberOfUsers = ratingsDF.select(ratingsDF.col("userId")).distinct().count();
long numberOfMovies = ratingsDF.select(ratingsDF.col("movieId")).distinct().count();

String print = String.*format*("Got %d ratings from %d users on %d movies.", numberOfRatings, numberOfUsers, numberOfMovies);
System.*out*.println(print);

输出结果如下:

Got 100004 ratings from 671 users on 9066 movies.

现在,让我们获取最大和最小评分以及评分过电影的用户数量。不过,你需要对我们刚刚在内存中创建的评分表执行一个 SQL 查询。在这里执行查询非常简单,类似于从 MySQL 数据库或关系型数据库管理系统(RDBMS)中进行查询。

然而,如果你不熟悉基于 SQL 的查询,建议你查看 SQL 查询规范,了解如何使用SELECT从特定表中进行选择,如何使用ORDER进行排序,如何使用JOIN关键字执行连接操作。

好吧,如果你知道 SQL 查询,你应该使用如下复杂的 SQL 查询获取一个新的数据集:

// Get the max, min ratings along with the count of users who have rated a movie.
Dataset<Row> sqlDF = spark.sql(
                "SELECT movies.title, movierates.maxr, movierates.minr, movierates.cntu "
                        + "FROM (SELECT "
                        + "ratings.movieId, MAX(ratings.rating) AS maxr,"
                        + "MIN(ratings.rating) AS minr, COUNT(distinct userId) AS cntu "
                        + "FROM ratings "
                        + "GROUP BY ratings.movieId) movierates "
                        + "JOIN movies ON movierates.movieId=movies.movieId "
                        + "ORDER BY movierates.cntu DESC");
sqlDF.show(10);

输出结果如下:

现在,为了深入了解,我们需要更多地了解用户及其评分。让我们找出前 10 个最活跃的用户,以及他们评分的电影次数:

// Top 10 active users and how many times they rated a movie.
Dataset<Row> mostActiveUsersSchemaRDD = spark.sql(
                "SELECT ratings.userId, count(*) AS ct "
                        + "FROM ratings "
                        + "GROUP BY ratings.userId "
                        + "ORDER BY ct DESC LIMIT 10");
mostActiveUsersSchemaRDD.show(10);

输出结果如下:

最后,让我们看一下某个特定用户,找出例如用户 668 评分高于 4 的电影:

// Movies that user 668 rated higher than 4
Dataset<Row> userRating = spark.sql(
                "SELECT ratings.userId, ratings.movieId, ratings.rating, movies.title "
                        + "FROM ratings JOIN movies "
                        + "ON movies.movieId=ratings.movieId "
                        + "WHERE ratings.userId=668 AND ratings.rating > 4");
userRating.show(10);

输出结果如下:

电影评分预测

首先,我们使用 FM 算法进行评分预测,该算法通过PointWiseGradientDescent学习。我们从数据预处理和转换为 LibFM 格式开始。要运行此评分预测,请按以下执行顺序:

  1. 首先,执行MovieLensFormaterWithMetaData.java;生成LibFM格式的MovieLens数据。

  2. 然后,执行SplitDataWithMetaData.java来准备训练集、测试集和验证集。

  3. 最后,执行MovieRatingPrediction.java,这是主类。

将数据集转换为 LibFM 格式

我们要重用的基于 FM 的模型只能消耗 LibFM 格式的训练数据,这与 LibSVM 格式差不多。因此,首先我们必须将 MovieLens 1M 数据集格式化,以便训练数据集包含用户、电影和现有评分信息。

LibFM 格式类似于 LibSVM 格式,但有一些基本的区别。更多信息,感兴趣的读者可以查看www.libfm.org/libfm-1.42.manual.pdf

同时,新特性将由用户信息和电影信息生成。首先,我们将定义输入(这将根据用户、电影和评分更新)和输出文件路径,如下所示:

//MovieLensFormaterWithMetaData.java
private static String *inputfilepath**;* private static String *outputfilepath*;

然后,我们定义数据路径和输出文件夹,用于保存生成的 LibFM 格式数据:

String foldername = "ml-1m";
String outFolder = "outFolder";

接着,我们定义目标列,这是 FM 模型要预测的内容。此外,我们还删除了时间戳列:

private static int *targetcolumn* = 0;
private static String *deletecolumns* = "3";

然后,我们设置分隔符为::并进行偏移:

private static String *separator* = "::";
private static int *offset* = 0;

接着,我们读取并解析用户数据(即users.dat),并为用户的类型、年龄和职业信息创建三个Map<Integer, String>

Set<Integer> deletecolumnsset = new HashSet<Integer>();
Map<String, Integer> valueidmap = new HashMap<String, Integer>(); 

*targetcolumn* = 2; // movielens format
String[] deletecolumnarr = *deletecolumns*.split(";"); 

for(String deletecolumn : deletecolumnarr) { 
          deletecolumnsset.add(Integer.*parseInt*(deletecolumn));
       }
*inputfilepath* = foldername + File.*separator* + "users.dat"; 
Reader fr = new FileReader(*inputfilepath*); 
BufferedReader br = new BufferedReader(fr); 

Map<Integer, String> usergenemap = new HashMap<Integer, String>();
Map<Integer, String> useragemap = new HashMap<Integer, String>();
Map<Integer, String> useroccupationmap = new HashMap<Integer, String>(); 

String line;
while (br.ready()) {
             line = br.readLine();
             String[] arr = line.split(*separator*); 
             usergenemap.put(Integer.*parseInt*(arr[0]), arr[1]); 
             useragemap.put(Integer.*parseInt*(arr[0]), arr[2]);
             useroccupationmap.put(Integer.*parseInt*(arr[0]), arr[3]);
          } 
br.close();
fr.close();

然后,我们解析电影数据集,创建一个Map<Integer, String>来存储电影信息:

*inputfilepath* = foldername + File.*separator* + "movies.dat"; 
fr = new FileReader(*inputfilepath*); 
br = new BufferedReader(fr);

Map<Integer, String> moviemap = new HashMap<Integer, String>();

while (br.ready()) {
              line = br.readLine(); 
              String[] arr = line.split(*separator*); 
               moviemap.put(Integer.*parseInt*(arr[0]), arr[2]); 
}
br.close();
fr.close();

然后,我们解析评分数据集,创建一个Map<Integer, String>来存储现有的评分。此外,我们定义了 LibFM 格式下将保存评分数据的输出文件名:

inputfilepath = foldername + File.separator + "ratings.dat";
outputfilepath = outFolder + File.separator + "ratings.libfm";
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outputfilepath)));

        fr = new FileReader(inputfilepath);
        br = new BufferedReader(fr);

        while(br.ready()) {
            line = br.readLine();
            String[] arr = line.split(separator);
            StringBuilder sb = new StringBuilder();
            sb.append(arr[targetcolumn]);

            int columnidx = 0;
            int userid = Integer.parseInt(arr[0]);
            int movieid = Integer.parseInt(arr[1]);

            for(int i = 0; i < arr.length; i++) {
                if(i != targetcolumn && !deletecolumnsset.contains(i)) {
                    String useroritemid = Integer.toString(columnidx) + " " + arr[i];

                    if(!valueidmap.containsKey(useroritemid)) {
                        valueidmap.put(useroritemid, offset++);
                    }

                    sb.append(" ");
                    sb.append(valueidmap.get(useroritemid));
                    sb.append(":1");

                    columnidx++;
                }

然后,我们开始添加一些属性,如性别信息、年龄、职业和电影类别信息:

// Add attributes
String gender = usergenemap.get(userid);
String attributeid = "The gender information " + gender;

 if(!valueidmap.containsKey(attributeid)) {
                valueidmap.put(attributeid, offset++);
            }

            sb.append(" ");
            sb.append(valueidmap.get(attributeid));
            sb.append(":1");

            String age = useragemap.get(userid);
            attributeid = "The age information " + age;

            if(!valueidmap.containsKey(attributeid)) {
                valueidmap.put(attributeid, offset++);
            }

            sb.append(" ");
            sb.append(valueidmap.get(attributeid));
            sb.append(":1");

            String occupation = useroccupationmap.get(userid);
            attributeid = "The occupation information " + occupation;

            if(!valueidmap.containsKey(attributeid)) {
                valueidmap.put(attributeid, offset++);
            }

            sb.append(" ");
            sb.append(valueidmap.get(attributeid));
            sb.append(":1");

            String movieclassdesc = moviemap.get(movieid);
            String[] movieclassarr = movieclassdesc.split("\\|");

            for(String movieclass : movieclassarr) {
                attributeid = "The movie class information " + movieclass;
                if(!valueidmap.containsKey(attributeid)) {
                    valueidmap.put(attributeid, offset++);
                }

                sb.append(" ");
                sb.append(valueidmap.get(attributeid));
                sb.append(":1");
}

在前面的代码块中,:1代表用户为哪个电影提供了评分。最后,我们添加元数据,useridmovieid

//add metadata information, userid and movieid
sb.append("#");
sb.append(userid);
sb.append(" "+movieid);
writer.write(sb.toString());
writer.newLine();

现在,生成的评分数据集(执行MovieLensFormaterWithMetaData.java后)将以 LibFM 格式保存在formatted_data目录下,文件名为ratings.libfm,其结构如下:

训练集和测试集的准备

现在我们已经了解了如何转换评分、电影和元数据,接下来我们可以开始从 LibFM 格式的数据中创建训练集、测试集和验证集。首先,我们设置将要使用的 LibFM 文件的路径,如下所示:

//SplitDataWithMetaData.java
private static String *ratinglibFM* = *formattedDataPath* + "/" + "ratings.libfm"; // input
private static String *ratinglibFM_train* = *formattedDataPath* + "/" + "ratings_train.libfm"; // for traning
private static String *ratinglibFM_test* = *formattedDataPath* + "/" + "ratings_test.libfm"; // for testing
private static String *ratinglibFM_test_meta* = *formattedDataPath* +"/"+"ratings_test.libfm.meta";// metadata
private static String *ratinglibFM_valid* = *formattedDataPath* + "/" + "ratings_valid.libfm"; // validation

然后,我们展示输出目录,用于写入分割后的训练集、验证集和测试集:

private static String *formattedDataPath* = "outFolder";

接着,我们实例化一个BufferedWriter,用于写入分割后的文件:

Reader fr = new FileReader(ratinglibFM);
Random ra = new Random();

BufferedWriter trainwrite = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(ratinglibFM_train)));

BufferedWriter testwrite = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(ratinglibFM_test)));

BufferedWriter testmetawrite = new BufferedWriter(new OutputStreamWriter(new                       FileOutputStream(ratinglibFM_test_meta)));   

BufferedWriter validwrite = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(ratinglibFM_valid)));

BufferedReader br = new BufferedReader(fr);
String line = null;
int testline = 0;

while(br.ready()) {
       line = br.readLine();
       String[] arr = line.split("#");
       String info = arr[0];

       double dvalue = ra.nextDouble();
       if(dvalue>0.9)
            {
             validwrite.write(info);
             validwrite.newLine();
            }

       else if(dvalue <= 0.9 && dvalue>0.1) {
                trainwrite.write(info);
                trainwrite.newLine();
         } else {
                testwrite.write(info);
                testwrite.newLine();
           if(arr.length==2)
                {
                testmetawrite.write(arr[1] + " " + testline);
                testmetawrite.newLine();
                testline++;
            }
         }
  }

最后,我们关闭文件指针以释放资源:

br.close();
fr.close();

trainwrite.flush();
trainwrite.close();

testwrite.flush();
testwrite.close();

validwrite.flush();
validwrite.close();

testmetawrite.flush();
testmetawrite.close();

现在,结果评分数据集(执行SplitDataWithMetaData.java后)将以 LibFM 格式保存在formatted_data目录中,格式与 LibSVM 类似:

最后,目录(即formatted_data)将包含以下文件:

太棒了!现在我们的数据集已经准备好,我们可以使用 FM 算法开始进行电影评分预测了。

电影评分预测

现在,所有训练、验证和评估所需的数据集都已准备就绪,我们可以开始训练 FM 模型。我们首先展示训练数据的文件名:

final String trainFile = *formattedDataPath*+ "/" + "ratings_train.libfm";

然后,我们设置测试数据文件路径:

final String testFile = *formattedDataPath*+ "/" + "ratings_test.libfm";

然后,我们设置测试元数据文件路径:

final String testMetaFile = *formattedDataPath*+ "/" + "ratings_test.libfm.meta";

然后,设置最终预测输出文件的文件名:

final String outputFile = *formattedDataPath*+ "/" + "predict_output.txt";

然后,我们设置日志、指标、时间等每次迭代的文件写入路径(但不用担心,我们也会看到图形形式的结果):

final String rLog = *outPut* + "/" + "metrics_logs.txt";

然后,我们设置 k0、k1 和 k2 的维度,使得 k0 用于偏置,k1 用于单向交互,k2 用于双向交互的维度:

final String dimension = "1,1,8"; // tunable parameters

我们将迭代训练 100 次迭代次数:

final String iterations = "100"; // tunable parameter

接着,我们为 SGD 设置学习率——优化器试图最小化误差的速率:

final String learnRate = "0.01"; // tunable and learnable parameter

现在优化器已经知道了学习率,接下来的重要参数是设置正则化参数,以防止训练时过拟合。

基于 Java 的 FM 库需要三种正则化:偏置、单向和双向正则化。因此,FM 库接受的格式是 r0、r1、r2。这里,r0 是偏置正则化,r1 是单向正则化,r2 是双向正则化:

final String regularization = "0,0,0.1";

然后,我们初始化用于初始化双向因子的标准差:

final String stdDeviation = "0.1";

然后,我们使用LibSVMDataProvider()类加载训练集和测试集:

System.*out*.println("Loading train...t");
DataProvider train = new LibSVMDataProvider();
Properties trainproperties = new Properties();

trainproperties.put(Constants.*FILENAME*, trainFile);
train.load(trainproperties,false);

System.*out*.println("Loading test... t");
DataProvider test = new LibSVMDataProvider();
Properties testproperties = new Properties();

testproperties.put(Constants.*FILENAME*, testFile);
test.load(testproperties,false);

一旦训练集和测试集加载完成,我们开始创建用户-物品表(即主表):

int num_all_attribute = Math.*max*(train.getFeaturenumber(), test.getFeaturenumber());
DataMetaInfo meta = new DataMetaInfo(num_all_attribute);
meta.debug();
Debug.*openConsole*();

然后,我们实例化因式分解机,在开始训练之前:

FmModel fm = new FmModel();

然后,使用init()方法初始化实例化和训练以下 FM 模型所需的参数:

public FmModel()
    {
        num_factor = 0;
        initmean = 0;
        initstdev = 0.01;
        reg0 = 0.0;
        regw = 0.0;
        regv = 0.0; 
        k0 = true;
        k1 = true;
    }

init()方法的签名如下:

public void init()
    {
        w0 = 0;
        w = new double[num_attribute];
        v = new DataPointMatrix(num_factor, num_attribute);
        Arrays.fill(w, 0);
        v.init(initmean, initstdev);
        m_sum = new double[num_factor];
        m_sum_sqr = new double[num_factor];
    }

然后,我们设置主类的属性数和标准差:

fm.num_attribute = num_all_attribute;
fm.initstdev = Double.*parseDouble*(stdDeviation);

然后,我们设置因式分解的维度数量。在我们的例子中,我们有三种交互——用户、电影和评分:

Integer[] dim = *getIntegerValues*(dimension);
assert (dim.length == 3);
fm.k0 = dim[0] != 0;
fm.k1 = dim[1] != 0;
fm.num_factor = dim[2];

前面的值实际上是通过getIntegerValues()方法解析的,该方法接受维度作为字符串并使用,进行拆分。

最后,它只返回用于模型进行交互的维度的整数值。使用以下签名来实现:

static public Integer[] getIntegerValues(String parameter) {
        Integer[] result = null;
        String[] strresult = Util.tokenize(parameter, ",");
        if(strresult!=null && strresult.length>0) {
            result = new Integer[strresult.length];
            for(int i=0;i<strresult.length;i++) {
                result[i] = Integer.parseInt(strresult[i]);
            }
        }
        return result;
    }

然后,我们将学习方法设置为随机梯度下降SGD):

FmLearn fml = new FmLearnSgdElement();
((FmLearnSgd) fml).num_iter = Integer.*parseInt*(iterations);

fml.fm = fm;
fml.max_target = train.getMaxtarget();
fml.min_target = train.getMintarget();
fml.meta = meta;

接着,我们定义要执行的任务类型。在我们的例子中,它是回归。然而,我们将使用 TASK_CLASSIFICATION 进行分类:

fml.task = TaskType.*TASK_REGRESSION*

然后,我们设置正则化:

Double[] reg = *getDoubleValues*(regularization);
assert ((reg.length == 3)); // should meet 3 way regularization

fm.reg0 = reg[0];
fm.regw = reg[1];
fm.regv = reg[2];

然后,关于学习率,我们必须设置每层的学习率(单独设置),这与 DL4J 库不同:

FmLearnSgd fmlsgd = (FmLearnSgd) (fml);

if (fmlsgd != null) {
        Double[] lr = *getDoubleValues*(learnRate);
        assert (lr.length == 1);
        fmlsgd.learn_rate = lr[0];
        Arrays.*fill*(fmlsgd.learn_rates, lr[0]);
}

前面的值实际上是通过 getDoubleValues() 方法解析的,该方法接受一个字符串形式的学习率,并使用 , 进行分割。最后,它返回一个单一的学习率值供模型使用。此方法的签名如下:

static public Double[] getDoubleValues(String parameter) {
        Double[] result;
        String[] strresult = Util.tokenize(parameter, ",");
        if(strresult!=null && strresult.length>0) {
            result = new Double[strresult.length];
            for(int i=0; i<strresult.length; i++) {
                result[i] = Double.parseDouble(strresult[i]);
            }
        }
        else {
            result = new Double[0];
        }
        return result;
    }

现在所有的超参数都已经设置好,我们准备开始训练了。与 DL4J FM 不同的是,它提供了一个 learn() 方法来学习模型:

fml.learn(train, test);

learn() 方法是一个抽象方法,接收训练集和测试集:

//FmLearn.java
public abstract void learn(DataProvider train, DataProvider test) throws Exception;

learn() 方法的具体实现需要同时传入训练集和测试集。然后,它会打乱训练集,以避免训练中的偏差。接着,使用 predict() 方法进行预测操作,基于我们一开始定义的任务类型(在我们这个例子中是回归)。

最后,它会在测试集上评估模型,并计算训练集和测试集的均方误差(MSE)。该方法的实际实现如下:

//FmLearnSgdElement.java
public void learn(DataProvider train, DataProvider test)  throws Exception{
        super.learn(train, test);
        List<Double> iterationList=new ArrayList<Double>();
        List<Double> trainList=new ArrayList<Double>();
        List<Double> testList=new ArrayList<Double>();

        // SGD
        for(int i = 0; i < num_iter; i++) {
            try
            {
                double iteration_time = Util.getusertime();
                train.shuffle();
                for(train.getData().begin(); !train.getData().end(); train.getData().next()) {
                    double p = fm.predict(train.getData().getRow(), sum, sum_sqr);
                    double mult = 0;

                    if(task == TaskType.TASK_REGRESSION) {
                        p = Math.min(max_target, p);
                        p = Math.max(min_target, p);
                        mult = -(train.getTarget()[train.getData().getRowIndex()]-p);
                    } else if(task == TaskType.TASK_CLASSIFICATION) {
                        mult = -train.getTarget()[train.getData().getRowIndex()]*
                                (1.0-1.0/(1.0+Math.exp(-train.getTarget()[train.getData()
                                .getRowIndex()]*p)));
                    }                
                    SGD(train.getData().getRow(), mult, sum);                    
                }                
                iteration_time = (Util.getusertime() - iteration_time);
                double rmse_train = evaluate(train);
                double rmse_test = evaluate(test);
                iterationList.add((double)i);
                testList.add(rmse_test);
                trainList.add(rmse_train);

                String print = String.format("#Iterations=%2d::  
                               Train_RMSE=%-10.5f  Test_RMSE=%-10.5f", i, rmse_train, rmse_test);
                Debug.println(print);
                if(log != null) {
                    log.log("rmse_train", rmse_train);
                    log.log("time_learn", iteration_time);
                    log.newLine();
                }
            }
            catch(Exception e)
            {
                throw new JlibfmRuntimeException(e);// Exception library for Java FM
            }
        }    
        PlotUtil_Rating.plot(convertobjectArraytoDouble(iterationList.toArray()),
                convertobjectArraytoDouble(testList.toArray()),
                convertobjectArraytoDouble(trainList.toArray()));

    }

在前面的代码块中,FM 模型通过考虑三元交互等方式进行预测操作,类似于其他回归算法,并将预测结果计算为概率:

// FmModel.java, we create a sparse matrix 
public double predict(SparseRow x, double[] sum, double[] sum_sqr)
    {
        double result = 0;
        if(k0) {    
            result += w0;
        }
        if(k1) {
            for(int i = 0; i < x.getSize(); i++) {
                result += w[x.getData()[i].getId()] * x.getData()[i].getValue();
            }
        }
        for(int f = 0; f < num_factor; f++) {
            sum[f] = 0;
            sum_sqr[f] = 0;
            for(int i = 0; i < x.getSize(); i++) {
                double d = v.get(f,x.getData()[i].getId()) * x.getData()[i].getValue();
                sum[f] = sum[f]+d;
                sum_sqr[f] = sum_sqr[f]+d*d;
            }
            result += 0.5 * (sum[f]*sum[f] - sum_sqr[f]);
        }

        return result;
 }

然而,最后,使用 PlotUtil_Rating 类中的 plot() 方法对每次迭代的训练和测试 MSE 进行可视化。我们稍后会讨论这个类。

此外,我们还初始化了日志记录,以便在控制台上打印计算的结果和进度:

System.*out*.println("logging to " + rLog);
RLog rlog = new RLog(rLog);
fml.log = rlog;
fml.init();
rlog.init();
fm.debug();
fml.debug();

最后,我们在测试集上评估模型。由于我们的任务是回归任务,我们计算每次迭代的回归指标,例如 RMSE:

String print = String.*format*("#Iterations=%s:: Train_RMSE=%-10.5f Test_RMSE=%-10.5f", iterations, fml.evaluate(train), fml.evaluate(test));
System.*out*.println(print);
>>> Loading train...
 Loading test...
 #attr=9794 #groups=1
 #attr_in_group[0]=9794
 logging to outFolder/output.txt
 num_attributes=9794
 use w0=true
 use w1=true
 dim v =8
 reg_w0=0.0
 reg_w=0.0
 reg_v=0.0
 init ~ N(0.0,0.1)
 num_iter=100
 task=TASK_REGRESSION
 min_target=1.0
 max_target=5.0
 learnrate=0.01
 learnrates=0.01,0.01,0.01
 #iterations=100
 #Iterations= 0:: Train_RMSE=0.92469 Test_RMSE=0.93231
 #Iterations= 1:: Train_RMSE=0.91460 Test_RMSE=0.92358
 #Iterations= 2:: Train_RMSE=0.91595 Test_RMSE=0.92535
 #Iterations= 3:: Train_RMSE=0.91238 Test_RMSE=0.92313
 ...
 #Iterations=98:: Train_RMSE=0.84275 Test_RMSE=0.88206
 #Iterations=99:: Train_RMSE=0.84068 Test_RMSE=0.87832

最后,我们将预测结果及所有相关的指标保存在一个文件中:

// prediction at the end
String print = String.format("#Iterations=%s::  Train_RMSE=%-10.5f  Test_RMSE=%-10.5f", iterations, fml.evaluate(train), fml.evaluate(test));
System.out.println(print);

// save prediction
Map<Integer, String> ratingsMetaData = new HashMap<>();
if(Files.exists(Paths.get(testMetaFile))) {
            BufferedReader bufferedReader = new BufferedReader(new FileReader(testMetaFile));
            String line;

            while((line = bufferedReader.readLine()) != null) {
                String[] splitLine = line.split("\\s+");
                if(splitLine.length > 0) {
                    Integer indexKey = Integer.parseInt(splitLine[2]);
                    String userIdmovieIdValue = splitLine[0] + " " +  splitLine[1];
                    ratingsMetaData.put(indexKey, userIdmovieIdValue);
                }
            }
        }

double[] pred = new double[test.getRownumber()];
fml.predict(test, pred);
Util.save(ratingsMetaData, pred, outputFile);

String FILENAME = Constants.FILENAME;
// Save the trained FM model 
fmlsgd.saveModel(FILENAME);

前面的代码块将生成两个文件,分别是 predict_output.txtmetrics_logs.txt,用于分别写入预测结果和日志。例如,predicted_output.txt 文件中的一个样本显示第二列是电影 ID,第三列是预测评分(满分 5.0),如下所示:

1 3408 4.40
 1 2797 4.19
 1 720 4.36
 1 1207 4.66
 2 1537 3.92
 2 1792 3.39
 2 1687 3.32
 2 3107 3.55
 2 3108 3.46
 2 3255 3.65

另一方面,metrics_logs.txt 显示了包括 RMSE、MAE 和日志等指标,如下图所示:

然而,由于仅凭这些数值难以理解训练状态和预测效果,我决定将它们绘制成图。以下图展示了每次迭代的训练和测试阶段的 MSE:

每次迭代的训练和测试 MSE(100 次迭代)

前述图表显示,训练误差和测试误差一致,这意味着 FM 模型没有过拟合。该图表还显示,错误数量仍然很高。然后,我将训练迭代了 1,000 次,发现错误有所减少,具体内容如下图所示:

每次迭代的训练和测试均方误差(MSE),最多迭代 1,000 次

现在,为了绘制前述图表,我在PlotUtil_Rating.java类中编写了一个plot()方法,使用JFreeChart库绘制训练和测试误差/迭代:

public static void plot(double[] iterationArray, double[] testArray, double[] trainArray) {
    final XYSeriesCollection dataSet = new XYSeriesCollection();
    addSeries(dataSet, iterationArray, testArray, "Test MSE per iteration");
    addSeries(dataSet, iterationArray, trainArray, "Training MSE per iteration");

    final JFreeChart chart = ChartFactory.createXYLineChart(
            "Training and Test error/iteration (1000 iterations)", // chart title
            "Iteration", // x axis label
            "MSE", // y axis label
            dataSet, // data
            PlotOrientation.VERTICAL,
            true, // include legend
            true, // tooltips
            false // urls
    );

    final ChartPanel panel = new ChartPanel(chart);
    final JFrame f = new JFrame();
    f.add(panel);
    f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
    f.pack();
    f.setVisible(true);
}

XYSeries类中的addSeries()方法用于为图表添加系列:

private static void addSeries (final XYSeriesCollection dataSet, double[] x, double[] y, final String label){
    final XYSeries s = new XYSeries(label);
    for(int j = 0; j < x.length; j++ ) s.add(x[j], y[j]);
    dataSet.addSeries(s);
}

哪个更有意义;– 排名还是评分?

在开发电影推荐系统时,评分预测还是排名预测更具逻辑性?如果每个用户的评分量足够高,分解用户-产品矩阵是最好的方法。在我看来,然而,如果数据集过于稀疏,预测可能会非常不准确。

知道这一点后,我开始探索 RankSys 库,并发现其中一位贡献者认为排名更具逻辑性,尽管他没有提供任何解释。后来,我与一些推荐系统的开发者和研究人员交流,了解到他可能指的是,排名由于评分和项目数量之间的差距,对预测误差不那么敏感。原因是排名保留了层次结构,而不依赖于绝对评分。

基于此理解,后来我决定向排名预测迈出一步。为此,我编写了一个单独的类RankingPrediction.java,用于预测测试集中的每个用户的电影排名,其结构如下:

电影排名预测子项目结构

该类有三个方法,具体如下:

  • createIndexFromFile():此方法用于根据传入方法参数的文件创建索引。

  • generateTrainAndTestDataSet():此方法用于将数据分为训练集和测试集,这是评估数据挖掘模型的重要部分。

  • main():此方法用于创建项目和用户的索引,并用于以下操作:

    • 一组用户的索引

    • 一组项目的索引

    • 存储由FastUserIndexFastItemIndex提供的用户和项目的偏好/评分

    • 创建一个推荐接口,将由FMRecommender使用

    • 使用一个因式分解机,该机器使用RMSE-like 损失,并均衡地采样负实例

首先,我们设置输入数据文件的路径:

final String folderPath = "ml-1m";
final String indexPath = "index";
final String userFileName = "users.dat";
final String moviesFileName = "movies.dat";
final String ratingsFileName = "ratings.dat";
final String encodingUTF8 = "UTF-8";

final String userDatPath = folderPath + "/" + userFileName;
final String movieDatPath = folderPath + "/" + moviesFileName;

接下来,我们设置之前提到的用户和电影索引路径:

final String indexPath = "index";
final String userIndexPath = indexPath + "/" + "userIndex";
final String movieIndexPath = indexPath + "/" + "movieIndex";

然后,我们设置结果文件的路径,训练集和测试集将在该路径生成:

String trainDataPath = indexPath + "/ratings_train";
String testDataPath = indexPath + "/ratings_test";
final String ratingsDatPath = folderPath + "/" + ratingsFileName;

然后,我们为users.dat文件中的所有用户创建用户索引。在这里,用户在内部由从 0(包括)到索引用户数量(不包括)的数字索引表示:

FastUserIndex<Long> userIndex = SimpleFastUserIndex.*load*(UsersReader.*read*(userIndexPath, *lp*));

在前面的代码行中,我们使用了 RankSys 库中的SimpleFastUserIndex类,它帮助我们创建了一个由名为IdxIndex的双向映射支持的FastUserIndex的简单实现。

然后,我们为movies.dat文件中的所有项目创建项目索引。这为一组项目创建了索引。在这里,项目在内部由从 0(包括)到索引项目数量(不包括)的数字索引表示:

FastItemIndex<Long> itemIndex = SimpleFastItemIndex.*load*(ItemsReader.*read*(movieIndexPath, *lp*));

在前面的代码行中,我们使用了 RankSys 库中的SimpleFastItemIndex类,它帮助我们创建了一个由名为IdxIndex的双向映射支持的FastItemIndex的简单实现。然后,我们存储了由FastUserIndexFastItemIndex提供的用户和项目的偏好/评分:

FastPreferenceData<Long, Long> trainData = SimpleFastPreferenceData.*load*(SimpleRatingPreferencesReader.*get*().read(trainDataPath, *lp*, *lp*), userIndex, itemIndex);

FastPreferenceData<Long, Long> testData = SimpleFastPreferenceData.*load*(SimpleRatingPreferencesReader.*get*().read(testDataPath, *lp*, *lp*), userIndex, itemIndex);

然后,我们调用这两个方法来创建用户和项目索引:

if (!Files.*exists*(Paths.*get*(userIndexPath))) {
     *createIndexFromFile*(userDatPath, encodingUTF8, userIndexPath);
}

if (!Files.*exists*(Paths.*get*(movieIndexPath))) {
 *createIndexFromFile*(movieDatPath, encodingUTF8, movieIndexPath);
}

在前面的 if 语句中,我们使用createIndexFromFile()方法从文件中生成了索引,该方法如下所示:

static void createIndexFromFile(String fileReadPath, String encodings, String fileWritePath) throws IOException {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(
                        fileReadPath), Charset.forName(encodings)));
        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
                        new FileOutputStream(fileWritePath)));

        String line;
        while((line = bufferedReader.readLine()) != null) {
            StringBuilder builder = new StringBuilder();
            String[] lineArray = line.split("::");
            builder.append(lineArray[0]);
            writer.write(builder.toString());
            writer.newLine();
        }

        writer.flush();

        bufferedReader.close();
        writer.close();
    }

一旦索引文件生成,我们就开始生成训练集和测试集,如下所示:

if ( !Files.*exists*(Paths.*get*(trainDataPath))) {
 *generateTrainAndTestDataSet*(ratingsDatPath, trainDataPath, testDataPath);
}

在这个代码块中,我们使用了generateTrainAndTestDataSet()方法来生成训练集和测试集:

static void generateTrainAndTestDataSet(String ratingsDatPath, String trainDataPath, String testDataPath) throws IOException {
        BufferedWriter writerTrain = new BufferedWriter(new OutputStreamWriter(
                        new FileOutputStream(trainDataPath)));

        BufferedWriter writerTest = new BufferedWriter(new OutputStreamWriter(
                        new FileOutputStream(testDataPath)));

        BufferedReader bufferedReader = new BufferedReader(new FileReader(ratingsDatPath));
        List<String> dummyData = new ArrayList<>();
        String line;

        while((line = bufferedReader.readLine()) != null) {
            String removeDots = line.replaceAll("::", "\t");
            dummyData.add(removeDots);
        }

        bufferedReader.close();

        Random generator = new Random();
        int dataSize = dummyData.size();
        int trainDataSize = (int)(dataSize * (2.0 / 3.0));
        int i = 0;

        while(i < trainDataSize){
            int random = generator.nextInt(dummyData.size()-0) + 0;
            line = dummyData.get(random);
            dummyData.remove(random);
            writerTrain.write(line);
            writerTrain.newLine();
            i++;
        }

        int j = 0;
        while(j < (dataSize - trainDataSize)){
            writerTest.write(dummyData.get(j));
            writerTest.newLine();
            j++;
        }

        writerTrain.flush();
        writerTrain.close();

        writerTest.flush();
        writerTest.close();
    }

前述方法将 2/3 作为训练集,1/3 作为测试集。最后,文件指针被关闭,以释放资源。如果前三个 if 语句成功执行,您应该会看到生成了两个索引文件和两个其他文件(分别用于训练集和测试集):

然后,我们创建一个推荐接口,它将由FMRecommender类使用,该类生成没有任何推荐项限制的推荐:

Map<String, Supplier<Recommender<Long, Long>>> recMap = new HashMap<>();

最后,包装偏好因子分解机以与 RankSys 用户-偏好对配合使用。然后,我们通过设置学习率、正则化和标准差来训练模型,并使用PointWiseGradientDescent迭代训练最多 100 次。FM 然后使用类似 RMSE 的损失,并通过负实例的平衡抽样:

// Use Factorisation machine that uses RMSE-like loss with balanced sampling of negative instances:
String outFileName = "outFolder/Ranking_RMSE.txt";
recMap.put(outFileName, Unchecked.supplier(() -> {
            double negativeProp = 2.0D;

            FMData fmTrain = new OneClassPreferenceFMData(trainData, negativeProp);
            FMData fmTest = new OneClassPreferenceFMData(testData, negativeProp);

            double learnRate = 0.01D; // Learning Rate
            int numIter = 10; // Number of Iterations
            double sdev = 0.1D;
            double regB = 0.01D;

            double[] regW = new double[fmTrain.numFeatures()];
            Arrays.fill(regW, 0.01D);
            double[] regM = new double[fmTrain.numFeatures()];

            Arrays.fill(regM, 0.01D);
            int K = 100;

            // returns enclosed FM
 FM fm = new FM(fmTrain.numFeatures(), K, new Random(), sdev);
            (new PointWiseGradientDescent(learnRate, numIter, PointWiseError.rmse(), 
                                          regB, regW, regM)).learn(fm, fmTrain, fmTest);
             // From general purpose factorization machines to preference FM for user-preference  
            PreferenceFM<Long, Long> prefFm = new PreferenceFM<Long, Long>(userIndex, itemIndex, fm);

            return new FMRecommender<Long, Long>(prefFm);
        }));

在前面的代码块中,FM 模型使用learn()方法进行训练,该方法与上一节中用于预测评分的learn()方法非常相似。然后,为了评估模型,首先,我们设置目标用户和SimpleRecommendationFormat,该格式为制表符分隔的用户-项目评分三元组(即原始数据集中的内容):

Set<Long> targetUsers = testData.getUsersWithPreferences().collect(Collectors.*toSet*());
//Format of the recommendation generated by the FM recommender model as <user, prediction)
RecommendationFormat<Long, Long> format = new SimpleRecommendationFormat<>(*lp*, *lp*);
Function<Long, IntPredicate> filter = FastFilters.*notInTrain*(trainData);
int maxLength = 100;

然后,我们调用RecommenderRunner接口来生成推荐并根据格式打印出来:

// Generate recommendations and print it based on the format.
RecommenderRunner<Long, Long> runner = new FastFilterRecommenderRunner<>(userIndex, itemIndex, targetUsers.stream(), filter, maxLength);

 recMap.forEach(Unchecked.biConsumer((name, recommender) -> {
            System.out.println("Ranking prediction is ongoing...");
            System.out.println("Result will be saved at " + name);
            try(RecommendationFormat.Writer<Long, Long> writer = format.getWriter(name)) {
                runner.run(recommender.get(), writer);
            }
        }));

前面的代码块将在测试集上执行评估,并将推荐写入我们之前指定的文本文件:

>>
 Ranking prediction is ongoing...
 Result will be saved at outFolder/Ranking_RMSE.txt
 INFO: iteration n = 1 t = 3.92s
 INFO: iteration n = 2 t = 3.08s
 INFO: iteration n = 3 t = 2.88s
 INFO: iteration n = 4 t = 2.84s
 INFO: iteration n = 5 t = 2.84s
 INFO: iteration n = 6 t = 2.88s
 INFO: iteration n = 7 t = 2.87s
 INFO: iteration n = 8 t = 2.86s
 INFO: iteration n = 9 t = 2.94s
 ...
 INFO: iteration n = 100 t = 2.87s
 Graph plotting...

预测结果已保存在 outFolder/Ranking_RMSE.txt

现在,让我们来看看输出文件:

944 2396 0.9340957389234708
 944 593 0.9299994477666256
 944 1617 0.9207678675263278
 944 50 0.9062805385053954
 944 1265 0.8740234972054955
 944 589 0.872143533435846
 944 480 0.8659624750023733
 944 2028 0.8649344355656503
 944 1580 0.8620307480644472
 944 2336 0.8576568651679782
 944 1196 0.8570902991702303

这张来自输出文件的快照显示了用户 944 对不同电影的预测排名。现在我们可以看到我们的 FM 模型已经预测了用户的电影排名,接下来检查模型在准确性和执行时间方面的表现是有意义的。

为此,我编写了一个名为PlotUtil_Rank.java的类。该类接受度量类型和迭代次数,并使用plot()方法生成图表:

public static void plot(double[] iterationArray, double[] timeArray, String chart_type, int iter) {
        String series = null;
        String title = null;
        String x_axis = null;
        String y_axis = null;

        if(chart_type =="MSE"){        
            series = "MSE per Iteration (" + iter + " iterations)";
            title = "MSE per Iteration (" + iter + " iterations)";
            x_axis = "Iteration";
            y_axis = "MSE";
        }else {
            series = "Time per Iteration (" + iter + " iterations)";
            title = "Time per Iteration (" + iter + " iterations)";
            x_axis = "Iteration";
            y_axis = "Time";            
        }
            final XYSeriesCollection dataSet = new XYSeriesCollection();
            addSeries(dataSet, iterationArray, timeArray, series);

            final JFreeChart chart = ChartFactory.createXYLineChart(
                    title, // chart title
                    x_axis, // x axis label
                    y_axis, // y axis label
                    dataSet, // data
                    PlotOrientation.VERTICAL,
                    true, // include legend
                    true, // tooltips
                    false // urls
                    );

        final ChartPanel panel = new ChartPanel(chart);
        final JFrame f = new JFrame();
        f.add(panel);
        f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        f.pack();
        f.setVisible(true);
    }

该方法进一步从PointWiseGradientDescent.java类中调用。首先,我们创建两个ArrayList类型的Double来存储执行时间和 MSE:

//PointWiseGradientDescent.java
List<Double> timeList = new ArrayList<Double>();
List<Double> errList = new ArrayList<Double>();

然后,对于每次迭代,; learn() 方法会生成每次迭代的 MSE 误差和时间,并将它们放入列表中:

iter = t;
long time1 = System.*nanoTime*() - time0;
iterationList.add((double)iter);
timeList.add((double)time1 / 1_000_000_000.0);
errList.add(error(fm, test));

最后,调用plot()方法绘制图表,如下所示:

PlotUtil_Rank.*plot*(convertobjectArraytoDouble(iterationList.toArray()),     convertobjectArraytoDouble(errList.toArray()), "MSE", iter); 

PlotUtil_Rank.*plot*(convertobjectArraytoDouble(iterationList.toArray()), convertobjectArraytoDouble(timeList.toArray()), "TIME", iter);

顺便提一下,convertobjectArraytoDouble(),在下面的代码中显示,用于将对象数组转换为双精度数值,以作为绘图的数据点:

public double [] convertobjectArraytoDouble(Object[] objectArray){
 double[] doubleArray = newdouble[objectArray.length];
               //Double[ ]doubleArray=new Double();
 for(int i = 0; i < objectArray.length; i++){
                   Object object = objectArray[i]; 
                   String string = object.toString(); double dub = Double.*valueOf*(string).doubleValue();
                   doubleArray[i] = dub;
                       }
 return doubleArray;
     }

上述调用应该生成两个图表。首先,我们看到每次迭代的 MSE,接下来的图表报告了 100 次迭代的相同数据:

每次迭代的 MSE(最多到第 100 次迭代)

然后,我们看到每次迭代的时间,接下来的图表报告了第 100 次迭代的相同数据:

每次迭代的时间(最多到第 100 次迭代)

最后,从第二个图表中,我们无法得出重要的结论,除了每次迭代的执行时间波动很大。然而,在第 90 次迭代时,每次迭代所需的时间已经达到饱和。

另一方面,MSE 在第 20 次迭代后大幅下降,从 0.16 降到 0.13,但在第 25 次迭代后趋于饱和。这意味着仅增加迭代次数并不能进一步减少 MSE。因此,我建议你在改变迭代次数的同时,也要尝试调整其他超参数。

常见问题解答(FAQs)

现在我们已经看到如何开发一个电影推荐系统,预测用户对电影的评分和排名,还有一些问题也需要我们关注。同时,本章没有涵盖/讨论该库,因此我建议你仔细阅读文档。

然而,我们仍然会在本节中看到一些你可能已经想过的常见问题。你可以在附录中找到这些问题的答案。

  1. 如何保存一个训练好的 FM 模型?

  2. 如何从磁盘恢复一个已保存的 FM 模型?

  3. 我可以使用 FM 算法来解决分类任务吗?

  4. 给我几个 FM 算法应用的示例用例。

  5. 我可以使用 FM 算法进行 Top-N 推荐吗?

总结

在本章中,我们学习了如何使用 FM 开发一个电影推荐系统,FM 是一组算法,通过以监督的方式引入缺失在矩阵分解算法中的二阶特征交互,从而增强线性模型的性能。

然而,在深入讨论使用基于 RankSys 库的 FM 实现项目之前,我们已经看过了使用矩阵分解和协同过滤的推荐系统的理论背景。由于页面限制,我没有详细讨论该库。建议读者查阅 GitHub 上的 API 文档:github.com/RankSys/RankSys

这个项目不仅涵盖了由个人用户进行的电影评分预测,还讨论了排名预测。因此,我们还使用了 FM(因子分解机)来预测电影的排名。

这或多或少是我们开发一个端到端 Java 项目的旅程的结束。然而,我们还没有结束!在下一章中,我们将讨论一些深度学习的最新趋势。然后,我们将看到一些可以使用 DL4J 库实现的新兴用例,或者至少我们会看到一些指引。

问题的答案

问题 1 的答案:为此,您可以通过提供输入模型文件名来调用saveModel()方法:

String FILENAME = Constants.FILENAME;
// Save the trained FM model 
fmlsgd.saveModel(FILENAME);

saveModel()方法如下:

public void saveModel(String FILENAME) throws Exception
    {
        FILENAME = Constants.FILENAME;
        FileOutputStream fos = null;
        DataOutputStream dos = null;        
        try {      
            fos = new FileOutputStream(FILENAME);
            dos = new DataOutputStream(fos);
            dos.writeBoolean(fm.k0);
            dos.writeBoolean(fm.k1);
            dos.writeDouble(fm.w0);
            dos.writeInt(fm.num_factor);
            dos.writeInt(fm.num_attribute);
            dos.writeInt(task.ordinal());
            dos.writeDouble(max_target);
            dos.writeDouble(min_target);

            for(int i=0;i<fm.num_attribute;i++)
            {
                dos.writeDouble(fm.w[i]);
            }

            for(int i=0;i<fm.num_factor;i++)
            {
                dos.writeDouble(fm.m_sum[i]);
            }

            for(int i=0;i<fm.num_factor;i++)
            {
                dos.writeDouble(fm.m_sum_sqr[i]);
            }

            for(int i_1 = 0; i_1 < fm.num_factor; i_1++) {
                for(int i_2 = 0; i_2 < fm.num_attribute; i_2++) {                    
                    dos.writeDouble(fm.v.get(i_1,i_2));
                }
            }

            dos.flush();
        }
        catch(Exception e) {
            throw new JlibfmRuntimeException(e);
        } finally {          
             if(dos!=null)
                dos.close();
             if(fos!=null)
                fos.close();
        }
    }

然后,该方法将把训练模型的所有元数据(包括维度、排名、权重和属性信息)保存到磁盘。

问题 2 的答案:为此,您可以通过提供输入模型文件名来调用restoreModel()方法:

public void restoreModel(String FILENAME) throws Exception
    {
        FILENAME = Constants.FILENAME;
        InputStream is = null;
        DataInputStream dis = null;        
        try {      
            is = new FileInputStream(FILENAME);          
            dis = new DataInputStream(is);

            fm.k0 = dis.readBoolean();
            fm.k1 = dis.readBoolean();
            fm.w0 = dis.readDouble();
            fm.num_factor = dis.readInt();
            fm.num_attribute = dis.readInt();

            if(dis.readInt() == 0)
            {
               task = TaskType.TASK_REGRESSION;
            }
            else
            {
               task = TaskType.TASK_CLASSIFICATION;
            }

            max_target = dis.readDouble();
            min_target = dis.readDouble();

            fm.w = new double[fm.num_attribute];

            for(int i=0;i<fm.num_attribute;i++)
            {
                fm.w[i] = dis.readDouble();
            }

            fm.m_sum = new double[fm.num_factor];
            fm.m_sum_sqr = new double[fm.num_factor];

            for(int i=0;i<fm.num_factor;i++)
            {
               fm.m_sum[i] = dis.readDouble();
            }

            for(int i=0;i<fm.num_factor;i++)
            {
                fm.m_sum_sqr[i] = dis.readDouble();
            }

            fm.v = new DataPointMatrix(fm.num_factor, fm.num_attribute);

            for(int i_1 = 0; i_1 < fm.num_factor; i_1++) {
                for(int i_2 = 0; i_2 < fm.num_attribute; i_2++) {        
                    fm.v.set(i_1,i_2, dis.readDouble());
                }
            }

        }
        catch(Exception e) {
            throw new JlibfmRuntimeException(e);
        } finally {          
             if(dis!=null)
                dis.close();
             if(is!=null)
                is.close();
        }
    }

该方法的调用将恢复保存的模型,包括从磁盘加载的训练模型的所有元数据(例如,维度、排名、权重和属性信息)。

问题 3 的答案:是的,当然可以。这种算法对于非常稀疏的数据集也非常有效。您只需要确保预测标签为整数,并且任务类型是分类,即task == TaskType.TASK_CLASSIFICATION

问题 4 的答案:有几个使用 FM 方法的应用场景。例如:

问题 5 的回答:是的,你可以从隐式反馈中提取(如评论、事件、交易等),因为将评分预测结果转换为 Top-N 列表是一个简单的工作。然而,我认为目前没有开源实现可用,但你当然可以通过大幅修改 LibFM 来尝试使用成对排序。

第十一章:讨论、当前趋势和展望

深度神经网络作为深度学习DL)的核心,使得由多个处理层组成的计算模型能够学习数据的多层次抽象表示。这些方法极大地提升了语音识别、多媒体(图像/音频/视频)分析、自然语言处理、图像处理与分割、视觉物体识别、物体检测以及许多生命科学领域(如癌症基因组学、药物发现、个性化医学和生物医学影像)的前沿技术。

在整本书中,我们展示了如何使用基于 JVM 的深度学习库来开发涵盖这些领域的应用程序。我承认,某些项目并不那么全面,无法商业化部署,但需要一些额外的努力。尽管如此,展示如何部署这些模型并不在本书的范围之内。然而,至少这些项目为我们提供了一些核心的见解。

现在,我们已经结束了这段关于使用不同 Java 库进行深度学习的小旅程,是时候总结一切了。但在此之前,在本章中,我们将讨论已完成的项目和一些抽象的收获。接着,我们会提出一些改进建议。此外,我们还将涵盖其他实际深度学习项目的扩展指南。总的来说,本章将覆盖以下内容:

  • 对项目的讨论、展望、未来改进与扩展

  • 当前监督学习与无监督学习深度学习算法的趋势

  • 常见问题解答(FAQ)

讨论与展望

在整本书中,我们涵盖了 10 个端到端的项目。从深度学习的介绍开始,到基于因式分解机的电影推荐系统项目结束。在这一部分,我们将简要回顾这些项目,讨论潜在的局限性,并提供一些未来的改进和扩展方向。

对已完成项目的讨论

在此过程中,我们尽力涵盖了多个来自不同领域的实际项目,如医疗保健、自然语言处理中的情感分析、迁移学习、图像和视频分类、分布式深度学习与训练、强化学习、在线交易以及视频中的实际物体检测。具体如下:

  • 使用 MLP 和 LSTM 网络进行泰坦尼克号生还预测

  • 使用递归网络进行癌症类型预测

  • 使用卷积神经网络进行多标签图像分类

  • 使用 Word2Vec 和 LSTM 网络进行情感分析

  • 使用迁移学习进行图像分类

  • 使用 YOLO、JavaCV 和 DL4J 进行实时物体检测

  • 使用 LSTM 网络进行股票价格预测

  • 使用卷积 LSTM 进行视频分类的分布式深度学习

  • 使用深度强化学习进行 GridWorld 游戏

  • 使用因式分解机开发电影推荐系统

现在我们将讨论改进这些项目以进行可能扩展的优缺点和未来方向。

泰坦尼克号生存预测使用 MLP 和 LSTM 网络

在这个项目中,我们的主要目标是熟悉 Apache Spark ML 库,然后基本介绍机器学习、深度学习它们的类型、架构和框架。

我们无法获得更高的准确性。然后,在第二章,使用递归类型网络进行癌症类型预测,我们重新审视了同一个项目,但是使用了一个强大的递归 LSTM 网络,显示了更高的准确性。要点是学习如何通过考虑大多数特征来准备数据集,并将其馈送到基于 Spark 和 DL4J 的 MLP 分类器中。

此外,这个数据集并不是很高维,所以应用深度学习方法并不是一个好主意。因此,我建议使用其他树集成方法,如随机森林和梯度提升树进行建模和部署。

癌症类型预测使用递归类型网络

我们解决了一个有趣的项目,在这个项目中,我们成功地根据癌症类型对癌症患者进行了分类。为此,我们使用了 LSTM 网络。我们使用了一个非常高维的基因表达数据集。我们将数据集转换为序列格式,并为每个样本的每个时间步训练了 LSTM 网络。

这个项目还展示了深度结构如 LSTM 的稳健性,证明即使没有应用降维,该模型也可以处理非常高维的数据集。

此方法的潜在限制之一是我们仅考虑了基因表达数据集,因此无法用于现实生活的预后和诊断,而其他数据集如拷贝数变异CNV)、DNA 甲基化和与生存相关的临床结果必须考虑在内。尽管如此,需要来自生物医学工程师和医生的领域专业知识,以提出一个集成解决方案。

最后,要点是至少显示了如何处理至少一种癌症基因组数据集。因此,同样的技术也可以应用于其他数据类型。然后,在部署之前,必须通过获取来自领域专家的输入,例如 AI 专家系统,来开发一个多模态网络。

使用卷积神经网络进行图像分类

在这个项目中,我们看到了如何解决多标签图像分类问题。我们使用了真实的 Yelp 图像。然后,我们训练了一个 CNN 来预测每个标记图像的类别。在这个项目中,最具挑战性的部分是特征工程,因为我们不仅要处理图像,还要处理不同的标签和元数据。不幸的是,我们无法达到非常高的准确性。

关键收获是,类似的方法可以应用于解决具有多标签的其他图像数据集。然而,一个多分类问题也可以以最小的努力解决。你所需要做的就是准备数据集,使得基于 CNN 的模型能够处理它。除了这个前景外,第五章中的项目《使用迁移学习进行图像分类》可以扩展到解决类似问题。

使用 Word2Vec 和 LSTM 网络进行情感分析

在这个项目中,我们展示了如何使用 Word2Vec 和 LSTM 开发情感分析应用程序。我们还讨论了如何将非结构化文本转换为神经词嵌入,并进一步将其转换为训练 LSTM 网络所需的序列形式。接着,我们使用每个时间步长对应文本的序列训练 LSTM。

本项目还解决了一个二分类问题,并取得了非常高的准确性。同样,这个应用可以扩展到分类其他问题,例如垃圾邮件与非垃圾邮件分类、电影或产品评论分类。最后,在常见问题解答(FAQ)部分,我们讨论了如何使用 CNN 解决相同的问题,这同样可以达到与 LSTM 类似的准确性。

使用迁移学习进行图像分类

在本章中,我们使用迁移学习技术解决了一个有趣的猫与狗分类问题。我们使用了一个预训练的 VGG16 模型及其权重,随后用 Kaggle 提供的实际猫狗数据集对训练进行了微调。

一旦训练完成,我们保存了训练好的模型以实现模型持久化并进行后续重用。我们看到,训练过的模型能够成功地检测并区分猫和狗的图像,无论它们的大小、质量和形状差异多大。

训练好的模型/分类器可以用来解决现实生活中的猫与狗问题。最后,收获是,这种类似的技术只需最小的努力,就可以扩展并用于解决类似的图像分类问题;这适用于二分类和多分类问题。

使用 YOLO、JavaCV 和 DL4J 进行实时物体检测

在这个项目中,我们再次使用了迁移学习技术来解决另一个有趣的问题,即从视频片段中进行实时物体检测。我们使用了预训练的 YOLOv2 模型、JavaCV 和 DL4J 库来解决这个问题。

如本章所述,我们将图像识别理念扩展以解决这个问题。也就是说,我们的技术将视频帧作为图像生成,然后使用边界框方法从帧中识别物体。要点是,尽管我们使用了一个视频片段来展示评估,但它仍然显示出非常高的准确性。从提供的演示中,任何人都可以观察到视频中的大多数物体都被准确识别。因此,类似的技术可以扩展到实时物体检测。

在这方面,我们看到了一些收集实时视频的技巧,可以通过网络摄像头、视频摄像机(甚至是手机)收集视频,并通过 JavaCV 库将它们输入到我们的 YOLOv2 模型中。

使用 LSTM 网络进行股票价格预测

在这个项目中,我们展示了如何开发一个预测股票价格的示范项目,预测五个类别的股票价格:开盘价(OPEN)、收盘价(CLOSE)、最低价(LOW)、最高价(HIGH)和交易量(VOLUME)。然而,这个结果也缺乏实际的信号;你的网络所做的只是生成一个类似于上一输入价格的值。

如果我们将你的预测结果作为下一个预测的输入,我们发现结果非常糟糕。我知道这种方法有一些严重的缺点。不过,我们没有使用足够的数据,这可能限制了这种模型的表现。

了解了这个项目的缺点后,最大的收获是将比特币或其他加密货币的价格预测扩展到更广泛的范围。正如在 FAQ 部分所建议的,可以从 Kaggle 下载历史比特币数据。然后,可以使用与本项目类似的特征工程来准备序列数据集。不过,也可以使用基于 CNN 的方法。

分布式深度学习——使用卷积-LSTM 网络进行视频分类

在这个项目中,我们开发了一个完整的深度学习应用程序,用于分类来自 UCF101 数据集的大量视频数据集。我们应用了结合 CNN 和 LSTM 的网络,利用deeplearning4jDL4J),克服了单独使用 CNN 或 RNN 长短期记忆LSTM)网络的局限性。

最后,我们展示了如何在 AWS EC2 AMI 9.0 实例上跨多个设备(包括 CPU 和 GPU)进行并行和分布式训练。我们在一个p2.8xlarge实例上进行并行和分布式训练,该实例配备了 8 个 GPU、32 个计算核心和 488 GB 的 RAM。

本章的一个重要收获是,这个端到端项目可以作为从视频中进行人类活动识别的入门项目。其次,我们没有取得高准确度,因为我们没有使用所有可用的视频片段来训练模型。

因此,使用完整的视频数据集对网络进行训练,并进行超参数调优,肯定能够提高准确性。在这种情况下,部署改进后的模型是商业上可行的。最后,如果你想让训练更快,可以配置一个 Hadoop 集群,将训练分布到 GPU 和 Spark 上。

使用深度强化学习进行 GridWorld 训练

在这个项目中,我们展示了如何使用 DL4J 和神经网络 Q 学习(Neural QLearning)开发一个示范性的 GridWorld 游戏,Q 函数在其中起到了重要作用。我们还提供了一些基本的理论背景,帮助开发 DQN 来玩 GridWorld 游戏。然而,我们没有开发一个模块来可视化智能体在整个过程中所做的动作。我承认,这是这个项目的最大缺点。不过,我在 FAQ 部分讨论了一些改进的建议。

从这个项目中得到的启示是,将这个应用扩展为可视化模型,或者甚至开发其他基于强化学习的游戏,比如《毁灭战士》和 ALE,将是一个不错的想法。其次,最后,我们也可以考虑开发另一个有趣的在线交易强化学习项目。

使用因式分解机的电影推荐系统

在这个项目中,我们展示了如何使用因式分解机(FMs)开发电影推荐系统。因式分解机是一组算法,通过引入矩阵分解算法中缺失的二阶特征交互,以监督方式增强线性模型的性能。

然而,在深入使用基于 RankSys 库的因式分解机(FMs)实现项目之前,我们了解了一些推荐系统的理论背景,包括矩阵分解和协同过滤。这个项目不仅涵盖了单个用户的电影评分预测,还讨论了排名预测。因此,我们还使用了因式分解机来预测电影的排名。

然而,这个库的潜在限制是,它不具备良好的可扩展性和结构性。我的建议是尝试基于 Python 的因式分解机库,这样会更好。最后,最大的收获是将这个应用扩展到使用 Python-based FM 库,从 MovieLens 或 IMDb 等更大的电影数据集,这一点是推荐的。

当前趋势与展望

作为一名研究人员,我在多个会议中担任程序委员会PC)成员,如 WWW'2018、ISWC'2018、ESWC'2017/2018 和 ESWC SemDeep'2018 国际研讨会。除此之外,我还担任《国际语义网期刊》、《云计算期刊》和《生物信息学简报》的客座编辑。

在审阅了许多会议和期刊的论文后,我发现研究人员并没有局限于使用原始的 RNN、CNN、DBN 或自编码器来开发新兴的应用案例和分析解决方案。他们通过将这些架构组合起来,提出了跨领域的创新思路。

当前趋势

正如在第一章中讨论的,深度学习入门,研究人员最近提出了许多新兴的深度学习架构。这些架构不仅包括改进 CNN/RNN 及其变种,还包括一些其他特殊类型的架构:深度时空神经网络DST-NNs)、多维循环神经网络MD-RNNs)、卷积自编码器CAEs)、深度嵌入聚类等。

然而,仍然有一些新兴的网络,如 CapsNets,这是 Hinton 等人提出的 CNN 改进版,旨在消除常规 CNN 的缺点。接下来是用于图像识别的残差神经网络和用于简单图像生成的生成对抗网络GANs)。

除了这些趋势和应用场景,深度学习的不同新兴架构正在被应用于多媒体分析、计算机视觉(特别是语义图像分割)、物联网、图像和网络流量中的异常检测、自然语言处理中的神经机器翻译以及知识图谱与神经网络的集成。

新兴深度学习架构的前景

在本小节中,我们将讨论一些新兴架构及其变体,重点介绍一些应用场景。

残差神经网络

由于与它们相关的数百万亿的超参数以及其他实际问题,训练深度神经网络变得非常困难。为了解决这个问题,何凯明等人(详见 arxiv.org/abs/1512.03385v1)提出了残差学习框架RNN),以简化比以往更深层的网络的训练。现在,根据原始论文:

“在这个网络设置中,我们不是期望每一层堆叠直接拟合一个期望的底层映射,而是明确地让这些层拟合一个残差映射。原始映射被重新表述为 F(x)+x。我们假设,优化残差映射比优化原始的、未引用的映射更容易。极端情况下,如果恒等映射是最优的,那么将残差推向零比通过堆叠非线性层拟合恒等映射要容易得多。”

通过这种方式,RNN 比其他深度神经网络架构更容易优化,并且可以从显著增加的深度中获得更高的准确性。缺点是,通过简单地堆叠残差块来构建网络,难免会限制其优化能力。为了解决这个问题,张科等人还提出了使用多层残差网络(详见 arxiv.org/abs/1608.02908)。

因此,残差网络被应用于解决许多新兴的应用场景,包括:

  • 基于骨架的动作识别,结合空间推理和时间堆栈学习(更多信息见 arxiv.org/pdf/1805.02335)。

  • 最近,袁等人提出了基于空间-光谱深度残差卷积神经网络的高光谱图像去噪(详见 arxiv.org/pdf/1806.00183)。

  • 使用改进的 DRN 进行交通流量预测的动态模型(更多信息见 arxiv.org/pdf/1805.00868

  • 使用宽残差网络分类模拟的无线电信号,以用于寻找外星智能(更多信息见 arxiv.org/pdf/1803.08624

生成对抗网络(GANs)

GAN 是由两网络相互对抗组成的深度神经网络架构(因此得名“对抗”)。Ian Goodfellow 等人首次在论文中介绍了 GAN(详细内容请见arxiv.org/abs/1406.2661v1)。GAN 是 AI 领域最好的研究成果之一,它可以学习模仿任何数据分布。训练好的 GAN 可以用于创建与我们世界相似的虚拟世界,特别是图像、音乐、语音或散文等方面。

尽管原始的 GAN 论文主要针对简单的图像生成(如 DCGAN、BEGAN 等),但人们正在将这一理念扩展到字体生成、动漫角色生成、互动图像生成、文本到图像生成、2D 物体生成、人体姿态估计等领域。以下是一些具体的研究导向应用案例:

胶囊网络(CapsNet)

如第一章所述,深入学习入门,CNN 在分类优质图像方面表现良好。然而,如果图像有旋转、倾斜或其他不同的方向,CNN 的表现会非常差。即使是 CNN 中的池化操作,在位置不变性方面也帮助有限。

为了克服 CNN 的局限性,Geoffrey Hinton 等人提出了一种突破性思路,称为胶囊网络(CapsNet),它特别擅长处理不同类型的视觉刺激,并能编码诸如姿势(位置、大小和方向)、形变、速度、反射率、色调、纹理等信息。

在常规的 DNN 中,我们不断增加层数(更多的层意味着更深的网络)。而在 CapsNet 中,思路是将更多的层添加到单一层内。通过这种方式,CapsNet 是一个嵌套的神经网络层集合。在 CapsNet 中,最大池化层的局限性被克服,并用基于协议的路由(RBA)替代,以捕获低层次的视觉信息。

不幸的是,原始论文过于理论化。因此,研究人员正试图将 CapsNet 的理念扩展到不同的 AI 和数据科学项目中,包括图像分类、GAN 改进以及改善基于强化学习的游戏体验。以下是一些示例应用案例:

除了这些新兴的架构外,研究人员还尝试使用 GAN 架构通过 CapsNet 进行图像合成(见arxiv.org/pdf/1806.03796)。此外,研究人员还使用了对抗性自编码器进行基于语音的情感识别(见arxiv.org/pdf/1806.02146)。最后,我建议读者通过arxiv.org/list/cs.AI/recent了解人工智能、机器学习和深度学习的最新趋势。

语义图像分割

图像分割是将图像划分为若干个连贯部分的方法,但并不尝试理解这些部分代表什么。语义分割则试图将图像划分为语义上有意义的部分,并将每个部分分类到预定的类别中。

这种将原始脑部图像分割成灰质、白质和脑脊液的语义分割有助于根据分割区域对其进行分类。基于深度学习的技术,如堆叠去噪自编码器SDAE),已被成功应用。

然而,基于循环的全卷积网络正在被用于高分辨率遥感图像的语义分割(详情见arxiv.org/pdf/1805.02091)。这类图像分割技术已被应用于新兴领域,如盆腔磁共振成像分析、自动驾驶汽车的物体检测、地理空间图像分类(见www.semantic-web-journal.net/system/files/swj1862.pdf)等。

深度学习在聚类分析中的应用

聚类分析是最广泛使用的数据驱动任务之一。迄今为止,现有的聚类分析技术采用了经典的聚类算法,如 k-means、二分 k-means 或高斯混合模型。特别是,k-means 聚类算法及其多个变体已被提出,以解决高维输入空间中的问题。

然而,它们本质上限于线性嵌入。因此,它们无法建模非线性关系。然而,这些方法中的微调仅基于聚类分配硬化损失。因此,无法实现细粒度的聚类准确性。

简而言之,针对基于深度学习的表示学习和聚类分析的研究相对较少。然而,k-means 的质量依赖于数据分布。深度架构可以帮助模型从数据空间学习到映射到低维特征空间,在该空间中,它反复优化聚类目标。

考虑到这些限制和动机,研究人员提出了基于深度学习的聚类技术,用于聚类非常高维的数据和非线性对象。在这些方法中,k-means 与深度架构相结合,其中同时优化聚类分配硬化损失(来自 k-means)和重构损失(来自 DNN)。这些方法包括:

常见问题(FAQ)

我们已经分析了完成的项目并观察了近期趋势。基于这些,可能会有一些问题出现在你脑海中。在这一节中,我将尝试设计一些这样的问答,并提供示例答案:

  1. 在本章中,我们认为,利用 GAN,我们可以解决许多研究问题。在 DL4J 中有 GAN 的实现吗?

  2. 在本章中,我们认为,使用 CapsNet 处理具有不同形状和方向的图像是一个更好的选择。在 DL4J 中有 CapsNet 的实现吗?

  3. 在第一章,深度学习入门中,我们讨论了 DBN 和限制玻尔兹曼机作为其基本构建块。然而,在任何已完成的项目中,我们都没有使用 DBN。这是什么原因呢?

  4. 在本章中,我们认为,利用来自物联网传感器数据或图像的无监督异常检测是一个新兴的研究应用场景。在 DL4J 中有这方面的例子吗?

  5. 在 DL4J 中,有没有开发推荐引擎的例子?

  6. 考虑到如今智能手机非常强大,我们可以在智能手机上开发图像/物体检测应用吗?

  7. 如何将深度学习应用打包成一个网页应用?

  8. 在运行项目时遇到了一些问题。此外,我在配置开发环境时也遇到困难(例如,在 Eclipse/IntelliJ IDEA 上配置 CUDA/CuDNN)。我该怎么办?

问题答案

问题 1 的答案:这方面存在一个未解决的问题。感兴趣的读者可以查看github.com/deeplearning4j/deeplearning4j/issues/1737来了解当前的更新情况。然而,讨论的热度不是很高。

问题 2 的答案:据我所知,DL4J 中没有 CapsNet 的实现。而且,我没有看到这个话题有任何公开的讨论或问题。我在 DL4J Gitter 频道提问了,但没有人回复。

问题 3 的答案:无监督预训练和有监督微调都可以使用 DBN 来进行。这意味着,如果我们没有足够的标记数据,但仍然希望进行基于神经网络的训练,这种概率网络是一个明智的选择。

问题 4 的答案:是的,使用变分自编码器和重建概率进行异常检测的示例已经有了,适用于 MNIST 数据。可以查看 DL4J 示例:github.com/deeplearning4j/dl4j-examples/tree/master/dl4j-examples/src/main/java/org/deeplearning4j/examples/unsupervised/anomalydetection。不过,这个示例也可以扩展到其他数据集。

问题 5 的答案:这个链接中有一个推荐引擎的实例,适用于 Well-Dressed 推荐:deeplearning4j.org/welldressed-recommendation-engine

问题 6 的答案:深度学习和神经网络也可以部署在 Android 设备上。更多信息,请参考deeplearning4j.org/android

问题 7 的答案:一旦神经网络训练完成,网络就可以用于推理,或者说是对它看到的数据进行预测。推理过程通常计算量较小。然后,可以使用 Spring Boot 或其他框架将应用打包成一个网页应用。可以参考一些指南:deeplearning4j.org/build_vgg_webapp

问题 8 的答案:你应该按照我在章节中提供的指示操作。此外,本书的代码可以在 GitHub 上找到,你可以自由提交 PR 或创建新的问题,我会尽快修复它们。关于任何新的问题,你可以通过 DL4J Gitter 实时频道提出:gitter.im/deeplearning4j/deeplearning4j

posted @ 2025-07-13 15:43  绝不原创的飞龙  阅读(97)  评论(0)    收藏  举报