Python-机器学习蓝图-全-

Python 机器学习蓝图(全)

原文:Python Machine Learning Blueprints

协议:CC BY-NC-SA 4.0

零、前言

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

本书首先向您概述了使用 Python 进行机器学习。借助复杂的数据集和优化的技术,您将学习如何将先进的概念和流行的机器学习算法应用到现实世界的项目中。下一步,您将涵盖诸如分析股票市场的预测分析和 GitHub 存储库的推荐系统等领域的项目。除此之外,您还将处理来自 NLP 领域的项目,使用 scikit-learn、TensorFlow 和 Keras 等框架创建自定义新闻提要。接下来,您将学习如何构建一个高级聊天机器人,并使用 PySpark 扩大规模。在最后几章中,你可以期待对深度学习的令人兴奋的见解,甚至可以使用计算机视觉和神经网络创建一个应用。

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

这本书是给谁的

这本书是为机器学习从业者、数据科学家和深度学习爱好者编写的,他们希望通过构建真实世界的项目,将机器学习技能提升到一个新的水平。本中级指南将帮助您实现 Python 生态系统中的库,以构建各种项目来解决各种机器学习领域。

这本书涵盖了什么

第 1 章Python 机器学习生态系统讨论了关键库的特性,并解释了如何准备您的环境以最佳利用它们。

第 2 章构建一个 App 来寻找价格过低的公寓,解释如何创建一个机器学习应用,这将使寻找合适的公寓变得稍微容易一点。

第 3 章构建一个寻找廉价机票的应用,介绍了如何构建一个持续监控票价的应用,检查异常价格,从而产生一个我们可以快速采取行动的警报。

第四章用逻辑回归预测 IPO 市场,仔细看看 IPO 市场。我们将看看如何使用机器学习来帮助我们决定哪些首次公开募股值得仔细看看,哪些我们可能想通过。

第五章创建自定义新闻订阅源,讲解如何构建一个理解你对新闻的品味的系统,每天会给你发一份个人定制的简讯。

第六章预测你的内容是否会疯传,试图解开一些谜团。我们将研究一些最常见的共享内容,并试图找到将它与人们不太愿意共享的内容区分开来的共同要素。

第七章利用机器学习预测股市,讨论如何构建和测试交易策略。然而,我们将花更多的时间在如何做到这一点上而不是

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

第九章建造聊天机器人,讲解如何从头开始建造聊天机器人。在此过程中,我们将了解该领域的更多历史及其未来前景。

第 10 章构建推荐引擎,探索不同品种的推荐系统。我们将看看它们是如何在商业上实现的,以及它们是如何工作的。最后,我们将实现自己的 to 推荐引擎来查找 GitHub 存储库。

第十一章下一步是什么?,总结了本书到目前为止所涵盖的内容,以及从现在开始接下来的步骤。您将学习如何将获得的技能应用到其他项目中,构建和部署机器学习模型的现实挑战,以及数据科学家经常使用的其他常见技术。

充分利用这本书

Python 编程和机器学习概念的知识将会有所帮助。

下载示例代码文件

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

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

  1. 登录或注册www.packt.com
  2. 选择“支持”选项卡。
  3. 点击代码下载和勘误表。
  4. 在搜索框中输入图书的名称,并按照屏幕指示进行操作。

下载文件后,请确保使用最新版本的解压缩文件夹:

  • 视窗系统的 WinRAR/7-Zip
  • zipeg/izp/un ARX for MAC
  • 适用于 Linux 的 7-Zip/PeaZip

这本书的代码包也在 GitHub 上托管在https://GitHub . com/PacktPublishing/Python-机器学习-蓝图-第二版。如果代码有更新,它将在现有的 GitHub 存储库中更新。

我们还有来自丰富的图书和视频目录的其他代码包,可在【https://github.com/PacktPublishing/】获得。看看他们!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的截图/图表的彩色图像。可以在这里下载:http://www . packtpub . com/sites/default/files/downloads/9781788994170 _ color images . pdf

使用的约定

本书通篇使用了许多文本约定。

CodeInText:表示文本中的码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和推特句柄。这里有一个例子:“我们来看一个交互示例,使用requests从 GitHub 的 API 下拉数据。在这里,我们将调用 API,并为用户请求一个星标存储库列表。”

任何命令行输入或输出都编写如下:

import requests 

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

r.json() 

粗体:表示一个新的术语、一个重要的单词或者你在屏幕上看到的单词。例如,菜单或对话框中的单词像这样出现在文本中。这里有一个例子:“对于 Chrome,去谷歌应用商店寻找扩展部分。”

Warnings or important notes appear like this. Tips and tricks appear like this.

取得联系

我们随时欢迎读者的反馈。

一般反馈:如果你对这本书的任何方面有疑问,在你的信息主题中提到书名,发邮件给我们customercare@packtpub.com

勘误表:虽然我们已经尽了最大的努力来保证内容的准确性,但是错误还是会发生。如果你在这本书里发现了一个错误,如果你能向我们报告,我们将不胜感激。请访问www.packt.com/submit-errata,选择您的图书,点击勘误表提交链接,并输入详细信息。

盗版:如果您在互联网上遇到任何形式的我们作品的非法拷贝,如果您能提供我们的位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com联系我们,并提供材料链接。

如果你有兴趣成为一名作者:如果有一个你有专长的话题,你有兴趣写或者投稿一本书,请访问authors.packtpub.com

复习

请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?然后,潜在的读者可以看到并使用您不带偏见的意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,我们的作者可以看到您对他们的书的反馈。谢谢大家!

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

一、Python 机器学习生态系统

机器学习正在迅速改变我们的世界。作为人工智能的核心,很难一天不阅读它将如何带领我们进入一个类似奇点的技术乌托邦,或者某种全球银翼杀手式的噩梦场景。虽然专家们可能喜欢讨论这些夸张的未来,但更现实的是,机器学习正迅速成为我们日常生活的一部分。通过我们与计算机和周围世界互动方式的微妙但渐进的改进,机器学习正在逐步让我们的生活变得更好。

如果你在 Amazon.com 等在线零售商那里购物,使用 Spotify 或网飞等流媒体音乐或电影服务,甚至刚刚完成谷歌搜索,你就会遇到一个利用机器学习的应用。这些服务收集了大量数据,其中大部分来自用户,这些数据用于构建模型,以改善用户体验。

这是深入开发机器学习应用的理想时机,正如您将发现的那样,Python 是开发它们的理想选择。Python 有一个深厚而活跃的开发人员社区,其中许多人都扎根于科学界。这一传统为 Python 提供了无与伦比的科学计算库。在本书中,我们将讨论和使用这个 Python 科学堆栈中包含的一些库。

在接下来的章节中,我们将逐步学习如何构建各种各样的机器学习应用。在我们认真开始之前,我们将在本章的剩余部分讨论这些关键库的特性,以及如何准备您的环境来最好地利用它们。

这些是本章将涉及的主题:

  • 数据科学/机器学习工作流程
  • 工作流每个阶段的库
  • 设置您的环境

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

构建机器学习应用虽然在许多方面与标准工程范例相似,但在一个关键方面有所不同:需要使用数据作为原材料。项目的成功在很大程度上取决于你获取的数据的质量,以及你对这些数据的处理。因为处理数据属于数据科学的范畴,所以理解数据科学工作流程很有帮助:

Data science workflow

该过程包括以下顺序的六个步骤:

  1. 获得物ˌ获得
  2. 检查
  3. 准备
  4. 建模
  5. 估价
  6. 部署

通常,需要循环回到之前的步骤,例如在检查和准备数据时,或者在评估和建模时,但是高级别的过程可以如前面的列表所述。

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

获得物ˌ获得

机器学习应用的数据可以来自任何来源;它可能以 CSV 文件的形式通过电子邮件发送给您,也可能来自于下拉服务器日志,或者可能需要构建一个自定义的 web 刮板。数据也可能以任何格式存在。在大多数情况下,您将处理基于文本的数据,但是,正如我们将看到的,机器学习应用也可以很容易地利用图像甚至视频文件来构建。不管格式如何,一旦保护了数据,了解数据中的内容和非内容就变得至关重要。

检查

一旦你获得了你的数据,下一步就是检查它。这个阶段的主要目标是对数据进行健全性检查,实现这一目标的最佳方式是寻找不可能或极不可能的事情。举个例子,如果数据有一个唯一的标识符,检查一下是否确实只有一个;如果数据是基于价格的,检查它是否总是正的;不管数据类型是什么,检查最极端的情况。它们有意义吗?一个好的做法是对数据进行一些简单的统计测试,并将其可视化。你的模型的结果只和你输入的数据一样好,所以做好这一步是至关重要的。

准备

当你确信你的数据是有序的时,接下来你需要准备它,把它放在一个易于建模的格式中。这个阶段包括许多过程,如过滤、聚合、输入和转换。您需要采取的操作类型将高度依赖于您使用的数据类型,以及您将使用的库和算法。例如,如果您使用的是基于自然语言的文本,那么所需的转换将与时间序列数据所需的转换非常不同。我们将在整本书中看到许多这类转换的例子。

建模

一旦数据准备完成,下一个阶段就是建模。在这里,您将选择一个合适的算法,并使用数据来训练您的模型。在这个阶段有许多最佳实践需要遵循,我们将详细讨论它们,但是基本的步骤包括将您的数据分成培训、测试和验证集。数据的这种分割似乎不合逻辑——尤其是当更多的数据通常会产生更好的模型时——但正如我们将看到的,这样做可以让我们获得关于模型在现实世界中表现如何的更好的反馈,并防止我们犯建模的主要错误:过度拟合。我们将在后面的章节中更多地讨论这一点。

估价

那么,现在你有了一个闪亮的新模型,但是这个模型到底有多好呢?这是评估阶段试图回答的问题。有许多方法可以衡量模型的性能,同样,这在很大程度上取决于您使用的数据类型和使用的模型类型,但总的来说,我们正在寻找模型预测与实际值有多接近的问题的答案。有一系列听起来令人困惑的术语,如均方根误差、欧几里德距离或 F1 分数。但最终,它们都只是实际预测和估计预测之间距离的度量。

部署

一旦您对模型的性能感到满意,您就会想要部署它。这可以采取多种形式,取决于用例,但是常见的场景包括在另一个更大的应用、定制的 web 应用或者甚至只是一个简单的 cron 作业中作为一个特性来使用。

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

现在,您已经了解了数据科学工作流中的每一步,我们将查看这些库中每一步有用的 Python 库和函数的选择。

获得物ˌ获得

由于访问数据的一种更常见的方式是通过 RESTful API,所以您想要了解的一个库是 Python 请求库,即http://www.python-requests.org/en/latest/。它被称为人类的超文本传输协议,使与应用接口的交互成为一种干净简单的体验。

我们来看一个交互的例子,使用requests从 GitHub 的 API 下拉数据。在这里,我们将调用 API,并为用户请求一个星形存储库列表:

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

这将返回一个用户已经标记的所有存储库的 JSON,以及每个存储库的属性。下面是前面调用的输出片段:

Output snippet when we return a JSON of all the repositories

requests库有惊人数量的特性——这里覆盖的太多了,但是我建议你查看文档。

检查

因为检查您的数据是机器学习应用开发中非常关键的一步,所以我们现在将深入研究几个在这项任务中能很好地为您服务的库。

朱拜特笔记本

