dl-quick-ref-merge-1

88 阅读1小时+

深度学习快速参考(二)

原文:Deep Learning Quick Reference

协议:CC BY-NC-SA 4.0

九、从头开始训练 RNN

循环神经网络RNN)是为建模顺序数据而构建的一组神经网络。 在最后几章中,我们研究了使用卷积层从图像中学习特征。 当我们想从所有相关的值中学习特征时,循环层同样有用: x[t]x[t-1]x[t-2]x[t-3]

在本章中,我们将讨论如何将 RNN 用于时间序列问题,这无疑是涉及按时间或时间顺序排列的一系列数据点的问题。

我们将在本章介绍以下主题:

  • 循环神经网络介绍
  • 时间序列问题
  • 将 LSTM 用于时间序列预测

循环神经网络介绍

如果定义不清楚,我们来看一个例子:一个股票行情指示器,我们可以在其中观察股票价格随时间的变化,例如以下屏幕快照中的 Alphabet Inc.,这是时间序列的一个示例:

在下一章中,我们将讨论使用循环神经网络为语言建模,这是另一种类型的序列,即单词序列。 由于您正在阅读本书,因此无疑已经对语言顺序有了一些直觉。

如果您不熟悉时间序列,您可能想知道是否可以使用普通的多层感知器来解决时间序列问题。 您当然可以做到; 但是,实际上,使用循环网络几乎总是可以得到更好的结果。 也就是说,循环神经网络在序列建模方面还有其他两个优点:

  • 他们可以比普通的 MLP 更轻松地学习很长的序列
  • 他们可以处理不同长度的序列

当然,这给我们提出了一个重要的问题...

是什么使神经元循环?

循环神经网络具有循环,可以使信息从一个预测持续到下一个预测。 这意味着每个神经元的输出取决于网络的当前输入和先前的输出,如下图所示:

如果我们将这个图跨时间展平,它将看起来更像下图。 网络通知本身的想法是“循环”一词的来源,尽管作为 CS 专业,我始终将其视为循环神经网络。

在上图中,我们可以看到神经元A接受输入x[t0]并输出h[t0]在时间步 0 处。然后在时间步 1,神经元使用输入x[t1]以及来自其上一个时间步的信号来输出h[t1]。 现在在时间步骤 2,它认为它是输入x[t2]以及上一个时间步骤的信号,该信号可能仍包含时间步骤 0 的信息。我们继续这种方式,直到到达序列中的最后一个时间步,网络逐步增加其内存。

标准 RNN 使用权重矩阵将前一个时间步的信号与当前时间步的输入和隐藏权重矩阵的乘积混合。 在通过非线性函数(通常是双曲正切函数)进行馈送之前,将所有这些函数组合在一起。 对于每个时间步骤,它看起来像:

此处t是前一个时间步输出和当前时间步输入的线性组合,均由权重矩阵WU进行参数化。 一旦计算出t,它就具有非线性函数,最常见的是双曲正切h[t]。 最后,神经元的输出o[t]h[t]与权重矩阵结合在一起,Va偏置,c偏置。

当您查看此结构时,请尝试想象一下一种情况,在该情况下,您很早就需要一些非常重要的信息。 随着序列的延长,重要的早期信息被遗忘的可能性就更高,因为新信号会轻易地压倒旧信息。 从数学上讲,单元的梯度将消失或爆炸。

这是标准 RNN 的主要缺点。 在实践中,传统的 RNN 难以按顺序学习真正的长期交互作用。 他们很健忘!

接下来,让我们看一下可以克服此限制的长短期内存网络。

长期短期记忆网络

每当需要循环网络时,长期短期记忆网络LSTM)都能很好地工作。 您可能已经猜到了,LSTM 在学习长期交互方面很出色。 实际上,这就是他们的设计意图。

LSTM 既可以积累先前时间步骤中的信息,又可以选择何时忘记一些不相关的信息,而选择一些新的更相关的信息。

例如,考虑序列In highschool I took Spanish. When I went to France I spoke French.。 如果我们正在训练一个网络来预测France一词,那么记住French并有选择地忘记Spanish是非常重要的,因为上下文已经发生了变化。 当序列的上下文发生变化时,LSTM 可以有选择地忘记某些事情。

为了完成这种选择性的长期记忆,LSTM 实现了一个“忘记门”,该门使 LSTM 成为了称为门控神经网络的神经网络家族的成员。 该遗忘门允许 LSTM 有选择地学习何时应从其长期存储器中丢弃信息。

LSTM 的另一个关键特性是内部自循环,使设备可以长期积累信息。 除了我们在 RNN 中看到的循环之外,还使用了该循环,可以将其视为时间步之间的外部循环。

相对于我们已经看到的其他神经元,LSTM 非常复杂,如下图所示:

每个 LSTM 单元展开时,都有一个时间段t的输入,称为x[t],一个输出,称为o[t]以及从上一个时间步C[t-1]到下一个C[t]进行存储的存储器总线C

除这些输入外,该单元还包含多个门。 我们已经提到的第一个是忘记门,在图中标记为F[t]

该门的输出(将在 0 和 1 之间)逐点乘以C[t-1]。 这允许门调节从C[t-1]C[t]的信息流。

下一个门,即输入门i[t]与函数候选C[t]结合使用。 候选C[t]学习可以添加到内存状态的向量。 输入门了解总线C中的哪些值得到更新。 下式说明i[t]和候选C[t]

我们取i[t]和候选C[t]的点积,决定添加到总线C的对象, 使用F[t]决定要忘记什么之后,如以下公式所示:

最后,我们将决定获取输出的内容。 输出主要来自内存总线C; 但是,它被另一个称为输出门的门过滤。 以下公式说明了输出:

尽管很复杂,但 LSTM 在各种问题上都非常有效。 尽管存在 LSTM 的多个变体,但在大多数任务上仍基本认为该基本实现是最新技术。

这些任务之一是预测时间序列中的下一个值,这就是我们将在本章中使用的 LSTM。 但是,在我们开始将 LSTM 应用于时间序列之前,必须对时间序列分析和更传统的方法进行简短的复习。

时间上的反向传播

训练 RNN 要求反向传播的实现略有不同,即在整个时间(BPTT)中称为反向传播

与正常反向传播一样,BPTT 的目标是使用整体网络误差,通过梯度来调整每个神经元/单元对它们对整体误差的贡献的权重。 总体目标是相同的。

但是,当使用 BPTT 时,我们对误差的定义会稍有变化。 正如我们刚刚看到的,可以通过几个时间步长展开神经元循环。 我们关心所有这些时间步长的预测质量,而不仅仅是终端时间步长,因为 RNN 的目标是正确预测序列,因为逻辑单元误差定义为所有时间步长上展开的误差之和。

使用 BPTT 时,我们需要总结所有时间步骤中的误差。 然后,在计算完该总体误差后,我们将通过每个时间步的梯度来调整单元的权重。

这迫使我们明确定义将展开 LSTM 的程度。 在下面的示例中,您将看到这一点,当我们创建一组特定的时间步长时,将为每个观察值进行训练。

您选择反向传播的步骤数当然是超参数。 如果您需要从序列中很远的地方学习一些东西,显然您必须在序列中包括很多滞后。 您需要能够捕获相关期间。 另一方面,捕获太多的时间步长也不可取。 该网络将变得非常难以训练,因为随着时间的流逝,梯度会变得非常小。 这是前面几章中描述的梯度消失问题的另一个实例。

如您想象的那样,您可能想知道是否选择太大的时间步会使程序崩溃。 如果梯度驱动得太小以至于变为 NaN,那么我们将无法完成更新操作。 解决此问题的一种常见且简便的方法是在某些上下阈值之间固定梯度,我们将其称为梯度裁剪。 默认情况下,所有 Keras 优化器均已启用梯度剪切。 如果您的梯度被剪裁,则在该时间范围内网络可能不会学到很多东西,但是至少您的程序不会崩溃。

如果 BPTT 看起来确实令人困惑,请想象一下 LSTM 处于展开状态,其中每个时间步都有一个单元。 对于该网络结构,该算法实际上与标准反向传播几乎相同,不同之处在于所有展开的层均共享权重。

时间序列问题回顾

时间序列问题是涉及按时间顺序放置的一系列数据点的问题。 我们通常将这些数据点表示为一组:

通常,我们在时间序列分析中的目标是预测。 但是,使用时间序列当然还可以执行许多其他有趣的事情,而这不在本书的讨论范围之内。 预测实际上只是回归的一种特殊形式,我们的目标是根据给定的先前点x[t-1], ..., x[t-n]来预测某个点x[t]或点x[t], x[t+1], x[t+2], ..., x[t+n]。 当时间序列自动关联时,我们可以执行此操作,这意味着数据点与其自身关联一个或多个时间上的点(称为滞后)。 自相关性越强,预测就越容易。

在许多书中,时间序列问题用y表示,而不是用x表示,以暗示我们通常关心预测给定自身的变量 y 的想法。

库存和流量

在计量经济学时间序列中,数量通常被定义为库存流量。 库存度量是指特定时间点的数量。 例如,2008 年 12 月 31 日的 SP500 的值是库存测量值。 流量测量是一段时间间隔内的速率。 美国股票市场从 2009 年到 2010 年的增长率是一种流量度量。

最经常进行预测时,我们会关注预测流量。 如果我们将预测想象为一种特定的回归,那么我们偏爱流量的第一个也是最明显的原因是,流量估计更有可能是插值而不是外推,而且插值几乎总是更安全。 此外,大多数时间序列模型都具有平稳性的假设。 固定时间序列是其统计属性(均值,方差和自相关)随时间恒定的序列。 如果我们使用一定数量的库存测量,则会发现大多数现实世界中的问题远非静止不动。

使用 LSTM 进行时间序列分析时,虽然没有假设(读取规则)需要平稳性,但根据实际经验,我发现对相对固定的数据进行训练的 LSTM 更加健壮。 使用 LSTM 进行时间序列预测时,几乎在所有情况下,一阶差分就足够了。

将库存数量转换为流量数量非常简单。 如果您具有n个点,则可以创建具有一阶差分的n-1流量测量值,其中,对于每个值t'[n],我们通过从t[n]中减去t[n-1]来进行计算,从而得出跨时间间隔的两次测量的变化率,如以下公式所示:

例如,如果我们在三月份拥有价值 80 美元的股票,而在四月份突然价值 100 美元,则该股票的流率将为 20 美元。

一阶微分不能保证平稳的时间序列。 我们可能还需要删除季节或趋势。 趋势消除是专业预测员日常生活的重要组成部分。 如果我们使用传统的统计模型进行预测,则需要做更多的工作。 虽然我们没有涵盖这些内容的页面,但我们可能还需要执行二阶差分,季节性趋势下降或更多操作。 增强 Dickey-FullerADF)测试是一种统计测试,通常用于确定我们的时间序列是否实际上是静止的。 如果您想知道时间序列是否稳定,可以使用增强的 Dickey-Fuller 检验来检查。 但是,对于 LSTM,一阶微分通常可能就足够了。 只需了解网络最肯定会学习您数据集中剩余的季节和周期。

ARIMA 和 ARIMAX 预测

值得一提的是自回归综合移动平均值ARIMA)模型,因为它们传统上用于时间序列预测。 虽然我显然是深度神经网络的忠实拥护者(事实上,我写过关于它们的书),但我建议从 ARIMA 开始并逐步进行深度学习。 在许多情况下,ARIMA 的表现将优于 LSTM。 当数据稀疏时尤其如此。

从可能可行的最简单模型开始。 有时这将是一个深层的神经网络,但通常情况会更简单一些,例如线性回归或 ARIMA 模型。 该模型的复杂性应通过其提供的提升来证明,通常越简单越好。 尽管整本书中多次重申,但在时间序列预测中,这一说法比其他任何话题都更为真实。

ARIMA 模型是三个部分的组合。 AR,即自回归部分,是根据自身的自相关性对序列进行建模的部分。 MA 部分尝试对时间序列中的本地突发事件或冲击建模。 I 部分涵盖了差异,我们刚刚介绍了差异。 ARIMA 模型通常采用三个超参数pdq,分别对应于建模的自回归滞后的数量,微分度和模型的移动平均部分的顺序。

ARIMA 模型在 R 的auto.arima()和预测包中实现得很好,这可能是使用 R 语言的唯一很好的理由之一。

ARIMAX 模型允许在时间序列模型中包含一个或多个协变量。 您问这种情况下的协变量是多少? 这是一个附加时间序列,也与因变量相关,可用于进一步改善预测表现。

交易员的常见做法是尝试通过使用另一种商品的一个或多个滞后以及我们预测的商品的自回归部分来预测某些商品的价值。 在这种情况下,ARIMAX 模型将很有用。

如果您有许多具有复杂的高阶交互作用的协变量,那么您已进入 LSTM 的最佳预测时间序列。 在本书的开头,我们讨论了多层感知器如何对输入变量之间的复杂相互作用进行建模,从而为我们提供了自动特征工程,该工程提供了线性或逻辑回归的提升。 此属性可以继续使用 LSTM 进行具有许多输入变量的时间序列预测。

如果您想全面了解 ARIMA,ARIMAX 和时间序列预测,建议从 Rob J. Hyndman 的博客 Hyndsight 开始。

将 LSTM 用于时间序列预测

在本章中,我们将通过使用 2017 年 1 月至 5 月的比特币分钟价格来预测 2017 年 6 月美元的比特币分钟价格。我知道这听起来确实很赚钱,但是在您购买那条船之前,我建议您通读本章的最后; 说起来容易做起来难,甚至建模起来也容易。

即使我们能够使用这种模型在美元和比特币之间创造套利潜力(由于效率低下而导致两个市场之间的价格差异),但由于存在延迟,围绕比特币制定交易策略可能极其复杂。 在完成比特币交易中。 在撰写本文时,比特币交易的平均交易时间超过一个小时! 任何交易策略都应考虑这种“非流动性”。

和以前一样,本书的 Git 存储库中的Chapter09下提供了本章的代码。 文件data/bitcoin.csv包含数年的比特币价格。 基于以下假设,即前几年的市场行为与 2017 年加密货币流行后的行为无关,我们将仅使用几个月的价格信息作为模型。

数据准备

对于此示例,我们将不使用验证集,而是将测试集用作验证集。 在处理此类预测问题时,验证成为一项具有挑战性的工作,因为训练数据从测试数据中获取的越多,执行效果越差的可能性就越大。 另一方面,这并不能为过度安装提供太多保护。

为了使事情简单,在这里我们将只使用一个测试集,并希望最好。

在继续之前,让我们看一下将要进行的数据准备的总体流程。 为了使用此数据集训练 LSTM,我们需要:

  1. 加载数据集并将周期时间转换为熊猫日期时间。
  2. 通过对日期范围进行切片来创建训练和测试集。
  3. 差分我们的数据集。
  4. 将差异缩放到更接近我们的激活函数的程度。 我们将使用 -1 到 1,因为我们将使用tanh作为激活
  5. 创建一个训练集,其中每个目标x[t]都有一系列与之相关的滞后x[t-1], ..., x[t-n]。 在此训练集中,您可以将x[t]视为我们的典型因变量y。 滞后序列x[t-1], ..., x[t-n]可以看作是典型的X训练矩阵。

我将在接下来的主题中介绍每个步骤,并在进行过程中显示相关的代码。

加载数据集

从磁盘加载数据集是一项相当简单的工作。 如前所述,我们将按日期对数据进行切片。 为此,我们需要将数据集中的 Unix 周期时间转换为可分割的日期。 可以通过pandas to_datetime()方法轻松实现,如以下代码所示:

def read_data():
    df = pd.read_csv("./data/bitcoin.csv")
    df["Time"] = pd.to_datetime(df.Timestamp, unit='s')
    df.index = df.Time
    df = df.drop(["Time", "Timestamp"], axis=1)
    return df

按日期切片和测试

现在,我们的数据帧已通过datetime时间戳编制索引,因此我们可以构造基于日期的切片函数。 为此,我们将定义一个布尔掩码,并使用该掩码选择现有的数据框。 虽然我们可以肯定地将其构造成一行,但我认为以这种方式阅读起来要容易一些,如以下代码所示:

def select_dates(df, start, end):
    mask = (df.index > start) & (df.index <= end)
    return df[mask]

现在我们可以使用日期来获取数据框的某些部分,我们可以使用以下代码通过几次调用这些函数轻松地创建训练和测试数据框:

df = read_data()
df_train = select_dates(df, start="2017-01-01", end="2017-05-31")
df_test = select_dates(df, start="2017-06-01", end="2017-06-30")

在使用这些数据集之前,我们需要对它们进行区别,如下所示。

差分时间序列

Pandas 数据框最初是为对时间序列数据进行操作而创建的,幸运的是,由于对数据集进行差分是时间序列中的一种常见操作,因此很容易内置。但是,根据良好的编码习惯,我们将围绕我们的一阶差分运算包装一个函数。 请注意,我们将用 0 填充无法进行一阶差分的所有空间。以下代码说明了此技术:

def diff_data(df):
    df_diffed = df.diff()
    df_diffed.fillna(0, inplace=True)
    return df_diffed

通过差分数据集,我们将这个问题(库存问题)转移到了流量问题。 在比特币投放中,流量可能会很大,因为比特币的价值会在数分钟之间发生很大变化。 我们将通过缩放数据集来解决此问题。

缩放时间序列

在此示例中,我们将使用MinMaxScaler将每个差异数据点缩放为最小值为 -1 且最大值为 1 的比例。这将使我们的数据与双曲线正切函数(tanh ),这是我们针对该问题的激活函数。 我们将使用以下代码缩放系列:


def scale_data(df, scaler=None):
    scaled_df = pd.DataFrame(index=df.index)
    if not scaler:
        scaler = MinMaxScaler(feature_range=(-1,1))
    scaled_df["Price"] = scaler.fit_transform(df.Close.values.reshape(-1,1))
    return scaler, scaled_df

请注意,此函数可以选择使用已经适合的缩放器。 这使我们能够将训练定标器应用到我们的测试仪上。

创建滞后的训练集

对于每个训练示例,给定一系列延迟x[t-1], ..., x[t-n],我们希望训练网络以预测值x[t]。 理想的延迟数是一个超参数,因此需要进行一些实验。

如前所述,以这种方式构造输入是 BPTT 算法的要求。 我们将使用以下代码来训练数据集:

def lag_dataframe(data, lags=1):
    df = pd.DataFrame(data)
    columns = [df.shift(i) for i in range(lags, 0, -1)]
    columns.append(df)
    df = pd.concat(columns, axis=1)
    df.fillna(0, inplace=True)

    cols = df.columns.tolist()
    for i, col in enumerate(cols):
        if i == 0:
            cols[i] = "x"
        else:
            cols[i] = "x-" + str(i)

    cols[-1] = "y"
    df.columns = cols
    return df

例如,如果我们用lags = 3调用lag_dataframe,我们期望数据集返回x[t-1], x[t-2], x[t-3]。 我发现很难理解这样的滞后代码,因此,如果您也这样做,您并不孤单。 我建议运行它并建立一些熟悉的操作。

在选择数量滞后时,在将模型部署到生产环境时,您可能还需要考虑要等待多少个滞后才能做出预测。

输入形状

Keras 期望 LSTM 的输入是一个三维张量,看起来像:

第一个维度显然是我们拥有的观测值的数量,并且我们可以预期。

