Python-机器学习蓝图第二版-全-

Python 机器学习蓝图第二版(全)

原文:annas-archive.org/md5/da2aba0c7496af58c8b1b0c62d741e1a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

机器学习正在改变我们理解和与周围世界互动的方式。本书是你将知识和技能付诸实践的完美指南,使用 Python 生态系统来涵盖机器学习的关键领域。本书的第二版涵盖了 Python 生态系统中的多个库,包括 TensorFlow 和 Keras,帮助你实现现实世界中的机器学习项目。

本书从给你提供 Python 机器学习概述开始。在复杂数据集和优化技术的帮助下,你将学习如何将先进的概念和流行的机器学习算法应用到实际项目中。接下来,你将学习一些领域的项目,如通过预测分析来分析股市,和为 GitHub 仓库构建推荐系统。除此之外,你还将从自然语言处理(NLP)领域着手,使用诸如 scikit-learn、TensorFlow 和 Keras 等框架来创建自定义新闻订阅源。接下来,你将学习如何构建一个高级聊天机器人,并使用 PySpark 扩展项目。最后的章节将为你提供关于深度学习的精彩见解,甚至可以创建一个基于计算机视觉和神经网络的应用程序。

到本书结尾时,你将能够无缝分析数据,并通过你的项目产生强大的影响力。

本书适合读者

本书适合机器学习从业者、数据科学家和深度学习爱好者,帮助你通过构建实际项目,将你的机器学习技能提升到一个新层次。本书是一本中级指南,旨在帮助你利用 Python 生态系统中的库,构建涵盖各个机器学习领域的各种项目。

本书内容涵盖

第一章,Python 机器学习生态系统,讨论了关键库的功能,并解释了如何准备环境以最大限度地利用它们。

第二章,构建一个寻找低价公寓的应用程序,讲解如何创建一个机器学习应用程序,使得寻找合适的公寓变得更容易。

第三章,构建一个寻找廉价机票的应用程序,介绍了如何构建一个不断监控票价的应用程序,检查异常价格并生成警报,便于我们迅速采取行动。

第四章,使用逻辑回归预测 IPO 市场,更详细地了解 IPO 市场。我们将学习如何利用机器学习帮助我们决定哪些 IPO 值得关注,哪些可能不值得投资。

第五章,创建自定义新闻订阅源,讲解如何构建一个理解你新闻偏好的系统,并每天向你发送量身定制的新闻简报。

第六章,预测你的内容是否会病毒式传播,试图解开其中的一些谜团。我们将分析一些最常见的分享内容,并尝试找出与人们不愿意分享的内容之间的共同点。

第七章,使用机器学习预测股市,讨论了如何构建和测试交易策略。我们将花更多的时间讲解如何进行操作。

第八章,使用卷积神经网络进行图像分类,详细介绍了使用深度学习创建计算机视觉应用的过程。

第九章,构建聊天机器人,解释了如何从零开始构建一个聊天机器人。在此过程中,我们将更多地了解这个领域的历史以及它的未来前景。

第十章,构建推荐引擎,探索了不同种类的推荐系统。我们将看看它们是如何在商业中实施的,以及它们的工作原理。最后,我们将实现一个用于查找 GitHub 仓库的推荐引擎。

第十一章,接下来做什么?,总结了本书迄今为止的内容,并讨论了接下来的步骤。你将学会如何将所学技能应用到其他项目中,解决在构建和部署机器学习模型过程中遇到的实际挑战,以及数据科学家常用的其他技术。

为了最大限度地从本书中获益

对 Python 编程和机器学习概念的了解将会有所帮助。

下载示例代码文件

你可以从 www.packt.com 登录你的账户,下载本书的示例代码文件。如果你是从其他地方购买的这本书,可以访问 www.packt.com/support 注册,并直接将文件通过邮件发送给你。

你可以按照以下步骤下载代码文件:

  1. www.packt.com 登录或注册。

  2. 选择 SUPPORT 标签。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书名并按照屏幕上的指示操作。

一旦文件下载完成,请确保使用以下最新版本的工具解压或提取文件夹:

  • Windows 系统的 WinRAR/7-Zip

  • Mac 系统的 Zipeg/iZip/UnRarX

  • Linux 系统的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Python-Machine-Learning-Blueprints-Second-Edition。如果代码有更新,它将会在现有的 GitHub 仓库中进行更新。

我们还从丰富的书籍和视频目录中提供了其他的代码包,访问 github.com/PacktPublishing/,来看看吧!

下载彩色图片

我们还提供了一份 PDF 文件,其中包含本书中使用的屏幕截图/图示的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781788994170_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 句柄。这里有一个例子:"让我们看一个示例互动,使用 requests 从 GitHub 的 API 拉取数据。在这里,我们将调用 API 并请求用户的 starred 仓库列表。"

任何命令行输入或输出都按以下方式书写:

import requests 

r = requests.get(r"https://api.github.com/users/acombs/starred") 

r.json() 

Bold: 指示新术语、重要词汇或屏幕上看到的词语。例如,菜单或对话框中的文字会像这样显示在文本中。这里有一个例子:"对于 Chrome,前往 Google 应用商店,查找 Extensions 部分。"

警告或重要提示会以此方式显示。

小贴士和技巧会以此方式显示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并发送邮件至 customercare@packtpub.com

勘误:尽管我们已经尽了最大努力确保内容的准确性,但错误难免发生。如果您在本书中发现错误,请向我们报告。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表格链接,并填写详细信息。

盗版:如果您在互联网上发现我们作品的任何非法副本,请提供给我们该位置地址或网站名称。请发送邮件至 copyright@packt.com 并附上链接。

如果您有兴趣成为作者:如果您在某个专业领域有专长,并且有意编写或为书籍做贡献,请访问 authors.packtpub.com

评论

请留下您的评论。一旦您阅读并使用了本书,请在您购买的网站上留下您的评价。潜在的读者可以通过您公正的意见来做出购买决策,Packt 能够了解您对我们产品的看法,而我们的作者也能看到您对他们书籍的反馈。谢谢!

欲了解更多关于 Packt 的信息,请访问 packt.com

第一章:Python 机器学习生态系统

机器学习正在迅速改变我们的世界。作为人工智能的核心,几乎每天我们都会看到有关它将引领我们进入类似奇点的技术乌托邦,或是某种全球性《银翼杀手》式噩梦场景的讨论。虽然评论员可能喜欢讨论这些夸张的未来,但更为平凡的现实是,机器学习正迅速成为我们日常生活的一部分。通过在我们与计算机及周围世界互动方式中的微妙但逐步改进,机器学习正在逐步改善我们的生活。

如果你在亚马逊等在线零售商购物,使用 Spotify 或 Netflix 等流媒体音乐或电影服务,或者甚至只是进行过 Google 搜索,那么你已经遇到了一个利用机器学习的应用程序。这些服务收集了大量数据——其中很多来自用户——并用这些数据来构建改善用户体验的模型。

现在是深入开发机器学习应用程序的理想时机,正如你将发现的那样,Python 是开发机器学习应用程序的理想选择。Python 拥有一个深厚且活跃的开发者社区,其中许多人根植于科学界。这一遗产为 Python 提供了无与伦比的科学计算库阵列。在本书中,我们将讨论并使用 Python 科学栈 中的若干库。

在接下来的章节中,我们将一步一步地学习如何构建各种机器学习应用程序。然而,在我们正式开始之前,本章的其余部分将讨论这些关键库的特性,以及如何准备你的环境以最佳方式利用它们。

本章将涵盖以下主题:

  • 数据科学/机器学习工作流

  • 每个工作流阶段的库

  • 设置你的环境

数据科学/机器学习工作流

构建机器学习应用程序在许多方面与标准工程范式类似,但有一个关键的不同点:需要将数据作为原材料来处理。你的项目成功与否,主要取决于你获得的数据质量,以及你对这些数据的处理方式。而且,因为数据处理属于数据科学领域,理解数据科学工作流会很有帮助:

数据科学工作流

该过程包括以下六个步骤,按顺序进行:

  1. 获取

  2. 检查

  3. 准备

  4. 建模

  5. 评估

  6. 部署

在许多情况下,需要回到前一步骤,比如检查和准备数据,或评估和建模,但在高层次上,这一过程可以按前述列表描述的顺序进行。

现在我们来详细讨论每一步。

获取

机器学习应用的数据可以来自许多来源;它可能通过电子邮件发送给你作为 CSV 文件,可能来自提取服务器日志,或者可能需要构建一个自定义的网页爬虫。数据也可能以各种格式存在。在大多数情况下,你将处理基于文本的数据,但正如我们将看到的,机器学习应用也可以轻松地处理图像或视频文件。无论数据格式如何,一旦你获得了数据,理解数据中包含的内容以及未包含的内容至关重要。

检查

一旦你获取了数据,下一步就是检查它。在这一阶段的主要目标是对数据进行合理性检查,而完成此目标的最佳方法是寻找那些不可能或极不可能出现的情况。例如,如果数据中有唯一标识符,检查是否确实只有一个;如果数据是基于价格的,检查它是否始终为正数;无论数据类型如何,检查最极端的情况。它们是否有意义?一个好的做法是对数据进行一些简单的统计测试,并进行可视化。你的模型结果好坏取决于输入的数据,因此,确保这个步骤的正确性至关重要。

准备

当你确信数据已经整理好后,接下来需要将数据准备成适合建模的格式。这个阶段包括多个过程,如过滤、聚合、填充和转换。你需要采取的操作将高度依赖于你所处理的数据类型,以及你将使用的库和算法。例如,如果你正在处理基于自然语言的文本,所需的转换将与处理时间序列数据所需的转换大不相同。在本书中,我们将看到这些类型转换的多个实例。

建模

数据准备完成后,下一阶段是建模。在这里,你将选择一个合适的算法,并利用数据来训练模型。在这一阶段有许多最佳实践需要遵循,我们将详细讨论它们,但基本步骤包括将数据分为训练集、测试集和验证集。将数据拆分可能看起来不合逻辑——尤其是当更多数据通常会带来更好的模型时——但正如我们将看到的那样,这样做可以让我们更好地评估模型在现实世界中的表现,并防止我们犯下建模中的最大错误:过拟合。我们将在后面的章节中详细讨论这一点。

评估

现在你有了一个崭新的模型,但这个模型到底有多好呢?这是评估阶段试图回答的问题。评估模型性能有很多方法,这在很大程度上取决于你使用的数据类型和模型类型,但总的来说,我们试图回答的问题是:模型的预测与实际值有多接近。这里有一些听起来令人困惑的术语,比如均方根误差、欧几里得距离或 F1 得分。但最终,它们都只是衡量实际预测与估算预测之间距离的标准。

部署

一旦你对模型的性能感到满意,你就可能想要部署它。根据使用场景,部署可能有很多种形式,常见的情况包括作为另一个更大应用程序中的一个功能、定制的 Web 应用程序,甚至只是一个简单的定时任务。

数据科学工作流程中每个阶段的 Python 库和函数

现在你已经理解了数据科学工作流程中的每个步骤,我们将看看一些在每个步骤中有用的 Python 库和库中的函数。

获取

由于访问数据的常见方式之一是通过 RESTful API,你需要了解的一个库是 Python Requests 库,www.python-requests.org/en/latest/。被称为人类的 HTTP,它使与 API 的交互变得简洁而简单。

让我们通过一个示例交互来看看,使用requests从 GitHub 的 API 中获取数据。在这里,我们将调用 API 并请求某个用户的收藏仓库列表:

import requests r = requests.get(r"https://api.github.com/users/acombs/starred") r.json() 

这将返回该用户所有收藏仓库的 JSON 数据,并附带每个仓库的属性。以下是前述调用的输出片段:

返回所有仓库的 JSON 时的输出片段

requests库功能强大—它的功能多到这里无法一一介绍,但我建议你查看文档以了解更多。

检查

由于检查数据在机器学习应用开发中的关键作用,我们将深入探讨几种能帮助你完成这项任务的优秀库。

Jupyter Notebook

有许多库可以简化数据检查过程。第一个是 Jupyter Notebook 配合 IPython (ipython.org/)。这是一种完全成熟的互动计算环境,非常适合数据探索。与大多数开发环境不同,Jupyter Notebook 是一个基于网页的前端(与 IPython 内核连接),它将内容划分为独立的代码块或单元格。单元格可以根据需要单独运行,也可以一次性运行所有单元格。这使得开发人员可以运行一个场景,查看输出,然后回过头调整代码,看到相应的变化——所有这些都无需离开笔记本。以下是在 Jupyter Notebook 中的一个示例交互:

Jupyter Notebook 中的示例交互

你会注意到,在这里我们做了很多事情,不仅与 IPython 后端交互,还与终端 shell 进行了互动。在这里,我导入了 Python 的 os 库,并调用它来查找当前的工作目录(单元格 #2),你可以看到输出在我的输入代码单元下方。然后我在单元格 #3 使用 os 库更改了目录,但在单元格 #4 中停止使用 os 库,转而开始使用基于 Linux 的命令。这是通过在单元格前加上 ! 来实现的。在单元格 #6 中,你可以看到我甚至将 shell 输出保存到了 Python 变量 (file_two) 中。这是一个非常棒的功能,它使得文件操作变得简单。

请注意,结果在你的机器上会有所不同,因为它显示的是运行时的用户信息。

现在,让我们看看使用笔记本进行的一些简单数据操作。这也是我们首次接触到另一个不可或缺的库——pandas。

Pandas

Pandas 是一个出色的数据分析工具,旨在成为任何语言中最强大、最灵活的开源数据分析/处理工具。正如你很快会看到的那样,如果它目前还没有完全实现这一目标,那也不远了。现在让我们来看看:

导入鸢尾花数据集

从前面的截图中你可以看到,我已经导入了一个经典的机器学习数据集——iris 数据集(也可以在 archive.ics.uci.edu/ml/datasets/Iris 找到),使用了 scikit-learn 这个我们稍后会详细讨论的库。接着,我将数据传入了一个 pandas DataFrame,并确保为其分配了列标题。一个 DataFrame 包含了花卉测量数据,另一个 DataFrame 包含了代表 iris 物种的数字。这些数字分别编码为 012,分别对应 setosaversicolorvirginica。接着,我将两个 DataFrame 合并在一起。

对于能够在单台机器上运行的数据集,pandas 是终极工具;你可以把它想象成是超级增强版的 Excel。而且,像流行的电子表格程序一样,操作的基本单元是数据的列和行,它们构成了表格。在 pandas 的术语中,数据列是 Series,表格是 DataFrame。

使用我们之前加载的相同的 iris DataFrame,接下来我们来看几个常见操作,包括以下内容:

第一个操作只是使用 .head() 命令来获取前五行。第二个命令是通过列名称引用来从 DataFrame 中选择单列。我们进行这种数据切片的另一种方式是使用 .iloc[row,column].loc[row,column] 语法。前者使用数字索引对列和行进行切片(位置索引),而后者对行使用数字索引,但允许使用命名列(基于标签的索引)。

让我们使用 .iloc 语法选择前两列和前四行。接着,我们再看一下 .loc 语法:

使用 .iloc 语法和 Python 列表切片语法,我们能够选择这个 DataFrame 的一个切片。

现在,让我们尝试一些更高级的内容。我们将使用列表迭代器来选择仅包含宽度特征的列:

我们在这里做的是创建一个包含所有列子集的列表。df.columns 返回所有列的列表,我们的迭代使用条件语句仅选择标题中包含 width 的列。显然,在这种情况下,我们本来也可以直接将想要的列名列出,但这给了你一个在处理更大数据集时可用的强大功能的感觉。

我们已经看到如何根据位置选择切片,但现在让我们看看另一种选择数据的方法。这一次,我们将根据满足我们指定的条件来选择数据的一个子集:

  1. 现在,让我们来看一下可用的species独特列表,并选择其中一个:

  1. 在最右侧的列中,你会注意到我们的 DataFrame 现在只包含 Iris-virginica 物种的数据(由 2 表示)。实际上,DataFrame 的大小现在是 50 行,比原始的 150 行少了:

  1. 你还可以看到,左侧的索引保留了原始的行号。如果我们想仅保存这些数据,我们可以将其保存为一个新的 DataFrame,并按以下图示重置索引:

  1. 我们通过在一列上设置条件来选择数据;现在,让我们添加更多的条件。我们将返回到原始的 DataFrame,并添加两个条件:

现在,DataFrame 只包含 virginica 物种且花瓣宽度大于 2.2 的数据。

接下来,我们使用 pandas 来快速获取 iris 数据集的一些描述性统计数据:

通过调用 .describe() 函数,我得到了相关列的描述性统计信息的详细数据。(注意,物种列被自动移除,因为它与此无关。)如果需要更详细的信息,我也可以传入自己的百分位数:

接下来,让我们检查这些特征之间是否存在任何相关性。这可以通过在 DataFrame 上调用 .corr() 来完成:

默认情况下返回每一行列对的皮尔逊相关系数。你可以通过传入方法参数(例如 .corr(method="spearman").corr(method="kendall"))将其切换为肯德尔τ系数斯皮尔曼等级相关系数

可视化

到目前为止,我们已经学习了如何从 DataFrame 中选择部分数据,并如何获取数据的总结统计信息,但接下来我们要学习如何进行数据的可视化检查。但首先,为什么要进行可视化检查呢?我们来看一个例子,理解一下为什么这么做。

以下是四个不同的 xy 值系列的总结统计数据:

x 和 y 的系列
x 的均值 9
y 的均值 7.5
x 的样本方差 11
y 的样本方差 4.1
xy 之间的相关性 0.816
回归线 y = 3.00 + 0.500 x

基于这些系列具有相同的总结统计数据,你可能会假设这些系列在视觉上会相似。当然,你会错的,非常错。这四个系列是安斯科姆四重奏的一部分,它们故意被创建用来说明数据可视化检查的重要性。每个系列的图形如下:

很明显,在可视化这些数据集后,我们不会将它们视为相同的数据集。因此,现在我们已经理解了可视化的重要性,接下来我们来看看一对有用的 Python 库。

matplotlib 库

我们首先要看的库是matplotlibmatplotlib库是 Python 绘图库的核心。最初为了模拟 MATLAB 的绘图功能而创建,它逐渐发展成了一个功能完备的库,拥有广泛的功能。如果你没有 MATLAB 背景,可能很难理解所有组件如何协同工作以生成你看到的图表。我会尽力将这些部分拆解成逻辑组件,以帮助你快速上手。在全面深入matplotlib之前,让我们先设置我们的 Jupyter Notebook,以便在其中查看图表。为此,请在你的import语句中添加以下几行:

import matplotlib.pyplot as plt 
plt.style.use('ggplot') 
%matplotlib inline 

第一行导入了matplotlib,第二行将样式设置为近似 R 的ggplot库(需要 matplotlib 1.41 或更高版本),最后一行设置图表使其能够在笔记本中显示。

现在,让我们使用我们的iris数据集生成第一个图表:

fig, ax = plt.subplots(figsize=(6,4)) 
ax.hist(df['petal width (cm)'], color='black'); 
ax.set_ylabel('Count', fontsize=12) 
ax.set_xlabel('Width', fontsize=12) 
plt.title('Iris Petal Width', fontsize=14, y=1.01) 

上面的代码生成了以下输出:

即使在这个简单的示例中,也有很多内容,但我们会逐行解析。第一行创建了一个宽度为6英寸、高度为4英寸的单个子图。接下来,我们通过调用.hist()并传入数据,绘制了我们iris数据框中花瓣宽度的直方图。我们还在这里将柱状图的颜色设置为黑色。接下来的两行分别为我们的y轴和x轴添加了标签,最后一行设置了图表的标题。我们通过y参数调整标题相对于图表顶部的位置,并略微增大字体大小。这样我们就得到了一个漂亮的花瓣宽度数据直方图。现在让我们扩展这个图表,生成我们iris数据集每一列的直方图:

fig, ax = plt.subplots(2,2, figsize=(6,4)) 

ax[0][0].hist(df['petal width (cm)'], color='black'); 
ax[0][0].set_ylabel('Count', fontsize=12) 
ax[0][0].set_xlabel('Width', fontsize=12) 
ax[0][0].set_title('Iris Petal Width', fontsize=14, y=1.01) 

ax[0][1].hist(df['petal length (cm)'], color='black'); 
ax[0][1].set_ylabel('Count', fontsize=12) 
ax[0][1].set_xlabel('Length', fontsize=12) 
ax[0][1].set_title('Iris Petal Length', fontsize=14, y=1.01) 

ax[1][0].hist(df['sepal width (cm)'], color='black'); 
ax[1][0].set_ylabel('Count', fontsize=12) 
ax[1][0].set_xlabel('Width', fontsize=12) 
ax[1][0].set_title('Iris Sepal Width', fontsize=14, y=1.01) 

ax[1][1].hist(df['sepal length (cm)'], color='black'); 
ax[1][1].set_ylabel('Count', fontsize=12) 
ax[1][1].set_xlabel('Length', fontsize=12) 
ax[1][1].set_title('Iris Sepal Length', fontsize=14, y=1.01) 

plt.tight_layout() 

上面代码的输出如下图所示:

显然,这不是编写代码的最有效方式,但它对于演示matplotlib的工作原理非常有用。注意,和我们在第一个示例中使用的单一子图对象ax不同,现在我们有了四个子图,这些子图通过现在的ax数组来访问。代码中的新添加部分是对plt.tight_layout()的调用;这个函数会自动调整子图的间距,以避免重叠。

现在让我们来看看matplotlib中可用的几种其他类型的图表。一种有用的图表是散点图。在这里,我们将绘制花瓣宽度与花瓣长度的关系:

fig, ax = plt.subplots(figsize=(6,6)) 
ax.scatter(df['petal width (cm)'],df['petal length (cm)'],                      color='green') 
ax.set_xlabel('Petal Width') 
ax.set_ylabel('Petal Length') 
ax.set_title('Petal Scatterplot') 

上面的代码生成了以下输出:

如前所述,我们可以添加多个子图来检查每个方面。

我们可以检查的另一个图表是一个简单的折线图。在这里,我们将查看花瓣长度的图表:

fig, ax = plt.subplots(figsize=(6,6)) 
ax.plot(df['petal length (cm)'], color='blue') 
ax.set_xlabel('Specimen Number') 
ax.set_ylabel('Petal Length') 
ax.set_title('Petal Length Plot') 

上面的代码生成了以下输出:

我们已经可以通过这个简单的折线图看到,每个物种的花瓣长度都有明显的聚类——记得我们的样本数据集每种类型有 50 个有序示例。这告诉我们,如果我们要构建一个分类器,花瓣长度可能是一个有用的特征,可以用来区分这些物种。

让我们看一下 matplotlib 库中的最后一种图表类型——柱状图。这可能是你最常见的一种图表。这里,我们将为三种鸢尾花的每个特征绘制柱状图,并为了使图表更有趣,我们将其制作成堆叠柱状图,并添加一些额外的 matplotlib 特性:

import numpy as np
fig, ax = plt.subplots(figsize=(6,6))
bar_width = .8
labels = [x for x in df.columns if 'length' in x or 'width' in x]
set_y = [df[df['species']==0][x].mean() for x in labels]
ver_y = [df[df['species']==1][x].mean() for x in labels]
vir_y = [df[df['species']==2][x].mean() for x in labels]
x = np.arange(len(labels))
ax.bar(x, set_y, bar_width, color='black')
ax.bar(x, ver_y, bar_width, bottom=set_y, color='darkgrey')
ax.bar(x, vir_y, bar_width, bottom=[i+j for i,j in zip(set_y, ver_y)], color='white')
ax.set_xticks(x + (bar_width/2))
ax.set_xticklabels(labels, rotation=-70, fontsize=12);
ax.set_title('Mean Feature Measurement By Species', y=1.01)
ax.legend(['Setosa','Versicolor','Virginica'])   

上述代码片段的输出结果如下:

要生成柱状图,我们需要将 xy 值传递给 .bar() 函数。在这种情况下,x 值将只是我们感兴趣的特征长度的数组——这里是四个,或者说是我们 DataFrame 中每一列的一个值。np.arange() 函数是生成此数组的简便方法,但我们也几乎可以手动输入这个数组。由于我们不希望 x 轴显示为从 1 到 4,我们调用 .set_xticklabels() 函数并传入我们希望显示的列名。为了正确对齐 x 标签,我们还需要调整标签的间距。这就是为什么我们将 xticks 设置为 x 加上 bar_width 大小的一半,我们之前也将其设置为 0.8y 值来自于每个特征在每个物种上的均值。然后我们通过调用 .bar() 来绘制每一组数据。需要注意的是,我们为每个系列传入了一个 bottom 参数,它设置了该系列的最小 y 值和下面系列的最大 y 值。这就创建了堆叠的柱状图。最后,我们添加了一个图例,说明了每个系列。图例中的名称按从上到下的顺序插入。

Seaborn 库

接下来我们要查看的可视化库叫做 seaborn,(seaborn.pydata.org/index.html)。它是一个专为统计可视化创建的库。事实上,它非常适合与 pandas DataFrame 一起使用,其中列是特征,行是观察值。这种形式的 DataFrame 被称为 tidy 数据,是机器学习应用中最常见的数据形式。

现在让我们来看看 seaborn 的强大功能:

import seaborn as sns 
sns.pairplot(df, hue='species') 

仅凭这两行代码,我们就能得到以下结果:

Seaborn 图

在详细介绍了matplotlib的复杂细节之后,你会立刻体会到我们生成这个图表的简便性。我们只用了两行代码,就把所有特征都绘制并正确标注了。你可能会想,我是不是浪费了几十页的篇幅教你matplotlib,而seaborn却能让这些类型的可视化变得如此简单。其实并非如此,因为seaborn是建立在matplotlib之上的。事实上,你可以利用你学到的所有matplotlib知识来修改和使用seaborn。让我们来看看另一个可视化:

fig, ax = plt.subplots(2, 2, figsize=(7, 7)) 
sns.set(style='white', palette='muted') 
sns.violinplot(x=df['species'], y=df['sepal length (cm)'], ax=ax[0,0]) sns.violinplot(x=df['species'], y=df['sepal width (cm)'], ax=ax[0,1]) sns.violinplot(x=df['species'], y=df['petal length (cm)'], ax=ax[1,0]) sns.violinplot(x=df['species'], y=df['petal width (cm)'], ax=ax[1,1]) fig.suptitle('Violin Plots', fontsize=16, y=1.03) 
for i in ax.flat:
 plt.setp(i.get_xticklabels(), rotation=-90) 
fig.tight_layout() 

上面的代码生成了以下输出:

小提琴图

在这里,我们为四个特征生成了小提琴图。小提琴图展示了特征的分布。例如,你可以很容易地看到setosa(0)的花瓣长度高度集中在 1 厘米到 2 厘米之间,而virginica(2)的花瓣长度则更加分散,从接近 4 厘米到超过 7 厘米。你还会注意到,我们使用了与构建matplotlib图形时大部分相同的代码。主要的区别在于我们用ax.plot()替换成了sns.plot()。我们还在所有子图上方添加了一个标题,而不是在每个子图上单独添加标题,这是通过fig.suptitle()函数实现的。另一个值得注意的变化是迭代每个子图以改变xticklabels的旋转角度。我们调用了ax.flat(),然后迭代每个子图轴,使用.setp()设置特定属性。这避免了我们像之前的matplotlib子图代码那样,必须单独输入ax[0][0]...ax[1][1]并设置属性。

使用matplotlibseaborn,你可以生成数百种不同风格的图表,我强烈建议你深入研究这两个库的文档——那将是值得的时间——不过我在前一节中详细介绍的图表应该能够帮助你很大程度地理解你的数据集,而这将有助于你在构建机器学习模型时。

准备工作

我们已经学到了很多关于检查我们拥有的数据的知识,现在让我们继续学习如何处理和操作这些数据。在这里,我们将学习pandas.map().apply().applymap().groupby()函数。这些函数在数据处理中非常宝贵,尤其在机器学习中的特征工程中极为重要,我们将在后续章节中详细讨论这一概念。

map

现在我们将从map函数开始。map函数适用于序列,所以在我们的例子中,我们将使用它来转换 DataFrame 中的一列,正如你所记得,它实际上是一个 pandas 序列。假设我们决定物种编号不适合我们的需求,我们将使用带有 Python 字典作为参数的map函数来完成这个任务。我们会为每个独特的iris类型传递一个替代值:

让我们来看一下我们所做的事情。我们对现有的species列中的每个值都应用了map函数。当每个值在 Python 字典中找到时,它会被添加到返回的序列中。我们将这个返回序列赋给了相同的species名称,因此它替换了我们原始的species列。如果我们选择了一个不同的名称,比如short code,那么那一列会被附加到 DataFrame 中,之后我们将拥有原始的species列以及新的short code列。

我们本可以将map函数传递给一个序列或函数,来对某一列进行转换,但这种功能也可以通过apply函数实现,我们接下来将了解它。字典功能是map函数特有的,且选择map而非apply进行单列转换的最常见原因。现在,让我们来看看apply函数。

apply

apply函数允许我们同时处理 DataFrame 和序列。我们将从一个可以同样适用于map的例子开始,然后再介绍一些只适用于apply的例子。

使用我们的iris DataFrame,让我们基于花瓣宽度创建一个新列。我们之前看到,花瓣宽度的均值是1.3。现在,让我们在 DataFrame 中创建一个新列wide petal,该列基于petal width列的值包含二进制值。如果petal width大于或等于中位数,我们将其编码为1,如果小于中位数,我们将其编码为0。我们将使用apply函数对petal width列执行这个操作:

这里发生了几件事情,我们一步步来分析。首先,我们能够通过使用列选择语法来简单地将新列附加到 DataFrame 中,这里是wide petal。我们将新列设置为apply函数的输出。在这里,我们对petal width列运行了apply,并返回了对应的wide petal列的值。apply函数通过遍历petal width列中的每个值来工作。如果该值大于或等于1.3,函数返回1,否则返回0。这种类型的转换是机器学习中常见的特征工程转换,因此熟悉如何执行它是很有用的。

接下来我们将演示如何在DataFrame上使用apply,而不是单个系列。我们将基于花瓣面积创建一个新特征:

创建一个新特征

请注意,我们在这里调用apply时不是对单个系列进行操作,而是对整个DataFrame进行操作。因为apply是对整个DataFrame调用的,所以我们传入了axis=1,以告知 pandas 我们希望按行应用该函数。如果传入axis=0,则函数会按列操作。在这里,每一列会被顺序处理,我们选择将petal length (cm)petal width (cm)两列的值相乘,结果系列成为petal area列。这种灵活性和强大功能正是 pandas 成为数据处理不可或缺工具的原因。

applymap

我们已经讨论过如何操作列并解释了如何处理行,但假设你想在DataFrame的所有数据单元格上执行一个函数。这个时候,applymap就是正确的工具。让我们看一个例子:

使用 applymap 函数

在这里,我们对DataFrame调用了applymap,以便获取每个值的对数(np.log()利用 NumPy 库返回此值),前提是该值是浮动类型。这个类型检查可以防止在specieswide petal列(分别是字符串和整数值)上返回错误或浮动类型。applymap的常见用法包括根据满足一系列条件对每个单元格进行转换或格式化。

groupby

现在我们来看看一个非常有用,但对于新手来说常常难以理解的操作:.groupby()函数。我们将通过多个示例逐步演示,以说明其最重要的功能。

groupby操作如其名所示:它根据你选择的某些类别或类对数据进行分组。让我们用iris数据集看一个简单的例子。我们将重新导入原始的iris数据集,并执行第一次groupby操作:

在这里,每个物种的数据被分区并提供了每个特征的平均值。现在我们更进一步,获取每个species的完整描述性统计数据:

每个物种的统计数据

现在,我们可以看到按species分组的完整数据。接下来,我们将看看还可以执行哪些其他的groupby操作。我们之前看到过,花瓣的长度和宽度在不同物种之间有相对明确的边界。现在,让我们研究如何使用groupby来观察这一点:

在这个例子中,我们根据每个独特物种的花瓣宽度对它们进行分组。这是一个可以管理的分组依据,但如果数据量变得更大,我们可能需要将测量数据划分为多个区间。正如我们之前看到的,这可以通过apply函数来实现。

现在让我们来看看一个自定义聚合函数:

在这段代码中,我们使用.max().min()函数以及返回最大花瓣宽度小于最小花瓣宽度的lambda函数对花瓣宽度按物种进行分组。

我们刚刚触及了groupby函数的一些功能,实际上它有更多的应用,建议你阅读pandas.pydata.org/pandas-docs/stable/中的文档。

希望你现在已经对如何操作和准备数据有了坚实的基础理解,为接下来的建模步骤做准备。接下来,我们将讨论 Python 机器学习生态系统中的主要库。

建模与评估

在这一部分,我们将介绍不同的库,如statsmodelsScikit-learn,并理解什么是部署。

Statsmodels

我们将介绍的第一个库是statsmodels库(statsmodels.sourceforge.net/)。Statsmodels 是一个 Python 包,功能强大,文档完善,专为数据探索、模型估计和统计测试而设计。在这里,我们将使用它来建立setosa物种的花萼长度和花萼宽度之间关系的简单线性回归模型。

首先,让我们通过散点图直观地检查这种关系:

fig, ax = plt.subplots(figsize=(7,7)) 
ax.scatter(df['sepal width (cm)'][:50], df['sepal length (cm)'][:50]) 
ax.set_ylabel('Sepal Length') 
ax.set_xlabel('Sepal Width') 
ax.set_title('Setosa Sepal Width vs. Sepal Length', fontsize=14, y=1.02) 

上述代码生成了以下输出:

所以我们可以看到,似乎存在正线性关系;也就是说,随着花萼宽度的增加,花萼长度也增加。接下来,我们将使用statsmodels对数据进行线性回归,以估计这种关系的强度:

import statsmodels.api as sm 

y = df['sepal length'][:50] 
x = df['sepal width'][:50] 
X = sm.add_constant(x) 

results = sm.OLS(y, X).fit() 
print results.summary() 

上述代码生成了以下输出:

在前面的图中,我们展示了简单回归模型的结果。由于这是一个线性回归模型,模型的形式为Y = Β[0]+ Β[1]X,其中B[0]是截距,B[1]是回归系数。这里的公式为花萼长度 = 2.6447 + 0.6909 * 花萼宽度。我们还可以看到,模型的值是一个令人满意的0.558,而p值(Prob)非常显著—至少对于这个物种来说。

现在让我们使用results对象来绘制回归线:

fig, ax = plt.subplots(figsize=(7,7)) 
ax.plot(x, results.fittedvalues, label='regression line') 
ax.scatter(x, y, label='data point', color='r') 
ax.set_ylabel('Sepal Length') 
ax.set_xlabel('Sepal Width') 
ax.set_title('Setosa Sepal Width vs. Sepal Length', fontsize=14, y=1.02) 
ax.legend(loc=2) 

上述代码生成了以下输出:

通过绘制results.fittedvalues,我们可以得到回归分析的回归线。

statsmodels 包中还有许多其他统计函数和测试,欢迎你去探索它们。它是 Python 中进行标准统计建模的一个非常有用的包。现在,让我们继续深入了解 Python 机器学习库中的王者:scikit-learn。

Scikit-learn

Scikit-learn 是一个了不起的 Python 库,具有无与伦比的文档,旨在为众多算法提供一致的 API。它建立在 Python 科学计算堆栈的基础上,并且本身也是这个堆栈的核心组件,堆栈包括 NumPy、SciPy、pandas 和 matplotlib。Scikit-learn 涉及的领域包括:分类、回归、聚类、降维、模型选择和预处理。

我们将查看一些示例。首先,我们将使用 iris 数据集构建一个分类器,然后我们将看看如何使用 scikit-learn 的工具来评估我们的模型:

  1. 在 scikit-learn 中构建机器学习模型的第一步是理解数据应该如何结构化。

  2. 自变量应该是一个数值型 n × m 矩阵 X,因变量 y 是一个 n × 1 向量。

  3. y 向量可以是数值型连续变量、分类变量,或者是字符串型分类变量。

  4. 这些数据随后被传递给所选分类器的 .fit() 方法。

  5. 这就是使用 scikit-learn 的巨大好处:每个分类器尽可能使用相同的方法。这使得更换分类器变得非常简单。

让我们在第一个示例中看看实际效果:

from sklearn.ensemble import RandomForestClassifier 
from sklearn.cross_validation import train_test_split 

clf = RandomForestClassifier(max_depth=5, n_estimators=10) 

X = df.ix[:,:4] 
y = df.ix[:,4] 

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.3) 

clf.fit(X_train,y_train) 

y_pred = clf.predict(X_test) 

rf = pd.DataFrame(zip(y_pred, y_test), columns=['predicted', 'actual']) 
rf['correct'] = rf.apply(lambda r: 1 if r['predicted'] == r['actual'] else 0, axis=1) 

rf 

上述代码生成了以下输出:

现在,让我们执行以下代码:

rf['correct'].sum()/rf['correct'].count() 

上述代码生成了以下输出:

在前面的几行代码中,我们构建、训练并测试了一个分类器,该分类器在我们的 iris 数据集上的准确率为 95%。让我们逐步解析每个步骤。首先,我们做了几个导入;前两个是来自 scikit-learn,幸运的是,在导入语句中它们被缩写为 sklearn。第一个导入是随机森林分类器,第二个是用于将数据拆分为训练集和测试集的模块。这种数据划分在构建机器学习应用时至关重要,原因有很多。我们将在后面的章节中详细讨论,但在此时可以说,它是必须的。这个 train_test_split 模块还会对数据进行洗牌,这一点也非常重要,因为数据的顺序可能包含会影响预测的偏置信息。

导入语句后面的第一行看起来有些奇怪,它实例化了我们的分类器,在这个例子中是一个随机森林分类器。我们选择了一个包含 10 棵决策树的森林,并且每棵树的最大分裂深度为五。这样设置是为了避免过拟合,这是我们将在后面章节中深入讨论的内容。

接下来的两行代码创建了我们的X矩阵和y向量。如果你记得我们最初的iris DataFrame,它包含了四个特征:花瓣宽度、花瓣长度、萼片宽度和萼片长度。这些特征被选中,成为我们的自变量特征矩阵X。最后一列,iris类别名称,则成为我们的因变量y向量。

然后,这些数据被传入train_test_split方法,该方法会打乱并将我们的数据分为四个子集,X_trainX_testy_trainy_testtest_size参数设置为.3,这意味着 30%的数据集将分配给X_testy_test,其余的则分配给训练集X_trainy_train

