R-深度学习实用指南-全-
R 深度学习实用指南(全)
原文:
annas-archive.org/md5/fcbb901b0d8ff6ccac76991f12c1823a译者:飞龙
前言
深度学习能够从海量数据中高效、准确地学习。深度学习被越来越多的行业采用,因为它可以帮助解决许多传统机器学习方法难以解决的挑战。
使用 R 的开发者将能够通过这本实用指南将他们的知识付诸实践。这本书提供了一种实践的方法,结合了实施和相关方法论,你将在短时间内开始运行并高效工作。书中详细讲解了关键概念和实际案例,你将从深度学习的基本概念入手,了解深度学习的优势和架构概览。你将探索各种深度学习算法的架构,并理解它们的应用领域。你还将学习如何构建深度学习模型、优化超参数并评估模型表现。
本书结束时,你将能够使用针对你的问题的深度学习框架和算法,构建并部署自己的深度学习模型和应用程序。
本书的适用对象
本书的目标读者是数据分析师、机器学习工程师和数据科学家,他们熟悉机器学习,并希望巩固深度学习的知识,或者通过使用 R 让他们的机器学习应用更加高效。我们假设读者具备一定的编程背景,至少掌握一些常见的机器学习技术,并且有一定的 R 语言经验或了解。
本书内容概述
第一章,机器学习基础,回顾了机器学习的所有基本要素。这个快速复习很重要,因为我们将进入深度学习——机器学习的一个子集,它包含了许多共通的术语和方法。
第二章,为深度学习设置 R 环境,总结了在 R 中用于深度学习和强化深度学习的常见框架和算法。你将熟悉常用的库,包括 MXNet、H2O 和 Keras,并学习如何在 R 中安装每个库。
第三章,人工神经网络,教你关于人工神经网络的知识,人工神经网络是所有深度学习的基础构建模块。你将构建一个简单的人工神经网络,并学习它的各个组件如何结合在一起解决复杂问题。
第四章,用于图像识别的卷积神经网络(CNN),演示了如何使用卷积神经网络进行图像识别。我们将简要介绍为什么这些深度学习网络优于浅层网络。该章的其余部分将讲解卷积神经网络的组成部分,并考虑如何做出最合适的选择。
第五章,用于信号检测的多层感知机神经网络,展示了如何构建用于信号检测的多层感知机神经网络。你将了解多层感知机神经网络的架构,并学习如何准备数据、定义隐藏层和神经元,以及如何使用反向传播算法在 R 中训练模型。
第六章,使用嵌入的神经协同过滤,解释了如何使用分层嵌入构建神经协同过滤推荐系统。你将学习如何使用自定义的 Keras API,构建具有用户-项目嵌入层的架构,并使用隐式评分训练一个实际的推荐系统。
第七章,自然语言处理的深度学习,解释了如何创建文档摘要。本章开始时将去除文档中不应考虑的部分,并对剩余文本进行分词。随后,将应用嵌入并创建聚类。这些聚类随后被用于生成文档摘要。我们还将学习如何编码限制玻尔兹曼机(RBM),并定义吉布斯采样、对比散度和自由能量。最后,本章将通过编译多个 RBM 来创建深度信念网络。
第八章,用于股票预测的长短期记忆网络,展示了如何使用长短期记忆(LSTM)RNN 网络进行预测分析。你将学习如何为 LSTM 准备序列数据,并学习如何使用 LSTM 构建预测模型。
第九章,面部生成对抗网络,描述了生成对抗网络(GANs)的主要组成部分和应用。你将了解生成对抗网络的常见应用,并学习如何利用 GANs 构建面部生成模型。
第十章,游戏中的强化学习,演示了在井字游戏中使用强化学习方法。你将了解强化学习的概念和在高度可定制框架中的实现。此外,你还将学习如何创建一个智能体,为每个游戏步骤选择最佳动作,并在 R 中实现强化学习。
第十一章,用于迷宫求解的深度 Q 学习,展示了如何使用 R 实现强化学习技术以应对迷宫环境。具体来说,我们将创建一个智能体,通过训练它执行动作并从失败的尝试中学习,以解决迷宫。
为了最大限度地利用本书
我们假设您已经熟悉并能顺利下载和安装计算机上的软件,包括 R 和从 CRAN 或 GitHub 安装的额外 R 库包。我们还假设您有一定的基础,能够根据 R Studio 控制台输出独立地排查和解决打包依赖问题(如有需要)。您需要在计算机上安装 R 和 R Studio 版本,最好是最新版本。
所有代码示例已在 macOS X 10.11(El Capitan)及更高版本的 R 3.6.3 上进行测试。此代码应该也能在未来的版本中运行,尽管这可能需要更新第二章中列出的某些深度学习 R 软件包,为深度学习配置 R。
| 本书中涉及的硬件/软件 | 操作系统要求 |
|---|---|
| 适用于 Intel Mac 的 64 位 | macOS X 10.11(El Capitan)及更高版本 |
| R 版本 3.6.3 | macOS X 10.11(El Capitan)及更高版本 |
| R Studio Desktop 1.2.5033(Orange Blossom 330255dd) | R 版本 3.0.1+ |
在您的计算机上安装了 R(www.r-project.org)和 R Studio Desktop(rstudio.com/products/rstudio/download/)后,您应该准备好安装第二章中概述的其他深度学习软件包,为深度学习配置 R。
如果您使用的是本书的数字版本,我们建议您自己输入代码,或者通过 GitHub 仓库访问代码(链接见下一节)。这样可以帮助您避免因复制和粘贴代码而导致的潜在错误。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您是在其他地方购买本书,您可以访问www.packtpub.com/support并注册,文件将直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
登录或注册到www.packt.com。
-
选择支持选项卡。
-
点击代码下载。
-
在搜索框中输入书名并按照屏幕上的说明操作。
下载文件后,请确保使用以下最新版工具解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
Linux 版 7-Zip/PeaZip
本书的代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-on-Deep-Learning-with-R。如果代码有更新,将在现有 GitHub 仓库中更新。
我们还提供其他代码包,您可以在我们丰富的书籍和视频目录中找到,访问地址是github.com/PacktPublishing/。快来看看吧!
下载彩色图像
我们还提供了一份 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。你可以在此下载:static.packt-cdn.com/downloads/9781788996839_ColorImages.pdf
使用的约定
本书中使用了多种文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“linear_fits函数随后再次使用,绘制另一条线。”
代码块设置如下:
linear_fits <- function(w, to_add = TRUE, line_type = 1) {curve(-w[1] / w[2] * x - w[3] / w[2], xlim = c(-1, 2), ylim = c(-1, 2), col = "black",lty = line_type, lwd = 2, xlab = "Input Value A", ylab = "Input Value B", add = to_add)}
当我们希望提醒你注意代码块中特定部分时,相关的行或项目会以粗体显示:
results <- softmax(c(2,3,6,9))
results
[1] 0.0008658387 0.0023535935 0.0472731888 0.9495073791
粗体:表示新术语、重要词汇,或屏幕上看到的词汇。例如,菜单或对话框中的词汇在文本中会像这样出现。这里有一个例子:“ReLU 的一个潜在问题被称为dying ReLU,因为该函数将所有负值赋值为零,信号在到达输出节点之前可能完全丢失。”
警告或重要提示如下所示。
提示和技巧如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何内容有疑问,请在邮件主题中提到书名,并通过customercare@packtpub.com联系我们。
勘误:虽然我们已经尽最大努力确保内容的准确性,但错误总是难以避免。如果你发现本书中的错误,我们将不胜感激,如果你能向我们报告。请访问 www.packtpub.com/support/errata,选择你的书籍,点击勘误提交表格链接,并填写相关信息。
盗版:如果你在互联网上发现我们作品的任何非法复制形式,我们将非常感激你能提供相关的地址或网站名称。请通过copyright@packt.com与我们联系,并附上该材料的链接。
如果你有兴趣成为一名作者:如果你在某个领域具有专业知识,并且有兴趣撰写或参与撰写一本书,请访问 authors.packtpub.com。
评论
请留下评论。阅读并使用完本书后,为什么不在你购买书籍的网站上留下评论呢?潜在读者可以查看并参考你的中立意见,从而做出购买决定。我们在 Packt 可以了解你对我们产品的看法,我们的作者也能看到你对他们书籍的反馈。谢谢!
获取更多关于 Packt 的信息,请访问 packt.com。
第一部分:深度学习基础
本节提供了与机器学习相关的深度学习简要概述。在本书的这一部分,你将学习如何在 R 中设置深度学习环境,并构建你的第一个神经网络,它是所有后续深度学习的基础。
本节包含以下章节:
-
第一章,机器学习基础
-
第二章,为深度学习设置 R 环境
-
第三章,人工神经网络
第一章:机器学习基础
欢迎来到R 语言深度学习实战!本书将带领你完成使用 R 统计编程语言编写深度学习模型的所有必要步骤。它从简单的示例开始,为刚刚入门的人提供第一步,并回顾了深度学习的基础元素,以便有更多经验的人复习。在你逐步深入本书时,你将学习如何编写越来越复杂的深度学习解决方案,适用于各种任务。无论任务复杂度如何,每一章都将详细说明每一步。这样所有的主题和概念都能被充分理解,并且每一行代码的原因都能得到完全解释。
在本章中,我们将快速概述机器学习过程,因为它将为本书后续章节奠定基础。我们将学习如何处理数据集,以复习处理离群值和缺失值等技术。我们将学习如何对数据进行建模,以回顾预测结果的过程并评估结果,还将复习针对各种问题最合适的评估指标。我们将探讨如何通过参数调整、特征工程和集成方法来改进模型,并学习如何根据任务选择不同的机器学习算法。
本章将涵盖以下主题:
-
机器学习概述
-
准备数据以进行建模
-
在准备好的数据上训练模型
-
评估模型结果
-
改进模型结果
-
回顾不同的算法
机器学习概述
所有的深度学习都是机器学习,但并非所有机器学习都是深度学习。本书将重点介绍与 R 中深度学习相关的过程和技术。然而,理解机器学习的所有核心原理是非常重要的,只有这样我们才能继续探索深度学习。
深度学习被视为机器学习的一个特殊子集,基于使用模拟大脑活动的神经网络。学习被称为“深度”是因为在建模过程中,数据通过多个隐藏层进行处理。在这种建模方式中,每个层都会收集特定的信息。例如,一个层可能会找到图像的边缘,而另一个层则可能会找到特定的色调。
该类型机器学习的显著应用包括以下几个方面:
-
图像识别(包括人脸识别)
-
信号检测
-
推荐系统
-
文档摘要
-
主题建模
-
预测
-
解决游戏
-
在空间中移动物体,例如自动驾驶汽车
本书的整个过程中将涵盖所有这些主题。所有这些主题都实现了深度学习和神经网络,主要用于分类和回归。
准备数据以进行建模
深度学习的一个好处是,它大大减少了对特征工程的需求,这可能是你在机器学习中常见的。不过,数据在建模之前仍然需要准备。让我们回顾以下目标,以便为建模做好数据准备:
-
删除无信息和极低信息变量
-
识别日期并提取日期部分
-
处理缺失值
-
处理离群值
在本章中,我们将使用伦敦空气质量网络提供的数据,研究空气质量数据。具体来说,我们将查看 2018 年 Tower Hamlets(Mile End Road)地区的二氧化氮读数。这是一个非常小的数据集,只有几个特征和大约 35,000 个观测值。我们使用一个有限的数据集,以确保我们的所有代码,甚至包括建模,都能快速运行。也就是说,这个数据集非常适合我们要探索的过程。它需要一些初步的清理和准备,但不需要过多的工作。除此之外,这个数据集适合用于基于决策树的建模,这也是我们在未来章节中开始应用深度学习模型时,审查的一个有用的机器学习方法。
我们的第一步将是进行一些初步的数据探索,以便了解哪些数据清理和准备步骤是必要的。R 有一些非常有用的便捷包,可以用于这种类型的探索性数据分析。让我们通过查看以下代码块中的一些重要领域,快速回顾一下探索性数据分析:
- 我们将从加载数据和库开始。为此,我们将使用基础 R 中的
library()函数加载我们所需的所有库。如果列表中有你没有安装的库,可以使用install.packages()函数来安装它们。我们还将使用readr包中的read_csv()函数来加载数据。我们将使用以下代码来加载库和数据:
library(tidyverse)
library(lubridate)
library(xgboost)
library(Metrics)
library(DataExplorer)
library(caret)
la_no2 <- readr::read_csv("data/LondonAir_TH_MER_NO2.csv")
包会在调用函数之前包含;因此,这能帮助我们理解每个包的使用位置和原因。如前面的代码块所示,本章将使用以下包:
-
tidyverse:这一系列的包将会广泛使用。在本章中,dplyr包用于数据清理,例如,查看汇总值或添加和删除列和行。 -
lubridate:这个包将用于轻松提取包含日期数据类型的列中的详细信息。 -
xgboost:这是我们将用于数据建模的模型。 -
Metrics:这个包将用于评估我们的模型。 -
DataExplorer:这个包将用于生成探索性数据分析的图表。 -
caret:当我们调整模型时,将使用此包,它提供了一种方便的网格搜索超参数的方法。
- 接下来,我们将使用
str函数查看数据的结构,该函数提供有关数据对象类别和维度的详细信息,以及每列数据类型的详细信息,并显示一些示例值,如下代码所示:
utils::str(la_no2)
运行代码后,我们将看到控制台输出如下内容:

- 所有这些数据来自同一站点,且污染物的物种读数相同,我们可以得出单位度量在整个数据中可能保持一致的结论。如果是这种情况,我们可以删除这些列,因为它们没有提供任何信息价值。即使我们不知道第一个变量总是相同的,通过
str函数的结果,我们也可以开始看出这个模式。我们可以通过运行以下代码来确认这一点,使用dplyr包中的group_by和summarise函数:
la_no2 %>% dplyr::group_by(Units, Site, Species) %>% dplyr::summarise(count = n())
运行上述代码后,我们将看到控制台输出如下内容:

我们已经确认Site、Species和Units的值始终相同,因此可以将它们从数据中删除,因为它们不会提供任何有用信息。我们还可以看到,实际的读数值是以字符字符串的形式存储的,日期也以相同的方式存储。当前形式下,date字段表现出一种特征,称为高基数性,即存在大量唯一的值。当我们看到这种情况时,通常会希望对这些类型的列进行处理,以减少其独特值的数量。在这种情况下,方法是显而易见的,因为我们知道这应该是一个日期值。
- 在接下来的代码中,我们将使用
dplyr的select函数删除不需要保留的列。我们将使用dplyr的mutate函数,并结合lubridate包中的函数来转换我们已识别的变量。转换数据后,我们可以删除旧的字符字符串日期列和完整日期列,因为我们将使用原子化的日期值。我们删除不需要的列,并使用以下代码将转换后的日期字段拆分为其组成部分:
la_no2 <- la_no2 %>%
dplyr::select(c(-Site,-Species,-Units)) %>%
dplyr::mutate(
Value = as.numeric(Value),
reading_date = lubridate::dmy_hm(ReadingDateTime),
reading_year = lubridate::year(reading_date),
reading_month = lubridate::month(reading_date),
reading_day = lubridate::day(reading_date),
reading_hour = lubridate::hour(reading_date),
reading_minute = lubridate::minute(reading_date)
) %>%
dplyr::select(c(-ReadingDateTime, -reading_date))
在运行上述代码之前,我们应该注意到我们的环境面板中的dataframe如下所示:

运行代码后,我们可以注意到dataframe的变化,现在应该如下所示:

如您所见,只包含一个值的列已被删除,数据列现在占据了五个列,每个列对应一个日期和时间部分。
- 接下来,我们将使用
DataExplorer包来检查是否存在缺失值。有多种方法可以总结数据对象中缺失值的数量和比例。其中,plot_missing()函数可以一次性提供缺失值的计数、百分比及其可视化效果。我们通过以下代码行来绘制缺失值:
DataExplorer::plot_missing(la_no2)
运行此代码后,会生成一个图表。在你的查看器面板中,你应该能看到如下图表:

如你所见,独立变量中没有缺失值。然而,在目标变量类别中,约有 1,500 个缺失值,占该列的 3.89%。
- 由于存在缺失值,我们应该考虑是否需要采取任何措施。在这种情况下,我们将简单地删除这些行,因为它们并不多,并且没有缺失值的数据部分仍然能够代表整个数据集。虽然在此案例中我们仅删除了缺失值,但处理缺失值的方式有很多种选择。本章稍后将介绍可能的处理方法。为了删除含有缺失值的行,我们运行以下代码:
la_no2 <- la_no2 %>% filter(!is.na(Value))
- 我们还将检查我们的离散变量,以查看该列中各类别的值分布。同样,
DataExplorer包提供了一个方便的函数,可以为离散值生成图表,显示每个值的频率。我们通过以下代码行使用plot_bar函数生成此图表:
DataExplorer::plot_bar(la_no2)
运行上述函数后,我们能够看到以下可视化结果,清晰地显示了批准结果比临时结果更多。从查阅该数据集的文档中,批准结果是经过验证的,可以信赖其准确性,而临时结果可能不如其准确。让我们来看一下输出:

我们创建了一个图表,以便快速查看临时或批准列中离散项的值分布。我们还可以使用以下代码创建一个表格,以获取更具体的详细信息:
la_no2 %>% dplyr::group_by(`Provisional or Ratified`) %>% dplyr::summarise(count = n())
上述代码使用group_by函数按临时或批准列中的值对行进行分组。接着,它使用summarise函数,并将count参数设置为n(),以计算包含每个离散值的行数。运行上述代码后,输出将显示在控制台中,如下所示:

- 由于标记为
Provisional的值非常少,我们将删除这些行。为此,我们将首先使用filter函数,它用于根据特定条件删除行。在这种情况下,我们将过滤数据,使得Provisional or Ratified列中只有值为'R'的行保留下来,如下所示:
la_no2 <- la_no2 %>%
dplyr::filter(
`Provisional or Ratified` == 'R'
)
- 接下来,我们将删除
Provisional or Ratified列,因为它只包含一个唯一值。为此,我们将使用select()函数,删除列的方式与删除行的过滤器类似。在这里,调用select()函数,传递给函数的参数是-Provisional or Ratified,这样就会删除这一列。或者,可以将所有其他列名(除了Provisional or Ratified)作为参数传递给它。select()函数通过指定要包含或排除的列来工作。在这种情况下,指定要排除的列更为高效,这就是为什么做出这个选择的原因。请参考以下代码:
la_no2 <- la_no2 %>%
dplyr::select(-`Provisional or Ratified`)
- 早些时候,我们提到所有数据都来自 2018 年。现在我们的日期数据已经被拆分成了各个组成部分,我们应该只在
reading_year列中看到一个值。检验这一点的一个方法是使用range()函数。我们通过运行以下代码来检查reading_year列中的最小值和最大值:
range(la_no2$reading_year)
运行上述代码将导致值被打印到控制台。您的控制台应该如下所示:

从我们调用range()函数的结果中,我们可以看到,实际上reading_year列只包含一个值。既然如此,我们可以使用select()函数在以下代码的帮助下删除reading_year列:
la_no2 <- la_no2 %>%
dplyr::select(-reading_year)
- 接下来,我们可以对连续变量进行直方图检查,以寻找异常值。为了实现这一点,我们将再次使用
DataExplorer包。这次,我们将使用plot_histogram函数来可视化所有具有这些类型值的列中的连续值,如下所示的代码块:
DataExplorer::plot_histogram(la_no2)
运行上述代码后,会生成五个图表,显示这五个包含连续值的列的频率。您的查看器面板应该如下所示:

在上述输出中,我们确实看到我们的自变量稍微偏向右侧,因此,我们可以对异常值采取一些处理措施。如果存在更剧烈的偏斜,我们可以应用对数变换。由于异常值不多,我们也可以删除这些值,如果我们认为它们是噪声的话。然而,目前我们将这些值保留在数据中。如果最终我们的模型表现不佳,那么进行一些异常值处理,看看是否能改善性能,将是值得的。
- 最后,让我们进行一次相关性检查。我们将再次使用
DataExplorer包,这次我们将使用plot_correlation()函数。为了生成相关图,我们运行以下代码:
DataExplorer::plot_correlation(la_no2)
运行前面一行代码将生成一个图表。该图表使用蓝色和红色分别表示负相关和正相关。这些颜色在本书中的图表中不会出现;然而,你仍然可以看到相关值。在运行前面的代码后,你将在查看器面板中看到一个图表,如下所示:

从这个图表中,我们可以看到污染物值确实与reading_hour特征有一定的相关性,而与reading_day和reading_month特征的相关性较小,这表明该污染物的产生在一天内有较高的趋势,而不是在一周、一个月或一年内。这个图表的主要目的是寻找高度相关的独立变量,因为这可能意味着这些变量传达了相同的信息,在这种情况下,我们可能希望删除其中一个或以某种方式将它们合并。
我们现在有一个已经适当预处理并准备好建模的数据集。相关图显示这些变量之间没有显著的相关性。当然,这并不令人惊讶,因为剩余的变量只是描述时间中的离散时刻。
日期值已从没有信息价值的字符串转换为日期值,进一步分割为以数字数据表示的日期部分。所有仅包含一个值的列都已被删除,因为它们没有提供任何信息。标记为临时值的少数行被删除,因为这些行不多,而且在数据描述中有关于这些污染物测量有效性的警告。最后,包含预测变量为空值的行被删除。之所以这样做,是因为这种行的数量不多;然而,我们在这种情况下本可以采取其他策略,我们将在下节中提到它们。
处理缺失值
在我们刚刚完成的预处理工作中,我们决定删除缺失值。当缺失值的情况非常少时,这是一个可行的选择,在这个例子中确实如此。然而,其他情况下可能需要不同的方法来处理缺失值。除了删除行和列外,以下是一些常见的其他选项:
-
使用集中趋势度量进行插补(均值/中位数/众数):使用一种集中趋势度量来填充缺失值。如果你的数据是正态分布的数值数据,这种方法效果较好。对于非数值数据,也可以使用众数插补,通过选择最频繁的值来替换缺失值。
-
调整缺失值:你可以使用已知值来填补缺失值。此方法的例子包括使用回归分析处理线性数据或使用k 近邻(KNN)算法,根据特征空间中与已知值的相似度来分配一个值。
-
用常数值替换:缺失值也可以用一个常数值替换,该常数值不在已有数据的值范围内,也未出现在分类数据中。这样做的好处是,后来会更清楚这些缺失值是否具有信息价值,因为它们会被明确地放置在一边。与用集中趋势度量填补缺失值的方式不同,集中趋势填补后的最终结果会使一些缺失值包含填补后的值,而有些相同的值实际上已经存在于数据中。在这种情况下,便难以区分哪些是缺失值,哪些是原本已存在的数据值。
训练一个模型来拟合准备好的数据
现在数据准备好了,我们将其划分为训练集和测试集,并运行一个简单的模型。此时的目标不是尽力取得最佳性能,而是得到一种基准结果,以便未来在尝试提升模型时使用。
训练数据和测试数据
在构建预测模型时,我们需要通过以下几个部分创建两个独立的数据集。一个用来让模型学习任务,另一个用来测试模型是否学会了这个任务。以下是我们将要查看的数据类型:
-
训练数据:用于拟合模型的数据部分。模型可以访问解释变量或独立变量(即被选择的列,用来描述数据记录),以及目标变量或依赖变量。我们在训练过程中尝试预测的就是这个目标变量。这个数据部分通常应该占你总数据的 50%到 80%。
-
测试数据:用于评估模型结果的数据部分。在学习过程中,模型永远不能接触到这些数据,也永远不应看到目标变量。这个数据集用于测试模型在依赖变量上的学习情况。在训练阶段拟合模型后,我们现在使用这个模型来预测测试集中的值。在这一阶段,只有我们拥有正确答案;而独立变量,即模型,永远无法接触到这些值。模型做出预测后,我们可以通过将预测值与实际正确值进行比较来评估模型的表现。
-
验证数据:验证数据是训练数据集的一部分,模型利用它来调整超参数。随着超参数的不同值被选定,模型会对验证集进行检查,并使用在此过程中收集的结果来选择产生最佳性能模型的超参数值。
-
交叉验证:当我们只使用一个训练集和测试集时,可能会出现一个潜在问题,即模型会学习到一些特定的描述性特征,这些特征是该数据段特有的。模型所学到的内容在未来应用于其他数据时可能无法很好地推广。这种情况被称为过拟合。为了减轻这个问题,我们可以使用一种叫做交叉验证的过程。在一个简单的例子中,我们可以将数据按 80/20 的比例划分,其中 20%作为测试数据,模型在这个划分上进行建模和测试。然后,我们可以创建一个独立的 80/20 划分,进行相同的建模和测试。我们可以重复这个过程 5 次,使用 5 个不同的测试集——每个测试集由数据的五分之一组成。这种精确的交叉验证方法被称为 5 折交叉验证。在所有迭代完成后,我们可以检查每次迭代的结果是否一致。如果一致,我们可以更有信心地认为我们的模型没有过拟合,并可以利用这个模型在更多数据上进行推广。
选择算法
对于这个任务,我们将使用xgboost,这是一个非常流行的梯度树提升算法的实现。它之所以效果如此好,是因为每次模型迭代都会从前一个模型的结果中学习。与 bagging 相比,该模型使用提升方法进行迭代学习。这两种集成技术都可以用来弥补基于树的学习者在训练数据上过拟合的已知弱点。
bagging 和 boosting 之间的一个简单区别是,在 bagging 中,会生长完整的树,然后将结果进行平均,而在 boosting 中,每次树模型的迭代都会从前一个模型中学习。这是一个重要的概念,因为这个结合了加法函数的算法思想,在对前一个模型残差建模后获得信息,将在深度学习中得到应用。
在这里,我们将探索这种类型的机器学习算法在这个简单例子中的强大功能。此外,我们还将特别关注我们在这里学到的内容如何与后续章节中更复杂的例子相关。我们将使用以下代码来训练一个xgboost模型:
- 我们通过以下代码开始将模型拟合到数据的过程,首先将数据划分为训练集和测试集:
set.seed(1)
partition <- sample(nrow(la_no2), 0.75*nrow(la_no2), replace=FALSE)
train <- la_no2[partition,]
test <- la_no2[-partition,]
target <- train$Value
dtrain <- xgboost::xgb.DMatrix(data = as.matrix(train), label= target)
dtest <- xgboost::xgb.DMatrix(data = as.matrix(test))
在前面的代码中,我们从 set.seed() 函数开始。这是因为这个建模过程的一些部分涉及伪随机性,设置种子可以确保每次使用相同的值,从而我们能始终如一地产生相同的结果。
我们将数据分为训练数据(用于建模)和测试集(用于检查预测是否准确)。我们通过获取随机的行索引值样本,存储在名为 partition 的向量中,并用这些索引来子集化数据。此外,在分区后,我们将目标变量分离出来,并将其存储在一个向量中。
然后,我们将数据转换为稠密矩阵,以便将其传递给 xgboost 算法时符合正确的格式。
- 接下来,我们将创建一个参数列表。该列表中的某些值是进行分析所需的,其他值则是随意选择的初始值。对于这些值,稍后我们将探讨更科学地选择它们的方法。我们使用以下代码准备初始参数列表:
params <-list(
objective = "reg:linear",
booster = "gbtree",
eval_metric = "rmse",
eta=0.1,
subsample=0.8,
colsample_bytree=0.75,
print_every_n = 10,
verbose = TRUE
)
在前面的代码中,以下是该列表中需要的值:
-
objective = "reg:linear":用于将任务目标定义为线性回归。在这里,我们正在进行一个回归任务,目标是预测二氧化氮的值。 -
booster = "gbtree":这告诉我们将使用梯度提升树来选择最适合预测结果的模型。 -
eval_metric = "rmse":这告诉我们将使用均方根误差(RMSE)来评估模型的成功。稍后我们将探讨为什么这是最合适的选择,并讨论我们在其他任务中可以使用的一些其他选项。
接下来,这些是我们任意选择初始值的变量:
-
eta=0.1:用于定义学习率。起初,使用较大的数值是有意义的;但是,随着进展,我们将希望使用较小的学习率并增加轮次以提高性能。 -
subsample=0.8:这告诉我们,对于每棵树,我们将使用数据中 80% 的行。 -
col_subsample=0.75:这告诉我们,对于每棵树,我们将使用数据中的 75% 列。
这些是不会影响模型本身,仅影响我们查看模型结果的参数:
-
print_every_n = 10:用于指定每经过 10 轮后打印一次评估分数。 -
verbose = TRUE:用于表示评估分数应打印到控制台,以便在模型运行时,最终用户能够看到这些分数。
- 现在我们已经定义了参数,我们将使用以下代码运行模型:
xgb <- xgboost::xgb.train(
params = params,
data = dtrain,
nrounds = 100
)
当我们运行模型时,我们会带入之前定义的参数列表,也就是说,模型应该在训练数据集上运行,我们选择运行模型 100 轮。这意味着我们将生成 100 棵树。这同样是一个任意值。稍后我们将探讨如何找到最优的轮次。接着,我们使用刚刚定义的模型来预测测试数据集。对于训练数据集,算法知道正确的值,并用它来调整模型。
- 在下一节中,我们将把模型应用于测试数据,其中模型不再访问正确的值,没有这些知识,模型将使用自变量来预测目标变量。请参阅以下代码块:
pred <- predict(xgb, dtest)
当我们运行前面的代码时,我们从密集矩阵中提取数据,标记为dtest,并通过我们标记为xgb的模型运行它。模型应用在训练过程中计算出的树分割,并将其应用于新数据来做出预测。预测结果将存储在一个名为pred的向量中。
- 最后,我们将使用 RMSE 函数检查模型的表现。对于这个评估指标,越接近零越好,因为它衡量的是真实值与预测值之间的差异。为了评估我们模型的表现,我们运行以下代码:
Metrics::rmse(test$Value,XGBpred)
在运行前面的代码后,我们将看到一个值被打印到控制台。你的控制台应该显示如下输出:

从我们的快速而简单的模型来看,我们已经获得了 0.054 的 RMSE 分数。很快,我们将对模型参数进行一些调整,以尝试提高分数。在此之前,让我们快速看一下除了 RMSE 之外,我们可以使用的所有不同评估指标,并深入解释 RMSE 是如何工作的。
评估模型结果
我们只有在能够衡量模型时,才知道模型是否成功,值得花点时间记住在不同场景下使用哪些评估指标。举个例子,假设有一个信用卡欺诈数据集,其中目标变量存在很大的不平衡,因为在许多非欺诈案例中,只有相对较少的欺诈案件。
如果我们使用一个仅仅衡量目标变量预测成功百分比的指标,那么我们就不会以一种非常有帮助的方式评估我们的模型。在这种情况下,为了简化数学计算,假设我们有 10,000 个案例,其中只有 10 个是欺诈账户。如果我们预测所有案件都不是欺诈,那么我们的准确率将是 99.9%。这个准确率很高,但其实用性不大。以下是各种评估指标的回顾以及何时使用它们。
机器学习评估指标
选择错误的指标会使得评估模型表现变得非常困难,从而也很难提升我们的模型。因此,选择正确的指标非常重要。让我们来看看以下的机器学习指标:
-
准确率:最简单的评估指标是准确率。准确率衡量预测值与实际值之间的差异。这个指标易于解释和沟通;然而,正如我们之前提到的,当用来评估一个高度不平衡的目标变量时,它并不能很好地反映模型表现。
-
混淆矩阵:混淆矩阵提供了一种方便的方式来展示分类准确性以及 I 型错误和 II 型错误。结合这四个相关指标的视图,在调优过程中决定集中精力的地方特别有帮助。它还可以帮助标出其他指标可能更有用的情况。当多数类别的值过多时,应该使用设计用于处理类别不平衡的指标,例如对数损失。
-
平均绝对误差(MAE):该指标计算预测值与实际值之间的差异,并求出这些误差的平均值。这个指标简单易懂,适用于在不需要对大误差施加额外惩罚的情况。如果一个误差是另一个误差的三倍大,而三倍大的误差被认为是三倍糟糕的,那么这个指标非常适用。然而,很多情况下,三倍大的误差远不止是三倍糟糕,这时就需要加上额外的惩罚,而这一点可以通过下一个指标来实现。
-
RMSE:该指标对每个预测的误差进行平方处理,即预测值与实际值之间的差异,然后将这些平方误差相加,最后对和进行平方根运算。在这种情况下,如果平方误差中有一些预测非常不准确,它将导致较大的惩罚,从而使这个误差指标的值更高。我们可以看到这在前面的例子中如何发挥作用,也可以理解我们为什么选择使用 RMSE。这个指标用于回归问题。
-
曲线下面积(AUC):AUC 指的是接收者操作特征曲线下面积。在这个模型中,你的目标变量需要表示一个值,表达某一行属于正目标条件或负目标条件的置信度或概率。为了让这个概念更加具体,AUC 可以用于预测某人进行某次购买的可能性。从这个解释中,我们可以清楚地看出 AUC 是一个分类问题的指标。
-
对数损失(Log-Loss):对数损失评估指标相比 AUC 更加奖励自信的预测,同时惩罚中立的预测。当目标数据集不平衡,且发现少数类非常关键时,这一点尤为重要。对错误预测施加额外的惩罚有助于我们找到一个能够更好地正确预测少数类成员的模型。对数损失在多类别模型中也表现更好,尤其是当目标变量不是二元时。
改进模型结果
由于我们有一个回归问题,现在我们明白为什么选择 RMSE,并且有了一个性能基准指标,我们可以开始着手改进模型了。每个模型都有自己独特的改进方法;不过我们可以稍微概括一下。特征工程有助于提升模型性能;然而,由于深度学习对这类工作要求较低,我们在这里不再过多关注。此外,我们已经通过特征工程生成了日期和时间部分。我们还可以通过使用较慢的学习率运行模型更长时间,并且调优超参数。为了使用这种模型改进方法找到最佳的参数值,我们将使用一种叫做网格搜索(grid search)的方法,查看多个字段的不同值范围。
让我们来寻找最佳的轮次数。通过 R 接口使用xgboost的交叉验证版本,我们可以根据默认的超参数设置再次训练我们的模型。这一次,我们将不再选择 100 轮,而是利用xgboost中的功能来确定最佳的树木数量。通过交叉验证,模型可以在每轮结束时评估误差率,并且我们将使用early_stopping_rounds功能,使模型在误差率不再下降时,停止增加树木的数量。
让我们来看一下下面的代码,它确定了在默认设置下,哪一个轮次或树木数量能够产生最低的误差率:
xgb_cv <- xgboost::xgb.cv(
params = params,
data = dtrain,
nrounds = 10000,
nfold = 5,
showsd = T,
stratified = T,
print_every_n = 100,
early_stopping_rounds = 25,
maximize = F)
在运行上述代码后,我们将看到模型性能指标在控制台上打印出来,随着模型的运行,报告的开头应该像以下截图一样:

在这里,我们可以看到模型性能正在迅速提升,并且得到了确认,当模型在 25 轮内没有任何改进时,训练会停止。
当你的模型达到最佳的运行次数时,控制台应该显示如下截图:

在这里,我们可以看到模型的表现提升较慢,且模型已经停止,因为不再有改善。控制台打印出的报告也标明了表现最好的轮次。
在分析前面的示例中发生的一切时,我们再次在相同的数据和相同的参数下训练xgboost模型,就像我们之前做的一样。然而,我们将轮次设置得更高。为了找到最佳迭代次数,我们需要足够多的轮次,这样模型在找到最佳的树木数量之前不会停止生长树木。在这种情况下,最佳的迭代发生在大约 3,205 次;所以,如果我们将轮次设置为 1,000 次,例如,建模过程将在生长 1,000 棵树后完成。然而,我们仍然无法知道产生最低错误率的轮次数,这就是为什么我们将轮次设置得如此之高的原因。
以下是前面代码中使用的设置列表。让我们来看看使用这些设置的目的:
-
nfold:这是折叠数或进行交叉验证时将数据划分成多少个部分。这里我们使用5,这利用了前面提到的交替 80/20 拆分。 -
showsd:这个选项显示标准差,以显示不同折叠组合之间结果的变化。这一点很重要,因为它能确保模型在所有数据集上都表现良好,并且在未来的数据上具有良好的泛化能力。 -
stratified:这确保每个折叠的数据包含相同比例的目标类。 -
print_every_n:这个选项告诉我们每隔多少次打印一次交叉验证的结果到控制台。 -
early_stopping_rounds:这是一个决定模型何时停止生长树木的值。你可以使用这个值来检查在给定轮次数内性能是否有所改善。当模型在达到设定的轮次限制后不再改善时,过程将停止。 -
maximize:这个选项标明评估指标是否是一个通过最大化分数或最小化分数来改进的指标。
现在,让我们对选定的超参数进行网格搜索。顾名思义,网格搜索会为所有定义的超参数值组合建模。使用这种技术,我们可以调整一些控制模型如何生长树木的设置,然后评估哪些设置提供了最佳的性能。xgboost的所有可调超参数的完整列表包含在软件包文档中。对于这个示例,我们将重点关注以下三个超参数:
-
max_depth:这是树的最大深度。由于我们只有 4 个特征,我们将尝试2、3和4的深度。 -
gamma:这是继续创建拆分所需的最小损失减少。将此级别设置得更高会创建更浅的树,因为对减少错误率贡献较小的节点不会进一步拆分。我们将尝试0、0.5和1的值。 -
min_child_weight:这是增长一个节点所需的实例数量。如果实例数少于此阈值,则树将停止从此节点分裂。这个数字越大,生成的树就越浅。在本例中,我们将尝试1、3和5这几个值。
我们现在将演示执行网格搜索所需的所有代码,以调整我们的参数到最佳值,从而提高模型性能:
- 我们的第一步将是通过将之前提到的值向量分配给参数网格中的各个超参数来定义我们的搜索网格。我们通过运行以下代码来定义我们将尝试的超参数值:
xgb_grid <- expand.grid(
nrounds = 500,
eta = 0.01,
max_depth = c(2,3,4),
gamma = c(0,0.5,1),
colsample_bytree = 0.75,
min_child_weight = c(1,3,5),
subsample = 0.8
)
如前面的代码所示,为了提高效率,我们将设置轮次为 500。尽管在实际情况下,您可以使用之前通过搜索最佳迭代得到的轮次。
在将eta包含到网格搜索中时,记得同时包含nrounds,因为随着学习率的降低,您将需要更多的迭代轮次。
- 接下来,我们将在
caret中使用trainControl函数来定义如何处理此参数搜索。为此,我们将列出代码并逐步解释所选择的设置。trainControl有很多其他设置,然而我们这一章只专注于其中几个。我们使用以下代码来设置模型训练方式:
xgb_tc = caret::trainControl(
method = "cv",
number = 5,
search = "grid",
returnResamp = "final",
savePredictions = "final",
verboseIter = TRUE,
allowParallel = TRUE
)
该函数的method和number参数仅定义我们的交叉验证策略,这里将使用 5 折交叉验证,和之前使用的一样。我们将使用网格搜索遍历在上一节代码中定义的所有超参数组合。接下来的两个参数用于在对所有组合建模后,保存最佳迭代的重采样和预测细节。最后,我们将verboseIter设置为TRUE,以便将迭代详情打印到控制台,并将allowParallel设置为TRUE,以使用并行处理提高计算速度。
- 在设置好网格搜索的值并定义搜索策略后,我们将再次运行模型,使用每一种可能的超参数组合。然后,我们在训练模型时会使用前两步设置中的网格搜索超参数:
xgb_param_tune = caret::train(
x = dtrain,
y = target,
trControl = xgb_tc,
tuneGrid = xgb_grid,
method = "xgbTree",
verbose = TRUE
)
运行此代码后,我们将在控制台看到一个类似于第一次运行模型时的报告。您的控制台应类似于以下截图:

报告显示了当前的折叠和当前的参数设置,当模型遍历所有五个不同数据划分的所有组合时。在前面的截图中,我们看到在数据的第五次划分上,对所有min_child_weight选项进行测试,同时保持其他条件不变。最终,我们看到选择了最佳的调优参数。
在这种情况下,最好的深度是使用所有特征,这并不奇怪,考虑到特征的缺乏。最佳的最小子节点权重是1,这意味着即使稀疏的节点仍然包含了对我们模型重要的信息。最佳的 gamma 值是0,这是默认值。随着这个值的增加,它对每个节点在分裂之前需要改进的误差率施加了轻微的约束。在这种情况下,经过我们的网格搜索,我们基本上仍然使用默认值;然而,我们可以看到选择这些默认值之外的替代方案的过程,如果它们有助于改进模型性能的话。
- 现在我们知道最佳的超参数设置,可以将它们重新插入之前运行的模型中,看看是否有任何改进。我们使用找到的参数和迭代次数训练我们的模型,优化性能,运行以下代码:
params <-list(
objective = "reg:linear",
booster = "gbtree",
eval_metric = "rmse",
eta=0.01,
subsample=0.8,
colsample_bytree=0.75,
max_depth = 4,
min_child_weight = 1,
gamma = 1
)
xgb <- xgboost::xgb.train(
params = params,
data = dtrain,
nrounds = 3162,
print_every_n = 10,
verbose = TRUE,
maximize = FALSE
)
pred <- stats::predict(xgb, dtest)
Metrics::rmse(test$Value,pred)
当我们运行上述代码时,我们训练我们的模型并进行预测,就像我们之前做的那样,我们还运行了一行代码来计算 RMSE 值。当我们运行这行代码时,我们将在控制台看到一个打印的值。您的控制台应该看起来像以下屏幕截图:

从计算错误率的结果来看,得分从 0.054 提高到了 0.022,这比我们第一次尝试有所改进。利用这些数据,即使特征有限,我们可能会认为时间序列模型是更好的选择;然而,只有 1 年的数据,时间序列方法不会捕捉到尚未存在的季节性影响。这种建模方式为未来几年创建了一张地图,并显示缺失数据可以通过简单使用日期和时间值来预测已知数据。这意味着我们可以估算未来几年的 NO2 值。在收集了几年的数据之后,可以使用时间序列方法进行预测,考虑到逐年趋势和此处捕获的季节性信息。
我们使用了xgboost,这是一种流行的树提升算法,用于预测伦敦某一地区的污染水平。早些时候,我们通过创建一个简单的模型来建立基准。然后,我们看了如何衡量性能。接着,我们采取了提高性能的步骤。虽然我们为这项任务选择了xgboost,但还有其他机器学习算法可供选择,我们将在接下来进行审查。
审查不同的算法
我们相对快速地介绍了机器学习,因为我们希望专注于随着我们进入深度学习而跟随的基本概念。因此,我们不能提供对所有机器学习技术的全面解释;然而,我们将快速回顾这里的不同算法类型,这将有助于未来记忆。
我们将快速审查以下几种机器学习算法:
-
决策树:决策树是一种简单的模型,它构成了许多更复杂算法的基本学习器。决策树简单地在给定的变量上将数据集进行划分,并记录每个分支中目标类别的比例。例如,如果我们要预测谁更可能喜欢玩婴儿玩具,那么基于年龄的划分可能会显示,包含 3 岁以下的数据显示出在
target变量中高比例的正确结果,也就是说,喜欢这种活动的人占较大比例,而年龄较大的则可能不太喜欢。 -
随机森林:随机森林类似于
xgboost,后者在本篇机器学习概述中有提到。随机森林与xgboost的显著区别在于,随机森林构建完整的决策树,这些树组成了基本学习器的集合。然后将这些简单的基本学习器模型的结果进行平均,得出比任何基本学习器更好的预测结果。这种技术被称为 bagging。相比之下,xgboost使用提升法(boosting),它会在构建先前基本学习器时学到的知识的基础上,应用额外的决策树来处理数据。尽管这两种方法都是有用且强大的集成结果和提升性能的方式,我们选择在本例中聚焦于xgboost,因为从前一次迭代中汲取的信息这一思想也同样体现在深度学习中。 -
逻辑回归与支持向量机(SVM):SVM 通过一条距离该空间中最近的两个点最远的直线来划分特征。然后将这个边界应用于测试数据,一侧的点按一种方式分类,而另一侧的点则按另一种方式分类。这与逻辑回归类似,主要的区别在于,逻辑回归会评估所有数据点,而 SVM 只包括离用于划分数据的直线最近的点。此外,当解释变量较少时,逻辑回归效果较好,而当数据集的维度较大时,SVM 效果更佳。SVM 会寻找一条分隔所有特征的直线,而逻辑回归则会使用一系列拟合度最好的直线来估计数据点属于目标变量中特定类别的概率。
-
KNN 和 k-means:这两种方法用于在数据中创建聚类。KNN 是一种监督学习技术。通过这种方法,模型将数据点绘制在k维特征空间中。当在训练过程中引入新点时,模型会识别出新点的最近邻,并将该类分配给这个记录。相对而言,k-means 是一种无监督学习技术,它在特征空间中找到质心,使得可以创建k个聚类,每个点根据与给定质心的最小距离来进行分类。
-
GBM 和 LightGBM:除了
xgboost,GBM 和 LightGBM 也提供了一种使用提升机制生成预测的方法,以提高迭代之间的模型性能。梯度提升机(GBM)是xgboost和 LightGBM 的前身。它大体上以相同的方式运作,通过在决策树基学习器上使用提升集成技术;然而,它在方法上较为原始。GBM 使用所有特征生长完整的树,而xgboost和 LightGBM 则有不同的方式来减少分裂的数量,从而加快树的生长速度。
xgboost和 LightGBM 之间的最大区别在于,xgboost在计算特征划分后,会为每棵树生长一个新层级,而 LightGBM 则仅会在最具预测性的叶子下方生长新层级。这种按叶子划分的方式相比于xgboost使用的按层级划分,提供了更快的速度优势。此外,专注于单一叶子的划分,模型能够更好地找到最小化误差的值,相较于划分整个层级,这能带来更好的性能。LightGBM 可能会超越xgboost,成为从业者的首选模型;然而,目前xgboost仍然被更广泛地使用,这也是为何在此简要概述中选择了它。
摘要
在本章中,我们使用了一个原始数据集,探索了数据,并采取了必要的数据预处理步骤,为建模做好准备。我们进行了数据类型转换,将以字符字符串形式存储的数字和日期分别转换为数值型和日期型列。此外,我们通过将日期值拆分成其组成部分,进行了特征工程。在完成预处理后,我们对数据进行了建模。我们采用了包括创建基准模型,并调优超参数以提高初始得分的方法。我们使用了早停轮次和网格搜索,找到了能够产生最佳结果的超参数值。在根据调优过程修改了模型的结果后,我们注意到性能有了显著提升。
本章讨论的所有机器学习方面的内容将在后续章节中继续使用。我们需要为建模准备数据,并且需要了解如何通过调整设置来提高模型的性能。此外,我们一直专注于xgboost中的决策树集成模型,因为在接下来的章节中我们将进行与神经网络相关的工作,内容类似。我们将像在xgboost中一样,考虑效率和性能,通过调整树的生长方式来实现。
这段机器学习的回顾为进入深度学习奠定了基础。在下一章中,我们将开始安装和探索所用的包。
第二章:为深度学习配置 R 环境
在本书中,我们将主要使用以下库进行深度学习:H2O、MXNet和Keras。我们还将专门使用限制玻尔兹曼机(RBM)包来处理 RBM 和深度信念网络(DBN)。此外,我们将在本书结尾时使用ReinforcementLearning包。
在本章中,我们将安装所有之前列出的包。每个包都可以用于在 R 中训练深度学习模型。然而,每个包都有其特定的优点和缺点。我们将探讨每个包的底层架构,这将帮助我们理解它们是如何执行代码的。除了RBM和ReinforcementLearning这两个包未使用 R 本地编写外,其它包都为 R 程序员提供了深度学习功能。这对于我们来说有重要的影响,从确保我们具备安装包所需的所有依赖开始。
本章将涵盖以下主要主题:
-
安装软件包
-
准备示例数据集
-
探索 Keras
-
探索 H2O
-
强化学习和 RBM
-
深度学习库对比
技术要求
你可以在github.com/PacktPublishing/Hands-on-Deep-Learning-with-R找到本章中使用的代码文件。
安装软件包
一些软件包可以直接从 CRAN 或 GitHub 安装,而H2O和MXNet则稍微复杂一些。我们将从最简单安装的包开始,然后转向那些更复杂的包。
安装 ReinforcementLearning
你可以通过使用install.packages来安装ReinforcementLearning,因为这个包有 CRAN 版本,使用以下代码行即可:
install.packages("ReinforcementLearning")
安装 RBM
RBM包仅在 GitHub 上提供,不在 CRAN 上发布,因此其安装方式稍有不同。首先,如果你还没有安装devtools包,需要先安装它。接着,使用devtools包中的install_github()函数代替install.packages来安装RBM包,代码如下:
install.packages("devtools")
library(devtools)
install_github("TimoMatzen/RBM")
安装 Keras
安装 Keras 的方式与我们安装RBM的方式类似,唯一的区别是稍微微妙但非常重要的。下载并安装包后,你需要运行install_keras()来完成安装。根据 Keras 的文档,如果你希望手动安装 Keras,则不需要调用install_keras()函数。
如果你选择这种方式,R 包将自动找到你已安装的版本。在本书中,我们将使用install_keras()来完成安装,代码如下:
devtools::install_github("rstudio/keras")
library(keras)
install_keras()
如果你更倾向于安装 GPU 版本,只需在调用函数时做出如下更改:
## for the gpu version :
install_keras(gpu=TRUE)
运行install_keras()将默认在虚拟环境中安装 Keras 和 TensorFlow,除了在 Windows 机器上——在写这本书时——此功能尚不支持,在这种情况下将使用conda环境,并且需要预先在 Windows 机器上安装 Anaconda。默认情况下,将安装 TensorFlow 的 CPU 版本以及最新版本的 Keras;可以添加一个可选参数来安装 GPU 版本,如前面的代码所示。对于本书,我们将接受默认值并运行install_keras。
如果你的机器上有多个版本的 Python,可能会遇到一些问题。如果你想使用的 Python 实例没有声明,R 将尝试通过先在常见位置查找,如usr/bin和usr/local/bin,来找到 Python。
使用 Keras 时,你可能希望指向 TensorFlow 虚拟环境中的 Python 实例。默认情况下,虚拟环境将命名为r-tensorflow。你可以使用 reticulate 包中的use_python()函数告诉 R 你想使用的 Python 版本。在函数内部,只需注明虚拟环境中 Python 实例的路径。在我的机器上,它如下所示:
use_python('/Users/pawlus/.virtualenvs/r-tensorflow/bin/python')
在你的机器上应该类似于这个样子。
一旦 R 找到正确的 Python 实例路径,接下来我们将在本章中介绍的代码应该可以正常运行。然而,如果没有引用正确的 Python 版本,你将遇到错误,代码将无法运行。
安装 H2O
对于 H2O,我们将使用 H2O 网站上的安装说明。通过这种方法,我们将首先搜索并移除任何先前安装的 H2O。接下来,安装 RCurl 和 jsonlite,然后从包含最新发布版本的 AWS S3 存储桶中安装 H2O。这个过程通过在获取软件包文件时简单地修改存储库位置来完成,默认情况下它是 CRAN 服务器。我们通过运行以下代码安装 H2O:
if ("package:h2o" %in% search()) { detach("package:h2o", unload=TRUE) }
if ("h2o" %in% rownames(installed.packages())) { remove.packages("h2o") }
pkgs <- c("RCurl","jsonlite")
for (pkg in pkgs) {
if (! (pkg %in% rownames(installed.packages()))) { install.packages(pkg) }
}
install.packages("h2o", type="source", repos=(c("http://h2o-release.s3.amazonaws.com/h2o/latest_stable_R")))
安装 MXNet
安装 MXNet 有多种方法。以下代码是设置 MXNet CPU 版本的最简单安装说明:
cran <- getOption("repos")
cran["dmlc"] <- "https://apache-mxnet.s3-accelerate.dualstack.amazonaws.com/R/CRAN/"
options(repos = cran)
install.packages("mxnet")
对于 GPU 支持,使用以下安装代码:
cran <- getOption("repos")
cran["dmlc"] <- "https://apache-mxnet.s3-accelerate.dualstack.amazonaws.com/R/CRAN/GPU/cu92"
options(repos = cran)
install.packages("mxnet")
使用 MXNet 需要 OpenCV 和 OpenBLAS。如果你需要安装这些,可以通过以下选项之一进行安装。
对于 macOS X,可以使用 Homebrew 来安装这些库:
-
如果尚未安装 Homebrew,安装说明可以在
brew.sh/找到。 -
如果你已经安装了 Homebrew,打开终端窗口并使用以下命令安装库:
brew install opencv
brew install openblas
- 最后,如此处所示,创建一个符号链接以确保使用的是最新版本的 OpenBLAS:
ln -sf /usr/local/opt/openblas/lib/libopenblas.dylib /usr/local/opt/openblas/lib/libopenblasp-r0.3.1.dylib
对于 Windows,过程稍微复杂一些,因此本书中不会详细说明:
-
要安装 OpenCV,请按照
docs.opencv.org/3.4.3/d3/d52/tutorial_windows_install.html中提供的说明进行操作。 -
要安装 OpenBLAS,请按照
github.com/xianyi/OpenBLAS/wiki/How-to-use-OpenBLAS-in-Microsoft-Visual-Studio中提供的说明进行操作。
在安装了 OpenCV 和 OpenBLAS 后,前面的几行代码应该可以正常工作,以下载和安装 MXNet 包。不过,如果在尝试加载库时遇到错误,可能需要构建 MXNet 包并创建 R 包。完成这些操作的说明非常清晰且详细,但它们太长,无法包含在本书中:
-
对于 macOS X:
mxnet.incubator.apache.org/versions/master/install/osx_setup.html -
对于 Windows:
mxnet.incubator.apache.org/versions/master/install/windows_setup.html
如果按照构建 MXNet 库和 R 绑定所需的步骤操作后,下载和安装包时仍然出现问题,这可能是由于多种可能的原因,很多问题已经有文档说明。不幸的是,尝试解决所有可能的安装场景和问题超出了本书的范围。不过,为了学习目的,你可以通过 Kaggle 网站上的内核使用 MXNet,那里可以使用 MXNet。
准备示例数据集
对于 Keras、H2O 和 MXNet,我们将使用成人普查数据集,该数据集使用美国普查数据来预测某人的年收入是否超过 50,000 美元。我们将在这里进行 Keras 和 MXNet 示例的数据准备,这样就不需要在两个示例中重复相同的代码:
- 在以下代码中,我们将加载数据并标注这两个数据集,以便将它们合并:
library(tidyverse)
library(caret)
train <- read.csv("adult_processed_train.csv")
train <- train %>% dplyr::mutate(dataset = "train")
test <- read.csv("adult_processed_test.csv")
test <- test %>% dplyr::mutate(dataset = "test")
运行上述代码后,我们的库已经加载并准备好使用。我们还加载了train和test数据集,现在可以在Environment面板中看到它们。
- 接下来,我们将合并这些数据集,以便可以同时对所有数据进行一些更改。为了简化这些示例,我们将使用
complete.cases函数来删除包含NA的行。我们还将删除字符项周围的空格,以便像Male和Male这样的项都被视为相同的项。让我们来看一下以下代码:
all <- rbind(train,test)
all <- all[complete.cases(all),]
all <- all %>%
mutate_if(~is.factor(.),~trimws(.))
- 接下来,我们将在
train数据集上执行一些额外的预处理步骤。首先,我们使用filter()函数从名为all的合并数据框中提取train数据。然后,我们将提取target列作为向量,并移除target和label列。我们通过以下代码来隔离train数据和train目标变量:
train <- all %>% filter(dataset == "train")
train_target <- as.numeric(factor(train$target))
train <- train %>% select(-target, -dataset)
- 现在,我们将分离数值列和字符列,以便对字符列进行编码,准备一个完全数值化的矩阵。我们通过以下代码分离数值列和字符列:
train_chars <- train %>%
select_if(is.character)
train_ints <- train %>%
select_if(is.integer)
- 接下来,我们将使用
caret中的dummyVars()函数,将列中的字符值转换为单独的列,并通过将1分配给行来指示某个字符字符串是否存在。如果字符字符串不存在,那么该列在这一行中将包含0。我们通过运行以下代码执行这一步骤:
ohe <- caret::dummyVars(" ~ .", data = train_chars)
train_ohe <- data.frame(predict(ohe, newdata = train_chars))
- 数据转换后,我们将使用以下代码将两个数据集重新合并:
train <- cbind(train_ints,train_ohe)
- 接下来,我们将通过运行以下代码对
test数据集执行相同的步骤:
test <- all %>% filter(dataset == "test")
test_target <- as.numeric(factor(test$target))
test <- test %>% select(-target, -dataset)
test_chars <- test %>%
select_if(is.character)
test_ints <- test %>%
select_if(is.integer)
ohe <- caret::dummyVars(" ~ .", data = test_chars)
test_ohe <- data.frame(predict(ohe, newdata = test_chars))
test <- cbind(test_ints,test_ohe)
- 当我们创建目标向量时,它将因子值转换为
1和2。但是,我们希望将其转换为1和0,因此我们将从向量中减去1,如以下代码所示:
train_target <- train_target-1
test_target <- test_target-1
- 最后一步是清理
train数据集中的一列,因为它在test数据集中不存在。我们通过运行以下代码来移除这一列:
train <- train %>% select(-native.countryHoland.Netherlands)
现在我们已经加载并准备好了这个数据集,可以在下一步中使用它来展示一些初步示例,演示我们已安装的所有包。此时,我们的目标是查看语法,确保代码能够运行且库已正确安装。在后续章节中,我们将深入探讨每个包的详细内容。
探索 Keras
Keras 由 Francois Chollet 创建并维护。Keras 声称自己是为人类设计的,因此常见的用例非常简单,语法清晰易懂。Keras 可与多种低级深度学习语言配合使用,在本书中,Keras 将作为接口,帮助我们利用多个流行的深度学习后端,包括 TensorFlow。
可用函数
Keras 提供了对多种深度学习方法的支持,包括以下内容:
-
循环神经网络 (RNNs)
-
长短期记忆 (LSTM) 网络
-
卷积神经网络 (CNNs)
-
多层感知机 (MLPs)
-
变量自编码器
这并不是一个详尽无遗的列表,针对其他方法的支持也可以提供。然而,这些是本书后续章节中将涉及的内容。
一个 Keras 示例
在此示例中,我们将训练一个多层感知器,使用我们刚刚准备的成人普查数据集。此示例包含在内,以介绍该包的语法,并展示无需过多代码即可完成的基础练习:
如果系统中安装了多个版本的 Python,可能会出现问题。使用reticulate包和use_python()函数来定义您希望使用的 Python 实例的路径;例如,use_python(usr/local/bin/python3)。您也可以在.Rprofile文件中使用RETICULATE_PYTHON来设置 R 应该使用的 Python 实例路径。
- 首先,我们将加载
tensorflow和keras库,如下所示:
library(tensorflow)
library(keras)
- 接下来,我们将把数据集转换为矩阵,如下所示:
train <- as.matrix(train)
test <- as.matrix(test)
- 现在,我们可以创建一个顺序模型,该模型将按顺序通过每一层。我们将有一层,然后我们将编译结果。我们通过运行以下代码来定义我们的模型:
model <- keras_model_sequential()
model %>%
layer_dense(units=35, activation = 'relu')
model %>% keras::compile(loss='binary_crossentropy',
optimizer='adam',
metrics='accuracy')
- 在前一步中,我们定义了我们的模型,现在,在以下代码中,我们将把此模型拟合到我们的训练数据集:
history <- model%>%
fit(train,
train_target,
epoch=10,
batch=16,
validation_split = 0.15)
- 最后,我们可以通过将模型结果与
test目标值进行比较来评估我们的模型。我们通过运行以下代码来评估模型性能:
model%>%
keras::evaluate(test,test_target)
这是keras的一般语法。如我们所示,它与管道操作兼容,并且具有对 R 程序员来说熟悉的语法。接下来,我们将查看一个使用 MXNet 包的例子。
探索 MXNet
MXNet 是由 Apache 软件基金会设计的深度学习库。它支持命令式编程和符号编程。通过将带有依赖关系的函数序列化,同时并行运行没有依赖关系的函数,它被设计成具有较高的速度。它支持 CPU 和 GPU 处理器。
可用函数
MXNet 提供了运行多种深度学习方法的手段,包括以下内容:
-
卷积神经网络(CNN)
-
循环神经网络(RNN)
-
生成对抗网络(GAN)
-
长短时记忆网络(LSTM)
-
自动编码器
-
受限玻尔兹曼机/深度玻尔兹曼网络(RBM/DBN)
-
强化学习
开始使用 MXNet
对于 MXNet,我们将使用相同的准备好的成人普查数据集。我们还将使用多层感知器作为我们的模型。如果您熟悉使用其他常见机器学习包来拟合模型,使用 MXNet 拟合模型将非常熟悉:
- 首先,我们将使用以下代码行加载 MXNet 包:
library(mxnet)
- 然后,我们将定义我们的多层感知器。为了可重复性,我们设置了一个
seed值。之后,训练数据将转换为数据矩阵,并作为参数传递给模型,同时传递训练目标值,如下所示:
mx.set.seed(0)
model <- mx.mlp(data.matrix(train), train_target, hidden_node=10, out_node=2, out_activation="softmax",
num.round=10, array.batch.size=20, learning.rate=0.05, momentum=0.8,
eval.metric=mx.metric.accuracy)
- 接下来,我们将通过将模型应用于
test数据的矩阵版本来进行预测,如以下代码所示:
preds = predict(model, data.matrix(test))
- 然后,我们可以使用混淆矩阵来评估性能,将调整后的目标类别放在y-轴上,预测结果放在x-轴上,如下所示:
pred.label = max.col(t(preds))-1
table(pred.label, test_target)
对于有 R 语言机器学习编程经验的人来说,MXNet 的语法应该看起来很熟悉。训练模型的函数接受描述性数据和目标数据,并且捕捉多个选项的值,就像使用 RandomForest 或 XGBoost 一样。
这些选项略有不同,我们将在后面的章节中介绍如何为这些参数赋值。然而,语法与其他 R 语言机器学习库的语法非常相似。接下来,我们将编写代码,使用 H2O 训练一个最小化的模型。
探索 H2O
H2O 的出现时间比 Keras 和 MXNet 更久,并且仍然被广泛使用。它利用 Java 和 MapReduce 内存压缩来处理大数据集。H2O 被用于许多机器学习任务,并且也支持深度学习。特别地,H2O 原生支持前馈人工神经网络(多层感知机)。H2O 还执行自动数据准备和缺失值处理。加载数据时需要使用一种特殊的数据类型:H2OFrame。
可用的函数
H2O 原生只支持前馈神经网络。与其他主要的深度学习包相比,这为该库带来了明显的限制。然而,这仍然是一个非常常见的深度学习实现方法。此外,H2O 允许将大对象存储在 H2O 集群的内存之外。由于这些原因,H2O 仍然是学习深度学习时值得了解的一个有价值的库。
一个 H2O 示例
在这个示例中,我们将再次使用成人人口普查数据集来预测收入。与我们的 Keras 示例一样,这个示例将保持极简,并且我们只会涵盖足够的内容来展示与 H2O 交互的语法,以及与其他包不同的设计细节:
- 使用 H2O 时的第一个主要区别是,我们必须显式地初始化我们的 H2O 会话,这将生成一个 Java 虚拟机实例,并将其与 R 连接。这可以通过以下代码行来实现:
# load H2O package
library(h2o)
# start H2O
h2o::h2o.init()
- 加载数据以供 H2O 使用时,需要将数据转换为
H2OFrame。H2OFrame与数据框非常相似,主要区别在于对象的存储位置。数据框存储在内存中,而H2OFrame存储在 H2O 集群中。对于非常大的数据集来说,这个特性可能是一个优势。在下面的示例中,我们将通过两步过程将数据转换为适当的格式。首先,我们按常规方式读取csv文件加载数据。然后,我们将数据框转换为H2OFrame。我们使用以下代码将数据转换为适当的格式:
## load data
train <- read.csv("adult_processed_train.csv")
test <- read.csv("adult_processed_test.csv")
# load data on H2o
train <- as.h2o(train)
test <- as.h2o(test)
- 对于这个例子,我们将执行一些插补作为唯一的预处理步骤。在此步骤中,我们将替换所有缺失值,对于数值数据我们使用
mean,对于因子数据我们使用mode。在 H2O 中,设置column = 0将函数应用于整个数据框。值得注意的是,该函数是作用于数据的;然而,不需要将结果分配给新对象,因为插补结果会直接反映在作为函数参数传递的数据中。还值得强调的是,在 H2O 中,我们可以将一个向量传递给方法参数,它将在此情况下用于每个变量,首先检查是否可以使用第一种方法,如果不行,则切换到第二种方法。通过运行以下代码行可以完成数据的预处理:
## pre-process
h2o.impute(train, column = 0, method = c("mean", "mode"))
h2o.impute(test, column = 0, method = c("mean", "mode"))
- 此外,在此步骤中,我们将定义
dependent和independent变量。dependent变量存储在target列中,而所有其余列包含independent变量,这些变量将在该任务中用于预测target变量:
#set dependent and independent variables
target <- "target"
predictors <- colnames(train)[1:14]
- 在所有准备步骤完成后,我们现在可以创建一个最小模型。H2O 的
deeplearning函数将创建一个前馈人工神经网络。在这个例子中,只包含运行模型所需的最小内容。然而,该函数可以接受 80 到 90 个参数,我们将在后面的章节中介绍这些参数。以下代码中,我们为模型提供一个名称,识别训练数据,通过复制模型中涉及的伪随机数设置种子以确保可重复性,定义dependent和independent变量,并指定模型应运行的次数以及每轮数据的切分方式:
#train the model - without hidden layer
model <- h2o.deeplearning(model_id = "h2o_dl_example"
,training_frame = train
,seed = 321
,y = target
,x = predictors
,epochs = 10
,nfolds = 5)
- 运行模型后,可以使用以下代码行在外部折叠样本上评估性能:
h2o.performance(model, xval = TRUE)
- 最后,当我们的模型完成时,集群必须像初始化时那样显式关闭。以下函数将关闭当前的
h2o实例:
h2o::h2o.shutdown()
我们可以在这个例子中观察到以下几点:
-
H2O 的语法与其他机器学习库有很大不同。
-
首先,我们需要初始化 Java 虚拟机,并且我们需要将数据存储在该包的特殊数据容器中。
-
此外,我们可以看到,通过在数据对象上运行函数而不将更改分配回对象,插补会发生。
-
我们可以看到,我们还需要包括所有自变量的列名,这与其他模型略有不同。
-
所有这些都是在说明,使用 H2O 时它可能会显得有些不熟悉。它在算法的可用性方面也有限。但它能够处理更大的数据集,这一点是该包的明显优势。
现在我们已经了解了全面的深度学习包,我们将重点关注使用 R 编写的,执行特定建模任务或一组有限任务的包。
探索 ReinforcementLearning 和 RBM
ReinforcementLearning和RBM包与之前介绍的库有两个重要的不同之处:首先,它们是专门化的包,仅包含单一深度学习任务的函数,而不是试图支持各种深度学习选项;其次,它们完全用 R 语言编写,没有额外的语言依赖。这是一个优点,因为之前的库的复杂性意味着,当包外部发生变化时,包可能会崩溃。对于这些库的支持页面,充满了安装常见问题和故障排除的示例,以及一些包突然停止工作或被弃用的案例。在这些情况下,我们鼓励你继续在 CRAN 和其他网站上寻找解决方案,因为 R 社区以其动态发展和强大的支持闻名。
强化学习示例
在这个示例中,我们将创建一个强化学习的样本环境。强化学习的概念将在后续章节中更详细地探讨。在这个示例中,我们将生成一系列状态和动作,以及采取这些动作的奖励,即,采取行动是否导致了期望的结果或负面后果。然后,我们将定义我们的代理如何响应或从这些动作中学习。一旦所有这些内容都被定义,我们将运行程序,代理将通过环境进行学习并解决任务。我们将通过运行以下代码定义并执行一个最小的强化学习示例:
library(ReinforcementLearning)
data <- sampleGridSequence(N = 1000)
control <- list(alpha = 0.1, gamma = 0.1, epsilon = 0.1)
model <- ReinforcementLearning(data, s = "State", a = "Action", r = "Reward", s_new = "NextState", control = control)
print(model)
在这个示例中,我们可以看到以下内容:
-
语法非常熟悉,类似于我们可能使用的许多其他 R 包。此外,我们还可以看到,我们可以使用极少的代码完成一个简单的强化学习任务。
-
在该包的 GitHub 代码库中,所有函数都是用 R 语言编写的,这为探索问题发生的可能原因提供了便利。如果出现问题,也能够减少对更复杂包中其他语言的依赖,这一点较为重要。
一个 RBM 示例
这里是一个使用RBM包的简单示例。RBM 模型可以通过MXNet库创建。然而,我们在本书中包含这个包,是为了说明何时使用MXNet训练RBM模型最为合适,以及何时独立实现算法可能会更合适。
在以下示例中,我们将train Fashion MNIST 数据集分配给一个对象,在此数据上创建一个 RBM 模型,并使用模型的结果进行预测。关于 RBM 算法如何实现这一结果以及建议的应用将在后续章节中详细探讨。我们将通过运行以下代码,看到我们如何使用熟悉的语法简单地训练此模型并用于预测:
library(RBM)
data(Fashion)
train <- Fashion$trainX
train_label <- Fashion$trainY
rbmModel <- RBM(x = t(train), y = train_label, n.iter = 500, n.hidden = 200, size.minibatch = 10)
test <- Fashion$testX
test_label <- Fashion$testY
PredictRBM(test = t(test), labels = test_label, model = rbmModel)
与ReinforcementLearning包类似,以下内容适用:
-
RBM 完全用 R 编写,因此浏览代码库是更好地理解这种特定技术如何工作的一个绝佳方法。
-
此外,正如之前提到的,如果你只需要使用 RBM 训练模型,那么使用一个独立的包是避免加载过多不必要的函数的好方法,这种情况在使用像 MXNet 这样的库时会出现。
-
综合包和独立包在深度学习工作流中各有其位置,因此本书将重点介绍它们各自的优缺点。
比较深度学习库
在比较本章中强调的三种综合机器学习库(Keras、H2O 和 MXNet)时,有三个主要的区别:外部语言依赖性、函数和语法(易用性和认知负担)。我们将逐一介绍这些主要区别。
三个软件包之间的第一个主要区别是它们的外部语言依赖性。如前所述,这些软件包都不是用 R 编写的。这意味着你需要在机器上安装额外的语言才能使这些软件包正常工作。这也意味着你不能轻松查看源文档来了解某个函数是如何工作的,或者为什么会收到特定的错误(当然,前提是你知道某种语言)。这些软件包是使用以下语言编写的:Keras 用 Python,H2O 用 Java,MXNet 用 C#。
下一个主要区别涉及每个软件包中可以实现的模型类型。你可以使用这三个软件包训练前馈模型,例如多层感知器,其中所有隐藏层都是全连接层。Keras 和 MXNet 允许你训练包括不同类型隐藏层的深度学习模型,并且支持层间反馈循环。这些模型包括 RNN、LSTM 和 CNN。MXNet 还支持其他算法,包括 GAN、RBM/DBN 和强化学习。
最后一个主要区别涉及模型的语法。有些语法非常熟悉,这使得它们更容易学习和使用。当代码类似于你用于其他用途的 R 代码时,你需要记住的内容会更少。为此,Keras 采用了一种非常熟悉的语法。它是模块化的,这意味着每个函数在整体模型中执行一个离散的步骤,所有函数都可以链式连接起来。
这与 tidyverse 函数如何连接在一起进行数据准备非常相似。MXNet 的语法类似于其他机器学习库,其中数据集和目标变量向量被传递给函数以训练模型,同时还有许多额外的参数来控制模型的创建。H2O 的语法则与常见的 R 编程习惯最为不同。它要求在任何建模之前初始化一个集群。数据也必须存储在特定的数据对象中,一些函数通过对该对象调用函数来操作数据对象,而不需要将结果分配给一个新对象,这与典型的 R 编程不同。
除了这些差异,Keras 还提供了使用 TensorFlow 的方法,而 H2O 允许将较大的对象存储在内存之外。如前所述,MXNet 提供了最强大的深度学习算法库。正如我们所看到的,每个包都有其优点,在本书中我们将深入探讨它们,并在此过程中指出最合适的使用案例和应用。
总结
完成本章后,你应该已经安装了本书中将使用的所有库。此外,你还应该熟悉每个库的语法,并且已经看到过如何使用它们中的每个来训练模型的初步示例。我们还探讨了深度学习库之间的一些差异,指出了它们的优点以及局限性。这三大主要库(Keras、MXNet 和 H2O)在工业和学术界广泛应用于深度学习,理解这些库将使你能够解决多个深度学习问题。现在,我们准备深入探索这些库。不过,在此之前,我们将回顾一下神经网络——所有深度学习的基础构建块。
在接下来的章节中,你将学习人工神经网络,它构成了所有深度学习的基本构建模块。在下一章中我们还不会使用这些深度学习库;然而,神经网络如何编码的基础知识将对我们后续的学习至关重要。在下一章中,我们所涵盖的内容将继续推进,并在我们编写深度学习模型示例时非常有用。所有深度学习的案例都是基本神经网络的变体,我们将在下一章中学习如何创建这些神经网络。
第三章:人工神经网络
在本章中,你将学习关于人工神经网络的知识,它是所有深度学习的基础。我们将讨论是什么使深度学习与其他形式的机器学习不同,然后花时间深入探讨一些其特定和特殊的特点。
到本章结束时,我们将了解是什么使深度学习成为机器学习的一个特殊子集。我们将理解神经网络,它们如何模仿大脑,以及隐藏层在离散元素检测中的优势。我们将创建一个前馈神经网络,并注意激活函数在确定变量权重中的作用。
本章将涵盖以下主题:
-
将深度学习与机器学习进行对比
-
比较神经网络和人脑
-
理解隐藏层的作用
-
创建前馈网络
-
使用反向传播增强我们的神经网络
技术要求
本章的源代码请参考 GitHub 链接:github.com/PacktPublishing/Hands-on-Deep-Learning-with-R。
将深度学习与机器学习进行对比
深度学习的一个关键优势是其他机器学习方法所没有的,它能够考虑变量之间的关系。例如,如果我们回想起当初学习动物时,可以想象一个简单的任务,我们被给出了五张猫的照片和五张狗的照片;之后,当我们看到一张新图片时,我们能够通过之前学习到的模式来判断这是一只猫还是狗。在我们的例子中,图片是要被分类为猫或狗的对象。我们可以把这个例子看作是一个训练集,并且在分类图片时使用相同的术语。从心理上讲,我们的大脑试图将这些图片与形成这两种不同物种特征的模式匹配,从而帮助我们区分它们。这在深度学习中也是如此。
今天,我们会发现前面的任务相当简单;然而,想象一下计算机必须如何学习这个任务:
-
它需要考虑狗和猫的特征之间的关系,并且无论动物在照片中的占比多少,都会对每一张照片进行这种处理。
-
对此,我们不能使用标准的机器学习方法,其中所有输入都被直接使用,而不考虑它们之间的关系。必须考虑像素在二维空间中的位置和相对距离,因此我们已经可以看到,对人类来说是一个简单的任务,机器却变得更加复杂。
-
此外,仅以二维数组的形式评估输入数据是不够的。机器还需要深度学习中存在的多个隐藏层架构,以识别数据数组中的多种不同模式。换句话说,单纯的信号并不像相互接近的信号之间的关系那样有用。
-
每一层都会识别图像中不同方面的存在,从基本的形状检测到颜色渐变的长度和陡峭度。
在执行简单的回归或分类任务时,我们会为每个变量应用一个权重,然后用这个权重预测结果。对于深度学习,存在一个中间步骤,其中人工神经元或单元是基于所有变量创建的。这个步骤创建了新的特征,这些特征是所有变量的组合,并应用了不同的权重。这一过程发生在所谓的隐藏层中。之后,信号会传递到另一个隐藏层,重复相同的过程。
每一层都会学习输入的不同方面。让我们来看一个例子:
-
第一层通过对负空间和正空间应用不同的权重,根据图像中物体的整体大小来创建神经元。
-
在第二层,神经元可能会根据耳朵和鼻子的形状和大小来创建。
-
通过这种方式,猫和狗的不同特征会被捕捉到所有隐藏层中。
初始时,权重是随机分配的。然后会与实际答案进行比较。经过多次尝试,模型学会了通过调整权重的方向来获得更好的结果,因此它会继续沿着相同的方向调整权重,直到选定的误差率最小化。
一旦完成并且权重被学习到,可以引入新的输入。模型将把所有变量与每个神经元的学习权重相乘,每个神经元将使用一个激活函数来决定是否激活或发火,并将信号传递到下一层。我们将在本章稍后详细讨论各种激活函数。目前,我们可以简单地说,在每个神经元上都会进行计算,最终在输出节点产生一个值。在这种情况下,概率是图像是狗还是猫。
在这个图示中,我们可以开始看到神经网络和深度学习的强大力量。模型不是孤立地评估每个变量,而是将它们一起考虑。相比之下,回归模型为每个单独的变量计算权重。回归模型可以使用交互项来计算变量组合的权重;然而,即便如此,它也没有像神经网络那样,在所有神经元中评估所有变量。由所有变量创建的神经元随后用于定义下一层中神经元的集合。通过这种方式,整个特征空间都会被考虑,并根据评估所有变量后出现的主题进行划分。
比较神经网络与人脑
让我们考虑一下人类大脑是如何学习的,从而了解神经网络在某些方面的相似性和不同之处。
我们的大脑包含大量的神经元,每个神经元与成千上万个附近的神经元相连接。当这些神经元接收到信号时,如果输入包含一定量的某种颜色或某种纹理,它们会被激活。经过数百万个这样的互联神经元激活后,大脑会将传入的信号解读为某一类。
当然,这些连接不是永久设定的,而是随着我们不断获得经验、注意到模式并发现关系而动态变化的。如果我们第一次尝试一种新水果,发现它非常酸,那么所有帮助我们识别这种水果的特征就会与我们已知的酸味联系在一起。未来,是否愿意再次体验这种水果的味道将取决于我们是否愿意再体验这种酸味。
另一个展示我们大脑中的神经网络如何不断演变的例子聚焦于我们发现愉快的活动类型。例如,你有没有想过,为什么婴儿觉得摇动一个简单的玩具很有趣,而我们却不这么觉得?在我们的脑中,新奇会通过释放类鸦片物质来获得奖励;然而,随着某一刺激变得不再那么令人惊讶,解读这种体验所需的神经元数量会减少,导致反应不那么强烈,因为更少的神经元在激活。通过这种方式,我们看到了大脑中神经连接的动态性质,它们始终处于变化之中。
当我们讨论神经元之间的连接时,我们特指的是神经元之间的突触。人工神经网络试图通过创建一个由构造的神经元和一个或多个输出节点之间的巨大连接网络,模拟人脑的学习方式,粗略地近似大脑的神经元。就像大脑中连接神经元的突触可以变强或变弱一样,神经元之间的权重也可以在训练过程中发生变化。
然而,与人类大脑不同,我们在本章研究的简单人工神经网络并不以任何遗传的权重和连接开始。它们从权重和连接的随机分配开始。此外,虽然在训练过程中权重会发生变化,但这种变化在模型应用时并不会继续。此时,训练过程中得到的权重会被应用,并且没有持续的调整。这与人类大脑有所不同。我们大脑中的神经网络确实与人工神经网络类似;然而,神经元连接的调整会不断更新。最后,人类大脑拥有数十亿个神经元和万亿级的连接,而我们即将构建的人工神经网络的神经元和连接要少得多。
尽管大脑学习的方式和人工神经网络学习的方式存在一些显著的差异,通过共享相似的设计结构,人工神经网络能够解决一些极其复杂的任务。这个理念不断发展和改进,在本书的过程中,我们将看到这一理念对数据科学的巨大影响。
在隐藏层中利用偏置和激活函数
当我们之前描述深度学习时,我们提到其定义特征是存在由神经元组成的隐藏层,这些神经元包含数据集中所有预测变量的加权和。我们刚刚讨论了这种互联神经元的结构是如何模仿人类大脑的。现在,让我们更深入地了解这些隐藏层中神经元创建时所发生的事情。
在这一点上,我们可以推导出以下结论:
-
我们理解到,所有变量都会根据我们希望在每一层创建的神经元单元数量,随机为每个神经元分配一个系数。
-
算法随后继续调整这些系数,直到最小化误差率。
-
然而,在这个将加权值传递给神经元的过程中,还有一个附加的系数,这就是所谓的偏置函数。
偏置函数可以简单地理解为一种调节数据在横向上分隔的线形的方式。目前,我们可以假设在二维空间中绘制了一条直线,用于将数据点分开。在以下的示例中,不论我们如何调整直线的斜率,我们都无法找到一条能够将三角形和圆形数据点分开的直线:

然而,如果我们稍微调整一下直线,使其与图形的 y 轴交点位于图中心的上方,那么我们就可以找到一条在两类数据点之间的最佳拟合线。这就是偏置函数的作用。它通过调整截距点,允许我们找到更合适的拟合线:

偏置函数是一个系数,它通过这种方式调整x轴上的直线,以应对数据要求直线与y等于0且x可能等于5的情况。
所有加权单元和偏置函数的值在神经元内被求和,并在一个线性划分的空间中绘制。计算一个点相对于这条直线的位置,决定了神经元是否激活或开关,并继续向前发送信号,或者关闭。如果使用这种类型的函数作为阈值来确定传入信号的处理方式,这种神经网络被称为感知器,是最早的人工神经网络形式。
然而,随着神经网络的发展,越来越明显的是,使用线性模型来分离数据并不适用于所有情况。因此,现在有许多可以在神经元内执行的函数,以决定信号在通过时是否继续传递。这些门控函数被称为激活函数,因为它们模拟了大脑中神经元被触发激活并向连接的神经元发送信号或不发送信号的过程。此时,让我们探索一下可用的各种激活函数。
调查激活函数
激活函数是我们尚未深入探讨的神经网络的最后一部分。回顾我们到目前为止所知道的,在神经网络中,我们从输入开始,就像我们在任何机器学习建模任务中一样。这些数据包括我们希望预测的目标变量(依赖变量),以及用于此预测任务的任意数量的独立预测变量。
在训练过程中,独立变量在模拟神经元中被加权并组合。此步骤还会应用偏置函数,并将此常数值与加权后的独立变量值结合。在此时,一个激活函数会评估这些值的聚合,如果聚合值超过设定的阈值限制,那么神经元就会被激活,信号将传递给其他隐藏层(如果存在),或者传递到输出节点。
我们来考虑最简单的激活函数——Heaviside 或二元阶跃函数。这可以通过视觉化的方式想象为两条水平线,它们作为阈值限制,位于一条垂直线的两侧,将数据分割,形成像阶梯一样的形状。如果值处于 1 的水平线上,那么信号会继续传递;否则,神经元就不会激活。我们之前也提到过,如何在这一步使用一条对角线来线性地分离点。当这些简单的激活函数无法分离数据点时,我们可以使用非线性的替代函数,接下来我们将介绍这些函数。
探索 sigmoid 函数
Sigmoid 函数是经典的 S 型函数。这种函数特别适用于逻辑回归任务。尽管大多数结果会通过曲线两侧的尾部进行分类,但曲线中间有一个区域,用于捕捉部分数据的不确定性。这种形状的缺点是,在极端位置,梯度几乎为零,因此随着数据点向两侧移动,模型可能无法继续学习。
Sigmoid 函数还包含一个导数值,这意味着我们可以将该函数与反向传播一起使用,在变量通过额外层之后更新权重。我们将在本章的最后部分更详细地探讨反向传播。
Sigmoid 函数的另一个优点是它将值限制在 0 和 1 之间,从而使值方便地被限定。
sigmoid函数可以通过 R 代码简单地定义:
sigmoid = function(x) {
1 / (1 + exp(-x))
}
在这个函数中,我们可以看到动态值是负x的指数。
让我们在-10到10之间的数值序列上使用这个 Sigmoid 函数:
- 首先,我们将创建一个包含两列的
tibble。一列将包含数值序列,另一列将使用将这些数值序列作为参数传递给sigmoid函数后的结果:
vals <- tibble(x = seq(-10, 10, 1), sigmoid_x = sigmoid(seq(-10, 10, 1)))
- 接下来,我们使用
ggplot()函数设置图形的基础,通过传入数据对象并定义用于X轴和Y轴的值。在这个例子中,我们使用-10到10之间的数值序列作为X轴的值,使用将这些数值传递到 sigmoid 函数后的结果作为Y轴的值:
p <- ggplot(vals, aes(x, sigmoid_x))
- 现在我们将添加数据点,并使用
stat_function特性将这些点连接起来,展示出 S 型曲线的形状:
p <- p + geom_point()
p + stat_function(fun = sigmoid, n = 1000)
如果我们现在观察这个形状,我们可以理解为什么这种激活函数在逻辑回归中表现得如此出色。通过使用sigmoid函数来转换我们的数值,大多数数值已经被推近到0或1的极端位置:

研究双曲正切函数
双曲正切函数,也称为tanh,与 sigmoid 函数非常相似;然而,曲线的下限位于负数区域,更适合处理包含负值的数据。
除了这一点,双曲正切函数与 sigmoid 函数其他方面完全相同,和 sigmoid 函数一样,双曲正切函数也包含导数元素,可以与反向传播一起使用。
由于 tanh 的值在-1到1之间,它的梯度较大,导数更加明显。被限定的特点使得 tanh 围绕0中心,这对于具有大量隐藏层的模型来说是有利的,因为层之间的结果更容易被下一个层使用。
让我们使用相同的数值序列,并绘制通过双曲正切函数转换后的值,该函数是 R 的基础函数之一,可以通过tanh()调用:
- 如我们在前面的 sigmoid 示例中所做的,我们将创建一个
tibble,其中包含我们的数值序列及其转换后的值:
vals <- tibble(x = seq(-10, 10, 1), tanh_x = tanh(seq(-10, 10, 1)))
- 然后,我们通过将数据集以及用于x轴和y轴的值传递给
ggplot()函数,来设置我们绘图的基础:
p <- ggplot(vals, aes(x, tanh_x))
- 最后,我们再次添加我们的点,并使用
stat_function功能来连接这些点并显示我们的形状:
p <- p + geom_point()
p + stat_function(fun = tanh, n = 1000)
观察这个形状时,我们可以看到它与我们刚才绘制的 sigmoid 形状非常相似;然而,请注意,y轴现在的范围是-1到1,而不是 sigmoid 形状中的0到1。因此,值被推向极端,负值在转换后仍然是负值:

绘制整流线性单元激活函数
整流线性单元(ReLU)是一个混合函数,对于正值的x,它拟合一条直线,而对于任何负值的x,它将其赋值为 0。尽管该函数的一半是线性的,但它的形状是非线性的,并且具有非线性的所有优点,例如可以使用导数进行反向传播。
与前两个激活函数不同,ReLU 没有上限。这种没有约束的特性有助于避免 sigmoid 或 tanh 函数的问题,即在极值附近梯度变得非常平缓,提供的信息很少,无法帮助模型继续学习。ReLU 的另一个主要优点是它会导致神经网络的稀疏性,因为在中心点附近有掉头现象。使用 sigmoid 或 tanh 时,函数的零值输出非常少,这意味着激活函数会触发,从而导致网络密集。相比之下,ReLU 会产生更多的零输出值,导致更少的神经元触发,从而形成一个更稀疏的网络。
ReLU 比 sigmoid 和 tanh 学习得更快,因为它的简单性。ReLU 函数比 sigmoid 或 tanh 产生更多的零值,这将提高训练过程的速度;然而,由于我们不再知道这些点的未修改值,它们不能在之后的反向传播中更新。如果权重需要在反向传播过程中调整以传递相关信息,这可能会成为一个问题;然而,一旦导数被设置为零,就无法再进行调整。
让我们编写一些代码来可视化 ReLU 函数:
- 首先,我们将定义这个函数。它简单地定义为,如果x的值大于 0,那么它将x设置为y,并创建一个斜率为
1的直线;否则,它将y设置为x,并在x轴上创建一条水平线。可以这样编码:
relu <- function(x){dplyr::if_else(x > 0, x, 0)}
- 接下来,让我们再次使用之前相同的数字序列创建数据集,并将该序列通过我们的 ReLU 函数后得到的转换值:
vals <- tibble(x = seq(-10, 10, 1), relu_x = relu(seq(-10, 10, 1)))
- 最后,让我们绘制这些点并连接它们,以展示该激活函数的形状:
p <- ggplot(vals, aes(x, relu_x))
p <- p + geom_point()
p + geom_line()
现在,当我们观察这个形状时,可以看到它的优点。没有上界为模型提供了比 sigmoid 更多的信息,因为 sigmoid 在极端值附近的梯度变得非常微小。此外,将所有负值转换为0会导致神经网络更加稀疏,从而加快训练速度:

计算 Leaky ReLU 激活函数
ReLU 的一个潜在问题是死亡 ReLU,由于该函数对所有负值赋值为零,因此信号在到达输出节点之前可能会完全丢失。解决这个问题的一种方法是使用 Leaky ReLU,当数值为负时,赋一个小的 alpha 值,从而避免信号完全丢失。应用这个常数后,本应为零的值现在有了一个小的斜率。这可以防止神经元完全失效,使得信息仍然能够传递,从而改善模型。
让我们创建一个简单的 Leaky ReLU 激活函数示例:
- 我们从定义函数开始。我们将以与 ReLU 函数完全相同的方式进行操作,不同之处在于,我们不是将所有负值赋值为
0,而是乘以一个常数以提供一个小的斜率:
leaky_relu <- function(x,a){dplyr::if_else(x > 0, x, x*a)}
- 然后,我们创建包含我们在这些示例中使用的数值序列的数据集,以及转换后的值:
vals <- tibble(x = seq(-10, 10, 1), leaky_relu_x = leaky_relu(seq(-10, 10, 1),0.01))
- 最后,我们绘制这些点来展示这个激活函数的形状:
p <- ggplot(vals, aes(x, leaky_relu_x))
p <- p + geom_point()
p + geom_line()
当我们查看这个形状时,我们可以理解为什么有时这种替代 ReLU 的方法更可取。对于负值的x拥有轻微的斜率可以解决死亡 ReLU 问题,在该问题中,神经网络变得过于稀疏,缺乏足够的数据来围绕预测收敛:

定义 swish 激活函数
Swish 是一种较新开发的激活函数,旨在发挥 ReLU 的优势,同时解决它的一些缺点。Swish 与 ReLU 一样,具有下界且没有上界,这是其优势,因为它仍然可以使神经元失效,同时防止数值被迫收敛到上界。然而,与 ReLU 不同的是,下界仍然是曲线形的,更值得注意的是,这条线是非单调的,这意味着当x值减小时,y值可以增加。这是一个重要的特点,可以防止死亡神经元问题,因为导数可以在迭代中继续调整。
让我们来研究这个激活函数的形状:
- 让我们像在其他示例中一样,首先定义函数。这个公式简单地接受一个值,并将其乘以通过 sigmoid 函数传递相同值的结果:
swish <- function(x){x * sigmoid(x)}
- 接下来,我们将再次创建我们的数据集,使用之前已经使用过的相同值序列,并附上对应的转换值集合:
vals <- tibble(x = seq(-10, 10, 1), swish_x = swish(seq(-10, 10, 1)))
- 最后,我们可以绘制这些点,展示这个函数的形状:
p <- ggplot(vals, aes(x, swish_x))
p <- p + geom_point()
p + geom_line()
当我们观察这个形状时,会发现它像 ReLU 一样有下界,但没有上界;然而,与 ReLU 及其他激活函数不同,它是非单调的——也就是说,我们可以看到当x为负时,y的值先减小后增大。这个特性已经证明在神经网络逐渐加深时尤其有益:

使用 softmax 预测类别概率
当有多个目标变量时,softmax()函数是必需的。Softmax 将计算输入变量属于每个类别的概率。计算出这些结果后,可以用来将输入行分配到可能的目标类别之一。让我们通过一个略有不同的示例来探索这个激活函数:
- 我们将从定义函数开始:
softmax <- function(x) {exp(x) / sum(exp(x))}
- 接下来,让我们将一个值的向量传递给函数:
results <- softmax(c(2,3,6,9))
results
[1] 0.0008658387 0.0023535935 0.0472731888 0.9495073791
- 让我们确认这些转换后的值的总和等于
1:
sum(results)
[1] 1
我们可以看到这个函数将接受一组值,并计算每个值是我们试图预测的值的概率,以便所有概率的总和为1。这可以用来从多个可能的值中选择最可能的一个,作为给定目标的预测值。
创建一个前馈网络
在理解神经网络的基础上,我们将构建一些简单的示例。首先,我们将创建自己需要的函数,以便创建一个非常简单的神经网络,从而更好地理解建模过程中发生了什么。然后,我们将使用neuralnet包,利用一个简单的数据集构建一个神经网络来解决一个任务。
使用 Base R 编写神经网络
在这个示例中,我们将使用 Base R 从头开始创建一个非常简单的神经网络,以便更好地理解每个步骤发生了什么。为了完成这个任务,我们将执行以下操作:
-
定义我们模型中神经元的激活函数
-
创建一个函数,显示每次学习权重后的线条
-
制作一些测试数据并绘制这些数据值
-
使用前一次尝试的结果更新权重
我们将使用以下步骤来实现:
- 首先,我们编写 Heaviside(二进制)阶跃激活函数作为开始。我们会回忆一下这个函数的工作原理,它评估输入值,如果该值大于零,则函数的输出值为
1;否则,输出值为0。我们可以使用以下代码行将这个逻辑转化为代码:
artificial_neuron <- function(input) { as.vector(ifelse(input %*% weights > 0, 1, 0))
}
- 接下来,我们可以创建一个绘制直线的函数,首先使用随机权重,然后随着迭代并更新传递给曲线函数的表达式部分的值,最终使用学习到的权重,如下所示:
linear_fits <- function(w, to_add = TRUE, line_type = 1) {curve(-w[1] / w[2] * x - w[3] / w[2], xlim = c(-1, 2), ylim = c(-1, 2), col = "black",lty = line_type, lwd = 2, xlab = "Input Value A", ylab = "Input Value B", add = to_add)}
在表达式方程中,我们可以看到一切都相对于x。在这种情况下,如果我们仅仅运行curve((x)),那么我们将得到一条 45 度的直线,这样x和y总是相等,且直线的斜率为1。在前面的代码中,我们使用权重来改变相对于x的直线斜率。剩下的部分只是定义了图像和直线,add参数用于声明通过曲线函数生成的新直线是否应添加到现有的图像中。
- 在定义了这些函数后,我们现在可以为输入、输出、学习率和权重分配一些初始值。输入值是一组x和y坐标,并且包含一个常数值。输出值只是二进制标志,在此情况下表示输入变量是否为目标类的成员。最后,一些随机权重被用来初始化模型,同时还包括一个学习率,用于在每次迭代时更新权重:
input <- matrix(c(1, 0,
0, 0,
1, 1,
0, 1), ncol = 2, byrow = TRUE)
input <- cbind(input, 1)
output <- c(0, 1, 0, 1)
weights <- c(0.12, 0.18, 0.24)
learning_rate <- 0.2
- 接下来,我们可以添加我们的第一条线,这条线是通过使用随机权重以及我们的输出点来进行猜测的。我们希望得到一条完全二分目标类的两个点和非目标类的两个点的直线。首先,我们将初始的随机权重应用到
linear_fits()函数中的表达式方程,创建一个初始的斜率。points()函数通过为属于目标类的两个点绘制方形标记,为不属于该类的点绘制圆形标记,来添加我们的点:
linear_fits(weights, to_add = FALSE)
points(input[ , 1:2], pch = (output + 21))
从下面这个由前面的代码生成的图像中我们可以看出,这第一条线与二分这两类数据相差甚远:

- 现在,我们将开始通过使用相应的输入和输出值来更新权重。首先,权重通过学习率进行更新。这个数字越小,变化就越平缓。它与目标类值减去人工神经元函数结果的乘积相乘,该结果仍然是
0或1,因为我们使用的是二值步进激活函数以及输入的初始值。然后,再次使用linear_fits()函数绘制一条新的直线:
weights <- weights + learning_rate * (output[1] - artificial_neuron(input[1, ])) * input[1, ]
linear_fits(weights)
使用linear_fits()函数,我们创建了一条更接近于二分这两类数据的直线,但它还没有完全按照我们希望的方式划分这些点:

- 这个相同的操作对剩余的输入和输出值重复进行。由于我们将看到,这条线解决了问题,并找到了一个能够分离两类的斜率,因此在最后的图表中使用了不同的线型。接下来绘制第三条线:
weights <- weights + learning_rate * (output[2] - artificial_neuron(input[2, ])) * input[2, ]
linear_fits(weights)
在这里,第三行与第二行完全重合:

- 下列代码用于绘制第四条线:
weights <- weights + learning_rate * (output[3] - artificial_neuron(input[3, ])) * input[3, ]
linear_fits(weights)
第四条线偏离了第二条和第三条线更远,未能很好地将点分开:

- 最后一条线是通过以下代码生成的:
weights <- weights + learning_rate * (output[4] - artificial_neuron(input[4, ])) * input[4, ]
linear_fits(weights, line_type = 2)
我们看到虚线成功地将方形点和圆形点分开:

在这个最小示例中,我们可以看到在拟合神经网络时发生了什么。该模型试图像其他机器学习模型一样将不同类别的变量分开。在这个过程中,每次迭代时,权重会根据神经元是否激活以及学习率值引入的常数变化进行更新。通过这一过程,目标是减少错误率。在本示例中,我们没有定义正式的错误率,因为我们可以清楚地看到当线条成功地分开类别时的效果。在下一个示例中,我们将不再关注底层数学,而是着重于使用一个开源数据集优化一个稍微复杂一些的神经网络。
使用威斯康星癌症数据创建模型
在这个示例中,我们将使用来自威斯康星大学的乳腺癌数据集。有关该数据集的详细信息,可以在 UCI 机器学习库的archive.ics.uci.edu/ml/datasets/breast+cancer+wisconsin+%28diagnostic%29中找到。
- 数据集可以通过以下代码加载:
library(tidyverse)
wbdc <- readr::read_csv("http://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.data", col_names = FALSE)
- 加载数据后,我们将通过将包含两个字符值的列转换为数值数据类型,来对目标变量进行编码,以表示是否存在恶性迹象。对于这种类型的神经网络,我们需要所有值都是数字类型,包括目标变量:
wbdc <- wbdc %>%
dplyr::mutate(target = dplyr::if_else(X2 == "M", 1, 0)) %>%
dplyr::select(-X2)
- 接下来,我们将对所有预测变量进行缩放和标准化。正如我们提到的,所有数据必须是数字类型,以便神经网络使用,通过缩放和标准化数据,我们将提高性能,因为这会将激活函数的值约束在相同的范围内:
wbdc <- wbdc %>% dplyr::mutate_at(vars(-X1, -target), funs((. - min(.))/(max(.) - min(.)) ))
- 接下来,我们将进行训练和测试数据的划分,就像在我们的机器学习示例中一样。我们将在此步骤中使用
X1中的 ID 列来拆分数据;不过,之后我们可以删除这一列。这里,我们将采用tidyverse方法来简化过程:
train <- wbdc %>% dplyr::sample_frac(.8)
test <- dplyr::anti_join(wbdc, train, by = 'X1')
test <- test %>% dplyr::select(-X1)
train <- train %>% dplyr::select(-X1)
- 我们的最后一步数据准备是从测试数据集中提取所有实际的正确响应,然后将这一列从测试数据集中移除。这个值的向量将用于在建模后计算性能:
actual <- test$target
test <- test %>% dplyr::select(-target)
数据现在已经完全准备好,可以用于神经网络建模。
- 接下来,我们需要为
neuralnet包创建公式语法,其中包含一个因变量~。这与 R 中拟合线性模型的语法类似。所有自变量可以通过+符号连接起来;然而,当自变量有很多时,写出每一列的名称会非常繁琐,即使在这个例子中,列名只是 X 后跟一个数字。幸运的是,有一种方法可以加速这个过程,我们将在接下来的步骤中使用它。首先,我们将获取训练集中所有列的名称,然后使用 paste 和 collapse 来创建自变量的字符串,将它们放在公式中因变量的另一侧:
n <- names(train)
formula <- as.formula(paste("target ~", paste(n[!n == "target"], collapse = " + ", sep = "")))
- 使用这组数据,我们现在可以拟合我们的模型。在这个例子中,我们将保持模型相对简单,只使用这个函数可用的一些参数。具体来说,我们包括了刚刚创建的公式,定义了因变量和自变量。接下来,我们指出模型将拟合训练数据。选择正确的层数和每层单元数涉及到尝试几种组合并比较性能。在这个例子中,我们从两层开始,每层的单元数大约是变量数的一半。最后,我们注意到激活函数应该是逻辑函数,也就是 sigmoid 函数,并且我们不进行线性运算:
net <- neuralnet::neuralnet(formula,
data = train,
hidden = c(15,15),
linear.output = FALSE,
act.fct = "logistic"
)
- 模型建模过程完成后,我们现在可以使用我们的模型进行预测。使用
neuralnet包,我们通过compute()函数来生成这些预测值:
prediction_list <- neuralnet::compute(net, test)
- 当我们通过
compute()函数传递模型和测试数据集时,返回的是一个列表。该列表包含了模型中神经元的详细信息以及预测值。在这种情况下,我们只需要预测值,因此我们将从列表中提取这些值。此外,我们还将创建一组二进制预测。二进制预测是通过将预测概率大于0.5的值设置为1来生成的;否则,值将设置为0。我们将使用每一组预测进行两种不同的模型评估方法:
predictions <- as.vector(prediction_list$net.result)
binary_predictions <- dplyr::if_else(predictions > 0.5, 1, 0)
- 使用我们的二进制预测,我们可以轻松计算基本的模型准确性——也就是说,我们将求出二进制预测值与实际值匹配的案例数,并将这个数字除以实际值的总数:
sum(binary_predictions == actual)/length(actual)
在这里,我们看到准确率为 92.98%,所以我们的基本神经网络在这个数据上表现得相当好。
- 我们还可以通过使用混淆矩阵来查看准确度值的分解。生成混淆矩阵的最简单方法是使用
confusionMatrix()函数,它是caret包的一部分。该函数需要一个包含预测值和实际值的表作为参数。在这种情况下,我们需要使用我们的二元预测,因为结果需要适应四种类别之一,因此不允许更高的粒度级别。
results_table <- table(binary_predictions, actual)
library(caret)
caret::confusionMatrix(results_table)
# Confusion Matrix and Statistics
#
# actual
# binary_predictions 0 1
# 0 67 2
# 1 1 44
#
# Accuracy : 0.9737
# 95% CI : (0.925, 0.9945)
# No Information Rate : 0.5965
# P-Value [Acc > NIR] : <2e-16
#
# Kappa : 0.9451
# Mcnemar's Test P-Value : 1
#
# Sensitivity : 0.9853
# Specificity : 0.9565
# Pos Pred Value : 0.9710
# Neg Pred Value : 0.9778
# Prevalence : 0.5965
# Detection Rate : 0.5877
# Detection Prevalence : 0.6053
# Balanced Accuracy : 0.9709
#
# 'Positive' Class : 0
- 调用此函数后,我们会看到提供了一个包含结果的二维网格。混淆矩阵将我们的预测分为以下四种结果:
-
真阳性:值为
1被正确预测为1。实际测试目标变量包含我们预测的值,我们正确地预测了它。 -
I 型错误:值为
0被错误预测为1。实际测试目标变量不包含我们预测的值;然而,我们预测它会包含。也被称为假阳性。 -
II 型错误:值为
1被错误预测为0。实际测试目标变量确实包含我们预测的值;然而,我们预测它不会包含。也被称为假阴性。 -
真阴性:值为
0被正确预测为0。实际测试目标变量不包含我们预测的值,我们正确地预测了它不会包含。
它还包括一些其他的统计度量,这些超出了本章的范围;然而,我们可以注意到,准确度被包含在内,并且该值与我们刚才自己计算的值相匹配。
除了使用二元预测计算准确度外,我们还可以使用我们的概率值,从而考虑到每个结果的确定性程度。为了使用这些值衡量性能,我们将使用 AUC(曲线下面积)得分。它比较了真实阳性案例的概率与假阳性案例的概率。最终结果是一个衡量正值为正且负值为负的信心值,或者在本例中,衡量负值不会被高置信度错误标记为正值。
- 为了计算 AUC 得分,我们可以使用
auc()函数,它是Metrics包的一部分。该函数接受两个参数——一个实际值向量和一个预测概率向量,该向量表示根据模型对该行独立变量的解释,一个记录应被分类为目标变量的概率:
library(Metrics)
Metrics::auc(actual, predictions)
AUC 得分为 0.987,甚至比之前计算的准确度得分还要强。
该模型已经在使用该数据集解决预测任务方面表现得非常好;然而,我们现在将尝试添加一个反向传播步骤,看看是否可以进一步提高性能。
增强我们的神经网络,采用反向传播
到此为止,我们已经有了一个可运行的神经网络。对于这个简单的示例,我们将加入神经网络的一个附加特性,这个特性可以提高性能,那就是反向传播。神经网络可以通过将变量与值相乘,从而在经过隐藏层时对变量进行加权,学习去解决任务。反向传播步骤允许模型回溯各层,并调整在先前步骤中学习到的权重。
- 在实际操作中,这一步是相当直接的实现。我们只需声明将使用反向传播算法,并指明学习率,学习率控制着权重调整的幅度。通常,这个学习率值应该非常低。
在以下示例中,我们需要做以下几步:
-
threshold值和stepmax值必须进行调整,因为使用默认值时模型未能收敛。 -
threshold参数定义了误差率必须达到的值,模型才会停止;而stepmax参数定义了模型在停止前将运行的迭代次数。
通过更改这些值,你可以编程让模型运行得更久,或者更早停止,如果在收敛时遇到错误,这两者都会有所帮助:
bp_net <- neuralnet::neuralnet(formula,
data = train,
hidden = c(15,15),
linear.output = FALSE,
act.fct = "logistic",
algorithm = "backprop",
learningrate = 0.00001,
threshold = 0.3,
stepmax = 1e6
)
- 运行这个新版本的模型后,我们可以重新运行相同的步骤来评估性能。首先,我们将在新模型上运行
compute以获得新的预测值。我们将再次创建一个包含概率和二元预测的向量,并且作为第一步,我们将创建二元预测值和实际值的表格,并将其传递给confusionMatrix()函数。由于confusionMatrix()函数的输出已经包括了准确率,因此这次我们跳过计算准确率:
prediction_list <- neuralnet::compute(bp_net, test)
predictions <- as.vector(prediction_list$net.result)
binary_predictions <- dplyr::if_else(predictions > 0.5, 1, 0)
results_table <- table(binary_predictions, actual)
caret::confusionMatrix(results_table)
- 我们的准确率有所提高,从 92.98% 增加到 94.74%。现在,让我们检查一下我们的 AUC 得分。同样,我们只需将实际值和预测概率传递给
auc()函数:
Metrics::auc(actual, predictions)
我们的 AUC 得分有所提高,从 0.987 增加到 0.993,因此我们可以看到,反向传播确实提高了模型性能。
话虽如此,在这一步骤中究竟发生了什么呢?
-
反向传播步骤取误差率的导数,并利用此导数根据结果更新权重。
-
导数只是当前权重对误差率影响的变化率。所以,如果导数率为
7,那么改变权重一个单位将导致误差率变化 7 倍。 -
仅使用前馈神经网络,我们可以根据最终的导数值更新初始权重;然而,使用反向传播时,我们可以在每个神经元上更新权重。
-
通过利用先前的变化如何影响导数的信息,这一步要么增加,要么减少权重。
-
学习率的应用确保了变化不会剧烈,而是平稳和逐步进行。这个过程可以持续,直到误差率最小化。
总结
在这一章中,我们了解到,深度学习与其他机器学习算法的区别在于使用了多个隐藏层。这个由人工神经元组成的隐藏层网络被设计成模拟我们大脑处理输入信号以解读环境的方式。隐藏层中的单元接收所有独立变量,并对这些变量应用一定的权重。通过这种方式,每个神经元以不同的方式对输入值的组合进行分类。
从高层次理解这种机器学习架构后,我们深入探讨了使用这种方法将输入转换为预测的实际过程。我们讨论了各种激活函数,它们作为每个神经元的“门”,决定是否将信号传递到下一层。随后,我们构建了两个前馈神经网络——一个使用基础 R 语言以便更好地理解发生了什么,另一个则在更大的数据集上使用neuralnet包。最后,我们应用了反向传播步骤,进一步改进了我们的模型。
如前所述,人工神经网络是更复杂的深度学习的基本构建块,现在我们已经掌握了这一理解,接下来将在下一章创建用于图像识别的卷积神经网络。
第二部分:深度学习应用
本节涵盖了图像处理、自然语言处理、推荐系统和预测分析等领域的各种深度学习应用。读者将学习如何解决包括图像识别和信号检测在内的识别问题,如何编程总结文档,进行主题建模,以及预测股市价格。
本节包括以下章节:
-
第四章,用于图像识别的卷积神经网络(CNN)
-
第五章,用于信号检测的多层感知机
-
第六章,使用嵌入的神经协同过滤
-
第七章,自然语言处理中的深度学习
-
第八章,用于股票预测的长短期记忆网络
-
第九章,用于人脸生成的生成对抗网络
第四章:CNNs 用于图像识别
在本章中,您将学习如何使用卷积神经网络(CNNs)进行图像识别。卷积神经网络是神经网络的一种变体,特别适合图像识别,因为它们考虑了空间中数据点之间的关系。
本章将介绍卷积神经网络如何与我们在上一章创建的基本前馈全连接神经网络有所不同。主要区别在于,CNN 中的隐藏层并非全部是全连接的密集层——CNN 包括一些特殊层。其中之一是卷积层,它在图像空间周围卷积一个滤波器。另一个特殊层是池化层,它减少输入的大小,并仅保留特定的值。我们将在本章稍后的部分深入探讨这些层。
在我们学习这些概念时,我们会看到它们为何在图像识别中如此重要。当我们思考图像分类时,我们知道需要在像素阵列中检测模式,并且邻近的像素对于寻找特定形状非常重要。通过了解卷积层,您将知道如何根据您的图像数据调整滤波器或镜头以检测不同的模式。您还将学习如何根据数据的大小调整池化层,以帮助使模型运行得更高效。
具体来说,本章将覆盖以下主题:
-
使用浅层网络进行图像识别
-
使用卷积神经网络进行图像识别
-
通过适当的激活层增强模型
-
选择最合适的激活函数
-
使用 dropout 和早期停止选择最佳 epochs
技术要求
您可以在github.com/PacktPublishing/Hands-on-Deep-Learning-with-R上的 GitHub 链接找到本章的代码文件。
使用浅层网络进行图像识别
图像分类器可以在不使用深度学习算法和方法的情况下创建。为了演示,让我们使用Fashion MNIST数据集,它是 MNIST 手写数据集的替代品。MNIST 的名字代表修改版国家标准与技术研究所数据库,正如名称所示,它是由国家标准与技术研究所创建的原始数据集的修改版。MNIST 是一系列手绘数字,而 Fashion MNIST 使用的是不同类型服装的小图像。数据集中的服装被标注为十个类别之一。Fashion MNIST 与国家标准与技术研究所无关,但由于 MNIST 作为图像识别的数据库广为人知,所以名称得以延续。
由于这个数据集不大,而且每个图像只有 28 x 28 像素,因此我们可以使用机器学习算法,例如RandomForest,来训练分类器。我们将训练一个非常简单的RandomForest模型,并取得令人惊讶的好结果;然而,在本章的最后,我们将讨论为什么这些结果在数据集变大以及单个图像变大时无法扩展。我们现在将使用传统的机器学习方法编写我们的图像识别模型:
- 我们将从加载
tidyverse套件包开始,如下所示的代码。在此情况下,我们只需要readr来读取数据;不过,我们稍后还会使用其他包。我们还将加载randomForest来训练我们的模型,并加载caret来评估模型的表现:
library(tidyverse)
library(caret)
library(randomForest)
这里的代码不会向控制台返回任何值;但是,在 RStudio 环境中,我们会看到在包窗口旁边出现一个勾选标记,表示这些包已准备好使用。你的 Packages 面板应显示如下图,表明三个包中的两个已经加载完毕:

- 接下来,我们将借助以下代码读取 Fashion MNIST 数据集的训练数据和测试数据:
fm <- readr::read_csv('fashionmnist/fashion-mnist_train.csv')
fm_test <- readr::read_csv('fashionmnist/fashion-mnist_test.csv')
这段代码将在我们的环境中创建两个数据对象,分别名为fm和fm_test。环境面板应显示如下截图:

我们将使用fm来训练我们的模型。fm中的数据将用于计算沿树模型的分割权重。然后,我们将使用包含自变量值与目标变量关系的模型,利用自变量值预测fm_test数据的目标变量。
- 接下来,我们将训练我们的模型。我们设置一个种子值以确保结果可复现,这样每次运行模型时我们都会得到相同的伪随机数,因此每次结果都相同。我们将标签转换为因子。标签在这里是一个介于
0和9之间的整数;然而,我们不希望模型将这些值当作数值处理。相反,它们应该被视为不同的类别。除标签外,剩余的列都是像素值。我们使用~来表示我们将使用所有剩余的列(所有像素值)作为模型的自变量。我们将生成 10 棵树,因为这只是一个示例,展示了图像分类可以通过这种方式完成。最后,我们将在每次分割时随机选择 5 个变量。我们将使用以下代码以这种方式训练我们的RandomForest模型:
set.seed(0)
rf_model <- randomForest::randomForest(as.factor(label)~.,
data = fm,
ntree=10,
mtry=5)
当我们执行代码时,模型将开始运行,可能需要几分钟。在此期间,我们将无法在控制台执行任何代码。我们可以看到模型现在已经进入我们的环境。以下截图显示了模型对象中的一些细节:

我们可以使用这个模型对象来对新数据进行预测。
- 然后,我们使用模型对测试数据集进行预测,并使用
ConfusionMatrix函数来评估性能。以下代码将填充预测值的向量,并评估预测的准确性:
pred <- predict(rf_model, fm_test, type="response")
caret::confusionMatrix(as.factor(fm_test$label), pred)
# Accuracy : 0.8457
上述代码将创建最后一个数据对象,它是一个向量,包含基于该数据集的独立变量训练的模型为每个案例预测的值。我们还在控制台打印了一些输出,包括性能指标。你收到的输出将如下所示:

这些指标是通过将测试数据集的实际目标变量与从测试数据中建模得到的预测值进行比较来计算的。
出乎意料的是,这个模型产生了不错的结果。我们已经达到了 84.6%的准确率。这表明,简单的方法可以适用于像这样的数据集;然而,随着数据量的增加,这种类型的模型表现会变得更差。
为了理解原因,我们首先需要解释图像如何作为数据存储用于建模。当我们查看灰度图像时,我们看到的是明暗不同的区域。实际上,每个像素都包含一个从 0 到 255 之间的整数,0 表示白色,255 表示黑色,中间的值则代表不同的灰度。这些数字被转换成音调,以便我们能可视化图像;然而,为了我们的目的,我们直接使用这些原始的像素值。在使用RandomForest进行建模时,每个像素值都会与其他图像进行单独比较;然而,这通常不是最理想的做法。通常,我们希望在每个图像内寻找更大的像素模式。
让我们来探讨如何创建一个只有一层的浅层神经网络。神经网络的隐藏层将使用所有输入值进行计算,以便考虑整个图像。为了演示,我们将这个问题设定为一个简单的二项分类问题,并使用与上一章类似的方法来创建我们的神经网络。如果你完成了上一章的内容,那么这将可能看起来很熟悉。完成上一章的内容不是先决条件,因为我们将在这里逐步讲解所有步骤:
- 在开始之前,我们需要加载两个额外的库:
neuralnet包用于训练我们的模型,Metrics包用于评估函数。特别是,我们稍后将使用 AUC 指标来评估我们的模型。通过运行以下代码行,可以加载这两个库:
library(neuralnet)
library(Metrics)
这段代码不会在控制台中产生任何效果;但是,我们会在包面板中看到这些包的检查,表明它们已经准备好使用。你的包面板将显示如下截图:

- 首先,我们将修改target列,使其成为一个简单的二元响应,而不是包含所有十个类别。这样做是为了保持这个神经网络的简洁,因为它只是为了创建一个基准,便于与后续的卷积神经网络(CNN)进行比较,并展示两种风格的神经网络编码方式的差异。这个筛选过程通过运行以下代码来实现:
fm <- fm %>% dplyr::filter(label < 2)
fm_test <- fm_test %>% dplyr::filter(label < 2)
运行这段代码后,我们将看到数据对象的大小发生了变化,经过筛选后数据变小了。你应该能看到数据对象的观察值从原来的 60,000 和 10,000 分别减少到 12,000 和 2,000,如下图所示:

以这种格式的数据,我们现在可以继续编写代码,作为一个二分类任务。
- 现在,使用以下代码,我们将从测试集中移除目标变量,并将其隔离到一个单独的向量中,以便稍后进行评估:
test_label <- fm_test$label
fm_test <- fm_test %>% dplyr::select(-label)
运行这段代码后,你会注意到两个变化:fm_test对象中少了一个变量或列,并且新增了一个数据对象叫做test_label,它是一个包含fm_test对象中标签列值的向量。你的环境面板应该如下图所示:

我们之所以做出这个修改,是因为我们不希望在测试对象中包含标签。在这个对象中,我们需要将数据视为我们不知道真实类别的情况,这样我们可以尝试预测类别。然后,我们稍后会使用来自向量的标签来评估我们预测正确值的效果。
- 接下来,我们将为我们的神经网络创建公式。使用
neuralnet包中的neuralnet函数,我们需要将目标变量放在波浪号(~)的一侧,所有自变量则放在另一侧,并通过加号(+)连接。在以下代码中,我们将所有列名收集到一个向量n中,然后使用paste将这个向量中的每一项与加号连接起来:
n <- names(fm)
formula <- as.formula(paste("label ~", paste(n[!n == "label"], collapse = " + ", sep = "")))
运行这段代码后,我们可以在环境面板中看到变化。我们将看到向量n,它包含了所有列名,还有formula对象,它将因变量和自变量按照正确的格式放在一起。你的环境面板现在应该如下图所示:

我们运行前面的代码是为了创建这个formula对象,因为这是使用neuralnet包训练神经网络的要求。
- 之后,我们可以编写代码来训练我们的模型。我们将设置一个种子以确保可复现性,就像我们在建模时总是做的那样。我们将包含一个隐藏层,单位数设为大约预测变量数量的三分之一。我们将
linear.output参数设置为false,表示这是一个分类模型。我们还将激活函数设置为logistic,因为这是一个分类问题。我们将使用以下代码按照之前描述的方式训练我们的模型:
set.seed(0)
net <- neuralnet::neuralnet(formula,
data = fm,
hidden = 250,
linear.output = FALSE,
act.fct = "logistic"
)
运行代码后,我们现在在环境面板中有一个新对象,包含了所有从训练模型中获得的细节,这些细节现在可以用来对新数据进行预测。你的环境面板中应该包含一个类似下面截图所示的模型对象:

现在我们已经运行了这段代码,我们有了一个模型,可以用它对我们的测试数据进行预测。
- 最后,我们可以借助以下代码进行预测并评估我们的结果:
prediction_list <- neuralnet::compute(net, fm_test)
predictions <- as.vector(prediction_list$net.result)
Metrics::auc(test_label, predictions)
运行这段代码将把准确度指标打印到控制台。你的控制台应该包含如下图像中的输出:

从这个输出结果来看,我们已经有了显著的提升。准确度现在达到了 97.487%。当像素值被联合考虑时,确实提高了结果。我们应该记住,这个模型只使用了两个目标变量,这些目标变量的选择可能也是准确度显著提升的原因之一。无论如何,在处理更大的图像时,将所有像素值输入激活函数并不高效。这就是卷积神经网络发挥作用的地方。它们能够通过观察较小的像素值组来寻找模式,并且还包含一种减少维度的方式。
现在,让我们来探索一下卷积神经网络与传统神经网络的区别。
使用卷积神经网络进行图像识别
卷积神经网络是一种特殊形式的神经网络。在传统神经网络中,输入作为向量传递到模型中;然而,对于图像数据,将数据以矩阵的形式排列更为有利,因为我们希望捕捉像素值在二维空间中的关系。
卷积神经网络能够通过一个滤波器来捕捉这些二维关系,滤波器在图像数据上进行卷积。滤波器是一个矩阵,具有固定的值和比图像数据更小的维度。这些常数值与底层值相乘,结果的乘积之和将通过激活函数进行处理。
激活函数步骤,也可以视为一个独立的层,用于评估图像中是否存在某个特定模式。在传统神经网络中,激活层决定输入值计算出的结果是否超过阈值,并将其传递到模型中。在卷积神经网络中,激活层的工作方式非常相似;然而,由于它使用矩阵乘法,因此能够评估数据中是否存在二维形状。
在激活层之后,数据会通过池化层进一步处理。池化层的功能是集中前一步骤捕获的信号,同时减少数据的维度。池化层将生成一个比输入数据更小的矩阵。通常会使用一个 2 x 2 的池化层,这样可以将输入数据的大小缩小一半。在这种情况下,每个 2 x 2 区域内的值通过某种聚合方式进行汇聚。这些值可以通过任何方式进行聚合,例如对值求和或取平均;然而,在大多数情况下,采用最大值,并将该值传递到池化层。
在前面的方法实现之后,处理并压缩后的图像数据会被扁平化,向前传递到一个基本上是传统神经网络的最后一步。让我们从这一最终步骤开始,因为我们已经熟悉使用传统神经网络进行建模。在这里,我们将看到两件事:首先,我们可以只通过最后这一步来训练图像数据模型;其次,尽管语法略有不同,但与使用 neuralnet 包训练这种模型相比,整体结构是可以识别的。
现在我们将使用 keras 包编写一个由全连接的密集隐藏层组成的神经网络:
- 首先,我们将加载
keras库以及该包自带的 Fashion MNIST 数据集。通过运行以下代码来完成这一操作:
library(keras)
fashion_mnist <- dataset_fashion_mnist()
当我们运行前面的代码时,我们会看到将训练数据和测试数据一起存储在一个列表对象中。你的环境面板现在应该如下所示:

- 接下来,我们可以将数据集拆分成各个部分。它被方便地设置为容易提取训练集、测试集和目标变量。在前面的代码中,我们使用的是已经预处理过的 Fashion MNIST 数据集,每个像素值都在单独的列中;然而,在接下来的代码中,我们将从一个 28 x 28 的大矩阵数组开始,演示如何将这些数据转换,使得每个图像的所有像素值都在同一行。这个过程的第一步是使用前面的代码将列表中的四个数据对象分离出来:
train <- fashion_mnist$train$x
train_target <- fashion_mnist$train$y
test <- fashion_mnist$test$x
test_target <- fashion_mnist$test$y
当您现在查看环境窗格时,您将看到图像数据存储在 28 x 28 的矩阵中,目标变量存储在数组中的向量内。您的环境窗格将如下所示:

有了这种格式的数据,我们可以应用我们的卷积滤波器,我们很快就会这样做;然而,此时我们将编写一个密集的全连接神经网络,为了这样做,我们需要将所有数据转换为每个图像一行,而不是每个图像的二维矩阵。由于我们在编码卷积神经网络的不同阶段需要两种格式的数据,这是一个简单的转换过程,我们将在第六步完成。
- 图像数据由介于
0和255之间的像素值矩阵或矩阵组成。要用我们的神经网络处理这些数据,我们需要将这些值转换为介于0和1之间的浮点数。如下所示的代码,我们将使用normalize()便捷函数来实现这一结果,并使用range()函数来测试这些值现在是否在0和1之间:
train <- normalize(train)
test <- normalize(test)
range(train)
运行此代码后,我们可能看不到环境窗格中数据对象的明显变化;然而,当我们运行range()函数时,我们可以看到所有值现在都在0和1之间。在对数据对象运行range()函数后,您的控制台输出将如下所示:

- 现在我们可以开始使用
keras语法来训练我们的模型。在下面的代码中,我们首先声明我们将创建一个序列模型,这意味着数据将依次通过每个后续层:
set.seed(0)
model <- keras_model_sequential()
运行上述代码会初始化模型对象;然而,它尚未包含任何数据。您可以在环境窗格中看到这一点,它将如下所示:

- 接下来我们要进行的步骤是卷积神经网络的最后步骤,代表了将数据转换为传统神经网络可以处理的方式的过程的一部分。第一步将是定义一个层,该层将从大量的矩阵中获取数据并将数据转换,使得给定图像的所有值都在一个单独的行中。为了做到这一点,我们使用
layer_flatten()函数,并将矩阵形状作为参数传递,如下所示:
model %>%
layer_flatten(input_shape = c(28, 28))
前述代码和定义模型的其余步骤不会导致您的 R 环境中出现明显变化。此步骤向模型对象添加了一个层。该层将我们的数据展平,使得每个图像的数据都包含在一个单独的行中。
- 我们将像之前一样包括一个隐藏层。在
keras中实现这一点的方法是使用layer_dense函数。接着,我们指定隐藏层的单元数量,以及用于决定某个单元的信号是否应该传递的激活函数。在这种情况下,我们选择的单元数量大约是独立变量列总数的三分之一,并选择整流线性单元(ReLU)作为我们的激活函数:
model %>%
layer_dense(units = 256, activation = 'relu')
同样,这一步也不会在环境中产生输出或显著变化。这将向模型中添加一个密集的全连接层,该层将包含 256 个单元或神经元,并将使用 ReLU 函数来确定哪些信号被传递。
- 在以下代码中,我们有 10 个可能的目标类别,因此我们在输出层中包含了 10 个单元,每个类别对应一个单元。由于这是一个多项分类问题,我们使用
softmax函数来计算任何给定的图像数据属于 10 个服装类别中的一个的概率:
model %>%
layer_dense(units = 10, activation = 'softmax')
这一行代码将向当前神经网络中添加最后一层。这将是我们的输出层,另一个密集的全连接层,其中单元数等于目标类别的数量。在这一步中,将使用softmax函数来确定给定数据集属于每个可能目标类别的概率。此步骤同样不会在 R 环境中产生输出或变化。
- 在进入
compile步骤之前,我们需要将目标向量转换为矩阵,这可以通过以下代码使用to_categorical函数简单实现:
test_target <- to_categorical(test_target)
train_target <- to_categorical(train_target)
运行代码后,我们将在环境窗格中看到变化。之前是向量的target变量对象现在变成了矩阵,每行包含一个值,等于该行所属类别的索引点处的1。您的环境窗格现在将如下所示:

这一步是使用keras训练多项分类模型的要求。
- 以下代码用于定义
compile步骤的参数。在这里,我们选择了optimizer、loss和evaluation指标。optimizer是计算模型结果与实际值之间误差率的算法,用于调整权重。我们通过以下代码定义模型的编译部分:
model %>% compile(
optimizer = 'adam',
loss = 'categorical_crossentropy',
metrics = 'categorical_accuracy'
)
在前面的代码中,我们做了以下操作:
-
我们选择了自适应矩估计(Adam)。损失函数是用于计算误差率的公式。
-
在处理像这样的多项分类问题时,最合适的损失函数是类别交叉熵,它是多类对数损失的另一种说法。
-
为了使用这个损失函数,目标变量必须以矩阵形式存储,这也是我们在上一步进行数据类型转换的原因。
-
metrics 参数存储用于评估模型性能的度量值,我们使用了类别准确率。
- 此时,拟合模型的步骤只需要三个参数。我们传入训练数据集、训练数据的目标变量以及希望模型运行的次数。如下面的代码所示,我们此时选择
10次周期,以便模型能快速运行;然而,如果您有时间让模型运行更长时间,您可能会得到更好的结果。使用以下代码在训练数据上训练神经网络:
model %>% fit(train, train_target, epochs = 10
当我们运行这段代码时,控制台将打印出每个时期的结果。控制台输出将如下所示:

除了控制台输出外,前面的代码还生成了一张图,这是相同信息的图形表示。在您的Viewer窗格中,您将看到类似这样的图:

这两部分输出显示,我们的模型最初提升很大,随后在模型额外迭代后提升速度变得很慢。
- 在此之后,我们可以在测试数据集上运行模型,并使用测试目标变量来评估我们的模型,如下截图所示。我们可以通过运行以下代码来计算损失和类别准确率度量,并打印出类别准确率度量:
score <- model %>% evaluate(test, test_target)
score$categorical_accuracy
您应该会看到以下内容打印到控制台:

我们已经达到了 88.66% 的类别准确率。这比我们之前的准确率有所下降;然而,请记住,之前的准确率度量是针对二元响应的,而这个度量描述的是所有类别的预测。
- 使用模型进行预测是通过
predict()函数实现的。在以下代码中,predict_classes可以用来选择基于概率分数最有可能的 10 个类别中的一个,而preds将计算这些概率:
preds <- model %>% predict(test)
predicted_classes <- model %>% predict_classes(test)
运行前面的代码后,我们可以在Environment窗格中看到差异,其中包含两个新的数据对象。您的Environment窗格将如下所示:

我们可以看到,preds 是一个大型矩阵,包含每个类别的概率值,而 predicted_classes 是一个向量,其中每个值表示每个案例最可能的类别。
- 最后,我们可以使用混淆矩阵来回顾我们的结果。为了运行这段代码,我们需要将测试目标标签以向量格式返回,而不是矩阵格式。为此,我们只需再次读取测试目标文件,如下所示:
test_target_vector <- fashion_mnist$test$y
caret::confusionMatrix(as.factor(predicted_classes),as.factor(test_target_vector))
运行代码将生成一个评估度量输出并打印到控制台。您的控制台将如下所示:

我们的准确率实际上从 97.487%下降到了 88.66%。这部分是由于我们将模型的运行次数限制为 10 次,同时,也因为我们现在正在为 10 个类别构建分类器,而另一个得分是通过二分类器得到的。此时,改善准确率并不是首要任务。前面的代码只是用来展示如何使用keras语法编写神经网络。
在前面的代码中,在编译阶段,我们选择了一个优化器、损失函数和评估指标;然而,我们应该注意其他可选项以及它们之间的区别。我们将在接下来的部分中讨论这些内容。
优化器
让我们看看以下优化器:
-
随机梯度下降(SGD):这是最简单的优化器。对于每个权重,模型会存储一个错误率。根据错误的方向(预测值是大于还是小于真实值),会应用一个小的学习率来调整权重,从而使下一轮结果的预测值朝着与错误方向相反的方向逐步调整。这个过程很简单,但对于深度神经网络来说,每次迭代的微调意味着模型可能需要很长时间才能收敛。
-
动量:动量本身并不是一个优化器,而是可以与不同优化器结合使用的一个方法。动量的概念是,它会取所有之前发生的修正的衰减平均值,并将这些值与当前步伐的修正结合使用。为此,我们可以想象动量就像一个物体从山坡上滚下来,然后利用自身的动量继续滚上另一座山。每一次的移动都会有一个力将物体拉回山坡下;然而,在一段时间内,继续上山的动量会强于将物体拉下山坡的力。
-
Adagrad/Adadelta:Adagrad 通过使用每个节点所有先前错误的矩阵来改进 SGD,而不是为所有节点使用统一的错误率;然而,使用这个值矩阵意味着学习率可能会在模型运行时间过长时被取消。Adadelta 是对 Adagrad 局限性的修正。Adadelta 利用动量来解决增长的错误率矩阵问题。
-
RMSprop:这是对 Adagrad 的另一种修正。它与 Adadelta 类似,然而,RMSprop 不仅通过每个节点的局部衰减平均错误率矩阵来除以学习率,还通过所有平方错误率的全局衰减平均来除以学习率。
-
Adam:该算法进一步修正并构建在之前的算法基础上。Adam 不仅包含 RMSprop 中看到的平方误差率的指数衰减平均值,还包含误差率(未平方)的指数衰减平均值,这就是动量。通过这种方式,Adam 或多或少就是带有动量的 RMSprop。两者的结合意味着存在类似于摩擦的动量修正,这减少了动量的效果,从而在许多情况下加速了收敛过程,使 Adam 成为撰写本文时非常流行的优化器。
损失函数
使用前述优化器时,你可以选择任意一个并尝试不同的算法,看看哪一个产生最好的结果。在损失函数方面,根据要解决的问题,有一些选择比其他选择更为合适:
-
binary_crossentropy:该损失函数用于分类问题,要求将输入数据分配给两个类别之一。如果输入数据可以属于多个类别而不仅仅是两个类别,这也可以使用。当一个案例可能属于多个类别时,每个目标类别将被视为一个独立的二元类别(针对每个目标类别,该案例是否属于该类别)。如果目标值在 0 和 1 之间,也可以用于回归问题。 -
categorical_crossentropy:分类交叉熵用于多类别分类问题,其中每一行输入数据只能属于两个以上的类别之一。如我们在二元交叉熵中提到的,为了使用分类交叉熵,目标向量必须转换为矩阵,以便每一行中与目标类别关联的索引的值为 1。如果目标向量包含应当被视为整数而非分类类别的整数,则可以使用sparse_categorical_crossentropy,无需执行分类交叉熵所需的矩阵转换。 -
MSE (均方误差):均方误差是回归问题的适当选择,因为预测值和真实值可以是任意数字。该损失函数计算预测值与目标值之间差异的平方。
评估指标
评估指标用于衡量模型的表现。虽然它与损失函数类似,但并不用于在训练过程中进行修正。它只在模型训练完成后用于评估性能:
-
准确度:该度量衡量预测正确类别的频率。默认情况下,使用 0.5 作为阈值,这意味着如果预测概率低于 0.5,则预测类别为 0;否则,预测类别为 1。预测类别与目标类别匹配的案例总数除以目标变量的总数。
-
余弦相似度:通过评估* n 维空间中术语的相似性,比较两个向量之间的相似性。这通常用于评估文本数据的相似性。为此,我们可以想象一段文本中“cat”一词出现四次,“dog”一词出现一次,另一段文本中“cat”一词出现四次,“dog”一词出现两次。在这种情况下,在二维空间中,我们可以设想一条从原点到达 y * = 4 和* x * = 1 的点的直线,代表第一段文本;而另一条从原点到达* y * = 4 和* x * = 2 的点的直线,代表第二段文本。如果我们评估这两条直线之间的角度,就可以得出用于判断相似性的余弦值。如果两条直线重合,则文档具有完美的相似度分数 1,角度越大,相似度越低,分数也会越低。
-
平均绝对误差:所有绝对误差的平均值。绝对误差是预测变量与目标变量之间的差异。
-
均方误差:所有平方误差的平均值。平方误差是预测变量与目标变量之间差异的平方。通过对误差进行平方,相较于平均绝对误差,较大的误差将受到更大的惩罚。
-
铰链损失:使用铰链评估指标时,所有目标变量应为-1 或 1。公式是从 1 中减去预测值与目标变量的乘积,然后使用该值和 0 中较大的一个来进行评估。结果越接近 0,说明评估的正确性越高。
-
KL 散度:该指标比较真实结果的分布与预测结果的分布,并评估两者分布的相似性。
到目前为止,我们使用了基于树的分类器和传统神经网络来对图像数据进行分类。我们还回顾了keras语法,并探讨了在使用该框架时,建模管道中几个函数的选择。接下来,我们将在刚刚编写的神经网络之前添加额外的层,来创建卷积神经网络。对于这种特殊类型的神经网络,我们将包括卷积层和池化层。通常还会包括一个丢弃层;不过我们稍后再添加它,因为它与卷积层和池化层的功能稍有不同。当这些层一起使用时,它们可以在数据中找到更复杂的模式,并且还可以减小数据的大小,这在处理大型图像文件时尤为重要。
增强模型,添加额外的层
在本节中,我们添加了两个重要的层:卷积层和池化层:
- 在开始之前,我们对数据结构进行了一些小的调整。我们将添加一个常数值作为第四个维度。我们使用以下代码添加这个额外的维度:
dim(train) <- c(nrow(train), 28, 28, 1)
dim(test) <- c(nrow(test), 28, 28, 1)
当我们进行此更改时,可以在环境窗格中看到这些数据对象的新增维度,输出将类似于以下图像:

我们对结构进行此更改,是因为这是使用keras建模 CNN 的要求。
- 如前所述,建模过程的第一步是通过调用
keras_model_sequential()函数并不传递任何参数来确定我们将构建一个顺序模型,代码如下:
set.seed(0)
model <- keras_model_sequential()
运行前面的代码将把模型对象放入我们的环境窗格中,但目前它没有数据,也不会在控制台上输出任何内容。
- 接下来,我们将为二维对象添加卷积层。在以下代码中,我们将决定滤波器的数量或数据子集的数量,以及这些子集的大小,并选择激活函数来判断子集是否包含某种模式:
model %>%
layer_conv_2d(filters = 484, kernel_size = c(7,7), activation = 'relu',
activation = 'relu', input_shape = c(28,28,1))
让我们更详细地看一下前面的代码:
-
我们选择包含 484 个滤波器,并且内核的大小为 7 个像素高和宽。
-
我们在图像上应用了与内核大小相同的滤波器,并且使用滤波器上的常量值,模型决定是否检测到模式。
-
本例中使用的步幅值为 1,这意味着每次滤波器评估后,内核会在图像表面滑动 1 个像素,尽管这个数字是可以改变的。
- 之后,我们将在以下代码中使用最大池化层,创建数据的新下采样版本,该版本代表卷积轮次后池化大小内的最大值:
model %>% layer_max_pooling_2d(pool_size = c(2, 2))
这行代码对环境窗格没有明显变化,也没有向控制台打印输出。它向我们的模型中添加了一个层,这个层将数据的大小减少到原始大小的四分之一。
- 此后,步骤将继续按照我们在卷积神经网络图像识别部分中描述的方式进行。让我们看一下以下代码:
model %>%
layer_flatten() %>%
layer_dense(units = 98, activation = 'relu') %>%
layer_dense(units = 10, activation = 'softmax')
model %>% compile(
loss = 'categorical_crossentropy',
optimizer = 'adam',
metrics = 'accuracy'
)
model %>% fit(
train, train_target,
batch_size = 100,
epochs = 5,
verbose = 1,
validation_data = list(test, test_target)
)
scores <- model %>% evaluate(
test, test_target, verbose = 1
)
preds <- model %>% predict(test)
predicted_classes <- model %>% predict_classes(test)
caret::confusionMatrix(as.factor(predicted_classes),as.factor(test_target_vector))
运行前面的代码后,我们将在控制台上看到模型诊断数据,输出将类似于以下图像:

您还将获得一个包含相同数据的图表,输出将类似于以下图像:

前面的代码还生成了性能指标,并将其打印到控制台。您的控制台将显示如下图像:

通过添加卷积层和池化层,我们已将准确度得分从 88.66%提高到 90.51%,而且我们减少了五轮,以避免使用长时间运行的模型进行学习。
如前所述,深度学习发生在信号需要通过多个层时,因此在以下方法中,我们将看到如何继续添加更多的层。通过这种方法,您可以根据需要继续添加任意数量的层来优化您的模型:
- 我们从重新定义模型开始。在以下代码中,我们再次声明它是一个顺序模型:
set.seed(0)
model <- keras_model_sequential()
上述代码将重置模型对象;然而,在“环境”面板中不会出现明显的变化,也不会有任何内容打印到控制台。
- 在下一步中,我们将使用具有 128 个过滤器的卷积层,然后使用池化层。在此情况下,我们还添加了
padding = "same",以防止卷积层后维度发生缩减。这是为了使我们能够添加更多层。如果我们过快地减少数据的维度,那么以后就无法在数据上使用任何大小的过滤器。我们使用以下代码来定义第一个卷积层和池化层:
model %>%
layer_conv_2d(filters = 128, kernel_size = c(7,7), activation = 'relu',
input_shape = c(28,28,1), padding = "same") %>%
layer_max_pooling_2d(pool_size = c(2, 2))
上述代码不会对“环境”面板产生明显变化,也不会在控制台中打印任何内容。
- 如下代码所示,我们将添加另一个卷积层,使用 64 个过滤器,并添加一个池化层。我们使用以下代码添加这两个额外的层:
model %>%
layer_conv_2d(filters = 64, kernel_size = c(7,7), activation = 'relu', padding = "same") %>%
layer_max_pooling_2d(pool_size = c(2, 2))
上述代码不会对“环境”面板产生明显变化,也不会在控制台中打印任何内容。
- 然后,我们再添加一个卷积层。这次,我们将使用 32 个过滤器和一个池化层,如以下代码所示。我们使用以下代码添加这两个额外的层:
model %>%
layer_conv_2d(filters = 32, kernel_size = c(7,7), activation = 'relu', padding = "same") %>%
layer_max_pooling_2d(pool_size = c(2, 2))
上述代码不会对“环境”面板产生明显变化,也不会在控制台中打印任何内容。
- 最后,在以下代码中,我们将数据展平,并按照之前的示例继续操作:
model %>%
layer_flatten() %>%
layer_dense(units = 128, activation = 'relu') %>%
layer_dense(units = 10, activation = 'softmax')
model %>% compile(
loss = 'categorical_crossentropy',
optimizer = 'adam',
metrics = 'accuracy'
)
model %>% fit(
train, train_target,
batch_size = 100,
epochs = 5,
verbose = 1,
validation_data = list(test, test_target)
)
score <- model %>% evaluate(
test, test_target, verbose = 1
)
preds <- model %>% predict(test)
predicted_classes <- model %>% predict_classes(test)
caret::confusionMatrix(as.factor(predicted_classes),as.factor(test_target_vector))
上述代码会在控制台中生成模型诊断信息。您的控制台将显示以下图像:

上述代码还生成了一个图表。您将在您的查看器面板中看到一个类似以下图像的图表:

我们还生成了额外的性能指标,并将其打印到我们的控制台。您将在控制台中看到以下输出:

我们的准确率保持几乎不变,仍为 89.97%。通过增加每层的过滤器数量,我们可能能够提高准确率;然而,这样做会显著增加运行时间,因此我们保持每层的过滤器数量不变。在这里,我们已经演示了如何编写代码来创建一个卷积神经网络(CNN)及其更深层结构,并注意到这种结构改善了性能。到目前为止,我们使用了 ReLU 激活函数,这是一种非常流行和常见的激活函数;然而,我们知道还有其他选项,因此接下来我们将编写代码来选择另一种激活函数。
选择最合适的激活函数
使用 keras,你可以使用多种不同的激活函数。我们在前面的章节中已经讨论了一些激活函数;然而,还有一些是之前没有涉及到的。我们可以先列出已经覆盖的函数,并简要说明每个函数:
-
线性:也叫做恒等函数,使用 x 的值。
-
Sigmoid:使用 1 除以 1 加上负 x 的指数。
-
双曲正切 (tanh):使用 x 的指数减去负 x 的指数,再除以 x 加上负 x 的指数。这与 Sigmoid 函数具有相同的形状;然而,y 轴上的范围是从 1 到 -1,而不是从 1 到 0。
-
修正线性单元 (ReLU):如果 x 大于 0,则使用 x 的值;否则,如果 x 小于或等于 0,则将值赋为 0。
-
Leaky ReLU:使用与 ReLU 相同的公式;然而,当 x 小于 0 时,它会应用一个小的 alpha 值。
-
Softmax:为每个可能的目标类别提供一个概率。
让我们来看看在前几章中没有提到的所有函数:
-
指数线性单元 (ELU):如果 x 小于 0,则使用 x 的指数减 1 乘以一个常数 alpha 值。
-
缩放指数线性单元 (SELU):使用 ELU 函数,然后将该函数的结果乘以一个常数缩放值。
-
Thresholded ReLU:使用与 ReLU 相同的公式;然而,它不是使用 0 作为 x 是否为 x 或 0 的阈值,而是使用用户定义的 theta 值来确定这个阈值。
-
参数化修正线性单元 (PReLU):与 Leaky ReLU 的公式相同;然而,它使用一组值作为 alpha,而不是单一值。
-
Softplus:使用 x 的指数加 1 的对数。
-
Softsign:使用 x 除以 x 的绝对值加 1。
-
指数:使用 x 的指数。
-
硬 Sigmoid:使用 Sigmoid 函数的修改版和更快版本。如果 x 小于 -2.5,则值为 0;如果 x 大于 2.5,则值为 1;否则,使用 0.2 * x + 0.5。
到目前为止,我们已经通过在层函数调用中将 relu 值赋给激活参数来使用 ReLU 激活函数。对于某些激活函数,如 Sigmoid 函数,我们可以简单地将 relu 值替换为 sigmoid 值;然而,更复杂的激活函数需要一个单独的激活函数层。让我们通过从层函数调用中移除激活参数并添加一个 Leaky ReLU 激活函数,将激活函数从 ReLU 切换为 Leaky ReLU:
model <- keras_model_sequential()
model %>%
layer_conv_2d(filters = 8, kernel_size = c(3,3), input_shape = c(28,28,1)) %>%
layer_activation_leaky_relu() %>%
layer_max_pooling_2d(pool_size = c(2, 2)) %>%
layer_conv_2d(filters = 16, kernel_size = c(3,3)) %>%
layer_activation_leaky_relu() %>%
layer_max_pooling_2d(pool_size = c(2, 2)) %>%
layer_conv_2d(filters = 32, kernel_size = c(3,3)) %>%
layer_activation_leaky_relu() %>%
layer_max_pooling_2d(pool_size = c(2, 2)) %>%
layer_flatten() %>%
layer_dense(units = 128) %>%
layer_activation_leaky_relu() %>%
layer_dense(units = 10, activation = 'softmax')
# compile model
model %>% compile(
loss = loss_categorical_crossentropy,
optimizer = 'rmsprop',
metrics = c('accuracy')
)
# train and evaluate
model %>% fit(
train, train_target,
batch_size = 100,
epochs = 5,
verbose = 1,
validation_data = list(test, test_target)
)
scores <- model %>% evaluate(
test, test_target, verbose = 1
)
preds <- model %>% predict(test)
predicted_classes <- model %>% predict_classes(test)
caret::confusionMatrix(as.factor(predicted_classes),as.factor(test_target_vector))
上述代码会将模型诊断数据打印到我们的控制台。你的控制台将显示如下图像:

上述代码还会生成一个包含模型性能数据的图表。你将在查看器面板中看到类似下图的图表:

上述代码还会生成性能指标。输出打印到控制台时,会显示如下图所示的内容:

在切换激活函数后,我们的准确率为 90.95%,这与我们之前的得分非常相似。在这种情况下,切换激活函数并没有提高性能;然而,有时候这种方法会有所帮助,因此了解如何进行这种修改非常重要。
使用 dropout 和早停选择最佳 epochs
为了避免过拟合,我们可以使用两种技术。第一种是添加 dropout 层。dropout 层将移除一部分层的输出。这使得每次迭代的数据略有不同,从而使模型更好地泛化,而不是过于专门地拟合训练数据。在前面的代码中,我们在池化层之后添加了 dropout 层:
model <- keras_model_sequential()
model %>%
layer_conv_2d(filters = 128, kernel_size = c(7,7), input_shape = c(28,28,1), padding = "same") %>%
layer_activation_leaky_relu() %>%
layer_max_pooling_2d(pool_size = c(2, 2)) %>%
layer_dropout(rate = 0.2) %>%
layer_conv_2d(filters = 64, kernel_size = c(7,7), padding = "same") %>%
layer_activation_leaky_relu() %>%
layer_max_pooling_2d(pool_size = c(2, 2)) %>%
layer_dropout(rate = 0.2) %>%
layer_conv_2d(filters = 32, kernel_size = c(7,7), padding = "same") %>%
layer_activation_leaky_relu() %>%
layer_max_pooling_2d(pool_size = c(2, 2)) %>%
layer_dropout(rate = 0.2) %>%
layer_flatten() %>%
layer_dense(units = 128) %>%
layer_activation_leaky_relu() %>%
layer_dropout(rate = 0.2) %>%
layer_dense(units = 10, activation = 'softmax')
# compile model
model %>% compile(
loss = 'categorical_crossentropy',
optimizer = 'adam',
metrics = 'accuracy'
)
# train and evaluate
model %>% fit(
train, train_target,
batch_size = 100,
epochs = 5,
verbose = 1,
validation_data = list(test, test_target)
)
scores <- model %>% evaluate(
test, test_target, verbose = 1
)
preds <- model %>% predict(test)
predicted_classes <- model %>% predict_classes(test)
caret::confusionMatrix(as.factor(predicted_classes),as.factor(test_target_vector))
上述代码会将模型诊断数据输出到我们的控制台。你的控制台将显示如下图所示的内容:

上述代码还会生成一个包含模型性能数据的图表。你将在查看面板中看到如下图所示的图表:

上述代码还会生成一些性能指标。输出到控制台的内容会显示如下图所示:

在这种情况下,使用这一策略导致我们的准确率略微下降,降至 90.07%。这可能是因为我们处理的数据集包含的是非常小的图像,模型在每次移除数据时受到了影响。对于更大的数据集,这种方法可能有助于提高模型效率并使其更好地泛化。
防止过拟合的另一种策略是使用早停。早停会监控进展,并在模型不再改进时停止继续训练数据。在以下代码中,我们将patience参数设置为2,这意味着评估指标必须连续两个 epoch 没有改进。
在下面的代码中,保持5个 epochs,模型将完成所有五轮;然而,如果你有时间,可以运行之前的所有代码,然后在拟合模型时,将 epochs 的数量增加,观察早停函数何时停止模型。我们通过以下代码将早停功能添加到模型中:
model %>% fit(
train, train_target,
batch_size = 100,
epochs = 5,
verbose = 1,
validation_data = list(test, test_target),
callbacks = list(
callback_early_stopping(patience = 2)
)
)
从前面的代码中,我们可以推断出以下内容:
-
通过使用 dropout 和早停,我们展示了发现模型最佳 epochs 或轮次的方法。
-
在考虑我们应该使用多少 epochs 时,目标是双重的。
-
我们希望我们的模型能够高效运行,这样即使额外的训练轮次没有提高性能,我们也不需要等待模型完成训练。我们还希望避免过拟合问题,即模型在训练数据上训练得过于具体,无法泛化到新的数据。
-
Dropout(丢弃法)通过在每一轮训练中随机移除部分数据来解决第二个问题。它还引入了随机性,防止模型对整个数据集学习过度。
-
提前停止(Early stopping)通过监控性能来解决第一个问题,当模型在一定时间内不再产生更好的结果时,停止模型训练。
-
将这两种技术结合使用,可以帮助你找到一个合适的训练轮次,使模型高效运行并且具有较好的泛化能力。
-
总结
在这一章中,我们首先展示了如何使用标准的机器学习技术创建图像分类模型;然而,随着图像尺寸的增大和复杂性的增加,这种方法会受到限制。为了解决这个问题,我们可以使用卷积神经网络(CNN)。通过这种方法,我们演示了如何进行降维处理,并提高训练图像数据分类模型的计算效率。我们构建了一个包含卷积层和池化层的模型,并展示了如何通过添加更多层来加深模型。最后,我们使用了丢弃法和提前停止策略来避免模型的过拟合。将这些方法结合使用,我们现在能够构建适用于任何类型图像数据的分类模型。
在下一章中,我们将学习如何编写一个多层感知器(Multilayer Perceptron)。多层感知器是一种前馈神经网络,只有稠密的完全连接的隐藏层。由于调节选项较少,我们将更深入地探讨可以调整的内容。此外,除了在第二章中的简要介绍外,我们将首次使用 MXNet 包,卷积神经网络(CNN)用于图像识别。
第五章:用于信号检测的多层感知机
本章将展示如何为信号检测构建多层感知机神经网络。我们将首先讨论多层感知机神经网络的架构。然后,我们将介绍如何准备数据、如何决定隐藏层和神经元数量,以及如何训练和评估模型。
关于数据准备的部分将是未来学习中的关键,因为这些深度学习模型需要数据以特定的格式进行传递才能送入模型。隐藏层是神经网络与其他机器学习算法的区别所在,在本章中,我们将展示如何寻找隐藏层中的最佳节点数。此外,在本章的过程中,你将更加熟悉 MXNet 的语法,包括模型的训练和评估步骤。
本章将涵盖以下主题:
-
理解多层感知机
-
准备和处理数据
-
决定隐藏层和神经元数量
-
训练和评估模型
技术要求
你可以在相应的 GitHub 链接github.com/PacktPublishing/Hands-on-Deep-Learning-with-R找到本章的代码文件。
理解多层感知机
多层感知机是一个前馈神经网络的实例,只有使用由感知机组成的全连接层。感知机是一个节点,它接受输入值并将其与权重相乘,然后将这个聚合值传递给激活函数,该函数返回一个值,表示这一组输入和权重与我们尝试寻找的模式的匹配程度。
多层感知机可以被视为最基本的神经网络实现。如我们所述,所有层都是全连接的,这意味着没有卷积层或池化层。它也是一个前馈模型,这意味着来自反向传播的信息不会在每一步都回传,如同在递归神经网络中那样。
简单性在网络的可解释性和初始设置方面可能是一个优点;然而,主要的缺点是,由于有这么多全连接层,权重的数量会增加到一个程度,以至于在大数据集上训练模型的时间会非常长。它还存在梯度消失问题,这意味着模型会达到一个点,在这个点上,回传给模型以进行修正的值非常小,以至于它不再显著地影响结果。
准备和预处理数据
在这个例子中,我们将使用 Adult 数据集。我们将逐步展示如何将此数据集转换为合适的格式,以便我们可以在其上训练多层感知机:
- 我们首先加载需要的库。我们将使用
mxnet包来训练 MLP 模型,使用tidyverse系列包进行数据清理和操作,使用caret来评估我们的模型。我们使用以下代码加载这些库:
library(mxnet)
library(tidyverse)
library(caret)
这段代码不会在控制台中输出任何内容;但是,你会在 Packages 面板中看到这些库旁边有一个勾号,表示这些库已经准备好使用。你的 Packages 面板应显示如下截图:

- 接下来,我们将加载我们的训练数据和测试数据。此外,我们会添加一个名为
dataset的列,并用train和test的值填充该列来标记数据。我们这样做是为了能够将数据合并,并对整个数据集进行一些操作,避免重复步骤,然后再将数据拆分。我们使用以下代码加载数据并添加标签:
train <- read.csv("adult_processed_train.csv")
train <- train %>% mutate(dataset = "train")
test <- read.csv("adult_processed_test.csv")
test <- test %>% mutate(dataset = "test")
上面的代码会在你的环境面板中放置两个数据对象。这将是我们用来进行建模练习的训练数据和测试数据。你的环境面板应显示如下截图:

- 在这一步,我们将使用行绑定(row bind)合并数据,并删除任何包含 NA 值的行。删除包含缺失值的行并不总是最合适的做法;有时,应该使用其他策略来处理缺失值。这些策略包括插补和替代。在这种情况下,由于我们仅仅是为了示范,我们希望移除这些行以便简化操作。我们使用以下代码合并数据并移除含缺失值的行:
all <- rbind(train,test)
all <- all[complete.cases(all),]
上面的代码将向我们的环境面板中添加另一个数据对象。现在你的环境面板应显示如下截图:

你可以看到,数据对象all包含了来自test和train的数据。现在我们可以同时修改train和test。
- 在下一步中,我们会处理任何因子值并去除空格。这样做是因为有些值本应表示相同的内容,但由于空格问题,显示为不同的值,例如
Male和Male。我们将演示空格是如何在准确定义类别时造成问题,并使用以下代码修正该问题:
unique(all$sex)
all <- all %>%
mutate_if(~is.factor(.),~trimws(.))
当我们运行前面的代码的第一行时,我们会将不同的因子水平打印到控制台。你的控制台应类似于以下截图:

然而,在运行第二行并移除空格后,我们会发现问题已经被修正,输出也将有所不同。如果你再次运行unique()函数,控制台中的输出会显示如下截图:

纠正这个问题将有助于算法在创建模型时使用正确数量的类别。
- 接下来,我们仅筛选出训练数据。我们将目标变量提取为一个向量,并将值转换为数值型。之后,我们可以从数据集中删除目标变量和数据集变量。我们通过以下代码提取训练数据,创建目标变量向量,并删除不需要的列:
train <- all %>% filter(dataset == "train")
train_target <- as.numeric(factor(train$target))
train <- train %>% select(-target, -dataset)
执行此代码后,我们会看到train数据已在Environment面板中更新,并且已添加了train_target向量。您的Environment面板将显示以下截图:

我们可以看到,训练数据减少了两个变量,因为我们已删除了目标和数据集。target列现在被提取到自己的向量中,我们将因变量和自变量分开,存放在不同的数据对象中,以便它们处于正确格式,准备传递给建模函数。
- 下一步是按列分离数据,以便一个子集仅包含包含数值的列,而另一个子集仅包含包含字符串的列。如前所述,所有值都需要是数值型,因此我们将对字符串值进行独热编码,也称为创建虚拟变量。这将为每个可能的字段名称–值对创建一列,并用
1或0填充该列,表示每行是否存在该字段名称的值。我们使用以下代码按描述的方式列分割数据:
train_chars <- train %>%
select_if(is.character)
train_ints <- train %>%
select_if(is.integer)
执行上述代码后,我们将看到两个新的数据对象——一个包含 14 个总列中的 6 个数值列,另一个包含剩余的 8 列,这些列包含字符串值。您的环境面板现在将如下所示:

现在我们的数据已经是这种格式,我们可以对仅包含字符串的列进行独热编码。
- 在这一步,我们实际上会创建虚拟变量。我们使用来自 caret 包的
dummyVars()函数。这包含两个步骤。第一步,我们定义希望转换为虚拟变量的列。由于我们希望所有列都进行转换,因此我们只需在波浪号后加一个点。接下来,我们使用predict()函数,根据公式实际创建新的变量。我们通过以下代码创建新的虚拟变量列:
ohe <- caret::dummyVars(" ~ .", data = train_chars)
train_ohe <- data.frame(predict(ohe, newdata = train_chars))
执行上述代码后,您将在环境面板中看到两个新的数据对象。一个是ohe对象,它是一个包含所有细节的列表,用于将字符串列转换为虚拟变量;另一个是train_ohe对象,包含虚拟变量。您的环境面板现在将如下所示:

我们可以看到,创建虚拟变量会导致数据集的列数比原始数据多得多。如前所述,我们会为每个列名和对应值对创建一个新的列,这导致了列数的增加。
- 在包含字符串值的列被转换为数值列后,我们可以将两个子集重新合并。我们使用以下代码将原本是数值型的数据和转换后的数值型数据合并:
train <- cbind(train_ints,train_ohe)
我们可以看到,环境面板中的train对象发生了变化。你的环境面板现在会显示如下:

现在,训练数据包含了所有的数值型列,且已处于正确的格式,适用于神经网络。
- 最后,对于原本包含数值的列,我们将对其进行缩放,使得所有值都在
0到1之间,从而与我们的独热编码列处于同一尺度。我们通过以下代码将所有数据调整到相同的尺度:
train <- train %>% mutate_all(funs(scales::rescale(.) %>% as.vector))
在运行上述代码之前,让我们先看看环境面板中train数据列的样子。你的界面在运行代码前将如下所示:

运行代码后,您的环境面板将显示如下截图:

我们可以看到,原先在第一张图中处于不同尺度的所有值,现在已经重新缩放,使得所有值都在0到1之间。将所有值统一尺度有助于提高模型训练的效率。
- 我们现在可以对测试数据集重复相同的步骤。通过运行以下代码行,我们使用与训练数据相同的步骤来准备测试数据进行建模:
test <- all %>% filter(dataset == "test")
test_target <- as.numeric(factor(test$target))
test <- test %>% select(-target, -dataset)
test_chars <- test %>%
select_if(is.character)
test_ints <- test %>%
select_if(is.integer)
ohe <- caret::dummyVars(" ~ .", data = test_chars)
test_ohe <- data.frame(predict(ohe, newdata = test_chars))
test <- cbind(test_ints,test_ohe)
test <- test %>% mutate_all(funs(scales::rescale(.) %>% as.vector))
运行完此代码后,你会看到测试数据对象已按与训练数据对象相同的方式被修改。你的环境面板现在会显示如下截图:

我们的所有数据现在都已经是正确的格式,并且可以用于训练我们的神经网络模型。
- 还有最后一步清理。如果我们查看列的数量,可以发现
train数据框比测试数据框多了一个列。我们可以使用setdiff函数查看哪些列在train中存在但在test集中不存在。一旦找到了该列,我们就可以从train数据集中删除它。我们需要确保数据具有相同的列数以进行建模。我们通过以下两行代码找出并删除在两个数据集中不存在的列:
setdiff(names(train), names(test))
train <- train %>% select(-native.countryHoland.Netherlands)
当我们运行第一行代码时,输出的值会打印到我们的控制台。你的控制台输出将如下所示:

我们现在知道,train 数据集有列 native.countryHoland.Netherlands,而 test 数据集没有。我们使用第二行代码将该列从 train 数据集中移除。运行第二行代码后,我们会注意到环境面板发生了变化。你的环境面板现在将像下面的截图一样:

当我们现在查看 train 和 test 数据集时,我们可以看到这两个数据对象的列数相同,这是使用这两个数据对象来训练和测试模型所必需的。
- 最后的数据准备步骤是对我们的目标变量向量中的所有值减去
1。当我们第一次将这些变量转换为数值格式时,我们取了它们的因子水平值,从而得到了编码为1或2的向量;然而,我们希望这些向量被编码为0或1,这样它们与我们的自变量在同一尺度上。我们通过运行以下代码将目标变量调整到与自变量相同的尺度:
train_target <- train_target-1
test_target <- test_target-1
运行以下代码后,你会注意到环境面板发生最后一次变化。你的环境面板现在将像下面的截图一样:

我们现在的所有数据都是数值型并且处于相同的尺度上,因此数据已经完全准备好用于建模。
我们从数据最初存储的状态开始,并采取了一些步骤将数据转换为正确的格式,以便用来训练神经网络。神经网络,特别是深度学习的实现,提供了不需要像其他机器学习技术那样进行特征工程的便利。尽管如此,仍然常常需要一些数据准备,因为神经网络要求所有数据必须存储为数值型数据,而且如果所有数值型数据处于相同的尺度下,模型的表现会更好。我们刚才进行的数据处理和转换步骤,代表了为其他数据进行神经网络训练时需要完成的数据准备工作。数据已经转换为正确的格式,现在我们可以设计一个系统来搜索我们模型中隐藏层的最优节点数。
决定隐藏层和神经元数量
多层感知器在模型设计过程中只有少数选择:隐藏层使用的激活函数、隐藏层的数量,以及每一层中节点或人工神经元的数量。本节将讨论选择最优层数和节点数的问题。我们可以从单层开始,使用一套启发式规则来指导我们选择该隐藏层中节点的数量。
在开始这一过程时,一个好的起始点是输入长度或独立变量列数的 66%。一般来说,这个值会位于输出大小与输入大小的两倍之间;然而,输入长度的 66%是这个范围内的一个好起始点。
这并不意味着这个起始值将始终是使用的最优节点数量。为了发现最优数量,我们可以编写一个函数,使用与起始点附近的不同节点数量来训练我们的模型,以观察趋势并尝试找到最优值。在这种情况下,我们将使用较大的学习率,进行少量的训练轮次以加快运行时间。如果你正在处理大型数据集,则可能需要在使用该策略时仅使用数据的子集,以避免每次迭代过长的运行时间成为问题。
现在,我们将逐步讲解如何创建一个函数来测试模型在不同节点数情况下的性能:
- 首先,我们来看一下独立变量列的数量,然后获取该值的 66%,得到我们的起始点。我们通过运行以下代码来决定隐层中节点数量的起始值:
length(train)*.66
上述代码将在控制台中打印以下输出:

精确的数值是67.98,但我们将把它四舍五入到70作为起始值。请记住,你可以使用任何你喜欢的值,因为这只是一个启发式方法——使用整数是方便的,你也可以在之后深入挖掘,找到最优的神经元数量——然而,在做小幅度变化时,性能差异通常是最小的,甚至在后续泛化模型时可能不会出现。
- 接下来,我们选择两个大于该起始点的值,以及两个小于该值的,存储在一个向量中。这些将作为参数传递给我们的函数。在此案例中,我们从
70开始,因此我们还将包含50、60、80和90。我们通过运行以下代码来创建隐层可能节点的向量:
possible_node_values <- c(50,60,70,80,90)
运行上述代码后,我们将看到这个数据对象现在出现在我们的环境面板中。你的环境面板现在应该类似于以下截图:

稍后我们将使用这个向量中的值来循环遍历这些选项,看看哪个性能最好。
- 现在我们将设置种子,以确保结果的可复现性。这一点始终很重要,在使用任何引入准随机数的模型时都应这样做。在我们的示范中,重要的是展示我们创建的函数能产生与直接运行代码相同的结果。我们通过运行以下代码行,专门为我们的 MXNet 模型设置种子:
mx.set.seed(0)
运行此代码后,控制台没有任何输出,RStudio 中也没有明显的变化;然而,使用此方法,我们确保了模型结果的一致性。
- 在编写我们的函数之前,我们将首先定义并运行我们的模型,并查看使用
mxnet包训练多层感知机的语法和选项。我们使用以下代码定义并运行我们的多层感知机模型:
model <- mx.mlp(data.matrix(train), train_target, hidden_node=70,out_node=2, out_activation="softmax",num.round=10, array.batch.size=32, learning.rate=0.1, momentum=0.8, eval.metric=mx.metric.accuracy)
运行上述代码后,我们会看到每次运行时模型的详细信息被打印到控制台。控制台输出将如下图所示:

控制台输出列出了所有使用train数据中的保留集计算的准确度值。我们将在运行完这段代码后介绍更多 MXNet 建模的选项,以便更详细地说明每个参数。简而言之,此时我们使用一些值来使模型运行更快,同时我们正在准备测试最优节点数。
- 除了训练这个模型之外,我们还希望有一个数据对象来保存性能结果,以便在尝试不同的隐藏层大小后进行比较。在这里,我们可以使用模型进行预测,并选择具有最高可能性的类别。最后,我们通过将预测正确的案例总数与测试目标变量的长度进行比较,来计算准确度。我们还可以看到现在如何将这两个值存储在表格中。我们这样做是为了演示函数内部的整个过程,该函数将包含所有不同的节点大小选择及其对应的准确度。我们使用以下代码进行预测并计算准确度:
preds = predict(model, data.matrix(test))
pred.label = max.col(t(preds))-1
acc = sum(pred.label == test_target)/length(test_target)
vals <- tibble(
nodes = 70,
accuracy = acc
)
vals
运行上述代码后,我们将在“环境”面板中看到四个新的数据对象。您的“环境”面板将如下图所示:

preds对象保存了我们通过模型进行预测的结果。MXNet 将这些预测结果存储在一个矩阵中,矩阵中包含每个类别的概率。如果我们转置这个矩阵或将其旋转 90 度,并选择每列的最大值,那么我们将得到与最可能类别对应的最高行号;然而,这样做时,矩阵中的行值为1和2,因此我们需要从所有值中减去 1,以便将预测值与我们真实的测试类别(即0或1)保持一致。对于准确度值,我们通过将预测值与真实值相同的情况总数与真实案例的总数相比,计算得到准确度。最后,我们可以将节点数和准确度分数放入一个表格中,这对比较不同节点数的结果非常有用。
- 现在我们已经为单个案例编写了所有代码,我们可以通过将我们想要测试的参数的赋值替换为变量来创建我们的函数。然后,我们将这个变量移到我们的新函数中作为参数。我们可以看到,代码中的一切与之前完全相同,只是我们曾经将
hidden_node参数的值设为70,并且后来在我们要创建的新表格中的nodes列下添加这个值的地方,现在它已被替换为x。然后,x被移到外部并作为我们新函数的参数传入。通过这种方式,我们现在可以将任何值传递给我们的新mlp_loop()函数,并让它替换代码中两个x的实例。我们编写自定义函数来尝试不同的hidden_node参数值,使用以下代码:
mlp_loop <- function(x) {
model <- mx.mlp(data.matrix(train), train_target, hidden_node=x, out_node=2, out_activation="softmax",
num.round=10, array.batch.size=32, learning.rate=0.1, momentum=0.8,eval.metric=mx.metric.accuracy)
preds = predict(model, data.matrix(test))
pred.label = max.col(t(preds))-1
acc = sum(pred.label == test_target)/length(test_target)
vals <- tibble(
nodes = x,
accuracy = acc
)
}
在运行上述代码后,我们将看到我们的环境面板发生了变化。你的环境面板现在将像下面的截图一样:

我们可以看到,现在我们的环境中已经定义并存储了一个自定义函数。
- 现在我们可以首先使用之前运行时的值来测试我们的函数。我们通过向刚才创建的函数提供
70这个值来测试我们的函数,代码如下:
results <- mlp_loop(70)
results
all.equal(results$accuracy,acc)
在我们运行以下代码后,我们将会在控制台上得到一份打印输出,显示准确度值、我们在隐藏层中包含的节点数以及测试相等性结果。你的控制台将会像下面的截图一样:

根据我们刚刚运行的代码结果,我们可以看到我们的函数生成的结果与直接将这些值传递给建模代码时生成的结果相同。这是合理的,因为我们只是将所有x的地方替换成了70。
- 现在可以确认我们的新函数正常工作(因为当我们通过函数传递
70时,得到的结果与直接在代码中使用70时相同),我们可以将整个数值向量传递给这个函数。为此,我们将使用purrr包中的map()函数,它使得迭代变得非常简单和直接。在这种情况下,我们将使用map_df()函数,以便在遍历所有值后获得一个数据框。我们通过以下代码循环遍历我们的函数,将之前创建的向量中的所有值传递进去:
results <- map_df(possible_node_values, mlp_loop)
results
当我们运行前面的代码时,我们将在控制台看到一个类似于步骤 4的输出,显示所有五次模型运行的结果。之后,我们将得到一个results数据框,它现在包含了所有节点数尝试的准确度分数。在控制台中,你可能会注意到一些四舍五入的情况,这可能会妨碍我们立刻判断哪个模型表现最佳。让我们改为点击环境面板中的results,以表格形式查看数据。点击results后,你应该会看到类似以下截图的表格:

- 虽然我们已经能够创建一个循环,将不同的节点数值传递给
mlp()函数以确定最佳节点数,但在下一步中我们会看到,使用类似的循环技巧来寻找最佳层数并不像预期的那么简单。要添加层,我们必须放弃mlp()函数的便捷性,一次创建一个层来构建我们的多层感知器(MLP)。我们可以使用以下代码一次性创建一个 MLP 层:
data <- mx.symbol.Variable("data")
fc1 <- mx.symbol.FullyConnected(data, num_hidden=90)
fc2 <- mx.symbol.FullyConnected(fc1, num_hidden=50)
smx <- mx.symbol.SoftmaxOutput(fc2)
model <- mx.model.FeedForward.create(smx, data.matrix(train), train_target,num.round=10, array.batch.size=32,
learning.rate=0.1, momentum=0.8, eval.metric=mx.metric.accuracy)
preds = predict(model, data.matrix(test))
pred.label = max.col(t(preds))-1
acc = sum(pred.label == test_target)/length(test_target)
acc
运行前面的代码后,我们将在环境面板中看到一些新的数据对象,并且控制台会打印出一个准确度分数。你的控制台将类似于以下截图:

从输出结果中,我们可以看到,在两层不同节点数的测试中,选取两个最高得分值并没有改善我们的得分。虽然你可以继续使用前面的代码尝试不同的层数,但增加更多的层并不总是能带来更好的模型表现。让我们回顾一下前面的代码在做什么,以及它与我们使用mlp()函数创建的模型有何不同。
在这种情况下,我们通过创建一个符号变量来初始化模型。然后,我们创建了两个完全连接的层,分别包含 90 和 50 个节点。接着,我们定义了一个输出层,使用 softmax 激活函数。接下来,我们使用FeedForward()函数定义了之前使用的其他选项。通过这样做,我们可以看到大多数参数可以传递给FeedForward,而hidden_node参数则转移到FullyConnected()函数中,适用于任意数量的层,out_node和out_activation参数则传递给输出函数,在本例中为SoftmaxOutput。
使用我们准备的数据,我们查看了如何测试隐藏层的最佳节点数。我们还讨论了如何更改代码以添加更多的层。与其他神经网络实现相比,MLP 的选项较少,因此我们主要集中在调整隐藏层,以利用神经网络模型的主要优势来优化我们的模型。在下一步中,我们将利用我们在调整参数时学到的一切,运行一个能够最大化性能的模型,并使用 MXNet 提供更深入的模型选项解释。
训练和评估模型
调整参数后,我们现在可以运行模型以获得最佳性能。为了实现这一点,我们将对模型选项进行一些重要更改。在进行更改之前,让我们更深入地审查一下模型选项:
-
hidden_node:这是隐藏层中的节点数。我们使用循环函数来找到最佳的节点数。 -
out_node:这是输出层中的节点数,必须设置为目标类别的数量。在这种情况下,该数字是2。 -
out_activation:这是用于输出层的激活函数。 -
num.round:这是我们训练模型的迭代次数。在参数调整阶段,我们将此数字设置得较低,以便能够快速循环通过多个选项;为了获得最佳准确性,我们将允许模型运行更多轮次,同时降低学习率,稍后我们将讨论这一点。 -
array.batch.size:这是设置批次大小的参数,即每一轮训练时同时训练的行数。该值设置得越高,所需的内存也越多。 -
learning.rate:这是应用于损失函数梯度的常数值,用来调整权重。为了参数调整,我们将其设置为较大的数值,以便在较少的轮次内快速沿成本面移动。为了获得最佳性能,我们将其设置为较小的数值,以便在学习新权重时做出更细微的调整,从而避免不断过度调整数值。 -
momentum:这个参数利用先前梯度的衰减值,避免在成本面上出现突然的移动变化。作为启发式方法,momentum的一个良好起始值在0.5和0.8之间。 -
eval.metric:这是用来评估性能的度量标准。在我们的案例中,我们使用的是accuracy(准确率)。
现在,我们已经涵盖了使用mlp()函数包含的模型选项,我们将进行调整以提高准确性。为了提高准确性,我们将增加训练轮次,同时降低学习率。我们将保持其他值不变,并使用之前循环中获得最佳性能的节点数。你可以使用以下代码,根据我们在参数调整时学到的内容来设置模型以提高性能:
model <- mx.mlp(data.matrix(train), train_target, hidden_node=90, out_node=2, out_activation="softmax",num.round=200, array.batch.size=32, learning.rate=0.005, momentum=0.8,eval.metric=mx.metric.accuracy)
preds = predict(model, data.matrix(test))
pred.label = max.col(t(preds))-1
acc = sum(pred.label == test_target)/length(test_target)
acc
运行此代码后,你将在控制台中看到模型在调整参数后运行的准确率得分。你的控制台输出将如下所示:

我们可以看到,通过我们的调整,准确率得到了提升。之前,当我们测试参数时,最佳准确率为 84.28%,现在我们可以看到准确率已经达到了 85.01%。
在将数据准备好以符合 MXNet 模型的格式,并进行参数调优以找到最佳模型值后,我们进一步调整了模型,利用之前学到的内容来提升性能。所有这些步骤一起描述了一个完整的数据操作和转换周期,优化参数,最后运行我们的最终模型。我们展示了如何使用 MXNet,它提供了一个简单的 MLP 便利函数,还可以通过激活、输出和前馈函数来构建带有额外隐藏层的 MLP。
总结
多层感知机是最简单的神经网络形式。它们是前馈式的,没有递归神经网络的反馈循环,所有隐藏层都是密集的完全连接层,不像卷积神经网络,它们包含卷积层和池化层。由于其简单性,调整的选项较少;然而,在本章中,我们专注于调整隐藏层中的节点数,并探索添加额外的层,因为这一点是区分神经网络模型和其他机器学习算法,进而区别深度学习方法的主要因素。通过本章中的所有代码,你已经学会了如何处理数据以使其准备好建模,如何选择最优的节点和层数,以及如何使用 mxnet 库在 R 中训练和评估模型。
在下一章,你将学习如何编写深度自编码器代码。这是一种无监督学习模型,用于自动分类输入数据。我们将使用这种聚类过程来编写一个基于协同过滤的推荐系统。
第六章:使用嵌入的神经协同过滤
在上一章中,你学习了如何实现多层感知机(MLP)神经网络进行信号检测。
本章将带你探索如何使用基于神经网络嵌入的协同过滤构建推荐系统。我们将简要介绍推荐系统,然后从概念讲解到实际实现。具体来说,你将学习如何使用自定义 Keras API 构建基于神经网络的推荐系统,通过嵌入层来预测用户评分。
本章涉及以下主题:
-
介绍推荐系统
-
使用神经网络的协同过滤
-
准备、预处理并探索数据
-
执行探索性数据分析
-
创建用户和物品嵌入
-
构建和训练神经推荐系统
-
评估结果和调整超参数
技术要求
本章将使用 Keras(TensorFlow API)库。
我们将使用steam200k.csv数据集。该数据集来源于公开的 Steam 数据,Steam 是全球最受欢迎的游戏平台之一。该数据包含了物品(game-title)、用户(user-id)以及两种用户行为(own和value),其中value表示每款游戏的游戏时长。你可以在以下网址找到该数据集:www.kaggle.com/tamber/steam-video-games/version/1#steam-200k.csv。
你可以在 GitHub 上找到本章的代码文件:github.com/PacktPublishing/Hands-on-Deep-Learning-with-R。
介绍推荐系统
推荐系统是信息过滤系统,旨在基于可用数据为用户生成准确且相关的物品建议。Netflix、Amazon、YouTube 和 Spotify 等都是目前在商业中使用推荐系统的流行服务。
推荐系统有三种主要类型:
-
协同过滤:物品推荐基于与其他用户的相似性,反映了个性化的偏好。偏好可以是显式的(物品评分)或隐式的(通过用户-物品互动,如观看、购买等进行的物品评分)。
-
基于内容的过滤:物品推荐反映了上下文因素,如物品属性或用户人口统计信息;物品建议还可以使用时间因素,如适用的地理位置、日期和时间。
-
混合:物品推荐结合了多种(集成)协同过滤和基于内容的过滤方法,这些方法曾在 Netflix 奖(2009 年)等著名竞赛中使用。
有关 Netflix 奖(2009 年)及各种推荐系统方法的历史细节,请参见www.netflixprize.com。
推荐系统通常使用用户和你希望推荐的物品的稀疏矩阵数据。顾名思义,稀疏矩阵是一个数据元素主要由零值组成的矩阵。
许多推荐系统算法旨在通过用户与物品之间的各种交互类型,填充用户-物品交互矩阵,并根据这些交互提供物品建议。如果没有可用的物品偏好或用户交互数据,这通常被称为冷启动问题,可以通过混合方法(协同过滤与基于内容的过滤)、上下文模型(时间、人口统计和元数据)以及随机物品和反馈采样策略等方法来解决。尽管这些干预措施超出了本章的范围,但重要的是要意识到可用的多种实验性和快速发展的技术类型。
为了便于说明,我们将重点关注协同过滤,这是一种基于用户-物品交互生成推荐的流行技术。此外,协同过滤特别适合我们的用户-物品数据集。在没有显式评分(例如,1 到 5,喜欢或不喜欢)时,我们将根据游戏时长创建隐式的用户-物品评分偏好,这些数据在我们的数据集中是可用的。
使用神经网络的协同过滤
协同过滤(CF)是推荐系统用于通过收集和分析其他相似用户的偏好来过滤建议的核心方法。CF 技术利用现有的信息和偏好模式数据,预测(过滤)特定用户的兴趣。
CF 的协同特性与以下观点相关:相关的推荐是从其他用户的偏好中得出的。CF 还假设,两个具有相似偏好的个体,比随机选择的两个个体,更可能对某一物品有共同的偏好。因此,CF 的主要任务是基于系统中其他(协同)相似用户生成物品建议(预测)。
为了识别相似的用户并找到未评分物品的评分(偏好),推荐系统通常需要基于可用输入数据,使用用户与用户-物品偏好之间的相似度索引。传统的基于记忆的方法包括使用距离度量(余弦相似度、杰卡德相似度)计算相似性,相关性(皮尔逊相关系数),或对用户偏好进行加权平均。其他用于确定未评分物品的用户-物品偏好的机器学习方法包括广义矩阵分解方法,如主成分分析(PCA)、奇异值分解(SVD)以及深度学习矩阵分解等。
探索嵌入表示
广义而言,深度神经网络旨在最小化与用于学习输入数据中重要特征的非线性数据表示相关的损失(误差)。
除了传统的降维方法(如聚类、KNN 或矩阵分解(PCA、聚类及其他概率技术))外,推荐系统还可以使用神经网络嵌入来支持降维和分布式非线性数据表示,以可扩展和高效的方式进行处理。
嵌入是通过神经网络从离散输入变量的表示(向量)中学习的低维连续数字表示(向量)。
神经网络嵌入提供了多个优势,如下所示:
-
减少计算时间和成本(可扩展性)
-
对某些学习激活函数所需的输入数据量减少(稀疏性)
-
复杂非线性关系的表示(灵活性)
-
自动特征重要性和选择(效率)
让我们看一个关于如何准备数据以实现基于神经网络嵌入的协同过滤的入门示例。
准备、预处理和探索数据
在构建模型之前,我们需要先探索输入数据,以了解有哪些数据可用于用户-物品推荐。在本节中,我们将通过以下步骤准备、处理和探索数据,这些数据包括用户、物品(游戏)和互动(游戏时长):
- 首先,让我们加载一些 R 包,用于准备和处理输入数据:
library(keras)
library(tidyverse)
library(knitr)
- 接下来,让我们将数据加载到 R 中:
steamdata <- read_csv("data/steam-200k.csv", col_names=FALSE)
- 让我们使用
glimpse()检查输入数据:
glimpse(steamdata)
这将产生以下输出:

- 让我们手动添加列标签,以便整理这些数据:
colnames(steamdata) <- c("user", "item", "interaction", "value", "blank")
- 让我们删除任何空白列或多余的空白字符:
steamdata <- steamdata %>%
filter(interaction == "play") %>%
select(-blank) %>%
select(-interaction) %>%
mutate(item = str_replace_all(item,'[ [:blank:][:space:] ]',""))
- 现在,我们需要创建顺序的用户和物品 ID,以便稍后通过以下代码为我们的查找矩阵指定合适的大小:
users <- steamdata %>% select(user) %>% distinct() %>% rowid_to_column()
steamdata <- steamdata %>% inner_join(users) %>% rename(userid=rowid)
items <- steamdata %>% select(item) %>% distinct() %>% rowid_to_column()
steamdata <- steamdata %>% inner_join(items) %>% rename(itemid=rowid)
- 让我们重命名
item和value字段,以明确我们正在探索用户-物品互动数据,并根据value字段(表示某个游戏的总游戏时长)隐式定义用户评分:
steamdata <- steamdata %>% rename(title=item, rating=value)
- 这个数据集包含用户、物品和互动数据。让我们使用以下代码来识别可用于分析的用户和物品数量:
n_users <- steamdata %>% select(userid) %>% distinct() %>% nrow()
n_items <- steamdata %>% select(itemid) %>% distinct() %>% nrow()
我们已确定有 11,350 名用户(玩家)和 3,598 个物品(游戏)可供分析和推荐。由于我们没有明确的物品评分(例如,是否/否定、1-5 等),我们将基于隐式反馈(游戏时长)生成物品(游戏)推荐以作示范。或者,我们可以尝试获取额外的用户-物品数据(如上下文、时间或内容数据),但我们拥有足够的基础物品互动数据来构建基于神经网络嵌入的初步协同过滤推荐系统。
- 在继续之前,我们需要对评分(用户-物品交互)数据进行归一化,这可以通过标准技术如最小-最大归一化来实现:
# normalize data with min-max function
minmax <- function(x) {
return ((x - min(x)) / (max(x) - min(x)))
}
# add scaled rating value
steamdata <- steamdata %>% mutate(rating_scaled = minmax(rating))
- 接下来,我们将数据拆分为训练数据和测试数据:
# split into training and test
index <- sample(1:nrow(steamdata), 0.8* nrow(steamdata))
train <- steamdata[index,]
test <- steamdata[-index,]
- 现在我们将为训练数据和测试数据创建用户、物品和评分的矩阵:
# create matrices of user, items, and ratings for training and test
x_train <- train %>% select(c(userid, itemid)) %>% as.matrix()
y_train <- train %>% select(rating_scaled) %>% as.matrix()
x_test <- test %>% select(c(userid, itemid)) %>% as.matrix()
y_test <- test %>% select(rating_scaled) %>% as.matrix()
在构建我们的神经网络模型之前,我们将首先进行探索性数据分析(EDA),以更好地理解潜在数据的范围、类型和特征。
进行探索性数据分析
推荐系统试图利用可用信息和偏好模式数据来预测特定用户的兴趣。
作为起点,我们可以使用 EDA 来识别潜在数据中的重要模式和趋势,以帮助我们理解并进行后续分析:
- 我们使用以下代码识别基于用户-物品交互数据构建的隐式评分的前 10 个物品:
# user-item interaction exploratory data analysis (EDA)
item_interactions <- aggregate(
rating ~ title, data = steamdata, FUN = 'sum')
item_interactions <- item_interactions[
order(item_interactions$rating, decreasing = TRUE),]
item_top10 <- head(item_interactions, 10)
kable(item_top10)
Dota 2是按总游戏时间计算的最受欢迎物品(游戏):

- 我们使用以下代码生成用户-物品交互的汇总统计,以识别一些洞察:
# average gamplay
steamdata %>% summarise(avg_gameplay = mean(rating))
# median gameplay
steamdata %>% summarise(median_gameplay = median(rating))
# top game by individual hours played
topgame <- steamdata %>% arrange(desc(rating)) %>% top_n(1,rating)
# show top game by individual hours played
kable(topgame)
根据这次探索性分析,Sid Meier 的文明 V是按个人游戏时间计算的最受欢迎游戏:

- 现在,让我们识别并可视化按游戏时间排列的前 10 个游戏:
# top 10 games by hours played
mostplayed <-
steamdata %>%
group_by(item) %>%
summarise(hours=sum(rating)) %>%
arrange(desc(hours)) %>%
top_n(10, hours) %>%
ungroup
# show top 10 games by hours played
kable(mostplayed)
# reset factor levels for items
mostplayed$item <- droplevels(mostplayed$item)
# top 10 games by collective hours played
ggplot(mostplayed, aes(x=item, y=hours, fill = hours)) +
aes(x = fct_inorder(item)) +
geom_bar(stat = "identity") +
theme(axis.text.x = element_text(size=8, face="bold", angle=90)) +
theme(axis.ticks = element_blank()) +
scale_y_continuous(expand = c(0,0), limits = c(0,1000000)) +
labs(title="Top 10 games by collective hours played") +
xlab("game") +
ylab("hours")
这将产生以下输出:

- 接下来,让我们识别按总用户数计算的最受欢迎游戏:
# most popular games by total users
mostusers <-
steamdata %>%
group_by(item) %>%
summarise(users=n()) %>%
arrange(desc(users)) %>%
top_n(10, users) %>%
ungroup
# reset factor levels for items
mostusers$item <- droplevels(mostusers$item)
# top 10 popular games by total users
ggplot(mostusers, aes(x=item, y=users, fill = users)) +
aes(x = fct_inorder(item)) +
geom_bar(stat = "identity") +
theme(axis.text.x = element_text(size=8, face="bold", angle=90)) +
theme(axis.ticks = element_blank()) +
scale_y_continuous(expand = c(0,0), limits = c(0,5000)) +
labs(title="Top 10 popular games by total users") +
xlab("game") +
ylab("users")
这将产生以下输出:

- 现在,让我们使用以下代码计算用户-物品交互的汇总统计:
summary(steamdata$value)
这将产生以下输出:

整体用户-物品交互的汇总统计数据显示,平均(中位数)交互时间为4.5小时,平均(算术平均)交互时间为48.88小时,这在考虑到最大(离群值)交互值时是合理的:Sid Meier 的文明 V的交互时间为11,754小时!
- 接下来,让我们查看按个人游戏时间分布的物品:
# plot item iteraction
ggplot(steamdata, aes(x=steamdata$value)) +
geom_histogram(stat = "bin", binwidth=50, fill="steelblue") +
theme(axis.ticks = element_blank()) +
scale_x_continuous(expand = c(0,0)) +
scale_y_continuous(expand = c(0,0), limits = c(0,60000)) +
labs(title="Item interaction distribution") +
xlab("Hours played") +
ylab("Count")
以下是按游戏时间排列的物品结果输出:

- 由于这种方法很难确定明确的用户-物品交互模式,让我们通过对游戏时间进行对数变换,再次检查按游戏时间排列的物品,以揭示其他分布模式:
# plot item iteraction with log transformation
ggplot(steamdata, aes(x=steamdata$value)) +
geom_histogram(stat = "bin", binwidth=0.25, fill="steelblue") +
theme(axis.ticks = element_blank()) +
scale_x_log10() +
labs(title="Item interaction distribution with log transformation") +
xlab("log(Hours played)") +
ylab("Count")
这将产生以下输出:

通过对游戏时间进行简单的对数变换,我们可以清楚地看到大多数数据集中的游戏与 1,000 小时或更少的游戏时间相关。
现在我们对潜在数据有了更好的理解,接下来让我们将注意力集中在构建一个神经网络模型,通过嵌入来预测用户评分。
创建用户和物品嵌入
推荐系统可以使用深度神经网络以灵活、可扩展且高效的方式支持复杂的非线性数据表示。
嵌入是从神经网络中离散输入变量的表示(向量)学习到的连续数字的低维表示(向量)。如本章前面所述,推荐系统通常需要一个用户和用户-物品偏好的相似度索引,以识别相似用户并找到未评分物品的评分(偏好)。
然而,与使用广义矩阵分解方法生成用户-物品亲和向量的传统协同过滤方法不同,神经网络可以通过使用分布式、低维表示(嵌入)在潜在(隐藏)空间中存储关于用户-物品亲和的重要信息。
因此,只要我们在同一个潜在空间中拥有用户和物品(游戏)的表示(嵌入),就可以使用点积函数确定用户和物品(游戏)之间关系的相互重要性。假设用户和物品向量已经被规范化,这实际上等同于使用余弦相似度,cos(Θ),作为距离度量,其中 A[i]和 B[i]分别是向量 A 和 B 的分量:

通过为用户和物品创建神经网络嵌入,我们可以减少某些学习激活函数所需的输入数据量,这在协同过滤系统中遇到的典型用户-物品数据稀疏条件下尤为有用。在下一部分,我们将概述如何构建、编译和训练一个神经推荐系统。
构建和训练神经推荐系统
我们现在将使用我们的用户-物品评分数据来构建、编译和训练我们的模型。具体来说,我们将使用 Keras 构建一个定制的神经网络,具有嵌入层(一个用于用户,一个用于物品),并使用一个 lambda 函数计算点积来构建一个基于神经网络的推荐系统的工作原型:
- 让我们通过以下代码开始:
# create custom model with user and item embeddings
dot <- function(
embedding_dim,
n_users,
n_items,
name = "dot"
) {
keras_model_custom(name = name, function(self) {
self$user_embedding <- layer_embedding(
input_dim = n_users+1,
output_dim = embedding_dim,
name = "user_embedding")
self$item_embedding <- layer_embedding(
input_dim = n_items+1,
output_dim = embedding_dim,
name = "item_embedding")
self$dot <- layer_lambda(
f = function(x)
k_batch_dot(x[[1]],x[[2]],axes=2),
name = "dot"
)
function(x, mask=NULL, training=FALSE) {
users <- x[,1]
items <- x[,2]
user_embedding <- self$user_embedding(users)
item_embedding <- self$item_embedding(items)
dot <- self$dot(list(user_embedding, item_embedding))
}
})
}
在前面的代码中,我们使用keras_model_custom函数定义了一个带有用户和物品嵌入的自定义模型。您会注意到,每个嵌入层的输入大小被初始化为输入数据的大小(分别为n_users和n_items)。
- 在以下代码中,我们定义了嵌入参数的大小(
embedding_dim),并定义了我们神经协同过滤模型的架构和向量表示(嵌入),以预测用户评分:
# initialize embedding parameter
embedding_dim <- 50
# define model
model <- dot(
embedding_dim,
n_users,
n_items
)
- 现在,让我们编译我们的模型:
# compile model
model %>% compile(
loss = "mse",
optimizer = "adam"
)
- 接下来,让我们使用以下代码训练我们的模型:
# train model
history <- model %>% fit(
x_train,
y_train,
epochs = 10,
batch_size = 500,
validation_data = list(x_test,y_test),
verbose = 1
)
这将产生以下输出:

- 现在,让我们查看我们模型的基础架构以供参考:
summary(model)
这是我们模型架构的打印输出:

在接下来的部分中,我们将评估模型结果,调整参数,并进行一些迭代调整,以在损失度量方面改善性能。
评估结果并调整超参数
构建推荐系统、评估其性能并调整超参数是一个高度迭代的过程。最终目标是最大化模型的性能和结果。现在我们已经构建并训练了基准模型,可以通过以下代码在训练过程中监控和评估其性能:
# evaluate model results
plot(history)
这将产生以下模型性能输出:

在接下来的部分中,我们将尝试调整模型参数,以提高其性能。
超参数调整
让我们尝试将embedding_dim超参数更改为32,并将batch_size超参数更改为50,以查看是否能获得更好的结果:
# initialize embedding parameter
embedding_dim <- 32
# train model
history <- model %>% fit(
x_train,
y_train,
epochs = 10,
batch_size = 50,
validation_data = list(x_test,y_test),
verbose = 1)
# show model
summary(model)
以下是模型架构的打印输出:

现在,我们将绘制结果,如下所示:
# evaluate results
plot(history)
这将产生以下输出:

不幸的是,这些模型性能结果与我们的基准模型没有显著区别,因此我们需要探索一些额外的模型配置。
添加丢弃层
在以下代码中,我们将添加丢弃层,并鼓励您尝试不同的丢弃率,以观察哪些配置在经验上能带来最佳结果:
# initialize embedding parameter
embedding_dim <- 64
# create custom model with dropout layers
dot_with_dropout <- function(
embedding_dim,
n_users,
n_items,
name = "dot_with_dropout"
) {
keras_model_custom(name = name, function(self) {
self$user_embedding <- layer_embedding(
input_dim = n_users+1,
output_dim = embedding_dim,
name = "user_embedding")
self$item_embedding <- layer_embedding(
input_dim = n_items+1,
output_dim = embedding_dim,
name = "item_embedding")
self$user_dropout <- layer_dropout(
rate = 0.2)
self$item_dropout <- layer_dropout(
rate = 0.4)
self$dot <-
layer_lambda(
f = function(x)
k_batch_dot(x[[1]],x[[2]],axes=2),
name = "dot"
)
function(x, mask=NULL, training=FALSE) {
users <- x[,1]
items <- x[,2]
user_embedding <- self$user_embedding(users) %>%
self$user_dropout()
item_embedding <- self$item_embedding(items) %>%
self$item_dropout()
dot <- self$dot(list(user_embedding,item_embedding))
}
})
}
在前面的代码中,我们使用layer_dropout()添加了丢弃层,这增加了我们初步模型的复杂性。在以下代码中,我们定义、编译并训练我们自定义的带有丢弃层的模型:
# define model
model <- dot_with_dropout(
embedding_dim,
n_users,
n_items)
# compile model
model %>% compile(
loss = "mse",
optimizer = "adam"
)
# train model
history <- model %>% fit(
x_train,
y_train,
epochs = 10,
batch_size = 50,
validation_data = list(x_test,y_test),
verbose = 1
)
这将产生以下输出:

现在,我们将输出模型的摘要,如下所示:
summary(model)
这是模型架构的摘要:

现在,我们将绘制结果,如下所示:
# evaluate results
plot(history)
这将产生以下模型性能输出:

虽然我们添加了丢弃层,但观察到的模型改进非常有限。
让我们重新审视我们的基本假设并尝试另一种方法。
调整用户-项目偏差
重要的是要认识到,实际上,一些用户与项目(游戏)的互动可能与其他用户在游戏频率和偏好上有所不同。这个差异可能会导致根据用户与项目的互动(游戏时间)数据,推导出不同的隐式评分,这些数据是本分析中可用的。
基于我们之前的发现,我们将修改模型,通过为平均用户和项目(游戏)包含嵌入层来考虑用户和项目偏差,使用以下代码:
# caculate minimum and max rating
min_rating <- steamdata %>% summarise(min_rating = min(rating_scaled)) %>% pull()
max_rating <- steamdata %>% summarise(max_rating = max(rating_scaled)) %>% pull()
# create custom model with user, item, and bias embeddings
dot_with_bias <- function(
embedding_dim,
n_users,
n_items,
min_rating,
max_rating,
name = "dot_with_bias"
) {
keras_model_custom(name = name, function(self) {
self$user_embedding <- layer_embedding(
input_dim = n_users+1,
output_dim = embedding_dim,
name = "user_embedding")
self$item_embedding <- layer_embedding(
input_dim = n_items+1,
output_dim = embedding_dim,
name = "item_embedding")
self$user_bias <- layer_embedding(
input_dim = n_users+1,
output_dim = 1,
name = "user_bias")
self$item_bias <- layer_embedding(
input_dim = n_items+1,
output_dim = 1,
name = "item_bias")
在上述代码中,我们创建了一个自定义模型,包含用户和物品的嵌入(user_embedding和item_embedding),以及用户偏差和物品偏差的嵌入(user_bias和item_bias)。在下面的代码中,我们为用户和物品添加了 dropout 层,并鼓励你尝试不同的 dropout 率,以获得最佳结果:
self$user_dropout <- layer_dropout(
rate = 0.3)
self$item_dropout <- layer_dropout(
rate = 0.5)
self$dot <- layer_lambda(
f = function(x)
k_batch_dot(x[[1]],x[[2]],axes=2),
name = "dot")
self$dot_bias <- layer_lambda(
f = function(x)
k_sigmoid(x[[1]]+x[[2]]+x[[3]]),
name = "dot_bias")
self$min_rating <- min_rating
self$max_rating <- max_rating
self$pred <- layer_lambda(
f = function(x)
x * (self$max_rating - self$min_rating) + self$min_rating,
name = "pred")
function(x,mask=NULL,training=FALSE) {
users <- x[,1]
items <- x[,2]
user_embedding <- self$user_embedding(users) %>% self$user_dropout()
item_embedding <- self$item_embedding(items) %>% self$item_dropout()
dot <- self$dot(list(user_embedding,item_embedding))
dot_bias <- self$dot_bias(list(dot, self$user_bias(users), self$item_bias(items)))
self$pred(dot_bias)
}
})
}
接下来,让我们定义、编译并训练我们修改后的神经网络模型:
# define model
model <- dot_with_bias(
embedding_dim,
n_users,
n_items,
min_rating,
max_rating)
# compile model
model %>% compile(
loss = "mse",
optimizer = "adam"
)
# train model
history <- model %>% fit(
x_train,
y_train,
epochs = 10,
batch_size = 50,
validation_data = list(x_test,y_test),
verbose = 1)
)
这将产生如下输出:

现在,我们将打印出总结:
# summary model
summary(model)
通过添加这些额外的嵌入层并调整超参数,我们几乎将训练参数的总数量翻倍,具体体现在以下的模型总结中:

最后,我们绘制了模型的结果如下:
# evaluate results
plot(history)
这将产生如下输出:

通过一系列迭代配置和经验指导的调整,我们改善了相对于先前模型的过拟合问题,并在验证数据集上达到了显著低于 0.1 的 RMSE。通过进一步调优超参数和 dropout 层的比例配置,我们可能会进一步提升模型的性能。未来的推荐是基于此模型继续扩展,获取并实施显式评分数据,此外,尝试更多的上下文信息和用户人口统计数据,以更好地理解用户与物品交互之间的关系和相关因素。
总结
在本章中,你学习了如何使用自定义的 Keras API 和嵌入(embeddings)来构建一个深度神经网络推荐系统。我们简要介绍了协同过滤的概念,并展示了如何准备数据以构建自定义神经网络。在这一迭代过程中,我们创建了用户和物品的嵌入,使用嵌入层训练了深度神经网络,调优了超参数,并使用常见的性能指标评估了结果。在下一章中,你将继续将神经网络方法应用于其他领域,例如自然语言处理。
第七章:自然语言处理中的深度学习
在本章中,您将学习如何创建文档摘要。我们将从删除不应被考虑的文档部分并标记化剩余文本开始。接下来,我们将应用嵌入并创建集群。这些集群将被用来生成文档摘要。我们还将学习如何使用限制玻尔兹曼机(RBMs)作为构建模块,创建用于主题建模的深度信念网络。我们将从编写 RBM 的代码并定义吉布斯采样率、对比散度和算法的自由能开始。最后,我们将通过编译多个 RBM 来创建深度信念网络。
本章涵盖以下主题:
-
使用标记化格式化数据
-
清理文本以去除噪声
-
应用词嵌入以增加可用数据
-
将数据聚类成主题组
-
使用模型结果总结文档
-
创建 RBM
-
定义吉布斯采样率
-
通过对比散度加速采样
-
计算自由能以进行模型评估
-
堆叠 RBM 创建深度信念网络
使用标记化格式化数据
我们开始分析文本的第一步是加载文本文件,然后通过将文本从句子转化为更小的片段(如单词或术语)来对数据进行标记化。文本对象可以通过多种方式进行标记化。在本章中,我们将文本标记化为单词,尽管也可以标记化为其他大小的术语。这些被称为 n-grams,因此我们可以获得由两个单词组成的术语(2-grams)、三个单词组成的术语,或者任何任意大小的术语。
为了开始从文本对象中创建单词标记的过程,我们将使用以下步骤:
- 让我们加载将需要的库。对于这个项目,我们将使用
tidyverse进行数据处理,使用tidytext来执行处理文本数据的特殊函数,使用spacyr提取文本元数据,使用textmineR进行词嵌入。要加载这些库,我们运行以下代码:
library(tidyverse)
library(tidytext)
library(spacyr)
library(textmineR)
在本章中,我们将使用的数据是 20 个新闻组数据集。该数据集包含来自 20 个新闻组之一的文本片段。我们提取的数据格式具有唯一的 ID、文本所属的组以及该组的内容。
- 我们通过以下代码读取数据:
twenty_newsgroups <- read_csv("http://ssc.wisc.edu/~ahanna/20_newsgroups.csv")
运行此代码后,您应该会在Environment窗口中看到twenty_newsgroups对象。该对象有 11,314 行和 3 列。
- 让我们来看一下数据的一个示例。在这种情况下,我们通过运行以下代码来打印第一行数据到控制台:
twenty_newsgroups[1,]
运行此代码后,您将在控制台中看到以下内容:

- 现在,让我们把这段文本拆分成词元。词元是我们在前面截图中看到的文本字符串的一个基本部分。在这种情况下,我们将把这个字符串拆分成单词词元。最终结果将是每个单词列出一行,旁边是 ID 和新闻组 ID。我们使用以下代码对文本数据进行分词:
word_tokens <- twenty_newsgroups %>%
unnest_tokens(word, text)
运行这段代码后,我们可以看到数据对象已经大幅增长。现在,我们的数据有 350 万行,而之前只有 11,000 行,因为每个单词现在都有了自己的一行。
- 现在,让我们快速查看一下词频情况,既然每个单词已经被分隔到各自的一行。在这一步中,我们可以开始观察某些词汇是否比其他词更频繁出现在数据集中。为了绘制数据中每个词的频率,我们将使用以下代码:
word_tokens %>%
group_by(word) %>%
summarize(word_count = n()) %>%
top_n(20) %>%
ggplot(aes(x=reorder(word, word_count), word_count)) +
xlab("word") +
geom_col() +
coord_flip()
运行这段代码后,我们将看到生成的以下图表:

我们成功地将一些文本分割成了词元。然而,从图表中我们可以看到,像“the”、“to”、“of”和“a”这样的词汇最为常见。这类词通常会被打包成一组词汇,称为停用词。接下来,我们将学习如何移除这些没有信息价值的词汇。
清理文本以移除噪音
我们为文本分析做的下一步准备工作是进行一些初步的清理。这是一种常见的起步方式,无论后续将应用何种机器学习方法。当处理文本时,有一些词汇和模式无法提供有意义的信息。部分词汇通常没有用处,移除这些文本数据的步骤可以每次都用,而其他的则更多依赖于具体的上下文。
如前所述,有一些词组被称为停用词。这些词没有信息价值,通常可以移除。为了从我们的数据中移除停用词,我们使用以下代码:
word_tokens <- word_tokens %>%
filter(!word %in% stop_words$word)
运行前面的代码后,我们的行数从 350 万降到了 170 万。实际上,通过移除所有停用词,我们的数据(word_tokens)几乎减少了一半。接下来,我们运行之前的绘图代码,看看现在哪些词汇是最常见的。我们可以通过以下代码行来识别词频:
word_tokens %>%
group_by(word) %>%
summarize(word_count = n()) %>%
top_n(20) %>%
ggplot(aes(x=reorder(word, word_count), word_count)) +
xlab("word") +
geom_col() +
coord_flip()
运行这段代码后,将生成以下图表:

在这张图中,我们可以看到诸如 the、to、of 和 a 这样的词语已被移除。然而,我们也可以看到一些数字作为常见术语出现在图中。这可能是上下文相关的,并且在某些情况下,从文本中提取数字对项目来说非常重要。然而,在这里我们将重点关注实际的单词,并移除所有包含非字母字符的术语。我们可以使用一些正则表达式(regex)来完成这一点。通过使用以下代码,我们可以移除不包含字母表字符的术语:
word_tokens <- word_tokens %>%
filter(str_detect(word, "^[a-z]+[a-z]$"))
运行完这个正则表达式(regex)代码后,我们可以再次运行之前的绘图代码。当我们这么做时,会生成如下所示的图表:

基于这个图表,我们可以看到我们的前二十个术语都是单词,其中包括一个可能的缩写词(nntp)。现在,我们的数据对象已减少至 140 万行,只包含以字母表中的字符开头和结尾的术语,接下来我们准备进入下一步,即使用嵌入(embeddings)为每个术语添加额外的上下文。
应用词嵌入以增加可用数据
从文本中提取术语是文本分析的良好起点。通过我们目前创建的文本词元,我们可以比较不同类别的术语频率,这开始讲述一个关于特定新闻组中占主导地位内容的故事。然而,单独的术语只是我们从某个术语中可以获得的整体信息的一部分。之前的图表中包含了people这个词,当然我们知道这个词的含义,尽管它与该术语相关的多个细微差别需要进一步解释。例如,people是一个名词。它与诸如person和human之类的术语相似,也与household之类的术语相关。所有这些与people相关的细节可能都很重要,但仅通过提取术语,我们无法直接推导出这些其他细节。这就是嵌入特别有用的地方。
在自然语言处理的背景下,嵌入(embeddings)是经过预训练的神经网络,执行如上所述的映射类型。我们可以使用这些嵌入将词性与术语匹配,并计算词语之间的词汇距离。让我们从查看词性嵌入开始。为了检查文本数据集中每个术语的词性,我们运行以下代码:
spacy_install()
spacy_initialize(model = "en_core_web_sm")
spacy_parse(twenty_newsgroups$text[1], entity = TRUE, lemma = TRUE)
使用前面的代码,我们首先在机器上安装spacy。接下来,我们使用一个小型(sm)英语(en)模型来初始化spacy,该模型经过网络文本(web)的训练,涵盖spacy的核心元素:命名实体、词性标注和句法依赖。然后,我们将该模型应用于数据集中的第一条文本。这样做后,我们会在控制台中看到以下结果:

在前面的例子中,我们看到spacy将每个词汇分别存储,并为其分配一个词汇 ID 和句子 ID。每个词汇旁边列出了三项附加数据。我们来看一下11词汇 ID 的例子。在这个例子中,Cubs被模型识别为一个词性,它是一个专有名词,命名实体类型是组织。我们看到ORG_B代码,这意味着这个词汇以一个组织的名字开始。在这个例子中,这个单一的词汇就代表着组织名称的开始和结束。
我们来看几个其他例子。如果你在控制台中向下滚动结果,你应该能找到类似以下输出的部分:

在前面的截图中,我们看到了spacy可以识别的额外信息。我们来看一下76和77行。我们看到文本中使用的词是won't。然而,spacy模型使用了词形还原来拆分这个缩写。自然,won't只是will not的缩写形式,模型已经将构成这个缩写的两个词分开了。另外,每个词的词性也被包含在内。另一个例子是90和91行。这里,this和season是相邻的,模型正确地将这两个词一起识别为指向特定日期部分的词性,这意味着它不是last season或者next season,而是this season。在命名实体列中,this具有DATE_B标签,这意味着该词表示日期,并且它是这个特定日期类型的开始部分。类似地,season具有DATE_I标签,这意味着它表示一个日期类型的数据,并且该词在实体内部。从这两个标签,我们可以知道this和season是相关的,并且一起指代一个特定的时间点。
我们还可以通过使用词嵌入将文本数据聚类为主题组。主题分组将生成一个数据对象,其中包含在文本中彼此靠近共现的词汇列表。通过这个过程,我们可以看到在我们分析的文本数据中,哪些话题被讨论得最多。接下来,我们将创建主题组聚类。
将数据聚类为主题组
我们可以使用词嵌入来找到所有语义相似的词汇。为此,我们将使用textmineR包来创建一个 skip-gram 模型。skip-gram 模型的目标是查找在给定窗口内与另一个词汇经常同时出现的词汇。由于这些词汇在我们的文本句子中如此频繁地靠近在一起,我们可以得出它们彼此之间有某种联系的结论。我们将通过以下步骤开始:
- 为了开始构建我们的 skip-gram 模型,我们首先通过运行以下代码创建一个词汇共现矩阵:
tcm <- CreateTcm(doc_vec = twenty_newsgroups$text,
skipgram_window = 10,
verbose = FALSE,
cpus = 2)
运行代码后,你的环境窗口中将会有一个sparse矩阵。该矩阵的两个维度上有所有可能的术语,并且在这些术语相互出现在跳字窗口内时,矩阵中相应位置会显示一个值,跳字窗口在本例中为10。矩阵的部分内容如下所示:

- 接下来,我们将基于我们刚刚创建的文本共现矩阵,拟合一个潜在狄利克雷分配(LDA)模型。对于我们的模型,我们将选择创建 20 个主题,并让模型执行 500 次 Gibbs 迭代,设置
burning值为200,即我们首先丢弃的样本数。我们将设置calc_coherence为TRUE以包括该度量。coherence是主题中术语之间的相对距离,我们将使用这个距离值来对找到的主题强度进行排名。我们通过运行以下代码来定义我们的 LDA 模型:
embeddings <- FitLdaModel(dtm = tcm,
k = 20,
iterations = 500,
burnin = 200,
calc_coherence = TRUE)
- 下一步,我们将为每个主题提取最重要的术语。我们将使用
phi,它表示主题中单词的分布,以及参数M,用来选择每个主题集群中包含多少个术语。我们可以通过运行以下代码来获取每个主题的最重要术语:
embeddings$top_terms <- GetTopTerms(phi = embeddings$phi,
M = 5)
- 我们将提取我们的主题和重要术语,并添加
coherence评分以及prevalence评分,后者表示这些术语在我们分析的整个文本语料库中出现的频率。我们可以通过运行以下代码来构建这个摘要数据对象:
embeddings$summary <- data.frame(topic = rownames(embeddings$phi),
coherence = round(embeddings$coherence, 3),
prevalence = round(colSums(embeddings$theta), 2),
top_terms = apply(embeddings$top_terms, 2, function(x){
paste(x, collapse = ", ")
}),
stringsAsFactors = FALSE)
- 现在我们已经创建了这个摘要数据对象,我们可以根据
coherence值查看前五个主题。我们可以通过运行以下代码,识别出哪些主题的术语相对更接近:
embeddings$summary[order(embeddings$summary$coherence, decreasing = TRUE),][1:5,]
当我们运行前面的代码时,我们会看到文本对象中识别出的前五个主题。你将在控制台中看到以下主题:

我们已经加载了文本数据,从文本中提取了术语,使用模型识别了与术语相关的信息——如命名实体细节和词性——并根据在文本中发现的主题组织了这些术语。接下来,我们将通过建模来减少我们的文本对象,以总结文档内容。
使用模型结果总结文档
在最后一步,在开始构建我们自己的模型之前,我们将使用textrank包来总结文本。该算法用于总结文本的方法是寻找一个包含最多在文本数据中其他句子中出现的词汇的句子。我们可以看到,这种类型的句子非常适合用来总结文本,因为它包含了在其他地方出现的许多词汇。为了开始,我们从我们的数据中选择一段文本:
- 让我们通过运行以下代码来查看第
400行的文本:
twenty_newsgroups$text[400]
当我们运行这行代码时,我们会在控制台看到以下文本:

在这封邮件中,我们可以看到主题是反对他人邮件的内容,因为它与主题无关。
- 让我们看看
textrank算法将提取哪一句来总结文本。首先,我们将对文本进行分词处理。然而,不像之前我们创建了词汇分词,这次我们将创建句子分词。此外,我们还将使用每个提取句子的行号作为句子 ID。为了从文本中创建句子分词,我们运行以下代码:
sentences <- tibble(text = twenty_newsgroups$text[400]) %>%
unnest_tokens(sentence, text, token = "sentences") %>%
mutate(id = row_number()) %>%
select(id, sentence)
- 接下来,我们将像之前一样创建词汇分词。记住,创建句子和词汇分词的原因是我们需要查看哪些词汇出现在最多的句子中,而在这些词汇中,哪一个句子包含最多频繁出现的词汇。为了创建每行一个词的数据对象,我们运行以下代码:
words <- sentences %>%
unnest_tokens(word, sentence)
- 接下来,我们运行
textrank_sentences函数,它按之前描述的方式计算最佳的摘要句子。我们通过运行以下代码计算textrank分数,这个分数衡量哪些句子最能总结文本:
article_summary <- textrank_sentences(data = sentences, terminology = words)
我们现在已经对句子进行了排名。如果查看摘要,我们可以看到默认显示的是前五个句子。然而,在这种情况下,让我们从排名最高的句子开始,看看它如何总结整体文本。
- 要查看排名最高的句子,我们首先必须查看返回列表中的第一个对象,这个对象是一个包含句子及其对应
textrank分数的数据框。接着,我们按照textrank分数降序排列,选择评分最高的句子。然后,我们选择顶部的行,并仅提取句子数据。为了打印基于textrank算法的排名最高的句子,我们运行以下代码:
article_summary[["sentences"]] %>%
arrange(desc(textrank)) %>%
top_n(1) %>%
pull(sentence)
运行这段代码后,您将看到如下的控制台输出:

选中的句子是将讨论限制在适当的新闻组中。如果我们重新阅读整篇文本,可以看到这句话确实捕捉到了作者想要表达的核心内容。实际上,如果邮件只有这一行,它几乎能传达相同的信息。通过这种方式,我们可以确认textrank算法表现良好,选中的句子是整个文本的一个很好的总结。
现在我们已经介绍了一些由各种 R 包提供的基础文本分析工具,接下来我们将开始创建自己的深度学习文本模型。
创建 RBM
到目前为止,我们已经从文本中提取了元素,添加了元数据,并创建了术语集群来发现潜在主题。接下来,我们将通过使用一种深度学习模型——RBM(受限玻尔兹曼机)来识别潜在特征。正如你可能记得的,我们曾通过在给定的窗口大小内寻找术语共现来发现文本中的潜在主题。在这种情况下,我们将重新回到使用神经网络的方法。RBM 是典型神经网络的一半。它不是通过隐藏层将数据传递到输出层,而是仅将数据传递到隐藏层,输出就是这个隐藏层的内容。最终结果类似于因子分析或主成分分析。在这里,我们将开始查找数据集中每个 20 个新闻组的过程,并且在本章的其余部分,我们将对模型进行修改以提高其性能。
为了开始构建我们的 RBM,我们需要加载两个库。第一个库是tm,它用于 R 中的文本挖掘,具有创建文档-术语矩阵和执行文本清理的功能。另一个库是deepnet,它有一个用于 RBM 的函数。为了加载这两个库,我们运行以下代码:
library(tm)
library(deepnet)
接下来,我们将取出我们的文本数据并创建一个语料库,在这个语料库中,每个新闻组的电子邮件内容将被放置在单独的列表元素中。然后,我们将移除一些不含有信息的元素。我们还将把所有文本转换为小写,以减少唯一术语的数量,并将相同的术语按字母大小写归类。之后,我们将把剩余的术语转换为文档-术语矩阵,其中所有术语构成矩阵的一个维度,所有文档构成另一个维度,矩阵中表示的值是该术语是否出现在文档中。我们还将使用词频-逆文档频率(tf-idf)加权。
在这种情况下,矩阵中的值将不再是二进制的,而是一个浮动值,表示术语在文档中的唯一性,减少那些在所有文档中都频繁出现的术语的权重,并增加那些仅出现在一个或一些文档中,而不是所有文档中的术语的权重。为了执行这些步骤并准备我们的文本数据输入到 RBM 模型中,我们运行以下代码:
corpus <- Corpus(VectorSource(twenty_newsgroups$text))
corpus <- tm_map(corpus, content_transformer(tolower))
corpus <- tm_map(corpus, removeNumbers)
corpus <- tm_map(corpus, removePunctuation)
corpus <- tm_map(corpus, removeWords, c("the", "and", stopwords("english")))
corpus <- tm_map(corpus, stripWhitespace)
news_dtm <- DocumentTermMatrix(corpus, control = list(weighting = weightTfIdf))
news_dtm <- removeSparseTerms(news_dtm, 0.95)
我们将像任何建模任务一样,现在将我们的数据分割成train和test集合。在这种情况下,我们将通过运行以下代码来创建我们的train和test集合:
split_ratio <- floor(0.75 * nrow(twenty_newsgroups))
set.seed(614)
train_index <- sample(seq_len(nrow(twenty_newsgroups)), size = split_ratio)
train_x <- news_dtm[train_index,]
train_y <- twenty_newsgroups$target[train_index]
test_x <- news_dtm[-train_index,]
test_y <- twenty_newsgroups$target[-train_index]
当数据已经按照正确的格式,并分割成train和test集合时,我们现在可以训练我们的 RBM 模型。训练模型非常直接,配置的参数并不多。暂时,我们将修改一些参数,并随着章节的进展对其他参数做出调整。首先,我们将通过运行以下代码来训练一个初步的 RBM 模型:
rbm <- rbm.train(x = as.matrix(train_x), hidden = 20, numepochs = 100)
在前面的代码中,我们将 hidden 层设置为新闻组的数量,以查看是否有足够的潜在信息将文本映射到新闻组上。我们从 100 轮开始,其他设置保持默认。
我们现在可以探索文本中找到的潜在特征。我们使用训练好的模型来执行此任务,通过将数据作为输入,从而生成推断的隐藏单元作为输出。我们通过运行以下代码来推断 test 数据的隐藏单元:
test_latent_features <- rbm.up(rbm, as.matrix(test_x))
运行这段代码后,我们已经定义了 test 数据的潜在特征空间。
定义吉布斯采样率
吉布斯采样在构建 RBM 模型中起着关键作用,因此我们在这里稍作停留,定义这一采样类型。我们将简要介绍几个快速概念,帮助理解如何执行吉布斯采样,以及为什么这种方法对这种类型的建模至关重要。在 RBM 模型中,我们首先使用神经网络将输入或可见单元映射到隐藏单元,这些可以看作是潜在特征。在训练我们的模型后,我们希望通过给定一个新的可见单元,定义它属于模型中隐藏单元的概率,或者反过来做。我们还希望这个过程在计算上是高效的,因此我们使用了蒙特卡罗方法。
蒙特卡罗方法涉及通过采样随机点来近似一个区域或分布。一个经典的例子是绘制一个 10x10 英寸的正方形,并在正方形内绘制一个圆。我们知道,直径为 10 英寸的圆的面积是 78.5 平方英寸。现在,如果我们使用随机数生成器选择 0 到 10 之间的浮动对,并进行 20 次操作并绘制这些点,我们可能会得到约 15 个点位于圆内,5 个点位于圆外。如果我们仅使用这些点,那么我们会估算该区域面积为 75 平方英寸。现在,如果我们尝试用一个不那么传统但有许多曲线和角度的形状来进行类似的操作,那么计算面积将变得更加困难。然而,我们仍然可以使用相同的方法来近似该区域。通过这种方式,当一个分布难以精确地定义或计算成本高昂时,蒙特卡罗方法会发挥很好的作用,这正是我们的 RBM 模型所面临的情况。
接下来,马尔科夫链是一种定义条件概率的技术,它仅考虑紧接在我们试图预测的事件概率之前发生的事件,而不是考虑发生在两步或更多步之前的事件。这是一种非常简单的条件概率形式。一个经典的例子用于解释这个概念的是“滑梯与梯子”游戏。在这个游戏中,有 100 个方格,玩家掷一个六面骰子来决定移动的步数,目标是到达第 100 格。在过程中,有些方格可能包含一个滑梯,玩家会倒退一定数量的方格,或者有一个梯子,玩家会向前移动一定数量的方格。
在确定停留在某个特定方格的可能性时,唯一重要的是导致玩家停留在某个方格上的上一轮掷骰子。无论是哪种掷骰子的组合将玩家带到这个点,都不会影响基于玩家当前所在方格到达某个特定方格的概率。
为了提供一些背景,我们将简要讨论这两个概念,因为它们都涉及到吉布斯采样。这种类型的采样是一种蒙特卡洛马尔可夫链方法,这意味着我们从一个初始状态开始,然后我们预测在给定另一个事件y的情况下,某个事件x发生的可能性,反之亦然。通过计算这种相互之间的条件概率,经过一定数量的样本后,我们可以高效地近似给定的visible单元属于某个hidden单元的概率。我们可以通过一些非常简单的吉布斯分布采样示例来进行实验。在这个示例中,我们将创建一个函数,使用参数rho作为系数值,在计算另一个变量的值时修改给定项,而在我们的 RBM 模型中,学习到的权重和偏置项执行了这个功能。让我们使用以下步骤创建一个采样器:
- 首先,让我们定义一个非常简单的吉布斯采样器,通过运行以下代码来理解这个概念:
gibbs<-function (n, rho)
{
mat <- matrix(ncol = 2, nrow = n)
x <- 0
y <- 0
mat[1, ] <- c(x, y)
for (i in 2:n) {
x <- rnorm(1, rho * y, sqrt(1 - rho²))
y <- rnorm(1, rho * x, sqrt(1 - rho²))
mat[i, ] <- c(x, y)
}
mat
}
现在我们已经定义了函数,让我们通过选择两个不同的rho值来计算两个独立的 10 x 2 矩阵。
- 我们通过以下代码计算第一个 10 x 2 矩阵,使用
0.75作为rho的值:
gibbs(10,0.75)
- 接下来,我们使用
0.03作为rho的值,通过以下代码计算一个 10 x 2 的矩阵:
gibbs(10,0.03)
在运行完每个步骤后,你应该能在控制台看到打印出的 10 x 2 矩阵。这个函数涉及从正态分布中抽取随机值,因此你在控制台上看到的矩阵会略有不同。不过,你会看到值是如何通过使用前一个迭代的值来逐步生成当前迭代的值的。我们可以看到蒙特卡洛的随机性是如何在计算我们的值时与马尔可夫链的条件概率一起使用的。现在,通过理解吉布斯采样,我们将探讨对比发散,它是我们可以利用吉布斯采样的知识来修改我们模型的一种方式。
通过对比发散加速采样
在继续之前,我们需要更换使用的数据集。虽然 20 个新闻组数据集到目前为止很好地适用于所有文本分析概念,但当我们尝试真正调优模型以预测潜在特征时,它变得不再那么适用了。接下来我们所做的所有额外修改对使用 20 个新闻组数据集的模型几乎没有影响,因此我们将切换到垃圾邮件与正常邮件的数据集,它们相似。不过,与其说是新闻组的电子邮件,这里是短信文本信息。此外,目标变量不再是一个给定的新闻组,而是判断消息是否是垃圾邮件或合法短信。
对比散度是一个可以帮助我们利用吉布斯采样学习的参数。我们在模型中传递给这个参数的值将调整吉布斯采样的执行次数。换句话说,这控制着马尔可夫链的长度。值越低,每一轮模型的执行速度越快。如果值较高,那么每一轮的计算开销较大,尽管模型可能更快收敛。在接下来的步骤中,我们可以使用三个不同的对比散度值来训练模型,看看调整这个参数对模型的影响:
- 首先,我们将使用以下代码加载垃圾邮件与正常邮件的数据集:
spam_vs_ham <- read.csv("spam.csv")
- 接下来,我们将目标变量移到一个向量
y中,将预测文本数据移到变量x中。之后,我们将进行一些基本的文本预处理,移除特殊字符和一字或二字词,并去除任何空白字符。我们通过运行以下代码定义目标变量和预测变量,并清理文本:
y <- if_else(spam_vs_ham$v1 == "spam", 1, 0)
x <- spam_vs_ham$v2 %>%
str_replace_all("[^a-zA-Z0-9/:-_]|\r|\n|\t", " ") %>%
str_replace_all("\b[a-zA-Z0-9/:-]{1,2}\b", " ") %>%
str_trim("both") %>%
str_squish()
- 接下来,我们将这段清理后的文本转换成一个
corpus数据对象,然后再转化为文档-词矩阵。我们通过运行以下代码,将文本数据转换为适合建模的格式:
corpus <- Corpus(VectorSource(x))
dtm <- DocumentTermMatrix(corpus)
- 接下来,我们将数据分成
train和test两部分,就像我们在 20 个新闻组数据集上做的那样。我们使用以下代码将数据划分并准备好用于建模:
split_ratio <- floor(0.75 * nrow(dtm))
set.seed(614)
train_index <- sample(seq_len(nrow(dtm)), size = split_ratio)
train_x <- dtm[train_index,]
train_y <- y[train_index]
test_x <- dtm[-train_index,]
test_y <- y[-train_index]
- 现在所有数据都已准备好,让我们运行三个模型并看看它们的比较。我们运行这三个 RBM 模型的简化版,通过以下代码评估调整对比散度值对模型的影响:
rbm3 <- rbm.train(x = as.matrix(train_x),hidden = 100,cd = 3,numepochs = 5)
rbm5 <- rbm.train(x = as.matrix(train_x),hidden = 100,cd = 5,numepochs = 5)
rbm1 <- rbm.train(x = as.matrix(train_x),hidden = 100,cd = 1,numepochs = 5)
为了衡量这个参数对模型的影响,我们将使用模型对象中的自由能值。
计算模型评估的自由能
RBM(受限玻尔兹曼机)属于一类基于能量的模型。它们使用的自由能方程类似于其他机器学习算法中的代价函数。就像代价函数一样,目标是最小化自由能值。较低的自由能值意味着可见单元变量更有可能被隐藏单元描述,而较高的自由能值则表示较低的可能性。
现在让我们查看我们刚刚创建的三个模型,并比较这些模型的自由能量值。我们通过运行以下代码来比较自由能量,从而识别哪个模型表现更好:
rbm5$e[1:10]
rbm3$e[1:10]
rbm1$e[1:10]
运行此代码后,控制台将打印出类似以下的输出:

在这种情况下,只使用一轮 Gibbs 采样就能生成在最快方式下减少自由能量的最佳模型。
堆叠 RBM 创建深度置信网络
RBM 模型是一个只有两层的神经网络:输入层,即可见层,以及具有潜在特征的隐藏层。然而,可以添加额外的隐藏层和输出层。当在 RBM 的上下文中进行此操作时,它被称为 深度置信网络。因此,深度置信网络就像其他深度学习架构一样。对于深度置信网络,每个隐藏层是完全连接的,意味着它学习整个输入。
第一层是典型的 RBM,其中潜在特征是从输入单元计算得出的。在下一层中,新的隐藏层从前一个隐藏层学习潜在特征。这样,最终可以得到一个用于分类任务的输出层。
实现深度置信网络使用的语法与训练 RBM 时所用的语法相似。为了开始,我们首先对刚训练好的 RBM 的潜在特征空间进行快速检查。为了打印出模型的潜在特征空间的样本,我们使用以下代码:
train_latent_features <- rbm.up(rbm1, as.matrix(train_x))
test_latent_features <- rbm.up(rbm1, as.matrix(test_x))
在前面的代码中,我们使用 up 函数通过我们刚刚拟合的模型生成潜在特征矩阵。up 函数以一个 RBM 模型和一个可见单元矩阵为输入,并输出一个隐藏单元矩阵。反之也可以。down 函数以一个隐藏单元矩阵为输入,输出可见单元。通过前面的代码,我们将看到类似以下内容的输出打印到控制台:

我们可以看到第一层特征空间中的方差。为了准备下一步,我们可以想象将此矩阵作为输入传递给另一个 RBM,以便进一步学习特征。通过这种方式,我们可以使用与训练 RBM 时几乎相同的语法来编码我们的深度置信网络。唯一的区别是,对于隐藏层参数,我们不再使用表示单个隐藏层单元数量的单一值,而是可以使用一个表示每个连续隐藏层单元数量的值向量。对于我们的深度置信网络,我们将从 100 个单元开始,就像我们的 RBM 一样。
接下来,我们将在下一层将单元数减少到50,在那之后的层减少到10。另一个不同之处是,我们现在有了一个目标变量。虽然 RBM 是一个无监督的生成模型,但我们可以利用我们的深度置信网络执行分类任务。我们使用以下代码训练深度置信网络:
dbn <- dbn.dnn.train(x = as.matrix(train_x), y = train_y, hidden = c(100,50,10), cd = 1, numepochs = 5)
在训练好深度置信网络之后,我们现在可以使用该模型进行预测。我们执行预测任务的方式与大多数机器学习任务的预测生成方式类似。然而,在这种情况下,我们将使用nn.predict函数来使用训练好的神经网络预测新的测试输入是否应该被分类为垃圾邮件或合法文本。我们使用以下代码对test数据进行预测:
predictions <- nn.predict(dbn, as.matrix(test_x))
我们现在已经得到了概率值,这些概率值告诉我们给定的消息是否是垃圾邮件。这些概率目前在一个受限范围内;然而,我们仍然可以使用它。让我们在概率上设置一个截断点,对于高于该阈值的概率值,我们将其标记为1,表示消息被预测为垃圾邮件,而低于该截断点的所有值将被赋值为0。在设置好这个分界线并创建一个二值化的向量后,我们可以创建混淆矩阵来查看模型的表现。我们创建了二进制变量,然后通过运行以下代码来评估模型的表现:
pred_class <- if_else(predictions > 0.3, 1, 0)
table(test_y,pred_class)
运行上述代码后,我们将在控制台看到以下输出:

如我们所见,即使这个非常简单的深度置信网络实现也表现得相当不错。从这里开始,可以进一步修改隐藏层的数量、每层的单元数、输出激活函数、学习率、动量、丢弃率,以及对比散度和迭代次数等参数。
总结
在本章中,我们介绍了多种分析文本数据的方法。我们从提取文本数据中的元素技巧开始,比如将句子分解为标记并比较词频,收集主题、识别最佳摘要句子,并从文本中提取这些内容。接下来,我们使用了一些嵌入技术,为我们的数据添加更多细节,如词性标注和命名实体识别。最后,我们使用了 RBM 模型来发现输入数据中的潜在特征,并将这些 RBM 模型堆叠起来执行分类任务。在下一章中,我们将探讨如何利用深度学习处理时间序列任务,尤其是股票价格预测。
第八章:用于股市预测的长短期记忆网络
本章将展示如何使用长短期记忆(LSTM)模型预测股价。这种模型特别适用于基于时间序列的预测任务。LSTM 模型是递归神经网络(RNN)的一种特殊类型。这些模型具有特殊的特点,允许你将最近的输出作为输入重复使用。通过这种方式,这些类型的模型通常被描述为具有记忆。我们将从创建一个简单的基准模型开始,预测股价。从这里出发,我们将创建一个最小化的 LSTM 模型,并深入探讨这种模型类型相较于我们的基准模型的优势,以及它如何在某种程度上优于更传统的 RNN。最后,我们将讨论一些调整模型的方式,以进一步提高其性能。
在本章中,我们将涵盖以下主题:
-
了解股票市场预测的常见方法
-
准备和预处理数据
-
配置数据生成器
-
训练和评估模型
-
调整超参数以提高性能
技术要求
你可以在github.com/PacktPublishing/Hands-on-Deep-Learning-with-R找到本章使用的代码文件。
了解股票市场预测的常见方法
在本章中,我们将学习一种不同类型的神经网络,叫做 RNN。特别地,我们将应用一种 RNN 模型,称为 LSTM 模型,来预测股价。在我们开始之前,首先让我们了解一些常见的股票价格预测方法,以更好地理解这个问题。
预测股价是一个时间序列问题。与大多数其他机器学习问题不同,变量可以随机分割并用于训练和测试数据集,但在解决时间序列问题时,这种做法不可行。变量必须保持顺序。解决问题的特征可以在事件的序列中找到,因此,事件发生的时间顺序必须保持,以生成有意义的预测,预测接下来会发生什么。虽然这对可用方法施加了限制,但也提供了一个机会,可以使用一些特别适合这些任务的模型。
在我们构建深度学习解决方案之前,让我们先从一些相对简单的方法开始,创建一个基准模型,之后可以用来比较结果。我们将构建的第一个模型是自回归积分滑动平均(ARIMA)模型。实际上,这个模型名称中的概念解释了时间序列数据建模的许多特殊挑战。
AR 在 ARIMA 中代表自回归,指的是模型的输入将是给定观测值和一组滞后观测值。MA 在 ARIMA 中代表移动平均,指的是模型的自回归特性,表示变量将包含在给定时间点的观测值和一组滞后变量。移动平均部分考虑了随时间变化的一组变量的平均值,从而捕捉到解释趋势的更一般化的值。
I 在 ARIMA 中代表积分,在这个上下文中意味着整个时间序列被视为一个整体。更具体地说,它指的是解决方案必须能够在整个序列上进行推广,这就像我们在其他机器学习解决方案中追求的一样。为此,在时间序列问题中,我们会对数据进行转换,使其成为平稳序列。对于 ARIMA,我们会查看一个观测值与前一个观测值之间的差异,并使用这些相对差异。通过使用相对差异,我们可以保持更广义的形状,从而有助于控制均值和方差,这对于预测未来状态至关重要。
有了这个背景,接下来我们将创建一个 ARIMA 模型。首先,我们将使用quantmod包加载股票信息。这个包中的getSymbols函数是一个非常方便的方式,可以从多个来源在设定的时间框架内提取任何公司股票的价格信息。我们将auto.assign设置为FALSE,因为我们会自己分配这个对象。对于我们的例子,我们将加载 5 年的 Facebook 股票数据:
- 在以下代码中,我们将读取数据并加载我们将使用的所有库:
library(quantmod)
library(tseries)
library(ggplot2)
library(timeSeries)
library(forecast)
library(xts)
library(keras)
library(tensorflow)
FB <- getSymbols('FB', from='2014-01-01', to='2018-12-31', source = 'google', auto.assign=FALSE)
运行此代码后,我们会看到在环境中有一个FB对象,它的类类型是xts。这个对象的类类型类似于标准的数据框,但行名是日期。让我们查看几行xts数据。
- 我们可以通过以下代码查看
FB对象的前五行:
FB[1:5,]
运行此代码后,你将在控制台中看到以下输出:

从这些选定的行中我们可以看到,数据中包含了来自当天的多个股票价格点,包括开盘价、收盘价、以及当天的最高价和最低价。同时也包含了股票交易量。对于我们的目的,我们将使用收盘时的价格。
- 我们从数据中只选择收盘价,并通过运行以下代码将其存储在一个新对象中:
closing_prices <- FB$FB.Close
- 现在我们可以使用
plot.xts函数来绘制股价数据。通常,绘制这些数据需要将日期存储在一个列中,但这个绘图函数提供了一种便捷的方式来绘制时间序列数据,而无需在数据框中保留日期列。我们可以通过运行以下代码来绘制 Facebook 过去五年的收盘股价:
plot.xts(closing_prices,main="Facebook Closing Stock Prices")
在我们运行这一行代码后,我们将在Plots选项卡中看到如下生成的图表:

- 现在我们可以看到 Facebook 股价在这个时间段内的变化,让我们来构建一个 ARIMA 模型。之后,我们将使用该 ARIMA 模型创建一个预测,并绘制这些预测值。我们通过运行以下代码来创建模型、进行预测并绘制图形:
arima_mod <- auto.arima(closing_prices)
forecasted_prices <- forecast(arima_mod,h=365)
autoplot(forecasted_prices)
在运行前面的代码后,我们将在Plots选项卡中看到如下图表:

ARIMA 模型没有找到规律,反而给出了一个上下边界,在这个范围内预测股价,这并不特别有用。ARIMA 是一个流行的基准模型,用于预测时间序列数据,通常表现不错。然而,在这个案例中,我们可以看到我们的 ARIMA 模型似乎没有提供太多有用的信息。
- 在继续之前,我们可以将这个数据框中的实际股价值添加进来,看看股价是否落在 ARIMA 模型预测的范围内。为了将数据拉入并添加到我们的图表中,我们运行以下代码:
fb_future <- getSymbols('FB', from='2019-01-01', to='2019-12-31', source = 'google', auto.assign=FALSE)
future_values <- ts(data = fb_future$FB.Close, start = 1258, end = 1509)
autoplot(forecasted_prices) + autolayer(future_values, series="Actual Closing Prices")
在我们运行前面的代码后,我们将在Plots选项卡中看到如下图表:

如前所述,这些日期的值超出了 ARIMA 模型的预测范围。我们可以看到,我们选择了一个比较复杂的时间序列数据集来进行建模,因为股价呈现下降趋势,然后又开始上升。话虽如此,整个五年期间,股价整体呈现上升趋势。我们能否构建一个能够学习这种上升趋势并在预测结果中正确反映的模型呢?让我们利用我们学到的关于时间序列数据建模的特殊挑战,看看是否能通过深度学习方法来改进我们的基准结果。
数据准备与预处理
在处理时间序列数据时,有多种数据类型格式可以选择并进行转换。我们已经使用了其中的两种格式,实际上有三种格式是最常用的。在进入深度学习模型之前,让我们简要回顾一下这些数据类型。
当我们希望将实际数据作为叠加层添加到 ARIMA 模型图中时,我们使用了ts函数来创建时间序列数据对象。对于此对象,索引值必须是整数。在使用autolayer函数与arima图结合时,也需要一个时间序列数据对象。这是更简单的时间序列数据类型,它在环境标签中看起来像一个向量。然而,这仅适用于常规时间序列。
另一种数据类型是zoo。zoo数据类型适用于常规和不规则的时间序列,且 zoo 对象还可以包含不同的数据类型作为索引值。zoo对象的缺点是环境窗格中提供的信息较少,唯一的细节是日期范围。有时,zoo数据在绘图时表现更好,尤其是在叠加多个时间序列对象时,这也是我们在本章后面将使用它的原因。
最后一种时间序列数据类型是xts。这种数据类型是zoo的扩展。与zoo一样,索引值是日期。然而,此外,数据被存储在一个矩阵中,并且在环境窗格中会显示许多属性,使得检查数据对象的大小和内容更加容易。这通常是处理时间序列数据的好选择,除非有特别的理由使用我们已经涵盖的其他类型。xts的另一个好处是,默认的绘图函数使用不同于基础绘图的格式。
在处理时间序列数据时,除了数据类型转换之外,还有另一个常用于建模的预处理步骤:将数据转换为增量值,而非绝对值,以便使数据平稳。在这种情况下,我们还将对价格值进行对数变换,以进一步控制异常值。当我们完成此过程时,我们还需要去除我们引入的缺失值。让我们通过运行以下代码,将我们的合并股票价格数据从收盘价转换为收盘价对数值的日变化:
future_prices <- fb_future$FB.Close
closing_deltas <- diff(log(rbind(closing_prices,future_prices)),lag=1)
closing_deltas <- closing_deltas[!is.na(closing_deltas)]
运行此代码后,我们得到了比之前处理的数据更为平稳的数据。现在,让我们通过运行以下代码查看我们的数据现在是什么样的:
plot(closing_deltas,type='l', main='Facebook Daily Log Returns')
当我们运行前面的代码时,我们将在图形窗格中看到以下生成的图表:

我们正在处理的值约束更多,一个部分的模式看起来能够更好地概括并解释不同部分的运动。尽管我们可以看到这里的值已经更好地进行了缩放,但我们还可以进行一个快速测试,证明数据现在是平稳的。为了检查平稳性,我们可以使用扩展的迪基-富勒检验,我们可以通过以下代码行在我们的数据上运行该检验:
adf.test(closing_deltas)
运行此代码后,我们将在控制台中看到以下输出:

尽管在运行此函数时不会输出实际的 p 值,但我们可以看到 p 值足够小,因此我们可以接受数据是平稳的备择假设。完成此预处理后,我们现在可以继续为深度学习模型设置train和test数据集。
配置数据生成器
类似于 ARIMA,对于我们的 LSTM 模型,我们希望模型使用滞后的历史数据来预测给定时间点的实际数据。然而,为了将这些数据传递给 LSTM 模型,我们必须将数据格式化,使得一定数量的列包含所有滞后的值,而一列包含目标值。在过去,这个过程略显繁琐,但现在我们可以使用数据生成器使这一任务变得更加简单。在我们的案例中,我们将使用一个时间序列生成器,它生成一个我们可以用于 LSTM 模型的张量。
在生成数据时,我们将包括的数据参数是我们将使用的数据对象及其目标。在这种情况下,我们可以将相同的数据对象作为这两个参数的值。之所以可以这样做,是因为下一个参数length,它配置了回溯的时间步数,用来填充滞后价格值。然后,我们定义采样率和步幅,它们分别决定目标值每行的连续时间步数以及每个序列的滞后值的时间步数。我们还定义了起始索引值和结束索引值。我们还需要确定数据是否应被打乱,或保持按时间顺序排列,或者是否应按逆时间顺序排列,或保持当前排序。最后,我们选择批量大小,这决定了每批模型中应包含多少时间序列样本。
对于这个模型,我们将创建生成的时间序列数据,其length值为3,意味着我们将回溯 3 天以预测给定的那一天。我们将保持采样率和步幅为1,以包括所有数据。接下来,我们将通过1258作为索引点来拆分train和test数据集。我们不会打乱数据或反转数据,而是保持其时间顺序,并将批量大小设置为1,以便一次建模一个价格。我们通过以下代码使用这些参数值来创建train和test数据集:
train_gen <- timeseries_generator(
closing_deltas,
closing_deltas,
length = 3,
sampling_rate = 1,
stride = 1,
start_index = 1,
end_index = 1258,
shuffle = FALSE,
reverse = FALSE,
batch_size = 1
)
test_gen <- timeseries_generator(
closing_deltas,
closing_deltas,
length = 3,
sampling_rate = 1,
stride = 1,
start_index = 1259,
end_index = 1507,
shuffle = FALSE,
reverse = FALSE,
batch_size = 1
)
运行此代码后,你将在环境面板中看到两个张量对象。现在我们已经配置了数据生成器并使用它创建了两个序列张量,我们可以开始使用 LSTM 模型对数据进行建模了。
训练和评估模型
我们的数据已正确格式化,现在可以训练我们的模型。为了这个任务,我们使用 LSTM。这是一种特殊类型的 RNN。此类神经网络非常适合时间序列数据,因为它们能够在建模过程中考虑时间因素。
大多数神经网络被归类为前馈 网络。在这些模型架构中,信号从输入节点开始,传递到任意数量的隐藏层,直到它们到达输出节点。前馈网络之间有一些变化。多层感知机模型由所有密集的全连接层组成,而卷积神经网络则包含在到达密集层和随后的输出层之前,作用于输入数据特定部分的层。在这些类型的模型中,反向传播步骤将从成本函数中传递回导数,但这发生在整个前馈传递完成之后。循环神经网络(RNN)在一个非常重要的方面有所不同。它们不是等到整个前馈传递完成,而是将某个时间点的数据传递到前面并通过隐藏层单元的激活函数进行评估。该激活函数的输出随后会反馈到节点,并与下一个基于时间的数据元素一起计算。通过这种方式,RNN 能够利用它们刚学到的内容来指导如何处理下一个数据点。现在我们可以看到,为什么在考虑包含时间因素的数据时,这些方法表现得如此有效。
虽然 RNN 被设计成能够很好地处理时间序列数据,但它们确实存在一个重要的限制。模型中的递归元素仅考虑它前面紧接的时间段,并且在反向传播过程中,当隐藏层数量很大时,传递回来的信号可能会衰减。这两种模型特性意味着,给定节点无法使用它在远期时间范围内学到的信息,尽管这些信息可能是有用的。LSTM 模型解决了这个问题。
在 LSTM 模型中,数据进入节点有两条路径。一条与 RNN 中相同,包含给定的基于时间的数据点以及之前时间点的输出。然而,如果该向量的激活函数输出大于 0,则它会被传递到前方。在下一个节点,它会走上一条独立的路径,通向一个被称为遗忘门的激活函数。如果数据通过这个函数时值为正,那么它将与取当前状态和紧接着的过去输出作为输入的激活函数的输出相结合。通过这种方式,我们可以看到,来自过去多个时间段的数据可以继续作为输入,传递到更远处的节点。通过这种模型设计,我们可以克服传统 RNN 的局限性。让我们开始训练我们的模型吧:
- 首先,我们运行以下代码行来初始化一个 Keras 顺序模型:
model <- keras_model_sequential()
- 运行此行代码后,我们将添加 LSTM 层。在我们的 LSTM 层中,我们将选择隐藏层的单元数量,并定义输入形状,这里输入形状的一个维度是之前定义的回溯长度,另一个维度是
1。然后,我们将添加一个具有一个单元的全连接层,该单元将赋予我们的预测价格。我们通过以下代码定义我们的 LSTM 模型:
model %>%
layer_lstm(units = 4,
input_shape = c(3, 1)) %>%
layer_dense(units = 1)
- 在定义模型后,我们接下来进行
compile步骤。在这种情况下,我们将使用均方误差(mse)作为损失函数,因为这是一个回归任务,我们将使用adam作为优化器。我们通过以下代码定义compile步骤并查看我们的模型:
model %>%
compile(loss = 'mse', optimizer = 'adam')
model
运行此代码块后,您将在控制台中看到以下输出:

- 现在,我们可以开始训练模型。为了训练模型,我们使用生成的
train数据集。我们将初步运行 100 轮,每轮只取一个时间步。我们将设置 verbose 参数,打印每一轮的结果。我们使用以下代码训练我们的 LSTM 模型:
history <- model %>% fit_generator(
train_gen,
epochs = 100,
steps_per_epoch=1,
verbose=2
)
- 既然模型已经训练完成,我们可以进行预测。在
predict步骤中,我们为train和test数据选择一个给定的时间步数。之后,我们可以将这些预测结果与实际值进行比较。我们通过运行以下代码,使用 LSTM 模型预测股票价格:
testpredict <- predict_generator(model, test_gen, steps = 200)
trainpredict <- predict_generator(model, train_gen, steps = 1200)
运行前面的代码后,我们现在得到了预测结果。
- 我们的下一步是将这些与实际值一起绘制,看看模型的效果如何。这一步需要在多个格式之间转换数据。我们的第一步是将预测向量转换为
xts对象。为此,我们需要定义索引值。我们将使用trainpredict数据的4到1203索引值。之所以从 4 开始,是因为我们有三个滞后值用于预测第四个索引点的值。我们将对测试数据做同样的处理,但从1263索引点开始。我们通过运行以下代码从预测中创建xts数据对象:
trainpredict <- data.frame(pred = trainpredict)
rownames(trainpredict) <- index(closing_deltas)[4:1203]
trainpredict <- as.xts(trainpredict)
testpredict <- data.frame(pred = testpredict)
rownames(testpredict) <- index(closing_deltas)[1262:1461]
testpredict <- as.xts(testpredict)
- 现在,我们将这些
xts对象中的值添加到closing_deltas对象中。接下来,我们绘制实际值,并将预测值叠加上去。为了做到这一点,我们首先为所有的 NA 添加列,然后仅填充那些与预测对象中的索引点匹配的行。我们通过运行以下代码,向closing_delta和xts对象中添加反映train和test集预测值的额外列:
closing_deltas$trainpred <- rep(NA,1507)
closing_deltas$trainpred[4:1203] <- trainpredict$pred
closing_deltas$testpred <- rep(NA,1507)
closing_deltas$testpred[1262:1461] <- testpredict$pred
- 现在,我们将预测结果与实际数据合并在一起,可以绘制结果。我们将预测值绘制为实心的深色线条,实际值则用浅灰色的虚线表示,使用以下代码:
plot(as.zoo(closing_deltas), las=1, plot.type = "single", col = c("light gray","black","black"), lty = c(3,1,1))
运行此代码后,您将在Plots标签页中看到以下图表:

虽然预测结果略显保守,但请注意,模型在不同时间点捕捉到了细微差别。这个模型发现了更多的模式,输出的波动比我们的 ARIMA 模型更大。
- 除了绘制数据图表外,我们还可以打印调用
evaluate_generator函数计算误差率的结果。为了打印模型的误差率,我们运行以下代码:
evaluate_generator(model, test_gen, steps = 200)
evaluate_generator(model, train_gen, steps = 1200)
运行上述代码后,我们会看到以下误差率值:

控制台中打印的警告可以忽略。到目前为止,这个问题是 TensorFlow 通过 Keras 的已知问题。我们的 LSTM 模型到目前为止相对简单。接下来,让我们看看如何调整一些超参数。我们将尝试调整哪些参数,以期实现更好的性能。
调整超参数以提高性能
为了改进我们的模型,我们现在将调整超参数。调整 LSTM 模型有很多选项。我们将专注于在创建时间序列数据时调整length值。在此基础上,我们还会添加额外的层,调整层中的单元数,并修改优化器。
我们将通过以下步骤来实现:
- 为了开始,我们将把传递给
timeseries_generator函数中length参数的值从3改为10,这样我们的模型就有了更长的价格窗口来进行预测计算。为了做这个更改,我们运行以下代码:
train_gen <- timeseries_generator(
closing_deltas,
closing_deltas,
length = 10,
sampling_rate = 1,
stride = 1,
start_index = 1,
end_index = 1258,
shuffle = FALSE,
reverse = FALSE,
batch_size = 1
)
test_gen <- timeseries_generator(
closing_deltas,
closing_deltas,
length = 10,
sampling_rate = 1,
stride = 1,
start_index = 1259,
end_index = 1507,
shuffle = FALSE,
reverse = FALSE,
batch_size = 1
)
我们保持此代码与之前相同,只做了length的一个更改。
- 接下来,我们将通过添加一个额外的 LSTM 层、丢弃层和一个额外的全连接层来加深我们的 LSTM 模型。我们还将更改输入形状,以反映生成器中的下一个
length参数。最后,我们将第一层的return_sequences设置为True,这样信号就可以流向额外的层。如果没有将其设置为True,您将遇到与数据进入第二个 LSTM 层时期望和实际维度相关的错误。我们通过运行以下代码为 LSTM 模型添加额外的层:
model <- keras_model_sequential()
model %>%
layer_lstm(units = 256,input_shape = c(10, 1),return_sequences="True") %>%
layer_dropout(rate = 0.3) %>%
layer_lstm(units = 256,input_shape = c(10, 1),return_sequences="False") %>%
layer_dropout(rate = 0.3) %>%
layer_dense(units = 32, activation = "relu") %>%
layer_dense(units = 1, activation = "linear")
- 我们的最后一个修改将是对优化器进行调整。在这种情况下,我们将降低优化器的学习率。这样做是为了避免预测值出现大的波动。我们可以通过运行以下代码来调整优化器:
model %>%
compile(
optimizer = optimizer_adam(lr = 0.001),
loss = 'mse',
metrics = 'accuracy')
model
运行上述代码后,以下内容将打印到控制台:

- 接下来,我们可以像以前一样使用以下代码来训练我们的模型:
history <- model %>% fit_generator(
train_gen,
epochs = 100,
steps_per_epoch=1,
verbose=2
)
- 训练完我们的模型后,我们可以评估它的表现,并与我们的第一个模型进行对比。我们通过以下代码来评估模型并将结果打印到控制台:
evaluate_generator(model, train_gen, steps = 1200)
evaluate_generator(model, test_gen, steps = 200)
当我们运行此代码时,我们将看到以下结果:

我们的修改产生了混合的结果。虽然train数据的损失值稍微变差,但test数据的误差率有所改善。
到此为止,我们已经完成了创建 LSTM 模型所需的所有步骤,并且初步调整了参数以提高性能。创建深度学习模型往往既是一门艺术,也是一门科学,因此我们鼓励你继续进行调整,看看是否能进一步提升模型的表现。你可能想尝试除了adam之外的其他优化器,或者尝试加入额外的隐藏层。有了这些基础,你已经准备好进行更多的修改,或者将这种方法应用到不同的数据集上。
总结
在这一章中,我们首先创建了一个基准模型来预测股价。为此,我们使用了 ARIMA 模型。基于这个模型,我们探索了使用时间序列数据进行机器学习的一些重要组成部分,包括使用滞后变量值来预测当前变量值以及平稳性的重要性。从这里开始,我们使用 Keras 构建了一个深度学习解决方案,组合了 LSTM,然后进一步调优了这个模型。在这个过程中,我们观察到,与其他传统模型如 ARIMA 相比,这种深度学习方法具有一些显著的优势。在下一章中,我们将使用生成对抗网络来创建一个合成的人脸图像。
第九章:用于面部的生成对抗网络
在上一章中,我们在时间序列预测任务中使用了长短期记忆(LSTM)模型。在本章中,我们将创建一个生成器模型,这意味着该模型将不会输出预测结果,而是输出文件(在此案例中为图像)。我们在第七章,深度学习与自然语言处理中创建了生成器模型;然而,在那个案例中,我们仅生成了潜在特征。在这里,我们将描述生成对抗网络(GANs)的主要组件和应用。你将了解 GAN 的常见应用,并学习如何使用 GAN 构建面部生成模型。
在本章中,我们将探讨 GAN 的架构。GAN 由两个相互竞争的神经网络组成,其中一个被称为生成器模型。它接受随机数据并生成合成目标数据。GAN 的另一个部分是判别器模型。该模型接受两种输入——合成目标数据和真实目标数据——并判断哪一个是真实的目标数据。理解这一过程后,我们将使用 Keras 包和来自标注面孔数据集的图像来编写我们自己的面部识别和生成的 GAN 模型代码。
在本章中,我们将讨论以下主题:
-
GAN 概述
-
定义生成器模型
-
定义判别器模型
-
准备和预处理数据集
-
训练和评估模型
技术要求
你可以在以下 GitHub 链接中找到本章使用的代码文件:
github.com/PacktPublishing/Hands-on-Deep-Learning-with-R
GAN 概述
GAN 是一种将两个神经网络相互对抗的建模算法。其中一个使用随机数据生成输出,另一个评估真实目标数据与生成的输出,并判断哪一个是实际的。随着时间的推移,第一个神经网络生成更好的伪目标数据,而第二个神经网络则继续尝试判断哪个是真实的目标数据。这两个神经网络不断竞争,模型也不断改进,以生成越来越逼真的合成数据。
解析这个术语,我们可以看到这种建模技术与其他技术的区别。首先,它是生成性的,这意味着目标是生成数据。这与其他模型(如分类或回归模型)不同,后者预测概率或数值。接下来,它是对抗性的。也就是说,存在两个模型,它们彼此对抗。通常,我们有一个模型,它在可评估的数据上进行训练,并使用各种度量来改进性能。然而,在这种情况下,我们有一个模型,旨在提高预测性能,它是判别器模型。此外,我们还有另一个模型,它创建虚假图像,试图降低判别器模型的性能。
我们通常将机器学习分为两大类:
-
监督学习:模型使用标注的目标数据来进行预测
-
无监督学习:模型在没有任何标注目标数据的情况下识别模式
然而,我们可以通过无监督学习进一步细化。GAN 属于无监督学习的一个特殊子集,它使用从未标注数据中学到的模式来生成合成数据,而不仅仅是对数据进行分类。然而,这为我们带来了一个问题。由于目标是生成数据,因此没有直接的度量可以用来评估性能。GAN 模型的相对成功或失败主要取决于对输出的主观解释。
对于这个 GAN,我们将使用图像作为输入。所有图像都可以表示为灰度图像的值矩阵,或者彩色图像的三个值矩阵。这些矩阵的值范围从0到255,对应于该位置像素值的强度。例如,像素值为255表示高强度或黑色(对于灰度图像),而0表示低强度或白色。矩阵的维度对应于图像的像素宽度和高度。彩色图像表示为三维数组。这可以看作是三个矩阵重叠,每个矩阵对应于图像的红色、绿色和蓝色像素值的强度。让我们来看一张示例图像,并查看它如何表示为值的矩阵。为此,我们将读取以下形状:

要以这种形状读取,我们将使用OpenImageR包。这个包将文件读取为一个四维数组。在这种情况下,我们只需要第四维,它包含灰度值。为了读取这个文件并查看一个小片段,我们运行以下代码:
library(OpenImageR)
clock <- readImage("Alarms_&_Clock_icon.png")
clock[1:10,46:56,4]
运行此代码后,我们将看到以下内容打印到控制台:

我们可以看到这些值表示了闹钟左侧铃铛的顶部。零值是白色空间,第二行和顶行显示出梯度,呈现曲线。通过这种方式,我们可以看到图像如何作为一个介于 0 和 1 之间的值矩阵来表示。
定义生成器模型
生成器模型是创建合成目标数据的神经网络,通过随机输入生成。在这种情况下,我们将反向使用 卷积神经网络 (CNN) 。这意味着我们将从一个数据点向量开始,创建一个全连接层,然后将数据重塑为我们希望的尺寸。作为中间步骤,我们将目标形状设为原来的一半大小,然后使用转置卷积层进行上采样。最终,我们得到一个归一化的像素值数组,其形状与我们的目标数组相同。这个数组将成为用于试图欺骗判别器模型的数据对象。这个合成值的数组将随着时间的推移被训练,使其更像目标数据对象中的值,以至于判别器模型无法以高概率预测哪个是真实的数据图像。我们将使用以下步骤定义判别器模型:
-
首先,我们将定义我们的入口点是一个 100 维的向量。我们定义模型的所有操作都将使用 Keras 完成。所以,在此步骤中,我们加载 Keras 模型。接下来,我们将输入形状定义为一个包含 100 个值的向量。
-
然后,我们将传递一个向量到这个模型中。在这个步骤中,我们告诉模型我们将在后续步骤中传递的内容。使用以下代码,我们声明该模型的输入将是一个包含 100 个值的向量,稍后我们会用随机值填充它:
library(keras)
generator_in <- layer_input(shape = c(100))
- 运行此步骤后,我们可以看到在数据环境中有一个特殊的数据对象,称为
Tensor。该对象包含了层的类型、名称、形状和数据类型。你的数据环境将如下图所示:

- 在此之后,我们定义如何处理、转换和重塑我们的随机值,以创建一个与目标数组匹配的合成数组。实现这一点的代码比较长,但其中许多部分是重复的。有一些行是必需的,而其他的可以修改。
layer_dense层需要包含稍后在layer_reshape层中出现的单元数量。在这种情况下,我们将创建一个宽度和高度为25、深度为128的形状。深度是可以修改的,但在使用一个转置卷积层时,宽度和高度必须设置为最终图像尺寸的一半,具体如下:
generator_out <- generator_in %>%
layer_dense(units = 128 * 25 * 25) %>%
layer_reshape(target_shape = c(25, 25, 128))
layer_conv_2d_transpose层使用 2 x 2 的步幅进行上采样,并将层的形状加倍。在此步骤中,形状从 25 x 25 变为 50 x 50:
generator_out <- generator_in %>%
layer_dense(units = 128 * 25 * 25) %>%
layer_reshape(target_shape = c(25, 25, 128)) %>%
layer_conv_2d(filters = 512, kernel_size = 5,
padding = "same")
- 卷积层应用了寻找模式的过滤器,而归一化步骤则对卷积结果进行标准化处理。因此,均值接近 0,标准差接近 1,ReLU 作为我们的激活函数。我们将在全连接层和卷积层后添加这些层,使用以下代码:
generator_out <- generator_in %>%
layer_dense(units = 128 * 25 * 25) %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_reshape(target_shape = c(25, 25, 128)) %>%
layer_conv_2d(filters = 512, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu()
- 在此之后,我们可以继续使用相同的卷积、归一化和激活模式,添加额外的卷积层。在这里,我们将添加四组额外的层,使用我们刚才描述的模式:
generator_out <- generator_in %>%
layer_dense(units = 128 * 25 * 25) %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_reshape(target_shape = c(25, 25, 128)) %>%
layer_conv_2d(filters = 512, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d_transpose(filters = 256, kernel_size = 4,
strides = 2, padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d(filters = 128, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d(filters = 64, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu()
- 在最后一步,
filters参数需要设置为图像的通道数——在这个例子中,对于彩色图像,它是三个通道:红色、绿色和蓝色。这完成了我们生成器模型的定义。整个生成器模型使用以下代码定义:
generator_out <- generator_in %>%
layer_dense(units = 128 * 25 * 25) %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_reshape(target_shape = c(25, 25, 128)) %>%
layer_conv_2d(filters = 512, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d_transpose(filters = 256, kernel_size = 4,
strides = 2, padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d(filters = 128, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d(filters = 64, kernel_size = 5,
padding = "same") %>%
layer_batch_normalization(momentum = 0.5) %>%
layer_activation_relu() %>%
layer_conv_2d(filters = 3, kernel_size = 7,
activation = "tanh", padding = "same")
- 运行此代码后,我们将看到环境中出现两个对象。我们已经定义了输入的连接张量和输出的连接张量。以这种方式设置张量,使得数据可以通过
keras_model函数批量输入。此时,您的数据环境应该如下所示:

-
接下来,我们定义输入为 100 个随机值,输出为映射到与目标图像尺寸相同的数据对象的随机值。
-
然后我们可以定义
keras_model,它专门接收输入和输出作为参数。此时,我们传入已定义的张量层,以完成模型的定义。 -
在定义完模型后,我们可以在生成器模型上运行
summary函数,帮助我们清楚地看到每一层的数据变化。我们使用以下代码定义生成器并查看其摘要:
generator <- keras_model(generator_in, generator_out)
summary(generator)
- 运行
summary函数后,我们将看到模型的详细信息打印在控制台上,内容如下所示:

- 从控制台输出中,我们可以看到,首先是一个全连接层,经过若干中间层后,我们最终得到了一个与目标图像数据形状匹配的最后一层。
我们现在已经完全定义了我们的生成器。我们看到如何插入随机值,并且这些随机值如何转化为合成图像。将数据传递给此模型的过程稍后会进行。现在,已经有了生成假图像的系统,我们接下来定义判别器模型,判别器模型将确定给定的像素数据数组是真实图像还是假图像。
定义判别器模型
判别器模型是神经网络,用于评估合成目标数据和真实目标数据,从而确定哪个是真实的。
在这种情况下,判别器是一个 CNN 模型;它接受一个三维数组作为输入。通常,CNN 会使用卷积层和池化层来调整输入的维度——最终连接到一个全连接层。然而,在创建 GAN 时,使用这些层来定义判别器模型时,我们在卷积层中使用 2 x 2 的步幅来调整输入的维度。最终,一个包含一个单元的全连接层会通过 sigmoid 激活函数,计算给定输入为真或假的概率。让我们按照以下代码行来定义判别器模型:
- 与生成器模型一样,我们先定义输入的形状。虽然生成器模型从一个包含 100 个随机值的向量开始,但我们的判别器从形状为图像数据的输入开始,因为这将被传递给模型。使用以下代码,通过图像的维度来定义输入形状:
discriminator_in <- layer_input(shape = c(50, 50, 3))
- 运行这段代码会向我们的数据环境中添加一个对象。你的环境面板现在会显示如下截图:

- 接下来,我们处理并转换我们的数据。生成器将一维向量转换为我们图像数据大小的三维数组,而在这里我们将做相反的操作。我们从形状与图像数据相同的数据开始作为第一层,如以下代码所示:
discriminator_out <- discriminator_in %>%
layer_conv_2d(filters = 256, kernel_size = 3)
- 我们将添加到判别器的下一个层是一个激活层。对于判别器,我们将使用 Leaky ReLU 激活函数。激活层在第一个卷积层之后添加,因此我们的代码现在如下所示:
discriminator_out <- discriminator_in %>%
layer_conv_2d(filters = 256, kernel_size = 3) %>%
layer_activation_leaky_relu()
- 在我们的下一个卷积层中,我们使用
2的步幅来减半高度和宽度,同时加倍深度:
discriminator_out <- discriminator_in %>%
layer_conv_2d(filters = 256, kernel_size = 3) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2)
- 我们现在可以继续向 Leaky ReLU 激活层添加更多的层,使用相同的卷积层序列。约束条件是——如前所述——在每一层中,高度和宽度都会减半,而深度会加倍,因此高度和宽度的维度必须满足可以被减半且输出为整数,否则你将收到错误信息。在我们的例子中,我们将再添加三组层,因此我们的代码现在看起来如下所示:
discriminator_out <- discriminator_in %>%
layer_conv_2d(filters = 256, kernel_size = 3) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 3, strides = 2) %>%
layer_activation_leaky_relu()
- 现在我们需要添加一个层,将我们的值展平成一维,为最终的输出层做准备。当我们添加此层时,我们的代码将如下所示:
discriminator_out <- discriminator_in %>%
layer_conv_2d(filters = 256, kernel_size = 3) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 3, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_flatten()
- 之后,我们添加了一个
dropout层,它随机删除一些数据,这迫使模型更加努力地工作,并且减缓了训练速度,从而产生更好的泛化能力。添加此层后的代码如下所示:
discriminator_out <- discriminator_in %>%
layer_conv_2d(filters = 256, kernel_size = 3) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 3, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_flatten() %>%
layer_dropout(rate = 0.5)
- 最后,我们添加了一个只有
1个单元的dense层,表示图像是真实还是假的概率。添加此最后一层将完成我们的判别器模型。最终的判别器模型通过以下代码定义:
discriminator_out <- discriminator_in %>%
layer_conv_2d(filters = 256, kernel_size = 3) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 5, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_conv_2d(filters = 256, kernel_size = 3, strides = 2) %>%
layer_activation_leaky_relu() %>%
layer_flatten() %>%
layer_dropout(rate = 0.5) %>%
layer_dense(units = 1, activation = "sigmoid")
运行代码后,在数据环境中现在有四个已定义的张量。您的数据环境将像以下截图所示:

- 在定义输入和输出后,这两个对象作为参数传递给
keras_model函数,就像之前的生成器模型一样。我们使用来自前面步骤的输入和输出定义来定义判别器模型,然后运行summary函数查看模型的详细信息,使用以下代码:
discriminator <- keras_model(discriminator_in, discriminator_out)
summary(discriminator)
在运行上述代码后,您将在控制台中看到模型的详细信息。控制台输出将像以下截图所示:

-
为了查看我们模型的详细信息,我们可以看到输入通过卷积层时,维度发生了变化。我们从形状与我们的图像数据相同的输入开始,在每一层,两个维度被减少,而第三个维度则增加。最终,我们得到一个全连接层。我们可以看到,如果我们再添加几个卷积层,我们将达到一个无法继续将数据减半而仍能保留完整单元的程度。
-
在这一步中,我们还将定义优化器,优化器决定了模型如何通过反向传递数据来改进模型的未来迭代。我们将使用
binary_crossentropy来计算性能,然后使用adam优化器将数据从误差率梯度反馈到模型中。我们使用以下代码定义如何评估并逐步改进我们的判别器模型:
discriminator_optimizer <- optimizer_adam(
lr = 0.0008
)
discriminator %>% compile(
optimizer = discriminator_optimizer,
loss = "binary_crossentropy"
)
现在我们已经定义了生成器模型和判别器模型。这是我们 GAN 的两个主要构建块。在下一步中,我们将加载真实图像,并向您展示如何将图像转换为数字数据。这是我们组装所有内容并开始训练 GAN,生成合成图像之前需要的第三个也是最后一个步骤。
准备和预处理数据集
本章中,我们将使用标注的“野外面部”数据集中一小部分的图像。具体来说,我们将使用前美国总统乔治·W·布什的图像,因为这是数据集中最常出现的图像对象。使用以下代码,我们将导入图像数据,并将其转换为可以输入到模型中的格式。我们首先加载所需的库和数据文件。
加载库和数据文件
我们将通过以下步骤开始:
- 首先,我们加载所有需要使用的库。我们将从这些库中使用每个库的一个函数,但每个库都需要加载才能使我们的数据格式正确。
jpeg库将用于读取图像数据并将其存储为矩阵。purrr包将用于对我们的数组列表应用函数。abind包将用于将数组列表转换为一个数组。最后,OpenImageR将用于调整我们的数据大小。我们通过以下代码加载所有必要的库,以便导入图像并将其转换为正确的格式:
library(jpeg)
library(purrr)
library(abind)
library(OpenImageR)
-
加载完库之后,接下来的步骤是导入所有图像文件。这个过程的第一步是将工作目录更改为包含所有图像文件的文件夹,以方便操作。
-
一旦你进入到这个文件夹,使用
list.files函数获取所有文件名的向量。最后,我们使用purrr包中的map()函数对向量中的每个元素执行函数,并将结果传递给列表。 -
在这种情况下,我们向量中的每个元素都是一个文件路径。我们将每个文件路径作为参数传递给
readJPEG函数,该函数来自jpeg包。这个函数会为每个图像返回一个数组,所有的像素值都表示为介于0和1之间的归一化值。这非常方便,因为这是我们希望神经网络使用的格式。如前所述,像素值通常以0到255之间的整数存储;然而,将值限定在0和1之间,在将数据传递给神经网络时效果更好。我们导入图像,将所有的像素值转换为介于0和1之间的归一化值,并使用以下代码将所有格式化后的图像数据存储在一个数组列表中:
setwd('data/faces')
filename_vector = list.files(pattern="*.jpg")
image_list <- purrr::map(filename_vector, jpeg::readJPEG)
- 运行代码后,我们现在已经在数据环境中拥有了数组列表。如果我们展开该对象,我们可以看到该数据集中图像的像素值示例。展开数据对象后,它将在你的环境中呈现如下截图:

调整图像大小
我们将通过以下步骤来调整图像大小:
- 这一步是为了加速模型执行时间,特意在本书中完成的。在实际应用中,这一步可能不必要或不理想。然而,了解如何调整图像大小在任何情况下都很有帮助。我们可以通过再次使用
purrr包中的map函数和OpenImageR包中的resizeImage函数来调整每个图像的大小。在这种情况下,map会从image_list对象中获取每个元素,并将其作为参数传递给resizeImage函数。因此,每个数组的尺寸将从 250 x 250 变为 50 x 50。我们通过运行以下代码来调整每个图像的大小:
image_list <- purrr::map(image_list, ~OpenImageR::resizeImage(., width=50, height=50, method = "nearest"))
- 运行此代码后,我们可以看到图像的维度发生了变化。如果
image_list仍然在数据环境窗格中展开,那么它现在将显示如下截图:

合并数组
现在调整大小的工作完成了,我们将开始合并数组:
- 在调整图像大小后,最后一步是将数据转换为正确的格式。目前,数据存储在一个数组列表中;然而,我们需要将所有数据合并为一个四维数组。以下代码将所有三维数组沿着一个新的第四维度进行合并。我们可以将所有三维数组合并为一个四维数组,然后使用以下代码查看维度:
image_array <- abind::abind( image_list, along = 0)
dim(image_array)
- 此代码将在控制台打印有关图像维度的详细信息,我们现在可以看到四维数组的新形状以及第四维度如何对应于我们拥有的图像对象数量。您将看到以下内容打印到控制台:

- 数据现在已经是正确格式,我们只需要执行最后两步清理工作。我们将移除数组列表和文件路径名称向量,因为这些数据已经不再需要,并将工作目录重置回项目的根目录:
rm(image_list,filename_vector)
setwd('../..')
- 运行此代码后,我们得到了开始组装 GAN 模型所需的所有对象。您的数据环境将显示如下截图:

数据现在已经导入到环境中并转换为正确格式,我们现在准备将所有内容结合起来创建 GAN 模型。我们刚刚加载的数据将与判别器模型一起使用,生成器模型创建的数组也将被使用。我们现在将编写代码,将数据、生成器和判别器结合起来创建 GAN 模型。
训练与评估模型
现在数据已经是正确格式,并且我们已经定义了判别器和生成器,我们可以将所有部分结合起来训练我们的 GAN。最终的 GAN 模型将从目标图像数据集中获取输入,输出是输入真实图像数据和伪造图像数据到判别器后,判断这张图片是否为真实图像的概率。我们通过运行以下部分来训练我们的 GAN 模型。
定义 GAN 模型
我们定义 GAN 模型的代码如下:
- 我们将执行的第一步是对判别器模型调用
freeze_weights函数。这是为了确保判别器模型的权重在训练过程中不会更新。我们希望更新生成器的权重,而不是判别器的权重:
freeze_weights(discriminator)
- 下一步是定义
keras_model的输入和输出,正如我们在生成器和鉴别器中所做的那样。在这种情况下,keras_model将是我们的最终 GAN 模型。请注意,输入将包含 100 个值,这与我们的生成器相同,因为输入到 GAN 模型的数据将通过生成器模型,然后继续传递给鉴别器模型,最后生成我们的模型输出。我们使用以下代码来定义 GAN 模型:
gan_in <- layer_input(shape = c(100))
gan_out <- discriminator(generator(gan_in))
gan <- keras_model(gan_in, gan_out)
- 运行此代码后,我们现在在数据环境中拥有以下对象。我们可以看到关于不同张量层的所有细节,这些细节展示了数据在整个 GAN 模型管道中的流动路径。你的环境面板将如下所示:

- 与鉴别器模型类似,我们需要定义编译步骤。我们以与鉴别器相同的方式设置它。使用
binary_crossentropy损失函数计算误差,并使用adam优化器来迭代改进模型。定义最终 GAN 模型的编译方式通过以下代码完成:
gan_optimizer <- optimizer_adam(
lr = 0.0004
)
gan %>% compile(
optimizer = gan_optimizer,
loss = "binary_crossentropy"
)
将数据传递给 GAN 模型
现在,我们将数据传递给模型,具体如下:
- 这样,我们的模型就准备好了,可以开始传递数据以生成合成图像。为了存储这些数据对象,我们需要在项目文件夹中创建一个目录。我们使用以下代码创建一个目录来存放我们的真实图像和假图像:
image_directory <- "gan_images"
dir.create(image_directory)
- 运行此代码后,你将在主项目文件夹中看到一个名为
gan_images的新文件夹。
训练 GAN 模型
现在我们已经准备好了模型,是时候使用以下步骤对其进行训练:
-
训练我们的 GAN 模型是一个迭代过程,我们需要创建一个循环来选择并创建图像数组,然后将它们传递给我们的 GAN 模型,GAN 模型将计算每个图像数组属于目标类别的概率。然而,如果我们从这里开始一个循环,那么每行代码的效果直到整个代码执行完毕后才会看到。为此,我们将首先遍历循环中的每一行代码,然后展示完整的
for循环代码。 -
在进入循环之前,我们声明一个将在循环内使用的变量。以下代码为
first_row变量设置了一个值。稍后,当我们对数组进行子集操作时,将使用这个变量。我们从四维数组中的第一个三维数组开始。当以下代码在循环中运行时,first_row的值将在每次迭代时变化,以确保将不同的真实图像子集传递给鉴别器模型。我们通过运行以下代码设置第一次迭代时first_row的值:
first_row <- 1
生成随机图像
训练完成后,我们将使用模型生成随机图像,具体如下:
- 下一步是创建一个随机变量矩阵。随机变量的数量应设置为批量大小乘以我们生成器模型的输入形状的大小。然后设置维度,使得行数等于批量大小,列数等于这里定义的输入形状的长度。在这种情况下,
20用作批量大小,100用作传递给生成器模型的向量的长度。这两个值都是可修改的。增加其中一个或两个值会为模型提供更多的数据,可能会提高性能,但也会增加运行时间。我们通过以下代码从正态分布中创建我们的随机值矩阵:
random_value_matrix <- matrix(rnorm(20 * 100),
nrow = 20, ncol = 100)
- 运行代码后,将创建一个矩阵,其中包含从正态(高斯)分布中选择的值。矩阵中的每一行将用于生成一张图像。这些图像是通过使用随机值创建的。通过在数据环境中选择对象,我们可以查看它。选择数据对象后,你将看到类似以下的内容:

- 接下来,使用我们的随机值矩阵,我们将生成假图像。这些假图像是通过我们之前定义的生成器模型创建的。模型将随机值作为输入,输出是一个四维数组。数组的第一维对应批量大小,在本例中为
20,其他三维对应于我们图像数据的维度。生成合成数据后,我们将抽取一些值,以展示数组已经创建并填充了随机值。我们通过运行以下代码创建数组并查看其中的一部分:
fake_images <- generator %>% predict(random_value_matrix)
fake_images[1,1:5,1:5,1]
- 运行前面的代码后,我们看到我们创建的数组的一小部分。由于第一维的值为
1,第四维的值为1,因此我们知道这些值将用于表示第一张图像的红色强度。前面的代码会将值打印到控制台。你将看到类似以下的内容打印到控制台:

- 之前,我们将
first_row值设置为指示我们希望每次迭代开始时的行子集的位置。接下来,我们需要定义最后一行,它等于第一行的值加上比批量大小少一个的值。在这种情况下,批量大小是20,因此我们使用19。另外,虽然first_row的值从1开始,但它会在每次迭代中动态变化。通过运行以下代码,我们设置了用于子集数据的最后一行值:
last_row <- first_row + 19
选择真实图像
现在,我们选择真实图像,如下所示:
- 接下来,我们使用
first_row和last_row的值来创建一个包含真实目标图像的数组子集。我们还将运行两行代码来从数据对象中移除属性。这不是绝对必要的,有时候你可能希望保留这里存储的数据。然而,为了演示目的,我们现在将其移除,这样我们就能在数据环境窗口中看到所有数组的维度。使用以下代码行创建一个等于批次大小的真实图像数组,该数组将在模型的一个迭代中使用:
real_images <- image_array[first_row:last_row,,,]
attr(real_images, "dimnames") <- NULL
attr(image_array, "dimnames") <- NULL
- 运行这些代码行后,我们现在可以看到
real_images和fake_images是相同大小的数组。在您的环境窗格中展开这两个数据对象,您会看到您的环境现在看起来如下所示:

结合真实和假图像
在区分了真实和假图像后,我们将使用以下步骤将它们合并:
- 在下一步中,我们创建一个包含所有
0值的数组,其形状为真实图像堆叠在假图像上。也就是说,第一维等于批次大小的两倍,这里是40,其余三个维度等于我们图像数组的大小。我们通过运行以下代码创建这个零值占位符数组:
combined_images <- array(0, dim = c(nrow(real_images) * 2, 50,50,3))
- 运行这段代码后,我们将在环境窗口中看到一个新对象,并且可以看到它的第一维是其他两个我们创建的数组大小的两倍。此时,您的环境窗口将如下所示:

- 接下来,我们将填充我们的占位符数组。对于数组的上半部分,我们赋值为我们生成的假图像,而对于下半部分,我们赋值为真实图像的值。我们使用以下代码将假图像和真实图像数据填充到数组中:
combined_images[1:nrow(real_images),,,] <- fake_images
combined_images[(nrow(real_images)+1):(nrow(real_images)*2),,,] <- real_images
- 运行这段代码后,我们会看到即使对于环境窗口中小样本数据,
combined_images的值也已经从之前的零值变化为我们fake_images数组中的随机值。此时,您的环境窗口将如下所示:

创建目标标签
现在,我们使用以下步骤为所有图像创建目标标签:
- 我们需要创建一个标签矩阵;这只是一个二进制值矩阵。我们添加
20行值为1的行来标记假图像,并添加20行值为0的行来标记真实图像。我们使用以下代码创建标签数据矩阵:
labels <- rbind(matrix(1, nrow = 20, ncol = 1),
matrix(0, nrow = 20, ncol = 1))
- 运行此代码后,让我们点击
labels对象进行查看。我们可以看到,它确实包含了 20 行值为1的行和 20 行值为0的行。以下是您在查看此矩阵时将看到的图像:

- 下一步是为标签添加一些噪声。就像之前使用 dropout 层时一样,我们希望在建模过程中引入一些噪声和随机性,迫使判别器进行更多的泛化,以避免过拟合。我们通过应用一个常数值到从均匀分布中选择的随机值数组(该数组与我们的标签对象长度相同),然后将其加到标签矩阵中的当前值来添加噪声。我们使用以下代码向标签中添加噪声:
labels <- labels + (0.1 * array(runif(prod(dim(labels))),
dim = dim(labels)))
- 完成上述操作后,我们可以查看
labels对象中的相同行子集,发现其值现在已略有修改。labels对象中的值将类似于以下截图所示:

将输入传递给判别器模型
现在,我们将通过以下步骤将输入传递给判别器模型:
- 接下来,我们将混合了真实和伪造图像的组合图像传递给判别器模型,标签作为模型的目标变量。我们使用以下代码将独立变量和因变量传递给判别器模型的输入层和输出层:
d_loss <- discriminator %>% train_on_batch(combined_images, labels)
d_loss
- 运行此代码的结果是判别器的误差率。我们只需运行对象名称,就可以将该值打印到控制台。运行上述代码后,您的控制台将显示如下截图,尽管值可能略有不同:

- 我们的下一步是创建另一个随机矩阵,作为我们 GAN 模型的输入。它会传递给生成器,再传递给判别器,按照我们在 GAN 模型定义中的设定进行处理。我们使用以下代码为 GAN 模型创建输入:
random_value_matrix <- matrix(rnorm(20 * 100),
nrow = 20, ncol = 100)
- 在此之后,我们创建一个大小与批量相同的数组。该数组被设置为所有数据对象为真:
fake_target_array <- array(0, dim = c(20, 1))
- 我们将这个随机变量矩阵和数组传递给 GAN 模型:
a_loss <- gan %>% train_on_batch(
random_value_matrix,
fake_target_array
)
a_loss
- 运行此代码的结果是计算 GAN 的误差率。如果许多真实的目标图像被正确识别,那么生成器将做出更大的变化;如果生成器创建的图像被选为真实图像,则在未来的迭代中会做出较少的变化。如果我们运行仅包含对象名称的行,则会在控制台打印输出。你的控制台将类似于以下截图:

更新行选择器
接下来,我们将通过以下步骤更新行选择器:
- 我们的下一步是重置
first_row值,以便在后续迭代中获取不同的real_image数据子集。我们使用以下代码重置first_row值:
first_row <- first_row + 20
if (first_row > (nrow(image_array) - 20))
first_row <- sample(1:10,1)
first_row
- 运行此代码后,
first_row的值将设置为前一个值加上批次大小20,或者如果该数值会导致子集超出范围,则first_row的值会设置为 1 到 10 之间的随机值。在这种情况下,值将设置为21。你将看到控制台输出,如下截图所示:

评估模型
最后,我们将通过以下步骤评估模型:
- 最后一步是定期打印模型诊断信息,并展示真实图像和生成图像进行对比,以跟踪合成图像是否按预期生成。我们打印模型的迭代次数和误差率,并使用以下代码将一张真实图像和一张伪造图像保存在我们之前创建的目录中:
if (i %% 100 == 0) {
cat("step:", i, "\n")
cat("discriminator loss:", d_loss, "\n")
cat("adversarial loss:", a_loss, "\n")
image_array_save(
fake_images[1,,,] * 255,
path = file.path(image_directory, paste0("fake_gwb", i, ".png"))
)
image_array_save(
real_images[1,,,] * 255,
path = file.path(image_directory, paste0("real_gwb", i, ".png"))
)
}
- 运行代码后,我们可以看到模型诊断信息打印在控制台中。你的控制台将显示如下截图:

- 此外,我们可以在之前创建的文件夹中看到生成的图像和真实图像。任何图像要像我们真实图像集中的面孔一样逼真,需要经历大量的轮次或周期。然而,即使在早期的轮次中,我们也能看到生成对抗网络(GAN)开始识别特征。以下是我们数据集中一张原始照片:

- 这是一张生成的图像:

这张早期的合成图像已经捕捉到了一些特征。
为了方便起见,用于迭代训练模型的整个for循环已包含在 GitHub 存储库中。
我们的 GAN 模型已经完成。你可以继续进行多次调整,看看它如何影响生成的合成图像。在整个模型管道创建过程中,我们记录了需要存在的值,以使模型正常工作。然而,许多部分是可以修改的。所有的修改都会导致不同的生成图像。如前所述,GAN 没有一个成功的标准。最终的效果将完全取决于最终用户对生成数据的解读。增加生成器或判别器的层数,以及调整滤波器大小、层参数和学习率,都是继续探索开发这种深度学习模型的不错选择。
总结
在这一章,我们创建了一个模型,可以将面部图像作为输入并生成面部图像作为输出。我们使用了“野外标注面孔”数据集中的图像。使用 GAN 模型,我们先生成一个随机值图像,然后采样得到一个实际图像。为了生成图像,我们将随机值重塑为与数据集中图像相同的尺寸。接着,我们将由随机值组成的图像与实际图像一起输入到一个模型中,该模型将数据重塑为一个简单的概率得分,表示图像是“真实”还是“伪造”的可能性。通过多次迭代,生成器被训练得能够生成更容易被判别模型分类为“真实”的图像。
在下一章,我们将学习另一种无监督深度学习技术——强化学习。它与生成对抗网络(GANs)类似,都是通过一个代理执行任务,并不断从失败中学习,直到能够成功完成任务。在下一章中,我们将深入探讨强化学习的细节。
第三部分:强化学习
本节将展示强化学习和深度强化学习。读者将学习强化学习与监督学习和无监督学习算法的不同之处。读者将在多个场景中使用这种机器学习技术,解决那些离散动作导致正向结果概率增加或减少的问题。
本节包含以下章节:
-
第十章,游戏中的强化学习
-
第十一章,深度 Q 学习用于迷宫求解
第十章:用于游戏的强化学习
在本章中,我们将学习强化学习。顾名思义,通过这种方法,最佳策略是通过强化或奖励某些行为并惩罚其他行为来发现的。这种类型的机器学习的基本思想是使用一个代理,在环境中执行朝着目标的动作。我们将通过使用 R 中的ReinforcementLearning包来计算代理的策略,从而帮助它在井字游戏中获胜,来探索这种机器学习技术。
尽管这看起来像是一个简单的游戏,但它是一个非常适合研究强化学习的环境。我们将学习如何为强化学习构建输入数据结构,这对于井字游戏和更复杂的游戏来说是相同的格式。我们将学习如何利用输入数据计算策略,以为代理提供环境的最佳策略。我们还将了解这种类型的机器学习中可用的超参数,以及调整这些值的效果。
在本章中,我们将完成以下任务:
-
理解强化学习的概念
-
准备和预处理数据
-
配置强化学习代理
-
调整超参数
技术要求
你可以在 GitHub 链接 github.com/PacktPublishing/Hands-on-Deep-Learning-with-R 上找到本章的代码文件。
理解强化学习的概念
强化学习是机器学习三大类别中的最后一个。我们已经学习过监督学习和无监督学习。强化学习是第三大类别,并在许多方面与其他两种类型有所不同。强化学习既不依赖标记数据进行训练,也不会为数据添加标签。相反,它旨在找到一个最优解,使得代理能获得最高的奖励。
环境是代理完成任务的空间。在我们的案例中,环境将是用于玩井字游戏的 3 x 3 网格。代理在环境内执行任务。在这种情况下,代理在网格上放置 X 或 O。环境还包含奖励和惩罚——也就是说,代理需要因某些行为而获得奖励,而因其他行为而受到惩罚。在井字游戏中,如果一方将标记(X 或 O)放在三格连续的空间内,不论是水平、垂直还是对角线,那么该玩家获胜,反之,另一方玩家失败。这就是该游戏的简单奖励和惩罚结构。策略是决定代理在给定一系列先前行为的情况下应采取哪些行动,以最大概率成功的策略。
为了确定最优策略,我们将使用 Q-learning。Q-learning 中的 Q 代表质量。它涉及开发一个质量矩阵来确定最佳的行动路线。这包括使用贝尔曼方程。方程的内部计算奖励值,加上未来动作的折扣最大值,再减去当前的质量评分。这个计算值会乘以学习率并加到当前的质量评分上。稍后,我们将看到如何使用 R 编写这个方程。
在本章中,我们使用 Q-learning;然而,还有其他执行强化学习的方法。另一个流行的算法叫做演员-评论家,它与 Q-learning 在许多方面有显著的不同。以下段落对这两者进行比较,以更好地展示它们在追求同一类型的机器学习时采取的不同方法。
Q-learning 计算一个价值函数,因此它需要一个有限的动作集合,例如井字棋。演员-评论家则适用于连续环境,并力求优化策略,而不像 Q-learning 那样使用价值函数。相反,演员-评论家有两个模型,其中一个是演员,执行动作,而另一个是评论家,计算价值函数。这一过程会针对每个动作进行,并在多个迭代中,演员学习到最佳的动作集合。虽然 Q-learning 适用于解决像井字棋这样的游戏,这类游戏有有限的空间和动作集合,但演员-评论家适用于不受约束或动态变化的环境。
在本节中,我们简要回顾了执行强化学习的不同方法。接下来,我们将开始在我们的井字棋数据上实现 Q-learning。
数据准备和处理
对于我们的第一个任务,我们将使用 ReinforcementLearning 包中的井字棋数据集。在这种情况下,数据集已经为我们构建好了;然而,我们将调查它是如何构建的,以理解如何将数据转换为适合强化学习的正确格式:
- 首先,让我们加载井字棋数据。要加载数据集,我们首先加载
ReinforcementLearning库,然后调用data函数,并将"tictactoe"作为参数传入。我们通过运行以下代码来加载数据:
library(ReinforcementLearning)
data("tictactoe")
运行这些代码后,你将在数据环境面板中看到数据对象。它当前的类型是 <Promise>;然而,我们将在下一步中将其更改,以查看此对象包含的内容。现在,你的环境面板将显示如下截图:

- 现在,让我们看一下前几行,评估数据集的内容。我们将使用
head函数将前几行打印到控制台,这还会将我们环境面板中的对象从<Promise>转换为我们可以互动并探索的对象。我们使用以下代码将前五行打印到控制台:
head(tictactoe, 5)
运行代码后,您的控制台将显示如下内容:

此外,环境窗格中的对象现在将显示如下内容:

当我们查看这些图像时,我们可以看到数据是如何设置的。为了进行强化学习,我们需要将数据设置为一种格式,其中一列是当前状态,另一列是动作,然后是随后的状态,最后是奖励。让我们以第一行作为例子,详细解释这些值的含义。
State是"........."。这些点表示 3x3 棋盘上的空格,所以这个字符串代表一个空的井字游戏棋盘。Action是"c7",意味着扮演 X 的代理将在第七个位置放置一个 X,即左下角。NextState是"......X.B",这意味着在这个场景中,对于这一行,对手已经在右下角放置了一个 O。Reward是0,因为游戏尚未结束,0的奖励值表示游戏将继续的中立状态。像这样的行将存在于每个可能的State、Action、NextState和Reward的组合中**。
- 仅使用前五行,我们可以看到所有可能的动作都是非终结的,也就是说,游戏在执行这些动作后会继续。现在让我们看一下导致游戏结束的动作:
tictactoe %>%
dplyr::filter(Reward == 1) %>%
head()
tictactoe %>%
dplyr::filter(Reward == -1) %>%
head()
运行前面的代码后,我们将在控制台看到以下几行,表示导致胜利的动作:

我们还会看到这些行被打印到控制台,表示导致失败的动作:

让我们看一下子集中的第一行,该行导致了胜利。在这种情况下,代理已经在游戏板的右上角和中心放置了一个 X。这里,代理将在左下角放置一个 X,这样就形成了一个斜线连续的三个 X,这意味着代理赢得了游戏,我们可以在Reward列中看到这一点。
- 接下来,我们来看一个给定的状态,并查看所有可能的动作:
tictactoe %>%
dplyr::filter(State == 'XB..X.XBB') %>%
dplyr::distinct()
通过这种方式对数据进行子集选择,我们从一个给定的状态开始,并查看所有可能的选项。您的控制台输出将如下所示:

在这种情况下,游戏板上只剩下三个空位。我们可以看到两种动作会导致代理胜利。如果代理选择游戏板上的另一个空位,那么游戏板上还剩下两个空位,我们可以看到,无论对手选择哪个,游戏都会继续。
从这次调查中,我们可以看到如何为强化学习准备数据集。尽管这个数据集是由别人为我们准备的,但我们可以看到如何自己制作一个。如果我们想以不同的方式编码我们的井字棋棋盘,我们可以使用游戏《数字拼图》的值。《数字拼图》与井字棋同构,但它涉及选择数字而不是在网格上放置标记;然而,数字值与网格完美匹配,因此可以互换。游戏《数字拼图》涉及两位玩家在 1 到 15 之间选择数字,每个数字只能选择一次,获胜者是第一个选择出使数字之和为 15 的数字的人。考虑到这一点,我们可以像这样重写我们查看的第一行:
State <- '0,0'
Action <- '4'
NextState <- '4,8'
Reward <- 0
numberscramble <- tibble::tibble(
State = State,
Action = Action,
NextState = NextState,
Reward = Reward
)
numberscramble
运行后,我们将在控制台看到以下输出:

从中,我们可以看到State、Action和NextState的值可以以我们喜欢的任何方式进行编码,只要使用一致的约定,以便强化学习过程可以从一个状态遍历到另一个状态,从而发现通向奖励的最优路径。
现在我们知道如何设置数据,接下来我们来看一下我们的智能体将如何找到最佳的奖励路径。
配置强化学习智能体
让我们详细了解如何使用 Q 学习配置一个强化学习智能体。Q 学习的目标是创建一个状态-动作矩阵,其中为所有状态-动作组合分配一个值——也就是说,如果我们的智能体处于某个状态,那么提供的值将决定智能体采取的行动,以获取最大值。我们将通过创建一个值矩阵来启用智能体最佳策略的计算,该矩阵为每一个可能的移动提供一个计算值:
- 首先,我们需要一组所有值为 0 的状态和动作对。作为最佳实践,我们将在这里使用哈希,这是一种比大型列表更高效的替代方案,可以扩展到更复杂的环境。首先,我们将加载哈希库,然后使用
for循环填充哈希环境。for循环首先从数据中获取每个唯一状态,对于每个唯一状态,它将附加每个唯一动作,创建所有可能的状态-动作对,并为所有对分配一个值 0。通过运行以下代码,我们生成了这个哈希环境,它将在 Q 学习阶段保存计算出的值:
library(hash)
Q <- hash()
for (i in unique(tictactoe$State)[!unique(tictactoe$State) %in% names(Q)]) {
Q[[i]] <- hash(unique(tictactoe$Action), rep(0, length(unique(tictactoe$Action))))
}
运行代码后,我们会看到环境面板现在看起来像下图所示:

我们有一个哈希环境,Q,它包含每个状态-动作对。
- 下一步是定义超参数。现在,我们将使用默认值;然而,我们很快就会调整这些值,以查看它们的影响。通过运行以下代码,我们将超参数设置为其默认值:
control = list(
alpha = 0.1,
gamma = 0.1,
epsilon = 0.1
)
运行代码后,我们现在可以看到环境面板中显示了我们的超参数值列表,面板现在看起来如下:

- 接下来,我们开始填充我们的 Q 矩阵。这同样是在一个
for循环内进行的;不过,我们将仅查看其中的一次独立迭代。我们首先通过以下代码将一行数据的元素提取到离散的数据对象中:
d <- tictactoe[1, ]
state <- d$State
action <- d$Action
reward <- d$Reward
nextState <- d$NextState
运行代码后,我们可以看到 环境 面板中的变化,其中现在包含了第一行中的离散元素。环境面板将如下所示:

- 接着,我们获取当前 Q 学习分数的值(如果有的话)。如果没有值,那么就将
0存储为当前值。我们通过运行以下代码来设置这个初始的质量分数:
currentQ <- Q[[state]][[action]]
if (has.key(nextState,Q)) {
maxNextQ <- max(values(Q[[nextState]]))
} else {
maxNextQ <- 0
}
运行此代码后,我们现在得到 currentQ 的值,在这种情况下它是 0,因为状态 '......X.B' 的所有 Q 值都为 0,因为我们已将所有值设置为 0;然而,在下一步中,我们将开始更新 Q 值。
- 最后,我们通过使用 Bellman 方程更新 Q 值。这也叫做 时序差分 学习。我们通过以下代码写出这一计算 R 值的步骤:
## Bellman equation
Q[[state]][[action]] <- currentQ + control$alpha *
(reward + control$gamma * maxNextQ - currentQ)
q_value <- Q[[tictactoe$State[1]]][[tictactoe$Action[1]]]
运行以下代码后,我们可以提取这个状态-动作对的更新值;我们可以在标记为 q_value 的字段中看到它。您的 环境 面板将如下所示:

我们在这里注意到 q_value 仍然是 0。为什么会是这样呢?如果我们看一下我们的公式,我们会发现奖励是公式的一部分,而我们的奖励是 0,这使得整个计算值为 0。因此,直到我们的代码遇到具有非零奖励的行时,才会开始看到更新后的 Q 值。
- 我们现在可以将所有这些步骤结合起来,针对每一行运行它们,来创建我们的 Q 矩阵。通过运行以下代码,我们创建了一个包含所有值的矩阵,这些值将用于选择最佳策略的决策:
for (i in 1:nrow(tictactoe) {
d <- tictactoe[i, ]
state <- d$State
action <- d$Action
reward <- d$Reward
nextState <- d$NextState
currentQ <- Q[[state]][[action]]
if (has.key(nextState,Q)) {
maxNextQ <- max(values(Q[[nextState]]))
} else {
maxNextQ <- 0
}
## Bellman equation
Q[[state]][[action]] <- currentQ + control$alpha *
(reward + control$gamma * maxNextQ - currentQ)
}
Q[[tictactoe$State[234543]]][[tictactoe$Action[234543]]]
在循环遍历所有行后,我们看到某些状态-动作对现在已经有了 Q 矩阵中的值。运行以下代码时,我们将在控制台上看到以下输出:

到目前为止,我们已经为 Q-learning 创建了矩阵。在这种情况下,我们将值存储在一个哈希环境中,每个键值对都有对应的值;然而,这等同于将值存储在矩阵中——只不过这种方式在之后的扩展中更为高效。现在我们有了这些值,我们可以为智能体计算一个策略,以提供通向奖励的最佳路径;然而,在我们计算这个策略之前,我们将做最后一组修改,那就是将之前设置的超参数调整回默认值。
调整超参数
我们已经定义了环境,并遍历了从任何给定状态下可能采取的所有行动及其结果,以计算每一步的质量值,并将这些值存储在我们的 Q 对象中。到此为止,我们现在可以开始调整这个模型的选项,看看这些调整如何影响性能。
如果我们回顾一下,强化学习有三个参数,它们分别是 alpha、gamma 和 epsilon。下面的列表描述了每个参数的作用以及调整其值的影响:
-
Alpha:强化学习中的 alpha 值与许多其他机器学习模型的学习率相同。它是用于控制在基于智能体采取某些行动探索奖励时,更新概率的速度的常量值。
-
Gamma:调整 gamma 值可以改变模型对未来奖励的重视程度。当 gamma 设置为
1时,当前和未来的所有奖励被赋予相同的权重。这意味着距离当前步骤几步之遥的奖励与下一步获得的奖励具有相同的价值。实际上,这几乎从来不是我们想要的效果,因为我们希望未来的奖励更有价值,因为获得它们需要更多的努力。相比之下,设置 gamma 为0意味着只有来自下一步行动的奖励才会有任何价值,未来的奖励完全没有价值。同样,除了特殊情况外,这通常不是我们想要的效果。在调整 gamma 时,你需要在未来奖励的加权平衡中找到一种平衡,使得智能体能够做出最优的行动选择。 -
Epsilon:epsilon 参数用于在选择未来行动时引入随机性。将 epsilon 设置为
0被称为贪婪学习。在这种情况下,智能体将始终选择成功概率最高的路径;然而,正如其他机器学习方法一样,智能体很容易陷入某些局部最小值,永远无法发现最优策略。通过引入一些随机性,在不同的迭代中将会采取不同的行动。调整此值有助于优化探索与利用之间的平衡。我们希望模型能够利用已学到的知识,选择最佳的未来行动;但我们也希望模型继续探索并不断学习。
利用我们对这些超参数的理解,接下来我们来看一下在调整这些参数时,值是如何变化的:
- 首先,我们将调整
alpha的值。如前所述,alpha值是学习率值,这是我们在学习其他机器学习主题时可能熟悉的内容。它只是一个常数值,用来控制算法调整的速度。目前,我们将alpha的值设置为0.1;然而,我们将alpha的值设为0.5。这个值高于我们通常实际希望的值,主要是为了探索更改这些值的影响。我们将需要将 Q 值重置为零并重新启动学习过程,以查看发生了什么。以下代码块将我们之前所做的一切再次执行,只不过对alpha进行了调整。我们调整alpha值并通过运行以下代码来查看其效果:
Q <- hash()
for (i in unique(tictactoe$State)[!unique(tictactoe$State) %in% names(Q)]) {
Q[[i]] <- hash(unique(tictactoe$Action), rep(0, length(unique(tictactoe$Action))))
}
control = list(
alpha = 0.5,
gamma = 0.1,
epsilon = 0.1
)
for (i in 1:nrow(tictactoe)) {
d <- tictactoe[i, ]
state <- d$State
action <- d$Action
reward <- d$Reward
nextState <- d$NextState
currentQ <- Q[[state]][[action]]
if (has.key(nextState,Q)) {
maxNextQ <- max(values(Q[[nextState]]))
} else {
maxNextQ <- 0
}
## Bellman equation
Q[[state]][[action]] <- currentQ + control$alpha *
(reward + control$gamma * maxNextQ - currentQ)
}
Q[[tictactoe$State[234543]]][[tictactoe$Action[234543]]]
从这个调整中,我们可以看到在234543处,Q 值发生了变化。你将在控制台上看到以下输出:

正如我们所预期的那样,由于我们提高了alpha的值,因此结果是,在我们之前查看的相同点上,Q 值变得更大。换句话说,我们的算法学习得更快,质量值获得了更大的权重。
- 接下来,我们调整
gamma的值。如果我们记得的话,调整gamma的值会改变代理对未来奖励的重视程度。我们当前的值设置为0.1,这意味着未来的奖励是有价值的,但它们被重视的程度相对较小。让我们将其提高到0.9,看看会发生什么。我们进行与调整alpha时相同的操作。首先重置 Q 哈希环境,使所有状态–动作对的值为0,然后通过循环遍历所有选项,应用贝尔曼方程,并对gamma值进行更改来重新填充该哈希环境。我们通过运行以下代码来评估更改gamma值时会发生什么:
library(hash)
Q <- hash()
for (i in unique(tictactoe$State)[!unique(tictactoe$State) %in% names(Q)]) {
Q[[i]] <- hash(unique(tictactoe$Action), rep(0, length(unique(tictactoe$Action))))
}
control = list(
alpha = 0.1,
gamma = 0.9,
epsilon = 0.1
)
for (i in 1:nrow(tictactoe)) {
d <- tictactoe[i, ]
state <- d$State
action <- d$Action
reward <- d$Reward
nextState <- d$NextState
currentQ <- Q[[state]][[action]]
if (has.key(nextState,Q)) {
maxNextQ <- max(values(Q[[nextState]]))
} else {
maxNextQ <- 0
}
## Bellman equation
Q[[state]][[action]] <- currentQ + control$alpha *
(reward + control$gamma * maxNextQ - currentQ)
}
Q[[tictactoe$State[234543]]][[tictactoe$Action[234543]]]
运行完这段代码后,你将在控制台看到以下代码输出:

从这点出发,我们可以得出以下观察结论:
-
我们可以看到我们的值显著增加了
-
从这个状态并采取这个动作,会有一定的价值,但在考虑未来奖励时,价值会更大
-
然而,对于像井字游戏这样的游戏,我们需要考虑到,从任何状态到奖励之间的步骤通常很少;然而,我们可以看到,从这个状态和这个动作出发,获得奖励的概率会很高
- 对于最后的调整,我们将调整
epsilon。epsilon的值取决于相对于探索知识的程度,我们使用多少以前的知识。为了这个调整,我们将回到使用ReinforcementLearning包中的函数,因为它不仅依赖于通过贝尔曼方程循环计算,还需要在多次迭代中存储这些值以供参考。要调整epsilon,我们使用以下代码:
# Define control object
control <- list(
alpha = 0.1,
gamma = 0.1,
epsilon = 0.9
)
# Perform reinforcement learning
model <- ReinforcementLearning(data = tictactoe,
s = "State",
a = "Action",
r = "Reward",
s_new = "NextState",
iter = 5,
control = control)
model$Q_hash[[tictactoe$State[234543]]][[tictactoe$Action[234543]]]
运行此代码后,我们会看到我们的 Q 值发生了变化。你将看到以下值打印到控制台:

从这里我们可以得出以下观察结论:
-
我们的数值类似于使用默认参数值时的数值,但稍微大一些
-
在这种情况下,我们引入了相对较大的随机性,以强迫我们的智能体继续探索;结果,我们可以看到,在这种随机性下,我们的价值损失并不大,而且即使它导致了不同的后续行动集,这个动作仍然保持相似的价值
- 在将参数调整到理想设置之后,我们现在可以查看给定状态的策略。首先,让我们看看Environment面板中的模型对象。你的Environment面板将如下所示:

让我们更深入地了解模型对象中的每个元素:
-
Q_hash:哈希环境,就像我们之前创建的那样,包含每个状态–动作对及其 Q 值 -
Q:一个命名矩阵,包含与哈希环境相同的数据,只是以命名矩阵的形式呈现 -
States:我们矩阵中的命名行 -
Actions:我们矩阵中的命名列 -
Policy:一个命名向量,包含智能体应该从任何状态采取的最优动作 -
Reward和RewardSequence:这些是数据集中导致奖励的行数,少于导致惩罚的行数
我们可以使用这里的数值来查看在任何给定状态下所有动作的价值,并判断哪个是最好的行动。让我们从一个全新的游戏开始,看看哪个动作的价值最高。我们可以从这个状态看到每个动作的价值,并通过运行以下代码来标记哪个动作是最好的:
sort(model$Q['.........',1:9], decreasing = TRUE)
model$Policy['.........']
运行此代码后,我们将看到以下内容打印到控制台:

我们可以看到,第一行列出了所有可能的动作以及它们按降序排列的各自价值。我们可以看到,在这个命名向量中,动作"c5",即网格中心的标记,具有最高的价值。因此,当我们查看智能体处于该状态时的策略时,我们看到它是"c5"。通过这种方式,我们现在可以利用强化学习的结果,从任何给定状态中选择最优的行动:
-
我们刚刚调整了所有参数,以便注意这些变量变化的影响
-
然后,在最后一步中,我们看到如何根据网格处于任何状态来选择最佳策略
-
通过尝试每种可能的行动组合,我们根据即时和未来的奖励计算了移动的价值
-
我们决定通过调整参数来权衡 Q 值,并决定一种解决游戏的方法
概要
在本章中,我们使用 Q-learning 编码了一个强化学习系统。我们定义了我们的环境或游戏表面,然后查看了包含每种可能状态、动作和未来状态的数据集。使用数据集,我们计算了每个状态-动作对的价值,将其存储在哈希环境和矩阵中。然后,我们将这个值矩阵作为我们策略的基础,选择具有最大价值的移动。
在我们的下一章中,我们将通过将神经网络添加到 Q-learning 中来扩展深度 Q-learning 网络。
第十一章:深度 Q 学习在迷宫求解中的应用
在本章中,你将学习如何使用 R 来实现强化学习技术,并将其应用于迷宫环境。特别地,我们将创建一个代理,通过训练代理执行动作并从失败中学习,来解决迷宫。我们将学习如何定义迷宫环境,并配置代理使其能够穿越迷宫。我们还将把神经网络添加到 Q 学习中,这为我们提供了获取所有状态-动作对值的替代方法。我们将多次迭代我们的模型,以创建一个策略,帮助代理走出迷宫。
本章将涵盖以下主题:
-
为强化学习创建环境
-
定义一个代理来执行动作
-
构建深度 Q 学习模型
-
运行实验
-
使用策略函数提高性能
技术要求
你可以在 github.com/PacktPublishing/Hands-on-Deep-Learning-with-R 上找到本章使用的代码文件。
为强化学习创建环境
在这一节中,我们将定义一个强化学习的环境。我们可以把它想象成一个典型的迷宫,其中代理需要在二维网格空间中导航到达终点。然而,在这种情况下,我们将使用一个基于物理的迷宫。我们将使用山地车问题来表示这一点。代理处在一个山谷中,需要到达山顶;但是,它不能直接爬坡。它必须利用动量到达山顶。为此,我们需要两个函数。一个函数将启动或重置代理,将其放置在表面上的一个随机点。另一个函数将描述代理在一步之后在表面上的位置。
我们将使用以下代码来定义 reset 函数,为代理提供一个起始位置:
reset = function(self) {
position = runif(1, -0.6, -0.4)
velocity = 0
state = matrix(c(position, velocity), ncol = 2)
state
}
我们可以看到,使用这个函数时,首先发生的事情是通过从 -0.6 到 -0.4 之间的均匀分布中随机选择一个值来定义 position 变量。这是代理将被放置在表面上的点。接下来,velocity 变量被设置为 0,因为我们的代理尚未移动。reset 函数仅用于将代理放置在起始点。position 变量和 velocity 变量现在被加入到一个 1 x 2 的矩阵中,这个 matrix 变量就是我们代理的起始位置和起始速度。
下一个函数获取每个动作的值,并计算代理将采取的下一步。为了编写这个函数,我们使用以下代码:
step = function(self, action) {
position = self$state[1]
velocity = self$state[2]
velocity = (action - 1L) * 0.001 + cos(3 * position) * (-0.0025)
velocity = min(max(velocity, -0.07), 0.07)
position = position + velocity
if (position < -1.2) {
position = -1.2
velocity = 0
}
state = matrix(c(position, velocity), ncol = 2)
reward = -1
if (position >= 0.5) {
done = TRUE
reward = 0
} else {
done = FALSE
}
list(state, reward, done)
}
在这个函数中,第一部分定义了位置和速度。在这个案例中,位置和速度是从self对象中获取的,接下来我们将介绍self。self变量包含有关代理的详细信息。这里,position和velocity变量是从self中获取的,表示代理当前在表面上的位置以及当前的速度。然后,action参数用于计算速度。接下来的行将velocity限制在-0.7到0.7之间。之后,我们通过将速度加到当前位置来计算下一个位置。然后,还有一行约束代码。如果position超过-1.2,代理就会超出边界,并且会重置到-1.2位置,且速度为零。最后,会进行检查,看代理是否已达到目标。如果状态值大于0.5,代理就算获胜;否则,代理继续移动并尝试达到目标。
当我们完成这两个代码块时,我们将看到在环境面板中定义了两个函数。你的环境面板将如以下截图所示:

这两个函数的组合定义了表面的形状、代理在表面上的位置以及目标位置在表面上的设置。reset函数用于代理的初始位置,step函数定义了每次迭代时代理的步伐。通过这两个函数,我们定义了环境的形状和边界,并且为将代理放置和移动到环境中提供了机制。接下来,我们来定义我们的代理。
定义一个代理来执行动作
在本节中,我们将定义用于深度 Q 学习的代理。我们已经看到前面环境函数如何定义代理的移动方式。在这里,我们定义代理本身。在上一章中,我们使用了 Q 学习,并且能够将贝尔曼方程应用到由特定动作产生的新状态。在本章中,我们将通过神经网络增强 Q 学习的这一部分,这就是将标准 Q 学习转化为深度 Q 学习的关键。
为了将这个神经网络模型添加到过程中,我们需要定义一个类。这在面向对象编程中是常见的;然而,在像 R 这样的编程语言中则不太常见。为此,我们将使用R6包来创建类。我们将把R6类的创建分解为多个部分,以便更容易理解。类提供了实例化和操作数据对象的指令。在这种情况下,我们的类将使用声明的变量来实例化数据对象,并且一系列的函数(在类的上下文中称为方法)将用于操作数据对象。在接下来的步骤中,我们将逐一查看类的各个部分,以便更容易理解我们正在创建的类的构成部分。然而,运行代码的部分会导致错误。在详细讲解所有部分之后,我们将把所有内容包装在一个函数中来创建我们的类,并且这是你将要运行的最终、较长的R6代码。首先,我们将使用以下代码来设置初始值:
portable = FALSE,
lock_objects = FALSE,
public = list(
state_size = NULL,
action_size = NA,
initialize = function(state_size, action_size) {
self$state_size = state_size
self$action_size = action_size
self$memory = deque()
self$gamma = 0.95
self$epsilon = 1.0
self$epsilon_min = 0.01
self$epsilon_decay = 0.995
self$learning_rate = 0.001
self$model = self$build_model()
}
)
在使用 R 创建一个用于此目的的类时,我们首先设置两个选项。首先,我们将portable设置为FALSE,这意味着其他类不能继承此类的方法或函数。但这也意味着我们可以使用self关键字。其次,我们将lock_objects设置为FALSE,因为我们需要在此类中修改对象。
接下来,我们定义我们的初始值。我们在这里使用self,它是一个特殊的关键字,指代所创建的对象。记住,类不是对象——它是创建对象的构造器。在这里,我们将创建一个类的实例,这个对象将作为代理。该代理将使用以下值进行初始化。状态大小和动作大小将在创建环境时作为参数传入。下一个记忆是一个空的 deque。deque是一种特殊的对象类型,它是双端的,因此可以从两端添加或移除值。我们将使用它来存储代理在试图达成目标时所采取的步骤。Gamma 是折扣率。Epsilon 是探索率。正如我们所知道的,深度学习的目标是平衡探索与利用,因此我们从一个激进的探索率开始。然而,我们随后定义了一个 epsilon 衰减,这是探索率将被减少的程度,以及一个 epsilon 最小值,以确保探索率永远不会达到0。最后,学习率就是在调整权重时使用的常数值,而模型则是运行我们的神经网络模型的结果,我们将在接下来的部分介绍。
接下来,我们将通过添加一个函数来赋予类操作变量的能力。特别地,我们将添加build_model函数来运行神经网络:
build_model = function(...){
model = keras_model_sequential() %>%
layer_dense(units = 24, activation = "relu", input_shape = self$state_size) %>%
layer_dense(units = 24, activation = "relu") %>%
layer_dense(units = self$action_size, activation = "linear")
compile(model, loss = "mse", optimizer = optimizer_adam(lr = self$learning_rate), metrics = "accuracy")
return(model)
}
模型将当前状态作为输入,输出将是我们在预测模型时可用的动作之一。然而,这个函数只是返回模型,因为我们将在调用时根据我们在深度 Q 学习路径中的位置,传递一个不同的状态参数给模型。模型在两种不同的场景下被调用,我们稍后会详细讲解。
接下来,我们将加入一个用于记忆的函数。这个类的记忆部分将是一个存储状态、动作、奖励和下一状态的函数,随着智能体尝试解决迷宫,我们将这些值存储在智能体的记忆中,并通过以下代码将它们添加到双端队列(deque)中:
memorize = function(state, action, reward, next_state, done){
pushback(self$memory,state)
pushback(self$memory,action)
pushback(self$memory,reward)
pushback(self$memory, next_state)
pushback(self$memory, done)
}
我们使用pushback函数将给定的值添加到双端队列的第一个位置,并将所有现有元素向后移动一个位置。我们对状态、动作、奖励、下一状态以及显示迷宫是否已完成的标志进行此操作。这些序列存储在智能体的记忆中,因此当探索与利用公式选择利用选项时,它可以通过访问记忆中的这个序列来利用已知的内容,而不是继续进行探索。
接下来,我们将添加一些代码来选择下一个动作。为完成此任务,我们将检查衰减的 epsilon 值。根据衰减的 epsilon 是否大于从均匀分布中随机选择的值,将发生两种可能的动作之一。我们通过以下代码设置了一个决定下一个动作的函数:
act = function(state){
if (runif(1) <= self$epsilon){
return(sample(self$action_size, 1))
} else {
act_values <- predict(self$model, state)
return(which(act_values==max(act_values)))
}
}
如前所述,这个动作函数有两种可能的结果。首先,如果从均匀分布中随机选择的值小于或等于 epsilon,那么将从活动动作的全部范围中选择一个值。否则,当前状态将用于使用我们先前定义的模型预测下一个动作,这会导致加权概率来判断这些动作中哪个是正确的。选择具有最高概率的动作。这是探索与利用之间的平衡,旨在寻找正确的下一步。
在之前讨论过探索步骤后,我们现在将编写我们的replay()函数,该函数将利用智能体已经知道并存储在记忆中的内容。我们使用以下代码来编写这个利用函数:
replay = function(batch_size){
minibatch = sample(length(self$memory), batch_size)
state = minibatch[1]
action = minibatch[2]
target = minibatch[3]
next_state = minibatch[4]
done = minibatch[5]
if (done == FALSE){
target = (target + self$gamma *
max(predict(self$model, next_state)))
target_f = predict(self$model, state)
target_f[0][action] = target
self$model(state, target_f, epochs=1, verbose=0)
}
if (self$epsilon > self$epsilon_min){
self$epsilon = self$epsilon * self$epsilon_decay
}
}
让我们拆解这个函数,以利用智能体已经知道的内容来帮助解决难题。我们首先做的是从记忆双端队列(deque)中选择一个随机序列。接着,我们将从样本中提取的每个元素放入智能体序列中的特定部分:状态、动作、目标、下一状态和done标志,后者用来指示迷宫是否已解决。接下来,我们将添加一些代码来改变模型的预测方式。我们从使用序列结果中的状态来定义目标,利用我们从尝试此序列中学到的内容。
接下来,我们预测状态以获得模型可能预测的所有值。然后,我们将计算出的值插入到该向量中。当我们再次运行模型时,我们帮助模型基于经验进行训练。这也是更新 epsilon 的步骤,这将导致在未来的迭代中更多的开发(exploitation)和更少的探索(exploration)。
最后一步是添加一个保存和加载我们模型的方法或函数。我们通过以下代码为保存和加载模型提供手段:
load = function(name) {
self$model %>% load_model_tf(name)
},
save = function(name) {
self$model %>% save_model_tf(name)
}
通过这些方法,我们现在能够保存之前定义的模型,并加载训练好的模型。
接下来,我们需要将我们所覆盖的所有内容放入一个总体函数中,该函数将接收所有声明的变量和函数,并用它们来创建一个 R6 类。为了创建我们的 R6 类,我们将把刚才写的所有代码整合在一起。
运行完整代码后,我们将会在环境中得到一个 R6 类。它将有一个 Environment 的类。如果你点击它,你可以看到类的所有属性。你会注意到有许多与创建类相关的属性,我们并没有特别定义;然而,看看以下截图。我们可以看到这个类不可移植,我们可以看到将要赋值的公共字段,还能看到我们定义的所有函数,这些函数在作为类的一部分时被称为方法:

通过这一步,我们已经完全创建了一个 R6 类,作为我们的代理。我们为它提供了基于当前状态和一定随机性的行动手段,并且还为我们的代理提供了一种方式来探索这个迷宫的表面,以找到目标位置。我们还提供了一种方式,让代理回顾它从过去经验中学到的内容,并用这些来指导未来的决策。总的来说,我们拥有了一个完整的强化学习代理,它通过试错学习,最重要的是,从过去的错误中学习,并不断地通过随机行动来学习。
构建深度 Q 学习模型
在这一点上,我们已经定义了环境和我们的代理,这将使得运行我们的模型变得相当简单。记住,为了使用 R 进行强化学习,我们采用了面向对象编程中的一种技巧,而这在像 R 这样的编程语言中并不常用。我们创建了一个描述对象的类,但它本身并不是一个对象。为了从类中创建对象,我们必须实例化它。我们设置了初始值,并通过以下代码使用我们的 DQNAgent 类实例化一个对象:
state_size = 2
action_size = 20
agent = DQNAgent(state_size, action_size)
运行这段代码后,我们将在环境中看到一个代理对象。该代理的类为 Environment;然而,如果我们点击它,我们将看到类似下面的截图,其中包含与我们类的一些差异:

运行这一行代码后,我们现在拥有了一个继承了类中所有属性的对象。我们将 2 作为参数传入状态大小,因为对于这个环境,状态是二维的。这两个维度是位置和速度。我们可以看到我们传入的值在 state_size 字段旁边被反映出来。我们将 20 作为参数传入动作大小,因为对于这个游戏,我们将允许代理使用最多 20 单位的力量向前或向后推进。我们也能看到这个值。同样,我们可以看到所有的方法;不过它们不再嵌套在不同的方法下——它们现在都由 agent 对象继承。
为了创建我们的环境,我们使用 reinforcelearn 包中的 makeEnvironment 函数,该函数允许自定义环境创建。我们使用以下代码将 step 和 reset 函数作为参数传递,以创建代理导航的自定义环境:
env = makeEnvironment(step = step, reset = reset)
在运行前面的代码行后,你会在 Environment 面板中看到一个 env 对象。请注意,这个对象也具有 Environment 类。当我们点击这个对象时,我们会看到以下内容:

前面的代码行使用了我们之前创建的函数来定义一个环境。现在我们拥有了一个环境实例,它包含了初始化游戏的方式,而 step 函数定义了代理每次行动时可能的动作范围。请注意,这也是一个 R6 类,就像我们的代理类一样。
最后,我们加入了两个额外的初始值。通过运行以下代码,我们建立了剩余的初始值,以完成我们的模型设置:
done = FALSE
batch_size = 32
FALSE 作为 done 的第一个值表示目标尚未完成。32 的批量大小是代理在开始利用已知信息进行下一系列动作之前,将尝试进行的探索动作或系列动作的大小。
这是深度 Q 学习的完整模型设置。我们有一个代理实例,这是一个根据我们之前在类中定义的特征创建的对象。我们还有一个定义了我们在创建 step 和 reset 函数时设置的参数的环境。最后,我们定义了一些初始值,现在,一切都已经完成。下一步就是让代理开始行动,我们将在接下来完成这一过程。
运行实验
强化学习的最后一步是运行实验。为此,我们需要将代理放入环境中,然后允许代理采取步骤,直到达到目标。代理受到可用移动次数的限制,环境也施加了另一个约束——在我们的例子中,就是通过设置边界来实现。我们设置了一个for循环,循环通过代理尝试合法移动的回合,然后查看迷宫是否已成功完成。当代理到达目标时,循环停止。为了开始我们的实验,使用我们定义的代理和环境,我们编写了以下代码:
state = reset(env)
for (j in 1:5000) {
action = agent$act(state)
nrd = step(env,action)
next_state = unlist(nrd[1])
reward = as.integer(nrd[2])
done = as.logical(nrd[3])
next_state = matrix(c(next_state[1],next_state[2]), ncol = 2)
reward = dplyr::if_else(done == TRUE, reward, as.integer(-10))
agent$memorize(state, action, reward, next_state, done)
state = next_state
env$state = next_state
if (done == TRUE) {
cat(sprintf("score: %d, e: %.2f",j,agent$epsilon))
break
}
if (length(agent$memory) > batch_size) {
agent$replay(batch_size)
}
if (j %% 10 == 0) {
cat(sprintf("try: %d, state: %f,%f ",j,state[1],state[2]))
}
}
前面的代码运行的是设置代理运动的实验。代理的行为受我们定义的类中的值和函数的控制,并且进一步由我们创建的环境推动。如我们所见,运行实验时会进行很多步骤。我们将在这里回顾每个步骤:
- 在运行前面的代码的第一行后,我们将看到代理的初始状态。如果查看
state对象,它将类似于这样,其中位置值介于-0.4和-0.6之间,速度为0:

- 在运行剩余的代码块后,我们将看到类似以下内容的输出打印到控制台,其中显示了每十轮的状态:

-
当我们运行这段代码时,首先发生的事情是环境被重置,代理被放置在表面上。
-
然后,启动循环。每一轮中的活动顺序如下:
-
首先,使用
agent类中的act函数来执行一个动作。记住,这个函数定义了代理允许的移动。 -
接下来,我们将代理采取的动作传递给
step函数,以获取结果。 -
输出是下一个状态,这是代理在执行动作后所到达的位置,以及基于该动作是否带来了正面结果的奖励,最后是
done标志,表示目标是否已经成功到达。 -
这三个元素作为
list对象从函数中输出。 -
接下来的步骤是将它们分配到各自的对象中。对于
reward和done,我们仅从列表中提取它们,并将它们分别分配为整数和逻辑数据类型。至于下一个状态,这稍微复杂一些。我们首先使用unlist提取两个值,然后将它们放入一个 2 x 1 的矩阵中。 -
在将代理移动的所有元素转移到它们自己的对象后,奖励被计算出来。在我们的例子中,除非达到目标,否则没有中间成就会导致奖励,因此
reward和done的操作方式相似。这里,我们看到,如果done标志被设置为TRUE,则当reward为TRUE时,reward被设置为0,如step函数中所定义的那样。 -
接下来,所有从
step函数输出的值都将添加到memory队列对象中。memorize函数将每个值推送到队列的第一个元素位置,并将现有值推回。 -
在此之后,
state对象被赋值为下一个状态。这是因为下一个状态现在成为了新的当前状态,因为智能体采取了一个新的步骤。 -
然后会检查智能体是否已经到达迷宫的终点。如果是,则跳出循环并打印出 epsilon 值,以查看通过探索完成了多少任务,探索和利用各占多少。对于其他回合,会有一个二次检查,打印每十步的当前状态和速度。
-
另一个条件是
replay函数的触发条件。在达到阈值后,智能体从记忆队列中提取值,过程从那里继续。
-
这是执行强化学习实验的整个过程。通过这个过程,我们现在拥有了一种比单纯使用 Q 学习更为强大的强化学习方法。虽然在环境有限且已知时,使用 Q 学习是一个不错的解决方案,但当环境扩展或动态变化时,则需要深度 Q 学习。通过让定义好的智能体在定义好的环境中采取行动并进行迭代,我们可以看到该智能体在解决环境中提出的问题时的表现如何。
使用策略函数提升性能
我们已经成功地编写了一个智能体,使用神经网络深度学习架构来解决问题。现在让我们看一下可以改进模型的几种方法。与其他机器学习不同,我们无法像通常那样通过性能指标进行评估,通常我们会尝试最小化某个选择的错误率。强化学习的成功略带主观性。你可能希望智能体尽可能快地完成任务,尽可能多地获取积分,或者尽可能少地犯错。此外,根据任务的不同,我们可能能够改变智能体本身,看看它对结果有何影响。
我们将讨论三种可能的提高性能的方法:
-
动作大小:有时这是一个选项,有时则不是。如果你正在尝试解决一个智能体规则和环境规则在外部设置的问题,例如尝试在棋类游戏中优化性能,那么这将不是一个选项。然而,你可以想象一个问题,例如设置一个自动驾驶汽车,在这种情况下,如果在该环境下效果更好,你可以改变智能体。通过我们的实验,尝试将动作大小值从
20更改为10,并且再更改为40,看看会发生什么。 -
批量大小:我们还可以调整批量大小,以观察它对性能的影响。请记住,当代理的移动次数达到批次的阈值时,代理就会从记忆中选择值,开始利用已有的知识。通过提高或降低这个阈值,我们为代理提供了一种策略,即在使用已有知识之前,应该进行更多或更少的探索。将批量大小更改为
16、64和128,观察哪个选项能让代理最快完成任务。 -
神经网络:我们将讨论的最后一个需要修改的代理策略部分是神经网络。在很多方面,它是代理的“大脑”。通过修改,我们可以让代理做出更多有利于优化性能的选择。在
AgentDQN类中,添加一些神经网络层,然后重新运行实验,看看会发生什么。接着,改变每一层的单元数量,并再次运行实验,查看结果。
除了这些变化外,我们还可以对起始的 epsilon 值、epsilon 衰减的速度以及神经网络模型的学习率进行调整。这些变化都会影响代理的策略函数。当我们更改一个值,这个值会改变动作或回放函数的输出时,我们就在修改代理用来解决问题的策略。我们可以为代理制定策略,让它探索更多或更少的动作,或者调整它在探索环境与利用当前知识之间所花费的时间,还可以调整代理从每一步中学习的速度,代理可能尝试多少次相似的动作以查看是否总是错误的,以及在采取导致失败的动作后,代理尝试调整的幅度。
与任何类型的机器学习一样,强化学习中有许多参数可以调节以优化性能。与其他问题不同,可能没有标准的度量来帮助调整这些参数,选择最合适的值可能更为主观,并且依赖于试验和错误的实验过程。
摘要
在这一章节中,我们编写了代码,使用深度 Q 学习进行强化学习。我们注意到,尽管 Q 学习是一种更简单的方法,但它需要一个有限且已知的环境。而应用深度 Q 学习则使我们能够在更大范围内解决问题。我们还定义了我们的智能体,这需要创建一个类。该类定义了我们的智能体,并通过实例化一个对象,将类中定义的属性应用于解决强化学习的挑战。接着,我们创建了一个自定义环境,使用函数定义了边界,以及智能体可以采取的移动范围和目标或目的。深度 Q 学习涉及在选择动作时加入神经网络,而不是像 Q 学习那样依赖 Q 矩阵。随后,我们将神经网络添加到我们的智能体类中。
最后,我们通过将智能体对象放入自定义环境,并让它采取各种行动,直到解决问题,从而将所有内容整合在一起。我们进一步讨论了一些可以采取的选择,以提高智能体的表现。有了这个框架,你已经准备好将强化学习应用于各种环境,并使用各种可能的智能体。这个过程基本上保持一致,变化将体现在智能体的编程方式以及环境中的规则。
这就完成了Hands-On Deep Learning with R。在本书中,你学习了多种深度学习方法。此外,我们还将这些方法应用于多种不同的任务。本书的编写偏向于实际操作。其目标是提供简洁的代码,解决实际项目中的问题。希望通过本书的学习,你已经准备好利用深度学习解决现实世界中的挑战。


浙公网安备 33010602011771号