第二维对应于使用lag_dataframe函数时我们选择的滞后次数。 这是我们要给 Keras 做出预测的时间步数。

第三维是该时间步中存在的特征数。 在我们的示例中,我们将使用一个,因为每个时间步只有一个特征,即该时间步的比特币价格。

在继续阅读之前,请仔细考虑此处定义三维矩阵给您的威力。 我们绝对可以将数百个其他时间序列作为预测该时间序列的特征。 通过这样做以及使用 LSTM,我们可以免费获得这些特征之间的特征工程。 正是这种功能使 LSTM 在金融领域变得如此令人兴奋。

对于当前的问题,我们将需要将二维矩阵转换为三维矩阵。 为此,我们将使用 NumPy 的便捷reshape函数,如以下代码所示:

X_train = np.reshape(X_train.values, (X_train.shape[0], X_train.shape[1], 1))
X_test = np.reshape(X_test.values, (X_test.shape[0], X_test.shape[1], 1))

数据准备

在此示例中,我们做了很多转换。 在继续进行训练之前,我认为最好将两者结合起来。 如此处所示,我们将使用另一个函数将所有这些步骤联系在一起:

def prep_data(df_train, df_test, lags):
    df_train = diff_data(df_train)
    scaler, df_train = scale_data(df_train)
    df_test = diff_data(df_test)
    scaler, df_test = scale_data(df_test, scaler)
    df_train = lag_dataframe(df_train, lags=lags)
    df_test = lag_dataframe(df_test, lags=lags)

    X_train = df_train.drop("y", axis=1)
    y_train = df_train.y
    X_test = df_test.drop("y", axis=1)
    y_test = df_test.y

    X_train = np.reshape(X_train.values, (X_train.shape[0], X_train.shape[1], 1))
    X_test = np.reshape(X_test.values, (X_test.shape[0], X_test.shape[1], 1))

    return X_train, X_test, y_train, y_test

此函数采用训练和测试数据帧,并应用差分,缩放和滞后代码。 然后,将这些数据帧重新调整为我们熟悉的Xy张量,以进行训练和测试。

现在,我们可以使用几行代码将这些转换粘合在一起,从而从加载数据到准备进行训练和测试,它们可以:

LAGS=10
df = read_data()
df_train = select_dates(df, start="2017-01-01", end="2017-05-31")
df_test = select_dates(df, start="2017-06-01", end="2017-06-30")
X_train, X_test, y_train, y_test = prep_data(df_train, df_test, lags=LAGS)

这样,我们就可以开始训练了。

网络输出

我们的网络将输出一个单一值,该值是在前一分钟内给定分钟内比特流价格的缩放流量或预期变化。

我们可以使用单个神经元获得此输出。 该神经元可以在 Keras 密集层中实现。 它将多个 LSTM 神经元的输出作为输入,我们将在下一部分中介绍。 最后,此神经元的激活可以是tanh,因为我们已将数据缩放到与双曲正切函数相同的比例,如下所示:

output = Dense(1, activation='tanh', name='output')(lstm2)

网络架构

我们的网络将使用两个 Keras LSTM 层,每个层具有 100 个 LSTM 单元:

inputs = Input(batch_shape=(batch_shape, sequence_length, 
               input_dim), name="input")
lstm1 = LSTM(100, activation='tanh', return_sequences=True, 
             stateful=True, name='lstm1')(inputs)
lstm2 = LSTM(100, activation='tanh', return_sequences=False, 
             stateful=True, name='lstm2')(lstm1)
output = Dense(1, activation='tanh', name='output')(lstm2)

要特别注意return_sequences参数。 连接两个 LSTM 层时,您需要前一个 LSTM 层来输出序列中每个时间步的预测,以便下一个 LSTM 层的输入是三维的。 但是,我们的密集层仅需要二维输出即可预测其执行预测的确切时间步长。

有状态与无状态 LSTM

在本章的前面,我们讨论了 RNN 跨时间步长维护状态或内存的能力。

使用 Keras 时,可以用两种方式配置 LSTM,即有状态无状态

默认为无状态配置。 使用无状态 LSTM 配置时,每批 LSTM 单元存储器都会重置。 这使得批量大小成为非常重要的考虑因素。 当您正在学习的序列彼此不依赖时,无状态效果最佳。 下一个单词的句子级预测可能是何时使用无状态的一个很好的例子。

有状态配置会在每个周期重置 LSTM 单元存储器。 当训练集中的每个序列取决于其之前的序列时,最常使用此配置。 如果句子级别的预测对于无状态配置可能是一项好任务,那么文档级别的预测对于有状态模型可能是一项好任务。

最终,这种选择取决于问题,并且可能需要在测试每个选项时进行一些试验。

对于此示例,我已经测试了每个选项,并选择使用有状态模型。 当我们考虑问题的背景时,这可能不足为奇。

训练

尽管此时的情况似乎有很大不同,但是训练 LSTM 实际上与训练典型横截面问题的深度神经网络没有什么不同:

LAGS=10
df = read_data()
df_train = select_dates(df, start="2017-01-01", end="2017-05-31")
df_test = select_dates(df, start="2017-06-01", end="2017-06-30")
X_train, X_test, y_train, y_test = prep_data(df_train, df_test, lags=LAGS)
model = build_network(sequence_length=LAGS)
callbacks = create_callbacks("lstm_100_100")
model.fit(x=X_train, y=y_train,
          batch_size=100,
          epochs=10,
          callbacks=callbacks)
model.save("lstm_model.h5")

在准备好数据之后,我们使用我们已经遍历的架构实例化一个网络,然后按预期对其进行拟合。

在这里,我使用的是有状态的 LSTM。 有状态 LSTM 的一个实际好处是,与无状态 LSTM 相比,它们倾向于在更少的时间进行训练。 如果要将其重构为无状态 LSTM,则在网络完成学习之前可能需要 100 个周期,而此处我们仅使用 10 个周期。

测量表现

在有状态的配置中经过 10 个星期之后,我们的损失已经停止改善,并且我们的网络也受到了良好的训练,如下图所示:

我们拥有一个合适的网络,似乎已经学到了一些东西。 现在,我们可以对比特币的价格流做出某种预测。 如果我们能做好,我们所有人都会非常富有。 在去买那栋豪宅之前,我们可能应该测量一下模型的表现。

财务模型的最终检验是这个问题:“您愿意在上面花钱吗?”很难回答这个问题,因为在时间序列问题中衡量表现可能具有挑战性。

一种衡量表现的非常简单的方法是使用均方根误差来评估y_testX_test预测之间的差异。 我们最肯定可以做到这一点,如以下代码所示:

RMSE = 0.0801932157201

0.08 是一个好分数吗? 让我们通过比较我们的预测与 6 月份比特币流量的实际值,开始对商品的调查。 这样做可能会使我们对模型的表现有直观的了解,这是我始终建议的一种做法:

我们用绿色表示的预测有很多不足之处。 我们的模型已经学会了预测平均流量,但是在匹配完整信号方面确实做得很差。 甚至有可能我们只是在学习一种趋势,因为我们所做的努力不那么激烈。 我认为我们可能不得不把那栋豪宅推迟更长的时间,但是我们走了正确的道路。

考虑到我们的预测,即仅给出比特币的先前价值,该模型就可以解释尽可能多的比特币价格。 我们可能在建模时间序列的自回归部分方面做得相当不错。 但是,可能有许多不同的外部因素影响比特币的价格。 美元的价值,其他市场的动向,也许最重要的是,围绕比特币的嗡嗡声或信息流通,都可能在美元的价格中发挥重要作用。

这就是 LSTM 用于时间序列预测的功能真正发挥作用的地方。 通过添加附加的输入特征,所有这些信息都可以在某种程度上轻松地添加到模型中,希望可以解释越来越多的整个图片。

但是,让我再破一次您的希望。 对表现进行更彻底的调查还将包括考虑模型相对于某些幼稚模型所提供的提升。 此简单模型的典型选择可能包括称为随机游走模型,指数平滑模型的模型,或者可能使用朴素的方法,例如使用上一个时间步长作为当前时间步长的预测。 如下图所示:

在此图中,我们将红色的预测与一个模型进行比较,在模型中,我们仅将前一分钟用作绿色的下一分钟的预测。 以蓝色表示的实际价格几乎完美地覆盖了这个朴素的模型。 我们的 LSTM 预测不如幼稚模型好。 仅使用最后一分钟的价格来预测当前分钟的价格会更好。 尽管我坚持认为我们走在正确的道路上,但在那艘船成为我们的船之前,我们还有很长的路要走。

对任何商品建模非常困难。 对于这种类型的问题,使用深度神经网络是可以肯定的,但是这个问题并不容易。 我加入了这个也许详尽的解释,以便如果您决定走这条路,便会明白自己的目标。

就是说,当您使用 LSTM 套利金融市场时,请记住给小费。

总结

在本章中,我们讨论了使用循环神经网络来预测序列中的下一个元素。 我们既涵盖了一般的 RNN,也涵盖了特定的 LSTM,我们专注于使用 LSTM 预测时间序列。 为了确保我们了解将 LSTM 用于时间序列的好处和挑战,我们简要回顾了时间序列分析的一些基础知识。 我们还花了几分钟讨论传统的时间序列模型,包括 ARIMA 和 ARIMAX。

最后,我们介绍了一个具有挑战性的用例,其中我们使用 LSTM 来预测比特币的价格。

在下一章中,我们将继续使用 RNN,现在将重点放在自然语言处理任务上,并介绍嵌入层的概念。

十、使用词嵌入从头开始训练 LSTM

到目前为止,我们已经看到了深度学习在结构化数据,图像数据甚至时间序列数据中的应用示例。 似乎唯一正确的方法是继续进行自然语言处理NLP)作为下一步。 机器学习和人类语言之间的联系非常有趣。 深度学习已像计算机视觉一样,以指数方式加快了该领域的发展速度。 让我们从 NLP 的简要概述开始,并在本章中将要完成的一些任务开始。

我们还将在本章中介绍以下主题:

  • 自然语言处理入门
  • 向量化文本
  • 词嵌入
  • Keras 嵌入层
  • 用于自然语言处理的一维 CNN
  • 文档分类的案例研究

自然语言处理入门

NLP 领域广阔而复杂。 从技术上讲,人类语言与计算机科学之间的任何交互都可能属于此类。 不过,为了便于讨论,我将 NLP 限于分析,理解,有时生成人类语言。

从计算机科学的起源开始,我们就对 NLP 着迷,因为它是通向强大人工智能的门户。 1950 年,艾伦·图灵(Alan Turing)提出了图灵测试,其中涉及一台计算机,它很好地模仿了一个人,使其与另一个人无法区分,以此作为机器智能的度量标准。 从那时起,我们一直在寻找帮助机器理解人类语言的聪明方法。 在此过程中,我们开发了语音到文本的转录,人类语言之间的自动翻译,文档的自动汇总,主题建模,命名实体标识以及各种其他用例。

随着我们对 NLP 的了解不断增长,我们发现 AI 应用在日常生活中变得越来越普遍。 聊天机器人作为客户服务应用已变得司空见惯,最近,它们已成为我们的个人数字助理。 在撰写本文时,我可以要求 Alexa 在我的购物清单中添加一些内容或演奏一些流畅的爵士乐。 自然语言处理以一种非常有趣和强大的方式将人类连接到计算机。

在本章中,我将专注于理解人类语言,然后使用这种理解进行分类。 我实际上将进行两个分类案例研究,一个涉及语义分析,另一个涉及文档分类。 这两个案例研究为深度学习的应用提供了巨大的机会,而且它们确实非常相似。

语义分析

语义分析从技术上讲是对语言含义的分析,但是通常当我们说语义分析时,我们是在谈论理解作者的感受。 语义分类器通常试图将某些话语分类为积极,消极,快乐,悲伤,中立等。

讽刺是我最喜欢的语言之一,这使这成为一个具有挑战性的问题。 人类语言中有许多微妙的模式,这些对于计算机学习来说是非常具有挑战性的。 但是挑战并不意味着没有可能。 只要有一个好的数据集,这个任务就很有可能实现。

要成功解决此类问题,需要一个好的数据集。 虽然我们当然可以在整个互联网上找到大量的人类对话,但其中大多数没有标签。 查找带标签的病例更具挑战性。 解决此问题的早期尝试是收集包含表情符号的 Twitter 数据。 如果一条推文中包含:),则认为该推文是肯定的。 这成为 Jimmy Lin 和 Alek Kolcz 在 Twitter 上的大规模机器学习中引用的知名表情符号技巧。

这种类型的分类器的大多数业务应用都是二元的,我们尝试在其中预测客户是否满意。 但是,那当然不是对这种语言模型的限制。 只要我们有用于此类事物的标签,我们就可以为其他音调建模。 我们甚至可能尝试衡量某人的声音或语言中的焦虑或困扰; 但是,解决音频输入超出了本章的范围。

进一步挖掘数据的尝试包括使用与正面和负面电影评论相关的语言以及与在线购物产品评论相关的语言。 这些都是很好的方法。 但是,在使用这些类型的数据源对来自不同域的文本进行分类时,应格外小心。 您可能会想到,电影评论或在线购买中使用的语言可能与 IT 帮助台客户支持电话中使用的语言完全不同。

当然,我们当然可以对情绪进行更多的分类。 在下一节中,我们将讨论文档分类的更一般的应用。

文档分类

文档分类与情感分析密切相关。 在这两种情况下,我们都使用文本将文档分类。 实际上,这只是改变的原因。 文档分类就是根据文档的类型对文档进行分类。 世界上最明显,最常见的文档分类系统是垃圾邮件过滤器,但它还有许多其他用途。

我最喜欢的文档分类用途之一是解决“联邦主义者论文”的原始作者的辩论。 亚历山大·汉密尔顿(Alexander Hamilton),詹姆斯·麦迪逊(James Madison)和约翰·杰伊(John Jay)在 1787 年和 1788 年以化名 Publius 出版了 85 篇文章,支持批准美国宪法。 后来,汉密尔顿提供了一份清单,详细列出了每篇论文的作者在 1804 年与亚伦·伯尔(Aaron Burr)进行致命的对决之前。麦迪逊(Madison)在 1818 年提供了自己的清单,这在作者身份上引起了争执,此后学者一直在努力解决。 虽然大多数人都同意有争议的作品是麦迪逊的作品,但是关于两者之间的合作仍存在一些理论。 将这 12 个有争议的文档归类为 Madison 还是 Hamilton,已经成为许多数据科学博客的不二之选。 正式而言,Glenn Fung 的论文《有争议的联邦主义者论文:通过凹面最小化进行 SVM 特征选择》 涵盖了相当严格的主题。

文档分类的最后一个示例可能是围绕了解文档的内容并规定操作。 想象一下一个分类器,它可能会读取有关法律案件的一些信息,例如请愿/投诉和传票,然后向被告提出建议。 然后,我们的假想系统可能会说:鉴于我在其他类似情况下的经验,您可能想解决

情感分析和文档分类是基于计算机理解自然语言的能力的强大技术。 但是,当然,这引出了一个问题,我们如何教计算机阅读?

向量化文本

机器学习模型(包括深度神经网络)吸收数字信息并产生数字输出。 自然语言处理的挑战自然就变成了将单词转换成数字。

我们可以通过多种方式将单词转换为数字。 所有这些方法都满足相同的目标,即将某些单词序列转换为数字向量。 有些方法比其他方法更好,因为有时进行转换时,翻译中可能会失去一些含义。

NLP 术语

让我们从定义一些通用术语开始,以便消除它们使用可能引起的任何歧义。 我知道,由于您可以阅读,因此您可能会对这些术语有所了解。 如果这看起来很古怪,我深表歉意,但是我保证,这将立即与我们接下来讨论的模型有关:

  • :我们将使用的大多数系统的原子元素。 尽管确实存在某些字符级模型,但我们今天不再讨论它们。
  • 句子:表达陈述,问题等的单词集合。
  • 文档:文档是句子的集合。 它可能是一个句子,或更可能是多个句子。
  • 语料库:文档的集合。

词袋模型

词袋BoW)模型是 NLP 模型,实际上忽略了句子结构和单词放置。 在“单词袋”模型中,我们将每个文档视为单词袋。 很容易想到这一点。 每个文档都是一个包含大量单词的容器。 我们忽略句子,结构以及哪个词排在前或后。 我们对文档中包含“非常”,“很好”和“不好”这两个词的事实感到关注,但是我们并不真正在意“好”而不是“坏”。

词袋模型很简单,需要相对较少的数据,并且考虑到该模型的朴素性,其运行效果非常好。

注意,这里使用模型表示表示。 我并不是在特定意义上指深度学习模型或机器学习模型。 相反,在这种情况下,模型是表示文本的一种方式。

给定一个由一组单词组成的文档,则需要定义一种策略来将单词转换为数字。 稍后我们将介绍几种策略,但首先我们需要简要讨论词干,词形化和停用词。

词干,词根去除和停用词

词干词根去除是两种不同但非常相似的技术,它们试图将每个单词还原为基本形式,从而简化了语言模型。 例如,如果要阻止猫的各种形式,我们将在此示例中进行转换:

cat, cats, cat's, cats' -> cat

限制词法化和词干化之间的差异成为我们进行此转换的方式。 提取是通过算法完成的。 当应用于同一个单词的多种形式时,提取的根在大多数情况下应相同。 这个概念可以与词条反义化形成对比,词条反义化使用具有已知基础的词汇表并考虑如何使用该词。

词干处理通常比词条化处理快得多。 Porter 提取器在很多情况下都可以很好地工作,因此您可以将其作为提取的第一个安全选择。

停用词是在该语言中非常常见的词,但几乎没有语义。 典范示例是the一词。 我在上一句话中只使用了 3 次,但实际上只保留了一次意思。 通常,我们会删除停用词,以使输入内容更加稀疏。

大部分 BoW 模型都受益于词干,词根化和删除停用词。 有时,我们很快将要讨论的词嵌入模型也可以从词干提取或词义化中受益。 词嵌入模型很少会受益于停用词的删除。

计数和 TF-IDF 向量化

计数向量化和词频逆文档频率TF-IDF)是两种策略,将词袋转换成适合机器学习算法输入的特征向量。

计数向量化采用我们的一组单词,并创建一个向量,其中每个元素代表语料库词汇中的一个单词。 自然,一组文档中唯一单词的数量可能会很大,并且许多文档可能不包含语料库中存在的单词的任何实例。 在这种情况下,使用稀疏矩阵表示这些类型的字向量通常是非常明智的。 当一个单词出现一次或多次时,计数向量化器将简单地对该单词出现在文档中的次数进行计数,然后将该计数放置在代表该单词的位置。

使用计数向量化器,整个语料库可以表示为二维矩阵,其中每一行是一个文档,每一列是一个单词,然后每个元素就是该单词在文档中的计数。

在继续之前,让我们先看一个简单的例子。 想象一个具有两个文档的语料库:

docA = "the cat sat on my face"
docB = "the dog sat on my bed"

语料库词汇为:

{'bed', 'cat', 'dog', 'face', 'my', 'on', 'sat', 'the'}

因此,如果我们要为该语料库创建一个计数嵌入,它将看起来像这样:

bedcatdogfacemyonsatthe
文件 001011111
文件 110101111

这就是计数向量化。 这是我们工具箱中最简单的向量化技术。