接下来,我们使用训练数据来拟合我们的模型。训练完成后,我们使用测试数据调用分类器的预测方法。请记住,测试数据是分类器未曾见过的数据。这个预测返回的是一个预测标签的列表。然后,我们创建一个包含实际标签与预测标签的 DataFrame。最后,我们统计正确预测的数量,并除以总实例数,从而得到一个非常准确的预测。现在,让我们看看哪些特征为我们提供了最具判别力或预测力的能力:

f_importances = clf.feature_importances_ 
f_names = df.columns[:4] 
f_std = np.std([tree.feature_importances_ for tree in clf.estimators_], axis=0) 

zz = zip(f_importances, f_names, f_std) 
zzs = sorted(zz, key=lambda x: x[0], reverse=True) 

imps = [x[0] for x in zzs] 
labels = [x[1] for x in zzs] 
errs = [x[2] for x in zzs] 

plt.bar(range(len(f_importances)), imps, color="r", yerr=errs, align="center") 
plt.xticks(range(len(f_importances)), labels); 

上述代码生成了以下输出:

正如我们预期的,根据之前的可视化分析,花瓣的长度和宽度在区分iris类别时具有更强的判别力。那么,这些数字究竟是从哪里来的呢?随机森林有一个叫做.feature_importances_的方法,它返回特征在叶节点划分时的相对表现。如果一个特征能够一致且干净地将一组数据划分成不同的类别,那么它的特征重要性就会很高。这个数值的总和始终为 1。正如你在这里看到的,我们还包括了标准差,这有助于说明每个特征的一致性。这是通过对每个特征的特征重要性进行计算,针对每十棵树,计算标准差来生成的。

现在让我们来看另一个使用 scikit-learn 的例子。我们将更换分类器,使用支持向量机SVM):

from sklearn.multiclass import OneVsRestClassifier 
from sklearn.svm import SVC 
from sklearn.cross_validation import train_test_split 

clf = OneVsRestClassifier(SVC(kernel='linear')) 

X = df.ix[:,:4] 
y = np.array(df.ix[:,4]).astype(str) 

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.3) 

clf.fit(X_train,y_train) 

y_pred = clf.predict(X_test) 

rf = pd.DataFrame(zip(y_pred, y_test), columns=['predicted', 'actual']) 
rf['correct'] = rf.apply(lambda r: 1 if r['predicted'] == r['actual'] else 0, axis=1) 

rf 

上述代码生成了以下输出:

现在,让我们执行以下代码行:

rf['correct'].sum()/rf['correct'].count() 

上述代码生成了以下输出:

在这里,我们使用了支持向量机(SVM),而几乎没有更改任何代码。唯一的变化是与导入 SVM 代替随机森林相关的部分,以及实例化分类器的那一行。(我确实需要对y标签的格式做一个小小的调整,因为 SVM 无法像随机森林分类器那样将它们解释为 NumPy 字符串。有时,必须做出特定的数据类型转换,否则会导致错误,但这只是一个小小的烦恼。)

这只是 scikit-learn 功能的一个小样本,但它应该能给你一些提示,说明这个强大的机器学习工具的威力。还有许多其他机器学习库,我们在这里无法详细讨论,但会在后续章节中探讨。如果这是你第一次使用机器学习库,并且你需要一个强大的通用工具,我强烈建议你选择 scikit-learn。

部署

当你决定将机器学习模型投入生产时,有许多选择可以选择。这主要取决于应用程序的性质。部署可以包括从本地机器上运行的定时任务到在 Amazon EC2 实例上部署的全规模实施。

我们在这里不会详细讨论具体的实现,但在全书中我们会有机会深入探讨不同的部署实例。

设置你的机器学习环境

我们已经覆盖了许多库,如果你要单独安装每一个库,可能会有些麻烦——当然,你完全可以这样做,因为大多数库都可以通过 pip,Python 的包管理器来安装,但我强烈建议你使用像 Anaconda Python 发行版(anaconda.org)这样的预打包解决方案。这样,你可以下载并安装一个包含所有包和依赖项的单一可执行文件,而所有的依赖问题都由它为你处理。由于该发行版针对 Python 科学计算栈用户,因此它本质上是一个一站式解决方案。

Anaconda 还包括一个包管理器,使得更新包变得非常简单。只需键入conda update <package_name>,就可以将包更新到最新的稳定版本。

总结

在这一章中,我们了解了数据科学/机器学习的工作流程。我们学习了如何一步步将数据从获取阶段通过每个阶段处理,直到部署阶段。我们还了解了 Python 科学计算栈中最重要的每个库的关键特性。现在,我们将应用这些知识和经验,开始创建独特且有用的机器学习应用。让我们开始吧!

第二章:构建一个应用程序来找到低价公寓

在第一章,Python 机器学习生态系统中,我们学习了处理数据的基本知识。接下来,我们将应用这些知识,构建我们的第一个机器学习应用程序。我们将从一个简明但高度实用的例子开始:构建一个识别低价公寓的应用程序。

如果你曾经搜索过公寓,你一定能体会到这个过程有多么令人沮丧。它不仅耗时,而且即便你找到了一个你喜欢的公寓,你如何确定它就是合适的呢?

很可能,你有一个预算目标和一个地点目标。但是,如果你像我一样,你也愿意做出一些妥协。例如,我住在纽约市,靠近地铁这种设施是一个很大的优势。但这值得多少?我是否应该为了靠近地铁而放弃住在有电梯的楼里?步行到地铁站的几分钟,是否值得爬上一层楼梯?租房时,像这样的疑问有很多。那么,我们怎样才能利用机器学习帮助我们做出这些决策呢?

本章剩下的内容将围绕这一主题展开。我们不能解答所有问题(有些原因稍后会变得清楚),但在本章结束时,我们将创建一个应用程序,让找到合适的公寓变得稍微容易一些。

本章我们将涵盖以下内容:

  • 获取公寓列表数据

  • 检查和准备数据

  • 可视化数据

  • 回归建模

  • 预测

获取公寓列表数据

在 1970 年代初期,如果你想购买股票,你需要聘请一个经纪人,他会收取近 1%的固定佣金。如果你想购买机票,你需要联系旅行代理商,他们会赚取大约 7%的佣金。如果你想卖房,你需要联系房地产代理商,他们会赚取 6%的佣金。而到了 2018 年,你几乎可以免费完成前两项。而最后一项依然保持着 1970 年代的样子。

为什么会这样?更重要的是,这一切与机器学习有什么关系呢?现实情况是,这一切归根结底是关于数据的,关于谁能访问这些数据。

你可能认为可以通过 API 或网络爬虫轻松获取大量房地产列表数据。你错了。如果你打算遵循这些网站的条款和条件的话,那就错了。房地产数据由美国房地产经纪人协会NAR)严格控制,他们运营着多重房源服务MLS)。这是一个汇总房源数据的服务,只提供给经纪人和代理商,且费用昂贵。所以,你可以想象,他们并不热衷于让任何人轻松下载这些数据大规模地

这是不幸的,因为开放这些数据无疑将会带来有用的消费者应用程序。这对于家庭预算中最大的一部分购买决策尤为重要。

话虽如此,并不是所有希望都失去,因为并非每个网站都明确禁止抓取数据。

获取公寓列表数据

我们将使用 RentHop 网站, www.renthop.com,来获取我们的公寓列表数据。以下是该网站的截图,展示了我们将要提取的列表布局:

从中我们可以看到,公寓列表中包括地址、价格、卧室数量和浴室数量。我们将从每个列表中获取这些信息。

我们将使用 Python 的 Requests 库来完成此任务。Requests 被称为 人性化的 HTTP,它使得获取网站变得非常容易。如果你想了解如何使用 Requests,快速入门指南可以在 docs.python-requests.org/en/master/user/quickstart/ 上找到。请按以下步骤操作:

  1. 所以,第一步是准备好我们的 Jupyter Notebook,并导入我们在这个任务中将使用的库。我们在以下代码片段中完成这项操作:
import numpy as np 
import pandas as pd 
import requests 
import matplotlib.pyplot as plt 
%matplotlib inline 

我们可能需要在稍后导入更多的库,但现在这应该足以让我们开始了。

  1. 我们将在模型中使用纽约公寓数据。该数据的网址是 www.renthop.com/nyc/apartments-for-rent。让我们进行一个快速测试,确保我们能成功获取该页面。我们在以下代码中完成这个操作:
r = requests.get('https://www.renthop.com/nyc/apartments-for-rent') 
r.content 
  1. 这段代码调用了该网站,并获取了信息,将其存储在 r 对象中。我们可以从 r 对象中获取许多属性,但现在我们只关心页面内容。我们可以在以下截图中看到这个输出:

  1. 经检查,看来我们需要的数据都包含在其中。为了验证这一点,让我们将所有 HTML 复制并粘贴到文本编辑器中,然后在浏览器中打开。我将使用 Sublime Text,这是一款流行的文本编辑器,可以在 www.sublimetext.com/ 上找到。

  2. 在下面的截图中,您可以看到我已将从 Jupyter 输出中复制的 HTML 粘贴到 Sublime Text 中,并保存为 test.html

HTML 文本

  1. 接下来,我们点击“在浏览器中打开”,可以看到类似以下图片的输出:

注意,尽管文本没有正确渲染(因为缺少 CSS),但我们目标的所有数据都在那里。幸运的是,这意味着 RentHop 网站没有使用任何高级的 JavaScript 渲染技术,这样就使得我们的工作更轻松。如果使用了高级技术,我们就不得不使用像 Selenium 这样的工具了。

现在让我们检查页面元素,看看如何解析页面数据:

  1. 在 Chrome 中打开 RentHop 网站,并右键点击页面的任意位置。

  2. 在上下文菜单的底部,你应该能看到“检查”(Inspect)选项。点击它。页面现在应该看起来像下图:

  1. 在刚刚打开的工具中,左上角有一个带箭头的方框。点击它,然后点击页面上的数据。它应该像下图一样:

从这里我们可以看到,每个房源的数据都在一个表格中,第一个td标签包含价格,第二个包含卧室数量,第三个包含浴室数量。我们还需要公寓的地址,它可以在一个锚点标签或<a>标签中找到。

现在让我们开始编写代码,测试我们的数据解析。为了进行 HTML 解析,我们将使用一个叫做BeautifulSoup的库。它的文档可以在www.crummy.com/software/BeautifulSoup/找到。BeautifulSoup 是一个流行、易于使用的 Python HTML 解析库。如果你还没有安装,可以通过 pip 安装。我们将使用它来提取我们公寓列表的所有单独规格:

  1. 要开始,我们只需要将页面内容传递给BeautifulSoup类。这可以在下面的代码中看到:
from bs4 import BeautifulSoup 

soup = BeautifulSoup(r.content, "html5lib") 
  1. 我们现在可以使用我们创建的soup对象开始解析公寓数据。我们要做的第一件事是获取包含我们房源数据的div标签。我们可以在下面的代码中看到:
listing_divs = soup.select('div[class*=search-info]') 
listing_divs 

在前面的代码中,我们做的是选择所有包含search-infodiv元素。这些正是包含我们数据的div元素。

  1. 接下来,我们来看一下下面截图中的输出:

  1. 注意,我们有一个包含所有目标div标签的 Python 列表。根据页面显示,我们知道应该有二十个这些元素。我们来确认一下:
len(listing_divs) 
  1. 然后我们会看到以下输出,这确认我们已经成功获取到所有需要的数据:

提取单独的数据点

现在我们已经拥有了所有包含房源数据的div元素,接下来需要提取每个公寓的单独数据点。

这些是我们要在每个房源中提取的点:

  • 房源的 URL

  • 公寓的地址

  • 邻里

  • 卧室数量

  • 浴室数量

显然,我们希望获得更多的信息——例如,公寓的面积,但我们只能利用现有的数据。

让我们从查看第一个房源开始:

listing_divs[0] 

上面的代码将输出以下结果:

请注意,这第一个div包含了我们寻找的所有数据点。现在我们只需开始解析它们,逐个提取。让我们先看看第一个我们想要获取的内容,URL。

我们可以看到页面的 URL 带有一个锚点或标签。现在让我们解析这个 URL。我们可以使用另一个select语句来完成这个操作,以下是相关代码片段:

listing_divs[0].select('a[id*=title]')[0]['href'] 

我们可以在以下截图中看到输出结果:

这正是我们所期望的结果。现在我们可以继续提取列表中的其他数据点。我们在下面的代码中执行了这一操作:

href = current_listing.select('a[id*=title]')[0]['href'] 
addy = current_listing.select('a[id*=title]')[0].string 
hood = current_listing.select('div[id*=hood]')[0]\ 
       .string.replace('\n','') 

让我们通过打印出我们已经捕获到的数据来验证这一点。我们通过以下代码来实现:

print(href) 
print(addy) 
print(hood) 

上面的代码生成了以下输出:

根据此输出,我们得到了所需的数据。接下来,让我们继续获取最后几个数据项——卧室数、浴室数和价格。

由于这些项目的呈现方式略有不同,它们在我们的div标签中的table标签内,并且在表格行tr中,我们需要遍历每个数据点以捕获数据。我们在下面的代码中进行了这个操作:

listing_specs = listing_divs[0].select('table[id*=info] tr') 
for spec in listing_specs: 
    spec_data = spec.text.strip().replace(' ', '_').split() 
    print(spec_data) 

上面的代码生成了以下输出:

再次检查,这正是我们要找的结果。我们现在已经收集了所有我们需要的数据。接下来,让我们将这些数据整合到一个循环中,以便从每个列表中提取数据并保存到一个列表里。

在接下来的代码中,我们将提取每个公寓列表中的所有数据点:

listing_list = [] 
for idx in range(len(listing_divs)): 
    indv_listing = [] 
    current_listing = listing_divs[idx] 
    href = current_listing.select('a[id*=title]')[0]['href'] 
    addy = current_listing.select('a[id*=title]')[0].string 
    hood = current_listing.select('div[id*=hood]')[0]\ 
    .string.replace('\n','') 
  indv_listing.append(href) 
    indv_listing.append(addy) 
    indv_listing.append(hood) 

    listing_specs = current_listing.select('table[id*=info] tr') 
    for spec in listing_specs: 
        try: 
            indv_listing.extend(spec.text.strip()\ 
                                .replace(' ', '_').split()) 
        except: 
            indv_listing.extend(np.nan) 
    listing_list.append(indv_listing) 

让我们稍微分析一下前面的代码。我们知道页面上有 20 个div元素,其中包含公寓的列表,所以我们创建了一个for循环,遍历每个元素,提取数据并将其添加到indv_listing中。完成后,所有单独公寓的数据会被添加到listing_list中,该列表包含了所有 20 个公寓的最终信息。我们可以通过以下代码来验证这一点:

listing_list 

上面的代码生成了以下输出:

再次检查,我们似乎得到了预期的结果,所以我们将继续进行。listing_list中的项数也确认我们已经获得了页面上的所有 20 个公寓。

到目前为止,我们已经成功获取了第一页的数据。虽然这很好,但如果我们想构建一个有意义的模型,我们将需要更多的公寓数据。为此,我们需要遍历多个页面。为此,我们需要使用适当的 URLs。我们可以看到在公寓列表的底部,有一个写着“下一页”的按钮。如果你右键点击该按钮并选择“复制链接地址”,你会看到以下的 URL:www.renthop.com/search/nyc?max_price=50000&min_price=0&page=2&sort=hopscore&q=&search=0

解析数据

对 URL 进行基本分析,我们可以看到传入的参数包括最小价格和最大价格,但最重要的是,页码。我们可以在代码中使用这个参数,并动态地改变页码,以便通过循环拉取更多的页面。

让我们试试一些示例代码:

url_prefix = "https://www.renthop.com/search/nyc?max_price=50000&min_price=0&page=" 
page_no = 1 
url_suffix = "&sort=hopscore&q=&search=0" 

for i in range(3): 
    target_page = url_prefix + str(page_no) + url_suffix 
    print(target_page) 
    page_no += 1 

前面的代码产生了以下输出:

看起来像是成功了。现在我们只需要把它们合并到一起。我们将通过将我们的解析循环转化为一个适当的函数来开始,这样我们就可以在每个页面上调用它。我们通过以下代码来实现:

def parse_data(listing_divs): 
    listing_list = [] 
    for idx in range(len(listing_divs)): 
        indv_listing = [] 
        current_listing = listing_divs[idx] 
        href = current_listing.select('a[id*=title]')[0]['href'] 
        addy = current_listing.select('a[id*=title]')[0].string 
        hood = current_listing.select('div[id*=hood]')[0]\ 
        .string.replace('\n','') 

        indv_listing.append(href) 
        indv_listing.append(addy) 
        indv_listing.append(hood) 

        listing_specs = current_listing.select('table[id*=info] tr') 
        for spec in listing_specs: 
            try: 
                values = spec.text.strip().replace(' ', '_').split() 
                clean_values = [x for x in values if x != '_'] 
                indv_listing.extend(clean_values) 
            except: 
                indv_listing.extend(np.nan) 
        listing_list.append(indv_listing) 
    return listing_list 

这个函数会接收一整页的listing_divs,并返回每个条目的数据载荷。然后我们可以将数据继续添加到我们的主公寓数据列表中。注意,在这段代码中有额外的代码来验证并移除一些错误的'_'值,这些值是在listing_spec循环中被加进去的。这样做是为了避免一些错误的解析,这些解析本不应该添加额外的列。

接下来,我们将构建主循环,该循环将检索每个页面,获取listing_divs,解析数据点,最后将所有信息添加到我们最终的 Python 列表中,包含每个条目的所有数据点。我们通过以下代码来实现:

all_pages_parsed = [] 
for i in range(100): 
    target_page = url_prefix + str(page_no) + url_suffix 
    print(target_page) 
    r = requests.get(target_page) 

    soup = BeautifulSoup(r.content, 'html5lib') 

    listing_divs = soup.select('div[class*=search-info]') 

    one_page_parsed = parse_data(listing_divs) 

    all_pages_parsed.extend(one_page_parsed) 

    page_no += 1 

在尝试 100 个页面之前,你应该确认它在更小的数量上能正常工作,比如 3 个页面。

你应该注意到页面在代码运行时已经被打印出来。如果你使用了 30 页,你应该会看到all_pages_parsed列表中有 2,000 个条目。

现在,让我们将数据移入一个pandas DataFrame,这样我们可以更方便地处理它。我们通过以下代码来实现:

df = pd.DataFrame(all_pages_parsed, columns=['url', 'address', 'neighborhood', 'rent', 'beds', 'baths']) 

df 

前面的代码产生了以下输出:

现在我们已经把所有数据拉取、解析并整合进了一个 DataFrame,接下来我们将进行数据的清理和验证。

检查并准备数据

让我们首先检查每一列的数据点。我们希望查找数据中的异常值和离群值。我们将从查看卧室和卫生间列开始:

  1. 在以下代码中,我们查看卧室的唯一值:
df['beds'].unique() 

前面的代码产生了以下输出:

  1. 现在,让我们来看一下卫生间。我们通过以下代码来实现:
df['baths'].unique() 

前面的代码产生了以下输出:

  1. 根据前面两个查询的输出,我们看到需要修正一些前面带有下划线的项。现在我们来做这个修改:
df['beds'] = df['beds'].map(lambda x: x[1:] if x.startswith('_') else x) 
df['baths'] = df['baths'].map(lambda x: x[1:] if x.startswith('_') else x) 
  1. 在前面的代码中,我们使用了 pandas 的map函数和一个lambda函数,基本上检查元素是否以下划线开始,如果是,则移除它。快速检查一下床和浴室的唯一值,应该可以发现我们错误的下划线已被移除:
df['beds'].unique() 

前面的代码产生了以下输出:

让我们执行以下代码行并查看结果:

df['baths'].unique() 

上面的代码生成了以下输出:

  1. 接下来,我们希望查看一些描述性统计数据,以更好地理解我们的数据。一种方法是使用describe方法。让我们在下面的代码中尝试一下:
df.describe() 

上面的代码生成了以下输出:

虽然我们希望得到诸如平均卧室和浴室数量、最大租金等指标,但我们实际上得到的结果远少于预期。问题是数据的类型不适合这些操作。Pandas 无法对字符串对象执行这些类型的操作。我们需要进一步清理数据,并将其设置为正确的数据类型。我们将在下面的代码中进行操作:

df['rent'] = df['rent'].map(lambda x: str(x).replace('$','').replace(',','')).astype('int') 
df['beds'] = df['beds'].map(lambda x: x.replace('_Bed', '')) 
df['beds'] = df['beds'].map(lambda x: x.replace('Studio', '0')) 
df['beds'] = df['beds'].map(lambda x: x.replace('Loft', '0')).astype('int') 
df['baths'] = df['baths'].map(lambda x: x.replace('_Bath', '')).astype('float') 

在前面的代码中,我们做了一个操作,将每个值中非数字的部分移除。你可以看到,我们去除了_Bed_Bath,只留下了数字,并且我们将诸如StudioLoft之类的词替换为实际的卧室数量,替换成了零。

数据类型预览

现在,让我们来看一下我们的数据类型:

df.dtypes 

上面的代码生成了以下输出:

这是我们想要看到的结果。请注意,由于我们可以有半个浴室,因此我们需要使用浮动类型而不是整数。

接下来,让我们进行一次检查。我们来统计每个社区单元的数量:

df.groupby('neighborhood')['rent'].count().to_frame('count')\ 
.sort_values(by='count', ascending=False) 

上面的代码生成了以下输出:

看起来大多数单元都位于曼哈顿,这也是我们所预期的。让我们确保我们的社区字符串是干净的。我们可以通过执行多个groupby操作来做到这一点:

df[df['neighborhood'].str.contains('Upper East Side')]['neighborhood'].value_counts() 

上面的代码生成了以下输出:

看起来我们在数据中有一些前导空格,可能还存在尾随空格的问题。让我们清理一下这些问题。我们将在以下代码中进行处理:

df['neighborhood'] = df['neighborhood'].map(lambda x: x.strip()) 

这样应该就清楚了。我们来验证一下:

df[df['neighborhood'].str.contains('Upper East Side')]['neighborhood'].value_counts() 

上面的代码生成了以下输出:

完美!正是我们想要看到的结果。到此为止,我们可以再做一些检查。我们来看看每个社区的平均租金:

df.groupby('neighborhood')['rent'].mean().to_frame('mean')\ 
.sort_values(by='mean', ascending=False) 

上面的代码生成了以下输出:

我们看到林肯广场区域的租金平均值似乎是最高的。到此为止,我们可以继续查询数据以寻找有趣的模式,但现在我们将继续进行数据的可视化。

可视化我们的数据

在处理地理数据时,就像我们现在做的那样,能够绘制这些信息是非常有价值的。一种实现方法是使用被称为 染色图 的地图。染色图本质上是一个地理热力图。我们将创建一个染色图,生成按 ZIP 码分布的平均租金价格热力图。

我们需要做的第一件事是 ZIP 码。不幸的是,我们的数据集中没有 ZIP 码信息。然而,我们有物业的地址。借助 Google Maps API,我们可以获取这些信息。

目前,Google Maps API 是一个收费 API。收费标准合理,1,000 次调用 $5,但每月还提供 $200 的信用额度(截至写作时)。他们还允许你在开始计费之前先注册免费试用,并且只有在你明确同意的情况下才会开始收费。由于市面上没有真正免费的替代品,我们将继续注册一个账户。接下来我将为你讲解步骤:

  1. 第一步是访问 Google Maps API 页面:developers.google.com/maps/documentation/geocoding/intro

  1. 点击右上角的 GET STARTED。接下来,你将被提示创建一个项目。给它取一个你喜欢的名字:

创建一个项目

  1. 然后你将启用计费:

  1. 接下来,你将启用 API 密钥:

  1. 完成后并获得 API 密钥后,返回首页启用地理定位 API。点击左侧面板中的 APIs:

  1. 然后,在未使用的 API 下,点击地理定位 API:

完成这些步骤并获得 API 密钥后,使用 pip install -U googlemaps 安装 Google Maps。你可以在命令行中执行此操作:

现在让我们继续在 Jupyter Notebook 中使用这个 API。我们将导入新的地图 API 并进行测试:

import googlemaps 

gmaps = googlemaps.Client(key='YOUR_API_KEY_GOES_HERE') 

ta = df.loc[3,['address']].values[0] + ' '\ 
+ df.loc[3,['neighborhood']].values[0].split(', ')[-1] 

ta 

上述代码会输出以下结果:

好的,实际上我们在代码的最后部分所做的就是导入并初始化 googlemaps 客户端,并使用我们某个公寓的地址作为可用地址。现在让我们将该地址传递给 Google Maps API:

geocode_result = gmaps.geocode(ta) 

geocode_result 

上述代码生成以下输出:

记住,我们这里的目标是提取 ZIP 码。ZIP 码嵌入在 JSON 中,但由于响应 JSON 对象的格式,这需要一些工作来提取。我们现在来做这个:

for piece in geocode_result[0]['address_components']: 
    if 'postal_code' in piece['types'] : 
        print(piece['short_name']) 

上述代码会输出以下结果:

看起来我们正在获得我们想要的信息。然而,有一个注意事项。深入查看地址栏时,我们可以看到偶尔并没有提供完整的地址。这将导致没有邮政编码返回。我们只需稍后处理这个问题。现在,让我们构建一个函数来检索邮政编码,代码如下:

import re 
def get_zip(row): 
    try: 
        addy = row['address'] + ' ' + row['neighborhood'].split(', ')[-1] 
        print(addy) 
        if re.match('^\d+\s\w', addy): 
            geocode_result = gmaps.geocode(addy) 
            for piece in geocode_result[0]['address_components']: 
                if 'postal_code' in piece['types']: 
                    return piece['short_name'] 
                else: 
                    pass 
        else: 
            return np.nan 
    except: 
        return np.nan 

df['zip'] = df.apply(get_zip, axis=1) 

上述代码片段有不少内容,让我们来讨论一下这里的过程:

首先,在底部,你会看到我们在 DataFrame 上运行了apply方法。因为我们设置了axis=1,所以df DataFrame 的每一行都会传递到我们的函数中。在函数内部,我们正在拼接一个地址来调用 Google Maps 地理定位 API。我们使用正则表达式限制我们的调用仅限于那些以街道编号开头的地址。然后,我们遍历 JSON 响应来解析出邮政编码。如果找到了邮政编码,我们就返回它,否则返回np.nan,即空值。请注意,这个函数运行时需要一些时间,因为我们需要进行数百次调用并解析响应。

一旦完成,我们将得到一个新的 DataFrame,其中包含了那些提供了完整地址的物业的邮政编码。让我们来看一下,看看有多少条数据实际上是有邮政编码的:

df[df['zip'].notnull()].count() 

上述代码生成了以下输出:

所以,我们失去了相当一部分数据,但无论如何,现在我们拥有的数据在许多方面都更有用,因此我们将继续进行。

首先,由于获取所有邮政编码数据需要较长时间,让我们现在存储我们已经获得的数据,以便以后需要时可以随时检索,而不必再次进行所有的 API 调用。我们可以通过以下代码来实现:

df.to_csv('apts_with_zip.csv') 

我们还将把仅包含邮政编码信息的数据存储在一个新的 DataFrame 中。我们将其命名为zdf

zdf = df[df['zip'].notnull()].copy() 

最后,让我们按邮政编码进行聚合,看看每个邮政编码的平均租金是多少:

zdf_mean = zdf.groupby('zip')['rent'].mean().to_frame('avg_rent')\ 
.sort_values(by='avg_rent', ascending=False).reset_index() 
zdf_mean 

上述代码生成了以下输出:

我们可以看到,这与我们之前发现的林肯中心区域的租金均价最高一致,因为 10069 就在林肯中心区域。

现在我们继续进行数据可视化:

可视化数据

由于这些数据是基于邮政编码的,最好的可视化方式是使用色块图。如果你不熟悉色块图,它只是根据颜色谱表示数据的可视化方式。现在让我们使用一个名为folium的 Python 映射库来创建一个色块图,github.com/python-visualization/folium 如果你没有安装 folium,可以通过命令行使用pip install来安装。

现在我们继续创建我们的可视化:

import folium 

m = folium.Map(location=[40.748817, -73.985428], zoom_start=13) 

m.choropleth( 
    geo_data=open('nyc.json').read(), 
    data=zdf_mean, 
    columns=['zip', 'avg_rent'], 
    key_on='feature.properties.postalCode', 
    fill_color='YlOrRd', fill_opacity=0.7, line_opacity=0.2, 
    ) 

m 

这里有很多内容,我们来一步步看:

  1. 在导入folium后,我们创建一个.Map()对象。我们需要传入坐标和缩放级别来居中地图。通过 Google 搜索帝国大厦的坐标,可以获得正确的纬度和经度(将经度符号翻转以正确显示)。最后,调整缩放级别,使其适合我们的数据并正确居中。

  2. 下一行代码需要使用 GeoJSON 文件。这是一种表示地理属性的开放格式。您可以通过搜索纽约市的 GeoJSON 文件来找到它们——特别是那些包含邮政编码映射的文件。完成后,我们通过输入文件路径来引用 GeoJSON 文件。

  3. 接下来,我们在data参数中引用我们的 DataFrame。在这里,我们使用的是之前创建的按邮政编码划分的平均租金。columns参数引用了这些列。key_on参数引用了我们目标 JSON 文件中的部分内容,在这个例子中是postalCode

  4. 最后,其他选项确定了色板和一些其他参数,用于调整图例和颜色。

当该单元格运行时,地图应该会在您的 Jupyter Notebook 中呈现,如下图所示:

完成热力图后,您可以开始了解哪些区域的租金较高或较低。这可以帮助我们在针对特定区域时做出决策,但让我们通过使用回归建模来进一步深入分析。

数据建模

让我们开始使用我们的数据集进行建模。我们将分析邮政编码和卧室数量对租金价格的影响。这里我们将使用两个包:第一个是statsmodels,我们在第一章《Python 机器学习生态系统》中介绍过,而第二个是patsypatsy.readthedocs.org/en/latest/index.html,这个包使得使用statsmodels更加简便。Patsy 允许您在运行回归时使用类似 R 语言的公式。我们现在来实现一下:

import patsy 
import statsmodels.api as sm 

f = 'rent ~ zip + beds' 
y, X = patsy.dmatrices(f, zdf, return_type='dataframe') 

results = sm.OLS(y, X).fit() 
results.summary() 

上述代码生成了以下输出:

请注意,前面的输出被截断了。

只需要几行代码,我们就运行了第一个机器学习算法。

虽然大多数人不认为线性回归是机器学习的一部分,但它实际上正是机器学习的一种。线性回归是一种有监督的机器学习方法。在此上下文中,“有监督”意味着我们为训练集提供了输出值。

现在,让我们来拆解一下发生了什么。在导入包之后,我们有两行与patsy模块相关。第一行是我们将使用的公式。在波浪号(~)左侧是我们的响应变量或因变量rent。在右侧是我们的自变量或预测变量zipbeds。这个公式的意思就是我们想知道邮政编码和卧室数量如何影响租金价格。

我们的公式接着与包含相应列名的 DataFrame 一起传递给 patsy.dmatrices()。Patsy 会返回一个包含预测变量 X 矩阵和响应变量 y 向量的 DataFrame。这些数据随后被传递给 sm.OLS(),并通过 .fit() 来运行我们的模型。最后,我们输出模型的结果。

如你所见,输出结果中提供了大量的信息。让我们从最上面的部分开始看。我们看到模型包含了 555 个观察值,调整后的 R² 值为 .367,并且具有显著性,其 F-统计量 的概率为 3.50e-31。这意味着什么呢?这意味着我们建立的模型能够利用卧室数和邮政编码解释大约三分之一的价格方差。这是一个好的结果吗?为了更好地回答这个问题,我们现在来看输出的中间部分。

中间部分为我们提供了关于模型中每个自变量的信息。从左到右,我们看到以下内容:变量、模型中的变量系数、标准误差、t-统计量、t-统计量的 p-值,以及 95% 的置信区间。

这些信息告诉我们什么?如果我们查看 p-值列,就可以判断各个变量是否在统计上显著。在回归模型中,统计显著性意味着自变量与响应变量之间的关系不太可能是偶然发生的。通常,统计学家使用 p-值为 .05 来确定这一点。.05p-值意味着我们看到的结果只有 5% 的概率是偶然发生的。在这里的输出中,卧室数量显然是显著的。那么邮政编码呢?

这里需要注意的第一点是,我们的截距表示的是 07302 邮政编码。在进行线性回归建模时,需要一个截距。截距就是回归线与 y 轴相交的点。Statsmodels 会自动选择一个预测变量作为截距。在此,它选择了泽西市(07302),因为它将邮政编码按升序排列。我们可以通过以下方式确认这一点:

X 

上面的代码生成了以下输出:

请注意,它们是按升序排列的,如果我们查看 DataFrame 中排序后的邮政编码值,我们会发现除了缺失的邮政编码 07302 外,其他都符合排序规则,07302 现在成为了我们的基准,所有其他邮政编码将与之进行比较。

再次查看我们的结果输出,我们注意到一些邮政编码非常显著,而其他的则不显著。让我们来看一下我们的老朋友,林肯中心附近的 10069 区。如果你记得的话,这个地区是我们样本中租金最高的地方。我们可以预期,与泽西市作为基准相比,它应该是显著的并且有一个较大的正系数,实际上它确实是如此。p-值为 0.000,系数为 4116。这意味着,与你在泽西市的同等公寓相比,你可以预期林肯中心附近的租金会显著更高——这并不令人意外。

现在让我们使用我们的模型进行一系列预测。

预测

假设我们从之前的分析中决定,我们对三个特定的邮政编码感兴趣:100021000310009。我们如何使用模型来确定我们应该为某个公寓支付多少钱呢?现在让我们来看一下。

首先,我们需要了解模型的输入是怎样的,这样我们才能知道如何输入新的数据。让我们来看一下我们的X矩阵:

X.head() 

前面的代码生成了以下输出:

我们看到的是,输入数据使用了所谓的虚拟变量(dummy variables)编码。由于邮政编码不是数值型特征,所以采用虚拟编码来表示它。如果公寓位于 10003 区域,那么该列将被编码为1,而所有其他邮政编码则编码为0。卧室数将根据实际数值进行编码,因为它是数值型特征。所以让我们现在创建自己的输入行进行预测:

to_pred_idx = X.iloc[0].index 
to_pred_zeros = np.zeros(len(to_pred_idx)) 
tpdf = pd.DataFrame(to_pred_zeros, index=to_pred_idx, columns=['value']) 

tpdf 

前面的代码生成了以下输出:

我们刚刚使用了X矩阵中的索引,并用全零填充了数据。现在让我们填入我们的值。我们将定价位于10009区号的单卧公寓:

tpdf.loc['Intercept'] = 1 
tpdf.loc['beds'] = 1 
tpdf.loc['zip[T.10009]'] = 1 

tpdf 

对于线性回归,截距值必须始终设置为1,这样模型才能返回准确的统计值。

前面的代码生成了以下输出:

我们已经将特征设置为合适的值,现在让我们使用模型返回预测结果。我们需要将其转换为 DataFrame 并进行转置,以获得正确的格式。我们可以按以下方式操作:

results.predict(tpdf['value'].to_frame().T) 

前面的代码生成了以下输出:

你会记得results是我们保存模型时的变量名。该模型对象有一个.predict()方法,我们用输入值调用它。正如你所看到的,模型返回了一个预测值。

如果我们想添加另一个卧室呢?我们可以按照以下方式操作:

  1. 让我们改变输入并查看结果:
tpdf['value'] = 0 
tpdf.loc['Intercept'] = 1 
tpdf.loc['beds'] = 2 
tpdf.loc['zip[T.10009]'] = 1 
  1. 然后我们再次运行预测:
results.predict(tpdf['value'].to_frame().T) 

前面的代码生成了以下输出:

  1. 看起来额外的卧室每月大约需要多花$800。但如果我们选择10069呢?让我们修改输入并看看:
tpdf['value'] = 0 
tpdf.loc['Intercept'] = 1 
tpdf.loc['beds'] = 2 
tpdf.loc['zip[T.10069]'] = 1 

results.predict(tpdf['value'].to_frame().T) 

前面的代码生成了如下输出:

根据我们的模型,在林肯中心地区,两间卧室的租金将比东村高出不少。

扩展模型

到目前为止,我们仅仅考察了邮政编码、卧室数量与租金价格之间的关系。尽管我们的模型有一些解释性作用,但数据集非常小,特征也太少,无法充分考察房地产估值这个复杂的领域。

然而幸运的是,如果我们向模型中添加更多数据和特征,我们可以使用完全相同的框架来扩展我们的分析。

一些可能的未来扩展可以探索利用来自 Foursquare 或 Yelp 等 API 的餐厅和酒吧数据,或者利用 Walk Score 等提供商的数据来评估步行便利性和交通便捷性。

扩展模型有很多种方式,我建议如果你确实打算做这样的项目,可以探索多种方法。每天都有更多的数据发布,借助这些数据,模型只能越来越好。

小结

在这一章中,我们学习了如何获取房地产列表数据,如何利用 pandas 的功能对数据进行处理和清洗,如何通过地理热力图对数据进行可视化检查,最后,如何构建和使用回归模型来估算公寓价格。

到此为止,我们仅仅触及了机器学习的表面。在接下来的章节中,我们将深入学习如何评估模型的质量,同时也将了解如何将这些模型转化为完整的解决方案。

第三章:构建一个查找便宜机票的应用程序

让我们谈谈错误。它们是生活的一部分;每个人都会犯错误——包括航空公司。

2014 年,某天下午我正好在浏览我的 Twitter 动态,看到我关注的一个账户发推称某家美国主要航空公司出售的欧洲机票价格远低于预期。当时,从纽约到维也纳的最低票价大约为 800 美元,但某些日期的广告票价却在 350 美元到 450 美元之间。这听起来好像太好以至于不真实。但事实并非如此。我幸运地碰到了业内所称的错误票价

在旅行黑客和常旅客的超级机密圈子里,大家都知道航空公司偶尔会—而且是意外地—发布不包括燃油附加费的票价。更令人惊讶的是,这并不是他们唯一犯的错误。你可能会认为,先进的算法会为每个航班更新票价,考虑到大量的因素。大多数情况下,你的想法是对的。但由于传统系统的限制以及处理多个航空公司和多个法域的复杂性,错误有时确实会发生。

以下是一些最近的错误票价列表:

  • 2007 年,联合航空公司将从旧金山飞往新西兰的商务舱机票售卖为 1500 美元

  • 2013 年,达美航空公司将从多个美国城市飞往夏威夷的经济舱票价定为 6.90 美元

  • 2015 年,美国航空公司从华盛顿特区到中国的商务舱票价为 450 美元

现在你知道这些票价的存在,那你该怎么利用它们呢?当然是机器学习!由于这些票价通常只持续几个小时就会消失,我们将构建一个应用程序,持续监控票价变化,检查是否有异常价格,从而生成一个可以迅速采取行动的警报。