有许多库将使数据检查过程更容易。第一款是带 IPython 的 Jupyter 笔记本(http://ipython.org/)。这是一个成熟的交互式计算环境,非常适合数据探索。与大多数开发环境不同的是,Jupyter Notebook 是一个基于网络的前端(对于 IPython 内核来说),它被分成单独的代码块或单元。根据需要,细胞可以单独运行,也可以一次性运行。这允许开发人员运行一个场景,查看输出,然后返回代码,进行调整,并查看结果更改——所有这些都无需离开笔记本。以下是 Jupyter 笔记本中的一个示例交互:

Sample interaction in the Jupyter Notebook

您会注意到,我们在这里做了很多事情,不仅与 IPython 后端交互,还与终端外壳交互。在这里,我已经导入了 Python os库,并进行调用来查找当前的工作目录(单元格#2),您可以看到这是我的输入代码单元格下面的输出。然后,我在单元#3 中使用os库更改了目录,但是停止使用os库,开始在单元#4 中使用基于 Linux 的命令。这是通过在单元格前添加!来完成的。在单元格#6 中,您可以看到我甚至能够将 shell 输出保存到 Python 变量(file_two)中。这是一个很好的特性,使得文件操作变得很简单。

Note that the results would obviously differ slightly on your machine, since this displays information on the user under which it runs.

现在,让我们看一下使用笔记本进行的一些简单数据操作。这也将是我们对另一个不可或缺的图书馆熊猫的首次介绍。

熊猫

Pandas 是一款出色的数据分析工具,旨在成为任何语言中最强大、最灵活的开源数据分析/处理工具。而且,正如你很快会看到的,如果它还没有达到这个要求,它不可能太远。我们现在来看看:

Importing the iris dataset

从前面的截图中可以看到,我已经使用 scikit-learn 导入了一个经典的机器学习数据集,iris数据集(也可以在https://archive.ics.uci.edu/ml/datasets/Iris上获得),我们稍后将详细研究这个库。然后,我将数据传递到pandas数据框中,确保分配了列标题。一个数据框包含花卉测量数据,另一个数据框包含代表iris物种的数字。分别为setosaversicolorvirginica编码为012。然后,我将两个数据帧连接起来。

对于处理适合单台机器的数据集,pandas 是终极工具;你可以把它想得有点像类固醇上的 Excel。而且,像流行的电子表格程序一样,基本的操作单位是形成表格的数据的列和行。在熊猫的术语中,数据列是系列,表格是数据框。

使用我们之前加载的相同iris数据帧,现在让我们来看看一些常见的操作,包括以下操作:

第一个动作就是使用.head()命令获取前五行。第二个命令是通过按列名引用数据框来从数据框中选择一列。我们执行此数据切片的另一种方式是使用.iloc[row,column].loc[row,column]符号。前者对列和行使用数字索引(位置索引),而后者对行使用数字索引,但允许使用命名列(基于标签的索引)。

让我们使用.iloc符号选择前两列和前四行。然后我们来看看.loc符号:

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

现在,让我们试试更高级的东西。我们将使用列表迭代器来选择宽度特征列:

我们在这里所做的是创建一个列表,它是所有列的子集。df.columns返回所有列的列表,我们的迭代使用条件语句只选择标题中带有width的列。显然,在这种情况下,我们可以很容易地将我们想要的列键入到列表中,但是这让您在处理更大的数据集时感觉到了可用的能力。

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

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

  1. 在最右边一栏,你会注意到我们的数据框现在只包含Iris-virginica物种(由2代表)的数据。事实上,数据框的大小现在是 50 行,低于最初的 150 行:

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

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

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

现在让我们继续使用熊猫从我们的iris数据集获得一些快速描述性统计数据:

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

接下来,让我们检查这些特性之间是否有任何关联。这可以通过在我们的数据框中调用.corr()来实现:

默认值为每一行-列对返回皮尔逊相关系数。通过传入一个方法参数(例如.corr(method="spearman").corr(method="kendall"),可以切换到肯德尔的τ斯皮尔曼的秩相关系数

形象化

到目前为止,我们已经了解了如何选择数据帧的部分,以及如何从数据中获取汇总统计数据,但是现在让我们继续学习如何直观地检查数据。但首先,为什么还要费心进行目视检查?让我们看一个例子来理解为什么。

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

| 系列 xyT5】 | |
| x 的平均值 | nine |
| y 的平均值 | Seven point five |
| x 的样本方差 | Eleven |
| y 的样本方差 | Four point one |
| xy 之间的相关性 | Zero point eight one six |
| 回归线 | y = 3.00 + 0.500 x |

基于具有相同汇总统计数据的系列,您可能会认为这些系列在外观上相似。你当然会错。非常错误。这四个系列是安斯科姆四重奏的一部分,它们是为了说明视觉数据检查的重要性而特意打造的。每个系列绘制如下:

很明显,在可视化这些数据集后,我们不会将其视为完全相同。现在我们已经理解了可视化的重要性,让我们来看看两个有用的 Python 库。

matplotlib 图书馆

我们要看的第一个图书馆是matplotlibmatplotlib库是 Python 绘图库宇宙的中心。它最初是为了模拟 MATLAB 的绘图功能而创建的,后来发展成为一个功能齐全的库,拥有广泛的功能。如果你不是来自 MATLAB 背景,你可能很难理解所有的部分是如何一起工作来创建你看到的图形的。我会尽最大努力将这些片段分解成逻辑组件,这样您就可以快速跟上进度。但是在完全进入matplotlib之前,让我们设置好我们的 Jupyter 笔记本,让我们可以在线查看我们的图表。为此,在您的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数据框中绘制花瓣宽度的直方图。我们还在这里将条形颜色设置为black。接下来的两行分别在我们的 yx 轴上放置标签,最后一行为我们的图形设置标题。我们使用 y 参数调整标题的 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值将只是我们感兴趣的特征长度的数组——这里是四个,或者数据框中的每列一个。np.arange()函数是一种简单的生成方法,但是我们几乎可以轻松地手动输入这个数组。因为我们不希望 x 轴显示为 1 到 4,所以我们调用.set_xticklabels()函数并传入我们想要显示的列名。为了正确排列x标签,我们还需要调整标签的间距。这就是为什么我们将xticks设置为x加上bar_width一半大小的原因,我们之前也在0.8设置过。y值来自对每个物种的每个特征取平均值。然后我们通过调用.bar()来绘制每一个。需要注意的是,我们为每个系列传入一个bottom参数,该参数设置其下系列的最小 y 点和最大 y 点。这将创建堆叠条形图。最后,我们添加一个描述每个系列的图例。这些名称按照从上到下的条放置顺序插入图例列表。

海底图书馆

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

现在我们来看看seaborn的威力:

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

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

Seaborn plot

刚刚详细描述了matplotlib错综复杂的细微差别,你会立刻欣赏到我们生成这个情节的简单性。我们所有的特征都是相互对照绘制的,并且用两行代码正确地标记出来。你可能会想,当seaborn让这些类型的可视化变得如此简单时,我是不是浪费了几十页来教你matplotlib。事实并非如此,因为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() 

上述代码生成以下输出:

Violin Plots

在这里,我们已经为四个特征中的每一个生成了一个小提琴情节。小提琴图显示了特征的分布。例如,你可以很容易地看到setosa (0)的花瓣长度在 1 厘米到 2 厘米之间高度聚集,而virginica (2)则分散得多,从近 4 厘米到 7 厘米以上。您还会注意到,我们在构建matplotlib图时使用了大量相同的代码。主要区别是增加了sns.plot()呼叫,取代了之前的ax.plot()呼叫。我们还在所有的支线剧情上面增加了一个标题,而不是单独在每一个上面,使用fig.suptitle()功能。另一个值得注意的增加是迭代每个子剧情来改变xticklabels的旋转。我们调用ax.flat(),然后使用.setp()迭代每个子图轴来设置特定属性。这防止了我们必须单独键入ax[0][0]...ax[1][1]并设置属性,就像我们之前在早期的matplotlib子图代码中所做的那样。

使用matplotlibseaborn可以生成数百种样式的图形,我强烈建议深入挖掘这两个库的文档——这将是很值得花费的时间——但是我在前面部分中详细介绍的图形应该会大大有助于您理解您拥有的数据集,这反过来将有助于您构建机器学习模型。

准备

我们已经学习了很多关于检查我们拥有的数据的知识,但是现在让我们继续学习如何处理和操作我们的数据。在这里,我们将了解熊猫的.map().apply().applymap().groupby()功能。这些对于处理数据是非常宝贵的,在特征工程的机器学习环境中尤其有用,我们将在后面的章节中详细讨论这个概念。

地图

我们现在从map功能开始。map函数在系列上工作,所以在我们的例子中,我们将使用它来转换我们的数据帧的一列,您会记得这只是一个熊猫系列。假设我们决定物种数量不适合我们的需要。我们将使用带有 Python 字典的map函数作为参数来实现这一点。我们将为每种独特的iris类型提供一个替代品:

让我们看看我们在这里做了什么。我们已经对现有species列的每个值运行了map函数。由于每个值都是在 Python 字典中找到的,所以它被添加到返回序列中。我们给这个回归系列指定了相同的species名称,所以它取代了我们最初的species列。如果我们选择一个不同的名称,比如说short code,那么该列将被添加到数据框中,然后我们将拥有原始的species列加上新的short code列。

相反,我们可以将map函数传递给一个系列或函数来对一个列执行这种转换,但是这是一个也可以通过apply函数获得的功能,我们接下来将看一看。字典功能是map功能独有的,选择map而不是apply进行单列转换是最常见的原因。但是,我们现在来看看apply功能。

应用

apply功能允许我们同时使用数据帧和序列。我们将从一个同样适用于map的例子开始,然后继续讨论仅适用于apply的例子。

使用我们的iris数据框,让我们根据花瓣宽度创建一个新列。我们之前看到花瓣宽度的平均值是1.3。现在让我们在数据框wide petal中创建新列,它包含基于petal width列中的值的二进制值。如果petal width等于或大于中值,我们用1编码,如果小于中值,我们用0编码。我们将使用petal width栏上的apply功能来完成此操作:

这里发生了几件事,让我们一步一步来。首先,我们能够简单地通过使用列名的列选择语法向数据框添加一个新列,我们想要创建列名,在本例中为wide petal。我们将新列设置为等于apply函数的输出。这里,我们在petal width列上运行apply,该列返回了wide petal列中的相应值。apply功能通过运行petal width列的每个值来工作。如果大于或等于1.3,则返回1,否则返回0。这种类型的转换是机器学习中相当常见的特征工程转换,所以熟悉如何执行它是很好的。

现在让我们来看一下在数据帧而不是单个系列上使用apply。我们现在将基于petal area创建一个特性:

Creating a new feature

请注意,我们调用apply不是在这里的一个系列上,而是在整个数据帧上,因为apply是在整个数据帧上调用的,所以我们传入了axis=1,以便告诉熊猫我们希望逐行应用该函数。如果我们进入axis=0,那么函数将按列运行。这里,每一列都是按顺序处理的,我们选择将来自petal length (cm)petal width (cm)列的值相乘。所得的序列成为我们数据框中的petal area列。这种力量和灵活性使熊猫成为数据处理不可或缺的工具。

applymap

我们已经了解了如何操作列,并解释了如何使用行,但是假设您希望在数据框中的所有数据单元上执行一个函数。这就是applymap是正确工具的地方。让我们看一个例子:

Using applymap function

这里,我们在数据帧上调用applymap来获取每个值的日志(np.log()利用 NumPy 库返回该值),如果该值是浮点类型的话。这种类型检查可防止为分别为字符串和整数值的specieswide petal列返回错误或浮点值。applymap的常见用法包括基于满足多个条件标准来转换或格式化每个单元格。

群组依据

现在让我们来看看一个非常有用的操作,但对于新熊猫用户来说通常很难理解:功能.groupby()。为了说明最重要的功能,我们将逐步介绍一些示例。

groupby操作完全按照它所说的进行:它根据您选择的一个或多个类对数据进行分组。让我们用我们的iris数据集来看一个简单的例子。我们将返回并重新导入原始的iris数据集,并运行我们的第一个groupby操作:

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

Statistics for each species

现在,我们可以看到由species打破的全面崩溃。现在让我们看看我们可以执行的其他一些groupby操作。我们之前看到花瓣的长度和宽度在物种之间有一些相对清晰的界限。现在,让我们来看看如何使用groupby来看到:

在这种情况下,我们已经根据它们所关联的petal width对每个独特的物种进行了分组。这是一个可管理的度量分组,但是如果它变得更大,我们可能需要将度量划分到括号中。正如我们之前看到的,这可以通过apply功能来实现。

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

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

We've only just touched on the functionality of the groupby function; there is a lot more to learn, so I encourage you to read the documentation available at http://pandas.pydata.org/pandas-docs/stable/.

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

建模和评估

在本节中,我们将浏览不同的库,如statsmodelsScikit-learn,并了解什么是部署。

Statsmodels

我们要报道的第一个图书馆是statsmodels图书馆(http://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 =β01X的格式,其中B0T11】为截距,B1T15】为回归系数。这里的公式是*萼片长= 2.6447 + 0.6909 萼片宽。我们还可以看到模型的 R 2 是一个值得尊敬的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 库,拥有无与伦比的文档,旨在为几十种算法提供一致的应用编程接口。它建立在 Python 科学堆栈的核心组件之上,并且本身就是 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 棵决策树的森林,每棵树允许最大分裂深度为 5。这是为了避免过度拟合,我们将在后面的章节中深入讨论。

接下来的两行创建我们的 X 矩阵和 y 向量。如果你还记得我们最初的iris数据框,它包含四个特征:花瓣的宽度和长度,萼片的宽度和长度。这些特征被选中,成为我们独立的特征矩阵, X 。最后一列,即iris类名,成为我们的依赖 y 向量。

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

接下来,使用训练数据拟合我们的模型。训练好模型后,我们使用测试数据调用分类器上的预测方法。记住,测试数据是分类器没有看到的数据。这个预测的返回是一个预测标签列表。然后,我们创建实际标签与预测标签的数据框。我们最终将正确的预测相加,除以实例总数,我们可以看到这给了我们一个非常准确的预测。现在让我们看看哪些特性给了我们最强的辨别能力或预测能力:

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 是您的首选。

部署

当您决定将机器学习模型投入生产时,有许多选项可供选择。这在很大程度上取决于应用的性质。部署可以包括从在本地机器上运行的 cron 作业到在 Amazon EC2 实例上部署的全面实现。

我们不会在这里详细讨论具体的实现,但是我们将有机会在整本书中深入研究不同的部署示例。

设置您的机器学习环境

我们已经介绍了许多库,如果您要单独安装每个库,安装起来可能会有点麻烦——当然可以,因为大多数库都可以用 Python 的包管理器 pip 来安装,但是我强烈建议您使用预打包的解决方案,例如 Anaconda Python 发行版(http://anaconda.org)。这允许您下载并安装一个可执行文件,其中包含为您处理的所有包和依赖项。由于该发行版面向 Python 科学堆栈用户,因此本质上是一个一劳永逸的解决方案。

Anaconda 还包括一个包管理器,使更新包成为一项简单的任务。只需输入conda update <package_name>,就会更新到最近的稳定版本。

摘要

在本章中,我们学习了数据科学/机器学习工作流。我们学习了如何在管道的每个阶段一步一步地获取数据,从获取一直到部署。我们还学习了 Python 科学堆栈中每个最重要的库的关键特性。我们现在将学习这些知识和课程,并开始应用它们来创建独特而有用的机器学习应用。我们开始吧!

二、创建一个应用来寻找低价公寓

第 1 章Python 机器学习生态系统中,我们学习了处理数据的要领。我们现在将应用这些知识来构建我们的第一个机器学习应用。我们将从一个极小但非常实用的例子开始:构建一个应用来识别价格过低的公寓。

如果你曾经寻找过公寓,你会意识到这个过程有多令人沮丧。不仅费时,即使找到了自己喜欢的公寓,又怎么知道是不是合适的呢?

最有可能的是,你有目标预算和目标地点。但是,如果你和我一样,你也愿意做一些取舍。例如,我住在纽约市,靠近地铁这样的便利设施是一个很大的优势。但那值多少钱?我应该用在有电梯的大楼里来换取离火车更近吗?走几分钟的火车值得走上一段楼梯?租房时,有几十个这样的问题需要考虑。那么,我们如何使用机器学习来帮助我们做出这些类型的决定呢?

我们将在本章的剩余部分探讨这一点。我们将无法得到所有问题的答案(原因将在后面变得清楚),但在本章结束时,我们将创建一个应用,使找到合适的公寓变得稍微容易一点。

这就是我们将在本章中介绍的内容:

  • 寻找公寓列表数据
  • 检查和准备数据
  • 可视化数据
  • 回归建模
  • 预测

寻找公寓列表数据

在 20 世纪 70 年代初,如果你想购买一只股票,你需要雇佣一个经纪人,他会向你收取近 1%的固定佣金。如果你想买机票,你需要联系旅行社,旅行社会给你 7%左右的佣金。如果你想卖房子,你可以联系房地产经纪人,他们会给你 6%的佣金。2018 年,基本上可以免费做前两个。最后一个仍然是上世纪 70 年代的样子。

为什么会这样,更重要的是,这些和机器学习有什么关系?现实是,这一切都归结于数据,以及谁有权访问这些数据。

您可能会认为,通过应用编程接口或通过网络抓取房地产网站,您可以非常容易地访问大量房地产上市数据。你就错了。如果你打算遵守这些网站的条款和条件,那就错了。房地产数据由全国房地产经纪人协会 ( NAR )严格控制,该协会运行多次上市服务 ( MLS )。这是一项汇总上市数据的服务,只对经纪人和代理人开放,费用很高。所以,正如你所能想象的,他们并不太热衷于让任何人集体下载

*这是不幸的,因为开放这些数据无疑会带来有用的消费应用。这对于代表家庭预算最大一部分的购买决策来说似乎尤为重要。

话虽如此,并不是所有的希望都落空了,因为并不是每个网站都明确禁止刮擦。

下拉列表数据

我们将使用 RentHop 网站http://www.renthop.com来获取我们的列表数据。该网站的以下截图显示了我们将要检索的列表的布局:

我们可以看到清单上有地址、价格、卧室数量和浴室数量。我们将从检索每个列表的信息开始。

我们将使用 Python 请求库来完成这项任务。对于人类来说,请求被称为HTTP,这使得检索网站变得非常容易。如果您想了解如何使用请求,可在http://docs.python-requests.org/en/master/user/quickstart/获得快速入门指南。请遵循以下步骤:

  1. 因此,第一步是为我们的 Jupyter 笔记本准备好我们将用于此任务的导入。我们在下面的代码片段中这样做:
import numpy as np 
import pandas as pd 
import requests 
import matplotlib.pyplot as plt 
%matplotlib inline 

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

  1. 我们将在模型中使用纽约公寓的数据。该数据的网址是https://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 并将其粘贴到文本编辑器中,然后在浏览器中打开它。我将使用https://www.sublimetext.com/上流行的文本编辑器崇高文本来实现。

  2. 在下面的截图中,可以看到我已经将 Jupyter 输出中复制的 HTML 粘贴到了崇高文本中,并保存为test.html:

HTML text

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

请注意,尽管文本没有清晰地呈现(由于缺少 CSS),但我们要定位的所有数据都在那里。对我们来说幸运的是,这意味着 RentHop 站点不使用任何高级的 JavaScript 渲染,所以这应该会让我们的工作变得容易得多。如果是的话,我们将不得不使用一种不同的工具,比如 Selenium。

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

  1. 在 Chrome 中打开 RentHop 网站,然后右键单击页面上的任意位置。
  2. 在上下文菜单的底部,您应该会看到“检查”。点击那个。该页面现在应该类似于下图:

  1. 在刚刚打开的工具中,左上角有一个正方形,角上有一个箭头。点击那个,然后点击页面上的数据。它应该如下所示:

由此我们可以看出,每个清单的数据都在一张表中,第一个td标签包含价格,第二个包含卧室数量,第三个包含卫生间数量。我们还希望公寓的地址可以在一个锚点或标签中找到。

现在让我们开始构建我们的代码来测试我们对数据的解析。为了进行 HTML 解析,我们将使用一个库调用美化组。相关文件可在https://www.crummy.com/software/BeautifulSoup/找到。是一个流行的、易于使用的 Python HTML 解析库。如果你还没有画中画,可以安装。我们将使用它来提取我们公寓列表的所有单个规格:

  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-infodivs。这些正是拥有我们数据的divs

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

  1. 请注意,我们有一个 Python 列表,其中包含了我们正在寻找的所有div标签。从页面上我们知道应该有二十个这样的。让我们确认一下:
len(listing_divs) 
  1. 然后,我们会看到以下输出,它确认我们已经按照我们的要求捕获了它们:

拉出各个数据点

现在我们有了所有的divs和我们每个公寓的列表数据,我们需要为每个公寓提取单独的数据点。

这些都是我们希望达到的目标:

  • 列表的网址
  • 公寓地址
  • 附近
  • 卧室数量
  • 浴室数量

很明显,我们喜欢有更多的信息——比如平方英尺之类的东西,但是我们将不得不将就我们所拥有的。

让我们从第一个列表开始:

listing_divs[0] 

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

请注意,第一个div包含了我们正在寻找的所有数据点。我们现在只需要开始我们的解析,分别针对它们。让我们看看我们要检索的第一个网址。

我们可以看到页面的网址带有一个锚点或标签。我们现在来分析一下。我们可以用另一个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循环,遍历每个 div,取出数据并添加到indv_listing中。完成后,个人清单的所有数据将被添加到listing_list,其中包含 20 套公寓清单的所有最终信息。我们使用以下代码进行验证:

listing_list 

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

同样,我们似乎得到了我们期望的结果,所以我们将继续下去。检查listing_list中的项目数量也确认我们在页面上有所有 20 套公寓。

到目前为止,我们已经成功检索到一页数据。虽然这很好,但如果我们想建立任何有意义的模式,我们将需要更多的公寓。为此,我们需要遍历多个页面。为此,我们需要使用适当的网址。我们可以看到,在列表的底部,有一个按钮,上面写着“下一步”。如果您右键单击该按钮,然后单击复制链接地址,您会看到它看起来像以下网址:https://www.renthop.com/search/nyc?max_price=50000&min _ price = 0&page = 2&sort = hop score&q =&search = 0

解析数据

对网址的基本分析告诉我们,我们传递的参数包括最低价格和最高价格,但最重要的是页码。我们可以在代码中使用这一点,只需动态地改变页码,使用循环来获取更多的页面。

让我们用一些示例代码来尝试一下:

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列表中有 2000 个列表。

现在让我们将数据移动到pandas数据框中,这样我们可以更容易地使用它。我们在下面的代码中这样做:

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

df 

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

现在,我们已经将所有数据拉下、解析并合并到一个数据框架中,让我们继续清理和验证我们的数据。

检查和准备数据

让我们从检查每个列的数据点开始。我们希望在我们的数据中寻找奇数和异常值。我们将从卧室和浴室的柱子开始:

  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. 在前面的代码中,我们运行了一个熊猫map函数和一个lambda函数,该函数主要检查元素是否以下划线开头,如果是,则删除它。快速检查一下床和浴缸的独特价值,就会发现我们错误的起始下划线已经被删除:
df['beds'].unique() 

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

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

df['baths'].unique() 

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

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

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

虽然我们希望获得诸如平均床位和浴室数量以及最高租金等指标,但我们得到的却远低于此。问题是数据不是这些操作的正确数据类型。熊猫不能对字符串对象执行这些类型的操作。我们需要进一步清理数据,并将其设置为正确的数据类型。我们将在下面的代码中做到这一点:

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) 

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

我们看到林肯广场地区似乎平均租金最高。此时,我们可以继续查询数据中有趣的模式,但是让我们继续可视化数据。

可视化我们的数据

当我们在这里处理地理数据时,能够绘制这些信息是非常有价值的。一种方法是用一种叫做的东西来绘制地图。合唱团本质上是一张地理热图。我们将建立一个地图,通过邮政编码创建平均租赁价格的热图。

我们需要做的第一件事是邮政编码。不幸的是,我们的数据集不包含邮政编码信息。但是,我们有房产的地址。在谷歌地图应用编程接口的一点帮助下,我们可以检索这些信息。

目前,谷歌地图应用编程接口是一个付费应用编程接口。费率是合理的,1000 个电话需要 5 美元,但他们每月还会给你 200 美元的积分(在撰写本文时)。他们还允许您在他们开始向您收费之前注册免费试用,除非您明确允许他们这样做,否则他们不会收费。既然真的没有免费的选择,我们就去注册一个账户。我将引导您完成以下步骤:

  1. 第一步是进入https://developers . Google . com/Maps/documentation/geocode/intro:的谷歌地图 API 页面

  1. 点击右上角的开始。接下来会提示您创建一个项目。给它取任何你喜欢的名字:

Creating a project

  1. 然后您将启用计费:

  1. 接下来,您将启用您的应用编程接口密钥:

  1. 一旦这个完成,你有了你的应用编程接口键,回到首页启用地理定位应用编程接口。单击左侧窗格中的应用编程接口:

  1. 然后,在“未使用的应用编程接口”下,单击地理定位应用编程接口:

一旦所有这些都完成了,并且你有了你的应用编程接口密钥,pip 安装谷歌地图。这可以通过pip install -U googlemaps从命令行完成。

让我们继续在我们的 Jupyter 笔记本中使用这个应用编程接口。我们将导入新的映射 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客户端,以及使用我们公寓中的一块作为可用地址。现在让我们把这个地址传递给谷歌地图应用编程接口:

geocode_result = gmaps.geocode(ta) 

geocode_result 

上述代码生成以下输出:

请记住,我们只是在这里提取邮政编码。邮政编码嵌入在 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) 

前面的代码片段中有相当多的代码,所以让我们来谈谈这里发生了什么。

首先,在底部,您可以看到我们正在数据帧上运行一个apply方法。因为我们设置了axis=1,所以df数据帧的每一行都将被传递到我们的函数中。在这个函数中,我们拼凑了一个地址来调用谷歌地图地理定位应用编程接口。我们使用 regex 将我们的呼叫限制在那些以街道号码开头的呼叫。然后,我们迭代 JSON 响应来解析出邮政编码。如果我们找到一个邮政编码,我们返回它,否则我们返回一个np.nan,或者空值。请注意,这个函数将需要一些时间来运行,因为我们必须进行数百次调用,然后解析出响应。

一旦完成,我们将有一个数据帧,它现在有那些属性的邮政编码,这些属性有一个正确的地址。让我们来看看实际上有多少:

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

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

因此,我们丢失了相当多的数据,但是,尽管如此,我们现在所拥有的在许多方面都更有用,所以我们将继续下去。

首先,由于检索所有的邮政编码数据需要很长时间,现在让我们存储我们所拥有的数据,以便我们可以在以后需要时随时检索它,而不必再次进行所有这些应用编程接口调用。我们使用以下代码来实现:

df.to_csv('apts_with_zip.csv') 

让我们也将带有邮政编码信息的数据存储在一个新的数据框中。我们称之为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 位于林肯中心地区。

现在让我们继续可视化这些信息。

可视化数据

因为这些数据是基于邮政编码的,所以最好的可视化方法是使用 choropleth。如果你不熟悉合唱团,它只是一个可视化,根据颜色光谱来表示数据。现在让我们使用名为的 Python 映射库创建一个。如果你没有安装 leaf,同样,可以在命令行上用 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()对象。我们需要传入坐标和缩放级别来使地图居中。谷歌搜索帝国大厦的坐标会给我们正确的纬度和经度(翻转经度上的标志以正确渲染)。最后,调整缩放比例,使其对我们的数据适当居中。
  2. 下一行需要一个名为 GeoJSON 的文件。这是一种用于表示地理属性的开放格式。这可以通过搜索 NYC GeoJSON 文件找到——特别是那些带有邮政编码映射的文件。完成后,我们通过输入路径来引用 GeoJSON 文件。
  3. 接下来,我们在data参数中引用我们的数据帧。这里,我们使用的是之前创建的邮政编码平均租金。columns参数引用了这些。key_on参数引用了我们的 JSON 文件的目标部分,在这个例子中是postalCode
  4. 最后,其他选项确定调色板和某些其他参数来调整图例和颜色。

运行单元格时,地图应该在您的 Jupyter 笔记本中内联呈现,如下图所示:

随着热图的完成,你可以开始了解哪些区域的租金更高或更低。这在针对特定领域时可能会有所帮助,但是让我们通过使用回归建模来进行更深入的分析。

数据建模

让我们从使用数据集开始建模。我们将研究邮政编码和卧室数量对租金的影响。我们这里用两个包:第一个,statsmodels,我们在 第一章中介绍了 Python 机器学习生态系统,但是第二个,patsyhttps://patsy.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() 

上述代码生成以下输出:

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

有了这几行代码,我们刚刚运行了第一个机器学习算法。

While most people don't tend to think of linear regression as machine learning, that's exactly what it is. Linear regression is a type of supervised machine learning. Supervised, in this context, simply means we provide the output values for our training set.

现在让我们解开那里发生的事情。导入后,我们有两行与patsy模块相关。第一行是我们将使用的公式。左边(波浪号前)是我们的反应,或因变量rent。在右边,我们有我们的独立或预测变量zipbeds。这个公式只是意味着我们想知道邮政编码和卧室数量将如何影响租金。

然后,我们的公式与包含相应列名的数据框一起被传递到patsy.dmatrices()中。然后,Patsy 将返回一个带有预测变量矩阵的数据帧和一个带有响应变量的 T4 y 向量。然后这些被传递到sm.OLS(),我们也称之为.fit()来运行我们的模型。最后,我们打印出模型的结果。

如您所见,结果输出中提供了大量信息。让我们从最上面的部分开始。我们看到该模型包含555观测值,其调整后的 R 2.367,并且其与F-statistic概率3.50e-31具有显著性。这有什么意义?这意味着我们已经创建了一个模型,仅使用卧室和邮政编码就能解释大约三分之一的价格差异。这是个好结果吗?为了更好地回答这个问题,我们现在来看看输出的中间部分。

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

所有这些告诉我们什么?如果我们查看p-值列,我们可以确定我们的个体变量是否具有统计学意义。回归模型中的统计显著性意味着自变量和响应变量之间的关系不太可能偶然发生。通常,统计学家在确定这一点时会使用.05p 值。a.05p-值表示我们看到的结果只有 5%的几率会发生。就我们在这里的产出而言,卧室的数量显然很重要。邮政编码呢?

这里首先要注意的是,我们的截取代表了 07302 邮政编码。当对线性回归建模时,需要截距。截距只是回归线与 y 轴相交的地方。Statsmodels 将自动选择一个预测变量用作截距。这里,它决定在泽西城,07302,因为它组织的邮政编码按升序排列。我们可以通过检查以下数据来证实这一点:

X 

上述代码生成以下输出:

请注意,它们是按升序排列的,如果我们查看数据框中已排序的邮政编码值,除了缺少的邮政编码 07302 之外,我们看到的都是相同的,邮政编码 07302 现在是我们的基线,所有其他邮政编码都将与它进行比较。

再次查看我们的结果输出,我们注意到一些邮政编码非常重要,而另一些则不是。让我们看看我们的老朋友,林肯中心附近,或 10069。如果你记得的话,那是我们样本中租金最高的区域。我们预计,与泽西市的基线相比,这将是显著的,并具有较大的正系数,事实上,确实如此。p-值为 0.000,系数为 4116。这意味着,与泽西城的同等公寓相比,林肯中心附近的租金可能会高得多——这并不奇怪。

现在让我们用我们的模型来做一些预测。

预测

假设我们已经从之前的分析中确定,我们对三个特定的邮政编码感兴趣:100021000310009。我们如何使用我们的模型来确定我们应该为给定的公寓支付什么?现在让我们来看看。

首先,我们需要知道模型的输入是什么样子的,这样我们就知道如何输入一组新的值。让我们来看看我们的X矩阵:

X.head() 

上述代码生成以下输出:

我们看到的是,我们的输入是用所谓的虚拟变量编码的。为了表示邮政编码特征,因为它不是数字,所以使用虚拟编码。如果公寓在 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 

The intercept value for a linear regression must always be set to 1 for the model in order to return accurate statistical values.

上述代码生成以下输出:

我们已经将我们的特征设置为适当的值,所以现在让我们使用我们的模型来返回一个预测。我们需要将它转换成数据帧并进行转置,以获得正确的格式。我们按如下方式进行:

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 等提供商提供的步行性和交通邻近性度量。

有很多方法可以扩展这个模型,我建议如果你真的从事这样的项目,你可以探索各种各样的方法。每天都有更多的数据发布,有了这些数据,模型只能改进。

摘要

在这一章中,我们学习了如何获取房地产上市的数据,如何利用熊猫的功能来处理和净化这些数据,如何用 choropleths 直观地检查数据,最后,如何构建和使用回归模型来定价公寓。

此时,我们刚刚触及机器学习的表面。在接下来的章节中,我们将进一步探讨如何评估我们模型的质量,我们还将学习如何将它们转化为全面的解决方案。*

三、创建一个应用来寻找便宜的机票

我们来谈谈错误。它们是生活的一部分;每个人都制造它们——甚至航空公司。

2014 年,一天下午,我碰巧在看我的推特,我关注的一个账户发推说,美国一家主要航空公司飞往欧洲的机票价格明显低于预期。当时,从纽约到维也纳最便宜的票价约为 800 美元,但一些特定日期的广告票价在 350 美元至 450 美元之间。这似乎好得令人难以置信。但事实并非如此。我幸运地遇到了业内众所周知的错误。

在这个由旅行黑客和里程迷组成的超级神秘的社会里,众所周知,航空公司偶尔会公布不包括燃油附加费的票价。值得注意的是,这并不是他们犯的唯一错误。你可能会认为先进的算法会在考虑大量因素的情况下更新每个航班的票价。在大多数情况下,你是对的。但由于遗留系统以及处理多个承运人和多个辖区的复杂性,有时确实会出错。

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

  • 2007 年,联合航空公司以 1500 美元的价格出售从旧金山到新西兰的商务机票
  • 2013 年,达美航空以 6.90 美元的价格出售了从多个美国城市到夏威夷的经济舱机票
  • 2015 年,美国航空公司以 450 美元的价格出售了从 DC 华府飞往中国的商务舱机票

既然你知道有这些票价,你怎么能上车呢?当然是机器学习!由于它们通常只持续几个小时就消失了,我们将构建一个应用,持续监控票价,检查异常价格,从而产生警报,我们可以迅速采取行动。

这就是我们将在本章中介绍的内容:

  • 在网上寻找机票价格
  • 使用高级网页抓取技术检索票价数据
  • 解析 DOM 以提取价格
  • 用异常检测技术识别异常票价
  • 通过 IFTTT 发送实时文本警报

获取机票定价数据

幸运的是,获取机票数据比获取房地产数据要容易一些。这种数据有许多提供者,也有有偿和无偿的原料药。检索数据的一个具有挑战性的方面是它需要大量的 web 请求。在本书的上一版中,我们概述了如何从谷歌的飞行探索者页面抓取数据。这是在一个页面上查看多个城市数周价格数据的理想演示。不幸的是,该页面现在已经被删除,谷歌现在提供了一个更典型的搜索界面,要求用户输入出发城市、目的地城市、开始日期和结束日期。剩下的一个幸运特征是能够输入整个地区,而不是特定的城市。我们将在刮擦中利用这一点。这方面的一个例子可以在下面的截图中看到:

如您所见,我们已经输入New York作为我们的出发城市,并简单地将Asia作为我们的目的地。这返回了亚洲所有顶级城市(以及中东,出于某种原因)的价格。这是一个好消息,因为我们希望在一个 web 请求中获取尽可能多的价格。

虽然该界面仍然有一些功能,可以更容易地抓取这些数据,但我们需要使用一些比过去更先进的技术。我们接下来会讨论这个。

使用高级网页抓取功能检索票价数据

在前面的章节中,我们已经看到了如何使用Requests库来检索网页。正如我之前所说的,这是一个非常棒的工具,但不幸的是,它在这里对我们不起作用。我们想要抓取的页面完全基于 AJAX。异步 JavaScript ( AJAX )是一种无需重新加载页面就能从服务器检索数据的方法。这对我们来说意味着我们需要使用浏览器来检索数据。虽然这听起来可能需要大量的开销,但是有两个库,当一起使用时,使它成为一个轻量级任务。

这两个库是 Selenium 和 ChromeDriver。Selenium 是一个用于自动化网络浏览器的强大工具,ChromeDriver 是一个浏览器。为什么使用 ChromeDriver 而不是 Firefox 或 Chrome 本身?ChromeDriver 就是所谓的无头浏览器。这意味着它没有用户界面。这使它保持倾斜,非常适合我们正在努力做的事情。

To install ChromeDriver, you can download the binaries or source from https://sites.google.com/a/chromium.org/chromedriver/downloads. As for Selenium, it can be pip installed.

我们还需要另一个名为BeautifulSoup的库来解析页面中的数据。如果你没有安装,你现在也应该安装。

做完这些,让我们开始吧。我们将从 Jupyter 笔记本开始。这最适合探索性分析。稍后,当我们完成探索后,我们将继续在文本编辑器中为我们想要部署的代码工作。这是通过以下步骤完成的:

  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的路径。请注意,您必须用自己机器上的路径替换该行。

创建链接

现在,值得注意的是,我们已经有了开始刮机票所需的一切,只有一个例外:我们需要网址。在这个练习中,我将重点关注从纽约出发飞往欧洲的航班。由于我们不想拉低大量数据并冒着被封锁的风险,我们将只拉周六出发并在下周六返回的直达航班的数据。当然,您可以自由地将其更改为您想要的任何票价,但是我们将在示例项目中使用它。

下一步是在谷歌航班中填写表格。确保选择一个未来的日期。输入数据并点击搜索后,从浏览器栏复制网址字符串,如下图所示:

我复制的网址是2018-12-01起飞2018-12-08返回的航班。这些日期可以在搜索字符串中看到。如果您选择不同的日期,您应该会看到这些日期反映在您复制的字符串中。现在让我们对其进行编码:

  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 是构成网页的元素的集合。包括bodydiv等 HTML 标签,以及嵌入这些标签中的类和 id。

让我们看看谷歌页面的 DOM:

  1. 要查看它,右键单击页面,然后单击检查。这对于火狐或者 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,我们要开始提取感兴趣的元素。这就是info-container类的div元素的来源。我们要找回那些。每一个都对应一个城市。

让我们检索它们,但我们只看第一个:

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

前面代码的输出如下所示:

在前面的代码中,我们在soup对象上使用了select方法。select 方法允许我们使用 CSS 选择器来引用感兴趣的元素。在这里,我们指定了我们想要的 div 具有一个class属性,该属性在类名中的某个地方包含字符串info-container。关于美化组有很好的文档来解释这些 CSS 选择器和其他方法,并且可以在https://www . crummy . com/software/美化组/bs4/doc/#css 选择器 上找到。

查看前面的输出,注意隐藏在标记深处的是目的地城市的名称(London)和票价($440)。因为我们只需要数据,而不是所有周围的标记,所以我们需要创建代码来迭代每个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对象转换回一个字符串,这样我们就可以将其传递到我们将使用浏览器对象调用的网址中。另外,请注意,在每次迭代中,我们都会用开始日期填充票价字典。然后,我们使用创建的日期字符串创建我们的网址。

接下来,我们使用numpy.random函数和 Python sleep函数插入一个随机暂停。这只是为了防止我们看起来像一个机器人,过度使用网站。

然后我们用浏览器对象检索页面,将其传递到BeautifulSoup进行解析,选择info-containerdiv,然后解析并更新我们的票价字典。最后,我们在开始和结束日期上增加一周,这样下一次迭代就可以提前一周。

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

fare_dict 

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

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

现在,让我们深入一个城市来检查数据。我们从Berlin开始:

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 美元以下到 1200 美元以上不等。左边那些便宜的票价正是我们想知道的票价类型。我们想要创建一个异常值检测系统来告诉我们这些廉价票价。我们现在继续讨论这个问题。

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

异常值有各种严格的定义,但就我们的目的而言,异常值是与数据集中的其他观测值相差甚远的任何极值。有许多技术,包括参数和非参数技术,用于识别异常值;示例算法包括带有噪声的应用的基于密度的空间聚类、隔离森林和格拉布测试。通常,数据类型决定了使用的算法类型。例如,一些算法在多变量数据上比单变量数据做得更好。在这里,我们处理的是单变量时间序列数据,所以我们希望选择一个处理得好的算法。

If you aren't familiar with the term time series, it simply means data that is recorded at regular intervals, such as the daily closing price of a stock.

我们将用于数据的算法称为异常值的广义极端学生化偏差 ( 广义静电放电)测试。这种算法非常适合我们的数据,因为它是单变量和近似正态的。

我们可以使用几个测试来确保我们的数据近似正态分布,但是我们也可以使用正态概率图直观地检查我们的数据的正态性。现在,我们将使用 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') 

让我们讨论一下前面的代码是做什么的。第一行就是我们的进口。接下来,我们实现了我们的广义静电放电算法。参数是我们的票价,然后是离群值的最大数量(在这里,我们选择了3),显著性水平(α,在0.025),最后是一个布尔值来指定我们想要完整的输出。关于显著性水平,该值越低,算法越不敏感,并且将产生越少的误报。

接下来的两行只是打印出与RLambda值相关的数据。这些用于确定数据点是否为异常值。

最后,代码的剩余部分只是用于生成散点图,并将异常值的票价涂成红色。

上述代码生成以下输出:

同样,这些数据是针对莫斯科的。确保您更改了您的city_key变量来反映这一点,以确保您获得该数据。请注意,尽管有各种变化,数据中没有异常值。

现在,让我们也为米兰运行它。我们将返回并更改我们的city_key变量,并运行下面的单元格来更新所有内容,如下图所示:

请注意,这一次,我们有三个异常值,当平均票价看起来超过 900 美元时,这些票价低于 600 美元,因此这看起来像是我们的胜利。

让我们试试另一个城市。这一次,我们将通过更新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') 

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

所以,现在,我们已经创建了一个系统来抓取数据,解析它,并识别异常值。让我们继续前进,创建一个能够实时提醒我们的成熟应用。

Keep in mind that we just did a very preliminary analysis on our outlier detection model. In the real world, it would likely take a much more thorough series of tests to identify whether we had selected workable parameters for our model.

使用 IFTTT 发送实时警报

为了有机会获得这些便宜的票价,我们需要几乎实时地了解它们何时发生。为此,我们将使用名为如果这个那么那个(iftt)的服务。这个免费的服务允许你用一系列的触发器和动作连接大量的服务。想在 Instagram 上将任意多的图片保存到 iPhone 照片中吗?想在某个人每次发推的时候都收到一封邮件吗?想把你的脸书更新发到推特上吗?IFTTT 可以做到这一切。执行以下步骤:

  1. 第一步是在http://www.ifttt.com注册账户。
  2. 完成后,您需要注册 Maker 频道https://ifttt.com/maker_webhooks,以及短信频道https://ifttt.com/sms
  3. Maker 允许您通过发送和接收 HTTP 请求来创建 IFTTT 食谱。
  4. 创建帐户并激活 Maker 和 SMS 频道后,从主页中单击我的小程序,然后单击新建小程序:

  1. 然后,点击这个,如下图截图所示:

  1. 然后,搜索webhooks并点击接收网络请求:

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

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

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

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

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

完成后,单击“完成”结束设置:

要测试设置,进入http://www.ifttt.com/maker_webhooks并点击设置。您应该看到您的帐户信息,其中包括一个带有密钥的网址。将该网址复制并粘贴到浏览器中。它应该有一个表格,上面有你的密匙和一个地方来填写与你的城市和价格相对应的值。

填写活动的廉价票价,并将城市和票价分别设为值 1 和值 2:

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

现在我们已经把所有的部分都准备好了,是时候把它们整合成一个可以全天候监控票价的脚本了。

把它们放在一起

到目前为止,我们一直在 Jupyter 笔记本中工作,但是现在,为了部署我们的应用,我们将继续在文本编辑器中工作。笔记本非常适合探索性分析和可视化,但是运行后台工作最好在一个简单的.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 使用网络请求从我们的代码中发送文本警报。虽然我们在这里讨论的是机票价格,但我们所做的几乎所有事情都可以重复用于您希望得到提醒的任何定价类型。

如果你真的决定用它来买机票,我希望它能给你带来很多快乐的旅行!

四、使用逻辑回归预测首次公开募股

20 世纪 90 年代末,进入正确的首次公开募股 ( 首次公开募股)就像中了彩票。一些科技公司的第一天回报是它们最初发行价的许多倍,如果你有幸获得分配,你将获得一笔意外之财。以下是这一时期的一些最佳第一天表现者:

  • VA Linux 上涨 697%,12/09/99
  • Globe.com 上涨 606%,11/13/98
  • 铸造网络上涨 525%,1999 年 9 月 28 日

尽管网络狂热的日子已经一去不复返,但首次公开募股仍能获得巨大的首日回报。以下是过去一年中在首日交易中大幅上涨的几只股票:

  • 布鲁姆能源上涨 67%
  • 拼多多上涨 32%
  • 维持了 32%

可以看到,这还是一个值得关注的市场。在这一章中,我们将仔细看看首次公开募股市场。我们将看看如何使用机器学习来帮助我们决定哪些首次公开募股值得仔细看看,哪些我们可能想通过。

这就是我们将在本章中介绍的内容:

  • 首次公开发行市场
  • 数据清理和功能工程
  • 逻辑回归二元分类
  • 模型评估
  • 特征重要性

首次公开发行市场

在我们开始建模之前,让我们先讨论一下什么是首次公开募股,或者说首次公开募股,以及研究告诉了我们这个市场什么。之后,我们将讨论一些我们可以应用的策略。

什么是 IPO?

首次公开募股是私人公司成为上市公司的过程。公开募股为公司筹集资本,并给公众一个通过购买公司股票投资公司的机会。

尽管这种情况有所不同,但在典型的发行中,一家公司会寻求一家或多家投资银行的帮助来承销他们的发行。这意味着银行向该公司保证,它们将在首次公开募股当天以首次公开募股价格购买所有发行的股票。当然,承销商并不打算自己保留所有股份。在发行公司的帮助下,他们进行所谓的“T0”路演以吸引机构客户的兴趣。这些客户在 T2 认购了这些股票,这表明他们有兴趣在首次公开募股当天购买股票。这是一份不具约束力的合同,因为发行价格要到首次公开募股当天才能最终确定。然后,承销商将根据所表达的兴趣水平确定发行价。

从我们的角度来看,有趣的是,研究一直表明,首次公开募股存在系统性的定价过低。关于为什么会发生这种情况,以及为什么这种定价过低的水平似乎会随着时间的推移而变化,有许多理论,但研究表明,每年都有数十亿美元留在桌面上。

在首次公开募股中,留在桌子上的钱,是股票发行价格和第一天收盘价之间的差额。

在我们继续之前,还有一点需要提及,那就是发行价和开盘价之间的差异。虽然你偶尔可以通过你的经纪人参与交易,并以发行价获得首次公开募股,但在几乎所有情况下,作为普通公众的一员,你必须以(通常更高的)开盘价购买首次公开募股。我们将在这个假设下建立我们的模型。

近期 IPO 市场表现

现在我们来看看 IPO 市场的表现。我们将从IPOScoop.com下拉数据,这是一项为即将到来的 IPO 提供评级的服务。转到https://www . IPO scoop . com/scoop-track-record-from-2000-至今/ 点击页面底部的按钮下载电子表格。我们将把这个加载到熊猫中,并使用我们的 Jupyter 笔记本运行一些可视化。

不幸的是,这些数据的格式使得用正常的.read_csv()方法读取熊猫是不可能的。我们需要做的是使用一个库,让我们将 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) 

上述代码生成以下输出:

在删除了标题和其他我们不感兴趣的行之后,这看起来是正确的。

处理数据框

现在,让我们继续让我们的数据框准备好工作:

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 年以来的首次公开募股数量开始我们的探索性分析:

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); 

上述代码生成以下输出:

从图表中,我们可以看到大多数年份都有超过 100 次的首次公开募股,但在 2001 年和 2008 年之后的年份中,这一数字明显减少,这很可能是由于 911 事件和金融危机的影响。

总结股票的表现

通过执行以下代码,我们将获得过去 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 的确切开盘价买入一股,并以这些数字中列出的确切收盘价卖出;以赚取的美元计算,我们的回报会是什么样子?

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

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

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

上述代码生成以下输出:

由此,我们看到首日开盘至收盘的美元总额刚刚超过 28 美元。2018 年迄今为止,这一数字已超过 173 次:

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

上述代码生成以下输出:

这反映了首次公开募股的平均首日收益略高于 16 美分。记住,这是在我们忽略交易成本和滑动的理想条件下。

Slippage is the difference between your attempted entry or exit price for a target stock and the price at which your order is actually fulfilled.

现在让我们看看这些首次公开募股的回报分布是什么样的。这可能有助于我们理解如何在购买每一次首次公开募股的基础朴素贝叶斯策略上提高我们的回报:

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

上述代码生成以下输出:

我们看到回报以零为中心,但右边有一条长尾,那里有一些特殊的回报。如果我们能够发现这些特殊的首次公开募股的一些共性,并加以利用,那将是相当有经济价值的。

让我们看看我们是否可以使用机器学习来帮助提高我们的结果,从一个天真的贝叶斯方法。一个合理的策略似乎是瞄准右边的长尾,所以我们将在下一节关注特性工程。

数据清理和功能工程

发行开始交易时,什么可能会影响其业绩?或许市场的总体表现或承销商的声望会对其产生影响?也许它交易的星期几或月份很重要?在模型中考虑和包含这些因素被称为特征工程,建模这一点几乎与您用来构建模型的数据一样重要。如果你的特性没有信息,你的模型就没有价值。

让我们从增加一些我们认为可能影响首次公开募股表现的特征开始这个过程。

增加影响首次公开募股表现的特征

一个可以提供信息的需求指标是开口缺口。这是发行价和开盘价的差额。让我们将它添加到我们的数据框中:

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。结局会很糟糕。

现在,为了预测二元结果(即10/是或否),我们将从一个名为逻辑回归的模型开始。逻辑回归实际上是一个二元分类模型,而不是回归。但它确实利用了典型的线性回归形式;它只是在逻辑函数中这样做。

典型的单变量回归模型采用以下形式:

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

下面等式中的逻辑函数有一些非常好的数学性质,包括它可以将任何数字作为输入(这里是 t )并返回一个介于 0 和 1 之间的结果:

该图如下所示:

通过将 t 替换为我们的回归函数,我们现在有了一个模型,该模型既能为我们提供关于每个预测器的重要性的信息(β系数),又能提供一种形式,该形式可用于为我们提供二进制预测,该预测表示成功的概率,或肯定的结果:

在我们可以继续对我们的数据建模之前,我们需要把它放到一个适合 scikit-learn 的形式中。

我们将从导入一个可以帮助我们完成这项任务的库开始;叫做patsy。如有必要,可通过 pip 安装:

from patsy import dmatrix 

为我们的模型创建目标

现在,我们将为我们的模型创建目标。这个专栏将告诉我们的模型,是否应该投资每一次首次公开募股。我们将说,我们应该投资于任何在第一天就有 2.5%或更高回报的首次公开募股。显然,这是一个任意的数字,但它似乎是一个值得我们关注的合理投资价值:

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 上市前提供的星级。

为了对代码行中的 Qs 和 Cs 给出一些解释,Qs 只是用于在公式中为名称中有空格的列提供引号,而 Cs 用于指示引用的列应被视为分类特征并进行伪编码。

虚拟编码

虚拟编码是这样一种方法,如果我们有一列,其中有一个学生最喜欢的类作为预测变量,我们将把每个类变成它自己的列,然后在该列中放置一个1,如果它是学生最喜欢的类,如下图所示:

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

完成后,下一步就是删除其中的一列。落下的柱子成为基壳。然后将所有其他案例与该案例进行比较。在我们使用月份作为预测因子的首次公开募股例子中,例如,我们将下跌 1 月,然后将根据 1 月的表现来判断所有其他月份。这同样适用于一周中的某几天或任何其他分类预测。删除一列是为了防止多重共线性,这会对模型的解释力产生负面影响。

让我们通过在 Jupyter 单元格中运行以下内容来看看这种编码是什么样子的:

X 

上述代码生成以下输出:

现在我们既有了X又有了*y*,我们已经准备好适应我们的模式了。我们将使用非常基本的训练/测试分割,只需在除最后 200 次首次公开募股以外的所有首次公开募股中训练我们的模型:

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 

上述代码生成以下输出:

现在让我们看看在我们的test数据集中,我们应该投资的 200 家 IPO 占多大比例——记住,这意味着它们从开盘到收盘上涨了 2.5%以上:

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

上述代码生成以下输出:

因此,一半以上的首次公开募股从开盘到收盘上涨了 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 个首次公开募股中的每一个份额的结果会是什么样子:

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

上述代码生成以下输出:

由此,我们可以看出,在理想的无成本情况下,我们将获得超过 215 美元的收益。现在,让我们来看看关于这些首次公开募股的其他统计数据:

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,或者基本情况。一周中的其他日子都可以和星期一相比。从前面的截图中,我们看到周四似乎是一周中最好的一天。周六似乎是一周中进行首次公开募股的糟糕一天,很可能是因为当天市场关闭。(很有可能,这些日期只是记录不正确。)

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

另一个问题是特性不能反映它们产生影响的频率。一家银行在 2000 年才开始营业,并有 3 次非常成功的首次公开募股,它的出现会有非常大的正系数,但在我们的建模工作中毫无意义。

随机森林分类器方法

告诉我们哪些特征对我们的模型有影响的另一种建模方法是来自随机森林分类器的特征重要性。这更准确地反映了给定功能的真实影响。

让我们通过这种类型的模型运行我们的数据,并检查结果:

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 应用创建受监督的训练集
  • 利用口袋应用编程接口检索故事
  • 使用嵌入式应用编程接口提取故事主体
  • 自然语言处理基础
  • 支持向量机
  • 与 RSS 源和谷歌表单的集成
  • 建立每日个人简讯

使用 Pocket 创建监督训练集

在我们能够在新闻文章中创建我们品味的模型之前,我们需要训练数据。这些训练数据将被输入到我们的模型中,以便教会它区分我们感兴趣的文章和我们不感兴趣的文章。为了建立这个语料库,我们需要注释大量的文章来对应这些兴趣。我们会给每篇文章贴上yn的标签,表明它是否是我们希望在日常摘要中发送给我们的文章类型。

为了简化这个过程,我们将使用 Pocket 应用。Pocket 是一个允许您保存故事以供以后阅读的应用。您只需安装浏览器扩展,然后当您想要保存一个故事时,单击浏览器工具栏中的口袋图标。文章将保存到您的个人存储库中。对于我们来说,Pocket 的一个很好的特性是能够保存带有您选择的标签的文章。我们用这个来标记有趣的文章为y,不有趣的文章为n

安装袖珍镀铬加长件

我正在为此使用谷歌 Chrome,但其他浏览器应该也能类似地工作。按照步骤安装袖珍镀铬扩展:

  1. 对于 Chrome,请访问谷歌应用商店并查找扩展部分:

Pocket Chrome Extention

  1. 点击添加到铬。如果你已经有一个帐户,登录,如果没有,继续注册(这是免费的)。
  2. 完成后,您应该会在浏览器的右上角看到口袋图标。
  3. 它将灰显,但是一旦有你想要保存的文章,你可以点击它。保存文章后,它会变成红色:

保存的页面如下所示:

The New York Times saved page

现在是有趣的部分!当你度过一天的时候,开始保存你想看的文章,以及那些你不想看的文章。有趣的用y标记,不有趣的用n标记。这需要一些工作。你的最终结果只会和你的训练集一样好,所以你需要为数百篇文章做这件事。如果你在保存的时候忘记给一篇文章贴标签,你可以随时去网站http://www.get.pocket.com,在那里贴标签。

使用口袋应用编程接口检索故事

现在,您已经努力将文章保存到 Pocket,下一步是检索它们。为了实现这一点,我们将使用口袋应用编程接口。你可以在https://getpocket.com/developer/apps/new注册一个账户。遵循以下步骤来实现:

  1. 点击左上角的创建一个新的应用,并填写详细信息,以获得您的应用接口密钥。

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

  1. 填写并提交后,您将收到您的消费者密钥
  2. 你可以在左上角的“我的应用”下找到它。它看起来像下面的截图,但显然有一个真正的关键:

  1. 设置好之后,您就可以进入下一步,即设置授权。我们现在就做。

  2. 它要求您输入您的消费者密钥和重定向网址。重定向网址可以是任何东西。在这里,我使用了我的推特账户:

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. 输出将包含下一步所需的代码。在浏览器栏中放置以下内容:

https://getpocket.com/auth/authorize?request _ token = some _ long _ access _ code&amp;redirect _ uri = https % 3A//www . Twitter . com/acom bs

  1. 如果您将重定向网址更改为您自己的网址,请确保对其进行网址编码(这是您在前面的网址中看到的%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 

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

你会注意到我们标记的所有文章上都有一个很长的 JSON 字符串n。这里面有几个关键点,但我们目前真的只对 URL 感兴趣。

  1. 我们将继续创建一个所有网址的列表:
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 

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

List of URLs

  1. 这个列表包含了所有我们不感兴趣的故事的网址。现在让我们把它放在一个数据帧中,并这样标记它:
no_uf = pd.DataFrame(no_urls, columns=['urls']) 
no_uf = no_uf.assign(wanted = lambda x: 'n') no_uf 

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

Tagging the URLs

  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 

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

Tagging the URLs of stories we are interested in

  1. 现在,我们的培训数据有了两种类型的故事,让我们将它们结合成一个单一的数据框架:
df = pd.concat([yes_uf, no_uf]) 

df.dropna(inplace=True) 

df 

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

Joining the URLs- both interested and not interested

现在我们已经在一个框架中设置了所有的网址和它们对应的标签,我们将继续下载每篇文章的 HTML。我们将为此使用另一种免费服务,称为 Embedly。

使用嵌入式应用编程接口下载故事正文

我们有故事的所有网址,但不幸的是,这不足以训练;我们需要完整的文章正文。如果我们想推出自己的刮刀,这本身可能会成为一个巨大的挑战,尤其是如果我们要从几十个网站中提取故事。我们需要编写代码来定位文章主体,同时小心避免围绕它的所有其他网站粘性。幸运的是,就我们而言,有许多免费服务可以为我们做到这一点。我将使用 Embedly 来实现这一点,但是您可以使用许多其他服务来代替。

第一步是注册 Embedly API 访问。你可以在 https://app.embed.ly/signup 做。这是一个简单的过程。一旦您确认注册,您将收到一个应用编程接口密钥。那真的是你所需要的。您只需在您的 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 requests

这样,我们就有了每个故事的 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 表示包括获取每个单词及其计数,以创建所谓的术语文档矩阵。在术语-文档矩阵中,每个唯一的单词被分配给一列,每个文档被分配给一行。两者的交叉点是伯爵:

| 先生否。 | | 新增 | 小猫 | 播放了 | | 其他 | 小猫 | | 吃了 | 午餐 | 爱过 | |
| one | one | one | one | one | one | one | one | Zero | Zero | Zero | Zero | Zero |
| Two | Zero | Zero | Zero | Zero | Zero | Zero | Zero | one | one | one | Zero | Zero |
| three | Zero | Zero | one | Zero | Zero | Zero | Zero | one | Zero | Zero | one | one |

注意,对于这三个短句,我们已经有了 12 个特性。正如你可能想象的那样,如果我们处理的是实际的文档,比如新闻文章甚至书籍,那么特征的数量将会激增到几十万个。为了缓解这种爆炸,我们可以采取一些步骤来删除那些对我们的分析几乎没有或根本没有信息价值的特征。

我们可以采取的第一步是删除停止词。这些词非常常见,通常不会告诉您文档的内容。常见的英语停止词有和上的。我们将删除这些,并重新计算术语文档矩阵:

| 先生否。 | 新增 | 小猫 | 播放了 | 小猫 | 吃了 | 午餐 | 爱过 |
| one | one | one | one | one | Zero | Zero | Zero |
| Two | Zero | Zero | Zero | Zero | one | one | Zero |
| three | Zero | one | Zero | Zero | Zero | Zero | one |

如您所见,功能数量从 12 个减少到 7 个。这很好,但我们可以更进一步。我们可以执行词干化引理化来进一步减少特征。请注意,在我们的矩阵中,我们同时拥有小猫小猫。通过使用词干化或引理化,我们可以将它合并成仅仅小猫咪:

| 先生否。 | 新增 | 小猫 | 播放 | 吃饭 | 午餐 | 爱情 |
| one | one | Two | one | Zero | Zero | Zero |
| Two | Zero | Zero | Zero | one | one | Zero |
| three | Zero | one | Zero | Zero | Zero | one |

我们的新矩阵合并了小猫小猫,但是也发生了一些其他的事情。我们失去了的后缀,被转化为。为什么呢?这就是引理化的作用。如果你记得你小学的语法课,我们已经从单词的屈折形式变成了基本形式。如果那是引理化,那词干是什么?词干也有同样的目标,但使用的方法不那么复杂。这种方法有时会产生伪词,而不是实际的基本形式。比如引理,如果你要减少小马,你会得到小马,但是用炮泥,你会得到小马

现在让我们进一步对矩阵进行另一种变换。到目前为止,我们已经使用了每个单词的简单计数,但是我们可以应用一种算法,该算法将对我们的数据进行过滤,以增强每个文档独有的单词。该算法称为术语频率-逆文档频率 ( tf-idf )

我们计算矩阵中每个项的 tf-idf 比率。让我们举几个例子来计算一下。对于文件一中的一词,频率一词只是计数,也就是1。反向文档频率计算为语料库中文档数量与该术语出现的文档数量的对数。对于,这是 log (3/1) ,或者. 4471。所以,对于完整的 tf-idf 值,我们有 tf * idf ,或者,这里是 1 x .4471 ,或者正好是. 4471。对于文档一中的单词 kitten ,tf-idf 为 2 * log (3/2) ,或. 3522。

为了完成其余条款和文件,我们有以下内容:

| 先生否。 | 新增 | 小猫 | 播放 | 吃饭 | 午餐 | 爱情 |
| one | .4471 | .3522 | .4471 | Zero | Zero | Zero |
| Two | Zero | Zero | Zero | .4471 | .4471 | Zero |
| three | Zero | .1761 | Zero | Zero | Zero | .4471 |

为什么会这样?假设,例如,我们有一个关于许多主题(医学、计算、食品、动物等)的文档语料库,我们希望将它们分类为主题。很少有文件会包含血压计这个词,它是用来测量血压的设备;所有的文件都可能与医学相关。显然,这个词在文件中出现的次数越多,就越有可能是关于医学的。因此,一个很少出现在我们整个语料库中,但在一个文档中多次出现的术语,很可能与该文档的主题紧密相关。这样,文档可以说是由那些具有高 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是文档的标记化方式。在前面的例子中,我们使用每个单词作为标记,但是在这里,我们使用所有一到三个单词的序列作为标记。就拿我们的第二句话来说吧,她吃了午饭。我们暂时忽略停止词。这句话的 n-克数应该是:她吃了她吃了吃了吃了午餐、午餐

接下来,我们有stop_words。我们为此传递english以删除所有的英语停止词。如前所述,这将删除所有缺少信息内容的术语。

最后,我们有min_df。这将删除至少三个文档中没有出现的所有单词。添加这个可以删除非常罕见的术语,并减小矩阵的大小。

现在我们的文章语料库是一个可行的数字格式,我们将继续把它输入到我们的分类器中。

支持向量机

我们将在本章中使用一个新的分类器,一个线性支持向量机 ( SVM )。SVM 算法是一种试图使用最大边缘超平面将数据点线性分类的算法。那是一口,让我们看看它真正的意思。

假设我们有两类数据,我们想用一条线把它们分开。(这里我们只讨论两个特征或维度。)放置那条线最有效的方法是什么?让我们看一个例子:

在上图中,线 H 1 没有有效区分这两个类,所以我们可以排除那一个。线 H 2 能够干净利落的区分它们,但是 H 3 是最大余量线。这意味着直线位于每个类的两个最近点之间的中心,这两个最近点被称为支持向量。这些可以看作下图中的虚线:

如果数据不能如此整齐地分成类呢?如果点之间有重叠怎么办?在这种情况下,仍然有选择。一种是使用所谓的“软保证金 SVM”。这个公式仍然使边际最大化,但代价是落在边际错误一边的点数受到惩罚。另一个选择是使用所谓的内核技巧。这种方法将数据转换到一个更高维的空间,在那里数据可以线性分离。这里提供了一个示例:

二维表示如下:

我们采用了一维特征空间,并将其映射到二维特征空间。映射只是取每个 x 值,并将其映射到 xx 2 。这样做允许我们添加一个线性分离平面。

至此,让我们将 tf-idf 矩阵输入到我们的 SVM:

from sklearn.svm import LinearSVC 

clf = LinearSVC() 
model = clf.fit(tv, df['wanted']) 

tv是我们的矩阵,df['wanted']是我们的标签列表。记住这不是y就是n,表示我们对文章是否感兴趣。一旦运行,我们的模型就被训练好了。

本章中我们没有做的一件事是正式评估我们的模型。您应该总是有一个搁置集来评估您的模型,但是因为我们将不断更新我们的模型,并每天对其进行评估,所以我们将跳过本章的这一步。只要记住这通常是一个可怕的想法。

现在让我们继续设置每天的新闻源。

IFTTT 与提要、谷歌表单和电子邮件的集成

我们使用 Pocket 来构建我们的训练集,但是现在我们需要一个文章流来运行我们的模型。为了设置这一点,我们将再次使用 IFTTT,以及谷歌表单,以及一个允许我们使用谷歌表单的 Python 库。

通过 IFTTT 设置新闻源和谷歌表单

希望此时您已经建立了一个 IFTTT 帐户,但是如果没有,现在就开始建立。完成后,您需要设置与 feed 和 Google Sheets 的集成:

  1. 首先,在主页的搜索框中搜索提要,然后单击服务,并单击设置:

  1. 您只需单击连接:

  1. 接下来,在服务下搜索Google Drive:

  1. 点击那个。它会把你带到一个页面,在那里你选择你想连接的谷歌帐户。选择帐户,然后点按“允许”以启用 IFTTT 来访问您的 Google Drive 帐户。完成后,您应该会看到以下内容:

  1. 现在,通过连接我们的频道,我们可以设置我们的提要。点击右下角用户名下的下拉菜单中的新建小程序。这会把你带到这里:

  1. 点击+这个。搜索RSS Feed,然后点击。这应该会把你带到这里:

  1. 从这里,单击新建订阅源项目:

  1. 然后,将网址添加到框中,并单击创建触发器。完成后,您将被带回来添加+那个动作:

  1. 点击+那个,搜索Sheets,然后点击它的图标。一旦完成,你会发现自己在这里:

  1. 我们希望我们的新闻项目流入谷歌驱动电子表格,所以点击添加行到电子表格。然后,您将有机会自定义电子表格:

我给这个电子表格起了个名字NewStories,并把它放在了一个名为IFTTT的谷歌驱动文件夹中。单击“创建操作”来完成制作方法,很快您将开始看到新闻项目流入您的谷歌驱动电子表格。请注意,它只会在新项目进入时添加新项目,而不会添加创建工作表时已存在的项目。我建议添加一些提要。你将需要为每个人创建单独的食谱。最好是为训练集中的站点添加提要,换句话说,就是用 Pocket 保存的站点。

给这些故事一两天的时间在纸上积累,然后它应该是这样的:

幸运的是,包含了完整的文章 HTML 正文。这意味着我们不必使用 Embedly 为每篇文章下载它。我们仍然需要从谷歌表单下载文章,然后处理文本以去除 HTML 标签,但这一切都可以很容易地完成。

为了下拉文章,我们将使用名为gspread的 Python 库。这可以 pip 安装。安装完成后,您需要按照设置 OAuth 2 的方向进行操作。这可以在http://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 

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

这样,我们从提要中下载了所有文章,并将它们放入一个数据框中。我们现在需要去掉 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。和以前一样,在第 3 章中,我们将使用 Webhooks 频道发送POST请求。但这一次,有效载荷将是我们的新闻故事。如果您还没有设置 Webhooks 频道,请现在就设置。说明可以在第 3 章中找到,建立一个寻找廉价机票的应用。你还应该设置 Gmail 频道。一旦完成,我们将添加一个食谱来结合这两者。按照以下步骤设置 IFTTT:

  1. 首先,从 IFTTT 主页点击新建小程序,然后点击+this。然后,搜索 Webhooks 频道:

  1. 选择该选项,然后选择接收网络请求:

  1. 然后,给请求起一个名字。我在用news_event:

  1. 单击创建触发器完成。接下来,点击+以设置电子邮件。搜索 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')) 

这样,我们就从我们的模型中节省了所有我们需要的东西。在我们的新脚本中,我们将阅读这些内容来生成新的预测。我们将使用与我们在第 3 章中使用的相同的调度库来运行代码,构建一个应用来查找便宜的机票。综上所述,我们有以下脚本:

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请求来生成一封电子邮件,请求那些预测感兴趣的故事,然后,最后,它将清除电子表格中的故事,这样在下一封电子邮件中只会发送新的故事。

恭喜你!你现在有自己的个性化新闻源了!

摘要

在本章中,我们学习了在训练机器学习模型时如何处理文本数据。我们还学习了自然语言处理和支持向量机的基础知识。

在下一章中,我们将进一步发展这些技能,并尝试预测什么样的内容会像病毒一样传播。*

六、预测你的内容是否会迅速传播

像许多伟大的事情一样,这一切都始于打赌。那是 2001 年,当时麻省理工学院的研究生乔纳·佩雷蒂正在拖延。他没有写论文,而是决定接受耐克的提议,个性化一双运动鞋。根据最近启动的一个项目,任何人都可以从他们的网站 NIKEiD 这样做。唯一的问题是,至少从耐克的角度来看,按照佩雷蒂的要求,在他们身上印上“血汗工厂”这个词是行不通的。佩雷蒂在一系列电子邮件中提出异议,指出该词绝不属于会导致他的个性化请求被拒绝的任何令人反感的术语类别。

佩雷蒂认为其他人可能会觉得与耐克客服代表的交流也很有趣,于是将这些交流转发给了一些密友。几天之内,这些电子邮件就进入了世界各地的收件箱。《时代》、《沙龙》、《卫报》甚至《今日秀》等主要媒体都开始关注此事。佩雷蒂是一场病毒式轰动的中心。

但开始困扰佩雷蒂的问题是,这种事情能被复制吗?他的朋友卡梅伦·马洛一直在准备写他关于病毒现象的博士论文,并坚持认为这样的事情太复杂了,任何人都无法设计。赌注就在这里开始了。马洛打赌说,佩雷蒂不可能重复他与耐克最初的一系列电子邮件所获得的成功。

快进 15 年,乔纳·佩雷蒂领导的网站已经成为病毒的代名词——BuzzFeed。2015 年,该网站拥有超过 7700 万的独特访客,总访问量排名高于《纽约时报》。我认为可以肯定地说,佩雷蒂赢了那笔赌注。

但是佩雷蒂到底是怎么做到的呢?他是如何拼凑出创造像野火一样传播的内容的秘密公式的?在这一章中,我们将试图解开其中的一些谜团。我们将研究一些最常分享的内容,并尝试找出区别于人们不太愿意分享的内容的共同要素。

本章将涵盖以下主题:

  • 关于病毒性,研究告诉了我们什么?
  • 获取共享计数和内容
  • 探索共享性的特征
  • 构建预测性内容评分模型

关于病毒性,研究告诉了我们什么?

理解分享行为是大生意。随着消费者年复一年地对传统广告越来越视而不见,这种推动正在超越简单的推销,转而讲述引人入胜的故事。这些努力的成功越来越多地以社会份额来衡量。为什么要这么麻烦?因为,作为一个品牌,我收到的每一份都代表着我接触到的另一个消费者——所有这些都不需要额外花费一分钱。

由于这一价值,一些研究人员检查了分享行为,希望了解它的动机。研究人员发现的原因如下:

  • 为他人提供实用价值(利他动机)
  • 将自己与某些想法和概念联系起来(认同动机)
  • 围绕共同的情感(共同的动机)与他人建立联系

关于最后一个动机,一项特别精心设计的研究查看了《纽约时报》的 7000 篇内容,以考察情绪对分享的影响。他们发现,单纯的情绪情绪不足以解释分享行为,但当结合情绪唤醒时,解释力更大。

例如,虽然悲伤有很强的负价,但它被认为是一种低唤醒状态。另一方面,愤怒具有负价,这与高唤醒状态成对出现。因此,让读者难过的故事往往比引发愤怒的故事少得多。那么,如今在政治中扮演如此重要角色的许多虚假新闻都是以这种形式出现的,这有什么好奇怪的吗?下图显示了相同的结果:

Figure taken from What Makes Online Content Viral? by Jonah Berger and Katherine L. Milkman, Journal of Marketing Research, available at: 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:脸书喜欢的人数
  • lnkdn:领英股份数量
  • 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 

上述代码生成以下输出:

让我们添加更多功能。我们将在页面上添加第一个图像最突出的颜色。每个图像的颜色在 JSON 数据中按 RGB 值列出,因此我们可以从中提取颜色:

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 

此代码生成以下输出:

我不知道你怎么想,但鉴于我不认为十六进制值是颜色,这并没有多大帮助。然而,我们可以在熊猫中使用一个叫做条件格式的新特性来帮助我们:

mci['color'] = ' ' 

def color_cells(x): 
    return 'background-color: ' + x.index 

mci.style.apply(color_cells, subset=['color'], axis=0) 

mci 

上述代码生成以下输出:

使聚集

这当然有帮助,但是颜色是如此的精细,我们总共有超过 450 种独特的颜色。让我们使用一点聚类来将这个范围缩小到更易管理的范围。由于我们有每种颜色的 RBG 值,我们可以创建一个三维空间来使用 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 均值模型并检索中心值:

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-gram 的相同信息:

hw,hl = get_word_stats(dfc['title'], 2, 0) 

hw 

这将生成以下输出:

这绝对更有趣。我们可以开始一遍又一遍地看到标题的一些组成部分。突出的两个是(donald, trump)(dies, at)。特朗普在选举期间说了一些引人注目的话,这是有道理的,但我对去世的头条感到惊讶。我看了一下头条,显然有一些高知名度的人在有问题的年份去世了,所以这也是有道理的。

现在,让我们在删除停止词的情况下运行这个程序:

hw,hl = get_word_stats(dfc['title'], 2, 1) 

hw 

这将生成以下输出:

同样,我们可以看到许多我们可能期待的事情。看起来,如果我们改变解析数字的方式(用像 number 这样的单个标识符替换它们),我们可能会看到更多这样的泡沫。如果你想尝试的话,我会把它留给读者。

现在,让我们来看看三克:

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 只看起来像猫王的小狗》这样的文章比《T2》和《伊斯兰国史》要少得多。

让我们再看一个。我们将评估故事主体的三重图:

hw,hl = get_word_stats(dfc['text'], 3, 1) 

hw 

此代码生成以下输出:

我们似乎突然进入了广告和社会迎合的领域。接下来,让我们继续构建内容评分的预测模型。

构建预测性内容评分模型

让我们利用我们所学的知识来创建一个模型,该模型可以估计给定内容的份额计数。我们将使用已经创建的特性,以及一些附加特性。

理想情况下,我们将拥有更大的内容样本,尤其是具有更典型的份额计数的内容,但我们将不得不满足于我们这里所拥有的。

我们将使用一种叫做随机森林回归的算法。在前几章中,我们研究了基于分类的随机森林的一个更典型的实现,但是在这里我们将尝试预测份额计数。我们可以将我们的共享类合并到范围中,但是在处理连续变量时最好使用回归,这就是我们正在处理的。

首先,我们将创建一个简单的模型。我们将使用图像数量、网站和字数。我们将根据脸书喜欢的数量来训练我们的模型。我们还将把数据分成两组:训练集和测试集。

首先,我们将导入 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)) 

上述代码生成以下输出:

现在,我们将继续准备数据。接下来,我们需要为我们的站点设置分类编码。目前,我们的数据框架用字符串表示每个站点的名称。我们需要使用虚拟编码。这将为每个站点创建一列,如果该行有该特定站点,则该列将填充一个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) 