计数向量化的问题在于我们使用了很多根本没有太多意义的单词。 实际上,英语中最常用的单词(the)占我们所讲单词的 7%,是第二个最受欢迎的单词(of)出现频率的两倍。 语言中单词的分布是幂律分布,这是称为 Zipf 定律的基础。 如果我们从计数中构造文档矩阵,那么最终得到的数字将包含很多信息,除非我们的目标是查看谁最经常使用the

更好的策略是根据单词在文档中的相对重要性对单词进行加权。 为此,我们可以使用 TF-IDF。

一个单词的 TF-IDF 分数是:

在此公式中:

这个公式:

如果我们要为同一语料库计算 TF-IDF 矩阵,它将看起来像这样:

bedcatdogfacemyonsatthe
文件 000.11600.1160000
文件 10.11600.11600000

您可能会注意到,通过对单词频率乘以逆文档频率进行加权,我们取消了所有文档中出现的单词,从而放大了不同的单词。 文件 0 全部关于猫和脸,而文件 1 全部关于狗和床。 这正是我们对许多分类器所要的。

词嵌入

词袋模型具有一些不理想的属性,值得注意的是。

我们之前研究过的词袋模型的第一个问题是它们没有考虑单词的上下文。 他们并没有真正考虑文档中单词之间存在的关系。

第二个相关问题是向量空间中单词的分配有些随意。 可能无法捕获有关语料库词汇中两个单词之间的关系的信息。 例如,虽然鳄鱼和鳄鱼都是相似的具有许多特征的生物,但已经学会处理鳄鱼的单词的模型几乎无法利用鳄鱼学到的知识(爬行动物学家讨厌邮件) 。

最后,由于语料库的词汇量可能很大,并且可能不会出现在所有文档中,因此 BoW 模型往往会产生非常稀疏的向量。

单词嵌入模型通过为每个单词学习一个向量来解决这些问题,其中每个语义相似的单词都映射到(嵌入)附近的点。 另外,与 BoW 模型相比,我们将在更小的向量空间中表示整个词汇表。 这提供了降维效果,并为我们提供了一个更小,更密集的向量,该向量可以捕获单词的语义值。

词嵌入模型在现实文档分类问题和语义分析问题中通常比词袋模型具有很大的提升,因为这种能力可以保留词相对于语料库中其他词的语义值。

一个简单的例子

如果您不熟悉单词嵌入,那么您现在可能会感到有些迷茫。 挂在那儿,它很快就会变得清晰起来。 让我们尝试一个具体的例子。

使用流行的单词嵌入模型word2vec,我们可以从单词cat开始,找到它的 384 元素向量,如以下输出代码所示:

