TensorFlow-深度学习项目-全-
TensorFlow 深度学习项目(全)
原文:
annas-archive.org/md5/827b5880d76a4fee71ae42c8e7da9039译者:飞龙
前言
TensorFlow 是最受欢迎的机器学习框架之一,最近也广泛应用于深度学习。它提供了一个快速高效的框架,用于训练不同种类的深度学习模型,且具有非常高的准确性。本书是你掌握 TensorFlow 深度学习的指南,通过 12 个实际项目帮助你完成学习。
TensorFlow 深度学习项目从设置适合深度学习的 TensorFlow 环境开始。你将学习如何使用 TensorFlow 训练不同类型的深度学习模型,包括 CNN、RNN、LSTM 和生成对抗网络。在此过程中,你将构建端到端的深度学习解决方案,解决图像处理、企业 AI 和自然语言处理等实际问题。你将训练高性能模型,自动生成图像标题、预测股票表现,并创建智能聊天机器人。本书还涵盖了一些高级内容,如推荐系统和强化学习。
到本书结束时,你将掌握深度学习的所有概念及其在 TensorFlow 中的实现,并能够使用 TensorFlow 构建和训练你自己的深度学习模型,解决任何类型的问题。
本书适合谁阅读
本书面向数据科学家、机器学习和深度学习从业者,以及那些希望通过测试自身知识和专业技能,打造真实世界智能系统的 AI 爱好者。如果你希望通过实现 TensorFlow 中的实际项目,掌握与深度学习相关的各种概念和算法,那么本书就是你所需要的!
本书内容
第一章,使用 ConvNets 识别交通标志,展示了如何通过所有必要的预处理步骤从图像中提取合适的特征。对于我们的卷积神经网络,我们将使用使用 matplotlib 生成的简单形状。在我们的图像预处理练习中,我们将使用耶鲁面部数据库。
第二章,使用目标检测 API 标注图像,详细介绍了如何构建一个实时目标检测应用,能够使用 TensorFlow 的最新目标检测 API(配备预训练卷积网络,也称为 TensorFlow 检测模型库)和 OpenCV,标注图像、视频和摄像头捕捉到的图像。
第三章,图像标题生成,使读者能够学习如何在有无预训练模型的情况下进行标题生成。
第四章,构建用于条件图像创建的 GANs,逐步引导你构建选择性 GAN,以重现所需类型的新图像。GAN 将重现的使用数据集将是手写字符(包括 Chars74K 中的数字和字母)。
第五章,使用 LSTM 进行股票价格预测,探讨了如何预测单维度信号——股票价格的未来。根据其过去的走势,我们将学习如何使用 LSTM 架构来预测未来,并使我们的预测变得越来越准确。
第六章,创建和训练机器翻译系统,展示了如何使用 TensorFlow 创建和训练一个前沿的机器翻译系统。
第七章,训练并设置一个能够像人类一样讨论的聊天机器人,告诉您如何从零开始构建一个智能聊天机器人,并如何与它讨论。
第八章,检测重复的 Quora 问题,讨论了使用 Quora 数据集检测重复问题的方法。当然,这些方法也可以用于其他类似的数据集。
第九章,构建 TensorFlow 推荐系统,涵盖了大型应用的实际示例。我们将学习如何在 AWS 上实现云 GPU 计算能力,并提供非常明确的指导。我们还将利用 H2O 的出色 API 进行大规模的深度网络操作。
第十章,通过强化学习的电子游戏,详细介绍了一个项目,您将构建一个能够独立玩Lunar Lander的 AI。该项目围绕现有的 OpenAI Gym 项目展开,并使用 TensorFlow 进行集成。OpenAI Gym 是一个提供不同游戏环境的项目,用于探索如何使用可以由包括 TensorFlow 神经网络模型在内的算法驱动的 AI 代理。
为了从本书中获得最大的收益
本书中涵盖的示例可以在 Windows、Ubuntu 或 Mac 上运行。所有安装说明都已涵盖。您需要具备基本的 Python、机器学习和深度学习知识,并且熟悉 TensorFlow。
下载示例代码文件
您可以从您的帐户中下载本书的示例代码文件,访问www.packtpub.com。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
登录或注册到www.packtpub.com。
-
选择 SUPPORT 标签。
-
点击代码下载和勘误。
-
在搜索框中输入书名并按照屏幕上的指示操作。
一旦文件下载完成,请确保使用最新版本的工具解压或提取文件夹:
-
适用于 Windows 的 WinRAR/7-Zip
-
适用于 Mac 的 Zipeg/iZip/UnRarX
-
适用于 Linux 的 7-Zip/PeaZip
本书的代码包也托管在 GitHub 上,网址是 github.com/PacktPublishing/TensorFlow-Deep-Learning-Projects。我们还在 github.com/PacktPublishing/ 上提供了其他来自我们丰富书籍和视频目录的代码包。快去看看吧!
使用的约定
本书中使用了多种文本约定。
CodeInText:指示文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。例如:"类 TqdmUpTo 只是一个 tqdm 包装器,它使得下载也能显示进度条。"
一块代码如下所示:
import numpy as np
import urllib.request
import tarfile
import os
import zipfile
import gzip
import os
from glob import glob
from tqdm import tqdm
任何命令行输入或输出如下所示:
epoch 01: precision: 0.064
epoch 02: precision: 0.086
epoch 03: precision: 0.106
epoch 04: precision: 0.127
epoch 05: precision: 0.138
epoch 06: precision: 0.145
epoch 07: precision: 0.150
epoch 08: precision: 0.149
epoch 09: precision: 0.151
epoch 10: precision: 0.152
粗体:表示新术语、重要单词或在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中会以这种形式出现。示例如下:“从管理面板中选择系统信息。”
警告或重要提示如下所示。
小贴士和技巧如下所示。
联系我们
我们欢迎读者的反馈。
一般反馈:发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书名。如果您对本书的任何部分有疑问,请通过电子邮件联系我们,地址是 questions@packtpub.com。
勘误:尽管我们已尽一切努力确保内容的准确性,但错误仍然会发生。如果您在本书中发现错误,请联系我们并报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击“勘误提交表格”链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法复制形式,我们将非常感激您提供相关位置或网站名称。请通过 copyright@packtpub.com 联系我们,并附上链接。
如果您有意成为作者:如果您在某个领域有专业知识,并且有兴趣撰写或参与编写书籍,请访问 authors.packtpub.com。
评论
请留下评论。在您阅读并使用本书后,不妨在购买本书的网站上留下评论?潜在的读者可以参考并使用您的公正意见来做出购买决策,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到您对其书籍的反馈。谢谢!
若想了解更多关于 Packt 的信息,请访问 packtpub.com。
第一章:使用卷积神经网络(ConvNets)识别交通标志
作为本书的第一个项目,我们将尝试构建一个简单的模型,在深度学习表现非常好的领域:交通标志识别。简而言之,给定一张交通标志的彩色图像,模型应能识别出它是哪种标志。我们将探讨以下几个方面:
-
数据集的组成方式
-
使用哪个深度网络
-
如何预处理数据集中的图像
-
如何训练并关注性能进行预测
数据集
由于我们将尝试通过图像预测一些交通标志,因此我们将使用一个为此目的而创建的数据集。幸运的是,德国神经信息学研究所的研究人员创建了一个包含近 40,000 张图像的数据集,所有图像都不同,且与 43 个交通标志相关。我们将使用的数据集是名为 德国交通标志识别基准(GTSRB)的一部分,该基准旨在对多个模型的表现进行评分,目标都是相同的。这个数据集已经相当旧了——2011 年!但看起来它是一个很不错且组织良好的数据集,适合我们从中启动项目。
本项目使用的数据集可以在 benchmark.ini.rub.de/Dataset/GTSRB_Final_Training_Images.zip 免费获取。
在你开始运行代码之前,请先下载文件并将其解压到与代码相同的目录下。解压完压缩包后,你将得到一个名为 GTSRB 的新文件夹,里面包含了数据集。
本书的作者感谢那些参与数据集制作并使其开源的人们。
另外,可以参考 cs231n.github.io/convolutional-networks/ 了解更多关于 CNN 的信息。
现在让我们来看一些示例:
“限速 20 公里/小时”:

“直行或右转”:

“环形交叉口”:

如你所见,这些信号的亮度不统一(有些非常暗,有些则非常亮),它们的大小不同,透视不同,背景不同,而且可能包含其他交通标志的部分图像。
数据集的组织方式是这样的:同一标签的所有图像都在同一个文件夹内。例如,在路径 GTSRB/Final_Training/Images/00040/ 中,所有的图像都有相同的标签 40。如果图像的标签是另一个标签 5,请打开文件夹 GTSRB/Final_Training/Images/00005/。还要注意,所有图像都是 PPM 格式,这是一种无损压缩格式,适用于图像,并且有许多开源解码器/编码器可以使用。
卷积神经网络(CNN)
对于我们的项目,我们将使用一个非常简单的网络,具有以下架构:

在这个架构中,我们仍然有以下选择:
-
2D 卷积中的滤波器数量和核大小
-
最大池化中的核大小
-
全连接层中的单元数
-
批处理大小、优化算法、学习步长(最终其衰减率)、每层的激活函数以及训练的轮数
图像预处理
模型的第一个操作是读取图像并进行标准化。事实上,我们无法处理大小不一的图像;因此,在这第一步中,我们将加载图像并将其调整为预定义的大小(32x32)。此外,我们将对标签进行独热编码,以便生成一个 43 维的数组,其中只有一个元素被激活(即值为 1),同时我们会将图像的颜色空间从 RGB 转换为灰度图像。从图像中可以明显看出,我们需要的信息不在信号的颜色中,而是在其形状和设计中。
现在,让我们打开一个 Jupyter Notebook,并编写一些代码来完成这项工作。首先,我们创建一些包含类别数量(43)和调整大小后图像大小的最终变量:
N_CLASSES = 43
RESIZED_IMAGE = (32, 32)
接下来,我们将编写一个函数,读取指定路径中的所有图像,将其调整为预定义的形状,转换为灰度图像,并进行独热编码标签。为此,我们将使用一个名为 dataset 的命名元组:
import matplotlib.pyplot as plt
import glob
from skimage.color import rgb2lab
from skimage.transform import resize
from collections import namedtuple
import numpy as np
np.random.seed(101)
%matplotlib inline
Dataset = namedtuple('Dataset', ['X', 'y'])
def to_tf_format(imgs):
return np.stack([img[:, :, np.newaxis] for img in imgs], axis=0).astype(np.float32)
def read_dataset_ppm(rootpath, n_labels, resize_to):
images = []
labels = []
for c in range(n_labels):
full_path = rootpath + '/' + format(c, '05d') + '/'
for img_name in glob.glob(full_path + "*.ppm"):
img = plt.imread(img_name).astype(np.float32)
img = rgb2lab(img / 255.0)[:,:,0]
if resize_to:
img = resize(img, resize_to, mode='reflect')
label = np.zeros((n_labels, ), dtype=np.float32)
label[c] = 1.0
images.append(img.astype(np.float32))
labels.append(label)
return Dataset(X = to_tf_format(images).astype(np.float32),
y = np.matrix(labels).astype(np.float32))
dataset = read_dataset_ppm('GTSRB/Final_Training/Images', N_CLASSES, RESIZED_IMAGE)
print(dataset.X.shape)
print(dataset.y.shape)
得益于 skimage 模块,读取、转换和调整大小的操作变得非常简单。在我们的实现中,我们决定将原始的颜色空间(RGB)转换为 lab,然后仅保留亮度成分。请注意,另一个好的转换是 YUV,其中应该保留“Y”分量作为灰度图像。
运行上面的单元格会得到以下结果:
(39209, 32, 32, 1)
(39209, 43)
关于输出格式的一个说明:观察矩阵X的形状有四个维度。第一个维度是观察值的索引(在这种情况下,我们有大约 40,000 个样本);其他三个维度包含图像(其大小为 32 像素 × 32 像素的灰度图像,也就是一维的)。这是在 TensorFlow 中处理图像时的默认形状(参见代码中的 _tf_format 函数)。
至于标签矩阵,行是观察的索引,列是标签的独热编码。
为了更好地理解观察矩阵,让我们打印第一个样本的特征向量以及它的标签:
plt.imshow(dataset.X[0, :, :, :].reshape(RESIZED_IMAGE)) #sample
print(dataset.y[0, :]) #label

[[1\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0.
0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0.]]
你可以看到图像,也就是特征向量,是 32x32 的。标签仅在第一个位置包含一个 1。
现在,让我们打印最后一个样本:
plt.imshow(dataset.X[-1, :, :, :].reshape(RESIZED_IMAGE)) #sample
print(dataset.y[-1, :]) #label

[[0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0.
0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 0\. 1.]]
特征向量的大小相同(32x32),而标签向量在最后一个位置包含一个 1。
这些是我们需要创建模型的两项信息。请特别注意形状,因为它们在深度学习中处理图像时至关重要;与经典机器学习中的观察矩阵不同,这里的X有四个维度!
我们预处理的最后一步是训练/测试集拆分。我们希望在数据集的一个子集上训练我们的模型,然后在剩余的样本上衡量模型的表现,即测试集。为此,让我们使用sklearn提供的函数:
from sklearn.model_selection import train_test_split
idx_train, idx_test = train_test_split(range(dataset.X.shape[0]), test_size=0.25, random_state=101)
X_train = dataset.X[idx_train, :, :, :]
X_test = dataset.X[idx_test, :, :, :]
y_train = dataset.y[idx_train, :]
y_test = dataset.y[idx_test, :]
print(X_train.shape)
print(y_train.shape)
print(X_test.shape)
print(y_test.shape)
在这个示例中,我们将使用数据集中 75%的样本用于训练,剩下的 25%用于测试。事实上,下面是前一个代码的输出:
(29406, 32, 32, 1)
(29406, 43)
(9803, 32, 32, 1)
(9803, 43)
训练模型并进行预测
首先需要一个函数来创建训练数据的小批量。事实上,在每次训练迭代时,我们都需要插入从训练集中提取的小批量样本。在这里,我们将构建一个函数,该函数以观察值、标签和批量大小作为参数,并返回一个小批量生成器。此外,为了在训练数据中引入一些变异性,让我们向函数中添加另一个参数,即可以选择是否打乱数据,从而为每个生成器提供不同的小批量数据。每个生成器中不同的小批量数据将迫使模型学习输入输出连接,而不是记住序列:
def minibatcher(X, y, batch_size, shuffle):
assert X.shape[0] == y.shape[0]
n_samples = X.shape[0]
if shuffle:
idx = np.random.permutation(n_samples)
else:
idx = list(range(n_samples))
for k in range(int(np.ceil(n_samples/batch_size))):
from_idx = k*batch_size
to_idx = (k+1)*batch_size
yield X[idx[from_idx:to_idx], :, :, :], y[idx[from_idx:to_idx], :]
为了测试这个函数,让我们打印出小批量的形状,同时设置batch_size=10000:
for mb in minibatcher(X_train, y_train, 10000, True):
print(mb[0].shape, mb[1].shape)
这将打印出以下内容:
(10000, 32, 32, 1) (10000, 43)
(10000, 32, 32, 1) (10000, 43)
(9406, 32, 32, 1) (9406, 43)
不出所料,训练集中的 29,406 个样本被分成两个小批量,每个小批量包含 10,000 个元素,最后一个小批量包含9406个元素。当然,标签矩阵中也有相同数量的元素。
现在终于到了构建模型的时候!让我们首先构建组成网络的各个模块。我们可以从创建一个带有可变数量单元的全连接层开始(这是一个参数),并且没有激活函数。我们决定使用 Xavier 初始化来初始化系数(权重),并使用 0 初始化来初始化偏置,以便使得该层居中并正确缩放。输出只是输入张量与权重的乘积,加上偏置。请注意,权重的维度是动态定义的,因此可以在网络中的任何位置使用:
import tensorflow as tf
def fc_no_activation_layer(in_tensors, n_units):
w = tf.get_variable('fc_W',
[in_tensors.get_shape()[1], n_units],
tf.float32,
tf.contrib.layers.xavier_initializer())
b = tf.get_variable('fc_B',
[n_units, ],
tf.float32,
tf.constant_initializer(0.0))
return tf.matmul(in_tensors, w) + b
现在,让我们创建一个带有激活函数的全连接层;具体来说,我们将使用泄漏 ReLU。正如你所看到的,我们可以使用之前的函数来构建这个功能:
def fc_layer(in_tensors, n_units):
return tf.nn.leaky_relu(fc_no_activation_layer(in_tensors, n_units))
最后,让我们创建一个卷积层,接受输入数据、卷积核大小和滤波器数量(或单元)作为参数。我们将使用与全连接层相同的激活函数。在这种情况下,输出将通过一个泄漏 ReLU 激活:
def conv_layer(in_tensors, kernel_size, n_units):
w = tf.get_variable('conv_W',
[kernel_size, kernel_size, in_tensors.get_shape()[3], n_units],
tf.float32,
tf.contrib.layers.xavier_initializer())
b = tf.get_variable('conv_B',
[n_units, ],
tf.float32,
tf.constant_initializer(0.0))
return tf.nn.leaky_relu(tf.nn.conv2d(in_tensors, w, [1, 1, 1, 1], 'SAME') + b)
现在,是时候创建一个maxpool_layer了。在这里,窗口大小和步幅都是正方形的(矩阵):
def maxpool_layer(in_tensors, sampling):
return tf.nn.max_pool(in_tensors, [1, sampling, sampling, 1], [1, sampling, sampling, 1], 'SAME')
最后需要定义的是 dropout,用于正则化网络。创建起来相当简单,但请记住,dropout 只应在训练网络时使用,而不应在预测输出时使用;因此,我们需要使用条件操作符来定义是否应用 dropout:
def dropout(in_tensors, keep_proba, is_training):
return tf.cond(is_training, lambda: tf.nn.dropout(in_tensors, keep_proba), lambda: in_tensors)
最后,是时候将所有内容整合起来,按照之前定义的创建模型。我们将创建一个由以下层组成的模型:
-
2D 卷积,5x5,32 个过滤器
-
2D 卷积,5x5,64 个过滤器
-
展平器
-
全连接层,1,024 个单元
-
Dropout 40%
-
全连接层,无激活函数
-
Softmax 输出
这是代码:
def model(in_tensors, is_training):
# First layer: 5x5 2d-conv, 32 filters, 2x maxpool, 20% drouput
with tf.variable_scope('l1'):
l1 = maxpool_layer(conv_layer(in_tensors, 5, 32), 2)
l1_out = dropout(l1, 0.8, is_training)
# Second layer: 5x5 2d-conv, 64 filters, 2x maxpool, 20% drouput
with tf.variable_scope('l2'):
l2 = maxpool_layer(conv_layer(l1_out, 5, 64), 2)
l2_out = dropout(l2, 0.8, is_training)
with tf.variable_scope('flatten'):
l2_out_flat = tf.layers.flatten(l2_out)
# Fully collected layer, 1024 neurons, 40% dropout
with tf.variable_scope('l3'):
l3 = fc_layer(l2_out_flat, 1024)
l3_out = dropout(l3, 0.6, is_training)
# Output
with tf.variable_scope('out'):
out_tensors = fc_no_activation_layer(l3_out, N_CLASSES)
return out_tensors
接下来,让我们编写一个函数,在训练集上训练模型并测试测试集上的表现。请注意,以下所有代码都属于 train_model 函数;它被拆解为几个部分,以便于解释。
该函数的参数包括(除了训练集、测试集及其标签)学习率、周期数和批次大小,也就是每个训练批次的图像数量。首先,定义了一些 TensorFlow 占位符:一个用于图像的迷你批次,一个用于标签的迷你批次,最后一个用于选择是否进行训练(这主要由 dropout 层使用):
from sklearn.metrics import classification_report, confusion_matrix
def train_model(X_train, y_train, X_test, y_test, learning_rate, max_epochs, batch_size):
in_X_tensors_batch = tf.placeholder(tf.float32, shape = (None, RESIZED_IMAGE[0], RESIZED_IMAGE[1], 1))
in_y_tensors_batch = tf.placeholder(tf.float32, shape = (None, N_CLASSES))
is_training = tf.placeholder(tf.bool)
现在,让我们定义输出、度量分数和优化器。在这里,我们决定使用 AdamOptimizer 和交叉熵损失函数与 softmax(logits):
logits = model(in_X_tensors_batch, is_training)
out_y_pred = tf.nn.softmax(logits)
loss_score = tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=in_y_tensors_batch)
loss = tf.reduce_mean(loss_score)
optimizer = tf.train.AdamOptimizer(learning_rate).minimize(loss)
最后,这里是使用迷你批次训练模型的代码:
with tf.Session() as session:
session.run(tf.global_variables_initializer())
for epoch in range(max_epochs):
print("Epoch=", epoch)
tf_score = []
for mb in minibatcher(X_train, y_train, batch_size, shuffle = True):
tf_output = session.run([optimizer, loss],
feed_dict = {in_X_tensors_batch : mb[0],
in_y_tensors_batch :
b[1],
is_training : True})
tf_score.append(tf_output[1])
print(" train_loss_score=", np.mean(tf_score))
训练完成后,接下来就是在测试集上测试模型。这里,我们将使用整个测试集,而不是发送迷你批次。注意!is_training 应该设置为 False,因为我们不想使用 dropout:
print("TEST SET PERFORMANCE")
y_test_pred, test_loss = session.run([out_y_pred, loss],
feed_dict = {in_X_tensors_batch : X_test, in_y_tensors_batch : y_test, is_training : False})
最后一步,打印分类报告并绘制混淆矩阵(及其 log2 版本),查看误分类情况:
print(" test_loss_score=", test_loss)
y_test_pred_classified = np.argmax(y_test_pred, axis=1).astype(np.int32)
y_test_true_classified = np.argmax(y_test, axis=1).astype(np.int32)
print(classification_report(y_test_true_classified, y_test_pred_classified))
cm = confusion_matrix(y_test_true_classified, y_test_pred_classified)
plt.imshow(cm, interpolation='nearest', cmap=plt.cm.Blues)
plt.colorbar()
plt.tight_layout()
plt.show()
# And the log2 version, to enphasize the misclassifications
plt.imshow(np.log2(cm + 1), interpolation='nearest', cmap=plt.get_cmap("tab20"))
plt.colorbar()
plt.tight_layout()
plt.show()
tf.reset_default_graph()
最后,让我们用一些参数运行这个函数。在这里,我们将使用学习步长为 0.001,单次批次 256 个样本,以及 10 个周期来运行模型:
train_model(X_train, y_train, X_test, y_test, 0.001, 10, 256)
这是输出结果:
Epoch= 0
train_loss_score= 3.4909246
Epoch= 1
train_loss_score= 0.5096467
Epoch= 2
train_loss_score= 0.26641673
Epoch= 3
train_loss_score= 0.1706828
Epoch= 4
train_loss_score= 0.12737551
Epoch= 5
train_loss_score= 0.09745725
Epoch= 6
train_loss_score= 0.07730477
Epoch= 7
train_loss_score= 0.06734192
Epoch= 8
train_loss_score= 0.06815668
Epoch= 9
train_loss_score= 0.060291935
TEST SET PERFORMANCE
test_loss_score= 0.04581982
接下来是每个类别的分类报告:
precision recall f1-score support
0 1.00 0.96 0.98 67
1 0.99 0.99 0.99 539
2 0.99 1.00 0.99 558
3 0.99 0.98 0.98 364
4 0.99 0.99 0.99 487
5 0.98 0.98 0.98 479
6 1.00 0.99 1.00 105
7 1.00 0.98 0.99 364
8 0.99 0.99 0.99 340
9 0.99 0.99 0.99 384
10 0.99 1.00 1.00 513
11 0.99 0.98 0.99 334
12 0.99 1.00 1.00 545
13 1.00 1.00 1.00 537
14 1.00 1.00 1.00 213
15 0.98 0.99 0.98 164
16 1.00 0.99 0.99 98
17 0.99 0.99 0.99 281
18 1.00 0.98 0.99 286
19 1.00 1.00 1.00 56
20 0.99 0.97 0.98 78
21 0.97 1.00 0.98 95
22 1.00 1.00 1.00 97
23 1.00 0.97 0.98 123
24 1.00 0.96 0.98 77
25 0.99 1.00 0.99 401
26 0.98 0.96 0.97 135
27 0.94 0.98 0.96 60
28 1.00 0.97 0.98 123
29 1.00 0.97 0.99 69
30 0.88 0.99 0.93 115
31 1.00 1.00 1.00 178
32 0.98 0.96 0.97 55
33 0.99 1.00 1.00 177
34 0.99 0.99 0.99 103
35 1.00 1.00 1.00 277
36 0.99 1.00 0.99 78
37 0.98 1.00 0.99 63
38 1.00 1.00 1.00 540
39 1.00 1.00 1.00 60
40 1.00 0.98 0.99 85
41 1.00 1.00 1.00 47
42 0.98 1.00 0.99 53
avg / total 0.99 0.99 0.99 9803
如你所见,我们成功在测试集上达到了 0.99 的准确率;同时,召回率和 F1 分数也达到了相同的分数。模型看起来很稳定,因为测试集中的损失值与最后一次迭代报告的损失值相似;因此,我们既没有过拟合也没有欠拟合。
以及混淆矩阵:

以下是前述截图的 log2 版本:

后续问题
-
尝试添加/移除一些 CNN 层和/或全连接层。性能如何变化?
-
这个简单的项目证明了 dropout 对于正则化是必要的。改变 dropout 百分比,并检查输出中的过拟合与欠拟合情况。
-
现在,拍一张你所在城市的多个交通标志的照片,并在现实生活中测试训练好的模型!
总结
在本章中,我们学习了如何使用卷积神经网络(CNN)识别交通标志。在下一章中,我们将看到 CNN 可以做的一些更复杂的事情。
第二章:使用物体检测 API 注释图像
近年来,计算机视觉因深度学习取得了重大进展,从而赋予计算机更高的理解视觉场景的能力。深度学习在视觉任务中的潜力巨大:让计算机能够视觉感知和理解其周围环境是开启新型人工智能应用的大门,这些应用包括移动领域(例如,自动驾驶汽车能够从车载摄像头检测出障碍物是行人、动物还是其他车辆,并决定正确的行动路线)以及日常生活中的人机交互(例如,让机器人感知周围物体并成功与之互动)。
在第一章介绍了卷积神经网络(ConvNets)及其操作原理后,我们现在打算创建一个快速、简单的项目,帮助您使用计算机理解来自相机和手机拍摄的图像,图像可以来自互联网或直接来自计算机的摄像头。该项目的目标是找出图像中物体的准确位置和类型。
为了实现这种分类和定位,我们将利用新的 TensorFlow 物体检测 API,这是一个谷歌项目,属于更大的 TensorFlow 模型项目的一部分,该项目为您提供一系列预训练的神经网络,您可以将其直接用于您的自定义应用程序中。
在这一章中,我们将展示以下内容:
-
使用正确数据对您的项目的优势
-
TensorFlow 物体检测 API 简介
-
如何注释存储的图像以供进一步使用
-
如何使用
moviepy对视频进行视觉注释 -
如何通过注释来自网络摄像头的图像实现实时操作
微软常见物体语境数据集
深度学习在计算机视觉中的应用进展通常高度集中在可以通过诸如 ImageNet(但也包括 PASCAL VOC - host.robots.ox.ac.uk/pascal/VOC/voc2012/)等挑战进行总结的分类问题上,以及适合解决这些问题的卷积神经网络(如 Xception、VGG16、VGG19、ResNet50、InceptionV3 和 MobileNet,仅举出在知名包 Keras 中提供的几种: keras.io/applications/)。
尽管基于 ImageNet 数据的深度学习网络是当前的技术前沿,但这些网络在面对实际应用时可能会遇到困难。实际上,在实际应用中,我们必须处理的图像与 ImageNet 提供的示例有很大不同。在 ImageNet 中,待分类的元素通常是图像中唯一清晰可见的元素,理想情况下位于照片的中心,且无遮挡。然而,在实际拍摄的图像中,物体往往分散在不同位置,并且数量众多。所有这些物体之间的差异也很大,导致有时会出现混乱的场景。此外,物体往往会被其他潜在有趣的物体遮挡,使得它们无法被清晰直接地感知。
请参考以下提到的参考文献中的图示:
图 1:来自 ImageNet 的图像示例:它们按层次结构排列,既可以处理一般类别,也可以处理更具体的类别。
来源:DENG, Jia 等. ImageNet:一个大规模层次化图像数据库。载于:计算机视觉与模式识别,2009。CVPR 2009。IEEE 会议。IEEE,2009 年,248-255 页。
真实图像包含多个物体,这些物体有时很难与嘈杂的背景区分开。通常,单纯通过标记图像并用一个标签告诉你物体被识别出的置信度最高,你实际上无法创造出有趣的项目。
在实际应用中,你真的需要能够做到以下几点:
-
单个和多个实例的物体分类,通常是同一类别的不同物体
-
图像定位,即理解物体在图像中的位置
-
图像分割,通过为图像中的每个像素打上标签:标明物体类型或背景,以便能够从背景中切割出有趣的部分。
为了实现前述目标之一或全部目标,需要训练一个 ConvNet,这促成了微软常见物体上下文(MS COCO)数据集的创建,具体描述见论文:LIN, Tsung-Yi 等。Microsoft coco:常见物体的上下文。载于:欧洲计算机视觉会议。Springer,Cham,2014 年,740-755 页。(你可以通过以下链接阅读原文:arxiv.org/abs/1405.0312。)该数据集由 91 个常见物体类别组成,按层次结构排列,其中 82 个类别具有超过 5,000 个标记实例。该数据集总计包含 2,500,000 个标记物体,分布在 328,000 张图像中。
以下是 MS COCO 数据集中可以识别的类别:
{1: 'person', 2: 'bicycle', 3: 'car', 4: 'motorcycle', 5: 'airplane', 6: 'bus', 7: 'train', 8: 'truck', 9: 'boat', 10: 'traffic light', 11: 'fire hydrant', 13: 'stop sign', 14: 'parking meter', 15: 'bench', 16: 'bird', 17: 'cat', 18: 'dog', 19: 'horse', 20: 'sheep', 21: 'cow', 22: 'elephant', 23: 'bear', 24: 'zebra', 25: 'giraffe', 27: 'backpack', 28: 'umbrella', 31: 'handbag', 32: 'tie', 33: 'suitcase', 34: 'frisbee', 35: 'skis', 36: 'snowboard', 37: 'sports ball', 38: 'kite', 39: 'baseball bat', 40: 'baseball glove', 41: 'skateboard', 42: 'surfboard', 43: 'tennis racket', 44: 'bottle', 46: 'wine glass', 47: 'cup', 48: 'fork', 49: 'knife', 50: 'spoon', 51: 'bowl', 52: 'banana', 53: 'apple', 54: 'sandwich', 55: 'orange', 56: 'broccoli', 57: 'carrot', 58: 'hot dog', 59: 'pizza', 60: 'donut', 61: 'cake', 62: 'chair', 63: 'couch', 64: 'potted plant', 65: 'bed', 67: 'dining table', 70: 'toilet', 72: 'tv', 73: 'laptop', 74: 'mouse', 75: 'remote', 76: 'keyboard', 77: 'cell phone', 78: 'microwave', 79: 'oven', 80: 'toaster', 81: 'sink', 82: 'refrigerator', 84: 'book', 85: 'clock', 86: 'vase', 87: 'scissors', 88: 'teddy bear', 89: 'hair drier', 90: 'toothbrush'}
尽管ImageNet数据集可以展示 1,000 个物体类别(如gist.github.com/yrevar/942d3a0ac09ec9e5eb3a所述),并且分布在 14,197,122 张图像中,MS COCO 则提供了一个独特的特点:多个物体分布在较少的图像中(该数据集是通过亚马逊 Mechanical Turk 收集的,这种方法相对更为昂贵,但也被 ImageNet 采用)。基于这一前提,MS COCO 的图像可以被视为上下文关系和非图标物体视图的极好示例,因为物体被安排在现实的场景和位置中。从下文中可以验证这一点,这是从上述提到的 MS COCO 论文中提取的比较示例:

图 2:图标和非图标图像的示例。来源:LIN, Tsung-Yi, 等. Microsoft coco: common objects in context. 在:欧洲计算机视觉会议。Springer, Cham, 2014。p. 740-755。
此外,MS COCO 的图像注释特别丰富,提供了图像中物体轮廓的坐标。这些轮廓可以轻松转换为边界框,即限定物体所在图像部分的框。这是一种比用于训练 MS COCO 本身的原始方法(基于像素分割)更粗略的物体定位方式。
在下图中,一排拥挤的物体被仔细分割,通过定义图像中的显著区域并创建这些区域的文本描述。在机器学习的术语中,这相当于为图像中的每个像素分配一个标签,并尝试预测分割类别(对应于文本描述)。历史上,这一任务一直通过图像处理完成,直到 2012 年 ImageNet 的出现,深度学习证明是一种更高效的解决方案。
2012 年是计算机视觉的一个里程碑,因为深度学习解决方案首次提供了比任何之前使用的技术都要优越的结果:KRIZHEVSKY, Alex; SUTSKEVER, Ilya; HINTON, Geoffrey E. 使用深度卷积神经网络进行 ImageNet 分类。 在:神经信息处理系统的进展。2012 年。p. 1097-1105
(papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks.pdf)。
图像分割对于多种任务特别有用,例如:
-
在图像中突出显示重要物体,例如在医学应用中检测患病区域
-
在图像中定位物体,以便机器人能够拾取或操作它们
-
帮助自动驾驶汽车或无人机理解道路场景以进行导航
-
通过自动提取图像的一部分或去除背景来编辑图像
这种标注非常昂贵(因此 MS COCO 中的样本数量较少),因为它必须完全手动完成,且需要细心和精确。有一些工具可以帮助通过图像分割来进行标注。你可以在stackoverflow.com/questions/8317787/image-labelling-and-annotation-tool找到一个完整的工具列表。然而,如果你想自己通过分割图像进行标注,我们可以推荐以下两款工具:
-
LabelImg
github.com/tzutalin/labelImg -
FastAnnotationTool
github.com/christopher5106/FastAnnotationTool
所有这些工具也可以用于通过边界框进行更简单的标注,如果你想使用自己定义的类别重新训练一个基于 MS COCO 的模型,它们确实会派上用场。(我们将在本章最后再次提到这一点):

在 MS COCO 训练阶段使用的图像像素分割
TensorFlow 目标检测 API
作为提升研究社区能力的一种方式,Google 的研究科学家和软件工程师通常会开发最先进的模型,并将其公开,而不是保留为专有技术。正如 Google 研究博客中所描述的, research.googleblog.com/2017/06/supercharge-your-computer-vision-models.html,在 2016 年 10 月,Google 内部的目标检测系统在 COCO 检测挑战赛中获得第一名,该挑战赛专注于在图像中找到物体(估算物体在该位置的可能性)及其边界框(你可以在arxiv.org/abs/1611.10012阅读他们解决方案的技术细节)。
Google 的解决方案不仅为很多论文做出了贡献,并且已被应用到一些 Google 产品中(Nest Cam - nest.com/cameras/nest-aware/,图片搜索 - www.blog.google/products/search/now-image-search-can-jump-start-your-search-style/,街景 - research.googleblog.com/2017/05/updating-google-maps-with-deep-learning.html),同时也作为一个开源框架发布给更广泛的公众,建立在 TensorFlow 之上。
该框架提供了一些有用的功能以及这五个预训练的不同模型(构成了所谓的预训练模型库):
-
基于 MobileNets 的单次检测多框(SSD)
-
基于 Inception V2 的 SSD
-
基于 Resnet 101 的区域卷积神经网络(R-FCN)
-
基于 Resnet 101 的 Faster R-CNN
-
使用 Inception Resnet v2 的 Faster R-CNN
这些模型按照检测精度的逐渐提高以及检测过程执行速度的逐渐减慢排序。MobileNets、Inception 和 Resnet 指的是不同类型的卷积神经网络(CNN)架构(MobileNets,顾名思义,是为手机优化的架构,体积小、执行速度快)。我们在上一章已经讨论过 CNN 架构,您可以参考那里以获取更多关于这些架构的深入信息。如果您需要复习,Joice Xu 撰写的这篇博客文章可以帮助您以一种简便的方式复习该主题:towardsdatascience.com/an-intuitive-guide-to-deep-network-architectures-65fdc477db41。
单次多框检测器(SSD)、基于区域的全卷积网络(R-FCN)和 更快的基于区域的卷积神经网络(Faster R-CNN)是用于检测图像中多个物体的不同模型。在接下来的段落中,我们将解释这些模型如何有效工作。
根据你的应用,你可以选择最适合你的模型(你需要进行一些实验),或者将多个模型的结果汇总,以获得更好的效果(正如 Google 的研究人员在 COCO 竞赛中所做的那样)。
掌握 R-CNN、R-FCN 和 SSD 模型的基础知识
即使你已经清楚地知道 CNN 如何管理图像分类,理解神经网络如何通过定义物体的边界框(围绕物体的矩形边界)来定位图像中的多个物体,可能仍然不那么显而易见。你可能想象的第一个也是最简单的解决方案是使用滑动窗口,并在每个窗口上应用 CNN,但对于大多数现实世界的应用来说,这可能非常耗费计算资源(如果你为自动驾驶汽车提供视觉系统,你肯定希望它在撞到障碍物之前就能识别并停下来)。
你可以在 Adrian Rosebrock 撰写的这篇博客文章中找到更多关于物体检测的滑动窗口方法:www.pyimagesearch.com/2015/03/23/sliding-windows-for-object-detection-with-python-and-opencv/,该文章通过将其与图像金字塔配对,提供了一个有效的示例。
尽管滑动窗口方法在一定程度上直观,但由于其复杂性和计算上的笨重(需要进行大量计算并处理不同图像尺度),滑动窗口有许多局限性,因此很快就找到了一个优选的解决方案——区域提议算法。这些算法使用图像分割(即基于区域之间主要颜色差异将图像划分为多个区域)来创建图像中可能的边界框的初步枚举。你可以在 Satya Mallik 的这篇文章中找到详细的算法工作原理:www.learnopencv.com/selective-search-for-object-detection-cpp-python/。关键是,区域提议算法建议一个有限数量的框进行评估,远远少于滑动窗口算法提议的数量。这使得它们能够应用于第一个 R-CNN——基于区域的卷积神经网络,工作原理为:
-
得益于区域提议算法,找到图像中的几百个或几千个感兴趣区域。
-
通过 CNN 处理每个感兴趣区域,以便为每个区域创建特征。
-
使用特征通过支持向量机分类区域,并通过线性回归计算更精确的边界框。
R-CNN 的直接演化是 Fast R-CNN,它使得处理速度更快,因为:
-
它一次性处理整张图像,通过 CNN 进行转换,并将区域提议应用于该转换。这将 CNN 处理的次数从几千次减少到一次。
-
它不是使用 SVM 进行分类,而是使用 soft-max 层和线性分类器,从而简单地扩展了 CNN,而不是将数据传递给不同的模型。
本质上,通过使用 Fast R-CNN,我们再次得到了一个单一的分类网络,该网络具有一个基于非神经网络算法的特殊过滤和选择层,即区域提议层。Faster R-CNN 甚至改变了这个层,将其替换为区域提议神经网络。这使得模型变得更加复杂,但比任何以前的方法更有效且更快速。
不过,R-FCN 比 Faster R-CNN 还要快,因为它们是完全卷积网络,在卷积层之后不使用任何全连接层。它们是端到端的网络:从输入到输出,都是通过卷积完成。这使得它们更加快速(它们的权重数量远少于使用全连接层的 CNN)。但它们的速度是有代价的,它们不再具备图像不变性(CNN 能够识别物体的类别,无论物体如何旋转)。Faster R-CNN 通过位置敏感的得分图来弥补这一缺陷,这是一种检查 FCN 处理的原始图像的部分是否与待分类的类别部分对应的方法。简而言之,它们并不直接对比类别,而是对比类别的部分。例如,它们不会识别一只狗,而是识别狗的左上部分、右下部分,依此类推。这种方法能够帮助识别图像的某部分是否包含狗,不管它的方向如何。显然,这种更快速的方法以牺牲精度为代价,因为位置敏感得分图无法完全补充 CNN 的所有特征。
最后,我们来谈谈 SSD(单次检测器)。这里的速度更快,因为网络在处理图像的同时同时预测边界框的位置及其类别。SSD 通过直接跳过区域提议阶段来计算大量的边界框。它只是减少了高度重叠的框,但相比之前提到的所有模型,它处理的边界框数量最大。它的速度是因为每次限定一个边界框时,它也会对其进行分类:通过一举完成所有任务,它拥有最快的速度,尽管其表现与其他模型相当。
Joice Xu 的另一篇简短文章可以为你提供更多我们之前讨论的检测模型的细节:towardsdatascience.com/deep-learning-for-object-detection-a-comprehensive-review-73930816d8d9
总结所有讨论内容,在选择网络时,你需要考虑的是,你正在将不同的 CNN 架构与分类能力和网络复杂性结合在一起,并与不同的检测模型结合。正是它们的综合效应决定了网络在识别物体、正确分类物体以及及时完成这些任务的能力。
如果您希望获得更多关于我们简要说明的模型的速度和精度参考,可以参考:现代卷积物体检测器的速度/精度权衡。黄俊,Rathod V,孙成,朱敏,Korattikara A,Fathi A,Fischer I,Wojna Z,宋阳,Guadarrama S,Murphy K,CVPR 2017:openaccess.thecvf.com/content_cvpr_2017/papers/Huang_SpeedAccuracy_Trade-Offs_for_CVPR_2017_paper.pdf 但我们还是建议您在实践中测试这些模型,以评估它们是否足够适合您的任务,并且是否能在合理的时间内执行。然后,您只需根据您的应用做出最佳的速度/精度权衡。
展示我们的项目计划
由于 TensorFlow 提供了如此强大的工具,我们的计划是通过创建一个类来利用它的 API,您可以使用该类对图像进行视觉和外部文件标注。这里所说的标注包括以下内容:
-
指出图像中的物体(由在 MS COCO 上训练的模型识别)
-
报告物体识别的置信度(我们只考虑置信度超过最低概率阈值的物体,该阈值设为 0.25,基于之前提到的现代卷积物体检测器的速度/精度权衡论文中的讨论)
-
输出每张图像的边界框两个对角顶点的坐标
-
将所有这些信息以 JSON 格式保存在文本文件中
-
如有需要,在原始图像上可视化表示边界框
为了实现这些目标,我们需要:
-
下载一个预训练模型(以
.pb格式提供 - protobuf),并将其作为 TensorFlow 会话加载到内存中。 -
重新构建 TensorFlow 提供的辅助代码,使得通过一个可以轻松导入脚本的类,加载标签、类别和可视化工具变得更加容易。
-
准备一个简单的脚本,演示如何使用单张图像、视频和从网络摄像头捕获的视频。
我们首先通过设置适合项目的环境来开始。
为项目设置适当的环境
您不需要任何专业环境来运行该项目,尽管我们强烈建议安装 Anaconda conda并为项目创建一个独立环境。如果您的系统中已有conda,则按照以下说明运行:
conda create -n TensorFlow_api python=3.5 numpy pillow
activate TensorFlow_api
启动环境后,您可以安装其他一些包,使用pip install命令或conda install命令指向其他仓库(如menpo,conda-forge):
pip install TensorFlow-gpu
conda install -c menpo opencv
conda install -c conda-forge imageio
pip install tqdm, moviepy
如果您倾向于另一种运行该项目的方式,请确保您已安装numpy,pillow,TensorFlow,opencv,imageio,tqdm和moviepy,以便顺利运行。
为了确保一切顺利运行,你还需要为你的项目创建一个目录,并将 TensorFlow 目标检测 API 项目的object_detection目录保存到其中(github.com/tensorflow/models/tree/master/research/object_detection)。
你可以通过在整个 TensorFlow 模型项目中使用git命令并选择性地拉取该目录来轻松获取。这在你的 Git 版本为 1.7.0(2012 年 2 月)或以上时是可行的:
mkdir api_project
cd api_project
git init
git remote add -f origin https://github.com/tensorflow/models.git
这些命令将获取 TensorFlow 模型项目中的所有对象,但它不会检出它们。通过执行这些前面的命令后:
git config core.sparseCheckout true
echo "research/object_detection/*" >> .git/info/sparse-checkout
git pull origin master
现在你只会在文件系统中看到object_detection目录及其内容,并且没有其他目录或文件。
只需记住,该项目需要访问object_detection目录,因此你必须将项目脚本保存在与object_detection目录相同的目录中。如果要在该目录外使用脚本,你需要使用完整路径访问它。
Protobuf 编译
TensorFlow 目标检测 API 使用 protobufs(协议缓冲区)——Google 的数据交换格式(github.com/google/protobuf),用于配置模型及其训练参数。在框架使用之前,必须编译 protobuf 库,如果你使用的是 Unix(Linux 或 Mac)或 Windows 操作系统环境,步骤会有所不同。
Windows 安装
首先,解压protoc-3.2.0-win32.zip,它可以在github.com/google/protobuf/releases找到,将其解压到项目文件夹中。现在你应该会看到一个新的protoc-3.4.0-win32目录,其中包含一个readme.txt文件和两个目录:bin和include。这些文件夹包含了协议缓冲区编译器(protoc)的预编译二进制版本。你需要做的就是将protoc-3.4.0-win32目录添加到系统路径中。
将其添加到系统路径后,你可以执行以下命令:
protoc-3.4.0-win32/bin/protoc.exe object_detection/protos/*.proto --python_out=.
这应该足够让 TensorFlow 目标检测 API 在你的计算机上运行。
Unix 安装
对于 Unix 环境,安装过程可以通过 shell 命令完成,只需按照github.com/tensorflow/models/blob/master/research/object_detection/g3doc/installation.md上的说明进行操作。
项目代码的配置
我们开始在文件tensorflow_detection.py中编写脚本,通过加载必要的包:
import os
import numpy as np
import tensorflow as tf
import six.moves.urllib as urllib
import tarfile
from PIL import Image
from tqdm import tqdm
from time import gmtime, strftime
import json
import cv2
为了能够处理视频,除了 OpenCV 3,我们还需要moviepy包。moviepy包是一个可以在zulko.github.io/moviepy/找到并自由使用的项目,因为它是以 MIT 许可证分发的。正如其主页所描述的,moviepy是一个用于视频编辑(如剪辑、拼接、标题插入)、视频合成(非线性编辑)、视频处理或创建高级效果的工具。
该包支持最常见的视频格式,包括 GIF 格式。为了正确操作,它需要FFmpeg转换器(www.ffmpeg.org/),因此在首次使用时,它将无法启动并会通过imageio插件下载FFmpeg:
try:
from moviepy.editor import VideoFileClip
except:
# If FFmpeg (https://www.ffmpeg.org/) is not found
# on the computer, it will be downloaded from Internet
# (an Internet connect is needed)
import imageio
imageio.plugins.ffmpeg.download()
from moviepy.editor import VideoFileClip
最后,我们需要 TensorFlow API 项目中object_detection目录下的两个有用函数:
from object_detection.utils import label_map_util
from object_detection.utils import visualization_utils as vis_util
我们定义了DetectionObj类及其init过程。初始化只需要一个参数和模型名称(默认设置为性能较差,但速度较快且更轻量级的模型,SSD MobileNet),但一些内部参数可以根据你的使用情况进行更改:
-
self.TARGET_PATH指向你希望保存处理后注释的目录。 -
self.THRESHOLD设置了注释过程要注意的概率阈值。事实上,套件中的任何模型都会在每张图片中输出许多低概率的检测。概率太低的对象通常是误报,因此你需要设置阈值,忽略那些极不可能的检测。根据经验,0.25 是一个不错的阈值,用于识别由于几乎完全遮挡或视觉杂乱而不确定的对象。
class DetectionObj(object):
"""
DetectionObj is a class suitable to leverage
Google Tensorflow detection API for image annotation from
different sources: files, images acquired by own's webcam,
videos.
"""
def __init__(self, model='ssd_mobilenet_v1_coco_11_06_2017'):
"""
The instructions to be run when the class is instantiated
"""
# Path where the Python script is being run
self.CURRENT_PATH = os.getcwd()
# Path where to save the annotations (it can be modified)
self.TARGET_PATH = self.CURRENT_PATH
# Selection of pre-trained detection models
# from the Tensorflow Model Zoo
self.MODELS = ["ssd_mobilenet_v1_coco_11_06_2017",
"ssd_inception_v2_coco_11_06_2017",
"rfcn_resnet101_coco_11_06_2017",
"faster_rcnn_resnet101_coco_11_06_2017",
"faster_rcnn_inception_resnet_v2_atrous_\
coco_11_06_2017"]
# Setting a threshold for detecting an object by the models
self.THRESHOLD = 0.25 # Most used threshold in practice
# Checking if the desired pre-trained detection model is available
if model in self.MODELS:
self.MODEL_NAME = model
else:
# Otherwise revert to a default model
print("Model not available, reverted to default", self.MODELS[0])
self.MODEL_NAME = self.MODELS[0]
# The file name of the Tensorflow frozen model
self.CKPT_FILE = os.path.join(self.CURRENT_PATH, 'object_detection',
self.MODEL_NAME,
'frozen_inference_graph.pb')
# Attempting loading the detection model,
# if not available on disk, it will be
# downloaded from Internet
# (an Internet connection is required)
try:
self.DETECTION_GRAPH = self.load_frozen_model()
except:
print ('Couldn\'t find', self.MODEL_NAME)
self.download_frozen_model()
self.DETECTION_GRAPH = self.load_frozen_model()
# Loading the labels of the classes recognized by the detection model
self.NUM_CLASSES = 90
path_to_labels = os.path.join(self.CURRENT_PATH,
'object_detection', 'data',
'mscoco_label_map.pbtxt')
label_mapping = \
label_map_util.load_labelmap(path_to_labels)
extracted_categories = \
label_map_util.convert_label_map_to_categories(
label_mapping, max_num_classes=self.NUM_CLASSES,
use_display_name=True)
self.LABELS = {item['id']: item['name'] \
for item in extracted_categories}
self.CATEGORY_INDEX = label_map_util.create_category_index\
(extracted_categories)
# Starting the tensorflow session
self.TF_SESSION = tf.Session(graph=self.DETECTION_GRAPH)
作为一个方便的变量,你可以访问self.LABELS,它包含一个将类别数字代码与文本表示相对应的字典。此外,init过程将加载、打开并准备好使用TensorFlow会话,位于self.TF_SESSION。
load_frozen_model和download_frozen_model函数将帮助init过程从磁盘加载所选的冻结模型,如果模型不可用,它们将帮助从互联网下载该模型作为 TAR 文件,并将其解压到正确的目录(即object_detection):
def load_frozen_model(self):
"""
Loading frozen detection model in ckpt
file from disk to memory
"""
detection_graph = tf.Graph()
with detection_graph.as_default():
od_graph_def = tf.GraphDef()
with tf.gfile.GFile(self.CKPT_FILE, 'rb') as fid:
serialized_graph = fid.read()
od_graph_def.ParseFromString(serialized_graph)
tf.import_graph_def(od_graph_def, name='')
return detection_graph
download_frozen_model函数利用tqdm包来可视化其下载新模型时的进度。某些模型相当大(超过 600MB),可能需要很长时间。提供进度和预计完成时间的可视化反馈将使用户对操作的进展更加有信心:
def download_frozen_model(self):
"""
Downloading frozen detection model from Internet
when not available on disk
"""
def my_hook(t):
"""
Wrapping tqdm instance in order to monitor URLopener
"""
last_b = [0]
def inner(b=1, bsize=1, tsize=None):
if tsize is not None:
t.total = tsize
t.update((b - last_b[0]) * bsize)
last_b[0] = b
return inner
# Opening the url where to find the model
model_filename = self.MODEL_NAME + '.tar.gz'
download_url = \
'http://download.tensorflow.org/models/object_detection/'
opener = urllib.request.URLopener()
# Downloading the model with tqdm estimations of completion
print('Downloading ...')
with tqdm() as t:
opener.retrieve(download_url + model_filename,
model_filename, reporthook=my_hook(t))
# Extracting the model from the downloaded tar file
print ('Extracting ...')
tar_file = tarfile.open(model_filename)
for file in tar_file.getmembers():
file_name = os.path.basename(file.name)
if 'frozen_inference_graph.pb' in file_name:
tar_file.extract(file,
os.path.join(self.CURRENT_PATH,
'object_detection'))
以下两个函数,load_image_from_disk和load_image_into_numpy_array,是从磁盘中选择图像并将其转换为适合本项目中任何 TensorFlow 模型处理的 Numpy 数组所必需的:
def load_image_from_disk(self, image_path):
return Image.open(image_path)
def load_image_into_numpy_array(self, image):
try:
(im_width, im_height) = image.size
return np.array(image.getdata()).reshape(
(im_height, im_width, 3)).astype(np.uint8)
except:
# If the previous procedure fails, we expect the
# image is already a Numpy ndarray
return image
detect函数则是该类分类功能的核心。该函数仅期望处理图像列表。一个布尔标志annotate_on_image仅告诉脚本在提供的图像上直接可视化边界框和注释。
这样的函数能够依次处理不同尺寸的图像,但需要逐个图像进行处理。因此,它会将每个图像扩展数组的维度,增加一个额外的维度。这是必要的,因为模型期望数组的尺寸为:图像数量 * 高度 * 宽度 * 深度。
注意,我们可以将所有批次的图像打包成一个矩阵进行预测。这是可行的,并且如果所有图像的高度和宽度相同,它会更快,但我们的项目并未做出这一假设,因此采用了逐个图像处理的方法。
然后,我们通过名称从模型中提取几个张量(detection_boxes、detection_scores、detection_classes、num_detections),这些正是我们期望从模型中得到的输出,我们将所有内容输入到image_tensor张量中,它会将图像标准化成适合模型层处理的形式。
结果被汇总成一个列表,图像经过检测框处理并在需要时呈现:
def detect(self, images, annotate_on_image=True):
"""
Processing a list of images, feeding it
into the detection model and getting from it scores,
bounding boxes and predicted classes present
in the images
"""
if type(images) is not list:
images = [images]
results = list()
for image in images:
# the array based representation of the image will
# be used later in order to prepare the resulting
# image with boxes and labels on it.
image_np = self.load_image_into_numpy_array(image)
# Expand dimensions since the model expects images
# to have shape: [1, None, None, 3]
image_np_expanded = np.expand_dims(image_np, axis=0)
image_tensor = \
self.DETECTION_GRAPH.get_tensor_by_name(
'image_tensor:0')
# Each box represents a part of the image where a
# particular object was detected.
boxes = self.DETECTION_GRAPH.get_tensor_by_name(
'detection_boxes:0')
# Each score represent how level of confidence
# for each of the objects. Score could be shown
# on the result image, together with the class label.
scores = self.DETECTION_GRAPH.get_tensor_by_name(
'detection_scores:0')
classes = self.DETECTION_GRAPH.get_tensor_by_name(
'detection_classes:0')
num_detections = \
self.DETECTION_GRAPH.get_tensor_by_name(
'num_detections:0')
# Actual detection happens here
(boxes, scores, classes, num_detections) = \
self.TF_SESSION.run(
[boxes, scores, classes, num_detections],
feed_dict={image_tensor: image_np_expanded})
if annotate_on_image:
new_image = self.detection_on_image(
image_np, boxes, scores, classes)
results.append((new_image, boxes,
scores, classes, num_detections))
else:
results.append((image_np, boxes,
scores, classes, num_detections))
return results
detection_on_image函数仅处理来自detect函数的结果,并返回一张新图像,这张图像通过边界框进行丰富,边界框将在屏幕上通过visualize_image函数呈现(你可以调整延迟参数,该参数定义了图像在屏幕上停留的秒数,脚本会在此后处理下一个图像)。
def detection_on_image(self, image_np, boxes, scores,
classes):
"""
Put detection boxes on the images over
the detected classes
"""
vis_util.visualize_boxes_and_labels_on_image_array(
image_np,
np.squeeze(boxes),
np.squeeze(classes).astype(np.int32),
np.squeeze(scores),
self.CATEGORY_INDEX,
use_normalized_coordinates=True,
line_thickness=8)
return image_np
visualize_image函数提供了一些可以修改的参数,以满足你在本项目中的需求。首先,image_size提供了图像在屏幕上显示的期望大小。因此,较大或较小的图像会被修改,以部分符合这个规定的尺寸。latency参数则定义了每张图像在屏幕上显示的时间(秒),即在转向处理下一张图像之前,锁定目标检测过程。最后,bluish_correction是当图像以BGR格式提供时应用的修正(在这种格式中,颜色通道按:蓝色-绿色-红色的顺序排列,这是 OpenCV 库的标准:stackoverflow.com/questions/14556545/why-opencv-using-bgr-colour-space-instead-of-rgb),而不是模型预期的RGB(红色-绿色-蓝色)图像格式。
def visualize_image(self, image_np, image_size=(400, 300),
latency=3, bluish_correction=True):
height, width, depth = image_np.shape
reshaper = height / float(image_size[0])
width = int(width / reshaper)
height = int(height / reshaper)
id_img = 'preview_' + str(np.sum(image_np))
cv2.startWindowThread()
cv2.namedWindow(id_img, cv2.WINDOW_NORMAL)
cv2.resizeWindow(id_img, width, height)
if bluish_correction:
RGB_img = cv2.cvtColor(image_np, cv2.COLOR_BGR2RGB)
cv2.imshow(id_img, RGB_img)
else:
cv2.imshow(id_img, image_np)
cv2.waitKey(latency*1000)
注释由serialize_annotations函数准备并写入磁盘,该函数会为每个图像创建单独的 JSON 文件,包含关于检测到的类别、边界框的顶点以及检测置信度的数据。例如,这是在狗的照片上进行检测的结果:
"{"scores": [0.9092628359794617], "classes": ["dog"], "boxes": [[0.025611668825149536, 0.22220897674560547, 0.9930437803268433, 0.7734537720680237]]}"
JSON 文件指出检测到的类别(单一的狗)、置信度(大约 0.91 的置信度),以及边界框的顶点,并以百分比表示图像的高度和宽度(因此它们是相对的,而不是绝对像素点):
def serialize_annotations(self, boxes, scores, classes, filename='data.json'):
"""
Saving annotations to disk, to a JSON file
"""
threshold = self.THRESHOLD
valid = [position for position, score in enumerate(
scores[0]) if score > threshold]
if len(valid) > 0:
valid_scores = scores[0][valid].tolist()
valid_boxes = boxes[0][valid].tolist()
valid_class = [self.LABELS[int(
a_class)] for a_class in classes[0][valid]]
with open(filename, 'w') as outfile:
json_data = {'classes': valid_class,
'boxes':valid_boxes, 'scores': valid_scores})
json.dump(json_data, outfile)
get_time函数方便地将当前时间转换为可以用于文件名的字符串:
def get_time(self):
"""
Returning a string reporting the actual date and time
"""
return strftime("%Y-%m-%d_%Hh%Mm%Ss", gmtime())
最后,我们准备了三条检测管道,分别用于图像、视频和网络摄像头。图像的管道将每个图像加载到一个列表中。视频的管道允许moviepy的VideoFileClip模块进行所有繁重的处理,只需将detect函数适当地包装在annotate_photogram函数中。最后,网络摄像头捕获的管道依赖于一个简单的capture_webcam函数,它基于 OpenCV 的 VideoCapture,从网络摄像头记录多个快照,只返回最后一张(该操作考虑了网络摄像头在适应环境光照水平前所需的时间):
def annotate_photogram(self, photogram):
"""
Annotating a video's photogram with bounding boxes
over detected classes
"""
new_photogram, boxes, scores, classes, num_detections =
self.detect(photogram)[0]
return new_photogram
capture_webcam函数将使用cv2.VideoCapture功能从你的网络摄像头获取图像(docs.opencv.org/3.0-beta/modules/videoio/doc/reading_and_writing_video.html)。由于网络摄像头首先需要调整光照条件以适应拍照环境,因此该过程会丢弃一些初始快照,之后再拍摄用于目标检测的图像。通过这种方式,网络摄像头有足够时间调整光照设置:
def capture_webcam(self):
"""
Capturing an image from the integrated webcam
"""
def get_image(device):
"""
Internal function to capture a single image
from the camera and return it in PIL format
"""
retval, im = device.read()
return im
# Setting the integrated webcam
camera_port = 0
# Number of frames to discard as the camera
# adjusts to the surrounding lights
ramp_frames = 30
# Initializing the webcam by cv2.VideoCapture
camera = cv2.VideoCapture(camera_port)
# Ramping the camera - all these frames will be
# discarded as the camera adjust to the right light levels
print("Setting the webcam")
for i in range(ramp_frames):
_ = get_image(camera)
# Taking the snapshot
print("Now taking a snapshot ... ", end='')
camera_capture = get_image(camera)
print('Done')
# releasing the camera and making it reusable
del (camera)
return camera_capture
file_pipeline包括加载图像并可视化/注释它们所需的所有步骤:
-
从磁盘加载图像。
-
对加载的图像应用目标检测。
-
将每个图像的注释写入一个 JSON 文件。
-
如果布尔参数
visualize需要,代表带有边界框的图像并显示在计算机屏幕上:
def file_pipeline(self, images, visualize=True):
"""
A pipeline for processing and annotating lists of
images to load from disk
"""
if type(images) is not list:
images = [images]
for filename in images:
single_image = self.load_image_from_disk(filename)
for new_image, boxes, scores, classes, num_detections in
self.detect(single_image):
self.serialize_annotations(boxes, scores, classes,
filename=filename + ".json")
if visualize:
self.visualize_image(new_image)
video_pipeline简单地安排了注释视频所需的所有步骤,并在完成操作后将其保存到磁盘:
def video_pipeline(self, video, audio=False):
"""
A pipeline to process a video on disk and annotating it
by bounding box. The output is a new annotated video.
"""
clip = VideoFileClip(video)
new_video = video.split('/')
new_video[-1] = "annotated_" + new_video[-1]
new_video = '/'.join(new_video)
print("Saving annotated video to", new_video)
video_annotation = clip.fl_image(self.annotate_photogram)
video_annotation.write_videofile(new_video, audio=audio)
webcam_pipeline是当你想要注释来自网络摄像头的图像时,安排所有步骤的函数:
-
从网络摄像头捕获图像。
-
将捕获的图像保存到磁盘(使用
cv2.imwrite,其优势是根据目标文件名写入不同的图像格式,详见:docs.opencv.org/3.0-beta/modules/imgcodecs/doc/reading_and_writing_images.html) -
对图像应用目标检测。
-
保存注释的 JSON 文件。
-
直观表示带有边界框的图像:
def webcam_pipeline(self):
"""
A pipeline to process an image acquired by the internal webcam
and annotate it, saving a JSON file to disk
"""
webcam_image = self.capture_webcam()
filename = "webcam_" + self.get_time()
saving_path = os.path.join(self.CURRENT_PATH, filename + ".jpg")
cv2.imwrite(saving_path, webcam_image)
new_image, boxes, scores, classes, num_detections =
self.detect(webcam_image)[0]
json_obj = {'classes': classes, 'boxes':boxes, 'scores':scores}
self.serialize_annotations(boxes, scores, classes, filename=filename+".json")
self.visualize_image(new_image, bluish_correction=False)
一些简单的应用
作为代码提供部分的结尾,我们展示了三个简单的脚本,利用了我们项目中的三种不同数据来源:文件、视频和摄像头。
我们的第一个测试脚本旨在注释和可视化三张图像,前提是从本地目录导入类DetectionObj(如果你在其他目录下操作,除非你将项目目录添加到 Python 路径中,否则导入将无法工作)。
为了在脚本中将目录添加到 Python 路径中,你只需在需要访问该目录的脚本部分之前放置sys.path.insert命令:
import sys
sys.path.insert(0,'/path/to/directory')
然后我们激活该类,使用 SSD MobileNet v1 模型进行声明。接着,我们需要将每个图像的路径放入一个列表中,并传递给file_pipeline方法:
from TensorFlow_detection import DetectionObj
if __name__ == "__main__":
detection = DetectionObj(model='ssd_mobilenet_v1_coco_11_06_2017')
images = ["./sample_images/intersection.jpg",
"./sample_images/busy_street.jpg", "./sample_images/doge.jpg"]
detection.file_pipeline(images)
我们在检测类应用到交叉口图像后收到的输出,将返回另一个图像,图像中包含了足够置信度的边界框,框住了识别出的物体:

在交叉口照片上进行 SSD MobileNet v1 物体检测
运行脚本后,所有三张图像将带有注释在屏幕上显示(每张显示三秒钟),并且一个新的 JSON 文件将被写入磁盘(写入目标目录,如果你没有通过修改类变量TARGET_CLASS来特别指定它,该目录将默认为本地目录)。
在可视化中,你将看到所有与物体相关的边界框,前提是预测置信度高于 0.5。无论如何,你会注意到,在这种交叉口图像的注释案例中(如前图所示),并不是所有的汽车和行人都被模型识别到。
查看 JSON 文件后,你会发现模型还定位了许多其他汽车和行人,尽管置信度较低。在该文件中,你会找到所有置信度至少为 0.25 的物体,这一阈值在许多物体检测研究中是常见的标准(但你可以通过修改类变量THRESHOLD来更改它)。
在这里,你可以看到 JSON 文件中生成的分数。只有八个检测到的物体的分数高于可视化阈值 0.5,而其他 16 个物体的分数较低:
"scores": [0.9099398255348206, 0.8124723434448242, 0.7853631973266602, 0.709653913974762, 0.5999227166175842, 0.5942907929420471, 0.5858771800994873, 0.5656214952468872, 0.49047672748565674, 0.4781857430934906, 0.4467884600162506, 0.4043623208999634, 0.40048354864120483, 0.38961756229400635, 0.35605812072753906, 0.3488095998764038, 0.3194449841976166, 0.3000411093235016, 0.294520765542984, 0.2912806570529938, 0.2889115810394287, 0.2781482934951782, 0.2767323851585388, 0.2747304439544678]
在这里你可以找到被检测物体的相关类别。许多汽车被以较低的置信度检测到。它们可能确实是图像中的汽车,也可能是错误的识别。根据你对检测 API 的应用,你可能想要调整阈值,或者使用另一种模型,只有当物体被不同模型重复检测,并且置信度超过阈值时,才估计它是一个有效的物体:
"classes": ["car", "person", "person", "person", "person", "car", "car", "person", "person", "person", "person", "person", "person", "person", "car", "car", "person", "person", "car", "car", "person", "car", "car", "car"]
将检测应用于视频使用相同的脚本方法。这一次,您只需指向适当的方法video_pipeline,提供视频路径,并设置结果视频是否应包含音频(默认情况下音频会被过滤掉)。脚本将自动完成所有操作,在与原始视频相同的目录路径下保存一个经过修改和注释的视频(您可以通过文件名看到它,因为它与原始视频文件名相同,只是在前面加上annotated_):
from TensorFlow_detection import DetectionObj
if __name__ == "__main__":
detection = DetectionObj(model='ssd_mobilenet_v1_coco_11_06_2017')
detection.video_pipeline(video="./sample_videos/ducks.mp4", audio=False)
最后,您还可以采用完全相同的方法来处理摄像头获取的图像。这次您将使用方法webcam_pipeline:
from TensorFlow_detection import DetectionObj
if __name__ == "__main__":
detection = DetectionObj(model='ssd_mobilenet_v1_coco_11_06_2017')
detection.webcam_pipeline()
该脚本将激活摄像头,调整光线,拍摄快照,将生成的快照及其注释 JSON 文件保存在当前目录,并最终在您的屏幕上展示带有检测物体边界框的快照。
实时摄像头检测
之前的webcam_pipeline并不是一个实时检测系统,因为它只拍摄快照并对单个图像进行检测。这是一个必要的限制,因为处理摄像头流需要大量的 I/O 数据交换。特别地,问题出在来自摄像头的图像队列,它会锁住 Python,直到传输完成。Adrian Rosebrock 在他的网站 pyimagesearch 上提出了一种基于线程的简单解决方案,您可以在此网址查看:www.pyimagesearch.com/2015/12/21/increasing-webcam-fps-with-python-and-opencv/。
这个想法非常简单。在 Python 中,由于全局解释器锁(GIL),一次只能执行一个线程。如果某个 I/O 操作阻塞了线程(例如下载文件或从摄像头获取图像),那么所有剩余的命令都会被延迟,直到该操作完成,从而导致程序执行非常缓慢。此时,将阻塞的 I/O 操作移到另一个线程是一个好的解决方案。由于线程共享相同的内存,程序线程可以继续执行指令,并不时地向 I/O 线程询问是否完成了操作。因此,如果将图像从摄像头移动到程序内存是一个阻塞操作,让另一个线程处理 I/O 操作可能就是解决方案。主程序将只是查询 I/O 线程,从缓冲区中提取最新接收到的图像并在屏幕上绘制。
from tensorflow_detection import DetectionObj
from threading import Thread
import cv2
def resize(image, new_width=None, new_height=None):
"""
Resize an image based on a new width or new height
keeping the original ratio
"""
height, width, depth = image.shape
if new_width:
new_height = int((new_width / float(width)) * height)
elif new_height:
new_width = int((new_height / float(height)) * width)
else:
return image
return cv2.resize(image, (new_width, new_height), \
interpolation=cv2.INTER_AREA)
class webcamStream:
def __init__(self):
# Initialize webcam
self.stream = cv2.VideoCapture(0)
# Starting TensorFlow API with SSD Mobilenet
self.detection = DetectionObj(model=\
'ssd_mobilenet_v1_coco_11_06_2017')
# Start capturing video so the Webca, will tune itself
_, self.frame = self.stream.read()
# Set the stop flag to False
self.stop = False
#
Thread(target=self.refresh, args=()).start()
def refresh(self):
# Looping until an explicit stop is sent
# from outside the function
while True:
if self.stop:
return
_, self.frame = self.stream.read()
def get(self):
# returning the annotated image
return self.detection.annotate_photogram(self.frame)
def halt(self):
# setting the halt flag
self.stop = True
if __name__ == "__main__":
stream = webcamStream()
while True:
# Grabbing the frame from the threaded video stream
# and resize it to have a maximum width of 400 pixels
frame = resize(stream.get(), new_width=400)
cv2.imshow("webcam", frame)
# If the space bar is hit, the program will stop
if cv2.waitKey(1) & 0xFF == ord(" "):
# First stopping the streaming thread
stream.halt()
# Then halting the while loop
break
上述代码使用了一个webcamStream类来实现这个解决方案,该类实例化一个用于网络摄像头 I/O 的线程,使主 Python 程序始终拥有最新接收到的图像,并通过 TensorFlow API(使用ssd_mobilenet_v1_coco_11_06_2017)对其进行处理。处理后的图像通过 OpenCV 函数流畅地绘制在屏幕上,监听空格键以终止程序。
致谢
与该项目相关的一切都始于以下论文:Speed/accuracy trade-offs for modern convolutional object detectors(arxiv.org/abs/1611.10012)由黄健、拉索德维克、孙晨、朱梦龙、科拉提卡拉阿努普、法蒂阿利瑞扎、费舍尔伊恩、沃纳兹比格涅夫、宋阳、瓜达拉玛谢尔吉奥、墨菲凯文、CVPR 2017. 结束这一章,我们必须感谢所有贡献者 TensorFlow 目标检测 API 的伟大工作,他们编程了这个 API 并使其开源,因此任何人都可以免费访问:乔纳森·黄、维韦克·拉索德、德里克·周、陈孙、孟龙·朱、马修·唐、阿努普·科拉提卡拉、阿利瑞扎·法蒂、伊恩·费舍尔、兹比格涅夫·沃纳、杨嵩、塞尔吉奥·瓜达拉玛、贾斯珀·乌吉林斯、维亚切斯拉夫·科瓦列夫斯基、凯文·墨菲。我们也不能忘记感谢 Dat Tran,在他的 Medium 上发布了两个 MIT 许可证项目的启发性文章,介绍如何实时使用 TensorFlow 目标检测 API 进行识别,甚至是在自定义情况下(towardsdatascience.com/building-a-real-time-object-recognition-app-with-tensorflow-and-opencv-b7a2b4ebdc32 和 towardsdatascience.com/how-to-train-your-own-object-detector-with-tensorflows-object-detector-api-bec72ecfe1d9)
总结
这个项目帮助您立即开始自信地对图像中的对象进行分类,而无需太多麻烦。它帮助您了解 ConvNet 对您的问题可以做些什么,更专注于您心中的总结(可能是一个更大的应用),并注释许多图像以训练更多使用选定类别新图像的 ConvNets。
在项目期间,您学到了许多有用的技术细节,可以在处理图像的许多项目中重复使用。首先,您现在知道如何处理来自图像、视频和网络摄像头捕获的不同类型的视觉输入。您还知道如何加载冻结模型并使其运行,以及如何使用类来访问 TensorFlow 模型。
另一方面,很明显,这个项目存在一些局限性,你迟早会遇到这些问题,这也许会激发你将代码整合起来,使它更加出色。首先,我们讨论的模型很快会被更新的、更高效的模型所超越(你可以在这里查看新发布的模型:github.com/tensorflow/models/blob/master/object_detection/g3doc/detection_model_zoo.md),你需要将它们纳入到你的项目中,或者创建你自己的架构(github.com/tensorflow/models/blob/master/object_detection/g3doc/defining_your_own_model.md)。接着,你可能需要将不同的模型进行结合,以实现项目所需的准确度(论文Speed/accuracy trade-offs for modern convolutional object detectors揭示了谷歌研究人员是如何做到的)。最后,你可能需要调整一个卷积神经网络(ConvNet)来识别新的类别(你可以在这里阅读如何做,但请注意,这是一个漫长的过程,且本身就是一个项目:github.com/tensorflow/models/blob/master/object_detection/g3doc/using_your_own_dataset.md)。
在下一章中,我们将研究图像中的最先进的目标检测技术,并设计一个项目,帮助你生成完整的描述性标题,描述提交的图像,而不仅仅是简单的标签和边界框。
第三章:图像的描述生成
图像描述生成是深度学习领域最重要的应用之一,近年来获得了相当大的关注。图像描述模型结合了视觉信息和自然语言处理。
本章我们将学习:
-
图像描述生成领域的最新进展
-
图像描述生成的工作原理
-
图像描述生成模型的实现
什么是图像描述生成?
图像描述生成是用自然语言描述图像的任务。以前,描述生成模型是基于物体检测模型与模板的组合,这些模板用于为检测到的物体生成文本。随着深度学习的进步,这些模型已经被卷积神经网络和循环神经网络的组合所取代。
以下是一个示例:

来源:arxiv.org/pdf/1609.06647.pdf
有多个数据集帮助我们创建图像描述模型。
探索图像描述数据集
有多个数据集可用于图像描述任务。这些数据集通常通过向几个人展示一张图片,并要求他们分别写出一段关于该图片的描述来准备。通过这种方法,同一张图片会生成多个描述。拥有多个描述选项有助于更好的泛化能力。难点在于模型性能的排序。每次生成后,最好由人类来评估描述。对于这个任务,自动评估是困难的。让我们来探索一下Flickr8数据集。
下载数据集
Flickr8数据集来自 Flickr,禁止用于商业用途。你可以从forms.illinois.edu/sec/1713398下载Flickr8数据集。描述可以在nlp.cs.illinois.edu/HockenmaierGroup/8k-pictures.html找到。请分别下载文本和图片。通过填写页面上的表格,你可以获取访问权限:

下载链接将通过电子邮件发送。下载并解压后,文件应该是这样的:
Flickr8k_text
CrowdFlowerAnnotations.txt
Flickr_8k.devImages.txt
ExpertAnnotations.txt
Flickr_8k.testImages.txt
Flickr8k.lemma.token.txt
Flickr_8k.trainImages.txt
Flickr8k.token.txt readme.txt
以下是数据集中给出的几个示例:

上图显示了以下组件:
-
一名穿着街头赛车盔甲的男子正在检查另一名赛车手的摩托车轮胎
-
两名赛车手骑着白色自行车沿着道路行驶
-
两名摩托车手正骑着一辆设计奇特且颜色鲜艳的车辆
-
两个人在小型赛车中驾驶,驶过绿色的山丘
-
两名穿着赛车服的人员在街车中
以下是第二个示例:

上图显示了以下组件:
-
一名穿着黑色连帽衫和牛仔裤的男子在扶手上滑板
-
一名男子正在滑板沿着一根陡峭的栏杆下滑,旁边有一些台阶
-
一个人正在用滑雪板滑下一个砖制栏杆
-
一个人正走下靠近台阶的砖制栏杆
-
一名滑雪者正在没有雪的栏杆上滑行
如你所见,对于一张图像提供了不同的标题。这些标题展示了图像标题生成任务的难度。
将单词转化为嵌入
英文单词必须转换为嵌入才能生成标题。嵌入其实就是单词或图像的向量或数值表示。如果将单词转换为向量形式,则可以使用向量进行运算。
这种嵌入可以通过两种方法进行学习,如下图所示:

CBOW方法通过预测给定上下文单词来学习嵌入。Skip-gram方法则是给定一个单词,预测其上下文单词,是CBOW的逆过程。基于历史数据,可以训练目标单词,如下图所示:

一旦训练完成,嵌入可以如下所示进行可视化:

单词的可视化
这种类型的嵌入可以用来执行单词的向量运算。单词嵌入的概念在本章中将会非常有帮助。
图像标题生成方法
有几种方法可以进行图像标题生成。早期的方法是基于图像中的物体和属性来构建句子。后来,循环神经网络(RNN)被用来生成句子。最准确的方法是使用注意力机制。让我们在本节中详细探讨这些技术和结果。
条件随机场
最初尝试了一种方法,使用条件随机场(CRF)通过图像中检测到的物体和属性构建句子。此过程的步骤如下所示:

示例图像的系统流程(来源:www.tamaraberg.com/papers/generation_cvpr11.pdf)
CRF 在生成连贯句子方面的能力有限。生成的句子的质量不高,如以下截图所示:

尽管已经正确地获取了物体和属性,但这里展示的句子仍然过于结构化。
Kulkarni 等人在论文 www.tamaraberg.com/papers/generation_cvpr11.pdf 中提出了一种方法,通过从图像中找到物体和属性,利用条件随机场(CRF)生成文本。
卷积神经网络上的循环神经网络
可以将循环神经网络与卷积神经网络特征结合起来生成新的句子。这使得模型能够进行端到端训练。以下是该模型的架构:

LSTM 模型(来源:arxiv.org/pdf/1411.4555.pdf)
使用了多个LSTM层来生成期望的结果。以下是该模型生成的一些结果截图:

来源:arxiv.org/pdf/1411.4555.pdf
这些结果优于 CRF 生成的结果。这展示了 LSTM 在生成句子方面的强大能力。
参考:Vinyals 等人在论文中提出了arxiv.org/pdf/1411.4555.pdf,提出了一种端到端可训练的深度学习图像标注方法,其中 CNN 和 RNN 堆叠在一起。
标题排序
标题排序是一种从一组标题中选择一个标题的有趣方法。首先,根据图像的特征对其进行排序,并选择相应的标题,如下图所示:

来源:papers.nips.cc/paper/4470-im2text-describing-images-using-1-million-captioned-photographs.pdf
通过使用不同的属性集合,顶部图像可以重新排序。通过获取更多的图像,质量可以大幅提高,正如以下截图所示:

来源:papers.nips.cc/paper/4470-im2text-describing-images-using-1-million-captioned-photographs.pdf
随着数据集中文件数量的增加,结果得到了改善。
要了解更多关于标题排序的信息,请参考:papers.nips.cc/paper/4470-im2text-describing-images-using-1-million-captioned-photographs.pdf
密集标注
密集标注是一个问题,即单张图像上有多个标题。以下是该问题的架构:

该架构产生了良好的结果。
要了解更多,请参考:Johnson 等人在论文中提出了www.cv-foundation.org/openaccess/content_cvpr_2016/papers/Johnson_DenseCap_Fully_Convolutional_CVPR_2016_paper.pdf,提出了一种密集标注方法。
RNN 标注
可将视觉特征与序列学习结合,形成输出。

这是一个生成标题的架构。
详情请参考:Donahue 等人在论文中,arxiv.org/pdf/1411.4389.pdf 提出了长时记忆卷积神经网络(LRCN)用于图像标题生成任务。
多模态标题生成
图像和文本都可以映射到同一个嵌入空间,以生成标题。

需要一个解码器来生成标题。
基于注意力的标题生成
对于详细学习,参考:Xu 等人在论文中,arxiv.org/pdf/1502.03044.pdf 提出了使用注意力机制的图像标题生成方法。
基于注意力的标题生成最近变得流行,因为它提供了更好的准确度:

这种方法按标题的顺序训练一个注意力模型,从而产生更好的结果:

这是一个带有注意力生成标题的LSTM图示:

这里展示了几个示例,并且通过时间序列方式非常好地可视化了对象的展开:

以时间序列方式展开对象
结果非常出色!
实现一个标题生成模型
首先,读取数据集并按需要进行转换。导入os库并声明数据集所在的目录,如以下代码所示:
import os
annotation_dir = 'Flickr8k_text'
接下来,定义一个函数来打开文件并返回文件中的行作为列表:
def read_file(file_name):
with open(os.path.join(annotation_dir, file_name), 'rb') as file_handle:
file_lines = file_handle.read().splitlines()
return file_lines
读取训练和测试数据集的图片路径,并加载标题文件:
train_image_paths = read_file('Flickr_8k.trainImages.txt')
test_image_paths = read_file('Flickr_8k.testImages.txt')
captions = read_file('Flickr8k.token.txt')
print(len(train_image_paths))
print(len(test_image_paths))
print(len(captions))
这应该打印出以下内容:
6000
1000
40460
接下来,需要生成图像到标题的映射。这将帮助训练中更方便地查找标题。此外,标题数据集中独特的词汇有助于创建词汇表:
image_caption_map = {}
unique_words = set()
max_words = 0
for caption in captions:
image_name = caption.split('#')[0]
image_caption = caption.split('#')[1].split('\t')[1]
if image_name not in image_caption_map.keys():
image_caption_map[image_name] = [image_caption]
else:
image_caption_map[image_name].append(image_caption)
caption_words = image_caption.split()
max_words = max(max_words, len(caption_words))
[unique_words.add(caption_word) for caption_word in caption_words]
现在,需要生成两个映射。一个是词到索引,另一个是索引到词的映射:
unique_words = list(unique_words)
word_to_index_map = {}
index_to_word_map = {}
for index, unique_word in enumerate(unique_words):
word_to_index_map[unique_word] = index
index_to_word_map[index] = unique_word
print(max_words)
标题中出现的最大词数为 38,这将有助于定义架构。接下来,导入所需的库:
from data_preparation import train_image_paths, test_image_paths
from keras.applications.vgg16 import VGG16
from keras.preprocessing import image
from keras.applications.vgg16 import preprocess_input
import numpy as np
from keras.models import Model
import pickle
import os
现在创建ImageModel类来加载带有权重的 VGG 模型:
class ImageModel:
def __init__(self):
vgg_model = VGG16(weights='imagenet', include_top=True)
self.model = Model(input=vgg_model.input,
output=vgg_model.get_layer('fc2').output)
权重已下载并存储。第一次尝试时可能需要一些时间。接下来,创建一个单独的模型,以便预测第二个全连接层。以下是从路径读取图像并进行预处理的方法:
@staticmethod
def load_preprocess_image(image_path):
image_array = image.load_img(image_path, target_size=(224, 224))
image_array = image.img_to_array(image_array)
image_array = np.expand_dims(image_array, axis=0)
image_array = preprocess_input(image_array)
return image_array
接下来,定义一个方法来加载图像并进行预测。预测的第二个全连接层可以重新调整为4096:
def extract_feature_from_imagfe_path(self, image_path):
image_array = self.load_preprocess_image(image_path)
features = self.model.predict(image_array)
return features.reshape((4096, 1))
遍历图片路径列表并创建特征列表:
def extract_feature_from_image_paths(self, work_dir, image_names):
features = []
for image_name in image_names:
image_path = os.path.join(work_dir, image_name)
feature = self.extract_feature_from_image_path(image_path)
features.append(feature)
return features
接下来,将提取的特征存储为 pickle 文件:
def extract_features_and_save(self, work_dir, image_names, file_name):
features = self.extract_feature_from_image_paths(work_dir, image_names)
with open(file_name, 'wb') as p:
pickle.dump(features, p)
接下来,初始化类并提取训练和测试图片特征:
I = ImageModel()
I.extract_features_and_save(b'Flicker8k_Dataset',train_image_paths, 'train_image_features.p')
I.extract_features_and_save(b'Flicker8k_Dataset',test_image_paths, 'test_image_features.p')
导入构建模型所需的层:
from data_preparation import get_vocab
from keras.models import Sequential
from keras.layers import LSTM, Embedding, TimeDistributed, Dense, RepeatVector, Merge, Activation, Flatten
from keras.preprocessing import image, sequence
获取所需的词汇表:
image_caption_map, max_words, unique_words, \
word_to_index_map, index_to_word_map = get_vocab()
vocabulary_size = len(unique_words)
对于最终的标题生成模型:
image_model = Sequential()
image_model.add(Dense(128, input_dim=4096, activation='relu'))
image_model.add(RepeatVector(max_words))
对于语言,创建一个模型:
lang_model = Sequential()
lang_model.add(Embedding(vocabulary_size, 256, input_length=max_words))
lang_model.add(LSTM(256, return_sequences=True))
lang_model.add(TimeDistributed(Dense(128)))
两种不同的模型被合并以形成最终模型:
model = Sequential()
model.add(Merge([image_model, lang_model], mode='concat'))
model.add(LSTM(1000, return_sequences=False))
model.add(Dense(vocabulary_size))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', optimizer='rmsprop', metrics=['accuracy'])
batch_size = 32
epochs = 10
total_samples = 9
model.fit_generator(data_generator(batch_size=batch_size), steps_per_epoch=total_samples / batch_size,
epochs=epochs, verbose=2)
这个模型可以被训练生成描述。
概述
在本章中,我们学习了图像描述技术。首先,我们理解了词向量的嵌入空间。然后,我们学习了几种图像描述的方法。接着,开始了图像描述模型的实现。
在下一章,我们将探讨生成对抗网络(GAN)的概念。GAN 非常有趣,并且在生成各种用途的图像方面非常有用。
第四章:构建用于条件图像生成的 GAN
Facebook AI 的总监 Yann LeCun 最近表示:“生成对抗网络是过去十年机器学习领域最有趣的想法”,这一观点无疑得到了学术界对这一深度学习解决方案日益关注的验证。如果你查看最近的深度学习论文(同时也可以看看 LinkedIn 或 Medium 上关于该话题的领先趋势),你会发现 GAN 的变种已经被大量生产出来。
你可以通过浏览 Hindu Puravinash 不断更新的参考表格,来了解 GAN 世界已经变成了一个什么样的动物园,该表格可以在github.com/hindupuravinash/the-gan-zoo/blob/master/gans.tsv找到,或者通过研究 Zheng Liu 准备的 GAN 时间线,时间线可以在github.com/dongb5/GAN-Timeline找到,帮助你将一切放入时间框架中。
GAN 具备激发想象力的能力,因为它们不仅能展示 AI 的计算能力,还能展现 AI 的创造力。在本章中,我们将:
-
通过提供所有必要的概念来揭开 GAN 的神秘面纱,帮助你理解 GAN 是什么、它们目前能做什么以及未来能做什么
-
展示如何基于示例图像的初始分布生成图像(即所谓的无监督 GAN)
-
解释如何为 GAN 设置条件,以便它们生成你期望的图像类型
-
设置一个基本但完整的项目,可以处理不同的数据集,例如手写字符和图标
-
提供基本指导,教你如何在云端(特别是在 Amazon AWS 上)训练你的 GAN
GAN 的成功除了取决于你所使用的特定神经网络架构外,还与它们面临的问题以及你提供的数据密切相关。我们为本章选择的数据集应该能够提供令人满意的结果。我们希望你能享受并从 GAN 的创造力中获得灵感!
介绍 GAN
我们将从一些相当近期的历史开始,因为 GAN 是你在 AI 和深度学习领域找到的最新想法之一。
一切始于 2014 年,当时 Ian Goodfellow 和他的同事们(包括 Yoshua Bengio 也在贡献者名单上)在蒙特利尔大学的计算机科学与运筹学系发表了关于生成对抗网络(GANs)的论文,这是一种能够基于一组初始示例生成新数据的框架:
GOODFELLOW,Ian 等。生成对抗网络。在:神经信息处理系统的进展,2014 年,第 2672-2680 页:arxiv.org/abs/1406.2661。
这些网络生成的初始图像令人惊讶,考虑到之前使用马尔可夫链的尝试远未达到可信的程度。在图像中,你可以看到论文中提出的一些例子,展示了从 MNIST、多伦多面部数据集(TFD)——一个非公开数据集以及 CIFAR-10 数据集中复制的示例:

图 1:第一篇关于 GAN 的论文中,使用不同数据集生成新图像的样本:a) MNIST b) TFD c) 和 d) CIFAR-10
来源:GOODFELLOW,Ian 等。生成对抗网络。在:神经信息处理系统进展。2014 年,第 2672-2680 页
这篇论文被认为相当创新,因为它将深度神经网络和博弈论结合在一个非常聪明的架构中,而这个架构并不需要太多除了常规的反向传播来进行训练。GANs 是生成模型,这些模型能够生成数据,因为它们已经学习到并刻画了一个模型分布(例如它们已经学习了这个分布)。因此,当它们生成某些东西时,就像是从这个分布中进行采样一样。
关键在于对抗性方法
理解 GANs 为什么能成为如此成功的生成模型的关键就在于“对抗”一词。实际上,GANs 的架构由两个独立的网络组成,这两个网络通过各自错误的聚合来优化,这个过程被称为对抗过程。
你从一个真实的数据集开始,假设它叫做 R,包含你的图像或其他类型的数据(尽管 GANs 主要应用于图像,但它们并不限于图像)。然后你设置一个生成器网络 G,它尝试生成看起来像真实数据的假数据,并设置一个判别器 D,其作用是将 G 生成的数据与真实数据 R 混合,比较并判断哪个是原始数据,哪个是伪造的。
Goodfellow 用伪造艺术家的隐喻来描述这个过程,其中生成器是伪造者,判别器是侦探(或艺术评论家),必须揭露伪造行为。伪造者和侦探之间存在一种挑战,因为伪造者必须变得更加熟练,以避免被侦探发现,而侦探则必须提高识别伪造品的能力。一切都变成了伪造者和侦探之间的无休止斗争,直到伪造的物品与原物完全相似。当 GANs 过拟合时,实际上它们只是复制了原始数据。这似乎是在解释一个竞争性市场,实际上也是如此,因为这个概念来源于竞争博弈理论。
在 GAN 中,生成器的目标是生成让判别器无法判断真伪的图像。生成器的一种显而易见的解决方案是简单地复制某些训练图像,或者选择一些看似能成功欺骗判别器的图像。一个解决方案是单边标签平滑,这是我们在项目中将应用的技术。该技术在 SALIMANS, Tim 等人所著的《训练 GAN 的改进技术》中进行了描述。书中出现在Advances in Neural Information Processing Systems
,2016 年,2234-2242 页
: arxiv.org/abs/1606.03498。
让我们更详细地讨论一下实际是如何工作的。最初,生成器G毫无头绪,生成完全随机的数据(实际上它从未见过任何原始数据),因此它会受到判别器D的惩罚——D非常容易区分真假数据。G承担了全部责任,开始尝试不同的方法,以获得来自D的更好反馈。这个过程是完全随机的,因为生成器看到的唯一数据是一个随机输入Z,它从未接触过真实数据。经过多次试错后,在判别器的提示下,生成器最终弄明白该做什么,并开始生成可信的输出。最终,随着时间的推移,生成器将完美复制所有原始数据,甚至从未见过任何一个原始样本:

图 2:展示了一个简单的 GAN 架构如何工作
一次寒武纪大爆发
正如前面提到的,每个月都会有关于 GAN 的新论文发布(你可以查看我们在本章开头提到的 Hindu Puravinash 制作的参考表)。
无论如何,除了 Goodfellow 及其同事最初论文中描述的基础实现外,最值得注意的实现是深度卷积生成对抗网络(DCGANs)和条件生成对抗网络(CGANs)。
-
DCGAN 是基于 CNN 架构的 GAN(
RADFORD, Alec; METZ, Luke; CHINTALA, Soumith. 使用深度卷积生成对抗网络进行无监督表示学习. arXiv 预印本 arXiv:1511.06434
, 2015
:arxiv.org/abs/1511.06434)。 -
CGAN 是条件化的 DCGAN,依赖于某些输入标签,因此可以生成具有特定所需特征的图像(
MIRZA, Mehdi; OSINDERO, Simon. 条件生成对抗网络. arXiv 预印本 arXiv:1411.1784
, 2014
:arxiv.org/abs/1411.1784)。我们的项目将编写一个CGAN类,并在不同数据集上训练它,以验证其功能。
但也有其他有趣的例子(这些并未包含在我们的项目中),它们提供了与图像创建或改善相关的实际解决方案:
-
CycleGAN 将一张图像转换为另一张图像(经典例子是马变成斑马:
ZHU, Jun-Yan, et al. Unpaired image-to-image translation using cycle-consistent adversarial networks. arXiv 预印本 arXiv:1703.10593
, 2017
:arxiv.org/abs/1703.10593) -
StackGAN 通过描述图像的文本生成逼真的图像(
ZHANG, Han, et al. Stackgan: Text to photo-realistic image synthesis with stacked generative adversarial networks. arXiv 预印本 arXiv:1612.03242
, 2016
:arxiv.org/abs/1612.03242) -
Discovery GAN(DiscoGAN)将一种图像的风格元素转移到另一张图像上,从而将纹理和装饰从一种时尚物品(例如包包)转移到另一种时尚物品(例如鞋子)上(
KIM, Taeksoo, et al. Learning to discover cross-domain relations with generative adversarial networks. arXiv 预印本 arXiv:1703.05192
, 2017
:arxiv.org/abs/1703.05192) -
SRGAN 能够将低质量图像转换为高分辨率图像(
LEDIG, Christian, et al. Photo-realistic single image super-resolution using a generative adversarial network. arXiv 预印本 arXiv:1609.04802
, 2016
:arxiv.org/abs/1609.04802)
DCGAN
DCGAN 是 GAN 架构的首次相关改进。DCGAN 始终能够成功完成训练阶段,并且在足够的训练周期和示例下,通常能够生成令人满意的输出。这使得它们很快成为了 GAN 的基准,并帮助产生了一些令人惊叹的成就,比如从已知的宝可梦生成新的宝可梦:www.youtube.com/watch?v=rs3aI7bACGc,或者创造出实际上从未存在过但极其真实的名人面孔(毫无诡异感),正如 NVIDIA 所做的:youtu.be/XOxxPcy5Gr4,使用一种新的训练方法叫做逐步生长:research.nvidia.com/sites/default/files/publications/karras2017gan-paper.pdf。它们的根基在于使用深度学习监督网络中用于图像分类的相同卷积操作,并且它们使用了一些聪明的技巧:
-
两个网络中都使用批量归一化
-
无完全隐藏连接层
-
无池化,仅使用步幅卷积
-
ReLU 激活函数
条件 GAN
在条件 GANs(CGANs)中,添加特征向量可以控制输出,并为生成器提供更好的指导,帮助其弄清楚该做什么。这样的特征向量可以编码图像应来源于哪个类别(如果我们试图创建虚构演员的面部图像,可以是女性或男性的图像),甚至可以是我们期望图像具备的一组特定特征(对于虚构演员,可能是发型、眼睛或肤色等)。这一技巧是通过将信息融入到需要学习的图像和Z输入中实现的,而Z输入不再是完全随机的。判别器的评估不仅仅是基于假数据与真实数据的相似度,还会基于假数据图像与其输入标签(或特征)的一致性进行评估。

图 3:将 Z 输入与 Y 输入(标签特征向量)结合,允许生成受控图像
项目
导入正确的库是我们的起点。除了tensorflow,我们还将使用numpy和 math 进行计算,scipy、matplolib用于图像和图形处理,warnings、random和distutils则为特定操作提供支持:
import numpy as np
import tensorflow as tf
import math
import warnings
import matplotlib.pyplot as plt
from scipy.misc import imresize
from random import shuffle
from distutils.version import LooseVersion
数据集类
我们的第一步是提供数据。我们将依赖已预处理的数据集,但读者可以使用不同类型的图像进行他们自己的 GAN 实现。我们的想法是保持一个独立的Dataset类,该类负责为我们稍后构建的 GAN 类提供规范化和重塑后的图像批次。
在初始化时,我们将处理图像及其标签(如果有的话)。首先,图像会被重塑(如果其形状与实例化类时定义的形状不同),然后进行打乱。打乱有助于 GANs 更好地学习,如果数据集一开始就按某种顺序(例如按类别)排列,打乱会更有效——这实际上对任何基于随机梯度下降的机器学习算法都是成立的:BOTTOU, Léon. 随机梯度下降技巧. 在: Neural networks: Tricks of the trade
。Springer,柏林,海德堡,2012 年,第 421-436 页
: www.microsoft.com/en-us/research/wp-content/uploads/2012/01/tricks-2012.pdf。标签则使用独热编码进行编码,即为每个类别创建一个二进制变量,该变量被设置为 1(而其他变量为 0),以向量的形式表示标签。
例如,如果我们的类别是{dog:0, cat:1},我们将使用这两个独热编码向量来表示它们:{dog:[1, 0], cat:[0, 1]}。
这样,我们可以轻松地将向量添加到我们的图像中,作为一个额外的通道,并在其中铭刻某种视觉特征,供我们的 GAN 复制。此外,我们还可以排列这些向量,以便铭刻更复杂的类别,并赋予其特殊特征。例如,我们可以指定一个我们希望生成的类别的代码,也可以指定一些它的特征:
class Dataset(object):
def __init__(self, data, labels=None, width=28, height=28,
max_value=255, channels=3):
# Record image specs
self.IMAGE_WIDTH = width
self.IMAGE_HEIGHT = height
self.IMAGE_MAX_VALUE = float(max_value)
self.CHANNELS = channels
self.shape = len(data), self.IMAGE_WIDTH,
self.IMAGE_HEIGHT, self.CHANNELS
if self.CHANNELS == 3:
self.image_mode = 'RGB'
self.cmap = None
elif self.CHANNELS == 1:
self.image_mode = 'L'
self.cmap = 'gray'
# Resize if images are of different size
if data.shape[1] != self.IMAGE_HEIGHT or \
data.shape[2] != self.IMAGE_WIDTH:
data = self.image_resize(data,
self.IMAGE_HEIGHT, self.IMAGE_WIDTH)
# Store away shuffled data
index = list(range(len(data)))
shuffle(index)
self.data = data[index]
if len(labels) > 0:
# Store away shuffled labels
self.labels = labels[index]
# Enumerate unique classes
self.classes = np.unique(labels)
# Create a one hot encoding for each class
# based on position in self.classes
one_hot = dict()
no_classes = len(self.classes)
for j, i in enumerate(self.classes):
one_hot[i] = np.zeros(no_classes)
one_hot[i][j] = 1.0
self.one_hot = one_hot
else:
# Just keep label variables as placeholders
self.labels = None
self.classes = None
self.one_hot = None
def image_resize(self, dataset, newHeight, newWidth):
"""Resizing an image if necessary"""
channels = dataset.shape[3]
images_resized = np.zeros([0, newHeight,
newWidth, channels], dtype=np.uint8)
for image in range(dataset.shape[0]):
if channels == 1:
temp = imresize(dataset[image][:, :, 0],
[newHeight, newWidth], 'nearest')
temp = np.expand_dims(temp, axis=2)
else:
temp = imresize(dataset[image],
[newHeight, newWidth], 'nearest')
images_resized = np.append(images_resized,
np.expand_dims(temp, axis=0), axis=0)
return images_resized
get_batches 方法将只释放数据集的一个批次子集,并通过将像素值除以最大值(256)并减去 -0.5 来规范化数据。结果图像的浮动值将在区间 [-0.5, +0.5] 内:
def get_batches(self, batch_size):
"""Pulling batches of images and their labels"""
current_index = 0
# Checking there are still batches to deliver
while current_index < self.shape[0]:
if current_index + batch_size > self.shape[0]:
batch_size = self.shape[0] - current_index
data_batch = self.data[current_index:current_index \
+ batch_size]
if len(self.labels) > 0:
y_batch = np.array([self.one_hot[k] for k in \
self.labels[current_index:current_index +\
batch_size]])
else:
y_batch = np.array([])
current_index += batch_size
yield (data_batch / self.IMAGE_MAX_VALUE) - 0.5, y_batch
CGAN 类
CGAN 类包含了运行基于 CGAN 模型的条件 GAN 所需的所有函数。深度卷积生成对抗网络已被证明能够生成接近照片质量的输出。我们之前已经介绍了 CGAN,因此提醒大家,它们的参考论文是:
RADFORD, Alec; METZ, Luke; CHINTALA, Soumith. 无监督表示学习与深度卷积生成对抗网络。arXiv 预印本 arXiv:1511.06434, 2015,链接:arxiv.org/abs/1511.06434。
在我们的项目中,我们将添加条件形式的 CGAN,它使用标签信息,就像在监督学习任务中一样。使用标签并将其与图像整合(这就是技巧)将生成更好的图像,并且可以决定生成图像的特征。
条件 GAN 的参考论文是:
MIRZA, Mehdi; OSINDERO, Simon. 条件生成对抗网络。arXiv 预印本 arXiv:1411.1784, 2014,链接:arxiv.org/abs/1411.1784。
我们的 CGAN 类需要作为输入的一个数据集类对象、训练的 epoch 数量、图像 batch_size、用于生成器的随机输入维度 (z_dim),以及一个 GAN 的名称(用于保存)。它还可以使用不同的 alpha 和 smooth 值进行初始化。我们稍后会讨论这两个参数如何影响 GAN 网络。
实例化设置了所有内部变量,并对系统进行性能检查,如果未检测到 GPU,则会发出警告:
class CGan(object):
def __init__(self, dataset, epochs=1, batch_size=32,
z_dim=96, generator_name='generator',
alpha=0.2, smooth=0.1,
learning_rate=0.001, beta1=0.35):
# As a first step, checking if the
# system is performing for GANs
self.check_system()
# Setting up key parameters
self.generator_name = generator_name
self.dataset = dataset
self.cmap = self.dataset.cmap
self.image_mode = self.dataset.image_mode
self.epochs = epochs
self.batch_size = batch_size
self.z_dim = z_dim
self.alpha = alpha
self.smooth = smooth
self.learning_rate = learning_rate
self.beta1 = beta1
self.g_vars = list()
self.trained = False
def check_system(self):
"""
Checking system suitability for the project
"""
# Checking TensorFlow version >=1.2
version = tf.__version__
print('TensorFlow Version: %s' % version)
assert LooseVersion(version) >= LooseVersion('1.2'),\
('You are using %s, please use TensorFlow version 1.2 \
or newer.' % version)
# Checking for a GPU
if not tf.test.gpu_device_name():
warnings.warn('No GPU found installed on the system.\
It is advised to train your GAN using\
a GPU or on AWS')
else:
print('Default GPU Device: %s' % tf.test.gpu_device_name())
instantiate_inputs 函数为输入(包括真实和随机输入)创建 TensorFlow 占位符。它还提供标签(作为与原图像形状相同但通道深度等于类别数量的图像进行处理),以及训练过程的学习率:
def instantiate_inputs(self, image_width, image_height,
image_channels, z_dim, classes):
"""
Instantiating inputs and parameters placeholders:
real input, z input for generation,
real input labels, learning rate
"""
inputs_real = tf.placeholder(tf.float32,
(None, image_width, image_height,
image_channels), name='input_real')
inputs_z = tf.placeholder(tf.float32,
(None, z_dim + classes), name='input_z')
labels = tf.placeholder(tf.float32,
(None, image_width, image_height,
classes), name='labels')
learning_rate = tf.placeholder(tf.float32, None)
return inputs_real, inputs_z, labels, learning_rate
接下来,我们开始着手网络架构的工作,定义一些基本函数,如 leaky_ReLU_activation 函数(我们将在生成器和判别器中都使用它,这与原始深度卷积 GAN 论文中所规定的相反):
def leaky_ReLU_activation(self, x, alpha=0.2):
return tf.maximum(alpha * x, x)
def dropout(self, x, keep_prob=0.9):
return tf.nn.dropout(x, keep_prob)
我们接下来的函数表示一个判别器层。它使用 Xavier 初始化创建卷积,对结果进行批量归一化,设置一个leaky_ReLU_activation,最后应用dropout进行正则化:
def d_conv(self, x, filters, kernel_size, strides,
padding='same', alpha=0.2, keep_prob=0.5,
train=True):
"""
Discriminant layer architecture
Creating a convolution, applying batch normalization,
leaky rely activation and dropout
"""
x = tf.layers.conv2d(x, filters, kernel_size,
strides, padding, kernel_initializer=\
tf.contrib.layers.xavier_initializer())
x = tf.layers.batch_normalization(x, training=train)
x = self.leaky_ReLU_activation(x, alpha)
x = self.dropout(x, keep_prob)
return x
Xavier 初始化确保卷积的初始权重既不太小,也不太大,以便从初期阶段开始,信号可以更好地在网络中传输。
Xavier 初始化提供了一个均值为零的高斯分布,其方差由 1.0 除以输入层神经元的数量给出。正是由于这种初始化方式,深度学习摆脱了以前用于设定初始权重的预训练技术,这些技术能够即使在存在许多层的情况下,也能传递反向传播。你可以在这篇文章中了解更多内容,关于 Glorot 和 Bengio 的初始化变体:andyljones.tumblr.com/post/110998971763/an-explanation-of-xavier-initialization.
批量归一化在这篇论文中有所描述:
IOFFE, Sergey; SZEGEDY, Christian. 批量归一化:通过减少内部协变量偏移加速深度网络训练。来源:国际机器学习会议,2015 年,p. 448-456。
正如作者所指出的,批量归一化算法用于归一化,它处理协变量偏移(sifaka.cs.uiuc.edu/jiang4/domain_adaptation/survey/node8.html),即输入数据的分布变化,可能导致之前学习到的权重无法正常工作。事实上,由于分布最初是在第一个输入层中学习的,它们会传递到所有后续层,而后续层的分布如果发生了变化(例如,最初你有更多的猫的照片而不是狗的照片,现在正好相反),则可能会很棘手,除非你已经将学习率设置得非常低。
批量归一化解决了输入数据分布变化的问题,因为它通过均值和方差(使用批量统计量)对每个批次进行归一化,正如论文中所示 IOFFE, Sergey; SZEGEDY, Christian
。批量归一化:通过减少内部协变量偏移加速深度网络训练。来源:国际机器学习会议,2015 年,p. 448-456
(可以通过arxiv.org/abs/1502.03167在网上找到)。
g_reshaping `和` g_conv_transpose是生成器的一部分。它们通过调整输入的形状来操作,无论输入是平坦层还是卷积层。实际上,它们只是反转卷积所做的工作,将卷积生成的特征恢复成原始特征:
def g_reshaping(self, x, shape, alpha=0.2,
keep_prob=0.5, train=True):
"""
Generator layer architecture
Reshaping layer, applying batch normalization,
leaky rely activation and dropout
"""
x = tf.reshape(x, shape)
x = tf.layers.batch_normalization(x, training=train)
x = self.leaky_ReLU_activation(x, alpha)
x = self.dropout(x, keep_prob)
return x
def g_conv_transpose(self, x, filters, kernel_size,
strides, padding='same', alpha=0.2,
keep_prob=0.5, train=True):
"""
Generator layer architecture
Transposing convolution to a new size,
applying batch normalization,
leaky rely activation and dropout
"""
x = tf.layers.conv2d_transpose(x, filters, kernel_size,
strides, padding)
x = tf.layers.batch_normalization(x, training=train)
x = self.leaky_ReLU_activation(x, alpha)
x = self.dropout(x, keep_prob)
return x
判别器架构的工作原理是通过将图像作为输入,并通过各种卷积操作将其转换,直到结果被展平并转化为对数值和概率(使用 sigmoid 函数)。实际上,所有操作与常规卷积中的操作相同:
def discriminator(self, images, labels, reuse=False):
with tf.variable_scope('discriminator', reuse=reuse):
# Input layer is 28x28x3 --> concatenating input
x = tf.concat([images, labels], 3)
# d_conv --> expected size is 14x14x32
x = self.d_conv(x, filters=32, kernel_size=5,
strides=2, padding='same',
alpha=0.2, keep_prob=0.5)
# d_conv --> expected size is 7x7x64
x = self.d_conv(x, filters=64, kernel_size=5,
strides=2, padding='same',
alpha=0.2, keep_prob=0.5)
# d_conv --> expected size is 7x7x128
x = self.d_conv(x, filters=128, kernel_size=5,
strides=1, padding='same',
alpha=0.2, keep_prob=0.5)
# Flattening to a layer --> expected size is 4096
x = tf.reshape(x, (-1, 7 * 7 * 128))
# Calculating logits and sigmoids
logits = tf.layers.dense(x, 1)
sigmoids = tf.sigmoid(logits)
return sigmoids, logits
至于生成器,其架构与判别器完全相反。从输入向量z开始,首先创建一个密集层,然后通过一系列转置操作重建判别器中的卷积逆过程,最终得到一个与输入图像形状相同的张量,接着通过tanh激活函数进行进一步的转换:
def generator(self, z, out_channel_dim, is_train=True):
with tf.variable_scope('generator',
reuse=(not is_train)):
# First fully connected layer
x = tf.layers.dense(z, 7 * 7 * 512)
# Reshape it to start the convolutional stack
x = self.g_reshaping(x, shape=(-1, 7, 7, 512),
alpha=0.2, keep_prob=0.5,
train=is_train)
# g_conv_transpose --> 7x7x128 now
x = self.g_conv_transpose(x, filters=256,
kernel_size=5,
strides=2, padding='same',
alpha=0.2, keep_prob=0.5,
train=is_train)
# g_conv_transpose --> 14x14x64 now
x = self.g_conv_transpose(x, filters=128,
kernel_size=5, strides=2,
padding='same', alpha=0.2,
keep_prob=0.5,
train=is_train)
# Calculating logits and Output layer --> 28x28x5 now
logits = tf.layers.conv2d_transpose(x,
filters=out_channel_dim,
kernel_size=5,
strides=1,
padding='same')
output = tf.tanh(logits)
return output
该架构与 CGAN 论文中介绍的架构非常相似,展示了如何从一个大小为 100 的向量的初始输入重建一个 64 x 64 x 3 的图像:

图 4:生成器的 DCGAN 架构。
来源:arXiv, 1511.06434, 2015
在定义了架构之后,损失函数是下一个需要定义的重要元素。它使用两个输出,一个是来自生成器的输出,经过管道传递给判别器并输出对数值,另一个是来自真实图像的输出,直接传递给判别器。对于这两者,都需要计算损失度量。这里,平滑参数非常有用,因为它有助于将真实图像的概率平滑成不是 1.0 的值,从而使 GAN 网络能够更好、更具概率性地学习(如果完全惩罚,假图像可能更难有机会与真实图像竞争)。
最终的判别器损失是简单地将对假图像和真实图像计算的损失相加。假图像的损失通过比较估计的对数值与零的概率来计算。真实图像的损失通过将估计的对数值与平滑后的概率(在我们的例子中是 0.9)进行比较来计算,目的是防止过拟合,并避免判别器仅仅因为记住了真实图像而学习识别它们。生成器的损失则是根据判别器对假图像估计的对数值与 1.0 的概率进行计算的。通过这种方式,生成器应该努力生成被判别器估计为最可能为真的假图像(因此使用较高的概率)。因此,损失从判别器对假图像的评估传递到生成器,形成一个反馈循环:
def loss(self, input_real, input_z, labels, out_channel_dim):
# Generating output
g_output = self.generator(input_z, out_channel_dim)
# Classifying real input
d_output_real, d_logits_real = self.discriminator(input_real, labels, reuse=False)
# Classifying generated output
d_output_fake, d_logits_fake = self.discriminator(g_output, labels, reuse=True)
# Calculating loss of real input classification
real_input_labels = tf.ones_like(d_output_real) * (1 - self.smooth) # smoothed ones
d_loss_real = tf.reduce_mean(
tf.nn.sigmoid_cross_entropy_with_logits(logits=d_logits_real,
labels=real_input_labels))
# Calculating loss of generated output classification
fake_input_labels = tf.zeros_like(d_output_fake) # just zeros
d_loss_fake = tf.reduce_mean(
tf.nn.sigmoid_cross_entropy_with_logits(logits=d_logits_fake,
labels=fake_input_labels))
# Summing the real input and generated output classification losses
d_loss = d_loss_real + d_loss_fake # Total loss for discriminator
# Calculating loss for generator: all generated images should have been
# classified as true by the discriminator
target_fake_input_labels = tf.ones_like(d_output_fake) # all ones
g_loss = tf.reduce_mean(
tf.nn.sigmoid_cross_entropy_with_logits(logits=d_logits_fake,
labels=target_fake_input_labels))
return d_loss, g_loss
由于 GAN 的工作是视觉性的,因此有一些函数用于可视化生成器当前生成的样本,以及一组特定的图像:
def rescale_images(self, image_array):
"""
Scaling images in the range 0-255
"""
new_array = image_array.copy().astype(float)
min_value = new_array.min()
range_value = new_array.max() - min_value
new_array = ((new_array - min_value) / range_value) * 255
return new_array.astype(np.uint8)
def images_grid(self, images, n_cols):
"""
Arranging images in a grid suitable for plotting
"""
# Getting sizes of images and defining the grid shape
n_images, height, width, depth = images.shape
n_rows = n_images // n_cols
projected_images = n_rows * n_cols
# Scaling images to range 0-255
images = self.rescale_images(images)
# Fixing if projected images are less
if projected_images < n_images:
images = images[:projected_images]
# Placing images in a square arrangement
square_grid = images.reshape(n_rows, n_cols,
height, width, depth)
square_grid = square_grid.swapaxes(1, 2)
# Returning a image of the grid
if depth >= 3:
return square_grid.reshape(height * n_rows,
width * n_cols, depth)
else:
return square_grid.reshape(height * n_rows,
width * n_cols)
def plotting_images_grid(self, n_images, samples):
"""
Representing the images in a grid
"""
n_cols = math.floor(math.sqrt(n_images))
images_grid = self.images_grid(samples, n_cols)
plt.imshow(images_grid, cmap=self.cmap)
plt.show()
def show_generator_output(self, sess, n_images, input_z,
labels, out_channel_dim,
image_mode):
"""
Representing a sample of the
actual generator capabilities
"""
# Generating z input for examples
z_dim = input_z.get_shape().as_list()[-1]
example_z = np.random.uniform(-1, 1, size=[n_images, \
z_dim - labels.shape[1]])
example_z = np.concatenate((example_z, labels), axis=1)
# Running the generator
sample = sess.run(
self.generator(input_z, out_channel_dim, False),
feed_dict={input_z: example_z})
# Plotting the sample
self.plotting_images_grid(n_images, sample)
def show_original_images(self, n_images):
"""
Representing a sample of original images
"""
# Sampling from available images
index = np.random.randint(self.dataset.shape[0],
size=(n_images))
sample = self.dataset.data[index]
# Plotting the sample
self.plotting_images_grid(n_images, sample)
使用 Adam 优化器,判别器损失和生成器损失都会被减少,首先从判别器开始(建立生成器的输出与真实图像的对比),然后将反馈传递给生成器,基于生成器所生成的假图像对判别器的影响进行评估:
def optimization(self):
"""
GAN optimization procedure
"""
# Initialize the input and parameters placeholders
cases, image_width, image_height,\
out_channel_dim = self.dataset.shape
input_real, input_z, labels, learn_rate = \
self.instantiate_inputs(image_width,
image_height,
out_channel_dim,
self.z_dim,
len(self.dataset.classes))
# Define the network and compute the loss
d_loss, g_loss = self.loss(input_real, input_z,
labels, out_channel_dim)
# Enumerate the trainable_variables, split into G and D parts
d_vars = [v for v in tf.trainable_variables() \
if v.name.startswith('discriminator')]
g_vars = [v for v in tf.trainable_variables() \
if v.name.startswith('generator')]
self.g_vars = g_vars
# Optimize firt the discriminator, then the generatvor
with tf.control_dependencies(\
tf.get_collection(tf.GraphKeys.UPDATE_OPS)):
d_train_opt = tf.train.AdamOptimizer(
self.learning_rate,
self.beta1).minimize(d_loss, var_list=d_vars)
g_train_opt = tf.train.AdamOptimizer(
self.learning_rate,
self.beta1).minimize(g_loss, var_list=g_vars)
return input_real, input_z, labels, learn_rate,
d_loss, g_loss, d_train_opt, g_train_opt
最后,我们完成了整个训练阶段。在训练过程中,有两个部分需要特别注意:
-
优化是如何通过两个步骤进行的:
-
运行判别器优化
-
在生成器部分工作
-
-
如何通过将随机输入和真实图像与标签混合的方式预处理图像,从而创建包含图像标签的独热编码信息的额外图层
通过这种方式,类被整合进图像中,既作为输入也作为输出,迫使生成器在生成时也要考虑这些信息,因为如果它生成不真实的图像,即没有正确标签的图像,就会受到惩罚。比如说我们的生成器生成了一只猫的图像,但给它加上了狗的标签。在这种情况下,判别器会惩罚它,因为判别器会注意到生成的猫和真实的猫在标签上有差异:
def train(self, save_every_n=1000):
losses = []
step = 0
epoch_count = self.epochs
batch_size = self.batch_size
z_dim = self.z_dim
learning_rate = self.learning_rate
get_batches = self.dataset.get_batches
classes = len(self.dataset.classes)
data_image_mode = self.dataset.image_mode
cases, image_width, image_height,\
out_channel_dim = self.dataset.shape
input_real, input_z, labels, learn_rate, d_loss,\
g_loss, d_train_opt, g_train_opt = self.optimization()
# Allowing saving the trained GAN
saver = tf.train.Saver(var_list=self.g_vars)
# Preparing mask for plotting progression
rows, cols = min(5, classes), 5
target = np.array([self.dataset.one_hot[i] \
for j in range(cols) for i in range(rows)])
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
for epoch_i in range(epoch_count):
for batch_images, batch_labels \
in get_batches(batch_size):
# Counting the steps
step += 1
# Defining Z
batch_z = np.random.uniform(-1, 1, size=\
(len(batch_images), z_dim))
batch_z = np.concatenate((batch_z,\
batch_labels), axis=1)
# Reshaping labels for generator
batch_labels = batch_labels.reshape(batch_size, 1, 1, classes)
batch_labels = batch_labels * np.ones((batch_size, image_width, image_height, classes))
# Sampling random noise for G
batch_images = batch_images * 2
# Running optimizers
_ = sess.run(d_train_opt, feed_dict={input_real: batch_images, input_z: batch_z,
labels: batch_labels, learn_rate: learning_rate})
_ = sess.run(g_train_opt, feed_dict={input_z: batch_z, input_real: batch_images,
labels: batch_labels, learn_rate: learning_rate})
# Cyclic reporting on fitting and generator output
if step % (save_every_n//10) == 0:
train_loss_d = sess.run(d_loss,
{input_z: batch_z, input_real: batch_images, labels: batch_labels})
train_loss_g = g_loss.eval({input_z: batch_z, labels: batch_labels})
print("Epoch %i/%i step %i..." % (epoch_i + 1, epoch_count, step),
"Discriminator Loss: %0.3f..." % train_loss_d,
"Generator Loss: %0.3f" % train_loss_g)
if step % save_every_n == 0:
rows = min(5, classes)
cols = 5
target = np.array([self.dataset.one_hot[i] for j in range(cols) for i in range(rows)])
self.show_generator_output(sess, rows * cols, input_z, target, out_channel_dim, data_image_mode)
saver.save(sess, './'+self.generator_name+'/generator.ckpt')
# At the end of each epoch, get the losses and print them out
try:
train_loss_d = sess.run(d_loss, {input_z: batch_z, input_real: batch_images, labels: batch_labels})
train_loss_g = g_loss.eval({input_z: batch_z, labels: batch_labels})
print("Epoch %i/%i step %i..." % (epoch_i + 1, epoch_count, step),
"Discriminator Loss: %0.3f..." % train_loss_d,
"Generator Loss: %0.3f" % train_loss_g)
except:
train_loss_d, train_loss_g = -1, -1
# Saving losses to be reported after training
losses.append([train_loss_d, train_loss_g])
# Final generator output
self.show_generator_output(sess, rows * cols, input_z, target, out_channel_dim, data_image_mode)
saver.save(sess, './' + self.generator_name + '/generator.ckpt')
return np.array(losses)
在训练过程中,网络会不断保存到磁盘上。当需要生成新图像时,你无需重新训练,只需加载网络并指定你希望 GAN 生成的标签即可:
def generate_new(self, target_class=-1, rows=5, cols=5, plot=True):
"""
Generating a new sample
"""
# Fixing minimum rows and cols values
rows, cols = max(1, rows), max(1, cols)
n_images = rows * cols
# Checking if we already have a TensorFlow graph
if not self.trained:
# Operate a complete restore of the TensorFlow graph
tf.reset_default_graph()
self._session = tf.Session()
self._classes = len(self.dataset.classes)
self._input_z = tf.placeholder(tf.float32, (None, self.z_dim + self._classes), name='input_z')
out_channel_dim = self.dataset.shape[3]
# Restoring the generator graph
self._generator = self.generator(self._input_z, out_channel_dim)
g_vars = [v for v in tf.trainable_variables() if v.name.startswith('generator')]
saver = tf.train.Saver(var_list=g_vars)
print('Restoring generator graph')
saver.restore(self._session, tf.train.latest_checkpoint(self.generator_name))
# Setting trained flag as True
self.trained = True
# Continuing the session
sess = self._session
# Building an array of examples examples
target = np.zeros((n_images, self._classes))
for j in range(cols):
for i in range(rows):
if target_class == -1:
target[j * cols + i, j] = 1.0
else:
target[j * cols + i] = self.dataset.one_hot[target_class].tolist()
# Generating the random input
z_dim = self._input_z.get_shape().as_list()[-1]
example_z = np.random.uniform(-1, 1,
size=[n_images, z_dim - target.shape[1]])
example_z = np.concatenate((example_z, target), axis=1)
# Generating the images
sample = sess.run(
self._generator,
feed_dict={self._input_z: example_z})
# Plotting
if plot:
if rows * cols==1:
if sample.shape[3] <= 1:
images_grid = sample[0,:,:,0]
else:
images_grid = sample[0]
else:
images_grid = self.images_grid(sample, cols)
plt.imshow(images_grid, cmap=self.cmap)
plt.show()
# Returning the sample for later usage
# (and not closing the session)
return sample
这个类通过fit方法完成,该方法接受学习率参数和 beta1(一个 Adam 优化器参数,根据平均一阶矩调整参数的学习率,即均值),并在训练完成后绘制判别器和生成器的损失曲线:
def fit(self, learning_rate=0.0002, beta1=0.35):
"""
Fit procedure, starting training and result storage
"""
# Setting training parameters
self.learning_rate = learning_rate
self.beta1 = beta1
# Training generator and discriminator
with tf.Graph().as_default():
train_loss = self.train()
# Plotting training fitting
plt.plot(train_loss[:, 0], label='Discriminator')
plt.plot(train_loss[:, 1], label='Generator')
plt.title("Training fitting")
plt.legend()
将 CGAN 应用于一些示例
现在CGAN类已经完成,让我们通过一些示例来提供新的思路,帮助你更好地使用这个项目。首先,我们需要准备好所有必要的资源,包括下载所需的数据和训练我们的 GAN。我们从导入常用库开始:
import numpy as np
import urllib.request
import tarfile
import os
import zipfile
import gzip
import os
from glob import glob
from tqdm import tqdm
然后我们加载之前准备好的数据集和CGAN类:
from cGAN import Dataset, CGAN
TqdmUpTo类只是一个Tqdm的封装器,它使得进度条显示器也可以用于下载。这个类是直接从项目页面github.com/tqdm/tqdm中提取的:
class TqdmUpTo(tqdm):
"""
Provides `update_to(n)` which uses `tqdm.update(delta_n)`.
Inspired by https://github.com/pypa/twine/pull/242
https://github.com/pypa/twine/commit/42e55e06
"""
def update_to(self, b=1, bsize=1, tsize=None):
"""
Total size (in tqdm units).
If [default: None] remains unchanged.
"""
if tsize is not None:
self.total = tsize
# will also set self.n = b * bsize
self.update(b * bsize - self.n)
最后,如果我们使用的是 Jupyter Notebook(强烈建议在本次展示中使用),你需要启用图像的内联显示:
%matplotlib inline
现在我们准备好继续进行第一个示例了。
MNIST
MNIST手写数字数据库由 Yann LeCun 在纽约大学 Courant 研究所时提供,并由 Corinna Cortes(谷歌实验室)和 Christopher J.C. Burges(微软研究院)共同提供。它被认为是从现实世界图像数据中学习的标准数据库,且在预处理和格式化方面所需的努力最小。该数据库包含手写数字,提供了 60,000 个训练样本和 10,000 个测试样本。它实际上是从 NIST 的一个更大数据集中提取的子集。所有数字都已经大小归一化并居中在固定尺寸的图像中:

图 5:原始 MNIST 样本有助于理解 CGAN 重建图像的质量。
首先,我们从互联网上上传数据集并将其保存在本地:
labels_filename = 'train-labels-idx1-ubyte.gz'
images_filename = 'train-images-idx3-ubyte.gz'
url = "http://yann.lecun.com/exdb/mnist/"
with TqdmUpTo() as t: # all optional kwargs
urllib.request.urlretrieve(url+images_filename,
'MNIST_'+images_filename,
reporthook=t.update_to, data=None)
with TqdmUpTo() as t: # all optional kwargs
urllib.request.urlretrieve(url+labels_filename,
'MNIST_'+labels_filename,
reporthook=t.update_to, data=None)
为了学习这一组手写数字,我们应用了 32 张图像的批量,学习率为0.0002,beta1为0.35,z_dim为96,并进行了15轮训练:
labels_path = './MNIST_train-labels-idx1-ubyte.gz'
images_path = './MNIST_train-images-idx3-ubyte.gz'
with gzip.open(labels_path, 'rb') as lbpath:
labels = np.frombuffer(lbpath.read(),
dtype=np.uint8, offset=8)
with gzip.open(images_path, 'rb') as imgpath:
images = np.frombuffer(imgpath.read(), dtype=np.uint8,
offset=16).reshape(len(labels), 28, 28, 1)
batch_size = 32
z_dim = 96
epochs = 16
dataset = Dataset(images, labels, channels=1)
gan = CGAN(dataset, epochs, batch_size, z_dim, generator_name='mnist')
gan.show_original_images(25)
gan.fit(learning_rate = 0.0002, beta1 = 0.35)
以下图像展示了 GAN 在第二轮和最后一轮生成的数字样本:

图 6:GAN 的结果随着训练轮次的变化
经过 16 轮训练后,数字看起来已经形成良好,准备使用。然后我们提取了按行排列的所有类别样本。
评估 GAN 的性能通常依赖于人工评估其某些结果,通过观察图像的整体外观或细节来判断图像是否可能是伪造的(像判别器一样)。GAN 缺乏一个客观函数来帮助评估和比较它们,尽管有一些计算技术可以作为评估指标,例如对数似然,如THEIS, Lucas; OORD, Aäron van den; BETHGE, Matthias. 关于生成模型评估的笔记. arXiv 预印本 arXiv:1511.01844
, 2015
: arxiv.org/abs/1511.01844所描述。
我们将保持评价方法简单且经验性,因此我们将使用由训练过的 GAN 生成的图像样本来评估网络的表现,同时尝试检查生成器和判别器的训练损失,以便发现任何特殊趋势:

图 7:在 MNIST 上训练后的最终结果样本显示,GAN 网络能够完成这一任务
观察训练拟合图(下图所示),我们可以看到生成器在训练完成时达到了最低误差。判别器在经历了一个先前的峰值后,正在努力恢复到之前的性能值,这表明生成器可能有了突破。我们可以预期,更多的训练周期可能会提高该 GAN 网络的性能,但随着输出质量的提升,所需时间可能会呈指数增长。通常,GAN 收敛的良好指标是生成器和判别器的损失值都呈下降趋势,这一点可以通过对两个损失向量拟合一条线性回归线来推测:

图 8:16 个训练周期内的拟合情况
训练一个出色的 GAN 网络可能需要很长时间和大量的计算资源。通过阅读《纽约时报》近期发布的文章,www.nytimes.com/interactive/2018/01/02/technology/ai-generated-photos.html,你可以看到来自 NVIDIA 的图表,展示了一个渐进式 GAN 学习名人照片的训练进度。虽然获得一个不错的结果可能只需要几天时间,但要达到惊人的效果至少需要两周。同样地,即使是我们的例子,投入更多的训练周期,结果也会变得更好。
Zalando MNIST
Fashion MNIST 是 Zalando 文章图像的数据集,由 60,000 个训练样本和 10,000 个测试样本组成。与 MNIST 类似,每个样本都是一个 28x28 的灰度图像,带有 10 个类别中的一个标签。Zalando Research 的作者们意图将其作为原始 MNIST 数据集的替代品,以更好地评估机器学习算法,因为它比 MNIST 更具挑战性,并且更能代表现实任务中的深度学习(twitter.com/fchollet/status/852594987527045120)。
github.com/zalandoresearch/fashion-mnist

图 9:原始 Zalando 数据集的一个样本
我们分别下载图像和它们的标签:
url = "http://fashion-mnist.s3-website.eu-central-\
1.amazonaws.com/train-images-idx3-ubyte.gz"
filename = "train-images-idx3-ubyte.gz"
with TqdmUpTo() as t: # all optional kwargs
urllib.request.urlretrieve(url, filename,
reporthook=t.update_to, data=None)
url = "http://fashion-mnist.s3-website.eu-central-\
1.amazonaws.com/train-labels-idx1-ubyte.gz"
filename = "train-labels-idx1-ubyte.gz"
_ = urllib.request.urlretrieve(url, filename)
为了学习这组图像,我们应用了一个包含 32 张图像的批次,学习率为0.0002,beta1为0.35,z_dim为96,并进行了10个周期的训练:
labels_path = './train-labels-idx1-ubyte.gz'
images_path = './train-images-idx3-ubyte.gz'
label_names = ['t_shirt_top', 'trouser', 'pullover',
'dress', 'coat', 'sandal', 'shirt',
'sneaker', 'bag', 'ankle_boots']
with gzip.open(labels_path, 'rb') as lbpath:
labels = np.frombuffer(lbpath.read(),
dtype=np.uint8,
offset=8)
with gzip.open(images_path, 'rb') as imgpath:
images = np.frombuffer(imgpath.read(), dtype=np.uint8,
offset=16).reshape(len(labels), 28, 28, 1)
batch_size = 32
z_dim = 96
epochs = 64
dataset = Dataset(images, labels, channels=1)
gan = CGAN(dataset, epochs, batch_size, z_dim, generator_name='zalando')
gan.show_original_images(25)
gan.fit(learning_rate = 0.0002, beta1 = 0.35)
训练需要很长时间才能完成所有周期,但质量似乎很快会稳定下来,尽管有些问题需要更多周期才能消失(例如衬衫上的孔洞):

图 10:CGAN 训练过程随周期的演变
这是 64 个 epoch 后的结果:

图 11:在 Zalando 数据集上进行 64 个 epoch 后的结果概览
结果完全令人满意,尤其是衣服和男鞋。然而,女鞋似乎更难学习,因为它们比其他图像更小且更精细。
EMNIST
EMNIST 数据集是一组手写字符数字,源自 NIST 特别数据库,并转换为 28 x 28 像素的图像格式和数据集结构,直接匹配 MNIST 数据集。我们将使用 EMNIST Balanced,它是一个每个类别样本数相等的字符集,包含 131,600 个字符,分布在 47 个平衡类别中。你可以在以下位置找到所有与该数据集相关的参考文献:
Cohen, G., Afshar, S., Tapson, J., & van Schaik, A. (2017)。EMNIST:MNIST 的手写字母扩展。检索自 arxiv.org/abs/1702.05373。
你还可以通过浏览数据集的官方网站,探索关于EMNIST的完整信息:www.nist.gov/itl/iad/image-group/emnist-dataset。以下是 EMNIST Balanced 中可以找到的字符类型的提取:

图 11:原始 EMNIST 数据集的示例
url = "http://biometrics.nist.gov/cs_links/EMNIST/gzip.zip"
filename = "gzip.zip"
with TqdmUpTo() as t: # all optional kwargs
urllib.request.urlretrieve(url, filename,
reporthook=t.update_to,
data=None)
从 NIST 网站下载后,我们解压了下载的包:
zip_ref = zipfile.ZipFile(filename, 'r')
zip_ref.extractall('.')
zip_ref.close()
在确认解压成功后,我们删除未使用的 ZIP 文件:
if os.path.isfile(filename):
os.remove(filename)
为了学习这组手写数字,我们应用了一个包含 32 张图像的批次,学习率为 0.0002,beta1 为 0.35,z_dim 为 96,并进行了 10 个 epoch 的训练:
labels_path = './gzip/emnist-balanced-train-labels-idx1-ubyte.gz'
images_path = './gzip/emnist-balanced-train-images-idx3-ubyte.gz'
label_names = []
with gzip.open(labels_path, 'rb') as lbpath:
labels = np.frombuffer(lbpath.read(), dtype=np.uint8,
offset=8)
with gzip.open(images_path, 'rb') as imgpath:
images = np.frombuffer(imgpath.read(), dtype=np.uint8,
offset=16).reshape(len(labels), 28, 28, 1)
batch_size = 32
z_dim = 96
epochs = 32
dataset = Dataset(images, labels, channels=1)
gan = CGAN(dataset, epochs, batch_size, z_dim,
generator_name='emnist')
gan.show_original_images(25)
gan.fit(learning_rate = 0.0002, beta1 = 0.35)
以下是完成 32 个 epoch 训练后的部分手写字母示例:

图 12:在 EMNIST 数据集上训练 CGAN 后的结果概览
对于 MNIST,GAN 可以在合理的时间内学习以准确、可信的方式复制手写字母。
重用训练好的 CGAN
训练好一个 CGAN 后,你可能会发现将生成的图像用于其他应用非常有用。方法 generate_new 可以用来提取单张图像或一组图像(以便检查特定图像类别的结果质量)。它作用于先前训练好的 CGan 类,因此你只需要先将其保存,然后在需要时恢复。
当训练完成后,你可以使用 pickle 保存你的 CGan 类,命令如下所示:
import pickle
pickle.dump(gan, open('mnist.pkl', 'wb'))
在这种情况下,我们已保存了在 MNIST 数据集上训练的 CGAN。
在重新启动 Python 会话并清理内存中的任何变量后,你可以再次 import 所有类,并恢复已保存的 CGan:
from CGan import Dataset, CGan
import pickle
gan = pickle.load(open('mnist.pkl', 'rb'))
完成后,设置你希望CGan生成的目标类别(在本例中我们要求打印数字8),你可以请求生成单个示例、一个 5 x 5 的网格或一个更大的 10 x 10 网格:
nclass = 8
_ = gan.generate_new(target_class=nclass,
rows=1, cols=1, plot=True)
_ = gan.generate_new(target_class=nclass,
rows=5, cols=5, plot=True)
images = gan.generate_new(target_class=nclass,
rows=10, cols=10, plot=True)
print(images.shape)
如果你只是想获得所有类别的概览,只需将参数target_class设置为-1。
在设置目标类别之后,generate_new方法被调用三次,最后返回的值存储在images变量中,该变量的大小为(100, 28, 28, 1),包含生成的图像的 Numpy 数组,可以用于我们的目的。每次调用该方法时,都会绘制一个结果网格,如下图所示:

图 13:绘制的网格是生成图像的组合,实际上是一个图像。从左到右,图像的绘制过程是
请求 1 x 1、5 x 5、10 x 10 结果网格。方法返回的真实图像可以重复使用。
如果你不需要generate_new来绘制结果,只需将plot参数设置为 False:images = gan.generate_new(target_class=nclass, rows=10, cols=10, plot=False)。
使用 Amazon Web Service
如前所述,强烈建议你使用 GPU 来训练本章中提出的示例。仅使用 CPU 在合理的时间内获得结果几乎是不可能的,甚至使用 GPU 也可能需要长时间等待计算机完成训练。一个需要支付费用的解决方案是使用 Amazon Elastic Compute Cloud,也就是 Amazon EC2 (aws.amazon.com/it/ec2/),它是Amazon Web Services(AWS)的一部分。在 EC2 上,你可以启动虚拟服务器,并通过互联网连接从你的计算机进行控制。你可以在 EC2 上要求强大的 GPU 服务器,使得 TensorFlow 项目的工作变得更加轻松。
Amazon EC2 并不是唯一的云服务提供商。我们推荐这个服务,因为它是我们在本书中测试代码时使用的。实际上,也有其他替代服务,比如 Google Cloud Compute (cloud.google.com)、Microsoft Azure(azure.microsoft.com)以及许多其他服务。
在 EC2 上运行本章的代码需要在 AWS 中拥有一个账户。如果你还没有账户,第一步是注册到aws.amazon.com,填写所有必要的表格,并开始使用免费的基础支持计划。
在你注册 AWS 之后,只需登录并访问 EC2 页面(aws.amazon.com/ec2),在该页面你将会:
-
选择一个既便宜又靠近你的区域,并且该区域支持我们需要的 GPU 实例,包括欧盟(爱尔兰)、亚太地区(东京)、美国东部(北弗吉尼亚)和美国西部(俄勒冈)。
-
在以下网址升级你的 EC2 服务限制报告:
console.aws.amazon.com/ec2/v2/home?#Limits。你将需要访问p3.2xlarge实例。因此,如果你当前的限制为零,至少应该通过请求增加配额表格将其提高到一个(这可能需要最多 24 小时,完成之前你无法访问这种类型的实例)。 -
获取一些 AWS 积分(例如提供你的信用卡信息)。
设置好你的区域,并确保有足够的信用和请求配额增加后,你可以启动一个p3.2xlarge服务器(用于深度学习应用的 GPU 计算服务器),该服务器配有已经包含所有必要软件的操作系统(感谢 Amazon 提供的 AMI,预先准备好的镜像):
-
进入 EC2 管理控制台,点击启动实例按钮。
-
点击 AWS Marketplace,搜索Deep Learning AMI with Source Code v2.0 (ami-bcce6ac4) AMI。这个 AMI 已经预安装了所有必要的东西:CUDA、cuDNN(
developer.nvidia.com/cudnn)、Tensorflow。 -
选择GPU计算p3.2xlarge实例。这个实例配备了强大的 NVIDIA Tesla V100 GPU。
-
配置一个安全组(你可以称之为Jupyter),通过添加自定义 TCP 规则,使用 TCP 协议,指定
port 8888,并允许任何地方访问。这将允许你在机器上运行 Jupyter 服务器,并且从任何连接到互联网的计算机上查看界面。 -
创建一个认证密钥对。你可以将其命名为
deeplearning_jupyter.pem例如。将其保存在一个你容易访问的目录中。 -
启动实例。记住,从此刻开始,你将开始付费,除非你在 AWS 菜单中选择停止它——这样你仍然会产生一些费用,但这些费用较小,并且你可以随时使用该实例,所有数据都在;或者你可以选择终止它,这样将不再产生任何费用。
一切启动后,你可以通过 ssh 从你的计算机访问服务器。
-
注意机器的 IP 地址。假设它是
xx.xx.xxx.xxx,作为示例。 -
在指向
.pem文件所在目录的 shell 中,输入:ssh -i deeplearning_jupyter.pem ubuntu@ xx.xx.xxx.xxx -
访问到服务器机器后,通过输入以下命令来配置 Jupyter 服务器:
jupyter notebook --generate-configsed -ie "s/#c.NotebookApp.ip = 'localhost'/#c.NotebookApp.ip = '*'/g" ~/.jupyter/jupyter_notebook_config.py -
在服务器上操作,复制代码(例如通过 git 克隆代码库)并安装任何你可能需要的库。例如,你可以为这个特定项目安装以下包:
sudo pip3 install tqdmsudo pip3 install conda -
运行命令启动 Jupyter 服务器:
jupyter notebook --ip=0.0.0.0 --no-browser -
此时,服务器将运行,并且您的 ssh shell 将提示您查看 Jupyter 的日志。在日志中,请注意 token(类似于一串数字和字母的序列)。
-
打开您的浏览器并在地址栏中输入:
http:// xx.xx.xxx.xxx:8888/
当需要时输入 token,您就可以像在本地计算机上一样使用 Jupyter 笔记本,但实际上它是在服务器上运行。此时,您将拥有一个强大的带有 GPU 的服务器,用于运行所有与 GAN 相关的实验。
致谢
在结束本章时,我们要感谢 Udacity 和 Mat Leonard 的 DCGAN 教程,根据 MIT 许可证(github.com/udacity/deep-learning/blob/master/LICENSE)提供了这个项目的良好起点和基准。
总结
在本章中,我们详细讨论了生成对抗网络的主题,它们的工作原理以及如何训练和用于不同目的。作为一个项目,我们创建了一个条件 GAN,它可以根据您的输入生成不同类型的图像,并且我们学习了如何处理一些示例数据集并训练它们,以便拥有一个可随需求生成新图像的可选类。
第五章:基于 LSTM 的股票价格预测
在本章中,您将学习如何预测由真实值组成的时间序列。具体来说,我们将根据历史表现预测一家在纽约证券交易所上市的大型公司的股票价格。
在本章中,我们将探讨:
-
如何收集历史股票价格信息
-
如何为时间序列预测任务格式化数据集
-
如何使用回归预测股票的未来价格
-
长短期记忆(LSTM)101
-
LSTM 如何提升预测性能
-
如何在 Tensorboard 上可视化性能
每个要点都是本章的一个部分。此外,为了让本章在视觉和直观上更易理解,我们将首先在一个简单的信号上应用每个技术:一个余弦波。余弦波比股票价格更具确定性,有助于理解算法的潜力。
注意:我们想指出,这个项目只是一个实验,使用的是我们可以获得的简单数据。请不要在实际场景中使用这段代码或相同的模型,因为它可能无法达到同样的效果。记住:您的资金存在风险,且没有任何保证您总是能够获利。
输入数据集 – 余弦波与股票价格
正如我们之前所述,我们将使用两个一维信号作为实验中的时间序列。第一个是带有一定均匀噪声的余弦波。
这是生成余弦信号的函数,给定(作为参数)数据点的数量、信号的频率以及均匀生成器的绝对强度噪声。此外,在函数体内,我们确保设置了随机种子,以便让我们的实验可复制:
def fetch_cosine_values(seq_len, frequency=0.01, noise=0.1):
np.random.seed(101)
x = np.arange(0.0, seq_len, 1.0)
return np.cos(2 * np.pi * frequency * x) + np.random.uniform(low=-noise, high=noise, size=seq_len)
要打印 10 个数据点,一个完整的余弦波振荡(因此 frequency 为 0.1),并加上 0.1 强度的噪声,请运行:
print(fetch_cosine_values(10, frequency=0.1))
输出为:
[ 1.00327973 0.82315051 0.21471184 -0.37471266 -0.7719616 -0.93322063
-0.84762375 -0.23029438 0.35332577 0.74700479]
在我们的分析中,我们将假设这是一只股票的价格,其中每个时间序列的点都是一个一维特征,代表该股票在当天的价格。
第二个信号来自真实的金融世界。金融数据可能很昂贵且难以提取,这就是为什么在这个实验中我们使用 Python 库 quandl 来获取这些信息。选择这个库是因为它易于使用、便宜(每天有 XX 次免费查询)且非常适合本次练习,我们只预测股票的收盘价。如果你对自动化交易感兴趣,可以进一步了解该库的付费版本,或者查看其他库或数据源。
Quandl 是一个 API,Python 库是对该 API 的封装。要查看返回的内容,在命令行中运行以下命令:
$> curl "https://www.quandl.com/api/v3/datasets/WIKI/FB/data.csv"
Date,Open,High,Low,Close,Volume,Ex-Dividend,Split Ratio,Adj. Open,Adj. High,Adj. Low,Adj. Close,Adj. Volume
2017-08-18,166.84,168.67,166.21,167.41,14933261.0,0.0,1.0,166.84,168.67,166.21,167.41,14933261.0
2017-08-17,169.34,169.86,166.85,166.91,16791591.0,0.0,1.0,169.34,169.86,166.85,166.91,16791591.0
2017-08-16,171.25,171.38,169.24,170.0,15580549.0,0.0,1.0,171.25,171.38,169.24,170.0,15580549.0
2017-08-15,171.49,171.5,170.01,171.0,8621787.0,0.0,1.0,171.49,171.5,170.01,171.0,8621787.0
...
格式是 CSV,每一行包含日期、开盘价、当天的最高价和最低价、收盘价、调整后的收盘价以及一些成交量数据。行按照从最近到最远的顺序排序。我们关心的列是Adj. Close,即调整后的收盘价。
调整后的收盘价是指在包含任何股息、拆股或合并的修改后,股票的收盘价格。
请记住,许多在线服务显示的是未调整价格或开盘价,因此数字可能不完全匹配。
现在,让我们构建一个 Python 函数,通过 Python API 提取调整后的价格。API 的完整文档可以在docs.quandl.com/v1.0/docs查看,但我们这里只使用quandl.get函数。请注意,默认排序是升序的,也就是说,从最旧的价格到最新的价格。
我们要找的函数应该能够缓存调用并指定初始和最终时间戳,以获取符号之外的历史数据。以下是实现此功能的代码:
def date_obj_to_str(date_obj):
return date_obj.strftime('%Y-%m-%d')
def save_pickle(something, path):
if not os.path.exists(os.path.dirname(path)):
os.makedirs(os.path.dirname(path))
with open(path, 'wb') as fh:
pickle.dump(something, fh, pickle.DEFAULT_PROTOCOL)
def load_pickle(path):
with open(path, 'rb') as fh:
return pickle.load(fh)
def fetch_stock_price(symbol,
from_date,
to_date,
cache_path="./tmp/prices/"):
assert(from_date <= to_date)
filename = "{}_{}_{}.pk".format(symbol, str(from_date), str(to_date))
price_filepath = os.path.join(cache_path, filename)
try:
prices = load_pickle(price_filepath)
print("loaded from", price_filepath)
except IOError:
historic = quandl.get("WIKI/" + symbol,
start_date=date_obj_to_str(from_date),
end_date=date_obj_to_str(to_date))
prices = historic["Adj. Close"].tolist()
save_pickle(prices, price_filepath)
print("saved into", price_filepath)
return prices
函数fetch_stock_price返回的对象是一个一维数组,包含所请求符号的股票价格,按from_date到to_date的顺序排列。缓存操作是在函数内部完成的,也就是说,如果发生缓存缺失,则调用quandl API。date_obj_to_str函数只是一个帮助函数,用于将datetime.date转换为 API 所需的正确字符串格式。
让我们打印出 Google 股票在 2017 年 1 月的调整后价格(其符号是 GOOG):
import datetime
print(fetch_stock_price("GOOG",
datetime.date(2017, 1, 1),
datetime.date(2017, 1, 31)))
输出结果为:
[786.14, 786.9, 794.02, 806.15, 806.65, 804.79, 807.91, 806.36, 807.88, 804.61, 806.07, 802.175, 805.02, 819.31, 823.87, 835.67, 832.15, 823.31, 802.32, 796.79]
为了让所有前面的函数在所有脚本中都可用,我们建议将它们放在一个 Python 文件中,例如,在本书中分发的代码里,它们位于tools.py文件中。
格式化数据集
经典的机器学习算法需要多个观测值,每个观测值都有一个预定义的大小(即特征大小)。在处理时间序列时,我们没有预定义的长度:我们想要创建一个既能适用于回溯 10 天的数据,也能适用于回溯三年的数据。那么如何做到这一点呢?
这非常简单,我们不会改变特征数量,而是改变观测值的数量,保持特征大小不变。每个观测值代表时间序列的一个时间窗口,通过将窗口向右滑动一位,我们就创建了另一个观测值。代码如下:
def format_dataset(values, temporal_features):
feat_splits = [values[i:i + temporal_features] for i in range(len(values) - temporal_features)]
feats = np.vstack(feat_splits)
labels = np.array(values[temporal_features:])
return feats, labels
给定时间序列和特征大小,该函数创建一个滑动窗口,扫描时间序列,生成特征和标签(即,在每次迭代时,滑动窗口结束后的值)。最后,所有观测值和标签都会垂直堆叠起来。结果是一个具有定义列数的观测值,以及一个标签向量。
我们建议将这个函数放在tools.py文件中,这样以后可以方便访问。
从图形上来看,下面是操作的结果。从余弦信号开始,首先我们在另一个 Python 脚本中绘制几个波动(在示例中,它被命名为1_visualization_data.py):
import datetime
import matplotlib.pyplot as plt
import numpy as np
import seaborn
from tools import fetch_cosine_values, fetch_stock_price, format_dataset
np.set_printoptions(precision=2)
cos_values = fetch_cosine_values(20, frequency=0.1)
seaborn.tsplot(cos_values)
plt.xlabel("Days since start of the experiment")
plt.ylabel("Value of the cosine function")
plt.title("Cosine time series over time")
plt.show()
代码非常简单;在导入了一些库之后,我们绘制了一个周期为 10(即频率为 0.01)的 20 点余弦时间序列:

现在让我们将时间序列格式化,以便机器学习算法可以处理,创建一个包含五列的观察矩阵:
features_size = 5
minibatch_cos_X, minibatch_cos_y = format_dataset(cos_values, features_size)
print("minibatch_cos_X.shape=", minibatch_cos_X.shape)
print("minibatch_cos_y.shape=", minibatch_cos_y.shape)
从一个包含 20 个数据点的时间序列开始,输出将是一个大小为15x5的观察矩阵,而标签向量将有 15 个元素。当然,通过改变特征大小,行数也会发生变化。
现在让我们可视化这个操作,使其更容易理解。例如,绘制观察矩阵的前五个观察值。我们还将打印每个特征的标签(用红色标记):
samples_to_plot = 5
f, axarr = plt.subplots(samples_to_plot, sharex=True)
for i in range(samples_to_plot):
feats = minibatch_cos_X[i, :]
label = minibatch_cos_y[i]
print("Observation {}: X={} y={}".format(i, feats, label))
plt.subplot(samples_to_plot, 1, i+1)
axarr[i].plot(range(i, features_size + i), feats, '--o')
axarr[i].plot([features_size + i], label, 'rx')
axarr[i].set_ylim([-1.1, 1.1])
plt.xlabel("Days since start of the experiment")
axarr[2].set_ylabel("Value of the cosine function")
axarr[0].set_title("Visualization of some observations: Features (blue) and Labels (red)")
plt.show()
下面是图示:

如你所见,时间序列变成了一个观察向量,每个向量的大小为五。
到目前为止,我们还没有展示股票价格的样子,因此我们在这里将其作为时间序列打印出来。我们选择了(精心挑选)一些美国最著名的公司;你也可以随意添加自己喜欢的公司,查看过去一年的趋势。在这个图示中,我们将只限于两年:2015 年和 2016 年。我们将在本章中使用完全相同的数据,因此接下来的运行将会缓存时间序列:
symbols = ["MSFT", "KO", "AAL", "MMM", "AXP", "GE", "GM", "JPM", "UPS"]
ax = plt.subplot(1,1,1)
for sym in symbols:
prices = fetch_stock_price(
sym, datetime.date(2015, 1, 1), datetime.date(2016, 12, 31))
ax.plot(range(len(prices)), prices, label=sym)
handles, labels = ax.get_legend_handles_labels()
ax.legend(handles, labels)
plt.xlabel("Trading days since 2015-1-1")
plt.ylabel("Stock price [$]")
plt.title("Prices of some American stocks in trading days of 2015 and 2016")
plt.show()
这是价格的图示:

每一条线代表一个时间序列,正如我们对余弦信号所做的那样,在本章中它将被转化为一个观察矩阵(使用format_dataset函数)。
你兴奋吗?数据已经准备好,现在我们可以进入项目中有趣的数据科学部分了。
使用回归预测股票的未来价格
给定观察矩阵和一个实际值标签,我们最初会倾向于将问题视为回归问题。在这种情况下,回归非常简单:从一个数值向量出发,我们希望预测一个数值。这并不是最理想的做法。将问题作为回归问题处理,我们迫使算法认为每个特征是独立的,但实际上它们是相关的,因为它们都是同一个时间序列的窗口。无论如何,我们先从这个简单的假设(每个特征是独立的)开始,在下一章中我们将展示如何通过利用时间相关性提高性能。
为了评估模型,我们现在创建一个函数,给定观测矩阵、真实标签和预测标签,它将输出预测的度量(以均方误差(MSE)和平均绝对误差(MAE)的形式)。它还会将训练、测试和预测的时间序列绘制在一起,以便直观地检查性能。为了比较结果,我们还包括了在没有使用任何模型时的度量,即我们简单地将第二天的值预测为当前的值(在股票市场中,这意味着我们将预测明天的价格为今天的股票价格)。
在此之前,我们需要一个辅助函数,将矩阵重塑为一维(1D)数组。请将此函数保留在tools.py文件中,因为多个脚本都会使用它:
def matrix_to_array(m):
return np.asarray(m).reshape(-1)
现在,是时候编写评估函数了。我们决定将这个函数放入evaluate_ts.py文件中,这样其他脚本可以访问它:
import numpy as np
from matplotlib import pylab as plt
from tools import matrix_to_array
def evaluate_ts(features, y_true, y_pred):
print("Evaluation of the predictions:")
print("MSE:", np.mean(np.square(y_true - y_pred)))
print("mae:", np.mean(np.abs(y_true - y_pred)))
print("Benchmark: if prediction == last feature")
print("MSE:", np.mean(np.square(features[:, -1] - y_true)))
print("mae:", np.mean(np.abs(features[:, -1] - y_true)))
plt.plot(matrix_to_array(y_true), 'b')
plt.plot(matrix_to_array(y_pred), 'r--')
plt.xlabel("Days")
plt.ylabel("Predicted and true values")
plt.title("Predicted (Red) VS Real (Blue)")
plt.show()
error = np.abs(matrix_to_array(y_pred) - matrix_to_array(y_true))
plt.plot(error, 'r')
fit = np.polyfit(range(len(error)), error, deg=1)
plt.plot(fit[0] * range(len(error)) + fit[1], '--')
plt.xlabel("Days")
plt.ylabel("Prediction error L1 norm")
plt.title("Prediction error (absolute) and trendline")
plt.show()
现在,是时候进入建模阶段了。
如之前所述,我们先从余弦信号开始,然后转到股票价格预测。
我们还建议将以下代码放在另一个文件中,例如2_regression_cosine.py(你可以在代码包中找到这个文件名对应的代码)。
首先导入一些库,并设置numpy和tensorflow的种子:
import matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf
from evaluate_ts import evaluate_ts
from tensorflow.contrib import rnn
from tools import fetch_cosine_values, format_dataset
tf.reset_default_graph()
tf.set_random_seed(101)
然后,是时候创建余弦信号并将其转化为观测矩阵。在这个例子中,我们将使用 20 作为特征大小,因为它大致相当于一个月的工作日数量。回归问题现在被设定为:给定过去 20 个余弦值,预测下一天的值。
在训练和测试时,我们将使用每个250观测的数据集,以相当于一年的数据(一年大约包含250个工作日)。在这个例子中,我们将只生成一个余弦信号,然后将其拆分成两部分:前一半包含训练数据,后一半包含测试数据。你可以自由更改这些,观察当这些参数变化时,性能如何变化:
feat_dimension = 20
train_size = 250
test_size = 250
- 现在,在脚本的这一部分,我们将定义一些 Tensorflow 的参数。更具体地说:学习率、优化器类型和
epoch数量(即训练操作中训练数据集进入学习器的次数)。这些值并不是最佳的,你可以自由更改它们,以预测更好的结果:
learning_rate = 0.01
optimizer = tf.train.AdamOptimizer
n_epochs = 10
- 最后,是时候准备训练和测试的观测矩阵了。请记住,为了加速 Tensorflow 分析,我们将使用
float32(4 字节长)进行分析:
cos_values = fetch_cosine_values(train_size + test_size + feat_dimension)
minibatch_cos_X, minibatch_cos_y = format_dataset(cos_values, feat_dimension)
train_X = minibatch_cos_X[:train_size, :].astype(np.float32)
train_y = minibatch_cos_y[:train_size].reshape((-1, 1)).astype(np.float32)
test_X = minibatch_cos_X[train_size:, :].astype(np.float32)
test_y = minibatch_cos_y[train_size:].reshape((-1, 1)).astype(np.float32)
给定数据集后,我们现在定义观测矩阵和标签的占位符。由于我们正在构建一个通用脚本,我们只设置特征数量,而不设置观测数量:
X_tf = tf.placeholder("float", shape=(None, feat_dimension), name="X")
y_tf = tf.placeholder("float", shape=(None, 1), name="y")
这是我们项目的核心:在 Tensorflow 中实现的回归算法。
- 我们选择了最经典的实现方式,即将观测矩阵与权重数组相乘再加上偏置。输出的结果(也是该函数的返回值)是包含所有观测值预测结果的数组,针对
x中的所有观测:
def regression_ANN(x, weights, biases):
return tf.add(biases, tf.matmul(x, weights))
- 现在,让我们定义回归器的可训练参数,即
tensorflow变量。权重是一个与特征大小相等的值的向量,而偏置则只是一个标量。
请注意,我们使用截断正态分布初始化权重,以使其接近零,但不会过于极端(因为普通正态分布可能会输出极端值);偏置则设置为零。
再次,您可以自由地更改初始化方式,以查看性能变化:
weights = tf.Variable(tf.truncated_normal([feat_dimension, 1], mean=0.0, stddev=1.0), name="weights")
biases = tf.Variable(tf.zeros([1, 1]), name="bias")
- 在
tensorflow图中,我们需要定义的最后一项是如何计算预测值(在我们的例子中,它只是定义模型的函数的输出),代价(在此示例中我们使用的是 MSE),以及训练操作符(我们希望最小化 MSE,使用之前设置的学习率优化器):
y_pred = regression_ANN(X_tf, weights, biases)
cost = tf.reduce_mean(tf.square(y_tf - y_pred))
train_op = optimizer(learning_rate).minimize(cost)
现在,我们准备打开tensorflow会话,开始训练模型。
- 我们将首先初始化变量,然后在一个循环中将
training数据集输入到tensorflow图中(使用占位符)。每次迭代时,我们将打印训练 MSE:
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
# For each epoch, the whole training set is feeded into the tensorflow graph
for i in range(n_epochs):
train_cost, _ = sess.run([cost, train_op], feed_dict={X_tf: train_X, y_tf: train_y})
print("Training iteration", i, "MSE", train_cost)
# After the training, let's check the performance on the test set
test_cost, y_pr = sess.run([cost, y_pred], feed_dict={X_tf: test_X, y_tf: test_y})
print("Test dataset:", test_cost)
# Evaluate the results
evaluate_ts(test_X, test_y, y_pr)
# How does the predicted look like?
plt.plot(range(len(cos_values)), cos_values, 'b')
plt.plot(range(len(cos_values)-test_size, len(cos_values)), y_pr, 'r--')
plt.xlabel("Days")
plt.ylabel("Predicted and true values")
plt.title("Predicted (Red) VS Real (Blue)")
plt.show()
训练后,我们评估了测试数据集上的 MSE,最后,我们打印并绘制了模型的性能。
使用我们在脚本中提供的默认值时,性能比没有模型的性能更差。通过一些调整,结果有所改善。例如,将学习率设置为 0.1,训练的 epoch 数设置为1000,脚本的输出将类似于以下内容:
Training iteration 0 MSE 4.39424
Training iteration 1 MSE 1.34261
Training iteration 2 MSE 1.28591
Training iteration 3 MSE 1.84253
Training iteration 4 MSE 1.66169
Training iteration 5 MSE 0.993168
...
...
Training iteration 998 MSE 0.00363447
Training iteration 999 MSE 0.00363426
Test dataset: 0.00454513
Evaluation of the predictions:
MSE: 0.00454513
mae: 0.0568501
Benchmark: if prediction == last feature
MSE: 0.964302
mae: 0.793475
训练性能和测试性能非常相似(因此我们没有对模型进行过拟合),MSE 和 MAE 都比没有建模的预测要好。
这就是每个时间点的误差情况。看起来它被控制在+/-0.15 之间,并且随着时间的推移没有任何趋势。请记住,我们用余弦函数人为引入的噪声大小为+/-0.1,且呈均匀分布:

最后,最后一张图显示了训练时间序列与预测时间序列的重叠情况。对于一个简单的线性回归模型来说,不错吧?

现在,让我们将相同的模型应用于股价预测。我们建议您将当前文件的内容复制到一个新文件中,命名为3_regression_stock_price.py。在这里,我们将只更改数据导入部分,其他保持不变。
在本例中,我们使用微软股票价格,符号为 "MSFT"。加载此符号的 2015/16 年股票价格并将其格式化为观察矩阵非常简单。下面是代码,包含了将数据转换为 float32 类型和训练/测试集的划分。在这个例子中,我们使用了一年的训练数据(2015 年),并将其用于预测整个 2016 年的股票价格:
symbol = "MSFT"
feat_dimension = 20
train_size = 252
test_size = 252 - feat_dimension
# Settings for tensorflow
learning_rate = 0.05
optimizer = tf.train.AdamOptimizer
n_epochs = 1000
# Fetch the values, and prepare the train/test split
stock_values = fetch_stock_price(symbol, datetime.date(2015, 1, 1), datetime.date(2016, 12, 31))
minibatch_cos_X, minibatch_cos_y = format_dataset(stock_values, feat_dimension)
train_X = minibatch_cos_X[:train_size, :].astype(np.float32)
train_y = minibatch_cos_y[:train_size].reshape((-1, 1)).astype(np.float32)
test_X = minibatch_cos_X[train_size:, :].astype(np.float32)
test_y = minibatch_cos_y[train_size:].reshape((-1, 1)).astype(np.float32)
在这个脚本中,我们发现最佳表现是在以下设置下得到的:
learning_rate = 0.5
n_epochs = 20000
optimizer = tf.train.AdamOptimizer
脚本的输出应该是这样的:
Training iteration 0 MSE 15136.7
Training iteration 1 MSE 106385.0
Training iteration 2 MSE 14307.3
Training iteration 3 MSE 15565.6
...
...
Training iteration 19998 MSE 0.577189
Training iteration 19999 MSE 0.57704
Test dataset: 0.539493
Evaluation of the predictions:
MSE: 0.539493
mae: 0.518984
Benchmark: if prediction == last feature
MSE: 33.7714
mae: 4.6968
即便在这种情况下,我们也没有发生过拟合,简单的回归模型比没有模型时的表现要好(我们都敢打赌)。一开始,误差确实很高,但经过一次又一次的迭代,它越来越接近零。此外,在这种情况下,mae(平均绝对误差)分数很容易解释,它是美元!如果使用学习算法,我们的预测结果平均距离真实价格仅半美元,而没有任何学习算法时,误差是九倍。
现在让我们通过视觉评估模型的表现,令人印象深刻,不是吗?
这是预测值:

这是绝对误差,带有趋势线(虚线):

最后,训练集中的真实值与预测值:

请记住,这些是一个简单回归算法的表现,没有利用特征之间的时间相关性。我们如何利用这一点来提升表现呢?
长短期记忆 – LSTM 101
长短期记忆(LSTM)模型是 RNN(递归神经网络)的一种特殊情况。它们的完整且严谨的描述超出了本书的范围;在本节中,我们将仅提供它们的核心内容。
你可以查看由 Packt 出版的以下书籍:
www.packtpub.com/big-data-and-business-intelligence/neural-network-programming-tensorflow 另外,你还可以查看这个:www.packtpub.com/big-data-and-business-intelligence/neural-networks-r
简单来说,RNN(递归神经网络)处理的是序列:它们接受多维信号作为输入,并输出多维信号。下图展示了一个 RNN 能够处理五个时间步的时间序列(每个时间步一个输入)。输入位于 RNN 的下部,输出位于上部。记住,每个输入/输出都是一个 N 维特征:

在内部,RNN 具有多个阶段;每个阶段都连接到自己的输入/输出以及前一阶段的输出。得益于这种配置,每个输出不仅仅是当前阶段输入的函数,还依赖于前一阶段的输出(这个输出本身又是前一阶段输入和输出的函数)。这种配置确保了每个输入都影响所有后续输出,或者换句话说,一个输出是所有前一个和当前阶段输入的函数。
请注意,并非所有的输出总是都会被使用。以情感分析任务为例,在这种情况下,给定一句话(即时间序列输入信号),我们只希望得到一个类别(正面/负面);因此,只有最后一个输出被视为输出,其他所有输出虽然存在,但并未被使用。记住,我们只使用最后一个,因为它对整个句子拥有完整的可见性。
LSTM 模型是 RNN 的进化版本:在长 RNN 中,训练阶段可能导致非常小或者巨大的梯度在网络中反向传播,进而导致权重变为零或无限大:这是一个通常表现为梯度消失/爆炸的问题。为了缓解这个问题,LSTM 在每个阶段都有两个输出:一个是模型的实际输出,另一个是内存状态,即该阶段的内部状态。
两个输出会被送入下一阶段,从而降低梯度消失或爆炸的可能性。当然,这也有代价:模型的复杂性(需要调整的权重数)和内存占用更大,这就是为什么我们强烈建议在训练 RNN 时使用 GPU 设备,时间上的加速非常显著!
与回归不同,RNN 需要一个三维的信号作为输入。Tensorflow 指定的格式是:
-
样本
-
时间步
-
特性
在前面的示例中,情感分析的训练张量会将句子放在x轴上,组成句子的单词放在y轴上,单词袋与字典放在z轴上。例如,对于一个包含 100 万条英文语料库(大约 20,000 个不同单词),且句子最长可达 50 个单词的情感分类任务,张量的维度是 100 万 x 50 x 20K。
使用 LSTM 进行股票价格预测
多亏了 LSTM,我们可以利用信号中包含的时间冗余。通过上一节的内容,我们了解到观察矩阵应该被重新格式化为一个三维张量,具有三个轴:
-
第一个包含样本。
-
第二个包含时间序列。
-
第三个包含输入特征。
由于我们处理的只是单一维度的信号,LSTM 的输入张量应具有大小为(None, time_dimension, 1),其中time_dimension是时间窗口的长度。现在我们开始编写代码,从余弦信号开始。建议将文件命名为4_rnn_cosine.py。
- 首先,一些导入:
import matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf
from evaluate_ts import evaluate_ts
from tensorflow.contrib import rnn
from tools import fetch_cosine_values, format_dataset
tf.reset_default_graph()
tf.set_random_seed(101)
- 然后,我们设置窗口大小以对信号进行分块。这个操作类似于观察矩阵的创建。
time_dimension = 20
train_size = 250
test_size = 250
- 接下来,为 Tensorflow 设置一些配置。此时,我们先从默认值开始:
learning_rate = 0.01
optimizer = tf.train.AdagradOptimizer
n_epochs = 100
n_embeddings = 64
- 现在,是时候提取嘈杂的余弦波,并将其重塑为一个 3D 张量形状(None,
time_dimension, 1)。这是在这里完成的:
cos_values = fetch_cosine_values(train_size + test_size + time_dimension)
minibatch_cos_X, minibatch_cos_y = format_dataset(cos_values, time_dimension)
train_X = minibatch_cos_X[:train_size, :].astype(np.float32)
train_y = minibatch_cos_y[:train_size].reshape((-1, 1)).astype(np.float32)
test_X = minibatch_cos_X[train_size:, :].astype(np.float32)
test_y = minibatch_cos_y[train_size:].reshape((-1, 1)).astype(np.float32)
train_X_ts = train_X[:, :, np.newaxis]
test_X_ts = test_X[:, :, np.newaxis]
- 就像在之前的脚本中一样,我们定义 Tensorflow 的占位符:
X_tf = tf.placeholder("float", shape=(None, time_dimension, 1), name="X")
y_tf = tf.placeholder("float", shape=(None, 1), name="y")
- 在这里,我们定义模型。我们将使用一个 LSTM,并且使用一个可变数量的嵌入层。此外,正如上一章所述,我们将只考虑通过线性回归(全连接层)得到的最后输出作为预测:
def RNN(x, weights, biases):
x_ = tf.unstack(x, time_dimension, 1)
lstm_cell = rnn.BasicLSTMCell(n_embeddings)
outputs, _ = rnn.static_rnn(lstm_cell, x_, dtype=tf.float32)
return tf.add(biases, tf.matmul(outputs[-1], weights))
- 我们像之前一样设置
trainable变量(weights),cost函数和训练操作符:
weights = tf.Variable(tf.truncated_normal([n_embeddings, 1], mean=0.0, stddev=1.0), name="weights")
biases = tf.Variable(tf.zeros([1]), name="bias")
y_pred = RNN(X_tf, weights, biases)
cost = tf.reduce_mean(tf.square(y_tf - y_pred))
train_op = optimizer(learning_rate).minimize(cost)
# Exactly as before, this is the main loop.
with tf.Session() as sess:
sess.run(tf.global_variables_initializer())
# For each epoch, the whole training set is feeded into the tensorflow graph
for i in range(n_epochs):
train_cost, _ = sess.run([cost, train_op], feed_dict={X_tf: train_X_ts, y_tf: train_y})
if i%100 == 0:
print("Training iteration", i, "MSE", train_cost)
# After the training, let's check the performance on the test set
test_cost, y_pr = sess.run([cost, y_pred], feed_dict={X_tf: test_X_ts, y_tf: test_y})
print("Test dataset:", test_cost)
# Evaluate the results
evaluate_ts(test_X, test_y, y_pr)
# How does the predicted look like?
plt.plot(range(len(cos_values)), cos_values, 'b')
plt.plot(range(len(cos_values)-test_size, len(cos_values)), y_pr, 'r--')
plt.xlabel("Days")
plt.ylabel("Predicted and true values")
plt.title("Predicted (Red) VS Real (Blue)")
plt.show()
经过超参数优化后的输出如下:
Training iteration 0 MSE 0.0603129
Training iteration 100 MSE 0.0054377
Training iteration 200 MSE 0.00502512
Training iteration 300 MSE 0.00483701
...
Training iteration 9700 MSE 0.0032881
Training iteration 9800 MSE 0.00327899
Training iteration 9900 MSE 0.00327195
Test dataset: 0.00416444
Evaluation of the predictions:
MSE: 0.00416444
mae: 0.0545878
性能与我们通过简单线性回归得到的结果非常相似。我们来看看是否可以通过使用一个像股票价格这样不太可预测的信号来获得更好的性能。我们将使用上一章中使用的相同时间序列,以便比较性能。
修改之前的程序,将股票价格时间序列代替余弦波。修改几行代码以加载股票价格数据:
stock_values = fetch_stock_price(symbol, datetime.date(2015, 1, 1), datetime.date(2016, 12, 31))
minibatch_cos_X, minibatch_cos_y = format_dataset(stock_values, time_dimension)
train_X = minibatch_cos_X[:train_size, :].astype(np.float32)
train_y = minibatch_cos_y[:train_size].reshape((-1, 1)).astype(np.float32)
test_X = minibatch_cos_X[train_size:, :].astype(np.float32)
test_y = minibatch_cos_y[train_size:].reshape((-1, 1)).astype(np.float32)
train_X_ts = train_X[:, :, np.newaxis]
test_X_ts = test_X[:, :, np.newaxis]
由于这个信号的动态范围更广,我们还需要修改用于提取初始权重的分布。我们建议将其设置为:
weights = tf.Variable(tf.truncated_normal([n_embeddings, 1], mean=0.0, stddev=10.0), name="weights")
经过几次测试,我们发现使用这些参数时达到了最佳性能:
learning_rate = 0.1
n_epochs = 5000
n_embeddings = 256
使用这些参数的输出是:
Training iteration 200 MSE 2.39028
Training iteration 300 MSE 1.39495
Training iteration 400 MSE 1.00994
...
Training iteration 4800 MSE 0.593951
Training iteration 4900 MSE 0.593773
Test dataset: 0.497867
Evaluation of the predictions:
MSE: 0.497867
mae: 0.494975
这个比之前的模型(测试 MSE)提高了 8%。记住,它也有代价!更多的参数需要训练,意味着训练时间比之前的示例要长得多(在笔记本电脑上,几分钟,使用 GPU)。
最后,让我们检查 Tensorboard。为了写入日志,我们应该添加以下代码:
- 在文件的开头,在导入模块之后:
import os
tf_logdir = "./logs/tf/stock_price_lstm"
os.makedirs(tf_logdir, exist_ok=1)
- 此外,
RNN函数的整个主体应该放在命名范围 LSTM 内,即:
def RNN(x, weights, biases):
with tf.name_scope("LSTM"):
x_ = tf.unstack(x, time_dimension, 1)
lstm_cell = rnn.BasicLSTMCell(n_embeddings)
outputs, _ = rnn.static_rnn(lstm_cell, x_, dtype=tf.float32)
return tf.add(biases, tf.matmul(outputs[-1], weights))
- 类似地,
cost函数应被包装在 Tensorflow 的作用域内。同时,我们会将mae的计算也加入到tensorflow图中:
y_pred = RNN(X_tf, weights, biases)
with tf.name_scope("cost"):
cost = tf.reduce_mean(tf.square(y_tf - y_pred))
train_op = optimizer(learning_rate).minimize(cost)
tf.summary.scalar("MSE", cost)
with tf.name_scope("mae"):
mae_cost = tf.reduce_mean(tf.abs(y_tf - y_pred))
tf.summary.scalar("mae", mae_cost)
- 最后,主函数应该是这样的:
with tf.Session() as sess:
writer = tf.summary.FileWriter(tf_logdir, sess.graph)
merged = tf.summary.merge_all()
sess.run(tf.global_variables_initializer())
# For each epoch, the whole training set is feeded into the tensorflow graph
for i in range(n_epochs):
summary, train_cost, _ = sess.run([merged, cost, train_op], feed_dict={X_tf:
train_X_ts, y_tf: train_y})
writer.add_summary(summary, i)
if i%100 == 0:
print("Training iteration", i, "MSE", train_cost)
# After the training, let's check the performance on the test set
test_cost, y_pr = sess.run([cost, y_pred], feed_dict={X_tf: test_X_ts, y_tf:
test_y})
print("Test dataset:", test_cost)
这样,我们将每个模块的作用域分开,并为训练的变量写一个总结报告。
现在,让我们启动 tensorboard:
$> tensorboard --logdir=./logs/tf/stock_price_lstm
打开浏览器并访问 localhost:6006 后,在第一个标签页中,我们可以观察到 MSE 和 MAE 的表现:

趋势看起来很不错,下降直到达到平稳期。另外,我们还可以检查 tensorflow 图(在 GRAPH 标签页中)。在这里,我们可以看到各个部分是如何连接在一起的,以及操作符如何相互影响。你还可以放大以精确查看 Tensorflow 中如何构建 LSTM:

这就是项目的结束。
可能的后续问题
-
用 RNN 替换 LSTM,然后再用 GRU。哪个表现最好?
-
除了预测收盘价,还可以尝试预测第二天的最高价/最低价。为此,你可以在训练模型时使用相同的特征(或者你也可以仅使用收盘价作为输入)。
-
为其他股票优化模型:使用一个适用于所有股票的通用模型,还是为每只股票建立一个特定的模型更好?
-
调整再训练。在这个例子中,我们使用模型预测了一整年的数据。如果你每月/每周/每天训练一次模型,你能发现任何改进吗?
-
如果你有一些金融经验,试着构建一个简单的交易模拟器,并将预测结果输入其中。从$100 开始进行模拟,经过一年后,你是赚了还是亏了?
总结
在这一章中,我们展示了如何进行时间序列预测:具体来说,我们观察了 RNN 在实际数据集(如股票价格)上的表现。在下一章,我们将看到 RNN 的另一个应用,例如,如何进行自动机器翻译,将一句话翻译成另一种语言。
第六章:创建并训练机器翻译系统
本项目的目标是训练一个人工智能(AI)模型,使其能够在两种语言之间进行翻译。具体来说,我们将看到一个自动翻译器,它读取德语并生成英语句子;不过,本章中开发的模型和代码足够通用,可以应用于任何语言对。
本章探讨的项目有四个重要部分,如下所示:
-
架构概述
-
预处理语料库
-
训练机器翻译器
-
测试与翻译
它们每个都会描述项目中的一个关键组件,最后,你将对发生的事情有一个清晰的了解。
架构概述
一个机器翻译系统接收一种语言的任意字符串作为输入,并生成一个在另一种语言中具有相同含义的字符串作为输出。Google 翻译就是一个例子(但许多其他大型 IT 公司也有自己的系统)。在这里,用户可以进行超过 100 种语言之间的翻译。使用网页非常简单:在左侧输入你想翻译的句子(例如,Hello World),选择其语言(在这个例子中是英语),然后选择你希望翻译成的目标语言。
这是一个例子,我们将句子 Hello World 翻译成法语:

这很容易吗?乍一看,我们可能会认为这只是简单的字典替换。单词被分块,翻译通过特定的英法词典查找,每个单词用其翻译进行替换。不幸的是,事实并非如此。在这个例子中,英语句子有两个单词,而法语句子有三个。更一般来说,想想短语动词(turn up, turn off, turn on, turn down),萨克森属格,语法性别,时态,条件句……它们并不总是有直接的翻译,正确的翻译应当根据句子的上下文来决定。
这就是为什么,在进行机器翻译时,我们需要一些人工智能工具。具体来说,就像许多其他自然语言处理(NLP)任务一样,我们将使用循环神经网络(RNN)。我们在上一章介绍了 RNN,主要特点是它们处理序列:给定输入序列,产生输出序列。本章的目标是创建正确的训练流程,以使句子作为输入序列,其翻译作为输出序列。还要记住没有免费的午餐定理:这个过程并不简单,更多的解决方案可以用相同的结果创造出来。在本章中,我们将提出一个简单而强大的方法。
首先,我们从语料库开始:这可能是最难找到的部分,因为它应该包含从一种语言到另一种语言的高保真度翻译。幸运的是,NLTK,一个著名的 Python 自然语言处理包,包含了 Comtrans 语料库。Comtrans是机器翻译组合方法(combination approach to machine translation)的缩写,包含了三种语言的对齐语料库:德语、法语和英语。
在这个项目中,我们将使用这些语料库出于以下几个原因:
-
它可以很容易地在 Python 中下载和导入。
-
不需要预处理来从磁盘/互联网读取它。NLTK 已经处理了这部分内容。
-
它足够小,可以在许多笔记本电脑上使用(只有几万句)。
-
它可以自由地在互联网上获取。
关于 Comtrans 项目的更多信息,请访问 www.fask.uni-mainz.de/user/rapp/comtrans/。
更具体来说,我们将尝试创建一个机器翻译系统,将德语翻译成英语。我们随机选择了这两种语言,作为 Comtrans 语料库中可用语言的其中一对:你可以自由选择交换它们,或者改用法语语料库。我们的项目管道足够通用,可以处理任何组合。
现在,让我们通过输入一些命令来调查语料库的组织结构:
from nltk.corpus import comtrans
print(comtrans.aligned_sents('alignment-de-en.txt')[0])
输出如下:
<AlignedSent: 'Wiederaufnahme der S...' -> 'Resumption of the se...'>
句子对可以通过函数aligned_sents获取。文件名包含源语言和目标语言。在这种情况下,作为项目的后续部分,我们将翻译德语(de)到英语(en)。返回的对象是类nltk.translate.api.AlignedSent的一个实例。从文档中可以看到,第一个语言可以通过属性words访问,第二个语言可以通过属性mots访问。所以,为了分别提取德语句子和其英语翻译,我们应该运行:
print(comtrans.aligned_sents()[0].words)
print(comtrans.aligned_sents()[0].mots)
上面的代码输出:
['Wiederaufnahme', 'der', 'Sitzungsperiode']
['Resumption', 'of', 'the', 'session']
真棒!这些句子已经被分词,并且看起来像是序列。实际上,它们将是我们项目中 RNN 的输入和(希望)输出,RNN 将为我们提供德语到英语的机器翻译服务。
此外,如果你想了解语言的动态,Comtrans 还提供了翻译中单词的对齐:
print(comtrans.aligned_sents()[0].alignment)
上面的代码输出:
0-0 1-1 1-2 2-3
德语中的第一个词被翻译为英语中的第一个词(Wiederaufnahme到Resumption),第二个词被翻译为第二个词(der到of和the),第三个(索引为 1)被翻译为第四个词(Sitzungsperiode到session)。
语料库的预处理
第一步是获取语料库。我们已经看到过如何做到这一点,但现在让我们将其形式化为一个函数。为了使其足够通用,我们将把这些函数封装在一个名为corpora_tools.py的文件中。
- 让我们导入一些稍后会用到的内容:
import pickle
import re
from collections import Counter
from nltk.corpus import comtrans
- 现在,让我们创建一个函数来获取语料库:
def retrieve_corpora(translated_sentences_l1_l2='alignment-de-en.txt'):
print("Retrieving corpora: {}".format(translated_sentences_l1_l2))
als = comtrans.aligned_sents(translated_sentences_l1_l2)
sentences_l1 = [sent.words for sent in als]
sentences_l2 = [sent.mots for sent in als]
return sentences_l1, sentences_l2
这个函数有一个参数;包含来自 NLTK Comtrans 语料库的对齐句子的文件。它返回两个句子列表(实际上是词汇列表),一个用于源语言(在我们的例子中是德语),另一个用于目标语言(在我们的例子中是英语)。
- 在一个单独的 Python REPL 中,我们可以测试这个函数:
sen_l1, sen_l2 = retrieve_corpora()
print("# A sentence in the two languages DE & EN")
print("DE:", sen_l1[0])
print("EN:", sen_l2[0])
print("# Corpora length (i.e. number of sentences)")
print(len(sen_l1))
assert len(sen_l1) == len(sen_l2)
- 上述代码生成了以下输出:
Retrieving corpora: alignment-de-en.txt
# A sentence in the two languages DE & EN
DE: ['Wiederaufnahme', 'der', 'Sitzungsperiode']
EN: ['Resumption', 'of', 'the', 'session']
# Corpora length (i.e. number of sentences)
33334
我们还打印了每个语料库中的句子数量(33,000),并确认源语言和目标语言的句子数量相同。
- 在接下来的步骤中,我们希望清理掉无用的标记。具体来说,我们要对标点符号进行分词处理,并将所有词汇小写。为此,我们可以在
corpora_tools.py中创建一个新函数。我们将使用regex模块来进一步分词:
def clean_sentence(sentence):
regex_splitter = re.compile("([!?.,:;$\"')( ])")
clean_words = [re.split(regex_splitter, word.lower()) for word in sentence]
return [w for words in clean_words for w in words if words if w]
- 再次,在 REPL 中,我们来测试这个函数:
clean_sen_l1 = [clean_sentence(s) for s in sen_l1]
clean_sen_l2 = [clean_sentence(s) for s in sen_l2]
print("# Same sentence as before, but chunked and cleaned")
print("DE:", clean_sen_l1[0])
print("EN:", clean_sen_l2[0])
上述代码输出与之前相同的句子,但已分块并清理:
DE: ['wiederaufnahme', 'der', 'sitzungsperiode']
EN: ['resumption', 'of', 'the', 'session']
不错!
该项目的下一步是筛选出过长的句子,无法进行处理。由于我们的目标是在本地机器上进行处理,我们应该限制句子的长度在N个词以内。在这种情况下,我们将N设置为 20,以便在 24 小时内能够训练学习器。如果你有一台强大的机器,可以随意提高这个限制。为了使函数足够通用,还设置了一个下限,默认值为 0,例如一个空的词汇集。
- 函数的逻辑非常简单:如果句子或其翻译的词汇数大于
N,那么就将该句子(无论源语言还是目标语言)移除:
def filter_sentence_length(sentences_l1, sentences_l2, min_len=0, max_len=20):
filtered_sentences_l1 = []
filtered_sentences_l2 = []
for i in range(len(sentences_l1)):
if min_len <= len(sentences_l1[i]) <= max_len and \
min_len <= len(sentences_l2[i]) <= max_len:
filtered_sentences_l1.append(sentences_l1[i])
filtered_sentences_l2.append(sentences_l2[i])
return filtered_sentences_l1, filtered_sentences_l2
- 再次,让我们在 REPL 中查看有多少句子通过了这个过滤器。记住,我们起始时有超过 33,000 个句子:
filt_clean_sen_l1, filt_clean_sen_l2 = filter_sentence_length(clean_sen_l1,
clean_sen_l2)
print("# Filtered Corpora length (i.e. number of sentences)")
print(len(filt_clean_sen_l1))
assert len(filt_clean_sen_l1) == len(filt_clean_sen_l2)
上述代码打印出以下输出:
# Filtered Corpora length (i.e. number of sentences)
14788
大约 15,000 个句子存活下来,也就是语料库的一半。
现在,我们终于从文本转向数字(AI 主要使用这些)。为此,我们将为每种语言创建一个词典。这个词典应该足够大,能够包含大多数词汇,尽管我们可以丢弃一些出现频率很低的词汇。如果某种语言有低频词汇,这是常见做法,就像 tf-idf(文档中词频乘以文档频率的倒数,即该词在多少个文档中出现)一样,极为罕见的词汇会被丢弃,以加速计算并使解决方案更加可扩展和通用。在这里,我们需要在两个词典中分别有四个特殊符号:
-
一个符号用于填充(稍后我们会看到为什么需要它)
-
一个符号用于分隔两个句子
-
一个符号表示句子的结束位置
-
一个符号用于表示未知词汇(比如那些非常罕见的词)
为此,让我们创建一个新的文件,命名为data_utils.py,并包含以下代码行:
_PAD = "_PAD"
_GO = "_GO"
_EOS = "_EOS"
_UNK = "_UNK"
_START_VOCAB = [_PAD, _GO, _EOS, _UNK]
PAD_ID = 0
GO_ID = 1
EOS_ID = 2
UNK_ID = 3
OP_DICT_IDS = [PAD_ID, GO_ID, EOS_ID, UNK_ID]
然后,返回到corpora_tools.py文件中,让我们添加以下函数:
import data_utils
def create_indexed_dictionary(sentences, dict_size=10000, storage_path=None):
count_words = Counter()
dict_words = {}
opt_dict_size = len(data_utils.OP_DICT_IDS)
for sen in sentences:
for word in sen:
count_words[word] += 1
dict_words[data_utils._PAD] = data_utils.PAD_ID
dict_words[data_utils._GO] = data_utils.GO_ID
dict_words[data_utils._EOS] = data_utils.EOS_ID
dict_words[data_utils._UNK] = data_utils.UNK_ID
for idx, item in enumerate(count_words.most_common(dict_size)):
dict_words[item[0]] = idx + opt_dict_size
if storage_path:
pickle.dump(dict_words, open(storage_path, "wb"))
return dict_words
这个函数的参数包括字典中的条目数和存储字典的路径。记住,字典是在训练算法时创建的:在测试阶段它会被加载,且令牌/符号的关联应与训练中使用的一致。如果唯一令牌的数量大于设定的值,则只选择最常见的那些。最终,字典包含每种语言中令牌及其 ID 之间的关联。
在构建字典之后,我们应该查找令牌并用它们的令牌 ID 进行替换。
为此,我们需要另一个函数:
def sentences_to_indexes(sentences, indexed_dictionary):
indexed_sentences = []
not_found_counter = 0
for sent in sentences:
idx_sent = []
for word in sent:
try:
idx_sent.append(indexed_dictionary[word])
except KeyError:
idx_sent.append(data_utils.UNK_ID)
not_found_counter += 1
indexed_sentences.append(idx_sent)
print('[sentences_to_indexes] Did not find {} words'.format(not_found_counter))
return indexed_sentences
这一步非常简单;令牌会被替换成其 ID。如果令牌不在字典中,则使用未知令牌的 ID。让我们在 REPL 中查看经过这些步骤后的句子:
dict_l1 = create_indexed_dictionary(filt_clean_sen_l1, dict_size=15000, storage_path="/tmp/l1_dict.p")
dict_l2 = create_indexed_dictionary(filt_clean_sen_l2, dict_size=10000, storage_path="/tmp/l2_dict.p")
idx_sentences_l1 = sentences_to_indexes(filt_clean_sen_l1, dict_l1)
idx_sentences_l2 = sentences_to_indexes(filt_clean_sen_l2, dict_l2)
print("# Same sentences as before, with their dictionary ID")
print("DE:", list(zip(filt_clean_sen_l1[0], idx_sentences_l1[0])))
这段代码打印了两个句子的令牌及其 ID。RNN 中使用的将只是每个元组的第二个元素,也就是整数 ID:
# Same sentences as before, with their dictionary ID
DE: [('wiederaufnahme', 1616), ('der', 7), ('sitzungsperiode', 618)]
EN: [('resumption', 1779), ('of', 8), ('the', 5), ('session', 549)]
另外请注意,像英语中的the和of,德语中的der等常见令牌,其 ID 较低。这是因为 ID 是按流行度排序的(见函数create_indexed_dictionary的主体)。
即使我们做了过滤以限制句子的最大长度,我们仍然应该创建一个函数来提取最大长度。对于那些拥有非常强大机器的幸运用户,如果没有进行任何过滤,那么现在就是看 RNN 中最长期限句子多长的时刻。这个函数就是:
def extract_max_length(corpora):
return max([len(sentence) for sentence in corpora])
让我们对这些句子应用以下操作:
max_length_l1 = extract_max_length(idx_sentences_l1)
max_length_l2 = extract_max_length(idx_sentences_l2)
print("# Max sentence sizes:")
print("DE:", max_length_l1)
print("EN:", max_length_l2)
如预期的那样,输出为:
# Max sentence sizes:
DE: 20
EN: 20
最终的预处理步骤是填充。我们需要所有序列具有相同的长度,因此需要填充较短的序列。此外,我们需要插入正确的令牌,指示 RNN 字符串的开始和结束位置。
基本上,这一步应该:
-
填充输入序列,使它们都为 20 个符号长
-
填充输出序列,使其为 20 个符号长
-
在输出序列的开头插入一个
_GO,在结尾插入一个_EOS,用以标识翻译的开始和结束
这是通过这个函数完成的(将其插入到corpora_tools.py中):
def prepare_sentences(sentences_l1, sentences_l2, len_l1, len_l2):
assert len(sentences_l1) == len(sentences_l2)
data_set = []
for i in range(len(sentences_l1)):
padding_l1 = len_l1 - len(sentences_l1[i])
pad_sentence_l1 = ([data_utils.PAD_ID]*padding_l1) + sentences_l1[i]
padding_l2 = len_l2 - len(sentences_l2[i])
pad_sentence_l2 = [data_utils.GO_ID] + sentences_l2[i] + [data_utils.EOS_ID] + ([data_utils.PAD_ID] * padding_l2)
data_set.append([pad_sentence_l1, pad_sentence_l2])
return data_set
为了测试它,让我们准备数据集并打印第一句:
data_set = prepare_sentences(idx_sentences_l1, idx_sentences_l2, max_length_l1, max_length_l2)
print("# Prepared minibatch with paddings and extra stuff")
print("DE:", data_set[0][0])
print("EN:", data_set[0][1])
print("# The sentence pass from X to Y tokens")
print("DE:", len(idx_sentences_l1[0]), "->", len(data_set[0][0]))
print("EN:", len(idx_sentences_l2[0]), "->", len(data_set[0][1]))
上述代码输出如下:
# Prepared minibatch with paddings and extra stuff
DE: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1616, 7, 618]
EN: [1, 1779, 8, 5, 549, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# The sentence pass from X to Y tokens
DE: 3 -> 20
EN: 4 -> 22
正如你所看到的,输入和输出都通过零进行填充以保持常数长度(在字典中,它们对应于_PAD,见data_utils.py),输出中包含标记 1 和 2,分别位于句子的开始和结束之前。根据文献证明的有效方法,我们将填充输入句子的开始,并填充输出句子的结束。完成此操作后,所有输入句子的长度都是20,输出句子的长度是22。
训练机器翻译器
到目前为止,我们已经看到了预处理语料库的步骤,但尚未看到使用的模型。实际上,模型已经可以在 TensorFlow Models 仓库中找到,可以从 github.com/tensorflow/models/blob/master/tutorials/rnn/translate/seq2seq_model.py 免费下载。
这段代码采用 Apache 2.0 许可证。我们非常感谢作者开源了如此出色的模型。版权所有 2015 TensorFlow 作者。保留所有权利。根据 Apache 许可证第 2.0 版(许可证)授权;除非符合该许可证,否则不得使用此文件。你可以在此处获得许可证副本:www.apache.org/licenses/LICENSE-2.0 除非适用法律要求或书面同意,否则按“原样”分发软件,且不提供任何形式的保证或条件。有关许可证下特定权限和限制的语言,请参见许可证。
我们将在本节中看到模型的使用。首先,让我们创建一个名为 train_translator.py 的新文件,并导入一些库和常量。我们将把字典保存在 /tmp/ 目录下,以及模型和它的检查点:
import time
import math
import sys
import pickle
import glob
import os
import tensorflow as tf
from seq2seq_model import Seq2SeqModel
from corpora_tools import *
path_l1_dict = "/tmp/l1_dict.p"
path_l2_dict = "/tmp/l2_dict.p"
model_dir = "/tmp/translate "
model_checkpoints = model_dir + "/translate.ckpt"
现在,让我们在一个函数中使用前面部分创建的所有工具,给定一个布尔标志返回语料库。更具体地说,如果参数是False,则从头开始构建字典(并保存);否则,它使用路径中现有的字典:
def build_dataset(use_stored_dictionary=False):
sen_l1, sen_l2 = retrieve_corpora()
clean_sen_l1 = [clean_sentence(s) for s in sen_l1]
clean_sen_l2 = [clean_sentence(s) for s in sen_l2]
filt_clean_sen_l1, filt_clean_sen_l2 = filter_sentence_length(clean_sen_l1, clean_sen_l2)
if not use_stored_dictionary:
dict_l1 = create_indexed_dictionary(filt_clean_sen_l1, dict_size=15000, storage_path=path_l1_dict)
dict_l2 = create_indexed_dictionary(filt_clean_sen_l2, dict_size=10000, storage_path=path_l2_dict)
else:
dict_l1 = pickle.load(open(path_l1_dict, "rb"))
dict_l2 = pickle.load(open(path_l2_dict, "rb"))
dict_l1_length = len(dict_l1)
dict_l2_length = len(dict_l2)
idx_sentences_l1 = sentences_to_indexes(filt_clean_sen_l1, dict_l1)
idx_sentences_l2 = sentences_to_indexes(filt_clean_sen_l2, dict_l2)
max_length_l1 = extract_max_length(idx_sentences_l1)
max_length_l2 = extract_max_length(idx_sentences_l2)
data_set = prepare_sentences(idx_sentences_l1, idx_sentences_l2, max_length_l1, max_length_l2)
return (filt_clean_sen_l1, filt_clean_sen_l2), \
data_set, \
(max_length_l1, max_length_l2), \
(dict_l1_length, dict_l2_length)
这个函数返回清理后的句子、数据集、句子的最大长度以及字典的长度。
此外,我们还需要一个清理模型的函数。每次运行训练例程时,我们需要清理模型目录,因为我们没有提供任何垃圾信息。我们可以通过一个非常简单的函数来实现这一点:
def cleanup_checkpoints(model_dir, model_checkpoints):
for f in glob.glob(model_checkpoints + "*"):
os.remove(f)
try:
os.mkdir(model_dir)
except FileExistsError:
pass
最后,让我们以可重用的方式创建模型:
def get_seq2seq_model(session, forward_only, dict_lengths, max_sentence_lengths, model_dir):
model = Seq2SeqModel(
source_vocab_size=dict_lengths[0],
target_vocab_size=dict_lengths[1],
buckets=[max_sentence_lengths],
size=256,
num_layers=2,
max_gradient_norm=5.0,
batch_size=64,
learning_rate=0.5,
learning_rate_decay_factor=0.99,
forward_only=forward_only,
dtype=tf.float16)
ckpt = tf.train.get_checkpoint_state(model_dir)
if ckpt and tf.train.checkpoint_exists(ckpt.model_checkpoint_path):
print("Reading model parameters from {}".format(ckpt.model_checkpoint_path))
model.saver.restore(session, ckpt.model_checkpoint_path)
else:
print("Created model with fresh parameters.")
session.run(tf.global_variables_initializer())
return model
这个函数调用模型的构造函数,传递以下参数:
-
源语言词汇大小(在我们的示例中是德语)
-
目标词汇大小(在我们的示例中是英语)
-
桶(在我们的示例中只有一个,因为我们已将所有序列填充为单一大小)
-
长短期记忆(LSTM)内部单元的大小
-
堆叠的 LSTM 层数
-
梯度的最大范数(用于梯度裁剪)
-
小批量大小(即每个训练步骤的观察次数)
-
学习率
-
学习率衰减因子
-
模型的方向
-
数据类型(在我们的示例中,我们将使用 flat16,即使用 2 个字节的浮动类型)
为了加速训练并获得良好的模型表现,我们已经在代码中设置了这些值;你可以自由更改它们并查看效果。
函数中的最终 if/else 语句会从检查点中检索模型(如果模型已经存在)。事实上,这个函数也会在解码器中使用,以在测试集上检索并处理模型。
最后,我们达到了训练机器翻译器的函数。它是这样的:
def train():
with tf.Session() as sess:
model = get_seq2seq_model(sess, False, dict_lengths, max_sentence_lengths, model_dir)
# This is the training loop.
step_time, loss = 0.0, 0.0
current_step = 0
bucket = 0
steps_per_checkpoint = 100
max_steps = 20000
while current_step < max_steps:
start_time = time.time()
encoder_inputs, decoder_inputs, target_weights = model.get_batch([data_set], bucket)
_, step_loss, _ = model.step(sess, encoder_inputs, decoder_inputs, target_weights, bucket, False)
step_time += (time.time() - start_time) / steps_per_checkpoint
loss += step_loss / steps_per_checkpoint
current_step += 1
if current_step % steps_per_checkpoint == 0:
perplexity = math.exp(float(loss)) if loss < 300 else float("inf")
print ("global step {} learning rate {} step-time {} perplexity {}".format(
model.global_step.eval(), model.learning_rate.eval(), step_time, perplexity))
sess.run(model.learning_rate_decay_op)
model.saver.save(sess, model_checkpoints, global_step=model.global_step)
step_time, loss = 0.0, 0.0
encoder_inputs, decoder_inputs, target_weights = model.get_batch([data_set], bucket)
_, eval_loss, _ = model.step(sess, encoder_inputs, decoder_inputs, target_weights, bucket, True)
eval_ppx = math.exp(float(eval_loss)) if eval_loss < 300 else float("inf")
print(" eval: perplexity {}".format(eval_ppx))
sys.stdout.flush()
该函数通过创建模型开始。此外,它设置了一些常量,用于确定每个检查点的步骤数和最大步骤数。具体来说,在代码中,我们将在每 100 步保存一次模型,并且最多执行 20,000 步。如果这仍然需要太长时间,可以随时终止程序:每个检查点都包含一个训练好的模型,解码器将使用最新的模型。
到这一点,我们进入了 while 循环。每一步,我们要求模型获取一个大小为 64 的小批量数据(如之前设置的)。get_batch 方法返回输入(即源序列)、输出(即目标序列)和模型的权重。通过 step 方法,我们执行一步训练。返回的信息之一是当前小批量数据的损失值。这就是所有的训练!
为了每 100 步报告性能并保存模型,我们会打印模型在过去 100 步中的平均困惑度(数值越低越好),并保存检查点。困惑度是与预测不确定性相关的指标:我们对单词的信心越强,输出句子的困惑度就越低。此外,我们重置计数器,并从测试集中的一个小批量数据(在这个案例中是数据集中的一个随机小批量)中提取相同的指标,并打印其性能。然后,训练过程会重新开始。
作为一种改进,每 100 步我们还会将学习率降低一个因子。在这种情况下,我们将其乘以 0.99。这有助于训练的收敛性和稳定性。
现在我们需要将所有函数连接在一起。为了创建一个可以通过命令行调用的脚本,同时也可以被其他脚本导入函数,我们可以创建一个 main 函数,如下所示:
if __name__ == "__main__":
_, data_set, max_sentence_lengths, dict_lengths = build_dataset(False)
cleanup_checkpoints(model_dir, model_checkpoints)
train()
在控制台中,你现在可以使用非常简单的命令来训练你的机器翻译系统:
$> python train_translator.py
在一台普通的笔记本电脑上,没有 NVIDIA GPU,困惑度降到 10 以下需要一天多的时间(12 个小时以上)。这是输出:
Retrieving corpora: alignment-de-en.txt
[sentences_to_indexes] Did not find 1097 words
[sentences_to_indexes] Did not find 0 words
Created model with fresh parameters.
global step 100 learning rate 0.5 step-time 4.3573073434829713 perplexity 526.6638556683066
eval: perplexity 159.2240770935855
[...]
global step 10500 learning rate 0.180419921875 step-time 4.35106209993362414 perplexity 2.0458043055629487
eval: perplexity 1.8646006006241982
[...]
测试并翻译
翻译的代码在文件 test_translator.py 中。
我们从一些导入和预训练模型的位置开始:
import pickle
import sys
import numpy as np
import tensorflow as tf
import data_utils
from train_translator import (get_seq2seq_model, path_l1_dict, path_l2_dict,
build_dataset)
model_dir = "/tmp/translate"
现在,让我们创建一个函数来解码 RNN 生成的输出序列。请注意,序列是多维的,每个维度对应于该单词的概率,因此我们将选择最可能的单词。在反向字典的帮助下,我们可以找出实际的单词是什么。最后,我们将修剪掉标记(填充、开始、结束符号),并打印输出。
在这个例子中,我们将解码训练集中的前五个句子,从原始语料库开始。随时可以插入新的字符串或使用不同的语料库:
def decode():
with tf.Session() as sess:
model = get_seq2seq_model(sess, True, dict_lengths, max_sentence_lengths, model_dir)
model.batch_size = 1
bucket = 0
for idx in range(len(data_set))[:5]:
print("-------------------")
print("Source sentence: ", sentences[0][idx])
print("Source tokens: ", data_set[idx][0])
print("Ideal tokens out: ", data_set[idx][1])
print("Ideal sentence out: ", sentences[1][idx])
encoder_inputs, decoder_inputs, target_weights = model.get_batch(
{bucket: [(data_set[idx][0], [])]}, bucket)
_, _, output_logits = model.step(sess, encoder_inputs, decoder_inputs,
target_weights, bucket, True)
outputs = [int(np.argmax(logit, axis=1)) for logit in output_logits]
if data_utils.EOS_ID in outputs:
outputs = outputs[1:outputs.index(data_utils.EOS_ID)]
print("Model output: ", " ".join([tf.compat.as_str(inv_dict_l2[output]) for output in outputs]))
sys.stdout.flush()
在这里,我们再次需要一个main来与命令行配合使用,如下所示:
if __name__ == "__main__":
dict_l2 = pickle.load(open(path_l2_dict, "rb"))
inv_dict_l2 = {v: k for k, v in dict_l2.items()}
build_dataset(True)
sentences, data_set, max_sentence_lengths, dict_lengths = build_dataset(False)
try:
print("Reading from", model_dir)
print("Dictionary lengths", dict_lengths)
print("Bucket size", max_sentence_lengths)
except NameError:
print("One or more variables not in scope. Translation not possible")
exit(-1)
decode()
运行上述代码会生成以下输出:
Reading model parameters from /tmp/translate/translate.ckpt-10500
-------------------
Source sentence: ['wiederaufnahme', 'der', 'sitzungsperiode']
Source tokens: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1616, 7, 618]
Ideal tokens out: [1, 1779, 8, 5, 549, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Ideal sentence out: ['resumption', 'of', 'the', 'session']
Model output: resumption of the session
-------------------
Source sentence: ['ich', 'bitte', 'sie', ',', 'sich', 'zu', 'einer', 'schweigeminute', 'zu', 'erheben', '.']
Source tokens: [0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 266, 22, 5, 29, 14, 78, 3931, 14, 2414, 4]
Ideal tokens out: [1, 651, 932, 6, 159, 6, 19, 11, 1440, 35, 51, 2639, 4, 2, 0, 0, 0, 0, 0, 0, 0, 0]
Ideal sentence out: ['please', 'rise', ',', 'then', ',', 'for', 'this', 'minute', "'", 's', 'silence', '.']
Model output: i ask you to move , on an approach an approach .
-------------------
Source sentence: ['(', 'das', 'parlament', 'erhebt', 'sich', 'zu', 'einer', 'schweigeminute', '.', ')']
Source tokens: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 52, 11, 58, 3267, 29, 14, 78, 3931, 4, 51]
Ideal tokens out: [1, 54, 5, 267, 3541, 14, 2095, 12, 1440, 35, 51, 2639, 53, 2, 0, 0, 0, 0, 0, 0, 0, 0]
Ideal sentence out: ['(', 'the', 'house', 'rose', 'and', 'observed', 'a', 'minute', "'", 's', 'silence', ')']
Model output: ( the house ( observed and observed a speaker )
-------------------
Source sentence: ['frau', 'präsidentin', ',', 'zur', 'geschäftsordnung', '.']
Source tokens: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 79, 151, 5, 49, 488, 4]
Ideal tokens out: [1, 212, 44, 6, 22, 12, 91, 8, 218, 4, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Ideal sentence out: ['madam', 'president', ',', 'on', 'a', 'point', 'of', 'order', '.']
Model output: madam president , on a point of order .
-------------------
Source sentence: ['wenn', 'das', 'haus', 'damit', 'einverstanden', 'ist', ',', 'werde', 'ich', 'dem', 'vorschlag', 'von', 'herrn', 'evans', 'folgen', '.']
Source tokens: [0, 0, 0, 0, 85, 11, 603, 113, 831, 9, 5, 243, 13, 39, 141, 18, 116, 1939, 417, 4]
Ideal tokens out: [1, 87, 5, 267, 2096, 6, 16, 213, 47, 29, 27, 1941, 25, 1441, 4, 2, 0, 0, 0, 0, 0, 0]
Ideal sentence out: ['if', 'the', 'house', 'agrees', ',', 'i', 'shall', 'do', 'as', 'mr', 'evans', 'has', 'suggested', '.']
Model output: if the house gave this proposal , i would like to hear mr byrne .
如你所见,输出结果主要是正确的,尽管仍然存在一些有问题的标记。为了减轻这个问题,我们需要一个更复杂的 RNN、更长的语料库或更多样化的语料库。
家庭作业
该模型是在相同的数据集上进行训练和测试的;这对数据科学来说并不理想,但为了有一个可运行的项目,这是必要的。尝试找一个更长的语料库,并将其拆分成两部分,一部分用于训练,另一部分用于测试:
-
更改模型的设置:这会如何影响性能和训练时间?
-
分析
seq2seq_model.py中的代码。如何在 TensorBoard 中插入损失的图表? -
NLTK 还包含法语语料库;你能否创建一个系统,将它们一起翻译?
在本章中,我们已经学习了如何基于 RNN 创建一个机器翻译系统。我们了解了如何组织语料库、如何训练它以及如何测试它。在下一章中,我们将看到 RNN 的另一个应用:聊天机器人。
第七章:摘要
训练并设置一个能够像人类一样对话的聊天机器人
本章将向你展示如何训练一个自动聊天机器人,使其能够回答简单且通用的问题,以及如何通过 HTTP 创建一个端点,通过 API 提供答案。更具体地,我们将展示:
-
什么是语料库以及如何预处理语料库
-
如何训练一个聊天机器人以及如何测试它
-
如何创建一个 HTTP 端点来暴露 API
项目介绍
聊天机器人正变得越来越普及,成为为用户提供帮助的一种方式。许多公司,包括银行、移动/固话公司以及大型电子商务公司,现在都在使用聊天机器人为客户提供帮助,并在售前阶段帮助用户。如今,单纯的 Q&A 页面已经不够了:每个客户现在都期待得到针对自己问题的答案,而这些问题可能在 Q&A 中没有覆盖或只是部分覆盖。此外,对于那些不需要为琐碎问题提供额外客户服务容量的公司来说,聊天机器人是一项很棒的工具:这真的是一种双赢的局面!
自从深度学习流行以来,聊天机器人已经成为非常流行的工具。得益于深度学习,我们现在能够训练机器人提供更好的个性化问题,并且在最新的实现中,还能够保持每个用户的上下文。
简单来说,主要有两种类型的聊天机器人:第一种是简单的聊天机器人,它尝试理解话题,总是为所有关于同一话题的问题提供相同的答案。例如,在火车网站上,问题“从 City_A 到 City_B 的时刻表在哪里?”和“从 City_A 出发的下一班火车是什么?”可能会得到相同的答案,可能是“你好!我们网络上的时刻表可以在这个页面找到:”。基本上,这种类型的聊天机器人通过分类算法来理解话题(在这个例子中,两个问题都是关于时刻表的话题)。在确定话题后,它们总是提供相同的答案。通常,它们有一个包含 N 个话题和 N 个答案的列表;此外,如果分类出来的话题概率较低(问题太模糊,或是涉及到不在列表中的话题),它们通常会要求用户更具体地说明并重复问题,最终可能会提供其他提问方式(例如发送电子邮件或拨打客服热线)。
第二种类型的聊天机器人更为先进、更智能,但也更复杂。对于这种类型的聊天机器人,答案是通过 RNN(循环神经网络)构建的,方式类似于机器翻译的实现(见前一章)。这些聊天机器人能够提供更个性化的答案,并且它们可能提供更具体的回复。事实上,它们不仅仅是猜测话题,而是通过 RNN 引擎,能够更好地理解用户的问题并提供最佳的答案:实际上,使用这种类型的聊天机器人,两个不同问题得到相同答案的可能性非常小。
在本章中,我们将尝试使用 RNN 构建第二种类型的聊天机器人,类似于我们在上一章中使用机器翻译系统所做的。同时,我们将展示如何将聊天机器人放在 HTTP 端点后面,以便将聊天机器人作为服务从您的网站或更简单地从命令行中使用。
输入语料库
不幸的是,我们没有找到任何面向消费者的、开放源代码并且可以自由使用的网络数据集。因此,我们将使用一个更通用的数据集来训练聊天机器人,而不是专注于客户服务的聊天数据集。具体来说,我们将使用康奈尔电影对话语料库(Cornell Movie Dialogs Corpus),该语料库来自康奈尔大学。该语料库包含从原始电影剧本中提取的对话集,因此聊天机器人能够更多地回答虚构性问题而非现实性问题。康奈尔语料库包含来自 617 部电影中的 10,000 多个电影角色之间的 200,000 多个对话交换。
数据集可以在这里获取:www.cs.cornell.edu/~cristian/Cornell_Movie-Dialogs_Corpus.html。
我们要感谢作者发布了这个语料库:这使得实验、可重复性和知识共享变得更加容易。
数据集以.zip归档文件的形式提供。解压后,您将找到其中的几个文件:
-
README.txt包含了数据集的描述、语料库文件的格式、收集过程的细节以及作者的联系方式。 -
Chameleons.pdf是发布该语料库的原始论文。虽然论文的主要目标并不直接围绕聊天机器人,但它研究了对话中使用的语言,是理解更多内容的好信息来源。 -
movie_conversations.txt包含了所有的对话结构。对于每个对话,它包括参与讨论的两个人物 ID、电影 ID 以及按时间顺序排列的句子 ID(或者更准确地说是发言 ID)列表。例如,文件的第一行是:
u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L194', 'L195', 'L196', 'L197']
这意味着用户u0在电影m0中与用户u2进行了对话,该对话包含了 4 个发言:'L194'、'L195'、'L196'和'L197'。
movie_lines.txt包含了每个发言 ID 的实际文本及其发言者。例如,发言L195在此列出为:
L195 +++$+++ u2 +++$+++ m0 +++$+++ CAMERON +++$+++ Well, I thought we'd start with pronunciation, if that's okay with you.
所以,发言L195的文本是Well, I thought we'd start with pronunciation, if that's okay with you.,并且是由电影m0中的角色u2(名为CAMERON)所发出的。
movie_titles_metadata.txt包含有关电影的信息,包括标题、年份、IMDB 评分、IMDB 的投票数和流派。例如,这里描述的电影m0是这样的:
m0 +++$+++ 10 things i hate about you +++$+++ 1999 +++$+++ 6.90 +++$+++ 62847 +++$+++ ['喜剧', '爱情']
因此,电影 ID 为 m0 的电影标题是 10 things i hate about you,出自 1999 年,是一部喜剧爱情片,IMDB 上获得了近 63,000 票,平均评分为 6.9(满分 10 分)。
movie_characters_metadata.txt包含有关电影角色的信息,包括角色名、出现的电影标题、性别(如果已知)和在演职员表中的位置(如果已知)。例如,角色“u2”在这个文件中以此描述:
u2 +++$+++ CAMERON +++$+++ m0 +++$+++ 10 things i hate about you +++$+++ m +++$+++ 3
角色 u2 的名字是 CAMERON,出现在电影 m0 中,标题是 10 things i hate about you,他是男性,排名第三。
raw_script_urls.txt包含可以检索每部电影对话的源 URL。例如,对于电影m0,它是:
m0 +++$+++ 10 things i hate about you +++$+++ http://www.dailyscript.com/scripts/10Things.html
正如您注意到的那样,大多数文件使用标记 +++$+++ 分隔字段。除此之外,该格式看起来相当容易解析。请特别注意解析文件时的格式:它们不是 UTF-8,而是 ISO-8859-1。
创建训练数据集
现在让我们为聊天机器人创建训练集。我们需要所有角色之间按正确顺序的对话:幸运的是,语料库包含了我们实际需要的以上内容。为了创建数据集,我们将从下载 zip 存档开始(如果尚未在磁盘上)。然后,我们将在临时文件夹解压缩存档(如果您使用 Windows,应该是 C:\Temp),并且我们将仅读取 movie_lines.txt 和 movie_conversations.txt 文件,这些是我们真正需要创建连续话语数据集的文件。
现在让我们一步一步地进行,创建多个函数,每个步骤一个函数,在文件 corpora_downloader.py 中。我们需要的第一个函数是,如果磁盘上没有可用,从互联网上检索文件。
def download_and_decompress(url, storage_path, storage_dir):
import os.path
directory = storage_path + "/" + storage_dir
zip_file = directory + ".zip"
a_file = directory + "/cornell movie-dialogs corpus/README.txt"
if not os.path.isfile(a_file):
import urllib.request
import zipfile
urllib.request.urlretrieve(url, zip_file)
with zipfile.ZipFile(zip_file, "r") as zfh:
zfh.extractall(directory)
return
此函数正是这样做的:它检查本地是否有 “README.txt” 文件;如果没有,它将下载文件(感谢 urllib.request 模块中的 urlretrieve 函数),然后解压缩 zip(使用 zipfile 模块)。
下一步是读取对话文件并提取话语 ID 列表。提醒一下,它的格式是:u0 +++$+++ u2 +++$+++ m0 +++$+++ ['L194', 'L195', 'L196', 'L197'],因此我们需要关注的是通过用+++$+++分割后的列表中的第四个元素。此外,我们还需要清除方括号和撇号,以获得一个干净的 ID 列表。为此,我们将导入 re 模块,函数将如下所示。
import re
def read_conversations(storage_path, storage_dir):
filename = storage_path + "/" + storage_dir + "/cornell movie-dialogs corpus/movie_conversations.txt"
with open(filename, "r", encoding="ISO-8859-1") as fh:
conversations_chunks = [line.split(" +++$+++ ") for line in fh]
return [re.sub('[\[\]\']', '', el[3].strip()).split(", ") for el in conversations_chunks]
如前所述,记得以正确的编码读取文件,否则会出现错误。此函数的输出是一个包含对话中角色话语 ID 序列的列表的列表。下一步是读取并解析movie_lines.txt文件,以提取实际的对话文本。提醒一下,文件的格式如下:
L195 +++$+++ u2 +++$+++ m0 +++$+++ CAMERON +++$+++ 好吧,我想我们从发音开始,如果你没问题的话。
在这里,我们需要关注的是第一个和最后一个块。
def read_lines(storage_path, storage_dir):
filename = storage_path + "/" + storage_dir + "/cornell movie-dialogs corpus/movie_lines.txt"
with open(filename, "r", encoding="ISO-8859-1") as fh:
lines_chunks = [line.split(" +++$+++ ") for line in fh]
return {line[0]: line[-1].strip() for line in lines_chunks}
最后部分涉及到标记化和对齐。我们希望拥有一组观察结果,其中包含两个连续的话语。通过这种方式,我们可以训练聊天机器人,在给定第一个话语的情况下,生成下一个话语。希望这能促使聊天机器人变得智能,能够回答多个问题。以下是这个函数:
def get_tokenized_sequencial_sentences(list_of_lines, line_text):
for line in list_of_lines:
for i in range(len(line) - 1):
yield (line_text[line[i]].split(" "), line_text[line[i+1]].split(" "))
它的输出是一个生成器,包含两个话语的元组(右边的那个时间上紧跟在左边的后面)。此外,话语是在空格字符上进行标记化的。
最后,我们可以将所有内容封装到一个函数中,该函数下载文件并解压(如果未缓存),解析对话和行,并将数据集格式化为生成器。默认情况下,我们将文件存储在/tmp目录中:
def retrieve_cornell_corpora(storage_path="/tmp", storage_dir="cornell_movie_dialogs_corpus"):
download_and_decompress("http://www.cs.cornell.edu/~cristian/data/cornell_movie_dialogs_corpus.zip",
storage_path,
storage_dir)
conversations = read_conversations(storage_path, storage_dir)
lines = read_lines(storage_path, storage_dir)
return tuple(zip(*list(get_tokenized_sequencial_sentences(conversations, lines))))
此时,我们的训练集与上一章翻译项目中使用的训练集非常相似。实际上,它不仅相似,它是相同的格式和相同的目标。因此,我们可以使用在上一章中开发的一些代码片段。例如,corpora_tools.py文件可以在这里直接使用而不需要任何更改(此外,它还依赖于data_utils.py)。
给定该文件,我们可以进一步深入分析语料库,使用一个脚本检查聊天机器人的输入。
要检查语料库,我们可以使用在上一章中编写的corpora_tools.py,以及我们之前创建的文件。让我们获取 Cornell 电影对话语料库,格式化语料库并打印一个示例及其长度:
from corpora_tools import *
from corpora_downloader import retrieve_cornell_corpora
sen_l1, sen_l2 = retrieve_cornell_corpora()
print("# Two consecutive sentences in a conversation")
print("Q:", sen_l1[0])
print("A:", sen_l2[0])
print("# Corpora length (i.e. number of sentences)")
print(len(sen_l1))
assert len(sen_l1) == len(sen_l2)
这段代码打印了两个标记化的连续话语示例,以及数据集中示例的数量,超过了 220,000 个:
# Two consecutive sentences in a conversation
Q: ['Can', 'we', 'make', 'this', 'quick?', '', 'Roxanne', 'Korrine', 'and', 'Andrew', 'Barrett', 'are', 'having', 'an', 'incredibly', 'horrendous', 'public', 'break-', 'up', 'on', 'the', 'quad.', '', 'Again.']
A: ['Well,', 'I', 'thought', "we'd", 'start', 'with', 'pronunciation,', 'if', "that's", 'okay', 'with', 'you.']
# Corpora length (i.e. number of sentences)
221616
现在,让我们清理句子中的标点符号,将其转为小写,并将其长度限制为最多 20 个单词(也就是那些至少有一个句子长度超过 20 个单词的示例会被丢弃)。这是为了标准化标记:
clean_sen_l1 = [clean_sentence(s) for s in sen_l1]
clean_sen_l2 = [clean_sentence(s) for s in sen_l2]
filt_clean_sen_l1, filt_clean_sen_l2 = filter_sentence_length(clean_sen_l1, clean_sen_l2)
print("# Filtered Corpora length (i.e. number of sentences)")
print(len(filt_clean_sen_l1))
assert len(filt_clean_sen_l1) == len(filt_clean_sen_l2)
这将使我们得到近 140,000 个示例:
# Filtered Corpora length (i.e. number of sentences)
140261
Then, let's create the dictionaries for the two sets of sentences. Practically, they should look the same (since the same sentence appears once on the left side, and once in the right side) except there might be some changes introduced by the first and last sentences of a conversation (they appear only once). To make the best out of our corpora, let's build two dictionaries of words and then encode all the words in the corpora with their dictionary indexes:
dict_l1 = create_indexed_dictionary(filt_clean_sen_l1, dict_size=15000, storage_path="/tmp/l1_dict.p")
dict_l2 = create_indexed_dictionary(filt_clean_sen_l2, dict_size=15000, storage_path="/tmp/l2_dict.p")
idx_sentences_l1 = sentences_to_indexes(filt_clean_sen_l1, dict_l1)
idx_sentences_l2 = sentences_to_indexes(filt_clean_sen_l2, dict_l2)
print("# Same sentences as before, with their dictionary ID")
print("Q:", list(zip(filt_clean_sen_l1[0], idx_sentences_l1[0])))
print("A:", list(zip(filt_clean_sen_l2[0], idx_sentences_l2[0])))
That prints the following output. We also notice that a dictionary of 15 thousand entries doesn't contain all the words and more than 16 thousand (less popular) of them don't fit into it:
[sentences_to_indexes] Did not find 16823 words
[sentences_to_indexes] Did not find 16649 words
# Same sentences as before, with their dictionary ID
Q: [('well', 68), (',', 8), ('i', 9), ('thought', 141), ('we', 23), ("'", 5), ('d', 83), ('start', 370), ('with', 46), ('pronunciation', 3), (',', 8), ('if', 78), ('that', 18), ("'", 5), ('s', 12), ('okay', 92), ('with', 46), ('you', 7), ('.', 4)]
A: [('not', 31), ('the', 10), ('hacking', 7309), ('and', 23), ('gagging', 8761), ('and', 23), ('spitting', 6354), ('part', 437), ('.', 4), ('please', 145), ('.', 4)]
As the final step, let's add paddings and markings to the sentences:
data_set = prepare_sentences(idx_sentences_l1, idx_sentences_l2, max_length_l1, max_length_l2)
print("# Prepared minibatch with paddings and extra stuff")
print("Q:", data_set[0][0])
print("A:", data_set[0][1])
print("# The sentence pass from X to Y tokens")
print("Q:", len(idx_sentences_l1[0]), "->", len(data_set[0][0]))
print("A:", len(idx_sentences_l2[0]), "->", len(data_set[0][1]))
And that, as expected, prints:
# Prepared minibatch with paddings and extra stuff
Q: [0, 68, 8, 9, 141, 23, 5, 83, 370, 46, 3, 8, 78, 18, 5, 12, 92, 46, 7, 4]
A: [1, 31, 10, 7309, 23, 8761, 23, 6354, 437, 4, 145, 4, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# The sentence pass from X to Y tokens
Q: 19 -> 20
A: 11 -> 22
Training the chatbot
After we're done with the corpora, it's now time to work on the model. This project requires again a sequence to sequence model, therefore we can use an RNN. Even more, we can reuse part of the code from the previous project: we'd just need to change how the dataset is built, and the parameters of the model. We can then copy the training script built in the previous chapter, and modify the build_dataset function, to use the Cornell dataset.
Mind that the dataset used in this chapter is bigger than the one used in the previous, therefore you may need to limit the corpora to a few dozen thousand lines. On a 4 years old laptop with 8GB RAM, we had to select only the first 30 thousand lines, otherwise, the program ran out of memory and kept swapping. As a side effect of having fewer examples, even the dictionaries are smaller, resulting in less than 10 thousands words each.
def build_dataset(use_stored_dictionary=False):
sen_l1, sen_l2 = retrieve_cornell_corpora()
clean_sen_l1 = [clean_sentence(s) for s in sen_l1][:30000] ### OTHERWISE IT DOES NOT RUN ON MY LAPTOP
clean_sen_l2 = [clean_sentence(s) for s in sen_l2][:30000] ### OTHERWISE IT DOES NOT RUN ON MY LAPTOP
filt_clean_sen_l1, filt_clean_sen_l2 = filter_sentence_length(clean_sen_l1, clean_sen_l2, max_len=10)
if not use_stored_dictionary:
dict_l1 = create_indexed_dictionary(filt_clean_sen_l1, dict_size=10000, storage_path=path_l1_dict)
dict_l2 = create_indexed_dictionary(filt_clean_sen_l2, dict_size=10000, storage_path=path_l2_dict)
else:
dict_l1 = pickle.load(open(path_l1_dict, "rb"))
dict_l2 = pickle.load(open(path_l2_dict, "rb"))
dict_l1_length = len(dict_l1)
dict_l2_length = len(dict_l2)
idx_sentences_l1 = sentences_to_indexes(filt_clean_sen_l1, dict_l1)
idx_sentences_l2 = sentences_to_indexes(filt_clean_sen_l2, dict_l2)
max_length_l1 = extract_max_length(idx_sentences_l1)
max_length_l2 = extract_max_length(idx_sentences_l2)
data_set = prepare_sentences(idx_sentences_l1, idx_sentences_l2, max_length_l1, max_length_l2)
return (filt_clean_sen_l1, filt_clean_sen_l2), \
data_set, \
(max_length_l1, max_length_l2), \
(dict_l1_length, dict_l2_length)
By inserting this function into the train_translator.py file (from the previous chapter) and rename the file as train_chatbot.py, we can run the training of the chatbot.
After a few iterations, you can stop the program and you'll see something similar to this output:
[sentences_to_indexes] Did not find 0 words
[sentences_to_indexes] Did not find 0 words
global step 100 learning rate 1.0 step-time 7.708967611789704 perplexity 444.90090078460474
eval: perplexity 57.442316329639176
global step 200 learning rate 0.990234375 step-time 7.700247814655302 perplexity 48.8545568311572
eval: perplexity 42.190180314697045
global step 300 learning rate 0.98046875 step-time 7.69800933599472 perplexity 41.620538109894945
eval: perplexity 31.291903031786116
...
...
...
global step 2400 learning rate 0.79833984375 step-time 7.686293318271639 perplexity 3.7086356605442767
eval: perplexity 2.8348589631663046
global step 2500 learning rate 0.79052734375 step-time 7.689657487869262 perplexity 3.211876894960698
eval: perplexity 2.973809378544393
global step 2600 learning rate 0.78271484375 step-time 7.690396382808681 perplexity 2.878854805600354
eval: perplexity 2.563583924617356
Again, if you change the settings, you may end up with a different perplexity. To obtain these results, we set the RNN size to 256 and 2 layers, the batch size of 128 samples, and the learning rate to 1.0.
At this point, the chatbot is ready to be tested. Although you can test the chatbot with the same code as in the test_translator.py of the previous chapter, here we would like to do a more elaborate solution, which allows exposing the chatbot as a service with APIs.
Chatbox API
First of all, we need a web framework to expose the API. In this project, we've chosen Bottle, a lightweight simple framework very easy to use.
To install the package, run pip install bottle from the command line. To gather further information and dig into the code, take a look at the project webpage, bottlepy.org.
现在让我们创建一个函数,用来解析用户作为参数提供的任意句子。所有接下来的代码应该都写在test_chatbot_aas.py文件中。我们从一些导入和使用字典来清理、分词并准备句子的函数开始:
import pickle
import sys
import numpy as np
import tensorflow as tf
import data_utils
from corpora_tools import clean_sentence, sentences_to_indexes, prepare_sentences
from train_chatbot import get_seq2seq_model, path_l1_dict, path_l2_dict
model_dir = "/home/abc/chat/chatbot_model"
def prepare_sentence(sentence, dict_l1, max_length):
sents = [sentence.split(" ")]
clean_sen_l1 = [clean_sentence(s) for s in sents]
idx_sentences_l1 = sentences_to_indexes(clean_sen_l1, dict_l1)
data_set = prepare_sentences(idx_sentences_l1, [[]], max_length, max_length)
sentences = (clean_sen_l1, [[]])
return sentences, data_set
prepare_sentence函数执行以下操作:
-
对输入句子进行分词
-
清理它(转换为小写并清理标点符号)
-
将词元转换为字典 ID
-
添加标记和填充以达到默认长度
接下来,我们需要一个函数,将预测的数字序列转换为由单词组成的实际句子。这是通过decode函数完成的,该函数根据输入句子运行预测,并使用 softmax 预测最可能的输出。最后,它返回没有填充和标记的句子(函数的更详细描述见上一章):
def decode(data_set):
with tf.Session() as sess:
model = get_seq2seq_model(sess, True, dict_lengths, max_sentence_lengths, model_dir)
model.batch_size = 1
bucket = 0
encoder_inputs, decoder_inputs, target_weights = model.get_batch(
{bucket: [(data_set[0][0], [])]}, bucket)
_, _, output_logits = model.step(sess, encoder_inputs, decoder_inputs,
target_weights, bucket, True)
outputs = [int(np.argmax(logit, axis=1)) for logit in output_logits]
if data_utils.EOS_ID in outputs:
outputs = outputs[1:outputs.index(data_utils.EOS_ID)]
tf.reset_default_graph()
return " ".join([tf.compat.as_str(inv_dict_l2[output]) for output in outputs])
最后是主函数,也就是在脚本中运行的函数:
if __name__ == "__main__":
dict_l1 = pickle.load(open(path_l1_dict, "rb"))
dict_l1_length = len(dict_l1)
dict_l2 = pickle.load(open(path_l2_dict, "rb"))
dict_l2_length = len(dict_l2)
inv_dict_l2 = {v: k for k, v in dict_l2.items()}
max_lengths = 10
dict_lengths = (dict_l1_length, dict_l2_length)
max_sentence_lengths = (max_lengths, max_lengths)
from bottle import route, run, request
@route('/api')
def api():
in_sentence = request.query.sentence
_, data_set = prepare_sentence(in_sentence, dict_l1, max_lengths)
resp = [{"in": in_sentence, "out": decode(data_set)}]
return dict(data=resp)
run(host='127.0.0.1', port=8080, reloader=True, debug=True)
初始时,它加载字典并准备反向字典。接着,它使用 Bottle API 创建一个 HTTP GET 端点(在/api URL 下)。路由装饰器设置并增强了当通过 HTTP GET 访问该端点时运行的函数。在这种情况下,运行的是api()函数,它首先读取作为 HTTP 参数传递的句子,然后调用上述的prepare_sentence函数,最后执行解码步骤。返回的是一个字典,其中包含用户提供的输入句子和聊天机器人的回复。
最后,网页服务器已启动,运行在 localhost 的 8080 端口上。使用 Bottle 实现聊天机器人作为服务是不是非常简单?
现在是时候运行它并检查输出了。要运行它,请从命令行执行:
$> python3 –u test_chatbot_aas.py
接着,让我们开始用一些通用问题查询聊天机器人,为此我们可以使用 CURL,这是一个简单的命令行工具;此外,所有浏览器都可以使用,只需记住 URL 应当编码,例如,空格字符应该用它的编码替代,即%20。
Curl 让事情变得更容易,它提供了一种简单的方式来编码 URL 请求。以下是几个示例:
$> curl -X GET -G http://127.0.0.1:8080/api --data-urlencode "sentence=how are you?"
{"data": [{"out": "i ' m here with you .", "in": "where are you?"}]}
$> curl -X GET -G http://127.0.0.1:8080/api --data-urlencode "sentence=are you here?"
{"data": [{"out": "yes .", "in": "are you here?"}]}
$> curl -X GET -G http://127.0.0.1:8080/api --data-urlencode "sentence=are you a chatbot?"
{"data": [{"out": "you ' for the stuff to be right .", "in": "are you a chatbot?"}]}
$> curl -X GET -G http://127.0.0.1:8080/api --data-urlencode "sentence=what is your name ?"
{"data": [{"out": "we don ' t know .", "in": "what is your name ?"}]}
$> curl -X GET -G http://127.0.0.1:8080/api --data-urlencode "sentence=how are you?"
{"data": [{"out": "that ' s okay .", "in": "how are you?"}]}
如果系统在你的浏览器中无法正常工作,请尝试对 URL 进行编码,例如:
$> curl -X GET http://127.0.0.1:8080/api?sentence=how%20are%20you? {"data": [{"out": "that ' s okay .", "in": "how are you?"}]}
回复相当有趣;始终记得我们训练聊天机器人的数据集是电影,因此回复的风格跟电影有关。
要关闭网页服务器,请使用Ctrl + C。
家庭作业
以下是家庭作业:
-
你能创建一个简单的网页,通过 JS 查询聊天机器人吗?
-
互联网上有许多其他训练集可供选择;尝试查看不同模型之间的回答差异。哪种最适合客户服务机器人?
-
你能否修改模型,使其作为服务进行训练,即通过 HTTP GET/POST 传递句子?
摘要
在本章中,我们实现了一个聊天机器人,能够通过 HTTP 端点和 GET API 回答问题。这是我们使用 RNN 能做的又一个精彩示例。在下一章,我们将转向另一个话题:如何使用 Tensorflow 创建推荐系统。
第八章:检测 Quora 重复问题
Quora (www.quora.com) 是一个由社区驱动的问答网站,用户可以匿名或公开提问和回答问题。2017 年 1 月,Quora 首次发布了一个公共数据集,其中包含了问答对,可能是重复的,也可能不是。重复的问答对在语义上相似;换句话说,两道重复的问题表示相同的意思,尽管它们使用不同的措辞来表达相同的意图。对于 Quora 来说,为每个不同的问题提供单独的页面是至关重要的,这样可以为用户提供更好的服务,避免他们在查找答案时需要寻找其他来源。版主可以帮助避免网站上的重复内容,但随着每天回答问题的数量增加以及历史数据仓库的不断扩展,这种方法很难扩大规模。在这种情况下,基于自然语言处理(NLP)和深度学习的自动化项目可能是解决问题的最佳方案。
本章将介绍如何基于 TensorFlow 构建一个项目,使用 Quora 数据集来阐明句子之间的语义相似性。本章基于 Abhishek Thakur 的工作(www.linkedin.com/pulse/duplicate-quora-question-abhishek-thakur/),他最初基于 Keras 包开发了一个解决方案。所展示的技术同样可以轻松应用于处理语义相似性问题的其他任务。在这个项目中,我们将涵盖以下内容:
-
文本数据的特征工程
-
TF-IDF 和 SVD
-
基于 Word2vec 和 GloVe 的特征
-
传统机器学习模型,如逻辑回归和使用
xgboost的梯度提升 -
包括 LSTM、GRU 和 1D-CNN 的深度学习模型
到本章结束时,您将能够训练自己的深度学习模型来解决类似的问题。首先,让我们快速浏览一下 Quora 数据集。
数据集展示
该数据集为非商业用途提供(www.quora.com/about/tos),并在 Kaggle 比赛中发布(www.kaggle.com/c/quora-question-pairs)以及 Quora 的博客上发布(data.quora.com/First-Quora-Dataset-Release-Question-Pairs),包含 404,351 对问题,其中 255,045 个为负样本(非重复问题),149,306 个为正样本(重复问题)。正样本约占 40%,这种轻微的不平衡无需特别修正。实际上,正如 Quora 博客所述,根据他们的原始抽样策略,数据集中的重复样本数量远高于非重复样本。为了建立一个更平衡的数据集,负样本通过使用相关问题对进行过上采样,即关于相同主题的、实际上不相似的问题。
在开始进行这个项目之前,你可以直接从其 Amazon S3 存储库下载大约 55 MB 的数据,下载链接为:qim.ec.quoracdn.net/quora_duplicate_questions.tsv,并将其放入我们的工作目录中。
加载后,我们可以通过选择一些示例行并检查它们,直接开始深入数据。以下图表显示了数据集中前几行的实际快照:

Quora 数据集的前几行
进一步探索数据,我们可以找到一些意思相同的问答对,即重复问题,如下所示:
| Quora 如何快速标记问题为需要改进? | 为什么 Quora 标记我的问题为需要改进/澄清? |
在我有时间详细说明之前?
字面意思上,几秒钟内…… |
| 为什么特朗普赢得了总统选举? | 唐纳德·特朗普是如何赢得 2016 年总统选举的? |
|---|---|
| 希格斯玻色子的发现可能带来哪些实际应用? | 希格斯玻色子的发现有什么实际的好处? |
初看,重复问题有很多共同的词语,但它们的长度可能非常不同。
另一方面,以下是一些非重复问题的示例:
| 如果我申请像 Mozilla 这样的公司,我应该将求职信寄给谁? | 从安全角度来看,哪款车更好?Swift 还是 Grand i10?我的首要考虑是安全性。 |
|---|---|
| 《黑客军团》(电视剧):《黑客军团》是否真实地表现了现实生活中的黑客和黑客文化?黑客社会的描绘是否现实? | 《黑客军团》中描绘的黑客与现实生活中的网络安全漏洞或普通技术使用相比,存在哪些错误? |
| 如何启动一个在线购物(电子商务)网站? | 哪种网络技术最适合构建大型电子商务网站? |
这些例子中的一些问题显然没有重复,并且只有少量相同的词汇,但其他一些则更难以识别为不相关。例如,第二对问题可能对某些人来说具有吸引力,甚至让人类评判者也感到不确定。这两个问题可能意味着不同的事情:为什么和如何,或者它们可能在表面上看起来是相同的。深入分析,我们甚至可能会发现更多可疑的例子,甚至一些明显的数据错误;我们肯定在数据集中存在一些异常(正如数据集上的 Quora 帖子所警告的那样),但鉴于这些数据源自现实世界问题,我们无法做任何事情,只能应对这种不完美并努力找到一个有效的解决方案。
到此为止,我们的探索变得更加定量而非定性,这里提供了一些关于问题对的统计数据:
| 问题 1 中字符的平均数 | 59.57 |
|---|---|
| 问题 1 中字符的最小数 | 1 |
| 问题 1 中字符的最大数 | 623 |
| 问题 2 中字符的平均数 | 60.14 |
| 问题 2 中字符的最小数 | 1 |
| 问题 2 中字符的最大数 | 1169 |
问题 1 和问题 2 的平均字符数大致相同,尽管问题 2 有更多的极端值。数据中肯定也有一些垃圾数据,因为我们无法理解由单个字符组成的问题。
我们甚至可以通过将数据绘制成词云,突出显示数据集中最常见的词汇,从而获得完全不同的数据视角:

图 1:由在 Quora 数据集中最常见的词汇构成的词云
词序列如“希拉里·克林顿”和“唐纳德·特朗普”的出现提醒我们,数据是在某个历史时刻收集的,我们可以在其中找到的许多问题显然是短暂的,仅在数据集收集时才是合理的。其他主题,如编程语言、世界大战或赚取金钱,可能会持续更长时间,无论是从兴趣的角度还是从提供的答案的有效性上来看。
在稍微探索了一下数据后,现在是时候决定我们在项目中要优化的目标指标是什么了。在本章中,我们将使用准确度作为评估模型性能的指标。准确度作为衡量标准,单纯关注预测的有效性,可能会忽视一些替代模型之间的重要差异,例如辨别能力(模型是否更能识别重复项?)或概率分数的准确性(是否存在重复与非重复之间的明显差距?)。我们选择了准确度,基于这样一个事实:这是 Quora 的工程团队为该数据集创建基准时决定采用的指标(正如他们在这篇博客文章中所述:engineering.quora.com/Semantic-Question-Matching-with-Deep-Learning)。使用准确度作为指标可以让我们更容易评估和比较我们的模型与 Quora 工程团队的模型,也可以与其他几篇研究论文进行比较。此外,在实际应用中,我们的工作可能仅根据其正确与错误的次数进行评估,而无需考虑其他因素。
我们现在可以继续在项目中进行一些非常基础的特征工程,作为起点。
从基础特征工程开始
在开始编写代码之前,我们需要在 Python 中加载数据集,并为我们的项目提供所有必需的包。我们需要在系统上安装这些包(最新版本应该足够,不需要特定版本的包):
-
Numpy -
pandas -
fuzzywuzzy -
python-Levenshtein -
scikit-learn -
gensim -
pyemd -
NLTK
由于我们将在项目中使用这些包中的每一个,因此我们将提供安装它们的具体说明和提示。
对于所有的数据集操作,我们将使用 pandas(Numpy 也会派上用场)。要安装 numpy 和 pandas:
pip install numpy
pip install pandas
数据集可以通过 pandas 和一种专用的数据结构——pandas 数据框,轻松加载到内存中(我们期望数据集与您的脚本或 Jupyter notebook 在同一目录下):
import pandas as pd
import numpy as np
data = pd.read_csv('quora_duplicate_questions.tsv', sep='\t')
data = data.drop(['id', 'qid1', 'qid2'], axis=1)
在本章中,我们将使用名为 data 的 pandas 数据框,并且当我们使用 TensorFlow 模型并为其提供输入时,也会使用它。
我们现在可以开始创建一些非常基础的特征。这些基础特征包括基于长度的特征和基于字符串的特征:
-
question1 的长度
-
question2 的长度
-
两个长度之间的差异
-
不含空格的 question1 字符长度
-
不含空格的 question2 字符长度
-
question1 中的单词数
-
question2 中的单词数
-
question1 和 question2 中的共同单词数
这些特征通过单行代码处理,使用 pandas 包中的 apply 方法转换原始输入:
# length based features
data['len_q1'] = data.question1.apply(lambda x: len(str(x)))
data['len_q2'] = data.question2.apply(lambda x: len(str(x)))
# difference in lengths of two questions
data['diff_len'] = data.len_q1 - data.len_q2
# character length based features
data['len_char_q1'] = data.question1.apply(lambda x:
len(''.join(set(str(x).replace(' ', '')))))
data['len_char_q2'] = data.question2.apply(lambda x:
len(''.join(set(str(x).replace(' ', '')))))
# word length based features
data['len_word_q1'] = data.question1.apply(lambda x:
len(str(x).split()))
data['len_word_q2'] = data.question2.apply(lambda x:
len(str(x).split()))
# common words in the two questions
data['common_words'] = data.apply(lambda x:
len(set(str(x['question1'])
.lower().split())
.intersection(set(str(x['question2'])
.lower().split()))), axis=1)
供日后参考,我们将这组特征标记为特征集-1 或 fs_1:
fs_1 = ['len_q1', 'len_q2', 'diff_len', 'len_char_q1',
'len_char_q2', 'len_word_q1', 'len_word_q2',
'common_words']
这种简单的方法将帮助你轻松回忆并结合我们将在构建的机器学习模型中的不同特征集,从而使得比较不同特征集运行的不同模型变得轻而易举。
创建模糊特征
下一组特征基于模糊字符串匹配。模糊字符串匹配也称为近似字符串匹配,是寻找与给定模式大致匹配的字符串的过程。匹配的接近度由将字符串转换为完全匹配所需的原始操作数量来定义。这些原始操作包括插入(在给定位置插入一个字符)、删除(删除一个特定字符)和替代(将字符替换为新字符)。
模糊字符串匹配通常用于拼写检查、抄袭检测、DNA 序列匹配、垃圾邮件过滤等,它是编辑距离更大范畴的一部分,编辑距离的思想是一个字符串可以转换成另一个字符串。它在自然语言处理和其他应用中经常被用来确定两个字符字符串之间的差异程度。
它也被称为 Levenshtein 距离,得名于俄罗斯科学家弗拉基米尔·列文斯坦,他于 1965 年提出了这一概念。
这些特征是使用fuzzywuzzy包创建的,该包可以在 Python 中使用(pypi.python.org/pypi/fuzzywuzzy)。该包使用 Levenshtein 距离来计算两个序列之间的差异,在我们的例子中是问题对。
fuzzywuzzy包可以通过 pip3 进行安装:
pip install fuzzywuzzy
作为一个重要的依赖,fuzzywuzzy需要Python-Levenshtein包(github.com/ztane/python-Levenshtein/),这是一个由编译的 C 代码驱动的经典算法的极其快速实现。为了使用fuzzywuzzy使计算变得更快,我们还需要安装Python-Levenshtein包:
pip install python-Levenshtein
fuzzywuzzy包提供了多种不同类型的比率,但我们将只使用以下几种:
-
QRatio
-
WRatio
-
部分比率
-
部分令牌集比率
-
部分令牌排序比率
-
令牌集比率
-
令牌排序比率
fuzzywuzzy在 Quora 数据上的特征示例:
from fuzzywuzzy import fuzz
fuzz.QRatio("Why did Trump win the Presidency?",
"How did Donald Trump win the 2016 Presidential Election")
这段代码将返回 67 的值:
fuzz.QRatio("How can I start an online shopping (e-commerce) website?", "Which web technology is best suitable for building a big E-Commerce website?")
在这个比较中,返回的值将是 60。根据这些示例,我们注意到尽管QRatio的值彼此接近,但来自数据集中相似问题对的值要高于没有相似性的对。让我们来看一下fuzzywuzzy为这些相同问题对提供的另一个特征:
fuzz.partial_ratio("Why did Trump win the Presidency?",
"How did Donald Trump win the 2016 Presidential Election")
在这种情况下,返回的值为 73:
fuzz.partial_ratio("How can I start an online shopping (e-commerce) website?", "Which web technology is best suitable for building a big E-Commerce website?")
现在返回的值是 57。
使用partial_ratio方法,我们可以观察到这两对问题的得分差异明显增大,从而使得区分是否为重复对更加容易。我们假设这些特征可能会为我们的模型增加价值。
通过使用 pandas 和 Python 中的fuzzywuzzy包,我们可以再次将这些特征作为简单的一行代码来应用:
data['fuzz_qratio'] = data.apply(lambda x: fuzz.QRatio(
str(x['question1']), str(x['question2'])), axis=1)
data['fuzz_WRatio'] = data.apply(lambda x: fuzz.WRatio(
str(x['question1']), str(x['question2'])), axis=1)
data['fuzz_partial_ratio'] = data.apply(lambda x:
fuzz.partial_ratio(str(x['question1']),
str(x['question2'])), axis=1)
data['fuzz_partial_token_set_ratio'] = data.apply(lambda x:
fuzz.partial_token_set_ratio(str(x['question1']),
str(x['question2'])), axis=1)
data['fuzz_partial_token_sort_ratio'] = data.apply(lambda x:
fuzz.partial_token_sort_ratio(str(x['question1']),
str(x['question2'])), axis=1)
data['fuzz_token_set_ratio'] = data.apply(lambda x:
fuzz.token_set_ratio(str(x['question1']),
str(x['question2'])), axis=1)
data['fuzz_token_sort_ratio'] = data.apply(lambda x:
fuzz.token_sort_ratio(str(x['question1']),
str(x['question2'])), axis=1)
这一组特征从此被称为特征集-2 或fs_2:
fs_2 = ['fuzz_qratio', 'fuzz_WRatio', 'fuzz_partial_ratio',
'fuzz_partial_token_set_ratio', 'fuzz_partial_token_sort_ratio',
'fuzz_token_set_ratio', 'fuzz_token_sort_ratio']
再次,我们将存储我们的工作,并在建模时保存以备后用。
借助 TF-IDF 和 SVD 特征
接下来几组特征基于 TF-IDF 和 SVD。词频-逆文档频率 (TF-IDF) 是信息检索的基础算法之一。这里,使用一个公式来解释该算法:


你可以通过这个符号来理解公式:C(t)是术语t在文档中出现的次数,N是文档中的总词数,这就得出了词频(TF)。ND 是文档的总数,ND[t]是包含术语t的文档数,这提供了逆文档频率(IDF)。术语t的 TF-IDF 是词频和逆文档频率的乘积:

在没有任何先验知识的情况下,除了文档本身的信息,这种得分将突出所有可能轻松区分一个文档与其他文档的术语,降低那些不会提供太多信息的常见词的权重,比如常见的词类(例如冠词)。
如果你需要更实际的 TFIDF 解释,这篇很棒的在线教程将帮助你尝试自己编写算法并在一些文本数据上进行测试:stevenloria.com/tf-idf/
为了方便和加快执行速度,我们使用了scikit-learn的 TFIDF 实现。如果你尚未安装scikit-learn,可以通过 pip 进行安装:
pip install -U scikit-learn
我们分别为问题 1 和问题 2 创建 TFIDF 特征(为了减少输入量,我们直接深拷贝问题 1 的TfidfVectorizer):
from sklearn.feature_extraction.text import TfidfVectorizer
from copy import deepcopy
tfv_q1 = TfidfVectorizer(min_df=3,
max_features=None,
strip_accents='unicode',
analyzer='word',
token_pattern=r'\w{1,}',
ngram_range=(1, 2),
use_idf=1,
smooth_idf=1,
sublinear_tf=1,
stop_words='english')
tfv_q2 = deepcopy(tfv_q1)
必须注意,这里显示的参数是在经过大量实验后选择的。这些参数通常能很好地应用于所有其他自然语言处理相关问题,特别是文本分类。可能需要根据所处理语言的不同来修改停用词列表。
我们现在可以分别获取问题 1 和问题 2 的 TFIDF 矩阵:
q1_tfidf = tfv_q1.fit_transform(data.question1.fillna(""))
q2_tfidf = tfv_q2.fit_transform(data.question2.fillna(""))
在我们的 TFIDF 处理过程中,我们基于所有可用数据计算了 TFIDF 矩阵(我们使用了fit_transform方法)。这在 Kaggle 竞赛中是一种常见方法,因为它有助于在排行榜上获得更高的分数。然而,如果你在实际环境中工作,你可能希望将一部分数据作为训练集或验证集,以确保你的 TFIDF 处理有助于你的模型在新的、未见过的数据集上进行泛化。
在获得 TFIDF 特征后,我们进入 SVD 特征的处理。SVD 是一种特征分解方法,全称是奇异值分解(Singular Value Decomposition)。它在自然语言处理(NLP)中广泛应用,因为有一种叫做潜在语义分析(LSA)的方法。
对 SVD 和 LSA 的详细讨论超出了本章的范围,但你可以通过尝试以下两个简明易懂的在线教程,了解它们的工作原理:alyssaq.github.io/2015/singular-value-decomposition-visualisation/ 和 technowiki.wordpress.com/2011/08/27/latent-semantic-analysis-lsa-tutorial/
为了创建 SVD 特征,我们再次使用scikit-learn的实现。这种实现是传统 SVD 的变种,称为TruncatedSVD。
TruncatedSVD是一种近似的 SVD 方法,可以为你提供可靠且计算速度较快的 SVD 矩阵分解。你可以通过查阅以下网页,了解更多关于该技术的工作原理以及如何应用:langvillea.people.cofc.edu/DISSECTION-LAB/Emmie'sLSI-SVDModule/p5module.html
from sklearn.decomposition import TruncatedSVD
svd_q1 = TruncatedSVD(n_components=180)
svd_q2 = TruncatedSVD(n_components=180)
我们选择了 180 个 SVD 分解组件,这些特征是基于 TF-IDF 矩阵计算的:
question1_vectors = svd_q1.fit_transform(q1_tfidf)
question2_vectors = svd_q2.fit_transform(q2_tfidf)
特征集-3 是通过将这些 TF-IDF 和 SVD 特征组合而来的。例如,我们可以仅使用两个问题的 TF-IDF 特征单独输入模型,或者将两个问题的 TF-IDF 与其上的 SVD 结合起来,再输入模型,依此类推。以下是这些特征的详细说明。
特征集-3(1)或fs3_1是通过对两个问题使用不同的 TF-IDF 计算得到的,随后将它们水平堆叠并传递给机器学习模型:

这可以按如下方式编写代码:
from scipy import sparse
# obtain features by stacking the sparse matrices together
fs3_1 = sparse.hstack((q1_tfidf, q2_tfidf))
特征集-3(2)或fs3_2是通过将两个问题合并并使用单一的 TF-IDF 来创建的:

tfv = TfidfVectorizer(min_df=3,
max_features=None,
strip_accents='unicode',
analyzer='word',
token_pattern=r'\w{1,}',
ngram_range=(1, 2),
use_idf=1,
smooth_idf=1,
sublinear_tf=1,
stop_words='english')
# combine questions and calculate tf-idf
q1q2 = data.question1.fillna("")
q1q2 += " " + data.question2.fillna("")
fs3_2 = tfv.fit_transform(q1q2)
该特征集中的下一个特征子集,特征集-3(3)或fs3_3,包含两个问题分别计算的 TF-IDF 和 SVD:

这可以按如下方式编写代码:
# obtain features by stacking the matrices together
fs3_3 = np.hstack((question1_vectors, question2_vectors))
我们可以通过类似的方式,使用 TF-IDF 和 SVD 创建几个组合,并分别称之为 fs3-4 和 fs3-5。这些组合在以下图示中有所展示,但代码部分留给读者作为练习。
特征集-3(4) 或 fs3-4:

特征集-3(5) 或 fs3-5:

在基本特征集以及一些 TF-IDF 和 SVD 特征之后,我们现在可以转向更复杂的特征,然后再深入到机器学习和深度学习模型中。
使用 Word2vec 嵌入进行映射
从广义上讲,Word2vec 模型是两层神经网络,它们将文本语料库作为输入,并为语料库中的每个单词输出一个向量。训练完成后,意义相似的单词的向量会相互接近,也就是说,它们之间的距离比意义差异很大的单词向量之间的距离要小。
目前,Word2vec 已成为自然语言处理问题中的标准,并且它通常能为信息检索任务提供非常有用的见解。对于这个特定问题,我们将使用 Google 新闻向量。这是一个在 Google 新闻语料库上训练的预训练 Word2vec 模型。
每个单词,当通过其 Word2vec 向量表示时,会在空间中获得一个位置,如下图所示:

这个示例中的所有单词,例如德国、柏林、法国和巴黎,如果我们使用来自 Google 新闻语料库的预训练向量,它们都可以用一个 300 维的向量来表示。当我们使用 Word2vec 表示这些单词时,若我们从柏林的向量中减去德国的向量,再加上法国的向量,我们将得到一个与巴黎向量非常相似的向量。因此,Word2vec 模型通过向量携带单词的含义。这些向量携带的信息构成了我们任务中非常有用的特征。
为了提供一个更为用户友好且更深入的解释和描述 Word2vec 可能的应用,我们建议阅读 www.distilled.net/resources/a-beginners-guide-to-Word2vec-aka-whats-the-opposite-of-canada/,或者如果你需要一个更具数学定义的解释,建议阅读这篇论文:www.1-4-5.net/~dmm/ml/how_does_Word2vec_work.pdf
为了加载 Word2vec 特征,我们将使用 Gensim。如果你还没有安装 Gensim,可以通过 pip 轻松安装。在此时,建议你同时安装 pyemd 包,这个包将被 WMD 距离函数使用,这个函数将帮助我们关联两个 Word2vec 向量:
pip install gensim
pip install pyemd
为了加载 Word2vec 模型,我们下载 GoogleNews-vectors-negative300.bin.gz 二进制文件,并使用 Gensim 的 load_Word2vec_format 函数将其加载到内存中。您可以使用 wget 命令从 Shell 中轻松下载该二进制文件,来自一个 Amazon AWS 的仓库:
wget -c "https://s3.amazonaws.com/dl4j-distribution/GoogleNews-vectors-negative300.bin.gz"
下载并解压文件后,您可以使用 Gensim 的 KeyedVectors 函数进行操作:
import gensim
model = gensim.models.KeyedVectors.load_word2vec_format(
'GoogleNews-vectors-negative300.bin.gz', binary=True)
现在,我们可以通过调用 model[word] 来轻松获得单词的向量。然而,当我们处理句子而非单个单词时,就会出现一个问题。在我们的案例中,我们需要问题 1 和问题 2 的所有向量,以便进行某种比较。为此,我们可以使用以下代码片段。该代码片段基本上是将句子中所有在 Google 新闻向量中存在的单词的向量相加,最后给出一个归一化的向量。我们可以称其为句子向量(Sent2Vec)。
在运行前,请确保已安装自然语言工具包(NLTK):
$ pip install nltk
还建议您下载 punkt 和 stopwords 包,因为它们是 NLTK 的一部分:
import nltk
nltk.download('punkt')
nltk.download('stopwords')
如果 NLTK 不可用,您只需运行以下代码片段并定义 sent2vec 函数:
from nltk.corpus import stopwords
from nltk import word_tokenize
stop_words = set(stopwords.words('english'))
def sent2vec(s, model):
M = []
words = word_tokenize(str(s).lower())
for word in words:
#It shouldn't be a stopword
if word not in stop_words:
#nor contain numbers
if word.isalpha():
#and be part of word2vec
if word in model:
M.append(model[word])
M = np.array(M)
if len(M) > 0:
v = M.sum(axis=0)
return v / np.sqrt((v ** 2).sum())
else:
return np.zeros(300)
当短语为空时,我们任意决定返回一个标准的零值向量。
为了计算问题之间的相似性,我们创建的另一个特征是词移动距离(word mover's distance)。词移动距离使用 Word2vec 嵌入,并基于类似地球移动距离(earth mover's distance)的原理,为两个文本文件之间提供距离。简而言之,词移动距离提供了将一个文档中的所有单词移动到另一个文档所需的最小距离。
该论文引入了 WMD:KUSNER, Matt, 等. 从词嵌入到文档距离. 在:国际机器学习会议. 2015 年,p. 957-966
,可以在proceedings.mlr.press/v37/kusnerb15.pdf找到。关于该距离的实操教程,您还可以参考基于 Gensim 实现的教程:markroxor.github.io/gensim/static/notebooks/WMD_tutorial.html
最终的Word2vec(w2v)特征还包括其他距离,如更常见的欧几里得距离或余弦距离。我们通过一些测量来补充这两个文档向量的分布特征:
-
词移动距离
-
归一化的词移动距离
-
问题 1 和问题 2 向量之间的余弦距离
-
问题 1 和问题 2 向量之间的曼哈顿距离
-
问题 1 和问题 2 向量之间的 Jaccard 相似度
-
问题 1 和问题 2 向量之间的 Canberra 距离
-
问题 1 和问题 2 向量之间的欧几里得距离
-
问题 1 和问题 2 向量之间的闵可夫斯基距离
-
问题 1 和问题 2 向量之间的 Braycurtis 距离
-
问题 1 向量的偏度
-
问题 2 向量的偏度
-
问题 1 向量的峰度
-
问题 2 向量的峰度
所有的 Word2vec 特征都用 fs4 表示。
一个单独的 w2v 特征集由 Word2vec 向量矩阵组成:
-
问题 1 的 Word2vec 向量
-
问题 2 的 Word2vec 向量
这些将通过 fs5 表示:
w2v_q1 = np.array([sent2vec(q, model)
for q in data.question1])
w2v_q2 = np.array([sent2vec(q, model)
for q in data.question2])
为了便于实现 Quora 问题的 Word2vec 嵌入向量之间的各种距离度量,我们使用了 scipy.spatial.distance module 中的实现:
from scipy.spatial.distance import cosine, cityblock,
jaccard, canberra, euclidean, minkowski, braycurtis
data['cosine_distance'] = [cosine(x,y)
for (x,y) in zip(w2v_q1, w2v_q2)]
data['cityblock_distance'] = [cityblock(x,y)
for (x,y) in zip(w2v_q1, w2v_q2)]
data['jaccard_distance'] = [jaccard(x,y)
for (x,y) in zip(w2v_q1, w2v_q2)]
data['canberra_distance'] = [canberra(x,y)
for (x,y) in zip(w2v_q1, w2v_q2)]
data['euclidean_distance'] = [euclidean(x,y)
for (x,y) in zip(w2v_q1, w2v_q2)]
data['minkowski_distance'] = [minkowski(x,y,3)
for (x,y) in zip(w2v_q1, w2v_q2)]
data['braycurtis_distance'] = [braycurtis(x,y)
for (x,y) in zip(w2v_q1, w2v_q2)]
与距离相关的所有特征名称都集中在 fs4_1 列表中:
fs4_1 = ['cosine_distance', 'cityblock_distance',
'jaccard_distance', 'canberra_distance',
'euclidean_distance', 'minkowski_distance',
'braycurtis_distance']
这两个问题的 Word2vec 矩阵会水平堆叠,并存储在 w2v 变量中,以便稍后使用:
w2v = np.hstack((w2v_q1, w2v_q2))
Word Mover's Distance 是通过一个函数实现的,该函数返回两个问题之间的距离,先将它们转换为小写并去除所有停用词。此外,我们还计算了距离的归一化版本,方法是将所有 Word2vec 向量转化为 L2 归一化向量(即将每个向量转换为单位范数,即如果我们将向量中的每个元素平方并求和,结果应为一),这一过程通过 init_sims 方法完成:
def wmd(s1, s2, model):
s1 = str(s1).lower().split()
s2 = str(s2).lower().split()
stop_words = stopwords.words('english')
s1 = [w for w in s1 if w not in stop_words]
s2 = [w for w in s2 if w not in stop_words]
return model.wmdistance(s1, s2)
data['wmd'] = data.apply(lambda x: wmd(x['question1'],
x['question2'], model), axis=1)
model.init_sims(replace=True)
data['norm_wmd'] = data.apply(lambda x: wmd(x['question1'],
x['question2'], model), axis=1)
fs4_2 = ['wmd', 'norm_wmd']
在这些最后的计算之后,我们现在拥有了大多数创建基本机器学习模型所需的重要特征,这些模型将作为我们深度学习模型的基准。下表展示了可用特征的快照:

让我们在这些和其他基于 Word2vec 的特征上训练一些机器学习模型。
测试机器学习模型
在继续之前,根据你的系统,你可能需要清理一些内存,并释放空间以便为机器学习模型腾出地方,可以通过 gc.collect 来完成,先删除任何不再需要的变量,然后通过 psutil.virtualmemory 函数来检查可用内存:
import gc
import psutil
del([tfv_q1, tfv_q2, tfv, q1q2,
question1_vectors, question2_vectors, svd_q1,
svd_q2, q1_tfidf, q2_tfidf])
del([w2v_q1, w2v_q2])
del([model])
gc.collect()
psutil.virtual_memory()
到目前为止,我们简要回顾了到目前为止创建的不同特征,以及它们在生成特征方面的含义:
-
fs_1: 基本特征列表 -
fs_2: 模糊特征列表 -
fs3_1: 分离问题的 TFIDF 稀疏数据矩阵 -
fs3_2: 组合问题的 TFIDF 稀疏数据矩阵 -
fs3_3: SVD 的稀疏数据矩阵 -
fs3_4: SVD 统计信息列表 -
fs4_1: W2vec 距离列表 -
fs4_2: WMD 距离列表 -
w2v: 通过Sent2Vec函数转化的短语的 Word2vec 向量矩阵
我们评估了两种基本且非常流行的机器学习模型,即逻辑回归和使用 xgboost 包的梯度提升算法。下表提供了逻辑回归和 xgboost 算法在先前创建的不同特征集上的表现,这些结果是在 Kaggle 竞赛中获得的:
| 特征集 | 逻辑回归准确率 | xgboost 准确率 |
|---|---|---|
| 基本特征 (fs1) | 0.658 | 0.721 |
| 基本特征 + 模糊特征 (fs1 + fs2) | 0.660 | 0.738 |
| 基本特征 + 模糊特征 + w2v 特征 (fs1 + fs2 + fs4) | 0.676 | 0.766 |
| W2v 向量特征 (fs5) | * | 0.78 |
| 基本特征 + 模糊特征 + w2v 特征 + w2v 向量特征 (fs1 + fs2 + fs4 + fs5) | * | 0.814 |
TFIDF-SVD 特征 (fs3-1) |
0.777 | 0.749 |
TFIDF-SVD 特征 (fs3-2) |
0.804 | 0.748 |
TFIDF-SVD 特征 (fs3-3) |
0.706 | 0.763 |
TFIDF-SVD 特征 (fs3-4) |
0.700 | 0.753 |
TFIDF-SVD 特征 (fs3-5) |
0.714 | 0.759 |
- = 由于高内存要求,这些模型未经过训练。
我们可以将这些表现作为基准或起始值,作为开始深度学习模型之前的参考,但我们不会仅限于此,我们还将尝试复制其中的一些。
我们将从导入所有必要的包开始。至于逻辑回归,我们将使用 scikit-learn 的实现。
xgboost 是一个可扩展、便携和分布式的梯度提升库(一个树集成机器学习算法)。最初由华盛顿大学的陈天奇创建,后来由 Bing Xu 添加了 Python 包装器,并由 Tong He 提供了 R 接口(你可以通过 homes.cs.washington.edu/~tqchen/2016/03/10/story-and-lessons-behind-the-evolution-of-xgboost.html 直接阅读关于 xgboost 的故事)。xgboost 支持 Python、R、Java、Scala、Julia 和 C++,它可以在单机(利用多线程)以及 Hadoop 和 Spark 集群中运行。
详细的 xgboost 安装说明可以在此页面找到:github.com/dmlc/xgboost/blob/master/doc/build.md
在 Linux 和 macOS 上安装 xgboost 比较简单,而对于 Windows 用户来说稍微复杂一些。
因此,我们提供了在 Windows 上安装 xgboost 的特定步骤:
-
首先,下载并安装 Git for Windows (git-for-windows.github.io)
-
然后,你需要在系统上安装 MINGW 编译器。你可以根据系统特点从 www.mingw.org 下载它
-
从命令行执行:
$> git clone --recursive [github.com/dmlc/xgboost](https://github.com/dmlc/xgboost)$> cd xgboost$> git submodule init$> git submodule update -
然后,总是从命令行中,你可以将 64 字节系统的配置复制为默认配置:
$> copy make\mingw64.mk config.mk或者,你可以直接复制 32 字节版本:
$> copy make\mingw.mk config.mk -
复制配置文件后,你可以运行编译器,设置为使用四个线程,以加速编译过程:
$> mingw32-make -j4 -
在 MinGW 中,
make命令的名称为mingw32-make;如果你使用的是其他编译器,之前的命令可能无法运行,但你可以简单地尝试:$> make -j4 -
最后,如果编译器工作没有错误,你可以使用以下命令在 Python 中安装该软件包:
$> cd python-package$> python setup.py install
如果 xgboost 已正确安装在你的系统上,你可以继续导入这两种机器学习算法:
from sklearn import linear_model
from sklearn.preprocessing import StandardScaler
import xgboost as xgb
由于我们将使用一个对特征尺度敏感的逻辑回归求解器(它是来自 github.com/EpistasisLab/tpot/issues/292 的 sag 求解器,要求计算时间与数据大小呈线性关系),因此我们将首先使用 scikit-learn 中的 scaler 函数对数据进行标准化:
scaler = StandardScaler()
y = data.is_duplicate.values
y = y.astype('float32').reshape(-1, 1)
X = data[fs_1+fs_2+fs3_4+fs4_1+fs4_2]
X = X.replace([np.inf, -np.inf], np.nan).fillna(0).values
X = scaler.fit_transform(X)
X = np.hstack((X, fs3_3))
我们还通过首先过滤 fs_1、fs_2、fs3_4、fs4_1 和 fs4_2 变量集的数据,然后堆叠 fs3_3 稀疏 SVD 数据矩阵,来选择用于训练的数据。我们还提供了一个随机划分,将数据的 1/10 分配给验证集(以便有效评估创建的模型的质量):
np.random.seed(42)
n_all, _ = y.shape
idx = np.arange(n_all)
np.random.shuffle(idx)
n_split = n_all // 10
idx_val = idx[:n_split]
idx_train = idx[n_split:]
x_train = X[idx_train]
y_train = np.ravel(y[idx_train])
x_val = X[idx_val]
y_val = np.ravel(y[idx_val])
作为第一个模型,我们尝试逻辑回归,将正则化 l2 参数 C 设置为 0.1(适度的正则化)。模型准备好后,我们在验证集上测试其有效性(x_val 为训练矩阵,y_val 为正确答案)。结果通过准确度进行评估,即验证集中正确猜测的比例:
logres = linear_model.LogisticRegression(C=0.1,
solver='sag', max_iter=1000)
logres.fit(x_train, y_train)
lr_preds = logres.predict(x_val)
log_res_accuracy = np.sum(lr_preds == y_val) / len(y_val)
print("Logistic regr accuracy: %0.3f" % log_res_accuracy)
过了一会儿(求解器最多会有 1,000 次迭代,若未能收敛则放弃),验证集上的最终准确率为 0.743,这将成为我们的起始基准。
现在,我们尝试使用 xgboost 算法进行预测。作为一个梯度提升算法,这种学习算法具有更高的方差(能够拟合复杂的预测函数,但也容易过拟合),而简单的逻辑回归则偏向更大的偏差(最终是系数的加和),因此我们期望能得到更好的结果。我们将其决策树的最大深度固定为 4(一个较浅的深度,应该可以防止过拟合),并使用 0.02 的 eta(学习较慢,需要生长更多的树)。我们还设置了一个监控列表,监控验证集的情况,如果验证集的预期误差在 50 步之后没有下降,则提前停止。
在同一数据集上(在我们这里是验证集)提前停止并不是最佳实践。理想情况下,在实际应用中,我们应该为调优操作(如提前停止)设置一个验证集,并为报告在泛化到新数据时的预期结果设置一个测试集。
设置好这些之后,我们运行算法。这一次,我们需要等待的时间将比运行逻辑回归时更长:
params = dict()
params['objective'] = 'binary:logistic'
params['eval_metric'] = ['logloss', 'error']
params['eta'] = 0.02
params['max_depth'] = 4
d_train = xgb.DMatrix(x_train, label=y_train)
d_valid = xgb.DMatrix(x_val, label=y_val)
watchlist = [(d_train, 'train'), (d_valid, 'valid')]
bst = xgb.train(params, d_train, 5000, watchlist,
early_stopping_rounds=50, verbose_eval=100)
xgb_preds = (bst.predict(d_valid) >= 0.5).astype(int)
xgb_accuracy = np.sum(xgb_preds == y_val) / len(y_val)
print("Xgb accuracy: %0.3f" % xgb_accuracy)
xgboost报告的最终结果是验证集上的0.803准确率。
构建一个 TensorFlow 模型
本章中的深度学习模型是使用 TensorFlow 构建的,基于 Abhishek Thakur 使用 Keras 编写的原始脚本(你可以在github.com/abhishekkrthakur/is_that_a_duplicate_quora_question阅读原始代码)。Keras 是一个 Python 库,提供了 TensorFlow 的简易接口。TensorFlow 官方支持 Keras,使用 Keras 训练的模型可以轻松转换为 TensorFlow 模型。Keras 使得深度学习模型的快速原型设计和测试成为可能。在我们的项目中,我们无论如何都从头开始完全用 TensorFlow 重写了解决方案。
首先,让我们导入必要的库,特别是 TensorFlow,并通过打印它来检查其版本:
import zipfile
from tqdm import tqdm_notebook as tqdm
import tensorflow as tf
print("TensorFlow version %s" % tf.__version__)
在这一点上,我们简单地将数据加载到df pandas 数据框中,或者从磁盘加载它。我们用空字符串替换缺失值,并设置包含目标答案的y变量,目标答案编码为 1(重复)或 0(未重复):
try:
df = data[['question1', 'question2', 'is_duplicate']]
except:
df = pd.read_csv('data/quora_duplicate_questions.tsv',
sep='\t')
df = df.drop(['id', 'qid1', 'qid2'], axis=1)
df = df.fillna('')
y = df.is_duplicate.values
y = y.astype('float32').reshape(-1, 1)
现在我们可以深入研究这个数据集的深度神经网络模型了。
深度神经网络之前的处理
在将数据输入到任何神经网络之前,我们必须首先对数据进行分词处理,然后将数据转换为序列。为此,我们使用 TensorFlow 中提供的 Keras Tokenizer,并设置最大单词数限制为 200,000,最大序列长度为 40。任何超过 40 个单词的句子都会被截断至前 40 个单词:
Tokenizer = tf.keras.preprocessing.text.Tokenizer pad_sequences = tf.keras.preprocessing.sequence.pad_sequences
tk = Tokenizer(num_words=200000) max_len = 40
在设置了Tokenizer(tk)后,我们将其应用于拼接的第一和第二个问题列表,从而学习学习语料库中所有可能的词汇:
tk.fit_on_texts(list(df.question1) + list(df.question2))
x1 = tk.texts_to_sequences(df.question1)
x1 = pad_sequences(x1, maxlen=max_len)
x2 = tk.texts_to_sequences(df.question2)
x2 = pad_sequences(x2, maxlen=max_len)
word_index = tk.word_index
为了跟踪分词器的工作,word_index是一个字典,包含所有已分词的单词及其分配的索引。
使用 GloVe 嵌入时,我们必须将其加载到内存中,如之前讨论如何获取 Word2vec 嵌入时所见。
可以通过以下命令从 Shell 中轻松恢复 GloVe 嵌入:
wget http://nlp.stanford.edu/data/glove.840B.300d.zip
GloVe 嵌入向量与 Word2vec 相似,都是基于共现关系将单词编码到一个复杂的多维空间中。然而,正如论文clic.cimec.unitn.it/marco/publications/acl2014/baroni-etal-countpredict-acl2014.pdf中所解释的那样——BARONI, Marco; DINU, Georgiana; KRUSZEWSKI, Germán. 不要计数,预测!上下文计数与上下文预测语义向量的系统比较。载于:第 52 届计算语言学协会年会论文集(第 1 卷:长篇论文)
。2014 年,第 238-247 页。
GloVe 并不像 Word2vec 那样来源于一个神经网络优化过程,该过程旨在从上下文预测单词。相反,GloVe 是从一个共现计数矩阵生成的(在这个矩阵中,我们统计一行中的单词与列中的单词共同出现的次数),该矩阵经过了降维处理(就像我们之前准备数据时提到的奇异值分解 SVD)。
为什么我们现在使用 GloVe 而不是 Word2vec?实际上,两者的主要区别归结为一个经验事实:GloVe 嵌入向量在某些问题上效果更好,而 Word2vec 嵌入向量在其他问题上表现更佳。经过实验后,我们发现 GloVe 嵌入向量在深度学习算法中表现更好。你可以通过斯坦福大学的官方网站查看更多关于 GloVe 及其应用的信息:nlp.stanford.edu/projects/glove/
在获取了 GloVe 嵌入向量后,我们可以继续通过填充embedding_matrix数组的行,将从 GloVe 文件中提取的嵌入向量(每个包含 300 个元素)填入该数组,来创建一个embedding_matrix。
以下代码读取 GloVe 嵌入文件并将其存储到我们的嵌入矩阵中,最终将包含数据集中所有分词单词及其相应的向量:
embedding_matrix = np.zeros((len(word_index) + 1, 300), dtype='float32')
glove_zip = zipfile.ZipFile('data/glove.840B.300d.zip')
glove_file = glove_zip.filelist[0]
f_in = glove_zip.open(glove_file)
for line in tqdm(f_in):
values = line.split(b' ')
word = values[0].decode()
if word not in word_index:
continue
i = word_index[word]
coefs = np.asarray(values[1:], dtype='float32')
embedding_matrix[i, :] = coefs
f_in.close()
glove_zip.close()
从一个空的embedding_matrix开始,每个行向量会被放置在矩阵中对应的行号位置,这个位置应该代表其相应的单词。单词与行号之间的这种对应关系是由分词器完成的编码过程之前定义的,现在可以在word_index字典中查阅。
在embedding_matrix加载完嵌入向量后,接下来就可以开始构建深度学习模型了。
深度神经网络构建模块
在本节中,我们将介绍一些关键功能,这些功能将使我们的深度学习项目得以运行。从批量数据输入(向深度神经网络提供学习数据块)开始,我们将为一个复杂的 LSTM 架构准备基础构件。
LSTM 架构在第七章中以一种实践性和详细的方式介绍,使用 LSTM 进行股价预测,位于长短期记忆 - LSTM 101部分。
我们开始处理的第一个函数是prepare_batches函数。该函数接受问题序列,并根据步长值(即批量大小),返回一个列表的列表,其中内部的列表是需要学习的序列批次:
def prepare_batches(seq, step):
n = len(seq)
res = []
for i in range(0, n, step):
res.append(seq[i:i+step])
return res
dense 函数将根据提供的大小创建一个密集层的神经元,并用随机正态分布的数字激活和初始化,这些数字的均值为零,标准差为 2 除以输入特征的数量的平方根。
合理的初始化有助于将输入的导数通过反向传播深入网络。事实上:
-
如果你将网络中的权重初始化得太小,那么导数在通过每一层时会逐渐缩小,直到它变得太微弱,无法触发激活函数。
-
如果网络中的权重初始化过大,那么当它穿越每一层时,导数会简单地增长(即所谓的爆炸梯度问题),导致网络无法收敛到一个合适的解,并且由于处理过大的数字,它会崩溃。
初始化过程确保权重适中,通过设定一个合理的起点,使导数可以通过许多层进行传播。深度学习网络有许多初始化方法,例如 Glorot 和 Bengio 提出的 Xavier(Xavier 是 Glorot 的名字),以及由 He、Rang、Zhen 和 Sun 提出的、基于 Glorot 和 Bengio 方法的 He 初始化方法。
权重初始化是构建神经网络架构的一个技术性方面,但它非常重要。如果你想了解更多,可以从阅读这篇文章开始,它还深入探讨了这个话题的更多数学解释:deepdish.io/2015/02/24/network-initialization/
在这个项目中,我们选择了 He 初始化方法,因为它对于整流单元(ReLU)非常有效。整流单元,或者称为 ReLU,是深度学习的核心,因为它们允许信号传播并避免爆炸或消失的梯度问题。然而,从实际角度看,通过 ReLU 激活的神经元大多数时间实际上只是输出零值。保持足够大的方差,以确保输入和输出梯度在层间传递时具有恒定的方差,确实有助于这种激活方式的最佳效果,正如这篇论文中所解释的:HE, Kaiming, et al. 深入探讨整流器:超越人类水平的 imagenet 分类性能。在:IEEE 国际计算机视觉会议论文集。
2015 年,p. 1026-1034,详细内容可以在arxiv.org/abs/1502.01852找到并阅读:
def dense(X, size, activation=None):
he_std = np.sqrt(2 / int(X.shape[1]))
out = tf.layers.dense(X, units=size,
activation=activation,
kernel_initializer=\
tf.random_normal_initializer(stddev=he_std))
return out
接下来,我们将处理另一种层,时间分布式密集层。
这种层通常用于递归神经网络(RNN),以保持输入和输出之间的“一对一”关系。一个 RNN(具有一定数量的单元提供通道输出),由标准的密集层馈送,接收的矩阵维度是行(示例)× 列(序列),它输出一个矩阵,矩阵的维度是行数 × 通道数(单元数)。如果通过时间分布式密集层馈送它,输出的维度将是行×列×通道的形状。实际上,密集神经网络会应用于时间戳(每列)。
时间分布式密集层通常用于当你有一个输入序列并且你想为每个输入标记时,考虑到到达的序列。这是标注任务的常见场景,如多标签分类或词性标注。在我们的项目中,我们将在 GloVe 嵌入之后使用它,以处理每个 GloVe 向量在问题序列中从一个词到另一个词的变化。
举个例子,假设你有一个包含两个案例(几个问题示例)的序列,每个案例有三个序列(一些词),每个序列由四个元素(它们的嵌入向量)组成。如果我们有这样一个数据集通过具有五个隐藏单元的时间分布式密集层,那么我们将得到一个形状为(2, 3, 5)的张量。实际上,经过时间分布式层,每个示例保留了序列,但嵌入向量被五个隐藏单元的结果所替代。将它们通过 1 轴上的归约操作,我们将得到一个形状为(2, 5)的张量,也就是每个示例的结果向量。
如果你想复制之前的示例:
print("Tensor's shape:", X.shape)
tensor = tf.convert_to_tensor(X, dtype=tf.float32)
dense_size = 5 i = time_distributed_dense(tensor, dense_size)
print("Shape of time distributed output:", i)
j = tf.reduce_sum(i, axis=1)
print("Shape of reduced output:", j)
时间分布式密集层的概念可能比其他层更难理解,网上对此有很多讨论。你也可以阅读这个 Keras 问题讨论线程,以获取更多关于这个主题的见解:github.com/keras-team/keras/issues/1029
def time_distributed_dense(X, dense_size):
shape = X.shape.as_list()
assert len(shape) == 3
_, w, d = shape
X_reshaped = tf.reshape(X, [-1, d])
H = dense(X_reshaped, dense_size,
tf.nn.relu)
return tf.reshape(H, [-1, w, dense_size])
conv1d 和 maxpool1d_global 函数最终是 TensorFlow 函数 tf.layers.conv1d(www.tensorflow.org/api_docs/python/tf/layers/conv1d)和 tf.reduce_max(www.tensorflow.org/api_docs/python/tf/reduce_max)的封装,前者是卷积层,后者计算输入张量维度上的元素的最大值。在自然语言处理领域,这种池化方式(称为全局最大池化)比计算机视觉深度学习应用中常见的标准最大池化更为常见。正如跨验证中的一个问答所解释的那样(stats.stackexchange.com/a/257325/49130),全局最大池化只是取输入向量的最大值,而标准最大池化则根据给定的池大小,从输入向量的不同池中返回最大值构成的新向量。
def conv1d(inputs, num_filters, filter_size, padding='same'):
he_std = np.sqrt(2 / (filter_size * num_filters))
out = tf.layers.conv1d(
inputs=inputs, filters=num_filters, padding=padding,
kernel_size=filter_size,
activation=tf.nn.relu,
kernel_initializer=tf.random_normal_initializer(
stddev=he_std))
return out
def maxpool1d_global(X):
out = tf.reduce_max(X, axis=1)
return out
我们的核心 lstm 函数在每次运行时都由一个不同的范围初始化,使用的是由 He 初始化方法(如前所示)生成的随机整数,并且它是 TensorFlow tf.contrib.rnn.BasicLSTMCell 的封装,用于基本 LSTM 循环网络单元层(www.tensorflow.org/api_docs/python/tf/contrib/rnn/BasicLSTMCell),以及 tf.contrib.rnn.static_rnn 用于创建由单元层指定的循环神经网络(www.tensorflow.org/versions/r1.1/api_docs/python/tf/contrib/rnn/static_rnn)。
基本 LSTM 循环网络单元的实现基于论文 ZAREMBA, Wojciech; SUTSKEVER, Ilya; VINYALS, Oriol. Recurrent neural network regularization. arXiv 预印本 arXiv
:1409.2329,2014,链接可见于 arxiv.org/abs/1409.2329。
def lstm(X, size_hidden, size_out):
with tf.variable_scope('lstm_%d'
% np.random.randint(0, 100)):
he_std = np.sqrt(2 / (size_hidden * size_out))
W = tf.Variable(tf.random_normal([size_hidden, size_out],
stddev=he_std))
b = tf.Variable(tf.zeros([size_out]))
size_time = int(X.shape[1])
X = tf.unstack(X, size_time, axis=1)
lstm_cell = tf.contrib.rnn.BasicLSTMCell(size_hidden,
forget_bias=1.0)
outputs, states = tf.contrib.rnn.static_rnn(lstm_cell, X,
dtype='float32')
out = tf.matmul(outputs[-1], W) + b
return out
在我们项目的这个阶段,我们已经收集了所有必要的构建模块,用于定义将学习区分重复问题的神经网络架构。
设计学习架构
我们通过固定一些参数来定义架构,例如 GloVe 嵌入考虑的特征数量、过滤器的数量和长度、最大池化的长度以及学习率:
max_features = 200000
filter_length = 5
nb_filter = 64
pool_length = 4
learning_rate = 0.001
抓住少量或大量不同短语之间不同语义含义的能力,以便识别可能的重复问题,确实是一个困难的任务,需要复杂的架构。为此,在经过多次实验后,我们创建了一个由 LSTM、时间分布的密集层和 1D-CNN 组成的更深模型。这样的模型有六个头部,通过连接合并为一个。连接后,架构通过五个密集层和一个带有 sigmoid 激活的输出层完成。
完整的模型如下面的图所示:

第一个头部由一个由 GloVe 嵌入初始化的嵌入层组成,后面跟着一个时间分布的密集层。第二个头部由 1D 卷积层组成,作用于由 GloVe 模型初始化的嵌入,第三个头部则是一个基于从零开始学习的嵌入的 LSTM 模型。其余三个头部遵循相同的模式,处理问题对中的另一个问题。
我们开始定义六个模型并将它们连接起来。最终,这些模型通过连接合并,即将六个模型的向量水平堆叠在一起。
即使以下代码块相当长,跟随它也很简单。一切从三个输入占位符place_q1、place_q2和place_y开始,分别将第一个问题、第二个问题和目标响应输入六个模型中。问题使用 GloVe(q1_glove_lookup和q2_glove_lookup)和随机均匀嵌入进行嵌入。两种嵌入都有 300 个维度。
前两个模型,model_1和model_2,获取 GloVe 嵌入,并应用时间分布的密集层。
以下两个模型,model_3和model_4,获取 GloVe 嵌入并通过一系列卷积、丢弃和最大池化进行处理。最终输出向量会进行批量归一化,以保持生成批次之间的方差稳定。
如果你想了解批量归一化的细节,Abhishek Shivkumar 在 Quora 上的回答清楚地提供了你需要了解的关于批量归一化是什么、它的作用以及它在神经网络架构中为何有效的所有关键信息:www.quora.com/In-layman%E2%80%99s-terms-what-is-batch-normalisation-what-does-it-do-and-why-does-it-work-so-well/answer/Abhishek-Shivkumar
最后,model_5和model_6获取均匀随机嵌入,并用 LSTM 进行处理。所有六个模型的结果将合并在一起并进行批量归一化:
graph = tf.Graph()
graph.seed = 1
with graph.as_default():
place_q1 = tf.placeholder(tf.int32, shape=(None, max_len))
place_q2 = tf.placeholder(tf.int32, shape=(None, max_len))
place_y = tf.placeholder(tf.float32, shape=(None, 1))
place_training = tf.placeholder(tf.bool, shape=())
glove = tf.Variable(embedding_matrix, trainable=False)
q1_glove_lookup = tf.nn.embedding_lookup(glove, place_q1)
q2_glove_lookup = tf.nn.embedding_lookup(glove, place_q2)
emb_size = len(word_index) + 1
emb_dim = 300
emb_std = np.sqrt(2 / emb_dim)
emb = tf.Variable(tf.random_uniform([emb_size, emb_dim],
-emb_std, emb_std))
q1_emb_lookup = tf.nn.embedding_lookup(emb, place_q1)
q2_emb_lookup = tf.nn.embedding_lookup(emb, place_q2)
model1 = q1_glove_lookup
model1 = time_distributed_dense(model1, 300)
model1 = tf.reduce_sum(model1, axis=1)
model2 = q2_glove_lookup
model2 = time_distributed_dense(model2, 300)
model2 = tf.reduce_sum(model2, axis=1)
model3 = q1_glove_lookup
model3 = conv1d(model3, nb_filter, filter_length,
padding='valid')
model3 = tf.layers.dropout(model3, rate=0.2, training=place_training)
model3 = conv1d(model3, nb_filter, filter_length, padding='valid')
model3 = maxpool1d_global(model3)
model3 = tf.layers.dropout(model3, rate=0.2, training=place_training)
model3 = dense(model3, 300)
model3 = tf.layers.dropout(model3, rate=0.2, training=place_training)
model3 = tf.layers.batch_normalization(model3, training=place_training)
model4 = q2_glove_lookup
model4 = conv1d(model4, nb_filter, filter_length, padding='valid')
model4 = tf.layers.dropout(model4, rate=0.2, training=place_training)
model4 = conv1d(model4, nb_filter, filter_length, padding='valid')
model4 = maxpool1d_global(model4)
model4 = tf.layers.dropout(model4, rate=0.2, training=place_training)
model4 = dense(model4, 300)
model4 = tf.layers.dropout(model4, rate=0.2, training=place_training)
model4 = tf.layers.batch_normalization(model4, training=place_training)
model5 = q1_emb_lookup
model5 = tf.layers.dropout(model5, rate=0.2, training=place_training)
model5 = lstm(model5, size_hidden=300, size_out=300)
model6 = q2_emb_lookup
model6 = tf.layers.dropout(model6, rate=0.2, training=place_training)
model6 = lstm(model6, size_hidden=300, size_out=300)
merged = tf.concat([model1, model2, model3, model4, model5, model6], axis=1)
merged = tf.layers.batch_normalization(merged, training=place_training)
然后,我们通过添加五个带丢弃和批量归一化的密集层来完成架构。接着,是一个带有 sigmoid 激活的输出层。该模型使用基于对数损失的AdamOptimizer进行优化:
for i in range(5):
merged = dense(merged, 300, activation=tf.nn.relu)
merged = tf.layers.dropout(merged, rate=0.2, training=place_training)
merged = tf.layers.batch_normalization(merged, training=place_training)
merged = dense(merged, 1, activation=tf.nn.sigmoid)
loss = tf.losses.log_loss(place_y, merged)
prediction = tf.round(merged)
accuracy = tf.reduce_mean(tf.cast(tf.equal(place_y, prediction), 'float32'))
opt = tf.train.AdamOptimizer(learning_rate=learning_rate)
# for batchnorm
extra_update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
with tf.control_dependencies(extra_update_ops):
step = opt.minimize(loss)
init = tf.global_variables_initializer()
session = tf.Session(config=None, graph=graph)
session.run(init)
定义架构后,我们初始化会话并准备开始学习。作为一个好的实践,我们将可用数据分为训练部分(9/10)和测试部分(1/10)。固定随机种子可以保证结果的可复制性:
np.random.seed(1)
n_all, _ = y.shape
idx = np.arange(n_all)
np.random.shuffle(idx)
n_split = n_all // 10
idx_val = idx[:n_split]
idx_train = idx[n_split:]
x1_train = x1[idx_train]
x2_train = x2[idx_train]
y_train = y[idx_train]
x1_val = x1[idx_val]
x2_val = x2[idx_val]
y_val = y[idx_val]
如果你运行以下代码片段,训练将开始,你会注意到随着纪元数的增加,模型的准确率也在提高。然而,模型的训练需要花费大量时间,具体取决于你决定遍历的批次数。在 NVIDIA Titan X 上,模型每个纪元的训练时间超过 300 秒。作为精度与训练时间之间的良好平衡,我们选择运行 10 个纪元:
val_idx = np.arange(y_val.shape[0])
val_batches = prepare_batches(val_idx, 5000)
no_epochs = 10
# see https://github.com/tqdm/tqdm/issues/481
tqdm.monitor_interval = 0
for i in range(no_epochs):
np.random.seed(i)
train_idx_shuffle = np.arange(y_train.shape[0])
np.random.shuffle(train_idx_shuffle)
batches = prepare_batches(train_idx_shuffle, 384)
progress = tqdm(total=len(batches))
for idx in batches:
feed_dict = {
place_q1: x1_train[idx],
place_q2: x2_train[idx],
place_y: y_train[idx],
place_training: True,
}
_, acc, l = session.run([step, accuracy, loss], feed_dict)
progress.update(1)
progress.set_description('%.3f / %.3f' % (acc, l))
y_pred = np.zeros_like(y_val)
for idx in val_batches:
feed_dict = {
place_q1: x1_val[idx],
place_q2: x2_val[idx],
place_y: y_val[idx],
place_training: False,
}
y_pred[idx, :] = session.run(prediction, feed_dict)
print('batch %02d, accuracy: %0.3f' % (i,
np.mean(y_val == y_pred)))
训练了 10 个纪元后,该模型的准确率为 82.5%。这比我们之前的基准要高得多。当然,通过使用更好的预处理和分词方法,模型的性能还可以进一步提高。更多的纪元(最多 200 个)也可能有助于进一步提升准确性。词干提取和词形还原也肯定能帮助模型接近 Quora 博客报告的 88%的先进准确率。
完成训练后,我们可以使用内存中的会话来测试一些问题评估。我们尝试用关于 Quora 上重复问题的两个问题进行测试,但该过程适用于任何你希望测试算法的问题对。
和许多机器学习算法一样,该算法依赖于它所学习到的分布。与训练数据完全不同的问题可能会让算法难以猜测。
def convert_text(txt, tokenizer, padder):
x = tokenizer.texts_to_sequences(txt)
x = padder(x, maxlen=max_len)
return x
def evaluate_questions(a, b, tokenizer, padder, pred):
feed_dict = {
place_q1: convert_text([a], tk, pad_sequences),
place_q2: convert_text([b], tk, pad_sequences),
place_y: np.zeros((1,1)),
place_training: False,
}
return session.run(pred, feed_dict)
isduplicated = lambda a, b: evaluate_questions(a, b, tk, pad_sequences, prediction)
a = "Why are there so many duplicated questions on Quora?"
b = "Why do people ask similar questions on Quora multiple times?"
print("Answer: %0.2f" % isduplicated(a, b))
运行代码后,答案应该揭示出这些问题是重复的(答案:1.0)。
总结
在本章中,我们借助 TensorFlow 构建了一个非常深的神经网络,以便从 Quora 数据集中检测重复问题。这个项目让我们讨论、修订并实践了许多在其他章节中曾见过的不同主题:TF-IDF、SVD、经典机器学习算法、Word2vec 和 GloVe 嵌入,以及 LSTM 模型。
最终,我们得到了一个模型,其准确率约为 82.5%,这一数字高于传统的机器学习方法,也接近 Quora 博客报告的其他先进深度学习解决方案。
还应注意,本章讨论的模型和方法可以轻松应用于任何语义匹配问题。
第九章:构建一个 TensorFlow 推荐系统
推荐系统是一种基于用户与软件的过去交互,向用户提供个性化建议的算法。最著名的例子就是亚马逊和其他电子商务网站上那种“购买了 X 的用户也购买了 Y”的推荐。
在过去几年中,推荐系统变得越来越重要:在线企业已明确认识到,网站上提供的推荐越好,赚的钱就越多。这也是为什么今天几乎每个网站都有一个个性化推荐的模块。
在本章中,我们将看到如何使用 TensorFlow 来构建自己的推荐系统。
我们将特别涵盖以下主题:
-
推荐系统基础
-
推荐系统中的矩阵分解
-
贝叶斯个性化排序
-
基于循环神经网络的高级推荐系统
在本章结束时,你将知道如何准备数据以训练推荐系统,如何使用 TensorFlow 构建自己的模型,并如何对这些模型的质量进行简单评估。
推荐系统
推荐系统的任务是根据特定用户的偏好,从所有可能的项目中排名,并生成个性化的排名列表,通常被称为推荐。
例如,一个购物网站可能会有一个推荐部分,用户可以看到他们可能感兴趣的商品,并可能决定购买。销售演唱会门票的网站可能会推荐有趣的演出,在线音乐播放器可能会推荐用户可能喜欢的歌曲。或者,像Coursera.org这样的在线课程网站,可能会推荐与用户已经完成的课程类似的课程:

网站上的课程推荐
推荐通常基于历史数据:用户的过去交易历史、访问记录和点击行为。因此,推荐系统是一个利用历史数据,通过机器学习提取用户行为模式,并根据这些模式提供最佳推荐的系统。
公司非常关注如何使推荐尽可能好:这通常通过改善用户体验来增加用户参与度,从而带动收入增长。当我们推荐一个用户本来不会注意到的商品,而用户最终购买了它时,不仅使用户感到满意,而且我们还卖出了本来不会卖出去的商品。
本章项目将实现多个推荐系统算法,使用 TensorFlow。我们将从经典的经过时间考验的算法开始,然后深入探讨并尝试基于 RNN 和 LSTM 的更复杂模型。对于本章中的每个模型,我们将首先简要介绍,然后在 TensorFlow 中实现该模型。
为了说明这些概念,我们使用 UCI 机器学习库中的在线零售数据集。这个数据集可以从 archive.ics.uci.edu/ml/datasets/online+retail 下载。
数据集本身是一个包含以下特征的 Excel 文件:
-
InvoiceNo:发票号,用于唯一标识每一笔交易 -
StockCode:购买商品的代码 -
Description:产品名称 -
Quantity:交易中购买商品的数量 -
UnitPrice:每件商品的价格 -
CustomerID:客户的 ID -
Country:客户所在国家的名称
它包含 25,900 个交易,每个交易大约有 20 件商品,总计约 540,000 件商品。记录的交易来自 4,300 名用户,交易时间从 2010 年 12 月开始,到 2011 年 12 月结束。
要下载数据集,我们可以使用浏览器保存文件,或者使用wget:
wget http://archive.ics.uci.edu/ml/machine-learning-databases/00352/Online%20Retail.xlsx
对于这个项目,我们将使用以下 Python 包:
-
pandas用于读取数据 -
numpy和scipy用于数值数据操作 -
tensorflow用于创建模型 -
implicit用于基准解决方案 -
[可选]
tqdm用于监控进度 -
[可选]
numba用于加速计算
如果你使用 Anaconda,那么你应该已经安装了numba,但如果没有,简单地运行pip install numba就可以安装这个包。要安装implicit,我们再次使用pip:
pip install implicit
一旦数据集下载完成并且包安装好,我们就可以开始了。在接下来的部分,我们将回顾矩阵分解技术,然后准备数据集,最后在 TensorFlow 中实现其中的一些。
推荐系统中的矩阵分解
在本节中,我们将介绍传统的推荐系统技术。正如我们将看到的,这些技术在 TensorFlow 中实现非常简单,生成的代码非常灵活,容易进行修改和改进。
对于这一部分,我们将使用在线零售数据集。我们首先定义我们想要解决的问题,并建立一些基准。然后我们实现经典的矩阵分解算法,以及基于贝叶斯个性化排序的修改版。
数据集准备和基准
现在我们准备开始构建推荐系统了。
首先,声明导入:
import tensorflow as tf
import pandas as pd
import numpy as np
import scipy.sparse as sp
from tqdm import tqdm
让我们读取数据集:
df = pd.read_excel('Online Retail.xlsx')
读取 xlsx 文件可能需要一些时间。为了节省时间,在下次读取该文件时,我们可以将加载的副本保存到 pickle 文件中:
import pickle
with open('df_retail.bin', 'wb') as f_out:
pickle.dump(df, f_out)
这个文件读取起来更快,因此在加载时我们应该使用 pickle 版本:
with open('df_retail.bin', 'rb') as f_in:
df = pickle.load(f_in)
一旦数据加载完成,我们可以查看数据。我们可以通过调用 head 函数来做到这一点:
df.head()
然后我们看到以下表格:

如果我们仔细查看数据,可以发现以下问题:
-
列名是大写的,这有点不常见,所以我们可以将其转换为小写。
-
一些交易是退货:它们对我们不重要,因此我们应该将其过滤掉。
-
最后,一些交易属于未知用户。我们可以为这些用户分配一个通用 ID,例如
-1。同时,未知用户被编码为NaN,这就是为什么CustomerID列被编码为浮动类型——因此我们需要将其转换为整数。
这些问题可以通过以下代码修复:
df.columns = df.columns.str.lower()
df = df[~df.invoiceno.astype('str').str.startswith('C')].reset_index(drop=True)
df.customerid = df.customerid.fillna(-1).astype('int32')
接下来,我们应该用整数编码所有商品 ID(stockcode)。一种方法是为每个代码构建一个与唯一索引号的映射:
stockcode_values = df.stockcode.astype('str')
stockcodes = sorted(set(stockcode_values))
stockcodes = {c: i for (i, c) in enumerate(stockcodes)}
df.stockcode = stockcode_values.map(stockcodes).astype('int32')
现在我们已经对商品进行编码,可以将数据集划分为训练集、验证集和测试集。由于我们有电商交易数据,最合理的划分方式是按时间划分。所以我们将使用:
-
训练集:2011 年 10 月 9 日之前(大约 10 个月的数据,大约 378,500 行)
-
验证集:2011 年 10 月 9 日到 2011 年 11 月 9 日之间(一个月的数据,大约 64,500 行)
-
测试集:2011 年 11 月 9 日之后(同样是一个月的数据,大约 89,000 行)
为此,我们只需过滤数据框:
df_train = df[df.invoicedate < '2011-10-09']
df_val = df[(df.invoicedate >= '2011-10-09') &
(df.invoicedate <= '2011-11-09') ]
df_test = df[df.invoicedate >= '2011-11-09']
在本节中,我们将考虑以下(非常简化的)推荐场景:
-
用户访问网站。
-
我们提供五个推荐。
-
用户查看列表,可能从中购买一些商品,然后像往常一样继续购物。
所以我们需要为第二步构建一个模型。为此,我们使用训练数据,然后利用验证集模拟第二步和第三步。为了评估我们的推荐是否有效,我们计算用户实际购买的推荐商品数量。
我们的评估指标是成功推荐的数量(即用户实际购买的商品)与我们做出的总推荐数量的比值。这叫做精准度——这是评估机器学习模型性能的常用指标。
对于这个项目,我们使用精准度。 当然,这是一个相当简化的评估性能的方法,实际上有不同的评估方式。你可能想使用的其他指标包括MAP(平均精准度),NDCG(标准化折扣累积增益)等。不过为了简化,我们在本章中并未使用它们。
在我们开始使用机器学习算法处理此任务之前,先建立一个基本的基准。比如,我们可以计算每个商品的购买次数,然后选出购买次数最多的五个商品,推荐给所有用户。
使用 pandas 很容易做到:
top = df_train.stockcode.value_counts().head(5).index.values
这将给我们一个整数数组——stockcode代码:
array([3527, 3506, 1347, 2730, 180])
现在我们使用这个数组推荐给所有用户。所以我们将top数组重复与验证数据集中交易的数量相同的次数,然后使用这些作为推荐,并计算精准度指标来评估质量。
对于重复项,我们使用 numpy 的tile函数:
num_groups = len(df_val.invoiceno.drop_duplicates())
baseline = np.tile(top, num_groups).reshape(-1, 5)
tile函数接受一个数组并将其重复num_group次。经过重塑后,它会给我们以下数组:
array([[3527, 3506, 1347, 2730, 180],
[3527, 3506, 1347, 2730, 180],
[3527, 3506, 1347, 2730, 180],
...,
[3527, 3506, 1347, 2730, 180],
[3527, 3506, 1347, 2730, 180],
[3527, 3506, 1347, 2730, 180]])
现在我们准备好计算这个推荐系统的精度了。
然而,这里有一个复杂的问题:由于项目存储的方式,使得计算每个组内正确分类元素的数量变得困难。使用 pandas 的groupby是解决这个问题的一种方法:
-
按照
invoiceno(即我们的交易 ID)分组 -
为每个交易做出推荐
-
记录每个组的正确预测数量
-
计算总体精度
然而,这种方式通常非常慢且低效。对于这个特定项目可能没问题,但对于稍微大的数据集,它就成为了一个问题。
它慢的原因在于 pandas 中groupby的实现方式:它在内部执行排序,而我们并不需要这样做。然而,我们可以通过利用数据存储的方式来提高速度:我们知道数据框中的元素总是有序的。也就是说,如果一个交易从某个行号i开始,那么它将在行号i + k结束,其中k是该交易中的项目数。换句话说,i和i + k之间的所有行都属于同一个invoiceid。
所以我们需要知道每个交易的开始和结束位置。为此,我们保持一个长度为n + 1的特殊数组,其中n是数据集中组(交易)的数量。
我们称这个数组为indptr。对于每个交易t:
-
indptr[t]返回交易开始的行号 -
indptr[t + 1]返回交易结束的行号
这种表示不同长度组的方式灵感来自于 CSR 算法——压缩行存储(有时称为压缩稀疏行)。它用于在内存中表示稀疏矩阵。你可以在 Netlib 文档中阅读更多内容——netlib.org/linalg/html_templates/node91.html。你也许在 scipy 中也会认出这个名字——它是scipy.sparse包中表示矩阵的几种方式之一:docs.scipy.org/doc/scipy-0.14.0/reference/generated/scipy.sparse.csr_matrix.html。
在 Python 中创建这样的数组并不困难:我们只需要查看当前交易的结束位置和下一个交易的开始位置。所以,在每一行索引处,我们可以将当前索引与前一个索引进行比较,如果不同,就记录该索引。这可以通过使用 pandas 的shift方法高效完成:
def group_indptr(df):
indptr, = np.where(df.invoiceno != df.invoiceno.shift())
indptr = np.append(indptr, len(df)).astype('int32')
return indptr
这样我们就得到了验证集的指针数组:
val_indptr = group_indptr(df_val)
现在我们可以在precision函数中使用它:
from numba import njit
@njit
def precision(group_indptr, true_items, predicted_items):
tp = 0
n, m = predicted_items.shape
for i in range(n):
group_start = group_indptr[i]
group_end = group_indptr[i + 1]
group_true_items = true_items[group_start:group_end]
for item in group_true_items:
for j in range(m):
if item == predicted_items[i, j]:
tp = tp + 1
continue
return tp / (n * m)
这里的逻辑很简单:对于每一个交易,我们检查我们预测正确的物品数量。所有预测正确的物品总数存储在tp中。最后,我们将tp除以预测的总数,即预测矩阵的大小,也就是在我们的例子中,交易次数乘以五。
注意 numba 的 @njit 装饰器。这个装饰器告诉 numba 代码应该被优化。当我们首次调用这个函数时,numba 会分析代码并使用 即时编译(JIT) 编译器将函数转换为本地代码。当函数被编译后,它的执行速度比原来快了几个数量级——接近于用 C 编写的本地代码。
Numba 的 @jit 和 @njit 装饰器提供了一种非常简便的方式来提高代码的运行速度。通常,只需将 @jit 装饰器应用于函数,就能显著加速代码。如果一个函数计算耗时,numba 是提高性能的一个好方法。
现在我们可以检查这个基准的精度:
val_items = df_val.stockcode.values
precision(val_indptr, val_items, baseline)
执行此代码应得到 0.064。也就是说,在 6.4% 的情况下,我们做出了正确的推荐。这意味着用户仅在 6.4% 的情况下购买了推荐的物品。
现在当我们初步查看数据并建立一个简单的基准后,我们可以继续使用更复杂的技术,如矩阵分解。
矩阵分解
2006 年,DVD 租赁公司 Netflix 举办了著名的 Netflix 竞赛。竞赛的目标是改善他们的推荐系统。为此,公司发布了一个大规模的电影评分数据集。这个竞赛有几个显著特点。首先,奖金池为一百万美元,这是它成名的主要原因之一。其次,由于奖金以及数据集本身,许多研究人员投入了大量时间来解决这个问题,这大大推动了推荐系统领域的技术进步。
正是 Netflix 竞赛展示了基于矩阵分解的推荐系统非常强大,能够扩展到大量的训练样本,而且实现和部署并不难。
Koren 等人(2009)在论文《矩阵分解技术用于推荐系统》中很好地总结了关键发现,我们将在本章中呈现这些内容。
假设我们有电影
的评分
,由用户
评分。我们可以通过以下方式来建模这个评分:
。
我们将评分分解为四个因素:
-
是全局偏差 -
是物品
的偏差(在 Netflix 的情况下——电影) -
是用户的偏差 ![]()
-
是用户向量
和物品向量
之间的内积
最后的因子——用户和物品向量之间的内积——就是为什么这种技术被称为矩阵因式分解的原因。
让我们将所有的用户向量
放入一个矩阵
中作为行。然后我们将得到一个
矩阵,其中
是用户的数量,
是向量的维度。同样地,我们可以将物品向量
放入一个矩阵
中作为行。这个矩阵的大小为
,其中
是物品的数量,
再次是向量的维度。维度
是模型的一个参数,它允许我们控制压缩信息的程度。维度
越小,从原始评分矩阵中保留的信息就越少。
最后,我们将所有已知的评分放入一个矩阵
——这个矩阵的大小为
。然后,这个矩阵可以被因式分解为
。
没有偏差部分时,这正是我们在前面公式中计算
时得到的。
为了使预测的评分
尽可能接近观测到的评分
,我们最小化它们之间的平方误差。也就是说,我们的训练目标是以下内容:

这种对评分矩阵的因式分解有时被称为SVD,因为它的灵感来自经典的奇异值分解方法——它同样优化了平方误差的和。然而,经典的 SVD 往往容易对训练数据进行过拟合,这就是为什么在这里我们在目标函数中加入了正则化项。
在定义了优化问题之后,论文接着讨论了两种解决方法:
-
随机梯度下降 (SGD)
-
交替最小二乘法 (ALS)
在本章的后面,我们将使用 TensorFlow 来实现 SGD 方法,并将其与 implicit 库中的 ALS 方法的结果进行比较。
然而,我们在这个项目中使用的数据集与 Netflix 竞赛数据集有一个非常重要的区别——我们并不知道用户不喜欢什么。我们只能观察到他们喜欢什么。这也是为什么接下来我们将讨论如何处理这种情况的方法。
隐式反馈数据集
在 Netflix 竞赛中,那里使用的数据依赖于用户提供的显式反馈。用户会访问网站并明确告诉他们自己有多喜欢某部电影,评分范围从 1 到 5。
通常来说,让用户做这件事是相当困难的。然而,仅仅通过访问网站并与其互动,用户已经生成了大量有用的信息,这些信息可以用来推断他们的兴趣。所有的点击、页面访问和过去的购买行为都能告诉我们用户的偏好。这种数据被称为隐式反馈——用户并没有明确告诉我们他们喜欢什么,而是通过使用系统间接地传达了这一信息。通过收集这些互动信息,我们得到了隐式反馈数据集。
我们在这个项目中使用的在线零售数据集正是这种数据集。它告诉我们用户之前购买了什么,但并没有告诉我们用户不喜欢什么。我们无法得知用户未购买某个商品,是因为他们不喜欢它,还是因为他们根本不知道这个商品的存在。
幸运的是,经过少许修改,我们仍然可以将矩阵分解技术应用于隐式数据集。与显式评分不同,矩阵中的值取 1 或 0——取决于是否与某个商品发生了互动。此外,还可以表示值为 1 或 0 的正确性置信度,这通常通过统计用户与商品的互动次数来实现。用户与商品互动的次数越多,我们的置信度就越大。
因此,在我们的案例中,用户购买过的所有值在矩阵中都赋值为 1,其他所有值为 0。由此我们可以看到这是一个二元分类问题,并在 TensorFlow 中实现基于 SGD 的模型来学习用户和商品矩阵。
但在此之前,我们将建立另一个比之前更强的基准。我们将使用 implicit 库,该库使用的是 ALS 方法。
隐式反馈数据集的协同过滤(Collaborative Filtering for Implicit Feedback Datasets)由 Hu 等人(2008 年)撰写,提供了隐式反馈数据集 ALS 方法的良好介绍。本章并不专注于 ALS,但如果你想了解如何在诸如 implicit 这样的库中实现 ALS,这篇论文无疑是一个很好的资源。在撰写本文时,该论文可以通过 yifanhu.net/PUB/cf.pdf 访问。
首先,我们需要将数据准备为 implicit 所期望的格式——为此,我们需要构建用户-物品矩阵 X。为了做到这一点,我们需要将用户和物品都转换为 ID,这样我们就可以将每个用户映射到 X 的一行,将每个物品映射到 X 的一列。
我们已经将物品(列 stockcode)转换为整数。现在我们需要对用户 ID(列 customerid)执行相同的操作:
df_train_user = df_train[df_train.customerid != -1].reset_index(drop=True)
customers = sorted(set(df_train_user.customerid))
customers = {c: i for (i, c) in enumerate(customers)}
df_train_user.customerid = df_train_user.customerid.map(customers)
注意,在第一行我们执行了过滤操作,只保留了已知用户——这些用户将用于后续的模型训练。然后我们对验证集中的用户应用相同的程序:
df_val.customerid = df_val.customerid.apply(lambda c: customers.get(c, -1))
接下来,我们使用这些整数代码来构建矩阵 X:
uid = df_train_user.customerid.values.astype('int32')
iid = df_train_user.stockcode.values.astype('int32')
ones = np.ones_like(uid, dtype='uint8')
X_train = sp.csr_matrix((ones, (uid, iid)))
sp.csr_matrix 是 scipy.sparse 包中的一个函数。它接受行和列的索引以及每对索引的对应值,并以压缩行存储格式构建矩阵。
使用稀疏矩阵是减少数据矩阵空间消耗的好方法。在推荐系统中,用户和物品的数量都很多。当我们构建矩阵时,我们会将所有用户未互动的物品填充为零。保留所有这些零是浪费,因此稀疏矩阵提供了一种只存储非零条目的方法。你可以在 scipy.sparse 包的文档中阅读更多内容:docs.scipy.org/doc/scipy/reference/sparse.html。
现在让我们使用 implicit 来对矩阵 X 进行分解,并学习用户和物品向量:
from implicit.als import AlternatingLeastSquares
item_user = X_train.T.tocsr()
als = AlternatingLeastSquares(factors=128, regularization=0.000001)
als.fit(item_user)
使用 ALS 时,我们使用 AlternatingLeastSquares 类。它需要两个参数:
-
factors:这是用户和物品向量的维度,之前我们称之为 k -
regularization:L2 正则化参数,用于避免过拟合
然后我们调用 fit 函数来学习向量。一旦训练完成,这些向量就可以轻松获取:
als_U = als.user_factors
als_I = als.item_factors
在得到 U 和 I 矩阵后,我们可以用它们向用户推荐内容,方法是计算每个矩阵行之间的内积。我们很快会看到如何做到这一点。
矩阵分解方法有一个问题:它们无法处理新用户。为了解决这个问题,我们可以简单地将它与基准方法结合:使用基准方法向新用户和未知用户推荐内容,但对已知用户应用矩阵分解。
所以,首先我们在验证集里选择已知用户的 ID:
uid_val = df_val.drop_duplicates(subset='invoiceno').customerid.values
known_mask = uid_val != -1
uid_val = uid_val[known_mask]
我们将只向这些用户推荐内容。然后,我们复制基准解决方案,并通过 ALS 的值替换已知用户的预测:
imp_baseline = baseline.copy()
pred_all = als_U[uid_val].dot(als_I.T)
top_val = (-pred_all).argsort(axis=1)[:, :5]
imp_baseline[known_mask] = top_val
prevision(val_indptr, val_items, imp_baseline)
在这里,我们获取验证集中每个用户 ID 的向量,并将其与所有物品向量相乘。接下来,对于每个用户,我们根据得分选择排名前五的物品。
这会输出 13.9%。这个基准比我们之前的 6% 强很多。这个基准应该更难超越,但接下来,我们还是尝试去做。
基于 SGD 的矩阵分解
现在我们终于准备好在 TensorFlow 中实现矩阵分解模型了。让我们来实现它,看看我们是否能通过 implicit 来改善基准模型。用 TensorFlow 实现 ALS 并非易事:它更适合基于梯度的方法,如 SGD。这就是为什么我们要采用这种方法,并将 ALS 留给专业的实现。
在这里,我们实现了前面章节中的公式:
。
回想一下,之前的目标函数是如下的:

注意,在这个目标函数中我们仍然使用了平方误差,但对我们来说,情况已经不同,因为我们将其建模为一个二分类问题。对于 TensorFlow 来说,实际上并不重要,优化损失可以很容易地更改。
在我们的模型中,我们将使用对数损失,它比平方误差更适合于二分类问题。
p 和 q 向量分别构成 U 和 I 矩阵。我们需要做的是学习这些 U 和 I 矩阵。我们可以将完整的 U 和 I 矩阵存储为 TensorFlow 的 Variable,然后使用嵌入层来查找适当的 p 和 q 向量。
让我们定义一个辅助函数来声明嵌入层:
def embed(inputs, size, dim, name=None):
std = np.sqrt(2 / dim)
emb = tf.Variable(tf.random_uniform([size, dim], -std, std), name=name)
lookup = tf.nn.embedding_lookup(emb, inputs)
return lookup
这个函数创建一个指定维度的矩阵,用随机值初始化,并最终使用查找层将用户或物品的索引转换为向量。
我们将这个函数作为模型图的一部分来使用:
# parameters of the model
num_users = uid.max() + 1
num_items = iid.max() + 1
num_factors = 128
lambda_user = 0.0000001
lambda_item = 0.0000001
K = 5
lr = 0.005
graph = tf.Graph()
graph.seed = 1
with graph.as_default():
# this is the input to the model
place_user = tf.placeholder(tf.int32, shape=(None, 1))
place_item = tf.placeholder(tf.int32, shape=(None, 1))
place_y = tf.placeholder(tf.float32, shape=(None, 1))
# user features
user_factors = embed(place_user, num_users, num_factors,
"user_factors")
user_bias = embed(place_user, num_users, 1, "user_bias")
user_bias = tf.reshape(user_bias, [-1, 1])
# item features
item_factors = embed(place_item, num_items, num_factors,
"item_factors")
item_bias = embed(place_item, num_items, 1, "item_bias")
item_bias = tf.reshape(item_bias, [-1, 1])
global_bias = tf.Variable(0.0, name='global_bias')
# prediction is dot product followed by a sigmoid
pred = tf.reduce_sum(user_factors * item_factors, axis=2)
pred = tf.sigmoid(global_bias + user_bias + item_bias + pred)
reg = lambda_user * tf.reduce_sum(user_factors * user_factors) + \
lambda_item * tf.reduce_sum(item_factors * item_factors)
# we have a classification model, so minimize logloss
loss = tf.losses.log_loss(place_y, pred)
loss_total = loss + reg
opt = tf.train.AdamOptimizer(learning_rate=lr)
step = opt.minimize(loss_total)
init = tf.global_variables_initializer()
该模型接收三个输入:
-
place_user:用户 ID -
place_item:物品的 ID -
place_y:每个(用户,物品)对的标签
然后我们定义:
-
user_factors:用户矩阵![]()
-
user_bias:每个用户的偏置项![]()
-
item_factors:物品矩阵![]()
-
item_bias:每个物品的偏置项![]()
-
global_bias:全局偏置项![]()
然后,我们将所有的偏置项组合在一起,并计算用户和物品因子之间的点积。这就是我们的预测结果,然后我们将其传递通过 sigmoid 函数以获得概率。
最后,我们将目标函数定义为数据损失和正则化损失的和,并使用 Adam 来最小化该目标。
模型具有以下参数:
-
num_users和num_items:用户(物品)的数量。它们分别指定U和I矩阵中的行数。 -
num_factors:用户和物品的潜在特征数量。它指定了U和I中的列数。 -
lambda_user和lambda_item:正则化参数。 -
lr:优化器的学习率。 -
K:每个正样本要采样的负样本数量(见下一节的解释)。
现在让我们来训练模型。为此,我们需要将输入切分成小批次。我们可以使用一个辅助函数来实现:
def prepare_batches(seq, step):
n = len(seq)
res = []
for i in range(0, n, step):
res.append(seq[i:i+step])
return res
这将把一个数组转换为指定大小的数组列表。
请记住,我们的数据集是基于隐式反馈的,正例的数量——即发生的交互——与负例的数量——即未发生的交互——相比非常少。我们该如何处理它呢?解决方案很简单:我们使用负采样。其背后的思想是仅采样一小部分负例。通常,对于每个正例,我们会采样K个负例,而K是一个可调的参数。这正是我们在这里所做的。
那么让我们来训练这个模型:
session = tf.Session(config=None, graph=graph)
session.run(init)
np.random.seed(0)
for i in range(10):
train_idx_shuffle = np.arange(uid.shape[0])
np.random.shuffle(train_idx_shuffle)
batches = prepare_batches(train_idx_shuffle, 5000)
progress = tqdm(total=len(batches))
for idx in batches:
pos_samples = len(idx)
neg_samples = pos_samples * K
label = np.concatenate([
np.ones(pos_samples, dtype='float32'),
np.zeros(neg_samples, dtype='float32')
]).reshape(-1, 1)
# negative sampling
neg_users = np.random.randint(low=0, high=num_users,
size=neg_samples, dtype='int32')
neg_items = np.random.randint(low=0, high=num_items,
size=neg_samples, dtype='int32')
batch_uid = np.concatenate([uid[idx], neg_users]).reshape(-1, 1)
batch_iid = np.concatenate([iid[idx], neg_items]).reshape(-1, 1)
feed_dict = {
place_user: batch_uid,
place_item: batch_iid,
place_y: label,
}
_, l = session.run([step, loss], feed_dict)
progress.update(1)
progress.set_description('%.3f' % l)
progress.close()
val_precision = calculate_validation_precision(graph, session, uid_val)
print('epoch %02d: precision: %.3f' % (i+1, val_precision))
我们运行模型 10 个周期(epoch),然后在每个周期内,我们随机打乱数据并将其切分为 5000 个正例的批次。接着,对于每个批次,我们生成K * 5000 个负例(在我们这里,K = 5)并将正负例放在同一个数组中。最后,我们运行模型,并在每次更新步骤时,使用tqdm监控训练损失。tqdm 库提供了一种非常好的方式来监控训练进度。
这是我们使用 tqdm jupyter notebook 小部件时生成的输出:

在每个周期结束时,我们计算精度—以监控模型在我们定义的推荐场景中的表现。calculate_validation_precision函数就是用来完成这一工作的。它的实现方式与我们之前在隐式反馈中做的类似:
-
我们首先提取矩阵和偏差。
-
然后将它们组合在一起,得到每个(用户,项目)对的评分。
-
最后,我们对这些对进行排序,保留前五个。
对于这个特定的情况,我们不需要全局偏差以及用户偏差:加入它们不会改变每个用户的项目排序。这个函数可以这样实现:
def get_variable(graph, session, name):
v = graph.get_operation_by_name(name)
v = v.values()[0]
v = v.eval(session=session)
return v
def calculate_validation_precision(graph, session, uid):
U = get_variable(graph, session, 'user_factors')
I = get_variable(graph, session, 'item_factors')
bi = get_variable(graph, session, 'item_bias').reshape(-1)
pred_all = U[uid_val].dot(I.T) + bi
top_val = (-pred_all).argsort(axis=1)[:, :5]
imp_baseline = baseline.copy()
imp_baseline[known_mask] = top_val
return precision(val_indptr, val_items, imp_baseline)
这是我们得到的输出:
epoch 01: precision: 0.064
epoch 02: precision: 0.086
epoch 03: precision: 0.106
epoch 04: precision: 0.127
epoch 05: precision: 0.138
epoch 06: precision: 0.145
epoch 07: precision: 0.150
epoch 08: precision: 0.149
epoch 09: precision: 0.151
epoch 10: precision: 0.152
到第六个周期时,它超过了之前的基线,而到了第十个周期时,达到了 15.2%。
矩阵分解技术通常会为推荐系统提供一个非常强的基线解决方案。但只需做一些小的调整,相同的技术可以产生更好的结果。我们可以不优化二分类的损失,而是使用一种专门为排序问题设计的损失。在下一部分,我们将了解这种损失,并看到如何进行这种调整。
贝叶斯个性化排序
我们使用矩阵分解方法为每个用户制作个性化的项目排序。然而,为了解决这个问题,我们使用了一个二分类优化标准——对数损失。这种损失表现良好,优化它通常会产生很好的排序模型。如果我们能使用一个专门为训练排序函数设计的损失会怎样呢?
当然,可以使用直接优化排名的目标函数。在 Rendle 等人(2012 年)的论文《BPR: 基于隐式反馈的贝叶斯个性化排名》中,作者提出了一种优化准则,称为 BPR-Opt。
之前,我们把每个物品独立来看待,即我们尝试预测某个物品的评分,或者预测物品 i 对用户 u 是否感兴趣的概率。这类排名模型通常被称为“点对点”(point-wise):它们使用传统的监督学习方法,如回归或分类,来学习评分,然后根据该评分对物品进行排名。这正是我们在上一节中所做的。
BPR-Opt 不同。相反,它关注的是物品对。如果我们知道用户 u 已经购买了物品 i,但从未购买过物品 j,那么很可能 u 对 i 的兴趣大于对 j 的兴趣。因此,当我们训练一个模型时,它为 i 产生的评分
应该高于为 j 产生的评分
。换句话说,对于评分模型,我们希望
。
因此,训练该算法时,我们需要三元组(用户,正向物品,负向物品)。对于这样的三元组 (u, i, j),我们定义评分的成对差异为:

其中
和
分别是 (u, i) 和 (u, j) 的评分。
在训练过程中,我们调整模型的参数,使得最终物品 i 的排名高于物品 j。我们通过优化以下目标来实现这一点:

其中
是差异,
是 sigmoid 函数,
是模型的所有参数。
我们可以很简单地修改之前的代码来优化这个损失函数。我们计算 (u, i) 和 (u, j) 的评分方式不变:我们使用偏置和用户与物品向量的内积。然后,我们计算评分之间的差异,并将差异输入到新的目标函数中。
实现中的差异也不大:
-
对于 BPR-Opt,我们没有
place_y,而是会分别为正向物品和负向物品使用place_item_pos和place_item_neg。 -
我们不再需要用户偏置和全局偏置:当我们计算差异时,这些偏置会相互抵消。而且,它们对排名来说并不重要——我们在先前计算验证数据的预测时就注意到了这一点。
另一个实现上的小差异是,由于我们现在有两个输入项,并且这些项必须共享嵌入,我们需要稍微不同地定义和创建嵌入。为此,我们修改了embed辅助函数,并且将变量创建和查找层分开。
def init_variable(size, dim, name=None):
std = np.sqrt(2 / dim)
return tf.Variable(tf.random_uniform([size, dim], -std, std), name=name)
def embed(inputs, size, dim, name=None):
emb = init_variable(size, dim, name)
return tf.nn.embedding_lookup(emb, inputs)
最后,让我们看看代码中的实现:
num_factors = 128
lambda_user = 0.0000001
lambda_item = 0.0000001
lambda_bias = 0.0000001
lr = 0.0005
graph = tf.Graph()
graph.seed = 1
with graph.as_default():
place_user = tf.placeholder(tf.int32, shape=(None, 1))
place_item_pos = tf.placeholder(tf.int32, shape=(None, 1))
place_item_neg = tf.placeholder(tf.int32, shape=(None, 1))
# no place_y
user_factors = embed(place_user, num_users, num_factors,
"user_factors")
# no user bias anymore as well as no global bias
item_factors = init_variable(num_items, num_factors,
"item_factors")
item_factors_pos = tf.nn.embedding_lookup(item_factors, place_item_pos)
item_factors_neg = tf.nn.embedding_lookup(item_factors, place_item_neg)
item_bias = init_variable(num_items, 1, "item_bias")
item_bias_pos = tf.nn.embedding_lookup(item_bias, place_item_pos)
item_bias_pos = tf.reshape(item_bias_pos, [-1, 1])
item_bias_neg = tf.nn.embedding_lookup(item_bias, place_item_neg)
item_bias_neg = tf.reshape(item_bias_neg, [-1, 1])
# predictions for each item are same as previously
# but no user bias and global bias
pred_pos = item_bias_pos + \
tf.reduce_sum(user_factors * item_factors_pos, axis=2)
pred_neg = item_bias_neg + \
tf.reduce_sum(user_factors * item_factors_neg, axis=2)
pred_diff = pred_pos—pred_neg
loss_bpr =—tf.reduce_mean(tf.log(tf.sigmoid(pred_diff)))
loss_reg = lambda_user * tf.reduce_sum(user_factors * user_factors) +\
lambda_item * tf.reduce_sum(item_factors_pos * item_factors_pos)+\
lambda_item * tf.reduce_sum(item_factors_neg * item_factors_neg)+\
lambda_bias * tf.reduce_sum(item_bias_pos) + \
lambda_bias * tf.reduce_sum(item_bias_neg)
loss_total = loss_bpr + loss_reg
opt = tf.train.AdamOptimizer(learning_rate=lr)
step = opt.minimize(loss_total)
init = tf.global_variables_initializer()
训练这个模型的方法也略有不同。BPR-Opt 论文的作者建议使用自助采样(bootstrap sampling),而不是通常的全数据遍历,也就是说,在每个训练步骤中,我们从训练数据集中均匀地采样三元组(用户、正向项、负向项)。
幸运的是,这比全数据遍历实现起来要简单得多:
session = tf.Session(config=None, graph=graph)
session.run(init)
size_total = uid.shape[0]
size_sample = 15000
np.random.seed(0)
for i in range(75):
for k in range(30):
idx = np.random.randint(low=0, high=size_total, size=size_sample)
batch_uid = uid[idx].reshape(-1, 1)
batch_iid_pos = iid[idx].reshape(-1, 1)
batch_iid_neg = np.random.randint(
low=0, high=num_items, size=(size_sample, 1), dtype='int32')
feed_dict = {
place_user: batch_uid,
place_item_pos: batch_iid_pos,
place_item_neg: batch_iid_neg,
}
_, l = session.run([step, loss_bpr], feed_dict)
val_precision = calculate_validation_precision(graph, session, uid_val)
print('epoch %02d: precision: %.3f' % (i+1, val_precision))
大约经过 70 次迭代后,它的精度达到了约 15.4%。尽管与之前的模型(精度为 15.2%)差别不大,但它为直接优化排名提供了很多可能性。更重要的是,我们展示了调整现有方法的难易程度,使其不再优化逐点损失,而是优化成对目标。
在下一节中,我们将更深入地探讨循环神经网络如何将用户行为建模为序列,并且我们将看看如何将它们用作推荐系统。
用于推荐系统的 RNN
循环神经网络(RNN)是一种特殊的神经网络,用于建模序列,并且在多个应用中都取得了相当成功。其中一个应用是序列生成。在《循环神经网络的非理性有效性》一文中,Andrej Karpathy 写到多个 RNN 取得非常令人印象深刻的结果的例子,包括生成莎士比亚作品、维基百科文章、XML、Latex,甚至是 C 代码!
既然 RNN 已经在一些应用中证明了其有效性,那么一个自然的问题是:我们能否将 RNN 应用到其他领域呢?比如推荐系统?这是《基于循环神经网络的子版块推荐系统》报告的作者们所提出的问题(请见cole-maclean.github.io/blog/RNN-Based-Subreddit-Recommender-System/)。答案是肯定的,我们也可以将 RNN 应用于这个领域!
在本节中,我们也将尝试回答这个问题。对于这一部分,我们考虑一个与之前略有不同的推荐场景:
-
用户进入网站。
-
我们提供了五个推荐。
-
每次购买后,我们更新推荐列表。
这个场景需要一种不同的结果评估方式。每当用户购买某个商品时,我们可以检查该商品是否在推荐列表中。如果在,则我们的推荐被视为成功。因此,我们可以计算出我们做了多少次成功的推荐。这种评估性能的方式叫做 Top-5 准确度,它通常用于评估具有大量目标类别的分类模型。
历史上,RNN(循环神经网络)用于语言模型,即预测给定句子中下一个最可能出现的单词。当然,TensorFlow 模型库中已经有一个实现了这样的语言模型,位于github.com/tensorflow/models(在tutorials/rnn/ptb/文件夹中)。本章剩下的一些代码示例深受这个例子的启发。
让我们开始吧。
数据准备和基准
像之前一样,我们需要将项目和用户表示为整数。然而,这次我们需要为未知用户设置一个特殊的占位符值。此外,我们还需要为项目设置一个特殊的占位符,表示每个交易开始时的“无项目”。我们稍后会详细讨论这一点,但目前,我们需要实现编码,以便0索引保留用于特殊用途。
之前我们使用的是字典,但这次我们为此目的实现了一个特殊的类LabelEncoder:
class LabelEncoder:
def fit(self, seq):
self.vocab = sorted(set(seq))
self.idx = {c: i + 1 for i, c in enumerate(self.vocab)}
def transform(self, seq):
n = len(seq)
result = np.zeros(n, dtype='int32')
for i in range(n):
result[i] = self.idx.get(seq[i], 0)
return result
def fit_transform(self, seq):
self.fit(seq)
return self.transform(seq)
def vocab_size(self):
return len(self.vocab) + 1
实现非常简单,基本上重复了我们之前使用的代码,但这次它被封装在一个类中,并且保留了0索引用于特殊需求——例如,用于训练数据中缺失的元素。
让我们使用这个编码器将项目转换为整数:
item_enc = LabelEncoder()
df.stockcode = item_enc.fit_transform(df.stockcode.astype('str'))
df.stockcode = df.stockcode.astype('int32')
然后我们执行相同的训练-验证-测试划分:前 10 个月用于训练,一个月用于验证,最后一个月用于测试。
接下来,我们对用户 ID 进行编码:
user_enc = LabelEncoder()
user_enc.fit(df_train[df_train.customerid != -1].customerid)
df_train.customerid = user_enc.transfrom(df_train.customerid)
df_val.customerid = user_enc.transfrom(df_val.customerid)
像之前一样,我们使用最常购买的项目作为基准。然而,这次情况有所不同,因此我们稍微调整了基准。具体而言,如果用户购买了某个推荐项目,我们会将其从未来的推荐中移除。
下面是我们如何实现它的:
from collections import Counter
top_train = Counter(df_train.stockcode)
def baseline(uid, indptr, items, top, k=5):
n_groups = len(uid)
n_items = len(items)
pred_all = np.zeros((n_items, k), dtype=np.int32)
for g in range(n_groups):
t = top.copy()
start = indptr[g]
end = indptr[g+1]
for i in range(start, end):
pred = [k for (k, c) in t.most_common(5)]
pred_all[i] = pred
actual = items[i]
if actual in t:
del t[actual]
return pred_all
在前面的代码中,indptr是指针数组——它与我们之前用于实现precision函数的数组相同。
现在我们将其应用到验证数据并生成结果:
iid_val = df_val.stockcode.values
pred_baseline = baseline(uid_val, indptr_val, iid_val, top_train, k=5)
基准模型如下所示:
array([[3528, 3507, 1348, 2731, 181],
[3528, 3507, 1348, 2731, 181],
[3528, 3507, 1348, 2731, 181],
...,
[1348, 2731, 181, 454, 1314],
[1348, 2731, 181, 454, 1314],
[1348, 2731, 181, 454, 1314]], dtype=int32
现在我们来实现 top-k 准确率指标。我们再次使用来自 numba 的@njit装饰器来加速这个函数:
@njit
def accuracy_k(y_true, y_pred):
n, k = y_pred.shape
acc = 0
for i in range(n):
for j in range(k):
if y_pred[i, j] == y_true[i]:
acc = acc + 1
break
return acc / n
要评估基准模型的性能,只需调用真实标签和预测结果:
accuracy_k(iid_val, pred_baseline)
它打印出0.012,即我们只有在 1.2%的情况下能够成功推荐。这看起来有很大的改进空间!
下一步是将长数组拆分为单独的交易。我们可以再次重用指针数组,它告诉我们每个交易的开始和结束位置:
def pack_items(users, items_indptr, items_vals):
n = len(items_indptr)—1
result = []
for i in range(n):
start = items_indptr[i]
end = items_indptr[i+1]
result.append(items_vals[start:end])
return result
现在我们可以解包交易并将它们放入一个单独的数据框中:
train_items = pack_items(indptr_train, indptr_train, df_train.stockcode.values)
df_train_wrap = pd.DataFrame()
df_train_wrap['customerid'] = uid_train
df_train_wrap['items'] = train_items
要查看最终结果,使用head函数:
df_train_wrap.head()
这显示了以下内容:

这些序列的长度各不相同,这对 RNN 来说是一个问题。因此,我们需要将它们转换为固定长度的序列,这样我们以后就可以轻松地将其输入到模型中。
如果原始序列太短,我们需要用零填充它。如果序列太长,我们需要将其切割或分割成多个序列。
最后,我们还需要表示用户已进入网站但尚未购买任何商品的状态。我们可以通过插入虚拟零项来实现——这是一项具有索引 0 的商品,专门为特殊用途保留,就像这个一样。此外,我们还可以利用这个虚拟项来填充那些过小的序列。
我们还需要为 RNN 准备标签。假设我们有以下序列:

我们希望生成一个固定长度为 5 的序列。通过在开头填充,训练用的序列将如下所示:

在这里,我们在原始序列的开始处用零填充,并且不包括最后一个元素——最后一个元素只会包含在目标序列中。因此,目标序列——我们要预测的输出——应该如下所示:

一开始可能看起来有些困惑,但这个想法很简单。我们希望构造序列,使得对于 X 中的第 i 个位置,Y 中的第 i 个位置包含我们想要预测的元素。对于前面的例子,我们想要学习以下规则:
-
- 都位于 X和Y的位置0。 -
— 都位于 X和Y的位置1。 -
以此类推
现在假设我们有一个较小的长度为 2 的序列,我们需要将其填充到长度为 5 的序列:

在这种情况下,我们再次在输入序列的开头填充 0,并且在末尾也加上了一些零:
。
我们类似地转换目标序列 Y:
。
如果输入太长,例如
,我们可以将其切分为多个序列:

为了执行这样的转换,我们编写了一个函数 pad_seq。它会在序列的开始和结束处添加所需数量的零。然后,我们在另一个函数中调用 pad_seq —— prepare_training_data —— 该函数会为每个序列创建 X 和 Y 的矩阵:
def pad_seq(data, num_steps):
data = np.pad(data, pad_width=(1, 0), mode='constant')
n = len(data)
if n <= num_steps:
pad_right = num_steps—n + 1
data = np.pad(data, pad_width=(0, pad_right), mode='constant')
return data
def prepare_train_data(data, num_steps):
data = pad_seq(data, num_steps)
X = []
Y = []
for i in range(num_steps, len(data)):
start = i—num_steps
X.append(data[start:i])
Y.append(data[start+1:i+1])
return X, Y
剩下的就是为每个训练历史中的序列调用 prepare_training_data 函数,然后将结果合并到 X_train 和 Y_train 矩阵中:
train_items = df_train_wrap['items']
X_train = []
Y_train = []
for i in range(len(train_items)):
X, Y = prepare_train_data(train_items[i], 5)
X_train.extend(X)
Y_train.extend(Y)
X_train = np.array(X_train, dtype='int32')
Y_train = np.array(Y_train, dtype='int32')
到此为止,我们已经完成了数据准备。现在,我们准备好创建一个可以处理这些数据的 RNN 模型。
TensorFlow 中的 RNN 推荐系统
数据准备工作已经完成,现在我们使用生成的矩阵 X_train 和 Y_train 来训练模型。但当然,我们需要先创建模型。在本章中,我们将使用带有 LSTM 单元(长短期记忆)的循环神经网络。LSTM 单元比普通 RNN 单元更好,因为它们能够更好地捕捉长期依赖关系。
了解更多关于 LSTM 的知识,可以参考 Christopher Olah 的博客文章《理解 LSTM 网络》,可以在 colah.github.io/posts/2015-08-Understanding-LSTMs/ 找到。在本章中,我们不会深入探讨 LSTM 和 RNN 的理论细节,只关注如何在 TensorFlow 中使用它们。
让我们从定义一个特殊的配置类开始,它包含所有重要的训练参数:
class Config:
num_steps = 5
num_items = item_enc.vocab_size()
num_users = user_enc.vocab_size()
init_scale = 0.1
learning_rate = 1.0
max_grad_norm = 5
num_layers = 2
hidden_size = 200
embedding_size = 200
batch_size = 20
config = Config()
这里的 Config 类定义了以下参数:
-
num_steps—这是固定长度序列的大小 -
num_items—我们训练数据中项目的数量(+1 代表虚拟的0项) -
num_users—用户数量(同样 +1 代表虚拟的0用户) -
init_scale—权重参数的缩放因子,初始化时需要 -
learning_rate—我们更新权重的速率 -
max_grad_norm—梯度的最大允许范数,如果梯度超过此值,我们将进行裁剪 -
num_layers—网络中 LSTM 层的数量 -
hidden_size—将 LSTM 输出转换为输出概率的隐藏密集层的大小 -
embedding_size—项目嵌入的维度 -
batch_size—我们在单次训练步骤中输入到网络的序列数
现在我们终于实现模型。我们首先定义了两个有用的辅助函数——我们将用它们来将 RNN 部分添加到我们的模型中:
def lstm_cell(hidden_size, is_training):
return rnn.BasicLSTMCell(hidden_size, forget_bias=0.0,
state_is_tuple=True, reuse=not is_training)
def rnn_model(inputs, hidden_size, num_layers, batch_size, num_steps,
is_training):
cells = [lstm_cell(hidden_size, is_training) for
i in range(num_layers)]
cell = rnn.MultiRNNCell(cells, state_is_tuple=True)
initial_state = cell.zero_state(batch_size, tf.float32)
inputs = tf.unstack(inputs, num=num_steps, axis=1)
outputs, final_state = rnn.static_rnn(cell, inputs,
initial_state=initial_state)
output = tf.reshape(tf.concat(outputs, 1), [-1, hidden_size])
return output, initial_state, final_state
现在我们可以使用 rnn_model 函数来创建我们的模型:
def model(config, is_training):
batch_size = config.batch_size
num_steps = config.num_steps
embedding_size = config.embedding_size
hidden_size = config.hidden_size
num_items = config.num_items
place_x = tf.placeholder(shape=[batch_size, num_steps], dtype=tf.int32)
place_y = tf.placeholder(shape=[batch_size, num_steps], dtype=tf.int32)
embedding = tf.get_variable("items", [num_items, embedding_size],
dtype=tf.float32)
inputs = tf.nn.embedding_lookup(embedding, place_x)
output, initial_state, final_state = \
rnn_model(inputs, hidden_size, config.num_layers, batch_size,
num_steps, is_training)
W = tf.get_variable("W", [hidden_size, num_items], dtype=tf.float32)
b = tf.get_variable("b", [num_items], dtype=tf.float32)
logits = tf.nn.xw_plus_b(output, W, b)
logits = tf.reshape(logits, [batch_size, num_steps, num_items])
loss = tf.losses.sparse_softmax_cross_entropy(place_y, logits)
total_loss = tf.reduce_mean(loss)
tvars = tf.trainable_variables()
gradient = tf.gradients(total_loss, tvars)
clipped, _ = tf.clip_by_global_norm(gradient, config.max_grad_norm)
optimizer = tf.train.GradientDescentOptimizer(config.learning_rate)
global_step = tf.train.get_or_create_global_step()
train_op = optimizer.apply_gradients(zip(clipped, tvars),
global_step=global_step)
out = {}
out['place_x'] = place_x
out['place_y'] = place_y
out['logits'] = logits
out['initial_state'] = initial_state
out['final_state'] = final_state
out['total_loss'] = total_loss
out['train_op'] = train_op
return out
在这个模型中有多个部分,具体如下:
-
首先,我们指定输入。和之前一样,这些是 ID,稍后我们通过嵌入层将它们转换为向量。
-
第二,我们添加 RNN 层,接着是一个密集层。LSTM 层学习购买行为中的时间模式,密集层将这些信息转换为所有可能项目的概率分布。
-
第三,由于我们的模型是多类分类模型,我们优化分类交叉熵损失。
-
最后,LSTM 被认为有梯度爆炸的问题,这就是为什么我们在进行优化时会执行梯度裁剪。
该函数返回一个包含所有重要变量的字典——所以稍后我们将能够在训练和验证结果时使用它们。
这次我们创建一个函数,而不是像之前那样仅使用全局变量,原因是我们希望在训练和测试阶段之间能够改变参数。在训练过程中,batch_size 和 num_steps 变量可以取任何值,实际上它们是模型的可调参数。相反,在测试过程中,这些参数只能取一个值:1。原因是当用户购买物品时,总是一次购买一个项目,而不是多个,所以 num_steps 为 1。batch_size 也因为同样的原因为 1。
因此,我们创建了两个配置:一个用于训练,一个用于验证:
config = Config()
config_val = Config()
config_val.batch_size = 1
config_val.num_steps = 1
现在让我们定义模型的计算图。由于我们希望在训练过程中学习参数,但在测试过程中使用具有不同参数的独立模型,因此我们需要使学习到的参数可共享。这些参数包括嵌入、LSTM 和密集层的权重。为了使两个模型共享这些参数,我们使用一个变量作用域并设置reuse=True:
graph = tf.Graph()
graph.seed = 1
with graph.as_default():
initializer = tf.random_uniform_initializer(-config.init_scale,
config.init_scale)
with tf.name_scope("Train"):
with tf.variable_scope("Model", reuse=None,
initializer=initializer):
train_model = model(config, is_training=True)
with tf.name_scope("Valid"):
with tf.variable_scope("Model", reuse=True,
initializer=initializer):
val_model = model(config_val, is_training=False)
init = tf.global_variables_initializer()
计算图准备好了。现在我们可以训练模型,为此我们创建一个 run_epoch 辅助函数:
def run_epoch(session, model, X, Y, batch_size):
fetches = {
"total_loss": model['total_loss'],
"final_state": model['final_state'],
"eval_op": model['train_op']
}
num_steps = X.shape[1]
all_idx = np.arange(X.shape[0])
np.random.shuffle(all_idx)
batches = prepare_batches(all_idx, batch_size)
initial_state = session.run(model['initial_state'])
current_state = initial_state
progress = tqdm(total=len(batches))
for idx in batches:
if len(idx) < batch_size:
continue
feed_dict = {}
for i, (c, h) in enumerate(model['initial_state']):
feed_dict[c] = current_state[i].c
feed_dict[h] = current_state[i].h
feed_dict[model['place_x']] = X[idx]
feed_dict[model['place_y']] = Y[idx]
vals = session.run(fetches, feed_dict)
loss = vals["total_loss"]
current_state = vals["final_state"]
progress.update(1)
progress.set_description('%.3f' % loss)
progress.close()
函数的初始部分应该对我们来说已经很熟悉:它首先创建一个我们感兴趣的变量字典,并且打乱数据集。
然而,下一部分有所不同:由于这次我们使用的是 RNN 模型(准确来说是 LSTM 单元),我们需要在多次运行中保持其状态。为此,我们首先获取初始状态——它应该全为零——然后确保模型确切地获得这些值。每完成一步,我们记录 LSTM 的最终状态并将其重新输入到模型中。通过这种方式,模型可以学习典型的行为模式。
再次像之前一样,我们使用 tqdm 来监控进度,并展示我们在一个周期中已经进行的步骤数量和当前的训练损失。
让我们训练这个模型一个周期:
session = tf.Session(config=None, graph=graph)
session.run(init)
np.random.seed(0)
run_epoch(session, train_model, X_train, Y_train, batch_size=config.batch_size)
一个周期已经足够让模型学习一些模式,所以现在我们可以查看它是否真的能够做到这一点。为此,我们首先编写另一个辅助函数,模拟我们的推荐场景:
def generate_prediction(uid, indptr, items, model, k):
n_groups = len(uid)
n_items = len(items)
pred_all = np.zeros((n_items, k), dtype=np.int32)
initial_state = session.run(model['initial_state'])
fetches = {
"logits": model['logits'],
"final_state": model['final_state'],
}
for g in tqdm(range(n_groups)):
start = indptr[g]
end = indptr[g+1]
current_state = initial_state
feed_dict = {}
for i, (c, h) in enumerate(model['initial_state']):
feed_dict[c] = current_state[i].c
feed_dict[h] = current_state[i].h
prev = np.array([[0]], dtype=np.int32)
for i in range(start, end):
feed_dict[model['place_x']] = prev
actual = items[i]
prev[0, 0] = actual
values = session.run(fetches, feed_dict)
current_state = values["final_state"]
logits = values['logits'].reshape(-1)
pred = np.argpartition(-logits, k)[:k]
pred_all[i] = pred
return pred_all
我们在这里做的事情是:
-
首先,我们初始化预测矩阵,其大小与基准模型相同,为验证集中的项目数量与推荐数量的乘积。
-
然后我们对数据集中的每个事务运行模型。
-
每次我们从虚拟的零项和空的零 LSTM 状态开始。
-
然后我们逐个预测下一个可能的项目,并将用户实际购买的项目作为前一个项目——我们将在下一步将其输入到模型中。
-
最后,我们取密集层的输出并获取前 k 个最可能的预测,作为我们在这一特定步骤的推荐。
让我们执行这个函数并观察它的性能:
pred_lstm = generate_prediction(uid_val, indptr_val, iid_val, val_model, k=5)
accuracy_k(iid_val, pred_lstm)
我们看到输出为 7.1%,是基准模型的七倍。
这是一个非常基础的模型,肯定还有很大的改进空间:我们可以调整学习率,并且逐渐减少学习率的训练几个时期。我们可以改变batch_size,num_steps,以及所有其他参数。我们也没有使用任何正则化——既不是权重衰减也不是 dropout。添加它应该会有所帮助。
但最重要的是,我们在这里没有使用任何用户信息:推荐仅基于物品的模式。通过包含用户上下文,我们应该能够获得额外的改进。毕竟,推荐系统应该是个性化的,即针对特定用户量身定制。
现在我们的X_train矩阵只包含物品。我们应该包括另一个输入,例如U_train,其中包含用户 ID:
X_train = []
U_train = []
Y_train = []
for t in df_train_wrap.itertuples():
X, Y = prepare_train_data(t.items, config.num_steps)
U_train.extend([t.customerid] * len(X))
X_train.extend(X)
Y_train.extend(Y)
X_train = np.array(X_train, dtype='int32')
Y_train = np.array(Y_train, dtype='int32')
U_train = np.array(U_train, dtype='int32')
现在让我们改变模型。将用户特征合并到物品向量中并将堆叠矩阵放入 LSTM 是最简单的方法。实现起来非常容易,我们只需要修改几行代码:
def user_model(config, is_training):
batch_size = config.batch_size
num_steps = config.num_steps
embedding_size = config.embedding_size
hidden_size = config.hidden_size
num_items = config.num_items
num_users = config.num_users
place_x = tf.placeholder(shape=[batch_size, num_steps], dtype=tf.int32)
place_u = tf.placeholder(shape=[batch_size, 1], dtype=tf.int32)
place_y = tf.placeholder(shape=[batch_size, num_steps], dtype=tf.int32)
item_embedding = tf.get_variable("items", [num_items, embedding_size], dtype=tf.float32)
item_inputs = tf.nn.embedding_lookup(item_embedding, place_x)
user_embedding = tf.get_variable("users", [num_items, embedding_size], dtype=tf.float32)
u_repeat = tf.tile(place_u, [1, num_steps])
user_inputs = tf.nn.embedding_lookup(user_embedding, u_repeat)
inputs = tf.concat([user_inputs, item_inputs], axis=2)
output, initial_state, final_state = \
rnn_model(inputs, hidden_size, config.num_layers, batch_size, num_steps, is_training)
W = tf.get_variable("W", [hidden_size, num_items], dtype=tf.float32)
b = tf.get_variable("b", [num_items], dtype=tf.float32)
logits = tf.nn.xw_plus_b(output, W, b)
logits = tf.reshape(logits, [batch_size, num_steps, num_items])
loss = tf.losses.sparse_softmax_cross_entropy(place_y, logits)
total_loss = tf.reduce_mean(loss)
tvars = tf.trainable_variables()
gradient = tf.gradients(total_loss, tvars)
clipped, _ = tf.clip_by_global_norm(gradient, config.max_grad_norm)
optimizer = tf.train.GradientDescentOptimizer(config.learning_rate)
global_step = tf.train.get_or_create_global_step()
train_op = optimizer.apply_gradients(zip(clipped, tvars),
global_step=global_step)
out = {}
out['place_x'] = place_x
out['place_u'] = place_u
out['place_y'] = place_y
out['logits'] = logits
out['initial_state'] = initial_state
out['final_state'] = final_state
out['total_loss'] = total_loss
out['train_op'] = train_op
return out
新实现与之前模型之间的变化用粗体显示。特别是,差异如下:
-
我们添加
place_u——作为输入接受用户 ID 的占位符 -
将
embeddings重命名为item_embeddings——以免与我们在其后添加的user_embeddings混淆几行 -
最后,我们将用户特征与物品特征串联起来。
模型其余的代码保持不变!
初始化类似于前一个模型:
graph = tf.Graph()
graph.seed = 1
with graph.as_default():
initializer = tf.random_uniform_initializer(-config.init_scale, config.init_scale)
with tf.name_scope("Train"):
with tf.variable_scope("Model", reuse=None, initializer=initializer):
train_model = user_model(config, is_training=True)
with tf.name_scope("Valid"):
with tf.variable_scope("Model", reuse=True, initializer=initializer):
val_model = user_model(config_val, is_training=False)
init = tf.global_variables_initializer()
session = tf.Session(config=None, graph=graph)
session.run(init)
唯一的区别是,我们在创建模型时调用不同的函数。模型训练一个时期的代码与以前非常相似。我们改变的唯一事物是函数的额外参数,我们将它们添加到feed_dict中:
def user_model_epoch(session, model, X, U, Y, batch_size):
fetches = {
"total_loss": model['total_loss'],
"final_state": model['final_state'],
"eval_op": model['train_op']
}
num_steps = X.shape[1]
all_idx = np.arange(X.shape[0])
np.random.shuffle(all_idx)
batches = prepare_batches(all_idx, batch_size)
initial_state = session.run(model['initial_state'])
current_state = initial_state
progress = tqdm(total=len(batches))
for idx in batches:
if len(idx) < batch_size:
continue
feed_dict = {}
for i, (c, h) in enumerate(model['initial_state']):
feed_dict[c] = current_state[i].c
feed_dict[h] = current_state[i].h
feed_dict[model['place_x']] = X[idx]
feed_dict[model['place_y']] = Y[idx]
feed_dict[model['place_u']] = U[idx].reshape(-1, 1)
vals = session.run(fetches, feed_dict)
loss = vals["total_loss"]
current_state = vals["final_state"]
progress.update(1)
progress.set_description('%.3f' % loss)
progress.close()
现在让我们为这个新模型训练一个时期:
session = tf.Session(config=None, graph=graph)
session.run(init)
np.random.seed(0)
user_model_epoch(session, train_model, X_train, U_train, Y_train, batch_size=config.batch_size)
我们使用模型的方式与之前几乎相同:
def generate_prediction_user_model(uid, indptr, items, model, k):
n_groups = len(uid)
n_items = len(items)
pred_all = np.zeros((n_items, k), dtype=np.int32)
initial_state = session.run(model['initial_state'])
fetches = {
"logits": model['logits'],
"final_state": model['final_state'],
}
for g in tqdm(range(n_groups)):
start = indptr[g]
end = indptr[g+1]
u = uid[g]
current_state = initial_state
feed_dict = {}
feed_dict[model['place_u']] = np.array([[u]], dtype=np.int32)
for i, (c, h) in enumerate(model['initial_state']):
feed_dict[c] = current_state[i].c
feed_dict[h] = current_state[i].h
prev = np.array([[0]], dtype=np.int32)
for i in range(start, end):
feed_dict[model['place_x']] = prev
actual = items[i]
prev[0, 0] = actual
values = session.run(fetches, feed_dict)
current_state = values["final_state"]
logits = values['logits'].reshape(-1)
pred = np.argpartition(-logits, k)[:k]
pred_all[i] = pred
return pred_all
最后,我们运行此函数为验证集生成预测,并计算这些推荐的准确性:
pred_lstm = generate_prediction_user_model(uid_val, indptr_val, iid_val, val_model, k=5)
accuracy_k(iid_val, pred_lstm)
我们看到的输出是 0.252,即 25%。我们自然期望它更好,但改进非常显著:几乎比上一个模型好了四倍,并且比朴素基线好了 25 个百分点。在保留测试集上跳过模型检查,但您可以(并且通常应该)自行执行以确保模型不会过拟合。
总结
在本章中,我们涵盖了推荐系统。我们首先介绍了一些背景理论,用 TensorFlow 实现了简单的方法,然后讨论了一些改进,例如应用 BPR-Opt 到推荐中。了解这些模型并在实际推荐系统中实现它们非常重要和有用。
在第二部分,我们尝试应用基于递归神经网络(RNN)和长短期记忆网络(LSTM)构建推荐系统的创新技术。我们将用户的购买历史视为一个序列,并能够利用序列模型进行成功的推荐。
在下一章,我们将讨论强化学习。这是深度学习最近的进展显著改变了技术前沿的领域之一:现在的模型在许多游戏中能够击败人类。我们将研究导致这一变化的先进模型,并学习如何使用 TensorFlow 实现真正的人工智能。
第十章:强化学习与视频游戏
与监督学习不同,监督学习要求算法将输入与输出关联起来,而强化学习则是另一种最大化任务。在强化学习中,你会被给定一个环境(即一个情境),并需要找到一个解决方案,进行行动(这可能需要与环境进行交互,甚至改变环境本身),其明确目标是最大化最终的奖励。因此,强化学习算法没有明确的目标,而是致力于获得最终可能的最大结果。它们可以通过反复试验和错误的方式自由地找到实现结果的路径。这类似于幼儿在新环境中自由实验并分析反馈,以便找出如何从经验中获得最佳效果的过程。它也类似于我们玩新视频游戏时的体验:首先,我们寻找最佳的获胜策略;尝试许多不同的方式,然后决定如何在游戏中行动。
目前,没有任何强化学习算法具有人类的通用学习能力。人类能从多种输入中更快速地学习,并且能够在非常复杂、多变、有结构、无结构和多重环境中学习如何行为。然而,强化学习算法已经证明能够在非常具体的任务中达到超越人类的能力(是的,它们可以比人类做得更好)。如果强化学习算法专注于某个特定游戏,并且有足够的时间进行学习,它们可以取得出色的成果(例如 AlphaGo deepmind.com/research/alphago/ —— 第一个击败围棋世界冠军的计算机程序,围棋是一项复杂的游戏,需要长期的策略和直觉)。
在本章中,我们将为你提供一个具有挑战性的项目,要求你让强化学习算法学习如何成功地控制 Atari 游戏《月球着陆者》的指令,该算法由深度学习提供支持。《月球着陆者》是这个项目的理想游戏,因为强化学习算法可以成功地在其中工作,游戏命令较少,并且仅通过查看描述游戏情境的几个数值,就能成功完成游戏(实际上,甚至不需要看屏幕来理解该怎么做,事实上,游戏的第一个版本可以追溯到 1960 年代,它是文字版的)。
神经网络和强化学习彼此并不陌生;在 1990 年代初期,IBM 的 Gerry Tesauro 编程了著名的 TD-Gammon,将前馈神经网络与时间差学习(蒙特卡洛方法和动态规划的结合)结合,训练 TD-Gammon 玩世界级的西洋双陆棋(一种使用骰子的两人棋盘游戏)。如果你对这款游戏感兴趣,可以通过美国双陆棋协会阅读规则:usbgf.org/learn-backgammon/backgammon-rules-and-terms/rules-of-backgammon/。当时,这种方法在西洋双陆棋中效果很好,因为骰子在游戏中起着非确定性作用。然而,它在其他更具确定性的问题游戏中却失败了。近年来,感谢谷歌深度学习团队的研究人员证明,神经网络可以帮助解决除西洋双陆棋外的其他问题,而且问题解决可以在任何人的计算机上实现。现在,强化学习已成为深度学习和机器学习领域的下一个重要趋势,正如你可以从谷歌大脑的 AI 研究科学家 Ian Goodfellow 的文章中看到,他把它列为首要事项:www.forbes.com/sites/quora/2017/07/21/whats-next-for-deep-learning/#6a8f8cd81002。
游戏的遗产
《月球着陆者》是由 Atari 公司开发的一款街机游戏,首次出现在大约 1979 年的电子游戏街机中。该游戏采用黑白矢量图形开发,并通过特制的机柜分发,展示了一个从侧面视角看到的月球着陆舱接近月球的场景,月球上有专门的着陆区域。由于周围地形的原因,着陆区域的宽度和可达性有所不同,因此着陆时会给玩家不同的分数。玩家会得到关于高度、速度、剩余燃料、得分和至今用时的信息。由于重力的作用将着陆舱吸引到地面,玩家可以旋转或推动着陆舱(同时需要考虑惯性力),以消耗部分燃料。燃料是游戏的关键。
游戏在着陆舱用尽燃料后触碰到月球时结束。直到燃料耗尽,你一直在玩游戏,即使你发生了碰撞。玩家可以使用的指令只有四个按钮:两个用于向左和向右旋转;一个用于从着陆舱底部推力,推动模块朝着其朝向的方向;最后一个按钮用于中止着陆,通过将着陆舱旋转至竖直状态并使用强力(且耗费燃料的)推力,以防止着陆舱坠毁。
这个游戏的有趣之处在于,尽管存在明显的成本和奖励,但有些是立刻显现的(比如你在尝试过程中消耗的燃料量),而其他则是直到着陆舱触碰到地面时才会出现(只有当着陆舱完全停止后,你才能知道着陆是否成功)。为了着陆而进行的操控消耗燃料,因此需要一种经济的游戏策略,尽量避免浪费过多燃料。着陆会提供一个分数,着陆越困难且越安全,分数就越高。
OpenAI 版本
正如其官网上的文档所述(gym.openai.com/),OpenAI Gym 是一个用于开发和比较强化学习算法的工具包。这个工具包实际上是一个 Python 包,支持 Python 2 和 Python 3 运行,还有网站 API,可以上传你自己算法的性能结果,并与其他结果进行比较(这个工具包的一个方面我们并不会探讨)。
这个工具包体现了强化学习的基本原理,其中包括一个环境和一个代理:代理可以在环境中执行动作或不动作,环境会返回一个新的状态(表示环境中的情况)和奖励,奖励是一个分数,用来告诉代理它是否做得好。Gym 工具包提供了环境的一切,因此你需要编写代理的算法,帮助代理应对环境。环境通过 env 类来处理,这个类包含强化学习方法,实例化后可用于特定游戏,通过命令 gym.make('environment') 创建。让我们来看一个来自官方文档的示例:
import gym
env = gym.make('CartPole-v0')
for i_episode in range(20):
observation = env.reset()
for t in range(100):
env.render()
print(observation)
# taking a random action
action = env.action_space.sample()
observation, reward, done, info = \
env.step(action)
If done:
print("Episode finished after %i \
timesteps" % (t+1))
break
在这个示例中,运行环境是 CartPole-v0。主要是一个控制问题,在 CartPole-v0 游戏中,一个摆锤被固定在一个沿无摩擦轨道移动的小车上。游戏的目的是通过对小车施加前进或后退的力量,使摆锤尽可能保持直立。你可以通过观看这个 YouTube 视频,了解游戏的动态,该视频是 IIT Madras 动力学与控制实验室的一项实际实验的一部分,并基于“类似神经元的自适应元素能够解决困难的控制问题”:www.youtube.com/watch?v=qMlcsc43-lg。
Cartpole 问题在类似神经元的自适应元素能够解决困难的学习控制问题(ieeexplore.ieee.org/document/6313077/)一文中由 BARTO, Andrew G.; SUTTON, Richard S.; ANDERSON, Charles W. 在 IEEE Transactions on Systems, Man, and Cybernetics 中描述。
以下是应用于示例中的 env 方法的简要说明:
-
reset():这将重置环境的状态为初始默认条件。实际上,它返回起始观察值。 -
step(action):这将使环境按单个时间步移动。它返回一个由四个值组成的向量:observations、reward、done和info。观察值是环境状态的表示,并且在每个游戏中由不同的数值向量表示。例如,在涉及物理的游戏(如CartPole-v0)中,返回的向量包括小车的位置、小车的速度、杆的角度和杆的速度。奖励是上一个动作获得的分数(你需要累计奖励,以便计算每一时刻的总得分)。变量done是一个布尔标志,告诉你是否已到达游戏的终止状态(游戏结束)。info将提供诊断信息,尽管这些信息在算法中不应使用,但可以用于调试。 -
render( mode='human', close=False):这将渲染环境的一个时间帧。默认模式将执行一些人性化的操作,例如弹出一个窗口。传递close标志会指示渲染引擎关闭任何此类窗口。
命令的最终效果如下:
-
设置
CartPole-v0环境 -
运行 1,000 步
-
随机选择是否对小车施加正向或负向的力
-
可视化结果
这种方法的有趣之处在于,你可以轻松地更改游戏,只需提供不同的字符串给gym.make方法(例如尝试MsPacman-v0或Breakout-v0,或者从通过gym.print(envs.registry.all())获得的列表中选择任何一个)即可,在不改变代码的情况下测试你解决不同环境的方案。OpenAI Gym 通过为所有环境提供通用接口,使得测试你算法在不同问题上的泛化能力变得容易。此外,它为你提供了一个框架,帮助你根据该模式推理、理解和解决智能体与环境的问题。在时刻t-1,状态和奖励被传送给智能体,智能体做出反应并执行一个动作,在时刻t产生一个新的状态和奖励:

图 1:环境和智能体通过状态、动作和奖励进行交互的方式
在 OpenAI Gym 中的每个不同游戏中,动作空间(智能体响应的命令)和observation_space(状态的表示)都会发生变化。你可以通过使用一些print命令,在设置环境之后查看它们的变化:
print(env.action_space)
print(env.observation_space)
print(env.observation_space.high)
print(env.observation_space.low)
在 Linux(Ubuntu 14.04 或 16.04)上安装 OpenAI
我们建议在 Ubuntu 系统上安装此环境。OpenGym AI 是为 Linux 系统创建的,对 Windows 的支持较少。根据你系统之前的设置,可能需要先安装一些额外的组件:
apt-get install -y python3-dev python-dev python-numpy libcupti-dev libjpeg-turbo8-dev make golang tmux htop chromium-browser git cmake zlib1g-dev libjpeg-dev xvfb libav-tools xorg-dev python-opengl libboost-all-dev libsdl2-dev swig
我们建议使用 Anaconda,所以也安装 Anaconda 3。你可以在www.anaconda.com/download/找到关于安装这个 Python 发行版的所有信息。
设置系统要求后,安装 OpenGym AI 及其所有模块非常简单:
git clone https://github.com/openai/gym
cd gym
pip install -e .[all]
对于这个项目,我们实际上是想使用 Box2D 模块,它是一个 2D 物理引擎,提供在 2D 环境中模拟真实世界物理的渲染效果,通常在伪现实的电子游戏中能看到。你可以通过在 Python 中运行这些命令来测试 Box2D 模块是否正常工作:
import gym
env = gym.make('LunarLander-v2')
env.reset()
env.render()
如果提供的代码运行没有问题,你可以继续进行项目。在某些情况下,Box2D 可能变得难以运行,例如可能会遇到github.com/cbfinn/gps/issues/34中报告的问题,虽然周围还有许多其他类似的例子。我们发现,将 Gym 安装在基于 Python 3.4 的 conda 环境中会使事情变得更加简单:
conda create --name gym python=3.4 anaconda gcc=4.8.5
source activate gym
conda install pip six libgcc swig
conda install -c conda-forge opencv
pip install --upgrade tensorflow-gpu
git clone https://github.com/openai/gym
cd gym
pip install -e .
conda install -c https://conda.anaconda.org/kne pybox2d
这个安装顺序应该能让你创建一个适合本章我们要介绍的项目的 conda 环境。
OpenAI Gym 中的 Lunar Lander
LunarLander-v2 是由 OpenAI 工程师 Oleg Klimov 开发的一个场景,灵感来源于原始的 Atari Lunar Lander (github.com/olegklimov)。在实现中,你需要将着陆舱带到一个始终位于坐标 x=0 和 y=0 的月球平台。此外,你的实际 x 和 y 位置是已知的,因为它们的值存储在状态向量的前两个元素中,状态向量包含所有信息,供强化学习算法在某一时刻决定采取最佳行动。
这使得任务更加可接近,因为你不需要处理与目标位置相关的模糊或不确定的位置定位问题(这是机器人技术中的常见问题)。

图 2:LunarLander-v2 的运行情况
每一刻,着陆舱都有四个可能的行动可供选择:
-
什么也不做
-
向左旋转
-
向右旋转
-
推力
然后有一个复杂的奖励系统来使事情变得有趣:
-
从屏幕顶部移动到着陆平台并达到零速度的奖励范围从 100 到 140 分(着陆在着陆平台外是可能的)
-
如果着陆舱在没有停下来的情况下离开着陆平台,它会失去一些之前的奖励
-
每一回合(指游戏会话的术语)会在着陆舱坠毁或停下来时结束,分别提供额外的 -100 或 +100 分
-
与地面接触的每条腿加 10 分
-
启动主引擎每帧扣除 -0.3 分(但燃料是无限的)
-
解决这一回合会获得 200 分
这个游戏非常适合离散命令(它们实际上是二进制的:全推力或无推力),因为正如模拟的作者所说,根据庞特里亚金最大值原理,最优的做法是以全推力开火引擎或完全关闭引擎。
这个游戏也可以通过一些简单的启发式方法解决,这些方法基于与目标的距离,并使用比例积分微分(PID)控制器来管理下降的速度和角度。PID 是用于控制系统的工程解决方案,在这些系统中你有反馈。你可以通过以下网址获取更详细的解释:www.csimn.com/CSI_pages/PIDforDummies.html。
通过深度学习探索强化学习
在这个项目中,我们并不打算开发启发式方法(虽然它仍然是解决许多人工智能问题的有效方法)或构建一个有效的 PID 控制器。相反,我们打算使用深度学习为智能体提供必要的智能,以成功操作月球着陆器视频游戏。
强化学习理论提供了一些框架来解决此类问题:
-
基于值的学习:通过计算处于某个状态时的奖励或结果来工作。通过比较不同可能状态的奖励,选择导致最佳状态的动作。Q-learning 就是这种方法的一个例子。
-
基于策略的学习:不同的控制策略基于来自环境的奖励进行评估,并决定哪种策略能够获得最佳结果。
-
基于模型的学习:这里的理念是复制一个环境模型到智能体中,从而允许智能体模拟不同的动作及其相应的奖励。
在我们的项目中,我们将使用基于值的学习框架;具体而言,我们将采用现在在强化学习中已经成为经典的 Q-learning 方法,这个方法成功地应用于控制游戏,其中一个智能体需要决定一系列动作,最终导致游戏后期的延迟奖励。这个方法由 C.J.C.H. Watkins 在 1989 年在他的博士论文中提出,也被称为Q-learning,它基于这样的理念:智能体在一个环境中操作,考虑当前状态,以定义一系列动作,最终获得奖励:

在上述公式中,描述了一个状态s在经过一个动作a后,如何导致奖励r和一个新状态s'。从游戏的初始状态开始,该公式应用一系列动作,依次转化每个后续状态,直到游戏结束。你可以将游戏想象成通过一系列动作连接起来的状态链。你还可以解读上述公式,如何通过一系列动作a将初始状态s转变为最终状态s'和最终奖励r。
在强化学习中,策略是如何选择最佳的动作序列,a。策略可以通过一个函数来逼近,称为Q,它以当前状态s和可能的动作a为输入,提供最大奖励r的估计,该奖励将从这个动作中得到:

这种方法显然是贪心的,意味着我们仅仅选择在某个精确状态下的最佳动作,因为我们预期始终选择最佳动作将导致最佳结果。因此,在贪心方法中,我们并不考虑可能的动作链条,而只是关注下一个动作a。然而,可以很容易证明,只要满足以下条件,我们就可以自信地采用贪心方法,并通过这种策略获得最大奖励:
-
我们找到了完美的策略预言机,
Q -
我们操作的环境信息是完备的(即我们可以知道环境的所有信息)
-
环境遵循马尔科夫原理(见提示框)
马尔科夫原理指出,未来(状态、奖励)仅依赖于当前状态,而与过去无关,因此我们可以通过仅查看当前状态并忽略过去的发生来推导出应该做什么。
事实上,如果我们将Q函数构建为递归函数,我们只需要使用广度优先搜索方法,探索当前状态下我们测试的动作的影响,递归函数将返回可能的最大奖励。
这种方法在计算机模拟中效果很好,但在现实世界中意义不大:
-
环境大多是概率性的。即使你执行了某个动作,也不能确定精确的奖励。
-
环境与过去紧密相关,单独的当前状态无法描述未来的可能性,因为过去可能会带来隐性或长期的后果。
-
环境并不完全可预测,因此你无法预先知道某个动作的奖励,但你可以在事后知道它们(这被称为后验条件)。
-
环境非常复杂。你无法在合理的时间内弄清楚一个动作可能带来的所有后果,因此你无法确定某个动作产生的最大奖励。
解决方案是采用近似的Q函数,它能够考虑概率性结果,并且不需要通过预测来探索所有未来状态。显然,这应该是一个真正的逼近函数,因为在复杂环境中构建值的查找表是不切实际的(一些状态空间可能是连续值,使得可能的组合数是无限的)。此外,函数可以离线学习,这意味着可以利用智能体的经验(记忆能力变得非常重要)。
之前曾有尝试通过神经网络来逼近Q函数,但唯一成功的应用是TD_Gammon,一个仅通过多层感知器的强化学习来学习玩跳棋的程序。TD_Gammon达到了超人类的水平,但当时它的成功无法在其他游戏中复制,比如国际象棋或围棋。
这导致人们认为神经网络不适合计算Q函数,除非游戏本身是随机的(例如你必须在跳棋中掷骰子)。然而,2013 年,Volodymyr Minh 等人发表了一篇关于深度强化学习的论文,Playing Atari with deep reinforcement learning(www.cs.toronto.edu/~vmnih/docs/dqn.pdf),证明了相反的观点。
这篇论文展示了如何使用神经网络学习一个Q函数,以便通过处理视频输入(通过以 60Hz 的频率从 210 x 160 RGB 视频中采样帧)并输出摇杆和开火按钮命令,来玩一系列的 Atari 街机游戏(如 Beam Rider、Breakout、Enduro、Pong、Q*bert、Seaquest 和 Space Invaders)。论文将这种方法命名为深度 Q 网络(DQN),并且它还介绍了经验重放和探索与利用的概念,我们将在下一节讨论这些概念。这些概念有助于克服在将深度学习应用于强化学习时的一些关键问题:
-
缺乏足够的例子供学习——这是强化学习所必须的,特别是在使用深度学习时更加不可或缺。
-
动作与有效奖励之间的延迟较长,这需要处理进一步行动的序列,这些序列长度不定,直到获得奖励为止。
-
一系列高度相关的行动序列(因为一个动作通常会影响后续的动作),这可能导致任何随机梯度下降算法过拟合最近的例子,或者根本没有以最优方式收敛(随机梯度下降期望的是随机样本,而不是相关样本)。
论文Human-level control through deep reinforcement learning(www.davidqiu.com:8888/research/nature14236.pdf)由 Mnih 和其他研究人员撰写,进一步确认了 DQN 的有效性,使用该方法探索了更多的游戏,并将 DQN 的表现与人类玩家及经典强化学习算法进行了比较。
在许多游戏中,DQN 表现得比人类更优秀,尽管长期策略仍然是该算法的一个问题。在某些游戏中,例如Breakout,代理发现了巧妙的策略,如通过墙壁挖隧道,将球送过墙壁并轻松摧毁它。在其他游戏中,如Montezuma's Revenge,代理依然一无所知。
在论文中,作者详细讨论了智能体如何理解赢得 Breakout 游戏的关键技巧,并提供了一张 DQN 函数响应的图表,展示了如何将较高的奖励分配给那些首先在墙上挖一个洞,然后让球通过它的行为。
深度 Q 学习的技巧与窍门
通过神经网络获得的 Q 学习被认为是不稳定的,直到一些技巧使其变得可行和切实可行。尽管最近已经开发出算法的其他变体来解决原始解决方案中的性能和收敛问题,但深度 Q 学习有两个关键动力源。我们项目中没有讨论这些新变体:双 Q 学习、延迟 Q 学习、贪婪 GQ 和快速 Q 学习。我们将要探讨的两个主要的 DQN 动力源是 经验回放 和 探索与利用之间的逐渐权衡。
通过经验回放,我们只需将游戏中观察到的状态存储在一个固定大小的队列中,因为当队列满时,我们会丢弃较早的序列。存储的数据中,我们预计包含多个元组,每个元组由当前状态、所采取的动作、由此得到的状态以及获得的奖励组成。如果我们考虑一个更简单的元组,只包含当前状态和动作,那么我们就得到了智能体在环境中操作的观察,这可以被视为后续状态和奖励的根本原因。现在,我们可以将这个元组(当前状态和动作)视为我们对于奖励的预测器(x 向量)。因此,我们可以直接使用与动作相关的奖励,以及在游戏结束时将会获得的奖励。
给定这些存储的数据(我们可以把它看作是智能体的记忆),我们从中随机抽取一些,以创建一个批次并用来训练我们的神经网络。然而,在将数据传递给网络之前,我们需要定义我们的目标变量,即我们的 y 向量。由于抽取的状态大多数不会是最终状态,我们可能会得到零奖励或仅是部分奖励,用来与已知的输入(当前状态和所选动作)进行匹配。部分奖励的意义不大,因为它只是告诉我们故事的一部分。我们的目标实际上是知道在游戏结束时我们将获得的总奖励,在评估当前状态下所采取的动作后(即我们的 x 值)。
在这种情况下,由于我们没有这样的信息,我们只需尝试通过使用现有的 Q 函数来近似值,以估计将会是(状态,动作)元组最大结果的剩余奖励。获得这个值后,我们使用贝尔曼方程对其进行折扣。
你可以在这篇由 Google 的软件工程师 Dr. Sal Candido 编写的优秀教程中阅读关于这一经典强化学习方法的解释:robotics.ai.uiuc.edu/~scandido/?Developing_Reinforcement_Learning_from_the_Bellman_Equation,其中当前奖励被加上折扣后的未来奖励。
使用一个较小的折扣值(接近零)使得Q函数更倾向于短期奖励,而使用较高的折扣值(接近一)则使Q函数更注重未来收益。
第二个非常有效的技巧是使用一个系数来在探索和利用之间进行权衡。在探索中,智能体会尝试不同的动作,以便在给定某个状态时找到最佳的行动方案。在利用中,智能体则利用之前探索中学到的内容,直接选择它认为在该情境下最好的行动。
在探索与利用之间找到一个良好的平衡,与我们之前讨论的经验重放的使用紧密相关。在 DQN 算法优化的开始阶段,我们只能依赖一组随机的网络参数。这就像在本章的简单入门示例中我们做的那样,随机选择动作。在这种情况下,智能体会探索不同的状态和动作,帮助塑造初始的Q函数。对于像Lunar Lander这样复杂的游戏,单纯依靠随机选择无法带领智能体走得很远,甚至在长期看来可能变得低效,因为它会阻止智能体学习那些只能在智能体做出正确行为后才能访问的状态-动作组合的预期奖励。实际上,在这种情况下,DQN 算法会很难弄清楚如何恰当地为一个动作分配正确的奖励,因为它从未见过一个完整的游戏。由于游戏本身很复杂,通过随机动作序列解决问题是不太可能的。
正确的方法是平衡通过随机性学习和利用已学知识推动智能体向前发展,直到进入尚未解决的问题区域。这类似于通过一系列逐步逼近找到解决方案,每次都将智能体带得更接近正确的行动序列,以实现安全且成功的着陆。因此,智能体应该首先通过随机方式学习,在某些情境下找到最佳的行动方案,然后应用学到的内容,进入新的情境,在这些情境中,智能体会通过随机选择继续解决问题、学习并逐步应用。
这是通过使用一个递减的值作为阈值,帮助智能体决定在游戏的某个时刻是选择随机行动看看会发生什么,还是利用迄今为止学到的知识,结合其实际能力来做出最佳的可能行动。通过从均匀分布中选择一个随机数字[0,1],智能体将其与一个 epsilon 值进行比较,如果随机数字大于 epsilon,它将使用近似的神经网络Q函数。否则,它将从可选动作中随机选择一个。之后,epsilon 值会减小。最初,epsilon 设置为最大值1.0,但根据衰减因子,它会随着时间推移以不同的速度减小,最终达到一个最小值,且永远不为零(避免完全没有随机动作的机会),以确保总有可能通过意外的方式学习到新的、不期而遇的东西(最小的开放性因素)。
理解深度 Q 学习的局限性
即便是深度 Q 学习,也存在一些限制,无论你是通过从视觉图像还是其他环境观察来近似你的Q函数:
-
近似过程需要很长时间才能收敛,有时甚至不能平稳收敛:你可能会看到神经网络的学习指标在许多回合中变得更差,而不是变得更好。
-
由于基于贪婪方法,Q 学习提供的方法与启发式方法类似:它指明了最佳方向,但无法提供详细的规划。当面对长期目标或需要分解成子目标的目标时,Q 学习表现不佳。
-
Q 学习的另一个后果是,它并不从整体的角度理解游戏动态,而是从特定的角度理解(它复制了在训练过程中有效的经验)。因此,游戏中任何新引入的内容(在训练过程中从未实际经历过)都可能导致算法崩溃,使其完全无效。同样,当引入一个新游戏到算法时,它根本无法表现出应有的效果。
开始项目
在经历了关于强化学习和 DQN 方法的长时间绕行后,我们终于准备好开始编码,已经具备了如何操作 OpenAI Gym 环境以及如何设置Q函数的 DQN 近似的基本理解。我们只需导入所有必要的包:
import gym
from gym import wrappers
import numpy as np
import random, tempfile, os
from collections import deque
import tensorflow as tf
tempfile模块生成临时文件和目录,可以作为数据文件的临时存储区。deque命令来自collections模块,用于创建一个双端队列,实际上是一个可以在开始或结束处添加项的列表。有趣的是,它可以设置为预定义的大小。队列满时,较旧的项会被丢弃,以腾出位置给新项。
我们将通过一系列类来构建这个项目,表示代理、代理的大脑(我们的 DQN)、代理的记忆和环境,环境由 OpenAI Gym 提供,但需要正确地与代理连接。需要为此编写一个类。
定义 AI 大脑
项目的第一步是创建一个Brain类,其中包含所有的神经网络代码,以便计算 Q 值近似值。该类将包含必要的初始化代码,用于创建适当的 TensorFlow 图,构建一个简单的神经网络(不是复杂的深度学习架构,而是一个适用于我们项目的简单、可运行的网络——你可以将其替换为更复杂的架构),最后,还包括拟合和预测操作的方法。
我们从初始化开始。作为输入,首先,我们确实需要知道与游戏中接收到的信息相关的状态输入的大小(nS),以及与我们可以按下的按钮对应的动作输出的大小(nA)。可以选择性地(但强烈推荐)设置作用域。为了定义作用域,我们需要一个字符串来帮助我们区分为不同目的创建的网络,在我们的项目中,我们有两个,一个用于处理下一个奖励,另一个用于猜测最终奖励。
然后,我们需要为优化器定义学习率,优化器使用的是 Adam。
Adam 优化器在以下论文中有描述:arxiv.org/abs/1412.6980.它是一种非常高效的基于梯度的优化方法,只需要很少的调节即可正常工作。Adam 优化是一个随机梯度下降算法,类似于带动量的 RMSprop。来自 UC Berkeley 计算机视觉评论信函的这篇文章,theberkeleyview.wordpress.com/2015/11/19/berkeleyview-for-adam-a-method-for-stochastic-optimization/,提供了更多信息。根据我们的经验,它是训练深度学习算法时最有效的解决方案之一,并且需要对学习率进行一些调优。
最后,我们还提供:
-
神经网络架构(如果我们希望更改类中提供的基础架构)
-
输入
global_step,这是一个全局变量,用于追踪到目前为止已经喂入 DQN 网络的训练批次数量。 -
存储 TensorBoard 日志的目录,这是 TensorFlow 的标准可视化工具。
class Brain:
"""
A Q-Value approximation obtained using a neural network.
This network is used for both the Q-Network and the Target Network.
"""
def __init__(self, nS, nA, scope="estimator",
learning_rate=0.0001,
neural_architecture=None,
global_step=None, summaries_dir=None):
self.nS = nS
self.nA = nA
self.global_step = global_step
self.scope = scope
self.learning_rate = learning_rate
if not neural_architecture:
neural_architecture = self.two_layers_network
# Writes Tensorboard summaries to disk
with tf.variable_scope(scope):
# Build the graph
self.create_network(network=neural_architecture,
learning_rate=self.learning_rate)
if summaries_dir:
summary_dir = os.path.join(summaries_dir,
"summaries_%s" % scope)
if not os.path.exists(summary_dir):
os.makedirs(summary_dir)
self.summary_writer = \
tf.summary.FileWriter(summary_dir)
else:
self.summary_writer = None
命令tf.summary.FileWriter在目标目录(summary_dir)中初始化一个事件文件,用于存储学习过程中的关键度量。该句柄保存在self.summary_writer中,我们稍后将使用它来存储我们希望在训练过程中以及训练后用于监控和调试学习情况的度量。
接下来定义的方法是我们将在此项目中使用的默认神经网络。作为输入,它接受输入层以及我们将使用的隐藏层的相应大小。输入层由我们使用的状态定义,状态可以是测量值的向量(如我们案例中的情况),或者是图像(如原始 DQN 论文中的情况)。
这样的层是通过 TensorFlow 的Layers模块提供的高级操作来定义的(www.tensorflow.org/api_guides/python/contrib.layers)。我们选择了基础的fully_connected层,使用ReLU(修正线性)激活函数用于两个隐藏层,输出层使用线性激活函数。
预定义的 32 的大小对于我们的目的来说是完全合适的,但如果你愿意,可以增加它。此外,网络中没有使用 dropout。显然,这里问题不在于过拟合,而是学习的质量,只有通过提供有用的、不相关的状态序列,并对最终的奖励做出良好的估计,才能改善学习质量。在有用的状态序列中,尤其是在探索与利用的权衡下,避免网络过拟合的关键所在。强化学习问题中,如果你陷入以下两种情况之一,就说明你的网络已经过拟合:
-
次优性:算法会建议次优的解决方案,也就是说,我们的着陆器学会了一种粗略的着陆方式,并坚持使用这种方式,因为至少它能成功着陆。
-
无助性:算法已经陷入了学到的无助状态,也就是说,它没有找到正确着陆的方法,因此它只是接受最不坏的方式去“撞击”。
这两种情况对于强化学习算法(如 DQN)来说,可能非常难以克服,除非该算法在游戏过程中能够有机会探索替代解决方案。偶尔采取随机动作,并非单纯的“搞乱事情”策略,正如你最初可能会认为的那样,而是一种避免陷阱的策略。
然而,对于比这个更大的网络,你可能会遇到“神经元死亡”问题,这时需要使用不同的激活函数,如tf.nn.leaky_relu(www.tensorflow.org/api_docs/python/tf/nn/leaky_relu),以便获得一个正常工作的网络。
死亡的ReLU最终总是输出相同的值,通常是零,并且它对反向传播更新变得抗拒。
激活函数 leaky_relu 从 TensorFlow 1.4 开始可用。如果你使用的是 TensorFlow 的较早版本,可以创建一个 ad hoc 函数,用于自定义网络:
def leaky_relu(x, alpha=0.2): return tf.nn.relu(x) - alpha * tf.nn.relu(-x)
现在我们继续编写 Brain 类的代码,为它添加一些更多的功能:
def two_layers_network(self, x, layer_1_nodes=32,
layer_2_nodes=32):
layer_1 = tf.contrib.layers.fully_connected(x, layer_1_nodes,
activation_fn=tf.nn.relu)
layer_2 = tf.contrib.layers.fully_connected(layer_1,
layer_2_nodes,
activation_fn=tf.nn.relu)
return tf.contrib.layers.fully_connected(layer_2, self.nA,
activation_fn=None)
create_network 方法结合了输入、神经网络、损失和优化。损失通过计算原始奖励与估计结果之间的差异,平方并计算批次中所有示例的平均值来创建。损失使用 Adam 优化器进行最小化。
此外,还记录了一些总结供 TensorBoard 使用:
-
批次的平均损失,用于跟踪训练过程中的拟合情况
-
批次中的最大预测奖励,用于跟踪极端的正向预测,指出最好的胜利动作
-
批次中的平均预测奖励,用于跟踪预测好动作的整体趋势
以下是 create_network 的代码,这是我们项目的 TensorFlow 引擎:
def create_network(self, network, learning_rate=0.0001):
# Placeholders for states input
self.X = tf.placeholder(shape=[None, self.nS],
dtype=tf.float32, name="X")
# The r target value
self.y = tf.placeholder(shape=[None, self.nA],
dtype=tf.float32, name="y")
# Applying the choosen network
self.predictions = network(self.X)
# Calculating the loss
sq_diff = tf.squared_difference(self.y, self.predictions)
self.loss = tf.reduce_mean(sq_diff)
# Optimizing parameters using the Adam optimizer
self.train_op = tf.contrib.layers.optimize_loss(self.loss,
global_step=tf.train.get_global_step(),
learning_rate=learning_rate,
optimizer='Adam')
# Recording summaries for Tensorboard
self.summaries = tf.summary.merge([
tf.summary.scalar("loss", self.loss),
tf.summary.scalar("max_q_value",
tf.reduce_max(self.predictions)),
tf.summary.scalar("mean_q_value",
tf.reduce_mean(self.predictions))])
该类通过 predict 和 fit 方法完成。fit 方法将状态矩阵 s 作为输入批次,将奖励向量 r 作为输出结果。它还考虑了训练的轮次数量(在原始论文中建议每个批次仅使用一个 epoch,以避免过拟合每个批次的观察数据)。然后,在当前会话中,输入会根据结果和总结(我们创建网络时已定义)进行拟合。
def predict(self, sess, s):
"""
Predicting q values for actions
"""
return sess.run(self.predictions, {self.X: s})
def fit(self, sess, s, r, epochs=1):
"""
Updating the Q* function estimator
"""
feed_dict = {self.X: s, self.y: r}
for epoch in range(epochs):
res = sess.run([self.summaries, self.train_op,
self.loss,
self.predictions,
tf.train.get_global_step()],
feed_dict)
summaries, train_op, loss, predictions,
self.global_step = res
if self.summary_writer:
self.summary_writer.add_summary(summaries,
self.global_step)
结果返回 global step,它是一个计数器,帮助跟踪到目前为止在训练中使用的示例数量,并记录以备后续使用。
为经验回放创建内存
在定义大脑(TensorFlow 神经网络)后,下一步是定义内存,这是存储数据的地方,这些数据将驱动 DQN 网络的学习过程。在每次训练的回合中,每一步(由一个状态和一个动作组成)都会被记录下来,并附上相应的状态和回合的最终奖励(这个奖励只有在回合结束时才能知道)。
添加一个标志,标明该观察是否为终止状态,完成了所记录信息的集合。这个想法是将某些动作与不仅是即时奖励(可能为零或适中),而是结束奖励相关联,从而将每个动作与该回合的最终奖励联系起来。
类 memory 只是一个固定大小的队列,然后用以前游戏经验的信息填充,且可以轻松从中进行采样和提取。由于其大小是固定的,因此很重要的一点是要将较旧的示例从队列中推除出去,从而确保可用示例始终是最近的那些。
该类包含一个初始化过程,其中数据结构起源并且其大小是固定的,len方法(以便我们知道内存是否已满,这在某些情况下非常有用,例如等待训练,至少等到我们有足够的数据进行更好的随机化和多样化学习),add_memory用于记录到队列中,以及recall_memory用于以列表格式从队列中恢复所有数据:
class Memory:
"""
A memory class based on deque, a list-like container with
fast appends and pops on either end (from the collections
package)
"""
def __init__(self, memory_size=5000):
self.memory = deque(maxlen=memory_size)
def __len__(self):
return len(self.memory)
def add_memory(self, s, a, r, s_, status):
"""
Memorizing the tuple (s a r s_) plus the Boolean flag status,
reminding if we are at a terminal move or not
"""
self.memory.append((s, a, r, s_, status))
def recall_memories(self):
"""
Returning all the memorized data at once
"""
return list(self.memory)
创建代理
下一个类是代理,负责初始化和维护大脑(提供Q 值函数近似)和记忆。代理还会在环境中执行动作。它的初始化设置了一系列参数,这些参数大部分是固定的,根据我们在优化《Lunar Lander》游戏学习中的经验得出的。不过,在代理首次初始化时,这些参数是可以显式更改的:
-
epsilon = 1.0是探索-利用参数的初始值。1.0的值强制代理完全依赖于探索,即随机移动。 -
epsilon_min = 0.01设置探索-利用参数的最小值:0.01的值意味着着陆舱有 1%的几率会随机移动,而不是基于Q函数的反馈。这始终提供了一个最小的机会来找到完成游戏的另一种最优方式,而不会影响游戏的进行。 -
epsilon_decay = 0.9994是调节epsilon向最小值衰减速度的衰减因子。在此设置中,它被调整为大约在 5,000 个回合后达到最小值,这样平均应能为算法提供至少 200 万次学习的样本。 -
gamma = 0.99是奖励折扣因子,通过它,Q 值估计将未来奖励相对于当前奖励的权重进行调整,从而使算法根据所玩的游戏类型可以短视或长视(在《Lunar Lander》中,最好是长视,因为实际的奖励只有在着陆舱成功着陆月球时才能体验)。 -
learning_rate = 0.0001是 Adam 优化器用于学习批量样本的学习率。 -
epochs = 1是神经网络用来拟合批量样本集的训练轮次。 -
batch_size = 32是批量样本的大小。 -
memory = Memory(memory_size=250000)是记忆队列的大小。
使用预设的参数,您可以确保当前项目能够正常运行。对于不同的 OpenAI 环境,您可能需要找到不同的最优参数。
初始化还将提供定义 TensorBoard 日志存放位置的命令(默认情况下为experiment目录),用于学习如何估算下一个即时奖励的模型,以及另一个用于存储最终奖励权重的模型。此外,还将初始化一个 saver(tf.train.Saver),允许将整个会话序列化到磁盘,以便稍后恢复并用于实际游戏的播放,而不仅仅是学习如何玩游戏。
这两个提到的模型在同一会话中初始化,使用不同的作用域名称(一个是q,用于监控 TensorBoard 的下一个奖励模型;另一个是target_q)。使用两个不同的作用域名称将方便神经元系数的处理,使得可以通过类中其他方法交换它们:
class Agent:
def __init__(self, nS, nA, experiment_dir):
# Initializing
self.nS = nS
self.nA = nA
self.epsilon = 1.0 # exploration-exploitation ratio
self.epsilon_min = 0.01
self.epsilon_decay = 0.9994
self.gamma = 0.99 # reward decay
self.learning_rate = 0.0001
self.epochs = 1 # training epochs
self.batch_size = 32
self.memory = Memory(memory_size=250000)
# Creating estimators
self.experiment_dir =os.path.abspath\
("./experiments/{}".format(experiment_dir))
self.global_step = tf.Variable(0, name='global_step',
trainable=False)
self.model = Brain(nS=self.nS, nA=self.nA, scope="q",
learning_rate=self.learning_rate,
global_step=self.global_step,
summaries_dir=self.experiment_dir)
self.target_model = Brain(nS=self.nS, nA=self.nA,
scope="target_q",
learning_rate=self.learning_rate,
global_step=self.global_step)
# Adding an op to initialize the variables.
init_op = tf.global_variables_initializer()
# Adding ops to save and restore all the variables.
self.saver = tf.train.Saver()
# Setting up the session
self.sess = tf.Session()
self.sess.run(init_op)
epsilon涉及用于探索新解决方案的时间比例与利用网络知识的时间比例,并通过epsilon_update方法不断更新,该方法通过将当前epsilon乘以epsilon_decay来修改epsilon,除非它已经达到了允许的最小值:
def epsilon_update(self, t):
if self.epsilon > self.epsilon_min:
self.epsilon *= self.epsilon_decay
save_weights和load_weights方法仅允许会话保存:
def save_weights(self, filename):
"""
Saving the weights of a model
"""
save_path = self.saver.save(self.sess,
"%s.ckpt" % filename)
print("Model saved in file: %s" % save_path)
def load_weights(self, filename):
"""
Restoring the weights of a model
"""
self.saver.restore(self.sess, "%s.ckpt" % filename)
print("Model restored from file")
set_weights和target_model_update方法协同工作,用于通过 Q 网络的权重更新目标 Q 网络(set_weights是一个通用的、可重用的函数,您也可以在自己的解决方案中使用)。由于我们给这两个作用域命名不同,所以很容易从可训练变量的列表中列出每个网络的变量。一旦列出,变量会被组合并通过运行中的会话执行赋值操作:
def set_weights(self, model_1, model_2):
"""
Replicates the model parameters of one
estimator to another.
model_1: Estimator to copy the parameters from
model_2: Estimator to copy the parameters to
"""
# Enumerating and sorting the parameters
# of the two models
model_1_params = [t for t in tf.trainable_variables() \
if t.name.startswith(model_1.scope)]
model_2_params = [t for t in tf.trainable_variables() \
if t.name.startswith(model_2.scope)]
model_1_params = sorted(model_1_params,
key=lambda x: x.name)
model_2_params = sorted(model_2_params,
key=lambda x: x.name)
# Enumerating the operations to be done
operations = [coef_2.assign(coef_1) for coef_1, coef_2 \
in zip(model_1_params, model_2_params)]
# Executing the operations to be done
self.sess.run(operations)
def target_model_update(self):
"""
Setting the model weights to the target model's ones
"""
self.set_weights(self.model, self.target_model)
act方法是策略实现的核心,因为它会根据epsilon的值决定是采取随机动作还是选择最佳可能的动作。如果选择最佳动作,它会请求训练好的 Q 网络为每个可能的下一个动作(在 Lunar Lander 游戏中通过按下四个按钮之一以二进制方式表示)提供奖励估计,并返回具有最大预测奖励的动作(这是一种贪婪方法):
def act(self, s):
"""
Having the agent act based on learned Q* function
or by random choice (based on epsilon)
"""
# Based on epsilon predicting or randomly
# choosing the next action
if np.random.rand() <= self.epsilon:
return np.random.choice(self.nA)
else:
# Estimating q for all possible actions
q = self.model.predict(self.sess, s)[0]
# Returning the best action
best_action = np.argmax(q)
return best_action
replay方法完成了该类。它是一个关键方法,因为它使得 DQN 算法的学习成为可能。因此,我们将详细讨论它的工作原理。replay方法做的第一件事是从之前游戏回合的记忆中采样一个批次(批次大小在初始化时定义)(这些记忆变量包含状态、动作、奖励、下一个状态以及一个标志变量,表示观察是否为最终状态)。随机采样使得模型能够通过批量调整网络权重,一步步地学习 Q 函数。
然后,该方法会检查采样回调的状态是否为终止状态。非终止奖励需要更新,以表示游戏结束时获得的奖励。这是通过使用目标网络来完成的,目标网络表示在上次学习结束时固定的Q函数网络的快照。目标网络输入以下状态,结果奖励在经过折扣因子 gamma 调整后,与当前奖励相加。
使用当前的Q函数可能会导致学习过程中的不稳定,并且可能无法得到令人满意的Q函数网络。
def replay(self):
# Picking up a random batch from memory
batch = np.array(random.sample(\
self.memory.recall_memories(), self.batch_size))
# Retrieving the sequence of present states
s = np.vstack(batch[:, 0])
# Recalling the sequence of actions
a = np.array(batch[:, 1], dtype=int)
# Recalling the rewards
r = np.copy(batch[:, 2])
# Recalling the sequence of resulting states
s_p = np.vstack(batch[:, 3])
# Checking if the reward is relative to
# a not terminal state
status = np.where(batch[:, 4] == False)
# We use the model to predict the rewards by
# our model and the target model
next_reward = self.model.predict(self.sess, s_p)
final_reward = self.target_model.predict(self.sess, s_p)
if len(status[0]) > 0:
# Non-terminal update rule using the target model
# If a reward is not from a terminal state,
# the reward is just a partial one (r0)
# We should add the remaining and obtain a
# final reward using target predictions
best_next_action = np.argmax(\
next_reward[status, :][0], axis=1)
# adding the discounted final reward
r[status] += np.multiply(self.gamma,
final_reward[status, best_next_action][0])
# We replace the expected rewards for actions
# when dealing with observed actions and rewards
expected_reward = self.model.predict(self.sess, s)
expected_reward[range(self.batch_size), a] = r
# We re-fit status against predicted/observed rewards
self.model.fit(self.sess, s, expected_reward,
epochs=self.epochs)
当非终止状态的奖励更新完成后,批量数据被输入到神经网络中进行训练。
指定环境
最后一个需要实现的类是Environment类。实际上,环境是由gym命令提供的,尽管你需要为它构建一个良好的包装器,以便它能与之前的agent类一起工作。这正是这个类所做的事情。在初始化时,它启动了月球着陆器游戏,并设置了关键变量,如nS、nA(状态和动作的维度)、agent以及累计奖励(用于通过提供过去 100 集的平均值来测试解决方案):
class Environment:
def __init__(self, game="LunarLander-v2"):
# Initializing
np.set_printoptions(precision=2)
self.env = gym.make(game)
self.env = wrappers.Monitor(self.env, tempfile.mkdtemp(),
force=True, video_callable=False)
self.nS = self.env.observation_space.shape[0]
self.nA = self.env.action_space.n
self.agent = Agent(self.nS, self.nA, self.env.spec.id)
# Cumulative reward
self.reward_avg = deque(maxlen=100)
然后,我们为test、train和incremental(增量训练)方法准备代码,这些方法被定义为综合learning方法的包装器。
使用增量训练有点棘手,如果你不想破坏到目前为止通过训练获得的结果,它需要一些关注。问题在于,当我们重新启动时,大脑有预训练的系数,但记忆实际上是空的(我们可以称之为冷启动)。由于代理的记忆为空,它无法支持良好的学习,因为示例太少且有限。因此,传入的示例质量实际上并不完美,学习的效果不好(这些示例大多是相互关联的,并且非常特定于那些新体验过的少数几集)。可以通过使用非常低的epsilon来降低破坏训练的风险(我们建议设置为最低值0.01):这样,网络大部分时间将仅仅重新学习自己的权重,因为它会为每个状态建议它已经知道的动作,并且它的表现不会恶化,而是以稳定的方式振荡,直到记忆中有足够的示例,网络才会开始再次改进。
以下是发出正确训练和测试方法的代码:
def test(self):
self.learn(epsilon=0.0, episodes=100,
trainable=False, incremental=False)
def train(self, epsilon=1.0, episodes=1000):
self.learn(epsilon=epsilon, episodes=episodes,
trainable=True, incremental=False)
def incremental(self, epsilon=0.01, episodes=100):
self.learn(epsilon=epsilon, episodes=episodes,
trainable=True, incremental=True)
最终方法是learn,它安排了代理与环境交互并从中学习的所有步骤。该方法接受epsilon值(因此会覆盖代理之前的epsilon值)、在环境中运行的集数、是否进行训练(布尔标志)以及训练是否从之前模型的训练继续(另一个布尔标志)。
在第一块代码中,方法加载了网络之前训练的权重,以进行 Q 值近似(如果我们需要的话)。
-
测试网络并查看其如何工作;
-
使用更多的示例继续进行之前的训练。
然后,方法深入到一个嵌套迭代中。外部迭代运行所需的回合数(每个回合一个 Lunar Lander 游戏的结束)。而内部迭代则是运行一个最多 1,000 步构成的回合。
在每个时间步的迭代中,神经网络会被询问下一步的动作。如果处于测试模式,它将始终简单地提供关于下一个最佳动作的答案。如果处于训练模式,根据epsilon的值,可能有一定的概率它不会推荐最佳动作,而是建议进行随机动作。
def learn(self, epsilon=None, episodes=1000,
trainable=True, incremental=False):
"""
Representing the interaction between the enviroment
and the learning agent
"""
# Restoring weights if required
if not trainable or (trainable and incremental):
try:
print("Loading weights")
self.agent.load_weights('./weights.h5')
except:
print("Exception")
trainable = True
incremental = False
epsilon = 1.0
# Setting epsilon
self.agent.epsilon = epsilon
# Iterating through episodes
for episode in range(episodes):
# Initializing a new episode
episode_reward = 0
s = self.env.reset()
# s is put at default values
s = np.reshape(s, [1, self.nS])
# Iterating through time frames
for time_frame in range(1000):
if not trainable:
# If not learning, representing
# the agent on video
self.env.render()
# Deciding on the next action to take
a = self.agent.act(s)
# Performing the action and getting feedback
s_p, r, status, info = self.env.step(a)
s_p = np.reshape(s_p, [1, self.nS])
# Adding the reward to the cumulative reward
episode_reward += r
# Adding the overall experience to memory
if trainable:
self.agent.memory.add_memory(s, a, r, s_p,
status)
# Setting the new state as the current one
s = s_p
# Performing experience replay if memory length
# is greater than the batch length
if trainable:
if len(self.agent.memory) > \
self.agent.batch_size:
self.agent.replay()
# When the episode is completed,
# exiting this loop
if status:
if trainable:
self.agent.target_model_update()
break
# Exploration vs exploitation
self.agent.epsilon_update(episode)
# Running an average of the past 100 episodes
self.reward_avg.append(episode_reward)
print("episode: %i score: %.2f avg_score: %.2f"
"actions %i epsilon %.2f" % (episode,
episode_reward,
np.average(self.reward_avg),
time_frame,
epsilon)
self.env.close()
if trainable:
# Saving the weights for the future
self.agent.save_weights('./weights.h5')
移动后,所有信息(初始状态、选择的动作、获得的奖励和后续状态)都会被收集并保存到内存中。在这个时间框架内,如果内存足够大以创建一个用于神经网络近似Q函数的批次,那么将执行训练过程。当本次回合的所有时间框架都已经消耗完毕,DQN 的权重会被存储到另一个网络中,作为稳定的参考,以便 DQN 网络在学习新一轮回合时使用。
运行强化学习过程
最后,在完成关于强化学习和 DQN 的所有讨论,并编写完整的项目代码后,你可以通过脚本或 Jupyter Notebook 运行它,利用将所有代码功能结合在一起的Environment类:
lunar_lander = Environment(game="LunarLander-v2")
在实例化之后,你只需运行train,从epsilon=1.0开始,并将目标设定为5000回合(相当于约 220 万个状态、动作和奖励的链变量示例)。我们提供的实际代码已经设定为成功完成一个完全训练的 DQN 模型,尽管这可能需要一些时间,具体取决于你的 GPU 的可用性和计算能力:
lunar_lander.train(epsilon=1.0, episodes=5000)
最终,类将完成所需的训练,并将保存的模型保存在磁盘上(可以随时运行或重新启动)。你甚至可以使用一个简单的命令来检查 TensorBoard,该命令可以从 shell 运行:
tensorboard --logdir=./experiments --port 6006
图表将出现在浏览器中,可以在本地地址localhost:6006进行查看:

图 4:训练中的损失趋势,峰值代表学习中的瓶颈,例如在 80 万示例时。
当它成功安全着陆时。
损失图表将显示,尽管与其他项目不同,优化过程仍然表现为损失逐渐减少,但在过程中会出现许多峰值和问题:
这里表示的图表是运行项目一次的结果。由于过程中的随机成分,你在自己计算机上运行项目时可能会得到略有不同的图表。

图 5:在批量学习会话中获得的最大 q 值趋势
最大预测的q值和平均预测的q值讲述了同样的故事。网络在最后有所改善,尽管它可能会略微回溯并在平台上停留较长时间:

图 6:在批量学习会话中获得的平均 q 值趋势
只有在你计算最后 100 个最终奖励的平均值时,才能看到一个渐进的路径,暗示 DQN 网络持续稳定的改进:

图 7:每次学习结束时实际获得的分数趋势,更清晰地描绘了 DQN 能力的增长
使用相同的信息,从输出中,而不是从 TensorBoard 中,你也会发现,所需的动作数量平均取决于epsilon值。一开始,完成一集所需的动作数不到 200。突然,当epsilon为0.5时,所需的平均动作数稳定增长,并在约 750 时达到峰值(着陆舱学会了通过使用火箭来对抗重力)。
最终,网络发现这是一种次优策略,当epsilon下降到0.3以下时,完成一集所需的平均动作数也有所下降。在这个阶段,DQN 正在发现如何以更高效的方式成功地着陆舱:

图 8:epsilon(探索/利用率)与 DQN 网络效率之间的关系
以完成一集所使用的移动次数表示
如果由于某种原因,你认为网络需要更多的示例和学习,你可以通过增量method重新开始学习,记住在这种情况下epsilon应该非常低:
lunar_lander.incremental(episodes=25, epsilon=0.01)
训练结束后,如果你需要查看结果并了解 DQN 在每 100 集中的平均得分(理想目标是score >=200),你只需运行以下命令:
lunar_lander.test()
致谢
在这个项目的结尾,我们确实要感谢 Peter Skvarenina,他的项目“Lunar Lander II”(www.youtube.com/watch?v=yiAmrZuBaYU)是我们自己项目的主要灵感来源,并感谢他在制作我们自己版本的深度 Q 网络时提供的所有建议和提示。
总结
在这个项目中,我们探讨了强化学习算法在 OpenAI 环境中能够实现的目标,并且我们编写了一个 TensorFlow 图,能够学习如何估算一个由代理、状态、动作和相应奖励所构成的环境中的最终奖励。这个方法叫做 DQN,旨在通过神经网络方法来近似贝尔曼方程的结果。最终的结果是一个“月球着陆者”游戏,软件在训练结束后能够成功地通过读取游戏状态并随时决定采取正确的行动来玩这个游戏。

是全局偏差
是物品
的偏差(在 Netflix 的情况下——电影)
是用户的偏差 
是用户向量
和物品向量
之间的内积




- 都位于
— 都位于
浙公网安备 33010602011771号