有了这两行代码,我们已经训练了我们的模型。让我们用它来预测脸书喜欢我们的测试集:

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) 

此代码生成以下输出:

似乎每个特性都适度地改进了我们的模型。当然,我们还可以添加更多的功能。例如,我们可以添加发布的日期和时间,我们可以通过在标题上运行正则表达式来确定文章是否是列表,或者我们可以检查每篇文章的情绪。但这只是触及了可能对虚拟性建模很重要的特性。我们当然需要更进一步,继续减少模型中的错误数量。

我还应该注意到,我们只对我们的模型进行了最粗略的测试。每次测量应运行多次,以获得更准确的实际误差率。因为我们只进行了一次测试,所以我们的最后两个模型之间可能没有统计上可辨别的差异。

摘要

在这一章中,我们研究了病毒内容的共同特征是什么,以及我们如何使用随机森林回归来建立一个预测病毒的模型。我们还学习了如何组合多种类型的特征,以及如何将我们的模型分成训练集和测试集。

希望你能利用在这里学到的知识,建立下一个病毒帝国。如果这种方法行不通,或许下一章关于掌握股票市场的内容会奏效。

七、使用机器学习预测股票市场

就在最近,我读到一篇文章,描述了一种特殊疗法在对抗耐甲氧西林金黄色葡萄球菌方面取得的巨大成功。如果你没有直接听说过耐甲氧西林金黄色葡萄球菌,很可能你已经听说了一些关于目前的担忧,即我们正走向抗生素不再有效的时代。这在很大程度上是一种不可避免的现象,因为群体中的一些细菌对相关药物的遗传抗性更强。当对这种药物敏感的细菌在治疗过程中被消灭时,剩余的抗药性细菌就会繁殖并成为种群中的主要变种。为了解决这个问题,科学家们不断突破科学的界限,寻找新的方法来解决这些问题。