array([ 5.81600726e-01, 3.07168198e+00, 3.73339128e+00,
 2.83814788e-01, 2.79787600e-01, 2.29124355e+00,
 -2.14855480e+00, -1.22236431e+00, 2.20581269e+00,
 1.81546474e+00, 2.06929898e+00, -2.71712840e-01,...

我缩短了输出,但您明白了。 此模型中的每个单词都将转换为 384 个元素的向量。 可以对这些向量进行比较,以评估数据集中单词的语义相似性。

现在我们有了猫的向量,我将计算狗和蜥蜴的词向量。 我建议猫比蜥蜴更像狗。 我应该能够测量猫向量和狗向量之间的距离,然后测量猫向量和蜥蜴向量之间的距离。 尽管有许多方法可以测量向量之间的距离,但余弦相似度可能是单词向量最常用的方法。 在下表中,我们正在比较猫与狗和蜥蜴的余弦相似度:

doglizard
cat0.740.63

不出所料,在我们的向量空间中,猫的含义比蜥蜴更接近狗。

通过预测学习单词嵌入

单词嵌入是通过使用专门为该任务构建的神经网络来计算的。 我将在这里介绍该网络的概述。 一旦计算了某些语料库的词嵌入,它们便可以轻松地重用于其他应用,因此使该技术成为迁移学习的候选者,类似于我们在第 8 章“使用预先训练的 CNN 的迁移学习”中介绍的技术。

当我们完成了对该词嵌入网络的训练后,我们网络中单个隐藏层的权重将成为我们词嵌入的查找表。 对于词汇表中的每个单词,我们将学习该单词的向量。

该隐藏层将包含比输入空间少的神经元,从而迫使网络学习输入层中存在的信息的压缩形式。 这种架构非常类似于自编码器。 但是,该技术围绕着一项任务,该任务帮助网络学习向量空间中每个单词的语义值。

我们将用来训练嵌入网络的任务是预测某些目标词出现在距训练词距离窗口内的概率。 例如,如果koala是我们的输入词,而marsupials是我们的目标词,则我们想知道这两个词彼此靠近的可能性。

此任务的输入层将是词汇表中每个单词的一个热编码向量。 输出层将是相同大小的softmax层,如下图所示:

该网络导致隐藏层的形状为权重矩阵[词汇 x 神经元]。 例如,如果我们的语料库中有 20,000 个唯一单词,而隐藏层中有 300 个神经元,那么我们的隐藏层权重矩阵将为20,000 x 300。将这些权重保存到磁盘后,我们将拥有一个 300 元素向量,可用于代表每个词。 然后,在训练其他模型时,可以使用这些向量表示单词。

当然,除此以外,还有更多的训练词嵌入网络的方法,而我故意过分简化了快速参考样式。

如果您想了解更多信息,我建议您先阅读 Mikolov 等人的《单词和短语的分布式表示及其组成》。 本文介绍了一种流行的创建单词嵌入的方法,称为word2vec

通过计数学习单词嵌入

学习单词嵌入的另一种方法是通过计数。 用于词表示的全局向量GloVe 是 Pennington 等人创建的算法

GloVe 通过创建单词共现的非常大的矩阵来工作。 对于某些语料库,这实际上是两个单词彼此相邻出现的次数的计数。 该算法的作者根据单词的接近程度来加权此计数,以使彼此接近的单词对每个计数的贡献更大。 一旦创建了这个共现矩阵,它将分解为一个较小的空间,从而生成一个单词 x 特征较大的矩阵。

有趣的是,word2vec和 GloVe 的结果非常相似,可以互换使用。 由 60 亿个单词的数据集预先构建的 GloVe 向量由斯坦福大学分发,是单词向量的常用来源。 本章稍后将使用 GloVe 向量。

从文本到文档

如果您一直在仔细阅读,您可能会注意到我尚未消除的鸿沟。 词嵌入模型为每个词创建一个向量。 相比之下,BoW 模型为每个文档创建一个向量。 那么,我们如何使用词嵌入模型进行文档分类呢?

一种幼稚的方法可能是获取文档中所有单词的向量并计算均值。 我们可能将此值解释为文档的平均语义值。 在实践中,通常使用此解决方案,并且可以产生良好的结果。 但是,它并不总是优于 BoW 嵌入模型。 考虑短语dog bites manman bites dog。 希望您会同意我的观点,这是两个截然不同的陈述。 但是,如果我们对它们的词向量进行平均,它们将具有相同的值。 这使我们提出了一些其他策略,可以用来设计文档中的特征,例如使用每个向量的均值,最大值和最小值。

Le 和 Mikolov 在《句子和文档的分布式表示》中提出了一种从单词到文档的更好的想法。 基于word2vec的思想,本文将段落标识符添加到我们描述的用于学习单词向量的神经网络的输入中。 使用文本中的单词以及文档 ID 可以使网络学习将可变长度文档嵌入向量空间中。 该技术称为 doc2vec,它可以很好地用作主题建模以及为模型创建输入特征的技术。

最后,许多深度学习框架都包含了嵌入层的概念。 嵌入层使您可以了解嵌入空间,这是网络正在执行的总体任务的一部分。 使用深度神经网络时,嵌入层可能是向量化文本的最佳选择。 接下来让我们看一下嵌入层。

Keras 嵌入层

Keras 嵌入层允许我们学习输入词的向量空间表示,就像我们在训练模型时在word2vec中所做的那样。 使用函数式 API,Keras 嵌入层始终是网络中的第二层,紧随输入层之后。

嵌入层需要以下三个参数:

  • input_dim:语料库的词汇量。
  • output_dim:我们要学习的向量空间的大小。 这将对应于word2vec隐藏层中神经元的数量。
  • input_length:我们将在每次观察中使用的文字数量。 在下面的示例中,我们将根据需要发送的最长文本使用固定大小,并将较小的文档填充为 0。

嵌入层将为每个输入文档输出 2D 矩阵,该矩阵包含input_length指定的每个单词的一个向量。

例如,我们可能有一个如下所示的嵌入层:

Embedding(input_dim=10000, output_dim=128, input_length=10)

在这种情况下,该层的输出将是形状为10 x 128的 2D 矩阵,其中每个文档的 10 个单词将具有与之关联的 128 元素向量。

这样的单词序列可以作为 LSTM 的出色输入。 LSTM 层可以紧随嵌入层。 就像上一章一样,我们可以将嵌入层中的这 10 行视为 LSTM 的顺序输入。 在本章的第一个示例中,我将使用 LSTM,因此,如果您在未阅读第 9 章“从头开始训练 RNN”的情况下,则请花一点时间重新了解 LSTM 的操作,可以在此处找到。

如果我们想将嵌入层直接连接到密集层,则需要对其进行展平,但您可能不想这样做。 如果您有序列文本,通常使用 LSTM 是更好的选择。 我们还有另外一个有趣的选择。

用于自然语言处理的一维 CNN

回顾第 7 章,“从头开始训练 CNN”时,我们使用了卷积在图像区域上滑动窗口以学习复杂的视觉特征。 这使我们能够学习重要的局部视觉特征,而不管这些特征在图片中的位置,然后随着我们的网络越来越深入,逐步地学习越来越复杂的特征。 我们通常在 2D 或 3D 图像上使用3 x 35 x 5过滤器。 如果您对卷积层及其工作原理的理解感到生疏,则可能需要阅读第 7 章“从头开始训练 CNN”。

事实证明,我们可以对一系列单词使用相同的策略。 在这里,我们的 2D 矩阵是嵌入层的输出。 每行代表一个单词,并且该行中的所有元素都是其单词向量。 继续前面的示例,我们将有一个 10 x 128 的向量,其中连续有 10 个单词,每个单词都由 128 个元素的向量空间表示。 我们当然可以在这些单词上滑动过滤器。

卷积过滤器的大小针对 NLP 问题而改变。 当我们构建网络来解决 NLP 问题时,我们的过滤器将与单词向量一样宽。 过滤器的高度可以变化,通常在 2 到 5 之间。高度为 5 表示我们一次要在五个字上滑动过滤器。

事实证明,对于许多 NLP 问题,CNN 可以很好地运行,并且比 LSTM 快得多。 很难就何时使用 RNN/LSTM 和何时使用 CNN 给出确切的规则。 通常,如果您的问题需要状态,或者从很远的序列中学习到一些东西,那么使用 LSTM 可能会更好。 如果您的问题需要检测描述文本的特定单词集或文档的语义感觉,那么 CNN 可能会更快甚至更好地解决您的问题。

文档分类的案例研究

由于我已经提出了两种可行的文档分类方法,因此本章将包含两个单独的文档分类示例。 两者都将使用嵌入层。 一个将使用 LSTM,另一个将使用 CNN。

我们还将比较学习嵌入层与从其他人的权重开始采用迁移学习方法之间的表现。

这两个示例的代码都可以在本书的 Git 存储库中的Chapter10文件夹中找到。 某些数据和 GloVe 向量将需要分别下载。 有关说明,请参见代码中的注释。

Keras 嵌入层和 LSTM 的情感分析

本章的第一个案例研究将演示情绪分析。 在此示例中,我们将应用本章中学到的大多数内容。

我们将使用从互联网电影数据库IMDB)内置于 Keras 中的数据集。 该数据集包含 25,000 条电影评论,每条评论均按情感标记。 正面评论标记为 1,负面评论标记为 0。此数据集中的每个单词均已替换为标识该单词的整数。 每个评论都被编码为单词索引序列。

我们的目标是仅使用评论中的文字将电影评论分为正面评论或负面评论。

准备数据

因为我们使用的是内置数据集,所以 Keras 会处理大量的日常工作,这些工作涉及标记,词干,停用词以及将词标记转换为数字标记的工作。 keras.datasets.imbd将为我们提供一个列表列表,每个列表包含一个长度可变的整数序列,这些整数表示审阅中的单词。 我们将使用以下代码定义数据:

def load_data(vocab_size):
    data = dict()
    data["vocab_size"] = vocab_size
    (data["X_train"], data["y_train"]), (data["X_test"], data["y_test"]) = 
    imdb.load_data(num_words=vocab_size)
    return data

我们可以通过调用load_data并为词汇表选择最大大小来加载数据。 在此示例中,我将使用 20,000 个单词作为词汇量。

如果需要手动执行此操作,以使示例代码可以解决您自己的问题,则可以使用keras.preprocessing.text.Tokenizer类,我们将在下一个示例中介绍该类。 我们将使用以下代码加载数据:

data = load_data(20000)

下一步,我希望这些序列中的每个序列都具有相同的长度,并且我需要此列表列表为 2D 矩阵,其中每个评论是一行,每列是一个单词。 为了使每个列表大小相同,我将用 0 填充较短的序列。 我们稍后将使用的 LSTM 将学习忽略那些 0,这对于我们当然非常方便。

这种填充操作相当普遍,因此已内置在 Keras 中。 我们可以通过以下代码使用keras.preprocessing.sequence.pad_sequences完成此操作:

def pad_sequences(data):
    data["X_train"] = sequence.pad_sequences(data["X_train"])
    data["sequence_length"] = data["X_train"].shape[1]
    data["X_test"] = sequence.pad_sequences(data["X_test"], maxlen=data["sequence_length"])
    return data

调用此函数会将列表列表转换为等长序列,并方便地将列表列表转换为 2D 矩阵,如下所示:

data = pad_sequences(data)

输入和嵌入层架构

在上一章中,我们使用时间序列中的一组滞后训练了 LSTM。 在这里,我们的滞后实际上是序列中的单词。 我们将使用这些词来预测审阅者的情绪。 为了从单词序列到考虑这些单词的语义值的输入向量,我们可以使用嵌入层。

使用 Keras 函数式 API,嵌入层始终是网络中输入层之后的第二层。 让我们看一下这两层如何结合在一起:

input = Input(shape=(sequence_length,), name="Input")
embedding = Embedding(input_dim=vocab_size, output_dim=embedding_dim,
                      input_length=sequence_length, name="embedding")(input)

我们的输入层需要知道序列长度,该长度与输入矩阵中的列数相对应。

嵌入层将使用输入层。 但是,它需要知道整体语料库词汇量,我们将这些词嵌入到的向量空间的大小以及序列长度。

我们定义的词汇量为 20,000 个单词,数据的序列长度为 2,494,并且指定的嵌入维数为 100。

将所有这些放在一起,嵌入层将从每个文件的 20,000 个输入热向量到每个文档的2,494 x 100 2D 矩阵,从而为序列中的每个单词嵌入向量空间。 随着模型的学习,嵌入层将不断学习。 很酷吧?

LSTM 层

我将在这里只使用一个 LSTM 层,只有 10 个神经元,如以下代码所示:

lstm1 = LSTM(10, activation='tanh', return_sequences=False,
             dropout=0.2, recurrent_dropout=0.2, name='lstm1')(embedding)

为什么要使用这么小的 LSTM 层? 就像您将要看到的那样,该模型将因过拟合而陷入困境。 甚至只有 10 个 LSTM 单元也能很好地学习训练数据。 解决此问题的方法可能是添加数据,但实际上不能添加数据,因此保持网络结构简单是一个好主意。

这导致我们使用丢弃法。 我将在这一层同时使用丢弃法和经常性丢弃。 我们还没有谈论经常性丢弃的问题,所以让我们现在解决它。 以这种方式应用于 LSTM 层的常规过滤器将随机掩盖 LSTM 的输入。 循环丢弃会随机打开和关闭 LSTM 单元/神经元中展开的单元之间的内存。 与往常一样,丢弃是一个超参数,您需要搜索最佳值。

因为我们的输入是基于文档的,并且因为没有任何上下文,所以我们需要记住在文档之间,这是使用无状态 LSTM 的绝佳时机。

输出层

在此示例中,我们预测了二元目标。 和以前一样,我们可以使用具有单个 Sigmoid 神经元的密集层来完成此二分类任务:

output = Dense(1, activation='sigmoid', name='sigmoid')(lstm1)

放在一起

现在,我们了解了组成部分,现在来看整个网络。 该网络显示在以下代码中,以供您参考:

def build_network(vocab_size, embedding_dim, sequence_length):
    input = Input(shape=(sequence_length,), name="Input")
    embedding = Embedding(input_dim=vocab_size,  
       output_dim=embedding_dim, input_length=sequence_length, 
         name="embedding")(input)
    lstm1 = LSTM(10, activation='tanh', return_sequences=False,
       dropout=0.2, recurrent_dropout=0.2, name='lstm1')(embedding)
    output = Dense(1, activation='sigmoid', name='sigmoid')(lstm1)
    model = Model(inputs=input, outputs=output)
    model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
    return model

与其他二分类任务一样,我们可以使用二元交叉熵。 请注意,因为我们正在将 LSTM 层连接到密集层,所以我们需要将return_sequences设置为False,正如我们在第 9 章,“从头训练”中讨论的那样。

为了使这部分代码可重用,我们使词汇量,嵌入维数和序列长度可配置。 如果要搜索超参数,则还可能希望参数化dropoutrecurrent_dropout和 LSTM 神经元的数量。

训练网络

现在,我的情绪分析网络已经建立,现在该进行训练了:

data = load_data(20000)
data = pad_sequences(data)
model = build_network(vocab_size=data["vocab_size"],
                      embedding_dim=100,
                      sequence_length=data["sequence_length"])

callbacks = create_callbacks("sentiment")

model.fit(x=data["X_train"], y=data["y_train"],
          batch_size=32,
          epochs=10,
          validation_data=(data["X_test"], data["y_test"]),
          callbacks=callbacks)

像这样将我所有的训练参数和数据保存在一个字典中,实际上只是一个样式问题,而与函数无关。 您可能希望单独处理所有事情。 我喜欢对所有内容使用字典,因为它使我无法来回传递大量参数。

由于我们使用的是无状态 LSTM,因此我们将在每个批次中重置单元存储器。 我的信念是,我们可以在不损失任何罚款的情况下重置文档之间的单元状态,因此批量大小实际上与表现有关。 我在这里使用了 32 个观察批,但是只要 GPU 内存允许,128 个观察批会产生相似的结果,并且表现会有所提高。

表现

从下面的屏幕截图中,让我们看一下我们的网络运行情况。 检查这些图时,请密切注意y轴上的刻度。 虽然挥杆动作看起来很戏剧性,但幅度并不大:

这里首先要注意的是,在第 1 阶段,网络正在做的相当不错。 此后,它迅速开始过拟合。 总体而言,我认为我们的结果相当不错。 在第 1 阶段,我们会在验证集上正确预测约 86% 的时间的情绪。

尽管此案例研究涵盖了本章到目前为止已讨论的许多主题,但让我们再来看一个可以在嵌入层使用预训练的单词向量与我们学习的单词向量进行比较的地方。

有和没有 GloVe 的文档分类

在此示例中,我们将使用一个比较著名的文本分类问题,称为 news20。 在此问题中,我们获得了 19,997 个文档,每个文档都属于一个新闻组。 我们的目标是使用帖子的文本来预测该文本所属的新闻组。对于我们中间的千禧一代,新闻组是 Reddit 的先驱(但可能更接近伟大的 -Reddit 的曾祖父)。 这些新闻组涵盖的主题差异很大,包括政治,宗教和操作系统等主题,您应避免在礼貌的公司中讨论所有这些主题。 这些职位相当长,语料库中有 174,074 个独特的单词。

这次,我将构建模型的两个版本。 在第一个版本中,我们将使用嵌入层,并且将学习嵌入空间,就像在前面的示例中一样。 在第二个版本中,我将使用 GloVe 向量作为嵌入层的权重。 然后,我将花一些时间比较和对比这两种方法。

最后,在此示例中,我们将使用一维 CNN 代替 LSTM。

准备数据

当使用这样的文本文档时,可能需要很多平凡的代码才能使您到达想要的位置。 我将这个示例作为解决问题的一种方式。 一旦了解了这里发生的事情,就可以在将来的问题中重用其中的大部分内容并缩短开发时间,因此值得考虑。

以下函数将进入 20 个新闻组文本所在的顶级目录。 在该目录中,将有 20 个单独的目录,每个目录都有文件。 每个文件都是新闻组帖子:

def load_data(text_data_dir, vocab_size, sequence_length, validation_split=0.2):
    data = dict()
    data["vocab_size"] = vocab_size
    data["sequence_length"] = sequence_length

    # second, prepare text samples and their labels
    print('Processing text dataset')

    texts = []  # list of text samples
    labels_index = {}  # dictionary mapping label name to numeric id
    labels = []  # list of label ids
    for name in sorted(os.listdir(text_data_dir)):
        path = os.path.join(text_data_dir, name)
        if os.path.isdir(path):
            label_id = len(labels_index)
            labels_index[name] = label_id
            for fname in sorted(os.listdir(path)):
                if fname.isdigit():
                    fpath = os.path.join(path, fname)
                    if sys.version_info < (3,):
                        f = open(fpath)
                    else:
                        f = open(fpath, encoding='latin-1')
                    t = f.read()
                    i = t.find('\n\n')  # skip header
                    if 0 < i:
                        t = t[i:]
                    texts.append(t)
                    f.close()
                    labels.append(label_id)
    print('Found %s texts.' % len(texts))
    data["texts"] = texts
    data["labels"] = labels
    return data

对于每个目录,我们将使用目录名称并将其添加到将其映射为数字的字典中。 这个数字将成为我们想要预测的值,我们的标签。 我们将把标签列表保留在data["labels"]中。

同样,对于文本,我们将打开每个文件,仅解析相关文本,而忽略有关谁在信息中张贴的垃圾邮件。 然后,我们将文本存储在data["texts"]中。 顺便说一句,删除标头中标识新闻组的部分非常重要。 那是作弊!

最后,我们剩下一个文本列表和一个相应的标签列表。 但是,此时,这些文本都是字符串。 我们需要做的下一件事是将这些字符串拆分为单词标记,将这些标记转换为数字标记,并填充序列,以使它们具有相同的长度。 这几乎是我们在前面的示例中所做的; 但是,在我们之前的示例中,数据已预先加标记。 我将使用此函数来完成任务,如以下代码所示:

def tokenize_text(data):
    tokenizer = Tokenizer(num_words=data["vocab_size"])
    tokenizer.fit_on_texts(data["texts"])
    data["tokenizer"] = tokenizer
    sequences = tokenizer.texts_to_sequences(data["texts"])

    word_index = tokenizer.word_index
    print('Found %s unique tokens.' % len(word_index))

    data["X"] = pad_sequences(sequences, maxlen=data["sequence_length"])
    data["y"] = to_categorical(np.asarray(data["labels"]))
    print('Shape of data tensor:', data["X"].shape)
    print('Shape of label tensor:', data["y"].shape)

    # texts and labels aren't needed anymore
    data.pop("texts", None)
    data.pop("labels", None)
    return data

在这里,我们获取该文本列表,并使用keras.preprocessing.text.Tokenizer将其标记化。 之后,我们将它们填充为相等的长度。 最后,我们将数字标签转换为one_hot格式,就像 Keras 在其他多分类问题中一样。

我们几乎完成了数据处理。 但是,最后,我们需要获取文本和标签,然后将数据随机分成训练,验证和测试集,如以下代码所示。 我没有太多数据需要处理,因此我将在此处选择testval。 如果样本太小,可能无法很好地理解实际模型的表现,因此在执行此操作时要格外小心:

def train_val_test_split(data):

    data["X_train"], X_test_val, data["y_train"],  y_test_val = train_test_split(data["X"],
                                                                                 data["y"],
                                                                                 test_size=0.2,
                                                                                 random_state=42)
    data["X_val"], data["X_test"], data["y_val"], data["y_test"] = train_test_split(X_test_val,
                                                                                    y_test_val,
                                                                                  test_size=0.25,
                                                                                 random_state=42)
    return data

加载预训练的单词向量

正如我刚才提到的,我将使用 Keras 嵌入层。 对于模型的第二个版本,我们将使用本章前面介绍的 GloVe 字向量来初始化嵌入层的权重。 为此,我们将需要从磁盘加载这些权重,并将它们放入合适的 2D 矩阵中,该层可用作权重。 我们将在这里介绍该操作。

下载 GloVe 向量时,您会发现在将下载文件解压缩到的目录中有几个文本文件。每个文件都对应一组单独的尺寸。 但是,在所有情况下,这些载体都是使用包含 60 亿个唯一单词的相同通用语料库开发的(因此标题为GloVe.6B)。 我将演示如何使用glove.6B.100d.txt文件。 在glove.6B.100d.txt中,每行都是单个单词向量。 在该行上,您将找到该单词和与其相关联的 100 维向量。 单词和向量的元素存储为文本,并用空格分隔。

为了使这些数据进入可用状态,我们将从磁盘加载开始。 然后,我们将线分为第一部分,单词和向量的元素。 完成此操作后,我们将向量转换为数组。 最后,我们将单词作为该值的键将数组作为值存储在字典中。 以下代码说明了此过程:

def load_word_vectors(glove_dir):
    print('Indexing word vectors.')

    embeddings_index = {}
    f = open(os.path.join(glove_dir, 'glove.6B.100d.txt'),    
             encoding='utf8')
    for line in f:
        values = line.split()
        word = values[0]
        coefs = np.asarray(values[1:], dtype='float32')
        embeddings_index[word] = coefs
    f.close()

    print('Found %s word vectors.' % len(embeddings_index))
    return embeddings_index

运行此命令后,我们将有一个名为embeddings_index的字典,其中包含 GloVe 单词作为键,其向量作为值。 Keras 嵌入层需要 2D 矩阵作为输入,但是不需要字典,因此我们需要使用以下代码将字典操纵为矩阵:

def embedding_index_to_matrix(embeddings_index, vocab_size, embedding_dim, word_index):
    print('Preparing embedding matrix.')

    # prepare embedding matrix
    num_words = min(vocab_size, len(word_index))
    embedding_matrix = np.zeros((num_words, embedding_dim))
    for word, i in word_index.items():
        if i >= vocab_size:
            continue
        embedding_vector = embeddings_index.get(word)
        if embedding_vector is not None:
            # words not found in embedding index will be all-zeros.
            embedding_matrix[i] = embedding_vector
    return embedding_matrix

我知道所有这些烦恼似乎都是可怕的,但确实如此,但是 GloVe 的作者在如何分配这些单词向量方面非常有心。 他们希望使使用任何一种编程语言的任何人都可以使用这些向量,为此,文本格式将受到人们的赞赏。 此外,如果您是一名实践中的数据科学家,您将习惯于此!

现在,我们将向量表示为 2D 矩阵,现在可以在 Keras 嵌入层中使用它们了。 我们的准备工作已经完成,所以现在让我们建立网络。

输入和嵌入层架构

我们在这里格式化 API 的方式与前面的示例稍有不同。 这种略有不同的结构将使在嵌入层中使用预训练向量更加容易。 我们将在以下各节中讨论这些结构性更改。

没有 GloVe 向量

让我们演示没有先训练词向量的embedding层的代码。 此代码应与上一个示例中的代码几乎相同:

sequence_input = Input(shape=(sequence_length,), dtype='int32')
embedding_layer = Embedding(input_dim=vocab_size,
                            output_dim=embedding_dim,
                            input_length=sequence_length,
                            name="embedding")(sequence_input)

带有 GloVe 向量

现在,将其与包含以 2D 矩阵编码的预先训练的 GloVe 向量的代码进行比较:

sequence_input = Input(shape=(sequence_length,), dtype='int32')
embedding_layer = Embedding(input_dim=vocab_size,
                            output_dim=embedding_dim,
                            weights=[embedding_matrix],
                            input_length=sequence_length,
                            trainable=False,
                            name="embedding")(sequence_input)

在大多数情况下,此代码看起来是等效的。 有两个主要区别:

  • 我们初始化层权重以包含在我们与weights=[embedding_matrix]组装的 GloVe 矩阵中。
  • 我们还将层设置为trainable=False。 这将阻止我们更新权重。 您可能希望以与微调权重相似的方式微调权重,该方式类似于我们在第 8 章“使用预训练的 CNN”进行的迁移学习中构建的 CNN,但是在大多数情况下, 不必要或没有帮助。

卷积层

对于一维卷积,层可以使用keras.layers.Conv1D。 我们将需要使用MaxPooling1D层以及Conv1D层,如以下代码所示:

x = Conv1D(128, 5, activation='relu')(embedding_layer)
x = MaxPooling1D(5)(x)
x = Conv1D(128, 5, activation='relu')(x)
x = MaxPooling1D(5)(x)
x = Conv1D(128, 5, activation='relu')(x)
x = GlobalMaxPooling1D()(x)

对于Conv1D层,第一个整数参数是单元数,第二个是过滤器大小。 我们的过滤器只有一维,因此命名为 1D 卷积。 上例中的窗口大小为 5。

我正在使用的MaxPooling1D层也将使用 5 的窗口大小。相同的规则适用于一维实现中的池化层。

在最后一个卷积层之后,我们应用GlobalMaxPooling1D层。 该层是最大池化的特殊实现,它将获取最后一个Conv1D层(一个[batch x 35 x 128]张量)的输出,并跨时间步长将其合并到[batch x 128]。 这通常是在 NLP 网络中完成的,其目的类似于在基于图像的卷积网络中使用Flatten()层。 该层充当卷积层和密集层之间的桥梁。

输出层

此示例中的输出层看起来像其他任何多分类。 我在输出层之前也包括了一个密集层,如以下代码所示:

x = Dense(128, activation='relu')(x)
preds = Dense(20, activation='softmax')(x)

放在一起

和以前一样,我们将在此处显示整个神经网络结构。 请注意,此结构适用于包含 GloVe 向量的模型版本:

def build_model(vocab_size, embedding_dim, sequence_length, embedding_matrix):

    sequence_input = Input(shape=(sequence_length,), dtype='int32')
    embedding_layer = Embedding(input_dim=vocab_size,
                                output_dim=embedding_dim,
                                weights=[embedding_matrix],
                                input_length=sequence_length,
                                trainable=False,
                                name="embedding")(sequence_input)
    x = Conv1D(128, 5, activation='relu')(embedding_layer)
    x = MaxPooling1D(5)(x)
    x = Conv1D(128, 5, activation='relu')(x)
    x = MaxPooling1D(5)(x)
    x = Conv1D(128, 5, activation='relu')(x)
    x = GlobalMaxPooling1D()(x)
    x = Dense(128, activation='relu')(x)
    preds = Dense(20, activation='softmax')(x)
    model = Model(sequence_input, preds)
    model.compile(loss='categorical_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])
    return model

我在这里再次使用adamcategorical_crossentropyaccuracy。 尽管本章介绍了许多新主题,但希望能看到保持不变的感觉会有些安慰。

训练

将所有代码放在一起,只需几行就可以完成训练,如以下代码所示:

glove_dir = os.path.join(BASE_DIR, 'glove.6B')
text_data_dir = os.path.join(BASE_DIR, '20_newsgroup')
embeddings_index = load_word_vectors(glove_dir)

data = load_data(text_data_dir, vocab_size=20000, sequence_length=1000)
data = tokenize_text(data)
data = train_val_test_split(data)
data["embedding_dim"] = 100
data["embedding_matrix"] = embedding_index_to_matrix(embeddings_index=embeddings_index,
                                                     vocab_size=data["vocab_size"],
                                                     embedding_dim=data["embedding_dim"],
                                                     word_index=data["tokenizer"].word_index)

callbacks = create_callbacks("newsgroups-pretrained")
model = build_model(vocab_size=data["vocab_size"],
                    embedding_dim=data['embedding_dim'],
                    sequence_length=data['sequence_length'],
                    embedding_matrix=data['embedding_matrix'])

model.fit(data["X_train"], data["y_train"],
          batch_size=128,
          epochs=10,
          validation_data=(data["X_val"], data["y_val"]),
          callbacks=callbacks)

请注意,我们只训练 10 个周期,因此将这个问题的损失降到最低不会花很长时间。

表现

而我们在这里处于关键时刻。 让我们看看我的表现如何。 更重要的是,让我们将 GloVe 向量与该问题的学习向量进行比较。

以下屏幕截图中的橙色线对应于学习的嵌入层,蓝色线对应于 GloVe 向量:

GloVe 预先训练的网络不仅学习得更快,而且在每个周期都表现得更好。 总体而言,这些网络似乎在学习文档分类任务方面做得很好。 大约在第五个周期之后,它们都开始过拟合。 但是,GloVe 模型比没有使用 GloVe 训练的网络更能防止过拟合。

通常,我建议尽可能在任何地方使用迁移学习。 图片和文字都是如此。

如果通过这些示例与我一起工作,我建议您对 LSTM 尝试同样的问题。 我认为使用 LSTM 时,您会发现该问题更加难以解决,并且难以解决过拟合问题。

总结

在本章中,我们以一般形式以及在情感分析的特定情况下研究了文档分类。 在此过程中,我们涵盖了很多 NLP 主题,包括 Word 袋模型,向量空间模型以及每个模型的相对优点。 我们还研究了使用 LSTM 和 1D 卷积进行文本分析。 最后,我们训练了两个单独的文档分类器,并通过实际示例应用了我们讨论的所有内容。

在下一章中,我们将讨论一个非常酷的自然语言模型,该模型将允许我们实际生成单词,称为序列到序列模型

十一、训练 Seq2Seq 模型

在上一章中,我们讨论了文档分类以及文档分类的一种特殊情况,称为情感分类。 这样做时,我们不得不谈论很多关于向量化的知识。

在本章中,我们将继续谈论解决 NLP 问题,但是除了分类之外,我们将生成新的单词序列。

我们将在本章介绍以下主题:

  • 序列到序列模型
  • 机器翻译

序列到序列模型

到目前为止,我们所研究的网络已经做了一些真正令人惊奇的事情。 但是它们都有一个很大的局限性:它们只能应用于输出具有固定且众所周知的大小的问题。

序列到序列模型能够将输入序列映射到具有可变长度的输出序列。

您可能还会看到术语序列到序列,甚至 Seq2Seq。 这些都是序列到序列模型的术语。

当使用序列到序列模型时,我们将引入一个序列并交换出一个序列。 这些序列的长度不必相同。 序列到序列模型使我们能够学习输入序列和输出序列之间的映射。

序列到序列模型可能在许多应用中有用,我们接下来将讨论这些应用。

序列到序列模型的应用

序列到序列模型具有许多实际应用。

也许最实际的应用是机器翻译。 我们可以使用机器翻译将一种语言的短语作为输入,并输出另一种语言的短语。 机器翻译是我们越来越依赖的一项重要服务。 得益于计算机视觉和机器翻译的进步,我们可以听不懂的语言,或者用不懂的语言查看标志,并且几乎可以立即在智能手机上获得不错的翻译。 序列到序列的网络确实使我们非常接近道格拉斯·亚当(Douglas Adam)想象的《银河系漫游指南》中的通天鱼。

问答也可以全部或部分通过序列到序列模型来完成,在这里我们可以将问题想象为输入序列,将答案想象为输出序列。 回答问题最普遍的应用是聊天。 如果您通过呼叫中心为企业提供支持,则每天会有成千上万甚至数百万个问题/答案对通过电话传递。 对于序列到序列聊天机器人来说,这是完美的训练。

我们可以利用这种问答方式的多种细微形式。 每天,我收到大约 34 亿封电子邮件。 其中,我可能只需要阅读 20-30(这是一个分类任务)。 但是,我对这些电子邮件的回复很少新颖。 我几乎可以肯定地创建一个序列到序列的网络,该网络可以为我写电子邮件,或者至少起草回复。 我认为我们已经开始看到这种行为已经内置在我们最喜欢的电子邮件程序中,并且肯定会出现更加全自动的响应。

序列到序列网络的另一个重要用途是自动文本摘要。 想象一下一组研究论文或大量期刊文章。 所有这些论文可能都有摘要。 这只是另一个翻译问题。 给定一些论文,我们可以使用序列到序列网络生成摘要。 网络可以学习以这种方式总结文档。

在本章的后面,我们将实现一个序列到序列的网络来进行机器翻译。 不过,在进行此操作之前,让我们了解一下这种网络架构是如何工作的。

序列到序列模型架构

理解序列到序列模型架构的关键是要理解该架构是为了允许输入序列的长度与输出序列的长度而变化的。 然后可以使用整个输入序列来预测长度可变的输出序列。

为此,网络被分为两个独立的部分,每个部分都包含一个或多个 LSTM 层,这些层负责一半的任务。 如果您想对其操作进行复习,我们在第 9 章“从头开始训练 RNN”中讨论了 LSTM。 我们将在以下各节中了解这两个部分。

编码器和解码器

序列到序列模型由两个单独的组件组成,一个编码器和一个解码器:

  • 编码器:模型的编码器部分采用输入序列,并返回输出和网络的内部状态。 我们并不在乎输出。 我们只想保留编码器的状态,即输入序列的内存。
  • 解码器:然后,模型的解码器部分将来自编码器的状态(称为上下文条件)作为输入。 然后,根据前一个时间步长的输出,可以预测每个时间步长的目标序列。

然后,编码器和解码器如下图所示一起工作,获取输入序列并生成输出序列。 如您所见,我们使用特殊字符表示序列的开始和结束。

我们知道,一旦序列字符的结尾(我称之为<EOS>)结束,就停止生成输出:

尽管此示例涵盖了机器翻译,但是序列到序列学习的其他应用却以相同的方式工作。

字符与文本

可以在字符级别或单词级别建立序列到序列模型。 单词级序列到序列模型将单词作为输入的原子单位,而字符级模型将字符作为输入的原子单位。

那么,您应该使用哪个呢? 通常,最好的结果是从单词级模型中获得的。 就是说,预测序列中最可能出现的下一个单词需要softmax层与问题的词汇量一样宽。 这导致了非常广泛的,高度尺寸的问题。

字符级模型要小得多。 字母表中有 26 个字母,但大约有 171,000 个英文单词是常用的。

对于本章中提出的问题,我将使用字符级模型,因为我重视您的 AWS 预算。 转换为单词非常简单,其中大部分复杂性都在数据准备中,这是留给读者的练习。

监督强迫

如上图所示,当预测序列y[t(n)]某个位置的输出时,我们使用y[t(n-1)]作为 LSTM 的输入。 然后,我们使用此时间步骤的输出来预测y[t(n+1)]

训练中这样做的问题是,如果y[t(n-1)]错误,则y[t(n)]将更加错误。 错误不断增加的链条会使事情变得非常缓慢。

解决该问题的一个显而易见的解决方案是将每个时间步长的每个序列预测替换为该时间步长的实际正确序列。 因此,我们将使用训练集中的实际值,而不是对y[t(n-1)]使用 LSTM 预测。

通过使用这个概念,我们可以促进模型的训练过程,这恰好被称为监督强迫

教师强迫有时会使我们的模型难以可靠地生成训练中看不到的序列,但总的来说,该技术可能会有所帮助。

注意

注意是可以在序列到序列模型中实现的另一种有用的训练技巧。 注意使解码器在输入序列的每个步骤中都能看到隐藏状态。 这使网络可以专注于(或关注)特定的输入,这可以加快训练速度并可以提高模型的准确率。 注意通常是一件好事。 但是,在撰写本文时,Keras 尚未内置注意力。尽管如此,Keras 目前确实有一个拉取请求正在等待自定义注意层。 我怀疑很快就会在 Keras 中建立对关注的支持。

翻译指标

知道翻译是否良好很难。 机器翻译质量的通用度量标准称为双语评估研究BLEU),它最初是由 Papineni 等人在《BLEU:一种自动评估机器翻译的方法》中创建的。 BLEU 是基于 ngram 的分类精度的改进应用。 如果您想使用 BLEU 来衡量翻译质量,TensorFlow 团队已经发布了一个脚本,该脚本可以根据给定的地面真实翻译和机器预测翻译的语料来计算 BLEU 分数。 您可以在这里找到该脚本。

机器翻译

Je ne parle pasfrançais,那就是你怎么说我不会说英语的法语。 大约两年前,我发现自己在巴黎,几乎不会说法语。 在我去之前,我已经看过一本书,听过一些 DVD,但是即使经过几个月的练习,我对法语的掌握还是很可悲的。 然后,在旅途的第一个早晨,我醒来,走进附近的boulangerie(法国或法式面包店)吃早餐和早晨咖啡。 我说Bonjour, parlez-vous anglais?,他们一点也不讲英语,或者也许他们正在享受我的奋斗。 无论哪种方式,当我的早餐取决于我对法语的掌握时,我都会比过去更有动力去争取Je voudrais un pain au chocolat(翻译:我想要其中一种美味的巧克力面包)。 在最终成本函数(我的胃)的驱动下,我很快学会了在英语序列和法语序列之间进行映射。

在本案例研究中,我们将教计算机讲法语。 在几个小时的训练中,该模型将比我说法语更好。 考虑一下,这真是太神奇了。 我将训练一台计算机来执行我自己无法完成的任务。 当然,也许您确实会说法语,但这并不会给您留下深刻的印象,在这种情况下,我将美国著名演员亚当·桑德勒(Adam Sandler)称为比利·麦迪逊(Billy Madison):好吧,对我来说很难,所以退缩!

该示例的大部分来自于弗朗索瓦·乔勒(Francois Chollet)的博客文章,标题为《序列到序列学习的十分钟介绍》。 尽管我怀疑自己是否可以改进这项工作,但我希望使用本示例的目的是花一点点多一点的时间看一下序列到序列的网络,以使您掌握实现自己的所有知识。

与往常一样,本章的代码可以在本书的 Git 存储库中的Chapter11下找到。 您可以在这个页面中找到此示例所需的数据,该文件将存档许多双语句子对的数据集,我们将在后面详细讨论。 我要使用的文件是 fra-eng.zip 。 这是英语/法语句子对的集合。 如果需要,您可以轻松选择其他语言,而无需进行太多修改。

在本案例研究中,我们将构建一个网络,该网络可以在给定一些英语句子的情况下学习法语句子。 这将是一个具有老师强迫作用的字符级序列到序列模型。

我希望最终得到的是看起来很像翻译服务的东西,您可以在网上找到它或下载到手机上。

了解数据

我们正在使用的数据是一个文本文件。 每行都有一个英文短语及其法语翻译,并用一个选项卡分隔,如以下代码所示:

Ignore Tom. Ignorez Tom.

(我不确定Tom对数据集的作者做了什么...)

通常,每行英语翻译都有重复的法语翻译行。 当有多种常用方法翻译英语短语时,会发生这种情况。 看下面的代码例如:

Go now.    Va, maintenant.
Go now.    Allez-y maintenant.
Go now.    Vas-y maintenant.

由于我们正在构建一个字符级序列到序列模型,因此需要将数据加载到内存中,然后对每个输入和输出在字符级进行热编码。 那是困难的部分。 让我们接下来做。

加载数据

加载此数据涉及很多工作。 阅读本文时,您可能想参考代码块。

以下代码中的第一个for循环将遍历整个输入文件或调用load_data()时指定的一些样本。 我这样做是因为您可能没有 RAM 来加载整个数据集。 多达 10,000 个示例,您可能会获得良好的结果; 但是,多多益善。

当我们逐行浏览输入文件时,我们一次要执行几项操作:

  • 我们将每个法语翻译包装在'\t'中,以开始该短语,并在'\n'中,以结束它。 这对应于我在序列到序列图中使用的<SOS><EOS>标签。 当我们要生成翻译序列时,这将允许我们使用'\t'作为输入来为解码器设定种子。
  • 我们将每一行分为英语输入和其各自的法语翻译。 这些存储在列表input_textstarget_texts中。
  • 最后,我们将输入文本和目标文本的每个字符添加到一个集合中。 这些集称为input_characterstarget_characters。 当需要对短语进行热编码时,我们将使用这些集合。

循环完成后,我们会将字符集转换为排序列表。 我们还将创建名为num_encoder_tokensnum_decoder_tokens的变量,以保存每个列表的大小。 稍后我们也将需要这些以进行单热编码。

为了将输入和目标输入矩阵,我们需要像上一章一样,将短语填充到最长短语的长度。 为此,我们需要知道最长的短语。 我们将其存储在max_encoder_seq_lengthmax_decoder_seq_length中,如以下代码所示:

def load_data(num_samples=50000, start_char='\t', end_char='\n', data_path='data/fra-eng/fra.txt'):
    input_texts = []
    target_texts = []
    input_characters = set()
    target_characters = set()
    lines = open(data_path, 'r', encoding='utf-8').read().split('\n')
    for line in lines[: min(num_samples, len(lines) - 1)]:
        input_text, target_text = line.split('\t')
        target_text = start_char + target_text + end_char
        input_texts.append(input_text)
        target_texts.append(target_text)
        for char in input_text:
            if char not in input_characters:
                input_characters.add(char)
        for char in target_text:
            if char not in target_characters:
                target_characters.add(char)

    input_characters = sorted(list(input_characters))
    target_characters = sorted(list(target_characters))
    num_encoder_tokens = len(input_characters)
    num_decoder_tokens = len(target_characters)
    max_encoder_seq_length = max([len(txt) for txt in input_texts])
    max_decoder_seq_length = max([len(txt) for txt in target_texts])

    print('Number of samples:', len(input_texts))
    print('Number of unique input tokens:', num_encoder_tokens)
    print('Number of unique output tokens:', num_decoder_tokens)
    print('Max sequence length for inputs:', max_encoder_seq_length)
    print('Max sequence length for outputs:', max_decoder_seq_length)
    return {'input_texts': input_texts, 'target_texts': target_texts,
           'input_chars': input_characters, 'target_chars': 
           target_characters, 'num_encoder_tokens': num_encoder_tokens, 
           'num_decoder_tokens': num_decoder_tokens,
           'max_encoder_seq_length': max_encoder_seq_length, 
           'max_decoder_seq_length': max_decoder_seq_length}

加载数据后,我们将在字典中返回所有这些信息,这些信息可以传递给一个函数,该函数将对每个短语进行热编码。 让我们接下来做。

单热编码

在此函数中,我们将使用刚刚构建的字典,并对每个短语的文本进行热编码。

一旦完成,我们将剩下三个字典。 它们每个的尺寸为[文本数 * 最大序列长度 * 标记]。 如果您停顿一下,回想一下第 10 章“使用单词嵌入从零开始训练 LSTM”的更简单的时间,您会发现这确实与我们在其他 NLP 模型中使用的相同,我们在输入端完成它。 我们将使用以下代码定义单热编码:

def one_hot_vectorize(data):
    input_chars = data['input_chars']
    target_chars = data['target_chars']
    input_texts = data['input_texts']
    target_texts = data['target_texts']
    max_encoder_seq_length = data['max_encoder_seq_length']
    max_decoder_seq_length = data['max_decoder_seq_length']
    num_encoder_tokens = data['num_encoder_tokens']
    num_decoder_tokens = data['num_decoder_tokens']

    input_token_index = dict([(char, i) for i, char in 
      enumerate(input_chars)])
    target_token_index = dict([(char, i) for i, char in 
      enumerate(target_chars)])
    encoder_input_data = np.zeros((len(input_texts), 
      max_encoder_seq_length, num_encoder_tokens), dtype='float32')
    decoder_input_data = np.zeros((len(input_texts), 
      max_decoder_seq_length, num_decoder_tokens), dtype='float32')
    decoder_target_data = np.zeros((len(input_texts), 
      max_decoder_seq_length, num_decoder_tokens), dtype='float32')

    for i, (input_text, target_text) in enumerate(zip(input_texts, 
     target_texts)):
        for t, char in enumerate(input_text):
            encoder_input_data[i, t, input_token_index[char]] = 1.
        for t, char in enumerate(target_text):
    # decoder_target_data is ahead of decoder_input_data by one 
       timestep
            decoder_input_data[i, t, target_token_index[char]] = 1.
            if t > 0:
           # decoder_target_data will be ahead by one timestep
           # and will not include the start character.
           decoder_target_data[i, t - 1, target_token_index[char]] = 1.
    data['input_token_index'] = input_token_index
    data['target_token_index'] = target_token_index
    data['encoder_input_data'] = encoder_input_data
    data['decoder_input_data'] = decoder_input_data
    data['decoder_target_data'] = decoder_target_data
    return data

我们在此代码中创建了三个训练向量。 在继续之前,我想确保我们了解以下所有向量:

  • encoder_input_data是形状为number_of_pairsmax_english_sequence_lengthnumber_of_english_characters的 3D 矩阵。
  • decoder_input_data是形状(number_of_pairsmax_french_sequence_lengthnumber_of_french_characters)的 3d 矩阵。
  • decoder_output_datadecoder_input_data相同,仅向前移了一个时间步。 这意味着decoder_input_data[:, t+1, :]等于decoder_output_data[:, t, :]

前面的每个向量都是字符层上整个短语的一个热编码表示。 这意味着,如果我们输入的短语是 Go! 向量的第一步是为文本中每个可能的英文字符包含一个元素。 除g设置为 1 以外,其他每个元素都将设置为0

我们的目标是使用encoder_input_datadecoder_input数据作为输入特征,训练序列至序列模型来预测decoder_output_data

终于完成了数据准备,因此我们可以开始构建序列到序列的网络架构。

用于训练的网络架构

在此示例中,我们实际上将使用两种单独的架构,一种用于训练,另一种用于推理。 我们将从推理模型训练中使用训练过的层。 虽然实际上我们为每种架构使用了相同的部分,但是为了使事情更清楚,我将分别展示每个部分。 以下是我们将用来训练网络的模型:

encoder_input = Input(shape=(None, num_encoder_tokens), name='encoder_input')
encoder_outputs, state_h, state_c = LSTM(lstm_units, return_state=True,
                                         name="encoder_lstm")(encoder_input)
encoder_states = [state_h, state_c]
decoder_input = Input(shape=(None, num_decoder_tokens), name='decoder_input')
decoder_lstm = LSTM(lstm_units, return_sequences=True, 
  return_state=True, name="decoder_lstm")
decoder_outputs, _, _ = decoder_lstm(decoder_input, initial_state=encoder_states)
decoder_dense = Dense(num_decoder_tokens, activation='softmax',
  name='softmax_output')
decoder_output = decoder_dense(decoder_outputs)

model = Model([encoder_input, decoder_input], decoder_output)
model.compile(optimizer='rmsprop', loss='categorical_crossentropy')

如果我们将放大编码器,则会看到相当标准的 LSTM。 不同之处在于,我们从编码器(return_state=True)获取状态,如果将 LSTM 连接到密集层,通常不会这样做。 这些状态是我们将在encoder_states中捕获的状态。 我们将使用它们为解码器提供上下文或条件。

在解码器方面,我们设置的decoder_lstm与我们先前构建 Keras 层的方式略有不同,但实际上只是语法略有不同。

看下面的代码:

decoder_lstm = LSTM(lstm_units, return_sequences=True, 
   return_state=True, name="decoder_lstm")
decoder_outputs, _, _ = decoder_lstm(decoder_input, initial_state=encoder_states)

其功能与以下代码相同:

decoder_outputs, _, _ = LSTM(lstm_units, return_sequences=True, 
  return_state=True, name="decoder_lstm")(decoder_input, initial_state=encoder_states)

我这样做的原因在推理架构中将变得显而易见。

请注意,解码器将编码器的隐藏状态作为其初始状态。 然后将解码器输出传递到预测decoder_output_datasoftmax层。

最后,我们将定义训练模型,我将其创造性地称为model,该模型将encoder_input_datadecoder_input数据作为输入并预测decoder_output_data

用于推理的网络架构

为了在给定输入序列的情况下预测整个序列,我们需要稍微重新安排一下架构。 我怀疑在 Keras 的未来版本中,这将变得更简单,但是从今天起这是必需的步骤。

为什么需要有所不同? 因为我们没有推断的decoder_input_data教师向量。 我们现在独自一人。 因此,我们将必须进行设置,以便我们不需要该向量。

让我们看一下这种推理架构,然后逐步执行代码:

encoder_model = Model(encoder_input, encoder_states)

decoder_state_input_h = Input(shape=(lstm_units,))
decoder_state_input_c = Input(shape=(lstm_units,))
decoder_states_inputs = [decoder_state_input_h, decoder_state_input_c]
decoder_outputs, state_h, state_c = decoder_lstm(
    decoder_input, initial_state=decoder_states_inputs)
decoder_states = [state_h, state_c]
decoder_outputs = decoder_dense(decoder_outputs)
decoder_model = Model(
    [decoder_input] + decoder_states_inputs,
    [decoder_outputs] + decoder_states)

首先,我们从构建编码器模型开始。 该模型将采用一个输入序列,并返回我们在先前模型中训练过的 LSTM 的隐藏状态。

然后,解码器模型具有两个输入,即hc隐藏状态,这些状态限制了其从编码器模型派生的输出。 我们统称为decoder_states_inputs

我们可以从上面重用decoder_lstm; 但是,这次我们不会丢弃状态state_hstate_c。 我们将把它们与目标的softmax预测一起作为网络输出传递。

现在,当我们推断出一个新的输出序列时,我们可以在预测第一个字符之后获得这些状态,然后将它们通过softmax预测传递回 LSTM,以便 LSTM 可以预测另一个字符。 我们将重复该循环,直到解码器生成一个'\n'信号为止,该信号已到达<EOS>

我们将很快看一下推理代码。 现在,让我们看看如何训练和序列化此模型集合。

放在一起

按照本书的传统,我将在这里向您展示该模型的整个架构如何融合在一起:

def build_models(lstm_units, num_encoder_tokens, num_decoder_tokens):
    # train model
    encoder_input = Input(shape=(None, num_encoder_tokens), 
      name='encoder_input')
    encoder_outputs, state_h, state_c = LSTM(lstm_units, 
      return_state=True, name="encoder_lstm")(encoder_input)
    encoder_states = [state_h, state_c]
    decoder_input = Input(shape=(None, num_decoder_tokens), 
      name='decoder_input')
    decoder_lstm = LSTM(lstm_units, return_sequences=True, 
      return_state=True, name="decoder_lstm")
    decoder_outputs, _, _ = decoder_lstm(decoder_input,
                                         initial_state=encoder_states)
    decoder_dense = Dense(num_decoder_tokens, activation='softmax',
                          name='softmax_output')
    decoder_output = decoder_dense(decoder_outputs)
    model = Model([encoder_input, decoder_input], decoder_output)
    model.compile(optimizer='rmsprop', loss='categorical_crossentropy')

    encoder_model = Model(encoder_input, encoder_states)
    decoder_state_input_h = Input(shape=(lstm_units,))
    decoder_state_input_c = Input(shape=(lstm_units,))
    decoder_states_inputs = [decoder_state_input_h, 
      decoder_state_input_c]
    decoder_outputs, state_h, state_c = decoder_lstm(
        decoder_input, initial_state=decoder_states_inputs)
    decoder_states = [state_h, state_c]
    decoder_outputs = decoder_dense(decoder_outputs)
    decoder_model = Model(
        [decoder_input] + decoder_states_inputs,
        [decoder_outputs] + decoder_states)

    return model, encoder_model, decoder_model

请注意,我们将在此处返回所有三个模型。 训练完训练模型后,我将使用keras model.save()方法序列化这三个方法。

训练

我们终于准备好训练我们的序列到序列网络。 以下代码首先调用我们所有的数据加载函数,创建回调,然后拟合模型:

data = load_data()
data = one_hot_vectorize(data)
callbacks = create_callbacks("char_s2s")
model, encoder_model, decoder_model = build_models(256, data['num_encoder_tokens'], data['num_decoder_tokens'])
print(model.summary())

model.fit(x=[data["encoder_input_data"], data["decoder_input_data"]],
          y=data["decoder_target_data"],
          batch_size=64,
          epochs=100,
          validation_split=0.2,
          callbacks=callbacks)

model.save('char_s2s_train.h5')
encoder_model.save('char_s2s_encoder.h5')
decoder_model.save('char_s2s_decoder.h5')

您会注意到,我以前没有像通常那样定义验证或测试集。 这次,按照博客文章中给出的示例,我将让 Keras 随机选择 20% 的数据作为验证,这在示例中可以很好地工作。 如果要使用此代码实际进行机器翻译,请使用单独的测试集。

训练模型适合后,我将保存所有三个模型,并将它们再次加载到为推理而构建的单独程序中。 我这样做是为了使代码保持简洁,因为推理代码本身非常复杂。

让我们来看看这个模型的 100 个周期的模型训练:

如您所见,我们在第 20 个周期开始过拟合。虽然损失持续减少,但val_loss却在增加。 在这种情况下,模型检查指向可能无法正常工作,因为在训练结束之前我们不会序列化推理模型。 因此,理想情况下,我们应该再训练一次,将训练的周期数设置为略大于 TensorBoard 中观察到的最小值。

推理

现在我们有了训练有素的模型,我们将实际生成一些翻译。

总体而言,推理步骤如下:

  1. 加载数据并再次向量化(我们需要字符到索引的映射以及一些转换进行测试)
  2. 使用字符对字典进行索引,我们将创建字符字典的反向索引,因此一旦我们预测了正确的字符,我们就可以从数字返回到字符
  3. 选择一些输入序列进行翻译,然后通过编码器运行,获取状态
  4. 将状态和<SOS>字符'\t'发送到解码器。
  5. 循环,获取每个下一个字符,直到解码器生成<EOS>'\n'

加载数据

我们可以从训练脚本中导入load_dataone_hot_vectorize函数,以相同的方式调用这些方法,如以下代码所示:

data = load_data()
data = one_hot_vectorize(data)

创建反向索引

解码器将预测正确字符的索引,该索引将是解码器的softmax输出的argmax。 我们将需要能够将索引映射到字符。 您可能还记得,数据字典中已经有一个字符到索引的映射,所以我们只需要反转它即可。 逆转字典非常简单,如下所示:

def create_reverse_indicies(data):
    data['reverse_target_char_index'] = dict(
        (i, char) for char, i in data["target_token_index"].items())
    return data

然后,我们可以如下调用此函数:

data = create_reverse_indicies(data)

载入模型

我们可以使用keras.models.load_model加载保存在训练脚本中的模型。 我创建了此助手来完成该任务。 我们将使用以下代码加载模型:

def load_models():
    model = load_model('char_s2s.h5')
    encoder_model = load_model('char_s2s_encoder.h5')
    decoder_model = load_model('char_s2s_decoder.h5')
    return [model, encoder_model, decoder_model]

我们可以调用以下函数来加载所有三个模型:

model, encoder_model, decoder_model = load_models()

翻译序列

现在,我们准备对一些输入序列进行采样并进行翻译。 在示例代码中,我们使用前 100 个双语对进行翻译。 一个更好的测试可能是在整个空间中随机抽样,但是我认为这个简单的循环说明了这一过程:

for seq_index in range(100):
    input_seq = data["encoder_input_data"][seq_index: seq_index + 1]
    decoded_sentence = decode_sequence(input_seq, data, encoder_model, 
                                       decoder_model)
    print('-')
    print('Input sentence:', data['input_texts'][seq_index])
    print('Correct Translation:', data['target_texts']
      [seq_index].strip("\t\n"))
    print('Decoded sentence:', decoded_sentence)

在这段代码中,我们将encoder_input_data的一个观察值用作decode_sequence的输入。 decode_sequence将传回解码器认为正确翻译的序列。 我们还需要将其传递给编码器和解码器模型,以便能够完成其工作。下面的翻译更加有趣,因为学习的短语未与

有了解码器预测后,就可以将其与输入和正确的转换进行比较。

当然,我们还没有完成,因为我们还没有探讨decode_sequence方法的工作方式。 接下来。

解码序列

解码器需要执行以下两项操作:

  • 来自编码器的状态。
  • 输入信号开始预测的翻译。 我们将在一个热向量中向其发送'\t',因为这是我们的<SOS>字符。

为了获得编码器状态,我们只需要使用以下代码将要翻译的短语的向量化版本发送到编码器:

states_value = encoder_model.predict(input_seq)

为了启动解码器,我们还需要一个包含<SOS>字符的热向量。 这段代码将我们带到了那里:

target_seq = np.zeros((1, 1, data['num_decoder_tokens']))
target_seq[0, 0, data['target_token_index']['\t']] = 1.

现在,我们准备使用以下代码设置一个解码器循环,该循环将生成我们的翻译短语:

stop_condition = False
decoded_sentence = ''
while not stop_condition:
    output_tokens, h, c = decoder_model.predict(
        [target_seq] + states_value)

    sampled_token_index = np.argmax(output_tokens[0, -1, :])
    sampled_char = data["reverse_target_char_index"][sampled_token_index]
    decoded_sentence += sampled_char

    if (sampled_char == '\n' or
       len(decoded_sentence) > data['max_decoder_seq_length']):
        stop_condition = True

    target_seq = np.zeros((1, 1, data['num_decoder_tokens']))
    target_seq[0, 0, sampled_token_index] = 1.

    states_value = [h, c]

首先要注意的是,我们一直循环到stop_condition = True。 这在解码器生成'\n'时发生。

第一次通过循环,我使用<SOS>向量和我们在循环外部创建的编码器的状态调用了decoder_model的预测方法。

当然,output_tokens将包含解码器可以预测的每个字符的softmax预测。 通过取output_tokensargmax,我们将获得最大softmax值的索引。 方便地,我可以使用之前创建的reverse_target_char_index将其转换回关联的字符,这是一个在索引和字符之间转换的字典。

接下来,我们将该字符附加到decode_sequence字符串。

接下来,我们可以检查该字符是否为'\n'并触发stop_conditionTrue

最后,我们将创建一个新的target_seq,其中包含解码器生成的最后一个字符,以及一个包含解码器隐藏状态的列表。 现在,我们准备再次重复循环。

我们的解码器将遵循此过程,直到生成解码序列为止。

翻译示例

只是为了好玩,我在这里提供了一些尝试的翻译。 所有这些都来自训练集的前面,这意味着我正在对training数据集进行预测,因此这些转换可能会使模型看起来比实际更好。

我们的第一版翻译使您对我们的期望有所了解,并且该网络做得很好:

输入句子:Help!

正确翻译:À l'aide!

解码后的句子:À l'aide!

后续的翻译更加有趣,因为学习的短语未与任何训练短语相关联。 短语Vas-tu immédiatement!转换为类似You go immediately的字词。这非常相似,甚至可能正确:

输入句子:Go on.

正确的翻译: Poursuis.

解码后的句子: Vas-tu immédiatement!

输入句子:Go on.

正确的翻译:Continuez.

解码后的句子: Vas-tu immédiatement!

输入句子:Go on.

正确的翻译: Poursuivez.

解码后的句子: Vas-tu immédiatement!

当然,有很多方法可以说相同的事情,这使得网络变得更加困难:

输入句子:Come on!

正确的翻译: Allez !

解码后的句子: Allez !

输入句子:Come on!

正确的翻译: Allez !

解码后的句子: Allez !

输入句子:Come on.

正确的翻译:Viens!

解码后的句子: Allez!

输入句子:Come on.

正确的翻译:Venez!

解码后的句子: Allez!

总结

在本章中,我们介绍了序列到序列模型的基础知识,包括它们如何工作以及如何使用它们。 希望我们已经向您展示了一个功能强大的工具,可用于机器翻译,问题解答和聊天应用。

如果您已经做到了,那就好。 您已经看到了很多深度学习的应用,并且发现自己正处于深层神经网络应用的最先进的钟形曲线的右边。

在下一章中,我将向您展示另一个高级主题的示例,即深度强化学习或深度 Q 学习,并向您展示如何实现自己的深度 Q 网络。

在此之前,请放松!

十二、深度强化学习

在本章中,我们将以略有不同的方式使用深度神经网络。 我们将要构建一个智能体,而不是预测一个类的成员,估计一个值,甚至生成一个序列。 尽管机器学习和人工智能这两个术语经常互换使用,但在本章中,我们将讨论人工智能作为一种可以感知其环境的智能体,并采取步骤在该环境中实现某些目标。

想象一个可以玩象棋或围棋之类策略游戏的特工。 构建神经网络来解决此类游戏的一种非常幼稚的方法可能是使用一种网络架构,在该架构中,我们对每个可能的棋盘/棋子组合进行热编码,然后预测每个可能的下一个动作。 尽管该网络庞大而复杂,但可能做得并不好。 要很好地玩国际象棋,您不仅要考虑下一步,而且还要考虑接下来的步伐。 在不确定的情况下,我们的智能体将需要考虑给定未来行动的最佳下一步行动。

这是一个令人兴奋的领域。 正是在智能体领域,研究人员才朝着人工智能或强大的 AI 迈进,这是创建可以执行人类任何智力任务的智能体的崇高目标。 强 AI 的概念通常与弱 AI 形成对比,弱 AI 是解决某些单个任务或应用的能力。

对于作者(我)和读者(您)而言,本章将是一个挑战,因为强化学习理应拥有自己的书,并且需要总结在数学,心理学和计算机科学方面所做的工作。 因此,请原谅快速参考处理,并知道我在为您提供足够的信息,而在接下来的部分中将不多说。

强化学习,马尔可夫决策过程和 Q 学习是智能体的基础,我们接下来将讨论这些内容。

我们将在本章中讨论以下主题:

  • 强化学习概述
  • Keras 强化学习框架
  • 在 Keras 中建立强化学习智能体

强化学习概述

强化学习基于智能体的概念。 智能体通过观察某种状态然后采取行动来与其环境进行交互。 当智能体采取行动在状态之间移动时,它会以奖励信号的形式接收有关其行动良好性的反馈。 这个奖励信号是强化学习中的强化。 这是一个反馈循环,智能体可以使用它来学习其选择的优势。 当然,奖励可以是正面的,也可以是负面的(惩罚)。

想象一下,无人驾驶汽车是我们正在制造的智能体。 在行驶过程中,它不断收到动作的奖励信号。 留在车道内可能会产生积极的报酬,而在行人上奔跑可能会给智能体带来非常消极的报酬。 当面临选择留在行人或撞到行人的选择时,智能体将希望学会以避开行人为代价,避开行人,损失车道线奖励,以避免更大的行人碰撞惩罚。

强化学习概念的核心是状态,行为和奖励的概念。 我已经讨论过奖励,所以让我们谈谈行动和状态。 动作是智能体在观察到某种状态时可以执行的操作。 如果我们的特工正在玩一个简单的棋盘游戏,那么该动作将由该特工轮到它来做。 然后转弯就是座席的状态。 为了解决这些问题,我们将在这里着眼于一个智能体可以采取的行动始终是有限的和离散的。 下图说明了此概念:

此反馈循环的一个步骤可以用数学方式表示为:

动作会在原始状态s和下一个状态s'的智能体之间进行转换,智能体会在其中获得一些奖励r。 智能体选择动作的方式称为智能体策略,通常称为pi

强化学习的目的是找到一系列动作,使行动者从一个状态到另一个状态,并获得尽可能多的报酬。

马尔可夫决策过程

我们构筑的这个世界恰好是马尔可夫决策过程MDP),它具有以下属性:

  • 它具有一组有限的状态,S
  • 它具有一组有限的动作 A
  • P[a](s, s')是采取行动A将在状态s和状态s'之间转换的概率
  • R[a](s, s')ss'之间过渡的直接奖励。
  • γ ∈ [0, 1]是折扣因子,这是我们相对于当前奖励对未来奖励的折扣程度(稍后会详细介绍)

一旦我们有了确定每个状态要采取的操作的策略函数pi,MDP 就解决了,成为了马尔可夫链。

好消息是,有一个警告就完全有可能完美解决 MDP。 需要注意的是,必须知道 MDP 的所有回报和概率。 事实证明,这种警告相当重要,因为在大多数情况下,由于智能体的环境混乱或至少不确定,因此智能体不知道所有的回报和状态更改概率。

Q 学习

想象一下,我们有一些函数Q,可以估计出采取行动的回报:

对于某些状态s以及动作a,它会根据状态为该动作生成奖励。 如果我们知道环境带来的所有回报,那么我们就可以遍历Q并选择能够为我们带来最大回报的行动。 但是,正如我们在上一节中提到的那样,我们的智能体不知道所有的奖励状态和状态概率。 因此,我们的Q函数需要尝试近似奖励。

我们可以使用称为 Bellman 公式的递归定义的Q函数来近似此理想的Q函数:

在这种情况下, r[0]是下一个动作的奖励,然后在下一个动作上(递归地)递归使用Q函数确定该行动的未来奖励。 为此,我们将γ作为相对于当前奖励的未来奖励的折扣。 只要伽玛小于 1,它就不会使我们的奖励序列变得无限大。 更明显地,与当前状态下的相同奖励相比,未来状态下的奖励的值要低。 具体来说,如果有人今天给您 100 美元,明天给您 100 美元,您应该立即拿走这笔钱,因为明天不确定。

如果我们尽最大的努力让我们的智能体经历每种可能的状态转换,并使用此函数来估计我们的报酬,我们将得出我们试图近似的理想Q函数。

无限状态空间

Q函数的讨论使我们陷入了传统强化学习的重要局限。 您可能还记得,它假设状态空间是有限且离散的。 不幸的是,这不是我们生活的世界,也不是我们的智能体在很多时候会发现自己的环境。 考虑一个可以打乒乓球的经纪人。 状态空间的重要组成部分是乒乓球的速度,它当然不是离散的。 像我们不久将要看到的那样,可以看到的特工会看到一个图像,该图像是一个很大的连续空间。

我们讨论的 Bellman 方程将要求我们在状态与状态之间转移时保持经验奖励的大矩阵。 但是,当面对连续的状态空间时,这是不可能的。 可能的状态本质上是无限的,我们不能创建无限大小的矩阵。

幸运的是,我们可以使用深度神经网络来近似Q函数。 这可能不会让您感到惊讶,因为您正在阅读一本深度学习书,因此您可能猜测深度学习必须在某个地方出现。 就是那个地方

深度 Q 网络

深层 Q 网络DQN)是近似Q函数的神经网络。 他们将状态映射到动作,并学会估计每个动作的Q值,如下图所示:

我们可以使用深度神经网络作为函数来逼近该矩阵,而不是尝试存储一个无限大的矩阵,而是将奖励从连续状态空间映射到动作。 这样,我们可以将神经网络用作智能体的大脑。 但这一切都导致我们提出一个非常有趣的问题。 我们如何训练这个网络?

在线学习

当我们的智能体通过采取行动从一个状态过渡到另一个状态时,它会得到奖励。 智能体可以通过使用每个状态,动作和奖励作为训练输入来在线学习。 在执行每个操作后,该智能体将更新其神经网络权重,并希望在此过程中变得更聪明。 这是在线学习的基本思想。 智能体就像您和我一样,不断学习。

这种朴素的在线学习的缺点有些明显,有两个方面:

  • 经历之后,我们就会放弃经验。
  • 我们所经历的经验彼此高度相关,我们将过度适应最新的经验。 有趣的是,这也是人类遭受的苦难,称为可用性偏差。

我们可以通过使用内存和经验重放来解决这些问题。

记忆和经验重放

当我们引入有限存储空间的概念时,可以找到针对这两个问题的巧妙解决方案,该存储空间用于存储智能体具有的一组经验。 在每个状态下,我们都可以借此机会记住状态,行动和奖励。 然后,智能体可以通过从内存中采样一个随机小批量并使用该小批量更新 DQN 权重,定期重放这些经验。

这种重放机制使智能体能够以一般的方式从更长远的经验中学习,因为它是从内存中的那些经验中随机采样的,而不是仅使用最近的经验来更新整个网络。

利用与探索

通常,我们希望智能体遵循贪婪策略,这意味着我们希望智能体采取具有最大Q值的操作。 在学习网络的同时,我们不希望它总是贪婪地表现。 如果这样做,它将永远不会探索新的选择,也不会学习新的东西。 因此,我们需要我们的智能体偶尔执行不符合规定的策略。

平衡这种探索的最佳方法是一个持续不断的研究主题,并且已经使用了很长时间。 但是,我们将使用的方法非常简单。 智能体每次执行操作时,我们都会生成一个随机数。 如果该数字等于或小于某个阈值ε,则智能体将采取随机措施。 这称为 ε 贪婪策略

智能体第一次启动时,对世界了解不多,应该探索更多。 随着智能体变得越来越聪明,它可能应该减少探索并更多地使用其对环境的了解。 为此,我们只需要在训练时逐渐降低ε。 在我们的示例中,我们将每转降低ε的衰减率,以使它随每个动作线性减小。

综上所述,我们有一个线性退火 ε - 贪心 Q 策略,说起来既简单又有趣。

DeepMind

至少没有提到 Mnih 等人的论文《和深度强化学习一起玩 Atari》,就不会完成关于强化学习的讨论。 然后是 DeepMind,现在是 Google。 在这篇具有里程碑意义的论文中,作者使用了卷积神经网络来训练深度 Q 网络来玩 Atari 2600 游戏。 他们从 Atari 2600 游戏中获取原始像素输出,将其缩小一点,将其转换为灰度,然后将其用作网络的状态空间输入。 为了使计算机了解屏幕上对象的速度和方向,他们使用了四个图像缓冲区作为深度 Q 网络的输入。

作者能够创建一个智能体,该智能体能够使用完全相同的神经网络架构玩 7 个 Atari 2600 游戏,并且在其中三个游戏上,该智能体要比人类更好。 后来又扩大到 49 场比赛,其中大多数比赛都比人类出色。 本文是迈向通用 AI 的非常重要的一步,它实际上是目前在强化学习中开展的许多研究的基础。

Keras 强化学习框架

在这一点上,我们应该有足够的背景知识来开始建立深层的 Q 网络,但是仍然需要克服很大的障碍。

实现利用深度强化学习的智能体可能是一个很大的挑战,但是最初由 Matthias Plappert 编写的 Keras-RL 库使其变得更加容易。 我将使用他的库来为本章介绍的智能体提供支持。

当然,如果没有环境,我们的经纪人将不会有太多的乐趣。 我将使用 OpenAI 体育馆,该体育馆提供许多环境,包括状态和奖励函数,我们可以轻松地使用它们来构建供智能体探索的世界。

安装 Keras-RL

Keras-RL 可以通过 PIP 安装。 但是,我建议从项目 GitHub 存储库中安装它,因为代码可能会更新一些。 为此,只需克隆存储库并按以下方式运行python setup.py install

git clone https://github.com/matthiasplappert/keras-rl.git
cd keras-rl
python setup.py install

安装 OpenAI Gym

OpenAI 体育场可作为点子安装。 我将使用他们的Box2Datari环境中的示例。 您可以使用以下代码安装它们:

pip install gym
pip install gym[atari]
pip install gym[Box2D]

使用 OpenAI Gym

使用 OpenAI 体育场确实使深度强化学习变得容易。 Keras-RL 将完成大部分艰苦的工作,但是我认为值得单独走遍体育馆,这样您才能了解智能体如何与环境互动。

环境是可以实例化的对象。 例如,要创建CartPole-v0环境,我们只需要导入体育场并创建环境,如以下代码所示:

import gym
env = gym.make("CartPole-v0")

现在,如果我们的智能体想要在那种环境中行动,它只需要发送一个action并返回一个状态和一个reward,如下所示:

next_state, reward, done, info = env.step(action)

该智能体可以通过使用循环与环境进行交互来播放整个剧集。 此循环的每次迭代都对应剧集中的单个步骤。 当智能体从环境接收到“完成”信号时,剧集结束。

在 Keras 中建立强化学习智能体

好消息,我们终于可以开始编码了。 在本部分中,我将演示两种名为 CartPoleLunar Lander 的 Keras-RL 智能体。 我选择这些示例是因为它们不会消耗您的 GPU 和云预算来运行。 它们可以很容易地扩展到 Atari 问题,我在本书的 Git 存储库中也包括了其中之一。 您可以照常在Chapter12文件夹中找到所有这些代码。 让我们快速讨论一下这两种环境:

  • CartPole:CartPole 环境由平衡在推车上的杆组成。 智能体必须学习如何在立柱下方的推车移动时垂直平衡立柱。 给智能体指定了推车的位置,推车的速度,杆的角度和杆的旋转速度作为输入。 智能体可以在推车的任一侧施加力。 如果电线杆与垂直线的夹角下降超过 15 度,我们的经纪人就此告吹。
  • Lunar Lander:Lunar Lander 的环境更具挑战性。 特工必须将月球着陆器降落在着陆垫上。 月亮的表面会发生变化,着陆器的方位也会在每个剧集发生变化。 该智能体将获得一个八维数组,用于描述每个步骤中的世界状态,并且可以在该步骤中执行四个操作之一。 智能体可以选择不执行任何操作,启动其主引擎,启动其左向引擎或启动其右向引擎。

CartPole

CartPole 智能体将使用一个相当适度的神经网络,即使没有 GPU,您也应该能够相当迅速地进行训练。 我们将一如既往地从模型架构开始。 然后,我们将定义网络的内存,探索策略,最后训练智能体。

CartPole 神经网络架构

三个具有 16 个神经元的隐藏层实际上可能足以解决这个简单的问题。 这个模型非常类似于我们在本书开始时使用的一些基本模型。 我们将使用以下代码来定义模型:

def build_model(state_size, num_actions):
    input = Input(shape=(1,state_size))
    x = Flatten()(input)
    x = Dense(16, activation='relu')(x)
    x = Dense(16, activation='relu')(x)
    x = Dense(16, activation='relu')(x)
    output = Dense(num_actions, activation='linear')(x)
    model = Model(inputs=input, outputs=output)
    print(model.summary())
    return model

输入将是一个1 x 状态空间向量,每个可能的动作都有一个输出神经元,它将预测每个步骤该动作的Q值。 通过获取输出的argmax,我们可以选择Q值最高的动作,但是我们不必自己做,因为 Keras-RL 会为我们做。

记忆

Keras-RL 为我们提供了一个名为rl.memory.SequentialMemory的类,该类提供了快速有效的数据结构,我们可以将智能体的经验存储在以下位置:

memory = SequentialMemory(limit=50000, window_length=1)

我们需要为此存储对象指定一个最大大小,它是一个超参数。 随着新的经验添加到该内存中并变得完整,旧的经验会被遗忘。

策略

Keras-RL 提供了一个称为rl.policy.EpsGreedyQPolicy的 ε-贪婪 Q 策略,我们可以用来平衡利用与探索。 当智能体程序向世界前进时,我们可以使用rl.policy.LinearAnnealedPolicy来衰减ε,如以下代码所示:

policy = LinearAnnealedPolicy(EpsGreedyQPolicy(), attr='eps', value_max=1., value_min=.1, value_test=.05, nb_steps=10000)

在这里我们要说的是,我们要从ε的值 1 开始,并且不小于 0.1,同时测试我们的随机数是否小于 0.05。 我们将步数设置为 .1 到 10,000 之间,Keras-RL 为我们处理衰减数学。

智能体

定义了模型,内存和策略后,我们现在就可以创建一个深度 Q 网络智能体,并将这些对象发送给该智能体。 Keras RL 提供了一个称为rl.agents.dqn.DQNAgent的智能体类,我们可以为此使用它,如以下代码所示:

dqn = DQNAgent(model=model, nb_actions=num_actions, memory=memory, nb_steps_warmup=10,
               target_model_update=1e-2, policy=policy)

dqn.compile(Adam(lr=1e-3), metrics=['mae'])

此时,其中两个参数target_model_updatenb_steps_warmup可能还不熟悉:

  • nb_steps_warmup:确定我们开始进行经验重放之前需要等待的时间,如果您还记得的话,这是我们实际上开始训练网络的时间。 这使我们积累了足够的经验来构建适当的小批量生产。 如果您为此参数选择的值小于批量大小,则 Keras RL 将抽样替换。
  • target_model_updateQ函数是递归的,当智能体更新它的网络以获取 Q(s, a)时,更新也影响其对Q(s', a)所​​做的预测。 这会导致网络非常不稳定。 大多数深度 Q 网络实现解决此限制的方法是使用目标网络,该目标网络是未经训练的深度 Q 网络的副本,而经常被新副本替换。 target_model_update参数控制这种情况发生的频率。

训练

Keras RL 提供了多个类似 Keras 的回调,可以方便地进行模型检查指向和记录。 我将在下面使用这两个回调。 如果您想查看 Keras-RL 提供的更多回调,可以在以下位置找到它们。 您还可以找到可用于创建自己的 Keras-RL 回调的回调类。

我们将使用以下代码来训练我们的模型:

def build_callbacks(env_name):
    checkpoint_weights_filename = 'dqn_' + env_name + '_weights_{step}.h5f'
    log_filename = 'dqn_{}_log.json'.format(env_name)
    callbacks = [ModelIntervalCheckpoint(checkpoint_weights_filename, interval=5000)]
    callbacks += [FileLogger(log_filename, interval=100)]
    return callbacks

callbacks = build_callbacks(ENV_NAME)

dqn.fit(env, nb_steps=50000,
 visualize=False,
 verbose=2,
 callbacks=callbacks)

一旦构建了智能体的回调,我们就可以使用.fit()方法来拟合DQNAgent,就像使用 Keras 模型一样。 在此示例中,请注意visualize参数。 如果将visualize设置为True,我们将能够观察智能体与环境的交互。 但是,这大大减慢了训练的速度。

结果

在前 250 个剧集之后,我们将看到剧集的总奖励接近 200,剧集步骤的总奖励也接近 200。这意味着智能体已学会平衡购物车上的杆位,直到环境结束最多 200 个步骤 。

观看我们的成功当然很有趣,因此我们可以使用DQNAgent .test()方法评估某些剧集。 以下代码用于定义此方法:

dqn.test(env, nb_episodes=5, visualize=True)

在这里,我们设置了visualize=True,以便我们可以看到我们的智能体平衡杆位,如下图所示:

我们走了,那是一根平衡杆! 好吧,我知道,我承认平衡手推车上的电线杆并不是那么酷,所以让我们再做一个轻量级的例子。 在此示例中,我们将把月球着陆器降落在月球上,希望它将给您留下深刻的印象。

Lunar Lander

感谢 Keras-RL,我们用于 Lunar Lander 的智能体几乎与 CartPole 相同,除了实际的模型架构和一些超参数更改外。 Lunar Lander 的环境有八个输入而不是四个输入,我们的智能体现在可以选择四个操作而不是两个。

如果您受到这些示例的启发,并决定尝试构建 Keras-RL 网络,请记住,超参数选择非常非常重要。 对于 Lunar Lander 智能体,对模型架构的最小更改导致我的智能体无法学习环境解决方案。 使网络正确运行是一项艰巨的工作。

Lunar Lander 网络架构

我的 Lunar Lander 智能体程序的架构仅比 CartPole 的架构稍微复杂一点,对于相同的三个隐藏层仅引入了几个神经元。 我们将使用以下代码来定义模型:

def build_model(state_size, num_actions):
    input = Input(shape=(1, state_size))
    x = Flatten()(input)
    x = Dense(64, activation='relu')(x)
    x = Dense(32, activation='relu')(x)
    x = Dense(16, activation='relu')(x)
    output = Dense(num_actions, activation='linear')(x)
    model = Model(inputs=input, outputs=output)
    print(model.summary())
    return model

在此问题的情况下,较小的架构会导致智能体学习控制和悬停着陆器,但实际上并未着陆。 当然,由于我们要对每个剧集的每个步骤进行小批量更新,因此我们需要仔细权衡复杂性与运行时和计算需求之间的关系。

记忆和策略

CartPole 的内存和策略可以重复使用。 我相信,通过进一步调整线性退火策略中的步骤,可能会提高智能体训练的速度,因为该智能体需要采取更多的步骤来进行训练。 但是,为 CartPole 选择的值似乎可以很好地工作,因此这是留给读者的练习。

智能体

从以下代码中可以看出,Lunar Lander DQNAgent再次相同,只是学习率小得多。

dqn = DQNAgent(model=model, nb_actions=num_actions, memory=memory, nb_steps_warmup=10, target_model_update=1e-2, policy=policy)
dqn.compile(Adam(lr=0.00025), metrics=['mae'])

训练

在训练该特工时,您会注意到它学会做的第一件事是将着陆器悬停,并避免着陆。 当着陆器最终着陆时,它会收到非常高的奖励,成功着陆时为 +100,坠毁时为 -100。 这种 -100 的奖励是如此之强,以至于智能体一开始宁愿因悬停而受到小额罚款。 我们的探员要花很多时间才能得出这样的提示:良好的着陆总比没有良好着陆好,因为坠机着陆非常糟糕。

可以塑造奖励信号来帮助座席更快地学习,但这超出了本书的范围。 有关更多信息,请查看奖励塑造。

由于这种对坠机着陆的极端负面反馈,网络需要花费相当长的一段时间才能学会着陆。 在这里,我们正在运行五十万个训练步骤,以传达我们的信息。 我们将使用以下代码来训练智能体:

callbacks = build_callbacks(ENV_NAME)

dqn.fit(env, nb_steps=1000000,
        visualize=False,
        verbose=2,
        callbacks=callbacks)

您可以通过调整参数gamma(默认值为 0.99)来进一步改进此示例。 如果您从Q函数中调用,此参数会减少或增加Q函数中将来奖励的影响。

结果

我在 Git 一章中包含了 Lunar Lander 的权重,并创建了一个脚本,该脚本在启用可视化的情况下运行这些权重dqn_lunar_lander_test.py。 它加载经过训练的模型权重并运行 10 集。 在大多数情况下,特工能够以惊人的技能和准确率将月球着陆器降落在其着陆板上,如以下屏幕截图所示:

希望这个例子可以说明,尽管深层 Q 网络并不是火箭科学,但仍可用于控制火箭。

总结

斯坦福大学只教授强化学习的整个课程。 可能只写了一本关于强化学习的书,实际上已经做了很多次。 我希望本章能够向您展示足够的知识,让您开始解决强化学习问题。

当我解决“月球着陆器”问题时,很容易让我的头脑从玩具问题到利用深层 Q 网络驱动的特工进行实际太空探索而徘徊。 我希望本章为您做同样的事情。

在下一章中,我将向您展示深度神经网络的最后一种用法,我们将研究可以生成新图像,数据点甚至音乐的网络,称为生成对抗网络

十三、生成对抗网络

尽管我在本书中花了很多时间谈论分类或估计的网络,但在本章中,我将向您展示一些具有创建能力的深度神经网络。 生成对抗网络GAN)通过两个内部深层网络之间的内部竞争来学习如何做到这一点,我们将在下面讨论。 在深度卷积生成对抗网络DCGAN)的情况下,这是我将在本章中重点介绍的 GAN 类型,该网络将学习创建类似于训练数据集的图像。

