原文:
annas-archive.org/md5/4e1b7010caf1fbe3188c9c515fe244d4
译者:飞龙
第九章:使用自编码器和异常检测进行欺诈分析
在金融公司,如银行、保险公司和信用合作社,检测和防止欺诈是一个重要任务,这对于业务的增长至关重要。到目前为止,在上一章中,我们已经学习了如何使用经典的有监督机器学习模型;现在是时候使用其他的无监督学习算法,比如自编码器。
在本章中,我们将使用一个包含超过 284,807 个信用卡使用实例的数据集,其中只有 0.172%的交易是欺诈交易。因此,这是一个高度不平衡的数据。因此,使用自编码器来预训练分类模型并应用异常检测技术以预测可能的欺诈交易是有意义的;也就是说,我们预计欺诈案件将在整个数据集中表现为异常。
总结来说,通过这个端到端项目,我们将学习以下主题:
-
使用异常值进行异常检测
-
在无监督学习中使用自编码器
-
开发一个欺诈分析预测模型
-
超参数调优,最重要的是特征选择
异常值和异常检测
异常是观察世界中不寻常和意外的模式。因此,分析、识别、理解和预测从已知和未知数据中的异常是数据挖掘中最重要的任务之一。因此,检测异常可以从数据中提取关键信息,这些信息随后可以用于许多应用。
虽然异常是一个广泛接受的术语,但在不同的应用领域中,通常会使用其他同义词,如异常值、背离观察、例外、偏差、惊讶、特异性或污染物。特别是,异常和异常值常常可以互换使用。异常检测在信用卡、保险或医疗保健的欺诈检测、网络安全的入侵检测、安全关键系统的故障检测以及敌方活动的军事监视等领域中得到了广泛应用。
异常检测的重要性源于这样一个事实:在许多应用领域,数据中的异常通常转化为具有重要可操作性的信息。当我们开始探索一个高度不平衡的数据集时,可以使用峰度对数据集进行三种可能的解释。因此,在应用特征工程之前,以下问题需要通过数据探索来回答和理解:
-
所有可用字段中,数据中存在或不存在空值或缺失值的比例是多少?然后,尝试处理这些缺失值,并在不丢失数据语义的情况下很好地解释它们。
-
各个字段之间的相关性是什么?每个字段与预测变量之间的相关性是什么?它们取什么值(即,分类的或非分类的、数值的或字母数字的,等等)?
然后找出数据分布是否有偏。你可以通过查看异常值或长尾来识别偏度(如图 1 所示,可能是稍微偏向右侧或正偏,稍微偏向左侧或负偏)。现在确定异常值是否有助于预测。更准确地说,你的数据具有以下三种可能的峰度之一:
-
如果峰度值小于但接近 3,则为正态峰态(Mesokurtic)
-
如果峰度值大于 3,则为高峰态(Leptokurtic)
-
如果峰度值小于 3,则为低峰态(Platykurtic)
图 1:不平衡数据集中不同类型的偏度
让我们举个例子。假设你对健身步行感兴趣,在过去四周内(不包括周末),你曾在运动场或乡间步行。你花费的时间如下(完成 4 公里步行赛道所需的分钟数):15,16,18,17.16,16.5,18.6,19.0,20.4,20.6,25.15,27.27,25.24,21.05,21.65,20.92,22.61,23.71,35,39,50。使用 R 计算并解释这些值的偏度和峰度,将生成如下的密度图。
图 2中关于数据分布(运动时间)的解释显示,密度图右偏,因此是高峰态(leptokurtic)。因此,位于最右端的数据点可以被视为在我们使用场景中不寻常或可疑。因此,我们可以考虑识别或移除它们以使数据集平衡。然而,这不是该项目的目的,目的仅仅是进行识别。
图 2:运动时间的直方图(右偏)
然而,通过去除长尾,我们不能完全去除不平衡问题。还有另一种方法叫做异常值检测,去除这些数据点可能会有帮助。
此外,我们还可以查看每个单独特征的箱型图。箱型图根据五数概括显示数据分布:最小值、第一个四分位数、中位数、第三个四分位数和最大值,如图 3所示,我们可以通过查看超出三倍四分位距(IQR)的异常值来判断:
图 3:超出三倍四分位距(IQR)的异常值
因此,探索去除长尾是否能为监督学习或无监督学习提供更好的预测是有用的。但对于这个高度不平衡的数据集,暂时没有明确的建议。简而言之,偏度分析在这方面对我们没有帮助。
最后,如果你发现你的模型无法提供完美的分类,但均方误差(MSE)可以为你提供一些线索,帮助识别异常值或异常数据。例如,在我们的案例中,即使我们投影的模型不能将数据集分为欺诈和非欺诈案例,欺诈交易的均方误差(MSE)肯定高于常规交易。所以,即使听起来有些天真,我们仍然可以通过应用 MSE 阈值来识别异常值。例如,我们可以认为 MSE > 0.02 的实例是异常值/离群点。
那么问题是,我们该如何做到这一点呢?通过这个端到端的项目,我们将看到如何使用自编码器和异常检测。我们还将看到如何使用自编码器来预训练一个分类模型。最后,我们将看到如何在不平衡数据上衡量模型的表现。让我们从了解自编码器开始。
自编码器和无监督学习
自编码器是能够在没有任何监督的情况下(即训练集没有标签)学习输入数据高效表示的人工神经网络。这种编码通常具有比输入数据更低的维度,使得自编码器在降维中非常有用。更重要的是,自编码器充当强大的特征检测器,可以用于深度神经网络的无监督预训练。
自编码器的工作原理
自编码器是一个包含三层或更多层的网络,其中输入层和输出层具有相同数量的神经元,而中间(隐藏)层的神经元数量较少。该网络的训练目标是仅仅将每个输入数据的输入模式在输出中再现。问题的显著特点是,由于隐藏层中的神经元数量较少,如果网络能够从示例中学习,并在可接受的范围内进行概括,它将执行数据压缩:隐藏神经元的状态为每个示例提供了输入和输出公共状态的压缩版本。
问题的一个显著特点是,由于隐藏层中的神经元数量较少,如果网络能够从示例中学习,并在可接受的范围内进行概括,它将执行数据压缩:隐藏神经元的状态为每个示例提供了压缩版本的输入和输出公共状态。自编码器的有用应用包括数据去噪和数据可视化的降维。
以下图示展示了自编码器的典型工作原理。它通过两个阶段来重建接收到的输入:一个编码阶段,它对应于原始输入的维度缩减,和一个解码阶段,它能够从编码(压缩)表示中重建原始输入:
图 4:自编码器中的编码器和解码器阶段
作为一种无监督神经网络,自编码器的主要特点在于其对称结构。自编码器有两个组成部分:一个编码器将输入转换为内部表示,接着是一个解码器将内部表示转换回输出。换句话说,自编码器可以看作是编码器和解码器的组合,其中编码器将输入编码为代码,而解码器则将代码解码/重建为原始输入作为输出。因此,多层感知机(MLP)通常具有与自编码器相同的结构,除了输出层中的神经元数量必须等于输入数量。
如前所述,训练自编码器的方式不止一种。第一种方法是一次性训练整个层,类似于多层感知机(MLP)。不过,与使用一些标记输出计算代价函数(如监督学习中一样)不同的是,我们使用输入本身。因此,代价
函数显示实际输入与重构输入之间的差异。
第二种方法是通过贪心训练逐层进行。这种训练实现源自于监督学习中反向传播方法所带来的问题(例如,分类)。在具有大量层的网络中,反向传播方法在梯度计算中变得非常缓慢和不准确。为了解决这个问题,Geoffrey Hinton 应用了一些预训练方法来初始化分类权重,而这种预训练方法是一次对两个相邻层进行的。
高效的数据表示与自编码器
所有监督学习系统面临的一个大问题是所谓的维度诅咒:随着输入空间维度的增加,性能逐渐下降。这是因为为了充分采样输入空间,所需的样本数随着维度的增加呈指数级增长。为了解决这些问题,已经开发出一些优化网络。
第一类是自编码器网络:这些网络被设计和训练用来将输入模式转化为其自身,以便在输入模式的降级或不完整版本出现时,能够恢复原始模式。网络经过训练,可以生成与输入相似的输出数据,而隐藏层则存储压缩后的数据,也就是捕捉输入数据基本特征的紧凑表示。
第二类优化网络是玻尔兹曼机:这类网络由一个输入/输出可见层和一个隐藏层组成。可见层和隐藏层之间的连接是无方向的:数据可以双向流动,即从可见层到隐藏层,或从隐藏层到可见层,不同的神经元单元可以是完全连接的或部分连接的。
让我们看一个例子。决定以下哪个序列你认为更容易记住:
-
45, 13, 37, 11, 23, 90, 79, 24, 87, 47
-
50, 25, 76, 38, 19, 58, 29, 88, 44, 22, 11, 34, 17, 52, 26, 13, 40, 20
看完前面两个序列,似乎第一个序列对于人类来说更容易记住,因为它更短,包含的数字比第二个序列少。然而,如果仔细观察第二个序列,你会发现偶数正好是后一个数字的两倍,而奇数后面跟着一个数字,乘以三再加一。这是一个著名的数字序列,叫做冰雹序列。
然而,如果你能轻松记住长序列,你也能更轻松、更快速地识别数据中的模式。在 1970 年代,研究人员观察到,国际象棋高手能够仅仅通过看棋盘五秒钟,就记住游戏中所有棋子的摆放位置。听起来可能有些争议,但国际象棋专家的记忆力并不比你我更强大。问题在于,他们比非棋手更容易识别棋盘上的模式。自编码器的工作原理是,它首先观察输入,将其转化为更好的内部表示,并能够吸收它已经学习过的内容:
图 5:国际象棋游戏中的自编码器
看一下一个更现实的图形,关于我们刚才讨论的国际象棋例子:隐藏层有两个神经元(即编码器本身),而输出层有三个神经元(换句话说,就是解码器)。因为内部表示的维度低于输入数据(它是 2D 而不是 3D),所以这个自编码器被称为“欠完备”。一个欠完备的自编码器无法轻松地将其输入复制到编码中,但它必须找到一种方法输出其输入的副本。
它被迫学习输入数据中最重要的特征,并丢弃不重要的特征。通过这种方式,自编码器可以与主成分分析(PCA)进行比较,PCA 用于使用比原始数据更少的维度来表示给定的输入。
到目前为止,我们已经了解了自编码器是如何工作的。现在,了解通过离群值识别进行异常检测将会很有意义。
开发欺诈分析模型
在我们完全开始之前,我们需要做两件事:了解数据集,然后准备我们的编程环境。
数据集的描述与线性模型的使用
对于这个项目,我们将使用 Kaggle 上的信用卡欺诈检测数据集。数据集可以从www.kaggle.com/dalpozz/creditcardfraud
下载。由于我正在使用这个数据集,因此通过引用以下出版物来保持透明性是一个好主意:
- Andrea Dal Pozzolo、Olivier Caelen、Reid A. Johnson 和 Gianluca Bontempi,《用欠采样校准概率进行不平衡分类》,在 IEEE 计算智能与数据挖掘研讨会(CIDM)上发表于 2015 年。
数据集包含 2013 年 9 月欧洲持卡人的信用卡交易,仅为两天。总共有 285,299 笔交易,其中只有 492 笔是欺诈交易,占 284,807 笔交易的 0.172%,表明数据集严重不平衡,正类(欺诈)占所有交易的 0.172%。
它只包含数值输入变量,这些变量是 PCA 转换的结果。不幸的是,由于保密问题,我们无法提供有关数据的原始特征和更多背景信息。有 28 个特征,即V1
、V2
、…、V28
,这些是通过 PCA 获得的主成分,除了Time
和Amount
。特征Class
是响应变量,在欺诈案例中取值为 1,否则为 0。我们稍后会详细了解。
问题描述
鉴于类别不平衡比率,我们建议使用精度-召回率曲线下面积(AUPRC)来衡量准确性。对于不平衡分类,混淆矩阵准确性并不具有意义。关于此,可以通过应用过采样或欠采样技术使用线性机器学习模型,如随机森林、逻辑回归或支持向量机。或者,我们可以尝试在数据中找到异常值,因为假设整个数据集中只有少数欺诈案例是异常。
在处理如此严重的响应标签不平衡时,我们在测量模型性能时也需要小心。由于欺诈案例很少,将所有预测为非欺诈的模型已经达到了超过 99%的准确率。但尽管准确率很高,线性机器学习模型不一定能帮助我们找到欺诈案例。
因此,值得探索深度学习模型,如自编码器。此外,我们需要使用异常检测来发现异常值。特别是,我们将看到如何使用自编码器来预训练分类模型,并在不平衡数据上测量模型性能。
准备编程环境
具体而言,我将为这个项目使用多种工具和技术。以下是解释每种技术的列表:
-
H2O/Sparking water:用于深度学习平台(详见上一章节)
-
Apache Spark:用于数据处理环境
-
Vegas:Matplotlib 的替代品,类似于 Python,用于绘图。它可以与 Spark 集成以进行绘图目的。
-
Scala:我们项目的编程语言
嗯,我将创建一个 Maven 项目,所有依赖项都将注入到pom.xml
文件中。pom.xml
文件的完整内容可以从 Packt 仓库下载。所以让我们开始吧:
<dependencies>
<dependency>
<groupId>ai.h2o</groupId>
<artifactId>sparkling-water-core_2.11</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>org.vegas-viz</groupId>
<artifactId>vegas_2.11</artifactId>
<version>0.3.11</version>
</dependency>
<dependency>
<groupId>org.vegas-viz</groupId>
<artifactId>vegas-spark_2.11</artifactId>
<version>0.3.11</version>
</dependency>
</dependencies>
现在,Eclipse 或你喜欢的 IDE 将拉取所有的依赖项。第一个依赖项也会拉取与该 H2O 版本兼容的所有 Spark 相关依赖项。然后,创建一个 Scala 文件并提供一个合适的名称。接下来,我们就准备好了。
步骤 1 - 加载所需的包和库
所以,让我们从导入所需的库和包开始:
package com.packt.ScalaML.FraudDetection
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.sql._
import org.apache.spark.h2o._
import _root_.hex.FrameSplitter
import water.Key
import water.fvec.Frame
import _root_.hex.deeplearning.DeepLearning
import _root_.hex.deeplearning.DeepLearningModel.DeepLearningParameters
import _root_.hex.deeplearning.DeepLearningModel.DeepLearningParameters.Activation
import java.io.File
import water.support.ModelSerializationSupport
import _root_.hex.{ ModelMetricsBinomial, ModelMetrics }
import org.apache.spark.h2o._
import scala.reflect.api.materializeTypeTag
import water.support.ModelSerializationSupport
import water.support.ModelMetricsSupport
import _root_.hex.deeplearning.DeepLearningModel
import vegas._
import vegas.sparkExt._
import org.apache.spark.sql.types._
步骤 2 - 创建 Spark 会话并导入隐式转换
然后我们需要创建一个 Spark 会话作为我们程序的入口:
val spark = SparkSession
.builder
.master("local[*]")
.config("spark.sql.warehouse.dir", "tmp/")
.appName("Fraud Detection")
.getOrCreate()
此外,我们还需要导入 spark.sql 和 h2o 的隐式转换:
implicit val sqlContext = spark.sqlContext
import sqlContext.implicits._
val h2oContext = H2OContext.getOrCreate(spark)
import h2oContext._
import h2oContext.implicits._
步骤 3 - 加载和解析输入数据
我们加载并获取交易数据。然后我们获得分布:
val inputCSV = "data/creditcard.csv"
val transactions = spark.read.format("com.databricks.spark.csv")
.option("header", "true")
.option("inferSchema", true)
.load(inputCSV)
步骤 4 - 输入数据的探索性分析
如前所述,数据集包含V1
到V28
的数值输入变量,这些变量是原始特征经过 PCA 转换后的结果。响应变量Class
告诉我们交易是否是欺诈行为(值=1)或正常交易(值=0)。
还有两个额外的特征,Time
和Amount
。Time
列表示当前交易和第一次交易之间的秒数,而Amount
列表示本次交易转账的金额。所以让我们看看输入数据的一个简要展示(这里只显示了V1
、V2
、V26
和V27
)在图 6中:
图 6:信用卡欺诈检测数据集的快照
我们已经能够加载交易数据,但上面的 DataFrame 没有告诉我们类别的分布情况。所以,让我们计算类别分布并考虑绘制它们:
val distribution = transactions.groupBy("Class").count.collect
Vegas("Class Distribution").withData(distribution.map(r => Map("class" -> r(0), "count" -> r(1)))).encodeX("class", Nom).encodeY("count", Quant).mark(Bar).show
>>>
图 7:信用卡欺诈检测数据集中的类别分布
现在,让我们看看时间是否对可疑交易有重要的影响。Time
列告诉我们交易发生的顺序,但并未提供任何关于实际时间(即一天中的时间)的信息。因此,将它们按天进行标准化,并根据一天中的时间将其分为四组,以便从Time
构建一个Day
列会很有帮助。我为此编写了一个 UDF:
val daysUDf = udf((s: Double) =>
if (s > 3600 * 24) "day2"
else "day1")
val t1 = transactions.withColumn("day", daysUDf(col("Time")))
val dayDist = t1.groupBy("day").count.collect
现在让我们绘制它:
Vegas("Day Distribution").withData(dayDist.map(r => Map("day" -> r(0), "count" -> r(1)))).encodeX("day", Nom).encodeY("count", Quant).mark(Bar).show
>>>
图 8:信用卡欺诈检测数据集中的天数分布
上面的图表显示了这两天的交易数量相同,但更具体地说,day1
的交易数量略多。现在让我们构建dayTime
列。我为此编写了一个 UDF:
val dayTimeUDf = udf((day: String, t: Double) => if (day == "day2") t - 86400 else t)
val t2 = t1.withColumn("dayTime", dayTimeUDf(col("day"), col("Time")))
t2.describe("dayTime").show()
>>>
+-------+------------------+
|summary| dayTime |
+-------+------------------+
| count| 284807|
| mean| 52336.926072744|
| stddev|21049.288810608432|
| min| 0.0|
| max| 86400.0|
+-------+------------------+
现在我们需要获取分位数(q1
、中位数、q2
)并构建时间区间(gr1
、gr2
、gr3
和gr4
):
val d1 = t2.filter($"day" === "day1")
val d2 = t2.filter($"day" === "day2")
val quantiles1 = d1.stat.approxQuantile("dayTime", Array(0.25, 0.5, 0.75), 0)
val quantiles2 = d2.stat.approxQuantile("dayTime", Array(0.25, 0.5, 0.75), 0)
val bagsUDf = udf((t: Double) =>
if (t <= (quantiles1(0) + quantiles2(0)) / 2) "gr1"
elseif (t <= (quantiles1(1) + quantiles2(1)) / 2) "gr2"
elseif (t <= (quantiles1(2) + quantiles2(2)) / 2) "gr3"
else "gr4")
val t3 = t2.drop(col("Time")).withColumn("Time", bagsUDf(col("dayTime")))
然后让我们获取类别0
和1
的分布:
val grDist = t3.groupBy("Time", "class").count.collect
val grDistByClass = grDist.groupBy(_(1))
现在让我们绘制类别0
的组分布:
Vegas("gr Distribution").withData(grDistByClass.get(0).get.map(r => Map("Time" -> r(0), "count" -> r(2)))).encodeX("Time", Nom).encodeY("count", Quant).mark(Bar).show
>>>
图 9:信用卡欺诈检测数据集中类别 0 的组分布
从前面的图表来看,显然大部分是正常交易。现在让我们看看 class 1
的分组分布:
Vegas("gr Distribution").withData(grDistByClass.get(1).get.map(r => Map("Time" -> r(0), "count" -> r(2)))).encodeX("Time", Nom).encodeY("count", Quant).mark(Bar).show
>>>
图 10:信用卡欺诈检测数据集中类 1 的分组分布
所以,四个Time区间中的交易分布显示,大多数欺诈案件发生在组 1。我们当然可以查看转账金额的分布:
val c0Amount = t3.filter($"Class" === "0").select("Amount")
val c1Amount = t3.filter($"Class" === "1").select("Amount")
println(c0Amount.stat.approxQuantile("Amount", Array(0.25, 0.5, 0.75), 0).mkString(","))
Vegas("Amounts for class 0").withDataFrame(c0Amount).mark(Bar).encodeX("Amount", Quantitative, bin = Bin(50.0)).encodeY(field = "*", Quantitative, aggregate = AggOps.Count).show
>>>
图 11:类 0 转账金额的分布
现在让我们为 class 1
绘制相同的图表:
Vegas("Amounts for class 1").withDataFrame(c1Amount).mark(Bar).encodeX("Amount", Quantitative, bin = Bin(50.0)).encodeY(field = "*", Quantitative, aggregate = AggOps.Count).show
>>>
图 12:类 1 转账金额的分布
因此,从前面这两张图表可以看出,欺诈性信用卡交易的转账金额均值较高,但最大金额却远低于常规交易。正如我们在手动构建的 dayTime
列中看到的那样,这并不十分显著,因此我们可以直接删除它。我们来做吧:
val t4 = t3.drop("day").drop("dayTime")
步骤 5 - 准备 H2O DataFrame
到目前为止,我们的 DataFrame(即 t4
)是 Spark DataFrame,但它不能被 H2O 模型使用。所以,我们需要将其转换为 H2O frame。那么我们来进行转换:
val creditcard_hf: H2OFrame = h2oContext.asH2OFrame(t4.orderBy(rand()))
我们将数据集划分为,假设 40% 为有监督训练,40% 为无监督训练,20% 为测试集,使用 H2O 内置的分割器 FrameSplitter:
val sf = new FrameSplitter(creditcard_hf, Array(.4, .4),
Array("train_unsupervised", "train_supervised", "test")
.map(Key.makeFrame), null)
water.H2O.submitTask(sf)
val splits = sf.getResult
val (train_unsupervised, train_supervised, test) = (splits(0), splits(1), splits(2))
在上面的代码片段中,Key.makeFrame
被用作低级任务,用于根据分割比例分割数据帧,同时帮助获得分布式的键/值对。
在 H2O 计算中,键非常关键。H2O 支持分布式的键/值存储,并且具有精确的 Java 内存模型一致性。关键点是,键是用来在云中找到链接值、将其缓存到本地,并允许对链接值进行全局一致的更新的手段。
最后,我们需要将 Time
列从字符串类型显式转换为类别类型(即枚举):
toCategorical(train_unsupervised, 30)
toCategorical(train_supervised, 30)
toCategorical(test, 30)
步骤 6 - 使用自编码器进行无监督预训练
如前所述,我们将使用 Scala 和 h2o
编码器。现在是时候开始无监督自编码器训练了。由于训练是无监督的,这意味着我们需要将 response
列从无监督训练集中排除:
val response = "Class"
val features = train_unsupervised.names.filterNot(_ == response)
接下来的任务是定义超参数,例如隐藏层的数量和神经元、用于重现性的种子、训练轮数以及深度学习模型的激活函数。对于无监督预训练,只需将自编码器参数设置为 true
:
var dlParams = new DeepLearningParameters()
dlParams._ignored_columns = Array(response))// since unsupervised, we ignore the label
dlParams._train = train_unsupervised._key // use the train_unsupervised frame for training
dlParams._autoencoder = true // use H2O built-in autoencoder dlParams._reproducible = true // ensure reproducibility dlParams._seed = 42 // random seed for reproducibility
dlParams._hidden = ArrayInt
dlParams._epochs = 100 // number of training epochs
dlParams._activation = Activation.Tanh // Tanh as an activation function
dlParams._force_load_balance = false var dl = new DeepLearning(dlParams)
val model_nn = dl.trainModel.get
在上面的代码中,我们应用了一种叫做瓶颈训练的技术,其中中间的隐藏层非常小。这意味着我的模型必须降低输入数据的维度(在这种情况下,降到两个节点/维度)。
然后,自编码器模型将学习输入数据的模式,而不考虑给定的类别标签。在这里,它将学习哪些信用卡交易是相似的,哪些交易是异常值或离群点。不过,我们需要记住,自编码器模型对数据中的离群点非常敏感,这可能会破坏其他典型模式。
一旦预训练完成,我们应该将模型保存在.csv
目录中:
val uri = new File(new File(inputCSV).getParentFile, "model_nn.bin").toURI ModelSerializationSupport.exportH2OModel(model_nn, uri)
重新加载模型并恢复以便进一步使用:
val model: DeepLearningModel = ModelSerializationSupport.loadH2OModel(uri)
现在,让我们打印模型的指标,看看训练的效果如何:
println(model)
>>>
图 13:自编码器模型的指标
太棒了!预训练进展非常顺利,因为我们可以看到 RMSE 和 MSE 都相当低。我们还可以看到,一些特征是相当不重要的,例如v16
、v1
、v25
等。我们稍后会分析这些。
步骤 7 - 使用隐藏层进行降维
由于我们使用了一个中间有两个节点的浅层自编码器,因此使用降维来探索我们的特征空间是值得的。我们可以使用scoreDeepFeatures()
方法提取这个隐藏特征并绘制图形,展示输入数据的降维表示。
scoreDeepFeatures()
方法会即时评分自编码重构,并提取给定层的深度特征。它需要以下参数:原始数据的框架(可以包含响应,但会被忽略),以及要提取特征的隐藏层的层索引。最后,返回一个包含深度特征的框架,其中列数为隐藏[层]。
现在,对于监督训练,我们需要提取深度特征。我们从第 2 层开始:
var train_features = model_nn.scoreDeepFeatures(train_unsupervised, 1)
train_features.add("Class", train_unsupervised.vec("Class"))
最终聚类识别的绘图如下:
train_features.setNames(train_features.names.map(_.replaceAll("[.]", "-")))
train_features._key = Key.make()
water.DKV.put(train_features)
val tfDataFrame = asDataFrame(train_features) Vegas("Compressed").withDataFrame(tfDataFrame).mark(Point).encodeX("DF-L2-C1", Quantitative).encodeY("DF-L2-C2", Quantitative).encodeColor(field = "Class", dataType = Nominal).show
>>>
图 14:类别 0 和 1 的最终聚类
从前面的图中,我们无法看到任何与非欺诈实例明显区分的欺诈交易聚类,因此仅使用我们的自编码器模型进行降维不足以识别数据集中的欺诈行为。但我们可以使用隐藏层之一的降维表示作为模型训练的特征。例如,可以使用第一层或第三层的 10 个特征。现在,让我们从第 3 层提取深度特征:
train_features = model_nn.scoreDeepFeatures(train_unsupervised, 2)
train_features._key = Key.make()
train_features.add("Class", train_unsupervised.vec("Class"))
water.DKV.put(train_features)
val features_dim = train_features.names.filterNot(_ == response)
val train_features_H2O = asH2OFrame(train_features)
现在,让我们再次使用新维度的数据集进行无监督深度学习:
dlParams = new DeepLearningParameters()
dlParams._ignored_columns = Array(response)
dlParams._train = train_features_H2O
dlParams._autoencoder = true
dlParams._reproducible = true
dlParams._ignore_const_cols = false
dlParams._seed = 42
dlParams._hidden = ArrayInt
dlParams._epochs = 100
dlParams._activation = Activation.Tanh
dlParams._force_load_balance = false dl = new DeepLearning(dlParams)
val model_nn_dim = dl.trainModel.get
然后我们保存模型:
ModelSerializationSupport.exportH2OModel(model_nn_dim, new File(new File(inputCSV).getParentFile, "model_nn_dim.bin").toURI)
为了衡量模型在测试数据上的表现,我们需要将测试数据转换为与训练数据相同的降维形式:
val test_dim = model_nn.scoreDeepFeatures(test, 2)
val test_dim_score = model_nn_dim.scoreAutoEncoder(test_dim, Key.make(), false)
val result = confusionMat(test_dim_score, test, test_dim_score.anyVec.mean)
println(result.deep.mkString("n"))
>>>
Array(38767, 29)
Array(18103, 64)
现在,从识别欺诈案例的角度来看,这实际上看起来相当不错:93%的欺诈案例已被识别!
步骤 8 - 异常检测
我们还可以问一下哪些实例被认为是我们测试数据中的离群值或异常值。根据之前训练的自编码器模型,输入数据将被重构,并为每个实例计算实际值与重构值之间的 MSE。我还计算了两个类别标签的平均 MSE:
test_dim_score.add("Class", test.vec("Class"))
val testDF = asDataFrame(test_dim_score).rdd.zipWithIndex.map(r => Row.fromSeq(r._1.toSeq :+ r._2))
val schema = StructType(Array(StructField("Reconstruction-MSE", DoubleType, nullable = false), StructField("Class", ByteType, nullable = false), StructField("idRow", LongType, nullable = false)))
val dffd = spark.createDataFrame(testDF, schema)
dffd.show()
>>>
图 15:显示均方误差(MSE)、类别和行 ID 的数据框
看着这个数据框,确实很难识别出离群值。但如果将它们绘制出来,可能会提供更多的见解:
Vegas("Reduced Test", width = 800, height = 600).withDataFrame(dffd).mark(Point).encodeX("idRow", Quantitative).encodeY("Reconstruction-MSE", Quantitative).encodeColor(field = "Class", dataType = Nominal).show
>>>
图 16:不同行 ID 下重建的 MSE 分布
正如我们在图表中所看到的,欺诈与非欺诈案例之间并没有完美的分类,但欺诈交易的平均 MSE 确实高于常规交易。但是需要进行最低限度的解释。
从前面的图表中,我们至少可以看到,大多数idRows的 MSE 为5µ。或者,如果我们将 MSE 阈值提高到10µ,那么超出此阈值的数据点可以视为离群值或异常值,即欺诈交易。
步骤 9 - 预训练的监督模型
现在我们可以尝试使用自编码器模型作为监督模型的预训练输入。这里,我再次使用神经网络。该模型现在将使用来自自编码器的权重进行模型拟合。然而,需要将类别从整数转换为类别型,以便进行分类训练。否则,H2O 训练算法将把它当作回归问题处理:
toCategorical(train_supervised, 29)
现在,训练集(即train_supervised
)已经为监督学习准备好了,我们可以开始进行训练了:
val train_supervised_H2O = asH2OFrame(train_supervised)
dlParams = new DeepLearningParameters()
dlParams._pretrained_autoencoder = model_nn._key
dlParams._train = train_supervised_H2O
dlParams._reproducible = true
dlParams._ignore_const_cols = false
dlParams._seed = 42
dlParams._hidden = ArrayInt
dlParams._epochs = 100
dlParams._activation = Activation.Tanh
dlParams._response_column = "Class"
dlParams._balance_classes = true dl = new DeepLearning(dlParams)
val model_nn_2 = dl.trainModel.get
做得很好!我们现在已经完成了监督训练。接下来,让我们看看预测类别与实际类别的对比:
val predictions = model_nn_2.score(test, "predict")
test.add("predict", predictions.vec("predict"))
asDataFrame(test).groupBy("Class", "predict").count.show //print
>>>
+-----+-------+-----+
|Class|predict|count|
+-----+-------+-----+
| 1| 0| 19|
| 0| 1| 57|
| 0| 0|56804|
| 1| 1| 83|
+-----+-------+-----+
现在,这看起来好多了!我们确实错过了 17% 的欺诈案例,但也没有错误地分类太多非欺诈案例。在现实生活中,我们会花更多的时间通过示例来改进模型,执行超参数调优的网格搜索,回到原始特征并尝试不同的工程特征和/或尝试不同的算法。现在,如何可视化前面的结果呢?让我们使用 Vegas
包来实现:
Vegas().withDataFrame(asDataFrame(test)).mark(Bar).encodeY(field = "*", dataType = Quantitative, AggOps.Count, axis = Axis(title = "", format = ".2f"), hideAxis = true).encodeX("Class", Ord).encodeColor("predict", Nominal, scale = Scale(rangeNominals = List("#EA98D2", "#659CCA"))).configMark(stacked = StackOffset.Normalize).show
>>>
图 17:使用监督训练模型的预测类别与实际类别对比
步骤 10 - 在高度不平衡的数据上进行模型评估
由于数据集在非欺诈案例上高度不平衡,因此使用模型评估指标(如准确率或曲线下面积(AUC))是没有意义的。原因是,这些指标会根据大多数类的高正确分类率给出过于乐观的结果。
AUC 的替代方法是使用精确度-召回率曲线,或者敏感度(召回率)-特异度曲线。首先,让我们使用ModelMetricsSupport
类中的modelMetrics()
方法来计算 ROC:
val trainMetrics = ModelMetricsSupport.modelMetricsModelMetricsBinomial
val auc = trainMetrics._auc
val metrics = auc._tps.zip(auc._fps).zipWithIndex.map(x => x match { case ((a, b), c) => (a, b, c) })
val fullmetrics = metrics.map(_ match { case (a, b, c) => (a, b, auc.tn(c), auc.fn(c)) })
val precisions = fullmetrics.map(_ match { case (tp, fp, tn, fn) => tp / (tp + fp) })
val recalls = fullmetrics.map(_ match { case (tp, fp, tn, fn) => tp / (tp + fn) })
val rows = for (i <- 0 until recalls.length) yield r(precisions(i), recalls(i))
val precision_recall = rows.toDF()
现在,我们已经有了precision_recall
数据框,绘制它会非常令人兴奋。那么我们就这样做:
Vegas("ROC", width = 800, height = 600).withDataFrame(precision_recall).mark(Line).encodeX("recall", Quantitative).encodeY("precision", Quantitative).show
>>>
图 18:精确度-召回率曲线
精确度是预测为欺诈的测试案例中,真正是欺诈的比例,也叫做真实正例预测。另一方面,召回率或敏感度是被识别为欺诈的欺诈案例的比例。而特异度是被识别为非欺诈的非欺诈案例的比例。
前面的精确度-召回率曲线告诉我们实际欺诈预测与被预测为欺诈的欺诈案例的比例之间的关系。现在,问题是如何计算敏感度和特异度。好吧,我们可以使用标准的 Scala 语法来做到这一点,并通过Vegas
包绘制它:
val sensitivity = fullmetrics.map(_
match {
case (tp, fp, tn, fn) => tp / (tp + fn) })
val specificity = fullmetrics.map(_
match {
case (tp, fp, tn, fn) => tn / (tn + fp) })
val rows2 =
for (i <- 0 until specificity.length)
yield r2(sensitivity(i), specificity(i))
val sensitivity_specificity = rows2.toDF
Vegas("sensitivity_specificity", width = 800, height = 600).withDataFrame(sensitivity_specificity).mark(Line).encodeX("specificity", Quantitative).encodeY("sensitivity", Quantitative).show
>>>
图 19:敏感度与特异度曲线
现在,前面的敏感度-特异度曲线告诉我们两个标签下正确预测类别之间的关系——例如,如果我们有 100%正确预测的欺诈案例,那么将没有正确分类的非欺诈案例,反之亦然。
最后,通过手动检查不同的预测阈值,并计算两个类别中正确分类的案例数量,从不同角度更深入地分析会很有帮助。更具体地说,我们可以直观地检查不同预测阈值下的真实正例、假正例、真实负例和假负例——例如,从 0.0 到 1.0:
val withTh = auc._tps.zip(auc._fps)
.zipWithIndex
.map(x => x match { case ((a, b), c)
=> (a, b, auc.tn(c), auc.fn(c), auc._ths(c)) })
val rows3 = for (i <- 0 until withTh.length) yield r3(withTh(i)._1, withTh(i)._2, withTh(i)._3, withTh(i)._4, withTh(i)._5)
首先,让我们绘制真实正例的图:
Vegas("tp", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).encodeX("th", Quantitative).encodeY("tp", Quantitative).show
>>>
图 20:在[0.0, 1.0]范围内的真实正例数量随不同预测阈值变化
其次,让我们绘制假正例的图:
Vegas("fp", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).encodeX("th", Quantitative).encodeY("fp", Quantitative).show
>>>
图 21:在[0.0, 1.0]范围内的假正例数量随不同预测阈值变化
然而,前面的图形不容易解读。所以让我们为datum.th
设置一个 0.01 的阈值,并重新绘制:
Vegas("fp", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).filter("datum.th > 0.01").encodeX("th", Quantitative).encodeY("fp", Quantitative).show
>>>
图 22:在[0.0, 1.0]范围内的假正例数量随不同预测阈值变化
然后,轮到绘制真实负例了:
Vegas("tn", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).encodeX("th", Quantitative).encodeY("tn", Quantitative).show
>>>
图 23:在[0.0, 1.0]范围内的假正例数量随不同预测阈值变化
最后,让我们绘制假负例的图,如下所示:
Vegas("fn", width = 800, height = 600).withDataFrame(rows3.toDF).mark(Line).encodeX("th", Quantitative).encodeY("fn", Quantitative).show
>>>
图 24:在[0.0, 1.0]范围内的假正例数量随不同预测阈值变化
因此,前面的图表告诉我们,当我们将预测阈值从默认的 0.5 提高到 0.6 时,我们可以增加正确分类的非欺诈性案例的数量,而不会丧失正确分类的欺诈性案例。
第 11 步 - 停止 Spark 会话和 H2O 上下文
最后,停止 Spark 会话和 H2O 上下文。以下的stop()
方法调用将分别关闭 H2O 上下文和 Spark 集群:
h2oContext.stop(stopSparkContext = true)
spark.stop()
第一种尤其重要,否则有时它不会停止 H2O 流动,但仍然占用计算资源。
辅助类和方法
在之前的步骤中,我们看到了一些类或方法,这里也应该进行描述。第一个方法名为toCategorical()
,它将 Frame 列从 String/Int 转换为枚举类型;用于将dayTime
袋(即gr1
、gr2
、gr3
、gr4
)转换为类似因子的类型。该函数也用于将Class
列转换为因子类型,以便执行分类:
def toCategorical(f: Frame, i: Int): Unit = {
f.replace(i, f.vec(i).toCategoricalVec)
f.update()
}
这个方法根据一个阈值构建异常检测的混淆矩阵,如果实例被认为是异常的(如果其 MSE 超过给定阈值):
def confusionMat(mSEs:water.fvec.Frame,actualFrame:water.fvec.Frame,thresh: Double):Array[Array[Int]] = {
val actualColumn = actualFrame.vec("Class");
val l2_test = mSEs.anyVec();
val result = Array.ofDimInt
var i = 0
var ii, jj = 0
for (i <- 0 until l2_test.length().toInt) {
ii = if (l2_test.at(i) > thresh) 1 else 0;
jj = actualColumn.at(i).toInt
result(ii)(jj) = result(ii)(jj) + 1
}
result
}
除了这两个辅助方法外,我还定义了三个 Scala 案例类,用于计算精准度、召回率;灵敏度、特异度;真正例、假正例、真负例和假负例等。签名如下:
caseclass r(precision: Double, recall: Double)
caseclass r2(sensitivity: Double, specificity: Double)
caseclass r3(tp: Double, fp: Double, tn: Double, fn: Double, th: Double)
超参数调优和特征选择
以下是通过调节超参数来提高准确度的一些方法,如隐藏层的数量、每个隐藏层中的神经元数、训练轮次(epochs)以及激活函数。当前基于 H2O 的深度学习模型实现支持以下激活函数:
-
ExpRectifier
-
ExpRectifierWithDropout
-
Maxout
-
MaxoutWithDropout
-
Rectifier
-
RectifierWithDropout
-
Tanh
-
TanhWithDropout
除了Tanh
之外,我没有尝试其他激活函数用于这个项目。不过,你应该尝试一下。
使用基于 H2O 的深度学习算法的最大优势之一是我们可以获得相对的变量/特征重要性。在前面的章节中,我们已经看到,使用 Spark 中的随机森林算法也可以计算变量的重要性。因此,如果你的模型表现不好,可以考虑丢弃不重要的特征,并重新进行训练。
让我们来看一个例子;在图 13中,我们已经看到在自动编码器的无监督训练中最重要的特征。现在,在监督训练过程中也可以找到特征的重要性。我在这里观察到的特征重要性:
图 25:不同预测阈值下的假正例,范围为[0.0, 1.0]
因此,从图 25中可以观察到,特征 Time、V21
、V17
和V6
的重要性较低。那么为什么不将它们去除,再进行训练,看看准确率是否有所提高呢?
然而,网格搜索或交叉验证技术仍然可能提供更高的准确率。不过,我将把这个决定留给你。
总结
在本章中,我们使用了一个数据集,该数据集包含了超过 284,807 个信用卡使用实例,并且每笔交易中只有 0.172%的交易是欺诈性的。我们已经看到如何使用自编码器来预训练分类模型,以及如何应用异常检测技术来预测可能的欺诈交易,尤其是在高度不平衡的数据中——也就是说,我们期望欺诈案件在整个数据集中是异常值。
我们的最终模型现在正确识别了 83%的欺诈案件和几乎 100%的非欺诈案件。然而,我们已经了解了如何使用异常检测来识别离群值,一些超参数调整的方法,最重要的是,特征选择。
循环神经网络(RNN)是一类人工神经网络,其中单元之间的连接形成一个有向循环。RNN 利用来自过去的信息,这样它们就能在具有高时间依赖性的数据中做出预测。这会创建一个网络的内部状态,使其能够展示动态的时间行为。
RNN 接收多个输入向量进行处理,并输出其他向量。与经典方法相比,使用带有长短期记忆单元(LSTM)的 RNN 几乎不需要特征工程。数据可以直接输入到神经网络中,神经网络就像一个黑箱,能够正确建模问题。就预处理的数据量而言,这里的方法相对简单。
在下一章,我们将看到如何使用名为LSTM的循环神经网络(RNN)实现,开发一个人类活动识别(HAR)的机器学习项目,使用的是智能手机数据集。简而言之,我们的机器学习模型将能够从六个类别中分类运动类型:走路、走楼梯、下楼梯、坐着、站立和躺下。
第十章:使用循环神经网络进行人类活动识别
循环神经网络(RNN)是一类人工神经网络,其中单元之间的连接形成一个有向循环。RNN 利用过去的信息,这样它们就能够对具有高时间依赖性的数据进行预测。这会创建一个网络的内部状态,使其能够表现出动态的时间行为。
RNN 接受多个输入向量进行处理并输出其他向量。与传统方法相比,使用带有长短期记忆单元(LSTM)的 RNN 几乎不需要,或者只需极少的特征工程。数据可以直接输入到神经网络中,神经网络像一个黑盒子一样,正确地建模问题。这里的方法在预处理数据的多少上相对简单。
在本章中,我们将看到如何使用 RNN 实现开发机器学习项目,这种实现称为 LSTM,用于人类活动识别(HAR),并使用智能手机数据集。简而言之,我们的机器学习模型将能够从六个类别中分类运动类型:走路、走楼梯、下楼梯、坐着、站立和躺下。
简而言之,在这个从头到尾的项目中,我们将学习以下主题:
-
使用循环神经网络
-
RNN 的长期依赖性和缺点
-
开发用于人类活动识别的 LSTM 模型
-
调优 LSTM 和 RNN
-
摘要
使用 RNN
在本节中,我们首先将提供一些关于 RNN 的背景信息。然后,我们将强调传统 RNN 的一些潜在缺点。最后,我们将看到一种改进的 RNN 变体——LSTM,来解决这些缺点。
RNN 的背景信息及其架构
人类不会从零开始思考;人类思维有所谓的记忆持久性,即将过去的信息与最近的信息关联起来的能力。而传统的神经网络则忽略了过去的事件。例如,在电影场景分类器中,神经网络无法使用过去的场景来分类当前的场景。RNN 的出现是为了解决这个问题:
图 1:RNN 具有循环结构
与传统神经网络不同,RNN 是带有循环的网络,允许信息保持(图 1)。在一个神经网络中,比如A:在某个时刻t,输入x[t]并输出一个值h[t]。因此,从图 1来看,我们可以把 RNN 看作是多个相同网络的副本,每个副本将信息传递给下一个副本。如果我们展开之前的网络,会得到什么呢?嗯,下面的图给出了些许启示:
图 2:图 1 中表示的同一 RNN 的展开表示
然而,前面的展开图并没有提供关于 RNN 的详细信息。相反,RNN 与传统神经网络不同,因为它引入了一个过渡权重W,用于在时间之间传递信息。RNN 一次处理一个顺序输入,更新一种包含所有过去序列元素信息的向量状态。下图展示了一个神经网络,它将**X(t)的值作为输入,然后输出Y(t)**的值:
图 3:一个 RNN 架构可以利用网络的先前状态来发挥优势
如图 1所示,神经网络的前半部分通过函数Z(t) = X(t) * W[in] 表示,神经网络的后半部分则呈现为Y(t)= Z(t) * W[out]。如果你愿意,整个神经网络就是函数Y(t) = (X(t) * W[in]) * W[out]*。
在每个时间点t,调用已学习的模型,这种架构没有考虑之前运行的知识。这就像只看当天数据来预测股市趋势。一个更好的方法是利用一周或几个月的数据中的总体模式:
图 4:一个 RNN 架构,其中所有层中的所有权重都需要随着时间学习
一个更为明确的架构可以在图 4中找到,其中时间共享的权重w2(用于隐藏层)必须在w1(用于输入层)和w3(用于输出层)之外进行学习。
难以置信的是,在过去的几年里,RNN 已被用于各种问题,如语音识别、语言建模、翻译和图像描述。
RNN 和长期依赖问题
RNN 非常强大,也很流行。然而,我们通常只需要查看最近的信息来执行当前任务,而不是很久以前存储的信息。这在自然语言处理中(NLP)进行语言建模时尤为常见。让我们来看一个常见的例子:
图 5:如果相关信息与所需位置之间的间隙较小,RNN 可以学会利用过去的信息
假设一个语言模型试图基于先前的单词预测下一个单词。作为人类,如果我们试图预测the sky is blue中的最后一个词,在没有进一步上下文的情况下,我们最可能预测下一个词是blue。在这种情况下,相关信息与位置之间的间隙较小。因此,RNN 可以轻松学习使用过去的信息。
但是考虑一个更长的句子:Asif 在孟加拉国长大……他在韩国学习……他讲一口流利的孟加拉语,我们需要更多的上下文。在这个句子中,最新的信息告诉我们,下一个单词可能是某种语言的名称。然而,如果我们想要缩小是哪种语言,我们需要从前面的词汇中得到孟加拉国的上下文:
图 6:当相关信息和所需位置之间的间隔较大时,RNNs 无法学会使用过去的信息
在这里,信息间的间隙更大,因此 RNNs 变得无法学习到这些信息。这是 RNN 的一个严重缺点。然而,LSTM 出现并拯救了这一局面。
LSTM 网络
一种 RNN 模型是LSTM。LSTM 的具体实现细节不在本书的范围内。LSTM 是一种特殊的 RNN 架构,最初由 Hochreiter 和 Schmidhuber 在 1997 年提出。这种类型的神经网络最近在深度学习领域被重新发现,因为它避免了梯度消失问题,并提供了出色的结果和性能。基于 LSTM 的网络非常适合时间序列的预测和分类,正在取代许多传统的深度学习方法。
这个名字很有趣,但它的意思正如其字面所示。这个名字表明短期模式在长期内不会被遗忘。LSTM 网络由相互连接的单元(LSTM 块)组成。每个 LSTM 块包含三种类型的门控:输入门、输出门和遗忘门,分别实现写入、读取和重置单元记忆的功能。这些门控不是二进制的,而是模拟的(通常由一个 sigmoid 激活函数管理,映射到范围(0, 1),其中 0 表示完全抑制,1 表示完全激活)。
如果你把 LSTM 单元看作一个黑箱,它的使用方式与基本单元非常相似,唯一不同的是它的表现会更好;训练过程会更快收敛,而且它能够检测数据中的长期依赖关系。那么,LSTM 单元是如何工作的呢?一个基本的 LSTM 单元架构如图 7所示:
图 7:LSTM 单元的框图
现在,让我们看看这个架构背后的数学符号。如果我们不看 LSTM 箱子内部的内容,LSTM 单元本身看起来就像一个普通的存储单元,唯一的区别是它的状态被分成两个向量,h(t) 和 c(t):
-
c 是单元
-
h(t) 是短期状态
-
c(t) 是长期状态
现在让我们打开这个盒子!关键思想是,网络可以学习在长期状态中存储什么,丢弃什么,以及从中读取什么。当长期状态**c[(t-1)]从左到右遍历网络时,你会看到它首先经过一个遗忘门,丢弃一些记忆,然后通过加法操作(将输入门选择的记忆添加进去)加入一些新的记忆。最终得到的c(t)**直接输出,不经过进一步的转换。
因此,在每个时间戳,某些记忆被丢弃,某些记忆被添加。此外,在加法操作后,长期状态会被复制并通过tanh函数,然后结果会被输出门过滤。这产生了短期状态h(t)(即该时间步的单元输出y(t))。现在让我们看看新记忆是从哪里来的,以及门是如何工作的。首先,将当前输入向量**x(t)和前一个短期状态h(t-1)**送入四个不同的全连接层。
这些门的存在使得 LSTM 单元可以在无限时间内记住信息;如果输入门低于激活阈值,单元将保留之前的状态;如果当前状态被启用,它将与输入值结合。如其名所示,遗忘门会重置单元的当前状态(当其值清零时),而输出门决定是否必须执行单元的值。以下方程用于执行 LSTM 计算,得到单元在每个时间步的长期状态、短期状态和输出:
在前面的方程中,W[xi]、W[xf]、W[xo]和W[xg]是每一层的权重矩阵,用于连接输入向量x[(t)]。另一方面,W[hi]、W[hf]、W[ho]和W[hg]是每一层的权重矩阵,用于连接前一个短期状态h[(t-1)]。最后,b[i]、b[f]、*b[o]和b[g]*是每一层的偏置项。
既然我们已经了解了这些,那么 RNN 和 LSTM 网络是如何工作的呢?是时候动手实践了。我们将开始实现一个基于 MXNet 和 Scala 的 LSTM 模型来进行 HAR。
使用 LSTM 模型的人类活动识别
人类活动识别(HAR)数据库是通过记录 30 名研究参与者执行日常生活活动(ADL)时佩戴带有惯性传感器的腰部智能手机的活动数据构建的。目标是将活动分类为执行的六种活动之一。
数据集描述
实验是在一组 30 名志愿者中进行的,年龄范围为 19 至 48 岁。每个人完成了六项活动,即走路、走楼梯、下楼、坐着、站立和躺下,佩戴的设备是三星 Galaxy S II 智能手机,固定在腰部。通过加速度计和陀螺仪,作者以 50 Hz 的恒定速率捕获了三轴线性加速度和三轴角速度。
仅使用了两个传感器,即加速度计和陀螺仪。传感器信号通过应用噪声滤波器进行预处理,然后在 2.56 秒的固定宽度滑动窗口中采样,重叠 50%。这意味着每个窗口有 128 个读数。通过 Butterworth 低通滤波器将来自传感器加速度信号的重力和身体运动分量分离为身体加速度和重力。
欲了解更多信息,请参考以下论文:Davide Anguita, Alessandro Ghio, Luca Oneto, Xavier Parra 和 Jorge L. Reyes-Ortiz。 使用智能手机的人体活动识别的公开数据集。第 21 届欧洲人工神经网络、计算智能与机器学习研讨会,ESANN 2013。比利时布鲁日,2013 年 4 月 24 日至 26 日。
为简便起见,假设重力只包含少数但低频的分量。因此,使用了 0.3 Hz 截止频率的滤波器。从每个窗口中,通过计算时间和频域的变量,得出了一个特征向量。
实验已通过视频录制,手动标注数据。获得的数据集已随机划分为两个集合,其中 70%的志愿者用于生成训练数据,30%用于生成测试数据。现在,当我浏览数据集时,训练集和测试集具有以下文件结构:
图 8:HAR 数据集文件结构
数据集中的每个记录提供以下内容:
-
来自加速度计的三轴加速度和估计的身体加速度
-
来自陀螺仪传感器的三轴角速度
-
一个包含时间和频域变量的 561 特征向量
-
它的活动标签
-
执行实验的主体的标识符
现在我们知道需要解决的问题,是时候探索技术和相关挑战了。正如我之前所说,我们将使用基于 MXNet 的 LSTM 实现。你可能会问:为什么我们不使用 H2O 或 DeepLearning4j?嗯,答案是这两者要么没有基于 LSTM 的实现,要么无法应用于解决这个问题。
设置和配置 Scala 中的 MXNet
Apache MXNet 是一个灵活高效的深度学习库。构建一个高性能的深度学习库需要做出许多系统级设计决策。在这篇设计说明中,我们分享了在设计 MXNet 时所做的具体选择的理由。我们认为这些见解可能对深度学习实践者以及其他深度学习系统的构建者有所帮助。
对于这个项目,我们将需要不同的包和库:Scala、Java、OpenBLAS、ATLAS、OpenCV,最重要的,还有 MXNet。现在让我们一步步地开始配置这些工具。对于 Java 和 Scala,我假设你已经配置好了 Java 和 Scala。接下来的任务是安装构建工具和git
,因为我们将使用来自 GitHub 仓库的 MXNet。只需要在 Ubuntu 上执行以下命令:
$ sudo apt-get update
$ sudo apt-get install -y build-essential git
然后,我们需要安装 OpenBLAS 和 ATLAS。这些库是 MXNet 进行线性代数运算时所必需的。要安装它们,只需执行以下命令:
$ sudo apt-get install -y libopenblas-dev
$ sudo apt-get install -y libatlas-base-dev
我们还需要安装 OpenCV 进行图像处理。让我们通过执行以下命令来安装它:
$ sudo apt-get install -y libopencv-dev
最后,我们需要生成预构建的 MXNet 二进制文件。为此,我们需要克隆并构建 MXNet 的 Scala 版本:
$ git clone --recursive https://github.com/apache/incubator-mxnet.git mxnet --branch 0.12.0
$ cd mxnet
$ make -j $(nproc) USE_OPENCV=1 USE_BLAS=openblas
$ make scalapkg
$ make scalainsta
现在,如果前面的步骤顺利进行,MXNet 的预构建二进制文件将在/home/$user_name/mxnet/scala-package/assembly/linux-x86_64-cpu
(如果配置了 GPU,则为linux-x86_64-gpu
,在 macOS 上为osx-x86_64-cpu
)中生成。请看一下 Ubuntu 上的 CPU 截图:
图 9:生成的 MXNet 预构建二进制文件
现在,开始编写 Scala 代码之前(在 Eclipse 或 IntelliJ 中作为 Maven 或 SBT 项目),下一步任务是将这个 JAR 文件包含到构建路径中。此外,我们还需要一些额外的依赖项来支持 Scala 图表和args4j
:
<dependency>
<groupId>org.sameersingh.scalaplot</groupId>
<artifactId>scalaplot</artifactId>
<version>0.0.4</version>
</dependency>
<dependency>
<groupId>args4j</groupId>
<artifactId>args4j</artifactId>
<version>2.0.29</version>
</dependency>
做得好!一切准备就绪,我们可以开始编码了!
实现一个用于 HAR 的 LSTM 模型
整体算法(HumanAR.scala
)的工作流程如下:
-
加载数据
-
定义超参数
-
使用命令式编程和超参数设置 LSTM 模型
-
应用批处理训练,即选择批量大小的数据,将其输入模型,然后在若干次迭代中评估模型,打印批次损失和准确率
-
输出训练和测试误差的图表
前面的步骤可以通过管道方式进行跟踪和构建:
图 10:生成的 MXNet 预构建二进制文件
现在让我们一步一步地开始实现。确保你理解每一行代码,然后将给定的项目导入到 Eclipse 或 SBT 中。
步骤 1 - 导入必要的库和包
现在开始编码吧。我们从最基础的开始,也就是导入库和包:
package com.packt.ScalaML.HAR
import ml.dmlc.mxnet.Context
import LSTMNetworkConstructor.LSTMModel
import scala.collection.mutable.ArrayBuffer
import ml.dmlc.mxnet.optimizer.Adam
import ml.dmlc.mxnet.NDArray
import ml.dmlc.mxnet.optimizer.RMSProp
import org.sameersingh.scalaplot.MemXYSeries
import org.sameersingh.scalaplot.XYData
import org.sameersingh.scalaplot.XYChart
import org.sameersingh.scalaplot.Style._
import org.sameersingh.scalaplot.gnuplot.GnuplotPlotter
import org.sameersingh.scalaplot.jfreegraph.JFGraphPlotter
步骤 2 - 创建 MXNet 上下文
然后我们为基于 CPU 的计算创建一个 MXNet 上下文。由于我是在使用 CPU,所以我为 CPU 实例化了它。如果你已经配置了 GPU,可以通过提供设备 ID 来使用 GPU:
// Retrieves the name of this Context object
val ctx = Context.cpu()
第 3 步 - 加载和解析训练集与测试集
现在让我们加载数据集。我假设你已经将数据集复制到UCI_HAR_Dataset/
目录下。然后,还需要将其他数据文件放在之前描述的地方:
val datasetPath = "UCI_HAR_Dataset/"
val trainDataPath = s"$datasetPath/train/Inertial Signals"
val trainLabelPath = s"$datasetPath/train/y_train.txt"
val testDataPath = s"$datasetPath/test/Inertial Signals"
val testLabelPath = s"$datasetPath/test/y_test.txt"
现在是时候分别加载训练集和测试集了。为此,我写了两个方法,分别是loadData()
和loadLabels()
,它们位于Utils.scala
文件中。这两个方法及其签名稍后会提供:
val trainData = Utils.loadData(trainDataPath, "train")
val trainLabels = Utils.loadLabels(trainLabelPath)
val testData = Utils.loadData(testDataPath, "test")
val testLabels = Utils.loadLabels(testLabelPath)
loadData()
方法加载并映射来自每个.txt
文件的数据,基于INPUT_SIGNAL_TYPES
数组中定义的输入信号类型,格式为Array[Array[Array[Float]]]
:
def loadData(dataPath: String, name: String): Array[Array[Array[Float]]] = {
val dataSignalsPaths = INPUT_SIGNAL_TYPES.map( signal => s"$dataPath/${signal}${name}.txt" )
val signals = dataSignalsPaths.map { path =>
Source.fromFile(path).mkString.split("n").map { line =>
line.replaceAll(" ", " ").trim().split(" ").map(_.toFloat) }
}
val inputDim = signals.length
val numSamples = signals(0).length
val timeStep = signals(0)(0).length
(0 until numSamples).map { n =>
(0 until timeStep).map { t =>
(0 until inputDim).map( i => signals(i)(n)(t) ).toArray
}
.toArray
}
.toArray
}
如前所述,INPUT_SIGNAL_TYPES
包含了一些有用的常量:它们是神经网络的独立、归一化输入特征:
private val INPUT_SIGNAL_TYPES = Array(
"body_acc_x_",
"body_acc_y_",
"body_acc_z_",
"body_gyro_x_",
"body_gyro_y_",
"body_gyro_z_",
"total_acc_x_",
"total_acc_y_",
"total_acc_z_")
另一方面,loadLabels()
也是一个用户定义的方法,用于仅加载训练集和测试集中的标签:
def loadLabels(labelPath: String): Array[Float] = {
Source.fromFile(labelPath).mkString.split("n").map(_.toFloat - 1)
}
标签在另一个数组中定义,如以下代码所示:
// Output classes: used to learn how to classify
private val LABELS = Array(
"WALKING",
"WALKING_UPSTAIRS",
"WALKING_DOWNSTAIRS",
"SITTING",
"STANDING",
"LAYING")
第 4 步 - 数据集的探索性分析
现在,让我们来看一些关于训练系列数量的统计数据(如前所述,每个系列之间有 50%的重叠)、测试系列数量、每个系列的时间步数以及每个时间步的输入参数数量:
val trainingDataCount = trainData.length // No. of training series
val testDataCount = testData.length // No. of testing series
val nSteps = trainData(0).length // No. of timesteps per series
val nInput = trainData(0)(0).length // No. of input parameters per timestep
println("Number of training series: "+ trainingDataCount)
println("Number of test series: "+ testDataCount)
println("Number of timestep per series: "+ nSteps)
println("Number of input parameters per timestep: "+ nInput)
>>>
输出结果是:
Number of training series: 7352
Number of test series: 2947
Number of timestep per series: 128
Number of input parameters per timestep: 9
第 5 步 - 定义内部 RNN 结构和 LSTM 超参数
现在,让我们定义 LSTM 网络的内部神经网络结构和超参数:
val nHidden = 128 // Number of features in a hidden layer
val nClasses = 6 // Total classes to be predicted
val learningRate = 0.001f
val trainingIters = trainingDataCount * 100 // iterate 100 times on trainset: total 7352000 iterations
val batchSize = 1500
val displayIter = 15000 // To show test set accuracy during training
val numLstmLayer = 3
第 6 步 - LSTM 网络构建
现在,让我们使用前述参数和结构来设置 LSTM 模型:
val model = LSTMNetworkConstructor.setupModel(nSteps, nInput, nHidden, nClasses, batchSize, ctx = ctx)
在前述行中,setupModel()
是完成此任务的方法。getSymbol()
方法实际上构建了 LSTM 单元。稍后我们将看到它的签名。它接受序列长度、输入数量、隐藏层数量、标签数量、批次大小、LSTM 层数量、丢弃率 MXNet 上下文,并使用LSTMModel
的 case 类构建 LSTM 模型:
case class LSTMModel(exec: Executor, symbol: Symbol, data: NDArray, label: NDArray, argsDict: Map[String, NDArray], gradDict: Map[String, NDArray])
现在这是setupModel()
的方法签名:
def setupModel(seqLen: Int, nInput: Int, numHidden: Int, numLabel: Int, batchSize: Int, numLstmLayer: Int = 1, dropout: Float = 0f, ctx: Context = Context.cpu()): LSTMModel = {
//get the symbolic model
val sym = LSTMNetworkConstructor.getSymbol(seqLen, numHidden, numLabel, numLstmLayer = numLstmLayer)
val argNames = sym.listArguments()
val auxNames = sym.listAuxiliaryStates()
// defining the initial argument and binding them to the model
val initC = for (l <- 0 until numLstmLayer) yield (s"l${l}_init_c", (batchSize, numHidden))
val initH = for (l <- 0 until numLstmLayer) yield (s"l${l}_init_h", (batchSize, numHidden))
val initStates = (initC ++ initH).map(x => x._1 -> Shape(x._2._1, x._2._2)).toMap
val dataShapes = Map("data" -> Shape(batchSize, seqLen, nInput)) ++ initStates
val (argShapes, outShapes, auxShapes) = sym.inferShape(dataShapes)
val initializer = new Uniform(0.1f)
val argsDict = argNames.zip(argShapes).map { case (name, shape) =>
val nda = NDArray.zeros(shape, ctx)
if (!dataShapes.contains(name) && name != "softmax_label") {
initializer(name, nda)
}
name -> nda
}.toMap
val argsGradDict = argNames.zip(argShapes)
.filter(x => x._1 != "softmax_label" && x._1 != "data")
.map( x => x._1 -> NDArray.zeros(x._2, ctx) ).toMap
val auxDict = auxNames.zip(auxShapes.map(NDArray.zeros(_, ctx))).toMap
val exec = sym.bind(ctx, argsDict, argsGradDict, "write", auxDict, null, null)
val data = argsDict("data")
val label = argsDict("softmax_label")
LSTMModel(exec, sym, data, label, argsDict, argsGradDict)
}
在前述方法中,我们通过getSymbol()
方法获得了深度 RNN 的符号模型,如下所示。我已经提供了详细的注释,并认为这些足以理解代码的工作流程:
private def getSymbol(seqLen: Int, numHidden: Int, numLabel: Int, numLstmLayer: Int = 1,
dropout: Float = 0f): Symbol = {
//symbolic training and label variables
var inputX = Symbol.Variable("data")
val inputY = Symbol.Variable("softmax_label")
//the initial parameters and cells
var paramCells = Array[LSTMParam]()
var lastStates = Array[LSTMState]()
//numLstmLayer is 1
for (i <- 0 until numLstmLayer) {
paramCells = paramCells :+ LSTMParam(i2hWeight =
Symbol.Variable(s"l${i}_i2h_weight"),
i2hBias = Symbol.Variable(s"l${i}_i2h_bias"),
h2hWeight = Symbol.Variable(s"l${i}_h2h_weight"),
h2hBias = Symbol.Variable(s"l${i}_h2h_bias"))
lastStates = lastStates :+ LSTMState(c =
Symbol.Variable(s"l${i}_init_c"),
h = Symbol.Variable(s"l${i}_init_h"))
}
assert(lastStates.length == numLstmLayer)
val lstmInputs = Symbol.SliceChannel()(inputX)(Map("axis"
> 1, "num_outputs" -> seqLen,
"squeeze_axis" -> 1))
var hiddenAll = Array[Symbol]()
var dpRatio = 0f
var hidden: Symbol = null
//for each one of the 128 inputs, create a LSTM Cell
for (seqIdx <- 0 until seqLen) {
hidden = lstmInputs.get(seqIdx)
// stack LSTM, where numLstmLayer is 1 so the loop will be executed only one time
for (i <- 0 until numLstmLayer) {
if (i == 0) dpRatio = 0f else dpRatio = dropout
//for each one of the 128 inputs, create a LSTM Cell
val nextState = lstmCell(numHidden, inData = hidden,
prevState = lastStates(i),
param = paramCells(i),
seqIdx = seqIdx, layerIdx = i, dropout =
dpRatio)
hidden = nextState.h // has no effect
lastStates(i) = nextState // has no effect
}
// adding dropout before softmax has no effect- dropout is 0 due to numLstmLayer == 1
if (dropout > 0f) hidden = Symbol.Dropout()()(Map("data" -> hidden, "p" -> dropout))
// store the lstm cells output layers
hiddenAll = hiddenAll :+ hidden
}
总结一下,该算法使用 128 个 LSTM 单元并行工作,我将这 128 个单元连接起来并送入输出激活层。让我们来连接这些单元,输出结果:
val finalOut = hiddenAll.reduce(_+_)
然后我们将它们连接到一个输出层,该层对应 6 个标签:
val fc = Symbol.FullyConnected()()(Map("data" -> finalOut, "num_hidden" -> numLabel))
//softmax activation against the label
Symbol.SoftmaxOutput()()(Map("data" -> fc, "label" -> inputY))
在前面的代码片段中,LSTMState
和LSTMParam
是两个案例类,用于定义每个 LSTM 单元的状态,后者接受构建 LSTM 单元所需的参数。最终案例类LSTMState(c: Symbol, h: Symbol)
,LSTMParam(i2hWeight: Symbol, i2hBias: Symbol, h2hWeight: Symbol, h2hBias: Symbol)
。
现在是讨论最重要的步骤——LSTM 单元构建的时候了。我们将使用一些图示和图例,如下图所示:
图 11:在接下来的内容中描述 LSTM 单元所用的图例
LSTM 中的重复模块包含四个相互作用的层,如下图所示:
图 12:在 LSTM 单元内部,即 LSTM 中的重复模块包含四个相互作用的层
一个 LSTM 单元由其状态和参数定义,如前面两个案例类所定义:
-
LSTM 状态:c是单元状态(它的记忆知识),用于训练过程中,h是输出
-
LSTM 参数:通过训练算法进行优化
-
i2hWeight:输入到隐藏的权重
-
i2hBias:输入到隐藏的偏置
-
h2hWeight:隐藏到隐藏的权重
-
h2hBias:隐藏到隐藏的偏置
-
i2h:输入数据的神经网络
-
h2h:来自前一个h的神经网络
在代码中,两个全连接层已经创建、连接,并通过以下代码转换为四个副本。让我们添加一个大小为numHidden * 4
(numHidden
设置为 28)的隐藏层,它以inputdata
作为输入:
val i2h = Symbol.FullyConnected(s"t${seqIdx}_l${layerIdx}_i2h")()(Map("data" -> inDataa, "weight" -> param.i2hWeight, "bias" -> param.i2hBias, "num_hidden" -> numHidden * 4))
然后我们添加一个大小为numHidden * 4
(numHidden
设置为 28)的隐藏层,它以单元的先前输出作为输入:
val h2h = Symbol.FullyConnected(s"t${seqIdx}_l${layerIdx}_h2h")()(Map("data" -> prevState.h,"weight" -> param.h2hWeight,"bias" -> param.h2hBias,"num_hidden" -> numHidden * 4))
现在让我们将它们连接起来:
val gates = i2h + h2h
然后我们在计算门之前制作四个副本:
val sliceGates = Symbol.SliceChannel(s"t${seqIdx}_l${layerIdx}_slice")(gates)(Map("num_outputs" -> 4))
然后我们计算各个门:
val sliceGates = Symbol.SliceChannel(s"t${seqIdx}_l${layerIdx}_slice")(gates)(Map("num_outputs" -> 4))
现在,遗忘门的激活表示为以下代码:
val forgetGate = Symbol.Activation()()(Map("data" -> sliceGates.get(2), "act_type" -> "sigmoid"))
我们可以在以下图示中看到这一点:
图 13:LSTM 单元中的遗忘门
现在,输入门和输入变换的激活表示为以下代码:
val ingate = Symbol.Activation()()(Map("data" -> sliceGates.get(0), "act_type" -> "sigmoid"))
val inTransform = Symbol.Activation()()(Map("data" -> sliceGates.get(1), "act_type" -> "tanh"))
我们也可以在图 14中看到这一点:
图 14:LSTM 单元中的输入门和变换门
下一个状态由以下代码定义:
val nextC = (forgetGate * prevState.c) + (ingate * inTransform)
前面的代码也可以用以下图示表示:
图 15:LSTM 单元中的下一个或转换门
最后,输出门可以用以下代码表示:
val nextH = outGate * Symbol.Activation()()(Map("data" -> nextC, "act_type" -> "tanh"))
前面的代码也可以用以下图示表示:
图 16:LSTM 单元中的输出门
太复杂了?没关系,这里我提供了该方法的完整代码:
// LSTM Cell symbol
private def lstmCell( numHidden: Int, inData: Symbol, prevState: LSTMState, param: LSTMParam,
seqIdx: Int, layerIdx: Int, dropout: Float = 0f): LSTMState = {
val inDataa = {
if (dropout > 0f) Symbol.Dropout()()(Map("data" -> inData, "p" -> dropout))
else inData
}
// add an hidden layer of size numHidden * 4 (numHidden set //to 28) that takes as input)
val i2h = Symbol.FullyConnected(s"t${seqIdx}_l${layerIdx}_i2h")()(Map("data" -> inDataa,"weight" -> param.i2hWeight,"bias" -> param.i2hBias,"num_hidden" -> numHidden * 4))
// add an hidden layer of size numHidden * 4 (numHidden set to 28) that takes output of the cell
val h2h = Symbol.FullyConnected(s"t${seqIdx}_l${layerIdx}_h2h")()(Map("data" -> prevState.h,"weight" -> param.h2hWeight,"bias" -> param.h2hBias,"num_hidden" -> numHidden * 4))
//concatenate them
val gates = i2h + h2h
//make 4 copies of gates
val sliceGates=Symbol.SliceChannel(s"t${seqIdx}_l${layerIdx}_slice")(gates)(Map("num_outputs"
-> 4))
// compute the gates
val ingate = Symbol.Activation()()(Map("data" -> sliceGates.get(0), "act_type" -> "sigmoid"))
val inTransform = Symbol.Activation()()(Map("data" -> sliceGates.get(1), "act_type" -> "tanh"))
val forgetGate = Symbol.Activation()()(Map("data" -> sliceGates.get(2), "act_type" -> "sigmoid"))
val outGate = Symbol.Activation()()(Map("data" -> sliceGates.get(3), "act_type" -> "sigmoid"))
// get the new cell state and the output
val nextC = (forgetGate * prevState.c) + (ingate * inTransform)
val nextH = outGate * Symbol.Activation()()(Map("data" -> nextC, "act_type" -> "tanh"))
LSTMState(c = nextC, h = nextH)
}
步骤 7 - 设置优化器
正如许多研究人员所建议的,RMSProp
优化器帮助 LSTM 网络快速收敛。因此,我也决定在这里使用它:
val opt = new RMSProp(learningRate = learningRate)
此外,待优化的模型参数是其所有参数,除了训练数据和标签(权重和偏置):
val paramBlocks = model.symbol.listArguments()
.filter(x => x != "data" && x != "softmax_label")
.zipWithIndex.map { case (name, idx) =>
val state = opt.createState(idx, model.argsDict(name))
(idx, model.argsDict(name), model.gradDict(name), state, name)
}
.toArray
第 8 步 - 训练 LSTM 网络
现在我们将开始训练 LSTM 网络。不过,在开始之前,我们先定义一些变量来跟踪训练的表现:
val testLosses = ArrayBuffer[Float]()
val testAccuracies = ArrayBuffer[Float]()
val trainLosses = ArrayBuffer[Float]()
val trainAccuracies = ArrayBuffer[Float]()
然后,我们开始执行训练步骤,每次循环进行batch_size
次迭代:
var step = 1
while (step * batchSize <= trainingIters) {
val (batchTrainData, batchTrainLabel) = {
val idx = ((step - 1) * batchSize) % trainingDataCount
if (idx + batchSize <= trainingDataCount) {
val datas = trainData.drop(idx).take(batchSize)
val labels = trainLabels.drop(idx).take(batchSize)
(datas, labels)
} else {
val right = (idx + batchSize) - trainingDataCount
val left = trainingDataCount - idx
val datas = trainData.drop(idx).take(left) ++ trainData.take(right)
val labels = trainLabels.drop(idx).take(left) ++ trainLabels.take(right)
(datas, labels)
}
}
不要偏离主题,但快速回顾一下第 6 步,我们在这里实例化了 LSTM 模型。现在是时候将输入和标签传递给 RNN 了:
model.data.set(batchTrainData.flatten.flatten)
model.label.set(batchTrainLabel)
然后我们进行前向和后向传播:
model.exec.forward(isTrain = true)
model.exec.backward()
此外,我们需要使用在第 7 步中定义的RMSProp
优化器来更新参数:
paramBlocks.foreach {
case (idx, weight, grad, state, name) => opt.update(idx, weight, grad, state)
}
获取如训练误差(即训练数据上的损失和准确度)等指标也会非常有用:
val (acc, loss) = getAccAndLoss(model.exec.outputs(0), batchTrainLabel)
trainLosses += loss / batchSize
trainAccuracies += acc / batchSize
在前面的代码段中,getAccAndLoss()
是一个计算损失和准确度的方法,具体实现如下:
def getAccAndLoss(pred: NDArray, label: Array[Float], dropNum: Int = 0): (Float, Float) = {
val shape = pred.shape
val maxIdx = NDArray.argmax_channel(pred).toArray
val acc = {
val sum = maxIdx.drop(dropNum).zip(label.drop(dropNum)).foldLeft(0f){ case (acc, elem) =>
if (elem._1 == elem._2) acc + 1 else acc
}
sum
}
val loss = pred.toArray.grouped(shape(1)).drop(dropNum).zipWithIndex.map { case (array, idx) =>
array(maxIdx(idx).toInt)
}.map(-Math.log(_)).sum.toFloat
(acc, loss)
}
此外,为了更快的训练,评估网络的某些步骤是很令人兴奋的:
if ( (step * batchSize % displayIter == 0) || (step == 1) || (step * batchSize > trainingIters) ) {
println(s"Iter ${step * batchSize}, Batch Loss = ${"%.6f".format(loss / batchSize)},
Accuracy = ${acc / batchSize}")
}
Iter 1500, Batch Loss = 1.189168, Accuracy = 0.14266667
Iter 15000, Batch Loss = 0.479527, Accuracy = 0.53866667
Iter 30000, Batch Loss = 0.293270, Accuracy = 0.83933336
Iter 45000, Batch Loss = 0.192152, Accuracy = 0.78933334
Iter 60000, Batch Loss = 0.118560, Accuracy = 0.9173333
Iter 75000, Batch Loss = 0.081408, Accuracy = 0.9486667
Iter 90000, Batch Loss = 0.109803, Accuracy = 0.9266667
Iter 105000, Batch Loss = 0.095064, Accuracy = 0.924
Iter 120000, Batch Loss = 0.087000, Accuracy = 0.9533333
Iter 135000, Batch Loss = 0.085708, Accuracy = 0.966
Iter 150000, Batch Loss = 0.068692, Accuracy = 0.9573333
Iter 165000, Batch Loss = 0.070618, Accuracy = 0.906
Iter 180000, Batch Loss = 0.089659, Accuracy = 0.908
Iter 195000, Batch Loss = 0.088301, Accuracy = 0.87333333
Iter 210000, Batch Loss = 0.067824, Accuracy = 0.9026667
Iter 225000, Batch Loss = 0.060650, Accuracy = 0.9033333
Iter 240000, Batch Loss = 0.045368, Accuracy = 0.93733335
Iter 255000, Batch Loss = 0.049854, Accuracy = 0.96
Iter 270000, Batch Loss = 0.062839, Accuracy = 0.968
Iter 285000, Batch Loss = 0.052522, Accuracy = 0.986
Iter 300000, Batch Loss = 0.060304, Accuracy = 0.98733336
Iter 315000, Batch Loss = 0.049382, Accuracy = 0.9993333
Iter 330000, Batch Loss = 0.052441, Accuracy = 0.9766667
Iter 345000, Batch Loss = 0.050224, Accuracy = 0.9546667
Iter 360000, Batch Loss = 0.057141, Accuracy = 0.9306667
Iter 375000, Batch Loss = 0.047664, Accuracy = 0.938
Iter 390000, Batch Loss = 0.047909, Accuracy = 0.93333334
Iter 405000, Batch Loss = 0.043014, Accuracy = 0.9533333
Iter 420000, Batch Loss = 0.054124, Accuracy = 0.952
Iter 435000, Batch Loss = 0.044272, Accuracy = 0.95133334
Iter 450000, Batch Loss = 0.058916, Accuracy = 0.96066666
Iter 465000, Batch Loss = 0.072512, Accuracy = 0.9486667
Iter 480000, Batch Loss = 0.080431, Accuracy = 0.94733334
Iter 495000, Batch Loss = 0.072193, Accuracy = 0.9726667
Iter 510000, Batch Loss = 0.068242, Accuracy = 0.972
Iter 525000, Batch Loss = 0.057797, Accuracy = 0.964
Iter 540000, Batch Loss = 0.063531, Accuracy = 0.918
Iter 555000, Batch Loss = 0.068177, Accuracy = 0.9126667
Iter 570000, Batch Loss = 0.053257, Accuracy = 0.9206667
Iter 585000, Batch Loss = 0.058263, Accuracy = 0.9113333
Iter 600000, Batch Loss = 0.054180, Accuracy = 0.90466666
Iter 615000, Batch Loss = 0.051008, Accuracy = 0.944
Iter 630000, Batch Loss = 0.051554, Accuracy = 0.966
Iter 645000, Batch Loss = 0.059238, Accuracy = 0.9686667
Iter 660000, Batch Loss = 0.051297, Accuracy = 0.9713333
Iter 675000, Batch Loss = 0.052069, Accuracy = 0.984
Iter 690000, Batch Loss = 0.040501, Accuracy = 0.998
Iter 705000, Batch Loss = 0.053661, Accuracy = 0.96066666
ter 720000, Batch Loss = 0.037088, Accuracy = 0.958
Iter 735000, Batch Loss = 0.039404, Accuracy = 0.9533333
第 9 步 - 评估模型
做得好!我们已经完成了训练。现在如何评估测试集呢:
val (testLoss, testAcc) = test(testDataCount, batchSize, testData, testLabels, model)
println(s"TEST SET DISPLAY STEP: Batch Loss = ${"%.6f".format(testLoss)}, Accuracy = $testAcc")
testAccuracies += testAcc
testLosses += testLoss
}
step += 1
}
val (finalLoss, accuracy) = test(testDataCount, batchSize, testData, testLabels, model)
println(s"FINAL RESULT: Batch Loss= $finalLoss, Accuracy= $accuracy")
TEST SET DISPLAY STEP: Batch Loss = 0.065859, Accuracy = 0.9138107
TEST SET DISPLAY STEP: Batch Loss = 0.077047, Accuracy = 0.912114
TEST SET DISPLAY STEP: Batch Loss = 0.069186, Accuracy = 0.90566677
TEST SET DISPLAY STEP: Batch Loss = 0.059815, Accuracy = 0.93043774
TEST SET DISPLAY STEP: Batch Loss = 0.064162, Accuracy = 0.9192399
TEST SET DISPLAY STEP: Batch Loss = 0.063574, Accuracy = 0.9307771
TEST SET DISPLAY STEP: Batch Loss = 0.060209, Accuracy = 0.9229725
TEST SET DISPLAY STEP: Batch Loss = 0.062598, Accuracy = 0.9290804
TEST SET DISPLAY STEP: Batch Loss = 0.062686, Accuracy = 0.9311164
TEST SET DISPLAY STEP: Batch Loss = 0.059543, Accuracy = 0.9250085
TEST SET DISPLAY STEP: Batch Loss = 0.059646, Accuracy = 0.9263658
TEST SET DISPLAY STEP: Batch Loss = 0.062546, Accuracy = 0.92941976
TEST SET DISPLAY STEP: Batch Loss = 0.061765, Accuracy = 0.9263658
TEST SET DISPLAY STEP: Batch Loss = 0.063814, Accuracy = 0.9307771
TEST SET DISPLAY STEP: Batch Loss = 0.062560, Accuracy = 0.9324737
TEST SET DISPLAY STEP: Batch Loss = 0.061307, Accuracy = 0.93518835
TEST SET DISPLAY STEP: Batch Loss = 0.061102, Accuracy = 0.93281305
TEST SET DISPLAY STEP: Batch Loss = 0.054946, Accuracy = 0.9375636
TEST SET DISPLAY STEP: Batch Loss = 0.054461, Accuracy = 0.9365456
TEST SET DISPLAY STEP: Batch Loss = 0.050856, Accuracy = 0.9290804
TEST SET DISPLAY STEP: Batch Loss = 0.050600, Accuracy = 0.9334917
TEST SET DISPLAY STEP: Batch Loss = 0.057579, Accuracy = 0.9277231
TEST SET DISPLAY STEP: Batch Loss = 0.062409, Accuracy = 0.9324737
TEST SET DISPLAY STEP: Batch Loss = 0.050926, Accuracy = 0.9409569
TEST SET DISPLAY STEP: Batch Loss = 0.054567, Accuracy = 0.94027823
FINAL RESULT: Batch Loss= 0.0545671,
Accuracy= 0.94027823
哇!我们成功达到了 94%的准确度,真是非常棒。在之前的代码中,test()
是用来评估模型性能的方法。模型的签名如下所示:
def test(testDataCount: Int, batchSize: Int, testDatas: Array[Array[Array[Float]]],
testLabels: Array[Float], model: LSTMModel): (Float, Float) = {
var testLoss, testAcc = 0f
for (begin <- 0 until testDataCount by batchSize) {
val (testData, testLabel, dropNum) = {
if (begin + batchSize <= testDataCount) {
val datas = testDatas.drop(begin).take(batchSize)
val labels = testLabels.drop(begin).take(batchSize)
(datas, labels, 0)
} else {
val right = (begin + batchSize) - testDataCount
val left = testDataCount - begin
val datas = testDatas.drop(begin).take(left) ++ testDatas.take(right)
val labels = testLabels.drop(begin).take(left) ++ testLabels.take(right)
(datas, labels, right)
}
}
//feed the test data to the deepNN
model.data.set(testData.flatten.flatten)
model.label.set(testLabel)
model.exec.forward(isTrain = false)
val (acc, loss) = getAccAndLoss(model.exec.outputs(0), testLabel)
testLoss += loss
testAcc += acc
}
(testLoss / testDataCount, testAcc / testDataCount)
}
完成后,最好销毁模型以释放资源:
model.exec.dispose()
我们之前看到,在测试集上取得了高达 93%的准确率。那么,如何通过图形展示之前的准确度和误差呢:
// visualize
val xTrain = (0 until trainLosses.length * batchSize by batchSize).toArray.map(_.toDouble)
val yTrainL = trainLosses.toArray.map(_.toDouble)
val yTrainA = trainAccuracies.toArray.map(_.toDouble)
val xTest = (0 until testLosses.length * displayIter by displayIter).toArray.map(_.toDouble)
val yTestL = testLosses.toArray.map(_.toDouble)
val yTestA = testAccuracies.toArray.map(_.toDouble)
var series = new MemXYSeries(xTrain, yTrainL, "Train losses")
val data = new XYData(series)
series = new MemXYSeries(xTrain, yTrainA, "Train accuracies")
data += series
series = new MemXYSeries(xTest, yTestL, "Test losses")
data += series
series = new MemXYSeries(xTest, yTestA, "Test accuracies")
data += series
val chart = new XYChart("Training session's progress over iterations!", data)
chart.showLegend = true
val plotter = new JFGraphPlotter(chart)
plotter.gui()
>>>
图 17:每次迭代的训练和测试损失及准确度
从前面的图表来看,很明显,经过几次迭代,我们的 LSTM 模型很好地收敛,并且产生了非常好的分类准确度。
调整 LSTM 超参数和 GRU
然而,我仍然相信,通过增加更多的 LSTM 层,能够达到接近 100%的准确率。以下是我仍然会尝试调整的超参数,以便查看准确度:
// Hyper parameters for the LSTM training
val learningRate = 0.001f
val trainingIters = trainingDataCount * 1000 // Loop 1000 times on the dataset
val batchSize = 1500 // I would set it 5000 and see the performance
val displayIter = 15000 // To show test set accuracy during training
val numLstmLayer = 3 // 5, 7, 9 etc.
LSTM 单元有很多其他变种。其中一个特别流行的变种是门控循环单元(GRU)单元,它是 LSTM 的稍微变化形式。它还将单元状态和隐藏状态合并,并做了一些其他改动。结果模型比标准的 LSTM 模型更简单,并且越来越受欢迎。这个单元是 Kyunghyun Cho 等人在 2014 年的一篇论文中提出的,论文还介绍了我们之前提到的编码器-解码器网络。
对于这种类型的 LSTM,感兴趣的读者可以参考以下文献:
-
使用 RNN 编码器-解码器进行统计机器翻译的学习短语表示,K. Cho 等人(2014)。
-
Klaus Greff 等人于 2015 年发表的论文 LSTM: A Search Space Odyssey,似乎表明所有 LSTM 变体的表现大致相同。
从技术上讲,GRU 单元是 LSTM 单元的简化版,其中两个状态向量合并成一个叫做 h(t) 的向量。一个单一的门控制器控制着遗忘门和输入门。如果门控制器输出 1,输入门打开,遗忘门关闭:
图 18:GRU 单元的内部结构
另一方面,如果输出为 0,则会发生相反的情况。每当需要存储记忆时,首先会清除它将被存储的位置,这实际上是 LSTM 单元的一种常见变体。第二个简化是,由于每个时间步都会输出完整的状态向量,因此没有输出门。然而,引入了一个新的门控制器,用来控制前一个状态的哪个部分会显示给主层。以下方程用于进行 GRU 单元在每个时间步长的长短期状态计算及输出:
LSTM 和 GRU 单元是近年来 RNN 成功的主要原因之一,尤其是在 NLP 应用中。
总结
在本章中,我们已经学习了如何使用 RNN 实现开发 ML 项目,并使用智能手机数据集进行 HAR 的 LSTM 模型。我们的 LSTM 模型能够从六个类别中分类运动类型:步行、走楼梯、下楼梯、坐着、站着和躺着。特别地,我们达到了 94% 的准确率。接着,我们讨论了如何通过使用 GRU 单元进一步提高准确性的一些可能方法。
卷积神经网络(CNN)是一种前馈神经网络,其中神经元之间的连接模式受到动物视觉皮层的启发。近年来,CNN 在复杂的视觉任务中表现出超越人类的性能,如图像搜索服务、自动驾驶汽车、自动视频分类、语音识别和 自然语言处理(NLP)。
考虑到这些,在下一章我们将看到如何开发一个端到端项目,使用基于 Scala 和 Deeplearning4j 框架的 CNN 来处理多标签(即每个实体可以属于多个类别)图像分类问题,并且使用真实的 Yelp 图像数据集。我们还将在开始之前讨论一些 CNN 的理论方面。更进一步地,我们将讨论如何调整超参数,以获得更好的分类结果。
第十一章:使用卷积神经网络进行图像分类
到目前为止,我们还没有开发过任何用于图像处理任务的 机器学习(ML)项目。线性机器学习模型和其他常规 深度神经网络(DNN)模型,例如 多层感知器(MLPs)或 深度置信网络(DBNs),无法从图像中学习或建模非线性特征。
另一方面,卷积神经网络(CNN)是一种前馈神经网络,其中神经元之间的连接模式受到动物视觉皮层的启发。在过去的几年里,CNN 在复杂视觉任务中展现了超越人类的表现,例如图像搜索服务、自动驾驶汽车、自动视频分类、语音识别和 自然语言处理(NLP)。
在本章中,我们将看到如何基于 Scala 和 Deeplearning4j(DL4j)框架,使用真实的 Yelp 图像数据集开发一个端到端的多标签(即每个实体可以属于多个类别)图像分类项目。我们还将讨论一些 CNN 的理论方面,以及如何调整超参数以获得更好的分类结果,之后再开始实际操作。
简而言之,在这个端到端项目中,我们将学习以下主题:
-
常规 DNN 的缺点
-
CNN 架构:卷积操作和池化层
-
使用 CNN 进行图像分类
-
调整 CNN 超参数
图像分类和 DNN 的缺点
在我们开始开发基于 CNN 的图像分类端到端项目之前,我们需要进行一些背景学习,比如常规 DNN 的缺点、CNN 相较于 DNN 在图像分类中的适用性、CNN 的构建方式、CNN 的不同操作等。虽然常规 DNN 对于小尺寸图像(例如 MNIST、CIFAR-10)效果良好,但对于更大尺寸的图像,它会因为所需的巨大参数数量而崩溃。例如,一张 100 x 100 的图像有 10,000 个像素,如果第一层只有 1,000 个神经元(这已经极大限制了传递到下一层的信息量),那么就意味着总共有 1,000 万个连接。而这仅仅是第一层的情况。
CNN 通过使用部分连接层解决了这个问题。由于连续层之间仅部分连接,并且由于其权重的高度重用,CNN 的参数远少于完全连接的 DNN,这使得它训练速度更快,减少了过拟合的风险,并且需要的训练数据量更少。此外,当 CNN 学会了能够检测某一特征的卷积核时,它可以在图像的任何位置检测到该特征。相比之下,当 DNN 在某个位置学到一个特征时,它只能在该位置检测到该特征。由于图像通常具有非常重复的特征,CNN 在图像处理任务(如分类)中能够比 DNN 更好地进行泛化,并且只需较少的训练样本。
重要的是,DNN 并不事先了解像素是如何组织的;它不知道相邻的像素是彼此接近的。而 CNN 的架构则将这种先验知识嵌入其中。较低层通常识别图像的小区域中的特征,而较高层则将低级特征组合成更大的特征。这在大多数自然图像中效果很好,使 CNN 相比 DNN 具有决定性的优势:
图 1:普通 DNN 与 CNN
例如,在图 1中,左侧显示了一个普通的三层神经网络。右侧,ConvNet 将它的神经元以三维(宽度、高度和深度)进行排列,如其中一层的可视化所示。ConvNet 的每一层将 3D 输入体积转换为 3D 输出体积的神经元激活。红色输入层承载着图像,因此它的宽度和高度就是图像的维度,而深度则是三(红色、绿色和蓝色通道)。
所以,我们之前看到的所有多层神经网络的层都是由一长串神经元组成的,在将输入图像或数据传递给神经网络之前,我们需要将其展平成 1D。但是,当你尝试直接将 2D 图像输入时会发生什么呢?答案是,在 CNN 中,每一层都是以 2D 形式表示的,这使得将神经元与它们对应的输入进行匹配变得更加容易。我们将在接下来的部分看到相关示例。
另一个重要的事实是,特征图中的所有神经元共享相同的参数,因此它显著减少了模型中的参数数量,但更重要的是,它意味着一旦 CNN 学会在某个位置识别某个模式,它就能够在任何其他位置识别该模式。相比之下,一旦普通的 DNN 学会在某个位置识别某个模式,它只能在那个特定位置识别该模式。
CNN 架构
在多层网络中,例如 MLP 或 DBN,输入层所有神经元的输出都连接到隐藏层中的每个神经元,因此输出将再次作为输入传递给全连接层。而在 CNN 网络中,定义卷积层的连接方式有显著不同。卷积层是 CNN 中的主要层类型,每个神经元都连接到输入区域的某个区域,这个区域称为感受野。
在典型的 CNN 架构中,几个卷积层以级联样式连接,每一层后面跟着一个整流线性单元(ReLU)层,然后是一个池化层,再接几个卷积层(+ReLU),再接另一个池化层,如此循环。
每个卷积层的输出是一组由单个核过滤器生成的对象,称为特征图。这些特征图可以用来定义下一个层的输入。CNN 网络中的每个神经元都会生成一个输出,之后是一个激活阈值,该阈值与输入成比例,并且没有限制:
图 2:CNN 的概念架构
如图 2所示,池化层通常被放置在卷积层之后。然后,卷积区域会被池化层划分为子区域。接着,使用最大池化或平均池化技术选择一个代表性值,从而减少后续层的计算时间。
这样,特征相对于其空间位置的鲁棒性也得到了增强。更具体地说,当图像特性(作为特征图)通过网络时,它们随着网络的推进变得越来越小,但通常会变得更深,因为更多的特征图将被添加到网络中。在堆叠的顶部,加入了一个常规的前馈神经网络,就像 MLP 一样,可能由几层全连接层(+ReLUs)组成,最后一层输出预测结果,例如,softmax 层输出多类分类的估计类别概率。
卷积操作
卷积是一个数学运算,它将一个函数滑过另一个函数,并测量它们逐点相乘的积分。它与傅里叶变换和拉普拉斯变换有着深刻的联系,并广泛应用于信号处理。卷积层实际上使用的是互相关,它与卷积非常相似。
因此,CNN 的最重要组成部分是卷积层:第一卷积层中的神经元并不是与输入图像中的每一个像素相连接(如同前几章所述),而只是与它们感受野中的像素相连接——见图 3。反过来,第二卷积层中的每个神经元仅与第一层中位于小矩形区域内的神经元相连接:
图 3:具有矩形局部感受野的 CNN 层
这种架构使得网络能够在第一隐藏层中集中关注低级特征,然后在接下来的隐藏层中将它们组装成更高级的特征,依此类推。这种层次结构在现实世界的图像中很常见,这也是 CNN 在图像识别中表现如此出色的原因之一。
池化层和填充操作
一旦你理解了卷积层的工作原理,池化层就非常容易理解了。池化层通常会独立地处理每一个输入通道,因此输出深度与输入深度相同。你也可以选择在深度维度上进行池化,正如我们接下来将看到的那样,在这种情况下,图像的空间维度(高度和宽度)保持不变,但通道的数量会减少。让我们看一下来自一个知名 TensorFlow 网站的池化层的正式定义:
“池化操作在输入张量上扫过一个矩形窗口,为每个窗口计算一个归约操作(平均、最大值或带有 argmax 的最大值)。每个池化操作使用称为 ksize 的矩形窗口,窗口间隔由偏移步幅定义。例如,如果步幅全为 1,则每个窗口都会被使用;如果步幅全为 2,则每个维度中的每隔一个窗口就会被使用,依此类推。”
因此,就像在卷积层中一样,池化层中的每个神经元与前一层中有限数量的神经元的输出相连接,这些神经元位于一个小的矩形感受野内。你必须像之前一样定义其大小、步幅和填充类型。然而,池化神经元没有权重;它所做的只是使用聚合函数(如最大值或均值)对输入进行聚合。
目标使用池化是为了对子输入图像进行子采样,以减少计算负荷、内存使用和参数数量。这有助于在训练阶段避免过拟合。减少输入图像的大小还使得神经网络能够容忍一定程度的图像位移。在以下示例中,我们使用了 2 x 2 的池化核和步幅为 2 的设置,并且没有填充。只有每个池化核中的最大输入值会传递到下一层,因为其他输入会被丢弃:
图 4:使用最大池化的示例,即子采样
通常,(stride_length) x + filter_size <= input_layer_size* 是大多数基于 CNN 的网络开发中推荐的。
子采样操作
如前所述,位于给定层中的神经元与前一层中神经元的输出相连接。现在,为了使一个层具有与前一层相同的高度和宽度,通常会在输入周围添加零,如图所示。这称为SAME或零填充。
"SAME"一词意味着输出特征图具有与输入特征图相同的空间维度。零填充被引入,以便在需要时使形状匹配,并且在输入图的每一侧填充相等。另一方面,"VALID"意味着没有填充,只是丢弃最右侧的列(或最下方的行):
图 5:CNN 中的 SAME 与 VALID 填充
现在我们已经掌握了关于 CNN 及其架构的最基本理论知识,是时候动手实践了,使用 Deeplearning4j(简称 DL4j)创建卷积、池化和子采样操作。DL4j 是最早的商业级分布式开源深度学习库之一,专为 Java 和 Scala 编写。它还提供对 Hadoop 和 Spark 的集成支持。DL4j 旨在用于商业环境中的分布式 GPU 和 CPU。
DL4j 中的卷积和子采样操作
在开始之前,设置我们的编程环境是一个先决条件。所以我们先做这个。
配置 DL4j、ND4s 和 ND4j
以下库可以与 DL4j 集成。无论你是在 Java 还是 Scala 中开发机器学习应用程序,它们都会使你的 JVM 体验更加顺畅:
-
DL4j:神经网络平台
-
ND4J:JVM 上的 NumPy
-
DataVec:机器学习 ETL 操作工具
-
JavaCPP:Java 与本地 C++之间的桥梁
-
Arbiter:机器学习算法评估工具
-
RL4J:JVM 上的深度强化学习
ND4j 就像 JVM 上的 NumPy。它提供了一些线性代数的基本操作,例如矩阵创建、加法和乘法。另一方面,ND4S 是一个科学计算库,专注于线性代数和矩阵操作。基本上,它支持 JVM 语言的 n 维数组。
如果你在 Eclipse(或任何其他编辑器,如 IntelliJ IDEA)中使用 Maven,请在pom.xml
文件(位于<dependencies>
标签内)中使用以下依赖项来解决 DL4j、ND4s 和 ND4j 的依赖问题:
<dependency>
<groupId>org.deeplearning4j</groupId>
<artifactId>deeplearning4j-core</artifactId>
<version>0.4-rc3.9</version>
</dependency>
<dependency>
<artifactId>canova-api</artifactId>
<groupId>org.nd4j</groupId>
<version>0.4-rc3.9</version>
</dependency>
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-native</artifactId>
<version>0.4-rc3.9</version>
</dependency>
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>canova-api</artifactId>
<version>0.0.0.17</version>
</dependency>
我使用的是旧版本,因为遇到了一些兼容性问题,但它仍在积极开发中。不过你可以自由地采用最新版本。我相信读者可以轻松完成这一点。
此外,如果你的系统没有配置本地的 BLAS,ND4j 的性能将会下降。一旦你运行简单的 Scala 代码,就会看到警告:
****************************************************************
WARNING: COULD NOT LOAD NATIVE SYSTEM BLAS
ND4J performance WILL be reduced
****************************************************************
然而,安装和配置 BLAS(如 OpenBLAS 或 IntelMKL)并不难,你可以投入一些时间去完成它。详情请参阅以下网址:nd4j.org/getstarted.html#open
。还需要注意的是,在使用 DL4j 时,以下是必备条件:
-
Java(开发者版本)1.8+(仅支持 64 位版本)
-
Apache Maven:用于自动构建和依赖管理
-
IntelliJ IDEA 或 Eclipse
-
Git
做得好!我们的编程环境已经准备好进行简单的深度学习应用开发。现在是时候动手编写一些示例代码了。让我们看看如何使用 CIFAR-10 数据集构建和训练一个简单的 CNN。CIFAR-10 是最受欢迎的基准数据集之一,包含成千上万的标注图像。
DL4j 中的卷积和子采样操作
在这一小节中,我们将展示如何构建一个用于 MNIST 数据分类的 CNN 示例。该网络将包含两个卷积层、两个子采样层、一个全连接层和一个输出层。第一层是卷积层,接着是子采样层,随后是另一个卷积层。然后是子采样层,接着是全连接层,最后是输出层。
让我们看看这些层在使用 DL4j 时的表现。第一个卷积层,使用 ReLU 作为激活函数:
val layer_0 = new ConvolutionLayer.Builder(5, 5)
.nIn(nChannels)
.stride(1, 1)
.nOut(20)
.activation("relu")
.build()
DL4j 当前支持以下激活函数:
-
ReLU
-
Leaky ReLU
-
Tanh
-
Sigmoid
-
Hard Tanh
-
Softmax
-
Identity
-
ELU(指数线性单元)
-
Softsign
-
Softplus
第二层(即第一个子采样层)是一个子采样层,池化类型为MAX
,卷积核大小为 2 x 2,步幅为 2 x 2,但没有激活函数:
val layer_1 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build()
第三层(第二个卷积层)是一个卷积层,使用 ReLU 作为激活函数,步幅为 1*1:
val layer_2 = new ConvolutionLayer.Builder(5, 5)
.nIn(nChannels)
.stride(1, 1)
.nOut(50)
.activation("relu")
.build()
第四层(即第二个子采样层)是一个子采样层,池化类型为MAX
,卷积核大小为 2 x 2,步幅为 2 x 2,但没有激活函数:
val layer_3 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build()
第五层是一个全连接层,使用 ReLU 作为激活函数:
val layer_4 = new DenseLayer.Builder()
.activation("relu")
.nOut(500)
.build()
第六层(即最后一层全连接层)使用 Softmax 作为激活函数,类别数量为待预测的类别数(即 10):
val layer_5 = new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.nOut(outputNum)
.activation("softmax")
.build()
一旦各层构建完成,接下来的任务是通过链接所有的层来构建 CNN。使用 DL4j,操作如下:
val builder: MultiLayerConfiguration.Builder = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(iterations)
.regularization(true).l2(0.0005)
.learningRate(0.01)
.weightInit(WeightInit.XAVIER)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.updater(Updater.NESTEROVS).momentum(0.9)
.list()
.layer(0, layer_0)
.layer(1, layer_1)
.layer(2, layer_2)
.layer(3, layer_3)
.layer(4, layer_4)
.layer(5, layer_5)
.backprop(true).pretrain(false) // feedforward and supervised so no pretraining
最后,我们设置所有的卷积层并初始化网络,如下所示:
new ConvolutionLayerSetup(builder, 28, 28, 1) //image size is 28*28
val conf: MultiLayerConfiguration = builder.build()
val model: MultiLayerNetwork = new MultiLayerNetwork(conf)
model.init()
按照惯例,要训练一个 CNN,所有的图像需要具有相同的形状和大小。所以我在前面的代码中将尺寸设置为 28 x 28,便于说明。现在,你可能会想,我们如何训练这样的网络呢?好吧,接下来我们就会看到这一点,但在此之前,我们需要准备 MNIST 数据集,使用MnistDataSetIterator()
方法,如下所示:
val nChannels = 1 // for grayscale image
val outputNum = 10 // number of class
val nEpochs = 10 // number of epoch
val iterations = 1 // number of iteration
val seed = 12345 // Random seed for reproducibility
val batchSize = 64 // number of batches to be sent
log.info("Load data....")
val mnistTrain: DataSetIterator = new MnistDataSetIterator(batchSize, true, 12345)
val mnistTest: DataSetIterator = new MnistDataSetIterator(batchSize, false, 12345)
现在让我们开始训练 CNN,使用训练集并为每个周期进行迭代:
log.info("Model training started...")
model.setListeners(new ScoreIterationListener(1))
var i = 0
while (i <= nEpochs) {
model.fit(mnistTrain);
log.info("*** Completed epoch {} ***", i)
i = i + 1
}
var ds: DataSet = null var output: INDArray = null
一旦我们训练好了 CNN,接下来的任务是评估模型在测试集上的表现,如下所示:
log.info("Model evaluation....")
val eval: Evaluation = new Evaluation(outputNum)
while (mnistTest.hasNext()) {
ds = mnistTest.next()
output = model.output(ds.getFeatureMatrix(), false)
}
eval.eval(ds.getLabels(), output)
最后,我们计算一些性能矩阵,如Accuracy
、Precision
、Recall
和F1 measure
,如下所示:
println("Accuracy: " + eval.accuracy())
println("F1 measure: " + eval.f1())
println("Precision: " + eval.precision())
println("Recall: " + eval.recall())
println("Confusion matrix: " + "n" + eval.confusionToString())
log.info(eval.stats())
mnistTest.reset()
>>>
==========================Scores=======================================
Accuracy: 1
Precision: 1
Recall: 1
F1 Score: 1
=======================================================================
为了方便你,我在这里提供了这个简单图像分类器的完整源代码:
package com.example.CIFAR
import org.canova.api.records.reader.RecordReader
import org.canova.api.split.FileSplit
import org.canova.image.loader.BaseImageLoader
import org.canova.image.loader.NativeImageLoader
import org.canova.image.recordreader.ImageRecordReader
import org.deeplearning4j.datasets.iterator.DataSetIterator
import org.canova.image.recordreader.ImageRecordReader
import org.deeplearning4j.datasets.canova.RecordReaderDataSetIterator
import org.deeplearning4j.datasets.iterator.impl.MnistDataSetIterator
import org.deeplearning4j.eval.Evaluation
import org.deeplearning4j.nn.api.OptimizationAlgorithm
import org.deeplearning4j.nn.conf.MultiLayerConfiguration
import org.deeplearning4j.nn.conf.NeuralNetConfiguration
import org.deeplearning4j.nn.conf.Updater
import org.deeplearning4j.nn.conf.layers.ConvolutionLayer
import org.deeplearning4j.nn.conf.layers.DenseLayer
import org.deeplearning4j.nn.conf.layers.OutputLayer
import org.deeplearning4j.nn.conf.layers.SubsamplingLayer
import org.deeplearning4j.nn.conf.layers.setup.ConvolutionLayerSetup
import org.deeplearning4j.nn.multilayer.MultiLayerNetwork
import org.deeplearning4j.nn.weights.WeightInit
import org.deeplearning4j.optimize.listeners.ScoreIterationListener
import org.nd4j.linalg.api.ndarray.INDArray
import org.nd4j.linalg.api.rng.Random
import org.nd4j.linalg.dataset.DataSet
import org.nd4j.linalg.dataset.SplitTestAndTrain
import org.nd4j.linalg.lossfunctions.LossFunctions
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.io.File
import java.util.ArrayList
import java.util.List
object MNIST {
val log: Logger = LoggerFactory.getLogger(MNIST.getClass)
def main(args: Array[String]): Unit = {
val nChannels = 1 // for grayscale image
val outputNum = 10 // number of class
val nEpochs = 1 // number of epoch
val iterations = 1 // number of iteration
val seed = 12345 // Random seed for reproducibility
val batchSize = 64 // number of batches to be sent
log.info("Load data....")
val mnistTrain: DataSetIterator = new MnistDataSetIterator(batchSize, true, 12345)
val mnistTest: DataSetIterator = new MnistDataSetIterator(batchSize, false, 12345)
log.info("Network layer construction started...")
//First convolution layer with ReLU as activation function
val layer_0 = new ConvolutionLayer.Builder(5, 5)
.nIn(nChannels)
.stride(1, 1)
.nOut(20)
.activation("relu")
.build()
//First subsampling layer
val layer_1 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build()
//Second convolution layer with ReLU as activation function
val layer_2 = new ConvolutionLayer.Builder(5, 5)
.nIn(nChannels)
.stride(1, 1)
.nOut(50)
.activation("relu")
.build()
//Second subsampling layer
val layer_3 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build()
//Dense layer
val layer_4 = new DenseLayer.Builder()
.activation("relu")
.nOut(500)
.build()
// Final and fully connected layer with Softmax as activation function
val layer_5 = new OutputLayer.Builder(LossFunctions.LossFunction.NEGATIVELOGLIKELIHOOD)
.nOut(outputNum)
.activation("softmax")
.build()
log.info("Model building started...")
val builder: MultiLayerConfiguration.Builder = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(iterations)
.regularization(true).l2(0.0005)
.learningRate(0.01)
.weightInit(WeightInit.XAVIER)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.updater(Updater.NESTEROVS).momentum(0.9)
.list()
.layer(0, layer_0)
.layer(1, layer_1)
.layer(2, layer_2)
.layer(3, layer_3)
.layer(4, layer_4)
.layer(5, layer_5)
.backprop(true).pretrain(false) // feedforward so no backprop
// Setting up all the convlutional layers and initialize the network
new ConvolutionLayerSetup(builder, 28, 28, 1) //image size is 28*28
val conf: MultiLayerConfiguration = builder.build()
val model: MultiLayerNetwork = new MultiLayerNetwork(conf)
model.init()
log.info("Model training started...")
model.setListeners(new ScoreIterationListener(1))
var i = 0
while (i <= nEpochs) {
model.fit(mnistTrain);
log.info("*** Completed epoch {} ***", i)
i = i + 1
var ds: DataSet = null
var output: INDArray = null
log.info("Model evaluation....")
val eval: Evaluation = new Evaluation(outputNum)
while (mnistTest.hasNext()) {
ds = mnistTest.next()
output = model.output(ds.getFeatureMatrix(), false)
}
eval.eval(ds.getLabels(), output)
println("Accuracy: " + eval.accuracy())
println("F1 measure: " + eval.f1())
println("Precision: " + eval.precision())
println("Recall: " + eval.recall())
println("Confusion matrix: " + "n" + eval.confusionToString())
log.info(eval.stats())
mnistTest.reset()
}
log.info("****************Example finished********************")
}
}
使用 CNN 进行大规模图像分类
在本节中,我们将展示一个逐步开发实际 ML 项目(用于图像分类)的示例。然而,我们首先需要了解问题描述,以便知道需要进行什么样的图像分类。此外,在开始之前,了解数据集是必须的。
问题描述
如今,食物自拍和以照片为中心的社交故事讲述正在成为社交趋势。美食爱好者愿意将大量与食物合影的自拍和餐厅的照片上传到社交媒体和相应的网站。当然,他们还会提供书面评论,这能显著提升餐厅的知名度:
图 6:从 Yelp 数据集中挖掘一些商业洞察
例如,数百万独立访问者访问 Yelp 并撰写了超过 1.35 亿条评论。平台上有大量照片和上传照片的用户。商家可以发布照片并与顾客互动。通过这种方式,Yelp 通过向这些本地商家 出售广告 来赚钱。一个有趣的事实是,这些照片提供了丰富的本地商业信息,涵盖了多个类别。因此,训练计算机理解这些照片的上下文并非一件简单的事,也不是一项容易的任务(参考 图 6 获取更多的见解)。
现在,这个项目的理念是充满挑战的:我们如何将这些图片转化为文字?让我们试试看。更具体地说,你将获得属于某个商家的照片。现在我们需要建立一个模型,使其能够自动为餐馆标记多个用户提交的照片标签——也就是说,预测商家的属性。
图像数据集的描述
对于这样的挑战,我们需要一个真实的数据集。别担心,有多个平台提供公开的数据集,或者可以在一定的条款和条件下下载。一种这样的平台是 Kaggle,它为数据分析和机器学习实践者提供了一个平台,参与机器学习挑战并赢取奖品。Yelp 数据集及其描述可以在以下网址找到:www.kaggle.com/c/yelp-restaurant-photo-classification
。
餐馆的标签是由 Yelp 用户在提交评论时手动选择的。数据集中包含 Yelp 社区标注的九种不同标签:
-
0: good_for_lunch
-
1: good_for_dinner
-
2: takes_reservations
-
3: outdoor_seating
-
4: restaurant_is_expensive
-
5: has_alcohol
-
6: has_table_service
-
7: ambience_is_classy
-
8: good_for_kids
所以我们需要尽可能准确地预测这些标签。需要注意的一点是,由于 Yelp 是一个社区驱动的网站,数据集中存在重复的图片,原因有很多。例如,用户可能会不小心将同一张照片上传到同一商家多次,或者连锁商家可能会将相同的照片上传到不同的分店。数据集包含以下六个文件:
-
train_photos.tgz
: 用作训练集的照片(234,545 张图片) -
test_photos.tgz
: 用作测试集的照片(500 张图片) -
train_photo_to_biz_ids.csv
: 提供照片 ID 和商家 ID 之间的映射(234,545 行) -
test_photo_to_biz_ids.csv
: 提供照片 ID 和商家 ID 之间的映射(500 行) -
train.csv
: 这是主要的训练数据集,包含商家 ID 和它们对应的标签(1996 行) -
sample_submission.csv
: 一个示例提交文件——参考正确的格式来提交你的预测结果,包括business_id
和对应的预测标签
整个项目的工作流程
在这个项目中,我们将展示如何将.jpg
格式的图像读取为 Scala 中的矩阵表示。接着,我们将进一步处理并准备这些图像,以便 CNN 能够接收。我们将看到几种图像操作,比如将所有图像转换为正方形,并将每个图像调整为相同的尺寸,然后对图像应用灰度滤镜:
图 7:用于图像分类的 CNN 概念视图
然后我们在训练数据上训练九个 CNN,每个分类一个。一旦训练完成,我们保存训练好的模型、CNN 配置和参数,以便以后恢复,接着我们应用一个简单的聚合函数来为每个餐厅分配类别,每个餐厅都有多个与之相关联的图像,每个图像都有其对应的九个类别的概率向量。然后我们对测试数据打分,最后,使用测试图像评估模型。
现在让我们看看每个 CNN 的结构。每个网络将有两个卷积层,两个子采样层,一个密集层,以及作为完全连接层的输出层。第一层是卷积层,接着是子采样层,再之后是另一个卷积层,然后是子采样层,再接着是一个密集层,最后是输出层。我们稍后会看到每一层的结构。
实现 CNN 用于图像分类
包含main()
方法的 Scala 对象有以下工作流:
-
我们从
train.csv
文件中读取所有的业务标签 -
我们读取并创建一个从图像 ID 到业务 ID 的映射,格式为
imageID
→busID
-
我们从
photoDir
目录获取图像列表,加载和处理图像,最后获取 10,000 张图像的图像 ID(可以自由设置范围) -
然后我们读取并处理图像,形成
photoID
→ 向量映射 -
我们将步骤 3和步骤 4的输出链接起来,对齐业务特征、图像 ID 和标签 ID,以提取 CNN 所需的特征
-
我们构建了九个 CNN。
-
我们训练所有的 CNN 并指定模型保存的位置
-
然后我们重复步骤 2到步骤 6来从测试集提取特征
-
最后,我们评估模型并将预测结果保存到 CSV 文件中
现在让我们看看前述步骤在高层次的图示中是如何表示的:
图 8:DL4j 图像处理管道用于图像分类
从程序的角度来看,前述步骤可以表示如下:
val labelMap = readBusinessLabels("data/labels/train.csv")
val businessMap = readBusinessToImageLabels("data/labels/train_photo_to_biz_ids.csv")
val imgs = getImageIds("data/images/train/", businessMap, businessMap.map(_._2).toSet.toList).slice(0,100) // 20000 images
println("Image ID retreival done!")
val dataMap = processImages(imgs, resizeImgDim = 128)
println("Image processing done!")
val alignedData = new featureAndDataAligner(dataMap, businessMap, Option(labelMap))()
println("Feature extraction done!")
val cnn0 = trainModelEpochs(alignedData, businessClass = 0, saveNN = "models/model0")
val cnn1 = trainModelEpochs(alignedData, businessClass = 1, saveNN = "models/model1")
val cnn2 = trainModelEpochs(alignedData, businessClass = 2, saveNN = "models/model2")
val cnn3 = trainModelEpochs(alignedData, businessClass = 3, saveNN = "models/model3")
val cnn4 = trainModelEpochs(alignedData, businessClass = 4, saveNN = "models/model4")
val cnn5 = trainModelEpochs(alignedData, businessClass = 5, saveNN = "models/model5")
val cnn6 = trainModelEpochs(alignedData, businessClass = 6, saveNN = "models/model6")
val cnn7 = trainModelEpochs(alignedData, businessClass = 7, saveNN = "models/model7")
val cnn8 = trainModelEpochs(alignedData, businessClass = 8, saveNN = "models/model8")
val businessMapTE = readBusinessToImageLabels("data/labels/test_photo_to_biz.csv")
val imgsTE = getImageIds("data/images/test//", businessMapTE, businessMapTE.map(_._2).toSet.toList)
val dataMapTE = processImages(imgsTE, resizeImgDim = 128) // make them 128*128
val alignedDataTE = new featureAndDataAligner(dataMapTE, businessMapTE, None)()
val Results = SubmitObj(alignedDataTE, "results/ModelsV0/")
val SubmitResults = writeSubmissionFile("kaggleSubmitFile.csv", Results, thresh = 0.9)
觉得太复杂了吗?别担心,我们现在将详细查看每一步。如果仔细看前面的步骤,你会发现步骤 1到步骤 5基本上是图像处理和特征构建。
图像处理
当我试图开发这个应用程序时,我发现照片的大小和形状各不相同:一些图像是高的,一些是宽的,一些在外面,一些在里面,大多数是食物图片。然而,还有一些其他的随机物品。另一个重要的方面是,尽管训练图像在纵向/横向和像素数量上有所不同,大多数都是大致正方形的,许多都是确切的 500 x 375:
图 9:调整大小后的图像(左边为原始和高大的图像,右边为正方形的图像)
正如我们已经看到的,CNN 无法处理尺寸和形状异质的图像。有许多强大且有效的图像处理技术可以仅提取感兴趣区域(ROI)。但是,老实说,我不是图像处理专家,所以决定简化这个调整大小的步骤。
卷积神经网络(CNN)有一个严重的限制,即不能处理方向和相对空间关系。因此,这些组成部分对 CNN 来说并不重要。简而言之,CNN 不太适合具有异构形状和方向的图像。因此,现在人们开始讨论胶囊网络。详细内容请参见原始论文:arxiv.org/pdf/1710.09829v1.pdf
和 openreview.net/pdf?id=HJWLfGWRb
。
简单地说,我将所有图像都制作成了正方形,但仍然努力保持质量。在大多数情况下,ROI 是居中的,因此仅捕获每个图像的中心最方正的部分并不那么简单。尽管如此,我们还需要将每个图像转换为灰度图像。让我们把不规则形状的图像变成正方形。请看下面的图像,左边是原始图像,右边是正方形图像(见图 9)。
现在我们已经生成了一个正方形图像,我们是如何做到这一点的呢?好吧,我首先检查高度和宽度是否相同,如果是,则不进行调整大小。在另外两种情况下,我裁剪了中心区域。以下方法可以达到效果(但随意执行SquaringImage.scala
脚本以查看输出):
def makeSquare(img: java.awt.image.BufferedImage): java.awt.image.BufferedImage = {
val w = img.getWidth
val h = img.getHeight
val dim = List(w, h).min
img match {
case x
if w == h => img // do nothing and returns the original one
case x
if w > h => Scalr.crop(img, (w - h) / 2, 0, dim, dim)
case x
if w < h => Scalr.crop(img, 0, (h - w) / 2, dim, dim)
}
}
干得好!现在我们所有的训练图像都是正方形的,下一个重要的预处理任务是将它们全部调整大小。我决定将所有图像都调整为 128 x 128 的大小。让我们看看之前(原始的)调整大小后的图像如何看起来:
图 10:图像调整(256 x 256, 128 x 128, 64 x 64 和 32 x 32 分别)
以下方法可以达到效果(但随意执行ImageResize.scala
脚本以查看演示):
def resizeImg(img: java.awt.image.BufferedImage, width: Int, height: Int) = {
Scalr.resize(img, Scalr.Method.BALANCED, width, height)
}
顺便说一句,为了图像调整和制作正方形,我使用了一些内置的图像读取包和一些第三方处理包:
import org.imgscalr._
import java.io.File
import javax.imageio.ImageIO
要使用上述包,请在 Maven 友好的pom.xml
文件中添加以下依赖项:
<dependency>
<groupId>org.imgscalr</groupId>
<artifactId>imgscalr-lib</artifactId>
<version>4.2</version>
</dependency>
<dependency>
<groupId>org.datavec</groupId>
<artifactId>datavec-data-image</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.sksamuel.scrimage</groupId>
<artifactId>scrimage-core_2.10</artifactId>
<version>2.1.0</version>
</dependency>
虽然基于 DL4j 的卷积神经网络(CNN)可以处理彩色图像,但使用灰度图像可以简化计算。虽然彩色图像更具吸引力且效果更好,但通过这种方式,我们可以使整体表示更简单,并且节省空间。
让我们举一个之前步骤的例子。我们将每个图像调整为 256 x 256 像素的图像,表示为 16,384 个特征,而不是为一个有三个 RGB 通道的彩色图像表示为 16,384 x 3(执行GrayscaleConverter.scala
来查看演示)。让我们看看转换后的图像效果:
图 11:左 - 原始图像,右 - 灰度图像 RGB 平均化
上述转换是使用名为pixels2Gray()
和makeGray()
的两个方法完成的:
def pixels2Gray(R: Int, G: Int, B: Int): Int = (R + G + B) / 3
def makeGray(testImage: java.awt.image.BufferedImage): java.awt.image.BufferedImage = {
val w = testImage.getWidth
val h = testImage.getHeight
for {
w1 <- (0 until w).toVector
h1 <- (0 until h).toVector
}
yield
{
val col = testImage.getRGB(w1, h1)
val R = (col & 0xff0000) / 65536
val G = (col & 0xff00) / 256
val B = (col & 0xff)
val graycol = pixels2Gray(R, G, B)
testImage.setRGB(w1, h1, new Color(graycol, graycol, graycol).getRGB)
}
testImage
}
那么,幕后发生了什么呢?我们将前面提到的三个步骤串联起来:首先将所有图像调整为正方形,然后将它们转换为 25 x 256,最后将调整大小后的图像转换为灰度图像:
val demoImage = ImageIO.read(new File(x))
.makeSquare
.resizeImg(resizeImgDim, resizeImgDim) // (128, 128)
.image2gray
总结一下,现在我们已经对所有图像进行了正方形化和调整大小,图像已经变为灰度。以下图像展示了转换步骤的一些效果:
图 12:调整大小后的图像(左侧为原始高图,右侧为调整后的正方形图像)
以下的步骤链式操作也需要额外的努力。现在我们将这三个步骤放在代码中,最终准备好所有的图像:
import scala.Vector
import org.imgscalr._
object imageUtils {
implicitclass imageProcessingPipeline(img: java.awt.image.BufferedImage) {
// image 2 vector processing
def pixels2gray(R: Int, G:Int, B: Int): Int = (R + G + B) / 3
def pixels2color(R: Int, G:Int, B: Int): Vector[Int] = Vector(R, G, B)
private def image2vecA => A ): Vector[A] = {
val w = img.getWidth
val h = img.getHeight
for {
w1 <- (0 until w).toVector
h1 <- (0 until h).toVector
}
yield {
val col = img.getRGB(w1, h1)
val R = (col & 0xff0000) / 65536
val G = (col & 0xff00) / 256
val B = (col & 0xff)
f(R, G, B)
}
}
def image2gray: Vector[Int] = image2vec(pixels2gray)
def image2color: Vector[Int] = image2vec(pixels2color).flatten
// make image square
def makeSquare = {
val w = img.getWidth
val h = img.getHeight
val dim = List(w, h).min
img match {
case x
if w == h => img
case x
if w > h => Scalr.crop(img, (w-h)/2, 0, dim, dim)
case x
if w < h => Scalr.crop(img, 0, (h-w)/2, dim, dim)
}
}
// resize pixels
def resizeImg(width: Int, height: Int) = {
Scalr.resize(img, Scalr.Method.BALANCED, width, height)
}
}
}
提取图像元数据
到目前为止,我们已经加载并预处理了原始图像,但我们还不知道需要哪些图像元数据来让我们的 CNN 进行学习。因此,现在是时候加载包含每个图像元数据的 CSV 文件了。
我写了一个方法来读取这种 CSV 格式的元数据,叫做readMetadata()
,稍后两个方法readBusinessLabels
和readBusinessToImageLabels
也会使用它。这三个方法定义在CSVImageMetadataReader.scala
脚本中。下面是readMetadata()
方法的签名:
def readMetadata(csv: String, rows: List[Int]=List(-1)): List[List[String]] = {
val src = Source.fromFile(csv)
def reading(csv: String): List[List[String]]= {
src.getLines.map(x => x.split(",").toList)
.toList
}
try {
if(rows==List(-1)) reading(csv)
else rows.map(reading(csv))
}
finally {
src.close
}
}
readBusinessLabels()
方法将商业 ID 映射到标签,格式为 businessID
→ Set (标签):
def readBusinessLabels(csv: String, rows: List[Int]=List(-1)): Map[String, Set[Int]] = {
val reader = readMetadata(csv)
reader.drop(1)
.map(x => x match {
case x :: Nil => (x(0).toString, Set[Int]())
case _ => (x(0).toString, x(1).split(" ").map(y => y.toInt).toSet)
}).toMap
}
readBusinessToImageLabels()
方法将图像 ID 映射到商业 ID,格式为 imageID
→ businessID
:
def readBusinessToImageLabels(csv: String, rows: List[Int] = List(-1)): Map[Int, String] = {
val reader = readMetadata(csv)
reader.drop(1)
.map(x => x match {
case x :: Nil => (x(0).toInt, "-1")
case _ => (x(0).toInt, x(1).split(" ").head)
}).toMap
}
图像特征提取
到目前为止,我们已经看到了如何预处理图像,以便从中提取特征并将其输入到 CNN 中。此外,我们还看到了如何提取和映射元数据并将其与原始图像链接。现在是时候从这些预处理过的图像中提取特征了。
我们还需要记住每个图像元数据的来源。正如你所猜的那样,我们需要三次映射操作来提取特征。基本上,我们有三个映射。详情请参见imageFeatureExtractor.scala
脚本:
-
商业映射形式为
imageID
→businessID
-
数据映射形式为
imageID
→ 图像数据 -
标签映射形式为
businessID
→ 标签
我们首先定义一个正则表达式模式,从 CSV ImageMetadataReader
类中提取.jpg
名称,该类用于与训练标签匹配:
val patt_get_jpg_name = new Regex("[0-9]")
然后,我们提取出所有与相应业务 ID 关联的图像 ID:
def getImgIdsFromBusinessId(bizMap: Map[Int, String], businessIds: List[String]): List[Int] = {
bizMap.filter(x => businessIds.exists(y => y == x._2)).map(_._1).toList
}
现在,我们需要加载并处理所有已经预处理过的图像,通过与从业务 ID 提取的 ID 进行映射,如前所示,来提取图像 ID:
def getImageIds(photoDir: String, businessMap: Map[Int, String] = Map(-1 -> "-1"), businessIds:
List[String] = List("-1")): List[String] = {
val d = new File(photoDir)
val imgsPath = d.listFiles().map(x => x.toString).toList
if (businessMap == Map(-1 -> "-1") || businessIds == List(-1)) {
imgsPath
}
else {
val imgsMap = imgsPath.map(x => patt_get_jpg_name.findAllIn(x).mkString.toInt -> x).toMap
val imgsPathSub = getImgIdsFromBusinessId(businessMap, businessIds)
imgsPathSub.filter(x => imgsMap.contains(x)).map(x => imgsMap(x))
}
}
到目前为止,我们已经能够提取出所有与至少一个业务相关的图像 ID。下一步是读取并处理这些图像,形成imageID
→ 向量映射:
def processImages(imgs: List[String], resizeImgDim: Int = 128, nPixels: Int = -1): Map[Int,Vector[Int]]= {
imgs.map(x => patt_get_jpg_name.findAllIn(x).mkString.toInt -> {
val img0 = ImageIO.read(new File(x))
.makeSquare
.resizeImg(resizeImgDim, resizeImgDim) // (128, 128)
.image2gray
if(nPixels != -1) img0.slice(0, nPixels)
else img0
}
).filter( x => x._2 != ())
.toMap
}
做得很好!我们只差一步,就能提取出训练 CNN 所需的数据。特征提取的最后一步是提取像素数据:
图 13:图像数据表示
总结起来,我们需要为每个图像跟踪四个对象的组成部分——即imageID
、businessID
、标签和像素数据。因此,如前图所示,主要数据结构由四种数据类型(四元组)构成——imgID
、businessID
、像素数据向量和标签:
List[(Int, String, Vector[Int], Set[Int])]
因此,我们应该有一个包含这些对象所有部分的类。别担心,我们需要的所有内容都已在featureAndDataAligner.scala
脚本中定义。一旦我们在Main.scala
脚本中的main
方法下,通过以下代码行实例化featureAndDataAligner
的实例,就可以提供businessMap
、dataMap
和labMap
:
val alignedData = new featureAndDataAligner(dataMap, businessMap, Option(labelMap))()
在这里,labMap
的选项类型被使用,因为在对测试数据评分时我们没有这个信息——即,对于该调用使用None
:
class featureAndDataAligner(dataMap: Map[Int, Vector[Int]], bizMap: Map[Int, String], labMap: Option[Map[String, Set[Int]]])(rowindices: List[Int] = dataMap.keySet.toList) {
def this(dataMap: Map[Int, Vector[Int]], bizMap: Map[Int, String])(rowindices: List[Int]) = this(dataMap, bizMap, None)(rowindices)
def alignBusinessImgageIds(dataMap: Map[Int, Vector[Int]], bizMap: Map[Int, String])
(rowindices: List[Int] = dataMap.keySet.toList): List[(Int, String, Vector[Int])] = {
for {
pid <- rowindices
val imgHasBiz = bizMap.get(pid)
// returns None if img doe not have a bizID
val bid = if(imgHasBiz != None) imgHasBiz.get
else "-1"
if (dataMap.keys.toSet.contains(pid) && imgHasBiz != None)
}
yield {
(pid, bid, dataMap(pid))
}
}
def alignLabels(dataMap: Map[Int, Vector[Int]], bizMap: Map[Int, String], labMap: Option[Map[String, Set[Int]]])(rowindices: List[Int] = dataMap.keySet.toList): List[(Int, String, Vector[Int], Set[Int])] = {
def flatten1A, B, C, D, D)): (A, B, C, D) = (t._1._1, t._1._2, t._1._3, t._2)
val al = alignBusinessImgageIds(dataMap, bizMap)(rowindices)
for { p <- al
}
yield {
val bid = p._2
val labs = labMap match {
case None => Set[Int]()
case x => (if(x.get.keySet.contains(bid)) x.get(bid)
else Set[Int]())
}
flatten1(p, labs)
}
}
lazy val data = alignLabels(dataMap, bizMap, labMap)(rowindices)
// getter functions
def getImgIds = data.map(_._1)
def getBusinessIds = data.map(_._2)
def getImgVectors = data.map(_._3)
def getBusinessLabels = data.map(_._4)
def getImgCntsPerBusiness = getBusinessIds.groupBy(identity).mapValues(x => x.size)
}
很好!到目前为止,我们已经成功提取了用于训练 CNN 的特征。然而,目前形式下的特征仍然不适合输入到 CNN 中,因为我们只有特征向量而没有标签。因此,我们需要进行中间转换。
准备 ND4j 数据集
如我所说,我们需要进行中间转换和预处理,以便将训练集包含特征向量以及标签。这个转换过程非常直接:我们需要特征向量和业务标签。
为此,我们有makeND4jDataSets
类(详见makeND4jDataSets.scala
)。该类通过alignLables
函数中的数据结构(以List[(imgID, bizID, labels, pixelVector)]
的形式)创建 ND4j 数据集对象。首先,我们使用makeDataSet()
方法准备数据集:
def makeDataSet(alignedData: featureAndDataAligner, bizClass: Int): DataSet = {
val alignedXData = alignedData.getImgVectors.toNDArray
val alignedLabs = alignedData.getBusinessLabels.map(x =>
if (x.contains(bizClass)) Vector(1, 0)
else Vector(0, 1)).toNDArray
new DataSet(alignedXData, alignedLabs)
}
然后,我们需要进一步转换前面的数据结构,转换为INDArray
,这样 CNN 就可以使用:
def makeDataSetTE(alignedData: featureAndDataAligner): INDArray = {
alignedData.getImgVectors.toNDArray
}
训练 CNN 并保存训练好的模型
到目前为止,我们已经看到如何准备训练集;现在我们面临一个挑战。我们必须训练 234,545 张图片。尽管测试阶段只用 500 张图片会轻松一些,但最好还是使用批处理模式,通过 DL4j 的MultipleEpochsIterator
来训练每个 CNN。以下是一些重要的超参数及其详细信息:
-
层数:正如我们在简单的 5 层 MNIST 网络中已经观察到的,我们获得了卓越的分类精度,这非常有前景。在这里,我将尝试构建一个类似的网络。
-
样本数量:如果你训练所有图片,可能需要很长时间。如果你使用 CPU 而不是 GPU 进行训练,那将需要数天时间。当我尝试使用 50,000 张图片时,一台配置为 i7 处理器和 32 GB 内存的机器用了整整一天。现在你可以想象,如果使用整个数据集会需要多长时间。此外,即使你使用批处理模式进行训练,它也至少需要 256 GB 的 RAM。
-
训练轮次:这是遍历所有训练记录的次数。
-
输出特征图的数量(即 nOut):这是特征图的数量。可以仔细查看 DL4j GitHub 仓库中的其他示例。
-
学习率:从类似 TensorFlow 的框架中,我获得了一些启示。在我看来,设置学习率为 0.01 和 0.001 会非常合适。
-
批次数量:这是每个批次中的记录数量——32、64、128,依此类推。我使用了 128。
现在,使用前面的超参数,我们可以开始训练我们的 CNN。以下代码实现了这一功能。首先,我们准备训练集,然后定义所需的超参数,接着我们对数据集进行归一化,使 ND4j 数据框架被编码,且任何被认为为真实的标签是 1,其余为 0。然后我们对编码后的数据集的行和标签进行洗牌。
现在,我们需要使用ListDataSetIterator
和MultipleEpochsIterator
分别为数据集迭代器创建 epoch。将数据集转换为批次模型后,我们就可以开始训练构建的 CNN:
def trainModelEpochs(alignedData: featureAndDataAligner, businessClass: Int = 1, saveNN: String = "") = {
val ds = makeDataSet(alignedData, businessClass)
val nfeatures = ds.getFeatures.getRow(0).length // Hyperparameter
val numRows = Math.sqrt(nfeatures).toInt //numRows*numColumns == data*channels
val numColumns = Math.sqrt(nfeatures).toInt //numRows*numColumns == data*channels
val nChannels = 1 // would be 3 if color image w R,G,B
val outputNum = 9 // # of classes (# of columns in output)
val iterations = 1
val splitTrainNum = math.ceil(ds.numExamples * 0.8).toInt // 80/20 training/test split
val seed = 12345
val listenerFreq = 1
val nepochs = 20
val nbatch = 128 // recommended between 16 and 128
ds.normalizeZeroMeanZeroUnitVariance()
Nd4j.shuffle(ds.getFeatureMatrix, new Random(seed), 1) // shuffles rows in the ds.
Nd4j.shuffle(ds.getLabels, new Random(seed), 1) // shuffles labels accordingly
val trainTest: SplitTestAndTrain = ds.splitTestAndTrain(splitTrainNum, new Random(seed))
// creating epoch dataset iterator
val dsiterTr = new ListDataSetIterator(trainTest.getTrain.asList(), nbatch)
val dsiterTe = new ListDataSetIterator(trainTest.getTest.asList(), nbatch)
val epochitTr: MultipleEpochsIterator = new MultipleEpochsIterator(nepochs, dsiterTr)
val epochitTe: MultipleEpochsIterator = new MultipleEpochsIterator(nepochs, dsiterTe)
//First convolution layer with ReLU as activation function
val layer_0 = new ConvolutionLayer.Builder(6, 6)
.nIn(nChannels)
.stride(2, 2) // default stride(2,2)
.nOut(20) // # of feature maps
.dropOut(0.5)
.activation("relu") // rectified linear units
.weightInit(WeightInit.RELU)
.build()
//First subsampling layer
val layer_1 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build()
//Second convolution layer with ReLU as activation function
val layer_2 = new ConvolutionLayer.Builder(6, 6)
.nIn(nChannels)
.stride(2, 2)
.nOut(50)
.activation("relu")
.build()
//Second subsampling layer
val layer_3 = new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX)
.kernelSize(2, 2)
.stride(2, 2)
.build()
//Dense layer
val layer_4 = new DenseLayer.Builder()
.activation("relu")
.nOut(500)
.build()
// Final and fully connected layer with Softmax as activation function
val layer_5 = new OutputLayer.Builder(LossFunctions.LossFunction.MCXENT)
.nOut(outputNum)
.weightInit(WeightInit.XAVIER)
.activation("softmax")
.build()
val builder: MultiLayerConfiguration.Builder = new NeuralNetConfiguration.Builder()
.seed(seed)
.iterations(iterations)
.miniBatch(true)
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.regularization(true).l2(0.0005)
.learningRate(0.01)
.list(6)
.layer(0, layer_0)
.layer(1, layer_1)
.layer(2, layer_2)
.layer(3, layer_3)
.layer(4, layer_4)
.layer(5, layer_5)
.backprop(true).pretrain(false)
new ConvolutionLayerSetup(builder, numRows, numColumns, nChannels)
val conf: MultiLayerConfiguration = builder.build()
val model: MultiLayerNetwork = new MultiLayerNetwork(conf)
model.init()
model.setListeners(SeqIterationListener).asJava)
model.fit(epochitTr)
val eval = new Evaluation(outputNum)
while (epochitTe.hasNext) {
val testDS = epochitTe.next(nbatch)
val output: INDArray = model.output(testDS.getFeatureMatrix)
eval.eval(testDS.getLabels(), output)
}
if (!saveNN.isEmpty) {
// model config
FileUtils.write(new File(saveNN + ".json"), model.getLayerWiseConfigurations().toJson())
// model parameters
val dos: DataOutputStream = new DataOutputStream(Files.newOutputStream(Paths.get(saveNN + ".bin")))
Nd4j.write(model.params(), dos)
}
}
在前面的代码中,我们还保存了一个.json
文件,包含所有网络配置,以及一个.bin
文件,用于存储所有 CNN 的权重和参数。这是通过两个方法完成的;即在NeuralNetwok.scala
脚本中定义的saveNN()
和loadNN()
。首先,让我们看看saveNN()
方法的签名,代码如下:
def saveNN(model: MultiLayerNetwork, NNconfig: String, NNparams: String) = {
// save neural network config
FileUtils.write(new File(NNconfig), model.getLayerWiseConfigurations().toJson())
// save neural network parms
val dos: DataOutputStream = new DataOutputStream(Files.newOutputStream(Paths.get(NNparams)))
Nd4j.write(model.params(), dos)
}
这个想法既有远见又很重要,因为,正如我所说,你不会为了评估一个新的测试集而第二次训练整个网络——也就是说,假设你只想测试一张图片。我们还有另一种方法叫做loadNN()
,它将之前创建的.json
和.bin
文件读取回MultiLayerNetwork
并用于评分新的测试数据。方法如下:
def loadNN(NNconfig: String, NNparams: String) = {
// get neural network config
val confFromJson: MultiLayerConfiguration =
MultiLayerConfiguration.fromJson(FileUtils.readFileToString(new File(NNconfig)))
// get neural network parameters
val dis: DataInputStream = new DataInputStream(new FileInputStream(NNparams))
val newParams = Nd4j.read(dis)
// creating network object
val savedNetwork: MultiLayerNetwork = new MultiLayerNetwork(confFromJson)
savedNetwork.init()
savedNetwork.setParameters(newParams)
savedNetwork
}
评估模型
我们将使用的评分方法非常简单。它通过平均图像级别的预测来分配业务级别的标签。我知道我做得比较简单,但你可以尝试更好的方法。我做的是,如果某个业务的所有图像属于类别0
的概率平均值大于 0.5,则为该业务分配标签0
:
def scoreModel(model: MultiLayerNetwork, ds: INDArray) = {
model.output(ds)
}
然后我们从scoreModel()
方法收集模型预测,并与alignedData
合并:
def aggImgScores2Business(scores: INDArray, alignedData: featureAndDataAligner ) = {
assert(scores.size(0) == alignedData.data.length, "alignedData and scores length are different. They must be equal")
def getRowIndices4Business(mylist: List[String], mybiz: String): List[Int] = mylist.zipWithIndex.filter(x => x._1 == mybiz).map(_._2)
def mean(xs: List[Double]) = xs.sum / xs.size
alignedData.getBusinessIds.distinct.map(x => (x, {
val irows = getRowIndices4Business(alignedData.getBusinessIds, x)
val ret =
for(row <- irows)
yield scores.getRow(row).getColumn(1).toString.toDouble
mean(ret)
}))
}
最后,我们可以恢复训练并保存的模型,恢复它们,并生成 Kaggle 的提交文件。关键是我们需要将图像预测聚合成每个模型的业务分数。
通过执行 main()方法进行总结
让我们通过查看模型的性能来总结整体讨论。以下代码是一个总体概览:
package Yelp.Classifier
import Yelp.Preprocessor.CSVImageMetadataReader._
import Yelp.Preprocessor.featureAndDataAligner
import Yelp.Preprocessor.imageFeatureExtractor._
import Yelp.Evaluator.ResultFileGenerator._
import Yelp.Preprocessor.makeND4jDataSets._
import Yelp.Evaluator.ModelEvaluation._
import Yelp.Trainer.CNNEpochs._
import Yelp.Trainer.NeuralNetwork._
object YelpImageClassifier {
def main(args: Array[String]): Unit = {
// image processing on training data
val labelMap = readBusinessLabels("data/labels/train.csv")
val businessMap = readBusinessToImageLabels("data/labels/train_photo_to_biz_ids.csv")
val imgs = getImageIds("data/images/train/", businessMap,
businessMap.map(_._2).toSet.toList).slice(0,20000) // 20000 images
println("Image ID retreival done!")
val dataMap = processImages(imgs, resizeImgDim = 256)
println("Image processing done!")
val alignedData =
new featureAndDataAligner(dataMap, businessMap, Option(labelMap))()
println("Feature extraction done!")
// training one model for one class at a time. Many hyperparamters hardcoded within
val cnn0 = trainModelEpochs(alignedData, businessClass = 0, saveNN = "models/model0")
val cnn1 = trainModelEpochs(alignedData, businessClass = 1, saveNN = "models/model1")
val cnn2 = trainModelEpochs(alignedData, businessClass = 2, saveNN = "models/model2")
val cnn3 = trainModelEpochs(alignedData, businessClass = 3, saveNN = "models/model3")
val cnn4 = trainModelEpochs(alignedData, businessClass = 4, saveNN = "models/model4")
val cnn5 = trainModelEpochs(alignedData, businessClass = 5, saveNN = "models/model5")
val cnn6 = trainModelEpochs(alignedData, businessClass = 6, saveNN = "models/model6")
val cnn7 = trainModelEpochs(alignedData, businessClass = 7, saveNN = "models/model7")
val cnn8 = trainModelEpochs(alignedData, businessClass = 8, saveNN = "models/model8")
// processing test data for scoring
val businessMapTE = readBusinessToImageLabels("data/labels/test_photo_to_biz.csv")
val imgsTE = getImageIds("data/images/test//", businessMapTE,
businessMapTE.map(_._2).toSet.toList)
val dataMapTE = processImages(imgsTE, resizeImgDim = 128) // make them 256x256
val alignedDataTE = new featureAndDataAligner(dataMapTE, businessMapTE, None)()
// creating csv file to submit to kaggle (scores all models)
val Results = SubmitObj(alignedDataTE, "results/ModelsV0/")
val SubmitResults = writeSubmissionFile("kaggleSubmitFile.csv", Results, thresh = 0.9)
}
}
>>>
==========================Scores======================================
Accuracy: 0.6833
Precision: 0.53
Recall: 0.5222
F1 Score: 0.5261
======================================================================
那么,你的印象如何?确实,我们没有得到优秀的分类准确度。但我们仍然可以尝试调整超参数。下一部分提供了一些见解。
调整和优化 CNN 超参数
以下超参数非常重要,必须调整以获得优化结果。
-
丢弃法(Dropout):用于随机省略特征检测器,以防止过拟合
-
稀疏性:用于强制激活稀疏/罕见输入
-
自适应梯度法(Adagrad):用于特征特定的学习率优化
-
正则化:L1 和 L2 正则化
-
权重转换:对深度自编码器有用
-
概率分布操控:用于初始权重生成
-
梯度归一化和裁剪
另一个重要的问题是:你什么时候想要添加一个最大池化层,而不是具有相同步幅的卷积层?最大池化层根本没有参数,而卷积层有很多。有时,添加一个局部响应归一化层,可以让最强激活的神经元抑制同一位置但邻近特征图中的神经元,鼓励不同的特征图进行专门化,并将它们分开,迫使它们探索更广泛的特征。通常用于较低层,以便拥有更多低级特征供上层构建。
在训练大规模神经网络时观察到的主要问题之一是过拟合,即为训练数据生成非常好的逼近,但在单个点之间的区域产生噪声。在过拟合的情况下,模型专门针对训练数据集进行调整,因此不能用于泛化。因此,尽管在训练集上表现良好,但在测试集和后续测试中的表现较差,因为它缺乏泛化能力:
图 14:丢弃法与不丢弃法的对比
该方法的主要优点是避免了同一层的所有神经元同步优化它们的权重。这种在随机组中进行的适应,避免了所有神经元收敛到相同的目标,从而使得适应的权重不相关。应用 dropout 时发现的第二个特性是,隐藏单元的激活变得稀疏,这也是一种理想的特性。
由于在 CNN 中,目标函数之一是最小化计算出的代价,我们必须定义一个优化器。DL4j 支持以下优化器:
-
SGD(仅学习率)
-
Nesterov 的动量
-
Adagrad
-
RMSProp
-
Adam
-
AdaDelta
在大多数情况下,如果性能不满意,我们可以采用已实现的 RMSProp,它是梯度下降的高级形式。RMSProp 表现更好,因为它将学习率除以平方梯度的指数衰减平均值。建议的衰减参数值为 0.9,而学习率的一个良好默认值为 0.001。
更技术性地说,通过使用最常见的优化器,如随机梯度下降(SGD),学习率必须按 1/T 的比例进行缩放才能收敛,其中 T 是迭代次数。RMSProp 尝试通过自动调整步长来克服这一限制,使步长与梯度处于相同的尺度。因此,如果你正在训练神经网络,但计算梯度是必须的,使用 RMSProp 将是小批量训练中更快的学习方式。研究人员还建议在训练深度 CNN 或 DNN 时使用动量优化器。
从分层架构的角度来看,CNN 与 DNN 不同;它有不同的需求和调优标准。CNN 的另一个问题是卷积层需要大量的 RAM,尤其是在训练过程中,因为反向传播的反向传递需要保留前向传播过程中计算的所有中间值。在推理过程中(即对新实例进行预测时),一个层占用的 RAM 可以在下一个层计算完毕后释放,因此你只需要两个连续层所需的内存。
然而,在训练过程中,前向传播过程中计算的所有内容都需要在反向传播时保留下来,因此所需的内存量至少是所有层所需的总内存量。如果你的 GPU 在训练 CNN 时内存不足,这里有五个解决问题的建议(除了购买更大内存的 GPU):
-
减小小批量的大小
-
使用较大的步幅在一层或多层中减少维度
-
移除一层或多层
-
使用 16 位浮点数代替 32 位浮点数
-
将 CNN 分布到多个设备上
总结
在本章中,我们已经看到如何使用和构建基于卷积神经网络(CNN)的现实应用,CNN 是一种前馈人工神经网络,其神经元之间的连接模式受到动物视觉皮层组织的启发。我们使用 CNN 构建的图像分类应用可以以可接受的准确度对现实世界中的图像进行分类,尽管我们没有达到更高的准确度。然而,鼓励读者在代码中调整超参数,并尝试使用其他数据集采用相同的方法。
然而,重要的是,由于卷积神经网络的内部数据表示没有考虑到简单和复杂物体之间的重要空间层级,因此 CNN 在某些实例中有一些严重的缺点和限制。因此,我建议你查看 GitHub 上关于胶囊网络的最新活动:github.com/topics/capsule-network
。希望你能从中获得一些有用的信息。
这基本上是我们使用 Scala 和不同开源框架开发机器学习项目的小旅程的结束。在各章中,我尝试为你提供了多个示例,展示如何有效地使用这些出色的技术来开发机器学习项目。在写这本书的过程中,我必须考虑到许多限制条件,例如页面数量、API 可用性和我的专业知识。但我尽量使书籍保持简洁,并且避免了过多的理论细节,因为关于 Apache Spark、DL4j 和 H2O 的理论内容,你可以在许多书籍、博客和网站上找到。
我还会在我的 GitHub 仓库上更新这本书的代码:github.com/PacktPublishing/Scala-Machine-Learning-Projects
。随时欢迎提出新问题或提交任何拉取请求,以改善这本书,并保持关注。
最后,我写这本书并不是为了赚钱,但大部分版税将用于资助孟加拉国我家乡地区的儿童教育。我想感谢并对购买并享受这本书的读者表示衷心的感谢!