原文:
annas-archive.org/md5/4e1b7010caf1fbe3188c9c515fe244d4
译者:飞龙
第六章:开发基于模型的电影推荐引擎
Netflix 是一家由 Reed Hastings 和 Marc Randolph 于 1997 年 8 月 29 日在加利福尼亚州 Scotts Valley 创立的美国娱乐公司。它专注于并提供流媒体、视频点播在线服务以及 DVD 邮寄服务。2013 年,Netflix 扩展到电影和电视制作及在线分发。Netflix 使用基于模型的协同过滤方法,为其订阅用户提供实时电影推荐。
本章中,我们将看到两个端到端的项目,并为电影相似度测量开发基于物品的协同过滤模型,以及使用 Spark 的基于模型的电影推荐引擎,后者能够为新用户推荐电影。我们将看到如何在 ALS 和矩阵分解(MF)之间进行交互操作,以实现这两个可扩展的电影推荐引擎。我们将使用电影镜头数据集进行该项目。最后,我们将看到如何将最佳模型部署到生产环境中。
简而言之,我们将通过两个端到端的项目学习以下内容:
-
推荐系统—如何以及为什么?
-
基于物品的协同过滤用于电影相似度测量
-
基于模型的电影推荐与 Spark
-
模型部署
推荐系统
推荐系统(即推荐引擎或RE)是信息过滤系统的一个子类,它帮助根据用户对某个项目的评分预测其评分或偏好。近年来,推荐系统变得越来越流行。简而言之,推荐系统试图根据其他用户的历史记录预测某个用户可能感兴趣的潜在项目。
因此,它们被广泛应用于电影、音乐、新闻、书籍、研究文章、搜索查询、社交标签、产品、合作、喜剧、餐厅、时尚、金融服务、寿险和在线约会等多个领域。开发推荐引擎的方式有很多,通常会生成一系列推荐结果,例如基于协同过滤和基于内容的过滤,或者基于个性化的方式。
协同过滤方法
使用协同过滤方法,可以基于用户过去的行为来构建推荐引擎,其中会根据用户购买的物品给出数值评分。有时,它还可以基于其他用户做出的相似决策来开发,这些用户也购买了相同的物品。从下图中,你可以对不同的推荐系统有一些了解:
图 1:不同推荐系统的比较视图
基于协同过滤的方法通常会面临三个问题——冷启动、可扩展性和稀疏性:
-
冷启动:当需要大量关于用户的数据来做出更准确的推荐时,有时会陷入困境。
-
可扩展性:通常需要大量的计算能力来从拥有数百万用户和产品的数据集中计算推荐。
-
稀疏性:当大量商品在主要电商网站上销售时,通常会发生这种情况,尤其是在众包数据集的情况下。在这种情况下,活跃用户可能只会对少数几件商品进行评分——也就是说,即使是最受欢迎的商品也会有很少的评分。因此,用户与商品的矩阵变得非常稀疏。换句话说,不能处理一个大规模的稀疏矩阵。
为了克服这些问题,一种特定类型的协同过滤算法使用 MF,一种低秩矩阵近似技术。我们将在本章后面看到一个例子。
基于内容的过滤方法
使用基于内容的过滤方法,利用项目的离散特征系列推荐具有相似属性的其他项目。有时它基于对项目的描述和用户偏好的个人资料。这些方法尝试推荐与用户过去喜欢或当前正在使用的项目相似的项目。
基于内容的过滤的一个关键问题是,系统是否能够从用户对某个内容源的行为中学习用户偏好,并将其应用于其他内容类型。当这种类型的推荐引擎被部署时,它可以用来预测用户感兴趣的项目或项目的评分。
混合推荐系统
如你所见,使用协同过滤和基于内容的过滤各有优缺点。因此,为了克服这两种方法的局限性,近年来的趋势表明,混合方法通过结合协同过滤和基于内容的过滤,可能更加有效和准确。有时,为了使其更强大,会使用如 MF 和奇异值分解(SVD)等因式分解方法。混合方法可以通过几种方式实现:
-
最初,基于内容的预测和基于协同的预测是分别计算的,之后我们将它们结合起来,即将这两者统一为一个模型。在这种方法中,FM 和 SVD 被广泛使用。
-
向基于协同的方式添加基于内容的能力,或反之。再次,FM 和 SVD 被用来进行更好的预测。
Netflix 是一个很好的例子,它使用这种混合方法向订阅者推荐内容。该网站通过两种方式进行推荐:
-
协同过滤:通过比较相似用户的观看和搜索习惯
-
基于内容的过滤:通过提供与用户高度评分的电影共享特征的电影
基于模型的协同过滤
如图 1所示,我确实计划使用因式分解机来实施一个系统化的项目,但最终由于时间限制未能实现。因此,决定开发一个基于协同过滤的方法的电影推荐系统。基于协同过滤的方法可以分为:
-
基于记忆的算法,即基于用户的算法
-
基于模型的协同过滤算法,即核映射
在基于模型的协同过滤技术中,用户和产品由一组较小的因素描述,这些因素也被称为潜在因素(LFs)。然后使用这些潜在因素来预测缺失的条目。交替最小二乘法(ALS)算法用于学习这些潜在因素。从计算的角度来看,基于模型的协同过滤通常用于许多公司,如 Netflix,用于实时电影推荐。
效用矩阵
在一个混合推荐系统中,有两类实体:用户和物品(例如电影、产品等)。现在,作为一个用户,你可能会对某些物品有偏好。因此,这些偏好必须从关于物品、用户或评分的数据中提取出来。通常这些数据表示为效用矩阵,例如用户-物品对。这种值可以表示已知的该用户对某个物品的偏好程度。矩阵中的条目,即一个表格,可以来自有序集合。例如,可以使用整数 1-5 来表示用户为物品评分的星级。
我们曾指出,通常用户可能没有对物品进行评分;也就是说,大多数条目是未知的。这也意味着矩阵可能是稀疏的。一个未知的评分意味着我们没有关于用户对物品偏好的明确反馈。表 1展示了一个效用矩阵示例。该矩阵表示用户对电影的评分,评分范围为 1 到 5,5 为最高评分。空白条目表示没有用户为这些电影提供评分。
在这里,HP1、HP2和HP3分别是电影哈利·波特 I、II和III的缩写;TW代表暮光之城;SW1、SW2和SW3分别代表星球大战系列的第1、2和3部。用户由大写字母A、B、C和D表示:
图 2:效用矩阵(用户与电影矩阵)
用户-电影对中有许多空白条目。这意味着用户没有为那些电影评分。在实际场景中,矩阵可能更加稀疏,典型的用户仅为所有可用电影中的一小部分评分。现在,利用这个矩阵,目标是预测效用矩阵中的空白部分。让我们来看一个例子。假设我们想知道用户A是否喜欢SW2。然而,由于矩阵中在表 1中几乎没有相关证据,确定这一点是非常困难的。
因此,在实际应用中,我们可能会开发一个电影推荐引擎来考虑电影的一些不常见属性,如制片人名称、导演名称、主演,甚至是它们名称的相似性。通过这种方式,我们可以计算电影SW1和SW2的相似性。这种相似性会引导我们得出结论:由于 A 不喜欢SW1,他们也不太可能喜欢SW2。
然而,这对于更大的数据集可能不适用。因此,随着数据量的增大,我们可能会观察到那些同时评分过SW1和SW2的用户倾向于给予它们相似的评分。最终,我们可以得出结论:A也会像评分SW1那样对SW2给出低分。
基于 Spark 的电影推荐系统
Spark MLlib 中的实现支持基于模型的协同过滤。在基于模型的协同过滤技术中,用户和产品通过一组小的因子(也称为 LF)来描述。在本节中,我们将看到两个完整的示例,展示它如何为新用户推荐电影。
基于物品的协同过滤用于电影相似度计算
首先,我们从文件中读取评分数据。对于这个项目,我们可以使用来自www.grouplens.org/node/73
的 MovieLens 100k 评分数据集。训练集评分数据保存在一个名为ua.base
的文件中,而电影项数据保存在u.item
中。另一方面,ua.test
包含了用于评估我们模型的测试集。由于我们将使用这个数据集,因此我们应该感谢明尼苏达大学的 GroupLens 研究项目团队,他们编写了以下文字:
F. Maxwell Harper 和 Joseph A. Konstan. 2015. The MovieLens 数据集: 历史与背景。ACM 交互式智能系统交易(TiiS)5, 4, 第 19 号文章(2015 年 12 月),共 19 页。DOI:dx.doi.org/10.1145/2827872
。
该数据集包含了来自 943 名用户对 1682 部电影的 1 至 5 分的 100,000 条评分。每个用户至少评分过 20 部电影。数据集还包含了关于用户的基本人口统计信息(如年龄、性别、职业和邮政编码)。
第 1 步 - 导入必要的库并创建 Spark 会话
我们需要导入一个 Spark 会话,以便我们可以创建 Spark 会话,这是我们 Spark 应用程序的入口:
import org.apache.spark.sql.SparkSession
val spark: SparkSession = SparkSession
.builder()
.appName("MovieSimilarityApp")
.master("local[*]")
.config("spark.sql.warehouse.dir", "E:/Exp/")
.getOrCreate()
第 2 步 - 读取和解析数据集
我们可以使用 Spark 的textFile
方法从你首选的存储系统(如 HDFS 或本地文件系统)读取文本文件。然而,我们需要自己指定如何分割字段。在读取输入数据集时,我们首先进行groupBy
操作,并在与flatMap
操作进行联接后进行转换,以获取所需字段:
val TRAIN_FILENAME = "data/ua.base"
val TEST_FIELNAME = "data/ua.test"
val MOVIES_FILENAME = "data/u.item"
// get movie names keyed on id
val movies = spark.sparkContext.textFile(MOVIES_FILENAME)
.map(line => {
val fields = line.split("\|")
(fields(0).toInt, fields(1))
})
val movieNames = movies.collectAsMap()
// extract (userid, movieid, rating) from ratings data
val ratings = spark.sparkContext.textFile(TRAIN_FILENAME)
.map(line => {
val fields = line.split("t")
(fields(0).toInt, fields(1).toInt, fields(2).toInt)
})
第 3 步 - 计算相似度
通过基于物品的协同过滤,我们可以计算两部电影之间的相似度。我们按照以下步骤进行:
-
对于每一对电影(A,B),我们找出所有同时评分过A和B的用户。
-
现在,使用前述的评分,我们计算出电影A的向量,比如X,和电影B的向量,比如Y
-
然后我们计算X和Y之间的相关性
-
如果一个用户观看了电影C,我们可以推荐与其相关性最高的电影
然后我们计算每个评分向量X和Y的各种向量度量,如大小、点积、范数等。我们将使用这些度量来计算电影对之间的各种相似度度量,也就是(A,B)。对于每对电影(A,B),我们计算多个度量,如余弦相似度、Jaccard 相似度、相关性和常规相关性。让我们开始吧。前两步如下:
// get num raters per movie, keyed on movie id
val numRatersPerMovie = ratings
.groupBy(tup => tup._2)
.map(grouped => (grouped._1, grouped._2.size))
// join ratings with num raters on movie id
val ratingsWithSize = ratings
.groupBy(tup => tup._2)
.join(numRatersPerMovie)
.flatMap(joined => {
joined._2._1.map(f => (f._1, f._2, f._3, joined._2._2))
})
ratingsWithSize
变量现在包含以下字段:user
,movie
,rating
和numRaters
。接下来的步骤是创建评分的虚拟副本以进行自连接。技术上,我们通过userid
进行连接,并过滤电影对,以避免重复计数并排除自对:
val ratings2 = ratingsWithSize.keyBy(tup => tup._1)
val ratingPairs =
ratingsWithSize
.keyBy(tup => tup._1)
.join(ratings2)
.filter(f => f._2._1._2 < f._2._2._2)
现在让我们计算每对电影的相似度度量的原始输入:
val vectorCalcs = ratingPairs
.map(data => {
val key = (data._2._1._2, data._2._2._2)
val stats =
(data._2._1._3 * data._2._2._3, // rating 1 * rating 2
data._2._1._3, // rating movie 1
data._2._2._3, // rating movie 2
math.pow(data._2._1._3, 2), // square of rating movie 1
math.pow(data._2._2._3, 2), // square of rating movie 2
data._2._1._4, // number of raters movie 1
data._2._2._4) // number of raters movie 2
(key, stats)
})
.groupByKey()
.map(data => {
val key = data._1
val vals = data._2
val size = vals.size
val dotProduct = vals.map(f => f._1).sum
val ratingSum = vals.map(f => f._2).sum
val rating2Sum = vals.map(f => f._3).sum
val ratingSq = vals.map(f => f._4).sum
val rating2Sq = vals.map(f => f._5).sum
val numRaters = vals.map(f => f._6).max
val numRaters2 = vals.map(f => f._7).max
(key, (size, dotProduct, ratingSum, rating2Sum, ratingSq, rating2Sq, numRaters, numRaters2))})
这是计算相似度的第三步和第四步。我们为每对电影计算相似度度量:
val similarities =
vectorCalcs
.map(fields => {
val key = fields._1
val (size, dotProduct, ratingSum, rating2Sum, ratingNormSq, rating2NormSq, numRaters, numRaters2) = fields._2
val corr = correlation(size, dotProduct, ratingSum, rating2Sum, ratingNormSq, rating2NormSq)
val regCorr = regularizedCorrelation(size, dotProduct, ratingSum, rating2Sum,ratingNormSq, rating2NormSq, PRIOR_COUNT, PRIOR_CORRELATION)
val cosSim = cosineSimilarity(dotProduct, scala.math.sqrt(ratingNormSq), scala.math.sqrt(rating2NormSq))
val jaccard = jaccardSimilarity(size, numRaters, numRaters2)
(key, (corr, regCorr, cosSim, jaccard))})
接下来是我们刚才使用的方法的实现。我们从correlation()
方法开始,用来计算两个向量(A,B)之间的相关性,公式为cov(A, B)/(stdDev(A) * stdDev(B)):
def correlation(size: Double, dotProduct: Double, ratingSum: Double,
rating2Sum: Double, ratingNormSq: Double, rating2NormSq: Double) = {
val numerator = size * dotProduct - ratingSum * rating2Sum
val denominator = scala.math.sqrt(size * ratingNormSq - ratingSum * ratingSum)
scala.math.sqrt(size * rating2NormSq - rating2Sum * rating2Sum)
numerator / denominator}
现在,通过在先验上添加虚拟伪计数来对相关性进行常规化,RegularizedCorrelation = w * ActualCorrelation + (1 - w) * PriorCorrelation,其中 w = # actualPairs / (# actualPairs + # virtualPairs):
def regularizedCorrelation(size: Double, dotProduct: Double, ratingSum: Double,
rating2Sum: Double, ratingNormSq: Double, rating2NormSq: Double,
virtualCount: Double, priorCorrelation: Double) = {
val unregularizedCorrelation = correlation(size, dotProduct, ratingSum, rating2Sum, ratingNormSq, rating2NormSq)
val w = size / (size + virtualCount)
w * unregularizedCorrelation + (1 - w) * priorCorrelation
}
两个向量 A,B 之间的余弦相似度为 dotProduct(A, B) / (norm(A) * norm(B)):
def cosineSimilarity(dotProduct: Double, ratingNorm: Double, rating2Norm: Double) = {
dotProduct / (ratingNorm * rating2Norm)
}
最后,两个集合A和B之间的 Jaccard 相似度为*|Intersection (A, B)| / |Union (A, B)|*:
def jaccardSimilarity(usersInCommon: Double, totalUsers1: Double, totalUsers2: Double) = {
val union = totalUsers1 + totalUsers2 - usersInCommon
usersInCommon / union
}
第 4 步 - 测试模型
让我们看看与Die Hard (1998)
最相似的 10 部电影,按常规相关性排名:
evaluateModel("Die Hard (1988)")
>>>
在前面的图表中,列包括电影 1,电影 2,相关性,常规相关性,余弦相似度和 Jaccard 相似度。现在,让我们看看与Postino, Il(1994)最相似的 10 部电影,按常规相关性排名:
evaluateModel("Postino, Il (1994)")
>>>
最后,让我们看看与Star Wars (1977)
最相似的 10 部电影,按常规相关性排名:
evaluateModel("Star Wars (1977)")
>>>
现在,从输出结果中,我们可以看到一些电影对的共同评分者非常少;可以看出,使用原始的相关性计算得出的相似度并不理想。虽然余弦相似度是协同过滤方法中的标准相似度度量,但其表现不佳。
原因在于有许多电影的余弦相似度为 1.0。顺便提一下,前面的evaluateModel()
方法会测试几部电影(用相关的电影名称替代 contains 调用),具体如下:
def evaluateModel(movieName: String): Unit = {
val sample = similarities.filter(m => {
val movies = m._1
(movieNames(movies._1).contains(movieName))
})
// collect results, excluding NaNs if applicable
val result = sample.map(v => {
val m1 = v._1._1
val m2 = v._1._2
val corr = v._2._1
val rcorr = v._2._2
val cos = v._2._3
val j = v._2._4
(movieNames(m1), movieNames(m2), corr, rcorr, cos, j)
}).collect().filter(e => !(e._4 equals Double.NaN)) // test for NaNs must use equals rather than ==
.sortBy(elem => elem._4).take(10)
// print the top 10 out
result.foreach(r => println(r._1 + " | " + r._2 + " | " + r._3.formatted("%2.4f") + " | " + r._4.formatted("%2.4f")
+ " | " + r._5.formatted("%2.4f") + " | " + r._6.formatted("%2.4f"))) }
你可以理解基于这些协同过滤方法的局限性。当然,这些方法有计算复杂性,但你部分是对的。最重要的方面是,这些方法无法预测在实际应用中缺失的条目。它们还存在一些前面提到的问题,如冷启动、可扩展性和稀疏性。因此,我们将看看如何使用 Spark MLlib 中的基于模型的推荐系统来改进这些局限性。
基于模型的推荐(使用 Spark)
为了为任何用户做出偏好预测,协同过滤使用其他兴趣相似的用户的偏好,并预测你可能感兴趣但未知的电影。Spark MLlib 使用 交替最小二乘法(ALS)来进行推荐。以下是 ALS 算法中使用的一种协同过滤方法的概览:
表 1 – 用户-电影矩阵
用户 | M1 | M2 | M3 | M4 |
---|---|---|---|---|
U1 | 2 | 4 | 3 | 1 |
U2 | 0 | 0 | 4 | 4 |
U3 | 3 | 2 | 2 | 3 |
U4 | 2 | ? | 3 | ? |
在前面的表格中,用户对电影的评分表示为一个矩阵(即用户-物品矩阵),其中每个单元格表示一个用户对特定电影的评分。单元格中的 ? 代表用户 U4 不知道或没有看过的电影。根据 U4 当前的偏好,单元格中的 ? 可以通过与 U4 兴趣相似的用户的评分来填充。因此,ALS 本身无法完成此任务,但可以利用 LF 来预测缺失的条目。
Spark API 提供了 ALS 算法的实现,该算法用于基于以下六个参数学习这些 LF:
-
numBlocks
: 这是用于并行计算的块数(设置为 -1 会自动配置)。 -
rank
: 这是模型中 LF(潜在因子)的数量。 -
iterations
: 这是 ALS 运行的迭代次数。ALS 通常在 20 次迭代或更少的次数内收敛到合理的解决方案。 -
lambda
: 这是 ALS 中指定的正则化参数。 -
implicitPrefs
: 这是指定是否使用 ALS 变体中的显式反馈(或用户定义的)来处理隐式反馈数据。 -
alpha
: 这是 ALS 的隐式反馈变体中的一个参数,用于控制偏好观察的基准信心。
请注意,要构造一个使用默认参数的 ALS 实例,可以根据需要设置相应的值。默认值如下:numBlocks: -1
,rank: 10
,iterations: 10
,lambda: 0.01
,implicitPrefs: false
,alpha: 1.0
。
数据探索
电影和相应的评分数据集是从 MovieLens 网站下载的(movielens.org
)。根据 MovieLens 网站上的数据说明,所有评分都记录在 ratings.csv
文件中。该文件中的每一行(包括标题行)代表一个用户对某部电影的评分。
该 CSV 数据集包含以下列:userId
、movieId
、rating
和 timestamp
。这些在图 14中显示。行按照userId
排序,并在每个用户内部按movieId
排序。评分采用五分制,并有半星递增(从 0.5 星到 5.0 星)。时间戳表示自 1970 年 1 月 1 日午夜以来的秒数,时间格式为协调世界时(UTC)。我们从 668 个用户那里收到了 105,339 个评分,涵盖 10,325 部电影:
图 2:评分数据集的快照
另一方面,电影信息包含在movies.csv
文件中。每行(除去表头信息)代表一部电影,包含这些列:movieId
、title
和 genres
(见图 2)。电影标题要么是手动创建或插入的,要么是从电影数据库网站(www.themoviedb.org/
)导入的。上映年份则以括号形式显示。
由于电影标题是手动插入的,因此这些标题可能存在一些错误或不一致的情况。因此,建议读者查阅 IMDb 数据库(www.imdb.com/
),确保没有不一致或错误的标题和对应的上映年份:
图 3:前 20 部电影的标题和类型
类型以分隔列表的形式出现,并从以下类型类别中选择:
-
动作、冒险、动画、儿童、喜剧和犯罪
-
纪录片、剧情、幻想、黑色电影、恐怖和音乐剧
-
神秘、浪漫、科幻、惊悚、西部和战争
使用 ALS 进行电影推荐
在本小节中,我们将通过一个系统的示例向您展示如何向其他用户推荐电影,从数据收集到电影推荐。
步骤 1 - 导入软件包,加载、解析并探索电影和评分数据集
我们将加载、解析并进行一些探索性分析。不过,在此之前,我们先导入必要的软件包和库:
package com.packt.ScalaML.MovieRecommendation
import org.apache.spark.sql.SparkSession
import org.apache.spark.mllib.recommendation.ALS
import org.apache.spark.mllib.recommendation.MatrixFactorizationModel
import org.apache.spark.mllib.recommendation.Rating
import scala.Tuple2
import org.apache.spark.rdd.RDD
该代码段应返回评分的 DataFrame:
val ratigsFile = "data/ratings.csv"
val df1 = spark.read.format("com.databricks.spark.csv").option("header", true).load(ratigsFile)
val ratingsDF = df1.select(df1.col("userId"), df1.col("movieId"), df1.col("rating"), df1.col("timestamp"))
ratingsDF.show(false)
以下代码段展示了电影的 DataFrame:
val moviesFile = "data/movies.csv"
val df2 = spark.read.format("com.databricks.spark.csv").option("header", "true").load(moviesFile)
val moviesDF = df2.select(df2.col("movieId"), df2.col("title"), df2.col("genres"))
步骤 2 - 注册两个 DataFrame 作为临时表,以便更方便地查询
要注册这两个数据集,我们可以使用以下代码:
ratingsDF.createOrReplaceTempView("ratings")
moviesDF.createOrReplaceTempView("movies")
这将通过在内存中创建一个临时视图作为表来加快内存查询的速度。使用createOrReplaceTempView()
方法创建的临时表的生命周期与用于创建该 DataFrame 的[[SparkSession]]
相关联。
步骤 3 - 探索和查询相关统计数据
让我们检查与评分相关的统计数据。只需使用以下代码行:
val numRatings = ratingsDF.count()
val numUsers = ratingsDF.select(ratingsDF.col("userId")).distinct().count()
val numMovies = ratingsDF.select(ratingsDF.col("movieId")).distinct().count()
println("Got " + numRatings + " ratings from " + numUsers + " users on " + numMovies + " movies.")
>>>
Got 105339 ratings from 668 users on 10325 movies.
你应该会发现668
用户在10,325
部电影上有105,339
条评分。现在,让我们获取最大和最小评分以及评分过电影的用户数量。然而,你需要对我们在上一步骤中创建的评分表执行 SQL 查询。在这里进行查询很简单,类似于从 MySQL 数据库或关系型数据库管理系统(RDBMS)中进行查询。
然而,如果你不熟悉基于 SQL 的查询,建议查看 SQL 查询规范,了解如何使用SELECT
从特定表中选择数据,如何使用ORDER
进行排序,以及如何使用JOIN
关键字进行连接操作。好吧,如果你熟悉 SQL 查询,你应该使用复杂的 SQL 查询来获取新的数据集,如下所示:
// Get the max, min ratings along with the count of users who have rated a movie.
val results = spark.sql("select movies.title, movierates.maxr, movierates.minr, movierates.cntu "
+ "from(SELECT ratings.movieId,max(ratings.rating) as maxr,"
+ "min(ratings.rating) as minr,count(distinct userId) as cntu "
+ "FROM ratings group by ratings.movieId) movierates "
+ "join movies on movierates.movieId=movies.movieId " + "order by movierates.cntu desc")
results.show(false)
输出:
图 4:最大和最小评分以及评分过电影的用户数量
为了获得一些洞察,我们需要更多了解用户及其评分。现在,让我们找出排名前 10 的活跃用户及其评分次数:
val mostActiveUsersSchemaRDD = spark.sql("SELECT ratings.userId, count(*) as ct from ratings "+ "group by ratings.userId order by ct desc limit 10")
mostActiveUsersSchemaRDD.show(false)
>>>
图 5:排名前 10 的活跃用户及其评分次数
让我们来看一下特定用户,并找出例如用户668
评分高于4
的电影:
val results2 = spark.sql(
"SELECT ratings.userId, ratings.movieId,"
+ "ratings.rating, movies.title FROM ratings JOIN movies"
+ "ON movies.movieId=ratings.movieId"
+ "where ratings.userId=668 and ratings.rating > 4")
results2.show(false)
>>>
图 6:用户 668 评分高于 4 分的电影
步骤 4 - 准备训练和测试评分数据并检查计数
以下代码将评分 RDD 拆分为训练数据 RDD(75%)和测试数据 RDD(25%)。这里的种子是可选的,但为了可重复性,需要指定:
// Split ratings RDD into training RDD (75%) & test RDD (25%)
val splits = ratingsDF.randomSplit(Array(0.75, 0.25), seed = 12345L)
val (trainingData, testData) = (splits(0), splits(1))
val numTraining = trainingData.count()
val numTest = testData.count()
println("Training: " + numTraining + " test: " + numTest)
你应该注意到,训练数据中有 78,792 条评分,测试数据中有 26,547 条评分。
DataFrame。
步骤 5 - 准备数据以构建使用 ALS 的推荐模型
ALS 算法使用训练数据的评分 RDD。为此,以下代码展示了如何使用 API 构建推荐模型:
val ratingsRDD = trainingData.rdd.map(row => {
val userId = row.getString(0)
val movieId = row.getString(1)
val ratings = row.getString(2)
Rating(userId.toInt, movieId.toInt, ratings.toDouble)
})
ratingsRDD
是一个包含userId
、movieId
及相应评分的 RDD,来源于我们在上一步骤中准备的训练数据集。另一方面,也需要一个测试 RDD 来评估模型。以下testRDD
也包含来自我们在上一步骤中准备的测试 DataFrame 的相同信息:
val testRDD = testData.rdd.map(row => {
val userId = row.getString(0)
val movieId = row.getString(1)
val ratings = row.getString(2)
Rating(userId.toInt, movieId.toInt, ratings.toDouble)
})
步骤 6 - 构建 ALS 用户-电影矩阵
基于ratingsRDD
构建一个 ALS 用户矩阵模型,通过指定最大迭代次数、块数、alpha、rank、lambda、种子以及implicitPrefs
来实现。基本上,这种技术根据其他用户对其他电影的相似评分来预测特定用户和特定电影的缺失评分:
val rank = 20
val numIterations = 15
val lambda = 0.10
val alpha = 1.00 val block = -1
val seed = 12345L
val implicitPrefs = false
val model = new ALS().setIterations(numIterations)
.setBlocks(block).setAlpha(alpha)
.setLambda(lambda)
.setRank(rank) .setSeed(seed)
.setImplicitPrefs(implicitPrefs)
.run(ratingsRDD)
最后,我们迭代训练了模型 15 次。在这个设置下,我们得到了良好的预测准确性。建议读者进行超参数调优,以找到这些参数的最优值。此外,将用户块和产品块的块数设置为-1,以便并行化计算并自动配置块数。该值为-1。
步骤 7 - 进行预测
让我们为用户668
获取前六部电影的预测。以下源代码可以用于进行预测:
// Making Predictions. Get the top 6 movie predictions for user 668
println("Rating:(UserID, MovieID, Rating)") println("----------------------------------")
val topRecsForUser = model.recommendProducts(668, 6) for (rating <- topRecsForUser) { println(rating.toString()) } println("----------------------------------")
>>>
图 7:用户 668 的前六部电影预测
步骤 8 - 评估模型
为了验证模型的质量,均方根误差(RMSE)被用来衡量模型预测值与实际观测值之间的差异。默认情况下,计算的误差越小,模型越好。为了测试模型的质量,使用了测试数据(该数据在步骤 4中已拆分)。
根据许多机器学习从业者的说法,RMSE 是一个良好的准确度衡量标准,但仅适用于比较不同模型在特定变量上的预测误差。他们表示,RMSE 不适合用于比较不同变量之间的误差,因为它依赖于尺度。以下代码行计算了使用训练集训练的模型的 RMSE 值:
val rmseTest = computeRmse(model, testRDD, true)
println("Test RMSE: = " + rmseTest) //Less is better
对于这个设置,我们得到以下输出:
Test RMSE: = 0.9019872589764073
该方法通过计算 RMSE 来评估模型。RMSE 越小,模型和预测能力越好。需要注意的是,computeRmse()
是一个 UDF,其实现如下:
def computeRmse(model: MatrixFactorizationModel, data: RDD[Rating], implicitPrefs: Boolean): Double = { val predictions: RDD[Rating] = model.predict(data.map(x => (x.user, x.product)))
val predictionsAndRatings = predictions.map { x => ((x.user, x.product), x.rating) }
.join(data.map(x => ((x.user, x.product), x.rating))).values
if (implicitPrefs) { println("(Prediction, Rating)")
println(predictionsAndRatings.take(5).mkString("n")) }
math.sqrt(predictionsAndRatings.map(x => (x._1 - x._2) * (x._1 - x._2)).mean())
}
>>>
最后,让我们为特定用户提供一些电影推荐。让我们为用户668
获取前六部电影的预测:
println("Recommendations: (MovieId => Rating)")
println("----------------------------------")
val recommendationsUser = model.recommendProducts(668, 6)
recommendationsUser.map(rating => (rating.product, rating.rating)).foreach(println) println("----------------------------------")
>>>
我们相信,前一个模型的性能可以进一步提高。然而,迄今为止,基于 MLlib 的 ALS 算法没有我们所知的模型调优工具。
有兴趣的读者可以参考这个网址,了解更多关于调优基于 ML 的 ALS 模型的内容:spark.apache.org/docs/preview/ml-collaborative-filtering.html
。
选择并部署最佳模型
值得一提的是,第一个项目中开发的第一个模型无法持久化,因为它仅是计算电影相似性的几行代码。它还有另一个之前未提到的限制。它可以计算两部电影之间的相似度,但如果是多于两部电影呢?坦率地说,像第一个模型这样的模型很少会应用于真实的电影推荐。因此,我们将重点关注基于模型的推荐引擎。
尽管用户的评分会不断出现,但仍然值得存储当前的评分。因此,我们还希望持久化当前的基础模型,以便以后使用,从而在启动服务器时节省时间。我们的想法是使用当前模型进行实时电影推荐。
然而,如果我们持久化一些已生成的 RDD,尤其是那些处理时间较长的 RDD,可能也能节省时间。以下代码保存了我们训练好的 ALS 模型(具体细节请参见MovieRecommendation.scala
脚本):
//Saving the model for future use
val savedALSModel = model.save(spark.sparkContext, "model/MovieRecomModel")
与其他 Spark 模型不同,我们保存的 ALS 模型将仅包含训练过程中数据和一些元数据,采用 parquet 格式,具体如下图所示:
现在,下一个任务是恢复相同的模型,并提供与前面步骤中展示的类似的工作流:
val same_model = MatrixFactorizationModel.load(spark.sparkContext, "model/MovieRecomModel/")
不过我不会让你感到困惑,特别是如果你是 Spark 和 Scala 的新手的话。这是预测用户 558 评分的完整代码:
package com.packt.ScalaML.MovieRecommendation
import org.apache.spark.sql.SparkSession
import org.apache.spark.mllib.recommendation.ALS
import org.apache.spark.mllib.recommendation.MatrixFactorizationModel
import org.apache.spark.mllib.recommendation.Rating
import scala.Tuple2
import org.apache.spark.rdd.RDD
object RecommendationModelReuse {
def main(args: Array[String]): Unit = {
val spark: SparkSession = SparkSession.builder()
.appName("JavaLDAExample")
.master("local[*]")
.config("spark.sql.warehouse.dir", "E:/Exp/")
.getOrCreate()
val ratigsFile = "data/ratings.csv"
val ratingDF = spark.read
.format("com.databricks.spark.csv")
.option("header", true)
.load(ratigsFile)
val selectedRatingsDF = ratingDF.select(ratingDF.col("userId"), ratingDF.col("movieId"), ratingDF.col("rating"), ratingDF.col("timestamp"))
// Randomly split ratings RDD into training data RDD (75%) and test data RDD (25%)
val splits = selectedRatingsDF.randomSplit(Array(0.75, 0.25), seed = 12345L)
val testData = splits(1)
val testRDD = testData.rdd.map(row => {
val userId = row.getString(0)
val movieId = row.getString(1)
val ratings = row.getString(2)
Rating(userId.toInt, movieId.toInt, ratings.toDouble) })
//Load the workflow back
val same_model = MatrixFactorizationModel.load(spark.sparkContext, "model/MovieRecomModel/")
// Making Predictions. Get the top 6 movie predictions for user 668
println("Rating:(UserID, MovieID, Rating)")
println("----------------------------------")
val topRecsForUser = same_model.recommendProducts(458, 10)
for (rating <- topRecsForUser) {
println(rating.toString()) }
println("----------------------------------")
val rmseTest = MovieRecommendation.computeRmse(same_model, testRDD, true)
println("Test RMSE: = " + rmseTest) //Less is better
//Movie recommendation for a specific user. Get the top 6 movie predictions for user 668
println("Recommendations: (MovieId => Rating)")
println("----------------------------------")
val recommendationsUser = same_model.recommendProducts(458, 10)
recommendationsUser.map(rating =>
(rating.product, rating.rating)).foreach(println)
println("----------------------------------")
spark.stop()
}
}
如果前面的脚本成功执行,您应该会看到以下输出:
做得好!我们成功地重用了模型,并为不同的用户(即 558)进行了相同的预测。然而,可能由于数据的随机性,我们观察到略微不同的 RMSE。
总结
在本章中,我们实现了两个端到端项目,分别开发了基于项目的协同过滤来进行电影相似度测量和基于模型的推荐,均使用 Spark 完成。我们还展示了如何在 ALS 和 MF 之间进行互操作,并开发可扩展的电影推荐引擎。最后,我们看到了如何将此模型部署到生产环境中。
作为人类,我们通过过去的经验学习。我们之所以变得如此迷人,并非偶然。多年的正面赞美和批评都帮助我们塑造了今天的自我。你通过与朋友、家人,甚至陌生人互动,学习如何让别人开心;你通过尝试不同的肌肉运动,直到自行车骑行技巧自然流畅,来学会骑车。当你执行某些动作时,有时会立即获得奖励。这一切都是关于强化学习(RL)。
下一章将讨论如何设计一个由反馈和奖励驱动的机器学习项目。我们将看到如何应用强化学习(RL)算法,利用现实中的 IBM 股票和期权价格数据集开发期权交易应用。
第七章:使用 Q 学习和 Scala Play 框架进行期权交易
作为人类,我们通过经验学习。我们并不是偶然变得如此迷人。多年的正面夸奖和负面批评,塑造了今天的我们。我们通过尝试不同的肌肉动作来学习骑自行车,直到掌握为止。当你执行某些动作时,往往会立即获得奖励。这就是强化学习(RL)的核心。
本章将专注于设计一个由批评和奖励驱动的机器学习系统。我们将展示如何将强化学习算法应用于实际数据集的预测模型中。
从交易的角度来看,期权是一种合约,赋予持有者在固定价格(行权价)下,在固定日期(到期日)或之前买入(看涨期权)或卖出(看跌期权)金融资产(标的资产)的权利。
我们将展示如何利用强化学习算法(称为QLearning)为期权交易开发一个实际应用。更具体地说,我们将解决计算期权交易中最佳策略的问题,并希望在某些市场条件和交易数据下进行特定类型的期权交易。
我们将使用 IBM 股票数据集来设计一个由批评和奖励驱动的机器学习系统。我们将从强化学习及其理论背景开始,以便更容易理解这个概念。最后,我们将通过使用 Scala Play 框架将整个应用程序封装为一个 Web 应用。
简言之,在这个从头到尾的项目中,我们将学习以下内容:
-
使用 Q 学习——一种强化学习算法
-
期权交易——它到底是怎么回事?
-
技术概述
-
为期权交易实现 Q 学习
-
使用 Scala Play 框架将应用程序封装为 Web 应用
-
模型部署
强化学习与监督学习和无监督学习的比较
虽然监督学习和无监督学习处于光谱的两端,但强化学习位于其中间。它不是监督学习,因为训练数据来自算法在探索和利用之间的选择。此外,它也不是无监督学习,因为算法会从环境中获得反馈。只要你处于一个执行某个动作能带来奖励的状态,你就可以使用强化学习来发现一个能够获得最大预期奖励的良好动作序列。
强化学习(RL)智能体的目标是最大化最终获得的总奖励。第三个主要子元素是价值
函数。奖励决定了状态的即时吸引力,而价值则表示状态的长期吸引力,考虑到可能跟随的状态以及这些状态中的可用奖励。价值
函数是根据所选择的策略来定义的。在学习阶段,智能体尝试那些能确定最高价值状态的动作,因为这些动作最终将获得最佳的奖励数量。
使用强化学习(RL)
图 1 显示了一个人做出决策以到达目的地。此外,假设你从家到公司时,总是选择相同的路线。然而,有一天你的好奇心占了上风,你决定尝试一条不同的道路,希望能更快到达。这个尝试新路线与坚持最熟悉路线的困境,就是探索与利用之间的例子:
图 1:一个代理始终尝试通过特定路线到达目的地
强化学习技术正在许多领域中被应用。目前正在追求的一个普遍理念是创建一个算法,它只需要任务的描述而不需要其他任何东西。当这种性能实现时,它将被几乎应用于所有领域。
强化学习中的符号、策略和效用
你可能会注意到,强化学习的术语涉及将算法化身为在情境中采取动作以获得奖励。事实上,算法通常被称为与环境互动的代理。你可以将它看作一个智能硬件代理,使用传感器感知环境,并利用执行器与环境互动。因此,强化学习理论在机器人学中的广泛应用并不令人惊讶。现在,为了进一步展开讨论,我们需要了解一些术语:
-
环境:环境是任何拥有状态以及在不同状态之间转换机制的系统。例如,一个机器人的环境就是它所操作的景观或设施。
-
代理:代理是一个与环境互动的自动化系统。
-
状态:环境或系统的状态是完全描述环境的变量或特征的集合。
-
目标:目标是一个状态,它提供比任何其他状态更高的折扣累计奖励。高累计奖励能够防止最佳策略在训练过程中依赖于初始状态。
-
动作:动作定义了状态之间的转变,代理负责执行或至少推荐某个动作。在执行动作后,代理会从环境中收获奖励(或惩罚)。
-
策略:策略定义了在环境的任何状态下需要执行的动作。
-
奖励:奖励量化了代理与环境之间的正向或负向互动。奖励本质上是学习引擎的训练集。
-
回合 *(**也称为 试验):这定义了从初始状态到达目标状态所需的步骤数量。
我们将在本节稍后讨论更多关于策略和效用的内容。图 2 展示了状态、动作和奖励之间的相互作用。如果你从状态 s[1] 开始,你可以执行动作 a[1] 来获得奖励 r (s[1], a[1])。箭头代表动作,状态由圆圈表示:
图 2:代理在一个状态下执行一个动作会产生回报
机器人执行动作以在不同状态之间变化。但它如何决定采取哪种动作呢?嗯,这一切都与使用不同的或具体的策略有关。
策略
在 RL 术语中,我们称一个策略为策略。RL 的目标是发现一个好的策略。解决 RL 问题的最常见方法之一是通过观察在每个状态下采取动作的长期后果。短期后果很容易计算:它就是回报。尽管执行某个动作会产生即时回报,但贪婪地选择回报最好的动作并不总是一个好主意。这也是生活中的一课,因为最直接的最佳选择可能不会在长远看来是最令人满足的。最好的策略被称为最优策略,它通常是 RL 中的“圣杯”,如图 3所示,展示了在给定任何状态下的最优动作:
图 3:策略定义了在给定状态下要采取的动作
我们看到过一种类型的策略,其中代理始终选择具有最大即时回报的动作,称为贪心策略。另一个简单的策略例子是随意选择一个动作,称为随机策略。如果你想出一个策略来解决一个 RL 问题,通常一个好主意是重新检查你的学习策略是否优于随机策略和贪心策略。
此外,我们还将看到如何开发另一种强健的策略,称为策略梯度,在这种策略中,神经网络通过使用来自环境的反馈调整其权重,通过梯度下降学习选择动作的策略。我们将看到,尽管两种方法都被使用,策略梯度更为直接且充满乐观。
效用
长期回报被称为效用。事实证明,如果我们知道在某一状态下执行某个动作的效用,那么解决强化学习(RL)就变得容易。例如,为了决定采取哪种动作,我们只需选择产生最高效用的动作。然而,揭示这些效用值是困难的。在状态s下执行动作a的效用被写为一个函数,Q(s, a),称为效用函数。它预测期望的即时回报,以及根据最优策略执行后续回报,如图 4所示:
图 4:使用效用函数
大多数 RL 算法归结为三个主要步骤:推断、执行和学习。在第一步中,算法根据当前所掌握的知识选择给定状态s下的最佳动作a。接下来,执行该动作以获得回报r以及下一个状态s’。然后,算法利用新获得的知识*(s, r, a, s’)*来改进对世界的理解。然而,正如你可能同意的那样,这只是计算效用的一种朴素方法。
现在,问题是:有什么更稳健的方法来计算它呢?我们可以通过递归地考虑未来动作的效用来计算某个特定状态-动作对*(s, a)*的效用。当前动作的效用不仅受到即时奖励的影响,还受到下一最佳动作的影响,如下式所示:
s’表示下一个状态,a’表示下一个动作。在状态s下采取动作a的奖励用r(s, a)表示。这里,γ是一个超参数,你可以选择它,称为折扣因子。如果γ为0,那么智能体选择的是最大化即时奖励的动作。较高的γ值会使智能体更加重视长期后果。在实际应用中,我们还需要考虑更多这样的超参数。例如,如果一个吸尘器机器人需要快速学习解决任务,但不一定要求最优,我们可能会希望设置一个较快的学习率。
另外,如果允许机器人有更多时间来探索和利用,我们可以调低学习率。我们将学习率称为α,并将我们的效用函数修改如下(请注意,当α = 1时,两个方程是相同的):
总结来说,如果我们知道这个*Q(s, a)*函数,就可以解决一个强化学习问题。接下来是一个叫做 Q 学习的算法。
一个简单的 Q 学习实现
Q 学习是一种可以用于金融和市场交易应用的算法,例如期权交易。一个原因是最佳策略是通过训练生成的。也就是说,强化学习通过在 Q 学习中定义模型,并随着每一个新的实验不断更新它。Q 学习是一种优化(累计)折扣奖励的方法,使得远期奖励低于近期奖励;Q 学习是一种无模型的强化学习方法。它也可以看作是异步动态规划(DP)的一种形式。
它为智能体提供了通过体验行动的后果来学习在马尔科夫领域中最优行动的能力,而无需它们建立领域的映射。简而言之,Q 学习被认为是一种强化学习技术,因为它不严格要求标签数据和训练。此外,Q 值不一定是连续可微的函数。
另一方面,马尔科夫决策过程提供了一个数学框架,用于在结果部分随机且部分受决策者控制的情况下建模决策过程。在这种框架中,随机变量在未来某一时刻的概率仅依赖于当前时刻的信息,而与任何历史值无关。换句话说,概率与历史状态无关。
Q 学习算法的组成部分
这个实现深受 Patrick R. Nicolas 所著《Scala for Machine Learning - Second Edition》一书中的 Q 学习实现的启发,出版于 Packt Publishing Ltd.,2017 年 9 月。感谢作者和 Packt Publishing Ltd.。源代码可以在github.com/PacktPublishing/Scala-for-Machine-Learning-Second-Edition/tree/master/src/main/scala/org/scalaml/reinforcement
获取。
有兴趣的读者可以查看原始实现,扩展版课程可以从 Packt 仓库或本书的 GitHub 仓库下载。Q 学习算法实现的关键组件有几个类——QLearning
、QLSpace
、QLConfig
、QLAction
、QLState
、QLIndexedState
和QLModel
——如以下几点所描述:
-
QLearning
:实现训练和预测方法。它使用类型为QLConfig
的配置定义一个类型为ETransform
的数据转换。 -
QLConfig
:这个参数化的类定义了 Q 学习的配置参数。更具体地说,它用于保存用户的显式配置。 -
QLAction
: 这是一个定义在源状态和多个目标状态之间执行的动作的类。 -
QLPolicy
:这是一个枚举器,用于定义在 Q 学习模型训练过程中更新策略时使用的参数类型。 -
QLSpace
:它有两个组成部分:类型为QLState
的状态序列和序列中一个或多个目标状态的标识符id
。 -
QLState
:包含一系列QLAction
实例,帮助从一个状态过渡到另一个状态。它还用作要评估和预测状态的对象或实例的引用。 -
QLIndexedState
:这个类返回一个索引状态,用于在搜索目标状态的过程中索引一个状态。 -
QLModel
:这个类用于通过训练过程生成一个模型。最终,它包含最佳策略和模型的准确性。
注意,除了前面的组件外,还有一个可选的约束函数,限制从当前状态搜索下一个最有回报的动作的范围。以下图示展示了 Q 学习算法的关键组件及其交互:
图 5:QLearning 算法的组成部分及其交互
QLearning 中的状态和动作
QLAction
类指定了从一个状态到另一个状态的过渡。它接受两个参数——即从和到。它们各自有一个整数标识符,且需要大于 0:
-
from
:动作的源 -
to
:动作的目标
其签名如下所示:
case class QLAction(from: Int, to: Int) {
require(from >= 0, s"QLAction found from:
$from required: >=0")require(to >= 0, s"QLAction found to:
$to required: >=0")
override def toString: String = s"n
Action: state
$from => state $to"
}
QLState
类定义了 Q 学习中的状态。它接受三个参数:
-
id
:一个唯一标识状态的标识符 -
actions
:从当前状态过渡到其他状态的动作列表, -
instance
:状态可能具有T
类型的属性,与状态转移无关
这是类的签名:
case class QLStateT {
import QLState._check(id)
final def isGoal: Boolean = actions.nonEmpty
override def toString: String =s"state: $id ${actions.mkString(" ")
}
nInstance: ${instance.toString}"
}
在上述代码中,toString()
方法用于表示 Q-learning 中状态的文本形式。状态由其 ID 和可能触发的动作列表定义。
状态可能没有任何动作。通常这种情况发生在目标状态或吸收状态中。在这种情况下,列表为空。参数化实例是指为其计算状态的对象。
现在我们知道要执行的状态和动作。然而,QLearning
代理需要知道形如(状态 x 动作)的搜索空间。下一步是创建图形或搜索空间。
搜索空间
搜索空间是负责任何状态序列的容器。QLSpace
类定义了 Q-learning 算法的搜索空间(状态 x 动作),如下图所示:
图 6:带有 QLData(Q 值、奖励、概率)的状态转移矩阵
搜索空间可以通过最终用户提供状态和动作的列表来提供,或者通过提供以下参数来自动创建状态数量:
-
States
:Q-learning 搜索空间中定义的所有可能状态的序列 -
goalIds
:目标状态的标识符列表
现在让我们来看一下这个类的实现。这是一个相当大的代码块。因此,我们从构造函数开始,它生成一个名为statesMap
的映射。它通过id
获取状态,并使用目标数组goalStates
:
private[scalaml] class QLSpace[T] protected (states: Seq[QLState[T]], goalIds: Array[Int]) {
import QLSpace._check(states, goalIds)
然后它创建一个不可变的状态映射,映射包含状态 ID 和状态实例:
private[this] val statesMap: immutable.Map[Int, QLState[T]] = states.map(st => (st.id, st)).toMap
现在我们已经有了策略和动作状态,接下来的任务是根据状态和策略计算最大值:
final def maxQ(state: QLState[T], policy: QLPolicy): Double = {
val best=states.filter(_ != state).maxBy(st=>policy.EQ(state.id, st.id))policy.EQ(state.id, best.id)
}
此外,我们还需要通过访问搜索空间中的状态数来知道状态的数量:
final def getNumStates: Int = states.size
然后,init
方法选择一个初始状态用于训练集。 如果state0
参数无效,则随机选择该状态:
def init(state0: Int): QLState[T] =
if (state0 < 0) {
val r = new Random(System.currentTimeMillis
+ Random.nextLong)states(r.nextInt(states.size - 1))
}
else states(state0)
最后,nextStates
方法检索执行所有与该状态相关的动作后得到的状态列表。搜索空间QLSpace
由在QLSpace
伴生对象中定义的工厂方法apply
创建,如下所示:
final def nextStates(st: QLState[T]): Seq[QLState[T]] =
if (st.actions.isEmpty)Seq.empty[QLState[T]]
else st.actions.flatMap(ac => statesMap.get(ac.to))
此外,如何知道当前状态是否为目标状态?嗯,isGoal()
方法可以解决这个问题。
它接受一个名为state
的参数,它是一个被测试是否为目标状态的状态,并且如果该状态是目标状态,则返回Boolean: true
;否则返回 false:
final def isGoal(state: QLState[T]): Boolean = goalStates.contains(state.id)
apply 方法使用实例集合、目标和约束函数constraints
作为输入,创建一个状态列表。每个状态都会创建一个动作列表。动作是从这个状态到任何其他状态生成的:
def applyT: QLSpace[T] =
apply(ArrayInt, instances, constraints)
函数约束限制了从任何给定状态触发的操作范围,如图 X 所示。
策略和行动值
QLData
类通过创建一个具有给定奖励、概率和 Q 值的 QLData
记录或实例来封装 Q-learning 算法中策略的属性,这些值在训练过程中被计算和更新。概率变量用于建模执行操作的干预条件。
如果操作没有任何外部约束,则概率为 1(即最高概率),否则为零(即无论如何该操作都不被允许)。签名如下所示:
final private[scalaml] class QLData(
val reward: Double,
val probability: Double = 1.0) {
import QLDataVar._
var value: Double = 0.0
@inline final def estimate: Double = value * probability
final def value(varType: QLDataVar): Double = varType
match {
case REWARD => reward
case PROB => probability
case VALUE => value
}
override def toString: String = s"nValue= $value Reward= $reward Probability= $probability"}
在前面的代码块中,Q 值通过 Q-learning 公式在训练过程中更新,但整体值是通过使用奖励调整其概率来计算的,然后返回调整后的值。然后,value()
方法使用属性的类型选择 Q-learning 策略元素的属性。它接受属性的 varType
(即 REWARD
、PROBABILITY
和 VALUE
),并返回该属性的值。
最后,toString()
方法有助于表示值、奖励和概率。现在我们知道数据将如何操作,接下来的任务是创建一个简单的模式,用于初始化与每个操作相关的奖励和概率。以下 Scala 示例是一个名为 QLInput
的类;它输入到 Q-learning 搜索空间(QLSpace
)和策略(QLPolicy
)中:
case class QLInput(from: Int, to: Int, reward: Double = 1.0, prob: Double = 1.0)
在前面的签名中,构造函数创建了一个 Q-learning 的操作输入。它接受四个参数:
-
from
,源状态的标识符 -
to
,目标或目的地状态的标识符 -
reward
,即从状态from
转移到状态to
的奖励或惩罚 -
prob,表示从状态
from
转移到状态to
的概率
在前面的类中,from
和 to
参数用于特定操作,而最后两个参数分别是操作完成后收集的奖励和其概率。默认情况下,这两个操作的奖励和概率均为 1。简而言之,我们只需要为那些具有更高奖励或更低概率的操作创建输入。
状态数和输入序列定义了 QLPolicy
类型的策略,这是一个数据容器。一个操作有一个 Q 值(也称为行动值)、一个奖励和一个概率。实现通过三个独立的矩阵定义这三个值——Q 用于行动值,R 用于奖励,P 用于概率——以保持与数学公式的一致性。以下是此类的工作流程:
-
使用输入的概率和奖励初始化策略(参见
qlData
变量)。 -
根据输入大小计算状态数(参见
numStates
变量)。 -
设置从状态
from
到状态to
的动作的 Q 值(见setQ
方法),并通过get()
方法获取 Q 值。 -
获取从状态
from
到状态to
的状态转移动作的 Q 值(见 Q 方法)。 -
获取从状态
from
到状态to
的状态转移动作的估计值(见EQ
方法),并以double
类型返回该值。 -
获取从状态
from
到状态to
的状态转移动作的奖励(见 R 方法)。 -
获取从状态
from
到状态to
的状态转移动作的概率(见P
方法)。 -
计算
Q
的最小值和最大值(见minMaxQ
方法)。 -
获取一对(源状态索引,目标状态索引),其转移值为正。状态的索引将转换为 Double 类型(见
EQ: Vector[DblPair]
方法)。 -
使用第一个
toString()
方法获取此策略的奖励矩阵的文本描述。 -
使用第二个
toString()
方法,文本表示以下任意一项:Q 值、奖励或概率矩阵。 -
使用
check()
方法验证from
和to
的值。
现在让我们来看一下包含前述工作流的类定义:
final private[scalaml] class QLPolicy(val input: Seq[QLInput]) {
import QLDataVar._QLPolicy.check(input)
private[this] val qlData = input.map(qlIn => new QLData(qlIn.reward, qlIn.prob))
private[this] val numStates = Math.sqrt(input.size).toInt
def setQ(from: Int, to: Int, value: Double): Unit =
{check(from, to, "setQ")qlData(from * numStates + to).value = value}
final def get(from: Int, to: Int, varType: QLDataVar): String
{f"${qlData(from * numStates + to).value(varType)}%2.2f"}
final def Q(from: Int, to: Int): Double = {check(from, to, "Q") qlData(from * numStates + to).value}
final def EQ(from: Int, to: Int): Double = {check(from, to, "EQ") qlData(from * numStates + to).estimate}
final def R(from: Int, to: Int): Double = {check(from, to, "R") qlData(from * numStates + to).reward}
final def P(from: Int, to: Int): Double = {check(from, to, "P") qlData(from * numStates + to).probability}
final def minMaxQ: DblPair = {
val r = Range(0, numStates)
val _min = r.minBy(from => r.minBy(Q(from, _)))
val _max = r.maxBy(from => r.maxBy(Q(from, _)))(_min, _max)}
final def EQ: Vector[DblPair] = {
import scala.collection.mutable.ArrayBuffer
val r = Range(0, numStates)r.flatMap(from =>r.map(to => (from, to, Q(from, to)))).map {
case (i, j, q) =>
if (q > 0.0) (i.toDouble, j.toDouble)
else (0.0, 0.0) }.toVector}
override def toString: String = s"Rewardn${toString(REWARD)}"
def toString(varType: QLDataVar): String = {
val r = Range(1, numStates)r.map(i => r.map(get(i, _, varType)).mkString(",")).mkString("n")}
private def check(from: Int, to: Int, meth: String): Unit = {require(from >= 0 && from < numStates,s"QLPolicy.
$meth Found from:
$from required >= 0 and <
$numStates")require(to >= 0 && to < numStates,s"QLPolicy.
$meth Found to: $to required >= 0 and < $numStates")
}
QLearning 模型的创建与训练
QLearning
类封装了 Q 学习算法,更具体地说,是动作-值更新方程。它是ETransform
类型的数据转换(我们稍后会讨论),并有一个明确的QLConfig
类型配置。该类是一个泛型参数化类,实现了QLearning
算法。Q 学习模型在类实例化时进行初始化和训练,以便它能处于正确的状态,进行运行时预测。
因此,类实例只有两种状态:成功训练和失败训练(我们稍后会看到这一点)。
实现不假设每个回合(或训练周期)都会成功。训练完成后,计算初始训练集上标签的比例。客户端代码负责通过测试该比例来评估模型的质量(见模型评估部分)。
构造函数接受算法的配置(即config
)、搜索空间(即qlSpace
)和策略(即qlPolicy
)参数,并创建一个 Q 学习算法:
final class QLearningT
extends ETransform[QLState[T], QLState[T]](conf) with Monitor[Double]
如果在类实例化过程中达到(或训练)最小覆盖率,模型会自动有效地创建,这本质上是一个 Q 学习模型。
以下的train()
方法应用于每个回合,并随机生成初始状态。然后,它根据由conf
对象提供的minCoverage
配置值计算覆盖率,即每个目标状态达到的回合数:
private def train: Option[QLModel] = Try {
val completions = Range(0, conf.numEpisodes).map(epoch =>
if (heavyLiftingTrain (-1)) 1 else 0)
.sum
completions.toDouble / conf.numEpisodes
}
.filter(_ > conf.minCoverage).map(new QLModel(qlPolicy, _)).toOption;
在上述代码块中,heavyLiftingTrain(state0: Int)
方法在每个回合(或迭代)中执行繁重的工作。它通过选择初始状态 state 0 或使用新种子生成的随机生成器r来触发搜索,如果state0
小于 0。
首先,它获取当前状态的所有相邻状态,然后从相邻状态列表中选择回报最高的状态。如果下一个回报最高的状态是目标状态,那么任务完成。否则,它将使用奖励矩阵(即QLPolicy.R
)重新计算状态转移的策略值。
对于重新计算,它通过更新 Q 值来应用 Q 学习更新公式,然后使用新的状态和递增的迭代器调用搜索方法。让我们来看一下该方法的主体:
private def heavyLiftingTrain(state0: Int): Boolean = {
@scala.annotation.tailrec
def search(iSt: QLIndexedState[T]): QLIndexedState[T] = {
val states = qlSpace.nextStates(iSt.state)
if (states.isEmpty || iSt.iter >= conf.episodeLength)
QLIndexedState(iSt.state, -1)
else {
val state = states.maxBy(s => qlPolicy.EQ(iSt.state.id, s.id))
if (qlSpace.isGoal(state))
QLIndexedState(state, iSt.iter)
else {
val fromId = iSt.state.id
val r = qlPolicy.R(fromId, state.id)
val q = qlPolicy.Q(fromId, state.id)
val nq = q + conf.alpha * (r + conf.gamma * qlSpace.maxQ(state, qlPolicy) - q)
count(QVALUE_COUNTER, nq)
qlPolicy.setQ(fromId, state.id, nq)
search(QLIndexedState(state, iSt.iter + 1))
}
}
}
val finalState = search(QLIndexedState(qlSpace.init(state0), 0))
if (finalState.iter == -1)
false else
qlSpace.isGoal(finalState.state)
}
}
给出一组策略和训练覆盖率后,让我们获取训练后的模型:
private[this] val model: Option[QLModel] = train
请注意,上述模型是通过用于训练 Q 学习算法的输入数据(参见类QLPolicy
)和内联方法getInput()
进行训练的:
def getInput: Seq[QLInput] = qlPolicy.input
现在我们需要执行一个在期权交易应用中将会用到的重要步骤。因此,我们需要将 Q 学习的模型作为一个选项进行检索:
@inline
finaldef getModel: Option[QLModel] = model
如果模型未定义,则整体应用程序会失败(参见validateConstraints()
方法进行验证):
@inline
finaldef isModel: Boolean = model.isDefined
override def toString: String = qlPolicy.toString + qlSpace.toString
然后,使用 Scala 尾递归执行下一最有回报的状态的递归计算。其思路是在所有状态中搜索,并递归选择为最佳策略给予最多奖励的状态。
@scala.annotation.tailrec
private def nextState(iSt: QLIndexedState[T]): QLIndexedState[T] = {
val states = qlSpace.nextStates(iSt.state)
if (states.isEmpty || iSt.iter >= conf.episodeLength)
iSt
else {
val fromId = iSt.state.id
val qState = states.maxBy(s => model.map(_.bestPolicy.EQ(fromId, s.id)).getOrElse(-1.0))
nextState(QLIndexedStateT)
}
}
在上述代码块中,nextState()
方法检索可以从当前状态转移到的合适状态。然后,它通过递增迭代计数器来提取具有最高回报策略的状态qState
。最后,如果没有更多状态或方法未在由config.episodeLength
参数提供的最大迭代次数内收敛,它将返回状态。
尾递归:在 Scala 中,尾递归是一种非常有效的结构,用于对集合中的每个项应用操作。它在递归过程中优化了函数栈帧的管理。注解触发了编译器优化函数调用所需的条件验证。
最后,Q 学习算法的配置QLConfig
指定:
-
学习率,
alpha
-
折扣率,
gamma
-
一个回合的最大状态数(或长度),
episodeLength
-
训练中使用的回合数(或迭代次数),
numEpisodes
-
选择最佳策略所需的最小覆盖率,
minCoverage
这些内容如下所示:
case class QLConfig(alpha: Double,gamma: Double,episodeLength: Int,numEpisodes: Int,minCoverage: Double)
extends Config {
import QLConfig._check(alpha, gamma, episodeLength, numEpisodes, minCoverage)}
现在我们几乎完成了,除了验证尚未完成。然而,让我们先看一下 Q 学习算法配置的伴生对象。此单例定义了QLConfig
类的构造函数,并验证其参数:
private[scalaml] object QLConfig {
private val NO_MIN_COVERAGE = 0.0
private val MAX_EPISODES = 1000
private def check(alpha: Double,gamma: Double,
episodeLength: Int,numEpisodes: Int,
minCoverage: Double): Unit = {
require(alpha > 0.0 && alpha < 1.0,s"QLConfig found alpha: $alpha required
> 0.0 and < 1.0")
require(gamma > 0.0 && gamma < 1.0,s"QLConfig found gamma $gamma required
> 0.0 and < 1.0")
require(numEpisodes > 2 && numEpisodes < MAX_EPISODES,s"QLConfig found
$numEpisodes $numEpisodes required > 2 and < $MAX_EPISODES")
require(minCoverage >= 0.0 && minCoverage <= 1.0,s"QLConfig found $minCoverage
$minCoverage required > 0 and <= 1.0")
}
太棒了!我们已经看到了如何在 Scala 中实现 QLearning
算法。然而,正如我所说,实施是基于公开的来源,训练可能并不总是收敛。对于这种在线模型,一个重要的考虑因素是验证。商业应用(或甚至是我们将在下一节讨论的高大上的 Scala Web 应用)可能需要多种验证机制,涉及状态转换、奖励、概率和 Q 值矩阵。
QLearning 模型验证
一个关键的验证是确保用户定义的约束函数不会在 Q-learning 的搜索或训练中产生死锁。约束函数确定了从给定状态通过行动可以访问的状态列表。如果约束过于严格,一些可能的搜索路径可能无法到达目标状态。下面是对约束函数的一个简单验证:
def validateConstraints(numStates: Int, constraint: Int => List[Int]): Boolean = {require(numStates > 1, s"QLearning validateConstraints found $numStates states should be >1")!Range(0,
numStates).exists(constraint(_).isEmpty)
}
使用训练好的模型进行预测
现在我们可以递归地选择给定最佳策略的最多奖励的状态(参见下面代码中的nextState
方法),例如,可以对期权交易执行 Q-learning 算法的在线训练。
因此,一旦 Q-learning 模型使用提供的数据进行了训练,下一状态就可以通过覆盖数据转换方法(PipeOperator
,即|
)来使用 Q-learning 模型进行预测,转换为预测的目标状态:
override def |> : PartialFunction[QLState[T], Try[QLState[T]]] = {
case st: QLState[T]
if isModel =>
Try(
if (st.isGoal) st
else nextState(QLIndexedStateT).state)
}
我想这已经够多了,虽然评估模型会很好。但是,在真实数据集上进行评估会更好,因为在假数据上运行和评估模型的表现,就像是买了辆新车却从未开过。因此,我想结束实现部分,继续进行基于这个 Q-learning 实现的期权交易应用。
使用 Q-learning 开发期权交易 Web 应用
交易算法是利用计算机编程,按照定义的一组指令执行交易,以生成人类交易员无法匹敌的速度和频率的利润。定义的规则集基于时机、价格、数量或任何数学模型。
问题描述
通过这个项目,我们将根据当前一组从到期时间、证券价格和波动性派生的观察特征,预测期权在未来N天的价格。问题是:我们应该使用什么模型来进行这种期权定价?答案是,实际上有很多模型;其中 Black-Scholes 随机偏微分方程(PDE)是最为人熟知的。
在数学金融学中,Black-Scholes 方程是必然的偏微分方程,它覆盖了欧式看涨期权或欧式看跌期权在 Black-Scholes 模型下的价格演变。对于不支付股息的标的股票的欧式看涨期权或看跌期权,方程为:
其中 V 表示期权价格,是股票价格 S 和时间 t 的函数,r 是无风险利率,σ 是股票的波动率。方程背后的一个关键金融洞察是,任何人都可以通过正确的方式买卖标的资产来完美对冲期权而不承担任何风险。这种对冲意味着只有一个正确的期权价格,由 Black-Scholes 公式返回。
考虑一种行使价格为 $95 的 IBM 一月到期期权。你写出一种行使价格为 $85 的 IBM 一月看跌期权。让我们考虑和关注给定安全性 IBM 的看涨期权。下图绘制了 2014 年 5 月 IBM 股票及其衍生看涨期权的每日价格,行使价格为 $190:
图 7:2013 年 5 月至 10 月期间 IBM 股票和行使价格为 $190 的看涨期权定价
现在,如果 IBM 在期权到期日以 $87 出售,这个头寸的盈亏将是多少?或者,如果 IBM 以 $100 出售呢?嗯,计算或预测答案并不容易。然而,在期权交易中,期权价格取决于一些参数,如时间衰减、价格和波动率:
-
期权到期时间(时间衰减)
-
标的证券的价格
-
标的资产收益的波动率
定价模型通常不考虑标的证券的交易量变化。因此,一些研究人员将其纳入期权交易模型中。正如我们所描述的,任何基于强化学习的算法应该具有显式状态(或状态),因此让我们使用以下四个归一化特征定义期权的状态:
-
时间衰减 (
timeToExp
):这是归一化后的到期时间在 (0, 1) 范围内。 -
相对波动性 (
volatility
):在一个交易会话内,这是标的证券价格相对变化的相对值。这与 Black-Scholes 模型中定义的更复杂收益波动性不同。 -
波动性相对于成交量 (
vltyByVol
):这是调整后的标的证券价格相对于其成交量的相对波动性。 -
当前价格与行权价格之间的相对差异 (
priceToStrike
):这衡量的是价格与行权价格之间差异与行权价格的比率。
下图显示了可以用于 IBM 期权策略的四个归一化特征:
图 8:IBM 股票的归一化相对股价波动性、相对于交易量的波动性以及相对于行权价格的股价
现在让我们看看股票和期权价格的数据集。有两个文件,IBM.csv
和IBM_O.csv
,分别包含 IBM 股票价格和期权价格。股票价格数据集包含日期、开盘价、最高价、最低价、收盘价、交易量和调整后的收盘价。数据集的一部分如下图所示:
图 9:IBM 股票数据
另一方面,IBM_O.csv
包含了 127 个 IBM 190 Oct 18, 2014 的期权价格。其中几个值为 1.41、2.24、2.42、2.78、3.46、4.11、4.51、4.92、5.41、6.01 等。到此为止,我们能否利用QLearning
算法开发一个预测模型,帮助我们回答之前提到的问题:它能告诉我们如何通过利用所有可用特征帮助 IBM 实现最大利润吗?
好的,我们知道如何实现QLearning
,也知道什么是期权交易。另一个好处是,本项目将使用的技术,如 Scala、Akka、Scala Play 框架和 RESTful 服务,已经在第三章《从历史数据中进行高频比特币价格预测》中进行了讨论。因此,可能是可行的。接下来我们尝试开发一个 Scala Web 项目,帮助我们最大化利润。
实现期权交易 Web 应用程序
本项目的目标是创建一个期权交易的 Web 应用程序,该程序从 IBM 股票数据中创建一个 QLearning 模型。然后,应用程序将从模型中提取输出作为 JSON 对象,并将结果显示给用户。图 10显示了整体工作流程:
图 10:期权交易 Scala Web 的工作流程
计算 API 为 Q-learning 算法准备输入数据,算法通过从文件中提取数据来构建期权模型。然后,它对数据进行归一化和离散化等操作。所有这些数据都传递给 Q-learning 算法以训练模型。之后,计算 API 从算法中获取模型,提取最佳策略数据,并将其放入 JSON 中返回给 Web 浏览器。期权交易策略的实现,使用 Q-learning 包含以下几个步骤:
-
描述期权的属性
-
定义函数近似
-
指定状态转换的约束条件
创建一个期权属性
考虑到市场波动性,我们需要更现实一点,因为任何长期预测都相当不可靠。原因是它将超出离散马尔科夫模型的约束。因此,假设我们想预测未来两天的价格——即 N= 2。这意味着期权未来两天的价格是利润或损失的奖励值。那么,让我们封装以下四个参数:
-
timeToExp
:期权到期前剩余时间,占期权整体持续时间的百分比 -
波动性标准化:给定交易时段内,基础证券的相对波动性
-
vltyByVol
:在给定交易时段内,相对于该时段交易量的基础证券波动性 -
priceToStrike
:相对于行使价的基础证券价格,在给定交易时段内
OptionProperty
类定义了一个交易期权的属性。构造函数为期权创建属性:
class OptionProperty(timeToExp: Double,volatility: Double,vltyByVol: Double,priceToStrike: Double) {
val toArray = ArrayDouble
require(timeToExp > 0.01, s"OptionProperty time to expiration found $timeToExp required 0.01")
}
创建一个期权模型
现在,我们需要创建一个 OptionModel
来充当期权属性的容器和工厂。它接受以下参数,并通过访问之前描述的四个特征的数据源,创建一个期权属性列表 propsList
:
-
证券的符号。
-
option
的行使价格,strikePrice
。 -
data
的来源,src
。 -
最小时间衰减或到期时间,
minTDecay
。期权价值低于行使价的期权会变得毫无价值,而价值高于行使价的期权在接近到期时价格行为差异很大。因此,期权到期日前的最后minTDecay
个交易时段不会参与训练过程。 -
用于近似每个特征值的步数(或桶数),
nSteps
。例如,四步近似会创建四个桶:(0,25),(25,50),(50,75),和(75,100)。
然后,它会组装 OptionProperties
并计算期权到期的最小标准化时间。接着,它通过将实际价值离散化为多个层次,从期权价格数组中近似计算期权的价值;最后,它返回一个包含期权价格和精度层次的映射。以下是该类的构造函数:
class OptionModel(
symbol: String,
strikePrice: Double,
src: DataSource,
minExpT: Int,
nSteps: Int
)
在这个类的实现中,首先通过 check()
方法进行验证,检查以下内容:
-
strikePrice
:需要一个正的价格 -
minExpT
:此值必须介于 2 和 16 之间 -
nSteps
:至少需要两个步数
以下是调用该方法的示例:
check(strikePrice, minExpT, nSteps)
上述方法的签名如下所示:
def check(strikePrice: Double, minExpT: Int, nSteps: Int): Unit = {
require(strikePrice > 0.0, s"OptionModel.check price found $strikePrice required > 0")
require(minExpT > 2 && minExpT < 16,s"OptionModel.check Minimum expiration time found $minExpT required ]2, 16")
require(nSteps > 1,s"OptionModel.check, number of steps found $nSteps required > 1")
}
一旦满足前述约束条件,期权属性列表 propsList
被创建如下:
val propsList = (for {
price <- src.get(adjClose)
volatility <- src.get(volatility)
nVolatility <- normalize[Double
vltyByVol <- src.get(volatilityByVol)
nVltyByVol <- normalizeDouble
priceToStrike <- normalizeDouble)
}
yield {
nVolatility.zipWithIndex./:(List[OptionProperty]()) {
case (xs, (v, n)) =>
val normDecay = (n + minExpT).toDouble / (price.size + minExpT)
new OptionProperty(normDecay, v, nVltyByVol(n), priceToStrike(n)) :: xs
}
.drop(2).reverse
}).get
在前面的代码块中,工厂使用了zipWithIndex
的 Scala 方法来表示交易会话的索引。所有的特征值都在区间(0, 1)内进行归一化,包括normDecay
期权的时间衰减(或到期时间)。
OptionModel
类的quantize()
方法将每个期权属性的归一化值转换为一个桶索引数组。它返回一个以桶索引数组为键的盈亏映射表:
def quantize(o: Array[Double]): Map[Array[Int], Double] = {
val mapper = new mutable.HashMap[Int, Array[Int]]
val acc: NumericAccumulator[Int] = propsList.view.map(_.toArray)
map(toArrayInt(_)).map(ar => {
val enc = encode(ar)
mapper.put(enc, ar)
enc
})
.zip(o)./:(
new NumericAccumulator[Int]) {
case (_acc, (t, y)) => _acc += (t, y); _acc
}
acc.map {
case (k, (v, w)) => (k, v / w) }
.map {
case (k, v) => (mapper(k), v) }.toMap
}
该方法还创建了一个映射器实例,用于索引桶数组。一个类型为NumericAccumulator
的累加器acc
扩展了Map[Int, (Int, Double)]
,并计算这个元组*(每个桶中特征的出现次数,期权价格的增减总和)*。
toArrayInt
方法将每个期权属性(如timeToExp
、volatility
等)的值转换为相应桶的索引。然后,索引数组被编码以生成一个状态的 id 或索引。该方法更新累加器,记录每个交易会话的期权出现次数及其总盈亏。最后,它通过对每个桶的盈亏进行平均计算每个操作的奖励。encode()
、toArrayInt()
方法的签名如下所示:
private def encode(arr: Array[Int]): Int = arr./:((1, 0)) {
case ((s, t), n) => (s * nSteps, t + s * n) }._2
private def toArrayInt(feature: Array[Double]): Array[Int] = feature.map(x => (nSteps *
x).floor.toInt)
final class NumericAccumulator[T]
extends mutable.HashMap[T, (Int, Double)] {
def +=(key: T, x: Double): Option[(Int, Double)] = {
val newValue =
if (contains(key)) (get(key).get._1 + 1, get(key).get._2 + x)
else (1, x)
super.put(key, newValue)
}
}
最后,也是最重要的,如果前述约束条件得到满足(不过你可以修改这些约束),并且一旦OptionModel
类的实例化成功生成OptionProperty
元素的列表;否则,它将生成一个空列表。
将它们汇总在一起
由于我们已经实现了 Q-learning 算法,我们现在可以使用 Q-learning 开发期权交易应用程序。然而,首先,我们需要使用DataSource
类加载数据(稍后我们将看到其实现)。然后,我们可以为给定股票创建一个期权模型,使用OptionModel
,它定义了一个在证券上交易的期权模型,并设置默认的行权价和最短到期时间参数。然后,我们需要为期权的盈亏模型创建基础证券。
盈亏被调整为产生正值。它实例化了一个 Q-learning 类的实例,即一个实现了 Q-learning 算法的通用参数化类。Q-learning 模型在类实例化时被初始化和训练,因此它可以在运行时进行预测时处于正确的状态。
因此,类的实例只有两种状态:成功训练和失败训练的 Q-learning 值操作。然后模型被返回并处理和可视化。
那么,让我们创建一个 Scala 对象并命名为QLearningMain
。接着,在QLearningMain
对象内部,定义并初始化以下参数:
-
Name
:用于指示强化算法的名称(在我们的例子中是 Q-learning) -
STOCK_PRICES
: 包含股票数据的文件 -
OPTION_PRICES
: 包含可用期权数据的文件 -
STRIKE_PRICE
: 期权行权价格 -
MIN_TIME_EXPIRATION
: 记录的期权最小到期时间 -
QUANTIZATION_STEP
: 用于对证券值进行离散化或近似的步长 -
ALPHA
: Q-learning 算法的学习率 -
DISCOUNT
(gamma):Q-learning 算法的折扣率 -
MAX_EPISODE_LEN
: 每个回合访问的最大状态数 -
NUM_EPISODES
: 训练过程中使用的回合数 -
MIN_COVERAGE
: Q-learning 模型训练过程中允许的最小覆盖率 -
NUM_NEIGHBOR_STATES
: 从任何其他状态可访问的状态数 -
REWARD_TYPE
: 最大奖励或随机
每个参数的初步初始化如下代码所示:
val name: String = "Q-learning"// Files containing the historical prices for the stock and option
val STOCK_PRICES = "/static/IBM.csv"
val OPTION_PRICES = "/static/IBM_O.csv"// Run configuration parameters
val STRIKE_PRICE = 190.0 // Option strike price
val MIN_TIME_EXPIRATION = 6 // Min expiration time for option recorded
val QUANTIZATION_STEP = 32 // Quantization step (Double => Int)
val ALPHA = 0.2 // Learning rate
val DISCOUNT = 0.6 // Discount rate used in Q-Value update equation
val MAX_EPISODE_LEN = 128 // Max number of iteration for an episode
val NUM_EPISODES = 20 // Number of episodes used for training.
val NUM_NEIGHBHBOR_STATES = 3 // No. of states from any other state
现在,run()
方法接受作为输入的奖励类型(在我们的例子中是最大奖励
)、量化步长(在我们的例子中是QUANTIZATION_STEP
)、alpha(学习率,在我们的例子中是ALPHA
)和 gamma(在我们的例子中是DISCOUNT
,Q-learning 算法的折扣率)。它显示了模型中的值分布。此外,它在散点图上显示了最佳策略的估计 Q 值(我们稍后会看到)。以下是前述方法的工作流程:
-
首先,它从
IBM.csv
文件中提取股票价格 -
然后它使用股票价格和量化方法
quantizeR
创建一个选项模型createOptionModel
(有关更多信息,请参见quantize
方法和稍后的主方法调用) -
期权价格从
IBM_o.csv
文件中提取 -
然后,使用期权模型创建另一个模型
model
,并使用期权价格oPrices
对其进行评估 -
最后,估计的 Q 值(即,Q 值 = 值 * 概率)在散点图上显示,使用
display
方法
通过结合前述步骤,以下是run()
方法的签名:
private def run(rewardType: String,quantizeR: Int,alpha: Double,gamma: Double): Int = {
val sPath = getClass.getResource(STOCK_PRICES).getPath
val src = DataSource(sPath, false, false, 1).get
val option = createOptionModel(src, quantizeR)
val oPricesSrc = DataSource(OPTION_PRICES, false, false, 1).get
val oPrices = oPricesSrc.extract.get
val model = createModel(option, oPrices, alpha, gamma)model.map(m => {if (rewardType != "Random")
display(m.bestPolicy.EQ,m.toString,s"$rewardType with quantization order
$quantizeR")1}).getOrElse(-1)
}
现在,这是createOptionModel()
方法的签名,该方法使用(请参见OptionModel
类)创建一个期权模型:
private def createOptionModel(src: DataSource, quantizeR: Int): OptionModel =
new OptionModel("IBM", STRIKE_PRICE, src, MIN_TIME_EXPIRATION, quantizeR)
接着,createModel()
方法创建一个期权的利润和亏损模型,给定基础证券。请注意,期权价格是使用之前定义的quantize()
方法量化的。然后,使用约束方法限制给定状态下可用的动作数。这个简单的实现计算了该状态范围内的所有状态列表。然后,它确定了一个预定义半径内的邻接状态。
最后,它使用输入数据训练 Q-learning 模型,计算最小的利润值和亏损值,以便最大亏损被转化为零利润。请注意,利润和亏损被调整为正值。现在让我们看看此方法的签名:
def createModel(ibmOption: OptionModel,oPrice: Seq[Double],alpha: Double,gamma: Double): Try[QLModel] = {
val qPriceMap = ibmOption.quantize(oPrice.toArray)
val numStates = qPriceMap.size
val neighbors = (n: Int) => {
def getProximity(idx: Int, radius: Int): List[Int] = {
val idx_max =
if (idx + radius >= numStates) numStates - 1
else idx + radius
val idx_min =
if (idx < radius) 0
else idx - radiusRange(idx_min, idx_max + 1).filter(_ != idx)./:(List[Int]())((xs, n) => n :: xs)}getProximity(n, NUM_NEIGHBHBOR_STATES)
}
val qPrice: DblVec = qPriceMap.values.toVector
val profit: DblVec = normalize(zipWithShift(qPrice, 1).map {
case (x, y) => y - x}).get
val maxProfitIndex = profit.zipWithIndex.maxBy(_._1)._2
val reward = (x: Double, y: Double) => Math.exp(30.0 * (y - x))
val probabilities = (x: Double, y: Double) =>
if (y < 0.3 * x) 0.0
else 1.0println(s"$name Goal state index: $maxProfitIndex")
if (!QLearning.validateConstraints(profit.size, neighbors))
thrownew IllegalStateException("QLearningEval Incorrect states transition constraint")
val instances = qPriceMap.keySet.toSeq.drop(1)
val config = QLConfig(alpha, gamma, MAX_EPISODE_LEN, NUM_EPISODES, 0.1)
val qLearning = QLearning[Array[Int]](config,ArrayInt,profit,reward,probabilities,instances,Some(neighbors)) val modelO = qLearning.getModel
if (modelO.isDefined) {
val numTransitions = numStates * (numStates - 1)println(s"$name Coverage ${modelO.get.coverage} for $numStates states and $numTransitions transitions")
val profile = qLearning.dumpprintln(s"$name Execution profilen$profile")display(qLearning)Success(modelO.get)}
else Failure(new IllegalStateException(s"$name model undefined"))
}
请注意,如果前面的调用无法创建一个选项模型,代码不会显示模型创建失败的消息。尽管如此,请记住,考虑到我们使用的小数据集,接下来的这一行中使用的minCoverage
非常重要(因为算法会非常快速地收敛):
val config = QLConfig(alpha, gamma, MAX_EPISODE_LEN, NUM_EPISODES, 0.0)
尽管我们已经说明模型的创建和训练未必成功,但一个简单的线索是使用一个非常小的minCoverage
值,范围在0.0
到0.22
之间。如果前面的调用成功,那么模型已训练完成,可以进行预测。如果成功,那么就可以使用显示方法,在散点图中显示估算的Q 值 = 值 * 概率。方法的签名如下:
private def display(eq: Vector[DblPair],results: String,params: String): Unit = {
import org.scalaml.plots.{ScatterPlot, BlackPlotTheme, Legend}
val labels = Legend(name, s"Q-learning config: $params", "States", "States")ScatterPlot.display(eq,
labels, new BlackPlotTheme)
}
稍等一下,不要急!我们终于准备好查看一个简单的rn
并检查结果。让我们来看看:
def main(args: Array[String]): Unit = {
run("Maximum reward",QUANTIZATION_STEP, ALPHA, DISCOUNT)
}
>>>
Action: state 71 => state 74
Action: state 71 => state 73
Action: state 71 => state 72
Action: state 71 => state 70
Action: state 71 => state 69
Action: state 71 => state 68...Instance: I@1f021e6c - state: 124
Action: state 124 => state 125
Action: state 124 => state 123
Action: state 124 => state 122
Action: state 124 => state 121Q-learning Coverage 0.1 for 126 states and 15750 transitions
Q-learning Execution profile
Q-Value -> 5.572310105096295, 0.013869013819834967, 4.5746487300071825, 0.4037703812585325, 0.17606260549479869, 0.09205272504875522, 0.023205692430068765, 0.06363082458984902, 50.405283888218435... 6.5530411130514015
Model: Success(Optimal policy: Reward - 1.00,204.28,115.57,6.05,637.58,71.99,12.34,0.10,4939.71,521.30,402.73, with coverage: 0.1)
评估模型
上述输出显示了从一个状态到另一个状态的转变,对于0.1的覆盖率,QLearning
模型在 126 个状态中有 15,750 次转变,最终达到了目标状态 37,且获得了最优奖励。因此,训练集相当小,只有少数桶包含实际值。所以我们可以理解,训练集的大小对状态的数量有影响。对于一个小的训练集(比如我们这个例子中的情况),QLearning
会收敛得太快。
然而,对于更大的训练集,QLearning
需要一定的时间才能收敛;它会为每个由近似生成的桶提供至少一个值。同时,通过查看这些值,很难理解 Q 值和状态之间的关系。
那么,如果我们能看到每个状态的 Q 值呢?当然可以!我们可以在散点图中看到它们:
图 12:Q-learning 训练过程中,不同周期的对数 Q 值曲线
上述图表说明了每个轮次中的 Q 值与训练的顺序无关。然而,达到目标状态所需的迭代次数则取决于在此示例中随机选择的初始状态。为了获得更多的见解,请检查编辑器中的输出,或者访问 API 端点http://localhost:9000/api/compute
(见下文)。那么,如果我们在模型中显示值的分布,并在散点图中展示给定配置参数下最佳策略的估算 Q 值会怎么样呢?
图 13:在量化 32 的情况下,QLearning 的最大奖励
最终评估包括评估学习率和折扣率对训练覆盖率的影响:
图 14:学习率和折扣率对训练覆盖率的影响
随着学习率的增加,覆盖率降低。这个结果验证了使用学习率 < 0.2的普遍规律。为了评估折扣率对覆盖率的影响,进行的类似测试并没有得出明确结论。我们可能会有成千上万种这种配置参数的不同选择和组合。那么,如果我们能将整个应用程序包装成类似于我们在第三章中做的那样的 Scala Web 应用程序——基于历史数据的高频比特币价格预测,会怎么样呢?我猜这应该不会是个坏主意。那么让我们深入研究一下吧。
将期权交易应用程序封装为 Scala Web 应用程序
这个想法是获取训练好的模型并构建最佳策略的 JSON 输出,以便得到最大回报的情况。PlayML
是一个 Web 应用程序,使用期权交易 Q-learning 算法,提供一个计算 API 端点,接收输入数据集和一些选项来计算 q 值,并以 JSON 格式返回这些值,以便在前端进行建模。
封装后的 Scala Web ML 应用程序具有以下目录结构:
图 15:Scala ML Web 应用程序目录结构
在前面的结构中,应用程序文件夹包含了原始的 QLearning 实现(见ml
文件夹)以及一些额外的后端代码。controller
子文件夹中有一个名为API.scala
的 Scala 类,它作为 Scala 控制器,用于控制前端的模型行为。最后,Filters.scala
作为DefaultHttpFilters
起作用:
图 16:ml 目录结构
conf
文件夹包含 Scala Web 应用程序的配置文件application.conf
,其中包含必要的配置。所有的依赖项都在build.sbt
文件中定义,如下所示:
name := "PlayML"version := "1.0"
lazy val `playml` = (project in file(".")).enablePlugins(PlayScala)
resolvers += "scalaz-bintray"
scalaVersion := "2.11.11"
libraryDependencies ++= Seq(filters, cache, ws, "org.apache.commons" % "commons-math3" %
"3.6","com.typesafe.play" %% "play-json" % "2.5",
"org.jfree" % "jfreechart" % "1.0.17",
"com.typesafe.akka" %% "akka-actor" % "2.3.8",
"org.apache.spark" %% "spark-core" % "2.1.0",
"org.apache.spark" %% "spark-mllib" % "2.1.0",
"org.apache.spark" %% "spark-streaming" % "2.1.0")
lib
文件夹包含一些作为外部依赖项的.jar
文件,这些依赖项在build.sbt
文件中定义。public
文件夹包含 UI 中使用的静态页面。此外,数据文件IBM.csv
和IBM_O.csv
也存放在其中。最后,target
文件夹保存打包后的应用程序(如果有的话)。
后端
在后端,我封装了前面提到的 Q-learning 实现,并额外创建了一个 Scala 控制器,来控制前端模型的行为。其结构如下:
import java.nio.file.Paths
import org.codehaus.janino.Java
import ml.stats.TSeries.{normalize, zipWithShift}
import ml.workflow.data.DataSource
import ml.trading.OptionModel
import ml.Predef.{DblPair, DblVec}
import ml.reinforcement.qlearning.{QLConfig, QLModel, QLearning}
import scala.util.{Failure, Success, Try}
import play.api._
import play.api.data.Form
import play.api.libs.json._
import play.api.mvc._
import scala.util.{Failure, Success, Try}
class API extends Controller {
protected val name: String = "Q-learning"
private var sPath = Paths.get((s"${"public/data/IBM.csv"}")).toAbsolutePath.toString
private var oPath = Paths.get((s"${"public/data/IBM_O.csv"}")).toAbsolutePath.toString
// Run configuration parameters
private var STRIKE_PRICE = 190.0 // Option strike price
private var MIN_TIME_EXPIRATION = 6 // Minimum expiration time for the option recorded
private var QUANTIZATION_STEP = 32 // Quantization step (Double => Int)
private var ALPHA = 0.2 // Learning rate
private var DISCOUNT = 0.6 // Discount rate used in the Q-Value update equation
private var MAX_EPISODE_LEN = 128 // Maximum number of iteration for an episode
private var NUM_EPISODES = 20 // Number of episodes used for training.
private var MIN_COVERAGE = 0.1
private var NUM_NEIGHBOR_STATES = 3 // Number of states accessible from any other state
private var REWARD_TYPE = "Maximum reward"
private var ret = JsObject(Seq())
private var retry = 0
private def run(REWARD_TYPE: String,quantizeR: Int,alpha: Double,gamma: Double) = {
val maybeModel = createModel(createOptionModel(DataSource(sPath, false, false, 1).get, quantizeR), DataSource(oPath, false, false, 1).get.extract.get, alpha, gamma)
if (maybeModel != None) {
val model = maybeModel.get
if (REWARD_TYPE != "Random") {
var value = JsArray(Seq())
var x = model.bestPolicy.EQ.distinct.map(x => {value = value.append(JsObject(Seq("x" -> JsNumber(x._1), "y" -> JsNumber(x._2))))})ret = ret.+("OPTIMAL", value)
}
}
}
/** Create an option model for a given stock with default strike and minimum expiration time parameters.
*/
privatedef createOptionModel(src: DataSource, quantizeR: Int): OptionModel =
new OptionModel("IBM", STRIKE_PRICE, src, MIN_TIME_EXPIRATION, quantizeR)
/** Create a model for the profit and loss on an option given
* the underlying security. The profit and loss is adjusted to
* produce positive values.
*/
privatedef createModel(ibmOption: OptionModel,oPrice: Seq[Double],alpha: Double,gamma: Double): Option[QLModel] = {
val qPriceMap = ibmOption.quantize(oPrice.toArray)
val numStates = qPriceMap.size
val neighbors = (n: Int) => {
def getProximity(idx: Int, radius: Int): List[Int] = {
val idx_max = if (idx + radius >= numStates) numStates - 1
else idx + radius
val idx_min = if (idx < radius) 0
else idx - radiusscala.collection.immutable.Range(idx_min, idx_max + 1)
.filter(_ != idx)./:(List[Int]())((xs, n) => n :: xs)
}
getProximity(n, NUM_NEIGHBOR_STATES)
}
// Compute the minimum value for the profit, loss so the maximum loss is converted to a null profit
val qPrice: DblVec = qPriceMap.values.toVector
val profit: DblVec = normalize(zipWithShift(qPrice, 1).map {
case (x, y) => y - x }).get
val maxProfitIndex = profit.zipWithIndex.maxBy(_._1)._2
val reward = (x: Double, y: Double) => Math.exp(30.0 * (y - x))
val probabilities = (x: Double, y: Double) =>
if (y < 0.3 * x) 0.0 else 1.0ret = ret.+("GOAL_STATE_INDEX", JsNumber(maxProfitIndex))
if (!QLearning.validateConstraints(profit.size, neighbors)) {ret = ret.+("error", JsString("QLearningEval Incorrect states transition constraint"))
thrownew IllegalStateException("QLearningEval Incorrect states transition constraint")}
val instances = qPriceMap.keySet.toSeq.drop(1)
val config = QLConfig(alpha, gamma, MAX_EPISODE_LEN, NUM_EPISODES, MIN_COVERAGE)
val qLearning = QLearning[Array[Int]](config,Array[Int]
(maxProfitIndex),profit,reward,probabilities,instances,Some(neighbors))
val modelO = qLearning.getModel
if (modelO.isDefined) {
val numTransitions = numStates * (numStates - 1)ret = ret.+("COVERAGE",
JsNumber(modelO.get.coverage))ret = ret.+("COVERAGE_STATES", JsNumber(numStates))
ret = ret.+("COVERAGE_TRANSITIONS", JsNumber(numTransitions))
var value = JsArray()
var x = qLearning._counters.last._2.distinct.map(x => {value = value.append(JsNumber(x))
})
ret = ret.+("Q_VALUE", value)modelO
}
else {
if (retry > 5) {ret = ret.+("error", JsString(s"$name model undefined"))
return None
}
retry += 1Thread.sleep(500)
return createModel(ibmOption,oPrice,alpha,gamma)
}
}
def compute = Action(parse.anyContent) { request =>
try {
if (request.body.asMultipartFormData != None) {
val formData = request.body.asMultipartFormData.get
if (formData.file("STOCK_PRICES").nonEmpty && formData.file("STOCK_PRICES").get.filename.nonEmpty)sPath = formData.file("STOCK_PRICES").get.ref.file.toString
if (formData.file("OPTION_PRICES").nonEmpty && formData.file("OPTION_PRICES").get.filename.nonEmpty)oPath = formData.file("OPTION_PRICES").get.ref.file.toString
val parts = formData.dataParts
if (parts.get("STRIKE_PRICE") != None)STRIKE_PRICE = parts.get("STRIKE_PRICE").get.mkString("").toDouble
if (parts.get("MIN_TIME_EXPIRATION") != None)MIN_TIME_EXPIRATION = parts.get("MIN_TIME_EXPIRATION").get.mkString("").toInt
if (parts.get("QUANTIZATION_STEP") != None)QUANTIZATION_STEP = parts.get("QUANTIZATION_STEP").get.mkString("").toInt
if (parts.get("ALPHA") != None)ALPHA = parts.get("ALPHA").get.mkString("").toDouble
if (parts.get("DISCOUNT") != None)DISCOUNT = parts.get("DISCOUNT").get.mkString("").toDouble
if (parts.get("MAX_EPISODE_LEN") != None)MAX_EPISODE_LEN = parts.get("MAX_EPISODE_LEN").get.mkString("").toInt
if (parts.get("NUM_EPISODES") != None)NUM_EPISODES = parts.get("NUM_EPISODES").get.mkString("").toInt
if (parts.get("MIN_COVERAGE") != None)MIN_COVERAGE = parts.get("MIN_COVERAGE").get.mkString("").toDouble
if (parts.get("NUM_NEIGHBOR_STATES") != None)NUM_NEIGHBOR_STATES = parts.get("NUM_NEIGHBOR_STATES").get.mkString("").toInt
if (parts.get("REWARD_TYPE") != None)REWARD_TYPE = parts.get("REWARD_TYPE").get.mkString("")
}
ret = JsObject(Seq("STRIKE_PRICE" ->
JsNumber(STRIKE_PRICE),"MIN_TIME_EXPIRATION" -> JsNumber(MIN_TIME_EXPIRATION),
"QUANTIZATION_STEP" ->
JsNumber(QUANTIZATION_STEP),
"ALPHA" -> JsNumber(ALPHA),
"DISCOUNT" -> JsNumber(DISCOUNT),
"MAX_EPISODE_LEN" ->
JsNumber(MAX_EPISODE_LEN),
"NUM_EPISODES" -> JsNumber(NUM_EPISODES),
"MIN_COVERAGE" -> JsNumber(MIN_COVERAGE),
"NUM_NEIGHBOR_STATES" ->
JsNumber(NUM_NEIGHBOR_STATES),
"REWARD_TYPE" -> JsString(REWARD_TYPE)))
run(REWARD_TYPE, QUANTIZATION_STEP, ALPHA, DISCOUNT)
}
catch {
case e: Exception => {
ret = ret.+("exception", JsString(e.toString))
}
}
Ok(ret)
}
}
仔细查看前面的代码,它与QLearningMain.scala
文件的结构差不多。这里只有两件重要的事,如下所示:
-
计算作为一个 Action 进行,该 Action 接收来自 UI 的输入并计算结果值
-
然后,结果作为 JSON 对象通过
JsObject()
方法返回,用于在 UI 上显示(见下文)
前端
该应用由两个主要部分组成:API 端点,使用 Play 框架构建,以及前端单页面应用,使用Angular.js
构建。前端应用将数据发送到 API 进行计算,然后使用chart.js
展示结果。我们需要的步骤如下:
-
初始化表单
-
与 API 通信
-
用覆盖数据和图表填充视图
算法的 JSON 输出应如下所示:
-
所有的配置参数都会被返回
-
GOAL_STATE_INDEX
,最大利润指数 -
COVERAGE
,达到预定义目标状态的训练试验或周期的比率 -
COVERAGE_STATES
,量化期权值的大小 -
COVERAGE_TRANSITIONS
,状态的平方数 -
Q_VALUE
,所有状态的 q 值 -
OPTIMAL
,如果奖励类型不是随机的,返回最多奖励的状态
前端代码使用如下代码初始化Angular.js
应用,并集成chart.js
模块(见PlayML/public/assets/js/main.js
文件):
angular.module("App", ['chart.js']).controller("Ctrl", ['$scope', '$http', function ($scope, $http) {
// First we initialize the form:
$scope.form = {REWARD_TYPE: "Maximum reward",NUM_NEIGHBOR_STATES: 3,STRIKE_PRICE: 190.0,MIN_TIME_EXPIRATION: 6,QUANTIZATION_STEP: 32,ALPHA: 0.2,DISCOUNT: 0.6,MAX_EPISODE_LEN: 128,NUM_EPISODES: 20,MIN_COVERAGE: 0.1
};
然后,运行按钮的操作准备表单数据并将其发送到 API,接着将返回的数据传递给结果变量,在前端使用。接下来,它会清除图表并重新创建;如果找到最优解,则初始化最优图表。最后,如果找到了 Q 值,则初始化 Q 值图表:
$scope.run = function () {
var formData = new FormData(document.getElementById('form'));
$http.post('/api/compute', formData, {
headers: {'Content-Type': undefined}}).then(function successCallback(response) {
$scope.result = response.data;
$('#canvasContainer').html('');
if (response.data.OPTIMAL) {
$('#canvasContainer').append('<canvas id="optimalCanvas"></canvas>')
Chart.Scatter(document.getElementById("optimalCanvas").getContext("2d"), {data: { datasets: [{data: response.data.OPTIMAL}] }, options: {...}});}if (response.data.Q_VALUE) {
$('#canvasContainer').append('<canvas id="valuesCanvas"></canvas>')
Chart.Line(document.getElementById("valuesCanvas").getContext("2d"), {
data: { labels: new Array(response.data.Q_VALUE.length), datasets: [{
data: response.data.Q_VALUE }] }, options: {...}});}});}}]
);
上述前端代码随后嵌入到 HTML 中(见PlayML/public/index.html
),使 UI 能够作为一个精美的应用通过 Web 在http://localhost:9000/
访问。根据您的需求,您可以随意编辑内容。我们很快会看到详细信息。
运行和部署说明
正如在第三章《从历史数据中预测高频比特币价格》中已经提到的,您需要 Java 1.8+和 SBT 作为依赖。然后按照以下说明操作:
-
下载应用。我将代码命名为
PlayML.zip
。 -
解压文件后,您将得到一个文件夹
ScalaML
。 -
转到 PlayML 项目文件夹。
-
运行
$ sudo sbt run
来下载所有依赖并运行应用。
然后可以通过http://localhost:9000/
访问该应用,在这里我们可以上传 IBM 的股票和期权价格,并且提供其他配置参数:
图 17:使用 QLearning 进行期权交易的 UI
现在,如果您上传股票价格和期权价格数据并点击运行按钮,系统将生成如下图表:
图 18:QLearning 以 0.2 的覆盖率在 126 个状态和 15,750 次转换中达到了目标状态 81
另一方面,API 端点可以通过localhost:9000/api/compute
进行访问。
图 19:API 端点(简化版)
模型部署
您可以通过将应用程序的 HTTP 端口设置为 9000 来轻松地将应用程序部署为独立服务器,例如:
$ /path/to/bin/<project-name> -Dhttp.port=9000
请注意,您可能需要根权限才能将进程绑定到此端口。以下是一个简短的工作流程:
-
运行
$ sudo sbt dist
来构建应用程序二进制文件。输出可以在PlayML /target/universal/APP-NAME-SNAPSHOT.zip
找到。在我们的案例中,它是playml-1.0.zip
。 -
现在,要运行该应用程序,解压文件,然后在
bin
目录中运行脚本:
$ unzip APP-NAME-SNAPSHOT.zip$ APP-NAME-SNAPSHOT /bin/ APP-NAME -Dhttp.port=9000
然后,您需要配置您的 web 服务器,以映射到应用程序的端口配置。不过,您可以通过将应用程序的 HTTP 端口设置为9000
,轻松将应用程序部署为独立服务器:
$ /path/to/bin/<project-name> -Dhttp.port=9000
然而,如果您打算在同一服务器上托管多个应用程序,或为了可扩展性或容错性而对多个应用程序实例进行负载均衡,您可以使用前端 HTTP 服务器。请注意,使用前端 HTTP 服务器通常不会比直接使用 Play 服务器提供更好的性能。
然而,HTTP 服务器非常擅长处理 HTTPS、条件 GET 请求和静态资源,许多服务假设前端 HTTP 服务器是您架构的一部分。更多信息可以在www.playframework.com/documentation/2.6.x/HTTPServer
中找到。
总结
本章中,我们学习了如何使用 Q-learning 算法开发一个名为“期权交易”的真实应用程序。我们使用了 IBM 股票数据集来设计一个由批评和奖励驱动的机器学习系统。此外,我们还学习了一些理论背景。最后,我们学习了如何使用 Scala Play Framework 将一个 Scala 桌面应用程序打包为 Web 应用,并部署到生产环境中。
在下一章中,我们将看到使用 H2O 在银行营销数据集上构建非常稳健和准确的预测模型的两个示例。在这个例子中,我们将使用银行营销数据集。该数据与葡萄牙银行机构的电话营销活动相关。营销活动是通过电话进行的。该端到端项目的目标是预测客户是否会订阅定期存款。
第八章:使用深度神经网络评估银行电话营销客户订阅情况
本章将展示两个例子,说明如何使用 H2O 在银行营销数据集上构建非常稳健且准确的预测模型进行预测分析。数据与葡萄牙一家银行机构的直接营销活动相关,这些营销活动基于电话进行。这个端到端项目的目标是预测客户是否会订阅定期存款。
本项目将涵盖以下主题:
-
客户订阅评估
-
数据集描述
-
数据集的探索性分析
-
使用 H2O 进行客户订阅评估
-
调整超参数
通过电话营销进行客户订阅评估
一段时间前,由于全球金融危机,银行在国际市场获得信贷的难度加大。这使得银行开始关注内部客户及其存款以筹集资金。这导致了对客户存款行为及其对银行定期电话营销活动响应的需求。通常,为了评估产品(银行定期存款)是否会被(是)或(否)订阅,需要与同一客户进行多次联系。
本项目的目的是实现一个机器学习模型,预测客户是否会订阅定期存款(变量y
)。简而言之,这是一个二分类问题。在开始实现应用之前,我们需要了解数据集。接着,我们将进行数据集的解释性分析。
数据集描述
我想感谢两个数据来源。此数据集曾被 Moro 等人在论文《A Data-Driven Approach to Predict the Success of Bank Telemarketing》中使用,发表在《决策支持系统》期刊(Elsevier,2014 年 6 月)。之后,它被捐赠到 UCI 机器学习库,并可以从archive.ics.uci.edu/ml/datasets/bank+marketing
下载。根据数据集描述,数据集包括四个子集:
-
bank-additional-full.csv
:包含所有示例(41,188 个)和 20 个输入,按日期排序(从 2008 年 5 月到 2010 年 11 月),与 Moro 等人 2014 年分析的数据非常接近 -
bank-additional.csv
:包含 10%的示例(4,119 个),从 1 和 20 个输入中随机选择 -
bank-full.csv
:包含所有示例和 17 个输入,按日期排序(该数据集的旧版本,输入较少) -
bank.csv
:包含 10%的示例和 17 个输入,随机选择自三个输入(该数据集的旧版本,输入较少)
数据集包含 21 个属性。独立变量,即特征,可以进一步分类为与银行客户相关的数据(属性 1 到 7),与本次活动的最后一次联系相关的数据(属性 8 到 11),其他属性(属性 12 到 15),以及社会和经济背景属性(属性 16 到 20)。因变量由y
指定,即最后一个属性(21):
ID | 属性 | 解释 |
---|---|---|
1 | age | 年龄(数值)。 |
2 | job | 这是工作类型的分类格式,可能的值有:admin 、blue-collar 、entrepreneur 、housemaid 、management 、retired 、self-employed 、services 、student 、technician 、unemployed 和unknown 。 |
3 | marital | 这是婚姻状况的分类格式,可能的值有:divorced 、married 、single 和unknown 。其中,divorced 表示离婚或丧偶。 |
4 | education | 这是教育背景的分类格式,可能的值如下:basic.4y 、basic.6y 、basic.9y 、high.school 、illiterate 、professional.course 、university.degree 和unknown 。 |
5 | default | 这是一个分类格式,表示信用是否违约,可能的值为no 、yes 和unknown 。 |
6 | housing | 客户是否有住房贷款? |
7 | loan | 个人贷款的分类格式,可能的值为no 、yes 和unknown 。 |
8 | contact | 这是联系的沟通方式,采用分类格式。可能的值有cellular 和telephone 。 |
9 | month | 这是最后一次联系的月份,采用分类格式,可能的值为jan 、feb 、mar 、…、nov 和dec 。 |
10 | day_of_week | 这是最后一次联系的星期几,采用分类格式,可能的值有mon 、tue 、wed 、thu 和fri 。 |
11 | duration | 这是最后一次联系的持续时间,单位为秒(数值)。这个属性对输出目标有很大影响(例如,如果duration=0 ,则y=no )。然而,持续时间在通话之前是未知的。此外,通话结束后,y 显然已知。因此,只有在基准测试时才应包括此输入,如果目的是建立一个现实的预测模型,则应丢弃此输入。 |
12 | campaign | 这是本次活动中与此客户进行的联系次数。 |
13 | pdays | 这是自上次与客户的前一个活动联系以来经过的天数(数值;999 表示客户之前没有被联系过)。 |
14 | previous | 这是此客户在本次活动之前进行的联系次数(数值)。 |
15 | poutcome | 上一次营销活动的结果(分类:failure 、nonexistent 和success )。 |
16 | emp.var.rate | 就业变化率—季度指标(数值)。 |
17 | cons.price.idx | 消费者价格指数—月度指标(数字)。 |
18 | cons.conf.idx | 消费者信心指数—月度指标(数字)。 |
19 | euribor3m | 欧元区 3 个月利率—每日指标(数字)。 |
20 | nr.employed | 员工人数—季度指标(数字)。 |
21 | y | 表示客户是否订阅了定期存款。其值为二进制(yes 和 no )。 |
表 1:银行营销数据集描述
对于数据集的探索性分析,我们将使用 Apache Zeppelin 和 Spark。我们将首先可视化分类特征的分布,然后是数值特征。最后,我们将计算一些描述数值特征的统计信息。但在此之前,让我们配置 Zeppelin。
安装并开始使用 Apache Zeppelin
Apache Zeppelin 是一个基于 Web 的笔记本,允许您以交互方式进行数据分析。使用 Zeppelin,您可以制作美丽的、数据驱动的、互动的和协作的文档,支持 SQL、Scala 等。Apache Zeppelin 的解释器概念允许将任何语言/数据处理后端插件集成到 Zeppelin 中。目前,Apache Zeppelin 支持许多解释器,如 Apache Spark、Python、JDBC、Markdown 和 Shell。
Apache Zeppelin 是 Apache 软件基金会推出的一项相对较新的技术,它使数据科学家、工程师和从业者能够进行数据探索、可视化、共享和协作,支持多种编程语言的后端(如 Python、Scala、Hive、SparkSQL、Shell、Markdown 等)。由于本书的目标不是使用其他解释器,因此我们将在 Zeppelin 上使用 Spark,所有代码将使用 Scala 编写。因此,在本节中,我们将向您展示如何使用仅包含 Spark 解释器的二进制包配置 Zeppelin。Apache Zeppelin 官方支持并在以下环境中经过测试:
要求 | 值/版本 |
---|---|
Oracle JDK | 1.7+(设置 JAVA_HOME ) |
| 操作系统 | Mac OS X Ubuntu 14.X+
CentOS 6.X+
Windows 7 Pro SP1+ |
如上表所示,执行 Spark 代码需要 Java。因此,如果未设置 Java,请在前述任何平台上安装并配置 Java。可以从 zeppelin.apache.org/download.html
下载 Apache Zeppelin 的最新版本。每个版本都有三种选项:
-
包含所有解释器的二进制包:包含对多个解释器的支持。例如,Zeppelin 目前支持 Spark、JDBC、Pig、Beam、Scio、BigQuery、Python、Livy、HDFS、Alluxio、Hbase、Scalding、Elasticsearch、Angular、Markdown、Shell、Flink、Hive、Tajo、Cassandra、Geode、Ignite、Kylin、Lens、Phoenix 和 PostgreSQL 等。
-
包含 Spark 解释器的二进制包:通常,这仅包含 Spark 解释器。它还包含一个解释器网络安装脚本。
-
Source:你也可以从 GitHub 仓库构建带有所有最新更改的 Zeppelin(稍后详细讲解)。为了向你展示如何安装和配置 Zeppelin,我们从此网站的镜像下载了二进制包。下载后,将其解压到你的机器上的某个位置。假设你解压的路径是
/home/Zeppelin/
。
从源代码构建
你还可以从 GitHub 仓库构建带有所有最新更改的 Zeppelin。如果你想从源代码构建,必须首先安装以下依赖项:
-
Git:任何版本
-
Maven:3.1.x 或更高版本
-
JDK:1.7 或更高版本
如果你还没有安装 Git 和 Maven,可以查看zeppelin.apache.org/docs/latest/install/build.html#build-requirements
中的构建要求。由于页面限制,我们没有详细讨论所有步骤。感兴趣的读者应参考此 URL,获取更多关于 Apache Zeppelin 的信息:zeppelin.apache.org/
。
启动和停止 Apache Zeppelin
在所有类 Unix 平台(如 Ubuntu、Mac 等)上,使用以下命令:
$ bin/zeppelin-daemon.sh start
如果前面的命令成功执行,你应该在终端中看到以下日志:
图 1:从 Ubuntu 终端启动 Zeppelin
如果你使用 Windows,使用以下命令:
$ binzeppelin.cmd
在 Zeppelin 成功启动后,使用你的网页浏览器访问http://localhost:8080
,你将看到 Zeppelin 正在运行。更具体地说,你将在浏览器中看到以下内容:
图 2:Zeppelin 正在http://localhost:8080
上运行
恭喜!你已经成功安装了 Apache Zeppelin!现在,让我们在浏览器中访问 Zeppelin,地址是http://localhost:8080/
,并在配置好首选解释器后开始我们的数据分析。现在,要从命令行停止 Zeppelin,请执行以下命令:
$ bin/zeppelin-daemon.sh stop
创建笔记本
一旦你进入http://localhost:8080/
,你可以探索不同的选项和菜单,帮助你了解如何熟悉 Zeppelin。有关 Zeppelin 及其用户友好界面的更多信息,感兴趣的读者可以参考zeppelin.apache.org/docs/latest/
。现在,首先让我们创建一个示例笔记本并开始使用。如下图所示,你可以通过点击图 2中的“Create new note”选项来创建一个新的笔记本:
图 3:创建示例 Zeppelin 笔记本
如图 3所示,默认解释器被选为 Spark。在下拉列表中,你只会看到 Spark,因为你已下载了仅包含 Spark 的 Zeppelin 二进制包。
数据集的探索性分析
做得好!我们已经能够安装、配置并开始使用 Zeppelin。现在我们开始吧。我们将看到变量与标签之间的关联。首先,我们在 Apache 中加载数据集,如下所示:
val trainDF = spark.read.option("inferSchema", "true")
.format("com.databricks.spark.csv")
.option("delimiter", ";")
.option("header", "true")
.load("data/bank-additional-full.csv")
trainDF.registerTempTable("trainData")
标签分布
我们来看看类别分布。我们将使用 SQL 解释器来进行此操作。在 Zeppelin 笔记本中执行以下 SQL 查询:
%sql select y, count(1) from trainData group by y order by y
>>>
职业分布
现在我们来看看职位名称是否与订阅决策相关:
%sql select job,y, count(1) from trainData group by job, y order by job, y
从图表中可以看到,大多数客户的职位是行政人员、蓝领工人或技术员,而学生和退休客户的*计数(y) / 计数(n)*比率最高。
婚姻分布
婚姻状况与订阅决策有关吗?让我们看看:
%sql select marital,y, count(1) from trainData group by marital,y order by marital,y
>>>
分布显示,订阅与实例数量成比例,而与客户的婚姻状况无关。
教育分布
现在我们来看看教育水平是否与订阅决策有关:
%sql select education,y, count(1) from trainData group by education,y order by education,y
因此,与婚姻状况类似,教育水平并不能揭示关于订阅的任何线索。现在我们继续探索其他变量。
默认分布
我们来检查默认信用是否与订阅决策相关:
%sql select default,y, count(1) from trainData group by default,y order by default,y
该图表显示几乎没有客户有默认信用,而没有默认信用的客户有轻微的订阅比率。
住房分布
现在我们来看看是否拥有住房与订阅决策之间有趣的关联:
%sql select housing,y, count(1) from trainData group by housing,y order by housing,y
以上图表显示住房也不能揭示关于订阅的线索。
贷款分布
现在我们来看看贷款分布:
%sql select loan,y, count(1) from trainData group by loan,y order by loan,y
图表显示大多数客户没有个人贷款,贷款对订阅比率没有影响。
联系方式分布
现在我们来检查联系方式是否与订阅决策有显著关联:
%sql select contact,y, count(1) from trainData group by contact,y order by contact,y
月份分布
这可能听起来有些奇怪,但电话营销的月份与订阅决策可能有显著的关联:
%sql select month,y, count(1) from trainData group by month,y order by month,y
所以,之前的图表显示,在实例较少的月份(例如 12 月、3 月、10 月和 9 月)中,订阅比率最高。
日期分布
现在,星期几与订阅决策之间有何关联:
%sql select day_of_week,y, count(1) from trainData group by day_of_week,y order by day_of_week,y
日期特征呈均匀分布,因此不那么显著。
之前的结果分布
那么,先前的结果及其与订阅决策的关联情况如何呢:
%sql select poutcome,y, count(1) from trainData group by poutcome,y order by poutcome,y
分布显示,来自上次营销活动的成功结果的客户最有可能订阅。同时,这些客户代表了数据集中的少数。
年龄特征
让我们看看年龄与订阅决策的关系:
%sql select age,y, count(1) from trainData group by age,y order by age,y
标准化图表显示,大多数客户的年龄在25到60岁之间。
以下图表显示,银行在年龄区间*(25, 60)*内的客户有较高的订阅率。
持续时间分布
现在让我们来看看通话时长与订阅之间的关系:
%sql select duration,y, count(1) from trainData group by duration,y order by duration,y
图表显示,大多数通话时间较短,并且订阅率与通话时长成正比。扩展版提供了更深入的见解:
活动分布
现在我们来看一下活动分布与订阅之间的相关性:
%sql select campaign, count(1), y from trainData group by campaign,y order by campaign,y
图表显示,大多数客户的联系次数少于五次,而客户被联系的次数越多,他们订阅的可能性就越低。现在,扩展版提供了更深入的见解:
Pdays 分布
现在让我们来看看 pdays
分布与订阅之间的关系:
%sql select pdays, count(1), y from trainData group by pdays,y order by pdays,y
图表显示,大多数客户此前没有被联系过。
先前分布
在以下命令中,我们可以看到之前的分布如何影响订阅:
%sql select previous, count(1), y from trainData group by previous,y order by previous,y
与之前的图表类似,这张图表确认大多数客户在此次活动前没有被联系过。
emp_var_rate 分布
以下命令显示了 emp_var_rate
分布与订阅之间的相关性:
%sql select emp_var_rate, count(1), y from trainData group by emp_var_rate,y order by emp_var_rate,y
图表显示,雇佣变动率较少见的客户更可能订阅。现在,扩展版提供了更深入的见解:
cons_price_idx 特征
con_price_idx
特征与订阅之间的相关性可以通过以下命令计算:
%sql select cons_price_idx, count(1), y from trainData group by cons_price_idx,y order by cons_price_idx,y
图表显示,消费者价格指数较少见的客户相比其他客户更有可能订阅。现在,扩展版提供了更深入的见解:
cons_conf_idx 分布
cons_conf_idx
分布与订阅之间的相关性可以通过以下命令计算:
%sql select cons_conf_idx, count(1), y from trainData group by cons_conf_idx,y order by cons_conf_idx,y
消费者信心指数较少见的客户相比其他客户更有可能订阅。
Euribor3m 分布
让我们看看euribor3m
的分布与订阅之间的相关性:
%sql select euribor3m, count(1), y from trainData group by euribor3m,y order by euribor3m,y
该图表显示,euribor 三个月期利率的范围较大,大多数客户聚集在该特征的四个或五个值附近。
nr_employed 分布
nr_employed
分布与订阅的相关性可以通过以下命令查看:
%sql select nr_employed, count(1), y from trainData group by nr_employed,y order by nr_employed,y
图表显示,订阅率与员工数量呈反比。
数值特征统计
现在,我们来看一下数值特征的统计数据:
import org.apache.spark.sql.types._
val numericFeatures = trainDF.schema.filter(_.dataType != StringType)
val description = trainDF.describe(numericFeatures.map(_.name): _*)
val quantils = numericFeatures
.map(f=>trainDF.stat.approxQuantile(f.name,
Array(.25,.5,.75),0)).transposeval
rowSeq = Seq(Seq("q1"+:quantils(0): _*),
Seq("median"+:quantils(1): _*),
Seq("q3"+:quantils(2): _*))
val rows = rowSeq.map(s=> s match{
case Seq(a:String,b:Double,c:Double,d:Double,
e:Double,f:Double,g:Double,
h:Double,i:Double,j:Double,k:Double)=> (a,b,c,d,e,f,g,h,i,j,k)})
val allStats = description.unionAll(sc.parallelize(rows).toDF)
allStats.registerTempTable("allStats")
%sql select * from allStats
>>>
summary | age | duration | campaign | pdays | previous |
---|---|---|---|---|---|
count | 41188.00 | 41188.00 | 41188.00 | 41188.00 | 41188.00 |
mean | 40.02 | 258.29 | 2.57 | 962.48 | 0.17 |
stddev | 10.42 | 259.28 | 2.77 | 186.91 | 0.49 |
min | 17.00 | 0.00 | 1.00 | 0.00 | 0.00 |
max | 98.00 | 4918.00 | 56.00 | 999.00 | 7.00 |
q1 | 32.00 | 102.00 | 1.00 | 999.00 | 0.00 |
median | 38.00 | 180.00 | 2.00 | 999.00 | 0.00 |
q3 | 47.00 | 319.00 | 3.00 | 999.00 | 0.00 |
summary | emp_var_rate | cons_price_idx | cons_conf_idx | euribor3m | nr_employed |
count | 41188.00 | 41188.00 | 41188.00 | 41188.00 | 41188.00 |
mean | 0.08 | 93.58 | -40.50 | 3.62 | 5167.04 |
stddev | 1.57 | 0.58 | 4.63 | 1.73 | 72.25 |
min | -3.40 | 92.20 | -50.80 | 0.63 | 4963.60 |
max | 1.40 | 94.77 | -26.90 | 5.05 | 5228.10 |
q1 | -1.80 | 93.08 | -42.70 | 1.34 | 5099.10 |
median | 1.10 | 93.75 | -41.80 | 4.86 | 5191.00 |
q3 | 1.40 | 93.99 | -36.40 | 4.96 | 5228.10 |
实现客户订阅评估模型
为了预测客户订阅评估,我们使用 H2O 中的深度学习分类器实现。首先,我们设置并创建一个 Spark 会话:
val spark = SparkSession.builder
.master("local[*]")
.config("spark.sql.warehouse.dir", "E:/Exp/") // change accordingly
.appName(s"OneVsRestExample")
.getOrCreate()
然后我们将数据集加载为数据框:
spark.sqlContext.setConf("spark.sql.caseSensitive", "false");
val trainDF = spark.read.option("inferSchema","true")
.format("com.databricks.spark.csv")
.option("delimiter", ";")
.option("header", "true")
.load("data/bank-additional-full.csv")
尽管这个数据集中包含了分类特征,但由于这些分类特征的域较小,因此无需使用StringIndexer
。如果将其索引,会引入一个并不存在的顺序关系。因此,更好的解决方案是使用 One Hot Encoding,事实证明,H2O 默认使用此编码策略处理枚举。
在数据集描述中,我已经说明了duration
特征只有在标签已知后才可用,因此不能用于预测。因此,在调用客户之前,我们应该丢弃它作为不可用的特征:
val withoutDuration = trainDF.drop("duration")
到目前为止,我们已经使用 Spark 的内置方法加载了数据集并删除了不需要的特征,但现在我们需要设置h2o
并导入其隐式功能:
implicit val h2oContext = H2OContext.getOrCreate(spark.sparkContext)
import h2oContext.implicits._implicit
val sqlContext = SparkSession.builder().getOrCreate().sqlContext
import sqlContext.implicits._
然后我们将训练数据集打乱,并将其转换为 H2O 框架:
val H2ODF: H2OFrame = withoutDuration.orderBy(rand())
字符串特征随后被转换为分类特征("2 Byte"类型表示 H2O 中的字符串类型):
H2ODF.types.zipWithIndex.foreach(c=> if(c._1.toInt== 2) toCategorical(H2ODF,c._2))
在前面的代码行中,toCategorical()
是一个用户定义的函数,用于将字符串特征转换为类别特征。以下是该方法的签名:
def toCategorical(f: Frame, i: Int): Unit = {f.replace(i,f.vec(i).toCategoricalVec)f.update()}
现在是时候将数据集分为 60%的训练集、20%的验证集和 20%的测试集:
val sf = new FrameSplitter(H2ODF, Array(0.6, 0.2),
Array("train.hex", "valid.hex", "test.hex")
.map(Key.makeFrame), null)
water.H2O.submitTask(sf)
val splits = sf.getResultval (train, valid, test) = (splits(0), splits(1), splits(2))
然后我们使用训练集训练深度学习模型,并使用验证集验证训练,具体如下:
val dlModel = buildDLModel(train, valid)
在前面的代码行中,buildDLModel()
是一个用户定义的函数,用于设置深度学习模型并使用训练和验证数据框架进行训练:
def buildDLModel(train: Frame, valid: Frame,epochs: Int = 10,
l1: Double = 0.001,l2: Double = 0.0,
hidden: Array[Int] = ArrayInt
)(implicit h2oContext: H2OContext):
DeepLearningModel = {import h2oContext.implicits._
// Build a model
val dlParams = new DeepLearningParameters()
dlParams._train = traindlParams._valid = valid
dlParams._response_column = "y"
dlParams._epochs = epochsdlParams._l1 = l2
dlParams._hidden = hidden
val dl = new DeepLearning(dlParams, water.Key.make("dlModel.hex"))
dl.trainModel.get
}
在这段代码中,我们实例化了一个具有三层隐藏层的深度学习(即 MLP)网络,L1 正则化,并且仅计划迭代训练 10 次。请注意,这些是超参数,尚未调优。因此,您可以自由更改这些参数并查看性能,以获得一组最优化的参数。训练阶段完成后,我们打印训练指标(即 AUC):
val auc = dlModel.auc()println("Train AUC: "+auc)
println("Train classification error" + dlModel.classification_error())
>>>
Train AUC: 0.8071186909427446
Train classification error: 0.13293674881631662
大约 81%的准确率看起来并不好。现在我们在测试集上评估模型。我们预测测试数据集的标签:
val result = dlModel.score(test)('predict)
然后我们将原始标签添加到结果中:
result.add("actual",test.vec("y"))
将结果转换为 Spark DataFrame 并打印混淆矩阵:
val predict_actualDF = h2oContext.asDataFrame(result)predict_actualDF.groupBy("actual","predict").count.show
>>>
现在,前面的混淆矩阵可以通过以下图表在 Vegas 中表示:
Vegas().withDataFrame(predict_actualDF)
.mark(Bar)
.encodeY(field="*", dataType=Quantitative, AggOps.Count, axis=Axis(title="",format=".2f"),hideAxis=true)
.encodeX("actual", Ord)
.encodeColor("predict", Nominal, scale=Scale(rangeNominals=List("#FF2800", "#1C39BB")))
.configMark(stacked=StackOffset.Normalize)
.show()
>>>
图 4:混淆矩阵的图形表示——归一化(左)与未归一化(右)
现在让我们看看测试集上的整体性能摘要——即测试 AUC:
val trainMetrics = ModelMetricsSupport.modelMetricsModelMetricsBinomialprintln(trainMetrics)
>>>
所以,AUC 测试准确率为 76%,这并不是特别好。但为什么我们不再迭代训练更多次(比如 1000 次)呢?嗯,这个问题留给你去决定。但我们仍然可以直观地检查精确度-召回率曲线,以看看评估阶段的情况:
val auc = trainMetrics._auc//tp,fp,tn,fn
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 vs recall
Vegas("ROC", width = 800, height = 600)
.withDataFrame(precision_recall).mark(Line)
.encodeX("re-call", Quantitative)
.encodeY("precision", Quantitative)
.show()
>>>
图 5:精确度-召回率曲线
然后我们计算并绘制敏感度特异度曲线:
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()
>>>
图 6:敏感度特异度曲线
现在,敏感度特异度曲线告诉我们正确预测的类别与两个标签之间的关系。例如,如果我们正确预测了 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
>>>
图 7:在[0.0, 1.0]之间不同预测阈值下的真正例
第二步,让我们绘制假阳性:
Vegas("fp", width = 800, height = 600)
.withDataFrame(rows3.toDF).mark(Line)
.encodeX("th", Quantitative)
.encodeY("fp", Quantitative)
.show
>>>
图 8:在[0.0, 1.0]范围内,不同预测阈值下的假阳性
接下来是正确的负类:
Vegas("tn", width = 800, height = 600)
.withDataFrame(rows3.toDF).mark(Line)
.encodeX("th", Quantitative)
.encodeY("tn", Quantitative)
.show
>>>
图 9:在[0.0, 1.0]范围内,不同预测阈值下的假阳性
最后,让我们绘制假阴性:
Vegas("fn", width = 800, height = 600)
.withDataFrame(rows3.toDF).mark(Line)
.encodeX("th", Quantitative)
.encodeY("fn", Quantitative)
.show
>>>
图 10:在[0.0, 1.0]范围内,不同预测阈值下的假阳性
因此,前面的图表告诉我们,当我们将预测阈值从默认的0.5提高到0.6时,可以在不丢失正确分类的欺诈案例的情况下,增加正确分类的非欺诈案例数量。
除了这两种辅助方法外,我还定义了三个 Scala case 类来计算precision
、recall
、sensitivity
、specificity
、真正例(tp
)、真负例(tn
)、假阳性(fp
)、假阴性(fn
)等。其签名如下:
case class r(precision: Double, recall: Double)
case class r2(sensitivity: Double, specificity: Double)
case class r3(tp: Double, fp: Double, tn: Double, fn: Double, th: Double)
最后,停止 Spark 会话和 H2O 上下文。stop()
方法调用将分别关闭 H2O 上下文和 Spark 集群:
h2oContext.stop(stopSparkContext = true)
spark.stop()
第一个尤其重要;否则,有时它并不会停止 H2O 流,但仍会占用计算资源。
超参数调优和特征选择
神经网络的灵活性也是它们的主要缺点之一:有许多超参数需要调整。即使在一个简单的 MLP 中,你也可以更改层数、每层的神经元数量、每层使用的激活函数类型、训练轮次、学习率、权重初始化逻辑、丢弃保持概率等。那么,如何知道哪种超参数组合最适合你的任务呢?
当然,你可以使用网格搜索结合交叉验证来为线性机器学习模型寻找合适的超参数,但对于深度学习模型来说,有很多超参数需要调优。而且,由于在大数据集上训练神经网络需要大量时间,你只能在合理的时间内探索超参数空间的一小部分。以下是一些有用的见解。
隐藏层数量
对于许多问题,你可以从一两个隐藏层开始,使用两个隐藏层并保持相同的神经元总数,训练时间大致相同,效果也很好。对于更复杂的问题,你可以逐渐增加隐藏层的数量,直到开始出现过拟合。非常复杂的任务,如大规模图像分类或语音识别,通常需要几十层的网络,并且需要大量的训练数据。
每个隐藏层的神经元数量
显然,输入层和输出层的神经元数量是由任务所需的输入和输出类型决定的。例如,如果你的数据集形状为 28 x 28,那么它的输入神经元数量应该是 784,输出神经元数量应等于要预测的类别数。
在这个项目中,我们通过下一个使用 MLP 的示例,展示了它在实践中的运作方式,我们设置了 256 个神经元,每个隐藏层 4 个;这只是一个需要调节的超参数,而不是每层一个。就像层数一样,你可以逐渐增加神经元的数量,直到网络开始过拟合。
激活函数
在大多数情况下,你可以在隐藏层使用 ReLU 激活函数。它比其他激活函数计算速度更快,而且与逻辑函数或双曲正切函数相比,梯度下降在平坦区域更不容易停滞,因为后者通常在 1 处饱和。
对于输出层,softmax 激活函数通常是分类任务的不错选择。对于回归任务,你可以简单地不使用任何激活函数。其他激活函数包括 Sigmoid 和 Tanh。当前基于 H2O 的深度学习模型支持以下激活函数:
-
指数线性整流器(ExpRectifier)
-
带 Dropout 的指数线性整流器(ExpRectifierWithDropout)
-
Maxout
-
带 Dropout 的 Maxout(MaxoutWithDropout)
-
线性整流器(Rectifier)
-
带 Dropout 的线性整流器(RectifierWthDropout)
-
Tanh
-
带 Dropout 的 Tanh(TanhWithDropout)
除了 Tanh(H2O 中的默认函数),我没有尝试过其他激活函数用于这个项目。然而,你应该肯定尝试其他的。
权重和偏置初始化
初始化隐藏层的权重和偏置是需要注意的一个重要超参数:
-
不要进行全零初始化:一个看似合理的想法是将所有初始权重设置为零,但实际上并不可行,因为如果网络中的每个神经元计算相同的输出,那么它们的权重初始化为相同的值时,就不会有神经元之间的对称性破坏。
-
小随机数:也可以将神经元的权重初始化为小数值,而不是完全为零。或者,也可以使用从均匀分布中抽取的小数字。
-
初始化偏置:将偏置初始化为零是可能的,且很常见,因为破坏对称性是通过权重中的小随机数来完成的。将偏置初始化为一个小常数值,例如将所有偏置设为 0.01,确保所有 ReLU 单元能够传播梯度。然而,这种做法既不能很好地执行,也没有持续的改进效果。因此,推荐将偏置设为零。
正则化
有几种方法可以控制神经网络的训练,防止在训练阶段过拟合,例如 L2/L1 正则化、最大范数约束和 Dropout:
-
L2 正则化:这可能是最常见的正则化形式。通过梯度下降参数更新,L2 正则化意味着每个权重都会线性衰减到零。
-
L1 正则化:对于每个权重w,我们将项λ∣w∣添加到目标函数中。然而,也可以结合 L1 和 L2 正则化以实现弹性网正则化。
-
最大范数约束:用于对每个隐藏层神经元的权重向量的大小施加绝对上限。然后,可以使用投影梯度下降进一步强制执行该约束。
-
Dropout(丢弃法):在使用神经网络时,我们需要另一个占位符用于丢弃法,这是一个需要调优的超参数,它仅影响训练时间而非测试时间。其实现方式是通过以某种概率(假设为p<1.0)保持一个神经元活跃,否则将其设置为零。其理念是在测试时使用一个没有丢弃法的神经网络。该网络的权重是经过训练的权重的缩小版。如果在训练过程中一个单元在
dropout_keep_prob
< 1.0时被保留,那么该单元的输出权重在测试时会乘以p(图 17)。
除了这些超参数,使用基于 H2O 的深度学习算法的另一个优点是我们可以得到相对变量/特征的重要性。在之前的章节中,我们看到通过在 Spark 中使用随机森林算法,也可以计算变量重要性。
所以,基本思想是,如果你的模型表现不佳,去掉不太重要的特征然后重新训练可能会有所帮助。现在,在监督学习过程中是可以找到特征重要性的。我观察到的特征重要性如下:
图 25:相对变量重要性
现在问题是:为什么不去掉它们,再次训练看看准确性是否有所提高?嗯,我将这个问题留给读者自己思考。
摘要
在本章中,我们展示了如何使用 H2O 在银行营销数据集上开发一个机器学习(ML)项目来进行预测分析。我们能够预测客户是否会订阅定期存款,准确率达到 80%。此外,我们还展示了如何调优典型的神经网络超参数。考虑到这是一个小规模数据集,最终的改进建议是使用基于 Spark 的随机森林、决策树或梯度提升树来提高准确性。
在下一章中,我们将使用一个包含超过 284,807 个信用卡使用实例的数据集,其中只有 0.172%的交易是欺诈的——也就是说,这是一个高度不平衡的数据集。因此,使用自编码器预训练一个分类模型并应用异常检测来预测可能的欺诈交易是有意义的——也就是说,我们预期我们的欺诈案件将是整个数据集中的异常。