Python-深度学习-全-
Python 深度学习(全)
原文:
zh.annas-archive.org/md5/98cfb0b9095f1cf64732abfaa40d7b3a译者:飞龙
序言
随着全球对人工智能的兴趣不断增长,深度学习引起了广泛的关注。每天,深度学习算法被广泛应用于不同行业。本书将为您提供关于主题的所有实际信息,包括最佳实践,使用真实用例。您将学会识别和提取信息,以提高预测精度并优化结果。
书籍首先快速回顾了重要的机器学习概念,然后直接深入探讨使用 scikit-learn 的深度学习原则。随后,您将学习使用最新的开源库,如 Theano、Keras、Google 的 TensorFlow 和 H2O。使用本指南来揭示模式识别的困难之处,以更高精度扩展数据,并讨论深度学习算法和技术。无论您是想深入了解深度学习,还是想探索如何更充分地利用这一强大技术,您都可以在本书中找到答案。
本书内容
第一章, 机器学习 - 简介,介绍了不同的机器学习方法和技术,以及它们在现实问题中的一些应用。我们将介绍 Python 中用于机器学习的一个主要开源软件包,即 scikit-learn。
第二章, 神经网络,正式介绍了神经网络是什么。我们将深入描述神经元的工作原理,并展示如何堆叠多层来创建和使用深度前馈神经网络。
第三章, 深度学习基础,将带您了解深度学习是什么,以及它与深度神经网络的关系。
第四章, 无监督特征学习,涵盖了两种最强大且常用的无监督特征学习架构:自编码器和受限玻尔兹曼机。
第五章, 图像识别,从类比我们视觉皮层的工作方式开始,并介绍卷积层,随后描述了它们为什么有效的直观认识。
第六章, 循环神经网络和语言模型,讨论了一些非常有前景的强大方法,在许多任务中表现出很高的潜力,比如语言建模和语音识别。
第七章, 棋盘游戏的深度学习,介绍了用于解决跳棋和国际象棋等棋盘游戏的不同工具。
第八章, 计算机游戏的深度学习,研究了训练 AI 玩计算机游戏所面临的更复杂的问题。
第九章, 异常检测,从解释异常值检测和异常检测概念之间的差异和相似之处开始。您将通过一个想象中的欺诈案例研究,以及展示在现实世界应用程序中存在异常的危险以及自动化和快速检测系统的重要性的示例来指导您。
第十章, 构建一个生产就绪的入侵检测系统,利用 H2O 和常用做法构建一个可部署到生产环境中的可扩展分布式系统。您将学习如何使用 Spark 和 MapReduce 来训练深度学习网络,如何使用自适应学习技术实现更快的收敛速度,并且非常重要的是如何验证模型和评估端到端流程。
您需要为本书准备什么
您将能够使用以下任何操作系统:Windows,Linux 和 Macintosh。
要顺利阅读本书,您需要以下内容:
-
TensorFlow
-
Theano
-
Keras
-
Matplotlib
-
H2O 。
-
scikit-learn
这本书是为谁准备的
本书适用于数据科学实践者和拥有基本机器学习概念和一些 Python 编程经验的有志者。还希望具备数学背景,对微积分和统计概念有概念上的理解。
约定
在本书中,您将找到一系列区分不同类型信息的文本样式。以下是一些示例和解释它们的含义。
文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:上述用于绘图的代码应立即清晰,我们只需要注意导入cm这一行。
代码块设置如下:
(X_train, Y_train), (X_test, Y_test) = cifar10.load_data()
X_train = X_train.reshape(50000, 3072)
X_test = X_test.reshape(10000, 3072)
input_size = 3072
当我们想要引起您对代码块的特定部分的注意时,相关行或项将以粗体设置:
def monte_carlo_tree_search_uct(board_state, side, number_of_rollouts):
state_results = collections.defaultdict(float)
state_samples = collections.defaultdict(float)
任何命令行输入或输出都是这样写的:
git clone https://github.com/fchollet/keras.git
cd keras
python setup.py install
新术语和重要单词以粗体显示。
注意
警告或重要说明以这样的框显示。
提示
提示和技巧显示如下。
第一章:机器学习——简介
“机器学习(CS229)是斯坦福最受欢迎的课程”——这是由劳拉·汉密尔顿在《福布斯》上的一篇文章的开头,然后她继续说“为什么?因为越来越多地,机器学习正在改变世界”。
确实,机器学习技术正在被应用于各种领域,并且数据科学家在许多不同的行业中备受追捧。有了机器学习,我们可以确定从数据中并不容易发现的知识,以便能够做出决策。机器学习技术的应用可能差异巨大,并且适用于医学、金融和广告等多种学科领域。
在本章中,我们将介绍不同的机器学习方法和技术,以及它们在实际问题中的一些应用,并且我们将介绍 Python 中一种主要的开源机器学习软件包scikit-learn。这将为随后的章节打下基础,我们将专注于一种特定类型的机器学习方法,使用神经网络来模拟大脑功能,特别是深度学习。深度学习利用比 80 年代使用的更先进的神经网络,不仅得益于理论的最新发展,还得益于计算机速度的提高以及使用GPU(图形处理单元)而不是更传统的CPU(计算处理单元)。本章主要是对机器学习是什么以及能做什么的一个总结,并准备读者更好地了解深度学习如何与流行的传统机器学习技术有所不同。
特别是,本章我们将涵盖:
-
什么是机器学习?
-
不同的机器学习方法
-
机器学习系统涉及的步骤
-
流行的技术/算法的简要描述
-
现实生活中的应用
-
一个流行的开源软件包
什么是机器学习?
机器学习经常和术语“大数据”和“人工智能”,或者简称为 A.I.一起提到,但它与两者都大不相同。要理解机器学习是什么,以及它为何有用,了解大数据是什么以及机器学习如何应用于其中是很重要的。大数据是一个用来描述通过摄像头、传感器或互联网社交网站等方式产生的大规模数据集的术语。据估计,仅谷歌每天处理超过 20PB 的信息,而且这个数字还将继续增加。IBM 估计(www-01.ibm.com/software/data/bigdata/what-is-big-data.html)每天都会产生 25 亿 GB 的数据,而且世界上 90%的数据是在过去两年内创建的。
显然,人类自己无法理解,更别说分析如此庞大的数据量了,而且机器学习技术被用来理解这些非常庞大的数据集。机器学习是用于大规模数据处理的工具,非常适用于具有大量变量和特征的复杂数据集。许多机器学习技术,特别是深度学习,其优势之一是在处理大量数据集时表现最好,从而提高了其分析和预测能力。换句话说,机器学习技术,尤其是深度学习神经网络,在可以访问大量数据集时“学习”最好,以发现数据中隐藏的模式和规律。
另一方面,机器学习的预测能力可以很好地适应人工智能系统。机器学习可以被认为是人工智能系统的“大脑”。人工智能可以被定义为(尽管这个定义可能不是独一无二的)一个能够与环境进行交互的系统:人工智能机器被赋予传感器,使它们可以了解所处的环境,以及可以进行关联的工具。因此,机器学习是允许机器分析经过传感器摄入的数据并制定适当答案的大脑。一个简单的例子是 iPhone 上的 Siri。Siri 通过麦克风听到命令,并通过扬声器或显示屏输出答案,但为了这样做,它需要“理解”所说的话来制定正确的答案。同样,无人驾驶汽车将配备摄像头、GPS 系统、声纳和激光雷达,但所有这些信息都需要被处理,以便提供正确的答案,即加速,刹车,转弯等。导致答案的信息处理代表了机器学习的内容。
不同的机器学习方法
机器学习这个术语,正如我们所看到的,被以非常普遍的方式使用,它指的是从大型数据集中外推模式的一般技术,或者是基于分析已知数据进行学习,并根据所学的内容对新数据进行预测的能力。这是一个非常普遍和广泛的定义,它囊括了许多不同的技术。机器学习技术可以大致分为两类:监督学习和无监督学习,尽管通常还会添加一类,称为强化学习。
监督学习
机器算法的第一类被称为监督学习。 监督学习算法是一类使用一组已标记数据以对相似未标记数据进行分类的机器学习算法。 已标记数据是已经被分类的数据,而未标记数据是还没有被分类的数据。 正如我们将看到的那样,标签可以是离散的或连续的。 为了更好地理解这个概念,让我们举个例子。
假设一个用户每天收到大量电子邮件,其中一些是重要的商务邮件,一些是垃圾邮件或垃圾邮件。 一个监督式机器算法将拿到用户已标记为垃圾邮件或非垃邮件的大量邮件。 该算法将运行在所有已标记的数据上,并预测这些邮件是否属于垃圾邮件。 这意味着算法将检查每个示例,并为每个示例预测这封邮件是否是垃圾邮件。 通常情况下,算法首次运行未标记的数据时,会错误标记许多邮件,并且表现可能相当糟糕。 然而,每次运行之后,算法将比较其预测与期望结果(标记)。 这样一来,算法将学会提高其性能和准确性。 如上所述,这种方法将受益于大量数据,以便更好地学习每封邮件被分类为垃圾邮件或非垃圾邮件的特征(或特征)。
算法在标记数据上运行一段时间后(通常也称为训练数据),并且在其准确性停止改善后,它可以用于新的未标记数据来测试其在新的电子邮件上的准确性。
在我们所使用的例子中,我们描述了一个从已标记数据中学习的算法过程(已被分类为垃圾邮件或非垃圾邮件的邮件),以便对新的未分类邮件进行预测。 然而,重要的是要注意,我们可以将这个过程泛化到不仅仅是两个类别:例如,我们可以运行软件并在一组已标记的电子邮件上对其进行训练,其中标签称为个人,商务/工作,社交或垃圾邮件。
事实上,谷歌提供的免费电子邮件服务 Gmail 允许用户选择最多五种类别,分别标注为:
-
主要,包括人与人之间的对话
-
社交,包括社交网络和媒体分享网站的消息
-
促销,包括市场营销邮件、优惠和折扣
-
更新,包括账单、银行对账单和收据
-
论坛,包括在线群体和邮件列表的消息
在某些情况下,结果可能并不一定是离散的,我们可能没有一个有限的类别来对数据进行分类。例如,我们可能正在基于预先确定的健康参数来预测一群人的预期寿命。在这种情况下,由于结果是一个连续函数(我们可以指定寿命预期为表示人们预期活多少年的实数),我们不谈论分类任务,而是一个回归问题。
想象一下监督学习的一种方法是,我们试图构建一个在数据集上定义的函数f。我们的数据集将包含由特征组织的信息。在电子邮件分类的例子中,这些特征可能是在垃圾邮件中出现频率较高的特定单词。使用显式的与性相关的词语很可能可以识别出垃圾邮件而不是商务/工作邮件。相反,诸如“会议”,“商务”和“演示”之类的词可能更有可能描述一个工作邮件。如果我们有访问元数据的权限,发送方信息也可以用来更好地分类电子邮件。然后,每封电子邮件将关联一组特征,并且每个特征将有一个值(在这种情况下,特定单词在电子邮件正文中出现的次数)。机器学习算法将寻求将这些值映射到表示一组类别的离散范围,或者在回归的情况下是一个实值。该算法将运行多个示例,直到能够定义出最佳函数,以便正确匹配大部分标记数据。然后它可以在未标记数据上运行,以做出预测而不需要人为干预。这定义了一个函数:

我们也可以将分类视为一种寻求分离不同数据点组的过程。一旦我们定义了我们的特征,任何例子,比如说,是我们数据集中的一个电子邮件,可以被视为特征空间中的一个点,每一个点代表一个不同的例子(或电子邮件)。机器算法的任务是画出一个超平面(即高维空间中的平面),将具有不同特征的点分开,正如我们想要将垃圾邮件与非垃圾邮件分开一样。
虽然在二维情况下可能看起来很简单,但在具有数百或数千维度的情况下可能非常复杂。

分类可以被看作是分隔输入数据的一种方式
在后面的章节中,我们将看到几个分类或回归问题的例子。我们将讨论其中一个问题是数字的分类:给定一组代表 0 到 9 的图像,机器学习算法将尝试对每个图像进行分类,并分配给它所描绘的数字。对于这样的例子,我们将使用最经典的数据集之一,即 MNIST 数据集。在这个例子中,每个数字由一个 28 x 28(=784)像素的图像表示,我们需要对每个数字进行分类,因此我们需要在 784 维空间中画出 9 个分隔超平面。

来自 MNIST 数据集的手写数字示例
无监督学习
机器学习算法的第二类称为无监督学习。在这种情况下,我们不事先为数据贴上标签,而是让算法得出结论。无监督学习中最常见、也许是最简单的例子之一是聚类。这是一种试图将数据分成子集的技术。
举例来说,在前述的垃圾邮件/非垃圾邮件的情况下,算法可能能够找到所有垃圾邮件共有的元素(例如,拼写错误的单词的存在)。尽管这可能提供比随机分类更好的结果,但不清楚垃圾邮件/非垃圾邮件是否能够如此轻松地分开。算法将数据分离成的子集是数据集的不同类别。为了使聚类有效,每个聚类中的每个元素在原则上应具有高的类内相似度和与其他类别的低相似度。聚类可以处理任意数量的类别,并且聚类方法(如 k 均值)背后的想法是找到原始数据的 k 个子集,这些子集的元素彼此之间比与其类外的任何其他元素更接近(更相似)。当然,为了做到这一点,我们需要定义更接近或更相似的含义,也就是说,我们需要定义某种度量,来定义点之间的距离。
在下图中,我们展示了一组点如何被分类成三个子集:

给定数据集的元素不一定需要聚集在一起形成一个有限集合,而聚类也可能包括给定数据集的无界子集,如下图所示:

聚类并不是唯一的无监督技术,我们将看到深度学习最近的成功与其在无监督学习任务中如此有效有关。
每天都会快速产生新数据,而对所有新数据进行标记是一项相当费力和耗时的活动。无监督学习算法的一个优点是它们不需要标记数据。无监督深度学习技术和方法,比如受限玻尔兹曼机,通过从数据中抽象特征来工作。例如,使用 MNIST 数据集,受限玻尔兹曼机将提取出对每个数字独特的特征,检测每个数字的线条和曲线的形状。无监督学习通过揭示数据中的隐藏结构来分类数据,而不是通过将其与标签进行匹配。
此外,例如使用深度信念网络,我们可以通过用监督学习对其进行改进来改善无监督方法的性能。
强化学习
机器学习技术的第三类是强化学习。尽管它仍然使用反馈元素来提高性能,但它的工作方式与监督学习不同。强化学习技术的一个常见应用是教导机器如何玩游戏:在这种情况下,我们不会将每一步标记为好或坏,而是从游戏中获得反馈,要么通过游戏的结果,要么通过游戏过程中的信号,比如得分或失分。赢得游戏会反映为积极的结果,类似于识别正确的数字或电子邮件是垃圾邮件还是不是,而输掉游戏将需要进一步的“学习”。强化学习算法倾向于重复尝试过去导致成功结果的动作,就像在游戏中获胜一样。然而,在未知领域,算法必须尝试新的动作,根据结果,它将更深入地学习游戏的结构。因为通常,动作是相互关联的,因此不能将单个动作评价为“好”或“坏”,而是整体动作的动态评价。类似于在下棋时有时候牺牲一个兵可能被认为是积极的动作,如果它带来了更好的棋盘位置,尽管丢失一个棋子通常是一个负面的结果,在强化学习中,探索的是整个问题及其目标。例如,一个移动的清洁机器人可能必须决定是继续清洁房间还是开始回到充电站,并且这样的决定可以基于在类似情况下它是否能在电池耗尽之前找到充电站。在强化学习中,基本思想是奖励,算法将寻求最大化总奖励。
强化学习的一个简单示例可以用来玩经典的井字棋游戏。在这种情况下,棋盘上的每个位置都关联了一个概率(一个值),这是基于先前经验从该状态赢得游戏的概率。开始时,每个状态被设定为 50%,这意味着在开始时我们假设从任何位置开始我们赢得或输掉的概率是相等的。一般来说,机器将尝试朝着价值更高的位置前进以赢得游戏,并且如果失败则重新评估它们。在每个位置,机器将基于可能的结果而做出选择,而不是基于固定的确定规则。随着继续进行游戏,这些概率将得到精炼,并根据位置输出更高或更低的成功机会。
机器学习系统涉及的步骤
到目前为止,我们已经讨论了不同的机器学习方法,并且我们已经大致将它们组织在三种不同的类别中。另一个重要的方面是了解数据,以更好地理解手头的问题。我们需要定义的重要方面大致可以描述如下:
-
学习者:这代表着使用的算法及其"学习哲学"。正如我们将在下一段中看到的,有许多不同的机器学习技术可以应用于不同的学习问题。学习者的选择很重要,因为不同的问题可以更适合某些机器学习算法。
-
训练数据:这是我们感兴趣的原始数据集。这样的数据可能是未标记的,用于无监督学习,或者它可能包含标签,用于监督学习。确保学习者有足够的样本数据以理解问题的结构非常重要。
-
表示:这是数据以所选特征的方式表达,以便学习者可以摄入的方式。例如,如果我们试图使用图像对数字进行分类,这将代表描述图像像素的值数组。良好的数据表示选择对于取得更好的结果是重要的。
-
目标:这代表了从手头的问题中学习的原因。这与目标息息相关,并且有助于定义应该使用什么学习者和什么表示。例如,目标可能是清理我们的邮箱中不需要的邮件,目标定义了我们的学习者的目标,例如,检测垃圾邮件。
-
目标:这代表着正在学习和最终输出的内容。它可以是未标记数据的分类,可以是根据隐藏的模式或特征表示输入数据,可以是未来预测的模拟器,可以是对外部刺激的响应,也可以是强化学习中的策略。
无论如何强调都不为过,任何机器学习算法只能达到目标的近似值,而不能得到完美的数值描述。机器学习算法不是问题的精确数学解,而只是近似值。在前面的段落中,我们已经将学习定义为从特征空间(输入)到类别范围的函数;我们将在后面看到,某些机器学习算法,如神经网络,理论上可以被证明能够近似地逼近任何函数到任何程度。这个定理被称为通用逼近定理,但这并不意味着我们可以得到问题的精确解。此外,通过更好地理解训练数据,可以更好地解决问题。
通常,使用经典机器学习技术可解决的问题在部署之前可能需要对训练数据进行深入了解和清理。如果我们要陈述处理机器学习问题所需的一些步骤,我们可以总结如下:
-
数据收集:这意味着尽可能收集尽可能多的数据,并且在监督学习问题中还要正确标记数据。
-
数据处理:这意味着清理数据(例如删除多余或高度相关的特征,或填充缺失数据),并且理解定义训练数据的特征。
-
测试用例的创建:通常数据可以分为两到三组:一个用于训练算法的训练数据集,一个用于在训练算法后测试方法准确性的测试数据集。通常情况下,我们还会创建一个用于最终测试(或验证)的验证数据集,经过多次训练-测试流程后,我们最终满意结果,进而做最终测试(或验证)。
有充分的理由创建一个测试集和一个验证集。正如我们所提到的,机器学习技术只能产生期望结果的近似值。这是因为通常情况下,我们只能包括有限数量的变量,而可能有许多变量是我们无法控制的。如果我们只使用一个数据集,我们的模型可能最终会“记住”数据,并在已记住的数据上产生极高的准确性值,但这个结果可能无法在其他类似的数据集上重现。机器学习技术的一个关键目标是其泛化能力。这就是为什么我们既创建了一个用于训练后模型选择调整的测试数据集,又创建了一个仅在流程结束时才用于确认所选算法有效性的最终验证数据集。
为了理解在数据中选择有效特征的重要性以及避免“记忆”数据的重要性(更技术性的术语是文献中所称的“过拟合”,从现在开始我们将使用这个术语),让我们用一个来自xkcd漫画的笑话来举例(xkcd.com/1122):"直到 1996 年,没有一个现任的民主党美国总统候选人,且没有军事经验,曾经打败过任何人名的首字母在 Scrabble 中价值更高的人"。很显然,这种“规则”在这个例子中是没有意义的,但它突出了选择有效特征的重要性(一个人的名字在 Scrabble 中价值多少与选择美国总统有关吗?),而选择随机特征作为预测因子,虽然可能会预测当前数据,但不能用作更一般数据的预测器,而 52 次选举中这一点成立只是一个简单的巧合。这通常被称为过拟合,也就是说,做出与手头数据完全吻合的预测,但不能推广到更大的数据集。过度拟合是试图理解通常所说的“噪音”(即,没有任何实际意义的信息)的过程,并尝试适应模型的小扰动。
另一个例子可以通过尝试使用机器学习来预测从地面抛出并上升到又落回地面的球的轨迹(不是垂直向上)。物理学告诉我们,轨迹呈抛物线状,我们期望一个好的机器学习算法观察到数千次这样的抛掷会得出一个抛物线作为解。然而,如果我们放大球并观察空气湍流中最小的波动,我们可能会注意到球不会保持稳定的轨迹,而可能受到小的扰动。这就是我们所称的“噪音”。试图模拟这些小扰动的机器学习算法将无法看到整体图景,并得出一个不令人满意的结果。换句话说,过拟合是使机器学习算法看到树木而忘记了整个森林的过程。

一个好的预测模型与一个糟糕的(过拟合的)预测模型有着不同的轨迹,对于一个从地面上抛出的球的轨迹来说。
这就是为什么我们将训练数据与测试数据分开的原因:如果测试数据的准确率与在训练数据上获得的结果不相似,那么这将是一个很好的指示,表明我们已经过度拟合了模型。当然,我们也需要确保我们不犯相反的错误,也就是说,不要欠拟合模型。但是在实践中,如果我们的目标是尽可能地使我们的预测模型在我们的训练数据上尽可能准确,那么欠拟合比过拟合的风险要小得多,因此我们应尽量避免过度拟合模型。

欠拟合也可能是一个问题
流行技术/算法的简要描述
除了根据它们的"学习风格"将算法分组之外,即本书开头讨论的三类,监督学习、无监督学习和强化学习,我们还可以根据它们的实现将它们分组。显然,上面讨论的每个类别都可以使用不同的机器学习算法来实现,例如,有许多不同的监督学习技术,每种技术可能最适合于手头的特定分类或回归任务。事实上,分类和回归之间的区别是最关键的之一,并且理解我们试图完成的任务是很重要的。
以下绝不意味着是一个详尽的列表或对每种机器学习方法的彻底描述,对于这些,我们建议读者参考 Sebastian Raschka 的书籍Python Machine Learning (www.packtpub.com/big-data-and-business-intelligence/python-machine-learning),而是意味着作为一个简单的回顾,为读者提供不同技术的简单味道以及深度学习与它们的区别。在接下来的章节中,我们将看到深度学习不仅仅是另一种机器学习算法,而且在很多方面与传统的机器学习技术不同。
我们将介绍一个回归算法,线性回归,经典的分类器,如决策树、朴素贝叶斯和支持向量机,以及无监督的聚类算法,如 k 均值,和强化学习技术,交叉熵方法,仅仅是对存在的各种机器学习技术的一个小小的了解,我们将通过介绍神经网络来结束这个列表,这是本书的主要焦点。
线性回归
回归算法是一种监督算法,它使用输入数据的特征来预测一个值,例如给定某些特征(如大小、年龄、浴室数量、楼层数、位置等)可以预测出房屋的价格。回归分析试图找到最能拟合输入数据集的函数参数值。在线性回归算法中,目标是通过找到适当的函数参数来最小化成本函数,使其最好地逼近目标值。成本函数是一个错误的函数,它表示我们离正确结果有多远。通常使用的成本函数是均方误差,其中我们计算预期值与预测结果之间的差的平方。对所有输入示例的总和给出了算法的错误值,它代表了成本函数。
假设我们有一所 100 平方米的房子,建于 25 年前,有三个浴室,两层。此外,假设我们将房子所在的城市分成 10 个不同的区域,用从 1 到 10 的整数表示,假设这所房子位于编号为 7 的区域。我们可以用一个 5 维向量 x = (100, 25, 3, 2, 7) 来对这所房子进行参数化。假设我们还知道这所房子的估计价值为€10,0000。我们想要实现的是创建一个函数 f,使得 f(x) = 100000。
在线性回归中,这意味着找到一个向量w =(w[1],w[2],w[3],w[4],w[5]),使100w[1] + 25w[2]* + 3w[3]* + 2w[4]* + 7w[5]* = 100000。如果我们有一千幢房子,我们可以为每栋房子重复相同的过程,理想情况下,我们希望找到一个 w向量,可以为每栋房子预测正确的值(或足够接近)。假设我们最初选择了一些 w的随机值。在这种情况下,我们不希望 f(x)* = 100* w[1] + 25* w[2] + 3* w[3] + 2* w[4] + 7* w[5] 等于 1,00,000,因此我们可以计算误差∆ =(100000 − * f(x) )[2]。这是一个示例 x的均方误差,所有示例的均方误差的均值代表了我们的成本,也就是我们的函数与真实值的差异有多少。因此,我们的目标是最小化此误差,为此我们计算成本函数相对于 w 的导数δ。导数指示函数增加(或减少)的方向,因此,把 w 移动到导数的相反方向会提高我们函数的准确性。这是线性回归的要点,向着成本函数的最小值移动,这代表了错误。当然,我们需要决定我们想以多快的速度沿着导数指示的方向移动,因为我们的导数只指明了一个方向。成本函数不是线性的,因此我们需要确保只在导数指示的方向上迈出小步。迈出太大的一步可能会导致我们超过最小值,因此无法收敛到最小值。这一步的大小就是所谓的学习率,让我们用符号“lr*”表示它的大小。
通过设置w = w - δ**lr,* 我们因此改善了w向更好解决方案的选择。重复这个过程多次将产生表示* f*的最佳可能选择的w值。然而,我们应该强调,如果空间不是凸的,这个过程只在局部起作用,并且可能无法找到全局的最佳值。正如图像所暗示的,如果存在许多局部最小值,算法可能最终会被困在其中一个局部最小值中,无法逃脱到达误差函数的全局最小值,类似于一个小球从山上滚下来,可能会卡在一个小山谷中,无法到达山底。

顶部图是凸的,因此只存在一个最小值。在底部图中,函数有两个局部最小值,因此,取决于初始化,该过程可能会找到第一个不是全局最小值的局部最小值。
决策树
另一个广泛使用的监督算法是决策树算法。决策树算法创建一个“树”形式的分类器。决策树由决策节点组成,其中对特定属性进行测试,以及指示目标属性值的叶节点。决策树是一种分类器,通过从根节点开始并通过决策节点向下移动直到达到叶子节点来工作。
该算法的一个经典应用是鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris),其中包含来自三种鸢尾花(山鸢尾、维吉尼亚鸢尾和变色鸢尾)的 50 个样本的数据。创建数据集的罗纳德·费舍尔测量了这些花的四种不同特征,即它们的萼片的长度和宽度,以及它们的花瓣的长度和宽度。根据这些特征的不同组合,可以创建一个决策树来决定每朵花属于哪个物种。在这里,我们将描述一个简单的简化决策树,它将只使用这两个特征,花瓣的长度和宽度,就能正确分类几乎所有的花。
我们从第一个节点开始,我们对花瓣长度进行第一个测试:如果花瓣长度小于 2.5,则该花属于山鸢尾物种。事实上,这将正确地对所有花瓣长度小于 2.5 厘米的山鸢尾花进行分类。因此,我们到达了一个标记为山鸢尾的叶子节点。如果花瓣长度大于 2.5,我们选择另一条分支,我们到达一个新的决策节点,并测试花瓣宽度是否大于 1.8。如果花瓣宽度大于或等于 1.8,则我们到达一个叶节点,并将我们的花分类为维吉尼亚鸢尾,否则我们到达一个新的决策节点,在这里我们再次测试花瓣长度是否大于 4.9。如果是,则我们到达一个标记为维吉尼亚鸢尾花的叶子节点,否则我们到达另一个标记为变色鸢尾花的叶子节点。
所讨论的决策树可以表示如下,其中左分支反映了决策节点中测试的积极答案,而右分支表示决策节点中测试的否定答案。每个分支的末端节点都是叶节点。

这个例子展示了决策树算法与线性回归有多么不同。此外,当我们介绍神经网络时,读者将能够通过使用相同的数据集来看到神经网络是如何工作的一个示例。在那个例子中,我们还将提供 Python 代码,并展示一些神经网络将如何基于它们的特征来尝试分离花朵的图片。
K-means
如我们已经讨论过的,聚类算法是一种无监督的机器学习方法。最常见的聚类技术是称为 k-means 聚类的技术,它是一种将数据集中的每个元素分组到 k 个不同子集中的聚类技术(因此在名称中有 k)。K-means 是一个相对简单的过程,包括选择代表 k 个不同子集的随机 k 点,称为中心。然后,我们为每个中心选择所有最接近它的点。这将创建 k 个不同的子集。此时,对于每个子集,我们将重新计算中心。我们再次有 k 个新中心,然后重复上述步骤,为每个中心选择与中心最接近的新子集。我们继续这个过程,直到中心停止移动。
很明显,为了使这种技术起作用,我们需要能够识别一种允许我们计算点之间距离的度量标准。此过程可以总结如下:
-
选择初始 k 点,称为中心点。
-
将数据集中的每个点与最近的中心关联起来。
-
计算与特定中心点关联的点集的新中心。
-
将新中心定义为新的中心。
-
重复步骤 3 和 4,直到中心停止移动。
需要注意的是,此方法对随机中心的初始选择非常敏感,可能重复使用不同的初始选择是个好主意。此外,某些中心可能不是数据集中任何点最近的中心,将子集数目减少到 k。值得一提的是,如果我们在上面讨论决策树的例子中使用 k=3 的 k-means,我们可能不会得到与使用决策树找到的鸢尾花数据集相同的分类,再次强调了如何重要地为每个问题选择和使用正确的机器学习方法。
现在,让我们讨论一个使用 k-means 聚类的实际示例。假设一个披萨外卖店想要在一个新城市开设四家新的连锁店,他们需要选择四个新店的位置。这是一个可以通过 k-means 聚类轻松解决的问题。其思想是找到最常订购披萨的地点;这些地点将成为我们的数据点。接下来,我们选择四个随机点作为站点位置。通过使用 k-means 聚类技术,我们可以后来确定最佳位置,以最小化到每个送货地点的距离。这是一个 k-means 聚类可以帮助解决业务问题的例子。

左边是披萨最常送达的点的分布。右边是圆点表示新的连锁店应该位于的位置及其相应的送货区域
朴素贝叶斯
朴素贝叶斯不同于许多其他机器学习算法。从概率上讲,大多数机器学习技术试图评估的是在给定条件X下某个事件Y的概率,我们用p(Y|X)表示。例如,给定描述数字的图片(即,具有特定像素分布的图片),那么该数字为 5 的概率是多少?如果像素的分布接近于其他标记为 5 的示例的像素分布,那么该事件的概率将很高,否则概率将很低。
有时我们具有相反的信息,也就是说,我们知道我们有一个事件Y的情况下,我们知道样本为X的概率。贝叶斯定理说明:p(X|Y) = p(Y|X)p(X)/p(Y),其中p(X|Y)*表示在知道Y的情况下生成实例X的概率,这也是为什么朴素贝叶斯被称为生成方法。简而言之,我们可以计算某个像素配置代表数字 5 的概率,知道在有 5 的情况下,即使随机像素配置可能与给定的一致,这是为什么。
这在医学测试领域是最容易理解的。假设我们针对特定疾病或癌症进行测试。我们想知道在我们测试结果为阳性的情况下,我们可能患有特定疾病的概率是多少。现在,大多数测试都有可靠性值,这是在针对患有特定疾病的人群时测试呈阳性的概率。通过颠倒表达式p(X|Y) = p(Y|X)p(X)/p(Y)*,我们有:
p(癌症 | 测试=阳性) = p(测试=阳性 | 癌症) * p(癌症)/p(测试=阳性)
假设测试的可靠率为 98%,这意味着在 98%的情况下,如果一个人患有癌症,那么测试结果为阳性,同样地,如果一个人没有癌症,测试结果将为阴性。还假设这种特定的癌症只影响年龄较大的人,50 岁以下的人中只有 2%的人患有这种癌症,对于这部分人群进行测试,结果呈阳性的概率仅占到人群的 3.9%(我们可以从数据中推导出这个事实,但为简单起见,我们提供这些信息)。
我们可能会提出这样一个问题:如果一项癌症测试的准确率为 98%,45 岁的人接受了测试,并且测试结果呈阳性,那么他/她可能患癌症的概率是多少?使用上述公式,我们可以计算:
p(癌症 | 测试=阳性) = 0.98 * 0.02/0.039 = 0.50
因此,尽管测试的准确性很高,朴素贝叶斯告诉我们我们还需要考虑这样一个事实:50 岁以下罹患癌症的情况相当罕见,因此仅仅通过测试结果的阳性并不能给出 98%的癌症患病概率。概率p(癌症),或者更一般地说是我们试图估计的结果的概率 p,被称为先验概率,因为它代表了没有任何额外信息的事件概率,也就是在我们进行测试之前的概率。
此时,我们可能会想知道如果我们有更多信息会发生什么,例如如果我们使用了可靠性更高的不同测试,或者了解了一些人的信息,例如家族中的癌症复发情况。在上述方程中,我们使用了作为计算因素之一的概率 p(test=positive | cancer),如果我们进行第二个测试,并且结果也为正,我们也会有 p(test2=positive | cancer)。朴素贝叶斯技术假设每个信息片段都是独立的(这意味着第二个测试的结果不知道第一个测试的结果,并且它与第一个测试无关,即进行第一个测试不能改变第二个测试的结果,因此它的结果不受第一个测试的影响)。朴素贝叶斯是一种分类算法,它假设不同事件之间是相互独立的,以计算它们的概率。因此:
= p(当第一个测试和第二个测试都为正时,癌症的条件概率) =p(当第一个测试为正时,癌症的条件概率)p(当第二个测试为正时,癌症的条件概率)*
这个方程也称为在人确实患有癌症的情况下,test1和test2为阳性的概率 L(test1 和 test2 = pos)。
我们可以将方程重写为:
= p(癌症在两个测试都为正时的条件概率) =
= p(当两个测试为正时,癌症的条件概率)p(癌症)/p(两个测试都为正)* =*
= p(当第一个测试为正时,癌症的条件概率)p(当第二个测试为正时,癌症的条件概率)p(癌症)/p(两个测试都为正)
支持向量机
支持向量机是一种用于分类的监督机器学习算法。支持向量机相对于其他机器学习算法的优势在于,它不仅将数据分成类别,而且还通过寻找最大化将每个点与超平面(多于三个维度的空间中的平面的类比)分离的分隔超平面来实现这一目标。此外,支持向量机还可以处理数据不是线性可分的情况。处理非线性可分数据的方法有两种,一种是引入软边界,另一种是引入所谓的核技巧。
松弛边界在保留算法大部分预测能力的同时允许存在少量错误分类的元素。正如我们上面所讨论的,在实践中,最好不要过度拟合任何机器学习模型,我们可以通过放宽一些支持向量机的假设来实现这一点。
相反,核技巧涉及将特征空间映射到另一个空间,在该空间中我们可以定义一个超平面,将其映射回特征空间时,它不再是一个线性超平面,从而允许分离数据集中看似不可分离的元素。由于本书主要关注深度学习,我们不会详细讨论支持向量机的实现方式,这可能会花费太多时间,而是要强调支持向量机之所以非常流行和有效的概念,这要归功于它们能够推广到非线性情况。正如我们之前所见,监督式机器学习算法的任务是找到从特征空间到一组类别的函数。每个输入x= (x[1], x[2], …, x[n])表示一个输入示例,而每个xᵢ表示第i个特征的值。早些时候我们举了一个例子,试图根据一些特征(如浴室数量或位置)来估计某个房屋的转售价值。如果第i个特征对应于浴室数量,则xᵢ将对应于房屋x中存在的浴室数量。我们可以从特征空间创建一个函数k到这个空间的另一个表示,称为核:例如,k可以将xᵢ映射到(x[i])²,并且通常将特征空间非线性地映射到另一个空间W。因此,在W中的分离超平面可以映射回特征空间,其中它将不再是线性超平面。这个条件确切的定义在哪些情况下成立是明确定义的,但超出了这个简短介绍的范围。然而,这再次突显了在经典机器学习算法中选择正确特征的重要性,这样的选择可以帮助找到特定问题的解决方案。

左侧是应用核之前的非线性可分数据集。右侧是应用了核之后的相同数据集,数据可以线性分离。
交叉熵方法
到目前为止,我们介绍了监督学习和无监督学习算法。相反,交叉熵方法属于强化学习类算法,将在本书的第七章和第八章中详细讨论,《棋类游戏的深度学习》和《电子游戏的深度学习》。交叉熵方法是解决优化问题的技术,即找到最佳参数以最小化或最大化特定函数。
通常,交叉熵方法包括以下阶段:
-
生成我们试图优化的变量的随机样本。对于深度学习,这些变量可能是神经网络的权重。
-
运行任务并存储性能。
-
识别最佳运行并选择性能最佳的变量。
-
根据表现最佳的运行,计算每个变量的新均值和方差,并生成变量的新样本。
-
重复步骤,直到达到停止条件或系统停止改进。
假设我们正在尝试解决一个依赖于许多变量的函数,例如我们正在尝试建立一个模型飞机,当从特定高度发射时,它能够飞得最远。飞机飞行的距离将取决于其机翼的大小、它们的角度、重量等。每次,我们都可以记录每个变量,然后发射飞机并测量它飞行的距离。但是,我们不是尝试所有可能的组合,而是创建统计数据,我们选择最佳和最差的运行,并注意在最佳运行和最差运行期间变量设置的值。例如,如果我们发现每次最佳运行飞机都具有特定大小的机翼,我们可以得出结论,该特定大小可能对于飞机飞行很远是最优的。相反,如果对于每次最差运行,飞机的机翼都处于某个角度,我们会得出结论,该特定角度对于我们飞机的机翼是一个不好的选择。总的来说,我们将为应该产生最佳飞机的每个值产生一个概率分布,这些概率不再是随机的,而是基于我们收到的反馈。
因此,该方法利用运行的反馈(飞机飞了多远)来确定问题的最佳解决方案(每个变量的值),这是典型的强化学习过程。
神经网络
在给读者带来一些流行的经典机器学习算法之后,我们现在将介绍神经网络,并深入解释它们的工作原理以及它们与我们简要总结的算法的区别。
神经网络是另一种机器学习算法,它们有着高人气的时期和极少被使用的时期。理解神经网络,我们将在接下来的几章中专门介绍,确实是理解本书内容的关键。
第一个神经网络的例子被称为感知机,它是由弗兰克·罗森布拉特于 1957 年发明的。感知机是一个由输入层和输出层组成的网络。在二元分类的情况下,输出层只有一个神经元或单元。一开始,感知机似乎非常有前途,但很快就发现它只能学习线性可分的模式。例如,马文·明斯基和西摩·帕普特证明它无法学习异或逻辑函数。在其最基本的表示中,感知机只是一个神经元及其输入的简单表示,输入可以由多个神经元组成。
给定不同的输入到一个神经元,我们通过公式
定义激活值,其中x [i]是输入神经元的值,而w [i]是神经元 i 和输出之间的连接值。我们将在下一章节中深入学习这个公式,现在我们只需要注意感知机与逻辑回归算法有许多相似之处,并且也受到线性分类器的限制。如果激活值(被视为神经元的内部状态)大于固定阈值 b,则神经元将被激活,即会发生激活,否则不会。

一个带有三个输入单元(神经元)和一个输出单元(神经元)的简单感知机。
上面定义的简单激活可以理解为向量w和向量x的点积。向量w是固定的,并且它定义了感知机的工作方式,而x表示输入。如果< w,x > = 0,则向量x垂直于权重向量w,因此所有满足< w,x > = 0的向量x定义了R ^(3) (其中3是x的维度,但通常可以是任何整数)中的一个超平面。因此,任何满足< w,x > > 0的向量x都是位于w定义的超平面的一侧的向量。这清楚地说明了感知机如何定义一个超平面,并且可以作为一个分类器工作。一般而言,我们可以将阈值设置为任何实数b,这使得超平面远离原点。然而,通常我们在网络中包含一个偏置单元,它是一个始终开启(值为 1)的特殊神经元,并具有连接权重-b。在这种情况下,如果连接权重的值为-b,激活值变为
,而设置a(x) > 0等同于设置
。

一个感知机加上一个偏置单元作为输出向量。偏置单元始终为开启状态。
感知机在性能上有限,但在历史上非常重要,因为它们是神经网络的第一个例子。
神经网络当然不需要只有一个输出神经元,事实上,一般情况下不会只有一个。如果网络有多个输出神经元,那么对于每个输出神经元,我们可以重复相同的过程。然后,每个权重都将被标记为两个指标,i 和 j,以指示权重连接了输入层的神经元 i 和输出层的神经元 j。还将从偏置单元连接到输出层的每个神经元,其值为 1。还应该注意,我们可以在激活值上定义不同的活动函数。我们已经将激活值定义为
(从现在开始,我们将假设偏差已包含在此公式中),并且我们已经说过,如果激活大于 0,则神经元激活。正如我们将看到的,这已经定义了一个活动函数,即在激活上定义的函数,即在神经元的内部状态上定义的函数,并且这被称为阈值活动,因为当激活大于 0 时,神经元激活。但是,我们将看到,神经网络可以具有许多不同的活动函数,这些函数可以在其激活值上定义,并且我们将在下一章中对其进行更详细的讨论。
深度学习
前面的段落介绍了一个非常简单的神经网络示例,即前馈 1 层网络。它们被称为前馈,因为信息从输入向输出传递,永远不会循环返回,而且是 1 层,因为除了输入层之外只有 1 个输出层。这不是一般情况。当我们提到它们只能处理线性可分数据时,我们已经讨论了前馈 1 层网络的局限性,特别是我们提到它们无法逼近逻辑异或函数。然而,存在具有额外层的网络,这些层位于输入和输出层之间:这些层称为隐藏层。具有隐藏层的前馈网络将信息从输入通过其隐藏层传递到输出层,这定义了一个接受输入并定义输出的函数。存在一个定理,称为通用定理,它说明任何函数都可以由具有至少一个隐藏层的神经网络逼近,并且我们将在下一章中给出为什么这是正确的直觉。
长时间以来,考虑到这个定理以及与复杂网络合作的困难,人们一直使用只有一个隐藏层的浅层网络。然而,最近人们意识到,具有许多隐藏层的更复杂的网络可以理解比浅层网络更多的抽象层次。此外,还引入了循环网络,其中神经元也可以将信息反馈给自身。一些神经网络的结构也可以允许定义能量函数,从而可以创建记忆。所有这些令人兴奋的功能将在接下来的章节中讨论,我们将深入研究深度学习的最新发展。

一个隐藏层的神经网络
在现实生活中的应用
一般来说,机器学习,尤其是深度学习,在预测质量、特征检测和分类方面正在产生越来越令人惊讶的结果。近年来,许多最近的结果都成为了新闻。进展如此之快,以至于许多专家担心很快机器将比人类更加智能。在 2015 年 10 月 14 日的联合国会议上,人工智能专家和其他许多领域的研究人员警告说,需要定义道德准则来防止超级智能机器可能给人类社会带来的危险。这些担忧源于一些最近的令人难以置信的结果,其中计算机已经能够击败人类最好的冠军,在人们认为直觉会给人类在与机器的比赛中带来优势的游戏中。
AlphaGo 是基于深度学习的人工智能机器,在 2016 年曾因击败世界围棋冠军李世石而成为新闻头条。在 2016 年 1 月,AlphaGo 已经因击败欧洲冠军范辉而成为新闻,尽管当时看起来不太可能击败世界冠军。几个月后,AlphaGo 成功通过以 4-1 的胜利系列横扫对手来实现这一非凡壮举。庆祝的原因在于围棋比其他游戏(如国际象棋)拥有更多可能的游戏变化,因此不可能事先考虑所有可能的走法。此外,在围棋中,即使是判断当前棋局上的单个棋子的位置或价值也是非常困难的,与国际象棋不同。
AlphaGo 的优势在于它不是被编程来玩游戏的,而是通过使用强化学习和深度学习技术与自身进行数千场比赛而学会玩游戏的。学习能力是使机器学习,尤其是深度学习,成为解决问题的完全不同方法的原因。深度学习是关于创建可以自己学习的程序,几乎不需要人类的帮助。
但是,深度学习获得重大成功的领域的种类不仅限于游戏。Kaggle(www.kaggle.com)是一个主办许多不同机器学习竞赛的网站。这些竞赛在使用领域和应用领域上有很大的差异。2013 年,俄勒冈大学发起了一场竞赛,要求使用机器学习技术通过标准的实际音频数据录音来检测和识别鸟类。为了更好地了解鸟类种群趋势,通常需要付出昂贵的人力努力。机器学习通过仅仅通过听音频记录自动识别存在的鸟类来解决这个问题。
亚马逊最近启动了另一项竞赛,用于解决员工访问内部计算机和网络的问题,希望成功的解决方案能够减少人工监督干预带来的昂贵延迟。
2015 年,芝加哥卫生部举办了一场比赛,要求提供“根据天气、地点、检测和喷洒数据……在什么地方以及何时会检测到不同种类的蚊子携带西尼罗河病毒”的答案。
2015 年 8 月,一项竞赛要求预测西澳大利亚的租金价格,2016 年 2 月,法国巴黎银行 BNP Paribas 发起了一场竞赛,以加快其索赔管理流程。
这提供了一些用机器学习解决的问题的种类,值得注意的是,所有这些比赛都提供了最佳解决方案的奖励。2009 年,Netflix 推出了一项百万美元的竞赛,以提高其根据用户之前观看电影的排名预测用户可能喜欢的电影的准确性,而数据科学家的工作通常被排名为工资最高且最受欢迎的工作之一。
从自动驾驶汽车、军事无人机和目标侦查系统到医疗应用,例如能够读取医生笔记以发现潜在健康问题的应用以及能够提供面部识别的监控系统,机器学习在各种应用中得到了常规使用。
光学字符识别广泛应用于诸如邮局等机构,以读取信封上的地址,我们将展示如何使用 MNIST 数据集将神经网络应用于数字识别。自主无监督深度学习在自然语言处理 (NLP)方面也有许多应用和出色的成果,我们每个人几乎在自己的智能手机上都有一个应用深度学习在 NLP 上的 NLP 应用,因为苹果和安卓都使用应用于 NLP 的深度学习来作为虚拟助手(例如,Siri)。机器学习还可以应用于生物特征识别,例如识别指纹、DNA 或视网膜。此外,自动驾驶汽车在近年来取得的进展已经到达了一个现实的阶段。
机器学习也可以应用于目录中的图片或者更重要的卫星图像,根据它们是不是城市环境,是否描述了森林、冰区、水域等不同环境进行描述。
总而言之,机器学习最近在我们生活的方方面面都找到了应用,并且其准确性和性能也得到了持续改善,这也要归因于越来越好和更快的计算机。
一个受欢迎的开源包
机器学习是一个受欢迎且竞争激烈的领域,有许多开源包实现了大多数经典的机器学习算法。其中最受欢迎的是scikit-learn(scikit-learn.org),这是一个广泛使用的 Python 开源库。
scikit-learn提供了实现大多数经典机器学习分类器、回归器和聚类算法的库,例如支持向量机(SVM)、最近邻、随机森林、线性回归、k 均值、决策树和神经网络等许多机器学习算法。
基类sklearn有几个可用的包,取决于选择的算法类型,比如sklearn.ensemble、sklearn.linear_model、sklearn.naive_bayes、sklearn.neural_network、sklearn.svm和sklearn.tree。
也有一些辅助工具来进行交叉验证,以及帮助选择最佳特征。我们将不再花时间抽象地描述所有功能,而是从使用多层神经网络的一个简单示例开始。scikit-learn库使用类似签名的方法来实现每种机器学习算法,因此分类器共享相同的通用功能。此外,我们希望读者能够快速了解神经网络的功能,而无需花时间从头开始创建神经网络。下一章将讨论其他库以及更多复杂的深度学习神经网络类型的实现,但目前,用户可以快速了解它们的功能。
例如,如果想要在 scikit-learn 中使用多层神经网络,只需在程序中导入它即可:
from sklearn.neural_network.multilayer_perceptron import MLPClassifier
每个算法都需要使用预定义的参数进行调用,尽管在大多数情况下可以使用默认值。对于 MLPClassifier 来说,不需要参数,可以使用默认值(所有参数都在 scikit-learn 网站上描述,特别是对于 MLPClassifier 可以在这里找到它们:scikit-learn.org/dev/modules/generated/sklearn.neural_network.MLPClassifier.html)。
然后在训练数据上调用该算法,使用标签来调整参数,使用fit函数:
MLPClassifier().fit(data, labels)
一旦算法在训练数据上拟合好,它就可以使用predict_proba函数在测试数据上进行预测,该函数将为每个类输出概率:
probabilities = MLPClassifier().predict_proba(data)
让我们写一个完整的例子,演示如何在我们简要介绍决策树时简要讨论的iris数据集上使用MLPClassifier分类器。
Scikit-learn 使加载重要的经典数据集变得很容易。要做到这一点,我们只需要:
from sklearn import datasets
iris = datasets.load_iris()
data = iris.data
labels = iris.target
这将加载数据集。现在,要加载分类器,我们只需要:
from sklearn.neural_network.multilayer_perceptron import MLPClassifier
现在我们使用数据来调整参数:
mlp = MLPClassifier(random_state=1)
mlp.fit(data, labels)
现在,由于权重是随机初始化的,random_state值只是为了强制初始化始终使用相同的随机值,以便在不同试验中获得一致的结果。这与理解过程完全无关。fit函数是调用的重要方法,它是通过使用提供的数据和标签,在受监督的方式下训练算法以找到最佳权重的方法。
现在我们可以检查我们的预测并将其与实际结果进行比较。由于函数predict_proba输出概率,而predict输出具有最高概率的类,因此我们将使用后者进行比较,并使用 sikit-learn 的一个辅助模块来给出准确度:
pred = mlp.predict(data)
from sklearn.metrics import accuracy_score
print('Accuracy: %.2f' % accuracy_score(labels, pred))
就是这样。当然,正如我们提到的,通常最好将我们的数据分为训练数据和测试数据,并且我们还可以通过对数据进行一些正则化来改进这个简单的代码。Scikit-learn 也提供了一些辅助函数:
from sklearn.cross_validation import train_test_split
from sklearn.preprocessing import StandardScaler
data_train, data_test, labels_train, labels_test = train_test_split(data, labels, test_size=0.5, random_state=1)
scaler = StandardScaler()
scaler.fit(data)
data_train_std = scaler.transform(data_train)
data_test_std = scaler.transform(data_test)
data_train_std = data_train
data_test_std = data_test
这段代码很容易理解,我们将数据拆分并对其进行归一化处理,这意味着我们减去均值并将数据缩放到单位方差。然后我们在训练数据上拟合我们的算法,然后在测试数据上进行测试:
mlp.fit(data_train, labels_train)
pred = mlp.predict(data_test)
print('Misclassified samples: %d' % (labels_test != pred).sum())
from sklearn.metrics import accuracy_score print('Accuracy: %.2f' % accuracy_score(labels_test, pred))
然后我们得到以下输出:
Misclassified samples: 3 Accuracy: 0.96
我们可以绘制一些图片来展示数据以及神经网络将空间分成三个区域以分离三种类型的花(由于我们只能绘制二维图片,我们一次只会绘制两个特征)。第一个图显示了算法如何根据花瓣的宽度和长度来分离花,而没有对数据进行归一化:

第二个图显示了基于花瓣宽度和花萼宽度的分离情况:

第三个图显示了第一个图的相同数据,在归一化数据之后:

最后,第四个图与第二个图相同,只是数据已归一化:

我们还展示了用于创建这些图形的代码。请注意,用于绘制这些图片的代码是根据 Sebastian Raschka 在他的Python 机器学习书籍中的类似代码进行调整的,该书由 Packt Publishing 出版,我们建议读者参考该书获取更多详情。
制作上述图形的代码如下。请注意,在数据之前必须设置为仅包含与两个变量相关的数据,例如对于萼片和花瓣长度,data = iris.data[:,[1,3]],因为我们只能绘制二维图像。
import numpy
from matplotlib.colors import ListedColormap
import matplotlib.pyplot as plt
markers = ('s', '*', '^')
colors = ('blue', 'green', 'red')
cmap = ListedColormap(colors)
x_min, x_max = data[:, 0].min() - 1, data[:, 0].max() + 1
y_min, y_max = data[:, 1].min() - 1, data[:, 1].max() + 1
resolution = 0.01
x, y = numpy.meshgrid(numpy.arange(x_min, x_max, resolution), numpy.arange(y_min, y_max, resolution))
Z = mlp.predict(numpy.array([x.ravel(), y.ravel()]).T)
Z = Z.reshape(x.shape)
plt.pcolormesh(x, y, Z, cmap=cmap)
plt.xlim(x.min(), x.max())
plt.ylim(y.min(), y.max())
# plot the data
classes = ["setosa", "versicolor", "verginica"]
for index, cl in enumerate(numpy.unique(labels)):
plt.scatter(data[labels == cl, 0], data[labels == cl, 1], c=cmap(index), marker=markers[index], s=50, label=classes[index])
plt.xlabel('petal length')
plt.ylabel('sepal length')
plt.legend(loc='upper left')
plt.show()
如我们所述,MLPClassifier具有许多参数可供我们使用;我们仅引用激活函数和隐藏层数以及每个隐藏层可能具有的神经元数量,但所有可能参数的文档都可在 scikit-learn.org/dev/modules/generated/sklearn.neural_network.MLPClassifier.html 上找到。
隐藏层数和神经元数量可以通过添加hidden_layer_sizes=(n[1], n[2], n[3], …, n[m])来指定,其中nᵢ是第i层的神经元数量。
对于一个具有分别为200和100个神经元的两个隐藏层的神经网络,我们会写成:
mlp = MLPClassifier(random_state=1, hidden_layer_sizes=(200, 100,))
另一个重要的参数是激活函数,我们之前称之为活动函数。该模块支持以下三种类型的定义:
注意
ReLU 是最简单的,也是最受欢迎的之一(也是默认的激活函数),其定义简单地为 
逻辑 函数用于计算事件的概率时使用,实际上它的值介于 0 和 1 之间,定义如下: 
最后,tanh 简单定义为: 
例如,要使用分别具有 200 个和 100 个神经元的两个隐藏层,其中一个逻辑激活函数,代码将修改为:
mlp = MLPClassifier(random_state=1, hidden_layer_sizes=(200, 100,), activation = "logistic")
我们邀请读者尝试一些这些参数,并且使用max_iter参数,它将限制迭代次数。迭代次数是指对训练数据的传递次数。小值,例如max_iter=100,将不会产生很好的结果,因为算法没有时间收敛。但请注意,在这样一个小数据集上,更多的隐藏层不一定会产生更好的预测结果,反而可能会降低预测准确性。
这就结束了本章,我们向读者介绍了机器学习的重要性以及在现实世界中的许多应用。我们简要提到了一些问题和困难,并且涉及了下一章的重点——神经网络的主题。我们还介绍了如何使用标准的开源库,例如scikit-learn来开始实现一些简单的多层前馈网络。
现在我们转而深入讨论神经网络及其使用背后的动机。
总结
在本章中,我们已经介绍了什么是机器学习以及为什么它如此重要。我们提供了几个机器学习技术应用的示例,以及使用机器学习可以解决哪些问题。我们还介绍了一种特定类型的机器学习算法,称为神经网络,它是深度学习的基础,并提供了一个编码示例,在其中,使用一种流行的机器学习库,我们解决了一个特定的分类问题。在下一章中,我们将更详细地介绍神经网络,并根据从观察我们自己的大脑工作方式中得出的生物学考虑,提供它们的理论解释。
第二章:神经网络
在上一章中,我们描述了几种机器学习算法,并介绍了不同的技术来分析数据以进行预测。例如,我们建议机器可以使用房屋销售价格的数据来预测新房屋的价格。我们描述了大型公司,例如 Netflix,如何使用机器学习技术来建议用户他们可能喜欢的新电影,这是一种在电子商务中被亚马逊或沃尔玛等巨头广泛使用的技术。然而,大多数这些技术需要有标签的数据才能对新数据进行预测,并且为了提高性能,需要人类描述数据以符合有意义的特征。
人类能够快速推断出模式并推断出规则,而不需要对数据进行清理和准备。如果机器也能学会做同样的事情,那就太好了。正如我们所讨论的,弗兰克·罗森布拉特于 50 多年前的 1957 年发明了感知器。感知器对于现代深度神经网络而言就像单细胞生物对于复杂多细胞生物一样重要,但了解和熟悉人工神经元的工作原理对于更好地理解和欣赏通过在许多不同层上组合许多神经元来生成的复杂性至关重要。神经网络试图模仿人脑的功能以及通过简单观察抽象出新规则的能力。尽管我们对人类大脑如何组织和处理信息还知之甚少,但我们已经对单个人类神经元的工作原理有了很好的理解。人工神经网络试图模仿相同的功能,将化学和电信号传递交换成数值和函数。在过去的十年中取得了很大进展,神经网络变得流行之后至少两次被遗忘:这种复苏部分原因是计算机的运行速度越来越快,使用GPU(图形处理单元)而不是最传统的CPU(计算处理单元),更好的算法和神经网络设计,以及越来越大的数据集,正如我们将在本书中看到的。
在本章中,我们将正式介绍神经网络是什么,我们将彻底描述神经元是如何工作的,并且我们将看到如何堆叠许多层来创建和使用深度前馈神经网络。
为什么要使用神经网络?
神经网络存在很多年了,并经历了几个时期的兴衰。然而,近年来,它们一直在与许多其他竞争的机器学习算法相比稳步地取得进展。这是因为先进的神经网络架构在许多任务上显示出的准确性远远超过其他算法。例如,在图像识别领域,准确性可能是针对一个名为 ImageNet 的包含 1600 万图像的数据库进行衡量的。
在引入深度神经网络之前,准确性一直以较慢的速度改善,但在引入深度神经网络之后,准确性从 2010 年的 40%的错误率下降到 2014 年的不到 7%,而且这个数值仍在下降。人类的识别率仍然较低,约为 5%。考虑到深度神经网络的成功,2013 年 ImageNet 竞赛的所有参赛者都使用了某种形式的深度神经网络。此外,深度神经网络会“学习”数据的表示,即不仅学习识别对象,还学习识别被识别对象所具有的重要特征。通过学习自动识别特征,深度神经网络可以成功地用于无监督学习,通过自然地将具有相似特征的对象分类在一起,而无需费力地人工标记。在其他领域,例如信号处理,也取得了类似的进展。现在,深度学习和使用深度神经网络是普遍存在的,例如在苹果的 Siri 中使用。当谷歌为其 Android 操作系统引入深度学习算法时,错误识别率降低了 25%。用于图像识别的另一个数据集是 MNIST 数据集,其中包含用不同手写方式书写的数字的示例。现在,使用深度神经网络进行数字识别的准确率可以达到 99.79%,与人类的准确率相当。此外,深度神经网络算法是人工智能中最接近人脑工作方式的例子。尽管它们可能仍然是我们大脑的一个更简化、基本版本,但它们比任何其他算法更多地包含了人类智能的种子,本书的其余内容将致力于研究不同的神经网络以及提供几个不同应用神经网络的示例。
基础知识
在第一章中,我们讨论了机器学习的三种不同方法:监督学习、无监督学习和强化学习。经典神经网络是一种监督式机器学习,尽管我们将在后面看到,现代深度神经网络的普及更多地归功于这样一个事实,即现代深度神经网络也可以用于无监督学习任务。在接下来的章节中,我们将重点介绍传统浅层神经网络和深度神经网络之间的主要区别。然而,现在我们将主要集中在以监督方式工作的经典前馈网络上。我们的第一个问题是,神经网络究竟是什么?也许解释神经网络的最佳方式是将其描述为信息处理的数学模型。虽然这可能听起来相当模糊,但在接下来的章节中,它将变得更加清晰。神经网络不是一个固定的程序,而是一个模型,一个处理信息或输入的系统,在某种程度上类似于生物实体被认为处理信息的方式。我们可以确定神经网络的三个主要特征:
-
神经网络架构:这描述了神经元之间的连接集合(前馈、循环、多层或单层等),层数以及每层中的神经元数量。
-
学习:这描述了我们通常定义为训练的内容。无论我们使用反向传播还是某种能量级训练,它都确定了我们如何确定神经元之间的权重。
-
活动函数:这描述了我们在传递给每个神经元的激活值上使用的函数,神经元的内部状态,以及它描述了神经元的工作方式(随机、线性等)以及在什么条件下它将激活或触发,以及它将传递给相邻神经元的输出。
但是,需要指出的是,一些研究人员会将活动函数视为架构的一部分;然而,对于初学者来说,现在将这两个方面分开可能更容易。需要注意的是,人工神经网络仅代表了生物大脑运作的近似方式。生物神经网络是一个更复杂的模型;然而,这不应该成为一个问题。人工神经网络仍然可以执行许多有用的任务,事实上,正如我们将在后面展示的那样,人工神经网络确实可以以我们希望的任何程度近似于输入到输出的任何函数。
神经网络的发展基于以下假设:
-
信息处理以其最简单的形式发生在称为神经元的简单元素上。
-
神经元之间相连并沿着连接链路交换信号。
-
神经元之间的连接可以更强或更弱,这决定了信息如何被处理。
-
每个神经元都有一个内部状态,该状态由来自其他神经元的所有传入连接确定。
-
每个神经元都有一个不同的活动函数,该函数是根据神经元的内部状态计算的,并确定其输出信号。
在下一节中,我们将详细定义神经元的工作原理以及它与其他神经元的交互方式。
神经元和层
什么是神经元?神经元是一个处理单元,接收一个输入值,并根据预定义的规则输出一个不同的值。

1943 年,Warren McCullock 和 Walter Pitts 发表了一篇文章(W. S. McCulloch 和 W. Pitts. A Logical Calculus of the Ideas Immanent in Nervous Activity, The Bulletin of Mathematical Biophysics, 5(4):115–133, 1943),其中描述了单个生物神经元的功能。生物神经元的组成部分包括树突、细胞体(细胞体)、轴突和突触间隙。在不同的名称下,这些也是人工神经元的组成部分。
树突将来自其他神经元的输入传递到细胞体,即神经元的主体。细胞体是输入被处理并汇总在一起的地方。如果输入超过一定的阈值,神经元将会“发射”,并传递一个单一的输出,该输出通过轴突以电信方式发送。在传输神经元的轴突和接收神经元的树突之间存在着介导化学脉冲的突触间隙,从而改变其频率。在人工神经网络中,我们通过一个数值权重来模拟频率:频率越高,脉冲越强,因此权重越高。然后,我们可以建立生物和人工神经元之间的等价表格(这是一个非常简化的描述,但适用于我们的目的):

生物神经元与人工神经元的示意对应关系
因此,我们可以如下示意地描述一个人工神经元:

图片的中心是神经元,或称为细胞体,它接收输入(激活)并设置神经元的内部状态,从而触发输出(活动函数)。输入来自其他神经元,并且通过权重(突触间隙)的强度来调节。
神经元的简单激活值由
给出,其中 xᵢ 是每个输入神经元的值,wᵢ 是神经元 i 与输出之间连接的值。在第一章中,我们在对神经网络的介绍中引入了偏差。如果我们包含偏差并希望使其存在明确化,我们可以将上述方程重写为
。偏差的效果是将由权重定义的超平面进行平移,使其不一定通过原点(因此得名)。我们应该将激活值解释为神经元的内部状态值。
如前一章所述,先前定义的激活值可以解释为向量w和向量x的点积。 如果<w,x> = 0,则向量x将与权重向量w垂直,因此所有满足<w,x> = 0的向量x在R^n 中定义一个超平面(其中 n 是x的维数)。
因此,任何满足<w,x> > 0的向量x都是在由w定义的超平面的一侧的向量。 因此,神经元是线性分类器,根据此规则,在输入高于一定阈值时激活,或者在几何上,在由权重向量定义的超平面的一侧时激活输入。

单个神经元是线性分类器
神经网络可以具有无限数量的神经元,但无论其数量如何,在传统网络中,所有神经元都将按层次排序。 输入层代表数据集,即初始条件。 例如,如果输入是灰度图像,则输入层由每个像素的输入神经元表示,其内部值为像素的强度。 但是,应该注意,输入层中的神经元不像其他神经元那样,因为它们的输出是恒定的,等于它们的内部状态的值,因此通常不计算输入层。 因此,1 层神经网络实际上是一个仅有一个层次的简单神经网络,即输出层之外的输入层。 我们从每个输入神经元绘制一条线连接到每个输出神经元,并且这个值由人工突触间隙中介,即连接输入神经元xi 到输出神经元yⱼ的权重w[i,j]。 通常,每个输出神经元代表一个类别,例如,在 MNIST 数据集的情况下,每个神经元代表一个数字。 因此,可以使用 1 层神经网络进行预测,例如,输入图像表示的是哪个数字。 实际上,输出值集合可以视为图像表示给定类别的概率的度量,因此具有最高值的输出神经元将代表神经网络的预测。
必须注意,同一层中的神经元永远不会彼此连接,如下图所示; 相反,它们都连接到下一层的每个神经元,依此类推:

1 层神经网络的示例:左侧的神经元代表具有偏差 b 的输入,中间列代表每个连接的权重,而右侧的神经元代表给定权重w的输出。
这是经典神经网络的必要和定义条件之一,即不存在层内连接,而神经元连接到相邻层中的每个神经元。在前面的图中,我们明确显示了神经元之间每个连接的权重,但通常连接神经元的边隐含地代表权重。1代表偏置单元,值为 1 的神经元与之前引入的偏置相等的连接权重。
如多次提到的,1 层神经网络只能对线性可分类进行分类;但是,没有任何东西可以阻止我们在输入和输出之间引入更多层。这些额外的层称为隐藏层。

显示的是一个具有两个隐藏层的 3 层神经网络。输入层有 k 个输入神经元,第一个隐藏层有 n 个隐藏神经元,第二个隐藏层有 m 个隐藏神经元。原则上,可以有任意多个隐藏层。在本例中,输出是两个类别,y₁和y₂。顶部的始终开启的偏置神经元。每个连接都有自己的权重 w(为简单起见未显示)。
不同类型的激活函数
从生物学角度来看,神经科学已经确定了数百,也许是上千种不同类型的神经元(参见大脑的未来,作者 Gary Marcus 和 Jeremy Freeman),因此我们应该能够模拟至少一些不同类型的人工神经元。这可以通过使用不同类型的活动函数来完成,即定义在神经元内部状态上的函数,表示为从所有输入神经元计算的激活值
。
活动函数是定义在a(x)上的函数,它定义了神经元的输出。最常用的活动函数包括:
-
:此函数允许激活值通过,并称为身份函数 -
:如果激活值高于某个特定值,则此函数激活神经元,称为阈值活动函数 -
:这个函数是最常用的之一,因为它的输出被限制在 0 和 1 之间,可以随机解释为神经元激活的概率,通常称为逻辑函数或逻辑 S 形函数。 -
:这个活动函数被称为双极 S 形函数,它简单地是一个逻辑 S 形函数重新缩放和平移,使其范围在(-1, 1)之间。 -
:这个活动函数被称为双曲正切函数。 -
:这个活动函数可能是与其生物学相似度最高的,它是标识和阈值函数的混合体,被称为整流器,或ReLU,如 Rectfied Linear Unit。
这些激活函数之间的主要区别是什么?通常,不同的激活函数对不同的问题效果更好。一般来说,标识活动函数或阈值函数,在神经网络的实现初始阶段广泛使用,例如 感知器 或 Adaline(自适应线性神经元),但最近逐渐失去了优势,转而使用 logistic sigmoid、双曲正切或 ReLU。虽然标识函数和阈值函数要简单得多,因此在计算机没有太多计算能力时是首选函数,但通常更倾向于使用非线性函数,例如 sigmoid 函数或 ReLU。还应该注意,如果我们只使用线性活动函数,那么添加额外的隐藏层就没有意义,因为线性函数的组合仍然只是一个线性函数。最后三个活动函数在以下方面有所不同:
-
它们的范围不同。
-
随着
x的增加,它们的梯度可能会消失。
当我们增加x时,梯度可能消失的事实以及为什么这很重要,稍后会更清楚;现在,我们只需提一下,函数的梯度(例如,导数)对神经网络的训练很重要。这类似于在线性回归示例中我们在第一章介绍的,我们试图最小化函数,沿着与其导数相反方向进行。
logistic 函数的范围是(0,1),这是这个函数被选择作为随机网络的首选函数的一个原因,即具有可能根据概率函数激活的神经元的网络。双曲函数与 logistic 函数非常相似,但其范围是(-1, 1)。相比之下,ReLU 的范围是(0,
),因此它可能具有非常大的输出。
然而,更重要的是,让我们看一下这三个函数的导数。对于一个 logistic 函数 f,其导数是 f * (1-f),而如果 f 是双曲正切,其导数是 (1+f) * (1-f)。
如果f是 ReLU,导数就简单得多,它简单地是
。
提示
让我们简要地看一下如何计算 logistic sigmoid 函数的导数。通过简单地注意到,相对于a的导数
函数是以下形式的快速计算:


当我们讨论反向传播时,我们会发现深层网络的一个问题是梯度消失(如之前提到的),而 ReLU 激活函数的优势在于导数是恒定的,当a变大时不趋于0。
通常,同一层中的所有神经元具有相同的激活函数,但不同的层可能具有不同的激活函数。但为什么神经网络多于 1 层深(2 层或更多)如此重要?正如我们所见,神经网络的重要性在于它们的预测能力,即能够逼近以输入为定义的函数与所需输出。存在一个定理,称为通用逼近定理,它指出在Rₙ的紧致子集上的任何连续函数都可以由至少有一个隐藏层的神经网络逼近。虽然该定理的正式证明过于复杂无法在此进行解释,但我们将尝试仅使用一些基本数学给出直观的解释,并且对此我们会使用逻辑 sigmoid 作为我们的激活函数。
逻辑 sigmoid 被定义为
其中
。现在假设我们只有一个神经元 x=x[i]:

左侧是一个标准 sigmoid,权重为 1,偏差为 0。中间是一个权重为 10 的 sigmoid,右侧是一个权重为 10 且偏差为 50 的 sigmoid。
然后很容易证明,如果w非常大,逻辑函数会接近一个阶梯函数。w越大,它就越像一个高度为 1 的 0 阶梯函数。另一方面,b只会平移函数,并且平移将等于比值b/w的负数。让我们称t = -b/w。
有了这个基础知识,现在让我们考虑一个简单的神经网络,其中有一个输入神经元和一个具有两个神经元的隐藏层,输出层只有一个输出神经元:

X 被映射到两个带有权重和偏差的隐藏神经元上,使得顶部隐藏神经元的比值 –b/w 是 t₁ 而底部隐藏神经元是 t₂。两个隐藏神经元都使用逻辑 sigmoid 激活函数。
输入 x 映射到两个神经元,一个具有权重和偏差,使得比例为t₁,另一个具有比例为t[2]的权重和偏差。然后这两个隐藏神经元可以映射到输出神经元,其权重分别为w和–w。如果对每个隐藏神经元应用逻辑 S 形活动函数,并对输出神经元应用恒等函数(没有偏差),我们将获得一个步函数,从t₁到t₂,高度为w,就像下图中所示的一样。由于类似图中的一系列步函数可以近似于R的紧致子集上的任何连续函数,这为什么普适逼近定理成立提供了直观(这是一个数学定理称为“简单函数逼近定理”的简化版本)。
多做一点努力,就可以推广到Rₙ。

生成上述图像的代码如下:
#The user can modify the values of the weight w
#as well as biasValue1 and biasValue2 to observe
#how this plots to different step functions
import numpy
import matplotlib.pyplot as plt
weightValue = 1000
#to be modified to change where the step function starts
biasValue1 = 5000
#to be modified to change where the step function ends
biasValue2 = -5000
plt.axis([-10, 10, -1, 10])
print ("The step function starts at {0} and ends at {1}"
.format(-biasValue1/weightValue,
-biasValue2/weightValue))
y1 = 1.0/(1.0 + numpy.exp(-weightValue*x - biasValue1))
y2 = 1.0/(1.0 + numpy.exp(-weightValue*x - biasValue2))
#to be modified to change the height of the step function
w = 7
y = y1*w-y2*w
plt.plot(x, y, lw=2, color='black')
plt.show()
反向传播算法
我们已经看到了神经网络是如何将输入映射到确定的输出,取决于固定的权重。一旦神经网络的架构被定义(前馈,隐藏层数量,每层神经元数量),以及一旦为每个神经元选择了活动函数,我们需要设置权重,这将定义网络中每个神经元的内部状态。我们将看到如何为 1 层网络设置这些权重,然后如何将其扩展到深度前馈网络。对于深度神经网络,用于设置权重的算法称为反向传播算法,我们将在本节中讨论和解释这个算法,因为这是多层前馈神经网络中最重要的主题之一。然而,我们将首先快速讨论一层神经网络的情况。
我们需要理解的一般概念是:每个神经网络都是函数的近似,因此每个神经网络都不等于期望的函数,而是会有一些差异。这个差异被称为误差,目标是最小化这个误差。由于误差是神经网络中的权重的函数,我们希望在权重方面最小化误差。误差函数是许多权重的函数;因此它是许多变量的函数。数学上,此函数为零的点集因此代表一个超曲面,为了在这个超曲面上找到最小值,我们需要选择一个点,然后沿着最小值方向跟随一条曲线。
线性回归
我们已经在第一章介绍了线性回归,但由于我们现在处理的是许多变量,为了简化事情,我们将引入矩阵表示法。让x是输入;我们可以将x视为一个向量。在线性回归的情况下,我们将考虑一个单输出神经元y;因此,权重w的集合是一个与x的维度相同的向量。然后,激活值被定义为内积<x, w>。
让我们假设对于每个输入值x,我们想要输出一个目标值t,而对于每个x,神经网络将输出一个值y,由选择的激活函数定义,在这种情况下,绝对值差异(y-t)表示预测值和特定输入示例x的实际值之间的差异。如果我们有m个输入值xᵢ,每个值都将有一个目标值tᵢ。在这种情况下,我们使用均方误差计算误差
,其中每个yᵢ是w的函数。因此,误差是w的函数,并且通常用J(w)表示。
如前所述,这表示了与w的维度相等的超曲面(我们隐式地也考虑了偏差),对于每个wⱼ,我们需要找到一个曲线,该曲线将导致表面的最小值。曲线沿特定方向增加的方向由其对该方向的导数给出,在这种情况下由以下公式给出:

为了朝着最小值移动,我们需要按照
设置的相反方向移动每个wⱼ。
让我们计算以下:

如果
,那么
,因此

提示
符号有时可能令人困惑,特别是第一次看到它时。输入由向量x^i 给出,其中上标表示第 i 个示例。由于x和w是向量,下标表示向量的jᵗh 坐标。y^i 然后表示给定输入x^i 的神经网络输出,而t^i 表示目标值,即与输入x[i]对应的期望值。
为了朝向最小值移动,我们需要将每个权重按其导数方向移动一小步长 l,称为学习速率,通常远小于 1,(例如 0.1 或更小)。因此,我们可以重新定义导数并将“2”合并到学习率中,以获得以下给出的更新规则:

或者,更一般地,我们可以将更新规则写成矩阵形式如下:

在这里,
(也称为 nabla)代表偏导数的向量。这个过程通常被称为梯度下降。
提示
是偏导数的向量。我们可以将对w的更新规则分别写为每个其分量wj,也可以用矩阵形式写出更新规则,其中,用
代替为每个j写出偏导数。
最后一点;更新可以在计算所有输入向量后进行,但在某些情况下,权重可以在每个示例之后或在一定预设数量的示例后进行更新。
逻辑回归
在逻辑回归中,输出不是连续的;相反,它被定义为一组类。在这种情况下,激活函数不会像之前那样是恒等函数,而是我们将使用逻辑 sigmoid 函数。正如我们之前看到的,逻辑 sigmoid 函数输出(0,1)中的实值,因此它可以被解释为概率函数,这也是为什么在 2 类分类问题中它可以运行得很好的原因。在这种情况下,目标可以是两个类别中的一个,而输出表示它是其中两个类别之一(比如t=1)的概率。
提示
再次,符号可能令人困惑。t是我们的目标,它可以在这个例子中有两个值。这两个值通常被定义为类别 0 和类别 1。这些值 0 和 1 不应与逻辑 sigmoid 函数的值混淆,后者是介于 0 和 1 之间的连续实值函数。sigmoid 函数的实际值表示输出属于类别 0 或类别 1 的概率。
如果a是之前定义的神经元激活值,让我们用 s(a)表示逻辑 sigmoid 函数,因此,对于每个示例 x,给定权重w时输出为类别y的概率是:

我们可以更简洁地将方程写为如下形式:

由于对每个样本x^i,概率P(t[i]|x[i], w)是独立的,全局概率如下:

如果我们取前述方程的自然对数(将乘积变为和),我们得到如下结果:

目标现在是最大化这个对数以获得预测正确结果的最高概率。通常,这是通过使用梯度下降最小化由J(w)= -log(P(y¦ x ,w))定义的损失函数J(w)来实现的,就像前面的情况一样。
与以前一样,我们计算相对于权重wⱼ的损失函数的导数,得到:


提示
要理解最后一个等式,让我们提醒读者以下事实:



因此,根据链式法则:

同样:

通常,在多类输出t的情况下,其中t是一个向量(t₁,*…, tₙ),我们可以使用
=
来推导出权重的更新方程:

这类似于我们对线性回归所看到的更新规则。
反向传播
在单层情况下,权重调整很容易,因为我们可以使用线性或逻辑回归,并同时调整权重以获得更小的错误(最小化成本函数)。对于多层神经网络,我们可以对连接最后隐藏层与输出层的权重使用类似的论证,因为我们知道输出层的期望值,但我们无法对隐藏层做同样的操作,因为预先我们并不知道隐藏层神经元的值应该是什么。相反,我们计算最后一个隐藏层的误差,并估计前一层的误差,从最后一层向第一层反向传播误差,因此得名反向传播。
反向传播是最难理解的算法之一,但所需的只是对基本微分和链式法则的一些知识。首先让我们引入一些符号。我们用J表示成本(误差),用y表示被定义为在激活值a上的活动函数(例如,y 可以是逻辑 Sigmoid),它是权重w和输入x的函数。让我们也定义w[i,j],i^(th)输入值和jth 输出之间的权重。在这里,我们比对 1 层网络更泛化地定义输入和输出:如果w[i,j]连接一个前馈网络中的连续两层,我们将"输入"称为第一层包含的神经元,"输出"称为第二层包含的神经元。为了不使符号过于繁重,并且不必指出每个神经元在哪一层,我们假设第i个输入yᵢ始终在包含第j^(th)输出 yⱼ的层之前的那一层。
提示
请注意,字母y既用于表示输入,也用于表示活动函数的输出。 yⱼ是下一层的输入,而 yⱼ是活动函数的输出,但它也是下一层的输入。因此,我们可以将 yⱼ's 视为 yⱼ's 的函数。
我们还使用下标i和j,其中我们始终将带有下标i的元素归属于包含下标j的元素的层之前的那一层。

图 10
在这个例子中,第 1 层表示输入,第 2 层表示输出,所以 w[i,j] 是连接一层中的 yⱼ 值和下一层中的 yⱼ 值的数值。
使用这个符号表示法和导数的链式法则,我们可以为我们神经网络的最后一层写出以下结果:

既然我们知道
,我们有以下结果:

如果 y 是之前定义的逻辑 S 型函数,我们将得到与前一节末尾已经计算过的相同结果,因为我们知道成本函数,我们可以计算所有的导数。
对于前面的层,相同的公式成立:

实际上,a j 是活动函数,我们知道,这是权重的一个函数。yⱼ 值,是“第二”层神经元的活动函数,是其激活值的函数,当然,成本函数是我们选择的活动函数的函数。
提示
即使我们有几个层,我们总是集中在连续层对中,因此,或许有点滥用符号,我们总是有一个“第一”层和一个“第二”层,就像图 10 中的那样,它是“输入”层和“输出”层。
既然我们知道
,并且我们知道
是我们可以计算的活动函数的导数,我们只需要计算导数
。让我们注意到,这是相对于“第二”层的激活函数的误差的导数,如果我们可以计算出最后一层的这个导数,并且有一个允许我们计算下一层的导数的公式,我们可以从最后一层开始计算所有导数并向后移动。
让我们注意到,正如我们通过 yⱼ 定义的那样,它们是“第二”层神经元的激活值,但它们也是活动函数,因此是第一层激活值的函数。因此,应用链式法则,我们有以下结果:

再次,我们可以计算
和
,因此一旦我们知道
,我们可以计算
,由于我们可以计算出最后一层的
,我们可以向后移动并计算任何一层的
,因此可以计算出任何一层的
。
总结一下,如果我们有一系列层,其中

然后我们有了这两个基本方程,第二个方程中的求和应该是对从y j到任何神经元
的传出连接的总和。


通过使用这两个方程,我们可以计算对每层成本的导数。
如果我们设置
,
表示成本对激活值的变化,我们可以将
看作是yⱼ神经元的误差。我们可以重写

这意味着
。这两个方程给出了看待反向传播的另一种方式,即成本对激活值的变化,并提供了一种计算这种变化的公式,以便我们知道了如何为以下层的任何层计算这种变化:


我们还可以组合这些方程并证明:

更新权重的反向传播算法然后在每一层上给出

在最后一节,我们将提供一个代码示例,以帮助理解和应用这些概念和公式。
工业应用
在上一章中,我们提到了一些机器学习应用的例子。神经网络,特别是具有相似应用的神经网络。我们将回顾一些应用程序,它们在 1980 年代末和 1990 年代初变得流行之后使用了这些应用程序,后者发现了反向传播,并且可以训练更深的神经网络。
信号处理
在信号处理领域,神经网络有许多应用。神经网络最早的应用之一是抑制电话线上的回声,特别是在 1957 年由伯纳德·威德罗和马西安·霍夫开发,特别是在洲际电话中。Adaline 使用恒等函数作为其训练的激活函数,并寻求最小化激活和目标值之间的均方误差。Adaline 经过训练,通过将输入信号应用于* Adaline (滤波器)和电话线来消除电话线上的回声。电话线输出和 Adaline *输出之间的差异是误差,用于训练网络并从信号中消除噪声(回声)。
医疗
该网络是由安德森于 1986 年开发的,其背后的思想是存储有关每种情况的症状、诊断和治疗信息的大量医疗记录。该网络经过训练,可以对不同症状的最佳诊断和治疗进行预测。
最近,IBM 利用深度神经网络开发了一个神经网络,可以预测可能的心脏衰竭,阅读医生的笔记,类似于经验丰富的心脏病专家。
自动驾驶汽车
1989 年,Nguyen 和 Widrow,以及 1990 年,Miller、Sutton 和 Werbos 开发了一个可以为大型拖车提供倒车到装货码头的方向指示的神经网络。神经网络由两个模块组成:第一个模块能够使用多层的神经网络计算新的位置,通过学习卡车对不同信号的反应。这个神经网络称为仿真器。第二个模块称为控制器,通过使用仿真器来了解其位置,学习给出正确的指令。近年来,自动驾驶汽车已经取得了巨大进步,并且已经成为现实,尽管更复杂的深度学习神经网络与来自摄像头、GPS、激光雷达和声纳单元的输入一起使用。
商业
1988 年,Collins、Ghosh 和 Scofield 开发了一个神经网络,可以用来评估是否应该批准和发放抵押贷款。利用抵押贷款评估员的数据,神经网络被训练来确定是否应该给予申请人贷款。输入是一些特征,如申请人的就业年限、收入水平、受抚养人数、财产的评估价值等等。
模式识别
我们已经多次讨论了这个问题。神经网络已经被应用的一个领域是字符识别。比如,这可以用于数字的识别,也可以用于手写邮政编码的识别。
语音产生
1986 年,Sejnowski 和 Rosenberg 提出了广为人知的 NETtalk 示例,通过阅读书面文字来产生口语。NETtalk 的要求是一组书面文字及其发音的示例。输入包括要发音的字母以及它前面和后面的字母(通常是三个),训练是使用最常见的单词及其语音转录进行的。在实现中,该网络首先学习识别元音和辅音,然后学习识别单词的开头和结尾。通常需要多次迭代才能使发音变得清晰,其进展有时类似于孩子学习如何发音单词。
一个用于 xor 函数的神经网络的代码示例
这是一个众所周知的事实,也是我们已经提到过的,即单层神经网络无法预测 XOR 函数。单层神经网络只能对线性可分集进行分类,然而,正如我们所见,通用逼近定理指出,一个具有足够复杂架构的 2 层网络可以近似任何函数。我们现在将创建一个隐藏层中具有两个神经元的神经网络,并演示如何模拟 XOR 函数。但是,我们将编写代码,让读者可以简单地修改它以允许任意数量的层和每层的神经元,以便读者可以尝试模拟不同的情景。我们还将使用双曲正切函数作为此网络的活动函数。为了训练网络,我们将实现之前讨论过的反向传播算法。
我们只需要导入一个库,numpy,尽管如果读者希望可视化结果,我们还建议导入matplotlib。因此,代码的前几行是:
import numpy
from matplotlib.colors import ListedColormap
import matplotlib.pyplot as plt
接下来我们定义我们的活动函数及其导数(在本示例中我们使用tanh(x)):
def tanh(x):
return (1.0 - numpy.exp(-2*x))/(1.0 + numpy.exp(-2*x))
def tanh_derivative(x):
return (1 + tanh(x))*(1 - tanh(x))
接下来我们定义NeuralNetwork类:
class NeuralNetwork:
为了遵循 Python 语法,NeuralNetwork类中的任何内容都必须缩进。我们定义了NeuralNetwork类的“构造函数”,即其变量,这在本例中将是神经网络的架构,即有多少层以及每层有多少个神经元,并且我们还将随机初始化权重为介于负 1 和正 1 之间。net_arch将是一个包含每层神经元数量的一维数组:例如[2,4,1]表示具有两个神经元的输入层,具有四个神经元的隐藏层和具有一个神经元的输出层。
由于我们正在研究 XOR 函数,因此对于输入层,我们需要有两个神经元,对于输出层,只需要一个神经元:
#net_arch consists of a list of integers, indicating
#the number of neurons in each layer, i.e. the network
#architecture
def __init__(self, net_arch):
self.activity = tanh
self.activity_derivative = tanh_derivative
self.layers = len(net_arch)
self.steps_per_epoch = 1000
self.arch = net_arch
self.weights = []
#range of weight values (-1,1)
for layer in range(self.layers - 1):
w = 2*numpy.random.rand(net_arch[layer] + 1, net_arch[layer+1]) - 1
self.weights.append(w)
在此代码中,我们已定义活动函数为双曲正切函数,并定义了其导数。我们还定义了每个时期应有多少个训练步骤。最后,我们初始化了权重,确保我们也初始化了稍后将添加的偏置的权重。接下来,我们需要定义fit函数,这个函数将训练我们的网络。在最后一行中,nn代表NeuralNetwork类,predict是我们稍后将定义的NeuralNetwork类中的函数:
#data is the set of all possible pairs of booleans
#True or False indicated by the integers 1 or 0
#labels is the result of the logical operation 'xor'
#on each of those input pairs
def fit(self, data, labels, learning_rate=0.1, epochs=100):
#Add bias units to the input layer
ones = numpy.ones((1, data.shape[0]))
Z = numpy.concatenate((ones.T, data), axis=1)
training = epochs*self.steps_per_epoch
for k in range(training):
if k % self.steps_per_epoch == 0:
print('epochs: {}'.format(k/self.steps_per_epoch))
for s in data:
print(s, nn.predict(s))
我们在这里所做的一切只是在输入数据中添加了一个“1”(始终开启的偏置神经元),并设置了代码以在每个时期结束时打印结果,以便跟踪我们的进度。我们现在将继续设置我们的前向传播:
sample = numpy.random.randint(data.shape[0])
y = [Z[sample]]
for i in range(len(self.weights)-1):
activation = numpy.dot(y[i], self.weights[i])
activity = self.activity(activation)
#add the bias for the next layer
activity = numpy.concatenate((numpy.ones(1),
numpy.array(activity)))
y.append(activity)
#last layer
activation = numpy.dot(y[-1], self.weights[-1])
activity = self.activity(activation)
y.append(activity)
我们将在每个步骤后更新我们的权重,因此我们随机选择其中一个输入数据点,然后设置前向传播,为每个神经元设置激活,然后在激活值上应用tanh(x)。由于我们有一个偏差,我们将偏差添加到我们的矩阵 y 中,该矩阵跟踪每个神经元的输出值。
现在我们进行错误的反向传播以调整权重:
#error for the output layer
error = labels[sample] - y[-1]
delta_vec = [error * self.activity_derivative(y[-1])]
#we need to begin from the back,
#from the next to last layer
for i in range(self.layers-2, 0, -1):
error = delta_vec[-1].dot(self.weights[i][1:].T)
error = error*self.activity_derivative(y[i][1:])
delta_vec.append(error)
#Now we need to set the values from back to front
delta_vec.reverse()
#Finally, we adjust the weights,
#using the backpropagation rules
for i in range(len(self.weights)):
layer = y[i].reshape(1, nn.arch[i]+1)
delta = delta_vec[i].reshape(1, nn.arch[i+1])
self.weights[i] +=learning_rate*layer.T.dot(delta)
这结束了我们的反向传播算法;我们所要做的只是编写一个预测函数来检查结果:
def predict(self, x):
val = numpy.concatenate((numpy.ones(1).T, numpy.array(x)))
for i in range(0, len(self.weights)):
val = self.activity(numpy.dot(val, self.weights[i]))
val = numpy.concatenate((numpy.ones(1).T,
numpy.array(val)))
return val[1]
在这一点上,我们只需要按照下面的主函数进行编写:
if __name__ == '__main__':
numpy.random.seed(0)
#Initialize the NeuralNetwork with
#2 input neurons
#2 hidden neurons
#1 output neuron
nn = NeuralNetwork([2,2,1])
X = numpy.array([[0, 0],
[0, 1],
[1, 0],
[1, 1]])
#Set the labels, the correct results for the xor operation
y = numpy.array([0, 1, 1, 0])
#Call the fit function and train the network
#for a chosen number of epochs
nn.fit(X, y, epochs=10)
print "Final prediction"
for s in X:
print(s, nn.predict(s))
注意numpy.random.seed(0)的用法。这只是为了确保权重初始化在不同运行中的一致性,以便比较结果,但对于神经网络的实现并不是必需的。
这结束了代码,结果应该是一个四维数组,例如:(0.003032173692499,0.9963860761357,0.9959034563937,0.0006386449217567),表明神经网络学会了输出应该是(0,1,1,0)。
读者可以略微修改我们之前在本书中使用的plot_decision_regions function中创建的代码,看看不同的神经网络如何根据所选择的架构区分不同的区域。
输出图片将如下图所示。圆代表(True,True)和(False,False)的输入,而三角形代表(True,False)和(False,True)的输入对于 XOR 函数。

同一图,左边是缩小的,右边是放大选择的输入。神经网络学会了分离这些点,创建了一个含有两个True输出值的带状区域。
不同的神经网络架构(例如,实现具有不同隐藏层中神经元数量的网络,或者具有不止一个隐藏层)可能产生不同的分离区域。为了实现这一点,读者只需要改变代码中的一行nn = NeuralNetwork([2,2,1]).。第一个2必须保留(输入不变),但可以修改第二个2以表示不同隐藏层中的神经元数量。添加另一个整数将添加一个带有所添加的整数指示的神经元数的新的隐藏层。最后的1不能修改。例如,([2,4,3,1])将表示一个 3 层神经网络,第一个隐藏层中有四个神经元,第二隐藏层中有三个神经元。
然后读者会发现,虽然解决方案是一样的,但是根据所选择的架构,分离区域的曲线会有所不同。实际上,选择nn = NeuralNetwork([2,4,3,1])将给出以下图形:

例如,选择nn = NeuralNetwork([2,4,1])会产生以下结果:

因此,神经网络的架构定义了神经网络解决手头问题的方式,不同的架构提供了不同的方法(尽管它们可能都会产生相同的结果),类似于人类思维过程可以沿着不同的路径达到相同的结论。我们现在准备更仔细地研究深度神经网络及其应用。
摘要
在本章中,我们详细介绍了神经网络,并提到了它们在与其他竞争算法相比的成功。神经网络由它们所属的“单元”或神经元,以及属于它们的连接或权重组成,这些权重表征了不同神经元之间通信的强度以及它们的活动函数,即神经元如何处理信息。我们讨论了如何创建不同的架构,以及神经网络如何可以具有许多层,以及为什么内部(隐藏)层很重要。我们解释了信息如何通过基于权重和定义的活动函数从输入流向输出,最后我们展示了如何定义一种称为反向传播的方法来“调整”权重以提高所需的准确性。我们还提到了许多神经网络被应用的领域。
在下一章中,我们将继续讨论深度神经网络,特别是我们将解释“深度”一词的含义,就像深度学习一样,我们将解释它不仅仅是指网络中隐藏层的数量,更重要的是指神经网络学习的质量。为此,我们将展示神经网络如何学习识别特征并将它们组合成识别对象的表示,这将为使用神经网络进行无监督学习打开大门。我们还将描述几个重要的深度学习库,最后,我们将提供一个具体的例子,说明我们如何应用神经网络进行数字识别。
第三章:深度学习基础
在第一章机器学习-简介中,我们介绍了机器学习及其一些应用,并简要讨论了一些可用于实现机器学习的算法和技术。在第二章神经网络中,我们专注于神经网络;我们已经表明 1 层网络太简单,只能处理线性问题,而且我们介绍了通用逼近定理,展示了只有一个隐层的 2 层神经网络能以任意程度逼近 R[n]的紧致子集上的任何连续函数。
在本章中,我们将介绍深度学习和深度神经网络,也就是至少有两个或更多个隐层的神经网络。读者可能会想知道为什么要使用多个隐层,考虑到通用逼近定理,并无不合理之处,因为在很长一段时间内使用的神经网络非常浅,只有一个隐层。答案是 2 层神经网络确实可以以任意程度逼近任何连续函数,然而,增加层次也增加了可能更加难以模拟的复杂性,并且可能需要更多的神经元来模拟浅层网络。还有另一个更重要的原因是深度学习的术语“深度”并不仅仅指网络的深度或神经网络的层数,而是指“学习”的水平。在深度学习中,神经网络不仅仅是学习在给定输入X的情况下预测输出Y,而且还能理解输入的基本特征。在深度学习中,神经网络能够对构成输入示例的特征进行抽象化,理解示例的基本特征,并根据这些特征进行预测。在深度学习中,存在其他基本机器学习算法或浅层神经网络中缺乏的抽象层面。
在本章中,我们将涵盖以下主题:
-
什么是深度学习?
-
深度学习的基本概念
-
深度学习的应用
-
GPU 与 CPU 对比
-
流行的开源库
什么是深度学习?
2012 年,Alex Krizhevsky,Ilya Sutskever 和 Geoff Hinton 在《神经信息处理系统》(NIPS)(2012)的《ImageNet 分类与深度卷积神经网络》一文中,写道:
"值得注意的是,如果移除一个卷积层,我们网络的性能会下降。例如,移除任何一个中间层都会导致网络的 top-1 性能损失约为 2%。所以深度确实对于实现我们的结果至关重要。"
在这个重要的论文中,他们明确提到了深度网络中隐藏层的数量的重要性。Krizheysky、Sutskever 和 Hilton 谈到了卷积层,我们将在第五章,图像识别中讨论它们,但基本问题仍然存在:这些隐藏层到底做什么?
一个典型的英语谚语是一张图片胜过千言万语。让我们使用这种方法来理解深度学习是什么。在 H. Lee、R. Grosse、R. Ranganath 和 A. Ng,用于可扩展无监督学习分层表示的卷积深度置信网络,发表于 2009 年国际机器学习会议(ICML)的论文中(参见web.eecs.umich.edu/~honglak/icml09-ConvolutionalDeepBeliefNetworks.pdf),作者使用了一些图片,我们在这里复制了一些。

在他们的例子中,他们展示了不同类别的对象和/或动物的神经网络图片,并且网络学习了每个类别的一些基本特征。例如,网络可以学习一些非常基本的形状,如线条或边缘,这些是每个类别都共有的。然而,在下一层,网络可以学习这些线条和边缘如何组合在一起,以使每个类别的图像具有眼睛或车轮等特征。这类似于人类视觉皮层的工作方式,我们的大脑从简单的线条和边缘开始越来越复杂地识别特征。

深度神经网络中的隐藏层也是通过逐层理解更加复杂的特征来工作的。如果我们想要定义什么是一个人脸,我们需要定义它的部分:眼睛、鼻子、嘴巴,然后我们需要上升到更高的层次,并定义它们相对于彼此的位置:两只眼睛在顶部的中间位置,处于同样的高度,鼻子在中间,嘴巴在下方中间位置,位于鼻子下方。深度神经网络通过自我学习来捕捉这些特征,首先学习图像的组成部分,然后学习它们的相对位置等等,就像在图像 1 和图像 2 中,我们可以看到深层抽象在每一层中的作用。一些深度学习网络实际上可以被看作是生成算法,例如受限玻尔兹曼机(RBMs),而不仅仅是一个预测算法,因为它们学会生成一个信号,然后根据已学习的生成假设进行预测。随着我们在本章中逐步深入,我们将使这个概念更加清晰。
基本概念
1801 年,约瑟夫·玛丽·查尔斯发明了雅卡尔织布机。查尔斯(Charles)并非科学家,而是一个简简单单的商人,雅卡尔织布机以他的名字命名。雅卡尔织布机使用一组打孔卡片,每个打孔卡片代表了需要在织布机上复制的图案。每个打孔卡片都代表了一个设计的抽象,一个图案的抽象,每个打孔卡片都是该图案的一个抽象表示。打孔卡片之后被用在其他地方,例如赫尔曼·荷里歇在 1890 年发明的制表机中,或者在最早的计算机中,它们被用来输入机器的代码。然而,在制表机中,打孔卡片只是对样本的抽象,用于计算人口的统计数据。而在雅卡尔织布机中,打孔卡片的使用更加微妙;在其中,每个卡片都代表一个图案的抽象,然后与其他卡片组合在一起,形成更复杂的图案。打孔卡片是对现实特征的一个抽象表示,最终成为织物的设计。
在某种程度上,Jacquard 织机具有使得深度学习成为今天的样子的种子:通过其特征的表示来定义现实。在深度学习中,神经网络不仅仅是识别使猫成为猫,松鼠成为松鼠的因素,而是了解猫中存在哪些特征,松鼠中存在哪些特征,并且学会使用这些特征设计猫或松鼠。如果我们要使用 Jacquard 织机设计成猫形状的编织图案,我们需要使用带有猫鼻子上胡子的打孔卡,以及优雅修长的身体。相反,如果我们要设计松鼠,我们需要使用制作毛茸茸尾巴的打孔卡,例如。一个学会基本输出表示的深度网络可以使用它所做出的假设进行分类;因此,如果没有毛茸茸的尾巴,它可能不会是松鼠,而更可能是猫。这有许多含义,正如我们将看到的那样,最重要的是,网络学到的信息量更加完整和稳健。通过学习生成模型(在技术术语中通过学习联合概率p(x,y)而不仅仅是p(y|x),网络对噪声的敏感性大大降低,并且它学会了识别即使场景中存在其他物体或物体部分被遮挡的图像。最激动人心的部分是,深度神经网络自动学会做到这一点。
特征学习
Ising 模型是物理学家威廉·伦兹在 1920 年发明的,他把它作为问题交给了他的学生恩斯特·伊辛。该模型由可以处于两种状态(正或负)的离散变量组成,代表磁偶极子。
在第四章《无监督特征学习》中,我们将介绍限制玻尔兹曼机和自动编码器,并且我们将开始更深入地了解如何构建多层神经网络。迄今为止我们看到的神经网络类型都具有前馈架构,但我们将看到我们可以定义具有反馈环路的网络以帮助调整定义神经网络的权重。虽然 Ising 模型并不直接用于深度学习,但它是一个很好的物理示例,帮助我们理解调整深度神经结构的基本内部工作方式,包括限制玻尔兹曼机,特别是帮助我们理解表示的概念。
这一节我们将讨论的是对 Ising 模型进行的简单调整(和简化),以适应深度学习。在第二章 神经网络中,我们已经讨论了调整神经元之间连接权重的重要性。实际上,神经网络中的权重使网络学习。给定一个输入(固定),这个输入传播到下一层,并根据它们之间连接的权重设置下一层神经元的内部状态。然后,这些神经元会发射信号,并通过新的连接将信息传递到下一层,这些连接由新的权重定义,依此类推。权重是网络的唯一变量,它们使网络学习。通常情况下,如果我们的激活函数是一个简单的阈值函数,一个较大的正权重会倾向于使两个神经元一起激活。所谓一起激活,我们指的是,如果一个神经元激活,且连接权重很高,则另一个神经元也会激活(因为输入乘以较大的连接权重很可能会超过选择的阈值)。事实上,1949 年,在他的行为的组织中,唐纳德·赫布(s-f-walker.org.uk/pubsebooks/pdfs/The_Organization_of_Behavior-Donald_O._Hebb.pdf)提出了相反的观点也是真的。唐纳德·赫布是一位加拿大心理学家,生活在 20 世纪,他提出了以他的名字命名的规则,赫布规则,该规则指出当神经元一起激活时,它们的连接加强;当它们不一起激活时,它们的连接减弱。
在下面的示例中,我们将 Ising 模型看作是一种以二进制方式运作的神经元网络,即它们只能激活(发射)或不激活,并且,它们的相对连接越强,它们一起激活的可能性就越大。我们假设网络是随机的,因此如果两个神经元之间连接很强,它们只有很大的可能性一起激活。
小贴士
随机意味着概率性。在随机网络中,我们定义神经元激活的概率:概率越高,神经元激活的可能性就越大。当两个神经元之间的连接很强时,即它们之间连接的权重很大时,一个神经元激活将引起另一个神经元也激活的概率非常高(反之亦然,弱连接会导致低概率)。然而,神经元只会根据概率激活,因此我们无法确定它是否会激活。
另一方面,如果它们呈反相关(具有较大的负权重),它们非常可能不会一起激活。让我们举些例子:

在第一张图中,前两个神经元处于活跃状态,并且它们与第三个神经元的连接很大且为正,因此第三个神经元也将处于活跃状态。在第二张图中,前两个神经元处于关闭状态,并且它们与第三个神经元的连接为正,因此第三个神经元也将处于关闭状态。
在第二张图中,前两个神经元处于关闭状态,并且它们与第三个神经元的连接为正,因此第三个神经元也将处于关闭状态。
可能会出现几种组合;我们将只展示其中几种。想法是第一层神经元的状态将以概率方式确定后续层神经元的状态,取决于连接的符号和强度。如果连接较弱,则后续层中连接的神经元可能以任何状态相等或几乎相等的概率存在。但如果连接非常强,则权重的符号将使连接的神经元以类似或相反的方式运作。当然,如果第二层的神经元具有超过一个神经元作为其输入,我们将像往常一样加权所有输入连接。如果输入神经元并非全部处于开启或关闭状态,并且它们的连接同样强,则连接的神经元可能以相等或几乎相等的概率处于开启或关闭状态。

在第一张图中,前两个神经元处于活跃状态,并且它们与第三个神经元的连接很大且为负,因此第三个神经元也将处于关闭状态。在第二张图中,前两个神经元处于关闭状态,并且它们与第三个神经元的连接很大且为负,因此第三个神经元很可能处于打开状态。
接下来很明显,要最有可能地确定下一层神经元的状态,第一层神经元应该都处于相似的状态(开或关)并且都与强连接(即,较大的权重)连接。让我们看更多的例子:

在第一张图中,前两个神经元处于活跃状态,并且它们与第三个神经元的连接很大但方向相反,因此第三个神经元可能同样有可能处于开启或关闭状态。在第二张图中,前两个神经元一个处于开启状态,一个处于关闭状态,并且它们与第三个神经元的连接都很大且为正,因此第三个神经元同样有可能处于开启或关闭状态。在最后一张图中,前两个神经元处于活跃状态,但它们与第三个神经元的连接很小,因此第三个神经元更有可能处于开启状态,但它也有相当高的几率处于关闭状态。
引入这种 Ising 模型的改编的目的是理解深度神经网络中的表示学习是如何工作的。我们已经看到,设置正确的权重可以使神经网络打开或关闭某些神经元,或者一般地影响它们的输出。然而,将神经元描绘成只有两种状态,有助于我们直观地理解神经网络中发生的事情。在二维平面中表示我们的网络层对我们的直观和视觉描述也有所帮助,而不是表示为一维层。让我们把我们的神经网络层想象成二维平面。然后我们可以想象每个神经元代表了二维图像上的像素,而“开”状态的神经元代表了白色平面上的黑点,而“关”状态的神经元则与白色背景融为一体(不可见)。我们的开/关状态的输入层可以被看作一个简单的二维黑白图像。比如,假设我们想要表示一个笑脸,或者一个悲伤的脸——我们只需激活正确的神经元,就可以得到以下图形:

一个快乐的脸和一个悲伤的脸:区别在于嘴角的几个神经元,可能是开或关状态。
现在假设这对应于输入层,因此此层将连接到另一层,即隐藏层之一。然后,这幅图像中的每个像素(无论是黑色还是白色)与下一层的每个神经元之间都会有连接。特别是,每个黑色(开)像素将连接到下一层的每个神经元。现在假设每个使左眼的神经元的连接具有强(大正权重)连接到隐藏层中的特定像素,但与隐藏层中的其他任何神经元都有大的负连接:

左边是一个笑脸,右边是相同的笑脸和其左眼与隐藏神经元之间的连接。
这意味着,如果我们在隐藏层和左眼之间设置大的正权重,以及左眼与任何其他隐藏神经元之间的大的负连接,每当我们向网络展示一个包含左眼的脸时(这意味着那些神经元处于开状态),这个特定的隐藏神经元将激活,而所有其他神经元往往会保持关闭。这意味着这个特定的神经元将能够检测左眼是否存在。我们也可以类似地创建右眼、鼻子和嘴巴主要部分之间的连接,这样我们就可以开始检测所有这些面部特征。

每个脸部特征, 眼睛、鼻子和嘴巴, 都与某些隐藏神经元具有大的正连接, 但与其他神经元具有大的负连接。
这展示了我们如何为我们的连接选择权重,让隐藏神经元开始识别输入的特征。
提示
作为一个重要的提醒,我们想向读者指出,事实上,我们并没有选择权重来开始识别输入的特征。相反,这些权重是由网络使用反向传播或其他调整方法自动选择的。
另外,我们可以拥有更多的隐藏层,它们可以识别特征的特征(我们脸上的嘴是笑着的还是悲伤的?),因此可以得到更精确的结果。
深度学习有几个优点。第一个优点就像我们所见,它可以识别特征。另一个更重要的优点是,它会自动识别特征。在这个例子中,我们自己设置了权重以识别我们选择的特征。这是许多机器学习算法的缺点之一,用户必须使用自己的经验来选择他/她认为最好的特征。因此,需要大量的时间来进行特征选择,这仍然需要人类来执行。相反,深度学习算法会自动选择最佳特征。正如我们在前一章中所见,这可以通过反向传播来完成,但事实上,还存在其他技术来选择这些权重,这些将是下一章将要讨论的重要点,如自动编码器和受限玻尔兹曼机(或哈蒙尼姆,1986 年由保罗·斯莫伦斯基发明)。然而,我们还要提醒读者,我们从自动特征选择中获得的优势必须付出这样的代价,即我们需要选择正确的神经网络结构。
在一些深度学习系统中(例如在受限玻尔兹曼机中,正如我们将在下一章中看到的那样),神经网络还可以学会“修复”自己。正如我们在前面的例子中提到的,我们可以通过激活我们分别与右/左眼、鼻子和嘴相联系的四个神经元来产生一个通用的面孔。由于它们与前一层之间的连接权重较大,这些神经元将被激活,我们将激活与这些特征对应的神经元,从而生成一个通用的面孔图像。同时,如果与面部对应的神经元被激活,那么眼睛、鼻子和嘴的四个对应神经元也将被激活。这意味着,即使没有所有定义面部的神经元都处于开启状态,如果连接足够强大,它们仍然可能激活四个对应的神经元,进而激活面部缺失的神经元。
这还有一个额外的优势:鲁棒性。人类视觉在视图部分被遮挡时也能识别物体。我们甚至可以在对方戴帽子或遮住嘴巴的围巾时认出人;我们对图像中的噪声不敏感。同样地,当我们创建这种对应关系时,如果我们稍微改变面部,比如通过微调嘴巴一两个像素,信号仍然足够强大,可以打开“嘴巴”神经元,这将打开正确的像素并关闭组成修改后眼睛的错误像素。这个系统对噪声不敏感,并且可以进行自动修正。
比如说,嘴巴有一对像素关闭(在图中那些带有x的像素)。

这幅图有一对构成嘴巴的像素没有打开。
然而,嘴巴可能仍然有足够数量的神经元在正确的位置上,可以打开代表它的对应神经元:

即使一对神经元关闭,与其他神经元的连接足够强大,下一层代表嘴巴的神经元仍然会打开。
另一方面,我们现在可以沿着连接逆向传播,每当代表嘴巴的神经元打开时,这将打开组成嘴巴的所有神经元,包括以前关闭的两个神经元:

这两个神经元被顶部的神经元激活。
总之,深度学习相对于许多其他机器学习算法,特别是浅层神经网络的优势有:
-
深度学习可以学习表示
-
深度学习对噪声不太敏感。
-
深度学习可以是一种生成算法(在下一章中会更详细介绍)
为了进一步理解为什么许多隐藏层可能是必要的,让我们考虑识别一个简单几何图形,一个立方体的任务。假设 3D 中的每条可能的线与一个神经元相关联(让我们暂时忘记这将需要无限多的神经元)。

同一视野上的每一条线都与不同的神经元相关联。
如果我们限制自己只看一个眼睛,我们视野中不同角度的线条将投影到二维平面上的同一条线上。因此,我们看到的每条线都可以由任何对应的三维线条给出,这些三维线条投影到视网膜上的同一条线上。假设任何可能的三维线条都与一个神经元相关联。因此,构成立方体的两条不同线条各自与一个神经元族相关联。然而,这两条线相交的事实允许我们连接属于不同族的两个神经元。我们对于构成立方体一条边的线有许多神经元,对于构成立方体另一条边的线也有许多神经元,但因为这两条线相交,有两个神经元会被连接。同样,每条线也连接到构成立方体的其他线条,使我们能够进一步重新定义我们的表示。在更高的层次上,我们的神经网络还可以开始识别这些线不是以任意角度相连,而是以确切的 90 度角相连。通过这种方式,我们可以制作越来越抽象的表示,从而使我们能够将在一张纸上画出的线条组识别为一个立方体。
不同层中的神经元按层次结构组织,并表示图像中基本元素及其结构的不同抽象水平。这个玩具例子显示,每个层次在抽象系统中都可以将下层的不同神经元联系在一起,建立它们之间的连接,类似于我们如何在抽象线条之间建立连接。它可以利用这些连接意识到这些抽象线条在一个点上相连,在更高层次上,实际上以 90 度相连并组成一个立方体,就像我们描述如何通过识别眼睛、鼻子和嘴巴及其相对位置来学习识别脸部的方式一样。

每条线都与一个神经元相关联,通过关联表示相交的线条来创建基本表示,通过关联表示特定角度的线条来创建更复杂的表示。
深度学习算法
在前面的段落中,我们对深度学习进行了直观的介绍。在本节中,我们将对下一章节中将彻底介绍的关键概念给出更精确的定义。具有许多层的深度神经网络也有存在的生物学原因:通过我们对人类理解语音的研究,实际上已经清楚地表明,我们天生具有一种分层的层次结构,它将听到的声音输入转化为语言水平。类似地,视觉系统和视觉皮层具有类似的分层结构,从 V1(或条纹皮层)到大脑中的 V2、V3 和 V4 视觉区域。深度神经网络模仿了我们大脑的本质,尽管以非常原始的方式。然而,我们应该警告读者,尽管理解我们的大脑可以帮助我们创建更好的人工神经网络,但最终,我们可能正在创建一种完全不同的架构,就像我们通过模仿鸟类创建了飞机,但最终得到了一个非常不同的模型。
在第二章 神经网络 中,我们介绍了反向传播算法作为一种流行的训练算法。在实践中,当我们有许多层时,反向传播可能是一种缓慢且难以使用的算法。事实上,反向传播主要是基于函数的梯度,而局部最小值的存在往往会阻止该方法的收敛。然而,深度学习这个术语适用于一类可能使用不同训练算法和权重调整的深度神经网络算法,它们不限于反向传播和经典的前馈神经网络。因此,我们应更加普遍地将深度学习定义为一类机器学习技术,其中信息在分层层次中进行处理,以便在逐渐增加的复杂性水平上理解数据的表示和特征。在这类算法中,我们通常可以包括:
-
多层感知器(MLP):具有许多隐藏层的神经网络,采用前馈传播。正如讨论的那样,这是深度学习网络的第一个示例,但不是唯一可能的示例。
-
玻尔兹曼机(BM):具有明确定义的能量函数的随机对称网络。
-
受限玻尔兹曼机(RBM):与上面的伊辛模型示例类似,受限玻尔兹曼机由两层之间的对称连接组成,一个是可见层,一个是隐藏层,但与一般玻尔兹曼机不同,神经元之间没有层内连接。它们可以堆叠在一起形成 DBN。
-
深度信念网络(DBN):一种随机生成模型,其中顶层之间具有对称连接(与前馈网络不同,是无向的),而底层通过来自上面层的定向连接接收来自处理后的信息。
-
自动编码器:一类无监督学习算法,其输出形状与输入相同,这使得网络能够更好地学习基本表示。
-
卷积神经网络(CNN):卷积层通过将滤波器应用于输入图像(或声音),通过在传入信号上滑动此滤波器来生成二维激活图。CNN 允许增强输入中隐藏的特征。
每种深度学习实现都有其优缺点,它们的训练难度取决于每层的层数和神经元数量。虽然简单的前馈深度神经网络通常可以使用第二章讨论的反向传播算法进行训练,但对于其他类型的网络存在不同的技术,这将在下一章中进一步讨论。
深度学习应用
在接下来的几段中,我们将讨论深度神经网络在语音识别和计算机视觉领域的应用,以及近年来它们在这两个领域的应用如何通过完全超越许多其他不基于深度神经网络的机器学习算法而大大提高了准确性。
语音识别
深度学习开始在本年代(2010 年及以后,例如 2012 年一篇标题为Deep Neural Networks for Acoustic Modeling in Speech Recognition的文章,由 Hinton 等人撰写,可在static.googleusercontent.com/media/research.google.com/en//pubs/archive/38131.pdf在线获取)中用于语音识别;在此之前,语音识别方法主要由称为 GMM-HMM 方法(具有高斯混合发射的隐马尔可夫模型)的算法主导。理解语音是一个复杂的任务,因为语音并不像天真地认为的那样,由清晰分隔开的单词组成,它们之间有明确的边界。实际上,语音中没有真正可辨识的部分,也没有清晰的单词边界。在组合单词时研究声音时,我们经常看到所谓的三音素,它们由三个区域组成,其中第一部分取决于前一个声音,中间部分通常是稳定的,下一个声音取决于后一个声音。此外,通常最好只检测三音素的部分,这些检测器称为 senones。
在《语音识别中的深度神经网络用于声学建模》中,对当时最先进的模型和作者采用的模型进行了几次比较,该模型由五个隐藏层组成,每层 2048 个单元。第一次比较是使用必应语音搜索应用程序,在 24 小时的训练数据上实现了 69.6%的准确率,而使用传统方法,名为 GMM-HMM 模型,在相同的训练数据上实现了 63.8%的准确率。该模型还在 Switchboard 语音识别任务上进行了测试,这是一个公共语音转文本转录基准(类似于用于数字识别的 MNIST 数据集),包括来自美国各地约 500 位发言者的大约 2500 次对话。此外,还使用了 Google 语音输入语音、YouTube 数据和英语广播新闻语音数据进行了测试和比较。在下一个表格中,我们总结了该文章的结果,显示了 DNN 与 GMM-HMM 的错误率对比。
| 任务 | 训练数据总小时数 | DNN(错误率) | 具有相同训练的 GMM-HMM(错误率) | 具有更长训练的 GMM-HMM(错误率) |
|---|---|---|---|---|
| Switchboard(测试 1) | 309 | 18.5 | 27.4 | 18.6(2000 小时) |
| Switchboard(测试 2) | 309 | 16.1 | 23.6 | 17.1(2000 小时) |
| 英语广播新闻 | 50 | 17.5 | 18.8 | |
| 必应语音搜索 | 24 | 30.4 | 36.2 | |
| Google 语音 | 5870 | 12.3 | 16.0(>>5870 小时) | |
| YouTube | 1400 | 47.6 | 52.3 |
另一篇文章,《语音识别和相关应用的新型深度神经网络学习方法概述》,由邓、欣顿和金斯伯里(www.microsoft.com/en-us/research/publication/new-types-of-deep-neural-network-learning-for-speech-recognition-and-related-applications-an-overview/)撰写,作者们也注意到深度神经网络在嘈杂语音方面表现出色。
DNN 的另一个优点是在 DNN 出现之前,人们必须创建语音声谱图的变换。声谱图是信号中频率的视觉表示。通过使用 DNN,这些神经网络可以自主自动地选择原始特征,本例中以原始谱特征表示。使用卷积和池化等技术,可以应用于这种原始谱特征,以应对说话者之间的典型语音变化。近年来,更加复杂的具有循环连接的神经网络(RNN)取得了巨大成功(A. Graves、A. Mohamed 和 G. Hinton,《Speech Recognition with Deep Recurrent Neural Networks》发表于国际会议 Acoustic Speech and Signal Processing(ICASSP) (2013);参见 www.cs.toronto.edu/~fritz/absps/RNN13.pdf),例如,一种特定类型的深度神经网络称为 LSTM(长短期记忆神经网络),将在后面的章节中描述。
在第二章 神经网络 中,我们讨论了不同的活动函数,尽管逻辑 S 形函数和双曲正切函数通常是最为人知的,但它们往往训练速度较慢。最近,ReLU 活动函数在语音识别中取得了成功应用,例如 G. Dahl、T. Sainath 和 G. Hinton 在 Improving Deep Neural Networks for LVCSR Using Rectified Linear Units and Dropout 中提到的文章,发表于国际会议 Acoustics Speech and Signal Processing (ICASSP) (2013) (www.cs.toronto.edu/~gdahl/papers/reluDropoutBN_icassp2013.pdf)。在第五章 图像识别 中,我们还将提及“Dropout”的含义,正如这篇论文中所讨论的(也在其标题中提到)。
对象识别和分类
这可能是深度神经网络取得成功并得到最好记录和理解的领域。就像语音识别一样,DNN 能够自动发现基本表示和特征。此外,手工选择的特征通常只能捕捉低级边缘信息,而 DNN 能够捕捉到更高级的表示,比如边缘交叉点。在 2012 年,来自 ImageNet 大规模视觉识别比赛的结果(结果可在image-net.org/challenges/LSVRC/2012/results.html上找到)显示,由 Alex Krizhevsky,Ilya Sutskever 和 Geoff Hinton 组成的获胜团队使用了一个拥有 6000 万参数和 650,000 个神经元的大型网络,其中包括五个卷积层和紧随其后的最大池化层,以 16.4%的错误率击败了第二名团队的 26.2%的错误率。卷积层和最大池化层将是第五章,图像识别的焦点。这是一个巨大而令人印象深刻的成果,这一突破性的结果引发了当前神经网络的复兴。作者们使用了许多新颖的方法来通过结合卷积网络、GPU 的使用以及一些技巧,比如放弃方法和使用 ReLU 活性函数代替 Sigmoid 来帮助学习过程。
该网络是使用 GPU 进行训练的(我们将在下一节讨论 GPU 的优势),并展示了大量标记数据可以极大地提高深度学习神经网络的性能,大大超越了图像识别和计算机视觉的更传统方法。鉴于深度学习中卷积层的成功,Zeiler 和 Fergus 在两篇文章中(M. Zeiler 和 R. Fergus,用于深度卷积神经网络的随机池化正则化,国际学习代表大会(ICLR) , 2013 年(www.matthewzeiler.com/pubs/iclr2013/iclr2013.pdf)和 M. Zeiler 和 R. Fergus,视觉化和理解卷积网络,arXiv:1311.2901, 页面 1-11, 2013 年,(www.matthewzeiler.com/pubs/arxive2013/arxive2013.pdf)试图了解为什么在深度学习中使用卷积网络效果如此好,以及网络学到了哪些表示。Zeiler 和 Fergus 试图通过映射回他们的神经活动来可视化中间层捕捉到的内容。他们为每个层创建了一个反卷积网络,将其环绕回输入的图像像素。

图片来源于 M. Zeiler 和 R. Fergus,《视觉化和理解卷积网络》。
文章展示了正在揭示的特征,其中第二层显示了角落和边缘,第三层显示了不同的网格图案,第四层显示了狗脸和鸟腿,而第五层显示了整个对象。

图像取自 M. Zeiler 和 R. Fergus,《可视化和理解卷积网络》
深度学习也可以通过使用包含 RBM 和自编码器的网络进行无监督学习。在 Q. Le、M. Ranzato、M. Devin、G. Corrado、K. Chen、J. Dean 和 A. Ng 的一篇文章中,使用大规模无监督学习构建高级特征,在国际机器学习大会(ICML)论文集中,作者使用了一个 9 层的自编码器网络,拥有十亿个连接,训练了来自互联网的 1000 万张图像。无监督特征学习使系统能够被训练以识别是否包含人脸的图像,而不需要告知。在文章中,作者表明:
"通过完全未标记的数据,可以训练神经元对高级概念进行选择性训练……通过对 YouTube 视频的随机帧进行训练,神经元可以成为面部、人体和猫脸的检测器……从这些表示开始,我们在 ImageNet 上的对象识别准确率达到了 15.8%,其中包括 20,000 个类别,相对于最先进技术的 70%的显著提高。"
GPU 与 CPU
如今深度学习受欢迎的一个原因是GPU(图形处理单元)的处理能力大幅增加。从架构上看,CPU(中央处理单元)由几个核心组成,每次只能处理几个线程,而 GPU 由数百个核心组成,可以同时处理数千个线程。与主要是串行单元的 CPU 相比,GPU 是高度可并行化的单元。
DNN 由几层组成,每一层的神经元的行为方式相同。此外,我们已经讨论了每个神经元的活动值是如何计算的,或者,如果用矩阵形式表示,我们有a = wx,其中a和x是向量,w 是矩阵。在整个网络中,所有激活值都是以相同的方式计算的。CPU 和 GPU 具有不同的架构,特别是它们的优化方式不同:CPU 是延迟优化的,而 GPU 是带宽优化的。在具有许多层和大量神经元的深度神经网络中,带宽成为瓶颈,而不是延迟,这就是为什么 GPU 性能如此出色的原因。此外,GPU 的 L1 缓存比 CPU 的 L1 缓存速度快得多,而且也更大。
L1 缓存表示程序下一步可能要使用的信息的内存,并存储这些数据可以加快处理速度。在深度神经网络中,许多内存会被重复使用,这就是为什么 L1 缓存内存很重要的原因。使用 GPU,你可以让你的程序的速度比单纯使用 CPU 快上一个数量级,并且这种加速也是近年来在使用深度神经网络进行语音和图像处理方面取得的许多进展背后的原因,这种计算能力的增加在十年前是不可用的。

除了在 DNN 训练 方面更快外,GPU 在运行 DNN 推理 时也更有效率。推理是我们部署经过训练的 DNN 的后训练阶段。在 GPU 供应商 Nvidia 发布的一份名为基于 GPU 的深度学习推理:性能和功耗分析的白皮书中,对 AlexNet 网络(具有多个卷积层的 DNN)使用 GPU 和 CPU 的效率进行了比较,并在以下表格中总结了结果,该白皮书可在线获取:www.nvidia.com/content/tegra/embedded-systems/pdf/jetson_tx1_whitepaper.pdf。
| 网络:AlexNet | 批大小 | Tegra X1(FP32) | Tegra X1(FP16) | Core i7 6700K(FP32) |
|---|---|---|---|---|
| 推理性能 | 1 | 47 img/sec | 67 img/sec | 62 img/sec |
| 功耗 | 5.5 W | 5.1 W | 49.7 W | |
| 性能/瓦特 | 8.6 img/sec/W | 13.1 img/sec/W | 1.3 img/sec/W | |
| 推理性能 | 128 (Tegra X1)48 (Core i7) | 155 img/sec | 258 img/sec | 242 img/sec |
| 功耗 | 6.0 W | 5.7 W | 62.5 W | |
| 性能/瓦特 | 25.8 img/sec/W | 45 img/sec/W | 3.9 img/sec/W |
结果表明,在 Tegra X1 上的推理可以比基于 CPU 的推理节能一个数量级,同时实现可比较的性能水平。
直接编写访问 GPU 而不是 CPU 的代码并不容易,但这就是为什么大多数流行的开源库(如 Theano 或 TensorFlow)允许你简单地在代码中打开一个简单的开关来使用 GPU 而不是 CPU。使用这些库不需要编写专门的代码,但如果可用,同样的代码可以在 CPU 和 GPU 上运行。开关取决于开源库,但通常可以通过设置确定的环境变量或创建一个特定的资源(.rc)文件来完成,该文件由所选择的特定开源库使用。
流行的开源库——简介
有几个开源库可用,允许在 Python 中创建深度神经网络,而无需显式地从头编写代码。最常用的是:Keras、Theano、TensorFlow、Caffe 和 Torch。在本书中,我们将提供使用前三个库的示例,这些库都可以在 Python 中使用。这样做的原因是 Torch 不基于 Python,而是基于一种称为 Lua 的不同语言,而 Caffe 主要用于图像识别。对于这些库,我们将快速描述如何打开我们在前一段讨论中讨论的 GPU 开关。然后,本书中的大部分代码都可以在 CPU 或 GPU 上运行,这取决于读者可用的硬件。
Theano
Theano(deeplearning.net/software/theano/)是一个用 Python 编写的开源库,实现了许多使编写神经网络代码变得容易的功能。此外,Theano 也可以很容易地利用 GPU 加速和性能。不深入讨论 Theano 如何工作的细节,Theano 使用符号变量和函数。在许多真正吸引人的功能中,Theano 允许我们通过为我们计算所有导数来很容易地使用反向传播。
正如前面提到的,Theano 也可以很容易地利用您计算机上的 GPU。有很多方法可以做到这一点,但最简单的方法是创建一个名为.theanorc的资源文件,并包含以下几行:
[global]
device = gpu
floatX = float32
您可以通过简单地输入以下命令来检查 Theano 是否配置为使用您的 GPU:
print(theano.config.device)
我们参考 Theano 文档来学习如何使用 Theano 的第一步,并且我们将在本书中使用 Theano 实现一些深度学习的测试代码示例。
TensorFlow
TensorFlow(www.tensorflow.org)与 Theano 非常相似,在 TensorFlow 中,计算也表示为图。因此,TensorFlow 图就是对计算的描述。在 TensorFlow 中,您不需要显式地要求使用 GPU,而是 TensorFlow 将自动尝试使用您的 GPU(如果有的话),但是如果您有多个 GPU,则必须显式地将操作分配给每个 GPU,否则只会使用第一个。要做到这一点,您只需键入以下行:
with tensorflow.device("/gpu:1"):
在这里,可以定义以下设备:
-
"/cpu:0":您的计算机的主 CPU -
"/gpu:0":如果存在的话,您计算机的第一个 GPU -
"/gpu:1":如果存在的话,您计算机的第二个 GPU -
"/gpu:2":如果存在的话,您计算机的第三个 GPU,以此类推
再次强调,我们参考 TensorFlow 文档来学习如何使用 TensorFlow 的第一步,并且测试使用 TensorFlow 的代码示例将在本书中实现。
Keras
Keras (keras.io) 是一个可以在 Theano 或 TensorFlow 上运行的神经网络 Python 库,尽管默认情况下会使用 TensorFlow 运行。在线提供了 keras.io/backend/ 的说明。Keras 可以在 CPU 或 GPU 上运行,如果你在 Theano 上运行它,你将需要像之前描述的那样设置一个 .theanorc 文件。Keras 允许不同的方式创建深度神经网络,它通过使用 model 来使其变得简单。主要类型的 model 是 Sequential model,它创建了一个线性堆叠的层。然后你可以通过简单调用 add 函数来新增层。在接下来的部分中,我们将使用 Keras 创建一些示例。Keras 可以通过以下简单命令轻松安装:
pip install Keras
也可以通过从其 Git 存储库派生然后在上面运行设置来安装它:
git clone https://github.com/fchollet/keras.git
cd keras
python setup.py install
然而,我们建议读者查阅在线文档以获取更多信息。
使用 Keras 的深度神经网络示例代码
在本节中,我们将介绍一些简单的代码,使用 Keras 对使用流行数据集 MNIST 正确分类数字。MNIST 是一个包含许多不同人手写数字的数据集,共有 70,000 个示例。通常,前 60,000 个用于训练,剩下的 10,000 个用于测试。

从 MNIST 数据集中获取的数字示例
Keras 的一个优点是它可以为你导入这个数据集,而无需显式从网络上下载它(Keras 会为你下载)。这可以通过一行简单的代码实现:
from keras.datasets import mnist
我们需要从 Keras 导入一些类来使用经典的深度神经网络,它们是:
from keras.models import Sequential
from keras.layers.core import Dense, Activation
from keras.utils import np_utils
我们现在准备开始编写导入数据的代码,我们可以用一行代码完成:
(X_train, Y_train), (X_test, Y_test) = mnist.load_data()
这导入了训练数据和测试数据;此外,这两个数据集被分为两个子集:一个包含实际图像,另一个包含标签。我们需要稍微修改数据以便使用它。事实上,X_train 和 X_test 数据包括了 60000 个小的 (28,28) 像素图像,但我们想将每个样本重新塑造为一个 784 像素长的向量,而不是一个 (28,28) 的二维矩阵。这可以通过以下两行轻松实现:
X_train = X_train.reshape(60000, 784)
X_test = X_test.reshape(10000, 784)
同样地,标签指示了图像所描述的数字的值,我们希望将其转换为一个包含全部零值和仅在对应于该数字的条目中有一个 1 的 10-entry 向量,因此例如 4 被映射为 [0, 0, 0, 0, 1, 0, 0, 0, 0, 0]。
classes = 10
Y_train = np_utils.to_categorical(Y_train, classes)
Y_test = np_utils.to_categorical(Y_test, classes)
最后,在调用我们的主函数之前,我们只需设置我们的输入大小(mnist 图像的大小)、隐藏层有多少个隐藏神经元、我们想要尝试我们网络的时期数量以及训练的批次大小:
input_size = 784
batch_size = 100
hidden_neurons = 100
epochs = 15
main(X_train, X_test, Y_train, Y_test)
现在我们已经准备好为我们的主函数编写代码了。Keras 通过定义一个模型来工作,我们将使用Sequential模型,然后添加层(在这种情况下,我们将使用常规的* dense *,而不是稀疏层)指定输入和输出神经元的数量。对于每一层,我们指定其神经元的活动函数:
model = Sequential()
model.add(Dense(hidden_neurons, input_dim=input_size))
model.add(Activation('sigmoid'))
model.add(Dense(classes, input_dim=hidden_neurons))
model.add(Activation('softmax'))
现在,Keras 提供了一种简单的方法来指定成本函数(loss)及其优化(训练速率、动量等)。我们不打算修改默认值,因此我们可以简单地传递:
model.compile(loss='categorical_crossentropy', metrics=['accuracy'], optimizer='sgd')
在这个例子中,优化器是sgd,代表随机梯度下降。在这一点上,我们需要训练网络,这与 scikit-learn 类似,通过调用fit函数完成。我们将使用 verbose 参数,以便可以跟踪这个过程:
model.fit(X_train, Y_train, batch_size=batch_size, nb_epoch=epochs, verbose=1)
唯一剩下的事情是添加代码来评估我们的网络在测试数据上的表现并打印准确率结果,这很简单:
score = model.evaluate(X_test, Y_test, verbose=1)
print('Test accuracy:', score[1])
这就是全部。现在可以运行了。测试准确率大约为 94%,这不是一个很好的结果,但这个例子在 CPU 上运行时间不到 30 秒,是一个非常简单的实现。有一些简单的改进可以做,比如选择更多的隐藏神经元或选择更多的 epochs,我们把这些简单的改变留给读者自己去熟悉代码。
Keras 还允许我们查看它创建的权重矩阵。要做到这一点,只需键入以下行:
weights = model.layers[0].get_weights()
通过在我们之前的代码中添加以下行,我们可以看看隐藏神经元学到了什么:
import matplotlib.pyplot as plt
import matplotlib.cm as cm
w = weights[0].T
for neuron in range(hidden_neurons):
plt.imshow(numpy.reshape(w[neuron], (28, 28)), cmap = cm.Greys_r)
plt.show()
为了得到更清晰的图像,我们将 epochs 的数量增加到 100,得到以下的图形:

所有隐藏神经元学到的内容组成的复合图形
为了简单起见,我们将每个神经元的所有图像聚合到一个单独的图形中,表示所有神经元的复合图形。显然,由于初始图像非常小且没有很多细节(它们只是数字),隐藏神经元学到的特征并不是很有趣,但已经清楚每个神经元都学到了不同的“形状”。
上面的绘图代码应该立即清晰明了;我们只注意到以下行正在导入cm:
import matplotlib.cm as cm
这只是允许对神经元进行灰度表示,它在imshow()调用中使用,通过传递选项cmap = cm.Greys_r。这是因为mnist图像不是彩色图像,而是灰度图像。
Keras 的美妙之处在于它很容易创建神经网络,但也很容易下载测试数据集。让我们尝试使用cifar10数据集而不是mnist数据集。cifar10数据集不是数字,而是由 10 类对象组成:飞机、汽车、鸟类、猫、鹿、狗、青蛙、马、船和卡车。要使用cifar10数据集,只需写:
from keras.datasets import cifar10
在前面的代码行的位置:
from keras.datasets import mnist
然后,我们需要对上面编写的代码进行以下更改:
(X_train, Y_train), (X_test, Y_test) = cifar10.load_data()
X_train = X_train.reshape(50000, 3072)
X_test = X_test.reshape(10000, 3072)
input_size = 3072
这是因为训练图像仅有 50,000 张(而不是 60,000 张),并且图像是彩色(RGB)32 x 32 像素图像,因此它们的大小是 3 x 32 x 32。就目前而言,我们可以保持其他一切不变,但是,如果我们运行这个示例,我们会发现我们的性能现在非常差,只有大约 20%。这是因为数据更加复杂,需要更复杂的神经网络。事实上,大多数用于图像分类的神经网络都使用一些基本的卷积层,这将在第五章中讨论,图像识别,然而,现在我们可以尝试将隐藏神经元数提高到 3,000,并添加一个包含 2,000 个神经元的第二个隐藏层。我们还将在第一个隐藏层中使用 ReLU 激活函数。
要做到这一点,我们只需要写下以下定义模型的行,而不是之前的内容:
model = Sequential()
model.add(Dense(3000, input_dim=input_size))
model.add(Activation('sigmoid'))
model.add(Dense(2000, input_dim=3000))
model.add(Activation('sigmoid'))
model.add(Dense(classes, input_dim=2000))
model.add(Activation('softmax'))
如果我们运行这段代码,我们会发现训练时间要长得多,但是最后,我们的训练集准确率约为 60%,而测试数据的准确率只有约 50%。与较简单的mnist数据集相比,尽管网络更大,训练时间更长,但准确率要低得多,这是由于数据的复杂性更高。此外,通过将适配网络的行替换为以下行:
model.fit(X_train, Y_train, batch_size=batch_size, nb_epoch=epochs, validation_split=0.1, verbose=1)
我们还可以在过程中输出训练数据分割为 90/10 的准确性如何提高。这也表明,尽管训练的准确性在训练过程中不断提高,但验证集的准确性在某一点上会达到饱和,表明网络开始过拟合并饱和一些参数。
尽管这可能看起来是深度网络在更丰富的数据集上无法提供良好准确性的失败,但事实上,我们将会看到,实际上有一些方法可以解决这个问题,让我们能够在更复杂更大的数据集上获得更好的性能。
摘要
在本章中,我们引导读者理解了深度学习的概念以及它与深度神经网络的关系。我们还讨论了除了经典的前馈实现之外,还存在许多不同的深度神经网络实现,并讨论了深度学习在许多标准分类任务上取得的最新成功。本章充满了从 Jacquard 织布机到伊辛模型的概念和想法,通过示例和历史评论进行了发展。这只是一个开始,我们将在许多示例中解释并更准确地发展本章介绍的思想。
我们将在接下来的章节开始这个过程,并向读者介绍许多我们在本章中涉及的概念,比如 RBM 和自编码器,以及清楚地展示我们如何创建比简单的前馈 DNN 更强大的深度神经网络。此外,在这些特定的神经网络中,表示和特征的概念如何自然地产生也将变得清晰。从上一个例子,使用 cifar10 数据集,可以清楚地看出经典的前馈 DNN 很难在更复杂的数据集上进行训练,我们需要更好的方法来设置权重参数。X. Glorot 和 Y. Bengio 在其论文《Understanding the difficulty of training deep feed-forward neural networks》中探讨了使用梯度下降来训练具有随机权重初始化的深度神经网络的性能不佳的问题。下一章将介绍并讨论可以成功训练深度神经网络的新算法。
第四章:无监督特征学习
深度神经网络能够成功的一个原因是能够学习数据中实体(特征)的正确表示,而不需要(太多)人类和领域知识。
理论上,神经网络能够直接消耗原始数据,并通过隐藏的中间表示将输入层映射到所需的输出。传统的机器学习技术主要专注于最终映射,假定“特征工程”的任务已经完成。
特征工程是利用现有的领域知识创建智能数据表示的过程,以便它可以被机器学习算法处理。
Andrew Yan-Tak Ng 是斯坦福大学的教授,也是机器学习和人工智能领域最著名的研究者之一。他在出版物和讲话中描述了传统机器学习在解决实际问题时的局限性。
使机器学习系统正常工作最困难的部分是找到正确的特征表示:
提出特征是困难的,耗时的,需要专业知识。在应用学习应用程序时,我们花费了大量时间调整特征。
安德鲁·吴,机器学习和人工智能通过大脑模拟,斯坦福大学
让我们假设我们正在将图片分类为几个类别,例如动物与车辆。原始数据是图像中的像素矩阵。如果我们直接在逻辑回归或决策树中使用这些像素,我们将为可能适用于给定的训练样本的每一张图片创建规则(或关联权重),但这将非常难以概括到相同图片的轻微变化。换句话说,假设我的决策树发现有五个重要的像素,它们的亮度(假设我们只显示黑白色调)可以确定大多数训练数据被分成两类--动物和车辆。相同的照片,如果裁剪、移位、旋转或重新着色,将不再遵循以前的那些规则。因此,模型可能会对它们进行随机分类。主要原因是我们正在考虑的特征太弱而不稳定。然而,我们可以首先预处理数据,以便提取这样的特征:
-
图片是否包含对称的,像车轮一样的形状?
-
它是否包含把手或方向盘?
-
它是否包含腿或头?
-
它是否有两只眼睛的脸?
在这种情况下,决策规则会非常容易和强大,如下所示:


需要多少努力才能提取这些相关特征?
由于我们没有把手检测器,我们可以尝试手动设计特征来捕捉图片的一些统计特性,例如,在不同的图片象限中找到不同方向的边缘。我们需要找到比像素更好的图像表示方法。
而且,强大和显著的特征通常是由先前提取的特征层次结构制成的。我们可以在第一步开始提取边缘,然后取得生成的“边缘向量”,并将它们组合起来识别物体部分,比如眼睛、鼻子、嘴巴,而不是光、镜子或者扰流板。最终的物体部分可以再次组合成对象模型;例如,两只眼睛,一只鼻子和一张嘴巴形成一张脸,或者两个车轮、一个座椅和一个把手形成一辆摩托车。整个检测算法可以以以下方式简化:


通过递归应用稀疏特征,我们设法获得更高级的特征。这就是为什么你需要比浅层算法更深的神经网络架构。单个网络可以学习如何从一个表示转移到另一个,但是将它们堆叠在一起将使整个端到端的工作流能够实现。
不过,真正的威力并不仅在于层次结构。重要的是要注意到,到目前为止我们只使用了无标签数据。我们通过对数据本身进行逆向工程来学习隐藏的结构,而不是依赖于手动标记的样本。监督学习仅表示最终的分类步骤,我们需要将其分配到车辆类别或动物类别。所有先前的步骤都是以无监督的方式执行的。
我们将在以下第五章中看到如何为图片执行特定的特征提取,图像识别。在本章中,我们将着重介绍学习任何类型数据(例如时间信号、文本或一般的属性向量)的特征表示的一般方法。
为此,我们将介绍两种最强大且广泛使用的无监督特征学习架构:自动编码器和受限波尔兹曼机。
自动编码器
自动编码器是用于无监督学习的对称网络,其中输出单元连接回输入单元:

H2O 训练手册中的自动编码器简单表示 (https://github.com/h2oai/h2o-training-book/blob/master/hands-on_training/images/autoencoder.png)
输出层的大小与输入层相同,因为它的目的是重构自己的输入,而不是预测一个依赖目标值。
这些网络的目标是通过编码层 Φ 充当压缩滤波器,将输入向量 X 适合到较小的潜在表示(编码) c,然后解码层 Φ 试图将其重构回 X':

损失函数是重构误差,它将迫使网络找到训练数据的最有效的紧凑表示,同时最小化信息损失。对于数值输入,损失函数可以是均方误差:

如果输入数据不是数值型,而是表示为比特向量或多项分布的向量,我们可以使用重构的交叉熵:

这里,d 是输入向量的维度。
网络的中央层(编码)是数据的压缩表示。我们实际上将一个 n 维数组转换为一个较小的 m 维数组,其中 m < n。这个过程与使用主成分分析(PCA)进行降维非常相似。PCA 将输入矩阵分成正交轴(称为分量),以便您可以通过在这些轴上投影原始点来重构原始矩阵的近似值。通过按重要性对它们进行排序,我们可以提取出前 m 个组件,这些组件可以被视为原始数据的高级特征。
例如,在多元高斯分布中,我们可以将每个点表示为两个正交分量上的坐标,这两个分量描述了数据中可能的最大方差:

一个样本散点图,按照以(1,3)为中心,(0.866, 0.5)方向上标准差为 3,在正交方向上标准差为 1 的多元(双变量)高斯分布进行分布。这些方向表示与样本相关联的主成分(PC)。由 Nicoguaro(自己的作品)CC BY 4.0 (http://creativecommons.org/licenses/by/4.0),通过维基媒体公共领域。
PCA 的局限性在于它只允许对数据进行线性变换,这并不总是足够的。
自编码器的优势在于可以使用非线性激活函数表示非线性表示。
自编码器的一个著名示例是 MITCHELL 在他的书 机器学习 中给出的。在这个例子中,我们有一个数据集,其中包含八个分类对象,用八个相互排斥的比特标记的二进制编码。网络将学习一个仅具有三个隐藏节点的紧凑表示:

Tom Mitchell 的自编码器示例。
通过应用正确的激活函数,学习到的紧凑表示与三比特二进制表示完全对应。
然而,在某些情况下,仅仅单个隐藏层不足以表示数据的整个复杂性和变异性。更深的架构可以学习输入和隐藏层之间更复杂的关系。然后,网络能够学习潜在特征并利用这些特征来最好地表示数据中的非平凡信息组成部分。
通过连接两个对称网络获得深度自动编码器,通常由最多五个浅层组成:

自动编码器的示意结构,具有 3 个完全连接的隐藏层(https://en.wikipedia.org/wiki/Autoencoder#/media/File:Autoencoder_structure.png)
深度自动编码器可以学习新的潜在表示,将先前学到的表示组合起来,以便每个隐藏级别可以被视为原始数据的某种压缩层次表示。然后,我们可以使用编码网络的代码或任何其他隐藏层作为描述输入向量的有效特征。
网络设计
在构建深度神经网络时,最常见的问题可能是:我们如何选择隐藏层的数量和每个层的神经元数量?此外,我们使用哪种激活和损失函数?
没有确定的答案。经验方法包括运行一系列试验和错误或标准网格搜索,其中深度和每个层的大小简单地被定义为调整超参数。我们将看一些设计准则。
对于自动编码器,问题略有简化。由于自动编码器有许多变体,我们将定义通用用例的指南。请记住,每个变体都将有其自己的规则需要考虑。我们可以建议以下内容:
-
输出层的大小与输入完全相同。
-
网络大多数情况下是对称的。拥有不对称网络意味着编码器和解码器函数的不同复杂性。除非有特殊原因,通常没有对称网络的优势。但是,您可以决定共享相同的权重或者决定在编码和解码网络中具有不同的权重。
-
在编码阶段,隐藏层比输入小,这种情况下,我们称之为“欠完备自动编码器”。多层编码器逐渐减小表示大小。隐藏层的大小通常最多是前一个的一半。如果数据输入层有 100 个节点,那么一个合理的架构可能是 100-40-20-40-100。比输入更大的层将导致没有任何压缩,这意味着不会学习到有趣的模式。我们将在正则化部分看到,这种约束在稀疏自动编码器的情况下并非必要。
-
中间层(代码)起着重要作用。在特征减少的情况下,我们可以将其保持较小,并且等于 2、3 或 4,以便允许高效的数据可视化。在堆叠的自编码器的情况下,我们应该将其设置得更大,因为它将代表下一个编码器的输入层。
-
在二进制输入的情况下,我们希望使用 sigmoid 作为输出激活函数,使用交叉熵,更确切地说,使用伯努利交叉熵的总和,作为损失函数。
-
对于实值,我们可以使用线性激活函数(ReLU 或 softmax)作为输出,并且使用均方误差(MSE)作为损失函数。
-
对于不同类型的输入数据(
x)和输出u,您可以遵循一般方法,其中包括以下步骤:-
找到观察到 x 的概率分布,给定
u,P(x/u) -
找到
u和隐藏层 h(x)之间的关系 -
使用
![网络设计]()
-
-
在深层网络(具有多个隐藏层)的情况下,为了不使编码器和解码器的复杂性失衡,使用相同的激活函数。
-
如果我们在整个网络中使用线性激活函数,我们将近似于 PCA 的行为。
-
除非是二进制的,否则最好对您的数据进行高斯缩放(0 均值和单位标准差),并且最好将输入值保留为 0 或 1。分类数据可以使用带有虚拟变量的独热编码来表示。
-
激活函数如下:
-
ReLU 通常是大多数神经网络的默认选择。由于其拓扑结构,自编码器可能会受益于对称激活函数。由于 ReLU 往往过拟合,因此在与正则化技术(如 dropout)结合时更受欢迎。
-
如果您的数据是二进制的或者可以缩放到[0,1]的范围内,则可能会使用 sigmoid 激活函数。如果您对输入分类数据使用了独热编码,则最好使用 ReLU。
-
双曲正切(tanh)是在梯度下降情况下进行计算优化的不错选择。由于数据将围绕 0 中心化,导数将更高。另一个效果是减少梯度中的偏差,正如《高效的反向传播》一文中所解释的那样(
yann.lecun.com/exdb/publis/pdf/lecun-98b.pdf)。
![网络设计]()
深度神经网络常用的不同激活函数
-
自编码器的正则化技术
在之前的章节中,我们已经看到了不同形式的正则化,例如 L1,L2,提前停止和 dropout。在本节中,我们将描述一些专门为自编码器量身定制的几种流行技术。
到目前为止,我们一直把自动编码器描述为"欠完备",这意味着隐藏层比输入层小。这是因为拥有更大的层根本没有任何压缩。隐藏单元可能只是精确复制输入并将精确复制作为输出返回。
另一方面,拥有更多的隐藏单元将使我们有更多的自由学习智能表示。
我们将看到如何用三种方法解决这个问题:去噪自动编码器,压缩自动编码器和稀疏自动编码器。
Denoising 自动编码器
想法是我们想训练我们的模型学习如何重建输入数据的嘈杂版本。
我们将使用 x 表示原始输入,
表示带有噪音的输入,
表示重建的输出。
带有噪声的输入,
,是通过随机分配输入
的子集为 0,概率为𝑝,再加上具有方差v的加性各向同性高斯噪声而生成的数值输入。
然后我们将有两个新的超参数要调整??和
,它们代表噪音水平。
我们将使用带噪声的变体,
,作为网络的输入,但损失函数仍然是输出
与原始无噪声输入
之间的误差。如果输入维度是d,编码函数f,解码函数g,我们将把损失函数j写成这样:

这里,L是重构误差,通常是均方误差或交叉熵。
有了这种变体,如果一个隐藏单元试图精确复制输入值,那么输出层就无法完全信任,因为它知道这可能是噪音而不是原始输入。我们正在强迫模型基于其他输入单元之间的相互关系来重建数据的有意义结构。
我们期望的是添加的噪声越大,在每个隐藏单元上应用的滤波器就越大。所谓的滤波器是指针对提取特定特征而激活的原始输入的部分。如果没有噪音,隐藏单元倾向于提取输入数据的一个小子集,并将其作为最不触及的版本提供给下一层。通过向单元添加噪声,对坏重构
的错误惩罚将迫使网络保留更多信息,以便在可能存在噪音的情况下对特征进行上下文化。
请注意,只需添加一个小的白噪声就相当于使用权重衰减正则化。权重衰减是一种技术,它在每个训练时期将权重乘以小于 1 的因子,以便限制模型中的自由参数。虽然这是一种常用的神经网络正则化技术,但通过将输入设置为 0 的概率p,我们实际上实现了完全不同的结果。
我们不希望获得高频滤波器,这些滤波器组合在一起会给出更广义的模型。我们的去噪方法生成代表潜在数据结构的独特特征并具有独立含义的滤波器。
收缩自编码器
收缩自编码器旨在通过明确添加一个惩罚项来实现类似于去噪方法的目标,当模型试图学习无趣的变化并且仅促进在训练集中观察到的那些变化时,它就会受到惩罚。
换句话说,模型可能会试图通过产生代表训练数据中并非必然存在的变化的滤波器来逼近恒等函数。
我们可以将这种敏感性表示为所提取特征对输入维度的所有偏导数的平方和。
对于由编码函数f映射到大小为d[h]的隐藏表示h的维度为x的输入,以下数量对应于编码器激活的雅可比矩阵的 L2 范数(Frobenius):

损失函数将修改如下:

在这里,λ是正则化因子。很容易看出,雅可比的 Frobenius 范数在线性编码器的情况下对应于 L2 权重衰减。主要的区别在于对于线性情况,实现收缩的唯一方法是保持权重非常小。在 sigmoid 非线性的情况下,我们还可以推动隐藏单元进入饱和状态。
让我们分析这两个术语。
误差J(MSE 或交叉熵)推动保留尽可能多的信息以完美重建原始值。
处罚推动了摆脱所有这些信息,使得隐藏单元对X的导数最小化。大值意味着所学到的表示对于输入变化太不稳定。当我们观察到输入值变化时,所观察到的隐藏表示几乎没有变化时,我们得到一个小的值。在这些导数限制为 0 的情况下,我们只保留了相对于输入X不变的信息。我们实际上摆脱了所有不够稳定且对微小扰动过于敏感的隐藏特征。
假设我们的输入是同一数据的许多变化。在图像的情况下,它们可能是同一主题的小旋转或不同曝光。在网络流量的情况下,它们可能是同一类型流量的数据包头部的增加/减少,可能是由于包装/解包协议。
如果我们只看这个维度,模型很可能会非常敏感。雅可比项将惩罚高敏感性,但它会被低重构误差所抵消。
在这种情况下,我们会有一个单位,对变化方向非常敏感,但对所有其他方向并不是很有用。例如,在图片的情况下,我们仍然拥有相同的主题;因此,所有其余的输入值都是常数。如果我们在训练数据中没有观察到给定方向的变化,我们希望丢弃该特征。
H2O 目前不支持收缩自编码器;但是,可以在0xdata.atlassian.net/browse/PUBDEV-1265找到一个未解决的问题。
稀疏自编码器
自编码器,截至目前我们所见的,隐藏层始终小于输入。
主要原因是否则,网络将具有足够的能力只需记忆输入并完美地重构它。向网络添加额外的容量只会是多余的。
减少网络的容量会迫使基于输入的压缩版本进行学习。算法将不得不选择最相关的特征,以帮助更好地重构训练数据。
然而,有些情况下压缩是不可行的。让我们考虑每个输入节点由独立随机变量形成的情况。如果变量彼此不相关,则实现压缩的唯一方法是完全摆脱其中一些。我们实际上正在模拟 PCA 的行为。
为了解决这个问题,我们可以在隐藏单元上设置一个稀疏约束。我们将尝试推动每个神经元大部分时间处于不活跃状态,这对于 sigmoid 和 ReLU 来说意味着激活函数的输出接近于 0,对于 tanh 来说是-1。
如果我们称呼隐藏单元
在输入为
时的激活为
,我们可以如下定义隐藏单元
的平均激活:

在这里,
是我们的训练数据集(或训练数据批次)的大小。
稀疏性约束包括强制
,其中
是稀疏参数,在区间[1,0]内且理想情况下足够接近 0。
原始论文(web.stanford.edu/class/cs294a/sparseAutoencoder.pdf)建议值接近 0.05。
我们将每个隐藏单元的平均激活值建模为具有均值
的伯努利随机变量,并且我们希望所有这些都趋向于具有均值
的伯努利分布。
为了实现这一点,我们需要添加一个额外的惩罚项,用于量化这两个分布之间的差异。我们可以根据我们希望实现的实际分布
和理论分布
之间的Kullback-Leibler(KL)散度来定义这个惩罚。
通常情况下,对于离散概率分布P和Q,以比特为单位测量信息时,KL散度定义如下:

其中一个要求是P对Q绝对连续,即对于任意可测的值P都满足
。这也可以写成
。每当
时,该项的贡献将是
,因为那时的
。
在我们的案例中,单元j的稀疏自编码器散度如下:

当两个平均值相等且单调递增时,此函数的性质是
,否则直到
接近 8 时,
会像这样增加。

或 1。
最终带有额外惩罚项的损失函数如下:

在这里,J是标准损失函数(均方根误差),
是隐藏单元的数量,ß是稀疏项的权重。
这个额外的惩罚将导致反向传播算法出现一些小的低效。特别是,前述公式在计算每个示例的反向传播之前,将需要经过整个训练集进行额外的前向步骤来预先计算平均激活值
。
自编码器总结
自编码器是强大的无监督学习算法,在异常检测或特征工程等领域越来越受欢迎,使用中间层的输出作为特征来训练监督模型,而不是使用原始输入数据。
无监督意味着在训练过程中不需要指定标签或地面真相。只要网络有足够的能力学习和表示内在的存在关系,它们就可以处理输入的任何数据。这意味着我们可以设定编码层的大小(减少的维度m),但根据隐藏层的数量和大小来获得不同的结果。
如果我们正在构建一个自动编码器网络,我们希望在避免错误表示的同时实现稳健性,但同时不要通过较小的顺序层压缩信息来限制网络的容量。
除噪声、收缩和自动编码器都是解决这些问题的很好的技术。
添加噪声通常更简单,而且不会在损失函数中增加复杂性,这会导致更少的计算。另一方面,嘈杂的输入使梯度变得不稳定,并且为了获得更好的特征而丢弃部分信息。
收缩自动编码器非常擅长使模型对训练分布的小偏差更加稳定。因此,它是减少误报的一个很好的选择。缺点是一种反效果,它会增加重构误差以减少敏感性。
稀疏自动编码器可能是最完整的解决方案。对于大型数据集来说,它计算成本最高,但由于梯度是确定的,它可以在二阶优化器的情况下提供很好的稳定性和低重构误差。
不管你做出什么选择,采用正则化技术都是强烈推荐的。它们都带有超参数需调整,我们将在相应的Tuning部分中看到如何优化。
除了迄今为止描述的技术外,值得一提的是变分自动编码器,它似乎是正则化自动编码器的最终解决方案。变分自动编码器属于生成模型类别。它不仅学习了最好地描述训练数据的结构,还学习了潜在单位高斯分布的参数,这些参数可以最好地再现输入数据。最终的损失函数将是重构误差和重构的潜在变量之间的 KL 散度的总和。编码器阶段将生成由均值和标准差向量组成的代码。从代码中,我们可以表征潜在分布参数,并通过从该分布中采样重构原始输入。
受限玻尔兹曼机
-
在 90 年代初,神经网络基本上已经过时。机器学习研究的大部分内容是关于其他技术,如随机森林和支持向量机。只有一个隐藏层的神经网络表现不如这些其他技术,而且人们认为训练更深的神经网络太困难。
-
兴趣再次高涨于神经网络,由 2004 年由Geoffrey Hinton领导的研究团队率先使用受限玻尔兹曼机(RBM)取得一系列突破,创造了具有多层的神经网络;他们将这种方法称为深度学习。在 10 年内,深度学习从一种小众技术发展到主导每一个人工智能竞赛。RBM 是这一巨大突破的一部分,使得 Hinton 和其他人在多种图像和语音识别问题上取得世界纪录成绩。
-
在这一部分中,我们将研究 RBM 的工作原理,如何实现它们以及如何将它们结合成深度信念网络。
一台受限玻尔兹曼机看起来很像是神经网络的一个单层。有一组输入节点与另一组输出节点相连:

图 1。受限玻尔兹曼机
输出节点被激活的方式也与自编码器完全相同。每个输入节点和输出节点之间有一个权重,每个输入节点的激活乘以这个权重映射矩阵,然后应用偏置向量,并且每个输出节点的总和将通过一个 sigmoid 函数。
使得受限玻尔兹曼机与众不同的是激活代表的内容、我们对它们的思考方式以及它们的训练方式。首先,当谈论 RBM 时,我们不是谈论输入和输出层,而是将层称为可见层和隐藏层。这是因为在训练时,可见节点代表我们已知的信息,而隐藏节点将旨在代表生成可见数据的一些变量。这与自编码器形成对比,自编码器的输出层不再明确地代表任何东西,只是通过信息传递的一种受限空间。
学习受限玻尔兹曼机的权重基础于统计物理学,并使用基于能量的模型(EBM)。在这些模型中,每个状态都经历一个能量函数,它与状态发生概率相关。如果能量函数返回一个高值,我们期望这种状态不太可能发生,很少发生。相反,能量函数的低结果意味着一个更稳定的状态,会更频繁发生。
一个很好的直观思考能量函数的方式是想象将大量的弹跳球扔进一个箱子中。起初,所有的球都具有很高的能量,因此会弹得很高。这里的状态是所有球的位置和它们关联速度的一个时间点快照。当球在弹跳时,这些状态将会非常短暂;它们只会存在片刻,因为球的移动范围很大,很不可能再次出现。但是当球开始平静下来,当能量离开系统时,一些球将开始越来越静止。这些状态一旦发生一次就稳定了,一旦发生就不会停止。最终,当球停止弹跳并且都变成静止时,我们有一个完全稳定的状态,具有很高的概率。
-
以应用于受限波尔兹曼机的例子,考虑学习一组蝴蝶图像的任务。我们在这些图像上训练我们的 RBM,并且希望它对任何蝴蝶图像分配低能量值。但是当给出来自不同集合的图像,比如汽车时,它会给它分配一个高能量值。相关的对象,如蛾子、蝙蝠或鸟,可能具有中等能量值。
-
如果我们定义了一个能量函数,那么给定状态的概率就如下所示:
![受限波尔兹曼机]()
-
在这里,v 是我们的状态,E 是我们的能量函数,Z 是分区函数;v 的所有可能配置的总和定义如下:
![受限波尔兹曼机]()
霍普菲尔德网络和波尔兹曼机
-
在我们进一步讨论受限波尔兹曼机之前,让我们简要谈谈霍普菲尔德网络;这应该有助于我们对如何到达受限波尔兹曼机有更多的理解。霍普菲尔德网络也是基于能量的模型,但与受限波尔兹曼机不同,它只有可见节点,并且它们都是相互连接的。每个节点的激活始终为-1 或+1。
![霍普菲尔德网络和波尔兹曼机]()
图 2. 霍普菲尔德网络,所有输入节点都相互连接。
-
在运行霍普菲尔德网络(或 RBM)时,您有两个选项。第一个选项是您可以将每个可见节点的值设置为您正在触发的数据项的相应值。然后,您可以触发连续的激活,在每次激活时,每个节点的值都根据其连接到的其他可见节点的值进行更新。另一个选项是仅随机初始化可见节点,然后触发连续的激活,以产生其已经训练过的数据的随机示例。这通常被称为网络做白日梦。
-
下一个时间步的每个可见节点的激活定义如下:
![霍普菲尔德网络和波尔兹曼机]()
-
在这里,W 是一个矩阵,定义了时间步骤 t 时每个节点 v 之间的连接强度。然后对 a 应用阈值规则,得到 v 的新状态:
![霍普菲尔德网络和波尔兹曼机器]()
-
节点之间的权重 W 可以是正的也可以是负的,在激活时会导致节点相互吸引或排斥。霍普菲尔德网络还有一个连续变体,它只是用 tanh 函数替换了阈值函数。
-
该网络的能量函数如下:
![霍普菲尔德网络和波尔兹曼机器]()
-
用矩阵表示,如下所示:
![霍普菲尔德网络和波尔兹曼机器]()
-
方程中的
是因为我们要遍历每对 i 和 j,因此重复计算每个连接(当 i=1 且 j=2 时,然后当 i=2 且 j=1 时又计算一次)。 -
这里可能出现的问题是:为什么只有可见节点的模型?我会给它激活,然后触发一些状态更新。但是这个新状态给我提供了什么有用的信息呢?能量基模型的特性在这里变得有趣。不同的 W 配置将改变与状态 v 相关的能量函数。如果我们将网络状态设置为具有高能量函数的东西,即不稳定状态(想象一下许多弹跳的球);网络会在连续的迭代中移动到一个稳定状态。
-
如果我们对数据集训练霍普菲尔德网络,学习得到一个对数据集中每个条目都有低能量的 W,然后我们可以从数据中创建一个损坏的样本,比如,通过随机交换一些输入的正负状态。因为损坏使得这些样本不太可能是原数据集的成员,所以这些损坏的样本可能现在处于高能量状态。如果我们激活网络的可见节点上的损坏样本,运行网络的更多迭代直到达到低能量状态;那么网络有很大的可能性已经重构了原始未损坏的模式。
-
这导致 Hopfield 网络的一个用途是拼写纠正;你可以在单词库上对其进行训练,其中包含单词中使用的字母作为输入。然后,如果给出一个拼写错误的单词,它可能能够找到正确的原始单词。Hopfield 网络的另一个用途是作为内容寻址内存。计算机内存和人类内存之间的一个重要区别是,计算机内存是用地址存储的。如果计算机想要检索内存,它必须知道存储它的确切位置。另一方面,人类记忆可以给出该记忆的部分内容,该内容的特性可以用来恢复其余部分。例如,如果我需要记住我的密码,我知道我正在寻找的内容以及该内容的属性,一个四位数;我的大脑利用这一点返回值。
-
Hopfield 网络允许您存储内容寻址内存,这导致一些人推测人类记忆系统可能像 Hopfield 网络一样运作,人类的梦境是学习权重的尝试。
-
Hopfield 网络的最后一个用途是,它可以用于解决优化任务,例如旅行推销员任务。可以定义能量函数来表示要优化的任务的成本,网络的节点表示要优化的选择。同样,只需最小化网络权重的能量函数即可。
Boltzmann 机器
-
Boltzmann 机器也被称为随机 Hopfield 网络。在 Hopfield 网络中,节点激活是基于阈值设置的;但在 Boltzmann 机器中,激活是随机的。Boltzmann 机器中的节点值始终设置为 +1 或 -1。节点处于状态 +1 的概率定义如下:
![Boltzmann 机器]()
-
这里,
a[i]是针对 Hopfield 网络定义的该节点的激活。
为了学习我们的 Boltzmann 机器或 Hopfield 网络的权重,我们希望最大化给定 W 的数据集的可能性,这简单地是每个数据项的可能性的乘积:

这里,W 是权重矩阵,x^((n)) 是大小为 N 的数据集 x 的第 n 个样本。现在让我们用来自我们的 Boltzmann 机器的实际可能性替换
:

这里,Z 如下方程所示:

如果您查看我们能量函数和 Z 的原始定义,那么x'应该是基于概率分布p(x)的每个可能配置的x。我们现在的模型中有 W 的一部分,因此分布将更改为
。不幸的是,
如果不是完全棘手的,至少是计算成本太高,无法计算的。我们需要在所有可能的 W 的所有可能的 x 的配置中进行计算。
计算这种难以处理的概率分布的一种方法是所谓的蒙特卡罗采样。这涉及从分布中取大量样本,并使用这些样本的平均值来近似真实值。我们从分布中取的样本越多,它的准确性就越高。假设无限数量的样本将完全符合我们想要的数量,而 1 将是一个非常差的近似值。
由于概率的乘积可能变得非常小,因此我们将使用对数概率;另外,让我们也包括Z的定义:

这里,x'是从网络学习的概率分布
中获取的网络状态样本。如果我们对节点 i 和 j 之间的单个权重取这个梯度,它看起来像这样:

这里,
在所有 N 个样本中只是节点 i 和 j 之间的相关性。另一种写法是对所有 N 个样本,对于每个权重i和j,可以写成这样:

这个方程可以理解为学习的两个阶段,被称为正相和负相或者,更具诗意地说,醒和睡眠。在正相中,
根据我们所给的数据增加权重。在负相中,
,我们从模型中根据当前权重抽取样本,然后将权重远离该分布。这可以被认为是减少模型生成的项目的概率。我们希望我们的模型尽可能地反映数据,因此我们希望减少模型生成的选择。如果我们的模型产生的图像与数据完全相同,那么这两个术语将互相抵消,达到平衡。
玻尔兹曼机和霍普菲尔德网络可用于优化和推荐系统等任务。它们需要大量的计算资源。必须测量每个节点之间的相关性,然后对模型进行每一步训练时的蒙特卡洛样本的范围。此外,它可以学习的模式种类有限。如果我们在图像上训练以学习形状,它无法学习位置不变的信息。图像左侧的蝴蝶与图像右侧的蝴蝶完全不同。在第五章图像识别中,我们将看一下卷积神经网络,它提供了这个问题的解决方案。
受限玻尔兹曼机
受限玻尔兹曼机与玻尔兹曼机相比进行了两项改变:第一是添加了隐藏节点,每个节点都连接到每个可见节点,但彼此不连接。 第二是删除了可见节点之间的所有连接。这导致在给定隐藏层的情况下,可见层中的每个节点都是条件独立的。给定可见层后,隐藏层中的节点也是条件独立的。我们现在还将向可见和隐藏节点添加偏置项。玻尔兹曼机也可以在每个节点上训练有偏置项,但这在等式中被忽略了以便简化符号。
-
由于我们拥有的数据只针对可见单元,我们的目标是通过训练找到隐藏单元的配置,当与可见单元结合时,可以导致低能态。在我们的受限玻尔兹曼机中,状态
x现在是可见和隐藏节点的完整配置。因此,我们将能量函数参数化为 E(v, h)。它现在看起来像这样:![受限玻尔兹曼机]()
-
在这里,a 是可见节点的偏置向量,b 是隐藏节点的偏置向量,W 是可见和隐藏节点之间的权重矩阵。此处,
是这两个向量的点积,等价于
。现在我们需要对新能量函数计算出的偏置和权重进行梯度下降。 -
由于层之间的条件独立性,我们现在有这个:
-
-
这两个定义将用于归一化常数 Z。由于我们不再有可见节点之间的连接,我们的
发生了很大的变化:![受限玻尔兹曼机]()
-
在这里,i 遍历每个可见节点,j 遍历每个隐藏节点。如果我们对不同参数取梯度,那么最终你会得到这个:
![受限玻尔兹曼机]()
![受限玻尔兹曼机]()
![受限玻尔兹曼机]()
与以前一样,
是通过从分布中取蒙特卡洛样本来近似的。这最后三个方程给出了我们迭代地训练给定数据集的所有参数的完整方法。训练将是通过这些梯度以某个学习速率更新我们的参数的情况。
从概念层面上再次说明这里发生了什么是值得的。v 表示可见变量,即我们正在学习的来自世界的数据。h 表示隐藏变量,即我们将训练以生成可见变量的变量。隐藏变量并不明确地代表任何东西,但通过训练和最小化系统中的能量,它们最终应该找到我们正在查看的分布的重要组成部分。例如,如果可见变量是一系列电影,如果一个人喜欢这部电影,则其值为 1,如果不喜欢,则为 0,那么隐藏变量可能会表示电影的流派,如恐怖片或喜剧片,因为人们可能有流派偏好,所以这是一种编码人们口味的有效方式。
如果我们随机生成隐藏变量的样本,然后基于此激活可见变量,那么它应该给我们一个看起来合理的电影口味集。同样,如果我们将可见变量设置为在隐藏和可见节点的连续激活过程中的随机电影选择,那么它应该使我们找到一个更合理的选择。
在 TensorFlow 中的实现
现在我们已经通过了数学,让我们看看它的实现是什么样子的。为此,我们将使用 TensorFlow。TensorFlow 是一个谷歌开源数学图形库,用于深度学习很受欢迎。它没有内置的神经网络概念,比如网络层和节点,这是一个更高级别的库,比如 Keras 才有;它更接近于像 Theano 这样的库。之所以选择它,是因为能够直接处理网络底层的数学符号,使用户能够更好地理解他们在做什么。
TensorFlow 可以直接通过 pip 安装,使用命令 pip install tensorflow 安装 CPU 版本,或者如果您有 NVidea GPU 启用的机器,则使用命令 pip install tensorflow-gpu 安装 GPU 版本。
我们将构建一个小型的受限玻尔兹曼机,并对其进行 MNIST 手写数字集的训练。我们将比可见节点少的隐藏节点数,这将迫使 RBM 学习输入中的模式。训练的成功将通过网络在经过隐藏层后重构图像的能力来衡量;为此,我们将使用原始图像与我们的重构之间的均方误差。完整的代码示例在 GitHub 仓库 github.com/DanielSlater/PythonDeepLearningSamples 的 restricted_boltzmann_machine.py 文件中。
由于 MNIST 数据集被如此广泛地使用,TensorFlow 有一种很好的内置方式来下载和缓存 MNIST 数据集。只需简单地调用以下代码即可完成:
from tensorflow.examples.tutorials.mnist import input_data
mnist = input_data.read_data_sets("MNIST_data/")
这将把所有 MNIST 数据下载到 "MNIST_data/" 目录下的 MNIST_data 文件夹中,如果还没有。mnist 对象有 train 和 test 属性,允许您访问 NumPy 数组中的数据。MNIST 图像都是 28x28 大小的,即每个图像有 784 个像素。我们将为我们的 RBM 需要每个像素一个可见节点:
input_placeholder = tf.placeholder("float", shape=(None, 784))
TensorFlow 中的占位符对象表示在使用期间将传递到计算图中的值。在这种情况下,input_placeholder 对象将保存我们给它的 MNIST 图像的值。"float" 指定了我们将传递的值的类型,shape 定义了维度。在这种情况下,我们想要 784 个值,每个像素一个,None 维度用于批处理。有一个 None 维度意味着它可以是任何大小;因此,这将允许我们发送可变大小的 784 长度的数组的批次:
weights = tf.Variable(tf.random_normal((784, 300), mean=0.0, stddev=1./784))
tf.variable 表示计算图上的变量。这是前述方程中的 W。传递给它的参数是变量值应该如何首先初始化的方式。在这里,我们将其初始化为一个大小为 784x300 的正态分布,即可见节点到隐藏节点的数量:
hidden_bias = tf.Variable(tf.zeros([300]))
visible_bias = tf.Variable(tf.zeros([784]))
这些变量将是我们前述方程中的 a 和 b;它们被初始化为全部从值为 0 开始。现在我们将编写网络的激活:
hidden_activation = tf.nn.sigmoid(tf.matmul(input_placeholder, weights) + hidden_bias)
这代表了在前述方程中隐藏节点的激活,
。应用 sigmoid 函数后,这个激活可以被放入二项分布中,以便隐藏层中的所有值都变为 0 或 1,概率由给定;但事实证明,RBM 的训练与原始概率一样好。因此,没有必要通过这种方式复杂化模型:
visible_reconstruction = tf.nn.sigmoid(tf.matmul(hidden_activation, tf.transpose(weights)) + visible_bias)
现在我们有了可见层的重构,
。根据方程的规定,我们给它 hidden_activation,从中我们得到了可见层的样本:
final_hidden_activation = tf.nn.sigmoid(tf.matmul(visible_reconstruction, weights) + hidden_bias)
现在我们计算我们需要的最终样本,来自我们的visible_reconstruction的隐藏节点的激活。这相当于方程中的
。我们可以继续使用连续迭代的隐藏和可视激活来从模型中获取一个更加无偏的样本。但只进行一次旋转就足够用于训练:
Positive_phase = tf.matmul(tf.transpose(input_placeholder), hidden_activation)
Negative_phase = tf.matmul(tf.transpose(visible_reconstruction), final_hidden_activation)
现在我们计算正相位和负相位。第一阶段是我们的 input_placeholder 中的样本和第一个 hidden_activation 之间的相关性,
。然后,负相位获取 visible_reconstruction,
和final_hidden_activation之间的相关性,
:
LEARING_RATE = 0.01
weight_update = weights.assign_add(LEARING_RATE * (positive_phase – negative_phase))
在我们的weights变量上调用assign_add会创建一个操作,当运行时,会将给定的数量添加到变量中。这里,0.01 是我们的学习率,我们通过它来缩放正相位和负相位:
visible_bias_update = visible_bias.assign_add(LEARING_RATE * tf.reduce_mean(input_placeholder - visible_reconstruction, 0))
hidden_bias_update = hidden_bias.assign_add(LEARING_RATE * tf.reduce_mean(hidden_activation - final_hidden_activation, 0))
现在我们创建缩放隐藏和可视偏置的操作。这些也会被我们的 0.01 学习率缩放:
train_op = tf.group(weight_update, visible_bias_update, hidden_bias_update)
调用tf.group创建一个新操作,当调用时会同时执行所有的操作参数。我们总是希望同时更新所有的权重,所以创建一个单独的操作是有意义的:
loss_op = tf.reduce_sum(tf.square(input_placeholder - visible_reconstruction))
这个loss_op将为我们提供有关我们的训练效果的反馈,使用 MSE。请注意,这仅用于信息;不会针对此信号进行反向传播。如果我们想将这个网络作为一个纯自动编码器来运行,我们将在这里创建一个优化器,并激活它以最小化loss_op:?
session = tf.Session()
session.run(tf.initialize_all_variables())
然后,我们创建一个将用于运行计算图的会话对象。调用tf.initialize_all_variables()时,所有内容都会初始化到图中。如果你在 GPU 上运行 TensorFlow,这是硬件首先被接口化的地方。现在我们已经为 RBM 创建了每一步,让我们经历几个时代的 MNIST 运行,并看看它学到了多少:
current_epochs = 0
for i in range(10):
total_loss = 0
while mnist.train.epochs_completed == current_epochs:
batch_inputs, batch_labels = mnist.train.next_batch(100)
_, reconstruction_loss = session.run([train_op, loss_op], feed_dict={input_placeholder: batch_inputs})
total_loss += reconstruction_loss
print("epochs %s loss %s" % (current_epochs, reconstruction_loss))
current_epochs = mnist.train.epochs_completed
每次我们调用mnist.train.next_batch(100),就会从mnist数据集中检索 100 张图片。在每个时代结束时,mnist.train.epochs_completed会增加 1,所有训练数据都会重新洗牌。如果你运行这个,你可能会看到类似这样的结果:
epochs 0 loss 1554.51
epochs 1 loss 792.673
epochs 2 loss 572.276
epochs 3 loss 479.739
epochs 4 loss 466.529
epochs 5 loss 415.357
epochs 6 loss 424.25
epochs 7 loss 406.821
epochs 8 loss 354.861
epochs 9 loss 410.387
epochs 10 loss 313.583
现在我们可以通过在mnist数据上运行以下命令来看看图像重构是什么样的:
reconstruction = session.run(visible_reconstruction, feed_dict={input_placeholder:[mnist.train.images[0]]})
这里有一些使用 300 个隐藏节点的重建图像的示例:

图 3. 使用不同数量的隐藏节点对受限玻尔兹曼机进行数字的重构
正如您所见,使用 300 个隐藏节点,不到像素数量的一半,它仍然可以几乎完美地重建图像,只有边缘周围有一点模糊。但是随着隐藏节点数量的减少,重建的质量也会降低。将隐藏节点减少到只有 10 个时,重建的图像可能会产生对于人眼来说看起来像是错误的数字,例如图 3 中的 2 和 3。
深信度网络
如果我们想象我们的 RBM 正在学习一组生成我们可见数据的潜在变量,并且我们感到好奇,我们可能会想知道:我们是否可以学习第二层生成隐藏层潜在变量的潜在变量?答案是肯定的,我们可以将先前训练的 RBM 堆叠在一起,以便学习有关可见数据的二阶、三阶、四阶等信息。这些连续的 RBM 层使网络能够学习越来越不变的表示底层结构:

图 4 深信度网络,包含许多链接的 RBM
这些堆叠的 RBM 被称为深度信度网络,是 Geoffrey Hinton 在他的 2002 年论文《通过最小化对比散度训练专家产品》中首次在 MNIST 上取得突破性成果时使用的深度网络。他发现有用的确切技术是在数据上训练连续的 RBM,每一层的尺寸都只稍微减小。一旦一个层训练到重建误差不再改善的程度,它的权重就被冻结,然后在其上堆叠一个新的 RBM,并再次训练直到误差率收敛。一旦整个网络训练完毕,最后添加一个监督层,以将最终 RBM 的隐藏层映射到数据的标签。然后使用整个网络的权重构建标准的深度前馈神经网络,使得这些预先计算的深度信度网络的权重能够通过反向传播进行更新。
起初,这些方法效果很好,但随着时间的推移,用于训练标准前馈网络的技术已经改进,RBMs 不再被认为是图像或语音识别的最佳方法。它们还存在一个问题,就是由于其两阶段性质,它们的训练速度可能会慢得多。但是它们在诸如推荐系统和纯无监督学习等方面仍然非常受欢迎。此外,从理论上讲,使用能量模型来学习深度表示是一种非常有趣的方法,并且为许多可以建立在该方法之上的扩展留下了大门。
摘要
在本章中,我们看到了许多实际深度学习实现核心的两种最强大的技术:自动编码器和受限玻尔兹曼机。
对于它们两个,我们都从一个隐藏层的浅层示例开始,并且探索了如何将它们堆叠在一起形成一个深度神经网络,能够自动学习高层次和分层次的特征,而不需要显式的人类知识。
它们都有类似的目的,但有一点小小的实质性差别。
自动编码器可以被看作是我们用来压缩数据的压缩过滤器,以保留其中最具信息量的部分,并能够确定性地重构原始数据的近似。自动编码器是对维度约简和非线性压缩的优雅解决方案,绕过了主成分分析(PCA)技术的限制。自动编码器的优点是,它们可以用作进一步分类任务的预处理步骤,其中每个隐藏层的输出是数据信息表示的可能级别之一,或者是其去噪和恢复版本。另一个巨大的优点是利用重构误差作为一个单点与其余组的不相似性的度量。这样的技术广泛用于异常检测问题,其中我们观察到的内容与内部表示之间的关系是恒定的和确定性的。在时间变化关系或取决于可观察维度的情况下,我们可以分组和训练不同的网络,以便适应,但一旦训练完成,网络就假设这些关系不受随机变化的影响。
另一方面,RBM 使用随机方法对样本进行采样和调整权重,以最小化重构误差。直觉可能是存在一些可见的随机变量和一些隐藏的潜在属性,目标是找出这两组之间的联系。举个例子,在电影评分的情况下,我们可以有一些隐藏的属性,比如电影类型,以及一些随机的观察,比如评分和/或评论。在这样的拓扑结构中,我们还可以将偏差项看作是调整每部电影不同内在流行度的一种方式。如果我们让用户从由哈利·波特、阿凡达、指环王、角斗士和泰坦尼克号组成的集合中评价他们喜欢哪部电影,我们可能会得到一个结果网络,其中两个潜在单元可能代表科幻电影和奥斯卡获奖电影:

可能的 RBM 示例,仅绘制与权重明显不同于 0 的链接。
虽然科幻和奥斯卡获奖的属性是确定性的(实际上,它们是电影的属性),但用户的评级受到概率方式的影响。学习到的权重是表征电影评分的概率分布的参数(例如,哈利·波特获得五星),假设用户喜欢特定的类型(例如,科幻)。
在这种关系不确定的情况下,我们更倾向于使用受限玻尔兹曼机(RBM)而不是自编码器。
总的来说,无监督特征学习是一种非常强大的方法,可以用最少的知识和人为干预来丰富特征工程。
根据一些基准测试([Lee, Pham and Ng, 2009] 和 [Le, Zhou and Ng, 2011])的结果,用于衡量不同特征学习技术准确性的,已经证明无监督特征学习相对于目前的最新技术有所提高。
不过,还存在一些挑战。如果您有一些知识,最好不要将其丢弃。我们可以在初始化阶段以先验的形式嵌入这些知识,在这一阶段我们可以手工设计网络拓扑和初始状态。
此外,由于神经网络本身已经很难解释,并且大多数时候被视为黑匣子,因此至少了解输入特征可能有所帮助。在我们的无监督特征学习中,我们希望直接使用原始数据。因此,理解模型的工作原理变得更加困难。
我们在本书中不会涉及这些问题。我们认为现在下结论还为时过早,深度学习的进一步发展以及人们和企业处理这些应用的方式将会趋于稳定和可靠。
第五章:图像识别
视觉可以说是人类最重要的感官之一。我们依赖视觉来识别食物,逃离危险,认出朋友和家人,以及在熟悉的环境中找到方向。我们甚至依赖视觉来阅读这本书,并识别其中打印的每一个字母和符号。然而,图像识别一直以来一直是计算机科学中最困难的问题之一。因为要教会计算机如何识别不同的物体是非常困难的,因为很难向机器解释构成指定物体的特征。然而,正如我们所看到的,深度学习中的神经网络通过自身学习,也就是学会了构成每个物体的特征,因此非常适合图像识别这样的任务。
在本章中,我们将涵盖以下主题:
-
人造模型和生物模型之间的相似之处
-
CNN 的直觉和理由
-
卷积层
-
池层
-
丢弃
-
深度学习中的卷积层
人造模型和生物模型之间的相似之处
人类视觉是一个复杂且结构严谨的过程。视觉系统通过视网膜、丘脑、视觉皮层和颞下皮质等阶级性地理解现实。视网膜的输入是一个二维的颜色密度数组,通过视神经传递到丘脑。丘脑除了嗅觉系统的感官信息外,还接收从视网膜收集的视觉信息,然后将该信息传递到初级视觉皮层,也就是 V1 区,它提取基本信息,例如线条和运动方向。然后信息流向负责色彩解释和不同光照条件下的颜色恒定性的 V2 区,然后到达 V3 和 V4 区,改善色彩和形态感知。最后,信息传递到颞下皮质(IT),用于物体和面部识别(事实上,IT 区域还进一步细分为三个亚区,即后部 IT、中央 IT 和前部 IT)。因此,大脑通过在不同层级处理信息来处理视觉信息。我们的大脑似乎通过在不同层级上创建简单的抽象现实表示,然后将它们重新组合在一起来解决这个问题(详细参考:J. DiCarlo, D. Zoccolan, and N. Rust, 大脑是如何处理视觉物体识别的?,www.ncbi.nlm.nih.gov/pmc/articles/PMC3306444)。
我们目前看到的深度学习神经网络通过创建抽象表示来工作,就像我们在 RBM 中看到的那样,但是理解感官信息的重要拼图中还有另一个重要部分:我们从感官输入中提取的信息通常主要由最相关的信息确定。从视觉上看,我们可以假设附近的像素是最相关的,它们的集体信息比我们从彼此非常遥远的像素中得出的信息更相关。在理解语音方面,我们已经讨论过研究三音素的重要性,也就是说,对音频的理解依赖于其前后的声音。要识别字母或数字,我们需要理解附近像素的依赖性,因为这决定了元素的形状,从而区分例如 0 和 1 等之间的差异。总的来说,远离 0 的像素通常对我们理解数字"0"没有或几乎没有影响。卷积网络的构建正是为了解决这个问题:如何使与更近的神经元相关的信息比来自更远的神经元更相关的信息。在视觉问题中,这意味着让神经元处理来自附近像素的信息,并忽略与远离像素相关的信息。
直觉和理解
我们在第三章中已经提到了 Alex Krizhevsky、Ilya Sutskever 和 Geoffrey Hinton 在 2012 年发表的论文:使用深度卷积神经网络进行 ImageNet 分类。尽管卷积的起源可以追溯到 80 年代,但那是第一篇突出卷积网络在图像处理和识别中深刻重要性的论文之一,当前几乎没有用于图像识别的深度神经网络可以在没有某些卷积层的情况下工作。
我们在使用传统前馈网络时遇到的一个重要问题是它们可能会过拟合,特别是在处理中等到大型图像时。这通常是因为神经网络具有非常多的参数,事实上,在经典神经网络中,一层中的所有神经元都连接到下一层中的每一个神经元。当参数数量很大时,过拟合的可能性更大。让我们看以下图片:我们可以通过画一条穿过所有点的线来拟合数据,或者更好的是,一条不完全匹配数据但更可能预测未来示例的线。

图中的点表示输入数据点。虽然它们明显遵循抛物线的形状,但由于数据中的噪声,它们可能不会被精确地绘制到抛物线上。
在两幅图中的第一个例子中,我们对数据进行了过拟合。在第二个例子中,我们已经将我们的预测与数据匹配得更好,这样我们的预测更有可能更好地预测未来的数据。在第一种情况下,我们只需要三个参数来描述曲线:y = ax² + bx + c,而在第二种情况下,我们需要比三个参数多得多的参数来编写该曲线的方程。这直观地解释了为什么有时候拥有太多参数可能不是一件好事,而且可能导致过拟合。对于像 cifar10 示例中那样小的图像(cifar10 是一个经过验证的计算机视觉数据集,由 60000 张 32 x 32 图像组成,分为 10 类,在本章中我们将看到该数据集的几个示例),经典的前馈网络的输入大小为 3 x 32 x 32,已经约为简单 mnist 数字图像的四倍。更大的图像,比如 3 x 64 x 64,将拥有大约 16 倍于输入神经元数量的连接权重:

在左图中,我们画了一条与数据完全匹配的直线。在第二个图中,我们画了一条近似连接数据点形状的直线,但并不完全匹配数据点。尽管第二条曲线在当前输入上不够精确,但比第一张图中的曲线更有可能预测未来的数据点。
卷积网络减少了所需的参数数量,因为它们要求神经元仅在本地与对应于相邻像素的神经元连接,因此有助于避免过拟合。此外,减少参数数量也有助于计算。在下一节中,我们将介绍一些卷积层的示例来帮助理解,然后我们将正式定义它们。
卷积层
卷积层(有时在文献中称为 "滤波器")是一种特殊类型的神经网络,它操作图像以突出显示某些特征。在深入了解细节之前,让我们使用一些代码和一些示例介绍一个卷积滤波器。这将使直觉更简单,也将更容易理解理论。为此,我们可以使用 keras 数据集,这使得加载数据变得容易。
我们将导入 numpy,然后是 mnist 数据集,以及 matplotlib 来展示数据:
import numpy
from keras.datasets import mnist
import matplotlib.pyplot as plt
import matplotlib.cm as cm
让我们定义我们的主函数,该函数接受一个整数,对应于 mnist 数据集中的图像,以及一个滤波器,这种情况下我们将定义 blur 滤波器:
def main(image, im_filter):
im = X_train[image]
现在我们定义一个新的图像 imC,大小为 (im.width-2, im.height-2):
width = im.shape[0]
height = im.shape[1]
imC = numpy.zeros((width-2, height-2))
此时我们进行卷积,我们将很快解释(正如我们将看到的,实际上有几种类型的卷积取决于不同的参数,现在我们只是解释基本概念,并稍后详细介绍):
for row in range(1,width-1):
for col in range(1,height-1):
for i in range(len(im_filter[0])):
for j in range(len(im_filter)):
imC[row-1][col-1] += im[row-1+i][col-1+j]*im_filter[i][j]
if imC[row-1][col-1] > 255:
imC[row-1][col-1] = 255
elif imC[row-1][col-1] < 0:
imC[row-1][col-1] = 0
现在我们准备显示原始图像和新图像:
plt.imshow( im, cmap = cm.Greys_r )
plt.show()
plt.imshow( imC/255, cmap = cm.Greys_r )
plt.show()
现在我们准备使用 Keras 加载mnist数据集,就像我们在第三章中所做的那样,深度学习基础。此外,让我们定义一个滤波器。滤波器是一个小区域(在本例中为 3 x 3),每个条目定义一个实数值。在这种情况下,我们定义一个所有条目值都相同的滤波器:
blur = [[1./9, 1./9, 1./9], [1./9, 1./9, 1./9], [1./9, 1./9, 1./9]]
由于我们有九个条目,我们将值设置为 1/9 以归一化值。
我们可以对任何图像(用一个表示位置的整数表示)调用main函数在这样一个数据集中:
if __name__ == '__main__':
(X_train, Y_train), (X_test, Y_test) = mnist.load_data()
blur = [[1./9, 1./9, 1./9], [1./9, 1./9, 1./9], [1./9, 1./9, 1./9]]
main(3, blur)
让我们看看我们做了什么。我们将滤波器的每个条目与原始图像的一个条目相乘,然后将它们全部加起来得到一个单一的值。由于滤波器的大小小于图像的大小,我们将滤波器移动 1 像素,并继续执行此过程,直到覆盖整个图像。由于滤波器由所有等于 1/9 的值组成,实际上我们已经用接近它的值的值平均了所有输入值,这就有了模糊图像的效果。
这就是我们得到的:

顶部是原始 mnist 图像,底部是我们应用滤波器后的新图像
在选择滤波器时,我们可以使用任何值;在这种情况下,我们使用的是全部相同的值。但是,我们可以使用不同的值,例如仅查看输入的相邻值,将它们相加,并减去中心输入的值。让我们定义一个新的滤波器,并将其称为边缘,如下所示:
edges = [[1, 1, 1], [1, -8, 1], [1, 1, 1]]
如果我们现在应用此滤波器,而不是之前定义的模糊滤波器,则会得到以下图像:

顶部是原始 mnist 图像,底部是我们应用滤波器后的新图像
因此很明显,滤波器可以改变图像,并显示可以用于检测和分类图像的“特征”。例如,要对数字进行分类,内部的颜色并不重要,而诸如“边缘”之类的滤波器有助于识别数字的一般形状,这对于正确分类是重要的。
我们可以将滤波器视为与神经网络相同,认为我们定义的滤波器是一组权重,并且最终值表示下一层中神经元的激活值(实际上,尽管我们选择了特定的权重来讨论这些示例,但我们将看到权重将通过反向传播由神经网络学习):

滤波器覆盖了一个固定的区域,对于该区域中的每个神经元,它定义了与下一层中的神经元的连接权重。然后,下一层中的神经元将具有输入值,该输入值等于通过相应的连接权重中介的所有输入神经元的贡献总和计算得到的常规激活值。
然后我们保持相同的权重,滑动滤波器,生成一个新的神经元集,这些神经元对应于过滤后的图像:

我们可以不断重复这个过程,直到我们移动到整个图像上,我们可以使用尽可能多的滤波器重复这个过程,创建一组新的图像,每个图像都会突出显示不同的特征或特性。虽然我们在示例中没有使用偏置,但也可以向滤波器添加偏置,这将添加到神经网络中,我们还可以定义不同的活动函数。在我们的代码示例中,您会注意到我们强制值保持在范围(0, 255)内,这可以被认为是一个简单的阈值函数:

当滤波器在图像上移动时,我们为输出图像中的神经元定义新的激活值。
由于可以定义许多滤波器,因此我们应该将输出视为一组图像,每个滤波器定义一个图像。如果我们仅使用“边缘”和“模糊”滤波器,则输出层将有两个图像,每个选择的滤波器一个。因此,输出将除了宽度和高度外,还具有等于选择的滤波器数的深度。实际上,如果我们使用彩色图像作为输入,输入层也可以具有深度;图像实际上通常由三个通道组成,在计算机图形中用 RGB 表示,红色通道、绿色通道和蓝色通道。在我们的示例中,滤波器由二维矩阵表示(例如模糊滤波器是一个 3 x 3 矩阵,所有条目都相等于 1/9)。然而,如果输入是彩色图像,则滤波器也将具有深度(在这种情况下等于三,即颜色通道的数量),因此将由三个(颜色通道数)3 x 3 矩阵表示。一般来说,滤波器因此将由一个三维数组表示,具有宽度、高度和深度,有时被称为“体积”。在前面的示例中,由于mnist图像仅为灰度,因此滤波器的深度为 1。因此,深度为d的通用滤波器由具有相同宽度和高度的d个滤波器组成。这些d个滤波器中的每一个称为“切片”或“叶子”:

类似地,和以前一样,对于每个“叶片”或“片段”,我们连接小的子区域中的每个神经元以及一个偏置到一个神经元,并计算其激活值,其由滤波器中设置的连接权重定义,并滑动滤波器跨整个区域。这样的过程,因为它容易计算,所以需要的参数数量等于滤波器定义的权重数(在我们上面的示例中,这将是 3 x 3 = 9),乘以“叶片”的数量,也就是层的深度,再加上一个偏置。这定义了一个特征图,因为它突出显示了输入的特定特征。在我们上面的代码中,我们定义了两个特征图,一个“模糊”和一个“边缘”。因此,我们需要将参数的数量乘以特征图的数量。请注意,每个滤波器的权重是固定的;当我们滑动滤波器跨区域时,我们不会改变权重。因此,如果我们从尺寸为(宽度,高度,深度)的层开始,以及一个维度为(filter_w,filter_h)的滤波器,那么应用卷积后的输出层是(width - filter_w + 1,height - filter_h + 1)。新层的深度取决于我们想要创建多少特征图。在我们之前的mnist代码示例中,如果我们同时应用了模糊和边缘滤波器,我们将拥有一个尺寸为(28 x 28 x 1)的输入层,因为只有一个通道,因为数字是灰度图像,并且一个尺寸为(26 x 26 x 2)的输出层,因为我们的滤波器尺寸为(3 x 3),我们使用了两个滤波器。参数的数量仅为 18(3 x 3 x 2),如果我们添加一个偏置,则为 20(3 x 3 x 2 + 2)。这比我们在传统的前馈网络中所需的要少得多,因为由于输入是 784 像素,一个只有 50 个神经元的简单隐藏层将需要 784 x 50 = 39200 个参数,如果我们添加偏置,则为 39250 个:

我们将滤波器沿着包含在层中的所有“叶片”滑过图像。
此外,卷积层可以更好地工作,因为每个神经元仅从相邻的神经元获得其输入,并且不关心从彼此相距较远的神经元收集输入的情况。
卷积层中的步幅和填充
我们所展示的示例,辅以图片,实际上只讲述了滤波器的一个特定应用(正如我们之前提到的,根据所选参数,有不同类型的卷积)。实际上,滤波器的大小可能会有所不同,以及它在图像上的移动方式以及在图像边缘的行为。在我们的示例中,我们每次将滤波器沿图像移动 1 个像素。我们每次移动滤波器时跳过多少像素(神经元)称为步幅。在上面的示例中,我们使用了步幅为 1,但使用较大的步幅,如 2 甚至更大,也并不罕见。在这种情况下,输出层的宽度和高度将较小:

使用步长为 2 的滤波器应用——滤波器每次移动两个像素。
另外,我们可能也决定部分地在原始图片外应用滤镜。在这种情况下,我们会假设缺失的神经元值为 0。这就是所谓的填充;也就是,在原始图像外部添加值为 0 的神经元。如果我们想要输出图像与输入图像大小相同的话,这可能会很有用。在上面,我们写出了零填充情况下新输出图像大小的公式,即(width - filter_w + 1, height – filter_h + 1),对应输入大小为(width, height)和滤波器尺寸为(filter_w, filter_h)。如果我们在图像的四周使用填充P,输出大小将为(width + 2P - filter_w + 1, height + 2P – filter_h + 1)。总结一下,在每个维度上(无论是宽度还是高度),让输入切片的大小称为I=(I[w](I[h]), 滤波器的大小为F=(F[w],F[h]), 步长的大小为S=(S[w],S[h]), 和填充的大小为P=(P[w],P[h]),那么输出切片的大小*O=(O[w], O[h])就由下式给出:


当然,这也确定了S的约束之一,即它必须在宽度方向和高度方向上都能整除(I + 2P – F)。最终体积的尺寸通过乘以所需的特征映射数得到。
相反,使用的参数数目W与步长和填充无关,仅仅是滤波器大小的函数,输入的深度D(切片数量),以及选定的特征映射数量M:

使用填充(也称为零填充,因为我们用零填充图像)有时很有用,如果我们希望输出维度与输入维度相同的话。如果我们使用一个大小为(2 x 2)的滤波器,实际上可以清楚地看到通过应用值为 1 的填充和步长为 1,输出切片的尺寸与输入切片的大小相同。
池化层
在前一节中,我们已经推导出了卷积层中每个切片大小的公式。正如我们讨论过的那样,卷积层的优势之一是它减少了所需的参数数量,提升了性能,减少了过拟合。在执行卷积操作后,通常会执行另一个操作——池化。最经典的例子就是最大池化,这意味着在每个切片上创建(2 x 2)的网格,并在每个网格中选择具有最大激活值的神经元,丢弃其他的。很明显,这样的操作会丢弃 75%的神经元,仅保留在每个单元格中贡献最多的神经元。
对于每个汇集层来说有两个参数,类似于卷积层中的步幅和填充参数,它们是单元大小和步幅。一个典型的选择是选择单元大小为 2,步幅为 2,不过选择单元大小为 3,步幅为 2,创建一些重叠也不少见。然而需要注意的是,如果单元大小太大,汇集层可能会丢弃太多信息,这对于帮助并不利。我们可以推导出与我们推导卷积层的公式类似的汇集层输出的公式。\


汇集层不会改变层的体积深度,保持相同数量的片,因为汇集操作是在每个片中独立地进行。
还需要注意的是,类似于我们可以使用不同的激活函数一样,我们也可以使用不同的汇集操作。取最大值是最常见的操作之一,不过取所有值的平均值或者L ²度量也并不少见,这是所有平方的平方根。在实践中,最大汇聚通常表现更好,因为它保留了图像中最相关的结构。
然而需要注意的是,虽然汇集层仍然被广泛使用,有时候只需使用步幅较大的卷积层而不是汇集层,就能达到类似或更好的结果(例如,见 J. Springerberg, A. Dosovitskiy, T. Brox, 和 M. Riedmiller,追求简洁:全卷积网络,(2015),arxiv.org/pdf/1412.6806.pdf)。
然而,如果使用汇集层,它们通常被用于在几个卷积层中间,通常是在每隔一个卷积操作之后。
还需要注意的是,汇集层不会增加新的参数,因为它们只是提取值(如最大值)而不需要额外的权重或偏置:

最大汇聚层的例子:计算每个 2x2 单元的最大值以生成一个新层。
丢弃
另一个重要的技术是可以在池化层之后应用的,但也通常可以应用于全连接层的技术是随机定期“丢弃”一些神经元及其相应的输入和输出连接。在一个丢弃层中,我们为神经元指定了一个概率p以随机方式“丢弃”。在每个训练周期中,每个神经元都有概率p被从网络中丢弃,概率(1-p)被保留。这是为了确保没有神经元过多地依赖其他神经元,并且每个神经元都“学到”了对网络有用的东西。这有两个优点:它加快了训练,因为我们每次训练一个较小的网络,还有助于防止过拟合(参见 N. Srivastava, G. Hinton, A. Krizhevsky, I. Sutskever, and R. Salakhutdinov 的Dropout: A Simple Way to Prevent Neural Networks from Overfitting,刊登于机器学习研究杂志15 (2014), 1929-1958, www.jmlr.org/papers/volume15/srivastava14a.old/source/srivastava14a.pdf)。
然而,重要的是要注意,丢弃层不仅仅限于卷积层;事实上,丢弃层在不同的神经网络架构中都有应用。丢弃层应被视为减少过拟合的正则化技术,我们提到它们是因为它们将在我们的代码示例中被明确使用。
深度学习中的卷积层
当我们介绍深度学习的概念时,我们讨论了“深度”一词不仅指的是我们在神经网络中使用了许多层,还指的是我们有一个“更深入”的学习过程。这种更深入的学习过程的一部分是神经网络自主学习特征的能力。在前一节中,我们定义了特定的滤波器来帮助网络学习特定的特征。这并不一定是我们想要的。正如我们讨论过的,深度学习的重点在于系统能够自主学习,如果我们不得不教会网络哪些特征或特性是重要的,或者如何通过应用边缘层来学习识别数字的形状,我们将会做大部分的工作,并可能限制网络学习可能对我们有用但对网络本身并不重要的特征,从而降低其性能。深度学习的重点在于系统必须自行学习。
在第二章 神经网络中,我们展示了神经网络中的隐藏层如何通过使用反向传播学习权重; 操作员没有设置权重。 同样,操作员设置滤波器中的权重是毫无意义的,我们希望神经网络通过使用反向传播再次学习滤波器中的权重。 操作员唯一需要做的是设置图层的大小、步长和填充,并决定我们要求网络学习多少个特征图。 通过使用监督学习和反向传播,神经网络将自主设置每个滤波器的权重(和偏差)。
还需要提及的是,虽然使用我们提供的卷积层描述可能更简单,但卷积层仍然可以被认为是我们在第三章 深度学习基础中介绍的普通全连接层。 实际上,卷积层的两个主要特征是每个神经元只连接到输入层的一个小区域,并且对应于相同小区域的不同切片共享相同的权重。 这两个属性可以通过创建一个稀疏的权重矩阵来呈现在普通层中,即具有许多零(由于卷积网络的局部连接性)和许多重复权重(由于切片之间的参数共享特性)。 理解这一点清楚地说明了为什么卷积层的参数要比全连接层少得多; 在卷积层中,权重矩阵主要由零条目组成。 然而,在实践中,将卷积层想象成本章节中描述的方式对直觉有所帮助,因为这样可以更好地欣赏卷积层如何突出显示原始图像的特征,正如我们通过模糊图像或突出我们示例中数字的轮廓来图形化展示的那样。
再要明确的一点是,卷积网络的深度通常应该等于可以通过 2 进行迭代除法的数字,例如 32,64,96,128 等。 这在使用池化层时很重要,比如 max-pool 层,因为池化层(如果其大小为(2,2))将使输入层的大小除以 2,类似于我们如何定义“步进”和“填充”,以使输出图像具有整数尺寸。 另外,可以添加填充以确保输出图像大小与输入相同。
Theano 中的卷积层
现在我们已经知道卷积层是如何工作的,我们将使用 Theano 实现一个卷积层的简单示例。
让我们首先导入所需的模块:
import numpy
import theano
import matplotlib.pyplot as plt
import theano.tensor as T
from theano.tensor.nnet import conv
import skimage.data
import matplotlib.cm as cm
Theano 首先创建我们定义的操作的符号表示。我们稍后将通过另一个使用 Keras 的例子,它提供了一个很好的接口来更轻松地创建神经网络,但是使用 Theano(或者 TensorFlow)直接使用时可能缺少一些灵活性。
我们通过定义所需的变量和神经网络操作来定义特征图的数量(卷积层的深度)和滤波器的大小,然后我们使用 Theano 张量类来符号化地定义输入。Theano 把图像通道视为一个单独的维度,所以我们把输入定义为 tensor4。接下来,我们使用-0.2 和 0.2 之间的随机分布来初始化权重。我们现在可以调用 Theano 卷积操作,然后在输出上应用逻辑 sigmoid 函数。最后,我们定义函数f,它接受一个输入,并使用所使用的操作来定义一个输出:
depth = 4
filter_shape = (3, 3)
input = T.tensor4(name='input')
w_shape = (depth, 3, filter_shape[0], filter_shape[1])
dist = numpy.random.uniform(-0.2, 0.2, size=w_shape)
W = theano.shared(numpy.asarray(dist, dtype=input.dtype), name = 'W')
conv_output = conv.conv2d(input, W)
output = T.nnet.sigmoid(conv_output)
f = theano.function([input], output)
我们导入的skimage模块可以用来加载一个名为lena的图像,然后在将图像重塑为可传递给我们定义的 Theano 函数后,我们就可以在该图像上调用 Theano 函数:
astronaut = skimage.data.astronaut()
img = numpy.asarray(astronaut, dtype='float32') / 255
filtered_img = f(img.transpose(2, 0, 1).reshape(1, 3, 512, 512))
就是这样。我们现在可以通过这段简单的代码打印出原始图片和经过滤波的图片。
plt.axis('off')
plt.imshow(img)
plt.show()
for img in range(depth):
fig = plt.figure()
plt.axis( 'off')
plt.imshow(filtered_img[0, img, :, :, ], cmap = cm.gray)
plt.show()
filename = "astro" + str(img)
fig.savefig(filename, bbox_inches='tight')
如果读者对可视化所使用的权重感兴趣,在 Theano 中,可以使用print W.get_value()来打印值。
这段代码的输出如下:(由于我们还没有固定随机种子,并且权重是随机初始化的,读者可能会得到略有不同的图像):

原始图片和滤波后的图片。
一个使用 Keras 识别数字的卷积层示例
在第三章中,我们介绍了使用 Keras 对数字进行分类的简单神经网络,我们得到了 94%的准确率。在本章中,我们将努力使用卷积网络将该准确率提高到 99%以上。由于初始化的变化,实际值可能会略有不同。
首先,我们可以通过使用 400 个隐藏神经元来改进我们之前定义的神经网络,并将其运行 30 个周期;这样就应该已经将准确率提高到了大约 96.5%:
hidden_neurons = 400
epochs = 30
接下来,我们可以尝试对输入进行缩放。图像由像素组成,每个像素的整数值在 0 到 255 之间。我们可以使该值成为浮点数,并将其在 0 到 1 之间缩放,只需在定义输入后添加这四行代码即可:
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255
如果我们现在运行我们的网络,我们得到的准确率较低,略高于 92%,但我们不需要担心。通过重新缩放,我们实际上改变了我们函数的梯度值,因此它将收敛得更慢,但有一个简单的解决方法。在我们的代码中,在model.compile函数内,我们定义了一个优化器等于"sgd"。这是标准的随机梯度下降,它使用梯度收敛到最小值。然而,Keras 允许其他选择,特别是"adadelta",它自动使用动量,并根据梯度调整学习率,使其与梯度成反比地变大或变小,以便网络不会学习得太慢,也不会通过采取太大的步骤跳过最小值。通过使用 adadelta,我们动态调整参数随时间改变(也见:Matthew D. Zeiler,Adadelta:一种自适应学习率方法,arXiv:1212.5701v1 (arxiv.org/pdf/1212.5701v1.pdf))。
在主函数内部,我们现在将改变我们的编译函数并使用:
model.compile(loss='categorical_crossentropy',
metrics=['accuracy'], optimizer='adadelta')
如果我们再次运行我们的算法,现在我们的准确率约为 98.25%。最后,让我们修改我们的第一个密集(全连接)层,使用relu激活函数而不是sigmoid:
model.add(Activation('relu'))
这将带来大约 98.4%的准确率。问题在于,现在使用传统的前馈架构变得越来越难以改善我们的结果,由于过拟合,增加迭代次数或修改隐藏神经元的数量将带来任何额外的好处,因为网络将简单地学会对数据进行过度拟合,而不是学会更好地泛化。因此,我们现在将在示例中引入卷积网络。
为了做到这一点,我们保持我们的输入值在 0 和 1 之间。然而,为了被卷积层使用,我们将数据重塑成大小为(28,28,1)的体积=(图像宽度,图像高度,通道数),并将隐藏神经元的数量减少到 200 个,但现在我们在开始处添加了一个简单的卷积层,使用 3 x 3 的滤波器,不填充,步长为 1,然后是一个步幅为 2 且大小为 2 的最大池化层。为了将输出传递给密集层,我们需要将体积(卷积层是体积)拉直以传递给具有 100 个隐藏神经元的常规密集层,使用以下代码:
from keras.layers import Convolution2D, MaxPooling2D, Flatten
hidden_neurons = 200
X_train = X_train.reshape(60000, 28, 28, 1)
X_test = X_test.reshape(10000, 28, 28, 1)
model.add(Convolution2D(32, (3, 3), input_shape=(28, 28, 1)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Flatten())
我们还可以将迭代次数减少到 8 次,然后我们将得到大约 98.55%的准确率。通常情况下,常用成对的卷积层,所以我们添加了一个类似第一个卷积层的第二个卷积层(在池化层之前):
model.add(Convolution2D(32, (3, 3)))
model.add(Activation('relu'))
现在我们的准确率已经达到了 98.9%。
为了达到 99%,我们按照我们所讨论的方法添加一个辍学层。这不会增加任何新的参数,但能帮助防止过拟合,并且我们将其添加在拉直层之前:
from keras.layers import Dropout
model.add(Dropout(0.25))
在这个例子中,我们使用了约 25%的辍学率,因此每个神经元每四次就会被随机抛弃一次。
这将使我们的准确度达到 99%以上。如果我们想进一步提高(准确度可能因初始化的差异而有所不同),我们还可以添加更多的 dropout 层,例如在隐藏层之后,并增加时期的数量。这将迫使最终密集层中容易过拟的神经元被随机丢弃。我们的最终代码如下:
import numpy as np
np.random.seed(0) #for reproducibility
from keras.datasets import mnist
from keras.models import Sequential
from keras.layers import Dense, Activation, Convolution2D, MaxPooling2D, Flatten, Dropout
from keras.utils import np_utils
input_size = 784
batch_size = 100
hidden_neurons = 200
classes = 10
epochs = 8
(X_train, Y_train), (X_test, Y_test) = mnist.load_data()
X_train = X_train.reshape(60000, 28, 28, 1)
X_test = X_test.reshape(10000, 28, 28, 1)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255
X_test /= 255
Y_train = np_utils.to_categorical(Y_train, classes)
Y_test = np_utils.to_categorical(Y_test, classes)
model = Sequential()
model.add(Convolution2D(32, (3, 3), input_shape=(28, 28, 1)))
model.add(Activation('relu'))
model.add(Convolution2D(32, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(hidden_neurons))
model.add(Activation('relu'))
model.add(Dense(classes))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy',
metrics=['accuracy'], optimizer='adadelta')
model.fit(X_train, Y_train, batch_size=batch_size,
epochs=epochs, validation_split = 0.1, verbose=1)
score = model.evaluate(X_train, Y_train, verbose=1)
print('Train accuracy:', score[1])
score = model.evaluate(X_test, Y_test, verbose=1)
print('Test accuracy:', score[1])
这个网络可以进一步优化,但这里的重点不是获得一个获奖得分,而是理解过程,并了解我们采取的每一步是如何提高性能的。还要注意,通过使用卷积层,我们实际上也避免了网络的过拟合问题,因为利用了更少的参数。
使用 Keras 进行 cifar10 的卷积层示例
现在我们可以尝试在cifar10数据集上使用相同的网络。在第三章中的深度学习基础知识中,我们在测试数据上得到了 50%的低准确度,为了测试刚刚在mnist数据集上使用的新网络,我们只需要对代码进行一些小的修改:我们需要加载cifar10数据集(不进行任何重新调整,那些行将被删除):
(X_train, Y_train), (X_test, Y_test) = cifar10.load_data()
改变第一个卷积层的输入值:
model.add(Convolution2D(32, (3, 3), input_shape=(32, 32, 3)))
运行这个网络 5 个时期将给我们约 60%的准确度(从约 50%提高)和 10 个时期后的 66%的准确度,但接着网络开始过拟合并停止改善性能。
当然,cifar10的图像有 32 x 32 x 3 = 3072 个像素,而不是 28 x 28 = 784 个像素,所以在前两层之后,我们可能需要添加几个额外的卷积层:
model.add(Convolution2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(Convolution2D(64, (3, 3)))
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
一般来说,最好将大型卷积层划分为较小尺寸的卷积层。例如,如果我们有两个连续的(3 x 3)卷积层,第一层将具有对输入图像的(3 x 3)视图,第二层将为每个像素提供对输入图像的(5 x 5)视图。然而,每层都会具有非线性特征,这些特征将堆叠起来,创建出比仅仅创建单个(5 x 5)滤波器时更复杂和有趣的输入特征。
如果我们将这个网络运行 3 个时期,我们的准确度也在 60%左右,但是经过 20 个时期后,通过使用简单的网络,我们的准确度达到了 75%。先进的卷积网络可以达到 90%的准确度,但需要更长的训练时间,并且更加复杂。我们将以图形方式展示一个重要的卷积神经网络的架构,称为 VGG-16,在下一段中,用户可以尝试使用 Keras 或其他他们熟悉的语言来实现它,例如 Theano 或 TensorFlow(该网络最初是使用 Caffe 创建的,Caffe 是在伯克利开发的一个重要的深度学习框架,详情请见:caffe.berkeleyvision.org)。
在使用神经网络时,能够“看到”网络学习的权重是很重要的。这使用户能够了解网络正在学习什么特征,并且能够进行更好的调整。这个简单的代码将输出每个层的所有权重:
index = 0
numpy.set_printoptions(threshold='nan')
for layer in model.layers:
filename = "conv_layer_" + str(index)
f1 = open(filename, 'w+')
f1.write(repr(layer.get_weights()))
f1.close()
print (filename + " has been opened and closed")
index = index+1
例如,如果我们对第 0 层,即第一个卷积层的权重感兴趣,我们可以将它们应用于图像,以查看网络正在突出显示的特征。如果我们将这些滤波器应用于图像lena,我们会得到:

我们可以看到每个滤波器如何突出显示不同的特征。
预训练
正如我们所见,神经网络,特别是卷积网络,通过调整网络的权重,就像它们是一个大型方程的系数一样来获得给定特定输入的正确输出。调整通过反向传播来移动权重,以使它们朝着给定选择的神经网络架构的最佳解决方案移动。因此,其中一个问题是找到神经网络中权重的最佳初始化值。诸如 Keras 的库可以自动处理这个问题。然而,这个话题足够重要,值得讨论这一点。
通过使用输入作为期望输出来预先训练网络来使用受限玻尔兹曼机,使网络自动学习输入的表示并相应地调整其权重,这个话题已经在第四章中讨论过,无监督特征学习。
此外,存在许多预训练网络提供良好的结果。正如我们所提到的,许多人一直在研究卷积神经网络,并取得了令人印象深刻的结果,通过重新利用这些网络学到的权重并将它们应用于其他项目,通常可以节省时间。
K. Simonyan, A. Zisserman 在Very Deep Convolutional Networks for Large-Scale Image Recognition中使用的 VGG-16 模型,arxiv.org/pdf/1409.1556v6.pdf,是图像识别中的重要模型。在这个模型中,输入是一个固定的 224 x 224 的 RGB 图像,唯一的预处理是减去在训练集上计算的平均 RGB 值。我们在附图中概述了这个网络的架构,用户可以尝试自己实现这样一个网络,但也要注意运行这样一个网络的计算密集性。在这个网络中,架构如下:

Simonyan 和 Zisserman 的 VGG-16 卷积神经网络架构。
我们还将感兴趣的读者引荐到另一个值得注意的例子,即 AlexNet 网络,包含在 Alex Krizhevsky, Ilya Sutskeve, Geoffrey Hinton 的使用深度卷积神经网络进行 ImageNet 分类中,出自于 Advances in Neural Information Processing Systems 25 (NIPS 2012),papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks.pdf,但我们出于简洁起见,在此不讨论它,但我们邀请感兴趣的读者去查看。我们还邀请感兴趣的读者查看 github.com/fchollet/deep-learning-models 获取 VGG-16 和其他网络的代码示例。
总结
值得注意的是,正如可能已经清楚的,卷积神经网络没有通用的架构。但是,有一些一般性的指导原则。通常,池化层跟在卷积层后面,并且经常习惯于堆叠两个或更多连续的卷积层来检测更复杂的特征,就像在前面展示的 VGG-16 神经网络示例中所做的那样。卷积网络非常强大。然而,它们可能非常耗费资源(例如上面的 VGG-16 示例相对复杂),通常需要长时间的训练,这就是为什么使用 GPU 可以帮助加速性能的原因。它们的优势在于它们不专注于整个图像,而是专注于较小的子区域,以找到组成图像的有趣特征,从而能够找到不同输入之间的区别元素。由于卷积层非常耗费资源,我们引入了池化层来帮助减少参数数量而不增加复杂性,而使用丢弃层有助于确保没有神经元过于依赖其他神经元,因此神经网络中的每个元素都会有助于学习。
在本章中,我们从类比我们的视觉皮层如何工作开始,介绍了卷积层,并随后描述了它们为何有效的直观理解。我们介绍了滤波器,还涵盖了滤波器可以有不同的大小和不同的填充方式,我们还看到了如何设置零填充可以确保结果图像与原始图像具有相同的大小。如上所述,池化层可以帮助减少复杂性,而丢弃层可以使神经网络在识别模式和特征方面更加有效,并且特别适用于减少过拟合的风险。
一般来说,在给定的例子中,特别是在mnist的例子中,我们已经展示了神经网络中的卷积层在处理图像时,可以比普通的深度神经网络取得更好的准确性,在数字识别方面达到了超过 99%的准确度,而且通过限制参数的使用,避免了模型过拟合的问题。在接下来的章节中,我们将会研究语音识别,然后开始研究使用强化学习而不是监督或非监督学习的模型的例子,介绍在棋盘游戏和视频游戏中使用深度学习的例子。
第六章:循环神经网络和语言模型
我们在前几章讨论的神经网络架构接受固定大小的输入并提供固定大小的输出。即使在图像识别中使用的卷积网络(第五章,“图像识别”)也被展平成一个固定输出向量。本章将通过引入循环神经网络(RNNs)来摆脱这一限制。RNN 通过在这些序列上定义递推关系来帮助我们处理可变长度的序列,因此得名。
处理任意输入序列的能力使得 RNN 可用于诸如语言建模(参见语言建模部分)或语音识别(参见语音识别部分)等任务。事实上,理论上,RNN 可以应用于任何问题,因为已经证明它们是图灵完备的 [1]。这意味着在理论上,它们可以模拟任何常规计算机无法计算的程序。作为这一点的例证,Google DeepMind 提出了一个名为“神经图灵机”的模型,该模型可以学习执行简单的算法,比如排序 [2]。
在本章中,我们将涵盖以下主题:
-
如何构建和训练一个简单的 RNN,基于一个简单的问题
-
RNN 训练中消失和爆炸梯度的问题以及解决方法
-
用于长期记忆学习的 LSTM 模型
-
语言建模以及 RNN 如何应用于这个问题
-
应用深度学习于语音识别的简要介绍
循环神经网络
RNN 之所以得名,是因为它们在序列上重复应用相同的函数。可以通过下面的函数将 RNN 写成递推关系:

这里的 Sₜ —第 t 步的状态—是由函数 f 从前一步的状态,即 t-1,和当前步骤的输入 Xₜ 计算得出。这种递推关系通过在先前状态上的反馈循环来定义状态如何逐步在序列中演变,如下图所示:

图来自[3]
左:RNN 递推关系的可视化示例:S [t] = S [t-1] ** W + X* [t] ** U*。最终输出将是 o [t] *= VS [t]
右:RNN 状态在序列 t-1, t, t+1 上递归展开。注意参数 U、V 和 W 在所有步骤之间共享。
这里 f 可以是任何可微分函数。例如,一个基本的 RNN 定义如下递推关系:

这里W定义了从状态到状态的线性变换,U是从输入到状态的线性变换。tanh函数可以被其他变换替代,比如 logit,tanh 或者 ReLU。这个关系可以在下图中进行解释,Oₜ是网络生成的输出。
例如,在词级语言建模中,输入X将是一个序列的单词编码的输入向量(X[1]…X[t]…)。状态S将会是一个状态向量的序列(S[1]…S[t]…)。输出O将是下一个序列中的单词的概率向量的序列(O[1]…O[t]…)。
需要注意的是,在 RNN 中,每个状态都依赖于其之前的所有计算,通过这种循环关系。这个关系的一个重要含义是,RNN 在时间上有记忆,因为状态S包含了基于先前步骤的信息。理论上,RNN 可以记住任意长时间的信息,但在实践中,它们只能回顾几个步骤。我们将在消失和爆炸梯度部分更详细地讨论这个问题。
因为 RNN 不限于处理固定大小的输入,它们确实扩展了我们可以使用神经网络进行计算的可能性,比如不同长度的序列或不同大小的图像。下图直观地说明了我们可以制作的一些序列的组合。以下是这些组合的简要说明:
-
一对一:这是非顺序处理,比如前馈神经网络和卷积神经网络。请注意,一个前馈网络和 RNN 应用在一个时间步骤上没有太大的区别。一个一对一处理的例子是来自章节的图像分类(参见第五章,图像识别)。
-
一对多:这基于单一的输入生成一个序列,例如,来自图像的标题生成[4]。
-
多对一:这基于一个序列输出一个单一的结果,例如,文本的情感分类。
-
多对多间接:一个序列被编码成一个状态向量,之后这个状态向量被解码成一个新的序列,例如,语言翻译[5],[6]。
-
多对多直接:这对每个输入步骤输出一个结果,例如,语音识别中的帧语素标记(见语音识别部分)。
来自[7]的图片
RNN 扩展了我们可以使用神经网络进行计算的可能性—红色:输入 X,绿色:状态 S,蓝色:输出 O。
![循环神经网络]()
RNN — 如何实现和训练
在前面的部分,我们简要讨论了 RNN 是什么以及它们可以解决的问题。让我们深入了解 RNN 的细节,以及如何通过一个非常简单的玩具例子来训练它:在一个序列中计算“1”的个数。
在这个问题中,我们要教会最基本的循环神经网络如何计算输入中 1 的个数,并且在序列结束时输出结果。我们将在 Python 和 NumPy 中展示这个网络的实现。输入和输出的一个示例如下:
In: (0, 0, 0, 0, 1, 0, 1, 0, 1, 0)
Out: 3
我们要训练的网络是一个非常基本的网络,如下图所示:

基本的循环神经网络用于计算输入中的 1 的个数
网络只有两个参数:一个输入权重 U 和一个循环权重 W。输出权重 V 设为 1,所以我们只需读取最后一个状态作为输出 y。这个网络定义的循环关系是 S [t] = S [t-1] ** W + X* [t] ** U*。请注意,这是一个线性模型,因为在这个公式中我们没有应用非线性函数。这个函数的代码定义如下:
def step(s, x, U, W):
return x * U + s * W
因为 3 是我们想要输出的数字,而且有三个 1,这个问题的一个很好的解决方案就是简单地对整个序列进行求和。如果我们设置 U=1,那么每当接收到一个输入,我们将得到它的完整值。如果我们设置 W=1,那么我们累积的值将永远不会衰减。所以,对于这个例子,我们会得到期望的输出:3。
尽管,这个神经网络的训练和实现将会很有趣,正如我们将在本节的其余部分中看到的。所以让我们看看我们如何通过反向传播来得到这个结果。
通过时间的反向传播
通过时间的反向传播算法是我们用来训练循环网络的典型算法[8]。这个名字已经暗示了它是基于我们在 第二章 讨论的反向传播算法,神经网络。
如果你了解常规的反向传播,那么通过时间的反向传播就不难理解。主要区别在于,循环网络需要在一定数量的时间步长内进行展开。这个展开如前图所示(基本的循环神经网络用于计算输入中的 1 的个数)。展开完成后,我们得到一个与常规的多层前馈网络非常相似的模型。唯一的区别在于,每层都有多个输入(上一个状态,即 S [t-1]),和当前输入(X [t]),以及参数(这里的 U 和 W)在每层之间是共享的。
前向传播将 RNN 沿着序列展开,并为每个步骤构建一个活动堆栈。批处理输入序列 X 的前向步骤可实现如下:
def forward(X, U, W):
# Initialize the state activation for each sample along the sequence
S = np.zeros((number_of_samples, sequence_length+1))
# Update the states over the sequence
for t in range(0, sequence_length):
S[:,t+1] = step(S[:,t], X[:,t], U, W) # step function
return S
在进行这个前向步骤之后,我们有了每一步和每个样本在批处理中的激活,由 S 表示。因为我们想输出更多或更少连续的输出(全部为 1 的总和),我们使用均方误差代价函数来定义我们的输出成本与目标和输出 y,如下:
cost = np.sum((targets – y)**2)
现在我们有了前向步骤和成本函数,我们可以定义梯度如何向后传播。首先,我们需要得到输出y相对于成本函数的梯度(??/?y)。
一旦我们有了这个梯度,我们可以通过向后传播堆栈中构建的活动来将其传播到每个时间步长处的误差导数。传播该梯度通过网络的循环关系可以写成以下形式:

参数的梯度通过以下方式累积:


在接下来的实现中,在反向步骤中,分别通过gU和gW累积U和W的梯度:
def backward(X, S, targets, W):
# Compute gradient of output
y = S[:,-1] # Output `y` is last activation of sequence
# Gradient w.r.t. cost function at final state
gS = 2.0 * (y - targets)
# Accumulate gradients backwards
gU, gW = 0, 0 # Set the gradient accumulations to 0
for k in range(sequence_len, 0, -1):
# Compute the parameter gradients and accumulate the results.
gU += np.sum(gS * X[:,k-1])
gW += np.sum(gS * S[:,k-1])
# Compute the gradient at the output of the previous layer
gS = gS * W
return gU, gW
现在我们可以尝试使用梯度下降优化我们的网络:
learning_rate = 0.0005
# Set initial parameters
parameters = (-2, 0) # (U, W)
# Perform iterative gradient descent
for i in range(number_iterations):
# Perform forward and backward pass to get the gradients
S = forward(X, parameters(0), parameters(1))
gradients = backward(X, S, targets, parameters(1))
# Update each parameter `p` by p = p - (gradient * learning_rate).
# `gp` is the gradient of parameter `p`
parameters = ((p - gp * learning_rate)
for p, gp in zip(parameters, gradients))
不过存在一个问题。注意,如果尝试运行此代码,最终参数U和W往往会变为不是一个数字(NaN)。让我们尝试通过在错误曲面上绘制参数更新来调查发生了什么,如下图所示。注意,参数慢慢朝着最佳值(U=W=1)移动,直到超过并达到大约(U=W=1.5)。此时,梯度值突然爆炸,使参数值跳出图表。这个问题被称为梯度爆炸。下一节将详细解释为什么会发生这种情况以及如何预防。

参数更新通过梯度下降在错误曲面上绘制。错误曲面以对数颜色比例尺绘制。
梯度消失和梯度爆炸
RNN 相对于前馈或卷积网络更难训练。一些困难源于 RNN 的循环性质,其中同一权重矩阵用于计算所有状态更新[9],[10]。
上一节的结尾,前面的图示了梯度爆炸,由于长期组件的膨胀导致 RNN 训练进入不稳定状态。除了梯度爆炸问题,还存在梯度消失问题,即相反的情况发生。长期组件以指数速度趋于零,模型无法从时间上遥远的事件中学习。在本节中,我们将详细解释这两个问题以及如何应对它们。
爆炸和消失梯度都源于通过时间向后传播梯度的循环关系形成了一个几何序列:

在我们简单的线性 RNN 中,如果|W| > 1,梯度会呈指数增长。这就是所谓的梯度爆炸(例如,50 个时间步长下的W=1.5为W[50]**=1.5˜≈6 * 10⁸)。如果|W| < 1,梯度会呈指数衰减;这就是所谓的梯度消失(例如,20 个时间步长下的W=0.6为W[20]=0.6˜≈310^(-5)*)。如果权重参数W是矩阵而不是标量,则这种爆炸或消失的梯度与W的最大特征值(ρ)有关(也称为谱半径)。对于梯度消失,ρ < 1足够,使得梯度消失,对于梯度爆炸,ρ > 1是必要的。
以下图形直观地说明了梯度爆炸的概念。发生的情况是我们正在训练的成本表面非常不稳定。使用小步,我们可能会移动到成本函数的稳定部分,梯度很低,并突然遇到成本的跳跃和相应的巨大梯度。因为这个梯度非常巨大,它将对我们的参数产生很大影响。它们最终会落在成本表面上离它们最初的位置很远的地方。这使得梯度下降学习不稳定,甚至在某些情况下是不可能的。

梯度爆炸的插图[11]
我们可以通过控制梯度的大小来对抗梯度爆炸的效果。一些解决方案的例子有:
-
梯度截断,我们将梯度能获得的最大值设定为阈值[11]。
-
二阶优化(牛顿法),我们模拟成本函数的曲率。模拟曲率使我们能够在低曲率情况下迈出大步,在高曲率情况下迈出小步。出于计算原因,通常只使用二阶梯度的近似值[12]。
-
依赖于局部梯度较少的优化方法,例如动量[13]或 RmsProp [14]。
例如,我们可以利用 Rprop [15]重新训练我们无法收敛的网络(参见梯度爆炸的插图)。 Rprop 是一种类似于动量法的方法,仅使用梯度的符号来更新动量参数,因此不受梯度爆炸的影响。如果我们运行 Rprop 优化,可以看到训练收敛于下图。请注意,尽管训练开始于一个高梯度区域(U=-1.5,W=2),但它很快收敛直到找到最佳点(U=W=1)。

通过 Rprop 在误差表面绘制的参数更新。误差表面是以对数刻度绘制的。
消失梯度问题是爆炸梯度问题的逆问题。梯度在步数上呈指数衰减。这意味着早期状态的梯度变得非常小,保留这些状态历史的能力消失了。较早时间步的小梯度被较近时间步的较大梯度所淘汰。Hochreiter 和 Schmidhuber [16] 将其描述如下:通过时间的反向传播对最近的干扰过于敏感。
这个问题更难以检测,因为网络仍然会学习和输出一些东西(不像爆炸梯度的情况)。它只是无法学习长期依赖性。人们已经尝试用类似于我们用于爆炸梯度的解决方案来解决这个问题,例如二阶优化或动量。这些解决方案远非完美,使用简单 RNN 学习长期依赖性仍然非常困难。幸运的是,有一种聪明的解决方案可以解决消失梯度问题,它使用由记忆单元组成的特殊架构。我们将在下一节详细讨论这个架构。
长短期记忆
在理论上,简单的 RNN 能够学习长期依赖性,但在实践中,由于消失梯度问题,它们似乎只限于学习短期依赖性。Hochreiter 和 Schmidhuber 对这个问题进行了广泛的研究,并提出了一种解决方案,称为长短期记忆(LSTM)[16]。由于特别设计的记忆单元,LSTM 可以处理长期依赖性。它们工作得非常好,以至于目前在各种问题上训练 RNN 的大部分成就都归功于使用 LSTM。在本节中,我们将探讨这个记忆单元的工作原理以及它是如何解决消失梯度问题的。
LSTM 的关键思想是单元状态,其中的信息只能明确地写入或删除,以使单元状态在没有外部干扰的情况下保持恒定。下图中时间 t 的单元状态表示为 c [t]。
LSTM 单元状态只能通过特定的门来改变,这些门是让信息通过的一种方式。这些门由 logistic sigmoid 函数和逐元素乘法组成。因为 logistic 函数只输出介于 0 和 1 之间的值,所以乘法只能减小通过门的值。典型的 LSTM 由三个门组成:遗忘门、输入门和输出门。这些在下图中都表示为 f、i 和 o。请注意,单元状态、输入和输出都是向量,因此 LSTM 可以在每个时间步骤保存不同信息块的组合。接下来,我们将更详细地描述每个门的工作原理。


LSTM 单元
x[t]、c[t]、h[t] 分别是时间 t 的输入、细胞状态和 LSTM 输出。
LSTM 中的第一个门是遗忘门;因为它决定我们是否要擦除细胞状态,所以被称为遗忘门。这个门不在 Hochreiter 最初提出的 LSTM 中;而是由 Gers 等人提出[17]的。遗忘门基于先前的输出 h [t-1] 和当前的输入 x[t] . 它将这些信息组合在一起,并通过逻辑函数压缩它们,以便为细胞的矢量块输出介于 0 和 1 之间的数字。由于与细胞的逐元素乘法,一个输出为 0 的完全擦除特定的细胞块,而输出为 1 会保留该细胞块中的所有信息。这意味着 LSTM 可以清除其细胞状态向量中的不相关信息。

接下来的门决定要添加到内存单元的新信息。这分为两部分进行。第一部分决定是否添加信息。与输入门类似,它基于 h[t-1] 和 x[t] 进行决策,并通过每个细胞块的矢量的逻辑函数输出 0 或 1。输出为 0 意味着不向该细胞块的内存中添加任何信息。因此,LSTM 可以在其细胞状态向量中存储特定的信息片段:

要添加的输入 a [t] 是由先前的输出 (h [t-1]) 和当前的输入 (x[t]) 派生,并通过 tanh 函数变换:

遗忘门和输入门完全决定了通过将旧的细胞状态与新的信息相加来确定新细胞:

最后一个门决定输出结果。输出门将 h [t-1] 和 x[t] 作为输入,并通过逻辑函数输出 0 或 1,在每个单元块的内存中均可用。输出为 0 表示该单元块不输出任何信息,而输出为 1 表示整个单元块的内存传递到细胞的输出。因此,LSTM 可以从其细胞状态向量中输出特定的信息块:

最终输出的值是通过 tanh 函数传递的细胞内存:

因为所有这些公式是可导的,我们可以像连接简单的 RNN 状态一样连接 LSTM 单元,并通过时间反向传播来训练网络。
现在问题是 LSTM 如何保护我们免受梯度消失的影响?请注意,如果遗忘门为 1 且输入门为 0,则细胞状态会被逐步地从步骤复制。只有遗忘门才能完全清除细胞的记忆。因此,记忆可以长时间保持不变。还要注意,输入是添加到当前细胞记忆的 tanh 激活;这意味着细胞记忆不会爆炸,并且非常稳定。
实际上,以下图示了 LSTM 如何展开。
初始时,网络的输入被赋值为 4.2;输入门被设置为 1,所以完整的值被存储。接下来的两个时间步骤中,遗忘门被设置为 1。所以在这些步骤中保留了全部信息,并且没有添加新的信息,因为输入门被设置为 0。最后,输出门被设置为 1,4.2 被输出并保持不变。

LSTM 通过时间展开[18]
尽管在前面的图示中描述的 LSTM 网络是大多数应用中使用的典型 LSTM 版本,但有许多变体的 LSTM 网络,它们以不同的顺序组合不同的门[19]。深入了解所有这些不同的架构超出了本书的范围。
语言建模
语言模型的目标是计算单词序列的概率。它们对于许多不同的应用非常关键,例如语音识别、光学字符识别、机器翻译和拼写校正。例如,在美式英语中,短语"wreck a nice beach"和"recognize speech"在发音上几乎相同,但它们的含义完全不同。一个好的语言模型可以根据对话的上下文区分哪个短语最有可能是正确的。本节将概述基于单词和字符的语言模型以及如何使用循环神经网络来构建它们。
基于单词的模型
基于单词的语言模型定义了一个对单词序列的概率分布。给定长度为m的单词序列,它为完整的单词序列赋予了概率P(w [1] , ... , w [m] )。这些概率的应用有两个方面。我们可以用它们来估计自然语言处理应用中不同短语的可能性。或者,我们可以用它们来生成新的文本。
N-gram 模型
推断一个长序列(如w [1] , ..., w [m])的概率通常是不可行的。通过应用以下链式法则可以计算出P(w [1] , ... , w [m] )的联合概率:

特别是基于前面的单词给出后面单词的概率将很难从数据中估计出来。这就是为什么这个联合概率通常被一个独立假设近似,即第i个单词只依赖于前n-1个单词。我们只建模n个连续单词称为 n-grams 的联合概率。注意,n-grams 可以用来指代其他长度为n的序列,例如n个字符。
联合分布的推断通过 n-gram 模型进行近似,将联合分布拆分为多个独立部分。注意,n-grams 是多个连续单词的组合,其中n是连续单词的数量。例如,在短语the quick brown fox中,我们有以下 n-grams:
-
1-gram:"The," "quick," "brown," 和 "fox"(也称为 unigram)
-
2-grams:"The quick," "quick brown," 和 "brown fox"(也称为 bigram)
-
3-grams:"The quick brown" 和 "quick brown fox"(也称为 trigram)
-
4-grams:"The quick brown fox"
现在,如果我们有一个庞大的文本语料库,我们可以找到直到某个n(通常为 2 到 4)的所有 n-grams,并计算该语料库中每个 n-gram 的出现次数。从这些计数中,我们可以估计每个 n-gram 的最后一个单词在给定前n-1个单词的情况下的概率:
-
1-gram:
![N-grams]()
-
2-gram:
![N-grams]()
-
n-gram:
![N-grams]()
现在可以使用第i个单词仅依赖于前n-1个单词的独立假设来近似联合分布。
例如,对于一个 unigram,我们可以通过以下方式近似联合分布:

对于 trigram,我们可以通过以下方式近似联合分布:

我们可以看到,基于词汇量,随着n的增加,n-grams 的数量呈指数增长。例如,如果一个小词汇表包含 100 个单词,那么可能的 5-grams 的数量将是100**⁵ = 10,000,000,000个不同的 5-grams。相比之下,莎士比亚的整个作品包含大约30,000个不同的单词,说明使用具有大n值的 n-grams 是不可行的。不仅需要存储所有概率,我们还需要一个非常庞大的文本语料库来为较大的n值创建良好的 n-gram 概率估计。这个问题就是所谓的维度灾难。当可能的输入变量(单词)数量增加时,这些输入值的不同组合数量呈指数增长。当学习算法需要至少一个与相关值组合的示例时,就会出现这种维度灾难,这在 n-gram 建模中是这样的情况。我们的n越大,我们就越能近似原始分布,并且我们需要更多的数据来对 n-gram 概率进行良好的估计。
神经语言模型
在前面的部分中,我们用 n-grams 建模文本时展示了维度灾难。我们需要计算的 n-grams 数量随着n和词汇表中的单词数量呈指数增长。克服这个问题的一种方法是通过学习单词的较低维度、分布式表示来学习一个嵌入函数[20]。这个分布式表示是通过学习一个嵌入函数将单词空间转换为较低维度的单词嵌入空间而创建的,具体如下:

从词汇表中取出的单词被转换为大小为 V 的独热编码向量(V 中的每个单词都被唯一编码)。然后,嵌入函数将这个 V 维空间转换为大小为 D 的分布式表示(这里 D=4)。
这个想法是,学习的嵌入函数会学习关于单词的语义信息。它将词汇表中的每个单词与一个连续值向量表示相关联,即单词嵌入。在这个嵌入空间中,每个单词对应一个点,其中不同的维度对应于这些单词的语法或语义属性。目标是确保在这个嵌入空间中彼此接近的单词应具有相似的含义。这样,一些单词语义上相似的信息可以被语言模型利用。例如,它可能会学习到“fox”和“cat”在语义上相关,并且“the quick brown fox”和“the quick brown cat”都是有效的短语。然后,一系列单词可以转换为一系列捕捉到这些单词特征的嵌入向量。
可以通过神经网络对语言模型进行建模,并隐式地学习这个嵌入函数。我们可以学习一个神经网络,给定一个n-1个单词的序列(w[t-n+1],…,w[t-1]),试图输出下一个单词的概率分布,即w[t]。网络由不同部分组成。
嵌入层接受单词w [i]的独热表示,并通过与嵌入矩阵C相乘将其转换为其嵌入。这种计算可以通过表查找有效地实现。嵌入矩阵C在所有单词上共享,因此所有单词使用相同的嵌入函数。C由一个V * D矩阵表示,其中V是词汇量的大小,D是嵌入的大小。得到的嵌入被连接成一个隐藏层;之后,可以应用一个偏置b和一个非线性函数,比如tanh。隐藏层的输出因此由函数z = tanh(concat(w [t-n+1] , …, w [t-1] ) + b)表示。从隐藏层,我们现在可以通过将隐藏层与U相乘来输出下一个单词w [t]的概率分布。这将隐藏层映射到单词空间,添加一个偏置b并应用 softmax 函数以获得概率分布。最终层计算softmax(zU +b)*。这个网络如下图所示:

给定单词w[t-1] ... w**[t-n+1],输出单词wₜ的概率分布的神经网络语言模型。C是嵌入矩阵。
这个模型同时学习词汇表中所有单词的嵌入以及单词序列的概率函数模型。由于这些分布式表示,它能够将这个概率函数推广到在训练过程中没有看到的单词序列。测试集中特定的单词组合在训练集中可能没有出现,但是具有类似嵌入特征的序列更有可能在训练过程中出现。
下图展示了一些词嵌入的二维投影。可以看到,在嵌入空间中,语义上接近的单词也彼此接近。

在这个空间中,二维嵌入空间中相关的单词彼此接近 [21]。
单词嵌入可以在大型文本数据语料库上无监督地训练。这样,它们能够捕捉单词之间的一般语义信息。得到的嵌入现在可以用于改进其他任务的性能,其中可能没有大量标记的数据可用。例如,试图对文章的情感进行分类的分类器可能是在使用先前学习的单词嵌入而不是独热编码向量进行训练的。这样,单词的语义信息就变得对情感分类器可用了。因此,许多研究致力于创建更好的单词嵌入,而不是专注于学习单词序列上的概率函数。例如,一种流行的单词嵌入模型是 word2vec [22],[23]。
令人惊讶的是,这些词嵌入可以捕捉单词之间的类比作为差异。例如,它可能捕捉到"女人"和"男人"的嵌入之间的差异编码了性别,并且这个差异在其他与性别相关的单词,如"皇后"和"国王"中是相同的。

词嵌入可以捕捉单词之间的语义差异 [24]。
embed(女人) - embed(男人) ? embed(姑妈) - embed(叔叔)
embed(女人) - embed(男人) ? embed(皇后) - embed(国王)
虽然之前的前馈网络语言模型可以克服模拟大词汇输入的维度诅咒,但仍然仅限于建模固定长度的单词序列。为了克服这个问题,我们可以使用 RNN 来构建一个不受固定长度单词序列限制的 RNN 语言模型 [25]。这些基于 RNN 的模型不仅可以在输入嵌入中聚类相似的单词,还可以在循环状态向量中聚类相似的历史。
这些基于单词的模型的一个问题是计算每个单词在词汇表中的输出概率 P(w [i] | context)。我们通过对所有单词激活进行 softmax 来获得这些输出概率。对于一个包含50,000个单词的小词汇表 V,这将需要一个|S| * |V|的输出矩阵,其中|V|是词汇表的大小,|S|是状态向量的大小。这个矩阵非常庞大,在增加词汇量时会变得更大。由于 softmax 通过所有其他激活的组合来归一化单个单词的激活,我们需要计算每个激活以获得单个单词的概率。这两者都说明了在大词汇表上计算 softmax 的困难性;在 softmax 之前需要大量参数来建模线性转换,并且 softmax 本身计算量很大。
有一些方法可以克服这个问题,例如,通过将 softmax 函数建模为一个二叉树,从而只需要 log(|V|) 计算来计算单个单词的最终输出概率 [26]。
不详细介绍这些解决方法,让我们看看另一种语言建模的变体,它不受这些大词汇量问题的影响。
基于字符的模型
在大多数情况下,语言建模是在单词级别进行的,其中分布是在一个固定词汇量为|V|的词汇表上。在实际任务中,如语音识别中使用的语言模型,词汇表通常超过100,000个单词。这个巨大的维度使得建模输出分布非常具有挑战性。此外,这些单词级别的模型在建模包含非单词字符串的文本数据时受到相当大的限制,比如多位数字或从未出现在训练数据中的单词(词汇外单词)。
可以克服这些问题的一类模型称为字符级语言模型[27]。这些模型对字符序列的分布建模,而不是单词,从而使您可以计算一个更小的词汇表上的概率。这里的词汇表包括文本语料库中所有可能的字符。然而,这些模型也有一个缺点。通过对字符序列而不是单词进行建模,我们需要对更长的序列进行建模,以在时间上捕获相同的信息。为了捕获这些长期依赖关系,让我们使用 LSTM RNN 语言模型。
本节的后续部分将详细介绍如何在 Tensorflow 中实现字符级 LSTM 以及如何在列夫·托尔斯泰的《战争与和平》上进行训练。这个 LSTM 将建模下一个字符的概率,给定先前看到的字符:P(c [t] | c [t-1] ... c [t-n] )。
因为完整文本太长,无法使用时间反向传播(BPTT)训练网络,我们将使用一种批量变体,称为截断的 BPTT。在这种方法中,我们将训练数据分成固定序列长度的批次,并逐批次训练网络。由于批次将相互跟随,我们可以使用最后一批的最终状态作为下一批的初始状态。这样,我们可以利用状态中存储的信息,而无需对完整输入文本进行完整的反向传播。接下来,我们将描述如何读取这些批次并将其馈送到网络中。
预处理和读取数据
要训练一个好的语言模型,我们需要大量的数据。在我们的示例中,我们将了解基于列夫·托尔斯泰的《战争与和平》的英文译本的模型。这本书包含超过500,000个单词,使其成为我们小范例的完美候选者。由于它是公有领域的作品,因此《战争与和平》可以从古腾堡计划免费下载为纯文本。作为预处理的一部分,我们将删除古腾堡许可证、书籍信息和目录。接下来,我们将去除句子中间的换行符,并将允许的最大连续换行数减少到两个。
要将数据馈送到网络中,我们必须将其转换为数字格式。每个字符将与一个整数相关联。在我们的示例中,我们将从文本语料库中提取总共 98 个不同的字符。接下来,我们将提取输入和目标。对于每个输入字符,我们将预测下一个字符。由于我们使用截断的 BPTT 进行训练,我们将使所有批次相互跟随,以利用序列的连续性。将文本转换为索引列表并将其分成输入和目标批次的过程如下图所示:

将文本转换为长度为 5 的整数标签的输入和目标批次。请注意,批次彼此相继。
LSTM 网络
我们要训练的网络将是一个具有 512 个单元的两层 LSTM 网络。我们将使用截断的 BPTT 来训练这个网络,因此我们需要在批处理之间存储状态。
首先,我们需要为输入和目标定义占位符。输入和目标的第一维是批处理大小,即并行处理的示例数。第二维将沿着文本序列的维度。这些占位符接受包含字符索引的序列批次:
inputs = tf.placeholder(tf.int32, (batch_size, sequence_length))
targets = tf.placeholder(tf.int32, (batch_size, sequence_length))
要将字符馈送到网络,我们需要将它们转换成向量。我们将它们转换为独热编码,这意味着每个字符将被转换为一个长度等于数据集中不同字符数量的向量。这个向量将全为零,除了与其索引对应的单元,该单元将被设置为 1。在 TensorFlow 中,可以轻松完成以下代码行:
one_hot_inputs = tf.one_hot(inputs, depth=number_of_characters)
接下来,我们将定义我们的多层 LSTM 架构。首先,我们需要为每一层定义 LSTM 单元(lstm_sizes是每一层大小的列表,例如(512, 512),在我们的情况下):
cell_list = (tf.nn.rnn_cell.LSTMCell(lstm_size) for lstm_size in lstm_sizes)
然后,使用以下方法将这些单元包装在单个多层 RNN 单元中:
multi_cell_lstm = tf.nn.rnn_cell.MultiRNNCell(cell_list)
为了在批处理之间存储状态,我们需要获取网络的初始状态,并将其包装在要存储的变量中。请注意,出于计算原因,TensorFlow 会将 LSTM 状态存储在两个单独张量的元组中(来自长短期记忆部分的c和h)。我们可以使用flatten方法展平这个嵌套数据结构,将每个张量包装在变量中,并使用pack_sequence``_as方法重新打包成原始结构:
initial_state = self.multi_cell_lstm.zero_state(batch_size, tf.float32)
# Convert to variables so that the state can be stored between batches
state_variables = tf.python.util.nest.pack_sequence_as(
self.initial_state,
(tf.Variable(var, trainable=False)
for var in tf.python.util.nest.flatten(initial_state)))
现在我们已经将初始状态定义为一个变量,我们可以开始通过时间展开网络。TensorFlow 提供了dynamic_rnn方法,根据输入的序列长度动态展开网络。该方法将返回一个包含表示 LSTM 输出和最终状态的张量的元组:
lstm_output, final_state = tf.nn.dynamic_rnn(
cell=multi_cell_lstm, inputs=one_hot_inputs,
initial_state=state_variable)
接下来,我们需要将最终状态存储为下一批处理的初始状态。我们使用变量的assign方法将每个最终状态存储在正确的初始状态变量中。control_dependencies方法用于强制状态更新在返回 LSTM 输出之前运行:
store_states = (
state_variable.assign(new_state)
for (state_variable, new_state) in zip(
tf.python.util.nest.flatten(self.state_variables),
tf.python.util.nest.flatten(final_state)))
with tf.control_dependencies(store_states):
lstm_output = tf.identity(lstm_output)
要从最终 LSTM 输出中获得 logit 输出,我们需要对输出应用线性变换,这样它就可以将batch size * sequence length * number of symbols作为其维度。在应用这个线性变换之前,我们需要将输出展平成大小为*number of outputs ** number of output features的矩阵:
output_flat = tf.reshape(lstm_output, (-1, lstm_sizes(-1)))
然后,我们可以定义并应用线性变换,使用权重矩阵W和偏差b来获得 logits,应用 softmax 函数,并将其重塑为一个尺寸为*batch size ** sequence length * number of characters的张量:
# Define output layer
logit_weights = tf.Variable(
tf.truncated_normal((lstm_sizes(-1), number_of_characters), stddev=0.01))
logit_bias = tf.Variable(tf.zeros((number_of_characters)))
# Apply last layer transformation
logits_flat = tf.matmul(output_flat, self.logit_weights) + self.logit_bias
probabilities_flat = tf.nn.softmax(logits_flat)
# Reshape to original batch and sequence length
probabilities = tf.reshape(
probabilities_flat, (batch_size, -1, number_of_characters))

LSTM 字符语言模型展开
训练
现在我们已经定义了网络的输入、目标和架构,让我们来定义如何训练它。训练的第一步是定义我们要最小化的损失函数。这个损失函数描述了在给定输入和目标的情况下输出错误序列的成本。因为我们是在考虑前面的字符来预测下一个字符,所以这是一个分类问题,我们将使用交叉熵损失。我们通过使用sparse_softmax_cross_ entropy_with_logits TensorFlow 函数来实现这一点。该函数将网络的 logits 输出(softmax 之前)和目标作为类标签,计算每个输出与其目标的交叉熵损失。为了减少整个序列和所有批次的损失,我们取所有损失的均值。
请注意,我们首先将目标扁平化为一个一维向量,以使它们与网络的扁平化 logits 输出兼容:
# Flatten the targets to be compatible with the flattened logits
targets_flat = tf.reshape(targets, (-1, ))
# Get the loss over all outputs
loss = tf.nn.sparse_softmax_cross_entropy_with_logits(
logits_flat, targets_flat)
# Reduce the loss to single value over all outputs
loss = tf.reduce_mean(loss)
现在我们已经定义了这个损失函数,可以在 TensorFlow 中定义训练操作,来优化我们的输入和目标批次的网络。为了执行优化,我们将使用 Adam 优化器;这有助于稳定梯度更新。Adam 优化器只是在更受控制的方式下执行梯度下降的特定方式 [28]。我们还会裁剪梯度,以防止梯度爆炸:
# Get all variables that need to be optimised
trainable_variables = tf.trainable_variables()
# Compute and clip the gradients
gradients = tf.gradients(loss, trainable_variables)
gradients, _ = tf.clip_by_global_norm(gradients, 5)
# Apply the gradients to those variables with the Adam optimisation algorithm.
optimizer = tf.train.AdamOptimizer(learning_rate=2e-3)
train_op = optimizer.apply_gradients(zip(gradients, trainable_variables))
已经定义了训练所需的所有 TensorFlow 操作,现在我们可以开始用小批量进行优化。如果data_feeder是一个生成器,返回连续的输入和目标批次,那么我们可以通过迭代地提供输入和目标批次来训练这些批次。我们每 100 个小批量重置一次初始状态,这样网络就能学习如何处理序列开头的初始状态。你可以使用 TensorFlow saver 来保存模型,以便稍后进行采样:
with tf.Session() as session:
session.run(tf.initialize_all_variables())
for i in range(minibatch_iterations):
input_batch, target_batch = next(data_feeder)
loss, _ = sess.run(
(loss, train_op),
feed_dict={ inputs: input_batch,targets: target_batch})
# Reset initial state every 100 minibatches
if i % 100 == 0 and i != 0:
for state in tf.python.util.nest.flatten(
state_variables):
session.run(state.initializer)
采样
一旦我们的模型训练完成,我们可能想要从该模型中对序列进行采样以生成文本。我们可以使用与训练模型相同的代码初始化我们的采样架构,但我们需要将batch_size设置为1,sequence_length设置为None。这样,我们可以生成单个字符串并对不同长度的序列进行采样。然后,我们可以使用训练后保存的参数初始化模型的参数。为了开始采样,我们将一个初始字符串(prime_string)输入网络的状态。输入这个字符串后,我们可以根据 softmax 函数的输出分布对下一个字符进行采样。然后我们可以输入这个采样的字符并获取下一个字符的输出分布。这个过程可以继续进行一定数量的步骤,直到生成指定大小的字符串:
# Initialize state with priming string
for character in prime_string:
character_idx = label_map(character)
# Get output distribution of next character
output_distribution = session.run(
probabilities,
feed_dict={inputs: np.asarray(((character_idx)))})
# Start sampling for sample_length steps
for _ in range(sample_length):
# Sample next character according to output distribution
sample_label = np.random.choice(
labels, size=(1), p=output_distribution(0, 0))
output_sample += sample_label
# Get output distribution of next character
output_distribution = session.run(
probabilities,
feed_dict={inputs: np.asarray((label_map(character))))
示例训练
现在我们已经有了用于训练和采样的代码,我们可以对列夫·托尔斯泰的《战争与和平》进行网络训练,并在每个批次迭代之后对网络学到的内容进行采样。让我们用短语“她出生在年份”来激活网络,看看它在训练期间如何完成它。
经过 500 批次,我们得到了这个结果:“她出生在年份 sive 但 us eret tuke Toffhin e feale shoud pille saky doctonas laft the comssing hinder to gam the droved at ay vime”。网络已经学会了一些字符的分布,并且提出了一些看起来像是单词的东西。
经过 5,000 批次,网络掌握了许多不同的词语和名称:“她出生在 年份,他有许多的 Seffer Zsites。现在在 他的冠军-毁灭中,eccention,形成了一个 Veakov 的狼 也因为他是 congrary,他突然有了 首次没有回答。” 它仍然会创造出看起来合理的单词,比如“congrary”和“eccention”。
经过 50,000 批次,网络输出以下文本:“她出生在年份 1813。最后,天空可能会表现出莫斯科的房子 有一个绝佳的机会必须通过 Rostóvs',所有的时间:坐退休,向他们展示 confure the sovereigns.” 网络似乎已经明白了一个年份是跟随我们激活字符串的一个非常合理的词。短字符串的词组似乎有意义,但是独立的句子还不具备意义。
经过 500,000 批次,我们停止了训练,网络输出了这个:“她出生在 年份 1806,当他在他的名字上表达了他的思想。公社不会牺牲他 :“这是什么?”娜塔莎问。“你还记得吗?” 我们可以看到,网络现在正在尝试构建句子,但这些句子彼此之间并不连贯。值得注意的是,在最后,它模拟了完整句子的小型对话,包括引号和标点符号。
尽管不完美,但 RNN 语言模型能够生成连贯的文本片段令人印象深刻。我们在此鼓励您尝试不同的架构,增加 LSTM 层的大小,将第三个 LSTM 层放入网络中,从互联网上下载更多文本数据,并查看您能够改进当前模型的程度。
到目前为止,我们讨论过的语言模型在许多不同的应用中被使用,从语音识别到创建能够与用户进行对话的智能聊天机器人。在接下来的部分中,我们将简要讨论深度学习语音识别模型,其中语言模型起着重要作用。
语音识别
在之前的章节中,我们看到了循环神经网络可以用来学习许多不同时间序列的模式。在本节中,我们将看看这些模型如何用于识别和理解语音的问题。我们将简要概述语音识别流水线,并提供如何在流水线的每个部分中使用神经网络的高层次视图。为了更多了解本节讨论的方法,我们希望您参考参考文献。
语音识别流水线
语音识别试图找到最有可能的单词序列的转录,考虑到提供的声学观察;这由以下表示:
转录 = argmax( P(单词 | 音频特征))
此概率函数通常由不同部分建模(请注意通常忽略归一化项 P(音频特征)):
P(单词 | 音频特征) = P(音频 特征 | 单词) * P(单词)
= P(音频特征 | 音素) * P(音素 | 单词) * P(单词)
注意
什么是音素?
音素是定义单词发音的基本声音单位。例如,单词“bat”由三个音素/b/、/ae/和/t/组成。每个音素都与特定的声音相关联。英语口语大约由 44 个音素组成。
这些概率函数中的每一个都将由识别系统的不同部分建模。典型的语音识别流水线接收音频信号并执行预处理和特征提取。然后使用这些特征在一个声学模型中,该模型尝试学习如何区分不同的声音和音素:P(音频特征 | 音素)。然后,这些音素将与发音词典的帮助匹配到字符或单词上:P(音素 | 单词)。从音频信号中提取的单词的概率然后与语言模型的概率相结合,P(单词)。然后通过一个解码搜索步骤找到最可能的序列,该步骤搜索最可能的序列(参见解码部分)。此语音识别流水线的高级概述如下图所示:

典型语音识别流水线概述
大型、实际应用的词汇语音识别流水线基于同样的流水线;然而,它们在每个步骤中使用了许多技巧和启发式方法来使问题可解。虽然这些细节超出了本节的范围,但有开源软件可用——Kaldi [29]——允许您使用先进的流水线训练语音识别系统。
在接下来的章节中,我们将简要描述标准流水线中的每个步骤以及深度学习如何帮助改善这些步骤。
语音作为输入数据
语音是一种通常传递信息的声音类型。它是通过介质(如空气)传播的振动。如果这些振动在 20 Hz 和 20 kHz 之间,则对人类是可听见的。这些振动可以被捕捉并转换成数字信号,以便在计算机上用于音频信号处理。它们通常由麦克风捕获,之后连续信号被离散采样。典型的采样率是 44.1 kHz,这意味着每秒对传入音频信号的幅度进行了 44100 次测量。请注意,这大约是最大人类听力频率的两倍。一个人说“hello world”的采样录音如下图所示:

一个人说“hello world”在时域的语音信号
预处理
在前述图像中的音频信号的录制持续了 1.2 秒。为了将音频数字化,它以每秒 44100 次(44.1 kHz)进行采样。这意味着对于这 1.2 秒的音频信号大约采集了 50000 个振幅样本。
即使是一个小例子,这些在时间维度上是很多点。为了减小输入数据的大小,在馈送到语音识别算法之前,这些音频信号通常被预处理以减少时间步数。一个典型的转换将信号转换为谱图,它表示信号中的频率随时间的变化,见下图。
这种频谱转换是通过将时间信号分成重叠窗口并对每个窗口进行傅立叶变换来完成的。傅立叶变换将信号随时间分解为组成信号的频率 [30]。得到的频率响应被压缩到固定的频率箱中。这个频率箱的数组也称为滤波器组。滤波器组是将信号分离到多个频率带中的一组滤波器。
假设前述的“hello world”录音被分成了 25 ms 的重叠窗口,并以 10 ms 的跨度。然后,利用窗口化傅立叶变换将得到的窗口转换为频率空间。这意味着每个时间步的振幅信息被转换为每个频率的振幅信息。最终的频率根据对数尺度(也称为 Mel 尺度)映射到 40 个频率箱中。得到的滤波器组谱图如下图所示。这个转换将时间维度从 50000 减少到 118 个样本,其中每个样本的大小为 40 个向量。

前图中语音信号的 Mel 频谱
特别是在较旧的语音识别系统中,这些 Mel-scale 滤波器组会通过去相关处理来消除线性依赖关系。通常,这是通过对滤波器组的对数进行离散 余弦变换 (DCT)来完成的。这个 DCT 是傅立叶变换的一种变体。这种信号转换也被称为梅尔频率倒谱系数 (MFCC)。
更近期,深度学习方法,如卷积神经网络,已学习了一些这些预处理步骤 [31], [32]。
声学模型
在语音识别中,我们希望将口语变成文本输出。这可以通过学习一个依赖时间的模型来实现,该模型接收一系列音频特征(如前一节所述),并输出可能的被说出的单词的序列分布。这个模型称为声学模型。
声学模型试图模拟一系列音频特征由一系列单词或音素生成的可能性:P (音频 特征 | 单词) = P (音频特征 | 音素) * P (音素 | 单词)。
在深度学习变得流行之前,典型的语音识别声学模型将使用隐马尔可夫模型 (HMMs) 来模拟语音信号的时间变化性 [33], [34]。每个 HMM 状态发射一组高斯混合以模拟音频信号的频谱特征。发射的高斯混合构成高斯混合模型 (GMM),它们确定每个 HMM 状态在短时间段内的声学特征拟合程度。HMMs 被用来模拟数据的序列结构,而 GMMs 则模拟信号的局部结构。
HMM 假设连续帧在给定 HMM 的隐藏状态的情况下是独立的。由于这种强条件独立假设,声学特征通常是去相关的。
深信度网络
在语音识别中使用深度学习的第一步是用深度神经网络 (DNN) 替代 GMMs [35]。DNNs 将一组特征向量作为输入,并输出 HMM 状态的后验概率:P (HMM 状态 | 音频特征)。
在这一步中使用的网络通常是在一组频谱特征上以一个通用模型进行预训练的。通常,深度信度网络 (DBN) 用于预训练这些网络。生成式预训练会创建多层逐渐复杂的特征检测器。一旦生成式预训练完成,网络会被判别性地微调以分类正确的 HMM 状态,基于声学特征。这些混合模型中的 HMMs 用于将由 DNNs 提供的段分类与完整标签序列的时间分类对齐。已经证明这些 DNN-HMM 模型比 GMM-HMM 模型具有更好的电话识别性能 [36]。
循环神经网络
本节描述了如何使用 RNN 模型来对序列数据进行建模。直接应用 RNN 在语音识别上的问题在于训练数据的标签需要与输入完全对齐。如果数据对齐不好,那么输入到输出的映射将包含太多噪音,网络无法学到任何东西。一些早期的尝试试图通过使用混合 RNN-HMM 模型来建模声学特征的序列上下文,其中 RNN 将模拟 HMM 模型的发射概率,很类似 DBNs 的使用 [37] 。
后来的实验尝试训练 LSTM(见长 短期记忆一节)输出给定帧的音素后验概率 [38]。
语音识别的下一步将是摆脱需要对齐标记数据的必要性,并消除混合 HMM 模型的需要。
CTC
标准 RNN 目标函数独立定义了每个序列步骤,每个步骤输出自己独立的标签分类。这意味着训练数据必须与目标标签完全对齐。然而,可以制定一个全局目标函数,最大化完全正确标记的概率。其思想是将网络输出解释为给定完整输入序列的所有可能标记序列的条件概率分布。然后可以通过搜索给定输入序列的最可能标记来将网络用作分类器。
连接主义 时间分类 (CTC) 是一种优化函数,它定义了所有输出序列与所有输出对齐的分布 [39]。它试图优化输出序列与目标序列之间的整体编辑距离。这种编辑距离是将输出标签更改为目标标签所需的最小插入、替换和删除次数。
CTC 网络在每个步骤都有一个 softmax 输出层。这个 softmax 函数输出每个可能标签的标签分布,还有一个额外的空白符(Ø)。这个额外的空白符表示该时间步没有相关标签。因此,CTC 网络将在输入序列的任何点输出标签预测。然后通过从路径中删除所有空白和重复标签,将输出转换为序列标记。这相当于在网络从预测无标签到预测标签,或者从预测一个标签到另一个标签时输出一个新的标签。例如,“ØaaØabØØ”被转换为“aab”。这样做的效果是只需要确保整体标签序列正确,从而消除了对齐数据的需要。
进行这种简化意味着可以将多个输出序列简化为相同的输出标签。为了找到最可能的输出标签,我们必须添加所有与该标签对应的路径。搜索这个最可能的输出标签的任务称为解码(见解码部分)。
在语音识别中这样的标注示例可以输出一系列音素,给定一系列声学特征。基于 LSTM 的 CTC 目标函数的功能是在声学建模上提供最先进的结果,并且消除了使用 HMM 对时间变化进行建模的需要 [40],[41]。
基于注意力的模型
使用 CTC 序列到序列模型的替代方案是基于注意力的模型 [42]。这些注意力模型具有动态关注输入序列部分的能力。这使它们能够自动搜索输入信号的相关部分以预测正确的音素,而无需对部分进行明确的分割。
这些基于注意力的序列模型由一个 RNN 组成,它将输入的表示解码为一系列标签,在这种情况下是音素。在实践中,输入表示将由一个模型生成,该模型将输入序列编码为合适的表示。第一个网络称为解码器网络,而后者称为编码器网络 [43]。
解码器由一个注意力模型引导,该模型在编码的输入上的每一步都集中在一个注意力窗口上。注意力模型可以由上下文(它正在关注的内容)或基于位置的信息(它正在关注的位置)的组合驱动。然后,解码器可以使用先前的信息和注意力窗口的信息来输出下一个标签(音素)。
解码
一旦我们用声学模型对音素分布进行建模并训练了语言模型(参见语言建模部分),我们就可以将它们与发音词典结合起来得到一个单词在音频特征上的概率函数:
P(单词|音频特征)= P(音频特征|音素)* P(音素|单词)* P(单词)*
这个概率函数还没有给出最终的转录结果;我们仍然需要在单词序列的分布上进行搜索,以找到最可能的转录。这个搜索过程被称为解码。解码的所有可能路径可以用格数据结构来表示:

修剪后的词格 [44]
给定一系列音频特征序列,最可能的单词序列是通过搜索所有可能的单词序列来找到的 [33]。一种基于动态规划的流行搜索算法,它保证可以找到最可能的序列是维特比算法 [45]。这个算法是一种广度优先搜索算法,主要与在 HMM 中找到最可能的状态序列相关联。
对于大词汇量的语音识别,维特比算法在实践中变得难以处理。因此,在实践中,启发式搜索算法,如束搜索,被用来尝试找到最可能的序列。束搜索启发式只在搜索过程中保留前 n 个最佳解,并假设其余所有解不会导致最可能的序列。
存在许多不同的解码算法 [46],而从概率函数中找到最佳转录的问题大多被视为未解决。
端到端模型
我们想要通过提及端到端技术来总结本章内容。深度学习方法,例如 CTC [47]、[48]和基于注意力的模型 [49],使我们能够以端到端的方式学习完整的语音识别流程。它们这样做而不需要显式地建模音素。这意味着这些端到端模型将在一个单一模型中学习声学模型和语言模型,并直接输出单词的分布。这些模型通过将所有内容合并到一个模型中展示了深度学习的力量;通过这样做,模型在概念上变得更容易理解。我们推测这将导致语音识别在未来几年被认为是一个已解决的问题。
摘要
在本章的开头,我们学习了什么是 RNN,如何训练它们,在训练过程中可能出现的问题以及如何解决这些问题。在第二部分中,我们描述了语言建模的问题以及 RNN 如何帮助我们解决一些建模语言的困难。第三部分以一个实际示例的形式将这些信息汇集在一起,介绍了如何训练一个基于字符级的语言模型,以生成基于列夫·托尔斯泰的《战争与和平》的文本。最后一节简要概述了深度学习,特别是 RNN 如何应用于语音识别问题。
本章讨论的 RNN 是一种非常强大的方法,当涉及到许多任务时非常有前途,例如语言建模和语音识别。它们特别适用于建模序列问题,可以在序列上发现模式。
参考文献
-
[1] Siegelmann, H.T.(1995)。"超越图灵极限的计算"。《科学》238(28):632–637。URL:
binds.cs.umass.edu/papers/1995_Siegelmann_Science.pdf -
[2] Alex Graves、Greg Wayne 和 Ivo Danihelka(2014)。"神经图灵机"。CoRR URL:
arxiv.org/pdf/1410.5401v2.pdf -
[3] Yann LeCun, Yoshua Bengio & Geoffrey Hinton(2015)。"深度学习"。《自然》521。URL:
www.nature.com/nature/journal/v521/n7553/full/nature14539.html -
[4] Oriol Vinyals 和 Alexander Toshev 和 Samy Bengio 和 Dumitru Erhan (2014). "Show and Tell: {A} Neural Image Caption Generator". CoRR. URL:
arxiv.org/pdf/1411.4555v2.pdf -
[5] Kyunghyun Cho 等人 (2014). "Learning Phrase Representations using RNN Encoder-Decoder for Statistical Machine Translation". CoRR. URL:
arxiv.org/pdf/1406.1078v3.pdf -
[6] Ilya Sutskever 等人 (2014). "Sequence to Sequence Learning with Neural Networks". NIPS'14. URL:
papers.nips.cc/paper/5346-sequence-to-sequence-learning-with-neural-networks.pdf -
[7] Andrej Karpathy (2015). "The Unreasonable Effectiveness of Recurrent Neural Networks". URL:
karpathy.github.io/2015/05/21/rnn-effectiveness/ -
[8] Paul J. Werbos (1990). "Backpropagation Through Time: What It Does and How to Do It" Proceedings of the IEEE. URL:
axon.cs.byu.edu/~martinez/classes/678/Papers/Werbos_BPTT.pdf -
[9] Razvan Pascanu 和 Tomas Mikolov 和 Yoshua Bengio. (2012). "Understanding the exploding gradient problem". URL:
proceedings.mlr.press/v28/pascanu13.pdf -
[10] Yoshua Bengio 等人 (1994). "Learning long-term dependencies with gradient descent is difficult". URL:
proceedings.mlr.press/v28/pascanu13.pdf -
[11] Razvan Pascanu 和 Tomas Mikolov 和 Yoshua Bengio. (2012). "Understanding the exploding gradient problem". URL:
proceedings.mlr.press/v28/pascanu13.pdf -
[12] James Martens, Ilya Sutskever. (2011). "Learning Recurrent Neural Networks with Hessian-Free Optimization". URL:
www.icml-2011.org/papers/532_icmlpaper.pdf -
[13] Ilya Sutskever 等人 (2013). "On the importance of initialization and momentum in deep learning". URL:
proceedings.mlr.press/v28/sutskever13.pdf -
[14] Geoffrey Hinton & Tijmen Tieleman. (2014) "Neural Networks for Machine Learning - Lecture 6a - Overview of mini-batch gradient descent". URL:
www.cs.toronto.edu/~tijmen/csc321/slides/lecture_slides_lec6.pdf -
[15] Martin Riedmiller 和 Heinrich Braun (1992). "Rprop - 一种快速自适应学习算法" URL:
axon.cs.byu.edu/~martinez/classes/678/Papers/riedmiller92rprop.pdf -
[16] Sepp Hochreiter 和 Jurgen Schmidhuber (1997). "Long Short-Term Memory". URL:
www.bioinf.jku.at/publications/older/2604.pdf -
[17] Gers 等人(2000 年)。"学习遗忘:带有 LSTM 的持续预测" 网址:
pdfs.semanticscholar.org/1154/0131eae85b2e11d53df7f1360eeb6476e7f4.pdf -
[18] Nikhil Buduma(2015)。"深入研究循环神经网络"。网址:
nikhilbuduma.com/2015/01/11/a-deep-dive-into-recurrent-neural-networks/ -
[19] Klaus Greff 等人(2015)。"LSTM:一场搜索空间的奥德赛"。网址:
arxiv.org/pdf/1503.04069v1.pdf -
[20] Yoshua Bengio 等人(2003)。"神经概率语言模型"。网址:
papers.nips.cc/paper/1839-a-neural-probabilistic-language-model.pdf -
[21] Christopher Olah(2014)。"深度学习、自然语言处理和表示"。网址:
colah.github.io/posts/2014-07-NLP-RNNs-Representations/ -
[22] Tomas Mikolov 等人(2013 年)。"单词和短语的分布式表示及其组成性"。网址:
papers.nips.cc/paper/5021-distributedrepresentations-of-words-and-phrases-and-theircompositionality.pdf -
[23] Tomas Mikolov 等人(2013)。"向量空间中单词表示的高效估计"。网址:
arxiv.org/pdf/1301.3781.pdf -
[24] Tomas Mikolov 等人(2013)。"连续空间词表示的语言规律"。网址:
www.microsoft.com/en-us/research/wp-content/uploads/2016/02/rvecs.pdf -
[25] Thomas Mikolov 等人(2010 年)。"基于循环神经网络的语言模型"。网址:
www.fit.vutbr.cz/research/groups/speech/publi/2010/mikolov_interspeech2010_IS100722.pdf -
[26] Frederic Morin 和 Yoshua Bengio(2005)。"分层概率神经网络语言模型"。网址:
www.iro.umontreal.ca/~lisa/pointeurs/hierarchical-nnlm-aistats05.pdf -
[27] Alex Graves(2013)。"使用循环神经网络生成序列"。网址:
arxiv.org/pdf/1308.0850.pdf -
[28] Diederik P. Kingma 和 Jimmy Ba(2014)。"Adam:一种随机优化方法"。网址:
arxiv.org/pdf/1412.6980.pdf -
[29] Daniel Povey 等人(2011 年)。"Kaldi 语音识别工具包"。网址:
kaldi-asr.org/ -
[30] Hagit Shatkay(1995 年)。"傅里叶变换——入门"。URL:
pdfs.semanticscholar.org/fe79/085198a13f7bd7ee95393dcb82e715537add.pdf -
[31] Dimitri Palaz 等人(2015 年)。"使用原始语音作为输入进行基于 CNN 的语音识别系统分析"。URL:
ronan.collobert.com/pub/matos/2015_cnnspeech_interspeech -
[32] Yedid Hoshen 等人(2015 年)"从原始多通道波形进行语音声学建模"。URL:
static.googleusercontent.com/media/research.google.com/en//pubs/archive/43290.pdf -
[33] Mark Gales 和 Steve Young。(2007)。"隐马尔可夫模型在语音识别中的应用"。URL:
mi.eng.cam.ac.uk/~mjfg/mjfg_NOW.pdf -
[34] L.R. Rabiner。(1989)。"隐马尔可夫模型及其在语音识别中的应用教程"。URL:
www.cs.ubc.ca/~murphyk/Bayes/rabiner.pdf -
[35] Abdel-rahman Mohamed 等人(2011 年)。"使用深度置信网络进行声学建模"。URL:
www.cs.toronto.edu/~asamir/papers/speechDBN_jrnl.pdf -
[36] Geoffrey Hinton 等人(2012 年)"语音识别中声学建模的深度神经网络"。URL:
www.microsoft.com/en-us/research/wp-content/uploads/2016/02/HintonDengYuEtAl-SPM2012.pdf -
[37] Tony Robinson 等人(1996)"循环神经网络在连续语音识别中的应用"。URL:
www.cstr.ed.ac.uk/downloads/publications/1996/rnn4csr96.pdf -
[38] Graves A,Schmidhuber J。(2005)"双向 LSTM 和其他神经网络架构的逐帧音素分类"。URL:
www.cs.toronto.edu/~graves/nn_2005.pdf -
[39] Alex Graves 等人(2006 年)。"使用循环神经网络标记未分段序列数据的连接时序分类法"。URL:
www.cs.toronto.edu/~graves/icml_2006.pdf -
[40] Alex Graves 等人(2013 年)"使用深度循环神经网络的语音识别"。URL:
arxiv.org/pdf/1303.5778.pdf -
[41] Dario Amodei 等人(2015 年)。"深度语音 2:英语和普通话端到端语音识别"。URL:
arxiv.org/pdf/1512.02595.pdf -
[42] Jan Chorowski 等人(2015 年)。"基于注意力的语音识别模型",URL:
arxiv.org/pdf/1506.07503.pdf -
[43] Dzmitry Bahdanau et al. (2015) "Neural Machine Translation by Jointly Learning to Align and Translate" URL:
arxiv.org/pdf/1409.0473.pdf -
[44] The Institute for Signal and Information Processing. "Lattice tools". URL:
www.isip.piconepress.com/projects/speech/software/legacy/lattice_tools/ -
[45] G.D. Forney. (1973). "The viterbi algorithm". URL:
www.systems.caltech.edu/EE/Courses/EE127/EE127A/handout/ForneyViterbi.pdf -
[46] Xavier L. Aubert (2002). "An overview of decoding techniques for large vocabulary continuous speech recognition". URL:
www.cs.cmu.edu/afs/cs/user/tbergkir/www/11711fa16/aubert_asr_decoding.pdf -
[47] Alex Graves and Navdeep Jaitly. (2014). "Towards End-To-End Speech Recognition with Recurrent Neural Networks" URL:
proceedings.mlr.press/v32/graves14.pdf -
[48] Awni Hannun. (2014) "Deep Speech: Scaling up end-to-end speech recognition". URL:
arxiv.org/pdf/1412.5567.pdf -
[49] William Chan (2015). "Listen, Attend and Spell" URL:
arxiv.org/pdf/1508.01211.pdf
第七章:棋盘游戏的深度学习
也许你读过五六十年代的科幻小说;它们充满了对 21 世纪生活会是什么样子的设想。他们想象了一个人们拥有个人喷气背包、水下城市、星际旅行、飞行汽车和真正有独立思考能力的机器人的世界。21 世纪现在已经到来了;可悲的是,我们不会得到那些飞行汽车,但由于深度学习,我们可能会得到那些机器人。
这与棋盘游戏的深度学习有什么关系?在接下来的两个章节,包括当前章节,我们将看看如何构建人工智能(AI),以学习游戏环境。现实具有广阔的可能性空间。即使是进行简单的人类任务,如让机器人手臂抓取物体,也需要分析大量的感官数据并控制许多用于移动手臂的连续响应变量。
游戏作为测试通用学习算法的绝佳场所。它们给你一个庞大但可控制的可能性环境。此外,说到电脑游戏,我们知道人类可以仅通过屏幕上可见的像素和最微小的指示就学会玩游戏。如果我们将相同的像素以及一个目标输入到计算机代理中,我们知道我们有一个可解决的问题,只要使用正确的算法。实际上,对于电脑来说,问题更容易,因为人类可以识别出他们在视野中看到的东西实际上是游戏像素,而不是屏幕周围的区域。这就是为什么如此多的研究人员将游戏视为开发真正的人工智能的绝佳起点——能够独立于我们运行的自学习机器。此外,如果你喜欢游戏,它会非常有趣。
在本章中,我们将介绍解决棋盘游戏(如跳棋和国际象棋)的不同工具。最终,我们将积累足够的知识,以便理解并实现构建 AlphaGo 的深度学习解决方案,该解决方案击败了最伟大的人类围棋选手。我们将使用各种深度学习技术来实现这一点。接下来的章节将在此基础知识之上构建,并介绍如何使用深度学习来学习如何玩计算机游戏,如乒乓球和打砖块。
我们将在两个章节中涵盖的概念列表如下:
-
极小极大算法
-
蒙特卡洛树搜索
-
强化学习
-
策略梯度
-
Q 学习
-
演员-评论家
-
基于模型的方法
我们将使用一些不同的术语来描述任务及其解决方案。以下是一些定义。它们都使用基本迷宫游戏的示例,因为它是一个很好的、简单的强化学习环境的例子。在迷宫游戏中,有一组位置,它们之间有路径。在这个迷宫中有一个代理,它可以利用路径在不同的位置之间移动。一些位置与奖励相关联。代理的目标是通过迷宫找到最好的奖励。

图 1
-
Agent 是我们试图学习行动的实体。在游戏中,这是玩家,他将尝试找到迷宫的出口。
-
环境 是代理操作的世界/关卡/游戏,也就是迷宫本身。
-
奖励 是代理在环境中获得的反馈。在这个示例迷宫游戏中,它可能是出口方块或图像中的胡萝卜,代理正在尝试收集的物品。一些迷宫还可能有陷阱,会给予负面奖励,代理应该尽量避免。
-
状态 指的是代理关于其当前环境的所有可用信息。在迷宫中,状态就是代理的位置。
-
行动 是代理可以采取的可能响应或一组响应。在迷宫中,这是代理可以从一个状态到另一个状态的潜在路径。
-
控制策略 确定了代理将采取的行动。在深度学习的背景下,这是我们将要训练的神经网络。其他策略可能是随机选择行动或根据程序员编写的代码选择行动。
这一章大部分内容都是代码密集型的,因此,作为从书中复制所有示例的替代方法,你可以在 GitHub 仓库 github.com/DanielSlater/PythonDeepLearningSamples 中找到完整的代码。章节中的所有示例都是使用 TensorFlow 呈现的,但这些概念可以转化为其他深度学习框架。
早期游戏 AI
50 年代开始,研究人员构建了玩跳棋和国际象棋的程序,从而开始了构建 AI 来玩游戏的工作。这两个游戏有一些共同点:
-
它们是零和游戏。一个玩家获得的任何奖励都对应着另一个玩家的损失,反之亦然。当一个玩家赢了,另一个玩家输了。不存在合作的可能性。例如,考虑一个游戏,比如囚徒困境;在这里,两个玩家可以同意合作,并且都获得较小的奖励。
-
它们都是完全信息游戏。游戏的整个状态对于两个玩家始终是已知的,不像扑克牌这样的游戏,你的对手手中确切的牌是未知的。这个事实减少了人工智能必须处理的复杂性。它还意味着关于什么是最佳移动的决定可以基于当前状态。在扑克中,关于如何打牌的假设最佳决策需要的信息不仅仅是你目前的手牌和每个玩家可用的金额,还有关于对手的打法以及他们在之前位置中的出价。
-
这两个游戏都是确定性的。如果任一玩家采取了某个移动,那么下一个状态将是确切的。在某些游戏中,游戏可能基于掷骰子或从牌堆中随机抽取卡片;在这些情况下,将会有许多可能的下一个状态需要考虑。
在国际象棋和跳棋中完美信息和确定性的组合意味着鉴于当前状态,我们可以确切地知道如果当前玩家采取行动我们将处于什么状态。这个属性也适用于如果我们有一个状态,然后采取行动导致一个新的状态。我们可以再次在这个新状态中采取行动,以保持玩得尽可能长的时间。
为了尝试一些掌握棋盘游戏的方法,我们将使用名为Tic-Tac-Toe的游戏的 Python 实现来举例。也被称为井字游戏,这是一个简单的游戏,玩家轮流在一个 3 乘 3 的网格上做标记。第一个在一行中得到三个标记的玩家获胜。Tic-Tac-Toe是另一种确定性、零和、完全信息游戏,在这里选择它是因为它的 Python 实现比国际象棋简单得多。事实上,整个游戏可以用不到一页的代码来完成,这将在本章后面展示。
使用极大极小算法来评估游戏状态
假设我们想要计算在一个零和、确定性、完全信息游戏中的最佳移动。我们怎么做呢?首先,考虑到我们有完全信息,我们知道确切地哪些移动是可用的。鉴于游戏是确定性的,我们知道每一个移动会导致游戏状态的确切变化。对于对手的移动也是如此;我们也知道他们有哪些可能的移动以及每个移动导致的状态会是怎样。
寻找最佳移动的一种方法是为每个玩家在每个状态下构造每个可能移动的完整树,直到我们到达游戏结束的状态。游戏的最终状态也称为终端状态。我们可以为这个终端状态赋予一个值;赢得的可以是值 1,平局是 0,输掉是-1。这些值反映了对我们来说状态的可取之处。我们宁愿赢也不愿平局,而宁愿平局也不愿输。图 2显示了一个例子:

图 2:井字棋所有状态的树
在一个终止状态中,我们可以回到玩家选择导致终止状态的移动的状态。那位玩家,其目标是找到最佳的可能移动,可以确定他们将从他们将采取的行动中获得的确切值,即他们最终将游戏带入的终止状态。他们显然会选择将导致对自己可能获得的最佳值的移动。如果他们有选择要么导致赢得终止状态要么输掉终止状态的行动,他们将选择导致赢得状态的那个行动。
终止状态被选择的状态的值可以标记为玩家可能采取的最佳行动的值。这给了我们在这种状态下的玩家的价值。但是在这里我们正在玩一个双人游戏,所以如果我们回到一个状态,我们将处于另一位玩家要做出移动的状态。现在在我们的图中,我们有了对手在该状态下将从他们在该状态下的行动中获得的价值。
由于这是一个零和游戏,我们希望我们的对手表现得尽可能糟糕,因此我们将选择导致对方状态值最低的移动。如果我们不断回溯状态图,标记所有状态的值为任何动作可能导致的最佳状态值,我们就可以确定当前状态中的最佳动作。

图 3. 极小极大算法
通过这种方式,可以构建游戏的完整树,显示我们可以在当前状态下进行的最佳移动。这种方法称为极小极大算法,是早期研究者用于国际象棋和跳棋游戏的方法。
尽管这种方法告诉我们任何零和、确定性、完美信息游戏的确切最佳移动,但不幸的是它有一个主要问题。国际象棋平均每回合大约有 30 个可能的移动,并且游戏平均持续 40 回合。因此,要从国际象棋的第一个状态构建到所有终止状态将需要大约 30⁴⁰ 个状态。这比世界上最好的硬件可能的数量要大得多。在谈论游戏时,玩家每回合可以采取的移动数量称为广度,游戏每回合采取的移动数量称为深度。
要使极小极大算法在棋类游戏中可行,我们需要大幅减少搜索的深度。与其计算整个树直到游戏结束,我们可以构建我们的树到一个固定的深度,比如从当前状态开始后的六步。在每个不是实际终止状态的叶子节点上,我们可以使用一个评估函数来估计在该状态下玩家获胜的可能性。
对于国际象棋,一个良好的评估函数是对每个玩家可用的棋子数量进行加权计数。因此,兵的得分为 1 分,主教或骑士的为 3 分,车的为 5 分,后的为 8 分。如果我有三个兵和一个骑士,我得到六分;同样地,如果你有两个兵和一个车,你有七分。因此,你领先一分。通常情况下,在国际象棋中,剩下的棋子更多的玩家往往会取胜。然而,任何曾经与好的交换牺牲对手交战的国际象棋玩家都会知道,这个评估函数是有局限性的。
实现 Python 版的 Tic-Tac-Toe 游戏
让我们构建一个基本的Tic-Tac-Toe实现,这样我们就可以看到 min-max 算法的实现是什么样子的。如果你不想复制所有这些,你可以在 GitHub 仓库github.com/DanielSlater/PythonDeepLearningSamples的tic_tac_toe.py文件中找到完整的代码。
在游戏棋盘中,我们将用一个 3 x 3 的整数元组表示。使用元组而不是列表,以便以后能够在匹配的棋盘状态之间得到相等。在这种情况下,0表示一个未被玩过的方格。两名玩家将分别用1和-1表示。如果玩家一在一个方格上下了一步,那么该方格将被标记为他们的数字。所以让我们开始:
def new_board():
return ((0,0,0),
(0,0,0),
(0,0,0))
在玩家进行下一步前,将会调用new_board方法,准备好一个新的棋盘:
def apply_move(board_state, move, side):
move_x, move_y = move
state_list = list(list(s) for s in board_state)
state_list[move_x][move_y] = side
return tuple(tuple(s) for s in state_list)
apply_move方法接受board_state的 3 x 3 元组之一,并返回应用了给定方向移动的新的board_state。移动将是一个包含两个整数坐标的长度为 2 的元组。方向将是代表玩家的整数,要么是 1,要么是-1:
import itertools
def available_moves(board_state):
for x, y in itertools.product(range(3), range(3)):
if board_state[x][y] == 0:
yield (x, y)
这个方法为给定的 3 x 3 board_state列出了合法的移动列表,它就是所有非零方格。现在我们只需要一个方法来确定玩家是否已经连成了三个获胜的标记:
def has_3_in_a_line(line):
return all(x==-1 for x in line) | all(x==1 for x in line)
has_3_in_a_line将获取棋盘上的三个方格的序列。如果所有的方格都是 1 或-1,这意味着其中一名玩家连成了三个获胜的标记,赢得了胜利。然后,我们需要对 Tic-Tac-Toe 棋盘上的每条可能的线运行这个方法,以确定玩家是否已经获胜:
def has_winner(board_state):
# check rows
for x in range(3):
if has_3_in_a_line (board_state[x]):
return board_state[x][0]
# check columns
for y in range(3):
if has_3_in_a_line([i[y] for i in board_state]):
return board_state[0][y]
# check diagonals
if has_3_in_a_line([board_state[i][i] for i in range(3)]):
return board_state[0][0]
if has_3_in_a_line([board_state[2 - i][i] for i in range(3)]):
return board_state[0][2]
return 0 # no one has won
只需这几个功能,你就可以玩一局Tic-Tac-Toe游戏。简单地开始,获取一个新的棋盘,然后让玩家依次选择移动并将这些移动应用到board_state上。如果我们发现没有剩余可用的移动,游戏就是平局。否则,如果has_winner返回1或-1,这意味着其中一名玩家获胜。接下来,让我们编写一个简单的函数来运行一个 Tic-Tac-Toe 游戏,其中的移动由我们传递的方法来决定,这些方法将会是我们将尝试的不同 AI 玩家的控制策略:
def play_game(plus_player_func, minus_player_func):
board_state = new_board()
player_turn = 1
我们宣告这个方法,并将其带到将为每个玩家选择动作的函数中。每个player_func将会有两个参数:第一个是当前的board_state,第二个是玩家所执的一方,1 或-1。player_turn变量将为我们跟踪这一切:
while True:
_available_moves = list(available_moves(board_state))
if len(_available_moves) == 0:
print("no moves left, game ended a draw")
return 0.
这是游戏的主要循环。首先,我们要检查board_state上是否还有可用的走法;如果有,游戏还没结束,就是平局:
if player_turn > 0:
move = plus_player_func(board_state, 1)
else:
move = minus_player_func(board_state, -1)
运行与轮到哪个玩家的函数相关联的方法来决定一步棋:
if move not in _avialable_moves:
# if a player makes an invalid move the other player wins
print("illegal move ", move)
return -player_turn
如果任一玩家走出违规步骤,那就是自动认输。代理应该更明白:
board_state = apply_move(board_state, move, player_turn)
print(board_state)
winner = has_winner(board_state)
if winner != 0:
print("we have a winner, side: %s" % player_turn)
return winner
player_turn = -player_turn
将走法应用到board_state上,并检查我们是否有获胜者。如果有,结束游戏;如果没有,切换player_turn到另一个玩家,并重新循环。
以下是我们如何编写一种控制策略的方法,该方法将完全随机选择可用的合法走法:
def random_player(board_state, side):
moves = list(available_moves(board_state))
return random.choice(moves)
让我们运行两个随机玩家相互对战,然后检查输出是否可能看起来像这样:
play_game(random_player, random_player)
((0, 0, 0), (0, 0, 0), [1, 0, 0])
([0, -1, 0], (0, 0, 0), [1, 0, 0])
([0, -1, 0], [0, 1, 0], [1, 0, 0])
([0, -1, 0], [0, 1, 0], [1, -1, 0])
([0, -1, 0], [0, 1, 1], [1, -1, 0])
([0, -1, 0], [0, 1, 1], [1, -1, -1])
([0, -1, 1], [0, 1, 1], [1, -1, -1])
we have a winner, side: 1
现在我们有了一种很好的方法来尝试在棋盘游戏上尝试不同的控制策略,所以让我们写些稍微好一点的东西。我们可以从一个 min-max 函数开始,该函数的水平应该比我们当前的随机玩家高得多。Min-max 函数的完整代码也可以在 GitHub 库的min_max.py文件中找到。
井字棋是一个可能性空间较小的游戏,所以我们可以简单地从棋盘的起始位置运行整个游戏的 min-max,直到我们遍历了每个玩家的每个可能走法。但是使用一个评估函数是个好习惯,因为对我们玩的大多数其他游戏来说,情况并非如此。这里的评估函数将为我们在后面得到两条线中的一个空位置时给我们一个分数;如果我们的对手实现了这一点,那么他将是相反的。首先,我们将需要一个为我们可能做出的每条线得分的方法。score_line将使用长度为 3 的序列并对它们进行评分:
def score_line(line):
minus_count = line.count(-1)
plus_count = line.count(1)
if plus_count == 2 and minus_count == 0:
return 1
elif minus_count == 2 and plus_count == 0:
return -1
return 0
然后evaluate方法简单地遍历井字棋棋盘上的每条可能的线,并将它们加总起来:
def evaluate(board_state):
score = 0
for x in range(3):
score += score_line(board_state[x])
for y in range(3):
score += score_line([i[y] for i in board_state])
#diagonals
score += score_line([board_state[i][i] for i in range(3)])
score += score_line([board_state[2-i][i] for i in range(3)])
return score
然后,我们来到实际的min_max算法方法:
def min_max(board_state, side, max_depth):
best_score = None
best_score_move = None
该方法的前两个参数,我们已经熟悉了,是board_state和side;不过,max_depth是新的。Min-max 是一种递归算法,max_depth将是我们在停止沿树向下移动并仅评估其以获取结果之前所使用的最大递归调用次数。每次我们递归调用min_max时,我们将max_depth减少 1,当我们达到 0 时停止评估:
moves = list(available_moves(board_state))
if not moves:
return 0, None
如果没有可走的步骤,那么就没有必要评估任何东西;这是平局,所以让我们返回一个分数为 0:
for move in moves:
new_board_state = apply_move(board_state, move, side)
现在我们将详细介绍每个合法走法,并创建一个应用了该走法的new_board_state:
winner = has_winner(new_board_state)
if winner != 0:
return winner * 10000, move
检查这个new_board_state是否已经获胜。如果游戏已经获胜,则不需要再进行递归调用。在这里,我们将获胜者的分数乘以 1,000;这只是一个任意的大数字,以便实际的胜利或失败总是被认为比我们可能从对evaluate的调用中获得的最极端结果更好/更差:
else:
if max_depth <= 1:
score = evaluate(new_board_state)
else:
score, _ = min_max(new_board_state, -side, max_depth - 1)
如果您没有获胜位置,那么算法的真正精华就开始了。如果达到max_depth,那么现在就是评估当前board_state以获得我们的启发式的时候,这能告诉我们当前位置对第一个玩家有多有利。如果还没有达到max_depth,则递归调用min_max,直到达到底部:
if side > 0:
if best_score is None or score > best_score:
best_score = score
best_score_move = move
else:
if best_score is None or score < best_score:
best_score = score
best_score_move = move
return best_score, best_score_move
现在我们对new_board_state中的评分有了,我们想要获得最佳或最差的得分位置,取决于我们是哪一方。我们通过best_score_move变量跟踪导致这一点的移动,最终在方法结束时将其与分数一起返回。
现在可以创建一个min_max_player方法,以便回到我们之前的play_game方法:
def min_max_player(board_state, side):
return min_max(board_state, side, 5)[1]
现在,如果我们让random_player和min_max玩家进行一系列游戏,我们会发现min_max玩家几乎每次都会赢。
尽管重要理解 min-max 算法,但实际上从未被使用,因为有一个更好的版本:带有 alpha-beta 剪枝的 min-max。这利用了树的某些分支可以被忽略或剪枝的事实,而无需完全评估它们。alpha-beta 剪枝将产生与 min-max 相同的结果,但平均搜索时间减少了一半。
要解释 alpha-beta 剪枝背后的思想,让我们考虑在构建我们的 min-max 树时,一半的节点试图做出决策以最大化分数,另一半则试图最小化它。当我们开始评估一些叶子时,我们会得到对 min 和 max 决策都有利的结果。如果通过树的某条路径得分为-6,min 分支知道它可以通过跟随该分支获得这个分数。阻止它使用这个分数的是 max 决策必须做出决策,而且它不能选择对 min 节点有利的叶子。
但随着更多叶子的评估,另一个可能对 max 节点有利的叶子出现,得分为+5。max 节点永远不会选择比这更差的结果。但是现在我们对 min 和 max 都有了得分,我们知道如果开始沿着一个最佳 min 得分比-6 更糟糕,而最佳 max 得分比+5 更糟糕的分支,那么无论 min 还是 max 都不会选择这个分支,我们就可以节省对整个分支的评估。
Alpha beta 剪枝中的 alpha 存储了最大决策可以实现的最佳结果。Beta 存储了最小决策可以实现的最佳结果(最低分数)。如果 alpha 大于或等于 beta,我们知道可以跳过对当前分支的进一步评估。这是因为这两个决策已经有更好的选择。
图 4给出了这一点的一个示例。在这里我们看到,从第一个叶子开始,我们可以将 alpha 值设为 0。这是因为一旦最大玩家在一个分支中找到分数为 0,他们就不需要选择一个更低的分数。接下来,第三个叶子的位置上,分数再次为 0,所以最小玩家可以将他们的 beta 分数设为 0。读取branch ignored的分支不再需要进行评估,因为 alpha 和 beta 都是 0。
要理解这一点,考虑一下从评估分支中可能获得的所有可能结果。如果结果为 +1,则最小玩家只需选择已经获得分数为 0 的现有分支。在这种情况下,分支被忽略的分支向左(left)走。如果分数结果为 -1,那么最大玩家只需选择图像中得分为 0 的最左边分支。最后,如果分数为 0,这意味着没有人发生改进,因此我们的位置的评估保持不变。你永远不会得到一个结果,评估一个分支会改变位置的整体评估。以下是修改后使用 alpha beta 剪枝的 min-max 方法的示例:
import sys
def

图 4:使用 alpha beta 剪枝的 min-max 方法
min_max_alpha_beta(board_state, side, max_depth,
alpha=-sys.float_info.max,
beta=sys.float_info.max):
现在我们传入 alpha 和 beta 作为参数;我们停止搜索那些小于 alpha 或大于 beta 的分支:
best_score_move = None
moves = list(available_moves(board_state))
if not moves:
return 0, None
for move in moves:
new_board_state = apply_move(board_state, move, side)
winner = has_winner(new_board_state)
if winner != 0:
return winner * 10000, move
else:
if max_depth <= 1:
score = evaluate(new_board_state)
else:
score, _ = min_max_alpha_beta(new_board_state, -side, max_depth - 1, alpha, beta)
现在,当我们递归调用 min_max_alpha_beta 时,我们传入可能已经更新的新 alpha 和 beta 值作为搜索的一部分:
if side > 0:
if score > alpha:
alpha = score
best_score_move = move
side > 0 表达式意味着我们希望最大化我们的分数,所以如果新的分数比我们当前的 alpha 更好,我们会将分数存储在 alpha 变量中:
else:
if score < beta:
beta = score
best_score_move = move
如果 side 是 < 0,我们在进行最小化,所以把最低分数存储在 beta 变量中:
if alpha >= beta:
break
如果 alpha 大于 beta,那么这个分支不能改善当前的分数,所以我们停止搜索:
return alpha if side > 0 else beta, best_score_move
1997 年,IBM 创建了一个名为深蓝的国际象棋程序。它是第一个击败现任世界象棋冠军加里·卡斯帕罗夫的程序。虽然这是一个了不起的成就,但很难称深蓝具有智能。尽管它具有巨大的计算能力,但其基础算法只是上世纪 50 年代的 min-max 算法。唯一的主要区别是深蓝利用了国际象棋的开局理论。
开局理论由一系列从起始位置开始的走法组成,这些走法被认为会导致有利或不利的局面。例如,如果白方棋手以 e4(王前的兵向前移动两格)开局,那么黑方应该回应 c5,这就是西西里防御,对于这个局面,有许多书籍介绍接下来可能出现的走法。深蓝计算机只是简单地遵循这些开局书籍推荐的最佳走法,并且只在开局走法结束时开始计算最佳的极小极大走法。这样,它既省去了计算时间,也利用了人类在国际象棋开局阶段找到最佳局面所进行的大量研究。
学习一个价值函数
让我们对极小极大算法需要计算的具体数量进行更详细的了解。如果我们的游戏广度为b,深度为d,那么使用极小极大评估一个完整游戏需要构建一棵树,最终有d(`b`)个叶子。如果我们使用最大深度`n`和一个评估函数,它将把我们的树大小减小到`n`(b)。但这是一个指数方程,即使n只有 4,b为 20,你仍然有 1,099,511,627,776 种可能性需要评估。这里的权衡是,随着n的降低,我们的评估函数在较浅的层次上被调用,这可能比局面的预估质量要差得多。再一次,以国际象棋为例,我们的评估函数只是简单地统计棋盘上剩下的棋子数量。在较浅的位置停止可能会忽略最后一步将皇后放在可以在下一步中被吃掉的位置的事实。更大的深度总是意味着更准确的评估。
训练 AI 掌握围棋
国际象棋中的可能性虽然很多,但并不是如此之多,以至于用一台强大的计算机无法击败世界上最伟大的人类棋手。围棋是一种源远流长的中国古老游戏,其起源可以追溯到 5500 多年前,远比国际象棋复杂得多。在围棋中,一子可以放在 19 x 19 的棋盘上的任何地方。首先有 361 个可能的走法。因此,要往前搜索k步,你必须考虑 361k 种可能性。使情况更加困难的是,在国际象棋中,你可以通过计算每一方的棋子数量相对精确地评估一个局势的好坏,但在围棋中,没有找到这样简单的评估函数。 要知道一个局势的价值,你必须计算到游戏结束,再往后走 200 多步。这使得游戏通过极小极大来达到一个良好水平几乎是不可能的。

图 5
要深入了解围棋的复杂性,值得思考人类学习围棋与国际象棋的方式。当初学国际象棋时,新手们会在棋盘向对手方向的一系列移动中前进。在某个时刻,他们会做出一步让自己的棋子暴露给对方吃掉的移动。于是对手就会乐意接受并吃掉这个棋子。这时新手玩家立刻就会意识到他们上一步走得不好,如果想要提高,就不能再犯同样的错误。对于玩家来说很容易找出他们做错了什么,尽管要一直自我纠正可能需要大量的实践。
另一方面,当初学围棋时,它看起来就像是棋盘上一系列几乎是随机的移动。在某个时刻,双方玩家都用完了他们的棋子,然后计算局面以确定谁赢了。初学者发现自己输了,盯着摆在不同位置的一堆棋子,想弄清楚到底发生了什么。对于人类来说,围棋是极其困难的,需要高度的经验和技巧才能理解玩家出错的地方。
另外,围棋没有像国际象棋那样的开局理论书籍。围棋的开局理论不是一系列计算机可以遵循的移动序列,而是许多通用原则,例如要追求的良好形状或者控制棋盘角落的方法。围棋中有一种叫做定式的东西,它是研究出的一系列走法,已知会导致不同的优势。但所有这些都必须在玩家意识到可能存在某种特定局面时应用;它们不是可以盲目遵循的动作。
对于围棋等游戏,评估如此困难的一个方法是蒙特卡罗树搜索(MCTS)。如果你学过贝叶斯概率,你会听说过蒙特卡罗采样。这涉及从概率分布中采样以获得无法计算的值的近似值。MCTS 类似。一个样本包括随机选择每个玩家的动作,直到达到终局状态。我们维护每个样本的统计数据,这样在完成后,我们就可以从当前状态中选择具有最高平均成功率的动作。这是我们之前提到的井字棋游戏的 MCTS 示例。完整的代码也可以在 GitHub 存储库的 monte_carlo.py 文件中找到:
import collections
def monte_carlo_sample(board_state, side):
result = has_winner(board_state)
if result != 0:
return result, None
moves = list(available_moves(board_state))
if not moves:
return 0, None
这里的 monte_carlo_sample 方法从给定位置生成一个样本。同样,我们有一个方法,它的参数是 board_state 和 side。这个方法将被递归调用,直到我们达到一个终局状态,所以要么是平局因为不能再下新的棋了,要么是某一方玩家赢了:
# select a random move
move = random.choice(moves)
result, next_move = monte_carlo_sample(apply_move(board_state, move, side), -side)
return result, move
将从局面中的合法移动中随机选择一个移动,并递归调用样本方法:
def monte_carlo_tree_search(board_state, side, number_of_samples):
results_per_move = collections.defaultdict(lambda: [0, 0])
for _ in range(number_of_samples):
result, move = monte_carlo_sample(board_state, side)
results_per_move[move][0] += result
results_per_move[move][1] += 1
从这个棋盘状态中取出蒙特卡罗样本,并根据它们更新我们的结果:
move = max(results_per_move,
key=lambda x: results_per_move.get(x)[0] /
results_per_move[move][1])
获得同样结果最佳走法:
return results_per_move[move][0] / results_per_move[move][1], move
这就是将所有内容整合在一起的方法。我们将调用monte_carlo_sample方法number_of_samples次,跟踪每次调用的结果。然后我们返回平均表现最佳的走法。
考虑一下 MCTS 得到的结果与涉及最小最大的结果有多大不同是很有意义的。如果我们以国际象棋为例,以这个局面来说,白方有一个获胜的着法,将车移到后排 c8,将黑方将军。使用最小最大算法,这个局面会被评价为白方获胜的局面。但是使用 MCTS,考虑到这里的所有其他走法都会导致黑方潜在的胜利,这个局面将被评价为对黑方有利。这就是为什么 MCTS 在国际象棋中表现很差,并且应该让你感受到为什么只有在最小最大算法不可行时才应该使用 MCTS。在围棋这类游戏中,传统上使用 MCTS 找到了最佳的人工智能表现。

图 6:一个被蒙特卡洛采样严重低估的国际象棋局面。如果轮到白走,他们有一个获胜的着法;但是,如果随机走子,黑方有获胜的机会。
将置信上界应用到树结构
总结一下,最小最大算法可以给出具体的最佳着法,假设有完美的信息;但是 MCTS 只给出一个平均值;尽管它允许我们处理无法用最小最大算法评估的更大状态空间。有没有办法改进 MCTS,使其在给出足够的评价时能收敛到最小最大算法?是的,置信上界应用到树结构的蒙特卡洛树搜索(UCT)确实可以做到这一点。其背后的想法是把 MCTS 看作是一个多臂老丨虎丨机问题。多臂老丨虎丨机问题是我们有一组老丨虎丨机——单臂老丨虎丨机——每台机器都有一个未确定的赔付和每次游戏的平均赔付金额。每台机器的赔付是随机的,但平均赔付金额可能差异很大。我们该如何确定要玩哪些老丨虎丨机?
在选择老丨虎丨机时需要考虑两个因素。第一点是显而易见的,即利用价值,也就是给定老丨虎丨机预期的回报。为了最大化赔付,我们需要始终玩出预期赔付最高的机器。第二点是探索价值,我们希望我们玩的机器增加我们对不同机器赔付的信息。
如果我们玩机器A三次,你将得到 13、10 和 7 的回报,平均回报为 10。我们也有机器B;我们已经玩了它一次,得到了 9 的回报。在这种情况下,可能更倾向于玩机器B,因为尽管平均回报较低,为 9 对 10。我们只玩了一次的事实意味着较低的支付可能只是运气不佳。如果我们再次玩它并得到 13 的回报,机器 B 的平均为 11。因此,我们应该切换到玩那台机器以获得最佳回报。
多臂老丨虎丨机问题在数学领域得到了广泛研究。如果我们重构我们的 MCTS 评估,使其看起来像一个多臂老丨虎丨机问题,我们就可以利用这些成熟的理论。一种思考方式是,与其将问题视为最大化奖励,不如将其视为最小化遗憾的问题。这里的遗憾定义为我们为我们玩的机器获得的奖励与如果我们从一开始就知道最佳机器会得到的最大可能奖励之间的差异。如果我们遵循一项政策,p(a)每次选择一个能给予奖励的动作。给定r为最佳可能行动的奖励的t次玩后的遗憾如下:

如果我们选择一个始终选取奖励最高的机器的政策,它可能并不是真正的最佳机器。因此,我们的遗憾会随着每次玩而线性增加。同样,如果我们采取一个始终试图探索以找到最佳机器的政策,我们的遗憾也会线性增加。我们希望的是一项p(a)的政策,其增长呈次线性时间。
最好的理论解决方案是根据置信区间执行搜索。置信区间是我们期望真实均值落在其中的范围,具有一定的概率。在面对不确定性时,我们想要保持乐观。如果我们不知道某件事,我们想要找出答案。置信区间代表了我们对给定随机变量的真实均值的不确定性。根据你的样本均值加上置信区间选择某样本,这将鼓励你探索可能性的空间,并同时加以利用。
对于 i.i.d 在 0 到 1 范围内的随机变量x,在 n 个样本上,真实均值大于样本均值的概率,即
加上常数u,由 Hoeffding 不等式给出:Hoeffding, Wassily (1963). 有界随机变量之和的概率不等式,美国统计协会杂志:

我们希望使用这个方程来找到每台机器的上界置信度。* E {x}, x *, 和 * n *都是我们已经有的统计学的一部分。我们需要解这个方程来计算一个值 * u *。为了做到这一点,把方程的左边化简为 p,并找到它与右边相等的点:

我们可以重排它,让u用n和p表示:



现在我们希望选择一个* p *的值,这样我们的精度随时间增加而提高。如果我们设
,那么当 n 趋近无穷大时,我们的损失将趋向于 0。代入这个值,我们可以简化为:

均值加上 u 是我们的上界置信界,所以我们可以用它来得到UCB1(上置信界)算法。我们可以用这些值代替我们之前在多臂老丨虎丨机问题中看到的值,其中* r * [* i ] 是从机器i得到的奖励的总和, n * [* i ]是机器i的玩的次数, n *是所有机器的总玩的次数:

我们总是希望选择能为我们带来最高分数的机器。如果我们这样做,我们的损失将以对数的方式随着玩的次数增加,这是我们可以实现的理论最佳情况。使用这个方程做出我们的行动选择会导致这样的行为,我们在早期会尝试各种各样的机器,但我们越多地尝试单一机器,它就会更鼓励我们最终尝试不同的机器。
还要记住,这一系列方程的假设是在早期方程中的 x 的范围,以及当我们将其应用到多臂老丨虎丨机问题时的* r *,它们的值都在 0 到 1 的范围内。所以,如果我们的工作不在这个范围内,我们需要缩放我们的输入。不过,我们并没有做出任何关于分布性质的假设;它可以是高斯的、二项式的等等。
现在我们已经找到了从一组未知分布中采样的最优解决方案;我们如何将其应用于 MCTS 呢?最简单的方法是将当前棋盘状态的第一个移动视为老丨虎丨机或投币机。尽管这样会稍微提高顶层的估计,但下面的每一步都会完全随机,这意味着* r * [* i *] 的估计将会非常不准确。
或者,我们可以将树的每个分支上的每个移动都视为一个多臂赌博机问题。问题在于,如果我们的树非常深,随着我们的评估越深入,我们将到达我们以前从未遇到过的位置,因此我们将没有样本用于我们需要在其中选择的移动范围。我们将为大范围的位置保留大量统计数据,其中大多数将永远不会被使用。
妥协解决方案称为树的上限置信,是我们接下来要讨论的内容。我们将从当前棋盘状态开始进行连续的模拟。在树的每个分支处,我们有一系列可供选择的操作,如果我们对每个潜在移动都有先前的样本统计数据,我们将使用 UCB1 算法来选择用于模拟的动作。如果我们没有每个移动的样本统计数据,我们将随机选择移动。
我们如何决定保留哪些样本统计数据?对于每次模拟,我们为我们以前没有统计数据的第一个遇到的位置保留新的统计数据。完成模拟后,我们更新我们跟踪的每个位置的统计数据。这样,我们忽略了模拟深处的所有位置。经过 x 次评估,我们应该恰好有 x 个树节点,每次模拟增加一个。更重要的是,我们跟踪的节点可能位于我们最常使用的路径周围,使我们能够通过增加我们在树深处评估的移动的准确性来增加我们的顶层评估准确性。
步骤如下:
-
从当前棋盘状态开始进行一次模拟。当你选择一个移动时,请执行以下操作:
-
如果你对当前位置的每一步都有统计数据,就使用 UCB1 算法来选择移动。
-
否则,随机选择移动。如果这是第一个随机选择的位置,则将其添加到我们正在保留统计数据的位置列表中。
-
-
运行模拟,直到达到终止状态,这将给出这次模拟的结果。
-
更新你正在保留统计数据的每个位置的统计数据,指示你在模拟中经历了什么。
-
重复,直到达到最大模拟次数。应用于树的上限置信边界,每个位置的统计数据显示在方框中:
![应用于树的上限置信边界]()
-
上图说明了这是如何发生的。在位置 A,四个可能的移动都收集了统计数据。因此,我们可以使用 UCB1 算法来选择最佳移动,平衡开发性和探索性的价值。在上图中,选择了最左边的移动。这将我们带到位置 B;在这里,只有三个可能的移动中的两个收集了统计数据。因此,你需要为这次模拟随机选择一个移动。由于巧合,选择了最右边的移动;剩下的移动是随机选择的,直到到达最终的位置 C,在那里圈圈玩家获胜。然后,将这些信息应用于一个图表,如下图所示:
![应用于树的置信上界]()
-
我们会为我们经过且已经有统计数据的任何一个位置添加统计信息,因此第一个图表中的 1/2 现在变成了 2/3。我们还会为我们遇到的第一个没有统计信息的位置添加统计信息。在这里,它是第二行中最右边的位置;它现在的分数是 1/1,因为圈圈玩家赢了。如果再次选择这条分支并且你到达位置 D,使用 UCB1 算法来选择移动,而不是随机选择。
-
这是我们的井字棋游戏在 Python 中的实现:
def upper_confidence_bounds(payout, samples_for_this_machine, log_total_samples): return payout / samples_for_this_machine + math.sqrt((2 * log_total_samples) / samples_for_this_machine)
首先,我们需要一个计算 UCB1 值的方法;这是在 Python 中的 UCB1 公式。唯一的区别是这里我们使用log_total_samples作为输入,因为它允许我们稍后进行小优化:
def monte_carlo_tree_search_uct(board_state, side, number_of_rollouts):
state_results = collections.defaultdict(float)
state_samples = collections.defaultdict(float)
声明该方法和两个字典,即state_results和state_samples。它们将跟踪我们在模拟期间遇到的不同棋盘状态的统计信息:
for _ in range(number_of_rollouts):
current_side = side
current_board_state = board_state
first_unvisited_node = True
rollout_path = []
result = 0
主循环是我们每次模拟都会经历的过程。在模拟开始时,我们需要初始化将跟踪模拟进展的变量。first_unvisited_node将跟踪我们是否为此次模拟创建了一个新的统计跟踪节点。当遇到第一个没有统计信息的状态时,我们创建新的统计节点,将其添加到state_results和state_samples字典中,并将变量设置为False。rollout_path将跟踪我们在此次模拟中访问的每个节点,这些节点是我们保留了统计节点的节点。当我们在模拟结束时获得结果时,我们将更新沿路径的所有状态的统计信息:
while result == 0:
move_states = {move: apply_move(current_board_state, move, current_side)
for move in available_moves(current_board_state)}
if not move_states:
result = 0
break
当result == 0时,我们进入模拟的循环;这将一直运行,直到一方胜利。在模拟的每个循环中,我们首先构造一个字典move_states,将每个可用的移动映射到该移动将带我们进入的状态。如果没有可以进行的移动,那么我们处于终止状态,这是一局平局。所以你需要将其记录为结果,并跳出模拟循环:
if all((state in state_samples) for _, state in move_states):
log_total_samples = math.log(sum(state_samples[s] for s in move_states.values()))
move, state = max(move_states, key=lambda _, s:
upper_confidence_bounds(state_results[s], state_samples[s], log_total_samples))
else:
move = random.choice(list(move_states.keys()))
现在我们需要选择在这次投掷中要采取的棋步。根据 MCTS-UCT 算法的规定,如果我们对每个可能的移动都有统计数据,我们选择具有最佳upper_confidence_bounds得分的移动;否则,我们随机选择。
current_board_state = move_states[move]
现在我们已经选择了我们的走步,我们可以将current_board_state更新为移动将我们置于的状态:
if first_unvisited_node:
rollout_path.append((current_board_state, current_side))
if current_board_state not in state_samples:
first_unvisited_node = False
现在我们需要检查我们是否已经到达了 MCTS-UCT 树的末端。我们将向rollout_path中添加我们访问的每个节点,直到第一个之前未访问的节点。一旦我们从这次投掷中得到结果,我们将更新所有这些节点的统计数据。
current_side = -current_side
result = has_winner(current_board_state)
我们处于我们的投掷循环的最后,所以下一次迭代时要改变双方的位置,并检查当前状态是否有人获胜。如果是的话,当我们回到while result == 0语句时,它将导致我们退出投掷循环:
for path_board_state, path_side in rollout_path:
state_samples[path_board_state] += 1.
result = result*path_side/2.+.5
state_results[path_board_state] += result
现在我们已经完成了一次投掷,离开了投掷循环。我们现在需要用结果更新我们的统计数据。rollout_path中包含要更新的每个节点的path_board_state和path_side,因此我们需要遍历其中的每个条目。最后需要指出的两点是,我们的游戏结果介于-1 和 1 之间。但是 UCB1 算法期望其支付在 0 和 1 之间;行result*path_side/2.+.5就做到了这一点。其次,我们还需要转换结果以代表它们所代表的一方。对我来说一个好棋步是对手的坏棋步的对立面:
move_states = {move: apply_move(board_state, move, side) for move in available_moves(board_state)}
move = max(move_states, key=lambda x: state_results[move_states[x]] / state_samples[move_states[x]])
return state_results[move_states[move]] / state_samples[move_states[move]], move
最后,一旦完成了所需数量的投掷,我们可以基于最佳预期报酬从当前状态选择最佳的走步。不再需要使用 UCB1 来选择最佳走步。因为这是最终决定,不需要进行额外的探索,最佳走步就是最佳平均报酬。
这就是 MCTS-UCT 算法。它有许多不同的变体,针对特定情况具有不同的优势,但它们都有这个作为核心逻辑。MCTS-UCT 给了我们一种一般的方式来评判类似围棋这样具有庞大搜索空间的游戏的走步。而且,它并不仅限于完全信息的游戏;在具有部分观察状态的游戏中,如扑克牌游戏中,它通常也表现良好。甚至更一般地说,任何我们遇到的可以重新配置适应它的问题,例如,它被用作自动定理证明机器的基础。
蒙特卡洛树搜索中的深度学习
即使使用了 MCTS-UCT 算法,计算机仍然无法与最优秀的围棋选手相提并论;然而,在 2016 年, Google Deep Mind 团队开发了一个名为 AlphaGo 的人工智能。它在五局比赛中击败了世界顶尖的围棋选手李世石,以 4-1 的比分获胜。他们这样做的方式是在标准 MCTS UCT 方法的基础上进行了三项改进。
如果我们思考为什么 MCTS 如此不准确,一个直观的答案可能是,评估中使用的动作是随机选择的,而我们知道某些动作比其他动作更有可能。在围棋中,当争夺角落的控制权时,该区域周围的动作是更好的选择,而不是棋盘另一侧的动作。如果我们有一种良好的方法来选择哪些动作可能被下,我们将大大减少搜索的广度,从而增加我们 MCTS 评估的准确性。如果我们回到前面的国际象棋局面,尽管每个合法的动作理论上都可以被下,但是如果你对手没有任何国际象棋技巧,只会下赢棋的动作,评估其他动作就是在浪费 CPU 周期。
深度学习可以帮助我们解决这个问题。我们可以利用神经网络的模式识别特性来粗略估计在游戏中给定位置的棋子被下的概率。对于 AlphaGo,使用了一个具有 13 层卷积网络和 relu 激活函数的网络。网络的输入是 19 x 19 的棋盘状态,输出是另一个 19 x 19 的 softmax 层,表示每个棋盘方格中下棋的概率。然后,它在大量专家级人类围棋对局的数据库上进行训练。网络将接收一个单一的位置作为输入,以及从该位置下的棋子作为目标。损失函数是网络激活和人类下的棋子之间的均方误差。在充分的训练下,该网络学会了以 57%的准确率预测人类下棋的动作。在这里使用测试集特别重要,因为过度拟合是一个大问题。除非网络能够将对一个位置的理解推广到以前未见过的位置,否则它是无用的。
如果我们想在前面的井字棋示例中实现类似的东西,我们只需将move = random.choice(moves)这行替换为使用由训练过的神经网络选择的动作的monte_carlo_sample方法或 UCT 版本。如果你有一个大型的训练集合例子游戏,这种技术将适用于任何离散游戏。
如果你没有例子游戏的数据库,你可以使用另一种方法。如果你有一个稍微有技巧的代理程序,你甚至可以使用该代理程序来生成初始的例子游戏集合。例如,一个好的方法是使用极小-极大或 MCTS UCT 算法来生成例子位置和动作。然后,可以训练一个网络来从该集合中下棋。这是一个很好的方法,可以让网络学会如何以足够高的标准玩游戏,以至于它至少可以探索游戏空间的可能动作,而不是完全随机的动作。
如果我们实现这样的神经网络,用它来选择在蒙特卡洛展开中使用哪些移动,那么我们的评估将更加准确,但我们仍然会遇到这样的问题,即当我们仍然关心我们的移动带来的最佳结果时,我们的 MCTS 将评估平均值。这就是引入强化学习以改进我们的代理的地方。
强化学习快速回顾
我们在第一章中首次遇到强化学习,机器学习 - 简介,当我们研究了三种不同类型的学习过程时:监督,无监督和强化。在强化学习中,代理在环境中接收奖励。例如,代理可能是迷宫中的老鼠,奖励可能是迷宫中的某些食物。强化学习有时会感觉有点像监督循环网络问题。网络获得一系列数据并必须学会响应。
区分任务成为强化学习问题的关键区别是,代理给出的响应会改变它在未来时间步中接收的数据。如果老鼠在迷宫的一个T交叉口向左转而不是向右转,那么它将改变其下一个状态。相比之下,监督循环网络只是预测一系列。它们所做的预测不会影响系列中的未来值。
AlphaGo 网络已经通过监督训练,但现在问题可以重塑为一个强化学习任务,以进一步改进代理。对于 AlphaGo,创建了一个新的网络,该网络与监督网络共享结构和权重。然后,使用强化学习继续其训练,并专门使用称为政策梯度的方法。
用于学习策略函数的政策梯度
政策梯度旨在解决的问题是强化学习问题的更一般版本,即如何在任务上使用反向传播,该任务没有梯度,从奖励到我们参数的输出。为了给出更具体的例子,我们有一个神经网络,它产生采取动作a的概率,给定状态s和一些参数?,这些参数是我们神经网络的权重:

我们还有我们的奖励信号R。行动影响我们采取的奖励信号,但它们与参数之间没有梯度。没有方程式可以插入R;它只是我们从环境中响应a而获得的值。
然而,鉴于我们知道我们选择的a和R之间存在链接,有几件事情我们可以尝试。我们可以从高斯分布中创建一个?的值范围并在环境中运行它们。然后我们可以选择最成功的一部分,并获取它们的平均值和方差。然后,我们使用新的均值和方差在我们的高斯分布中创建一个新的?种群。我们可以反复执行此过程,直到在R中不再看到改进,然后将我们的最终均值作为参数的最佳选择。这种方法被称为交叉熵方法。
尽管它可能非常成功,但它是一种爬山法,不能很好地探索可能性空间。它很容易陷入局部最优解,这在强化学习中非常常见。此外,它仍然没有利用梯度信息。
要使用梯度,我们可以利用* a 和 R 之间虽然没有数学关系,但存在概率关系的事实。在某个 s 中采取特定的 a 往往会比其他 R 获得更多的 R 。我们可以将获得 R 的?对 R *的梯度的问题写成如下形式:

在这里,r [t] 是时间步骤 t 的奖励。这可以重新排列成:

如果我们乘以并除以
,我们有以下结果:

使用事实
并简化为以下形式:

这实际上是如果我们使参数沿着每个时间步骤的奖励梯度的对数方向推动,我们倾向于向所有时间步骤的奖励梯度移动。要在 Python 中实现这一点,我们需要执行以下步骤:
-
创建一个输出是在给定输入状态下采取不同动作的概率的神经网络。根据先前的方程,它将表示
。 -
在我们的代理在其环境中运行的批次中运行若干个训练。根据网络输出的概率分布随机选择其动作。在每个时间步骤,记录输入状态、收到的奖励和实际采取的动作。
-
在每个训练的最后,使用从该点开始的该训练中的奖励总和为每一步分配奖励。在围棋等游戏中,这将只是一个表示最终结果的 1、0 或-1 应用于每一步的值。这将代表方程中的
r[t]。对于更动态的游戏,可以使用折扣奖励;折扣奖励将在下一章中详细解释。 -
一旦在我们的情节中存储了一组数量的状态,我们就会通过更新我们的网络参数来训练它们,更新依据是网络输出的对数乘以实际的移动,乘以奖励。这被用作我们神经网络的损失函数。我们对每个时间步执行这一操作,作为单批次更新。
-
然后从步骤 2 开始重复执行,直到达到停止点,要么在一定的迭代次数内,要么在环境内得到一定的分数。
此循环的效果是,如果一个动作与正向奖励相关联,我们会增加导致该状态下此动作的参数。如果奖励是负向的,我们会减少导致该动作的参数。需要注意的是,为了使其工作,我们需要具有一些负值的奖励;否则,随着时间的推移,所有动作都会被简单地提升。如果这种情况没有自然发生,最好的选择是在每个批次中对我们的奖励进行归一化。
已经证明政策梯度方法在学习一系列复杂任务时取得了成功,尽管训练速度可能会非常缓慢,并且对学习速率非常敏感。学习速率过高时,行为将会发生剧烈震荡,永远无法保持稳定,以至于无法学到有意义的东西。学习速率过低时,它永远无法收敛。这就是为什么在下面的示例中,我们使用 RMSProp 作为优化器。标准的梯度下降法带有固定学习率通常会不成功。另外,尽管这里展示的例子是针对棋盘游戏的,但政策梯度在学习更具动态性的游戏,如乒乓球,也表现得非常出色。
现在让我们为井字游戏的play_game方法创建player_func;它使用政策梯度来学习最佳策略。我们将建立一个神经网络,以棋盘的九个方格作为输入。数字 1 代表玩家的标记,-1 代表对手的标记,0 代表未标记的方格。在这里,网络将设置为三个隐藏层,每个隐藏层有 100 个隐藏节点和 relu 激活函数。输出层还将包含九个节点,每个代表一个方格。因为我们希望最终的输出是移动是最佳移动的概率,我们希望最后一层的所有节点的输出总和为 1。这意味着使用 softmax 激活函数是一个自然的选择。softmax 激活函数如下:

在这里,x 和 y 是具有相同维数的向量。
这是在 TensorFlow 中创建网络的代码。完整的代码也可以在 GitHub 仓库中的policy_gradients.py中找到。
import numpy as np
import tensorflow as tf
HIDDEN_NODES = (100, 100, 100)
INPUT_NODES = 3 * 3
LEARN_RATE = 1e-4
OUTPUT_NODES = INPUT_NODES
首先,我们导入 NumPy 和 TensorFlow,它将用于网络,并创建一些常量变量,稍后将使用它们。3 * 3 输入节点是棋盘的大小:
input_placeholder = tf.placeholder("float", shape=(None, INPUT_NODES))
input_placeholder变量是神经网络的输入占位符。在 TensorFlow 中,占位符对象用于向网络提供所有值。在运行网络时,它将设置为游戏的board_state。此外,input_placeholder的第一个维度是None。这是因为在训练时,使用小批量训练会更快。None将在训练时调整为我们的样本小批量的大小:
hidden_weights_1 = tf.Variable(tf.truncated_normal((INPUT_NODES, HIDDEN_NODES[0]), stddev=1\. / np.sqrt(INPUT_NODES)))
hidden_weights_2 = tf.Variable(
tf.truncated_normal((HIDDEN_NODES[0], HIDDEN_NODES[1]), stddev=1\. / np.sqrt(HIDDEN_NODES[0])))
hidden_weights_3 = tf.Variable(
tf.truncated_normal((HIDDEN_NODES[1], HIDDEN_NODES[2]), stddev=1\. / np.sqrt(HIDDEN_NODES[1])))
output_weights = tf.Variable(tf.truncated_normal((HIDDEN_NODES[-1], OUTPUT_NODES), stddev=1\. / np.sqrt(OUTPUT_NODES)))
在这里,我们创建我们网络三层所需的权重。它们都将使用随机的 Xavier 初始化创建;在本章中会更详细讲解:
hidden_layer_1 = tf.nn.relu(
tf.matmul(input_placeholder, hidden_weights_1) +
tf.Variable(tf.constant(0.01, shape=(HIDDEN_NODES[0],))))
创建第一个隐藏层,我们的hidden_weights_1 2 维张量,并将其与input_placeholder进行矩阵相乘。然后添加偏差变量tf.Variable(tf.constant(0.01, shape=(HIDDEN_NODES[0],))),这可以使网络在学习模式中具有更大的灵活性。然后通过 relu 激活函数处理输出:tf.nn.relu。这就是我们在 TensorFlow 中写神经网络层的基本方程。另一点需要注意的是 0.01。使用relu函数时,添加一小部分正偏差是一个好的实践。这是因为 relu 函数是最大值并且为 0。这意味着值小于 0 将没有梯度,所以在学习过程中不会被调整。如果节点激活始终小于零,那么因为权重初始化不佳的不幸,这将被视为一个死节点,并且永远不会对网络产生影响,并且只会浪费 GPU/CPU 周期。一小部分正偏差极大地减少了网络中完全死节点的机会:
hidden_layer_2 = tf.nn.relu(
tf.matmul(hidden_layer_1, hidden_weights_2) +
tf.Variable(tf.truncated_normal((HIDDEN_NODES[1],), stddev=0.001)))
hidden_layer_3 = tf.nn.relu(
tf.matmul(hidden_layer_2, hidden_weights_3) + tf.Variable(tf.truncated_normal((HIDDEN_NODES[2],), stddev=0.001)))
output_layer = tf.nn.softmax(tf.matmul(hidden_layer_3, output_weights) + tf.Variable(tf.truncated_normal((OUTPUT_NODES,), stddev=0.001)))
接下来的几层以相同的方式创建:
reward_placeholder = tf.placeholder("float", shape=(None,))
actual_move_placeholder = tf.placeholder("float", shape=(None, OUTPUT_NODES))
对于loss函数,我们需要两个额外的占位符。其中一个用于表示我们从环境中获得的奖励,即井字棋游戏的结果。另一个用于表示每个时间步我们将要采取的实际动作。请记住,我们将根据网络输出的随机策略选择我们的动作。当我们调整参数时,我们需要知道我们实际采取的动作,这样我们就可以根据奖励的正负移动参数的方向:
policy_gradient = tf.reduce_sum(
tf.reshape(reward_placeholder, (-1, 1)) *
actual_move_placeholder * output_layer)
train_step = tf.train.RMSPropOptimizer(LEARN_RATE).minimize(-policy_gradient)
当激活actual_move_placeholder时,它将是一个独热向量,例如,[0, 0, 0, 0, 1, 0, 0, 0, 0],其中 1 表示实际移动所在的方格。这将作为一个掩码应用到output_layer上,以便只调整该移动的梯度。到达第一个方格的成功与失败对第二个方格的成功与失败没有影响。将它与reward_placeholder相乘,可以告诉我们是否要增加导致这个动作的权重还是减少权重。然后将policy_gradient输入我们的优化器;我们想要最大化我们的奖励,这意味着最小化其的倒数。
最后一点是我们在这里使用了 RMSPropOptimizer。如前所述,策略梯度对使用的学习率和类型非常敏感。已经证明 RMSProp 效果很好。
在 TensorFlow 中,变量也需要在会话中初始化;然后会话将用于运行我们的计算:
sess = tf.Session()
sess.run(tf.initialize_all_variables())
现在我们需要一个方法来运行我们的网络以选择要传递给之前创建的 play_game 方法的动作:
board_states, actual_moves, rewards = [], [], []
def make_move(board_state):
board_state_flat = np.ravel(board_state)
board_states.append(board_state_flat)
probability_of_actions = sess.run(output_layer, feed_dict={input_placeholder: [board_state_flat]})[0]
在 make_move 方法中,我们做了一些不同的事情。首先,我们将 board_state 展开,它起初是一个我们需要用作网络输入的一维数组中的第二个数组。然后,我们将该状态附加到我们的 board_states 列表中,以便稍后在我们拿到该场景奖励后用于训练。然后,我们使用 TensorFlow 会话运行网络: probability_of_actions。现在会有一个包含九个数字的数组,它们将加起来等于一;这些数字是网络将学习将每个动作设置为当前最有利的概率的数字:
try:
move = np.random.multinomial(1, probability_of_actions)
except ValueError:
move = np.random.multinomial(1, probability_of_actions / (sum(probability_of_actions) + 1e-7))
现在我们使用 probability_of_actions 作为多项式分布的输入。np.random.multinomial 返回你传递给它的分布的一系列值。因为我们为第一个参数给了 1,所以只会生成一个值;这就是我们将要做出的移动。围绕 multinomial 调用的 try…catch 的存在是因为由于小的舍入误差,probability_of_actions 有时会加起来大于 1。这大约每 10,000 次调用会发生一次,因此我们将pythonic;如果失败了,只需通过一些小的 epsilon 调整它,然后再试一次:
actual_moves.append(move)
move_index = move.argmax()
return move_index / 3, move_index % 3
make_move 方法的最后一部分是我们需要在训练后存储我们实际使用的移动。然后将移动返回到我们的井字游戏期望的格式中,即作为两个整数的元组:一个是 x 位置,一个是 y 位置。
在训练之前的最后一步是,一旦我们有了一个完整的批次进行训练,我们需要对批次中的奖励进行归一化。这样做有几个优点。首先,在早期训练时,当几乎所有游戏都输了或赢了,我们希望鼓励网络朝着更好的示例迈进。归一化将使我们能够对罕见、更重要的示例施加额外的权重。此外,批量归一化倾向于加速训练,因为它减少了目标的方差:
BATCH_SIZE = 100
episode_number = 1
我们为我们的 BATCH_SIZE 定义了一个大小常量。这定义了我们用于训练的小批量中有多少示例。许多不同的值都可以很好地工作;100 就是其中之一。episode_number 将跟踪我们已经完成了多少个游戏循环。这将追踪我们何时需要启动小批量训练:
while True:
reward = play_game(make_move, random_player)
while True将我们置于主循环中。我们需要在这里迈出的第一步是运行一场比赛,使用我们在本章前面使用过的老朋友play_game方法。为了简单起见,我们将始终让策略梯度玩家以make_move方法作为第一玩家,以random_player作为第二玩家。更改顺序也不难:
last_game_length = len(board_states) - len(rewards)
# we scale here
reward /= float(last_game_length)
rewards += ([reward] * last_game_length)
获取我们刚刚玩的游戏的长度,并将我们收到的奖励附加到rewards数组中,这样每个棋盘状态都可以得到我们收到的相同的最终奖励。实际上,有些移动可能对最终奖励产生了更大或更小的影响,但我们在这里无法知道。我们希望通过训练,随着类似的好状态更频繁地出现,并且有正向奖励,网络会随着时间学会这一点。我们还通过last_game_length来缩放奖励,所以快速获胜比慢速获胜更好,慢速失败比快速失败更好。另一个需要注意的是,如果我们运行的游戏奖励分布更不均匀——比如 Pong,大部分帧都没有奖励,只有偶尔才有——这就是我们可能会在剧集的时间步上应用未来的折现的地方:
episode_number += 1
if episode_number % BATCH_SIZE == 0:
normalized_rewards = rewards - np.mean(rewards)
normalized_rewards /= np.std(normalized_rewards)
sess.run(train_step, feed_dict={input_placeholder: board_states, reward_placeholder: normalized_rewards, actual_move_placeholder: actual_moves})
增加episode_number,如果我们有一个BATCH_SIZE数量的样本,就进入训练代码。我们首先对我们的奖励进行批量归一化。这并不总是必需的,但几乎总是值得推荐的,因为它有很多好处。它倾向于通过减少训练中的差异来改善训练时间。如果我们的所有奖励都是正数/负数,这将解决问题,而无需您再考虑。最后,通过在 TensorFlow 会话对象上运行train_step操作来启动训练:
del board_states[:]
del actual_moves[:]
del rewards[:]
最后,清空当前的小批量以为下一个小批量让路。现在让我们看看策略梯度的表现如何:

可以看到,最终它实现了尊重的 85%的获胜率。随着更多的时间和超参数的调整,它可能会做得更好。另外,请注意这一点,这说明了只选择有效移动的随机玩家的获胜率超过 50%的原因。这是因为在这里,被观察到的玩家总是先行动的。
AlphaGo 中的策略梯度
对于使用策略梯度的 AlphaGo,设置网络来与自己对弈。它每一步的奖励都是 0,直到最后一步游戏结束,游戏要么赢要么输,给出 1 或-1 的奖励。然后,将这个最终奖励应用到网络的每一步,并使用策略梯度训练网络,方式与我们的井字棋示例相同。为了防止过拟合,游戏是对抗随机选择的先前版本的网络进行的。如果网络不断地与自己对弈,风险是它可能会得出一些非常特定的策略,这些策略不适用于各种各样的对手,可以说是一种局部最小值。
构建最初的监督学习网络,以预测人类玩家最可能的走法,使得 AlphaGo 能够大幅减少在 MCTS 中需要执行的搜索范围。这使得他们可以更准确地评估每次模拟。问题在于运行一个大型多层神经网络非常慢,而不是只选择一个随机动作。在我们的蒙特卡洛模拟中,我们平均需要选择 100 步,而我们希望在数十万次模拟中完成这一过程以评估一个位置。以这种方式使用网络是不切实际的。我们需要找到一种方法来减少计算时间。
如果我们使用网络选出的最佳走法,而不是手动选择一个走法,那么我们的网络就是确定性的。给定棋盘上的一个位置,棋盘达到的结果也将是确定性的。当使用网络的最佳走法进行评估时,位置要么是白方或黑方的胜利,要么是平局。这个结果是在网络的最优策略下的位置值。因为结果是确定性的,所以我们可以训练一个新的深度神经网络来学习这个位置的值。如果表现良好,就可以仅通过神经网络的一次通行来准确评估一个位置,而不是每一步都要进行一次。
最终,使用与前面网络相同的结构创建了一个监督网络,不同之处在于最终的输出不再是整个棋盘上行动的概率,而是一个表示游戏预期结果的单个节点:白方赢、黑方赢或平局。
该网络的损失函数是其输出与强化学习网络实现的结果之间的均方误差。经过训练后发现,价值网络在训练集和测试集上的均方误差仅为 0.226 和 0.234。这表明它能够以很高的准确性学习结果。
总结一下,在这一点上,Alpha Go 有三种不同训练的深度神经网络:
-
SL:这是一个使用监督学习训练的网络,用于预测从棋盘位置到人类走法的概率。
-
RL:这是一个经过训练的网络,最初使用 SL 网络的权重,然后使用强化学习进一步训练,以选择给定位置的最佳移动。
-
V:这是一个再次通过监督学习训练的网络,用于学习在使用 RL 网络进行游戏时的位置的预期结果。它提供状态的值。
在与李世石进行真实比赛时,Alpha Go 使用了我们之前介绍的 MCTS-UCT 的变体。当从 MCTS 叶子节点模拟模拟时,选择的移动不是随机的,而是使用另一个更小的单层网络选择的。该网络称为快速模拟策略,并且在所有可能的移动上使用 softmax 分类器,其中输入是动作周围的 3 x 3 颜色模式和一系列手工特征,例如自由度计数。在我们的示例中,以下是一行:
move = random.choice(list(move_states.keys()))
可以用类似下面的内容替换:
probability_of_move = fast_rollout_policy.run(board_state)
move = np.random.binomial(1, probability_of_move)
这个小型网络用于运行蒙特卡罗模拟。SL 网络几乎肯定会更好,但速度会过慢。
在评估从叶子节点进行蒙特卡罗模拟的成功值时,分数是使用快速模拟策略的结果和由 V 网络给出的分数的组合来确定的。使用混合参数?来确定这些的相对权重:

这里,s是叶子的状态,f是使用快速模拟策略进行模拟的结果。在尝试了各种值的?之后,发现 0.5 产生了最佳结果,表明这两种评估方法是互补的。
李世石与 Alpha Go 之间的五局比赛于 2016 年 3 月 9 日开始,赛场上有大量观众,胜者将获得 100 万美元的奖金。李世石在备战中非常自信,宣称:“我听说谷歌 DeepMind 的人工智能异常强大且日益强大,但我确信我至少可以这次赢。”遗憾的是,Alpha Go 继续赢得了前三局,每局迫使李世石投降。在这一点上,竞争已经决定,他赢得了第四局,但输掉了第五局,比赛结果为 4-1。
这是人工智能方面的重大进步,标志着人工智能首次在如此复杂的游戏中几乎与顶级人类玩家相媲美。这引发了各种问题,比如在哪些其他领域可能会开发出能够超越最优秀人类的人工智能。比赛对人类的完全意义尚待观察。
摘要
在本章中,我们涵盖了很多内容,并查看了很多 Python 代码。我们简单讨论了离散状态和零和博弈的理论。我们展示了如何使用 Min-max 来评估位置的最佳移动。我们还展示了评估函数如何允许 Min-max 在可能的移动和位置状态空间过大的游戏中运行。
对于没有好的评估函数的游戏,我们展示了如何使用蒙特卡洛树搜索来评估位置,以及如何使用带有置信上界的蒙特卡洛树搜索来让 MCTS 的性能接近 Min-max。这使我们了解了 UCB1 算法。除了允许我们计算 MCTS-UCT 外,它还是一种在不同结果中选择的通用方法。
我们还研究了如何将强化学习与这些方法相结合。我们还看到了如何使用策略梯度来训练深度网络以学习复杂的模式,并在难以评估的状态下找到优势。最后,我们看到了这些技术如何应用在 AlphaGo 中击败了当时的世界冠军。
如果您有兴趣更深入地参与深度学习的棋盘游戏,Alpha Toe 项目(github.com/DanielSlater/AlphaToe)提供了在更广泛的游戏上运行深度学习的示例,包括在 5 x 5 棋盘上的连连看和井字棋。
尽管这些技术是为棋盘游戏引入的,但它们的应用范围更广。我们遇到的许多问题都可以被形式化,例如为送货公司优化路线、在金融市场上投资以及制定企业战略。我们只是刚刚开始探索所有的可能性。
在下一章中,我们将研究如何使用深度学习来学习电脑游戏。这将在本章的策略梯度知识基础上进行,并介绍处理动态环境的新技术。
第八章:深度学习与电脑游戏
上一章关注的是解决棋盘游戏问题。在本章中,我们将研究更复杂的问题,即训练人工智能玩电脑游戏。与棋盘游戏不同,游戏规则事先是不知道的。人工智能不能预测它采取行动会发生什么。它不能模拟一系列按钮按下对游戏状态的影响,以查看哪些获得最高分。它必须纯粹通过观察、玩耍和实验来学习游戏的规则和约束。
在本章中,我们将涵盖以下主题:
-
Q 学习
-
经验重演
-
演员-评论家
-
基于模型的方法
游戏的监督学习方法
强化学习中的挑战在于找到我们网络的良好目标。我们在上一章中就一种方法,策略梯度。如果我们能够将强化学习任务转化为监督任务问题,那么问题就会变得容易得多。因此,如果我们的目标是构建一个玩电脑游戏的人工智能代理,我们可能会尝试观察人类的游戏方式,并让我们的代理从他们那里学习。我们可以录制一个专家玩家玩游戏的视频,同时跟踪屏幕图像和玩家按下的按钮。
正如我们在计算机视觉章节中所看到的,深度神经网络可以从图像中识别模式,因此我们可以训练一个以屏幕为输入,以每一帧中用户按下的按钮为目标的网络。这类似于上一章中 AlphaGo 的预训练。这种方法在一系列复杂的 3D 游戏上进行了尝试,例如《超级大乱斗》和《马里奥网球》。卷积网络用于其图像识别质量,而 LTSM 用于处理帧之间的长期依赖关系。使用这种方法,一个针对《超级大乱斗》训练过的网络可以在最困难的难度设置下击败游戏内 AI:

从人类身上学习是一个很好的起点,但我们进行强化学习的目标应该是实现超越人类的表现。此外,用这种方式训练的智能体将永远受到其能力的限制,而我们真正想要的是能够真正自我学习的智能体。在本章的其余部分,我们将介绍一些旨在超越人类水平的方法。
应用遗传算法玩游戏
长期以来,AI 在视频游戏环境中的最佳结果和大部分研究都围绕着遗传算法展开。这种方法涉及创建一组模块,这些模块接受参数以控制 AI 的行为。参数值的范围由一组基因的选择来确定。然后将创建一组代理,使用这些基因的不同组合,然后在游戏上运行。最成功的一组代理基因将被选择,然后将使用成功代理的基因的组合创建一个新的代理一代。这些代理再次在游戏上运行,直到达到停止条件,通常是达到最大迭代次数或游戏中的性能水平。偶尔,在创建新一代时,一些基因可以发生突变以创建新基因。一个很好的例子是 MarI/O,这是一个使用神经网络遗传演化学习玩经典的 SNES 游戏 超级马里奥世界 的 AI:

图 1:使用遗传算法学习马里奥(https://www.youtube.com/watch?v=qv6UVOQ0F44)
这些方法的一个很大的缺点是,它们需要大量的时间和计算能力来模拟所有参数的变化。每一代的每个成员都必须运行整个游戏直到终止点。该技术也没有利用游戏中人类可以使用的丰富信息。每当收到奖励或惩罚时,都会有关于状态和采取的行动的上下文信息,但遗传算法只使用运行的最终结果来确定适应度。它们不是那么多的学习而是试错。近年来,已经找到了更好的技术,利用反向传播来允许代理在玩耍时真正学习。与上一章一样,这一章也非常依赖代码;如果您不想花时间从页面上复制文本,您可以在 GitHub 仓库中找到所有代码:github.com/DanielSlater/PythonDeepLearningSamples。
Q 学习
想象一下,我们有一个代理将在一个迷宫环境中移动,其中某处有一个奖励。我们的任务是尽快找到到达奖励的最佳路径。为了帮助我们思考这个问题,让我们从一个非常简单的迷宫环境开始:

图 2:一个简单的迷宫,代理可以沿着线移动从一个状态到另一个状态。如果代理到达状态 D,将获得奖励 4。
在所示的迷宫中,代理可以在任何节点之间来回移动,通过沿着线移动。代理所在的节点是它的状态;沿着线移动到不同的节点是一种行动。如果代理达到状态D的目标,就会得到4的奖励。我们希望从任何起始节点找到迷宫的最佳路径。
让我们思考一下这个问题。如果沿着一条直线移动将我们置于状态D,那么这将永远是我们想要采取的路径,因为这将在下一个时间步给我们4的奖励。然后退回一步,我们知道如果我们到达状态C,它直接通往D,我们可以获得那个 4 的奖励。
要选择最佳行动,我们需要一个能够为行动让我们置于的状态提供预期奖励的函数。在强化学习中,这个函数的名称是 Q 函数:
state, action => expected reward
如前所述,到达状态D的奖励是4。那么到达状态C的奖励应该是多少呢?从状态C,可以采取一个行动转移到状态D并获得4的奖励,所以也许我们可以将C的奖励设为4。但是如果我们在所示的迷宫中采取一系列随机行动,我们最终总是会到达状态D,这意味着每个行动都会获得相同的奖励,因为从任何状态,我们最终都会到达状态D的4奖励。
我们希望我们的预期奖励考虑到获得未来奖励需要的行动数。我们希望这种期望能够产生这样的效果,即当处于状态A时,我们直接转移到状态C而不是通过状态B,这将导致到达D需要更长的时间。所需的是一个考虑到未来奖励的方程,但与更早获得的奖励相比打折。
另一种思考这个问题的方式是考虑人们对待金钱的行为,这是对人们对待奖励的行为的良好代理。如果在一周后和十周后选择收到 1 美元的选择,人们通常会选择尽快收到 1 美元。生活在不确定的环境中,我们对以较少不确定性获得的奖励更加重视。我们推迟获得奖励的每一刻都是世界不确定性可能消除我们奖励的更多时间。
为了将这个应用于我们的代理,我们将使用用于评估奖励的时间差方程;它如下所示:

在这个方程中,V 是采取一系列动作的奖励,r [t] 是在这个序列中在时间 t 收到的奖励,g 是一个常数,其中 0 < g < 1,这意味着将来的奖励不如更早获得的奖励有价值;这通常被称为折扣因子。如果我们回到我们的迷宫,这个函数将为在一个动作中到达奖励的动作提供更好的奖励,而不是在两个或更多动作中到达奖励的动作。如果将 g 的值设为 1,方程简化为随时间的奖励总和。这在 Q 学习中很少使用;它可能导致代理不收敛。
Q 函数
现在我们可以评估代理在迷宫中移动的路径,那么如何找到最优策略呢?对于我们的迷宫问题,简单的答案是,在面临动作选择时,我们希望选择导致最大奖励的动作;这不仅适用于当前动作,还适用于当前动作后我们将进入的状态的最大动作。
这个函数的名称是 Q 函数。如果我们有完美的信息,这个函数将给出我们在任何状态下的最优动作;它看起来如下:

在这里,s 是一个状态,a 是在该状态下可以采取的动作,而 0 < g < 1 是折扣因子。rewards 是一个函数,它返回在某个状态下采取某个动作的奖励。actions 是一个函数,它返回在状态 s 中采取动作 a 后转移到的状态 s' 以及在该状态下所有可用的动作 a'。
让我们看看如果我们将 Q 函数应用于折扣因子为 g=0.5 的迷宫会是什么样子:

图 3:简单迷宫,现在带有 Q 值。箭头显示了在每个末端两个状态之间移动的预期奖励
您会注意到所示的 Q 函数是无限递归的。这是一个假设的完美 Q 函数,所以不是我们可以在代码中应用的东西。为了在代码中使用它,一个方法是简单地预先设定一个最大的动作数;那么它可能是这样的:
def q(state, action, reward_func, apply_action_func, actions_for_state_func, max_actions_look_ahead, discount_factor=0.9):
new_state = apply_action_func(state, action)
if max_actions_look_ahead > 0:
return reward_func(new_state) + discount_factor \ * max(q(new_state, new_action, reward_func, apply_action_func, actions_for_state_func, max_actions_look_ahead-1)
for new_action in actions_for_state_func(new_state))
else:
return reward_func(new_state)
在这里,state 是定义环境状态的某个对象。action 是定义在状态中可以采取的有效动作的某个对象。reward_func 是一个函数,它返回给定状态的浮点值奖励。apply_action_func 返回将给定动作应用于给定状态后的新状态。actions_for_state_func 是一个函数,它返回给定状态的所有有效动作。
如果我们不必担心未来的奖励并且我们的状态空间很小,上述方法将获得良好的结果。它还要求我们能够准确地从当前状态模拟到未来状态,就像我们可以为棋盘游戏做的那样。但是,如果我们想要训练一个代理来玩动态电脑游戏,那么这些约束都不成立。当被提供来自电脑游戏的图像时,我们不知道在按下给定按钮后图像将会变成什么,或者我们将获得什么奖励,直到我们尝试为止。
Q 学习的实践
一个游戏可能每秒有 16-60 帧,并且经常会根据许多秒前所采取的动作来获得奖励。此外,状态空间是广阔的。在电脑游戏中,状态包含作为游戏输入的屏幕上的所有像素。如果我们想象一个屏幕被降低到 80 x 80 像素,所有像素都是单色和二进制,黑色或白色,那仍然是 2⁶⁴⁰⁰ 个状态。这使得状态到奖励的直接映射变得不切实际。
我们需要做的是学习 Q 函数的近似值。这就是神经网络可以应用其通用函数近似能力的地方。为了训练我们的 Q 函数近似值,我们将存储游戏状态、奖励和我们的代理在游戏中采取的行动。我们网络的损失函数将是其对前一状态的奖励的近似值与其在当前状态获得的实际奖励之间的差的平方,加上其对游戏中达到的当前状态的奖励的近似值乘以折扣因子的差的平方:

s 是先前的状态,a 是在该状态下采取的动作,而 0 < g < 1 是折扣因子。rewards 是返回在状态中采取行动的奖励的函数。actions 是返回在状态 s 中采取行动后你过渡到的 s' 状态和该状态中所有可用的动作 a'。Q 是先前介绍的 Q 函数。
通过以这种方式训练连续的迭代,我们的 Q 函数逼近器将慢慢收敛到真实的 Q 函数。
让我们先为世界上最简单的游戏训练 Q 函数。环境是一个一维状态地图。一个假设的代理必须通过向左或向右移动来最大化其奖励来导航迷宫。我们将为每个状态设置奖励如下:
rewards = [0, 0, 0, 0, 1, 0, 0, 0, 0]
如果我们要可视化它,它可能会看起来像这样:

图 4:简单的迷宫游戏,代理可以在相连节点之间移动,并可以在顶部节点获得奖励 1。
如果我们把我们的代理放到这个“迷宫”中的第一个位置,他可以选择移动到 0 或 2 的位置。我们想要构建一个学习每个状态的价值的网络,并通过此可推断出采取移动到该状态的动作的价值。网络的第一次训练将仅学习每个状态的内在奖励。但在第二次训练中,它将利用从第一次训练中获得的信息来改进奖励的估计。在训练结束时,我们预期看到一个金字塔形状,在 1 个奖励空间中具有最大的价值,然后在离中心更远的空间上递减价值,因为您必须更进一步旅行,从而应用更多的未来折扣以获得奖励。以下是代码中的示例(完整示例在 Git 存储库中的q_learning_1d.py中):
import tensorflow as tf
import numpy as np
states = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]
NUM_STATES = len(states)
我们创建一个states列表;列表中每个项的值是代理移动到该位置时获得的奖励。在这个示例中,它获得到第 5 个位置的奖励:
NUM_ACTIONS = 2
DISCOUNT_FACTOR = 0.5
def one_hot_state(index):
array = np.zeros(NUM_STATES)
array[index] = 1.
return array
这个方法将使用一个数字,并将其转换为我们状态空间的独热编码,例如,3 变为[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]:
session = tf.Session()
state = tf.placeholder("float", [None, NUM_STATES])
targets = tf.placeholder("float", [None, NUM_ACTIONS])
我们创建了一个 TensorFlow session和用于输入和目标的占位符;数组中的None用于小批量维度:
weights = tf.Variable(tf.constant(0., shape=[NUM_STATES, NUM_ACTIONS]))
output = tf.matmul(state, weights)
对于这个简单的例子,我们可以使用状态和动作奖励之间的线性关系来准确地评估一切,所以我们只需要创建一个output层,它是weights的矩阵乘法。不需要隐藏层或任何非线性函数:
loss = tf.reduce_mean(tf.square(output - targets))
train_operation = tf.train.GradientDescentOptimizer(0.05).minimize(loss)
session.run(tf.initialize_all_variables())
我们使用均方误差(MSE)作为损失函数和标准梯度下降训练。这就是我们最终将用作目标值的 Q-learning 的一部分:
for _ in range(1000):
state_batch = []
rewards_batch = []
for state_index in range(NUM_STATES):
state_batch.append(one_hot_state(state_index))
我们创建一个state_batch,其中的每个项目都是游戏中的每个状态,以独热编码形式表示。例如,[1, 0, 0, 0, 0, 0, 0, 0, 0],[0, 1, 0, 0, 0, 0, 0, 0, 0],以此类推。然后,我们将训练网络来逼近每个状态的值:
minus_action_index = (state_index - 1) % NUM_STATES
plus_action_index = (state_index + 1) % NUM_STATES
对于每个状态,我们现在获取如果我们从该状态采取每个可能动作后所在的位置。注意,在这个示例中,状态会循环,所以从位置 0 向-1 移动会使您处于位置 8:
minus_action_state_reward = session.run(output, feed_dict={state: [one_hot_state(minus_action_index)]})
plus_action_state_reward = session.run(output, feed_dict={state: [one_hot_state(plus_action_index)]})
我们使用我们的网络,即我们的 q 函数近似器,来获取它认为如果我们采取每个动作(minus_action_index和plus_action_index),我们将获得的奖励,即网络认为我们在它将我们放入的状态中能够获得的奖励:
minus_action_q_value = states[minus_action_index] + DISCOUNT_FACTOR * np.max(minus_action_state_reward)
plus_action_q_value = states[plus_action_index] + DISCOUNT_FACTOR * np.max(plus_action_state_reward)]
在这里,我们有了现在常见的 Q 函数方程的 Python 版本。我们将移动到一个状态的初始奖励与DISCOUNT_FACTOR乘以我们在该状态下采取的动作所能获得的最大奖励相加:
action_rewards = [minus_action_q_value, plus_action_q_value]
rewards_batch.append(action_rewards)
我们将这些添加到rewards_batch中,它将用作训练操作的目标值:
session.run(train_operation, feed_dict={
state: state_batch,
targets: rewards_batch})
print([states[x] + np.max(session.run(output, feed_dict={state: [one_hot_state(x)]}))
for x in range(NUM_STATES)])
一旦我们获得了每个状态的完整奖励集,我们就会运行实际的训练步骤。如果我们运行此脚本并查看输出,我们可以感受到算法是如何迭代更新的。在第一次训练运行之后,我们看到了这个:
[0.0, 0.0, 0.0, 0.05, 1.0, 0.05, 0.0, 0.0, 0.0, 0.0]
一切都是 0,除了奖励状态两边的项目。现在这两个状态基于你可以从它们移动到奖励方块上获得奖励。再向前走几步,你会发现奖励开始在状态之间传播:
[0.0, 0.0, 0.013, 0.172, 1.013, 0.172, 0.013, 0.0, 0.0, 0.0]
此程序的最终输出将类似于这样:
[0.053, 0.131, 0.295, 0.628, 1.295, 0.628, 0.295, 0.131, 0.053, 0.02]
正如你所看到的,数组中最高的奖励位于第五个位置,我们最初设置为有奖励的位置。但是我们给出的奖励只有 1;那么为什么这里的奖励比这个要高呢?这是因为 1.295 是在当前空间获得的奖励与我们可以在未来从这个空间移开并反复返回时获得的奖励的总和,这些未来的奖励通过我们的折扣因子 0.5 减少。
学习这种未来的无限奖励是好的,但是奖励通常是在执行具有固定结束的任务过程中学到的。例如,任务可能是在架子上堆放物体,当堆栈倒塌或所有物体都堆放完毕时结束。要将这个概念添加到我们的简单 1-D 游戏中,我们需要添加终端状态。这些将是达到后,任务就结束的状态;所以与其他任何状态相比,在评估其 Q 函数时,我们不会通过添加未来奖励来训练。要进行此更改,首先我们需要一个数组来定义哪些状态是终止状态:
terminal = [False, False, False, False, True, False, False, False, False, False]
这将设置为第五个状态,我们从中获得奖励的状态为终止状态。然后,我们只需要修改我们的训练代码以考虑这个终止状态:
if terminal[minus_action_index]:
minus_action_q_value = DISCOUNT_FACTOR * states[minus_action_index]
else:
minus_action_state_reward = session.run(output, feed_dict={state: [one_hot_state(minus_action_index)]})
minus_action_q_value = DISCOUNT_FACTOR *(states[minus_action_index] + np.max(minus_action_state_reward))
if terminal[plus_action_index]:
plus_action_q_value = DISCOUNT_FACTOR * states[plus_action_index]
else:
plus_action_state_reward = session.run(output,
feed_dict={state: [one_hot_state(plus_action_index)]})
plus_action_q_value = DISCOUNT_FACTOR * (states[plus_action_index] + np.max(plus_action_state_reward))
如果我们现在再次运行代码,输出将稳定为这样:
[0.049, 0.111, 0.242, 0.497, 1.0, 0.497, 0.242, 0.111, 0.0469, 0.018]
动态游戏
现在我们已经学习了世界上最简单的游戏,让我们尝试学习一些更加动态的内容。Cart pole 任务是一个经典的强化学习问题。代理必须控制一个小车,上面平衡着一个杆,通过一个关节连接到小车上。在每一步,代理可以选择将小车向左或向右移动,并且每一步平衡杆的时候都会获得奖励 1。如果杆与竖直方向偏差超过 15 度,游戏就结束:

图 5:Cart pole 任务
要运行 Cart pole 任务,我们将使用 OpenAIGym,这是一个于 2015 年创建的开源项目,它以一致的方式提供了一种运行强化学习代理与一系列环境进行交互的方法。在撰写本文时,OpenAIGym 支持运行一系列 Atari 游戏,甚至还支持一些更复杂的游戏,如 doom,而且设置最少。可以通过运行以下命令来安装它:
pip install gym[all]
在 Python 中运行 Cart pole 可以通过以下方式实现:
import gym
env = gym.make('CartPole-v0')
current_state = env.reset()
gym.make方法创建了我们的代理将在其中运行的环境。传入"CartPole-v0"字符串告诉 OpenAIGym 我们希望这是车杆任务。返回的env对象用于与车杆游戏进行交互。env.reset()方法将环境置于其初始状态,并返回描述它的数组。调用env.render()将以可视方式显示当前状态,并对env.step(action)的后续调用允许我们与环境进行交互,以响应我们调用它的动作返回新的状态。
我们需要如何修改我们简单的一维游戏代码以学习车杆挑战?我们不再有一个明确定义的位置;相反,车杆环境将一个描述车和杆位置和角度的四个浮点值的数组作为输入给我们。这些将成为我们的神经网络的输入,它将由一个具有 20 个节点和一个tanh激活函数的隐藏层组成,导致一个具有两个节点的输出层。一个输出节点将学习当前状态向左移动的预期奖励,另一个输出节点将学习当前状态向右移动的预期奖励。以下是代码示例(完整的代码示例在 git repo 的deep_q_cart_pole.py中):
feed_forward_weights_1 = tf.Variable(tf.truncated_normal([4,20], stddev=0.01))
feed_forward_bias_1 = tf.Variable(tf.constant(0.0, shape=[20]))
feed_forward_weights_2 = tf.Variable(tf.truncated_normal([20,2], stddev=0.01))
feed_forward_bias_2 = tf.Variable(tf.constant(0.0, shape=[2]))
input_placeholder = tf.placeholder("float", [None, 4])
hidden_layer = tf.nn.tanh(tf.matmul(input_placeholder, feed_forward_weights_1) + feed_forward_bias_1)
output_layer = tf.matmul(hidden_layer, feed_forward_weights_2) + feed_forward_bias_2
为什么使用一个具有 20 个节点的隐藏层?为什么使用tanh激活函数?挑选超参数是一门黑暗的艺术;我能给出的最好答案是,当尝试时,这些值表现良好。但是知道它们在实践中表现良好,以及知道一些关于解决车杆问题需要什么样的复杂程度的信息,我们可以猜测为什么这可能指导我们选择其他网络和任务的超参数。
一个关于在监督学习中隐藏节点数量的经验法则是,它应该在输入节点数量和输出节点数量之间。通常,输入数量的三分之二是一个不错的区域。然而,在这里,我们选择了 20,是输入节点数量的五倍。一般来说,偏爱更少的隐藏节点有两个原因:第一个是计算时间,更少的单元意味着我们的网络运行和训练速度更快。第二个是减少过拟合并提高泛化能力。你已经从前面的章节中了解到了过拟合以及过于复杂模型的风险,即它完全学习了训练数据,但没有能力泛化到新数据点。
在强化学习中,这些问题都不那么重要。虽然我们关心计算时间,但通常瓶颈大部分时间花在运行游戏上;因此,额外的几个节点不太重要。对于第二个问题,在泛化方面,我们没有测试集和训练集的划分,我们只有一个环境,一个代理人在其中获得奖励。因此,过度拟合不是我们必须担心的事情(直到我们开始训练能够跨多个环境运行的代理人)。这也是为什么你经常看不到强化学习代理人使用正则化器的原因。这个警告是,随着训练过程的进行,我们的训练集的分布可能会因为我们的代理人在训练过程中的变化而发生显著变化。存在的风险是代理人可能会对我们从环境中获得的早期样本过度拟合,并导致学习在后来变得更加困难。
鉴于这些问题,选择隐藏层中的任意大数量节点是有意义的,以便给予最大可能性学习输入之间的复杂交互。但是唯一真正的方法是测试。图 6显示了运行具有三个隐藏节点的神经网络与小车杆任务的结果。正如您所看到的,尽管它最终能够学会,但其表现远不及具有 20 个隐藏节点的情况,如图 7所示:

图 6:具有三个隐藏节点的小车杆,y = 最近 10 场比赛的平均奖励,x = 已玩的比赛
为什么只有一个隐藏层?任务的复杂性可以帮助我们估计这一点。如果我们考虑小车杆任务,我们知道我们关心输入参数的相互关系。杆的位置可能好也可能不好,这取决于小车的位置。这种交互水平意味着仅仅是权重的纯线性组合可能不够。通过快速运行,这个猜测可以得到确认,它将显示出尽管没有隐藏层的网络可以比随机更好地学习这个任务,但它的表现远不如单隐藏层网络。
更深的网络会更好吗?也许,但是对于只有这种轻微复杂性的任务来说,更多的层次往往不会改善事情。运行网络将确认额外的隐藏层似乎几乎没有什么影响。一个隐藏层为我们提供了我们在这个任务中所需要的容量。
至于选择tanh,有几个因素需要考虑。relu 激活函数之所以在深度网络中流行,是因为饱和。当运行一个具有激活函数范围受限的多层网络时,例如 logistic 函数的 0 到 1,许多节点会学会在接近 1 的最大值处激活。它们在 1 处饱和。但我们经常希望在输入更极端时信号更明显。这就是为什么 relu 如此流行的原因——它给一个层增加了非线性,同时不限制其最大激活。这在许多层网络中尤为重要,因为早期层可能会获得极端的激活,这对于向未来层发出信号是有用的。
只有一个层时,这不是一个问题,所以 sigmoid 函数是合理的。输出层将能够学习将来自我们隐藏层的值缩放到它们需要的值。有没有理由更喜欢 tanh 而不是 logistic 函数呢?我们知道我们的目标有时会是负数,并且对于一些参数组合,这可能是好的或坏的,具体取决于它们的相对值。这表明,提供由 tanh 函数提供的 -1 到 1 的范围可能比 logistic 函数更可取,其中要判断负相关性,首先必须学习偏差。这是事后的大量推测和推理;最好的答案最终是这种组合在这个任务中非常有效,但希望它能够在面对其他类似问题时给出一些最佳超参数的开始猜测的感觉。
要回到代码,我们的损失和训练函数在我们的推车杆任务中将是这样的:
action_placeholder = tf.placeholder("float", [None, 2])
target_placeholder = tf.placeholder("float", [None])
q_value_for_state_action = tf.reduce_sum(tf.mul(output_layer, action_placeholder),reduction_indices=1)
q_value_for_state_action 变量将是网络为给定状态和动作预测的 q-value。将 output_layer 乘以 action_placeholder 向量,除了我们采取的动作外,其他所有值都将为 0,然后对其求和,这意味着我们的输出将是我们的神经网络对于仅仅那个动作的预期值的近似:
cost = tf.reduce_mean(tf.square(target_placeholder –
q_value_for_state_action))
train_operation = tf.train.RMSPropOptimizer(0.001).minimize(cost)
我们的成本是我们认为的状态和动作的预期回报与由 target_placeholder 定义的应该是的回报之间的差异。
描述在第七章棋盘游戏的深度学习中的策略梯度方法的一个缺点是,所有训练都必须针对环境进行。一组策略参数只能通过观察其对环境奖励的影响来评估。而在 Q 学习中,我们试图学习如何评估一个状态和动作的价值。随着我们对特定状态价值的理解能力提高,我们可以利用这些新信息更好地评估我们曾经经历过的先前状态。因此,与其总是在当前经历的状态上进行训练,我们可以让我们的网络存储一系列状态,并针对这些状态进行训练。这被称为经验回放。
经验回放
每次我们采取一个动作并进入一个新状态时,我们都会存储一个元组previous_state, action_taken, next_reward, next_state和next_terminal。这五个信息片段就足以运行一个 Q 学习训练步骤。当我们玩游戏时,我们将把这些信息存储为一系列观察。
另一个经验回放有助于解决的困难是,在强化学习中,训练很难收敛。部分原因是我们训练的数据之间存在非常强的相关性。学习代理人经历的一系列状态将密切相关;如果一系列状态和行动的时间序列一起训练,会对网络的权重产生很大影响,并可能撤销大部分之前的训练。神经网络的一个假设是训练样本都是来自某个分布的独立样本。经验回放有助于解决这个问题,因为我们可以让训练的小批量样本从内存中随机抽样,这样样本之间就不太可能相关。
从记忆中学习的学习算法称为离线学习算法。另一种方法是在线学习,其中我们只能根据直接玩游戏来调整参数。策略梯度、遗传算法和交叉熵方法都是其示例。
运行带有经验回放的车杆的代码如下:
from collections import deque
observations = deque(maxlen=20000)
last_action = np.zeros(2)
last_action[0] = 1
last_state = env.reset()
我们从我们的observations集合开始。在 Python 中,一个 deque 是一个队列,一旦达到容量就会开始从队列开头删除项目。在这里创建 deque 时,其 maxlen 为 20,000,这意味着我们只会存储最近的 20,000 个观察。我们还创建了最后一个动作,np.array,它将存储我们从上一个主循环中决定的动作。它将是一个独热向量:
while True:
env.render()
last_action = choose_next_action(last_state)
current_state, reward, terminal, _ = env.step(np.argmax(last_action))
这是主循环。我们首先渲染环境,然后根据我们所处的last_state决定采取什么动作,然后采取该动作以获得下一个状态:
if terminal:
reward = -1
OpenAIGym 中的小车杆任务始终在每个时间步长给出奖励 1。当我们达到终止状态时,我们将强制给予负奖励,以便代理有信号学习避免它:
observations.append((last_state, last_action, reward, current_state, terminal))
if len(observations) > 10000:
train()
我们将此转换的信息存储在我们的观察数组中。如果我们有足够的观察结果存储,我们也可以开始训练。只有在我们有足够多的样本时才开始训练非常重要,否则少量早期观察结果可能会严重偏倚训练:
if terminal:
last_state = env.reset()
else:
last_state = current_state
如果我们处于终止状态,我们需要重置我们的env以便给我们一个新的游戏状态。否则,我们可以将last_state设置为下一个训练循环的current_state。我们现在还需要根据状态决定采取什么行动。然后是实际的train方法,使用与我们之前的 1-D 示例相同的步骤,但改为使用来自我们观察的样本:
def _train():
mini_batch = random.sample(observations, 100)
从我们的观察中随机取 100 个项目;这些将是要进行训练的mini_batch:
previous_states = [d[0] for d in mini_batch]
actions = [d[1] for d in mini_batch]
rewards = [d[2] for d in mini_batch]
current_states = [d[3] for d in mini_batch]
将mini_batch元组解包为每种类型数据的单独列表。这是我们需要馈入神经网络的格式:
agents_reward_per_action = session.run(_output_layer, feed_dict={input_layer: current_states})
获取由我们的神经网络预测的每个current_state的奖励。这里的输出将是一个大小为mini_batch的数组,其中每个项目都是一个包含两个元素的数组,即采取左移动作的 Q 值估计和采取右移动作的 Q 值估计。我们取其中的最大值以获取状态的估计 Q 值。连续的训练循环将改进此估计值以接近真实的 Q 值:
agents_expected_reward = []
for i in range(len(mini_batch)):
if mini_batch[i][4]:
# this was a terminal frame so there is no future reward...
agents_expected_reward.append(rewards[i])
else:
agents_expected_reward.append(rewards[i] + FUTURE_REWARD_DISCOUNT * np.max(agents_reward_per_action[i]))
如果是非终止状态,将我们实际获得的奖励与我们的网络预测的奖励相结合:
session.run(_train_operation, feed_dict={
input_layer: previous_states,
action: actions,
target: agents_expected_reward})
最后,对网络运行训练操作。
Epsilon 贪心
Q 学习的另一个问题是,最初,网络会非常糟糕地估计动作的奖励。但是这些糟糕的动作估计决定了我们进入的状态。我们早期的估计可能非常糟糕,以至于我们可能永远无法从中学习到奖励的状态。想象一下,在小车杆中,网络权重被初始化,使代理始终选择向左移动,因此在几个时间步长后失败。因为我们只有向左移动的样本,所以我们永远不会开始调整我们的权重以向右移动,因此永远无法找到具有更好奖励的状态。
对此有几种不同的解决方案,例如为网络提供进入新颖情境的奖励,称为新颖性搜索,或者使用某种修改来寻找具有最大不确定性的动作。
最简单的解决方案,也是被证明效果良好的解决方案之一,是从随机选择动作开始,以便探索空间,然后随着网络估计变得越来越好,将这些随机选择替换为网络选择的动作。这被称为 epsilon 贪心策略,它可以作为一种轻松实现一系列算法的探索方法。这里的 epsilon 是指用于选择是否使用随机动作的变量,贪心是指如果不是随机行动,则采取最大行动。在单车杆示例中,我们将这个 epsilon 变量称为probability_of_random_action。它将从 1 开始,表示 0 的随机动作几率,然后在每个训练步骤中,我们将其减小一些小量,直到它为 0 为止:
if probability_of_random_action > 0.and len(_observations) > OBSERVATION_STEPS:
probability_of_random_action -= 1\. / 10000
在最后一步,我们需要的是将我们的神经网络输出转换为智能体动作的方法:
def choose_next_action():
if random.random() <= probability_of_random_action:
action_index = random.randrange(2)
如果随机值小于probability_of_random_action,则随机选择一个动作;否则选择我们神经网络的最大输出:
else:
readout_t = session.run(output_layer, feed_dict={input_layer: [last_state]})[0]
action_index = np.argmax(readout_t)
new_action = np.zeros([2])
new_action[action_index] = 1
return new_action
这是训练进度与单车杆任务的图表:

图 7:单车杆任务,y = 过去 10 次游戏的平均长度 x = 所玩游戏的数量
看起来很不错。单车杆任务的成功定义为能够持续超过 200 轮。在 400 次游戏后,我们轻松击败了这个标准,平均每局游戏持续时间远远超过 300 轮。因为我们使用 OpenAIGym 设置了这个学习任务,现在可以轻松地设置到其他游戏中。我们只需要将gym.make行更改为以新输入游戏字符串为输入,然后调整我们网络的输入和输出数量以适应该游戏。在 OpenAIGym 中还有一些其他有趣的控制任务,例如摆锤和杂技,q-learning 在这些任务上也应该表现良好,但作为挑战,让我们来玩一些 Atari 游戏。
Atari Breakout
Breakout 是一款经典的 Atari 游戏,最初于 1976 年发布。玩家控制一个挡板,必须用它将球弹到屏幕顶部的彩色方块上。每次命中一个方块都会得分。如果球下落到屏幕底部超出挡板范围,玩家将失去一条生命。游戏在所有方块被销毁或玩家失去最初的三条生命后结束:

图 8:Atari Breakout
想想学习像 Breakout 这样的游戏比我们刚刚看过的单车杆任务要难多少。对于单车杆来说,如果做出了导致杆倾斜的错误动作,我们通常会在几个动作内收到反馈。在 Breakout 中,这样的反馈要少得多。如果我们将挡板定位不正确,可能是因为进行了 20 多次移动才导致的。
Atari Breakout 随机基准测试
在我们进一步探讨之前,让我们创建一个通过随机选择移动来玩 Breakout 的代理程序。这样,我们将有一个基准来评价我们的新代理程序:
from collections import deque
import random
import gym
import numpy as np
env = gym.make("Breakout-v0")
observation = env.reset()
reward_per_game = 0
scores = dequeu(maxlen=1000)
while True:
env.render()
next_action = random.randint(1, 3)
observation, reward, terminal, info = env.step(next_action)
reward_per_game += reward
我们随机选择我们的移动方向;在 Breakout 中,移动的方式如下:
-
1:向左移动
-
2:保持静止
-
3:向右移动
if terminal:
scores.append(reward_per_game)
reward_per_game = 0
print(np.mean(scores))
env.reset()
如果我们已经玩了很多游戏,那么我们将存储并打印我们的分数,然后调用env.reset()继续玩。通过让这个运行一段时间,我们可以看到随机 Breakout 倾向于每场比赛得分约为 1.4 分。让我们看看我们能用 Q-learning 做得更好多少。
我们必须处理的第一个问题是从我们的车杆任务中进行调整的状态空间要大得多。对于车杆输入,是 210 x 160 像素的完整屏幕,每个像素包含三个浮点数,分别是每种颜色的值。要理解游戏,这些像素必须与方块、球拍和球相关联,然后这些物体之间的交互必须在某种程度上被计算。更让事情变得更加困难的是,单个屏幕图像不足以理解游戏正在发生什么。球随时间以某种速度移动;要理解最佳移动,你不能仅仅依赖于当前屏幕图像。
处理这个问题有三种方法:一种是使用循环神经网络,它将根据先前的输出来判断当前状态。这种方法可以奏效,但训练难度较大。另一种方法是将屏幕输入作为当前帧和上一帧之间的增量。在图 9中,你会看到一个例子。由于在 Pong 中颜色并没有提供信息,因此两个帧都已经被转换为灰度。上一帧的图像已从当前帧的图像中减去。这可以让你看到球的路径和两个球拍移动的方向:

图 9:Pong 游戏的增量图像
这种方法对于只由移动元素组成的游戏(如 Pong)效果很好,但对于像 Breakout 这样的游戏,其中方块的位置是固定的,我们将丢失有关游戏状态的重要信息。事实上,我们只能在方块被击中时看到它的一瞬间,而我们尚未击中的方块将保持不可见。
对于 Breakout,我们将采取的第三种方法是将当前状态设置为游戏最近 n 个状态的图像,其中 n 为 2 或更多。这允许神经网络拥有做出对游戏状态的良好判断所需的所有信息。对于大多数游戏来说,默认值 n 为 4 是很好的选择;但对于 Breakout,已经发现 n 为 2 就足够了。尽可能使用较低的 n 值是很好的,因为这会减少我们的网络所需的参数数量。
屏幕预处理
全部代码在 Git 存储库中的deep_q_breakout.py中。但我们将在此处逐个讨论与杆平衡示例的一些重要修改。首先是神经网络的类型。对于杆平衡,一个具有单个隐藏层的网络就足够了。但这涉及到将四个值映射到只有两个动作。现在,我们要处理screen_width * screen_height * color_channels * number_of_frames_of_state = 201600被映射到三个动作,这是一个更高级别的复杂度。
我们可以做的第一件事是将屏幕调整大小,以便为自己省点力。经过实验,我们发现可以将屏幕缩小后仍可玩 Breakout。缩小两倍仍可看到球,球拍和所有方块。而且,图像空间中大部分都不是对代理有用的信息,顶部的得分、侧面和顶部的灰色区域,以及底部的黑色空间都可以从图像中裁剪掉。这使我们能够将 210 * 160 的屏幕缩减为更容易管理的 72 * 84,将参数数量减少了四分之三以上。
在 Breakout 游戏中,像素的颜色不包含任何有用的信息,所以我们可以用单一颜色代替三种颜色,这只有黑色或白色,将输入的数量再次减少到三分之一。现在我们只剩下 72 * 84 = 6048 个位,需要两帧的游戏才能学习。我们现在来写一个方法来处理 Breakout 的屏幕:
def pre_process(screen_image):
screen_image参数将是我们从 OpenAIGym 的env.reset或env.next_step操作中获得的 Numpy 数组。它的形状为 210 * 160 * 3,每个项都是表示该颜色值的 0 到 255 之间的整数:
screen_image = screen_image[32:-10, 8:-8]
对 Numpy 数组的这个操作裁剪了图像,因此我们去掉了顶部的分数,底部的黑色空间和两侧的灰色区域:
screen_image = screen_image[::2, ::2, 0]
Python 数组的::2参数意味着我们取每隔一个项目,幸运的是 Numpy 也支持这种操作。末尾的 0 表示我们只取红色通道,这很好,因为我们马上就要把它变成只有黑白两种颜色。screen_image现在将被处理成 72 * 84 * 1 的大小:
screen_image[screen_image != 0] = 1
这将图像中不是完全黑色的一切设为 1。这在一些需要精确对比度的游戏中可能行不通,但对于 Breakout 游戏来说就有效了:
return screen_image.astype(np.float)
最后,这个方法返回的screen_image确保类型转换成浮点数。这会在以后将值放入 TensorFlow 时节省时间。图 10展示了处理前后屏幕的样子。经过处理后,尽管不太美观,图像仍然包含你玩游戏所需的所有元素:

图 10:处理前后的 Breakout 样子
这使我们的状态为 72842 = 12800 位,意味着我们需要将我们的三个动作映射到 2^(12800) 种可能的状态。这听起来很多,但问题变得更简单了,因为尽管这是打砖块游戏中可能的所有状态的完整范围,但只有一组相当小且可预测的状态会发生。挡板在固定区域水平移动;一个像素将激活球,一些方块将存在于中央区域。可以很容易地想象从图像中提取出一些特征,这些特征可能更好地与我们想要采取的动作相关联 —— 例如,我们的挡板与球的相对位置、球的速度等 —— 这是深度神经网络可以捕捉到的特征。
创建一个深度卷积网络
接下来,让我们用一个深度卷积网络来替换小车摆动示例中的单隐藏层网络。卷积网络首次出现在 第四章,“无监督特征学习”。卷积网络是有意义的,因为我们处理的是图像数据。我们创建的网络将有三个卷积层,导致一个单一的平坦层,导致我们的输出。使用四个隐藏层有一定的直观意义,因为我们知道我们将需要从像素中检测非常抽象的不变表示,但也已经被证明对于一系列架构是成功的。因为这是一个深度网络,relu 激活函数是有意义的。图 11 显示了网络的样子:

图 11:我们的网络架构,将学习玩打砖块游戏。
这是创建我们的深度卷积网络的代码:
SCREEN_HEIGHT = 84
SCREEN_WIDTH = 74
STATE_FRAMES = 2
CONVOLUTIONS_LAYER_1 = 32
CONVOLUTIONS_LAYER_2 = 64
CONVOLUTIONS_LAYER_3 = 64
FLAT_HIDDEN_NODES = 512
这些常量将在我们的 create_network 方法中使用:
def create_network():
input_layer = tf.placeholder("float", [None, SCREEN_HEIGHT, SCREEN_WIDTH, STATE_FRAMES])
我们将我们的输入定义为高度、宽度和状态帧的乘积;none 维度将用于状态批次:
convolution_weights_1 = tf.Variable(tf.truncated_normal([8, 8, STATE_FRAMES, CONVOLUTIONS_LAYER_1], stddev=0.01))
convolution_bias_1 = tf.Variable(tf.constant(0.01, shape=[CONVOLUTIONS_LAYER_1]))
第一个卷积层将是一个 8x8 的窗口,跨越宽度和高度,同时接收状态帧。因此,它将获得关于当前图像的 8x8 部分和上一帧中该 8x8 补丁是什么样子的数据。每个补丁将映射到 32 个卷积,将成为下一层的输入。我们给偏置一个非常轻微的正值;这对于具有 relu 激活的层来说可能是有好处的,以减少 relu 函数造成的死神经元数量:
hidden_convolutional_layer_1 = tf.nn.relu(
tf.nn.conv2d(input_layer, convolution_weights_1, strides=[1, 4, 4, 1], padding="SAME") + convolution_bias_1)
我们将权重和偏置变量放入卷积层中。这是通过 tf.nn.conv2d 方法创建的。设置 strides=[1, 4, 4, 1] 意味着 8x8 的卷积窗口将在图像的宽度和高度上每四个像素应用一次。所有的卷积层都将通过 relu 激活函数:
convolution_weights_2 = tf.Variable(tf.truncated_normal([4, 4, CONVOLUTIONS_LAYER_1, CONVOLUTIONS_LAYER_2], stddev=0.01))
convolution_bias_2 = tf.Variable(tf.constant(0.01, shape=[CONVOLUTIONS_LAYER_2]))
hidden_convolutional_layer_2 = tf.nn.relu(
tf.nn.conv2d(hidden_convolutional_layer_1, convolution_weights_2, strides=[1, 2, 2, 1], padding="SAME") + convolution_bias_2)
convolution_weights_3 = tf.Variable(tf.truncated_normal([3, 3, CONVOLUTIONS_LAYER_2, CONVOLUTIONS_LAYER_3], stddev=0.01))
convolution_bias_3 = tf.Variable(tf.constant(0.01, shape=[CONVOLUTIONS_LAYER_2]))
hidden_convolutional_layer_3 = tf.nn.relu(
tf.nn.conv2d(hidden_convolutional_layer_2, convolution_weights_3, strides=[1, 1, 1, 1], padding="SAME") + convolution_bias_3)
创建接下来的两个卷积层的步骤与之前相同。我们的最后一个卷积层hidden_convolutional_layer_3现在必须连接到一个扁平层:
hidden_convolutional_layer_3_flat = tf.reshape(hidden_convolutional_layer_3, [-1, 9*11*CONVOLUTIONAL_LAYER_3])
这将把我们的卷积层重新整形为单个扁平层,其维度为 none,9,11,64:
feed_forward_weights_1 = tf.Variable(tf.truncated_normal([FLAT_SIZE, FLAT_HIDDEN_NODES], stddev=0.01))
feed_forward_bias_1 = tf.Variable(tf.constant(0.01, shape=[FLAT_HIDDEN_NODES]))
final_hidden_activations = tf.nn.relu(
tf.matmul(hidden_convolutional_layer_3_flat, feed_forward_weights_1) + feed_forward_bias_1)
feed_forward_weights_2 = tf.Variable(tf.truncated_normal([FLAT_HIDDEN_NODES, ACTIONS_COUNT], stddev=0.01))
feed_forward_bias_2 = tf.Variable(tf.constant(0.01, shape=[ACTIONS_COUNT]))
output_layer = tf.matmul(final_hidden_activations, feed_forward_weights_2) + feed_forward_bias_2
return input_layer, output_layer
我们接着按照标准方式创建最后两个扁平层。请注意,最后一层没有激活函数,因为我们在这里学习的是给定状态下动作的价值,它具有无界范围。
现在我们的主循环需要添加以下代码,以便当前状态是多个帧的组合,在打砖块游戏中,STATE_FRAMES设置为2,但较高的数字也会起效:
screen_binary = preprocess(observation)
if last_state is None:
last_state = np.stack(tuple(screen_binary for _ in range(STATE_FRAMES)), axis=2)
如果我们没有last_state,那么我们就构造一个新的 Numpy 数组,它只是当前的screen_binary堆叠了我们想要的STATE_FRAMES次:
else:
screen_binary = np.reshape(screen_binary, (SCREEN_HEIGHT, SCREEN_WIDTH, 1))
current_state = np.append(last_state[:, :, 1:], screen_binary, axis=2)
否则,我们将新的screen_binary添加到我们的last_state的第一个位置以创建新的current_state。然后我们只需要记住在主循环结束时将我们的last_state重新分配为等于我们的当前状态:
last_state = current_state
现在可能遇到的一个问题是,我们的状态空间现在是一个大小为 84742 的数组,并且我们想要以 100 万个这样的数组的顺序作为过去观察的列表,用于训练。除非您的计算机非常强大,否则可能会遇到内存问题。幸运的是,这些数组中很多将是非常稀疏的,并且只包含两种状态,因此可以使用内存压缩来解决这个问题。这将牺牲一些 CPU 时间来节省内存;因此在使用之前,请考虑哪个对您更重要。在 Python 中实现它只需要几行代码:
import zlib
import pickle
observations.append(zlib.compress(
pickle.dumps((last_state, last_action, reward, current_state, terminal), 2), 2))
在这里,我们压缩数据然后将其添加到我们的观察列表中:
mini_batch_compressed = random.sample(_observations, MINI_BATCH_SIZE)
mini_batch = [pickle.loads(zlib.decompress(comp_item)) for comp_item in mini_batch_compressed]
当从列表中取样时,我们只在使用时解压我们的小批量样本。
我们可能遇到的另一个问题是,尽管推车杆可能只需要几分钟就能训练好,但打砖块的训练时间可能会以天计算。为了防止出现意外情况,比如断电关机,我们希望在训练过程中随时保存我们的网络权重。在 Tensorflow 中,这只需要几行代码:
CHECKPOINT_PATH = "breakout"
saver = tf.train.Saver()
if not os.path.exists(CHECKPOINT_PATH):
os.mkdir(CHECKPOINT_PATH)
checkpoint = tf.train.get_checkpoint_state(CHECKPOINT_PATH)
if checkpoint:
saver.restore(session, checkpoint.model_checkpoint_path)
这可以放在文件的开头,就在session.run(tf.initialize_all_variables())这一行上面。然后我们只需执行以下命令:
saver.save(_session, CHECKPOINT_PATH + '/network')
这意味着每隔几千次训练迭代都要创建我们网络的定期备份。现在让我们看一下训练的效果如何:

我们可以看到,在 170 万次迭代后,我们玩的水平远远超出了随机水平。这种相同的 Q 学习算法已经尝试过多种 Atari 游戏,并且通过良好的超参数调整,在 Pong、Space Invaders 和 Q*bert 等游戏中能够达到人类水平或更高的表现。
Q 学习中的收敛问题
但是,并不是一帆风顺的。让我们看看在前面序列结束后代理的训练如何继续:

如您所见,在某个时刻,代理的能力出现了巨大且持续的下降,然后回到了类似的水平。这种情况的可能原因(尽管我们很难确切知道原因)之一是 Q 学习的问题之一。
Q 学习是针对其自身对状态动作对的表现期望进行训练的。这是一个移动的目标,因为每次运行训练步骤时,目标都会发生变化。我们希望它们朝着对奖励更准确的估计值的方向移动。但随着它们朝着那里前进,参数的小变化可能会导致相当极端的振荡。
一旦我们陷入了比先前的能力评估更差的状态,每个状态动作评估都必须调整到这个新现实。如果我们每场比赛平均得分为 30 分,而通过我们的新策略,我们只能得到 20 分,整个网络都必须调整到这个情况。
目标网络冻结(Minh 等人 2015 年,《通过深度强化学习实现人类水平控制》- 自然)可以帮助减少这种情况。第二个神经网络,称为目标网络,被创建为主训练网络的副本。在训练期间,我们使用目标网络生成用于训练主神经网络的目标值。通过这种方式,主网络正在学习针对更固定的点。目标网络的权重被冻结,但一旦过了一定数量的迭代次数或达到收敛标准,目标网络就会更新为来自主网络的值。已经证明,这个过程可以显著加快训练速度。
很多强化学习可能遇到的另一个问题与具有相当极端奖励的游戏相关。例如,吃下力量丸然后吃掉鬼魂给予了非常高的奖励。这些接收到的极端奖励可能会导致梯度问题,并导致次优学习。修复这个问题的非常简单但不够令人满意的方法叫做奖励剪切,它只是将从环境中接收到的奖励剪切在某个范围内(-1 和 +1 常用)。这种方法花费很少的精力,但它的问题在于代理已经丢失了关于这些更大奖励的信息。
另一种方法是所谓的归一化深度 Q 网络(Hasselt 等人——跨多个数量级学习价值,2016)。这涉及将神经网络设置为在 -1 到 1 范围内输出状态和动作的预期奖励。将输出放入此范围后,它通过以下方程进行处理:

这里,U(s, a) 是神经网络的输出。参数 σ 和 µ 可以通过确保目标网络和主网络之间的缩放输出保持恒定来计算出,如目标网络冻结中所述:

使用这种方法,神经网络梯度将更多地指向学习状态和动作的相对值,而不是简单地消耗精力学习 Q 值的规模。
策略梯度与 Q 学习
虽然我们举了一个例子,使用策略梯度来学习棋盘游戏,使用 Q 学习来学习计算机游戏,但这两种技术并不局限于此类型。最初,Q 学习被认为是更好的技术,但随着时间的推移和更好的超参数调整,策略梯度常常表现更好。1991 年利用神经网络和 Q 学习在博弈中取得了世界最佳表现,最新研究表明策略梯度对大多数雅达利游戏效果最佳。那么何时应该使用策略梯度而不是 Q 学习呢?
一个限制是,Q 学习只适用于离散动作任务,而策略梯度可以学习连续动作任务。此外,Q 学习是确定性算法,对于某些任务,最佳行为涉及一定程度的随机性。例如,石头、剪刀、布,任何偏离纯随机性的行为都可能被对手利用。
也存在在线学习与离线学习的方面。对于许多任务,特别是机器人控制任务,在线学习可能非常昂贵。需要从记忆中学习的能力,因此 Q 学习是最佳选择。不幸的是,无论是 Q 学习还是策略梯度的成功都会受到任务和超参数选择的影响很大;因此,在确定新任务的最佳学习方法时,实验似乎是最好的方法。
策略梯度也更容易陷入局部最小值。Q 学习更有可能找到全局最优解,但这样做的成本是未经证实的收敛,性能可能在达到全局最优解的过程中发生剧烈波动或完全失败。
但也有另一种方法,它兼具两者的优点,这就是演员-评论员方法。
演员-评论员方法
强化学习方法可以分为三大类:
-
基于价值的学习:这个方法试图学习处于某个状态的预期奖励/价值。然后可以根据其相对值来评估进入不同状态的可取性。Q 学习就是基于价值的学习的例子。
-
基于策略的学习:在这种方法中,不尝试评估状态,而是尝试不同的控制策略,并根据环境的实际奖励进行评估。策略梯度就是例子。
-
基于模型的学习:在这种方法中,代理试图对环境的行为进行建模,并选择基于模型模拟其可能采取的行动结果来评估其模型的行为。
演员评论方法都围绕着使用两个神经网络进行训练的想法。第一个,评论者,使用基于价值的学习来学习给定状态的值函数,即代理人实现的预期奖励。然后,演员网络使用基于策略的学习来最大化评论者的值函数。演员正在使用策略梯度进行学习,但现在其目标已经改变。不再是通过游戏获得的实际奖励,而是使用评论者对该奖励的估计。
Q 学习的一个重大问题是,在复杂情况下,算法很难收敛。由于 Q 函数的重新评估改变了选择的动作,实际的价值奖励可能会有很大的变化。例如,想象一个简单的走迷宫机器人。在迷宫中遇到的第一个 T 形交叉口,它最初向左移动。 Q 学习的连续迭代最终导致它确定右移是更可取的方式。但现在,因为其路径完全不同,现在必须重新计算每个其他状态评估; 先前学到的知识现在价值很低。 Q 学习由于策略的微小变化可能对奖励产生巨大影响而受到高方差的影响。
在演员评论中,评论者所做的事情与 Q 学习非常相似,但存在一个关键区别:不是学习给定状态的假设最佳动作,而是学习基于演员当前遵循的最可能的次优策略的预期奖励。
相反,策略梯度存在相反的高方差问题。由于策略梯度是随机地探索迷宫,某些移动可能被选择,实际上相当不错,但由于在同一次试验中选择了其他不良移动而被评估为不良。这是因为尽管策略更稳定,但与评估策略相关的方差很高。
这就是演员评论的目标,旨在共同解决这两个问题。基于价值的学习现在方差较低,因为策略现在更加稳定和可预测,而基于策略梯度的学习也更加稳定,因为现在它具有一个从中获取梯度的方差值函数。
方差减少的基线
演员评论方法有几种不同的变体:我们将首先看一下基线演员评论。在这里,评论者试图学习代理人从给定位置的平均表现,因此其损失函数将是这样的:

这里,
是评论者网络在时间步t的状态的输出,r[t]是从时间步t开始的累积折现奖励。然后可以使用目标训练演员:

因为基线是从这个状态的平均表现中得出的,这样做的效果是大幅降低训练的方差。如果我们使用策略梯度一次运行小车杆任务,再使用基线一次,其中我们不使用批量规范化,我们可以看到基线表现更好。但如果我们加入批量规范化,结果并没有太大不同。对于比小车杆更复杂的任务,奖励可能随状态变化而变化很多,基线方法可能会更大程度地改善事物。这方面的一个例子可以在actor_critic_baseline_cart_pole.py中找到。
广义优势估计器
基线方法在减少方差方面做得很好,但它不是真正的演员评论家方法,因为演员不是在学习评论者的梯度,而只是使用它来规范化奖励。广义优势估计器进一步前进,并将评论者的梯度纳入演员的目标中。
为了做到这一点,我们需要学习的不仅仅是代理处于的状态的价值,还有它采取的状态动作对的价值。如果V(s[t])是状态的价值,Q(s[t], a[t])是状态动作对的价值,我们可以这样定义一个优势函数:

这将给我们带来动作a[t]在状态s[t]中的表现与代理在这个位置上平均动作之间的差异。向着这个函数的梯度移动应该会使我们最大化我们的奖励。而且,我们不需要另一个网络来估计Q(s[t], a[t]),因为我们可以利用我们在s[*t+1*]达到的状态的价值函数,而 Q 函数的定义如下:

在这里,r t 现在是该时间步的奖励,而不是基线方程中的累积奖励,
是未来奖励的折扣因子。我们现在可以将其代入,纯粹地给出我们的优势函数中的V项:

同样,这给我们提供了一个度量标准,用来判断评论者是否认为给定的动作改善了还是损害了位置的价值。我们将我们的演员损失函数中的累积奖励替换为优势函数的结果。这方面的完整代码在actor_critic_advantage_cart_pole.py中。这种方法用于小车杆挑战可以完成,但可能比仅使用批量规范化的策略梯度花费更长的时间。但对于像学习电脑游戏这样更复杂的任务,优势演员-评论家可能表现最好。
异步方法
在本章中我们看到了许多有趣的方法,但它们都受到训练速度非常慢的限制。当我们在基本控制问题上运行时,例如推车和杆子任务,这并不是什么问题。但是对于学习 Atari 游戏或者未来可能想要学习的更复杂的人类任务来说,数天到数周的训练时间就太长了。
对于策略梯度和演员-评论家来说,时间限制的一个重要部分是,在在线学习时,我们只能同时评估一个策略。我们可以通过使用更强大的 GPU 和更大的处理器获得显著的速度提升;在线评估策略的速度将始终作为性能的硬性限制。
这就是异步方法旨在解决的问题。其想法是在多个线程上训练相同的神经网络的多个副本。每个神经网络在线针对其线程上运行的环境的一个单独实例进行训练。不同于对每个训练步骤更新每个神经网络,更新跨多个训练步骤存储。每x个训练步骤,来自每个线程的累积批量更新被汇总在一起,并应用于所有网络。这意味着网络权重将根据所有网络更新中参数值的平均变化进行更新。
这种方法已经被证明适用于策略梯度、演员-评论家和 Q 学习。它极大地改善了训练时间,甚至提高了性能。在异步方法的最佳版本中,被认为是最成功的广义游戏学习算法的异步优势演员-评论家方法,在撰写本文时,被认为是最成功的广义游戏学习算法。
基于模型的方法
到目前为止,我们已经展示的方法可以很好地学习各种任务,但是通过这些方法训练出来的智能体仍然可能遭受重大限制:
-
它训练速度非常慢;一个人可以通过几次游玩学会像乒乓球一样的游戏,而对于 Q 学习,可能需要数百万次游玩才能达到类似的水平。
-
针对需要长期规划的游戏,所有技术表现都非常糟糕。想象一个平台游戏,玩家必须从房间的一侧取回一把钥匙,以打开另一侧的门。游戏中很少会发生这种情况,即使发生了,学习到这个钥匙是导致门获得额外奖励的机会也微乎其微。
-
它无法制定策略或以任何方式适应新颖的对手。它可能可以在与训练对手对战时表现良好,但在面对游戏玩法上有新颖性的对手时,学会适应将需要很长时间。
-
如果在环境中给出一个新的目标,就需要重新训练。如果我们正在训练打乒乓球作为左挡板,然后我们改为右挡板,我们将很难重新利用先前学到的信息。一个人可以毫不费力地做到这一点。
所有这些观点都可以说与一个中心问题相关。Q 学习和策略梯度在游戏中为奖励优化参数非常成功,但它们并没有学习如何理解游戏。人类学习在许多方面与 Q 学习有所不同,但一个显著的不同是,当人类学习一个环境时,他们在某种程度上正在学习这个环境的模型。然后他们可以使用该模型进行预测或者想象在环境中采取不同行动会发生什么事情。
想象一个玩家学习下棋的情景:他可以思考如果他进行某个特定的移动会发生什么。他可以想象在这一步之后棋盘会呈现什么样子,在那个新的位置他将会有哪些选择。他甚至可以将对手考虑进他的模型中,这个玩家是什么性格,倾向于采取什么样的走法,他的心情如何。
这就是基于模型的强化学习方法的目标。基于模型的 Pong 方法旨在建立一个模拟,模拟出它可能采取的不同行动的结果,并努力使该模拟尽可能接近现实。一旦建立起一个良好的环境模型,学习最佳行动就变得简单得多,因为代理可以将当前状态视为马尔可夫链的根,并利用一些来自第七章, 棋盘游戏的深度学习的技术,比如 MCTS-UCT,从其模型中抽样以查看哪些行动有最佳结果。它甚至可以更进一步,使用在自身模型上训练的 Q 学习或策略梯度,而不是在环境上训练。
基于模型的方法还有一个优势,那就是它们可能使人工智能更容易适应变化。如果我们已经学会了一个环境模型,但想要在其中改变我们的目标,我们可以重复使用同一个模型,只需简单地调整模型内的策略。如果我们讨论的是机器人或者在物理世界中运作的其他人工智能,通过玩数百万次的情节来学习策略梯度是完全不切实际的,特别是考虑到现实世界中的每次实验都会耗费时间、能量,并且存在着由于意外事件而带来的风险。基于模型的方法可以缓解许多这些问题。
构建模型引发种种问题。如果你正在构建一个基于模型的代理来学习 Pong,你知道它发生在一个二维环境中,有两个球拍和一个球,并且基本的物理规则。你需要这些元素都在你的模型中才能成功。但如果你手工制作这些,那么学习就不会那么多,并且你的代理远离了泛化学习算法。对于模型来说,什么是正确的先验?我们如何构建一个足够灵活,可以学习世界中遇到的复杂事物,同时仍能成功学习特定内容的模型?
更正式地说,学习模型可以看作是学习一个函数,它给出下一个状态在给定当前状态和动作对的情况下:

如果环境是随机的,此函数甚至可能返回可能的下一状态的概率分布。一个深度神经网络自然是该函数的一个很好的选择,然后学习将采取以下步骤:
-
构建一个输入为当前状态,输出为下一个状态和奖励的动作网络。
-
从环境中遵循一种探索性策略,收集一系列状态动作转换。简单地随机行动可能是一个很好的初始选择。
-
使用状态动作转换的集合以监督的方式训练网络,以下一状态和状态奖励作为目标。
-
使用训练好的网络转换来确定使用 MCTS、策略梯度或 Q-learning 的最佳移动。
如果我们以倒立摆任务为例,并以 MSE 作为损失函数,我们可以发现训练深度神经网络准确预测该环境的所有状态转换很容易,包括新状态何时终止。这个示例代码在 Git 仓库中。
甚至可以使用卷积和循环层来学习更复杂的 Atari 游戏模型。这是网络架构的一个例子:

来源:http://cs231n.stanford.edu/reports2016/116_Report.pdf
一个这样的网络使用了两个卷积/反卷积层和 128 个节点的 RNN 来学习预测 Pong 游戏中的下一帧。它能够成功地预测模糊版本的下一帧,但发现该模型不够稳健,无法运行 MCTS 来预测未来一两帧的事件。
这种方法的修改版本效果好得多。在这种方法中,网络不再尝试进行反卷积来预测下一帧图像,而是仅仅尝试预测 RNN 输入在下一帧中将是什么,从而消除了反卷积的需要。该网络可以学会以足够高的水平玩乒乓球,以击败游戏内的人工智能,训练后平均每场比赛赢得 2.9 分。这离完全训练的深度 Q 网络可以达到的 20.0 分还有很长的路要走,但对于一种非常新的方法来说,这仍然是一个有希望的结果。类似的结果也在 Breakout 游戏中实现了。
摘要
在本章中,我们研究了使用强化学习构建计算机游戏代理的方法。我们介绍了三种主要方法:策略梯度、Q 学习和基于模型的学习,并展示了如何将深度学习与这些方法结合使用以实现人类或更高水平的表现。我们希望读者能够从本章中获得足够的知识,以便能够将这些技术应用到他们可能想要解决的其他游戏或问题中。强化学习是当前非常令人兴奋的研究领域。谷歌、Deepmind、OpenAI 和微软等公司都在大力投资以解锁这一未来。
在下一章中,我们将探讨异常检测以及如何应用深度学习方法来检测金融交易数据中的欺诈实例。
第九章:异常检测
在第四章中,我们看到了特征学习的机制,特别是自动编码器作为监督学习任务的无监督预训练步骤的使用。
在本章中,我们将应用类似的概念,但用于不同的用例,即异常检测。
出色的异常检测器之一是找到智能数据表示,可以轻易表现出与正态分布的偏差。深度自动编码器在学习基础数据的高级抽象和非线性关系方面表现非常好。我们将展示深度学习如何非常适合异常检测。
在本章中,我们将首先解释离群点检测和异常检测概念之间的差异和共同之处。读者将通过一个想象的欺诈案例研究,随后通过示例,展示在现实世界应用中存在异常的危险以及自动和快速检测系统的重要性。
在进入深度学习实现之前,我们将介绍一些广泛应用于传统机器学习的技术家族及其当前局限性。
我们将应用在第四章中看到的深度自动编码器的架构,但用于一种特定的半监督学习,也称为新颖性检测。我们将提出两种强大的方法:一种基于重建错误,另一种基于低维特征压缩。
我们将介绍 H2O,这是一个最受欢迎的用于构建简单但可扩展的前馈多层神经网络的开源框架之一。
最后,我们将使用 H2O 自动编码器模型的 Python API 编写一些异常检测示例。
第一个例子将重用你在第三章中看到的 MNIST 数字数据集,深度学习基础和第四章中看到的无监督特征学习,但用于检测书写不良的数字。第二个例子将展示如何检测心电图时间序列中的异常脉动。
总结一下,本章将涵盖以下主题:
-
什么是异常和离群点检测?
-
异常检测的实际应用
-
流行的浅层机器学习技术
-
使用深度自动编码器进行异常检测
-
H2O 概述
-
代码示例:
-
MNIST 数字异常识别
-
心电图脉动检测
-
什么是异常和离群点检测?
异常检测通常与离群值检测和新奇检测相关,它是识别在同质数据集中偏离预期模式的项目、事件或观察结果。
异常检测是关于预测未知的。
每当我们在数据中发现一个不一致的观察结果,我们可以称之为异常或离群值。尽管这两个词经常可以互换使用,但实际上它们指的是两个不同的概念,正如 Ravi Parikh 在他的一篇博客文章中描述的那样(https://blog.heapanalytics.com/garbage-in-garbage-out-how-anomalies-can-wreck-your-data/):
"异常值是一个远离分布均值或中位数的合法数据点。它可能是不寻常的,比如 9.6 秒的 100 米赛跑,但仍在现实范围内。异常是由与其余数据生成过程不同的过程生成的非法数据点。"
让我们尝试用一个简单的欺诈检测示例来解释两者的区别。
在一份交易日志中,我们观察到一个特定客户每个工作日的午餐平均花费 10 美元。突然间,有一天他们花了 120 美元。这当然是一个离群值,但也许那天他们决定用信用卡支付整笔账单。如果这些交易中有几笔远高于预期金额的订单,那么我们可以识别出异常。异常是指当单一的罕见事件理由不再成立时,例如,连续三个订单的交易金额超过 120 美元。在这种情况下,我们谈论的是异常,因为已经从一个不同的过程生成了重复和相关的离群值模式,可能是信用卡欺诈,与通常的行为相比。
当阈值规则可以解决许多检测问题时,发现复杂的异常需要更高级的技术。
如果一个克隆的信用卡进行了大量金额为 10 美元的微支付,基于规则的检测器可能会失败。
通过简单地查看每个维度上的度量值,异常生成过程仍然可能隐藏在平均分布内。单一维度信号不会触发任何警报。让我们看看如果我们在信用卡欺诈示例中添加一些额外维度会发生什么:地理位置、当地时区的时间以及一周中的日期。
让我们更详细地分析同一个欺诈示例。我们的客户是一名全职员工,居住在罗马,但在米兰工作。每个周一早上,他乘火车去上班,然后在周六早上回罗马看朋友和家人。他喜欢在家做饭;他一周只出去吃几次晚餐。在罗马,他住在他的亲戚附近,所以他周末从不必准备午餐,但他经常喜欢和朋友出去过夜。预期行为的分布如下:
-
金额:介于 5 到 40 美元之间
-
位置:米兰 70%和罗马 30%
-
一天中的时间:70%在中午到下午 2 点之间,30%在晚上 9 点到 11 点之间。
-
一周中的日期:一周内均匀分布
有一天,他的信用卡被克隆了。欺诈者住在他的工作地附近,为了不被抓住,他们每天晚上约 10 点在一家同伙的小店里系统地进行 25 美元的小额支付。
如果我们只看单个维度,欺诈交易将只是略微偏离预期分布,但仍然可接受。金额和一周中的日期的分布效果将保持更多或更少相同,而位置和一天中的时间将稍微增加到米兰的晚上时间。
即使是系统地重复,他生活方式的微小变化也是一个合理的解释。欺诈行为很快就会变成新的预期行为,即正常状态。
让我们考虑联合分布:
-
约 70%的金额在米兰午餐时间约 10 美元左右,只在工作日
-
约 30%的金额在周末晚餐时间在罗马约 30 美元左右
在这种情况下,欺诈行为在第一次发生时会立即被标记为异常值,因为米兰夜间超过 20 美元的交易非常罕见。
给出前面的例子,我们可能会认为考虑更多维度可以使我们的异常检测更智能。就像任何其他机器学习算法一样,你需要在复杂性和泛化之间找到一个权衡。
如果维度过多,所有观察结果都会投射到一个空间中,其中所有观察结果彼此等距离。因此,一切都将成为“异常值”,按照我们定义异常值的方式,这本质上使整个数据集“正常”。换句话说,如果每个点看起来都一样,那么你就无法区分这两种情况。如果维度太少,模型将无法从草堆中发现异常值,可能会让它在大量分布中隐藏更长时间,甚至永远。
然而,仅识别异常值是不够的。异常值可能是由于罕见事件、数据收集中的错误或噪音引起的。数据总是肮脏的,充满了不一致性。第一条规则是“永远不要假设你的数据是干净和正确的”。找到异常值只是一个标准例程。更令人惊讶的是发现偶发且无法解释的重复行为:
"数据科学家意识到,他们最好的日子与发现数据中真正奇怪的特征的日子重合。"
《草堆与针》:异常检测,作者:Gerhard Pilcher & Kenny Darrell,数据挖掘分析师,Elder Research, Inc.
特定异常模式的持续存在是我们正在监控的系统中发生了变化的信号。真正的异常检测发生在观察到基础数据生成过程中的系统偏差时。
这也影响到数据预处理步骤。与许多机器学习问题相反,在异常检测中,你不能只过滤掉所有的异常值!尽管如此,你应该仔细区分它们的性质。你确实想要过滤掉错误的数据条目,删除噪声,并对剩余的数据进行归一化。最终,你想要在清理后的数据集中检测到新颖性。
异常检测的现实应用
异常情况可能发生在任何系统中。从技术上讲,你总是可以找到一个在系统历史数据中找不到的从未见过的事件。在某些情况下检测到这些观察结果的影响可能会产生巨大的影响(积极和消极)。
在执法领域,异常检测可以用于揭示犯罪活动(假设你在一个平均人足够诚实以便识别突出分布之外的罪犯的地区)。
在网络系统中,异常检测可以帮助发现外部入侵或用户的可疑活动,例如,一个意外或故意向公司内部网络以外泄露大量数据的员工。或者可能是黑客在非常用端口和/或协议上打开连接。在互联网安全的特定案例中,异常检测可以用于通过简单地观察非受信任域名上的访客激增来阻止新的恶意软件传播。即使网络安全不是你的核心业务,你也应该通过数据驱动的解决方案来保护你的网络,以便在出现未识别的活动时监控并提醒你。
另一个类似的例子是许多主要社交网络的身份验证系统。专门的安全团队已经开发出可以衡量每个单独活动或活动序列以及它们与其他用户的中位行为有多远的解决方案。每当算法标记一项活动为可疑时,系统将提示你进行额外的验证。这些技术可以大大减少身份盗窃,并提供更大的隐私保护。同样,相同的概念也可以应用于金融欺诈,正如我们在前面的例子中看到的那样。
由人类行为产生的异常是最受欢迎的应用之一,但也是最棘手的。这就像一场国际象棋比赛。一方面,你有专业领域的专家、数据科学家和工程师开发先进的检测系统。另一方面,你有黑客,他们了解这场比赛,研究对手的走法。这就是为什么这种系统需要大量的领域知识,并且应该设计成具有反应性和动态性的。
并非所有的异常都来自“坏人”。在营销中,异常可以代表孤立的,但高利润的客户,可以用定制的报价来定位他们。他们不同和特殊的兴趣和/或有利可图的个人资料可用于检测离群客户。例如,在经济衰退期间,找到一些潜在客户,尽管大趋势,他们的利润增长,这可能是适应你的产品和重新设计业务策略的一个想法。
其他应用包括医学诊断、硬件故障检测、预测性维护等。这些应用也需要灵活性。
商机,就像新的恶意软件一样,每天都可能出现,它们的生命周期可能非常短,从几小时到几周。如果你的系统反应慢,你可能会太晚,永远追不上你的竞争对手。
人工检测系统不能扩展,通常也遭受泛化的困扰。正常行为的偏差并不总是显而易见,分析师可能难以记住整个历史以进行比对,这是异常检测的核心要求。如果异常模式隐藏在数据中实体的抽象和非线性关系中,情况会变得复杂。需要智能和完全自动化的系统,能够学习复杂的互动关系,提供实时和准确的监控,是该领域创新的下一个前沿。
流行的浅层机器学习技术
异常检测并不新鲜,许多技术已经被广泛研究。建模可以分为两个阶段:数据建模和检测建模。
数据建模
数据建模通常包括将可用数据分组成我们希望检测的观察的粒度,以包含检测模型需要考虑的所有必要信息。
我们可以确定三种主要类型的数据建模技术:
点异常:这类似于单个异常值检测。我们数据集中的每一行对应一个独立的观察。目标是将每个观察分类为“正常”或“异常”,或者更好地提供一个数字异常得分。
上下文异常:每个点都附加有额外的上下文信息。一个典型的例子是在时间序列中查找异常,其中时间本身就代表了上下文。一月份冰淇淋销售的激增和七月份是不同的。上下文必须封装到额外的特征中。时间上下文可以是代表月份、季度、日期、星期几的分类日历变量,或布尔标志如是否 是 公共假期?
集体异常:代表潜在异常原因的观测模式。集体指标应该被智能地聚合成新的特征。一个例子是之前描述的欺诈检测示例。交易应该被分组到会话或间隔中,并且应该从序列中提取统计数据,比如付款金额的标准偏差、频率、两次连续交易之间的平均间隔、消费趋势等。
同样的问题可以用多种混合方法来解决,定义不同粒度的数据点。例如,可以独立地最初检测出个别异常交易,然后在时间上进行链接,封装时间上下文,并在分组序列上重复检测。
检测建模
无论数据类型如何,检测模型的通用输入由多维空间中的点(特征空间)组成。因此,通过一些特征工程,我们可以将任何异常表示转换为单个特征向量。
出于这个原因,我们可以将异常检测看作是离群值检测的特殊情况,其中单个数据点还包含了上下文和能够代表模式的任何其他信息。
与任何其他机器学习技术一样,我们既有监督学习方法,也有无监督学习方法。此外,我们还提出了半监督模式:
-
监督:以监督方式进行的异常检测也可以称为异常分类,例如垃圾邮件检测。在异常分类中,我们将每个观测标记为异常(垃圾邮件)或非异常(正常邮件),然后使用二元分类器将每个点分配到相应的类别。可以使用任何标准的机器学习算法,比如支持向量机、随机森林、逻辑回归,当然还有神经网络,尽管它不是本章的重点。
这种方法的主要问题之一是数据的倾斜度。根据定义,异常只占人口的一小部分。在训练阶段没有足够的反例将导致糟糕的结果。此外,一些异常可能以前从未见过,很难建立一个足够概括正确分类它们的模型。
-
无监督:纯粹的无监督方法意味着没有关于什么构成异常或不异常的基本事实(没有黄金标准)的历史信息。我们知道数据中可能存在异常,但没有关于它们的历史信息。
在这些场景中,检测也可以视为聚类问题,目标不仅是将相似的观测结果进行分组,还要识别所有其余的孤立点。因此,它带来了所有关于聚类问题的问题和考虑。数据建模和距离度量应该被谨慎选择,以便能够将每个点排列为靠近或远离现有的“正常行为”群集之一。
典型的算法是 k-means 或基于密度的聚类。聚类的主要困难在于对噪声的高度敏感和著名的维度灾难。
-
半监督:也被称为新颖性检测,半监督学习可能对你来说是一个新名词。它既可以被视为无监督学习(数据未标记),也可以被视为单类别监督学习(所有都在同一个标签下)。半监督的假设是训练数据集完全属于一个标签:"期望的行为"。我们不是学习用于预测“期望”还是“异常”的规则,而是学习用于预测观察到的点是否来自生成训练数据的相同源的规则。这是一个相当强的假设,也是使异常检测成为实践中最难解决的问题之一的原因。
流行的技术包括 SVM 单类别分类器和统计分布模型,例如多元高斯分布。
更多关于用于异常检测的多元高斯分布的信息可以在这个教程中找到:
dnene.bitbucket.org/docs/mlclass-notes/lecture16.html。下图显示了在二维空间中可视化的主分布中的异常值的经典识别:![检测建模]()
具有单个异常值的正态分布的二维表示(
dnene.bitbucket.org/docs/mlclass-notes/lecture16.html)
使用深度自编码器进行异常检测。
使用深度学习的提出方法是半监督的,并且在以下三个步骤中广泛解释:
-
确定代表正态分布的一组数据。在这种情况下,“正常”一词代表一组我们有信心主要代表非异常实体的点,并且不应与高斯正态分布混淆。
识别通常是历史性的,我们知道没有官方确认的异常。这就是为什么这种方法不是纯无监督的原因。它依赖于这样一个假设:大多数观察结果是没有异常的。我们可以使用外部信息(即使是可用的标签)来实现所选子集的更高质量。
-
从这个训练数据集中学习“正常”是什么意思。训练模型将在其数学定义中提供一种度量标准;也就是说,将每个点映射到代表与另一个代表正态分布的点之间距离的实数。
基于异常分数的阈值进行检测。通过选择合适的阈值,我们可以在精度(更少的虚警)和召回(更少的漏报)之间实现所需的折衷。
这种方法的优点之一是对噪声的鲁棒性。我们可以接受训练中正常数据中的一小部分异常值,因为该模型将试图概括群体的主要分布而不是单个观测值。这种特性在泛化方面给我们带来了巨大的优势,相对于监督方法而言,后者仅限于过去所能观察到的内容。
此外,这种方法也可以扩展到带标签的数据,使其适用于各种类别的异常检测问题。由于建模过程中不考虑标签信息,我们可以将其从特征空间中丢弃,并将所有内容视为同一标签下的。在验证阶段,标签仍然可以用作基本真相。然后,我们可以将异常分数视为二元分类分数,并使用 ROC 曲线及相关指标作为基准。
对于我们的用例,我们将利用自编码器架构来学习训练数据的分布。正如我们在 第四章 无监督特征学习 中所看到的,网络被设计为具有任意但对称的隐藏层,输入层和输出层中的神经元数量相同。整个拓扑结构必须对称,即左侧的编码拓扑与右侧的解码部分相同,并且它们都共享相同数量的隐藏单元和激活函数:

H2O 训练手册中的自编码器简单表示(https://github.com/h2oai/h2o-training-book/blob/master/hands-on_training/images/autoencoder.png)
通常使用的损失函数是输入与输出层中相应神经元之间的 MSE(均方误差)。通过这种方式,网络被迫通过原始数据的非线性和压缩表示来逼近一个恒等函数。
深度自编码器也经常用作监督学习模型的预训练步骤和降维。事实上,自编码器的中心层可以用于表示降维的点,正如我们将在最后一个示例中看到的那样。
然后,我们可以开始使用完全重构的表示进行分析,这是编码和解码级联的结果。恒等自编码器会完全重构原始点的相同值。这并不是非常有用的。实际上,自编码器基于中间表示进行重构,这些表示使训练误差最小化。因此,我们从训练集中学习这些压缩函数,使得正常点很可能被正确重构,但异常值的 重构误差(原始点与重构点之间的均方误差)会更高。
然后我们可以使用重构误差作为异常分数。
或者,我们可以使用一个技巧,将网络的中间层设置得足够小,以便我们可以将每个点转换为低维压缩表示。如果将其设置为二或三,甚至可以可视化这些点。因此,我们可以使用自动编码器来降低维度,然后使用标准机器学习技术进行检测。
H2O
在我们深入研究示例之前,让我们花一些时间来证明我们选择使用 H2O 作为异常检测的深度学习框架的决定是合理的。
H2O 不仅仅是一个要安装的库或软件包。它是一个开源、功能丰富的分析平台,提供了机器学习算法和高性能并行计算抽象。
H2O 核心技术是围绕着为内存处理分布式数据集进行优化的 Java 虚拟机构建的。
可以通过基于 Web 的 UI 或在许多语言中以编程方式使用,例如 Python、R、Java、Scala 和 JSON 中的 REST API。
数据可以从许多常见数据源加载,例如 HDFS、S3、大多数流行的 RDBMS 和少数其他 NoSQL 数据库。
加载后,数据以H2OFrame的形式表示,使得习惯于使用 R、Spark 和 Python pandas 数据框架的人感到熟悉。
后端可以在不同引擎之间切换。它可以在您的机器上本地运行,也可以部署在 Spark 或 Hadoop MapReduce 之上的集群中。
H2O 将自动处理内存占用,并优化大多数数据操作和模型学习的执行计划。
它提供了针对经过训练模型的数据点进行快速评分的功能;据宣传,它的运行时间为纳秒级。
除了传统的数据分析和机器学习算法外,它还提供了一些非常强大的深度学习模型的实现。
构建模型的一般 API 是通过H2OEstimator。可以使用专门的H2ODeepLearningEstimator类来构建前馈多层人工神经网络。
我们选择 H2O 用于异常检测的一个主要原因是它提供了一个内置类,非常适用于我们的用例,即H2OAutoEncoderEstimator。
正如您将在以下示例中看到的那样,构建一个自动编码器网络只需要指定几个参数,然后它将自动调整其余部分。
估算器的输出是一个模型,根据要解决的问题,可以是分类模型、回归、聚类,或在我们的情况下是自动编码器。
H2O 的深度学习并不是穷尽的,但它相当简单直接。它具有自动自适应权重初始化、自适应学习率、各种正则化技术、性能调整、网格搜索和交叉折叠验证等功能。我们将在第十章 构建生产就绪的入侵检测系统 中探讨这些高级功能。
我们也希望很快在框架中看到 RNN 和更高级的深度学习架构的实现。
H2O 的关键点是可伸缩性、可靠性和易用性。它非常适合关心生产方面的企业环境。其简单性和内置功能也使其非常适合研究任务和希望学习和尝试深度学习的好奇用户。
开始使用 H2O
本地模式下的 H2O 可以简单地使用 pip 安装为依赖项。请按照 www.h2o.ai/download/h2o/python 上的说明操作。
第一次初始化时将自动启动本地实例。
打开 Jupyter 笔记本并创建一个 h2o 实例:
import h2o
h2o.init()
要检查初始化是否成功,应该打印出类似于 "Checking whether there is an H2O instance running at http://localhost:54321. connected." 的内容。
你现在已经准备好导入数据并开始构建深度学习网络了。
示例
以下示例是如何应用自动编码器来识别异常的概念证明。本章不涉及特定调优和高级设计考虑。我们将默认一些文献中的结果,而不深入研究太多已经在前几章中涵盖的理论基础。
我们建议读者仔细阅读第四章 无监督特征学习 和有关自动编码器的相关部分。
我们将在示例中使用 Jupyter 笔记本。
或者,我们也可以使用 H2O Flow (http://www.h2o.ai/product/flow/),这是一个类似 Jupyter 的 H2O 笔记本样式的用户界面,但我们不想在整本书中使读者感到困惑。
我们还假设读者对 H2O 框架、pandas 和相关绘图库 (matplotlib 和 seaborn) 的工作原理有基本了解。
在代码中,我们经常将一个 H2OFrame 实例转换为 pandas.DataFrame,以便我们可以使用标准绘图库。这是可行的,因为我们的 H2OFrame 包含小数据;但在数据量大时不推荐使用。
MNIST 数字异常识别
这是一个用于基准测试异常检测模型的相当标准的示例。
我们在第三章中已经看到过这个数据集,深度学习基础。不过,在这种情况下,我们不是在预测每个图像代表的数字,而是判断图像代表的是一个清晰的手写数字还是一个丑陋的手写数字。目标是识别写得不好的数字图像。
实际上,在我们的示例中,我们将丢弃包含标签(数字)的响应列。我们对每个图像代表的数字不感兴趣,而是更关心这个数字的清晰程度。
我们将遵循 H2O 教程中提供的相同配置,位于github.com/h2oai/h2o-training-book/blob/master/hands-on_training/anomaly_detection.md。
我们将以标准的pandas和matplotlib导入开始:
%matplotlib inline
import pandas as pd
from matplotlib import cm
import matplotlib.pyplot as plt
import numpy as np
from pylab import rcParams
rcParams['figure.figsize'] = 20, 12
from six.moves import range
接下来,我们将从 H2O 存储库导入数据(这是原始数据集的改编版本,以便更轻松地解析和加载到 H2O 中):
train_with_label = h2o.import_file("http://h2o-public-test-data.s3.amazonaws.com/bigdata/laptop/mnist/train.csv.gz")
test_with_label = h2o.import_file("http://h2o-public-test-data.s3.amazonaws.com/bigdata/laptop/mnist/test.csv.gz")
加载的训练和测试数据集表示每行一个数字图像,并包含 784 列,表示 28 x 28 图像网格中每个像素的 0 到 255 的灰度值,最后一列用作标签(数字)。
我们将只使用前 784 个作为预测因子,而将标签保留在验证中:
predictors = list(range(0,784))
train = train_with_label[predictors]
test = test_with_label[predictors]
H2O 教程建议使用一个只有 20 个神经元的隐藏层的浅层模型,以双曲正切作为激活函数,并进行 100 个 epochs(对数据进行 100 次扫描)。
目标不是学习如何调整网络,而是理解异常检测方法背后的直觉和概念。我们需要理解的是编码器容量取决于隐藏神经元的数量。过大的容量会导致一个恒等函数模型,这不会学习任何有趣的结构。在我们的案例中,我们设置了一个较低的容量,从 784 个像素到 20 个节点。这样,我们将迫使模型学习如何通过只使用表示数据相关结构的少数特征最好地逼近恒等函数:
from h2o.estimators.deeplearning import H2OAutoEncoderEstimator
model = H2OAutoEncoderEstimator(activation="Tanh", hidden=[20], ignore_const_cols=False, epochs=1)
model.train(x=predictors,training_frame=train)
在我们训练自编码器模型之后,我们可以预测测试集中使用我们的新降维表示重构的数字,并根据重构错误对它们进行排序:
test_rec_error = model.anomaly(test)
让我们快速描述一下重构错误:
test_rec_error.describe()
我们会看到重构错误在 0.01 到 1.62 之间,平均值大约为 0.02,不是对称分布。
让我们绘制出所有测试点的重构错误的散点图:
test_rec_error_df = test_rec_error.as_data_frame()
test_rec_error_df['id'] = test_rec_error_df.index
test_rec_error_df.plot(kind='scatter', x='id', y='Reconstruction.MSE')

我们可以看到测试集仅包含一个明显的异常点,而其余的点落在[0.0,0.07]范围内。
让我们将包括标签在内的测试特征集与重构错误连接起来,并抓取异常点,并尝试使用自编码器模型重构它:
test_with_error = test_with_label.cbind(test_rec_error)
outlier = test_with_error[test_with_error['Reconstruction.MSE'] > 1.0][0, :]
outlier_recon = model.predict(outlier[predictors]).cbind(outlier['Reconstruction.MSE'])
我们需要定义一个辅助函数来绘制单个数字图像:
def plot_digit(digit, title):
df = digit.as_data_frame()
pixels = df[predictors].values.reshape((28, 28))
error = df['Reconstruction.MSE'][0]
fig = plt.figure()
plt.title(title)
plt.imshow(pixels, cmap='gray')
error_caption = 'MSE: {}'.format(round(error,2))
fig.text(.1,.1,error_caption)
plt.show()
并且绘制原始异常值和其重构版本:
plot_digit(outlier, 'outlier')
plot_digit(outlier_recon, 'outlier_recon')

重构版本非常嘈杂,尽管异常值似乎清晰地表示数字三。我们会发现它有一个使它与其他三个数字不同的特定细节。
让我们更仔细地观察剩余点的错误分布:
test_rec_error.as_data_frame().hist(bins=1000, range=[0.0, 0.07])

根据分布,我们可以将“中心钟”在 0.02 处分为“好”数字(在左边)和“坏”数字(在右边)。最右边的尾部(大于 0.05)可以被视为“丑陋”的数字或最异常的数字。
现在我们将从“好”子集中挑选一些数字三的数字,并与我们的异常值进行比较:
digits_of_3 = test_with_error[(test_with_error['C785'] == 3) & (test_with_error['Reconstruction.MSE'] < 0.02)]
为了可视化多个数字,我们需要将绘图工具扩展为一个绘制图像网格的函数:
def plot_multi_digits(digits, nx, ny, title):
df = digits[0:(nx * ny),:].as_data_frame()
images = [digit.reshape((28,28)) for digit in df[predictors].values]
errors = df['Reconstruction.MSE'].values
fig = plt.figure()
plt.title(title)
plt.xticks(np.array([]))
plt.yticks(np.array([]))
for x in range(nx):
for y in range(ny):
index = nx*y+x
ax = fig.add_subplot(ny, nx, index + 1)
ax.imshow(images[index], cmap='gray')
plt.xticks(np.array([]))
plt.yticks(np.array([]))
error_caption = '{} - MSE: {}'.format(index, round(errors[index],2))
ax.text(.1,.1,error_caption)
plt.show()
现在,我们可以绘制 36 个随机数字的原始值和重构值,排列在一个6(nx)乘以6(ny)的网格中:
plot_multi_digits(digits_of_3, 6, 6, "good digits of 3")
plot_multi_digits(model.predict(digits_of_3[predictors]).cbind(digits_of_3['Reconstruction.MSE']), 6, 6, "good reconstructed digits of 3")

原始的数字三的好数字

数字三的好数字的重构版本
乍一看,我们的异常值看起来与分类良好的图像并没有太大的不同。许多重构出来的图像看起来与它们的原始表示很相似。
如果我们仔细观察这些数字,我们会发现它们中没有一个数字具有几乎触及角落的底部左侧形状。
让我们选择索引为 1 的数字,得分为 0.02,并复制异常值图像的底部左侧部分(最后的 16 x 10 像素)。我们将重新计算修改后图像的异常分数:
good_digit_of_3 = digits_of_3[1, :]
bottom_left_area = [(y * 28 + x) for y in range(11,28) for x in range (0, 11)]
good_digit_of_3[bottom_left_area] = outlier[bottom_left_area]
good_digit_of_3['Reconstruction.MSE'] = model.anomaly(good_digit_of_3)
plot_digit(good_digit_of_3, 'good digit of 3 with copied bottom left from outlier')

神奇的是,均方误差上升到了 0.86。高异常值分数(~1.62)的剩余贡献可能是由异常的书写风格解释的。
这个解释意味着模型对噪音过于敏感。它会因为训练数据不包含足够的样本,而将一个数字图像标记为异常,仅仅是因为它具有合法的特性。这就是异常值检测器的“异常值”,一个错误的正例示例。
一般情况下,可以使用去噪自动编码器来解决这个问题。为了发现更健壮的表示,我们可以训练模型从它的嘈杂版本中重构原始输入。我们可以在第四章,无监督特征学习 中找到更多理论解释。
在我们的用例中,我们可以使用二项式抽样掩盖每个数字,在这个过程中,我们以概率 p 随机将像素设为 0。损失函数将是从嘈杂版本和原始版本的重构图像的误差。在撰写本文时,H2O 没有提供这个功能,也没有损失函数的定制。因此,为了这个例子而实现它将会太过复杂。
我们的数据集包含数字的标签,但不幸的是,它没有关于它们质量的任何评估。我们将不得不进行手动检查,以确保我们的模型运行良好。
我们将抓取底部的 100 个(好的)和顶部的 100 个(丑的)点,并将它们可视化成一个 10 x 10 的网格:
sorted_test_with_error_df = test_with_error.as_data_frame().sort_values(by='Reconstruction.MSE')
test_good = sorted_test_with_error_df[:100]
plot_multi_digits(test_good, 10, 10, "good digits")

最佳数字的重构误差
test_ugly = sorted_test_with_error_df.tail(100)
plot_multi_digits(test_ugly, 10, 10, "ugly digits")

最糟糕的丑数字的重构误差
从图中很容易看出,“好的”代表数字 1,这是最容易写的数字,因为它的简单结构是一条直线。因此,数字 1 的数字不太容易写错。
底部的组别显然很丑陋。圆形的形状使得在类似数字之间更难区分,并且它非常依赖于特定人的手写风格。因此,它们很可能代表“异常”。它们很可能偏离大多数人口的书写风格。
请注意,不同的运行可能会因为引入了用于可扩展性的随机性而导致不同的结果,这是由于 Hogwild!算法在以下章节中解释的竞争条件引起的。为了使结果可重复,您应该指定一个 seed 并设置 reproducibility=True。
心电图脉冲检测
在第二个例子中,我们将从 H2O 专门为异常检测用例准备的心电图时间序列数据中获取一份快照。
准备好的数据可从 H2O 公共存储库获取。原始数据集由www.physionet.org/提供。其他参考资料可在www.cs.ucr.edu/~eamonn/discords/找到。
准备好的数据集包含 20 个正常心跳的心电图时间序列加上三个异常心跳。
每一行有 210 列,表示有序序列中的值样本。
首先,我们加载心电图数据并生成训练集和测试集:
ecg_data = h2o.import_file("http://h2o-public-test-data.s3.amazonaws.com/smalldata/anomaly/ecg_discord_test.csv")
train_ecg = ecg_data[:20:, :]
test_ecg = ecg_data[:23, :]
让我们定义一个函数,堆叠并绘制时间序列:
def plot_stacked_time_series(df, title):
stacked = df.stack()
stacked = stacked.reset_index()
total = [data[0].values for name, data in stacked.groupby('level_0')]
pd.DataFrame({idx:pos for idx, pos in enumerate(total)}, index=data['level_1']).plot(title=title)
plt.legend(bbox_to_anchor=(1.05, 1))
然后绘制数据集:
plot_stacked_time_series(ecg_data.as_data_frame(), "ECG data set")

我们可以清楚地看到前 20 个时间序列是正常的,而最后三个(标记为 21、22 和 23)与其他时间序列非常不同。
因此,我们只想对前 20 个样本训练模型。这一次,我们将使用由 50 个、20 个和 20 个、50 个边缘和两个神经元组成的五个隐藏层的更深层架构。请记住,自编码器的拓扑结构总是对称的,并且通常随着层大小的减小。其思想是学会如何将原始数据编码到一个较低维度的空间中,最小化信息的丢失,然后能够从这种压缩表示中重建原始值。
这一次,我们将为可再现性固定种子的值:
from h2o.estimators.deeplearning import H2OAutoEncoderEstimator
seed = 1
model = H2OAutoEncoderEstimator(
activation="Tanh",
hidden=[50,20, 2, 20, 50],
epochs=100,
seed=seed,
reproducible=True)
model.train(
x=train_ecg.names,
training_frame=train_ecg
)
我们可以如下绘制重构信号:
plot_stacked_time_series(model.predict(ecg
_data).as_data_frame(), "Reconstructed test set")

重构信号看起来都非常相似。异常点(20、21 和 23)现在无法区分,这意味着它们将具有更高的重构误差。
让我们计算并绘制重构误差:
recon_error = model.anomaly(test_ecg)
plt.figure()
df = recon_error.as_data_frame(True)
df["sample_index"] = df.index
df.plot(kind="scatter", x="sample_index", y="Reconstruction.MSE", title = "reconstruction error")

很容易将最后的三个点识别为异常点。
现在让我们尝试从不同的角度看问题。通过将中心层大小设置为二,我们可以使用编码器输出来压缩和可视化我们的点在二维图中。我们将使用训练模型的deepfeatures API 来绘制一个新的数据框,其中包含指定隐藏层索引的二维表示(从 0 开始,中间的索引为 2):
from matplotlib import cm
def plot_bidimensional(model, test, recon_error, layer, title):
bidimensional_data = model.deepfeatures(test, layer).cbind(recon_error).as_data_frame()
cmap = cm.get_cmap('Spectral')
fig, ax = plt.subplots()
bidimensional_data.plot(kind='scatter',
x= 'DF.L{}.C1'.format(layer+1),
y= 'DF.L{}.C2'.format(layer+1),
s = 500,
c = 'Reconstruction.MSE',
title = title,
ax = ax,
colormap=cmap)
layer_column = 'DF.L{}.C'.format(layer + 1)
columns = [layer_column + '1', layer_column + '2']
for k, v in bidimensional_data[columns].iterrows():
ax.annotate(k, v, size=20, verticalalignment='bottom', horizontalalignment='left')
fig.canvas.draw()
然后我们使用先前训练的种子为 1 的模型来可视化所有点:
plot_bidimensional(model, test_ecg, recon_error, 2, "2D representation of data points seed {}".format(seed))
如果我们通过将种子设置为 2、3、4、5 和 6 重新训练模型重复相同的程序,我们可以得到如下结果:

如你所见,每个种子给出了完全不同的二维表示。更有趣的是异常点(标记为 20、21 和 22)始终具有相同的重构误差(由它们的颜色给出)。对于模型来说,这些都是有效的二维压缩表示,其中包含相同数量的信息,并且可以解码为原始时间序列。
然后我们可以使用自编码器来降低维度,然后使用无监督方法(例如基于密度的聚类)来将相似点分组。通过对每个种子重复聚类,我们可以应用一致性聚类来确定哪些点最大程度上相互一致(总是被聚类在一起的点)。这种方法不一定告诉你异常在哪里,但它将帮助你了解数据并发现可以进一步调查的小维度聚类。越小且与其他聚类相隔越远,异常得分越高。
总结
异常检测是许多应用中常见的问题。
在本章的开始,我们描述了一些可能的用例,并根据上下文和应用需求突出了主要类型和区别。
我们简要介绍了使用浅层机器学习算法解决异常检测的一些流行技术。主要差异在于特征生成的方式。在浅层机器学习中,这通常是一个手动任务,也称为特征工程。使用深度学习的优势在于它可以以无监督的方式自动学习智能数据表示。良好的数据表示可以极大帮助检测模型发现异常。
我们概述了 H2O 并总结了其用于深度学习的功能,特别是自编码器。
我们实施了一些概念验证示例,以学习如何应用自编码器来解决异常检测问题。
对于数字识别,我们根据模型重构误差给出的异常分数对每个图像进行了排序。
类似的方法还可以进一步扩展到应用程序,如签名验证、手稿的作者手写识别或通过图像照片进行故障检测。
数字识别示例是一种单点异常检测。它使用了仅有一个隐藏层的浅层架构。
对于心电图(ECG)示例,我们使用了更深的架构,并展示了一种基于压缩特征表示而不是完全重构的附加检测技术。我们使用网络的编码器部分将原始数据的非线性关系压缩成更小的维度空间。然后可以将新的表示用作预处理步骤,以应用常规的异常检测算法,如高斯多元分布。通过减少到二维空间,甚至可以可视化数据点并识别主椭圆分布边界上的异常。
尽管如此,自编码器并不是使用深度学习进行异常检测的唯一方法。您也可以采用监督方法,从数据中剔除部分信息,并尝试根据剩余信息进行估计。预测值将代表您的正常预期行为,与该值偏离的部分将代表异常。例如,在时间序列的情况下,您可以使用循环神经网络(RNN)或其在长短期记忆(LSTM)中的演变作为回归模型,以预测时间序列的下一个数值,然后使用预测值和观察值之间的误差作为异常得分。
我们更倾向于专注于这种半监督方法,因为它可以应用于许多应用程序,并且还因为它在 H2O 中得到了很好的实现。
另一个重要的细节是,大部分代码片段是用于数据分析、操作和可视化的。通过使用 H2O,我们可以仅用几行代码就实现深度神经网络。与其他框架相比,这相当令人印象深刻。此外,H2O 的估计器和模型提供了各种可自定义的参数和不同的配置。另一方面,我们发现 H2O 在扩展其用途到目前不支持的范围方面相当有限。总的来说,这是一项非常有前景的技术,还有很大的改进空间。
请注意,本章涵盖的技术仅作为深度学习如何应用于异常检测的概念验证。在处理生产数据时,有许多技术和实际方面的注意事项和陷阱需要考虑。我们将在第十章, 构建一个生产就绪的入侵检测系统中涵盖其中的一些。
第十章:构建生产就绪的入侵检测系统
在前一章中,我们详细解释了异常检测是什么以及如何使用自动编码器来实现它。我们提出了一种半监督方法来进行新颖性检测。我们介绍了 H2O,并展示了一些在该框架之上实现的例子(MNIST 数字识别和 ECG 脉冲信号),这些例子在本地模式下运行。这些示例使用了一个已经清理和准备好用作概念验证的小型数据集。
现实世界的数据和企业环境工作方式大不相同。在本章中,我们将利用 H2O 和一般的常见做法来构建一个可扩展的分布式系统,准备好在生产环境中部署。
我们将以入侵检测系统为例,旨在检测网络环境中的入侵和攻击。
我们将提出一些实际和技术问题,这些问题在构建用于入侵检测的数据产品时可能会遇到。
特别是,你将学到:
-
数据产品是什么
-
如何更好地初始化深度网络的权重
-
如何使用 HOGWILD! 并行化多线程随机梯度下降算法
-
如何利用 Apache Spark 和 Sparkling Water 在 Map/Reduce 上分布计算
-
调整可扩展性和实现参数的一些经验法则
-
自适应学习的全面技术列表
-
如何在有和无地面真实情况下进行验证
-
如何在精度和减少误报之间选择正确的权衡
-
考虑技术和业务方面的详尽评估框架的一个例子
-
模型超参数和调整技术的摘要
-
如何将训练好的模型导出为 POJO 并部署在异常检测 API 中
什么是数据产品?
数据科学的最终目标是通过采用数据密集型解决方案来解决问题。重点不仅在于回答问题,而且在于满足业务需求。
仅构建数据驱动的解决方案是不够的。如今,任何应用程序或网站都由数据驱动。构建一个用于列出待售物品的 Web 平台确实会使用数据,但不一定是一个数据产品。
Mike Loukides 给出了一个很好的定义:
数据应用程序从数据本身获取其价值,并因此生成更多数据;它不仅仅是一个带有数据的应用程序;它是一个数据产品。数据科学使得能够创建数据产品。
来源于《什么是数据科学》(
www.oreilly.com/ideas/what-is-data-science)
基本要求是系统能够从数据中提取价值——而不仅仅是消耗它——并生成知识(以数据或见解的形式)作为输出。数据产品是能够从原始数据中提取信息、建立知识并有效地消耗它以解决特定问题的自动化。
在异常检测章节中的两个示例定义了数据产品的概念。我们打开了一个笔记本,加载了一份数据快照,开始分析和尝试深度学习,并最终产生了一些证明我们可以应用自编码器来检测异常的图表。尽管整个分析是可重复的,在最好的情况下,我们可能已经建立了一个概念验证或玩具模型。这对解决现实世界的问题合适吗?这对你的业务来说是一个最小可行产品(MVP)吗?可能不是。
机器学习、统计学和数据分析技术并不是新事物。数学统计学的起源可以追溯到 17 世纪;机器学习是人工智能(AI)的一个子集,这是由艾伦·图灵在 1950 年通过他的Turing Test证明的。你可能会认为数据革命始于数据收集的增加和技术的进步。我认为这正是使数据革命能够顺利进行的原因。真正的转变可能发生在公司开始意识到他们可以通过信任他们的数据来创建新产品、提供更好的服务,并显著改进他们的决策。然而,创新不在于手动地在数据中寻找答案;而是在于整合从数据驱动系统中生成的信息流,这些信息流可以提取并提供能够推动人类行动的见解。
数据产品是科学和技术交汇的结果,旨在生成人工智能,能够在我们的代表进行规模化和不偏颇的决策。
因为数据产品通过消耗更多的数据而变得更好,而且它本身也会生成数据,所以生成效应理论上可以建立一个无限的信息流。因此,数据产品必须也是自适应的,并能在收集到新观测数据时逐步融合新知识。统计模型只是最终数据产品的一个组成部分。例如,在异常检测后的入侵检测系统会反馈一堆可用于后续模型训练的标记数据。
然而,数据分析在每个组织中也非常重要。在组织中经常会找到数据科学家和分析师混合团队。手动监督、检查和可视化中间结果对于构建成功的解决方案是必不可少的要求。我们的目标是消除有限产品的人工干预。换句话说,开发阶段涉及大量的探索性分析和手动检查点,但最终的交付通常是端到端的管道(或一堆独立的微服务),它以数据作为输入并产生数据作为输出。整个工作流最好是自动化、经过测试且可扩展的。理想情况下,我们希望在企业系统中集成实时预测,以便对每次检测做出反应。
例如,工厂中的一个大屏幕显示实时测量数据,来自活动机器,可以在出现问题时发出警报。这些数据产品不会替你修复机器,但会成为人类干预的支持工具。
人类互动通常应该是:
-
领域专业知识通过从经验中设置先验来
-
开发与测试
-
产品的最终消费
在我们的入侵检测系统中,我们将利用数据为安全分析团队推荐行动,以便他们能够优先考虑并做出更好的决策。
训练
训练网络意味着已经设计好了网络的拓扑结构。为此,我们建议参考第四章中的相应自编码器部分,对输入数据的类型和预期用例进行设计指南。
一旦我们定义了神经网络的拓扑结构,我们就处于起点了。模型现在需要在训练阶段进行拟合。我们将介绍一些适合于具有大型数据集的生产环境的训练算法的学习加速和扩展技术。
权重初始化
神经网络的最终收敛性可以受到初始权重的强烈影响。根据我们选择的激活函数,我们希望在最初的迭代中具有陡峭的斜率,以便梯度下降算法可以快速跳入最佳区域。
对于第一层(直接连接到输入层)的隐藏单元j,维度为d的训练样本x在第一次迭代的值之和为:

这里,w[*0,i*]是第i维的初始权重。
由于我们选择的权重是独立同分布的(i.i.d.),并且也独立于输入,单元j的均值为:

如果输入值x[i]被归一化为µ[x]=0和标准差s[x]=1,则均值为E(h[j]),方差为:

隐藏单元j的输出将通过其激活函数转换为:

这里的b是偏置项,可以简单地初始化为 0 或非常接近 0 的值,例如在 ReLU 激活函数的情况下为 0.01。
在 sigmoid 函数的情况下,对于大值(正负),我们得到非常平坦的曲线。为了获得较大的梯度,我们希望处于[-4,+4]范围内。
如果我们从均匀分布
中抽取初始权重,则单元j的方差变为:

h[j]落在[-4,+4]之外的概率非常小。我们有效地减少了过早饱和的概率,无论d的大小如何。
将初始权重分配为输入层节点数d的函数的技术称为均匀自适应初始化。H2O 默认应用均匀自适应选项,通常比固定均匀或正态分布更好。
如果我们只有一个隐藏层,只需初始化第一层的权重即可。在深度自动编码器的情况下,我们可以预先训练一堆单层自动编码器。也就是说,我们创建一堆浅自动编码器,其中第一个重建输入层,第二个重建第一个隐藏层的潜在状态,依此类推。
让我们使用标签L[i]来标识第i层,其中L[0]是输入层,最后一个是最终输出,其他所有层都是隐藏层。
例如,一个 5 层网络
可以拆分为 2 个网络
和
。
第一个自动编码器,在训练后,将初始化L[1]的权重,并将输入数据转换为L[1]的潜在状态。这些状态用于训练第二个自动编码器,后者将用于初始化L[2]的权重。
解码层共享编码对应的初始权重和偏置。因此,我们只需要预训练网络的左半部分。
很可能,一个 7 层网络
可以拆分为
、
和
。
一般来说,如果深度自动编码器有 N 层,我们可以将其视为一堆
堆叠的单层自动编码器:

预训练后,我们可以一起训练整个网络,使用指定的权重。
使用 HOGWILD!的并行 SGD
正如我们在前几章中所看到的,深度神经网络是通过反向传播给定损失函数产生的错误来进行训练的。反向传播提供了模型参数(每一层的权重 W 和偏差 B)的梯度。一旦我们计算出梯度,我们可以使用它来沿着最小化错误的方向移动。其中最流行的技术之一是随机梯度下降(SGD)。
SGD 可以总结如下。
-
初始化
W,B。 -
在收敛前:
-
获取训练样本
i -
对于任何
的W -
对于任何
的B
-
这里W是权重矩阵,B是偏置向量,
是通过反向传播计算的梯度,a是学习率。
尽管 SGD 是许多机器学习模型最流行的训练算法,但它并不是高效的可并行化的。文献中提出了许多并行化版本,但大多数都受到处理器之间同步和内存锁限制的困扰,没有利用参数更新的稀疏性,这是神经网络的常见特性。
在大多数神经网络问题中,更新步骤通常是稀疏的。对于每个训练输入,只有少数与错误反应的神经元相关的权重被更新。一般来说,神经网络被构建成每个神经元只有在输入中存在特定特征时才激活。事实上,每次输入都激活的神经元并不是很有用。
HOGWILD! 是一种替代算法,允许每个线程覆盖其他线程的工作,并提供更好的性能。使用 HOGWILD!,多个核心可以异步处理训练数据的不同子集,并独立地对梯度更新做出贡献
如果我们把数据的维度 d 分成小的子集 E,然后
是由E的坐标索引的向量x的部分,我们可以把整个成本函数 L 分解为:

我们利用的关键属性是成本函数在某种意义上是稀疏的,即
,而d可能很大,但是L[e]只在输入向量(
)的较小部分上计算。
如果我们有p个处理器,共享相同的内存,且都能访问向量x,则组件更新是原子的,因为具有加法性质:

这意味着我们可以更新单个单元的状态而无需单独的锁定结构。更新多个组件的情况则不同,在这种情况下,每个处理器都会异步重复以下循环:
在E中均匀随机采样e。
读取当前状态
并评估
。
对于
执行
。
这里
是梯度
乘以
。b[v]是一个位掩码向量,其中 1 对应于e的选定索引,?是步长,每个时期末会缩小一个因子ß。
因为梯度计算不是瞬时的,任何处理器可能随时修改x,我们可能会使用在许多时钟周期之前读取的旧值计算梯度来更新x。HOGWILD 的新颖之处在于提供了一种异步的、增量的梯度算法在其中收敛的条件。
特别是,已经证明梯度计算和使用之间的延迟始终小于或等于最大值$t$。$t$的上界值取决于处理器的数量,并且当我们接近算法的标准串行版本时,$t$收敛于 0。如果处理器的数量小于d^(1/4),那么我们获得的梯度步数几乎与串行版本相同,这意味着我们在处理器数量方面实现了线性加速。此外,输入数据越稀疏,处理器之间的内存争用可能性就越小。
在最坏的情况下,即使梯度计算具有计算密集性,该算法也始终可以提供一些速度改进。
你可以在原始论文中找到更多细节:people.eecs.berkeley.edu/~brecht/papers/hogwildTR.pdf。
总之,有许多优化学习速度、稳定性和陷入局部最优的概率的技术。非自适应学习率与动量结合可能会产生最好的结果,但这将需要调整更多的参数。Adadelta 是复杂性和性能之间的权衡,因为它只需要两个参数(ρ和ϵ),并且能够适应不同的场景。
自适应学习
在前面的段落中,我们已经看到了权重初始化的重要性和 SGD 算法的概述,其基本版本使用固定值的学习率 a。它们都是保证快速和准确收敛的重要条件。
可以采用一些先进的技术来动态优化学习算法。特别是,我们可以划分为两种类型的技术:一种旨在在任何方便的地方加快学习,另一种在接近局部最小值时减慢学习。
如果θ[t]表示我们在迭代t(权重和偏差参数)更新的数量,则一般 SGD 算法的更新如下:


学习率退火
我们需要选择α。学习率较低将需要很多迭代才能收敛,并且有搁置在局部最小值的风险。具有较高学习率会导致不稳定性。如果算法包含太多动能,那么最小化θ的步骤会导致其在周围跳来跳去。
学习率退火在训练期间消耗数据点时,会将α[t]缓慢降低。一种技术是在每k个样本更新一次
:

因此,衰减率将对应于需要将学习率减半所需的训练样本数的倒数。
动量
动量考虑了前几次迭代的结果来影响当前迭代的学习。引入并定义一个新的速度向量v,如下所示:

这里µ是动量衰减系数。我们不再使用梯度来改变位置,而是使用梯度来改变速度。动量项负责加快学习,在梯度继续指向同一方向的维度上,减慢那些梯度符号交替的维度,也就是那些对应于局部最优解区域的区域。
这个额外的动量项将有助于更快地收敛。不过过多的动量可能会导致发散。假设我们运行带动量的 SGD 足够的 epochs,最终速度最终将是:

如果µ小于 1,则这是一个几何级数;那么极限将收敛到与以下成比例的某物:

在这个公式中,当µ接近 1 时,系统会移动得太快。
此外,在学习初期,可能已经存在大梯度(权重初始化的影响)。因此,我们希望以一个小的动量开始(例如 0.5); 一旦大梯度消失,我们可以增加动量,直到它达到最终稳定值(例如 0.9),并保持恒定。
Nesterov 的加速
标准动量计算当前位置的梯度,并放大累积梯度方向的步骤。就像把球推下山并盲目地跟随山坡斜率一样。由于我们可以近似地预测球会落在哪里,所以我们希望在计算梯度时考虑这个信息。
让我们记住时间t处参数θ的值是:

如果我们省略二阶导数,?t 的梯度可以近似为:

更新步骤将使用时间t处的梯度而不是t – 1处的梯度计算:

Nesterov 变化首先会朝着先前累积梯度的方向迈出一大步,然后再根据跳跃后计算的梯度进行校正。这种校正防止了它过快地行进并提高了稳定性。
在球滚下山的类比中,Nesterov 校正根据山坡调整速度,并且仅在可能的情况下加速。
牛顿方法
而单阶方法只使用梯度和函数评估来最小化L,二阶方法也可以使用曲率。在牛顿方法中,我们计算损失函数L(θ)的二阶偏导数的 Hessian 矩阵HL(θ) 。逆 Hessian 将定义 a 的值,最终步骤方程为:

这里使用对角线的绝对值来确保负梯度方向最小化L。参数?用于平滑具有小曲率的区域。
通过使用二阶导数,我们可以在更有效的方向上执行更新。特别是,在平缓(平坦)曲率上我们会有更激进的更新,而在陡峭的曲率上会有更小的步长。
该方法的最佳属性是它没有超参数,除了平滑参数被固定为一个小值;因此它是一个维度较少的调整。主要问题在于计算和内存成本。H的大小是神经网络大小的平方。
已经开发了许多拟牛顿方法来近似逆 Hessian。例如,L-BFGS(Limited Memory Broyden-Fletcher-Goldfarb-Shanno)只存储几个向量,这些向量隐含地表示近似和所有先前向量的最后更新的历史。由于 Hessian 是从以前的梯度评估中近似构建的,因此在优化过程中不改变目标函数非常重要。此外,朴素实现需要在单个步骤中计算完整数据集,并且不太适合小批量训练。
Adagrad
Adagrad是 SGD 的另一种优化,根据先前所有计算梯度的 L2 范数每个维度进行学习率的调整。
α的值取决于时间t和第i个参数θ[t,i]:

这里G[t]是一个d x d大小的对角矩阵,元素i, i是θ[k,i]的梯度平方和直到迭代t – 1:

每个维度的学习率与梯度成反比。也就是说,较大的梯度将具有较小的学习率,反之亦然。
参数ϵ是一个平滑项,有助于避免除以零。它通常在 1e-4 和 1e-10 之间波动。
向量化更新步骤由按元素矩阵-向量乘法给出:

全局学习率a在分子上可以设置为默认值(例如 0.01),因为算法会在几次迭代后自动适应它。
现在我们已经获得了速率退火的相同衰减效果,但具有良好的性质,即每个维度随着时间的推移均匀化,就像二阶优化方法一样。
Adadelta
Adagrad 的一个问题是非常敏感于初始状态。如果初始梯度很大,并且我们希望它们像权重初始化中描述的那样很大,那么相应的学习率将从训练开始就非常小。因此,我们必须通过设置a的高值来抵消这种效应。
Adagrad 的另一个问题是分母一直在积累梯度,并在每次迭代中增长。这使得学习率最终变得无限小,以至于算法不能再从剩余的训练数据中学到任何新东西。
Adadelta 旨在通过将累积的过去梯度数量固定为某个W值,而不是t-1来解决后一个问题。它不是存储w个先前的值,而是在时间t上以递减的方式执行正在运行的平均值。我们可以用过去梯度的递减平均值替换对角矩阵G[t]:

这里的ρ是衰减常数,通常在 0.9 和 0.999 之间波动。
我们真正需要的是
的平方根,它近似了时间t下
的均方根(RMS):

更新步骤将是:

我们已经定义了Δ,即每次迭代时要添加到参数向量中的更新步骤。为了使这些方程正确,我们必须确保单位匹配。如果我们想象参数有一些假设的单位,Δ应具有相同的单位。到目前为止考虑的所有一阶方法都将Δ的单位与参数的梯度相关联,并假设成本函数L是无量纲的:

相比之下,牛顿法等二阶方法使用 Hessian 信息,或其近似值,来获取正确的更新步骤单位?:

对于
方程,我们需要用某个与t的 RMS 成比例的量替换项a。
由于我们目前不知道?(t),所以我们只能计算相同大小的窗口w上t – 1 的均方根值:

其中使用相同的常数 ?,其目的是在 ?(0) = 0 时启动第一次迭代,并确保即使由于累积梯度在分母上饱和效应导致之前的更新很小,也能保持进展。
如果曲率足够平滑,我们可以近似
,这将改变 Adadelta 的方程为:

最终的 Adadelta 方程覆盖了讨论的许多方法的特性:
-
它是对对角 Hessian 的近似,但只使用 ?L 和 ? 的 RMS 度量,并且每次迭代只进行一次梯度计算。
-
它始终遵循负梯度,就像普通的 SGD 一样。
-
分子滞后于分母 1 次。这使得学习对突然出现的大梯度更加稳健,在分子能够反应之前,它会增加分母并降低学习率。
-
分子起到了加速项的作用,就像动量法一样。
-
分母的作用类似于 Adagrad 中的每个维度衰减,但是通过固定的窗口保证了在任何步骤中,每个维度总是取得进展。
通过 Map/Reduce 进行分布式学习
将训练并行化在多个并发线程中是一个很大的改进,但它受到单台机器中可用核心和内存的数量的约束。换句话说,我们只能通过购买更多资源丰富和更昂贵的机器来实现垂直扩展。
结合并行和分布式计算可以实现所需的水平可扩展性,只要我们有增加额外节点的能力,理论上是无限的。
我们选择 H2O 作为异常检测框架的两个原因是它提供了一个易于使用的内置自动编码器实现,以及它在功能(我们想要实现的内容)和实现(我们如何实现它)之间提供了一个抽象层。这个抽象层提供了透明和可扩展的实现,允许以 map/reduce 的方式进行计算和数据处理的分布。
如果我们的数据在每个节点上均匀分区在较小的分片中,我们可以将高级分布式算法描述如下:
-
初始化:提供具有权重和偏置的初始模型。
-
洗牌:数据可以完全在每个节点上可用,也可以进行引导采样。我们将在段落末尾解决这个数据复制问题。
-
映射:每个节点将通过使用 HOGWILD!中的异步线程基于本地数据进行模型训练。
-
减少:每个训练模型的权重和偏置被平均到最终模型中。这是一个蒙德运算和可交换操作;平均是可结合和可交换的。
-
验证(可选):当前的平均模型可以针对验证集进行评分,以进行监控、模型选择和/或提前停止准则。
-
迭代:在满足收敛标准之前多次重复整个工作流程。
![通过 Map/Reduce 进行分布式学习]()
H2O 深度学习架构
复杂度时间将会是每次迭代 o(n/p + log(p)),其中 n 是每个节点中数据点的数量,p 是处理器的数量(节点)。线性项是映射计算,对数项是减少计算。
在前述公式中,我们没有考虑内存占用和数据洗牌的昂贵性。我们可以忽略减少步骤中模型平均的复杂性,因为我们假设模型参数相对于数据大小足够小。特别是,模型的大小是网络的神经元数量加上隐藏层的数量(偏置项)对应的参数数量。假设你有一百万个神经元,模型的总大小将小于 8MB。
最终的可扩展性将取决于:
-
计算并行性
-
内存缓冲
-
网络流量和 I/O
我们的目标是在模型精度和训练速度之间找到合适的权衡。
我们将使用术语迭代来表示仅在指定数量的train_samples_per_iteration上训练的单个 Map/Reduce 步骤。参数epochs将定义完成训练所需的数据通行证数量。
train_samples_per_iteration参数可以对应整个数据集,也可以更小(无替换的随机采样),甚至更大(有替换的随机采样)。
train_samples_per_iteration的值将影响内存占用和模型平均时间,也就是训练速度。
另一个重要的参数是布尔标志replicate_training_data。如果启用,整个数据的副本将在每个节点上可用。这个选项将允许每个模型训练得更快。
另一个关联参数是shuffle_trainingd_data,它决定数据是否可以在节点之间进行洗牌。
如果 N 是可用节点的数量,n 是训练数据集的大小,我们可以通过train_samples_per_iteration的特殊值和replicate_training_data的激活来识别一些特定的操作模式:
train_samples_per_iteration |
replicate_training_data |
描述 |
|---|---|---|
| 0 | False | 只进行一个 epoch,在本地数据平均构建 N 个模型。 |
| -1 | True | 每个节点每次迭代处理整个数据集。这导致 N 个节点中的每个并行训练 N 个 epoch。 |
| -1 | False | 所有节点只处理本地存储的数据。一个 epoch 对应一个迭代。你可以有很多 epochs。 |
| -2 | True | 根据计算时间和网络开销的自动调整迭代次数。完整数据集被复制,进行无替换采样。 |
| -2 | False | 基于计算时间和网络开销自动调整每次迭代的样本数。只有本地数据可用;可能需要有放回地进行采样。 |
| > 0 | true | 从完整数据集中抽样的每次迭代的固定样本数量。 |
| > 0 | false | 从只有本地可用数据中抽样的每次迭代的固定样本数量。 |
如果n=1M且N=4,每个节点平均将存储 25K 个本地数据。如果我们设置samples_per_iteration=200K,单个 Map/Reduce 迭代将处理 20 万条记录。也就是说,每个节点将处理 5 万行。为了完成一个 epoch,我们需要 5 个 Map/Reduce 迭代对应 20 个本地训练步骤。
在前面的例子中,每个节点都将从本地可用数据中获取这 50K 个样本,根据本地数据量与请求的数据量的大小,可以有或没有对数据进行采样。采用有放回抽样可能会对模型的准确性产生负面影响,因为我们将在数据的重复和有限的子集上进行训练。如果我们启用复制,我们在每个节点上始终具有最多的本地数据,假设可以放入内存。
当我们想要精确处理本地数据量而不对数据进行采样(train_samples_per_iteration = -1)时,也是一个特殊情况。在这种情况下,在每次迭代中,我们将反复迭代相同的数据集,这对于多次迭代来说是多余的。
另一个特殊情况是当samples_per_iteration接近或大于启用复制的 N * n。在这种情况下,每个节点在每次迭代中将几乎使用整个数据集或更多进行训练。同样,在每次迭代中几乎使用相同的数据。
对于这两种特殊情况,shuffle_training_data会自动开启。也就是说,在每次训练之前本地数据将被随机混洗。
总之,根据数据大小的不同,我们可能会在每个节点上复制或不复制数据。H2O 提供了一种智能的方式,通过平衡 CPU 成本和网络开销自动调整和适应每次迭代的大小。除非你对系统进行微调有特殊要求,你可能会想使用自动调整选项。
深度学习的分布式算法将在准确性和训练速度上使你的最终模型受益。即使你可能没有一个非常大的数据集,这种分布式方法也是你考虑用于生产系统的东西。
Sparkling Water
尽管 H2O 可以在自己的独立集群上运行,但企业环境可能已经有一个分布式数据处理集群。即使物理上在相同的机器上,管理两个单独的集群也可能会很昂贵和冲突。
Apache Spark如今是处理大型数据集和构建可扩展数据产品的事实计算框架。H2O 包括 Sparkling Water,这是一个抽象层,让你可以将数据和算法模型与本机框架的所有功能和功能结合起来,同时还具有 Spark 的能力。
Sparkling Water 是用于进行机器学习的 ML 和 MLlib 框架的替代品,也是在 Spark 之上进行深度学习的少数替代品之一。
Spark 是用 Scala 设计和实施的。为了理解 H2O 和 Spark 的互操作性,我们需要参考本地 Scala API。
在 Sparkling Water 架构中,H2O 上下文与 Spark 上下文共存于驱动节点。此外,现在 Spark 2 有 SparkSession 作为主要入口点。很可能,H2O 和 Spark 执行器共存于工作节点。因此,它们共享相同的Java 虚拟机(JVM)和内存。资源分配和设置可以通过 YARN 来完成,YARN 是用于资源管理和作业调度的 Hadoop 组件。
你可以构建端到端的管道,结合了 Spark 和 MLlib 的优势以及 H2O 的特点。
例如,你可能会一起使用 Spark 和 H2O 进行数据整理并交替应用不同的转换函数。然后在 H2O 中进行深度学习建模。最终,你可以将训练好的模型返回,以在更大的应用程序中进行集成。
Spark 提供了三种 API 用于存储、建模和操纵数据。类型化的RDD(弹性分布式数据)、DataFrame 和最近统一的 DataSet API。DataFrame是sql.Row类型的 RDD;因此在这种集成中,它们被认为是类似的。
Sparkling Water 目前提供了在H2OFrame和 RDD 以及 DataFrame 之间的双向转换。将H2OFrame转换为 RDD 时,会创建一个包装器,将列名映射到在Product trait 中指定的类类型的相应元素。也就是说,你通常需要声明一个 Scala case 类,作为你从H2OFrame转换数据的容器。这种方法的局限性在于 case 类只能存储最多 21 个平面字段。对于更大的表,可以使用嵌套结构或字典。
将H2OFrame转换为 Spark DataFrame 不需要任何类型的参数。模式会动态地从H2OFrame的列名和类型中派生出来。
相反地,将现有的 RDD 或 DataFrame 转换成H2OFrame需要数据被复制和重新加载。由于H2OFrame被注册在键/值存储中,我们可以选择性地指定框架名称。在 RDD 的情况下,不需要指定明确的类型,因为 Scala 编译器可以推断出来。
列的基本类型必须与以下表格相匹配:
| Scala/Java 类型 | SQL 类型 | H2O 类型 |
|---|---|---|
| 无 | 二进制类型 | 数值 |
| Byte | ByteType | Numeric |
| Short | ShortType | Numeric |
| Integer | IntegerType | Numeric |
| Long | LongType | Numeric |
| Float | FloatType | Numeric |
| Double | DoubleType | Numeric |
| String | StringType | String |
| Boolean | BooleanType | Numeric |
| java.sql.TimeStamp | TimestampType | Time |
RDD 和 H2OFrame 在执行器 JVM 中共享相同的内存空间;在转换和复制后取消持久化它们是方便的。
现在我们已经了解了与 Spark 的本地 Scala 集成的工作原理,我们可以考虑 Python 包装器。
在驱动程序中,Python SparkContext 将使用 Py4J 启动驱动程序 JVM 和相应的 Java SparkContext。后者将创建 H2OContext,然后在 Spark 集群中启动 H2O 云。在此设置阶段之后,可以使用 H2O 和 PySpark 的 Python API 与数据和算法进行交互。
虽然 PySpark 和 PySparkling 是在 Python 中开发 Spark 和 H2O 的良好选择,但请记住 Python API 是 JVM 执行器的包装器。在分布式环境中维护和调试复杂项目可能比坚持使用本地 API 更加繁琐。尽管如此,在大多数情况下,Python API 都能正常工作,您不必在 Python 和本地语言之间切换。
测试
在讨论数据科学中测试的含义之前,让我们总结一些概念。
首先,总的来说,在科学中什么是模型?我们可以引用以下定义:
在科学中,模型是用于描述和解释无法直接体验的现象的想法、对象、甚至过程或系统的表示。
科学建模, 科学学习中心, http://sciencelearn.org.nz/Contexts/The-Noisy-Reef/Science-Ideas-and-Concepts/Scientific-modelling
还有这个:
科学模型是对现实世界现象的概念、数学或物理表示。当对象或过程至少部分理解但难以直接观察时,通常会构建模型。例如,用棍子和球表示分子,数学模型表示行星运动或概念原理如理想气体定律。由于实际自然界中的无限变化,除了最简单和最模糊的模型外,其他模型都是对真实世界现象的不完美表示。
在科学中,什么是模型?参考: https://www.reference.com/science/model-science-727cde390380e207
我们需要一个模型来简化系统的复杂性,以一种假设的形式。我们证明了深度神经网络可以描述复杂的非线性关系。尽管我们只是用比浅层模型更复杂的东西来逼近一个真实系统,但最终这只是另一个近似。我怀疑任何真实系统实际上都像神经网络一样工作。神经网络受到我们的大脑处理信息的方式的启发,但它们只是对它的巨大简化。
模型是根据一些参数(参数模型)来定义的。一方面,我们有一个将输入空间映射到输出的函数模型的定义。另一方面,我们需要一堆参数,函数需要这些参数来应用映射。例如,权重矩阵和偏差。
模型拟合和训练是指估计模型参数以使其最佳描述基础数据的过程。模型拟合通过定义依赖于模型参数和数据的损失函数的学习算法进行,然后尝试通过估计模型参数的最佳值集合来最小化这个函数。其中最常见的算法之一是梯度下降,以及它的所有变体。请参见之前的训练部分。对于自动编码器,你将最小化重构误差以及正则化惩罚(如果有的话)。
验证有时被与测试和评估混淆。验证和测试通常使用相同的技术和/或方法,但它们有两个不同的目的。
模型验证对应于一种假设验证。我们认为我们的数据可以被模型很好地描述。假设是,如果该模型是正确的,在经过训练(参数估计)后,它将以与训练集相同的方式描述未见过的数据。我们假设模型在我们将要使用的场景的限制下足够泛化。模型验证旨在找到一个量化模型如何拟合验证数据的度量(通常称为指标)。对于有标签数据,我们可以从验证数据上的异常分数计算的受试者工作特征(ROC)或精确率-召回率(PR)曲线中推导出一些指标。对于无标签数据,例如可以使用异常质量(EM)或质量-体积(MV)曲线。
尽管模型验证可以作为评估性能的一种方式,但它被广泛用于模型选择和调整。
模型选择是在一组候选模型中选择得分最高的模型的过程。候选模型可以是相同模型的不同配置,许多不同模型,选择不同特征、不同归一化和/或转换技术等。
在深度神经网络中,特征选择可能被省略,因为我们委托网络本身来扮演找出和生成相关特征的角色。此外,特征也通过学习过程中的正则化而被丢弃。
假设空间(模型参数)取决于拓扑选择、激活函数、大小和深度、预处理(例如图像白化或数据清洗)和后处理(例如,使用自动编码器减少维度,然后运行聚类算法)。我们可以将整个流程(给定配置上的组件集)看作模型,即使每个部分的拟合可能是独立进行的。
类似地,学习算法将引入一些参数(例如,学习率或衰减率)。特别是,因为我们希望最大化模型的泛化能力,通常在学习函数中引入正则化技术,这将引入额外的参数(例如,稀疏系数,噪声比或正则化权重)。
此外,算法的特定实施还具有一些参数(例如,周期,迭代次数)
我们可以使用相同的验证技术来量化模型和学习算法的性能。我们可以想象存在一个包括模型参数和超参数的单个大向量。我们可以调整所有内容以最小化验证度量标准。
在经过验证的模型选择和调整结束时,我们得到了一个系统,该系统:
-
取可用数据的一部分
-
分为训练和验证,确保不引入偏见或不平衡
-
创建由不同模型或不同配置、学习参数和实现参数构成的搜索空间
-
利用给定的损失函数(包括正则化)根据指定参数在训练数据上使用训练数据和学习算法对每个模型进行拟合
-
通过在验证数据上应用拟合模型来计算验证度量标准
-
选择使验证度量标准最小化的搜索空间中的一个点
选定的点将明确定义我们的最终理论。该理论表明我们的观察结果是从所选点对应的流程生成的模型生成的。
评估是验证最终理论的可接受性并从技术和业务角度量化其质量的过程。
科学文献显示了在历史进程中一个理论是如何取代另一个的。在不引入认知偏见的情况下选择正确的理论需要理性、准确的判断和逻辑解释。
确认理论,即指导科学推理而非演绎推理的研究,可以帮助我们定义一些原则。
在我们的情况下,我们想量化我们的理论的质量,并验证它是否足够好,并且与一个简单得多的理论(基线)相比具有显而易见的优势。基线可以是我们系统的一个天真的实现。在异常检测器的情况下,它可以简单地是一个基于规则的阈值模型,其中对于每个特征值超过静态阈值集的观察结果都标记为异常。这样一个基线可能是我们可以在一段时间内实现和维护的最简单的理论。它可能不会满足所有的接受标准,但它将帮助我们证明为什么我们需要另一个理论,即更高级的模型。
Colyvan,在他的书 数学的不可或缺性 中,总结了接受一个好的理论作为另一个理论替代品的四个主要标准:
-
简洁性/简约性:如果实证结果可以比较的话,简单比复杂更好。只有在需要克服某些限制时才需要复杂性。否则,无论是数学形式还是本体论承诺,都应该更喜欢简单。
-
统一性/解释力:能够一致解释现有和未来观察结果的能力。此外,统一性意味着尽量减少解释所需的理论设备数量。一个好的理论提供了一个直观的方法来解释为什么期望某个给定的预测。
-
大胆性/富有成效性:一个大胆的理论是一个想法,如果它是真实的,就能够预测和/或解释我们正在建模的系统的更多内容。大胆性有助于我们拒绝那些对我们已知的知识贡献很少的理论。可以制定一些新颖而创新的内容,然后尝试用已知证据来反驳它。如果我们无法证明一个理论是正确的,我们可以证明证据并不证明相反。另一个方面是启发式潜力。一个好的理论可以促使更多的理论。在两个理论之间,我们希望更偏向于更富有成效的那一个:具有更多被重新使用或扩展的潜力的那一个。
-
形式优雅:一个理论必须具有美学吸引力,并且应该足够强大,以便对一个失败的理论进行临时修改。优雅是以一种清晰、经济、简洁的方式解释某事的质量。优雅也能够更好地进行审查和维护。
在神经网络的情况下,这些标准被转化为以下内容:
-
我们更喜欢具有少量层和小容量的浅层模型。正如我们在网络设计部分讨论的那样,我们从简单的东西开始,如果需要的话逐渐增加复杂性。最终,复杂性将收敛,并且任何进一步的增加都不会带来任何好处。
-
我们将区分解释力和统一力:
-
解释力 与模型验证类似,但使用不同的数据集进行评估。我们之前提到我们将数据分成三组:训练、验证和测试。我们将使用训练和验证来制定理论(模型和超参数),然后模型会重新训练在两者的联合上,成为新的训练集;最终,已经验证过的最终模型将与测试集进行评估。在这个阶段,考虑在训练集和测试集上的验证指标非常重要。我们期望模型在训练集上表现更好,但两者之间有太大差距意味着模型无法很好地解释未见观察。
-
统一力 可以通过模型的稀疏性来表示。解释意味着将输入映射到输出。统一意味着减少应用映射所需的元素数量。通过添加正则化惩罚,我们使特征更稀疏,这意味着我们可以使用更少的回归器(理论设备)来解释观察和其预测。
-
-
果实性和果敢性 也可以分为两个方面:
-
果敢性 由我们的测试驱动方法来代表。除了第 2 点,我们试图明确模型的功能和原因,并在测试驱动方法中,我们把系统视为黑盒,并检查在不同条件下的回应。对于异常检测,我们可以系统地创建一些不同程度异常性的失败场景,并测量系统在何种程度上能够检测和反应。或者对于时间反应探测器,我们可以测量检测数据漂移需要多长时间。如果测试通过,那么我们就可以确定它无论如何都能正常工作。这可能是机器学习中最常见的方法之一。我们尝试一切我们认为可能奏效的方法;当我们的关键努力未能成功时,我们会谨慎评估并暂时接受(即,测试通过)。
-
果实性 来自于给定模型和系统的可重复性。它是否与特定用例过于紧密耦合?自编码器独立于底层数据表示的内容,它们使用非常少的领域知识。因此,如果理论是特定自编码器可用于解释系统在其工作条件下的情况,那么我们可以扩展它并在任何类型的系统中重复使用。如果我们引入一个预处理步骤(如图像白化),那么我们就假设输入数据是图像的像素,因此即使这个理论非常适合我们的用例,它对更大范围的可用性贡献度较小。然而,如果领域特定的预处理显著改善最终结果,那么我们将把它视为理论的重要部分。但如果贡献可以忽略不计,建议拒绝以换取更可重复的东西。
-
-
深度神经网络中优雅的一个方面可以被隐式地表示为从数据中学习特征而不是手动构建特征的能力。如果是这样,我们可以通过学习相关特征来衡量同一模型在不同场景下的自适应能力。例如,我们可以测试,在给定任何我们认为正常的数据集的情况下,我们是否可以构建一个始终学习正态分布的自动编码器。我们可以向同一数据集中添加或删除特征,或根据某些外部标准进行分组,从而生成具有不同分布的数据集。然后,我们可以检查学习到的表示,并测量模型的重构能力。与描述模型的具体输入特征和权重的函数相比,我们将其描述为具有学习能力的神经元实体。可以说,这是一个很好的优雅示例。
从商业角度来看,我们真的需要仔细考虑接受标准是什么。
我们至少要回答以下问题:
-
我们试图解决什么问题?
-
公司将如何从中受益?
-
在实际和技术层面上,模型将以何种方式集成到现有系统中?
-
最终的交付物如何才能具有可消化性和可执行性?
我们将尝试以入侵检测系统为例,并尝试回答这些问题。
我们想要实时监控网络流量,对每个单独的网络连接进行标记,标记为正常或可疑。这将使业务能够更好地防范入侵者。被标记的连接将被停止,并进入手动检查队列。安全专家团队将查看这些连接,并确定是否为误报,如果确认是攻击,则将该连接标记为其中一个可用的标签。因此,模型必须提供按异常分数排序的连接实时列表。列表中的元素数量不能超过安全团队的能力。此外,我们需要在允许攻击的成本、在发生攻击时的损害成本以及检查所需的成本之间取得平衡。为了以概率化方式限制最坏情况,最低要求是精确度和召回率。
所有这些评估策略都主要是定性而非定量定义的。很难比较和报告那些无法用数字衡量的内容。
数据科学从业者 Bryan Hudson 说:
如果你无法定义它,那就无法衡量它。如果无法测量,就不应该报告。首先定义,然后测量,再报告。
首先定义,然后测量,再报告。但要小心。我们可以考虑定义一个新的评估指标,考虑到迄今讨论的每个可能的方面和场景。
虽然许多数据科学家可能会尝试使用单一的实用函数来量化模型的评估,就像您在验证过程中所做的那样,但对于真正的生产系统,这是不被建议的。正如专业数据科学宣言中所表达的那样:
产品需要一系列措施来评估其质量。一个单一数字无法捕捉现实的复杂性。
专业数据科学宣言,www.datasciencemanifesto.org
即使在我们定义了关键绩效指标 (KPIs)之后,与基准相比,它们的实际含义是相对的。我们必须考虑为什么我们需要对比更简单或现有的解决方案。
评估策略需要定义测试用例和 KPI,以便我们可以涵盖最科学的方面和业务需求。其中一些是聚合数字,其他可以用图表表示。我们的目标是在单个评估仪表板中总结所有这些内容并有效地呈现它们。
在接下来的几节中,我们将看到一些使用标记和未标记数据进行模型验证的技术。
接下来,我们将看看如何使用一些并行搜索空间技术来调整参数空间。
最后,我们将给出使用 A/B 测试技术进行网络入侵使用情况的最终评估的示例。
模型验证
模型验证的目标是评估所训练模型的假设估计/预测的数值结果是否是对独立数据集的可接受描述。主要原因是由于训练集上的任何测量都会存在偏见和乐观主义,因为模型已经看到了这些观察结果。如果我们没有不同的验证数据集,我们可以从训练数据中留出一部分并将其用作基准。另一个常见的技术是交叉折叠验证,及其分层版本,其中整个历史数据集被分成多个折叠。为简单起见,我们将讨论留一法; 同样的标准也适用于交叉折叠验证。
训练集和验证集的划分不能完全随机。验证集应代表我们将用模型进行评分的未来假设场景。重要的是不要用与训练集高度相关的信息(泄露)污染验证集。
可以考虑一系列标准。最简单的是时间。如果您的数据是按时间顺序排列的,那么您将希望选择验证集总是在训练集之后。
如果您的部署计划是每天重新训练一次,并对接下来 24 小时的所有观察结果进行评分,那么您的验证集应恰好为 24 小时。24 小时后的所有观察结果将永远不会使用最后训练的模型进行评分,而是使用包括额外过去 24 小时观察结果的模型进行评分。
当然,仅使用 24 小时观察来进行验证太过严格了。我们需要进行几次验证,在每个分割点,我们将在该点之前训练模型,并在随后的验证窗口中验证数据。
分割点的选择取决于可用资源的数量。理想情况下,我们希望能够映射模型训练的确切频率,也就是说,过去一年左右每天一个分割点。
在分割训练和验证集时需要考虑一些操作事项:
-
无论数据是否具有时间戳,时间顺序应该根据当时可用的时间来设定。换句话说,假设数据生成和将其转换为训练特征空间之间有 6 小时的延迟;你应该考虑后者的时间,以便过滤掉分割点之前或之后的数据。
-
训练过程需要多长时间?假设我们的模型需要 1 小时进行重新训练;我们将在之前模型过期的前一小时安排重新训练。在其训练间隔期间得分将由以前的模型覆盖。这意味着我们无法对在最后一次收集训练数据的后续一小时内发生的任何观察进行预测。这在训练集和验证集之间引入了一个间隙。
-
模型在 day-0 恶意软件(冷启动问题)上表现如何?在验证过程中,我们希望以最坏的情况来评估模型,而不是过于乐观。如果我们可以找到一个分区属性,例如设备 ID 或网络卡 MAC 地址,那么我们可以将用户分成代表不同验证 fold 的桶,并进行交叉 fold 验证,依次选择一个用户 fold 来验证使用其他用户 fold 训练的模型。通过这样做,我们总是验证我们以前从未见过历史的用户的预测结果。这有助于真正衡量对于那些训练集已经包含同一设备在过去连接中的异常信号的情况的泛化能力。在这种情况下,模型很容易发现异常,但他们不一定与实际用例相匹配。
-
应用分区的属性(主键)的选择并不简单。我们希望尽可能减少 fold 之间的相关性。如果我们简单地根据设备 ID 进行分区,我们将如何处理同一用户或同一台机器具有多个设备,都使用不同的标识符注册的情况?选择分区键是一个实体解析问题。解决这个问题的正确方法是首先对属于同一实体的数据进行聚类,然后分区使得属于同一实体的数据绝不会分隔在不同的 fold 中。实体的定义取决于特定的用例背景。
-
在执行交叉折叠验证时,我们仍然需要确保时间约束。也就是说,对于每个验证折叠,我们需要在与其他训练折叠的交集中找到一个时间分割点。在实体 ID 和时间戳上过滤训练集;然后根据验证窗口和间隔来过滤验证折叠中的数据。
-
交叉折叠验证引入了一个类别不平衡的问题。按定义;异常是罕见的;因此我们的数据集是高度倾斜的。如果我们随机抽样实体,那么我们可能会得到一些没有异常的折叠和一些有太多异常的折叠。因此,我们需要应用分层交叉折叠验证,我们希望在每个折叠中均匀保留相同的异常分布。这在未标记数据的情况下是一个棘手的问题。但是我们仍然可以对整个特征空间运行一些统计,并以最小化折叠之间的分布差异的方式进行分区。
我们刚刚列举了在定义分割策略时需要考虑的一些常见陷阱。现在我们需要计算一些度量标准。验证度量标准的选择应与真实操作用例显著相关。
我们将在接下来的几节中看到为标记和未标记数据定义的几个可能的度量。
标记数据
标记数据的异常检测可以被视为标准的二元分类器。
让
成为我们的异常评分函数,其中分数越高,成为异常的概率就越高。对于自编码器来说,它可以简单地是重构误差上计算的 MSE,并重新缩放为[0,1]范围内。我们主要关心的是相对排序而不是绝对值。
我们现在可以使用 ROC 或 PR 曲线进行验证。
为此,我们需要设置一个与评分函数s对应的阈值a,并将具有评分s(x) = a的所有点x视为异常。
对于每个a值,我们可以计算混淆矩阵如下:
| 观察数量 n | 预测的异常 s(x) = a | 预测的非异常 (s < a) |
|---|---|---|
| 真异常 | 真正例(TP) | 假负例(FN) |
| 真负例 | 假正例(FP) | 真负例(TN) |
从与 a 值对应的每个混淆矩阵中,我们可以推导出真正例率(TPR)和假正例率(FPR)的度量标准:


我们可以在二维空间中绘制每个a值,生成包含
的 ROC 曲线。
我们解释图的方式如下:每个切断点告诉我们在 y 轴上我们在验证数据中发现的异常的比例(召回率)。x 轴是误报比率,标记为异常的观察值在所有正常观察值中的比例。
如果我们将阈值设定为接近 0,意味着我们将一切标记为异常,但所有正常的观察将产生虚警。如果我们将其设定为接近 1,我们将永远不会触发任何异常。
假设对于给定的 a 值,相应的 TPR = 0.9 和 FPR = 0.5;这意味着我们检测到了 90%的异常,但异常队列中也包含了一半的正常观察。
最佳阈值点将位于坐标(0,1)处,对应于 0 假阳性和 0 假阴性。这种情况从来不会发生,因此我们需要在召回率和虚警率之间找到一个折衷。
ROC 曲线的一个问题是它不能很好地展现高度偏斜的数据集的情况。如果异常只占数据的 1%,那么* x 轴很可能会很小,我们可能会放松阈值以增加召回率,而对 x *轴没有太大的影响。
精确度-召回率(PR)图交换轴,并用精确度替换 FPR 定义为:

精确度是一个更有意义的指标,它代表了检测到的异常中的异常部分。
现在的想法是最大化两个轴。在* y 轴上,我们可以观察到将要被检查的部分的预期结果, x *轴告诉我们有多少异常将会遗漏,它们都取决于异常概率。
有一个二维图可以帮助我们理解检测器在不同场景下的行为,但为了应用模型选择,我们需要最小化一个单一的效用函数。
有一系列措施可以用来综合这一点。最常见的是曲线下面积(AUC),它是检测器在任何阈值下的平均性能指标。对于 ROC 曲线,AUC 可以解释为均匀抽取的随机异常观察排在均匀抽取的随机正常观察之前的概率。这对于异常检测并不是非常有用。
精确度和召回率的绝对值在同一尺度上被定义,可以使用调和平均值(也称为F-score)进行汇总:

在这里,ß是一个系数,它权衡了召回率比精确度更重要的程度。
为了将评分缩放在 0 和 1 之间,添加了术语
。
对称的情况下,我们得到了 F1 分数:

安全分析员也可以根据精确度和召回率的最小要求设定偏好。在这种情况下,我们可以将偏好中心得分定义为:

PC 分数使我们能够选择一系列可接受的阈值,并根据 F1 分数优化中间点。第一个情况中的单位术语是添加的,因此它将始终优于第二个情况。
无标签的数据
不幸的是,大多数情况下数据都没有标签,而且需要太多的人力去对每个观察结果进行分类。
我们提出了两种不需要标签的 ROC 和 PR 曲线的替代品:质量体积(MV)和 超额质量(EM)曲线。
这次让
成为我们的逆异常评分函数,其中分数越小,异常的概率越高。在自动编码器的情况下,我们可以使用重构误差的倒数:

这里 ϵ 是一个小项,用于在接近零的重构误差情况下稳定。
评分函数将对每个观察结果进行排序。
让
成为一组 i.i.d. 观测值 X[1],…,X[n] 的正态分布的概率密度函数,F 是其累积密度函数。
函数 f 对于任何不属于正态分布的观察结果都会返回一个非常接近 0 的分数。我们想找到评分函数 s 与 f 的接近程度的度量。理想的评分函数将与 f 完全一致。我们将称这样的性能准则为 C(s)。
给定一组与勒贝格测度可积的评分函数 S。
s 的 MV-曲线是映射的绘图:

这里
。
集合 X 的勒贝格测度通过将集合分成桶(开区间序列)并求和每个桶的 n-体积得到。n-体积是每个维度的长度的乘积,定义为最大值和最小值之间的差异。如果 Xᵢ 是一堆 d 维点的子集,则它们在每个轴上的投影将给出长度,长度的乘积将给出 d 维体积。
a 处的 MV 测度对应于 X 的由阈值 t 定义的下确界子集的 n-体积,使得 s(X) 在 t 处的 c.d.f. 高于或等于 a。

来自“质量体积曲线与异常排名”的体积质量曲线,S. Clemencon,UMR LTCI No. 5141,Telecom ParisTech/CNRS
最佳的 MV 曲线将是在 f 上计算的曲线。我们希望找到最小化在感兴趣的区间 IMV 上点与 MVf 之间的逐点差异的得分函数 s,该区间表示大密度级集合(例如,[0.9, 1])。
已经证明
。由于 MV s 总是在 MV f 下方,因此
将对应于
。我们的 MV 的性能准则为
。CMV 的值越小,评分函数的性能越好。
MV 曲线的一个问题是,如果分布的支持是无限的(可能值的集合没有界限),则曲线下的面积(AUC)在 a = 1 时会发散。
一个解决方法是选择区间
。
更好的变体是过剩质量(EM)曲线,定义为映射的绘制:

性能指标将是
和
,其中
。EM[s] 现在总是有限的。

从《异常排名和过剩质量曲线》中的过剩质量曲线,N. Goix,A. Sabourin,S. Clemencon,UMR LTCI No. 5141,Telecom ParisTech/CNRS。
EM 的一个问题是,大级别集的区间与总支持体积的倒数数量级相同。对于具有大尺寸的数据集来说,这是一个问题。此外,对于 EM 和 MV,正常数据的分布 f 是未知的,必须进行估计。为了实用性,可以通过蒙特卡洛逼近来估计勒贝格体积,这仅适用于小尺寸。
为了适应大维数据,我们可以迭代地用替换子集的方式在随机固定数量的特征 d' 中进行训练和验证数据的子采样,以计算 EM 或 MV 性能指标分数。仅在我们为每个特征子集绘制样本后才进行替换。
最终的性能指标是通过对不同特征绘制的这些部分指标进行平均得到的。缺点是我们不能验证超过 d' 个特征的组合。另一方面,这种特征抽样使我们能够估计大维度下的 EM 或 MV,并且使我们能够比较从不同维度的数据输入空间产生的模型,假设我们想要在消耗不同视图的模型之间进行选择。
验证摘要
我们已经看到了如何在有标签和无标签数据的情况下绘制曲线图并计算聚合度量。
我们已经展示了如何选择得分函数的阈值子范围,以使聚合度量在异常检测中更具意义。对于 PR 曲线,我们可以设置精确度和召回率的最小要求;对于 EM 或 MV,即使它们没有直接对应的含义,我们也可以任意选择相应于大级别集的区间。
在我们的网络入侵示例中,我们对异常点进行评分并将其存储到队列中供进一步人工检查。在这种情况下,我们还需要考虑安全团队的吞吐量。假设他们每天只能检查 50 个连接;我们的性能指标应仅计算队列中的前 50 个元素。即使模型能够在前 1,000 个元素上达到 100% 的召回率,这些 1,000 个元素在实际情况下也不可检查。
这种情况有点简化了问题,因为我们将自动选择给出预期数量的预测异常的阈值,而与真正阳性或假阳性无关。这是模型可以做的最好的,鉴于最有可能是异常的前 N 个观察值。
在交叉折叠验证中,基于阈值的验证指标存在另一个问题,那就是聚合技术。聚合有两种主要方式:微观和宏观。
宏观聚合是最常见的一种;我们在每个验证折叠中计算阈值和指标,然后对它们求平均。微观聚合包括存储每个验证折叠的结果,将它们串联在一起,并在最后计算一个单一的阈值和指标。
宏观聚合技术还提供了稳定性的度量,以及如果我们通过使用不同样本进行扰动时系统性能的变化程度。另一方面,宏观聚合会给模型估计引入更多偏差,特别是在罕见类别(如异常检测)中。因此,一般偏向于微观聚合。
超参数调整
根据前面章节的深度神经网络设计,我们将得到一堆需要调整的参数。其中一些具有默认值或推荐值,并且不需要昂贵的微调。其他参数则严重依赖于底层数据、特定应用领域和一系列其他组件。因此,找到最佳值的唯一方法是执行模型选择,根据在验证数据折叠上计算的所需指标进行验证。
现在我们将列出一个表格,其中包含我们可能想要考虑调整的参数。请注意,每个库或框架可能有额外的参数和自定义设置方式。此表格源自于 H2O 中可用的调整选项。它总结了在生产中构建深度自动编码器网络时的常见参数,但不是全部:
| 参数 | 描述 | 推荐值 |
|---|---|---|
activation |
可微激活函数。 | 取决于数据的特性。流行函数包括:Sigmoid、Tanh、Rectifier 和 Maxout。每个函数都可以映射到相应的丢弃版本。请参考网络设计部分。 |
| hidden | 尺寸和层数。 | 当网络是自编码器时,层数始终是奇数,并且在编码和解码之间对称。尺寸取决于网络设计和正则化技术。没有正则化时,编码层应连续小于前一层。有了正则化,我们可以拥有比输入尺寸更高的容量。 |
| epochs | 对训练集进行的迭代次数。 | 一般来说,介于 10 和几百之间。根据算法的不同,可能需要额外的迭代来收敛。如果使用了早停法,就不需要担心迭代次数太多。对于使用网格搜索进行模型选择,最好将其保持足够小(小于 100)。 |
train_samples_per_iteration |
Map/Reduce 迭代中的训练样例数。 | 此参数仅适用于分布式学习的情况。这在很大程度上取决于实现方式。H2O 提供了自动调优选项。请参考Distributed learning via Map/Reduce部分。 |
adaptive_rate |
启用自适应学习率。 | 每个库可能有不同的策略。H2O 的默认实现是ADADELTA。对于ADADELTA,还必须指定额外的参数 rho(介于 0.9 和 0.999 之间)和 epsilon(介于 1e-10 和 1e-4 之间)。请参考自适应学习部分。 |
rate,rate_decay |
学习率的值和衰减系数(如果不是自适应学习)。 | 较高的学习率可能导致不稳定的模型,较低的值会减缓收敛速度。一个合理的值是 0.005。衰减系数表示学习率在各个层级上衰减的速率。 |
momentum_start,momentum_ramp,momentum_stable |
动量技术的参数(如果不是自适应学习)。 | 当动量开始和稳定值之间存在间隔时,动量斜坡是以训练样例数量为单位衡量的。默认值通常较大,例如 1e6。 |
Input_dropout_ratio,hidden_dropout_ratio |
每个层级中要在训练过程中省略的输入节点的比例。 | 输入(所有特征)的默认值为 0,隐藏层的值约为 0.5。 |
l1,l2 |
L1 和 L2 正则化参数。 | 较大的 L1 值会导致许多权重变为 0,较大的 L2 值会减小但保留大部分权重。 |
max_w2 |
一个节点上所有权重的平方和的最大值。 | 对于无界激活函数(如 ReLU 或 Maxout)很有用的一个参数。 |
initial_weight_distribution |
初始权重的分布。 | 典型的值有均匀分布(Uniform)、正态分布(Normal)或自适应均匀分布(UniformAdaptive)。通常更倾向于后者。 |
loss |
后向传播过程中要使用的损失函数。 | 这取决于问题和数据的性质。常见的函数有交叉熵(CrossEntropy)、平方差(Quadratic)、绝对值(Absolute)、Huber。请参考网络设计部分。 |
rho_sparsity,beta_sparsity |
稀疏自动编码器的参数。 | Rho 是平均激活频率,beta 是与稀疏惩罚相关的权重。 |
这些参数可以使用搜索空间优化技术来进行调优。H2O 支持的两个基本和流行的技术是网格搜索和随机搜索。
网格搜索是一种穷举的方法。每个维度指定了一系列可能的值,笛卡尔积生成了搜索空间。每个点将以并行方式进行评估,并选择得分最低的点。评分函数由验证指标定义。
一方面,我们的计算成本与维度的幂等于(维度的诅咒)。另一方面,它是尴尬地并行的。也就是说,每个点都是完全可以并行化的,它的运行与其他点是独立的。
另外,在密集搜索空间中随机选择点可能更有效,并且可以在需要更少的计算的情况下产生类似的结果。在一个特定数据集中,浪费的网格搜索尝试的数量与被证明对于某一个数据集是无关紧要的搜索维度的数量是指数级的。不是每个参数在调整过程中都具有相同的重要性。随机搜索不受这些低重要性维度的影响。
在随机搜索中,每个参数必须提供一个分布,取决于参数的值是连续的还是离散的。试验点是从这些分布中独立抽样的点。
随机搜索的主要优势包括:
-
您可以固定预算(最大探索点数或最大允许时间)。
-
您可以设置收敛标准。
-
添加不影响验证性能的参数不影响效率。
-
在调整过程中,您可以动态地添加额外的参数,而无需调整网格并增加尝试次数。
-
如果某次试验运行失败,由于任何原因,可以放弃或重新启动,而不会危及整个调整算法。
随机搜索的常见应用与早期停止有关。特别是在高维空间中有许多不同模型的情况下,收敛到全局最优解之前的尝试次数可能会很多。当学习曲线(训练)或验证曲线(调整)趋于平缓时,早期停止将停止搜索。
因为我们也可以限制计算预算,所以我们可以设置诸如:当 RMSE 比最佳 5 个模型的移动平均改善少于 0.0001 时停止,但最多不超过 1 小时 的标准。
基于度量的早期停止结合最大运行时一般给出最佳的权衡。
通常也会有多阶段的调整,例如,您可以运行随机搜索来识别可能存在最佳配置的子空间,然后仅在所选子空间中进行进一步的调整阶段。
更高级的技术还利用了顺序,自适应的搜索/优化算法,其中一个试验的结果影响下一个试验的选择和/或超参数是联合优化的。目前正在进行研究,试图预先确定超参数的变量重要性。此外,领域知识和手动微调对于那些自动技术难以收敛的系统可能是有价值的。
端到端评估
从商业角度来看,真正重要的是最终的端到端性能。你的利益相关者都不会对你的训练误差、参数调整、模型选择等感兴趣。重要的是基于最终模型计算的关键绩效指标。评估可以被看作是最终的裁决。
此外,正如我们预期的那样,评估产品不能仅仅依靠单一指标。通常,构建一个内部仪表板是一个好的有效的做法,它可以以汇总数字或易于解释的可视化图表的形式实时报告或测量我们产品的一系列绩效指标。通过一瞥,我们希望理解整个图片并将其转化为我们在业务中产生的价值。
评估阶段通常包括与模型验证相同的方法。我们在前面的章节中看到了一些在有标签和无标签数据情况下验证的技术。这些可以作为起点。
除了那些,我们还应该包括一些具体的测试场景。例如:
-
已知与未知检测性能:这意味着衡量检测器对已知和未知攻击的性能。我们可以使用标签创建不同的训练集,其中一些根本没有攻击,而另一些则有小部分攻击;请记住,在训练集中有太多异常将违反异常的定义。我们可以根据训练集中异常百分比的函数来测量前 N 个元素的精度。这将为我们提供检测器相对于过去异常和假设的新异常的一般性的指示。取决于我们试图构建的内容,我们可能更感兴趣于新异常还是已知异常。
-
相关性能:只有得分达到阈值或者在优先级队列中被选择是重要的,但排名也很重要。我们希望最相关的异常总是排在队列的前面。在这里,我们可以定义不同标签的优先级,并计算排名系数(例如,Spearman 系数),或者使用一些用于推荐系统的评估技术。后者的一个例子是信息检索中使用的 k 个均值平均精度(MAP@k),用于评分查询引擎返回文档的相关性。
-
模型稳定性:我们在验证过程中选择最佳模型。如果我们以不同的方式抽样训练数据,或者使用略有不同的验证数据集(包含不同类型的异常),我们希望最佳模型始终保持相同,或者至少是在顶部选出的模型之一。我们可以创建直方图,显示给定模型被选择的频率。如果没有明显的获胜者或一组频繁候选模型,那么模型选择就有些不稳定。每天,我们可能会选择一个不同的模型,该模型可以很好地对新攻击做出反应,但代价是稳定性不好。
-
攻击结果:如果模型检测到一次得分非常高的攻击,并且分析师们确认了这次攻击,那么模型是否能够检测出系统是否已被入侵或恢复正常?一种测试方法是在发出警报后测量异常得分的分布。将新的分布与旧的分布进行比较,并测量其中的差距。一个好的异常检测器应该能够告诉你系统的状态。评估仪表板可以将最近检测到的异常可视化显示出来。
-
故障案例模拟:安全分析师可以定义一些场景并生成一些合成数据。其中一个业务目标可以是“能够保护免受未来这些类型的攻击”。可以从这个人工数据集中提取专用的性能指标。例如,对同一主机和端口的网络连接进行递增的斜坡可能是拒绝服务 (DOS) 攻击的迹象。
-
检测时间:检测器通常独立地对每个数据点进行评分。对于上下文和基于时间的异常,同一实体可能会生成许多数据点。例如,如果我们打开一个新的网络连接,我们可以在连接仍然打开时对其进行评分,并且每隔几秒生成一个特征收集在不同时间间隔内的新数据点。通常,您会将多个连续的连接整合到一个数据点中进行评分。我们希望能够测量反应所需的时间。如果第一个连接不被视为异常,也许在连续尝试了 10 次之后,检测器将会有反应。我们可以将已知的异常拆分成连续增长的数据点,然后报告在经过多少个数据点后发现了上下文异常。
-
损害成本:如果我们能够以某种方式量化攻击造成的损害或由于检测而产生的节省,我们应该将其纳入最终评估中。我们可以以过去的一个月或一年作为基准,并估计节省的金额;希望这个平衡是正向的,如果我们自那时起部署了当前解决方案,或者如果当前解决方案是在最近这段时间部署的,这样可以获得真正的节省。
我们希望能够在单个仪表板中总结所有这些信息,以便我们可以发表如下的声明:我们的异常检测器能够以 76%(+- 5%)的精度和平均反应时间为 10 秒来检测先前发现的异常,以及以 68%(+- 15%)的精度和 14 秒的反应时间来检测新异常。我们每天观察到平均 10 个异常。考虑到每天可以进行 1,000 次检查的能力,我们可以在队列的前 120 个元素中填充 80%的最相关检测,对应于仅将 6 个异常纳入其中。这些中仅有 2 个是危及系统的。然后我们可以将检查分为两个级别;第一级将立即响应前 120 个元素,第二级将处理剩下的。按照当前模拟的故障场景,我们在其中受到了 90%的保护。自去年以来的总节省额相当于 120 万美元。
A/B 测试
到目前为止,我们只考虑过基于过去历史数据(事后分析)和/或基于合成数据集模拟的评估。第二种方法是基于假设未来会发生特定故障场景的。仅基于历史数据进行评估假定了系统将始终在这些条件下运行,并且当前的数据分布也描述了未来数据流。此外,任何关键绩效指标都应相对于基线进行评估。产品负责人希望为该项目的投资提供理由。如果相同的问题可以以更便宜的方式解决呢?
出于这个原因,评估任何机器学习系统的唯一方法是 A/B 测试。A/B 测试是一种统计假设检验,有两种变体(控制组和变体组)的受控实验。A/B 测试的目标是确定两组之间的性能差异。在网站用户体验设计或广告/营销活动中广泛使用这种技术。在异常检测的情况下,我们可以将基线(最简单的基于规则的检测器)作为控制版本,当前选择的模型作为变体候选者。
下一步是找到一个有意义的评估,量化投资回报。
"我们必须找到一种让重要的事物可度量,而不是让可度量的事物变得重要的方法。"
罗伯特·麦克纳马拉,前美国国防部部长
投资回报将由提升定义为:

两个 KPI 之间的差异量化了治疗效果。
为了公平比较,我们必须确保这两组共享相同的人口分布。我们希望消除数据样本选择所带来的任何偏见。在异常检测器的情况下,我们原则上可以将相同的数据流应用到这两个模型。虽然这样做并不推荐。通过应用一个模型,您可以影响给定过程的行为。一个典型的例子是一个入侵者首先被模型检测到,因此系统会通过中断他的开放连接来做出反应。一个聪明的入侵者会意识到他已被发现,不会再尝试连接。在这种情况下,由于第一个模型的影响,第二个模型可能永远不会观察到某个预期模式。
通过将两个模型分离到数据的两个不相交子集中,我们确保这两个模型不能相互影响。此外,如果我们的用例要求分析师进一步调查异常,那么它们不能被复制。
在这里,我们必须根据与数据验证中相同的标准进行分割:没有数据泄露和实体子采样。能够确认这两组实际上是相同分布的最终测试是 A/A 测试。
顾名思义,A/A 测试就是在两组上重新使用控制版。我们期望性能应该非常相似,相当于接近 0 的增益。这也是性能方差的指标。如果 A/A 增益不为零,则我们必须重新设计受控实验,使其更加稳定。
A/B 测试非常适合衡量两个模型之间性能的差异,但模型不是唯一影响最终性能的因素。如果我们考虑损耗成本模型,即业务核心,模型必须准确地生成一个优先级列表,以便调查异常,同时分析师必须擅长识别、确认和采取行动。
因此,我们有两个因素:模型准确性和安全团队的有效性。
我们可以将受控实验分成 A/B/C/D 测试,创建四个独立的组,如下所示:
| 基础模型 | 先进模型 | |
|---|---|---|
| 来自安全团队的无操作 | A 组 | B 组 |
| 来自安全团队的干预 | C 组 | D 组 |
我们可以计算一系列增益度量,量化模型准确性和安全团队的有效性。特别是:
-
uplift(A,B): 先进模型单独的有效性 -
uplift(D,C): 发生安全干预时先进模型的有效性 -
uplift(D,A): 先进模型和安全干预一起的有效性 -
uplift(C,A): 低准确性队列上的安全干预的有效性 -
uplift(D,B): 安全干预对高准确性队列的有效性
这只是一个有意义的实验和评估的示例,您想进行这些评估以便以数字形式量化业务真正关心的内容。
此外,还有一堆用于 A/B 测试的高级技术。只是举一个例子,多臂老丨虎丨机算法允许您动态调整不同测试组的大小,以适应它们的性能并最小化由于性能低的组造成的损失。
测试摘要
总之,对于使用神经网络和标记数据的异常检测系统,我们可以定义以下内容:
-
模型作为网络拓扑的定义(隐藏层的数量和大小),激活函数,预处理和后处理转换。
-
模型参数作为隐藏单元的权重和隐藏层的偏置。
-
拟合模型作为具有参数估计值的模型,能够将样本从输入层映射到输出层。
-
学习算法(也称为训练算法)作为 SGD 或其变体(HOGWILD!,自适应学习)+损失函数+正则化。
-
训练集、验证集和测试集是可用数据的三个不相交且可能独立的子集,其中我们保留相同的分布。
-
模型验证作为在训练集上拟合的模型计算的 ROC 曲线上的最大 F-度量分数。
-
模型选择作为一组可能配置中的最佳验证模型(1 隐藏层 Vs. 3 隐藏层,50 个神经元 Vs. 1000 个神经元,Tanh Vs. Sigmoid,Z-scaling Vs. Min/Max 归一化等等...)。
-
超参数调整作为模型选择的延伸,使用算法和实现参数,如学习参数(epochs,批量大小,学习速率,衰减因子,动量...),分布式实现参数(每次迭代的样本),正则化参数(L1 和 L2 中的 lambda,噪声因子,稀疏性约束...),初始化参数(权重分布)等等。
-
模型评估,或测试,作为在测试集上计算的最终业务指标和验收标准,使用在训练集和验证集上拟合的模型合并在一起。一些示例是仅针对前 N 个测试样本的精确度和召回率,检测时间等等。
-
A/B 测试作为模型与基线之间的评估性能提升,基线是根据现场数据人群的两个不同但同质的子集计算的(对照组和变化组)。
我们希望我们已经澄清了在测试生产就绪的深度学习入侵检测系统时需要考虑的基本和最重要的步骤。这些技术、指标或调整参数可能对您的用例不同,但我们希望深思熟虑的方法论可以作为任何数据产品的指南。
一个关于构建既科学正确又对业务有价值的数据科学系统的指导方针和最佳实践的重要资源是专业数据科学宣言:www.datasciencemanifesto.org。推荐阅读并围绕列出的原则进行思考。
部署
在这个阶段,我们应该已经完成了几乎所有构建异常检测器或通用深度学习数据产品所需的分析和开发工作。
我们只剩下最后但同样重要的一步:部署。
部署通常非常特定于用例和企业基础架构。在本节中,我们将介绍一些在通用数据科学生产系统中使用的常见方法。
POJO 模型导出
在测试部分,我们总结了机器学习管道中的所有不同实体。特别是,我们已经看到了模型、适配模型和学习算法的定义和区别。在我们训练、验证和选择了最终模型之后,我们得到了一个准备好使用的最终适配版本。在测试阶段(除了 A/B 测试),我们只对通常已经可用于训练模型的历史数据进行了评分。
在企业架构中,常见的是有一个数据科学集群,您在其中构建一个模型,以及用于部署和使用适配模型的生产环境。
一种常见的导出适配模型的方法是纯旧的 Java 对象(POJO)。POJO 的主要优点是它可以很容易地集成到 Java 应用程序中,并安排在特定数据集上运行或部署以实时进行评分。
H2O 允许您通过编程方式或从 Flow Web UI 中提取适配模型,这在本书中没有涵盖。
如果 model 是您的适配模型,您可以通过运行以下命令将其保存为指定路径中的 POJO jar:
model.download_pojo(path)
POJO jar 包含了一个独立的 Java 类 hex.genmodel.easy.EasyPredictModelWrapper,不依赖于训练数据或整个 H2O 框架,而只依赖于 h2o-genmodel.jar 文件,该文件定义了 POJO 接口。它可以从任何在 JVM 中运行的东西中读取和使用。
POJO 对象将包含与在 H2O 中使用的模型 id(model.id)对应的模型类名称,以及用于异常检测的模型类别将是 hex.ModelCategory.AutoEncoder。
不幸的是,在撰写本章时,关于实现 AutoEncoder 的 Easy API 仍然存在一个未解决的问题:0xdata.atlassian.net/browse/PUBDEV-2232。
来自 h2ostream 邮件列表的 Roberto Rösler 通过实现自己的 AutoEncoderModelPrediction 类解决了这个问题,如下所示:
public class AutoEncoderModelPrediction extends AbstractPrediction {
public double[] predictions;
public double[] feature;
public double[] reconstrunctionError;
public double averageReconstructionError;
}
并修改了 EasyPredictModelWrapper 中的 predictAutoEncoder 方法,如下所示:
public AutoEncoderModelPrediction predictAutoEncoder(RowData data) throws PredictException { double[] preds = preamble(ModelCategory.AutoEncoder, data);
// save predictions
AutoEncoderModelPrediction p = new AutoEncoderModelPrediction();
p.predictions = preds;
// save raw data
double[] rawData = new double[m.nfeatures()];
setToNaN(rawData);
fillRawData(data, rawData);
p.feature = rawData;
//calculate and reconstruction error
double[] reconstrunctionError = new double [rawData.length];
for (int i = 0; i < reconstrunctionError.length; i++) {
reconstrunctionError[i] = Math.pow(rawData[i] - preds[i],2); } p.reconstrunctionError = reconstrunctionError;
//calculate mean squared error
double sum = 0; for (int i = 0; i < reconstrunctionError.length; i++) {
sum = sum + reconstrunctionError[i];
} p.averageReconstructionError = sum/reconstrunctionError.length;
return p;
}
自定义修改的 API 将公开一种检索每个预测行的重构错误的方法。
为使 POJO 模型工作,我们必须指定与训练期间使用的相同的数据格式。数据应加载到hex.genmodel.easy.RowData对象中,这只是java.util.Hashmap<String, Object>的实例。
创建RowData对象时,必须确保以下事项:
-
使用相同的列名和
H2OFrame的类型。对于分类列,必须使用 String。对于数值列,可以使用 Double 或 String。不支持不同的列类型。 -
对于分类特征,除非您将
convertUnknownCategoricalLevelsToNa显式设置为模型包装器中的 true,否则值必须属于训练时使用的相同集合。 -
可以指定其他列,但将被忽略。
-
任何缺少的列都将被视为 NA。
-
数据也应该应用相同的预处理转换。
这最后一个要求可能是最棘手的。如果我们的机器学习流水线由一堆转换器组成,那么这些转换器必须在部署中完全复制。因此,POJO类是不够的,还应该与 H2O 神经网络以及流水线中的所有其他步骤一起使用。
下面是一个 Java 主函数的示例,它读取一些数据,并针对导出的POJO类进行评分:
import java.io.*;
import hex.genmodel.easy.RowData;
import hex.genmodel.easy.EasyPredictModelWrapper;
import hex.genmodel.easy.prediction.*;
public class main {
public static String modelClassName = "autoencoder_pojo_test";
public static void main(String[] args) throws Exception {
hex.genmodel.GenModel rawModel;
rawModel = (hex.genmodel.GenModel) Class.forName(modelClassName).newInstance();
EasyPredictModelWrapper model = new EasyPredictModelWrapper(rawModel);
RowData row = new RowData();
row.put("Feature1", "value1");
row.put("Feature2", "value2");
row.put("Feature3", "value3");
AutoEncoderModelPrediction p = model.predictAutoEncoder(row);
System.out.println("Reconstruction error is: " + p.averageReconstructionError);
}
}
我们已经看到了如何将 POJO 模型实例化为 Java 类并将其用于评分模拟数据点的示例。我们可以重新调整此代码,以便将其集成到现有的基于 JVM 的企业系统中。如果您正在集成它到 Spark 中,您只需将我们在示例主类中实现的逻辑包装在一个函数中,并从 Spark 数据集的 map 方法中调用它。您所需要的只是将模型 POJO jar 加载到您想要进行预测的 JVM 中。或者,如果您的企业栈是基于 JVM 的,还有一些实用的入口点,例如hex.genmodel.PredictCsv。它允许您指定一个 csv 输入文件和一个用于存储输出的路径。由于 Easy API 尚不支持AutoEncoder,您将不得不根据我们之前看到的自定义补丁修改PredictCsv主类。另一种架构可能是使用 Python 构建模型并在生产部署中使用基于 JVM 的应用程序。
异常分数 API
将模型导出为 POJO 类是以程序方式将其包含在现有 JVM 系统中的一种方法,就像导入外部库一样。
在一些其他情况下,使用自包含的 API 进行集成会更好,比如在微服务架构或非 JVM-based 系统中。
H2O 可以将训练好的模型封装为一个 REST API,通过附加在 HTTP 请求中的 JSON 对象指定要评分的行数据来调用。 REST API 后端的实现可以执行您在 Python H2O API 中执行的所有操作,包括预处理和后处理步骤。
REST API 可从以下位置访问:
-
任何使用简单插件的浏览器,例如 Chrome 中的 Postman
-
curl,用于客户端 URL 传输的最流行工具之一
-
任何您选择的语言;REST API 完全与语言无关
尽管存在 POJO 类,但 H2O 提供的 REST API 依赖于运行中的 H2O 集群实例。您可以在http://hostname:54321后面加上 API 版本(最新为 3)和资源路径,例如,http://hostname:54321/3/Frames将返回所有 Frames 的列表。
RESTAPI 支持五个动词或方法:GET、POST、PUT、PATCH和DELETE。
GET用于读取没有副作用的资源,POST用于创建新资源,PUT用于更新和完全替换现有资源,PATCH用于修改现有资源的一部分,DELETE用于删除资源。H2O RESTAPI 不支持PATCH方法,并添加了一个称为HEAD的新方法。它类似于GET请求,但仅返回HTTP状态,可用于检查资源是否存在而无需加载它。
H2O 中的端点可以是 Frames、Models 或 Clouds,这些是与 H2O 集群中节点状态相关的信息片段。
每个端点将指定其自己的有效载荷和模式,并且文档可以在docs.h2o.ai/h2o/latest-stable/h2o-docs/rest-api-reference.html上找到。
H2O 在 Python 模块中提供了一个连接处理程序,用于所有 REST 请求:
with H2OConnection.open(url='http://hostname:54321') as hc:
hc.info().pprint()
hc对象有一个名为request的方法,可以用来发送REST请求:
hc.request(endpoint='GET /3/Frames')
对于POST请求的数据载荷可以使用data参数(x-www 格式)或json参数(json 格式),并指定一个键值对字典。通过指定filename参数映射到本地文件路径来上传文件。
在这个阶段,无论我们使用 Python 模块还是任何 REST 客户端,我们必须按照以下步骤上传一些数据并获取模型得分:
-
使用
POST /3/ImportFiles导入要评分的数据,使用ImporFilesV3模式,包括从哪里加载数据的远程路径(通过 http、s3 或其他协议)。相应的目标帧名称将是文件路径:POST /3/ImportFiles HTTP/1.1 Content-Type: application/json { "path" : "http://s3.amazonaws.com/my-data.csv" } -
猜测解析参数;它将返回从数据中推断出的一堆参数,用于最终解析(您可以跳过并手动指定这些参数):
POST /3/ParseSetup HTTP/1.1 Content-Type: application/json { "source_frames" : "http://s3.amazonaws.com/my-data.csv" } -
根据解析参数解析:
POST /3/Parse HTTP/1.1 Content-Type: application/json { "destination_frame" : "my-data.hex" , source_frames : [ "http://s3.amazonaws.com/my-data.csv" ] , parse_type : "CSV" , "number_of_columns" : "3" , "columns_types" : [ "Numeric", "Numeric", "Numeric" ] , "delete_on_done" : "true" } -
从响应中获取作业名称,并轮询导入完成状态:
GET /3/Jobs/$job_name HTTP/1.1 -
当返回状态为 DONE 时,您可以运行模型评分如下:
POST /3/Predictions/models/$model_name/frames/$frame_name HTTP/1.1 Content-Type: application/json { "predictions_frame" : "$prediction_name" , "reconstruction_error" : "true" , "reconstruction_error_per_feature" : "false" , "deep_features_hidden_layer" : 2 } -
解析结果后,您可以删除输入和预测框架:
DELETE /3/Frames/$frame_name DELETE /3/Frames/$prediction_name
让我们分析 Predictions API 的输入和输出。 reconstruction_error、reconstruction_error_per_feature 和 deep_features_hidden_layer 是 AutoEncoder 模型的特定参数,并确定输出中将包含什么。输出是一个 model_metrics 数组,对于 AutoEncoder 将包含:
-
MSE:预测的均方误差
-
RMSE:预测的均方根误差
-
得分时间:自这次评分运行开始以来的毫秒数
-
预测:包含所有预测行的框架
部署的总结
我们已经看到两种导出和部署训练模型的选项:将其导出为 POJO 并将其合并到基于 JVM 的应用程序中,或者使用 REST API 调用已加载到运行中 H2O 实例中的模型。
一般来说,使用 POJO 是一个更好的选择,因为它不依赖于运行中的 H2O 集群。因此,您可以使用 H2O 构建模型,然后在任何其他系统上部署它。
如果您想要实现更大的灵活性,并且能够在任何客户端随时生成预测,只要 H2O 集群正在运行,那么 REST API 就会很有用。然而,这个过程需要比 POJO 部署更多的步骤。
另一个推荐的架构是使用导出的 POJO 并将其包装在使用 Jersey 进行 Java 或者 Play 或 akka-http 进行 Scala 的 JVM REST API 中。构建自己的 API 意味着您可以以编程方式定义接受输入数据的方式以及单个请求中要返回的内容,而不是 H2O 中的多个步骤。此外,您的 REST API 可以是无状态的。也就是说,您不需要将数据导入帧并在之后删除它们。
最终,如果您希望基于 POJO 的 REST API 能够轻松地移植和部署到任何地方,建议您使用 Docker 将其包装在虚拟容器中。Docker 是一个开源框架,允许您将软件包装在一个完整的文件系统中,其中包含运行所需的所有内容:代码、运行时、系统工具、库以及您需要安装的所有内容。这样,您就有了一个单一的轻量级容器,在任何环境中始终可以运行相同的服务。
Docker 化的 API 可以轻松地部署到您的任何生产服务器上。
部署摘要
在本章中,我们经历了一段漫长的优化、调整、测试策略和工程实践之旅,将我们的神经网络转化为入侵检测数据产品。
特别是,我们将数据产品定义为从原始数据中提取价值并将可操作的知识作为输出返回的系统。
我们看到了一些训练深度神经网络以获得更快、可扩展和更健壮性的优化方法。我们通过权重初始化解决了早期饱和问题。利用并行多线程版本的 SGD 和 Map/Reduce 中的分布式实现来提高可扩展性。我们看到了 H2O 框架如何通过 Sparkling Water 将 Apache Spark 作为计算后端来实现。
我们强调了测试的重要性,以及模型验证和完整端到端评估之间的区别。模型验证用于拒绝或接受给定模型,或选择性能最佳的模型。同样,模型验证指标可用于超参数调整。另一方面,端到端评估更全面地量化了完整解决方案如何解决实际业务问题。
最终,我们进行了最后一步——通过将测试过的模型直接部署到生产环境中,要么将其导出为 POJO 对象,要么通过REST API 将其转换为服务。
我们总结了在构建强大的机器学习系统和更深层次的架构方面所学到的一些经验教训。我们期望读者将这些作为进一步发展和根据每个使用案例定制解决方案的基础。

:此函数允许激活值通过,并称为身份函数
:如果激活值高于某个特定值,则此函数激活神经元,称为阈值活动函数
:这个函数是最常用的之一,因为它的输出被限制在 0 和 1 之间,可以随机解释为神经元激活的概率,通常称为逻辑函数或逻辑 S 形函数。
:这个活动函数被称为双极 S 形函数,它简单地是一个逻辑 S 形函数重新缩放和平移,使其范围在(-1, 1)之间。
:这个活动函数被称为双曲正切函数。
:这个活动函数可能是与其生物学相似度最高的,它是标识和阈值函数的混合体,被称为整流器,或ReLU,如 Rectfied Linear Unit。







是因为我们要遍历每对 i 和 j,因此重复计算每个连接(当 i=1 且 j=2 时,然后当 i=2 且 j=1 时又计算一次)。

是这两个向量的点积,等价于
。现在我们需要对新能量函数计算出的偏置和权重进行梯度下降。

发生了很大的变化:










对于任何
的
对于任何
的
浙公网安备 33010602011771号