Java 深度学习项目(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

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

在上一章中,我们开发了一个基于 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 在处理图像时具有决定性的优势:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/f6a9a8ed-d7aa-4f6e-9d9e-854ca4fd78b9.png

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

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

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

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

CNN 架构

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

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/50345908-1f46-4a96-801b-e62f4648cf8b.png

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

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/f436e2e8-09eb-4a3b-8910-1f3389993208.png

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

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

卷积操作

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/48f7701f-6398-490e-ba5d-65ac689f0d22.png

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

在 第二章《使用递归类型网络进行癌症类型预测》中,我们已经看到所有的多层神经网络(例如,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 填充表示没有填充,只丢弃最右侧的列(或最底部的行):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/b37960ea-1ba3-4df2-9d86-9017f1424776.png

SAMEVALID 填充在 CNN 中的比较

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/ed02b9c1-62ad-479e-8ec0-a2fd75b91cb7.png

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

全连接层(密集层)

在网络的最上层,添加了一个常规的全连接层(前馈神经网络或密集层),它的作用类似于一个可能由几个全连接层(+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 通过向本地商家出售广告赚钱。

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/954a1298-4086-4644-9a69-ae3a20bf1ebb.png

从 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 文件夹内)。简而言之,经过提取和复制后,我们项目中使用的文件夹结构如下。因此,最终的结构将如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/84b1fa36-fa32-4d88-9743-09c6f5da2f10.png

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

整体项目的工作流程

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

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/04a01dab-a68c-4ec0-83ba-c393c91dc1ea.png

卷积神经网络(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 文件中

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/6ee535a5-af3c-4880-a53f-6d5477b45aff.png

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

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

图像预处理

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/b9b618f0-ad81-48e5-8a5e-b75997f17044.png

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

我们已经看到,卷积神经网络(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。让我们看看调整大小后的图像是什么样子的(原始图像):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/13daa415-e364-4988-b0c9-35c650fd0b93.jpg

图像调整大小(分别为 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来查看演示)。让我们看看转换后的图像会是什么样子:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/9e7e7c72-9b24-4331-8382-6dc595c1d4d6.png

左侧是原始图像,右侧是经过 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();

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

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/fee1cc5f-9747-4161-837c-20e182d2f710.png

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

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

//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、标签和像素数据:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/1f48580d-b6fc-44aa-ba0a-f40ea4946ae7.png

图像数据表示

因此,如前面的图示所示,主数据结构是由四种数据类型(即四个元组)构成——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 图像。最后,我们仍然可以从以下图表中看到模型与迭代得分及其他参数,因此我们可以看到我们的模型并没有过拟合:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/36122f86-763f-4da4-8628-de1c9a84a1c2.png

模型与迭代得分及 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”一词,并且与金融、金钱、国库和利率等词语一同出现,我们可以理解它的含义是前者。然而,如果它周围的词语是水、海岸、河流、湖泊等,那么它指的就是后者。那么,问题来了:我们是否可以利用这一概念来处理歧义和同义词,并使我们的模型学习得更好?

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/f620eb24-b4e5-4a8d-9a95-ef5685415cd2.png

经典机器学习与基于深度学习的自然语言处理(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 根据当前单词预测周围的单词:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/bba3319f-913c-4a4d-af80-de53f74b9cfd.png

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 分的星级评分。查看以下图示可以更清楚地了解目录结构:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/3c758053-b424-4acf-b7d1-76fa4dcc7225.png

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

例如,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%。现在,如果我们查看模型与迭代分数以及以下图表中的其他参数,我们可以看到我们的模型没有过拟合:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/45849f1d-1af8-415e-ba55-2557cb8acf40.png

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:尝试微调整个网络,甚至大多数层,可能会导致过拟合。因此,需要额外的努力,使用反向传播在新任务上微调预训练的权重。

  • 重用带检查点的预训练模型:第三种广泛使用的场景是下载互联网上公开的检查点。如果你没有足够的计算能力从头训练模型,你可以选择这个场景,只需使用已发布的检查点初始化模型,然后进行少量微调。

在这一点上,你可能会产生一个有趣的问题:传统的机器学习和使用迁移学习的机器学习有什么区别?嗯,在传统的机器学习中,你不会将任何知识或表示转移到其他任务中,而迁移学习则不同。

与传统机器学习不同,源任务和目标任务或领域不必来自相同的分布,但它们必须是相似的。此外,你可以在训练样本较少或没有足够计算能力的情况下使用迁移学习。

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/792d1f27-123a-4af5-b400-7bd0cb749722.png

传统机器学习与迁移学习

DL4J 和迁移学习

现在,让我们看看 DL4J 是如何通过其迁移学习 API 为我们提供这些功能的。DL4J 的迁移学习 API 使用户能够(更多信息请见deeplearning4j.org/transfer-learning):

  • 修改现有模型的架构

  • 微调现有模型的学习配置

  • 在训练过程中保持指定层的参数(也叫冻结层)不变

这些功能在下图中有所体现,我们通过迁移学习技术解决任务 B(与任务 A 相似):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/74b69371-b7bf-47e7-8f0a-9ec9bde897ac.png

迁移学习的工作原理

在下一节中,我们将深入探讨如何使用 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 张图像中随机选取的一些样本:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/56ec1ba5-7445-4ac4-82ae-dc4d575a03ae.png

显示随机选择的图像的真实标签

对于测试集中的每一张图像,我们必须预测该图像是否包含一只狗(1 = 狗,0 = 猫)。简而言之,这是一个二分类问题。

架构选择与采纳

如前所述,我们将重用 VGG-16 的预训练模型,该模型已使用来自 ImageNet 的不同猫狗品种图像进行了训练(请参阅这里的列表)。原始的 VGG-16 模型有 1,000 个要预测的图像类别,如下图所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/8de28bf5-65da-4908-9b80-d3edef23304d.png

原始 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());

现在,让我们来看一下网络架构,包括进出神经元的数量、参数形状和参数数量:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/913bd9a8-7cd0-4b01-8494-d4f46594a1ea.png

VGG-16 模型架构作为计算图

现在我们已经有了预训练的模型,利用它,我们可以预测最多 1,000 个类别。而可训练的参数数量等于总参数数量:1.38 亿。训练这么多参数是件很困难的事。

然而,由于我们只需要预测两个类别,因此我们需要稍微修改模型架构,使其仅输出两个类别,而不是 1,000 个。所以我们保持其他部分不变。修改后的 VGG-16 网络将如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/7a85deba-fab7-4f1c-91ec-adacd11c161a.png

从输入层到最后一个全连接层(即 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());

以下截图展示了前一个代码片段的输出:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/55a7cae8-7caf-4ffe-9693-5f9134fd91f5.png

冻结网络仅有 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类来加载用户界面,界面如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/dd775f02-53c6-4827-b0b2-5b3bdc1593f6.png

猫狗识别器的 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!

现在,让我们上传一些来自测试集的照片(这更有意义,因为我们正在重新使用训练好的模型,而该模型只训练了训练集,因此测试集中的图片仍然是未见过的):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/78c5ac06-bae8-46c2-85f3-8f7c5afb8c4b.png

我们的猫狗识别器能够识别具有不同形状和颜色的狗的图片

因此,我们训练好的模型能够识别不同形状、尺寸和颜色的狗的图片。现在,让我们尝试上传几张猫的图片,看看它是否能正常工作:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/399ca5c8-189f-4585-b8be-d13a23744b0f.png

我们的猫狗识别器能够识别具有不同形状和颜色的猫的图片

常见问题解答(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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值