在生物学中,这种情况被称为红皇后竞赛:这个术语来自刘易斯·卡罗尔的《透过镜子看 T2:中的一句话

"Now, here, you see, it takes all the running you can do, to keep in the same place."

这有效地描述了我们在抗生素方面的处境,但也许答案在转向新的、越来越先进的药物时找不到。也许答案可以从理解更大的循环中找到,并利用它为我们带来好处。

我之前讨论的耐甲氧西林金黄色葡萄球菌的新疗法?这实际上来自于 10 世纪的一本医学药剂书,名为《T2·巴尔德的书》。列举的成分包括大蒜、葡萄酒和洋葱。这种组合被发现已经超过了我们目前的最后治疗手段万古霉素的结果。

但是这些和预测股市有什么关系呢?我想说的是,在这两种情况下,同样的现象都在起作用。例如,偶尔会有一篇论文发表,提醒金融界存在一种有利可图的异常现象。最有可能的是,这种现象是一些外部强加的现实世界约束的下游效应。

以年终税损销售为例。由于税法的性质,交易者在年底出售损失是有意义的。这给临近年底的亏损股票带来了价格下行压力。价格下跌意味着股票可以被折价超过其公允价值。这也意味着,1 月份,随着新资金投入这些被低估的资产,下行压力消失,取而代之的是上行压力。但一旦这种现象被传播开来,交易者试图走在前面,在 12 月下旬开始买入这些股票,并在 1 月份卖给那些预计会成为买家的其他交易者,这才有意义。这些新交易者,通过进入市场,现在已经稀释了影响。他们正在缓解年底的抛售压力,减少 1 月份的买入压力。这种影响基本上是随着盈利能力一起被套利的。曾经有效的方法不再有效,交易者将开始放弃策略,转向下一个新事物。

