深度学习秘籍书-全-

深度学习秘籍书(全)

原文:Deep Learning Cookbook

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

深度学习简史

当前深度学习热潮的根源出奇地早,可以追溯到上世纪 50 年代。虽然“智能机器”的模糊想法可以在小说和推测中找到更早的踪迹,但上世纪 50 年代和 60 年代见证了第一批基于生物神经元极为简化模型的“人工神经网络”的引入。在这些模型中,由弗兰克·罗森布拉特提出的感知器系统引起了特别的兴趣(和炒作)。连接到一个简单的“摄像头”电路,它可以学会区分不同类型的物体。尽管第一个版本是在 IBM 计算机上作为软件运行的,但随后的版本都是在纯硬件上完成的。

对多层感知器(MLP)模型的兴趣在 60 年代持续。这种情况在 1969 年马文·明斯基和西摩·帕普特出版了他们的书《感知器》(麻省理工学院出版社)后发生了变化。这本书中包含了一个证明,证明了线性感知器无法对非线性函数(XOR)的行为进行分类。尽管证明存在局限性(在书出版时存在非线性感知器模型,作者甚至有所提及),但其出版标志着对神经网络模型的资金投入急剧下降。直到 1980 年代,研究才得以恢复,新一代研究人员崛起。

随着计算能力的增加以及反向传播技术的发展(自上世纪 60 年代以来以各种形式存在,但直到 80 年代才得到普遍应用),引发了对神经网络的兴趣再次高涨。计算机不仅有能力训练更大的网络,而且我们还有技术可以高效地训练更深层次的网络。第一个卷积神经网络将这些见解与哺乳动物大脑的视觉识别模型相结合,首次实现了能够高效识别复杂图像(如手写数字和人脸)的网络。卷积网络通过将相同的“子网络”应用于图像的不同位置,并将这些结果聚合到更高级的特征中来实现这一点。在第十二章中,我们将更详细地探讨这一点。

在 90 年代和 21 世纪初,对神经网络的兴趣再次下降,因为更“可理解”的模型如支持向量机(SVM)和决策树变得流行。SVM 在当时许多数据源中被证明是优秀的分类器,特别是当与人工设计的特征结合时。在计算机视觉中,“特征工程”变得流行。这涉及构建图片中小元素的特征检测器,并手动将它们组合成识别更复杂形式的东西。后来发现,深度学习网络学会识别非常相似的特征,并学会以非常相似的方式将它们组合起来。在第十二章中,我们探索了这些网络的一些内部工作,并可视化它们学到的内容。

随着 2000 年代末期通用图形处理单元(GPU)上的通用编程的出现,神经网络架构能够在竞争中取得巨大进步。GPU 包含数千个小处理器,可以并行进行数万亿次操作。最初是为计算机游戏开发的,用于实时渲染复杂的 3D 场景,结果表明相同的硬件可以并行训练神经网络,实现速度提高 10 倍或更高的因素。

另一件事是互联网提供了非常大的训练集。在此之前,研究人员通常使用数千张图像来训练分类器,现在他们可以访问数千万甚至数亿张图像。结合更大的网络,神经网络有了展示自己的机会。这种主导地位在接下来的几年中持续存在,通过改进的技术和将神经网络应用于图像识别之外的领域,包括翻译、语音识别和图像合成。

为什么现在?

尽管计算能力的激增和更好的技术导致了对神经网络的兴趣增加,但我们也看到了在可用性方面取得了巨大进步。特别是,像 TensorFlow、Theano 和 Torch 这样的深度学习框架允许非专家构建复杂的神经网络来解决他们自己的机器学习问题。这使得以前需要数月甚至数年的手工编码和头撞桌子的努力(编写高效的 GPU 内核很难!)的任务变成了任何人都可以在一个下午(或者实际上几天)完成的事情。增加的可用性极大地增加了可以处理深度学习问题的研究人员数量。像 Keras 这样的框架具有更高级别的抽象,使得任何具有 Python 工作知识和一些工具的人都可以运行一些有趣的实验,正如本书所示。

“为什么现在”第二个重要因素是大型数据集已经对每个人都可用。是的,Facebook 和 Google 可能仍然拥有访问数十亿张图片、用户评论等的优势,但数百万项数据集可以从各种来源获得。在第一章中,我们将看看各种选择,在整本书中,每个章节的示例代码通常会在第一个配方中显示如何获取所需的训练数据。

与此同时,私营公司已经开始生产和收集数量级更多的数据,这使得整个深度学习领域突然变得商业上非常有趣。一个可以区分猫和狗的模型是很好的,但一个可以通过考虑所有历史销售数据使销售额增加 15%的模型对于一家公司来说可能是生死攸关的区别。

你需要了解什么?

如今,深度学习有各种平台、技术和编程语言可供选择。在本书中,所有示例都是用 Python 编写的,大部分代码依赖于出色的 Keras 框架。示例代码作为一套 Python 笔记本在 GitHub 上可用,每章一个。因此,具有以下工作知识将有所帮助:

Python

首选 Python 3,但 Python 2.7 也可以工作。我们使用各种辅助库,所有这些库都可以使用 pip 轻松安装。代码通常很简单,因此即使是相对新手也应该能够跟上操作。

Keras

机器学习的大部分繁重工作几乎完全由 Keras 完成。Keras 是 TensorFlow 或 Theano 的抽象,两者都是深度学习框架。Keras 使得以一种非常可读的方式定义神经网络变得容易。所有代码都经过了 TensorFlow 的测试,但也应该可以与 Theano 一起工作。

NumPy、SciPy、scikit-learn

这些有用且广泛的库在许多配方中随意使用。大多数情况下,从上下文中应该清楚发生了什么,但对它们进行快速了解也不会有害。

Jupyter Notebook

笔记本是分享代码的一种非常好的方式;它们允许在浏览器中查看代码、代码输出和注释的混合。

每个章节都有一个包含工作代码的对应笔记本。书中的代码经常省略了导入等细节,因此最好从 Git 获取代码并启动本地笔记本。首先检出代码并进入新目录:

git clone https://github.com/DOsinga/deep_learning_cookbook.git
cd deep_learning_cookbook

然后为项目设置一个虚拟环境:

python3 -m venv venv3
source venv3/bin/activate

并安装依赖项:

pip install -r requirements.txt

如果您有 GPU 并希望使用它,您需要卸载tensorflow并安装tensorflow-gpu,您可以使用 pip 轻松完成:

pip uninstall tensorflow
pip install tensorflow-gpu

你还需要设置一个兼容的 GPU 库,这可能有点麻烦。

最后,启动 IPython 笔记本服务器:

jupyter notebook

如果一切正常,这应该会自动打开一个包含每章笔记本概述的网页浏览器。随意尝试代码;您可以使用 Git 轻松撤消您所做的任何更改,如果您想返回到基线:

git checkout <*notebook_to_reset*>.ipynb

每章的第一部分列出了该章相关的笔记本,笔记本按章节编号,因此通常很容易找到您要找的内容。在笔记本文件夹中,您还会找到另外三个目录:

数据

包含各种笔记本所需的数据,主要是开放数据集的样本或您自己生成会太繁琐的内容。

生成的

用于存储中间数据。

动物园

每章包含一个子目录,其中保存了该章节的已保存模型。如果您没有时间实际训练模型,仍然可以通过从这里加载模型来运行模型。

本书结构

第一章提供了有关神经网络如何运作、从哪里获取数据以及如何预处理数据以便更容易消化的深入信息。第二章是关于遇到困难以及如何应对的内容。神经网络极其难以调试,本章中关于如何使其行为良好的技巧和窍门在阅读本书其余以项目为导向的配方时将会派上用场。如果您心急,可以跳过本章,等到遇到困难时再回来阅读。

第三章到第十五章围绕媒体进行分组,从文本处理开始,然后是图像处理,最后是第十五章中的音乐处理。每一章描述一个项目,分为各种配方。通常,一章将以数据获取配方开始,然后是几个配方,以实现章节目标,并包含一个数据可视化配方。

第十六章是关于在生产中使用模型。在笔记本中运行实验很好,但最终我们希望与实际用户分享我们的结果,并在真实服务器或移动设备上运行我们的模型。本章介绍了各种选择。

本书中使用的约定

本书中使用以下排版约定:

斜体

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序清单,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

等宽斜体

显示应替换为用户提供的值或由上下文确定的值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

附带代码

本书的每一章都附带一个或多个 Python 笔记本,其中包含章节中提到的示例代码。您可以阅读章节而不运行代码,但在阅读时使用笔记本会更有趣。代码可以在https://github.com/DOsinga/deep_learning_cookbook找到。

要使配方中的示例代码运行起来,请在 shell 中执行以下命令:

git clone https://github.com/DOsinga/deep_learning_cookbook.git
cd deep_learning_cookbook
python3 -m venv venv3
source venv3/bin/activate
pip install -r requirements.txt
jupyter notebook

本书旨在帮助您完成工作。所有附带笔记本中的代码都在宽松的 Apache 许可证 2.0 下许可。

我们感激,但不要求归属。归属通常包括标题、作者、出版商和 ISBN。例如:“Deep Learning Cookbook by Douwe Osinga (O’Reilly)。版权所有 2018 年 Douwe Osinga,978-1-491-99584-6。”

致谢

从学者在https://arxiv.org上(预)发布论文分享新想法,到黑客在 GitHub 上编写这些想法的代码,再到公共和私人机构发布数据集供任何人使用,机器学习领域充满了欢迎新人的人和组织,使入门变得如此容易。开放数据、开源和开放获取出版——如果没有机器学习分享文化,这本书就不会存在。

这本书中所呈现的想法是真实的,这本书中的代码更是如此。从头开始编写一个机器学习模型很难,因此笔记本中的几乎所有模型都基于其他地方的代码。这是完成任务的最佳方式——找到一个与您想要的类似的模型,并逐步更改它,确保每一步都仍然有效。

特别感谢我的朋友和这本书的合著者 Russell Power。除了帮助撰写这篇序言,第六章和第七章,他在检查书籍和相关代码的技术可靠性方面发挥了重要作用。此外,他作为许多想法的 sounding board,对于一些想法,其中一些已经被纳入了书中,他是一个宝贵的资产。

然后是我的可爱妻子,她是第一道防线,当章节逐渐形成时,她负责校对。她有一种神奇的能力,能够发现文本中的错误,即使这些错误不是她的母语,也不是她之前是专家的主题。

requirements.in文件列出了本书中使用的开源软件包。衷心感谢所有这些项目的所有贡献者。对于 Keras,感谢更加深刻,因为几乎所有的代码都是基于该框架的,并且经常借鉴其示例。

这本书的例子代码和想法来自这些软件包和许多博客文章。特别是:

[第二章,摆脱困境]

这一章节借鉴了 Slav Ivanov 的博客文章[“37 Reasons Why Your Neural Network Is Not Working”]。

[第三章,使用词嵌入计算文本相似性]

感谢 Google 发布其 Word2vec 模型。

Radim Řehůřek 的 Gensim 支持本章,部分代码基于这个伟大项目的示例。

[第五章,生成文本的风格]

这一章节在很大程度上借鉴了 Andrej Karpathy 的博客文章[“递归神经网络的不合理有效性”]。那篇博客文章重新激发了我对神经网络的兴趣。

可视化灵感来自 Motoki Wu 的[“递归神经网络的可视化”]。

[第六章,问题匹配]

这一章节在一定程度上受到了 Kaggle 上 Quora Question Pairs 挑战的启发。

[第八章,序列到序列映射]

示例代码是从 Keras 的一个示例中复制的,但应用在一个稍微不同的数据集上。

[第十一章,检测多个图像]

这一章基于 Yann Henon 的[keras_frcnn]。

[第十二章,图像风格]

这借鉴了[“How Convolutional Neural Networks See the World”]和当然也包括 Google 的[DeepDream]。

[第十三章,使用自动编码器生成图像]

代码和想法基于 Nicholas Normandin 的[条件变分自动编码器]。

[第十四章,使用深度网络生成图标]

Keras 的自动编码器训练代码基于 Qin Yongliang 的[DCGAN-Keras]。

[第十五章,音乐和深度学习]

这受到了 Heitor Guimarães 的[gtzan.keras]的启发。

第一章:工具和技术

在本章中,我们将介绍深度学习的常见工具和技术。这是一个很好的章节,可以通读一次以了解各种情况,并在需要时回头查看。

我们将首先概述本书涵盖的不同类型的神经网络。本书后面的大部分配方都侧重于完成任务,并仅简要讨论深度神经网络的架构。

接下来我们将讨论数据来源。像 Facebook 和 Google 这样的科技巨头可以访问大量数据来进行他们的深度学习研究,但我们也有足够的数据可以做一些有趣的事情。本书中的配方从各种来源获取数据。

下一部分是关于数据预处理的。这是一个经常被忽视的非常重要的领域。即使您拥有正确的网络设置和出色的数据,您仍然需要确保您拥有的数据以最佳方式呈现给网络。您希望尽可能地让网络学习它需要学习的东西,并且不要被数据中的其他无关紧要的部分分散注意力。

1.1 神经网络的类型

在本章和整本书中,我们将讨论网络模型。网络是神经网络的简称,指的是一堆连接的。您在一侧输入数据,转换后的数据从另一侧输出。每个层对通过它流动的数据执行数学操作,并具有一组可以修改的变量,这些变量确定了层的确切行为。这里的数据指的是张量,一个具有多个维度的向量(通常是二维或三维)。

对不同类型的层及其操作背后的数学进行全面讨论超出了本书的范围。最简单类型的层,即全连接层,将其输入作为矩阵,将该矩阵与另一个称为权重的矩阵相乘,并添加一个称为偏置的第三个矩阵。每个层后面都跟着一个激活函数,这是一个数学函数,将一个层的输出映射到下一层的输入。例如,一个简单的激活函数称为 ReLU 会传递所有正值,但将负值设为零。

从技术上讲,术语网络指的是架构,即各个层之间连接的方式,而模型是一个网络加上确定运行时行为的所有变量。训练模型会修改这些变量,使预测更好地符合预期输出。然而,在实践中,这两个术语经常可以互换使用。

实际上,“深度学习”和“神经网络”这些术语涵盖了各种模型。这些网络中的大多数将共享一些元素(例如,几乎所有分类网络都将使用特定形式的损失函数)。虽然模型空间多样,但我们可以将大多数模型分为一些广泛的类别。一些模型将使用来自多个类别的部分:例如,许多图像分类网络具有一个全连接部分“头部”来执行最终分类。

全连接网络

全连接网络是第一种被研究的网络类型,并且在 1980 年代后期之前占据了主导地位。在全连接网络中,每个输出单元都被计算为所有输入的加权和。术语“全连接”源于这种行为:每个输出都连接到每个输入。我们可以将其写成一个公式:

y i = j W ij x j

为简洁起见,大多数论文使用矩阵表示全连接网络。在这种情况下,我们将一个输入向量与权重矩阵W相乘以获得一个输出向量:

y = W x

由于矩阵乘法是一个线性操作,一个只包含矩阵乘法的网络将被限制在学习线性映射。为了使我们的网络更具表现力,我们在矩阵乘法后面加上一个非线性激活函数。这可以是任何可微函数,但有一些非常常见。双曲正切函数,或tanh函数,直到最近仍然是主要的激活函数类型,并且仍然可以在一些模型中找到:

双曲正切激活

双曲正切函数的困难在于当输入远离零时它非常“平坦”。这导致梯度很小,这意味着网络可能需要很长时间才能改变行为。最近,其他激活函数变得流行起来。其中最常见的是修正线性单元,或ReLU,激活函数:

Relu 激活

最后,许多网络在网络的最后一层使用sigmoid激活函数。这个函数总是输出一个介于 0 和 1 之间的值。这使得输出可以被视为概率:

Sigmoid 激活

矩阵乘法后跟激活函数被称为网络的一个。在一些网络中,完整的网络可以有超过 100 层,尽管全连接网络往往被限制在少数几个。如果我们正在解决一个分类问题(“这张图片中有什么类型的猫?”),网络的最后一层被称为分类层。它的输出数量总是与我们可以选择的类别数量相同。

网络中间的层被称为隐藏层,来自隐藏层的单个输出有时被称为隐藏单元。术语“隐藏”来自于这些单元不像输入或输出那样直接可见于我们模型的外部。这些层中的输出数量取决于模型:

网络层

虽然有一些关于如何选择隐藏层的数量和大小的经验法则,但除了试错之外,没有选择最佳设置的一般政策。

卷积网络

早期的研究使用全连接网络来尝试解决各种问题。但是当我们的输入是图像时,全连接网络可能是一个不好的选择。图像非常大:一个单独的 256×256 像素图像(分类常用分辨率)有 256×256×3 个输入(每个像素 3 种颜色)。如果这个模型有一个具有 1,000 个隐藏单元的单隐藏层,那么这一层将有近 2 亿个参数(可学习的值)!由于图像模型在分类时需要相当多的层,如果我们仅使用全连接层来实现它们,我们最终将得到数十亿个参数。

由于参数太多,我们几乎不可能避免过拟合我们的模型(过拟合在下一章节中将详细描述;它指的是当网络无法泛化,而只是记住结果时)。卷积神经网络(CNN)为我们提供了一种使用更少参数训练超人类图像分类器的方法。它们通过模仿动物和人类的视觉方式来实现这一点:

CNN 层 - 来自维基百科

CNN 中的基本操作是卷积。与将函数应用于整个输入图像不同,卷积一次在图像的一个小窗口上扫描。在每个位置,它应用一个(通常是矩阵乘法后跟激活函数,就像在全连接网络中一样)。单个核通常被称为滤波器。将核应用于整个图像的结果是一个新的、可能更小的图像。例如,常见的滤波器形状是(3, 3)。如果我们将 32 个这些滤波器应用于我们的输入图像,我们将需要 3 * 3 * 3(输入颜色)* 32 = 864 个参数——这比全连接网络节省了很多!

子采样

这种操作节省了参数的数量,但现在我们有了一个不同的问题。网络中的每一层只能一次“看”图像的一个 3×3 层:如果是这种情况,我们如何可能识别占据整个图像的对象?为了处理这个问题,典型的卷积网络在图像通过网络时使用子采样来减小图像的大小。用于子采样的两种常见机制是:

步幅卷积

在步幅卷积中,我们在滑动卷积滤波器时简单地跳过一个或多个像素。这会导致图像尺寸变小。例如,如果我们的输入图像是 256×256,并且我们跳过每隔一个像素,那么我们的输出图像将是 128×128(为简单起见,我们忽略了图像边缘的填充问题)。这种步幅下采样通常在生成器网络中找到(参见“对抗网络和自动编码器”)。

池化

许多网络不是在卷积过程中跳过像素,而是使用池化层来缩小它们的输入。池化层实际上是另一种形式的卷积,但我们不是将输入乘以矩阵,而是应用池化运算符。通常,池化使用最大平均运算符。最大池化从正在扫描的区域中的每个通道(颜色)中取最大值。平均池化则对该区域中的所有值进行平均。 (可以将其视为输入的简单模糊处理。)

一种思考子采样的方式是将其视为增加网络所做的抽象级别的一种方式。在最低级别上,我们的卷积检测小的局部特征。有许多不太深的特征。通过每个池化步骤,我们增加了抽象级别;特征的数量减少,但每个特征的深度增加。这个过程一直持续到最终得到非常少的具有高度抽象的特征,可以用于预测。

预测

在堆叠了多个卷积和池化层之后,CNN 在网络头部使用一个或两个全连接层来输出预测。

循环网络

循环神经网络(RNNs)在概念上类似于 CNNs,但在结构上有很大的不同。当我们有一个顺序输入时,经常应用循环网络。在处理文本或语音时,这些输入通常是常见的。与处理单个示例完全不同(就像我们可能使用 CNN 处理图像一样),对于顺序问题,我们可以一次只处理问题的一部分。例如,让我们考虑构建一个为我们写莎士比亚剧本的网络。我们的输入自然是莎士比亚的现有剧本:

Lear. Attend the lords of France and Burgundy, Gloucester.
Glou. I shall, my liege.

我们希望网络学会为我们预测剧本的下一个单词。为了做到这一点,它需要“记住”到目前为止看到的文本。循环网络为我们提供了这样的机制。它们还允许我们构建自然适用于不同长度输入(例如句子或语音块)的模型。最基本形式的 RNN 如下所示:

RNN 层-来自维基百科

从概念上讲,你可以将这个 RNN 看作是一个非常深的全连接网络,我们已经“展开”了。在这个概念模型中,网络的每一层接受两个输入,而不是我们习惯的一个:

RNN 层展开

回想一下,在我们最初的全连接网络中,我们有一个类似的矩阵乘法操作:

y = W x

将第二个输入添加到这个操作的最简单方法是将其简单地连接到我们的隐藏状态中:

h i d d e n i = W h i d d e n i-1 | x

在这种情况下,“|”代表连接。与全连接网络一样,我们可以对矩阵乘法的输出应用激活函数以获得我们的新状态:

h i d d e n i = f W h i d d e n i-1 | x

通过这种对我们 RNN 的解释,我们也可以很容易地理解它如何被训练:我们简单地将 RNN 视为我们展开的全连接网络,并正常训练它。这在文献中被称为时间反向传播(BPTT)。如果我们有非常长的输入,通常将它们分成较小的片段并独立训练每个片段。虽然这并不适用于每个问题,但通常是安全的,并且是一种广泛使用的技术。

消失的梯度和 LSTM

我们的朴素 RNN 不幸地在处理长输入序列时表现得比我们希望的要差。这是因为其结构使得它很可能遇到“消失的梯度”问题。消失的梯度是由于我们的展开网络非常深。每次经过激活函数时,都有可能导致一个小梯度通过(例如,ReLU 激活函数对于任何输入<0 都有零梯度)。一旦这发生在一个单元上,就无法通过该单元进一步传递更多的训练。这导致随着我们向下进行,训练信号变得越来越稀疏。观察到的结果是网络学习非常缓慢或根本没有学习。

为了对抗这一点,研究人员开发了一种构建 RNN 的替代机制。保持时间上展开我们的状态的基本模型,但是不再进行简单的矩阵乘法后跟激活函数,而是通过更复杂的方式将我们的状态向前传递(来源:维基百科):

LSTM 架构

长短期记忆网络(LSTM)用四个矩阵乘法替换了我们的单个矩阵乘法,并引入了与向量相乘的的概念。使 LSTM 比普通 RNN 更有效地学习的关键行为是,从最终预测到保留梯度的任何层始终存在一条路径。它是如何实现这一点的细节超出了本章的范围,但网上有几篇优秀的教程

对抗网络和自动编码器

对抗网络和自动编码器并没有像我们迄今讨论过的网络那样引入新的结构组件。相反,它们使用最适合问题的结构:例如,用于图像的对抗网络或自动编码器将使用卷积。它们的不同之处在于它们的训练方式。大多数普通网络被训练以从输入(一张图片)预测输出(这是一只猫吗?):

猫检测

相反,自动编码器被训练为输出它们所呈现的图像:

自动编码猫

为什么我们要这样做呢?如果我们网络中间的隐藏层包含比原始图像少(显着)的信息量,但原始图像可以从中重建,那么这将导致一种压缩形式:我们可以通过隐藏层的值来表示任何图像。一种思考方式是,我们将原始图像使用网络投影到一个抽象空间中。该空间中的每个点都可以转换回图像。

自动编码器已成功应用于小图像,但训练它们的机制无法扩展到更大的问题。从中间提取图像的空间实际上不够“密集”,许多点实际上并不代表连贯的图像。

我们将在第十三章中看到一个自动编码器网络的示例。

对抗网络是一种更近期的模型,可以生成逼真的图像。它们通过将问题分为两部分来工作:生成器网络和鉴别器网络。生成器网络接受一个小的随机种子并生成一幅图片(或文本)。鉴别器网络试图确定输入图像是“真实的”还是来自生成器网络。

当我们训练我们的对抗模型时,我们同时训练这两个网络:

对抗网络

我们从生成器网络中采样一些图像,并通过鉴别器网络进行馈送。生成器网络会受到奖励,因为它生成的图像可以欺骗鉴别器。鉴别器网络还必须正确识别真实图像(不能总是说图像是假的)。通过让网络相互竞争,这个过程可以导致生成器网络生成高质量的自然图像。第十四章展示了我们如何使用生成对抗网络生成图标。

结论

设计网络有许多方法,选择显然主要取决于网络的目的。设计一种新类型的网络属于研究领域,即使重新实现一篇论文中描述的网络类型也很困难。实际上,最容易的方法是找到一个示例,该示例朝着您想要的方向做一些事情,并逐步更改,直到它真正实现您想要的功能。

1.2 获取数据

近年来深度学习蓬勃发展的一个关键原因是数据的可用性大幅增加。二十年前,网络是用数千张图像进行训练的;而如今,像 Facebook 和 Google 这样的公司使用数十亿张图像。

毫无疑问,这些以及其他互联网巨头对用户信息的全部访问为他们在深度学习领域带来了自然优势。然而,互联网上有许多数据源很容易获取,稍加整理即可适用于许多训练目的。在本节中,我们将讨论最重要的数据源。对于每个数据源,我们将探讨如何获取数据,有哪些流行的库可用于解析,以及典型的用例是什么。我还会引导您查看使用此数据源的任何配方。

维基百科

英文维基百科不仅包括 500 多万篇文章,而且维基百科也以数百种语言提供,尽管深度和质量差异很大。基本的维基思想只支持链接作为编码结构的一种方式,但随着时间的推移,维基百科已经超越了这一点。

类别页面链接到具有相同属性或主题的页面,由于维基百科页面链接回它们的类别,我们可以有效地将它们用作标签。类别可以非常简单,比如“猫”,但有时在它们的名称中编码信息,有效地为页面分配(键,值)对,比如“1758 年描述的哺乳动物”。类别层次结构,就像维基百科上的许多内容一样,相当临时。此外,递归类别只能通过沿树向上行走来跟踪。

模板最初被设计为维基标记的片段,意味着它们可以自动复制(“转入”)到页面中。通过将模板的名称放在{{双大括号}}中添加它们。这使得可以保持不同页面的布局同步,例如,所有城市页面都有一个信息框,其中包含人口、位置和旗帜等属性,这些属性在页面上呈现一致。

这些模板具有参数(如人口)并可以被视为将结构化数据嵌入维基百科页面的一种方式。在第四章中,我们使用这个来提取一组电影,然后用来训练电影推荐系统。

维基数据

维基数据是维基百科的结构化数据表兄弟。它不太为人所知,也不太完整,但更加雄心勃勃。它旨在提供一个可以由任何人在公共领域许可下使用的数据共享源。因此,它是一个极好的免费数据来源。

所有维基数据都以(主题,谓词,对象)的形式存储为三元组。所有主题和谓词都有自己的维基数据条目,列出了适用于它们的所有谓词。对象可以是维基数据条目,也可以是字符串、数字或日期等文字。这种结构受到早期围绕语义网络的想法的启发。

维基数据有自己的查询语言,看起来像 SQL,具有一些有趣的扩展。例如:

SELECT ?item ?itemLabel ?pic
WHERE
{
	?item wdt:P31 wd:Q146 .
	OPTIONAL {
		?item wdt:P18 ?pic
	}
	SERVICE wikibase:label {
	  bd:serviceParam wikibase:language "[AUTO_LANGUAGE],en"
	}
}

将选择一系列猫和它们的图片。任何以问号开头的内容都是一个变量。wdt:P31,或属性 31,表示“是一个实例”,wd:Q146是家猫的类别。因此,第四行将任何猫的实例存储在item中。OPTIONAL { .. }子句然后尝试查找项目的图片,最后一个神奇的行尝试使用自动语言功能或英语找到项目的标签。

在第十章中,我们使用维基数据和维基百科的组合来获取类别的规范图像,以用作反向图像搜索引擎的基础。

开放街图

开放街图就像维基百科,但用于地图。维基百科的理念是,如果世界上每个人都在维基上写下他们所知道的一切,我们将拥有最好的百科全书,而开放街图(OSM)则基于这样一个理念,即如果每个人都在维基上记录他们所知道的道路,我们将拥有最好的地图系统。值得注意的是,这两个理念都取得了相当不错的成效。

尽管 OSM 的覆盖范围相当不均匀,从几乎没有覆盖的地区到与谷歌地图相媲美或超过的地方,但数据量庞大且全部免费提供,使其成为所有地理性质项目的重要资源。

OSM 可以免费下载为二进制格式或一个庞大的 XML 文件。整个世界有数十吉字节,但在互联网上有许多地方可以找到按国家或地区分类的 OSM 数据转储,如果我们想要从小处开始。

二进制和 XML 格式都具有相同的结构:地图由一系列具有纬度经度节点组成,然后是一系列将先前定义的节点组合成较大结构的路径。最后,有关系将之前看到的任何内容(节点、路径或关系)组合成超级结构。

节点用于表示地图上的点,包括单个特征,以及定义路径的形状。路径用于简单的形状,如建筑物和道路段。最后,关系用于包含多个形状或非常大的事物,如海岸线或边界。

书中的后续部分,我们将研究一个模型,该模型接收卫星图像和渲染地图,并尝试学习自动识别道路。这些配方使用的实际数据并非专门来自 OSM,但 OSM 在深度学习中用于这类事情。例如,"Images to OSM"项目展示了如何训练网络以从卫星图像中提取体育场形状,以改进 OSM 本身。

Twitter

作为一个社交网络,Twitter 可能无法与更大的 Facebook 竞争,但作为训练深度学习模型的文本来源,它要好得多。Twitter 的 API 非常全面,可以支持各种应用。对于新手机器学习黑客来说,流式 API 可能是最有趣的。

Twitter 提供的所谓的 Firehose API 将所有推文直接流向客户端。可以想象,这是一大量数据。此外,Twitter 对此收费。不太为人所知的是,免费的 Twitter API 提供 Firehose API 的抽样版本。该 API 仅返回所有推文的 1%,但对于许多文本处理应用来说已经足够了。

推文大小有限,并附带一组有趣的元信息,如作者、时间戳、有时位置,当然还有标签、图片和 URL。在第七章中,我们研究使用此 API 构建分类器来基于文本预测表情符号。我们利用流式 API,仅保留包含一个表情符号的推文。获取一个体面的训练集需要几个小时,但如果你有一台带有稳定互联网连接的计算机,让其运行几天不应该是问题。

Twitter 是情感分析实验的热门数据来源,可以说预测表情符号是其变体,但针对语言检测、位置消歧和命名实体识别的模型也已成功在 Twitter 数据上训练。

古腾堡计划

早在 Google Books 之前,事实上,早在 Google 甚至互联网问世之前,1971 年,古腾堡计划启动,旨在数字化所有图书。它包含超过 5 万部作品的全文,不仅包括小说、诗歌、短篇小说和戏剧,还包括烹饪书、参考书和期刊。大部分作品属于公共领域,可以从网站上免费下载。

这是一个大量文本的便捷格式,如果你不介意大部分文本有点陈旧(因为它们不再受版权保护),那么这是一个非常好的用于文本处理实验的数据来源。在第五章中,我们使用古腾堡计划获取莎士比亚的作品集作为生成更多类似莎士比亚文本的基础。如果你有 Python 库可用,只需这一行代码:

shakespeare = strip_headers(load_etext(100))

通过古腾堡计划提供的材料大多是英文,尽管少量作品提供其他语言版本。该项目最初是纯 ASCII,但后来发展到支持多种字符编码,因此如果下载非英文文本,需要确保选择正确的编码方式——世界上并非所有内容都是 UTF-8。在第八章中,我们从古腾堡计划检索的一组书籍中提取所有对话,然后训练一个聊天机器人来模仿这些对话。

Flickr

Flickr是一个自 2004 年以来运营的照片分享网站。最初,它是一个名为Game Neverending的大型多人在线游戏的副产品。当游戏未能独立成为业务时,公司的创始人意识到公司的照片分享部分正在起飞,因此他们执行了所谓的转变,完全改变了公司的主要重点。一年后,Flickr 被雅虎收购。

在众多照片分享网站中,Flickr 因为几个原因而成为深度学习实验的有用图像来源。

一点是,Flickr 已经做了很长时间,并收集了数十亿张图像。这可能与人们在一个月内上传到 Facebook 的图像数量相比相形见绌,但由于用户将他们为公众消费而感到自豪的照片上传到 Flickr,Flickr 的图像平均质量更高,更具一般兴趣。

第二个原因是许可证。Flickr 上的用户为他们的照片选择许可证,许多人选择某种形式的知识共享许可证,允许在不征得许可的情况下重新使用。虽然如果你只是通过最新的巧妙算法运行一堆照片,并且只对最终结果感兴趣,通常不需要这个,但如果你的项目最终需要重新发布原始或修改后的图像,这是非常重要的。Flickr 使这成为可能。

最后,也可能是最重要的优势是 Flickr 相对于大多数竞争对手具有 API。就像 Twitter 的 API 一样,它是一个经过深思熟虑的 REST 风格 API,使得可以轻松地以自动方式执行与网站相同的任何操作。就像 Twitter 一样,API 有很好的 Python 绑定,这使得开始实验变得更加容易。你只需要正确的库和一个 Flickr API 密钥。

对于本书相关的 API 的主要特点是搜索图像和获取图像。搜索功能非常灵活,模仿了主要网站的大多数搜索选项,尽管一些高级过滤器遗憾地缺失。获取图像可以为多种尺寸完成。通常最好先用较小版本的图像开始,然后再放大。

在第九章中,我们使用 Flickr API 获取了两组图像,一组是狗的图像,另一组是猫的图像,并训练了一个分类器来学习两者之间的区别。

互联网档案馆

互联网档案馆的宣布使命是提供“对所有知识的普遍访问”。该项目可能最著名的是其 Wayback Machine,这是一个 Web 界面,让用户随时间查看网页。它包含超过 3000 亿个捕获,追溯到 2001 年,项目称之为三维网络索引。

但互联网档案馆远远不止 Wayback Machine,它包括一系列文档、媒体和数据集,涵盖从过期的书籍到 NASA 图像再到 CD 封面艺术品到音频和视频材料的一切。这些都值得浏览,通常会激发新项目的灵感。

一个有趣的例子是截至 2015 年的所有 Reddit 评论集,共有超过 5000 万条记录。这起初是一个 Reddit 用户的项目,他耐心地使用 Reddit API 下载了所有评论,然后在 Reddit 上宣布了这一消息。当提出要将其托管在何处时,互联网档案馆成为一个不错的选择(尽管相同的数据也可以在 Google 的 BigQuery 上找到,以进行更即时的分析)。

我们在本书中使用的一个示例是Stack Exchange 问题集。Stack Exchange 一直以来都是根据知识共享许可证授权的,因此没有什么能阻止我们自己下载这些集合,但是从互联网档案馆获取它们要容易得多。在本书中,我们使用这个数据集来训练一个模型,以匹配问题和答案(参见第六章)。

爬取

如果您需要项目中的特定内容,那么您要获取的数据很可能无法通过公共 API 访问。即使有公共 API,它可能会被限制到无法使用的程度。您喜欢的体育比赛的历史结果很难获得。您当地的报纸可能有在线存档,但可能没有 API 或数据转储。Instagram 有一个不错的 API,但最近对服务条款的更改使得难以使用它来获取大量的训练数据。

在这些情况下,您总是可以使用爬取,或者,如果您想听起来更加正式,可以使用爬取。在最简单的情况下,您只是想在本地系统上获取网站的副本,并且对该网站的结构或 URL 格式没有先验知识。在这种情况下,您只需从网站的根开始,获取其网页内容,从该网页内容中提取所有链接,并对每个链接执行相同的操作,直到找不到新链接为止。这也是谷歌的做法,只是规模更大。Scrapy是这种类型的框架的有用工具。

有时存在明显的层次结构,比如一个旅行网站有国家页面,这些国家的地区,这些地区的城市,最后是这些城市的景点。在这种情况下,编写一个更有针对性的爬虫可能更有用,依次通过各层次的层次结构工作,直到获取所有景点。

其他时候,有一个内部 API 可以利用。许多内容导向的网站将加载整体布局,然后使用 JSON 回调到 Web 服务器获取实际数据,并将其动态插入到模板中。这样就可以轻松支持无限滚动和搜索。从服务器返回的 JSON 通常很容易理解,传递给服务器的参数也很容易理解。Chrome 扩展程序Request Maker显示页面发出的所有请求,是查看是否有任何有用信息传输的好方法。

然后有一些不希望被爬取的网站。谷歌可能已经建立了一个基于爬取世界的帝国,但是它的许多服务非常聪明地检测到爬取的迹象,并会阻止您,可能会阻止从您的 IP 地址发出请求的任何人,直到您完成验证码。您可以尝试限制速率和用户代理,但是在某个时候,您可能不得不使用浏览器进行爬取。

WebDriver 是一个为测试网站而开发的框架,通过操纵浏览器可以在这些情况下非常有帮助。页面的获取是通过您选择的浏览器完成的,因此对于 Web 服务器来说,一切都似乎是真实的。然后,您可以使用控制脚本“点击”链接以转到下一页并检查结果。考虑在代码中添加延迟,使其看起来像是一个人在浏览网站,然后您就可以开始了。

第十章中的代码使用爬取技术从维基百科获取图像。有一个 URL 方案可以从维基百科 ID 转到相应的图像,但并不总是奏效。在这种情况下,我们获取包含图像的页面,并跟随链接图形,直到找到实际图像。

其他选项

有许多获取数据的方法。ProgrammableWeb列出了超过 18,000 个公共 API(尽管其中一些处于失修状态)。以下是值得强调的三个:

Common Crawl

如果网站规模不是很大,那么爬取一个网站是可行的。但是如果您想爬取互联网的所有主要页面呢?Common Crawl 每月进行一次爬取,每次获取大约 20 亿个网页,格式易于处理。AWS 将其作为公共数据集提供,因此如果您恰好在该平台上运行,这是在互联网上运行作业的简便方法。

Facebook

多年来,Facebook API 从一个构建在 Facebook 数据之上的应用程序的非常有用的资源,逐渐转变为一个构建使 Facebook 数据更好的应用程序的资源。尽管这从 Facebook 的角度来看是可以理解的,但作为数据勘探者,人们常常想知道它可能公开的数据。尽管如此,Facebook API 仍然是一个有用的资源——尤其是在 OSM 编辑不均匀的情况下,Places API 是一个有用的资源。

美国政府

美国政府在各个层面发布了大量数据,所有这些数据都是免费可访问的。例如,人口普查数据提供了关于美国人口的详细信息,而 Data.gov 则提供了一个包含各种不同数据集的门户网站。此外,各个州和城市都有值得关注的资源。

预处理数据

深度神经网络在数据中找到有助于学习预测数据标签的模式方面表现出色。这也意味着我们必须小心处理给予它们的数据;数据中的任何与我们问题无关的模式都可能导致网络学习错误的内容。通过正确预处理数据,我们可以确保为我们的网络尽可能简化事情。

获取平衡的训练集

一个传闻故事讲述了美国军队曾经训练过一个神经网络,用于区分伪装坦克和普通森林——这是在自动分析卫星数据时非常有用的技能。乍一看,他们做了一切正确。有一天,他们在一片有伪装坦克的森林上空飞行,并拍摄了照片,另一天,当没有坦克时,他们做了同样的事情,确保拍摄的场景相似但不完全相同。他们将数据分成训练集和测试集,并让网络进行训练。

网络训练良好,开始获得良好的结果。然而,当研究人员将其送到野外测试时,人们认为这是一个笑话。预测似乎完全随机。经过一番调查,发现输入数据存在问题。所有包含坦克的图片都是在晴天拍摄的,而只有森林的图片恰好是在多云天气下拍摄的。因此,尽管研究人员认为他们的网络已经学会区分坦克和非坦克,但实际上他们训练的是一个观察天气的网络。

预处理数据的关键在于确保网络捕捉到我们希望捕捉到的信号,并且不会被无关的事物分散注意力。这里的第一步是确保我们实际上拥有正确的输入数据。理想情况下,数据应尽可能接近真实世界的情况。

确保数据中的信号是我们试图学习的信号似乎是显而易见的,但很容易出错。获取数据很困难,每个来源都有其独特之处。

当我们发现输入数据被污染时,我们可以做一些事情。最好的方法当然是重新平衡数据。因此,在坦克与森林的例子中,我们将尝试在所有类型的天气条件下获取两种情况的图片。(当您考虑到,即使所有原始图片都是在晴天拍摄的,训练集仍然会是次优的——平衡的集合应包含所有类型的天气条件。)

第二个选择是丢弃一些数据,使数据集更加平衡。也许确实有一些坦克的图片是在多云天气下拍摄的,但数量不够,所以我们可以丢弃一些晴天的图片。然而,这显然会减少训练集的大小,并且可能不是一个选项。(数据增强,在“图像预处理”中讨论,可能会有所帮助。)

第三个选择是尝试修复输入数据,比如使用一个照片滤镜使天气条件看起来更相似。然而,这很棘手,很容易导致网络可能检测到其他或更多的伪影。

创建数据批次

神经网络以批量(输入/输出对的集合)消耗数据。确保这些批次被适当随机化是很重要的。想象一下,我们有一组图片,前一半都是猫,后一半是狗。如果不进行洗牌,网络将无法从这个数据集中学到任何东西:几乎所有的批次要么只包含猫,要么只包含狗。如果我们使用 Keras,并且数据完全存储在内存中,这很容易通过使用fit方法来实现,因为它会为我们进行洗牌:

char_cnn_model.fit(training_data, training_labels, epochs=20, batch_size=128)

这将从training_datatraining_labels集合中随机创建大小为 128 的批次。Keras 会负责适当的随机化。只要我们的数据在内存中,这通常是一个不错的选择。

注意

在某些情况下,我们可能希望一次使用一个批次调用fit,在这种情况下,我们确实需要确保事物被适当洗牌。numpy.random.shuffle可以很好地完成这项任务,尽管我们必须小心地同时洗牌数据和标签。

我们并不总是将所有数据存储在内存中。有时数据量太大,或者需要实时处理,无法以理想格式提供。在这种情况下,我们使用fit_generator

char_cnn_model.fit_generator(
    data_generator(train_tweets, batch_size=BATCH_SIZE),
    epochs=20
)

在这里,data_generator是一个生成器,产生数据批次。生成器必须确保数据被适当随机化。如果数据是从文件中读取的,那么洗牌实际上并不是一个选项。如果文件来自 SSD,并且记录都是相同大小,我们可以通过在文件内部随机寻找来进行洗牌。如果不是这种情况,文件有某种排序,我们可以通过在同一个文件中具有多个文件句柄,每个句柄位于不同位置,来增加随机性。

当设置一个生成器来实时生成批次时,我们还需要注意保持事物适当随机化。例如,在第四章中,我们通过在维基百科文章上进行训练来构建一个电影推荐系统,使用从电影页面到其他页面的链接作为训练单元。生成这些(FromPage,ToPage)对的最简单方法是随机选择一个 FromPage,然后从 FromPage 上找到的所有链接中随机选择一个 ToPage。

当然,这种方法有效,但它会更频繁地选择链接较少的页面,而不是应该的频率。一个页面上只有一个链接的 FromPage 在第一步被选中的机会与一个页面上有一百个链接的页面相同。然而,在第二步中,那一个链接肯定会被选中,而来自有一百个链接的页面的任何链接只有很小的选择机会。

训练、测试和验证数据

在我们设置好干净、规范化的数据并进入实际训练阶段之前,我们需要将数据分成训练集、测试集和可能的验证集。和许多事情一样,我们这样做的原因与过拟合有关。网络几乎总是会记住一点训练数据,而不是学习泛化。通过将一小部分数据分离出来作为我们不用于训练的测试集,我们可以衡量这种情况发生的程度;每个时代结束后,我们都会在训练集和测试集上测量准确性,只要这两个数字不相差太多,我们就没问题。

如果我们的数据在内存中,我们可以使用sklearn中的train_test_split来将数据整齐地分成训练集和测试集:

data_train, data_test, label_train, label_test = train_test_split(
    data, labels, test_size=0.33, random_state=42)

这将创建一个包含 33%数据的测试集。random_state变量用于随机种子,这可以保证如果我们两次运行相同的程序,我们会得到相同的结果。

当使用生成器向我们的网络提供输入时,我们需要自己进行拆分。一个一般但不是非常高效的方法是使用类似以下的东西:

def train_or_test(gen, train=True):
    for i, x in enumerate(gen):
        if (i % 4 == 0) != train:
            yield x

trainFalse时,这将产生来自生成器gen的每四个元素。当它为True时,它将产生其余的元素。

有时会从训练数据中分离出第三组,称为验证集。这里的命名有些混淆;当只有两组时,测试集有时也被称为验证集(或留出集)。在有训练、验证和测试集的情况下,验证集用于在调整模型时测量性能。测试集只用于在所有调整完成且不再对代码进行更改时使用。

保留这第三组的原因是为了防止我们手动过拟合。复杂的神经网络可能有非常多的调整选项或超参数。找到这些超参数的正确值是一个优化问题,也可能会受到过拟合的影响。我们不断调整这些参数,直到验证集上的性能不再增加。通过拥有一个在调整过程中未使用的测试集,我们可以确保我们没有无意中为验证集优化我们的超参数。

文本预处理

许多神经网络问题涉及文本处理。在这些情况下,预处理输入文本涉及将输入文本映射到可以馈送到网络中的向量或矩阵。

通常,第一步是将文本分成单元。有两种常见的方法可以做到这一点:按字符或按单词。

将文本分成一系列单个字符是直接的,并且给我们一个可预测的不同标记数量。如果我们所有的文本都是基于音素的脚本,那么不同标记的数量是相当受限的。

将文本分成单词是一种更复杂的标记化策略,特别是在不指示单词开始和结束的脚本中。此外,我们最终得到的不同标记的数量没有明显的上限。许多文本处理工具包都有一个“标记化”功能,通常还允许去除重音并可选择将所有标记转换为小写。

一个称为词干提取的过程,其中我们将每个单词转换为其根形式(通过删除任何与语法相关的修改),可以帮助,特别是对于比英语更注重语法的语言。在第八章中,我们将遇到一种子词标记化策略,将复杂的单词分解为子标记,从而保证不同标记的数量有一个特定的上限。

一旦我们将文本分成标记,我们需要对其进行向量化。最简单的方法是称为一热编码。在这里,我们为每个唯一的标记分配一个整数i,从 0 到标记数量,然后将每个标记表示为一个只包含 0 的向量,除了第i个条目,其中包含 1。在 Python 代码中,这将是:

idx_to_token = list(set(tokens))
token_to_idx = {token: idx for idx, token in enumerate(idx_to_token)}
one_hot = lambda token: [1 if i == token_to_idx[token] else 0
                         for i in range(len(idx_to_token))]
encoded = np.asarray([one_hot(token) for token in tokens])

这应该让我们得到一个准备好供消费的大型二维数组。

一热编码适用于在字符级别处理文本时。它也适用于单词级别处理,尽管对于词汇量大的文本,可能会变得难以处理。有两种流行的编码策略可以解决这个问题。

第一种方法是将文档视为“词袋”。在这里,我们不关心单词的顺序,只关心某个单词是否存在。然后,我们可以将文档表示为一个向量,其中每个唯一标记都有一个条目。在最简单的方案中,如果单词在文档中存在,则我们将放置一个 1,如果不存在则放置一个 0。

由于英语中出现频率最高的 100 个单词占所有文本的一半左右,它们对于文本分类任务并不是很有用;几乎所有文档都会包含它们,所以在我们的向量中包含这些词并没有太大帮助。一个常见的策略是从我们的词袋中删除它们,这样网络就可以专注于那些真正有影响的词语。

术语频率-逆文档频率,或 tf-idf,是这一概念的一个更复杂的版本。我们不是在文档中存储一个标记是否存在,而是存储该术语在文档中的相对频率,与该术语在整个文档语料库中出现的频率相比。这里的直觉是,一个不太常见的标记在文档中出现比一个一直出现的标记更有意义。Scikit-learn 提供了自动计算这一概念的方法。

处理单词级别的编码的第二种方法是使用嵌入。第三章讲解了嵌入并提供了一个很好的理解方式。通过嵌入,我们将一个特定大小的向量(通常长度为 50 到 300)与每个标记关联起来。当我们输入一个表示为标记 ID 序列的文档时,嵌入层将自动查找相应的嵌入向量并输出一个二维数组。

嵌入层将学习每个术语的正确权重,就像神经网络中的任何一层一样。这通常需要大量的学习,无论是在处理方面还是所需的数据量方面。不过,嵌入的一个好处是可以下载预训练集,并且我们可以使用这些预训练集来初始化我们的嵌入层。第七章有一个这种方法的很好例子。

图像预处理

深度神经网络在处理图像方面非常有效,可以用于从视频中检测猫到将不同艺术家的风格应用于自拍等各种任务。然而,与文本一样,正确预处理输入图像是至关重要的。

第一步是标准化。许多网络只能在特定大小上运行,因此第一步是将图像调整/裁剪到目标大小。通常使用中心裁剪和直接调整大小,但有时结合使用效果更好,以保留更多图像的同时保持调整大小的失真在一定范围内。

为了使颜色标准化,对于每个像素,我们通常减去平均值并除以标准差。这确保所有值的平均值围绕 0 中心,并且近 70%的值在舒适的[-1, 1]范围内。这里的一个新发展是使用批量标准化;而不是事先对所有数据进行标准化,这会减去批次的平均值并除以标准差。这会带来更好的结果,并且可以作为网络的一部分。

数据增强是一种通过添加训练图像的变化来增加训练数据量的策略。如果我们在训练数据中添加水平翻转的图像版本,那么我们就可以将训练数据翻倍——镜像猫仍然是一只猫。从另一个角度来看,我们所做的是告诉网络可以忽略翻转。如果我们所有的猫图片都是朝一个方向看的,我们的网络可能会学习到这是猫的一部分;添加翻转会撤销这一点。

Keras 有一个方便的ImageDataGenerator类,您可以配置它来生成各种图像变化,包括旋转、平移、颜色调整和放大。然后,您可以将其用作模型的fit_generator方法的数据生成器:

datagen = ImageDataGenerator(
    rotation_range=20,
    horizontal_flip=True)

model.fit_generator(datagen.flow(x_train, y_train, batch_size=32),
                    steps_per_epoch=len(x_train) / 32, epochs=epochs)

结论

在训练深度学习模型之前,数据的预处理是一个重要的步骤。所有这些中的一个共同点是,我们希望网络尽可能容易地学习正确的内容,而不会被输入的无关特征所困扰。获取平衡的训练集,创建随机化的训练批次,以及规范化数据的各种方式都是其中的重要部分。

第二章:摆脱困境

深度学习模型经常被视为黑匣子;我们在一端输入数据,另一端输出答案,而我们不必太关心网络是如何学习的。虽然深度神经网络确实擅长从复杂输入数据中提取信号,但将这些网络视为黑匣子的反面是,当事情陷入困境时并不总是清楚该怎么办。

我们在这里讨论的技术中的一个共同主题是,我们希望网络泛化而不是记忆。值得思考的问题是为什么神经网络总体化。本书中描述并用于生产的一些模型包含数百万个参数,这些参数允许网络记忆大量示例的输入。然而,如果一切顺利,它不会这样做,而是会发展出关于其输入的泛化规则。

如果事情不顺利,您可以尝试本章描述的技术。我们将首先看看如何知道我们陷入困境。然后,我们将看看各种方法,我们可以预处理输入数据,使网络更容易处理。

2.1 确定您陷入困境

问题

当您的网络陷入困境时,您如何知道?

解决方案

在网络训练时查看各种指标。

神经网络出现问题的最常见迹象是网络没有学到任何东西,或者学到了错误的东西。当我们设置网络时,我们指定损失函数。这决定了网络试图优化的内容。在训练过程中,损失会持续打印。如果这个值在几次迭代后没有下降,我们就有麻烦了。网络没有学到任何东西,根据自己的进展概念来衡量。

另一个方便的指标是准确率。这显示网络预测正确答案的输入百分比。随着损失的降低,准确率应该提高。如果准确率没有提高,即使损失在减少,那么我们的网络正在学习某些东西,但不是我们希望的东西。准确率可能需要一段时间才能提高。一个复杂的视觉网络在学习的同时可能需要很长时间才能正确地获取任何标签,因此在过早放弃之前,请考虑这一点。

要寻找的第三个问题,这可能是陷入困境的最常见方式,是过拟合。过拟合时,我们看到损失减少,准确率增加,但我们在测试集上看到的准确率并没有跟上。假设我们有一个测试集,并已将其添加到要跟踪的指标中,我们可以在每个时代结束时看到这一点。通常,测试准确率一开始会随着训练集的准确率增加,但随后会出现差距,而且测试准确率甚至有时会开始下降,而训练准确率则继续增加。

这里发生的情况是,我们的网络正在学习输入和预期输出之间的直接映射,而不是学习泛化。只要它看到之前见过的输入,一切都很顺利。但当面对测试集中的样本时,它开始失败。

讨论

关注训练过程中显示的指标是跟踪学习过程进展的好方法。我们在这里讨论的三个指标是最重要的,但像 Keras 这样的框架提供了更多选项,也可以自己构建它们。

2.2 解决运行时错误

问题

当您的网络抱怨不兼容的形状时,您应该怎么办?

解决方案

查看网络结构,并尝试不同的数字。

Keras 是对 TensorFlow 或 Theano 等复杂框架的很好的抽象,但是像任何抽象一样,这是有代价的。当一切顺利时,我们清晰定义的模型可以在 TensorFlow 或 Theano 之上愉快地运行。但是,当出现问题时,我们会从底层框架的深处收到错误。如果不了解这些框架的复杂性,这些错误很难理解,而我们使用 Keras 的初衷就是要避免这种情况。

有两件事可以帮助我们,而不需要深入研究。第一件事是打印网络的结构。假设我们有一个简单的模型,接受五个变量并分类为八个类别:

data_in = Input(name='input', shape=(5,))
fc = Dense(12, activation='relu')(data_in)
data_out = Dense(8, activation='sigmoid')(fc)
model = Model(inputs=[data_in], outputs=[data_out])
model.compile(loss='binary_crossentropy',
              optimizer='adam',
              metrics=['accuracy'])

现在我们可以使用以下方式检查模型:

model.summary()
Layer (type)                 Output Shape              Param #
=================================================================
input (InputLayer)           (None, 5)                 0
_________________________________________________________________
dense_5 (Dense)              (None, 12)                72
_________________________________________________________________
dense_6 (Dense)              (None, 8)                 104
=================================================================
Total params: 176
Trainable params: 176
Non-trainable params: 0

现在,如果我们遇到一个关于不兼容形状的运行时错误,可能不是我们担心的形式:

InvalidArgumentError: Incompatible shapes: X vs. Y

我们知道内部肯定有问题,这不容易通过堆栈跟踪来追踪。不过,还有一些其他尝试的方法。

首先,看看是否有任何形状是XY。如果是这样,那可能就是问题所在。知道这一点已经完成了一半的工作——当然还有另一半。另一件需要注意的事情是层的名称。它们经常出现在错误消息中,有时以扭曲的形式出现。Keras 会自动为匿名层分配名称,因此查看摘要在这方面也很有用。如果需要,我们可以为自己分配名称,就像在这里显示的示例中的输入层一样。

如果我们找不到运行时错误提到的形状或名称,我们可以在深入研究之前尝试其他方法(或在 StackOverflow 上发布):使用不同的数字。

神经网络包含大量的超参数,如各个层的大小。这些通常是因为它们看起来合理,与做类似事情的其他网络相似。但是它们的实际值有些是任意的。在我们的示例中,隐藏层是否真的需要 12 个单元?11 个会差很多吗,13 会导致过拟合吗?

我们倾向于选择感觉好的数字,通常是 2 的幂。因此,如果您遇到运行时错误,请更改这些数字,看看它对错误消息有何影响。如果错误消息仍然相同,则您更改的变量与此无关。但是一旦开始更改,您就知道已经接近相关内容了。

这可能有点微妙。例如,有些网络要求所有批次具有相同的大小。如果您的数据不能被批次大小整除,那么最后一个批次将太小,您将收到类似以下错误的错误消息:

Incompatible shapes: [X,784] vs. [Y,784]

这里X将是批量大小,Y将是您最后一个不完整批次的大小。您可能会认出X作为批量大小,但是很难确定Y的位置。但是,如果更改批量大小,Y也会更改,这为您提供了一个查找位置的提示。

讨论

理解由 Keras 抽象的框架报告的错误基本上是棘手的。抽象被打破,我们突然看到了机器的内部。这个配方中的技术允许您通过发现错误中的形状和名称来推迟查看这些细节,如果失败,则尝试使用数字并查看变化。

2.3 检查中间结果

问题

您的网络很快达到了一个令人满意的准确度水平,但拒绝超越这一水平。

解决方案

检查是否没有陷入明显的局部最大值。

这种情况可能发生在一个标签比其他任何标签都更常见的情况下,您的网络很快学会了总是预测这种结果会得到不错的结果。验证这一点并不难;只需向网络提供一些输入样本并查看输出。如果所有输出都相同,您就陷入了这种情况。

本章中的一些配方提供了如何解决这个问题的建议。或者,你可以调整数据的分布。如果你的例子中有 95%是狗,只有 5%是猫,那么网络可能看不到足够的猫。通过人为改变分布,比如说,65%/35%,可以让网络更容易一些。

当然,这也不是没有风险的。网络现在可能有更多机会了解猫,但它也会学习错误的基础分布,或先验。这意味着在疑惑的情况下,网络现在更有可能选择“猫”作为答案,尽管一切相等,“狗”更有可能。

讨论

查看网络输出标签的分布对于一小部分输入样本是了解实际操作的简单方法,但往往被忽视。调整分布是尝试使网络摆脱僵局的一种方式,如果网络只关注顶部答案,你可能应该考虑其他技术。

当网络不快速收敛时,还有其他要注意的输出情况;NaN 的出现表明梯度爆炸,如果网络的输出似乎被截断,无法达到正确的值,那么你可能在最终层上使用了不正确的激活函数。

2.4 选择正确的激活函数(用于最终层)

问题

当情况不对时,如何为最终层选择正确的激活函数?

解决方案

确保激活函数与网络的意图相对应。

开始深度学习的一个好方法是在网上找到一个示例,并逐步修改直到它达到你想要的效果。然而,如果示例网络的意图与你的目标不同,你可能需要更改最终层的激活函数。让我们看一些常见的选择。

Softmax 激活函数确保输出向量的总和恰好为 1。这是一个适合网络的激活函数,它为给定输入输出一个标签(例如,图像分类器)。输出向量将表示概率分布——如果输出向量中“猫”的条目为 0.65,那么网络认为它以 65%的确定性看到了一只猫。Softmax 只在有一个答案时有效。当可能有多个答案时,可以尝试使用 sigmoid 激活。

线性激活函数适用于回归问题,当我们需要根据输入预测数值时。一个例子是根据一系列电影评论预测电影评分。线性激活函数将取前一层的值,并将它们与一组权重相乘,以使其最好地适应预期输出。就像将输入数据归一化为[-1, 1]范围或附近一样是一个好主意,通常也有助于对输出进行相同的处理。因此,如果我们的电影评分在 0 到 5 之间,我们会在创建训练数据时减去 2.5 并除以相同的值。

如果网络输出图像,请确保所使用的激活函数与像素的归一化方式一致。标准的减去平均像素值并除以标准差的归一化会导致值围绕 0 中心,因此它不适用于 sigmoid,而且由于 30%的值将落在范围[-1, 1]之外,tanh 也不是一个好选择。你仍然可以使用这些,但你需要改变应用于输出的归一化。

根据您对输出分布的了解,可能会有更高级的方法。例如,电影评分通常集中在 3.7 左右,因此以此作为中心可能会产生更好的结果。当实际分布偏斜,使得平均值附近的值比异常值更有可能时,使用 tanh 激活函数可能是合适的。这将任何值压缩到[-1, 1]范围内。通过将预期输出映射到相同的范围,考虑预期分布,我们可以模拟输出数据的任何形状。

讨论

选择正确的输出激活函数至关重要,但在大多数情况下并不困难。如果您的输出代表具有一个可能结果的概率分布,那么 softmax 适合您;否则,您需要进行实验。

您还需要确保损失函数与最终层的激活函数配合使用。损失函数通过计算给定预期值时预测有多“错误”来引导网络的训练。我们看到当网络进行多标签预测时,softmax 激活函数是正确的选择;在这种情况下,您可能希望选择像 Keras 的categorical_crossentropy这样的分类损失函数。

2.5 正则化和 Dropout

问题

一旦您发现您的网络过拟合了,您可以采取什么措施?

解决方案

通过使用正则化和 dropout 来限制网络的功能。

一个具有足够参数的神经网络可以通过记忆来适应任何输入/输出映射。在训练时准确率看起来很好,但当然网络在未见过的数据上表现不佳,因此在测试数据或实际生产中表现不佳。网络过拟合了。

防止网络过拟合的一个明显方法是通过减少参数的数量,可以通过减少层数或使每一层更小来实现。但这当然也会降低网络的表达能力。正则化和 dropout 在这方面为我们提供了一种折中方案,通过限制网络的表达能力,而不会损害学习的能力(太多)。

通过正则化,我们对参数的极端值添加惩罚。这里的直觉是,为了适应任意的输入/输出映射,网络需要任意的参数,而学习到的参数往往在一个正常范围内。因此,使得难以达到那些任意参数应该保持网络在学习的道路上,而不是记忆。

在 Keras 中的应用很简单:

dense = Dense(128,
              activation='relu',
              kernel_regularizer=regularizers.l2(0.01))(flatten)

正则化器可以应用于核的权重或层的偏置,也可以应用于层的输出。选择哪种方法和使用什么惩罚主要是一个试错的问题。0.01 似乎是一个受欢迎的起始值。

Dropout 是一种类似的技术,但更激进。与保持神经元的权重不同,我们在训练期间随机忽略一定百分比的所有神经元。

与正则化类似,这使得网络更难记住输入/输出对,因为它不能依赖特定的神经元在训练期间工作。这促使网络学习一般的、稳健的特征,而不是一次性的、特定的特征来覆盖一个训练实例。

在 Keras 中,通过使用Dropout(伪)层将 dropout 应用于一个层:

    max_pool_1x = MaxPooling1D(window)(conv_1x)
    dropout_1x = Dropout(0.3)(max_pool_1x)

这将在最大池化层应用 30%的 dropout,在训练期间忽略 30%的神经元。

在进行推断时,不会应用 dropout。一切相等的情况下,这将使层的输出增加超过 40%,因此框架会自动将这些输出缩小。

讨论

随着网络变得更具表现力,它倾向于过拟合或记忆输入而不是学习一般特征的倾向会增加。正则化和 dropout 都可以起到减少这种影响的作用。它们都通过减少网络发展任意特征的自由度来起作用,通过惩罚极端值(正则化)或忽略层中百分比的神经元的贡献(dropout)。

观察具有 dropout 的网络如何工作的一个有趣的替代方式是考虑,如果我们有N个神经元,并随机关闭一定百分比的神经元,我们实际上创建了一个可以创建非常多不同但相关网络的生成器。在训练期间,这些不同的网络都学习手头的任务,但在评估时,它们都并行运行,取其平均意见。因此,即使其中一些开始过拟合,很可能在总体投票中被淹没。

2.6 网络结构、批量大小和学习率

问题

如何找到给定问题的最佳网络结构、批量大小和学习率?

解决方案

从小处开始,逐步扩大。

一旦我们确定了解决特定问题所需的网络类型,我们仍然必须做出许多实现决策。其中最重要的决策是关于网络结构、学习率和批量大小的决策。

让我们从网络结构开始。我们将有多少层?每一层的大小将是多少?一个不错的策略是从可能有效的最小尺寸开始。对于“深度学习”感到热情,有一种诱惑是从许多层开始。但通常,如果一个一层或两层的网络根本不起作用,增加更多层也不会真正有所帮助。

继续考虑每个单独层的大小,较大的层可以学到更多,但也需要更长时间,并且有更多隐藏问题的空间。与层数相同,从小处开始,逐步扩大。如果你怀疑较小网络的表现力不足以理解你的数据,考虑简化你的数据;从一个只区分两个最流行标签的小网络开始,然后逐渐增加数据和网络的复杂性。

批量大小是我们在调整权重之前向网络输入的样本数量。批量大小越大,完成一个批次所需的时间就越长,但梯度更准确。为了快速获得结果,建议从一个较小的批量大小开始——32 似乎效果很好。

学习率确定我们将如何根据导数梯度改变网络中的权重。学习率越高,我们在景观中移动得越快。然而,如果学习率太大,我们就有可能跳过好的部分,开始折腾。考虑到较小的批量大小会导致梯度不够准确,我们应该将较小的批量大小与较小的学习率结合起来。因此,建议在开始时再次从小处开始,当事情顺利时,尝试较大的批量率和更高的学习率。

注意

在 GPU 上进行训练会影响这个评估。GPU 可以高效地并行运行步骤,因此没有真正的理由选择一个批量大小太小以至于让 GPU 的一部分空闲。批量大小取决于网络,但只要每个批次的时间不会因增加批量大小而显著增加,你仍然处于正确的一边。在 GPU 上运行时的第二个考虑因素是内存。当一个批次不再适合 GPU 内存时,事情开始失败,你会开始看到内存不足的消息。

讨论

网络结构、批量大小和学习率是影响网络性能的重要超参数之一,但与实际策略关系不大。对于所有这些,一个合理的策略是从小开始(但足够大以至于事情仍然有效),逐步扩大,观察网络仍然表现。

随着层数和每层大小的增加,我们会在某个时候开始看到过拟合的症状(例如,训练和测试准确度开始发散)。这可能是一个好时机来考虑正则化和丢弃。

第三章:使用单词嵌入计算文本相似性

提示

在我们开始之前,这是第一个包含实际代码的章节。有可能你直接跳到这里,谁会责怪你呢?不过,要按照这些步骤,确保你已经准备好了相关的代码是非常有帮助的。你可以通过在 shell 中执行以下命令来轻松实现这一点:

git clone \
  https://github.com/DOsinga/deep_learning_cookbook.git
cd deep_learning_cookbook
python3 -m venv venv3
source venv3/bin/activate
pip install -r requirements.txt
jupyter notebook

你可以在“你需要知道什么?”中找到更详细的解释。

在本章中,我们将看看单词嵌入以及它们如何帮助我们计算文本片段之间的相似性。单词嵌入是自然语言处理中使用的一种强大技术,将单词表示为n维空间中的向量。这个空间的有趣之处在于具有相似含义的单词会彼此靠近。

我们将在这里使用 Google 的 Word2vec 的一个版本作为主要模型。这不是一个深度神经模型。事实上,它只是一个从单词到向量的大型查找表,因此几乎根本不算是一个模型。Word2vec 的嵌入是在训练网络以从 Google 新闻中获取的句子的上下文中预测单词的副作用。此外,它可能是嵌入的最著名的例子,嵌入是深度学习中一个重要的概念。

一旦你开始寻找它们,具有语义属性的高维空间就会在深度学习中随处可见。我们可以通过将电影投影到高维空间中来构建电影推荐系统(第四章),或者仅使用两个维度创建手写数字的地图(第十三章)。图像识别网络将图像投影到一个空间中,使得相似的图像彼此靠近(第十章)。

在当前章节中,我们将专注于单词嵌入。我们将从使用预训练的单词嵌入模型计算单词相似性开始,然后展示一些有趣的 Word2vec 数学。然后,我们将探索如何可视化这些高维空间。

接下来,我们将看看如何利用 Word2vec 等单词嵌入的语义属性进行特定领域的排名。我们将把单词及其嵌入视为它们所代表的实体,得到一些有趣的结果。我们将从在 Word2vec 嵌入中找到实体类开始——在这种情况下是国家。然后,我们将展示如何对这些国家进行排名术语,以及如何在地图上可视化这些结果。

单词嵌入是将单词映射到向量的强大方式,有许多用途。它们经常被用作文本的预处理步骤。

与本章相关的有两个 Python 笔记本:

03.1 Using pretrained word embeddings
03.2 Domain specific ranking using word2vec cosine distance

3.1 使用预训练的单词嵌入查找单词相似性

问题

你需要找出两个单词是否相似但不相等,例如当你验证用户输入时,不希望要求用户完全输入预期的单词。

解决方案

你可以使用一个预训练的单词嵌入模型。在这个例子中,我们将使用gensim,这是一个在 Python 中用于主题建模的有用库。

第一步是获取一个预训练模型。互联网上有许多可供下载的预训练模型,但我们将选择 Google News 的一个。它包含 300 万个单词的嵌入,并且是在大约 1000 亿个来自 Google 新闻档案的单词上进行训练的。下载需要一些时间,所以我们将在本地缓存文件:

MODEL = 'GoogleNews-vectors-negative300.bin'
path = get_file(MODEL + '.gz',
    'https://s3.amazonaws.com/dl4j-distribution/%s.gz' % MODEL)
unzipped = os.path.join('generated', MODEL)
if not os.path.isfile(unzipped):
    with open(unzipped, 'wb') as fout:
        zcat = subprocess.Popen(['zcat'],
                          stdin=open(path),
                          stdout=fout
                         )
        zcat.wait()
Downloading data from GoogleNews-vectors-negative300.bin.gz
1647050752/1647046227 [==============================] - 71s 0us/step

现在我们已经下载了模型,我们可以将其加载到内存中。这个模型非常庞大,大约需要 5GB 的 RAM。

model = gensim.models.KeyedVectors.load_word2vec_format(MODEL, binary=True)

模型加载完成后,我们可以使用它来查找相似的单词:

model.most_similar(positive=['espresso'])
[(u'cappuccino', 0.6888186931610107),
 (u'mocha', 0.6686209440231323),
 (u'coffee', 0.6616827249526978),
 (u'latte', 0.6536752581596375),
 (u'caramel_macchiato', 0.6491267681121826),
 (u'ristretto', 0.6485546827316284),
 (u'espressos', 0.6438628435134888),
 (u'macchiato', 0.6428250074386597),
 (u'chai_latte', 0.6308028697967529),
 (u'espresso_cappuccino', 0.6280542612075806)]

讨论

单词嵌入将一个n维向量与词汇表中的每个单词相关联,使得相似的单词彼此靠近。查找相似单词只是一个最近邻搜索,即使在高维空间中也有高效的算法。

简化一下,Word2vec 嵌入是通过训练神经网络来预测单词的上下文而获得的。因此,我们要求网络预测在一系列片段中应该选择哪个单词作为 X;例如,“咖啡馆提供了一种让我真正清醒的 X。”

这样,可以插入类似模式的单词将获得彼此接近的向量。我们不关心实际任务,只关心分配的权重,这将作为训练这个网络的副作用得到。

在本书的后面部分,我们将看到词嵌入也可以用来将单词输入神经网络。将一个 300 维的嵌入向量输入网络比输入一个 300 万维的 one-hot 编码更可行。此外,使用预训练的词嵌入来喂养网络不需要学习单词之间的关系,而是可以立即开始处理真正的任务。

3.2 Word2vec 数学

问题

如何自动回答“A 是 B,C 是什么”的问题?

解决方案

利用 Word2vec 模型的语义属性。gensim库使这变得相当简单:

def A_is_to_B_as_C_is_to(a, b, c, topn=1):
  a, b, c = map(lambda x:x if type(x) == list else [x], (a, b, c))
  res = model.most_similar(positive=b + c, negative=a, topn=topn)
  if len(res):
    if topn == 1:
      return res[0][0]
    return [x[0] for x in res]
  return None

现在我们可以将这种方法应用于任意单词,例如,找到与“国王”相关的单词,就像“儿子”与“女儿”相关一样:

A_is_to_B_as_C_is_to('man', 'woman', 'king')
u'queen'

我们也可以使用这种方法查找选定国家的首都:

for country in 'Italy', 'France', 'India', 'China':
    print('%s is the capital of %s' %
          (A_is_to_B_as_C_is_to('Germany', 'Berlin', country), country))
Rome is the capital of Italy
Paris is the capital of France
Delhi is the capital of India
Beijing is the capital of China

或者查找公司的主要产品(注意这些嵌入中使用的任何数字的占位符#):

for company in 'Google', 'IBM', 'Boeing', 'Microsoft', 'Samsung':
  products = A_is_to_B_as_C_is_to(
    ['Starbucks', 'Apple'], ['Starbucks_coffee', 'iPhone'], company, topn=3)
  print('%s -> %s' %
        (company, ', '.join(products)))
Google -> personalized_homepage, app, Gmail
IBM -> DB2, WebSphere_Portal, Tamino_XML_Server
Boeing -> Dreamliner, airframe, aircraft
Microsoft -> Windows_Mobile, SyncMate, Windows
Samsung -> MM_A###, handset, Samsung_SCH_B###

讨论

正如我们在前面的步骤中看到的,与单词相关联的向量编码了单词的含义——相互相似的单词具有彼此接近的向量。事实证明,单词向量之间的差异也编码了单词之间的差异,因此如果我们取单词“儿子”的向量并减去单词“女儿”的向量,我们最终得到一个可以解释为“从男性到女性”的差异。如果我们将这个差异加到单词“国王”的向量上,我们最终会接近单词“女王”的向量。

澄清图

most_similar方法接受一个或多个正词和一个或多个负词。它查找相应的向量,然后从正向量中减去负向量,并返回与结果向量最接近的单词。

因此,为了回答“A 是 B,C 是什么”的问题,我们希望从 B 中减去 A,然后加上 C,或者使用positive = [B, C]negative = [A]调用most_similar。示例A_is_to_B_as_C_is_to为这种行为添加了两个小特性。如果我们只请求一个示例,它将返回一个单个项目,而不是一个包含一个项目的列表。同样,我们可以为 A、B 和 C 返回列表或单个项目。

提供列表的能力在产品示例中证明是有用的。我们要求每家公司三种产品,这使得准确获取向量比仅要求一种产品更为重要。通过提供“星巴克”和“苹果”,我们可以获得更准确的“是产品”的概念向量。

3.3 可视化词嵌入

问题

您想要了解单词嵌入如何将一组对象分区。

解决方案

300 维空间很难浏览,但幸运的是,我们可以使用一种称为 t-分布随机邻居嵌入(t-SNE)的算法将高维空间折叠成更易理解的二维空间。

假设我们想看看三组术语是如何分区的。我们选择国家、体育和饮料:

beverages = ['espresso', 'beer', 'vodka', 'wine', 'cola', 'tea']
countries = ['Italy', 'Germany', 'Russia', 'France', 'USA', 'India']
sports = ['soccer', 'handball', 'hockey', 'cycling', 'basketball', 'cricket']

items = beverages + countries + sports

现在让我们查找它们的向量:

item_vectors = [(item, model[item])
                    for item in items
                    if item in model]

现在我们可以使用 t-SNE 来找到 300 维空间中的聚类:

vectors = np.asarray([x[1] for x in item_vectors])
lengths = np.linalg.norm(vectors, axis=1)
norm_vectors = (vectors.T / lengths).T
tsne = TSNE(n_components=2, perplexity=10,
            verbose=2).fit_transform(norm_vectors)

让我们使用 matplotlib 在一个漂亮的散点图中展示结果:

x=tsne[:,0]
y=tsne[:,1]

fig, ax = plt.subplots()
ax.scatter(x, y)

for item, x1, y1 in zip(item_vectors, x, y):
    ax.annotate(item[0], (x1, y1))

plt.show()

结果是:

项目的散点图

讨论

t-SNE 是一个聪明的算法;你给它一组高维空间中的点,它会迭代地尝试找到最佳投影到一个保持点之间距离尽可能好的低维空间(通常是一个平面)。因此,它非常适合用于可视化高维空间,比如(单词)嵌入。

对于更复杂的情况,perplexity参数是一个可以尝试的东西。这个变量大致确定了局部准确性和整体准确性之间的平衡。将其设置为一个较低的值会创建小的局部准确的簇;将其设置得更高会导致更多的局部扭曲,但整体簇更好。

3.4 在嵌入中找到实体类

问题

在高维空间中,通常存在只包含一个类别实体的子空间。如何找到这些空间呢?

解决方案

在一组示例和反例上应用支持向量机(SVM)。例如,让我们在 Word2vec 空间中找到国家。我们将重新加载模型并探索与国家德国相似的内容:

model = gensim.models.KeyedVectors.load_word2vec_format(MODEL, binary=True)
model.most_similar(positive=['Germany'])
[(u'Austria', 0.7461062073707581),
 (u'German', 0.7178748846054077),
 (u'Germans', 0.6628648042678833),
 (u'Switzerland', 0.6506867408752441),
 (u'Hungary', 0.6504981517791748),
 (u'Germnay', 0.649348258972168),
 (u'Netherlands', 0.6437495946884155),
 (u'Cologne', 0.6430779099464417)]

正如你所看到的,附近有许多国家,但像“German”这样的词和德国城市的名称也出现在列表中。我们可以尝试构建一个最能代表“国家”概念的向量,通过将许多国家的向量相加而不仅仅使用德国,但这只能走到这一步。在嵌入空间中,国家的概念不是一个点,而是一个形状。我们需要的是一个真正的分类器。

支持向量机已被证明对于这样的分类任务非常有效。Scikit-learn 提供了一个易于部署的解决方案。第一步是构建一个训练集。对于这个示例,获取正面例子并不难,因为国家并不多:

positive = ['Chile', 'Mauritius', 'Barbados', 'Ukraine', 'Israel',
  'Rwanda', 'Venezuela', 'Lithuania', 'Costa_Rica', 'Romania',
  'Senegal', 'Canada', 'Malaysia', 'South_Korea', 'Australia',
  'Tunisia', 'Armenia', 'China', 'Czech_Republic', 'Guinea',
  'Gambia', 'Gabon', 'Italy', 'Montenegro', 'Guyana', 'Nicaragua',
  'French_Guiana', 'Serbia', 'Uruguay', 'Ethiopia', 'Samoa',
  'Antarctica', 'Suriname', 'Finland', 'Bermuda', 'Cuba', 'Oman',
  'Azerbaijan', 'Papua', 'France', 'Tanzania', 'Germany' … ]

当然,拥有更多的正面例子更好,但对于这个示例,使用 40-50 个将让我们对解决方案的工作原理有一个很好的了解。

我们还需要一些负面例子。我们直接从 Word2vec 模型的一般词汇中抽样这些。我们可能会不走运地抽到一个国家并将其放入负面例子,但考虑到我们的模型中有 300 万个词,世界上不到 200 个国家,我们确实需要非常不走运:

negative = random.sample(model.vocab.keys(), 5000)
negative[:4]
[u'Denys_Arcand_Les_Invasions',
 u'2B_refill',
 u'strained_vocal_chords',
 u'Manifa']

现在我们将根据正面和负面例子创建一个带标签的训练集。我们将使用1作为表示某物是一个国家的标签,使用0表示它不是一个国家。我们将遵循将训练数据存储在变量X中,标签存储在变量y中的惯例:

labelled = [(p, 1) for p in positive] + [(n, 0) for n in negative]
random.shuffle(labelled)
X = np.asarray([model[w] for w, l in labelled])
y = np.asarray([l for w, l in labelled])

让我们训练模型。我们将留出一部分数据来评估我们的表现:

TRAINING_FRACTION = 0.7
cut_off = int(TRAINING_FRACTION * len(labelled))
clf = svm.SVC(kernel='linear')
clf.fit(X[:cut_off], y[:cut_off])

由于我们的数据集相对较小,即使在一台性能不是很强大的计算机上,训练应该几乎瞬间完成。我们可以通过查看模型对评估集的位的正确预测次数来了解我们的表现如何:

res = clf.predict(X[cut_off:])

missed = [country for (pred, truth, country) in
          zip(res, y[cut_off:], labelled[cut_off:]) if pred != truth]
100 - 100 * float(len(missed)) / len(res), missed

你得到的结果会有点取决于选择的正面国家和你碰巧抽到的负面样本。我主要得到了一个错过的国家列表——通常是因为国家名称也意味着其他东西,比如约旦,但也有一些真正的遗漏。精度大约为 99.9%。

现在我们可以运行分类器来提取所有单词中的国家:

res = []
for word, pred in zip(model.index2word, all_predictions):
  if pred:
    res.append(word)
    if len(res) == 150:
      break
random.sample(res, 10)
[u'Myanmar',
 u'countries',
 u'Sri_Lanka',
 u'Israelis',
 u'Australia',
 u'Pyongyang',
 u'New_Hampshire',
 u'Italy',
 u'China',
 u'Philippine']

结果相当不错,虽然不是完美的。例如,单词“countries”本身被分类为一个国家,大陆或美国州等实体也是如此。

讨论

支持向量机是在像单词嵌入这样的高维空间中找到类别的有效工具。它们通过尝试找到将正面例子与负面例子分开的超平面来工作。

在 Word2vec 中,国家都相互靠近,因为它们共享一个语义方面。支持向量机帮助我们找到国家的云,并提出边界。以下图表在二维中可视化了这一点:

clever diagram

SVMs 可以用于机器学习中各种特定的分类器,因为即使维度数大于样本数,它们也是有效的,就像在这种情况下一样。300 个维度可能使模型过度拟合数据,但由于 SVM 试图找到一个简单的模型来拟合数据,我们仍然可以从一个只有几十个示例的数据集中推广。

取得的结果相当不错,尽管值得注意的是,在有 300 万个负例的情况下,99.7%的精度仍会给我们带来 9000 个假阳性,淹没了实际的国家。

3.5 计算类内的语义距离

问题

如何找到一个类中对于给定标准最相关的项目?

解决方案

给定一个类,例如国家,我们可以根据一个标准对该类的成员进行排名,通过查看相对距离:

country_to_idx = {country['name']: idx for idx, country in enumerate(countries)}
country_vecs = np.asarray([model[c['name']] for c in countries])
country_vecs.shape
(184, 300)

现在我们可以像以前一样,将国家的向量提取到一个与国家对齐的numpy数组中:

countries = list(country_to_cc.keys())
country_vecs = np.asarray([model[c] for c in countries])

快速检查看哪些国家最像加拿大:

dists = np.dot(country_vecs, country_vecs[country_to_idx['Canada']])
for idx in reversed(np.argsort(dists)[-8:]):
    print(countries[idx], dists[idx])
Canada 7.5440245
New_Zealand 3.9619699
Finland 3.9392405
Puerto_Rico 3.838145
Jamaica 3.8102934
Sweden 3.8042784
Slovakia 3.7038736
Australia 3.6711009

加勒比国家有些令人惊讶,关于加拿大的许多新闻必须与曲棍球有关,鉴于斯洛伐克和芬兰出现在列表中,但除此之外看起来并不不合理。

让我们转换思路,对一组国家的任意术语进行排名。对于每个国家,我们将计算国家名称与我们想要排名的术语之间的距离。与术语“更接近”的国家对于该术语更相关:

def rank_countries(term, topn=10, field='name'):
    if not term in model:
        return []
    vec = model[term]
    dists = np.dot(country_vecs, vec)
    return [(countries[idx][field], float(dists[idx]))
            for idx in reversed(np.argsort(dists)[-topn:])]

例如:

rank_countries('cricket')
[('Sri_Lanka', 5.92276668548584),
 ('Zimbabwe', 5.336524486541748),
 ('Bangladesh', 5.192488670349121),
 ('Pakistan', 4.948408126831055),
 ('Guyana', 3.9162840843200684),
 ('Barbados', 3.757995128631592),
 ('India', 3.7504401206970215),
 ('South_Africa', 3.6561498641967773),
 ('New_Zealand', 3.642028331756592),
 ('Fiji', 3.608567714691162)]

由于我们使用的 Word2vec 模型是在 Google 新闻上训练的,排名器将返回最近新闻中大多数以给定术语而闻名的国家。印度可能更常被提及与板球有关,但只要它也涵盖其他事物,斯里兰卡仍然可以获胜。

讨论

在我们将不同类的成员投影到相同维度的空间中的情况下,我们可以使用跨类距离作为亲和度的度量。Word2vec 并不完全代表一个概念空间(单词“Jordan”可以指河流、国家或一个人),但它足够好,可以很好地对各种概念的国家进行排名。

构建推荐系统时通常采用类似方法。例如,在 Netflix 挑战中,一种流行的策略是使用用户对电影的评分来将用户和电影投影到一个共享空间中。接近用户的电影预计会受到用户高度评价。

在我们有两个不同的空间的情况下,如果我们可以计算从一个空间到另一个空间的投影矩阵,我们仍然可以使用这个技巧。如果我们有足够多的候选者在两个空间中的位置我们都知道,这是可能的。

3.6 在地图上可视化国家数据

问题

如何在地图上可视化实验中的国家排名?

解决方案

GeoPandas 是在地图上可视化数值数据的完美工具。

这个巧妙的库将 Pandas 的强大功能与地理原语结合在一起,并预装了一些地图。让我们加载世界:

world = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'))
world.head()

这向我们展示了一些关于一组国家的基本信息。我们可以根据我们的rank_countries函数向world对象添加一列:

def map_term(term):
    d = {k.upper(): v for k, v in rank_countries(term,
                                                 topn=0,
                                                 field='cc3')}
    world[term] = world['iso_a3'].map(d)
    world[term] /= world[term].max()
    world.dropna().plot(term, cmap='OrRd')

map_term('coffee')

例如,这很好地绘制了咖啡地图,突出了咖啡消费国家和咖啡生产国家。

咖啡世界地图

讨论

可视化数据是机器学习中的一项重要技术。能够查看数据,无论是输入还是某些算法的结果,都可以让我们快速发现异常。格陵兰岛的人们真的喝那么多咖啡吗?还是因为“格陵兰咖啡”(爱尔兰咖啡的变种)而看到了一种人为现象?那些位于非洲中部的国家——他们真的既不喝咖啡也不生产咖啡吗?还是因为我们的嵌入中没有关于它们的数据?

GeoPandas 是分析地理编码信息的理想工具,它基于 Pandas 的通用数据功能,我们将在第六章中更多地了解到。

第四章:基于维基百科外部链接构建推荐系统

推荐系统传统上是根据用户先前收集的评分进行训练的。我们希望预测用户的评分,因此从历史评分开始似乎是一个自然的选择。然而,这要求我们在开始之前有一个大量的评分集,并且不允许我们对尚未评分的新项目做出良好的工作。此外,我们故意忽略了我们对项目的元信息。

在本章中,您将探索如何仅基于维基百科的外部链接构建一个简单的电影推荐系统。您将首先从维基百科中提取一个训练集,然后基于这些链接训练嵌入。然后,您将实现一个简单的支持向量机分类器来提供建议。最后,您将探索如何使用新训练的嵌入来预测电影的评分。

本章中的代码可以在这些笔记本中找到:

04.1 Collect movie data from Wikipedia
04.2 Build a recommender system based on outgoing Wikipedia links

4.1 收集数据

问题

您希望获得一个特定领域的训练数据集,比如电影。

解决方案

解析维基百科转储文件并仅提取电影页面。

注意

本配方中的代码展示了如何从维基百科获取和提取训练数据,这是一个非常有用的技能。然而,下载和处理完整的转储文件需要相当长的时间。笔记本文件夹的data目录包含了预先提取的前 10000 部电影,我们将在本章的其余部分中使用,因此您不需要运行本配方中的步骤。

让我们从维基百科下载最新的转储文件开始。您可以使用您喜欢的浏览器轻松完成这一操作,如果您不需要最新版本,您可能应该选择附近的镜像。但您也可以通过编程方式完成。以下是获取最新转储页面的方法:

index = requests.get('https://dumps.wikimedia.org/backup-index.html').text
soup_index = BeautifulSoup(index, 'html.parser')
dumps = [a['href'] for a in soup_index.find_all('a')
             if a.has_attr('href') and a.text[:-1].isdigit()]

我们现在将浏览转储文件,并找到最新的已完成处理的文件:

for dump_url in sorted(dumps, reverse=True):
    print(dump_url)
    dump_html = index = requests.get(
        'https://dumps.wikimedia.org/enwiki/' + dump_url).text
    soup_dump = BeautifulSoup(dump_html, 'html.parser')
    pages_xml = [a['href'] for a in soup_dump.find_all('a')
                 if a.has_attr('href')
                 and a['href'].endswith('-pages-articles.xml.bz2')]
    if pages_xml:
        break
    time.sleep(0.8)

注意睡眠以保持在维基百科的速率限制之下。现在让我们获取转储文件:

wikipedia_dump = pages_xml[0].rsplit('/')[-1]
url = url = 'https://dumps.wikimedia.org/' + pages_xml[0]
path = get_file(wikipedia_dump, url)
path

我们检索到的转储文件是一个 bz2 压缩的 XML 文件。我们将使用sax来解析维基百科的 XML。我们对<title><page>标签感兴趣,因此我们的Content​Handler看起来像这样:

class WikiXmlHandler(xml.sax.handler.ContentHandler):
    def __init__(self):
        xml.sax.handler.ContentHandler.__init__(self)
        self._buffer = None
        self._values = {}
        self._movies = []
        self._curent_tag = None

    def characters(self, content):
        if self._curent_tag:
            self._buffer.append(content)

    def startElement(self, name, attrs):
        if name in ('title', 'text'):
            self._curent_tag = name
            self._buffer = []

    def endElement(self, name):
        if name == self._curent_tag:
            self._values[name] = ' '.join(self._buffer)

        if name == 'page':
            movie = process_article(**self._values)
            if movie:
                self._movies.append(movie)

对于每个<page>标签,这将收集标题和文本内容到self._values字典中,并使用收集到的值调用process_article

尽管维基百科最初是一个超链接文本型百科全书,但多年来它已经发展成一个更结构化的数据转储。其中一种方法是让页面链接回所谓的分类页面。这些链接起到标签的作用。电影《飞越疯人院》的页面链接到“1975 年电影”分类页面,因此我们知道这是一部 1975 年的电影。不幸的是,并没有仅仅针对电影的分类页面。幸运的是,有一个更好的方法:维基百科模板。

模板最初是一种确保包含相似信息的页面以相同方式呈现该信息的方法。“信息框”模板对数据处理非常有用。它不仅包含适用于页面主题的键/值对列表,还有一个类型。其中之一是“电影”,这使得提取所有电影的任务变得更加容易。

对于每部电影,我们想要提取名称、外部链接以及,仅仅是因为我们可以,存储在信息框中的属性。名为mwparserfromhell的工具在解析维基百科时表现得相当不错:

def process_article(title, text):
    rotten = [(re.findall('\d\d?\d?%', p),
        re.findall('\d\.\d\/\d+|$', p), p.lower().find('rotten tomatoes'))
        for p in text.split('\n\n')]
    rating = next(((perc[0], rating[0]) for perc, rating, idx in rotten
        if len(perc) == 1 and idx > -1), (None, None))
    wikicode = mwparserfromhell.parse(text)
    film = next((template for template in wikicode.filter_templates()
                 if template.name.strip().lower() == 'infobox film'),
                 None)
    if film:
        properties = {param.name.strip_code().strip():
                      param.value.strip_code().strip()
                      for param in film.params
                      if param.value.strip_code().strip()
                     }
        links = [x.title.strip_code().strip()
                 for x in wikicode.filter_wikilinks()]
        return (title, properties, links) + rating

现在我们可以将 bzipped 转储文件输入解析器:

parser = xml.sax.make_parser()
handler = WikiXmlHandler()
parser.setContentHandler(handler)
for line in subprocess.Popen(['bzcat'],
                             stdin=open(path),
                             stdout=subprocess.PIPE).stdout:
  try:
    parser.feed(line)
  except StopIteration:
    break

最后,让我们保存结果,这样下次我们需要数据时就不必再处理几个小时:

with open('wp_movies.ndjson', 'wt') as fout:
  for movie in handler._movies:
    fout.write(json.dumps(movie) + '\n')

讨论

维基百科不仅是回答几乎任何人类知识领域问题的重要资源;它也是许多深度学习实验的起点。了解如何解析转储文件并提取相关部分是许多项目中有用的技能。

13 GB 的数据转储是相当大的下载。解析维基百科标记语言带来了自己的挑战:这种语言多年来有机地发展,似乎没有强大的基础设计。但是随着今天快速的连接和一些出色的开源库来帮助解析,这一切都变得相当可行。

在某些情况下,维基百科 API 可能更合适。这个对维基百科的 REST 接口允许您以多种强大的方式搜索和查询,并且只获取您需要的文章。考虑到速率限制,以这种方式获取所有电影将需要很长时间,但对于较小的领域来说,这是一个选择。

如果您最终要为许多项目解析维基百科,那么首先将转储导入到像 Postgres 这样的数据库中可能是值得的,这样您就可以直接查询数据集。

4.2 训练电影嵌入

问题

如何使用实体之间的链接数据生成建议,比如“如果您喜欢这个,您可能也对那个感兴趣”?

解决方案

使用一些元信息作为连接器来训练嵌入。这个示例建立在之前的示例之上,使用了那里提取的电影和链接。为了使数据集变得更小且更少噪音,我们将仅使用维基百科上受欢迎程度确定的前 10,000 部电影。

我们将外链视为连接器。这里的直觉是链接到同一页面的电影是相似的。它们可能有相同的导演或属于相同的类型。随着模型的训练,它不仅学习哪些电影相似,还学习哪些链接相似。这样它可以泛化并发现指向 1978 年的链接与指向 1979 年的链接具有相似的含义,从而有助于电影的相似性。

我们将从计算外链开始,这是一个快速查看我们是否合理的方法:

link_counts = Counter()
for movie in movies:
    link_counts.update(movie[2])
link_counts.most_common(3)
[(u'Rotten Tomatoes', 9393),
 (u'Category:English-language films', 5882),
 (u'Category:American films', 5867)]

我们模型的任务是确定某个链接是否可以在电影的维基百科页面上找到,因此我们需要提供标记的匹配和不匹配示例。我们只保留至少出现三次的链接,并构建所有有效的(链接,电影)对的列表,我们将其存储以供以后快速查找。我们将保留相同的便于以后快速查找:

top_links = [link for link, c in link_counts.items() if c >= 3]
link_to_idx = {link: idx for idx, link in enumerate(top_links)}
movie_to_idx = {movie[0]: idx for idx, movie in enumerate(movies)}
pairs = []
for movie in movies:
    pairs.extend((link_to_idx[link], movie_to_idx[movie[0]])
                  for link in movie[2] if link in link_to_idx)
pairs_set = set(pairs)

我们现在准备介绍我们的模型。从图表上看,我们将link_idmovie_id作为数字输入到它们各自的嵌入层中。嵌入层将为每个可能的输入分配一个大小为embedding_size的向量。然后我们将这两个向量的点积设置为我们模型的输出。模型将学习权重,使得这个点积接近标签。然后这些权重将把电影和链接投影到一个空间中,使得相似的电影最终位于相似的位置:

def movie_embedding_model(embedding_size=30):
    link = Input(name='link', shape=(1,))
    movie = Input(name='movie', shape=(1,))
    link_embedding = Embedding(name='link_embedding',
        input_dim=len(top_links), output_dim=embedding_size)(link)
    movie_embedding = Embedding(name='movie_embedding',
        input_dim=len(movie_to_idx), output_dim=embedding_size)(movie)
    dot = Dot(name='dot_product', normalize=True, axes=2)(
        [link_embedding, movie_embedding])
    merged = Reshape((1,))(dot)
    model = Model(inputs=[link, movie], outputs=[merged])
    model.compile(optimizer='nadam', loss='mse')
    return model

model = movie_embedding_model()

我们将使用生成器来喂养模型。生成器产生由正样本和负样本组成的数据批次。

我们从对数组中的正样本进行采样,然后用负样本填充它。负样本是随机选择的,并确保它们不在pairs_set中。然后我们以我们的网络期望的格式返回数据,即输入/输出元组:

def batchifier(pairs, positive_samples=50, negative_ratio=5):
    batch_size = positive_samples * (1 + negative_ratio)
    batch = np.zeros((batch_size, 3))
    while True:
        for idx, (link_id, movie_id) in enumerate(
                random.sample(pairs, positive_samples)):
            batch[idx, :] = (link_id, movie_id, 1)
        idx = positive_samples
        while idx < batch_size:
            movie_id = random.randrange(len(movie_to_idx))
            link_id = random.randrange(len(top_links))
            if not (link_id, movie_id) in pairs_set:
                batch[idx, :] = (link_id, movie_id, -1)
                idx += 1
        np.random.shuffle(batch)
        yield {'link': batch[:, 0], 'movie': batch[:, 1]}, batch[:, 2]

训练模型的时间:

positive_samples_per_batch=512

model.fit_generator(
    batchifier(pairs,
               positive_samples=positive_samples_per_batch,
               negative_ratio=10),
    epochs=25,
    steps_per_epoch=len(pairs) // positive_samples_per_batch,
    verbose=2
)

训练时间将取决于您的硬件,但如果您从 10,000 部电影数据集开始,即使在没有 GPU 加速的笔记本电脑上,训练时间也应该相当短。

我们现在可以通过访问movie_embedding层的权重从我们的模型中提取电影嵌入。我们对它们进行归一化,以便我们可以使用点积作为余弦相似度的近似:

movie = model.get_layer('movie_embedding')
movie_weights = movie.get_weights()[0]
lens = np.linalg.norm(movie_weights, axis=1)
normalized = (movie_weights.T / lens).T

现在让我们看看嵌入是否有些意义:

def neighbors(movie):
    dists = np.dot(normalized, normalized[movie_to_idx[movie]])
    closest = np.argsort(dists)[-10:]
    for c in reversed(closest):
        print(c, movies[c][0], dists[c])

neighbors('Rogue One')
29 Rogue One 0.9999999
3349 Star Wars: The Force Awakens 0.9722805
101 Prometheus (2012 film) 0.9653338
140 Star Trek Into Darkness 0.9635347
22 Jurassic World 0.962336
25 Star Wars sequel trilogy 0.95218825
659 Rise of the Planet of the Apes 0.9516557
62 Fantastic Beasts and Where to Find Them (film) 0.94662267
42 The Avengers (2012 film) 0.94634
37 Avatar (2009 film) 0.9460137

讨论

嵌入是一种有用的技术,不仅适用于单词。在这个示例中,我们训练了一个简单的网络,并为电影生成了嵌入,取得了合理的结果。这种技术可以应用于任何我们有办法连接项目的时间。在这种情况下,我们使用了维基百科的外链,但我们也可以使用内链或页面上出现的单词。

我们在这里训练的模型非常简单。我们只需要让它提供一个嵌入空间,使得电影的向量和链接的向量的组合可以用来预测它们是否会共同出现。这迫使网络将电影投影到一个空间中,使得相似的电影最终位于相似的位置。我们可以使用这个空间来找到相似的电影。

在 Word2vec 模型中,我们使用一个词的上下文来预测这个词。在这个示例中,我们不使用链接的上下文。对于外部链接来说,这似乎不是一个特别有用的信号,但如果我们使用的是内部链接,这可能是有意义的。链接到电影的页面以一定的顺序进行链接,我们可以利用链接的上下文来改进我们的嵌入。

或者,我们可以使用实际的 Word2vec 代码,并在链接到电影的任何页面上运行它,但保留电影链接作为特殊标记。这样就会创建一个混合的电影和单词嵌入空间。

4.3 建立电影推荐系统

问题

如何基于嵌入构建推荐系统?

解决方案

使用支持向量机将排名靠前的项目与排名靠后的项目分开。

前面的方法让我们对电影进行聚类,并提出建议,比如“如果你喜欢《侠盗一号》,你也应该看看《星际穿越》。”在典型的推荐系统中,我们希望根据用户评分的一系列电影来显示建议。就像我们在第三章中所做的那样,我们可以使用 SVM 来做到这一点。让我们按照滚石杂志 2015 年评选的最佳和最差电影,并假装它们是用户评分:

best = ['Star Wars: The Force Awakens', 'The Martian (film)',
        'Tangerine (film)', 'Straight Outta Compton (film)',
        'Brooklyn (film)', 'Carol (film)', 'Spotlight (film)']
worst = ['American Ultra', 'The Cobbler (2014 film)',
         'Entourage (film)', 'Fantastic Four (2015 film)',
         'Get Hard', 'Hot Pursuit (2015 film)', 'Mortdecai (film)',
         'Serena (2014 film)', 'Vacation (2015 film)']
y = np.asarray([1 for _ in best] + [0 for _ in worst])
X = np.asarray([normalized_movies[movie_to_idx[movie]]
                for movie in best + worst])

基于此构建和训练一个简单的 SVM 分类器很容易:

clf = svm.SVC(kernel='linear')
clf.fit(X, y)

我们现在可以运行新的分类器,打印出数据集中所有电影中最好的五部和最差的五部:

estimated_movie_ratings = clf.decision_function(normalized_movies)
best = np.argsort(estimated_movie_ratings)
print('best:')
for c in reversed(best[-5:]):
    print(c, movies[c][0], estimated_movie_ratings[c])

print('worst:')
for c in best[:5]:
    print(c, movies[c][0], estimated_movie_ratings[c])
best:
(6870, u'Goodbye to Language', 1.24075226186855)
(6048, u'The Apu Trilogy', 1.2011876298842317)
(481, u'The Devil Wears Prada (film)', 1.1759994747169913)
(307, u'Les Mis\xe9rables (2012 film)', 1.1646775074857494)
(2106, u'A Separation', 1.1483743944891462)
worst:
(7889, u'The Comebacks', -1.5175929012505527)
(8837, u'The Santa Clause (film series)', -1.4651252650867073)
(2518, u'The Hot Chick', -1.464982008376793)
(6285, u'Employee of the Month (2006 film)', -1.4620595013243951)
(7339, u'Club Dread', -1.4593221506016203)

讨论

正如我们在上一章中看到的,我们可以使用支持向量机高效地构建一个区分两个类别的分类器。在这种情况下,我们让它根据我们先前学习到的嵌入来区分好电影和坏电影。

由于支持向量机找到一个或多个超平面来将“好”的示例与“坏”的示例分开,我们可以将其用作个性化功能——距离分隔超平面最远且在右侧的电影应该是最受喜爱的电影。

4.4 预测简单的电影属性

问题

您想要预测简单的电影属性,比如烂番茄评分。

解决方案

使用学习到的嵌入模型的向量进行线性回归模型,以预测电影属性。

让我们尝试一下这个方法来处理烂番茄评分。幸运的是,它们已经以movie[-2]的形式作为字符串存在于我们的数据中,形式为*N*%

rotten_y = np.asarray([float(movie[-2][:-1]) / 100
                       for movie in movies if movie[-2]])
rotten_X = np.asarray([normalized_movies[movie_to_idx[movie[0]]]
                       for movie in movies if movie[-2]])

这应该为我们大约一半的电影提供数据。让我们在前 80%的数据上进行训练:

TRAINING_CUT_OFF = int(len(rotten_X) * 0.8)
regr = LinearRegression()
regr.fit(rotten_X[:TRAINING_CUT_OFF], rotten_y[:TRAINING_CUT_OFF])

现在让我们看看我们在最后 20%的进展如何:

error = (regr.predict(rotten_X[TRAINING_CUT_OFF:]) -
         rotten_y[TRAINING_CUT_OFF:])
'mean square error %2.2f' % np.mean(error ** 2)
mean square error 0.06

看起来真的很令人印象深刻!但虽然这证明了线性回归的有效性,但我们的数据存在一个问题,使得预测烂番茄评分变得更容易:我们一直在训练前 10000 部电影,而热门电影并不总是更好,但平均来说它们得到更高的评分。

通过将我们的预测与始终预测平均分数进行比较,我们可以大致了解我们的表现如何:

error = (np.mean(rotten_y[:TRAINING_CUT_OFF]) - rotten_y[TRAINING_CUT_OFF:])
'mean square error %2.2f' % np.mean(error ** 2)
'mean square error 0.09'

我们的模型确实表现得更好一些,但基础数据使得产生一个合理的结果变得容易。

讨论

复杂的问题通常需要复杂的解决方案,深度学习肯定可以给我们这些。然而,从可能起作用的最简单的事物开始通常是一个不错的方法。这让我们能够快速开始,并让我们知道我们是否朝着正确的方向努力:如果简单模型根本不产生任何有用的结果,那么复杂模型帮助的可能性不大,而如果简单模型有效,更复杂的模型有很大机会帮助我们取得更好的结果。

线性回归模型就是最简单的模型之一。该模型试图找到一组因素,使这些因素与我们的向量的线性组合尽可能接近目标值。与大多数机器学习模型相比,这些模型的一个好处是我们实际上可以看到每个因素的贡献是什么。

第五章:生成类似示例文本风格的文本

在本章中,我们将看看如何使用递归神经网络(RNN)生成类似文本体的文本。这将产生有趣的演示。人们已经使用这种类型的网络生成从婴儿姓名到颜色描述等各种内容。这些演示是熟悉递归网络的好方法。RNN 也有它们的实际用途——在本书的后面,我们将使用它们来训练聊天机器人,并基于收集的播放列表构建音乐推荐系统,RNN 已经被用于生产中跟踪视频中的对象。

递归神经网络是一种在处理时间或序列时有帮助的神经网络类型。我们将首先查看 Project Gutenberg 作为免费书籍的来源,并使用一些简单的代码下载威廉·莎士比亚的作品集。接下来,我们将使用 RNN 生成似乎是莎士比亚风格的文本(如果您不太注意的话),通过训练网络下载的文本。然后,我们将在 Python 代码上重复这一技巧,并看看如何改变输出。最后,由于 Python 代码具有可预测的结构,我们可以查看哪些神经元在哪些代码位上激活,并可视化我们的 RNN 的工作原理。

本章的代码可以在以下 Python 笔记本中找到:

05.1 Generating Text in the Style of an Example Text

5.1 获取公共领域书籍的文本

问题

您想要下载一些公共领域书籍的完整文本,以用于训练您的模型。

解决方案

使用 Project Gutenberg 的 Python API。

Project Gutenberg 包含超过 5 万本书的完整文本。有一个方便的 Python API 可用于浏览和下载这些书籍。如果我们知道 ID,我们可以下载任何一本书:

shakespeare = load_etext(100)
shakespeare = strip_headers(shakespeare)

我们可以通过浏览网站并从书籍的 URL 中提取书籍的 ID,或者通过作者或标题查询http://www.gutenberg.org/ 来获取书籍的 ID。但在查询之前,我们需要填充元信息缓存。这将创建一个包含所有可用书籍的本地数据库。这需要一点时间,但只需要做一次:

cache = get_metadata_cache()
cache.populate()

我们现在可以发现莎士比亚的所有作品:

for text_id in get_etexts('author', 'Shakespeare, William'):
    print(text_id, list(get_metadata('title', text_id))[0])

讨论

Project Gutenberg 是一个志愿者项目,旨在数字化图书。它专注于提供美国版权已过期的英语中最重要的书籍,尽管它也有其他语言的书籍。该项目始于 1971 年,早于迈克尔·哈特发明万维网之前。

在 1923 年之前在美国出版的任何作品都属于公共领域,因此古腾堡收藏中的大多数书籍都比这个年代更久远。这意味着语言可能有些过时,但对于自然语言处理来说,该收藏仍然是无与伦比的训练数据来源。通过 Python API 进行访问不仅使访问变得容易,而且还尊重该网站为自动下载文本设置的限制。

5.2 生成类似莎士比亚的文本

问题

如何以特定风格生成文本?

解决方案

使用字符级 RNN。

让我们首先获取莎士比亚的作品集。我们将放弃诗歌,这样我们就只剩下戏剧的一组更一致的作品。诗歌恰好被收集在第一个条目中:

shakespeare = strip_headers(load_etext(100))
plays = shakespeare.split('\nTHE END\n', 1)[-1]

我们将逐个字符地输入文本,并对每个字符进行独热编码——也就是说,每个字符将被编码为一个包含所有 0 和一个 1 的向量。为此,我们需要知道我们将遇到哪些字符:

chars = list(sorted(set(plays)))
char_to_idx = {ch: idx for idx, ch in enumerate(chars)}

让我们创建一个模型,该模型将接收一个字符序列并预测一个字符序列。我们将把序列输入到多个 LSTM 层中进行处理。TimeDistributed层让我们的模型再次输出一个序列:

def char_rnn_model(num_chars, num_layers, num_nodes=512, dropout=0.1):
    input = Input(shape=(None, num_chars), name='input')
    prev = input
    for i in range(num_layers):
        prev = LSTM(num_nodes, return_sequences=True)(prev)
    dense = TimeDistributed(Dense(num_chars, name='dense',
                                  activation='softmax'))(prev)
    model = Model(inputs=[input], outputs=[dense])
    optimizer = RMSprop(lr=0.01)
    model.compile(loss='categorical_crossentropy',
                  optimizer=optimizer, metrics=['accuracy'])
    return model

我们将向网络中随机提供剧本片段,因此生成器似乎是合适的。生成器将产生成对序列块,其中成对的序列仅相差一个字符:

def data_generator(all_text, num_chars, batch_size):
    X = np.zeros((batch_size, CHUNK_SIZE, num_chars))
    y = np.zeros((batch_size, CHUNK_SIZE, num_chars))
    while True:
        for row in range(batch_size):
            idx = random.randrange(len(all_text) - CHUNK_SIZE - 1)
            chunk = np.zeros((CHUNK_SIZE + 1, num_chars))
            for i in range(CHUNK_SIZE + 1):
                chunk[i, char_to_idx[all_text[idx + i]]] = 1
            X[row, :, :] = chunk[:CHUNK_SIZE]
            y[row, :, :] = chunk[1:]
        yield X, y

现在我们将训练模型。我们将设置steps_per_epoch,以便每个字符都有足够的机会被网络看到:

model.fit_generator(
    data_generator(plays, len(chars), batch_size=256),
    epochs=10,
    steps_per_epoch=2 * len(plays) / (256 * CHUNK_SIZE),
    verbose=2
)

训练后,我们可以生成一些输出。我们从剧本中随机选择一个片段,让模型猜测下一个字符是什么。然后我们将下一个字符添加到片段中,并重复,直到达到所需的字符数:

def generate_output(model, start_index=None, diversity=1.0, amount=400):
    if start_index is None:
        start_index = random.randint(0, len(plays) - CHUNK_SIZE - 1)
    fragment = plays[start_index: start_index + CHUNK_SIZE]
    generated = fragment
    for i in range(amount):
        x = np.zeros((1, CHUNK_SIZE, len(chars)))
        for t, char in enumerate(fragment):
            x[0, t, char_to_idx[char]] = 1.
        preds = model.predict(x, verbose=0)[0]
        preds = np.asarray(preds[len(generated) - 1])
        next_index = np.argmax(preds)
        next_char = chars[next_index]

        generated += next_char
        fragment = fragment[1:] + next_char
    return generated

for line in generate_output(model).split('\n'):
    print(line)

经过 10 个时期,我们应该看到一些让我们想起莎士比亚的文本,但我们需要大约 30 个时期才能开始看起来像是可以愚弄一个不太注意的读者:

FOURTH CITIZEN. They were all the summer hearts.
  The King is a virtuous mistress.
CLEOPATRA. I do not know what I have seen him damn'd in no man
  That we have spoken with the season of the world,
  And therefore I will not speak with you.
  I have a son of Greece, and my son
  That we have seen the sea, the seasons of the world
  I will not stay the like offence.
OLIVIA. If it be aught and servants, and something
  have not been a great deal of state)) of the world, I will not stay
  the forest was the fair and not by the way.
SECOND LORD. I will not serve your hour.
FIRST SOLDIER. Here is a princely son, and the world
  in a place where the world is all along.
SECOND LORD. I will not see thee this:
  He hath a heart of men to men may strike and starve.
  I have a son of Greece, whom they say,
  The whiteneth made him like a deadly hand
  And make the seasons of the world,
  And then the seasons and a fine hands are parted
  To the present winter's parts of this deed.
  The manner of the world shall not be a man.
  The King hath sent for thee.
  The world is slain.

克利奥帕特拉和第二勋爵都有一个希腊的儿子,但目前的冬天和世界被杀死是合适的权力的游戏

讨论

在这个配方中,我们看到了如何使用 RNN 生成特定风格的文本。结果相当令人信服,尤其是考虑到模型是基于字符级别进行预测的。由于 LSTM 架构,网络能够学习跨越相当大序列的关系——不仅仅是单词,还有句子,甚至是莎士比亚戏剧布局的基本结构。

尽管这里展示的示例并不是非常实用,但 RNN 可能是实用的。每当我们想要让网络学习一系列项目时,RNN 可能是一个不错的选择。

其他人使用这种技术构建的玩具应用程序生成了婴儿姓名、油漆颜色名称,甚至食谱。

更实用的 RNN 可以用于预测用户在智能手机键盘应用程序中要输入的下一个字符,或者在训练了一组开局后预测下一步棋。这种类型的网络还被用来预测诸如天气模式或甚至股市价格等序列。

循环网络相当易变。看似对网络架构的微小更改可能会导致它们不再收敛,因为所谓的梯度爆炸问题。有时在训练过程中,经过多个时期取得进展后,网络似乎会崩溃并开始忘记它所学到的东西。一如既往,最好从一些简单的有效方法开始,逐步增加复杂性,同时跟踪所做的更改。

有关 RNN 的稍微深入的讨论,请参阅第一章。

使用 RNN 编写代码

问题

如何使用神经网络生成 Python 代码?

解决方案

训练一个循环神经网络,使用 Python 分发的 Python 代码运行您的脚本。

实际上,我们可以为这个任务使用与上一个配方中几乎相同的模型。就深度学习而言,关键是获取数据。Python 附带许多模块的源代码。由于它们存储在random.py模块所在的目录中,我们可以使用以下方法收集它们:

def find_python(rootdir):
    matches = []
    for root, dirnames, filenames in os.walk(rootdir):
        for fn in filenames:
            if fn.endswith('.py'):
                matches.append(os.path.join(root, fn))

    return matches
srcs = find_python(random.__file__.rsplit('/', 1)[0])

然后我们可以读取所有这些源文件,并将它们连接成一个文档,并开始生成新的片段,就像我们在上一个配方中对莎士比亚文本所做的那样。这种方法效果相当不错,但是在生成片段时,很明显很大一部分 Python 源代码实际上是英语。英语既出现在注释中,也出现在字符串的内容中。我们希望我们的模型学习 Python,而不是英语!

去除注释很容易:

COMMENT_RE = re.compile('#.*')
src = COMMENT_RE.sub('', src)

删除字符串的内容稍微复杂一些。一些字符串包含有用的模式,而不是英语。作为一个粗略的规则,我们将用"MSG"替换任何具有超过六个字母和至少一个空格的文本片段:

def replacer(value):
    if ' ' in value and sum(1 for ch in value if ch.isalpha()) > 6:
        return 'MSG'
    return value

使用正则表达式可以简洁地找到字符串文字的出现。但是正则表达式速度相当慢,而且我们正在对大量代码进行运行。在这种情况下,最好只扫描字符串:

def replace_literals(st):
    res = []
    start_text = start_quote = i = 0
    quote = ''
    while i < len(st):
        if quote:
            if st[i: i + len(quote)] == quote:
                quote = ''
                start_text = i
                res.append(replacer(st[start_quote: i]))
        elif st[i] in '"\'':
            quote = st[i]
            if i < len(st) - 2 and st[i + 1] == st[i + 2] == quote:
                quote = 3 * quote
            start_quote = i + len(quote)
            res.append(st[start_text: start_quote])
        if st[i] == '\n' and len(quote) == 1:
            start_text = i
            res.append(quote)
            quote = ''
        if st[i] == '\\':
            i += 1
        i += 1
    return ''.join(res) + st[start_text:]

即使以这种方式清理,我们最终会得到几兆字节的纯 Python 代码。现在我们可以像以前一样训练模型,但是在 Python 代码而不是戏剧上。经过大约 30 个时代,我们应该有可行的东西,并且可以生成代码。

讨论

生成 Python 代码与编写莎士比亚风格的戏剧没有什么不同——至少对于神经网络来说是这样。我们已经看到,清理输入数据是神经网络数据处理的一个重要方面。在这种情况下,我们确保从源代码中删除了大部分英语的痕迹。这样,网络就可以专注于学习 Python,而不会被学习英语分散注意力。

我们可以进一步规范输入。例如,我们可以首先将所有源代码通过“漂亮的打印机”传递,以便它们都具有相同的布局,这样我们的网络就可以专注于学习这一点,而不是当前代码中的多样性。更进一步的一步是使用内置的标记器对 Python 代码进行标记化,然后让网络学习这个解析版本,并使用untokenize生成代码。

5.4 控制输出的温度

问题

您想控制生成代码的变化性。

解决方案

将预测作为概率分布,而不是选择最高值。

在莎士比亚的例子中,我们选择了预测中得分最高的字符。这种方法会导致模型最喜欢的输出。缺点是我们对每个开始都得到相同的输出。由于我们从实际的莎士比亚文本中选择了一个随机的开始序列,这并不重要。但是如果我们想要生成 Python 函数,最好总是以相同的方式开始——比如以/ndef开始,并查看各种解决方案。

我们的网络的预测是通过 softmax 激活函数得出的结果,因此可以看作是一个概率分布。因此,我们可以让numpy.random.multinomial给出答案,而不是选择最大值。multinomial运行n次实验,并考虑结果的可能性。通过将n = 1 运行,我们得到了我们想要的结果。

在这一点上,我们可以引入在如何绘制结果时引入温度的概念。这个想法是,温度越高,结果就越随机,而较低的温度则更接近我们之前看到的纯确定性结果。我们通过相应地缩放预测的对数,然后再次应用 softmax 函数来获得概率。将所有这些放在一起,我们得到:

def generate_code(model, start_with='\ndef ',
                  end_with='\n\n', diversity=1.0):
    generated = start_with
    yield generated
    for i in range(2000):
        x = np.zeros((1, len(generated), len(chars)))
        for t, char in enumerate(generated):
            x[0, t, char_to_idx[char]] = 1.
        preds = model.predict(x, verbose=0)[0]

        preds = np.asarray(preds[len(generated) - 1]).astype('float64')
        preds = np.log(preds) / diversity
        exp_preds = np.exp(preds)
        preds = exp_preds / np.sum(exp_preds)
        probas = np.random.multinomial(1, preds, 1)
        next_index = np.argmax(probas)
        next_char = chars[next_index]
        yield next_char

        generated += next_char
        if generated.endswith(end_with):
            break

我们终于准备好玩一些了。在diversity=1.0时,生成了以下代码。请注意模型生成了我们的"MSG"占位符,并且除了混淆了valvalue之外,几乎让我们运行了代码:

def _calculate_ratio(val):
    """MSG"""
    if value and value[0] != '0':
        raise errors.HeaderParseError(
            "MSG".format(Storable))
    return value

讨论

将 softmax 激活函数的输出作为概率分布使我们能够获得与模型“意图”相对应的各种结果。一个额外的好处是,它使我们能够引入温度的概念,因此我们可以控制输出的“随机性”程度。在第十三章中,我们将看到变分 自动编码器如何使用类似的技术来控制生成的随机性。

如果我们不注意细节,生成的 Python 代码肯定可以通过真实代码。进一步改进结果的一种方法是在生成的代码上调用compile函数,并且仅保留编译的代码。这样我们可以确保它至少在语法上是正确的。稍微变化这种方法的一种方法是在语法错误时不重新开始,而只是删除发生错误的行以及其后的所有内容,然后重试。

5.5 可视化循环网络激活

问题

如何获得对循环网络正在执行的操作的洞察?

解决方案

提取神经元处理文本时的激活。由于我们将要可视化神经元,将它们的数量减少是有意义的。这会稍微降低模型的性能,但使事情变得更简单:

flat_model = char_rnn_model(len(py_chars), num_layers=1, num_nodes=512)

这个模型稍微简单一些,得到的结果略微不够准确,但足够用于可视化。Keras 有一个方便的方法叫做function,它允许我们指定一个输入和一个输出层,然后运行网络中需要的部分来进行转换。以下方法为网络提供了一段文本(一系列字符),并获取特定层的激活值:

def activations(model, code):
    x = np.zeros((1, len(code), len(py_char_to_idx)))
    for t, char in enumerate(code):
        x[0, t, py_char_to_idx[char]] = 1.
    output = model.get_layer('lstm_3').output
    f = K.function([model.input], [output])
    return f([x])[0][0]

现在的问题是要看哪些神经元。即使我们简化的模型有 512 个神经元。LSTM 中的激活值在-1 到 1 之间,因此找到有趣的神经元的一个简单方法就是选择与每个字符对应的最高值。np.argmax(act, axis=1)将帮助我们实现这一点。我们可以使用以下方式可视化这些神经元:

img = np.full((len(neurons) + 1, len(code), 3), 128)
scores = (act[:, neurons].T + 1) / 2
img[1:, :, 0] = 255 * (1 - scores)
img[1:, :, 1] = 255 * scores

这将产生一个小的位图。当我们放大位图并在顶部绘制代码时,我们得到:

RNN 中的神经元激活

这看起来很有趣。顶部的神经元似乎跟踪新语句的开始位置。带有绿色条的神经元跟踪空格,但仅限于用于缩进。倒数第二个神经元似乎在有=符号时激活,但在有==时不激活,这表明网络学会了赋值和相等之间的区别。

讨论

深度学习模型可能非常有效,但它们的结果很难解释。我们更多或少了解训练和推理的机制,但往往很难解释一个具体的结果,除了指向实际的计算。可视化激活是使网络学到的内容更清晰的一种方式。

查看每个字符的最高激活的神经元很快地得到了一组可能感兴趣的神经元。或者,我们可以明确地尝试寻找在特定情况下激活的神经元,例如在括号内。

一旦我们有一个看起来有趣的特定神经元,我们可以使用相同的着色技术来突出显示更大的代码块。

第六章:问题匹配

我们现在已经看到了一些示例,说明我们如何构建和使用词嵌入来比较术语。自然而然地,我们会问如何将这个想法扩展到更大的文本块。我们能否为整个句子或段落创建语义嵌入?在本章中,我们将尝试做到这一点:我们将使用来自 Stack Exchange 的数据为整个问题构建嵌入;然后我们可以使用这些嵌入来查找相似的文档或问题。

我们将从互联网档案馆下载和解析我们的训练数据。然后我们将简要探讨 Pandas 如何帮助分析数据。当涉及到对数据进行特征化和构建模型时,我们让 Keras 来处理繁重的工作。然后我们将研究如何从 Pandas DataFrame中提供此模型以及如何运行它以得出结论。

本章的代码可以在以下笔记本中找到:

06.1 Question matching

6.1 从 Stack Exchange 获取数据

问题

您需要访问大量问题来启动您的训练。

解决方案

使用互联网档案馆检索问题的转储。

Stack Exchange 数据转储可以在互联网档案馆上免费获取,该网站提供许多有趣的数据集(并努力提供整个网络的存档)。数据以一个 ZIP 文件的形式布置在 Stack Exchange 的每个领域上(例如旅行、科幻等)。让我们下载旅行部分的文件:

xml_7z = utils.get_file(
    fname='travel.stackexchange.com.7z',
    origin=('https://ia800107.us.archive.org/27/'
            'items/stackexchange/travel.stackexchange.com.7z'),
)

虽然输入技术上是一个 XML 文件,但结构足够简单,我们可以通过仅读取单独的行并拆分字段来处理。当然,这有点脆弱。我们将限制自己处理数据集中的 100 万条记录;这可以避免内存使用过多,并且应该足够让我们处理。我们将处理后的数据保存为 JSON 文件,这样下次就不必再次处理:

def extract_stackexchange(filename, limit=1000000):
    json_file = filename + 'limit=%s.json' % limit

    rows = []
    for i, line in enumerate(os.popen('7z x -so "%s" Posts.xml'
                             % filename)):
        line = str(line)
        if not line.startswith('  <row'):
            continue

        if i % 1000 == 0:
            print('\r%05d/%05d' % (i, limit), end='', flush=True)

        parts = line[6:-5].split('"')
        record = {}
        for i in range(0, len(parts), 2):
            k = parts[i].replace('=', '').strip()
            v = parts[i+1].strip()
            record[k] = v
        rows.append(record)

        if len(rows) > limit:
            break

    with open(json_file, 'w') as fout:
        json.dump(rows, fout)

    return rows

rows = download_stackexchange()

讨论

Stack Exchange 数据集是一个提供问题/答案对的很好的来源,并附带一个很好的可重用许可证。只要您进行归属,您可以以几乎任何方式使用它。将压缩的 XML 转换为更易消耗的 JSON 格式是一个很好的预处理步骤。

6.2 使用 Pandas 探索数据

问题

如何快速探索大型数据集,以确保其中包含您期望的内容?

解决方案

使用 Python 的 Pandas。

Pandas 是 Python 中用于数据处理的强大框架。在某些方面,它类似于电子表格;数据存储在行和列中,我们可以快速过滤、转换和聚合记录。让我们首先将我们的 Python 字典行转换为DataFrame。Pandas 会尝试“猜测”一些列的类型。我们将强制将我们关心的列转换为正确的格式:

df = pd.DataFrame.from_records(rows)
df = df.set_index('Id', drop=False)
df['Title'] = df['Title'].fillna('').astype('str')
df['Tags'] = df['Tags'].fillna('').astype('str')
df['Body'] = df['Body'].fillna('').astype('str')
df['Id'] = df['Id'].astype('int')
df['PostTypeId'] = df['PostTypeId'].astype('int')

通过df.head,我们现在可以看到我们数据库中发生了什么。

我们也可以使用 Pandas 快速查看我们数据中的热门问题:

list(df[df['ViewCount'] > 2500000]['Title'])
['How to horizontally center a &lt;div&gt; in another &lt;div&gt;?',
 'What is the best comment in source code you have ever encountered?',
 'How do I generate random integers within a specific range in Java?',
 'How to redirect to another webpage in JavaScript/jQuery?',
 'How can I get query string values in JavaScript?',
 'How to check whether a checkbox is checked in jQuery?',
 'How do I undo the last commit(s) in Git?',
 'Iterate through a HashMap',
 'Get selected value in dropdown list using JavaScript?',
 'How do I declare and initialize an array in Java?']

正如你所期望的那样,最受欢迎的问题是关于经常使用的语言的一般问题。

讨论

Pandas 是一个很好的工具,适用于许多类型的数据分析,无论您只是想简单查看数据还是想进行深入分析。尽管很容易尝试利用 Pandas 来完成许多任务,但不幸的是,Pandas 的接口并不规范,对于复杂操作,性能可能会明显不如使用真正的数据库。在 Pandas 中进行查找比使用 Python 字典要昂贵得多,所以要小心!

6.3 使用 Keras 对文本进行特征化

问题

如何快速从文本创建特征向量?

解决方案

使用 Keras 的Tokenizer类。

在我们可以将文本输入模型之前,我们需要将其转换为特征向量。一个常见的方法是为文本中的前N个单词分配一个整数,然后用其整数替换每个单词。Keras 使这变得非常简单:

from keras.preprocessing.text import Tokenizer
VOCAB_SIZE = 50000

tokenizer = Tokenizer(num_words=VOCAB_SIZE)
tokenizer.fit_on_texts(df['Body'] + ' ' + df['Title'])

现在让我们对整个数据集的标题和正文进行标记化:

df['title_tokens'] = tokenizer.texts_to_sequences(df['Title'])
df['body_tokens'] = tokenizer.texts_to_sequences(df['Body'])

讨论

通过使用标记器将文本转换为一系列数字是使文本可被神经网络消化的经典方法之一。在前一章中,我们是基于每个字符进行文本转换的。基于字符的模型以单个字符作为输入(无需标记器)。权衡在于训练模型所需的时间:因为您强制模型学习如何对单词进行标记化和词干化,所以您需要更多的训练数据和更多的时间。

基于每个单词处理文本的一个缺点是,文本中出现的不同单词的数量没有实际上限,特别是如果我们必须处理拼写错误和错误。在这个示例中,我们只关注出现在前 50,000 位的单词,这是解决这个问题的一种方法。

6.4 构建问题/答案模型

问题

如何计算问题的嵌入?

解决方案

训练一个模型来预测 Stack Exchange 数据集中的问题和答案是否匹配。

每当我们构建一个模型时,我们应该问自己的第一个问题是:“我们的目标是什么?”也就是说,模型将尝试对什么进行分类?

理想情况下,我们会有一个“与此类似的问题列表”,我们可以用来训练我们的模型。不幸的是,获取这样的数据集将非常昂贵!相反,我们将依赖于一个替代目标:让我们看看是否可以训练我们的模型,即给定一个问题,区分匹配答案和来自随机问题的答案。这将迫使模型学习标题和正文的良好表示。

我们首先通过定义我们的输入来启动我们的模型。在这种情况下,我们有两个输入,标题(问题)和正文(答案):

title = layers.Input(shape=(None,), dtype='int32', name='title')
body = layers.Input(shape=(None,), dtype='int32', name='body')

两者长度不同,因此我们必须对它们进行填充。每个字段的数据将是一个整数列表,每个整数对应标题或正文中的一个单词。

现在我们想要定义一组共享的层,两个输入都将通过这些层。我们首先要为输入构建一个嵌入,然后屏蔽无效值,并将所有单词的值相加:

 embedding = layers.Embedding(
        mask_zero=True,
        input_dim=vocab_size,
        output_dim=embedding_size
    )

mask = layers.Masking(mask_value=0)
def _combine_sum(v):
    return K.sum(v, axis=2)

sum_layer = layers.Lambda(_combine_sum)

在这里,我们指定了 vocab_size(我们的词汇表中有多少单词)和 embedding_size(每个单词的嵌入应该有多宽;例如,GoogleNews 的向量是 300 维)。

现在让我们将这些层应用于我们的单词输入:

title_sum = sum_layer(mask(embedding(title)))
body_sum = sum_layer(mask(embedding(body)))

现在我们有了标题和正文的单个向量,我们可以通过余弦距离将它们相互比较,就像我们在 Recipe 4.2 中所做的那样。在 Keras 中,这通过 dot 层来表示:

sim = layers.dot([title_sum, word_sum], normalize=True, axes=1)

最后,我们可以定义我们的模型。它接受标题和正文作为输入,并输出两者之间的相似度:

sim_model = models.Model(inputs=[title,body], outputs=[sim])
sim_model.compile(loss='mse', optimizer='rmsprop')

讨论

我们在这里构建的模型学习匹配问题和答案,但实际上我们给它的唯一自由是改变单词的嵌入,使标题和正文的嵌入之和相匹配。这应该为我们提供问题的嵌入,使得相似的问题具有相似的嵌入,因为相似的问题将有相似的答案。

我们的训练模型编译时使用了两个参数,告诉 Keras 如何改进模型:

损失函数

这告诉系统一个给定答案有多“错误”。例如,如果我们告诉网络 title_abody_a 应该输出 1.0,但网络预测为 0.8,那么这是多么糟糕的错误?当我们有多个输出时,这将变得更加复杂,但我们稍后会涵盖这一点。对于这个模型,我们将使用 均方误差。对于前面的例子,这意味着我们将通过 (1.0–0.8) ** 2,或 0.04 来惩罚模型。这种损失将通过模型传播回去,并在模型看到一个示例时改进每次的嵌入。

优化器

有许多方法可以利用损失来改进我们的模型。这些被称为优化策略优化器。幸运的是,Keras 内置了许多可靠的优化器,所以我们不必太担心这个问题:我们只需要选择一个合适的。在这种情况下,我们使用rmsprop优化器,这个优化器在各种问题上表现非常好。

6.5 使用 Pandas 训练模型

问题

如何在 Pandas 中包含的数据上训练模型?

解决方案

构建一个数据生成器,利用 Pandas 的过滤和采样特性。

与前一个配方一样,我们将训练我们的模型来区分问题标题和正确答案(正文)与另一个随机问题的答案。我们可以将其写成一个迭代我们数据集的生成器。它将为正确的问题标题和正文输出 1,为随机标题和正文输出 0:

def data_generator(batch_size, negative_samples=1):
    questions = df[df['PostTypeId'] == 1]
    all_q_ids = list(questions.index)

    batch_x_a = []
    batch_x_b = []
    batch_y = []

    def _add(x_a, x_b, y):
        batch_x_a.append(x_a[:MAX_DOC_LEN])
        batch_x_b.append(x_b[:MAX_DOC_LEN])
        batch_y.append(y)

    while True:
        questions = questions.sample(frac=1.0)

        for i, q in questions.iterrows():
            _add(q['title_tokens'], q['body_tokens'], 1)

            negative_q = random.sample(all_q_ids, negative_samples)
            for nq_id in negative_q:
                _add(q['title_tokens'],
                     df.at[nq_id, 'body_tokens'], 0)

            if len(batch_y) >= batch_size:
                yield ({
                    'title': pad_sequences(batch_x_a, maxlen=None),
                    'body': pad_sequences(batch_x_b, maxlen=None),
                }, np.asarray(batch_y))

                batch_x_a = []
                batch_x_b = []
                batch_y = []

这里唯一的复杂性是数据的分批处理。这并不是绝对必要的,但对性能非常重要。所有深度学习模型都被优化为一次处理数据块。要使用的最佳批量大小取决于您正在处理的问题。使用更大的批量意味着您的模型在每次更新时看到更多数据,因此可以更准确地更新其权重,但另一方面它不能经常更新。更大的批量大小也需要更多内存。最好从小批量开始,然后将批量大小加倍,直到结果不再改善。

现在让我们训练模型:

sim_model.fit_generator(
    data_generator(batch_size=128),
    epochs=10,
    steps_per_epoch=1000
)

我们将进行 10,000 步的训练,分为 10 个包含 1,000 步的时期。每一步将处理 128 个文档,因此我们的网络最终将看到 1.28M 个训练示例。如果您有 GPU,您会惊讶地发现这个过程运行得多么快!

6.6 检查相似性

问题

您想使用 Keras 通过另一个网络的权重来预测值。

解决方案

构建一个使用原始网络不同输入和输出层的第二个模型,但共享其他一些层。

我们的sim_model已经训练过了,并且作为其中的一部分学会了如何从标题到title_sum,这正是我们想要的。只做这件事的模型是:

embedding_model = models.Model(inputs=[title], outputs=[title_sum])

现在我们可以使用“嵌入”模型为数据集中的每个问题计算一个表示。让我们将这个封装成一个易于重复使用的类:

questions = df[df['PostTypeId'] == 1]['Title'].reset_index(drop=True)
question_tokens = pad_sequences(tokenizer.texts_to_sequences(questions))

class EmbeddingWrapper(object):
    def __init__(self, model):
        self._questions = questions
        self._idx_to_question = {i:s for (i, s) in enumerate(questions)}
        self._weights = model.predict({'title': question_tokens},
                                      verbose=1, batch_size=1024)
        self._model = model
        self._norm = np.sqrt(np.sum(self._weights * self._weights
                                    + 1e-5, axis=1))

    def nearest(self, question, n=10):
        tokens = tokenizer.texts_to_sequences([sentence])
        q_embedding = self._model.predict(np.asarray(tokens))[0]
        q_norm= np.sqrt(np.dot(q_embedding, q_embedding))
        dist = np.dot(self._weights, q_embedding) / (q_norm * self._norm)

        top_idx = np.argsort(dist)[-n:]
        return pd.DataFrame.from_records([
            {'question': self._r[i], ‘similarity': float(dist[i])}
            for i in top_idx
        ])

现在我们可以使用它了:

lookup = EmbeddingWrapper(model=sum_embedding_trained)
lookup.nearest('Python Postgres object relational model')

这产生了以下结果:

相似性 问题
0.892392 使用 django 和 SQLAlchemy 但后端...
0.893417 自动生成/更新表的 Python ORM...
0.893883 在 SqlA 中进行动态表创建和 ORM 映射...
0.896096 使用 count、group_by 和 order_by 的 SQLAlchemy...
0.897706 使用 ORM 扫描大表?
0.902693 使用 SQLAlchemy 高效更新数据库...
0.911446 有哪些好的 Python ORM 解决方案?
0.922449 Python ORM
0.924316 从 r...构建类的 Python 库...
0.930865 允许创建表和构建的 Python ORM...

在非常短的训练时间内,我们的网络设法弄清楚了“SQL”、“查询”和“插入”都与 Postgres 有关!

讨论

在这个配方中,我们看到了如何使用网络的一部分来预测我们想要的值,即使整个网络是为了预测其他东西而训练的。Keras 的功能 API 提供了层之间、它们如何连接以及哪种输入和输出层组成模型的良好分离。

正如我们将在本书后面看到的,这给了我们很大的灵活性。我们可以取一个预训练的网络,并使用其中一个中间层作为输出层,或者我们可以取其中一个中间层并添加一些新的层(参见第九章)。我们甚至可以反向运行网络(参见第十二章)。

第七章:建议表情符号

在本章中,我们将构建一个模型,根据一小段文本建议表情符号。我们将首先基于一组带有各种情感标签的推文开发一个简单的情感分类器,如快乐、爱、惊讶等。我们首先尝试一个贝叶斯分类器,以了解基线性能,并查看这个分类器可以学到什么。然后我们将切换到卷积网络,并查看各种调整这个分类器的方法。

接下来我们将看看如何使用 Twitter API 收集推文,然后我们将应用配方 7.3 中的卷积模型,然后转向一个单词级模型。然后我们将构建并应用一个递归单词级网络,并比较这三种不同的模型。

最后,我们将把这三个模型组合成一个胜过任何一个的集成模型。

最终模型表现得非常不错,只需要整合到一个移动应用程序中!

本章的代码可以在这些笔记本中找到:

07.1 Text Classification
07.2 Emoji Suggestions
07.3 Tweet Embeddings

7.1 构建一个简单的情感分类器

问题

如何确定文本中表达的情感?

解决方案

找到一个由标记了情感的句子组成的数据集,并对其运行一个简单的分类器。

在尝试复杂的东西之前,首先尝试在一个 readily 可用的数据集上尝试我们能想到的最简单的事情是一个好主意。在这种情况下,我们将尝试基于一个已发布数据集构建一个简单的情感分类器。在接下来的配方中,我们将尝试做一些更复杂的事情。

快速的谷歌搜索让我们找到了一个来自 CrowdFlower 的不错的数据集,其中包含推文和情感标签。由于情感标签在某种程度上类似于表情符号,这是一个很好的开始。让我们下载文件并看一眼:

import pandas as pd
from keras.utils.data_utils import get_file
import nb_utils

emotion_csv = get_file('text_emotion.csv',
                       'https://www.crowdflower.com/wp-content/'
                       'uploads/2016/07/text_emotion.csv')
emotion_df = pd.read_csv(emotion_csv)

emotion_df.head()

这导致:

推文 ID 情感 作者 内容
0 1956967341 xoshayzers @tiffanylue 我知道我在听坏习惯...
1 1956967666 伤心 wannamama 躺在床上头疼,等待...
2 1956967696 伤心 coolfunky 葬礼仪式...阴郁的星期五...
3 1956967789 热情 czareaquino 想很快和朋友们一起出去!
4 1956968416 中性 xkilljoyx @dannycastillo 我们想和某人交易...

我们还可以检查各种情绪发生的频率:

emotion_df['sentiment'].value_counts()
neutral       8638
worry         8459
happiness     5209
sadness       5165
love          3842
surprise      2187

一些最简单的模型通常会产生令人惊讶的好结果,来自朴素贝叶斯家族。我们将从使用sklearn提供的方法对数据进行编码。TfidfVectorizer根据其逆文档频率为单词分配权重;经常出现的单词获得较低的权重,因为它们往往不太具有信息性。LabelEncoder为它看到的不同标签分配唯一的整数:

tfidf_vec = TfidfVectorizer(max_features=VOCAB_SIZE)
label_encoder = LabelEncoder()
linear_x = tfidf_vec.fit_transform(emotion_df['content'])
linear_y = label_encoder.fit_transform(emotion_df['sentiment'])

有了这些数据,我们现在可以构建贝叶斯模型并评估它:

bayes = MultinomialNB()
bayes.fit(linear_x, linear_y)
pred = bayes.predict(linear_x)
precision_score(pred, linear_y, average='micro')
0.28022727272727271

我们有 28%的正确率。如果我们总是预测最可能的类别,我们会得到略高于 20%,所以我们有了一个良好的开端。还有一些其他简单的分类器可以尝试,可能会做得更好,但速度较慢:

classifiers = {'sgd': SGDClassifier(loss='hinge'),
               'svm': SVC(),
               'random_forrest': RandomForestClassifier()}

for lbl, clf in classifiers.items():
    clf.fit(X_train, y_train)
    predictions = clf.predict(X_test)
    print(lbl, precision_score(predictions, y_test, average='micro'))
random_forrest 0.283939393939
svm 0.218636363636
sgd 0.325454545455

讨论

尝试“可能起作用的最简单的事情”有助于我们快速入门,并让我们了解数据是否具有足够的信号来完成我们想要做的工作。

贝叶斯分类器在早期的电子邮件垃圾邮件对抗中表现非常有效。然而,它们假设每个因素的贡献是彼此独立的——因此在这种情况下,推文中的每个单词对预测标签都有一定影响,独立于其他单词——这显然并非总是如此。一个简单的例子是,在句子中插入单词“not”可以否定表达的情感。尽管如此,该模型很容易构建,并且可以很快为我们带来结果,而且结果是可以理解的。一般来说,如果贝叶斯模型在您的数据上没有产生好的结果,使用更复杂的东西可能不会有太大帮助。

注意

贝叶斯模型通常比我们天真地期望的要好。关于这一点已经有一些有趣的研究。在机器学习之前,它们帮助破译了恩尼格玛密码,也帮助驱动了第一个电子邮件垃圾邮件检测器。

7.2 检查一个简单分类器

问题

如何查看一个简单分类器学到了什么?

解决方案

查看使分类器输出结果的贡献因素。

使用贝叶斯方法的一个优点是我们可以理解模型。正如我们在前面的配方中讨论的那样,贝叶斯模型假设每个单词的贡献与其他单词无关,因此为了了解我们的模型学到了什么,我们可以询问模型对个别单词的看法。

现在记住,模型期望一系列文档,每个文档都编码为一个向量,其长度等于词汇表的大小,每个元素编码为该文档中对应单词相对频率与所有文档的比率。因此,每个只包含一个单词的文档集合将是一个对角线上有 1 的方阵;第 n 个文档将对词汇表中的所有单词都有零,除了单词 n。现在我们可以为每个单词预测标签的可能性:

d = eye(len(tfidf_vec.vocabulary_))
word_pred = bayes.predict_proba(d)

然后我们可以查看所有预测,并找到每个类别的单词分数。我们将这些存储在一个Counter对象中,以便我们可以轻松访问贡献最大的单词:

by_cls = defaultdict(Counter)
for word_idx, pred in enumerate(word_pred):
    for class_idx, score in enumerate(pred):
        cls = label_encoder.classes_[class_idx]
        by_cls[cls][inverse_vocab[word_idx]] = score

让我们打印结果:

for k in by_cls:
    words = [x[0] for x in by_cls[k].most_common(5)]
    print(k, ':', ' '.join(words))
happiness : excited woohoo excellent yay wars
hate : hate hates suck fucking zomberellamcfox
boredom : squeaking ouuut cleanin sooooooo candyland3
enthusiasm : lena_distractia foolproofdiva attending krisswouldhowse tatt
fun : xbox bamboozle sanctuary oldies toodaayy
love : love mothers mommies moms loved
surprise : surprise wow surprised wtf surprisingly
empty : makinitrite conversating less_than_3 shakeyourjunk kimbermuffin
anger : confuzzled fridaaaayyyyy aaaaaaaaaaa transtelecom filthy
worry : worried poor throat hurts sick
relief : finally relax mastered relief inspiration
sadness : sad sadly cry cried miss
neutral : www painting souljaboytellem link frenchieb

讨论

在深入研究更复杂的内容之前检查一个简单模型学到了什么是一个有用的练习。尽管深度学习模型非常强大,但事实是很难真正了解它们在做什么。我们可以大致了解它们的工作原理,但要真正理解训练结果中的数百万个权重几乎是不可能的。

我们的贝叶斯模型的结果符合我们的预期。单词“sad”是“sadness”类的指示,“wow”是惊讶的指示。令人感动的是,单词“mothers”是爱的强烈指示。

我们看到了一堆奇怪的单词,比如“kimbermuffin”和“makinitrite”。检查后发现这些是 Twitter 用户名。 “foolproofdiva”只是一个非常热情的人。根据目标,我们可能会考虑将这些过滤掉。

7.3 使用卷积网络进行情感分析

问题

您想尝试使用深度网络来确定文本中表达的情感。

解决方案

使用卷积网络。

CNNs 更常用于图像识别(参见第九章),但它们在某些文本分类任务中也表现良好。其思想是在文本上滑动一个窗口,从而将一系列项目转换为(更短的)特征序列。在这种情况下,项目将是字符。每一步都使用相同的权重,因此我们不必多次学习相同的内容——单词“cat”在推文中的任何位置都表示“cat”:

char_input = Input(shape=(max_sequence_len, num_chars), name='input')

conv_1x = Conv1D(128, 6, activation='relu', padding='valid')(char_input)
max_pool_1x = MaxPooling1D(6)(conv_1x)
conv_2x = Conv1D(256, 6, activation='relu', padding='valid')(max_pool_1x)
max_pool_2x = MaxPooling1D(6)(conv_2x)

flatten = Flatten()(max_pool_2x)
dense = Dense(128, activation='relu')(flatten)
preds = Dense(num_labels, activation='softmax')(dense)

model = Model(char_input, preds)
model.compile(loss='sparse_categorical_crossentropy',
              optimizer='rmsprop',
              metrics=['acc'])

为了使模型运行,我们首先必须对数据进行向量化。我们将使用在前面的配方中看到的相同的一热编码,将每个字符编码为一个填满所有零的向量,除了第 n 个条目,其中 n 对应于我们要编码的字符:

chars = list(sorted(set(chain(*emotion_df['content']))))
char_to_idx = {ch: idx for idx, ch in enumerate(chars)}
max_sequence_len = max(len(x) for x in emotion_df['content'])

char_vectors = []
for txt in emotion_df['content']:
    vec = np.zeros((max_sequence_len, len(char_to_idx)))
    vec[np.arange(len(txt)), [char_to_idx[ch] for ch in txt]] = 1
    char_vectors.append(vec)
char_vectors = np.asarray(char_vectors)
char_vectors = pad_sequences(char_vectors)
labels = label_encoder.transform(emotion_df['sentiment'])

让我们将数据分成训练集和测试集:

def split(lst):
    training_count = int(0.9 * len(char_vectors))
    return lst[:training_count], lst[training_count:]

training_char_vectors, test_char_vectors = split(char_vectors)
training_labels, test_labels = split(labels)

现在我们可以训练模型并评估它:

char_cnn_model.fit(training_char_vectors, training_labels,
                   epochs=20, batch_size=1024)
char_cnn_model.evaluate(test_char_vectors, test_labels)

经过 20 个时代,训练准确率达到 0.39,但测试准确率只有 0.31。这种差异可以通过过拟合来解释;模型不仅学习了数据的一般方面,这些方面也适用于测试集,而且开始记忆部分训练数据。这类似于学生学习哪些答案与哪些问题匹配,而不理解为什么。

讨论

卷积网络在我们希望网络学习独立于发生位置的情况下效果很好。对于图像识别,我们不希望网络为每个像素单独学习;我们希望它学会独立于图像中发生位置的特征。

同样,对于文本,我们希望模型学会,如果推文中出现“爱”这个词,那么“爱”将是一个好的标签。我们不希望模型为每个位置单独学习这一点。CNN 通过在文本上运行一个滑动窗口来实现这一点。在这种情况下,我们使用大小为 6 的窗口,因此我们每次取 6 个字符;对于包含 125 个字符的推文,我们会应用这个过程 120 次。

关键的是,这 120 个神经元中的每一个都使用相同的权重,因此它们都学习相同的东西。在卷积之后,我们应用一个max_pooling层。这一层将取六个神经元的组并输出它们激活的最大值。我们可以将其视为将任何神经元中最强的理论传递给下一层。它还将大小减小了六分之一。

在我们的模型中,我们有两个卷积/最大池化层,它将输入从 167×100 的大小更改为 3×256。我们可以将这些看作是增加抽象级别的步骤。在输入级别,我们只知道在 167 个位置中的每一个位置上出现了 100 个不同字符中的哪一个。在最后一个卷积之后,我们有 3 个 256 个向量,它们分别编码了推文开头、中间和结尾发生的情况。

7.4 收集 Twitter 数据

问题

如何自动收集大量用于训练目的的 Twitter 数据?

解决方案

使用 Twitter API。

首先要做的是前往https://apps.twitter.com注册一个新应用。点击“创建新应用”按钮并填写表格。我们不会代表用户做任何事情,所以可以将回调 URL 字段留空。

完成后,您应该有两个密钥和两个允许访问 API 的密钥。让我们将它们存储在相应的变量中:

CONSUMER_KEY = '<your value>'
CONSUMER_SECRET = '<your value>'
ACCESS_TOKEN = '<your value>'
ACCESS_SECRET = '<your value>'

现在我们可以构建一个认证对象:

auth=twitter.OAuth(
    consumer_key=CONSUMER_KEY,
    consumer_secret=CONSUMER_SECRET,
    token=ACCESS_TOKEN,
    token_secret=ACCESS_SECRET,
)

Twitter API 有两部分。REST API 使得可以调用各种函数来搜索推文、获取用户的状态,甚至发布到 Twitter。在这个示例中,我们将使用流 API。

如果你付费给 Twitter,你将获得一个包含所有推文的流。如果你不付费,你会得到所有推文的一个样本。这对我们的目的已经足够了:

status_stream = twitter.TwitterStream(auth=auth).statuses

stream对象有一个迭代器sample,它将产生推文。让我们使用itertools.islice来查看其中一些:

[x['text'] for x in itertools.islice(stream.sample(), 0, 5) if x.get('text')]

在这种情况下,我们只想要英文推文,并且至少包含一个表情符号:

def english_has_emoji(tweet):
    if tweet.get('lang') != 'en':
        return False
    return any(ch for ch in tweet.get('text', '') if ch in emoji.UNICODE_EMOJI)

现在我们可以获取包含至少一个表情符号的一百条推文:

tweets = list(itertools.islice(
    filter(english_has_emoji, status_stream.sample()), 0, 100))

我们每秒获得两到三条推文,这还不错,但要花一段时间才能拥有一个规模可观的训练集。我们只关心那些只有一种类型的表情符号的推文,我们只想保留该表情符号和文本:

stripped = []
for tweet in tweets:
    text = tweet['text']
    emojis = {ch for ch in text if ch in emoji.UNICODE_EMOJI}
    if len(emojis) == 1:
        emoiji = emojis.pop()
        text = ''.join(ch for ch in text if ch != emoiji)
        stripped.append((text, emoiji))

讨论

Twitter 可以是一个非常有用的训练数据来源。每条推文都有大量与之相关的元数据,从发布推文的账户到图片和哈希标签。在本章中,我们只使用语言元信息,但这是一个值得探索的丰富领域。

7.5 一个简单的表情符号预测器

问题

如何预测最匹配一段文本的表情符号?

解决方案

重新利用来自 Recipe 7.3 的情感分类器。

如果在上一步中收集了大量推文,可以使用这些。如果没有,可以在data/emojis.txt中找到一个好的样本。让我们将这些读入 Pandas 的DataFrame。我们将过滤掉出现次数少于 1000 次的任何表情符号:

all_tweets = pd.read_csv('data/emojis.txt',
        sep='\t', header=None, names=['text', 'emoji'])
tweets = all_tweets.groupby('emoji').filter(lambda c:len(c) > 1000)
tweets['emoji'].value_counts()

这个数据集太大了,无法以向量化形式保存在内存中,所以我们将使用生成器进行训练。Pandas 方便地提供了一个sample方法,允许我们使用以下data_generator

def data_generator(tweets, batch_size):
    while True:
        batch = tweets.sample(batch_size)
        X = np.zeros((batch_size, max_sequence_len, len(chars)))
        y = np.zeros((batch_size,))
        for row_idx, (_, row) in enumerate(batch.iterrows()):
            y[row_idx] = emoji_to_idx[row['emoji']]
            for ch_idx, ch in enumerate(row['text']):
                X[row_idx, ch_idx, char_to_idx[ch]] = 1
        yield X, y

我们现在可以在不进行修改的情况下从 Recipe 7.3 训练模型:

train_tweets, test_tweets = train_test_split(tweets, test_size=0.1)
BATCH_SIZE = 512
char_cnn_model.fit_generator(
    data_generator(train_tweets, batch_size=BATCH_SIZE),
    epochs=20,
    steps_per_epoch=len(train_tweets) / BATCH_SIZE,
    verbose=2
)

模型训练到大约 40%的精度。即使考虑到顶部表情符号比底部表情符号更频繁出现,这听起来还是相当不错的。如果我们在评估集上运行模型,精度得分会从 40%下降到略高于 35%:

char_cnn_model.evaluate_generator(
    data_generator(test_tweets, batch_size=BATCH_SIZE),
    steps=len(test_tweets) / BATCH_SIZE
)
[3.0898117224375405, 0.35545459692028986]

讨论

在不对模型本身进行任何更改的情况下,我们能够为推文建议表情符号,而不是运行情感分类。这并不太令人惊讶;在某种程度上,表情符号是作者应用的情感标签。对于这两个任务性能大致相同可能不太出乎意料,因为我们有更多的标签,而且我们预计标签会更加嘈杂。

7.6 Dropout 和多窗口

问题

你如何提高网络的性能?

解决方案

增加可训练变量的数量,同时引入了一种使更大的网络难以过拟合的技术——dropout。

增加神经网络的表达能力的简单方法是使其更大,可以通过使单个层更大或向网络添加更多层来实现。具有更多变量的网络具有更高的学习能力,并且可以更好地泛化。然而,这并非是免费的;在某个时候,网络开始过拟合。(Recipe 1.3 更详细地描述了这个问题。)

让我们从扩展当前网络开始。在上一个配方中,我们为卷积使用了步长 6。六个字符似乎是一个合理的数量来捕捉局部信息,但也稍微随意。为什么不是四或五呢?实际上我们可以做这三种然后将结果合并:

layers = []
for window in (4, 5, 6):
    conv_1x = Conv1D(128, window, activation='relu',
                     padding='valid')(char_input)
    max_pool_1x = MaxPooling1D(4)(conv_1x)
    conv_2x = Conv1D(256, window, activation='relu',
                     padding='valid')(max_pool_1x)
    max_pool_2x = MaxPooling1D(4)(conv_2x)
    layers.append(max_pool_2x)

merged = Concatenate(axis=1)(layers)

使用这个网络的额外层,训练过程中精度提高到 47%。但不幸的是,测试集上的精度仅达到 37%。这仍然比之前稍微好一点,但过拟合差距已经增加了很多。

有许多防止过拟合的技术,它们的共同点是限制模型可以学习的内容。其中最流行的之一是添加Dropout层。在训练期间,Dropout会随机将所有神经元的权重设置为零的一部分。这迫使网络更加稳健地学习,因为它不能依赖于特定的神经元存在。在预测期间,所有神经元都在工作,这会平均结果并减少异常值。这减缓了过拟合的速度。

在 Keras 中,我们像添加任何其他层一样添加Dropout。我们的模型随后变为:

    for window in (4, 5, 6):
        conv_1x = Conv1D(128, window,
                         activation='relu', padding='valid')(char_input)
        max_pool_1x = MaxPooling1D(4)(conv_1x)
        dropout_1x = Dropout(drop_out)(max_pool_1x)
        conv_2x = Conv1D(256, window,
                         activation='relu', padding='valid')(dropout_1x)
        max_pool_2x = MaxPooling1D(4)(conv_2x)
        dropout_2x = Dropout(drop_out)(max_pool_2x)
        layers.append(dropout_2x)

    merged = Concatenate(axis=1)(layers)

    dropout = Dropout(drop_out)(merged)

选择 dropout 值有点艺术性。较高的值意味着更稳健的模型,但训练速度也更慢。使用 0.2 进行训练将训练精度提高到 0.43,测试精度提高到 0.39,这表明我们仍然可以更高。

讨论

这个配方提供了一些我们可以使用的技术来改善网络性能的想法。通过添加更多层,尝试不同的窗口,并在不同位置引入Dropout层,我们有很多旋钮可以调整来优化我们的网络。找到最佳值的过程称为超参数调整

有一些框架可以通过尝试各种组合来自动找到最佳参数。由于它们需要多次训练模型,您需要耐心等待或者可以同时训练多个实例来并行训练您的模型。

7.7 构建一个单词级模型

问题

推文是单词,而不仅仅是随机字符。您如何利用这一事实?

解决方案

训练一个以单词嵌入序列作为输入而不是字符序列的模型。

首先要做的是对我们的推文进行标记化。我们将构建一个保留前 50000 个单词的标记器,将其应用于我们的训练和测试集,然后填充两者,使它们具有统一的长度:

VOCAB_SIZE = 50000
tokenizer = Tokenizer(num_words=VOCAB_SIZE)
tokenizer.fit_on_texts(tweets['text'])
training_tokens = tokenizer.texts_to_sequences(train_tweets['text'])
test_tokens = tokenizer.texts_to_sequences(test_tweets['text'])
max_num_tokens = max(len(x) for x in chain(training_tokens, test_tokens))
training_tokens = pad_sequences(training_tokens, maxlen=max_num_tokens)
test_tokens = pad_sequences(test_tokens, maxlen=max_num_tokens)

我们可以通过使用预训练的嵌入来快速启动我们的模型(请参见第三章)。我们将使用一个实用函数load_wv2加载权重,它将加载 Word2vec 嵌入并将其与我们语料库中的单词匹配。这将构建一个矩阵,每个令牌包含来自 Word2vec 模型的权重的一行:

def load_w2v(tokenizer=None):
    w2v_model = gensim.models.KeyedVectors.load_word2vec_format(
        word2vec_vectors, binary=True)

    total_count = sum(tokenizer.word_counts.values())
    idf_dict = {k: np.log(total_count/v)
                for (k,v) in tokenizer.word_counts.items()}

    w2v = np.zeros((tokenizer.num_words, w2v_model.syn0.shape[1]))
    idf = np.zeros((tokenizer.num_words, 1))

    for k, v in tokenizer.word_index.items():
        if < tokenizer.num_words and k in w2v_model:
            w2v[v] = w2v_model[k]
            idf[v] = idf_dict[k]

    return w2v, idf

现在我们可以创建一个与我们的字符模型非常相似的模型,主要是改变如何处理输入。我们的输入接受一系列令牌,嵌入层在我们刚刚创建的矩阵中查找每个令牌:

    message = Input(shape=(max_num_tokens,), dtype='int32', name='title')
    embedding = Embedding(mask_zero=False, input_dim=vocab_size,
                          output_dim=embedding_weights.shape[1],
                          weights=[embedding_weights],
                          trainable=False,
                          name='cnn_embedding')(message)

这个模型可以工作,但效果不如字符模型好。我们可以调整各种超参数,但差距相当大(字符级模型的精度为 38%,而单词级模型的精度为 30%)。有一件事可以改变这种情况——将嵌入层的trainable属性设置为True。这有助于将单词级模型的精度提高到 36%,但这也意味着我们使用了错误的嵌入。我们将在下一个配方中看看如何解决这个问题。

讨论

一个单词级模型比一个字符级模型对输入数据有更广泛的视角,因为它查看的是单词的簇,而不是字符的簇。我们使用单词嵌入来快速开始,而不是使用字符的独热编码。在这里,我们通过表示每个单词的向量来表示该单词的语义值作为模型的输入。(有关单词嵌入的更多信息,请参见第三章。)

这个配方中介绍的模型并没有超越我们的字符级模型,也没有比我们在配方 7.1 中看到的贝叶斯模型做得更好。这表明我们预训练的单词嵌入的权重与我们的问题不匹配。如果我们将嵌入层设置为可训练,事情会好得多;如果允许它更改这些嵌入,模型会有所改进。我们将在下一个配方中更详细地讨论这个问题。

权重不匹配并不令人惊讶。Word2vec 模型是在 Google 新闻上训练的,它的语言使用方式与社交媒体上的平均使用方式有很大不同。例如,流行的标签在 Google 新闻语料库中不会出现,而它们似乎对于分类推文非常重要。

7.8 构建您自己的嵌入

问题

如何获取与您的语料库匹配的单词嵌入?

解决方案

训练您自己的单词嵌入。

gensim包不仅让我们可以使用预训练的嵌入模型,还可以训练新的嵌入。它需要的唯一东西是一个生成器,产生令牌序列。它将使用这个生成器来建立词汇表,然后通过多次遍历生成器来训练模型。以下对象将遍历一系列推文,清理它们,并对其进行标记化:

class TokensYielder(object):
    def __init__(self, tweet_count, stream):
        self.tweet_count = tweet_count
        self.stream = stream

    def __iter__(self):
        print('!')
        count = self.tweet_count
        for tweet in self.stream:
            if tweet.get('lang') != 'en':
                continue
            text = tweet['text']
            text = html.unescape(text)
            text = RE_WHITESPACE.sub(' ', text)
            text = RE_URL.sub(' ', text)
            text = strip_accents(text)
            text = ''.join(ch for ch in text if ord(ch) < 128)
            if text.startswith('RT '):
                text = text[3:]
            text = text.strip()
            if text:
                yield text_to_word_sequence(text)
                count -= 1
                if count <= 0:
                    break

现在我们可以训练模型了。明智的做法是收集一周左右的推文,将它们保存在一组文件中(每行一个 JSON 文档是一种流行的格式),然后将一个遍历文件的生成器传递到TokensYielder中。

在我们开始这项工作并等待一周让我们的推文涓涓细流进来之前,我们可以通过获取 100,000 条经过筛选的推文来测试这是否有效:

tweets = list(TokensYielder(100000,
              twitter.TwitterStream(auth=auth).statuses.sample()))

然后构建模型:

model = gensim.models.Word2Vec(tweets, min_count=2)

查看单词“爱”的最近邻居,我们发现我们确实有自己的领域特定的嵌入——只有在 Twitter 上,“453”与“爱”相关,因为在线上它是“酷故事,兄弟”的缩写:

model.wv.most_similar(positive=['love'], topn=5)
[('hate', 0.7243724465370178),
 ('loved', 0.7227891087532043),
 ('453', 0.707709789276123),
 ('melanin', 0.7069753408432007),
 ('appreciate', 0.696381688117981)]

“黑色素”稍微出乎意料。

讨论

使用现有的词嵌入是一个快速入门的好方法,但只适用于我们处理的文本与嵌入训练的文本相似的情况。在这种情况不成立且我们可以访问大量与我们正在训练的文本相似的文本的情况下,我们可以轻松地训练自己的词嵌入。

正如我们在前一篇文章中看到的,一个训练新嵌入的替代方法是使用现有的嵌入,但将层的trainable属性设置为True。这将使网络调整嵌入层中单词的权重,并在缺失的地方找到新的单词。

7.9 使用递归神经网络进行分类

问题

当然有一种方法可以利用推文是一系列单词的事实。你可以怎么做呢?

解决方案

使用单词级递归网络进行分类。

卷积网络适用于在输入流中发现局部模式。对于情感分析,这通常效果很好;某些短语会独立于它们出现的位置影响句子的情感。然而,建议表情符号的任务中有一个时间元素,我们没有利用 CNN。与推文相关联的表情符号通常是推文的结论。在这种情况下,RNN 可能更合适。

我们看到了如何教 RNN 生成文本在第五章。我们可以使用类似的方法来建议表情符号。就像单词级 CNN 一样,我们将输入转换为它们的嵌入的单词。一个单层 LSTM 表现得相当不错:

def create_lstm_model(vocab_size, embedding_size=None, embedding_weights=None):
    message = layers.Input(shape=(None,), dtype='int32', name='title')
    embedding = Embedding(mask_zero=False, input_dim=vocab_size,
                          output_dim=embedding_weights.shape[1],
                          weights=[embedding_weights],
                          trainable=True,
                          name='lstm_embedding')(message)

    lstm_1 = layers.LSTM(units=128, return_sequences=False)(embedding)
    category = layers.Dense(units=len(emojis), activation='softmax')(lstm_1)

    model = Model(
        inputs=[message],
        outputs=[category],
    )
    model.compile(loss='sparse_categorical_crossentropy',
                  optimizer='rmsprop', metrics=['accuracy'])
    return model

经过 10 个时期,我们在训练集上达到了 50%的精度,在测试集上达到了 40%,远远超过了 CNN 模型。

讨论

我们在这里使用的 LSTM 模型明显优于我们的单词级 CNN。我们可以将这种卓越的性能归因于推文是序列,推文末尾发生的事情与开头发生的事情有不同的影响。

由于我们的字符级 CNN 往往比我们的单词级 CNN 做得更好,而我们的单词级 LSTM 比字符级 CNN 做得更好,我们可能会想知道字符级 LSTM 是否会更好。结果表明并不是。

原因是,如果我们一次向 LSTM 输入一个字符,到达末尾时,它大部分时间都会忘记推文开头发生的事情。如果我们一次向 LSTM 输入一个单词,它就能克服这个问题。还要注意,我们的字符级 CNN 实际上并不是一次处理一个字符。我们一次使用四、五或六个字符的序列,并且将多个卷积层堆叠在一起,这样平均推文在最高级别只剩下三个特征向量。

我们可以尝试将两者结合起来,通过创建一个 CNN 将推文压缩成更高抽象级别的片段,然后将这些向量输入到 LSTM 中得出最终结论。当然,这与我们的单词级 LSTM 的工作方式非常接近。我们不是使用 CNN 对文本片段进行分类,而是使用预训练的词嵌入在每个单词级别上执行相同的操作。

7.10 可视化(不)一致性

问题

您希望可视化您构建的不同模型在实践中的比较。

解决方案

使用 Pandas 显示它们的一致性和不一致性。

精度给我们一个关于我们的模型表现如何的概念。虽然建议表情符号是一个相当嘈杂的任务,但是将我们的各种模型的表现并排进行比较是非常有用的。Pandas 是一个很好的工具。

让我们首先将字符模型的测试数据作为向量而不是生成器导入:

test_char_vectors, _ = next(data_generator(test_tweets, None))

现在让我们对前 100 个项目进行预测:

predictions = {
    label: [emojis[np.argmax(x)] for x in pred]
    for label, pred in (
        ('lstm', lstm_model.predict(test_tokens[:100])),
        ('char_cnn', char_cnn_model.predict(test_char_vectors[:100])),
        ('cnn', cnn_model.predict(test_tokens[:100])),
    )
}

现在我们可以构建并显示一个 Pandas DataFrame,其中包含每个模型的前 25 个预测,以及推文文本和原始表情符号:

pd.options.display.max_colwidth = 128
test_df = test_tweets[:100].reset_index()
eval_df = pd.DataFrame({
    'content': test_df['text'],
    'true': test_df['emoji'],
    **predictions
})
eval_df[['content', 'true', 'char_cnn', 'cnn', 'lstm']].head(25)

这样就得到了:

# 内容 真实 char_cnn cnn lstm
0 @Gurmeetramrahim @RedFMIndia @rjraunac #8DaysToLionHeart 太棒了
1 @suchsmallgods 我迫不及待想向他展示这些推文
2 @Captain_RedWolf 我有大约 20 套 lol 比你领先太多了
3 @OtherkinOK 刚刚在@EPfestival,太棒了!下一站是@whelanslive,2016 年 11 月 11 日星期五。
4 @jochendria: KathNiel 与 GForce Jorge。#PushAwardsKathNiels
5 好的
6 “Distraught 意味着心烦意乱” “那意味着困惑对吧?” -@ReevesDakota
7 @JennLiri 宝贝,怎么了,打电话回来,我想听这首铃声
8 珍想要做朋友吗?我们可以成为朋友。爱你,女孩。#BachelorInParadise
9 @amwalker38: 去关注这些热门账号 @the1stMe420 @DanaDeelish @So_deelish @aka_teemoney38 @CamPromoXXX @SexyLThings @l...
10 @gspisak: 我总是取笑那些提前 30 分钟以上来接孩子的父母,今天轮到我了,至少我得到了...
11 @ShawnMendes: 多伦多广告牌。太酷了!@spotify #ShawnXSpotify 去找到你所在城市的广告牌
12 @kayleeburt77 我可以要你的号码吗?我好像把我的弄丢了。
13 @KentMurphy: 蒂姆·提博在职业棒球比赛中第一球就击出了一支全垒打
14 @HailKingSoup...
15 @RoxeteraRibbons 同样,我必须找出证明
16 @theseoulstory: 九月回归:2PM,SHINee,INFINITE,BTS,Red Velvet,Gain,Song Jieun,Kanto...
17 @VixenMusicLabel - 和平与爱
18 @iDrinkGallons 抱歉
19 @StarYouFollow: 19- Frisson
20 @RapsDaiIy: 别错过 Ugly God
21 怎么我的所有班次都这么快被接走了?!什么鬼
22 @ShadowhuntersTV: #Shadowhunters 粉丝,你们会给这个父女#FlashbackFriday 亲密时刻打几分?
23 @mbaylisxo: 感谢上帝,我有一套制服,不用每天担心穿什么
24 心情波动如...

浏览这些结果,我们可以看到通常当模型出错时,它们会落在与原始推文中非常相似的表情符号上。有时,预测似乎比实际使用的更有意义,有时候没有一个模型表现得很好。

讨论

查看实际数据可以帮助我们看到我们的模型出错的地方。在这种情况下,提高性能的一个简单方法是将所有相似的表情符号视为相同的。不同的心形和不同的笑脸表达的基本上是相同的。

一个替代方案是为表情符号学习嵌入。这将给我们一个关于表情符号相关性的概念。然后,我们可以有一个损失函数,考虑到这种相似性,而不是一个硬性的正确/错误度量。

7.11 结合模型

问题

您希望利用模型的联合预测能力获得更好的答案。

解决方案

将模型组合成一个集成模型。

群体智慧的概念——即群体意见的平均值通常比任何具体意见更准确——也适用于机器学习模型。我们可以通过使用三个输入将所有三个模型合并为一个模型,并使用 Keras 的Average层来组合我们模型的输出:

def prediction_layer(model):
    layers = [layer for layer in model.layers
              if layer.name.endswith('_predictions')]
    return layers[0].output

def create_ensemble(*models):
    inputs = [model.input for model in models]
    predictions = [prediction_layer(model) for model in models]
    merged = Average()(predictions)
    model = Model(
        inputs=inputs,
        outputs=[merged],
    )
    model.compile(loss='sparse_categorical_crossentropy',
                  optimizer='rmsprop',
                  metrics=['accuracy'])
    return model

我们需要一个不同的数据生成器来训练这个模型;而不是指定一个输入,我们现在有三个输入。由于它们有不同的名称,我们可以让我们的数据生成器产生一个字典来提供这三个输入。我们还需要做一些整理工作,使字符级数据与单词级数据对齐:

def combined_data_generator(tweets, tokens, batch_size):
    tweets = tweets.reset_index()
    while True:
        batch_idx = random.sample(range(len(tweets)), batch_size)
        tweet_batch = tweets.iloc[batch_idx]
        token_batch = tokens[batch_idx]
        char_vec = np.zeros((batch_size, max_sequence_len, len(chars)))
        token_vec = np.zeros((batch_size, max_num_tokens))
        y = np.zeros((batch_size,))
        it = enumerate(zip(token_batch, tweet_batch.iterrows()))
        for row_idx, (token_row, (_, tweet_row)) in it:
            y[row_idx] = emoji_to_idx[tweet_row['emoji']]
            for ch_idx, ch in enumerate(tweet_row['text']):
                char_vec[row_idx, ch_idx, char_to_idx[ch]] = 1
            token_vec[row_idx, :] = token_row
        yield {'char_cnn_input': char_vec,
               'cnn_input': token_vec,
               'lstm_input': token_vec}, y

然后我们可以使用以下方式训练模型:

BATCH_SIZE = 512
ensemble.fit_generator(
    combined_data_generator(train_tweets, training_tokens, BATCH_SIZE),
    epochs=20,
    steps_per_epoch=len(train_tweets) / BATCH_SIZE,
    verbose=2,
    callbacks=[early]
)

讨论

组合模型或集成模型是将各种方法结合到一个模型中解决问题的好方法。在像 Kaggle 这样的流行机器学习竞赛中,获胜者几乎总是基于这种技术,这并非巧合。

而不是将模型几乎完全分开,然后在最后使用Average层将它们连接起来,我们也可以在更早的阶段将它们连接起来,例如通过连接每个模型的第一个密集层。实际上,这在更复杂的 CNN 中是我们所做的一部分,我们使用了各种窗口大小的小子网,然后将它们连接起来得出最终结论。

第八章:序列到序列映射

在本章中,我们将研究使用序列到序列网络来学习文本片段之间的转换。这是一种相对较新的技术,具有诱人的可能性。谷歌声称已经通过这种技术大大改进了其 Google 翻译产品;此外,它已经开源了一个版本,可以纯粹基于平行文本学习语言翻译。

我们不会一开始就走得那么远。相反,我们将从一个简单的模型开始,学习英语中复数形式的规则。之后,我们将从古腾堡计划的 19 世纪小说中提取对话,并在其中训练一个聊天机器人。对于这个最后的项目,我们将不得不放弃在笔记本中运行 Keras 的安全性,并使用谷歌的开源 seq2seq 工具包。

以下笔记本包含本章相关的代码:

08.1 Sequence to sequence mapping
08.2 Import Gutenberg
08.3 Subword tokenizing

8.1 训练一个简单的序列到序列模型

问题

你如何训练一个模型来逆向工程一个转换?

解决方案

使用序列到序列的映射器。

在第五章中,我们看到了如何使用循环网络来“学习”序列的规则。模型学习如何最好地表示一个序列,以便能够预测下一个元素是什么。序列到序列映射建立在此基础上,但现在模型学习根据第一个序列预测不同的序列。

我们可以使用这个来学习各种转换。让我们考虑将英语中的单数名词转换为复数名词。乍一看,这似乎只是在单词后添加一个s,但当你仔细观察时,你会发现规则实际上相当复杂。

这个模型与我们在第五章中使用的模型非常相似,但现在不仅输入是一个序列,输出也是一个序列。这是通过使用RepeatVector层实现的,它允许我们从输入映射到输出向量:

def create_seq2seq(num_nodes, num_layers):
    question = Input(shape=(max_question_len, len(chars),
                     name='question'))
    repeat = RepeatVector(max_expected_len)(question)
    prev = input
    for _ in range(num_layers)::
        lstm = LSTM(num_nodes, return_sequences=True,
                    name='lstm_layer_%d' % (i + 1))(prev)
        prev = lstm
    dense = TimeDistributed(Dense(num_chars, name='dense',
                            activation='softmax'))(prev)
    model = Model(inputs=[input], outputs=[dense])
    optimizer = RMSprop(lr=0.01)
    model.compile(loss='categorical_crossentropy',
                  optimizer=optimizer,
                  metrics=['accuracy'])
    return model

数据的预处理与以前大致相同。我们从文件data/plurals.txt中读取数据并将其向量化。一个要考虑的技巧是是否要颠倒输入中的字符串。如果输入被颠倒,那么生成输出就像展开处理,这可能更容易。

模型需要相当长的时间才能达到接近 99%的精度。然而,大部分时间都花在学习如何重现单词的单数和复数形式共享的前缀上。事实上,当我们检查模型在达到超过 99%精度时的性能时,我们会发现大部分错误仍然在这个领域。

讨论

序列到序列模型是强大的工具,只要有足够的资源,就可以学习几乎任何转换。学习从英语的单数到复数的规则只是一个简单的例子。这些模型是领先科技公司提供的最先进的机器翻译解决方案的基本元素。

像这个配方中的简单模型可以学习如何在罗马数字中添加数字,或者学习在书面英语和音标英语之间进行翻译,这在构建文本到语音系统时是一个有用的第一步。

在接下来的几个配方中,我们将看到如何使用这种技术基于从 19 世纪小说中提取的对话来训练一个聊天机器人。

8.2 从文本中提取对话

问题

你如何获取大量的对话语料库?

解决方案

解析一些从古腾堡计划中提供的文本,并提取所有的对话。

让我们从古腾堡计划中下载一套书籍。我们可以下载所有的书籍,但在这里我们将专注于那些作者出生在 1835 年之后的作品。这样可以使对话保持现代。data/books.json文档包含相关的参考资料:

with open('data/gutenberg_index.json') as fin:
    authors = json.load(fin)
recent = [x for x in authors
          if 'birthdate' in x and x['birthdate'] > 1830]
[(x['name'], x['birthdate'], x['english_books']) for x in recent[:5]]
[('Twain, Mark', 1835, 210),
 ('Ebers, Georg', 1837, 164),
 ('Parker, Gilbert', 1862, 135),
 ('Fenn, George Manville', 1831, 128),
 ('Jacobs, W. W. (William Wymark)', 1863, 112)]

这些书大多以 ASCII 格式一致排版。段落之间用双换行符分隔,对话几乎总是使用双引号。少量书籍也使用单引号,但我们将忽略这些,因为单引号也出现在文本的其他地方。我们假设对话会持续下去,只要引号外的文本长度不超过 100 个字符(例如,“嗨,”他说,“你好吗?”):

def extract_conversations(text, quote='"'):
    paragraphs = PARAGRAPH_SPLIT_RE.split(text.strip())
    conversations = [['']]
    for paragraph in paragraphs:
        chunks = paragraph.replace('\n', ' ').split(quote)
        for i in range((len(chunks) + 1) // 2):
            if (len(chunks[i * 2]) > 100
                or len(chunks) == 1) and conversations[-1] != ['']:
                if conversations[-1][-1] == '':
                    del conversations[-1][-1]
                conversations.append([''])
            if i * 2 + 1 < len(chunks):
                chunk = chunks[i * 2 + 1]
                if chunk:
                    if conversations[-1][-1]:
                        if chunk[0] >= 'A' and chunk[0] <= 'Z':
                            if conversations[-1][-1].endswith(','):
                                conversations[-1][-1] = \
                                     conversations[-1][-1][:-1]
                            conversations[-1][-1] += '.'
                        conversations[-1][-1] += ' '
                    conversations[-1][-1] += chunk
        if conversations[-1][-1]:
            conversations[-1].append('')

    return [x for x in conversations if len(x) > 1]

处理前 1000 位作者的数据可以得到一组良好的对话数据:

for author in recent[:1000]:
    for book in author['books']:
        txt = strip_headers(load_etext(int(book[0]))).strip()
        conversations += extract_conversations(txt)

这需要一些时间,所以最好将结果保存到文件中:

with open('gutenberg.txt', 'w') as fout:
    for conv in conversations:
        fout.write('\n'.join(conv) + '\n\n')

讨论

正如我们在第五章中看到的,Project Gutenberg 是一个很好的来源,可以自由使用文本,只要我们不介意它们有点过时,因为它们必须已经过期。

该项目是在关于布局和插图的担忧之前开始的,因此所有文档都是以纯 ASCII 格式生成的。虽然这不是实际书籍的最佳格式,但它使解析相对容易。段落之间用双换行符分隔,没有智能引号或任何标记。

8.3 处理开放词汇

问题

如何仅使用固定数量的标记完全标记化文本?

解决方案

使用子词单元进行标记化。

在前一章中,我们只是跳过了我们前 50,000 个单词的词汇表中找不到的单词。通过子词单元标记化,我们将不经常出现的单词分解为更小的子单元,直到所有单词和子单元适合我们的固定大小的词汇表。

例如,如果我们有workingworked这两个词,我们可以将它们分解为work--ed-ing。这三个标记很可能会与我们词汇表中的其他标记重叠,因此这可能会减少我们整体词汇表的大小。所使用的算法很简单。我们将所有标记分解为它们的各个字母。此时,每个字母都是一个子词标记,而且我们可能少于最大数量的标记。然后找出在我们的标记中最常出现的一对子词标记。在英语中,这通常是(th)。然后将这些子词标记连接起来。这通常会增加一个子词标记的数量,除非我们的一对中的一个项目现在已经用完。我们继续这样做,直到我们有所需数量的子词和单词标记。

尽管代码并不复杂,但使用这个算法的开源版本是有意义的。标记化是一个三步过程。

第一步是对我们的语料库进行标记化。默认的标记器只是分割文本,这意味着它保留所有标点符号,通常附加在前一个单词上。我们想要更高级的东西。我们希望除了问号之外剥离所有标点符号。我们还将所有内容转换为小写,并用空格替换下划线:

RE_TOKEN = re.compile('(\w+|\?)', re.UNICODE)
token_counter = Counter()
with open('gutenberg.txt') as fin:
    for line in fin:
        line = line.lower().replace('_', ' ')
        token_counter.update(RE_TOKEN.findall(line))
with open('gutenberg.tok', 'w') as fout:
    for token, count in token_counter.items():
        fout.write('%s\t%d\n' % (token, count))

现在我们可以学习子词标记:

./learn_bpe.py -s 25000 < gutenberg.tok > gutenberg.bpe

然后我们可以将它们应用于任何文本:

./apply_bpe.py -c gutenberg.bpe < some_text.txt > some_text.bpe.txt

生成的some_text.bpe.txt看起来像我们的原始语料库,只是罕见的标记被分解并以@@结尾表示继续。

讨论

将文本标记化为单词是减少文档大小的有效方法。正如我们在第七章中看到的,它还允许我们通过加载预训练的单词嵌入来启动我们的学习。然而,存在一个缺点:较大的文本包含太多不同的单词,我们无法希望覆盖所有单词。一个解决方案是跳过不在我们词汇表中的单词,或用固定的UNKNOWN标记替换它们。这对于情感分析来说效果还不错,但对于我们想要生成输出文本的任务来说,这是相当不令人满意的。在这种情况下,子词单元标记化是一个很好的解决方案。

另一个选择是最近开始受到关注的选项,即训练一个字符级模型来为词汇表中不存在的单词生成嵌入。

8.4 训练一个 seq2seq 聊天机器人

问题

您想要训练一个深度学习模型来复制对话语料库的特征。

解决方案

使用 Google 的 seq2seq 框架。

配方 8.1 中的模型能够学习序列之间的关系,甚至是相当复杂的关系。然而,序列到序列模型很难调整性能。在 2017 年初,Google 发布了 seq2seq,这是一个专门为这种应用程序开发的库,可以直接在 TensorFlow 上运行。它让我们专注于模型的超参数,而不是代码的细节。

seq2seq 框架希望其输入被分成训练、评估和开发集。每个集合应包含一个源文件和一个目标文件,其中匹配的行定义了模型的输入和输出。在我们的情况下,源应包含对话的提示,目标应包含答案。然后,模型将尝试学习如何从提示转换为答案,有效地学习如何进行对话。

第一步是将我们的对话分成(源,目标)对。对于对话中的每一对连续的句子,我们提取第一句和最后一句作为源和目标:

RE_TOKEN = re.compile('(\w+|\?)', re.UNICODE)
def tokenize(st):
    st = st.lower().replace('_', ' ')
    return ' '.join(RE_TOKEN.findall(st))

pairs = []
prev = None
with open('data/gutenberg.txt') as fin:
    for line in fin:
        line = line.strip()
        if line:
            sentences = nltk.sent_tokenize(line)
            if prev:
                pairs.append((prev, tokenize(sentences[0])))
            prev = tokenize(sentences[-1])
        else:
            prev = None

现在让我们洗牌我们的对,并将它们分成我们的三个组,devtest集合各代表我们数据的 5%:

random.shuffle(pairs)
ss = len(pairs) // 20

data = {'dev': pairs[:ss],
        'test': pairs[ss:ss * 2],
        'train': pairs[ss * 2:]}

接下来,我们需要解压这些对,并将它们放入正确的目录结构中:

for tag, pairs2 in data.items():
    path = 'seq2seq/%s' % tag
    if not os.path.isdir(path):
        os.makedirs(path)
    with open(path + '/sources.txt', 'wt') as sources:
        with open(path + '/targets.txt', 'wt') as targets:
            for source, target in pairs2:
                sources.write(source + '\n')
                targets.write(target + '\n')

是时候训练网络了。克隆 seq2seq 存储库并安装依赖项。您可能希望在一个单独的virtualenv中执行此操作:

git clone https://github.com/google/seq2seq.git
cd seq2seq
pip install -e .

现在让我们设置一个指向我们已经准备好的数据的环境变量:

Export SEQ2SEQROOT=/path/to/data/seq2seq

seq2seq 库包含一些配置文件,我们可以在example_configs目录中混合匹配。在这种情况下,我们想要训练一个大型模型,其中包括:

python -m bin.train \                                                                                                               --config_paths="                                                                                                                                                ./example_configs/nmt_large.yml,
      ./example_configs/train_seq2seq.yml" \
  --model_params "
      vocab_source: $SEQ2SEQROOT/gutenberg.tok
      vocab_target: $SEQ2SEQROOT/gutenberg.tok" \
  --input_pipeline_train "
    class: ParallelTextInputPipeline
    params:
      source_files:
        - $SEQ2SEQROOT/train/sources.txt
      target_files:
        - $SEQ2SEQROOT/train/targets.txt" \
  --input_pipeline_dev "
    class: ParallelTextInputPipeline
    params:
       source_files:
        - $SEQ2SEQROOT/dev/sources.txt
       target_files:
        - $SEQ2SEQROOT/dev/targets.txt" \
  --batch_size 1024  --eval_every_n_steps 5000 \
  --train_steps 5000000 \
  --output_dir $SEQ2SEQROOT/model_large

不幸的是,即使在具有强大 GPU 的系统上,也需要很多天才能获得一些像样的结果。然而,笔记本中的zoo文件夹包含一个预训练模型,如果您等不及的话。

该库没有提供一种交互式运行模型的方法。在第十六章中,我们将探讨如何做到这一点,但现在我们可以通过将我们的测试问题添加到一个文件(例如,/tmp/test_questions.txt)并运行以下命令来快速获得一些结果:

python -m bin.infer \
  --tasks "
    - class: DecodeText" \
  --model_dir $SEQ2SEQROOT/model_large \
  --input_pipeline "
    class: ParallelTextInputPipeline
    params:
      source_files:
        - '/tmp/test_questions.txt'"

一个简单的对话可以运行:

> hi
hi
> what is your name ?
sam barker
> how do you feel ?
Fine
> good night
good night

对于更复杂的句子,有时会有点碰运气。

讨论

seq2seq 模型的主要用途似乎是自动翻译,尽管它也对图像字幕和文本摘要有效。文档中包含了一个教程,介绍如何训练一个在几周或几个月内学会不错的英语-德语翻译的模型,具体取决于您的硬件。Google 声称,将序列到序列模型作为其机器翻译工作的核心部分,显著提高了质量。

一个有趣的思考序列到序列映射的方式是将其视为嵌入过程。对于翻译,源句和目标句都被投影到一个多维空间中,模型学习一个投影,使得意思相同的句子最终在该空间中的同一点附近。这导致了“零翻译”的有趣可能性;如果一个模型学会了在芬兰语和英语之间进行翻译,然后稍后在英语和希腊语之间进行翻译,并且它使用相同的语义空间,它也可以直接在芬兰语和希腊语之间进行翻译。这就打开了“思维向量”的可能性,这是相对复杂的想法的嵌入,具有与我们在第三章中看到的“词向量”类似的属性。

第九章:重用预训练的图像识别网络

图像识别和计算机视觉是深度学习取得重大影响的领域之一。拥有几十层甚至超过一百层的网络已经被证明在图像分类任务中非常有效,甚至超过了人类。

然而,训练这样的网络非常复杂,无论是在处理能力还是所需的训练图像数量方面。幸运的是,我们通常不必从头开始,而是可以重用现有的网络。

在本章中,我们将介绍如何加载 Keras 支持的五个预训练网络之一,讨论在将图像输入网络之前需要的预处理,最后展示如何在推理模式下运行网络,询问它认为图像包含什么。

接下来,我们将探讨所谓的迁移学习——将一个预训练网络部分重新训练以适应新任务的新数据。我们首先从 Flickr 获取一组包含猫和狗的图像。然后教会我们的网络区分它们。接下来,我们将应用这个网络来改进 Flickr 的搜索结果。最后,我们将下载一组包含 37 种不同宠物图片的图像,并训练一个网络,使其在标记它们方面超过平均人类。

以下笔记本包含本章中提到的代码:

09.1 Reusing a pretrained image recognition network
09.2 Images as embeddings
09.3 Retraining

9.1 加载预训练网络

问题

您想知道如何实例化一个预训练的图像识别网络。

解决方案

使用 Keras 加载一个预训练网络,如果需要的话下载权重。

Keras 不仅使得组合网络更容易,还提供了对各种预训练网络的引用,我们可以轻松加载:

model = VGG16(weights='imagenet', include_top=True)
model.summary()

这也将打印网络的摘要,显示其各个层。当我们想要使用网络时,这是有用的,因为它不仅显示层的名称,还显示它们的大小以及它们是如何连接的。

讨论

Keras 提供了访问多个流行的图像识别网络的功能,可以直接下载。这些下载被缓存在~/.keras/models/中,所以通常您只需要在第一次下载时等待。

总共我们可以使用五种不同的网络(VGG16、VGG19、ResNet50、Inception V3 和 Xception)。它们在复杂性和架构上有所不同,但对于大多数简单的应用程序来说,可能不太重要选择哪个模型。VGG16 只有 16 层深度,更容易检查。Inception 是一个更深的网络,但变量少了 85%,这使得加载更快,占用内存更少。

9.2 图像预处理

问题

您已经加载了一个预训练网络,但现在需要知道如何在将图像输入网络之前对图像进行预处理。

解决方案

裁剪和调整图像到正确的大小,并对颜色进行归一化。

Keras 包含的所有预训练网络都期望它们的输入是方形的,并且具有特定的大小。它们还期望颜色通道被归一化。在训练时对图像进行归一化使得网络更容易专注于重要的事物,而不会被“分心”。

我们可以使用 PIL/Pillow 加载和中心裁剪图像:

img = Image.open('data/cat.jpg')
w, h = img.size
s = min(w, h)
y = (h - s) // 2
x = (w - s) // 2
img = img.crop((x, y, s, s))

通过查询input_shape属性,我们可以从网络的第一层获取所需的大小。该属性还包含颜色深度,但根据架构的不同,这可能是第一个或最后一个维度。通过对其调用max,我们将得到正确的数字:

target_size = max(model.layers[0].input_shape)
img = img.resize((target_size, target_size), Image.ANTIALIAS)
imshow(np.asarray(img))

我们猫的处理图像

最后,我们需要将图像转换为适合网络处理的格式。这涉及将图像转换为数组,扩展维度使其成为一个批次,并对颜色进行归一化:

np_img = image.img_to_array(img)
img_batch = np.expand_dims(np_img, axis=0)
pre_processed = preprocess_input(img_batch)
pre_processed.shape
(1, 224, 224, 3)

我们现在准备对图像进行分类!

讨论

中心裁剪并不是唯一的选择。事实上,Keras 的image模块中有一个名为load_img的函数,它可以加载和调整图像的大小,但不进行裁剪。尽管如此,这是一个将图像转换为网络期望大小的良好通用策略。

中心裁剪通常是最佳策略,因为我们想要分类的内容通常位于图像中间,直接调整大小会扭曲图片。但在某些情况下,特殊策略可能效果更好。例如,如果我们有很高的白色背景图像,那么中心裁剪可能会切掉太多实际图像,而调整大小会导致严重扭曲。在这种情况下,更好的解决方案可能是在两侧用白色像素填充图像,使其变成正方形。

9.3 在图像上运行推断

问题

如果你有一张图像,如何找出它显示的是什么?

解决方案

使用预训练网络对图像进行推断。

一旦我们将图像转换为正确的格式,就可以在模型上调用predict

features = model.predict(pre_processed)
features.shape
(1, 1000)

预测结果以numpy数组的形式返回(1, 1,000)—每个批次中的每个图像对应一个包含 1,000 个元素的向量。向量中的每个条目对应一个标签,而条目的值表示图像代表该标签的可能性有多大。

Keras 有方便的decode_predictions函数,可以找到得分最高的条目并返回标签和相应的分数:

decode_predictions(features, top=5)

以下是上一个配方中图像的结果:

[[(u'n02124075', u'Egyptian_cat', 0.14703247),
  (u'n04040759', u'radiator', 0.12125628),
  (u'n02123045', u'tabby', 0.097638465),
  (u'n03207941', u'dishwasher', 0.047418527),
  (u'n02971356', u'carton', 0.047036409)]]

网络认为我们在看一只猫。它猜测是暖气片有点令人惊讶,尽管背景看起来有点像暖气片。

讨论

这个网络的最后一层具有 softmax 激活函数。softmax 函数确保所有类别的激活总和等于 1。由于网络在训练时学习的方式,这些激活可以被视为图像匹配类别的可能性。

所有预训练网络都具有一千个可以识别的图像类别。这是因为它们都是为ImageNet 竞赛进行训练的。这使得比较它们的相对性能变得容易,但除非我们碰巧想要检测这个竞赛中的图像,否则对于实际目的来说并不立即有用。在下一章中,我们将看到如何使用这些预训练网络来对我们自己选择的图像进行分类。

另一个限制是这些类型的网络只返回一个答案,而图像中通常有多个对象。我们将在第十一章中探讨如何解决这个问题。

9.4 使用 Flickr API 收集一组带标签的图像

问题

如何快速组合一组带标签的图像进行实验?

解决方案

使用 Flickr API 的search方法。

要使用 Flickr API,您需要一个应用程序密钥,所以请前往https://www.flickr.com/services/apps/create注册您的应用程序。一旦您有了密钥和秘钥,您就可以使用flickrapi库搜索图像:

flickr = flickrapi.FlickrAPI(FLICKR_KEY, FLICKR_SECRET, format='parsed-json')
res = flickr.photos.search(text='"cat"', per_page='10', sort='relevance')
photos = res['photos']['photo']

Flickr 返回的照片默认不包含 URL。但我们可以从记录中组合 URL:

def flickr_url(photo, size=''):
    url = 'http://farm{farm}.staticflickr.com/{server}/{id}_{secret}{size}.jpg'
    if size:
        size = '_' + size
    return url.format(size=size, **photo)

HTML方法是在笔记本中显示图像的最简单方法:

tags = ['<img src="{}" width="150" style="display:inline"/>'
        .format(flickr_url(photo)) for photo in photos]
HTML(''.join(tags))

这应该显示一堆猫的图片。确认我们有不错的图片后,让我们下载一个稍大一点的测试集:

def fetch_photo(dir_name, photo):
    urlretrieve(flickr_url(photo), os.path.join(dir_name, photo['id'] + '.jpg'))

def fetch_image_set(query, dir_name=None, count=250, sort='relevance'):
    res = flickr.photos.search(text='"{}"'.format(query),
                               per_page=count, sort=sort)['photos']['photo']
    dir_name = dir_name or query
    if not os.path.exists(dir_name):
        os.makedirs(dir_name)
    with multiprocessing.Pool() as p:
        p.map(partial(fetch_photo, dir_name), res)

fetch_image_set('cat')

讨论

在深度学习实验中运行时,获取良好的训练数据始终是一个关键问题。在图像方面,很难超越 Flickr API,它为我们提供了数十亿张图像。我们不仅可以根据关键字和标签找到图像,还可以根据它们的拍摄地点进行筛选。我们还可以根据如何使用这些图像进行过滤。对于随机实验来说,这并不是一个因素,但如果我们想以某种方式重新发布这些图像,这肯定会派上用场。

Flickr API 让我们可以访问一般的用户生成的图像。根据您的目的,可能有其他可用的 API 更适合。在 Chapter 10 中,我们将看看如何直接从维基百科获取图像。Getty Images提供了一个用于库存图像的良好 API,而500px通过其 API 提供了高质量图像的访问。最后两个对于再发布有严格的要求,但对于实验来说非常好。

9.5 构建一个可以区分猫和狗的分类器

问题

您希望能够将图像分类为两个类别之一。

解决方案

在预训练网络的特征之上训练支持向量机。

让我们从获取狗的训练集开始。

fetch_image_set('dog')

将图像加载为一个向量,先是猫,然后是狗:

images = [image.load_img(p, target_size=(224, 224))
          for p in glob('cat/*jpg') + glob('dog/*jpg')]
vector = np.asarray([image.img_to_array(img) for img in images])

现在加载预训练模型,并构建一个以fc2为输出的新模型。fc2是网络分配标签之前的最后一个全连接层。这一层的值描述了图像的抽象方式。另一种说法是,这将图像投影到高维语义空间中:

base_model = VGG16(weights='imagenet')
model = Model(inputs=base_model.input,
              outputs=base_model.get_layer('fc2').output)

现在我们将在所有图像上运行模型:

vectors = model.predict(vector)
vectors.shape

对于我们的 500 张图像中的每一张,我们现在有一个描述该图像的 4,096 维向量。就像在 Chapter 4 中一样,我们可以构建一个支持向量机来找到这个空间中猫和狗之间的区别。

让我们运行支持向量机并打印我们的性能:

X_train, X_test, y_train, y_test = train_test_split(
    p, [1] * 250 + [0] * 250, test_size=0.20, random_state=42)

clf = svm.SVC(kernel='rbf')
clf.fit(X_train, y_train)
sum(1 for p, t in zip(clf.predict(X_test), y_test) if p != t)

根据我们获取的图像,我们应该看到大约 90%的精度。我们可以查看以下代码中我们预测错误类别的图像:

mm = {tuple(a): b for a, b in zip(p, glob('cat/*jpg') + glob('dog/*jpg'))}
wrong = [mm[tuple(a)] for a, p, t in zip(X_test,
                                         clf.predict(X_test),
                                         y_test) if p != t]

for x in wrong:
    display(Image(x, width=150))

总的来说,我们的网络表现还不错。对于一些被标记为猫或狗的图像,我们也会感到困惑!

讨论

正如我们在 Recipe 4.3 中看到的,当我们需要在高维空间上建立分类器时,支持向量机是一个不错的选择。在这里,我们提取图像识别网络的输出,并将这些向量视为图像嵌入。我们让支持向量机找到将猫和狗分开的超平面。这对于二元情况效果很好。我们可以在有多于两个类别的情况下使用支持向量机,但情况会变得更加复杂,也许更合理的做法是在我们的网络中添加一层来完成繁重的工作。Recipe 9.7 展示了如何做到这一点。

很多时候分类器没有得到正确答案,你可以真的归咎于搜索结果的质量。在下一个配方中,我们将看看如何利用我们提取的图像特征来改进搜索结果。

9.6 改进搜索结果

问题

如何从一组图像中过滤掉异常值?

解决方案

将图像分类器的最高但一个层的特征视为图像嵌入,并在该空间中找到异常值。

正如我们在前一个配方中看到的,我们的网络有时无法区分猫和狗的原因之一是它看到的图像质量不太好。有时图像根本不是猫或狗的图片,网络只能猜测。

Flickr 搜索 API 不会返回与提供的文本查询匹配的图像,而是返回其标签、描述或标题与文本匹配的图像。即使是主要搜索引擎最近也开始考虑返回的图像中实际可见的内容。(因此,搜索“猫”可能会返回一张标题为“看这只大猫”的狮子图片。)

只要返回的图像大多数符合用户的意图,我们可以通过过滤掉异常值来改进搜索。对于生产系统,值得探索更复杂的东西;在我们的情况下,我们最多有几百张图像和数千个维度,我们可以使用更简单的方法。

让我们从最近的猫图片开始。由于我们按recent而不是relevance排序,我们预计搜索结果会稍微不太准确:

fetch_image_set('cat', dir_name='maybe_cat', count=100, sort='recent')

与以前一样,我们将图像加载为一个向量:

maybe_cat_fns = glob('maybe_cat/*jpg')
maybe_cats = [image.load_img(p, target_size=(224, 224))
              for p in maybe_cat_fns]
maybe_cat_vectors = np.asarray([image.img_to_array(img)
                                for img in maybe_cats])

我们将首先通过找到“可能是猫”的空间中的平均点来寻找异常值:

centroid = maybe_cat_vectors.sum(axis=0) / len(maybe_cats)

然后我们计算猫向量到质心的距离:

diffs = maybe_cat_vectors - centroid
distances = numpy.linalg.norm(diffs, axis=1)

现在我们可以看看与平均猫最不相似的东西:

sorted_idxs = np.argsort(distances)
for worst_cat_idx in sorted_idxs[-10:]:
    display(Image(maybe_cat_fns[worst_cat_idx], width=150))

通过这种方式过滤掉非猫的效果还不错,但由于异常值对平均向量的影响较大,我们列表的前面看起来有点杂乱。改进的一种方法是反复在迄今为止的结果上重新计算质心,就像一个穷人的异常值过滤器:

to_drop = 90
sorted_idxs_i = sorted_idxs
for i in range(5):
    centroid_i = maybe_cat_vectors[sorted_idxs_i[:-to_drop]].sum(axis=0) /
        (len(maybe_cat_fns) - to_drop)
    distances_i = numpy.linalg.norm(maybe_cat_vectors - centroid_i, axis=1)
    sorted_idxs_i = np.argsort(distances_i)

这导致了非常不错的顶级结果。

讨论

在这个示例中,我们使用了与示例 9.5 相同的技术来改进从 Flickr 获取的搜索结果。我们可以将我们的图像看作一个大的“点云”高维空间。

与其找到一个将狗与猫分开的超平面,我们试图找到最中心的猫。然后我们假设到这个典型猫的距离是“猫性”的一个很好的度量。

我们采取了一种简单的方法来找到最中心的猫;只需平均坐标,去除异常值,再次取平均,重复。在高维空间中排名异常值是一个活跃的研究领域,正在开发许多有趣的算法。

9.7 重新训练图像识别网络

问题

如何训练一个网络来识别一个专门的类别中的图像?

解决方案

在从预训练网络中提取的特征之上训练一个分类器。

在预训练网络的基础上运行 SVM 是一个很好的解决方案,如果我们有两类图像,但如果我们有大量可选择的类别,则不太适合。例如,牛津-IIIT 宠物数据集包含 37 种不同的宠物类别,每个类别大约有 200 张图片。

从头开始训练一个网络会花费很多时间,而且可能效果不是很好——当涉及到深度学习时,7000 张图片并不多。我们将采取的做法是拿一个去掉顶层的预训练网络,然后在其基础上构建。这里的直觉是,预训练层的底层识别图像中的特征,我们提供的层可以利用这些特征学习如何区分这些宠物。

让我们加载 Inception 模型,去掉顶层,并冻结权重。冻结权重意味着它们在训练过程中不再改变:

base_model = InceptionV3(weights='imagenet', include_top=False)
for layer in base_model.layers:
    layer.trainable = False

现在让我们在顶部添加一些可训练的层。在中间加入一个全连接层,我们要求模型预测我们的动物宠物类别:

pool_2d = GlobalAveragePooling2D(name='pool_2d')(base_model.output)
dense = Dense(1024, name='dense', activation='relu')(pool_2d)
predictions = Dense(len(idx_to_labels), activation='softmax')(dense)
model = Model(inputs=base_model.input, outputs=predictions)
model.compile(optimizer='rmsprop',
              loss='categorical_crossentropy',
              metrics=['accuracy'])

让我们从牛津-IIIT 宠物数据集提供的解压缩的tar.gz中加载数据。文件名的格式为<class_name>_.jpg,因此我们可以分离出<class_name>,同时更新label_to_idxidx_to_label表:

pet_images_fn = [fn for fn in os.listdir('pet_images') if fn.endswith('.jpg')]
labels = []
idx_to_labels = []
label_to_idx = {}
for fn in pet_images_fn:
    label, _ = fn.rsplit('_', 1)
    if not label in label_to_idx:
        label_to_idx[label] = len(idx_to_labels)
        idx_to_labels.append(label)
    labels.append(label_to_idx[label])

接下来,我们将图像转换为训练数据:

def fetch_pet(pet):
    img = image.load_img('pet_images/' + pet, target_size=(299, 299))
    return image.img_to_array(img)
img_vector = np.asarray([fetch_pet(pet) for pet in pet_images_fn])

并将标签设置为独热编码向量:

y = np.zeros((len(labels), len(idx_to_labels)))
for idx, label in enumerate(labels):
    y[idx][label] = 1

对模型进行 15 个时期的训练可以产生不错的结果,精度超过 90%:

model.fit(
    img_vector, y,
    batch_size=128,
    epochs=30,
    verbose=2
)

到目前为止我们所做的被称为迁移学习。我们可以通过解冻预训练网络的顶层来使其有更多的训练余地,做得更好。mixed9是网络中的一层,大约在中间的三分之二处:

unfreeze = False
for layer in base_model.layers:
    if unfreeze:
        layer.trainable = True
    if layer.name == 'mixed9':
        unfreeze = True
model.compile(optimizer=SGD(lr=0.0001, momentum=0.9),
              loss='categorical_crossentropy', metrics=['accuracy'])

我们可以继续训练:

model.fit(
    img_vector, y,
    batch_size=128,
    epochs=15,
    verbose=2
)

我们应该看到性能进一步提高,达到 98%!

讨论

迁移学习是深度学习中的一个关键概念。世界上机器学习领域的领导者经常发布他们最佳网络的架构,如果我们想要复现他们的结果,这是一个很好的起点,但我们并不总是能够轻松获得他们用来获得这些结果的训练数据。即使我们有访问权限,训练这些世界级网络需要大量的计算资源。

如果我们想要做与网络训练相同的事情,那么拥有实际训练过的网络是非常有用的,但是当我们想要执行类似任务时,使用迁移学习也可以帮助我们很多。Keras 附带了各种模型,但如果它们不够用,我们可以调整为其他框架构建的模型。

第十章:构建反向图像搜索服务

在前一章中,我们看到如何在我们自己的图像上使用预训练网络,首先通过在网络顶部运行分类器,然后在一个更复杂的示例中,我们只训练网络的部分来识别新的图像类别。在本章中,我们将使用类似的方法来构建一个反向图像搜索引擎,或者通过示例搜索。

我们将从查找维基数据查询维基百科获取一组良好的基础图像开始。然后,我们将使用预训练网络提取每个图像的值以获得嵌入。一旦我们有了这些嵌入,找到相似的图像只是最近邻搜索的一个简单问题。最后,我们将研究主成分分析(PCA)作为一种可视化图像之间关系的方法。

本章的代码可以在以下笔记本中找到:

10.1 Building an inverse image search service

10.1 从维基百科获取图像

问题

如何从维基百科获取一组覆盖主要类别的干净图像?

解决方案

使用维基数据的元信息来查找代表一类事物的维基百科页面。

维基百科包含大量可用于大多数目的的图片。然而,其中绝大多数图片代表具体实例,这并不是反向搜索引擎所需要的。我们希望返回代表猫作为一个物种的图片,而不是像加菲猫这样的特定猫。

维基数据,维基百科的结构化表亲,基于形式为(主题,关系,对象)的三元组,并且有大量编码的谓词,部分基于维基百科。其中之一“实例”由P31表示。我们要找的是实例关系中对象的图像列表。我们可以使用维基数据查询语言来请求这个:

query = """SELECT DISTINCT ?pic
WHERE
{
 ?item wdt:P31 ?class .
 ?class wdt:P18 ?pic
}
"""

我们可以使用请求调用维基数据的查询后端,并将结果 JSON 展开为图像引用列表:

url = 'https://query.wikidata.org/bigdata/namespace/wdq/sparql'
data = requests.get(url, params={'query': query, 'format': 'json'}).json()
images = [x['pic']['value'] for x in data['results']['bindings']]

返回的引用是指向图像页面的 URL,而不是图像本身。各种维基项目中的图像应该存储在http://upload.wikimedia.org/wikipedia/commons/,但不幸的是,这并不总是情况—有些仍然存储在特定语言的文件夹中。因此,我们还必须至少检查英语文件夹(en)。图像的实际 URL 由文件名和文件名的 MD5 哈希的hexdigest的前两个字符确定。如果我们需要多次执行此操作,则将图像缓存到本地会有所帮助:

def center_crop_resize(img, new_size):
    w, h = img.size
    s = min(w, h)
    y = (h - s) // 2
    x = (w - s) // 2
    img = img.crop((x, y, s, s))
    return img.resize((new_size, new_size))

def fetch_image(image_cache, image_url):
    image_name = image_url.rsplit('/', 1)[-1]
    local_name = image_name.rsplit('.', 1)[0] + '.jpg'
    local_path = os.path.join(image_cache, local_name)
    if os.path.isfile(local_path):
        img = Image.open(local_path)
        img.load()
        return center_crop_resize(img, 299)
    image_name = unquote(image_name).replace(' ', '_')
    m = md5()
    m.update(image_name.encode('utf8'))
    c = m.hexdigest()
    for prefix in ('http://upload.wikimedia.org/wikipedia/en',
                   'http://upload.wikimedia.org/wikipedia/commons'):
        url = '/'.join((prefix, c[0], c[0:2], image_name))
        r = requests.get(url)
        if r.status_code != 404:
            try:
                img = Image.open(BytesIO(r.content))
                if img.mode != 'RGB':
                    img = img.convert('RGB')
                img.save(local_path)
                return center_crop_resize(img, 299)
            except IOError:
                pass
    return None

即使这样有时候也不起作用。本章的笔记本包含一些更多处理边缘情况的代码,以增加图像的产出。

现在我们需要做的就是获取图像。这可能需要很长时间,因此我们使用tqdm来显示我们的进度:

valid_images = []
for image_name in tqdm(images):
    img = fetch_image(IMAGE_DIR, image_name)
    if img:
        valid_images.append(img)

讨论

维基数据的查询语言并不广为人知,但它是访问结构化数据的有效方式。这里的示例非常简单,但在线上可以找到更复杂的查询,例如返回世界上最大的女市长或虚构角色中最流行的姓氏。这些数据大部分也可以从维基百科中提取,但运行维基数据查询通常更快、更精确且更有趣。

维基媒体宇宙也是图像的良好来源。有数千万张可用的图像,全部都有友好的重用许可证。此外,使用维基数据,我们可以访问这些图像的各种属性。很容易扩展这个方法,不仅返回图像 URL,还返回图像中对象的名称,可以选择使用我们选择的语言。

注意

这里描述的fetch_image函数大多数时候都有效,但并非总是有效。我们可以通过获取从维基数据查询返回的 URL 的内容,并从 HTML 代码中提取<img>标签来改进这一点。

10.2 将图像投影到 N 维空间

问题

给定一组图像,如何组织它们以使相似的图像彼此靠近?

解决方案

将图像识别网络的倒数第二层的权重视为图像嵌入。这一层直接连接到绘制结论的softmax层。因此,网络认为是猫的任何东西应该具有相似的值。

让我们加载并实例化预训练网络。我们将再次使用 Inception——让我们使用.summary()来查看其结构:

base_model = InceptionV3(weights='imagenet', include_top=True)
base_model.summary()

正如您所看到的,我们需要avg_pool层,其大小为 2,048:

model = Model(inputs=base_model.input,
              outputs=base_model.get_layer('avg_pool').output)

现在我们可以在一张图像或一组图像上运行模型:

def get_vector(img):
    if not type(img) == list:
        images = [img]
    else:
        images = img
    target_size = int(max(model.input.shape[1:]))
    images = [img.resize((target_size, target_size), Image.ANTIALIAS)
              for img in images]
    np_imgs = [image.img_to_array(img) for img in images]
    pre_processed = preprocess_input(np.asarray(np_imgs))
    return model.predict(pre_processed)

并对我们在上一个示例中获取的图像进行分块索引(当然,如果您有足够的内存,可以尝试一次性完成整个操作):

chunks = [get_vector(valid_images[i:i+256])
          for i in range(0, len(valid_images), 256)]
vectors = np.concatenate(chunks)

讨论

在这个示例中,我们使用了网络的倒数第二层来提取嵌入。由于这一层直接连接到确定实际输出层的softmax层,我们期望权重形成一个语义空间,将所有猫的图像大致放在同一空间中。但如果我们选择不同的层会发生什么呢?

将卷积网络视为图像识别的特征检测器,逐层增加抽象级别。最低级别直接处理像素值,并将检测到非常局部的模式。最后一层检测“猫”的概念。

选择一个较低的层应该会导致在较低的抽象级别上的相似性,所以我们不会返回类似猫的东西,而是期望看到具有相似纹理的图像。

10.3 在高维空间中找到最近邻

问题

如何在高维空间中找到彼此最接近的点?

解决方案

使用 scikit-learn 的k-最近邻实现。

k-最近邻算法构建一个模型,可以快速返回最近邻。虽然会有一些精度损失,但比进行精确计算要快得多。实际上,它在我们的向量上构建了一个距离索引:

nbrs = NearestNeighbors(n_neighbors=10,
                        balgorithm='ball_tree').fit(vectors)

有了这个距离索引,我们现在可以快速返回给定输入图像的图像集中的近似匹配。我们已经实现了逆图像搜索!让我们把所有这些放在一起找更多的猫:

cat = get_vector(Image.open('data/cat.jpg'))
distances, indices = nbrs.kneighbors(cat)

并使用内联 HTML 图像显示前几个结果:

html = []
for idx, dist in zip(indices[0], distances[0]):
    b = BytesIO()
    valid_images[idx].save(b, format='jpeg')
    b64_img = base64.b64encode(b.getvalue()).decode('utf-8'))
    html.append("<img src='data:image/jpg;base64,{0}'/>".format(b64_img)
HTML(''.join(html))

您应该看到一个由猫主导的漂亮图像列表!

讨论

在机器学习中,快速计算最近邻是一个活跃的研究领域。最简单的邻居搜索实现涉及在数据集中所有点对之间计算距离,如果我们在高维空间中有大量点,这种方法很快就会失控。

Scikit-learn 为我们提供了许多预先计算树的算法,可以帮助我们快速找到最近邻居,但会消耗一些内存。不同的方法在文档中有所讨论,但一般的方法是使用算法递归地将空间分割成子空间,从而构建树。这样我们可以快速识别在寻找邻居时要检查哪些子空间。

10.4 在嵌入中探索局部邻域

问题

您想探索图像的局部聚类是什么样子。

解决方案

使用主成分分析找到在局部图像集中最能区分图像的维度。

例如,假设我们有与我们的猫图像最接近的 64 张图像:

nbrs64 = NearestNeighbors(n_neighbors=64, algorithm='ball_tree').fit(vectors)
distances64, indices64 = nbrs64.kneighbors(cat)

PCA 允许我们以尽可能少的损失降低空间的维度。如果我们将维度降低到二维,PCA 将找到可以将提供的示例投影到的平面,以尽可能少的损失。然后,我们看看这些示例在平面上的位置,就可以很好地了解本地邻域的结构。在这种情况下,我们将使用TruncatedSVD实现:

vectors64 = np.asarray([vectors[idx] for idx in indices64[0]])
svd = TruncatedSVD(n_components=2)
vectors64_transformed = svd.fit_transform(vectors64)

vectors64_transformed现在的形状是 64×2。我们将在一个 8×8 的网格上绘制这 64 个图像,每个单元格的大小为 75×75。让我们首先将坐标归一化到 0 到 1 的比例上:

mins = np.min(vectors64_transformed, axis=0)
maxs = np.max(vectors64_transformed, axis=0)
xys = (vectors64_transformed - mins) / (maxs - mins)

现在我们可以绘制并显示本地区域:

img64 = Image.new('RGB', (8 * 75, 8 * 75), (180, 180, 180))

for idx, (x, y) in zip(indices64[0], xys):
    x = int(x * 7) * 75
    y = int(y * 7) * 75
    img64.paste(valid_images[idx].resize((75, 75)), (x, y))

img64

本地聚类的图像

我们看到一个猫图像大致位于中间,一个角被动物主导,其余的图像由于其他原因匹配。请注意,我们在现有图像上绘制,因此网格实际上不会完全填满。

讨论

在食谱 3.3 中,我们使用 t-SNE 将高维空间折叠成二维平面。在这个食谱中,我们改用主成分分析。这两种算法实现了相同的目标,即降低空间的维度,但是它们的方式不同。

t-SNE 试图保持空间中点之间的距离相同,尽管降低了维度。在这种转换中当然会丢失一些信息,因此我们可以选择是尝试保持簇在本地保持完整(在高维度中彼此接近的点之间的距离保持相似)还是保持簇之间的距离保持完整(在高维度中彼此远离的点之间的距离保持相似)。

PCA 试图找到一个* N 维的超平面,该超平面与空间中的所有项目尽可能接近。如果 N *为 2,我们谈论的是一个普通平面,因此它试图找到在我们的高维空间中与所有图像最接近的平面。换句话说,它捕捉了两个最重要的维度(主成分),然后我们用它们来可视化猫空间。

第十一章:检测多个图像

在之前的章节中,我们看到了如何使用预训练分类器来检测图像并学习新的类别。然而,在所有这些实验中,我们总是假设我们的图像中只有一件事情要看。在现实世界中,情况并非总是如此——例如,我们可能有一张既有猫又有狗的图像。

这一章探讨了一些技术来克服这个限制。我们首先建立在一个预训练的分类器上,并修改设置,以便我们得到多个答案。然后我们看一下解决这个问题的最先进的解决方案。

这是一个活跃研究领域,最先进的算法很难在 Python 笔记本上重现,而且还要在 Keras 之上。相反,我们在本章的第二和第三个配方中使用一个开源库来演示可能性。

本章的代码可以在以下笔记本中找到:

11.1 Detecting Multiple Images

11.1 使用预训练分类器检测多个图像

问题

如何在单个图像中找到多个图像类别?

解决方案

使用中间层的输出作为特征图,并在其上运行一个滑动窗口。

使用预训练的神经网络进行图像分类并不难,一旦我们设置好一切。如果图像中有多个对象要检测,我们就做得不太好:预训练网络将返回图像代表任何类别的可能性。如果它看到两个不同的对象,它可能会分割返回的分数。如果它看到一个对象但不确定它是两个类别中的一个,它也会分割分数。

一个想法是在图像上运行一个滑动窗口。我们不是将图像下采样到 224×224,而是将其下采样到 448×448,原始尺寸的两倍。然后我们将所有不同的裁剪都输入到较大图像中:

带有两个裁剪的猫和狗

让我们从较大的图像中创建裁剪:

cat_dog2 = preprocess_image('data/cat_dog.jpg', target_size=(448, 448))
crops = []
for x in range(7):
    for y in range(7):
        crops.append(cat_dog2[0,
                              x * 32: x * 32 + 224,
                              y * 32: y * 32 + 224,
                              :])
crops = np.asarray(crops)

分类器在批处理上运行,因此我们可以以相同的方式将crops对象馈送到之前加载的分类器中:

preds = base_model.predict(vgg16.preprocess_input(crops))
l = defaultdict(list)
for idx, pred in enumerate(vgg16.decode_predictions(preds, top=1)):
    _, label, weight = pred[0]
    l[label].append((idx, weight))
l.keys()
dict_keys(['Norwegian_elkhound', 'Egyptian_cat', 'standard_schnauzer',
           'kuvasz', 'flat-coated_retriever', 'tabby', 'tiger_cat',
           'Labrador_retriever'])

分类器似乎大多认为各种瓷砖要么是猫,要么是狗,但并不确定是哪种类型。让我们看看对于给定标签具有最高值的裁剪:

def best_image_for_label(l, label):
    idx = max(l[label], key=lambda t:t[1])[0]
    return deprocess_image(crops[idx], 224, 224)

showarray(best_image_for_label(crop_scores, 'Egyptian_cat'))

埃及猫的最佳裁剪

showarray(best_image_for_label(crop_scores, 'Labrador_retriever'))

拉布拉多的最佳裁剪

这种方法有效,但相当昂贵。而且我们重复了很多工作。记住 CNN 的工作方式是通过在图像上运行卷积来进行的,这与做所有这些裁剪非常相似。此外,如果我们加载一个没有顶层的预训练网络,它可以在任何大小的图像上运行:

bottom_model = vgg16.VGG16(weights='imagenet', include_top=False)
(1, 14, 14, 512)

网络的顶层期望输入为 7×7×512。我们可以根据已加载的网络重新创建网络的顶层,并复制权重:

def top_model(base_model):
    inputs = Input(shape=(7, 7, 512), name='input')
    flatten = Flatten(name='flatten')(inputs)
    fc1 = Dense(4096, activation='relu', name='fc1')(flatten)
    fc2 = Dense(4096, activation='relu', name='fc2')(fc1)
    predictions = Dense(1000, activation='softmax',
                        name='predictions')(fc2)
    model = Model(inputs,predictions, name='top_model')
    for layer in model.layers:
        if layer.name != 'input':
            print(layer.name)
            layer.set_weights(
                base_model.get_layer(layer.name).get_weights())
    return model

model = top_model(base_model)

现在我们可以根据底部模型的输出进行裁剪,并将其输入到顶部模型中,这意味着我们只对原始图像的 4 倍像素运行底部模型,而不是像之前那样运行 64 倍。首先,让我们通过底部模型运行图像:

bottom_out = bottom_model.predict(cat_dog2)

现在,我们将创建输出的裁剪:

vec_crops = []
for x in range(7):
    for y in range(7):
        vec_crops.append(bottom_out[0, x: x + 7, y: y + 7, :])
vec_crops = np.asarray(vec_crops)

然后运行顶部分类器:

crop_pred = top_model.predict(vec_crops)
l = defaultdict(list)
for idx, pred in enumerate(vgg16.decode_predictions(crop_pred, top=1)):
    _, label, weight = pred[0]
    l[label].append((idx, weight))
l.keys()

这应该给我们带来与以前相同的结果,但速度更快!

讨论

在这个配方中,我们利用了神经网络的较低层具有关于网络看到的空间信息的事实,尽管这些信息在预测时被丢弃。这个技巧基于围绕 Faster RCNN(见下一个配方)进行的一些工作,但不需要昂贵的训练步骤。

我们的预训练分类器在固定大小的图像(在本例中为 224×224 像素)上运行,这在这里有些限制。输出区域始终具有相同的大小,我们必须决定将原始图像分成多少个单元格。然而,它确实很好地找到有趣的子图像,并且易于部署。

Faster RNN 本身并没有相同的缺点,但训练成本更高。我们将在下一个示例中看一下这一点。

11.2 使用 Faster RCNN 进行目标检测

问题

如何在图像中找到多个紧密边界框的对象?

解决方案

使用(预训练的)Faster RCNN 网络。

Faster RCNN 是一种神经网络解决方案,用于在图像中找到对象的边界框。不幸的是,该算法过于复杂,无法在 Python 笔记本中轻松复制;相反,我们将依赖于一个开源实现,并将该代码更多地视为黑盒。让我们从 GitHub 克隆它:

git clone https://github.com/yhenon/keras-frcnn.git

在我们从requirements.txt安装了依赖项之后,我们可以训练网络。我们可以使用我们自己的数据进行训练,也可以使用Visual Object Challenge的标准数据集进行训练。后者包含许多带有边界框和 20 个类别的图像。

在我们下载了 VOC 2007/2012 数据集并解压缩后,我们可以开始训练:

python train_frcnn.py -p <downloaded``-``data``-``set>

注意

这需要相当长的时间——在一台强大的 GPU 上大约需要一天,而在仅使用 CPU 上则需要更长时间。如果您希望跳过此步骤,可以在https://storage.googleapis.com/deep-learning-cookbook/model_frcnn.hdf5上找到一个预训练的网络。

训练脚本会在每次看到改进时保存模型的权重。为了测试目的实例化模型有些复杂:

img_input = Input(shape=input_shape_img)
roi_input = Input(shape=(c.num_rois, 4))
feature_map_input = Input(shape=input_shape_features)

shared_layers = nn.nn_base(img_input, trainable=True)

num_anchors = len(c.anchor_box_scales) * len(c.anchor_box_ratios)
rpn_layers = nn.rpn(shared_layers, num_anchors)

classifier = nn.classifier(feature_map_input,
                           roi_input,
                           c.num_rois,
                           nb_classes=len(c.class_mapping),
                           trainable=True)

model_rpn = Model(img_input, rpn_layers)
model_classifier_only = Model([feature_map_input, roi_input], classifier)

model_classifier = Model([feature_map_input, roi_input], classifier)

现在我们有两个模型,一个能够建议可能有一些有趣内容的区域,另一个能够告诉我们那是什么。让我们加载模型的权重并编译:

model_rpn.load_weights('data/model_frcnn.hdf5', by_name=True)
model_classifier.load_weights('data/model_frcnn.hdf5', by_name=True)

model_rpn.compile(optimizer='sgd', loss='mse')
model_classifier.compile(optimizer='sgd', loss='mse')

现在让我们将图像输入到区域建议模型中。我们将重塑输出,以便更容易运行下一步。之后,r2是一个三维结构,最后一个维度保存了预测:

img_vec, ratio = format_img(cv2.imread('data/cat_dog.jpg'), c)
y1, y2, f = model_rpn.predict(img_vec)
r = keras_frcnn.roi_helpers.rpn_to_roi(y1, y2, c, K.image_dim_ordering(),
                                       overlap_thresh=0.7)
roi_count = R.shape[0] // c.num_rois
r2 = np.zeros((roi_count * c.num_rois, r.shape[1]))
r2 = r[:r2.shape[0],:r2.shape[1]]
r2 = np.reshape(r2, (roi_count, c.num_rois, r.shape[1]))

图像分类器在一维批次上运行,因此我们必须逐个输入r2的两个维度。p_cls将包含检测到的类别,p_regr将包含框的微调信息:

p_cls = []
p_regr = []
for i in range(r2.shape[0]):
    pred = model_classifier_only.predict([F, r2[i: i + 1]])
    p_cls.append(pred[0][0])
    p_regr.append(pred[1][0])

将三个数组组合在一起以获得实际的框、标签和确定性是通过循环遍历两个维度来实现的:

boxes = []
w, h, _ = r2.shape
for x in range(w):
    for y in range(h):
        cls_idx = np.argmax(p_cls[x][y])
        if cls_idx == len(idx_to_class) - 1:
            continue
        reg = p_regr[x, y, 4 * cls_idx:4 * (cls_idx + 1)]
        params = list(r2[x][y])
        params += list(reg / c.classifier_regr_std)
        box = keras_frcnn.roi_helpers.apply_regr(*params)
        box = list(map(lambda i: i * c.rpn_stride, box))
        boxes.append((idx_to_class[cls_idx], p_cls[x][y][cls_idx], box))

现在列表boxes中包含了检测到的猫和狗。有许多重叠的矩形,可以解析成彼此。

讨论

Faster RCNN 算法是 Fast RCNN 算法的演变,而 Fast RCNN 算法又是原始 RCNN 的改进。所有这些算法工作方式类似;一个区域提议者提出可能包含有趣图像的矩形,然后图像分类器检测那里是否有什么。这种方法与我们在上一个示例中所做的并没有太大不同,那里我们的区域提议者只是生成了图像的 64 个子裁剪。

Jian Sun 提出了 Faster RCNN,他聪明地观察到在前一个示例中使用的产生特征图的 CNN 也可以成为区域提议的良好来源。因此,Faster RCNN 不是将区域提议问题单独处理,而是在相同的特征图上并行训练区域提议,该特征图也用于图像分类。

您可以在 Athelas 博客文章"CNN 在图像分割中的简要历史:从 R-CNN 到 Mask-CNN"中了解 RCNN 演变为 Faster RCNN 以及这些算法的工作原理。

11.3 在我们自己的图像上运行 Faster RCNN

问题

您想要训练一个 Faster RCNN 模型,但不想从头开始。

解决方案

从预训练模型开始训练。

从头开始训练需要大量标记数据。VOC 数据集包含 20 个类别的 20000 多个标记图像。那么如果我们没有那么多标记数据怎么办?我们可以使用我们在第九章中首次遇到的迁移学习技巧。

如果重新启动训练脚本,它已经加载了权重;我们需要做的是将来自在 VOC 数据集上训练的网络的权重转换为我们自己的权重。在之前的示例中,我们构建了一个双网络并加载了权重。只要我们的新任务类似于 VOC 分类任务,我们只需要改变类别数量,写回权重,然后开始训练。

最简单的方法是让训练脚本运行足够长的时间,以便它写入其配置文件,然后使用该配置文件和先前加载的模型来获取这些权重。对于训练我们自己的数据,最好使用 GitHub 上描述的逗号分隔格式:

filepath,x1,y1,x2,y2,class_name

在这里,filepath应该是图像的完整路径,x1y1x2y2形成了该图像上的像素边界框。我们现在可以用以下方式训练模型:

python train_frcnn.py -o simple -p my_data.txt \
       --config_filename=newconfig.pickle

现在,在我们像以前一样加载了预训练模型之后,我们可以加载新的配置文件:

new_config = pickle.load(open('data/config.pickle', 'rb'))
Now construct the model for training and load the weights:

img_input = Input(shape=input_shape_img)
roi_input = Input(shape=(None, 4))
shared_layers = nn.nn_base(img_input, trainable=True)

num_anchors = len(c.anchor_box_scales) * len(c.anchor_box_ratios)
rpn = nn.rpn(shared_layers, num_anchors)

classifier = nn.classifier(shared_layers, roi_input, c.num_rois,
                           len(c.class_mapping), trainable=True)

model_rpn = Model(img_input, rpn[:2])
model_classifier = Model([img_input, roi_input], classifier)
model_all = Model([img_input, roi_input], rpn[:2] + classifier)

model_rpn.load_weights('data/model_frcnn.hdf5', by_name=True)
model_classifier.load_weights('data/model_frcnn.hdf5', by_name=True)

我们可以看到训练模型只取决于分类器对象的类别数量。因此,我们想要重建分类器对象和任何依赖它的对象,然后保存权重。这样我们就基于旧权重构建了新模型。如果我们查看构建分类器的代码,我们会发现它完全依赖于倒数第三层。因此,让我们复制该代码,但使用new_config运行:

new_nb_classes = len(new_config.class_mapping)
out = model_classifier_only.layers[-3].output
new_out_class = TimeDistributed(Dense(new_nb_classes,
                    activation='softmax', kernel_initializer='zero'),
                    name='dense_class_{}'.format(new_nb_classes))(out)
new_out_regr = TimeDistributed(Dense(4 * (new_nb_classes-1),
                    activation='linear', kernel_initializer='zero'),
                    name='dense_regress_{}'.format(new_nb_classes))(out)
new_classifer =  [new_out_class, new_out_regr]

有了新的分类器,我们可以像以前一样构建模型并保存权重。这些权重将保留模型之前学到的内容,但对于特定于新训练任务的分类器部分将为零:

new_model_classifier = Model([img_input, roi_input], classifier)
new_model_rpn = Model(img_input, rpn[:2])
new_model_all = Model([img_input, roi_input], rpn[:2] + classifier)
new_model_all.save_weights('data/model_frcnn_new.hdf5')

我们现在可以继续训练:

python train_frcnn.py -o simple -p my_data.txt \
       --config_filename=newconfig.pickle \
       --input_weight_path=data/model_frcnn_new.hdf5

讨论

大多数迁移学习的例子都基于图像识别网络。这部分是因为预训练网络易于获取,而且获取带标签图像的训练集也很简单。在这个示例中,我们看到我们也可以在其他情况下应用这种技术。我们只需要一个预训练网络和对网络构建方式的了解。通过加载网络权重,修改网络以适应新数据集,并再次保存权重,我们可以显著提高学习速度。

即使在没有预训练网络可用的情况下,但有大量公共训练数据可用且我们自己的数据集很小的情况下,首先在公共数据集上进行训练,然后将学习迁移到我们自己的数据集可能是有意义的。对于本示例中讨论的边界框情况,这很容易成为可能。

提示

如果您自己的数据集很小,可能有必要像我们在第 9.7 节中所做的那样,将网络的一部分设置为不可训练。

第十二章:图像风格

在本章中,我们将探讨一些技术,以可视化卷积网络在分类图像时看到的内容。我们将通过反向运行网络来实现这一点——而不是给网络一个图像并询问它是什么,我们告诉网络要看到什么,并要求它以一种使检测到的物品更夸张的方式修改图像。

我们将首先为单个神经元执行此操作。这将向我们展示该神经元对哪种模式做出反应。然后,我们将引入八度概念,当我们优化图像以获得更多细节时,我们会放大。最后,我们将看看将这种技术应用到现有图像,并可视化网络在图像中“几乎”看到的内容,这种技术被称为深度梦想。

然后,我们将转变方向,看看网络的“较低”层的组合如何决定图像的艺术风格,以及我们如何仅可视化图像的风格。这使用了格拉姆矩阵的概念,以及它们如何代表一幅画的风格。

接下来,我们将看看如何将这种概念与稳定图像的方法相结合,这样我们就可以生成一幅只复制图像风格的图像。然后,我们将应用这种技术到现有图像,这样就可以以文森特·梵高的《星夜》风格呈现最近的照片。最后,我们将使用两种风格图像,并在两种风格之间的同一图片上呈现不同版本。

以下笔记本包含本章的代码:

12.1 Activation Optimization
12.2 Neural Style

12.1 可视化 CNN 激活

问题

您想看看图像识别网络内部实际发生了什么。

解决方案

最大化神经元的激活,看看它对哪些像素反应最强烈。

在前一章中,我们看到卷积神经网络在图像识别方面是首选网络。最低层直接处理图像的像素,随着层叠的增加,我们推测识别特征的抽象级别也会提高。最终层能够实际识别图像中的事物。

这是直观的。这些网络的设计方式类似于我们认为人类视觉皮层是如何工作的。让我们看看个别神经元在做什么,看看这是否属实。我们将像之前一样加载网络。我们在这里使用 VGG16,因为它的架构更简单:

model = vgg16.VGG16(weights='imagenet', include_top=False)
layer_dict = dict([(layer.name, layer) for layer in model.layers[1:]])

现在我们将反向运行网络。也就是说,我们将定义一个损失函数,优化一个特定神经元的激活,并要求网络计算改变图像的方向,以优化该神经元的值。在这种情况下,我们随机选择block3_conv层和索引为 1 的神经元:

input_img = model.input
neuron_index  = 1
layer_output = layer_dict['block3_conv1'].output
loss = K.mean(layer_output[:, neuron_index, :, :])

要反向运行网络,我们需要定义一个名为iterate的 Keras 函数。它将接受一个图像,并返回损失和梯度(我们需要对网络进行的更改)。我们还需要对梯度进行归一化:

grads = K.gradients(loss, input_img)[0]
grads = normalize(grads)
iterate = K.function([input_img], [loss, grads])

我们将从一个随机噪音图像开始,并将其重复输入到我们刚刚定义的iterate函数中,然后将返回的梯度添加到我们的图像中。这样逐步改变图像,使其朝着我们选择的神经元和层具有最大激活的方向变化——20 步应该可以解决问题:

for i in range(20):
    loss_value, grads_value = iterate([input_img_data])
    input_img_data += grads_value * step

在我们能够显示生成的图像之前,需要对数值进行归一化和剪裁,使其在通常的 RGB 范围内:

def visstd(a, s=0.1):
    a = (a - a.mean()) / max(a.std(), 1e-4) * s + 0.5
    return np.uint8(np.clip(a, 0, 1) * 255)

完成后,我们可以显示图像:

一个激活的神经元

这很酷。这让我们一窥网络在这个特定层次上的运作方式。尽管整个网络有数百万个神经元;逐个检查它们并不是一个很可扩展的策略,以便深入了解正在发生的事情。

一个很好的方法是选择一些逐渐抽象的层:

layers = ['block%d_conv%d' % (i, (i + 1) // 2) for i in range(1, 6)]

对于每一层,我们将找到八个代表性神经元,并将它们添加到一个网格中:

grid = []
layers = [layer_dict['block%d_conv%d' % (i, (i + 1) // 2)]
          for i in range(1, 6)]
for layer in layers:
    row = []
    neurons = random.sample(range(max(x or 0
                            for x in layers[0].output_shape)
    for neuron in tqdm(neurons), sample_size), desc=layer.name):
        loss = K.mean(layer.output[:, neuron, :, :])
        grads = normalize(K.gradients(loss, input_img)[0])
        iterate = K.function([input_img], [loss, grads])
        img_data = np.random.uniform(size=(1, 3, 128, 128, 3)) + 128.
        for i in range(20):
            loss_value, grads_value = iterate([img_data])
            img_data += grads_value
        row.append((loss_value, img_data[0]))
    grid.append([cell[1] for cell in
                islice(sorted(row, key=lambda t: -t[0]), 10)])

将网格转换并在笔记本中显示与我们在 Recipe 3.3 中所做的类似:

img_grid = PIL.Image.new('RGB',
                         (8 * 100 + 4, len(layers) * 100 + 4), (180, 180, 180))
for y in range(len(layers)):
    for x in range(8):
        sub = PIL.Image.fromarray(
                 visstd(grid[y][x])).crop((16, 16, 112, 112))
        img_grid.paste(sub,
                       (x * 100 + 4, (y * 100) + 4))
display(img_grid)

激活的神经元网格

讨论

最大化神经网络中神经元的激活是可视化该神经元在网络整体任务中功能的好方法。通过从不同层中抽样神经元,我们甚至可以可视化随着我们在堆栈中上升,神经元检测的特征的复杂性的增加。

我们看到的结果主要包含小图案。我们更新像素的方式使得更大的对象难以出现,因为一组像素必须协同移动,它们都针对其局部内容进行了优化。这意味着更抽象的层次更难“得到它们想要的”,因为它们识别的模式具有更大的尺寸。我们可以在我们生成的网格图像中看到这一点。在下一个配方中,我们将探索一种技术来帮助解决这个问题。

你可能会想为什么我们只尝试激活低层和中间层的神经元。为什么不尝试激活最终预测层?我们可以找到“猫”的预测,并告诉网络激活它,我们期望最终得到一张猫的图片。

遗憾的是,这并不起作用。事实证明,网络将分类为“猫”的所有图像的宇宙是惊人地庞大的,但只有极少数的图像对我们来说是可识别的猫。因此,生成的图像几乎总是对我们来说像噪音,但网络认为它是一只猫。

在第十三章中,我们将探讨一些生成更真实图像的技术。

12.2 Octaves 和 Scaling

问题

如何可视化激活神经元的较大结构?

解决方案

在优化图像以最大化神经元激活的同时进行缩放。

在前一步中,我们看到我们可以创建最大化神经元激活的图像,但模式仍然相当局部。一个有趣的方法来解决这个问题是从一个小图像开始,然后通过一系列步骤来优化它,使用前一个配方的算法,然后对图像进行放大。这允许激活步骤首先勾勒出图像的整体结构,然后再填充细节。从一个 64×64 的图像开始:

img_data = np.random.uniform(size=(1, 3, size, size)) + 128.

现在我们可以进行 20 次缩放/优化操作:

for octave in range(20):
    if octave>0:
        size = int(size * 1.1)
        img_data = resize_img(img_data, (size, size))
    for i in range(10):
        loss_value, grads_value = iterate([img_data])
        img_data += grads_value
    clear_output()
    showarray(visstd(img_data[0]))

使用block5_conv1层和神经元 4 会得到一个看起来很有机的结果:

Octave 激活的神经元

讨论

Octaves 和 scaling 是让网络生成某种程度上代表它所看到的东西的图像的好方法。

这里有很多可以探索的地方。在解决方案中的代码中,我们只优化一个神经元的激活,但我们可以同时优化多个神经元,以获得更混合的图片。我们可以为它们分配不同的权重,甚至为其中一些分配负权重,迫使网络远离某些激活。

当前算法有时会产生太多高频率,特别是在第一个 octave 中。我们可以通过对第一个 octave 应用高斯模糊来抵消这一点,以产生一个不那么锐利的结果。

当图像达到我们的目标大小时,为什么要停止调整大小呢?相反,我们可以继续调整大小,但同时裁剪图像以保持相同的大小。这将创建一个视频序列,我们在不断缩放的同时,新的图案不断展开。

一旦我们开始制作电影,我们还可以改变我们激活的神经元集合,并通过这种方式探索网络。movie_dream.py脚本结合了其中一些想法,并生成了令人着迷的电影,你可以在YouTube上找到一个示例。

12.3 可视化神经网络几乎看到了什么

问题

你能夸大网络检测到的东西,以更好地了解它看到了什么吗?

解决方案

扩展前一个配方的代码以操作现有图像。

有两件事情我们需要改变才能使现有的算法工作。首先,对现有图像进行放大会使其变得相当块状。其次,我们希望保持与原始图像的某种相似性,否则我们可能会从一个随机图像开始。修复这两个问题会重现谷歌著名的 DeepDream 实验,其中出现了怪异的图片,如天空和山脉景观。

我们可以通过跟踪由于放大而丢失的细节来实现这两个目标,并将丢失的细节注入生成的图像中;这样我们就可以消除缩放造成的伪影,同时在每个八度将图像“引导”回原始状态。在以下代码中,我们获取所有想要经历的形状,然后逐步放大图像,优化图像以适应我们的损失函数,然后通过比较放大和缩小之间丢失的内容来添加丢失的细节:

successive_shapes = [tuple(int(dim / (octave_scale ** i))
                     for dim in original_shape)
                     for i in range(num_octave - 1, -1, -1)]

original_img = np.copy(img)
shrunk_original_img = resize_img(img, successive_shapes[0])

for shape in successive_shapes:
    print('Processing image shape', shape)
    img = resize_img(img, shape)
    for i in range(20):
        loss_value, grads_value = iterate([img])
        img += grads_value
    upscaled_shrunk_original_img = resize_img(shrunk_original_img, shape)
    same_size_original = resize_img(original_img, shape)
    lost_detail = same_size_original - upscaled_shrunk_original_img

    img += lost_detail
    shrunk_original_img = resize_img(original_img, shape)

这给出了一个相当不错的结果:

Deep Dream 一个神经元

原始的谷歌 Deep Dream 算法略有不同。我们刚刚告诉网络优化图像以最大化特定神经元的激活。而谷歌所做的是让网络夸大它已经看到的东西。

事实证明,我们可以通过调整我们先前定义的损失函数来优化图像,以增加当前激活。我们不再考虑一个神经元,而是要使用整个层。为了使其工作,我们必须修改我们的损失函数,使其最大化已经高的激活。我们通过取激活的平方和来实现这一点。

让我们首先指定我们要优化的三个层及其相应的权重:

settings = {
        'block3_pool': 0.1,
        'block4_pool': 1.2,
        'block5_pool': 1.5,
}

现在我们将损失定义为这些的总和,通过仅涉及损失中的非边界像素来避免边界伪影:

loss = K.variable(0.)
for layer_name, coeff in settings.items():
    x = layer_dict[layer_name].output
    scaling = K.prod(K.cast(K.shape(x), 'float32'))
    if K.image_data_format() == 'channels_first':
        loss += coeff * K.sum(K.square(x[:, :, 2: -2, 2: -2])) / scaling
    else:
        loss += coeff * K.sum(K.square(x[:, 2: -2, 2: -2, :])) / scaling

iterate函数保持不变,生成图像的函数也是如此。我们唯一做出的改变是,在将梯度添加到图像时,通过将grad_value乘以 0.1 来减慢速度:

    for i in range(20):
        loss_value, grads_value = iterate([img])
        img += grads_value * 0.10

运行此代码,我们看到眼睛和类似动物脸的东西出现在风景中:

使用整个图像的 Deep Dream

您可以尝试调整层、它们的权重和速度因子以获得不同的图像。

讨论

Deep Dream 似乎是一种生成致幻图像的有趣方式,它确实允许无休止地探索和实验。但它也是一种了解神经网络在图像中看到的内容的方式。最终,这反映了网络训练的图像:一个训练有关猫和狗的网络将在云的图像中“看到”猫和狗。

我们可以利用来自第九章的技术。如果我们有一组大量的图像用于重新训练现有网络,但我们只将该网络的一个层设置为可训练,那么网络必须将其所有“偏见”放入该层中。然后,当我们以该层作为优化层运行深度梦想步骤时,这些“偏见”应该被很好地可视化。

总是很诱人地将神经网络的功能与人类大脑的工作方式进行类比。由于我们对后者了解不多,这当然是相当推测性的。然而,在这种情况下,某些神经元的激活似乎接近于大脑实验,研究人员通过在其中插入电极来人为激活人脑的一部分,受试者会体验到某种图像、气味或记忆。

同样,人类有能力在云的形状中识别出脸部和动物。一些改变思维的物质增加了这种能力。也许这些物质在我们的大脑中人为增加了神经层的激活?

12.4 捕捉图像的风格

问题

如何捕捉图像的风格?

解决方案

计算图像的卷积层的格拉姆矩阵。

在前一个教程中,我们看到了如何通过要求网络优化图像以最大化特定神经元的激活来可视化网络学到的内容。一层的格拉姆矩阵捕捉了该层的风格,因此如果我们从一个充满随机噪声的图像开始优化它,使其格拉姆矩阵与目标图像的格拉姆矩阵匹配,我们期望它开始模仿目标图像的风格。

注意

格拉姆矩阵是激活的扁平化版本,乘以自身的转置。

然后,我们可以通过从每个激活集中减去格拉姆矩阵,平方结果,然后将所有结果相加来定义两组激活之间的损失函数:

def gram_matrix(x):
    if K.image_data_format() != 'channels_first':
        x = K.permute_dimensions(x, (2, 0, 1))
    features = K.batch_flatten(x)
    return K.dot(features, K.transpose(features))

def style_loss(layer_1, layer_2):
    gr1 = gram_matrix(layer_1)
    gr2 = gram_matrix(layer_1)
    return K.sum(K.square(gr1 - gr2))

与以前一样,我们希望一个预训练网络来完成工作。我们将在两个图像上使用它,一个是我们生成的图像,另一个是我们想要从中捕捉风格的图像——在这种情况下是克劳德·莫奈 1912 年的《睡莲》。因此,我们将创建一个包含两者的输入张量,并加载一个没有最终层的网络,该网络以此张量作为输入。我们将使用VGG16,因为它很简单,但任何预训练网络都可以:

style_image = K.variable(preprocess_image(style_image_path,
                                          target_size=(1024, 768)))
result_image = K.placeholder(style_image.shape)
input_tensor = K.concatenate([result_image,
                              style_image], axis=0)

model = vgg16.VGG16(input_tensor=input_tensor,
                    weights='imagenet', include_top=False)

一旦我们加载了模型,我们就可以定义我们的损失变量。我们将遍历模型的所有层,并对其中名称中包含_conv的层(卷积层)收集style_imageresult_image之间的style_loss

loss = K.variable(0.)
for layer in model.layers:
    if '_conv' in layer.name:
        output = layer.output
        loss += style_loss(output[0, :, :, :], output[1, :, :, :])

现在我们有了一个损失,我们可以开始优化。我们将使用scipyfmin_l_bfgs_b优化器。该方法需要一个梯度和一个损失值来完成其工作。我们可以通过一次调用获得它们,因此我们需要缓存这些值。我们使用一个方便的辅助类Evaluator来做到这一点,它接受一个损失和一个图像:

class Evaluator(object):
    def __init__(self, loss_total, result_image):
        grads = K.gradients(loss_total, result_image)
        outputs = [loss_total] + grads
        self.iterate = K.function([result_image], outputs)
        self.shape = result_image.shape

        self.loss_value = None
        self.grads_values = None

    def loss(self, x):
        outs = self.iterate([x.reshape(self.shape)])
        self.loss_value = outs[0]
        self.grad_values = outs[-1].flatten().astype('float64')
        return self.loss_value

    def grads(self, x):
        return np.copy(self.grad_values)

现在我们可以通过重复调用来优化图像:

image, min_val, _ = fmin_l_bfgs_b(evaluator.loss, image.flatten(),
                                  fprime=evaluator.grads, maxfun=20)

经过大约 50 步后,生成的图像开始看起来相当合理。

讨论

在这个教程中,我们看到了格拉姆矩阵有效地捕捉了图像的风格。天真地,我们可能会认为匹配图像风格的最佳方法是直接匹配所有层的激活。但这种方法太过直接了。

也许不明显格拉姆矩阵方法会更好。其背后的直觉是,通过将给定层的每个激活与其他每个激活相乘,我们捕捉了神经元之间的相关性。这些相关性编码了风格,因为它是激活分布的度量,而不是绝对激活。

考虑到这一点,我们可以尝试一些事情。一个要考虑的事情是零值。将一个向量与其自身转置的点积将在任一乘数为零时产生零。这使得无法发现与零的相关性。由于零值经常出现,这是不太理想的。一个简单的修复方法是在进行点操作之前向特征添加一个增量。值为-1效果很好:

return K.dot(features - 1, K.transpose(features - 1))

我们还可以尝试向表达式添加一个常数因子。这可以使结果更加平滑或夸张。同样,-1效果很好。

最后要考虑的是我们正在获取所有激活的格拉姆矩阵。这可能看起来有点奇怪——难道我们不应该只为每个像素的通道执行此操作吗?实际上正在发生的是,我们为每个像素的通道计算格拉姆矩阵,然后查看它们在整个图像上的相关性。这允许一种快捷方式:我们可以计算平均通道并将其用作格拉姆矩阵。这给我们一个捕捉平均风格的图像,因此更加规则。它也运行得更快:

def gram_matrix_mean(x):
    x = K.mean(x, axis=1)
    x = K.mean(x, axis=1)
    features = K.batch_flatten(x)
    return K.dot(features - 1,
                 K.transpose(features - 1)) / x.shape[0].value

在这个教程中添加的总变差损失告诉网络保持相邻像素之间的差异。如果没有这个,结果将更加像素化和跳跃。在某种程度上,这种方法与我们用来控制网络层的权重或输出的正则化非常相似。总体效果类似于在输出像素上应用轻微模糊滤镜。

12.5 改进损失函数以增加图像的连贯性

问题

如何使从捕捉到的风格生成的图像不那么像素化?

解决方案

添加一个损失组件来控制图像的局部连贯性。

前一个配方中的图像看起来已经相当合理。然而,如果我们仔细看,似乎有些像素化。我们可以通过添加一个损失函数来引导算法,确保图像在局部上是连贯的。我们将每个像素与其左侧和下方的邻居进行比较。通过试图最小化这种差异,我们引入了一种对图像进行模糊处理的方法:

def total_variation_loss(x, exp=1.25):
    _, d1, d2, d3 = x.shape
    a = K.square(x[:, :d1 - 1, :d2 - 1, :] - x[:, 1:, :d2 - 1, :])
    b = K.square(x[:, :d1 - 1, :d2 - 1, :] - x[:, :d1 - 1, 1:, :])
    return K.sum(K.pow(a + b, exp))

1.25 指数确定了我们惩罚异常值的程度。将这个添加到我们的损失中得到:

loss_variation = total_variation_loss(result_image, h, w) / 5000
loss_with_variation = loss_variation + loss_style
evaluator_with_variation = Evaluator(loss_with_variation, result_image)

如果我们运行这个评估器 100 步,我们会得到一个非常令人信服的图片:

没有图像的神经风格

讨论

在这个配方中,我们向我们的损失函数添加了最终组件,使图片在全局上看起来像内容图像。实际上,我们在这里所做的是优化生成的图像,使得上层的激活对应于内容图像,而下层的激活对应于风格图像。由于下层对应于风格,上层对应于内容,我们可以通过这种方式实现风格转移。

结果可能非常引人注目,以至于新手可能认为计算机现在可以创作艺术。但仍然需要调整,因为有些风格比其他风格更狂野,我们将在下一个配方中看到。

12.6 将风格转移到不同的图像

问题

如何将从一幅图像捕捉到的风格应用到另一幅图像上?

解决方案

使用一个损失函数,平衡一幅图像的内容和另一幅图像的风格。

在现有图像上运行前一个配方中的代码会很容易,而不是在噪声图像上运行,但结果并不那么好。起初似乎是将风格应用到现有图像上,但随着每一步,原始图像都会逐渐消失一点。如果我们继续应用算法,最终结果将基本相同,不管起始图像如何。

我们可以通过向我们的损失函数添加第三个组件来修复这个问题,考虑生成的图像与我们参考图像之间的差异:

def content_loss(base, combination):
    return K.sum(K.square(combination - base))

现在我们需要将参考图像添加到我们的输入张量中:

w, h = load_img(base_image_path).size
base_image = K.variable(preprocess_image(base_image_path))
style_image = K.variable(preprocess_image(style2_image_path, target_size=(h, w)))
combination_image = K.placeholder(style_image.shape)
input_tensor = K.concatenate([base_image,
                              style_image,
                              combination_image], axis=0)

我们像以前一样加载网络,并在网络的最后一层定义我们的内容损失。最后一层包含了网络所看到的最佳近似,所以这确实是我们想要保持不变的:

loss_content = content_loss(feature_outputs[-1][0, :, :, :],
                            feature_outputs[-1][2, :, :, :])

我们将稍微改变风格损失,考虑网络中的层的位置。我们希望较低的层承载更多的权重,因为较低的层捕捉更多的纹理/风格,而较高的层更多地涉及图像的内容。这使得算法更容易平衡图像的内容(使用最后一层)和风格(主要使用较低的层):

loss_style = K.variable(0.)
for idx, layer_features in enumerate(feature_outputs):
    loss_style += style_loss(layer_features[1, :, :, :],
                             layer_features[2, :, :, :]) * (0.5 ** idx)

最后,我们平衡这三个组件:

loss_content /= 40
loss_variation /= 10000
loss_total = loss_content + loss_variation + loss_style

在阿姆斯特丹的 Oude Kerk(古教堂)的图片上运行这个算法,以梵高的《星夜》作为风格输入,我们得到:

梵高的 Oude Kerk

12.7 风格插值

问题

您已经捕捉到两幅图像的风格,并希望将这两者之间的风格应用到另一幅图像中。如何混合它们?

解决方案

使用一个损失函数,带有额外的浮点数,指示应用每种风格的百分比。

我们可以轻松地扩展我们的输入张量,以接受两种风格图像,比如夏季和冬季各一种。在我们像以前一样加载模型之后,我们现在可以为每种风格创建一个损失:

loss_style_summer = K.variable(0.)
loss_style_winter = K.variable(0.)
for idx, layer_features in enumerate(feature_outputs):
    loss_style_summer += style_loss(layer_features[1, :, :, :],
                                    layer_features[-1, :, :, :]) * (0.5 ** idx)
    loss_style_winter += style_loss(layer_features[2, :, :, :],
                                    layer_features[-1, :, :, :]) * (0.5 ** idx)

然后我们引入一个占位符summerness,我们可以输入以获得所需的summerness损失:

summerness = K.placeholder()
loss_total = (loss_content + loss_variation +
              loss_style_summer * summerness +
              loss_style_winter * (1 - summerness))

我们的Evaluator类没有一种传递summerness的方法。我们可以创建一个新类或者子类现有类,但在这种情况下,我们可以通过“猴子补丁”来解决:

combined_evaluator = Evaluator(loss_total, combination_image,
                               loss_content=loss_content,
                               loss_variation=loss_variation,
                               loss_style=loss_style)
iterate = K.function([combination_image, summerness],
                     combined_evaluator.iterate.outputs)
combined_evaluator.iterate = lambda inputs: iterate(inputs + [0.5])

这将创建一个图像,其中有 50%的夏季风格,但我们可以指定任何值。

讨论

在损失变量中添加另一个组件允许我们指定两种不同风格之间的权重。当然,没有任何阻止我们添加更多风格图像并改变它们的权重。值得探索的是改变风格图像的相对权重;梵高的星空图像非常鲜明,其风格很容易压倒更加微妙的绘画风格。

第十三章:使用自动编码器生成图像

在第五章中,我们探讨了如何生成文本,以某个现有语料库的风格为基础,无论是莎士比亚的作品还是 Python 标准库中的代码,而在第十二章中,我们研究了通过优化预训练网络中通道的激活来生成图像。在本章中,我们将结合这些技术并在其基础上生成基于示例的图像。

基于示例生成图像是一个活跃研究领域,新的想法和突破性进展每月都有报道。然而,目前最先进的算法在模型复杂性、训练时间和所需数据方面超出了本书的范围。相反,我们将在一个相对受限的领域进行工作:手绘草图。

我们将从查看谷歌的 Quick Draw 数据集开始。这是一个在线绘图游戏的结果,包含许多手绘图片。这些绘画以矢量格式存储,因此我们将把它们转换为位图。我们将选择带有一个标签的草图:猫。

基于这些猫的草图,我们将构建一个自动编码器模型,能够学习猫的特征——它可以将猫的绘图转换为内部表示,然后从该内部表示生成类似的东西。我们将首先查看这个网络在我们的猫上的性能可视化。

然后我们将切换到手绘数字的数据集,然后转向变分自动编码器。这些网络生成密集空间,是它们输入的抽象表示,我们可以从中进行采样。每个样本将产生一个看起来逼真的图像。我们甚至可以在点之间进行插值,看看图像是如何逐渐变化的。

最后,我们将看看条件变分自动编码器,在训练时考虑标签,因此可以以随机方式再现某一类别的图像。

与本章相关的代码可以在以下笔记本中找到:

13.1 Quick Draw Cat Autoencoder
13.2 Variational Autoencoder

13.1 从 Google Quick Draw 导入绘图

问题

你在哪里可以获得一组日常手绘图像?

解决方案

使用谷歌 Quick Draw 的数据集。

Google Quick Draw是一个在线游戏,用户被挑战绘制某物,并查看 AI 是否能猜出他们试图创建的内容。这个游戏很有趣,作为一个副产品,产生了一个大量带标签的绘画数据库。谷歌已经让任何想玩机器学习的人都可以访问这个数据集。

数据可在多种格式中获得。我们将使用简化的矢量绘图的二进制编码版本。让我们开始获取所有的猫:

BASE_PATH = 'https://storage.googleapis.com/quickdraw_dataset/full/binary/
path = get_file('cat', BASE_PATH + 'cat.bin')

我们将逐个解压这些图像。它们以二进制矢量格式存储,我们将在一个空位图上绘制。这些绘画以 15 字节的头部开始,因此我们只需继续处理,直到我们的文件不再至少有 15 字节为止:

x = []
with open(path, 'rb') as f:
    while True:
        img = PIL.Image.new('L', (32, 32), 'white')
        draw = ImageDraw.Draw(img)
        header = f.read(15)
        if len(header) != 15:
            break

一幅图是一系列笔画的列表,每个笔画由一系列xy坐标组成。xy坐标分开存储,因此我们需要将它们压缩成一个列表,以便输入到我们刚刚创建的ImageDraw对象中:

            strokes, = unpack('H', f.read(2))
            for i in range(strokes):
                n_points, = unpack('H', f.read(2))
                fmt = str(n_points) + 'B'
                read_scaled = lambda: (p // 8 for
                                       p in unpack(fmt, f.read(n_points)))
                points = [*zip(read_scaled(), read_scaled())]
                draw.line(points, fill=0, width=2)
            img = img_to_array(img)
            x.append(img)

超过十万幅猫的绘画属于你。

讨论

通过游戏收集用户生成的数据是建立机器学习数据集的一种有趣方式。这不是谷歌第一次使用这种技术——几年前,它运行了Google Image Labeler 游戏,两个不相识的玩家会为图像打标签,并根据匹配的标签获得积分。然而,该游戏的结果从未向公众公开。

数据集中有 345 个类别。在本章中,我们只使用了猫,但您可以尝试其余的类别来构建图像分类器。数据集存在缺点,其中最主要的是并非所有的绘画都是完成的;当 AI 识别出绘画时,游戏就结束了,对于一幅骆驼画来说,画两个驼峰可能就足够了。

注意

在这个示例中,我们自己对图像进行了光栅化处理。Google 确实提供了一个numpy数组版本的数据,其中图像已经被预先光栅化为 28×28 像素。

13.2 创建图像的自动编码器

问题

即使没有标签,是否可以自动将图像表示为固定大小的向量?

解决方案

使用自动编码器。

在第九章中,我们看到我们可以使用卷积网络通过连续的层从像素到局部特征再到更多结构特征最终到图像的抽象表示来对图像进行分类,然后我们可以使用这个抽象表示来预测图像的内容。在第十章中,我们将图像的抽象表示解释为高维语义空间中的向量,并利用接近的向量表示相似的图像作为构建反向图像搜索引擎的一种方式。最后,在第十二章中,我们看到我们可以可视化卷积网络中不同层级的各种神经元的激活意味着什么。

为了做到这一点,我们需要对图像进行标记。只有因为网络看到了大量的狗、猫和许多其他东西,它才能在这个高维空间中学习这些东西的抽象表示。如果我们的图像没有标签怎么办?或者标签不足以让网络形成对事物的直觉?在这些情况下,自动编码器可以提供帮助。

自动编码器背后的想法是强制网络将图像表示为具有特定大小的向量,并且基于网络能够从该表示中准确复制输入图像的损失函数。输入和期望输出是相同的,这意味着我们不需要标记的图像。任何一组图像都可以。

网络的结构与我们之前看到的非常相似;我们取原始图像并使用一系列卷积层和池化层来减小大小并增加深度,直到我们有一个一维向量,这是该图像的抽象表示。但是,我们不会就此罢手并使用该向量来预测图像是什么,而是跟进并通过一组上采样层从图像的抽象表示开始,进行相反操作,直到我们再次得到一个图像。作为我们的损失函数,我们然后取输入和输出图像之间的差异:

def create_autoencoder():
    input_img = Input(shape=(32, 32, 1))

    channels = 2
    x = input_img
    for i in range(4):
        channels *= 2
        left = Conv2D(channels, (3, 3),
                      activation='relu', padding='same')(x)
        right = Conv2D(channels, (2, 2),
                       activation='relu', padding='same')(x)
        conc = Concatenate()([left, right])
        x = MaxPooling2D((2, 2), padding='same')(conc)

    x = Dense(channels)(x)

    for i in range(4):
        x = Conv2D(channels, (3, 3), activation='relu', padding='same')(x)
        x = UpSampling2D((2, 2))(x)
        channels //= 2
    decoded = Conv2D(1, (3, 3), activation='sigmoid', padding='same')(x)

    autoencoder = Model(input_img, decoded)
    autoencoder.compile(optimizer='adadelta', loss='binary_crossentropy')
    return autoencoder

autoencoder = create_autoencoder()
autoencoder.summary()

我们可以将网络架构想象成一个沙漏。顶部和底部层代表图像。网络中最小的点位于中间,并经常被称为潜在表示。我们在这里有一个具有 128 个条目的潜在空间,这意味着我们强制网络使用 128 个浮点数来表示每个 32×32 像素的图像。网络能够最小化输入和输出图像之间的差异的唯一方法是尽可能多地将信息压缩到潜在表示中。

我们可以像以前一样训练网络:

autoencoder.fit(x_train, x_train,
                epochs=100,
                batch_size=128,
                validation_data=(x_test, x_test))

这应该相当快地收敛。

讨论

自动编码器是一种有趣的神经网络类型,因为它们能够在没有任何监督的情况下学习其输入的紧凑、有损表示。在这个示例中,我们已经将它们用于图像,但它们也成功地被部署来处理文本或其他形式的时间序列数据。

自动编码器思想有许多有趣的扩展。其中之一是去噪自动编码器。这里的想法是要求网络不是从自身预测目标图像,而是从自身的损坏版本预测目标图像。例如,我们可以向输入图像添加一些随机噪声。损失函数仍然会将网络的输出与原始(非加噪)输入进行比较,因此网络将有效地学习如何从图片中去除噪声。在其他实验中,这种技术在恢复黑白图片的颜色方面被证明是有用的。

我们在第十章中使用图像的抽象表示来创建一个反向图像搜索引擎,但我们需要标签。使用自动编码器,我们不需要这些标签;我们可以在模型仅训练了一组图像之后测量图像之间的距离。事实证明,如果我们使用去噪自动编码器,我们的图像相似性算法的性能会提高。这里的直觉是噪声告诉网络不要注意的内容,类似于数据增强的工作方式(参见“图像的预处理”)。

13.3 可视化自动编码器结果

问题

您想要了解您的自动编码器的工作情况如何。

解决方案

从输入中随机抽取几张猫的图片,并让模型预测这些图片;然后将输入和输出呈现为两行。

让我们预测一些猫:

cols = 25
idx = np.random.randint(x_test.shape[0], size=cols)
sample = x_test[idx]
decoded_imgs = autoencoder.predict(sample)

并在我们的笔记本中展示它们:

def decode_img(tile):
    tile = tile.reshape(tile.shape[:-1])
    tile = np.clip(tile * 400, 0, 255)
    return PIL.Image.fromarray(tile)

overview = PIL.Image.new('RGB', (cols * 32, 64 + 20), (128, 128, 128))
for idx in range(cols):
    overview.paste(decode_img(sample[idx]), (idx * 32, 5))
    overview.paste(decode_img(decoded_imgs[idx]), (idx * 32, 42))
f = BytesIO()
overview.save(f, 'png')
display(Image(data=f.getvalue()))

一排猫

正如您所看到的,网络确实捕捉到了基本形状,但似乎对自己不太确定,这导致模糊的图标绘制,几乎像阴影一样。

在下一个步骤中,我们将看看是否可以做得更好。

讨论

由于自动编码器的输入和输出应该是相似的,检查网络性能的最佳方法就是从验证集中随机选择一些图标,并要求网络对其进行重建。使用 PIL 创建一个显示两行图像并在 Jupyter 笔记本中显示的图像是我们之前见过的。

这里的一个问题是我们使用的损失函数会导致网络模糊其输出。输入图纸包含细线,但我们模型的输出却没有。我们的模型没有动力预测清晰的线条,因为它不确定线条的确切位置,所以它宁愿分散其赌注并绘制模糊的线条。这样至少有一些像素会被命中的机会很高。为了改进这一点,我们可以尝试设计一个损失函数,强制网络限制它绘制的像素数量,或者对清晰的线条加以奖励。

13.4 从正确分布中抽样图像

问题

如何确保向量中的每个点都代表一个合理的图像?

解决方案

使用变分自动编码器。

自动编码器作为一种将图像表示为比图像本身小得多的向量的方式非常有趣。但是,这些向量的空间并不是密集的;也就是说,每个图像在该空间中都有一个向量,但并非该空间中的每个向量都代表一个合理的图像。自动编码器的解码器部分当然会根据任何向量创建一个图像,但其中大多数图像都不会被识别。变分自动编码器确实具有这种属性。

在本章和接下来的配方中,我们将使用手写数字的 MNIST 数据集,包括 60000 个训练样本和 10000 个测试样本。这里描述的方法适用于图标,但会使模型复杂化,为了良好的性能,我们需要比现有的图标更多。如果您感兴趣,笔记本目录中有一个可用的模型。让我们从加载数据开始:

def prepare(images, labels):
    images = images.astype('float32') / 255
    n, w, h = images.shape
    return images.reshape((n, w * h)), to_categorical(labels)

train, test = mnist.load_data()
x_train, y_train = prepare(*train)
x_test, y_test = prepare(*test)
img_width, img_height = train[0].shape[1:]

变分自动编码器背后的关键思想是在损失函数中添加一个项,表示图像和抽象表示之间的统计分布差异。为此,我们将使用 Kullback-Leibler 散度。我们可以将其视为概率分布空间的距离度量,尽管从技术上讲它不是距离度量。维基百科文章中有详细信息,供那些想要了解数学知识的人阅读。

我们模型的基本轮廓与上一个示例类似。我们从代表我们像素的输入开始,将其通过一些隐藏层,然后将其采样到一个非常小的表示。然后我们再次逐步提升,直到我们恢复我们的像素:

pixels = Input(shape=(num_pixels,))
encoder_hidden = Dense(512, activation='relu')(pixels)
z_mean = Dense(latent_space_depth,
               activation='linear')(encoder_hidden)
z_log_var = Dense(latent_space_depth,
                  activation='linear')(encoder_hidden)
z = Lambda(sample_z, output_shape=(latent_space_depth,))(
        [z_mean, z_log_var])
decoder_hidden = Dense(512, activation='relu')
reconstruct_pixels = Dense(num_pixels, activation='sigmoid')
hidden = decoder_hidden(z)
outputs = reconstruct_pixels(hidden)
auto_encoder = Model(pixels, outputs)

这里有趣的部分是z张量和它被分配给的Lambda。这个张量将保存我们图像的潜在表示,而Lambda使用sample_z方法进行采样:

def sample_z(args):
    z_mean, z_log_var = args
    eps = K.random_normal(shape=(batch_size, latent_space_depth),
                         mean=0., stddev=1.)
    return z_mean + K.exp(z_log_var / 2) * eps

这是我们使用两个变量z_meanz_log_var从正态分布中随机采样点的地方。

现在让我们来看看我们的损失函数。第一个组件是重构损失,它衡量了输入像素和输出像素之间的差异:

def reconstruction_loss(y_true, y_pred):
    return K.sum(K.binary_crossentropy(y_true, y_pred), axis=-1)

我们需要的第二件事是在我们的损失函数中添加一个使用 Kullback-Leibler 散度来引导分布走向正确方向的组件:

def KL_loss(y_true, y_pred):
    return 0.5 * K.sum(K.exp(z_log_var) +
                       K.square(z_mean) - 1 - z_log_var,
                       axis=1)

然后我们简单地将其相加:

def total_loss(y_true, y_pred):
    return (KL_loss(y_true, y_pred) +
            reconstruction_loss(y_true, y_pred))

我们可以用以下方式编译我们的模型:

auto_encoder.compile(optimizer=Adam(lr=0.001),
                     loss=total_loss,
                     metrics=[KL_loss, reconstruction_loss])

这也会方便地跟踪训练过程中损失的各个组件。

由于额外的损失函数和对sample_z的带外调用,这个模型稍微复杂;要查看详细信息,最好在相应的笔记本中查看。现在我们可以像以前一样训练模型:

cvae.fit(x_train, x_train, verbose = 1, batch_size=batch_size, epochs=50,
         validation_data = (x_test, x_test))

一旦训练完成,我们希望通过在潜在空间中提供一个随机点并查看出现的图像来使用结果。我们可以通过创建一个第二个模型,其输入是我们auto_encoder模型的中间层,输出是我们的目标图像来实现这一点:

decoder_in = Input(shape=(latent_space_depth,))
decoder_hidden = decoder_hidden(decoder_in)
decoder_out = reconstruct_pixels(decoder_hidden)
decoder = Model(decoder_in, decoder_out)

现在我们可以生成一个随机输入,然后将其转换为一幅图片:

random_number = np.asarray([[np.random.normal()
                            for _ in range(latent_space_depth)]])
def decode_img(a):
    a = np.clip(a * 256, 0, 255).astype('uint8')
    return PIL.Image.fromarray(a)

decode_img(decoder.predict(random_number)
               .reshape(img_width, img_height)).resize((56, 56))

随机生成的数字

讨论

当涉及生成图像而不仅仅是复制图像时,变分自动编码器为自动编码器添加了一个重要组件;通过确保图像的抽象表示来自一个“密集”空间,其中靠近原点的点映射到可能的图像,我们现在可以生成具有与我们输入相同的可能性分布的图像。

基础数学知识超出了本书的范围。这里的直觉是一些图像更“正常”,一些更意外。潜在空间具有相同的特征,因此从原点附近绘制的点对应于“正常”的图像,而更极端的点则对应于更不太可能的图像。从正态分布中采样将导致生成具有与模型训练期间看到的预期和意外图像混合的图像。

拥有密集空间很好。它允许我们在点之间进行插值,并仍然获得有效的结果。例如,如果我们知道潜在空间中的一个点映射到 6,另一个点映射到 8,我们期望在两者之间的点会产生从 6 到 8 的图像。如果我们找到相同的图像但是以不同的风格,我们可以寻找混合风格的中间图像。或者我们甚至可以朝着另一个方向前进,并期望找到更极端的风格。

在第三章中,我们看过了词嵌入,其中每个单词都有一个将其投影到语义空间的向量,以及我们可以对其进行的计算。尽管这很有趣,但由于空间不是密集的,我们通常不会期望在两个单词之间找到某种折中的东西——在 之间没有 骡子。同样,我们可以使用预训练的图像识别网络为一张猫的图片找到一个向量,但其周围的向量并不都代表猫的变化。

13.5 可视化变分自动编码器空间

问题

如何可视化您可以从潜在空间生成的图像的多样性?

解决方案

使用潜在空间中的两个维度创建一个生成图像的网格。

从我们的潜在空间中可视化两个维度是直接的。对于更高的维度,我们可以首先尝试 t-SNE 将其降至两个维度。幸运的是,在前一个示例中我们只使用了两个维度,所以我们可以通过一个平面并将每个 (x, y) 位置映射到潜在空间中的一个点。由于我们使用正态分布,我们期望合理的图像出现在 [-1.5, 1.5] 范围内:

num_cells = 10
overview = PIL.Image.new('RGB',
                         (num_cells * (img_width + 4) + 8,
                          num_cells * (img_height + 4) + 8),
                         (128, 128, 128))
vec = np.zeros((1, latent_space_depth))
for x in range(num_cells):
    vec[:, 0] = (x * 3) / (num_cells - 1) - 1.5
    for y in range(num_cells):
        vec[:, 1] = (y * 3) / (num_cells - 1) - 1.5
        decoded = decoder.predict(vec)
        img = decode_img(decoded.reshape(img_width, img_height))
        overview.paste(img, (x * (img_width + 4) + 6,
                             y * (img_height + 4) + 6))
overview

这将为我们提供网络学习的不同数字的漂亮图像:

随机生成的网格

讨论

通过将 (x, y) 映射到我们的潜在空间并将结果解码为图像,我们可以很好地了解我们的空间包含的内容。正如我们所看到的,空间确实相当密集。并非所有点都会产生数字;一些点,如预期的那样,代表中间形式。但模型确实找到了一种在网格上以自然方式分布数字的方法。

这里要注意的另一件事是,我们的变分自动编码器在压缩图像方面表现出色。每个输入图像仅由 2 个浮点数在潜在空间中表示,而它们的像素表示使用 28 × 28 = 784 个浮点数。这是接近 400 的压缩比,远远超过了 JPEG。当然,这种压缩是有损的——一个手写的 5 在编码和解码后仍然看起来像一个手写的 5,仍然是同一风格,但在像素级别上没有真正的对应。此外,这种形式的压缩是极其领域特定的。它只适用于手写数字,而 JPEG 可用于压缩各种图像和照片。

13.6 条件变分自动编码器

问题

如何生成特定类型的图像而不是完全随机的图像?

解决方案

使用条件变分自动编码器。

前两个示例中的自动编码器很好地生成了随机数字,还能够接收一个数字并将其编码为一个漂亮、密集的潜在空间。但它无法区分 5 和 3,所以我们唯一能让它生成一个随机的 3 的方法是首先找到潜在空间中的所有 3,然后从该子空间中进行采样。条件变分自动编码器通过将标签作为输入并将标签连接到模型中的潜在空间向量 z 来帮助这里。

这样做有两个作用。首先,它让模型在学习编码时考虑实际标签。其次,由于它将标签添加到潜在空间中,我们的解码器现在将同时接收潜在空间中的一个点和一个标签,这使我们能够明确要求生成特定的数字。模型现在看起来像这样:

pixels = Input(shape=(num_pixels,))
label = Input(shape=(num_labels,), name='label')
inputs = concat([pixels, label], name='inputs')

encoder_hidden = Dense(512, activation='relu',
                       name='encoder_hidden')(inputs)
z_mean = Dense(latent_space_depth,
               activation='linear')(encoder_hidden)
z_log_var = Dense(latent_space_depth,
                  activation='linear')(encoder_hidden)
z = Lambda(sample_z,
           output_shape=(latent_space_depth, ))([z_mean, z_log_var])
zc = concat([z, label])

decoder_hidden = Dense(512, activation='relu')
reconstruct_pixels = Dense(num_pixels, activation='sigmoid')
decoder_in = Input(shape=(latent_space_depth + num_labels,))
hidden = decoder_hidden(decoder_in)
decoder_out = reconstruct_pixels(hidden)
decoder = Model(decoder_in, decoder_out)

hidden = decoder_hidden(zc)
outputs = reconstruct_pixels(hidden)
cond_auto_encoder = Model([pixels, label], outputs)

我们通过向模型提供图像和标签来训练模型:

cond_auto_encoder.fit([x_train, y_train], x_train, verbose=1,
                      batch_size=batch_size, epochs=50,
                      validation_data = ([x_test, y_test], x_test))

现在我们可以生成一个明确的数字 4:

number_4 = np.zeros((1, latent_space_depth + y_train.shape[1]))
number_4[:, 4 + latent_space_depth] = 1
decode_img(cond_decoder.predict(number_4).reshape(img_width, img_height))

数字四

由于我们指定了要生成的数字的一位有效编码,我们也可以要求在两个数字之间生成某些东西:

number_8_3 = np.zeros((1, latent_space_depth + y_train.shape[1]))
number_8_3[:, 8 + latent_space_depth] = 0.5
number_8_3[:, 3 + latent_space_depth] = 0.5
decode_img(cond_decoder.predict(number_8_3).reshape(
    img_width, img_height))

这确实产生了介于两者之间的东西:

数字八或三

另一个有趣的尝试是将数字放在 y 轴上,使用 x 轴来选择我们的潜在维度之一的值:

num_cells = 10
overview = PIL.Image.new('RGB',
                         (num_cells * (img_width + 4) + 8,
                          num_cells * (img_height + 4) + 8),
                         (128, 128, 128))
img_it = 0
vec = np.zeros((1, latent_space_depth + y_train.shape[1]))
for x in range(num_cells):
    vec = np.zeros((1, latent_space_depth + y_train.shape[1]))
    vec[:, x + latent_space_depth] = 1
    for y in range(num_cells):
        vec[:, 1] = 3 * y / (num_cells - 1) - 1.5
        decoded = cond_decoder.predict(vec)
        img = decode_img(decoded.reshape(img_width, img_height))
        overview.paste(img, (x * (img_width + 4) + 6,
                             y * (img_height + 4) + 6))
overview

风格和数字

正如您所看到的,潜在空间表达了数字的风格,而这种风格在数字之间是一致的。在这种情况下,它似乎控制了数字倾斜的程度。

讨论

条件变分自动编码器标志着我们穿越各种自动编码器的旅程的最终站。这种类型的网络使我们能够将我们的数字映射到一个稠密的潜在空间,该空间也带有标签,使我们能够在指定图像类型的同时对随机图像进行采样。

向网络提供标签的副作用是,它现在不再需要学习数字,而是可以专注于数字的风格。

第十四章:使用深度网络生成图标

在上一章中,我们看了一下从 Quick Draw 项目生成手绘草图和从 MNIST 数据集生成数字。在本章中,我们将尝试三种类型的网络来完成一个稍微具有挑战性的任务:生成图标。

在我们进行任何生成之前,我们需要获取一组图标。在线搜索“免费图标”会得到很多结果。这些都不是“言论自由”的,大多数在“免费啤酒”方面都有困难。此外,您不能自由重复使用这些图标,通常网站强烈建议您最终付费。因此,我们将从如何下载、提取和处理图标开始,将它们转换为我们可以在本章的其余部分中使用的标准格式。

我们将尝试的第一件事是在我们的图标集上训练一个条件变分自动编码器。我们将使用上一章中得到的网络作为基础,但我们将添加一些卷积层以使其表现更好,因为图标空间比手绘数字的空间复杂得多。

我们将尝试的第二种类型的网络是生成对抗网络。在这里,我们将训练两个网络,一个用于生成图标,另一个用于区分生成的图标和真实的图标。两者之间的竞争会带来更好的结果。

我们将尝试的第三种也是最后一种类型的网络是 RNN。在第五章中,我们使用它来生成特定风格的文本。通过将图标重新解释为一组绘图指令,我们可以使用相同的技术来生成图像。

本章相关的代码可以在以下笔记本中找到:

14.1 Importing Icons
14.2 Icon Autoencoding
14.3 Icon GAN
14.4 Icon RNN

14.1 获取训练图标

问题

如何获取标准格式的大量图标?

解决方案

从 Mac 应用程序Icons8中提取它们。

Icons8 分发了一个庞大的图标集——超过 63,000 个。这部分是因为不同格式的图标被计为两倍,但仍然是一个不错的集合。不幸的是,这些图标分布在 Mac 和 Windows 的应用程序中。好消息是 Mac 的.dmg存档实际上只是一个包含应用程序的 p7zip 存档,而应用程序本身也是一个 p7zip 存档。让我们从下载应用程序开始。转到https://icons8.com/app并确保下载 Mac 版本(即使您在 Linux 或 Windows 上也是如此)。现在为您喜欢的操作系统安装 p7zip 的命令行版本,并将.dmg文件的内容提取到自己的文件夹中:

7z x Icons8App_for_Mac_OS.dmg

.dmg包含一些元信息和 Mac 应用程序。让我们也解压缩应用程序:

cd Icons8\ v5.6.3
7z x Icons8.app

就像洋葱一样,这个东西有很多层。现在您应该看到一个也需要解压缩的.tar文件:

tar xvf icons.tar

这给我们一个名为icons的目录,其中包含一个.ldb文件,这表明该目录代表一个 LevelDB 数据库。切换到 Python,我们可以查看其中的内容:

# Adjust to your local path:
path = '/some/path/Downloads/Icons8 v5.6.3/icons'
db = plyvel.DB(path)

for key, value in db:
    print(key)
    print(value[:400])
    break
> b'icon_1'
b'TSAF\x03\x00\x02\x00\x07\x00\x00\x00\x00\x00\x00\x00$\x00\x00\x00
\x18\x00\x00\x00\r\x00\x00\x00-\x08id\x00\x08Messaging\x00\x08categ
ory\x00\x19\x00\x03\x00\x00\x00\x08Business\x00\x05\x01\x08User
Interface\x00\x08categories\x00\x18\x00\x00\x00\x03\x00\x00\x00\x08
Basic Elements\x00\x05\x04\x01\x05\x01\x08Business
Communication\x00\x05\x03\x08subcategories\x00\x19\x00\r\x00\x00\x00
\x08contacts\x00\x08phone book\x00\x08contacts
book\x00\x08directory\x00\x08mail\x00\x08profile\x00\x08online\x00
\x08email\x00\x08records\x00\x08alphabetical\x00\x08sim\x00\x08phone
numbers\x00\x08categorization\x00\x08tags\x00\x0f9\x08popularity\x00
\x18\x00\x00\x02\x00\x00\x00\x1c\x00\x00\x00\xe8\x0f\x00\x00<?xml
version="1.0" encoding="utf-8"?>\n<!-- Generato'

太棒了。我们已经找到了我们的图标,它们似乎是使用.svg矢量格式编码的。看起来它们包含在另一种格式中,带有TSAF头。在线阅读,似乎是一些与 IBM 相关的格式,但是很难找到一个 Python 库来从中提取数据。再说一遍,这个简单的转储表明我们正在处理由\x00分隔的键/值对,键和值由\x08分隔。它并不完全奏效,但足以构建一个 hacky 解析器:

splitter = re.compile(b'[\x00-\x09]')

def parse_value(value):
    res = {}
    prev = ''
    for elem in splitter.split(value):
        if not elem:
            continue
        try:
            elem = elem.decode('utf8')
        except UnicodeDecodeError:
            continue
        if elem in ('category', 'name', 'platform',
                    'canonical_name', 'svg', 'svg.simplified'):
            res[elem] = prev
        prev = elem
    return res

这会提取 SVG 文件和一些可能在以后有用的基本属性。各个平台包含更多或更少相同的图标,因此我们需要选择一个平台。iOS 似乎拥有最多的图标,所以让我们选择它:

icons = {}
for _, value in db:
    res = parse_value(value)
    if res.get('platform') == 'ios':
        name = res.get('name')
        if not name:
            name = res.get('canonical_name')
            if not name:
                continue
            name = name.lower().replace(' ', '_')
        icons[name] = res

现在让我们将所有内容写入磁盘以供以后处理。我们将保留 SVG 文件,同时也将位图写出为 PNG 文件:

saved = []
for icon in icons.values():
    icon = dict(icon)
    if not 'svg' in icon:
        continue
    svg = icon.pop('svg')
    try:
        drawing = svg2rlg(BytesIO(svg.encode('utf8')))
    except ValueError:
        continue
    except AttributeError:
        continue
    open('icons/svg/%s.svg' % icon['name'], 'w').write(svg)
    p = renderPM.drawToPIL(drawing)
    for size in SIZES:
        resized = p.resize((size, size), Image.ANTIALIAS)
        resized.save('icons/png%s/%s.png' % (size, icon['name']))
    saved.append(icon)
json.dump(saved, open('icons/index.json', 'w'), indent=2)

讨论

尽管有许多在线广告免费图标的网站,但实际上获得一个好的训练集是相当复杂的。在这种情况下,我们在一个神秘的TSAF商店内找到了 SVG 格式的图标,这些图标存储在一个 LevelDB 数据库内的 Mac 应用程序内,而这个 Mac 应用程序存储在我们下载的.dmg文件中。一方面,这似乎比应该的要复杂。另一方面,这表明通过一点侦探工作,我们可以发现一些非常有趣的数据集。

14.2 将图标转换为张量表示

问题

如何将保存的图标转换为适合训练网络的格式?

解决方案

连接它们并对它们进行归一化。

这与我们为预训练网络处理图像的方式类似,只是现在我们将训练自己的网络。我们知道所有图像都将是 32×32 像素,我们将跟踪均值和标准差,以便正确地对图像进行归一化和反归一化。我们还将数据分成训练集和测试集:

def load_icons(train_size=0.85):
    icon_index = json.load(open('icons/index.json'))
    x = []
    img_rows, img_cols = 32, 32
    for icon in icon_index:
        if icon['name'].endswith('_filled'):
            continue
        img_path = 'icons/png32/%s.png' % icon['name']
        img = load_img(img_path, grayscale=True,
                       target_size=(img_rows, img_cols))
        img = img_to_array(img)
        x.append(img)
    x = np.asarray(x) / 255
    x_train, x_val = train_test_split(x, train_size=train_size)
    return x_train, x_val

讨论

处理过程相当标准。我们读取图像,将它们全部附加到一个数组中,对数组进行归一化,然后将结果集拆分为训练集和测试集。我们通过将灰度像素除以 255 来进行归一化。我们稍后将使用的激活函数是 sigmoid,它只会产生正数,因此不需要减去均值。

14.3 使用变分自动编码器生成图标

问题

您想以某种风格生成图标。

解决方案

在第十三章的 MNIST 解决方案中添加卷积层。

我们用于生成数字的变分自动编码器只有两个维度的潜在空间。我们可以使用这样一个小空间,因为手写数字之间的变化并不那么大。从本质上讲,只有 10 种看起来相似的不同数字。此外,我们使用全连接层来进入和离开潜在空间。我们的图标更加多样化,因此我们将使用一些卷积层来减小图像的大小,然后应用一个全连接层,最终得到我们的潜在状态:

input_img = Input(shape=(32, 32, 1))
channels = 4
x = input_img
for i in range(5):
    left = Conv2D(channels, (3, 3),
                  activation='relu', padding='same')(x)
    right = Conv2D(channels, (2, 2),
                  activation='relu', padding='same')(x)
    conc = Concatenate()([left, right])
    x = MaxPooling2D((2, 2), padding='same')(conc)
    channels *= 2

 x = Dense(channels)(x)
 encoder_hidden = Flatten()(x)

我们像以前一样处理损失函数和分布。KL_loss的权重很重要。如果设置得太低,结果空间将不会很密集。如果设置得太高,网络将很快学会预测空位图会得到一个体面的reconstruction_loss和一个很好的KL_loss

z_mean = Dense(latent_space_depth,
               activation='linear')(encoder_hidden)
z_log_var = Dense(latent_space_depth,
                  activation='linear')(encoder_hidden)

def KL_loss(y_true, y_pred):
    return (0.001 * K.sum(K.exp(z_log_var)
            + K.square(z_mean) - 1 - z_log_var, axis=1))

def reconstruction_loss(y_true, y_pred):
    y_true = K.batch_flatten(y_true)
    y_pred = K.batch_flatten(y_pred)
    return binary_crossentropy(y_true, y_pred)

    def total_loss(y_true, y_pred):
        return (reconstruction_loss(y_true, y_pred)
                + KL_loss(y_true, y_pred))

现在我们将潜在状态放大回图标。与以前一样,我们为编码器和自动编码器并行执行此操作:

z = Lambda(sample_z,
           output_shape=(latent_space_depth, ))([z_mean, z_log_var])
decoder_in = Input(shape=(latent_space_depth,))

d_x = Reshape((1, 1, latent_space_depth))(decoder_in)
e_x = Reshape((1, 1, latent_space_depth))(z)
for i in range(5):
    conv = Conv2D(channels, (3, 3), activation='relu', padding='same')
    upsampling = UpSampling2D((2, 2))
    d_x = conv(d_x)
    d_x = upsampling(d_x)
    e_x = conv(e_x)
    e_x = upsampling(e_x)
    channels //= 2

final_conv = Conv2D(1, (3, 3), activation='sigmoid', padding='same')
auto_decoded = final_conv(e_x)
decoder_out = final_conv(d_x)

为了训练网络,我们需要确保训练集和测试集的大小可以被batch_size整除,否则KL_loss函数将失败:

def truncate_to_batch(x):
    l = x.shape[0]
    return x[:l - l % batch_size, :, :, :]

x_train_trunc = truncate_to_batch(x_train)
x_test_trunc = truncate_to_batch(x_test)
x_train_trunc.shape, x_test_trunc.shape

我们可以像以前一样从空间中抽样一些随机图标:

自动编码器生成的图标图像

正如您所看到的,网络确实学到了一些关于图标的东西。它们往往有一种填充在某种程度上的盒子,通常不会触及 32×32 容器的外部。但仍然相当模糊!

讨论

为了将我们在上一章中开发的变分自动编码器应用于图标更异构的空间,我们需要使用卷积层逐步减少位图的维度并增加抽象级别,直到我们进入潜在空间。这与图像识别网络的功能非常相似。一旦我们的图标投影到一个 128 维空间中,我们就可以为生成器和自动编码器使用上采样层。

结果比一次扣篮更有趣。问题的一部分是,图标,就像上一章中的猫一样,包含许多线条绘图,这使得网络很难完全正确地识别它们。当存在疑问时,网络会选择模糊的线条。更糟糕的是,图标通常包含像棋盘一样抖动的区域。这些模式肯定是可以学习的,但一个像素错误就会导致整个答案完全错误!

我们的网络性能相对较差的另一个原因是我们的图标相对较少。下一个配方展示了一个绕过这个问题的技巧。

14.4 使用数据增强来提高自动编码器的性能

问题

如何在不增加更多数据的情况下提高网络的性能?

解决方案

使用数据增强。

在上一个配方中,我们的自动编码器学习了图标集的模糊轮廓,但仅限于此。结果表明它正在捕捉某些东西,但不足以做出出色的工作。增加更多数据可能有所帮助,但这将要求我们找到更多图标,并且这些图标必须与我们的原始集合足够相似才能帮助。相反,我们将生成更多数据。

数据增强的背后思想,如第一章中讨论的,是生成输入数据的变化,这些变化对网络不重要。在这种情况下,我们希望通过输入图标让我们的网络学习图标特性的概念。但如果我们翻转或旋转我们的图标,这会使它们不那么图标化吗?实际上并不是。这样做将使我们的输入增加 16 倍。我们的网络将从这些新的训练示例中学习,旋转和翻转并不重要,希望能够表现更好。增强会是这样的:

def augment(icons):
    aug_icons = []
    for icon in icons:
        for flip in range(4):
            for rotation in range(4):
                aug_icons.append(icon)
                icon = np.rot90(icon)
            icon = np.fliplr(icon)
    return np.asarray(aug_icons)

让我们将这应用到我们的训练和测试数据中:

x_train_aug = augment(x_train)
x_test_aug = augment(x_test)

现在训练网络显然需要更长一点时间。但结果也更好:

数据增强后的自动编码器图标

讨论

数据增强是在计算机图像方面广泛使用的技术。旋转和翻转是这样做的一种明显方式,但考虑到我们实际上是从图标的.svg表示开始的,我们还可以做很多其他事情。SVG 是一种矢量格式,因此我们可以轻松地创建具有轻微旋转或放大的图标,而不会出现我们如果基线数据仅包含位图时会出现的那种伪影。

我们得到的图标空间比上一个配方的要好,似乎捕捉到了某种图标特性。

14.5 构建生成对抗网络

问题

您想构建一个可以生成图像的网络,另一个可以学习区分生成图像和原始图像的网络。

解决方案

创建一个可以一起工作的图像生成器和图像鉴别器。

生成对抗网络背后的关键见解是,如果你有两个网络,一个生成图像,一个评判生成的图像,并且同时训练它们,它们在学习过程中会互相刺激。让我们从一个生成器网络开始。这与自动编码器的解码器部分类似:

inp = Input(shape=(latent_size,))
x = Reshape((1, 1, latent_size))(inp)

channels = latent_size
padding = 'valid'
strides = 1
for i in range(4):
    x = Conv2DTranspose(channels, kernel_size=4,
                        strides=strides, padding=padding)(x)
    x = BatchNormalization()(x)
    x = LeakyReLU(.2)(x)

    channels //= 2
    padding = 'same'
    strides = 2

x = Conv2DTranspose(1, kernel_size=4, strides=1, padding='same')(x)
image_out = Activation('tanh')(x)

model = Model(inputs=inp, outputs=image_out)

另一个网络,鉴别器,将接收一幅图像并输出它认为是生成的还是原始图像之一。在这个意义上,它看起来像一个具有二进制输出的经典卷积网络:

inp = Input(shape=(32, 32, 1))
x = inp

channels = 16

for i in range(4):
    layers = []
    conv = Conv2D(channels, 3, strides=2, padding='same')(x)
    if i:
        conv = BatchNormalization()(conv)
    conv = LeakyReLU(.2)(conv)
    layers.append(conv)
    bv = Lambda(lambda x: K.mean(K.abs(x[:] - K.mean(x, axis=0)),
                          axis=-1,
                          keepdims=True))(conv)
    layers.append(bv)
    channels *= 2
    x = Concatenate()(layers)

x = Conv2D(128, 2, padding='valid')(x)
x = Flatten(name='flatten')(x)

fake = Dense(1, activation='sigmoid', name='generation')(x)

m = Model(inputs=inp, outputs=fake)

在下一个配方中,我们将看看如何训练这两个网络。

讨论

生成对抗网络或 GAN 是一种相对较新的用于生成图像的创新。看待它们的一种方式是将两个组件网络,生成器和鉴别器,视为一起学习,通过竞争变得更好。

另一种看待它们的方式是将鉴别器视为生成器的动态损失函数。当网络学习区分猫和狗时,直接的损失函数效果很好;某物是猫,或者不是,我们可以使用答案与真相之间的差异作为损失函数。

在生成图像时,这更加棘手。如何比较两幅图像?在本章早些时候,当我们使用自动编码器生成图像时,我们遇到了这个问题。在那里,我们只是逐像素比较图像;当查看两幅图像是否相同时,这种方法有效,但对于相似性来说效果不佳。两个完全相同但偏移一个像素的图标不一定会有许多像素处于相同位置。因此,自动编码器通常选择生成模糊的图像。

让第二个网络进行评判允许整个系统发展出更流畅的图像相似性感知。此外,随着图像变得更好,它可以变得更加严格,而使用自动编码器,如果我们一开始过于强调密集空间,网络将永远无法学习。

14.6 训练生成对抗网络

问题

如何训练 GAN 的两个组件?

解决方案

回退到底层 TensorFlow 框架来同时运行两个网络。

通常,当涉及与底层 TensorFlow 框架通信时,我们只让 Keras 承担繁重的工作。但是,我们直接使用 Keras 所能做的最好的事情是在训练生成器和鉴别器网络之间交替,这是次优的。秦永亮写了一篇博客文章描述了如何解决这个问题。

我们将从生成一些噪音开始,并将其输入生成器以获得生成的图像,然后将真实图像和生成的图像输入鉴别器:

noise = Input(shape=g.input_shape[1:])
real_data = Input(shape=d.input_shape[1:])

generated = g(noise)
gscore = d(generated)
rscore = d(real_data)

现在我们可以构建两个损失函数。生成器的得分取决于鉴别器认为图像是真实的可能性。鉴别器的得分取决于它在假和真实图像上的表现:

dloss = (- K.mean(K.log((1 - gscore) + .1 * K.log((1 - rscore)
         + .9 * K.log((rscore)))
gloss = - K.mean(K.log((gscore))

现在我们将计算梯度,以优化这两个损失函数对两个网络的可训练权重:

optimizer = tf.train.AdamOptimizer(1e-4, beta1=0.2)
grad_loss_wd = optimizer.compute_gradients(dloss, d.trainable_weights)
update_wd = optimizer.apply_gradients(grad_loss_wd)
grad_loss_wg = optimizer.compute_gradients(gloss, g.trainable_weights)
update_wg = optimizer.apply_gradients(grad_loss_wg)

我们收集各种步骤和张量:

other_parameter_updates = [get_internal_updates(m) for m in [d, g]]
train_step = [update_wd, update_wg, other_parameter_updates]
losses = [dloss, gloss]
learning_phase = K.learning_phase()

现在我们准备设置训练器。Keras 需要设置 learning_phase

 def gan_feed(sess,batch_image, z_input):
       feed_dict = {
           noise: z_input,
           real_data: batch_image,
           learning_phase: True,
       }
       loss_values, = sess.run([losses], feed_dict=feed_dict)

我们可以通过生成自己的批次提供的变量:

sess = K.get_session()
l = x_train.shape[0]
l -= l % BATCH_SIZE
for i in range(epochs):
    np.random.shuffle(x_train)
    for batch_start in range(0, l, BATCH_SIZE):
        batch = x_train[batch_start: batch_start + BATCH_SIZE]
        z_input = np.random.normal(loc=0.,
                                   scale=1.,
                                   size=(BATCH_SIZE, LATENT_SIZE))
        losses = gan_feed(sess, batch, z_input)

讨论

同时更新两个网络的权重使我们降到了 TensorFlow 本身的级别。虽然这有点复杂,但也很好地了解底层系统,而不总是依赖 Keras 提供的“魔法”。

注意

网络上有许多实现采用简单的方式,只是逐步运行两个网络,但不是同时。

14.7 展示 GAN 生成的图标

问题

在学习过程中如何展示 GAN 的进展?

解决方案

在每个时代后添加一个图标渲染器。

由于我们正在运行自己的批处理,我们可以利用这一点,并在每个时代结束时使用中间结果更新笔记本。让我们从使用生成器渲染一组图标开始:

def generate_images(count):
    noise = np.random.normal(loc=0.,
                             scale=1.,
                             size=(count, LATENT_SIZE))
    for tile in gm.predict([noise]).reshape((count, 32, 32)):
        tile = (tile * 300).clip(0, 255).astype('uint8')
        yield PIL.Image.fromarray(tile)

接下来,让我们将它们放在海报概览上:

def poster(w_count, h_count):
    overview = PIL.Image.new('RGB',
                             (w_count * 34 + 2, h_count * 34 + 2),
                             (128, 128, 128))
    for idx, img in enumerate(generate_images(w_count * h_count)):
        x = idx % w_count
        y = idx // w_count
        overview.paste(img, (x * 34 + 2, y * 34 + 2))
    return overview

现在我们可以将以下代码添加到我们的时代循环中:

        clear_output(wait=True)
        f = BytesIO()
        poster(8, 5).save(f, 'png')
        display(Image(data=f.getvalue()))

经过一个时代后,一些模糊的图标已经开始出现:

第一个 GAN 生成的图像

再经过 25 个时代,我们真的开始看到一些图标的出现:

经过 25 个时代后

讨论

使用 GAN 生成图标的最终结果比我们从自动编码器中得到的要好。大多数绘图更加清晰,这可以归因于鉴别器网络决定一个图标是否好,而不是逐像素比较图标。

注意

GAN 及其衍生应用已经爆炸性增长,范围从从图片重建 3D 模型到为旧图片上色和超分辨率,网络可以增加小图像的分辨率而不使其看起来模糊或块状。

14.8 将图标编码为绘图指令

问题

您想将图标转换为适合训练 RNN 的格式。

解决方案

将图标编码为绘图指令。

RNN 可以学习序列,就像我们在第五章中看到的那样。但是如果我们想使用 RNN 生成图标怎么办?我们可以简单地将每个图标编码为像素序列。一种方法是将图标视为一系列已“打开”的像素。有 32 * 32 = 1,024 个不同的像素,因此这将是我们的词汇表。这样做是有效的,但是通过使用实际的绘图指令,我们可以做得更好。

如果我们将图标视为一系列扫描线,我们只需要 32 个不同的令牌来表示扫描线中的像素。添加一个令牌以移动到下一个扫描线,再添加一个最终令牌来标记图标的结束,我们就有了一个很好的顺序表示。或者,在代码中:

def encode_icon(img, icon_size):
    size_last_x = 0
    encoded = []
    for y in range(icon_size):
        for x in range(icon_size):
            p = img.getpixel((x, y))
            if img.getpixel((x, y)) < 192:
                encoded.append(x)
                size_last_x = len(encoded)
        encoded.append(icon_size)
    return encoded[:size_last_x]

然后,我们可以通过遍历像素来解码图像:

def decode_icon(encoded, icon_size):
    y = 0
    for idx in encoded:
        if idx == icon_size:
            y += 1
        elif idx == icon_size + 1:
            break
        else:
            x = idx
            yield x, y

    icon = PIL.Image.new('L', (32, 32), 'white')
    for x, y in decode_icon(sofar, 32):
        if y < 32:
            icon.putpixel((x, y), 0)

讨论

将图标编码为一组绘图指令只是预处理数据的另一种方式,使网络更容易学习我们想要学习的内容,类似于我们在第一章中看到的其他方法。通过具体的绘图指令,我们确保网络不会学习绘制模糊线条,就像我们的自动编码器容易做的那样——它将无法做到。

14.9 训练 RNN 绘制图标

问题

您想训练一个 RNN 来生成图标。

解决方案

基于绘图指令训练网络。

现在我们可以将单个图标编码为绘图指令,下一步是编码整个集合。由于我们将向 RNN 馈送块,并要求它预测下一个指令,因此我们实际上构建了一个大“文档”:

def make_array(icons):
    res = []
    for icon in icons:
        res.extend(icon)
        res.append(33)
    return np.asarray(res)

def load_icons(train_size=0.90):
    icon_index = json.load(open('icons/index.json'))
    x = []
    img_rows, img_cols = 32, 32
    for icon in icon_index:
        if icon['name'].endswith('_filled'):
            continue
        img_path = 'icons/png32/%s.png' % icon['name']
        x.append(encode_icon(PIL.Image.open(img_path), 32))
    x_train, x_val = train_test_split(x, train_size=train_size)
    x_train = make_array(x_train)
    x_val = make_array(x_val)
    return x_train, x_val

x_train, x_test = load_icons()

我们将使用帮助我们生成莎士比亚文本的相同模型运行:

def icon_rnn_model(num_chars, num_layers, num_nodes=512, dropout=0.1):
    input = Input(shape=(None, num_chars), name='input')
    prev = input
    for i in range(num_layers):
        lstm = LSTM(num_nodes, return_sequences=True,
                    name='lstm_layer_%d' % (i + 1))(prev)
        if dropout:
            prev = Dropout(dropout)(lstm)
        else:
            prev = lstm
    dense = TimeDistributed(Dense(num_chars,
                                  name='dense',
                                  activation='softmax'))(prev)
    model = Model(inputs=[input], outputs=[dense])
    optimizer = RMSprop(lr=0.01)
    model.compile(loss='categorical_crossentropy',
                  optimizer=optimizer,
                  metrics=['accuracy'])
    return model

model = icon_rnn_model(34, num_layers=2, num_nodes=256, dropout=0)

讨论

要更详细地了解我们在这里使用的网络是如何训练的以及数据是如何生成的,最好回顾一下第五章。

您可以尝试不同层数和节点数,或尝试不同的 dropout 值。不同的 RNN 层也会产生影响。该模型有点脆弱;很容易陷入不学习任何内容的状态,或者在学习时陷入局部最大值。

14.10 使用 RNN 生成图标

问题

您已经训练了网络;现在如何让它生成图标?

解决方案

将一些测试集的随机位馈送到网络中,并将预测解释为绘图指令。

这里的基本方法与我们生成莎士比亚文本或 Python 代码时相同;唯一的区别是我们需要将预测输入到图标解码器中以获取图标。让我们首先运行一些预测:

def generate_icons(model, num=2, diversity=1.0):
    start_index = random.randint(0, len(x_test) - CHUNK_SIZE - 1)
    generated = x_test[start_index: start_index + CHUNK_SIZE]
    while num > 0:
        x = np.zeros((1, len(generated), 34))
        for t, char in enumerate(generated):
            x[0, t, char] = 1.
        preds = model.predict(x, verbose=0)[0]
        preds = np.asarray(preds[len(generated) - 1]).astype('float64')
        exp_preds = np.exp(np.log(preds) / diversity)

diversity参数控制预测与确定性之间的距离(如果diversity0,模型将将其转换为确定性)。我们需要这个来生成多样化的图标,但也要避免陷入循环。

我们将每个预测收集到一个变量so_far中,每次遇到值33(图标结束)时我们都会清空它。我们还检查y值是否在范围内——模型更多或更少地学习了图标的大小,但有时会尝试在线条外部着色:

            if next_index == 33:
                icon = PIL.Image.new('L', (32, 32), 'white')
                for x, y in decode_icon(sofar, 32):
                    if y < 32:
                        icon.putpixel((x, y), 0)
                yield icon
                num -= 1
            else:
                sofar.append(next_index)

有了这个,我们现在可以绘制一个图标“海报”:

cols = 10
rows = 10
overview = PIL.Image.new('RGB',
                         (cols * 36 + 4, rows * 36 + 4),
                         (128, 128, 128))
for idx, icon in enumerate(generate_icons(model, num=cols * rows)):
    x = idx % cols
    y = idx // cols
    overview.paste(icon, (x * 36 + 4, y * 36 + 4))
overview

RNN 生成的图标

讨论

使用 RNN 生成的图标是我们在本章中进行的三次尝试中最大胆的,可以说最好地捕捉了图标的本质。该模型学习了图标中的对称性和基本形状,甚至偶尔进行抖动以获得半色调的概念。

我们可以尝试结合本章中的不同方法。例如,我们可以有一个 RNN,它不再尝试预测下一个绘图指令,而是接受绘图指令,捕捉该点的潜在状态,然后有一个基于该状态的第二个 RNN 重构绘图指令。这样我们就会有一个基于 RNN 的自动编码器。在文本世界中,在这个领域已经取得了一些成功。

RNNs 也可以与 GANs 结合。我们不再使用一个生成器网络,它接受一个潜变量并将其放大成一个图标,而是使用 RNN 生成绘图指令,然后让鉴别器网络决定这些是真实的还是假的。

第十五章:音乐和深度学习

这本书中的其他章节都是关于图像或文本的处理。这些章节代表了深度学习研究中媒体的平衡,但这并不意味着声音处理不是有趣的,我们在过去几年中也看到了一些重大进展。语音识别和语音合成使得像亚马逊 Alexa 和谷歌 Home 这样的家庭助手成为可能。自从 Siri 推出以来,那个老的情景喜剧笑话中电话拨错号码的情节并不是很现实。

开始尝试这些系统很容易;有一些 API 可以让你在几个小时内建立一个简单的语音应用程序。然而,语音处理是在亚马逊、谷歌或苹果的数据中心进行的,所以我们不能真的将这些视为深度学习实验。构建最先进的语音识别系统很困难,尽管 Mozilla 的 Deep Speech 正在取得一些令人印象深刻的进展。

这一章重点是音乐。我们将首先训练一个音乐分类模型,可以告诉我们正在听什么音乐。然后我们将使用这个模型的结果来索引本地 MP3,使得可以找到风格相似的歌曲。之后我们将使用 Spotify API 创建一个公共播放列表的语料库,用来训练音乐推荐系统。

本章的笔记本有:

15.1 Song Classification
15.2 Index Local MP3s
15.3 Spotify Playlists
15.4 Train a Music Recommender

15.1 为音乐分类创建训练集

问题

如何获取和准备一组音乐用于分类?

解决方案

从加拿大维多利亚大学提供的测试集中创建频谱图。

你可以尝试通过连接那个带有 MP3 收藏的尘封外部驱动器,并依赖那些歌曲的标签来做这件事。但很多标签可能有些随机或缺失,所以最好从一个科学机构获得一个有标签的训练集开始:

wget http://opihi.cs.uvic.ca/sound/genres.tar.gz
tar xzf genres.tar.gz

这将为我们创建一个包含不同流派音乐的子目录genres

>ls ~/genres
blues  classical  country  disco  hiphop  jazz  metal  pop  reggae  rock

这些目录包含声音文件(.au),每种流派 100 个片段,每个片段长 29 秒。我们可以尝试直接将原始声音帧馈送到网络中,也许 LSTM 会捕捉到一些东西,但有更好的声音预处理方法。声音实际上是声波,但我们听不到波。相反,我们听到一定频率的音调。

因此,让我们的网络更像我们的听觉工作的一个好方法是将声音转换为频谱图块;每个样本将由一系列音频频率及其相应的强度表示。Python 的librosa库有一些标准函数可以做到这一点,并且还提供了所谓的melspectrogram,一种旨在紧密模拟人类听觉工作方式的频谱图。所以让我们加载音乐并将片段转换为 melspectrograms:

def load_songs(song_folder):
    song_specs = []
    idx_to_genre = []
    genre_to_idx = {}
    genres = []
    for genre in os.listdir(song_folder):
        genre_to_idx[genre] = len(genre_to_idx)
        idx_to_genre.append(genre)
        genre_folder = os.path.join(song_folder, genre)
        for song in os.listdir(genre_folder):
            if song.endswith('.au'):
                signal, sr = librosa.load(
                    os.path.join(genre_folder, song))
                melspec = librosa.feature.melspectrogram(
                    signal, sr=sr).T[:1280,]
                song_specs.append(melspec)
                genres.append(genre_to_idx[genre])
    return song_specs, genres, genre_to_idx, idx_to_genre

让我们也快速看一下一些流派的频谱图。由于这些频谱图现在只是矩阵,我们可以将它们视为位图。它们实际上非常稀疏,所以我们将过度曝光它们以查看更多细节:

def show_spectogram(show_genre):
    show_genre = genre_to_idx[show_genre]
    specs = []
    for spec, genre in zip(song_specs, genres):
        if show_genre == genre:
            specs.append(spec)
            if len(specs) == 25:
                break
    if not specs:
        return 'not found!'
    x = np.concatenate(specs, axis=1)
    x = (x - x.min()) / (x.max() - x.min())
    plt.imshow((x *20).clip(0, 1.0))

show_spectogram('classical')

古典音乐频谱图

show_spectogram('metal')

金属音乐频谱图

尽管很难说图片到底代表什么意思,但有一些迹象表明金属音乐可能比古典音乐更具有刚性结构,这也许并不完全出乎意料。

讨论

正如我们在整本书中看到的那样,在让网络处理数据之前对数据进行预处理会显著增加我们成功的机会。在声音处理方面,librosa有几乎任何你想要的功能,从加载声音文件并在笔记本中播放它们到可视化它们和进行任何类型的预处理。

通过视觉检查频谱图并不能告诉我们太多,但它确实给了我们一个暗示,即不同音乐流派的频谱图是不同的。在下一个步骤中,我们将看到网络是否也能学会区分它们。

15.2 训练音乐流派检测器

问题

如何设置和训练一个深度网络来检测音乐流派?

解决方案

使用一维卷积网络。

在本书中,我们已经使用卷积网络进行图像检测(参见第九章)和文本(参见第七章)。处理我们的频谱图像为图像可能是更合乎逻辑的方法,但实际上我们将使用一维卷积网络。我们的频谱图中的每个帧代表音乐的一个帧。当我们尝试对流派进行分类时,使用卷积网络将时间段转换为更抽象的表示是有意义的;减少帧的“高度”在直觉上不太合理。

我们将从顶部堆叠一些层。这将把我们的输入从 128 维减少到 25。GlobalMaxPooling层将把这个转换成一个 128 浮点向量:

inputs = Input(input_shape)
x = inputs
for layers in range(3):
x = Conv1D(128, 3, activation='relu')(x)
x = BatchNormalization()(x)
x = MaxPooling1D(pool_size=6, strides=2)(x)
x = GlobalMaxPooling1D()(x)

接着是一些全连接层,以获得标签:

for fc in range(2):
x = Dense(256, activation='relu')(x)
    x = Dropout(0.5)(x)

    outputs = Dense(10, activation='softmax')(x)

在将数据输入模型之前,我们将每首歌曲分成 10 个每个 3 秒的片段。我们这样做是为了增加数据量,因为 1000 首歌曲并不算太多:

def split_10(x, y):
    s = x.shape
    s = (s[0] * 10, s[1] // 10, s[2])
    return x.reshape(s), np.repeat(y, 10, axis=0)

genres_one_hot = keras.utils.to_categorical(
    genres, num_classes=len(genre_to_idx))

x_train, x_test, y_train, y_test = train_test_split(
    np.array(song_specs), np.array(genres_one_hot),
    test_size=0.1, stratify=genres)

x_test, y_test = split_10(x_test, y_test)
x_train, y_train = split_10(x_train, y_train)

在 100 个 epochs 后,训练这个模型可以达到大约 60%的准确率,这并不差,但肯定不是超人的水平。我们可以通过利用将每首歌曲分成 10 个片段并使用跨片段的信息来改进结果。多数投票可能是一种策略,但事实证明,选择模型最确定的片段效果更好。我们可以通过将数据重新分成 100 个片段,并在每个片段上应用argmax来实现这一点。这将为每个片段得到整个片段中的索引。通过应用模 10,我们可以得到我们标签集中的索引:

def unsplit(values):
    chunks = np.split(values, 100)
    return np.array([np.argmax(chunk) % 10 for chunk in chunks])

predictions = unsplit(model.predict(x_test))
truth = unsplit(y_test)
accuracy_score(predictions, truth)

这让我们达到了 75%的准确率。

讨论

对于我们的 10 种流派,每种有 100 首歌曲,我们没有太多的训练数据。将我们的歌曲分成 10 个每个 3 秒的块,可以让我们达到一定程度的准确性,尽管我们的模型仍然有点过拟合。

探索的一个方向是应用一些数据增强技术。我们可以尝试给音乐添加噪音,稍微加快或减慢速度,尽管频谱图本身可能并不会有太大变化。最好是能获取更大量的音乐数据。

15.3 可视化混淆

问题

如何清晰地展示网络所犯的错误?

解决方案

以图形方式显示混淆矩阵。

混淆矩阵的列代表真实的流派,行代表模型预测的流派。单元格包含每个(真实,预测)对的计数。sklearn带有一个方便的方法来计算它:

cm = confusion_matrix(pred_values, np.argmax(y_test, axis=1))
print(cm)
[[65 13  0  6  5  1  4  5  2  1]
 [13 54  1  3  4  0 20  1  0  9]
 [ 5  2 99  0  0  0 12 33  0  2]
 [ 0  0  0 74 29  1  8  0 18 10]
 [ 0  0  0  2 55  0  0  1  2  0]
 [ 1  0  0  1  0 95  0  0  0  6]
 [ 8 17  0  2  5  2 45  0  1  4]
 [ 4  4  0  1  2  0 10 60  1  4]
 [ 0  1  0  1  0  1  0  0 64  5]
 [ 4  9  0 10  0  0  1  0 12 59]]

我们可以通过给矩阵着色来更清晰地可视化。将矩阵转置,这样我们可以看到每行的混淆,也让处理变得更容易:

plt.imshow(cm.T, interpolation='nearest', cmap='gray')
plt.xticks(np.arange(0, len(idx_to_genre)), idx_to_genre)
plt.yticks(np.arange(0, len(idx_to_genre)), idx_to_genre)

plt.show()

混淆矩阵

讨论

混淆矩阵是展示网络性能的一种巧妙方式,但它也让你了解它出错的地方,这可能暗示着如何改进事情。在这个示例中,我们可以看到网络在区分古典音乐和金属音乐与其他类型音乐时表现得非常好,但在区分摇滚和乡村音乐时表现得不太好。当然,这一切都是意料之中的。

15.4 索引现有音乐

问题

您想建立一个能捕捉音乐风格的音乐片段索引。

解决方案

将模型的最后一个全连接层视为嵌入层。

在第十章中,我们通过将图像识别网络的最后一个全连接层解释为图像嵌入,构建了一个图像的反向搜索引擎。我们可以用音乐做类似的事情。让我们开始收集一些 MP3 文件——你可能有一些散落在某处的收藏:

MUSIC_ROOT = _</path/to/music>_
mp3s = []
for root, subdirs, files in os.walk(MUSIC_ROOT):
    for fn in files:
        if fn.endswith('.mp3'):
            mp3s.append(os.path.join(root, fn))

然后我们将对它们进行索引。与以前一样,我们提取一个梅尔频谱图。我们还获取 MP3 标签:

def process_mp3(path):
    tag = TinyTag.get(path)
    signal, sr = librosa.load(path,
                              res_type='kaiser_fast',
                              offset=30,
                              duration=30)
    melspec = librosa.feature.melspectrogram(signal, sr=sr).T[:1280,]
        if len(melspec) != 1280:
            return None
    return {'path': path,
            'melspecs': np.asarray(np.split(melspec, 10)),
            'tag': tag}

songs = [process_mp3(path) for path in tqdm(mp3s)]
songs = [song for song in songs if song]

我们想要索引所有 MP3 的每个频谱图像-如果我们将它们全部连接在一起,我们可以一次完成:

inputs = []
for song in songs:
    inputs.extend(song['melspecs'])
inputs = np.array(inputs)

为了获得向量表示,我们将构建一个模型,该模型从我们先前模型的倒数第四层返回,并在收集的频谱上运行:

cnn_model = load_model('zoo/15/song_classify.h5')
vectorize_model = Model(inputs=cnn_model.input,
                        outputs=cnn_model.layers[-4].output)
vectors = vectorize_model.predict(inputs)

一个简单的最近邻模型让我们现在可以找到相似的歌曲。给定一首歌曲,我们将查找它的每个向量的其他最近向量是什么。我们可以跳过第一个结果,因为它是向量本身:

nbrs = NearestNeighbors(n_neighbors=10, algorithm='ball_tree').fit(vectors)
def most_similar_songs(song_idx):
    distances, indices = nbrs.kneighbors(
        vectors[song_idx * 10: song_idx * 10 + 10])
    c = Counter()
    for row in indices:
        for idx in row[1:]:
            c[idx // 10] += 1
    return c.most_common()

在随机歌曲上尝试这个似乎有效:

song_idx = 7
print(songs[song_idx]['path'])

print('---')
for idx, score in most_similar_songs(song_idx)[:5]:
    print(songs[idx]['path'], score)
print('')
00 shocking blue - Venus (yes the.mp3
---
00 shocking blue - Venus (yes the.mp3 20
The Shocking Blue/Have A Nice Day_ Vol 1/00 Venus.mp3 12
The Byrds/00 Eve of Destruction.mp3 12
Goldfinger _ Weezer _ NoFx _ L/00 AWESOME.mp3 6

使用我们模型的最后一个全连接层对歌曲进行索引工作得相当不错。在这个例子中,它不仅找到了原始歌曲,还找到了一个略有不同版本的歌曲,恰好在 MP3 收藏中。其他两首返回的歌曲是否在风格上真的相似是一个判断问题,但它们并不完全不同。

这里的代码可以用作构建类似 Shazam 的基础;录制一小段音乐,通过我们的向量化器运行,看看它与哪首索引歌曲最接近。Shazam 的算法是不同的,并且早于深度学习的流行。

通过找到听起来相似的其他音乐来获取音乐推荐系统的基础。然而,它只适用于我们已经可以访问的音乐,这在一定程度上限制了其实用性。在本章的其余部分,我们将看看另一种构建音乐推荐系统的方法。

15.5 设置 Spotify API 访问权限

问题

如何获得大量音乐数据的访问权限?

解决方案

使用 Spotify API。

我们在上一个配方中创建的系统是一种音乐推荐器,但它只推荐它已经看过的歌曲。通过从 Spotify API 中收集播放列表和歌曲,我们可以建立一个更大的训练集。让我们从 Spotify 注册一个新应用程序开始。转到https://beta.developer.spotify.com/dashboard/applications,创建一个新应用程序。

注意

这里提到的 URL 以 beta 开头。当您阅读此内容时,Spotify 上的新应用程序界面可能已经退出 beta 测试,URL 可能已更改。

您需要先登录,可能需要注册。创建应用程序后,转到应用程序页面并记下客户端 ID 和客户端密钥。由于密钥是秘密的,您需要按下按钮显示它。

现在在三个常量中输入您的各种细节:

CLIENT_ID = '<your client id>'
CLIENT_SECRET = '<your secret>'
USER_ID = '<your user id>'

您现在可以访问 Spotify API:

uri = 'http://127.0.0.1:8000/callback'
token = util.prompt_for_user_token(USER_ID, '',
                                   client_id=CLIENT_ID,
                                   client_secret=CLIENT_SECRET,
                                   redirect_uri=uri)
session = spotipy.Spotify(auth=token)

第一次运行此代码时,API 将要求您在浏览器中输入一个 URL。当从笔记本运行时,这种方式有些笨拙;要重定向的 URL 将打印在笔记本服务器运行的窗口中。但是,如果您在浏览器中按下停止按钮,它将向您显示要重定向的 URL。单击该 URL。它将重定向到以http://127.0.0.1开头的内容,这不会解析,但这并不重要。将该 URL 输入回现在在笔记本页面上显示的框中,然后按 Enter。这应该授权您。

您只需要执行一次此操作;令牌将存储在名为.cache-的文件中。如果出现问题,请删除此文件并重试。

讨论

Spotify API 是一个非常好的音乐数据来源。该 API 通过一个设计精良的 REST API 可访问,具有定义良好的端点,返回自描述的 JSON 文档。

API 文档提供了有关如何访问歌曲、艺术家和播放列表的信息,包括丰富的元信息,如专辑封面。

15.6 从 Spotify 收集播放列表和歌曲

问题

您需要为您的音乐推荐器创建一个训练集。

解决方案

搜索常见词以找到播放列表,并获取属于它们的歌曲。

尽管 Spotify API 非常丰富,但没有简单的方法可以获取一组公共播放列表。但您可以通过单词搜索它们。在这个配方中,我们将使用这种方法来获取一组不错的播放列表。让我们首先实现一个函数来获取与搜索词匹配的所有播放列表。代码中唯一的复杂之处在于我们需要从超时和其他错误中恢复:

def find_playlists(session, w, max_count=5000):
    try:
        res = session.search(w, limit=50, type='playlist')
        while res:
            for playlist in res['playlists']['items']:
                yield playlist
                max_count -= 1
                if max_count == 0:
                    raise StopIteration
            tries = 3
            while tries > 0:
                try:
                    res = session.next(res['playlists'])
                    tries = 0
                except SpotifyException as e:
                    tries -= 1
                    time.sleep(0.2)
                    if tries == 0:
                        raise
    except SpotifyException as e:
        status = e.http_status
        if status == 404:
            raise StopIteration
        raise

我们将从一个单词“a”开始,获取包含该单词的 5,000 个播放列表。我们将跟踪所有这些播放列表,同时计算出现在这些播放列表标题中的单词。这样,当我们完成单词“a”后,我们可以使用出现最多的单词做同样的操作。我们可以一直这样做,直到我们有足够的播放列表:

while len(playlists) < 100000:
    for word, _ in word_counts.most_common():
        if not word in words_seen:
            words_seen.add(word)
            print('word>', word)
            for playlist in find_playlists(session, word):
                if playlist['id'] in playlists:
                    dupes += 1
                elif playlist['name'] and playlist['owner']:
                    playlists[playlist['id']] = {
                      'owner': playlist['owner']['id'],
                      'name': playlist['name'],
                      'id': playlist['id'],
                    }
                    count += 1
                    for token in tokenize(playlist['name'],
                                          lowercase=True):
                        word_counts[token] += 1
            break

我们获取的播放列表实际上并不包含歌曲;为此,我们需要进行单独的调用。要获取播放列表的所有曲目,请使用:

def track_yielder(session, playlist):
    res = session.user_playlist_tracks(playlist['owner'], playlist['id'],
          fields='items(track(id, name, artists(name, id), duration_ms)),next')
    while res:
        for track in res['items']:
            yield track['track']['id']
            res = session.next(res)
            if not res or  not res.get('items'):
                raise StopIteration

获取大量歌曲和播放列表可能需要相当长的时间。为了获得一些体面的结果,我们至少需要 100,000 个播放列表,但更接近一百万会更好。获取 100,000 个播放列表及其歌曲大约需要 15 个小时,这是可行的,但不是您想一遍又一遍地做的事情,所以最好保存结果。

我们将存储三个数据集。第一个包含播放列表信息本身——实际上我们不需要这个信息用于下一个配方,但检查事物时会很有用。其次,我们将把播放列表中歌曲的 ID 存储在一个大文本文件中。最后,我们将存储每首歌曲的信息。我们希望能够以动态方式查找这些详细信息,因此我们将使用 SQLite 数据库。我们将在收集歌曲信息时将结果写出,以控制内存使用:

conn = sqlite3.connect('data/songs.db')
c = conn.cursor()
c.execute('CREATE TABLE songs '
          '(id text primary key, name text, artist text)')
c.execute('CREATE INDEX name_idx on songs(name)')

tracks_seen = set()
with open('data/playlists.ndjson', 'w') as fout_playlists:
    with open('data/songs_ids.txt', 'w') as fout_song_ids:
        for playlist in tqdm.tqdm(playlists.values()):
            fout_playlists.write(json.dumps(playlist) + '\n')
            track_ids = []
            for track in track_yielder(session, playlist):
                track_id = track['id']
                if not track_id:
                    continue
                if not track_id in tracks_seen:
                    c.execute("INSERT INTO songs VALUES (?, ?, ?)",
                              (track['id'], track['name'],
                               track['artists'][0]['name']))
                track_ids.append(track_id)
            fout_song_ids.write(' '.join(track_ids) + '\n')
            conn.commit()
conn.commit()

讨论

在这个配方中,我们研究了建立播放列表及其歌曲数据库。由于没有明确的方法可以从 Spotify 获取公共播放列表的平衡样本,我们采取了使用搜索界面并尝试流行关键词的方法。虽然这样做有效,但我们获取的数据集并不完全公正。

首先,我们从获取的播放列表中获取流行关键词。这确实为我们提供了与音乐相关的单词,但也很容易增加我们已经存在的偏差。如果最终我们的播放列表过多地涉及乡村音乐,那么我们的单词列表也将开始充斥着与乡村相关的单词,这反过来将使我们获取更多的乡村音乐。

另一个偏见风险是获取包含流行词的播放列表将使我们得到流行歌曲。像“最伟大”和“热门”这样的术语经常出现,会导致我们获得很多最伟大的热门歌曲;小众专辑被选中的机会较小。

15.7 训练音乐推荐系统

问题

您已经获取了大量的播放列表,但如何使用它们来训练您的音乐推荐系统呢?

解决方案

使用现成的 Word2vec 模型,将歌曲 ID 视为单词。

在第三章中,我们探讨了 Word2vec 模型如何将单词投影到具有良好属性的语义空间中;相似的单词最终位于同一邻域,单词之间的关系也相对一致。在第四章中,我们使用了嵌入技术构建了一个电影推荐系统。在这个配方中,我们结合了这两种方法。我们不会训练自己的模型,而是使用现成的 Word2vec 模型,但我们将使用结果来构建一个音乐推荐系统。

我们在第三章中使用的gensim模块还具有训练模型的可能性。它所需要的只是一个生成一系列标记的迭代器。这并不太难,因为我们将我们的播放列表存储为文件中的行,每行包含由空格分隔的歌曲 ID:

class WordSplitter(object):
    def __init__(self, filename):
        self.filename = filename

    def __iter__(self):
        with open(self.filename) as fin:
            for line in fin:
                yield line.split()

之后,训练模型只需一行操作:

model = gensim.models.Word2Vec(model_input, min_count=4)

根据前一个配方产生的歌曲/播放列表数量,这可能需要一段时间。让我们保存模型以备将来使用:

with open('zoo/15/songs.word2vec', 'wb') as fout:
    model.save(fout)

15.8 使用 Word2vec 模型推荐歌曲

问题

如何使用您的模型根据示例预测歌曲?

解决方案

使用 Word2vec 距离和您的 SQLite3 歌曲数据库。

第一步是根据歌曲名称或部分名称获取一组song_idLIKE运算符将为我们提供与搜索模式匹配的歌曲选择。但是,如今歌曲名称很少是唯一的。即使是同一位艺术家也有不同版本。因此,我们需要一种评分方式。幸运的是,我们可以使用模型的vocab属性——其中的记录具有count属性。歌曲在我们的播放列表中出现的次数越多,它就越有可能是我们要找的歌曲(或者至少是我们最了解的歌曲):

conn = sqlite3.connect('data/songs.db')
def find_song(song_name, limit=10):
    c = conn.cursor()
    c.execute("SELECT * FROM songs WHERE UPPER(name) LIKE '%"
              + song_name + "%'")
    res = sorted((x + (model.wv.vocab[x[0]].count,)
                  for x in c.fetchall() if x[0] in model.wv.vocab),
                 key=itemgetter(-1), reverse=True)
    return [*res][:limit]

for t in find_song('the eye of the tiger'):
    print(*t)
2ZqGzZWWZXEyPxJy6N9QhG The eye of the tiger Chiara Mastroianni 39
4rr0ol3zvLiEBmep7HaHtx The Eye Of The Tiger Survivor 37
0R85QWa6KRzB8p44XXE7ky The Eye of the Tiger Gloria Gaynor 29
3GxdO4rTwVfRvLRIZFXJVu The Eye of the Tiger Gloria Gaynor 19
1W602jfZkdAsbabmJEYfFi The Eye of the Tiger Gloria Gaynor 5
6g197iis9V2HP7gvc5ZpGy I Got the Eye of the Tiger Circus Roar 5
00VQxzTLqwqBBE0BuCVeer The Eye Of The Tiger Gloria Gaynor 5
28FwycRDU81YOiGgIcxcPq The Eye of the Tiger Gloria Gaynor 5
62UagxK6LuPbqUmlygGjcU It's the Eye of the Tiger Be Cult 4
6lUHKc9qrIHvkknXIrBq6d The Eye Of The Tiger Survivor 4

现在我们可以选择我们真正想要的歌曲,这种情况下可能是 Survivor 的那首歌。现在开始建议歌曲。让我们的模型来做繁重的工作:

similar = dict(model.most_similar([song_id]))

现在我们有了从歌曲 ID 到分数的查找表,我们可以很容易地扩展为实际歌曲的列表:

song_ids = ', '.join(("'%s'" % x) for x in similar.keys())
c.execute("SELECT * FROM songs WHERE id in (%s)" % song_ids)
res = sorted((rec + (similar[rec[0]],) for rec in c.fetchall()),
             key=itemgetter(-1),
             reverse=True)

“The Eye of the Tiger”的输出是:

Girls Just Wanna Have Fun Cyndi Lauper 0.9735351204872131
Enola Gay - Orchestral Manoeuvres In The Dark 0.9719518423080444
You're My Heart, You're My Soul Modern Talking 0.9589041471481323
Gold - 2003 Remastered Version Spandau Ballet 0.9566971659660339
Dolce Vita Ryan Paris 0.9553133249282837
Karma Chameleon - 2002 Remastered Version Culture Club 0.9531201720237732
Bette Davis Eyes Kim Carnes 0.9499865770339966
Walking On Sunshine Katrina & The Waves 0.9481900930404663
Maneater Daryl Hall & John Oates 0.9481032490730286
Don't You Want Me The Human League 0.9471924901008606

这看起来是一种不错的充满活力的 80 年代风格音乐的混合。

讨论

使用 Word2vec 是创建歌曲推荐系统的有效方法。与我们在第四章中所做的训练自己的模型不同,我们在这里使用了来自gensim的现成模型。虽然调整较少,但由于句子中的单词和播放列表中的歌曲是相当可比的,因此它效果很好。

Word2vec 通过尝试从上下文预测单词来工作。这种预测导致嵌入,使得相似的单词彼此靠近。在播放列表中对歌曲运行相同的过程意味着尝试根据播放列表中歌曲的上下文来预测歌曲。相似的歌曲最终在歌曲空间中靠近彼此。

使用 Word2vec,事实证明单词之间的关系也具有意义。分隔“queen”和“princess”之间的向量类似于分隔“king”和“prince”之间的向量。有趣的是,看看是否可以用类似的方式处理歌曲——披头士版本的滚石乐队的“Paint It Black”是什么?然而,这将要求我们以某种方式将艺术家投影到相同的空间中。

第十六章:生产机器学习系统

构建和训练模型是一回事;在生产系统中部署您的模型是另一回事,通常被忽视。在 Python 笔记本中运行代码很好,但不是为 web 客户端提供服务的好方法。在本章中,我们将看看如何真正开始运行。

我们将从嵌入开始。嵌入在本书的许多食谱中发挥了作用。在第三章中,我们看到了我们可以通过查看最近邻来找到相似单词的有趣事情,或者通过添加和减去嵌入向量来找到类比。在第四章中,我们使用维基百科文章的嵌入来构建一个简单的电影推荐系统。在第十章中,我们看到了我们可以将预训练图像分类网络的最终层的输出视为输入图像的嵌入,并使用此来构建反向图像搜索服务。

就像这些示例一样,我们发现真实世界的案例通常以某些实体的嵌入结束,然后我们希望从生产质量的应用程序中查询这些实体。换句话说,我们有一组图像、文本或单词,以及一个算法,为每个实体在高维空间中生成一个向量。对于一个具体的应用程序,我们希望能够查询这个空间。

我们将从一个简单的方法开始:我们将构建一个最近邻模型并将其保存到磁盘,以便在需要时加载。然后我们将看看如何使用 Postgres 达到相同的目的。

我们还将探讨使用微服务作为一种使用 Flask 作为 web 服务器和 Keras 的保存和加载模型功能来暴露机器学习模型的方法。

以下笔记本可用于本章:

16.1 Simple Text Generation
16.2 Prepare Keras Model for TensorFlow Serving
16.3 Prepare Model for iOS

16.1 使用 Scikit-Learn 的最近邻算法进行嵌入

问题

如何快速提供嵌入模型的最接近匹配项?

解决方案

使用 scikit-learn 的最近邻算法并将模型保存到文件中。我们将继续从第四章的代码开始,我们在那里创建了一个电影预测模型。在我们运行完所有内容后,我们将归一化值并拟合一个最近邻模型:

movie = model.get_layer('movie_embedding')
movie_weights = movie.get_weights()[0]
movie_lengths = np.linalg.norm(movie_weights, axis=1)
normalized_movies = (movie_weights.T / movie_lengths).T
nbrs = NearestNeighbors(n_neighbors=10, algorithm='ball_tree').fit(
    normalized_movies)
with open('data/movie_model.pkl', 'wb') as fout:
    pickle.dump({
        'nbrs': nbrs,
        'normalized_movies': normalized_movies,
        'movie_to_idx': movie_to_idx
    }, fout)

然后稍后可以再次加载模型:

with open('data/movie_model.pkl', 'rb') as fin:
    m = pickle.load(fin)
movie_names = [x[0] for x in sorted(movie_to_idx.items(),
               key=lambda t:t[1])]
distances, indices = m['nbrs'].kneighbors(
    [m['normalized_movies'][m['movie_to_idx']['Rogue One']]])
for idx in indices[0]:
    print(movie_names[idx])
Rogue One
Prometheus (2012 film)
Star Wars: The Force Awakens
Rise of the Planet of the Apes
Star Wars sequel trilogy
Man of Steel (film)
Interstellar (film)
Superman Returns
The Dark Knight Trilogy
Jurassic World

讨论

生产机器学习模型的最简单方法是在训练完成后将其保存到磁盘,然后在需要时加载。所有主要的机器学习框架都支持这一点,包括我们在本书中使用的 Keras 和 scikit-learn。

如果您控制内存管理,这个解决方案非常好。然而,在生产 web 服务器中,通常情况并非如此,当 web 请求到来时,如果必须将大型模型加载到内存中,延迟显然会受到影响。

16.2 使用 Postgres 存储嵌入

问题

您想使用 Postgres 存储嵌入。

解决方案

使用 Postgres 的Cube扩展。

Cube扩展允许处理高维数据,但需要先启用它:

CREATE EXTENSION cube;

完成后,我们可以创建一个表和相应的索引。为了也能够在movie_name字段上进行搜索,我们还将在movie_name字段上创建一个文本索引:

DROP TABLE IF EXISTS movie;
CREATE TABLE movie (
               movie_name TEXT PRIMARY KEY,
               embedding FLOAT[] NOT NULL DEFAULT '{}'
);
CREATE INDEX movie_embedding ON movie USING gin(embedding);
CREATE INDEX movie_movie_name_pattern
    ON movie USING btree(lower(movie_name) text_pattern_ops);

讨论

Postgres 是一个免费的数据库,非常强大,其中一个原因是有大量可用的扩展。其中一个模块是cube模块。顾名思义,它最初是为了将三维坐标作为原始数据可用,但后来已扩展到可以索引高达 100 维的数组。

Postgres 有许多扩展,值得探索,特别是对于处理大量数据的任何人。特别是,在经典 SQL 表中以数组和 JSON 文档的形式存储较少结构化的数据的能力在原型设计时非常方便。

16.3 填充和查询存储在 Postgres 中的嵌入

问题

您能够在 Postgres 中存储我们的模型和查询结果吗?

解决方案

使用psycopg2从 Python 连接到 Postgres。

通过给定的用户名/密码/数据库/主机组合,我们可以轻松地使用 Python 连接到 Postgres:

connection_str = "dbname='%s' user='%s' password='%s' host='%s'"
conn = psycopg2.connect(connection_str % (DB_NAME, USER, PWD, HOST))

插入我们之前构建的模型与 Python 中的任何其他 SQL 操作一样,只是我们需要将我们的numpy数组转换为 Python 列表:

with conn.cursor() as cursor:
    for movie, embedding in zip(movies, normalized_movies):
        cursor.execute('INSERT INTO movie (movie_name, embedding)'
                       ' VALUES (%s, %s)',
               (movie[0], embedding.tolist()))
conn.commit()

完成后,我们可以查询这些值。在这种情况下,我们取(部分)电影标题,找到该电影的最佳匹配,并返回最相似的电影:

def recommend_movies(conn, q):
    with conn.cursor() as cursor:
        cursor.execute('SELECT movie_name, embedding FROM movie'
                       '    WHERE lower(movie_name) LIKE %s'
                       '    LIMIT 1',
                       ('%' + q.lower() + '%',))
        if cursor.rowcount == 0:
            return []
        movie_name, embedding = cursor.fetchone()
        cursor.execute('SELECT movie_name, '
                       '       cube_distance(cube(embedding), '
                       '                     cube(%s)) as distance '
                       '    FROM movie'
                       '    ORDER BY distance'
                       '    LIMIT 5',
                       (embedding,))
        return list(cursor.fetchall())

讨论

将嵌入模型存储在 Postgres 数据库中允许我们直接查询它,而无需在每个请求中加载模型,因此当我们想要从 Web 服务器使用这样的模型时,这是一个很好的解决方案——特别是当我们的 Web 设置一开始就是基于 Postgres 的时候。

在支持您网站的数据库服务器上运行模型或模型的结果具有额外的优势,您可以无缝地混合排名组件。我们可以轻松地扩展这个示例的代码,将新鲜度评分包含在我们的电影表中,从那时起,我们可以使用这些信息来帮助对返回的电影进行排序。但是,如果评分和相似性距离来自不同的来源,我们要么必须手动进行内存连接,要么返回不完整的结果。

16.4 在 Postgres 中存储高维模型

问题

如何在 Postgres 中存储具有超过 100 个维度的模型?

解决方案

使用降维技术。

假设我们想要加载谷歌预训练的 Word2vec 模型,我们在第三章中使用了这个模型,加载到 Postgres 中。由于 Postgres 的cube扩展(参见 Recipe 16.2)限制了它将索引的维度数量为 100,我们需要做一些处理以使其适应。使用奇异值分解(SVD)来降低维度——这是我们在 Recipe 10.4 中遇到的一种技术——是一个不错的选择。让我们像以前一样加载 Word2vec 模型:

model = gensim.models.KeyedVectors.load_word2vec_format(
    MODEL, binary=True)

每个单词的归一化向量存储在syn0norm属性中,因此我们可以在其上运行 SVD。这需要一点时间:

svd = TruncatedSVD(n_components=100, random_state=42,
                   n_iter=40)
reduced = svd.fit_transform(model.syn0norm)

我们需要重新归一化向量:

reduced_lengths = np.linalg.norm(reduced, axis=1)
normalized_reduced = reduced.T / reduced_lengths).T

现在我们可以看相似性:

def most_similar(norm, positive):
    vec = norm[model.vocab[positive].index]
    dists = np.dot(norm, vec)
    most_extreme = np.argpartition(-dists, 10)[:10]
    res = ((model.index2word[idx], dists[idx]) for idx in most_extreme)
    return list(sorted(res, key=lambda t:t[1], reverse=True))
for word, score in most_similar(normalized_reduced, 'espresso'):
    print(word, score)
espresso 1.0
cappuccino 0.856463080029
chai_latte 0.835657488972
latte 0.800340435865
macchiato 0.798796776324
espresso_machine 0.791469456128
Lavazza_coffee 0.790783985201
mocha 0.788645681469
espressos 0.78424218748
martini 0.784037414689

结果看起来仍然合理,但并不完全相同。最后一个条目,马提尼,出现在一系列含有咖啡因提神饮料的列表中有些出乎意料。

讨论

Postgres 的cube扩展很棒,但带有一个警告,它仅适用于具有 100 个或更少元素的向量。文档有用地解释了这个限制:“为了让人们更难破坏事物,立方体的维度数量限制为 100。”绕过这个限制的一种方法是重新编译 Postgres,但这只是一个选项,如果您直接控制您的设置。此外,随着数据库的新版本发布,您需要继续这样做。

在将我们的向量插入数据库之前降低维度可以很容易地使用TruncatedSVD类来完成。在这个示例中,我们使用了 Word2vec 数据集中的整个单词集,这导致了一些精度的损失。如果我们不仅降低输出的维度,还减少术语的数量,我们可以做得更好。然后 SVD 可以找到我们提供的数据中最重要的维度,而不是所有数据。这甚至可以通过泛化一点并掩盖原始输入数据中的数据不足来帮助。

16.5 使用 Python 编写微服务

问题

您想编写并部署一个简单的 Python 微服务。

解决方案

使用 Flask 构建一个最小的 Web 应用程序,根据 REST 请求返回一个 JSON 文档。

首先我们需要一个 Flask Web 服务器:

app = Flask(__name__)

然后我们定义我们想要提供的服务。例如,我们将接收一张图片并返回图片的大小。我们期望图片是POST请求的一部分。如果我们没有收到POST请求,我们将返回一个简单的 HTML 表单,这样我们就可以在没有客户端的情况下测试服务。@app.route装饰指定return_size处理根目录的任何请求,支持GETPOST

@app.route('/', methods=['GET', 'POST'])
def return_size():
  if request.method == 'POST':
    file = request.files['file']
    if file:
      image = Image.open(file)
      width, height = image.size
      return jsonify(results={'width': width, 'height': height})
  return '''
 <h1>Upload new File</h1>
 <form action="" method=post enctype=multipart/form-data>
 <p><input type=file name=file>
 <input type=submit value=Upload>
 </form>
 '''

现在我们只需要在一个端口上运行服务器:

app.run(port=5050, host='0.0.0.0')

讨论

REST 最初是作为一个完整的资源管理框架,为系统中的所有资源分配 URL,并让客户端与 HTTP 谓词的整个范围进行交互,从PUTDELETE。就像许多 API 一样,在这个示例中我们放弃了所有这些,只在一个处理程序上定义了一个GET方法,触发我们的 API 并返回一个 JSON 文档。

我们在这里开发的服务当然相当琐碎;仅仅为了获取图像大小而拥有一个微服务可能有点过头了。在下一个示例中,我们将探讨如何使用这种方法来提供先前开发的机器学习模型的结果。

16.6 使用微服务部署 Keras 模型

问题

您想要将 Keras 模型部署为独立服务。

解决方案

将您的 Flask 服务器扩展到转发请求到预训练的 Keras 模型。

这个示例是在第十章中的示例构建的,我们从维基百科下载了成千上万张图片,并将它们馈送到一个预训练的图像识别网络中,得到一个描述每张图片的 2,048 维向量。我们将在这些向量上拟合一个最近邻模型,以便我们可以快速找到最相似的图像,给定一个向量。

第一步是加载腌制的图像名称和最近邻模型,并实例化用于图像识别的预训练模型:

with open('data/image_similarity.pck', 'rb') as fin:
    p = pickle.load(fin)
    image_names = p['image_names']
    nbrs = p['nbrs']
base_model = InceptionV3(weights='imagenet', include_top=True)
model = Model(inputs=base_model.input,
              outputs=base_model.get_layer('avg_pool').output)

我们现在可以通过更改if file:后的代码部分来修改如何处理传入的图像。我们将调整图像大小到模型的目标大小,规范化数据,运行预测,并找到最近的邻居:

      img = Image.open(file)
      target_size = int(max(model.input.shape[1:]))
      img = img.resize((target_size, target_size), Image.ANTIALIAS)
      pre_processed = preprocess_input(
          np.asarray([image.img_to_array(img)]))
      vec = model.predict(pre_processed)
      distances, indices = nbrs.kneighbors(vec)
      res = [{'distance': dist,
              'image_name': image_names[idx]}
             for dist, idx in zip(distances[0], indices[0])]
      return jsonify(results=res)

给它一张猫的图片,你应该看到从维基百科图片中抽样出大量猫的图片——还有一张孩子们玩家用电脑的照片。

讨论

通过在启动时加载模型,然后在图像进入时馈送图像,我们可以减少我们遵循本节第一个示例方法所获得的延迟。我们在这里有效地链接了两个模型,预训练的图像识别网络和最近邻分类器,并将组合导出为一个服务。

16.7 从 Web 框架调用微服务

问题

您想要从 Django 调用一个微服务。

解决方案

使用requests调用微服务同时处理 Django 请求。我们可以按照以下示例的方式进行:

def simple_view(request):
    d = {}
    update_date(request, d)
    if request.FILES.get('painting'):
        data = request.FILES['painting'].read()
        files = {'file': data}
        reply = requests.post('http://localhost:5050',
                              files=files).json()
        res = reply['results']
        if res:
            d['most_similar'] = res[0]['image_name']
    return render(request, 'template_path/template.html', d)

讨论

这里的代码来自 Django 请求处理程序,但在其他 Web 框架中看起来应该非常相似,即使是基于 Python 以外的语言的框架。

这里的关键是我们将 Web 框架的会话管理与微服务的会话管理分开。这样我们就知道在任何给定时间只有一个模型实例,这使得延迟和内存使用可预测。

Requests是一个用于发起HTTP调用的简单模块。尽管它不支持发起异步调用。在这个示例的代码中,这并不重要,但如果我们需要调用多个服务,我们希望能够并行进行。有许多选项可供选择,但它们都遵循一种模式,即在请求开始时向后端发起调用,进行我们需要的处理,然后在需要结果时等待未完成的请求。这是使用 Python 构建高性能系统的良好设置。

16.8 TensorFlow seq2seq 模型

问题

如何将 seq2seq 聊天模型投入生产?

解决方案

运行一个带有输出捕获钩子的 TensorFlow 会话。

Google 发布的seq2seq模型是一种非常好的快速开发序列到序列模型的方式,但默认情况下推断阶段只能使用stdinstdout运行。通过这种方式从我们的微服务调用是完全可能的,但这意味着我们将在每次调用时承担加载模型的延迟成本。

更好的方法是手动实例化模型并使用钩子捕获输出。第一步是从检查点目录中恢复模型。我们需要加载模型和模型配置。模型将输入source_tokens(即聊天提示),我们将使用批量大小为 1,因为我们将以交互方式进行:

checkpoint_path = tf.train.latest_checkpoint(model_path)
train_options = training_utils.TrainOptions.load(model_path)
model_cls = locate(train_options.model_class) or \
  getattr(models, train_options.model_class)
model_params = train_options.model_params
model = model_cls(
    params=model_params,
    mode=tf.contrib.learn.ModeKeys.INFER)
source_tokens_ph = tf.placeholder(dtype=tf.string, shape=(1, None))
source_len_ph = tf.placeholder(dtype=tf.int32, shape=(1,))
model(
  features={
    "source_tokens": source_tokens_ph,
    "source_len": source_len_ph
  },
  labels=None,
  params={
  }
)

下一步是设置允许我们将数据输入模型的 TensorFlow 会话。这都是相当标准的内容(应该让我们更加欣赏像 Keras 这样的框架):

  saver = tf.train.Saver()
  def _session_init_op(_scaffold, sess):
      saver.restore(sess, checkpoint_path)
      tf.logging.info("Restored model from %s", checkpoint_path)
  scaffold = tf.train.Scaffold(init_fn=_session_init_op)
  session_creator = tf.train.ChiefSessionCreator(scaffold=scaffold)
  sess = tf.train.MonitoredSession(
      session_creator=session_creator,
      hooks=[DecodeOnce({}, callback_func=_save_prediction_to_dict)])
  return sess, source_tokens_ph, source_len_pht

我们现在配置了一个带有DecodeOnce钩子的 TensorFlow 会话,它是一个实现推断任务基本功能的类,但在完成后调用提供的callback函数返回实际结果。

seq2seq_server.py的代码中,我们可以使用这个来处理 HTTP 请求,如下所示:

@app.route('/', methods=['GET'])
def handle_request():
  input = request.args.get('input', '')
  if input:
    tf.reset_default_graph()
    source_tokens = input.split() + ["SEQUENCE_END"]
    session.run([], {
        source_tokens_ph: [source_tokens],
        source_len_ph: [len(source_tokens)]
      })
    return prediction_dict.pop(_tokens_to_str(source_tokens))

这将让我们处理来自简单 Web 服务器的 seq2seq 调用。

讨论

在这个配方中,我们将数据输入到 seq2seq TensorFlow 模型的方式并不是很漂亮,但它是有效的,并且在性能方面比使用stdinstdout要好得多。希望这个库的即将推出的版本将为我们提供一个更好的方式来在生产中使用这些模型,但目前这样做就可以了。

16.9 在浏览器中运行深度学习模型

问题

如何在没有服务器的情况下运行深度学习 Web 应用程序?

解决方案

使用 Keras.js 在浏览器中运行模型。

在浏览器中运行深度学习模型听起来很疯狂。深度学习需要大量的处理能力,我们都知道 JavaScript 很慢。但事实证明,您可以在浏览器中以相当快的速度运行模型,并使用 GPU 加速。Keras.js有一个工具,可以将 Keras 模型转换为 JavaScript 运行时可以处理的内容,并使用 WebGL 来让 GPU 帮助处理。这是一个令人惊叹的工程成就,并且配备了一些令人印象深刻的演示。让我们尝试在我们自己的模型上尝试一下。

笔记本16.1 简单文本生成取自 Keras 示例目录,并基于尼采的著作训练了一个简单的文本生成模型。训练后,我们保存模型:

model.save('keras_js/nietzsche.h5')
with open('keras_js/chars.js', 'w') as fout:
    fout.write('maxlen = ' + str(maxlen) + '\n')
    fout.write('num_chars = ' + str(len(chars)) + '\n')
    fout.write('char_indices = ' + json.dumps(char_indices, indent=2) + '\n')
    fout.write('indices_char = ' + json.dumps(indices_char, indent=2) + '\n')

现在我们需要将 Keras 模型转换为 Keras.js 格式。首先获取转换代码:

git clone https://github.com/transcranial/keras-js.git

现在打开一个 shell,在保存模型的目录中执行:

python <*git-root*>/keras-js/python/encoder.py nietzsche.h5

这将给您一个nietzsche.bin文件。

下一步是从网页中使用这个文件。

我们将在nietzsche.html文件中执行此操作,您将在deep_learning_cookbook存储库的keras_js目录中找到它。让我们来看看。它以加载 Keras.js 库和我们从 Python 保存的变量的代码开始:

<script src="https://unpkg.com/keras-js"></script>
<script src="chars.js"></script>

在底部,我们有一个非常简单的 HTML 代码,让用户输入一些文本,然后按下按钮以尼采式的方式扩展文本:

<textarea cols="60" rows="4" id="textArea">
   i am all for progress, it is
</textarea><br/>
<button onclick="runModel(250)" disabled id="buttonGo">Go!</button>

现在让我们加载模型,加载完成后,启用当前禁用的按钮buttonGo

const model = new KerasJS.Model({
      filepath: 'sayings.bin',
      gpu: true
    })

    model.ready().then(() => {
      document.getElementById("buttonGo").disabled = false
    })

runModel中,我们首先需要使用之前导入的char_indices对文本数据进行独热编码:

    function encode(st) {
      var x = new Float32Array(num_chars * st.length);
      for(var i = 0; i < st.length; i++) {
        idx = char_indices[ch = st[i]];
        x[idx + i * num_chars] = 1;
      }
      return x;
    };

现在我们可以运行模型:

return model.predict(inputData).then(outputData => {
    ...
    ...
  })

outputData变量将包含我们词汇表中每个字符的概率分布。理解这一点最简单的方法是选择具有最高概率的字符:

      var maxIdx = -1;
      var maxVal = 0.0;
      for (var idx = 0; idx < output.length; idx ++) {
        if (output[idx] > maxVal) {
          maxVal = output[idx];
          maxIdx = idx;
        }
      }

现在我们只需将该字符添加到我们到目前为止的内容中,并再次执行相同的操作:

     var nextChar = indices_char["" + maxIdx];
      document.getElementById("textArea").value += nextChar;
      if (steps > 0) {
        runModel(steps - 1);
      }

讨论

能够直接在浏览器中运行模型为生产提供了全新的可能性。这意味着您不需要服务器来执行实际计算,并且使用 WebGL,您甚至可以免费获得 GPU 加速。查看https://transcranial.github.io/keras-js上的有趣演示。

这种方法存在一些限制。为了使用 GPU,Keras.js 使用 WebGL 2.0。不幸的是,目前并非所有浏览器都支持这一点。此外,张量被编码为 WebGL 纹理,其大小受限。实际限制取决于您的浏览器和硬件。当然,您可以退回到仅使用 CPU,但这意味着在纯 JavaScript 中运行。

第二个限制是模型的大小。生产质量的模型通常有几十兆字节的大小,当它们一次加载到服务器上时,这一点根本不是问题,但当它们需要发送到客户端时可能会出现问题。

注意

encoder.py脚本有一个名为--quantize的标志,它将模型的权重编码为 8 位整数。这将减少模型大小 75%,但意味着权重将不太精确,这可能会影响预测准确性。

16.10 使用 TensorFlow Serving 运行 Keras 模型

问题

如何使用谷歌最先进的服务器运行 Keras 模型?

解决方案

转换模型并调用 TensorFlow Serving 工具包以写出模型规范,以便您可以使用 TensorFlow Serving 运行它。

TensorFlow Serving 是 TensorFlow 平台的一部分;根据谷歌的说法,它是一个灵活、高性能的机器学习模型服务系统,专为生产环境设计。

将一个 TensorFlow 模型写成 TensorFlow Serving 可以使用的方式有些复杂。为了使其正常工作,Keras 模型需要更多的调整。原则上,只要模型只有一个输入和一个输出,就可以使用任何模型——这是 TensorFlow Serving 的限制之一。另一个限制是 TensorFlow Serving 只支持 Python 2.7。

首先要做的是将模型重新创建为仅用于测试的模型。模型在训练和测试期间的行为不同。例如,Dropout层在训练时只会随机丢弃神经元,而在测试时会使用所有神经元。Keras 会将这些隐藏在用户之外,将学习阶段作为额外变量传递。如果您看到错误提示缺少输入的某些内容,这可能是原因。我们将学习阶段设置为0(false),并从我们的字符 CNN 模型中提取配置和权重:

K.set_learning_phase(0)
char_cnn = load_model('zoo/07.2 char_cnn_model.h5')
config = char_cnn.get_config()
if not 'config' in config:
    config = {'config': config,
              'class_name': 'Model'}

weights = char_cnn.get_weights()

此时可能有必要对模型进行预测,以便稍后查看它仍然有效:

tweet = ("There's a house centipede in my closet and "
         "since Ryan isn't here I have to kill it....")
encoded = np.zeros((1, max_sequence_len, len(char_to_idx)))
for idx, ch in enumerate(tweet):
    encoded[0, idx, char_to_idx[ch]] = 1

res = char_cnn.predict(encoded)
emojis[np.argmax(res)]
u'\ude03'

然后我们可以重新构建模型:

new_model = model_from_config(config)
new_model.set_weights(weights)

为了使模型运行,我们需要为 TensorFlow Serving 提供输入和输出规范:

input_info = utils.build_tensor_info(new_model.inputs[0])
output_info = utils.build_tensor_info(new_model.outputs[0])
prediction_signature = signature_def_utils.build_signature_def(
          inputs={'input': input_info},
          outputs={'output': output_info},
          method_name=signature_constants.PREDICT_METHOD_NAME)

然后我们可以构建builder对象来定义我们的处理程序并写出定义:

outpath = 'zoo/07.2 char_cnn_model.tf_model/1'
shutil.rmtree(outpath)

legacy_init_op = tf.group(tf.tables_initializer(), name='legacy_init_op')
builder = tf.saved_model.builder.SavedModelBuilder(outpath)
builder.add_meta_graph_and_variables(
      sess, [tf.saved_model.tag_constants.SERVING],
      signature_def_map={
           'emoji_suggest': prediction_signature,
      },
      legacy_init_op=legacy_init_op)
builder.save()

现在我们运行服务器:

tensorflow_model_server \
    --model_base_path="char_cnn_model.tf_model/" \
    --model_name="char_cnn_model"

您可以直接从谷歌获取二进制文件,也可以从源代码构建——有关详细信息,请参阅安装说明

让我们看看我们是否可以从 Python 调用模型。我们将实例化一个预测请求并使用grpc进行调用:

request = predict_pb2.PredictRequest()
request.model_spec.name = 'char_cnn_model'
request.model_spec.signature_name = 'emoji_suggest'
request.inputs['input'].CopyFrom(tf.contrib.util.make_tensor_proto(
    encoded.astype('float32'), shape=[1, max_sequence_len, len(char_to_idx)]))

channel = implementations.insecure_channel('localhost', 8500)
stub = prediction_service_pb2.beta_create_PredictionService_stub(channel)
result = stub.Predict(request, 5)

获取实际预测的表情符号:

response = np.array(result.outputs['output'].float_val)
prediction = np.argmax(response)
emojis[prediction]

讨论

TensorFlow Serving 是谷歌认可的将模型投入生产的方式,但与启动自定义 Flask 服务器并自行处理输入和输出相比,使用它与 Keras 模型有些复杂。

尽管有优势。首先,由于不是自定义的,这些服务器都表现一致。此外,它是一个工业强度的服务器,支持版本控制,并可以直接从多个云提供商加载模型。

16.11 在 iOS 上使用 Keras 模型

问题

您希望在 iOS 上的移动应用程序中使用在桌面上训练的模型。

解决方案

使用 CoreML 将您的模型转换并直接从 Swift 与之通信。

注意

本文介绍了如何为 iOS 构建应用程序,因此您需要安装了 Xcode 的 Mac 来运行示例。此外,由于示例使用相机进行检测,因此您还需要具有相机的 iOS 设备来尝试它。

首先要做的是转换模型。不幸的是,苹果的代码只支持 Python 2.7,并且在支持最新版本的tensorflowkeras方面似乎有点滞后,所以我们将设置特定版本。打开一个 shell 来设置 Python 2.7 并输入正确的要求,然后输入:

virtualenv venv2
source venv2/bin/activate
pip install coremltools
pip install h5py
pip install keras==2.0.6
pip install tensorflow==1.2.1

然后启动 Python 并输入:

from keras.models import load_model
import coremltools

加载先前保存的模型和标签:

keras_model = load_model('zoo/09.3 retrained pet recognizer.h5')
class_labels = json.load(open('zoo/09.3 pet_labels.json'))

然后转换模型:

coreml_model = coremltools.converters.keras.convert(
    keras_model,
    image_input_names="input_1",
    class_labels=class_labels,
    image_scale=1/255.)
coreml_model.save('zoo/PetRecognizer.mlmodel')
提示

您也可以跳过这一步,在zoo目录中使用.mlmodel文件进行工作。

现在开始 Xcode,创建一个新项目,并将PetRecognizer.mlmodel文件拖到项目中。Xcode 会自动导入模型并使其可调用。让我们识别一些宠物!

苹果在其网站上有一个示例项目链接,使用了标准的图像识别网络。下载这个项目,解压缩它,然后用 Xcode 打开它。

在项目概述中,您应该看到一个名为MobileNet.mlmodel的文件。删除它,然后将PetRecognizer.mlmodel文件拖到原来MobileNet.mlmodel的位置。现在打开ImageClassificationViewController.swift,并将任何MobileNet的出现替换为PetRecognizer

现在您应该能够像以前一样运行该应用程序,但使用新模型和输出类。

讨论

在 iOS 应用程序中使用 Keras 模型非常简单,至少如果我们坚持使用苹果 SDK 提供的示例的话。尽管这项技术相当新,但在那里没有太多与苹果示例大不相同的可用示例。此外,CoreML 仅适用于苹果操作系统,仅适用于 iOS 11 或更高版本或 macOS 10.13 或更高版本。

posted @ 2025-11-22 09:03  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报