我们将在本章介绍以下主题:

  • GAN 概述
  • 深度卷积 GAN 架构
  • GAN 如何失败
  • GAN 的安全选择
  • 使用 Keras GAN 生成 MNIST 图像
  • 使用 Keras GAN 生成 CIFAR-10 图像

GAN 概述

生成对抗网络都是关于生成新内容的。 GAN 能够学习一些分布并从该分布创建新样本。 该样本可能只是我们训练数据中未出现的直线上的新点,但也可能是非常复杂的数据集中的新点。 GAN 已用于生成新的音乐,声音和图像。 根据 Yann LeCun 所说,《对抗训练是切片以来最酷的事情》。 我不确定切片面包是否特别酷,但是 Yann LeCun 是​​一个非常酷的家伙,所以我会信守诺言。 无论如何,GAN 都非常受欢迎,虽然它可能不如我们在业务环境中涵盖的其他一些主题那么实用,但在我们对深度学习技术的调查中值得考虑。

2014 年,伊恩·古德费洛(Ian Goodfellow)等人。 撰写了一篇名为生成对抗网络的论文,提出了使用两个深度网络进行对抗训练的框架,每个都尝试打败对方。 该框架由两个独立的网络组成:判别器和生成器。

判别器正在查看来自训练集的真实数据和来自生成器的假数据。 它的工作是将每一个作为传入数据实例分类为真实还是伪造。