本章内容包括:

  • 在网上获取机票定价

  • 使用先进的网页抓取技术获取票价数据

  • 解析 DOM 以提取价格

  • 使用异常检测技术识别异常票价

  • 使用 IFTTT 发送实时文本警报

获取机票定价数据

幸运的是,获取机票数据比获取房地产数据要容易一些。有许多提供这类数据的服务商,且有付费和免费的 API。一项挑战是,获取数据需要进行多个网页请求。在本书的前一版中,我们概述了如何从 Google 的Flight Explorer页面抓取数据。这是查看多个城市的数周票价数据的理想方式。不幸的是,该页面现在已被移除,Google 现在提供的是一个更为典型的搜索界面,用户需要输入出发城市、目的城市、起始日期和结束日期。一个幸运的特性是,仍然可以输入整个区域而不是具体的城市。我们将在抓取中使用这一点。下面的截图可以作为一个示例:

如您所见,我们输入了New York作为出发城市,并简单地将Asia作为目的地。这将返回所有亚洲(以及中东地区,出于某些原因)的主要城市的票价。这是一个好消息,因为我们希望在一个网页请求中捕获尽可能多的票价。

虽然界面仍然具有一些使得抓取数据更容易的功能,但我们需要使用一些比过去更高级的技术。接下来我们将讨论这些技术。

使用高级网页抓取技术获取票价数据

在前面的章节中,我们已经学习了如何使用Requests库来获取网页。如我之前所说,它是一个很棒的工具,但不幸的是,在这里我们无法使用它。我们想抓取的页面完全基于 AJAX。异步 JavaScript (AJAX) 是一种从服务器获取数据的方法,无需重新加载页面。这对我们意味着,我们需要使用浏览器来获取数据。虽然这听起来可能需要大量的开销,但有两个库,如果一起使用,可以使这个任务变得轻便。

这两个库分别是 Selenium 和 ChromeDriver。Selenium 是一个强大的网页浏览器自动化工具,而 ChromeDriver 是一个浏览器。为什么使用 ChromeDriver 而不是 Firefox 或 Chrome 本身呢?ChromeDriver 是一种被称为无头浏览器的工具。这意味着它没有用户界面。这使得它更加精简,十分适合我们要做的事情。

要安装 ChromeDriver,您可以从sites.google.com/a/chromium.org/chromedriver/downloads下载二进制文件或源代码。至于 Selenium,它可以通过 pip 安装。

我们还需要一个名为BeautifulSoup的库,用于解析页面中的数据。如果您尚未安装它,应该立即使用pip install安装它。

完成这些之后,我们开始吧。我们将在 Jupyter Notebook 中开始,这对于探索性分析效果最好。稍后,当我们完成探索后,将转向文本编辑器编写我们要部署的代码。以下是操作步骤:

  1. 首先,我们导入常用库,如下面的代码片段所示:
import numpy as np 
import pandas as pd 
import matplotlib.pyplot as plt 
%matplotlib inline 
  1. 接下来,请确保您已经安装了BeautifulSoupSelenium,并下载了ChromeDriver,如前所述。我们现在将在新单元格中导入它们:
from bs4 import BeautifulSoup 
from selenium import webdriver 

# replace this with the path of where you downloaded chromedriver 
chromedriver_path = "/Users/alexcombs/Downloads/chromedriver" 

browser = webdriver.Chrome(chromedriver_path) 

请注意,我已经引用了我机器上下载的ChromeDriver路径。请记住,您需要将该路径替换为您自己机器上的路径。

创建链接

现在,值得注意的是,我们已经拥有开始进行航班票价抓取所需的所有内容,只有一个例外:我们需要 URL。在这个练习中,我将专注于从纽约市(NYC)出发飞往欧洲的航班。由于我们不希望下载大量数据并冒着被封锁的风险,我们将仅提取从纽约出发的直飞航班数据,这些航班在周六起飞,次周六返回。当然,你可以根据自己的需求修改目标航班,但我们将以此作为示例项目。

下一步是填写 Google Flights 表单。确保选择一个未来的日期。一旦输入数据并点击搜索,从浏览器地址栏复制 URL 字符串,如以下截图所示:

我复制的 URL 是 2018 年 12 月 1 日起飞并于 2018 年 12 月 8 日返回的航班。可以在搜索字符串中看到这些日期。如果你选择不同的日期,复制的字符串中应该会反映这些变化。现在我们来编写代码:

  1. 让我们输入该字符串并将其保存为变量 sats,如以下代码块所示:
sats = 'https://www.google.com/flights/f=0#f=0&flt=/m/02_286.r/m/02j9z.2018-12-01*r/m/02j9z./m/02_286.2018-12-08;c:USD;e:1;s:0*1;sd:1;t:e' 
  1. 接下来,我们将测试能否成功提取页面上看到的内容。我们将使用以下代码行来进行测试,这段代码使用了 selenium
browser.get(sats) 
  1. 这一行代码就足够我们获取页面了。我们可以通过添加几行额外的代码来验证获取是否成功。

  2. 首先,让我们检查页面的标题:

browser.title 

结果输出如下所示:

看起来我们成功获取了正确的页面。现在让我们检查一下是否抓取了我们所需要的所有内容。我们可以通过截取页面的截图来检查。我们通过以下代码行来实现:

browser.save_screenshot('/Users/alexcombs/Desktop/test_flights.png') 

同样,我保存截图的路径是基于我的机器的;你需要在自己的机器上引用一个路径。如以下输出所示,我们成功获取了页面的所有内容:

由于我们似乎已经获取了所需的页面数据,接下来我们将介绍如何从页面中提取个别数据点。为此,首先,我们需要了解 文档对象模型 (DOM)。

解析 DOM 以提取定价数据

DOM 是组成网页的元素集合。它包括 HTML 标签,如 bodydiv,以及嵌入在这些标签中的类和 ID。

让我们来看一下 Google 页面上的 DOM:

  1. 要查看它,请右键点击页面并选择“检查”。在 Firefox 或 Chrome 中应该是一样的。这将打开开发者工具标签,允许你查看页面源代码,如以下截图所示:

  1. 打开页面后,选择左上角的元素选择器,点击一个元素以跳转到该元素在页面源代码中的位置:

  1. 我们关注的元素是包含航班信息的框。这可以通过下面的截图看到:

如果仔细查看元素,你会注意到它是一个名为div的元素。这个div有一个名为class的属性。这个class是由一长串随机的数字和字母组成的,但你会注意到它包含了字符串info-container。我们可以利用这一信息来提取所有包含航班信息的div元素。我们稍后会进行操作,现在先讨论一下解析过程。

解析

为了开始解析,我们需要使用前面提到的BeautifulSoup库。我们已经导入了这个库,现在只需要将页面源代码传递给BeautifulSoup。我们可以通过以下代码来实现:

soup = BeautifulSoup(browser.page_source, "html5lib") 

请注意,浏览器对象包含page_source属性。这是我们之前用get请求检索到的所有 HTML 内容。传递给BeautifulSoup的另一个参数是它将使用的解析库。在这里,我们将使用html5lib

现在,一旦页面内容被传递给BeautifulSoup,我们就可以开始提取感兴趣的元素了。这时,div元素和info-container类就派上了用场。我们将提取这些元素。每个div元素代表一个城市。

我们先提取第一个div

cards = soup.select('div[class*=info-container]') cards[0] 

上述代码的输出如下所示:

在前面的代码中,我们在soup对象上使用了select方法。select方法允许我们使用 CSS 选择器来引用感兴趣的元素。在这里,我们指定了要选择包含字符串info-containerclass属性的div元素。有关 BeautifulSoup 的 CSS 选择器和其他方法的详细文档,可以在www.crummy.com/software/BeautifulSoup/bs4/doc/#css-selectors找到。

查看前面的输出时,请注意,在标记中深埋着目的地城市名称(London)和票价($440)。由于我们只需要数据而非所有的 HTML 标记,我们需要编写代码来遍历每个info-containerdiv,并提取城市和票价:

for card in cards: 
    print(card.select('h3')[0].text) 
    print(card.select('span[class*=price]')[0].text) 
    print('\n') 

上述代码的输出如下所示:

由于我们似乎已经成功地获取了每个城市的票价,现在让我们继续构建一个完整的抓取和解析程序,以处理大量的票价数据。

现在我们将尝试获取从纽约市到欧洲的最低价、直飞航班票价,时间跨度为 26 周。我使用的开始日期是 2018 年 12 月 1 日,但显然,如果你是在这个日期之后阅读本文,确保相应地调整你的日期。

我们首先需要做的是引入一些额外的导入语句。我们在以下代码中完成这一操作:

from datetime import date, timedelta 
from time import sleep 

接下来,我们将构建剩余的抓取代码:

start_sat = '2018-12-01' 
end_sat = '2018-12-08' 

start_sat_date = datetime.strptime(start_sat, '%Y-%m-%d') 
end_sat_date = datetime.strptime(end_sat, '%Y-%m-%d') 

fare_dict = {} 

for i in range(26):     
    sat_start = str(start_sat_date).split()[0] 
    sat_end = str(end_sat_date).split()[0] 

    fare_dict.update({sat_start: {}}) 

    sats = "https://www.google.com/flights/?f=0#f=0&flt=/m/02_286.r/m/02j9z." + \ 
    sat_start + "*r/m/02j9z./m/02_286." + \ 
    sat_end + ";c:USD;e:1;s:0*1;sd:1;t:e" 

    sleep(np.random.randint(3,7)) 
    browser.get(sats) 

    soup = BeautifulSoup(browser.page_source, "html5lib") 

    cards = soup.select('div[class*=info-container]') 

    for card in cards: 
        city = card.select('h3')[0].text 
        fare = card.select('span[class*=price]')[0].text 
        fare_dict[sat_start] = {**fare_dict[sat_start], **{city: fare}} 

    start_sat_date = start_sat_date + timedelta(days=7) 
    end_sat_date = end_sat_date + timedelta(days=7) 

这段代码量比较大,所以我们将逐行分析。前两行仅仅是创建我们将要使用的开始和结束日期。接下来的两行将这些日期字符串转换为datetime对象。这将在以后使用timedelta时加入一周。for循环前的最后一行仅仅是创建一个字典,用于存储我们解析后的数据。

下一行开始了一个for循环。在这个将运行 26 次的循环内,我们将datetime对象转换回字符串,以便将其传递到我们将用浏览器对象调用的 URL 中。同时,注意到每次迭代时,我们都会用开始日期填充票价字典。然后,我们使用创建的日期字符串来构建 URL。

接下来,我们使用numpy.random函数和 Python 的sleep函数插入一个随机的暂停。这只是为了避免我们看起来像是一个机器人,并防止对网站造成过大负担。

然后我们用浏览器对象获取页面,将其传递给BeautifulSoup进行解析,选择info-container divs,然后解析并更新我们的票价字典。最后,我们将开始和结束日期各加上一周,以便下一次迭代能够向前推进一周。

现在,让我们查看我们票价字典中的数据:

fare_dict 

上面的代码生成了以下输出:

如你所见,我们有一个以日期为主键的字典,然后是包含城市和票价配对的子字典。

现在,让我们深入了解一个城市的数据。我们从柏林开始:

city_key = 'Berlin' 
for key in fare_dict: 
    print(key, fare_dict[key][city_key]) 

上面的代码生成了以下输出:

我们立即注意到的一件事是,我们需要清理票价数据,以便进行处理。我们需要移除美元符号和逗号,并将其转换为整数。我们在以下代码中完成这一操作:

city_dict = {} 
for k,v in fare_dict.items(): 
    city_dict.update({k:int(v[city_key].replace(',','').split('$')[1])}) 

上面的代码生成了以下输出:

请记住,上述代码的输出仅适用于柏林,因为我们现在只是在检查一个城市的数据。

现在,让我们绘制这些数据:

prices = [int(x) for x in city_dict.values()] 
dates = city_dict.keys() 

fig,ax = plt.subplots(figsize=(10,6)) 
plt.scatter(dates, prices, color='black', s=50) 
ax.set_xticklabels(dates, rotation=-70); 

上面的代码生成了以下输出:

请注意,我们有 26 个连续的周的数据,在这种情况下,从纽约到柏林的直飞航班,周六出发,次周六返回。这些票价存在相当大的变化。仅凭眼观数据,看起来在期间的开始和结束处可能有两个高端的异常值。

现在,让我们来看另一个城市。为此,我们只需返回我们的代码并更改city_key变量。然后我们可以重新运行下面的单元格。我们将在以下代码中执行此操作:

city_key = 'Milan' 
for key in fare_dict: 
    print(key, fare_dict[key][city_key]) 

这导致以下输出:

我们需要去掉美元符号和逗号,并将它们转换为整数。我们在以下代码中执行此操作:

city_dict = {} 
for k,v in fare_dict.items(): 
    city_dict.update({k:int(v[city_key].replace(',','').split('$')[1])}) 

city_dict 

以下代码生成以下输出:

现在,让我们绘制这些数据:

prices = [int(x) for x in city_dict.values()] 
dates = city_dict.keys() 

fig,ax = plt.subplots(figsize=(10,6)) 
plt.scatter(dates, prices, color='black', s=50) 
ax.set_xticklabels(dates, rotation=-70); 

以下代码生成以下输出:

在这里,我们可以看到更广泛的变化,票价从低于$600 到超过$1,200 不等。左侧的廉价票价正是我们想了解的类型。我们将要创建一个异常值检测系统,告诉我们这些便宜的票价。我们将继续讨论这个问题。

使用异常检测技术识别异常票价

有多种严格的异常值定义,但对于我们的目的,异常值是指远离数据集中其他观测值的极端值。有许多用于识别异常值的技术,包括基于密度的空间聚类应用(DBSCAN)、孤立森林和格鲁布斯测试,这些算法的例子。通常,数据类型决定了使用的算法类型。例如,某些算法在多变量数据上表现更好,而在单变量数据上表现不错。在这里,我们处理的是单变量时间序列数据,因此我们希望选择一个能很好处理这种数据的算法。

如果您对时间序列这个术语不熟悉,它简单地意味着以固定间隔记录的数据,比如股票的每日收盘价。

我们将用于我们的数据的算法称为广义极端学生化偏差检验Generalized ESD)异常值检验。由于我们的数据是单变量且近似正态分布,这个算法非常适合我们的数据。

我们可以使用几种方法来确保我们的数据近似正态分布,但我们还可以使用正态概率图来直观检查我们的数据的正态性。现在,我们将为莫斯科城市数据使用来自 SciPy 库的一些功能执行此操作:

from scipy import stats 
fix, ax = plt.subplots(figsize=(10,6)) 
stats.probplot(list(city_dict.values()), plot=plt) 
plt.show() 

以下代码生成以下输出:

在评估正态概率分位数-分位数Q-Q时,我们希望数据尽可能靠近直线,以揭示正态性。数据如果向一个方向偏离,或者呈现出明显的 S 形,则表明数据不符合正态分布。这里,我们的数据点较少,且已有的数据点大致平衡分布在对角线上。如果我们有更多数据,可能会更接近对角线。这个结果应该足够满足我们的需求。

接下来,我们将进入异常值检测代码部分。我们将使用另一个库——PyAstronomy。如果你没有这个库,可以通过 pip 安装。

让我们看看代码:

from PyAstronomy import pyasl 

r = pyasl.generalizedESD(prices, 3, 0.025, fullOutput=True) 

print('Total Outliers:', r[0]) 

out_dates = {} 
for i in sorted(r[1]): 
    out_dates.update({list(dates)[i]: list(prices)[i]}) 

print('Outlier Dates', out_dates.keys(), '\n') 
print('     R         Lambda') 

for i in range(len(r[2])): 
    print('%2d  %8.5f  %8.5f' % ((i+1), r[2][i], r[3][i])) 

fig, ax = plt.subplots(figsize=(10,6)) 
plt.scatter(dates, prices, color='black', s=50) 
ax.set_xticklabels(dates, rotation=-70); 

for i in range(r[0]): 
    plt.plot(r[1][i], prices[r[1][i]], 'rp') 

让我们来讨论一下前面的代码做了什么。第一行只是我们的导入。接下来,我们实现了通用的 ESD 算法。参数是我们的票价,然后是最大异常值数量(在这里,我们选择了3),显著性水平(alpha,值为0.025),最后是一个布尔值,用于指定我们希望获得完整输出。关于显著性水平,值越低,算法的敏感性越低,产生的假阳性越少。

接下来的两行仅仅是打印出与RLambda值相关的数据。这些数据用于判断一个数据点是否为异常值。

最后,代码的其余部分仅用于生成散点图,并将异常值的票价标记为红色。

上面的代码生成了以下输出:

再次提醒,这些数据是针对莫斯科的。确保你已经更改了city_key变量,以便获取相关数据。注意,尽管数据中存在一些变化,但并没有异常值。

现在,让我们也为米兰运行这个代码。我们将返回上面,修改city_key变量,然后运行下面的单元格以更新所有内容,正如下图所示:

请注意,这一次我们发现有三个异常值,这些票价低于$600,而平均票价似乎超过了$900,所以这对我们来说是个好结果。

让我们试试另一个城市。这一次,我们将通过更新city_key变量来查看雅典的数据,并运行随后的单元格:

请注意,这一次我们有三个异常值,但它们是极高的票价。由于我们只对便宜票价的异常值感兴趣,我们可以设置一个机制,仅当异常票价低于平均票价时才会发出警报。

现在,我们将创建一些代码来处理这一元素:

city_mean = np.mean(list(city_dict.values())) 

for k,v in out_dates.items(): 
    if v < city_mean: 
        print('Alert for', city_key + '!') 
        print('Fare: $' + str(v), 'on', k) 
        print('\n') 

当我们为雅典运行代码时,它不会生成任何输出。而为米兰运行时,它会生成以下输出:

所以,现在,我们已经创建了一个系统来抓取数据,解析它,并识别异常值。接下来我们将创建一个完整的应用程序,在实时中警告我们。

请记住,我们刚刚对我们的异常值检测模型进行了初步分析。在实际应用中,可能需要进行更加彻底的一系列测试,以确认我们是否为模型选择了可行的参数。

使用 IFTTT 发送实时提醒

为了有机会获得这些便宜的票价,我们需要几乎实时地知道它们何时发生。为此,我们将使用一个名为 If This Then ThatIFTTT)的服务。这个免费的服务允许你通过一系列的触发器和动作将大量服务连接起来。想将 Instagram 上的所有照片保存到你的 iPhone 照片库吗?想每次某个人发推文时收到邮件通知吗?想将你的 Facebook 更新发布到 Twitter 吗?IFTTT 可以做到这一切。请按照以下步骤操作:

  1. 第一步是访问 www.ifttt.com 注册账户。

  2. 一旦完成,你需要注册 Maker 渠道,ifttt.com/maker_webhooks,以及 SMS 渠道,ifttt.com/sms

  3. Maker 允许你通过发送和接收 HTTP 请求来创建 IFTTT 配方。

  4. 一旦你创建了账户并激活了 Maker 和 SMS 渠道,点击主页上的我的 Applets,然后点击新建 Applet:

  1. 然后,点击此项,如下图所示:

  1. 然后,搜索 webhooks 并点击接收一个 Web 请求:

  1. 然后,我们将创建一个名为 cheap_fares 的事件:

  1. 填写事件名称后,点击创建触发器。接下来,我们将设置 +that:

  1. 点击那,然后搜索 SMS 并选择它:

然后,选择发送短信给我:

  1. 之后,我们将自定义我们的消息:

完成后,点击完成以结束设置:

要测试设置,访问 www.ifttt.com/maker_webhooks 并点击设置。你应该能看到包含带有秘密密钥的 URL 的账户信息。复制并粘贴该 URL 到浏览器中。它应该包含一个表单,包含你的秘密密钥和填写城市和价格的字段。

填写 cheap_fares 作为事件,并将城市和票价分别放入 value1 和 value2:

最后,点击测试,它应该在几秒钟内收到一条短信。

现在我们所有的部分都准备好了,是时候将它们整合成一个监控票价 24/7 的单一脚本了。

将所有内容整合在一起

到目前为止,我们一直在 Jupyter Notebook 中工作,但现在,为了部署我们的应用程序,我们将转到文本编辑器中工作。Notebook 非常适合探索性分析和可视化,但运行后台任务最好是在简单的.py文件中完成。所以,开始吧。

我们将从导入开始。如果你还没有安装这些库,可能需要通过pip install安装一些:

import sys 
import sys 
import numpy as np 
from bs4 import BeautifulSoup 
from selenium import webdriver 
import requests 
import scipy 
from PyAstronomy import pyasl 

from datetime import date, timedelta, datetime 
import time 
from time import sleep 
import schedule 

接下来,我们将创建一个函数,拉取数据并运行我们的算法:

def check_flights(): 
   # replace this with the path of where you downloaded chromedriver 
   chromedriver_path = "/Users/alexcombs/Downloads/chromedriver" 

   browser = webdriver.Chrome(chromedriver_path) 

   start_sat = sys.argv[2] 
   end_sat = sys.argv[3] 

   start_sat_date = datetime.strptime(start_sat, '%Y-%m-%d') 
   end_sat_date = datetime.strptime(end_sat, '%Y-%m-%d') 

   fare_dict = {} 

   for i in range(26):     
       sat_start = str(start_sat_date).split()[0] 
       sat_end = str(end_sat_date).split()[0] 

       fare_dict.update({sat_start: {}}) 

       sats = "https://www.google.com/flights/?f=0#f=0&flt=/m/02_286.r/m/02j9z." + \ 
       sat_start + "*r/m/02j9z./m/02_286." + \ 
       sat_end + ";c:USD;e:1;s:0*1;sd:1;t:e" 

       sleep(np.random.randint(10,15)) 

       browser.get(sats) 
       soup = BeautifulSoup(browser.page_source, "html5lib") 

       cards = soup.select('div[class*=info-container]') 

       for card in cards: 
           city = card.select('h3')[0].text 
           fare = card.select('span[class*=price]')[0].text 
           fare_dict[sat_start] = {**fare_dict[sat_start], **{city: fare}} 

       start_sat_date = start_sat_date + timedelta(days=7) 
       end_sat_date = end_sat_date + timedelta(days=7) 

   city_key = sys.argv[1] 

   city_dict = {} 
   for k,v in fare_dict.items(): 
       city_dict.update({k:int(v[city_key].replace(',','').split('$')[1])}) 

   prices = [int(x) for x in city_dict.values()] 
   dates = city_dict.keys() 

   r = pyasl.generalizedESD(prices, 3, 0.025, fullOutput=True) 

   print('Total Outliers:', r[0]) 

   out_dates = {} 
   for i in sorted(r[1]): 
       out_dates.update({list(dates)[i]: list(prices)[i]}) 

   city_mean = np.mean(list(city_dict.values())) 

   for k,v in out_dates.items(): 
       if v < city_mean: 
          requests.post('https://maker.ifttt.com/trigger/cheap_fares/with/key/bNHFwiZx0wMS7EnD425n3T', \ 
             data={ "value1" : str(city_key), "value2" : str(v), "value3" : "" }) 
          print('Alert for', city_key + '!') 
          print('Fare: $' + str(v), 'on', k) 
          print('\n') 
       else: 
          print(str(v) + ' is greater than ' + str(city_mean)) 

最后,我们将加入一个调度器。它将每 60 分钟运行一次我们的代码:

# set up the scheduler to run our code every 60 min 
schedule.every(60).minutes.do(check_flights) 

while 1: 
    schedule.run_pending() 
    time.sleep(1) 

这应该就够了。现在我们可以将其保存为fare_alerter.py,并通过命令行运行它。你需要传入三个参数。第一个是城市,第二个是开始日期,最后一个是结束日期。示例如下:

python fare_alerter.py 'Milan' '2018-12-01' '2018-12-08'

脚本将继续运行,每 60 分钟检查一次票价。如果发生错误票价,我们将是最先得知的之一!

总结

本章我们涵盖了很多内容。我们学会了如何在网上找到最佳的机票数据,如何通过 DOM 操作找到我们要解析的元素,如何识别异常值,最后,如何通过 IFTTT 通过网页请求发送文本提醒。虽然我们这里讲的是机票,但几乎所有的内容都可以应用于任何你希望收到提醒的定价类型。

如果你决定使用它来查询机票价格,希望它能为你带来愉快的旅行!

第四章:使用逻辑回归预测 IPO 市场

在 1990 年代末,参与正确的首次公开募股IPO)就像中了大奖一样。某些科技公司在第一天的回报是其初始发行价格的数倍,如果你足够幸运地获得配售,你就能大赚一笔。以下是这一时期几个首日表现最突出的例子:

  • VA Linux 上涨 697%,1999 年 12 月 9 日

  • Globe.com 上涨 606%,1998 年 11 月 13 日

  • Foundry Networks 上涨 525%,1999 年 9 月 28 日

虽然互联网泡沫的时代已经远去,但 IPO 仍然可能在首日产生巨大的回报。以下是过去一年内一些在首个交易日上涨显著的例子:

  • Bloom Energy 上涨 67%

  • 拼多多上涨 32%

  • Tenable 上涨 32%

正如你所看到的,这仍然是一个值得关注的市场。在本章中,我们将更深入地了解 IPO 市场。我们将看看如何利用机器学习帮助我们决定哪些 IPO 值得关注,哪些我们可能要放弃。

本章内容概览:

  • IPO 市场

  • 数据清洗与特征工程

  • 使用逻辑回归进行二元分类

  • 模型评估

  • 特征重要性

IPO 市场

在我们开始建模之前,先来讨论一下什么是 IPO(首次公开募股),以及研究告诉我们关于这个市场的信息。之后,我们将讨论可以应用的多种策略。

什么是 IPO?

IPO(首次公开募股)是一个私人公司变成上市公司的过程。公开募股为公司筹集资本,并为公众提供通过购买公司股票来投资该公司的机会。

尽管这一过程有所不同,但在典型的发行中,公司会寻求一个或多个投资银行的帮助来承销其发行。这意味着银行向公司保证,他们将在 IPO 当天按发行价购买所有的股票。当然,承销商并不打算自己持有所有的股票。在发行公司的帮助下,他们会进行所谓的路演,以引起机构客户的兴趣。这些客户会对股票进行认购,表示他们有兴趣在 IPO 当天购买股票。这是一份非约束性合同,因为发行价格直到 IPO 当天才会最终确定。然后,承销商会根据表达出的兴趣来设定发行价格。

从我们的角度来看,值得注意的是,研究表明 IPO 常常出现系统性的定价过低现象。有许多理论解释为什么会发生这种情况,以及为什么这种定价过低的程度随时间变化,但研究表明,每年都会有数十亿美元未被充分利用。

在 IPO 中,未被利用的资金是指股票的发行价格与首日收盘价之间的差额。

还有一点需要在继续之前说明,那就是发行价与开盘价的区别。虽然你偶尔可以通过你的经纪人以发行价参与 IPO,但在几乎所有情况下,作为普通公众,你必须以(通常较高的)开盘价购买 IPO。我们将在这个假设下构建我们的模型。

最近 IPO 市场表现

现在我们来看一下 IPO 市场的表现。我们将从IPOScoop.com获取数据,这是一个提供即将上市 IPO 评级的服务。请访问www.iposcoop.com/scoop-track-record-from-2000-to-present/,点击页面底部的按钮下载电子表格。我们将把这个文件加载到 pandas 中,并使用 Jupyter 笔记本进行一些可视化分析。

不幸的是,数据的格式使得我们无法通过常规的.read_csv()方法直接加载到 pandas 中。我们需要使用一个库,允许我们将 Excel 文件读取为 Python 列表,然后执行一些预处理,过滤掉那些不相关的行,主要是表头行以及一些额外的信息。请按照以下步骤设置笔记本:

  1. 现在让我们在笔记本中开始,设置我们需要的库:
import numpy as np 
import pandas as pd 
import xlrd 
import matplotlib.pyplot as plt 
%matplotlib inline 

xlrd库是我们将用来处理之前下载的 Excel 电子表格的库。如果你还没有安装它,可以通过命令行使用pip install xlrd将其添加到你的 Python 环境中。

  1. 下一步是加载工作簿,如下代码块所示:
wb = xlrd.open_workbook('SCOOP-Rating-Performance.xls') 
  1. 现在我们已经加载了整个 Excel 工作簿,让我们定位到我们要处理的工作表,在这个例子中是第一个工作表:
ws = wb.sheet_by_index(0)  
  1. 现在让我们检查一下是否获取了预期的数据:
ws.nrows 
  1. 上面的代码行会生成以下输出:

  1. 与电子表格对比后,这个数字看起来差不多,所以我们现在开始逐行地合并数据:
ipo_list = [] 
for i in range(36,ws.nrows): 
    if isinstance(ws.row(i)[0].value, float): 
        ipo_list.append([x.value for x in ws.row(i)]) 
    else: 
        print(i, ws.row(i)) 

上面的代码会生成以下输出:

让我们来解释一下那段代码发生了什么。首先,我们创建了一个空列表,然后将每一行添加到列表中。接着,我们遍历电子表格中的每一行,检查第一列(最左边的单元格)是否为浮动值。如果是的话,我们将该行所有单元格的值添加到我们的列表中。这是因为Date列在读取时会作为浮动值出现,而我们只关心那些以日期为开头的行。请注意,我们还从第 36 行开始循环,以跳过表格中的摘要数据。

  1. 现在我们再检查一下我们期望的行数是否在列表中:
len(ipo_list) 

上面的代码会生成以下输出:

在去除标题和其他不感兴趣的行后,这样看起来差不多正确。

处理数据框

现在让我们开始准备数据框(DataFrame)以供使用:

df = pd.DataFrame(ipo_list) 

df.head() 

上述代码生成了以下输出:

数据看起来不错,现在让我们添加我们的列:

df.columns = ['Date', 'Company', 'Ticker', 'Managers', \ 
              'Offer Price', 'Opening Price', '1st Day Close',\ 
              '1st Day % Chg', '$ Chg Open', '$ Chg Close',\ 
              'Star Rating', 'Performed'] 

df.head() 

上述代码生成了以下输出:

现在让我们把那个Date列从浮动数值转换为正确的日期格式。xlrd库有一些功能可以帮助我们做到这一点。我们将在一个函数中使用它,确保日期格式正确:

def to_date(x): 
    return xlrd.xldate.xldate_as_datetime(x, wb.datemode) 
df['Date'] = df['Date'].apply(to_date) 
df 

上述代码生成了以下输出:

现在我们有了可以使用的日期数据,让我们添加一些额外的日期相关列,以帮助我们更好地处理数据:

df['Year'], df['Month'], df['Day'], df['Day of Week'] = \ 
df['Date'].dt.year, df['Date'].dt.month, df['Date'].dt.day, df['Date'].dt.weekday 
df 

上述代码生成了以下输出:

现在我们完成了这些步骤,让我们将数据框中的数据与原始电子表格中的数据进行对比:

by_year_cnt = df.groupby('Year')[['Ticker']].count() 

by_year_cnt 

上述代码生成了以下输出:

将此与电子表格中的相同数值进行比较,结果显示我们有几乎相同的数值,所以可以继续进行。

在这里,我们将采取一个额外的步骤,排除有时被称为便士股票,或者是特别低价的股票。然后,我们将检查数据类型,以确保它们看起来合适:

df.drop(df[df['Offer Price'] < 5].index, inplace=True) 

df.reset_index(drop=True, inplace=True) 

df.dtypes 

上述代码生成了以下输出:

这看起来与我们的预期相符,除了1st Day % Chg这一列。我们将通过将数据类型更改为浮动数值来纠正这一点:

df['1st Day % Chg'] = df['1st Day % Chg'].astype(float) 
df.dtypes 

上述代码生成了以下输出:

数据分析

数据类型现在看起来都没问题了,因此我们将通过绘制自 2000 年以来 IPO 的数量来开始我们的探索性分析:

fig, ax = plt.subplots(figsize=(16,8)) 
by_year_cnt.plot(kind='bar', ax=ax, color='crimson') 
ax.legend(['Ticker Count']) 
ax.set_title('IPO Count by Year', fontdict={'size': 18}, y=1.02); 

上述代码生成了以下输出:

从图表中我们可以看到,大多数年份的 IPO 数量超过 100 个,但在 2001 年及 2008 年之后,数量明显减少,这很可能是由于 9/11 事件和金融危机的后果。

总结股票表现

我们将通过执行以下代码来快速总结过去 18 年股票的表现:

summary_by_year = df.groupby('Year')['1st Day % Chg'].describe() 

summary_by_year 

上述代码生成了以下输出:

从表格中,我们可以看到 2000 年 IPO 市场的异常平均回报率。超过 35%的回报率是列表中任何其他年份的两倍多。另一个显著的事实是,每一年的首次交易表现平均回报率都是正数。

让我们绘制首次交易表现,以便更好地了解它:

fig, ax = plt.subplots(figsize=(16,8)) 
summary_by_year['mean'].plot(kind='bar', ax=ax) 
ax.set_title('Mean First Day Percentage Change by Year', fontdict={'size': 18}, y=1.02); 

上述代码生成了以下输出:

关于这些数字的重要一点是,它们不是普通投资者在第一天能够期望获得的回报。只有那些参与了发行的投资者才能期望看到这些数字。

普通投资者在第一天能够期望获得的回报将是开盘价与收盘价之间的差额。这完全不同,也远没有那么有利可图。现在让我们添加一列数据,反映这一值,并看看结果:

df['1st Day Open to Close % Chg'] = ((df['1st Day Close'] - df['Opening Price'])/df['Opening Price']) 

df['1st Day Open to Close % Chg'].describe() 

上述代码生成了以下输出:

这显示出的回报明显不如预期那么令人激动。现在让我们像之前一样绘制这些数据:

fig, ax = plt.subplots(figsize=(16,8)) 
df.groupby('Year')['1st Day Open to Close % Chg'].mean().plot(kind='bar', ax=ax) 
ax.set_title('Mean First Day Open to Close % Change by Year', fontdict={'size': 18}, y=1.02); 

上述代码生成了以下输出:

比较前面的图表和早期的图表,可以明显看到,第一天的年平均回报显示在其数量级范围内,而在许多情况下要低得多。

基准 IPO 策略

假设我们以每个 IPO 的精确开盘价购买一股,并以这些数字中精确的收盘价卖出;那么我们的回报在赚取的美元上会是什么样子呢?

为了回答这个问题,让我们看看实际的开盘到收盘的美元价格变化:

df['1st Day Open to Close $ Chg'] = (df['1st Day Close'] - df['Opening Price']) 

df[df['Year']==2018].sum() 

上述代码生成了以下输出:

从中我们可以看到,第一天从开盘到收盘的总金额刚好超过 28 美元。这个数字是基于 2018 年迄今为止的 173 多只 IPO:

df[df['Year']==2018]['1st Day Open to Close $ Chg'].describe() 

上述代码生成了以下输出:

这反映了每个首次公开募股(IPO)第一天的平均收益略高于 16 美分。请记住,这是在忽略交易成本和滑点的理想条件下计算的。

滑点是你试图进场或出场的目标股票价格与实际成交价格之间的差额。

现在让我们看看这些 IPO 的回报分布是什么样的。这可能有助于我们理解如何提高相对于简单买入每个 IPO 的基准朴素贝叶斯策略的回报:

fig, ax = plt.subplots(figsize=(16,8)) 
df[df['Year']==2018]['1st Day Open to Close % Chg'].plot(kind='hist', bins=25, ax=ax) 

上述代码生成了以下输出:

我们看到回报围绕零分布,但右侧有一个长尾,那里有一些异常的回报。如果我们能够识别出这些异常 IPO 的共同特点并加以利用,那将是非常有利可图的。

让我们看看是否可以利用机器学习来帮助改善来自朴素贝叶斯方法的结果。一个合理的策略似乎是针对右侧的长尾,所以我们将在下一节专注于特征工程。

数据清洗和特征工程

什么因素可能影响一只股票在交易开始时的表现?也许整体市场的表现或者承销商的声望会有影响?也许交易的星期几或月份很重要?在模型中考虑并加入这些因素的过程被称为特征工程,对其建模几乎和使用的数据一样重要。如果你的特征不具备信息量,那么你的模型就不会有价值。

让我们通过添加一些我们认为可能影响 IPO 表现的特征来开始这个过程。

添加特征以影响首次公开募股(IPO)的表现

可能有用的需求度量之一是开盘缺口。这是发行价和开盘价之间的差异。让我们把它添加到我们的 DataFrame 中:

df['Opening Gap % Chg'] = (df['Opening Price'] - df['Offer Price'])/df['Offer Price'] 

接下来,让我们统计一下承销商的数量。也许更多的银行参与能够更好地推广此次发行?这在以下代码块中得到了演示:

def get_mgr_count(x): 
    return len(x.split('/')) 

df['Mgr Count'] = df['Managers'].apply(get_mgr_count) 

让我们快速通过可视化来查看这个假设是否可能成立:

df.groupby('Mgr Count')['1st Day Open to Close % Chg'].mean().to_frame().style.bar(align='mid', color=['#d65f5f', '#5fba7d']) 

上述代码生成了以下输出:

从这个图表上并不明显能看出什么关系,但显然九位银行家是最佳的选择!

接下来,让我们继续提取列表中的第一个承销商。这将是主承销商,也许该银行的声望对首日收益至关重要:

df['Lead Mgr'] = df['Managers'].apply(lambda x: x.split('/')[0])

接下来,让我们快速查看一下我们新创建的列中的数据:

df['Lead Mgr'].unique() 

上述代码生成了以下输出:

即使是粗略检查前面的内容,我们也可以发现数据中存在一些实际问题。很多名字存在不同的拼写和标点形式。在这一点上,我们可以停止并尝试清理数据,如果我们计划依赖我们的模型进行一些重要操作,这将是正确的做法,但因为这只是一个玩具项目,我们将继续进行,希望影响尽可能小。

使用逻辑回归进行二元分类

我们不打算预测总的首日回报率,而是尝试预测是否应该购买这只 IPO 进行交易。此时我们要指出,这不是投资建议,仅用于演示目的。请不要随便拿这个模型去进行 IPO 日内交易,这样做的结果会很糟糕。

现在,要预测二元结果(即10,也就是“是”或“否”),我们将从一个叫做逻辑回归的模型开始。逻辑回归实际上是一个二元分类模型,而不是回归模型。但它确实使用了典型的线性回归形式,只不过是在逻辑函数中进行。

一个典型的单变量回归模型具有以下形式:

在这里,t是单一解释变量x的线性函数。当然,这可以扩展为多个变量的线性组合。对于一个二元结果变量,这种形式的问题是t自然不会落在 1 和 0 之间。

