原文:
annas-archive.org/md5/ddd311fd2802b8b875714761e8af3c7e
译者:飞龙
第五章:卷积神经网络
在第二章《深度学习基础》中,我们学习了关于卷积神经网络(CNN)的一个非常高层次的概述。在这一章,我们将深入了解这种类型的 CNN,更加详细地探讨它们各层的可能实现,并且我们将开始通过 DeepLearning4j 框架动手实现 CNN。本章最后也会涉及到使用 Apache Spark 的示例。CNN 的训练与评估策略将在第七章《用 Spark 训练神经网络》、第八章《监控与调试神经网络训练》以及第九章《解释神经网络输出》中讲解。在不同层的描述中,我尽量减少了数学概念和公式的使用,以便让没有数学或数据科学背景的开发者和数据分析师能够更容易地阅读和理解。因此,你会看到更多关于 Scala 代码实现的内容。
本章涵盖以下主题:
-
卷积层
-
池化层
-
全连接层
-
权重
-
GoogleNet Inception V3 模型
-
动手实践 CNN 与 Spark
卷积层
由于卷积神经网络(CNN)部分已经在第二章《深度学习基础》中讲解过,你应该知道 CNN 通常在哪些场景下使用。在该章节中,我们提到过同一 CNN 的每一层可以有不同的实现方式。本章的前三部分详细描述了可能的层实现,从卷积层开始。但首先,让我们回顾一下 CNN 如何感知图像的过程。CNN 将图像视为体积(3D 物体),而非二维画布(仅有宽度和高度)。原因如下:数字彩色图像采用红-蓝-绿(RGB)编码,正是这些颜色的混合产生了人眼能够感知的光谱。这也意味着 CNN 将图像作为三个颜色层分别处理,层与层之间是叠加的。这转化为以矩形框的形式接收彩色图像,宽度和高度可以用像素来度量,且有三个层(称为通道)的深度,每个通道对应一个 RGB 颜色。简而言之,输入图像被 CNN 看作一个多维数组。我们来举个实际的例子。如果我们考虑一个 480 x 480 的图像,网络会将其看作一个 480 x 480 x 3 的数组,其中每个元素的值在 0 到 255 之间。这些值描述了图像某一点的像素强度。这里是人眼与机器之间的主要区别:这些数组值是机器唯一的输入。接收到这些数值作为输入的计算机输出将是其他数字,描述图像属于某个类别的概率。CNN 的第一层总是卷积层。假设输入是一个 32 x 32 x 3 的像素值数组,我们试着想象一个具体的可视化,清楚简洁地解释卷积层的作用。我们可以将其想象为一个手电筒照射在图像的左上部分。
下图展示了手电筒的照射范围,覆盖了一个 5 x 5 的区域:
图 5.1:5 x 5 滤波器
然后,虚拟的滤光器开始滑动覆盖图像的其他区域。适当的术语是滤波器(或神经元或卷积核),而被照亮的图像区域被称为感受野。用数学术语来说,滤波器是一个数字数组(称为权重或参数)。滤波器的深度必须与输入的深度匹配。参考本节的示例,我们有一个维度为 5 x 5 x 3 的滤波器。滤波器覆盖的第一个位置(如前面图示所示)是输入图像的左上角。当滤波器在图像上滑动或进行卷积(来自拉丁动词convolvere,意为包裹)时,它会将其值与原始图像像素值相乘。所有的乘法结果会相加(在我们的示例中,总共有 75 次乘法)。最终的结果是一个数字,表示滤波器仅位于输入图像的左上角时的值。这个过程会在输入图像的每个位置重复。与第一次一样,每个唯一的位置都会产生一个数字。一旦滤波器完成在图像所有位置上的滑动过程,结果将是一个 28 x 28 x 1(假设输入图像为 32 x 32,5 x 5 的滤波器可以适应 784 个不同的位置)的数字数组,称为激活图(或特征图)。
池化层
在实践中(正如你将在本章的代码示例以及第七章《使用 Spark 训练神经网络》中看到的那样),通常会在 CNN 模型的连续卷积层之间定期插入池化层。这种层的作用是逐步减少网络的参数数量(这意味着显著降低计算成本)。事实上,空间池化(在文献中也被称为下采样或子采样)是一种减少每个特征图维度的技术,同时保留最重要的信息。存在不同类型的空间池化。最常用的是最大池化、平均池化、求和池化和 L2 范数池化。
以最大池化为例,这种技术需要定义一个空间邻域(通常是一个 2 × 2 的窗口);然后从经过修正的特征图中取出该窗口内的最大元素。平均池化策略则要求取窗口内所有元素的平均值或和。一些论文和实际应用表明,最大池化已经证明能够比其他空间池化技术产生更好的结果。
下图展示了最大池化操作的一个示例(这里使用了一个 2 × 2 的窗口):
图 5.2:使用 2 × 2 窗口的最大池化操作
全连接层
全连接层是卷积神经网络(CNN)的最后一层。全连接层在给定输入数据的情况下,输出一个多维向量。输出向量的维度与特定问题的类别数相匹配。
本章及本书中的其他章节展示了一些 CNN 实现和训练的例子,用于数字分类。在这些情况下,输出向量的维度为 10(可能的数字是 0 到 9)。10 维输出向量中的每个数字表示某个类别(数字)的概率。以下是一个用于数字分类推断的输出向量:
[0 0 0 .1 .75 .1 .05 0 0 0]
我们如何解读这些值?网络告诉我们,它认为输入图像是一个四,概率为 75%(在本例中是最高的),同时图像是三的概率为 10%,图像是五的概率为 10%,图像是六的概率为 5%。全连接层会查看同一网络中前一层的输出,并确定哪些特征与某一特定类别最相关。
不仅仅是在数字分类中发生这种情况。在图像分类的一个通用使用案例中,如果一个使用动物图像训练的模型预测输入图像是例如马,它将在表示特定高级特征的激活图中具有较高的值,比如四条腿或尾巴,仅举几个例子。类似地,如果该模型预测图像是另一种动物,比如鱼,它将在表示特定高级特征的激活图中具有较高的值,比如鳍或鳃。我们可以说,全连接层会查看与某一特定类别最相关的高级特征,并拥有特定的权重:这确保了在计算了权重与前一层的乘积后,能够获得每个不同类别的正确概率。
权重
CNN 在卷积层中共享权重。这意味着在一层中的每个感受野使用相同的滤波器,并且这些复制的单元共享相同的参数(权重向量和偏置),并形成一个特征图。
以下图示展示了一个网络中属于同一特征图的三个隐藏单元:
图 5.3:隐藏单元
前述图中较深灰色的权重是共享且相同的。这种复制使得无论特征在视觉场景中的位置如何,都能够进行特征检测。权重共享的另一个结果是:学习过程的效率通过大幅减少需要学习的自由参数数量得到显著提高。
GoogleNet Inception V3 模型
作为卷积神经网络(CNN)的具体实现,在这一部分,我将介绍 Google 的 GoogleNet 架构(ai.google/research/pubs/pub43022
)及其 Inception 层。该架构已在ImageNet 大规模视觉识别挑战赛 2014(ILSVRC2014,www.image-net.org/challenges/LSVRC/2014/
)上展示。无需多说,它赢得了那场比赛。这个实现的显著特点如下:增加了深度和宽度,同时保持了恒定的计算预算。提高计算资源的利用率是网络设计的一部分。
下面的图表总结了在该上下文中提出的网络实现的所有层:
图 5.4:GoogleNet 层
该网络有 22 层参数(不包括池化层;如果包括池化层,总共有 27 层),其参数数量几乎是过去几届同一比赛获胜架构的 12 分之一。这个网络的设计考虑了计算效率和实用性,使得推理过程也能够在有限资源的单个设备上运行,尤其是那些内存占用较低的设备。所有卷积层都使用修正线性单元(ReLU)激活函数。感受野的大小为 224 × 224,使用的是 RGB 颜色空间(均值为零)。通过前面的图表中的表格来看,#3 × 3和**#5 × 5**的减少数量是位于 3 × 3 和 5 × 5 卷积层之前的 1 × 1 滤波器数量。这些减少层的激活函数同样是 ReLU。
在user-images.githubusercontent.com/32988039/33234276-86fa05fc-d1e9-11e7-941e-b3e62771716f.png
中的示意图展示了网络的结构。
在这种架构中,来自前一层的每个单元都对应输入图像的一个区域——这些单元被分组到滤波器组中。在接近输入的层中,相关的单元集中在局部区域。这导致许多聚集在单一区域的簇,因此可以通过下一个层中的 1 × 1 卷积来覆盖它们。然而,也可能有较少的、更空间分散的簇,由较大块的卷积覆盖,而且随着区域增大,块的数量会减少。为了防止这些补丁对齐问题,inception 架构的实现被限制为只能使用 1 × 1、3 × 3 和 5 × 5 滤波器。建议的架构是将多个层的输出滤波器组聚合为一个单一的输出向量,这个向量代表了下一阶段的输入。此外,在每个阶段并行添加一个替代的池化路径可能会有进一步的有益效果:
图 5.5:简单版本的 inception 模块
从前面的图中可以看出,就计算成本而言,对于具有大量滤波器的层,5 × 5 卷积可能太昂贵(即使卷积数量不多)。当然,随着添加更多池化单元,这个问题会变得更严重,因为输出滤波器的数量等于前一阶段的滤波器数量。显然,将池化层的输出与卷积层的输出合并,可能不可避免地导致越来越多的输出从一个阶段传递到下一个阶段。因此,提出了 inception 架构的第二个、更具计算效率的想法。这个新想法是在计算需求可能增加过多的地方进行维度降低。但需要注意的是:低维嵌入可能包含大量关于大图块的信息,但它们以压缩形式表示这些信息,这使得处理起来变得困难。因此,一个好的折衷方法是保持表示尽可能稀疏,同时仅在真正需要大量聚合信号时,才对信号进行压缩。为此,在进行任何昂贵的 3 × 3 和 5 × 5 卷积之前,使用 1 × 1 卷积 来进行维度降低。
下图展示了考虑到上述问题后的新模块:
图 5.6:具有维度减少的 inception 模块
使用 Spark 实现的 CNN
在本章前面的部分,我们已经讨论了 CNN 的理论和 GoogleNet 架构。如果这是你第一次阅读这些概念,可能会对实现 CNN 模型、训练和评估时 Scala 代码的复杂性感到困惑。通过采用像 DL4J 这样的高层次框架,你将发现它自带了许多功能,且实现过程比预期的更简单。
在本节中,我们将通过使用 DL4J 和 Spark 框架,探索 CNN 配置和训练的真实示例。所使用的训练数据来自MNIST
数据库(yann.lecun.com/exdb/mnist/
)。它包含手写数字的图像,每张图像都由一个整数进行标记。该数据库用于评估 ML 和 DL 算法的性能。它包含 60,000 个训练样本和 10,000 个测试样本。训练集用于教算法预测正确的标签,即整数,而测试集则用于检查训练后的网络在进行预测时的准确性。
对于我们的示例,我们下载并在本地解压 MNIST
数据。会创建一个名为 mnist_png
的目录,它包含两个子目录:training
,其中包含训练数据,以及 testing
,其中包含评估数据。
我们先只使用 DL4J(稍后会将 Spark 添加到堆栈中)。我们需要做的第一件事是将训练数据向量化。我们使用 ImageRecordReader
(deeplearning4j.org/datavecdoc/org/datavec/image/recordreader/ImageRecordReader.html
) 作为读取器,因为训练数据是图像,而使用 RecordReaderDataSetIterator
(javadox.com/org.deeplearning4j/deeplearning4j-core/0.4-rc3.6/org/deeplearning4j/datasets/canova/RecordReaderDataSetIterator.html
) 来遍历数据集,方法如下:
val trainData = new ClassPathResource("/mnist_png/training").getFile
val trainSplit = new FileSplit(trainData, NativeImageLoader.ALLOWED_FORMATS, randNumGen)
val labelMaker = new ParentPathLabelGenerator(); // parent path as the image label
val trainRR = new ImageRecordReader(height, width, channels, labelMaker)
trainRR.initialize(trainSplit)
val trainIter = new RecordReaderDataSetIterator(trainRR, batchSize, 1, outputNum)
让我们对像素值进行最小-最大缩放,将其从 0-255 缩放到 0-1,方法如下:
val scaler = new ImagePreProcessingScaler(0, 1)
scaler.fit(trainIter)
trainIter.setPreProcessor(scaler)
对测试数据也需要进行相同的向量化处理。
让我们按照以下方式配置网络:
val channels = 1
val outputNum = 10
val conf = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(iterations)
.regularization(true)
.l2(0.0005)
.learningRate(.01)
.weightInit(WeightInit.XAVIER)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.updater(Updater.NESTEROVS)
.momentum(0.9)
.list
.layer(0, new ConvolutionLayer.Builder(5, 5)
.nIn(channels)
.stride(1, 1)
.nOut(20)
.activation(Activation.IDENTITY)
.build)
.layer(1, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build)
.layer(2, new ConvolutionLayer.Builder(5, 5)
.stride(1, 1)
.nOut(50)
.activation(Activation.IDENTITY)
.build)
.layer(3, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build)
.layer(4, new DenseLayer.Builder()
.activation(Activation.RELU)
.nOut(500)
.build)
.layer(5, new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.nOut(outputNum)
.activation(Activation.SOFTMAX).build)
.setInputType(InputType.convolutionalFlat(28, 28, 1))
.backprop(true).pretrain(false).build
然后,可以使用生成的 MultiLayerConfiguration
对象 (deeplearning4j.org/doc/org/deeplearning4j/nn/conf/MultiLayerConfiguration.html
) 来初始化模型 (deeplearning4j.org/doc/org/deeplearning4j/nn/multilayer/MultiLayerNetwork.html
),方法如下:
val model: MultiLayerNetwork = new MultiLayerNetwork(conf)
model.init()
我们现在可以训练(和评估)模型,方法如下:
model.setListeners(new ScoreIterationListener(1))
for (i <- 0 until nEpochs) {
model.fit(trainIter)
println("*** Completed epoch {} ***", i)
...
}
现在让我们将 Apache Spark 引入其中。通过 Spark,可以在集群的多个节点上并行化内存中的训练和评估过程。
和往常一样,首先创建 Spark 上下文,如下所示:
val sparkConf = new SparkConf
sparkConf.setMaster(master)
.setAppName("DL4J Spark MNIST Example")
val sc = new JavaSparkContext(sparkConf)
然后,在将训练数据向量化后,通过 Spark 上下文将其并行化,如下所示:
val trainDataList = mutable.ArrayBuffer.empty[DataSet]
while (trainIter.hasNext) {
trainDataList += trainIter.next
}
val paralleltrainData = sc.parallelize(trainDataList)
测试数据也需要进行相同的处理。
配置和初始化模型后,您可以按如下方式配置 Spark 进行训练:
var batchSizePerWorker: Int = 16
val tm = new ParameterAveragingTrainingMaster.Builder(batchSizePerWorker)
.averagingFrequency(5)
.workerPrefetchNumBatches(2)
.batchSizePerWorker(batchSizePerWorker)
.build
创建 Spark 网络,如下所示:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
最后,用以下代码替换之前的训练代码:
var numEpochs: Int = 15
var i: Int = 0
for (i <- 0 until numEpochs) {
sparkNet.fit(paralleltrainData)
println("Completed Epoch {}", i)
}
完成后,不要忘记删除临时训练文件,如下所示:
tm.deleteTempFiles(sc)
完整示例是书籍随附的源代码的一部分。
总结
本章中,我们首先深入了解了 CNN 的主要概念,并探索了 Google 提供的 CNN 架构中最流行和表现最好的一个例子。接着,我们开始使用 DL4J 和 Spark 实现一些代码。
在下一章,我们将沿着类似的路线更深入地探讨 RNN。
第六章:循环神经网络
在本章中,我们将进一步了解循环神经网络(RNNs),它们最常见的应用场景概述,以及最后通过使用 DeepLearning4j 框架进行实际操作的可能实现。本章的代码示例还涉及到 Apache Spark。如同前一章关于 CNNs 的内容所述,RNN 的训练和评估策略将在第七章,使用 Spark 训练神经网络,第八章,监控与调试神经网络训练,以及第九章,解释神经网络输出中详细介绍。
在本章中,我尽量减少了数学概念和公式的使用,以便让没有数学或数据科学背景的开发人员和数据分析师能够更容易地阅读和理解。
本章涵盖以下主题:
-
长短期记忆(LSTM)
-
应用场景
-
实战 RNN 与 Spark
LSTM
RNN 是多层神经网络,用于识别数据序列中的模式。这里的数据序列可以是文本、手写字、数字时间序列(例如来自传感器的数据)、日志条目等。涉及的算法也具有时间维度:它们会考虑时间(这与 CNN 的主要区别)和序列。为了更好地理解为什么需要 RNN,我们首先要看一下前馈网络的基础。与 RNN 类似,这些网络通过一系列数学操作在网络的节点上处理信息,但它们是将信息直接传递,且每个节点不会被重复访问。网络接收输入示例,然后将其转化为输出:简而言之,它们将原始数据映射到类别。训练过程发生在有标签的输入上,直到猜测输入类别时所犯的错误最小化。这是网络学习如何对它从未见过的新数据进行分类的方式。前馈网络没有时间顺序的概念:它仅考虑当前输入,并不一定会改变它如何分类下一个输入。RNN 则接收当前示例以及它之前感知到的任何信息作为输入。可以将 RNN 视为多个前馈神经网络,将信息从一个网络传递到另一个网络。
在 RNN 的应用场景中,一个序列可能是有限的或无限的、相互依赖的数据流。CNN 在这些情况下表现不好,因为它们没有考虑前一个和下一个输入之间的相关性。根据第五章,你已经了解到,CNN 接收输入并根据训练的模型进行输出。对于给定数量的不同输入,任何一个都不会受到之前输出的影响。但如果考虑到本章最后几节提出的情况(一个句子生成的案例),其中所有生成的单词都依赖于之前生成的单词,那么就一定需要根据之前的输出进行偏置。这时,RNN 就派上用场了,因为它们能记住数据序列中之前发生的事情,这有助于它们获取上下文。理论上,RNN 可以无限回顾所有前一步骤,但实际上,出于性能考虑,它们只能回顾最后几步。
让我们深入了解 RNN 的细节。为了进行这个解释,我将从多层感知机(MLP)开始,它是一个前馈人工神经网络(ANN)类别。MLP 的最小实现至少有三层节点。但对于输入节点,每个节点是一个使用非线性激活函数的神经元。输入层当然是接收输入的部分。第一个隐藏层进行激活操作,将信息传递给下一个隐藏层,以此类推。最终,信息到达输出层,负责提供输出。所有隐藏层的行为不同,因为每一层都有不同的权重、偏置和激活函数。为了使这些层能够合并并简化这一过程,所有层需要替换为相同的权重(以及相同的偏置和激活函数)。这是将所有隐藏层合并成一个单一的循环层的唯一方法。它们开始看起来如下图所示。
图 6.1
根据前面的图示,网络H接收一些输入x并产生输出o。信息通过循环机制从网络的一个步骤传递到下一个步骤。在每个步骤中,输入会被提供给网络的隐藏层。RNN 的任何神经元都会存储它在所有前一步骤中接收到的输入,然后可以将这些信息与当前步骤传递给它的输入进行合并。这意味着在时间步t-1做出的决策会影响在时间步t做出的决策。
让我们用一个例子重新表述前面的解释:假设我们想预测在一系列字母之后,接下来的字母是什么。假设输入的单词是 pizza,它由五个字母组成。当网络尝试推测第五个字母时,会发生什么呢?前四个字母已经输入到网络中。对于隐藏层来说,会进行五次迭代。如果我们展开网络,它将变成一个五层网络,每一层对应输入单词的一个字母(参考 第二章,深度学习基础,图 2.11)。我们可以将它看作是一个重复多次(5)的普通神经网络。展开的次数与网络能够记住多远的过去有直接关系。回到 pizza 的例子,输入数据的词汇表是 {p, i, z, a}。隐藏层或 RNN 会对当前输入和前一个状态应用一个公式。在我们的例子中,单词 pizza 中的字母 p 作为第一个字母,它前面没有任何字母,所以什么也不做,然后我们可以继续处理下一个字母 i。在字母 i 和前一个状态(字母 p)之间,隐藏层应用公式。如果在某个时刻 t,输入是 i,那么在时刻 t-1,输入是 p。通过对 p 和 i 应用公式,我们得到一个新的状态。计算当前状态的公式可以写成如下:
h[t] = f(h[t-1], x[t])
其中 h[t] 是新的状态,h[t-1] 是前一个状态,x[t] 是当前输入。从之前的公式可以理解,当前状态是前一个输入的函数(输入神经元对前一个输入进行了变换)。任何连续的输入都会作为时间步长。在这个 pizza 的例子中,我们有四个输入进入网络。在每个时间步长,都会应用相同的函数和相同的权重。考虑到 RNN 的最简单实现,激活函数是 tanh,即双曲正切函数,其值范围在 -1 到 1 之间,这是 MLP 中最常见的 S 型激活函数之一。因此,公式如下:
h[t] = tanh(W[hh]h[t-1] + W[xh]x[t])
这里 W[hh] 是递归神经元的权重,W[xh] 是输入神经元的权重。这个公式意味着递归神经元会考虑到前一个状态。当然,前面的公式可以在更长的序列情况下涉及多个状态,而不仅仅是 pizza。一旦计算出最终状态,就可以通过以下方式获得输出 y[t]:
y[t] = W[hy]h[t]
关于误差的最后一点说明。误差通过将输出与实际输出进行比较来计算。一旦计算出误差,就通过反向传播将其传播到网络中,以更新网络的权重。
反向传播通过时间(BPTT)
为 RNN 提出了多种变体架构(其中一些已在第二章,深度学习基础,循环神经网络一节中列出)。在详细介绍 LSTM 实现之前,需要先简要讨论一下之前描述的通用 RNN 架构的问题。对于神经网络,一般使用前向传播技术来获得模型的输出并检查其是否正确。同样,反向传播是一种通过神经网络向后传播,找出误差对权重的偏导数的技术(这使得可以从权重中减去找到的值)。这些偏导数随后被梯度下降算法使用,梯度下降算法以迭代的方式最小化一个函数,然后对权重进行上下调整(调整的方向取决于哪个方向能减少误差)。在训练过程中,反向传播是调整模型权重的方式。BPTT 只是定义在展开的 RNN 上执行反向传播过程的一种方法。参考第二章,深度学习基础,图 2.11,在执行 BPTT 时,必须进行展开的公式化,即某一时间步的误差依赖于前一个时间步。在 BPTT 技术中,误差是从最后一个时间步反向传播到第一个时间步,同时展开所有时间步。这使得可以为每个时间步计算误差,从而更新权重。请注意,在时间步数较多的情况下,BPTT 可能会计算非常耗时。
RNN 问题
影响 RNN 的两个主要问题是梯度爆炸和梯度消失。当算法在没有理由的情况下给模型权重赋予过高的重要性时,我们称之为梯度爆炸。但解决这个问题的方法很简单,只需要截断或压缩梯度即可。我们称之为梯度消失,是指梯度的值非常小,以至于它导致模型停止学习或学习速度过慢。如果与梯度爆炸相比,这是一个主要问题,但现在已经通过LSTM(长短期记忆)神经网络得到了解决。LSTM 是一种特殊类型的 RNN,能够学习长期依赖关系,1997 年由 Sepp Hochreiter(en.wikipedia.org/wiki/Sepp_Hochreiter
)和 Juergen Schmidhuber(en.wikipedia.org/wiki/J%C3%BCrgen_Schmidhuber
)提出。
它们明确设计为具有默认的长期记忆能力。之所以能够实现这一点,是因为 LSTM 会在一个内存中保持信息,这个内存的功能类似于计算机的内存:LSTM 可以从中读取、写入和删除信息。LSTM 的内存可以被视为一个带门单元:它决定是否存储或删除信息(是否打开门),这取决于它对给定信息的重要性赋予了多少权重。赋予重要性的过程通过权重进行:因此,网络随着时间的推移学习哪些信息需要被认为是重要的,哪些不重要。LSTM 有三个门:输入门、遗忘门和输出门。输入门决定是否让新输入进入,遗忘门删除不重要的信息,输出门影响网络当前时间步的输出,如下图所示:
图 6.2:LSTM 的三个门
你可以将这三个门看作是传统的人工神经元,就像在前馈神经网络(MNN)中一样:它们计算一个加权和的激活(使用激活函数)。使得 LSTM 门能够进行反向传播的原因在于它们是模拟的(sigmoid 函数,其范围从零到一)。这种实现解决了梯度消失的问题,因为它保持了足够陡峭的梯度,从而使得训练能够在相对较短的时间内完成,同时保持较高的准确性。
使用案例
RNN 有多个使用场景。以下是最常见的几种:
-
语言建模与文本生成:这是一种尝试,根据一系列单词预测下一个单词的概率。这对于语言翻译非常有用:最有可能的句子通常是正确的句子。
-
机器翻译:这是一种尝试将文本从一种语言翻译成另一种语言的方法。
-
时间序列中的异常检测:研究表明,特别是 LSTM 网络非常适合学习包含未知长度的长期模式的序列,因为它们能够保持长期记忆。由于这一特性,它们在时间序列中的异常或故障检测中非常有用。实际应用案例包括日志分析和传感器数据分析。
-
语音识别:这是一种基于输入声波预测语音片段,然后形成单词的尝试。
-
语义解析:将自然语言表达转换为逻辑形式——一种机器可理解的意义表示。实际应用包括问答系统和编程语言代码生成。
-
图像描述:这通常涉及 CNN 和 RNN 的组合。CNN 进行图像分割,RNN 则利用 CNN 分割后的数据来重建描述。
-
视频标注:RNN 可以用于视频搜索,当进行逐帧视频图像说明时,RNN 可以发挥作用。
-
图像生成:这是一个将场景的各部分独立生成并逐步改进大致草图的过程,最终生成的图像在肉眼下无法与真实数据区分。
使用 Spark 动手实践 RNN
现在让我们开始动手使用 RNN。本节分为两部分——第一部分是关于使用 DL4J 实现网络,第二部分将介绍使用 DL4J 和 Spark 实现同样目标的方法。与 CNN 一样,借助 DL4J 框架,许多高级功能都可以开箱即用,因此实现过程比你想象的要容易。
使用 DL4J 的 RNN
本章展示的第一个示例是一个 LSTM,经过训练后,当学习字符串的第一个字符作为输入时,它将会复述接下来的字符。
这个示例的依赖项如下:
-
Scala 2.11.8
-
DL4J NN 0.9.1
-
ND4J Native 0.9.1 以及你运行该模型的机器操作系统专用分类器
-
ND4J jblas 0.4-rc3.6
假设我们有一个通过不可变变量LEARNSTRING
指定的学习字符串,接下来我们开始创建一个由它生成的可能字符列表,如下所示:
val LEARNSTRING_CHARS: util.LinkedHashSet[Character] = new util.LinkedHashSet[Character]
for (c <- LEARNSTRING) {
LEARNSTRING_CHARS.add(c)
}
LEARNSTRING_CHARS_LIST.addAll(LEARNSTRING_CHARS)
让我们开始配置网络,如下所示:
val builder: NeuralNetConfiguration.Builder = new NeuralNetConfiguration.Builder
builder.iterations(10)
builder.learningRate(0.001)
builder.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
builder.seed(123)
builder.biasInit(0)
builder.miniBatch(false)
builder.updater(Updater.RMSPROP)
builder.weightInit(WeightInit.XAVIER)
你会注意到,我们正在使用与上一章中 CNN 示例相同的NeuralNetConfiguration.Builder
类。这个抽象类用于任何你需要通过 DL4J 实现的网络。使用的优化算法是随机梯度下降(en.wikipedia.org/wiki/Stochastic_gradient_descent
)。其他参数的意义将在下一章中进行讲解,该章将重点介绍训练过程。
现在让我们定义这个网络的各层。我们实现的模型基于 Alex Graves 的 LSTM RNN(en.wikipedia.org/wiki/Alex_Graves_(computer_scientist)
)。在决定它们的总数并将一个值分配给不可变变量HIDDEN_LAYER_CONT
后,我们可以定义网络的隐藏层,如下所示:
val listBuilder = builder.list
for (i <- 0 until HIDDEN_LAYER_CONT) {
val hiddenLayerBuilder: GravesLSTM.Builder = new GravesLSTM.Builder
hiddenLayerBuilder.nIn(if (i == 0) LEARNSTRING_CHARS.size else HIDDEN_LAYER_WIDTH)
hiddenLayerBuilder.nOut(HIDDEN_LAYER_WIDTH)
hiddenLayerBuilder.activation(Activation.TANH)
listBuilder.layer(i, hiddenLayerBuilder.build)
}
激活函数是tanh
(双曲正切)。
然后我们需要定义outputLayer
(选择 softmax 作为激活函数),如下所示:
val outputLayerBuilder: RnnOutputLayer.Builder = new RnnOutputLayer.Builder(LossFunction.MCXENT)
outputLayerBuilder.activation(Activation.SOFTMAX)
outputLayerBuilder.nIn(HIDDEN_LAYER_WIDTH)
outputLayerBuilder.nOut(LEARNSTRING_CHARS.size)
listBuilder.layer(HIDDEN_LAYER_CONT, outputLayerBuilder.build)
在完成配置之前,我们必须指定该模型没有经过预训练,并且我们使用反向传播,如下所示:
listBuilder.pretrain(false)
listBuilder.backprop(true)
网络(MultiLayerNetwork
)可以从上述配置开始创建,如下所示:
val conf = listBuilder.build
val net = new MultiLayerNetwork(conf)
net.init()
net.setListeners(new ScoreIterationListener(1))
一些训练数据可以通过编程方式从学习字符串字符列表生成,如下所示:
val input = Nd4j.zeros(1, LEARNSTRING_CHARS_LIST.size, LEARNSTRING.length)
val labels = Nd4j.zeros(1, LEARNSTRING_CHARS_LIST.size, LEARNSTRING.length)
var samplePos = 0
for (currentChar <- LEARNSTRING) {
val nextChar = LEARNSTRING((samplePos + 1) % (LEARNSTRING.length))
input.putScalar(ArrayInt, samplePos), 1)
labels.putScalar(ArrayInt, samplePos), 1)
samplePos += 1
}
val trainingData: DataSet = new DataSet(input, labels)
该 RNN 训练的过程将在下一章中介绍(代码示例将在那里完成)——本节的重点是展示如何使用 DL4J API 配置和构建 RNN 网络。
使用 DL4J 和 Spark 进行 RNN 训练
本节中展示的示例是一个 LSTM 模型,它将被训练以一次生成一个字符的文本。训练通过 Spark 进行。
该示例的依赖项如下:
-
Scala 2.11.8
-
DL4J NN 0.9.1
-
ND4J Native 0.9.1 和适用于运行环境操作系统的特定分类器。
-
ND4J jblas 0.4-rc3.6
-
Apache Spark Core 2.11,版本 2.2.1
-
DL4J Spark 2.11,版本 0.9.1_spark_2
我们像往常一样通过 NeuralNetConfiguration.Builder
类开始配置网络,具体如下:
val rng = new Random(12345)
val lstmLayerSize: Int = 200
val tbpttLength: Int = 50
val nSamplesToGenerate: Int = 4
val nCharactersToSample: Int = 300
val generationInitialization: String = null
val conf = new NeuralNetConfiguration.Builder()
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.iterations(1)
.learningRate(0.1)
.rmsDecay(0.95)
.seed(12345)
.regularization(true)
.l2(0.001)
.weightInit(WeightInit.XAVIER)
.updater(Updater.RMSPROP)
.list
.layer(0, new GravesLSTM.Builder().nIn(SparkLSTMCharacterExample.CHAR_TO_INT.size).nOut(lstmLayerSize).activation(Activation.TANH).build())
.layer(1, new GravesLSTM.Builder().nIn(lstmLayerSize).nOut(lstmLayerSize).activation(Activation.TANH).build())
.layer(2, new RnnOutputLayer.Builder(LossFunction.MCXENT).activation(Activation.SOFTMAX)
.nIn(lstmLayerSize).nOut(SparkLSTMCharacterExample.nOut).build) //MCXENT + softmax for classification
.backpropType(BackpropType.TruncatedBPTT).tBPTTForwardLength(tbpttLength).tBPTTBackwardLength(tbpttLength)
.pretrain(false).backprop(true)
.build
关于 RNNs with DL4J 部分中展示的示例,这里使用的 LSTM RNN 实现是 Alex Graves 的版本。所以配置、隐藏层和输出层与前一个示例非常相似。
现在,Spark 开始发挥作用了。让我们设置 Spark 配置和上下文,如下所示:
val sparkConf = new SparkConf
sparkConf.setMaster(master)
sparkConf.setAppName("LSTM Character Example")
val sc = new JavaSparkContext(sparkConf)
假设我们已经获得了一些训练数据,并从中创建了一个名为 trainingData
的 JavaRDD[DataSet]
,我们需要为数据并行训练进行设置。特别是,我们需要设置 TrainingMaster
(deeplearning4j.org/doc/org/deeplearning4j/spark/api/TrainingMaster.html
)。
它是一个抽象,控制学习如何在 Spark 上执行,并允许使用多种不同的训练实现与 SparkDl4jMultiLayer
(deeplearning4j.org/doc/org/deeplearning4j/spark/impl/multilayer/SparkDl4jMultiLayer.html
)一起使用。为数据并行训练设置如下:
val averagingFrequency: Int = 5
val batchSizePerWorker: Int = 8
val examplesPerDataSetObject = 1
val tm = new ParameterAveragingTrainingMaster.Builder(examplesPerDataSetObject)
.workerPrefetchNumBatches(2)
.averagingFrequency(averagingFrequency)
.batchSizePerWorker(batchSizePerWorker)
.build
val sparkNetwork: SparkDl4jMultiLayer = new SparkDl4jMultiLayer(sc, conf, tm)
sparkNetwork.setListeners(Collections.singletonListIterationListener))
目前,DL4J 框架仅实现了一个 TrainingMaster
,即 ParameterAveragingTrainingMaster
(deeplearning4j.org/doc/org/deeplearning4j/spark/impl/paramavg/ParameterAveragingTrainingMaster.html
)。我们在当前示例中为其设置的参数如下:
-
workerPrefetchNumBatches
:能够异步预取的 Spark 工作节点数量;一组 mini-batches(数据集对象),以避免等待数据加载。将该参数设置为0
表示禁用预取。将其设置为2
(如我们的示例中)是一个较好的折中方案(在不过度使用内存的情况下,合理的默认值)。 -
batchSizePerWorker
:这是每个 Spark 工作节点在每次参数更新时使用的示例数量。 -
averagingFrequency
:控制参数平均和重新分发的频率,以batchSizePerWorker
大小的迷你批次数量为单位。设置较低的平均周期可能效率较低,因为网络通信和初始化开销相较于计算较高,而设置较大的平均周期可能导致性能较差。因此,良好的折衷方案是将其值保持在5
到10
之间。
SparkDl4jMultiLayer
需要的参数包括 Spark 上下文、Spark 配置和TrainingMaster
。
现在可以开始通过 Spark 进行训练。训练过程将在下一章中详细介绍(并将在那里完成此代码示例)——本节的重点是展示如何使用 DL4J 和 Spark API 配置和构建 RNN 网络。
加载多个 CSV 用于 RNN 数据管道
在本章结束前,这里有一些关于如何加载多个 CSV 文件的注意事项,每个文件包含一个序列,用于 RNN 训练和测试数据。我们假设有一个由多个 CSV 文件组成的数据集,这些文件存储在集群中(可以是 HDFS 或像 Amazon S3 或 Minio 这样的对象存储),每个文件表示一个序列,文件中的每一行仅包含一个时间步的值,各个文件的行数可能不同,头行可能存在也可能缺失。
参考保存在 S3 基础对象存储中的 CSV 文件(更多细节请参考第三章,提取、转换、加载,从 S3 加载数据),Spark 上下文已如下创建:
val conf = new SparkConf
conf.setMaster(master)
conf.setAppName("DataVec S3 Example")
val sparkContext = new JavaSparkContext(conf)
Spark 作业配置已设置为访问对象存储(如第三章,提取、转换、加载中所述),我们可以如下获取数据:
val origData = sparkContext.binaryFiles("s3a://dl4j-bucket")
(dl4j-bucket
是包含 CSV 文件的存储桶)。接下来,我们创建一个 DataVec CSVSequenceRecordReader
,并指定所有 CSV 文件是否有头行(如果没有头行,使用值0
;如果有头行,使用值1
),以及值分隔符,如下所示:
val numHeaderLinesEachFile = 0
val delimiter = ","
val seqRR = new CSVSequenceRecordReader(numHeaderLinesEachFile, delimiter)
最后,我们通过对seqRR
中的原始数据应用map
转换来获取序列,如下所示:
val sequencesRdd = origData.map(new SequenceRecordReaderFunction(seqRR))
在使用非序列 CSV 文件进行 RNN 训练时也非常相似,使用dl4j-spark
的DataVecDataSetFunction
类并指定标签列的索引和分类的标签数,如下所示:
val labelIndex = 1
val numClasses = 4
val dataSetRdd = sequencesRdd.map(new DataVecSequenceDataSetFunction(labelIndex, numClasses, false))
小结
在本章中,我们首先深入探讨了 RNN 的主要概念,然后了解了这些特定神经网络在许多实际应用中的使用案例,最后,我们开始动手实践,使用 DL4J 和 Spark 实现一些 RNN。
下一章将重点介绍 CNN 和 RNN 模型的训练技巧。训练技巧在第三章中已经提到,或者从提取、转换、加载跳过到本章,因为迄今为止,主要的目标是理解如何获取和准备训练数据,以及如何通过 DL4J 和 Spark 实现模型。
第七章:使用 Spark 训练神经网络
在前两章中,我们学习了如何使用DeepLearning4j(DL4J)API 在 Scala 中编程配置和构建卷积神经网络(CNNs)和递归神经网络(RNNs)。在那里提到了这些网络的训练实现,但并没有提供详细的解释。本章最终详细讲解了如何实现这两种网络的训练策略。本章还解释了为什么 Spark 在训练过程中至关重要,以及从性能角度看,DL4J 的基本作用。
本章的第二和第三部分分别聚焦于 CNN 和 RNN 的具体训练策略。第四部分还提供了关于如何正确配置 Spark 环境的建议、技巧和窍门。最后一部分介绍了如何使用 DL4J 的 Arbiter 组件进行超参数优化。
下面是本章将涵盖内容的总结:
-
使用 Spark 和 DL4J 进行 CNN 分布式训练
-
使用 Spark 和 DL4J 进行 RNN 分布式训练
-
性能考虑事项
-
超参数优化
使用 Spark 和 DeepLearning4j 进行分布式网络训练
多层神经网络(MNNs)的训练计算量大—它涉及庞大的数据集,并且需要尽可能快速地完成训练过程。在第一章《Apache Spark 生态系统》中,我们学习了 Apache Spark 如何在进行大规模数据处理时实现高性能。这使得 Spark 成为执行训练的完美选择,能够充分利用其并行特性。但仅有 Spark 是不够的—尽管 Spark 在 ETL 或流处理方面的性能表现优秀,但在 MNN 训练的计算背景下,一些数据转换或聚合需要使用低级语言(如 C++)来完成。
这时,DL4J 的 ND4J 模块(nd4j.org/index.html
)发挥了作用。无需学习和编程 C++,因为 ND4J 提供了 Scala API,而这些正是我们需要使用的。底层的 C++库对于使用 ND4J 的 Scala 或 Java 开发者是透明的。下面是一个使用 ND4J API 的 Scala 应用程序的简单示例(内联注释解释了代码的功能):
object Nd4JScalaSample {
def main (args: Array[String]) {
// Create arrays using the numpy syntax
var arr1 = Nd4j.create(4)
val arr2 = Nd4j.linspace(1, 10, 10)
// Fill an array with the value 5 (equivalent to fill method in numpy)
println(arr1.assign(5) + "Assigned value of 5 to the array")
// Basic stats methods
println(Nd4j.mean(arr1) + "Calculate mean of array")
println(Nd4j.std(arr2) + "Calculate standard deviation of array")
println(Nd4j.`var`(arr2), "Calculate variance")
...
ND4J 为 JVM 带来了一个开源、分布式、支持 GPU 的直观科学库,填补了 JVM 语言与 Python 程序员之间在强大数据分析工具可用性方面的空白。DL4J 依赖于 Spark 进行并行模型训练。大型数据集被分区,每个分区可供独立的神经网络使用,每个神经网络都在其自己的核心中运行—DL4J 会反复平均它们在中央模型中生成的参数。
为了完整信息,无论训练是否仅要求 DL4J,若在同一服务器上运行多个模型,则应使用 ParallelWrapper
(deeplearning4j.org/api/v1.0.0-beta2/org/deeplearning4j/parallelism/ParallelWrapper.html
)。但请注意,这个过程特别昂贵,服务器必须配备大量的 CPU(至少 64 个)或多个 GPU。
DL4J 提供了以下两个类,用于在 Spark 上训练神经网络:
-
SparkDl4jMultiLayer
(deeplearning4j.org/api/v1.0.0-beta2/org/deeplearning4j/spark/impl/multilayer/SparkDl4jMultiLayer.html
),是MultiLayerNetwork
的封装类(这是在前几章中一些示例中使用的类)。 -
SparkComputationGraph
(deeplearning4j.org/api/v1.0.0-beta2/org/deeplearning4j/spark/impl/graph/SparkComputationGraph.html
),是ComputationGraph
(deeplearning4j.org/api/v1.0.0-beta2/org/deeplearning4j/nn/graph/ComputationGraph.html
) 的封装类,ComputationGraph
是一种具有任意连接结构(DAG)的神经网络,且可以有任意数量的输入和输出。
这两个类是标准单机类的封装类,因此网络配置过程在标准训练和分布式训练中是相同的。
为了通过 DL4J 在 Spark 集群上训练一个网络,你需要遵循这个标准的工作流程:
-
通过
MultiLayerConfiguration
(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/nn/conf/MultiLayerConfiguration.html
) 类或ComputationGraphConfiguration
(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/nn/conf/ComputationGraphConfiguration.html
) 类指定网络配置 -
创建一个
TrainingMaster
实例 (static.javadoc.io/org.deeplearning4j/dl4j-spark_2.11/0.9.1_spark_2/org/deeplearning4j/spark/api/TrainingMaster.html
),以控制分布式训练的执行方式 -
使用网络配置和之前创建的
TrainingMaster
对象,创建SparkDl4jMultiLayer
或SparkComputationGraph
实例 -
加载训练数据
-
在
SparkDl4jMultiLayer
(或SparkComputationGraph
)实例上调用适当的 fit 方法 -
保存训练好的网络
-
为 Spark 任务构建 JAR 文件
-
提交 JAR 文件以执行
第五章中展示的代码示例,卷积神经网络,和第六章中展示的代码示例,递归神经网络,让你了解如何配置和构建 MNN;第三章中展示的代码示例,提取、转换、加载,以及第四章中展示的代码示例,流处理,让你了解不同方式加载训练数据的思路,而第一章中介绍的内容,Apache Spark 生态系统,则让你了解如何执行 Spark 任务。接下来,让我们在接下来的章节中专注于理解如何实现缺失的部分:网络训练。
目前,为了训练网络,DL4J 提供了一种单一的方法——参数平均化(arxiv.org/abs/1410.7455
)。以下是这一过程的概念性步骤:
-
Spark 主节点开始使用网络配置和参数
-
根据
TrainingMaster
的配置,数据被划分为多个子集 -
对于每个子集:
-
配置和参数从主节点分发到每个工作节点
-
每个工作节点在其自身的分区上执行 fit 操作
-
计算参数的平均值,然后将结果返回给主节点
-
-
训练完成后,已训练的网络副本会保存在主节点中
使用 Spark 和 DL4J 进行 CNN 分布式训练
让我们回到在第五章中介绍的例子,卷积神经网络,Spark 实战 CNN,关于手写数字图像分类的MNIST
数据集。为了方便起见,下面是该处使用的网络配置的提醒:
val channels = 1
val outputNum = 10
val conf = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(iterations)
.regularization(true)
.l2(0.0005)
.learningRate(.01)
.weightInit(WeightInit.XAVIER)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.updater(Updater.NESTEROVS)
.momentum(0.9)
.list
.layer(0, new ConvolutionLayer.Builder(5, 5)
.nIn(channels)
.stride(1, 1)
.nOut(20)
.activation(Activation.IDENTITY)
.build)
.layer(1, new
SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build)
.layer(2, new ConvolutionLayer.Builder(5, 5)
.stride(1, 1)
.nOut(50)
.activation(Activation.IDENTITY)
.build)
.layer(3, new
SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build)
.layer(4, new DenseLayer.Builder()
.activation(Activation.RELU)
.nOut(500)
.build)
.layer(5, new
OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.nOut(outputNum)
.activation(Activation.SOFTMAX).build)
.setInputType(InputType.convolutionalFlat(28, 28, 1))
.backprop(true).pretrain(false).build
我们使用该MultiLayerConfiguration
对象来初始化模型。拥有模型和训练数据后,可以开始训练。如前一节所述,训练通过 Spark 进行。因此,接下来的步骤是创建 Spark 上下文,如下所示:
val sparkConf = new SparkConf
sparkConf.setMaster(master)
.setAppName("DL4J Spark MNIST Example")
val sc = new JavaSparkContext(sparkConf)
然后,我们将训练数据加载到内存中后进行并行化处理,如下所示:
val trainDataList = mutable.ArrayBuffer.empty[DataSet]
while (trainIter.hasNext) {
trainDataList += trainIter.next
}
val paralleltrainData = sc.parallelize(trainDataList)
现在是时候创建TrainingMaster
实例,如下所示:
var batchSizePerWorker: Int = 16
val tm = new ParameterAveragingTrainingMaster.Builder(batchSizePerWorker)
.averagingFrequency(5)
.workerPrefetchNumBatches(2)
.batchSizePerWorker(batchSizePerWorker)
.build
我们可以使用当前唯一可用的TrainingMaster
接口实现——ParameterAveragingTrainingMaster
(static.javadoc.io/org.deeplearning4j/dl4j-spark_2.11/0.9.1_spark_2/org/deeplearning4j/spark/impl/paramavg/ParameterAveragingTrainingMaster.html
)。在上述示例中,我们只使用了该TrainingMaster
实现的三个配置选项,但还有更多选项:
-
dataSetObjectSize
:指定每个DataSet
中的示例数量。 -
workerPrefetchNumBatches
:Spark 工作节点能够异步预取一定数量的DataSet
对象,以避免等待数据加载。通过将此属性设置为零,可以禁用预取。如果将其设置为二(如我们的示例所示),则是一个很好的折衷方案(既不过度使用内存,又能保证合理的默认设置)。 -
*rddTrainingApproach*
:DL4J 在从 RDD 训练时提供了两种方法——RDDTrainingApproach.Export
和RDDTrainingApproach.Direct
(static.javadoc.io/org.deeplearning4j/dl4j-spark_2.11/0.9.1_spark_2/org/deeplearning4j/spark/api/RDDTrainingApproach.html
)。Export
是默认方法;它首先将RDD<DataSet>
以批量序列化的形式保存到磁盘上。然后,执行器异步加载所有DataSet
对象。选择Export
方法还是Direct
方法,取决于数据集的大小。对于不适合放入内存的大型数据集以及多轮训练,Export
方法更为优选——在这种情况下,Direct
方法的拆分和重分区操作开销并不适用,而且内存消耗较小。 -
exportDirectory
:临时数据文件存储的位置(仅限Export
方法)。 -
storageLevel
:仅在使用Direct
方法并从RDD<DataSet>
或RDD<MultiDataSet>
进行训练时适用。DL4J 持久化RDD时的默认存储级别是StorageLevel.MEMORY_ONLY_SER
。 -
storageLevelStreams
:仅在使用fitPaths(RDD<String>)
方法时适用。DL4J 持久化RDD<String>
时的默认存储级别是StorageLevel.MEMORY_ONLY
*。 -
repartitionStrategy
:指定应如何进行重分区操作的策略。可能的值为Balanced
(默认,DL4J 定义的自定义重分区策略)和SparkDefault
(Spark 使用的标准重分区策略)。
这里可以找到完整的列表及其含义:
deeplearning4j.org/docs/latest/deeplearning4j-spark-training
一旦定义了TrainingMaster
配置和策略,就可以创建一个SparkDl4jMultiLayer
实例,如下所示:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
然后可以开始训练,选择适当的fit
方法,如下所示:
var numEpochs: Int = 15
var i: Int = 0
for (i <- 0 until numEpochs) {
sparkNet.fit(paralleltrainData)
println("Completed Epoch {}", i)
}
第八章,监控和调试神经网络训练,以及第九章,解释神经网络输出,将解释如何监控、调试和评估网络训练的结果。
使用 Spark 和 DL4J 进行 RNN 分布式训练
让我们重新考虑在第六章中提出的例子,递归神经网络,DL4J 和 Spark 中的 RNN部分,关于一个 LSTM 模型的训练,该模型将逐个字符地生成文本。为了方便起见,让我们回顾一下那里使用的网络配置(这是 Alex Graves 提出的模型的 LSTM RNN 实现):
val rng = new Random(12345)
val lstmLayerSize: Int = 200
val tbpttLength: Int = 50
val nSamplesToGenerate: Int = 4
val nCharactersToSample: Int = 300
val generationInitialization: String = null
val conf = new NeuralNetConfiguration.Builder()
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.iterations(1)
.learningRate(0.1)
.rmsDecay(0.95)
.seed(12345)
.regularization(true)
.l2(0.001)
.weightInit(WeightInit.XAVIER)
.updater(Updater.RMSPROP)
.list
.layer(0, new GravesLSTM.Builder().nIn(SparkLSTMCharacterExample.CHAR_TO_INT.size).nOut(lstmLayerSize).activation(Activation.TANH).build())
.layer(1, new GravesLSTM.Builder().nIn(lstmLayerSize).nOut(lstmLayerSize).activation(Activation.TANH).build())
.layer(2, new RnnOutputLayer.Builder(LossFunction.MCXENT).activation(Activation.SOFTMAX)
.nIn(lstmLayerSize).nOut(SparkLSTMCharacterExample.nOut).build) //MCXENT + softmax for classification
.backpropType(BackpropType.TruncatedBPTT).tBPTTForwardLength(tbpttLength).tBPTTBackwardLength(tbpttLength)
.pretrain(false).backprop(true)
.build
在使用 Spark 和 DeepLearning4j 进行 CNN 分布式训练中提到的所有关于TrainingMaster
实例创建和配置的考虑事项,同样适用于SparkDl4jMultiLayer
实例的创建和配置,因此不再重复。SparkDl4jMultiLayer
的不同之处在于,在这种情况下,我们必须为模型指定IteratorListeners
(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/optimize/api/IterationListener.html
),这对于监控和调试尤其有用,正如在下一章中所解释的那样。按如下方式指定迭代器监听器:
val sparkNetwork: SparkDl4jMultiLayer = new SparkDl4jMultiLayer(sc, conf, tm)
sparkNetwork.setListeners(Collections.singletonListIterationListener))
以下是此情况下训练的一种可能方式。定义训练的轮数,如下所示:
val numEpochs: Int = 10
然后,对于每个,使用sparkNetwork
应用适当的fit
方法并采样一些字符,如下所示:
(0 until numEpochs).foreach { i =>
//Perform one epoch of training. At the end of each epoch, we are returned a copy of the trained network
val net = sparkNetwork.fit(trainingData)
//Sample some characters from the network (done locally)
println("Sampling characters from network given initialization \"" +
(if (generationInitialization == null) "" else generationInitialization) + "\"")
val samples = ... // Implement your own sampling method
samples.indices.foreach { j =>
println("----- Sample " + j + " -----")
println(samples(j))
}
}
最后,由于我们选择了Export
训练方式,完成后需要删除临时文件,如下所示:
tm.deleteTempFiles(sc)
第八章,监控和调试神经网络训练,以及第九章,解释神经网络输出,将解释如何监控、调试和评估该网络训练的结果。
性能考虑
本节将介绍一些建议,以便在 Spark 上训练时最大化 DL4J 的性能。我们从内存配置的一些考虑因素开始。首先,了解 DL4J 如何管理内存非常重要。该框架是基于 ND4J 科学库(用 C++ 编写)构建的。ND4J 使用堆外内存管理——这意味着为 INDArrays
分配的内存不在 JVM 堆上,如 Java 对象那样,而是分配在 JVM 外部。这种内存管理方式允许高效使用高性能本地代码进行数值操作,并且在运行于 GPU 时也对 CUDA 操作(developer.nvidia.com/cuda-zone
)至关重要。
通过这种方式,可以清楚地看到额外的内存和时间开销——在 JVM 堆上分配内存要求每次需要先将数据从那里复制出来,然后进行计算,最后将结果复制回去。ND4J 只是传递指针进行数值计算。堆内存(JVM)和堆外内存(通过 JavaCPP 的 ND4J (github.com/bytedeco/javacpp
)) 是两个独立的内存池。在 DL4J 中,这两者的内存限制是通过 Java 命令行参数,通过以下系统属性进行控制的:
-
Xms
:JVM 堆在应用程序启动时可以使用的内存 -
Xmx
:JVM 堆可以使用的最大内存限制 -
org.bytedeco.javacpp.maxbytes
:堆外最大内存限制 -
org.bytedeco.javacpp.maxphysicalbytes
:通常设置为与maxbytes
属性相同的值
第十章,在分布式系统上的部署,(该章节专注于训练或运行神经网络的分布式系统部署)将详细介绍内存管理。
提高性能的另一个好方法是配置 Spark 的本地性设置。这是一个可选的配置,但可以在这方面带来好处。本地性指的是数据相对于可以处理它的位置。在执行时,每当数据必须通过网络复制到空闲执行器进行处理时,Spark 需要在等待具有本地访问数据的执行器变为空闲状态和执行网络传输之间做出选择。Spark 的默认行为是在通过网络将数据传输到空闲执行器之前稍作等待。
使用 DL4J 训练神经网络计算量大,因此每个输入 DataSet
的计算量相对较高。因此,Spark 的默认行为并不适合最大化集群的利用率。在 Spark 训练过程中,DL4J 确保每个执行器只有一个任务——因此,最好是立即将数据传输到空闲的执行器,而不是等待另一个执行器变为空闲。计算时间会变得比任何网络传输时间都更为重要。告诉 Spark 不必等待,而是立即开始传输数据的方法很简单——提交配置时,我们需要将 spark.locality.wait
属性的值设置为 0
。
Spark 在处理具有大堆外组件的 Java 对象时存在问题(例如,在 DL4J 中,DataSet
和 INDArray
对象可能会遇到此问题),特别是在缓存或持久化它们时。从第一章《Apache Spark 生态系统》一章中你了解到,Spark 提供了不同的存储级别。在这些存储级别中,MEMORY_ONLY
和 MEMORY_AND_DISK
持久化可能会导致堆外内存问题,因为 Spark 无法正确估算 RDD 中对象的大小,从而导致内存溢出问题。因此,持久化 RDD<DataSet>
或 RDD<INDArray>
时,采用 MEMORY_ONLY_SER
或 MEMORY_AND_DISK_SER
是一种好的做法。
让我们详细探讨一下这个问题。Spark 根据估算的块大小来丢弃部分 RDD。它根据选择的持久化级别来估算块的大小。在 MEMORY_ONLY
或 MEMORY_AND_DISK
的情况下,估算是通过遍历 Java 对象图来完成的。问题在于,这个过程没有考虑到 DL4J 和 ND4J 使用的堆外内存,因此 Spark 低估了对象的真实大小,比如 DataSets
或 INDArrays
。
此外,在决定是否保留或丢弃块时,Spark 仅考虑了堆内存的使用情况。DataSet
和 INDArray
对象的堆内存占用非常小,因此 Spark 会保留太多此类对象,导致堆外内存耗尽,进而出现内存溢出问题。在 MEMORY_ONLY_SER
或 MEMORY_AND_DISK_SER
的情况下,Spark 将以序列化形式将块存储在 JVM 堆中。由于序列化的对象没有堆外内存,因此它们的大小可以被 Spark 准确估算——当需要时,Spark 会丢弃块,从而避免内存溢出问题。
Spark 提供了两个序列化库——Java(默认序列化)和 Kryo(github.com/EsotericSoftware/kryo
)。默认情况下,它使用 Java 的 ObjectOutputStream
进行对象序列化(docs.oracle.com/javase/8/docs/api/java/io/ObjectOutputStream.html
),并且可以与任何实现了序列化接口的类一起工作(docs.oracle.com/javase/8/docs/api/java/io/Serializable.html
)。然而,它也可以使用 Kryo 库,Kryo 的序列化速度显著快于 Java 序列化,而且更紧凑。
缺点是 Kryo 并不支持所有的可序列化类型,并且与 ND4J 的堆外数据结构不兼容。因此,如果你希望在 Spark 上使用 Kryo 序列化与 ND4J 配合使用,就需要设置一些额外的配置,以跳过由于某些 INDArray
字段的序列化不正确而导致的潜在 NullPointerExceptions
。要使用 Kryo,你需要将依赖项添加到项目中(以下示例是针对 Maven 的,但你也可以使用 Gradle 或 sbt 以这些构建工具特有的语法导入相同的依赖项),如下所示:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-kryo_2.11</artifactId>
<version>0.9.1</version>
</dependency>
然后配置 Spark 使用 Nd4J Kryo 注册器,如下所示:
val sparkConf = new SparkConf
sparkConf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
sparkConf.set("spark.kryo.registrator", "org.nd4j.Nd4jRegistrator")
超参数优化
在任何训练开始之前,一般的机器学习技术,尤其是深度学习技术,都有一组必须选择的参数。这些被称为超参数。专注于深度学习时,我们可以说,其中一些(如层数及其大小)定义了神经网络的架构,而其他一些则定义了学习过程(如学习率、正则化等)。超参数优化尝试通过使用专用软件应用一些搜索策略来自动化这个过程(该过程对训练神经网络所取得的结果有显著影响)。DL4J 提供了一个名为 Arbiter 的工具,用于神经网络的超参数优化。这个工具并未完全自动化这个过程——数据科学家或开发人员需要手动介入,以指定搜索空间(超参数的有效值范围)。请注意,当前的 Arbiter 实现并不会在那些没有很好地手动定义搜索空间的情况下,阻止寻找好的模型失败。接下来的部分将介绍如何以编程方式使用 Arbiter 的详细信息。
需要将 Arbiter 依赖项添加到需要进行超参数优化的 DL4J Scala 项目中,如下所示:
groupId: org.deeplearning4j
artifactId: arbiter-deeplearning4j
version: 0.9.1
设置和执行超参数优化的步骤顺序始终是相同的,如下所示:
-
定义超参数搜索空间
-
定义该超参数搜索空间的候选生成器
-
定义数据源
-
定义模型保存器
-
选择一个评分函数
-
选择一个终止条件。
-
使用之前定义的数据源、模型保存器、评分函数和终止条件来构建优化配置。
-
使用优化运行器执行该过程。
现在让我们来看看如何以编程方式实现这些步骤。超参数配置空间的设置与在 DL4J 中配置 MNN 非常相似。它通过MultiLayerSpace
类(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/MultiLayerSpace.html
)实现。ParameterSpace<P>
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/api/ParameterSpace.html
)是 Arbiter 类,通过它可以定义给定超参数的可接受值范围。以下是一些示例:
val learningRateHyperparam = new ContinuousParameterSpace(0.0001, 0.1)
val layerSizeHyperparam = new IntegerParameterSpace(16, 256)
在ParameterSpace
构造函数中指定的上下边界值包含在区间内。区间值将在给定边界之间均匀地随机生成。然后,可以构建超参数空间,如以下示例所示:
val hyperparameterSpace = new MultiLayerSpace.Builder
.weightInit(WeightInit.XAVIER)
.l2(0.0001)
.updater(new SgdSpace(learningRateHyperparam))
.addLayer(new DenseLayerSpace.Builder
.nIn(784)
.activation(Activation.LEAKYRELU)
.nOut(layerSizeHyperparam)
.build())
.addLayer(new OutputLayerSpace.Builder
.nOut(10)
.activation(Activation.SOFTMAX)
.lossFunction(LossFunctions.LossFunction.MCXENT)
.build)
.numEpochs(2)
.build
在 DL4J 中,有两个类,MultiLayerSpace
和ComputationGraphSpace
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/ComputationGraphSpace.html
),可用于设置超参数搜索空间(它们代表MultiLayerConfiguration
和ComputationGraphConfiguration
在 MNN 配置中的作用)。
下一步是定义候选生成器。它可以是随机搜索,如以下代码行所示:
val candidateGenerator:CandidateGenerator = new RandomSearchGenerator(hyperparameterSpace, null)
或者,它可以是网格搜索。
为了定义数据源(即用于训练和测试不同候选者的数据来源),Arbiter 中提供了DataSource
接口(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/api/data/DataSource.html
),并且需要实现它(它需要一个无参构造函数)以适应给定的来源。
目前我们需要定义保存将要生成和测试的模型的位置。Arbiter 支持将模型保存到磁盘或将结果存储在内存中。以下是使用FileModelSaver
类(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/saver/local/FileModelSaver.html
)保存到磁盘的示例:
val baseSaveDirectory = "arbiterOutput/"
val file = new File(baseSaveDirectory)
if (file.exists) file.delete
file.mkdir
val modelSaver: ResultSaver = new FileModelSaver(baseSaveDirectory)
我们必须选择一个评分函数。Arbiter 提供了三种不同的选择——EvaluationScoreFunction
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/scoring/impl/EvaluationScoreFunction.html
),ROCScoreFunction
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/scoring/impl/ROCScoreFunction.html
),和 RegressionScoreFunction
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/scoring/impl/RegressionScoreFunction.html
)。
有关评估、ROC 和回归的更多细节将在 第九章,解读神经网络输出 中讨论。以下是使用 EvaluationScoreFunction
的示例:
val scoreFunction:ScoreFunction = new EvaluationScoreFunction(Evaluation.Metric.ACCURACY)
最后,我们指定了一组终止条件。当前的 Arbiter 实现只提供了两个终止条件,MaxTimeCondition
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/api/termination/MaxTimeCondition.html
) 和 MaxCandidatesCondition
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/api/termination/MaxCandidatesCondition.html
)。当超参数空间满足其中一个指定的终止条件时,搜索会停止。在以下示例中,搜索会在 15 分钟后或在达到 20 个候选项后停止(具体取决于哪个条件先满足)。
发生的第一个条件是:
val terminationConditions = Array(new MaxTimeCondition(15, TimeUnit.MINUTES), new MaxCandidatesCondition(20))
现在,所有选项都已设置完毕,可以构建 OptimizationConfiguration
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/config/OptimizationConfiguration.html
),如下所示:
val configuration: OptimizationConfiguration = new OptimizationConfiguration.Builder
.candidateGenerator(candidateGenerator)
.dataSource(dataSourceClass,dataSourceProperties)
.modelSaver(modelSaver)
.scoreFunction(scoreFunction)
.terminationConditions(terminationConditions)
.build
然后通过 IOptimizationRunner
(deeplearning4j.org/api/latest/org/deeplearning4j/arbiter/optimize/runner/IOptimizationRunner.html
) 运行它,如以下示例所示:
val runner = new LocalOptimizationRunner(configuration, new MultiLayerNetworkTaskCreator())
runner.execute
执行结束时,应用程序会将生成的候选项存储在模型保存器初步指定的基础保存目录中的不同子目录里。每个子目录都以递增的数字命名。
参考本节的示例,对于第一个候选者,它将是./arbiterOutput/0/
,对于第二个候选者,将是./arbiterOutput/1/
,以此类推。还会生成模型的 JSON 表示(如下图所示),并且可以将其存储以便进一步重复使用:
图 7.1:Arbiter 中的候选者 JSON 序列化
Arbiter UI
要获取超参数优化的结果,必须等待过程执行结束,然后使用 Arbiter API 来检索它们,如以下示例所示:
val indexOfBestResult: Int = runner.bestScoreCandidateIndex
val allResults = runner.getResults
val bestResult = allResults.get(indexOfBestResult).getResult
val bestModel = bestResult.getResult
println("Configuration of the best model:\n")
println(bestModel.getLayerWiseConfigurations.toJson)
但是,具体情况可能不同,这个过程可能会很长,甚至需要几个小时才能结束,并且结果才会变得可用。幸运的是,Arbiter 提供了一个 Web UI,可以在运行时监控它,并获取潜在问题的洞察和优化配置的调优提示,无需在等待过程中浪费时间。为了开始使用这个 Web UI,需要将以下依赖项添加到项目中:
groupId: org.deeplearning4j
artifactId: arbiter-ui_2.11
version: 1.0.0-beta3
在IOptimizationRunner
开始之前,需要配置管理 Web UI 的服务器,如下所示:
val ss: StatsStorage = new FileStatsStorage(new File("arbiterUiStats.dl4j"))
runner.addListeners(new ArbiterStatusListener(ss))
UIServer.getInstance.attach(ss)
在前面的示例中,我们正在将 Arbiter 统计信息持久化到文件。一旦优化过程开始,Web UI 可以通过以下 URL 访问,如下所示:
http://:9000/arbiter
它有一个单一视图,在顶部显示正在进行的优化过程的摘要,如下图所示:
图 7.2:超参数优化过程的实时总结
在其中央区域,它展示了优化设置的总结,如下图所示:
图 7.3:超参数优化设置的摘要
在底部,它显示了结果列表,如下图所示:
图 7.4:超参数优化过程的实时结果总结
通过点击一个结果 ID,显示该特定候选者的额外细节、附加图表以及模型配置,如下图所示:
图 7.5:Arbiter Web UI 中的候选者详情
Arbiter UI 使用与 DL4J UI 相同的实现和持久化策略来监控训练过程。有关这些细节将在下一章中介绍。
摘要
在本章中,你已了解了如何使用 DL4J、ND4J 和 Apache Spark 训练 CNN 和 RNN。你现在也了解了内存管理,改进训练过程性能的若干技巧,以及如何使用 Arbiter 进行超参数优化的细节。
下一章将重点讨论如何在 CNN 和 RNN 的训练阶段监控和调试它们。
第八章:监控和调试神经网络训练
前一章重点介绍了多层神经网络(MNNs)的训练,并特别展示了 CNN 和 RNN 的代码示例。本章将介绍如何在训练过程中监控网络,以及如何利用这些监控信息来调整模型。DL4J 提供了用于监控和调整的 UI 功能,这将是本章的重点。这些功能也可以在 DL4J 和 Apache Spark 的训练环境中使用。将提供两种情况的示例(仅使用 DL4J 训练和 DL4J 与 Spark 结合使用)。同时,本章还将讨论一些潜在的基础步骤或网络训练的技巧。
在神经网络训练阶段进行监控和调试
在第五章,卷积神经网络,和第七章,使用 Spark 训练神经网络之间,提供了一个完整的例子,涉及 CNN 模型的配置和训练。这是一个图像分类的示例。所使用的训练数据来自MNIST
数据库。训练集包含 60,000 个手写数字的例子,每张图片都带有一个整数标签。我们将使用相同的例子来展示 DL4J 提供的可视化工具,以便在训练时监控和调试网络。
在训练结束时,你可以通过编程方式将生成的模型保存为 ZIP 压缩包,并调用ModelSerializer
类的writeModel
方法(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/util/ModelSerializer.html
):
ModelSerializer.writeModel(net, new File(System.getProperty("user.home") + "/minist-model.zip"), true)
生成的压缩包包含三个文件:
-
configuration.json
:以 JSON 格式表示的模型配置 -
coefficients.bin
:估算的系数 -
updaterState.bin
:更新器的历史状态
例如,可以使用 JDK 的 JavaFX 功能(en.wikipedia.org/wiki/JavaFX
)来实现一个独立的 UI,用于测试在训练网络后构建的模型。查看以下截图:
图 8.1:手写数字分类 CNN 示例的测试 UI
然而,这对于监控目的几乎没什么用处,因为在实际应用中,你可能希望实时查看当前网络状态和训练进展。DL4J 训练 UI 将满足你所有的监控需求,我们将在本章接下来的两节中详细介绍这一功能。上面截图中显示的测试 UI 的实现细节将在下一章中讨论,该章节将讲解网络评估——在阅读过这一部分之后,你会更容易理解这些实现。
8.1.1 DL4J 训练 UI
DL4J 框架提供了一个网页用户界面,用于实时可视化当前网络状态和训练进展。它用于帮助理解如何调整神经网络。在本节中,我们将讨论一个仅使用 DL4J 进行 CNN 训练的用例。下一节将展示通过 DL4J 和 Spark 进行训练时的不同之处。
我们首先需要做的是将以下依赖添加到项目中:
groupId = org.deeplearning4j
artifactId = deeplearning4j-ui_2.11
version = 0.9.1
然后,我们可以开始添加必要的代码。
让我们为 UI 初始化后端:
val uiServer = UIServer.getInstance()
配置在训练过程中为网络生成的信息:
val statsStorage:StatsStorage = new InMemoryStatsStorage()
在前面的示例中,我们选择将信息存储在内存中。也可以选择将其存储在磁盘上,以便稍后加载使用:
val statsStorage:StatsStorage = new FileStatsStorage(file)
添加监听器(deeplearning4j.org/api/latest/org/deeplearning4j/ui/stats/StatsListener.html
),这样你可以在网络训练时收集信息:
val listenerFrequency = 1
net.setListeners(new StatsListener(statsStorage, listenerFrequency))
最后,为了实现可视化,将StatsStorage
(deeplearning4j.org/api/latest/org/deeplearning4j/ui/storage/InMemoryStatsStorage.html
)实例附加到后端:
uiServer.attach(statsStorage)
当训练开始时(执行fit
方法),可以通过网页浏览器访问 UI,网址为:
http://localhost:<ui_port>/
默认监听端口为9000
。可以通过org.deeplearning4j.ui.port
系统属性选择不同的端口,例如:
-Dorg.deeplearning4j.ui.port=9999
UI 的登陆页面是概览页面:
图 8.2:DL4J UI 的概览页面
如前面的截图所示,页面上有四个不同的部分。在页面的左上方是“得分与迭代”图表,展示了当前小批量的损失函数。在右上方是有关模型及其训练的信息。在左下方,有一个展示所有网络中参数更新比率(按层)的图表,称为“权重与迭代”。该图表中的值以对数底数 10 展示。在右下方是一个图表,展示了更新、梯度和激活的标准差。该图表的值同样以对数底数 10 展示。
UI 的另一个页面是模型页面:
图 8.3:DL4J UI 的模型页面
它展示了神经网络的图形表示。通过点击图中的某一层,可以显示该层的详细信息:
图 8.4:DL4J UI 模型页面中的单层详细信息
在页面的右侧部分,我们可以找到一个包含所选层详细信息的表格,以及一个展示此层参数更新比例的图表(根据概述页面)。向下滚动,我们还可以在同一部分找到其他图表,展示层激活随时间变化的情况、参数的直方图,以及每种参数类型和学习率与时间的更新。
UI 的第三页是系统页面:
图 8.5:DL4J UI 的系统页面
它展示了每台进行训练的机器的系统信息(JVM 和堆外内存利用率百分比、硬件和软件详情)。
UI 的左侧菜单呈现了第四个选项,语言,它列出了此 UI 所支持的所有语言翻译:
图 8.6:DL4J UI 支持的语言列表
8.1.2 DL4J 训练 UI 和 Spark
当在技术栈中训练并包括 Spark 时,也可以使用 DL4J UI。与仅使用 DL4J 的情况相比,主要区别在于:一些冲突的依赖关系要求 UI 和 Spark 运行在不同的 JVM 上。这里有两个可能的替代方案:
-
在运行时收集并保存相关的训练统计信息,然后稍后离线可视化它们。
-
执行 DL4J UI 并在不同的 JVM(服务器)中使用远程 UI 功能。数据随后会从 Spark 主节点上传到 UI 服务器。
让我们看看如何实现步骤 1的替代方案。
一旦 Spark 网络创建完成,让我们参考我们在第五章中展示的 CNN 示例,卷积神经网络部分,在基于 Spark 的 CNN 实战章节中:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
我们需要创建一个FileStatsStorage
对象,以便将结果保存到文件中并为 Spark 网络设置监听器:
val ss:StatsStorage = new FileStatsStorage(new File("NetworkTrainingStats.dl4j"))
sparkNet.setListeners(ss, Collections.singletonList(new StatsListener(null)))
接下来,我们可以通过实现以下步骤离线加载并显示已保存的数据:
val statsStorage:StatsStorage = new FileStatsStorage("NetworkTrainingStats.dl4j")
val uiServer = UIServer.getInstance()
uiServer.attach(statsStorage)
现在,让我们探索一下步骤 2的替代方案。
如前所述,UI 服务器需要在单独的 JVM 上运行。从那里,我们需要启动 UI 服务器:
val uiServer = UIServer.getInstance()
然后,我们需要启用远程监听器:
uiServer.enableRemoteListener()
我们需要设置的依赖项与我们在DL4J 训练 UI部分中展示的示例相同(DL4J UI):
groupId = org.deeplearning4j
artifactId = deeplearning4j-ui_2.11
version = 0.9.1
在 Spark 应用程序中(我们仍然指的是第五章中展示的 CNN 示例,卷积神经网络),在创建了 Spark 网络之后,我们需要创建一个RemoteUIStatsStorageRouter
的实例(static.javadoc.io/org.deeplearning4j/deeplearning4j-core/0.9.1/org/deeplearning4j/api/storage/impl/RemoteUIStatsStorageRouter.html
),该实例会异步地将所有更新推送到远程 UI,并最终将其设置为 Spark 网络的监听器:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
val remoteUIRouter:StatsStorageRouter = new RemoteUIStatsStorageRouter("http://UI_HOST_IP:UI_HOST_PORT")
sparkNet.setListeners(remoteUIRouter, Collections.singletonList(new StatsListener(null)))
UI_HOST_IP
是 UI 服务器运行的机器的 IP 地址,UI_HOST_PORT
是 UI 服务器的监听端口。
为了避免与 Spark 的依赖冲突,我们需要将此应用程序的依赖项添加到依赖列表中,而不是整个 DL4J UI 模型:
groupId = org.deeplearning4j
artifactId = deeplearning4j-ui-model
version = 0.9.1
选择步骤 2的替代方案时,网络的监控发生在训练过程中,并且是实时的,而不是在训练执行完成后离线进行。
DL4J UI 页面和内容与没有 Spark 的网络训练场景中展示的相同(本章DL4J 训练 UI部分)。
8.1.3 使用可视化调优网络
现在,让我们看看如何解读 DL4J UI 中展示的可视化结果,并利用它们来调优神经网络。我们从概览页面开始。模型得分与迭代图表展示了当前小批量的损失函数,应该随着时间推移而下降(如图 8.2中的示例所示)。无论观察到的得分是否应持续增加,学习率可能设得太高。在这种情况下,应降低学习率,直到得分变得更稳定。得分不断增加也可能表明存在其他问题,比如数据归一化不正确。另一方面,如果得分平稳或下降非常缓慢,则表示学习率可能设置得太低,或者优化很困难。在这种情况下,应尝试使用不同的更新器重新进行训练。
在DL4J 训练 UI一节中展示的示例中,使用了 Nesterov 动量更新器(见图 8.4),并取得了良好的结果(见图 8.2)。你可以通过NeuralNetConfiguration.Builder
类的updater
方法来更改更新器:
val conf = new NeuralNetConfiguration.Builder()
...
.updater(Updater.NESTEROVS)
在这张折线图中,应该预期会有一些噪声,但如果分数在不同的运行之间变化较大,那就成了一个问题。根本原因可能是我们之前提到的一些问题(学习率、归一化)或数据洗牌。另外,将小批量大小设置为非常小的样本数量也会增加图表中的噪声——这也可能导致优化困难。
在训练过程中,其他有助于理解如何调整神经网络的重要信息来自于结合概述页和模型页的一些细节。参数(或更新)的平均幅度是指在给定时间步长下,它们绝对值的平均值。在训练运行时,平均幅度的比率由概述页(对于整个网络)和模型页(对于特定层)提供。当选择学习率时,我们可以使用这些比率值。通常的规则是,大多数网络的比率应接近 0.001(1:1000),在*log[10]*图表(如概述页和模型页中的图表)中,该比率对应于-3。当比率显著偏离此值时,意味着网络参数可能不稳定,或者它们变化太慢,无法学习到有用的特征。通过调整整个网络或一个或多个层的学习率,可以改变平均幅度的比率。
现在,让我们探索模型页中其他有助于调整过程的有用信息。
模型页中的层激活图(见下图)可以用来检测梯度消失或爆炸现象。理想情况下,这个图应该随着时间的推移趋于稳定。激活值的标准差应介于 0.5 和 2.0 之间。
值显著超出此范围表明可能存在数据未归一化、高学习率或权重初始化不当等问题:
图 8.7:模型页的层激活图
模型页中层参数直方图图(权重和偏置,见下图)仅显示最新迭代的结果,提供了其他常见的洞察:
图 8.8:层参数直方图图(权重)
在训练过程中,经过一段时间后,这些权重的直方图应该呈现近似的高斯正态分布,而偏置通常从 0 开始,并最终趋于高斯分布。参数向+/-无穷大发散通常是学习率过高或网络正则化不足的良好指示。偏置变得非常大意味着类别分布非常不平衡。
模型页中的层更新直方图图(权重和偏置,见下图)也仅显示最新迭代的结果,与层参数直方图一样,提供了其他常见的信息:
图 8.9:层更新直方图图(权重)
这与参数图相同——经过一段时间后,它们应该呈现大致的高斯正态分布。非常大的值表示网络中的梯度爆炸。在这种情况下,根本原因可能出在权重初始化、输入或标签数据的归一化,或是学习率上。
总结
在本章中,我们已经了解了 DL4J 为神经网络训练时的监控和调优提供的 UI 细节。我们还学习了在使用 DL4J 进行训练时,尤其是在 Apache Spark 参与的情况下,如何使用该 UI。最后,我们理解了从 DL4J UI 页面上展示的图表中可以获得哪些有用的见解,以识别潜在问题以及一些解决方法。
下一章将重点介绍如何评估神经网络,以便我们能理解模型的准确性。在深入探讨通过 DL4J API 和 Spark API 实现的实际示例之前,我们将介绍不同的评估技术。
第九章:解释神经网络输出
在上一章中,详细描述了如何使用 DL4J UI 来监控和调试多层神经网络(MNN)。上一章的最后部分也解释了如何解读和使用 UI 图表中的实时可视化结果来调整训练。本章将解释如何在模型训练完成后、投入生产之前评估模型的准确性。对于神经网络,存在多种评估策略。本章涵盖了主要的评估策略及其所有实现,这些实现由 DL4J API 提供。
在描述不同的评估技术时,我尽量减少数学和公式的使用,尽量集中讲解如何使用 DL4J 和 Spark 进行 Scala 实现。
在本章中,我们将覆盖以下主题:
-
解释神经网络的输出
-
使用 DL4J 的评估技术,包括以下内容:
-
分类评估
-
在 Spark 环境下的分类评估
-
DL4J 支持的其他类型评估
-
使用 DL4J 的评估技术
在训练时以及在部署 MNN 之前,了解模型的准确性并理解其性能非常重要。在上一章中,我们了解到,在训练阶段结束时,模型可以保存为 ZIP 归档文件。从那里,可以通过实现自定义 UI 来运行并测试模型,正如图 8.1所示(它是通过 JavaFX 功能实现的,示例代码是本书随附的源代码的一部分)。但是,可以利用更为重要的策略来进行评估。DL4J 提供了一个 API,可以用来评估二分类器和多分类器的性能。
本节及其子节涵盖了如何进行分类评估的所有细节(DL4J 和 Spark),而下一节则概述了其他可以进行的评估策略,所有这些策略都依赖于 DL4J API。
分类评估
实现评估时,核心的 DL4J 类叫做evaluation(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/0.9.1/org/deeplearning4j/eval/Evaluation.html
,是 DL4J NN 模块的一部分)。
本小节所展示的示例所用的数据集是鸢尾花数据集(可以在 archive.ics.uci.edu/ml/datasets/iris
下载)。这是一个多变量数据集,由英国统计学家和生物学家 Ronald Fisher(en.wikipedia.org/wiki/Ronald_Fisher
)于 1936 年引入。该数据集包含 150 条记录——来自三种鸢尾花(Iris setosa、Iris virginica 和 Iris versicolor)的 50 个样本。每个样本测量了四个属性(特征)——萼片和花瓣的长度和宽度(单位:厘米)。该数据集的结构在 第四章 流式数据 的 使用 DL4J 和 Spark 处理流式数据 部分的示例中使用过。以下是该数据集中包含的一个样本数据:
sepal_length,sepal_width,petal_length,petal_width,species
5.1,3.5,1.4,0.2,0
4.9,3.0,1.4,0.2,0
4.7,3.2,1.3,0.2,0
4.6,3.1,1.5,0.2,0
5.0,3.6,1.4,0.2,0
5.4,3.9,1.7,0.4,0
...
通常,对于像这样的监督学习情况,数据集会被分为两部分:70% 用于训练,30% 用于计算误差并在必要时修改网络。这对于本节的示例也是如此——我们将使用 70% 的数据集进行网络训练,其余 30% 用于评估。
我们需要做的第一件事是使用 CSVRecordReader
获取数据集(输入文件是一个由逗号分隔的记录列表):
val numLinesToSkip = 1
val delimiter = ","
val recordReader = new CSVRecordReader(numLinesToSkip, delimiter)
recordReader.initialize(new FileSplit(new ClassPathResource("iris.csv").getFile))
现在,我们需要将将用于神经网络的数据进行转换:
val labelIndex = 4
val numClasses = 3
val batchSize = 150
val iterator: DataSetIterator = new RecordReaderDataSetIterator(recordReader, batchSize, labelIndex, numClasses)
val allData: DataSet = iterator.next
allData.shuffle()
输入文件的每一行包含五个值——四个输入特征,后跟一个整数标签(类别)索引。这意味着标签是第五个值(labelIndex
是 4
)。数据集有三种类别,代表三种鸢尾花类型。它们的整数值分别为零(setosa)、一(versicolor)或二(virginica)。
如前所述,我们将数据集分成两部分——70% 的数据用于训练,其余部分用于评估:
val iterator: DataSetIterator = new RecordReaderDataSetIterator(recordReader, batchSize, labelIndex, numClasses)
val allData: DataSet = iterator.next
allData.shuffle()
val testAndTrain: SplitTestAndTrain = allData.splitTestAndTrain(0.70)
val trainingData: DataSet = testAndTrain.getTrain
val testData: DataSet = testAndTrain.getTest
数据集的拆分通过 ND4J 的 SplitTestAndTrain
类 (deeplearning4j.org/api/latest/org/nd4j/linalg/dataset/SplitTestAndTrain.html
) 完成。
我们还需要使用 ND4J 的 NormalizeStandardize
类 (deeplearning4j.org/api/latest/org/nd4j/linalg/dataset/api/preprocessor/NormalizerStandardize.html
) 对输入数据(包括训练集和评估集)进行归一化处理,以便我们得到零均值和标准差为一:
val normalizer: DataNormalization = new NormalizerStandardize
normalizer.fit(trainingData)
normalizer.transform(trainingData)
normalizer.transform(testData)
我们现在可以配置并构建模型(一个简单的前馈神经网络):
val conf = new NeuralNetConfiguration.Builder()
.seed(seed)
.activation(Activation.TANH)
.weightInit(WeightInit.XAVIER)
.l2(1e-4)
.list
.layer(0, new DenseLayer.Builder().nIn(numInputs).nOut(3)
.build)
.layer(1, new DenseLayer.Builder().nIn(3).nOut(3)
.build)
.layer(2, new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.activation(Activation.SOFTMAX)
.nIn(3).nOut(outputNum).build)
.backprop(true).pretrain(false)
.build
以下截图显示了本示例中网络的图形表示:
MNN 可以通过从前面的配置开始创建:
val model = new MultiLayerNetwork(conf)
model.init()
model.setListeners(new ScoreIterationListener(100))
如果我们使用为训练预留的输入数据集的部分(70%),则可以开始训练:
for(idx <- 0 to 2000) {
model.fit(trainingData)
}
在训练结束时,可以使用输入数据集的保留部分(30%)进行评估:
val eval = new Evaluation(3)
val output = model.output(testData.getFeatureMatrix)
eval.eval(testData.getLabels, output)
println(eval.stats)
传递给评估类构造函数的值是评估中需要考虑的类别数——这里是3
,因为数据集中有3
个花卉类别。eval
方法将测试数据集中的标签数组与模型生成的标签进行比较。评估的结果最终会打印到输出中:
默认情况下,Evaluation
类的stats
方法会显示混淆矩阵条目(每行一个条目),准确度、精确度、召回率和 F1 分数,但也可以显示其他信息。让我们来谈谈这些stats
是什么。
混淆矩阵是用于描述分类器在测试数据集上表现的表格,其中真实值是已知的。我们来考虑以下示例(对于二分类器):
预测数量 = 200 | 预测为否 | 预测为是 |
---|---|---|
实际: 否 | 55 | 5 |
实际: 是 | 10 | 130 |
这些是从上述矩阵中得到的见解:
-
预测类别有两种可能,“是”和“否”
-
分类器总共做出了 200 个预测
-
在这 200 个案例中,分类器预测为是 135 次,预测为否 65 次
-
实际上,样本中有 140 个案例是“是”,60 个是“否”
当这些被翻译成适当的术语时,见解如下:
-
真阳性 (TP):这些是预测为“是”且实际上是“是”的情况
-
真阴性 (TN):预测为否,且实际上是否
-
假阳性 (FP):预测为是,但实际上是否
-
假阴性 (FN):预测为否,但实际上是是
让我们考虑以下示例:
预测为否 | 预测为是 | |
---|---|---|
实际: 否 | 真阴性 | 假阳性 |
实际: 是 | 假阴性 | 真阳性 |
这是通过数字来完成的。可以从混淆矩阵中计算出一组比率。参考本节中的代码示例,它们如下所示:
-
准确度:表示分类器正确的频率:(TP+TN)/总数。
-
精确度:表示分类器预测为正的观测值时,分类器的正确率。
-
召回率:评估数据集中所有类别(标签)的平均召回率:TP/(TP+FN)。
-
F1 分数:这是精确度和召回率的加权平均值。它考虑了假阳性和假阴性:2 * TP / (2TP + FP + FN)。
Evaluation
类还可以显示其他信息,例如 G-measure 或 Matthew 相关系数等等。混淆矩阵也可以显示其完整形式:
println(eval.confusionToString)
上述命令返回以下输出:
混淆矩阵也可以直接访问并转换为 CSV 格式:
eval.getConfusionMatrix.toCSV
上述命令返回以下输出:
它也可以转换为 HTML 格式:
eval.getConfusionMatrix.toHTML
上述命令返回以下输出:
分类评估 – Spark 示例
让我们来看另一个分类评估的示例,但在一个涉及 Spark 的上下文中(分布式评估)。我们将完成在 第五章,卷积神经网络,在 使用 Spark 的 CNN 实战 部分,第七章,使用 Spark 训练神经网络,在 使用 Spark 和 DL4J 的 CNN 分布式训练 部分,以及 第八章,监控和调试神经网络训练,在 DL4J 训练 UI 和 Spark 部分展示的示例。记住,这个示例是基于 MNIST
数据集训练的手写数字图像分类。
在这些章节中,我们只使用了 MNIST
数据集的一部分进行训练,但下载的归档文件还包括一个名为 testing
的单独目录,包含了保留用于评估的数据集部分。评估数据集也需要像训练数据集一样进行向量化:
val testData = new ClassPathResource("/mnist_png/testing").getFile
val testSplit = new FileSplit(testData, NativeImageLoader.ALLOWED_FORMATS, randNumGen)
val testRR = new ImageRecordReader(height, width, channels, labelMaker)
testRR.initialize(testSplit)
val testIter = new RecordReaderDataSetIterator(testRR, batchSize, 1, outputNum)
testIter.setPreProcessor(scaler)
我们需要在评估时将其加载到内存之前进行此操作,并将其并行化:
val testDataList = mutable.ArrayBuffer.empty[DataSet]
while (testIter.hasNext) {
testDataList += testIter.next
}
val paralleltesnData = sc.parallelize(testDataList)
然后,可以通过 Evaluation
类进行评估,这正是我们在前一部分示例中所做的:
val sparkNet = new SparkDl4jMultiLayer(sc, conf, tm)
var numEpochs: Int = 15
var i: Int = 0
for (i <- 0 until numEpochs) {
sparkNet.fit(paralleltrainData)
val eval = sparkNet.evaluate(parallelTestData)
println(eval.stats)
println("Completed Epoch {}", i)
trainIter.reset
testIter.reset
}
Evaluation
类的 stas
方法生成的输出与通过 DL4J 训练和评估的任何其他网络实现相同。例如:
也可以使用 SparkDl4jMultiLayer
类的 doEvaluation
方法在同一轮次中执行多次评估。该方法需要三个输入参数:要评估的数据(以 JavaRDD<org.nd4j.linalg.dataset.DataSet>
的形式),一个空的 Evaluation
实例,以及表示评估批量大小的整数。它返回填充后的 Evaluation
对象。
其他类型的评估
通过 DL4J API 还可以进行其他评估。此部分列出了它们。
也可以通过 RegressionEvaluation
类评估执行回归的网络(static.javadoc.io/org.deeplearning4j/deeplearning4j-nn/1.0.0-alpha/org/deeplearning4j/eval/RegressionEvaluation.html
,DL4J NN)。参考我们在 分类评估 部分中使用的示例,回归评估可以按以下方式进行:
val eval = new RegressionEvaluation(3)
val output = model.output(testData.getFeatureMatrix)
eval.eval(testData.getLabels, output)
println(eval.stats)
stats
方法的输出包括 MSE、MAE、RMSE、RSE 和 R²:
ROC(即接收者操作特征,en.wikipedia.org/wiki/Receiver_operating_characteristic
)是另一种常用的分类器评估指标。DL4J 为 ROC 提供了三种不同的实现:
-
ROC
:deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/ROC.html
,适用于二分类器的实现 -
ROCBinary
:deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/ROCBinary.html
,适用于多任务二分类器 -
ROCMultiClass
:deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/ROCMultiClass.html
,适用于多类分类器
前面提到的三个类都有计算ROC 曲线(AUROC)和精确度-召回曲线(AUPRC)下的面积的能力,计算方法分别是calculateAUC
和calculateAUPRC
。这三种 ROC 实现支持两种计算模式:
-
阈值化:它使用更少的内存,并近似计算 AUROC 和 AUPRC。这适用于非常大的数据集。
-
精确:这是默认设置。它精确,但需要更多的内存。不适合非常大的数据集。
可以将 AUROC 和 AUPRC 导出为 HTML 格式,以便使用网页浏览器查看。需要使用EvaluationTools
类的exportRocChartsToHtmlFile
方法(deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/evaluation/EvaluationTools.html
)进行此导出。此方法需要 ROC 实现和一个 File 对象(目标 HTML 文件)作为参数。它会将两条曲线保存在一个 HTML 文件中。
要评估具有二分类输出的网络,可以使用EvaluationBinary
类(deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/EvaluationBinary.html
)。该类为每个输出计算典型的分类指标(准确率、精确度、召回率、F1 得分等)。该类的语法如下:
val size:Int = 1
val eval: EvaluationBinary = new EvaluationBinary(size)
那么,时间序列评估(在 RNN 的情况下)如何呢?它与我们在本章中描述的分类评估方法非常相似。对于 DL4J 中的时间序列,评估是针对所有未被掩盖的时间步进行的。那什么是 RNN 的掩盖?RNN 要求输入具有固定长度。掩盖是一种用于处理这种情况的技术,它标记了缺失的时间步。与之前介绍的其他评估情况的唯一区别是掩盖数组的可选存在。这意味着,在许多时间序列的情况下,你可以直接使用 MultiLayerNetwork
类的 evaluate
或 evaluateRegression
方法——无论是否存在掩盖数组,它们都能正确处理。
DL4J 还提供了一种分析分类器校准的方法——EvaluationCalibration
类(deeplearning4j.org/api/1.0.0-beta2/org/deeplearning4j/eval/EvaluationCalibration.html
)。它提供了一些工具,例如:
-
每个类别的标签和预测数量
-
每个类别的概率直方图
使用此类对分类器的评估与其他评估类的方式类似。可以通过 EvaluationTools
类的 exportevaluationCalibrationToHtmlFile
方法将其图表和直方图导出为 HTML 格式。此方法需要传入 EvaluationCalibration
实例和文件对象(目标 HTML 文件)作为参数。
总结
在本章中,我们已经学习了如何使用 DL4J API 提供的不同工具来以编程方式评估模型的效率。我们已经完整地了解了使用 DL4J 和 Apache Spark 实现、训练和评估 MNN 的全过程。
下一章将为我们提供有关分发环境部署、导入和执行预训练的 Python 模型的见解,并对 DL4J 与其他 Scala 编程语言的替代深度学习框架进行比较。
第十章:在分布式系统上部署
本书接下来的章节将展示我们迄今为止学到的内容,以便实现一些实际的、现实世界中的 CNN 和 RNN 用例。但在此之前,我们先考虑一下 DL4J 在生产环境中的应用。本章分为四个主要部分:
-
关于 DL4J 生产环境设置的一些考虑,特别关注内存管理、CPU 和 GPU 设置以及训练作业的提交
-
分布式训练架构细节(数据并行性和 DL4J 中实现的策略)
-
在基于 DL4J(JVM)的生产环境中导入、训练和执行 Python(Keras 和 TensorFlow)模型的实际方法
-
DL4J 与几种替代的 Scala 编程语言 DL 框架的比较(特别关注它们在生产环境中的就绪性)
配置一个分布式环境与 DeepLearning4j
本节解释了一些设置 DL4J 神经网络模型训练和执行的生产环境时的技巧。
内存管理
在第七章,使用 Spark 训练神经网络章节中的性能考虑部分,我们学习了在训练或运行模型时,DL4J 如何处理内存。由于它依赖于 ND4J,它不仅使用堆内存,还利用堆外内存。作为堆外内存,它位于 JVM 的垃圾回收(GC)机制管理的范围之外(内存分配在 JVM 外部)。在 JVM 层面,只有指向堆外内存位置的指针;这些指针可以通过 Java 本地接口(JNI, docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/jniTOC.html
)传递给 C++代码,用于 ND4J 操作。
在 DL4J 中,可以使用两种不同的方法来管理内存分配:
-
JVM 垃圾回收(GC)和弱引用跟踪
-
内存工作空间
本节将涵盖这两种方法。它们的思想是相同的:一旦INDArray
不再需要,应该释放与其相关的堆外内存,以便可以重复使用。两种方法之间的区别如下:
-
JVM 垃圾回收(GC):当
INDArray
被垃圾回收器回收时,它的堆外内存会被释放,假设该内存不会在其他地方使用 -
内存工作空间:当
INDArray
离开工作空间范围时,它的堆外内存可以被重用,而无需进行释放和重新分配
请参考第七章,使用 Spark 训练神经网络章节中的性能考虑部分,了解如何配置堆内存和堆外内存的限制。
内存工作空间的方式需要更多解释。与 JVM 垃圾回收方法相比,它在循环工作负载的性能方面提供了最佳的结果。在工作空间内,任何操作都可以与 INDArrays
一起进行。然后,在工作空间循环结束时,内存中所有 INDArrays
的内容都会失效。是否需要在工作空间外部使用 INDArray
(当需要将结果移出工作空间时,可能会出现这种情况),可以使用 INDArray
本身的 detach
方法来创建它的独立副本。
从 DL4J 1.0.0-alpha 版本开始,工作空间默认启用。对于 DL4J 0.9.1 或更早版本,使用工作空间需要先进行激活。在 DL4J 0.9.1 中,在网络配置时,工作空间可以这样激活(用于训练):
val conf = new NeuralNetConfiguration.Builder()
.trainingWorkspaceMode(WorkspaceMode.SEPARATE)
或者在推理时,可以按以下方式激活:
val conf = new NeuralNetConfiguration.Builder()
.inferenceWorkspaceMode(WorkspaceMode.SINGLE)
SEPARATE
工作空间较慢,但使用的内存较少,而 SINGLE
工作空间较快,但需要更多的内存。选择 SEPARATE
和 SINGLE
之间的权衡,取决于你对内存占用和性能的平衡。在启用工作空间时,训练过程中使用的所有内存都会被重用并进行跟踪,且不受 JVM 垃圾回收的干扰。只有 output
方法,内部使用工作空间来进行前向传播循环,才是例外,但它会将生成的 INDArray
从工作空间中分离出来,这样它就可以由 JVM 垃圾回收器处理。从 1.0.0-beta 版本开始,SEPARATE
和 SINGLE
模式已被弃用,现有的模式是 ENABLED
(默认)和 NONE
。
请记住,当训练过程使用工作空间时,为了最大限度地利用这种方法,需要禁用定期的垃圾回收调用,具体如下:
Nd4j.getMemoryManager.togglePeriodicGc(false)
或者它们的频率需要减少,具体如下:
val gcInterval = 10000 // In milliseconds
Nd4j.getMemoryManager.setAutoGcWindow(gcInterval)
该设置应在调用模型的 fit
方法进行训练之前进行。工作空间模式也适用于 ParallelWrapper
(在仅要求 DL4J 进行训练的情况下,在同一服务器上运行多个模型)。
在某些情况下,为了节省内存,可能需要释放在训练或评估期间创建的所有工作空间。这可以通过调用 WorkspaceManager
的以下方法来完成:
Nd4j.getWorkspaceManager.destroyAllWorkspacesForCurrentThread
它会销毁调用线程中创建的所有工作空间。可以使用相同的方法在不再需要的外部线程中销毁创建的工作空间。
在 DL4J 1.0.0-alpha 版本及以后版本中,使用 nd4j-native
后端时,还可以使用内存映射文件代替 RAM。虽然这比较慢,但它允许以一种使用 RAM 无法实现的方式进行内存分配。这种选项主要适用于那些 INDArrays
无法放入 RAM 的情况。以下是如何以编程方式实现:
val mmap = WorkspaceConfiguration.builder
.initialSize(1000000000)
.policyLocation(LocationPolicy.MMAP)
.build
try (val ws = Nd4j.getWorkspaceManager.getAndActivateWorkspace(mmap, "M2")) {
val ndArray = Nd4j.create(20000) //INDArray
}
在这个例子中,创建了一个 2 GB 的临时文件,映射了一个工作空间,并在该工作空间中创建了 ndArray
INDArray
。
CPU 和 GPU 设置
正如本书前面所提到的,任何通过 DL4J 实现的应用程序都可以在 CPU 或 GPU 上执行。要从 CPU 切换到 GPU,需要更改 ND4J 的应用程序依赖。以下是 CUDA 9.2 版本(或更高版本)和支持 NVIDIA 硬件的示例(该示例适用于 Maven,但相同的依赖关系也可以用于 Gradle 或 sbt),如下所示:
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-cuda-9.2</artifactId>
<version>0.9.1</version>
</dependency>
这个依赖替代了nd4j-native
的依赖。
当你的系统中有多个 GPU 时,是否应该限制它们的使用并强制在一个 GPU 上执行,可以通过nd4j-cuda
库中的CudaEnvironment
助手类(deeplearning4j.org/api/latest/org/nd4j/jita/conf/CudaEnvironment.html
)以编程方式进行更改。以下代码行需要作为 DL4J 应用程序入口点的第一条指令执行:
CudaEnvironment.getInstance.getConfiguration.allowMultiGPU(true)
在第 10.1.1 节中,我们已经学习了如何在 DL4J 中配置堆内存和堆外内存。在 GPU 执行时需要考虑一些问题。需要明确的是,命令行参数org.bytedeco.javacpp.maxbytes
和org.bytedeco.javacpp.maxphysicalbytes
定义了 GPU 的内存限制,因为对于INDArrays
,堆外内存被映射到 GPU 上(使用的是nd4j-cuda
)。
同样,在 GPU 上运行时,JVM 堆内存的使用量通常较少,而堆外内存的使用量较多,因为所有的INDArrays
都存储在堆外内存中。如果将太多内存分配给 JVM 堆内存,可能会导致堆外内存不足的风险。在进行适当设置时,在某些情况下,执行可能会导致以下异常:
RuntimeException: Can't allocate [HOST] memory: [memory]; threadId: [thread_id];
这意味着我们的堆外内存已经用完。在这种情况下(特别是在训练过程中),我们需要考虑使用WorkspaceConfiguration
来处理INDArrays
的内存分配(如在内存管理部分所学)。如果不这样做,INDArrays
及其堆外资源将通过 JVM GC 机制回收,这可能会显著增加延迟,并产生其他潜在的内存不足问题。
设置内存限制的命令行参数是可选的。如果没有指定,默认情况下,堆内存的限制为总系统内存的 25%,而堆外内存的限制是堆内存的两倍。我们需要根据实际情况找到最佳平衡,特别是在 GPU 执行时,考虑INDArrays
所需的堆外内存。
通常,CPU 内存大于 GPU 内存。因此,需要监控多少内存被用作堆外内存。DL4J 会在 GPU 上分配与通过上述命令行参数指定的堆外内存相等的内存。为了提高 CPU 和 GPU 之间的通信效率,DL4J 还会在 CPU 内存上分配堆外内存。这样,CPU 就可以直接访问 INDArray
中的数据,而无需每次都从 GPU 获取数据。
然而,有一个警告:如果 GPU 的内存少于 2 GB,那么它可能不适合用于深度学习(DL)生产工作负载。在这种情况下,应使用 CPU。通常,深度学习工作负载至少需要 4 GB 的内存(推荐在 GPU 上使用 8 GB 的内存)。
另一个需要考虑的因素是:使用 CUDA 后端并通过工作区,也可以使用 HOST_ONLY
内存。从编程角度来看,可以通过以下示例进行设置:
val basicConfig = WorkspaceConfiguration.builder
.policyAllocation(AllocationPolicy.STRICT)
.policyLearning(LearningPolicy.FIRST_LOOP)
.policyMirroring(MirroringPolicy.HOST_ONLY)
.policySpill(SpillPolicy.EXTERNAL)
.build
这样会降低性能,但在使用 INDArray
的 unsafeDuplication
方法时,它可以作为内存缓存对来使用,unsafeDuplication
方法能够高效地(但不安全地)进行 INDArray
复制。
构建一个作业并提交给 Spark 进行训练
在这个阶段,我假设你已经开始浏览并尝试本书相关的 GitHub 仓库中的代码示例(github.com/PacktPublishing/Hands-On-Deep-Learning-with-Apache-Spark
)。如果是这样,你应该已经注意到所有 Scala 示例都使用 Apache Maven(maven.apache.org/
)进行打包和依赖管理。在本节中,我将使用这个工具来构建一个 DL4J 作业,然后将其提交给 Spark 来训练模型。
一旦你确认开发的作业已经准备好在目标 Spark 集群中进行训练,首先要做的是构建 uber-JAR 文件(也叫 fat JAR 文件),它包含 Scala DL4J Spark 程序类和依赖项。检查项目 POM 文件中的 <dependencies>
块,确保所有必需的 DL4J 依赖项都已列出。确保选择了正确版本的 dl4j-Spark 库;本书中的所有示例都旨在与 Scala 2.11.x 和 Apache Spark 2.2.x 一起使用。代码应如下所示:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>dl4j-spark_2.11</artifactId>
<version>0.9.1_spark_2</version>
</dependency>
如果你的项目 POM 文件以及其他依赖项包含对 Scala 和/或任何 Spark 库的引用,请将它们的作用域声明为 provided
,因为它们已经在集群节点上可用。这样,uber-JAR 文件会更轻。
一旦检查了正确的依赖项,你需要在 POM 文件中指示如何构建 uber-JAR。构建 uber-JAR 有三种技术:unshaded、shaded 和 JAR of JARs。对于本案例,最好的方法是使用 shaded uber-JAR。与 unshaded 方法一样,它适用于 Java 默认的类加载器(因此无需捆绑额外的特殊类加载器),但它的优点是跳过某些依赖版本冲突,并且在多个 JAR 中存在相同路径的文件时,可以对其应用追加转换。Shading 可以通过 Maven 的 Shade 插件实现(maven.apache.org/plugins/maven-shade-plugin/
)。该插件需要在 POM 文件的<plugins>
部分注册,方法如下:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.1</version>
<configuration>
<!-- put your configurations here -->
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
当发出以下命令时,本插件会执行:
mvn package -DskipTests
在打包过程结束时,插件的最新版本会用 uber-JAR 替换精简版 JAR,并将其重命名为原始文件名。对于具有以下坐标的项目,uber-JAR 的名称将为rnnspark-1.0.jar
:
<groupId>org.googlielmo</groupId>
<artifactId>rnnspark</artifactId>
<version>1.0</version>
精简版 JAR 依然会被保留,但它会被重命名为original-rnnspark-1.0.jar
。它们都可以在项目根目录的target
子目录中找到。
然后,JAR 可以通过spark-submit
脚本提交到 Spark 集群进行训练,与提交任何其他 Spark 作业的方式相同,如下所示:
$SPARK_HOME/bin/spark-submit --class <package>.<class_name> --master <spark_master_url> <uber_jar>.jar
Spark 分布式训练架构细节
第七章中的分布式网络训练与 Spark 和 DeepLearning4J部分,使用 Spark 训练神经网络,解释了为什么将 MNNs(神经网络模型)以分布式方式在集群中进行训练是重要的,并指出 DL4J 采用了参数平均方法进行并行训练。本节详细介绍了分布式训练方法的架构细节(参数平均和梯度共享,后者从 DL4J 框架的 1.0.0-beta 版本开始替代了参数平均方法)。虽然 DL4J 的分布式训练方式对开发者是透明的,但了解它仍然是有益的。
模型并行性和数据并行性
并行化/分布式训练计算可以通过模型并行性或数据并行性来实现。
在模型并行性(见下图)中,集群的不同节点负责单个 MNN(神经网络模型)中不同部分的计算(例如,每一层网络分配给不同的节点):
图 10.1:模型并行性
在数据并行性(见下图)中,不同的集群节点拥有网络模型的完整副本,但它们获取不同子集的训练数据。然后,来自每个节点的结果会被合并,如下图所示:
图 10.2:数据并行性
这两种方法也可以结合使用,它们并不相互排斥。模型并行性在实践中效果很好,但对于分布式训练,数据并行性是首选;实施、容错和优化集群资源利用等方面(仅举几例)在数据并行性中比在模型并行性中更容易实现。
数据并行性方法需要某种方式来合并结果并同步各工作节点的模型参数。在接下来的两个小节中,我们将探讨 DL4J 中已实现的两种方法(参数平均化和梯度共享)。
参数平均化
参数平均化按以下方式进行:
-
主节点首先根据模型配置初始化神经网络参数
-
然后,它将当前参数的副本分发给每个工作节点
-
每个工作节点使用其自己的数据子集开始训练
-
主节点将全局参数设置为每个工作节点的平均参数
-
在需要处理更多数据的情况下,流程将从步骤 2重新开始
下图展示了从步骤 2到步骤 4的表示:
图 10.3:参数平均化
在此图中,W表示网络中的参数(权重和偏置)。在 DL4J 中,这一实现使用了 Spark 的 TreeAggregate(umbertogriffo.gitbooks.io/apache-spark-best-practices-and-tuning/content/treereduce_and_treeaggregate_demystified.html
)。
参数平均化是一种简单的方法,但它带来了一些挑战。最直观的平均化方法是每次迭代后直接对参数进行平均。虽然这种方法是可行的,但增加的开销可能非常高,网络通信和同步成本可能会抵消通过增加额外节点来扩展集群的任何好处。因此,参数平均化通常会在平均周期(每个工作节点的最小批次数量)大于一时实现。如果平均周期过于稀疏,每个工作节点的局部参数可能会显著偏离,导致模型效果不佳。合适的平均周期通常是每个工作节点每 10 到 20 次最小批次中进行一次。另一个挑战与优化方法(DL4J 的更新方法)相关。已有研究表明,这些方法(ruder.io/optimizing-gradient-descent/
)能够改善神经网络训练过程中的收敛性。但它们有一个内部状态,也可能需要进行平均化。这将导致每个工作节点的收敛速度更快,但代价是网络传输的大小翻倍。
异步随机梯度共享
异步随机梯度共享是最新版本的 DL4J(以及未来版本)所选择的方法。异步随机梯度共享和参数平均的主要区别在于,在异步随机梯度共享中,更新而不是参数被从工作者传递到参数服务器。从架构角度来看,这与参数平均类似(参见下图):
图 10.4:异步随机梯度共享架构
不同之处在于计算参数的公式:
在这里,https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-dl-spk/img/c91cb8c9-1433-4549-b7aa-381e8950a0a3.png 是缩放因子。通过允许将更新 https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-dl-spk/img/108002a8-d16d-451e-836f-f5c30eb9e7bf.png 在计算完成后立即应用到参数向量,从而得到异步随机梯度共享算法。
异步随机梯度共享的主要优点之一是,它可以在分布式系统中获得更高的吞吐量,而无需等待参数平均步骤完成,从而使工作者可以花更多时间进行有用的计算。另一个优点是:与同步更新的情况相比,工作者可以更早地结合来自其他工作者的参数更新。
异步随机梯度共享的一个缺点是所谓的陈旧梯度问题。梯度(更新)的计算需要时间,当一个工作者完成计算并将结果应用到全局参数向量时,参数可能已经更新了不止一次(这个问题在参数平均中看不出来,因为参数平均是同步的)。为了解决陈旧梯度问题,已经提出了几种方法。其中一种方法是根据梯度的陈旧程度,针对每次更新单独调整值 https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-dl-spk/img/c91cb8c9-1433-4549-b7aa-381e8950a0a3.png。另一种方法称为软同步:与其立即更新全局参数向量,参数服务器会等待收集来自任何学习者的一定数量的更新。然后,通过该公式更新参数:
在这里,s 是参数服务器等待收集的更新数量,https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-dl-spk/img/7fbd793e-4160-4bee-bba8-18fec6a7f311.png 是与陈旧程度相关的标量缩放因子。
在 DL4J 中,尽管参数平均实现一直是容错的,但从 1.0.0-beta3 版本开始,梯度共享实现已完全具备容错能力。
将 Python 模型导入到 JVM 中使用 DL4J
在上一章中,我们已经学习了在配置、构建和训练多层神经网络模型时,DL4J API 是如何强大且易于使用的。仅依靠这个框架,在 Scala 或 Java 中实现新模型的可能性几乎是无穷无尽的。
但是,让我们来看一下 Google 的以下搜索结果;它们是关于网络上可用的 TensorFlow 神经网络模型:
图 10.5:关于 TensorFlow 神经网络模型的 Google 搜索结果
你可以看到,从结果来看,这是一个相当令人印象深刻的数字。而这只是一个原始搜索。将搜索精炼到更具体的模型实现时,数字会更高。但什么是 TensorFlow?TensorFlow (www.tensorflow.org/
) 是一个功能强大且全面的开源框架,专为机器学习(ML)和深度学习(DL)开发,由 Google Brain 团队研发。目前,它是数据科学家最常用的框架。因此,它拥有庞大的社区,许多共享的模型和示例可以使用。这也解释了这些庞大的数字。在这些模型中,找到一个符合你特定使用场景需求的预训练模型的几率是很高的。那么,问题在哪里呢?TensorFlow 主要是 Python。
它也支持其他编程语言,比如 Java(适用于 JVM),但是它的 Java API 目前还处于实验阶段,并且不包含在 TensorFlow 的 API 稳定性保证中。此外,TensorFlow 的 Python API 对于没有 Python 开发经验的开发者和没有或只有基础数据科学背景的软件工程师来说,学习曲线较为陡峭。那么,他们如何能从这个框架中受益呢?我们如何在基于 JVM 的环境中复用现有的有效模型?Keras (keras.io/
) 来解救了我们。它是一个开源的高层神经网络库,用 Python 编写,可以用来替代 TensorFlow 的高层 API(下图展示了 TensorFlow 框架架构):
图 10.6:TensorFlow 架构
相较于 TensorFlow,Keras 更加轻量,且更易于原型开发。它不仅可以运行在 TensorFlow 之上,还可以运行在其他后端 Python 引擎上。而且,Keras 还可以用于将 Python 模型导入到 DL4J。Keras 模型导入 DL4J 库提供了导入通过 Keras 框架配置和训练的神经网络模型的功能。
以下图示展示了一旦模型被导入到 DL4J 后,可以使用完整的生产堆栈来使用它:
图 10.7:将 Keras 模型导入 DL4J
现在,我们来详细了解这一过程。对于本节中的示例,我假设你已经在机器上安装了 Python 2.7.x 和 pip
(pypi.org/project/pip/
)包管理器。为了在 Keras 中实现模型,我们必须先安装 Keras 并选择一个后端(此处示例选择 TensorFlow)。必须首先安装 TensorFlow,如下所示:
sudo pip install tensorflow
这仅适用于 CPU。如果你需要在 GPU 上运行,需要安装以下内容:
sudo pip install tensorflow-gpu
现在,我们可以安装 Keras,如下所示:
sudo pip install keras
Keras 使用 TensorFlow 作为默认的张量操作库,因此如果我们选择 TensorFlow 作为后端,就无需采取额外的操作。
我们从简单的开始,使用 Keras API 实现一个 MLP 模型。在进行必要的导入后,输入以下代码:
from keras.models import Sequential
from keras.layers import Dense
我们创建一个 Sequential
模型,如下所示:
model = Sequential()
然后,我们通过 Sequential
的 add
方法添加层,如下所示:
model.add(Dense(units=64, activation='relu', input_dim=100))
model.add(Dense(units=10, activation='softmax'))
该模型的学习过程配置可以通过 compile
方法完成,如下所示:
model.compile(loss='categorical_crossentropy',
optimizer='sgd',
metrics=['accuracy'])
最后,我们将模型序列化为 HDF5 格式,如下所示:
model.save('basic_mlp.h5')
层次数据格式 (HDF) 是一组文件格式(扩展名为 .hdf5 和 .h5),用于存储和管理大量数据,特别是多维数字数组。Keras 使用它来保存和加载模型。
保存这个简单程序 basic_mlp.py
并运行后,模型将被序列化并保存在 basic_mlp.h5
文件中:
sudo python basic_mlp.py
现在,我们准备好将此模型导入到 DL4J 中。我们需要将通常的 DataVec API、DL4J 核心和 ND4J 依赖项,以及 DL4J 模型导入库添加到 Scala 项目中,如下所示:
groupId: org.deeplearning4j
artifactId: deeplearning4j-modelimport
version: 0.9.1
将 basic_mlp.h5
文件复制到项目的资源文件夹中,然后通过编程方式获取其路径,如下所示:
val basicMlp = new ClassPathResource("basic_mlp.h5").getFile.getPath
然后,通过 KerasModelImport
类的 importKerasSequentialModelAndWeights
方法(static.javadoc.io/org.deeplearning4j/deeplearning4j-modelimport/1.0.0-alpha/org/deeplearning4j/nn/modelimport/keras/KerasModelImport.html
)将模型加载为 DL4J 的 MultiLayerNetwork
,如下所示:
val model = KerasModelImport.importKerasSequentialModelAndWeights(basicMlp)
生成一些模拟数据,如下所示:
val input = Nd4j.create(256, 100)
var output = model.output(input)
现在,我们可以像往常一样在 DL4J 中训练模型,如下所示:
model.fit(input, output)
在 第七章,《使用 Spark 训练神经网络》,第八章,《监控与调试神经网络训练》,和 第九章,《解释神经网络输出》中关于训练、监控和评估 DL4J 的内容,也适用于这里。
当然,也可以在 Keras 中训练模型(如下示例):
model.fit(x_train, y_train, epochs=5, batch_size=32)
在这里,x_train
和 y_train
是 NumPy (www.numpy.org/
) 数组,并在保存为序列化格式之前进行评估,方法如下:
loss_and_metrics = model.evaluate(x_test, y_test, batch_size=128)
你可以像之前所解释的那样,导入预训练模型,然后直接运行它。
与 Sequential
模型导入一样,DL4J 也允许导入 Keras 的 Functional
模型。
最新版本的 DL4J 还允许导入 TensorFlow 模型。假设你想要导入这个(github.com/tensorflow/models/blob/master/official/mnist/mnist.py
)预训练模型(一个用于MNIST
数据库的 CNN 估算器)。在 TensorFlow 中进行训练后,你可以将模型保存为序列化格式。TensorFlow 的文件格式基于协议缓冲区(developers.google.com/protocol-buffers/?hl=en
),它是一种语言和平台中立的可扩展序列化机制,用于结构化数据。
将序列化的 mnist.pb
文件复制到 DL4J Scala 项目的资源文件夹中,然后通过编程方式获取并导入模型,方法如下:
val mnistTf = new ClassPathResource("mnist.pb").getFile
val sd = TFGraphMapper.getInstance.importGraph(mnistTf)
最后,给模型输入图像并开始进行预测,方法如下:
for(i <- 1 to 10){
val file = "images/img_%d.jpg"
file = String.format(file, i)
val prediction = predict(file) //INDArray
val batchedArr = Nd4j.expandDims(arr, 0) //INDArray
sd.associateArrayWithVariable(batchedArr, sd.variables().get(0))
val out = sd.execAndEndResult //INDArray
Nd4j.squeeze(out, 0)
...
}
Scala 编程语言的 DL4J 替代方案
DL4J 并不是唯一为 Scala 编程语言提供的深度学习框架,还有两个开源替代方案。在本节中,我们将详细了解它们,并与 DL4J 进行对比。
BigDL
BigDL (bigdl-project.github.io/0.6.0/
) 是一个开源的分布式深度学习框架,适用于 Apache Spark,由英特尔 (www.intel.com
) 实现。它使用与 DL4J 相同的 Apache 2.0 许可证。它是用 Scala 实现的,并暴露了 Scala 和 Python 的 API。它不支持 CUDA。虽然 DL4J 允许在独立模式(包括 Android 移动设备)和分布式模式(有或没有 Spark)下跨平台执行,但 BigDL 仅设计为在 Spark 集群中执行。现有的基准测试表明,训练/运行此框架比最流行的 Python 框架(如 TensorFlow 或 Caffe)要快,因为 BigDL 使用英特尔数学核心库(MKL,software.intel.com/en-us/mkl
),前提是它运行在基于英特尔处理器的机器上。
它为神经网络提供了高级 API,并且支持从 Keras、Caffe 或 Torch 导入 Python 模型。
尽管它是用 Scala 实现的,但在编写本章时,它仅支持 Scala 2.10.x。
从这个框架的最新发展来看,英特尔似乎将提供更多对导入通过其他框架实现的 Python 模型的支持(并且也开始支持一些 TensorFlow 操作)以及 Python API 的增强,而不是 Scala API。
那么,社区和贡献方面呢?BigDL 由英特尔支持和驱动,特别关注这个框架在基于其微处理器的硬件上的使用情况。因此,在其他生产硬件环境中采用这个框架可能存在潜在风险。而 DL4J 由 Skymind(skymind.ai/
)支持,该公司由 Adam Gibson 拥有,Adam Gibson 是该框架的作者之一。就未来发展而言,DL4J 的愿景并不局限于公司的业务。目标是使框架在功能上更全面,并尝试进一步缩小 JVM 语言与 Python 在可用数值计算和深度学习工具/功能方面的差距。同时,DL4J 的贡献者、提交和版本发布数量都在增加。
与 Scala BigDL API 相比,DL4J 对 Scala(和 Java)的 API 更高层次(某种程度的领域特定语言),这对于第一次接触深度学习的 Scala 开发者特别有帮助,因为它加快了熟悉框架的过程,并且让程序员可以更多地关注正在训练和实现的模型。
如果你的计划是留在 JVM 世界,我绝对认为 DL4J 比 BigDL 更合适。
DeepLearning.scala
DeepLearning.scala (deeplearning.thoughtworks.school/
) 是来自 ThoughtWorks (www.thoughtworks.com/
) 的深度学习框架。该框架用 Scala 实现,自开始以来,其目标就是最大化地利用函数式编程和面向对象编程范式。它支持 GPU 加速的 N 维数组。该框架中的神经网络可以通过数学公式构建,因此可以计算公式中权重的导数。
这个框架支持插件,因此可以通过编写自定义插件来扩展它,这些插件可以与开箱即用的插件集共存(目前在模型、算法、超参数、计算功能等方面有一套相当丰富的插件)。
DeepLearning.scala 应用程序可以作为独立程序在 JVM 上运行,作为 Jupyter (jupyter.org/
) 笔记本运行,或者作为 Ammonite (ammonite.io/
) 脚本运行。
数值计算通过 ND4J 进行,与 DL4J 相同。
它不支持 Python,也没有导入通过 Python 深度学习框架实现的模型的功能。
这个框架与其他框架(如 DL4J 和 BigDL)之间的一个大区别如下:神经网络的结构在运行时动态确定。所有的 Scala 语言特性(函数、表达式、控制流等)都可以用于实现。神经网络是 Scala 单子(Monads),因此可以通过组合高阶函数来创建,但这不是 DeepLearning.scala 中唯一的选项;该框架还提供了一种类型类 Applicative
(通过 Scalaz 库,eed3si9n.com/learning-scalaz/Applicative.html
),它允许并行计算多个任务。
本章撰写时,该框架并未提供对 Spark 或 Hadoop 的原生支持。
在不需要 Apache Spark 分布式训练的环境下,DeepLearning.scala 可以是 DL4J 的一个不错替代选择,特别是在你希望使用纯 Scala 实现的情况下。在该编程语言的 API 方面,它比 DL4J 更遵循纯 Scala 编程原则,而 DL4J 的目标是所有在 JVM 上运行的语言(从 Java 开始,然后扩展到 Scala、Clojure 等,甚至包括 Android)。
这两个框架的最初愿景也不同:DL4J 开始时针对的是软件工程师,而 DeepLearning.scala 的方法则更多地面向数据科学家。它在生产环境中的稳定性和性能仍待验证,因为它比 DL4J 更年轻,并且在实际使用案例中的采用者较少。缺乏对从 Python 框架导入现有模型的支持也可能是一个限制,因为你需要从头开始构建和训练模型,而无法依赖于现有的 Python 模型,后者可能非常适合你的特定用例。在社区和发布方面,目前它当然无法与 DL4J 和 BigDL 相提并论(尽管它有可能在不久的将来增长)。最后但同样重要的是,官方文档和示例仍然有限,且尚未像 DL4J 那样成熟和全面。
总结
本章讨论了将 DL4J 移动到生产环境时需要考虑的一些概念。特别是,我们理解了堆内存和非堆内存管理的设置方式,了解了 GPU 配置的额外考虑因素,学习了如何准备要提交给 Spark 进行训练的作业 JAR 文件,并看到如何将 Python 模型导入并集成到现有的 DL4J JVM 基础设施中。最后,介绍了 DL4J 与另外两个针对 Scala 的深度学习框架(BigDL 和 DeepLearning.scala)之间的比较,并详细阐述了为什么从生产角度来看,DL4J 可能是一个更好的选择。
在下一章,将解释自然语言处理(NLP)的核心概念,并详细介绍使用 Apache Spark 及其 MLLib(机器学习库)实现 NLP 的完整 Scala 实现。我们将在第十二章中,文本分析与深度学习,介绍使用 DL4J 和/或 Keras/TensorFlow 实现相同的解决方案,并探讨这种方法的潜在局限性。
第十一章:自然语言处理基础
在前一章中,涉及了在 Spark 集群中进行深度学习分布式训练的多个主题。那里介绍的概念适用于任何网络模型。从本章开始,将首先讨论 RNN 或 LSTM 的具体应用场景,接着介绍 CNN 的应用。本章开始时,将介绍以下自然语言处理(NLP)的核心概念:
-
分词器
-
句子分割
-
词性标注
-
命名实体提取
-
词块分析
-
语法分析
上述概念背后的理论将被详细讲解,最后将呈现两个完整的 Scala 例子,一个使用 Apache Spark 和斯坦福核心 NLP 库,另一个使用 Spark 核心和Spark-nlp
库(该库构建在 Apache Spark MLLib 之上)。本章的目标是让读者熟悉 NLP,然后进入基于深度学习(RNN)的实现,使用 DL4J 和/或 Keras/Tensorflow 结合 Spark,这将是下一章的核心内容。
自然语言处理(NLP)
自然语言处理(NLP)是利用计算机科学和人工智能处理与分析自然语言数据,使机器能够像人类一样理解这些数据的领域。在 1980 年代,当这个概念开始受到关注时,语言处理系统是通过手动编写规则来设计的。后来,随着计算能力的增加,一种主要基于统计模型的方法取代了原来的方法。随后的机器学习(ML)方法(最初是监督学习,目前也有半监督或无监督学习)在这一领域取得了进展,例如语音识别软件和人类语言翻译,并且可能会引领更复杂的场景,例如自然语言理解和生成。
下面是 NLP 的工作原理。第一个任务,称为语音转文本过程,是理解接收到的自然语言。一个内置模型执行语音识别,将自然语言转换为编程语言。这个过程通过将语音分解为非常小的单元,然后与之前输入的语音单元进行比较来实现。输出结果确定最可能被说出的单词和句子。接下来的任务,称为词性标注(POS)(在一些文献中也称为词类消歧),使用一组词汇规则识别单词的语法形式(名词、形容词、动词等)。完成这两个阶段后,机器应该能够理解输入语音的含义。NLP 过程的第三个任务可能是文本转语音转换:最终,编程语言被转换为人类可以理解的文本或语音格式。这就是 NLP 的最终目标:构建能够分析、理解并自然生成语言的软件,使计算机能够像人类一样进行交流。
给定一段文本,在实现 NLP 时,有三件事需要考虑和理解:
-
语义信息:单个词的具体含义。例如,考虑单词pole,它可能有不同的含义(磁铁的一端、一根长棍等)。在句子极右和极左是政治系统的两个极端中,为了理解正确的含义,了解极端的相关定义非常重要。读者可以很容易地推断出它指的是哪种含义,但机器在没有机器学习(ML)或深度学习(DL)的情况下无法做到这一点。
-
语法信息:短语结构。考虑句子William 加入了拥有丰富国际经验的足球队。根据如何解读,它有不同的含义(可能是威廉拥有丰富的国际经验,也可能是足球队拥有丰富的国际经验)。
-
上下文信息:词语或短语出现的上下文。例如,考虑形容词low。它在便利的上下文中通常是积极的(例如,这款手机价格很低),但在谈到供应时几乎总是消极的(例如,饮用水供应不足)。
以下小节将解释 NLP 监督学习的主要概念。
分词器
分词意味着在 NLP 机器学习算法中定义一个词是什么。给定一段文本,分词任务是将其切分成片段,称为tokens,同时去除特定字符(如标点符号或分隔符)。例如,给定以下英语输入句子:
To be, or not to be, that is the question
分词的结果将产生以下 11 个 tokens:
To be or or not to be that is the question
分词的一个大挑战是如何确定正确的 tokens。在前一个示例中,决定是很容易的:我们去除了空格和所有标点符号字符。但如果输入文本不是英语呢?例如中文等其他语言,没有空格,前述规则就不起作用了。因此,任何针对 NLP 的机器学习或深度学习模型训练都应考虑到特定语言的规则。
但即使仅限于单一语言,比如英语,也可能出现棘手的情况。考虑以下示例句子:
David Anthony O'Leary 是一位爱尔兰足球经理和前球员
如何处理撇号?在这种情况下,O'Leary
有五种可能的分词方式,分别如下:
-
leary
-
oleary
-
o'leary
-
o' leary
-
o leary
那么,哪个是期望的结果呢?一个快速想到的简单策略可能是把句子中的所有非字母数字字符去掉。因此,获取o
和leary
这些 tokens 是可以接受的,因为用这些 tokens 进行布尔查询搜索会匹配五个案例中的三个。但以下这个句子呢?
Michael O'Leary 批评了在爱尔兰航空罢工的机组人员,称“他们没有被像西伯利亚盐矿工一样对待”。
对于aren’t,有四种可能的词元拆分方式,如下:
-
aren't
-
arent
-
are n't
-
aren t
再次强调,虽然o
和leary
的拆分看起来没问题,但aren
和t
的拆分怎么样呢?最后这个拆分看起来不太好;用这些词元做布尔查询搜索,只有四种情况中的两种能匹配。
词元化的挑战和问题是语言特定的。在这种情况下,需要深入了解输入文档的语言。
句子分割
句子分割是将文本拆分成句子的过程。从定义来看,这似乎是一个简单的过程,但也可能会遇到一些困难,例如,存在可能表示不同含义的标点符号:
Streamsets 公司发布了新的 Data Collector 3.5.0 版本。新功能之一是 MongoDB 查找处理器。
看一下前面的文本,你会发现同一个标点符号(.
)被用来表示三种不同的意思,而不仅仅是作为句子的分隔符。某些语言,比如中文,拥有明确的句尾标记,而其他语言则没有。因此,必须制定一个策略。在像前面例子中这种情况下,找到句子结束的位置的最快且最粗暴的方法是:
-
如果是句号,那么它表示一句话的结束。
-
如果紧接句号的词元出现在预先编译的缩写词列表中,那么句号不表示句子的结束。
-
如果句号后的下一个词元是大写字母开头的,那么句号表示一句话的结束。
这个方法能正确处理超过 90%的句子,但可以做得更智能一些,比如使用基于规则的边界消歧技术(自动从标记过句子断句的输入文档中学习一组规则),或者更好的是,使用神经网络(这可以达到超过 98%的准确率)。
词性标注
词性标注是自然语言处理中根据单词的定义和上下文标记文本中每个单词为相应词性的过程。语言有九大类词性——名词、动词、形容词、冠词、代词、副词、连词、介词和感叹词。每一类都有子类。这个过程比词元化和句子分割更复杂。词性标注不能是通用的,因为根据上下文,相同的单词在同一文本中的句子中可能具有不同的词性标签,例如:
请锁好门,并且不要忘记把钥匙留在锁里。
在这里,单词 lock
在同一句话中被两次使用,且含义不同(作为动词和名词)。不同语言之间的差异也应该考虑在内。因此,这是一个无法手动处理的过程,应该由机器来完成。使用的算法可以是基于规则的,也可以是基于随机的。基于规则的算法,为了给未知(或至少模糊的)单词分配标签,利用上下文信息。通过分析单词的不同语言特征,如前后的单词,可以实现歧义消解。基于规则的模型从一组初始规则和数据开始训练,尝试推断出 POS 标注的执行指令。随机标注器涉及不同的方法;基本上,任何包含概率或频率的模型都可以这样标记。一种简单的随机标注器可以仅通过单词与特定标签发生的概率来消除歧义。当然,更复杂的随机标注器效率更高。最流行的之一是隐藏马尔可夫模型(en.wikipedia.org/wiki/Hidden_Markov_model
),这是一种统计模型,其中被建模的系统被假设为具有隐藏状态的马尔可夫过程(en.wikipedia.org/wiki/Markov_chain
)。
命名实体识别(NER)
NER 是 NLP 的一个子任务,其目标是在文本中定位和分类命名实体,并将其划分为预定义的类别。让我们举个例子。我们有以下句子:
Guglielmo 正在为 Packt Publishing 写一本书,出版时间为 2018 年。
对其进行 NER 处理后,得到以下注释文本:
[Guglielmo][人名] 正在为 [Packt Publishing][组织] 写一本书,出版时间为 [2018][时间] 。
已经检测到三个实体,一个人,Guglielmo
,一个由两个标记组成的组织,Packt Publishing
,以及一个时间表达,2018
。
传统上,NER 应用于结构化文本,但最近,非结构化文本的使用案例数量有所增加。
自动化实现此过程的挑战包括大小写敏感性(早期算法经常无法识别例如 Guglielmo Iozzia 和 GUGLIELMO IOZZIA 是同一个实体)、标点符号的不同使用以及缺失的分隔符。NER 系统的实现使用了基于语言学语法的技术、统计模型和机器学习。基于语法的系统可以提供更高的精度,但在经验丰富的语言学家工作数月的成本上有很大开销,而且召回率较低。基于机器学习的系统具有较高的召回率,但需要大量手动标注的数据来进行训练。无监督方法正在崭露头角,旨在大幅减少数据标注的工作量。
这个过程的另一个挑战是上下文领域——一些研究表明,为一个领域开发的命名实体识别(NER)系统(在该领域达到较高的性能)通常在其他领域表现不佳。例如,一个已经在 Twitter 内容上训练过的 NER 系统,不能简单地应用到医疗记录中,并期望它能达到同样的性能和准确性。这适用于基于规则和统计/机器学习的系统;在新的领域中调整 NER 系统以达到在原始领域成功训练时的同样性能,需要付出相当大的努力。
分块
自然语言处理中的分块(Chunking)是从文本中提取短语的过程。使用分块的原因是,因为简单的词语可能无法代表所分析文本的真实含义。举个例子,考虑短语Great Britain;虽然这两个单独的词语有意义,但更好的做法是将Great Britain作为一个整体来使用。分块通常建立在词性标注(POS tagging)的基础上;通常,词性标注是输入,而分块则是它的输出。这个过程与人类大脑将信息分块以便于处理和理解的方式非常相似。想一想你记忆数字序列(例如借记卡密码、电话号码等)的方式;你通常不会把它们当作单独的数字来记,而是试图将它们分组,以便更容易记住。
分块可以向上或向下进行。向上分块更倾向于抽象化;向下分块则更倾向于寻找更具体的细节。举个例子,考虑在一个票务销售和分发公司的电话中发生的场景。接线员问:“您想购买哪种类型的票?”顾客的回答是:“音乐会票”,这属于向上分块,因为它更倾向于一个更高层次的抽象。然后,接线员提出更多问题,如:“哪种类型”,“哪位艺术家或团体”,“哪个日期和地点”,“多少人”,“哪个区域”等等,以获得更多细节并满足顾客的需求(这就是向下分块)。最终,你可以将分块视为一种集合的层次结构。对于一个特定的上下文,总是有一个更高层次的集合,它有子集,每个子集又可以有其他子集。例如,可以考虑编程语言作为一个更高层次的子集;然后你可以得到以下情况:
编程语言
Scala(编程语言的子集)
Scala 2.11(Scala 的子集)
特性(Scala 的特定概念之一)
迭代器(Scala 的核心特性之一)
解析
NLP 中的解析是确定文本句法结构的过程。它通过分析文本的组成词汇进行工作,并基于文本所在语言的基础语法。解析的结果是输入文本每个句子的解析树。解析树是一个有序的、带根的树,表示句子的句法结构,依据的是某种上下文无关语法(描述给定形式语言中所有可能字符串的规则集)。我们来举个例子。考虑英语语言和以下的语法示例:
sentence -> 名词短语,动词短语
noun-phrase -> 专有名词
noun-phrase -> 决定词,名词
verb-phrase -> 动词,名词短语
考虑短语 Guglielmo wrote a book
,并对其应用解析过程。输出将是这样的解析树:
目前,自动化机器解析的方法主要是统计的、概率的或机器学习(ML)方法。
使用 Spark 进行 NLP 实践
在本节中,将详细介绍在 Apache Spark 中实现 NLP(以及前述核心概念)的几个示例。这些示例不包括 DL4J 或其他深度学习框架,因为多层神经网络的 NLP 将是下一章的主要内容。
虽然 Spark 的核心组件之一,MLLib,是一个机器学习库,但它并未提供 NLP 的相关功能。因此,您需要在 Spark 上使用其他 NLP 库或框架。
使用 Spark 和 Stanford Core NLP 进行 NLP 实践
本章的第一个示例涉及使用 Scala 包装的 Stanford Core NLP (github.com/stanfordnlp/CoreNLP
) 库,它是开源的,并以 GNU 通用公共许可证 v3 发布 (www.gnu.org/licenses/gpl-3.0.en.html
)。它是一个 Java 库,提供一套自然语言分析工具。其基本分发版提供了用于分析英语的模型文件,但该引擎也兼容其他语言的模型。它稳定且适用于生产环境,广泛应用于学术和工业领域。Spark CoreNLP (github.com/databricks/spark-corenlp
) 是 Stanford Core NLP Java 库的 Apache Spark 封装。它已用 Scala 实现。Stanford Core NLP 注释器已作为 Spark DataFrame 封装。
spark-corenlp 库的当前版本包含一个 Scala 类函数,提供了所有高级封装方法,如下所示:
-
cleanXml
:输入一个 XML 文档,并移除所有 XML 标签。 -
tokenize
:将输入句子分割成单词。 -
ssplit
:将输入文档分割成句子。 -
pos
:生成输入句子的词性标签。 -
lemma
:生成输入句子的词形还原。 -
ner
:生成输入句子的命名实体标签。 -
depparse
:生成输入句子的语义依赖关系。 -
coref
:生成输入文档的coref
链。 -
natlog
:生成输入句子中每个词元的自然逻辑极性。可能的返回值有:up(上升)、down(下降)或 flat(平稳)。 -
openie
:生成一组开放的 IE 三元组,表示为扁平的四元组。 -
sentiment
:测量输入句子的情感。情感评分的范围是从零(强烈负面)到四(强烈正面)。
首先要做的是设置此示例的依赖项。它依赖于 Spark SQL 和 Stanford core NLP 3.8.0(需要通过 Models
分类器显式指定模型的导入),如下所示:
groupId: edu.stanford.nlp
artifactId: stanford-corenlp
version: 3.8.0
groupId: edu.stanford.nlp
artifactId: stanford-corenlp
version: 3.8.0
classifier: models
当您只需要处理某一种语言时,例如西班牙语,您也可以选择仅导入该语言的模型,通过其特定的分类器,方式如下:
groupId: edu.stanford.nlp
artifactId: stanford-corenlp
version: 3.8.0
classifier: models-spanish
在 Maven 中央库中没有可用的 spark-corenlp
库。因此,您必须从 GitHub 源代码构建其 JAR 文件,然后将其添加到您的 NLP 应用程序的类路径中,或者如果您的应用程序依赖于某个工件库(例如 JFrog Artifactory(jfrog.com/artifactory/
)、Apache Archiva(archiva.apache.org/index.cgi
)或 Sonatype Nexus OSS(www.sonatype.com/nexus-repository-oss
)),请将 JAR 文件存储在那里,并按照与 Maven 中央库中任何其他依赖项相同的方式,将其依赖关系添加到您的项目构建文件中。
我之前提到过,spark-corenlp
将 Stanford core NLP 注释器封装为 DataFrame。因此,源代码中需要做的第一件事是创建一个 SparkSession
,如下所示:
val sparkSession = SparkSession
.builder()
.appName("spark-corenlp example")
.master(master)
.getOrCreate()
现在,创建一个 Sequence
(www.scala-lang.org/api/current/scala/collection/Seq.html
)来表示输入文本内容(XML 格式),然后将其转换为 DataFrame,如下所示:
import sparkSession.implicits._
val input = Seq(
(1, "<xml>Packt is a publishing company based in Birmingham and Mumbai. It is a great publisher.</xml>")
).toDF("id", "text")
给定这个输入,我们可以使用 functions
可用的方法执行不同的 NLP 操作,例如从标签中清理输入的 XML(包含在 input
DataFrame 的 text
字段中)、将每个句子拆分成单词、生成每个句子的命名实体标签,并测量每个句子的情感等操作:
val output = input
.select(cleanxml('text).as('doc))
.select(explode(ssplit('doc)).as('sen))
.select('sen, tokenize('sen).as('words), ner('sen).as('nerTags), sentiment('sen).as('sentiment))
最后,我们打印这些操作的输出(output
本身是一个 DataFrame),如下所示:
output.show(truncate = false)
最后,我们需要停止并销毁 SparkSession
,如下所示:
sparkSession.stop()
执行此示例时,输出如下:
XML 内容已经从标签中清除,句子已按照预期拆分成单个单词,对于某些单词(如Birmingham
、Mumbai
),已生成命名实体标签(LOCATION
)。并且,对于输入的两句话,情感分析结果为积极(3
)!
这种方法是开始使用 Scala 和 Spark 进行 NLP 的好方式;该库提供的 API 简单且高层次,能够帮助人们快速理解 NLP 的核心概念,同时利用 Spark DataFrames 的强大功能。但它也有缺点。当需要实现更复杂和定制化的 NLP 解决方案时,现有的 API 过于简单,难以应对。此外,如果你的最终系统不仅仅是内部使用,而是计划销售并分发给客户,则可能会出现许可问题;斯坦福核心 NLP 库和spark-corenlp
模型依赖于并且在完整的 GNU GPL v3 许可下发布,禁止将其作为专有软件的一部分重新分发。下一节介绍了一个更可行的 Scala 和 Spark 替代方案。
使用 Spark NLP 进行实践操作
另一个可与 Apache Spark 集成以进行 NLP 的替代库是 John Snow Labs 的spark-nlp
(nlp.johnsnowlabs.com/
)(www.johnsnowlabs.com/
)。它是开源的,并且在 Apache 许可证 2.0 下发布,因此与spark-corenlp
不同,它的许可模式使得可以将其作为商业解决方案的一部分重新分发。它是在 Scala 上实现的,基于 Apache Spark ML 模块,并且可以在 Maven 中央仓库中找到。它为机器学习流水线提供了 NLP 注释,这些注释既易于理解和使用,又具有出色的性能,并且能够在分布式环境中轻松扩展。
我在本节中提到的版本是 1.6.3(本书写作时的最新版本)。
spark-nlp
的核心概念是 Spark ML 流水线(spark.apache.org/docs/2.2.1/api/java/org/apache/spark/ml/Pipeline.html
)。一个流水线由一系列阶段组成。每个阶段可以是一个变换器(spark.apache.org/docs/2.2.1/api/java/org/apache/spark/ml/Transformer.html
)或一个估算器(spark.apache.org/docs/2.2.1/api/java/org/apache/spark/ml/Estimator.html
)。变换器将输入数据集转换为另一个数据集,而估算器则将模型拟合到数据上。当流水线的拟合方法被调用时,其各个阶段会按顺序执行。现有三种类型的预训练流水线:基础型、进阶型和情感型。该库还提供了多个预训练的 NLP 模型和多个标注器。但为了澄清 spark-nlp
的核心概念,让我们从一个简单的示例开始。我们尝试实现一个基于 ML 的命名实体标签提取的基本流水线。
以下示例依赖于 Spark SQL 和 MLLib 组件以及spark-nlp
库:
groupId: com.johnsnowlabs.nlp
artifactId: spark-nlp_2.11
version: 1.6.3
我们需要首先启动一个SparkSession
,如下所示:
val sparkSession: SparkSession = SparkSession
.builder()
.appName("Ner DL Pipeline")
.master("local[*]")
.getOrCreate()
在创建管道之前,我们需要实现其各个阶段。第一个阶段是com.johnsnowlabs.nlp.DocumentAssembler
,用于指定应用程序输入的列以进行解析,并指定输出列名称(该列将作为下一个阶段的输入列),如下所示:
val document = new DocumentAssembler()
.setInputCol("text")
.setOutputCol("document")
下一个阶段是Tokenizer
(com.johnsnowlabs.nlp.annotators.Tokenizer
),如下所示:
val token = new Tokenizer()
.setInputCols("document")
.setOutputCol("token")
在此阶段之后,任何输入的句子应该已经被拆分成单个词语。我们需要清理这些词语,因此下一个阶段是normalizer
(com.johnsnowlabs.nlp.annotators.Normalizer
),如下所示:
val normalizer = new Normalizer()
.setInputCols("token")
.setOutputCol("normal")
现在我们可以使用spaek-nlp
库中的一个预训练模型来生成命名实体标签,如下所示:
val ner = NerDLModel.pretrained()
.setInputCols("normal", "document")
.setOutputCol("ner")
这里我们使用了NerDLModel
类(com.johnsnowlabs.nlp.annotators.ner.dl.NerDLModel
),它背后使用的是一个 TensorFlow 预训练模型。该模型生成的命名实体标签采用 IOB 格式(en.wikipedia.org/wiki/Inside%E2%80%93outside%E2%80%93beginning_(tagging)
),因此我们需要将它们转换为更易读的格式。我们可以使用NerConverter
类(com.johnsnowlabs.nlp.annotators.ner.NerConverter
)来实现这一点,如下所示:
val nerConverter = new NerConverter()
.setInputCols("document", "normal", "ner")
.setOutputCol("ner_converter")
最后一个阶段是最终化管道的输出,如下所示:
val finisher = new Finisher()
.setInputCols("ner", "ner_converter")
.setIncludeMetadata(true)
.setOutputAsArray(false)
.setCleanAnnotations(false)
.setAnnotationSplitSymbol("@")
.setValueSplitSymbol("#")
为此,我们使用了Finisher
转换器(com.johnsnowlabs.nlp.Finisher
)。
现在我们可以使用目前创建的阶段来构建管道,如下所示:
val pipeline = new Pipeline().setStages(Array(document, token, normalizer, ner, nerConverter, finisher))
你可能已经注意到,每个阶段的输出列是下一个阶段输入列的输入。这是因为管道的各个阶段会按它们在setStages
方法的输入Array
中列出的顺序依次执行。
现在让我们给应用程序输入一些句子,如下所示:
val testing = Seq(
(1, "Packt is a famous publishing company"),
(2, "Guglielmo is an author")
).toDS.toDF( "_id", "text")
与前一节中spaek-corenlp
的示例相同,我们为输入文本内容创建了一个Sequence
,然后将其转换为 Spark DataFrame。
通过调用pipeline
的fit
方法,我们可以执行所有阶段,如下所示:
val result = pipeline.fit(Seq.empty[String].toDS.toDF("text")).transform(testing)
然后,我们得到如下的结果 DataFrame 输出:
result.select("ner", "ner_converter").show(truncate=false)
这将产生以下输出:
当我们仔细观察时,情况如下所示:
已为单词Packt
生成了一个ORGANIZATION
命名实体标签,为单词Guglielmo
生成了一个PERSON
命名实体标签。
spark-nlp
还提供了一个类,com.johnsnowlabs.util.Benchmark
,用于执行管道执行的基准测试,例如:
Benchmark.time("Time to convert and show") {result.select("ner", "ner_converter").show(truncate=false)}
最后,我们在管道执行结束时停止SparkSession
,如下所示:
sparkSession.stop
现在让我们做一些更复杂的事情。这个第二个示例中的管道使用 n-grams 进行分词(en.wikipedia.org/wiki/N-gram
),它是从给定的文本或语音中提取的n个标记(通常是单词)的序列。此示例的依赖项与本节前面展示的示例相同——Spark SQL、Spark MLLib 和spark-nlp
。
创建SparkSession
并配置一些 Spark 属性,如下所示:
val sparkSession: SparkSession = SparkSession
.builder()
.appName("Tokenize with n-gram example")
.master("local[*]")
.config("spark.driver.memory", "1G")
.config("spark.kryoserializer.buffer.max","200M")
.config("spark.serializer","org.apache.spark.serializer.KryoSerializer")
.getOrCreate()
管道的前三个阶段与之前的示例相同,如下所示:
val document = new DocumentAssembler()
.setInputCol("text")
.setOutputCol("document")
val token = new Tokenizer()
.setInputCols("document")
.setOutputCol("token")
val normalizer = new Normalizer()
.setInputCols("token")
.setOutputCol("normal")
在使用 n-gram 阶段之前添加一个finisher
阶段,如下所示:
val finisher = new Finisher()
.setInputCols("normal")
n-gram 阶段使用了 Spark MLLib 中的NGram
类(spark.apache.org/docs/2.2.1/api/scala/index.html#org.apache.spark.ml.feature.NGram
),如下所示:
val ngram = new NGram()
.setN(3)
.setInputCol("finished_normal")
.setOutputCol("3-gram")
NGram
是一个特征变换器,它将输入的字符串数组转换为 n-grams 数组。在这个示例中,选择的n值是3
。现在,我们需要一个额外的DocumentAssembler
阶段来处理 n-gram 的结果,如下所示:
val gramAssembler = new DocumentAssembler()
.setInputCol("3-gram")
.setOutputCol("3-grams")
让我们实现管道,如下所示:
val pipeline = new Pipeline().setStages(Array(document, token, normalizer, finisher, ngram, gramAssembler))
现在,用与之前示例相同的输入句子来运行应用程序:
import sparkSession.implicits._
val testing = Seq(
(1, "Packt is a famous publishing company"),
(2, "Guglielmo is an author")
).toDS.toDF( "_id", "text")
然后执行管道的各个阶段,如下所示:
val result = pipeline.fit(Seq.empty[String].toDS.toDF("text")).transform(testing)
将结果打印到屏幕上:
result.show(truncate=false)
这会生成以下输出:
最后,我们停止SparkSession
,如下所示:
sparkSession.stop
最后的示例是使用 Vivek Narayanan(github.com/vivekn
)模型进行的机器学习情感分析。情感分析是自然语言处理的一个实际应用,它是通过计算机识别和分类文本中表达的意见,以确定其作者/讲述者对某一产品或话题的态度是积极的、消极的,还是中立的。特别地,在这个示例中,我们将训练并验证电影评论的模型。此示例的依赖项与往常一样——Spark SQL、Spark MLLib 和spark-nlp
。
如往常一样,创建一个SparkSession
(同时配置一些 Spark 属性),如下所示:
val spark: SparkSession = SparkSession
.builder
.appName("Train Vivek N Sentiment Analysis")
.master("local[*]")
.config("spark.driver.memory", "2G")
.config("spark.kryoserializer.buffer.max","200M")
.config("spark.serializer","org.apache.spark.serializer.KryoSerializer")
.getOrCreate
然后我们需要两个数据集,一个用于训练,一个用于测试。为了简便起见,我们将训练数据集定义为一个Sequence
,然后将其转换为 DataFrame,其中列为评论文本和相关情感,如下所示:
import spark.implicits._
val training = Seq(
("I really liked it!", "positive"),
("The cast is horrible", "negative"),
("Never going to watch this again or recommend it", "negative"),
("It's a waste of time", "negative"),
("I loved the main character", "positive"),
("The soundtrack was really good", "positive")
).toDS.toDF("train_text", "train_sentiment")
While the testing data set could be a simple Array:
val testing = Array(
"I don't recommend this movie, it's horrible",
"Dont waste your time!!!"
)
我们现在可以定义管道的各个阶段。前面三个阶段与之前示例管道中的完全相同(DocumentAssembler
、Tokenizer
和Normalizer
),如下所示:
val document = new DocumentAssembler()
.setInputCol("train_text")
.setOutputCol("document")
val token = new Tokenizer()
.setInputCols("document")
.setOutputCol("token")
val normalizer = new Normalizer()
.setInputCols("token")
.setOutputCol("normal")
我们现在可以使用com.johnsnowlabs.nlp.annotators.sda.vivekn.ViveknSentimentApproach
注解器,如下所示:
val vivekn = new ViveknSentimentApproach()
.setInputCols("document", "normal")
.setOutputCol("result_sentiment")
.setSentimentCol("train_sentiment")
And finally we use a Finisher transformer as last stage:
val finisher = new Finisher()
.setInputCols("result_sentiment")
.setOutputCols("final_sentiment")
使用之前定义的各个阶段创建管道:
val pipeline = new Pipeline().setStages(Array(document, token, normalizer, vivekn, finisher))
然后开始训练,如下所示:
val sparkPipeline = pipeline.fit(training)
一旦训练完成,我们可以使用以下测试数据集进行测试:
val testingDS = testing.toSeq.toDS.toDF("testing_text")
println("Updating DocumentAssembler input column")
document.setInputCol("testing_text")
sparkPipeline.transform(testingDS).show()
输出结果如下:
测试数据集中的两个句子已经被正确标记为负面。
当然,也可以通过spark-nlp
的Benchmark
类来进行情感分析的基准测试,如下所示:
Benchmark.time("Spark pipeline benchmark") {
val testingDS = testing.toSeq.toDS.toDF("testing_text")
println("Updating DocumentAssembler input column")
document.setInputCol("testing_text")
sparkPipeline.transform(testingDS).show()
}
在本节结束时,我们可以说明spak-nlp
提供了比spark-corenlp
更多的功能,且与 Spark MLLib 集成良好,并且得益于其许可模型,在应用程序/系统的分发上不会出现相同的问题。它是一个稳定的库,适用于 Spark 环境中的生产环境。不幸的是,它的大部分文档缺失,现有的文档非常简略且维护不善,尽管项目仍在积极开发中。
为了理解某个功能如何工作以及如何将它们结合在一起,你必须浏览 GitHub 中的源代码。该库还使用通过 Python 框架实现的现有 ML 模型,并提供了一个 Scala 类来表示它们,将底层的模型实现细节隐藏起来,避免开发人员接触。这在多个使用场景中都能有效,但为了构建更强大和高效的模型,你可能需要自己实现神经网络模型。只有 DL4J 才能在 Scala 中为开发和训练提供那种自由度。
总结
本章中,我们了解了 NLP 的主要概念,并开始动手使用 Spark,探索了两个潜在有用的库,spark-corenlp
和spark-nlp
。
在下一章,我们将看到如何通过实现复杂的 NLP 场景在 Spark 中实现相同或更好的结果,主要是基于 RNN 的深度学习。我们将通过使用 DL4J、TensorFlow、Keras、TensorFlow 后端以及 DL4J + Keras 模型导入来探索不同的实现方式。