原文:
annas-archive.org/md5/d340e8e4b7f611ff61fca2a8b0b525a6
译者:飞龙
第六章:长短期记忆网络
“当我年轻时,我常常思考自己的人生该做什么。对我来说,最激动人心的事情似乎是能够解开宇宙的谜团。这意味着要成为一名物理学家。然而,我很快意识到,也许有更宏伟的目标。假如我能尝试去构建一台机器,让它成为比我任何时候都更优秀的物理学家呢?也许,这就是我能将自己微不足道的创造力,扩展到永恒的方法。”
– Jeurgen Schmidthuber,长短期记忆网络的共同发明人
在 1987 年的毕业论文中,Schmidthuber 提出了一种元学习机制的理论,该机制能够检查自身的学习算法,并随后对其进行修改,以有效地优化所使用的学习机制。这个想法意味着将学习空间开放给系统本身,以便它能够在看到新数据时不断改进自身的学习:可以说是一个“学习如何学习”的系统。Schmidthuber 甚至将这台机器命名为 Gödel 机器,命名灵感来自于递归自我改进算法背后的数学概念创始人 Gödel。不幸的是,我们至今尚未构建出 Schmidthuber 描述的那种自学习的通用问题解决器。然而,这可能并不像你想象的那么令人失望。有人可能会认为,鉴于当前人类事务的状态,自然界本身还未成功构建出这样的系统。
另一方面,Schmidthuber 和他的同事确实成功开发了一些相当新颖的东西。我们当然指的是长短期记忆(LSTM)网络。有趣的是,LSTM 在很多方面是门控递归单元(GRU)的“哥哥”。LSTM 网络不仅比 GRU(Cho 等人,2014)早(Hochreiter 和 Schmidthuber,1997)提出,而且它的计算复杂度也更高。虽然计算负担较重,但与我们之前看到的其他递归神经网络(RNN)相比,它在长期依赖建模方面带来了大量的表示能力。
LSTM 网络为我们早前回顾过的梯度爆炸和梯度消失问题提供了一种更为复杂的解决方案。你可以将 GRU 看作是 LSTM 的简化版本。
以下是本章将涉及的主题:
-
LSTM 网络
-
剖析 LSTM
-
LSTM 记忆块
-
可视化信息流动
-
计算竞争者记忆
-
LSTM 的变种及其性能
-
理解窥视孔连接
-
时机与计数的重要性
-
将我们的知识付诸实践
-
关于建模股市数据
-
数据去噪
-
实现指数平滑
-
一步预测问题
-
创建观察序列
-
构建 LSTM
-
结束语
处理复杂序列
在上一章中,我们讨论了人类如何倾向于按顺序处理事件。我们将日常任务分解成一系列较小的行动,而不会过多考虑。当你早上起床时,可能会选择先去洗手间,再做早餐。在洗手间,你可能会先洗澡,然后刷牙。有些人可能会选择同时完成这两个任务。通常,这些选择归结为个人偏好和时间限制。从另一个角度来看,我们做事的方式往往与大脑如何选择表示这些相对任务的重要性有关,这种选择是由它对近过去和远过去保存的信息所支配的。例如,当你早上醒来时,如果你住在一个有共享水供应的公寓楼里,你可能会倾向于先洗澡。
另一方面,如果你知道你的邻居正在度假,你可能会在某些日子推迟完成某些任务。事实证明,我们的大脑非常擅长选择、减少、分类并提供最有利的信息,以便对周围世界做出预测。
分解记忆
我们人类的大脑中有层次化的神经元,聚集在特定的区域,负责维护我们可能感知到的各种重要事件的详细且独特的表示。例如,考虑到颞叶,它包含了负责我们陈述性记忆或长期记忆的结构。这一部分通常被认为构成了我们对事件的意识回忆的范围。它提醒我们在世界的心理模型中,所有正在发生的一般事件,形成了对这些事件的语义记忆(有关其语义事实)以及事件发生的回忆(在情节记忆中)。一个语义事实可能是水的分子化合物由一个氢原子和两个氧原子组成。相反,一个情节事实可能是某个水池的水被污染了,因此不能饮用。记忆中的这些区分帮助我们有效地应对信息丰富的环境,使我们能够做出决策,优化我们可能有的任何目标。而且,有人甚至可能认为,做出这样的区分来划分信息,对于处理复杂的时间依赖数据序列至关重要。
最终,我们需要保持预测模型在长时间内的相关性,无论是用于创建互动聊天机器人,还是预测股价的走势。保持相关性不仅仅意味着了解最近发生了什么,还需要知道历史是如何展开的。毕竟,正如老话所说,历史往往会重演。因此,保持对所谓历史的记忆表示是很有用的。正如我们即将看到的,LSTM 正是为了实现这一目标而设计的。
LSTM 网络
看啊,LSTM 架构。这一模型以其复杂的信息路径和门控机制而著称,能够从它所接收到的输入中学习时间依赖的有意义表示。下图中的每一条线代表一个向量从一个节点传递到另一个节点,方向由箭头指示。当这些线条分开时,它们所携带的值会被复制到每一条路径上。来自前一时间步的记忆从单元的左上方进入,而来自前一时间步的激活值则从左下角进入。
这些框表示学习到的权重矩阵与某些通过激活函数传递的输入的点积。圆圈表示逐点操作,如逐元素向量乘法(*)或加法(+):
在上一章,我们看到了 RNN 如何通过时间上的反馈连接来存储近期输入的表示,通过激活值。这些激活值本质上可以被视为单元的短期记忆,因为它们主要受紧接着的前一时间步激活值的影响。遗憾的是,梯度消失问题使得我们无法利用发生在非常早期时间步(长期记忆)的信息来指导后续的预测。我们看到,构成隐藏状态的权重倾向于衰减或爆炸,因为误差在更多时间步中进行反向传播。我们该如何解决这个问题?我们如何才能有效地让信息流经时间步,像是让它流动,来影响序列中后期的预测?答案,当然,来自 Hochreiter 和 Schmidthuber,他们提出了在 RNN 中同时使用长期记忆 (c^((t-1))) 和短期记忆 (a^((t-1))) 的方法。
这种方法使得它们能够有效克服在长序列中进行相关预测的问题,通过实现一种能够有效保存远程事件相关记忆的 RNN 设计。实际上,这是通过采用一组信息门来完成的,这些信息门在保存和传递细胞状态方面表现出色,细胞状态编码了来自遥远过去的相关表示。这一重大突破已被证明适用于多种应用场景,包括语音处理、语言建模、非马尔可夫控制和音乐生成。
这里提供了进一步阅读的来源:
- 原始 LSTM 论文 Hochreiter 和 Schmidthuber:
www.bioinf.jku.at/publications/older/2604.pdf
解构 LSTM
如前所述,LSTM 架构依赖于一系列门,这些门可以独立地影响来自前一个时间步的激活值(a((t-1))*)以及记忆值(*c(**^(t-1)))。这些值在信息流经 LSTM 单元时被转化,最终在每次迭代中输出当前时间步的激活(at*)和记忆(*ct)向量。虽然它们的早期版本分别进入单元,但它们允许以两种大致的方式相互作用。在下面的图示中,门(用大写希腊字母 gama,或 Γ 表示)代表了对它们各自初始化的权重矩阵与先前激活和当前输入的点积应用的 sigmoid 激活函数:
比较最接近的已知亲戚
让我们尝试通过运用我们之前对 GRU 架构的知识来理解 LSTM 是如何工作的,我们在上一章中已经看到过它。正如我们很快会发现的那样,LSTM 只是 GRU 的一个更复杂版本,尽管它遵循了与 GRU 操作相同的基本原理。
GRU 记忆
记得 GRU 架构是通过更新门利用两个向量来计算其单元状态(或记忆)的。这两个向量分别是来自先前时间步的激活(c****t-1),以及一个候选向量(c ̴****t)。候选向量在每个时间步表现为当前单元状态的候选者,而激活则代表了 GRU 从前一个时间步的隐藏状态。这两个向量对当前单元状态的影响程度由更新门决定。这个门控制信息流,允许记忆单元用新的表示来更新自身,从而为后续的预测提供相关的信息。通过使用更新门,我们能够计算出给定时间步的新单元状态(c^t),如下所示:
正如我们所观察到的,GRU 使用更新门(Γu)及其逆门(1- Γu)来决定是用新值(c ̴**t**)更新记忆单元,还是保留前一个时间步的旧值(**c****(t-1)**)。更重要的是,GRU 利用一个更新门及其逆值来控制记忆值(c^t)。LSTM 架构则提出了一种更复杂的机制,并且在核心部分使用与 GRU 架构类似的方程来维持相关状态。但它到底是如何做到的呢?
LSTM 记忆单元
在下面的图示中,您会注意到 LSTM 单元顶部的直线,它表示该单元的记忆或细胞状态(c^t)。更技术性地讲,细胞状态由常数误差旋转环(CEC)定义,它本质上是一个递归自连接的线性单元。这个实现是 LSTM 层的核心组件,使得在反向传播过程中能够强制执行恒定的误差流动。本质上,它允许缓解其他 RNN 所遭遇的梯度消失问题。
CEC 防止误差信号在反向传播过程中迅速衰减,从而使得早期的表示能够得到良好保持,并传递到未来的时间步。可以将其视为信息高速公路,使得这种架构能够学习在超过 1,000 步的时间间隔内传递相关信息。研究表明,这在各种时间序列预测任务中是有效的,能够有效解决以前架构面临的问题,并处理噪声输入数据。尽管通过梯度裁剪(如我们在上一章所见)可以解决梯度爆炸问题,但梯度消失问题同样可以通过 CEC 实现来解决。
现在我们已经对细胞状态如何通过 CEC 的激活来表示有了一个高层次的理解。这个激活(即 c^t)是通过多个信息门的输入来计算的。LSTM 架构中不同门的使用使其能够控制通过各个单元的误差流动,从而帮助维持相关的细胞状态(简写为c):
将激活值和记忆单独处理
注意观察短期记忆(a*(t-1)*)和长期记忆(*c**(t-1))是如何分别流入该架构的。来自前一时刻的记忆通过图示的左上角流入,而来自前一时刻的激活值则从左下角流入。这是我们从已经熟悉的 GRU 架构中能够注意到的第一个关键区别。这样做使得 LSTM 能够同时利用短期激活值和网络的长期记忆(细胞状态),同时计算当前记忆(c*t*)和激活值(*a**t)。这种二元结构有助于维持时间上的持续误差流动,同时让相关的表示被传递到未来的预测中。在自然语言处理(NLP)中,这样的预测可能是识别不同性别的存在,或者某个词序列中存在复数实体的事实。然而,如果我们希望从一个给定的词序列中记住多个信息呢?如果我们想在较长的词序列中记住一个主题的多个事实呢?考虑机器问答的情况,以下是两个句子:
-
拿破仑被流放到圣赫勒拿岛已经有几个月了。他的精神已经衰弱,身体虚弱,但正是从他房间四周苍白绿色墙纸上滋生的潮湿霉菌中的砒霜,慢慢导致了他的死亡。
-
拿破仑在哪里?拿破仑是如何去世的?
LSTM 记忆块
为了能够回答这些问题,我们的网络必须有多个记忆单元,每个单元可以存储与我们研究对象——法国皇帝拿破仑·波拿巴——相关的准依赖信息。实际上,一个 LSTM 单元可以有多个记忆单元,每个单元存储输入序列中的不同表示。一个可能存储主题的性别,另一个可能存储有多个主题的事实,依此类推。为了清晰地展示,我们在本章中只描绘了每个图示中的一个记忆单元。我们这么做是因为理解一个单元的工作原理足以推断出一个包含多个记忆单元的记忆块的工作方式。LSTM 中包含所有记忆单元的部分被称为记忆块。架构的自适应信息门控由记忆块中的所有单元共享,并用于控制短期激活值(a(t-1)*)、当前输入(*Xt)和 LSTM 的长期状态(c*^t*)之间的信息流动。
忘记门的重要性
正如我们所注意到的,定义 LSTM 记忆单元状态(c*^t*)的方程与 GRU 的状态方程在本质上是相似的。然而,一个关键的区别是,LSTM 利用了一个新的门(Γf),即遗忘门,以及更新门来决定是否忘记在前一个时间步存储的值(c*^(t-1)*),或者将其包含在新单元记忆的计算中。以下公式描述了负责保持我们 LSTM 单元状态的 CEC(记忆单元控制单元)。它正是让 LSTM 能够有效记住长期依赖关系的公式。如前所述,CEC 是每个 LSTM 记忆单元特有的神经元,定义了在任何给定时间的单元状态。我们将从 LSTM 单元如何计算它的记忆单元中存储的值(C^t)开始:
这使得我们可以将来自候选值(c ̴**t)和前一个时间步的记忆值(c^(t-1))的信息,结合到当前的记忆值中。正如我们很快会看到的,这个遗忘门其实就是一个对矩阵级别的点积应用 sigmoid 激活函数,并加上一个偏置项,帮助我们控制从前一个时间步传递过来的信息流。
概念化差异
值得注意的是,遗忘门在保持单元状态方面与 GRU 架构所采用的机制存在一个重要的概念性区别,它们的目标是实现相似的效果。可以这样理解:遗忘门允许我们控制前一个单元状态(或记忆)在多大程度上影响当前的单元状态。而在 GRU 架构中,我们只是简单地暴露前一个时间步的全部记忆,或者只是新的候选值,很少在两者之间做出妥协。
GRU 单元状态的计算如下:
这种在暴露整个记忆和新的候选值之间的二元权衡实际上是可以避免的,正如 LSTM 架构所展示的那样。通过使用两个独立的门,每个门都有自己可学习的权重矩阵,来控制我们 LSTM 的单元状态,从而实现这一点。LSTM 单元状态的计算如下:
走进 LSTM
所以,让我们仔细看一下描述 LSTM 架构的整个方程组。我们将首先研究的门是遗忘门和更新门。与 GRU 不同,LSTM 使用这两个门来确定每个时间步的记忆值(c^t):
首先,让我们看看这些门是如何计算的。以下公式表明,这些门实际上只是将前一时刻的激活值与当前输入的点积,通过对应的权重矩阵(Wf和Wu分别用于遗忘门和输出门),再应用 sigmoid 函数的结果:
-
遗忘门 (ΓF) = sigmoid ( Wf at-1, ![ t ] + bF)
-
更新门 (ΓU) = sigmoid ( Wu at-1, ![ t ] + bu)
可视化信息流
这两个向量 (a*^(t-1)* 和 https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/5c963b23-c24f-448e-ae07-1e67f1f82c77.png t) 分别从 LSTM 单元的左下角进入,并在到达时被复制到每个门(ΓF 和ΓU)。然后,它们分别与各自门的权重矩阵相乘,再对它们的点积应用 sigmoid,并加上偏置项。正如我们所知,sigmoid 函数以其将输入压缩到零和一之间而闻名,因此每个门的值都在这个范围内。重要的是,每个权重矩阵是特定于给定门的(Wf用于遗忘门,Wu用于更新门)。权重矩阵(Wf和Wu)代表 LSTM 单元中的一部分可学习参数,并在反向传播过程中迭代更新,就像我们一直在做的那样。
计算单元状态
现在我们知道了两个门(更新门和遗忘门)分别代表什么,它们是如何计算的,我们可以继续理解它们如何在给定时间步影响我们 LSTM 的记忆(或状态)。请再次注意流向和流出门的不同信息路径。输入从单元格的左侧进入,经过转换并传播,直到它们到达 LSTM 单元的右侧,如下图所示:
正如我们所看到的,遗忘门(ΓF)的作用,字面上就是忘记来自前一个时间步的记忆值。同样,更新门(Γu)决定是否允许将潜在的候选值(c ^(̴t)) 纳入当前时间步。这两个门共同负责在给定时间步保留我们 LSTM 记忆的状态(c*^t*)。在数学上,这可以转化为以下公式:
- 当前记忆值 (c^t) = ( Γu * c ^(̴t) ) + (ΓF * c^(t-1) )*
正如我们提到的,每个门本质上表示一个介于零和一之间的值,因为我们通过非线性 sigmoid 函数将值压缩。我们知道,由于 sigmoid 的工作范围,大多数值往往非常接近零或接近一,因此我们可以将这些门看作是二进制值。这是有用的,因为我们可以将这些门想象成打开(1)让信息流通,或者关闭(0)。介于零和一之间的任何值都能让部分信息流入,但并不是全部。
所以,现在我们理解了这些门值是如何计算的,以及它们如何控制候选者值(c ̴**t)或前一个记忆状态(c*^(t-1))在当前状态计算中应具有的影响程度。LSTM 记忆的状态(c*^t)由之前展示的 LSTM 图中顶部的直线定义。实际上,这条直线(即常数误差环)非常擅长保持相关信息并将其传递到未来的时间步,以协助预测。
计算候选者记忆
我们现在知道了如何计算时间点(t)的记忆,但那么候选者(c ^(̴t))本身呢?毕竟,它在维护相关的记忆状态方面起着部分作用,特点是每个时间步出现的可能有用的表示。
这与我们在 GRU 单元中看到的想法相同,在那里我们允许在每个时间步使用候选者值更新记忆值。早些时候,在 GRU 中,我们使用了一个相关性门来帮助我们为 GRU 计算它。然而,在 LSTM 的情况下,这是不必要的,我们得到了一个更加简单且可以说更优雅的公式,如下所示:
- 候选者记忆值 (c ^(̴t)) = tanh ( Wc a^(t-1), ![ t ] + bc)
这里,Wc 是一个权重矩阵,在训练开始时初始化,并随着网络训练而迭代更新。这个矩阵与前一时刻的激活值(a(*t*-1)*)和当前输入(*xt)的点积,再加上偏置项(bc),通过 tanh 激活函数得出候选者值(c ̴t)。然后,这个候选者向量与我们在当前时间看到的内存状态(c*^t*)的更新门值进行逐元素相乘。在下图中,我们说明了候选者记忆向量的计算,并展示了如何将信息传递到下一个时间步,影响最终的记忆单元状态(c**t):
请记得,tanh 激活函数有效地将输出压缩到 -1 和 1 之间,因此候选者向量(c ^(̴t))的值总是出现在这个范围内。现在我们理解了如何计算 LSTM 的单元状态(或记忆)在给定时间步的值。我们还了解了在更新门调整之前,候选者值是如何计算的,然后传递到当前记忆的计算中,(c*^t*)。
计算每个时间步的激活值
正如我们之前在 LSTM 架构中指出的,它分别接收来自前一时间步的记忆和激活值。这与我们在 GRU 单元中做出的假设不同,在 GRU 中我们有 a^t = ct。这种双重数据处理方式使得我们能够在很长的序列中保留相关的表示,甚至可能达到 1,000 个时间步!然而,激活值始终与每个时间步的记忆(ct*)功能相关。因此,我们可以通过首先对记忆(*ct)应用 tanh 函数,然后将结果与输出门值(Γo)进行逐元素计算,来计算某个时间步的激活值。请注意,在这一步我们并不初始化权重矩阵,而只是对(c^t)向量中的每个元素应用 tanh 函数。数学表达式如下:
- 当前激活值 (a^t ) = Γo * tanh(c^t)
在这里,输出门不过是另一个 sigmoid 函数,应用于一个可学习的权重矩阵的点积,其中包含来自前一时间步的激活值和当前时刻的输入,具体如下:
- 输出门 (Γo) = sigmoid ( Wo a^(t-1), ![ t ] + bo)
存在于每个单独门(分别为遗忘门、更新门、候选门和输出门)的权重矩阵(Wf, Wu, Wc, 和 Wo)可以被视为 LSTM 单元的可学习参数,并在训练过程中不断更新。在这里提供的图示中,我们可以观察到每个权重矩阵是如何塑造进入各自门的输入,随后将结果传递到架构的其他部分:
LSTM 的变种与性能
你已经看到了 LSTM 的一个变种,即 GRU。我们已经广泛讨论了这两种架构的不同。还有其他一些变种同样值得注意。其中之一是 LSTM 的变种,它包含了被称为窥视连接(peephole connections)的东西。这些连接允许信息从细胞状态流回到信息门(遗忘门、更新门和输出门)。这使得我们的 LSTM 门在计算当前时间的门值时,可以“窥视”来自前一时间步的记忆值。
了解窥视连接(peephole connections)
窥视孔连接的核心思想是捕捉时间延迟信息。换句话说,我们希望在建模过程中包括序列子模式之间时间间隔传递的信息。这不仅对于某些语言处理任务(如语音识别)相关,而且对于从机器运动控制到计算机生成音乐中保持复杂节奏的其他众多任务也非常重要。以前处理语音识别等任务的方法使用了隐马尔可夫模型(HMMs)。这些本质上是统计模型,基于隐藏状态转移序列估计一组观察值的概率。在语音处理的例子中,观察值被定义为对应语音的数字信号片段,而马尔可夫隐藏状态则是我们希望识别为单词的音素序列。如你所见,这个模型中并未考虑音素之间的延迟,无法判断某一数字信号是否对应某个特定的单词。这些信息在 HMM 中通常会被丢弃,但在我们判断是听到句子I want to open my storage unit before…还是I want to open my storage unit, B-4时,延迟信息可能至关重要。在这些例子中,音素之间的延迟很可能区分出B-4和before。虽然 HMM 超出了本章讨论的范围,但它帮助我们理解了 LSTM 如何通过利用时间序列之间的延迟,克服了以往模型的局限。
你可以在以下链接查看窥视孔论文:ftp://ftp.idsia.ch/pub/juergen/TimeCount-IJCNN2000.pdf:
请注意,窥视孔修改可以应用于任意一个门。你可以选择对所有门实施该修改,或者仅对其中的一部分实施。
以下方程展示了在添加窥视孔连接以包含前一单元状态时,计算各门值时执行的计算:
-
遗忘门 (ΓF) = sigmoid ( Wf c^(t-1) , a^(t-1), ![ t ] + bF)
-
更新门 (ΓU) = sigmoid ( Wu c^(t-1) , a^(t-1), ![ t ] + bu)
-
输出门 (Γo) = sigmoid ( Wo c^(t-1) , a^(t-1), ![ t ] + bo)
因此,窥视孔修改在数学上简化为在计算给定门值时执行额外的矩阵级别乘法。换句话说,门的值现在可以通过与给定门的权重矩阵计算点积来容纳前一单元状态。然后,得到的点积与前两个点积及偏置项一起求和,再通过 sigmoid 函数进行处理。
时序和计数的重要性
让我们通过另一个概念性例子,进一步巩固使用时间间隔相关信息来指导顺序预测的理念,在这个例子中,这类信息被认为对于准确预测至关重要。举个例子,考虑一个人类鼓手,必须执行一系列精确的运动指令,这些指令对应着精确的节奏流。鼓手根据时间来安排他们的动作,并按顺序依赖地计数他们的进度。在这里,代表生成序列模式的信息,至少部分地,是通过这些事件之间的时间延迟来传达的。自然,我们会有兴趣人工复制这种复杂的序列建模任务,这种任务在这些互动中发生。从理论上讲,我们甚至可以利用这种方法从计算机生成的诗歌中提取新的押韵模式,或者创造可以在未来的奥运会中与人类竞争的机器人运动员(无论我们集体决定出于什么理由认为这是个好主意)。如果你希望进一步研究如何通过窥视连接来增强对复杂时间延迟序列的预测,我们鼓励你阅读原始的 LSTM 窥视点修改论文,链接如下:
www.jmlr.org/papers/volume3/gers02a/gers02a.pdf
探索其他架构变体
除了本书中涉及的 RNN 变体外,还有许多其他 RNN 变体(参见深度门控 RNNs,姚等人,2015;或时钟 RNNs,Koutnik 等人,2014)。这些变体每个都适合在特定任务中使用——普遍的共识是 LSTM 在大多数时间序列预测任务中表现出色,并且可以相当修改以适应大多数常见和更复杂的使用案例。事实上,作为进一步阅读,我们推荐一篇优秀的文章(LSTM:一次搜索空间奥德赛,2017:arxiv.org/abs/1503.04069
),该文章比较了不同 LSTM 变体在多种任务中的表现,如语音识别和语言建模。由于该研究使用了大约 15 年的 GPU 时间来进行实验,因此它成为了一项独特的探索性资源,供研究人员更好地理解不同 LSTM 架构的考虑因素及其在建模顺序数据时的效果。
运用我们的知识
现在我们已经充分理解了 LSTM 的工作原理,以及它在特定任务中尤为擅长的方面,是时候实施一个真实世界的例子了。当然,时间序列数据可以出现在各种场景中,从工业机器的传感器数据到表示来自遥远星辰的光谱数据。然而,今天我们将模拟一个更常见但臭名昭著的用例。我们将使用 LSTM 来预测股价的波动。为此,我们将使用标准普尔(S&P)500 数据集,并随机选择一只股票准备进行序列建模。该数据集可以在 Kaggle 上找到,包含了所有当前 S&P 500 大市值公司在美国股市交易的历史股价(开盘价、最高价、最低价和收盘价)。
关于建模股市数据
在继续之前,我们必须提醒自己,市场趋势中蕴含着固有的随机性。也许你更倾向于相信有效市场假说,而不是非理性市场理论。无论你个人对股票波动背后的内在逻辑持何种信念,现实是,市场中有大量的随机性,常常连最具预测性的模型也无法捕捉。投资者行为难以预见,因为投资者往往出于不同的动机进行操作。即使是一般的趋势也可能具有欺骗性,正如最近比特币资产泡沫在 2017 年底的崩溃所证明的那样;还有许多其他例子(2008 年全球危机、津巴布韦的战后通货膨胀、1970 年代的石油危机、一战后德国的经济困境、荷兰黄金时代的郁金香狂热,等等,甚至可以追溯到古代)。
事实上,许多经济学家曾引用股市波动中似乎固有的随机性。普林斯顿大学经济学家伯顿·马尔基尔在近半个世纪前的著作《华尔街的随机漫步》中强调了这一点。然而,仅仅因为我们无法获得完美的预测结果,并不意味着我们不能尝试将我们的猜测引导到比喻上的“正确方向”。换句话说,这种序列建模的尝试在预测市场短期内的整体趋势时,仍然可能是有用的。那么,我们现在就导入数据,看看我们在这里处理的是什么内容,不再赘述。请随时跟随您的市场数据,或者使用我们所用的相同数据集,您可以在以下网址找到: www.kaggle.com/camnugent/sandp500
。
导入数据
数据存储在逗号分隔值(CSV)文件中,可以通过 pandas 的 CSV 读取器导入。我们还将导入标准的 NumPy 和 Matplotlib 库,并使用来自 sklearn 的MinMaxScaler
库,以便在合适的时候重塑、绘制和归一化我们的数据,如以下代码所示:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
df = pd.read_csv('D:/Advanced_Computing/Active_Experiments/LSTM/
stock_market/all_stocks_5yr.csv')
df.head()
我们得到的输出如下:
排序并可视化趋势
首先,我们将从数据集中 505 只不同的股票中随机选择一只。你可以选择任何一只来重复进行这个实验。我们还将按日期对 DataFrame 进行排序,因为我们处理的是时间序列预测问题,在这种问题中,序列的顺序对于预测任务的价值至关重要。接着,我们可以通过按顺序绘制高价和低价(某一天)的走势图来可视化我们的数据。这有助于我们直观地查看美国航空集团(股票代码:AAL
)在五年期(2013-2017 年)内的股票价格走势,如下所示:
plt.figure(figsize = (18,9))
plt.plot(range(aal.shape[0]),(aal['low']), color='r')
plt.plot(range(aal.shape[0]),(aal['high']), color = 'b')
plt.xticks(range(0,aal.shape[0],60),aal['date'].loc[::60],rotation=60)
plt.xlabel('Date',fontsize=18)
plt.ylabel('Price',fontsize=18)
plt.show()
从 DataFrame 到张量
我们观察到,尽管高价和低价略有不同,但它们的走势显然遵循相同的模式。因此,使用这两个变量进行预测建模是多余的,因为它们高度相关。当然,我们可以从这两个中选择一个,但也可以在任何给定的市场日,将两个价格指标取个平均值。我们将把包含某一天的高价和低价的列转换为 NumPy 数组。具体做法是通过调用各自列的values
,这将返回每列的 NumPy 表示。然后,我们可以使用这些新定义的列来计算一个第三个 NumPy 数组,存储所有给定观察值的中间价(计算方式为 (high + low) /2),如下面所示:
high_prices = aal.loc[:,'high'].values
low_prices = aal.loc[:,'low'].values
mid_prices = (high_prices+low_prices)/2.0
mid_prices.shape
----------------------------------------------------
Output:
(1259,) ----------------------------------------------------mid_prices
----------------------------------------------------
Output:
array([14.875, 14.635, 14.305, ..., 51.07 , 50.145, 51.435])
我们注意到,总共有1259
个观察值,每个观察值对应于我们 AAL 股票在某一天的中间价。我们将使用这个数组来定义我们的训练数据和测试数据,然后将它们按序列批次准备好,供 LSTM 模型使用。
拆分数据
现在,我们将把整个实例范围(即mid_prices
变量)拆分为训练集和测试集。稍后,我们将分别使用这些数据集生成训练和测试序列:
train_data = mid_prices[:1000]
test_data = mid_prices[1000:1251]
train_data = train_data.reshape(-1,1) #scaler.fit_transform
test_data = test_data.reshape(-1,1) #scaler.fit_transform
print('%d training and %d total testing instances'%(len(train_data),
len(test_data)))
-----------------------------------------------------------------
Output:
1000 training and 251 total testing instances
绘制训练和测试集的划分
在下面的截图中,我们简单地展示了两个子图,用于可视化未归一化的 AAL 股票数据的训练和测试部分。你可能会注意到,图表的比例并不一致,因为训练数据包含 1,000 个观察值,而测试数据仅包含大约四分之一的观察值。同样,测试数据的价格区间出现在 40 到 57 美元之间,而训练数据则出现在 0 到 50+美元之间,并覆盖了一个更长时间范围的观察期。请记住,测试数据只是我们预处理后的 AAL 中间股票价格数据中,紧跟前 1,000 个观察值后的时间序列:
#Subplot with training data
plt.subplot(1,2,1)
plt.plot(range(train_data.shape[0]),train_data,color='r',label='Training split')
plt.title('Train Data')
plt.xlabel('time')
plt.ylabel('Price')
plt.legend()
#Subplot with test data
plt.subplot(1,2,2)
plt.plot(range(test_data.shape[0]),test_data,color='b',label='Test Split')
plt.title('Test Data')
plt.xlabel('time')
plt.ylabel('Price')
plt.legend()
#adjust layout and plot all
plt.tight_layout()
plt.show()
上述代码块生成了以下输出:
窗口归一化
在我们将数据划分为更小的序列进行训练之前,我们必须将所有数据点缩放到零和一之间,正如我们迄今为止所做的那样。回想一下,这种表示方式使得我们的网络更容易从展示的数据中捕捉到相关的表示,并且这是深度学习社区内外针对各种机器学习(ML)任务的常见标准化方法。
然而,与之前的方法不同,我们必须调整我们的标准化策略来适应这个特定的时间序列问题。为此,我们采用了窗口标准化方法。为什么?因为这使我们能够在较小的批次中对数据进行标准化,而不是一次性对整个数据集进行标准化。早些时候,当我们可视化整个股票数据的时间序列时,我们注意到了一些问题。事实证明,不同年份的数据在不同时间段内具有不同的值范围。因此,整体标准化过程会导致时间序列早期出现的值接近零。这会阻止我们的模型按预期区分相关的趋势,并且在训练网络时,严重削弱了能够捕捉到的表示。你当然可以选择更宽的特征范围——但是,这也会对学习过程产生不利影响,因为人工神经网络(ANNs)在处理零到一之间的值时效果最好。
所以让我们实现这个窗口标准化方案,如下代码块所示:
#Window size to normalize data in chunks
normalization_window = 250
#Feature range for normalization
scaler = MinMaxScaler(feature_range=(0, 1))
# Loop over the training data in windows of 250 instances at a time
for i in range(0,1000,normalization_window):
# Fit the scaler object on the data in the current window
scaler.fit(train_data[i:i+normalization_window,:])
# Transform the data in the current window into values between the chosen feature range (0 and 1)
train_data[i:i+normalization_window,:] = scaler.transform(train_data[i:i+normalization_window,:])
# normalize the the test data
test_data=scaler.fit_transform(test_data)
我们刚才采用的窗口标准化方法有一个问题值得提及。批量标准化数据可能在每个批次的结尾引入连续性的中断,因为每个批次都是独立标准化的。因此,建议选择一个合理的窗口大小,以避免在训练数据中引入过多的中断。就我们的情况而言,我们将选择 250 天的窗口大小,因为这不仅能完美地划分我们的训练集和测试集,而且在标准化整个数据集时(即 1000 / 250 = 4)只会引入四个潜在的连续性中断。我们认为这对于当前的演示用例是可控的。
数据去噪
接下来,我们将去噪我们的股票价格数据,以去除当前存在的那些不太相关的市场波动。我们可以通过以指数递减的方式加权数据点来做到这一点(也就是指数平滑)。这使得我们能够让近期的事件对当前数据点的影响大于远古的事件,从而每个数据点都可以作为当前值和时间序列中前面值的加权递归函数来表示(或平滑)。这可以用数学公式表示如下:
前面的公式表示了给定数据点(x[t])的平滑转换,作为加权项 gamma 的函数。结果(S[t])是给定数据点的平滑值,而 gamma 项表示一个介于零和一之间的平滑因子。衰减项使我们能够将可能对特定时间间隔内数据变化(即季节性)存在的假设编码到我们的预测模型中。因此,我们将平滑绘制的股价曲线与时间的关系。这是时间序列建模中常用的信号预处理技术,有助于去除数据中的高频噪声。
实施指数平滑
因此,我们通过循环遍历每个中间价格值来转换我们的训练数据,更新平滑系数,然后将其应用于当前价格值。请注意,我们使用之前展示的公式来更新平滑系数,这使我们能够根据当前和前一个观测值的加权函数对时间序列中的每个观测值进行加权:
Smoothing = 0.0 #Initialize smoothing value as zero
gamma = 0.1 #Define decay
for i in range(1000):
Smoothing = gamma*train_data[i] + (1-gamma)*Smoothing # Update
smoothing value
train_data[i] = Smoothing # Replace datapoint with smoothened value
可视化曲线
使用下面的图示,我们可以可视化平滑前后数据点曲率的差异。如您所见,紫色图表显示了一个更加平滑的曲线,同时保持了股价随时间变化的总体走势。
如果我们使用未平滑的数据点,我们很可能会很难使用任何类型的机器学习技术来训练预测模型:
表示是关键,且始终存在准确性和效率之间的最佳平衡。一方面,使用简化的表示可能使机器更快地从数据中学习。然而,简化到更易管理的表示的过程可能会导致有价值的信息丢失,这些信息可能不再被我们的统计模型捕捉到。另一方面,处理全部信息会引发计算复杂性的洪流,这种复杂性既没有必要的资源来建模,也不常需要考虑来解决眼前的问题。
执行一步预测
接下来,我们将解释一些基准模型。这将帮助我们更好地评估 LSTM 网络的有效性。我们进行的平滑处理将帮助我们实现这些基准模型,这些模型将用于基准测试我们 LSTM 模型的性能。我们将尝试使用一些相对简单的算法。为此,我们将使用两种技术,分别是简单移动平均和指数移动平均算法。这两种方法本质上都是进行一步预测,将训练数据中下一个时间序列的值预测为前一序列值的平均值。
为了评估每种方法的有效性,我们可以使用均方误差(MSE)函数来评估预测值与实际值之间的差异。回顾一下,这个函数实际上是在每个时间步长上,对预测值和实际结果之间的误差进行平方运算。我们还将通过将预测的时间序列进程与实际的股票价格时间序列进程叠加,直观地验证我们的预测。
简单移动平均预测
对于简单移动平均法,我们在预测时间序列中的下一个值时,会对给定窗口内的过去观测值进行等权重处理。在这里,我们计算给定时间间隔内股票价格的算术平均值。这个简单的算法可以用以下数学公式表示:
采用短期平均(即在几个月内)将使模型能够快速响应价格变化,而长期平均(即在几年内)通常对价格变化的反应较慢。在 Python 中,这一操作可以转化为以下代码:
window_size = 26 # Define window size
N = train_data.size # and length of observations
std_avg_predictions = [] # Empty list to catch std
mse_errors = [] # and mse
for i in range(window_size,N):
# Append the standard mean per window
std_avg_predictions.append(np.mean(train_data[i-window_size:i]))
# Compute mean squared error per batch
mse_errors.append((std_avg_predictions[-1]-train_data[i])**2)
print('MSE error for standard averaging: %.5f'
(0.5*np.mean(mse_errors)))
MSE error for standard averaging: 0.00444
我们通过再次遍历我们的训练数据,使用预定义的窗口大小,并收集每个数据点的批次均值以及均方误差(MSE),从而收集了简单平均预测。正如 MSE 值所示,我们的简单平均预测模型表现得还不错。接下来,我们可以将这些预测值绘制出来,并将其与我们股票价格的真实时间序列进程叠加在一起,从而直观地展示这种方法的表现:
plt.figure(figsize = (19,6))
plt.plot(range(train_data.shape[0]),train_data,color='darkblue',label='Actual')
plt.plot(range(window_size,N),std_avg_predictions,color='orange',label='Predicted')
plt.xticks(range(0,aal.shape[0]-len(test_data),50),aal['date'].loc[::50],rotation=45)
plt.xlabel('Date')
plt.ylabel('Mid Price')
plt.legend(fontsize=18)
plt.show()
我们得到以下图表:
在简单平均预测图中,我们注意到我们的预测确实捕捉到了股票价格的总体趋势,但在时间序列的各个独立点上并没有提供准确且可靠的预测。有些预测可能看起来非常准确,但大多数都偏离了实际值,而且它们相对于真实值变化的速度也太慢,无法做出任何有利可图的预测。如果你想更直观地了解预测的准确度,可以单独打印出预测数组的各个值,并与训练数据中的实际值进行比较。接下来,我们将继续进行第二个基准测试。
指数移动平均预测
指数移动平均比简单的移动平均稍微复杂一些;然而,我们已经熟悉我们将使用的公式。从本质上讲,我们将使用与平滑数据时相同的方程式。不过,这次我们将使用指数平均法来预测时间序列中的下一个数据点,而不是重新调整当前的数据点:
ema_avg_predictions = []
mse_errors = []
EMA = 0.0
ema_avg_predictions.append(EMA)
gamma = 0.5
window_size = 100
N = len(train_data)
for i in range(1,N):
EMA = EMA*gamma + (1.0-gamma)*train_data[i-1]
ema_avg_predictions.append(EMA)
mse_errors.append((ema_avg_predictions[-1]-train_data[i])**2)
print('MSE error for EMA averaging: %.5f'%(0.5*np.mean(mse_errors)))
MSE error for EMA averaging: 0.00018
正如我们所见,简单移动平均(en.wikipedia.org/wiki/Moving_average#Simple_moving_average
)赋予过去的观察值相同的权重。相反,我们使用指数函数来控制先前数据点对未来数据点的影响程度。换句话说,我们能够随着时间推移,给早期数据点分配逐渐减少的权重。这种技术允许建模者通过修改衰减率(gamma),将先验假设(例如季节性需求)编码到预测算法中。当与简单平均法计算得到的 MSE 相比,基于一步前的指数平均法得到的 MSE 要低得多。让我们绘制一张图表,直观地检查我们的结果:
plt.figure(figsize = (19,6))
plt.plot(range(train_data.shape[0]),train_data,color='darkblue',label='True')
plt.plot(range(0,N),ema_avg_predictions,color='orange', label='Prediction')
plt.xticks(range(0,aal.shape[0]-len(test_data),50),aal['date'].loc[::50],rotation=45)
plt.xlabel('Date')
plt.ylabel('Mid Price')
plt.legend(fontsize=18)
plt.show()
我们得到如下图表:
一步前预测的问题
非常棒!看起来,给定一组前几天的数据,我们几乎可以完美预测第二天的股价。我们甚至不需要训练复杂的神经网络!那么,为什么还要继续呢?事实证明,提前一天预测股价并不能让我们变成百万富翁。移动平均线本质上是滞后指标,它们仅在股价开始跟随某一特定趋势后,才能反映市场的重大变化。由于我们的预测与事件实际发生之间时间跨度较短,当这种模型反映出明显的趋势时,市场的最佳进入点往往已经过去。
另一方面,使用这种方法尝试预测多个时间步的未来股价也不会成功。我们实际上可以用数学来说明这一概念。假设我们有一个数据点,我们希望使用指数移动平均法预测两步以后的股价。换句话说,我们将不会使用(X[t + 1])的真实值,而是使用我们的预测值来计算接下来一天的股价。回顾一下,一步前预测的方程式定义如下:
假设数据点*X[t]的值为 0.6,X[t-1]的EMA为 0.2,我们选择的衰减率(gamma)为 0.3。那么,我们对X[t-1]*的预测可以如下计算:
-
= 0.3 x 0.2 + (1 – 0.3) x 0.6
-
= 0.06 + (0.7 x 0.6)
-
= 0.06 + 0.42 = 0.48
所以,0.48 既是我们对X[t-1]的预测值,也是当前时间步的EMA。如果我们使用相同的公式来计算下一时间步(X[t-2])的股价预测,就会遇到一些问题。以下方程式展示了这一难题,其中EMA[t] = X[t + 1] = 0.48:
因此,无论我们选择什么样的 gamma,由于*EMA[t]和X[t + 1]*持有相同的值,*X[t + 2]的预测将与X[t + 1]的预测相同。这对于任何超过一个时间步长的X[t]*预测都适用。实际上,指数加权移动平均线(EMA)常常被日内交易者用作理性检查,他们用它来评估和验证市场的重要波动,尤其是在可能快速变化的市场中。所以,现在我们已经使用一步前移平均预测建立了一个简单的基准,我们可以开始构建更复杂的模型,来预测未来更远的价格。
很快,我们将构建一组神经网络并评估它们的性能,看看 LSTM 在预测股价走势任务中的表现如何。我们将再次使用一个简单的前馈神经网络作为基准,并逐步构建更复杂的 LSTM 以比较它们的性能。然而,在我们继续之前,必须准备好数据。我们需要确保我们的网络能够接收一系列训练数据,才能对下一个序列值(我们股票的缩放中价)进行预测。
创建观测序列
我们使用以下函数来创建训练和测试序列,供我们训练和测试网络。该函数接受一组时间序列股价,并将其组织成一组n个连续值的片段,形成一个给定的序列。关键的不同之处在于,每个训练序列的标签将对应于四个时间步后的股价!这与我们之前使用移动平均法有所不同,因为移动平均法只能预测股价一个时间步的变化。因此,我们生成数据序列,使得我们的模型能够预测未来四个时间步后的股价。
我们定义了一个look_back
值,它表示我们在给定的观测中保留的股价数量。在我们的案例中,我们实际上允许网络回顾过去7
个价格值,然后再让它预测四个时间步后我们的股价会发生什么变化:
def create_dataset(dataset, look_back=7, foresight=3):
X, Y = [], []
for i in range(len(dataset)-look_back-foresight):
obs = dataset[i:(i+look_back), 0] # Sequence of 7 stock prices
as features forming an observation
# Append sequence
X.append(obs)
# Append stock price value occurring 4 time-steps into future
Y.append(dataset[i + (look_back+foresight), 0])
return np.array(X), np.array(Y)
我们使用create_dataset
函数来生成序列及其相应标签的数据集。此函数在我们的时间序列数据(即train_data
变量)上调用,并接受两个额外的参数。第一个参数(look_back
)表示每个观察序列中希望有多少个数据点。在我们的例子中,我们将创建包含七个数据点的序列,表示时间序列中某一点之前的七个中间价格值。同样,第二个参数(foresight
)表示从观察序列的最后一个数据点到我们想要预测的目标数据点之间的步数。因此,我们的标签将反映每个训练和测试序列未来四个时间步的滞后。我们通过使用步长为一的方式,从原始训练数据中重复创建训练序列及其标签的方法。最终,我们将得到一个包含 990 个观察序列的训练数据集,每个序列的标签对应于四个时间步后达到的股票价格。虽然我们的look_back
和foresight
值在某种程度上是任意的,但我们鼓励你尝试不同的值,以评估较大的look_back
和foresight
值如何分别影响模型的预测能力。在实际操作中,你会发现这两个值在两端会有递减的回报。
调整数据形状
接下来,我们简单地调整训练集和测试集的序列形状以适应我们的网络。我们准备一个三维张量,维度为(时间步长,1,特征),这将对测试不同的神经网络模型非常有用:
x_train = np.reshape(x_train, (x_train.shape[0], 1, x_train.shape[1]))
x_test = np.reshape(x_test, (x_test.shape[0], 1, x_test.shape[1]))
x_train.shape
(990, 1, 7)
进行一些导入
现在我们准备好最终构建和测试一些神经网络架构,并看看它们在预测股市趋势任务中的表现。我们将从导入相关的 Keras 层开始,以及一些回调函数,回调函数可以让我们在模型训练过程中进行交互,以便保存模型或在我们认为合适的时候停止训练:
from keras.models import Sequential
from keras.layers import LSTM, GRU, Dense
from keras.layers import Dropout, Flatten
from keras.callbacks import ModelCheckpoint, EarlyStopping
基准神经网络
正如我们之前提到的,开始时使用较简单的模型进行检查总是好的,然后再逐步过渡到更复杂的模型。数据建模人员往往容易被所谓的强大模型吸引,但很多时候,这些复杂的模型可能并不一定是完成任务所必需的。在这些情况下,使用较简单(且通常计算开销较小)的模型来建立一个合适的基准,进而评估使用更复杂模型的增值效果,会更好。抱着这种精神,我们将构建两个基准模型。每个基准模型将展示特定类型网络在任务中的表现。我们将使用简单的前馈网络来建立所有神经网络的初步基准。然后,我们将使用基本的 GRU 网络来建立递归网络的基准。
构建前馈网络
虽然前馈网络是您非常熟悉的网络,但这种体系结构进行了一些修改,使其适合当前的任务。例如,最后一层是一个只有一个神经元的回归器层。它还使用线性激活函数。至于损失函数,我们选择了平均绝对误差(MAE)。我们还选择了adam
优化器来执行此任务。所有未来的网络将实现相同的最后一层、损失和优化器。我们还将在一个函数中嵌套构建和编译模型,以便我们可以轻松测试多个网络,就像我们迄今为止所做的那样。以下代码块显示了如何实现这一点:
def feed_forward():
model = Sequential()
model.add(Flatten())
model.add(Dense(128, activation='relu'))
model.add(Dense(1, activation='linear'))
model.compile(loss='mae', optimizer='adam')
return model
递归基准
接下来,我们将构建一个简单的 GRU 网络来建立一个递归基准。我们指定正确的输入形状,并添加了一个小的递归 dropout 比例。请记住,这将对后续时间步骤应用相同的 dropout 方案,比简单的 dropout 方案更好地保留时间信息。我们还包括了一个小比例的神经元随机 dropout。我们建议您分别进行实验,保存我们当前正在进行的实验,以了解在不同 dropout 策略下 RNN 性能的差异:
def simple_gru():
model = Sequential()
model.add(GRU(32, input_shape=(1, 7), dropout=0.1, recurrent_dropout=0.1))
model.add(Dense(1, activation='linear'))
model.compile(loss='mae', optimizer='adam', metrics =
['mean_absolute_error'])
return model
构建 LSTM 模型
现在我们已经有了一些基准模型,让我们继续构建这一章的重点:一个 LSTM 模型。我们首先从一个普通的单层 LSTM 开始,没有使用任何 dropout 策略,配置 32 个神经元,如下所示:
def simple_lstm():
model = Sequential()
model.add(LSTM(32, input_shape=(1, 7)))
model.add(Dense(1, activation='linear'))
model.compile(loss='mae', optimizer='adam')
return model
我们将 LSTM 层连接到我们的密集回归层,并继续使用相同的损失和优化器函数。
堆叠的 LSTM
接下来,我们简单地将两个 LSTM 层堆叠在一起,就像我们在上一章中对 GRU 所做的那样。我们将看到这是否有助于网络记住我们股票数据中更复杂的时间相关信号。我们对两个 LSTM 层都应用了 dropout 和递归 dropout 方案,如下所示:
def lstm_stacked():
model = Sequential()
model.add(LSTM(16, input_shape=(1, 7), dropout=0.1, recurrent_dropout=0.2, return_sequences=True))
model.add(LSTM(16, dropout=0.1, recurrent_dropout=0.2))
model.add(Dense(1, activation='linear'))
model.compile(loss='mae', optimizer='adam')
return model
现在我们准备运行实验并评估结果。我们可以通过 MSE 指标进行评估,以及通过将模型的预测结果与实际预测结果叠加进行视觉解释。我们已经构建了一些函数,帮助我们在每次训练会话结束时可视化我们的结果。
使用辅助函数
在我们开始训练我们的网络之前,我们可以构建一些辅助函数,这些函数可以在模型训练完成后帮助我们了解模型的性能。前者plot_losses
函数简单地绘制训练损失和验证损失,使用我们模型的history
对象。请记住,这是一个默认的回调函数,提供了在会话中计算的训练和验证损失的字典:
def plot_losses(network):
plt.plot(network.history['loss'], label='loss')
plt.plot(network.history['val_loss'], label='val loss')
plt.legend()
plt.show()
接下来,我们将使用plot_predictions
函数绘制模型在我们隔离的测试集上的预测,并将其叠加到测试集的实际标签上。这与我们之前在一步预测中的做法精神相似。唯一的区别是,现在我们将可视化由我们的网络预测的三步预测趋势,如下所示:
def plot_predictions(model, y_test=y_test):
preds = model.predict(x_test)
plt.figure(figsize = (12,6))
plt.plot(scaler.inverse_transform(preds.reshape(-1,1)),
label='generated', color='orange')
plt.plot(scaler.inverse_transform(y_test.reshape(-1,1)),
label='Actual')
plt.legend()
plt.show()
训练模型
最后,我们构建了一个训练函数,帮助我们为每个网络启动训练会话,在每个周期保存模型权重,并在训练会话结束时可视化模型表现。
这个函数可以接受一个模型列表,并对每个模型执行上述步骤。因此,在运行以下代码单元后,请准备好进行一次简短/长时间的散步(取决于你的硬件配置)。
def train_network(list, x_train, y_train, epochs=5):
for net in list:
network_name = str(net).split(' ')[1]
filepath = network_name + "_epoch-{epoch:02d}-loss-
{loss:.4f}-.hdf5"
print('Training:', network_name)
checkpoint = ModelCheckpoint(filepath, monitor='loss',
verbose=0, save_best_only=True, mode='min')
callbacks_list = [checkpoint]
model = net()
network = model.fit(x_train, y_train,
validation_data=(x_test, y_test),
epochs=epochs,
batch_size=64,
callbacks=callbacks_list)
model.summary()
plot_predictions(model, y_test)
return network, model
all_networks = [feed_forward, simple_gru, simple_lstm, lstm_stacked]
train_network(all_networks, x_train, y_train, epochs=50)
可视化结果
最后,我们将展示模型的预测与实际价格的对比,如下图所示。请注意,虽然简单的 LSTM 表现最佳(MAE 为 0.0809),但它与简单的前馈神经网络的表现非常接近,后者在设计上具有比 LSTM 网络更少的可训练参数。
你可能会想,怎么做呢?嗯,虽然 LSTM 在编码复杂的时间依赖信号方面非常擅长,但这些信号必须首先出现在我们的数据中:
通过查看过去七个中间价格来预测未来,能够传递的信息是有限的。在我们的案例中,似乎 LSTM 能够为预测任务构建的表示与前馈网络所构建的表示相匹配。LSTM 在此上下文中可能能够建模很多复杂信号,但这些信号似乎在我们的数据集中并不存在。例如,在预测标签x[t + 3]时,我们并未设计性地包含关于市场在时间t+1或t+2的任何信息。此外,可能还存在其他变量,而非过去的中间股票价格,更能与股市的未来走势相关。例如,社交媒体情绪(如 Twitter,参考:arxiv.org/pdf/1010.3003.pdf
)已被证明能与股票价格的变动相关,最多提前七天!事实证明,获胜的情感是冷静,而非快乐或神经质,这与股市在最多提前一周的变动最为一致。因此,包含代表其他类型和信息来源的特征,可能有助于提高我们的 LSTM 模型的表现,相比于基准模型。
结束评论
请注意,这并不一定意味着通过引入社交媒体数据,可以更好地预测所有行业中所有股票的走势。然而,这确实说明了我们的观点,即基于启发式的特征生成还有一些空间,可能允许利用额外的信号来实现更好的预测结果。为了对我们的实验做一些总结评论,我们还注意到,简单的 GRU 和堆叠的 LSTM 都具有更平滑的预测曲线,并且不太容易受到噪声输入序列的影响。它们在保持股票整体趋势方面表现得非常好。这些模型的外部精度(通过预测值与实际值之间的 MAE 来评估)告诉我们,它们的表现略逊色于前馈网络和简单的 LSTM。然而,根据具体的使用案例,我们可能更倾向于使用具有更平滑曲线的模型进行决策,而不是噪声较大的预测模型。
摘要
在本章中,我们深入探讨了 LSTM 网络的内部工作原理。我们探索了与这些网络相关的概念和数学实现,理解了信息是如何在 LSTM 单元中处理的,并使用短期和长期记忆来存储事件。我们还了解了为什么这个网络得名,因为它擅长在非常远的时间步长中保持相关的单元状态。虽然我们讨论了该架构的一些变体,如窥视孔连接,但在大多数常见的 LSTM 候选场景中很少见到它。尽管我们使用了一个简单的时间序列数据集进行演示,但我们强烈建议你实现这个架构来解决你可能已经熟悉的其他问题(例如 IMDB 情感分类数据集),并将结果与我们早期的工作进行比较。
LSTM 在自然语言处理(NLP)任务中确实表现突出。你可以尝试使用维基百科电影数据集生成电影剧本,或者尝试使用 music21 库和一些 MIDI 文件来生成音乐,并用训练歌曲进行训练。
进一步的编码可以在这里找到:
LSTM 背后的理论概念仍然相当引人注目——尤其是考虑到它们在各种顺序和非顺序任务中的出色表现。那么,是否可以将 LSTM 冠以 RNN 领域的终极冠军称号呢?嗯,答案并不完全是。下一个接近 RNN 领域的重要思想来源于注意力模型的领域,在这个领域中,我们字面上地试图引导神经网络在处理一组信息时的注意力。这种方法在图像描述任务中非常有用,因为我们需要将输入图像的关键部分与输出中必须包含的、按序排列的单词相关联。我们将在接下来的章节中详细探讨注意力模型的相关话题。对于感兴趣的读者,你可以通过阅读 Fang 等人于 2016 年发表的优秀论文Image captioning with semantic attention来进一步了解机器图像描述任务。
然而,在下一章中,我们将把注意力集中在神经网络和深度学习的另一个部分:强化学习。这是机器学习中一个极为有趣的领域,它研究人工智能体如何在一个设计好的环境中行动,以便能够累积性地最大化某个奖励。这个方法可以应用于各种各样的使用案例,例如教机器进行手术、生成笑话或玩视频游戏。让机器能够利用与人类相当(甚至超越)水平的身体或心理灵活性,能够帮助我们构建非常复杂和智能的系统。这些系统维护与其操作环境相关的内部状态,并能够通过研究其行为对环境的影响来更新内部状态,同时优化特定目标。因此,每一种行动组合都会触发不同的奖励信号,学习系统可以利用这些信号进行自我提升。
正如我们很快会看到的,设计允许通过奖励信号来强化的系统,可以导致非常复杂的行为,从而使机器能够执行高度智能的行动,甚至在人类通常占优势的领域也能表现突出。AlphaGo 与李世石的对决故事浮现在脑海中。2016 年,AlphaGo 以五比一战胜了李世石,而这一事件与 1997 年 IBM 的 Deep Blue 战胜加里·卡斯帕罗夫(Gary Kasparov)大不相同。许多观看 AlphaGo 与李世石对局的人都看到了机器操作方式的特殊性。有些人甚至称之为直觉。
在下一章,我们将看到这样的系统,基于环境和可能行动的一些相当简单的统计属性,如何产生出美丽而复杂的结果,有时甚至超出我们自己的预期。
练习
-
检查模型收敛所需的时间。不同模型之间有很大的差异吗?
-
检查模型之间的训练和验证损失。你注意到了什么?
-
尝试缩小和放大架构,注意这如何影响学习过程。
-
尝试不同的优化器和损失度量,并注意这如何影响学习过程。
-
在 IMBD 数据集上实现 LSTM 进行情感分类。
-
在 Wikimovies 数据集上实现 LSTM,构建字符/词级语言模型并生成人工电影剧情。
第七章:深度 Q 网络强化学习
在上一章中,我们看到了递归循环、信息门控和记忆单元如何被用来通过神经网络建模复杂的时间相关信号。更具体地说,我们看到长短期记忆(LSTM)架构如何利用这些机制来保留预测误差,并将其在越来越长的时间步长上反向传播。这使得我们的系统能够利用短期(即来自与即时环境相关的信息)和长期表示(即来自于很久以前观察到的环境信息)来提供预测。
LSTM 的美妙之处在于,它能够在非常长的时间跨度内(最长可达一千个时间步)学习并保留有用的表示。通过在架构中保持恒定的误差流,我们可以实现一种机制,使得我们的网络能够学习复杂的因果模式,这些模式嵌入在我们日常面对的现实中。实际上,教育计算机理解因果关系的问题,至今仍是人工智能(AI)领域中的一个巨大挑战。现实世界的环境充斥着稀疏且具有时间延迟的奖励,且随着奖励对应的动作集的复杂度增加,问题变得更加复杂。在这种情况下,建模最佳行为涉及到发现关于给定环境的足够信息,以及可能的动作集合和相应的奖励,以便做出相关预测。正如我们所知,编码这种复杂的因果关系对于人类来说也是一项困难的任务。我们常常在没有考虑到一些相当有益的因果关系时,屈服于我们的非理性欲望。为什么?简单来说,实际的因果关系可能与我们对情况的内部评估不相符。我们可能是在不同的奖励信号驱动下行动,这些信号跨越时间影响着我们的整体决策。
我们对某些奖励信号的反应程度差异很大。这是基于特定个体的情况,并由基因组成和环境因素的复杂组合决定的。在某些方面,这已经深深植根于我们的天性中。我们之中有些人天生更容易被短期奖励(如美味的小吃或娱乐电影)所吸引,而不是长期奖励(如保持健康的身体或高效利用时间)。这也不是特别糟糕,对吧?其实不一定。不同的环境需要不同的短期与长期考虑平衡才能成功。鉴于人类可能遇到的环境多种多样(无论是个体层面还是物种层面),我们在不同个体之间观察到如此多样的奖励信号解读方式也就不足为奇。从宏观角度来看,进化的目标只是最大化我们在各种环境中生存的机会,适应现实世界带来的挑战。然而,正如我们很快会看到的,这对某些个体(甚至是一些贪婪的机器)在微观层面的事件中可能会带来后果。
关于奖励与满足感
有趣的是,一组斯坦福大学的研究人员(由心理学家沃尔特·米歇尔领导,进行的棉花糖实验,发生在 1970 年代)展示了个体延迟短期满足感的能力与长期成功之间的相关性。简而言之,这些研究人员邀请了一些孩子,观察他们在面对一组选择时的行为。这些孩子有两个选择,决定他们在互动中能够获得多少棉花糖。他们可以选择立即拿到一个棉花糖,或者选择等待 15 分钟后拿到两个棉花糖。这个实验深入揭示了如何解读奖励信号对在特定环境中的表现是有益还是有害的,结果显示,选择等待两个棉花糖的孩子,平均一生中表现得更加成功。事实证明,延迟满足感可能是最大化长期更有益的行动的关键部分。许多人甚至指出,像宗教这样的概念可能就是延迟短期满足感的集体体现(即:不偷窃),以换取长期的好处(例如最终升天)。
一种新的学习检视方式
因此,似乎我们发展出了对应采取何种行动及其可能影响未来结果的内在感知。我们有机制使我们能够通过环境互动来调整这些感知,观察不同的行为会带来何种奖励,这一过程可能持续很长时间。这一点对于人类以及地球上大多数生物来说都是真实的,包括不仅是动物,甚至是植物。即使是植物,也在全天优化某种能量评分,它们通过转动叶子和枝条来捕捉光线,这是它们生存所必需的。那么,这种机制是什么,能让这些生物体建模出最佳结果?这些生物系统如何追踪环境并执行及时而精确的操作以达到有利的结果呢?或许,强化理论这一行为心理学分支能够为这个话题提供一些启示。
由哈佛心理学家 B.F. 斯金纳提出,这一观点将强化定义为代理(人类、动物,现在也包括计算机程序)与其环境之间观察到的互动的结果。从这种互动中编码的信息可能会增强或削弱代理在未来相似情境中以相同方式行动的可能性。简而言之,如果你走在炙热的煤炭上,你感受到的疼痛将作为负向强化,减少你未来选择再次踏上炙热煤炭的可能性。相反,如果你抢劫银行并成功逃脱,所体验到的刺激和兴奋可能会强化这一行为,使其在未来成为更可能的选择。事实上,斯金纳展示了如何通过简单的强化机制训练普通的鸽子,帮助它区分英语单词的差异,甚至让它玩乒乓球。他展示了,经过一段时间的奖励信号的暴露,足以激励鸽子识别它所看到的单词之间的微妙差异或它被要求执行的动作。由于识别这些差异意味着鸽子是否能够获得饱腹感,斯金纳通过逐步奖励鸽子达到期望的结果,从而影响其行为。从这些实验中,他创造了操作性条件反射一词,这一概念与将任务分解成小步骤并逐步奖励有利行为有关。
今天,约半个世纪后,我们在机器学习(ML)领域将这些概念应用于强化模拟代理在特定环境中执行有利行为。这一概念被称为强化学习,它可以产生复杂的系统,这些系统在执行任务时可能会与我们自己的智力相媲美(甚至超越)。
使用强化学习训练机器
到目前为止,我们一直在处理简单的回归和分类任务。我们根据连续值对观察结果进行回归(例如预测股市)并将特征分类到不同的标签中(例如进行情感分析)。这两项任务是监督学习的基石活动。在训练过程中,我们为每个网络遇到的观察结果显示一个特定的目标标签。稍后在本书中,我们将介绍一些无监督学习技术,使用生成对抗网络(GANs)和自动编码器。不过,今天我们将神经网络应用于与这两种学习任务截然不同的领域。这种学习任务可以称为强化学习。
强化学习与前述的机器学习变种明显不同。在这里,我们并不会明确标注环境中所有可能的动作序列与可能的结果(如监督分类中那样),也不会基于相似性度量来划分数据(如无监督聚类中那样)以寻找最佳动作。相反,我们让机器监控它所采取的行动的反馈,并在一段时间内逐步建模最大可能奖励作为行动的函数。本质上,我们处理的是目标导向的算法,这些算法通过给定的时间步学习实现复杂目标。目标可以是击败逐渐向屏幕下方移动的太空侵略者,或者让一个形似狗的机器人从现实世界中的 A 点移动到 B 点。
信用分配问题
就像父母通过奖励和奖励来强化我们的行为一样,我们也可以在给定环境的状态(或配置)下,强化机器的期望行为。这更像是一种试错学习方法,近期的事件表明,这种方法可以产生极其强大的学习系统,打开了非常有趣的应用场景的大门。然而,这种明显不同但强大的学习范式也带来了一些自身的复杂性。例如,考虑信用分配问题。也就是说,我们之前的哪些行动为生成奖励负责,负责的程度如何?在奖励稀疏且时延的环境中,许多动作可能发生在某些行动之间,这些行动后来产生了所讨论的奖励。正确地将应得的信用分配给各个行动会变得非常困难。如果没有适当的信用分配,我们的代理在评估不同策略时会毫无头绪,无法达成其目标。
探索-利用困境
假设我们的智能体甚至设法找到了一个稳定的策略来获取奖励。那么接下来怎么办?它是否应该一直坚持这个策略,永远生成相同的奖励?还是它应该一直尝试新的方法?也许,通过不利用已知的策略,智能体可以在未来获得更大的奖励?这就是所谓的探索与利用困境,指的是智能体在探索新策略或利用已知策略之间的权衡。
极端情况下,我们可以通过理解依赖已知策略来获得即时奖励的长期不利性,更好地理解探索与利用的困境。例如,通过对老鼠的实验表明,如果给这些动物一个触发多巴胺释放机制的方式,它们会饿死自己(多巴胺是一种负责调节我们奖励系统的神经递质)。显然,尽管结局可能让老鼠非常兴奋,但从长远来看,饿死自己并不是正确的选择。然而,由于老鼠利用了一种简单的策略,它不断触发奖励信号,因此它并没有去探索长期奖励的前景(例如活下去)。那么,如何弥补环境中可能存在更好机会的事实,而放弃当前的机会呢?如果我们希望智能体能够恰当地解决复杂环境,我们必须让它理解延迟满足的概念。很快,我们将看到深度强化学习如何尝试解决这些问题,从而催生出更复杂、更强大的系统,有些人甚至可能称其为异常敏锐。
通向人工通用智能的路径
以 AlphaGo 系统为例,该系统由总部位于英国的初创公司 DeepMind 开发,利用了一种深度强化学习的独特形式来为其预测提供依据。谷歌以 5 亿美元的价格收购 DeepMind 是有充分理由的,因为许多人认为 DeepMind 已经迈出了朝向人工通用智能(AGI)的第一步——如果你愿意的话,这可以看作是 AI 的圣杯。这一概念指的是人工智能系统在多种任务上表现良好的能力,而不是我们目前的网络在狭窄应用范围内的表现。一个通过观察自己在环境中行动来学习的系统,从精神上看(并且可能更快),与我们人类的学习方式相似。
我们在前几章构建的网络在狭窄的分类或回归任务中表现良好,但如果要执行其他任务,则必须进行显著的重新设计和重新训练。然而,DeepMind 展示了如何训练一个单一的网络,在多个不同(尽管狭窄)的任务上表现出色,这些任务包括玩几款经典的 Atari 2600 游戏。虽然这些游戏有些过时,但它们最初设计时就是为了给人类带来挑战,这使得这一成就成为 AI 领域的一个相当了不起的突破。在他们的研究中(deepmind.com/research/dqn/
),DeepMind 展示了他们的深度 Q 网络(DQN)如何仅通过观察屏幕上的像素,而不需要任何关于游戏本身的先验信息,就能让人工代理玩不同的游戏。他们的工作激发了一波新研究人员,他们开始使用基于强化学习的算法训练深度学习网络,推动了深度强化学习的诞生。此后,研究人员和企业家们纷纷尝试利用这些技术应用于一系列场景,包括但不限于让机器像动物和人类一样移动、生成药物分子化合物,甚至创建能够在股市交易的机器人。
不用多说,这种系统在模拟现实世界事件方面更具灵活性,并可以应用于多种任务,从而减少训练多个独立狭窄系统所需的资源。未来,它们甚至可能揭示复杂且高维的因果关系,通过利用来自多个领域的训练示例来编码协同表示,这些表示反过来帮助我们解决更复杂的问题。我们的发现往往受到来自不同科学领域的信息启发,这有助于加深我们对这些情况及其复杂动态的理解。那么,为什么不让机器也来做这些呢?只要给定合适的奖励信号,机器在特定环境下的可能行动甚至可能超越我们自己的直觉!也许有一天你能在这方面提供帮助。现在,让我们先来看看如何模拟一个虚拟代理,并让它与环境互动,解决简单的问题。
模拟环境
首先,我们需要一个模拟环境。环境被定义为学习代理的互动空间。对于人类而言,环境可以是你一天中去的任何地方。对于人工智能代理而言,这通常是我们精心设计的模拟环境。为什么是模拟的呢?我们可以要求代理像我们一样在实时中学习,但事实证明这是相当不切实际的。首先,我们必须为每个代理设计一个身体,然后精确地设计其行为以及它们要与之互动的环境。此外,代理在模拟中训练得更快,无需局限于人类的时间框架。当机器在现实中完成一个任务时,其模拟版本可能已经完成了同一个任务好几次,为其从错误中学习提供了更好的机会。
接下来,我们将介绍一些基本术语,这些术语用于描述游戏,而游戏代表了一个代理需要在其中执行特定任务以获得奖励并解决环境的环境。
理解状态、行动和奖励
环境本身可以分解为一系列不同的状态,这些状态代表了代理可能遇到的不同情况。代理可以通过尝试不同的行动组合来在这些状态间导航(例如,在二维街机游戏中,向左或向右走、跳跃等)。代理所做的行动会有效地改变环境的状态,使得可用的工具、备用路线、敌人或游戏设计者可能隐藏的任何其他元素变得可用,从而使游戏更加有趣。这些对象和事件代表了学习环境在代理进行导航时可能经历的不同状态。每当代理在其先前的状态下与环境互动,或环境中发生随机事件时,环境就会生成一个新的状态。这就是游戏如何进行,直到达到终端状态,即游戏无法继续(因为胜利或死亡)。
本质上,我们希望代理采取及时且有利的行动来解决其环境。这些行动必须改变环境的状态,使代理更接近达成预定目标(如从 A 点到 B 点或最大化得分)。为了做到这一点,我们需要设计奖励信号,这些信号作为代理与环境中不同状态互动的结果发生。我们可以将奖励的概念视为反馈,允许我们的代理评估其行动所取得的成功程度,从而优化给定目标:
对于那些熟悉经典街机风格视频游戏的人来说,可以想象一场《马里奥》的游戏。马里奥本身是代理角色,由你来控制。环境指的是马里奥可以在其中移动的地图。金币和蘑菇的存在代表着游戏中的不同状态。一旦马里奥与这些状态中的任何一个互动,就会触发奖励,形式为积分,新的状态也会随之产生,进而改变马里奥的环境。马里奥的目标可以是从 A 点移动到 B 点(如果你急于完成游戏),或者是最大化他的得分(如果你更感兴趣的是解锁成就)。
一辆自动驾驶出租车
接下来,我们将通过观察人工智能代理如何解决环境问题,来澄清我们迄今为止所获得的理论理解。我们将看到,即使通过随机从代理的动作空间中采样动作(代理可能执行的动作),也能实现这一目标。这将帮助我们理解解决即使是最简单环境所涉及的复杂性,以及为什么我们可能很快需要调用深度强化学习来帮助我们实现目标。我们即将解决的目标是在一个简化的模拟环境中创建一辆自动驾驶出租车。尽管我们将处理的环境比现实世界简单得多,但这个模拟将作为深入强化学习系统设计架构的一个优秀跳板。
为了实现这个目标,我们将使用 OpenAI 的gym
,一个恰如其名的模块,用于模拟人工环境以训练机器。你可以使用pip
包管理器来安装 OpenAI gym 依赖。以下命令将在 Jupyter Notebooks 中运行并启动该模块的安装:
! pip install gym
gym
模块提供了大量预安装的环境(或测试问题),涵盖了从简单到复杂的各种模拟。我们将在以下示例中使用的测试问题来自'TaxiCab-v2'
环境。我们将从所谓的出租车模拟开始实验,这个模拟简单地模拟了一张道路网格,出租车需要在其中穿行,以便接送顾客:
import numpy as np
import gym
from gym import envs
# This will print allavailable environemnts
# print(envs.registry.all())
理解任务
出租车模拟最早由(Dietterich 2000)提出,用以展示在分层方式应用强化学习时所遇到的问题。然而,我们将使用此模拟来巩固我们对智能体、环境、奖励和目标的理解,然后再继续模拟和解决更复杂的问题。目前,我们面临的问题相对简单:接送乘客并将其送到指定地点。共有四个目的地,这些地点用字母表示。我们的智能体只需前往这些接载地点,接上乘客,然后前往指定的下车地点,乘客可以下车。成功下车后,智能体会获得+20 点奖励(模拟我们虚拟出租车司机获得的报酬)。每经过一步,出租车司机在到达目的地前会被扣除-1 点奖励(直观上,这代表了出租车司机为补充油料而产生的费用)。最后,对于未按计划接载或下车的情况,还有一个-10 点的惩罚。你可以想象,惩罚接载的原因就像出租车公司试图优化其车队部署,以覆盖城市的各个区域,要求我们的虚拟出租车司机只接载指定乘客。而未按计划下车,则反映了顾客的不满和困惑。让我们来看一下出租车环境实际是什么样子的。
渲染环境
为了可视化我们刚加载的环境,我们必须首先通过在环境对象上调用reset()
来初始化它。然后,我们可以渲染起始帧,显示出租车(黄色)的位置以及四个不同的接客地点(用不同颜色的字母表示):
# running env.reset() returns the initial state of the environment
print('Initial state of environment:' , env.reset())
env.render()
我们得到以下输出:
请注意,前面的截图中,开放的道路用冒号(:
)表示,而出租车无法穿越的墙壁则用竖线(|
)表示。虽然这些障碍物和道路的位置保持不变,但表示接载点的字母以及我们黄色出租车的位置在每次初始化环境时都会发生变化。我们还可以注意到,重置环境会生成一个整数。这表示环境的一个特定状态(即出租车和接载点的位置),这是在初始化时获得的。
你可以将Taxi-v2
字符串替换为注册表中的其他环境(如CartPole-v0
或MountainCar-v0
),并渲染几帧来大致了解我们所处理的环境。还有一些其他命令可以帮助你更好地理解你所面对的环境。虽然出租车环境足够简单,可以使用彩色符号进行模拟,但更复杂的环境可能会在执行时在一个单独的窗口中渲染。
参考观察空间
接下来,我们将尝试更好地理解我们的环境和动作空间。出租车环境中的所有状态通过一个介于 0 到 499 之间的整数来表示。我们可以通过打印出环境中所有可能状态的总数来验证这一点。让我们来看一下我们环境可能的不同状态的数量:
env.observation_space.n
500
引用动作空间
在出租车模拟中,我们的司机智能体在每个时间步骤上有六个不同的动作可以执行。我们可以通过查看环境的动作空间来检查可能的动作总数,具体如下所示:
env.action_space.n
6
我们的司机在任何给定时刻都可以执行六个动作之一。这些动作分别对应向上、向下、向左或向右移动;接乘客;或把乘客放下。
与环境互动
要让我们的智能体执行某个动作,我们可以在环境对象上使用step()
方法。step(i)
方法接受一个整数,这个整数对应智能体允许执行的六个可能动作中的一个。在这个例子中,这些动作标记如下:
-
(0) 表示向下移动
-
(1) 表示向上移动
-
(2) 表示右转
-
(3) 表示左转
-
(4) 表示接乘客
-
(5) 表示放下乘客
以下是代码的表示方式:
#render current position
env.render()
#move down
env.step(0)
#render new position
env.render()
我们得到以下输出:
如我们所见,我们让智能体向下执行一个步骤。现在,我们理解了如何让智能体执行所有必要的步骤以达成目标。事实上,调用环境对象的step(i)
会返回四个特定的变量,这些变量描述了从智能体的角度来看,动作(i)对环境的影响。这些变量如下:
-
observation
:这是环境的观测状态。它可以是来自游戏截图的像素数据,或者是以其他方式表示环境状态的形式,以便学习智能体理解。 -
reward
:这是我们为智能体提供的奖励,用来表示智能体在某个时间步骤执行某个动作后的补偿。我们利用奖励为学习智能体设定目标,方法是要求其最大化在特定环境中获得的奖励。注意,奖励(浮动)值的尺度可能会根据实验设置有所不同。 -
done
:这个布尔(布尔值)变量表示一个试验是否已结束。在出租车模拟中,当乘客被接上并在指定位置放下时,认为这一轮试验已完成。在一款雅达利游戏中,试验可以定义为智能体的生命,直到被外星人击中而结束。 -
info
:这个字典项用于存储调试智能体动作时所需要的信息,通常在学习过程中不使用。然而,它存储了有价值的信息,比如影响前一个状态变化的概率,在给定步骤中:
# env.step(i) will return four variables
# They are defiend as (in order) the state, reward, done, info
env.step(1)
(204, -1, False, {'prob': 1.0})
随机解决环境
在掌握了 OpenAI gym 环境的逻辑和如何让人工智能体在其中互动之后,我们可以继续实现一个随机算法,让智能体(最终)解决出租车环境。首先,我们定义一个固定状态来开始我们的仿真。如果你想重复同一个实验(即用相同的状态初始化环境),这将非常有帮助,同时可以检查智能体在每次任务中解决环境时采取了多少随机步骤。我们还定义了一个counter
变量,用于简单地跟踪智能体在任务进展过程中所采取的时间步骤数。奖励变量初始化为None
,并将在智能体采取第一步时更新。然后,我们简单地启动一个while
循环,反复从我们的动作空间中随机选择可能的动作,并更新每个选定动作的state
、reward
和done
变量。为了从环境的动作空间中随机选择动作,我们使用.sample()
方法操作env.action_space
对象。最后,我们增加counter
,并渲染环境以便可视化:
# Overriding current state, used for reproducibility
state = env.env.s = 114
# counter tracks number of moves made
counter = 0
#No reward to begin with
reward = None
dropoffs = 0
#loop through random actions until successful dropoff (20 points)
while reward != 20:
state, reward, done, info = env.step(env.action_space.sample())
counter += 1
print(counter)
env.render()
print(counter, dropoffs)
我们得到以下输出:
直到第 2,145 次尝试,我们的智能体才找到了正确的乘客(如出租车变绿所示)。即便你可能没有感到时间的流逝,这也是相当长的。随机算法有助于在调用更复杂的模型时作为基准,确保结果的合理性。但显然,我们可以做得比智能体运行在随机算法上所用的 6,011 步(解决这个简单环境)要好。怎么做呢?我们奖励它做对的事情。为此,我们必须首先在数学上定义奖励的概念。
立刻奖励与未来奖励之间的权衡
初看之下,这可能显得相当简单。我们已经看到,出租车司机通过正确送客获得+20 分,错误送客扣-10 分,每个时间步骤完成一轮任务时扣-1 分。逻辑上,你可以将一个智能体在一次任务中的总奖励计算为该智能体在每个时间步骤所获得的所有个别奖励的累积。我们可以通过数学表示来表示这一点,并表示一次任务中的总奖励如下:
这里,n仅仅表示任务的时间步骤。这似乎是直观的。现在,我们可以要求智能体最大化给定任务中的总奖励。但问题来了。就像我们自己所处的现实一样,智能体所面临的环境可能主要由随机事件主导。因此,无法保证在相似的未来状态下执行相同的动作会返回相同的奖励。事实上,随着我们进入未来,由于固有的随机性,奖励可能会越来越偏离每个状态下采取的相应动作所带来的奖励。
对未来奖励的折扣
那么,我们如何弥补这种差异呢?一种方法是通过对未来奖励进行折扣,从而增强当前奖励相对于未来时间步奖励的重要性。我们可以通过在每个时间步生成奖励时加入折扣因子来实现这一点,同时在计算给定回合的总奖励时应用它。折扣因子的目的是减弱未来奖励并增强当前奖励。从短期来看,通过使用相应的状态-行动对,我们更有把握获得奖励。然而,由于环境中随机事件的累积效应,从长期来看就无法做到这一点。因此,为了激励智能体集中于相对确定的事件,我们可以修改之前的总奖励公式,加入这个折扣因子,如下所示:
在我们新的总奖励公式中,γ 表示一个介于 0 和 1 之间的折扣因子,t 表示当前的时间步。如你所见,γ 项的指数递减使得未来的奖励相对于当前奖励被减弱。直观地说,这意味着在智能体考虑下一步行动时,较远的未来奖励会比更接近的奖励被考虑得少。那么,减弱的程度如何呢?这仍然由我们决定。一个接近零的折扣因子会产生短视的策略,享乐主义地偏向即时奖励而非未来奖励。另一方面,如果折扣因子过于接近 1,折扣因子的作用将被完全抵消。实际上,根据环境中的随机性程度,折扣因子的平衡值通常在 0.75 到 0.9 之间。作为经验法则,较为确定性环境中需要较高的 γ 值,而较为随机的环境中需要较低的 γ 值。我们甚至可以像下面这样简化之前给出的总奖励公式:
因此,我们将回合中的总奖励形式化为每个时间步的累计折扣奖励。通过使用折扣未来奖励的概念,我们可以为智能体生成策略,从而指导其行动。执行有利策略的智能体将会选择那些在给定回合中最大化折扣未来奖励的行动。现在,我们已经大致了解了如何为智能体设计奖励信号,是时候继续研究整个学习过程的概述了。
马尔可夫决策过程
在强化学习中,我们试图解决将即时行动与其返回的延迟奖励相关联的问题。这些奖励仅仅是稀疏的、时间延迟的标签,用于控制智能体的行为。到目前为止,我们已经讨论了智能体如何根据环境的不同状态进行行动。我们还看到了交互如何为智能体生成不同的奖励,并解锁环境的新状态。从这里开始,智能体可以继续与环境进行交互,直到一回合结束。现在是时候数学上形式化这些智能体与环境之间的关系,以便进行目标优化了。为此,我们将借用由俄罗斯数学家安德烈·马尔可夫提出的框架,现在称为马尔可夫决策过程(MDP)。
这个数学框架使我们能够建模智能体在部分随机且部分可控的环境中的决策过程。这个过程依赖于马尔可夫假设,假设未来状态的概率(st+1)仅依赖于当前状态(st)。这一假设意味着,所有导致当前状态的状态和动作对未来状态的概率没有影响。MDP 由以下五个变量定义:
虽然前两个变量相当直观,第三个变量(R)指的是给定一个状态-动作对下的奖励概率分布。这里,状态-动作对仅指给定环境状态下应采取的相应动作。接下来是转移概率(P),它表示在某一时间步下,给定选择的状态-动作对后得到新状态的概率。
最后,折扣因子指的是我们希望将未来奖励折扣到更即时奖励的程度,以下图说明:
左:强化学习问题。右:马尔可夫决策过程
因此,我们可以使用 MDP 描述智能体与环境之间的交互。一个 MDP 由一系列状态和动作组成,并包含规则,规定了从一个状态到另一个状态的转移。我们现在可以数学地定义一次回合为一个有限的状态、动作和奖励序列,如下所示:
在这里,(s[t]) 和 (a[t]) 表示时间 t 时的状态和相应的动作。我们还可以将与该状态-动作对对应的奖励表示为 (r[t+1])。因此,我们通过从环境中采样一个初始状态 (s[o]) 来开始一个回合。接下来,直到目标完成,我们要求智能体为其所处环境的相应状态选择一个动作。一旦智能体执行了一个动作,环境就会为智能体采取的动作以及接下来的状态 (s[t+1]) 采样一个奖励。然后,智能体在接收到奖励和下一个状态后,会重复这个过程,直到它能够解决环境中的问题。最后,在回合结束时,会到达一个终止状态 (s[n])(也就是说,当目标完成时,或者在游戏中用完所有生命时)。决定智能体在每个状态下采取动作的规则统称为策略,并用希腊字母 (π) 表示。
理解策略函数
如我们所见,智能体解决一个环境的效率取决于它在每个时间步骤中使用什么策略来匹配状态-动作对。因此,一个被称为策略函数(π)的函数可以指定智能体在每个时间步骤上遇到的状态-动作对的组合。随着模拟的进行,策略负责生成轨迹,轨迹由游戏状态、智能体作为响应所采取的动作、环境生成的奖励以及智能体接收到的游戏下一个状态组成。直观地讲,你可以将策略看作是一个启发式方法,它生成响应环境状态的动作。策略函数本身可以是好的,也可以是坏的。如果你的策略是先开枪再问问题,最终你可能会误伤人质。因此,我们现在要做的就是评估不同的策略(即它们产生的轨迹,包括状态、动作、奖励和下一个状态的序列),并挑选出能最大化给定游戏的累计折扣奖励的最优策略 (π *)。这可以通过以下数学公式来表示:
因此,如果我们试图从 A 点移动到 B 点,我们的最优策略将包括采取一个使我们在每个时间步尽可能靠近 B 点的行动。不幸的是,由于在这样的环境中存在随机性,我们不能如此确定地声称我们的策略绝对最大化了折扣奖励的总和。根据定义,我们无法考虑在从 A 点到 B 点的过程中发生的某些随机事件,例如地震(假设你不是地震学专家)。因此,我们也无法完美地考虑因环境中的随机性而产生的动作所带来的奖励。相反,我们可以将最优策略(π*)定义为一种让我们的代理最大化预期的折扣奖励之和的策略。这可以通过对先前方程式进行轻微修改来表示,表示如下:
在这里,我们使用 MDP 框架,并从我们的状态概率分布 p(s[o])中采样初始状态(s[o])。代理的动作(a[t])根据给定状态从策略中采样。然后,采样一个奖励,对应于在给定状态下执行的动作的效用。最后,环境从当前状态-动作对的转移概率分布中采样下一个状态(s[t+1])。因此,在每个时间步,我们的目标是更新最优策略,以最大化预期的折扣奖励之和。你们中的一些人可能会在此时想,如何在给定状态的情况下评估一个动作的效用呢?嗯,这就是值函数和 Q 值函数发挥作用的地方。为了评估不同的策略函数,我们需要能够评估不同状态的值,以及给定策略下与这些状态对应的动作质量。为此,我们需要定义两个额外的函数,分别是值函数和 Q 值函数。
评估状态的值
首先,我们需要估计在遵循特定策略(π)下,状态(s)的值(V)。这告诉你在遵循从状态(s)开始的策略(π)下,游戏结束时的预期累计奖励。为什么这个有用呢?想象一下,我们的学习代理环境中充满了不断追赶代理的敌人。它可能已经形成了一个策略,指示它在整个游戏过程中永远不停跑。在这种情况下,代理应该具有足够的灵活性来评估游戏状态的值(例如,当它跑到悬崖边缘时,以避免跑下去并死亡)。我们可以通过定义给定状态下的值函数,V π (s),作为代理在遵循该策略时,从当前状态开始的预期累计(折扣)奖励来实现这一点:
因此,我们能够使用价值函数来评估在遵循特定策略时,某个状态的优劣。然而,这仅仅告诉我们给定策略下一个状态本身的价值。我们还希望我们的智能体能够判断某个动作在特定状态下的价值。这正是允许智能体根据环境中的任何情况(无论是敌人还是悬崖边缘)动态做出反应的关键。我们可以通过使用 Q 值函数来实现这一概念,将给定状态-动作对在特定策略下的“优良”程度量化出来。
评估动作质量
如果你走到墙前,能执行的动作不多。你可能会通过选择掉头动作来回应这个状态,接着会问自己为什么一开始走到墙前。同样地,我们希望我们的智能体能根据它所处状态的不同,利用一个关于动作优劣的感知,同时遵循策略。我们可以通过使用 Q 值函数来实现这一目标。这个函数简单地表示在特定状态下采取特定动作时,遵循一个策略所期望的累积奖励。换句话说,它表示给定策略下,状态-动作对的质量。数学上,我们可以表示 Q π ( a , s) 关系如下:
Q π ( s , a) 函数允许我们表示遵循一个策略(π)所获得的期望累积奖励。直观地说,这个函数帮助我们量化在游戏结束时的总得分,给定每个状态(即你观察到的游戏画面)下所采取的动作(即不同的摇杆控制操作),并遵循一个策略(例如:在跳跃时向前移动)。通过这个函数,我们可以定义在游戏的终止状态下,给定所遵循的策略时,最好的期望累积奖励。这可以表示为 Q 值函数能够达到的最大期望值,称为最优 Q 值函数。我们可以通过以下数学公式来定义它:
现在,我们有一个函数,该函数量化了在给定策略下,状态-动作对的期望最优值。我们可以使用这个函数来预测在给定游戏状态下应该采取的最优动作。然而,我们如何评估预测的真实标签呢?我们并没有给游戏屏幕标注对应的目标动作,因此无法评估我们的网络偏差有多大。这时,贝尔曼方程派上用场了,它帮助我们评估给定状态-动作对的值,作为当前产生的奖励和随后的游戏状态值的函数。然后,我们可以利用这个函数来比较网络的预测结果,并通过反向传播误差来更新模型权重。
使用贝尔曼方程
贝尔曼方程是由美国数学家理查德·贝尔曼提出的,它是驱动深度 Q 学习的主要方程之一。它本质上让我们能够解决之前所定义的马尔可夫决策过程。直观地说,贝尔曼方程做了一个简单的假设。它声明,对于一个给定的状态下执行的动作,最大未来奖励是当前奖励加上下一个状态的最大未来奖励。为了类比棉花糖实验,两个棉花糖的最大可能奖励是通过在第一时刻自我克制(奖励为 0 个棉花糖)然后在第二时刻收集(奖励为两个棉花糖)来实现的。
换句话说,给定任何状态-动作对,在给定状态(s)下执行动作(a)的质量(Q)等于将要收到的奖励(r),以及智能体最终到达的下一个状态(s’)的价值。因此,只要我们能够估计下一个时间步的最优状态-动作值,Q(s’,a’),就能计算当前状态的最优动作。正如我们在棉花糖实验中所看到的那样,智能体需要能够预见到未来某个时刻可能获得的最大奖励(两个棉花糖),以便在当前时刻拒绝只获得一个棉花糖。使用贝尔曼方程,我们希望智能体采取最大化当前奖励(r)以及下一个状态-动作对的最优 Q值的动作,Q(s’,a’)*,并且考虑折扣因子 gamma(y)。用更简单的话来说,我们希望它能够计算当前状态下动作的最大预期未来奖励。这可以转化为以下公式:
现在,我们知道如何在数学上估计给定状态下执行一个动作的预期质量。我们也知道如何估计遵循特定策略时,状态-动作对的最大预期奖励。从这里,我们可以重新定义给定状态(s)下的最优策略(π*),即给定状态下的动作最大预期 Q 值。这可以表示如下:
最后,我们已经具备了所有拼图的部分,实际上可以尝试找到一个最优策略(π*)来引导我们的智能体。这个策略将允许我们的智能体通过为环境生成的每个状态选择理想的动作,从而最大化预期的折扣奖励(考虑环境的随机性)。那么,我们究竟该如何进行操作呢?一个简单的非深度学习解决方案是使用价值迭代算法来计算未来时间步的动作质量*( Qt+1 ( s , a ))*,作为期望当前奖励(r)和下一个游戏状态的最大折扣奖励(γ max a Qt ( s’ , a’ ))的函数。从数学上讲,我们可以将其表示如下:
在这里,我们基本上会不断迭代更新贝尔曼方程,直到 Qt 收敛到 Q*,随着t的增加,直到无限远。我们实际上可以通过直接实现贝尔曼方程来测试它在解决出租车模拟问题中的估计效果。
迭代更新贝尔曼方程
你可能记得,采用随机方法解决出租车模拟时,我们的智能体大约需要 6,000 个时间步。有时,凭借纯粹的运气,你可能会在 2,000 个时间步以内解决它。然而,我们可以通过实现一个贝尔曼方程的版本进一步提升我们的成功概率。这种方法将基本上允许我们的智能体通过使用 Q 表记住每个状态下的动作及其相应的奖励。我们可以在 Python 中使用 NumPy 数组实现这个 Q 表,数组的维度对应于我们出租车模拟中的观察空间(可能的不同状态的数量)和动作空间(智能体可以执行的不同动作的数量)。回想一下,出租车模拟的环境空间是 500,动作空间是六个,因此我们的 Q 表是一个 500 行六列的矩阵。我们还可以初始化一个奖励变量(R
)和一个折扣因子(gamma)的值:
#Q-table, functions as agent's memory of state action pairs
Q = np.zeros([env.observation_space.n, env.action_space.n])
#track reward
R = 0
#discount factor
gamma = 0.85
# Track successful dropoffs
dropoffs_done = 0
#Run for 1000 episodes
for episode in range(1,1001):
done = False
#Initialize reward
R, reward = 0,0
#Initialize state
state = env.reset()
counter=0
while done != True:
counter+=1
#Pick action with highest Q value
action = np.argmax(Q[state])
#Stores future state for compairason
new_state, reward, done, info = env.step(action)
#Update state action pair using reward and max Q-value for the new state
Q[state,action] += gamma * (reward + np.max(Q[new_state]) - Q[state,action])
#Update reward
R += reward
#Update state
state = new_state
#Check how many times agent completes task
if reward == 20:
dropoffs_done +=1
#Print reward every 50 episodes
if episode % 50 == 0:
print('Episode {} Total Reward: {} Dropoffs done: {} Time-Steps taken {}'
.format(episode,R, dropoffs_done, counter))
Episode 50 Total Reward: -30 Dropoffs done: 19 Time-Steps taken 51
Episode 100 Total Reward: 14 Dropoffs done: 66 Time-Steps taken 7
Episode 150 Total Reward: -5 Dropoffs done: 116 Time-Steps taken 26
Episode 200 Total Reward: 14 Dropoffs done: 166 Time-Steps taken 7
Episode 250 Total Reward: 12 Dropoffs done: 216 Time-Steps taken 9
Episode 300 Total Reward: 5 Dropoffs done: 266 Time-Steps taken 16
然后,我们只需循环执行一千个回合。在每个回合中,我们初始化环境的状态,设置一个计数器来跟踪已执行的下车操作,以及奖励变量(总奖励:R
和每回合奖励:r
)。在我们的第一个循环中,我们再次嵌套另一个循环,指示智能体选择具有最高 Q 值的动作,执行该动作,并存储环境的未来状态及获得的奖励。这个循环会一直执行,直到回合结束,由布尔变量 done 表示。
接下来,我们在 Q 表中更新状态-动作对,并更新全局奖励变量(它表示我们的智能体整体表现如何)。算法中的 alpha 项(α)表示学习率,它有助于控制在更新 Q 表时前一个 Q 值与新生成的 Q 值之间的变化量。因此,我们的算法通过在每个时间步近似最优 Q 值来迭代更新状态-动作对的质量(Q[state, action])。随着这个过程不断重复,我们的智能体最终会收敛到最优的状态-动作对,正如 Q*所示。
最后,我们更新状态变量,用新的状态变量重新定义当前状态。然后,循环可以重新开始,迭代地更新 Q 值,并理想情况下收敛到存储在 Q 表中的最优状态-动作对。我们每 50 个回合输出一次环境采样的整体奖励,作为代理动作的结果。我们可以看到,代理最终会收敛到任务的最优奖励(即考虑到每个时间步的旅行成本,以及正确的下车奖励的最优奖励),这个任务的最优奖励大约在 9 到 13 分之间。你还会注意到,到第 50^(回合)时,我们的代理在 51 个时间步内成功完成了 19 次下车!这个方法的表现明显优于我们之前实现的随机方法。
为什么使用神经网络?
正如我们刚才所看到的,基本的价值迭代方法可以用来更新贝尔曼方程,并通过迭代地寻找理想的状态-动作对,从而最优地导航给定的环境。这个方法实际上在每一个时间步都会存储新的信息,不断地让我们的算法变得更加智能。然而,这个方法也有一个问题,那就是它根本不可扩展!出租车环境足够简单,只有 500 个状态和 6 个动作,可以通过迭代更新 Q 值来解决,从而估计每个状态-动作对的价值。然而,更复杂的模拟环境,比如视频游戏,可能会有数百万个状态和数百个动作,这就是为什么计算每个状态-动作对的质量变得在计算上不可行且在逻辑上低效的原因。在这种情况下,我们唯一能做的就是尝试使用加权参数的网络来逼近函数Q(a,s)。
因此,我们进入了神经网络的领域,正如我们现在已经很清楚的,神经网络是非常优秀的函数逼近器。我们即将体验的深度强化学习的特殊形式叫做深度 Q 学习(Deep Q-learning),它的名字来源于它的任务:学习给定状态-动作对的最优 Q 值。更正式地说,我们将使用神经网络通过模拟代理的状态、动作和奖励序列来逼近最优函数Q(s,a)*。通过这样做,我们可以迭代更新我们的模型权重(theta),朝着最匹配给定环境的最优状态-动作对的方向前进:
在 Q 学习中执行前向传递
现在,你理解了使用神经网络逼近最优函数Q(s,a)的直觉,找到给定状态下的最佳动作。不言而喻,状态序列的最优动作序列将生成一个最优的奖励序列。因此,我们的神经网络试图估计一个函数,可以将可能的动作映射到状态,从而为整个剧集生成最优的奖励。正如你也会回忆起的,我们需要估计的最优质量函数Q*(s,a)*必须满足贝尔曼方程。贝尔曼方程简单地将最大可能的未来奖励建模为当前时刻的奖励加上紧接着的下一个时间步骤的最大可能奖励:
因此,我们需要确保在预测给定时间的最优 Q 值时,贝尔曼方程中规定的条件得以保持。为此,我们可以将该模型的整体损失函数定义为最小化贝尔曼方程和实际预测中的误差。换句话说,在每次前向传播时,我们计算当前状态-动作质量值*Q (s, a ; θ)*与由贝尔曼方程在该时间(Y[t])所表示的理想值之间的差距。由于由贝尔曼方程表示的理想预测正在被迭代更新,我们实际上是在使用一个移动目标变量(Y[t])来计算我们模型的损失。这可以通过数学公式表示如下:
因此,我们的网络将通过最小化一系列损失函数Lt来训练,在每个时间步骤中进行更新。这里,术语*y[t]是我们在时间(t)进行预测时的目标标签,并在每个时间步骤中持续更新。另请注意,术语ρ(s, a)*仅表示我们模型在序列 s 和执行的动作上的内部概率分布,也称为其行为分布。正如你所看到的,在优化给定时间(t)的损失函数时,前一时刻(t-1)的模型权重保持不变。虽然这里展示的实现使用相同的网络进行两个独立的前向传播,但后来的 Q 学习变体(Mihn 等,2015 年)使用两个独立的网络:一个用于预测满足贝尔曼方程的移动目标变量(称为目标网络),另一个用于计算给定时间的模型预测。现在,让我们看看反向传播是如何在深度 Q 学习中更新我们的模型权重的。
在 Q 学习中执行反向传播
现在,我们有了一个定义好的损失度量,它计算给定时间点最优 Q 函数(由 Bellman 方程推导得出)与当前 Q 函数之间的误差。然后,我们可以将 Q 值中的预测误差反向传播,通过模型层进行反向传播,就像我们的网络在环境中进行探索一样。正如我们现在已经非常清楚的那样,这通过对损失函数关于模型权重的梯度进行求解,并根据每个学习批次在梯度的反方向上更新这些权重来实现。因此,我们可以在最优 Q 值函数的方向上迭代地更新模型权重。我们可以像这样表述反向传播过程,并说明模型权重(theta)的变化:
最终,随着模型看到足够的状态-动作对,它将充分反向传播误差并学习最优的表示,从而帮助其在给定环境中导航。换句话说,一个经过训练的模型将具有理想的层权重配置,对应于最优的 Q 值函数,映射代理在环境给定状态下的动作。
长话短说,这些方程描述了估算最优策略(π*)以解决给定环境的过程。我们使用神经网络学习在给定环境中状态-动作对的最佳 Q 值,这反过来可以用来计算生成最优奖励的轨迹,为我们的代理(即最优策略)。这就是我们如何使用强化学习来训练在稀疏时间延迟奖励的准随机模拟中运行的预期性和反应性代理。现在,我们已经掌握了所有实施我们自己的深度强化学习代理所需的理解。
用深度学习替代迭代更新
在我们开始实现之前,让我们澄清一下迄今为止我们在深度 Q 学习方面所学到的内容。正如我们在迭代更新方法中所看到的,我们可以使用转移(从初始状态、执行的动作、生成的奖励、以及采样的新状态,< s, a, r, s’ >)来更新 Q 表,在每个时间步骤保存这些元组的值。然而,正如我们提到的,这种方法在计算上不可扩展。相反,我们将替代这种在 Q 表上进行的迭代更新,并尝试使用神经网络近似最优的 Q 值函数 (Q(s,a)*),如下所示:
-
使用当前状态(s)作为输入执行前馈传递,然后预测该状态下所有动作的 Q 值。
-
使用新的状态(s’)执行前馈传递,计算网络在下一个状态下的最大总体输出,即 max a’ Q(s’, a’)。
-
在步骤 2 中计算出最大整体输出后,我们将目标 Q 值设置为所选动作的r + γmax a’ Q(s’, a’)。我们还将所有其他动作的目标 Q 值设置为步骤 1 返回的相同值,针对每个未选择的动作,仅计算所选动作的预测误差。这有效地消除了(设为零)未被我们的智能体在每个时间步选择的预测动作带来的误差影响。
-
反向传播误差以更新模型权重
我们在这里所做的只是建立一个能够对动态目标进行预测的网络。这非常有用,因为随着模型在玩游戏的过程中不断获得更多的环境物理信息,它的目标输出(在给定状态下执行的动作)也不断变化,这不同于监督学习,在监督学习中我们有固定的输出,称之为标签。因此,我们实际上是在尝试学习一个可以学习不断变化的输入(游戏状态)与输出(相应动作)之间映射关系的函数 Q(s,a)。
通过这个过程,我们的模型在执行动作时形成更好的直觉,并随着它看到更多的环境信息,获得关于状态-动作对正确 Q 值的更清晰认识。从理论上讲,Q 学习通过将奖励与在先前游戏状态中所采取的动作相关联来解决信用分配问题。误差会被反向传播,直到我们的模型能够识别出决定性的状态-动作对,这些对负责生成给定的奖励。然而,我们很快会看到,为了让深度 Q 学习系统如预期那样工作,使用了许多计算和数学技巧。在我们深入研究这些考虑因素之前,了解深度 Q 网络中前向和反向传播的过程可能会有所帮助。
Keras 中的深度 Q 学习
现在我们已经理解了如何训练智能体选择最佳的状态-动作对,接下来让我们尝试解决一个比之前的出租车模拟更复杂的环境。为什么不实现一个学习智能体来解决一个最初为人类设计的问题呢?好吧,感谢开源运动的奇迹,这正是我们将要做的。接下来,我们将实施 Mnih 等人(2013 年和 2015 年)的方法,参考原始的 DeepMind 论文,该论文实现了基于 Q 学习的智能体。研究人员使用相同的方法和神经架构来玩七种不同的 Atari 游戏。值得注意的是,研究人员在七个测试的不同游戏中,有六个取得了显著的成绩。在这六个游戏中的三款,智能体的表现超过了人类专家。这就是为什么今天,我们试图部分复现这些结果,并训练一个神经网络来玩一些经典游戏,如《太空入侵者》和《吃豆人》。
这是通过使用卷积神经网络(CNN)完成的,该网络将视频游戏截图作为输入,并估计给定游戏状态下动作的最优 Q 值。为了跟上进度,你只需要做的是安装建立在 Keras 基础上的强化学习包,名为keras-rl
。你还需要为 OpenAI gym
模块安装 Atari 依赖,这是我们之前使用过的。Atari 依赖本质上是一个为 Atari 主机设计的模拟器,将生成我们的训练环境。虽然该依赖最初是为 Ubuntu 操作系统设计的,但它已经被移植并与 Windows 和 Mac 用户兼容。你可以使用pip
包管理器安装这两个模块,以便进行以下实验。
- 你可以使用以下命令安装 Keras 强化学习包:
! pip install keras-rl
- 你可以使用以下命令为 Windows 安装 Atari 依赖:
! pip install --no-index -f https://github.com/Kojoley/atari-
py/releases atari_py
- Mnih 等人(2015)
arxiv.org/pdf/1312.5602v1.pdf
导入一些库
在机器智能领域,实现人类水平的控制一直是一个长期的梦想,尤其是在游戏等任务中。涉及到的复杂性包括自动化智能体的操作,该操作仅依赖于高维感知输入(如音频、图像等),这在强化学习中一直是一个非常具有挑战性的任务。之前的方法主要依赖于手工设计的特征,结合了过于依赖工程特征质量的线性策略表示,以便取得良好的表现。与以往的尝试不同,这种技术不需要我们的智能体拥有任何关于游戏的人工设计知识。它将完全依赖它所接收到的像素输入,并编码表示,以预测在其遍历的每个环境状态下每个可能动作的最优 Q 值。挺酷的,是吧?让我们将以下库导入到工作区,以便继续进行此任务:
from PIL import Image
import numpy as np
import gym
from keras.models import Sequential
from keras.layers import Dense, Activation, Flatten, Convolution2D, Permute
from keras.optimizers import Adam
import keras.backend as K
from rl.agents.dqn import DQNAgent
from rl.policy import LinearAnnealedPolicy, BoltzmannQPolicy, EpsGreedyQPolicy
from rl.memory import SequentialMemory
from rl.core import Processor
from rl.callbacks import FileLogger, ModelIntervalCheckpoint
预处理技术
正如我们之前提到的,我们将使用卷积神经网络(CNN)对展示给我们智能体的每个状态进行编码,提取代表性的视觉特征。我们的 CNN 将继续回归这些高级表示与最优 Q 值,这些 Q 值对应于每个给定状态下应该采取的最优行动。因此,我们必须向我们的网络展示一系列输入, corresponding to the sequence of screenshots you would see, when playing an Atari game.
如果我们正在玩《太空侵略者》(Atari 2600),这些截图大致会是这样的:
原始的 Atari 2600 游戏画面帧,设计时旨在让人眼前一亮,符合 70 年代的美学,尺寸为 210 x 160 像素,色彩方案为 128 色。尽管按顺序处理这些原始帧可能在计算上要求较高,但请注意,我们可以从这些帧中对训练图像进行降采样,从而得到更易于处理的表示。实际上,这正是 Minh 等人所采用的方法,将输入维度降至更易管理的大小。这是通过将原始 RGB 图像降采样为 110 x 84 像素的灰度图像实现的,然后裁剪掉图像的边缘部分,因这些部分没有太多变化。这使得我们最终得到了 84 x 84 像素的图像大小。这一维度的减少有助于我们的 CNN 更好地编码代表性的视觉特征,遵循我们在第四章《卷积神经网络》中讲解的理论。
定义输入参数
最终,我们的卷积神经网络将按批次接收这些裁剪后的图像,每次四张。使用这四帧图像,神经网络将被要求估算给定输入帧的最优 Q 值。因此,我们定义了我们的输入形状,指的是经过预处理的 84 x 84 游戏画面帧的大小。我们还定义了一个窗口长度为4
,它仅仅指的是我们的网络每次看到的图像数量。对于每一张图像,网络将对最优 Q 值进行标量预测,该 Q 值最大化了我们智能体可以获得的预期未来奖励:
创建 Atari 游戏状态处理器
由于我们的网络只能通过输入图像观察游戏状态,我们必须首先构建一个 Python 类,允许我们的深度 Q 学习智能体(DQN)处理由 Atari 模拟器生成的状态和奖励。这个类将接受一个处理器对象,处理器对象指的是智能体与其环境之间的耦合机制,正如在keras-rl
库中实现的那样。
我们正在创建AtariProcessor
类,因为我们希望使用相同的网络在不同的环境中进行操作,每个环境具有不同类型的状态、动作和奖励。这背后的直觉是什么呢?嗯,想想太空入侵者游戏和吃豆人游戏之间的游戏画面和可能的操作差异。虽然太空入侵者游戏中的防御者只能左右移动并开火,但吃豆人可以上下左右移动,以应对环境的不同状态。自定义的处理器类帮助我们简化不同游戏之间的训练过程,而不必对学习智能体或观测环境做过多修改。我们将实现的处理器类将允许我们简化不同游戏状态和奖励的处理,这些状态和奖励是通过智能体对环境的作用生成的:
class AtariProcessor(Processor):
def process_observation(self, observation):
# Assert dimension (height, width, channel)
assert observation.ndim == 3
# Retrieve image from array
img = Image.fromarray(observation)
# Resize and convert to grayscale
img = img.resize(INPUT_SHAPE).convert('L')
# Convert back to array
processed_observation = np.array(img)
# Assert input shape
assert processed_observation.shape == INPUT_SHAPE
# Save processed observation in experience memory (8bit)
return processed_observation.astype('uint8')
def process_state_batch(self, batch):
#Convert the batches of images to float32 datatype
processed_batch = batch.astype('float32') / 255.
return processed_batch
def process_reward(self, reward):
return np.clip(reward, -1., 1.) # Clip reward
处理单独的状态
我们的处理器类包含三个简单的函数。第一个函数(process_observation
)接收一个表示模拟游戏状态的数组,并将其转换为图像。然后,这些图像被调整大小、转换回数组,并作为可管理的数据类型返回给经验记忆(这是一个我们稍后将详细说明的概念)。
批量处理状态
接下来,我们有一个(process_state_batch
)函数,它批量处理图像并将其作为float32
数组返回。虽然这个步骤也可以在第一个函数中实现,但我们单独处理的原因是为了提高计算效率。按照简单的数学规律,存储一个float32
数组比存储一个 8 位数组需要更多四倍的内存。由于我们希望将观察结果存储在经验记忆中,因此我们宁愿将它们存储为可管理的表示形式。当处理给定环境中的数百万个状态时,这一点变得尤为重要。
处理奖励
最后,我们类中的最后一个函数使用(process_reward
)函数对从环境中生成的奖励进行裁剪。为什么这么做呢?让我们先考虑一下背景信息。当我们让智能体在真实且未经修改的游戏中进行训练时,这种奖励结构的变化仅在训练过程中进行。我们不会让智能体使用游戏画面中的实际得分,而是将正奖励和负奖励分别固定为+1 和-1。奖励为 0 的部分不受此裁剪操作的影响。这样做在实践中非常有用,因为它可以限制我们在反向传播网络误差时的导数规模。此外,由于智能体不需要为全新的游戏类型学习新的评分方案,因此在不同学习环境中实现相同的智能体变得更加容易。
奖励裁剪的局限性
如 DeepMind 论文(Minh 等,2015)所指出,奖励裁剪的一个明显缺点是,这个操作使得我们的智能体无法区分不同大小的奖励。这一点对于更复杂的模拟环境来说肯定会有影响。例如,考虑一辆真实的自动驾驶汽车。控制的人工智能智能体可能需要评估在给定环境状态下,采取困境性行动时,奖励/惩罚的大小。也许该智能体面临的选择是为了避免更严重的事故,选择撞到行人。这种局限性,然而,对我们智能体在 Atari 2600 游戏中应对简单学习环境的能力似乎并不会产生严重影响。
初始化环境
接下来,我们只需使用之前添加到可用gym
环境中的 Atari 依赖(无需单独导入),初始化太空入侵者环境:
env = gym.make('SpaceInvaders-v0')
np.random.seed(123)
env.seed(123)
nb_actions = env.action_space.n
我们还生成了一个随机种子来始终如一地初始化环境的状态,以便进行可重复的实验。最后,我们定义了一个变量,表示代理在任何给定时刻可以执行的动作数量。
构建网络
从直观上思考我们的这个问题,我们正在设计一个神经网络,该网络接收来自环境的游戏状态序列。在序列的每个状态下,我们希望我们的网络预测出具有最高 Q 值的动作。因此,我们网络的输出将指代每个可能游戏状态下的每个动作的 Q 值。因此,我们首先定义了几个卷积层,随着层数的增加,滤波器数量逐渐增多,步幅长度逐渐减少。所有这些卷积层都使用修正线性单元 (ReLU) 激活函数。接下来,我们添加了一个展平层,将卷积层的输出维度缩减为向量表示。
然后,这些表示会被输入到两个密集连接的层中,这些层执行游戏状态与动作的 Q 值回归:
input_shape = (WINDOW_LENGTH,) + INPUT_SHAPE
# Build Conv2D model
model = Sequential()
model.add(Permute((2, 3, 1), input_shape=input_shape))
model.add(Convolution2D(32, (8, 8), strides=(4, 4), activation='relu'))
model.add(Convolution2D(64, (4, 4), strides=(2, 2), activation='relu'))
model.add(Convolution2D(64, (3, 3), strides=(1, 1), activation='relu'))
model.add(Flatten())
model.add(Dense(512, activation='relu'))
# Last layer: no. of neurons corresponds to action space
# Linear activation
model.add(Dense(nb_actions, activation='linear'))
print(model.summary())
_____________________________________________________________
Layer (type) Output Shape Param #
=============================================================
permute_2 (Permute) (None, 84, 84, 4) 0
_____________________________________________________________
conv2d_4 (Conv2D) (None, 20, 20, 32) 8224
_____________________________________________________________
conv2d_5 (Conv2D) (None, 9, 9, 64) 32832
_____________________________________________________________
conv2d_6 (Conv2D) (None, 7, 7, 64) 36928
_____________________________________________________________
flatten_2 (Flatten) (None, 3136) 0
_____________________________________________________________
dense_3 (Dense) (None, 512) 1606144
_____________________________________________________________
dense_4 (Dense) (None, 6) 3078
=============================================================
Total params: 1,687,206
Trainable params: 1,687,206
Non-trainable params: 0
______________________________________________________________
None
最后,你会注意到我们的输出层是一个密集连接的层,包含与我们代理的动作空间相对应的神经元数量(即它可能执行的动作数)。这个层也采用了线性激活函数,就像我们之前看到的回归示例一样。这是因为我们的网络本质上在执行一种多变量回归,它利用其特征表示来预测代理在给定输入状态下每个动作的最高 Q 值。
缺乏池化层
你可能还注意到与之前的 CNN 示例相比的另一个区别是缺少池化层。之前,我们使用池化层对每个卷积层生成的激活图进行下采样。正如你在第四章《卷积神经网络》中回忆的那样,这些池化层帮助我们实现了对不同类型输入的空间不变性。然而,在为我们特定的使用案例实现 CNN 时,我们可能不希望丢弃特定于表示空间位置的信息,因为这实际上可能是识别代理正确动作的一个重要部分。
如你在这两张几乎相同的图片中所看到的,太空侵略者发射的弹丸的位置显著地改变了我们代理的游戏状态。当代理在第一张图中足够远以避开这个弹丸时,它可能在第二张图中因犯一个错误(向右移动)而迎来末日。由于我们希望它能够显著区分这两种状态,因此我们避免使用池化层。
实时学习中的问题
正如我们之前提到的,我们的神经网络将一次处理四帧图像,并将这些输入回归到每个个体状态(即从 Atari 模拟器中采样的图像)对应的最高 Q 值动作。然而,如果我们不打乱网络接收每个四张图像批次的顺序,那么在学习过程中,网络会遇到一些相当棘手的问题。
我们不希望网络从连续批次的样本中学习的原因是,这些序列是局部相关的。这是一个问题,因为网络的参数在任何给定时刻都会决定由模拟器生成的下一批训练样本。根据马尔可夫假设,未来的游戏状态概率依赖于当前的游戏状态。因此,如果当前的最大化动作要求我们的智能体向右移动,那么接下来批次中的训练样本将会主要是智能体向右移动,导致糟糕且不必要的反馈循环。而且,连续的训练样本通常过于相似,网络难以从中有效学习。这些问题可能会导致网络的损失在训练过程中收敛到局部(而不是全局)最小值。那么,我们到底该如何应对这个问题呢?
存储经验到重放记忆中
解决方案在于为我们的网络构建重放记忆的概念。本质上,重放记忆可以充当一种固定长度的经验 队列。它可以用来存储正在进行的游戏的连续状态,连同所采取的动作、生成的奖励和返回给智能体的状态。这些经验队列会不断更新,以保持最近的n个游戏状态。然后,我们的网络将使用从重放记忆中保存的随机批次经验元组(state
,action
,reward
,和next state
)来执行梯度下降。
在rl.memory
,即keras-rl
模块中,提供了不同类型的重放记忆实现。我们使用SequentialMemory
对象来实现我们的目的。这个对象需要两个参数,如下所示:
memory = SequentialMemory(limit=1000000, window_length=WINDOW_LENGTH)
limit
参数表示要保存在记忆中的条目数。一旦超出此限制,新的条目将替换旧的条目。window_length
参数仅指每个批次中的训练样本数量。
由于经验元组批次的随机顺序,网络不容易陷入局部最小值,并最终会收敛找到最优权重,代表给定环境的最优策略。此外,使用非顺序批次进行权重更新意味着我们可以实现更高的数据效率,因为同一张图像可以被打乱到不同的批次中,从而贡献多次权重更新。最后,这些经验元组甚至可以从人类游戏数据中收集,而不是之前由网络执行的动作。
其他方法(Schaul 等人,2016 年:arxiv.org/abs/1511.05952
)通过添加一个额外的数据结构,记录每个转换(状态 -> 动作 -> 奖励 -> 下一状态)的优先级,实现了优先级版本的经验重放记忆,以便更频繁地重放重要的转换。其背后的直觉是让网络更频繁地从其最佳和最差的表现中学习,而不是从那些无法产生多少学习的实例中学习。尽管这些是一些巧妙的方法,有助于我们的模型收敛到相关的表示,但我们也希望它时不时地给我们带来惊喜,并探索它尚未考虑过的机会。这让我们回到了之前讨论过的,探索-开发困境。
平衡探索与开发
我们如何确保我们的代理在旧策略和新策略之间保持良好的平衡呢?这个问题在我们的 Q 网络随机初始化权重时变得更加复杂。由于预测的 Q 值是这些随机权重的结果,模型在初期训练时会生成次优的预测,进而导致 Q 值学习较差。自然,我们不希望我们的网络过于依赖它最初为给定状态-动作对生成的策略。就像多巴胺成瘾的老鼠一样,如果代理不探索新策略并扩展其视野,而只是利用已知的策略,它不可能在长期内表现良好。为了解决这个问题,我们必须实现一种机制,鼓励代理尝试新的动作,忽略已学得的 Q 值。这样做基本上允许我们的学习代理尝试新的策略,这些策略可能在长期内更有利。
Epsilon-贪婪探索策略
这可以通过算法修改我们学习代理解决环境问题时使用的策略来实现。一个常见的方法是使用 epsilon-贪婪探索策略。在这里,我们定义一个概率(ε)。然后,我们的代理可能会忽略学习到的 Q 值,并以(1 - ε)的概率尝试一个随机动作。因此,如果 epsilon 值设置为 0.5,那么我们的网络平均将忽略其学习的 Q 表所建议的动作,做一些随机的事情。这是一个相当具有探索性的代理。相反,epsilon 值为 0.001 时,网络将更一致地依赖学习到的 Q 值,平均每一百个时间步中才随机选择一次动作。
很少使用固定的 ε 值,因为探索与利用的程度可以基于许多内部(例如,代理的学习率)和外部因素(例如,给定环境中的随机性与确定性的程度)有所不同。在 DeepMind 论文中,研究人员实现了一个随时间衰减的 ε 项,从 1(即完全不依赖初始随机预测)到 0.1(在 10 次中有 9 次依赖于预测的 Q 值):
policy = LinearAnnealedPolicy(EpsGreedyQPolicy(),
attr='eps',
value_max=1.,
value_min=.1,
value_test=.05,
nb_steps=1000000)
因此,衰减的 epsilon 确保我们的代理不会在初期训练阶段依赖于随机预测,而是在 Q 函数逐渐收敛到更一致的预测后,逐渐更积极地利用自身的预测。
初始化深度 Q 学习代理
现在,我们已经通过编程定义了初始化深度 Q 学习代理所需的所有个体组件。为此,我们使用从 rl.agents.dqn
导入的 DQNAgent
对象,并定义了相应的参数,如下所示:
#Initialize the atari_processor() class
processor = AtariProcessor()
# Initialize the DQN agent
dqn = DQNAgent(model=model, #Compiled neural network model
nb_actions=nb_actions, #Action space
policy=policy, #Policy chosen (Try Boltzman Q policy)
memory=memory, #Replay memory (Try Episode Parameter
memory)
processor=processor, #Atari processor class
#Warmup steps to ignore initially (due to random initial weights)
nb_steps_warmup=50000,
gamma=.99, #Discount factor
train_interval=4, #Training intervals
delta_clip=1., #Reward clipping
)
前述参数是根据原始 DeepMind 论文初始化的。现在,我们准备好最终编译我们的模型并启动训练过程了。为了编译模型,我们只需要在我们的 dqn
模型对象上调用 compile 方法:
dqn.compile(optimizer=Adam(lr=.00025), metrics=['mae'])
这里的 compile
方法需要传入一个优化器和我们希望跟踪的度量标准作为参数。在我们的例子中,我们选择了学习率为 0.00025
的 Adam
优化器,并跟踪 平均绝对误差 (MAE) 度量,具体如图所示。
训练模型
现在,我们可以启动我们的深度 Q 学习网络的训练过程。我们通过调用已编译的 DQN 网络对象上的 fit
方法来实现这一点。fit
参数需要传入正在训练的环境(在我们这个例子中是 SpaceInvaders-v0)以及在此次训练过程中总的游戏步数(类似于 epoch,表示从环境中采样的游戏状态总数)作为参数。如果你希望可视化训练过程中代理的表现,可以选择将可选参数 visualize
设置为 True
。虽然这非常有趣——甚至有点让人着迷——但它会显著影响训练速度,因此不建议将其设置为默认选项:
dqn.fit(env, nb_steps=1750000) #visualize=True
Training for 1750000 steps ...
Interval 1 (0 steps performed)
2697/10000 [=======>....................] - ETA: 26s - reward: 0.0126
测试模型
我们使用以下代码测试模型:
dqn.test(env, nb_episodes=10, visualize=True)
Testing for 10 episodes ...
Episode 1: reward: 3.000, steps: 654
Episode 2: reward: 11.000, steps: 807
Episode 3: reward: 8.000, steps: 812
Episode 4: reward: 3.000, steps: 475
Episode 5: reward: 4.000, steps: 625
Episode 6: reward: 9.000, steps: 688
Episode 7: reward: 5.000, steps: 652
Episode 8: reward: 12.000, steps: 826
Episode 9: reward: 2.000, steps: 632
Episode 10: reward: 3.000, steps: 643
<keras.callbacks.History at 0x24280aadc50>
总结 Q 学习算法
恭喜!你现在已经深入理解了深度 Q 学习的概念,并将这些概念应用于使模拟代理逐步学习解决其环境。以下伪代码作为我们刚刚实现的整个深度 Q 学习过程的回顾:
initialize replay memory
initialize Q-Value function with random weights
sample initial state from environment
Keep repeating:
choose an action to perform:
with probability ε select a random action
otherwise select action with argmax a Q(s, a')
execute chosen action
collect reward and next state
save experience <s, a, r, s'> in replay memory
sample random transitions <s, a, r, s'> from replay memory
compute target variable for each mini-batch transition:
if s' is terminal state then target = r
otherwise t = r + γ max a'Q(s', a')
train the network with loss (target - Q(s,a)`²)
s = s'
until done
双重 Q 学习
我们刚刚构建的标准 Q-learning 模型的另一个扩展是 Double Q-learning 的思想,该方法由 Hado van Hasselt(2010 年,及 2015 年)提出。其背后的直觉非常简单。回顾一下,到目前为止,我们通过 Bellman 方程估计每个状态-动作对的目标值,并检查在给定状态下我们的预测偏差,如下所示:
然而,从这种方式估算最大期望未来奖励会出现一个问题。如你可能在之前注意到的那样,目标方程中的最大运算符(y[t])使用相同的 Q 值来评估给定动作,这些 Q 值也被用于预测采样状态下的给定动作。这引入了 Q 值过度估计的倾向,最终可能失控。为了解决这种可能性,Van Hasselt 等人(2016)实施了一个模型,将动作选择与其评估解耦。这是通过使用两个独立的神经网络来实现的,每个网络都被参数化以估算整个方程的一个子集。第一个网络负责预测给定状态下应该采取的动作,而第二个网络则用来生成目标,供第一个网络的预测在计算损失时迭代评估。尽管每次迭代时损失的公式没有变化,但给定状态的目标标签现在可以通过增强的 Double DQN 方程来表示,如下所示:
如我们所见,目标网络有自己的一组参数需要优化,(θ-)。这种将动作选择与评估解耦的做法已被证明可以弥补天真 DQN 所学习到的过度乐观的表示。因此,我们能够更快地收敛我们的损失函数,同时实现更稳定的学习。
实际上,目标网络的权重也可以被固定,并且定期/缓慢地更新,以避免因目标与预测之间的反馈回路不良而使模型不稳定。这项技术被另一本 DeepMind 论文(Hunt、Pritzel、Heess 等人,2016)广泛推广,该论文指出这种方法能够稳定训练过程。
Hunt、Pritzel、Heess 等人的 DeepMind 论文,*Continuous Control with Deep Reinforcement *Learning, 2016,可以通过以下链接访问:arxiv.org/pdf/1509.02971.pdf
。
你可以通过使用之前训练我们《太空侵略者》智能体时的相同代码,并对定义 DQN 智能体的部分做些微小修改,来通过keras-rl
模块实现 Double DQN:
double_dqn = DQNAgent(model=model,
nb_actions=nb_actions,
policy=policy,
memory=memory,
processor=processor,
nb_steps_warmup=50000,
gamma=.99,
target_model_update=1e-2,
train_interval=4,
delta_clip=1.,
enable_double_dqn=True,
)
我们只需要将enable_double_dqn
的布尔值设置为True
,然后就可以开始了!如果需要,您还可以尝试调整预热步数(即在模型开始学习之前的步骤数)以及目标模型更新的频率。我们可以进一步参考以下论文:
- 深度强化学习与双 Q 学习:
arxiv.org/pdf/1509.06461.pdf
对战网络架构
我们将实现的最后一种 Q 学习架构是对战网络架构(arxiv.org/abs/1511.06581
)。顾名思义,在这种架构中,我们通过使用两个独立的估计器来使神经网络与自己对战,一个估计器用于评估状态值,另一个用于评估状态-动作对的值。你可能还记得在本章前面部分,我们使用一个单一的卷积和密集连接层流来估计状态-动作对的质量。然而,我们实际上可以将 Q 值函数分解为两个独立项的和。这样做的原因是让我们的模型能够分别学习某些状态可能有价值,某些则没有价值,而不需要专门学习在每个状态下执行每个动作的影响:
在前面的图表顶部,我们可以看到标准的 DQN 架构。底部则展示了对战 DQN 架构如何分成两个独立的流,其中状态值和状态-动作值被分别估计,而不需要额外的监督。因此,对战 DQN 使用独立的估计器(即密集连接层)来分别估计处于某一状态时的值V(s)以及在给定状态下执行某个动作相较于其他动作的优势A(s,a)。这两个项随后被结合起来,预测给定状态-动作对的 Q 值,从而确保我们的智能体在长期内选择最优动作。与标准的 Q 函数*Q(s,a)*只能让我们估计给定状态下选择动作的价值不同,使用这种方法我们可以分别衡量状态的价值和动作的相对优势。在执行某个动作不会显著改变环境的情况下,这种做法可能会有所帮助。
值函数和优势函数可以通过以下方程给出:
DeepMind 的研究人员(Wang 等人,2016 年)在一个早期的赛车游戏(Atari Enduro)中测试了这样的架构,游戏中要求代理在道路上行驶,途中可能会遇到障碍物。研究人员注意到,状态值流会学习关注道路和屏幕上的分数,而动作优势流则只有在游戏屏幕上出现特定障碍物时才会学会关注。自然,只有当障碍物出现在其路径上时,代理才需要执行一个动作(向左或向右移动)。否则,向左或向右移动对代理来说没有任何意义。另一方面,代理始终需要关注道路和分数,这是由网络的状态值流来完成的。因此,在他们的实验中,研究人员展示了这种架构如何在代理面对许多具有相似后果的动作时,提供更好的策略评估。
我们可以使用keras-rl
模块实现对战 DQN,解决之前提到的 Space Invaders 问题。我们需要做的就是重新定义我们的代理,如下所示:
dueling_dqn = DQNAgent(model=model,
nb_actions=nb_actions,
policy=policy,
memory=memory,
processor=processor,
nb_steps_warmup=50000,
gamma=.99,
target_model_update=10000,
train_interval=4,
delta_clip=1.,
enable_dueling_network=True,
dueling_type='avg'
)
在这里,我们只需将布尔参数enable_dueling_network
设置为True
并指定一个对战类型。
想要了解更多关于网络架构和使用潜在好处的信息,我们鼓励你参考完整的研究论文,《深度强化学习中的对战网络架构》,你可以在arxiv.org/pdf/1511.06581.pdf
查看。
练习
-
在 Atari 环境中实现标准的 Q 学习并采用不同的策略(Boltzman),检查性能指标的差异
-
在相同问题上实现双重 DQN 并比较性能差异
-
实现一个对战 DQN 并比较性能差异
Q 学习的局限性
真是令人惊叹,像这样的相对简单的算法,经过足够的训练时间后,竟能产生复杂的策略,代理能够凭此做出决策。特别是,研究人员(现在你也可以)能够展示出如何通过与环境的充分互动,学习到专家策略。例如,在经典的打砖块游戏中(该游戏作为 Atari 依赖的环境之一),你需要移动屏幕底部的挡板,反弹一个球并击破屏幕上方的砖块。经过足够的训练时间,DQN 代理甚至能想出复杂的策略,比如将球卡在屏幕的顶部,获得最大得分:
这种直观的行为自然会让你产生疑问——我们能将这种方法应用到多远?我们能通过这种方法掌握哪些类型的环境,它的局限性又是什么?
的确,Q 学习算法的优势在于它们能够解决具有高维观测空间的问题,比如来自游戏屏幕的图像。我们通过卷积架构实现了这一点,使我们能够将状态-动作对与最优奖励关联起来。然而,到目前为止,我们关注的动作空间大多是离散的和低维的。例如,向右或向左转是一个离散的动作,而不是像以角度转向左那样的连续动作。后者是一个连续动作空间的例子,因为智能体向左转的动作依赖于一个由特定角度表示的变量,该角度可以取连续值。我们最初也没有那么多可执行的动作(Atari 2600 游戏中的动作范围为 4 到 18)。其他潜在的深度强化学习问题,如机器人运动控制或优化车队部署,可能需要建模非常高维和连续的动作空间,在这种情况下,标准的 DQN 表现较差。这是因为 DQN 依赖于找到最大化 Q 值函数的动作,而这在连续动作空间的情况下需要在每一步进行迭代优化。幸运的是,针对这个问题,已经有其他方法存在。
使用策略梯度改进 Q 学习
到目前为止,我们的方法是通过迭代更新状态-动作对的 Q 值估计,从而推断出最优策略。然而,当面对连续动作空间时,这变成了一项繁重的学习任务。例如,在机器人运动控制的情况下,我们的动作空间由机器人关节位置和角度等连续变量定义。在这种情况下,估计 Q 值函数变得不切实际,因为我们可以假设这个函数本身非常复杂。因此, вместо学习每个关节位置和角度的最优 Q 值,我们可以尝试一种不同的方法。如果我们能够直接学习一个策略,而不是通过迭代更新状态-动作对的 Q 值来推断策略,那会怎么样呢?回想一下,策略仅仅是一个状态轨迹,之后是执行的动作、产生的奖励以及返回给智能体的状态。因此,我们可以定义一组参数化的策略(通过神经网络的权重(θ)参数化),其中每个策略的价值可以由此处给出的函数定义:
在这里,策略的价值由函数*J(θ)表示,其中 theta 表示我们的模型权重。在左侧,我们可以用之前熟悉的术语来定义给定策略的价值,表示预期的累计未来奖励之和。在这个新设置下,我们的目标是找到能够使策略价值函数J(θ)*最大化的模型权重,从而为我们的智能体带来最佳的预期未来奖励。
之前,为了找到函数的全局最小值,我们通过对该函数的一级导数进行迭代优化,采取与梯度负方向成比例的步骤来更新模型权重。这就是我们所说的梯度下降。然而,由于我们想要找到我们策略价值函数的最大值,J(θ),我们将执行梯度上升,它会迭代地更新与梯度正方向成比例的模型权重。因此,我们可以通过评估由给定策略生成的轨迹来使深度神经网络收敛到最优策略,而不是单独评估状态-动作对的质量。接着,我们甚至可以让来自有利策略的动作在给定游戏状态下有更高的被选中概率,而来自不利策略的动作可以被较少地采样。这就是策略梯度方法背后的主要直觉。自然,跟随这一方法会有一整套新的技巧,我们鼓励你去阅读。例如,深度强化学习中的连续控制,可以在arxiv.org/pdf/1509.02971.pdf
找到。
一个值得关注的有趣的策略梯度实现是演员-评论家模型,它可以在连续动作空间中实现,以解决更复杂的高维动作空间问题,例如我们之前讨论过的问题。有关演员-评论家模型的更多信息可以在arxiv.org/pdf/1509.02971.pdf
中找到。
相同的演员-评论家概念已经在不同的环境中应用于各种任务,如自然语言生成和对话建模,甚至是在像《星际争霸 II》这样的复杂实时策略游戏中,感兴趣的读者可以进一步探索:
-
自然语言生成与对话建模:
arxiv.org/pdf/1607.07086.pdf
-
星际争霸 II:强化学习的新挑战:
arxiv.org/pdf/1708.04782.pdf?fbclid=IwAR30QJE6Kw16pHA949pEf_VCTbrX582BDNnWG2OdmgqTIQpn4yPbtdV-xFs
总结
在这一章中,我们涵盖了很多内容。我们不仅探索了机器学习的一个全新分支——强化学习,还实现了一些被证明能够培养复杂自主智能体的最先进算法。我们了解了如何使用马尔可夫决策过程来建模环境,并通过贝尔曼方程评估最优奖励。我们还看到,如何通过使用深度神经网络近似质量函数来解决信用分配问题。在这个过程中,我们探索了许多技巧,如奖励折扣、裁剪和经验回放等(仅举几例),它们有助于表示高维输入,比如游戏画面图像,以便在模拟环境中导航并优化目标。
最后,我们探索了深度 Q 学习领域的一些进展,概述了双重 DQN 和对抗性 DQN 等架构。最后,我们回顾了一些在使智能体成功导航高维动作空间时面临的挑战,并了解了如策略梯度等不同方法如何帮助解决这些问题。
第三部分:混合模型架构
本节让读者了解当前深度学习的潜力与局限,以及如何从学术界和工业界获得启发,整理实施端到端深度学习工作流程所需的所有资源。
本节包含以下章节:
-
第八章,自编码器
-
第九章,生成网络
第八章:自编码器
在前一章中,我们熟悉了机器学习(ML)中的一个新领域:强化学习的领域。我们看到如何通过神经网络增强强化学习算法,以及如何学习近似函数,将游戏状态映射到智能体可能采取的动作。这些动作随后与一个动态的目标变量进行比较,而该目标变量是通过我们所称的贝尔曼方程定义的。严格来说,这是一种自监督学习的机器学习技术,因为用来比较我们预测结果的是贝尔曼方程,而不是一组标记的目标变量,这在监督学习方法中才会出现(例如,带有每个状态下应采取的最优动作标签的游戏画面)。后者虽然可能实现,但对于给定的使用场景来说,计算成本要高得多。现在,我们将继续前进,发现另一种自监督机器学习技术,探索神经自编码器的世界。
本章中,我们将探讨让神经网络学习从给定数据集中编码最具代表性特征的实用性和优势。本质上,这使我们能够保留并在以后重建定义观察类别的关键元素。观察本身可以是图像、自然语言数据,甚至是可能受益于降维的时间序列观察,通过去除那些表示给定观察中较不具信息性方面的信息。你可能会问,谁得益?
本章将涵盖以下主题:
-
为什么选择自编码器?
-
自动编码信息
-
理解自编码器的局限性
-
解析自编码器
-
训练自编码器
-
概览自编码器原型
-
网络规模和表示能力
-
理解自编码器中的正则化
-
使用稀疏自编码器进行正则化
-
探索数据
-
构建验证模型
-
设计深度自编码器
-
使用功能性 API 设计自编码器
-
深度卷积自编码器
-
编译和训练模型
-
测试并可视化结果
-
去噪自编码器
-
训练去噪网络
为什么选择自编码器?
过去(大约 2012 年),自编码器因其在初始化深度卷积神经网络(CNNs)层权重方面的应用(通过一种被称为贪婪逐层预训练的操作)而短暂地享有一些声誉,但随着更好的随机权重初始化方案的出现,以及允许训练更深层神经网络的更具优势的方法(例如 2014 年的批量归一化,及 2015 年的残差学习)逐渐成为主流,研究人员对这种预训练技术的兴趣逐渐减退。
如今,自动编码器的一个重要应用来自于它们能够发现高维数据的低维表示,同时尽量保留其中的核心属性。这使我们能够执行例如恢复损坏图像(或图像去噪)等任务。自动编码器的另一个活跃研究领域是它们能够执行主成分分析,例如数据的变换,从而可以可视化数据中主要方差因素的有用信息。事实上,带有线性激活函数的单层自动编码器与在数据集上执行的标准主成分分析(PCA)操作非常相似。这样的自动编码器只学习一个维度减少的子空间,这个子空间正是通过 PCA 得到的。因此,自动编码器可以与 t-SNE 算法(en.wikipedia.org/wiki/T-distributed_stochastic_neighbor_embedding
)结合使用,后者因其在二维平面上可视化信息的能力而著名,首先对高维数据集进行降采样,然后可视化观察到的主要方差因素。
此外,自动编码器在此类应用中的优势(即执行降维)源于它们可能具有非线性的编码器和解码器函数,而 PCA 算法仅限于线性映射。这使得自动编码器能够学习比 PCA 分析相同数据得到的结果更强大的非线性特征空间表示。事实上,当你处理非常稀疏和高维的数据时,自动编码器可以证明是数据科学工具箱中一个非常强大的工具。
除了自动编码器的这些实际应用外,还有一些更具创意和艺术性的应用。例如,从编码器生成的低维表示中采样,已被用来生成艺术图像,这些图像在纽约某拍卖行以约 50 万美元的价格拍卖(见www.bloomberg.com/news/articles/2018-10-25/ai-generated-portrait-is-sold-for-432-500-in-an-auction-first
)。我们将在下一章回顾此类图像生成技术的基础知识,当时我们将介绍变分自动编码器架构和生成对抗网络(GANs)。但首先,让我们尝试更好地理解自动编码器神经网络的本质。
自动编码信息
那么,自编码器的理念有什么不同之处呢?你肯定已经接触过无数的编码算法,像是 MP3 压缩用于存储音频文件,或者 JPEG 压缩用于存储图像文件。自编码神经网络之所以有趣,是因为它们采用了一种与之前提到的准对等物相比非常不同的信息表示方式。这正是你在阅读完神经网络内部工作机制的七个章节后,理应期待的一种方法。
与 MP3 或 JPEG 算法不同,后者通常对声音和像素有普遍的假设,而神经自编码器则被迫自动从任何训练过程中显示的输入中学习代表性特征。它接着使用在训练过程中捕获的学习表示来重建给定的输入。重要的是要理解,自编码器的吸引力并不在于简单地复制其输入。当训练自编码器时,我们通常并不关心它所生成的解码输出,而更关心的是网络如何转化给定输入的维度。理想情况下,我们希望通过给网络提供激励和约束,以尽可能准确地重建原始输入,从而寻找代表性的编码方案。通过这样做,我们可以将编码器函数应用于类似的数据集,作为一种特征检测算法,从而为给定的输入提供语义丰富的表示。
这些表示方法可以用来执行某种分类,具体取决于所处理的使用案例。因此,采用的正是编码的架构机制,这也定义了自编码器与其他标准编码算法相比的新颖方法。
理解自编码器的局限性
如前所述,神经网络,例如自编码器,被用来自动从数据中学习代表性特征,而不需要明确依赖于人工设计的假设。尽管这种方法可以让我们发现适用于不同类型数据的理想编码方案,但它也确实存在一些局限性。首先,自编码器被认为是数据特定的,这意味着它们的作用仅限于与训练数据非常相似的数据。例如,一个仅训练生成猫图像的自编码器在没有明确训练的情况下,几乎无法生成狗的图像。显然,这似乎限制了此类算法的可扩展性。值得注意的是,直到现在,自编码器在编码图像时的表现并没有明显优于 JPEG 算法。另一个问题是,自编码器往往会产生有损输出。这意味着压缩和解压操作会降低网络输出的质量,生成的表示相比输入会不够精确。这个问题似乎在大多数编码应用场景中都有出现(包括基于启发式的编码方案,如 MP3 和 JPEG)。
因此,自编码器揭示了一些非常有前景的实践方法,用于处理未标记的真实世界数据。然而,今天在数字领域中可用的绝大多数数据实际上是无结构且未标记的。另一个值得注意的常见误解是将自编码器归类为无监督学习,但实际上,它不过是自监督学习的另一种变体,正如我们很快将要发现的那样。那么,这些网络究竟是如何工作的呢?
分析自编码器
从高层次看,自编码器可以被认为是一种特定类型的前馈网络,它学习模仿输入并重构出相似的输出。正如我们之前提到的,它由两部分组成:编码器函数和解码器函数。我们可以将整个自编码器视为一层层互联的神经元,首先通过编码输入数据,然后使用生成的编码重构输出:
一个不完全自编码器的示例
上图展示了一个特定类型的自动编码器网络。从概念上讲,自动编码器的输入层连接到一个神经元层,将数据引导到一个潜在空间,这就是 编码器函数。该函数可以泛化定义为 h = f(x),其中 x 代表网络输入,h 代表由编码器函数生成的潜在空间。潜在空间可能体现了输入到我们网络的压缩表示,随后被解码器函数(即后续的神经元层)用来解开这个简化的表示,将其映射到一个更高维的特征空间。因此,解码器函数(形式化为 r = g(h)) 接着将由编码器生成的潜在空间 (h) 转换为网络的 重构 输出 (r)。
训练一个自动编码器
编码器和解码器函数之间的交互由另一个函数控制,该函数操作输入和输出之间的距离,我们通常称之为神经网络中的 loss
函数。因此,为了训练一个自动编码器,我们只需对编码器和解码器函数分别关于 loss
函数(通常使用均方误差)进行求导,并使用梯度来反向传播模型的误差,更新整个网络的层权重。
因此,自动编码器的学习机制可以表示为最小化一个 loss
函数,其公式如下:
min L(x, g ( f ( x ) ) )
在前面的公式中,L 代表一个 loss
函数(如均方误差 MSE),它对解码器函数的输出(g(f( x )))进行惩罚,惩罚的内容是输出与网络输入 (x) 的偏差。通过这种方式反复最小化重构损失,我们的模型最终将收敛到编码适应输入数据的理想表示,这些表示可以用于解码类似数据,同时损失的信息量最小。因此,自动编码器几乎总是通过小批量梯度下降进行训练,这与其他前馈神经网络的训练方法相同。
虽然自编码器也可以使用一种称为再循环(Hinton 和 McClelland,1988)的技术进行训练,但我们在本章中不会深入讨论这一子话题,因为这种方法在大多数涉及自编码器的机器学习应用中很少使用。仅需提及,再循环通过将给定输入的网络激活与生成的重建的网络激活进行比较,而不是通过反向传播基于梯度的误差(通过对loss
函数相对于网络权重求导来获得)来工作。尽管在概念上有所不同,从理论角度来看,这可能是一个有趣的阅读内容,因为再循环被认为是反向传播算法的生物学上可行的替代方案,暗示着我们自己可能如何随着新信息的出现,更新我们对世界的心理模型。
概览自编码器原型
我们之前描述的其实是一个欠完备自编码器的例子,基本上它对潜在空间维度进行了约束。之所以称其为欠完备,是因为编码维度(即潜在空间的维度)小于输入维度,这迫使自编码器学习数据样本中最显著的特征。
相反,过完备自编码器则具有相对于输入维度更大的编码维度。这种自编码器相比输入大小拥有更多的编码能力,正如下图所示:
网络规模与表示能力
在之前的图示中,我们可以看到四种基本的自编码架构。浅层自编码器(浅层神经网络的扩展)通过仅有一个隐藏层的神经元来定义,而深层自编码器则可以有多个层来执行编码和解码操作。回顾之前章节的内容,较深的神经网络相比浅层神经网络可能具备更强的表示能力。这一原则同样适用于自编码器。除此之外,还注意到,深层自编码器可能会在网络学习表示输入时,显著减少所需的计算资源。它还可以大大减少网络学习输入的丰富压缩版本所需的训练样本数量。虽然读到最后几行可能会促使你们中的一些人开始训练数百层的自编码器,但你可能想要稍安勿躁。赋予编码器和解码器函数过多的能力也会带来自身的缺点。
例如,一个具有过多容量的自编码器可能学会完美地重建毕加索画作的输入图像,而从未学会与毕加索画风相关的任何代表性特征。在这种情况下,你得到的只是一个昂贵的模仿算法,它可以与微软画图的复制功能类比。另一方面,按照所建模数据的复杂性和分布设计自编码器,可能使得自编码器能够捕捉到毕加索创作方法中具有代表性的风格特征,进而成为艺术家和历史学者学习的素材。实际上,选择正确的网络深度和规模可能依赖于对学习过程的理论理解、实验以及与使用场景相关的领域知识的精妙结合。听起来有点耗时吗?幸运的是,可能有一种折衷方案,通过使用正则化自编码器来实现。
理解自编码器中的正则化
在一个极端情况下,你可能总是通过坚持浅层网络并设置非常小的潜在空间维度来限制网络的学习能力。这种方法甚至可能为基准测试复杂方法提供一个优秀的基线。然而,也有其他方法可以让我们在不被过度容量问题惩罚的情况下,受益于更深层网络的表示能力,直到某种程度。这些方法包括修改自编码器使用的loss
函数,以激励学习网络中潜在空间的某些表示标准。
例如,我们可以要求loss
函数考虑潜在空间的稀疏性,偏向于更丰富的表示,而不是其他表示。正如我们所看到的,我们甚至可以考虑潜在空间的导数大小或对缺失输入的鲁棒性等属性,以确保我们的模型确实捕捉到它所展示的输入中的代表性特征。
使用稀疏自编码器进行正则化
如前所述,确保我们的模型从输入中编码代表性特征的一种方法是对表示潜在空间(h)的隐藏层添加稀疏性约束。我们用希腊字母欧米伽(Ω)表示这个约束,这样我们就可以重新定义稀疏自编码器的loss
函数,方式如下:
-
正常自编码器损失:L ( x , g ( f ( x ) ) )
-
稀疏自编码器损失:L ( x , g ( f ( x ) ) ) + Ω(h)
这个稀疏性约束项,Ω(h),可以简单地看作是可以添加到前馈神经网络中的正则化项,就像我们在前几章中看到的那样。
关于自编码器中不同形式稀疏约束方法的全面综述可以在以下研究论文中找到,我们推荐感兴趣的读者参考:通过学习深度稀疏自编码器进行面部表情识别:www.sciencedirect.com/science/article/pii/S0925231217314649
。
这为我们的议程腾出了一些空间,使我们可以简要介绍一些自编码器在实践中使用的其他正则化方法,然后再继续编写我们自己的模型。
使用去噪自编码器的正则化
与稀疏自编码器不同,去噪自编码器通过不同的方式确保我们的模型能够在其赋予的能力范围内捕捉有用的表示。在这种情况下,我们不是向loss
函数添加约束,而是可以实际修改loss
函数中的重建误差项。换句话说,我们只是告诉网络,通过使用该输入的噪声版本来重建其输入。
在这种情况下,噪声可能指的是图像中的缺失像素、句子中的缺失单词,或者碎片化的音频流。因此,我们可以将去噪自编码器的loss
函数重新公式化如下:
-
正常 AE 损失:L ( x , g ( f ( x ) ) )
-
去噪 AE 损失:L ( x , g ( f ( ~x) ) )
在这里,术语(~x)仅指被某种噪声形式破坏过的输入x的版本。我们的去噪自编码器必须对提供的有噪声输入进行去噪,而不是仅仅尝试复制原始输入。向训练数据中添加噪声可能迫使自编码器捕捉最能代表正确重建损坏版本训练实例的特征。
一些有趣的特性和使用案例(例如语音增强)已在以下论文中探讨,对于感兴趣的读者来说值得注意:基于深度去噪自编码器的语音增强:pdfs.semanticscholar.org/3674/37d5ee2ffbfee1076cf21c3852b2ec50d734.pdf
。
这将引出我们在本章中将讨论的最后一种正则化策略——收缩自编码器,然后再进入实际的内容。
使用收缩自编码器的正则化
虽然我们不会深入探讨这种自编码器网络亚种的数学,但收缩自编码器(CAE)因其在概念上与去噪自编码器的相似性以及如何局部扭曲输入空间而值得注意。在 CAE 的情况下,我们再次向loss
函数添加一个约束(Ω),但方式有所不同:
-
正常 AE 损失:L ( x , g ( f ( x ) ) )
-
CAE 损失:L ( x , g ( f ( x) ) ) + Ω(h,x)
在这里,术语*Ω(h, x)*以不同的方式表示,可以按照以下方式公式化:
在这里,CAE 利用对loss
函数的约束来鼓励编码器的导数尽可能小。对于那些更加数学倾向的人来说,约束项Ω(h, x)实际上被称为Frobenius 范数的平方(即元素的平方和),用于填充编码器函数的偏导数的雅可比矩阵。
对于那些希望扩展其知识以了解 CAEs 内部工作原理和特征提取应用的人,以下论文提供了一个优秀的概述:Contractive Auto-Encoders: Explicit Invariance During Feature Extraction:www.iro.umontreal.ca/~lisa/pointeurs/ICML2011_explicit_invariance.pdf
。
从实际角度来看,我们在这里需要理解的是,通过将ω项定义为这样,CAEs 可以学习近似一个函数,该函数可以将输入映射到输出,即使输入略有变化。由于这种惩罚仅在训练过程中应用,网络学会从输入中捕获代表性特征,并且在测试期间能够表现良好,即使所展示的输入与其训练时略有不同。
现在我们已经讨论了基本的学习机制以及定义各种自编码器网络的一些架构变化,我们可以继续进行本章的实现部分。在这里,我们将在 Keras 中设计一个基本的自编码器,并逐步更新架构,以涵盖一些实际考虑因素和用例。
在 Keras 中实现浅层 AE
现在,我们将在 Keras 中实现一个浅层自编码器。我们将使用标准的时尚 MNIST 数据集作为这个模型的用例:通过像素化的 28 x 28 图像来生成不同的时尚服装。由于我们知道网络输出的质量直接取决于可用的输入数据的质量,我们必须警告我们的观众,不要期望通过这种方式生成下一个畅销服装。该数据集提供了程序概念和实现步骤的澄清,您在设计任何类型的 Keras AE 网络时必须熟悉这些步骤。
导入一些库
在本练习中,我们将使用 Keras 的功能性 API,通过 keras.models
进行访问,允许我们构建无环图和多输出模型,就像我们在第四章《卷积神经网络》中所做的那样,深入研究卷积网络的中间层。尽管你也可以使用顺序 API 来复制自动编码器(毕竟自动编码器是顺序模型),但它们通常通过功能性 API 实现,这也让我们有机会更加熟悉 Keras 的两种 API。
import numpy as np
import matplotlib.pyplot as plt
from keras.layers import Input, Dense
from keras.models import Model
from keras.datasets import fashion_mnist
探索数据
接下来,我们只需加载 Keras 中包含的 fashion_mnist
数据集。请注意,尽管我们已经加载了每个图像的标签,但对于我们接下来要执行的任务,这并不是必需的。我们只需要输入图像,而我们的浅层自动编码器将重新生成这些图像:
(x_train, y_train), (x_test, y_test) = fashion_mnist.load_data()
x_train.shape, x_test.shape, type(x_train)
((60000, 28, 28), (10000, 28, 28), numpy.ndarray)
plt.imshow(x_train[1], cmap='binary')
以下是输出:
我们可以继续检查输入图像的维度和类型,然后从训练数据中绘制一个示例图像,满足我们自己的视觉需求。该示例似乎是一件印有一些难以辨认内容的休闲 T 恤。很好——现在,我们可以继续定义我们的自动编码器模型了!
数据预处理
正如我们之前做过无数次的那样,我们现在将像素数据归一化到 0 和 1 之间,这有助于提高我们网络对归一化数据的学习能力:
# Normalize pixel values
x_train = x_train.astype('float32') / 255.
x_test = x_test.astype('float32') / 255.
# Flatten images to 2D arrays
x_train = x_train.reshape((len(x_train), np.prod(x_train.shape[1:])))
x_test = x_test.reshape((len(x_test), np.prod(x_test.shape[1:])))
# Print out the shape
print(x_train.shape)
print(x_test.shape)
-----------------------------------------------------------------------
(60000, 784)
(10000, 784)
我们还将把 28 x 28 像素的图像平铺成一个 784 像素的向量,就像我们在之前训练前馈网络时所做的那样。最后,我们将打印出训练集和测试集的形状,以确保它们的格式符合要求。
构建模型
现在我们准备好在 Keras 中设计我们的第一个自动编码器网络,我们将使用功能性 API 来实现。功能性 API 的基本原理相当简单,正如我们在之前的示例中所看到的那样。在我们的应用场景中,我们将定义潜在空间的编码维度。在这里,我们选择了 32。这意味着每个 784 像素的图像将经过一个压缩维度,该维度仅存储 32 个像素,输出将从中重建。
这意味着压缩因子为 24.5(784/32),虽然这个选择有些随意,但可以作为类似任务的经验法则:
# Size of encoded representation
# 32 floats denotes a compression factor of 24.5 assuming input is 784 float
# we have 32*32 or 1024 floats
encoding_dim = 32
#Input placeholder
input_img = Input(shape=(784,))
#Encoded representation of input image
encoded = Dense(encoding_dim, activation='relu', activity_regularizer=regularizers.l1(10e-5))(input_img)
# Decode is lossy reconstruction of input
decoded = Dense(784, activation='sigmoid')(encoded)
# This autoencoder will map input to reconstructed output
autoencoder = Model(input_img, decoded)
然后,我们使用 keras.layers
中的输入占位符来定义输入层,并指定我们期望的平铺图像维度。正如我们从之前的 MNIST 实验中知道的那样(通过一些简单的数学计算),将 28 x 28 像素的图像平铺后得到一个 784 像素的数组,之后可以通过前馈神经网络进行处理。
接下来,我们定义编码后的潜在空间的维度。这是通过定义一个与输入层相连的全连接层来完成的,并且该层的神经元数与我们的编码维度(先前定义为 32)相对应,使用 ReLU 激活函数。这些层之间的连接通过在定义后续层的参数后,括号中包含定义前一层的变量来表示。
最后,我们定义解码器函数作为一个与输入层维度相同的全连接层(784 个像素),使用 sigmoid 激活函数。该层自然地与表示潜在空间的编码维度相连接,并重新生成依赖于编码层神经激活的输出。现在我们可以通过使用功能性 API 中的模型类来初始化自编码器,并将输入占位符和解码器层作为参数提供给它。
实现稀疏性约束
正如我们在本章前面提到的,在设计自编码器时有许多方法可以进行正则化。例如,稀疏自编码器通过对潜在空间实施稀疏性约束,强制自编码器偏向丰富的表示。回想一下,当神经网络中的神经元的输出值接近 1 时,它们可能会激活,而当输出接近 0 时,它们则不会激活。添加稀疏性约束可以简单地理解为限制潜在空间中的神经元大部分时间保持不活跃。因此,任何给定时刻可能只有少数神经元被激活,迫使这些被激活的神经元尽可能高效地传播信息,从潜在空间到输出空间。幸运的是,在 Keras 中实现这一过程非常简单。这可以通过定义activity_regularizer
参数来实现,同时在定义代表潜在空间的全连接层时进行设置。在下面的代码中,我们使用keras.regularizers
中的 L1 正则化器,稀疏参数非常接近零(在我们这里是 0.067)。现在你也知道如何在 Keras 中设计稀疏自编码器了!虽然我们将继续使用非稀疏版本,但为了这个练习的目的,你可以比较这两种浅层自编码器的性能,亲自感受在设计此类模型时向潜在空间添加稀疏性约束的好处。
编译和可视化模型
我们可以通过简单地编译模型并调用模型对象上的summary()
来可视化我们刚刚做的事情,像这样。我们将选择 Adadelta 优化器,它在反向传播过程中限制了过去梯度的累计数目,仅在一个固定的窗口内进行,而不是通过选择像 Adagrad 优化器那样单调减少学习率。如果你错过了本书早些时候提到的内容,我们鼓励你去研究可用优化器的广泛库(ruder.io/optimizing-gradient-descent/
),并进行实验以找到适合你用例的优化器。最后,我们将定义二元交叉熵作为loss
函数,它在我们的情况下考虑了输出生成的像素级损失:
autoencoder.compile(optimizer='adadelta', loss='binary_crossentropy')
autoencoder.summary()
以下是输出:
构建验证模型
现在我们几乎拥有了启动浅层自编码器训练会话所需的所有内容。然而,我们缺少一个至关重要的组成部分。严格来说,这部分并不是训练自编码器所必需的,但我们必须实现它,以便能够直观地验证我们的自编码器是否真的从训练数据中学习到重要特征。为了做到这一点,我们实际上将定义两个额外的网络。别担心——这两个网络本质上是我们刚刚定义的自编码器网络中编码器和解码器功能的镜像。因此,我们所做的就是创建一个独立的编码器和解码器网络,它们将匹配自编码器中编码器和解码器功能的超参数。这两个独立的网络将在自编码器训练完成后用于预测。基本上,编码器网络将用于预测输入图像的压缩表示,而解码器网络则会继续预测存储在潜在空间中的信息的解码版本。
定义一个独立的编码器网络
在下面的代码中,我们可以看到编码器功能是我们自编码器上半部分的精确副本;它本质上将展平的像素值输入向量映射到一个压缩的潜在空间:
''' The seperate encoder network '''
# Define a model which maps input images to the latent space
encoder_network = Model(input_img, encoded)
# Visualize network
encoder_network.summary()
以下是总结:
定义一个独立的解码器网络
同样,在下面的代码中,我们可以看到解码器网络是我们自编码器神经网络下半部分的完美副本,它将存储在潜在空间中的压缩表示映射到重建输入图像的输出层:
''' The seperate decoder network '''
# Placeholder to recieve the encoded (32-dimensional) representation as input
encoded_input = Input(shape=(encoding_dim,))
# Decoder layer, retrieved from the aucoencoder model
decoder_layer = autoencoder.layers[-1]
# Define the decoder model, mapping the latent space to the output layer
decoder_network = Model(encoded_input, decoder_layer(encoded_input))
# Visualize network
decoder_network.summary()
这是总结:
请注意,要定义解码器网络,我们必须首先构建一个形状与我们的编码维度(即 32)相匹配的输入层。然后,我们只需通过引用之前自动编码器模型最后一层的索引来复制解码器层。现在,我们已经准备好启动自动编码器网络的训练了!
训练自动编码器
接下来,我们像之前做过无数次的那样,简单地训练我们的自动编码器网络。我们选择将该模型训练 50 个 epoch,每次批次处理 256 张图像,然后再进行网络节点的权重更新。我们在训练过程中还会打乱数据。正如我们所知道的那样,这样做能确保批次之间减少一些方差,从而提高模型的泛化能力:
最后,我们还使用我们的测试集定义了验证数据,只是为了能够在每个 epoch 结束时比较模型在未见示例上的表现。请记住,在正常的机器学习工作流程中,常见的做法是将数据分为验证集和开发集,这样你可以在一个数据集上调整模型,并在另一个数据集上进行测试。虽然这不是我们演示用例的前提,但为了获得可泛化的结果,实施这种双重验证策略总是有益的。
可视化结果
现在到了收获成果的时候了。让我们看看我们的自动编码器能够通过使用我们隔离的测试集重建出什么样的图像。换句话说,我们将为网络提供与训练集相似(但不完全相同)的图像,看看模型在未见数据上的表现如何。为此,我们将使用编码器网络对测试集进行预测。编码器将预测如何将输入图像映射到压缩表示。然后,我们将简单地使用解码器网络预测如何解码由编码器网络生成的压缩表示。以下代码展示了这些步骤:
# Time to encode some images
encoded_imgs = encoder_network.predict(x_test)
# Then decode them
decoded_imgs = decoder_network.predict(encoded_imgs)
接下来,我们重构一些图像,并将它们与触发重构的输入进行比较,看看我们的自动编码器是否捕捉到了衣物应有的外观。为此,我们将简单地使用 Matplotlib 绘制九个图像,并将它们的重构图像展示在下方,如下所示:
# use Matplotlib (don't ask)
import matplotlib.pyplot as plt
plt.figure(figsize=(22, 6))
num_imgs = 9
for i in range(n):
# display original
ax = plt.subplot(2, num_imgs, i + 1)
true_img = x_test[i].reshape(28, 28)
plt.imshow(true_img)
# display reconstruction
ax = plt.subplot(2, num_imgs, i + 1 + num_imgs)
reconstructed_img = decoded_imgs[i].reshape(28,28)
plt.imshow(reconstructed_img)
plt.show()
以下是生成的输出:
如你所见,虽然我们的浅层自动编码器无法重建品牌标签(例如第二张图片中的Lee标签),但它确实能捕捉到人类服装的基本概念,尽管它的学习能力相当有限。但这就足够了吗?对于任何实际应用场景来说,答案是“不够”,比如计算机辅助服装设计。缺少的细节太多,部分是由于我们网络的学习能力有限,部分是因为压缩输出存在损失。自然而然地,这让人想知道,深度模型能做到什么呢?正如那句老话所说,nullius in verba(用更现代的语言来说,就是让我们自己看看!)。
设计一个深度自动编码器
接下来,我们将研究自动编码器的重建效果可以有多好,看看它们是否能生成比我们刚刚看到的模糊表示更好的图片。为此,我们将设计一个深度前馈自动编码器。如你所知,这意味着我们将在自动编码器的输入层和输出层之间添加额外的隐藏层。为了保持趣味性,我们还将使用一个不同的数据集。你可以根据自己的兴趣,在 fashion_mnist
数据集上重新实现这个方法,进一步探索自动编码器能达成的时尚感。
对于下一个练习,我们将使用位于 Kaggle 的 10 种猴子物种数据集。我们将尝试重建我们这些顽皮、捣蛋的“远房亲戚”——来自丛林的猴子们的图片,并看看我们的自动编码器在一个更复杂的重建任务中表现如何。这也给了我们一个机会,远离 Keras 中现成预处理数据集的舒适区,因为我们将学会处理不同大小和更高分辨率的图片,而不是单调的 MNIST 示例:www.kaggle.com/slothkong/10-monkey-species
。
导入必要的库
我们将首先导入必要的库,这已经是传统了。你会注意到一些常见的库,比如 NumPy、pandas、Matplotlib,以及一些 Keras 的模型和层对象:
import cv2
import datetime as dt
import matplotlib.pylab as plt
import numpy as np
import pandas as pd
from keras import models, layers, optimizers
from keras.layers import Input, Dense
from keras.models import Model
from pathlib import Path
from vis.utils import utils
请注意,我们从 Keras 的 vis
库中导入了一个实用模块。虽然该模块包含许多其他方便的图片处理功能,但我们将使用它来将我们的训练图片调整为统一的尺寸,因为该数据集中的图片并不都是统一的。
理解数据
我们选择这个数据集用于我们的应用案例有一个特定的原因。与 28 x 28 像素的衣物图片不同,这些图片呈现了丰富和复杂的特征,比如体型的变化,以及当然,还有颜色!我们可以绘制出数据集的组成,看看类别分布的情况,这完全是为了满足我们的好奇心:
你会注意到,这 10 种不同的猴子物种各自有显著不同的特征,包括不同的体型、毛发颜色和面部构成,这使得对于自编码器来说这是一个更加具有挑战性的任务。以下是来自八种不同猴子物种的示例图像,以便更好地展示这些物种之间的差异。如你所见,它们每一种看起来都独一无二:
由于我们知道自编码器是数据特定的,因此训练自编码器重构一个具有高方差的图像类别可能会导致可疑的结果,这也是合乎逻辑的。然而,我们希望这能为你提供一个有用的案例,以便你更好地理解使用这些模型时会遇到的潜力和限制。那么,让我们开始吧!
导入数据
我们将从 Kaggle 仓库开始导入不同猴子物种的图像。像之前一样,我们将数据下载到文件系统中,然后使用 Python 内置的操作系统接口(即os
模块)访问训练数据文件夹:
import os
all_monkeys = []
for image in os.listdir(train_dir):
try:
monkey = utils.load_img(('C:/Users/npurk/Desktop/VAE/training/' + image), target_size=(64,64))
all_monkeys.append(monkey)
except Exception as e:
pass
print('Recovered data format:', type(all_monkeys))
print('Number of monkey images:', len(all_monkeys))
-----------------------------------------------------------------------
Recovered data format: <class 'list'>
Number of monkey images: 1094
你会注意到我们将图像变量嵌套在一个try
/except
循环中。这只是一个实现上的考虑,因为我们发现数据集中的一些图像已经损坏。因此,如果我们无法通过utils
模块中的load_img()
函数加载图像,我们将完全忽略该图像文件。这种(有些任意的)选择策略使得我们从训练文件夹中恢复了 1,094 张图像,总共有 1,097 张。
数据预处理
接下来,我们将把像素值列表转换成 NumPy 数组。我们可以打印出数组的形状来确认我们确实有 1,094 张 64 x 64 像素的彩色图像。确认之后,我们只需通过将每个像素值除以可能的最大像素值(即 255)来将像素值归一化到 0 – 1 之间:
# Make into array
all_monkeys = np.asarray(all_monkeys)
print('Shape of array:', all_monkeys.shape)
# Normalize pixel values
all_monkeys = all_monkeys.astype('float32') / 255.
# Flatten array
all_monkeys = all_monkeys.reshape((len(all_monkeys), np.prod(all_monkeys.shape[1:])))
print('Shape after flattened:', all_monkeys.shape)
Shape of array: (1094, 64, 64, 3)
Shape after flattened: (1094, 12288)
最后,我们将四维数组展平成二维数组,因为我们的深度自编码器由前馈神经网络组成,神经网络通过其层传播二维向量。这类似于我们在第三章中所做的,信号处理 – 使用神经网络进行数据分析,我们实际上是将每张三维图像(64 x 64 x 3)转换成一个二维向量,维度为(1,12,288)。
数据分区
现在我们的数据已经经过预处理,并作为一个标准化像素值的 2D 张量存在,我们最终可以将其划分为训练集和测试集。这样做非常重要,因为我们希望最终在网络从未见过的图像上使用我们的模型,并能够利用它对猴子应该是什么样子的理解重建这些图像。请注意,虽然我们在这个用例中并不使用数据集中提供的标签,但网络本身会接收到每个图像的标签。在这种情况下,标签将仅仅是图像本身,因为我们处理的是图像重建任务,而非分类。因此,在自编码器的情况下,输入变量与目标变量是相同的。正如以下截图所示,我们使用 sklearn 的模型选择模块中的 train_test_split
函数来生成训练和测试数据(80/20 的划分比例)。你会注意到,由于我们任务的性质,x
和 y
变量都由相同的数据结构定义:
现在我们剩下 875 个训练样本和 219 个测试样本,用于训练和测试我们的深度自编码器。请注意,Kaggle 数据集自带一个明确的测试集目录,因为该数据集的最初目的是尝试使用机器学习模型对不同猴子物种进行分类。然而,在我们的用例中,我们暂时并没有严格保证类的平衡,只是对深度自编码器在高方差数据集上进行训练时重建图像的表现感兴趣。我们确实鼓励进一步的实验,比较在特定猴子物种上训练的深度自编码器的表现。逻辑上,这些模型会在重建输入图像时表现更好,因为它们在训练观察中的相似性较高。
使用功能性 API 设计自编码器
就像我们在前一个示例中做的那样,我们将使用功能性 API 来构建我们的深度自编码器。我们将导入输入层和全连接层,以及我们稍后将用于初始化网络的模型对象。我们还将定义图像的输入维度(64 x 64 x 3 = 12,288),以及编码维度为 256,这样我们的压缩比为 48。简单来说,这意味着每张图像会被压缩 48 倍,然后网络将尝试从潜在空间中重建它:
from keras.layers import Input, Dense
from keras.models import Model
##Input dimension
input_dim=12288
##Encoding dimension for the latent space
encoding_dim=256
压缩因子是一个非常重要的参数,值得考虑,因为将输入映射到非常低的维度空间会导致过多的信息丢失,从而导致重建效果较差。可能根本没有足够的空间存储图像的关键要素。另一方面,我们已经知道,向模型提供过多的学习能力可能会导致过拟合,这也是手动选择压缩因子可能相当棘手的原因。当有疑问时,尝试不同的压缩因子和正则化方法(只要你有时间)总是值得一试。
构建模型
为了构建我们的深度自编码器,我们将从定义输入层开始,该层接受与猴子图像的二维向量相对应的维度。接着,我们简单地开始定义网络的编码器部分,使用密集层,每一层的神经元数量逐层减少,直到达到潜在空间。请注意,我们简单地选择了每一层神经元数量相对于所选编码维度减少的比例为 2。因此,第一层有(256 x 4) 1024 个神经元,第二层有(256 x 2) 512 个神经元,第三层,即潜在空间层,有 256 个神经元。虽然你不必严格遵守这一约定,但通常在接近潜在空间时减少每一层的神经元数量,而在潜在空间之后的层中增加神经元数量,这是在使用欠完备自编码器时的常见做法:
# Input layer placeholder
input_layer = Input(shape=(input_dim,))
# Encoding layers funnel the images into lower dimensional representations
encoded = Dense(encoding_dim * 4, activation='relu')(input_layer)
encoded = Dense(encoding_dim * 2, activation='relu')(encoded)
# Latent space
encoded = Dense(encoding_dim, activation='relu')(encoded)
# "decoded" is the lossy reconstruction of the input
decoded = Dense(encoding_dim * 2, activation='relu')(encoded)
decoded = Dense(encoding_dim * 4, activation='relu')(decoded)
decoded = Dense(input_dim, activation='sigmoid')(decoded)
# this model maps an input to its reconstruction
autoencoder = Model(input_layer, decoded)
autoencoder.summary()
_______________________________________________________________
Layer (type) Output Shape Param # =================================================================
input_1 (InputLayer) (None, 12288) 0 _______________________________________________________________
dense_1 (Dense) (None, 1024) 12583936 _______________________________________________________________
dense_2 (Dense) (None, 512) 524800 _______________________________________________________________
dense_3 (Dense) (None, 256) 131328 _______________________________________________________________
dense_4 (Dense) (None, 512) 131584 _______________________________________________________________
dense_5 (Dense) (None, 1024) 525312 _______________________________________________________________
dense_6 (Dense) (None, 12288) 12595200 =================================================================
Total params: 26,492,160
Trainable params: 26,492,160
Non-trainable params: 0
_________________________________________________________________
最后,我们通过将输入层和解码器层作为参数传递给模型对象来初始化自编码器。然后,我们可以直观地总结我们刚刚构建的模型。
训练模型
最后,我们可以开始训练会话了!这次,我们将使用adam
优化器来编译模型,并用均方误差来操作loss
函数。然后,我们只需通过调用.fit()
方法并提供适当的参数来开始训练:
autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x_train, x_train, epochs=100, batch_size=20, verbose=1)
Epoch 1/100
875/875 [==============================] - 15s 17ms/step - loss: 0.0061
Epoch 2/100
875/875 [==============================] - 13s 15ms/step - loss: 0.0030
Epoch 3/100
875/875 [==============================] - 13s 15ms/step - loss: 0.0025
Epoch 4/100
875/875 [==============================] - 14s 16ms/step - loss: 0.0024
Epoch 5/100
875/875 [==============================] - 13s 15ms/step - loss: 0.0024
该模型在第 100 个周期结束时的损失为(0.0046)。请注意,由于之前为浅层模型选择了不同的loss
函数,因此每个模型的损失指标不能直接进行比较。实际上,loss
函数的定义方式决定了模型试图最小化的目标。如果你希望基准测试并比较两种不同神经网络架构的性能(例如前馈网络和卷积神经网络),建议首先使用相同的优化器和loss
函数,然后再尝试其他的选择。
可视化结果
现在,让我们通过在孤立的测试集上测试深度自编码器的表现,来看看它能够进行的重建。为此,我们将简单地使用单独的编码器网络来预测如何将这些图像压缩到潜在空间,然后解码器网络将从编码器预测的潜在空间中接手,进行解码并重建原始图像:
decoded_imgs = autoencoder.predict(x_test)
# use Matplotlib (don't ask)
import matplotlib.pyplot as plt
n = 6 # how many digits we will display
plt.figure(figsize=(22, 6))
for i in range(n):
# display original
ax = plt.subplot(2, n, i + 1)
plt.imshow(x_test[i].reshape(64, 64, 3)) #x_test
plt.gray()
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
# display reconstruction
ax = plt.subplot(2, n, i + 1 + n)
plt.imshow(decoded_imgs[i].reshape(64, 64, 3))
plt.gray()
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
plt.show()
我们将得到以下输出:
尽管这些图像本身可以说在美学上令人愉悦,但似乎代表猴子的本质特征大部分依然没有被我们的模型捕捉到。大多数重建图像看起来像星空,而不是猴子的特征。我们确实注意到,网络已经开始在一个非常基础的层面上学习到一般的人形形态,但这并不足为奇。那么,如何改进呢?归根结底,我们希望至少能够用一些看起来逼真的猴子重建图像来结束这一章。为此,我们将使用一种特定类型的网络,它擅长处理图像数据。我们所说的就是卷积神经网络(CNN)架构,我们将重新设计它,以便在本练习的下一部分中构建一个深度卷积自编码器。
深度卷积自编码器
幸运的是,我们所要做的就是定义一个卷积网络,并将我们的训练数组调整为适当的维度,以测试它在当前任务中的表现。因此,我们将导入一些卷积、MaxPooling 和 UpSampling 层,开始构建网络。我们定义输入层,并为其提供 64 x 64 彩色图像的形状。然后,我们简单地交替使用卷积层和池化层,直到达到潜在空间,该空间由第二个 MaxPooling2D
层表示。另一方面,从潜在空间出去的层必须交替使用卷积层和 UpSampling 层。UpSampling 层顾名思义,通过重复前一层的数据的行和列,简单地增加表示维度:
from keras.layers import Conv2D, MaxPooling2D, UpSampling2D
# Input Placeholder
input_img = Input(shape=(64, 64, 3)) # adapt this if using `channels_first` image data format
# Encoder part
l1 = Conv2D(32, (3, 3), activation='relu', padding='same')(input_img)
l2 = MaxPooling2D((2, 2), padding='same')(l1)
l3 = Conv2D(16, (3, 3), activation='relu', padding='same')(l2)
# Latent Space, with dimension (None, 32, 32, 16)
encoded = MaxPooling2D((1,1), padding='same')(l3)
# Decoder Part
l8 = Conv2D(16, (3, 3), activation='relu', padding='same')(encoded)
l9 = UpSampling2D((2, 2))(l8)
decoded = Conv2D(3, (3, 3), activation='sigmoid', padding='same')(l9)
autoencoder = Model(input_img, decoded)
autoencoder.summary()
_______________________________________________________________
Layer (type) Output Shape Param # =================================================================
input_2 (InputLayer) (None, 64, 64, 3) 0 _______________________________________________________________
conv2d_5 (Conv2D) (None, 64, 64, 32) 896 _______________________________________________________________
max_pooling2d_3 (MaxPooling2 (None, 32, 32, 32) 0 _______________________________________________________________
conv2d_6 (Conv2D) (None, 32, 32, 16) 4624 _______________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 32, 32, 16) 0 _______________________________________________________________
conv2d_7 (Conv2D) (None, 32, 32, 16) 2320 _______________________________________________________________
up_sampling2d_2 (UpSampling2 (None, 64, 64, 16) 0 _______________________________________________________________
conv2d_8 (Conv2D) (None, 64, 64, 3) 435 =================================================================
Total params: 8,275
Trainable params: 8,275
Non-trainable params: _________________________________________________________________
正如我们所见,这个卷积自编码器有八层。信息首先进入输入层,然后卷积层生成 32 个特征图。这些特征图通过最大池化层进行下采样,生成 32 个特征图,每个特征图的大小为 32 x 32 像素。接着,这些特征图传递到潜在层,该层存储输入图像的 16 种不同表示,每种表示的尺寸为 32 x 32 像素。这些表示随后传递到后续层,输入在卷积和上采样操作下不断处理,直到到达解码层。就像输入层一样,我们的解码层与 64 x 64 彩色图像的尺寸匹配。你可以通过使用 Keras 后台模块中的int_shape()
函数来检查特定卷积层的尺寸(而不是可视化整个模型),如下所示:
# Check shape of a layer
import keras
keras.backend.int_shape(encoded)
(None, 32, 32, 16)
编译和训练模型
接下来,我们简单地使用相同的优化器和loss
函数来编译我们的网络,这些都是我们为深度前馈网络选择的,并通过调用模型对象的.fit()
方法启动训练会话。需要注意的是,我们只训练这个模型 50 个周期,并且在每次批量更新时处理 128 张图像。证明这种方法在计算上更高效,使得我们能在比训练前馈模型所需的时间短得多的时间内完成训练。让我们看看在这个特定用例中,训练时间和准确性之间的折衷是否对我们有利:
autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x_train, x_train, epochs=50, batch_size=20,
shuffle=True, verbose=1)
Epoch 1/50
875/875 [==============================] - 7s 8ms/step - loss: 0.0462
Epoch 2/50
875/875 [==============================] - 6s 7ms/step - loss: 0.0173
Epoch 3/50
875/875 [==============================] - 7s 9ms/step - loss: 0.0133
Epoch 4/50
875/875 [==============================] - 8s 9ms/step - loss: 0.0116
到第 50^(次)训练周期结束时,模型的损失达到了(0.0044)。这比早期的前馈模型要低,后者在训练时使用了更大的批量大小,并且训练了较少的训练周期。接下来,让我们通过视觉判断模型在重建从未见过的图像时的表现。
测试和可视化结果
现在是时候看看卷积神经网络(CNN)是否真的能够胜任我们当前的图像重建任务了。我们简单地定义了一个辅助函数,允许我们绘制出从测试集中生成的若干样本,并将它们与原始测试输入进行比较。然后,在接下来的代码单元中,我们定义了一个变量,用来存储我们模型对测试集进行推理后的结果,方法是使用模型对象的.predict()
方法。这将生成一个 NumPy ndarray,包含所有解码后的测试集输入图像。最后,我们调用compare_outputs()
函数,使用测试集和对应的解码预测作为参数来可视化结果:
def compare_outputs(x_test, decoded_imgs=None, n=10):
plt.figure(figsize=(22, 5))
for i in range(n):
ax = plt.subplot(2, n, i+1)
plt.imshow(x_test[i].reshape(64,64,3))
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
if decoded_imgs is not None:
ax = plt.subplot(2, n, i+ 1 +n)
plt.imshow(decoded_imgs[i].reshape(64,64,3))
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
plt.show()
decoded_imgs = autoencoder.predict(x_test)
print('Upper row: Input image provided \nBottom row: Decoded output
generated')
compare_outputs(x_test, decoded_imgs)
Upper row: Input image provided
Bottom row: Decoded output generated
以下是输出结果:
如我们所见,深度卷积自编码器在重建测试集中的图像方面表现非常出色。它不仅学会了身体形态和正确的色彩方案,甚至能够重建一些细节,比如相机闪光灯下的红眼现象(如猴子 4 及其人工复制品所示)。太棒了!所以,我们成功地重建了一些猿类图像。随着兴奋感的渐渐消退(如果一开始就有的话),我们将希望将自编码器应用于更多有用的现实任务——比如图像去噪任务,在这类任务中,我们委托网络从损坏的输入中完全重建图像。
去噪自编码器
再次,我们将继续使用猴子物种数据集,并修改训练图像以引入噪声因子。这个噪声因子本质上是通过改变原始图像的像素值,去除构成原始图像的一些信息,从而使任务变得比简单重建原始输入更具挑战性。需要注意的是,这意味着我们的输入变量将是噪声图像,而在训练期间,网络看到的目标变量将是未损坏的噪声输入图像版本。为了生成训练和测试图像的噪声版本,我们只需对图像像素应用一个高斯噪声矩阵,然后将其值截断在 0 到 1 之间:
noise_factor = 0.35
# Define noisy versions
x_train_noisy = x_train + noise_factor * np.random.normal(loc=0.0, scale=1.0, size=x_train.shape)
x_test_noisy = x_test + noise_factor * np.random.normal(loc=0.0, scale=1.0, size=x_test.shape)
# CLip values between 0 and 1
x_train_noisy = np.clip(x_train_noisy, 0., 1.)
x_test_noisy = np.clip(x_test_noisy, 0., 1.)
我们可以通过绘制来自数据中的一个随机示例,看到我们任意选择的噪声因子0.35
如何实际影响图像,如下面的代码所示。在这个分辨率下,噪声图像几乎无法被人眼理解,看起来仅仅是一些随机像素聚集在一起:
# Effect of adding noise factor
f = plt.figure()
f.add_subplot(1,2, 1)
plt.imshow(x_test[1])
f.add_subplot(1,2, 2)
plt.imshow(x_test_noisy[1])
plt.show(block=True)
这是您将得到的输出:
训练去噪网络
我们将使用相同的卷积自编码器架构来处理这个任务。然而,我们将重新初始化模型并从头开始训练,这次使用的是带有噪声输入变量的数据:
autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x_train_noisy, x_train, epochs=50, batch_size=20,
shuffle=True, verbose=1)
Epoch 1/50875/875 [==============================] - 7s 8ms/step - loss: 0.0449
Epoch 2/50
875/875 [==============================] - 6s 7ms/step - loss: 0.0212
Epoch 3/50
875/875 [==============================] - 6s 7ms/step - loss: 0.0185
Epoch 4/50
875/875 [==============================] - 6s 7ms/step - loss: 0.0169
如我们所见,在去噪自编码器的情况下,损失收敛速度比之前的实验更为缓慢。这是自然而然的,因为现在输入信息已经丢失了很多,导致网络更难学习到一个适当的潜在空间来生成未损坏的输出。因此,网络在压缩和重建操作中被迫变得稍微有点创造性。该网络的训练在 50 个周期后结束,损失为 0.0126。现在我们可以对测试集进行一些预测,并可视化一些重建结果。
可视化结果
最后,我们可以测试模型在面对更具挑战性的任务(如图像去噪)时的表现。我们将使用相同的辅助函数,将我们的网络输出与测试集中的一个样本进行比较,如下所示:
def compare_outputs(x_test, decoded_imgs=None, n=10):
plt.figure(figsize=(22, 5))
for i in range(n):
ax = plt.subplot(2, n, i+1)
plt.imshow(x_test_noisy[i].reshape(64,64,3))
plt.gray()
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
if decoded_imgs is not None:
ax = plt.subplot(2, n, i+ 1 +n)
plt.imshow(decoded_imgs[i].reshape(64,64,3))
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
plt.show()
decoded_imgs = autoencoder.predict(x_test_noisy)
print('Upper row: Input image provided \nBottom row: Decoded output generated')
compare_outputs(x_test, decoded_imgs)
Upper row: Input image provided
Bottom row: Decoded output generated
以下是输出结果:
如我们所见,尽管添加了噪声因素,网络在重建图像方面表现相当不错!这些图像对人眼来说很难区分,因此,网络能够重建其中元素的整体结构和组成确实值得注意,尤其是考虑到分配给网络的有限学习能力和训练时间。
我们鼓励你通过更改层数、过滤器数量和潜在空间的编码维度来尝试更复杂的架构。事实上,现在可能是进行一些练习的最佳时机,相关练习会在本章末尾提供。
总结
本章中,我们从高层次探讨了自编码器的基本理论,并概念化了允许这些模型进行学习的基础数学。我们看到了几种不同的自编码器架构,包括浅层、深层、不完全和过度完整的模型。这让我们能够概述与每种模型的表示能力相关的考虑因素,以及它们在容量过大时容易过拟合的倾向。我们还探讨了一些正则化技术,帮助我们弥补过拟合问题,例如稀疏自编码器和收缩自编码器。最后,我们训练了几种不同类型的自编码器网络,包括浅层、深层和卷积网络,进行图像重建和去噪的任务。我们看到,尽管学习能力和训练时间非常有限,卷积自编码器在图像重建方面超越了所有其他模型。此外,它能够从受损的输入中生成去噪图像,保持输入数据的整体格式。
虽然我们没有探索其他使用案例,例如使用降维来可视化主要的方差因子,但自编码器在不同领域中得到了广泛应用,从推荐系统中的协同过滤,到甚至预测未来的病人,见Deep Patient:www.nature.com/articles/srep26094
。有一种特定类型的自编码器是我们故意没有在本章中讨论的:变分 自编码器(VAE)。这种自编码器在模型学习的潜在空间上施加了特殊的约束。它实际上迫使模型学习一个表示输入数据的概率分布,并从中采样生成输出。这与我们至今探索的方法大不相同,后者最多只能让我们的网络学习一个某种程度上任意的函数。我们选择不在本章中包括这一有趣的子话题的原因是,VAE 在技术术语中属于生成模型的一个实例,而生成模型正是我们下一章的主题!
练习
-
使用时尚 MNIST 数据集创建深度自动编码器(AE),并监控损失何时趋于平稳。然后,与浅层自动编码器进行比较。
-
在另一个您选择的数据集上实现自动编码器(AE),并尝试不同的编码维度、优化器和
loss
函数,看看模型表现如何。 -
比较不同模型(CNN、FF)损失何时收敛以及损失值下降的稳定性或不稳定性。您注意到了什么?
第九章:生成对抗网络
在上一章中,我们沉浸在自编码神经网络的世界里。我们看到了这些模型如何用于估计能够根据目标输出重建给定输入的参数化函数。虽然乍一看这似乎很简单,但我们现在知道,这种自监督编码方式具有多种理论和实际意义。
实际上,从机器学习(ML)的角度来看,能够将高维空间中的一组连接点近似到低维空间(即流形学习)具有多个优点,从更高的数据存储效率到更高效的内存消耗。实际上,这使我们能够为不同类型的数据发现理想的编码方案,或在此基础上执行降维,以应用于主成分分析(PCA)或信息检索等用例。例如,使用相似查询搜索特定信息的任务可以通过从低维空间中学习有用的表示来大大增强。此外,学习到的表示还可以作为特征检测器,用于分类新的、传入的数据。这种应用可能使我们能够构建强大的数据库,在面对查询时能够进行高级推理和推断。衍生的实现包括律师用来根据当前案件的相似性高效检索先例的法律数据库,或允许医生根据每个患者的噪声数据高效诊断患者的医疗系统。这些潜变量模型使研究人员和企业能够解决各种用例,从序列到序列的机器翻译,到将复杂意图归因于客户评论。实质上,使用生成模型,我们试图回答这个问题:在给定数据实例属于某个类别(y)的情况下,这些特征(x)出现的可能性有多大? 这与我们在监督学习任务中会问的这个问题是完全不同的:在给定特征(x)的情况下,这个实例属于类别(y)的可能性有多大? 为了更好地理解这种角色的反转,我们将进一步探讨前一章中介绍的潜变量建模的理念。
在本章中,我们将看到如何进一步发展潜变量的概念。我们不再只是简单地学习一个参数化的函数,它将输入映射到输出,而是可以使用神经网络来学习一个表示潜在空间中概率分布的函数。然后,我们可以从这个概率分布中采样,生成新的、合成的输入数据实例。这就是生成模型背后的核心理论基础,正如我们即将发现的那样。
在本章中,我们将涵盖以下主题:
-
复制与生成内容
-
理解潜在空间的概念
-
深入了解生成网络
-
使用随机性来增强输出
-
从潜在空间进行采样
-
理解生成对抗网络的类型
-
理解变分自编码器(VAE)
-
在 Keras 中设计变分自编码器(VAE)
-
在 VAE 中构建编码模块
-
构建解码器模块
-
可视化潜在空间
-
潜在空间采样与输出生成
-
探索生成对抗网络(GANs)
-
深入了解生成对抗网络(GANs)
-
在 Keras 中设计生成对抗网络(GAN)
-
设计生成器模块
-
设计判别器模块
-
整合生成对抗网络(GAN)
-
训练函数
-
定义判别器标签
-
按批次训练生成器
-
执行训练会话
复制与生成内容
尽管我们在上一章中的自动编码应用仅限于图像重建和去噪,这些应用与我们将在本章讨论的案例有很大区别。到目前为止,我们让自动编码器通过学习任意映射函数来重建某些给定的输入。在本章中,我们希望理解如何训练一个模型来创造某些内容的新实例,而不仅仅是复制它的输入。换句话说,如果我们要求神经网络像人类一样真正具备创造力并生成内容,这是否可行?在人工智能(AI)领域,标准答案是肯定的,但过程相当复杂。在寻找更详细的答案时,我们来到了本章的主题:生成网络。
尽管存在众多生成网络,从深度玻尔兹曼机到深度信念网络的变种,但大多数已经失去了流行,原因在于它们的适用性有限且出现了更具计算效率的方法。然而,少数几种依然处于焦点之中,因为它们具备生成合成内容的神奇能力,比如从未存在过的面孔、从未写过的电影评论和新闻文章,或是从未拍摄过的视频!为了更好地理解这些魔术背后的机制,我们将花几行文字来介绍潜在空间的概念,从而更好地理解这些模型如何转化其学习到的表示,创造出看似全新的东西。
理解潜在空间的概念
回顾上一章,潜在空间不过是输入数据在低维空间中的压缩表示。它本质上包含了对于识别原始输入至关重要的特征。为了更好地理解这个概念,尝试在脑海中可视化潜在空间可能编码的那些信息是很有帮助的。一个有用的类比是考虑我们如何用想象力创造内容。假设你被要求创造一个虚构的动物,你会依赖哪些信息来创造这个生物?你将从之前见过的动物身上采样特征,比如它们的颜色,或者它们是双足的,四足的,是哺乳动物还是爬行动物,生活在陆地还是海洋等。事实证明,我们自己也在世界中航行时,逐渐发展出潜在的世界模型。当我们尝试想象某个类别的新实例时,实际上是在采样一些潜在变量模型,这些模型是在我们存在的过程中学习得到的。
想一想。在我们的一生中,我们遇到了无数种不同颜色、大小和形态的动物。我们不断地将这些丰富的表示减少到更可管理的维度。例如,我们都知道狮子长什么样,因为我们已经在脑海中编码了代表狮子的属性(或潜在变量),比如它们的四条腿、尾巴、毛茸茸的皮毛、颜色等。这些学习到的属性证明了我们如何在低维空间中存储信息,从而创造出周围世界的功能性模型。我们假设,这些信息是存储在低维空间中的,因为大多数人,例如,无法在纸上完美重现狮子的形象。有些人甚至无法接近,这对于本书的作者来说就是如此。然而,我们所有人只要提到狮子这个词,都会立即并集体地同意狮子的总体形态。
识别概念向量
这个小小的思维实验展示了潜在变量模型在创造世界的功能性表示方面的巨大力量。如果我们的脑袋没有不断将从感官输入接收到的信息进行下采样,以创造可管理且现实的世界模型,它很可能会消耗远超过那可怜的 12 瓦特的能量。因此,使用潜在变量模型本质上允许我们查询输入的简化表示(或属性),这些表示可能会与其他表示重新组合,从而生成看似新颖的输出(例如:独角兽 = 来自马的身体和脸 + 来自犀牛/独角鲸的角)。
同样,神经网络也可以从学习到的潜在空间中转换样本,以生成新的内容。实现这一目标的一种方法是识别嵌入在学习到的潜在空间中的概念向量。这里的想法非常简单。假设我们要从代表面孔的潜在空间中采样一个面孔(f)。然后,另一个点(f + c)可以被认为是相同面孔的嵌入式表示,并且包含某些修改(即在原始面孔上添加微笑、眼镜或面部毛发)。这些概念向量本质上编码了输入数据的各个差异维度,随后可以用于改变输入图像的某些有趣特性。换句话说,我们可以在潜在空间中寻找与输入数据中存在的概念相关的向量。在识别出这些向量后,我们可以修改它们,以改变输入数据的特征。例如,微笑向量可以被学习并用来修改某人在图像中的微笑程度。同样,性别向量可以用来修改某人的外观,使其看起来更女性化或男性化,反之亦然。现在,我们对潜在空间中可能查询到的信息以及如何修改这些信息以生成新内容有了更好的理解,我们可以继续我们的探索之旅。
深入探讨生成网络
所以,让我们尝试理解生成网络的核心机制,以及这种方法与我们已知的其他方法有何不同。到目前为止,我们实现的大多数网络都是为了执行某些输入的确定性变换,从而得到某种输出。直到我们探讨强化学习的话题(第七章,使用深度 Q 网络进行强化学习),我们才了解了将一定程度的随机性(即随机因素)引入建模过程的好处。这是一个核心概念,我们将在进一步熟悉生成网络如何运作的过程中进行探讨。正如我们之前提到的,生成网络的核心思想是使用深度神经网络来学习在简化的潜在空间中,变量的概率分布。然后,可以以准随机的方式对潜在空间进行采样和转换,从而生成一些输出(y)。
正如你所注意到的,这与我们在上一章中采用的方法有很大的不同。对于自编码器,我们只是估计了一个任意函数,通过编码器将输入(x)映射到压缩的潜在空间,再通过解码器重构输出(y)。而在生成对抗网络中,我们则学习输入数据(x)的潜在变量模型。然后,我们可以将潜在空间中的样本转化为生成的输出。不错吧?然而,在我们进一步探讨如何将这个概念操作化之前,让我们简要回顾一下随机性在生成创造性内容方面的作用。
受控随机性与创造力
回想一下,我们通过使用epsilon 贪心选择策略在深度强化学习算法中引入了随机性,这基本上使我们的网络不再过度依赖相同的动作,而是能够探索新动作来解决给定的环境。引入这种随机性,从某种意义上说,为这个过程带来了创造性,因为我们的网络能够系统地创建新的状态-动作对,而不依赖于之前学到的知识。然而需要注意的是,将引入随机性的后果称为创造力,可能是我们某种程度上的拟人化。实际上,赋予人类(我们的基准)创造力的真实过程仍然极为难以捉摸,并且科学界对其理解甚少。另一方面,随机性与创造力之间的联系早已得到认可,特别是在人工智能领域。早在 1956 年,人工智能研究者就对超越机器看似决定性的局限性产生了兴趣。当时,基于规则的系统的突出地位使得人们认为,诸如创造力这样的概念只能在高级生物有机体中观察到。尽管有这种广泛的信念,但塑造人工智能历史的最重要文件之一(可以说影响了未来一个世纪),即达特茅斯夏季研究项目提案(1956 年),特别提到了受控随机性在人工智能系统中的作用,以及它与生成创造性内容的联系。虽然我们鼓励你阅读整个文件,但我们提取了其中与当前话题相关的部分:
“一个相当有吸引力但显然不完整的猜想是,创造性思维与缺乏想象力的有能力的思维之间的区别在于引入了一些随机性。这些随机性必须通过直觉来指导,以提高效率。换句话说,教育性猜测或直觉包括在其他有序思维中引入受控的随机性。”
约翰·麦卡锡、马文·L·明斯基、内森尼尔·罗切斯特和克劳德·E·香农
使用随机性来增强输出
多年来,我们开发了可以实现这种注入受控随机性的机制,这些方法在某种程度上是由输入的直觉引导的。当我们谈论生成模型时,本质上是希望实现一种机制,允许我们对输入进行受控且准随机的变换,从而生成新的东西,但仍然在可行的范围内与原始输入相似。
让我们花一点时间考虑一下如何实现这一点。我们希望训练一个神经网络,利用一些输入变量(x)生成一些输出变量(y),这些输出来自模型生成的潜在空间。解决这一问题的一种简单方法是向我们的生成器网络输入添加一个随机性元素,这里由变量(z)定义。z的值可以从某个概率分布中抽样(例如,高斯分布),并与输入一起传递给神经网络。因此,这个网络实际上是在估计函数f(x, z),而不仅仅是f(x)。自然地,对于一个无法测量z值的独立观察者来说,这个函数看起来是随机的,但在现实中并非如此。
从潜在空间进行采样
进一步说明,假设我们必须从潜在空间的变量的概率分布中抽取一些样本(y),其中均值为(μ),方差为(σ2):
- 采样操作:y ̴ N(μ , σ2)
由于我们使用采样过程从该分布中抽取样本,每次查询该过程时,每个单独的样本可能会发生变化。我们无法准确地根据分布参数(μ 和 σ2)对生成的样本(y)进行求导,因为我们处理的是采样操作,而不是函数。那么,我们究竟如何进行反向传播模型的误差呢?一种解决方法可能是重新定义采样过程,例如对随机变量(z)进行变换,以得到我们的生成输出(y),如下所示:
- 采样方程:y = μ + σz
这是一个关键步骤,因为现在我们可以使用反向传播算法来计算生成输出(y)相对于采样操作本身((μ + σz))的梯度。有什么变化?本质上,我们现在将采样操作视为一个确定性操作,它包含了概率分布中的均值(μ)和标准差(σ),以及一个与我们要估计的其他变量分布无关的随机变量(z)。我们使用这种方法来估计当我们分布的均值(μ)或标准差(σ)发生变化时,如何影响生成输出(y),前提是采样操作以相同的z值被重现。
学习概率分布
既然我们现在可以通过采样操作进行反向传播,我们就可以将这一步骤作为更大网络的一部分。将其插入到更大的网络中后,我们可以重新定义早期采样操作(μ和σ)的参数,作为可以通过这个更大神经网络的部分来估算的函数!更数学化地说,我们可以将概率分布的均值和标准差重新定义为可以通过神经网络参数(例如,μ = f(x ;θ) 和 σ = g(x; θ),其中θ表示神经网络的可学习参数)来逼近的函数。然后,我们可以使用这些定义的函数来生成输出(y):
- 采样函数:y = μ + σz
在这个函数中,μ = f(x ;θ) 和 σ = g(x; θ)。
现在我们知道如何对输出进行采样(y),我们可以通过对定义的损失函数J(y)进行微分,来最终训练我们的更大网络。回想一下,我们使用链式法则重新定义这个过程,关于中间层,这些中间层在此表示参数化的函数(μ和σ)。因此,微分这个损失函数可以得到它的导数,利用这些导数迭代更新网络的参数,而这些参数本身代表了一个概率分布。
太棒了!现在我们有了关于这些模型如何生成输出的全面理论理解。这个整个过程允许我们首先估计,然后从由编码器函数生成的密集编码变量的概率分布中采样。在本章后面,我们将进一步探讨不同生成网络如何通过对比其输出进行学习,并使用反向传播算法进行权重更新。
理解生成网络的类型
所以,我们实际上在做的就是通过转换从表示编码潜在空间的概率分布中采样得到的样本来生成输出。在上一章中,我们展示了如何使用编码函数从一些输入数据生成这样的潜在空间。在本章中,我们将展示如何学习一个连续的潜在空间(l),然后从中采样以生成新的输出。为了实现这一点,我们本质上学习一个可微分的生成器函数,g (l ; θ(g)), 该函数将来自连续潜在空间(l)的样本转换为输出。在这里,神经网络实际上在逼近这个函数本身。
生成网络家族包括变分自编码器(VAEs)和生成对抗网络(GANs)。如前所述,存在许多类型的生成模型,但在本章中,我们将重点讨论这两种变体,因为它们在各种认知任务(如计算机视觉和自然语言生成)中具有广泛的应用性。值得注意的是,VAEs 通过将生成网络与近似推断网络结合,来区分自己,而近似推断网络其实就是我们在上一章看到的编码架构。另一方面,GANs 将生成网络与独立的判别网络结合,后者接收来自实际训练数据和生成输出的样本,并负责区分原始图像与计算机生成的图像。一旦生成器被认为“骗过”了判别器,你的 GAN 就被认为训练完成了。本质上,这两种不同类型的生成模型采用不同的学习潜在空间的方法,这使得它们在不同类型的使用场景中具有独特的适用性。例如,VAEs 在学习良好结构化的空间方面表现出色,在这些空间中,由于输入数据的特定组成,可能会编码出显著的变化(正如我们稍后会看到,使用 MNIST 数据集时)。然而,VAEs 也存在模糊重建的问题,其原因尚未得到充分理解。相比之下,GANs 在生成逼真内容方面表现得更好,尽管它们是从一个无结构且不连续的潜在空间进行采样的,正如我们将在本章后面看到的那样。
理解变分自编码器(VAEs)
现在我们对生成网络的概念有了一个大致的理解,我们可以专注于一种特定类型的生成模型。VAE 就是其中之一,它由 Kingma 和 Welling(2013)以及 Rezende、Mohamed 和 Wierstra(2014)提出。这个模型实际上与我们在上一章中看到的自动编码器非常相似,但它们有一个小小的变化——或者更准确地说,有几个变化。首先,学习的潜在空间不再是离散的,而是通过设计变成了连续的!那么,这有什么大不了的呢?正如我们之前所解释的那样,我们将从这个潜在空间中进行采样来生成输出。然而,从离散的潜在空间中进行采样是有问题的。因为它是离散的,意味着潜在空间中会有不连续的区域,这样如果这些区域被随机采样,输出将看起来完全不真实。另一方面,学习一个连续的潜在空间使得模型能够以概率的方式学习从一个类别到另一个类别的过渡。此外,由于学习的潜在空间是连续的,因此可以识别和操控我们之前提到的概念向量,这些向量以一种有意义的方式编码了输入数据中存在的各种方差轴。在这一点上,许多人可能会好奇 VAE 如何准确地学习建模一个连续的潜在空间。嗯,别再好奇了。
之前,我们已经看到如何重新定义从潜在空间中采样的过程,以便能够将其插入到一个更大的网络中来估计概率分布。我们通过使用参数化函数(也就是神经网络的部分)来估计潜在空间中变量的均值(μ)和标准差(σ),从而将潜在空间分解。在 VAE 中,它的编码器函数正是做了这件事。这就迫使模型学习在连续潜在空间上变量的统计分布。这个过程使我们可以假设输入图像是以概率的方式生成的,因为潜在空间编码了一个概率分布。因此,我们可以使用学习到的均值和标准差参数,从该分布中进行随机采样,并将其解码到数据的原始维度。这里的插图帮助我们更好地理解 VAE 的工作流程:
这个过程使我们能够首先学习,然后从连续潜在空间中采样,生成合理的输出。还觉得有些模糊吗?嗯,也许一个演示示例可以帮助澄清这个概念。让我们从在 Keras 中构建一个 VAE 开始,边构建模型边讨论理论和实现方面的内容。
在 Keras 中设计 VAE
对于这个练习,我们将回到一个所有人都能轻松获取的知名数据集:MNIST 数据集。手写数字的视觉特征使得这个数据集特别适合用来实验变分自编码器(VAE),从而帮助我们更好地理解这些模型是如何工作的。我们首先导入必要的库:
import numpy as np
import matplotlib.pyplot as plt
from keras.layers import Input, Dense, Lambda, Layer
from keras.models import Model
from keras import backend as K
from keras import metrics
from keras.datasets import mnist
加载和预处理数据
接下来,我们加载数据集,就像在第三章《信号处理–神经网络数据分析》中做的那样。我们还自定义了一些变量,稍后在设计网络时可以复用。在这里,我们简单地定义了用于定义原始图像尺寸的图像大小(每个图像 784 像素)。我们选择了一个 2
的编码维度来表示潜在空间,并且选择了一个 256
的中间维度。这里定义的这些变量稍后将被传递到 VAE 的全连接层,用来定义每层的神经元数量:
(x_train, y_train), (x_test, y_test) = mnist.load_data()
image_size = x_train.shape[1]
original_dim=image_size * image_size
latent_dim= 2
intermediate_dim= 256
epochs=50
epsilon_std=1.0
#preprocessing training arrays
x_train=np.reshape(x_train, [-1, original_dim])
x_test=np.reshape(x_test, [-1, original_dim])
x_train=x_train.astype('float 32')/255
x_test=x_test.astype('float 32')/255
然后,我们简单地通过将图像先展平为二维向量(每个图像的维度为 784)来预处理图像。最后,我们将这些二维向量中的像素值归一化到 0 和 1 之间。
构建 VAE 中的编码模块
接下来,我们将开始构建 VAE 的编码模块。这一部分几乎与我们在上一章中构建的浅层编码器相同,只不过它拆分成了两个独立的层:一个估计均值,另一个估计潜在空间上的方差:
#Encoder module
input_layer= Input(shape=(original_dim,))
intermediate_layer= Dense(intermediate_dim, activation='relu', name='Intermediate layer')(input_layer)
z_mean=Dense(latent_dim, name='z-mean')(intermediate_layer)
z_log_var=Dense(latent_dim, name='z_log_var')(intermediate_layer)
在定义一个层时,你可以选择性地添加 name
参数,以便直观地可视化我们的模型。如果我们愿意,实际上可以通过初始化并总结它来可视化我们到目前为止构建的网络,如下所示:
请注意,中间层的输出如何连接到均值估计层(z_mean
)和方差估计层(z_log_var
),这两个层都表示由网络编码的潜在空间。总的来说,这些分离的层估计了潜在空间上变量的概率分布,正如本章前面所述。
所以,现在我们有了一个由 VAE 中间层学习的概率分布。接下来,我们需要一个机制从这个概率分布中随机采样,以生成我们的输出。这就引出了采样方程。
对潜在空间进行采样
这个过程背后的思想非常简单。我们通过在方程中使用从潜在空间中学到的均值(z_mean
)和方差(z_log_variance
)来定义一个样本(z),这个方程可以表述如下:
z = z_mean + exp(z_log_variance) * epsilon
在这里,epsilon 只是一个由非常小的值组成的随机张量,确保每次查询的样本中都会渗透一定程度的随机性。由于它是一个由非常小的值组成的张量,确保每个解码后的图像在可信度上会与输入图像相似。
这里介绍的采样函数简单地利用编码器网络学习到的值(即均值和方差),定义一个匹配潜在维度的小值张量,然后通过之前定义的采样方程返回一个来自概率分布的样本:
由于 Keras 要求所有操作都必须嵌套在层中,我们使用一个自定义 Lambda 层来嵌套这个采样函数,并定义输出形状。这个层,在这里定义为(z
),将负责从学习到的潜在空间生成样本。
构建解码器模块
现在我们已经实现了从潜在空间采样的机制,可以继续构建解码器模块,将该样本映射到输出空间,从而生成输入数据的新实例。回想一下,就像编码器通过逐渐缩小层的维度直到得到编码表示,解码器层则逐步扩大从潜在空间采样的表示,将它们映射回原始图像维度:
#Decoder module
decoder_h= Dense(intermediate_dim, activation='relu')
decoder_mean= Dense(original_dim, activation='sigmoid')
h_decoded=decoder_h(z)
x_decoded_mean=decoder_mean(h_decoded)
定义一个自定义变分层
现在我们已经构建了网络的编码器和解码器模块,在开始训练 VAE 之前,我们还需要关注一个实现问题。这个问题非常重要,因为它涉及到我们的网络如何计算损失并更新自己,以生成更逼真的输出。乍一看,这可能有点奇怪。我们将生成结果与什么进行比较呢?并不是说我们有一个目标表示来与模型的生成结果进行比较,那么我们如何计算模型的误差呢?其实答案很简单。我们将使用两个独立的loss
函数,每个函数跟踪模型在生成图像的不同方面的表现。第一个损失函数被称为重建损失,它简单地确保我们的模型的解码输出与提供的输入匹配。第二个loss
函数是正则化损失。这个函数实际上帮助模型避免仅仅复制训练数据,从而避免过拟合,进而从输入中学习理想的潜在空间。不幸的是,这些loss
函数在 Keras 中并没有直接实现,因此需要一些额外的技术处理才能运作。
我们通过构建一个自定义变分层类来操作这两个loss
函数,这将成为我们网络的最终层,执行两个不同损失度量的计算,并使用它们的均值来计算关于网络参数的损失梯度:
如您所见,自定义层包含三个函数。第一个是初始化函数。第二个函数负责计算两个损失。它使用二元交叉熵度量来计算重建损失,并使用Kullback–Leibler(KL)散度公式来计算正则化损失。KL 散度项本质上允许我们计算生成输出相对于采样潜在空间(z)的相对熵。它让我们能够迭代地评估输出概率分布与潜在空间分布之间的差异。然后,vae_loss
函数返回一个综合损失值,这个值就是这两项计算得出的度量的均值。
最后,call
函数用于实现自定义层,方法是使用内建的add_loss
层方法。这个部分实际上是将我们网络的最后一层定义为损失层,从而使用我们任意定义的loss
函数生成损失值,进而可以进行反向传播。
编译并检查模型
接下来,我们使用我们刚刚实现的自定义变分层类来定义网络的最后一层(y),如下面所示:
现在我们终于可以编译并训练我们的模型了!首先,我们将整个模型组合在一起,使用功能性 API 中的Model
对象,并将其输入层传入自编码器模块,以及我们刚刚定义的最后一个自定义损失层。接着,我们使用通常的compile
语法来初始化网络,并为其配备rmsprop
优化器。然而需要注意的是,由于我们使用了自定义损失函数,compile
语句实际上不接受任何损失度量,通常应该存在的地方并没有出现。在这时,我们可以通过调用.summary()
方法来可视化整个模型,如下所示:
如您所见,这个架构将输入图像传入并将其压缩为两个不同的编码表示:z_mean
和z_log_var
(即在潜在空间中学习得到的均值和方差)。然后,使用添加的 Lambda 层对这个概率分布进行采样,从而生成潜在空间中的一个点。接着,这个点通过全连接层(dense_5
和dense_6
)进行解码,最后通过我们自定义的损失层计算损失值。现在您已经看到了整个流程。
启动训练会话
现在是时候真正训练我们的网络了。这里没有什么特别之处,除了我们不需要指定目标变量(即y_train
)之外。只是因为目标通常用于计算损失指标,而现在这些损失是由我们最后的自定义层计算的。你可能还会注意到,训练过程中显示的损失值相当大,相比之前的实现。这些损失值的大小不必惊慌,因为这只是由于这种架构计算损失的方式所导致的:
该模型训练了 50 个周期,在最后我们得到了151.71
的验证损失和149.39
的训练损失。在我们生成一些新颖的手写数字之前,让我们尝试可视化一下我们的模型所学到的潜在空间。
可视化潜在空间
由于我们有一个二维潜在空间,我们可以简单地将表示绘制为一个二维流形,在这个流形中,每个数字类别的编码实例可以根据它们与其他实例的接近度进行可视化。这使我们能够检查我们之前提到的连续潜在空间,并观察网络如何将不同特征与 10 个数字类别(0 到 9)相互关联。为此,我们重新访问 VAE 的编码模块,该模块现在可以用来从给定的数据中生成压缩的潜在空间。因此,我们使用编码器模块对测试集进行预测,从而将这些图像编码到潜在空间中。最后,我们可以使用 Matplotlib 中的散点图来绘制潜在表示。请注意,每个单独的点代表测试集中的一个编码实例。颜色表示不同的数字类别:
# 2D visualization of latent space
x_test_encoded = encoder_network.predict(x_test, batch_size=256)
plt.figure(figsize=(8, 8))
plt.scatter(x_test_encoded[:, 0], x_test_encoded[:, 1], c=y_test, cmap='Paired')
plt.colorbar()
plt.show()
以下是输出结果:
请注意,不同数字类别之间几乎没有不连续性或间隙。因此,我们现在可以从这种编码表示中采样,生成有意义的数字。如果学到的潜在空间是离散的,像我们在上一章构建的自编码器那样,这样的操作将不会产生有意义的结果。这些模型的潜在空间看起来与 VAE 所学到的潜在空间截然不同:
潜在空间采样与输出生成
最后,我们可以利用我们的变分自编码器(VAE)生成一些新的手写数字。为此,我们只需要重新访问 VAE 的解码器部分(自然不包括损失层)。我们将使用它从潜在空间解码样本,并生成一些从未由任何人实际书写过的手写数字:
接下来,我们将显示一个 15 x 15 的数字网格,每个数字的大小为 28。为了实现这一点,我们初始化一个全为零的矩阵,矩阵的尺寸与要生成的整个输出相匹配。然后,我们使用 SciPy 的ppf
函数,将一些线性排列的坐标转化为潜在变量(z
)的网格值。之后,我们遍历这些网格以获取一个采样的(z
)值。我们现在可以将这个样本输入到生成器网络中,生成器将解码潜在表示,随后将输出重塑为正确的格式,最终得到如下截图:
请注意,这个网格展示了如何从连续空间进行采样,从而使我们能够直观地呈现输入数据中潜在的变化因素。我们注意到,随着沿着x或y轴的移动,数字会变换成其他数字。例如,考虑从图像的中心开始移动。向右移动会将数字8变成9,而向左移动则会将其变为6。同样,沿右上对角线向上移动,数字8会先变为5,然后最终变为1。这些不同的轴可以被看作是代表给定数字上某些特征的存在。这些特征随着我们沿给定轴的方向进一步推进而变得更加突出,最终将数字塑造成特定数字类别的一个实例。
VAE 的总结性评论
正如我们在 MNIST 实验中所见,VAE 擅长学习一个结构良好的连续潜在空间,从中我们可以采样并解码输出。这些模型非常适合编辑图像,或者生成视觉效果类似于图像转化为其他图像的迷幻过渡效果。一些公司甚至开始尝试使用基于 VAE 的模型,让顾客通过手机摄像头完全虚拟地试戴珠宝、太阳镜或其他服饰!这是因为 VAE 在学习和编辑概念向量方面独具优势,正如我们之前讨论的那样。例如,如果你想生成一个介于 1 和 0 之间的新样本,我们只需计算它们在潜在空间中的均值向量差异,并将差异的一半加到原始样本上,然后再解码。这将生成一个 6,就像我们在之前的截图中看到的那样。同样的概念也适用于训练面部图像的 VAE(例如使用 CelebFaces 数据集),我们可以在两位不同名人的面部之间采样,从而创建他们的“合成兄弟”。类似地,如果我们想要生成特定的面部特征,比如胡子,只需要找到有胡子和没有胡子的面部样本。接着,我们可以使用编码函数获取它们各自的编码向量,并保存这两个向量之间的差异。现在,我们保存的胡子向量就可以应用到任何图像上,只需将它加到新图像的编码空间中,再进行解码即可。
其他有趣的 VAE 应用案例包括在实时视频中交换面孔,或者为了娱乐添加额外的元素。这些网络非常独特,因为它们能够逼真地修改图像并生成那些原本不存在的图像。自然地,这也让人想知道这些技术是否可以用于一些不那么有趣的目的;滥用这些模型来歪曲人们或情况的真实性,可能会导致严重后果。然而,既然我们可以训练神经网络来欺骗我们人类,我们也可以训练它们来帮助我们辨别这种伪造。这引出了本章的下一个主题:GANs。
探索 GANs
与其他类似模型相比,GANs 的思想更容易理解。从本质上讲,我们使用多个神经网络来进行一场相当复杂的博弈。就像电影《猫鼠游戏》中的情节一样。对于那些不熟悉该电影情节的人,我们提前为错过的暗示表示歉意。
我们可以将 GAN 看作是一个由两个参与者组成的系统。在一方,我们有一个类似 Di Caprio 的网络,试图重新创作一些莫奈和达利的作品,并将它们发送给毫无戒心的艺术经销商。另一方面,我们有一个警觉的、像汤姆·汉克斯风格的网络,拦截这些货物并识别其中的伪作。随着时间的推移,两者都变得越来越擅长自己的工作,导致骗子那一方能够创造出逼真的伪作,而警察那一方则具备了敏锐的眼光来识别它们。这种常用的类比变体,确实很好地介绍了这些架构背后的理念。
一个 GAN 本质上有两个部分:生成器和判别器。这两个部分可以看作是独立的神经网络,它们在模型训练时通过相互检查输出进行协同工作。生成器网络的任务是通过从潜在空间中抽取随机向量来生成虚假的数据点。然后,判别器接收这些生成的数据点,以及实际的数据点,并识别哪些数据点是真的,哪些是假的(因此得名判别器)。随着网络的训练,生成器和判别器分别在生成合成数据和识别合成数据的能力上不断提升:
GAN 的实用性和实际应用
该架构最早由 Goodfellow 等人于 2014 年提出,随后在多个领域的研究人员中得到了广泛应用。它们之所以迅速走红,是因为它们能够生成几乎无法与真实图像区分的合成图像。虽然我们已经讨论了由此方法衍生的一些较为有趣和日常的应用,但也有一些更复杂的应用。例如,虽然 GAN 主要用于计算机视觉任务,如纹理编辑和图像修改,但它们在多个学术领域中的应用也日益增加,出现在越来越多的研究方法中。如今,你可能会发现 GAN 被应用于医学图像合成,甚至在粒子物理学和天体物理学等领域中得到应用。生成合成数据的相同方法可以用来再生来自遥远星系的去噪图像,或模拟高能粒子碰撞产生的真实辐射模式。GAN 的真正实用性在于它们能够学习数据中潜在的统计分布,使其能够生成原始输入的合成实例。这种方法尤其对研究人员有用,因为收集真实数据可能既昂贵又物理上不可能实现。此外,GAN 的应用不仅限于计算机视觉领域。其他应用包括使用这些网络的变种从自然语言数据生成精细的图像,比如描述某种风景的句子:
https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/hsn-nn-keras/img/08bc6c1d-aaa9-4497-b53c-6c87d163b7b4.pngarxiv.org/pdf/1612.03242v1.pdf
这些应用案例展示了 GAN 如何使我们能够处理新任务,既有创造性的也有实用性的影响。然而,这些架构并非都是轻松愉快的。它们以训练困难著称,深入研究这些领域的人们形容它们更像是一门艺术,而非科学。
关于这个主题的更多信息,请参考以下内容:
-
Goodfellow 等人的原创论文:
papers.nips.cc/paper/5423-generative-adversarial-nets
-
天体物理学中的 GAN:
academic.oup.com/mnrasl/article/467/1/L110/2931732
-
粒子物理学中的 GAN:
link.springer.com/article/10.1007/s41781-017-0004-6
-
细粒度文本到图像生成:
openaccess.thecvf.com/content_cvpr_2018/html/Xu_AttnGAN_Fine-Grained_Text_CVPR_2018_paper.html
深入了解 GAN
那么,让我们尝试更好地理解 GAN 的不同部分如何协作生成合成数据。考虑一下带参数的函数(G)(你知道的,我们通常使用神经网络来近似的那种)。这个将是我们的生成器,它从某个潜在的概率分布中采样输入向量(z),并将它们转换为合成图像。我们的判别网络(D)随后将会接收到由生成器生成的一些合成图像,这些图像与真实图像混合,并尝试将真实图像与伪造图像区分开来。因此,我们的判别网络只是一个二分类器,配备了类似于 sigmoid 激活函数的东西。理想情况下,我们希望判别器在看到真实图像时输出较高的值,而在看到生成的伪造图像时输出较低的值。相反,我们希望我们的生成器网络通过使判别器对生成的伪造图像也输出较高的值来愚弄判别器。这些概念引出了训练 GAN 的数学公式,其本质上是两个神经网络(D和G)之间的对抗,每个网络都试图超越另一个:
在给定的公式中,第一个项实际上表示与来自真实分布的数据点(x)相关的熵,该数据点被呈现给判别器。判别器的目标是尽量将这个项最大化到 1,因为它希望能够正确识别真实图像。此外,公式中的第二个项表示与随机抽样的点相关的熵,这些点被生成器转换为合成图像 G(z),并呈现给判别器 D(G(z))。判别器不希望看到这个,因此它试图将数据点是假的对数概率(即第二项)最大化到 0。因此,我们可以说,判别器试图最大化整个 V 函数。另一方面,生成器的目标则是做相反的事情。生成器的目标是尽量最小化第一个项并最大化第二个项,以便判别器无法区分真实与虚假。这就开始了警察与小偷之间的漫长博弈。
优化 GAN 存在的问题
有趣的是,由于两个网络轮流优化自己的度量,GAN 具有动态的损失景观。这与我们在本书中看到的其他所有例子不同,在那些例子中,损失超平面保持不变,随着我们通过反向传播调整模型误差,逐步收敛到更理想的参数。而在这里,由于两个网络都在优化其参数,每一步沿超平面的下降都会略微改变景观,直到两种优化约束之间达到平衡。正如生活中的许多事情一样,这种平衡并不容易实现,它需要大量的关注和努力。在 GAN 的情况下,关注层权重初始化、使用 LeakyRelu
和 tanh
替代 修正线性单元 (ReLU) 和 sigmoid 激活函数、实现批量归一化和 dropout 层等方面,都是提高 GAN 达到平衡能力的众多考虑因素之一。然而,没有比通过实际编写代码并实现这些令人着迷的架构实例更好的方式来熟悉这些问题。
更多相关信息,请参考以下内容:
-
改进的 GAN 训练技术:
arxiv.org/pdf/1606.03498.pdf
-
照片级真实感图像生成:
openaccess.thecvf.com/content_cvpr_2017/html/Ledig_Photo-Realistic_Single_Image_CVPR_2017_paper.html
在 Keras 中设计 GAN
假设你是一个研究团队的一员,团队为一家大型汽车制造商工作。你的老板希望你想出一种生成汽车合成设计的方法,以系统地激发设计团队的灵感。你听说过很多关于 GAN 的宣传,决定研究它们是否能用于这个任务。为此,你首先想做一个概念验证,因此你迅速获取了一些低分辨率的汽车图片,并在 Keras 中设计了一个基础的 GAN,看看网络是否至少能够重建汽车的一般形态。一旦你能够确认这一点,你就可以说服经理为办公室投资几台Titan x GUPs,获取更高分辨率的数据,并开发更复杂的架构。那么,让我们首先获取一些汽车图片,通过实现这个概念验证来开始吧。对于这个演示用例,我们使用了经典的 CIFAR-10 数据集,并将自己限制在商用汽车类别。我们从导入一些库开始,如下所示:
准备数据
我们继续通过 Keras 加载数据,只选择汽车图像(索引=1)。然后,我们检查训练和测试数组的形状。我们看到有 5,000 张训练图像和 1,000 张测试图像:
可视化一些实例
现在我们将使用 Matplotlib 查看数据集中的真实图像。记住这些图像,因为稍后我们将生成一些假图像进行比较:
# Plot many
plt.figure(figsize=(5, 4))
for i in range(20):
plt.subplot(4, 5, i+1)
plt.imshow(x_train[i].reshape(32,32,3), cmap='gray')
plt.xticks([])
plt.yticks([])
plt.tight_layout()
plt.show()
以下是输出结果:
数据预处理
接下来,我们只是简单地对像素值进行归一化。然而,与之前的尝试不同,这次我们将像素值归一化到-1 到 1 之间(而不是 0 到 1 之间)。这是因为我们将为生成器网络使用tanh
激活函数。这个特定的激活函数输出的值在-1 到 1 之间;因此,以类似的方式归一化数据会使学习过程更加平滑:
我们鼓励你尝试不同的归一化策略,探索它们如何影响网络训练中的学习过程。现在我们已经准备好所有组件,可以开始构建 GAN 架构了。
设计生成器模块
现在是最有趣的部分。我们将实现一个深度卷积生成对抗网络(DCGAN)。我们从 DCGAN 的第一部分开始:生成器网络。生成器网络本质上将通过从某个正态概率分布中采样来重建真实的汽车图像,代表一个潜在空间。
我们将再次使用功能 API 来定义我们的模型,将其嵌套在一个带有三个不同参数的函数中。第一个参数latent_dim
指的是从正态分布中随机采样的输入数据的维度。leaky_alpha
参数指的是提供给网络中使用的LeakyRelu
激活函数的 alpha 参数。最后,init_stddev
参数指的是初始化网络随机权重时使用的标准差,用于定义构建层时的kernel_initializer
参数:
# Input Placeholder
def gen(latent_dim, leaky_alpha, init_stddev ):
input_img = Input(shape=(latent_dim,)) # adapt this if using `channels_first` image data format
# Encoder part
x = Dense(32*32*3)(input_img)
x = Reshape((4, 4, 192))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2DTranspose(256, kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2DTranspose(128, kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2DTranspose(3, kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev), activation='tanh')(x)
generator = Model(input_img, x)
generator.summary()
return generator
注意在设计此模型时考虑了许多因素。例如,LeakyReLU
激活函数在倒数第二层被选中,因为与 ReLU 相比,它能放宽输出的稀疏性约束。这是因为LeakyReLU
允许一些小的负梯度值,而 ReLU 会将所有负值压缩为零。梯度稀疏性通常被认为是训练神经网络时的理想特性,但对于 GAN 来说并不适用。这也是为什么最大池化操作在 DCGAN 中不太流行的原因,因为这种下采样操作通常会产生稀疏的表示。相反,我们将使用带有 Conv2D 转置层的步幅卷积进行下采样需求。我们还实现了批量归一化层(其均值和方差的移动参数设置为 0.8),因为我们发现这对改善生成图像的质量有显著的作用。你还会注意到,卷积核的大小被设置为能被步幅整除,这有助于改善生成的图像,同时减少生成图像区域之间的差异,因为卷积核可以均匀地采样所有区域。最后,网络的最后一层配备了tanh
激活函数,因为在 GAN 架构中,这一激活函数 consistently 显示出更好的效果。下一张截图展示了我们 GAN 的整个生成器模块,它将生成 32 x 32 x 3 的合成汽车图像,随后用于尝试欺骗判别器模块:
设计判别器模块
接下来,我们继续设计判别器模块,该模块负责区分由刚设计的生成器模块提供的真实图像和假图像。该架构的概念与生成器非常相似,但也有一些关键的不同之处。判别器网络接收尺寸为 32 x 32 x 3 的图像,然后将其转化为多种表示,随着信息通过更深层传播,直到达到带有一个神经元和 sigmoid 激活函数的密集分类层。它有一个神经元,因为我们处理的是区分假图像与真实图像的二分类任务。sigmoid
函数确保输出一个介于 0 和 1 之间的概率值,表示网络认为给定图像可能有多假或多真。还需要注意的是,在密集分类器层之前引入了 dropout 层,这有助于增强模型的鲁棒性和泛化能力:
def disc(leaky_alpha, init_stddev):
disc_input = Input(shape=(32,32,3))
x = Conv2D(64, kernel_size=5, strides=2, padding='same', kernel_initializer=RandomNormal(stddev=init_stddev))(disc_input)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2D(128, kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Conv2D(256,kernel_size=5, strides=2, padding='same',
kernel_initializer=RandomNormal(stddev=init_stddev))(x)
x = BatchNormalization(momentum=0.8)(x)
x = LeakyReLU(alpha=leaky_alpha)(x)
x = Flatten()(x)
x = Dropout(0.2)(x)
x = Dense(1, activation='sigmoid')(x)
discriminator = Model(disc_input, x)
discriminator.summary()
return discriminator
再次强调,我们鼓励你尽可能多地尝试不同的模型超参数,以便更好地理解如何通过调整这些超参数影响学习过程和我们的 GAN 模型生成的输出。
组装 GAN
接下来,我们使用此处显示的函数将两个模块组合在一起。作为参数,它接受生成器的潜在样本大小,生成器网络将通过该大小转换生成合成图像。它还接受生成器和判别器网络的学习率和衰减率。最后,最后两个参数表示用于LeakyReLU
激活函数的 alpha 值,以及网络权重随机初始化的标准差值:
def make_DCGAN(sample_size,
g_learning_rate,
g_beta_1,
d_learning_rate,
d_beta_1,
leaky_alpha,
init_std):
# clear first
K.clear_session()
# generator
generator = gen(sample_size, leaky_alpha, init_std)
# discriminator
discriminator = disc(leaky_alpha, init_std)
discriminator_optimizer = Adam(lr=d_learning_rate, beta_1=d_beta_1) #keras.optimizers.RMSprop(lr=d_learning_rate, clipvalue=1.0, decay=1e-8)
discriminator.compile(optimizer=discriminator_optimizer, loss='binary_crossentropy')
# GAN
gan = Sequential([generator, discriminator])
gan_optimizer = Adam(lr=g_learning_rate, beta_1=g_beta_1) #keras.optimizers.RMSprop(lr=g_learning_rate, clipvalue=1.0, decay=1e-8)
gan.compile(optimizer=gan_optimizer, loss='binary_crossentropy')
return generator, discriminator, gan
我们通过调用导入的后端对象K
上的.clear_session()
来确保没有之前的 Keras 会话在运行。然后,我们可以通过调用之前设计的生成器和判别器网络函数,并为它们提供适当的参数,来定义这两个网络。需要注意的是,判别器已被编译,而生成器没有被编译。
请注意,这些函数的设计方式鼓励通过使用参数来快速实验,调整不同的模型超参数。
最后,在使用二进制交叉熵损失函数编译判别器网络后,我们将这两个独立的网络合并。我们使用顺序 API 来实现这一点,顺序 API 使得将两个全连接的模型合并变得非常简单。然后,我们可以编译整个 GAN,再次使用相同的损失函数和优化器,但使用不同的学习率。在我们的实验中,我们选择了Adam
优化器,GAN 的学习率为 0.0001,判别器网络的学习率为 0.001,这在当前任务中效果很好。
用于训练的辅助函数
接下来,我们将定义一些辅助函数,帮助我们在训练过程中进行操作。第一个函数只是从正态概率分布中生成潜在变量的样本。接下来,我们有make_trainable()
函数,它帮助我们交替训练鉴别器和生成器网络。换句话说,它允许我们冻结一个模块(鉴别器或生成器)的层权重,同时训练另一个模块。此函数的 trainable 参数只是一个布尔变量(true 或 false)。最后,make_labels()
函数只是返回用于训练鉴别器模块的标签。这些标签是二进制的,其中1
代表真实,0
代表伪造:
def make_latent_samples(n_samples, sample_size):
#return np.random.uniform(-1, 1, size=(n_samples, sample_size))
return np.random.normal(loc=0, scale=1, size=(n_samples, sample_size))
def make_trainable(model, trainable):
for layer in model.layers:
layer.trainable = trainable
def make_labels(size):
return np.ones([size, 1]), np.zeros([size, 1])
显示输出的辅助函数
接下来的两个辅助函数使我们能够在训练结束时可视化损失值,并在每个周期结束时绘制出一张图像,从而直观评估网络的表现。由于损失值的变化是动态的,因此损失值的意义较小。就像在生成对抗网络中常见的情况一样,其输出的评估通常是由人类观察者通过视觉检查来完成的。因此,我们需要能够在训练过程中实时地检查模型的表现:
def show_results(losses):
labels = ['Classifier', 'Discriminator', 'Generator']
losses = np.array(losses)
fig, ax = plt.subplots()
plt.plot(losses.T[0], label='Discriminator Net')
plt.plot(losses.T[1], label='Generator Net')
plt.title("Losses during training")
plt.legend()
plt.show()
def show_images(generated_images):
n_images = len(generated_images)
rows = 4 cols = n_images//rows
plt.figure(figsize=(cols, rows))
for i in range(n_images):
img = deprocess(generated_images[i])
plt.subplot(rows, cols, i+1)
plt.imshow(img, cmap='gray')
plt.xticks([])
plt.yticks([])
plt.tight_layout()
plt.show()
第一个函数只是接受一个包含鉴别器和生成器网络在整个训练过程中损失值的列表,进行转置并按周期绘制。第二个函数让我们能够在每个周期结束时可视化生成的图像网格。
训练函数
接下来是训练函数。是的,它比较复杂。但正如你很快就会看到的,它是相当直观的,并基本上结合了我们到目前为止实现的所有内容:
def train(
g_learning_rate, # learning rate for the generator
g_beta_1, # the exponential decay rate for the 1st moment estimates in Adam optimizer
d_learning_rate, # learning rate for the discriminator
d_beta_1, # the exponential decay rate for the 1st moment estimates in Adam optimizer
leaky_alpha,
init_std,
smooth=0.1, # label smoothing
sample_size=100, # latent sample size (i.e. 100 random numbers)
epochs=200,
batch_size=128, # train batch size
eval_size=16): # evaluate size
# labels for the batch size and the test size
y_train_real, y_train_fake = make_labels(batch_size)
y_eval_real, y_eval_fake = make_labels(eval_size)
# create a GAN, a generator and a discriminator
generator, discriminator, gan = make_DCGAN(
sample_size,
g_learning_rate,
g_beta_1,
d_learning_rate,
d_beta_1,
leaky_alpha,
init_std)
losses = []
for epoch_indx in range(epochs):
for i in tqdm(range(len(X_train_real)//batch_size)):
# real images
X_batch_real = X_train_real[i*batch_size:(i+1)*batch_size]
# latent samples and the generated images
latent_samples = make_latent_samples(batch_size, sample_size)
X_batch_fake = generator.predict_on_batch(latent_samples)
# train the discriminator to detect real and fake images
make_trainable(discriminator, True)
discriminator.train_on_batch(X_batch_real, y_train_real * (1 - smooth))
discriminator.train_on_batch(X_batch_fake, y_train_fake)
# train the generator via GAN
make_trainable(discriminator, False)
gan.train_on_batch(latent_samples, y_train_real)
# evaluate
X_eval_real = X_test_real[np.random.choice(len(X_test_real), eval_size, replace=False)]
latent_samples = make_latent_samples(eval_size, sample_size)
X_eval_fake = generator.predict_on_batch(latent_samples)
d_loss = discriminator.test_on_batch(X_eval_real, y_eval_real)
d_loss += discriminator.test_on_batch(X_eval_fake, y_eval_fake)
g_loss = gan.test_on_batch(latent_samples, y_eval_real) # we want the fake to be realistic!
losses.append((d_loss, g_loss))
print("At epoch:{:>3}/{},\nDiscriminator Loss:{:>7.4f} \nGenerator Loss:{:>7.4f}".format(
epoch_indx+1, epochs, d_loss, g_loss))
if (epoch_indx+1)%1==0:
show_images(X_eval_fake)
show_results(losses)
return generator
训练函数中的参数
你已经熟悉了训练函数中的大多数参数。前四个参数仅仅是指分别用于生成器和鉴别器网络的学习率和衰减率。类似地,leaky_alpha
参数是我们为LeakyReLU
激活函数实现的负斜率系数,在两个网络中都使用了这个函数。接下来的 smooth 参数代表的是单边标签平滑的实现,如 Goodfellow 等人(2016)提出的那样。其背后的思想是将鉴别器模块中的真实(1)目标值替换为平滑的值,比如 0.9,因为这已被证明能够减少神经网络在对抗样本面前的失败风险:
def train(
g_learning_rate, # learning rate for the generator
g_beta_1, # the exponential decay rate for the 1st moment estimates in Adam optimizer
d_learning_rate, # learning rate for the discriminator
d_beta_1, # the exponential decay rate for the 1st moment estimates in Adam optimizer
leaky_alpha,
init_std,
smooth=0.1, # label smoothing
sample_size=100, # latent sample size (i.e. 100 random numbers)
epochs=200,
batch_size=128, # train batch size
eval_size=16): # evaluate size
接下来,我们有四个简单易懂的参数。其中第一个是sample_size
,指的是从潜在空间中提取的样本大小。接下来,我们有训练的周期数以及用于进行权重更新的batch_size
。最后,我们有eval_size
参数,它指的是在每个训练周期结束时用于评估的生成图像数量。
定义鉴别器标签
接下来,我们通过调用make_labels()
函数,并使用合适的批次维度,来定义用于训练和评估图像的标签数组。这样会返回带有标签 1 和 0 的数组,用于每个训练和评估图像的实例:
# labels for the batch size and the test size
y_train_real, y_train_fake = make_labels(batch_size)
y_eval_real, y_eval_fake = make_labels(eval_size)
初始化 GAN
随后,我们通过调用之前定义的make_DCGAN()
函数并传入适当的参数,初始化 GAN 网络:
# create a GAN, a generator and a discriminator
generator, discriminator, gan = make_DCGAN(
sample_size,
g_learning_rate,
g_beta_1,
d_learning_rate,
d_beta_1,
leaky_alpha,
init_std)
每批次训练判别器
之后,我们定义一个列表,用于在训练过程中收集每个网络的损失值。为了训练这个网络,我们实际上会使用.train_on_batch()
方法,它允许我们有选择地操作训练过程,正如我们案例中所需要的那样。基本上,我们将实现一个双重for
循环:
losses = []
for epoch_indx in range(epochs):
for i in tqdm(range(len(X_train_real)//batch_size)):
# real images
X_batch_real = X_train_real[i*batch_size:(i+1)*batch_size]
# latent samples and the generated images
latent_samples = make_latent_samples(batch_size, sample_size)
X_batch_fake = generator.predict_on_batch(latent_samples)
# train the discriminator to detect real and fake images
make_trainable(discriminator, True)
discriminator.train_on_batch(X_batch_real, y_train_real * (1 - smooth))
discriminator.train_on_batch(X_batch_fake, y_train_fake)
因此,在每个 epoch 中的每个批次里,我们将首先训练判别器,然后在给定的批次数据上训练生成器。我们首先使用第一批真实的训练图像,并从正态分布中采样一批潜在变量。然后,我们使用生成器模块对潜在样本进行预测,实质上生成一张汽车的合成图像。
随后,我们允许判别器在两个批次(即真实图像和生成图像)上进行训练,使用make_trainable()
函数。这时,判别器有机会学习区分真实和虚假。
每批次训练生成器
接下来,我们冻结判别器的层,再次使用make_trainable()
函数,这次只训练网络的其余部分。现在轮到生成器尝试击败判别器,通过生成一张真实的图像:
# train the generator via GAN
make_trainable(discriminator, False)
gan.train_on_batch(latent_samples, y_train_real)
每个 epoch 的评估结果
接下来,我们退出nested
循环,在每个 epoch 的结束执行一些操作。我们随机采样一些真实图像以及潜在变量,然后生成一些假图像并进行绘制。请注意,我们使用了.test_on_batch()
方法来获取判别器和 GAN 的损失值,并将其附加到损失列表中。在每个 epoch 的末尾,我们打印出判别器和生成器的损失,并绘制出 16 张样本的网格。现在,只剩下调用这个函数了:
# evaluate
X_eval_real = X_test_real[np.random.choice(len(X_test_real), eval_size, replace=False)]
latent_samples = make_latent_samples(eval_size, sample_size)
X_eval_fake = generator.predict_on_batch(latent_samples)
d_loss = discriminator.test_on_batch(X_eval_real, y_eval_real)
d_loss += discriminator.test_on_batch(X_eval_fake, y_eval_fake)
g_loss = gan.test_on_batch(latent_samples, y_eval_real) # we want the fake to be realistic!
losses.append((d_loss, g_loss))
print("At epoch:{:>3}/{},\nDiscriminator Loss:{:>7.4f} \nGenerator Loss:{:>7.4f}".format(
epoch_indx+1, epochs, d_loss, g_loss))
if (epoch_indx+1)%1==0:
show_images(X_eval_fake)
show_results(losses)
return generator
更多信息,请参考以下内容:
- 改进的 GAN 训练技巧:
arxiv.org/pdf/1606.03498.pdf
执行训练会话
我们最终使用相应的参数启动了训练会话。您会注意到,tqdm 模块显示一个百分比条,指示每个周期处理的批次数量。周期结束时,您将能够可视化一个 4 x 4 网格(如下所示),其中包含从 GAN 网络生成的样本。到此为止,您已经知道如何在 Keras 中实现 GAN。顺便提一下,如果您在具有 GPU 的本地机器上运行代码,设置tensorflow-gpu
和 CUDA 会非常有益。我们运行了 200 个周期的代码,但如果有足够的资源和时间,运行几千个周期也并不罕见。理想情况下,两个网络对抗的时间越长,结果应该越好。然而,这并不总是如此,因此,这样的尝试可能也需要仔细监控损失值:
训练过程中测试损失的解释
正如您接下来看到的,测试集上的损失值变化非常不稳定。我们预期不同的优化器会呈现出更平滑或更剧烈的损失曲线,并且我们鼓励您使用不同的损失函数来测试这些假设(例如,RMSProp 是一个很好的起点)。虽然查看损失的曲线图不是特别直观,但跨越多个训练周期可视化生成的图像可以对这一过程进行有意义的评估:
跨周期可视化结果
接下来,我们展示了在训练过程中不同时间点生成的 16 x 16 网格样本的八个快照。尽管图像本身相当小,但它们无可否认地呈现出训练结束时接近汽车的形态:
就是这样。如您所见,GAN 在训练一段时间后变得非常擅长生成逼真的汽车图像,因为它在愚弄判别器方面越来越好。到最后几个周期时,人眼几乎无法分辨真假,至少在初看时是如此。此外,我们通过相对简单且直接的实现达到了这一点。考虑到生成器网络从未实际见过一张真实图像,这一成就显得更加令人惊讶。回想一下,它仅仅是从一个随机概率分布中进行采样,并仅通过判别器的反馈来改善自己的输出!正如我们所看到的,训练 DCGAN 的过程涉及了大量对细节的考虑,以及选择特定模型约束和超参数。对于感兴趣的读者,您可以在以下研究论文中找到更多关于如何优化和微调您的 GAN 的详细信息:
-
关于 GAN 的原始论文:
papers.nips.cc/paper/5423-generative-adversarial-nets
-
使用 DCGAN 进行无监督表示学习:
arxiv.org/abs/1511.06434
-
照片级超分辨率 GAN:
openaccess.thecvf.com/content_cvpr_2017/papers/Ledig_Photo-Realistic_Single_Image_CVPR_2017_paper.pdf
结论
在本章的这一部分,我们实现了一种特定类型的 GAN(即 DCGAN),用于特定的应用场景(图像生成)。然而,使用两个网络并行工作,相互制约的思路,可以应用于多种类型的网络,解决非常不同的用例。例如,如果你希望生成合成的时间序列数据,我们可以将我们在这里学到的相同概念应用于递归神经网络,设计一个生成对抗模型!在研究界,已经有几次尝试,并取得了相当成功的结果。例如,一组瑞典研究人员就使用递归神经网络,在生成对抗框架下生成古典音乐的合成片段!与 GAN 相关的其他重要思想包括使用注意力模型(遗憾的是本书未涉及该话题)来引导网络的感知,并将记忆访问引导至图像的更精细细节。例如,我们在本章中讨论的基础理论可以应用于许多不同的领域,使用不同类型的网络来解决越来越复杂的问题。核心思想保持不变:使用两个不同的函数近似器,每个都试图超越另一个。接下来,我们将展示一些链接,供感兴趣的读者进一步了解不同的基于 GAN 的架构及其各自的应用。我们还包括一个由 Google 和乔治亚理工大学开发的非常有趣的工具的链接,它可以让你可视化使用不同类型的数据分布和采样考虑来训练 GAN 的整个过程!
如需更多信息,请参阅以下内容:
-
C-RNN_GAN 音乐生成:
mogren.one/publications/2016/c-rnn-gan/mogren2016crnngan.pdf
-
自注意力 GAN:
arxiv.org/abs/1805.08318
-
OpenAI 关于生成网络的博客:
openai.com/blog/generative-models/
-
GAN 实验室:
poloclub.github.io/ganlab/?fbclid=IwAR0JrixZYr1Ah3c08YjC6q34X0e38J7_mPdHaSpUsrRSsi0v97Y1DNQR6eU
摘要
在本章中,我们学习了如何以系统化的方式通过随机性来增强神经网络,从而使它们输出我们人类认为是创造性的实例。通过变分自编码器(VAE),我们看到如何利用神经网络的参数化函数近似来学习一个连续潜在空间上的概率分布。接着,我们学习了如何从这样的分布中随机抽样,并生成原始数据的合成实例。在本章的第二部分,我们了解了如何以对抗的方式训练两个网络来完成类似的任务。
训练生成对抗网络(GAN)的方法论与变分自编码器(VAE)不同,是学习潜在空间的另一种策略。尽管 GAN 在生成合成图像的应用场景中有一些关键优势,但它们也有一些缺点。GAN 的训练 notoriously 难度较大,且通常生成来自无结构且不连续潜在空间的图像,而 VAE 则相对更为结构化,因此 GAN 在挖掘概念向量时更为困难。在选择这些生成网络时,还需要考虑许多其他因素。生成建模领域在不断扩展,尽管我们能够涵盖其中一些基本的概念性内容,但新的想法和技术几乎每天都在涌现,这使得研究这类模型成为一个激动人心的时刻。