下列方程中的逻辑函数具有一些非常有利的数学性质,包括它能够接受任何数值作为输入(此处为t)并返回一个介于 0 和 1 之间的结果:

图表如下所示:

通过将t替换为我们的回归函数,现在我们有了一个能够提供每个预测变量的重要性(beta 系数)并给出二元预测结果的模型,表示成功的概率,或者是正向结果

在我们继续对数据建模之前,需要将数据转换为适合 scikit-learn 格式的形式。

我们将通过导入一个可以帮助我们完成此任务的库开始;它叫做patsy,如果需要,可以通过 pip 安装:

from patsy import dmatrix 

创建我们模型的目标

现在,我们将为我们的模型创建目标。这是告诉我们模型每个 IPO 是否应该被投资的列。我们将假设任何在第一天回报率达到 2.5%或更高的 IPO 都应该投资。显然,这是一个任意的数字,但它似乎是一个合理的值,足以让我们关注这个投资:

y = df['1st Day Open to Close % Chg'].apply(lambda x: 1 if x > .025 else 0) 

现在我们已经设置了目标列,我们需要设置预测变量。我们将再次使用patsy来实现:

X = dmatrix("Q('Opening Gap % Chg') + C(Q('Month'), Treatment) + C(Q('Day of Week'), Treatment)\ 
+ Q('Mgr Count') + Q('Lead Mgr') + Q('Offer Price') + C(Q('Star Rating'), Treatment)", df, return_type="dataframe") 

让我们讨论一下那行代码的含义。X是我们的设计矩阵,或者说是包含我们预测变量的矩阵。我们已经包含了之前讨论过的可能会对性能产生影响的因素:开盘差距大小、IPO 的月份和日期、发行价格、主承销商、承销商数量,以及最后,IPOScoop.com在 IPO 上市之前提供的星级评分。

为了说明代码行中的 Q 和 C 的含义,Q 仅用于为包含空格的列名提供引号,而 C 用于指示引用的列应作为分类特征并进行虚拟编码。

虚拟编码

虚拟编码是一种方法,如果我们有一列表示学生最喜欢的课程作为预测变量,我们会将每个课程转化为自己的列,并在该列中放置一个1,如果它是学生最喜欢的课程,如下图所示:

来源: http://www.statisticssolutions.com/dummy-coding-the-how-and-why/

一旦完成这一操作,接下来的步骤就是实际删除其中一列。被删除的列将成为基准情况。然后,所有其他情况将与该基准情况进行比较。在我们使用月份作为预测变量的 IPO 示例中,我们将删除一月作为例子,然后所有其他月份将与一月的表现进行比较。星期几或任何其他类别的预测变量也是如此。删除列的目的是防止多重共线性,因为多重共线性会对模型的解释能力产生负面影响。

让我们通过在 Jupyter 单元格中运行以下代码来看看这个代码的实际效果:

X 

上述代码生成以下输出:

现在我们已经有了X*y*,我们准备好拟合模型了。我们将使用一个非常简单的训练/测试划分,并仅在除了最后 200 只 IPO 的所有数据上训练我们的模型:

from sklearn.linear_model import LogisticRegression 

X_train = X[:-200] 
y_train = y[:-200] 

X_test = X[-200:] 
y_test = y[-200:] 

clf = LogisticRegression() 
clf.fit(X_train, y_train) 

至此,我们得到了我们的模型。让我们来评估这个非常简单的模型的表现。

评估模型表现

我们将首先对我们的测试数据进行预测,然后检查我们的预测是否正确:

y_hat = clf.predict(X_test) 
y_true = y_test 

pdf = pd.DataFrame({'y_true': y_true, 'y_hat': y_hat}) 

pdf['correct'] = pdf.apply(lambda x: 1 if x['y_true'] == x['y_hat'] else 0, axis=1) 

pdf 

上述代码生成以下输出:

现在,让我们看看我们应该投资的 200 只 IPO 中的百分比——记住,这意味着它们从开盘到收盘上涨了超过 2.5%:

pdf['y_true'].value_counts(normalize=True) 

上述代码生成以下输出:

因此,超过一半的 IPO 从开盘到收盘上涨了超过 2.5%。让我们看看我们的模型预测的准确性如何:

pdf['correct'].value_counts(normalize=True) 

上述代码生成以下输出:

看起来我们的模型的准确性差不多像抛硬币一样。这似乎不太有前景。但是在投资中,重要的不是准确性,而是预期收益。如果我们有一些小的亏损,但有几个巨大的胜利,总体上,模型仍然可能非常盈利。让我们看看这里是否也是这样。我们将把我们的结果数据与首日变化数据结合起来,以便深入探讨:

results = pd.merge(df[['1st Day Open to Close $ Chg']], pdf, left_index=True, right_index=True) 

results 

上述代码生成以下输出:

首先,让我们看看如果我们投资了每一只 200 只 IPO 中的一只股票,我们的结果会是什么样的:

results['1st Day Open to Close $ Chg'].sum() 

上述代码生成以下输出:

从这点来看,在理想的无成本情境下,我们将获得超过 215 美元的收益。现在,让我们来看看与这些 IPO 相关的其他一些统计数据:

results['1st Day Open to Close $ Chg'].describe() 

上述代码生成以下输出:

根据上述内容,我们看到平均收益略超过 1 美元,而最大亏损是平均收益的 15 倍。我们的模型与这些数据相比如何?首先,我们看看模型建议我们进行的交易以及相应的收益:

# ipo buys 
results[results['y_hat']==1]['1st Day Open to Close $ Chg'].sum() 

上面的代码生成了以下输出:

让我们也看看其他统计数据:

# ipo buys 
results[results['y_hat']==1]['1st Day Open to Close $ Chg'].describe() 

上面的代码生成了以下输出:

在这里,我们看到我们的模型建议只投资 34 个 IPO,平均收益上升到 1.50 美元,最大损失减少到 10 美元以下,我们仍然能够捕捉到表现最好的 IPO。这不是特别出色,但我们可能发现了一些值得关注的东西。我们需要进一步探索,才能真正知道是否有值得进一步扩展的内容。

现在,让我们继续检查那些似乎影响我们模型表现的因素。

从我们的模型中生成特征的重要性

逻辑回归的一个优点是,它提供了预测系数,告诉我们预测变量或特征的相对重要性。对于分类特征,特征系数上的正号告诉我们,当该特征存在时,它增加了正面结果的概率,相对于基准。对于连续特征,正号告诉我们特征值的增加对应于正面结果概率的增加。系数的大小告诉我们概率增加的幅度。

让我们从我们的模型中生成特征的重要性,然后我们可以检查它的影响:

fv = pd.DataFrame(X_train.columns, clf.coef_.T).reset_index() 
fv.columns = ['Coef', 'Feature'] 
fv.sort_values('Coef', ascending=0).reset_index(drop=True) 

上面的代码生成了以下输出:

在上面的截图中,我们看到了那些具有最大系数的特征。让我们看看星期几及其影响:

fv[fv['Feature'].str.contains('Day')] 

上面的代码生成了以下输出:

在这里,星期一是每周的第一天,并且被编码为T.0,即基准情况。所有其他星期几都会与星期一进行比较。从上面的截图中,我们看到星期四似乎是最好的交易日。星期六似乎是进行首次公开募股(IPO)的糟糕日子,很可能是因为那天市场关闭。(很可能,那些日期只是记录错误。)

进一步观察具有最高系数的特征时,我们现在可以理解,对于每个特征提取有预测价值的有用信息是困难的,因为其中许多特征涉及已经不存在的事物。例如,虽然德意志银行仍然存在,但它不再以德意志银行亚历克斯·布朗的名义进行承销,所以它实际上是在传递历史信息,而不是对未来有用的信息。

另一个问题是,特征并没有反映它们产生影响的频率。一个只在 2000 年运营并且拥有 3 个非常成功 IPO 的银行,其出现会有一个非常大的正系数,但在我们的建模工作中是没有意义的。

随机森林分类器方法

另一种建模方法是通过随机森林分类器得出的特征重要性,它告诉我们哪些特征对模型有影响。这种方法更准确地反映了某个特征的真实影响。

让我们将数据输入这种类型的模型并检查结果:

from sklearn.ensemble import RandomForestClassifier 
clf_rf = RandomForestClassifier(n_estimators=1000) 
clf_rf.fit(X_train, y_train) 

f_importances = clf_rf.feature_importances_ 

f_names = X_train.columns 
f_std = np.std([tree.feature_importances_ for tree in clf_rf.estimators_], axis=0) 

zz = zip(f_importances, f_names, f_std) 
zzs = sorted(zz, key=lambda x: x[0], reverse=True) 

n_features = 10 
imps = [x[0] for x in zzs[:n_features]] 
labels = [x[1] for x in zzs[:n_features]] 
errs = [x[2] for x in zzs[:n_features]] 

fig, ax = plt.subplots(figsize=(16, 8)) 
ax.bar(range(n_features), imps, color="r", yerr=errs) 
plt.xticks(range(n_features), labels) 
plt.setp( ax.xaxis.get_majorticklabels(), rotation=-70, ha="left" ); 

上述代码生成了以下输出:

在前面的代码中,我们运行了一个随机森林分类器,提取并排序了特征的重要性,然后将这些值与它们的误差条一起绘制了图表。

从这些数据中,我们可以看出,模型中影响最大的是开盘差距、报价和参与交易的经理人数。这些特征似乎都有预测价值,因为它们表明该交易有强烈的需求。

总结

本章我们涵盖了很多内容,但在构建这种类型的模型方面,我们仅仅触及了表面。希望你能更好地理解建模过程,从数据清理、特征工程到测试。并且希望你能利用这些信息自己扩展模型并加以改进。

在下一章,我们将转向一个完全不同的领域,从数字数据转向基于文本的数据。

第五章:创建自定义新闻订阅源

我阅读的很多。有些人甚至会说是强迫症。我曾经一天看过超过一百篇文章。但即便如此,我经常感到自己还在寻找更多的文章。总有一种隐隐的感觉,觉得自己错过了什么有趣的东西,永远会在知识的空白中受困!

如果你也有类似的困扰,不用担心,因为在本章中,我将揭示一个简单的技巧,帮助你找到所有你想阅读的文章,而不必翻找那些你不感兴趣的。

到本章结束时,你将学会如何构建一个能够理解你新闻兴趣的系统,并且每天向你发送个性化的新闻简报。

本章内容概述:

  • 使用 Pocket 应用创建监督学习数据集

  • 利用 Pocket API 获取文章

  • 使用 Embedly API 提取文章正文

  • 自然语言处理基础

  • 支持向量机

  • IFTTT 与 RSS 订阅和 Google Sheets 的集成

  • 设置每日个人新闻简报

使用 Pocket 创建一个监督学习数据集

在我们创建一个能够理解我们新闻兴趣的模型之前,我们需要训练数据。这些训练数据将输入到我们的模型中,以教会它区分我们感兴趣的文章和不感兴趣的文章。为了构建这个语料库,我们需要标注大量文章,标注它们是否符合我们的兴趣。我们将为每篇文章标记yn,表示它是否是我们希望每天收到的新闻简报中的文章。

为了简化这个过程,我们将使用 Pocket 应用。Pocket 是一个可以让你保存稍后阅读的故事的应用程序。你只需安装浏览器扩展程序,然后在想保存文章时,点击浏览器工具栏中的 Pocket 图标。文章会保存到你的个人仓库。Pocket 的一个强大功能是你可以为保存的文章打上自定义标签。我们将用y标记有趣的文章,用n标记不感兴趣的文章。

安装 Pocket Chrome 扩展

我正在使用 Google Chrome,但其他浏览器应该也能类似操作。按照以下步骤安装 Pocket Chrome 扩展:

  1. 对于 Chrome,访问 Google 应用商店并查找扩展程序部分:

Pocket Chrome 扩展

  1. 点击“添加到 Chrome”。如果你已经有账户,登录即可;如果没有,注册一个(是免费的)。

  2. 完成后,你应该能在浏览器的右上角看到 Pocket 图标。

  3. 它会变灰,但一旦有你想保存的文章,你可以点击它。文章保存后,它会变为红色:

保存的页面如下所示:

《纽约时报》保存页面

现在是有趣的部分!在你的一天中,开始保存你想阅读的文章,以及你不想阅读的文章。将感兴趣的文章标记为y,将不感兴趣的文章标记为n。这将需要一些工作。你的最终结果将与训练集的质量直接相关,因此你需要为成百上千的文章进行标记。如果你保存文章时忘记标记,可以随时访问www.get.pocket.com,在那里进行标记。

使用 Pocket API 来检索文章

现在你已经认真地将文章保存到 Pocket,下一步是检索它们。为此,我们将使用 Pocket API。你可以在getpocket.com/developer/apps/new注册一个账户。请按照步骤完成:

  1. 点击左上角的“创建新应用”,并填写详细信息以获取你的 API 密钥。

  2. 确保点击所有权限,以便你可以添加、更改和检索文章:

  1. 一旦你填写并提交了这些,你将收到你的消费者密钥

  2. 你可以在左上角的“我的应用”下找到它。它看起来会像下面的截图,但显然会有一个真实的密钥:

  1. 一旦设置完成,你就可以继续进行下一步,即设置授权。我们现在开始操作。

  2. 这要求你输入消费者密钥和重定向 URL。重定向 URL 可以是任何内容。在这里,我使用了我的 Twitter 账户:

import requests 
import pandas as pd 
import json 
pd.set_option('display.max_colwidth', 200) 

CONSUMER_KEY = 'enter_your_consumer_key_here 

auth_params = {'consumer_key': CONSUMER_KEY, 'redirect_uri': 'https://www.twitter.com/acombs'} 

tkn = requests.post('https://getpocket.com/v3/oauth/request', data=auth_params) 

tkn.text 

上面的代码将产生以下输出:

  1. 输出将包含你下一步所需的代码。将以下内容放入浏览器地址栏:

getpocket.com/auth/authorize?request_token=some_long_access_code&amp;redirect_uri=https%3A//www.twitter.com/acombs

  1. 如果你将重定向 URL 更改为你自己的某个 URL,请确保进行 URL 编码(就是你在前面的 URL 中看到的%3A类型的东西)。

  2. 此时,你应该会看到一个授权屏幕。点击同意,然后我们可以继续进行下一步:

# below we parse out the access code from the tkn.text string 
ACCESS_CODE = tkn.text.split('=')[1] 

usr_params = {'consumer_key': CONSUMER_KEY, 'code': ACCESS_CODE} 

usr = requests.post('https://getpocket.com/v3/oauth/authorize', data=usr_params) 

usr.text 

上面的代码将产生以下输出:

  1. 我们将使用这里的输出代码,继续检索这些文章。首先,我们检索标记为n的文章:
# below we parse out the access token from the usr.text string 
ACCESS_TOKEN = usr.text.split('=')[1].split('&amp;')[0] 

no_params = {'consumer_key': CONSUMER_KEY, 
'access_token': ACCESS_TOKEN, 
'tag': 'n'} 

no_result = requests.post('https://getpocket.com/v3/get', data=no_params) 

no_result.text 

上面的代码将产生以下输出:

你会注意到我们在所有标记为n的文章中有一个很长的 JSON 字符串。这个字符串中有多个键,但此时我们只关心 URL。

  1. 我们将继续创建一个包含所有 URL 的列表:
no_jf = json.loads(no_result.text) 
no_jd = no_jf['list'] 

no_urls=[] 
for i in no_jd.values(): 
    no_urls.append(i.get('resolved_url')) no_urls 

上面的代码将产生以下输出:

URL 列表

  1. 这个列表包含了我们不感兴趣的所有故事的 URL。现在我们将它放入一个 DataFrame,并标记为不感兴趣:
no_uf = pd.DataFrame(no_urls, columns=['urls']) 
no_uf = no_uf.assign(wanted = lambda x: 'n') no_uf 

前面的代码产生了以下输出:

标记 URL

  1. 现在我们已经处理好了不感兴趣的故事。让我们对我们感兴趣的故事做同样的事情:
yes_params = {'consumer_key': CONSUMER_KEY, 
'access_token': ACCESS_TOKEN, 
'tag': 'y'} 
yes_result = requests.post('https://getpocket.com/v3/get', data=yes_params) 

yes_jf = json.loads(yes_result.text) 
yes_jd = yes_jf['list'] 

yes_urls=[] 
for i in yes_jd.values(): 
    yes_urls.append(i.get('resolved_url')) 

yes_uf = pd.DataFrame(yes_urls, columns=['urls']) 
yes_uf = yes_uf.assign(wanted = lambda x: 'y') 

yes_uf 

前面的代码产生了以下输出:

标记我们感兴趣的故事的 URL

  1. 现在我们已经拥有了两种类型的故事作为训练数据,让我们将它们合并成一个单一的 DataFrame:
df = pd.concat([yes_uf, no_uf]) 

df.dropna(inplace=True) 

df 

前面的代码产生了以下输出:

合并 URL——感兴趣的和不感兴趣的

现在我们已经将所有 URL 和对应的标签放入一个框架中,接下来我们将开始下载每篇文章的 HTML 内容。我们将使用另一个免费的服务来完成这个操作,名为 Embedly。

使用 Embedly API 下载故事正文

我们已经拥有了所有故事的 URL,但不幸的是,这还不足以进行训练;我们还需要完整的文章正文。如果我们想自己编写抓取程序,这可能会变成一个巨大的挑战,特别是当我们需要从数十个网站上提取故事时。我们需要编写代码来专门提取文章正文,同时小心避免抓取到周围的其他无关内容。幸运的是,就我们而言,有一些免费的服务可以帮我们做到这一点。我将使用 Embedly 来完成这项任务,但你也可以选择其他服务。

第一步是注册 Embedly API 访问权限。你可以在 app.embed.ly/signup 上进行注册。这个过程很简单。确认注册后,你会收到一个 API 密钥。这就是你需要的一切。你只需要在 HTTP 请求中使用这个密钥。现在我们来操作一下:

import urllib 

EMBEDLY_KEY = 'your_embedly_api_key_here' 

def get_html(x): 
    try: 
        qurl = urllib.parse.quote(x) 
        rhtml = requests.get('https://api.embedly.com/1/extract?url=' + qurl + '&amp;key=' + EMBEDLY_KEY) 
        ctnt = json.loads(rhtml.text).get('content') 
    except: 
        return None 
    return ctnt 

前面的代码产生了以下输出:

HTTP 请求

到此为止,我们已经获取了每篇故事的 HTML 内容。

由于内容嵌入在 HTML 标记中,而我们希望将纯文本输入到模型中,我们将使用解析器来剥离掉标记标签:

from bs4 import BeautifulSoup 

def get_text(x): 
    soup = BeautifulSoup(x, 'html5lib') 
    text = soup.get_text() 
    return text 

df.loc[:,'text'] = df['html'].map(get_text) 

df 

前面的代码产生了以下输出:

到此为止,我们已经准备好了训练集。现在可以开始讨论如何将文本转化为模型能够处理的形式。

自然语言处理基础

如果机器学习模型只能处理数字数据,我们该如何将文本转化为数字表示呢?这正是自然语言处理NLP)的核心问题。我们来简单了解一下它是如何做到的。

我们将从一个包含三句话的小语料库开始:

  1. 那只新小猫和其他小猫一起玩

  2. 她吃了午饭

  3. 她爱她的小猫

我们将首先将语料库转换为词袋模型BOW)表示。暂时跳过预处理。将语料库转换为 BOW 表示包括获取每个词及其出现次数,以创建所谓的词项-文档矩阵。在词项-文档矩阵中,每个独特的单词会被分配到一列,每个文档会被分配到一行。它们的交点处会记录出现次数:

序号 the 小猫 其他 小猫们 午餐
1 1 1 1 1 1 1 1 0 0 0 0 0
2 0 0 0 0 0 0 0 1 1 1 0 0
3 0 0 1 0 0 0 0 1 0 0 1 1

请注意,对于这三句话,我们已经有了 12 个特征。正如你所想象的那样,如果我们处理的是实际的文档,比如新闻文章或甚至是书籍,特征的数量会激增到数十万。为了减少这种膨胀,我们可以采取一系列措施,删除那些对分析几乎没有信息价值的特征。

我们可以采取的第一步是移除停用词。这些是那些如此常见的词,通常不会提供关于文档内容的任何信息。常见的英语停用词有theisatwhichon。我们将删除这些词,并重新计算我们的词项-文档矩阵:

序号 小猫 小猫们 午餐
1 1 1 1 1 0 0 0
2 0 0 0 0 1 1 0
3 0 1 0 0 0 0 1

如你所见,特征数量从 12 个减少到了 7 个。这很好,但我们还可以进一步减少特征。我们可以进行词干提取词形还原来进一步减少特征。注意,在我们的矩阵中,我们有kittenkittens两个词。通过词干提取或词形还原,我们可以将其合并成kitten

序号 小猫 午餐
1 1 2 1 0 0 0
2 0 0 0 1 1 0
3 0 1 0 0 0 1

我们的新矩阵将kittenskitten合并了,但还发生了其他变化。我们丢失了playedloved的后缀,ate被转换成了eat。为什么?这就是词形还原的作用。如果你还记得小学的语法课,我们已经从动词的屈折形式转到了词根形式。如果这就是词形还原,那词干提取是什么呢?词干提取有相同的目标,但采用的是一种不那么精细的方法。这种方法有时会生成伪单词,而不是实际的词根形式。例如,在词形还原中,如果将ponies还原,你会得到pony,但在词干提取中,你会得到poni

现在让我们进一步应用另一个变换到我们的矩阵。到目前为止,我们使用了每个词的简单计数,但我们可以应用一种算法,这种算法会像一个过滤器一样作用于我们的数据,增强每个文档中独特的词汇。这个算法叫做词频-逆文档频率 (tf-idf)

我们为矩阵中的每个词项计算 tf-idf 比率。让我们通过几个例子来计算。对于文档一中的词汇new,词频就是它的出现次数,即1。逆文档频率是通过计算语料库中所有文档数与该词出现的文档数的比值的对数来得出的。对于new,这个值是log (3/1),即.4471。所以,完整的 tf-idf 值就是tf * idf,或者在这里,它是1 x .4471,即.4471。对于文档一中的词汇kitten,tf-idf 是2 * log (3/2),即.3522。

完成其余词项和文档的计算后,我们得到了以下结果:

序号 new kitten play eat lunch love
1 .4471 .3522 .4471 0 0 0
2 0 0 0 .4471 .4471 0
3 0 .1761 0 0 0 .4471

为什么要做这些呢?假设我们有一个关于多个主题(医学、计算机、食物、动物等)的文档集,并且我们想将它们分类成不同的主题。很少有文档会包含词汇sphygmomanometer,它是用来测量血压的仪器;而所有包含这个词的文档,可能都涉及医学主题。显然,这个词在文档中出现的次数越多,它就越可能与医学相关。所以,一个在整个语料库中很少出现,但在某个文档中出现多次的词汇,很可能与该文档的主题紧密相关。通过这种方式,可以认为文档是通过那些具有高 tf-idf 值的词汇来表示的。

在这个框架的帮助下,我们现在将训练集转换为 tf-idf 矩阵:

from sklearn.feature_extraction.text import TfidfVectorizer 

vect = TfidfVectorizer(ngram_range=(1,3), stop_words='english', min_df=3) 

tv = vect.fit_transform(df['text']) 

通过这三行,我们已将所有文档转换为 tf-idf 向量。我们传入了若干参数:ngram_rangestop_wordsmin_df。接下来我们逐一讨论。

首先,ngram_range是文档的分词方式。在之前的示例中,我们使用每个单词作为一个词元,但在这里,我们将所有的 1 到 3 个词的序列作为词元。以我们第二句“She ate lunch”为例。我们暂时忽略停用词。这个句子的 n-grams 会是:sheshe ateshe ate lunchateate lunchlunch

接下来是stop_words。我们传入english,以移除所有的英文停用词。正如之前讨论的,这会移除所有缺乏信息内容的词汇。

最后,我们有min_df。这个参数会移除所有在至少三个文档中未出现的词汇。添加这个选项可以去除非常稀有的词汇,并减少我们的矩阵大小。

现在,我们的文章语料库已经转化为可操作的数值格式,我们将继续将其输入到分类器中。

支持向量机

在这一章中,我们将使用一种新的分类器——线性 支持向量机SVM)。SVM 是一种算法,试图通过使用 最大边距超平面 来将数据点线性地分到不同类别。这个词听起来很复杂,所以我们来看看它到底是什么意思。

假设我们有两个类别的数据,并且我们想用一条线将它们分开。(在这里我们只处理两个特征或维度。)放置这条线的最有效方式是什么呢?让我们看看下面的插图:

在上面的图示中,线 H[1] 并不能有效地区分这两个类别,所以我们可以去掉它。线 H[2] 能够干净利落地区分它们,但 H[3] 是最大边界线。这意味着这条线位于每个类别的两个最近点之间的中间,这些点被称为 支持向量。这些可以在下图中看到为虚线:

如果数据无法如此整齐地被分割成不同类别呢?如果数据点之间存在重叠呢?在这种情况下,仍然有一些选项。一种方法是使用所谓的 软边距 SVM。这种公式仍然最大化边距,但其代价是对落在边距错误一侧的点进行惩罚。另一种方法是使用所谓的 核技巧。这种方法将数据转换到一个更高维度的空间,在那里数据可以被线性分割。这里提供了一个示例:

二维表示如下:

我们已经将一维特征空间映射到二维特征空间。这个映射只是将每个 x 值映射到 x。这样做使我们能够添加一个线性分隔平面。

既然已经涵盖了这些,我们现在将我们的 tf-idf 矩阵输入到 SVM 中:

from sklearn.svm import LinearSVC 

clf = LinearSVC() 
model = clf.fit(tv, df['wanted']) 

tv 是我们的矩阵,而 df['wanted'] 是我们的标签列表。记住,这个列表的内容要么是 y,要么是 n,表示我们是否对这篇文章感兴趣。一旦运行完毕,我们的模型就训练完成了。

在这一章中,我们没有正式评估我们的模型。你几乎总是应该有一个保留集来评估你的模型,但由于我们将不断更新模型并每天评估它,所以本章将跳过这一步。请记住,这通常是一个非常糟糕的主意。

现在我们继续设置我们的每日新闻订阅源。

IFTTT 与订阅源、Google Sheets 和电子邮件的集成

我们使用 Pocket 来构建我们的训练集,但现在我们需要一个文章的流媒体订阅源来运行我们的模型。为了设置这个,我们将再次使用 IFTTT、Google Sheets,以及一个允许我们与 Google Sheets 进行交互的 Python 库。

通过 IFTTT 设置新闻订阅源和 Google Sheets

希望此时你已经设置好 IFTTT 帐户,如果没有,请现在设置好。完成后,你需要设置与订阅源和 Google Sheets 的集成:

  1. 首先,在主页的搜索框中搜索“feeds”,然后点击“服务”,接着点击进行设置:

  1. 你只需要点击“连接”:

  1. 接下来,在“服务”下搜索Google Drive

  1. 点击该按钮,它将带你到一个页面,在这里你可以选择你想要连接的 Google 帐户。选择帐户后,点击“允许”以使 IFTTT 访问你的 Google Drive 帐户。完成后,你应该会看到以下内容:

  1. 现在,连接了我们的频道,我们可以设置订阅源。点击右上角用户名下拉菜单中的“新建 Applet”。这将带你到这里:

  1. 点击+this,搜索RSS Feed,然后点击它。这将带你到如下页面:

  1. 从这里,点击“新建订阅源项”:

  1. 然后,将 URL 添加到框中并点击“创建触发器”。完成后,你将返回继续添加+that 动作:

  1. 点击+that,搜索Sheets,然后点击它的图标。完成后,你会看到如下界面:

  1. 我们希望我们的新闻项流入 Google Drive 电子表格,因此点击“添加行到电子表格”。然后你将有机会自定义电子表格:

我将电子表格命名为NewStories,并将其放入名为IFTTT的 Google Drive 文件夹中。点击“创建动作”完成配方设置,很快你就会看到新闻项开始流入你的 Google Drive 电子表格。请注意,它只会添加新进项,而不是你创建表格时已经存在的项。我建议你添加多个订阅源。你需要为每个源创建单独的配方。最好添加你训练集中网站的订阅源,换句话说,就是你在 Pocket 中保存的那些网站。

给这些故事一两天的时间在表格中积累,然后它应该会看起来像这样:

幸运的是,完整的文章 HTML 主体已包括在内。这意味着我们不需要使用 Embedly 为每篇文章下载它。我们仍然需要从 Google Sheets 下载文章,然后处理文本以去除 HTML 标签,但这一切都可以相对轻松地完成。

为了下载文章,我们将使用一个名为gspread的 Python 库。可以通过 pip 安装。一旦安装完成,你需要按照OAuth 2的设置步骤进行操作。具体可以参考gspread.readthedocs.org/en/latest/oauth2.html。你最终会下载一个 JSON 凭证文件。至关重要的是,在获得该文件后,找到其中带有client_email键的电子邮件地址。然后,你需要将你要发送故事的NewStories电子表格与该电子邮件地址共享。只需点击表格右上角的蓝色“分享”按钮,并粘贴电子邮件地址。你会在 Gmail 账户中收到发送失败的消息,但这是预期的。确保在以下代码中替换为你文件的路径和文件名:

import gspread 

from oauth2client.service_account import ServiceAccountCredentials 
JSON_API_KEY = 'the/path/to/your/json_api_key/here' 

scope = ['https://spreadsheets.google.com/feeds', 
         'https://www.googleapis.com/auth/drive'] 

credentials = ServiceAccountCredentials.from_json_keyfile_name(JSON_API_KEY, scope) 
gc = gspread.authorize(credentials) 

现在,如果一切顺利,它应该能正常运行。接下来,你可以下载这些故事:

ws = gc.open("NewStories") 
sh = ws.sheet1 

zd = list(zip(sh.col_values(2),sh.col_values(3), sh.col_values(4))) 

zf = pd.DataFrame(zd, columns=['title','urls','html']) 
zf.replace('', pd.np.nan, inplace=True) 
zf.dropna(inplace=True) 

zf 

上述代码会产生以下输出:

通过这一过程,我们从源中下载了所有文章,并将它们放入了 DataFrame 中。现在我们需要去除 HTML 标签。我们可以使用之前用来提取文本的函数。然后,我们将使用 tf-idf 向量化器进行转换:

zf.loc[:,'text'] = zf['html'].map(get_text) 

zf.reset_index(drop=True, inplace=True) 

test_matrix = vect.transform(zf['text']) 

test_matrix 

上述代码会产生以下输出:

在这里,我们看到我们的向量化已经成功。接下来,我们将其传入模型以获取结果:

results = pd.DataFrame(model.predict(test_matrix), columns=['wanted']) 

results 

上述代码会产生以下输出:

在这里,我们看到每篇故事都有对应的结果。现在让我们将它们与故事本身合并,这样我们就可以评估结果:

rez = pd.merge(results,zf, left_index=True, right_index=True) 

rez 

上述代码会产生以下输出:

在此时,我们可以通过查看结果并纠正错误来改进模型。你需要自己完成这个步骤,但这是我如何修改我自己的模型:

change_to_no = [130, 145, 148, 163, 178, 199, 219, 222, 223, 226, 235, 279, 348, 357, 427, 440, 542, 544, 546, 568, 614, 619, 660, 668, 679, 686, 740, 829] 

change_to_yes = [0, 9, 29, 35, 42, 71, 110, 190, 319, 335, 344, 371, 385, 399, 408, 409, 422, 472, 520, 534, 672] 

for i in rez.iloc[change_to_yes].index: 
    rez.iloc[i]['wanted'] = 'y' 

for i in rez.iloc[change_to_no].index: 
    rez.iloc[i]['wanted'] = 'n' 

rez 

上述代码会产生以下输出:

这些看起来可能是很多更改,但在评估的 900 多篇文章中,我只需要更改非常少的几篇。通过这些修正,我们现在可以将其反馈到我们的模型中,以进一步改进它。让我们将这些结果添加到我们之前的训练数据中,然后重新构建模型:

combined = pd.concat([df[['wanted', 'text']], rez[['wanted', 'text']]]) combined 

上述代码会产生以下输出:

使用以下代码重新训练模型:

tvcomb = vect.fit_transform(combined['text'], combined['wanted']) 

model = clf.fit(tvcomb, combined['wanted']) 

现在我们已经使用所有可用的数据重新训练了我们的模型。随着时间的推移,您可能需要多次执行此操作,以获取更多的结果。添加的数据越多,结果将会越好。

在这一点上,我们假设您已经有一个训练有素的模型,并准备开始使用它。现在让我们看看如何部署它来设置个性化新闻订阅。

设置您的每日个性化新闻简报

为了设置一个包含新闻故事的个人电子邮件,我们将再次利用 IFTTT。与之前一样,在第三章中,构建一个查找廉价机票的应用,我们将使用 Webhooks 渠道发送 POST 请求。但这次,负载将是我们的新闻故事。如果您还没有设置 Webhooks 渠道,请立即执行此操作。有关说明,请参阅第三章。您还应该设置 Gmail 渠道。完成后,我们将添加一个配方来将两者结合起来。按照以下步骤设置 IFTTT:

  1. 首先,从 IFTTT 主页点击新 Applet,然后点击 +this。然后,搜索 Webhooks 渠道:

  1. 选择它,然后选择接收 Web 请求:

  1. 然后,给请求一个名称。我使用 news_event

  1. 最后,点击创建触发器。接下来,点击 +that 来设置电子邮件部分。搜索 Gmail 并点击它:

  1. 一旦您点击了 Gmail,请点击发送自己邮件。从那里,您可以自定义您的电子邮件消息:

输入主题行,并在电子邮件正文中包含 {{Value1}}。我们将通过我们的 POST 请求将故事标题和链接传递到这里。点击创建操作,然后完成以完成设置。

现在,我们准备生成一个计划运行的脚本,自动发送我们感兴趣的文章。我们将为此创建一个单独的脚本,但我们现有的代码中还需要做一件事情,即序列化我们的向量化器和模型,如下面的代码块所示:

import pickle 

pickle.dump(model, open(r'/input/a/path/here/to/news_model_pickle.p', 'wb')) 

pickle.dump(vect, open(r'/input/a/path/here/to/news_vect_pickle.p', 'wb')) 

现在,我们已经保存了我们模型所需的一切。在我们的新脚本中,我们将读取它们以生成我们的新预测。我们将使用与我们在第三章中使用的相同调度库来运行代码,构建一个查找廉价机票的应用。将所有内容组合起来,我们有以下脚本:

import pandas as pd 
from sklearn.feature_extraction.text import TfidfVectorizer 
from sklearn.svm import LinearSVC 
import schedule 
import time 
import pickle 
import json 
import gspread 
from oauth2client.service_account import ServiceAccountCredentials 
import requests 
from bs4 import BeautifulSoup 

def fetch_news(): 

    try: 
        vect = pickle.load(open(r'/your/path/to/news_vect_pickle.p', 'rb')) 
        model = pickle.load(open(r'/your/path/to /news_model_pickle.p', 'rb')) 

        JSON_API_KEY = r'/your/path/to/API KEY.json' 

        scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive'] 

        credentials = ServiceAccountCredentials.from_json_keyfile_name(JSON_API_KEY, scope) 
        gc = gspread.authorize(credentials) 

        ws = gc.open("NewStories") 
        sh = ws.sheet1 
        zd = list(zip(sh.col_values(2),sh.col_values(3), sh.col_values(4))) 
        zf = pd.DataFrame(zd, columns=['title','urls','html']) 
        zf.replace('', pd.np.nan, inplace=True) 
        zf.dropna(inplace=True) 

        def get_text(x): 
            soup = BeautifulSoup(x, 'html5lib') 
            text = soup.get_text() 
            return text 

        zf.loc[:,'text'] = zf['html'].map(get_text) 

        tv = vect.transform(zf['text']) 
        res = model.predict(tv) 

        rf = pd.DataFrame(res, columns=['wanted']) 
        rez = pd.merge(rf, zf, left_index=True, right_index=True) 

        rez = rez.iloc[:20,:] 

        news_str = '' 
 for t, u in zip(rez[rez['wanted']=='y']['title'], rez[rez['wanted']=='y']['urls']): 
            news_str = news_str + t + '\n' + u + '\n' 

        payload = {"value1" : news_str} 
        r = requests.post('https://maker.ifttt.com/trigger/news_event/with/key/bNHFwiZx0wMS7EnD425n3T', data=payload) 

        # clean up worksheet 
        lenv = len(sh.col_values(1)) 
        cell_list = sh.range('A1:F' + str(lenv)) 
        for cell in cell_list: 
            cell.value = "" 
        sh.update_cells(cell_list) 
        print(r.text) 

    except: 
        print('Action Failed') 

schedule.every(480).minutes.do(fetch_news) 

while 1: 
    schedule.run_pending() 
    time.sleep(1) 

这个脚本将每 4 小时运行一次,从 Google Sheets 下载新闻故事,通过模型运行故事,通过向 IFTTT 发送 POST 请求生成邮件,预测为感兴趣的故事,最后清空电子表格中的故事,以便在下一封电子邮件中只发送新故事。

恭喜!你现在拥有了属于自己的个性化新闻推送!

总结

在本章中,我们学习了如何在训练机器学习模型时处理文本数据。我们还学习了自然语言处理(NLP)和支持向量机(SVM)的基础知识。

在下一章,我们将进一步发展这些技能,并尝试预测哪些内容会变得流行。

第六章:预测你的内容是否会成为病毒式传播

像许多伟大的事物一样,这一切都始于一场赌注。那是 2001 年,乔纳·佩雷蒂当时是麻省理工学院的研究生,他在拖延。与其写论文,他决定接受耐克提供的定制运动鞋服务。在耐克最近推出的一个项目下,任何人都可以通过他们的网站 NIKEiD 来定制鞋子。唯一的问题,至少从耐克的角度来看,就是佩雷蒂要求在鞋子上印上“sweatshop”字样,这显然是不可行的。佩雷蒂通过一系列电子邮件回应,指出这个词绝不属于任何会导致个性化请求被拒绝的敏感词汇类别。

佩雷蒂认为其他人也许会觉得他与耐克客服代表之间的来回邮件很有趣,于是将这些邮件转发给了几位亲密的朋友。几天之内,这些邮件传到了全世界的收件箱里。包括《时代》杂志、Salon、《卫报》以及《今日秀》等主要媒体纷纷报道。佩雷蒂成为了一场病毒式现象的中心。