生成器试图使判别器误以为所生成的数据是真实的。

生成器和判别器被锁定在一个游戏中,它们各自试图超越彼此。 这种竞争驱使每个网络不断改进,直到最终判别器将生成器的输出与训练集中的数据区分开。 当生成器和判别器都正确配置时,它们将达到纳什均衡,在纳什均衡中,两者都无法找到优势。

深度卷积 GAN 架构

关于 GAN 的论文很多,每篇都提出了新的新颖架构和调整。 但是,它们中的大多数至少在某种程度上基于深度卷积 GANDCGAN)。 在本章的其余部分中,我们将重点介绍这种模型,因为当您采用此处未介绍的新的令人兴奋的 GAN 架构(例如条件 GANCGAN),Stack GAN,InfoGAN 或 Wasserstein GAN),或者可能还有一些其他的新变种,您可能会选择接下来看看。

DCGAN 由 Alex Radford,Luke Metz 和 Soumith Chintala 在论文《深度卷积生成对抗网络》中提出。

接下来让我们看一下 DCGAN 的总体架构。

对抗训练架构

GAN 的整体架构如下图所示。 生成器和判别器分别是单独的深度神经网络,为了易于使用,将它们简化为黑匣子。 我们将很快介绍它们的各个架构,但首先,我想着重介绍它们的交互方式:

给生成器一个随机噪声向量(z),并创建一个输出G(z)(对于 DCGAN,这是一个图像),希望它能欺骗判别器。

判别器既得到实际训练数据(X),又得到生成器输出G(z)。 要做的是确定其输入实际上是真实的概率P(X)

判别器和生成器都在栈中一起训练。 随着一个方面的改进,另一个方面也有所改进,直到希望生成器产生如此好的输出,从而使判别器不再能够识别该输出与训练数据之间的差异。

当然,在您准备好构建自己的 GAN 之前,我们还要介绍更多细节。 接下来,让我们更深入地研究生成器。

生成器架构

在此示例中,我们使用适合于生成28 x 28灰度图像的层大小,这正是我们稍后在 MNIST 示例中将要执行的操作。 如果您以前没有使用过生成器,那么生成器的算法可能会有些棘手,因此我们将在遍历每一层时进行介绍。 下图显示了架构:

生成器的输入只是100 x 1的随机向量,我们将其称为噪声向量。 当此噪声向量是从正态分布生成时,GAN 往往工作得最好。

网络的第一层是密集的并且完全连接。 它为我们提供了一种建立线性代数的方法,以便最终得到正确的输出形状。 对于每个卷积块,我们最终将第一轴和第二轴(最终将成为图像的高度和宽度的行和列)加倍,而通道数逐渐缩小到 1。我们最终需要输出的高度和宽度为 28。因此,我们将需要从7 x 7 x 128张量开始,以便它可以移动到14 x 14,然后最终是28 x 28。 为此,我们将密集层的大小设置为128 x 7 x 7神经元或 6,272 单元。 这使我们可以将密集层的输出重塑为7 x 7 x 128。 如果现在看来这还不算什么,请不用担心,在编写代码后,这才有意义。

在完全连接的层之后,事情变得更加简单。 就像我们一直一样,我们正在使用卷积层。 但是,这次我们反向使用它们。 我们不再使用最大池来缩减样本量。 取而代之的是,我们进行上采样,在学习视觉特征时使用卷积来构建我们的网络,并最终输出适当形状的张量。

通常,生成器中最后一层的激活是双曲正切,并且训练图像矩阵中的元素被归一化为 -1 和 1 之间。这是我将在整章中提到的众多 GAN 黑魔法之一。 研究人员已经发现了一些经验证明可以帮助构建稳定的 GAN 的黑魔法,Soumith Chintala 可以在此 Git 上找到大多数黑客,而 Soumith Chintala 也是 DCGAN 原始论文的作者之一。 深度学习研究的世界无疑是一个很小的领域。

判别器架构

判别器的架构更像我们在前几章中已经看到的。 它实际上只是一个典型的图像分类器,如下图所示。 输出是 Sigmoid 的,因为判别器将预测输入图像是真实图像集的成员的概率。 判别器正在解决二分类问题:

现在,我们已经介绍了 DCGAN 的架构以及它的各个层次,下面让我们看一下如何训练框架。

DCGAN

DCGAN 框架是使用迷你批量来进行训练的,这与我之前在本书中对网络进行训练的方式相同。 但是,稍后在构建代码时,您会注意到我们正在构建一个训练循环,该循环明确控制每个更新批量的情况,而不仅仅是调用models.fit()方法并依靠 Keras 为我们处理它。 我这样做是因为 GAN 训练需要多个模型来更新同一批次中的权重,所以它比我们以前所做的单个参数更新要稍微复杂一些。

对 DCGAN 进行训练的过程分为两步,每批次进行一次。

步骤 1 – 训练判别器

批量训练 DCGAN 的第一步是在实际数据和生成的数据上训练判别器。 赋予真实数据的标签显然是1,而用于假数据的标签则是0

步骤 2 – 训练栈

判别器更新权重后,我们将判别器和生成器一起训练为一个模型。 这样做时,我们将使判别器的权重不可训练,将其冻结在适当的位置,但仍允许判别器将梯度反向传播到生成器,以便生成器可以更新其权重。

对于训练过程中的这一步,我们将使用噪声向量作为输入,这将导致生成器生成图像。 判别器将显示该图像,并要求预测该图像是否真实。 下图说明了此过程:

判别器将提出一些预测,我们可以称之为y_hat。 此栈的loss函数将是二元交叉熵,并且我们将loss函数的标签传递为 1,我们可以考虑y。 如您在本书前面所提到的, yy_hat之间的loss转换为梯度,然后通过判别器传给生成器。 这将更新生成器权重,使它可以从判别者对问题空间的了解中受益,以便它可以学习创建更逼真的生成图像。

然后重复这两个步骤,直到生成器能够创建与训练集中的数据相似的数据,使得判别器无法再将两个数据集区分开,这成为了一个猜谜游戏。 判别器。 此时,生成器将不再能够改进。 当我们找到纳什均衡时,就对网络进行了训练。

GAN 如何失败

至少可以说,训练 GAN 是一件棘手的事情。 训练 GAN 失败的方法有很多种。 实际上,在撰写本章时,我发现自己大大扩展了亵渎向量的词汇量,同时还花了一点时间在云 GPU 上! 在本章稍后向您展示两个可用的 GAN 之前,让我们考虑可能发生的故障以及如何修复这些问题。

稳定性

训练 GAN 需要在判别器和生成器之间进行仔细的平衡。 判别器和生成器都在争夺深度网络优势。 另一方面,他们也需要彼此学习和成长。 为了使它起作用,任何一个都不能压倒另一个。

在不稳定的 GAN 中,判别器可能会使生成器过载,并绝对确定生成器是假的。 损失为零,并且没有可用于发送到生成器的梯度,因此它不再可以改善。 网络游戏结束。 解决此问题的最佳方法是降低判别器的学习率。 您也可以尝试减少整个判别器架构中神经元的数量。 但是,您可能会在训练过程的后期错过这些神经元。 最终,调整网络架构和超参数是避免这种情况的最佳方法。

当然,这可能是相反的方式,如模式崩溃的情况。

模式崩溃

模式崩溃是 GAN 失败的类似且相关的方式。 在模式崩溃中,生成器在多模式分布中学习一种模式,并选择始终使用该方法来利用判别器。 如果您的训练集中有鱼和小猫,并且您的生成器仅生成奇怪的小猫而没有鱼,则您经历了模式崩溃。 在这种情况下,增加判别器的威力可能会有所帮助。

GAN 的安全选择