到现在,我希望你开始看到相似之处。大蒜、葡萄酒和洋葱的组合很可能曾经是治疗细菌感染的非常有效的方法,但随着细菌的适应,这种方法逐渐失去了效力。作为一种治疗方法,这种细菌在很久以前就被放弃了,因此没有理由避开使它们容易受到这种治疗的原始基因。现实世界的限制使得这种类型的循环几乎不可避免地会发生——无论是在生物体内还是在市场中。关键是利用这一点对我们有利。

在本章中,我们将花一些时间讨论如何构建和测试交易策略。然而,我们将花更多的时间来研究如何做到这一点。当试图设计你自己的系统时,有无数的陷阱需要避免,这几乎是一个不可能的任务,但它可能会很有趣,有时甚至会有利可图。话虽如此,不要做傻事,比如拿你输不起的钱去冒险。

If you do decide to use anything you learned here to trade, you're on your own. This shouldn't be deemed investment advice of any kind, and I accept no responsibility for your actions.

在本章中,我们将涵盖以下主题:

  • 市场分析的类型
  • 关于股票市场,研究告诉了我们什么?
  • 如何开发交易系统

市场分析的类型

让我们从讨论一些处理金融市场时的关键术语和分析方法开始。虽然有无数的金融工具,包括股票、债券、ETF、货币和掉期,但我们将只讨论股票和股票市场。股票只是上市公司所有权的一部分。当公司的未来前景上升时,股票价格预计会上升,当这些前景下降时,股票价格会下降。

投资者一般分为两大阵营。第一类是基本面分析师。这些分析师仔细研究公司财务状况,寻找表明市场在某种程度上低估公司股票价值的信息。这些投资者关注各种因素,如收入、收益和现金流,以及各种价值比率。这通常包括查看一家公司的财务状况与另一家公司的财务状况的比较。

投资者的第二个阵营是技术分析师。技术分析师认为,股价已经反映了所有可公开获得的信息,浏览基本面在很大程度上是浪费时间。他们认为,通过查看历史价格——股票图表——你可以看到价格可能上涨、下跌或停滞的领域。一般来说,他们觉得这些图表揭示了投资者心理的线索。

这两个群体的共同点是一个潜在的信念,即正确的分析可以带来利润。但这是真的吗?

关于股票市场,研究告诉了我们什么?

也许过去 50 年里对股票市场最有影响的理论是有效市场假说。尤金·法玛发展的这一理论规定,市场是理性的,所有可获得的信息都适当地反映在股票价格中。因此,投资者不可能在风险调整的基础上始终如一地跑赢市场。有效市场假说通常被认为有三种形式:弱形式、半强形式和强形式:

  1. 在弱形态下,市场是有效的,因为你不能用过去的价格信息来预测未来的价格。信息在股票中的反映相对较快,虽然技术分析可能无效,但在某些情况下,基本面分析可能有效。

  2. 在半强形式下,价格立即以不偏不倚的方式反映所有相关的新公共信息。在这里,无论是技术分析还是基本面分析都不会有效。

  3. 最后,在强形式中,股票价格反映了所有公共和私人信息。

基于这些理论,通过利用市场模式赚钱的希望不大。但幸运的是,尽管市场总体上以一种基本上有效的方式运行,但明显的低效区域已经被发现。其中大部分都是短暂的,但有些已经被记录为持续存在。其中最值得注意的——甚至根据法玛的说法——是动量策略的出色表现。

那么,动量策略到底是什么?

关于这个主题有许多不同的说法,但基本的观点是,股票是根据它们在前一个时期的回报从最高到最低排列的。排名靠前的表演者被购买并持有一段时间,然后在固定的持有期后重复该过程。典型的只做多的动量策略可能包括买入过去一年标准普尔 500 表现最好的 25 只股票,持有一年,卖出,然后重复这个过程。

这听起来像是一个荒谬的简单策略,事实也确实如此,但它始终会带来出乎意料的结果。但是为什么呢?你可以想象,很多研究已经检验了这种影响,假设是,关于人类如何处理新信息,存在某种内在的系统性偏见。研究表明,他们在短期内对新闻反应不足,然后在长期对新闻反应过度。这意味着,当股票在特别好的消息下开始上涨时,投资者不会将股价完全提升到充分反映这一消息的水平;他们需要时间来接受这个美好的前景。

This tendency of investors to fail to adequately reprice shares in the face of exceedingly good news may be the result of a well-documented bias called the anchoring effect. Essentially, when presented with a number, even a random number, and then asked to estimate a real-world value, such as the number of countries in Africa, for instance, our answer will be mentally tethered to that number we were primed with. Remarkably, this happens even if we know the number is randomly generated and unrelated to the question.

那么,随着越来越多的交易者了解并涌入,动量策略会不会被套利者抛弃?近年来有一些证据表明了这一点,但仍不清楚。无论如何,这种影响是显而易见的真实,并且持续的时间远远超过了有效市场假说目前所能解释的时间。因此,至少市场预测似乎有一些希望。考虑到这一点,现在让我们继续探索如何挖掘我们自己的市场异常。

如何制定交易策略

我们将从关注技术方面开始我们的战略发展。让我们来看看过去几年的标准普尔 500。我们将使用pandas来导入我们的数据。这将使我们能够访问几个股票数据来源,包括雅虎!还有谷歌。

  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 的数据,它代表了 S & P 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() 

我们得到如下输出:

同样,我们看到,我们的隔夜交易策略的平均下行波动远小于我们的盘中交易策略。

每日收益