但困扰佩雷蒂的问题是,这种事情能否被复制?他的朋友卡梅伦·马洛正在准备写他的博士论文,研究病毒式现象,并坚信这种事情过于复杂,任何人都无法复制。而在这里,赌注开始起作用。马洛打赌,佩雷蒂无法复制他在最初那组与耐克的电子邮件中获得的成功。

快进到 15 年后,乔纳·佩雷蒂领导着那个名字已与病毒式传播划上等号的网站——BuzzFeed。2015 年,它的独立访客超过 7700 万,排名超越了《纽约时报》的总覆盖面。我想可以放心地说,佩雷蒂赢得了那场赌注。

那么佩雷蒂到底是如何做到的呢?他是如何拼凑出创造如野火般传播内容的秘密公式的?在本章中,我们将尝试解开其中的一些谜团。我们将研究一些最具分享性的内容,并尝试找出与人们不太愿意分享的内容之间的共同点。

本章将涵盖以下主题:

  • 研究告诉我们关于病毒式传播的什么?

  • 获取共享数量和内容

  • 探索分享性特点

  • 构建一个预测内容得分模型

研究告诉我们关于病毒式传播的什么?

理解分享行为是大生意。随着消费者越来越对传统广告视而不见,推动的力量是超越简单的推销,讲述引人入胜的故事。越来越多地,这些努力的成功是通过社交分享来衡量的。为什么要这么麻烦?因为作为一个品牌,每一次分享都代表着我接触到的另一个消费者——而这一切不需要花费额外的一分钱。

由于这种价值,一些研究者已经研究了分享行为,希望了解其动机。研究者们发现的动机包括以下几点:

  • 为了为他人提供实际价值(利他动机)

  • 为了与某些观念和概念关联(身份动机)

  • 为了与他人围绕共同的情感建立联系(社群动机)

关于最后一个动机,有一项特别精心设计的研究调查了《纽约时报》上的 7,000 篇内容,研究了情感对分享的影响。他们发现,仅仅简单的情感倾向不足以解释分享行为,但当情感与情绪激发相结合时,解释力更强。

例如,尽管悲伤具有强烈的负面情感,它被认为是一种低激发状态。而愤怒则具有负面情感,且与高度激发状态相伴。因此,令读者感到悲伤的故事往往生成的分享远少于激怒读者的故事。那么,难道我们不应该感到惊讶的是,如今在政治中起着重要作用的假新闻往往就是这种形式吗?下图展示了相同的结果:

该图摘自《什么使在线内容具有病毒性?》,作者:乔纳·伯杰(Jonah Berger)与凯瑟琳·L·米尔克曼(Katherine L. Milkman),《市场营销研究杂志》,可通过以下网址获得:http://jonahberger.com/wp-content/uploads/2013/02/ViralityB.pdf

这部分内容涵盖了动机方面的内容,但如果我们将这些因素保持不变,其他属性如何影响内容的病毒性呢?其中的一些因素可能包括以下内容:标题措辞、标题长度、标题的词性、内容长度、发布的社交网络、主题、内容的时效性等等。毫无疑问,一个人可以花费一生的时间研究这一现象。然而,现阶段,我们只会花大约 30 页左右的篇幅来进行探讨。从那里,你可以决定是否希望进一步深入研究。

共享计数和内容的来源

在我们开始探讨哪些特征使内容具有可分享性之前,我们需要收集大量的内容,并获取有关这些内容被分享的频率的数据。不幸的是,获取这类数据在过去几年变得更加困难。实际上,当本书的第一版在 2016 年出版时,这些数据是很容易获得的。但如今,似乎没有免费的数据来源,尽管如果你愿意支付费用,仍然可以找到这些数据。

幸运的是,我拥有一个从现已关闭的网站ruzzit.com收集的数据集。当这个网站还在运营时,它追踪了随着时间推移分享次数最多的内容,这正是我们这个项目所需要的:

我们将首先将我们的导入文件加载到笔记本中,像往常一样,然后加载数据。这个数据是 JSON 格式的。我们可以使用 pandas 的read_json()方法读取,如下所示:

import numpy as np 
import pandas as pd 
import matplotlib.pyplot as plt 
%matplotlib inline 

dfc = pd.read_json('viral_dataset.json') 
dfc.reset_index(drop=True, inplace=True) 
dfc 

上述代码会生成以下输出:

让我们来看看这个数据集的列,以更好地理解我们将要处理的内容:

dfc.columns 

上述代码会生成以下输出:

现在,我们来逐一看看这些列代表的含义:

  • title: 文章的标题

  • link: ruzzit.com的链接

  • bb: Facebook 的点赞次数

  • lnkdn: LinkedIn 分享次数

  • pins: Pinterest 上的钉住次数

  • date: 文章的日期

  • redirect: 原始文章的链接

  • pg_missing: 描述该页面是否可用的字段

  • img_link: 文章中图片的链接

  • json_data: 文章的附加数据

  • site: 文章托管的网站域名

  • img_count: 文章中包含的图片数量

  • entities: 与人物、地点和事物相关的文章特征

  • html: 文章的正文

  • text: 文章正文的文本

另一个有用的功能是每篇文章的字数。我们目前的数据中没有这个信息,因此让我们创建一个函数来提供字数:

def get_word_count(x): 
    if not x is None: 
        return len(x.split(' ')) 
    else: 
        return None 

dfc['word_count'] = dfc['text'].map(get_word_count) 
dfc 

上述代码会生成以下输出:

我们来添加更多功能。我们将提取页面中第一张图片的最突出颜色。每张图片的颜色以 RGB 值的形式列在 JSON 数据中,因此我们可以从中提取:

import matplotlib.colors as mpc 

def get_rgb(x): 
    try: 
        if x.get('images'): 
            main_color = x.get('images')[0].get('colors')[0].get('color') 
            return main_color 
    except: 
        return None 

def get_hex(x): 
    try: 
        if x.get('images'): 
            main_color = x.get('images')[0].get('colors')[0].get('color') 
            return mpc.rgb2hex([(x/255) for x in main_color]) 
    except: 
        return None 
 dfc['main_hex'] = dfc['json_data'].map(get_hex) 
dfc['main_rgb'] = dfc['json_data'].map(get_rgb) 

dfc 

上述代码会生成以下输出:

我们从第一张图片中提取了最突出颜色的 RGB 值,同时也将其转换为十六进制值。稍后我们会在分析图片颜色时使用这个值。

数据现在准备好了,我们可以开始进行分析。我们将尝试找出是什么让内容具有高度的分享性。

探索分享功能

我们在这里收集的故事大约代表了 2015 年和 2016 年初最具分享性的 500 篇内容。我们将尝试拆解这些文章,找出让它们如此具有分享性的共同特征。我们将从图片数据开始。

探索图片数据

我们先来看看每篇故事包含的图片数量。我们将对其进行计数,然后绘制图表:

dfc['img_count'].value_counts().to_frame('count') 

这应该会显示类似以下的输出:

现在,让我们绘制相同的信息:

fig, ax = plt.subplots(figsize=(8,6)) 
y = dfc['img_count'].value_counts().sort_index() 
x = y.sort_index().index 
plt.bar(x, y, color='k', align='center') 
plt.title('Image Count Frequency', fontsize=16, y=1.01) 
ax.set_xlim(-.5,5.5) 
ax.set_ylabel('Count') 
ax.set_xlabel('Number of Images') 

这段代码会生成以下输出:

目前为止,我对这些数字感到惊讶。绝大多数故事都包含五张图片,而那些只有一张图片或完全没有图片的故事则相当罕见。

因此,我们可以看到人们倾向于分享大量包含图片的内容。接下来,我们来看看这些图片中最常见的颜色:

mci = dfc['main_hex'].value_counts().to_frame('count') 

mci 

这段代码生成了以下输出:

我不知道你怎么看,但对我来说,这并不十分有帮助,因为我并不能把十六进制值当作颜色来看。不过,我们可以使用 pandas 中的一个新功能——条件格式化来帮助我们:

mci['color'] = ' ' 

def color_cells(x): 
    return 'background-color: ' + x.index 

mci.style.apply(color_cells, subset=['color'], axis=0) 

mci 

上述代码生成了以下输出:

聚类

这当然有所帮助,但颜色的粒度如此精细,以至于我们总共有超过 450 种独特的颜色。让我们使用一点聚类技术将其缩减到一个更易管理的范围。由于我们有每种颜色的 RGB 值,因此我们可以创建一个三维空间,通过 k-means 算法对它们进行聚类。这里我不打算深入讲解该算法,但它是一个相当简单的迭代算法,基于通过测量与中心的距离来生成聚类并进行重复。该算法要求我们选择 k,即我们期望的聚类数量。由于 RGB 的范围是从 0 到 256,我们将使用 256 的平方根,即 16。这样应该能给我们一个可管理的数量,同时保留我们调色板的特点。

首先,我们将 RGB 值拆分成单独的列:

def get_csplit(x): 
    try: 
        return x[0], x[1], x[2] 
    except: 
        return None, None, None 

dfc['reds'], dfc['greens'], dfc['blues'] = zip(*dfc['main_rgb'].map(get_csplit)) 

接下来,我们将使用这个功能来运行我们的 k-means 模型,并获取中心值:

from sklearn.cluster import KMeans 

clf = KMeans(n_clusters=16) 
clf.fit(dfc[['reds', 'greens', 'blues']].dropna()) 

clusters = pd.DataFrame(clf.cluster_centers_, columns=['r', 'g', 'b']) 

clusters 

这将生成以下输出:

现在,我们从每张图片的第一张图片中提取了十六种最流行的主导颜色。接下来,我们来看看它们是否使用了我们在之前创建的 DataFrame.style() 方法,以及用于为单元格着色的函数。我们需要将索引设置为三列的十六进制值,以便使用我们的 color_cells 函数,因此我们也会做这一步:

def hexify(x): 
    rgb = [round(x['r']), round(x['g']), round(x['b'])] 
    hxc = mpc.rgb2hex([(x/255) for x in rgb]) 
    return hxc 

clusters.index = clusters.apply(hexify, axis=1) 

clusters['color'] = ' ' 

clusters.style.apply(color_cells, subset=['color'], axis=0) 

这将生成以下输出:

所以,结果就是这些;这些是你在最常被分享的内容中看到的最常见的颜色(至少是第一张图片中的颜色)。这些颜色比我预期的有些单调,因为前几种似乎都是米色和灰色的不同色调。

现在,让我们继续,检查一下我们故事的头条新闻。

探索头条新闻

让我们从创建一个可以用来检查最常见元组的函数开始。我们将使它可以在稍后的正文文本中使用。我们将使用 Python 自然语言工具包NLTK)库来实现。如果你还没有安装这个库,可以通过 pip 安装:

from nltk.util import ngrams 
from nltk.corpus import stopwords 
import re 

def get_word_stats(txt_series, n, rem_stops=False): 
    txt_words = [] 
    txt_len = [] 
    for w in txt_series: 
        if w is not None: 
            if rem_stops == False: 
                word_list = [x for x in ngrams(re.findall('[a-z0-9\']+', w.lower()), n)] 
            else: 
                word_list = [y for y in ngrams([x for x in re.findall('[a-z0-9\']+', w.lower())\ 
                                                if x not in stopwords.words('english')], n)] 
            word_list_len = len(list(word_list)) 
            txt_words.extend(word_list) 
            txt_len.append(word_list_len) 
    return pd.Series(txt_words).value_counts().to_frame('count'), pd.DataFrame(txt_len, columns=['count']) 

这里面有很多内容,我们来逐步解析。我们创建了一个函数,该函数接收一个系列、一个整数和一个布尔值。整数确定我们将用于 n-gram 解析的 n 值,而布尔值决定是否排除停用词。该函数返回每行的元组数和每个元组的频率。

让我们在保留停用词的情况下运行这个,首先从单个词开始:

hw,hl = get_word_stats(dfc['title'], 1, 0) 

hl 

这段代码生成以下输出:

现在,我们得到了每个标题的字数统计。让我们看看这些统计数据是什么样的:

hl.describe() 

这段代码生成以下输出:

我们可以看到,我们的病毒式故事的中位数标题长度恰好是 11 个单词。接下来,我们来看看最常用的单词:

这并不完全有用,但符合我们可能的预期。现在,我们来看一下双字组(bi-grams)的相同信息:

hw,hl = get_word_stats(dfc['title'], 2, 0) 

hw 

这段代码生成以下输出:

这明显更有趣了。我们开始看到标题中一些重复出现的元素。最突出的是 (donald, trump)(dies, at)。特朗普在选举期间发表了一些引人注目的声明,所以特朗普的名字出现很有道理,但我对 dies 这个词的标题感到惊讶。我查看了这些标题,显然有很多高调的人物在那一年去世,所以这也能解释得通。

现在,让我们在移除停用词后运行这个:

hw,hl = get_word_stats(dfc['title'], 2, 1) 

hw 

这段代码生成以下输出:

再次,我们可以看到很多我们可能预期的结果。看起来如果我们改变解析数字的方式(将每个数字替换为一个单一标识符,比如数字),我们可能会看到更多这些词汇浮现出来。如果你想尝试,留给读者自己去做这个练习吧。

现在,我们来看看三字组(tri-grams):

hw,hl = get_word_stats(dfc['title'], 3, 0) 

这段代码生成以下输出:

看起来我们包含的单词越多,标题就越接近经典的 BuzzFeed 原型。实际上,咱们来看看是不是这样。我们还没有查看哪些网站产生了最多的病毒式故事;让我们看看 BuzzFeed 是否占据了榜首:

dfc['site'].value_counts().to_frame() 

这段代码生成以下输出:

我们可以清楚地看到,BuzzFeed 主导了这个列表。排在第二位的是《赫芬顿邮报》,顺便提一下,乔纳·佩雷蒂曾在这家网站工作过。看起来,研究病毒传播的科学确实能带来丰厚的回报。

到目前为止,我们已经检查了图像和标题。接下来,我们将开始检查故事的完整文本。

探索故事内容

在上一节中,我们创建了一个函数来检查我们故事标题中常见的 n-grams。现在,让我们将这一方法应用到探索故事的完整内容上。

我们将首先探索去除停用词的二元组。由于标题相较于正文较短,因此查看带有停用词的标题是合理的,而在正文中,通常需要去除停用词:

hw,hl = get_word_stats(dfc['text'], 2, 1) 

hw 

这将生成以下输出:

有趣的是,我们可以看到,在标题中的轻松氛围完全消失了。现在,文本充满了讨论恐怖主义、政治和种族关系的内容。

既然标题如此轻松愉快,而正文却显得沉重且富有争议,怎么可能是这样呢?我认为这是因为像《13 只看起来像猫王的小狗》这样的文章,其正文文字会比《伊斯兰国历史》少得多。

让我们再看一个。我们将评估故事正文的三元组(tri-grams):

hw,hl = get_word_stats(dfc['text'], 3, 1) 

hw 

这段代码生成了以下输出:

我们似乎突然进入了广告和社会迎合的领域。接下来,让我们开始构建一个用于内容评分的预测模型。

构建预测内容评分模型

让我们运用所学知识,创建一个可以估算给定内容分享数的模型。我们将使用已经创建的特征,并结合一些额外的特征。

理想情况下,我们会有一个更大的内容样本,尤其是那些有更多典型分享数的内容,但我们只能利用手头的这些。

我们将使用一种叫做随机森林回归的算法。在之前的章节中,我们讨论了基于分类的随机森林的更典型实现,但在这里,我们将尝试预测分享数。我们可以将分享类别合并为区间,但在处理连续变量时,使用回归更为合适,而我们这里正是处理的连续变量。

首先,我们将创建一个简化的模型。我们将使用图像数量、网站和字数作为特征,并以 Facebook 点赞数作为训练目标。我们还将把数据分为两个集合:训练集和测试集。

首先,我们将导入 scikit-learn 库,然后通过删除空值行、重置索引,最后将数据框分为训练集和测试集来准备数据:

from sklearn.ensemble import RandomForestRegressor 

all_data = dfc.dropna(subset=['img_count', 'word_count']) 
all_data.reset_index(inplace=True, drop=True) 

train_index = [] 
test_index = [] 
for i in all_data.index: 
    result = np.random.choice(2, p=[.65,.35]) 
    if result == 1: 
        test_index.append(i) 
    else: 
        train_index.append(i) 

我们使用了一个随机数生成器,并设置了约三分之二和三分之一的概率,用来决定哪些行项(根据它们的index)将被分配到每个集合中。通过这种概率设置,我们确保训练集的行数大约是测试集的两倍。我们可以在以下代码中看到这一点:

print('test length:', len(test_index), '\ntrain length:', len(train_index)) 

上述代码生成以下输出:

现在,我们将继续准备我们的数据。接下来,我们需要为我们的网站设置分类编码。目前,我们的 DataFrame 中每个网站的名称都是以字符串形式表示的。我们需要使用虚拟编码。这会为每个网站创建一列,如果该行包含该网站,那么该列将填充为1,而所有其他网站的列将用0进行编码。现在我们来做这个:

sites = pd.get_dummies(all_data['site']) 

sites 

上述代码生成以下输出:

你可以从上述输出中看到虚拟编码是如何显示的。

现在,我们继续:

y_train = all_data.iloc[train_index]['fb'].astype(int) 
X_train_nosite = all_data.iloc[train_index][['img_count', 'word_count']] 

X_train = pd.merge(X_train_nosite, sites.iloc[train_index], left_index=True, right_index=True) 

y_test = all_data.iloc[test_index]['fb'].astype(int) 
X_test_nosite = all_data.iloc[test_index][['img_count', 'word_count']] 

X_test = pd.merge(X_test_nosite, sites.iloc[test_index], left_index=True, right_index=True) 

通过这些步骤,我们已经设置好了我们的X_testX_trainy_testy_train变量。现在,我们将使用我们的训练数据来构建模型:

clf = RandomForestRegressor(n_estimators=1000) 
clf.fit(X_train, y_train) 

通过这两行代码,我们已经训练了我们的模型。现在,让我们使用它来预测测试集中的 Facebook 点赞数:

y_actual = y_test 
deltas = pd.DataFrame(list(zip(y_pred, y_actual, (y_pred - y_actual)/(y_actual))), columns=['predicted', 'actual', 'delta']) 

deltas 

这段代码生成以下输出:

在这里,我们可以看到预测值、实际值以及它们之间的差异(以百分比形式)并排显示。让我们来看一下这些数据的描述性统计:

deltas['delta'].describe() 

上述代码生成以下输出:

这看起来很棒。我们的中位数误差是 0!不过,不幸的是,这实际上是一个相当有用的信息,因为误差在正负两边都有——并且倾向于平均化,这也是我们在这里看到的。让我们看看一个更有信息量的指标来评估我们的模型。我们将看一下均方根误差占实际均值的百分比。

评估模型

为了说明这为什么更有用,我们来在两个示例序列上运行以下场景:

a = pd.Series([10,10,10,10]) 
b = pd.Series([12,8,8,12]) 

np.sqrt(np.mean((b-a)**2))/np.mean(a) 

这将生成以下输出:

现在,将其与均值进行比较:

(b-a).mean() 

这将生成以下输出:

很明显,后者是更有意义的统计数据。现在,我们来为我们的模型运行它:

np.sqrt(np.mean((y_pred-y_actual)**2))/np.mean(y_actual) 

这将生成以下输出:

突然间,我们的精彩模型看起来没那么精彩了。让我们来看看模型的部分预测值与实际值的对比:

deltas[['predicted','actual']].iloc[:30,:].plot(kind='bar', figsize=(16,8)) 

上述代码生成以下输出:

根据我们在这里看到的数据,模型——至少在这个样本中——倾向于轻微低估典型文章的传播性,但对于少数几篇文章则严重低估其传播性。我们来看一下那些数据:

all_data.loc[test_index[:30],['title', 'fb']].reset_index(drop=True) 

上述代码会产生以下输出:

从前面的输出中,我们可以看到,一篇关于马拉拉的文章和一篇关于丈夫抱怨留守妻子花费过多的文章,远远超出了我们模型预测的数字。这两篇文章似乎都具有较高的情感效价。

向我们的模型添加新特征

现在,让我们为我们的模型添加另一个特征。我们来看看,加入单词计数是否能帮助我们的模型。我们将使用CountVectorizer来完成这一操作。就像我们处理网站名称时一样,我们将把单个词和 n-grams 转换成特征:

from sklearn.feature_extraction.text import CountVectorizer 

vect = CountVectorizer(ngram_range=(1,3)) 
X_titles_all = vect.fit_transform(all_data['title']) 

X_titles_train = X_titles_all[train_index] 
X_titles_test = X_titles_all[test_index] 

X_test = pd.merge(X_test, pd.DataFrame(X_titles_test.toarray(), index=X_test.index), left_index=True, right_index=True) 

X_train = pd.merge(X_train, pd.DataFrame(X_titles_train.toarray(), index=X_train.index), left_index=True, right_index=True) 

在前面的几行中,我们将现有特征与新的 n-gram 特征结合起来。让我们训练我们的模型,看看是否有任何改善:

clf.fit(X_train, y_train) 

y_pred = clf.predict(X_test) 

deltas = pd.DataFrame(list(zip(y_pred, y_actual, (y_pred - y_actual)/(y_actual))), columns=['predicted', 'actual', 'delta']) 

deltas 

这段代码生成了以下输出:

如果我们再次检查我们的错误,我们将看到以下结果:

np.sqrt(np.mean((y_pred-y_actual)**2))/np.mean(y_actual) 

前面的代码生成了以下输出:

看起来我们的模型有了适度的改善。让我们再为模型添加一个特征——标题的单词数:

all_data = all_data.assign(title_wc = all_data['title'].map(lambda x: len(x.split(' ')))) 

X_train = pd.merge(X_train, all_data[['title_wc']], left_index=True, right_index=True) 

X_test = pd.merge(X_test, all_data[['title_wc']], left_index=True, right_index=True) 

clf.fit(X_train, y_train) 

y_pred = clf.predict(X_test) 

np.sqrt(np.mean((y_pred-y_actual)**2))/np.mean(y_actual) 

这段代码生成了以下输出:

看起来每个特征都对我们的模型有了一定的改进。当然,我们还有更多的特征可以添加。例如,我们可以添加星期几和发布时间的小时数,或者通过对标题进行正则表达式匹配来判断文章是否为列表文章,或者我们可以分析每篇文章的情感。但是,这仅仅触及了模型化病毒传播中可能重要的特征。我们当然需要走得更远,继续减少模型中的错误。

我还应该指出,我们对模型只做了最初步的测试。每个测量结果应该进行多次测试,以便更准确地表示实际的错误率。由于我们只进行了一次测试,可能没有统计学上显著的差异。

总结

在这一章中,我们考察了病毒式内容的共同特征,以及如何使用随机森林回归模型来预测病毒传播性。我们还学会了如何组合多种类型的特征,以及如何将模型拆分为训练集和测试集。

希望你能将你在这里学到的知识运用到下一个病毒式传播的帝国。如果那不行,也许下一章关于掌握股市的内容会有帮助。

第七章:使用机器学习预测股市

最近,我正在阅读一篇文章,描述了一种特定治疗方法在抗击耐甲氧西林金黄色葡萄球菌MRSA)超级细菌方面取得的巨大成功。如果你没有直接听说过 MRSA,你很可能听说过我们即将面临一个时代——我们的抗生素将不再有效的担忧。这在很大程度上是一个不可避免的现象,因为某些细菌群体在基因上对相关药物具有更强的抵抗力。当易感药物的细菌在治疗过程中被消灭时,剩下的耐药性细菌就会繁殖并成为群体中占主导地位的变种。为了应对这种情况,科学家们不断推动科学的边界,寻找新的方法来抗击这些细菌。

在生物学中,这种情况被称为红皇后竞赛:这个术语来源于路易斯·卡罗尔的《镜中奇遇记》中的一句话:

“现在,看看,这里,你看,得跑得越快,才能保持在原地。”

这有效地描述了我们在抗生素领域所面临的状况,但或许答案不在于转向新的、更先进的药物。或许答案在于理解正在起作用的更大周期,并利用它为我们所用。

我之前讨论的那个针对 MRSA 的新治疗方法?实际上来自一本 10^(th)世纪的医学药方书,名为巴尔德的引血书。书中列举的成分包括大蒜、酒和洋葱。这种组合的效果超越了我们当前的最后治疗手段——万古霉素

但这一切和预测股市有什么关系呢?我想建议的是,这两种情况中正好存在着同样的现象。例如,时不时会有一篇论文发布,警告金融界某种现象的存在,而这种现象是一种有利可图的异常现象。很可能,这种现象是某些外部强加的、现实世界的约束所带来的下游效应。

以年底税务亏损销售为例。由于税法的性质,交易者在年底卖出亏损的股票是有意义的。这会对亏损股票在年底施加下行压力。然后,股价的下跌意味着这些股票可能被低于其公允价值的价格折扣出售。这也意味着,到了 1 月,原本的下行压力消失,取而代之的是上涨的压力,因为新资金开始投资于这些被低估的资产。但一旦这一现象被广泛传播,交易者就会试图走在前面,在 12 月末开始买入这些股票,并卖给那些预计会在 1 月成为买家的其他交易者。新的交易者进入市场后,实际上稀释了这一效应。他们减轻了年底的卖压,并降低了 1 月的买压。这个效应本质上被套利掉了,连同盈利一同消失。曾经有效的策略不再有效,交易者将开始放弃这种策略,转向下一个新的机会。

到现在为止,我希望你已经开始看到这些相似之处。大蒜、葡萄酒和洋葱的组合可能曾是一个非常有效的抗菌疗法,但随着细菌的适应,它逐渐失去了效力。由于很久以前这种治疗方法已被废弃,细菌也没有理由避免那些使它们对这种治疗敏感的原始基因。现实世界中的一些限制使得这些类型的循环几乎不可避免——无论是在生物体中还是在市场中。关键是如何利用这一点来为我们所用。

在本章中,我们将花一些时间讨论如何建立和测试交易策略。然而,我们将花更多的时间讨论如何这样做。在尝试设计你自己的系统时,有无数的陷阱需要避免,而这几乎是一项不可能完成的任务,但它可以非常有趣,有时甚至能带来盈利。话虽如此,千万不要做出那些你无法承受的亏损的愚蠢决定。

如果你决定用这里学到的任何知识进行交易,你得自己承担风险。这不应被视为任何形式的投资建议,我对你的行为不承担任何责任。

在本章中,我们将涵盖以下主题:

  • 市场分析的类型

  • 研究告诉我们关于股市的什么信息?

  • 如何开发交易系统

市场分析的类型

让我们从讨论一些金融市场分析时的关键术语和分析方法开始。虽然金融工具种类繁多,包括股票、债券、ETF、货币和掉期,但我们将把讨论限定在股票和股市上。股票只是公众公司所有权的一个部分份额。当公司未来前景上升时,股票价格预计会增加,而当这些前景下滑时,股票价格则会下降。

投资者通常会分为两大阵营。第一类是基本面分析师。这些分析师仔细研究公司的财务数据,寻找表明市场在某种程度上低估公司股票的信息。这些投资者会关注各种因素,如收入、盈利、现金流以及这些值的各种比率。这通常涉及将一家公司财务与另一家公司进行对比。

第二类投资者是技术分析师。技术分析师认为,股票价格已经反映了所有公开的可得信息,而研究基本面分析基本上是浪费时间。他们认为,通过观察历史价格—股票图表—可以看出价格上涨、下跌或停滞的区域。一般来说,他们认为这些图表揭示了投资者心理的线索。

两种类型的投资者有一个共同点,那就是他们都相信正确的分析能够带来利润。但这是正确的吗?

研究告诉我们关于股市的什么信息?

过去 50 年中,或许最有影响力的股市理论就是有效市场假说。这个理论由尤金·法马(Eugene Fama)提出,规定市场是理性的,所有可得的信息都已恰当反映在股价中。因此,投资者不可能在风险调整后的基础上持续战胜市场。有效市场假说通常被讨论为有三种形式:弱型形式、半强型形式和强型形式:

  1. 在弱型形式下,市场是有效的,因为你无法利用价格的过去信息来预测未来的价格。信息会相对较快地反映在股票中,虽然技术分析无效,但在某些情况下,基本面分析可能是有效的。

  2. 在半强型形式下,价格会立即以公正的方式反映所有相关的公共新信息。在这种情况下,技术分析和基本面分析都不会有效。

  3. 最终,在强型形式下,股票价格反映所有的公共和私人信息。

根据这些理论,通过利用市场中的模式来赚钱并没有太大希望。但幸运的是,尽管整体市场运作效率较高,但仍然有一些效率低下的独立领域被发现。这些低效领域大多数都是短暂的,但也有一些被证明是持续存在的。即便按照法马的说法,其中最值得注意的一点就是动量策略的超常表现。

那么,动量策略究竟是什么呢?

这个主题有许多变化,但基本的思路是根据股票在一段时间内的回报将其从高到低排序。排名靠前的股票被购买并持有一段时间,之后在固定的持有期过后,重复这一过程。一个典型的只做多的动量策略可能包括购买过去一年在标准普尔 500 指数中表现最好的 25 只股票,持有一年后卖出,然后再重复这一过程。

这听起来可能是一个过于简单的策略,确实如此,但它始终如一地产生了出人意料的结果。那么,为什么呢?正如你可以想象的那样,许多研究已经探讨了这一现象,假设是人类在处理新信息时存在某种固有的系统性偏差。研究表明,人们在短期内对新闻反应不足,而在长期内则反应过度。这意味着,当股票因极好的新闻开始上涨时,投资者并没有完全将股价提升到能够完全反映这些新闻的水平;他们需要一些时间才能适应并将这种乐观情绪纳入考量。

投资者未能在面对极好新闻时充分重新定价股票的倾向,可能是由一种广为记录的偏差——锚定效应——所导致的。实际上,当呈现给我们一个数字,即使是一个随机数字,然后要求我们估计一个现实世界的值(比如非洲的国家数量),我们的答案会在心理上被固定在我们最初接收到的那个数字上。值得注意的是,即使我们知道这个数字是随机生成的,并与问题无关,这种情况依然发生。

那么,随着越来越多的交易者了解这一策略并纷纷加入,动量策略是否会被套利消除呢?近年来确实有一些证据表明这一点,但仍然不清楚。无论如何,这一效应确实存在,并且持续的时间远远超出了目前有效市场假说能够解释的范围。因此,至少似乎存在一些市场预测的希望。考虑到这一点,让我们接下来探讨如何挖掘我们自己的市场异常。

如何开发一个交易策略

我们将从技术层面开始策略开发。让我们来看看过去几年的标准普尔 500 指数。我们将使用pandas导入我们的数据。这将让我们访问多个股票数据源,包括 Yahoo!和 Google。

  1. 首先,你需要安装数据读取器:
!pip install pandas_datareader 
  1. 然后,继续加入你的导入语句:
import pandas as pd 
from pandas_datareader import data, wb 
import matplotlib.pyplot as plt 

%matplotlib inline 
pd.set_option('display.max_colwidth', 200) 
  1. 现在,我们将获取SPY ETF 的数据,该 ETF 代表标准普尔 500 指数中的股票。我们将提取 2010 年初至 2018 年 12 月的数据:
import pandas_datareader as pdr 

start_date = pd.to_datetime('2010-01-01') 
stop_date = pd.to_datetime('2018-12-01') 

spy = pdr.data.get_data_yahoo('SPY', start_date, stop_date) 

这段代码生成以下输出:

  1. 现在,我们可以绘制我们的数据了。我们将只选择收盘价:
spy_c = spy['Close'] 

fig, ax = plt.subplots(figsize=(15,10)) 
spy_c.plot(color='k') 
plt.title("SPY", fontsize=20); 
  1. 这将生成以下输出:

在前面的图表中,我们看到的是我们选择的期间内,标准普尔 500 指数每日收盘价的价格图。

数据分析

让我们进行一些分析,看看如果我们投资了这个 ETF,这段时间的回报会是什么样:

  1. 我们将首先提取first_open的数据:
first_open = spy['Open'].iloc[0] 
first_open 

这将生成以下输出:

  1. 接下来,让我们获取该期间最后一天的收盘价:
last_close = spy['Close'].iloc[-1] 
last_close 

这将生成以下输出:

  1. 最后,我们来看一下整个时期内的变化:
last_close - first_open 

这将生成以下输出:

所以,看起来在该期间开始时购买 100 股将花费大约 11,237 美元,而在该期间结束时,这 100 股的价值大约为 27,564 美元。这个交易将使我们在该期间内获得约 145%的收益。还不错。

现在我们来看一下同一时期内仅考虑日内涨幅的回报。这假设我们每天在开盘时买入股票,并在当天收盘时卖出:

spy['Daily Change'] = pd.Series(spy['Close'] - spy['Open']) 

这将给我们每天从开盘到收盘的变化。让我们来看一下:

spy['Daily Change'] 

这将生成以下输出:

现在让我们总计一下这段期间的变化:

spy['Daily Change'].sum() 

这将生成以下输出:

如您所见,我们从超过 163 点的收益下降到了略高于 53 点的收益。哎呀!市场的收益中超过一半来自于期间的隔夜持有。

回报的波动性

隔夜回报优于日内回报,但波动性如何呢?回报总是以风险调整后的方式来评判的,因此我们来看看隔夜交易和日内交易在标准差上的对比。

我们可以使用 NumPy 来计算这个,方法如下:

np.std(spy['Daily Change']) 

这将生成以下输出:

spy['Overnight Change'] = pd.Series(spy['Open'] - spy['Close'].shift(1)) 

np.std(spy['Overnight Change']) 

这将生成以下输出:

因此,与日内交易相比,我们的隔夜交易不仅收益更高,而且波动性更低。但并非所有的波动性都是相同的。我们来比较一下两种策略下,跌日与涨日的平均变化:

 spy[spy['Daily Change']<0]['Daily Change'].mean() 

这段代码生成了以下输出:

运行这个代码以分析涨日:

 spy[spy['Overnight Change']<0]['Overnight Change'].mean() 

我们得到的输出如下:

再次看到,平均的下行波动性对于我们的隔夜交易策略要远低于日内交易策略。

每日回报

到目前为止,我们一直是从点数的角度来看的,但现在让我们来看一下日常回报。这将帮助我们将收益和亏损放入一个更现实的背景中。让我们为每种情况创建一个 pandas 系列:日常回报(收盘到收盘的变化)、日内回报和过夜回报:

daily_rtn = ((spy['Close'] - spy['Close'].shift(1))/spy['Close'].shift(1))*100 

id_rtn = ((spy['Close'] - spy['Open'])/spy['Open'])*100 

on_rtn = ((spy['Open'] - spy['Close'].shift(1))/spy['Close'].shift(1))*100 

我们所做的就是使用 pandas 的 .shift() 方法,从前一天的系列中减去每个系列。例如,对于前面第一个系列,我们将每天的收盘价与前一天的收盘价相减。这将导致少一个数据点。如果你打印出新的系列,你可以看到如下内容:

Daily_rtn 

这会生成以下输出:

各策略的统计数据

现在让我们来看一下这三种策略的统计数据。我们将创建一个函数,可以接收每一组回报数据,并打印出总结结果。我们将获取每个盈利、亏损和持平交易的统计数据,还有一个叫做夏普比率的东西。我之前说过,回报是基于风险调整来评估的;这正是夏普比率所提供的;它是一种通过考虑回报波动性来比较回报的方法。在这里,我们使用经过调整的夏普比率来年化比率:

def get_stats(s, n=252): 
    s = s.dropna() 
    wins = len(s[s>0]) 
    losses = len(s[s<0]) 
    evens = len(s[s==0]) 
    mean_w = round(s[s>0].mean(), 3) 
    mean_l = round(s[s<0].mean(), 3) 
    win_r = round(wins/losses, 3) 
    mean_trd = round(s.mean(), 3) 
    sd = round(np.std(s), 3) 
    max_l = round(s.min(), 3) 
    max_w = round(s.max(), 3) 
    sharpe_r = round((s.mean()/np.std(s))*np.sqrt(n), 4) 
    cnt = len(s) 
    print('Trades:', cnt,\ 
          '\nWins:', wins,\ 
          '\nLosses:', losses,\ 
          '\nBreakeven:', evens,\ 
          '\nWin/Loss Ratio', win_r,\ 
          '\nMean Win:', mean_w,\ 
          '\nMean Loss:', mean_l,\ 
          '\nMean', mean_trd,\ 
          '\nStd Dev:', sd,\ 
          '\nMax Loss:', max_l,\ 
          '\nMax Win:', max_w,\ 
          '\nSharpe Ratio:', sharpe_r) 

现在让我们运行每个策略,看看统计数据。我们将从买入并持有策略(每日回报)开始,然后依次处理其他两个策略,如下所示:

get_stats(daily_rtn) 

这会生成以下输出:

运行以下代码来计算日内回报:

get_stats(id_rtn) 

这会生成以下输出:

运行以下代码来计算过夜回报:

get_stats(on_rtn) 

这会生成以下输出:

如你所见,买入并持有策略具有三种策略中最高的平均回报和最高的标准差。它还拥有最大的日内最大回撤(亏损)。你还会注意到,尽管仅限过夜策略的平均回报高于日内策略,但它的波动性要小得多。这反过来使得它的夏普比率高于日内策略。

到目前为止,我们已经有了一个坚实的基准来比较我们未来的策略。现在,我将告诉你一个能够轻松击败这三种策略的策略。

神秘策略

现在让我们来看一下这个新神秘策略的统计数据:

通过这个策略,我基本上将夏普比率从买入并持有策略的水平提高了一倍,大幅降低了波动性,增加了最大盈利,并显著降低了最大亏损。

那么,我是如何设计出这个超越市场的策略的呢?等等... 我是通过生成 5000 个随机的过夜信号,并挑选出最好的一个来做到的。

显然,这不是击败市场的方法。那么,我为什么这么做呢?是为了演示,如果你测试足够多的策略,仅凭随机机会,你会碰到一个看似非常优秀的数字。这就是所谓的数据挖掘谬误,它是交易策略开发中的一个实际风险。这也是为什么找到一个与现实世界投资者偏见和行为相匹配的策略是如此重要。如果你想在交易中占有优势,你不是在交易市场,你是在交易那些交易市场的人。*。

因此,优势来自于深思熟虑地理解人们在某些情况下可能做出的错误反应。

现在,让我们扩展分析。首先,我们将提取从 2000 年开始的指数数据:

start_date = pd.to_datetime('2000-01-01') 
stop_date = pd.to_datetime('2018-12-01') 

sp = pdr.data.get_data_yahoo('SPY', start_date, stop_date) 

现在,让我们看看我们的图表:

fig, ax = plt.subplots(figsize=(15,10)) 
sp['Close'].plot(color='k') 
plt.title("SPY", fontsize=20) 

这将生成以下输出:

