原文:
annas-archive.org/md5/d89c1af559e5f85f6b80756c31400c3f
译者:飞龙
第六章:使用 YOLO、JavaCV 和 DL4J 进行实时物体检测
Deep Convolutional Neural Networks (DCNN) have been used in computer vision—for example, image classification, image feature extraction, object detection, and semantic segmentation. Despite such successes of state-of-the-art approaches for object detection from still images, detecting objects in a video is not an easy job.
考虑到这一缺点,在本章中,我们将开发一个端到端的项目,该项目将在视频片段连续播放时从视频帧中检测物体。我们将利用训练好的 YOLO 模型进行迁移学习,并在Deeplearning4j(DL4J)的基础上使用 JavaCV 技术来实现。简而言之,以下主题将贯穿整个端到端的项目:
-
物体检测
-
视频中物体检测的挑战
-
使用 YOLO 与 DL4J
-
常见问题解答(FAQ)
图像与视频中的物体检测
深度学习已广泛应用于各种计算机视觉任务,如图像分类、物体检测、语义分割和人体姿态估计。当我们打算解决图像中的物体检测问题时,整个过程从物体分类开始。接着我们进行物体定位,最后进行物体检测。
本项目深受 Klevis Ramo 的《Java 自动驾驶——汽车检测》一文的启发(ramok.tech/
)。同时,部分理论概念(但在此需求下已显著扩展)已获得作者的授权。
物体分类、定位与检测
在物体分类问题中,给定一张图像(或视频片段),我们关心的是它是否包含感兴趣的区域(ROI)或物体。更正式地说,就是“图像包含一辆车”与“图像不包含任何车”。为了解决这个问题,在过去的几年中,ImageNet 和 PASCAL VOC(详见host.robots.ox.ac.uk/pascal/VOC/
)被广泛使用,并且基于深度卷积神经网络(CNN)架构。
此外,当然,最新的技术进展(即软硬件的进步)也推动了性能提升,达到了新的水平。尽管如此,尽管最先进的方法在静态图像中的成功,视频中的物体检测依然不容易。然而,从视频中进行物体检测引出了许多新的问题、可能性和挑战,如何有效且稳健地解决视频中的物体检测问题。
解答这个问题并不容易。首先,让我们一步步地尝试解决这个问题。首先,让我们先尝试解决静态图像的情况。好吧,当我们想要使用 CNN 来预测图像是否包含特定物体时,我们需要在图像中定位物体的位置。为此,我们需要指定物体在图像中的位置,并与分类任务一起完成。这通常通过用矩形框标记物体来完成,矩形框通常被称为边界框(bounding box)。
现在,边界框的概念是,在视频的每一帧中,算法需要对每个类别的物体标注边界框和置信度分数。为了清楚地理解这一点,我们来看一下什么是边界框。边界框通常由中心 (b^x, b^y)、矩形高度 (b^h) 和矩形宽度 (b^w) 来表示,如下图所示:
边界框表示
现在我们知道如何表示这样的边界框,我们可以理解在我们的训练数据中定义这些信息所需的内容,针对每张图像中的每个物体。只有这样,网络才能输出以下内容:
-
图像类别的概率(例如,20% 的概率是汽车,60% 的概率是公交车,10% 的概率是卡车,或 10% 的概率是火车)
-
此外,定义物体边界框的四个变量
仅知道这些信息还不够。有趣的是,凭借关于边界框点的最小上下文信息(即中心、宽度和高度),我们的模型仍然能够进行预测,并为我们提供更详细的内容视图。换句话说,采用这种方法,我们可以解决物体定位问题,但它仍然仅适用于单一物体。
因此,我们甚至可以进一步推进,不仅仅定位单一物体,而是定位图像中的多个或所有物体,这将帮助我们向物体检测问题迈进。尽管原始图像的结构保持不变,但我们需要在单张图像中处理多个边界框。
现在,为了解决这个问题,一种最先进的技术是将图像划分为较小的矩形。我们已经看到的五个额外变量(P^c, b^x, b^y, b^h, b^w)以及每个边界框的正常预测概率,仍然适用。
这个想法听起来很简单,但它在实际中如何运作呢?如果我们只需要处理一个静态图像分类问题,事情会变得更简单。使用一种朴素方法,即从成千上万张汽车图像中裁剪出每一张汽车图像,然后训练一个卷积神经网络(例如,VGG-19)来使用所有这些图像训练模型(尽管每张图像的大小可能不同)。
典型的高速公路交通
现在,为了处理这种情况,我们可以使用滑动矩形窗口扫描图像,每次让我们的模型预测其中是否有汽车。正如我们所看到的,通过使用不同大小的矩形,我们可以为汽车及其位置推测出非常不同的形状。
尽管这种方法在检测汽车方面效果很好,但假设一个更实际的问题,比如开发自动驾驶应用。在典型的高速公路上,城市甚至郊区,会有许多汽车、公交车、卡车、摩托车、自行车和其他交通工具。此外,还会有行人、交通标志、桥梁、隔离带和路灯等其他物体。这些因素会使场景变得更加复杂。然而,实际图像的尺寸会与裁剪图像的尺寸差异很大(即,实际图像要大得多)。此外,在前方,许多汽车可能正在接近,因此需要手动调整大小、特征提取,然后进行手工训练。
另一个问题是训练算法的慢速,因此它不能用于实时视频目标检测。这个应用将被构建出来,以便大家可以学到一些有用的知识,从而将相同的知识扩展并应用到新兴的应用中,比如自动驾驶。
不管怎样,让我们回到最初的讨论。当矩形(向右、向左、向上和向下)移动时,许多共享的像素可能无法被重用,而是被反复重新计算。即使使用非常精确和不同大小的边界框,这种方法也无法非常准确地标出物体的边界框。
因此,模型可能无法非常准确地输出车辆的类别,因为框中可能只包含物体的一部分。这可能导致自动驾驶汽车容易发生事故——即,可能会与其他车辆或物体发生碰撞。为了摆脱这一限制,当前最先进的一个方法是使用卷积滑动窗口(CSW)解决方案,这在 YOLO 中被广泛使用(我们稍后会看到)。
卷积滑动窗口(CSW)
在前面的子章节中,我们看到基于朴素滑动窗口的方法存在严重的性能缺陷,因为这种方法无法重用已经计算出的许多值。
然而,当每个独立的窗口移动时,我们需要为所有像素执行数百万个超参数计算才能得到预测。实际上,通过引入卷积,大部分计算可以被重用(参见第五章,使用迁移学习的图像分类,了解更多关于使用预训练的深度卷积神经网络(DCNN)架构进行图像分类的迁移学习)。这一点可以通过两种增量方式实现:
-
通过将全连接 CNN 层转化为卷积
-
使用 CSW
我们已经看到,无论人们使用的是哪种 DCNN 架构(例如,DarkNet、VGG-16、AlexNet、ImageNet、ResNet 和 Inception),无论其大小和配置如何,最终它们都被用来喂入全连接神经网络,具有不同数量的层,并根据类别输出多个预测结果。
此外,这些深度架构通常有非常多的层,以至于很难很好地解释它们。因此,选择一个更小的网络听起来是一个合理的选择。在以下图示中,网络以一个 32 x 32 x 3 的彩色图像(即 RGB)作为输入。然后,它使用相同的卷积,这使得前两个维度(即宽度 x 高度)保持不变,仍为 3 x 3 x 64,以获得输出 32 x 32 x 64。通过这种方式,第三维度(即 64)与卷积矩阵保持一致。
然后,放置一个最大池化层来减少宽度和高度,但保持第三维度不变,仍为 16 x 16 x 64。之后,将减少后的层输入到一个密集层,该层有两个隐藏层,每个隐藏层包含 256 和 128 个神经元。最后,网络使用 Softmax 层输出五个类别的概率。
现在,让我们看看如何将全连接(FC)层替换为卷积层,同时保持输入的线性函数为 16 x 16 x 64,如下图所示:
在前面的图示中,我们只是将 FC 层替换为卷积滤波器。实际上,一个 16 x 16 x 256 的卷积滤波器相当于一个 16 x 16 x 64 x 256 的矩阵。在这种情况下,第三维度 64 总是与输入的第三维度 16 x 16 x 64 相同。因此,它可以通过省略 64 来表示为 16 x 16 x 256 的卷积滤波器,这实际上相当于对应的 FC 层。以下的数学公式可以解答这个问题:
输出: 1 x 1 x 256 = 输入: [16 x 16 x 64] * 卷积: [16 x 16 x 64 x 256]
上述数学公式意味着输出 1 x 1 x 256 的每个元素都是输入 16 x 16 x 64 中相应元素的线性函数。我们将 FC 层转换为卷积层的原因是,它将为我们提供更多的灵活性来生成网络的输出:对于 FC 层,我们将始终得到相同的输出大小,也就是类别的数量。
现在,为了看到将 FC 层替换为卷积滤波器的效果,我们需要使用一个更大的输入图像,比如 36 x 36 x 3。如果我们使用简单的滑动窗口技术,步长为 2 并且有 FC,我们需要将原始图像的大小移动九次才能覆盖所有区域,因此也需要执行九次模型。因此,采用这种方法显然没有意义。相反,让我们尝试将这个新的更大的矩阵作为输入,应用到我们只包含卷积层的新模型中。
现在,我们可以看到输出已经从 1 x 1 x 5 变成了 3 x 3 x 5,这与全连接(FC)进行对比时有所不同。再回想一下基于 CSW 的方法,我们必须将滑动窗口移动九次以覆盖所有图像,这巧妙地与卷积的 3 x 3 输出相等,不过,每个 3 x 3 的单元代表了一个 1 x 1 x 5 类别的滑动窗口的概率预测结果!因此,最终输出不再是仅有的一个 1 x 1 x 5 并经过 9 次滑动窗口移动,而是通过一次操作得到的 3 x 3 x 5。
现在,采用基于 CSW 的方法,我们已经能够解决图像中的物体检测问题。然而,这种方法并不十分准确,但仍然能在精度稍逊的情况下产生一个可接受的结果。不过,当涉及到实时视频时,情况变得更加复杂。我们将在本章稍后看到 YOLO 是如何解决剩余的局限性的。现在,先让我们试着理解从视频片段中检测物体的底层复杂性。
从视频中进行物体检测
在深入研究之前,让我们先思考一个简单的情境。假设我们有一个视频片段,包含一只猫或狼在森林中的移动。现在,我们希望在每个时间点上检测到这个移动中的动物。
下图展示了这种情境下的挑战。红色框是实际标注。图像上半部分(即a)显示静态图像物体检测方法在帧间存在较大的时间波动,甚至在实际标注的边界框上。波动可能是由运动模糊、视频失焦、部分遮挡或姿态问题引起的。相邻帧中同一物体的框的信息需要被利用,以便在视频中进行物体检测。
另一方面,(b)显示了跟踪能够关联同一物体的框。然而,由于遮挡、外观变化和姿态变化,跟踪框可能会漂移到非目标物体上。物体检测器应与跟踪算法结合,以便在漂移发生时持续开始新的跟踪。
从视频中进行物体检测的挑战(来源:Kai Kang 等,《基于卷积神经网络的视频管道物体检测》)
有一些方法可以解决这个问题。然而,大多数方法侧重于检测一种特定类别的物体,比如行人、汽车或有动作的人类。
幸运的是,类似于静态图像中的物体检测能够协助图像分类、定位和物体检测等任务,准确检测视频中的物体也可能提升视频分类的性能。通过定位视频中的物体,也可以更清晰地描述视频的语义含义,从而增强视频任务的鲁棒性。
换句话说,现有的通用目标检测方法无法有效解决这个问题。它们的性能可能会受到视频中物体外观变化较大的影响。例如,在上面的图(a)中,如果猫最初面对相机然后转身,背部的图像可能无法有效地被识别为猫,因为它包含的纹理信息很少,而且不太可能包含在训练数据中。然而,这是一个更简单的场景,我们只需要检测一个物体(即动物)。
当我们想为自动驾驶汽车开发应用程序时,我们将不得不处理许多物体和考虑因素。无论如何,由于我们无法在本章中涵盖所有方面,我们就以最基本的知识来解决这个问题。
此外,从零开始实现和训练这些类型的应用程序既耗时又具有挑战性。因此,如今,迁移学习技术正在成为流行且可行的选择。通过利用已经训练好的模型,我们可以更轻松地进行开发。一个这样的训练过的目标检测框架是 YOLO,它是最先进的实时目标检测系统之一。这些挑战和像 YOLO 这样的框架激励我以最小的努力开发这个项目。
只看一次(YOLO)
尽管我们已经通过引入卷积滑动窗口解决了静态图像中的目标检测问题,但即便使用了多个边界框尺寸,我们的模型可能仍然无法输出非常准确的边界框。让我们看看 YOLO 是如何很好地解决这个问题的:
使用边界框规格,我们查看每张图像并标记我们想要检测的目标
我们需要以特定的方式标记训练数据,以便 YOLO 算法能够正确工作。YOLO V2 格式要求边界框的尺寸为bx*、*by、bh*、*bw,这些尺寸必须相对于原始图像的宽度和高度。
首先,我们通常会查看每一张图像,并标记我们想要检测的目标。之后,每张图像会被分割成更小的矩形(框),通常是 13 x 13 个矩形,但在这里为了简化,我们使用 8 x 9 个矩形。边界框(蓝色)和目标可以属于多个框(绿色),因此我们只将目标和边界框分配给包含目标中心的框(黄色框)。
通过这种方式,我们用四个附加变量(除了识别目标是汽车外)训练我们的模型(bx*、*by、b^h 和 bw*),并将这些变量分配给拥有目标中心的框*bx、b^y。由于神经网络是用这些标记过的数据训练的,它也会预测这四个变量(除了目标是什么)的值或边界框。
我们不再使用预定义的边界框大小进行扫描并尝试拟合物体,而是让模型学习如何用边界框标记物体。因此,边界框现在是灵活的。这无疑是一种更好的方法,边界框的准确性更高、更灵活。
让我们看看现在如何表示输出,考虑到我们在类别(例如 1-汽车,2-行人)旁边添加了四个变量(bx*,*by,b^h 和 b^w)。实际上,还添加了另一个变量 P^c,它简单地告诉我们图像是否包含我们想要检测的任何物体。
-
P^c =1(red): 这意味着至少有一个物体存在,所以值得关注概率和边界框。
-
P^c =0(red): 该图像没有我们想要的任何物体,因此我们不关心概率或边界框规格。
结果预测,b[w,] 和 b[h],是通过图像的高度和宽度进行归一化的。(训练标签是这样选择的。)因此,如果包含汽车的边界框预测 b[x] 和 b[y] 为 (0.3, 0.8),那么在 13 x 13 特征图上的实际宽度和高度为 (13 x 0.3, 13 x 0.8)。
YOLO 预测的边界框是相对于拥有物体中心的框(黄色)来定义的。框的左上角从 (0, 0) 开始,右下角是 (1, 1)。由于该点位于框内,因此在这种情况下,sigmoid 激活函数确保中心 (b[x] , b[y]) 的值在 0 到 1 之间,如下图所示:
Sigmoid 激活函数确保中心点 (b[x] , b[y]) 的值在 0 到 1 之间。
虽然 b[h],b[^w] 是按照框的 w 和 h 值(黄色)按比例计算的,值可以大于 1(对于正值使用指数)。从图中我们可以看到,边界框的宽度 b[w] 几乎是框宽度 w 的 1.8 倍。类似地,b[h] 约为框高度 h 的 1.6 倍,如下图所示:
现在,问题是物体包含在边界框中的概率是多少?要回答这个问题,我们需要知道物体得分,它表示物体被包含在边界框中的概率。对于红色及邻近的网格,它应该接近 1,而对于角落的网格来说,几乎是 0。以下公式描述了网络输出如何被转换以获取边界框预测:
在前面的公式中,b[x],b[y],b[w] 和 b[h] 分别是我们预测的 x、y 中心坐标、宽度和高度。另一方面,t[x],t[y],t[w] 和 t[h] 是网络输出的值。此外,c[x] 和 c[y] 是网格的左上坐标。最后,p[w] 和 p[h] 是框的锚点尺寸。
物体性得分也通过 sigmoid 函数进行处理,因为它需要被解释为概率。然后,使用类别置信度表示检测到的物体属于特定类别的概率。预测后,我们查看预测框与最初标记的真实边界框的交集程度。我们试图最大化它们之间的交集,因此理想情况下,预测的边界框与标记的边界框完全重叠。
简而言之,我们提供足够的带有边界框的标注数据(bx*,*by,bh*,*bw),然后将图像分割并分配给包含中心的框,使用 CSW 网络进行训练并预测物体及其位置。所以,首先我们进行分类,然后本地化物体并检测它。
到目前为止,我们已经能够克服大部分障碍,使用 YOLO 解决了问题。然而,实际上还有两个小问题需要解决。首先,尽管在训练时物体被分配到一个框中(即包含物体中心的框),但在推理时,训练好的模型会假设有多个框(即黄色框)包含物体的中心(即红色框)。因此,这种混淆为同一物体引入了额外的边界框。
幸运的是,非最大抑制算法可以解决这个问题:首先,算法选择一个具有最大P^c概率的预测框,这样它的值介于 0 和 1 之间,而不是二进制的 0 或 1 值。然后,移除与该框交集超过某个阈值的每个框。重复相同的逻辑,直到没有更多的边界框剩余。其次,由于我们预测多个物体(如汽车、火车、公共汽车等),两个或更多物体的中心可能位于一个框内。这个问题可以通过引入锚框来解决:
一个锚框规范
通过锚框,我们选择几种经常用于检测目标的边界框形状。然后,通过对输出应用对数空间变换,预测边界框的维度,再将其乘以锚框。
开发一个实时物体检测项目
在这一部分,我们将使用预训练的 YOLO 模型(即迁移学习)、DL4J 和 OpenCV 开发一个视频物体分类应用程序,可以检测视频帧中的标签,如汽车和树木。坦率地说,这个应用程序实际上是将图像检测问题扩展到视频检测。所以让我们开始吧。
步骤 1 - 加载一个预训练的 YOLO 模型
自 Alpha 版本 1.0.0 以来,DL4J 通过 ZOO 提供了一个 Tiny YOLO 模型。为此,我们需要在 Maven 友好的pom.xml
文件中添加一个依赖项:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-zoo</artifactId>
<version>${dl4j.version}</version>
</dependency>
除此之外,如果可能的话,确保你通过添加以下依赖项来使用 CUDA 和 cuDNN(更多细节请参见第二章,使用递归类型网络进行癌症类型预测):
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-cuda-9.0-platform</artifactId>
<version>${nd4j.version}</version>
</dependency>
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-cuda-9.0</artifactId>
<version>${dl4j.version}</version>
</dependency>
然后,我们准备加载预训练的 Tiny YOLO 模型作为ComputationGraph
,代码如下:
private ComputationGraph model;
private TinyYoloModel() {
try {
model = (ComputationGraph) new TinyYOLO().initPretrained();
createObjectLabels();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
在前面的代码片段中,createObjectLabels()
方法指的是用于训练 YOLO 2 模型的 PASCAL 视觉物体类(PASCAL VOC)数据集中的标签。该方法的签名如下所示:
private HashMap<Integer, String> labels;
void createObjectLabels() {
if (labels == null) {
String label = "aeroplanen" + "bicyclen" + "birdn" + "boatn" + "bottlen" + "busn" + "carn" +
"catn" + "chairn" + "cown" + "diningtablen" + "dogn" + "horsen" + "motorbiken" +
"personn" + "pottedplantn" + "sheepn" + "sofan" + "trainn" + "tvmonitor";
String[] split = label.split("\n");
int i = 0;
labels = new HashMap<>();
for(String label1 : split) {
labels.put(i++, label1);
}
}
}
现在,让我们创建一个 Tiny YOLO 模型实例:
static final TinyYoloModel yolo = new TinyYoloModel();
public static TinyYoloModel getPretrainedModel() {
return yolo;
}
现在,出于好奇,让我们看看模型架构以及每一层的超参数数量:
TinyYoloModel model = TinyYoloModel.getPretrainedModel();
System.out.println(TinyYoloModel.getSummary());
预训练 Tiny YOLO 模型的网络总结和层结构
因此,我们的 Tiny YOLO 模型在其 29 层网络中大约有 160 万个参数。然而,原始 YOLO 2 模型的层数更多。有兴趣的读者可以查看原始 YOLO 2,链接地址为github.com/yhcc/yolo2/blob/master/model_data/model.png
。
步骤 2 - 从视频片段生成帧
现在,为了处理实时视频,我们可以使用视频处理工具或框架,如 JavaCV 框架,它可以将视频拆分为单独的帧,并获取图像的高度和宽度。为此,我们需要在pom.xml
文件中包含以下依赖项:
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
<version>1.4.1</version>
</dependency>
JavaCV 使用 JavaCPP 预设的库包装器,这些库通常被计算机视觉领域的研究人员使用(例如,OpenCV 和 FFmpeg),并提供了有用的工具类,使它们的功能在 Java 平台上(包括 Android)更容易使用。更多细节请参见github.com/bytedeco/javacv
。
对于这个项目,我收集了两段视频片段(每段 1 分钟),它们应该能让你对自动驾驶汽车有一个初步了解。我从 YouTube 上下载了以下链接的数据集:
-
构建自动驾驶汽车 - 本地数据集 - 白天:
www.youtube.com/watch?v=7BjNbkONCFw
-
构建自动驾驶汽车 - 本地数据集 - 夜间:
www.youtube.com/watch?v=ev5nddpQQ9I
从 YouTube 下载后(或其他方式),我将它们重命名如下:
-
SelfDrivingCar_Night.mp4
-
SelfDrivingCar_Day.mp4
现在,如果你播放这些视频片段,你会看到德国人开车时时速达到 160 km/h 甚至更快。现在,让我们解析视频(首先使用白天 1)并查看一些属性,了解视频质量的硬件要求:
String videoPath = "data/SelfDrivingCar_Day.mp4";
FFmpegFrameGrabber frameGrabber = new FFmpegFrameGrabber(videoPath);
frameGrabber.start();
Frame frame;
double frameRate = frameGrabber.getFrameRate();
System.out.println("The inputted video clip has " + frameGrabber.getLengthInFrames() + " frames");
System.out.println("Frame rate " + framerate + "fps");
>>>
The inputted video clip has 1802 frames.
The inputted video clip has frame rate of 29.97002997002997.
然后,我们抓取每一帧,并使用Java2DFrameConverter
;它帮助我们将帧转换为 JPEG 图片:
Java2DFrameConverter converter = new Java2DFrameConverter();
// grab the first frame
frameGrabber.setFrameNumber(1);
frame = frameGrabber.grab();
BufferedImage bufferedImage = converter.convert(frame);
System.out.println("First Frame" + ", Width: " + bufferedImage.getWidth() + ", Height: " + bufferedImage.getHeight());
// grab the second frame
frameGrabber.setFrameNumber(2);
frame = frameGrabber.grab();
bufferedImage = converter.convert(frame);
System.out.println("Second Frame" + ", Width: " + bufferedImage.getWidth() + ", Height: " + bufferedImage.getHeight());
>>>
First Frame: Width-640, Height-360
Second Frame: Width-640, Height-360
这样,前面的代码将生成 1,802 张 JPEG 图片,与相同数量的帧一一对应。我们来看看生成的图片:
从视频片段到视频帧再到图像
因此,这段 1 分钟长的视频片段包含了相当数量的帧(即 1,800 帧),并且每秒 30 帧。简而言之,这段视频的分辨率为 720p。所以,你可以理解,处理这个视频需要较好的硬件配置,特别是配置 GPU 会有帮助。
第三步 – 将生成的帧输入 Tiny YOLO 模型
现在我们知道了片段的一些属性,可以开始生成帧并传递给 Tiny YOLO 预训练模型。首先,让我们看看一种不太重要但透明的方法:
private volatile Mat[] v = new Mat[1];
private String windowName = "Object Detection from Video";
try {
for(int i = 1; i < frameGrabber.getLengthInFrames();
i+ = (int)frameRate) {
frameGrabber.setFrameNumber(i);
frame = frameGrabber.grab();
v[0] = new OpenCVFrameConverter.ToMat().convert(frame);
model.markObjectWithBoundingBox(v[0], frame.imageWidth,
frame.imageHeight, true, windowName);
imshow(windowName, v[0]);
char key = (char) waitKey(20);
// Exit on escape:
if (key == 27) {
destroyAllWindows();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
frameGrabber.stop();
}
frameGrabber.close();
在前面的代码块中,我们将每一帧传送到模型。然后,我们使用Mat
类以 n 维、密集、数值化的多通道(即 RGB)数组表示每一帧。
要了解更多信息,请访问docs.opencv.org/trunk/d3/d63/classcv_1_1Mat.html#details
。
换句话说,我们将视频片段分割成多个帧,并逐一传入 Tiny YOLO 模型进行处理。通过这种方式,我们对整张图像应用了一个神经网络。
第四步 – 从图像帧中进行物体检测
Tiny YOLO 从每帧中提取特征,生成一个 n 维的、密集的、数值化的多通道数组。然后将每张图像分割成较少数量的矩形(边界框):
public void markObjectWithBoundingBox(Mat file, int imageWidth, int imageHeight, boolean newBoundingBOx,
String winName) throws Exception {
// parameters matching the pretrained TinyYOLO model
int W = 416; // width of the video frame
int H = 416; // Height of the video frame
int gW = 13; // Grid width
int gH = 13; // Grid Height
double dT = 0.5; // Detection threshold
Yolo2OutputLayer outputLayer = (Yolo2OutputLayer) model.getOutputLayer(0);
if (newBoundingBOx) {
INDArray indArray = prepareImage(file, W, H);
INDArray results = model.outputSingle(indArray);
predictedObjects = outputLayer.getPredictedObjects(results, dT);
System.out.println("results = " + predictedObjects);
markWithBoundingBox(file, gW, gH, imageWidth, imageHeight);
} else {
markWithBoundingBox(file, gW, gH, imageWidth, imageHeight);
}
imshow(winName, file);
}
在前面的代码中,prepareImage()
方法将视频帧作为图像传入,使用NativeImageLoader
类进行解析,进行必要的预处理,并提取图像特征,进一步转换成INDArray
格式,供模型使用:
INDArray prepareImage(Mat file, int width, int height) throws IOException {
NativeImageLoader loader = new NativeImageLoader(height, width, 3);
ImagePreProcessingScaler imagePreProcessingScaler = new ImagePreProcessingScaler(0, 1);
INDArray indArray = loader.asMatrix(file);
imagePreProcessingScaler.transform(indArray);
return indArray;
}
然后,markWithBoundingBox()
方法将在有多个边界框的情况下用于非最大抑制。
第五步 – 在有多个边界框的情况下进行非最大抑制
由于 YOLO 每个物体可能预测多个边界框,因此需要实施非最大抑制;它将所有属于同一物体的检测结果合并。因此,我们不再使用bx*、*by、bh*和*bw,**而是可以使用左上角和右下角的坐标。gridWidth
和gridHeight
是我们将图像分割成的小框数量。在我们的情况下,这个值是 13 x 13,其中w
和h
分别是原始图像帧的宽度和高度:
void markObjectWithBoundingBox(Mat file, int gridWidth, int gridHeight, int w, int h, DetectedObject obj) {
double[] xy1 = obj.getTopLeftXY();
double[] xy2 = obj.getBottomRightXY();
int predictedClass = obj.getPredictedClass();
int x1 = (int) Math.round(w * xy1[0] / gridWidth);
int y1 = (int) Math.round(h * xy1[1] / gridHeight);
int x2 = (int) Math.round(w * xy2[0] / gridWidth);
int y2 = (int) Math.round(h * xy2[1] / gridHeight);
rectangle(file, new Point(x1, y1), new Point(x2, y2), Scalar.RED);
putText(file, labels.get(predictedClass), new Point(x1 + 2, y2 - 2),
FONT_HERSHEY_DUPLEX, 1, Scalar.GREEN);
}
最后,我们去除那些与最大抑制框重叠的物体,具体操作如下:
static void removeObjectsIntersectingWithMax(ArrayList<DetectedObject> detectedObjects,
DetectedObject maxObjectDetect) {
double[] bottomRightXY1 = maxObjectDetect.getBottomRightXY();
double[] topLeftXY1 = maxObjectDetect.getTopLeftXY();
List<DetectedObject> removeIntersectingObjects = new ArrayList<>();
for(DetectedObject detectedObject : detectedObjects) {
double[] topLeftXY = detectedObject.getTopLeftXY();
double[] bottomRightXY = detectedObject.getBottomRightXY();
double iox1 = Math.max(topLeftXY[0], topLeftXY1[0]);
double ioy1 = Math.max(topLeftXY[1], topLeftXY1[1]);
double iox2 = Math.min(bottomRightXY[0], bottomRightXY1[0]);
double ioy2 = Math.min(bottomRightXY[1], bottomRightXY1[1]);
double inter_area = (ioy2 - ioy1) * (iox2 - iox1);
double box1_area = (bottomRightXY1[1] - topLeftXY1[1]) * (bottomRightXY1[0] - topLeftXY1[0]);
double box2_area = (bottomRightXY[1] - topLeftXY[1]) * (bottomRightXY[0] - topLeftXY[0]);
double union_area = box1_area + box2_area - inter_area;
double iou = inter_area / union_area;
if(iou > 0.5) {
removeIntersectingObjects.add(detectedObject);
}
}
detectedObjects.removeAll(removeIntersectingObjects);
}
在第二个代码块中,我们将每张图像缩放为 416 x 416 x 3(即,W x H x 3 RGB 通道)。然后,这张缩放后的图像被传递给 Tiny YOLO 进行预测,并标记边界框,操作如下:
我们的 Tiny YOLO 模型预测图像中检测到的物体的类别
一旦markObjectWithBoundingBox()
方法执行完成,下面的日志将被生成并显示在控制台上,包括预测的类别、bx*、*by、bh*、*bw以及置信度(即检测阈值):
[4.6233e-11]], predictedClass=6),
DetectedObject(exampleNumber=0,
centerX=3.5445247292518616, centerY=7.621537864208221,
width=2.2568163871765137, height=1.9423424005508423,
confidence=0.7954192161560059,
classPredictions=[[ 1.5034e-7], [ 3.3064e-9]...
第六步 – 整合所有步骤并运行应用程序
既然到目前为止我们已经知道了我们方法的整体工作流程,现在可以将所有内容汇总,看看它是否真的有效。然而,在此之前,让我们先看一下不同 Java 类的功能:
-
FramerGrabber_ExplorartoryAnalysis.java
:此类展示了如何从视频片段中抓取帧并将每一帧保存为 JPEG 图像。此外,它还展示了一些视频片段的探索性属性。 -
TinyYoloModel.java
:此类实例化 Tiny YOLO 模型并生成标签。它还创建并标记带有边界框的对象。然而,它展示了如何处理每个对象的多个边界框的非最大抑制。 -
ObjectDetectorFromVideo.java
:主类。它持续抓取帧并将其传递给 Tiny YOLO 模型(即,直到用户按下Esc键)。然后,它成功预测每个对象的相应类别,这些对象被检测到,并位于正常或重叠的边界框内,使用非最大抑制(如果需要)。
简而言之,首先,我们创建并实例化 Tiny YOLO 模型。然后我们抓取每一帧,并将每一帧视为单独的 JPEG 图像。接着,我们将所有图像传递给模型,模型根据之前概述的方式进行处理。整个工作流程现在可以通过以下 Java 代码进行描述:
// ObjectDetectorFromVideo.java
public class ObjectDetectorFromVideo{
private volatile Mat[] v = new Mat[1];
private String windowName;
public static void main(String[] args) throws java.lang.Exception {
String videoPath = "data/SelfDrivingCar_Day.mp4";
TinyYoloModel model = TinyYoloModel.getPretrainedModel();
System.out.println(TinyYoloModel.getSummary());
new ObjectDetectionFromVideo().startRealTimeVideoDetection(videoPath, model);
}
public void startRealTimeVideoDetection(String videoFileName, TinyYoloModel model)
throws java.lang.Exception {
windowName = "Object Detection from Video";
FFmpegFrameGrabber frameGrabber = new FFmpegFrameGrabber(videoFileName);
frameGrabber.start();
Frame frame;
double frameRate = frameGrabber.getFrameRate();
System.out.println("The inputted video clip has " + frameGrabber.getLengthInFrames() + " frames");
System.out.println("The inputted video clip has frame rate of " + frameRate);
try {
for(int i = 1; i < frameGrabber.getLengthInFrames(); i+ = (int)frameRate) {
frameGrabber.setFrameNumber(i);
frame = frameGrabber.grab();
v[0] = new OpenCVFrameConverter.ToMat().convert(frame);
model.markObjectWithBoundingBox(v[0], frame.imageWidth, frame.imageHeight,
true, windowName);
imshow(windowName, v[0]);
char key = (char) waitKey(20);
// Exit on escape:
if(key == 27) {
destroyAllWindows();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
frameGrabber.stop();
}
frameGrabber.close();
}
}
一旦执行前述类,应用程序应加载预训练模型,并且用户界面应加载,显示每个被分类的对象:
我们的 Tiny YOLO 模型可以同时从视频片段中预测多辆汽车(白天)
现在,为了查看我们的模型在夜间模式下的有效性,我们可以对夜间数据集进行第二次实验。为此,只需在main()
方法中更改一行,如下所示:
String videoPath = "data/SelfDrivingCar_Night.mp4";
执行前述类时,应用程序应该加载预训练模型,并且用户界面应该加载,显示每个被分类的对象:
我们的 Tiny YOLO 模型可以同时从视频片段中预测多辆汽车(夜间)
此外,为了查看实时输出,可以执行给定的屏幕录制片段,展示应用程序的输出。
常见问题解答(FAQs)
在本节中,我们将看到一些可能已经出现在你脑海中的常见问题。有关这些问题的答案,请参阅附录 A。
-
我们不能从头开始训练 YOLO 吗?
-
我在想是否可以使用 YOLO v3 模型。
-
我需要对代码做哪些更改才能使其适用于我自己的视频片段?
-
提供的应用程序可以检测视频片段中的汽车和其他车辆。然而,处理并不流畅,似乎停滞不前。我该如何解决这个问题?
-
我可以扩展这个应用程序,使其能够处理来自摄像头的实时视频吗?
总结
在本章中,我们了解了如何开发一个端到端的项目,该项目可以在视频剪辑连续播放时从视频帧中检测对象。我们学习了如何利用预训练的 Tiny YOLO 模型,它是原始 YOLO v2 模型的一个更小的变种。
此外,我们还讨论了从静态图像和视频中进行目标检测时常见的一些挑战,并介绍了如何使用边界框和非最大抑制技术解决这些问题。我们学习了如何利用 JavaCV 库在 DL4J 之上处理视频剪辑。最后,我们还回顾了一些常见问题,这些问题对于实现和扩展这个项目非常有帮助。
在下一章中,我们将学习如何开发异常检测系统,这在金融公司(如银行、保险公司和信用合作社)的欺诈分析中非常有用。这是一个推动业务增长的重要任务。我们将使用无监督学习算法,如变分自编码器和重构概率。
问题的答案
问题 1 的回答:我们可以从头开始训练一个 YOLO 网络,但那会需要大量的工作(以及昂贵的 GPU 计算时间)。作为工程师和数据科学家,我们希望尽可能利用现成的库和机器学习模型,因此我们将使用一个预训练的 YOLO 模型,以便更快、更便宜地将我们的应用程序投入生产。
问题 2 的回答:也许可以,但最新的 DL4J 发布版本仅提供 YOLO v2。然而,当我与他们的 Gitter(见deeplearning4j.org/
)进行交流时,他们告诉我,通过一些额外的努力,你可以让它工作。我的意思是,你可以通过 Keras 导入导入 YOLO v3。不幸的是,我尝试过,但没能完全实现。
问题 3 的回答:你应该能够直接输入你自己的视频。不过,如果无法正常工作,或者抛出任何不必要的异常,那么视频的属性,如帧率、宽度和每一帧的高度,应该与边界框的规格一致。
问题 4 的回答:嗯,我已经说明过,你的机器应该有良好的硬件配置,处理过程不应该造成任何延迟。例如,我的机器有 32GB 的内存,Core i7 处理器,GeForce GTX 1050 GPU,4GB 的主内存,应用程序运行得非常流畅。
问题 5 的回答:也许可以。在这种情况下,视频的主要来源应该是直接来自网络摄像头。根据github.com/bytedeco/javacv
提供的文档,JavaCV 还带有硬件加速的全屏图像显示、易于使用的方法来在多个核心上并行执行代码、以及对摄像头、投影仪等设备的用户友好的几何和颜色校准。
第七章:使用 LSTM 网络进行股票价格预测
股票市场价格预测是最具挑战性的任务之一。一个主要原因是噪声和这种类型数据集的波动特性。因此,如何准确预测股价走势仍然是现代交易世界中的一个未解问题。然而,经典的机器学习算法,如支持向量机、决策树和树集成算法(例如,随机森林和梯度提升树),在过去十年中已被广泛应用。
然而,股市价格存在严重的波动性和历史视角,这使得它们适合进行时间序列分析。这也对经典算法提出了挑战,因为这些算法无法利用长期依赖关系。考虑到这些挑战和现有算法的局限性,在本章中,我们将学习如何利用 LSTM 并基于 DL4J 库开发一个真实的股票开盘或收盘价预测模型。
将使用从真实股市数据集生成的时间序列数据集来训练 LSTM 模型,该模型将用于预测一次仅一天的股票价格。简而言之,我们将在整个端到端的项目中学习以下内容:
-
股票价格预测与在线交易
-
数据收集与描述
-
使用 LSTM 进行股票价格预测
-
常见问题解答(FAQ)
先进的自动化股票交易
通常,在证券交易所,交易所会维护所有买卖订单的订单簿,包括它们的数量和价格,并在买方和卖方匹配时执行这些订单。此外,交易所还会保持并提供关于状态交易的统计数据,这些数据通常以OHCL(即开盘-最高-最低-收盘)和交易对货币的成交量形式呈现。
顺便提一下,柱状图用于展示开盘价、最高价、最低价和收盘价。与线形图不同,OHLC 图表使得技术分析师能够评估日内波动性,并查看价格的开盘和收盘情况。看看这个图表:
OHLC 定价模型展示了某一时间段的开盘价、最高价、最低价和收盘价(来源:en.tradimo.com/tradipedia/ohlc-chart/
)
这些数据以某些时间段的聚合形式展示,从秒到天,甚至是几个月。专门的服务器在为专业交易员和机构收集这些数据。虽然你不能指望所有订单数据都可以免费获取,但其中一部分是对公众开放的,并且可以使用。第一组数据是历史股市交易数据(OHLC),第二组数据包含股市交易的技术指标。
例如,比特币作为最早的加密货币之一,吸引了投资者和交易员的关注。这是因为以下原因:
-
使用比特币,可以开始进行交易
-
比特币让你保持伪匿名状态
-
在比特币的历史中,曾经历过剧烈的增长(见下图的一些统计数据),这吸引了长期投资者
-
存在高度的波动性,这吸引了日内交易者
难以预测比特币的长期价值,因为比特币背后的价值较为抽象,其价格主要反映市场认知,并且高度依赖于新闻、法规、政府与银行的合作、平台的技术问题,如交易费用和区块大小、机构投资者是否将比特币纳入其投资组合等。看看这个截图:
比特币及其在 2017 年 9 月之前的剧烈价格上涨(来源:http://www.bitcoin2040.com/bitcoin-price-history/)
现在的问题是如何以自动化的方式分析这个数据集,帮助投资者或在线货币交易者。好吧,在传统证券世界中,比如公司的股票,过去是由人来做分析,预测股价并进行交易。目前,比特币的交易量相较于传统交易所来说还是较低的。造成这种情况的两个原因是股市的高波动性和加密货币的监管问题。看看这个图表:
比特币的买卖订单数据(BTC/USD 对,截止 2018 年 6 月 18 日,来源:https://cex.io/trade#)
所以,现在人们主要通过购买和出售比特币进行交易,而这一切都伴随着与此相关的非理性行为,但也有一些尝试将比特币交易自动化的努力。最著名的尝试之一是麻省理工学院和斯坦福大学研究人员于 2014 年发布的一篇论文。
许多事情已经发生了变化,考虑到过去三年比特币价格的大幅上涨,任何只买入并持有的人都会对结果感到满意。显然,一些交易者使用机器学习(ML)进行交易,这类应用看起来很有前景。直到现在,仍然有几种最佳的可能方法。
对于训练,使用订单簿数据,而不是衍生的OHLC + 成交量数据。因此,训练和预测时,使用以下方式的数据:
-
将数据拆分为某一大小的时间序列(其中大小是一个可以调整的参数)。
-
将时间序列数据聚类为K个集群,其中K是唯一需要调节的参数。假设某些具有自然趋势的集群会出现(如价格的急剧下跌/上涨等)。
-
对每个集群,训练回归/分类器,分别预测价格和价格变化。
对于推理和评估,这种方法考虑了最新的时间序列,并使用特定窗口的大小来训练模型。然后,它会按如下方式对数据进行分类:
-
它会采用用于训练的窗口大小的最新时间序列,并对其进行分类——它属于哪个集群?
-
它使用机器学习模型来预测价格和价格变动的聚类
这个解决方案来源于 2014 年,但仍然具有一定的鲁棒性。由于需要识别多个参数,并且没有可用的订单簿历史数据,在本项目中我们使用了一种更简单的方法和数据集。
开发股票价格预测模型
如前所述,股市价格具有较大的波动性和历史视角,这使得它非常适合时间序列分析。这也对经典算法构成了挑战,因为这些算法无法处理长期依赖关系。
如下图所示,首先我们收集历史财务数据。数据经过必要的预处理和特征工程后,转换成时间序列。最终生成的时间序列数据被输入到 LSTM 中进行训练。下图展示了这一过程:
本项目原型的高层数据管道
因此,我们将使用 LSTM 模型,不仅因为它优于经典算法,还因为它能够解决长期依赖问题。因此,我们的项目将包括以下步骤:
-
加载并预处理数据,并将其划分为训练集和测试集
-
使用数据训练
LSTM
模型 -
在测试数据上评估模型
-
可视化模型的表现
我们将详细讲解每一步。但在此之前,了解数据集是必须的。
数据收集与探索性分析
如前所述,我们将使用历史股票数据来训练我们的 LSTM 网络。数据集包含来自 506 只不同证券的每分钟 OHLC 数据,时间跨度为 2016 年 1 月至 2016 年 12 月。让我们来看一下我们将使用的数据:
//DataPreview.java
SparkSession spark = SparkSession.*builder*().master("local").appName("StockPricePredictor").getOrCreate();
spark.conf().set("spark.sql.crossJoin.enabled", "true");//enables cross joining across Spark DataFrames
// load data from csv file
String filename = "data/prices-split-adjusted.csv";
Dataset<Row> data = spark.read().option("inferSchema", false).option("header", true)
.format("csv").load(filename)
.withColumn("openPrice", functions.*col*("open").cast("double")).drop("open")
.withColumn("closePrice", functions.*col*("close").cast("double")).drop("close")
.withColumn("lowPrice", functions.*col*("low").cast("double")).drop("low")
.withColumn("highPrice", functions.*col*("high").cast("double")).drop("high")
.withColumn("volumeTmp", functions.*col*("volume").cast("double")).drop("volume")
.toDF("date", "symbol", "open", "close", "low", "high", "volume");
data.show(10);
以下快照展示了该代码的输出:
本项目使用的历史数据集快照
如前面的截图所示,我们的数据集包含七个特征,具体如下:
-
date
:2016 年 1 月到 2016 年 12 月之间的时间 -
symbol
:506 只不同证券的股票代码 -
open
:时间区间开始时的开盘价 -
close
:时间区间结束时的收盘价 -
high
:该时间区间内所有订单执行时的最高价格 -
low
:同样的,但为最低价格 -
volume
:该时间段内所有转手的股票数量
现在让我们看看一些股票代码(更多内容请见securities.csv
文件):
data.createOrReplaceTempView("stock");
spark.sql("SELECT DISTINCT symbol FROM stock GROUP BY symbol").show(10);
这是上一段代码的输出快照:
本项目使用的部分股票符号及其价格数据
如果我们需要了解证券,以下表格可以为我们提供一些信息:
本项目使用的部分证券及其详细信息
然后,我们决定查看所有个别证券的四个类别的平均价格——开盘价、收盘价、最低价和最高价。看看这个代码:
spark.sql("SELECT symbol, avg(open) as avg_open, "
+ "avg(close) as avg_close, "
+ "avg(low) as avg_low, "
+ "avg(high) as avg_high "
+ "FROM stock GROUP BY symbol")
.show(10);
这个快照展示了之前代码的输出:
开盘价、收盘价、最低价和最高价的平均价格。
然而,前面的表格并没有提供太多的见解,除了平均价格这一点。因此,知道最小值和最大值价格可以让我们了解股票市场是否真的有很高的波动性。看看这个代码:
spark.sql("SELECT symbol, "
+ "MIN(open) as min_open, MAX(open) as max_open, "
+ "MIN(close) as min_close, MAX(close) as max_close, "
+ "MIN(low) as min_low, MAX(low) as max_low, "
+ "MIN(high) as min_high, MAX(high) as max_high "
+ "FROM stock GROUP BY symbol")
.show(10);
这个快照展示了代码的输出:
开盘价、收盘价、最低价和最高价的平均最大和最小价格。
这个表格展示了例如最小开盘价和收盘价并没有显著的差异。然而,最大开盘价甚至收盘价差异很大。这是时间序列数据的特点,它促使我选择通过将数据转换为时间序列来使用 LSTM
。
准备训练集和测试集。
数据科学流程中最重要的部分之一,在数据收集(从某种意义上讲是外包的——我们使用了别人收集的数据)之后,就是数据预处理,即清理数据集并将其转换为适应我们需求的格式。
所以,我们的目标是预测价格变化的方向,基于实际的美元价格随时间的变化。为了做到这一点,我们定义了像 file
、symbol
、batchSize
、splitRatio
和 epochs
这样的变量。你可以在代码中的内联注释中看到每个变量的解释:
// StockPricePrediction.java
String file = "data/prices-split-adjusted.csv";
String symbol = "GRMN"; // stock name
int batchSize = 128; // mini-batch size
double splitRatio = 0.8; // 80% for training, 20% for testing
int epochs = 100; // training epochs
我们使用 StockDataSetIterator
构造函数变量来为模型准备数据集。在这里,我们为模型准备了一个按序列格式的输入数据集,category = PriceCategory.ALL
,这意味着我们将预测所有五个价格类别(开盘价、收盘价、最低价、最高价和成交量)。看看这个代码:
//StockPricePrediction.java
System.*out*.println("Creating dataSet iterator...");
PriceCategory category = PriceCategory.*ALL*; // CLOSE: predict close price
*iterator* = new StockDataSetIterator(file, symbol, batchSize, *exampleLength*, splitRatio, category);
System.*out*.println("Loading test dataset...");
List<Pair<INDArray, INDArray>> test = *iterator*.getTestDataSet();
在前面的代码块中,我们使用的 PriceCategory
构造函数具有以下签名:
public enum PriceCategory {
OPEN, CLOSE, LOW, HIGH, VOLUME, ALL}
在同一行中,以下选项也是有效的:
PriceCategory category = PriceCategory.OPEN; // OPEN: predict open price
PriceCategory category = PriceCategory.CLOSE; // CLOSE: predict close price
PriceCategory category = PriceCategory.LOW; // LOW: predict low price
PriceCategory category = PriceCategory.HIGH; // HIGH: predict high price.
而在内部,StockDataSetIterator
类的构造函数具有以下功能:
-
我们从文件中读取股票数据,对于每个符号,我们创建一个列表。
-
我们将
miniBatchSize
、exampleLength
和category
变量设置为类的属性。 -
然后,
split
变量是根据splitRation
变量计算得出的。 -
我们将
stockDataList
分成两部分:训练集和测试集。 -
然后,股票数据被分割成训练集和测试集。
-
我们调用函数
initializeOffsets()
来初始化exampleStartOffsets
数组的值。
接下来,StockDataSetIterator()
构造函数具有以下签名,它将生成一个 List<Pair<INDArray, INDArray>>
类型的测试数据集:
//StockDataSetIterator.java
/** stock dataset for training */
private List<StockData> train;
在下面的代码中,StockData
是一个 case 类,提供了从输入的 CSV
文件中提取或准备数据集的结构:
//StockData.java
private String date; // date
private String symbol; // stock name
private double open; // open price
private double close; // close price
private double low; // low price
private double high; // high price
private double volume; // volume
public StockData () {}
public StockData (String date, String symbol, double open, double close, double low, double high, double volume) {
this.date = date;
this.symbol = symbol;
this.open = open;
this.close = close;
this.low = low;
this.high = high;
this.volume = volume;
}
然后,我们有以下的 getter 和 setter 方法,用于上述变量,如下所示:
public String getDate() { return date; }
public void setDate(String date) { this.date = date; }
public String getSymbol() { return symbol; }
public void setSymbol(String symbol) { this.symbol = symbol; }
public double getOpen() { return open; }
public void setOpen(double open) { this.open = open; }
public double getClose() { return close; }
public void setClose(double close) { this.close = close; }
public double getLow() { return low; }
public void setLow(double low) { this.low = low; }
public double getHigh() { return high; }
public void setHigh(double high) { this.high = high; }
public double getVolume() { return volume; }
public void setVolume(double volume) { this.volume = volume; }
现在我们已经看过StockData.java
类的签名,是时候创建测试数据集作为StockDataSetIterator
了:
/** adjusted stock dataset for testing */
private List<Pair<INDArray, INDArray>> test;
public StockDataSetIterator (String filename, String symbol, int miniBatchSize, int exampleLength,
double splitRatio, PriceCategory category) {
List<StockData> stockDataList = readStockDataFromFile(filename, symbol);
this.miniBatchSize = miniBatchSize;
this.exampleLength = exampleLength;
this.category = category;
int split = (int) Math.round(stockDataList.size() * splitRatio);
train = stockDataList.subList(0, split);
test = generateTestDataSet(stockDataList.subList(split, stockDataList.size()));
initializeOffsets();
}
在前面的代码中,调用了initializeOffsets()
方法来初始化小批量的偏移量:
private void initializeOffsets() {
exampleStartOffsets.clear();
int window = exampleLength + predictLength;
for(int i = 0; i < train.size() - window; i++) {
exampleStartOffsets.add(i);
}
}
实际读取是通过readStockDataFromFile()
方法完成的。在构造函数内部,首先,我们调用函数readStockDataFromFile()
从文件中读取数据并加载到stockDataList
中。然后,我们初始化StockDataList
列表,以包含从csv
文件中读取的数据。
接下来,我们用Double.MIN_VALUE
和Double.MAX_VALUE
初始化最大值和最小值数组。然后,逐行读取CSV
文件中的五个值。接着将这些值依次插入到StockData
对象的构造函数中,并将该对象添加到StockDataList
中。此外,如果出现任何异常,我们会抛出异常。最后,方法返回StockDataList
。方法的签名如下:
private List<StockData> readStockDataFromFile (String filename, String symbol) {
List<StockData> stockDataList = new ArrayList<>();
try {
for(int i = 0; i < maxArray.length; i++) { // initialize max and min arrays
maxArray[i] = Double.MIN_VALUE;
minArray[i] = Double.MAX_VALUE;
}
List<String[]> list = new CSVReader(new FileReader(filename)).readAll();//load as a list
for(String[] arr : list) {
if(!arr[1].equals(symbol)) continue;
double[] nums = new double[VECTOR_SIZE];
for(int i = 0; i < arr.length - 2; i++) {
nums[i] = Double.valueOf(arr[i + 2]);
if(nums[i] > maxArray[i]) maxArray[i] = nums[i];
if(nums[i] < minArray[i]) minArray[i] = nums[i];
}
stockDataList.add(new StockData(arr[0], arr[1], nums[0], nums[1],
nums[2], nums[3], nums[4]));
}
} catch (IOException e) {
e.printStackTrace();
}
return stockDataList;
}
然后,generateTestDataSet()
方法实际上生成仅能由LSTM
模型消费的特征,格式为List<Pair<INDArray, INDArray>>
,其中排序设置为f
以便更快构建:
private List<Pair<INDArray, INDArray>> generateTestDataSet (List<StockData> stockDataList) {
int window = exampleLength + predictLength;
List<Pair<INDArray, INDArray>> test = new ArrayList<>();
for (int i = 0; i < stockDataList.size() - window; i++) {
INDArray input = Nd4j.create(new int[] {exampleLength, VECTOR_SIZE}, 'f');
for (int j = i; j < i + exampleLength; j++) {
StockData stock = stockDataList.get(j);
input.putScalar(new int[] {j - i, 0}, (stock.getOpen() - minArray[0]) / (maxArray[0] -
minArray[0]));
input.putScalar(new int[] {j - i, 1}, (stock.getClose() - minArray[1]) / (maxArray[1] -
minArray[1]));
input.putScalar(new int[] {j - i, 2}, (stock.getLow() - minArray[2]) / (maxArray[2] -
minArray[2]));
input.putScalar(new int[] {j - i, 3}, (stock.getHigh() - minArray[3]) / (maxArray[3] -
minArray[3]));
input.putScalar(new int[] {j - i, 4}, (stock.getVolume() - minArray[4]) / (maxArray[4] -
minArray[4]));
}
StockData stock = stockDataList.get(i + exampleLength);
INDArray label;
if (category.equals(PriceCategory.ALL)) {
label = Nd4j.create(new int[]{VECTOR_SIZE}, 'f'); // ordering is set faster construct
label.putScalar(new int[] {0}, stock.getOpen());
label.putScalar(new int[] {1}, stock.getClose());
label.putScalar(new int[] {2}, stock.getLow());
label.putScalar(new int[] {3}, stock.getHigh());
label.putScalar(new int[] {4}, stock.getVolume());
} else {
label = Nd4j.create(new int[] {1}, 'f');
switch (category) {
case OPEN: label.putScalar(new int[] {0}, stock.getOpen()); break;
case CLOSE: label.putScalar(new int[] {0}, stock.getClose()); break;
case LOW: label.putScalar(new int[] {0}, stock.getLow()); break;
case HIGH: label.putScalar(new int[] {0}, stock.getHigh()); break;
case VOLUME: label.putScalar(new int[] {0}, stock.getVolume()); break;
default: throw new NoSuchElementException();
}
}
test.add(new Pair<>(input, label));
}
return test;
}
在前面的代码块中,我们将miniBatchSize
、exampleLength
和category
变量保存为类属性。然后,根据splitRation
变量计算split
变量。接着,我们将stockDataList
分为两部分:
-
从开始到
split
的索引属于训练集。 -
从
split+1
到列表末尾的索引属于测试集。
生成的测试数据与训练数据集差异很大。调用函数generatedTestDataSet()
来设置测试数据集。首先,我们通过示例长度和预测长度设置一个窗口变量。然后,我们从 0 开始循环,直到测试数据的长度减去窗口大小。请考虑以下内容:
-
读取五个输入变量:开盘价、收盘价、最低价、最高价和交易量。
-
基于
category
的值,读取标签值。如果category
等于ALL
,则读取五个变量,如输入变量。否则,仅通过category
的值读取一个变量。
在前面的代码块中,标签是通过feedLabel()
方法传递的,具体如下:
private double feedLabel(StockData data) {
double value;
switch(category) {
case OPEN: value = (data.getOpen() - minArray[0]) / (maxArray[0] - minArray[0]); break;
case CLOSE: value = (data.getClose() - minArray[1]) / (maxArray[1] - minArray[1]); break;
case LOW: value = (data.getLow() - minArray[2]) / (maxArray[2] - minArray[2]); break;
case HIGH: value = (data.getHigh() - minArray[3]) / (maxArray[3] - minArray[3]); break;
case VOLUME: value = (data.getVolume() - minArray[4]) / (maxArray[4] - minArray[4]); break;
default: throw new NoSuchElementException();
}
return value;
}
在前面的代码块中,我们初始化变量value
。然后检查变量category
的值,计算出的变量value
的值可以用数学符号表示如下:
value = (data.getOpen() - minArray[0]) / (maxArray[0] - minArray[0])
然后,特征和标签都被用来准备数据集。看看这段代码:
public DataSet next(int num) {
if(exampleStartOffsets.size() == 0) throw new NoSuchElementException();
int actualMiniBatchSize = Math.min(num, exampleStartOffsets.size());
INDArray input = Nd4j.create(new int[] {actualMiniBatchSize, VECTOR_SIZE, exampleLength}, 'f');
INDArray label;
if(category.equals(PriceCategory.ALL))
label = Nd4j.create(new int[] {actualMiniBatchSize, VECTOR_SIZE, exampleLength}, 'f');
else
label = Nd4j.create(new int[] {actualMiniBatchSize, predictLength, exampleLength}, 'f');
for(int index = 0; index < actualMiniBatchSize; index++) {
int startIdx = exampleStartOffsets.removeFirst();
int endIdx = startIdx + exampleLength;
StockData curData = train.get(startIdx);
StockData nextData;
for(int i = startIdx; i < endIdx; i++) {
int c = i - startIdx;
input.putScalar(new int[] {index, 0, c}, (curData.getOpen() - minArray[0])
/ (maxArray[0] - minArray[0]));
input.putScalar(new int[] {index, 1, c}, (curData.getClose() - minArray[1])
/ (maxArray[1] - minArray[1]));
input.putScalar(new int[] {index, 2, c}, (curData.getLow() - minArray[2])
/ (maxArray[2] - minArray[2]));
input.putScalar(new int[] {index, 3, c}, (curData.getHigh() - minArray[3])
/ (maxArray[3] - minArray[3]));
input.putScalar(new int[] {index, 4, c}, (curData.getVolume() - minArray[4])
/ (maxArray[4] - minArray[4]));
nextData = train.get(i + 1);
if(category.equals(PriceCategory.ALL)) {
label.putScalar(new int[] {index, 0, c}, (nextData.getOpen() - minArray[1])
/ (maxArray[1] - minArray[1]));
label.putScalar(new int[] {index, 1, c}, (nextData.getClose() - minArray[1])
/ (maxArray[1] - minArray[1]));
label.putScalar(new int[] {index, 2, c}, (nextData.getLow() - minArray[2])
/ (maxArray[2] - minArray[2]));
label.putScalar(new int[] {index, 3, c}, (nextData.getHigh() - minArray[3])
/ (maxArray[3] - minArray[3]));
label.putScalar(new int[] {index, 4, c}, (nextData.getVolume() - minArray[4])
/ (maxArray[4] - minArray[4]));
} else {
label.putScalar(new int[]{index, 0, c}, feedLabel(nextData));
}
curData = nextData;
}
if(exampleStartOffsets.size() == 0) break;
}
return new DataSet(input, label);
}
在前面的代码块中,我们循环epochs
次数,对于每次循环,直到获取到数据,我们通过iterator.next()
函数将数据拟合到网络中。请考虑以下内容:
-
我们初始化两个变量:
input
使用actualMinibatchSize
,label
使用category
。 -
然后我们从 0 循环到
actualMiniBatchSize
。每次循环时,我们创建两个额外的变量:curData
,它是当前时间点的StockData
数据。然后我们将它们的值放入input
列表中。类似地,nextData
变量也是一天的StockData
数据,它是curData
数据的后一天。最后,我们将nextData
的值放入label
列表中。
LSTM 网络构建
如前所述,我编写了一个名为RecurrentNets.java
的类来构建 LSTM 网络。我们创建了一个MultilayerNetwork
LSTM 网络,包含一个输入层、四个 LSTM 层、三个密集层和一个输出层。输入由基因变异的序列组成。
我们使用BuildBuildLstmNetworks()
方法,传入两个参数——输入层的输入数量和输出层的输出数量,如下所示:
private static final int lstmLayer1Size = 128;
private static final int lstmLayer2Size = 128;
private static final int denseLayerSize = 32;
private static final double dropoutRatio = 0.5;
private static final int truncatedBPTTLength = 22;
现在,在我们开始创建和构建网络之前,先来看看我们的模型将是什么样子:
股票价格 LSTM 网络
然后,使用createAndBuildLstmNetworks()
方法根据前面的参数设置创建并构建网络:
public static MultiLayerNetwork createAndBuildLstmNetworks(int nIn, int nOut) {
// Creating MultiLayerConfiguration
MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
.seed(123456)// for the reproducibility
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)//optimizer
.updater(new Adam(0.001)) // Adam updater with SGD
.l2(1e-4)// l2 regularization
.weightInit(WeightInit.XAVIER)// network weight initialization
.activation(Activation.RELU)// ReLU as activation
.list()
.layer(0, new LSTM.Builder()//LSTM layer 1
.nIn(nIn)
.nOut(lstmLayer1Size)
.activation(Activation.TANH)
.gateActivationFunction(Activation.HARDSIGMOID)// Segment-wise linear
// approximation of sigmoid
.dropOut(dropoutRatio)// keeping drop-out ratio
.build())
.layer(1, new LSTM.Builder()// LSTM layer 2
.nIn(lstmLayer1Size)
.nOut(lstmLayer2Size)
.activation(Activation.TANH)
.gateActivationFunction(Activation.HARDSIGMOID)
.dropOut(dropoutRatio)//kee drop-out ratio
.build())
.layer(2, new LSTM.Builder()//LSTM layer 3
.nIn(lstmLayer1Size)
.nOut(lstmLayer2Size)
.activation(Activation.TANH)
.gateActivationFunction(Activation.HARDSIGMOID)
.dropOut(dropoutRatio)// keep drop-out ratio
.build())
.layer(3, new DenseLayer.Builder()// FC layer 1
.nIn(lstmLayer2Size)
.nOut(denseLayerSize)
.activation(Activation.RELU)
.build())
.layer(4, new DenseLayer.Builder()//FC layer 2
.nIn(denseLayerSize)
.nOut(denseLayerSize)
.activation(Activation.RELU)
.build())
.layer(5, new DenseLayer.Builder()//FC layer 3
.nIn(denseLayerSize)
.nOut(denseLayerSize)
.activation(Activation.RELU)
.build())
.layer(6, new RnnOutputLayer.Builder() // RNN output layer
.nIn(denseLayerSize)
.nOut(nOut)
.activation(Activation.IDENTITY)// Regression with MSE as the loss function
.lossFunction(LossFunctions.LossFunction.MSE)
.build())
.backpropType(BackpropType.TruncatedBPTT)// Back propagation with time
.tBPTTForwardLength(truncatedBPTTLength)
.tBPTTBackwardLength(truncatedBPTTLength)
.pretrain(false).backprop(true)//no pretraining necessary
.build();
// Creating MultiLayerNetwork using the above MultiLayerConfig
MultiLayerNetwork net = new MultiLayerNetwork(conf);
net.init(); // initilize the MultiLayerNetwork
net.setListeners(new ScoreIterationListener(100));// shows score in each 100th iteration/epoch
return net; // return the MultiLayerNetwork
}
由于我们在本章中多次创建并使用了LSTM
网络,我决定不讨论它的详细内容。不过,这里有一个重要的点是使用了IDENTITY
激活函数,并结合均方根误差(RMSE)
,它通常用于回归问题。
简而言之,要在 DL4J 中执行回归分析,你需要设置一个多层神经网络,并在末尾添加一个输出层,具有如下属性,如前所示:
//Create output layer
.layer()
.nIn($NumberOfInputFeatures)
.nOut(1)// regression hence, only a single output
.activation(Activation.IDENTITY)//Regression with RMSE as the loss function
.lossFunction(LossFunctions.LossFunction.RMSE)
有关使用 DL4J 进行回归分析的更多信息,感兴趣的读者可以访问deeplearning4j.org/evaluation#Regression
。
网络训练,以及保存训练好的模型
现在,既然我们的网络以及训练和测试集已经准备好,我们就可以开始训练网络了。为此,我们再次使用 DL4J 提供的fit()
方法。我们循环epochs
次数,每次循环直到获得数据。我们在每个时间步中使用miniBatchSize
数量的数据来拟合网络,如下所示:
// StockPricePrediction.java
System.out.println("Training LSTM network...");
for(int i = 0; i < epochs; i++) {
while(iterator.hasNext()) net.fit(iterator.next()); // fit model using mini-batch data
iterator.reset(); // reset iterator
net.rnnClearPreviousState(); // clear previous state
}
>>
Creating dataSet iterator...
Loading test dataset...
Building LSTM networks...
Training LSTM network...
训练完成后,我们将训练好的模型保存到磁盘(在data
目录中)。这里我指定了一个示例名称StockPriceLSTM_+ category name + .zip
,如下所示:
# StockPricePrediction.java
System.*out*.println("Saving model...");
File locationToSave = new File("data/StockPriceLSTM_".concat(String.*valueOf*(category)).concat(".zip"));
// saveUpdater: i.e., state for Momentum, RMSProp, Adagrad etc. Save this to train your network in future
ModelSerializer.*writeModel*(net, locationToSave, true);
现在让我们来看一下每层的参数数量:
//Print the number of parameters in the network (and for each layer)
Layer[] layers_before_saving = net.getLayers();
int totalNumParams_before_saving = 0;
for(int i=0; i<layers_before_saving.length; i++ ){
int nParams = layers_before_saving[i].numParams();
System.out.println("Number of parameters in layer " + i + ": " + nParams);
totalNumParams_before_saving += nParams;
}
System.out.println("Total number of network parameters: " + totalNumParams_before_saving);
>>>
Saving model...
Number of parameters in layer 0: 68608
Number of parameters in layer 1: 131584
Number of parameters in layer 2: 131584
Number of parameters in layer 3: 4128
Number of parameters in layer 4: 1056
Number of parameters in layer 5: 1056
Number of parameters in layer 6: 165
Total number of network parameters: 338181
尽管如此,我们启用了 DL4J UI 以查看训练进度和参数,如下所示:
//Initialize the user interface backend
UIServer uiServer = UIServer.*getInstance*();
//Configure where the network information (gradients, activations, score vs. time etc) is to be stored. //Then add the StatsListener to collect this information from the network, as it trains:
StatsStorage statsStorage = new InMemoryStatsStorage();
//Alternative: new FileStatsStorage(File) - see UIStorageExample. Attach the StatsStorage instance to the //UI: this allows the contents of the StatsStorage to be visualized:
uiServer.attach(statsStorage);
int listenerFrequency = 1;
net.setListeners(new StatsListener(statsStorage, listenerFrequency));
以下截图显示了输出:
用户界面上的网络参数
图表看起来似乎没有进行正则化,可能是因为我们没有足够的训练数据。
恢复已保存的模型进行推断
现在我们已经完成了训练,并且训练好的模型已在手,我们可以直接使用该训练模型进行推断,或者从磁盘恢复已保存的模型,或者开始推理。看看这段代码:
System.*out*.println("Restoring model...");
net = ModelSerializer.*restoreMultiLayerNetwork*(locationToSave);
//print the score with every 1 iteration
net.setListeners(new ScoreIterationListener(1));
//Print the number of parameters in the network (and for each layer)
Layer[] layers = net.getLayers();
int totalNumParams = 0;
for( int i=0; i<layers.length; i++ ){
int nParams = layers[i].numParams();
System.*out*.println("Number of parameters in layer " + i + ": " + nParams);
totalNumParams += nParams;
}
System.*out*.println("Total number of network parameters: " + totalNumParams);
>>>
Restoring model...
Number of parameters in layer 0: 68608
Number of parameters in layer 1: 131584
Number of parameters in layer 2: 131584
Number of parameters in layer 3: 4128
Number of parameters in layer 4: 1056
Number of parameters in layer 5: 1056
Number of parameters in layer 6: 165
Total number of network parameters: 338181
评估模型
参数的数量与我们在磁盘上保存的一样。这意味着我们的训练模型没有受到污染,因此我们是安全的。接下来,我们开始在测试集上评估模型。但是,正如前面所说,我们将对模型进行双向评估。首先,我们预测某只股票的一个特征,提前一天,如下所示:
/** Predict one feature of a stock one-day ahead */
private static void predictPriceOneAhead (MultiLayerNetwork net, List<Pair<INDArray, INDArray>> testData, double max, double min, PriceCategory category) {
double[] predicts = new double[testData.size()];
double[] actuals = new double[testData.size()];
for (int i = 0; i < testData.size(); i++) {
predicts[i] = net.rnnTimeStep(testData.get(i).getKey()).getDouble(exampleLength - 1)
* (max - min) + min;
actuals[i] = testData.get(i).getValue().getDouble(0);
}
RegressionEvaluation eval = net.evaluateRegression(iterator);
System.out.println(eval.stats());
System.out.println("Printing predicted and actual values...");
System.out.println("Predict, Actual");
for (int i = 0; i < predicts.length; i++)
System.out.println(predicts[i] + "," + actuals[i]);
System.out.println("Plottig...");
PlotUtil.plot(predicts, actuals, String.valueOf(category));
}
在前面的代码块中,我们对单一类别进行训练,例如,设置以下任意选项:
PriceCategory category = PriceCategory.OPEN; // OPEN: predict open price
PriceCategory category = PriceCategory.CLOSE; // CLOSE: predict close price
PriceCategory category = PriceCategory.LOW; // LOW: predict low price
PriceCategory category = PriceCategory.HIGH; // HIGH: predict high price
我们可以同时对所有类别进行评估,方法是设置PriceCategory category = PriceCategory.***ALL***; // **ALL**: 预测收盘价
。
因此,我们预测了所有股票特征(开盘价、收盘价、最低价、最高价和交易量)的一天后值。对一个类别的评估过程在所有类别中都是相同的。唯一不同的是:我们需要使用PlotUtil
循环遍历多个类别,绘制XY
折线图,如下所示:
/** Predict all the features (open, close, low, high prices and volume) of a stock one-day ahead */
private static void predictAllCategories (MultiLayerNetwork net, List<Pair<INDArray, INDArray>> testData, INDArray max, INDArray min) {
INDArray[] predicts = new INDArray[testData.size()];
INDArray[] actuals = new INDArray[testData.size()];
for(int i = 0; i < testData.size(); i++) {
predicts[i] = net.rnnTimeStep(testData.get(i).getKey()).getRow(exampleLength - 1)
.mul(max.sub(min)).add(min);
actuals[i] = testData.get(i).getValue();
}
System.out.println("Printing predicted and actual values...");
System.out.println("Predict, Actual");
for(int i = 0; i < predicts.length; i++)
System.out.println(predicts[i] + "\t" + actuals[i]);
System.out.println("Plottig...");
RegressionEvaluation eval = net.evaluateRegression(iterator);
System.out.println(eval.stats());
for(int n = 0; n < 5; n++) {
double[] pred = new double[predicts.length];
double[] actu = new double[actuals.length];
for(int i = 0; i < predicts.length; i++) {
pred[i] = predicts[i].getDouble(n);
actu[i] = actuals[i].getDouble(n);
}
String name;
switch(n) {
case 0: name = "Stock OPEN Price"; break;
case 1: name = "Stock CLOSE Price"; break;
case 2: name = "Stock LOW Price"; break;
case 3: name = "Stock HIGH Price"; break;
case 4: name = "Stock VOLUME Amount"; break;
default: throw new NoSuchElementException();
}
PlotUtil.plot(pred, actu, name);
}
}
在前面的代码块中,我们进入函数predictAllCategories()
,查看在所有类别中的评估过程。接下来,我们创建两个数组,predicts
和actuals
,分别用于存储预测结果和实际结果。然后我们遍历测试数据。接着我们执行以下操作:
-
调用函数
net.rnnTimeStep()
,参数为第 i 行的键,并将结果附加到predicts
列表中 -
实际值来自测试数据行i^(th)的值
-
打印预测值和实际值
最后,我们遍历五个类别;我们使用PlotUtil.java
来绘制预测值与实际值之间的XY折线图。请考虑以下内容:
-
最初的两个双精度数组分别命名为
pred
和actu
,其大小与预测的长度相等。 -
遍历
predicts
和actuals
数组,获取每个列表中每个元素的双精度值。 -
每个n的值有四个从 0 到 4 的值。将变量
name
设置为Y列的边缘。 -
调用
PlotUtil
函数来绘制XY折线图。
顺便提一下,PlotUtil.java
类用于绘制预测值与实际值的XY折线图,代码如下:
public static void plot(double[] predicts, double[] actuals, String name) {
double[] index = new double[predicts.length];
for(int i = 0; i < predicts.length; i++)
index[i] = i;
int min = minValue(predicts, actuals);
int max = maxValue(predicts, actuals);
final XYSeriesCollection dataSet = new XYSeriesCollection();
addSeries(dataSet, index, predicts, "Predicted");
addSeries(dataSet, index, actuals, "Actual");
final JFreeChart chart = ChartFactory.createXYLineChart(
"Predicted vs Actual", // chart title
"Index", // x axis label
name, // y axis label
dataSet, // data
PlotOrientation.VERTICAL,
true, // include legend
true, // tooltips
false // urls
);
XYPlot xyPlot = chart.getXYPlot();
// X-axis
final NumberAxis domainAxis = (NumberAxis) xyPlot.getDomainAxis();
domainAxis.setRange((int) index[0], (int) (index[index.length - 1] + 2));
domainAxis.setTickUnit(new NumberTickUnit(20));
domainAxis.setVerticalTickLabels(true);
// Y-axis
final NumberAxis rangeAxis = (NumberAxis) xyPlot.getRangeAxis();
rangeAxis.setRange(min, max);
rangeAxis.setTickUnit(new NumberTickUnit(50));
final ChartPanel panel = new ChartPanel(chart);
final JFrame f = new JFrame();
f.add(panel);
f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
f.pack();
f.setVisible(true);
}
在前面的代码块中,addSeries()
方法用于添加XY系列,代码如下:
private static void addSeries (final XYSeriesCollection dataSet, double[] x, double[] y, final String label){
final XYSeries s = new XYSeries(label);
for(int j = 0; j < x.length; j++ ) s.add(x[j], y[j]);
dataSet.addSeries(s);
}
除了这些,找出我们在前面代码中使用的predicted
和actual
值的最小值和最大值,过程如下:
- **寻找最小值:**首先,我们将变量
min
设置为MAX_VALUE
。然后我们遍历predicted
和actual
数组,如果min
大于任何元素,则将min
重置为当前元素。接着我们取min
值的最接近下界的整数:
private static int minValue (double[] predicts, double[] actuals) {
double min = Integer.MAX_VALUE;
for(int i = 0; i < predicts.length; i++) {
if(min > predicts[i]) min = predicts[i];
if(min > actuals[i]) min = actuals[i];
}
return (int) (min * 0.98);
}
- **查找最大值:**首先,我们将变量
max
设置为MIN_VALUE
。然后,我们循环遍历predicts
和actual
数组,如果max
小于某个元素,则将max
重置为该元素。接着,我们取max
值上限最接近的整数,如下所示:
private static int maxValue (double[] predicts, double[] actuals) {
double max = Integer.MIN_VALUE;
for(int i = 0; i < predicts.length; i++) {
if(max < predicts[i]) max = predicts[i];
if(max < actuals[i]) max = actuals[i];
}
return (int) (max * 1.02);
}
最后,我们使用 addSeries()
方法在绘制图表时将一个系列添加到数据集。然而,由于这是一个回归任务,我们也会展示回归指标,例如 MSE
、MAE
、R2
等。
现在,基于前述计划和变量 category
的值,我们有两种方法来评估模型。如果类别是 ALL
,那么网络将预测所有类别;否则,网络只会处理一个类别。首先,对于单一类别,比如 OPEN
,请查看以下代码:
System.out.println("Evaluating...");
if(category.equals(PriceCategory.OPEN)) {
INDArray max = Nd4j.create(iterator.getMaxArray());
INDArray min = Nd4j.create(iterator.getMinArray());
predictAllCategories(net, test, max, min);
} else {
double max = iterator.getMaxNum(category);
double min = iterator.getMinNum(category);
predictPriceOneAhead(net, test, max, min, category);
}
System.out.println("Done...");
>>>
Evaluating...
Printing predicted and actual values...
Predict, Actual
---------------------------------------
29.175033326034814,35.61000061035156
29.920153324534823,35.70000076293945
30.84457991629533,35.9900016784668
31.954761620513793,36.150001525878906
33.171770076832885,36.79999923706055
34.42622247035372,36.150001525878906
35.63831635695636,36.41999816894531
36.79695794284552,36.04999923706055
37.79222186089784,35.9900016784668
38.45504267616927,35.470001220703125
38.837315702846766,35.66999816894531
然后回归指标将会如下所示(尽管你的结果可能略有不同):
Column MSE MAE RMSE RSE PC R²
-------------------------------------------------------------------------------------------
col_0 3.27134e-02 1.14001e-01 1.80868e-01 5.53901e-01 7.17285e-01 4.46100e-01
最后,我们观察到以下截图,展示了预测价格与实际 OPEN
类别价格的对比:
OPEN
类别的预测与实际价格对比
然后,对于仅 **ALL**
类别,我们运行类似的代码,唯一不同的是使用了 PriceCategory.ALL
,如下所示:
System.out.println("Evaluating...");
if(category.equals(PriceCategory.ALL)) {
INDArray max = Nd4j.create(iterator.getMaxArray());
INDArray min = Nd4j.create(iterator.getMinArray());
predictAllCategories(net, test, max, min);
} else {
double max = iterator.getMaxNum(category);
double min = iterator.getMinNum(category);
predictPriceOneAhead(net, test, max, min, category);
}
System.out.println("Done...");
>>>
Evaluating...
Printing predicted and actual values...
Predict, Actual
------------ ---------------------------------------------------------------
[[27.8678,27.1462,27.0535,27.9431, 9.7079e5]] [[35.6100,35.8900,35.5500,36.1100, 1.5156e6]]
[[28.3925,27.2648,27.2769,28.4423, 1.2579e6]] [[35.7000,35.8100,35.6500,36.1000,8.623e5]]
[[29.0413,27.4402,27.6015,29.1540, 1.6014e6]] [[35.9900,36.1400,35.9000,36.3200, 1.0829e6]]
[[29.9264,27.6811,28.0419,30.1133, 2.0673e6]] [[36.1500,36.7100,36.0700,36.7600, 1.0635e6]]
[[30.9201,27.9385,28.5584,31.2908, 2.5381e6]] [[36.8000,36.5700,36.4600,37.1600, 1.0191e6]]
[[32.0080,28.2469,29.1343,32.6514, 3.0186e6]] [[36.1500,36.2300,35.9300,36.7600, 1.8299e6]]
[[33.1358,28.5809,29.7641,34.1525, 3.4644e6]] [[36.4200,36.5400,36.1800,36.8900,8.774e5]]
[[45.2637,31.2634,39.5828,53.1128, 5.0282e6]] [[50.3600,49.2200,49.1700,50.4500,9.415e5]]
[[45.1651,31.2336,39.5284,52.9815, 4.9879e6]] [[49.1700,49.0100,48.8100,49.4400,9.517e5]]
然后回归指标将会如下所示(尽管你的结果可能略有不同):
Column MSE MAE RMSE RSE PC R²
-------------------------------------------------------------------------------------------------
col_0 4.52917e-02 1.35709e-01 2.12819e-01 7.49715e-01 6.60401e-01 2.50287e-01
col_1 1.52875e-01 3.27669e-01 3.90993e-01 2.54384e+00 6.61151e-01 -1.54384e+00
col_2 8.46744e-02 2.19064e-01 2.90989e-01 1.41381e+00 6.01910e-01 -4.13806e-01
col_3 6.05071e-02 1.93558e-01 2.45982e-01 9.98581e-01 5.95618e-01 1.41977e-03
col_4 2.34488e-02 1.17289e-01 1.53130e-01 9.97561e+00 5.59983e-03 -8.97561e+00
现在看看以下图表,展示了预测价格与实际 ALL
类别价格的对比:
ALL
类别的预测与实际价格对比
从图表中我们可以看到,OPEN
和 HIGH
的价格表现得较为匹配,而 LOW
的表现则稍微较好。遗憾的是,CLOSE
和 VOLUME
的匹配程度非常令人失望(请参见前面的回归结果表)。一个可能的原因是数据不足。另外,使用的超参数完全没有进行超参数调优。不过,大部分超参数是天真地选择的。
常见问题解答 (FAQs)
在这一部分,我们将看到一些可能已经浮现在你脑海中的常见问题。答案可以在附录 A 中找到:
-
我可以将这个项目扩展用于比特币价格预测吗?如果可以,如何做以及在哪里可以获得这样的数据集?
-
如果你将预测值作为输入进行下一次预测,会发生什么?
-
我理解这是一个回归问题,但我如何预测价格是会上涨还是下跌?
-
我想扩展这个应用并部署一个 Web 应用程序。我该怎么做?
-
我想将这个应用扩展,不仅用于价格预测,还用于价格的异常检测。我该怎么做?
-
我可以使用类似的技术进行股票价格推荐吗?
总结
在本章中,我们展示了如何开发一个示范项目,用于预测五个类别的股票价格:OPEN
(开盘价)、CLOSE
(收盘价)、LOW
(最低价)、HIGH
(最高价)和VOLUME
(成交量)。然而,我们的方法不能生成实际的信号。尽管如此,它仍然提供了使用 LSTM 的一些思路。我知道这种方法存在一些严重的缺点。然而,我们并没有使用足够的数据,这可能限制了该模型的性能。
在下一章中,我们将看到如何将深度学习方法应用于视频数据集。我们将描述如何处理和提取来自大量视频片段的特征。然后,我们将通过在多个设备(CPU 和 GPU)上分布式训练,并进行并行运行,使整个流程更加可扩展和高效。
我们将看到如何开发一个深度学习应用的完整示例,该应用能够准确地分类大规模视频数据集,如UCF101
,使用结合 CNN-LSTM 网络与 DL4J。这克服了独立 CNN 或 LSTM
网络的局限性。训练将在 Amazon EC2 GPU 计算集群上进行。最终,这个端到端的项目可以作为视频中人类活动识别的入门指南。
问题的答案
问题 1 的答案: 一些历史比特币数据可以从 Kaggle 下载,例如,www.kaggle.com/mczielinski/bitcoin-historical-data/data
。
下载数据集后,尝试提取最重要的特征,并将数据集转换为时间序列,这样就可以输入到 LSTM 模型中。然后,模型可以通过每个时间步的时间序列进行训练。
问题 2 的答案: 我们的示例项目只计算那些实际股价已给出的股票的股价,而不是第二天的股价。它显示的是实际
和预测
,但是第二天的股价应仅包含预测
。如果我们将预测值作为输入进行下一次预测,就会出现这种情况:
预测与实际价格对比,针对所有
类别,预测值作为下一次预测的输入
问题 3 的答案: 好的,那么这个任务将是一个二分类问题。为了实现这一点,您需要进行两个更改:
-
转换数据集,使其包含两个标签
-
将
IDENTITY
激活函数和RMSE
损失函数替换为交叉熵损失函数
问题 4 的答案: 这是个很好的主意。你可以尝试通过问题 1 和 2 来改进建模。然后,你可以将模型保存到磁盘,以便后续推理。最后,你可以像前面章节建议的那样,将这个模型作为 web 应用提供服务。
回答第 5 题: 在这样的数据集中应用异常检测非常具有挑战性,我不确定是否可行,因为市场波动性非常大。因此,时间序列有时会经历非常多的波动,这是股市的本质。这有助于训练好的模型识别出这种异常波动。
回答第 6 题: 是的,你可以。你可以尝试使用基于机器学习的 ZZAlpha 有限公司股票推荐 2012-2014 数据集。该数据集可以从UCI ML 仓库
下载,网址是archive.ics.uci.edu/ml/datasets/Machine+Learning+based+ZZAlpha+Ltd.+Stock+Recommendations+2012-2014
。仓库中还描述了问题和数据集的详细信息。
第八章:分布式深度学习 – 使用卷积 LSTM 网络进行视频分类
到目前为止,我们已经看到如何在数字和图像上开发基于深度学习的项目。然而,将类似的技术应用于视频片段,例如从视频中进行人类活动识别,并不是一件简单的事。
在本章中,我们将看到如何将深度学习方法应用于视频数据集。我们将描述如何处理和提取大量视频片段的特征。然后,我们将通过在多个设备(CPU 和 GPU)上分配训练,并使其并行运行,从而使整体管道变得可扩展且更快。
我们将看到一个完整的示例,展示如何使用Deeplearning4j(DL4J)开发一个深度学习应用程序,准确地分类大型视频数据集(如 UCF101 数据集)。该应用程序结合了 CNN 和 LSTM 网络,克服了独立 CNN 或 RNN 长短时记忆(LSTM)网络的局限性。
训练将在 Amazon EC2 GPU 计算集群上进行。最终,这个端到端项目可以作为从视频中进行人类活动识别的入门指南。简而言之,我们将在整个端到端项目中学习以下主题:
-
在多个 GPU 上进行分布式深度学习
-
数据集收集与描述
-
使用卷积-LSTM 网络开发视频分类器
-
常见问题解答(FAQ)
在多个 GPU 上进行分布式深度学习
如前所述,我们将看到一个系统的示例,展示如何使用卷积-LSTM 网络对UCF101
数据集中的大量视频片段进行分类。然而,首先我们需要知道如何将训练分配到多个 GPU 上。在之前的章节中,我们讨论了多种先进技术,如网络权重初始化、批量归一化、更快的优化器、适当的激活函数等,这些无疑有助于网络更快地收敛。然而,单机训练一个大型神经网络可能需要数天甚至数周。因此,这种方法不适用于处理大规模数据集。
理论上,神经网络的分布式训练主要有两种方法:数据并行和模型并行。DL4J 依赖于数据并行,称为具有参数平均的分布式深度学习。然而,多媒体分析通常会使事情变得更加复杂,因为从一个视频片段中,我们可以看到成千上万的帧和图像等等。为了避免这个问题,我们将首先在一台机器上的多个设备上分配计算,然后在多个机器的多个设备上进行分布式训练,具体如下:
在多个设备上并行执行 DL4J Java 应用程序
例如,你通常可以在一台机器上使用八个 GPU 训练神经网络,而不必使用跨多台机器的 16 个 GPU,原因很简单——在多机器设置中,网络通信带来的额外延迟。下图显示了如何配置 DL4J 来使用 CUDA 和 cuDNN 控制 GPU 并加速 DNN:
DL4J 使用 CUDA 和 cuDNN 来控制 GPU 并加速 DNN。
在 GPU 上使用 DL4J 进行分布式训练
DL4J 既支持分布式 GPU,也支持本地(即有 CPU 后端的)GPU。它允许用户在单个 GPU 上本地运行,比如 Nvidia Tesla、Titan 或 GeForce GTX,也可以在 Nvidia GRID GPU 上的云端运行。我们还可以在安装了多个 GPU 的 Amazon AWS EC2 GPU 集群上进行训练。
为了在 GPU 上训练神经网络,你需要对根目录下的 pom.xml
文件进行一些更改,例如属性设置和依赖管理,以拉取 DL4J 团队提供的必需依赖。首先,我们处理项目属性,如下所示:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
<jdk.version>1.8</jdk.version>
<nd4j.backend>nd4j-cuda-9.0-platform</nd4j.backend>
<nd4j.version>1.0.0-alpha</nd4j.version>
<dl4j.version>1.0.0-alpha</dl4j.version>
<datavec.version>1.0.0-alpha</datavec.version>
<arbiter.version>1.0.0-alpha</arbiter.version>
<logback.version>1.2.3</logback.version>
</properties>
在前面的 <properties>
标签中,如条目所示,我们将使用 DL4J 1.0.0-alpha 版本,并以 CUDA 9.0 平台作为后端。此外,我们计划使用 Java 8。不过,还定义了一个额外的 logback
属性。
Logback 是流行的 log4j 项目的继任者,承接了 log4j 的发展。Logback 的架构足够通用,能够在不同情况下应用。目前,logback 被划分为三个模块:logback-core、logback-classic 和 logback-access。欲了解更多信息,请参阅 logback.qos.ch/
。
我假设你已经配置好了 CUDA 和 cuDNN,并且相应地设置了路径。一旦我们定义了项目属性,接下来重要的任务是定义与 GPU 相关的依赖,如下所示:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-cuda-9.0-platform</artifactId>
<version>${nd4j.version}</version>
</dependency>
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-cuda-9.0</artifactId>
<version>${dl4j.version}</version>
</dependency>
其中,ND4J 是支持 DL4J 的数值计算引擎,充当其后端,或者说是它工作的不同硬件类型。如果你的系统安装了多个 GPU,你可以在数据并行模式下训练模型,这被称为 多 GPU 数据并行。DL4J 提供了一个简单的封装器,可以实例化,类似于这样:
// ParallelWrapper will take care of load balancing between GPUs. ParallelWrapper wrapper = new ParallelWrapper.Builder(YourExistingModel)
.prefetchBuffer(24)
.workers(8)
.averagingFrequency(1)
.reportScoreAfterAveraging(true)
.useLegacyAveraging(false)
.build();
更具体的示例如下所示:
ParallelWrapper wrapper = new ParallelWrapper.Builder(net)
.prefetchBuffer(8)// DataSets prefetching options. Set to number of actual devices
.workers(8)// set number of workers equal to number of available devices
.averagingFrequency(3)// rare averaging improves performance, but reduce accuracy
.reportScoreAfterAveraging(true) // if set TRUE, on every averaging model's score reported
.build();
ParallelWrapper
将现有模型作为主要参数,并通过将工作者数量保持为等于或大于机器上 GPU 数量的方式进行并行训练。
在 ParallelWrapper
内,初始模型将被复制,每个工作者将训练自己的模型。在 averagingFrequency(X)
中的每 N 次迭代后,所有模型将被平均,并继续训练。现在,要使用此功能,请在 pom.xml
文件中使用以下依赖:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-parallel-wrapper_2.11</artifactId>
<version>${dl4j.version}</version>
</dependency>
对于最新的文档,感兴趣的读者可以查看以下链接:deeplearning4j.org/gpu
。
现在我们已经对如何在多个 GPU 之间分配基于深度学习的训练有了理论理解。在接下来的部分中,我们将很快看到一个动手示例。
使用卷积 – LSTM 进行视频分类
在本节中,我们将开始结合卷积、最大池化、全连接和递归层来对每一帧视频进行分类。具体来说,每个视频包含多个持续多帧的人的活动(尽管它们在帧之间移动),并且可能离开画面。首先,让我们更详细地了解我们将用于此项目的数据集。
UCF101 – 动作识别数据集
UCF101
是一个真实动作视频的动作识别数据集,收集自 YouTube,包含 101 个动作类别,涵盖了 13,320 个视频。视频收集时考虑到了摄像机运动、物体外观与姿势、物体尺度、视角、杂乱背景和光照条件的变化。
101 个动作类别的视频进一步被聚类成 25 个组(每个组中的剪辑具有共同的特征,例如背景和视角),每个组包含四到七个同一动作的视频。共有五个动作类别:人类与物体交互、仅身体动作、人类与人类交互、演奏乐器和体育运动。
关于该数据集的更多事实:
-
UCF101
视频包含不同的帧长度,每个视频剪辑的帧数范围在 100 到 300 帧之间。 -
UCF101
使用XVID
压缩标准(即.avi
格式) -
UCF101
数据集的图片大小为 320 x 240 -
UCF101
数据集包含不同视频文件中的不同类别。
数据集的高层次概览如下:
来自UCF50
数据集的一些随机剪辑(来源:crcv.ucf.edu/data/UCF50.php
)
预处理和特征工程
处理视频文件是一项非常具有挑战性的任务,尤其是当涉及到通过处理和互操作不同的编码来读取视频剪辑时;这是一个繁琐的工作。此外,视频剪辑可能包含失真帧,这在提取高质量特征时是一个障碍。
考虑到这些问题,在本小节中,我们将看到如何通过处理视频编码问题来预处理视频剪辑,并详细描述特征提取过程。
解决编码问题
在 Java 中处理视频数据是一项繁琐的工作(因为我们没有像 Python 那样多的库),尤其是当视频采用旧的.avi
格式时。我在 GitHub 上看到一些博客和示例,使用 JCodec Java 库版本 0.1.5(或 0.2.3)来读取和解析 MP4 格式的UCF101
视频剪辑。
即使 DL4J 也依赖于 datavec-data-codec,它依赖于旧版的 JCodec API,且与新版本不兼容。不幸的是,即使是新版的 JCodec 也无法读取UCF101
视频。因此,我决定使用 FFmpeg 来处理 MP4 格式的视频。这属于 JavaCV 库,之前的章节中已经讨论过了。总之,要使用这个库,只需在pom.xml
文件中添加以下依赖:
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
<version>1.4.1</version>
</dependency>
由于UCF101
是.avi
格式,我在使用 JCodec 或 FFmpeg 库处理时遇到了困难。因此,我手动将视频转换为MP4
格式。
为此,我编写了一个 Python 脚本(名为prepare.py
,可以在本章的代码库中找到)。这个 Python 脚本会下载、解压和解码完整的UCF101
数据集,但根据硬件配置和互联网速度,可能需要几个小时。尽管将 Python 代码放在此书中并不相关,我还是把它放在这里,以便大家能够了解整个过程,因此请看一下这个代码:
import os
ROOT = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(ROOT, 'VideoData')
UCF_RAW = os.path.join(ROOT, 'VideoData', 'UCF101')
UCF_MP4 = os.path.join(ROOT, 'VideoData', 'UCF101_MP4')
if not os.path.isdir(UCF_MP4):
print("Start converting UCF101 dataset to MP4...")
filepaths = []
for label_dir in os.listdir(os.path.join(UCF_RAW)):
for file in os.listdir(os.path.join(UCF_RAW, label_dir)):
filepath = (UCF_RAW, label_dir, file)
filepaths.append(filepath)
files_len = len(filepaths)
os.mkdir(UCF_MP4)
for i, (_, label_dir, file_avi) in enumerate(filepaths):
if file_avi.endswith('.avi'):
file_mp4 = file_avi.rstrip('.avi') + '.mp4'
input_filepath = os.path.join(UCF_RAW, label_dir, file_avi)
output_filepath = os.path.join(UCF_MP4, label_dir, file_mp4)
if not os.path.isfile(output_filepath):
output_dir = os.path.join(UCF_MP4, label_dir)
if not os.path.isdir(output_dir):
os.mkdir(output_dir)
os.system('ffmpeg -v error -i %s -strict -2 %s' % (input_filepath, output_filepath))
print("%d of %d files converted" % (i+1, files_len))
print("Dataset ready")
如代码所示,你只需从crcv.ucf.edu/data/UCF101.php
下载UCF101
数据集,并将其放入VideoData/UCF101
文件夹中。然后,Python 使用内置的 FFmpeg 包将所有.avi
文件转换为.mp4
格式,并在执行$ python3 prepare.py
命令后保存到VideoData/UCF101_MP4
目录。
数据处理工作流
一旦文件转换为 MP4 格式,我们就可以开始提取特征。现在,为了处理UCF101
数据集并提取特征,我编写了另外三个 Java 类,具体如下:
-
UCF101Reader.java
**:**这是视频文件读取、解码和转换为 ND4J 向量的主要入口点。它接收数据集的完整路径并创建神经网络所需的DataSetIterator
。此外,它还生成所有类的列表,并为每个类分配顺序整数。 -
UCF101ReaderIterable.java
:该类读取所有视频片段并使用 JCodec 进行解码。 -
RecordReaderMultiDataSetIterator.java
:这与 DL4J 提供的类似,但这是一个改进版,在新版本的 JCodec 上表现良好。
然后,为了准备训练和测试集,使用了UCF101Reader.getDataSetIterator()
方法。该方法读取每个视频片段,但首先,根据参数和偏移值决定读取多少个示例(视频文件)。这些参数然后传递给UCF101ReaderIterable
。该方法的签名如下:
public UCF101Reader(String dataDirectory) {
this.dataDirectory = dataDirectory.endsWith("/") ? dataDirectory : dataDirectory + "/";
}
public DataSetIterator getDataSetIterator(int startIdx, int nExamples, int miniBatchSize) throws Exception {
ExistingDataSetIterator iter = new ExistingDataSetIterator(createDataSetIterable(startIdx,
nExamples, miniBatchSize));
return new AsyncDataSetIterator(iter,1);
}
在此方法中,ExistingDataSetIterator
作为一个封装器,提供了一个DataSetIterator
接口,用于现有的 Java Iterable<DataSet>
和Iterator<DataSet>
。然后,使用UCF101Reader.UCF101ReaderIterable()
方法创建标签映射(类名到整数索引)和逆标签映射,如下所示:
private UCF101RecordIterable createDataSetIterable(int startIdx, int nExamples, int miniBatchSize)
throws IOException {
return new UCF101RecordIterable(dataDirectory, labelMap(), V_WIDTH, V_HEIGHT,startIdx, nExamples);
}
如您所见,dataDirectory
是 MP4 格式视频的目录,(V_WIDTH
, V_HEIGHT
) 表示视频帧的大小,而 labelMap()
则提供每个视频剪辑的映射:
public static final int V_WIDTH = 320;
public static final int V_HEIGHT = 240;
public static final int V_NFRAMES = 100;
private final String dataDirectory;
private volatile Map<Integer, String> _labelMap;
因此,labelMap()
的签名如下:
public Map<Integer, String> labelMap() throws IOException {
if(_labelMap == null) {
synchronized (this) {
if(_labelMap == null) {
File root = new File(dataDirectory);
_labelMap = Files.list(root.toPath()).map(f -> f.getFileName().toString())
.sorted().collect(HashMap::new, (h, f) -> h.put(h.size(), f), (h, o) -> {});
}
}
}
return _labelMap;
}
然后,UCF101ReaderIterable.iterator()
用于创建网络所需的 DataSet
迭代器。此迭代器传递给 ExistingDataSetIterator
,以符合神经网络 API 所需的形式,如下所示:
// The @NotNull Annotation ensures iterator() method des not return null.
@NotNull
@Override
public Iterator<DataSet> iterator() {
return rowsStream(dataDirectory).skip(this.skip).limit(this.limit).flatMap(p ->
dataSetsStreamFromFile(p.getKey(), p.getValue())).iterator();
}
此外,AsyncDataSetIterator
用于在单独的线程中进行所有数据处理。而 UCF101ReaderIterable.rowStream()
则列出所有数据集文件,并创建文件和相应类标签的序列,如下所示:
public static Stream<Pair<Path, String>> rowsStream(String dataDirectory) {
try {
List<Pair<Path, String>> files = Files.list(Paths.get(dataDirectory)).flatMap(dir -> {
try {
return Files.list(dir).map(p -> Pair.of(p, dir.getFileName().toString()));
} catch (IOException e) {
e.printStackTrace();
return Stream.empty();
}
}).collect(Collectors.toList());
Collections.shuffle(files, new Random(43));
return files.stream();
} catch (IOException e) {
e.printStackTrace();
return Stream.empty();
}
}
接下来,使用 UCF101ReaderIterable.dataSetStreamFromFile()
方法将基础迭代器转换为 Java 流。这只是将迭代器转换为流的技术步骤。因为在 Java 中,通过流更方便地过滤一些元素并限制流中的元素数量。看一下这段代码!
private Stream<DataSet> dataSetsStreamFromFile(Path path, String label) {
return StreamSupport.stream(Spliterators.spliteratorUnknownSize(dataSetsIteratorFromFile(path,
label), Spliterator.ORDERED), false);
}
UCF101ReaderIterable.dataSetIteratorFromFile()
方法接收视频文件路径,然后创建帧读取器(FrameGrab
—JCodec 类)。最后,将帧读取器传递给 RecordReaderMultiDataSetIterator.nextDataSet
,如下所示:
private Iterator<DataSet> dataSetsIteratorFromFile(Path path, String label) {
FileChannelWrapper _in = null;
try {
_in = NIOUtils.readableChannel(path.toFile());
MP4Demuxer d1 = MP4Demuxer.createMP4Demuxer(_in);
SeekableDemuxerTrack videoTrack_ = (SeekableDemuxerTrack)d1.getVideoTrack();
FrameGrab fg = new FrameGrab(videoTrack_, new AVCMP4Adaptor(videoTrack_.getMeta()));
final int framesTotal = videoTrack_.getMeta().getTotalFrames();
return Collections.singleton(recordReaderMultiDataSetIterator.nextDataSet(_in, framesTotal,
fg, labelMapInversed.get(label), labelMap.size())).iterator();
} catch(IOException | JCodecException e) {
e.printStackTrace();
return Collections.emptyIterator();
}
}
在上述代码块中,使用 RecordReaderMultiDataSetIterator.nextDataSet()
方法将每个视频帧转换为与 DL4J 兼容的 DataSet
。DataSet
是从帧生成的特征向量和使用单热编码生成的标签向量的组合。
嗯,这个逻辑基于 DL4J 的 RecordReaderMultiDataSetIterator
类,但必要的支持来自最新的 JCodec API。然后我们使用 UCF101RecordIterable.labelToNdArray()
方法将标签编码为 ND4J 的 INDArray
格式:
private INDArray labelToNdArray(String label) {
int maxTSLength = 1; // frames per dataset
int labelVal = labelMapInversed.get(label);
INDArray arr = Nd4j.*create*(new int[]{1, classesCount}, 'f');
arr.put(0, labelVal, 1f);
return arr;
}
前面提到的工作流程步骤可以在以下图表中描述:
特征提取过程中的数据流
检查视频帧的简易 UI
我开发了一个简单的 UI 应用程序,使用 Java Swing 来测试代码是否正确处理帧。此 UI 读取 MP4 格式的输入视频文件,并像简单的视频播放器一样逐帧显示给读者。该 UI 应用程序名为 JCodecTest.java
。
在 JCodecTest.java
类中,testReadFrame()
方法利用 FrameGrab
类的 getFrameFromFile()
方法(即来自 JavaCV 库),检查每个视频剪辑的帧提取过程是否正常工作。这是方法签名:
private void testReadFrame(Consumer<Picture> consumer) throws IOException, JCodecException {
// Read the clip sequentially one by one
next:
for(Iterator<Pair<Path, String>> iter = rowsStream().iterator(); iter.hasNext(); ) {
Pair<Path, String> pair = iter.next();
Path path = pair.getKey();
pair.getValue();
for(int i = 0; i < 100; i++) {
try {
// Hold video frames as pictures
Picture picture = FrameGrab.getFrameFromFile(path.toFile(), i);
consumer.accept(picture);
} catch (Throwable ex) {
System.out.println(ex.toString() + " frame " + i + " " + path.toString());
continue next;
}
}
System.out.println("OK " + path.toString());
}
}
在上述代码块中,rowsStream()
方法如下所示:
private Stream<Pair<Path, String>> rowsStream() {
try {
return Files.list(Paths.get(dataDirectory)).flatMap(dir -> {
try {
return Files.list(dir).map(p -> Pair.of(p, dir.getFileName().toString()));
} catch (IOException e) {
e.printStackTrace();
return Stream.empty();
}
});
} catch (IOException e) {
e.printStackTrace();
return Stream.empty();
}
}
要查看此方法的有效性,读者可以执行包含 main()
方法的 JCodecTest.java
类,如下所示:
private String dataDirectory = "VideoData/UCF101_MP4/";
public static void main(String[] args) throws IOException, JCodecException {
JCodecTest test = new JCodecTest();
test.testReadFrame(new FxShow());
}
一旦执行,您将体验以下输出,如此屏幕截图所示:
JCodecTest.java 类检查每个视频片段的帧提取是否正常工作
准备训练集和测试集
如前所述,UCF101Reader.java
类用于提取特征并准备训练集和测试集。首先,我们设置并展示 Java 中 MP4 文件的路径,如下所示:
String dataDirectory = "VideoData/UCF101_MP4/";// Paths to video dataset
需要注意的是,使用视频片段训练网络花费了我大约 45 小时,使用的是 EC2 p2.8xlarge
机器。然而,我第二次没有那样的耐心,因此,我只利用了包含 1,112 个视频片段的视频类别来进行训练:
UCF101 数据集目录结构(MP4 版本)
然后我们定义了用于准备训练集和测试集的迷你批次大小。对于我们的情况,我设置了 128,如下所示:
private static int *miniBatchSize* = 128;
private static int *NUM_EXAMPLE* = 10;
UCF101Reader reader = new UCF101Reader(dataDirectory);
我们定义了提取过程从哪个文件开始:
int examplesOffset = 0; // start from N-th file
然后我们决定使用多少个视频片段来训练网络,而 UCF101Reader.fileCount()
方法返回 UCF101_MP4
目录中视频片段的数量。看看这行代码:
int nExamples = Math.*min*(NUM_*EXAMPLE*, reader.fileCount());
接下来,我们计算测试集的起始索引。我们使用 80% 的数据进行训练,其余 20% 用于测试。让我们看看这段代码:
int testStartIdx = examplesOffset + Math.*max*(2, (int) (0.8 * nExamples)); //80% in train, 20% in test
int nTest = nExamples - testStartIdx + examplesOffset;
System.*out*.println("Dataset consist of " + reader.fileCount() + " video clips, use "
+ nExamples + " of them");
现在我们准备训练集。为此,getDataSetIterator()
方法会返回一个 DataSetIterator
,包含所有视频片段,除了那些计划用于测试集的片段。请查看这段代码:
System.*out*.println("Starting training...");
DataSetIterator trainData = reader.getDataSetIterator(examplesOffset, nExamples - nTest, *miniBatchSize*);
然后我们准备测试集。为此,同样 getDataSetIterator()
方法会返回一个 DataSetIterator
,包含所有视频片段,除了那些计划用于测试集的片段。请查看这段代码:
System.out.println("Use " + String.*valueOf*(nTest) + " video clips for test");
DataSetIterator testData = reader.getDataSetIterator(testStartIdx, nExamples, *miniBatchSize*);
太棒了!到目前为止,我们已经能够准备好训练集和测试集。接下来的步骤是创建网络并进行训练。
网络创建与训练
现在,我们开始通过结合卷积层、最大池化层、全连接层(前馈)和递归层(LSTM)来创建网络,对每一帧视频进行分类。首先,我们需要定义一些超参数和必要的实例化,如下所示:
private static MultiLayerConfiguration *conf*;
private static MultiLayerNetwork *net*;
private static String *modelPath* = "bin/ConvLSTM_Model.zip";
private static int *NUM_CLASSES*;
private static int *nTrainEpochs* = 100;
这里,NUM_CLASSES
是 UCF101
数据集中的类别数量,计算方法是数据集根目录下目录的数量:
*NUM_CLASSES* = reader.labelMap().size();
然后,我们通过调用 networkTrainer()
方法开始训练。正如我之前所说,我们将结合卷积层、最大池化层、全连接层(前馈)和递归层(LSTM)来对视频片段的每一帧进行分类。训练数据首先输入到卷积层(层 0),然后经过子采样(层 1),再输入到第二个卷积层(层 2)。接着,第二个卷积层将数据传递到全连接层(层 3)。
需要注意的是,对于第一个 CNN 层,我们有 CNN 预处理器输入宽度/高度为 13 x 18,这反映了 320 x 240 的图片大小。这样,密集层作为 LSTM 层(层 4)的输入层(但你也可以使用常规的 LSTM)。然而,重要的是要注意,密集层的输入大小为 2,340(即 13 * 18 * 10)。
然后,递归反馈连接到 RNN 输出层,该层使用 softmax 激活函数来进行类别的概率分布。我们还使用梯度归一化来处理梯度消失和梯度爆炸问题,最后一层的反向传播使用截断 BPTT。除此之外,我们还使用了一些其他超参数,这些参数不言而喻。以下图显示了该网络设置:
网络架构
现在,从编码的角度来看,networkTrainer()
方法具有以下网络配置:
//Set up network architecture:
conf = new NeuralNetConfiguration.Builder()
.seed(12345)
.l2(0.001) //l2 regularization on all layers
.updater(new Adam(0.001)) // we use Adam as updater
.list()
.layer(0, new ConvolutionLayer.Builder(10, 10)
.nIn(3) //3 channels: RGB
.nOut(30)
.stride(4, 4)
.activation(Activation.RELU)
.weightInit(WeightInit.RELU)
.build()) //Output: (130-10+0)/4+1 = 31 -> 31*31*30
.layer(1, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(3, 3)
.stride(2, 2).build()) //(31-3+0)/2+1 = 15
.layer(2, new ConvolutionLayer.Builder(3, 3)
.nIn(30)
.nOut(10)
.stride(2, 2)
.activation(Activation.RELU)
.weightInit(WeightInit.RELU)
.build()) //Output: (15-3+0)/2+1 = 7 -> 7*7*10 = 490
.layer(3, new DenseLayer.Builder()
.activation(Activation.RELU)
.nIn(2340) // 13 * 18 * 10 = 2340, see CNN layer width x height
.nOut(50)
.weightInit(WeightInit.RELU)
.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
.gradientNormalizationThreshold(10)
.updater(new AdaGrad(0.01))// for faster convergence
.build())
.layer(4, new LSTM.Builder()
.activation(Activation.SOFTSIGN)
.nIn(50)
.nOut(50)
.weightInit(WeightInit.XAVIER)
.updater(new AdaGrad(0.008))
.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
.gradientNormalizationThreshold(10)
.build())
.layer(5, new RnnOutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
.activation(Activation.SOFTMAX)
.nIn(50)
.nOut(NUM_CLASSES)
.weightInit(WeightInit.XAVIER)
.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
.gradientNormalizationThreshold(10)
.build())
.inputPreProcessor(0, new RnnToCnnPreProcessor(UCF101Reader.V_HEIGHT,
UCF101Reader.V_WIDTH, 3))
.inputPreProcessor(3, new CnnToFeedForwardPreProcessor(13, 18, 10))
.inputPreProcessor(4, new FeedForwardToRnnPreProcessor())
.pretrain(false).backprop(true)
.backpropType(BackpropType.TruncatedBPTT)
.tBPTTForwardLength(UCF101Reader.V_NFRAMES / 5)
.tBPTTBackwardLength(UCF101Reader.V_NFRAMES / 5)
.build();
接下来,根据前述的网络配置设置,我们创建并初始化了一个MultiLayerNetwork
,如下所示:
*net* = new MultiLayerNetwork(*conf*);
*net*.init();
*net*.setListeners(new ScoreIterationListener(1));
然后,我们可以观察每一层的参数数量,如下所示:
System.*out*.println("Number of parameters in network: " + *net*.numParams());
for(int i=0; i<*net*.getnLayers(); i++){
System.*out*.println("Layer " + i + " nParams = " + *net*.getLayer(i).numParams());
}
>>>
网络中的参数数量:149599
第零层 nParams = 9030
第一层 nParams = 0
第二层 nParams = 2710
第三层 nParams = 117050
第四层 nParams = 20350
第五层 nParams = 459
最后,我们使用这个训练集开始训练:
for (int i = 0; i < *nTrainEpochs*; i++) {
int j = 0;
while(trainData.hasNext()) {
long start = System.*nanoTime*();
DataSet example = trainData.next();
*net*.fit(example);
System.*out*.println(" Example " + j + " processed in "
+ ((System.*nanoTime*() - start) / 1000000) + " ms");
j++;
}
System.*out*.println("Epoch " + i + " complete");
}
我们使用saveConfigs()
方法保存训练好的网络和视频配置,该方法的签名非常直接,正如你所看到的:
private static void saveConfigs() throws IOException {
Nd4j.*saveBinary*(*net*.params(),new File("bin/videomodel.bin"));
FileUtils.*writeStringToFile*(new File("bin/videoconf.json"), *conf*.toJson());
}
然后,我们使用saveNetwork()
方法保存训练好的模型,以便以后进行推理;其代码如下:
privates tatic void saveNetwork() throws IOException {
File locationToSave = new File(*modelPath*);
boolean saveUpdater = true;
ModelSerializer.*writeModel*(*net*, locationToSave, saveUpdater);
}
性能评估
为了评估网络性能,我编写了evaluateClassificationPerformance()
方法,该方法接受测试集和evalTimeSeries
评估,如下所示:
private static void evaluateClassificationPerformance(MultiLayerNetwork net, int testStartIdx,
int nExamples, DataSetIterator testData) throws Exception {
Evaluation evaluation = new Evaluation(*NUM_CLASSES*);
while(testData.hasNext()) {
DataSet dsTest = testData.next();
INDArray predicted = net.output(dsTest.getFeatureMatrix(), false);
INDArray actual = dsTest.getLabels();
evaluation.evalTimeSeries(actual, predicted);
}
System.*out*.println(evaluation.stats());
}
>>>
Predictions labeled as 0 classified by model as 0: 493 times
Predictions labeled as 0 classified by model as 7: 3 times
Predictions labeled as 1 classified by model as 6: 287 times
Predictions labeled as 1 classified by model as 7: 1 times
Predictions labeled as 2 classified by model as 6: 758 times
Predictions labeled as 2 classified by model as 7: 3 times
Predictions labeled as 3 classified by model as 6: 111 times
Predictions labeled as 3 classified by model as 7: 1 times
Predictions labeled as 4 classified by model as 6: 214 times
Predictions labeled as 4 classified by model as 7: 2 times
Predictions labeled as 5 classified by model as 6: 698 times
Predictions labeled as 5 classified by model as 7: 3 times
Predictions labeled as 6 classified by model as 6: 128 times
Predictions labeled as 6 classified by model as 5: 1 times
Predictions labeled as 7 classified by model as 7: 335 times
Predictions labeled as 8 classified by model as 8: 209 times
Predictions labeled as 8 classified by model as 7: 2 times
==========================Scores===================
# of classes: 9
Accuracy: 0.4000
Precision: 0.39754
Recall: 0.4109
F1 Score: 0.4037
Precision, recall & F1: macro-averaged (equally weighted avg. of 9 classes)
======================================================
现在,为了更清晰地遵循上述步骤,以下是包含这些步骤的main()
方法:
public static void main(String[] args) throws Exception {
String dataDirectory = "VideoData/UCF101_MP4/";
UCF101Reader reader = new UCF101Reader(dataDirectory);
NUM_CLASSES = reader.labelMap().size();
int examplesOffset = 0; // start from N-th file
int nExamples = Math.min(NUM_EXAMPLE, reader.fileCount()); // use only "nExamples" for train/test
int testStartIdx = examplesOffset + Math.max(2, (int) (0.9 * nExamples)); //90% train, 10% in test
int nTest = nExamples - testStartIdx + examplesOffset;
System.out.println("Dataset consist of " + reader.fileCount() + " images, use "
+ nExamples + " of them");
//Conduct learning
System.out.println("Starting training...");
DataSetIterator trainData = reader.getDataSetIterator(examplesOffset,
nExamples - nTest, miniBatchSize);
networkTrainer(reader, trainData);
//Save network and video configuration
saveConfigs();
//Save the trained model
saveNetwork();
//Evaluate classification performance:
System.out.println("Use " + String.valueOf(nTest) + " images for validation");
DataSetIterator testData = reader.getDataSetIterator(testStartIdx, nExamples, miniBatchSize);
evaluateClassificationPerformance(net,testStartIdx,nTest, testData);
}
我们尚未达到更高的准确率。可能有许多原因导致这种情况。例如,我们只使用了少数类别(即仅使用了 9 个类别中的 9 个)。因此,我们的模型没有足够的训练数据来学习。此外,大多数超参数设置过于简单。
在 AWS 深度学习 AMI 9.0 上的分布式训练
到目前为止,我们已经看到如何在单个 GPU 上进行训练和推理。然而,为了以并行和分布式的方式加速训练,拥有一台或服务器上有多个 GPU 是一个可行的选择。实现这一点的简单方法是使用 AMAZON EC2 GPU 计算实例。
例如,P2 非常适合用于分布式深度学习框架,这些框架预安装了最新的深度学习框架(MXNet、TensorFlow、Caffe、Caffe2、PyTorch、Keras、Chainer、Theano 和 CNTK)的二进制文件,并分别在虚拟环境中运行。
更大的优势在于,它们已经完全配置了 NVidia CUDA 和 cuDNN。有兴趣的读者可以查看aws.amazon.com/ec2/instance-types/p2/
。以下是 P2 实例配置和定价的简要概览:
P2 实例详情
对于这个项目,我决定使用p2.8xlarge
。你也可以创建它,但请确保你已经提交了至少一个实例的限制增加请求,这可能需要三天时间。如果你不知道怎么做,直接在 AWS 上创建一个帐户并完成验证;然后进入 EC2 管理控制台。在左侧面板中,点击“限制”标签,它会带你到一个页面,在这里你可以通过点击“请求限制增加”链接来提交增加限制的请求。
无论如何,我假设你知道这些简单的内容,所以我将继续创建一个p2.8xlarge
类型的实例。在左侧面板中,点击实例菜单,它应该会带你进入以下页面:
选择一个深度学习 AMI
一个简单的选项是创建一个已经配置了 CUDA 和 cuDNN 的深度学习 AMI(Ubuntu)版本 9.0,该版本可以在八个 GPU 上使用。另一个好处是它具有 32 个计算核心和 488GB 的内存;这对我们的数据集也足够。因此,除了使用只有九个类别的视频片段外,我们还可以使用完整的数据集进行训练。
然而,注意,由于我们将使用基于 JVM 的 DL4J,因此必须安装并配置 Java(需要设置JAVA_HOME
)。首先,通过 SSH 或使用 SFTP 客户端连接到您的实例。然后,在 Ubuntu 上,我们可以通过以下几个命令来完成,具体如下面所示:
$ sudo apt-get install python-software-properties
$ sudo apt-get update
$ sudo add-apt-repository ppa:webupd8team/java
$ sudo apt-get update
然后,根据您要安装的版本,执行以下其中一个命令:
$ sudo apt-get install oracle-java8-installer
安装后,别忘了设置 Java home。只需应用以下命令(假设 Java 已安装在/usr/lib/jvm/java-8-oracle
):
$ echo "export JAVA_HOME=/usr/lib/jvm/java-8-oracle" >> ~/.bashrc
$ echo "export PATH=$PATH:$JAVA_HOME/bin" >> ~/.bashrc
$ source ~/.bashrc
现在让我们来看一下Java_HOME
,如下所示:
$ echo $JAVA_HOME
现在,您应该在终端看到以下结果:
/usr/lib/jvm/java-8-oracle
最后,我们通过执行以下命令来检查 Java 是否已成功安装(您可能会看到最新版本!):
$ java -version
>>>
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b15)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b15, mixed mode)
太棒了!我们已经能够在我们的实例上设置并配置 Java 了。接下来,让我们通过在终端发出nvidia-smi
命令,查看 GPU 驱动是否已配置:
显示 Tesla K80 GPU 的 p2.8xlarge 实例
如我们所见,最初没有使用 GPU,但它清楚地指出,在该实例上已安装并配置了八个 Tesla K80 GPU。现在我们的 GPU 和机器已经完全配置好,我们可以专注于项目。我们将使用与之前差不多的代码,但做一些最小的修改。我们需要进行的第一个更改是在 main()
方法的开头添加以下代码:
CudaEnvironment.getInstance().getConfiguration()
.allowMultiGPU(true) // key option enabled
.setMaximumDeviceCache(2L * 1024L * 1024L * 1024L) // large cache
.allowCrossDeviceAccess(true); // cross-device access for faster model averaging over a piece
然后我们使用 ParallelWrapper 在八个 GPU 上进行训练,它负责 GPU 之间的负载均衡。网络构建与之前相同,如下所示:
*net* = new MultiLayerNetwork(*conf*);
*net*.init();
ParallelWrapper wrapper = new ParallelWrapper.Builder(net)
.prefetchBuffer(8)// DataSets prefetching options. Set this with respect to number of devices
.workers(8)// set number of workers equal to number of available devices -i.e. 8 for p2.8xlarge
.averagingFrequency(3)// rare averaging improves performance, but might reduce model accuracy
.reportScoreAfterAveraging(true) // if set TRUE, on every avg. model score will be reported
.build();
现在我们通过拟合完整的测试集来开始训练,如下所示:
for (int i = 0; i < nTrainEpochs; i++) {
wrapper.fit(trainData);
System.out.println("Epoch " + i + " complete");
}
这就是我们需要做的全部。然而,请确保在 VideoClassificationExample.java
文件的开头导入以下内容,以便使用 CudaEnvironment
和 ParallelWrapper
,如下面所示:
import org.nd4j.jita.conf.CudaEnvironment;
import org.deeplearning4j.parallelism.ParallelWrapper;
尽管如此,我仍然认为展示 main()
方法和 networkTrainer()
方法的代码会很有帮助。此外,为了避免可能的混淆,我编写了两个 Java 类,分别用于单个和多个 GPU:
-
VideoClassificationExample.java
: 用于单个 GPU 或 CPU -
VideoClassificationExample_MUltipleGPU.java
:用于 AWS EC2 实例上的多个 GPU
因此,后者类有一个方法,networkTrainer()
,用于创建一个用于分布式训练的网络,如下所示:
private static void networkTrainer(UCF101Reader reader, DataSetIterator trainData) throws Exception {
//Set up network architecture:
conf = new NeuralNetConfiguration.Builder()
.seed(12345)
.l2(0.001) //l2 regularization on all layers
.updater(new Adam(0.001))
.list()
.layer(0, new ConvolutionLayer.Builder(10, 10)
.nIn(3) //3 channels: RGB
.nOut(30)
.stride(4, 4)
.activation(Activation.RELU)
.weightInit(WeightInit.RELU)
.build()) //Output: (130-10+0)/4+1 = 31 -> 31*31*30
.layer(1, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(3, 3)
.stride(2, 2).build()) //(31-3+0)/2+1 = 15
.layer(2, new ConvolutionLayer.Builder(3, 3)
.nIn(30)
.nOut(10)
.stride(2, 2)
.activation(Activation.RELU)
.weightInit(WeightInit.RELU)
.build()) //Output: (15-3+0)/2+1 = 7 -> 7*7*10 = 490
.layer(3, new DenseLayer.Builder()
.activation(Activation.RELU)
.nIn(2340) // 13 * 18 * 10 = 2340, see CNN layer width x height
.nOut(50)
.weightInit(WeightInit.RELU)
.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
.gradientNormalizationThreshold(10)
.updater(new AdaGrad(0.01))
.build())
.layer(4, new LSTM.Builder()
.activation(Activation.SOFTSIGN)
.nIn(50)
.nOut(50)
.weightInit(WeightInit.XAVIER)
.updater(new AdaGrad(0.008))
.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
.gradientNormalizationThreshold(10)
.build())
.layer(5, new RnnOutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
.activation(Activation.SOFTMAX)
.nIn(50)
.nOut(NUM_CLASSES)
.weightInit(WeightInit.XAVIER)
.gradientNormalization(GradientNormalization.ClipElementWiseAbsoluteValue)
.gradientNormalizationThreshold(10)
.build())
.inputPreProcessor(0, new RnnToCnnPreProcessor(UCF101Reader.V_HEIGHT,
UCF101Reader.V_WIDTH, 3))
.inputPreProcessor(3, new CnnToFeedForwardPreProcessor(13, 18, 10))
.inputPreProcessor(4, new FeedForwardToRnnPreProcessor())
.pretrain(false).backprop(true)
.backpropType(BackpropType.TruncatedBPTT)
.tBPTTForwardLength(UCF101Reader.V_NFRAMES / 5)
.tBPTTBackwardLength(UCF101Reader.V_NFRAMES / 5)
.build();
net = new MultiLayerNetwork(conf);
net.init();
net.setListeners(new ScoreIterationListener(1));
System.out.println("Number of parameters in network: " + net.numParams());
for( int i=0; i<net.getnLayers(); i++ ){
System.out.println("Layer " + i + " nParams = " + net.getLayer(i).numParams());
}
// ParallelWrapper will take care of load balancing between GPUs.
ParallelWrapper wrapper = new ParallelWrapper.Builder(net)
.prefetchBuffer(8)// DataSets prefetching options. Set value with respect to number of devices
.workers(8)// set number of workers equal to number of available devices
.averagingFrequency(3)// rare avg improves performance, but might reduce accuracy
.reportScoreAfterAveraging(true) // if set TRUE, on every avg. model score will be reported
.build();
for (int i = 0; i < nTrainEpochs; i++) {
wrapper.fit(trainData);
System.out.println("Epoch " + i + " complete");
}
}
现在 main()
方法如下所示:
public static void main(String[] args) throws Exception {
// Workaround for CUDA backend initialization
CudaEnvironment.getInstance()
.getConfiguration()
.allowMultiGPU(true)
.setMaximumDeviceCache(2L * 1024L * 1024L * 1024L)
.allowCrossDeviceAccess(true);
String dataDirectory = "/home/ubuntu/UCF101_MP4/";
UCF101Reader reader = new UCF101Reader(dataDirectory);
NUM_CLASSES = reader.labelMap().size();
int examplesOffset = 0; // start from N-th file
int nExamples = Math.min(NUM_EXAMPLE, reader.fileCount()); // use only "nExamples" for train/test
int testStartIdx = examplesOffset + Math.max(2, (int) (0.9 * nExamples)); //90% train, 10% in test
int nTest = nExamples - testStartIdx + examplesOffset;
System.out.println("Dataset consist of " + reader.fileCount() + " images, use "
+ nExamples + " of them");
//Conduct learning
System.out.println("Starting training...");
DataSetIterator trainData = reader.getDataSetIterator(examplesOffset,
nExamples - nTest, miniBatchSize);
networkTrainer(reader, trainData);
//Save network and video configuration
saveConfigs();
//Save the trained model
saveNetwork();
//Evaluate classification performance:
System.out.println("Use " + String.valueOf(nTest) + " images for validation");
DataSetIterator testData = reader.getDataSetIterator(testStartIdx, nExamples, 10);
evaluateClassificationPerformance(net,testStartIdx,nTest, testData);
}
这是我们在执行 VideoClassificationExample_MUltipleGPU.java
类之前所需要的一切。还应该注意,从终端运行独立的 Java 类并不是一个好主意。因此,我建议创建一个 fat .jar
文件并包含所有依赖项。为此,使用任何 SFTP 客户端将代码移到实例上。然后安装 maven
:
$sudo apt-get install maven
一旦安装了 maven,我们可以开始创建包含所有依赖项的 fat JAR 文件,如下所示:
$ sudo mvn clean install
然后,过了一段时间,一个 fat JAR 文件将在目标目录中生成。我们移动到该目录并执行 JAR 文件,如下所示:
$ cd target/
$ java -Xmx30g -jar VideoClassifier-0.0.1-SNAPSHOT-jar-with-dependencies.jar
此时,请确保您已正确设置所有路径并具有必要的权限。好吧,我假设一切都设置好了。那么,执行前面的命令将迫使 DL4J 选择 BLAS、CUDA 和 cuDNN,并执行训练和其他步骤。大致上,您应该在终端上看到如下日志:
ubuntu@ip-172-31-40-27:~/JavaDeepLearningDL4J/target$ java -Xmx30g -jar VideoClassifier-0.0.1-SNAPSHOT-jar-with-dependencies.jar
前面的命令应该开始训练,您应该在终端/命令行中观察到以下日志:
Dataset consist of 1112 images, use 20 of them
Starting training...
18:57:34.815 [main] INFO org.nd4j.linalg.factory.Nd4jBackend - Loaded [JCublasBackend] backend
18:57:34.844 [main] WARN org.reflections.Reflections - given scan urls are empty. set urls in the configuration
18:57:47.447 [main] INFO org.nd4j.nativeblas.NativeOpsHolder - Number of threads used for NativeOps: 32
18:57:51.433 [main] DEBUG org.nd4j.jita.concurrency.CudaAffinityManager - Manually mapping thread [28] to device [0], out of [8] devices...
18:57:51.441 [main] INFO org.nd4j.nativeblas.Nd4jBlas - Number of threads used for BLAS: 0
18:57:51.447 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Backend used: [CUDA]; OS: [Linux]
18:57:51.447 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Cores: [32]; Memory: [26.7GB];
18:57:51.447 [main] INFO org.nd4j.linalg.api.ops.executioner.DefaultOpExecutioner - Blas vendor: [CUBLAS]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.452 [main] INFO org.nd4j.linalg.jcublas.ops.executioner.CudaExecutioner - Device opName: [Tesla K80]; CC: [3.7]; Total/free memory: [11995578368]
18:57:51.697 [main] DEBUG org.nd4j.jita.handler.impl.CudaZeroHandler - Creating bucketID: 1
18:57:51.706 [main] DEBUG org.nd4j.jita.handler.impl.CudaZeroHandler - Creating bucketID: 2
18:57:51.711 [main] DEBUG org.reflections.Reflections - going to scan these urls:
jar:file:/home/ubuntu/JavaDeepLearningDL4J/target/VideoClassifier-0.0.1-SNAPSHOT-jar-with-dependencies.jar!/.
...
然后训练应该开始。现在让我们检查一下 DL4J 是否正在利用所有的 GPU。要确认这一点,再次在终端执行 nvidia-smi
命令,它应该显示如下内容:
显示在 p2.8 xlarge 实例上的 Tesla K80 GPU 的资源使用情况
由于视频片段较多,训练需要几个小时。训练完成后,代码应提供相似或稍微更好的分类准确率。
常见问题解答(FAQs)
现在我们已经解决了视频分类问题,但准确率较低。这个问题及整体深度学习现象还有其他实际方面需要考虑。在本节中,我们将看到一些可能出现在你脑海中的常见问题。答案可以在附录 A 中找到。
-
我的机器上安装了多块 GPU(例如,两个),但 DL4J 只使用一个。我该如何解决这个问题?
-
我已经在 AWS 上配置了一个 p2.8 xlarge EC2 GPU 计算实例。然而,在安装和配置 CUDA 和 cuDNN 时,显示磁盘空间不足。如何解决这个问题?
-
我了解如何在 AWS EC2 AMI 实例上进行分布式训练。然而,我的机器有一块低端 GPU,且经常出现 GPU OOP 错误。我该如何解决这个问题?
-
我可以将这个应用程序视为从视频中进行人体活动识别吗?
总结
在本章中,我们开发了一个完整的深度学习应用程序,利用 UCF101
数据集对大量视频数据集进行分类。我们应用了结合 CNN 和 LSTM 网络的 DL4J,克服了单独使用 CNN 或 RNN LSTM 网络的局限性。
最后,我们展示了如何在多个设备(CPU 和 GPU)上并行和分布式地进行训练。总的来说,这个端到端的项目可以作为从视频中进行人体活动识别的入门教程。虽然我们在训练后没有取得高准确率,但在具有完整视频数据集和超参数调优的网络中,准确率肯定会提高。
下一章将介绍如何设计一个由批评和奖励驱动的机器学习系统。我们将看到如何使用 DL4J、RL4J 和神经网络 Q 学习来开发一个演示版 GridWorld 游戏,其中 Q 学习起到 Q 函数的作用。我们将从强化学习及其理论背景开始,帮助更容易理解这些概念。
问题答案
问题 1 的答案: 这意味着训练没有分布式进行,也就是说系统强制你使用单个 GPU。现在,为了解决这个问题,只需在 main()
方法的开头添加以下代码:
CudaEnvironment.getInstance().getConfiguration().allowMultiGPU(true);
问题 2 的答案: 这个问题显然与 AWS EC2 相关。不过,我会提供一个简短的解释。如果你查看默认的启动设备,它只分配了 7.7 GB 的空间,但大约 85% 的空间被分配给了 udev 设备,如下所示:
显示 p2.8xlarge 实例上的存储
为了消除这个问题,在创建实例时,你可以在启动设备中指定足够的存储,如下所示:
增加 p2.8xlarge 实例默认启动设备上的存储
问题 3 的答案:好吧,如果是这种情况,你可能可以在 CPU 上进行训练,而不是 GPU。然而,如果必须在 GPU 上进行训练,我建议使用 HALF
数据类型。
如果你的机器和代码能够支持使用半精度数学运算,你可以将其作为数据类型启用。这将确保 DL4J 使用的 GPU 内存减少一半。要启用此功能,只需将以下代码行添加到 main()
方法的开头(即使是在多 GPU 允许的代码之前):
DataTypeUtil.setDTypeForContext(DataBuffer.Type.HALF);
使用 HALF
数据类型将强制你的网络压缩精度,低于 float
或 double
类型。然而,调优网络可能会更困难。
问题 4 的答案:我们尚未成功达到良好的准确率。这是本章端到端的主要目标。因此,在使用完整的视频数据集进行训练并调优超参数后,准确率肯定会提高。
最后,老实说,如果你想将一个应用程序投入生产,Java 可能不是完美的选择。我之所以这么说,是因为许多从视频片段提取高级特征的库都是用 Python 编写的,而且那些库也可以使用。