Scala 机器学习快速启动指南(二)
在无监督学习中,在训练阶段向系统提供一个输入集。与监督学习相反,输入对象没有标记其类别。虽然在分类分析中训练数据集是标记的,但在现实世界中收集数据时,我们并不总是有这种优势,但我们仍然希望找到数据的重要值或隐藏结构。在 2016 年的 NeuralIPS 上,Facebook AI 首席科学家 Yann LeCun 介绍了蛋糕类比“如果智能是一块蛋糕,无监督学习就是蛋糕本身,监督学习就是蛋糕上的糖
原文:
annas-archive.org/md5/7cee668dc80e7e6b8a656779b72e2561
译者:飞龙
第五章:Scala 用于降维和聚类
在前面的章节中,我们看到了几个监督学习的例子,包括分类和回归。我们在结构化和标记数据上执行了监督学习技术。然而,正如我们之前提到的,随着云计算、物联网和社交媒体的兴起,非结构化数据正在以前所未有的速度增加。总的来说,超过 80%的数据是非结构化的,其中大部分是无标签的。
无监督学习技术,如聚类分析和降维,是数据驱动研究和工业环境中寻找非结构化数据集中隐藏结构的关键应用。为此,提出了许多聚类算法,如 k-means、二分 k-means 和高斯混合模型。然而,这些算法不能在高维输入数据集上高效运行,并且经常遭受维度灾难。因此,使用主成分分析(PCA)等算法降低维度,并输入潜在数据,是聚类数十亿数据点的一种有用技术。
在本章中,我们将使用一种基因变体(一种基因组数据)根据他们的主要血统(也称为地理种族)对人群进行聚类。我们将评估聚类分析结果,然后进行降维技术,以避免维度灾难。
本章我们将涵盖以下主题:
-
无监督学习概述
-
学习聚类——聚类地理种族
-
使用 PCA 进行降维
-
使用降维数据进行聚类
技术要求
确保 Scala 2.11.x 和 Java 1.8.x 已安装并配置在您的机器上。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter05
查看以下视频,了解代码的实际应用:
无监督学习概述
在无监督学习中,在训练阶段向系统提供一个输入集。与监督学习相反,输入对象没有标记其类别。虽然在分类分析中训练数据集是标记的,但在现实世界中收集数据时,我们并不总是有这种优势,但我们仍然希望找到数据的重要值或隐藏结构。在 2016 年的 NeuralIPS 上,Facebook AI 首席科学家 Yann LeCun 介绍了蛋糕类比:
“如果智能是一块蛋糕,无监督学习就是蛋糕本身,监督学习就是蛋糕上的糖霜,强化学习就是蛋糕上的樱桃。我们知道如何制作糖霜和樱桃,但我们不知道如何制作蛋糕。”
为了创建这样的蛋糕,需要使用包括聚类、维度约简、异常检测和关联规则挖掘在内的几个无监督学习任务。如果无监督学习算法能够在不需要标签的情况下帮助在数据集中找到先前未知的模式,我们可以为这一章学习以下类比:
-
K-means 是一种流行的聚类分析算法,用于将相似数据点分组在一起
-
维度约简算法,如 PCA,有助于在数据集中找到最相关的特征
在本章中,我们将通过实际示例讨论这两种聚类分析技术。
聚类分析
聚类分析和维度约简是无监督学习的两个最流行的例子,我们将在本章中通过示例进行讨论。假设您在电脑或智能手机中有大量法律 MP3 文件。在这种情况下,如果没有直接访问它们的元数据,您如何将歌曲分组在一起?
一种可能的方法可能是混合各种机器学习技术,但聚类通常是最佳解决方案。这是因为我们可以开发一个聚类模型,以自动将相似的歌曲分组并组织到您最喜欢的类别中,例如乡村、说唱或摇滚。
尽管数据点没有标签,我们仍然可以进行必要的特征工程并将相似的对象分组在一起,这通常被称为聚类。
聚类是指根据某些相似性度量将数据点分组在一起的一组数据点。
然而,这对人类来说并不容易。相反,一种标准的方法是定义两个对象之间的相似性度量,然后寻找任何对象簇,这些对象簇之间的相似性比它们与其他簇中的对象之间的相似性更大。一旦我们对数据点(即 MP3 文件)进行了聚类(即验证完成),我们就知道了数据的模式(即哪种类型的 MP3 文件属于哪个组)。
左侧图显示了播放列表中所有的MP3 曲目,它们是分散的。右侧部分显示了基于流派如何对 MP3 进行聚类:
聚类分析算法
聚类算法的目标是将一组相似的无标签数据点分组在一起,以发现潜在的模式。以下是一些已经提出并用于聚类分析算法的算法:
-
K-means
-
二分 k-means
-
高斯混合模型(GMM)
-
幂迭代聚类(PIC)
-
潜在狄利克雷分配(LDA)
-
流式 k-means
K-means、二分 k-means 和 GMM 是最广泛使用的。我们将详细说明,以展示快速入门的聚类分析。然而,我们还将查看仅基于 k-means 的示例。
K-means 聚类分析
K-means 寻找一个固定的簇数k(即质心的数量),将数据点划分为k个簇,并通过尽可能保持质心最小来将每个数据点分配到最近的簇。
质心是一个想象中的或实际的位置,代表簇的中心。
K-means 通过最小化成本函数,称为簇内平方和(WCSS),来计算数据点到k个簇中心的距离(通常是欧几里得距离)。k-means 算法通过交替进行以下两个步骤进行:
-
簇分配步骤:每个数据点被分配到具有最小平方欧几里得距离的簇,从而产生最低的 WCSS
-
质心更新步骤:计算新簇中观测值的新均值,并将其用作新的质心
前面的步骤可以用以下图表表示:
当质心稳定或达到预定义的迭代次数时,k-means 算法完成。尽管 k-means 使用欧几里得距离,但还有其他计算距离的方法,例如:
-
切比雪夫距离可以用来通过仅考虑最显著的维度来测量距离
-
汉明距离算法可以识别两个字符串之间的差异
-
为了使距离度量不受尺度影响,可以使用马氏距离来归一化协方差矩阵
-
曼哈顿距离通过仅考虑轴对齐方向来测量距离
-
闵可夫斯基距离算法用于生成欧几里得距离、曼哈顿距离和切比雪夫距离
-
哈夫曼距离用于测量球面上两点之间的球面距离,即经纬度
二分 k-means
二分 k-means 可以看作是 k-means 和层次聚类的组合,它从单个簇中的所有数据点开始。然后,它随机选择一个簇进行分割,使用基本的 k-means 返回两个子簇。这被称为二分步骤。
二分 k-means 算法基于一篇题为“A Comparison of Document Clustering Techniques”的论文,由 Michael Steinbach 等人撰写,发表于 2000 年的 KDD 文本挖掘研讨会,该论文已被扩展以适应 Spark MLlib。
然后,对二分步骤进行预定义的次数迭代(通常由用户/开发者设置),并收集产生具有最高相似度的所有分割。这些步骤一直持续到达到所需的簇数。尽管二分 k-means 比常规 k-means 更快,但它产生的聚类不同,因为二分 k-means 随机初始化簇。
高斯混合模型
GMM 是一种概率模型,它强假设所有数据点都是由有限数量的高斯分布的混合生成的,且参数未知。因此,它也是一种基于分布的聚类算法,该算法基于期望最大化方法。
GMM 也可以被视为一种广义的 k-means,其中模型参数通过迭代优化以更好地拟合训练数据集。整个过程可以用以下三步伪代码表示:
-
目标函数:使用期望最大化(EM)计算并最大化对数似然
-
EM 步骤:这个 EM 步骤包括两个子步骤,称为期望和最大化:
-
步骤 E:计算最近数据点的后验概率
-
步骤 M:更新和优化模型参数以拟合高斯混合模型
-
-
分配:在步骤 E期间进行软分配
前面的步骤可以非常直观地表示如下:
其他聚类分析算法
其他聚类算法包括 PIC,它用于根据给定的成对相似度(如边)对图中的节点进行聚类。LDA 在文本聚类用例中经常被使用,如主题建模。
另一方面,流式 k-means 与 k-means 类似,但适用于流数据。例如,当我们想要动态估计簇,以便在新的数据到达时更新聚类分配时,使用流式 k-means 是一个好的选择。对于更详细的讨论和示例,感兴趣的读者可以参考以下链接:
-
基于 Spark ML 的聚类算法 (
spark.apache.org/docs/latest/ml-clustering.html
) -
基于 Spark MLlib 的聚类算法 (
spark.apache.org/docs/latest/mllib-clustering.html
)
通过示例进行聚类分析
在聚类分析中,最重要的任务之一是对基因组图谱进行分析,以将个体归入特定的种族群体,或者对疾病易感性进行核苷酸单倍型分析。根据亚洲、欧洲、非洲和美洲的基因组数据,可以区分人类祖先。研究表明,Y 染色体谱系可以在地理上定位,这为将人类基因型的等位基因进行聚类提供了证据。根据国家癌症研究所(www.cancer.gov/publications/dictionaries/genetics-dictionary/def/genetic-variant
):
“遗传变异是 DNA 最常见核苷酸序列的改变。变异一词可以用来描述可能良性、致病或意义未知的改变。变异一词越来越多地被用来代替突变。”
更好地理解遗传变异有助于我们找到相关的种群群体,识别易患常见疾病的患者,以及解决罕见疾病。简而言之,想法是根据遗传变异将地理民族群体进行聚类。然而,在进一步探讨之前,让我们先了解数据。
数据集描述
1,000 基因组项目的数据是人类遗传变异的大型目录。该项目旨在确定在研究人群中频率超过 1%的遗传变异。1,000 基因组项目的第三阶段于 2014 年 9 月完成,涵盖了来自 26 个种群和 8,440,000,000 个遗传变异的 2,504 个个体。根据其主要的血统,种群样本被分为五个超级种群群体:
-
亚洲东部(CHB、JPT、CHS、CDX 和 KHV)
-
欧洲地区(CEU、TSI、FIN、GBR 和 IBS)
-
非洲地区(YRI、LWK、GWD、MSL、ESN、ASW 和 ACB)
-
美国地区(MXL、PUR、CLM 和 PEL)
-
南亚地区(GIH、PJL、BEB、STU 和 ITU)
每个基因型由 23 条染色体和一个包含样本和种群信息的单独的 PANEL 文件组成。变异调用格式(VCF)中的数据以及 PANEL 文件可以从 ftp://ftp.1000genomes.ebi.ac.uk/vol1/ftp/release/20130502/下载。
准备编程环境
由于 1,000 基因组项目的第三次发布贡献了大约 820 GB 的数据,因此需要使用可扩展的软件和硬件来处理它们。为此,我们将使用以下组件组成的软件栈:
-
ADAM:这可以用来实现支持 VCF 文件格式的可扩展基因组数据分析平台,从而将基于基因型的 RDD 转换为 Spark DataFrame。
-
Sparkling Water:H20 是一个机器学习 AI 平台,以及一个支持 Java、Python 和 R 等编程语言的基于 Web 的数据处理 UI。简而言之,Sparkling Water 等于 H2O 加上 Spark。
-
基于 Spark-ML 的 k-means 用于聚类分析。
对于这个例子,我们需要使用多个技术和软件栈,例如 Spark、H2O 和 Adam。在使用 H20 之前,请确保您的笔记本电脑至少有 16 GB 的 RAM 和足够的存储空间。我将把这个解决方案作为一个 Maven 项目来开发。
让我们在pom.xml
文件上定义属性标签,以适应 Maven 友好的项目:
<properties>
<spark.version>2.4.0</spark.version>
<scala.version>2.11.7</scala.version>
<h2o.version>3.22.1.1</h2o.version>
<sparklingwater.version>2.4.1</sparklingwater.version>
<adam.version>0.23.0</adam.version>
</properties>
一旦你在 Eclipse 上创建了一个 Maven 项目(从一个 IDE 或使用mvn install
命令),所有必需的依赖项都将被下载!
聚类地理民族
24 个 VCF 文件贡献了大约 820 GB 的数据,这将带来巨大的计算挑战。为了克服这一点,使用最小的染色体 Y 中的遗传变异。这个 VCF 文件的大小大约为 160 MB。让我们通过创建SparkSession
开始:
val spark:SparkSession = SparkSession
.builder()
.appName("PopStrat")
.master("local[*]")
.config("spark.sql.warehouse.dir", "temp/")
.getOrCreate()
现在,让我们向 Spark 展示 VCF 和 PANEL 文件的路由:
val genotypeFile = "Downloads/ALL.chr22.phase3_shapeit2_mvncall_integrated_v5a.20130502.genotypes.vcf"
val panelFile = "Downloads/integrated_call_samples_v3.20130502.ALL.panel"
我们使用 Spark 处理 PANEL 文件,以访问目标人群数据并识别人群组。首先,我们创建一组我们想要形成聚类的 populations
:
val populations = Set("FIN", "GBR", "ASW", "CHB", "CLM")
然后,我们需要创建样本 ID 和给定人群之间的映射,以便我们可以过滤掉我们不感兴趣的样本:
def extract(file: String, filter: (String, String) => Boolean): Map[String, String] = {
Source
.fromFile(file)
.getLines()
.map(line => {
val tokens = line.split(Array('\t', ' ')).toList
tokens(0) -> tokens(1)
})
.toMap
.filter(tuple => filter(tuple._1, tuple._2))
}
val panel: Map[String, String] = extract(
panelFile,(sampleID: String, pop: String) => populations.contains(pop))
面板文件生成了所有个体的样本 ID、人群组、民族、超人群组和性别:
请查看面板文件的详细信息:ftp://ftp.1000genomes.ebi.ac.uk/vol1/ftp/release/20130502/integrated_call_samples_v3.20130502.ALL.panel。
然后,加载 ADAM 基因型并过滤基因型,以便我们只留下对我们感兴趣的人群中的那些:
val allGenotypes: RDD[Genotype] = sc.loadGenotypes(genotypeFile).rdd
val genotypes: RDD[Genotype] = allGenotypes.filter(genotype => {
panel.contains(genotype.getSampleId)
})
接下来,将 Genotype
对象转换为我们的 SampleVariant
对象以节省内存。然后,将 genotype
对象转换为包含需要进一步处理的数据的 SampleVariant
对象:
-
样本 ID:用于唯一标识特定的样本
-
变异 ID:用于唯一标识特定的遗传变异
-
替代等位基因计数:当样本与参考基因组不同时需要
准备 SampleVariant
的签名如下,它接受 sampleID
、variationId
和 alternateCount
对象:
// Convert the Genotype objects to our own SampleVariant objects to try and conserve memory
case class SampleVariant(sampleId: String, variantId: Int, alternateCount: Int)
然后,我们必须从基因型文件中找到 variantID
。varitantId
是一个由名称、染色体中的起始和结束位置组成的字符串类型:
def variantId(genotype: Genotype): String = {
val name = genotype.getVariant.getContigName
val start = genotype.getVariant.getStart
val end = genotype.getVariant.getEnd
s"$name:$start:$end"
}
一旦我们有了 variantID
,我们就应该寻找 alternateCount
。在基因型文件中,具有等位基因参考的对象将是遗传替代物:
def alternateCount(genotype: Genotype): Int = {
genotype.getAlleles.asScala.count(_ != GenotypeAllele.REF)
}
最后,我们将构建一个 SampleVariant
对象。为此,我们需要将样本 ID 内部化,因为它们在 VCF 文件中会重复很多次:
def toVariant(genotype: Genotype): SampleVariant = {
new SampleVariant(genotype.getSampleId.intern(),
variantId(genotype).hashCode(),
alternateCount(genotype))
}
现在,我们需要准备 variantsRDD
。首先,我们必须按样本 ID 对变异进行分组,以便我们可以逐个处理变异。然后,我们可以获取用于查找某些样本缺失变异的总样本数。最后,我们必须按变异 ID 对变异进行分组,并过滤掉某些样本缺失的变异:
val variantsRDD: RDD[SampleVariant] = genotypes.map(toVariant)
val variantsBySampleId: RDD[(String, Iterable[SampleVariant])] = variantsRDD.groupBy(_.sampleId)
val sampleCount: Long = variantsBySampleId.count()
println("Found " + sampleCount + " samples")
val variantsByVariantId: RDD[(Int, Iterable[SampleVariant])] =
variantsRDD.groupBy(_.variantId).filter {
case (_, sampleVariants) => sampleVariants.size == sampleCount
}
现在,让我们将 variantId
与具有大于零的替代计数的样本数量进行映射。然后,我们过滤掉不在我们期望的频率范围内的变异。这里的目的是减少数据集的维度数量,使其更容易训练模型:
val variantFrequencies: collection.Map[Int, Int] = variantsByVariantId
.map {
case (variantId, sampleVariants) =>
(variantId, sampleVariants.count(_.alternateCount > 0))
}
.collectAsMap()
样本总数(或个体数)已经确定。现在,在根据变异 ID 对它们进行分组之前,我们可以过滤掉不太重要的变异。由于我们有超过 8400 万个遗传变异,过滤可以帮助我们处理维度诅咒。
指定的范围是任意的,因为它包括合理数量的变体,但不是太多。更具体地说,对于每个变体,已经计算了等位基因的频率,并且排除了具有少于 12 个等位基因的变体,从而在分析中留下了大约 3,000,000 个变体(对于 23 个染色体文件):
val permittedRange = inclusive(11, 11) // variants with less than 12 alternate alleles
val filteredVariantsBySampleId: RDD[(String, Iterable[SampleVariant])] =
variantsBySampleId.map {
case (sampleId, sampleVariants) =>
val filteredSampleVariants = sampleVariants.filter(
variant =>
permittedRange.contains(
variantFrequencies.getOrElse(variant.variantId, -1)))
(sampleId, filteredSampleVariants)
}
一旦我们有了 filteredVariantsBySampleId
,我们需要对每个样本 ID 的变体进行排序。每个样本现在应该具有相同数量的排序变体:
val sortedVariantsBySampleId: RDD[(String, Array[SampleVariant])] =
filteredVariantsBySampleId.map {
case (sampleId, variants) =>
(sampleId, variants.toArray.sortBy(_.variantId))
}
println(s"Sorted by Sample ID RDD: " + sortedVariantsBySampleId.first())
RDD 中的所有项现在都应该具有相同的变体,并且顺序相同。最终任务是使用 sortedVariantsBySampleId
来构建一个包含区域和等位基因计数的行 RDD:
val rowRDD: RDD[Row] = sortedVariantsBySampleId.map {
case (sampleId, sortedVariants) =>
val region: Array[String] = Array(panel.getOrElse(sampleId, "Unknown"))
val alternateCounts: Array[Int] = sortedVariants.map(_.alternateCount)
Row.fromSeq(region ++ alternateCounts)
}
因此,我们只需使用第一个来构建我们的训练 DataFrame 的标题:
val header = StructType(
Array(StructField("Region", StringType)) ++
sortedVariantsBySampleId
.first()
._2
.map(variant => {
StructField(variant.variantId.toString, IntegerType)
}))
干得好!我们有了我们的 RDD 和 StructType
标题。现在,我们可以用最小的调整/转换来玩 Spark 机器学习算法。
训练 k-means 算法
一旦我们有了 rowRDD
和标题,我们需要使用标题和 rowRDD
从变体中构建我们的模式 DataFrame 的行:
// Create the SchemaRDD from the header and rows and convert the SchemaRDD into a Spark DataFrame
val sqlContext = sparkSession.sqlContext
var schemaDF = sqlContext.createDataFrame(rowRDD, header)
schemaDF.show(10)
>>>
前面的 show()
方法应该显示包含特征和 label
列(即 Region
)的训练数据集快照:
在前面的 DataFrame 中,只显示了少数 feature
列和 label
列,以便它适合页面。由于训练将是无监督的,我们需要删除 label
列(即 Region
):
schemaDF = sqlContext.createDataFrame(rowRDD, header).drop("Region")
schemaDF.show(10)
>>>
前面的 show()
方法显示了以下 k-means 的训练数据集快照。注意,没有 label
列(即 Region
):
在第一章,《使用 Scala 的机器学习入门》,和第二章,《Scala 回归分析》,我们了解到 Spark 预期用于监督训练有两个列(features
和 label
)。然而,对于无监督训练,只需要包含特征的单一列。由于我们删除了 label
列,我们现在需要将整个 variable
列合并为一个单一的 features
列。为此,我们将使用 VectorAssembler()
转换器。让我们选择要嵌入到向量空间中的列:
val featureCols = schemaDF.columns
然后,我们将通过指定输入列和输出列来实例化 VectorAssembler()
转换器:
// Using vector assembler to create feature vector
val featureCols = schemaDF.columns
val assembler = new VectorAssembler()
.setInputCols(featureCols)
.setOutputCol("features")
val assembleDF = assembler.transform(schemaDF).select("features")
现在,让我们看看 k-means 的特征向量是什么样的:
assembleDF.show()
前面的行显示了组装的向量,这些向量可以用作 k-means 模型的特征向量:
最后,我们准备好训练 k-means 算法并通过计算 WCSS 来评估聚类:
val kmeans = new KMeans().setK(5).setSeed(12345L)
val model = kmeans.fit(assembleDF)
val WCSS = model.computeCost(assembleDF)
println("Within Set Sum of Squared Errors for k = 5 is " + WCSS)
}
下面的 WCSS 值为 k = 5
:
Within Set Sum of Squared Errors for k = 5 is 59.34564329865
我们成功地将 k-means 应用于聚类遗传变异。然而,我们注意到 WCSS 很高,因为 k-means 无法分离不同相关的高维特征之间的非线性。这是因为基因组测序数据集由于大量的遗传变异而具有非常高的维度。
在下一节中,我们将看到如何使用降维技术,如 PCA,在将数据输入到 k-means 之前降低输入数据的维度,以获得更好的聚类质量。
降维
由于人类是视觉生物,理解高维数据集(甚至超过三个维度)是不可能的。即使是对于机器(或者说,我们的机器学习算法),也很难从相关的高维特征中建模非线性。在这里,降维技术是一个救星。
从统计学的角度来看,降维是减少随机变量的数量,以找到数据的一个低维表示,同时尽可能保留尽可能多的信息**。**
PCA 的整体步骤可以在以下图表中直观地表示:
主成分分析(PCA)和奇异值分解(SVD)是降维中最受欢迎的算法。从技术上讲,PCA 是一种用于强调变异并从数据集中提取最显著模式(即特征)的统计技术,这不仅对聚类有用,对分类和可视化也有帮助。
基于 Spark ML 的主成分分析
基于 Spark-ML 的 PCA 可以用来将向量投影到低维空间,在将它们输入到 k-means 模型之前降低遗传变异特征的维度。以下示例展示了如何将以下特征向量投影到 4 维主成分:
val data = Array(
Vectors.dense(1.2, 3.57, 6.8, 4.5, 2.25, 3.4),
Vectors.dense(4.60, 4.10, 9.0, 5.0, 1.67, 4.75),
Vectors.dense(5.60, 6.75, 1.11, 4.5, 2.25, 6.80))
val df = spark.createDataFrame(data.map(Tuple1.apply)).toDF("features")
df.show(false)
现在我们有一个具有 6 维特征向量的特征 DataFrame,它可以被输入到 PCA 模型中:
首先,我们必须通过设置必要的参数来实例化 PCA 模型,如下所示:
val pca = new PCA()
.setInputCol("features")
.setOutputCol("pcaFeatures")
.setK(4)
.fit(df)
为了区分原始特征和基于主成分的特征,我们使用setOutputCol()
方法将输出列名设置为pcaFeatures
。然后,我们设置 PCA 的维度(即主成分的数量)。最后,我们将 DataFrame 拟合以进行转换。可以从旧数据中加载模型,但explainedVariance
将会有一个空向量。现在,让我们展示生成的特征:
val result = pca.transform(df).select("features", "pcaFeatures")
result.show(false)
上述代码使用 PCA 生成一个具有 4 维特征向量的特征 DataFrame,作为主成分:
同样,我们可以将上一步组装的 DataFrame(即assembleDF
)和前五个主成分进行转换。你可以调整主成分的数量。
最后,为了避免任何歧义,我们将 pcaFeatures
列重命名为 features
:
val pcaDF = pca.transform(assembleDF)
.select("pcaFeatures")
.withColumnRenamed("pcaFeatures", "features")
pcaDF.show()
上述代码行显示了嵌入的向量,这些向量可以用作 k-means 模型的特征向量:
上述截图显示了前五个主成分作为最重要的特征。太好了——一切顺利。最后,我们准备训练 k-means 算法并通过计算 WCSS 来评估聚类:
val kmeans = new KMeans().setK(5).setSeed(12345L)
val model = kmeans.fit(pcaDF)
val WCSS = model.computeCost(pcaDF)
println("Within Set Sum of Squared Errors for k = 5 is " + WCSS)
}
这次,WCSS 略微降低(与之前的值 59.34564329865
相比):
Within Set Sum of Squared Errors for k = 5 is 52.712937492025276
通常,我们随机设置 k
的数量(即 5
)并计算 WCSS。然而,这种方法并不能总是设置最佳聚类数量。为了找到一个最佳值,研究人员提出了两种技术,称为肘部方法和轮廓分析,我们将在下一小节中探讨。
确定最佳聚类数量
有时候,在开始训练之前天真地假设聚类数量可能不是一个好主意。如果假设与最佳聚类数量相差太远,模型会因为引入的过拟合或欠拟合问题而表现不佳。因此,确定最佳聚类数量是一个独立的优化问题。有两种流行的技术来解决此问题:
-
被称为肘部方法的启发式方法
-
轮廓分析,用于观察预测聚类的分离距离
肘部方法
我们首先将 k
值设置为 2
,并在相同的数据集上运行 k-means 算法,通过增加 k
并观察 WCSS 的值。正如预期的那样,成本函数(即 WCSS 值)在某一点应该会有一个急剧下降。然而,在急剧下降之后,随着 k
值的增加,WCSS 的值变得微不足道。正如肘部方法所建议的,我们可以在 WCSS 的最后一次大幅下降后选择 k
的最佳值:
val iterations = 20
for (k <- 2 to iterations) {
// Trains a k-means model.
val kmeans = new KMeans().setK(k).setSeed(12345L)
val model = kmeans.fit(pcaDF)
// Evaluate clustering by computing Within Set Sum of Squared Errors.
val WCSS = model.computeCost(pcaDF)
println("Within Set Sum of Squared Errors for k = " + k + " is " + WCSS)
}
现在,让我们看看不同数量聚类(例如 2
到 20
)的 WCSS 值:
Within Set Sum of Squared Errors for k = 2 is 135.0048361804504
Within Set Sum of Squared Errors for k = 3 is 90.95271589232344
...
Within Set Sum of Squared Errors for k = 19 is 11.505990055606803
Within Set Sum of Squared Errors for k = 20 is 12.26634441065655
如前述代码所示,我们计算了成本函数 WCSS 作为聚类数量的函数,并将其应用于所选种群组的 Y 染色体遗传变异。可以观察到,当 k = 5
时会出现一个大的下降(尽管不是急剧下降)。因此,我们选择聚类数量为 10。
轮廓分析
通过观察预测聚类的分离距离来分析轮廓。绘制轮廓图将显示数据点与其邻近聚类之间的距离,然后我们可以通过视觉检查多个聚类,以便相似的数据点得到良好的分离。
轮廓得分,用于衡量聚类质量,其范围为 [-1, 1]。通过计算轮廓得分来评估聚类质量:
val evaluator = new ClusteringEvaluator()
for (k <- 2 to 20 by 1) {
val kmeans = new KMeans().setK(k).setSeed(12345L)
val model = kmeans.fit(pcaDF)
val transformedDF = model.transform(pcaDF)
val score = evaluator.evaluate(transformedDF)
println("Silhouette with squared Euclidean distance for k = " + k + " is " + score)
}
我们得到以下输出:
Silhouette with squared Euclidean distance for k = 2 is 0.9175803927739566
Silhouette with squared Euclidean distance for k = 3 is 0.8288633816548874
....
Silhouette with squared Euclidean distance for k = 19 is 0.5327466913746908
Silhouette with squared Euclidean distance for k = 20 is 0.45336547054142284
如前述代码所示,轮廓的高度值是通过k = 2
生成的,为0.9175803927739566
。然而,这表明遗传变异应该分为两组。肘部方法建议k = 5
作为最佳聚类数量。
让我们使用平方欧几里得距离来找出轮廓,如下面的代码块所示:
val kmeansOptimal = new KMeans().setK(2).setSeed(12345L)
val modelOptimal = kmeansOptimal.fit(pcaDF)
// Making predictions
val predictionsOptimalDF = modelOptimal.transform(pcaDF)
predictionsOptimalDF.show()
// Evaluate clustering by computing Silhouette score
val evaluatorOptimal = new ClusteringEvaluator()
val silhouette = evaluatorOptimal.evaluate(predictionsOptimalDF)
println(s"Silhouette with squared Euclidean distance = $silhouette")
k = 2
的平方欧几里得距离轮廓值为0.9175803927739566
。
已经发现,分割 k 均值算法可以对数据点的聚类分配产生更好的结果,收敛到全局最小值。另一方面,k 均值算法往往陷入局部最小值。请注意,根据您的机器硬件配置和数据集的随机性,您可能会观察到前面参数的不同值。
感兴趣的读者还应参考基于 Spark-MLlib 的聚类技术spark.apache.org/docs/latest/mllib-clustering.html
,以获得更多见解。
摘要
在本章中,我们讨论了一些聚类分析方法,如 k 均值、分割 k 均值和 GMM。我们看到了如何根据遗传变异对族群进行聚类的逐步示例。特别是,我们使用了 PCA 进行降维,k 均值进行聚类,以及 H2O 和 ADAM 处理大规模基因组数据集。最后,我们学习了肘部和轮廓方法来寻找最佳聚类数量。
聚类是大多数数据驱动应用的关键。读者可以尝试在更高维度的数据集上应用聚类算法,例如基因表达或 miRNA 表达,以聚类相似和相关的基因。一个很好的资源是基因表达癌症 RNA-Seq 数据集,它是开源的。此数据集可以从 UCI 机器学习存储库下载,网址为archive.ics.uci.edu/ml/datasets/gene+expression+cancer+RNA-Seq
。
在下一章中,我们将讨论推荐系统中的基于物品的协同过滤方法。我们将学习如何开发一个图书推荐系统。技术上,它将是一个基于 Scala 和 Spark 的模型推荐引擎。我们将看到如何实现 ALS 和矩阵分解之间的互操作。
第六章:用于推荐系统的 Scala
在本章中,我们将学习开发推荐系统的不同方法。然后我们将学习如何开发一个书籍推荐系统。技术上,它将是一个基于交替最小二乘法(ALS)和矩阵分解算法的模型推荐引擎。我们将使用基于 Spark MLlib 的这些算法的 Scala 实现。简而言之,我们将在本章中学习以下主题:
-
推荐系统概述
-
基于相似度的推荐系统
-
基于内容的推荐系统
-
协同方法
-
混合推荐系统
-
开发基于模型的书籍推荐系统
技术要求
确保 Scala 2.11.x 和 Java 1.8.x 已安装并配置在您的机器上。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter06
查看以下视频以查看代码的实际应用:
推荐系统概述
推荐系统是一种信息过滤方法,它预测用户对项目的评分。然后,预测评分高的项目将被推荐给用户。推荐系统现在在推荐电影、音乐、新闻、书籍、研究文章、产品、视频、书籍、新闻、Facebook 朋友、餐厅、路线、搜索查询、社交标签、产品、合作伙伴、笑话、餐厅、服装、金融服务、Twitter 页面、Android/iOS 应用、酒店、人寿保险,甚至在在线约会网站上被或多或少地使用。
推荐系统的类型
开发推荐引擎有几种方法,通常会产生一个推荐列表,如以下图中所示的基于相似度、基于内容、协同和混合推荐系统:
我们将讨论基于相似度、基于内容、协同和混合推荐系统。然后基于它们的优缺点,我们将通过一个实际示例展示如何开发一个书籍推荐系统。
基于相似度的推荐系统
基于相似度的两种主要方法:用户-用户相似度和用户-项目相似度。这些方法可以用来构建推荐系统。要使用用户-用户项目相似度方法,首先构建一个用户-用户相似度矩阵。然后它会选择那些被相似用户喜欢的项目,最后为特定用户推荐项目。
假设我们想要开发一个图书推荐系统:自然地,会有许多图书用户(读者)和一系列图书。为了简洁起见,让我们选择以下与机器学习相关的图书作为读者的代表:
然后,基于用户-用户相似度的推荐系统将根据某些相似度度量技术使用相似度度量来推荐图书。例如,余弦相似度的计算如下:
在前面的方程中,A 和 B 代表两个用户。如果相似度阈值大于或等于定义的阈值,用户 A 和 B 很可能具有相似偏好:
然而,基于用户-用户相似度的推荐系统并不稳健。有以下几个原因:
-
用户偏好和口味通常会随时间变化
-
由于需要从非常稀疏的矩阵计算中计算许多案例的相似度,因此它们在计算上非常昂贵
亚马逊和 YouTube 拥有数百万的订阅用户,因此你创建的任何用户-用户效用矩阵都将是一个非常稀疏的矩阵。一种解决方案是使用项目-项目相似度,这也会计算出一个项目-项目效用矩阵,找到相似的项目,最后推荐相似的项目,就像以下图示:
这种方法与用户-用户相似度方法相比有一个优点,即通常在初始阶段之后,给定项目的评分不会发生很大的变化。以《百页机器学习书》为例,尽管它只发布了几个月,但在亚马逊上已经获得了非常好的评分。因此,即使在未来几个月内,有几个人给出了较低的评分,其评分在初始阶段之后也不会有太大变化。
有趣的是,这也是一个假设,即评分在一段时间内不会发生很大的变化。然而,这个假设在用户数量远多于项目数量的情况下非常有效。
基于内容的过滤方法
基于内容的过滤方法基于经典的机器学习技术,如分类或回归。这类系统学习如何表示一个项目(图书)I[j] 和一个用户 U[i]。然后,在将它们组合为特征向量之前,为 I[j] 和 U[i] 创建单独的特征矩阵。然后,将特征向量输入到训练的分类或回归模型中。这样,ML 模型生成标签 L[ij],这有趣的是用户 U[i] 对项目 I[j] 给出的相应评分:
一个一般的警告是,应该创建特征,以便它们对评分(标签)有直接影响。这意味着特征应该尽可能依赖,以避免相关性。
协同过滤方法
协同过滤的想法是,当我们有很多喜欢某些物品的用户时,这些物品可以推荐给尚未看到它们的用户。假设我们有四位读者和四本书,如下面的图所示:
此外,想象所有这些用户都购买了物品 1(即使用 TensorFlow 进行预测分析)和物品 2(即使用 TensorFlow 进行深度学习)。现在,假设用户 4阅读了物品 1、2 和 3,而用户 1和用户 2购买了物品 3(即精通机器学习算法)。然而,由于用户 4尚未看到物品 4(即Python 机器学习),用户 3可以向他推荐它。
因此,基本假设是,之前推荐过物品的用户倾向于在将来也给出推荐。如果这个假设不再成立,那么就无法构建协同过滤推荐系统。这可能是协同过滤方法遭受冷启动、可扩展性和稀疏性问题的主要原因。
冷启动:协同过滤方法可能会陷入困境,无法进行推荐,尤其是在用户-物品矩阵中缺少大量用户数据时。
效用矩阵
假设我们有一组用户,他们偏好一组书籍。用户对书籍的偏好越高,评分就越高,介于 1 到 10 之间。让我们尝试使用矩阵来理解这个问题,其中行代表用户,列代表书籍:
假设评分范围从 1 到 10,10 是最高偏好级别。那么,在先前的表中,用户(第 1 行)对第一本书(第 1 列)给出了7的评分,对第二本书评分为6。还有许多空单元格,表示用户没有对那些书籍进行任何评分。
这个矩阵通常被称为用户-物品或效用矩阵,其中每一行代表一个用户,每一列代表一个物品(书籍),而单元格代表用户对该物品给出的相应评分。
在实践中,效用矩阵非常稀疏,因为大量单元格是空的。原因是物品数量众多,单个用户几乎不可能对所有物品进行评分。即使一个用户对 10%的物品进行了评分,这个矩阵的其他 90%的单元格仍然为空。这些空单元格通常用 NaN 表示,即不是一个数字,尽管在我们的效用矩阵示例中我们使用了**?**。这种稀疏性通常会创建计算复杂性。让我给你举个例子。
假设有 100 万用户(n)和 10,000 个项目(电影,m),这是10,000,000 * 10,000或10¹¹,一个非常大的数字。现在,即使一个用户评了 10 本书,这也意味着总的评分数量将是10 * 1 百万 = 10⁷。这个矩阵的稀疏度可以计算如下:
S[m ]= 空单元格数 / 总单元格数 = (10^(10 )- 10⁷)/10¹⁰ = 0.9999
这意味着 99.99%的单元格仍然为空。
基于模型的书籍推荐系统
在本节中,我们将展示如何使用 Spark MLlib 库开发一个基于模型的书籍推荐系统。书籍及其对应的评分是从以下链接下载的:www2.informatik.uni-freiburg.de/~cziegler/BX/
。这里有三个 CSV 文件:
-
BX-Users.csv
: 包含用户的统计数据,每个用户都指定了用户 ID(User-ID
)。 -
BX-Books.csv
: 包含书籍相关信息,如Book-Title
、Book-Author
、Year-Of-Publication
和Publisher
。每本书都有一个 ISBN 标识。此外,还提供了Image-URL-S
、Image-URL-M
和Image-URL-L
。 -
BX-Book-Ratings.csv
: 包含由Book-Rating
列指定的评分。评分在1
到10
的范围内(数值越高表示越高的评价),或者隐式表达为0
。
在我们进入编码部分之前,我们需要了解一些关于矩阵分解技术,如奇异值分解(SVD)的更多信息。SVD 可以将项目和用户条目转换到相同的潜在空间,这代表了用户和项目之间的交互。矩阵分解背后的原理是潜在特征表示用户如何评分项目。
矩阵分解
因此,给定用户和项目的描述,这里的任务是预测用户将如何评分那些尚未评分的项目。更正式地说,如果用户U[i]喜欢项目V[1]、V[5]和V[7],那么任务就是向用户U[i]推荐他们可能也会喜欢的项目V[j],如图所示:
一旦我们有了这样的应用,我们的想法是每次我们收到新的数据时,我们将其更新到训练数据集,然后更新通过 ALS 训练获得的模型,其中使用了协同过滤方法。为了处理用户-书籍效用矩阵,使用了一个低秩矩阵分解算法:
由于并非所有书籍都被所有用户评分,这个矩阵中的并非所有条目都是已知的。前面章节中讨论的协同过滤方法在这里作为救星出现。嗯,使用协同过滤,我们可以解决一个优化问题,通过分解用户因素(V)和书籍因素(V)来近似评分矩阵,如下所示:
这两个矩阵被选择,使得用户-书籍对(在已知评分的情况下)的错误最小化。ALS 算法首先用随机值(在我们的案例中是 1 到 10 之间)填充用户矩阵,然后优化这些值以使错误最小化。然后 ALS 将书籍矩阵保持固定,并使用以下数学方程优化用户矩阵的值:
Spark MLlib 支持基于模型的协同过滤方法。在这种方法中,用户和物品由一组小的潜在因素来描述,以预测用户-物品效用矩阵中缺失的条目。如前所述,ALS 算法可以通过迭代方式学习这些潜在因素。ALS 算法接受六个参数,即numBlocks
、rank
、iterations
、lambda
、implicitPrefs
和alpha
。numBlocks
是并行计算所需的块数。rank
参数是潜在因素的数量。iterations
参数是 ALS 收敛所需的迭代次数。lambda
参数表示正则化参数。implicitPrefs
参数表示我们希望使用其他用户的显式反馈,最后,alpha
是偏好观察的基线置信度。
探索性分析
在本小节中,我们将对评分、书籍和相关统计进行一些探索性分析。这种分析将帮助我们更好地理解数据:
val ratigsFile = "data/BX-Book-Ratings.csv"
var ratingDF = spark.read.format("com.databricks.spark.csv")
.option("delimiter", ";")
.option("header", true)
.load(ratigsFile)
以下代码片段显示了来自BX-Books.csv
文件的书籍 DataFrame:
/* Explore and query on books */
val booksFile = "data/BX-Books.csv"
var bookDF = spark.read.format("com.databricks.spark.csv")
.option("header", "true")
.option("delimiter", ";")
.load(booksFile)
bookDF = bookDF.select(bookDF.col("ISBN"),
bookDF.col("Book-Title"),
bookDF.col("Book-Author"),
bookDF.col("Year-Of-Publication"))
bookDF = bookDF.withColumnRenamed("Book-Title", "Title")
.withColumnRenamed("Book-Author", "Author")
.withColumnRenamed("Year-Of-Publication", "Year")
bookDF.show(10)
以下为输出结果:
让我们看看有多少独特的书籍:
val numDistinctBook = bookDF.select(bookDF.col("ISBN")).distinct().count()
println("Got " + numDistinctBook + " books")
以下为输出结果:
Got 271,379 books
这些信息对于后续案例将非常有价值,这样我们就可以知道在评分数据集中有多少书籍缺少评分。为了注册这两个数据集,我们可以使用以下代码:
ratingsDF.createOrReplaceTempView("ratings")
moviesDF.createOrReplaceTempView("books")
这将通过创建一个临时视图作为内存中的表来加快内存查询速度。让我们检查与评分相关的统计信息。只需使用以下代码行:
/* Explore and query ratings for books */
val numRatings = ratingDF.count()
val numUsers = ratingDF.select(ratingDF.col("UserID")).distinct().count()
val numBooks = ratingDF.select(ratingDF.col("ISBN")).distinct().count()
println("Got " + numRatings + " ratings from " + numUsers + " users on " + numBooks + " books")
您应该找到“从 105283 个用户对 340556 本书进行了 1149780 次评分”。现在,让我们获取最大和最小评分,以及评分书籍的用户数量:
// Get the max, min ratings along with the count of users who have rated a book.
val statDF = spark.sql("select books.Title, bookrates.maxRating, bookrates.minRating, bookrates.readerID "
+ "from(SELECT ratings.ISBN,max(ratings.Rating) as maxRating,"
+ "min(ratings.Rating) as minRating,count(distinct UserID) as readerID "
+ "FROM ratings group by ratings.ISBN) bookrates "
+ "join books on bookrates.ISBN=books.ISBN " + "order by bookrates.readerID desc")
statDF.show(10)
前面的代码应该生成最大和最小评分,以及评分书籍的用户数量:
现在,为了获得更深入的洞察,我们需要更多地了解用户及其评分,这可以通过找到最活跃的十个用户以及他们为书籍评分的次数来实现:
// Show the top 10 most-active users and how many times they rated a book
val mostActiveReaders = spark.sql("SELECT ratings.UserID, count(*) as CT from ratings "
+ "group by ratings.UserID order by CT desc limit 10")
mostActiveReaders.show()
前面的代码行应该显示最活跃的十个用户以及他们为书籍评分的次数:
现在,让我们查看一个特定的用户,并找到那些用户130554
评分高于5
的书籍:
// Find the movies that user 130554 rated higher than 5
val ratingBySpecificReader = spark.sql(
"SELECT ratings.UserID, ratings.ISBN,"
+ "ratings.Rating, books.Title FROM ratings JOIN books "
+ "ON books.ISBN=ratings.ISBN "
+ "WHERE ratings.UserID=130554 and ratings.Rating > 5")
ratingBySpecificReader.show(false)
如描述,上述代码行应显示用户 130554 评分超过 5 分的所有电影名称:
准备训练和测试评分数据
以下代码将评分 RDD 分割为训练数据 RDD(60%)和测试数据 RDD(40%)。第二个参数(即1357L
)是种子,通常用于可重复性目的:
val splits = ratingDF.randomSplit(Array(0.60, 0.40), 1357L)
val (trainingData, testData) = (splits(0), splits(1))
trainingData.cache
testData.cache
val numTrainingSample = trainingData.count()
val numTestSample = testData.count()
println("Training: " + numTrainingSample + " test: " + numTestSample)
你会看到训练 DataFrame 中有 689,144 个评分,测试 DataFrame 中有 345,774 个评分。ALS 算法需要训练的评分 RDD。以下代码展示了如何使用 API 构建推荐模型:
val trainRatingsRDD = trainingData.rdd.map(row => {
val userID = row.getString(0)
val ISBN = row.getInt(1)
val ratings = row.getString(2)
Rating(userID.toInt, ISBN, ratings.toDouble)
})
trainRatingsRDD
是一个包含UserID
、ISBN
以及对应评分的 RDD,这些评分来自我们在前一步准备的训练数据集。同样,我们还从测试 DataFrame 中准备了一个另一个 RDD:
val testRatingsRDD = testData.rdd.map(row => {
val userID = row.getString(0)
val ISBN = row.getInt(1)
val ratings = row.getString(2)
Rating(userID.toInt, ISBN, ratings.toDouble)
})
基于上述trainRatingsRDD
,我们通过添加最大迭代次数、块的数量、alpha、rank、lambda、seed 和隐式偏好来构建一个 ALS 用户模型。这种方法通常用于分析和预测特定用户的缺失评分:
val model : MatrixFactorizationModel = new ALS()
.setIterations(10)
.setBlocks(-1)
.setAlpha(1.0)
.setLambda(0.01)
.setRank(25)
.setSeed(1234579L)
.setImplicitPrefs(false) // We want explicit feedback
.run(trainRatingsRDD)
最后,我们迭代模型进行学习10
次。在这个设置下,我们得到了良好的预测准确度。建议读者应用超参数调整以找到这些参数的最佳值。为了评估模型的质量,我们计算均方根误差(RMSE)。以下代码计算了使用训练集开发的模型的 RMSE 值:
var rmseTest = computeRmse(model, testRatingsRDD, true)
println("Test RMSE: = " + rmseTest) //Less is better
对于上述设置,我们得到以下输出:
Test RMSE: = 1.6867585251053991
前述方法计算 RMSE 来评估模型。RMSE 越低,模型及其预测能力越好,如下所示:
//Compute the RMSE to evaluate the model. Less the RMSE better the model and it's prediction capability.
def computeRmse(model: MatrixFactorizationModel, ratingRDD: RDD[Rating], implicitPrefs: Boolean): Double = {
val predRatingRDD: RDD[Rating] = model.predict(ratingRDD.map(entry => (entry.user, entry.product)))
val predictionsAndRatings = predRatingRDD.map {entry => ((entry.user, entry.product), entry.rating)}
.join(ratingRDD
.map(entry => ((entry.user, entry.product), entry.rating)))
.values
math.sqrt(predictionsAndRatings.map(x => (x._1 - x._2) * (x._1 - x._2)).mean()) // return MSE
}
最后,让我们为特定用户做一些电影推荐。让我们获取用户276747
的前十本书的预测:
println("Recommendations: (ISBN, Rating)")
println("----------------------------------")
val recommendationsUser = model.recommendProducts(276747, 10)
recommendationsUser.map(rating => (rating.product, rating.rating)).foreach(println)
println("----------------------------------")
我们得到以下输出:
Recommendations: (ISBN => Rating)
(1051401851,15.127044702142243)
(2056910662,15.11531283195148)
(1013412890,14.75898119158678)
(603241602,14.53024153450836)
(1868529062,14.180262929540024)
(746990712,14.121654522195225)
(1630827789,13.741728003481194)
(1179316963,13.571754513473993)
(505970947,13.506755847456258)
(632523982,13.46591014905454)
----------------------------------
我们相信前述模型的表现可以进一步提高。然而,据我们所知,MLlib 基于的 ALS 算法没有可用的模型调整功能。
想要了解更多关于调整基于 ML 的 ALS 模型的信息的读者应参考spark.apache.org/docs/preview/ml-collaborative-filtering.html
添加新的用户评分和进行新的预测
我们可以创建一个新用户 ID、书的 ISBN 和上一步预测的评分的序列:
val new_user_ID = 300000 // new user ID randomly chosen
//The format of each line is (UserID, ISBN, Rating)
val new_user_ratings = Seq(
(new_user_ID, 817930596, 15.127044702142243),
(new_user_ID, 1149373895, 15.11531283195148),
(new_user_ID, 1885291767, 14.75898119158678),
(new_user_ID, 459716613, 14.53024153450836),
(new_user_ID, 3362860, 14.180262929540024),
(new_user_ID, 1178102612, 14.121654522195225),
(new_user_ID, 158895996, 13.741728003481194),
(new_user_ID, 1007741925, 13.571754513473993),
(new_user_ID, 1033268461, 13.506755847456258),
(new_user_ID, 651677816, 13.46591014905454))
val new_user_ratings_RDD = spark.sparkContext.parallelize(new_user_ratings)
val new_user_ratings_DF = spark.createDataFrame(new_user_ratings_RDD).toDF("UserID", "ISBN", "Rating")
val newRatingsRDD = new_user_ratings_DF.rdd.map(row => {
val userId = row.getInt(0)
val movieId = row.getInt(1)
val ratings = row.getDouble(2)
Rating(userId, movieId, ratings)
})
现在我们将它们添加到我们将用于训练推荐模型的原始数据中。我们使用 Spark 的union()
转换来完成这个操作:
val complete_data_with_new_ratings_RDD = trainRatingsRDD.union(newRatingsRDD)
最后,我们使用之前(在小数据集使用时)选定的所有参数来训练 ALS 模型:
val newModel : MatrixFactorizationModel = new ALS()
.setIterations(10)
.setBlocks(-1)
.setAlpha(1.0)
.setLambda(0.01)
.setRank(25)
.setSeed(123457L)
.setImplicitPrefs(false)
.run(complete_data_with_new_ratings_RDD)
每当用户添加新的评分时,我们都需要重复这个过程。理想情况下,我们将批量处理,而不是为每个用户系统中每个单独的评分进行处理。然后我们可以再次为其他用户,例如之前缺少评分的276724
,提供推荐:
// Making Predictions. Get the top 10 book predictions for user 276724
//Book recommendation for a specific user. Get the top 10 book predictions for reader 276747
println("Recommendations: (ISBN, Rating)")
println("----------------------------------")
val newPredictions = newModel.recommendProducts(276747, 10)
newPredictions.map(rating => (rating.product, rating.rating)).foreach(println)
println("----------------------------------")
以下为输出结果:
Recommendations: (ISBN, Rating)
----------------------------------
(1901261462,15.48152758068679)
(1992983531,14.306018295431224)
(1438448913,14.05457411015043)
(2022242154,13.516608439192192)
(817930596,13.487733919030019)
(1079754533,12.991618591680165)
(611897245,12.716161072778828)
(11041460,12.44511878072316)
(651596038,12.13345082904184)
(1955775932,11.7254312955358)
----------------------------------
最后,我们计算 RMSE:
var newrmseTest = computeRmse(newModel, testRDD, true)
println("Test RMSE: = " + newrmseTest) //Less is better
以下为输出结果:
Test RMSE: = 4.892434600794704
摘要
在本章中,我们学习了推荐系统的不同方法,例如基于相似度、基于内容、协同过滤和混合。此外,我们还讨论了这些方法的缺点。然后我们实现了一个端到端的书籍推荐系统,这是一个基于 Spark 的模型推荐系统。我们还看到了如何高效地处理效用矩阵,通过在 ALS 和矩阵分解之间进行交互操作。
在下一章中,我们将解释深度学习(DL)的一些基本概念,它是机器学习(ML)的一个新兴分支。我们将简要讨论一些最著名和最广泛使用的神经网络架构。然后,我们将探讨深度学习框架和库的各种特性。
然后,我们将了解如何准备编程环境,在开始使用一些开源深度学习库(如Deeplearning4j(DL4J))进行编码之前。最后,我们将使用两种神经网络架构,即多层感知器(MLP)和长短期记忆(LSTM),来解决一个现实生活中的问题。
第七章:使用 Scala 进行回归分析简介
在 第二章 “使用 Scala 进行回归分析” 到 第六章 “使用 Scala 进行推荐系统” 中,我们通过实际案例学习了线性经典 机器学习(ML)算法。在本章中,我们将解释一些 深度学习(DL)的基本概念。我们将从深度学习开始,这是机器学习的一个新兴分支。我们将简要讨论一些最著名和最广泛使用的神经网络架构和深度学习框架和库。
最后,我们将使用来自 The Cancer Genome Atlas(TCGA)的非常高维数据集的 长短期记忆(LSTM)架构进行癌症类型分类。本章将涵盖以下主题:
-
深度学习与机器学习
-
深度学习与神经网络
-
深度神经网络架构
-
深度学习框架
-
开始学习
技术要求
确保您的机器上已安装并配置了 Scala 2.11.x 和 Java 1.8.x。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide/tree/master/Chapter07
查看以下视频,了解代码的实际应用:
深度学习与机器学习
在小规模数据分析中使用的一些简单机器学习方法不再有效,因为随着大型和高维数据集的增加,机器学习方法的有效性会降低。于是出现了深度学习——一种基于一组试图在数据中模拟高级抽象的算法的机器学习分支。Ian Goodfellow 等人(《深度学习》,麻省理工学院出版社,2016 年)将深度学习定义为如下:
“深度学习是一种特殊的机器学习方法,通过学习将世界表示为嵌套的概念层次结构,每个概念都是相对于更简单的概念定义的,并且更抽象的表示是通过更不抽象的表示来计算的,从而实现了强大的功能和灵活性。”
与机器学习模型类似,深度学习模型也接受一个输入 X,并从中学习高级抽象或模式以预测输出 Y。例如,基于过去一周的股票价格,深度学习模型可以预测下一天的股票价格。在训练此类历史股票数据时,深度学习模型试图最小化预测值与实际值之间的差异。这样,深度学习模型试图推广到它之前未见过的新输入,并在测试数据上做出预测。
现在,你可能想知道,如果 ML 模型可以完成同样的任务,为什么我们还需要 DL?嗯,DL 模型在大数据量下往往表现良好,而旧的 ML 模型在某个点之后就会停止改进。DL 的核心概念灵感来源于大脑的结构和功能,被称为人工神经网络(ANNs)。作为 DL 的核心,ANNs 帮助您学习输入和输出集合之间的关联,以便做出更稳健和准确的预测。然而,DL 不仅限于 ANNs;已经有许多理论进步、软件堆栈和硬件改进,使 DL 普及。让我们看一个例子;假设我们想要开发一个预测分析模型,例如动物识别器,我们的系统必须解决两个问题:
-
要分类图像是否代表猫或狗
-
要对猫和狗的图像进行聚类
如果我们使用典型的机器学习(ML)方法来解决第一个问题,我们必须定义面部特征(耳朵、眼睛、胡须等)并编写一个方法来识别在分类特定动物时哪些特征(通常是非线性)更重要。
然而,与此同时,我们无法解决第二个问题,因为用于聚类图像的经典 ML 算法(如 k-means)无法处理非线性特征。看看以下流程图,它显示了如果我们想要分类给定的图像是否为猫时我们将遵循的流程:
DL 算法将这两个问题进一步推进,在确定哪些特征对分类或聚类最为重要后,最重要的特征将被自动提取。相比之下,当使用经典 ML 算法时,我们必须手动提供特征。
深度学习(DL)算法会采取更复杂的步骤。例如,首先,它会识别在聚类猫或狗时最相关的边缘。然后,它会尝试以分层的方式找到各种形状和边缘的组合。这一步被称为提取、转换和加载(ETL)。然后,经过几次迭代后,将进行复杂概念和特征的分层识别。然后,基于识别出的特征,DL 算法将决定哪些特征对分类动物最为重要。这一步被称为特征提取。最后,它会提取标签列并使用自动编码器(AEs)进行无监督训练,以提取要重新分配给 k-means 进行聚类的潜在特征。然后,聚类分配硬化损失(CAH 损失)和重建损失将共同优化以实现最佳的聚类分配。
然而,在实践中,深度学习算法使用的是原始图像表示,它并不像我们看待图像那样看待图像,因为它只知道每个像素的位置及其颜色。图像被划分为各种分析层。在较低层次,软件分析,例如,几个像素的网格,任务是检测某种颜色或各种细微差别。如果它发现某些东西,它会通知下一层,此时该层检查给定的颜色是否属于更大的形状,例如一条线。
这个过程一直持续到算法理解以下图中所示的内容:
虽然狗与猫是一个非常简单的分类器的例子,但现在能够执行这些类型任务的软件已经非常普遍,例如在识别面部或搜索谷歌图片的系统中发现。这类软件基于深度学习算法。相反,使用线性机器学习算法,我们无法构建这样的应用程序,因为这些算法无法处理非线性图像特征。
此外,使用机器学习方法,我们通常只处理几个超参数。然而,当引入神经网络时,事情变得过于复杂。在每个层中,都有数百万甚至数十亿个超参数需要调整——如此之多,以至于代价函数变得非凸。另一个原因是,在隐藏层中使用的激活函数是非线性的,因此代价是非凸的。
深度学习与 ANNs(人工神经网络)
受人类大脑工作方式启发的 ANNs(人工神经网络)是深度学习的核心和真正实现。今天围绕深度学习的革命如果没有 ANNs(人工神经网络)是不可能发生的。因此,为了理解深度学习,我们需要了解神经网络是如何工作的。
ANNs(人工神经网络)与人类大脑
ANNs(人工神经网络)代表了人类神经系统的一个方面,以及神经系统由许多通过轴突相互通信的神经元组成。感受器接收来自内部或外部世界的刺激。然后,它们将此信息传递给生物神经元以进行进一步处理。
除了另一个被称为轴突的长延伸之外,还有许多树突。在其末端,有微小的结构称为突触末端,用于将一个神经元连接到其他神经元的树突。生物神经元从其他神经元接收称为信号的短暂电脉冲,作为回应,它们触发自己的信号。
因此,我们可以总结说,神经元由细胞体(也称为胞体)、一个或多个用于接收来自其他神经元信号的树突,以及一个用于执行神经元产生的信号的轴突组成。当神经元向其他神经元发送信号时,它处于活跃状态。然而,当它从其他神经元接收信号时,它处于非活跃状态。在空闲状态下,神经元积累所有接收到的信号,直到达到一定的激活阈值。这一切激励研究人员测试人工神经网络(ANNs)。
人工神经网络简史
人工神经网络和深度学习最显著的进步可以用以下时间线来描述。我们已经看到,人工神经元和感知器分别在 1943 年和 1958 年为基础。然后,1969 年,明斯基(Minsky)等人将 XOR 表述为线性不可分问题,但后来在 1974 年,韦伯斯(Werbos)等人证明了用于训练感知器的反向传播算法。
然而,最显著的进步发生在 20 世纪 80 年代,当时约翰·霍普菲尔德(John Hopfield)等人于 1982 年提出了霍普菲尔德网络。然后,神经网络和深度学习的奠基人之一辛顿及其团队于 1985 年提出了玻尔兹曼机。然而,可能最显著的进步发生在 1986 年,当时辛顿等人成功训练了多层感知器(MLP),乔丹等人提出了 RNNs。同年,斯莫伦斯基(Smolensky)等人还提出了改进的玻尔兹曼机,称为受限玻尔兹曼机(RBM)。
然而,在 20 世纪 90 年代,最显著的一年是 1997 年,当时勒克伦(Lecun)等人于 1990 年提出了 LeNet,乔丹(Jordan)等人于 1997 年提出了循环神经网络(RNN)。同年,舒斯特(Schuster)等人提出了改进的 LSTM 和原始 RNN 的改进版本,称为双向 RNN。以下时间线简要概述了不同神经网络架构的历史:
尽管计算取得了显著进步,但从 1997 年到 2005 年,我们并没有经历太多的进步,直到辛顿在 2006 年再次取得突破,当时他和他的团队通过堆叠多个 RBM 提出了深度信念网络(DBN)。然后,在 2012 年,辛顿发明了 dropout,这显著提高了深度神经网络的正则化和过拟合。
之后,伊恩·古德费洛(Ian Goodfellow)等人引入了生成对抗网络(GANs),这在图像识别领域是一个重要的里程碑。2017 年,辛顿(Hinton)提出了 CapsNet 以克服常规卷积神经网络(CNNs)的局限性,这至今为止是最重要的里程碑之一。
人工神经网络是如何学习的?
基于生物神经元的理念,人工神经网络(ANNs)的术语和概念应运而生。与生物神经元相似,人工神经元由以下部分组成:
-
一个或多个汇聚来自神经元信号的输入连接
-
一个或多个输出连接,用于将信号传递到其他神经元
-
激活函数,它决定了输出信号的数值
除了神经元的当前状态外,还考虑了突触权重,这影响了网络内的连接。每个权重都有一个由 W[ij] 表示的数值,它是连接神经元 i 和神经元 j 的突触权重。现在,对于每个神经元 i,可以定义一个输入向量 x[i] = (x[1], x[2],…x[n]) 和一个权重向量 w[i] = (w[i1], w[i2],…w[in])。现在,根据神经元的定位,权重和输出函数决定了单个神经元的行为。然后,在正向传播过程中,隐藏层中的每个单元都会接收到以下信号:
尽管如此,在权重中,还有一种特殊的权重类型,称为偏置单元,b。技术上讲,偏置单元不连接到任何前一层,因此它们没有真正的活动。但仍然,偏置 b 的值允许神经网络将激活函数向左或向右移动。考虑偏置单元后,修改后的网络输出如下所示:
前面的方程表示每个隐藏单元都得到输入的总和,乘以相应的权重——这被称为 求和节点。然后,求和节点中的结果输出通过激活函数,如图所示进行压缩:
人工神经元模型的工作原理
然而,一个实际的神经网络架构是由输入、隐藏和输出层组成的,这些层由 nodes 构成网络结构,但仍遵循前面图表中所示的人工神经元模型的工作原理。输入层只接受数值数据,例如实数特征、具有像素值的图像等:
一个具有一个输入层、三个隐藏层和一个输出层的神经网络
在这里,隐藏层执行大部分计算以学习模式,网络通过使用称为损失函数的特殊数学函数来评估其预测与实际输出的准确性。它可能很复杂,也可能非常简单,可以定义为以下:
在前面的方程中,https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/ml-scl-qkstgd/img/2fe01c92-f29a-441d-b7b0-094ae8aaedc5.png 表示网络做出的预测,而 Y 代表实际或预期的输出。最后,当错误不再减少时,神经网络收敛并通过输出层进行预测。
训练神经网络
神经网络的训练过程被配置为一个迭代优化权重的过程。权重在每个时代更新。一旦开始训练,目标是通过最小化损失函数来生成预测。然后,网络的性能在测试集上评估。我们已经了解了人工神经元的基本概念。然而,仅生成一些人工信号是不够学习复杂任务的。因此,常用的监督学习算法是反向传播算法,它被广泛用于训练复杂的 ANN。
最终,训练这样的神经网络也是一个优化问题,我们通过迭代调整网络权重和偏差,使用通过梯度下降(GD)的反向传播来最小化误差。这种方法迫使网络反向遍历所有层,以更新节点间的权重和偏差,方向与损失函数相反。
然而,使用梯度下降法(GD)的过程并不能保证达到全局最小值。隐藏单元的存在和输出函数的非线性意味着误差的行为非常复杂,并且有许多局部最小值。这个反向传播步骤通常要执行成千上万次,使用许多训练批次,直到模型参数收敛到最小化成本函数的值。当验证集上的误差开始增加时,训练过程结束,因为这可能标志着过拟合阶段的开始。
使用 GD 的缺点是它收敛得太慢,这使得它无法满足处理大规模训练数据的需求。因此,提出了一个更快的 GD,称为随机梯度下降(SDG),它也是 DNN 训练中广泛使用的优化器。在 SGD 中,我们使用训练集中的单个训练样本在每个迭代中更新网络参数,这是对真实成本梯度的随机近似。
现在还有其他一些高级优化器,如 Adam、RMSProp、ADAGrad、Momentum 等。它们中的每一个都是 SGD 的直接或间接优化版本。
权重和偏差初始化
现在,这里有一个棘手的问题:我们如何初始化权重?好吧,如果我们把所有权重初始化为相同的值(例如,0 或 1),每个隐藏神经元将接收到完全相同的信号。让我们来分析一下:
-
如果所有权重都初始化为 1,那么每个单元接收到的信号等于输入的总和
-
如果所有权重都是 0,这甚至更糟糕,那么隐藏层中的每个神经元都将接收到零信号
对于网络权重初始化,广泛使用 Xavier 初始化。它与随机初始化类似,但通常效果更好,因为它可以默认根据输入和输出神经元的总数来确定初始化速率。
你可能想知道在训练常规深度神经网络(DNN)时是否可以去掉随机初始化。好吧,最近,一些研究人员一直在讨论随机正交矩阵初始化,这种初始化对于训练 DNN 来说比任何随机初始化都好。当涉及到初始化偏差时,我们可以将它们初始化为零。
但是将偏差设置为一个小常数值,例如所有偏差的 0.01,确保所有修正线性单元(ReLUs)都能传播一些梯度。然而,它既没有表现出良好的性能,也没有显示出一致的改进。因此,建议坚持使用零。
激活函数
为了使神经网络能够学习复杂的决策边界,我们对其某些层应用非线性激活函数。常用的函数包括 Tanh、ReLU、softmax 及其变体。从技术上讲,每个神经元接收一个信号,该信号是突触权重和连接为输入的神经元的激活值的加权和。为此目的最广泛使用的函数之一是所谓的 sigmoid 逻辑函数,其定义如下:
这个函数的定义域包括所有实数,而陪域是(0, 1)。这意味着从神经元(根据其激活状态的计算)获得的任何输出值都将始终介于零和一之间。以下图中表示的Sigmoid函数提供了对神经元饱和率的解释,从非激活状态(等于0)到完全饱和,这发生在预定的最大值(等于1):
Sigmoid 与 Tanh 激活函数
另一方面,双曲正切,或Tanh,是另一种激活函数形式。Tanh将一个介于**-1和1之间的实数值拉平。前面的图表显示了Tanh和Sigmoid**激活函数之间的差异。特别是,从数学上讲,tanh激活函数可以表示如下:
通常,在前馈神经网络(FFNN)的最后一层,应用 softmax 函数作为决策边界。这是一个常见的情况,尤其是在解决分类问题时。在多类分类问题中,softmax 函数用于对可能的类别进行概率分布。
对于回归问题,我们不需要使用任何激活函数,因为网络生成的是连续值——即概率。然而,我注意到现在有些人使用 IDENTITY 激活函数来解决回归问题。
总结来说,选择合适的激活函数和网络权重初始化是使网络发挥最佳性能并有助于获得良好训练的两个问题。既然我们已经了解了神经网络简短的历史,那么让我们在下一节深入探讨不同的架构,这将给我们一个关于它们用法的想法。
神经网络架构
我们可以将深度学习架构分为四组:
-
深度神经网络(DNNs)
-
卷积神经网络(CNNs)
-
循环神经网络(RNNs)
-
涌现架构(EAs)
然而,DNNs、CNNs 和 RNNs 有许多改进的变体。尽管大多数变体都是为了解决特定领域的研究问题而提出或开发的,但它们的基本工作原理仍然遵循原始的 DNN、CNN 和 RNN 架构。以下小节将简要介绍这些架构。
DNNs
DNNs 是具有复杂和更深架构的神经网络,每一层都有大量神经元,并且它们之间有许多连接。尽管 DNN 指的是一个非常深的网络,但为了简单起见,我们将 MLP、堆叠自编码器(SAE)和深度信念网络(DBNs)视为 DNN 架构。这些架构大多作为 FFNN 工作,意味着信息从输入层传播到输出层。
多个感知器堆叠在一起形成 MLP,其中层以有向图的形式连接。本质上,MLP 是最简单的 FFNN 之一,因为它有三层:输入层、隐藏层和输出层。这样,信号以单向传播,从输入层到隐藏层再到输出层,如下面的图所示:
自编码器和 RBM 是 SAE 和 DBN 的基本构建块。与以监督方式训练的 FFNN MLP 不同,SAE 和 DBN 都是在两个阶段进行训练的:无监督预训练和监督微调。在无监督预训练中,层按顺序堆叠并以分层方式使用未标记的数据进行训练。在监督微调中,堆叠一个输出分类器层,并通过使用标记数据进行重新训练来优化整个神经网络。
MLP 的一个问题是它经常过拟合数据,因此泛化能力不好。为了克服这个问题,Hinton 等人提出了 DBN。它使用一种贪婪的、层级的、预训练算法。DBN 由一个可见层和多个隐藏单元层组成。DBN 的构建块是 RBM,如下面的图所示,其中几个 RBM 一个接一个地堆叠:
最上面的两层之间有未定向、对称的连接,但底层有从前一层的有向连接。尽管 DBNs 取得了许多成功,但现在它们正被 AEs 所取代。
自编码器
AEs 也是从输入数据自动学习的特殊类型的神经网络。AE 由两个组件组成:编码器和解码器。编码器将输入压缩成潜在空间表示。然后,解码器部分试图从这个表示中重建原始输入数据:
-
编码器:使用称为 h=f(x) 的函数将输入编码或压缩成潜在空间表示。
-
解码器:使用称为 r=g(h) 的函数从潜在空间表示解码或重建输入。
因此,一个 AE 可以通过一个函数来描述 g(f(x)) = o,其中我们希望 0 尽可能接近原始输入 x。以下图显示了 AE 通常的工作方式:
AEs 在数据去噪和降维以用于数据可视化方面非常有用。AEs 比 PCA 更有效地学习数据投影,称为表示。
CNNs
CNNs 取得了很大的成就,并在计算机视觉(例如,图像识别)中得到广泛应用。在 CNN 网络中,连接方案与 MLP 或 DBN 相比有显著不同。一些卷积层以级联方式连接。每一层都由一个 ReLU 层、一个池化层和额外的卷积层(+ReLU)以及另一个池化层支持,然后是一个全连接层和一个 softmax 层。以下图是用于面部识别的 CNN 架构示意图,它以面部图像为输入,预测情绪,如愤怒、厌恶、恐惧、快乐、悲伤等。
用于面部识别的 CNN 的示意图架构
重要的是,DNNs 对像素的排列没有先验知识,因为它们不知道附近的像素是接近的。CNNs 通过在图像的小区域使用特征图来利用这种先验知识,而高层将低级特征组合成更高级的特征。
这与大多数自然图像都很好,使 CNNs 在 DNNs 中取得了决定性的领先优势。每个卷积层的输出是一组对象,称为特征图,由单个核滤波器生成。然后,特征图可以用来定义下一层的新输入。CNN 网络中的每个神经元都产生一个输出,随后是一个激活阈值,该阈值与输入成正比,没有界限。
RNNs
在 RNNs 中,单元之间的连接形成一个有向循环。RNN 架构最初由 Hochreiter 和 Schmidhuber 在 1997 年构思。RNN 架构具有标准的 MLP,并增加了循环,以便它们可以利用 MLP 强大的非线性映射能力。它们也具有某种形式的记忆。以下图显示了一个非常基本的 RNN,它有一个输入层、两个循环层和一个输出层:
然而,这个基本的 RNN 受梯度消失和爆炸问题的影响,无法建模长期依赖。这些架构包括 LSTM、门控循环单元(GRUs)、双向-LSTM 和其他变体。因此,LSTM 和 GRU 可以克服常规 RNN 的缺点:梯度消失/爆炸问题和长期短期依赖。
生成对抗网络(GANs)
Ian Goodfellow 等人在一篇名为《生成对抗网络》(见更多内容https://arxiv.org/abs/1406.2661v1)的论文中介绍了 GANs。以下图表简要展示了 GAN 的工作原理:
GAN 的工作原理
GANs 是由两个网络组成的深度神经网络架构,一个生成器和一个判别器,它们相互对抗(因此得名,对抗):
-
生成器试图从一个特定的概率分布中生成数据样本,并且与实际对象非常相似
-
判别器将判断其输入是否来自原始训练集或生成器部分
许多深度学习实践者认为,GANs 是其中最重要的进步之一,因为 GANs 可以用来模拟任何数据分布,并且基于数据分布,GANs 可以学会创建机器人艺术家图像、超分辨率图像、文本到图像合成、音乐、语音等。
例如,由于对抗训练的概念,Facebook 的人工智能研究总监 Yann LeCun 将 GAN 称为过去 10 年机器学习中最有趣的想法。
胶囊网络
在 CNN 中,每一层通过缓慢的接受场或最大池化操作以更细粒度的水平理解图像。如果图像有旋转、倾斜或非常不同的形状或方向,CNN 无法提取此类空间信息,在图像处理任务中表现出非常差的性能。即使 CNN 中的池化操作也无法在很大程度上帮助对抗这种位置不变性。CNN 中的这个问题促使我们通过 Geoffrey Hinton 等人撰写的题为《胶囊之间的动态路由》(见更多内容https://arxiv.org/abs/1710.09829)的论文,最近在 CapsNet 方面取得了进展:
“胶囊是一组神经元,其活动向量表示特定类型实体(如对象或对象部分)的实例化参数。”
与我们不断添加层的常规 DNN 不同,在 CapsNets 中,想法是在单个层内添加更多层。这样,CapsNet 是一个嵌套的神经网络层集。在 CapsNet 中,胶囊的向量输入和输出通过路由算法计算,该算法迭代地传输信息和处理自洽场(SCF)过程,这在物理学中应用:
上述图表显示了简单三层 CapsNet 的示意图。DigiCaps层中每个胶囊的活动向量长度表示每个类实例的存在,这被用来计算损失。
现在我们已经了解了神经网络的工作原理和不同的神经网络架构,动手实现一些内容将会很棒。然而,在那之前,让我们看看一些流行的深度学习库和框架,它们提供了这些网络架构的实现。
深度学习框架
有几个流行的深度学习框架。每个框架都有其优缺点。其中一些是基于桌面的,而另一些是基于云的平台,您可以在这些平台上部署/运行您的深度学习应用。然而,大多数开源许可证下发布的库在人们使用图形处理器时都有帮助,这最终有助于加快学习过程。
这些框架和库包括 TensorFlow、PyTorch、Keras、Deeplearning4j、H2O 以及微软认知工具包(CNTK)。甚至就在几年前,其他实现如 Theano、Caffee 和 Neon 也被广泛使用。然而,这些现在都已过时。由于我们将专注于 Scala 的学习,基于 JVM 的深度学习库如 Deeplearning4j 可以是一个合理的选择。Deeplearning4j(DL4J)是第一个为 Java 和 Scala 构建的商业级、开源、分布式深度学习库。这也提供了对 Hadoop 和 Spark 的集成支持。DL4J 是为在分布式 GPU 和 CPU 上用于商业环境而构建的。DL4J 旨在成为前沿和即插即用,具有比配置更多的惯例,这允许非研究人员快速原型设计。以下图表显示了去年的 Google 趋势,说明了 TensorFlow 有多受欢迎:
不同深度学习框架的趋势——TensorFlow 和 Keras 占据主导地位;然而,Theano 正在失去其受欢迎程度;另一方面,Deeplearning4j 在 JVM 上崭露头角
它的众多库可以与 DL4J 集成,无论您是在 Java 还是 Scala 中开发机器学习应用,都将使您的 JVM 体验更加容易。类似于 JVM 的 NumPy,ND4J 提供了线性代数的基本操作(矩阵创建、加法和乘法)。然而,ND4S 是一个用于线性代数和矩阵操作的科学研究库。它还为基于 JVM 的语言提供了多维数组。
除了上述库之外,还有一些最近在云上进行的深度学习倡议。想法是将深度学习能力带给拥有数以亿计数据点和高维数据的大数据。例如,亚马逊网络服务(AWS)、微软 Azure、谷歌云平台以及NVIDIA GPU 云(NGC)都提供了其公共云本地的机器和深度学习服务。
2017 年 10 月,AWS 为Amazon Elastic Compute Cloud(Amazon EC2)P3 实例发布了深度学习 AMI(DLAMIs)。这些 AMI 预先安装了深度学习框架,如 TensorFlow、Gluon 和 Apache MXNet,这些框架针对 Amazon EC2 P3 实例中的 NVIDIA Volta V100 GPU 进行了优化。该深度学习服务目前提供三种类型的 AMI:Conda AMI、Base AMI 和带源代码的 AMI。
CNTK 是 Azure 的开源深度学习服务。类似于 AWS 的提供,它专注于可以帮助开发者构建和部署深度学习应用程序的工具。工具包安装在 Python 2.7 的根环境中。Azure 还提供了一个模型库,其中包括代码示例等资源,以帮助企业开始使用该服务。
另一方面,NGC 通过 GPU 加速容器(见www.nvidia.com/en-us/data-center/gpu-cloud-computing/
)为 AI 科学家和研究人员提供支持。NGC 具有容器化的深度学习框架,如 TensorFlow、PyTorch、MXNet 等,这些框架由 NVIDIA 经过调整、测试和认证,可在参与云服务提供商的最新 NVIDIA GPU 上运行。尽管如此,通过它们各自的市场,也有第三方服务可用。
现在你已经了解了神经网络架构的工作原理,并且对可用于实现深度学习解决方案的 DL 框架有了简要的了解,让我们继续到下一部分进行一些动手学习。
开始学习
大规模癌症基因组数据通常以多平台和异构形式出现。这些数据集在生物信息学方法和计算算法方面提出了巨大的挑战。许多研究人员提出了利用这些数据来克服几个挑战的方法,使用经典机器学习算法作为主要主题或癌症诊断和预后支持的元素。
数据集描述
基因组数据涵盖了与生物体 DNA 相关的所有数据。尽管在本论文中我们也会使用其他类型的数据,例如转录组数据(RNA 和 miRNA),为了方便起见,所有数据都将被称为基因组数据。由于人类基因组计划(HGP)(1984-2000)在测序人类 DNA 全序列方面的成功,近年来人类遗传学研究取得了巨大的突破。现在,让我们看看一个可以用于我们目的的真实数据集是什么样的。我们将使用基因表达癌症 RNA-Seq数据集,该数据集可以从 UCI ML 存储库下载(更多信息请见archive.ics.uci.edu/ml/datasets/gene+expression+cancer+RNA-Seq
)。
这个数据集是以下论文中报告的另一个数据集的随机子集:Weinstein, John N.,et al. The cancer genome atlas pan-cancer analysis project. Nature Genetics 45.10 (2013): 1113-1120。该项目的名称是全癌症分析项目。它汇集了来自数千名患者的数据,这些患者的主要肿瘤发生在身体的不同部位。它涵盖了 12 种肿瘤类型,包括以下内容:
-
多形性胶质母细胞瘤 (GBM)
-
淋巴细胞性急性髓系白血病 (AML)
-
头颈鳞状细胞癌 (HNSC)
-
肺腺癌 (LUAD)
-
肺鳞状细胞癌 (LUSC)
-
乳腺癌 (BRCA)
-
肾脏肾细胞癌 (KIRC)
-
卵巢癌 (OV)
-
膀胱癌 (BLCA)
-
结肠腺癌 (COAD)
-
子宫颈和子宫内膜癌 (UCEC)
-
直肠腺癌 (READ)
这组数据是 RNA-Seq (HiSeq) PANCAN 数据集的一部分。它是来自不同类型肿瘤(BRCA、KIRC、COAD、LUAD 和 PRAD)患者的基因表达的随机提取。
这个数据集是从 801 名癌症患者中随机收集的,每位患者有 20,531 个属性。样本(instances
)按行存储。每个样本的变量(attributes
)是 Illumina HiSeq 平台测量的 RNA-Seq 基因表达水平。每个属性提供了一个虚拟名称(gene_XX
)。属性按与原始提交一致的顺序排列。例如,gene_1
在sample_0
上是显著且差异表达的,其值为2.01720929003
。
当你下载数据集时,你会看到有两个 CSV 文件:
-
data.csv
:包含每个样本的基因表达数据 -
labels.csv
:与每个样本关联的标签
让我们看看处理过的数据集。请注意,考虑到以下截图中的高维性,我们只会查看一些选定的特征,其中第一列代表样本 ID(即匿名患者 ID)。其余的列表示患者肿瘤样本中特定基因表达的发生情况:
现在,请查看下表中标签。在这里,id
列包含样本 ID,而Class
列表示癌症标签:
现在,你可以想象我为什么选择这个数据集了。尽管我们不会有太多的样本,但数据集仍然是高度多维的。此外,这种高度多维的数据集非常适合应用深度学习算法。因此,如果给出了特征和标签,我们能否根据特征和真实情况对这些样本进行分类?为什么不呢?我们将尝试使用 DL4J 库来解决这个问题。首先,我们必须配置我们的编程环境,以便我们可以编写我们的代码。
准备编程环境
在本节中,我们将讨论在开始编码之前如何配置 DL4J、ND4s、Spark 和 ND4J。以下是在使用 DL4J 时你必须考虑的先决条件:
-
Java 1.8+ (仅 64 位)
-
Apache Maven 用于自动构建和依赖关系管理器
-
IntelliJ IDEA 或 Eclipse IDE
-
Git 用于版本控制和 CI/CD
以下库可以与 DJ4J 集成,以增强你在开发机器学习应用程序时的 JVM 体验:
-
DL4J:核心神经网络框架,包含许多深度学习架构和底层功能。
-
ND4J:可以被认为是 JVM 的 NumPy。它包含一些线性代数的基本操作。例如矩阵创建、加法和乘法。
-
DataVec:这个库在执行特征工程的同时允许 ETL 操作。
-
JavaCPP:这个库充当 Java 和原生 C++之间的桥梁。
-
Arbiter:这个库为深度学习算法提供基本的评估功能。
-
RL4J:JVM 的深度强化学习。
-
ND4S:这是一个科学计算库,它也支持基于 JVM 的语言的 n 维数组。
如果你正在你喜欢的 IDE 上使用 Maven,让我们定义项目属性,在pom.xml
文件中提及这些版本:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jdk.version>1.8</jdk.version>
<spark.version>2.2.0</spark.version>
<nd4j.version>1.0.0-alpha</nd4j.version>
<dl4j.version>1.0.0-alpha</dl4j.version>
<datavec.version>1.0.0-alpha</datavec.version>
<arbiter.version>1.0.0-alpha</arbiter.version>
<logback.version>1.2.3</logback.version>
</properties>
然后,使用pom.xml
文件中显示的 DL4J、ND4S 和 ND4J 所需的全部依赖项。顺便说一句,DL4J 自带 Spark 2.1.0。另外,如果你的机器上没有配置本地系统 BLAS,ND4J 的性能将会降低。一旦你执行任何用 Scala 编写的简单代码,你将体验到以下警告:
****************************************************************
WARNING: COULD NOT LOAD NATIVE SYSTEM BLAS
ND4J performance WILL be reduced
****************************************************************
然而,安装和配置 BLAS,如 OpenBLAS 或 IntelMKL,并不那么困难;你可以投入一些时间并完成它。有关更多详细信息,请参阅以下 URL:
干得好!我们的编程环境已准备好进行简单的深度学习应用开发。现在,是时候用一些示例代码来动手实践了。
预处理
由于我们没有未标记的数据,我想随机选择一些样本进行测试。还有一点需要注意,特征和标签在两个单独的文件中。因此,我们可以执行必要的预处理,然后将它们合并在一起,这样我们的预处理数据将包含特征和标签。
然后,将使用剩余的数据进行训练。最后,我们将训练和测试集保存到单独的 CSV 文件中,以便以后使用。按照以下步骤开始:
- 首先,让我们加载样本并查看统计信息。在这里,我们使用 Spark 的
read()
方法,但也要指定必要的选项和格式:
val data = spark.read.option("maxColumns", 25000).format("com.databricks.spark.csv")
.option("header", "true") // Use first line of all files as header
.option("inferSchema", "true") // Automatically infer data types
.load("TCGA-PANCAN/TCGA-PANCAN-HiSeq-801x20531/data.csv");// set this path accordingly
- 然后,我们将看到一些相关的统计信息,例如特征数量和样本数量:
val numFeatures = data.columns.length
val numSamples = data.count()
println("Number of features: " + numFeatures)
println("Number of samples: " + numSamples)
因此,有来自801
个不同患者的801
个样本,由于它有20532
个特征,数据集的维度非常高:
Number of features: 20532
Number of samples: 801
- 此外,由于
id
列仅代表患者的匿名 ID,因此我们可以简单地删除它:
val numericDF = data.drop("id") // now 20531 features left
- 然后,我们使用 Spark 的
read()
方法加载标签,并指定必要的选项和格式:
val labels = spark.read.format("com.databricks.spark.csv")
.option("header", "true")
.option("inferSchema", "true")
.load("TCGA-PANCAN/TCGA-PANCAN-HiSeq-801x20531/labels.csv")
labels.show(10)
我们已经看到了标签 DataFrame 的样子。我们将跳过id
列。然而,Class
列是分类的。正如我们之前提到的,DL4J 不支持需要预测的分类标签。因此,我们必须将其转换为数值格式(更具体地说,是一个整数);为此,我将使用 Spark 的StringIndexer()
:
- 首先,我们创建一个
StringIndexer()
,将索引操作应用于Class
列,并将其重命名为label
。此外,我们跳过
空值条目:
val indexer = new StringIndexer().setInputCol("Class")
.setOutputCol("label")
.setHandleInvalid("skip"); // skip null/invalid values
- 然后,我们通过调用
fit()
和transform()
操作执行索引操作,如下所示:
val indexedDF = indexer.fit(labels).transform(labels)
.select(col("label")
.cast(DataTypes.IntegerType)); // casting data types to integer
- 现在,让我们看一下索引后的 DataFrame:
indexedDF.show()
上一行代码应该将label
列转换为数值格式:
- 太棒了!现在,所有列(包括特征和标签)都是数值的。因此,我们可以将特征和标签合并到一个 DataFrame 中。为此,我们可以使用 Spark 的
join()
方法,如下所示:
val combinedDF = numericDF.join(indexedDF)
- 现在,我们可以通过随机拆分
combinedDF
来生成训练集和测试集,如下所示:
val splits = combinedDF.randomSplit(Array(0.7, 0.3), 12345L) //70% for training, 30% for testing
val trainingDF = splits(0)
val testDF = splits(1)
- 现在,让我们看看每个集合中的样本
count
:
println(trainingDF.count())// number of samples in training set
println(testDF.count())// number of samples in test set
- 训练集中应该有 561 个样本,测试集中应该有 240 个样本。最后,我们将它们保存在单独的 CSV 文件中,以供以后使用:
trainingDF.coalesce(1).write
.format("com.databricks.spark.csv")
.option("header", "false")
.option("delimiter", ",")
.save("output/TCGA_train.csv")
testDF.coalesce(1).write
.format("com.databricks.spark.csv")
.option("header", "false")
.option("delimiter", ",")
.save("output/TCGA_test.csv")
- 现在我们有了训练集和测试集,我们可以用训练集训练网络,并用测试集评估模型。
Spark 将在项目根目录下的output
文件夹下生成 CSV 文件。然而,你可能会看到一个非常不同的名称。我建议你将它们分别重命名为TCGA_train.csv
和TCGA_test.csv
,以区分训练集和测试集。
考虑到高维性,我更愿意尝试一个更好的网络,比如 LSTM,它是 RNN 的改进版本。在这个时候,了解一些关于 LSTM 的上下文信息将有助于把握这个想法,这些信息将在以下部分提供。
数据集准备
在上一节中,我们准备了训练集和测试集。然而,我们需要做一些额外的工作来使它们能够被 DL4J 使用。更具体地说,DL4J 期望训练数据是数值格式,并且最后一列是label
列。其余的数据应该是特征。
现在,我们将尝试像那样准备我们的训练集和测试集。首先,我们将找到我们保存训练集和测试集的文件:
// Show data paths
val trainPath = "TCGA-PANCAN/TCGA_train.csv"
val testPath = "TCGA-PANCAN/TCGA_test.csv"
然后,我们将定义所需的参数,例如特征数量、类别数量和批量大小。在这里,我使用128
作为batchSize
,但你可以相应地调整它:
// Preparing training and test set.
val labelIndex = 20531
val numClasses = 5
val batchSize = 128
这个数据集用于训练:
val trainingDataIt: DataSetIterator = readCSVDataset(trainPath, batchSize, labelIndex, numClasses)
这是我们想要分类的数据:
val testDataIt: DataSetIterator = readCSVDataset(testPath, batchSize, labelIndex, numClasses)
如前两行代码所示,readCSVDataset()
基本上是一个包装器,它读取 CSV 格式的数据,然后RecordReaderDataSetIterator()
方法将记录读取器转换为数据集迭代器。
LSTM 网络构建
使用 DL4J 创建神经网络从MultiLayerConfiguration
开始,它组织网络层及其超参数。然后,使用NeuralNetConfiguration.Builder()
接口添加创建的层。如图所示,LSTM 网络由五个层组成:一个输入层,后面跟着三个 LSTM 层。最后一层是 RNN 层,在这种情况下也是输出层:
一个用于癌症类型预测的 LSTM 网络,它接受 20,531 个特征和固定的偏置(即 1),并生成多类输出
要创建 LSTM 层,DL4J 提供了 LSTM 类的实现。然而,在我们开始为网络创建层之前,让我们定义一些超参数,例如输入/隐藏/输出节点的数量(神经元):
// Network hyperparameters
val numInputs = labelIndex
val numOutputs = numClasses
val numHiddenNodes = 5000
然后,我们通过指定层来创建网络。第一、第二和第三层是 LSTM 层。最后一层是 RNN 层。对于所有的隐藏 LSTM 层,我们指定输入和输出单元的数量,并使用 ReLU 作为激活函数。然而,由于这是一个多类分类问题,我们在输出层使用SOFTMAX
作为激活
函数,MCXNET
作为损失函数:
//First LSTM layer
val layer_0 = new LSTM.Builder()
.nIn(numInputs)
.nOut(numHiddenNodes)
.activation(Activation.RELU)
.build()
//Second LSTM layer
val layer_1 = new LSTM.Builder()
.nIn(numHiddenNodes)
.nOut(numHiddenNodes)
.activation(Activation.RELU)
.build()
//Third LSTM layer
val layer_2 = new LSTM.Builder()
.nIn(numHiddenNodes)
.nOut(numHiddenNodes)
.activation(Activation.RELU)
.build()
//RNN output layer
val layer_3 = new RnnOutputLayer.Builder()
.activation(Activation.SOFTMAX)
.lossFunction(LossFunction.MCXENT)
.nIn(numHiddenNodes)
.nOut(numOutputs)
.build()
在前面的代码块中,softmax 激活
函数给出了类别的概率分布,MCXENT
是多类分类设置中的交叉熵损失函数。
然后,使用 DL4J,我们通过NeuralNetConfiguration.Builder()
接口添加我们之前创建的层。首先,我们添加所有的 LSTM 层,然后是最终的 RNN 输出层:
//Create network configuration and conduct network training
val LSTMconf: MultiLayerConfiguration = new NeuralNetConfiguration.Builder()
.seed(seed) //Random number generator seed for improved repeatability. Optional.
.optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
.weightInit(WeightInit.XAVIER)
.updater(new Adam(5e-3))
.l2(1e-5)
.list()
.layer(0, layer_0)
.layer(1, layer_1)
.layer(2, layer_2)
.layer(3, layer_3)
.pretrain(false).backprop(true).build()
在前面的代码块中,我们使用了 SGD 作为优化器,它试图优化MCXNET
损失函数。然后,我们使用XAVIER
初始化网络权重,Adam
作为网络更新器与 SGD 一起工作。最后,我们使用前面的多层配置初始化一个多层网络:
val model: MultiLayerNetwork = new MultiLayerNetwork(LSTMconf)
model.init()
此外,我们还可以检查整个网络中各层的超参数数量。通常,这种类型的网络有很多超参数。让我们打印网络中的参数数量(以及每个层的参数数量):
//print the score with every 1 iteration
model.setListeners(new ScoreIterationListener(1))
//Print the number of parameters in the network (and for each layer)
val layers = model.getLayers()
var totalNumParams = 0
var i = 0
for (i <- 0 to layers.length-1) {
val nParams = layers(i).numParams()
println("Number of parameters in layer " + i + ": " + nParams)
totalNumParams = totalNumParams + nParams
}
println("Total number of network parameters: " + totalNumParams)
前面代码的输出如下:
Number of parameters in layer 0: 510640000
Number of parameters in layer 1: 200020000
Number of parameters in layer 2: 200020000
Number of parameters in layer 3: 25005
Total number of network parameters: 910705005
如我之前所述,我们的网络有 9.1 亿个参数,这是一个巨大的数字。这也给调整超参数带来了巨大的挑战。
网络训练
首先,我们将使用前面的MultiLayerConfiguration
创建一个MultiLayerNetwork
。然后,我们将初始化网络并在训练集上开始训练:
var j = 0
println("Train model....")
for (j <- 0 to numEpochs-1) {
model.fit(trainingDataIt)
最后,我们还指定我们不需要进行任何预训练(这在 DBN 或堆叠自编码器中通常是必需的)。
评估模型
一旦训练完成,接下来的任务是评估模型,我们将在测试集上完成这项任务。对于评估,我们将使用Evaluation()
方法。此方法创建一个具有五个可能类别的评估对象。
首先,让我们对每个测试样本进行迭代评估,并从训练模型中获得网络的预测。最后,eval()
方法将预测与真实类别进行核对:
println("Evaluate model....")
val eval: Evaluation = new Evaluation(5) //create an evaluation object with 5 possible classes
while (testDataIt.hasNext()) {
val next:DataSet = testDataIt.next()
val output:INDArray = model.output(next.getFeatureMatrix()) //get the networks prediction
eval.eval(next.getLabels(), output) //check the prediction against the true class
}
println(eval.stats())
println("****************Example finished********************")
}
以下是输出:
==========================Scores========================================
# of classes: 5
Accuracy: 0.9900
Precision: 0.9952
Recall: 0.9824
F1 Score: 0.9886
Precision, recall & F1: macro-averaged (equally weighted avg. of 5 classes)
========================================================================
****************Example finished******************
哇!难以置信!我们的 LSTM 网络已经准确地分类了样本。最后,让我们看看分类器是如何预测每个类别的:
Actual label 0 predicted by the model as 0: 82 times
Actual label 1 predicted by the model as 0: 1 times
Actual label 1 predicted by the model as 1: 17 times
Actual label 2 predicted by the model as 2: 35 times
Actual label 3 predicted by the model as 0: 1 times
Actual label 3 predicted by the model as 3: 30 times
使用 LSTM 进行癌症类型预测的预测准确率异常高,不是吗?我们的模型欠拟合了吗?我们的模型过拟合了吗?
使用 Deeplearning4j UI 观察训练
由于我们的准确率异常高,我们可以观察训练过程。是的,有方法可以找出它是否过度拟合,因为我们可以在 DL4J UI 上观察到训练、验证和测试损失。然而,这里我不会讨论细节。请查看deeplearning4j.org/docs/latest/deeplearning4j-nn-visualization
获取更多关于如何做到这一点的信息。
摘要
在本章中,我们看到了如何根据从 TCGA 中精心挑选的非常高维度的基因表达数据集对癌症患者进行基于肿瘤类型的分类。我们的 LSTM 架构成功实现了 99%的准确率,这是非常出色的。尽管如此,我们讨论了 DL4J 的许多方面,这些将在未来的章节中有所帮助。最后,我们看到了与该项目、LSTM 网络和 DL4J 超参数/网络调整相关的常见问题的答案。
这,或多或少,标志着我们使用 Scala 和不同开源框架开发 ML 项目的短暂旅程的结束。在整个章节中,我试图为您提供几个如何高效使用这些优秀技术来开发 ML 项目的示例。在撰写这本书的过程中,我不得不在心中牢记许多限制条件;例如,页数限制、API 可用性,当然还有我的专业知识。
然而,总的来说,我试图通过避免在理论上的不必要细节来使这本书变得简单,因为您可以在许多书籍、博客和网站上找到这些内容。我还会在 GitHub 仓库github.com/PacktPublishing/Machine-Learning-with-Scala-Quick-Start-Guide
上更新这本书的代码。您可以随意打开一个新问题或任何 pull request 来改进代码,并保持关注。
尽管如此,我会将每个章节的解决方案上传为 Zeppelin 笔记本,这样您就可以交互式地运行代码。顺便说一句,Zeppelin 是一个基于网页的笔记本,它通过 SQL 和 Scala 实现数据驱动的交互式数据分析以及协作文档。一旦您在您首选的平台配置了 Zeppelin,您就可以从 GitHub 仓库下载笔记本,将它们导入 Zeppelin,然后开始使用。更多详情,您可以查看 zeppelin.apache.org/
。
更多推荐
所有评论(0)