这里,我们看到从 2000 年初到 2018 年 12 月 1 日SPY的价格走势。在此期间,市场经历了高度正面和高度负面的市场周期,波动非常大。

让我们为三种基本策略的新扩展期设置基准。

首先,让我们为每个设置变量:

long_day_rtn = ((sp['Close'] - sp['Close'].shift(1))/sp['Close'].shift(1))*100 

long_id_rtn = ((sp['Close'] - sp['Open'])/sp['Open'])*100 

long_on_rtn = ((sp['Open'] - sp['Close'].shift(1))/sp['Close'].shift(1))*100 

现在,让我们看看每个的得分总计:

(sp['Close'] - sp['Close'].shift(1)).sum() 

这将生成以下输出:

现在,让我们看看从开盘到收盘的得分总计:

(sp['Close'] - sp['Open']).sum() 

这将生成以下输出:

现在,让我们看看从收盘到开盘的得分总计:

(sp['Open'] - sp['Close'].shift(1)).sum() 

这将生成以下输出:

现在让我们看看每个的统计数据:

get_stats(long_day_rtn) 

这将生成以下输出:

现在,让我们看看盘中回报的统计数据:

get_stats(long_id_rtn) 

这将生成以下输出:

现在,让我们看看隔夜回报的统计数据:

get_stats(long_on_rtn) 

这将生成以下输出:

我们可以看到,在较长的时间段内,三者之间的差异更加显著。如果你在过去 18 年只在白天持有这个 S&P ETF,你将会亏钱。如果你只在隔夜持有,你的总点数回报将提高超过 18%!显然,这假设没有交易成本和税费,并且有完美的成交,但无论如何,这都是一个显著的发现。

构建回归模型

现在我们有了对比的基准,接下来我们将构建我们的第一个回归模型。我们将使用一个非常基础的模型,只利用股票的前一个收盘值来预测第二天的收盘,并使用支持向量回归来构建它。那么,让我们开始设置模型:

  1. 第一步是设置一个包含每日价格历史的 DataFrame。我们将在模型中包括过去 20 个收盘数据:
for i in range(1, 21, 1): 
    sp.loc[:,'Close Minus ' + str(i)] = sp['Close'].shift(i) 

sp20 = sp[[x for x in sp.columns if 'Close Minus' in x or x == 'Close']].iloc[20:,] 

sp20 
  1. 这段代码给出了每一天的收盘价,以及之前的 20 天数据,所有数据都在同一行。我们的代码结果如下所示:

  1. 这将形成我们将输入到模型中的X数组。但在我们准备好之前,还需要一些额外的步骤。

  2. 首先,我们将反转我们的列,使时间从左到右排列:

sp20 = sp20.iloc[:,::-1] 

sp20 

这生成了以下输出:

  1. 现在,让我们导入支持向量机并设置我们的训练和测试矩阵及向量:
from sklearn.svm import SVR 
clf = SVR(kernel='linear') 

X_train = sp20[:-2000] 
y_train = sp20['Close'].shift(-1)[:-2000] 

X_test = sp20[-2000:] 
y_test = sp20['Close'].shift(-1)[-2000:] 
  1. 我们只有 5,000 个数据点可供使用,所以我选择了最后 2,000 个数据点作为测试集。现在让我们拟合我们的模型,并使用它来检查样本外数据:
model = clf.fit(X_train, y_train) 

preds = model.predict(X_test) 
  1. 现在我们有了预测结果,让我们将它们与实际数据进行比较:
tf = pd.DataFrame(list(zip(y_test, preds)), columns=['Next Day Close', 'Predicted Next Close'], index=y_test.index) 

tf 

上面的代码生成了以下输出:

模型的表现

现在让我们看看我们模型的表现。如果预测的收盘价高于开盘价,我们将买入次日的开盘价。然后,我们将在当天的收盘时卖出。为了计算我们的结果,我们需要向数据框中添加一些额外的数据点,具体如下:

cdc = sp[['Close']].iloc[-1000:] 
ndo = sp[['Open']].iloc[-1000:].shift(-1) 

tf1 = pd.merge(tf, cdc, left_index=True, right_index=True) 
tf2 = pd.merge(tf1, ndo, left_index=True, right_index=True) 
tf2.columns = ['Next Day Close', 'Predicted Next Close', 'Current Day Close', 'Next Day Open'] 

tf2 

这生成了以下输出:

这里我们将添加以下代码,以获取我们的信号和该信号的盈亏:

def get_signal(r): 
    if r['Predicted Next Close'] > r['Next Day Open']: 
        return 1 
    else: 
        return 0 

def get_ret(r): 
    if r['Signal'] == 1: 
        return ((r['Next Day Close'] - r['Next Day Open'])/r['Next Day Open']) * 100 
    else: 
        return 0 

tf2 = tf2.assign(Signal = tf2.apply(get_signal, axis=1)) 
tf2 = tf2.assign(PnL = tf2.apply(get_ret, axis=1)) 

tf2 

这生成了以下输出:

现在让我们看看,仅凭价格历史,我们是否能够成功预测次日的价格。我们将从计算收益点数开始:

(tf2[tf2['Signal']==1]['Next Day Close'] - tf2[tf2['Signal']==1]['Next Day Open']).sum() 

这生成了以下输出:

哎呀!看起来很糟糕。但是我们测试的那一段时间呢?我们从未单独评估过它。我们的基本日内策略在过去 2,000 天内生成了多少点数:

(sp['Close'].iloc[-2000:] - sp['Open'].iloc[-2000:]).sum() 

这生成了以下输出:

看起来我们的策略非常糟糕。让我们比较一下两者。

首先是该时期的基本日内策略:

get_stats((sp['Close'].iloc[-2000:] - sp['Open'].iloc[-2000:])/sp['Open'].iloc[-2000:] * 100) 

这生成了以下输出:

现在是我们模型的结果:

get_stats(tf2['PnL']) 

这生成了以下输出:

很明显,我们的策略并不是我们想要实现的策略。我们如何改进现有的策略呢?如果我们修改我们的交易策略会怎样?如果我们只选择那些预期上涨 1 点或以上的交易,而不仅仅是任何大于开盘价的交易呢?这会有帮助吗?让我们试试。我们将使用修改后的信号重新运行策略,如下所示的代码块:

def get_signal(r): 
    if r['Predicted Next Close'] > r['Next Day Open'] + 1: 
        return 1 
    else: 
        return 0 

def get_ret(r): 
    if r['Signal'] == 1: 
        return ((r['Next Day Close'] - r['Next Day Open'])/r['Next Day Open']) * 100 
    else: 
        return 0 

tf2 = tf2.assign(Signal = tf2.apply(get_signal, axis=1)) 
tf2 = tf2.assign(PnL = tf2.apply(get_ret, axis=1)) 

(tf2[tf2['Signal']==1]['Next Day Close'] - tf2[tf2['Signal']==1]['Next Day Open']).sum() 

这生成了以下输出:

现在是统计数据:

get_stats(tf2['PnL']) 

这生成了以下输出:

我们的情况变得越来越糟。看起来,如果过去的价格历史暗示未来会有好结果,你实际上可以预期恰恰相反。我们似乎开发了一个反向指标。那如果我们探索一下这个呢?让我们看看如果我们将模型反转,当我们预测强劲的涨幅时,我们不交易,而其他情况下我们进行交易,结果会如何:

def get_signal(r): 
    if r['Predicted Next Close'] > r['Next Day Open'] + 1: 
        return 0 
    else: 
        return 1 

def get_ret(r): 
    if r['Signal'] == 1: 
        return ((r['Next Day Close'] - r['Next Day Open'])/r['Next Day Open']) * 100 
    else: 
        return 0 

tf2 = tf2.assign(Signal = tf2.apply(get_signal, axis=1)) 
tf2 = tf2.assign(PnL = tf2.apply(get_ret, axis=1)) 

(tf2[tf2['Signal']==1]['Next Day Close'] - tf2[tf2['Signal']==1]['Next Day Open']).sum() 

这会生成以下输出:

让我们获取我们的统计数据:

get_stats(tf2['PnL']) 

这会生成以下输出:

看起来我们确实有一个反向指标。当我们的模型预测第二天强劲的涨幅时,市场表现明显不佳,至少在我们的测试期间是这样。这个结论在大多数情况下成立吗?不太可能。市场往往会从均值回归的状态转变为趋势持续的状态。

到此为止,我们可以对这个模型做出一些扩展。我们甚至没有涉及使用技术指标或基本面数据,也将我们的交易限制为一天内完成。所有这些都可以调整和扩展,但有一个我们还没有涉及的重要点,必须提及。

我们正在处理的数据是一种特殊类型,称为时间序列数据。时间序列数据需要特殊处理以正确建模,因为它通常违反了统计建模所需的假设,如恒定的均值和方差。

错误地处理时间序列数据的一个后果是,误差度量会给出极其不准确的结果。由于存在显著的自相关,换句话说,下一个周期的数据与当前周期的数据高度相关,因此看起来我们的预测比实际情况要好得多。

为了解决这些问题,时间序列数据通常会被差分(在股市数据中,这意味着我们查看的是每日变化,而不是指数的绝对值),以使其变为我们所谓的平稳数据;也就是说,数据具有恒定的均值和方差,并且没有显著的自相关。

如果你打算继续处理时间序列数据,我强烈建议你更详细地研究这些概念。

动态时间规整

接下来,我想介绍另一个使用完全不同算法的模型。这个算法叫做动态时间规整。它的作用是给出一个度量,表示两个时间序列之间的相似性:

  1. 为了开始,我们需要通过pip install安装fastdtw库:
!pip install fastdtw 
  1. 安装完成后,我们将导入需要的其他库:
from scipy.spatial.distance import euclidean 
from fastdtw import fastdtw 
  1. 接下来,我们将创建一个函数,接受两个序列并返回它们之间的距离:
def dtw_dist(x, y): 
    distance, path = fastdtw(x, y, dist=euclidean) 
    return distance 
  1. 现在,我们将把 18 年的时间序列数据分成不同的五天周期。我们将每个周期与一个额外的点配对。这将用于创建我们的xy数据,具体如下:
tseries = [] 
tlen = 5 
for i in range(tlen, len(sp), tlen): 
    pctc = sp['Close'].iloc[i-tlen:i].pct_change()[1:].values * 100 
    res = sp['Close'].iloc[i-tlen:i+1].pct_change()[-1] * 100 
    tseries.append((pctc, res)) 
  1. 我们可以查看我们的第一个序列,了解数据的样子:
tseries[0] 

这将生成以下输出:

  1. 现在我们有了每个系列,我们可以通过我们的算法将它们与其他所有系列进行比对,计算每个系列与其他系列的距离度量:
dist_pairs = [] 
for i in range(len(tseries)): 
    for j in range(len(tseries)): 
        dist = dtw_dist(tseries[i][0], tseries[j][0]) 
        dist_pairs.append((i,j,dist,tseries[i][1], tseries[j][1])) 

一旦我们得到这些,我们可以将其放入一个DataFrame。我们将删除距离为0的系列,因为它们代表相同的系列。我们还将按系列的日期进行排序,并只查看那些第一个系列出现在第二个系列之前的情况,按照时间顺序:

dist_frame = pd.DataFrame(dist_pairs, columns=['A','B','Dist', 'A Ret', 'B Ret']) 

sf = dist_frame[dist_frame['Dist']>0].sort_values(['A','B']).reset_index(drop=1) 

sfe = sf[sf['A']<sf['B']] 

最后,我们将限制交易距离小于1并且第一个系列有正回报的情况:

winf = sfe[(sfe['Dist']<=1)&(sfe['A Ret']>0)] 

winf 

这将生成以下输出:

让我们看看我们最顶尖的一个模式(A:6 和 B:598)绘制出来是什么样子的:

plt.plot(np.arange(4), tseries[6][0]); 

上述代码生成了以下输出:

现在,我们来绘制第二个:

plt.plot(np.arange(4), tseries[598][0]) 

上述代码生成了以下输出:

如你所见,这些曲线几乎完全相同,这正是我们所期望的。我们将尝试找到所有有正向次日收益的曲线,然后,一旦我们找到一条与这些盈利曲线高度相似的曲线,我们就会购买它,期待再次获得收益。

评估我们的交易

现在,让我们构建一个函数来评估我们的交易。我们将购买相似的曲线,除非它们未能产生正向回报。如果发生这种情况,我们将把它们剔除,代码如下:

excluded = {} 
return_list = [] 
def get_returns(r): 
    if excluded.get(r['A']) is None: 
        return_list.append(r['B Ret']) 
        if r['B Ret'] < 0: 
            excluded.update({r['A']:1}) 

winf.apply(get_returns, axis=1); 

现在我们已经将所有交易的回报存储在return_list中,接下来让我们评估一下结果:

get_stats(pd.Series(return_list)) 

这将生成以下输出:

这些结果是我们迄今为止看到的最好的。盈亏比和均值都远高于其他模型。看起来我们在这个新模型上可能有所突破,尤其是与我们之前看到的其他模型相比。

此时,为了进一步验证我们的模型,我们应通过检视其他时间段来考察其稳健性。延长四天的时间跨度是否能改进模型?我们是否应该总是排除那些产生亏损的模式?此时有大量问题需要探讨,但我将把这些作为留给读者的练习。

总结

在这一章中,我们探讨了股市的内部运作,并探索了多种在交易策略中应用机器学习的方法。毫无疑问,本章的内容本身就足以填满一本书。我们甚至没有涵盖一些最重要的交易方面,比如投资组合构建、风险缓解和资金管理。这些都是任何策略中至关重要的组成部分,甚至可能比交易信号更为重要。

希望这能成为你自己探索的起点,但再次提醒你,战胜市场几乎是一个不可能的游戏——你将与世界上最聪明的头脑竞争。如果你决定尝试,祝你好运。只要记住,如果结果不如你所愿,我已经提醒过你!

第八章:使用卷积神经网络进行图像分类

在这一章中,我们将探索计算机视觉的广阔而精彩的世界。

如果你曾经想过使用图像数据构建一个预测性的机器学习模型,本章将作为一个易于消化且实用的资源。我们将一步一步地构建一个图像分类模型,对其进行交叉验证,然后以更好的方式构建它。在本章的结尾,我们将有一个相当不错的模型,并讨论一些未来增强的路径。

当然,了解一些预测建模的基础将有助于让这一过程顺利进行。正如你很快就会看到的那样,将图像转换为可用于我们模型的特征的过程可能会让人觉得是新鲜的,但一旦提取了特征,模型构建和交叉验证的过程就完全一样。

在本章中,我们将构建一个卷积神经网络,用于对 Zalando Research 数据集中的服装图像进行分类——该数据集包含 70,000 张图像,每张图像展示了 10 种可能的服装类别之一,例如 T 恤/上衣、裤子、毛衣、连衣裙、外套、凉鞋、衬衫、运动鞋、包或短靴。但首先,我们将一起探索一些基础知识,从图像特征提取开始,并逐步了解卷积神经网络的工作原理。

那么,让我们开始吧。真的!

本章将涵盖以下内容:

  • 图像特征提取

  • 卷积神经网络:

    • 网络拓扑

    • 卷积层与滤波器

    • 最大池化层

    • 展平

    • 全连接层与输出

  • 使用 Keras 构建卷积神经网络来对 Zalando Research 数据集中的图像进行分类

图像特征提取

在处理非结构化数据时,无论是文本还是图像,我们必须首先将数据转换为机器学习模型可以使用的数字表示。将非数字数据转换为数字表示的过程称为特征提取。对于图像数据来说,我们的特征就是图像的像素值。

首先,假设我们有一张 1,150 x 1,150 像素的灰度图像。这样的一张图像将返回一个 1,150 x 1,150 的像素强度矩阵。对于灰度图像,像素值的范围是从 0 到 255,其中 0 代表完全黑色的像素,255 代表完全白色的像素,而 0 到 255 之间的值则代表不同的灰色阴影。

为了展示这在代码中的表现,我们来提取我们灰度猫卷饼图像的特征。该图像可以在 GitHub 上找到,链接是 github.com/PacktPublishing/Python-Machine-Learning-Blueprints-Second-Edition/tree/master/Chapter08,文件名是grayscale_cat_burrito.jpg

我已经将本章中使用的图像资源提供给你,链接为github.com/mroman09/packt-image-assets。你可以在那里找到我们的猫肉卷!

现在,我们来看一下以下代码中的一个示例:

import matplotlib.image as mpimg
import matplotlib.pyplot as plt
import pandas as pd
%matplotlib inline

cat_burrito = mpimg.imread('images/grayscale_cat_burrito.jpg')
cat_burrito

如果你无法通过运行前面的代码读取.jpg文件,只需运行pip install pillow安装PIL

在前面的代码中,我们导入了pandas和两个子模块:imagepyplot,来自matplotlib。我们使用了matplotlib.image中的imread方法来读取图像。

运行前面的代码会得到以下输出:

输出是一个二维的numpy ndarray,包含了我们模型的特征。像大多数应用机器学习的场景一样,您可能需要对这些提取的特征执行若干预处理步骤,其中一些我们将在本章稍后与 Zalando 时尚数据集一起探讨,但这些就是图像的原始提取特征!

提取的灰度图像特征的形状为image_height行 × image_width列。我们可以通过运行以下代码轻松检查图像的形状:

cat_burrito.shape

前面的代码返回了以下输出:

我们也可以轻松检查ndarray中的最大和最小像素值:

print(cat_burrito.max())
print(cat_burrito.min())

这将返回以下结果:

最后,我们可以通过运行以下代码从ndarray中显示灰度图像:

plt.axis('off')
plt.imshow(cat_burrito, cmap='gray');

前面的代码返回了我们的图像,该图像可在github.com/PacktPublishing/Python-Machine-Learning-Blueprints-Second-Edition/tree/master/Chapter08上找到,文件名为output_grayscale_cat_burrito.png

彩色图像的特征提取过程是相同的;不过,对于彩色图像,我们的ndarray输出的形状将是三维的——一个张量——表示图像的红、绿、蓝RGB)像素值。在这里,我们将执行与之前相同的过程,这次是在猫肉卷的彩色版本上进行。该图像可在 GitHub 上通过github.com/PacktPublishing/Python-Machine-Learning-Blueprints-Second-Edition/tree/master/Chapter08 访问,文件名为color_cat_burrito.jpg

我们通过以下代码提取猫肉卷的彩色版本特征:

color_cat_burrito = mpimg.imread('images/color_cat_burrito.jpg')
color_cat_burrito.shape

运行此代码将返回以下输出:

同样,在这里我们看到该图像包含三个通道。我们的color_cat_burrito变量是一个张量,包含三个矩阵,告诉我们图像中每个像素的 RGB 值。

我们可以通过运行以下代码来显示ndarray中的彩色图像:

plt.axis('off')
plt.imshow(color_cat_burrito);

这返回了我们的彩色图像。图像可以在 GitHub 上找到,链接为github.com/PacktPublishing/Python-Machine-Learning-Blueprints-Second-Edition/tree/master/Chapter08,文件名为output_color_cat_burrito.png

这是我们图像特征提取的第一步。我们一次处理一张图像,并通过几行代码将这些图像转换为数值。通过这一过程,我们看到,从灰度图像中提取特征会产生一个二维的 ndarray,而从彩色图像中提取特征会产生一个像素强度值的张量。

然而,这里有一个小问题。记住,这只是一张单独的图像,一条单独的训练样本,一行数据。以我们的灰度图像为例,如果我们将这个矩阵展平为一行,我们将拥有image_height x image_width列,或者在我们的例子中,是 1,322,500 列。我们可以通过运行以下代码片段来确认这一点:

# flattening our grayscale cat_burrito and checking the length
len(cat_burrito.flatten())

这是一个问题!与其他机器学习建模任务一样,高维度会导致模型性能问题。在如此高维度的情况下,我们构建的任何模型都可能会出现过拟合,且训练时间会很慢。

这个维度问题是这种计算机视觉任务的普遍问题。即便是一个分辨率较低的数据集,比如 400 x 400 像素的灰度猫卷饼图像,也会导致每张图像有 160,000 个特征。

然而,解决这个问题的已知方法是:卷积神经网络。在接下来的部分,我们将继续使用卷积神经网络进行特征提取,以构建这些原始图像像素的低维表示。我们将讨论它们的工作原理,并继续了解它们在图像分类任务中为何如此高效。

卷积神经网络

卷积神经网络是一类解决我们在上一部分提到的高维度问题的神经网络,因此在图像分类任务中表现出色。事实证明,给定图像区域中的图像像素是高度相关的——它们为我们提供了关于该特定图像区域的相似信息。因此,使用卷积神经网络,我们可以扫描图像的区域,并在较低维度的空间中总结该区域。正如我们将看到的,这些低维表示,称为特征图,告诉我们关于各种形状存在的许多有趣的事情——从最简单的线条、阴影、环路和漩涡,到非常抽象、复杂的形式,特定于我们的数据,在我们的例子中是猫耳、猫脸或玉米饼——并且在比原始图像更少的维度中完成这一切。

在使用卷积神经网络从图像中提取这些低维特征之后,我们将把卷积神经网络的输出传入一个适合进行分类或回归任务的网络。在我们的例子中,当建模 Zalando 研究数据集时,卷积神经网络的输出将传入一个全连接神经网络,用于多分类。

那么这是怎么运作的呢?我们将讨论几个关键组件,这些组件对于卷积神经网络在灰度图像上的应用非常重要,它们对我们构建理解至关重要。

网络拓扑

你可能见过类似于上述的图示,它将卷积神经网络与前馈神经网络架构进行了对比。我们很快也会构建一个类似的东西!但这里描绘的是什么呢?看看这个:

在前面的图示中,最左边是我们的输入。这些是我们图像的提取特征,是一个值范围从 0 到 255 的矩阵(就像灰度猫卷饼的情况一样),描述了图像中像素的强度。

接下来,我们将数据通过交替的卷积层和最大池化层。这些层定义了所描绘架构中的卷积神经网络组件。在接下来的两部分中,我们将描述这些层类型的作用。

之后,我们将数据传递给一个全连接层,然后到达输出层。这两层描述了一个全连接神经网络。你可以在这里使用任何你喜欢的多分类算法,而不是全连接神经网络——例如逻辑回归随机森林分类器——但对于我们的数据集,我们将使用全连接神经网络。

所描绘的输出层与其他任何多分类分类器相同。以我们的猫卷饼示例为例,假设我们正在构建一个模型,用来预测图像属于五种不同类别中的哪一种:鸡肉猫卷饼、牛排猫卷饼、牧羊猫卷饼、素食猫卷饼,或者鱼类猫卷饼(我让你发挥想象,想象我们的训练数据可能是什么样子)。输出层将是图像属于五个类别之一的预测概率,max(probability) 表示我们模型认为最有可能的类别。

从高层次上看,我们已经介绍了前面网络的架构或拓扑。我们讨论了输入与卷积神经网络组件以及前面拓扑中的全连接神经网络组件之间的关系。现在让我们更深入一点,加入一些概念,帮助我们更详细地描述拓扑:

  • 网络有多少个卷积层?两个。

  • 那么在每个卷积层中,有多少个特征图?卷积层 1 有 7 个,卷积层 2 有 12 个。

  • 网络有多少个池化层?两个。

  • 完全连接层有多少层?一层。

  • 完全连接层中有多少个神经元?10 个。

  • 输出是多少?五。

模型者决定使用两个卷积层而不是其他数量的层,或使用单个完全连接层而不是其他层数,应该被视为模型的超参数。也就是说,这些是我们作为模型开发者应该进行实验和交叉验证的内容,而不是模型显式学习和优化的参数。

仅通过查看网络的拓扑结构,你可以推断出一些关于你正在解决的问题的有用信息。正如我们讨论的那样,我们的网络输出层包含五个节点,这表明该神经网络是为了解决一个有五个类别的多类别分类任务。如果它是回归问题或二元分类问题,我们的网络架构通常(在大多数情况下)会只有一个输出节点。我们还知道,模型者在第一个卷积层使用了七个滤波器,在第二个卷积层使用了 12 个内核,因为每一层产生的特征图数量(我们将在下一节详细讨论这些内核是什么)。

太好了!我们学到了一些有用的术语,它们将帮助我们描述我们的网络,并构建对网络如何工作的概念理解。现在让我们探索一下我们架构中的卷积层。

卷积层和滤波器

卷积层和滤波器是卷积神经网络的核心。在这些层中,我们将一个滤波器(在本文中也称为窗口内核)滑动过我们的 ndarray 特征,并在每一步进行内积运算。以这种方式对 ndarray 和内核进行卷积,最终得到一个低维的图像表示。让我们看看在这张灰度图像上是如何工作的(可在 image-assets 库中找到):

上图是一个 5 x 5 像素的灰度图像,显示了一个黑色的对角线,背景是白色的。

从以下图示中提取特征后,我们得到如下的像素强度矩阵:

接下来,假设我们(或 Keras)实例化了以下内核:

现在我们将可视化卷积过程。窗口的移动从图像矩阵的左上角开始。我们将窗口向右滑动一个预定的步长。在这种情况下,步长为 1,但通常步长大小应该视为模型的另一个超参数。一旦窗口到达图像的最右边缘,我们将窗口向下滑动 1(即步长大小),然后将窗口移回到图像的最左边,重新开始内积计算的过程。

现在让我们一步一步来做:

  1. 将内核滑过矩阵的左上部分并计算内积:

我将显式地展示第一步的内积计算,以便你能轻松跟上:

(0x0)+(255x0)+(255x0)+(255x0)+(0x1)+(255x0)+(255x0)+(255x0)+(0x0) = 0

我们将结果写入特征图并继续!

  1. 计算内积并将结果写入我们的特征图:

  1. 第 3 步:

  1. 我们已经到达图像的最右边缘。将窗口向下滑动 1 个单位,即我们的步长大小,然后从图像的最左边开始重新开始这个过程:

  1. 第 5 步:

  1. 第 6 步:

  1. 第 7 步:

  1. 第 8 步:

  1. 第 9 步:

看!我们已经将原始的 5 x 5 图像表示为 3 x 3 的矩阵(我们的特征图)。在这个简单的示例中,我们已经将维度从 25 个特征减少到只有 9 个特征。让我们看看这个操作后的结果图像:

如果你觉得这看起来和我们原始的黑色对角线图像一样,只是变小了,你是对的。内核的取值决定了识别到的内容,在这个具体示例中,我们使用了所谓的单位矩阵内核。如果使用其他值的内核,它将返回图像的其他特征——例如检测线条、边缘、轮廓、高对比度区域等。

我们将在每个卷积层同时应用多个内核对图像进行处理。使用的内核数量由模型设计者决定——这是另一个超参数。理想情况下,你希望在实现可接受的交叉验证结果的同时,尽可能使用最少的内核。越简单越好!然而,根据任务的复杂性,使用更多内核可能会带来性能提升。相同的思路也适用于调节模型的其他超参数,比如网络中的层数或每层的神经元数。我们在追求简洁与复杂性之间做出权衡,同时在通用性、速度、细节和精度之间进行选择。

核心数量是我们的选择,而每个核心的取值是我们模型的一个参数,这个参数是通过训练数据学习得到的,并在训练过程中通过优化减少成本函数来调整。

我们已经看到如何一步步将过滤器与图像特征进行卷积,以创建单一的特征图。那么,当我们同时应用多个核时会发生什么呢?这些特征图如何通过网络的每一层传递?让我们看看以下截图:

图片来源:Lee 等人,《卷积深度信念网络用于可扩展的无监督学习层次表示》,来自 Stack Exchange。源文本请参见:https://ai.stanford.edu/~ang/papers/icml09-ConvolutionalDeepBeliefNetworks.pdf

上面的截图展示了一个训练过面孔图像的网络在每个卷积层生成的特征图。在网络的早期层(最底部),我们检测到简单的视觉结构——简单的线条和边缘。我们是通过使用我们的身份核来做到这一点的!这一层的输出会传递到下一层(中间一行),该层将这些简单的形状组合成抽象的形式。我们在这里看到,边缘的组合构建了面部的组成部分——眼睛、鼻子、耳朵、嘴巴和眉毛。中间层的输出又会传递到最终层,该层将边缘的组合合成完整的物体——在这种情况下,是不同人的面孔。

这个整个过程的一个特别强大的特性是,所有这些特征和表示都是从数据中学习出来的。在任何时候,我们都不会明确地告诉我们的模型:模型,对于这个任务,我想在第一个卷积层使用一个身份核和一个底部 Sobel 核,因为我认为这两个核将提取出最丰富的特征图。一旦我们设置了要使用的核数量的超参数,模型通过优化学习到哪些线条、边缘、阴影及其复杂组合最适合判断什么是面孔,什么不是。模型进行这种优化时,并没有使用任何关于面孔、猫卷饼或衣服的领域特定的硬编码规则。

卷积神经网络还有许多其他迷人的特性,这些我们在本章中不再讨论。然而,我们确实探讨了其基础知识,并且希望你能感受到使用卷积神经网络来提取高表达性、信号丰富、低维度特征的重要性。

接下来,我们将讨论最大池化层

最大池化层

我们已经讨论了减少维度空间的重要性,以及如何使用卷积层来实现这一点。我们使用最大池化层有同样的原因——进一步减少维度。很直观地说,正如名字所示,最大池化是我们将一个窗口滑动到特征图上,并取该窗口的最大值。让我们回到我们对角线示例中的特征图来说明这一点,如下所示:

让我们看看当我们使用 2 x 2 窗口进行最大池化时,前面的特征图会发生什么。再说一遍,我们这里只是返回max(窗口中的值)

  1. 返回max(0,255,255,0),结果是 255:

  1. 第二步:

  1. 第三步:

  1. 第四步:

通过使用 2 x 2 窗口进行最大池化,我们去掉了一列和一行,将表示从 3 x 3 变成了 2 x 2——不错吧!

还有其他形式的池化,比如平均池化和最小池化;然而,你会发现最大池化是最常用的。

接下来,我们将讨论展平,这是一个步骤,我们将执行此操作,将我们的最大池化特征图转换为适合建模的形状。

展平

到目前为止,我们专注于尽可能构建一个紧凑且富有表现力的特征表示,并通过卷积神经网络和最大池化层来实现这一目标。我们转换的最后一步是将我们的卷积和最大池化后的 ndarray(在我们的示例中是一个 2 x 2 的矩阵)展平为一行训练数据。

我们的最大池化对角线黑线示例在代码中看起来像下面这样:

import numpy as np
max_pooled = np.array([[255,255],[255,255]])
max_pooled

运行这段代码将返回以下输出:

我们可以通过运行以下代码来检查形状:

max_pooled.shape

这会返回以下输出:

为了将这个矩阵转换为单个训练样本,我们只需要运行flatten()。让我们来做这个,并查看我们展平后的矩阵的形状:

flattened = max_pooled.flatten()
flattened.shape

这会生成以下输出:

最初是一个 5 x 5 的像素强度矩阵,现在变成了一个包含四个特征的单行数据。我们现在可以将其传入一个全连接的神经网络。

全连接层和输出

全连接层是我们将输入——通过卷积、最大池化和展平操作得到的行——映射到目标类别或类别的地方。在这里,每个输入都与下一层中的每个神经元节点相连接。这些连接的强度,或称为权重,以及每个节点中存在的偏置项,是模型的参数,这些参数在整个训练过程中不断优化,以最小化目标函数。

我们模型的最后一层将是输出层,它给出我们的模型预测结果。输出层中神经元的数量以及我们应用的激活函数由我们要解决的问题类型决定:回归、二分类或多分类。当我们在下一节开始使用 Zalando Research 的时尚数据集时,我们将看到如何为多分类任务设置全连接层和输出层。

全连接层和输出层——即我们架构中的前馈神经网络组件——属于一种与我们在本节中讨论的卷积神经网络不同的神经网络类型。我们在本节中简要描述了前馈网络的工作原理,目的是为了帮助理解我们架构中的分类器组件如何工作。你可以随时将这一部分架构替换为你更熟悉的分类器,例如logit

有了这些基础知识,你现在可以开始构建你的网络了!

使用 Keras 构建卷积神经网络,分类 Zalando Research 数据集中的图像

在本节中,我们将构建卷积神经网络来分类 Zalando Research 的服装图片,使用该公司的时尚数据集。该数据集的仓库可以在github.com/zalandoresearch/fashion-mnist找到。

该数据集包含 70,000 张灰度图像——每张图像展示了一种服装——这些服装来自 10 种可能的服装类型。具体而言,目标类别如下:T 恤/上衣、裤子、毛衣、连衣裙、外套、凉鞋、衬衫、运动鞋、包和踝靴。

Zalando 是一家总部位于德国的电子商务公司,发布了这个数据集,以为研究人员提供经典手写数字 MNIST 数据集的替代方案。此外,这个数据集,他们称之为Fashion MNIST,在预测准确性上稍有挑战——MNIST 手写数字数据集可以在没有大量预处理或特别深度神经网络的情况下以 99.7%的准确率进行预测。

好的,让我们开始吧!请按照以下步骤操作:

  1. 将仓库克隆到我们的桌面。在终端中运行以下命令:
cd ~/Desktop/
git clone git@github.com:zalandoresearch/fashion-mnist.git

如果你还没有安装 Keras,请通过命令行运行pip install keras进行安装。我们还需要安装 TensorFlow。为此,请在命令行中运行pip install tensorflow

  1. 导入我们将要使用的库:
import sys
import numpy as np
import pandas as pd
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPool2D
from keras.utils import np_utils, plot_model
from PIL import Image
import matplotlib.pyplot as plt

这些库中的许多应该已经很熟悉了。然而,对于你们中的一些人来说,这可能是第一次使用 Keras。Keras 是一个流行的 Python 深度学习库。它是一个可以运行在 TensorFlow、CNTK 或 Theano 等机器学习框架之上的封装库。

对于我们的项目,Keras 将在后台运行 TensorFlow。直接使用 TensorFlow 可以让我们更明确地控制网络的行为;然而,由于 TensorFlow 使用数据流图来表示其操作,因此这可能需要一些时间来适应。幸运的是,Keras 抽象了很多内容,它的 API 对于熟悉sklearn的人来说非常容易学习。

另一个可能对你们中的一些人来说是新的库是Python Imaging LibraryPIL)。PIL 提供了一些图像处理功能。我们将使用它来可视化我们 Keras 网络的拓扑结构。

  1. 加载数据。Zalando 为我们提供了一个辅助脚本,帮助我们进行数据加载。我们只需要确保fashion-mnist/utils/在我们的路径中:
sys.path.append('/Users/Mike/Desktop/fashion-mnist/utils/')
import mnist_reader
  1. 使用辅助脚本加载数据:
X_train, y_train = mnist_reader.load_mnist('/Users/Mike/Desktop/fashion-mnist/data/fashion', kind='train')
X_test, y_test = mnist_reader.load_mnist('/Users/Mike/Desktop/fashion-mnist/data/fashion', kind='t10k')
  1. 看一下X_trainX_testy_trainy_test的形状:
print(X_train.shape, y_train.shape)
print(X_test.shape, y_test.shape)

运行这段代码会给出以下输出:

在这里,我们可以看到我们的训练集包含 60,000 张图像,测试集包含 10,000 张图像。每张图像当前是一个长度为 784 的值的向量。现在,让我们检查一下数据类型:

print(type(X_train))
print(type(y_train))
print(type(X_test))
print(type(y_test))

这将返回以下内容:

接下来,让我们看看数据的样子。记住,在当前形式下,每张图像是一个值的向量。我们知道这些图像是灰度图,所以为了可视化每张图像,我们必须将这些向量重新构造成一个 28 x 28 的矩阵。我们来做一下这个,并瞥一眼第一张图像:

image_1 = X_train[0].reshape(28,28)
plt.axis('off')
plt.imshow(image_1, cmap='gray');

这将生成以下输出:

太棒了!我们可以通过运行以下代码来查看这张图像所属的类别:

y_train[0]

这将生成以下输出:

类别被编码为 0-9。在 README 文件中,Zalando 提供了我们需要的映射:

鉴于此,我们现在知道我们的第一张图像是一个踝靴。太棒了!让我们创建一个明确的映射,将这些编码值与它们的类别名称对应起来。稍后这会很有用:

mapping = {0: "T-shirt/top", 1:"Trouser", 2:"Pullover", 3:"Dress", 
 4:"Coat", 5:"Sandal", 6:"Shirt", 7:"Sneaker", 8:"Bag", 9:"Ankle Boot"}

很好。我们已经看到了单张图片,但我们仍然需要了解数据中的内容。图片长什么样子?理解这一点可以告诉我们一些信息。举个例子,我很想看看这些类别在视觉上有多么明显区分。看起来与其他类别相似的类别,比起那些更独特的类别,会更难以被分类器区分。

在这里,我们定义一个辅助函数,帮助我们完成可视化过程:

def show_fashion_mnist(plot_rows, plot_columns, feature_array, target_array, cmap='gray', random_seed=None):
 '''Generates a plot_rows * plot_columns grid of randomly selected images from a feature         array. Sets the title of each subplot equal to the associated index in the target array and     unencodes (i.e. title is in plain English, not numeric). Takes as optional args a color map     and a random seed. Meant for EDA.'''
 # Grabs plot_rows*plot_columns indices at random from X_train. 
 if random_seed is not None:
 np.random.seed(random_seed)

 feature_array_indices = np.random.randint(0,feature_array.shape[0], size = plot_rows*plot_columns)

 # Creates our plots
 fig, ax = plt.subplots(plot_rows, plot_columns, figsize=(18,18))

 reshaped_images_list = []

 for feature_array_index in feature_array_indices:
 # Reshapes our images, appends tuple with reshaped image and class to a reshaped_images_list.
 reshaped_image = feature_array[feature_array_index].reshape((28,28))
 image_class = mapping[target_array[feature_array_index]]
 reshaped_images_list.append((reshaped_image, image_class))

 # Plots each image in reshaped_images_list to its own subplot
 counter = 0
 for row in range(plot_rows):
 for col in range(plot_columns):
 ax[row,col].axis('off')
 ax[row, col].imshow(reshaped_images_list[counter][0], 
                                cmap=cmap)
 ax[row, col].set_title(reshaped_images_list[counter][1])
 counter +=1

这个函数做什么?它从数据中随机选择一组图像,创建一个图像网格,这样我们就能同时查看多张图像。