我之前已经提到过 Soumith Chintala 的 GAN 黑魔法 Git,当您试图使 GAN 稳定时,这是一个很好的起点。 既然我们已经讨论了训练稳定的 GAN 会有多么困难,让我们来谈谈一些安全的选择,这些选择可能会帮助您成功找到自己的地方。 尽管有很多技巧,但以下是本章中尚未涵盖的我的主要建议:

  • 批量规范:使用批量规范化时,请为真实数据和伪数据构造不同的微型批量,并分别进行更新。
  • 泄漏的 ReLU:泄漏的 ReLU 是 ReLU 激活函数的变异。 回想一下 ReLU 函数是f(x) = max(0, x)

但是,泄漏的 ReLU 可以表示为:

当设备不工作时,泄漏的 ReLU 允许非常小的非零梯度。 这可以消除消失的梯度,当我们像在判别器和生成器的组合中那样将多个层堆叠在一起时,这总是一个问题。

  • 在生成器中使用丢弃:这将产生噪声并防止模式崩溃。
  • 使用软标签:对于真实示例,请使用介于 0.7 和 1 之间的标签,对于伪示例,请使用介于 0 和 0.3 之间的标签。 这种噪声有助于保持信息从判别器流向生成器。

在本章的其他地方,我们还将介绍许多其他的 GAN 黑魔法。 但是,我认为在成功实现 GAN 时,这几项技巧是最重要的。

使用 Keras GAN 生成 MNIST 图像

我们之前曾与 MNIST 合作,但是这次我们将使用 GAN 生成新的 MNIST 图像。 训练 GAN 可能需要很长时间。 但是,此问题很小,可以在几个小时内在大多数笔记本电脑上运行,这是一个很好的例子。 稍后,我们将把这个例子扩展到 CIFAR-10 图像。

我在这里使用的网络架构已被许多人发现并进行了优化,包括 DCGAN 论文的作者以及像 ErikLinder-Norén 这样的人,他是 GAN 实现的优秀集合,称为 Keras GAN 作为我在此处使用的代码的基础。 如果您想知道我是如何在这里使用的架构选择的,这些就是我试图站在肩膀上的巨人。

加载数据集

MNIST数据集由 60,000 个手绘数字(从 0 到 9)组成。Keras 为我们提供了一个内置加载程序,可将其分为 50,000 个训练图像和 10,000 个测试图像。 我们将使用以下代码加载数据集:

from keras.datasets import mnist

def load_data():
    (X_train, _), (_, _) = mnist.load_data()
    X_train = (X_train.astype(np.float32) - 127.5) / 127.5
    X_train = np.expand_dims(X_train, axis=3)
    return X_train

您可能已经注意到,我没有返回任何标签或测试数据集。 我将只使用训练数据集。 不需要标签,因为我要使用的唯一标签是0代表假货,1代表真货。 这些是真实的图像,因此将在判别器上将它们全部分配为标签 1。

创建生成器

生成器使用了一些新的层,我们将在本节中讨论这些层。 首先,有机会略读以下代码:

def build_generator(noise_shape=(100,)):
    input = Input(noise_shape)
    x = Dense(128 * 7 * 7, activation="relu")(input)
    x = Reshape((7, 7, 128))(x)
    x = BatchNormalization(momentum=0.8)(x)
    x = UpSampling2D()(x)
    x = Conv2D(128, kernel_size=3, padding="same")(x)
    x = Activation("relu")(x)
    x = BatchNormalization(momentum=0.8)(x)
    x = UpSampling2D()(x)
    x = Conv2D(64, kernel_size=3, padding="same")(x)
    x = Activation("relu")(x)
    x = BatchNormalization(momentum=0.8)(x)
    x = Conv2D(1, kernel_size=3, padding="same")(x)
    out = Activation("tanh")(x)
    model = Model(input, out)
    print("-- Generator -- ")
    model.summary()
    return model

我们以前没有使用过UpSampling2D层。 该层将增加输入张量的行和列,从而使通道保持不变。 它通过重复输入张量中的值来实现。 默认情况下,它将使输入加倍。 如果给UpSampling2D层一个7 x 7 x 128输入,它将给我们一个14 x 14 x 128输出。

通常,当我们构建一个 CNN 时,我们从一个非常高和宽的图像开始,并使用卷积层来获得一个非常深但又不高又不宽的张量。 在这里,我将相反。 我将使用一个密集层并进行重塑,以7 x 7 x 128张量开始,然后将其加倍两次后,剩下28 x 28张量。 由于我需要灰度图像,因此可以使用具有单个单元的卷积层来获得28 x 28 x 1输出。

这种生成器运算法则有点令人反感,乍一看似乎很尴尬,但是经过几个小时的痛苦之后,您就会掌握它了!

创建判别器

判别符实际上在很大程度上与我之前谈到的任何其他 CNN 相同。 当然,我们应该谈论一些新事物。 我们将使用以下代码来构建判别器:

def build_discriminator(img_shape):
    input = Input(img_shape)
    x =Conv2D(32, kernel_size=3, strides=2, padding="same")(input)
    x = LeakyReLU(alpha=0.2)(x)
    x = Dropout(0.25)(x)
    x = Conv2D(64, kernel_size=3, strides=2, padding="same")(x)
    x = ZeroPadding2D(padding=((0, 1), (0, 1)))(x)
    x = (LeakyReLU(alpha=0.2))(x)
    x = Dropout(0.25)(x)
    x = BatchNormalization(momentum=0.8)(x)
    x = Conv2D(128, kernel_size=3, strides=2, padding="same")(x)
    x = LeakyReLU(alpha=0.2)(x)
    x = Dropout(0.25)(x)
    x = BatchNormalization(momentum=0.8)(x)
    x = Conv2D(256, kernel_size=3, strides=1, padding="same")(x)
    x = LeakyReLU(alpha=0.2)(x)
    x = Dropout(0.25)(x)
    x = Flatten()(x)
    out = Dense(1, activation='sigmoid')(x)

    model = Model(input, out)
    print("-- Discriminator -- ")
    model.summary()
    return model

首先,您可能会注意到形状奇怪的zeroPadding2D()层。 第二次卷积后,我们的张量从28 x 28 x 3变为7 x 7 x 64。 这一层使我们回到偶数,在行和列的一侧都加零,这样我们的张量现在为8 x 8 x 64

更不寻常的是同时使用批量规范化和丢弃法。 通常,这两层不能一起使用。 但是,就 GAN 而言,它们似乎确实使网络受益。

创建栈式模型

现在我们已经组装了generatordiscriminator,我们需要组装第三个模型,这是两个模型的栈,在discriminator损失的情况下,我们可以用来训练生成器。

为此,我们可以创建一个新模型,这次使用以前的模型作为新模型中的层,如以下代码所示:

discriminator = build_discriminator(img_shape=(28, 28, 1))
generator = build_generator()

z = Input(shape=(100,))
img = generator(z)
discriminator.trainable = False
real = discriminator(img)
combined = Model(z, real)

注意,在建立模型之前,我们将判别器的训练属性设置为False。 这意味着对于该模型,在反向传播期间,我们将不会更新判别器的权重。 正如我们在“栈式训练”部分中提到的,我们将冻结这些权重,仅将生成器的权重与栈一起移动。 判别器将单独训练。

现在,所有模型都已构建,需要对其进行编译,如以下代码所示:

gen_optimizer = Adam(lr=0.0002, beta_1=0.5)
disc_optimizer = Adam(lr=0.0002, beta_1=0.5)

discriminator.compile(loss='binary_crossentropy',
                           optimizer=disc_optimizer,
                           metrics=['accuracy'])

generator.compile(loss='binary_crossentropy', optimizer=gen_optimizer)

combined.compile(loss='binary_crossentropy', optimizer=gen_optimizer)

如果您会注意到,我们将创建两个自定义 Adam 优化器。 这是因为很多时候,我们只想更改判别器或生成器的学习率,从而减慢一个或另一个的学习速度,以至于我们得到一个稳定的 GAN,而后者却无法胜任另一个。 您还会注意到我正在使用beta_1 = 0.5。 这是我发扬光大并取得成功的 DCGAN 原始论文的推荐。 从原始 DCGAN 论文中可以发现,0.0002 的学习率也是一个很好的起点。

训练循环

以前,我们曾很奢侈地在模型上调用.fit(),让 Keras 处理将数据分成小批和为我们训练的痛苦过程。

不幸的是,因为我们需要为一个批量器对判别器和堆叠模型一起执行单独的更新,所以我们将不得不用老式的方式来做一些循环。 这就是过去一直做的事情,因此虽然可能需要做更多的工作,但它的确使我感到怀旧。 以下代码说明了训练技术:

num_examples = X_train.shape[0]
num_batches = int(num_examples / float(batch_size))
half_batch = int(batch_size / 2)

for epoch in range(epochs + 1):
  for batch in range(num_batches):
      # noise images for the batch
      noise = np.random.normal(0, 1, (half_batch, 100))
      fake_images = generator.predict(noise)
      fake_labels = np.zeros((half_batch, 1))
      # real images for batch
      idx = np.random.randint(0, X_train.shape[0], half_batch)
      real_images = X_train[idx]
      real_labels = np.ones((half_batch, 1))
      # Train the discriminator (real classified as ones and 
      generated as zeros)
      d_loss_real = discriminator.train_on_batch(real_images, 
        real_labels)
      d_loss_fake = discriminator.train_on_batch(fake_images, 
        fake_labels)
      d_loss = 0.5 * np.add(d_loss_real, d_loss_fake)
      noise = np.random.normal(0, 1, (batch_size, 100))
      # Train the generator
      g_loss = combined.train_on_batch(noise, np.ones((batch_size, 1)))
        # Plot the progress
      print("Epoch %d Batch %d/%d [D loss: %f, acc.: %.2f%%] [G loss: 
        %f]" %
      (epoch,batch, num_batches, d_loss[0], 100 * d_loss[1], g_loss))
        if batch % 50 == 0:
            save_imgs(generator, epoch, batch)

可以肯定,这里发生了很多事情。 和以前一样,让我们​​逐个细分。 首先,让我们看一下生成噪声向量的代码:

        noise = np.random.normal(0, 1, (half_batch, 100))
        fake_images = generator.predict(noise)
        fake_labels = np.zeros((half_batch, 1))

这段代码生成了一个噪声向量矩阵(我们之前将其称为z)并将其发送到生成器。 它返回了一组生成的图像,我称之为伪图像。 我们将使用它们来训练判别器,因此我们要使用的标签为 0,表示这些实际上是生成的图像。

注意,这里的形状是half_batch x 28 x 28 x 1half_batch正是您所想的。 我们将创建一半的生成图像,因为另一半将是真实数据,我们将在下一步进行组装。 要获取真实图像,我们将在X_train上生成一组随机索引,并将X_train的切片用作真实图像,如以下代码所示:

idx = np.random.randint(0, X_train.shape[0], half_batch)
real_images = X_train[idx]
real_labels = np.ones((half_batch, 1))

是的,在这种情况下,我们正在抽样更换。 它确实可以解决,但可能不是实现小批量训练的最佳方法。 但是,它可能是最简单,最常见的。

由于我们正在使用这些图像来训练判别器,并且由于它们是真实图像,因此我们将它们分配为1作为标签,而不是0。 现在我们已经组装了判别器训练集,我们将更新判别器。 还要注意,我们没有使用我们之前讨论的软标签。 那是因为我想让事情尽可能地容易理解。 幸运的是,在这种情况下,网络不需要它们。 我们将使用以下代码来训练判别器:

# Train the discriminator (real classified as ones and generated as zeros)
d_loss_real = discriminator.train_on_batch(real_images, real_labels)
d_loss_fake = discriminator.train_on_batch(fake_images, fake_labels)
d_loss = 0.5 * np.add(d_loss_real, d_loss_fake)

请注意,这里我使用的是判别符的train_on_batch()方法。 这是我第一次在本书中使用此方法。 train_on_batch()方法正好执行一轮正向和反向传播。 每次我们调用它时,它都会从模型的先前状态更新一次模型。

另请注意,我正在分别对真实图像和伪图像进行更新。 这是我先前在“生成器架构”部分中引用的 GAN 黑魔法 Git 上给出的建议。 尤其是在训练的早期阶段,当真实图像和伪图像来自完全不同的分布时,如果我们将两组数据放在同一更新中,则批量归一化将导致训练问题。

现在,判别器已经更新,是时候更新生成器了。 这是通过更新组合栈间接完成的,如以下代码所示:

noise = np.random.normal(0, 1, (batch_size, 100))
g_loss = combined.train_on_batch(noise, np.ones((batch_size, 1)))

为了更新组合模型,我们创建了一个新的噪声矩阵,这次它将与整个批次一样大。 我们将其用作栈的输入,这将使生成器生成图像,并使用判别器评估该图像。 最后,我们将使用1标签,因为我们想在实际图像和生成的图像之间反向传播误差。

最后,训练循环报告epoch/batch处的判别器和生成器损失,然后每epoch中的每 50 批,我们将使用save_imgs生成示例图像并将其保存到磁盘,如以下代码所示:

print("Epoch %d Batch %d/%d [D loss: %f, acc.: %.2f%%] [G loss: %f]" %
      (epoch,batch, num_batches, d_loss[0], 100 * d_loss[1], g_loss))

if batch % 50 == 0:
    save_imgs(generator, epoch, batch)

save_imgs函数使用生成器在运行时创建图像,因此我们可以看到工作的成果。 我们将使用以下代码来定义save_imgs

def save_imgs(generator, epoch, batch):
    r, c = 5, 5
    noise = np.random.normal(0, 1, (r * c, 100))
    gen_imgs = generator.predict(noise)
    gen_imgs = 0.5 * gen_imgs + 0.5

    fig, axs = plt.subplots(r, c)
    cnt = 0
    for i in range(r):
for j in range(c):
            axs[i, j].imshow(gen_imgs[cnt, :, :, 0], cmap='gray')
            axs[i, j].axis('off')
            cnt += 1
    fig.savefig("images/mnist_%d_%d.png" % (epoch, batch))
    plt.close()

它通过创建噪声矩阵并检索图像矩阵来仅使用生成器。 然后,使用matplotlib.pyplot将这些图像保存到5 x 5网格中的磁盘上。

模型评估

当您构建深层神经网络来创建图像时,好坏有点主观。 让我们看一下训练过程的一些示例,以便您可以亲自了解 GAN 如何开始学习如何生成 MNIST。

这是第一个周期的第一批网络。 显然,此时生成器对生成 MNIST 并不了解。 只是噪音,如下图所示:

但是只有 50 个批次,正在发生一些事情,如下面的图像所示:

在 200 个批次的周期 0 之后,我们几乎可以看到数字,如下图所示:

一个完整的周期后,这是我们的生成器。 我认为这些生成的数字看起来不错,而且我可以看到判别符可能会被它们欺骗。 在这一点上,我们可能会继续改善一点,但是随着计算机生成一些令人信服的 MNIST 数字,我们的 GAN 似乎已经发挥了作用,如下图所示:

尽管大多数代码是相同的,但在结束本章之前,让我们再看一个使用彩色图像的示例。

使用 Keras GAN 生成 CIFAR-10 图像

虽然网络架构在很大程度上保持不变,但我认为有必要向您展示一个使用彩色图像的示例,并在 Git 中提供示例,以便在想要将 GAN 应用于您的 GAN 时有一些起点。 自己的数据。

CIFAR-10是一个著名的数据集,包含 60,000 张32 x 32 x 3 RGB 彩色图像,分布在 10 个类别中。 这些类别是飞机,汽车,鸟类,猫,鹿,狗,青蛙,马,船和卡车。 希望以后看到生成的图像时,您可能会看到一些可以想象的东西,就像那些对象。

加载 CIFAR-10

加载数据集几乎完全相同,因为 Keras 还使用以下代码为CIFAR-10提供了一个加载器:

from keras.datasets import cifar10
def load_data():
 (X_train, y_train), (X_test, y_test) = cifar10.load_data()
 X_train = (X_train.astype(np.float32) - 127.5) / 127.5
 return X_train

创建生成器

生成器需要产生32 x 32 x 3图像。 这需要对我们的网络架构进行两项细微更改,您可以在此处看到它们:

input = Input(noise_shape)
x = Dense(128 * 8 * 8, activation="relu")(input)
x = Reshape((8, 8, 128))(x)
x = BatchNormalization(momentum=0.8)(x)
x = UpSampling2D()(x)
x = Conv2D(128, kernel_size=3, padding="same")(x)
x = Activation("relu")(x)
x = BatchNormalization(momentum=0.8)(x)
x = UpSampling2D()(x)
x = Conv2D(64, kernel_size=3, padding="same")(x)
x = Activation("relu")(x)
x = BatchNormalization(momentum=0.8)(x)
x = Conv2D(3, kernel_size=3, padding="same")(x)
out = Activation("tanh")(x)
model = Model(input, out)

由于我们需要在 32 处结束,并且我们将两次上采样,因此我们应该从 8 开始。这可以通过将密集层及其相应的重塑层从128 * 7 * 7更改为128 * 8 * 8来轻松实现。

由于我们的图像现在包含三个通道,因此最后的卷积层也需要包含三个通道,而不是一个。 这里的所有都是它的; 我们现在可以生成彩色图像!

创建判别器

判别符几乎完全不变。 输入层需要从28 x 28 x 1更改为32 x 32 x 3。 另外ZeroPadding2D可以毫无问题地删除,因为没有它的层算术就可以工作。

训练循环

训练循环保持不变,区别器构建调用除外,该调用需要与 CIFAR-10 图像大小相对应的新尺寸,如以下代码所示:

discriminator = build_discriminator(img_shape=(32, 32, 3))

当从一个数据集移动到另一个数据集时,通常会需要调整我们的学习率或网络架构。 幸运的是,在此示例中并非如此。

模型评估

CIFAR-10数据集当然更加复杂,并且网络具有更多的参数。 因此,事情将需要更长的时间。 这是在周期 0(批次 300)中我们的图像的样子:

我可能开始看到一些边缘,但是看起来并不像什么。 但是,如果我们等待几个周期,我们显然处在松鼠和怪异的鱼类地区。 我们可以看到一些东西正在成形,只是有些模糊,如下图所示:

下图显示了 12 个周期后的生成器:

我看到分辨率很低的鸟,鱼,甚至还有飞机和卡车。 当然,我们还有很长的路要走,但是我们的网络已经学会了创建图像,这非常令人兴奋。

总结

在本章中,我们研究了 GAN 以及如何将其用于生成新图像。 我们学习了一些很好地构建 GAN 的规则,甚至学习了模拟 MNIST 和 CIFAR-10 图像。 毫无疑问,您可能已经在媒体上看到了一些由 GANs 制作的惊人图像。 在阅读了本章并完成了这些示例之后,您将拥有执行相同操作的工具。 我希望您可以采纳这些想法并加以调整。 剩下的唯一限制是您自己的想象力,数据和 GPU 预算。

在这本书中,我们涵盖了深度学习的许多应用,从简单的回归到生成对抗网络。 我对这本书的最大希望是,它可以帮助您实际使用深度学习技术,而其中的许多技术已经存在于学术界和研究领域,而这超出了实践数据科学家或机器学习工程师的能力。 在此过程中,我希望我能就如何构建更好的深度神经网络以及何时使用深度网络(而不是更传统的模型)提供一些建议。 如果您在这 13 章中一直跟着我,请多多关照。

“我们都是手工艺品的学徒,没人能成为大师。”

——欧内斯特·海明威