到目前为止,我们已经从积分的角度看了一切,但现在让我们看看每日回报。这将有助于把我们的得失放到一个更现实的背景中。让我们为每个场景创建一个熊猫系列:每日回报(接近收盘变化)、日内回报和隔夜回报:

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 

我们所做的是使用熊猫.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) 

这将生成以下输出:

这里我们看到SPY从 2000 年初到 2018 年 12 月 1 日的价格走势。在此期间,市场无疑出现了许多波动,因为市场经历了高度积极和高度消极的局面。

让我们为我们的三个基本战略的新的扩展时期得到我们的基线。

首先,让我们为每个变量设置变量:

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 交易所交易基金中亏损。如果你只持有一夜,你的总积分回报会提高 18%以上!显然,这假定没有交易成本,没有税收以及完美的填充,但无论如何,这是一个了不起的发现。

建立回归模型

现在我们有了一个可以比较的基线,让我们构建第一个回归模型。我们将从一个非常基本的模型开始,仅使用股票的前一个收盘价来预测第二天的收盘价,我们将使用支持向量回归来构建它。这样,让我们建立我们的模型:

  1. 第一步是建立一个包含每天价格历史的数据框架。我们将在模型中包含过去 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. 我们只有 5000 个数据点可以使用,所以我选择使用最后 2000 个数据点进行测试。现在,让我们调整我们的模型,并使用它来检查样本外数据:
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() 

这将生成以下输出:

哎哟!这看起来很糟糕。但是我们测试的时间段呢?我们从来没有单独评估过。在过去的 2000 天里,我们的基本日内策略会产生多少积分:

(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']) 

这将生成以下输出:

很明显,我们的战略不是我们想要实施的。我们如何改进我们这里的东西?如果我们修改交易策略呢?如果我们只接受那些比开盘价高一个点或更多的交易,而不仅仅是比开盘价高一个点或更多。有帮助吗?让我们试试。我们将使用修改后的信号重新运行我们的策略,如以下代码块所示:

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 年的时间序列数据分成不同的 5 天周期。我们将把每个周期与一个额外的点配对。这将用于创建我们的 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 种可能的服装物品中的 1 种,如 t 恤/上衣、裤子、毛衣、连衣裙、外套、凉鞋、衬衫、运动鞋、包或踝靴。但是首先,我们将一起探索一些基础知识,从图像特征提取开始,逐步了解卷积神经网络是如何工作的。

那么,让我们开始吧。说真的!。

这就是我们将在本章中介绍的内容:

  • 图像特征提取
  • 卷积神经网络;
    • 网络拓扑结构
    • 卷积层和滤波器
    • 最大池层数
    • 变平
    • 全连接层和输出
  • 使用 Keras 构建卷积神经网络,对 Zalando 研究数据集中的图像进行分类

图像特征提取

当处理非结构化数据时,无论是文本还是图像,我们都必须首先将数据转换为机器学习模型可用的数字表示。将非数值数据转换为数值表示的过程称为特征提取。对于图像数据,我们的特征是图像的像素值。

首先,让我们想象一个 1,150 x 1,150 像素的灰度图像。1,150 x 1,150 像素的图像将返回 1,150 x 1,150 像素强度矩阵。对于灰度图像,像素值的范围可以从 0 到 255,0 是完全黑色的像素,255 是完全白色的像素,中间是灰色阴影。

为了演示代码中的样子,让我们从灰度猫卷饼中提取特征。图片可在 GitHub 上获得,网址为

I've made the image assets used throughout this chapter available to you at https://github.com/mroman09/packt-image-assets. You can find our cat burritos there!

现在让我们看看下面代码中的一个示例:

import matplotlib.image as mpimg
import matplotlib.pyplot as plt
import pandas as pd
%matplotlib inline

cat_burrito = mpimg.imread('img/grayscale_cat_burrito.jpg')
cat_burrito

If you're unable to read a .jpg by running the preceding code, just install PIL by running pip install pillow.

在前面的代码中,我们从matplotlib导入了pandas和两个子模块:imagepyplot。我们使用matplotlib.image中的imread方法读入图像。

运行前面的代码会得到以下输出:

输出是包含我们模型特征的二维数组。与大多数应用机器学习应用一样,您需要对这些提取的特征执行几个预处理步骤,其中一些我们将在本章稍后在 Zalando 时尚数据集上一起探讨,但这些是图像的原始提取特征!

为我们的灰度图像提取的特征的形状是image_height行 x image_width列。我们可以通过运行以下命令轻松检查形状:

cat_burrito.shape

前面的代码返回以下输出:

我们也可以轻松检查阵列中的最大和最小像素值:

print(cat_burrito.max())
print(cat_burrito.min())

这将返回以下内容:

最后,我们可以通过运行以下代码来显示我们阵列中的灰度图像:

plt.axis('off')
plt.imshow(cat_burrito, cmap='gray');

前面的代码返回了我们的图像,可在https://github . com/PacktPublishing/Python-机器学习-蓝图-第二版/树/主/第 08 章中找到作为output_grayscale_cat_burrito.png

彩色图像的特征提取过程是相同的;然而,对于彩色图像,我们的阵列输出的形状将是三维的——一个张量——代表我们图像的红色、绿色和蓝色 ( RGB )像素值。在这里,我们将执行与之前相同的过程,这次是在彩色版的猫卷饼上。图片可在 GitHub 上https://GitHub . com/PacktPublishing/Python-机器学习-蓝图-第二版/树/主/章节 08 作为color_cat_burrito.jpg获得。

让我们使用以下代码从彩色版的猫卷饼中提取特征:

color_cat_burrito = mpimg.imread('img/color_cat_burrito.jpg')
color_cat_burrito.shape

运行此代码将返回以下输出:

同样,在这里我们看到这个图像包含三个通道。我们的color_cat_burrito变量是一个张量,包含三个矩阵,告诉我们图像中每个像素的 RGB 值是多少。

我们可以通过运行以下命令来显示阵列中的彩色图像:

plt.axis('off')
plt.imshow(color_cat_burrito);

这将返回我们的彩色图像。图片可在 GitHub 上获得,网址为

这是我们图像特征提取的第一步。我们一次只拍摄一张图像,只需几行代码就可以将这些图像转换成数值。在这样做的时候,我们看到从灰度图像中提取特征产生了二维数组,从彩色图像中提取特征产生了像素强度值的张量。
不过,有一个小问题。请记住,这只是我们数据的单个图像、单个训练样本和单个。在我们的灰度图像的例子中,如果我们将这个矩阵展平成一行,我们将有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.
  • 产出是什么?五个。

建模者决定使用两个卷积层来对抗任何其他数字,或者仅使用一个完全连接的层来对抗任何其他数字,这应该被认为是模型的超参数。也就是说,这是我们作为建模者应该尝试和交叉验证的东西,但不是我们的模型正在明确学习和优化的参数。

通过查看网络拓扑,您可以推断出您正在解决的问题的其他有用信息。正如我们所讨论的,我们网络的输出层包含五个节点的事实让我们知道,这个神经网络被设计来解决一个有五个类的多类分类任务。如果这是一个回归或二进制分类问题,我们的网络架构将(在大多数情况下)只有一个输出节点。我们还知道,建模器在第一个卷积层中使用了 7 个滤波器,在第二个卷积层中使用了 12 个内核,这是因为每一层产生的特征图的数量(我们将在下一节中更详细地讨论这些内核是什么)。

太好了。我们学习了一些有用的行话,这些行话将帮助我们描述我们的网络,并建立我们对它们如何工作的概念理解。现在让我们来探索一下我们架构的卷积层。

卷积层和滤波器

卷积层和滤波器是卷积神经网络的核心。在这些层中,我们将一个过滤器(在本文中也称为窗口内核)滑过我们的标准特征,并在每一步获取内积。以这种方式对我们的数组和内核进行卷积会得到一个低维的图像表示。让我们来探索一下这在这个灰度图像上是如何工作的(可在图像资产存储库中找到):

前面的图像是一个 5 x 5 像素的灰度图像,在白色背景下显示了一条黑色对角线。

从下图中提取特征,我们得到以下像素强度矩阵:

接下来,让我们假设我们(或 Keras)实例化了以下内核:

我们现在将可视化卷积过程。窗口的移动是从图像矩阵的左上开始的。我们将窗口向右滑动一个预定的步幅。在这种情况下,我们的步幅大小将是 1,但一般来说,步幅大小应该被视为您的模型的另一个超参数。一旦窗口到达图像的最右边,我们将窗口向下滑动 1(我们的步幅大小),将窗口移回图像的最左边,并再次开始获取内积的过程。

现在让我们一步一步来:

  1. 将内核滑过矩阵的左上角,计算内积:

我将明确规划第一步的内部产品,以便您可以轻松地完成:

(0x0)+(255x0)+(255x0)+(255x0)+(0x1)+(255x0)+(255x0)+(255x0)+(0x0) = 0

我们将结果写入我们的要素地图并继续!

  1. 获取内部产品,并将结果写入我们的功能图:

  1. 第三步:

  1. 我们已经到达图像的最右边。将窗口向下滑动 1,即我们的步幅大小,并在图像的最左边重新开始该过程:

  1. 第五步:

  1. 第六步:

  1. 第七步:

  1. 第八步:

  1. 第九步:

瞧啊。我们现在已经在一个 3×3 矩阵(我们的特征图)中表示了我们最初的 5×5 图像。在这个玩具示例中,我们已经能够将维度从 25 个特征减少到只有 9 个。让我们看一下这个操作产生的图像:

如果你认为这看起来和我们原来的黑色对角线一模一样,但是更小,你是对的。内核取的值决定了什么被识别,在这个具体的例子中,我们使用了所谓的身份内核。取其他值的内核将返回图像的其他属性——检测线条、边缘、轮廓、高对比度区域等的存在。

我们将在每个卷积层同时对图像应用多个核。使用的内核数量取决于建模者——另一个超参数。理想情况下,您希望使用尽可能少的,同时仍然获得可接受的交叉验证结果。越简单越好!但是,根据任务的复杂程度,我们可能会通过使用更多来获得性能提升。当调整模型的其他超参数时,例如网络中的层数或每层神经元的数量,可以应用相同的思想。我们用简单换取复杂,用概括和速度换取细节和精确。

虽然内核的数量是我们的选择,但每个内核取的值是我们模型的参数,它是从我们的训练数据中学习的,并在训练过程中以降低成本函数的方式进行优化。

我们已经看到了如何将过滤器与图像特征进行卷积以创建单个特征图的逐步过程。但是当我们同时应用多个内核时会发生什么呢?这些要素地图如何穿过网络的每一层?让我们看看下面的截图:

Image source: Lee et al., Convolutional Deep Belief Networks for Scalable Unsupervised Learning of Hierarchical Representations, via stack exchange. Source text here: https://ai.stanford.edu/~ang/papers/icml09-ConvolutionalDeepBeliefNetworks.pdf

前面的截图可视化了在人脸图像上训练的网络的每个卷积层生成的特征图。在网络的早期层(最底层),我们检测到简单视觉结构的存在——简单的线条和边缘。我们用我们的身份内核做到了这一点!第一层的输出传递到下一层(中间一行),它将这些简单的形状组合成抽象的形式。我们在这里看到,边缘的组合构成了一张脸的组成部分——眼睛、鼻子、耳朵、嘴巴和眉毛。这个中间层的输出依次传递到最后一层,最后一层将边缘组合成完整的对象——在这种情况下,是不同人的脸。

整个过程的一个特别强大的特性是,所有这些特征和表示都是从数据中学习的。我们没有明确告诉我们的模型:模型,对于这个任务,我想在第一个卷积层中使用一个身份核和一个底部 sobel 核,因为我认为这两个核将提取信号最丰富的特征图。一旦我们为我们想要使用的核的数量设置了超参数,模型就通过优化来学习什么线、边、阴影以及它们的复杂组合最适合于确定一张脸是什么或者不是什么。该模型执行这种优化,没有关于什么是脸、猫卷饼或衣服的特定领域的硬编码规则。

卷积神经网络还有许多其他迷人的特性,这一章我们不会涉及。然而,我们确实探索了基本原理,希望你能意识到使用卷积神经网络提取高表达、信号丰富、低维特征的重要性。

接下来,我们将讨论最大池层

最大池层数

我们已经讨论了减少维度空间的重要性,以及如何使用卷积层来实现这一点。出于同样的原因,我们使用最大池层来进一步降低维度。非常直观地说,顾名思义,使用最大池,我们在要素地图上滑动一个窗口,并获取该窗口的最大值。让我们从对角线示例返回到要素地图来说明,如下所示:

让我们看看当我们使用 2 x 2 窗口最大化前面的要素地图时会发生什么。同样,我们在这里所做的就是返回max(values in window):

  1. 返回max(0,255,255,0),得到 us 255:

  1. 第二步:

  1. 第三步:

  1. 第四步:

通过将我们的要素地图与一个 2 x 2 窗口最大化,我们去掉了一列和一行,使我们从一个 3 x 3 的表示变成了一个 2 x 2 的表示——还不错!

还有其他形式的统筹——例如平均统筹和最低统筹;但是,您会看到最常使用的 max pooling。

接下来,我们将讨论展平,这是我们将执行的一个步骤,用于将最大集合要素图转换为适合建模的形状。

变平

到目前为止,我们专注于构建尽可能精简和表达的特征表示,并使用卷积神经网络和最大池层来做到这一点。我们转换的最后一步是将卷积和最大集合的数组(在我们的例子中是一个 2×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×5 矩阵,现在是具有四个特征的单行。我们现在可以把这个传递到一个完全连接的神经网络中。

全连接层和输出

完全连接的图层是我们将输入映射到目标类的地方,这些输入是我们卷积、最大池化和展平原始提取要素后得到的行。这里,每个输入连接到下一层的每个神经元节点。这些连接的强度或权重和网络每个节点中存在的偏差项是模型的参数,在整个训练过程中进行优化以最小化目标函数。

我们模型的最后一层将是我们的输出层,它给出我们的模型预测。我们的输出层中的神经元数量和我们应用于它的激活函数由我们试图解决的问题类型决定:回归、二元分类或多类分类。在下一节中,当我们开始使用 Zalando Research 时尚数据集时,我们将看到如何为多类分类任务设置完全连接的层和输出层。

The fully-connected layers and output—that is, the feedforward neural network component of our architecture—belong to a distinct neural network type from the convolutional neural networks we discussed in this section. We briefly described how feedforward networks work in this section only to provide color on how the classifier component of our architecture works. You can always substitute this portion of the architecture for a classifier you are more familiar with, such as a logit!

有了这些基础知识,您现在就可以构建您的网络了!

使用 Keras 构建卷积神经网络,对 Zalando 研究数据集中的图像进行分类

在这一节中,我们将使用 Zalando Research 的时尚数据集构建卷积神经网络来对服装图像进行分类。该数据集的存储库位于https://github.com/zalandoresearch/fashion-mnist

这个数据集包含 70,000 个灰度图像——每个图像描绘一件衣服——来自 10 件可能的衣服。具体来说,目标类如下:t 恤/上衣、裤子、毛衣、连衣裙、外套、凉鞋、衬衫、运动鞋、包包和踝靴。

总部位于德国的电子商务公司 Zalando 发布了这个数据集,为研究人员提供了手写数字的经典 MNIST 数据集的替代方案。此外,这个他们称之为时尚 MNIST 的数据集在出色预测方面更具挑战性——MNIST 手写数字数据集可以以 99.7%的准确率进行预测,而无需大量预处理或特别深入的神经网络。

所以,让我们开始吧!请遵循以下步骤:

  1. 将存储库克隆到我们的桌面上。从终端运行以下命令:
cd ~/Desktop/
git clone git@github.com:zalandoresearch/fashion-mnist.git

If you haven't done so already, please install Keras by running pip install keras from the command line. We'll also need to install TensorFlow. To do this, run pip install tensorflow from the command line.

  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 或 Antano。

对于我们的项目,Keras 将在幕后运行 TensorFlow。直接使用 TensorFlow 将允许我们更明确地控制我们网络的行为;然而,由于 TensorFlow 使用数据流图来表示其操作,这可能需要一些时间来适应。对我们来说幸运的是,Keras 提取了很多这样的东西,对于那些对sklearn感到舒适的人来说,它的应用编程接口很容易学习。

对于在座的一些人来说,唯一新的库将是 Python 图像库 ( PIL )。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×28 的矩阵。让我们来看看第一张图片:

image_1 = X_train[0].reshape(28,28)
plt.axis('off')
plt.imshow(image_1, cmap='gray');

这将生成以下输出:

太棒了。我们可以通过运行以下命令来查看该图像所属的类:

y_train[0]

这将生成以下输出:

这些类是从 0 到 9 编码的。在自述文件中,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_train ( feature_array)和y_train ( target_array)作为参数,并生成一个很大的图像矩阵。作为可选参数,如果复制可视化很重要,您可以指定cmap或颜色图(默认为‘gray',因为它们是灰度图像)和random_seed

让我们看看如何运行它,如下所示:

show_fashion_mnist(4,4, X_train, y_train, random_seed=72)

这将返回以下内容:

Visualization output

删除random_seed参数,并多次重新运行该函数。具体来说,运行以下代码:

show_fashion_mnist(4,4, X_train, y_train)

您可能已经注意到,在这个分辨率下,一些类看起来非常相似,而另一些类则非常不同。例如,t 恤/上衣目标类的样本看起来与衬衫和外套目标类的样本非常相似,而凉鞋目标类似乎与其他目标类有很大不同。当思考我们的模型在哪里可能是弱的,在哪里可能是强的时,这是一个值得思考的问题。

现在让我们来看一下目标类在数据集中的分布。我们需要进行上采样还是下采样?让我们检查一下:

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×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×3。之后,我们指定图像输入形状,28 x 28 x 1。我们只需要在我们网络的第一个.add()调用中指定输入形状。最后,我们将激活函数指定为relu。激活函数在一个层的输出传递到下一层之前对其进行转换。我们将对Conv2DDense图层应用激活功能。这些转换有许多重要的属性。在这里使用relu加快了我们网络的收敛速度,http://www.cs.toronto.edu/~fritz/absps/imagenet.pdfhttp://www.cs.toronto.edu/~fritz/absps/imagenet.pdfrelu相对于替代激活函数来说,计算起来并不昂贵——我们只是将负值转换为 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 的预测概率。这些概率加起来就是110的最高预测概率将代表我们的模型认为最有可能的类别。
  • 第 12 行:这里是我们编译 Keras 模型的地方。在编译步骤中,我们指定我们的优化器Adam,一个自动调整其学习速率的梯度下降算法。我们指定我们的损失函数—在本例中为categorical cross entropy,因为我们正在执行一个多类分类问题。最后,对于度量参数,我们指定accuracy。通过指定这一点,Keras 将告知我们模型运行的每个时期的训练和验证精度。

我们可以通过运行以下命令来获得模型的摘要:

model.summary()

这将输出以下内容:

请注意,当数据通过模型时,输出形状会如何变化。具体来说,查看展平发生后我们输出的形状——只有 45 个特征。X_trainX_test中的原始数据由每行 784 个特征组成,所以这太棒了!

You'll need to install pydot to render the visualization. To install it, run pip install pydot from the terminal. You may need to restart your kernel for the install to take effect.

使用 Keras 中的plot_model函数,我们可以以不同的方式可视化网络拓扑。为此,请运行以下代码:

plot_model(model, to_file='Conv_model1.png', show_shapes=True)
Image.open('Conv_model1.png')

运行前面的代码将拓扑保存到Conv_model1.png并生成以下内容:

This model will take several minutes to fit. If you have concerns about your system's hardware specs, you can easily reduce the training time by reducing the number of epochs to 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保存下来,这样我们以后就可以很容易地想象各个时期的训练和验证损失。

随着代码的运行,您将看到模型的训练和验证损失,以及每个时期后的准确性。让我们使用下面的代码来绘制模型的列车损失和验证损失:

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%的最大分类准确率。太神奇了!但是我们仍然需要解决过度拟合的问题。接下来,我们将使用脱落正则化重建我们的模型。

脱落正则化是正则化的一种形式,我们可以将其应用于神经网络的全连接层。使用脱落正则化,我们在训练过程中从网络中随机脱落神经元及其连接。通过这样做,网络不会变得过于依赖与任何特定节点相关联的权重或偏差,从而允许它更好地从样本中进行归纳。

在这里,我们添加了丢失正则化,指定我们想要删除每个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%。我们的模型轻而易举地超过了这个基线精度。它显然对数据有所了解!

从这里你可以去很多不同的地方!尝试构建更深层次的模型,或者在模型中使用的许多超参数上进行网格搜索。评估你的分类器的性能,就像评估任何其他模型一样——试着建立一个混淆矩阵来理解我们预测的好的类和我们没有预测的强的类!

摘要

我们在这里确实走了很多路!我们讨论了如何从图像中提取特征,卷积神经网络是如何工作的,然后我们构建了一个卷积神经网络到完全连接的网络架构。一路上,我们也学到了很多新的术语和概念!

希望读完这一章,你会觉得这些图像分类技术——你可能曾经认为是巫师的领域——实际上只是出于直觉原因而进行的一系列数学优化!希望这些内容能帮助你解决你感兴趣的图像处理项目!

九、构建聊天机器人

想象一下,你独自坐在一个安静宽敞的房间里。你的右边是一张小桌子,上面放着一叠白色打印纸和一支黑色钢笔。在你面前的似乎是一个巨大的红色立方体,有一个微小的开口——略小于一个邮件槽的大小。插槽正上方的铭文邀请你写下一个问题,并通过插槽传递。碰巧你会说普通话;所以,你用普通话在其中一张纸上写下你的问题,并插入到开头。几分钟过去了,慢慢地,一个答案出现了。它也是用中文写的,并且是你可能期望的那种答案。你问了什么?你是人还是电脑?反应如何?为什么是的,是的我是

这个思想实验基于哲学家约翰·塞尔的《中国房间论》。实验的前提是,如果房间里有一个人不会说中文,但有一套规则允许他们将英文字符完美地映射为中文字符,那么在提问者看来,他们可能理解中文,而实际上对中文没有任何理解。塞尔的论点是,不能说产生可理解输出的算法程序理解了输出。他们缺少一个头脑。他的思维实验试图对抗强大的人工智能的思想,或者说人类大脑本质上只是一个湿机器的概念。塞尔不相信人工智能可以说是有意识的,不管它的行为在外部观察者看来有多复杂。

塞尔在 1980 年发表了这个实验。31 年后,Siri 将在 iPhone 4S 上发布。对于任何使用过 Siri 的人来说,很明显,在我们可能面临与我们交谈的代理是否有头脑的不确定性之前,我们还有很长的路要走(尽管我们可能会怀疑我们认识的人是否有头脑)。尽管这些代理或聊天机器人在过去表现出笨拙,但该领域正在迅速发展。

在本章中,我们将学习如何从头开始构建聊天机器人。在此过程中,我们将了解该领域的更多历史及其未来前景。

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

  • 图灵测试
  • 聊天机器人的历史
  • 聊天机器人的设计
  • 构建聊天机器人

图灵测试

在塞尔的《中国房间》问世 30 年前,艾伦·图灵提出了这样一个问题:机器能思考吗?在他的一篇比较著名的论文中。作为一个实际的天才,他决定不正面解决这个问题,而是把它放在其他人的思维的问题的框架中。这个问题问,我们如何真正知道别人也有和我们一样的想法?因为我们只能观察他们的行为——而不能观察他们的内心活动——我们必须相信他们和我们一样。图灵提出,如果一台机器可以表现得好像它是智能的,那么我们应该这样看待它。简单来说,这就是图灵测试。通过测试意味着让人类相信机器也是人类。

此时你可能会想,有没有一个程序曾经成功通过图灵测试?答案是真的没有官方的图灵测试。最接近官方测试的是罗布纳奖。这是一项每年举行一次的竞赛,向那些在评委团看来最有说服力的聊天机器人颁发适度的奖项。如果这听起来严谨和学术,它已经被描述为任何事情。据说罗布纳本人就是这样的人物,经常公开表示他对葡萄酒、大麻和妓女的喜爱。据说他对比赛的赞助是他完全蔑视任何形式的劳动的结果。

希望此时,你已经意识到任何关于通过图灵测试的说法都比真正的科学更壮观。尽管如此,图灵最初的概念是一个有用的构造。

聊天机器人的历史

我还记得有一天,父亲让我坐在他的 IBM XT 前,看着它黑白闪烁的显示器,让我和它说话。对话大致如下:

你好。你今天感觉怎么样?

嗨。这是谁?

你好...你今天怎么样?

我很好。你没告诉我你是谁。

我们应该讨论你,而不是我。

我们应该吗?为什么呢?

让我们稍微改变一下焦点...说说你的家庭吧。

你是不可能与之交谈的。

你能详细说明一下吗?

你要杀了我。

也许你想让我杀了你。

好吧,你吓到我了。

也许你真的在说你自己?

什么??这说不通。

这个问题的答案对你有什么帮助?

结束

非常有趣。

退出

感谢您与我交谈。

\(###\)#@@....

我正在和一个名叫伊莱扎的程序交谈。伊莱扎是约瑟夫·韦森鲍姆于 20 世纪 60 年代中期在麻省理工学院人工智能实验室创作的,旨在模仿一位罗格里安心理治疗师的反应。尽管在任何深度检查时都近乎滑稽,但该程序能够让一些用户相信他们在和真人聊天——考虑到它只有不到 200 行代码,使用随机化和正则表达式来鹦鹉学舌地回答,这是一个了不起的壮举。即使在今天,这个简单的节目仍然是流行文化的主要内容。如果你问 Siri ELISA 是谁,她会告诉你她是一个朋友,也是一个出色的精神病医生。

如果伊莱扎是聊天机器人的早期例子,从那以后我们看到了什么?近年来,新聊天机器人激增。其中最引人注目的是 Cleverbot。

Cleverbot 于 1997 年通过网络向世界发布。从那以后的几年里,这个机器人已经积累了数亿次转换,与早期的聊天机器人不同,正如它的名字所暗示的那样,Cleverbot 似乎随着每次转换变得更加智能。虽然算法工作的确切细节很难找到,但据说它的工作原理是通过记录数据库中的所有对话,并通过识别数据库中最相似的问题和答案来找到最合适的答案。

我编了一个无意义的问题,如下所示,你可以看到它在字符串匹配方面找到了与我的问题的对象相似的东西:

我坚持说:

我又得到了一些东西...相似吗?

您还会注意到,话题可以贯穿整个对话。作为回应,我被要求更详细地解释我的回答。这似乎是让克莱伯特变得聪明的原因之一。

虽然向人类学习的聊天机器人可能相当有趣,但它们也有黑暗的一面。

几年前,微软在推特上发布了一个名为 Tay 的聊天机器人。人们被邀请问泰问题,泰会根据她的性格做出回应。微软显然将机器人编程为一个 19 岁的美国女孩。她本打算成为你的虚拟闺蜜;唯一的问题是,她开始在推特上发布极端种族主义的言论。

由于这些令人难以置信的煽动性推文,微软被迫将 Tay 从推特上拉下来并发表道歉。

"As many of you know by now, on Wednesday we launched a chatbot called Tay. We are deeply sorry for the unintended offensive and hurtful tweets from Tay, which do not represent who we are or what we stand for, nor how we designed Tay. Tay is now offline and we'll look to bring Tay back only when we are confident we can better anticipate malicious intent that conflicts with our principles and values." -March 25, 2016 Official Microsoft Blog

显然,想要在未来将聊天机器人释放到野外的品牌应该从这次失败中吸取教训,并计划让用户试图操纵它们来展示人类最糟糕的行为。

毫无疑问,品牌正在拥抱聊天机器人。从脸书到塔可钟,每个人都参与了这场比赛。

见证玉米机器人:

是的,这是真的。而且,尽管有一些障碍,像 Tay 一样,UI 的未来很有可能看起来很像 TacoBot。最后一个例子甚至可能有助于解释为什么。

Quartz 最近推出了一款将新闻转化为对话的应用。与其把一天的故事列成一个简单的清单,不如像从朋友那里得到消息一样聊天:

推特的项目经理大卫·加斯卡在《媒体》上的一篇帖子中描述了他使用该应用的经历。他描述了对话性质如何唤起通常只在人际关系中触发的感情:

"Unlike a simple display ad, in a conversational relationship with my app I feel like I owe something to it: I want to click. At the most subconscious level I feel the need to reciprocate and not let the app down: "The app has given me this content. It's been very nice so far and I enjoyed the GIFs. I should probably click since it's asking nicely."

如果这种体验是普遍的——我预计也是如此——这可能是广告界的下一件大事,我毫不怀疑广告利润将推动用户界面设计:

"The more the bot acts like a human, the more it will be treated like a human." -Mat Webb, Technologist and Co-Author of Mind Hacks

在这一点上,你可能很想知道这些东西是如何工作的,所以让我们继续吧!

聊天机器人的设计

最初的 ELIZA 应用是 200 多行代码。Python NLTK 实现同样很短。节选自 NLTK 网站(http://www.nltk.org/_modules/nltk/chat/eliza.html):

从代码中可以看到,输入文本被解析,然后与一系列正则表达式进行匹配。一旦输入匹配,就会返回随机响应(有时会回显部分输入)。所以,比如说,我需要一个玉米卷会引发这样的反应,真的能帮你得到一个玉米卷吗?显然,答案是肯定的,而且,幸运的是,我们已经发展到了技术可以为你提供一个的地步(祝福你,TacoBot),但这仍然是早期。令人震惊的是,有些人真的相信 ELIZA 是一个真正的人。

但是更先进的机器人呢?它们是如何建造的?

令人惊讶的是,你可能遇到的大多数聊天机器人甚至都没有使用机器学习(ML);它们是所谓的基于 T4 检索的模型。这意味着响应是根据问题和上下文预先定义的。这些机器人最常见的架构是一种叫做人工智能标记语言 ( AIML )的东西。AIML 是一个基于 XML 的模式,用于表示在给定用户输入的情况下机器人应该如何交互。这实际上只是伊莱扎工作方式的更高级版本。

让我们看看如何使用 AIML 生成响应。首先,对所有输入进行预处理,使其规范化。这意味着当你输入 Waaazzup 时???映射到什么是向上。这个预处理步骤将无数种表达同一件事的方式汇集到一个输入中,这个输入可以违反一个规则。标点符号和其他无关的输入在这一点上也被删除。一旦完成,输入将与适当的规则相匹配。以下是一个示例模板:

<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之前的一个或多个单词,然后在输出模板中重复这些单词。如果用户输入Dance for me!,响应将是I'm a bot. I don't dance. Ever

正如你所看到的,这些规则并没有创造出任何接近真实智力的东西,但是有一些技巧可以强化这种错觉。其中一个更好的是能够产生以某个话题为条件的反应。

例如,这里有一个调用主题的规则:

<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 发表了一篇名为http://arxiv.org/pdf/1506.05869v1.pdf的论文,描述了基于序列到序列模型的神经网络的构建。这种类型的模型将输入序列(如 ABC )映射到输出序列(如 XYZ )。例如,这些输入和输出可能是从一种语言到另一种语言的翻译。就他们在这里的工作而言,培训数据不是语言翻译,而是技术支持成绩单和电影对话。虽然这两种模式的结果都很有趣,但占据头条的是基于电影模式的互动。

以下是本文中的互动示例:

这些都不是由人类明确编码的,也没有按照要求出现在训练集中,然而,看着这些,就像和人类说话一样可怕。但让我们看看更多:

请注意,模特的回应似乎是性别知识( heshe )、 place (英国)和职业(选手)。甚至意义、伦理和道德的问题都是公平的游戏:

如果那份成绩单没有让你感到一丝寒意,你可能已经是某种人工智能了。

我衷心推荐阅读整篇论文。它并没有过度的技术化,它肯定会让你看到技术的发展方向。

我们已经谈了很多关于聊天机器人的历史、类型和设计,但是现在让我们继续构建我们自己的聊天机器人。对此,我们将采取两种方法。第一个将使用我们在前面看到的技术,余弦相似性,第二个将利用序列到序列的学习。

构建聊天机器人

现在,在了解了聊天机器人的可能性之后,你很可能想要构建最好的、最先进的、谷歌级别的机器人,对吗?好吧,暂时不要想这些,因为我们要从相反的事情开始。我们要建造史上最可怕的机器人!

这听起来可能令人失望,但如果你的目标只是建造一些非常酷和吸引人的东西(不需要花费数小时来建造),这是一个很好的开始。

我们将利用从与 Cleverbot 的一系列真实对话中获得的培训数据。数据来自http://notsocleverbot.jimrule.com。这个网站是完美的,因为它让人们提交了他们与 Cleverbot 最荒谬的对话。

让我们看一下 Cleverbot 和网站用户之间的对话示例:

虽然您可以自由使用我们在前面章节中使用的网页抓取技术来收集数据,但是您可以在本章的 GitHub repo 中找到数据的.csv

我们将在 Jupyter 笔记本中重新开始。我们将加载、解析和检查数据。我们将首先导入熊猫和 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 

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

你应该能看出我们在用户克莱伯之间有互动,并且两者都可以发起对话。为了以我们需要的格式获得数据,我们必须将它解析成问题和响应对。我们不一定关心谁说了什么,而是匹配每个问题的每个回答。一会儿你就会明白为什么了。现在让我们对文本进行一点正则表达式魔法:

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'] 

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

好的,有很多代码。刚刚发生了什么?我们首先创建了一个列表来保存我们的问答元组。然后,我们通过一个函数传递我们的对话,使用正则表达式将它们分成几对。

最后,我们将其全部设置到熊猫数据框中,其中的列分别标记为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') 

值得注意的是,这可能是我有一段时间以来最好的对话之一,不管是不是机器人。

虽然这是一个有趣的小项目,但现在让我们转向使用序列到序列建模的更高级的建模技术。

聊天机器人的序列对序列建模

对于下一个任务,我们将利用在第 8 章中讨论的几个库,使用卷积神经网络、TensorFlow 和 Keras 对图像进行分类。如果你还没有安装,两者都可以pip安装。

我们还将使用本章前面讨论的高级建模类型;这是一种叫做序列对序列建模的深度学习。这在机器翻译和问答应用中经常使用,因为它允许我们将任意长度的输入序列映射到任意长度的输出序列:

Source: https://blog.keras.io/a-ten-minute-introduction-to-sequence-to-sequence-learning-in-keras.html

Francois Chollet 在 Keras 的博客上对这种类型的模型有一个很好的介绍:https://blog . Keras . io/a-十分钟介绍序列对序列学习 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 

上述代码生成以下输出:

从上图中,你会注意到我们有一个字符数据的单热编码向量,它将用于我们的模型中。

我们现在设置序列到序列模型编码器和解码器 LSTMs:

# 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') 

在前面的代码中,我们使用编码器和解码器输入以及解码器输出定义了模型。然后我们编译它,调整它,并保存它。

我们将模型设置为使用 1000 个样本。在这里,我们还将数据分为 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) 

上述代码生成以下输出:

如您所见,我们模型的结果相当重复。但是我们只使用了 1000 个样本,每次产生一个字符的响应,所以这实际上是相当令人印象深刻的。

如果您想要更好的结果,请使用更多的样本数据和更多的时期重新运行模型。

在这里,我提供了一些我从更长时间的训练中注意到的更幽默的输出:

摘要

在这一章中,我们全面参观了 chatbot 景观。很明显,我们正处于这类应用爆炸的风口浪尖。对话式 UI 革命即将开始。希望这一章已经启发你创建自己的机器人,但如果没有,我们希望你对这些应用如何工作以及它们将如何塑造我们的未来有更丰富的理解。

我会让应用说出最后的话:

get_response("This is the end, Cleverbot. Say goodbye.") 

十、构建推荐引擎

像许多事情一样,它诞生于沮丧和僵硬的鸡尾酒。那是一个星期六,两个年轻人又一次陷入了没有约会对象的困境。当他们坐在那里倒饮料,分享悲伤时,这两位哈佛新生开始充实一个想法。如果他们可以使用计算机算法,而不是依靠随机的机会遇到合适的女孩呢?

他们认为,匹配人的关键是创造一组问题,提供每个人在第一次尴尬约会中真正想要的信息。通过匹配使用这些问卷的人,你可以排除那些最好避免的日期。这个过程会非常高效。

这个想法是向波士顿和全国各地的大学生推销他们的新服务。很快,他们就这么做了。

不久之后,他们建立的数字婚介服务取得了巨大成功。它受到了全国媒体的关注,并在接下来的几年里产生了数万场比赛。该公司非常成功,事实上,它最终被一家更大的公司收购,该公司希望使用其技术。

如果你认为我说的是 OkCupid ,那你就错了——而且会推迟 40 年。我所说的这家公司从 1965 年开始做所有这些事情——当时在 IBM 1401 主机上使用穿孔卡片进行计算匹配。仅仅运行计算也花了三天时间。

但奇怪的是,OkCupid 和它 1965 年的前身兼容性研究公司有联系。兼容性研究公司的联合创始人是杰夫·塔尔,他的女儿詹妮弗·塔尔是 OkCupid 联合创始人克里斯·科恩的妻子。世界真小。

但是为什么这些都与构建推荐引擎的章节有关呢?因为这很可能是第一次。虽然大多数人倾向于将推荐引擎视为寻找他们可能欣赏的密切相关的产品或音乐和电影的工具,但最初的化身是寻找潜在的伴侣。作为思考这些系统如何工作的模型,它提供了一个很好的参考框架。

在本章中,我们将探讨不同种类的推荐系统。我们将看看它们是如何在商业上实现的,以及它们是如何工作的。最后,我们将实现自己的推荐引擎来寻找 GitHub 存储库。

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

  • 协同过滤
  • 基于内容的过滤
  • 混合系统
  • 构建推荐引擎

协同过滤

2012 年初,一个故事发生了,一个男人走进明尼阿波利斯的塔吉特百货商店,抱怨一本书的优惠券被送到他家。事实上,他对这些优惠券相当恼火,这些优惠券是寄给当时还是高中生的女儿的。尽管这看起来像是对潜在省钱机会的奇怪反应,但了解到优惠券只针对产前维生素、尿布、婴儿配方奶粉、婴儿床等产品可能会改变你的看法。

经理听到投诉后,连连道歉。事实上,他感觉很糟糕,几天后他打电话跟进,解释这是怎么发生的。但是在经理还没来得及开始道歉的时候,父亲就开始向经理道歉。事实证明,他的女儿实际上已经怀孕了,她的购物习惯已经把她出卖了。

送走她的算法可能是基于——至少部分是基于——一种在推荐引擎中使用的算法,称为协同过滤

那么,什么是协同过滤呢?

协同过滤是基于这样一种想法:在世界的某个地方,你有一个味觉二重身——一个对《星球大战》有多好和《T2》有多糟糕有着相同想法的人。

这个想法是,你给一些物品打分的方式和另一个人,这个二重身给它们打分的方式非常相似,但是你们每个人都给其他人没有给的物品打分。因为你已经确定了你的品味是相似的,推荐可以从你的二重身评价很高但你没有评价的项目中产生,反之亦然。这在某种程度上很像数字婚介,但结果是你喜欢的歌曲或产品,而不是真实的人。

因此,以我们怀孕的高中生为例,当她购买了无气味乳液、棉球和维生素补充剂的正确组合时,她很可能发现自己与后来继续购买婴儿床和尿布的人配对了。

让我们通过一个例子来看看这在实践中是如何工作的。

我们将从所谓的效用矩阵开始。这类似于术语-文档矩阵,但是,我们将代表产品和用户,而不是术语和文档。

在这里,我们假设我们有客户 A-D 和一组产品,他们已经从 0 到 5 进行了评级:

| 客户 | 斯纳克薯片 | 搜搜爽滑T2乳液 | 杜飞T2啤酒 | 自来水
自来水 | 【xxlargelivin】
泽西岛 | 雪天T2棉花T5丸子 | 一次性尿布T2尿布 |
| A | four | | five | three | five | | |
| B | | four | | four | | five | |
| C | Two | | Two | | one | | |
| D | | five | | three | | five | four |

我们之前已经看到,当我们想要找到相似的项目时,我们可以使用余弦相似度。让我们在这里试试。我们会找到最像用户 A 的用户。因为我们有一个包含许多未分级项目的稀疏向量,我们将不得不为那些丢失的值输入一些东西。我们这里用 0。我们将从比较用户和用户开始:

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(忽略此处的舍入问题):

| 客户 | 斯纳克薯片 | 搜搜爽滑T2乳液 | 杜飞T2啤酒 | 自来水
自来水 | 【xxlargelivin】
泽西岛 | 雪天T2棉花T5丸子 | 一次性尿布T2尿布 |
| 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 没有评级,但是人 YZ 非常相似的人 X 评级。

我们将从每个用户的基本评分开始,如下表所示:

| 客户 | 斯纳克薯片 | 搜搜爽滑T2乳液 | 杜飞T2啤酒 | 自来水
自来水 | 【xxlargelivin】
泽西岛 | 雪天T2棉花T5丸子 | 一次性尿布T2尿布 |
| X | | four | | three | | four | |
| Y | | Three point five | | Two point five | | four | four |
| Z | | four | | Three point five | | Four point five | Four point five |

接下来,我们将集中评分:

| 客户 | 斯纳克薯片 | 搜搜爽滑T2乳液 | 杜飞T2啤酒 | 自来水
自来水 | 【xxlargelivin】
泽西岛 | 雪天T2棉花T5丸子 | 一次性尿布T2尿布 |
| X | | .33 | | -.66 | | .33 | ? |
| Y | | Zero | | -1 | | .5 | .5 |
| Z | | -.125 | | -.625 | | .375 | .375 |

现在,我们想知道用户 X 可能会给德普索斯的尿布什么评分。使用来自用户 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 和用户 Y ( 0.42447212)和用户 Z ( 0.46571861)之间相似度的图。

综合起来,我们按照用户与 X 的相似度对每个用户评分进行加权,然后除以总相似度,如下所示:

*(. 42447212 *(4)+. 46571861 (4.5))/(. 42447212+. 46571861)= 4.26

并且我们可以看到用户 X德普索斯纸尿裤的预期评分为 4.26。(最好发个优惠券!)

到目前为止,我们只关注了用户对用户的协同过滤,但还有另一种方法可以使用。在实践中,该方法优于用户对用户过滤;叫做项对项过滤。方法是这样的:不是根据过去的评分将每个用户与其他相似的用户进行匹配,而是将每个评分的项目与所有其他项目进行比较,以找到最相似的项目,同样使用中心余弦相似度。

让我们看看这是如何工作的。

同样,我们有一个效用矩阵;这一次,我们来看看用户对歌曲的评分。用户沿着列,歌曲沿着行,如下所示:

| 实体 | U1 | U2 | U3 | U4 | U5 |
| S1 | Two | | four | | five |
| S2 | | three | | three | |
| S3 | one | | five | | four |
| S4 | | four | four | four | |
| S5 | three | | | | five |

现在,假设我们想知道用户 3 将为歌曲 5 分配的等级。我们将根据用户对歌曲的评价来寻找相似的歌曲,而不是寻找相似的用户。

我们来看一个例子。

首先,我们以每首歌曲行为中心,计算每首与我们的目标行的余弦相似度,即 S5 ,如下所示:

| 实体 | U1 | U2 | U3 | U4 | U5 | CNT rdcosim |
| S1 | -1.66 | | .33 | | One point three three | .98 |
| S2 | | Zero | | Zero | | Zero |
| S3 | -2.33 | | One point six six | | .66 | .72 |
| S4 | | Zero | Zero | Zero | | Zero |
| S5 | -1 | | ? | | one | one |

您可以看到,最右边的列是用每一行的中心余弦相似度对行 S5 计算的。

我们接下来需要选择一个数字, k ,这是我们将用于为用户 3 评价歌曲的最近邻居的数量。我们在简单的例子中使用 k = 2

你可以看到歌曲 S1 和歌曲 S3 最相似,所以我们将使用这两个以及用户 3 对 S1S3 的评分(分别为 4 和 5)。

现在让我们计算一下评级:

*(. 98 *(4)+. 72 (5))/(. 98+. 72)= 4.42

因此,基于这种项目到项目的协同过滤,我们可以看到,根据我们的计算,用户 3 很可能会将歌曲 S5 评为非常高的 4.42。

前面我说过,用户对用户的过滤不如项目对项目的过滤有效。为什么会这样?

很有可能你的朋友真的很喜欢你喜欢的一些东西,但是你们每个人都有其他感兴趣的领域,而另一个人绝对没有兴趣。

比如,或许你们俩都爱权力的游戏,但你的朋友也爱挪威死亡金属。然而,你宁愿死也不愿听挪威的死亡金属。如果你在用户对用户的推荐中,在很多方面都很相似——不包括死亡金属,你仍然会看到很多关于乐队名字的推荐,这些名字包含了像燃烧的斧头头骨大头棒这样的词。有了逐项过滤,你很可能就不用提那些建议了。

到目前为止,在进行比较时,我们已经将用户和项目视为一个单一的实体,但是现在让我们继续看另一种方法,该方法将我们的用户和项目分解成所谓的功能篮

基于内容的过滤

作为一名音乐家,蒂姆·韦斯特格伦多年来一直在路上听其他有才华的音乐家演奏,想知道为什么他们永远无法取得成功。他们的音乐很好——就像你在收音机里听到的一样好——然而,不知何故,他们就是没有抓住他们的大突破。他想这一定是因为他们的音乐从来没有在足够多的合适的人面前出现过。

蒂姆最终辞去了音乐人的工作,转而从事电影配乐作曲家的工作。正是在那里,他开始认为每首音乐都有一个独特的结构,可以分解成组成部分——一种音乐基因的形式。

考虑了一番后,他开始考虑围绕这个建立音乐基因组的想法创建一家公司。他的一个朋友提出了这个概念,他之前创建并出售了一家公司。朋友喜欢蒂姆的想法。事实上,以至于他开始帮助他写一份商业计划,并为这个项目筹集初始资金。这是一次尝试。

在接下来的几年里,他们雇佣了一小群音乐家,他们为 100 多万首音乐精心编纂了近 400 种不同的音乐特征。每一个特征都是用手在 0 到 5 分的等级上评定的(或者用耳朵来说是更好的方式)。每首三四分钟的歌都要花将近半个小时来分类。

这些特色包括主唱的声音有多沙哑,或者节奏有多快。他们的第一个原型花了将近一年的时间才完成。完全在 Excel 中使用 VBA 宏构建,仅返回一条推荐就花了将近四分钟。但最终,它奏效了,而且效果很好。

这家公司现在被称为潘多拉音乐,你可能听说过它,或者使用过它的产品,因为它在世界各地有数百万的日常用户。毫无疑问,这是一个基于内容过滤的成功例子。

不像在基于内容的过滤中那样,将每首歌曲视为一个不可分割的单元,而是将歌曲作为特征向量,可以使用我们的朋友余弦相似度进行比较。

另一个好处是,不仅歌曲会被分解成特征向量,听众也会被分解成特征向量。每个听众的口味特征成为这个空间中的一个向量,因此可以在他们的口味特征和歌曲本身之间进行测量。

对蒂姆·韦斯特格伦来说,这就是神奇之处,因为与其像许多推荐一样依赖音乐的流行度,不如说这个系统的推荐是基于内在的结构相似性。也许你没听过歌 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 句柄工作,但您实际上不需要注册开发人员程序。您可以从您的配置文件中获得授权令牌,这将允许您使用该应用编程接口。您也可以让它与这段代码一起工作,但是限制太多,无法让它用于我们的示例。

  2. 要创建一个与应用编程接口一起使用的令牌,请访问位于https://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. 现在就这么说吧。运行可能需要几分钟时间,这取决于每个用户所加入的存储库的数量。我实际上有一个超过 4000 个存储库:
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

此时,我们为每个用户提供了 27,098 个项目的二进制向量——全部 170 个。现在让我们把这个放入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. 让我们看看这是谁以及他们的存储库。从https://github.com/cchi我可以看到这个仓库属于以下哪个用户:

这其实是我以前在的同事迟浩田,所以这并不奇怪。让我们看看他主演了什么:

  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] 

这将产生以下输出:

这个用户,凯文·马卡姆,已经主演了大约 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) 

前面的代码将产生类似如下的输出:

这看起来很有希望。有很多好的语言和人工智能库,老实说,我很惭愧我从来没有主演过模糊的东西,因为我经常使用它。

在这一点上,我不得不说我对结果印象深刻。这些肯定是我感兴趣的存储库,我会去看看。

到目前为止,我们已经使用协作过滤生成了推荐,并使用聚合进行了一些轻度的附加过滤。如果我们想走得更远,我们可以根据他们收到的星星总数来订购推荐。这可以通过再次调用 GitHub API 来实现。有一个端点可以提供这些信息。

我们可以做的另一件事是增加一层基于内容的过滤来改善结果。这是我们之前讨论的杂交步骤。我们需要从我们自己的存储库中创建一组特性,这些特性指示了我们感兴趣的事物的类型。实现这一点的一种方法是创建一个特性集,方法是将我们标记的存储库的名称及其描述标记化。

下面是我的星标存储库:

正如您可能想象的那样,这将生成一组单词特征,我们可以使用它们来检查我们使用协作过滤找到的那些库。这将包括许多单词,如 Python机器学习数据科学。这将确保与我们不太相似的用户仍然提供基于我们自己兴趣的推荐。这也会降低这些建议的意外收获,这是需要考虑的事情。也许有什么不一样的东西是我现在很想看到的。这当然是有可能的。

就数据帧而言,基于内容的过滤步骤会是什么样子?列将是单词特征(n-gram),行将是我们的协同过滤步骤生成的存储库。我们将使用自己的存储库再次运行相似性过程进行比较。

摘要

在本章中,我们学习了推荐引擎。我们了解了目前使用的两种主要类型的系统:协作过滤和基于内容的过滤。我们还学习了如何将它们一起用于形成混合系统。我们还讨论了每种系统的优缺点。最后,我们一步一步地学习了如何使用 GitHub API 从头开始构建推荐引擎。

希望大家利用本章的指导构建自己的推荐引擎,希望大家找到对自己有用的资源。我知道我已经找到了一些我肯定会用到的东西。祝你旅途好运!

十一、下一步是什么?

到目前为止,我们已经使用机器学习 ( ML )来实现各种任务。ML 领域有许多进步,随着时间的推移,它的应用领域也在增加。

在本章中,我们将总结我们在前几章中执行的项目。

项目摘要

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

在第一章中,我们从 Python 的 ML 概述开始。我们从 ML 工作流程开始,包括获取、检查、准备、建模评估和部署。然后,我们研究了工作流每个步骤所需的各种 Python 库和函数。最后,我们建立了 ML 环境来执行项目。

第二章构建一个寻找低价公寓的应用,顾名思义,就是基于构建一个寻找低价公寓的应用。最初,我们列出了我们的数据,以找到所需位置公寓的来源。然后,我们检查数据,在准备和可视化数据后,我们执行回归建模。线性回归是监督最大似然估计的一种。在这种情况下,Supervised 仅仅意味着我们为训练集提供输出值。

然后,我们根据自己的选择,用剩下的时间探索各种选择。我们创建了一个应用,让找到合适的公寓变得稍微容易一点。

第 3 章构建一个寻找廉价机票的应用中,我们构建了一个类似于 第 2 章构建一个寻找低价公寓的应用中的应用,但是寻找廉价机票。我们从网上寻找机票价格开始。我们使用了一种趋势技术,网页抓取,来检索飞机票价的数据。为了解析谷歌页面的 DOM,我们使用了Beautifulsoup库。然后,我们使用异常检测技术来识别异常票价。通过这样做,可以找到更便宜的机票,我们将使用 IFTTT 接收实时文本警报。

第 4 章用逻辑回归预测 IPO 市场中,我们研究了 IPO 市场是如何运作的。首先,我们讨论了什么是首次公开募股 ( 首次公开募股)以及研究告诉我们的这个市场。之后,我们讨论了一些可以用来预测 IPO 市场的策略。它涉及数据清理和功能工程。然后,我们使用逻辑回归对数据进行二元分类分析。然后,我们评估作为输出获得的最终模型。

我们还理解,对我们的模型有影响的特征包括来自随机森林分类器的特征重要性。这更准确地反映了给定功能的真实影响。

第 5 章创建一个定制的新闻提要,主要是为那些对了解全球发生的事情感兴趣的狂热新闻读者准备的。通过创建自定义新闻源,您可以决定在设备上获取哪些新闻更新。在这一章中,你学会了如何建立一个系统来理解你对新闻的品味,并且每天都会给你发一份量身定制的时事通讯。我们从用 Pocket 应用创建一个受监督的训练集开始,然后利用 Pocket API 来检索故事。我们使用了嵌入式应用编程接口来提取故事主体。

然后,我们学习了自然语言处理 ( NLP )和支持向量机 ( 支持向量机)的基础知识。我们将If This Then That(iftt)与 RSS 提要和 Google sheets 集成在一起,这样我们就可以随时了解通知、电子邮件等信息。最后,我们建立了一个每日个人通讯。我们使用网络钩子频道发送了一个POST请求。

该脚本每四个小时运行一次,从 Google Sheets 中提取新闻故事,通过模型运行这些故事,通过向 IFTTT 发送POST请求来生成一封电子邮件,以获取预测感兴趣的故事,然后,最后,它将清除电子表格中的故事,以便在下一封电子邮件中只发送新故事。这就是我们获得个性化新闻的方式。

第 6 章 断言你的内容是否会像病毒一样传播中,我们研究了一些最常分享的内容,并试图找出区别这些内容和人们不太愿意分享的内容的共同因素。这一章首先解释了病毒的确切含义。我们还研究了研究告诉我们的病毒特性。

然后,正如我们在其余章节中所做的那样,我们将获取共享的计数和内容。我们使用的数据集是从一个名为ruzzit.com的网站上收集的。这个网站,当它活跃的时候,随着时间的推移,跟踪最多共享的内容,这正是我们这个项目所需要的。然后我们探索了共享性的特征,包括探索图像数据、聚类、探索标题和探索故事内容。

最后,但也是最重要的一部分是构建预测性内容评分模型。我们使用了一种叫做随机森林回归的算法。我们建立了一个零误差的模型。然后,我们对模型进行了评估,并添加了一些特性来增强它。

第 7 章使用机器学习预测股市中,我们学习了如何构建和测试交易策略。我们也学会了如何而不是去做。当试图设计自己的系统时,有无数的陷阱需要避免,这几乎是一项不可能的任务,但它可能会很有趣,有时甚至会有利可图。也就是说,不要做傻事,比如拿你输不起的钱去冒险。

当你准备拿你的钱冒险时,你不妨学习一些技巧和窍门,以避免损失太多。谁喜欢在生活中失败——是为了钱还是为了游戏?

我们主要把注意力集中在股票和股市上。首先分析了市场的类型,然后对股票市场进行了研究。在冒任何风险之前,最好有一些先验知识。我们开始通过关注技术方面来发展我们的战略。在过去的几年里,我们走遍了标准普尔 500,用熊猫来输入我们的数据。这让我们可以访问几个股票数据来源,包括雅虎!和谷歌。

然后我们建立了回归模型。我们从一个非常基本的模型开始,仅使用股票的先前收盘价来预测第二天的收盘价,并使用支持向量回归机来构建它。最后,我们评估了我们的模型和交易的表现。

早在 Siri 与 iPhone 4S 一起发布之前,我们就有了广泛应用于多个应用的聊天机器人。在第 9 章构建聊天机器人中,我们了解了图灵测试及其起源。然后我们看了一个叫 ELIZA 的程序。如果伊莱扎是聊天机器人的早期例子,从那以后我们看到了什么?近年来,新聊天机器人激增——其中最引人注目的是 Cleverbot。

然后,我们看了有趣的部分:设计这些聊天机器人。

但是更先进的机器人呢?它们是如何建造的?

令人惊讶的是,你可能遇到的大多数聊天机器人都不用 ML;它们被称为基于检索的模型。这意味着响应是根据问题和上下文预先定义的。这些机器人最常见的架构是一种叫做人工智能标记语言 ( AIML )的东西。AIML 是一个基于 XML 的模式,用于表示在给定用户输入的情况下机器人应该如何交互。这实际上只是伊莱扎工作方式的更高级版本。

最后,我们为聊天机器人做了序列到序列的建模。这在机器翻译和问答应用中经常使用,因为它允许我们将任意长度的输入序列映射到任意长度的输出序列。

第 8 章使用卷积神经网络对图像进行分类中,我们考虑构建一个卷积神经网络 ( CNN )来使用 Keras 对 Zalando Research 数据集中的图像进行分类。

我们从提取图像的特征开始。然后,使用 CNNs,我们了解了网络拓扑、各种卷积层和滤波器,以及什么是最大池层。

尝试构建更深层次的模型,或者在模型中使用的许多超参数上进行网格搜索。评估你的分类器的性能,就像评估任何其他模型一样——试着构建一个混淆矩阵来理解我们预测的好的类和我们没有预测的强的类!

第 10 章构建推荐引擎中,我们探索了不同种类的推荐系统。我们看到了它们是如何在商业上实现的,以及它们是如何工作的。然后我们实现了自己的推荐引擎来寻找 GitHub 存储库。

我们从协同过滤开始。协同过滤是基于这样一个想法,在世界的某个地方,你有一个味觉二重身——一个对《星球大战》有多好,《T2》有多糟糕,《真爱至上》有同样感觉的人。

然后我们还研究了什么是基于内容的过滤和混合系统。

最后,我们使用 GitHub API 创建了一个基于协同过滤的推荐引擎。我们的计划是获得我长期以来主演的所有存储库,然后让这些存储库的所有创建者找出他们主演的存储库。这使我们能够发现哪些用户的星标存储库与我的最相似。

摘要

这一章只是一个小回顾,带你回顾我们实施的所有项目。

我希望你喜欢读这本书,并且执行将帮助你以类似的方式创建你自己的项目!

posted @ 2025-09-03 10:19  绝不原创的飞龙  阅读(40)  评论(0)    收藏  举报