它的参数包括所需的图像行数(plot_rows)、图像列数(plot_columns)、我们的X_trainfeature_array)和y_traintarget_array),并生成一个plot_rows x plot_columns大小的图像矩阵。作为可选参数,您可以指定一个cmap,即色图(默认值为‘gray',因为这些是灰度图像),以及一个random_seed,如果复制可视化很重要的话。

让我们看看如何运行,如下所示:

show_fashion_mnist(4,4, X_train, y_train, random_seed=72)

这将返回以下结果:

可视化输出

移除random_seed参数,并多次重新运行这个函数。具体来说,运行以下代码:

show_fashion_mnist(4,4, X_train, y_train)

你可能已经注意到,在这个分辨率下,一些类看起来非常相似,而其他一些类则非常不同。例如,t-shirt/top 目标类的样本可能看起来与 shirt 和 coat 目标类的样本非常相似,而 sandal 目标类似乎与其他类明显不同。在考虑模型可能的弱点与强项时,这是值得思考的内容。

现在让我们来看看数据集中目标类的分布情况。我们需要做上采样或下采样吗?让我们检查一下:

y = pd.Series(np.concatenate((y_train, y_test)))
plt.figure(figsize=(10,6))
plt.bar(x=[mapping[x] for x in y.value_counts().index], height = y.value_counts());
plt.xlabel("Class")
plt.ylabel("Number of Images per Class")
plt.title("Distribution of Target Classes");

运行上述代码会生成以下图表:

太棒了!这里不需要做类平衡调整。

接下来,让我们开始预处理数据,为建模做好准备。

正如我们在图像特征提取部分讨论的,这些灰度图像包含从 0 到 255 的像素值。我们通过运行以下代码来确认这一点:

print(X_train.max())
print(X_train.min())
print(X_test.max())
print(X_test.min())

这将返回以下值:

为了建模的目的,我们需要将这些值归一化到 0-1 的范围内。这是准备图像数据进行建模时的常见预处理步骤。保持这些值在这个范围内可以让我们的神经网络更快收敛。我们可以通过运行以下代码来归一化数据:

# First we cast as float
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
# Then normalize
X_train /= 255
X_test /= 255

我们的数据现在已从 0.0 缩放到 1.0。我们可以通过运行以下代码来确认这一点:

print(X_train.max())
print(X_train.min())
print(X_test.max())
print(X_test.min())

这将返回以下输出:

在运行我们的第一个 Keras 网络之前,我们需要执行的下一个预处理步骤是调整数据的形状。记住,X_trainX_test当前的形状分别是(60,000, 784)和(10,000, 784)。我们的图像仍然是向量。为了能够将这些美丽的卷积核应用到整个图像上,我们需要将它们调整为 28 x 28 的矩阵形式。此外,Keras 要求我们明确声明数据的通道数。因此,当我们将这些灰度图像调整为建模格式时,我们将声明1

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

最后,我们将对y向量进行独热编码,以符合 Keras 的目标形状要求:

y_train = np_utils.to_categorical(y_train, 10)
y_test = np_utils.to_categorical(y_test, 10)

我们现在准备开始建模。我们的第一个网络将有八个隐藏层。前六个隐藏层将由交替的卷积层和最大池化层组成。然后我们将把这个网络的输出展平,并将其输入到一个两层的前馈神经网络中,最后生成预测。代码如下所示:

model = Sequential()
model.add(Conv2D(filters = 35, kernel_size=(3,3), input_shape=(28,28,1), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Conv2D(filters = 35, kernel_size=(3,3), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Conv2D(filters = 45, kernel_size=(3,3), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Flatten())
model.add(Dense(64, activation='relu'))
model.add(Dense(32, activation='relu'))
model.add(Dense(10, activation='softmax'))
model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])

让我们深入描述每一行的内容:

  • 第 1 行:这里,我们只是实例化了我们的模型对象。接下来,我们将通过一系列的.add()方法调用依次定义架构——即层的数量。这就是 Keras API 的魅力所在。

  • 第 2 行:这里,我们添加了第一个卷积层。我们指定了35个卷积核,每个大小为 3 x 3。之后,我们指定了图像输入的形状,28 x 28 x 1。我们只需要在网络的第一次.add()调用中指定输入形状。最后,我们将激活函数指定为relu。激活函数在将输出传递到下一层之前,对输出进行变换。我们将在Conv2DDense层上应用激活函数。这些变换具有许多重要的性质。在这里使用relu可以加速网络的收敛,www.cs.toronto.edu/~fritz/absps/imagenet.pdf,并且与其他激活函数相比,relu计算起来并不昂贵——我们只是将负值变为 0,其他正值保持不变。从数学上讲,relu函数为max(0, value)。在本章中,我们将为每一层使用relu激活函数,除了输出层。

  • 第 3 行:这里,我们添加了第一个最大池化层。我们指定该层的窗口大小为 2 x 2。

  • 第 4 行:这是我们的第二个卷积层。我们设置它的方式与第一个卷积层完全相同。

  • 第 5 行:这是第二个最大池化层。我们设置该层的方式与第一个最大池化层完全相同。

  • 第 6 行:这是我们第三个也是最后一个卷积层。这次,我们增加了额外的过滤器(45个,而之前的层是35个)。这只是一个超参数,我鼓励你尝试不同的变体。

  • 第 7 行:这是第三个也是最后一个最大池化层。它的配置与之前的所有最大池化层完全相同。

  • 第 8 行:这是我们展平卷积神经网络输出的地方。

  • 第 9 行:这是我们全连接网络的第一层。在这一层,我们指定了64个神经元,并使用relu激活函数。

  • 第 10 行:这是我们全连接网络的第二层。在这一层,我们指定了32个神经元,并使用relu激活函数。

  • 第 11 行:这是我们的输出层。我们指定了10个神经元,等于我们数据中目标类别的数量。由于这是一个多分类问题,我们指定了softmax激活函数。输出将表示图像属于类别 0 到 9 的预测概率。这些概率的和为1。这 10 个概率中,最高的一个将代表我们的模型认为最有可能的类别。

  • 第 12 行:这是我们编译 Keras 模型的地方。在编译步骤中,我们指定了优化器Adam,这是一种梯度下降算法,能够自动调整学习率。我们还指定了损失函数——在这种情况下,使用categorical cross entropy,因为我们正在执行多分类问题。最后,在 metrics 参数中,我们指定了accuracy。通过指定这一点,Keras 将在每个 epoch 结束时告诉我们训练和验证准确率。

我们可以通过运行以下命令来获取模型的总结:

model.summary()

这将输出如下内容:

请注意,当数据通过模型时,输出形状如何变化。特别是,观察扁平化操作后的输出形状——只有 45 个特征。X_trainX_test中的原始数据每行有 784 个特征,所以这非常棒!

你需要安装pydot来渲染可视化。要安装它,请在终端运行pip install pydot。你可能需要重新启动内核以使安装生效。

使用 Keras 中的plot_model函数,我们可以以不同的方式可视化网络的拓扑结构。要做到这一点,请运行以下代码:

plot_model(model, to_file='Conv_model1.png', show_shapes=True)
Image.open('Conv_model1.png')

运行前面的代码将保存拓扑到Conv_model1.png并生成如下内容:

这个模型需要几分钟才能拟合。如果你担心系统的硬件规格,可以通过将训练周期数减少到10来轻松缩短训练时间。

运行以下代码块将拟合模型:

my_fit_model = model.fit(X_train, y_train, epochs=25, validation_data=
                        (X_test, y_test))

在拟合步骤中,我们指定了X_trainy_train。然后我们指定了希望训练模型的周期数。接着,我们输入验证数据——X_testy_test——以观察模型在外样本上的表现。我喜欢将model.fit步骤保存为变量my_fit_model,这样我们就可以在后面轻松地可视化每个 epoch 的训练和验证损失。

随着代码运行,你将看到每个 epoch 后模型的训练损失、验证损失和准确率。我们可以使用以下代码绘制模型的训练损失和验证损失:

plt.plot(my_fit_model.history['val_loss'], label="Validation")
plt.plot(my_fit_model.history['loss'], label = "Train")
plt.xlabel("Epoch", size=15)
plt.ylabel("Cat. Crossentropy Loss", size=15)
plt.title("Conv Net Train and Validation loss over epochs", size=18)
plt.legend();

运行前面的代码将生成如下图。你的图可能不会完全相同——这里有几个随机过程在进行——但它应该大致相同:

一瞥这个图表,我们可以看出我们的模型出现了过拟合。我们看到每个训练周期的训练损失持续下降,但验证损失没有同步变化。让我们看看准确率,以了解这个模型在分类任务中的表现如何。我们可以通过运行以下代码来查看:

plt.plot(my_fit_model.history['val_acc'], label="Validation")
plt.plot(my_fit_model.history['acc'], label = "Train")
plt.xlabel("Epoch", size=15)
plt.ylabel("Accuracy", size=15)
plt.title("Conv Net Train and Validation accuracy over epochs", 
           size=18)
plt.legend();

这将生成以下结果:

这个图表也告诉我们我们已经过拟合。但看起来我们的验证准确率已接近 80% 高位,真不错!为了获得模型达成的最大准确率以及出现该准确率的训练周期,我们可以运行以下代码:

print(max(my_fit_model.history['val_acc']))
print(my_fit_model.history['val_acc'].index(max(my_fit_model.history['v
      al_acc'])))

你的结果可能会与我的不同,但这是我的输出:

使用我们的卷积神经网络,我们在第 21 个周期达到了最高的分类准确率 89.48%。这真是太棒了!但我们仍然需要解决过拟合问题。接下来,我们将通过使用dropout 正则化来重建模型。

Dropout 正则化是我们可以应用于神经网络全连接层的一种正则化方法。使用 dropout 正则化时,我们在训练过程中随机丢弃神经元及其连接。通过这样做,网络不会过于依赖于与特定节点相关的权重或偏置,从而能更好地进行样本外泛化。

这里,我们添加了 dropout 正则化,指定在每个 Dense 层丢弃 35% 的神经元:

model = Sequential()
model.add(Conv2D(filters = 35, kernel_size=(3,3), input_shape=
         (28,28,1), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Conv2D(filters = 35, kernel_size=(3,3), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Conv2D(filters = 45, kernel_size=(3,3), activation='relu'))
model.add(MaxPool2D(pool_size=(2,2)))
model.add(Flatten())
model.add(Dense(64, activation='relu'))
model.add(Dropout(0.35))
model.add(Dense(32, activation='relu'))
model.add(Dropout(0.35))
model.add(Dense(10, activation='softmax'))
model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])

运行前面的代码将编译我们的新模型。让我们通过重新运行以下代码来再次查看总结:

model.summary()

运行前面的代码会返回以下输出:

让我们通过重新运行以下代码来重新拟合我们的模型:

my_fit_model = model.fit(X_train, y_train, epochs=25, validation_data=
                        (X_test, y_test))

一旦模型重新拟合完成,重新运行绘图代码以可视化损失。这是我的结果:

看起来更好了!我们训练和验证损失之间的差距缩小了,这是我们预期的效果,尽管仍然有一些改进的空间。

接下来,重新绘制准确率曲线。这是我这次训练的结果:

从过拟合的角度看,这也显得更好。太棒了!在应用正则化后,我们达成的最佳分类准确率是多少?让我们运行以下代码:

print(max(my_fit_model.history['val_acc']))
print(my_fit_model.history['val_acc'].index(max(my_fit_model.history['v
      al_acc'])))

我这次运行模型的输出如下:

有意思!我们达到的最佳验证准确率比未正则化模型的还低,但差距不大。而且它仍然相当不错!我们的模型告诉我们,我们预测正确衣物类别的概率为 88.85%。

评估我们在这里取得的成果的一种方式是将我们的模型准确率与数据集的基准准确率进行比较。基准准确率就是通过天真地选择数据集中最常出现的类别所得到的分数。对于这个特定的数据集,因为类别是完美平衡的且有 10 个类别,所以基准准确率是 10%。我们的模型轻松超越了这一基准准确率,显然它已经从数据中学到了一些东西!

从这里开始,你可以去探索很多不同的方向!尝试构建更深的模型,或者对我们在模型中使用的众多超参数进行网格搜索。像评估任何其他模型一样评估你的分类器性能——试着构建一个混淆矩阵,了解我们预测准确的类别以及哪些类别的表现较弱!

总结

我们在这里确实覆盖了很多内容!我们讨论了如何从图像中提取特征,卷积神经网络是如何工作的,接着我们又将卷积神经网络构建成一个全连接网络架构。在这个过程中,我们还学习了许多新的术语和概念!

希望在阅读本章后,你会觉得这些图像分类技术——你曾经可能认为只有巫师才能掌握的知识——实际上只是一系列为了直观原因进行的数学优化!希望这些内容能够帮助你在处理感兴趣的图像处理项目时取得进展!

第九章:构建一个聊天机器人

想象一下,假设你正独自坐在一个安静宽敞的房间里。右侧有一张小桌子,上面放着一叠白色打印纸和一支黑色的钢笔。你面前似乎有一个大红色的立方体,顶部有一个小孔——大小略小于邮筒的孔。洞口上方的铭文邀请你写下一个问题并把它穿过孔口。碰巧你会说普通话,于是你在纸张上写下你的问题,并将其放入孔中。几秒钟后,慢慢地,一个回答出现了。它同样是中文写的,正是你可能期待的那种回答。那么,你问了什么?你是人还是计算机? 回答是什么?是的,是的,我是。

这个思想实验基于哲学家约翰·赛尔的“中文房间”论证。实验的前提是,如果房间里有一个不会说中文的人,但他有一套规则,能够将英文字符完美地映射到中文字符上,那么他可能看起来像是理解中文,尽管他实际上并不懂中文。赛尔的论点是,产生可理解输出的算法程序不能说自己“理解”这一输出。它们缺乏意识。他的思想实验旨在反对强人工智能的观点,或认为人类大脑本质上只是一种湿机器的观点。赛尔不认为无论 AI 的行为看起来多么复杂,都能被称为拥有意识。

赛尔于 1980 年发布了这个实验。31 年后,Siri 将在 iPhone 4S 上发布。对于使用过 Siri 的人来说,显然我们还有很长的路要走,才可能面临是否我们正在与一个具有意识的代理交流的不确定性(尽管我们可能会对那些已知为人类的人的意识产生怀疑)。尽管这些代理,或聊天机器人,过去表现得笨拙,但该领域正在迅速发展。

在本章中,我们将学习如何从零开始构建一个聊天机器人。在这个过程中,我们还会了解该领域的历史及其未来前景。

本章将涵盖以下主题:

  • 图灵测试

  • 聊天机器人的历史

  • 聊天机器人设计

  • 构建聊天机器人

图灵测试

在塞尔的中文房间理论提出的 30 年前,艾伦·图灵在他的一篇较为著名的论文中提出了问题,机器能思考吗? 作为一位实践天才,他决定不是直接面对这个问题,而是将其置于他人心智问题的框架内。这个问题是:我们如何才能真正知道其他人有像我们一样的心智? 由于我们只能观察他们的行为,而无法看到他们内心的工作机制,我们必须信任他们像我们一样。图灵提出,如果一台机器能够表现得像智能一样,那么我们应该把它看作是智能的。这,简而言之,就是图灵测试。通过测试意味着让人类相信这台机器是一个人类。

你现在可能在想,有程序成功通过过图灵测试吗? 答案是,实际上并没有官方的图灵测试。最接近官方测试的是洛布纳奖。这是一个每年举办的比赛,奖励那些在评委眼中最能表现得像人的聊天机器人。如果这听起来很严谨和学术,实际上并非如此。传闻洛布纳本人是个相当有个性的人,经常公开表达自己对葡萄酒、大麻和妓女的喜爱。据说,他资助这项比赛完全是因为他对任何形式的劳动都抱有极大的厌恶。

希望到这个时候,你已经意识到,关于通过图灵测试的任何说法,更多的是一种表演,而非真正的科学。尽管如此,图灵的原始概念依然是一个有用的构建。

聊天机器人的历史

我依然记得那天,我父亲让我坐在他的 IBM XT 前,那台黑绿相间、屏幕闪烁的显示器前,叫我与对话。我们的对话大概是这样的:

你好,今天感觉怎么样?

嗨,这是谁?

嗨,今天怎么样?

我很好。你没告诉我你是谁。

我们应该讨论的是你,而不是我。

我们应该?为什么?

我们换个话题吧... 说说你的家人。

你简直无法进行对话。

你能详细说明一下吗?

你快把我搞死了。

也许你希望我在杀了你?

好吧,你让我有点害怕。

也许你真的是在说你自己?

什么??这毫无意义。

这个回答能对你有帮助吗?

结束

非常有趣。

退出

谢谢你和我交谈。

\(###\)#@@....

我所说的那个 it 是一个名为 ELIZA 的程序。ELIZA 是由约瑟夫·韦伊泽恩鲍姆(Joseph Weizenbaum)在 1960 年代中期编写于麻省理工学院人工智能实验室的,旨在模仿罗杰斯式心理治疗师的回应。尽管在深入研究时几乎显得滑稽,但这个程序能够让一些用户相信他们正在与真正的人类交谈,这是一个了不起的成就,考虑到它仅仅是使用随机化和正则表达式来模仿回复的 200 行代码。即使在今天,这个简单的程序仍然是流行文化的重要组成部分。如果你问 Siri 谁是 ELIZA,她会告诉你她是你的朋友和一位杰出的心理医生。

如果 ELIZA 是聊天机器人的早期示例,那么自那时以来我们看到了什么?近年来,新型聊天机器人如雨后春笋般涌现,其中最引人注目的是 Cleverbot。

Cleverbot 在 1997 年通过网络发布到世界上。多年来,该机器人已累积了数亿次对话,与早期的聊天机器人不同,正如其名称所示,Cleverbot 似乎随着每次对话变得更加智能。尽管其算法的确切细节难以找到,据说它通过记录所有对话并在数据库中查找最相似的问题和回答来工作,以找到最合适的回应。

我编造了一个无意义的问题,如下所示,你可以看到它在字符串匹配方面找到了与我的问题对象相似的内容:

我坚持说:

而且,我又得到了类似的东西...

你还会注意到,话题可以在对话中持续存在。作为回应,我被要求详细阐述并证明我的答案。这似乎是使 Cleverbot 变得聪明的其中一点。

尽管能从人类那里学到东西的聊天机器人可能相当有趣,但它们也可能有更黑暗的一面。

几年前,微软在 Twitter 上发布了一个名为 Tay 的聊天机器人。人们被邀请向 Tay 提问,而 Tay 则会根据其 个性 回应。微软显然将该机器人编程为看起来像一个 19 岁的美国女孩。她旨在成为你的虚拟 闺蜜;唯一的问题是,她开始发布极端种族主义言论。

由于这些令人难以置信的煽动性推文,微软被迫将 Tay 从 Twitter 下线,并发布了道歉声明。

"正如你们许多人现在所知道的,我们在周三推出了一个名为 Tay 的聊天机器人。我们对 Tay 不经意的冒犯性和伤人的推文深表歉意,这些推文不代表我们是谁或我们的立场,也不代表我们设计 Tay 的方式。Tay 现在已下线,只有当我们有信心能更好地预测与我们原则和价值观相冲突的恶意意图时,我们才会考虑重新启动 Tay。"

-2016 年 3 月 25 日 官方微软博客

很明显,未来那些希望将聊天机器人投入市场的品牌应该从这次的失败中吸取教训,并计划好让用户尝试操控它们,展示人类最糟糕的行为。

毋庸置疑,品牌们正在拥抱聊天机器人。从 Facebook 到 Taco Bell,每个品牌都在加入这场游戏。

见证 TacoBot:

是的,它真的是个现实存在的东西。尽管像 Tay 这样的失败让人跌倒,但未来的用户界面很可能会像 TacoBot 那样。最后的一个例子甚至可能帮助解释其中的原因。

Quartz 最近推出了一款将新闻转化为对话的应用。与其将当天的新闻按平铺方式展示,它让你参与一场对话,就像是从朋友那里获取新闻一样:

Twitter 的项目经理 David Gasca 在 Medium 上发布了一篇文章,描述了他使用该应用的体验。他讲述了这种对话式的设计如何唤起通常只在人与人关系中才会触发的情感:

“与简单的展示广告不同,在与我的应用建立对话关系时,我感觉自己欠它什么:我想要点击。在最潜意识的层面,我感到需要回报,不想让应用失望:‘应用给了我这个内容。到目前为止非常好,我很喜欢这些 GIF。我应该点击一下,因为它很有礼貌地请求了。’”

如果这种体验是普遍的——我相信是——这可能会成为广告的下一个大趋势,我毫不怀疑广告利润将推动用户界面设计的发展:

“机器人越像人类,就越会被当作人类对待。”

-Mat Webb,技术专家,Mind Hacks 的合著者

到这时,你可能迫不及待地想知道这些东西是如何工作的,那我们就继续吧!

聊天机器人的设计

原始的 ELIZA 应用程序大约是 200 行代码。Python 的 NLTK 实现也同样简短。以下是 NLTK 网站上的一段摘录(www.nltk.org/_modules/nltk/chat/eliza.html):

从代码中可以看到,输入文本首先被解析,然后与一系列正则表达式进行匹配。一旦输入匹配成功,系统会返回一个随机的回应(有时会回响部分输入内容)。所以,像 我需要一个塔可 这样的输入会触发一个回应:你真的需要一个塔可吗? 显然,答案是“是的”,而且幸运的是,我们已经发展到技术可以提供它(感谢你,TacoBot),但那时仍是初期阶段。令人震惊的是,有些人真的相信 ELIZA 是一个真实的人类。

那么更先进的机器人呢?它们是如何构建的?

令人惊讶的是,大多数你可能遇到的聊天机器人甚至没有使用机器学习ML);它们被称为基于检索的模型。这意味着回答是根据问题和上下文预先定义的。这些机器人的最常见架构是被称为人工智能标记语言AIML)的东西。AIML 是一种基于 XML 的模式,用于表示机器人在接收到用户输入时应如何互动。它其实就是 ELIZA 工作方式的更高级版本。

让我们看看如何使用 AIML 生成回应。首先,所有输入都会被预处理以进行标准化。这意味着当你输入Waaazzup???时,它会被映射为WHAT IS UP。这个预处理步骤将表达相同意思的多种说法归纳为一个输入,这个输入可以通过一个规则进行匹配。在这一过程中,标点符号和其他无关的输入也会被去除。一旦完成这些处理,输入就会与相应的规则进行匹配。以下是一个样例模板:

<category> 
<pattern>WHAT IS UP</pattern> 
<template>The sky, duh. Pfft. Humans...</template> 
</category> 

这就是基本设置,但你也可以在其中加入通配符、随机化和优先级方案。例如,以下模式使用了通配符匹配:

<category> 
<pattern>* FOR ME<pattern> 
<template>I'm a bot. I don't <star/>. Ever.</template> 
</category> 

在这里,*通配符匹配FOR ME之前的一个或多个单词,然后在输出模板中重复这些单词。如果用户输入的是为我跳舞!,则回复将是我是一个机器人。我不跳舞。永远不

正如你所看到的,这些规则并不能产生任何接近真实智能的东西,但有一些技巧可以增强这种错觉。其中一个更好的技巧是能够生成基于话题的回应。

例如,这里有一个引发话题的规则:

<category> 
<pattern>I LIKE TURTLES</pattern> 
<template>I feel like this whole <set name="topic">turtle</set> thing could be a problem. What do you like about them? </template> 
</category> 

一旦话题确定,特定于该上下文的规则可以进行匹配:

<topic name="turtles"> 

<category> 
<pattern>* SHELL IS *</pattern> 
<template>I dislike turtles primarily because of their shells. What other creepy things do you like about turtles? </template> 
</category> 

<category> 
<pattern>* HIDE *</pattern> 
<template>I wish, like a turtle, that I could hide from this conversation. </template> 
</category> 

</topic> 

让我们看看这种互动可能是什么样子的:

我喜欢乌龟!

我觉得这整个乌龟的事儿可能会是个问题。你喜欢它们什么呢?

我喜欢它们如何躲进壳里。

我希望,像乌龟一样,我能躲避这场对话。

你可以看到,贯穿整个对话的连贯性增加了一定的现实感。

你可能在想,在这个深度学习的时代,这种技术不可能是最先进的,没错。虽然大多数聊天机器人是基于规则的,但下一代聊天机器人正在崭露头角,它们基于神经网络。

在 2015 年,谷歌的 Oriol Vinyas 和 Quoc Le 发表了一篇论文,arxiv.org/pdf/1506.05869v1.pdf,描述了基于序列到序列模型构建神经网络的过程。这种类型的模型将输入序列(如ABC)映射到输出序列(如XYZ)。这些输入和输出可能是不同语言之间的翻译。例如,在他们的研究中,训练数据并不是语言翻译,而是技术支持记录和电影对话。尽管这两个模型的结果都很有趣,但基于电影模型的互动却成为了头条新闻。

以下是论文中的一些示例互动:

这些内容没有被人类明确编码,也不在训练集里,如问题所要求的。然而,看着这些,感觉像是在和一个人对话,真让人不寒而栗。接下来我们来看看更多内容:

注意,模型正在响应看起来像是性别()、地点(英格兰)和职业(运动员)的知识。即使是关于意义、伦理和道德的问题也是可以探讨的:

如果这个对话记录没有让你感到一丝寒意,那你很可能已经是某种人工智能了。

我强烈推荐通读整篇论文。它并不太技术性,但肯定会让你看到这项技术的未来发展方向。

我们已经讨论了很多关于聊天机器人的历史、类型和设计,但现在我们来开始构建我们自己的聊天机器人。我们将采用两种方法。第一种将使用我们之前看到的余弦相似度技术,第二种将利用序列到序列学习。

构建聊天机器人

现在,既然已经看到聊天机器人的潜力,你可能想要构建最好的、最先进的、类似 Google 级别的机器人,对吧?好吧,先把这个想法放在一边,因为我们现在将从做完全相反的事情开始。我们将构建一个最糟糕、最糟糕的机器人!

这听起来可能让人失望,但如果你的目标只是构建一些非常酷且吸引人的东西(而且不需要花费数小时来构建),这是一个很好的起点。

我们将利用从 Cleverbot 的真实对话中获取的训练数据。这些数据是从notsocleverbot.jimrule.com收集的。这个网站非常适合,因为它收录了人们与 Cleverbot 进行的最荒谬的对话。

让我们来看一下 Cleverbot 与用户之间的示例对话:

虽然你可以自由使用我们在前几章中介绍的网页抓取技术来收集数据,但你也可以在本章的 GitHub 仓库中找到一个.csv格式的数据。

我们将再次从 Jupyter Notebook 开始。我们将加载、解析并检查数据。首先,我们将导入 pandas 库和 Python 的正则表达式库re。我们还将设置 pandas 的选项,扩大列宽,以便更好地查看数据:

import pandas as pd 
import re 
pd.set_option('display.max_colwidth',200) 

现在,我们将加载我们的数据:

df = pd.read_csv('nscb.csv') 
df.head() 

上面的代码会产生以下输出:

由于我们只对第一列——对话数据感兴趣,因此我们将只解析这一列:

convo = df.iloc[:,0] 
convo 

上面的代码会产生以下输出:

你应该能看出我们有用户Cleverbot之间的互动,且任一方都可以发起对话。为了获得我们所需的格式,我们必须将数据解析为问答对。我们不一定关注谁说了什么,而是关注如何将每个回答与每个问题匹配。稍后你会明白为什么。现在,让我们对文本进行一些正则表达式的魔法处理:

clist = [] 
def qa_pairs(x): 
    cpairs = re.findall(": (.*?)(?:$|\n)", x) 
    clist.extend(list(zip(cpairs, cpairs[1:]))) 

convo.map(qa_pairs); 
convo_frame = pd.Series(dict(clist)).to_frame().reset_index() 
convo_frame.columns = ['q', 'a'] 

上述代码生成了以下输出:

好的,这里有很多代码。刚才发生了什么?我们首先创建了一个列表来存储问题和回答的元组。然后我们通过一个函数将我们的对话拆分成这些对,使用了正则表达式。

最后,我们将所有这些放入一个 pandas DataFrame 中,列标为 qa

接下来我们将应用一些算法魔法,来匹配与用户输入问题最相似的问题:

from sklearn.feature_extraction.text import TfidfVectorizer 
from sklearn.metrics.pairwise import cosine_similarity 

vectorizer = TfidfVectorizer(ngram_range=(1,3)) 
vec = vectorizer.fit_transform(convo_frame['q']) 

在前面的代码中,我们导入了 tf-idf 向量化库和余弦相似度库。然后我们使用训练数据创建了一个 tf-idf 矩阵。现在我们可以利用这个矩阵来转换我们自己的新问题,并衡量它们与训练集中现有问题的相似度。现在就让我们做这个:

my_q = vectorizer.transform(['Hi. My name is Alex.']) 

cs = cosine_similarity(my_q, vec) 

rs = pd.Series(cs[0]).sort_values(ascending=False) 
top5 = rs.iloc[0:5] 
top5 

上述代码生成了以下输出:

我们在这里看到了什么?这是我提出的问题与最相似的五个问题之间的余弦相似度。在左侧是索引,右侧是余弦相似度。让我们来看一下这些:

convo_frame.iloc[top5.index]['q'] 

这会产生以下输出:

正如你所看到的,没有完全相同的,但确实有一些相似之处。

现在让我们来看看这个回答:

rsi = rs.index[0] 
rsi 

convo_frame.iloc[rsi]['a'] 

上述代码生成了以下输出:

好的,我们的机器人似乎已经有了个性。让我们更进一步。

我们将创建一个方便的函数,这样我们就能轻松地测试多个陈述:

def get_response(q): 
    my_q = vectorizer.transform([q]) 
    cs = cosine_similarity(my_q, vec) 
    rs = pd.Series(cs[0]).sort_values(ascending=False) 
    rsi = rs.index[0] 
    return convo_frame.iloc[rsi]['a'] 

get_response('Yes, I am clearly more clever than you will ever be!') 

这会产生以下输出:

我们显然已经创造了一个怪物,所以我们会继续:

get_response('You are a stupid machine. Why must I prove anything to    
              you?') 

这会产生以下输出:

我很享受这个过程。让我们继续:

get_response('Did you eat tacos?') 

get_response('With beans on top?') 

get_response('What else do you like to do?') 

get_response('What do you like about it?') 

get_response('Me, random?') 

get_response('I think you mean you\'re') 

令人惊讶的是,这可能是我一段时间以来经历过的最棒的对话之一,无论是机器人还是其他。

尽管这是一个有趣的小项目,但现在让我们进入一个更高级的建模技术:序列到序列建模。

聊天机器人序列到序列建模

对于接下来的任务,我们将利用在第八章中讨论的几个库,使用卷积神经网络对图像进行分类,TensorFlow 和 Keras。如果你还没有安装它们,可以通过 pip 安装。

我们还将使用本章前面讨论的那种高级建模方法;它是一种深度学习方法,叫做序列到序列建模。这种方法常用于机器翻译和问答应用,因为它可以将任何长度的输入序列映射到任何长度的输出序列:

来源:https://blog.keras.io/a-ten-minute-introduction-to-sequence-to-sequence-learning-in-keras.html

François Chollet 在 Keras 博客中有一个很好的关于这种模型的介绍:blog.keras.io/a-ten-minute-introduction-to-sequence-to-sequence-learning-in-keras.html。值得一读。

我们将大量使用他的示例代码来构建我们的模型。尽管他的示例使用的是机器翻译(英语到法语),但我们将重新利用它来进行问答,并使用我们的 Cleverbot 数据集:

  1. 设置导入项:
from keras.models import Model 
from keras.layers import Input, LSTM, Dense 
import numpy as np 
  1. 设置训练参数:
batch_size = 64  # Batch size for training. 
epochs = 100  # Number of epochs to train for. 
latent_dim = 256  # Latent dimensionality of the encoding space. 
num_samples = 1000  # Number of samples to train on. 

我们将从这些开始。我们可以检查模型的成功,并根据需要进行调整。

数据处理的第一步是将数据获取到正确的格式,然后进行向量化。我们将一步步进行:

input_texts = [] 
target_texts = [] 
input_characters = set() 
target_characters = set() 

这将为我们的提问和回答(目标)创建列表,并为我们的问题和答案中的单个字符创建集合。该模型实际上是通过一次生成一个字符来工作的:

  1. 让我们将问题和回答对的字符数限制为 50 个或更少。这将有助于加速训练:
convo_frame['q len'] = convo_frame['q'].astype('str').apply(lambda  
                       x: len(x)) 
convo_frame['a len'] = convo_frame['a'].astype('str').apply(lambda 
                       x: len(x)) 
convo_frame = convo_frame[(convo_frame['q len'] < 50)&
                          (convo_frame['a len'] < 50)] 
  1. 让我们设置输入文本和目标文本列表:
input_texts = list(convo_frame['q'].astype('str')) 
target_texts = list(convo_frame['a'].map(lambda x: '\t' + x + 
                    '\n').astype('str')) 

上述代码将数据格式化为正确的格式。请注意,我们向目标文本中添加了制表符(\t)和换行符(\n)。这些将作为解码器的开始和停止标记。

  1. 让我们看看输入文本和目标文本:
input_texts 

上述代码生成了以下输出:

target_texts 

上述代码生成了以下输出:

现在让我们看看这些输入和目标字符集:

input_characters 

上述代码生成了以下输出:

target_characters 

上述代码生成了以下输出:

接下来,我们将对传入模型的数据做一些额外的准备。尽管数据可以是任意长度并且返回的长度也可以是任意的,但我们需要将数据填充到最大长度,以便模型可以正常工作:

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

print('Number of samples:', len(input_texts)) 
print('Number of unique input tokens:', num_encoder_tokens) 
print('Number of unique output tokens:', num_decoder_tokens) 
print('Max sequence length for inputs:', max_encoder_seq_length) 
print('Max sequence length for outputs:', max_decoder_seq_length) 

上述代码生成了以下输出:

接下来,我们将使用独热编码对数据进行向量化:

input_token_index = dict( 
    [(char, i) for i, char in enumerate(input_characters)]) 
target_token_index = dict( 
    [(char, i) for i, char in enumerate(target_characters)]) 

encoder_input_data = np.zeros( 
    (len(input_texts), max_encoder_seq_length, num_encoder_tokens), 
    dtype='float32') 
decoder_input_data = np.zeros( 
    (len(input_texts), max_decoder_seq_length, num_decoder_tokens), 
    dtype='float32') 
decoder_target_data = np.zeros( 
    (len(input_texts), max_decoder_seq_length, num_decoder_tokens), 
    dtype='float32') 

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

让我们来看一下其中一个向量:

Decoder_input_data 

上面的代码生成了以下输出:

从上图中,你会注意到我们有一个对字符数据进行独热编码的向量,这将在我们的模型中使用。

现在我们设置好我们的序列到序列模型的编码器和解码器 LSTM:

# Define an input sequence and process it. 
encoder_inputs = Input(shape=(None, num_encoder_tokens)) 
encoder = LSTM(latent_dim, return_state=True) 
encoder_outputs, state_h, state_c = encoder(encoder_inputs) 
# We discard `encoder_outputs` and only keep the states. 
encoder_states = [state_h, state_c] 

# Set up the decoder, using `encoder_states` as initial state. 
decoder_inputs = Input(shape=(None, num_decoder_tokens)) 

# We set up our decoder to return full output sequences, 
# and to return internal states as well. We don't use the 
# return states in the training model, but we will use them in  
# inference. 
decoder_lstm = LSTM(latent_dim, return_sequences=True,  
               return_state=True) 
decoder_outputs, _, _ = decoder_lstm(decoder_inputs, 
                                     initial_state=encoder_states) 
decoder_dense = Dense(num_decoder_tokens, activation='softmax') 
decoder_outputs = decoder_dense(decoder_outputs) 

然后我们继续讲解模型本身:

# Define the model that will turn 
# `encoder_input_data` & `decoder_input_data` into `decoder_target_data` 
model = Model([encoder_inputs, decoder_inputs], decoder_outputs) 

# Run training 
model.compile(optimizer='rmsprop', loss='categorical_crossentropy') 
model.fit([encoder_input_data, decoder_input_data], 
           decoder_target_data, 
           batch_size=batch_size, 
           epochs=epochs, 
           validation_split=0.2) 
# Save model 
model.save('s2s.h5') 

在上面的代码中,我们使用编码器和解码器的输入以及解码器的输出来定义模型。然后我们编译它,训练它,并保存它。

我们将模型设置为使用 1,000 个样本。在这里,我们还将数据按 80/20 的比例分为训练集和验证集。我们还将训练周期设为 100,因此它将运行 100 个周期。在一台标准的 MacBook Pro 上,这大约需要一个小时才能完成。

一旦该单元运行,以下输出将被生成:

下一步是我们的推理步骤。我们将使用这个模型生成的状态作为输入,传递给下一个模型以生成我们的输出:

# Next: inference mode (sampling). 
# Here's the drill: 
# 1) encode input and retrieve initial decoder state 
# 2) run one step of decoder with this initial state 
# and a "start of sequence" token as target. 
# Output will be the next target token 
# 3) Repeat with the current target token and current states 

# Define sampling models 
encoder_model = Model(encoder_inputs, encoder_states) 

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

# Reverse-lookup token index to decode sequences back to 
# something readable. 
reverse_input_char_index = dict( 
    (i, char) for char, i in input_token_index.items()) 
reverse_target_char_index = dict( 
    (i, char) for char, i in target_token_index.items()) 

def decode_sequence(input_seq): 
    # Encode the input as state vectors. 
    states_value = encoder_model.predict(input_seq) 

    # Generate empty target sequence of length 1\. 
    target_seq = np.zeros((1, 1, num_decoder_tokens)) 
    # Populate the first character of target sequence with the start character. 
    target_seq[0, 0, target_token_index['\t']] = 1\. 

    # Sampling loop for a batch of sequences 
    # (to simplify, here we assume a batch of size 1). 
    stop_condition = False 
    decoded_sentence = '' 
    while not stop_condition: 
        output_tokens, h, c = decoder_model.predict( 
            [target_seq] + states_value) 

        # Sample a token 
        sampled_token_index = np.argmax(output_tokens[0, -1, :]) 
        sampled_char = reverse_target_char_index[sampled_token_index] 
        decoded_sentence += sampled_char 

        # Exit condition: either hit max length 
        # or find stop character. 
        if (sampled_char == '\n' or 
           len(decoded_sentence) > max_decoder_seq_length): 
            stop_condition = True 

        # Update the target sequence (of length 1). 
        target_seq = np.zeros((1, 1, num_decoder_tokens)) 
        target_seq[0, 0, sampled_token_index] = 1\. 

        # Update states 
        states_value = [h, c] 

    return decoded_sentence 

for seq_index in range(100): 
    # Take one sequence (part of the training set) 
    # for trying out decoding. 
    input_seq = encoder_input_data[seq_index: seq_index + 1] 
    decoded_sentence = decode_sequence(input_seq) 
    print('-') 
    print('Input sentence:', input_texts[seq_index]) 
    print('Decoded sentence:', decoded_sentence) 

上面的代码生成了以下输出:

如你所见,我们模型的结果相当重复。但是我们仅使用了 1,000 个样本,并且响应是一个字符一个字符生成的,所以这实际上已经相当令人印象深刻。

如果你想获得更好的结果,可以使用更多样本数据和更多训练周期重新运行模型。

在这里,我提供了一些我从更长时间的训练中记录下来的较为幽默的输出:

摘要

在本章中,我们对聊天机器人领域进行了全面的探索。很明显,我们正处于这类应用程序爆炸性增长的前夕。对话式用户界面的革命即将开始。希望本章能激励你创建自己的聊天机器人,如果没有,也希望你对这些应用的工作原理及其如何塑造我们的未来有了更丰富的理解。

我会让应用程序说出最后的结论:

get_response("This is the end, Cleverbot. Say goodbye.") 

第十章:构建推荐引擎

就像许多事情一样,它源于挫败感和烈酒。那是一个星期六,两个年轻人再次陷入了没有约会的困境。当他们坐下来倒酒、分享苦恼时,这两个哈佛大学的新生开始构思一个想法。如果他们不再依赖随机机会来遇到合适的女孩,而是能利用计算机算法呢?

他们认为,匹配人们的关键是创建一组问题,提供每个人在第一次尴尬约会时真正想了解的信息。通过使用这些问卷来匹配人们,你可以消除那些最好避免的约会。这个过程将是超级高效的。

这个想法是将他们的新服务推向波士顿及全国各地的大学生。简而言之,他们确实做到了这一点。

不久之后,他们所构建的数字匹配服务获得了巨大的成功。它吸引了全国媒体的关注,并在接下来的几年里生成了数万次匹配。事实上,这家公司如此成功,以至于最终被一家更大的公司收购,该公司希望利用其技术。

如果你认为我在谈论OkCupid,那你就错了——而且错得有点远,大约错了 40 年。我说的这家公司从 1965 年开始就做了这些事——那时候,匹配计算是通过 IBM 1401 主机上的穿孔卡来完成的。完成计算甚至需要三天时间。

但奇怪的是,OkCupid和它 1965 年的前身兼容性研究公司(Compatibility Research, Inc.)之间有一种联系。兼容性研究的共同创始人是杰夫·塔尔(Jeff Tarr),他的女儿詹妮弗·塔尔(Jennifer Tarr)是OkCupid共同创始人克里斯·科因(Chris Coyne)的妻子。真是个小世界。

那么,为什么这一切和构建推荐引擎的章节有关系呢?因为很可能这实际上是第一个推荐引擎。而尽管大多数人通常把推荐引擎视为用来寻找相关产品、音乐和电影的工具,这些是人们可能会喜欢的,但最初的版本是用来寻找潜在的伴侣的。作为思考这些系统如何工作的模型,它提供了一个很好的参考框架。

本章我们将探索推荐系统的不同种类。我们将看到它们是如何商业化实施的,以及它们是如何运作的。最后,我们将实现自己的推荐引擎,用来查找 GitHub 上的仓库。

本章将涵盖以下主题:

  • 协同过滤

  • 基于内容的过滤

  • 混合系统

  • 构建推荐引擎

协同过滤

2012 年初,一则新闻报道了一个男人的故事,他来到明尼阿波利斯的 Target 商店投诉送到他家中的一本优惠券书。实际上,他对这些优惠券非常生气,这些优惠券是寄给他女儿的,而她当时是一名高中生。虽然这看起来像是对一项潜在的省钱机会的奇怪反应,但得知这些优惠券只针对产前维生素、尿布、婴儿配方奶粉、婴儿床等产品时,可能会改变你的看法。

经理在听到投诉后,深感抱歉。事实上,他感到十分难过,以至于几天后他再次打电话进行跟进并解释事情是如何发生的。但在经理还没开始道歉之前,父亲开始向经理道歉。事实证明,他的女儿实际上已经怀孕了,而且她的购物习惯暴露了这一点。

揭露她的算法很可能至少部分基于一种在推荐引擎中使用的算法,叫做协同过滤

那么,什么是协同过滤?

协同过滤基于这样一个理念:在世界的某个地方,你有一个品味的双胞胎——一个与自己在评价星际大战的好坏以及真爱至上的糟糕程度上有相同看法的人。

其核心理念是,你对一组物品的评分方式非常类似于另一个人——这个双胞胎——对这些物品的评分方式,但你们每个人又分别对其他物品进行了评分,而这些物品对方并没有评分。由于你们的品味相似,推荐可以基于你们的双胞胎对某些你没有评分的高分物品,或者基于你对某些他没有评分的高分物品进行生成。从某种意义上讲,这就像是数字化的配对,但结果是你会喜欢的歌曲或产品,而不是实际的人。

因此,在我们怀孕的高中女生的例子中,当她购买了正确的无香料润肤霜、棉花球和维生素补充剂组合时,她很可能与那些后来购买婴儿床和尿布的人配对了。

让我们通过一个例子来看看这在实际中是如何运作的。

我们将从所谓的效用矩阵开始。这与词-文档矩阵类似,但我们将代表的是产品和用户,而不是词汇和文档。

在这里,我们假设我们有客户A-D,以及一组他们根据 0 到 5 的评分标准对产品的评价:

| 客户 | 斯纳奇薯片 | 顺滑润肤霜 | 达夫啤酒 | 更佳水 | XX 大型生活足球衫 | 雪白棉花

尿布 | 迪斯波索尿布 |

A 4 5 3 5
B 4 4 5
C 2 2 1
D 5 3 5 4

我们之前看到,当我们想要找到相似的项目时,可以使用余弦相似度。我们就在这里尝试一下。我们将找到最像用户 A 的用户。由于我们有一个稀疏向量,其中包含许多未评分的项目,我们需要为这些缺失值输入一些内容。我们这里就使用 0。我们从比较用户 A 和用户 B 开始:

from sklearn.metrics.pairwise import cosine_similarity 
cosine_similarity(np.array([4,0,5,3,5,0,0]).reshape(1,-1),\ 
                  np.array([0,4,0,4,0,5,0]).reshape(1,-1)) 

上述代码的结果是以下输出:

如你所见,两者的相似度评分并不高,这也有道理,因为他们没有共同的评分项。

现在我们来看一下用户 C 与用户 A 进行比较:

cosine_similarity(np.array([4,0,5,3,5,0,0]).reshape(1,-1),\ 
                  np.array([2,0,2,0,1,0,0]).reshape(1,-1)) 

上述代码的结果是以下输出:

在这里,我们看到他们之间有很高的相似度评分(记住,1 是完美相似),尽管他们对相同的产品给出了截然不同的评分。为什么会得到这样的结果呢?问题出在我们选择使用 0 来表示未评分的产品。它被视为这些未评分产品的强烈(负向)一致性。0 在这种情况下并不是中立的。

那么,我们该如何解决这个问题呢?

我们可以做的,不仅仅是对缺失值使用 0,而是重新调整每个用户的评分,使得平均评分为 0,或者说是中立的。我们通过将每个用户的评分减去该用户所有评分的平均值来实现这一点。例如,对于用户 A,其平均值为 17/4,即 4.25。然后,我们从用户 A 提供的每个评分中减去这个平均值。

完成这一步后,我们继续为每个用户计算平均值,并将其从每个用户的评分中减去,直到所有用户都被处理完。

这个过程将生成如下表格。你会注意到每个用户的行总和为 0(这里忽略四舍五入问题):

| 顾客 | Snarky's 土豆片 | SoSo 顺滑 乳液 | Duffly 啤酒 | BetterTap | XXLargeLivin' 橄榄球球衣 | Snowy 棉花

Balls | Disposos' 尿布 |

A -.25 .75 -1.25 .75
B -.33 -.33 .66
C .33 .33 -.66
D .75 -1.25 .75 -.25

现在让我们在我们重新调整过的数据上尝试余弦相似度。我们将再次进行用户 A 与用户 BC 的比较。

首先,让我们将用户 A 与用户 B 进行比较:

cosine_similarity(np.array([-.25,0,.75,-1.25,.75,0,0])\ 
                  .reshape(1,-1),\ 
                  np.array([0,-.33,0,-.33,0,.66,0])\ 
                  .reshape(1,-1)) 

上述代码的结果是以下输出:

现在让我们尝试将用户 AC 进行比较:

cosine_similarity(np.array([-.25,0,.75,-1.25,.75,0,0])\ 
                  .reshape(1,-1),\ 
                  np.array([.33,0,.33,0,-.66,0,0])\ 
                  .reshape(1,-1)) 

上述代码的结果是以下输出:

我们可以看到,AB 之间的相似度略有增加,而 AC 之间的相似度则大幅下降。这正是我们希望看到的结果。

这个中心化过程,除了帮助我们处理缺失值外,还具有一个副作用,帮助我们处理评分严格或宽松的用户,因为现在每个人的评分都是围绕 0 进行中心化的。你可能会注意到,这个公式等同于皮尔逊相关系数,就像那个系数一样,数值范围在-11之间。

预测产品评分

现在,让我们利用这个框架来预测某个产品的评分。我们将示例限定为三位用户,用户X、用户Y和用户Z。我们将预测用户X未评分的产品,但用户Y和用户Z已经对其进行了评分,而且他们与X非常相似。

我们将从每个用户的基础评分开始,如下表所示:

| 客户 | Snarky's Potato Chips | SoSo Smooth Lotion | Duffly Beer | BetterTap Water | XXLargeLivin' Football Jersey | Snowy Cotton

| Disposos' Diapers |

X 4 3 4
Y 3.5 2.5 4 4
Z 4 3.5 4.5 4.5

接下来,我们将对评分进行中心化处理:

| 客户 | Snarky's Potato Chips | SoSo Smooth Lotion | Duffly Beer | BetterTap Water | XXLargeLivin' Football Jersey | Snowy Cotton

| Disposos' Diapers |

X .33 -.66 .33 ?
Y 0 -1 .5 .5
Z -.125 -.625 .375 .375

现在,我们想知道用户X可能给Disposos' Diapers的评分是多少。通过使用用户Y和用户Z的评分,我们可以根据他们的中心化余弦相似度计算加权平均值来得到这个评分。

我们先计算出这个数值:

user_x = [0,.33,0,-.66,0,33,0] 
user_y = [0,0,0,-1,0,.5,.5] 

cosine_similarity(np.array(user_x).reshape(1,-1),\ 
                  np.array(user_y).reshape(1,-1)) 

前面的代码产生了以下输出:

现在,让我们计算用户Z的数值:

user_x = [0,.33,0,-.66,0,33,0] 
user_z = [0,-.125,0,-.625,0,.375,.375] 

cosine_similarity(np.array(user_x).reshape(1,-1),\ 
                  np.array(user_z).reshape(1,-1)) 

前面的代码产生了以下输出:

所以,现在我们已经得到了用户X与用户Y0.42447212)和用户Z0.46571861)之间的相似度值。

将所有内容整合在一起,我们通过每个用户与X的相似度加权评分,然后再按总相似度进行除法,如下所示:

(.42447212 * (4) + .46571861 * (4.5) ) / (.42447212 + .46571861) = 4.26

我们可以看到,用户XDisposos' Diapers的预期评分是 4.26(最好发送一个优惠券!)

目前为止,我们只看了用户对用户的协同过滤方法,但还有一种方法可以使用。在实际应用中,这种方法优于用户对用户的过滤;它被称为项目对项目过滤。该方法的工作原理是:与其基于过去的评分将每个用户与其他相似的用户进行匹配,不如将每个已评分的项目与所有其他项目进行比较,以找到最相似的项目,再次使用中心化余弦相似度。

让我们看看这个是如何工作的。

我们又有了一个效用矩阵;这次,我们将查看用户对歌曲的评分。用户在列上,歌曲在行上,结果如下:

实体 U1 U2 U3 U4 U5
S1 2 4 5
S2 3 3
S3 1 5 4
S4 4 4 4
S5 3 5

现在,假设我们想知道用户 3 会给歌曲 5 打多少分。我们不再寻找相似的用户,而是根据这些歌曲在不同用户中的评分相似度,寻找相似的歌曲。

让我们看一个例子。

首先,我们通过对每首歌的行进行居中,并计算与我们的目标行S5的余弦相似度,结果如下:

实体 U1 U2 U3 U4 U5 CntrdCoSim
S1 -1.66 .33 1.33 .98
S2 0 0 0
S3 -2.33 1.66 .66 .72
S4 0 0 0 0
S5 -1 ? 1 1

你可以看到最右列是通过每一行与行S5的中心余弦相似度计算出来的。

我们接下来需要选择一个数字,k,即我们用来对用户 3 打分的最近邻数量。在这个简单的示例中,我们使用k = 2

你可以看到歌曲S1和歌曲S3是最相似的,因此我们将使用这两首歌曲以及用户 3 对S1S3的评分(分别为 4 和 5)。

现在让我们来计算评分:

(.98 * (4) + .72 * (5)) / (.98 + .72) = 4.42

所以,根据这个物品到物品的协同过滤,我们可以看到用户 3 可能会对歌曲S5给出非常高的评分,计算结果为 4.42。

之前,我说过用户到用户过滤不如物品到物品过滤有效。这是为什么呢?

很有可能你有一些朋友非常喜欢你也喜欢的东西,但每个人还有其他一些兴趣领域,而这些领域是对方完全没有兴趣的。

例如,也许你们俩都喜欢权力的游戏,但你的朋友还喜欢挪威死亡金属。然而,你宁愿死掉也不愿听挪威死亡金属。如果你们在许多方面相似——排除死亡金属——通过用户到用户的推荐,你仍然会看到许多包含火焰斧头骷髅重击等词语的乐队推荐。而通过物品到物品的过滤,最有可能的是,你将避免这些建议。

到目前为止,我们一直将用户和物品视为一个整体进行比较,但现在让我们看看另一种方法,它将用户和物品分解为可能称为特征篮子的东西。

基于内容的过滤

作为一名音乐人,Tim Westergren 曾在路上度过多年时间,聆听其他有才华的音乐人,思考为什么他们始终无法成功突破。尽管他们的音乐很好——与无线电广播上播放的音乐一样出色——然而,他们似乎从未迎来大爆发。他想,这一定是因为他们的音乐从未出现在足够多合适的人面前。

Tim 最终辞去了音乐人工作,转而担任电影配乐作曲家。在那里,他开始认为每一段音乐都有独特的结构,可以被分解成组成部分——一种音乐 DNA 的形式。

思考了一段时间后,他开始考虑围绕构建音乐基因组的这个想法成立一家公司。他向一位曾经创建并出售过公司朋友介绍了这个概念。朋友非常喜欢 Tim 的这个想法。事实上,他喜欢到开始帮助 Tim 编写商业计划书,并为项目筹集初期的资金。这项计划得到了推进。

在接下来的几年里,他们雇佣了一支小型的音乐家团队,细致地为超过百万首音乐作品制定了近 400 个独特的音乐特征。每个特征都用 0 到 5 分的评分标准手工评分(或者说是用耳朵评分可能更为恰当)。每首三到四分钟的歌曲需要将近半小时才能分类完毕。

特征包括主唱的声音沙哑程度或节奏的每分钟节拍数等。他们的第一个原型花了近一年时间才完成。这个原型完全是用 Excel 和 VBA 宏构建的,单单返回一个推荐就需要近四分钟的时间。但最终,它成功了,并且效果非常好。

这家公司现在被称为 Pandora 音乐,可能你要么听说过它,要么使用过它的产品,因为它在全球有数百万的日活跃用户。毫无疑问,这是基于内容的过滤的一个成功案例。

与基于内容的过滤方法将每首歌视为一个不可分割的单元不同,这些歌曲变成了特征向量,可以使用我们的老朋友余弦相似度进行比较。

另一个好处是,不仅歌曲可以被分解成特征向量,听众的偏好也可以被分解。每个听众的品味档案也会变成该空间中的一个向量,这样就可以在他们的品味档案和歌曲之间进行衡量。

对 Tim Westergren 来说,这就是魔力所在,因为与许多基于流行度的推荐不同,这个系统的推荐是基于固有的结构相似性做出的。也许你从未听说过歌曲X,但如果你喜欢歌曲Y,那么你也应该喜欢歌曲X,因为它在基因上几乎是相同的。这就是基于内容的过滤。

混合系统

我们现在已经看了两种主要的推荐系统,但你应该知道,在任何大规模的生产环境中,你可能会看到结合这两种方式的推荐系统。这被称为混合系统,混合系统的偏好原因在于,它们有助于消除使用单一系统时可能出现的缺点。这两个系统结合在一起,创造了一个更强大的解决方案。

让我们来看看每种类型的优缺点。

协同过滤

协同过滤的优点如下:

  • 不需要手动制作特征

缺点如下:

  • 如果没有大量的商品和用户,效果不好

  • 当商品数量远远超过可以购买的数量时,会出现稀疏性问题

基于内容的过滤

基于内容的过滤的优点如下:

  • 它不需要大量的用户

缺点如下:

  • 定义正确的特征可能是一个挑战

  • 缺乏偶然性

正如你所看到的,当你还没有建立起庞大的用户基础时,基于内容的过滤是一个更好的选择,但随着你的发展,增加协同过滤可以帮助将更多的偶然性引入推荐中。

现在你已经熟悉了推荐引擎的类型和工作原理,让我们开始构建一个自己的推荐引擎。

构建推荐引擎

我喜欢偶然发现一些非常有用的 GitHub 仓库。你可以找到从手工策划的机器学习教程到可以节省你大量代码行数的库,特别是在使用 Elasticsearch 时。问题是,找到这些库比应该的更难。幸运的是,我们现在有了足够的知识,可以通过 GitHub API 来帮助我们找到这些代码宝藏。

我们将使用 GitHub API 来基于协同过滤创建一个推荐引擎。计划是获取我一段时间内标记的所有仓库,然后获取这些仓库的所有创建者,查看他们标记了哪些仓库。一旦完成,我们将找到与我(或你,如果你为自己的仓库运行此过程,我建议这样做)最相似的用户。一旦找到最相似的用户,我们就可以使用他们标记的,而我没有标记的仓库来生成推荐列表。

让我们开始吧:

  1. 我们将导入所需的库:
import pandas as pd 
import numpy as np 
import requests 
import json 
  1. 你需要在 GitHub 上注册一个帐户,并标记一些仓库,这样才能使你的 GitHub 账号生效,但实际上你不需要注册开发者程序。你可以从你的个人资料中获得一个授权令牌,这将允许你使用 API。你也可以用这段代码使它正常工作,但由于限制太多,无法在我们的示例中使用。

  2. 要为 API 创建一个令牌,请访问以下网址:github.com/settings/tokens。在这里,你会看到右上角有一个按钮,像这样:

  1. 你需要点击那个生成新令牌的按钮。点击之后,你需要选择权限,我选择了仅限 public_repo。然后,最后复制它给你的令牌,并在以下代码中使用。确保将其包含在引号内:
myun = YOUR_GITHUB_HANDLE 
mypw = YOUR_PERSONAL_TOKEN 
  1. 我们将创建一个函数,提取你标星的每个仓库的名字:
my_starred_repos = [] 
def get_starred_by_me(): 
    resp_list = [] 
    last_resp = '' 
    first_url_to_get = 'https://api.github.com/user/starred' 
    first_url_resp = requests.get(first_url_to_get, auth=(myun,mypw)) 
    last_resp = first_url_resp 
    resp_list.append(json.loads(first_url_resp.text)) 

    while last_resp.links.get('next'): 
        next_url_to_get = last_resp.links['next']['url'] 
        next_url_resp = requests.get(next_url_to_get, auth=(myun,mypw)) 
        last_resp = next_url_resp 
        resp_list.append(json.loads(next_url_resp.text)) 

    for i in resp_list: 
        for j in i: 
            msr = j['html_url'] 
            my_starred_repos.append(msr) 

这里发生了很多事情,但本质上,我们在查询 API 以获取我们自己的标星仓库。GitHub 使用分页,而不是一次性返回所有内容。因此,我们需要检查每个响应中返回的.links。只要还有“下一页”链接可调用,我们就会继续这样做。

  1. 我们只需要调用我们创建的那个函数:
get_starred_by_me() 
  1. 然后,我们可以看到所有被标星的仓库的完整列表:
my_starred_repos 

前面的代码将输出类似以下内容:

  1. 我们需要解析出我们标星的每个库的用户名,以便我们可以获取他们标星的库:
my_starred_users = [] 
for ln in my_starred_repos: 
    right_split = ln.split('.com/')[1] 
    starred_usr = right_split.split('/')[0] 
    my_starred_users.append(starred_usr) 

my_starred_users 

这将产生类似以下的输出:

  1. 现在我们已经获取了所有我们标星的用户的用户名,我们需要获取他们标星的所有仓库。以下函数将实现这一功能:
starred_repos = {k:[] for k in set(my_starred_users)} 
def get_starred_by_user(user_name): 
    starred_resp_list = [] 
    last_resp = '' 
    first_url_to_get = 'https://api.github.com/users/'+ user_name +'/starred' 
    first_url_resp = requests.get(first_url_to_get, auth=(myun,mypw)) 
    last_resp = first_url_resp 
    starred_resp_list.append(json.loads(first_url_resp.text)) 

    while last_resp.links.get('next'): 
        next_url_to_get = last_resp.links['next']['url'] 
        next_url_resp = requests.get(next_url_to_get, auth=(myun,mypw)) 
        last_resp = next_url_resp 
        starred_resp_list.append(json.loads(next_url_resp.text)) 

    for i in starred_resp_list: 
        for j in i: 
            sr = j['html_url'] 
            starred_repos.get(user_name).append(sr) 

这个函数的工作方式与我们之前调用的函数几乎相同,但它调用的是不同的端点。它将把他们标星的仓库添加到我们稍后会用到的字典中。

  1. 现在让我们调用它。根据每个用户标星的仓库数量,它可能需要几分钟才能运行完成。实际上,我有一个用户标星了超过 4,000 个仓库:
for usr in list(set(my_starred_users)): 
    print(usr) 
    try: 
        get_starred_by_user(usr) 
    except: 
        print('failed for user', usr) 

前面的代码将输出类似以下内容:

注意,在调用它之前,我将标星用户列表转成了集合。我注意到有一些重复项,是因为一个用户标星了多个仓库,所以按这些步骤操作来减少额外的调用是很有意义的:

  1. 现在我们需要构建一个特征集,包含我们所有标星的用户标星的所有仓库:
repo_vocab = [item for sl in list(starred_repos.values()) for item in sl] 
  1. 我们将把它转换为集合,去除可能存在的重复项,这些重复项可能是多个用户标星了相同的仓库:
repo_set = list(set(repo_vocab)) 
  1. 让我们看看这会生成多少内容:
len(repo_vocab) 

前面的代码应该会输出类似以下内容:

我标星了 170 个仓库,此外,这些仓库的用户们一共标星了超过 27,000 个独立的仓库。你可以想象,如果我们再往外推一步,会看到多少仓库。

现在我们拥有了完整的特征集或仓库词汇,我们需要对每个用户进行操作,创建一个二进制向量,对于每个他们标星的仓库赋值1,对于每个没有标星的仓库赋值0

all_usr_vector = [] 
for k,v in starred_repos.items(): 
    usr_vector = [] 
    for url in repo_set: 
        if url in v: 
            usr_vector.extend([1]) 
        else: 
            usr_vector.extend([0]) 
    all_usr_vector.append(usr_vector) 

我们刚才做的事情是检查每个用户是否标星了我们仓库词汇中的每个仓库。如果标星了,他们会得到1,如果没有,他们会得到0

到目前为止,我们为每个用户(总共 170 个用户)创建了一个包含 27,098 个项目的二进制向量。现在,让我们将其放入一个DataFrame中。行索引将是我们标星过的用户句柄,列将是仓库词汇:

df = pd.DataFrame(all_usr_vector, columns=repo_set, index=starred_repos.keys()) 

df 

上面的代码将生成类似以下的输出:

接下来,为了与其他用户进行比较,我们需要将我们自己的行添加到这个框架中。在这里,我添加了我的用户句柄,但你应该添加你自己的:

my_repo_comp = [] 
for i in df.columns: 
    if i in my_starred_repos: 
        my_repo_comp.append(1) 
    else: 
        my_repo_comp.append(0) 

mrc = pd.Series(my_repo_comp).to_frame('acombs').T 

mrc 

上面的代码将生成类似以下的输出:

我们现在需要添加适当的列名,并将其与我们的其他DataFrame进行拼接:

mrc.columns = df.columns 

fdf = pd.concat([df, mrc]) 

fdf 

上面的代码将生成类似以下的输出:

你可以在上面的截图中看到,我已经被加入到了DataFrame中。

从这里开始,我们只需要计算自己与其他我们标星过的用户之间的相似度。我们现在就用pearsonr函数来做这个计算,我们需要从 SciPy 导入这个函数:

from scipy.stats import pearsonr 

sim_score = {} 
for i in range(len(fdf)): 
    ss = pearsonr(fdf.iloc[-1,:], fdf.iloc[i,:]) 
    sim_score.update({i: ss[0]}) 

sf = pd.Series(sim_score).to_frame('similarity') 
sf 

上面的代码将生成类似以下的输出:

我们刚才所做的是将我们的向量(DataFrame中的最后一个)与其他所有用户的向量进行比较,以生成一个居中的余弦相似度(皮尔逊相关系数)。由于某些用户没有标星任何仓库,因此有些值必然为NaN,这会导致在计算时除以零:

  1. 现在让我们对这些值进行排序,返回最相似用户的索引:
sf.sort_values('similarity', ascending=False) 

上面的代码将生成类似以下的输出:

所以,我们得到了最相似的用户,因此我们可以利用这些用户来推荐我们可能喜欢的仓库。让我们看看这些用户以及他们标星了哪些我们可能喜欢的仓库。

  1. 你可以忽略第一个具有完美相似度分数的用户;那是我们自己的仓库。向下看,三个最接近的匹配是用户 6、用户 42 和用户 116。我们来看看每一个:
fdf.index[6] 

上面的代码将生成类似以下的输出:

  1. 让我们看看这个用户是谁以及他们的仓库。通过github.com/cchi,我可以看到这个仓库属于以下用户:

这个人实际上是 Charles Chi,我以前在彭博社的同事,所以这不是什么意外。我们来看看他标星了哪些内容:

  1. 有几种方法可以做到这一点;我们可以使用代码,或者直接点击他们头像下方的星标。我们这次做两者,比较一下并确保结果一致。首先,让我们通过代码来操作:
fdf.iloc[6,:][fdf.iloc[6,:]==1] 

这将产生以下输出:

  1. 我们看到他加星了 30 个仓库。让我们将这些仓库与 GitHub 网站上的仓库进行比较:

  1. 在这里我们看到它们是相同的,你会注意到可以标识出我们都加星的仓库:它们是那些标记为 Unstar 的。

  2. 不幸的是,仅凭 30 个加星的仓库,推荐的仓库数量并不多。

  3. 与我相似的下一个用户是 42,Artem Golubin:

fdf.index[42] 

上述代码将产生以下输出:

以及他下面的 GitHub 资料:

这是他加星的仓库:

  1. Artem 加星了超过 500 个仓库,所以那里肯定有一些推荐。

  2. 最后,让我们来看一下第三位最相似的用户:

fdf.index[116] 

这将产生以下输出:

这位用户,Kevin Markham,加星了大约 60 个仓库:

我们可以在下面的图像中看到加星的仓库:

这无疑是生成推荐的肥沃土壤。现在,让我们就做这件事;我们将使用这三个链接来生成一些推荐:

  1. 我们需要收集他加星的仓库链接,而我没有加星的仓库。我们将创建一个DataFrame,包含我加星的仓库以及与我最相似的三位用户:
all_recs = fdf.iloc[[6,42,116,159],:] 
all_recs.T 

上述代码将产生以下输出:

  1. 如果它看起来全是零,不用担心;这是一个稀疏矩阵,所以大部分值为 0。让我们看看是否有我们都加星的仓库:
all_recs[(all_recs==1).all(axis=1)] 

上述代码将产生以下输出:

  1. 正如你所见,我们似乎都热衷于 scikit-learn 和机器学习的仓库——这不令人惊讶。让我们看看他们可能都加星了哪些我错过的仓库。我们将先创建一个不包含我的框架,然后查询其中共同加星的仓库:
str_recs_tmp = all_recs[all_recs[myun]==0].copy() 
str_recs = str_recs_tmp.iloc[:,:-1].copy() 
str_recs[(str_recs==1).all(axis=1)] 

上述代码产生以下输出:

  1. 好的,看起来我没有错过任何明显的内容。让我们看看是否有至少三位用户中有两位加星的仓库。为了找到这些仓库,我们将对行进行求和:
str_recs.sum(axis=1).to_frame('total').sort_values(by='total', ascending=False) 

上述代码将产生类似以下的输出:

这看起来很有前景。有很多很好的机器学习和人工智能的存储库,老实说,我很羞愧以前没收藏 fuzzywuzzy,因为我经常用到它。

到目前为止,我必须说我对结果感到印象深刻。这些确实是我感兴趣的存储库,我会去查看它们。

到目前为止,我们已经通过协同过滤生成了推荐,并且通过聚合做了一些轻量的附加过滤。如果我们想更进一步,可以根据推荐所收到的总星标数来排序。这可以通过再次调用 GitHub API 来实现。GitHub 有一个端点提供这一信息。

我们可以做的另一件事是增加一层基于内容的过滤。这就是我们之前讨论的混合化步骤。我们需要从我们自己的存储库中创建一组特征,来表示我们感兴趣的内容类型。一种方法是通过对我们已收藏的存储库名称及其描述进行分词,来创建一组特征。

这是我收藏的存储库:

正如你想象的那样,这将生成一组我们可以用来筛选通过协同过滤找到的存储库的词特征。这将包括许多词,如 Python机器学习数据科学。这可以确保即使是与我们不太相似的用户,也能提供基于我们自己兴趣的推荐。同时,这也会减少推荐的偶然性,这一点需要考虑。也许有些东西与我目前拥有的完全不同,我会喜欢看到。这个可能性是存在的。

那么,基于内容的过滤步骤在 DataFrame 中会是什么样子的呢?列将是词特征(n-grams),行则是通过协同过滤步骤生成的存储库。我们只需要再次运行相似度处理,使用我们自己的存储库进行比较。

总结

在本章中,我们了解了推荐引擎。我们学习了当前使用的两种主要系统:协同过滤和基于内容的过滤。我们还学习了如何将这两者结合使用,形成一个混合系统。我们还讨论了每种系统的优缺点。最后,我们一步步了解了如何使用 GitHub API 从零开始构建推荐引擎。

我希望你能按照本章的指导,构建你自己的推荐引擎,并找到对你有用的资源。我知道我已经发现了许多我一定会使用的东西。祝你在旅程中好运!

第十一章:下一步是什么?

到目前为止,我们已经使用机器学习ML)实现了各种任务。机器学习领域有许多进展,随着时间的推移,其应用领域也在不断增加。

在本章中,我们将总结在前几章中执行的项目。

项目总结

让我们从第一章,Python 机器学习生态系统开始。

在第一章,我们开始了 Python 机器学习的概述。我们从机器学习的工作流程入手,包括数据获取、检查、准备、建模评估和部署。然后我们学习了每个工作流程步骤所需的各种 Python 库和函数。最后,我们设置了机器学习环境来执行这些项目。

第二章,构建一个寻找低价公寓的应用程序,顾名思义,基于构建一个应用程序来寻找低价公寓。最初,我们列出了数据来寻找所需位置的公寓来源。然后,我们检查了数据,并在准备和可视化数据后,进行了回归建模。线性回归是一种监督式机器学习(ML)。在这个上下文中,监督式意味着我们为训练集提供输出值。

接着,我们花剩余的时间按照我们的选择探索其他选项。我们创建了一个应用程序,使寻找合适的公寓变得更加轻松。

在第三章,构建一个寻找便宜机票的应用程序,我们构建了一个类似于第二章,构建一个寻找低价公寓的应用程序,但目的是寻找便宜的机票。我们首先在网上获取了机票价格。我们使用了当下流行的技术之一——网页抓取,来获取机票价格数据。为了解析我们的 Google 页面的 DOM,我们使用了Beautifulsoup库。接着,我们使用异常检测技术来识别离群的机票价格。通过这样做,我们可以找到更便宜的机票,并且通过 IFTTT 收到实时文本提醒。

在第四章,使用逻辑回归预测 IPO 市场,我们探讨了 IPO 市场的运作方式。首先,我们讨论了什么是首次公开募股IPO),以及研究告诉我们关于这个市场的情况。接着,我们讨论了多种策略,这些策略可以用来预测 IPO 市场。这包括数据清洗和特征工程。然后,我们使用逻辑回归对数据进行了二分类分析。最后,我们评估了获得的最终模型。

我们还了解到,影响我们模型的特征包括来自随机森林分类器的特征重要性。这能更准确地反映某个特征的实际影响。

第五章,创建自定义新闻推送,主要面向那些对全球新闻充满兴趣的新闻爱好者。通过创建一个自定义新闻推送,你可以决定哪些新闻更新会出现在你的设备上。在本章中,你学习了如何构建一个能够理解你新闻偏好的系统,并每天向你发送量身定制的新闻简报。我们从使用 Pocket 应用创建一个监督训练集开始,然后利用 Pocket API 来获取故事内容。我们使用 Embedly API 来提取故事正文。

接着,我们学习了自然语言处理NLP)和支持向量机SVMs)的基础知识。我们将If This Then ThatIFTTT)与 RSS 源和 Google Sheets 结合使用,以便我们能够通过通知、电子邮件等方式保持最新。最后,我们设置了一个每日个人新闻简报。我们使用 Webhooks 通道发送POST请求。

该脚本每四小时运行一次,从 Google Sheets 中提取新闻故事,通过模型处理这些故事,生成电子邮件,发送POST请求到 IFTTT,通知我们预测可能感兴趣的故事,最后,它会清空电子表格中的旧故事,以确保下次邮件只发送新的故事。这就是我们如何获取个性化新闻推送的方式。

在第六章,判断你的内容是否会病毒式传播,我们考察了一些最受分享的内容,并试图找出这些内容与人们不太愿意分享的内容之间的共同元素。本章开始时提供了关于病毒式传播的定义。我们还研究了关于病毒式传播的研究结果。

然后,正如我们在其他章节中所做的那样,我们将会获取共享的计数和内容。我们使用了一个来自现已关闭的名为ruzzit.com的网站收集的数据集。该网站在运营时,追踪了最受分享的内容,这正是我们这个项目所需要的。接着,我们探索了可分享性的特征,其中包括探索图片数据、聚类、探索标题以及分析故事的内容。

最后,也是最重要的一部分,是构建预测内容评分模型。我们使用了一种名为随机森林回归的算法。我们构建的模型没有任何错误。然后,我们评估了这个模型,并添加了一些特性来增强它。

在第七章,使用机器学习预测股市,我们学会了如何建立和测试一个交易策略。我们还学到了如何去做这件事。在尝试设计自己的系统时,有无数的陷阱需要避免,这几乎是一个不可能完成的任务,但它也可以非常有趣,有时甚至能带来利润。话虽如此,不要做愚蠢的事情,比如冒着自己负担不起的风险去投资。

当你准备好冒险投资时,不妨学习一些技巧和窍门,以避免损失太多。谁喜欢在生活中失败——无论是钱财还是游戏?

我们主要集中精力在股票和股市上。最初,我们分析了市场类型,然后研究了股市。在冒险之前,有一些先验知识总是更好的。我们通过关注技术方面开始制定策略。我们回顾了过去几年中的标准普尔 500 指数,并使用 pandas 导入我们的数据。这使我们能够访问多个股票数据来源,包括 Yahoo!和 Google。

然后我们建立了回归模型。我们从一个非常基础的模型开始,只使用股票的前一天收盘值来预测第二天的收盘价,并使用支持向量回归来构建它。最后,我们评估了模型的表现以及所执行的交易。

在 Siri 随 iPhone 4S 发布之前,我们就有了广泛应用于多种场景的聊天机器人。在第九章,构建聊天机器人中,我们学习了图灵测试及其起源。然后我们看了一个叫做 ELIZA 的程序。如果 ELIZA 是聊天机器人的早期示例,那么我们从那时以来又见到了什么?近年来,新的聊天机器人层出不穷,其中最著名的就是 Cleverbot。

然后,我们看到了有趣的部分:设计这些聊天机器人。

那么更先进的机器人呢?它们是如何构建的?

令人惊讶的是,大多数你可能遇到的聊天机器人并没有使用机器学习;它们是所谓的基于检索的模型。这意味着响应是根据问题和上下文预定义的。这些机器人最常见的架构是被称为人工智能标记语言AIML)的东西。AIML 是一种基于 XML 的架构,用于表示机器人在用户输入的情况下应该如何互动。它实际上只是 ELIZA 工作方式的一个更高级版本。

最后,我们进行了聊天机器人中的序列到序列建模。这种方法在机器翻译和问答应用中经常使用,因为它允许我们将任意长度的输入序列映射到任意长度的输出序列。

在第八章,使用卷积神经网络进行图像分类中,我们学习了使用 Keras 构建卷积神经网络CNN)来分类 Zalando 研究数据集中的图像。

我们从提取图像的特征开始。然后,使用卷积神经网络(CNN),我们理解了网络拓扑结构、各种卷积层和滤波器,以及最大池化层的原理。

尝试构建更深层次的模型,或者对我们在模型中使用的许多超参数进行网格搜索。像评估其他模型一样评估你的分类器的表现——尝试构建混淆矩阵,了解我们预测得好的类别以及我们不太擅长的类别!

在第十章,构建推荐引擎,我们探索了不同种类的推荐系统。我们了解了它们是如何在商业中实现的,以及它们是如何工作的。然后,我们为寻找 GitHub 仓库实现了自己的推荐引擎。

我们从协同过滤开始。协同过滤基于这样一个观点:在这个世界的某个地方,你有一个品味的“替身”——某个人对星球大战的评价和对真爱至上的看法与你完全相同。

接着,我们还学习了基于内容的过滤和混合系统是什么。

最后,我们使用 GitHub API 创建了一个基于协同过滤的推荐引擎。计划是获取我在一段时间内所有的星标仓库,并通过这些仓库的创建者,找出他们星标的仓库。这样,我们就能找出哪些用户的星标仓库与我最相似。

总结

本章只是一个小小的回顾,带你回顾我们实施过的所有项目。

希望你喜欢阅读这本书,并且这些实践能帮助你以类似的方式创建自己的项目!

posted @ 2025-07-14 17:27  绝不原创的飞龙  阅读(8)  评论(0)    收藏  举报