深入浅出数据科学-全-

深入浅出数据科学(全)

原文:zh.annas-archive.org/md5/0687660a89e07af03df6e505d0ced1e8

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

几年前,Google 的首席经济学家 Hal Varian 自信地声称:“未来 10 年最吸引人的工作将是统计学家。”自他发表这个言论以来,发生了两件事:我们开始称统计学家为数据科学家,这个职业不仅需求急剧增长,而且薪资水平也大幅提高。

熟练的数据科学家供给未能跟上需求。本书的部分目的是通过介绍当今顶尖公司使用的所有主要数据科学技术,帮助解决这个问题。每个例子都配有详尽的代码说明,并且我们还提供了关于各种数据科学方法如何应用以及如何找到创造性解决方案的思路。本书旨在让任何阅读它的人都能掌握成为数据科学家的技能,并应对当今企业面临的最艰难、最激动人心的挑战。

但是,数据科学不仅仅是一个职业机会。它是一个广泛的领域,结合了统计学、软件开发、数学、经济学和计算机科学的元素。它使你能够分析数据,检测群体之间的差异,调查神秘现象的原因,分类物种,并进行实验——换句话说,就是做科学。任何对发现难以理解事物的真相感到兴奋,或者希望更好地理解这个世界的人,都应该对数据科学的这一方面感到激动。

简而言之,数据科学几乎可以为每个人提供一些东西。它可以帮助你解决商业问题并让你的生意更成功。它可以让你更像一个科学家,更好地观察和清晰地理解周围的世界。它可以提高你的分析能力和编程技能。而且,它还可以很有趣。成为数据科学家意味着加入一个不断增长和扩展的领域,这意味着你每天都需要扩展自己的知识和技能。如果你准备好迎接学习广泛新技能的挑战,这些技能将帮助你工作得更好、思考得更好,并获得一个“吸引人的”工作,那就继续阅读吧。

本书适合谁阅读?

尽管我们会用通俗的语言解释每段代码,以使本书对没有 Python 经验和编程经验的人也能易于理解,但对于那些至少具备一些编程基础理解——如变量赋值、for循环、if...then语句和函数调用等——的人来说,将最能从本书内容中受益。

本书是针对以下群体编写的:

有志成为数据科学家的人员

  1. 现在,似乎每个人都想成为数据科学家,每家公司也都希望雇佣数据科学家。本书将帮助刚刚进入职场的初学者获得从事数据科学领域所需的技能。它也能帮助已经从事其他职业的人,想要横向转型成为数据科学家,或者在当前岗位上做更多数据科学工作。

学生

  1. 本书适用于本科水平的数据科学入门课程,或者供有兴趣的学生独立阅读。

专业人士

  1. 包括项目经理、执行级领导、开发人员和一般商业人士在内的多种专业人员,都能从了解数据科学家同事的日常工作中受益。本书所授技能能帮助他们与数据科学家更有效地合作。

感兴趣的业余爱好者

  1. 你不必仅仅为了职业晋升而阅读本书。数据科学是一个新兴且令人兴奋的领域,任何感兴趣的业余爱好者都会觉得本书既有趣又具有启发性。

关于本书

本书介绍了世界顶级企业中数据科学家最常用的所有技术。你还将了解数据科学如何创意性地应用于各行各业的问题。以下是各章内容的组织概述。

第一章:探索性数据分析 解释了每个数据科学问题的第一步:探索数据,包括在 Python 中读取数据、计算汇总统计、可视化数据以及发掘常识性见解。

第二章:预测 介绍线性回归,这是一种流行的统计技术,可以用来确定定量变量之间的关系,甚至预测未来。

第三章:组间比较 探讨假设检验,这是比较组别测量值的标准统计方法。

第四章:A/B 测试 讨论如何通过实验来确定哪些商业实践最有效。

第五章:二分类 介绍了逻辑回归,这是一种简单的机器学习技术,用于将数据分类为两类。

第六章:监督学习 深入探讨几种用于预测的机器学习方法,包括决策树、随机森林和神经网络。

第七章:无监督学习 介绍两种聚类方法,另一种机器学习类型,可用于在无标签数据中找到自然的群体。

第八章:网页抓取 解释了自动从面向公众的网站下载数据的方法。

第九章:推荐系统 讨论如何构建一个能够自动向客户推荐产品的系统。

第十章:自然语言处理 探讨一种将文本转换为定量向量的高级方法,这些向量可用于各种数据科学分析。

第十一章:其他语言中的数据科学 介绍了 R 和 SQL,这两个语言通常用于数据科学应用。

设置环境

我们将使用 Python 语言实现本书中描述的算法。Python 是免费的开源软件,可以在所有主流平台上运行。你可以使用以下步骤在 Windows、macOS 和 Linux 上安装 Python。

Windows

在 Windows 上安装 Python,按以下步骤操作:

  1. 打开专门为最新版本的 Windows Python 页面:www.python.org/downloads/windows/(确保包括最后的斜杠)。

  2. 点击你想下载的 Python 版本的链接。要下载最新版本,请点击链接最新 Python 3 版本 - 3.X.Y,其中3.X.Y是最新的版本号,如 3.10.4。书中的代码在 Python 3.8 上进行了测试,并应与后续版本兼容。如果你想下载旧版本,可以滚动到页面底部的“稳定版本”部分,找到你想要的版本。

  3. 第 2 步中点击的链接会将你带到一个专门为你选择的 Python 版本页面。在“文件”部分,点击Windows 安装程序(64 位)链接。

  4. 第 3 步中的链接会下载一个.exe文件到你的计算机。这是一个安装程序文件;双击它以打开。它将自动执行安装过程。勾选将 Python 3.X 添加到 PATH框,其中X是你下载的安装程序的版本号,如 10。之后,点击立即安装并选择默认选项。

  5. 当你看到安装成功消息时,点击关闭以完成安装过程。

现在,计算机上有一个新的应用程序。它的名字是 Python 3.X,其中X是你安装的 Python 3 的版本。在 Windows 搜索栏中输入Python,然后点击出现的应用程序。这将打开一个 Python 控制台。你可以在这个控制台中输入 Python 命令,并且它们会在这里执行。

macOS

在 macOS 上安装 Python,按以下步骤操作:

  1. 打开专门为最新版本的 macOS Python 页面:www.python.org/downloads/mac-osx/(确保包括最后的斜杠)。

  2. 点击你想下载的 Python 版本的链接。要下载最新版本,请点击链接最新 Python 3 版本 - 3.X.Y,其中 3.X.Y是最新的版本号,如 3.10.4。书中的代码在 Python 3.8 上进行了测试,并应与后续版本兼容。如果你想下载旧版本,可以滚动到页面底部的“稳定版本”部分,找到你想要的版本。

  3. 第 2 步中点击的链接会将你带到一个专门为最新 Python 版本的页面。在“文件”部分,点击macOS 64 位安装程序链接。

  4. 第三步中的链接会将一个 .pkg 文件下载到你的计算机上。这是一个安装文件;双击它以打开并执行安装过程,选择默认选项。

  5. 安装程序会在你的计算机上创建一个名为 Python 3.X 的文件夹,其中 X 是你安装的 Python 版本号。在这个文件夹中,双击 IDLE 图标。这将打开 Python 3.X.Y Shell,其中 3.X.Y 是你的版本号。这是一个 Python 控制台,你可以在这里运行任何 Python 命令。

Linux

要在 Linux 上安装 Python,请按以下步骤操作:

  1. 确定你的 Linux 版本使用的是哪个包管理器。两种常见的包管理器是 Yum 和 APT。

  2. 打开 Linux 控制台(也叫做 终端),并执行以下两个命令:

    > `sudo apt-get update`
    > `sudo apt-get install python3.11`
    

    如果你使用的是 Yum 或其他包管理器,请将这两行中的 apt-get 替换为 yum 或你的包管理器名称。同样,如果你想安装不同版本的 Python,请将 3.11(本书撰写时的最新版本号)替换为其他版本号,比如 3.8,这是本书中用于测试代码的版本之一。要查看最新版本的 Python,请访问 www.python.org/downloads/source/。在那里,你会看到一个 Latest Python 3 Release - Python 3.X.Y 的链接,其中 3.X.Y 是发布号;使用前两个数字(例如,3 和 11)来更新之前的安装命令。

  3. 通过在 Linux 控制台中执行以下命令来运行 Python:

    > `python3`
    

    Python 控制台会在 Linux 控制台窗口中打开。你可以在这里输入 Python 命令。

使用 Python 安装包

当你按照上一节中的步骤安装 Python 时,你安装的是 Python 标准库,或者更通俗地说,是 基本 Python。基本 Python 使你能够运行简单的 Python 代码,并且包含 Python 语言内置的标准功能。基本 Python 非常强大,你可以用它做很多了不起的事情。但它并不能做所有事情,这就是为什么 Python 社区中那些友善且才华横溢的人会创建 Python 包的原因。

Python (也称为 Python )是对基本 Python 的附加功能,它们提供了基本 Python 中没有的额外功能。例如,你可能会发现有一些数据以 Microsoft Excel 格式存储,而你希望编写 Python 代码来读取这些 Excel 数据。在基本 Python 中没有直接的方法做到这一点,但一个名为 pandas 的包使你能够轻松地在 Python 中读取 Excel 文件。

要使用 pandas 包,或任何其他 Python 包,你需要先安装它。安装基本的 Python 时,会默认安装一些包,但大多数包需要手动安装。要手动安装任何 Python 包,你需要使用 Python 的标准包安装工具 pip。

如果你已经安装了基础的 Python,安装 pip 就非常简单。首先,你需要从 bootstrap.pypa.io/get-pip.py 下载一个 Python 脚本。正如这个脚本的名字所示,它是一个帮助你安装 pip 的脚本。现在,你需要运行这个脚本。如何运行这个脚本将取决于你使用的操作系统:

  • 如果你使用的是 Windows,你需要打开命令提示符。你可以通过点击开始按钮并在搜索框中输入cmd来做到这一点。命令提示符程序将会作为建议出现在开始菜单中,你可以点击它来打开命令提示符。

  • 如果你使用的是 macOS,你需要打开终端。要打开终端,可以打开 Finder,进入/Applications/Utilities 文件夹,然后双击终端

  • 如果你使用的是 Linux,你需要打开终端。大多数 Linux 发行版默认会在桌面上提供快捷方式来打开终端。

打开命令提示符(Windows)或终端(macOS 或 Linux)后,你需要运行以下命令:

> **python3 get-pip.py**

这将会在你的电脑上安装 pip。如果你在运行这个命令时遇到错误,可能是因为你的 get-pip.py 文件存储的位置 Python 无法找到。更具体地指定文件位置通常会有所帮助,因此你可以尝试使用如下命令:

> **python3 C:/Users/AtticusFinch/Documents/get-pip.py**

在这里,我们指定了一个文件路径(C:/Users/AtticusFinch/Documents/get-pip.py),它告诉 Python 到哪里查找 get-pip.py 文件。你应该根据你自己电脑上 get-pip.py 文件的位置修改这个文件路径;例如,你可能需要将 AtticusFinch 更改为你自己的名字。

安装 pip 后,你就能使用它来安装任何其他的 Python 包。例如,如果你想安装 pandas 包,可以使用以下命令安装:

> **pip install pandas**

你可以将 pandas 替换为任何你想要安装的 Python 包的名称。在安装 pandas 或其他任何 Python 包后,你就可以在 Python 脚本中使用它。我们将在第一章中详细讨论如何在 Python 中使用包。

其他工具

前面的章节包含了如何安装 Python 和如何手动安装 Python 包的详细信息。如果你能做这两件事,你就能运行本书中的所有代码。

一些 Python 用户倾向于使用其他工具来运行 Python 代码。例如,一个叫做 Anaconda 的流行工具也可以免费使用,并允许你在数据科学领域运行 Python 代码。Anaconda 包括了基础的 Python,以及许多流行的包和其他功能。如果你想免费下载安装 Anaconda,可以访问其网页:www.anaconda.com/products/distribution。你可能会发现它很合适,并且能够用它运行本书中的任何代码,但这并不是必需的。

Jupyter 项目提供了一套流行的工具集,用于运行 Python 代码。你可以访问其网站 jupyter.org/ 了解其最受欢迎的工具:JupyterLab 和 Jupyter Notebook。这些工具允许用户在高度可读、交互式、可共享且用户友好的环境中运行 Python 代码。本书中的所有 Python 代码都经过 Jupyter 测试,但运行本书中的代码并不需要 Jupyter 或 Anaconda:只需要基础的 Python 和 pip(如前面的章节所描述)即可。

概述

数据科学可以赋予你像魔法一样的能力:预测未来的能力、增加利润的能力、自动收集巨大数据集的能力、将文字转化为数字的能力,等等。学习如何将这些技能做到极致并不容易,需要认真的学习才能达到最先进的水平。但学习数据科学的难度也可能带来巨大的回报,如果你掌握了本书中的技能,你不仅能够在数据科学领域取得成功,还能收获不少乐趣。本书是数据科学主要思想及其在商业中应用的入门介绍——它将帮助你开启成为任何领域专家数据科学家的旅程。

第一章:探索性数据分析

这是一本数据科学书籍,因此让我们从数据入手。这是你应该习惯的做法:每一个数据科学问题的第一步就是探索数据。仔细查看数据的每一个细节将帮助你更好地理解它,并为下一步行动和更复杂的分析提供更清晰的思路。这也将帮助你尽早发现数据中的任何错误或问题。数据科学过程的这几个初步步骤被称为探索性数据分析

我们将通过介绍一个商业场景并描述如何利用数据更好地运营企业来开始这一章。我们将讨论如何在 Python 中读取数据并检查基本的汇总统计信息。接着,我们将介绍一些 Python 工具来创建数据图表。我们将探讨可以执行的简单探索性分析,并讨论它们能为我们回答哪些问题。最后,我们将讨论这些分析如何帮助我们改进商业实践。本章中的简单分析正是你在处理任何数据科学问题时可以作为第一步进行的分析。让我们开始吧!

你作为首席执行官的第一天

假设你收到了一个工作邀请,成为华盛顿特区一家提供自行车租赁服务的公司的首席执行官。这家公司允许人们在短时间内租用自行车在城市中骑行。尽管你没有运营共享单车公司的经验,但你还是接受了这个邀请。

你第一天到公司上班,开始思考作为首席执行官的商业目标。你可能会考虑的一些目标与客户满意度、员工士气、品牌认知度、市场份额最大化、成本降低或收入增长等问题相关。你如何决定应该首先追求哪些目标,又该如何去实现它们呢?例如,考虑提高客户满意度。在专注于这一目标之前,你需要先弄清楚客户是否满意,如果不满意,需要找出导致满意度下降的原因以及如何改进。或者,假设你更关心的是增加收入。那么,你需要知道现在的收入状况,才能想出如何增加它。换句话说,在你更好地了解你的公司之前,你无法决定最初应该集中精力在哪个方向。

如果你想了解你的公司,你需要数据。你可能会尝试查看一些总结公司数据的图表和报告,但没有任何一份准备好的报告能够告诉你像你亲自深入数据所能学到的东西那么多。

在数据集中寻找模式

让我们来看一些来自实际共享单车服务的数据,并假设这些数据来自你的公司。你可以从bradfordtuckfield.com/hour.csv下载这些数据。(这个文件使用的是一种特殊的格式,叫做.csv,我们很快会详细讨论。)你可以在电子表格编辑器(如 Microsoft Excel 或 LibreOffice Calc)中打开这个文件;你应该会看到类似图 1-1 的内容。

图 1-1:共享单车数据,显示在电子表格中

这个数据集和你以前可能见过的许多其他数据集没有什么不同:一个由行和列组成的矩形数组。在这个数据集中,每一行代表了 2011 年 1 月 1 日午夜至 2012 年 12 月 31 日晚上 11:59 之间某一特定小时的信息——总共有超过 17,000 个小时。这些行是按顺序排列的,因此前几行提供了 2011 年最初几个小时的信息,而最后几行则与 2012 年最后几个小时相关。

每一列包含了这些小时内测量的某个特定指标。例如,windspeed列给出了位于华盛顿特区的某个气象记录站的每小时风速测量值。请注意,这个测量值并不是使用我们熟悉的单位(如英里每小时)。相反,测量值已经被转换,使得它们始终介于 0 和 1 之间;我们只需要知道 1 代表高速风,0 代表没有风。

如果你查看前几行,你会发现windspeed值在这些行中为0,这意味着在共享单车服务的最初几个小时里没有测量到风速。在第七行(把标题行算作第一行)中,你可以看到终于有了风,其测量风速为 0.0896。如果你查看hr列,你会发现这个风速是在hr = 5时记录的,即早上 5 点。我们知道这一行给出的信息是关于 2011 年 1 月 1 日的,因为第七行的dteday列的值为2011-01-01

仅仅通过查看数据中的几个值,我们已经可以开始讲述一个故事,尽管这个故事并不激动人心:一个宁静的元旦夜晚,逐渐变成了一个稍微不那么安静的元旦早晨。如果我们想了解一些关于共享单车公司及其表现的故事,而不仅仅是天气情况,我们需要查看其他更相关的列。

最重要的信息列是最后三列:casualregisteredcount。这些列显示了每小时使用你公司自行车的人数。注册并享受折扣和福利的用户是注册用户,他们的自行车使用记录在registered列中。但人们也可以不注册就使用你的自行车,他们的自行车使用记录在casual列中。casualregistered两列的总和即为每小时的总用户数,记录在count列中。

现在,既然你已经熟悉了数据集中一些更相关的列,你可以通过简单地浏览它们的数字学到很多东西。例如,看看图 1-1 中显示的前 20 个小时,你可以发现,在大多数小时内,注册用户数量多于临时用户数量(registered列的值高于casual列的值)。这只是一个简单的数字事实,但作为 CEO,你应该思考它对你的业务的含义。注册用户多于临时用户可能意味着你在说服人们注册方面做得很好,但也可能意味着在不注册的情况下随便使用你的服务并不像它应该那么容易。你需要思考一下,哪个客户群体更重要:像日常通勤者这样的常规注册用户,还是像观光游客这样的偶尔使用者。

我们可以更仔细地观察日常用户和注册用户的行为模式,看看是否能从中学到更多信息。我们再来看一下图 1-1 中显示的小时数。我们看到,临时用户在第一天的下午之前相对稀少,并在下午 1 点时达到峰值。注册用户即使在第一天的凌晨 1 点也相对较多,并在下午 2 点时达到峰值。注册用户和临时用户之间的行为差异虽小,但可能具有意义。例如,它们可能反映了这两个群体之间的人口统计差异。这反过来可能要求使用不同的营销策略,分别针对每个群体。

考虑一下我们已经做过的事情:仅仅通过查看前 24 行数据中的几列,我们就已经了解了关于公司的一些重要信息,并开始获得一些商业想法。数据科学有着需要精通复杂数学和计算机科学的神秘知识的声誉,但实际上,只需稍微浏览一下数据集,稍加思考,并运用常识,就可以在提升任何商业场景方面发挥很大作用。

使用 .csv 文件来回顾和存储数据

让我们更仔细地查看我们的数据。如果你在电子表格编辑器中打开数据文件(hour.csv),它将像图 1-1 所示。但是,你也可以在文本编辑器中打开此文件,如 Windows 中的记事本、macOS 中的 TextEdit,或 Linux 中的 GNU Emacs 或 gedit。当你在文本编辑器中打开此文件时,它将像图 1-2 所示。

图 1-2:共享单车数据以原始文本显示

这些原始数据(即每个文本字符)构成了我们的hour.csv文件,没有你在电子表格中看到的直线对齐的列。注意到许多逗号。这个文件的扩展名,.csv,是逗号分隔值的缩写,因为每行中的数值是通过逗号与其他数值分隔的。

当你使用电子表格编辑器打开.csv文件时,编辑器会尝试将每个逗号解释为电子表格单元格之间的边界,以便将数据按直线对齐显示在行和列中。但数据本身并不是那样存储的:它仅仅是原始文本,包含一行行的数值,每个数值之间用逗号分隔。

.csv文件的简易性意味着它们可以轻松创建、被多种程序打开并轻松修改。这就是为什么数据科学家通常将数据存储为.csv格式的原因。

使用 Python 显示数据

使用 Python 将使我们能够进行比文本编辑器和电子表格程序更复杂的分析。它还将使我们能够自动化流程,更快速地运行分析。我们可以轻松地在 Python 中打开.csv文件。以下三行 Python 代码将读取hour.csv文件到你的 Python 会话中并显示其前五行:

import pandas as pd
hour=pd.read_csv('hour.csv')
print(hour.head())

我们稍后会更仔细地查看这段代码的输出。现在,让我们先看一下代码本身。它的目的是读取并显示我们的数据。第二行通过使用read_csv()方法读取数据。方法是执行单一、明确功能的代码单元。顾名思义,read_csv()专门用于读取存储在.csv文件中的数据。运行这一行后,hour变量将包含hour.csv文件中的所有数据;然后你就可以在 Python 中访问这些数据。

在第三行,我们使用print()函数将数据打印到屏幕上。我们可以将第三行改为print(hour),以查看整个数据集的输出。但是数据集可能非常庞大,一次性读取很难理解。因此,我们添加了head()方法,因为它仅返回数据集的前五行。

read_csv()head()对我们非常有用。但它们并不是Python 标准库的一部分——即默认安装的标准 Python 功能。它们实际上是某个包的一部分,包是第三方的代码库,可以选择安装并在 Python 脚本中使用。

这两种方法是一个叫做pandas的流行包的一部分,里面包含了处理数据的代码。这就是为什么前一个代码片段的第一行是 import pandas as pd:它导入了 pandas 包,这样我们就可以在 Python 会话中访问它。当我们写as pd时,这为该包指定了一个别名,这样每次我们想访问 pandas 的功能时,都可以写pd,而不是完整的包名 pandas。因此,当我们写pd.read_csv()时,我们是在访问 pandas 包中的read_csv()方法。

如果你在运行import pandas as pd时遇到错误,可能是因为你的计算机上没有安装 pandas。(在你导入包之前,必须先安装它们。)要安装 pandas 或任何其他 Python 包,你应该使用标准的 Python 包安装工具——pip。你可以在本书的介绍部分找到如何安装 pip 并使用它安装像 pandas 这样的 Python 包的说明。在本书中,每次导入一个包时,你都应该确保首先使用 pip 在你的计算机上安装它。

当你运行这个代码片段时,可能会遇到另一个错误。最常见的错误之一是当 Python 无法找到hour.csv文件时。如果发生这种情况,Python 会打印出错误报告。错误报告的最后一行可能会这样写:

FileNotFoundError: [Errno 2] No such file or directory: 'hour.csv'

即使你不是 Python 专家,你也可以推测出这意味着什么:Python 尝试读取hour.csv文件,但未能找到它。这可能是一个令人沮丧的错误,但也是可以解决的。首先,确保你已经下载了hour.csv文件,并且它在你的计算机上也叫hour.csv。计算机上的文件名必须与 Python 代码中的文件名完全一致。

如果你的 Python 代码中hour.csv的名称拼写正确(完全小写字母),那么问题可能出在文件的位置。记住,每个文件在你的计算机上都有一个唯一的文件路径,指定了你需要导航到的位置。一个文件路径可能是这样的:

C:\Users\DonQuixote\Documents\hour.csv

这个文件路径是 Windows 操作系统中使用的格式。如果你使用的是 Windows,尽量确保你的目录和文件名不包含任何特殊字符(例如非英语字母表的字符),因为包含特殊字符的文件路径可能导致错误。下面是另一个文件路径的例子,它采用的是 Unix 风格操作系统(包括 macOS 和 Linux)使用的格式:

/home/DonQuixote/Documents/hour.csv

你会注意到,Windows 文件路径与 macOS 和 Linux 的文件路径不同。在 macOS 和 Linux 中,我们只使用正斜杠,并且从斜杠(/)开始,而不是像 C:\ 这样的驱动器名称。当你将文件读入 Python 时,避免错误的最直接方法是指定完整的文件路径,如下所示:

import pandas as pd
hour=pd.read_csv('/home/DonQuixote/Documents/hour.csv')
print(hour.head())

当你运行这段代码时,可以将read_csv()方法中的文件路径替换为自己计算机上的文件路径。当你运行前面的代码片段,并正确指定文件路径以匹配计算机上hour.csv的位置时,你应该会得到以下输出:

 instant      dteday  season  yr  ...  windspeed  casual  registered count
0        1  2011-01-01       1   0  ...        0.0       3          13    16
1        2  2011-01-01       1   0  ...        0.0       8          32    40
2        3  2011-01-01       1   0  ...        0.0       5          27    32
3        4  2011-01-01       1   0  ...        0.0       3          10    13
4        5  2011-01-01       1   0  ...        0.0       0           1     1

[5 rows x 17 columns]

该输出显示了我们数据的前五行。你可以看到数据按列排列,类似于我们的电子表格输出。就像在图 1-1 中一样,每一行包含与共享单车公司某一特定小时历史相关的数值。

在这里,我们看到一些列被省略号替代,这样更容易在屏幕上阅读,也不会太难阅读或复制粘贴到文本文件中。(你可能看到的是所有的列,而不是省略号——具体显示取决于 Python 和 pandas 在你计算机上的配置情况。)就像我们在电子表格编辑器中打开文件时做的那样,我们可以开始查看这些数字,发现关于公司历史的故事,并获得经营业务的灵感。

计算汇总统计

除了仅仅查看数据,量化其重要属性会非常有帮助。我们可以从计算某一列的均值开始,如下所示:

print(hour['count'].mean())

在这里,我们通过使用方括号([])和列名(count)来访问hour数据集中的count列。如果你单独运行print(hour['count']),你会看到整列数据被打印到屏幕上。但我们只想要列的均值,而不是整列数据,因此我们添加了mean()方法——这是 pandas 提供的另一个功能。我们看到均值大约是 189.46。这从商业角度来看很有趣,它是一个粗略的度量,表示覆盖数据中两年时间段的业务规模。

除了计算均值,我们还可以计算其他重要的指标,如下所示:

print(hour['count'].median())
print(hour['count'].std())
print(hour['registered'].min())
print(hour['registered'].max())

在这里,我们通过使用median()方法计算count列的中位数。我们还使用std()方法计算count变量的标准差。(你可能已经知道,标准差是一个测量一组数字分布范围的指标。它有助于我们理解数据中不同时段之间骑行人数的波动程度。)我们还使用min()max()方法分别计算registered变量的最小值和最大值。注册用户的数量从 0 到 886 不等,这告诉我们你设定的小时记录,以及如果你希望你的业务超过历史最佳成绩,你需要打破的记录。

这些简单的计算被称为汇总统计,它们对于你处理的每一个数据集都很有用。检查数据集的汇总统计有助于你更好地理解数据,在这个案例中,它帮助你更好地了解你的业务。

尽管这些总结性统计数据看起来很简单,但许多首席执行官如果被问到,可能连自己公司确切的客户数量都无法说出。了解一些简单的事情,比如任何一天的任意时段的平均客户数量,可以帮助你了解公司规模以及公司发展空间。

这些总结性统计信息还可以与其他信息结合,给我们提供更多的洞察。例如,如果你查找一下公司收费的单小时自行车使用费,你可以将这个费用与count列的均值相乘,得出这两年数据覆盖期内的总收入。

你可以通过使用像mean()median()这样的 pandas 方法手动检查总结性统计数据,正如我们之前所做的。但是,还有一种方法可以轻松地检查总结性统计数据:

print(hour.describe())

在这里,我们使用describe()方法检查数据集中所有变量的总结性统计数据。输出结果如下:

 instant        season  ...    registered         count
count  17379.0000  17379.000000  ...  17379.000000  17379.000000
mean    8690.0000      2.501640  ...    153.786869    189.463088
std     5017.0295      1.106918  ...    151.357286    181.387599
min        1.0000      1.000000  ...      0.000000      1.000000
25%     4345.5000      2.000000  ...     34.000000     40.000000
50%     8690.0000      3.000000  ...    115.000000    142.000000
75%    13034.5000      3.000000  ...    220.000000    281.000000
max    17379.0000      4.000000  ...    886.000000    977.000000

[8 rows x 16 columns]

你可以看到,describe()为我们提供了一个完整的表格,表格包含了几个有用的指标,包括每个变量的均值、最小值和最大值。describe()的输出还包括百分位数。例如,25%行包含了hour数据中每个变量的第 25 百分位数。我们可以看到,count变量的第 25 百分位数是 40,这意味着我们数据集中 25%的时间段内用户数为 40 人或更少,而 75%的时间段内用户数超过 40 人。

我们从describe()方法得到的表格也有助于我们检查数据中的问题。数据集通常包含一些重大错误,而这些错误可以通过describe()的输出被发现。例如,如果你在一个包含人群的数据显示方法上运行describe(),并且看到他们的平均年龄是 200 岁,那么你的数据就有错误。虽然这看起来显而易见,但在最近一篇发表于顶级学术期刊的知名研究论文中,竟然发现了这个错误(平均年龄大于 200 岁)——如果那些研究人员使用了describe(),他们就能避免这个问题!你应该查看每个数据集的describe()输出,确保所有的数值至少是合理的。如果你发现平均年龄超过 200 岁,或者其他看起来不可信的数据,你就需要定位并修复数据中的问题。

在这个阶段,我们已经可以开始利用从数据中学到的知识,来为改进业务提供思路。例如,我们看到在数据的前 24 小时内,夜间的骑行人数远低于白天的骑行人数。我们还看到了每小时用户数量的巨大差异:25%的时段内骑行人数少于 40 人,但某个时段有 886 名骑行者。作为 CEO,你可能希望更多的时段能接近 886 名骑行者,而不是有些时段少于 40 名骑行者。

你可以通过多种方式来实现这个目标。例如,你可能会选择在夜间降低价格,以便吸引更多的顾客,从而减少低乘车率的时间段。通过简单的探索,你可以继续从数据中学习,并获得改善业务的思路。

数据子集分析

我们已经查看了与整个数据集相关的摘要统计信息,然后考虑在夜间提供更低的价格以增加夜间的乘车人数。如果我们真的想要追求这个想法,我们应该查看与夜间相关的摘要统计信息。

夜间数据

我们可以从使用loc()方法开始:

print(hour.loc[3,'count'])

这个loc()方法允许我们指定一个完整数据集的子集。当我们使用loc()时,我们通过方括号和以下模式来指定我们想要选择的子集:[<>,<>]。在这里,我们指定了[3,'count'],表示我们要选择数据中的第 3 行和count列。我们从中得到的输出是 13,如果你查看图 1-1 或图 1-2 中的数据,你会发现这个结果是正确的。

这里需要指出的一个重要事项是,在 Python 和 pandas 中,标准的做法是使用零索引。我们从零开始计数,因此,如果我们的数据集有四行,我们会标记为第 0 行、第 1 行、第 2 行和第 3 行。数据的第四行被称为第 3 行,或者我们说它的索引是 3。同样,数据的第三行的索引是 2,第二行的索引是 1,第一行的索引是 0。因此,当我们运行print(hour.loc[3,'count'])时,我们会得到 13,这是存储在数据中的第四个值(来自索引为 3 的行),而不是 32,后者是存储在数据中的第三个值(来自索引为 2 的行)。零索引对许多人来说可能不太直观,但通过经验,你可以逐渐习惯并感到舒适。

在前面的代码片段中,我们查看了一个由单个数字(来自单行单列的count)组成的子集。但你可能想要了解一个包含多行或多列的子集。通过使用冒号(:),我们可以指定一个我们想查看的行范围:

print(hour.loc[2:4,'registered'])

在这个代码片段中,我们指定了想要获取registered变量的值。通过在方括号中指定2:4,我们表示希望获取第 2 行到第 4 行之间的所有数据,因此我们会得到三个数字作为输出:27、10 和 1。如果你查看这些行,你会发现这些观察数据与凌晨 2 点、3 点和 4 点相关。我们并没有打印出所有数据,而只是打印了这三行。由于我们只打印了一个子集的数据,我们可以将这个过程称为子集选择——选择数据的子集。这在数据探索和分析中非常有用。

与其一次查看几行相邻的数据,不如直接查看数据中所有的夜间观测数据。我们可以使用逻辑条件和loc()方法来实现:

print(hour.loc[hour['hr']<5,'registered'].mean())

这段代码使用loc()来访问数据的子集,就像我们之前做的那样。不过,它不是指定特定的行号,而是指定一个逻辑条件:hour['hr']<5,意思是它会选择数据中hr变量值小于 5 的所有行。这将给我们一个数据子集,代表了清晨的最早时段(午夜至凌晨 4 点)。我们可以指定多个条件来实现更复杂的逻辑。例如,我们可以特别检查寒冷或温暖的清晨的乘车人数:

print(hour.loc[(hour['hr']<5) & (hour['temp']<.50),'count'].mean())
print(hour.loc[(hour['hr']<5) & (hour['temp']>.50),'count'].mean())

在这里,我们指定了多个逻辑条件,用&字符连接,表示,这意味着两个条件必须同时为真。第一行选择了hr值小于 5并且temp值小于 0.50 的行。在这个数据集中,temp变量记录的是温度,但不是我们熟悉的华氏度或摄氏度,而是使用一个特殊的尺度,将所有温度都表示在 0 到 1 之间,其中 0 表示非常冷,1 表示非常暖和。每当你处理数据时,确保你了解每个变量使用的单位非常重要。我们指定hour['temp']<.50来选择温度较冷的时段,指定hour['temp']>.50来选择温暖的时段。这两行代码让我们能够比较寒冷清晨和温暖清晨的平均乘车人数。

我们还可以使用|符号表示。这在这样的示例中可能会很有用:

print(hour.loc[(hour['temp']>0.5) | (hour['hum']>0.5),'count'].mean())

这行代码选择了温度高湿度高的行的平均乘车人数——这两者都不是必须的。能够选择这些复杂条件可能有助于你选择在不舒适的天气条件下提高乘车人数的方法。

季节性数据

夜间折扣并不是提高乘车人数和收入的唯一策略。你还可以考虑在某些季节或年份的特定时间推出特价。在我们的数据中,season变量记录了冬季为 1,春季为 2,夏季为 3,秋季为 4。我们可以使用groupby()方法来找到每个季节的平均用户数:

print(hour.groupby(['season'])['count'].mean())

这段代码中的大部分内容应该看起来很熟悉。我们使用print()查看与hour数据相关的指标。我们使用mean()方法,表示我们在查看平均值。然后我们使用['count']来访问数据中的count列。所以很明显,我们将查看hour数据中的平均乘车人数。

唯一的新部分是groupby(['season'])。这是一个将数据分组的方法——在本例中,它会根据season列中出现的每个唯一值来划分组。输出结果显示了每个单独季节的平均乘车人数:

season
1    111.114569
2    208.344069
3    236.016237
4    198.868856
Name: count, dtype: float64

解释这个输出很简单:在第一季(冬季),每小时的平均乘客量大约为 111.115;在第二季(春季),每小时的平均乘客量大约为 208.344;依此类推。可以看出一个明显的季节性模式:春季和夏季的乘客量较高,而秋季和冬季的乘客量较低。groupby()方法也可以对多个列进行分组,具体如下:

print(hour.groupby(['season','holiday'])['count'].mean())

结果如下:

season  holiday
1       0          112.685875
        1           72.042683
2       0          208.428472
        1          204.552083
3       0          235.976818
        1          237.822917
4       0          199.965998
        1          167.722222
Name: count, dtype: float64

在这里,我们指定了两个列进行分组:seasonholiday。这将我们的每小时数据分成四个单独的季节,然后将每个季节分为节假日(由 1 表示)和非节假日(由 0 表示)。它分别展示了每个季节中节假日和非节假日的平均乘客量。结果是,我们可以看到节假日和非节假日之间的季节性差异。看起来寒冷季节的节假日乘客量低于非节假日,而温暖季节的节假日乘客量与非节假日相当。理解这些差异可以帮助你做出关于如何经营业务的决策,并可能为你提供关于在不同季节或不同节假日采取的策略的想法。

这个数据集很大,而且可以通过各种不同的方式进行分析。我们已经开始查看几个子集,并且开始得到一些想法。你应该做得更多:检查与所有列相关的子集,并从多个角度探索数据。即使不进行高级统计和机器学习,你仍然可以学到很多并得到许多有用的想法。

使用 Matplotlib 可视化数据

总结统计数据对于探索非常有价值和有用。然而,探索性数据分析中有一个非常重要的部分我们还没有做:绘图,或者说是将数据可视化成有组织的图表。

绘制并显示一个简单的图表

你应该在每次进行数据分析时,尽早并频繁地绘制数据。我们将使用一个流行的绘图包,叫做Matplotlib。我们可以通过以下方式绘制一个简单的图表:

import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(x = hour['instant'], y = hour['count'])
plt.show()

在这里,我们导入了 Matplotlib 包,并给它起了个别名plt。接下来,我们创建一个图形,称为fig,以及一个坐标轴,称为ax。图形fig将包含我们绘制的任何图表或图组的所有信息。坐标轴ax将为我们提供用于实际绘制图表的有用方法。subplots()方法为我们创建了这两个对象,并且在这个方法中,我们可以指定图形的大小(figsize)。在这种情况下,我们指定了一个图形大小为(10,6),意味着我们的图形宽度为 10 英寸,高度为 6 英寸。

接下来,我们通过使用scatter()方法绘制图表。在scatter()中,我们指定x=hour['instant'],这样 x 轴将显示我们hour数据中的instant变量。我们指定y=hour['count'],这样 y 轴将显示count变量。最后,我们使用plt.show()来显示这个图表。这个代码片段生成的图表应该类似于图 1-3。

图 1-3:两年内每小时的乘客数

在这个图表中,你可以看到每个点代表了数据集中记录的每一小时。第一小时(2011 年的开始)出现在图表的最左边。最后一小时(2012 年的结束)出现在图表的最右边,所有其他小时按顺序排列。

这个图表,称为散点图,是绘制的第一个好图表,因为它显示了数据中的每个观察值;它还使得关系容易通过视觉识别。在这种情况下,我们可以看到groupby()语句之前提示的季节性变化的完整表示。我们还可以看到随着时间推移,乘客数量的整体增长。

用标题和标签来澄清图表

图 1-3 中的图表展示了数据,但它的呈现方式不够清晰。我们可以通过如下方式为图表添加标题和标签:

fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(x = hour['instant'], y = hour['count'])
plt.xlabel("Hour")
plt.ylabel("Count")
plt.title("Ridership Count by Hour")
plt.show()

这个代码片段使用了xlabel()来为 x 轴添加标签,ylabel()来为 y 轴添加标签,title()来为图表添加标题。你可以在这些方法中指定任何文本,以获得你想要的标签。输出应该类似于图 1-4。

图 1-4:按小时划分的乘客数,带有轴标签和标题

我们的数据集非常大,一次查看所有数据很困难。让我们看看如何绘制数据的较小子集。

绘制数据子集

我们可以使用之前做的子集来仅绘制数据的一个子集:

hour_first48=hour.loc[0:48,:]
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(x = hour_first48['instant'], y = hour_first48['count'])
plt.xlabel("Hour")
plt.ylabel("Count")
plt.title("Count by Hour - First Two Days")
plt.show()

在这里,我们定义了一个新的变量hour_first48。这个变量包含了与原始数据中第 0 行到第 48 行相关的数据,大致对应数据中的前两天。

注意,我们通过写hour.loc[0:48,:]来选择这个子集。这是我们之前使用过的相同loc()方法。我们使用0:48来指定我们希望选择索引最大为 48 的行,但我们没有指定任何列——我们只是写了一个冒号(:),在我们通常指定列名的地方进行选择。这是一个有用的快捷方式:仅用冒号告诉 pandas 我们想选择数据集的所有列,因此不需要逐一写出每个列名。这个子集的图表看起来像图 1-5。

图 1-5:前两天按小时划分的乘客数

通过只绘制两天的数据,而不是两年的数据,我们避免了点重叠和相互遮挡的问题。我们可以更清楚地看到每个观测值。当你有一个大数据集时,最好同时做两件事:一次性绘制整个数据集(以了解整体的模式),同时也绘制数据的较小子集(以了解单个观测值和小范围的模式)。在这种情况下,我们不仅能看到每天的数据模式,还能看到跨年份的长期季节性模式。

测试不同的图类型

我们有多种方法可以改变图的外观。我们的scatter()函数包含一些参数,我们可以调整这些参数来获得不同的效果:

fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(x = hour_first48['instant'], y = hour_first48['count'],c='red',marker='+')
plt.xlabel("Hour")
plt.ylabel("Count")
plt.title("Count by Hour - First Two Days")
plt.show()

在这里,我们使用c参数来指定图中点的颜色(红色)。我们还指定了一个marker参数来改变标记样式,即绘制的点的形状。通过将+指定为标记参数,我们得到的图点看起来像小加号,而不是小圆点。图 1-6 展示了输出结果。

图 1-6:骑行人数统计,包含不同的样式选择

这本书没有彩色印刷,因此你在本页上看不到指定的红色。但如果你在家运行这段代码,应该能看到红色的点。

散点图并不是我们能绘制的唯一类型的图。让我们尝试绘制一条线图:

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(hour_first48['instant'], hour_first48['casual'],c='red',label='casual',linestyle='-')
ax.plot(hour_first48['instant'],\
hour_first48['registered'],c='blue',label='registered',linestyle='--')
ax.legend()
plt.show()

在这种情况下,我们使用ax.plot()而不是ax.scatter()来绘制图形。ax.plot()方法允许我们绘制线图。在这里,我们调用ax.plot()两次,在同一张图上绘制两条线。这样我们就能比较临时用户和注册用户(图 1-7)。

图 1-7:显示临时用户和注册用户在前两天的线图

这个图显示了临时骑行者的数量几乎总是低于注册骑行者的数量。图例显示了临时和注册用户的不同颜色,以及不同的线条样式(临时骑行者为实线,注册骑行者为虚线)。在家运行这段代码,你可以更清晰地看到颜色和它们的对比。

我们还可以尝试另一种类型的图:

import seaborn as sns
fig, ax = plt.subplots(figsize=(10, 6))
sns.boxplot(x='hr', y='registered', data=hour)
plt.xlabel("Hour")
plt.ylabel("Count")
plt.title("Counts by Hour")
plt.show()

这次,我们导入了一个名为seaborn的包。这个包基于 Matplotlib,因此它包含了 Matplotlib 的所有功能,此外还提供了更多帮助快速创建美观、信息丰富的图形的特性。我们使用 seaborn 的boxplot()方法来创建一种新的图形:箱线图。图 1-8 展示了这段代码生成的箱线图。

图 1-8:按时间段分组的骑行人数箱线图

你可以看到 24 个垂直箱形图,它们平行绘制——每一个都代表一天中特定小时的信息。箱形图是一种简单的图表,但它提供了大量的信息。在箱形图中,每个矩形的上边界和下边界分别代表所绘数据的第 75 百分位数和第 25 百分位数。矩形内的水平线表示中位数(或第 50 百分位数)。从每个矩形的顶部和底部延伸出的垂直线表示所有非异常值观察数据的完整范围。超出垂直线范围的独立绘制点被视为异常值。

在图 1-8 中一起查看这些箱形图可以让你比较不同时间段的乘车人数。例如,第 5 小时(大约早上 5 点)的中位数乘车人数非常低,而第 6 小时(大约早上 6 点)的中位数乘车人数要高得多。在第 7 小时(大约早上 7 点),中位数乘车人数更高。乘车人数在下午 5 点和下午 6 点再次出现高峰;也许这些高峰表明许多客户使用自行车上下班。

正如你所预期的那样,我们可以绘制更多类型的图表。另一个有用的图表是直方图,你可以按如下方式创建:

fig, ax = plt.subplots(figsize=(10, 6))
ax.hist(hour['count'],bins=80)
plt.xlabel("Ridership")
plt.ylabel("Frequency")
plt.title("Ridership Histogram")
plt.show()

这个代码片段使用hist()命令绘制直方图。图 1-9 展示了输出结果。

图 1-9:显示每小时乘车人数频率的直方图

在直方图中,每个柱形的高度表示频率。在这个例子中,我们的直方图展示了每个乘车人数的频率。例如,如果你查看 x 轴在 800 附近的位置,你会看到高度接近 0 的柱形。这意味着在我们的数据集中,只有极少数小时的乘车人数接近 800。相比之下,在 x 轴约 200 的位置,你会看到更高的柱形,接近 500 的高度。这表明在我们数据中的大约 500 个小时,乘车人数接近 200。我们在这个直方图中看到的模式是企业常见的模式:很多小时的客户很少,只有少数小时的客户很多。

你可以使用这种直方图来考虑公司容量。例如,也许你的公司今天有 1,000 辆自行车可供租赁。你认为出售 200 辆自行车可能是一个节省开支的好方法——这样,你将赚取一些额外的现金,同时不必担心多余自行车的维护和存储。这将使你剩下 800 辆可供租赁。通过查看直方图,你可以清楚地看到这种变化对公司容量的影响:由于只有少数小时的需求超过 800,这对你的容量影响应该相对较小。你可以根据直方图来决定到底出售多少辆自行车最为合适。

另一种类型的图表是配对图,它为数据中每一对可能的变量绘制所有可能的散点图:

thevariables=['hr','temp','windspeed']
hour_first100=hour.loc[0:100,thevariables]
sns.pairplot(hour_first100, corner=True)
plt.show()

在这里,我们创建了一个thevariables变量,它是我们将要绘制的三个变量的列表。(我们仅绘制三个变量而不是所有变量,因为书中的空间有限。)我们还创建了hour_first100,它是我们完整数据集的一个子集,只包含hour数据集中索引为 100 或更小的行。同样,seaborn 包通过提供pairplot()方法帮助我们创建图表。结果,图 1-10 是一个包含散点图和直方图的图表集合。

图 1-10:显示所选变量之间关系的配对图

配对图显示了我们选择的数据子集中的每一对可能组合的散点图,以及我们选择的单个变量的直方图。这里绘制了大量的数据,但散点图并未显示变量之间明显的关系;这些关系似乎基本上是随机的。

有时候,当我们绘制配对图时,我们看到的不仅仅是随机性。相反,我们可以看到变量之间的清晰关系。例如,如果我们在数据中有降雪量的测量值,我们会看到,随着温度升高,降雪量降低,反之亦然。变量之间这种清晰的关系被称为相关性,我们将在下一节探讨它。

探索相关性

如果两个变量的变化倾向于一起发生,我们说这两个变量是相关的。如果它们一起变化,我们说这两个变量是正相关的:一个变量在另一个变量上升时也倾向于上升,一个变量在另一个变量下降时也倾向于下降。在现实世界中,我们可以找到无数正相关的例子。例如,一个城市中家猫的数量与该城市购买猫粮的数量是正相关的。如果其中一个变量很高,另一个也倾向于很高;如果其中一个变量很低,另一个也倾向于很低。

我们也可以讨论负相关性:如果一个变量倾向于在另一个变量下降时上升,或者一个变量倾向于在另一个变量上升时下降,那么这两个变量是负相关的。负相关性在现实中也很常见。例如,一个城市的平均气温与该城市典型居民每年在厚冬季外套上的平均花费是负相关的。在一个城市中,如果其中一个数值很高,另一个数值通常很低;如果其中一个数值很低,另一个数值通常很高。

在数据科学的世界里,找到并理解相关性(无论是正相关还是负相关)是至关重要的。如果你能发现并理解这些相关性,作为 CEO 的表现将会提升。例如,你可能会发现骑行人数与温度之间存在正相关关系。如果是这样,意味着温度较低时骑行人数往往较少。你甚至可以考虑在骑行人数较少的季节出售一些自行车,以产生现金流,而不是让许多自行车闲置。你最终选择做什么将取决于你情况的许多其他细节,但深入理解数据将帮助你做出最佳的商业决策。

计算相关性

我们可以在 Python 中计算相关性:

print(hour['casual'].corr(hour['registered']))
print(hour['temp'].corr(hour['hum']))

在这里,我们使用 corr() 方法,这是 pandas 提供的又一功能。corr() 方法计算一个被称为 相关系数 的数字。我们可以计算多种类型的相关系数,但默认情况下,corr() 计算的是皮尔逊相关系数。这是最常用的相关系数,因此在本书中提到的相关系数,指的通常都是皮尔逊相关系数。

皮尔逊相关系数是一个介于 –1 和 1 之间的数字,通常用变量 r 来表示。它用于描述两个变量之间的关系;其符号表示相关类型,大小表示相关性的强度。如果相关系数 r 是正数,说明这两个变量是正相关的;如果 r 是负数,说明它们是负相关的。如果相关系数为 0,或者接近 0,我们说这两个变量是 不相关的

在这种情况下,这段代码的第一行计算了描述 casualregistered 变量之间关系的相关系数。对于这些变量,r 大约是 0.51,这是一个正数,表示正相关。

理解强相关与弱相关

除了注意相关系数是正数、负数还是 0,我们还要关注它们的确切 大小。如果相关系数很大(远离 0,接近 1 或 –1),我们通常说相关性是 的。查看 图 1-11 可以看到相关性的示例。

图 1-11:正相关变量

在这里,你可以看到两个图表。第一个图表显示了华氏温度和摄氏温度之间的关系。你可以看到,华氏温度和摄氏温度是正相关的:当一个上升时,另一个也上升,反之亦然。第二个图表显示了你公司中临时和注册乘客数量之间的关系。同样,我们看到它们是正相关的:当临时乘客数量上升时,注册乘客数量也倾向于上升,反之亦然。

图 1-11 中的两个相关性都是正相关的,但我们可以看到它们之间的定性差异。华氏温度和摄氏温度之间的关系是决定性的:知道华氏温度就能精确得知摄氏温度,没有不确定性或猜测。这种出现在图表上呈直线的决定性正相关也被称为完美相关,当我们计算完美正相关的相关系数时,我们会发现r = 1。

相比之下,临时乘客和注册乘客之间的关系是决定性的。通常,临时乘客的数量与注册乘客的数量成正比。但有时并非如此;我们不能仅通过一个变量完美预测另一个变量。当两个变量相关,但没有决定性关系时,我们称这两个变量之间存在“噪声”或随机性。

随机性很难精确定义,但你可以把它看作是不可预测性。当你知道一个华氏温度时,你可以准确预测出摄氏温度。相比之下,当你知道临时乘客数量时,你可以预测注册乘客数量,但你的预测可能不完全准确。当存在这种不可预测性时,两个变量的相关系数会小于 1。在这种情况下,我们可以计算临时和注册乘客数量的相关性,发现r = 0.51。

你可以将相关系数的大小视为两个变量关系中随机性的衡量标准。较大的相关系数对应着较少的随机性(接近决定性关系,比如华氏温度和摄氏温度之间的关系)。较小的相关系数对应着更多的随机性和较低的可预测性。你可以把 0 的相关系数,表示变量之间没有任何关系,看作是纯随机性或纯噪声的标志。

你可以查看图 1-12,了解不同幅度的负相关性示例。

图 1-12:负相关变量

在这里,我们看到与图 1-11 中相同的思想。第一个图显示了一个完美的负相关性:这次是压力和体积之间的确定性关系。这里的相关性正好是 r = –1,表明变量之间的关系没有任何随机性;每个变量都可以通过另一个变量完美预测。

第二个图显示了我们数据中温度和湿度之间的关系。这两个变量也有负相关性,但相关系数要小得多:r 大约为 –0.07。就像我们在图 1-11 中分析正相关时所做的那样,我们可以将这些相关系数解释为随机性的度量:一个较大幅度的相关系数(意味着它更接近 1 或 –1)是一个高度可预测的相关性,随机性较小,而一个幅度较小的系数(更接近 0)则表示该相关性有更多的随机性。当我们看到 r = –0.07 时,我们解释为温度和湿度之间存在负相关关系,但它们的相关性非常弱——几乎接近纯随机性。

当你观察相关性时,有一件重要的事情需要记住,那就是一句名言:“相关性并不意味着因果关系。” 当我们观察到强烈的相关性时,唯一可以确定的是两个变量倾向于一起变化;我们不能确定其中一个变量是导致另一个变量变化的原因。

举个例子,假设我们研究硅谷的创业公司,发现它们的月收入与购买的乒乓球桌数量之间存在相关性。我们可能会仓促地得出结论,认为乒乓球桌导致了收入的增加;也许它们所促进的放松和团队精神提高了生产力,或者它们创造的有趣氛围提高了员工的留存率和招聘成功率。

另一方面,这些观点可能完全错误,也许因果关系是反向的;那些成功的公司(与它们的乒乓球桌完全无关)拥有更高的收入,并且由于预算突然增加,它们将部分新增的资金用于购买乒乓球桌等有趣的物品。在这种情况下,收入将导致乒乓球桌的购买,而不是相反。

最后,相关性可能只是纯粹的巧合。也许乒乓球桌并不会导致更高的收入,收入也不会导致更多的乒乓球桌,实际上我们观察到的是一个虚假的相关性:这是一种仅仅由于巧合而发生的相关性,并不表示任何因果关系或特殊的联系。相关性也可能是由于遗漏变量所致,这个变量我们没有观察到,但它独立地同时导致了收入的增加和乒乓球桌的购买。

无论如何,重要的是在发现并解释相关性时要始终保持谨慎。相关性意味着两个变量趋向于一起变化,它们可以帮助我们做出预测,但它们不一定意味着一个变量导致了另一个变量,甚至它们之间是否存在任何真正的关系。

发现和理解相关系数可以帮助你在 CEO 的职责中,尤其是当你发现意外的相关性时。例如,你可能会发现,租借自行车的团队规模与租后顾客满意度之间有很强的正相关。也许这能给你一些启示,鼓励人们与朋友一起租车,以提高顾客的满意度。发现相关性、理解相关性的大小及其对可预测性所带来的启示,对商业决策是非常有价值的。

查找变量之间的相关性

我们不仅可以计算成对变量之间的相关性,还可以进一步创建一个相关矩阵,它是一个矩阵(或矩形数组),每个元素都是测量两个特定变量之间关系的相关系数。相关矩阵将显示所有变量之间的关系:

thenames=['hr','temp','windspeed']
cor_matrix = hour[thenames].corr()
print(cor_matrix)

在这里,我们使用与之前相同的corr()方法。当我们在括号内使用corr()而不传入任何参数时,它会创建一个包含数据集所有变量的相关矩阵。在这个例子中,我们创建了一个较小的相关矩阵,只显示三个选定变量之间的相关性。我们计算得到的相关矩阵如下所示:

 hr      temp  windspeed
hr         1.000000  0.137603   0.137252
temp       0.137603  1.000000  -0.023125
windspeed  0.137252 -0.023125   1.000000

在这里,我们有一个 3×3 的矩阵。这个矩阵中的每个条目都是一个相关系数。例如,在第二行第三列,你可以看到windspeedtemp之间的相关性大约是r = –0.023。技术上讲,这是一个负相关,尽管它接近于 0,我们通常会描述这两个变量之间没有相关性。

你还可以看到,矩阵中的三个相关系数等于 1.0。这是预期的:这些完美的相关性测量的是每个变量与自身的相关性(hrhrtemptemp,以及windspeedwindspeed)。每个变量总是与自身具有完美的相关性。创建一个相关矩阵是查找数据中所有变量之间相关性以及发现任何意外的正相关或负相关的快速简便方法。

创建热图

创建相关矩阵后,我们可以绘制出所有这些相关性图表,使矩阵更易于阅读:

plt.figure(figsize=(14,10))
corr = hour[thenames].corr()
sns.heatmap(corr, annot=True,cmap='binary',
        fmt=".3f",
        xticklabels=thenames,
        yticklabels=thenames)
plt.show()

在这里,我们创建了一个热图。在这种类型的图表中,单元格的颜色或深浅表示该单元格中数字的值。图 1-13 中的热图展示了变量之间的相关性测量。

图 1-13:通过热力图展示的相关性

这个热力图展示了九个矩形的集合。正如右侧图例所示,矩形中较深的填充色表示特定的相关性较高,而较浅的填充色表示相关性较低。相关矩阵的热力图可以提供一种更快捷的方式来检查变量之间的模式和关系,因为强关系会迅速引起注意。

如果你更喜欢彩色图而不是灰度图,可以更改sns.heatmap()方法中的cmap='binary'参数。这个cmap参数指的是热力图的颜色映射,通过选择不同的cmap值,你可以获得不同的色彩方案。例如,如果你使用cmap='coolwarm',你将看到一种热力图,其中较高的数值用红色表示,较低的数值用蓝色表示。

热力图不仅可以用于相关矩阵的绘制,也可以用于其他变量。例如,我们可以绘制一个热力图,显示一周内每个小时的乘客数量:

# Create a pivot table
df_hm =hour.pivot_table(index = 'hr',columns ='weekday',values ='count')
# Draw a heatmap
plt.figure(figsize = (20,10)) # To resize the plot
sns.heatmap(df_hm,  fmt="d", cmap='binary',linewidths=.5, vmin = 0)
plt.show()

为了创建这个图表,我们需要创建一个数据透视表,即一个分组值的表格。如果你曾经花很多时间使用 Excel 或其他电子表格程序,可能已经遇到过数据透视表。在这里,我们的数据透视表根据每周的日期和每小时的时间对完整数据集进行了分组。我们列出了每一天(从周日到周六)每小时(0 到 23 点)的平均客流量。通过这种方式分组的数据透视表创建后,我们可以使用相同的heatmap()方法来创建图中所示的热力图图 1-14。

图 1-14:每天每小时的客流量

这个热力图包含了客流量较多的小时段用较深的矩形表示,而客流量较少的小时段则用较浅的矩形表示。我们可以看到通勤者在早上 8 点和下午 5 点的活动激增。我们还可以看到周六和周日下午的周末出行情况。

从商业角度来看,这个热力图可以为我们提供各种商业想法。例如,看到工作日早上 8 点左右客流量激增,可能会给你增加收入的启示。正如我们曾考虑在低活跃时段提供折扣一样,我们也可以考虑采取相反的策略:在特别活跃的时段进行加价(暂时提高价格)。其他交通公司,如 Uber、Lyft 和 Grab,也使用这一加价策略,不仅为了增加收入,还为了确保产品的高可用性。

深入探索

到目前为止,我们只看过一个数据集,而且只进行了其中一些无限可能的探索。当你从作为 CEO 的第一天早晨开始,直到第一天下午,再到第二天,甚至更远的时间,你将需要对你的业务及其运作方式做出许多决策。我们在这一章中所做的探索可以应用到你将来遇到的任何业务问题。例如,你可以考虑将自行车租赁与清爽的饮品捆绑销售,从而赚取额外的收入(更不用说这样可以让骑行者更加健康和安全)。分析与你的客户、他们的骑行模式以及骑行过程中他们的口渴程度相关的数据,能够帮助你判断这一策略是否可行。

其他分析可能与你的自行车维修需求相关。你的自行车维修的频率是多少?维修费用是多少?你可以检查维修的时间,确保它们没有发生在高峰时段。你可以检查各种类型自行车的维修费用。你可以检查维修费用的直方图,看看是否有任何异常值导致费用过高。这些探索将帮助你更好地理解你的业务,并为你提供改进业务的想法。

到目前为止,我们的分析并不特别复杂;大多数情况下,我们只是计算了一些汇总统计并绘制了图表。但这些简单的计算和图表,结合常识,作为做出商业决策的第一步,依然非常有价值。有些 CEO 不足够重视数据,其他一些则希望查看数据,但依赖员工为他们提供报告,而这些报告可能会很慢或者不完美。一个能够自信地检查与公司相关数据的 CEO,才能成为一名有效的 CEO。CEO 可以变得擅长数据,这与他们的商业知识结合起来,将使他们在工作中更加出色。同样,数据科学家也可以变得擅长商业,当他们的数据技能与商业眼光结合时,他们将真正成为一个不可忽视的力量。

总结

在本章中,我们从一个简单的商业场景开始:成为一名 CEO,并做出与更好地运营业务相关的决策。我们讨论了一些 CEO 需要做的事情,以及探索性数据分析如何发挥作用。我们涵盖了如何将数据读取到 Python 中,计算汇总统计,绘制图表,并在商业上下文中解读结果。在下一章中,我们将讨论线性回归,这是一种更复杂的方法,既可以用于探索,也可以用于预测。让我们继续!

第二章:预测

让我们看看一些可以帮助你预测未来的数据科学工具。在本章中,我们将介绍一个简单的商业场景,假设一家公司需要预测客户需求。然后,我们将讨论如何应用数据科学工具来做出准确的预测,以及如何通过这些预测做出更好的商业决策。

我们将使用线性回归进行预测,并讨论单变量和多变量线性回归。最后,我们将研究回归线的外推以及如何评估各种回归模型,以选择最佳模型。

预测客户需求

假设你在加拿大魁北克经营一家汽车经销商。你使用的是标准的零售商业模式:你以低价从制造商那里购买汽车,然后以较高的价格卖给个人客户。每个月,你需要决定从制造商那里订购多少汽车。如果你订购了太多汽车,你将无法快速卖出,导致高额的存储成本或现金流问题。如果你订购的汽车太少,你将无法满足客户的需求。

订购正确数量的汽车很重要。但什么是正确的数量呢?答案取决于一些商业考虑因素,比如你的银行账户中的现金和你希望增长的规模——但在一个典型的月份,正确的订购数量正好是客户在下个月愿意购买的汽车数量。由于我们无法预测未来,我们需要通过预测需求来下订单。

我们可以选择几种经过验证的定量方法来获得下个月需求的预测。最好的方法之一是线性回归。在本章的其余部分,我们将解释如何使用线性回归进行预测。我们将利用过去的数据来预测未来的数据,从而了解我们需要订购多少汽车。我们将从简单的步骤开始,先读取并查看一些数据,然后进入预测过程的其他步骤。

清理错误数据

我们将分析的数据是魁北克的汽车经销商在连续 108 个月中每月销售的汽车数量记录。这些数据最初由统计学教授和预测专家 Rob Hyndman 在线提供。你可以从bradfordtuckfield.com/carsales.csv下载这些数据。

这些数据较旧;记录的最近一个月是 1968 年 12 月。因此,在这个场景中,我们将假设我们生活在 1968 年 12 月,并为 1969 年 1 月做出预测。我们将讨论的预测原则是长久有效的,因此,如果你能用 1968 年的数据预测 1969 年的结果,你也能用年份n的数据预测年份n + 1 的结果,n = 2,023 或 3,023 或任何其他年份。

将此文件保存在你运行 Python 的相同目录下。然后我们将使用 Python 的 pandas 包读取数据:

import pandas as pd
carsales=pd.read_csv('carsales.csv')

在这里,我们导入 pandas 并给它取别名pd。然后我们使用它的read_csv()方法将数据读取到 Python 中,并存储在carsales变量中。我们在这里导入并使用的 pandas 包是一个强大的模块,它使得在 Python 中处理数据变得更加容易。我们创建的carsales对象是一个 pandas 数据框(dataframe),它是 pandas 在 Python 会话中存储数据的标准格式。由于这个对象被存储为 pandas 数据框,我们将能够像在第一章中一样,使用许多有用的 pandas 方法来处理它。让我们从使用head()方法开始,它让我们能够检查 pandas 数据框:

>>> **print(carsales.head())**
     Month  Monthly car sales in Quebec 1960-1968
0  1960-01                                 6550.0
1  1960-02                                 8728.0
2  1960-03                                12026.0
3  1960-04                                14395.0
4  1960-05                                14587.0

通过查看这些行,我们可以注意到几个重要的点。首先,我们可以看到列名。这个数据集的列名是MonthMonthly car sales in Quebec 1960-1968。第二个列名如果我们缩短它,将会更容易处理。在 Python 中我们可以很容易做到这一点:

carsales.columns= ['month','sales']

在这段代码中,我们访问了数据框的列并重新定义它们,使用了更简短的名称(分别是monthsales)。

就像head()方法打印数据集的前五行一样,tail()方法打印数据集的后五行。如果你运行print(carsales.tail()),你会看到以下输出:

>>> **print(carsales.tail())**
                                     month    sales
104                                1968-09  14385.0
105                                1968-10  21342.0
106                                1968-11  17180.0
107                                1968-12  14577.0
108  Monthly car sales in Quebec 1960-1968      NaN

我们可以看到,现在列名更短且更易读。但我们也看到,最后一行没有包含汽车销售数据。相反,它的第一项是一个标签,或者说是描述整个数据集的标签。它的第二项是NaN,代表不是数字,意味着该项没有数据或数据未定义。我们不需要标签项和空的(NaN)项,所以让我们删除整行最后一行(第 108 行):

carsales=carsales.loc[0:107,:].copy()

在这里,我们使用 pandas 的loc()方法来指定我们想要保留的行:在这种情况下,我们保留从第 0 行到第 107 行(包括第 107 行)的所有行。我们在逗号后面使用冒号(:)来表示我们想要保留数据集的两列。我们将结果存储在carsales变量中,从而删除了多余的第 108 行。如果你再次运行print(carsales.tail()),你会看到该行已经被删除。

通过查看数据的头部和尾部,我们还可以看到月份数据的格式。第一项是1960-01(1960 年 1 月),第二项是1960-02(1960 年 2 月),依此类推。

作为数据科学家,我们有兴趣使用数学、统计学和其他定量方法进行数值分析。日期有时会带来一些繁琐的挑战,使得我们很难按照想要的方式进行数学和统计分析。第一个挑战是日期有时不是以数字数据类型存储的。在这里,日期被存储为字符串,即字符的集合。

要理解为什么这是一个问题,可以在 Python 控制台尝试 print(1960+1);你会发现结果是 1961。Python 识别到我们正在处理两个数字,并按照我们期望的方式将它们相加。接着,在 Python 控制台尝试 print('1960'+'1');这时你得到的结果是 19601。这次,Python 识别到我们输入的是字符串,并假设 + 符号表示我们想要进行 连接,即将字符串简单地拼接在一起,而不是按照数学规则进行相加。

日期的另一个挑战是,即使它们是数字形式,它们也遵循与自然数逻辑不同的规则。例如,如果我们将 1 加到第 11 个月,我们得到第 12 个月,这符合算术规则:11 + 1 = 12。然而,如果我们将 1 加到第 12 个月,我们又回到了第 1 个月(因为每年的 12 月之后是 1 月),这与 12 + 1 = 13 的简单算术不一致。

在这种情况下,解决日期数据类型问题的最简单方法是定义一个新的变量叫做 period。我们可以如下定义它:

carsales['period']=list(range(108))

我们的新 period 变量只是从 0 到 107 的所有数字的列表。我们将 1960 年 1 月称为期 0,1960 年 2 月称为期 1,以此类推,直到 1968 年 12 月,这是我们数据中的最后一个月,我们称其为期 107。这个新变量是数字类型的,因此我们可以对它进行加法、减法或其他任何数学运算。而且,它将遵循标准算术规则,期 13 紧跟在期 12 后面,正如我们对数字变量的预期。这种简单的解决方案之所以可行,是因为在这个特定的数据集中,行是按时间顺序排列的,因此我们可以确保每个期号都分配给了正确的月份。

这些简单的任务,比如为月份添加一个数字列、删除多余的行、修改列名,都是 数据清理 的一部分。这不是一个光鲜亮丽或特别令人兴奋的过程,但做得对非常重要,因为它为数据科学过程中更令人激动的步骤打下了基础。

绘制数据以寻找趋势

完成这些基本的数据清理任务后,我们应该立即绘制数据。在每个数据科学项目中,绘图应该尽早并且经常进行。让我们使用 Matplotlib 模块创建一个简单的图表来显示我们的数据:

from matplotlib import pyplot as plt
plt.scatter(carsales['period'],carsales['sales'])
plt.title('Car Sales by Month')
plt.xlabel('Month')
plt.ylabel('Sales')
plt.show()

在这个代码片段中,我们导入了 Matplotlib 的 pyplot 模块,并将其别名设为 plt。然后,我们使用 scatter() 方法创建了一个散点图,显示了所有销售数据,按期(月份)组织。我们还用了几行代码来添加坐标轴标签和图表标题,然后展示图表。图 2-1 显示了结果。

图 2-1:九年内按月销售的汽车数量

这个简单的图表显示了我们的 period 变量在 x 轴上,sales 在 y 轴上。每个点代表一行数据,或者换句话说,代表一个特定月份的汽车销售数量。

看看在这个图表中有什么有趣的信息能引起你的注意。可能最明显的事情是从左到右的逐渐上升趋势:销售似乎随着时间的推移在逐步增加。除了这个趋势,数据似乎杂乱无章,散布着巨大的波动,且每个月之间的波动也很大。一年或季节内的波动看起来是随机的、嘈杂的,并且不可预测。我们接下来将实现的线性回归方法将尝试捕捉数据中的顺序和模式,帮助我们不那么被随机性和噪声所干扰。

到目前为止,我们所做的只是读取数据并绘制一个简单的图表。但我们已经开始看到一些模式,这些模式将有助于我们做出准确的预测。接下来,我们将进入一些更严谨的预测步骤。

执行线性回归

现在我们已经清理了数据、绘制了图表,并注意到了一些基本的模式,我们已经准备好进行认真的预测了。我们将使用线性回归来进行预测。线性回归是每个数据科学家工具箱中的必备工具:它找到一条能够捕捉变量之间噪声关系的直线,我们可以利用这条直线来对我们从未见过的事物进行预测。

线性回归是在“机器学习”一词被提出的一个多世纪之前就已经被发明的,历史上它一直被认为是纯统计学的一部分。然而,由于它与许多常见的机器学习方法有着非常相似的特点,并且与机器学习共享一些共同的理论基础,因此线性回归有时被视为机器学习领域的一部分。像所有最好的科学工具一样,它帮助我们从混乱中提取秩序。

在这个例子中,我们有魁北克汽车销售的混乱数据,其中季节性变化、时间趋势和单纯的随机性交织在一起,形成一个嘈杂的数据集。当我们将简单的线性回归应用于这些数据时,输出将是一条直线,这条直线捕捉到了一种潜在的结构,帮助我们对未来做出准确的预测。图 2-2 展示了线性回归的典型输出示例。

图 2-2:显示线性回归典型输出的虚线

在这张图中,你可以看到代表数据的点,就像在图 2-1 中一样。我们再次看到了数据集的混乱:整个数据集在每个月之间都有很大的波动。

从左到右略微向上的虚线代表线性回归的输出。它被称为 回归线,我们常常说这条回归线 拟合 数据。换句话说,它通过看起来大致处于所有点构成的云的中心的地方。它接近我们数据中的许多点,并且没有数据点特别远离它。就像这条线表达或揭示了时间与销量之间的基本关系(一种逐渐增长的关系)。线拟合一组点的概念是线性回归的基础。事实上,出于我们稍后讨论的原因,回归线有时被称为数据集的 最佳拟合线

由于我们的回归线是一条直线,它没有真实数据中的随机波动。该线以可预测的方式前进。通过去除这些随机性,回归线向我们展示了数据潜在模式的清晰表示。在这种情况下,回归线显示数据随着时间的推移有一个整体向上的趋势,如果我们仔细测量回归线,我们可以精确找到该趋势的斜率和高度。

我们可以将回归线在任何特定月份的值解释为该月预期的汽车销量。稍后,我们将把这条简单的线向前外推到未来(通过继续用相同的斜率绘制它,直到它延伸到图表的右边缘之外),以生成未来几个月的销售预测。

让我们运行执行线性回归并输出回归线的代码。我们将使用对数据的 形状 非常敏感的线性回归方法,也就是说,销售数据是以 108 行 × 1 列的形式存储,还是以 108 列 × 1 行的形式存储。在这种情况下,如果我们的数据存储为 108 行,每行包含一个数字的列表,我们的线性回归代码将运行得更顺畅。为了将数据转化为这种形状,我们将使用 pandas 的 reshape() 方法,具体如下:

x = carsales['period'].values.reshape(-1,1)
y = carsales['sales'].values.reshape(-1,1)

如果你运行 print(x)print(y),你可以看到数据的新形状:108 行的单元素列表。实际上,执行线性回归的代码非常简短。我们可以用三行代码完成整个过程,包括导入相关模块:

from sklearn.linear_model import LinearRegression
regressor = LinearRegression()
regressor.fit(x, y)

在这里,我们从 scikit-learn 包导入线性回归功能,该包可以通过其标准缩写 sklearn 来引用。这个在机器学习领域极为流行的包提供了许多有用的机器学习功能,包括线性回归。在导入 sklearn 后,我们定义变量 regressor。如其名称所示,回归器 是一个 Python 对象,我们将使用它来执行回归操作。在创建回归器后,我们告诉它对 xy 变量进行 fit 操作。我们告诉它通过匹配数据的位置和总体趋势来计算如 图 2-2 所示的拟合数据的直线。

描述拟合回归的一个更定量的方式是:它是确定两个数字的精确、优化值:一个系数和一个截距。在运行了前面的代码片段后,我们可以如下查看这两个数字:

print(regressor.coef_)
print(regressor.intercept_)

这段代码打印出回归器fit()方法输出的两个数字:一个截距,你应该能看到大约是 10,250.8;以及一个叫做coef_的变量,coef_系数的缩写,它的值应该大约是 81.2。结合这两个数字,可以指定图 2-2 中虚线回归线的确切位置和趋势。在接下来的部分,你将看到它们是如何做到这一点的。

将代数应用于回归线

要理解这两个数字如何指定回归线,回想一下你高中数学课学到的内容。你可能记得,每一条直线都可以用类似下面的形式表示:

y = m · x + b

在这里,m是斜率或系数,b是截距(严格来说是y-截距——即直线穿过图表 y 轴的精确位置)。在这种情况下,我们找到的coef_变量值,大约是 81.2,就是m的值,而我们找到的截距变量值,大约是 10,250.8,就是b的值。所以,通过我们的回归过程,我们学到的是,时间段和汽车销量之间的关系可以至少大致表示为:

汽车销量 = 81.2 · 时间段 + 10250.8

汽车销售数据集看似随机变化的混乱(如图 2-1 所示)现在已简化为这个简单方程的规律。这个方程描述的直线就是图 2-2 中的虚线。我们可以将这条直线上的每一个点看作是对每个时间段内汽车销售预期的预测,忽略了干扰性随机性和噪音。

我们方程中的mb值有着有用的解释。线性斜率 81.2 的解释是汽车销售的月度增长趋势。根据我们过去观察到的数据,我们得出结论,魁北克的汽车销量每月大约增长 81.2 辆。虽然仍然存在随机性和其他变动,但 81.2 的增长值是我们大致期望的。截距变量 10,250.8 的解释是汽车销售的基准值:即“移除”或忽略季节性变化、时间流逝和其他影响后,在第 0 个月预期的汽车销量。

线性回归找到的方程也可以叫做模型,即描述两个或多个变量之间关系的定量描述。因此,当我们执行前面的步骤时,我们可以说我们拟合了回归,或者我们可以等效地说我们训练了一个模型。我们的回归,或者等效地说我们的模型,告诉我们:在数据时间框架的开始,我们预期销售大约 10,250.8 辆汽车,并且每个月比前一个月多售出大约 81.2 辆汽车。

自然会有人想知道,我们的回归器是如何确定 81.2 和 10,250.8(回归器的 coef_intercept 输出值)是回归线中mb的最佳值的。这条线在图 2-2 中看起来足够好,但它并不是唯一一条可以穿过我们数据点云的线。实际上,还有无数条可以穿过数据点云的线,并且它们也可以被说成符合数据。例如,我们可能假设以下这条线更好地近似时间周期与销售之间的关系:

汽车销售 = 125 · 时间周期 + 8000

我们将这条新线称为我们的假设线。如果我们将其作为数据模型,我们就有了新的mb,从而得到了新的解释。特别是,这条线的斜率是 125,我们将其解释为预期每个月的汽车销售将增加大约 125 辆——这明显高于回归线估计的 81.2。接下来,我们将回归线和这条新的假设线与数据一起绘制如下:

plt.scatter(carsales['period'],carsales['sales'])
plt.plot(carsales['period'],[81.2 * i + 10250.8 for i in \
carsales['period']],'r-',label='Regression Line')
plt.plot(carsales['period'],[125 * i + 8000 for i in
carsales['period']],'r--',label='Hypothesized Line')
plt.legend(loc="upper left")
plt.title('Car Sales by Month')
plt.xlabel('Month')
plt.ylabel('Sales')
plt.show()

你可以在图 2-3 中看到这个代码片段的输出,我们绘制了数据、回归线(浅色实线)以及我们新的假设线(更陡的虚线)。

图 2-3:一条回归线和一条更陡的线,这两条线都符合数据

两条线都穿过我们的数据点云,且都表现出随时间的上升趋势。它们都是合理的候选线,可以近似表示时间与销售之间的关系,并且都可以说符合数据。为什么我们的回归器输出了一条线而不是另一条呢?我们之前说过,线性回归过程中输出的回归线是最佳拟合线。是什么让我们能说它比任何其他线拟合得更好呢?

计算误差测量值

我们可以通过查看与回归误差相关的测量值来找到答案。记住,我们将回归线上的每个点解释为我们对数据中预期值的预测。图 2-4 显示了一条回归线及其所用数据。

图 2-4:回归误差:点与回归线之间的垂直距离

你可以看到,这条回归线很好地拟合了数据,意味着它接近大多数示例点。然而,它并不是完美拟合。对于每一个数据点,我们可以计算该数据点与回归线之间的垂直距离。回归线预测了一个特定值,而数据中的点与这个预测值之间有一个特定的距离。这个预测值与实际值之间的距离被称为回归的误差(error)相对于该点。在图 2-4 中,变量e[i]是数据中某一点的误差测量。你可以看到,e[i]是该点与回归线之间的垂直距离。我们可以为数据中的每一个点计算这个距离。

计算相对于每个数据点的误差将为我们提供一个量化任何线如何拟合我们数据的方法。误差小的线能够很好地拟合数据,而误差大的线则拟合得不好。这就是为什么我们说,衡量回归误差是衡量回归线的拟合优度(goodness of fit)的一种方式,即衡量一条线与数据拟合的程度。

让我们来计算这些关于汽车销售回归的误差测量。我们将计算我们感兴趣的线的每一个点,并将这些点与我们数据集中的每一个点进行比较:

saleslist=carsales['sales'].tolist()
regressionline=[81.2 * i + 10250.8 for i in carsales['period']]
hypothesizedline=[125 * i + 8000 for i in carsales['period']]
error1=[(x-y) for x, y in zip(regressionline,saleslist)]
error2=[(x-y) for x, y in zip(hypothesizedline,saleslist)]

在这个代码片段中,我们创建了销售列表(saleslist),一个包含每个月汽车销售数量的变量。接着我们创建了两个变量,回归线(regressionline)和假设线(hypothesizedline)。这些变量分别记录回归线和假设线上的每一个点。我们想要衡量每个实际销售数字与这两条线的距离,因此我们创建了两个额外的变量:误差 1(error1),用于记录实际销售数字与回归线之间的距离,误差 2(error2),用于记录实际销售数字与假设线之间的距离。

我们可以打印出这些变量,以查看我们在两条线上的误差:

print(error1)
print(error2)

当你查看这些误差列表时,你可以看到 108 个分别测量这两条线与原始数据的距离的独立值。这 108 个测量值表达了这些线如何拟合原始数据。然而,一次性查看这 216 个测量值是很困难的。如果我们能够将所有这些表示线拟合情况的信息压缩为一个数字,那会更容易。以下代码片段展示了一种实现方法:

import numpy as np

error1abs=[abs(value) for value in error1]
error2abs=[abs(value) for value in error2]

print(np.mean(error1abs))
print(np.mean(error2abs))

在这个代码片段中,我们导入了 Python 的 NumPy 包。NumPy 在数据科学中非常常用,特别是在进行数组和矩阵计算时。这里,我们导入它是因为它能帮助我们计算一个列表的均值。然后,我们定义了两个新变量:误差 1 绝对值(error1abs)和误差 2 绝对值(error2abs),它们分别包含我们对两条线的误差测量的绝对值列表。最后,我们计算这些列表的均值。

我们得到的均值被称为均值绝对误差(MAE),即每条线的误差测量值。希望 MAE 对你来说是一个直观的误差衡量:它只是线与数据集中的点之间的平均垂直距离。与数据集中点非常接近的线会有较低的 MAE,而与大多数点距离较远的线则会有较高的 MAE。

MAE 是一种合理的方式来表示回归线或任何其他线的拟合优度。MAE 越小,拟合效果越好。在这种情况下,我们可以看到回归线的 MAE 为 3,154.4,而我们假设的线的 MAE 为 3,239.8。至少根据这个测量,回归线比我们假设的线拟合得更好。

MAE 有一个简单的解释:它是我们使用特定回归线进行预测时,预计会有的平均误差。当我们说回归线的 MAE 为 3,154.4 时,我们的意思是,如果我们使用这条回归线进行预测,我们预计我们的预测结果平均会偏差大约 3,154.4(可能偏低或偏高)。

例如,假设我们预测三个月后将销售 20,000 辆汽车。我们等待三个月,统计每月的销售量,发现实际销售量是 23,154 辆,而不是 20,000。我们的预测错了;我们低估了汽车销售量 3,154 辆。所以,我们的预测并不完美,预测误差的大小正好告诉我们我们有多不完美。我们的误差大小是否令人惊讶?我们刚刚测得的 MAE(3,154.4)告诉我们,误差这么大并不奇怪——事实上,低估 3,154 辆(四舍五入后)正是我们在使用这个回归模型进行任何月度预测时,预计会遇到的误差大小。有时我们会高估,而不是低估;有时我们的误差会低于或高于 3,154。但无论如何,MAE 告诉我们,使用这个回归模型进行这种预测时,误差大约为 3,154 是我们所期望的。

MAE 并不是唯一衡量线拟合数据集程度的指标。让我们看看另一个可能的指标:

error1squared=[(value)**2 for value in error1]
error2squared=[(value)**2 for value in error2]

print(np.sqrt(np.mean(error1squared)))
print(np.sqrt(np.mean(error2squared)))

在这里,我们创建了每个误差的平方值列表。然后,我们取这些误差平方和的平方根。这个指标被称为均方根误差(RMSE)。较低的 RMSE 值表示拟合效果更好的回归线——它预计能做出更准确的预测。

我们可以创建简单的 Python 函数来计算 MAE 和 RMSE:

def get_mae(line,actual):
    error=[(x-y) for x,y in zip(line,actual)]
    errorabs=[abs(value) for value in error]
    mae=np.mean(errorabs)
    return(mae)

def get_rmse(line,actual):
    error=[(x-y) for x,y in zip(line,actual)]
    errorsquared=[(value)**2 for value in error]
    rmse=np.sqrt(np.mean(errorsquared))
    return(rmse)

这些函数分别计算 MAE 和 RMSE,和我们之前做的一样。如果你运行 print(get_rmse(regressionline,saleslist)),你会看到回归线的 RMSE 约为 3,725;如果你运行 print(get_rmse(hypothesizedline,saleslist)),你会看到我们假设的线的 RMSE 约为 3,969。

你会注意到我们回归线的 RMSE 比假设线的 RMSE 小。这使我们可以根据 RMSE 指标说回归线比假设线更适合数据。

我们回归线的 RMSE 低于假设线的 RMSE 并非偶然。当我们之前在 Python 中运行命令regressor.fit(x,y)时,regressor.fit()方法执行了由伟大数学家阿德里安-玛丽·勒让德(Adrien-Marie Legendre)发明的线性代数计算,并首次在 1805 年发布。勒让德的计算方法接受一组点作为输入,输出的是最小化 RMSE 值的截距和系数。换句话说,勒让德方法确定的系数对应的直线,在数学上保证比任何其他我们尝试绘制的、用来拟合数据的无数条直线具有更低的 RMSE。当我们称回归线为最优拟合线时,我们的意思是它在所有使用我们指定的变量的可能直线中,数学上保证具有最低的 RMSE。这一保证是线性回归持续受欢迎的原因,也是为什么它多年来仍然是寻找适合数据集的直线的标准方法。

回归器输出的直线是最优拟合线,不仅在宽松的意义上它看起来很好地拟合了数据点云,而且在严格的定量意义上,所有通过数据点云的无数条直线中,它保证具有最低的 RMSE。你可以随意尝试其他直线并检查它们的 RMSE 值——你不会找到比我们的回归线表现更好的直线。

使用回归进行未来趋势预测

到目前为止,我们使用线性回归找到了最适合历史数据的直线。但我们的历史数据都来自过去,因此我们还没有进行任何真正的预测。从线性回归到预测是简单的:我们只需要外推。

我们在图 2-2 中绘制的虚线回归线停止在图表的边缘,左边是第 0 个月,右边是第 107 个月,但没有理由它必须停在那里。如果我们继续将回归线向右延伸,我们可以看到任何未来月份的预期值,尽管时间跨度可能很远。当然,扩展回归线时,我们会保持相同的斜率和截距。让我们编写代码来实现这一点:

x_extended = np.append(carsales['period'], np.arange(108, 116))

在这里,我们创建了变量x_extended。这个变量是由两组数字组合而成的。首先,它包含我们数据集中period列的值,这些值记录了从 0 到 107 的周期顺序。其次,它包含从 108 到 115 的所有数字——这些数字代表了数据结束后未来的月份(第 108 个月、第 109 个月……直到第 115 个月)。我们使用np.append()方法将这两者合并,最终结果是原始x变量的扩展版。

接下来,我们可以使用回归模型的predict方法来计算x_extended中每个月份对应的回归线上的值:

x_extended=x_extended.reshape(-1,1)
extended_prediction=regressor.predict(x_extended)

现在,我们已经将预测值存储在变量extended_prediction中。如果你查看extended_prediction,你可以看到这些预测值。这些预测值遵循一个简单的规律:每个预测值比前一个高出约 81.2。这是因为 81.2 是回归线的斜率。记住,81.2 不仅仅是回归线的斜率,它也是我们预计每个月汽车销售增长的大小,忽略了随机性和季节性波动。

我们在这里使用的预测方法很有帮助,但我们并不真正需要它。我们可以通过将数字代入回归方程来获得回归线上的任何值:

汽车销售 = 81.2 · 周期 + 10250.8

无论我们如何得到下一个预测值,我们都可以将其绘制出来,并查看它们在图表上的表现(图 2-5):

plt.scatter(carsales['period'],carsales['sales'])
plt.plot(x_extended,extended_prediction,'r--')
plt.title('Car Sales by Month')
plt.xlabel('Month')
plt.ylabel('Sales')
plt.show()

图 2-5:一条回归线向前推算了几个周期,用于预测

这个图表可能不会让你感到惊讶。它看起来几乎和图 2-2 一样,且本应如此。唯一的区别是我们将回归线向右延伸了几个周期,以查看我们预期的汽车销售量——也就是我们预测的销售量——在不久的未来会是什么样子。这种回归线的外推是一种简单但有效的预测方法。

我们已经通过线性回归完成了预测,但我们可以做更多的工作来提高预测的准确性。在接下来的几节中,我们将讨论如何评估和改进我们预测的表现。

尝试更多回归模型

我们在前几节做的线性回归是一个简单的类型,称为单变量线性回归。这种回归方法只使用一个变量来预测另一个变量。在我们的例子中,我们仅使用了周期变量来预测销售额。只使用一个变量有几个优点:首先,它很简单;其次,它创建了一条简单的直线,可以表达数据中的某些规律,而不会包括随机噪声。但我们还有其他选择。

多变量线性回归预测销售额

如果我们使用其他变量来预测销售,而不仅仅是时间周期,我们可以执行一种更复杂的回归分析,称为多元线性回归。多元线性回归的细节与单变量线性回归基本相同;唯一的真正区别是我们用于预测的变量数量。我们可以选择任何我们喜欢的变量进行多元回归:国内生产总值(GDP)增长率、人口估算、汽车价格、通货膨胀率,或任何我们想要的其他变量。

目前,我们受到限制,因为我们的数据集不包含任何这些变量。它仅包含时间周期和销售数据。然而,我们仍然可以进行多元回归,方法是使用从时间周期变量推导出来的变量。例如,我们可以使用period²作为多元回归中的新变量,或者使用 log(period),或任何其他时间周期变量的数学变换。

请记住,当我们之前进行回归分析时,我们在以下方程式中找到了 mb(斜率和截距)变量:

y = m · x + b

当我们使用多个变量来预测汽车销售时,我们也在求解斜率和截距变量。唯一的区别是我们还需要求解更多的变量。如果我们使用三个变量进行预测(可以分别称为 x[1],x[2],和 x[3]),那么我们需要在以下方程式中找到 m[1],m[2],m[3] 和 b 变量:

y = m[1] · x[1] + m[2] · x[2] + m[3] · x[3] + b

这个思想与单变量回归相同,但我们得到的斜率更多,因为有更多的预测变量。如果我们想用periodperiod²和period³来预测汽车销售,我们就需要估算方程式 2-1 中的 m[1],m[2],m[3] 和 b 变量:

汽车销售 = m[1] · period + m[2] · period² + m[3] · period³ + b

方程式 2-1:使用我们的汽车销售数据进行多元回归的方程式

让我们看看生成这些时间周期变量转化并进行三变量线性回归的代码:

carsales['quadratic']=carsales['period'].apply(lambda x: x**2)
carsales['cubic']=carsales['period'].apply(lambda x: x**3)

x3 = carsales.loc[:,['period','quadratic','cubic']].values.reshape(-1,3)
y = carsales['sales'].values.reshape(-1,1)

regressor_cubic = LinearRegression()
regressor_cubic.fit(x3, y)
plt.scatter(carsales['period'],carsales['sales'])
plt.plot(x,regressor.predict(x),'r-')
plt.plot(x,regressor_cubic.predict(x3),'r--')
plt.title('Car Sales by Month')
plt.xlabel('Month')
plt.ylabel('Sales')
plt.show()

在这段代码中,我们定义了两个新变量:quadratic,其值等于period²,和cubic,其值等于period³。然后,我们定义了一个新的x3数据框,其中包含这三个新变量,并且我们将它的形状调整为适合回归模型的形式。对于这个三变量的多元回归,正确的形状是一个包含 108 行的数组,每一行是我们三个变量在某个月份的值列表。只要数据具有正确的形状,我们就可以使用fit()方法进行任何单变量或多变量的线性回归,且变量数量不受限制。调用fit()方法后,我们计算出由此回归预测的数值,并将其绘制成图。这段代码生成了图 2-6 中的图表。

图 2-6:同样拟合数据的曲线

这里,你可以看到两条回归线。一条是(实线)直线,是我们之前(单变量)回归的结果。另一条新的回归线不是直线,而是一条(虚线)曲线——更准确地说,是一条三次曲线。线性回归最初是为了拟合直线而设计的(因此得名线性),但我们也可以用它来找到最佳拟合曲线和非线性函数,如图 2-6 中的三次多项式。

无论是找到最佳拟合直线还是最佳拟合曲线,我们所使用的线性回归方法都是完全相同的。同样,使用多个变量进行预测与使用一个变量进行单变量回归其实并没有太大区别:输出依然能够拟合我们的数据,实际上,我们的新曲线与直线非常接近。每次选择不同的变量进行回归时,输出会看起来稍有不同:可能形状不同或曲线不同。但它总是能够拟合数据。在这种情况下,如果你想知道方程 2-1 中的未知变量,我们可以像下面这样打印出来:

print(regressor_cubic.coef_)
print(regressor_cubic.intercept_)

这些print()语句的输出结果如下:

[[ 8.13410634e+01 7.90279561e-01 -8.19451188e-03]]
[9746.41276055]

这些输出使我们能够填写方程 2-1 中的所有变量,从而得到一个使用三次多项式估算汽车销量的方程:

汽车销量 = 81.34 · 周期 + 0.79 · 周期² – 0.008 · 周期³ + 9746.41

关于图 2-6,一个重要的注意点是我们的回归线在最后几个周期中的不同表现,即图表右侧的部分。我们单变量回归得到的直线每个周期增加约 81.2,且当我们将其外推到更远的右侧时,它将继续预测每个周期约增加 81.2。相比之下,我们的多元回归得到的曲线在图表的右侧开始向下弯曲。如果我们将其外推到更远的右侧,它将预测每个月的汽车销量会永远下降。

这两条线虽然行为相似,且都是线性回归的结果,但它们对未来的预测却正好相反:一条预测增长,另一条预测收缩。本章稍后我们将详细讨论如何选择哪条回归线用于预测。

用三角函数捕捉变化

我们可以在多元回归中添加任意数量的变量。每一次选择的变量都会导致一条形状略有不同的曲线。我们在每个回归问题中需要做出的一个困难选择是:选择哪些变量来添加到回归模型中。

在这种情况下,单变量回归线(图 2-2 中的直线)和三次回归线(图 2-6 中的曲线)都是可接受的,并且都可以用来预测未来。然而,尽管它们都穿过看似我们的数据点云的中心,但仍然有太多变动它们没有捕捉到——许多单个月份的销售比这些线高得多或低得多。理想情况下,我们可以找到一组变量,当使用线性回归进行拟合时,得到一条更好地适应这些变动的曲线。在这种情况下,对数据绘制方式做一个小的改动,可以让我们接下来的工作变得更加清晰。

让我们通过对代码做一个小的修改(以粗体显示),将图 2-1 从散点图改为折线图。

from matplotlib import pyplot as plt
plt**.plot**(carsales['period'],carsales['sales'])
plt.title('Car Sales by Month')
plt.xlabel('Month')
plt.ylabel('Sales')
plt.show()

图 2-7 展示了新的图示。

图 2-7:折线图使得每年内的模式(高夏季和低冬季)更加明显。

这个新的图示展示了相同的数据,但将其绘制为折线图,而不是一系列点。通过折线图,另一个模式变得更加明显。我们可以看到,单个年份内每月销售的波动比在散点图中看起来更有序。

特别是,我们的数据包括了九年的销售数据,图中的折线图显示了正好九个主要的峰值。看似完全随机的噪声实际上具有某些结构:每年夏季都有一个可预测的销售高峰,每年冬季都有一个相应的低谷。如果你再想一想,你可能会意识到为什么一年内会有这种波动:因为这些数据来自魁北克,在那里寒冷的冬季与低活动水平相关联,而温暖的美丽夏季则与外出购物和长途自驾游等活动相关联,这些活动需要汽车。

现在你可以看到汽车销量在一年中如何上下波动,也许它让你想起了一个数学函数。事实上,周期性增长和下降的模式看起来像是一个三角函数曲线,比如正弦曲线或余弦曲线。图 2-8 展示了正弦曲线和余弦曲线的例子。

图 2-8:正弦曲线和余弦曲线的图示

让我们尝试使用周期的正弦和余弦在多元回归中进行回归分析:

import math
carsales['sin_period']=carsales['period'].apply(lambda x: math.sin(x*2*math.pi/12))
carsales['cos_period']=carsales['period'].apply(lambda x: math.cos(x*2*math.pi/12))

x_trig = carsales.loc[:,['period','sin_period','cos_period']].values.reshape(-1,3)
y = carsales['sales'].values.reshape(-1,1)

regressor_trig = LinearRegression()
regressor_trig.fit(x_trig, y)

plt.plot(carsales['period'],carsales['sales'])
plt.plot(x,regressor_trig.predict(x_trig),'r--')
plt.title('Car Sales by Month')
plt.xlabel('Month')
plt.ylabel('Sales')
plt.show()

在这个代码片段中,我们定义了period变量的正弦和余弦变换,然后我们拟合了一个回归模型,使用这些新变量作为预测因子。最后,我们绘制了结果,见图 2-9。

图 2-9:拟合到我们数据上的三角函数曲线

在图 2-9 中,你可以看到原始销售数据以实线形式绘制,而三角函数回归曲线以虚线形式绘制。你可以看到,我们现在真的取得了一些进展。依赖于三角函数的回归模型似乎特别适合这些数据。特别是,它似乎在每年的高峰期上升,在每年的低谷期下降,从而更加接近真实的销售数据。我们可以通过以下方式验证该三角曲线的 RMSE 低于直线模型:

trig_line=regressor_trig.predict(x_trig)[:, 0]
print(get_rmse(trig_line,saleslist))

我们得到的 RMSE 输出是迄今为止最低的:约为 2,681。三角函数帮助我们很好地拟合数据并非完全是偶然的。事实上,我们星球上季节性温度的升高和下降是由于地球在围绕太阳公转过程中角度的变化。地球相对于太阳的角度变化呈现类似正弦曲线的变化,因此每年的温度变化也遵循类似正弦曲线的规律。如果汽车销售受冬季和夏季天气变化的影响,并且温度变化与之相关,那么它们也应该遵循类似正弦曲线的规律。无论我们是通过盲目偶然、观察图 2-1 中的散点图,还是因为我们知道地球绕太阳公转的天文学知识发现了三角模型,我们似乎都找到了一个拟合数据良好的回归曲线。

选择用于预测的最佳回归模型

我们已经观察到,包含正弦和余弦周期项的回归线似乎能很好地拟合数据。当我们说这条线拟合得很好时,我们的意思是,从定性上讲,图 2-9 中的虚线与实线非常接近。更准确地说,我们的意思是,从定量上讲,三角函数回归线的 RMSE 低于我们看过的其他回归线的 RMSE。每当我们找到一个 RMSE 较低的模型时,我们就得到了一个更好地拟合数据的模型。

自然的诱惑是不断寻找具有更低 RMSE 的新回归模型。例如,让我们尝试一个包含七个预测项的回归模型来预测销售,并计算该模型的 RMSE:

carsales['squareroot']=carsales['period'].apply(lambda x: x**0.5)
carsales['exponent15']=carsales['period'].apply(lambda x: x**1.5)
carsales['log']=carsales['period'].apply(lambda x: math.log(x+1))

x_complex = carsales.loc[:,['period','log','sin_period','cos_period', \
'squareroot','exponent15','log','quadratic', 'cubic']].values.reshape(-1,9)
y = carsales['sales'].values.reshape(-1,1)

regressor_complex = LinearRegression()
regressor_complex.fit(x_complex,y)

complex_line=[prediction for sublist in regressor_complex.predict(x_complex) \
for prediction in sublist]
print(get_rmse(complex_line,saleslist))

在这个代码片段中,我们重复了之前做过的步骤:定义一些变量,在线性回归中使用这些变量,并检查回归的 RMSE。注意某些行末尾的反斜杠(\)。这些是行续符:它们告诉 Python,当前行和下一行应当视为一行代码。我们在这里使用它们是因为完整的行无法适应书页的大小。在家里,你可以使用行续符,或者如果能够输入完整的行而不需要换行,则可以忽略它们。

在前面的代码片段结束时,我们检查了这个新回归模型的 RMSE,结果发现它大约为 2,610,甚至低于图 2-9 中三角函数模型的 RMSE。如果 RMSE 是我们判断模型拟合优度的标准,并且我们得到了迄今为止最低的 RMSE,那么得出这是我们目前最好的模型,并且应该使用该模型进行预测,似乎是一个很自然的结论。

但是要小心;这个看似合理的结论其实并不正确。我们在模型选择时所采取的方法存在一个问题:它并不完全模拟我们在现实生活中遇到的预测情况。想想我们做了什么。我们用过去的数据来拟合回归线,然后根据回归线与过去数据点的接近程度(即它的 RMSE)来判断回归线的好坏。我们在使用过去的数据来拟合回归线并评估它的表现。在一个真实的预测场景中,我们会用过去的数据来拟合回归线,但我们应该用未来的数据来评估它的表现。只有当一种预测方法能够预测未知的未来时,它才是有价值的。

当我们选择最佳回归线用于预测时,我们希望找到一种方法,基于回归线在未来数据上的表现来评估不同的回归线。这是不可行的,因为未来还没有发生,所以我们永远无法获得未来数据。但我们可以在执行和评估回归时做出一些小的改变,这样我们在过去数据上的表现评估就能很好地估计它们在预测未来时的表现。

我们需要做的是将完整的数据集分成两个独立且互斥的子集:一个是训练集,包含大部分数据,另一个是测试集,包含其余数据。我们只使用训练集来拟合回归模型,或者换句话说,来训练它们。拟合/训练回归模型后,我们会使用测试集来评估回归模型的好坏,使用像 RMSE 或 MAE 这样的度量标准。

这个简单的变化带来了重要的区别。我们不再基于用来拟合回归模型的相同数据来评估性能,而是基于未在拟合过程中使用的独立数据来评估。我们的测试集来自过去,但它仿佛来自未来,因为它没有用于确定回归中的系数和截距,只用于测试回归预测的准确性。由于测试集没有用于拟合回归模型,我们有时会说回归模型没有测试数据中“学习”,或者说它仿佛测试数据来自未来。通过拥有一些仿佛来自未来的数据,我们的回归评估更接近于一个真实的预测过程,在这个过程中,预测未来是最重要的目标。

让我们看看实现训练/测试分割的代码,然后我们将了解它为何如此有效:

x_complex_train = carsales.loc[0:80,['period','log','sin_period','cos_period','squareroot', \
'exponent15','log','quadratic','cubic']].values.reshape(-1,9)
y_train = carsales.loc[0:80,'sales'].values.reshape(-1,1)

x_complex_test = carsales.loc[81:107,['period','log','sin_period','cos_period','squareroot', \
'exponent15','log','quadratic','cubic']].values.reshape(-1,9)
y_test = carsales.loc[81:107,'sales'].values.reshape(-1,1)

regressor_complex.fit(x_complex_train, y_train)

在这里,我们将数据分为两组:训练集和测试集。我们使用训练集来训练数据(拟合回归线)。然后,我们可以使用测试集来测试回归模型的表现。如果你考虑这个方法,它类似于实际的预测情况:我们仅通过过去的数据来训练模型,但模型必须能够在没有用于训练的数据(未来数据,或类似未来的数据)上表现良好。创建这样的测试集,本质上是在创造一个模拟的未来。

在上面的代码片段中,我们使用前 81 个时间段作为训练数据,其余的 27 个时间段作为测试数据。从百分比上看,我们使用了 75%的数据用于训练,约 25%的数据用于测试。按这种比例拆分训练集和测试集是很常见的:70%的训练数据和 30%的测试数据也很常见,80/20 和 90/10 的拆分也很常见。我们通常将大多数数据保留在训练集中,因为找到正确的回归线至关重要,使用更多数据来训练有助于我们找到最准确的回归线(具有最高预测精度的那一条)。与此同时,我们在测试集上需要足够的数据,因为我们还需要准确估算回归模型在新数据上的表现。

在创建了训练集和测试集之后,我们可以在测试集上测试不同的回归模型,并检查每个模型的 RMSE 或 MAE。测试集上 RMSE 或 MAE 最小的模型,是我们可以用来预测实际未来的合理选择。让我们检查一下迄今为止我们运行的几个回归模型的 RMSE:

x_train = carsales.loc[0:80,['period']].values.reshape(-1,1)
x_test = carsales.loc[81:107,['period']].values.reshape(-1,1)
x_trig_train = carsales.loc[0:80,['period','sin_period','cos_period']].values.reshape(-1,3)
x_trig_test = carsales.loc[81:107,['period','sin_period','cos_period']].values.reshape(-1,3)

regressor.fit(x_train, y_train)
regressor_trig.fit(x_trig_train, y_train)

complex_test_predictions=[prediction for sublist in \
      regressor_complex.predict(x_complex_test) for prediction in sublist]
test_predictions=[prediction for sublist in regressor.predict(x_test) for \
      prediction in sublist]
trig_test_predictions=[prediction for sublist in \
      regressor_trig.predict(x_trig_test) for prediction in sublist]

print(get_rmse(test_predictions,saleslist[81:107]))
print(get_rmse(trig_test_predictions,saleslist[81:107]))
print(get_rmse(complex_test_predictions,saleslist[81:107]))

在运行了上面的代码片段后,你会看到我们的单变量回归在测试集上的 RMSE 约为 4,116。三角函数多元回归的 RMSE 约为 3,461——比单变量回归要好得多。相比之下,包含九个预测项的复杂回归模型在测试集上的 RMSE 约为 6,006——表现非常糟糕。尽管它在训练集上表现优秀,但我们发现它在测试集上的表现非常糟糕。

这个复杂的模型展示了一个特别糟糕的过拟合例子。在这个常见的机器学习问题中,模型过于复杂,拟合了数据中的噪声和偶然性,而不是数据的真实模式。过拟合通常发生在我们为了在训练集上获得低误差而导致在测试集上出现更高误差的情况下。

例如,假设由于某种巧合,魁北克的汽车销量在 1960 年至 1968 年期间,每次猎户座星 Betelgeuse 的 V 波段亮度大于 0.6 时都会出现激增。如果我们在回归分析中将 Betelgeuse 的 V 波段亮度作为一个参数,我们会发现由于这个巧合,在预测 1960 到 1968 年的数据时,我们的 RMSE 相当低。发现 RMSE 较低可能会让我们很有信心认为我们有一个表现优秀的模型。我们可能会将这种模式外推到未来,并预测在 Betelgeuse 亮度周期的高点时未来销量的激增。然而,由于 Betelgeuse 与汽车销量之间的过去关系只是巧合,将这种模式外推到未来会导致巨大的误差;这会导致未来预测的 RMSE 相当高。Betelgeuse/汽车销量的关系仅仅是噪声,我们的回归分析应当捕捉的只有真实信号,而不是噪声。将 Betelgeuse 的亮度数据包括在回归分析中就是一个过拟合的例子,因为我们为了降低过去的 RMSE,可能会导致未来的 RMSE 升高。

这个例子应该清楚地表明,使用训练集上的误差度量来选择最佳模型可能会导致我们选择一个在测试集上误差较高的模型。因此,在所有预测任务中,测试集上的误差度量是用来比较模型的正确指标。作为一般规则,当你在回归分析中包括了太多无关变量时,你可以预期会发生过拟合。因此,你可以通过从回归中移除无关变量(如 Betelgeuse 的亮度)来避免过拟合。

问题在于,我们并不总是完全确定哪些变量是无关的,哪些变量实际上是有用的。这就是为什么我们必须尝试几个模型并检查性能。找到在测试集上 RMSE 最低的模型,这将是那些变量组合恰当且不会让你分心于巧合或导致过拟合的模型。

现在我们已经根据模型在测试集上的 RMSE 进行了比较,我们可以选择三角函数模型作为目前为止的最佳模型。我们可以在该模型中向前外推一个周期,并确定下个月的消费者需求预测,就像我们之前在单变量模型中进行的外推一样。我们可以将这个数字作为基于严格线性回归分析的估算值报告给业务部门。不仅如此,我们还可以解释为什么做出这个预测,以及为什么使用这个模型,包括最佳拟合线的概念、季节的三角函数建模以及在测试集上较低(有利的)误差。如果没有反对意见或相反的业务考虑,我们可以在下个月订购这个数量的汽车,并且我们可以预计客户将会购买接近这个数量的汽车。

进一步探索

线性回归和预测都是可以填满很多教科书的主题。如果你继续学习数据科学,你将有机会了解与这些主题相关的许多细节和微妙之处。

如果你想在数据科学领域达到高级水平,其中一件你应该考虑学习的内容是线性回归背后的线性代数。你可以将数据中的每个观测值看作是矩阵的一行,然后你可以使用矩阵乘法和矩阵求逆来计算最佳拟合线,而不是依赖 Python 库来为你进行计算。如果你深入探索这些线性代数概念,你将了解线性回归背后的数学假设。理解这些数学假设将使你能更准确地判断线性回归是否是处理你的数据的最佳方法,或者你是否应该使用本书后面介绍的某些方法(特别是第六章讨论的监督学习话题)。

另一个你应该熟悉的问题是线性回归作为预测方法的局限性。正如其名称所示,线性回归是一种线性方法,旨在用于具有线性关系的变量。例如,如果客户每周比前一周多订购约 10 单位的产品,那么时间与客户需求之间存在线性关系,线性回归将是一个完美的工具来衡量这一增长并预测未来的客户需求。另一方面,如果你的销售额每周翻倍一年,然后突然崩溃,再过一段时间缓慢回升,那么时间与销售之间的关系将高度非线性,线性回归可能无法得出准确的预测。

同样,请记住,当我们使用线性回归进行预测时,我们是在将过去的增长外推来预测未来的增长。如果某些情况在你的历史数据中没有体现或没有考虑到,线性回归就无法准确预测它们在未来的发生。例如,如果你使用来自稳定、繁荣年份的数据作为训练数据,你可能会预测未来会有稳定、繁荣的增长。然而,你可能会发现全球金融危机或疫情改变了一切,而由于回归的训练数据没有包含疫情,未来就不会有任何关于疫情的预测。回归模型仅在未来类似过去时有效。像战争和疫情这样的事件是如此无法预测,以至于回归永远无法给出完全准确的预测。在这些情况下,准备工作比预测更为重要;确保你的企业为艰难时刻和意外情况做好准备,而不是指望线性回归总能给出完全正确的答案。尽管预测很重要,线性回归很强大,但记住这些限制是非常重要的。

摘要

我们从一个常见的商业场景开始本章:一个公司需要决定应该订购多少新库存。我们使用线性回归作为主要的预测工具,并稍微涉及了它的编程方面(如何编写回归的代码)、统计方面(我们可以使用哪些误差度量来确定模型的拟合度)以及数学方面(为什么我们的特定直线是最佳拟合线)。在经过这些方面的分析后,我们得出了一个我们认为最好的模型,用它来预测下个月的消费者需求。

这个场景——考虑一个商业问题,并使用编程、数学理论和常识来寻找一个数据驱动的解决方案——是数据科学的典型应用。在接下来的章节中,我们将探讨其他商业场景,并讨论如何使用数据科学来找到它们的理想解决方案。在下一章中,我们将介绍数据分布,并展示如何检验两个组是否显著不同。

第三章:组间比较

在本章中,我们将讨论如何在不同组之间进行智能比较,使用来自商业场景的例子。我们将从小处开始,首先仅查看一个组。我们将了解哪些描述性统计量最简洁地描述它,绘制出能够捕捉其本质的图形,并比较它的各个样本。然后,我们将准备好对来自两个组的样本进行推理。最后,我们将通过查看统计显著性检验来结束:t 检验和曼-惠特尼 U 检验。

阅读人口数据

让我们从读取一些数据开始。这些数据记录了 1,034 名职业棒球运动员的身高、体重以及测量时的年龄。你可以直接从bradfordtuckfield.com/mlb.csv下载这些数据。其原始来源是统计在线计算资源(SOCR)网站(web.archive.org/web/20220629205951/https://wiki.stat.ucla.edu/socr/index.php/SOCR_Data_MLB_HeightsWeights)。

import pandas as pd
mlb=pd.read_csv('mlb.csv')
print(mlb.head())
print(mlb.shape)

在这个代码片段中,我们导入了 pandas 并使用其read_csv()方法来读取我们的数据。这都是简单的数据摄取,就像我们在第一章和第二章中所做的那样。运行这个片段后,你应该能看到以下输出:

 name team       position  height  weight    age
0    Adam_Donachie  BAL        Catcher      74   180.0  22.99
1        Paul_Bako  BAL        Catcher      74   215.0  34.69
2  Ramon_Hernandez  BAL        Catcher      72   210.0  30.78
3     Kevin_Millar  BAL  First_Baseman      72   210.0  35.43
4      Chris_Gomez  BAL  First_Baseman      73   188.0  35.71
(1034, 6)

输出的最后一行显示了数据的形状,即数据集中的行数和列数。我们可以看到数据有 1,034 行和 6 列。每一行对应一个被测量的人,每一列对应记录的关于该人的一个事实。这 1,034 人被统称为我们的总体,在统计学中,总体指的是为回答特定问题而研究的一组相似项。

摘要统计

每当我们获得一个新的数据集时,进行探索性分析是非常有用的。我们可以做的一件事是运行print(mlb.describe())来一次性查看我们的摘要统计信息:

 height       weight          age
count  1034.000000  1033.000000  1034.000000
mean     73.697292   201.689255    28.736712
std       2.305818    20.991491     4.320310
min      67.000000   150.000000    20.900000
25%      72.000000   187.000000    25.440000
50%      74.000000   200.000000    27.925000
75%      75.000000   215.000000    31.232500
max      83.000000   290.000000    48.520000

在任何数据分析工作中,尽早并频繁地绘制数据也是一个好主意。我们将使用以下代码创建一个箱型图:

import matplotlib.pyplot as plt
fig1, ax1 = plt.subplots()
ax1.boxplot([mlb['height']])
ax1.set_ylabel('Height (Inches)')
plt.title('MLB Player Heights')
plt.xticks([1], ['Full Population'])
plt.show()

在这里,我们导入了 Matplotlib 来创建图表。我们使用其boxplot()命令创建我们人群中所有身高的箱型图。你可以在图 3-1 中看到结果。

图 3-1:展示我们职业棒球(MLB)人群身高分布的箱型图

这个箱线图类似于我们在第一章中看到的箱线图。记住,箱线图展示了数据的范围和分布。在这里,我们可以看到数据中身高的最小值约为 67,最大值约为 83。中位数(位于箱子中间的水平线)约为 74 英寸。我们可以看到,Matplotlib 将一些数据点视为离群值,这就是为什么它们被绘制成超出箱子上下垂直线范围的圆圈。箱线图提供了一种简单的方法来探索我们的群体并更好地理解它。

随机样本

在许多常见的情境中,我们有兴趣研究一个群体,但我们无法接触到整个群体,因此我们研究一个小部分或样本。例如,医学研究人员可能想开发一种能治愈 50 岁以上所有女性的疾病的药物。研究人员无法联系到全球所有 50 岁以上的女性,因此他们招募了这个整体人群的一个样本,也许是几百人。他们研究这种药物对这个样本的效果。他们希望他们的样本能代表整体人群,这样如果药物对样本有效,也能对整体人群有效。

招募样本是你应该小心进行的,以确保样本尽可能地代表整体人群。例如,如果你在奥林匹克训练设施招募参与者,你的样本将包含比平均水平更健康的人群,因此你可能会开发出只对极其健康的人有效的药物,而对普通人群无效。如果你在波兰社区节庆活动上招募参与者,你可能会开发出只对东欧人有效的药物,而对其他人无效。收集一个类似于整体人群的样本的最佳方法是随机抽样。通过从整个群体中随机选择样本,你期望每种不同类型的人被选择的概率相等。

让我们来看一下我们棒球运动员群体的样本,我们可以在 Python 中这样创建:

sample1=mlb.sample(n=30,random_state=8675309)
sample2=mlb.sample(n=30,random_state=1729)

在这里,我们使用了方便的 pandas sample()方法。这个方法会随机选择 30 名棒球运动员作为我们的两个样本,sample1sample2。设置random_state参数并不是必要的,但我们在这里设置了它,因为它确保你运行相同的代码时,得到的结果与你看到的一致。

你可能会想,为什么我们选择了 30 个样本,而不是 20 个、40 个或其他数量。实际上,我们可以通过将n=30改为n=20n=40或任何我们喜欢的其他数字,轻松地选择任何其他数量的样本。当我们为随机样本选择一个较大的n时,我们期望样本能够非常接近整个总体。但有时招募参与者可能会比较困难,因此我们希望选择一个较小的n来避免招募的难度。在统计学中,选择n = 30 是一个常见的约定;当我们选择的样本大小至少为 30 时,我们可以合理地相信,样本足够大,可以使我们的统计计算给出良好的结果。

我们还可以创建第三个样本,我们将手动定义它如下:

sample3=[71, 72, 73, 74, 74, 76, 75, 75, 75, 76, 75, 77, 76, 75, 77, 76, 75,\ 
76, 76, 75, 75, 81,77, 75, 77, 75, 77, 77, 75, 75]

我们知道sample1sample2是来自我们棒球选手总体的随机样本,因为它们是通过sample()生成的。但sample3中的数据来源尚不清楚。稍后,你将学习如何使用统计检验推理sample3是否可能是我们棒球选手总体的随机样本,或者它是否更可能与另一个总体相关,如篮球选手或其他群体。继续思考sample3,因为推理sample3的来源(以及一般来说,判断两个给定样本是否来自同一总体)将是本章的核心目标。

让我们看一下这些样本的图表,看看它们是否彼此相似,并且与整个总体相似:

import numpy as np
fig1, ax1 = plt.subplots()
ax1.boxplot([mlb['height'],sample1['height'],sample2['height'],np.array(sample3)])
ax1.set_ylabel('Height (Inches)')
plt.title('MLB Player Heights')
plt.xticks([1,2,3,4], ['Full Population','Sample 1','Sample 2','Sample 3'])
plt.show()

在这里,我们使用之前用过的相同箱线图代码,但这次不是仅绘制一个数据集,而是绘制四个数据集:整个总体的身高分布,以及三个样本的身高分布。我们可以在图 3-2 中看到结果。

图 3-2:我们完整的 MLB 总体(最左边)、两个来自总体的样本(中间)以及一个可能来自或不来自我们总体的神秘样本(右边)的箱线图

我们可以看到,这些箱线图没有完全相同,但它们之间确实有一些相似之处。我们看到一些相似的中位数值和 75 百分位数值,以及一些相似的最大值。前三个箱线图的相似性应该符合你的直觉:当我们从一个总体中抽取足够大的随机样本时,样本应该与总体相似,并且彼此相似。我们还可以检查与每个样本相关的简单汇总统计量,如均值:

print(np.mean(sample1['height']))
print(np.mean(sample2['height']))
print(np.mean(sample3))

在这里,我们检查了所有样本的平均身高。sample1的平均身高为 73.8,sample2的平均身高为 74.4,sample3的平均身高为 75.4。这些均值与总体的平均身高 73.7 非常接近。在这个背景下,总体的平均身高有一个特殊的名称;它被称为总体的期望值。如果我们从总体中抽取一个随机样本,我们期望该样本的平均身高大致与总体的期望值 73.7 相同。至少有两个样本是来自总体的随机样本,我们可以看到它们的均值确实接近我们的期望值。

当我们查看sample3的箱形图时,我们可以看到它似乎不像其他三个箱形图那样相似。我们可以将其解释为它不是来自我们棒球运动员总体的随机样本。另一方面,它看起来并没有足够不同于总体或其他样本,以至于我们可以立刻确定它不是来自我们总体的随机样本。在我们能够确信sample3是来自我们总体的随机抽样,还是来自其他总体之前,我们需要更多的信息。

到目前为止,我们使用了模糊和印象性的语言来描述我们的样本:它们相似,它们的均值相对接近或者大致相同于我们的期望。如果我们想要做出具体的、基于证据的决策,我们需要更加精确。在下一部分,我们将探讨统计学家为推理群体间差异所开发的定量方法,包括一些易于使用的测试,帮助我们决定两个群体是否来自相同的总体。

样本数据之间的差异

我们看到sample1sample2之间大约有 0.6 英寸的差异,而sample1sample3之间的差异超过了 1.6 英寸。这里是我们想要回答的一个重要问题:我们是否相信sample3是来自与sample1sample2相同总体的随机样本?我们需要一种比直觉更可靠的方法来判断,例如,样本均值之间 0.6 英寸的差异是合理或可能的,而样本均值之间 1.6 英寸的差异使得它们来自相同总体的可能性变得不太可信。样本均值之间的差异多大时,才会让我们认为这两个样本不可能来自同一总体?

要回答这个问题,我们需要理解我们期望从总体中随机抽取的样本之间的大小差异。到目前为止,我们只看了来自我们总体的两个随机样本。与其仅仅基于这两个样本进行概括,不如看一大批样本,看看它们之间的差异有多大。这将帮助我们理解哪些变化是合理的,哪些变化是不可接受的。

下面是一些代码,用来获取 2,000 个样本均值及其差异:

alldifferences=[]
for i in range(1000):
    newsample1=mlb.sample(n=30,random_state=i*2)
    newsample2=mlb.sample(n=30,random_state=i*2+1)
    alldifferences.append(newsample1['height'].mean()-newsample2['height'].mean())

print(alldifferences[0:10])

在这个代码片段中,我们将 alldifferences 变量创建为空列表。然后我们创建一个循环,进行 1,000 次迭代。在每次迭代中,我们创建两个新的样本,并将它们的样本均值之差附加到 alldifferences 列表中。最终结果是一个完全填充的 alldifferences 列表,其中包含 1,000 个随机选取样本之间的差异。在运行此代码片段后,您应该看到以下输出:

[0.8333333333333286, -0.30000000000001137, -0.10000000000000853,\
-0.1666666666666572, 0.06666666666667709, -0.9666666666666686,\
0.7999999999999972, 0.9333333333333371, -0.5333333333333314,\
-0.20000000000000284]

你可以看到我们检查的前两个样本的均值相差约 0.83 英寸。第二对样本的均值相差约 0.3 英寸。第六对样本的均值几乎相差一英寸(约 -0.97 英寸),而第五对样本的均值几乎相同,仅相差约 0.07 英寸。通过查看这 10 个数字,我们可以看到,0.6 并不是我们总体中两个样本之间不合理的差异,因为我们前 10 个差异中有几个大于 0.6。然而,到目前为止,我们看到的差异都没有超过 1 英寸,所以 1.6 英寸开始显得不太合理。

我们可以通过绘制 alldifferences 列表来查看我们 1,000 个差异的完整表现:

import seaborn as sns
sns.set()
ax=sns.distplot(alldifferences).set_title("Differences Between Sample Means")
plt.xlabel('Difference Between Means (Inches)')
plt.ylabel('Relative Frequency')
plt.show()

这里,我们导入 seaborn 包,因为它可以绘制美观的图表。我们使用它的 distplot() 方法来绘制我们找到的差异。你可以在图 3-3 中查看结果。

图 3-3:显示随机样本均值差异分布的直方图,形成一个大致的钟形曲线模式

在这个直方图中,每个柱状条代表一个相对频率;它表示每个观察值相对于其他观察值的可能性。在 x 轴标记为 0 的位置有一个较高的柱状条。这表示我们的alldifferences列表中,接近 0 的差异相对较多。在 x = 1 的位置出现了一个较低的柱状条。这表示样本均值之间差异接近 1 的情况相对较少。整个图形的形状应该是直观上可以理解的:我们的随机样本之间很少有很大的差异,因为它们来自同一个总体,我们期望它们的均值大致相同。

图 3-3 中柱形图的形状类似于一个钟形曲线。你可以看到,我们在柱形图上绘制了一条线,显示了这个大致的钟形曲线。这个图形所近似的曲线被称为钟形曲线。在许多情况下,都可以找到近似的钟形曲线。统计学中有一个强大的理论结果叫做中心极限定理,它指出,在某些常见的条件下,样本均值之间的差异会呈现出一种大致为钟形曲线的分布。这一定理成立的技术条件是:随机样本彼此独立且同分布(即来自同一群体的随机抽样),且该群体具有有限的期望值和有限的方差。我们在许多领域看到近似的钟形曲线,表明这些技术条件在很多情况下得到了满足。

一旦我们了解了图 3-3 中钟形曲线的形状和大小,我们就可以更准确地推理出一些困难的统计问题。让我们回到 sample3 的问题。基于我们目前所知道的,我们是否认为 sample3 是我们棒球选手群体中的一个随机样本?我们看到 sample3 的均值与 sample1 的均值之间的差异大约是 1.6 英寸。当我们查看图 3-3 时,我们可以看到钟形曲线在那个位置非常低,接近 0。这意味着从我们群体中随机抽取的样本,均值差异达到 1.6 英寸的情况是非常罕见的。这使得 sample3 是我们棒球选手群体中的随机样本的可能性看起来相对不太可信。我们可以通过检查有多少差异大于或等于 1.6 英寸来了解它的不可信程度:

largedifferences=[diff for diff in alldifferences if abs(diff)>=1.6]
print(len(largedifferences))

在这个代码片段中,我们创建了largedifferences,这是一个包含所有alldifferences中大于或等于 1.6 的元素的列表。然后我们检查largedifferences列表的长度。我们发现该列表仅包含八个元素,意味着从我们mlb群体中随机抽取的样本,均值差异达到 1.6 或更多的情况大约只有每千次中 8 次,或 0.8% 的时间。这个值,0.8% 或 0.008,是一个计算得出的可能性。我们可以把它看作是我们估算的概率,即从 mlb 群体中随机抽取的两个样本的均值差异大于或等于 1.6 英寸的概率。这个概率通常被称为p 值,其中p概率的缩写。

如果我们假设 sample3 是来自我们 mlb 人群的随机样本,我们就必须相信这种罕见的差异,发生概率不到 1%,是自然发生的。这个事件发生的低概率可能让我们相信 sample3 并不来自与 sample1 相同的人群。换句话说,低 p-值促使我们拒绝这两个群体来自同一人群的观点。p-值越低,我们就越有信心拒绝这两个群体来自同一人群的观点,因为低 p-值要求我们相信越来越不可能发生的巧合。相反,考虑一下我们的人群中,样本均值差异达到 0.6 英寸或更多的情况有多常见:

smalldifferences=[diff for diff in alldifferences if abs(diff)>=0.6]
print(len(smalldifferences))

在这里,我们创建了 smalldifferences,一个包含所有 alldifferences 中大于或等于 0.6 英寸的元素的列表。我们可以看到,这样大小的差异大约发生 31.4% 的时间。在这种情况下,我们会说我们的 p-值是 0.314。如果 sample1sample2 来自同一人群,我们就必须相信这种差异的大小,约 31% 的概率会发生在我们的案例中。相信 31% 概率发生的事件并不难,所以我们得出结论,sample1sample2 之间的差异是合理的;我们愿意接受它们虽然不完全相同,但它们是来自同一人群的随机样本。

我们在这里计算的 p-值使我们接受了 sample1sample2 来自同一人群的观点,并拒绝了 sample1sample3 来自同一人群的观点。你可以看到,p-值的大小在我们比较群体时有多么重要。

进行假设检验

我们已经概述了进行统计推理方法所需的所有要素,这种方法被称为 假设检验。我们可以用更科学的术语来形式化这种推理方法。我们正在尝试确定 sample3 是否是来自与 sample1 相同人群的随机样本。从科学的角度讲,我们可以说我们在考虑两种不同的假设:

假设 0 sample1sample3 是来自同一人群的随机样本。

假设 1 sample1sample3 不是来自同一人群的随机样本。

在常见的统计学术语中,我们将假设 0 称为 零假设,将假设 1 称为 备择假设。零假设断言这两个样本是从同一人群(我们的棒球运动员数据集)中随机抽取的,只有一个均值和一个标准差。备择假设则断言这两个样本是从两个完全不同的人群中随机抽取的,每个样本有自己的均值、标准差和所有独特特征。我们选择这两种假设之间的区别方式与我们之前遵循的推理方式相同:

  1. 假设零假设(Hypothesis 0)为真。

  2. 假设零假设为真,我们需要找到观察到的样本均值与假设的样本均值之间差异的可能性。这种可能性称为p-值。

  3. 如果p-值足够小,我们就拒绝零假设,并因此愿意接受假设 1。

请注意,第 3 步表述得很模糊:它没有具体说明p-值需要小到什么程度才能证明拒绝零假设是合理的。这种模糊性是因为没有数学上的定量要求,决定p-值需要多小才能拒绝零假设。我们可以根据自己的判断和直觉选择一个合适的小值来证明拒绝零假设是合理的。我们认为可以用来拒绝零假设的p-值大小被称为显著性水平

实证研究中最常用的显著性水平是 5%,这意味着如果p < 0.05,我们认为拒绝零假设是有依据的。对于sample1sample3,我们可以在显著性水平仅为 1%的情况下拒绝零假设,因为我们发现p < 0.01。 当我们发现p-值小于我们选择的显著性水平时,我们称组间差异是统计学显著的。推荐的做法是在进行任何计算之前,先确定我们要使用的显著性水平;这样,我们可以避免选择一个能支持我们希望被确认的假设的显著性水平。

t 检验

我们不必每次进行假设检验时都重新计算均值、绘制直方图,或者手动计算p-值。统计学家们发现了简洁的方程,定义了两组数据来自同一总体的可能性。他们创建了一种相对简单的检验方法,称为t 检验,它能快速而轻松地完成假设检验过程,无需使用for循环或直方图。我们可以使用 t 检验来检验我们的零假设(Hypothesis 0)和假设 1。我们将按照以下方式检查sample1sample2是否来自同一总体:

import scipy.stats
scipy.stats.ttest_ind(sample1['height'],sample2['height'])

在这里,我们导入scipy.stats模块。这个模块属于 SciPy 包,SciPy 是一个流行的 Python 库,包含了许多统计检验工具,在你深入学习统计学和数据科学时会非常有用。导入此模块后,我们使用其ttest_ind命令来检查样本之间的差异。它的输出如下:

Ttest_indResult(statistic=-1.0839563860213952, pvalue=0.2828695892305152)

在这里,p-值相对较高(约为 0.283),明显高于 0.05 的显著性阈值。(它与我们之前计算的 0.314 p-值略有不同,因为之前的* p-值计算方法是近似方法,而这一方法更为数学精确。)这个较高的p-值表明这些样本来自同一人群的可能性较大。这并不令人惊讶,因为我们知道它们来自同一人群(我们自己创建了这些样本)。在这种情况下,我们决定不拒绝原假设,并接受(直到有其他证据让我们改变看法)sample1sample2来自同一人群。你也可以运行scipy.stats.ttest_ind(sample1['height'], sample3)来比较sample1sample3,如果这样做,你会发现一个较低的p*-值(小于 0.05),这表明我们可以拒绝原假设,即sample1sample3来自同一人群。

存在几种类型的 t 检验,除了 t 检验,还有其他假设检验方法。我们目前使用的ttest_ind命令带有_ind后缀,表示它是用来处理独立样本的。在这里,独立意味着我们所期望的:一个样本中的个体与另一个样本中的个体之间没有有意义或一致的关系——这些样本由随机选择的不同人组成。

如果我们有相关样本而非独立样本,我们可以使用另一个命令scipy.stats.ttest_rel,它执行一种数学上与ttest_ind略有不同的 t 检验。当不同样本中的观测值之间有有意义的关系时,ttest_rel命令就适用——例如,如果它们是同一学生的两次考试成绩,或是同一患者的两项不同医疗测试结果。

另一种 t 检验类型是Welch 的 t 检验,它适用于我们不希望假设样本方差相等的情况。当我们不希望假设样本具有相同方差时,Welch 的 t 检验是设计用来比较样本的。你可以通过在 t 检验命令中添加equal_var=False来实现 Welch 的 t 检验。

t 检验是一种参数检验,意味着它依赖于我们数据集分布的假设。t 检验依赖于几个技术性假设:首先,被比较的组的样本均值应当遵循正态分布;其次,被比较组的方差应当相同(除非使用 Welch 的 t 检验);第三,两个组之间应当是独立的。如果这些假设不成立,t 检验就不完全准确,尽管即使假设不成立,结果也通常不会偏差太大。

在某些情况下,我们希望进行假设检验时使用一种不做这些强假设的检验,因为这些假设可能并不成立。如果是这样,我们可以依赖一种称为非参数统计的知识体系,它提供了进行假设检验和其他统计推理的工具,这些工具对我们数据分布的假设较少(例如,我们不需要处理那些样本均值呈钟形曲线分布的总体)。一种来自非参数统计的假设检验叫做Mann-Whitney U 检验(或Wilcoxon 秩和检验),我们可以通过以下 Python 代码轻松实现:

scipy.stats.mannwhitneyu(sample1['height'],sample2['height'])

这个检验只需要一行代码,因为 SciPy 包含了 Mann-Whitney U 检验的实现。就像 t 检验一样,我们需要输入的是我们要比较的数据,代码会输出一个p-值。如果你想深入了解各种假设检验及其准确使用时机,你应该阅读一些高级的理论统计学教材。现在,我们使用的简单独立样本 t 检验已经相当稳健,应该适用于大多数实际场景。

假设检验的细微差别

使用零假设和 t 检验进行假设检验足够常见,以至于被称为流行,但它并不像大多数流行事物那样受到喜爱。学生们往往不喜欢它,因为它对大多数人来说不直观,并且需要一些复杂的推理才能理解。教师们有时也不喜欢它,因为他们的学生不喜欢它并且在学习上遇到困难。许多方法学研究者感到恼火,因为在各个层次上,人们普遍误解和误用 t 检验、p-值和假设检验。对假设检验的反感甚至导致一些受尊敬的科学期刊禁止发表相关内容,尽管这种情况较为罕见。

大多数对假设检验的负面情绪源于误解。研究人员误解了假设检验的一些细微差别并滥用它,进而导致研究中的错误,这让方法学上的严格要求者感到不满。由于这些误解在专业人士中也很常见,因此值得在这里提到其中一些,并试图解释一些细微差别,帮助你避免这些相同的错误。

需要记住的一个重要点是p-值告诉你什么:它告诉你在假设零假设为真的情况下,观察到数据的可能性。人们常常认为或者希望它能告诉他们相反的内容:即在某些观察数据下假设为真之概率。始终记住,p-值不应直接解释为假设为真之概率。因此,当我们看到比较sample1sample3身高的p-值为p = 0.008 时,我们不能说:“这些样本来自同一总体的概率只有 0.8%。”也不能说:“零假设为真的概率是 0.8%。”我们只能说:“如果零假设为真,发生了一个 0.8%的可能事件。”这使我们能够决定是否拒绝零假设,但并不能让我们准确判断任何一个假设的真实性有多大可能。

另一个重要的细微差别是接受假设和未能拒绝假设之间的区别。假设检验只有两种可能的结果:要么我们拒绝零假设,要么我们决定不拒绝零假设。未能拒绝并不等于完全接受它,而且仅仅因为一个p-值没有低于显著性阈值,并不意味着两个组一定相同。仅仅因为一个 t 检验未能拒绝零假设,并不意味着零假设一定成立。

同样,仅仅因为一个p-值似乎证明拒绝零假设是合理的,并不意味着零假设一定是错误的。尤其是在我们拥有有限数据、困难的噪声测量,或有理由怀疑我们的测量时,这一点尤为重要。假设检验并不能让我们在数据不确定的情况下,对假设做出完全确定的判断。相反,它提供了一部分证据,我们必须正确理解这些证据,并与大量其他证据一起加以权衡。

另一个需要记住的重要概念是安娜·卡列尼娜原则。列夫·托尔斯泰在《安娜·卡列尼娜》中写道:“所有幸福的家庭都是相似的;每个不幸的家庭都有不幸的方式。”统计学有一个类似的原则:所有接受零假设的情况都是相似的,但每次拒绝零假设的原因都是不同的。零假设认为两个样本是来自同一总体的随机抽样。如果我们拒绝零假设,可能有一个或多个原因:我们的两个样本可能来自不同的总体,或者两个样本可能来自同一总体但不是随机选择的,或者可能存在抽样偏差,或者可能是盲目运气。仅仅因为我们有信心拒绝零假设,并不意味着我们可以有信心知道零假设的哪部分是错误的。正如经验研究者常说的:“需要进一步研究。”

需要记住的一个细微差别是统计显著性实际显著性之间的区别。一个运动员样本的平均身高可能是 73.11,另一个运动员样本的平均身高可能是 73.12,并且根据 t 检验,这两个平均值之间可能存在统计显著性差异。我们可以合理地得出结论,这两个群体不是来自同一总体的随机样本,并且由于它们的平均身高不同,我们应该以不同的方式对待它们。然而,即使这个 0.01 英寸的差异在统计上是显著的,是否具有实际显著性仍不明确。这两个群体的成员应该能够穿相同的衣服、坐在飞机的同样座位上,并且(平均而言)够得到同样高的橱柜。我们没有理由认为一个群体在棒球方面会比另一个群体更优秀,至少在任何实际重要的意义上是如此。在这种情况下,我们可能希望忽略 t 检验的结果,因为尽管存在统计上可检测到的差异,但这个差异并没有任何实际后果。在假设检验过程中,实际显著性始终是一个重要的考虑因素。

现在我们已经讨论了假设检验及其棘手的理论细节,让我们转向一个实际的商业例子。

在实际情境中比较不同的群体

到目前为止,本章主要集中在统计理论上。但对于数据科学家来说,理论性的考虑总是在实际情境中进行的。让我们从棒球的例子转到市场营销的例子。假设你正在运营一家制造计算机的公司。为了保持与客户的联系并增加销售额,你的公司维护着邮件列表:感兴趣的客户可以注册自己感兴趣的主题,并定期收到与你公司相关的邮件。目前,你只有两个邮件列表:桌面电脑列表和笔记本电脑列表,分别是为对桌面电脑和笔记本电脑感兴趣的客户设计的。

到目前为止,桌面电脑和笔记本电脑是你公司唯一的产品。但很快,你将推出一套已经开发了几年新产品:顶级的网络服务器。这是你公司自然的产品线,因为你已经制造计算机硬件,并且已有许多需要服务器基础设施的科技客户。但由于这个产品线是新的,几乎没有人知道它。你的营销团队计划通过邮件列表向订阅者发送邮件,告知他们这些新产品,并希望能够顺利启动并获得服务器产品的高销售额。

营销团队成员希望使这次电子邮件营销活动尽可能有效。他们与你讨论活动策略。他们可以设计一封电子邮件并发送给两个电子邮件列表中的每一个人,或者他们可以为桌面用户设计一封电子邮件,为笔记本用户设计另一封电子邮件。你们营销团队的专家们在定向方面知识丰富:例如,他们知道外向型的人最积极反应的电子邮件消息与内向型的人最积极反应的电子邮件消息不同。其他个人特征——包括年龄、收入和文化——也对广告反应有强烈的影响。

我们需要了解桌面用户和笔记本用户是否具有不同的特点。如果这两个群体本质上相同,我们可以节省营销团队成员的一些时间,让他们向所有人发送相同的电子邮件。如果这两个群体在我们能够理解的方面有显著不同,我们可以设计更有针对性的消息,以吸引每个群体并提高销售额。

我们可以通过读取数据开始我们的调查。我们将读取两个虚拟数据集(不是基于真实人物或产品,只是为了说明本章的观点)。你可以从bradfordtuckfield.com/desktop.csvbradfordtuckfield.com/laptop.csv下载这两个数据集,然后按如下方式将它们读入 Python:

desktop=pd.read_csv('desktop.csv')
laptop=pd.read_csv('laptop.csv')

你可以运行print(desktop.head())print(laptop.head())来查看每个数据集的前五行。你会注意到这两个数据集都有四列:

userid 包含一个唯一的数字,用于标识特定用户

spending 包含用户在贵公司网站上消费的记录

age 包含用户的年龄,可能是在单独的调查中记录的

visits 包含用户访问贵公司网站页面的次数

我们的目标是确定desktop数据框中的用户和laptop数据框中的用户是否存在显著差异。我们可以绘制一些图表,看看是否有明显的差异。

我们可以从绘制每个列表订阅者在我们公司产品上花费的金额开始。我们将使用以下代码创建一个箱型图:

import matplotlib.pyplot as plt
sns.reset_orig()
fig1, ax1 = plt.subplots()
ax1.set_title('Spending by Desktop and Laptop Subscribers')
ax1.boxplot([desktop['spending'].values,laptop['spending'].values])
ax1.set_ylabel('Spending ($)')
plt.xticks([1,2], ['Desktop Subscribers','Laptop Subscribers'])
plt.show()

在这里,我们导入 Matplotlib 来创建图表。我们使用它的boxplot()命令,结合desktop的消费列和laptop的消费列的数据。你可以在图 3-4 中看到结果。

图 3-4:显示桌面电子邮件列表订阅者(左)和笔记本电子邮件列表订阅者(右)消费水平的箱型图

我们可以通过查看这些箱线图学到一些东西。两个群体的最小值都是 0。笔记本电脑订阅者的第 25 百分位、第 50 百分位和第 75 百分位较高,并且有一个高于桌面订阅者群体中任何观测值的高异常值。另一方面,分布看起来也没有太大的不同;桌面订阅者似乎与笔记本电脑订阅者并没有完全不同。我们有一些不同的群体,但并不是非常不同。我们应该更仔细地观察,看看一些更精确的定量指标是否能帮助我们判断它们的差异有多大。

除了绘制图表,我们还可以进行简单的计算,得到我们数据的汇总统计。在下面的代码片段中,我们将获取一些描述性统计数据:

print(np.mean(desktop['age']))
print(np.mean(laptop['age']))
print(np.median(desktop['age']))
print(np.median(laptop['age']))
print(np.quantile(laptop['spending'],.25))
print(np.quantile(desktop['spending'],.75))
print(np.std(desktop['age']))

在这个代码片段中,我们检查了桌面订阅者和笔记本电脑订阅者的平均年龄。结果显示,桌面订阅者的平均年龄约为 35.8 岁,而笔记本电脑订阅者的平均年龄约为 38.7 岁。我们可以得出结论,这两个群体是不同的,因为它们不是完全相同的。但还不清楚这两个群体是否存在足够的差异,以至于我们应该告诉市场营销团队创建两个不同的电子邮件,而不是只发送一个。为了做出这个判断,我们需要使用假设检验框架。我们可以按如下方式指定我们的零假设和备择假设:

假设 0 两个电子邮件列表是来自同一人群的随机样本。

假设 1 两个电子邮件列表不是来自同一人群的随机样本。

假设 0,我们的零假设,描述了一个这样一个世界:有一个对计算机感兴趣的群体,包括笔记本电脑和桌面电脑。来自这个群体的人偶尔会加入你公司电子邮件列表。但当他们加入一个列表时,他们完全是随机选择加入你们两个列表中的一个。在这个世界里,你的列表有表面上的差异,但它们确实是来自同一人群的两个随机样本,且没有任何本质上的差异,因此不需要你公司给予不同的对待。

假设 1,备择假设,描述了一个零假设不成立的世界。这意味着,订阅者加入不同电子邮件列表的原因,至少部分是因为喜欢桌面电脑和喜欢笔记本电脑的人之间的潜在差异。如果假设 0 成立,那么向两个群体发送相同的营销电子邮件是合理的。如果假设 1 成立,那么向每个群体发送不同的营销电子邮件更为合适。现在,商业决策依赖于统计检验的结果。

让我们进行 t 检验,看看我们的两个订阅者群体是否真的有差异。首先,我们需要指定显著性水平。我们使用在研究中常见的 5%显著性水平。我们可以通过一行代码运行 t 检验:

scipy.stats.ttest_ind(desktop['spending'],laptop['spending'])

当你查看我们的 t 检验结果时,你会看到我们的p-值大约为 0.04。由于我们使用常见的 5%显著性水平,这个p-值足够低,足以让我们得出结论,桌面和笔记本组不是从同一总体中随机抽取的,因此我们可以拒绝原假设。看来,桌面和笔记本的电子邮件订阅者至少在某种可检测的方式上有所不同。

在发现这些差异后,我们可以与公司营销团队进行讨论,共同做出是否为不同组设计不同电子邮件活动的决策。假设团队决定这样做,我们可以为自己感到自豪,因为我们的统计分析为一个我们认为有充分依据的实际决策提供了支持。我们不仅仅是分析数据;我们用数据做出了决策。这在数据科学中很常见:我们用数据分析来做出数据驱动的决策,从而改善商业成果。

那接下来呢?我们在这一章中已经走了这么远,只做了一个决定:是否向两个订阅者列表发送不同的电子邮件。我们需要问的下一个问题是,每组的电子邮件应该有什么不同:它们的内容应该是什么,设计应该如何,如何知道我们是否做对了?在下一章中,我们将讨论 A/B 测试,这是一种强有力的框架,用于回答这些困难的问题。

总结

在本章中,我们讨论了总体和样本,以及来自同一总体的样本应该如何相似。我们介绍了假设检验,包括 t 检验,这是一种简单且有用的工具,用于检测两个组是否可能是从同一总体中随机抽取的。我们讨论了一些 t 检验有用的商业场景,包括一个营销场景以及是否向不同电子邮件列表发送不同电子邮件的决策。

下一章将建立在我们在这里介绍的工具基础上。我们不仅仅是比较不同的组,而是将讨论如何运行实验,然后使用组比较工具检查实验处理之间的差异。

第四章:A/B 测试

在上一章中,我们讨论了观察两个群体并对它们之间的关系做出定量判断的科学实践。但科学家(包括数据科学家)不仅仅是观察已有的差异。科学的一个重要部分是通过实验制造差异,然后得出结论。在本章中,我们将讨论如何在商业中进行这些类型的实验。

我们将从讨论实验的必要性和我们进行测试的动机开始。我们将涵盖如何正确设置实验,包括随机化的必要性。接下来,我们将详细介绍 A/B 测试和冠军/挑战者框架的步骤。最后,我们将描述一些细微差别,比如探索/利用的权衡,以及伦理问题。

实验的必要性

让我们回到第三章下半部分概述的情境。假设你经营一家电脑公司,并维护着客户可以选择订阅的电子邮件营销列表。一个电子邮件列表是为对台式机感兴趣的客户设计的,另一个电子邮件列表是为对笔记本电脑感兴趣的客户设计的。你可以从bradfordtuckfield.com/desktop.csvbradfordtuckfield.com/laptop.csv下载两个虚构的数据集。如果你将它们保存在你运行 Python 的相同目录下,你可以如下方式将这些假设的列表读取到 Python 中:

import pandas as pd
desktop=pd.read_csv('desktop.csv')
laptop=pd.read_csv('laptop.csv')

你可以运行print(desktop.head())print(laptop.head())来查看每个数据集的前五行。

在第三章中,你学会了如何使用简单的 t 检验来检测我们的数据集之间的差异,具体如下:

import scipy.stats
print(scipy.stats.ttest_ind(desktop['spending'],laptop['spending']))
print(scipy.stats.ttest_ind(desktop['age'],laptop['age']))
print(scipy.stats.ttest_ind(desktop['visits'],laptop['visits']))

在这里,我们导入 SciPy 包的stats模块,以便使用 t 检验。然后我们打印三个独立 t 检验的结果:一个比较台式机和笔记本电脑订阅者的消费,一个比较台式机和笔记本电脑订阅者的年龄,一个比较台式机和笔记本电脑订阅者的记录网站访问次数。我们可以看到,第一个p值小于 0.05,这表明这两个群体在消费水平上存在显著差异(在 5%的显著性水平下),正如我们在第三章中得出的结论。

在确定台式机订阅者与笔记本电脑订阅者有所不同之后,我们可以得出结论,应该向他们发送不同的营销邮件。然而,仅凭这一事实并不足以完全指导我们的营销策略。仅仅知道我们的台式机订阅者群体的消费略低于笔记本电脑订阅者群体,并不能告诉我们,长篇信息或短篇信息哪种更能促进销售,或者使用红色文本还是蓝色文本能获得更多的点击,或者使用非正式语言还是正式语言能更好地提升客户忠诚度。在某些情况下,学术营销期刊中发布的过往研究可以为我们提供有效的线索。但即便存在相关的研究,每个公司都有其独特的客户群,这些客户群可能不会像过往研究所示那样对营销产生反应。

我们需要一种方法来生成以前从未收集或发布过的新数据,这样我们才能利用这些数据来回答有关我们常常面临的新情况的问题。只有能够生成这种新数据,我们才能可靠地了解在面对我们特定的独特客户群时,哪些方法最能推动我们的业务增长。本章剩余的部分将讨论一种可以实现这一目标的方法。

A/B 测试,本章的重点,使用实验来帮助企业确定哪些做法能为它们带来最大的成功机会。它包含几个步骤:实验设计、将样本随机分配到实验组和控制组、仔细衡量结果,最后对各组之间的结果进行统计比较。

我们将采用的统计比较方法应该是熟悉的:我们将使用上一章介绍的 t 检验。虽然 t 检验是 A/B 测试的一部分,但它并不是唯一的部分。A/B 测试是一个收集新数据的过程,这些数据可以通过 t 检验等方法进行分析。由于我们已经介绍了 t 检验,因此本章不会再重点讨论它。相反,我们将重点讨论 A/B 测试的其他所有步骤。

进行实验以测试新假设

让我们考虑一个关于我们客户的假设,这可能引起我们的兴趣。假设我们想研究是否将我们营销邮件中的文本颜色从黑色改为蓝色会增加我们从这些邮件中获得的收入。我们可以提出与此相关的两个假设:

假设 0:将我们电子邮件中的文本颜色从黑色改为蓝色不会对收入产生任何影响。

假设 1:将我们电子邮件中的文本颜色从黑色改为蓝色将会导致收入发生变化(无论是增加还是减少)。

我们可以使用第三章中介绍的假设检验框架来检验原假设(假设 0),并决定是否要拒绝它,转而接受其替代假设(假设 1)。唯一的区别是,在第三章中,我们检验的是已经收集的数据相关的假设。而在这里,我们的数据集并不包括关于蓝色文本和黑色文本电子邮件的信息。因此,在进行假设检验之前需要额外的步骤:设计实验、进行实验,并收集与实验结果相关的数据。

进行实验可能听起来不那么困难,但一些关键的细节非常重要,需要做到精准无误。为了进行我们刚才提到的假设检验,我们需要来自两个组的数据:一个收到蓝色文本电子邮件的组和一个收到黑色文本电子邮件的组。我们需要知道从收到蓝色文本电子邮件的每个组成员那里获得了多少收入,以及从收到黑色文本电子邮件的每个组成员那里获得了多少收入。

在我们有了这些数据之后,我们可以做一个简单的 t 检验,以确定蓝色文本组收集的收入是否与黑色文本组收集的收入有显著差异。在本章中,我们将对所有测试使用 5%的显著性水平——也就是说,如果我们的p-值小于 0.05,我们将拒绝原假设并接受替代假设。如果我们进行 t 检验,发现收入差异显著,我们可以拒绝原假设(假设 0)。否则,我们不会拒绝原假设,并且在没有其他证据的情况下,我们将接受其关于蓝色和黑色文本产生相等收入的断言。

我们需要将目标人群分成两个子组,向一个子组发送蓝色文本电子邮件,向另一个子组发送黑色文本电子邮件,这样我们就可以比较每个组的收入。现在,先让我们只关注桌面端订阅者,并将我们的桌面数据框分为两个子组。

我们可以通过多种方式将一个组分成两个子组。一个可能的选择是将我们的数据集分为年轻人组和老年人组。我们可能这样划分数据,因为我们认为年轻人和老年人可能对不同的产品感兴趣,或者我们可能仅仅因为年龄是我们数据中少数几个变量之一而这么做。稍后我们将看到,这种将组分成子组的方法会在我们的分析中引发问题,我们将讨论更好的创建子组的方法。但由于这种分组方法简单易行,让我们先尝试一下,看看会发生什么:

import numpy as np
medianage=np.median(desktop['age'])
groupa=desktop.loc[desktop['age']<=medianage,:]
groupb=desktop.loc[desktop['age']>medianage,:]

这里,我们导入了 NumPy 包,并为其指定别名np,以便使用它的median()方法。然后,我们简单地取出桌面订阅者组的中位年龄,并创建groupa,它是一个年龄小于或等于中位年龄的桌面订阅者子集;groupb是一个年龄高于中位年龄的桌面订阅者子集。

在创建了groupagroupb之后,你可以将这两个数据框发送给你的营销团队成员,并指示他们向每个组发送不同的邮件。假设他们将黑色文本的邮件发送给groupa,将蓝色文本的邮件发送给groupb。每封邮件中都会包含他们想要销售的新产品链接,通过跟踪谁点击了哪些链接以及他们的购买情况,团队成员可以衡量每个邮件接收者带来的总收入。

让我们读取一些虚构的数据,这些数据展示了我们两个组成员的假设结果。你可以从bradfordtuckfield.com/emailresults1.csv下载这些数据;并将其存储在你运行 Python 的同一目录下。然后,你可以按如下方式将其读取到 Python 会话中:

emailresults1=pd.read_csv('emailresults1.csv')

如果你在 Python 中运行print(emailresults1.head()),你可以看到这组新数据的前几行。这是一个简单的数据集:每一行对应一个个人桌面邮件订阅者,其 ID 在userid列中标识。revenue列记录了你公司通过这次电子邮件活动从每个用户那里获得的收入。

将这些新的收入信息与我们关于每个用户的其他信息放在同一个数据框中是非常有用的。让我们合并这些数据集:

groupa_withrevenue=groupa.merge(emailresults1,on='userid')
groupb_withrevenue=groupb.merge(emailresults1,on='userid')

在这个代码片段中,我们使用 pandas 的merge()方法来合并数据框。我们指定on='userid',这意味着我们将emailresults1中与特定userid对应的行与groupa中与该userid相对应的行合并。使用merge()的最终结果是一个数据框,其中每一行对应一个通过其唯一userid识别的用户。列中不仅告诉我们关于用户的特征(如年龄),还告诉我们通过最近的电子邮件活动从他们那里获得的收入。

在准备好数据之后,进行 t 检验来检查我们的组是否有差异变得简单。我们可以通过一行代码来完成,如下所示:

print(scipy.stats.ttest_ind(groupa_withrevenue['revenue'],groupb_withrevenue['revenue']))

当你运行这段代码时,你将得到如下结果:

Ttest_indResult(statistic=-2.186454851070545, pvalue=0.03730073920038287)

输出中重要的部分是pvalue变量,它告诉我们测试的p-值。我们可以看到结果显示p ≈ 0.037。由于p < 0.05,我们可以得出结论,这是一种具有统计学意义的差异。我们可以检查差异的大小:

print(np.mean(groupb_withrevenue['revenue'])-np.mean(groupa_withrevenue['revenue']))

输出结果是 125.0。平均而言,groupb 的客户比 groupa 的客户多花费了 $125。这一差异在统计学上具有显著性,因此我们拒绝零假设,支持假设一,得出结论(至少目前为止)认为营销邮件中的蓝色文本使每个用户的收入比黑色文本多出约 $125。

我们刚刚做的是一个实验。我们将一个人群分成两组,对每组进行不同的处理,并比较结果。在商业环境中,这种实验通常被称为 A/B 测试。名称中的 A/B 部分指的是两组,A 组和 B 组,我们比较了它们对电子邮件的不同反应。每个 A/B 测试遵循我们这里经历的相同模式:将人群分成两组,对每组施加不同的处理(例如,发送不同的电子邮件),并通过统计分析比较两组的结果,得出哪个处理更好的结论。

现在我们已经成功进行了 A/B 测试,我们可能会得出结论,蓝色文本的效果是增加 $125 的支出。然而,我们进行的 A/B 测试存在问题:它是混淆的。为了更好地理解这一点,参考 表 4-1。

表 4-1:组间差异

A 组 B 组
个人特征 年轻(其他方面与 B 组相同) 年长(其他方面与 A 组相同)
电子邮件文本颜色 黑色 蓝色
每用户平均收入 $104 | $229

我们可以看到 A 组和 B 组的重要特征。我们通过 t 检验比较了它们的支出,结果发现它们的支出水平有显著差异。我们想要解释为什么它们的支出不同,任何对不同结果的解释都必须依赖于 表 4-1 中列出的差异。我们希望得出结论,支出差异可以通过文本颜色的不同来解释。然而,这种差异与另一个差异并存:年龄。

我们不能确定支出水平的差异是由文本颜色引起的,而不是由年龄差异引起的。例如,也许根本没有人注意到文本颜色的不同,但年长的人通常比年轻人更富有,也更愿意购买你的产品。如果真是这样,那么我们的 A/B 测试并不是在测试蓝色文本的效果,而是在测试年龄或财富的影响。我们原本只打算研究文本颜色的影响,但现在我们不知道到底是在研究文本颜色,还是在研究年龄、财富,或者其他因素。如果我们的 A/B 测试有一个更简单、不带混淆因素的设计,像 表 4-2 中所示的那样,那就更好了。

表 4-2:一个无混淆的 A/B 测试设计

C 组 D 组
个人特征 (与 D 组完全相同) (与 C 组完全相同)
电子邮件文本颜色 黑色 蓝色
每用户平均收入 $104 | $229

表格 4-2 假设我们将用户分成了两个假设的组,C 组和 D 组,这两个组在所有个人特征上都是相同的,唯一的区别是他们收到的邮件文本不同。在这个假设场景下,消费差异只能通过不同的文本颜色来解释,因为这是它们之间唯一的区别。我们应该以一种确保组与组之间唯一差异来自实验处理,而非组成员先前特征的方式来分组。如果我们这么做了,就能避免实验中的混杂效应。

理解 A/B 测试中的数学原理

我们也可以用数学的方式来表示这些概念。我们可以使用常见的统计符号E()来表示期望值。所以E(A 使用黑色文本时的收入)就表示我们通过给 A 组发送黑色文本邮件所能获得的期望收入。我们可以写出两个简单的方程,描述我们期望从黑色文本、实验效应和蓝色文本中获得的收入之间的关系:

E(A 使用黑色文本时的收入) + E(将黑色文本改为蓝色对 A 的影响) = E(A 使用蓝色文本时的收入)

E(B 使用黑色文本时的收入) + E(将黑色文本改为蓝色对 B 的影响) = E(B 使用蓝色文本时的收入)

为了决定是否拒绝假设 0,我们需要求解效应值:E(将黑色文本改为蓝色对 A 的影响)和E(将黑色文本改为蓝色对 B 的影响)。如果其中任何一个效应值不为 0,我们就应该拒绝假设 0。通过实验,我们发现E(A 使用黑色文本时的收入) = 104,E(B 使用蓝色文本时的收入) = 229。知道这些数值后,我们可以得到以下方程:

104 + E(将黑色文本改为蓝色对 A 的影响) = E(A 使用蓝色文本时的收入)

E(B 使用黑色文本时的收入) + E(将黑色文本改为蓝色对 B 的影响) = 229

但这仍然存在许多我们不知道的变量,并且我们还无法解出E(将黑色文本改为蓝色对 A 的影响)和E(将黑色文本改为蓝色对 B 的影响)。要解决这些效应值,唯一的方法是简化这两个方程。例如,如果我们知道E(A 使用黑色文本时的收入) = E(B 使用黑色文本时的收入),并且E(将黑色文本改为蓝色对 A 的影响) = E(将黑色文本改为蓝色对 B 的影响),且E(A 使用蓝色文本时的收入) = E(B 使用蓝色文本时的收入),那么我们就可以将这两个方程简化为一个简单的方程。如果我们知道实验前我们的组是相同的,那么我们就知道这些期望值都是相等的,从而能够轻松简化我们的两个方程,得到一个可解的方程:

104 + E(将黑色文本改为蓝色对每个人的影响) = 229

有了这个,我们可以确信蓝色文本的效果是$125 的收入增加。这就是为什么我们认为设计没有混杂因素的实验如此重要,在这些实验中,各组在个人特征上的期望值相等。通过这样做,我们能够解出之前的方程,并且可以自信地认为我们测量的效应大小实际上是我们研究的内容的效应,而不是不同潜在特征的结果。

将数学转化为实践

我们知道该如何从数学上处理,但我们需要将其转化为实际行动。我们应该如何确保E(A 使用蓝色文本的收入) = E(B 使用蓝色文本的收入),并且如何确保其他期望值都是相同的?换句话说,我们如何确保我们的研究设计像表格 4-2 而不是表格 4-1?我们需要找到一种方法,从我们的桌面订阅者列表中选择预期相同的子组。

选择预期相同的子组最简单的方法是随机选择。我们在第三章中简要提到过这一点:来自一个总体的每个随机样本,其期望值等于总体均值。因此,我们预期来自同一总体的两个随机样本之间不会有显著差异。

让我们对我们的笔记本电脑订阅者列表进行 A/B 测试,但这次我们将使用随机化来选择我们的组,以避免出现混杂的实验设计。假设在这个新的 A/B 测试中,我们想要测试在营销邮件中添加图片是否会提高收入。我们可以像之前一样进行:我们将笔记本电脑订阅者列表分成两个子组,并向每个子组发送不同的邮件。不同之处在于,这次我们不是根据年龄分组,而是进行随机分组:

np.random.seed(18811015)
laptop.loc[:,'groupassignment1']=1*(np.random.random(len(laptop.index))>0.5)
groupc=laptop.loc[laptop['groupassignment1']==0,:].copy()
groupd=laptop.loc[laptop['groupassignment1']==1,:].copy()

在这个代码片段中,我们使用 NumPy 的random.random()方法生成一个包含随机生成的 0 和 1 的列。我们可以将 0 解释为用户属于 C 组,而 1 表示用户属于 D 组。当我们像这样随机生成 0 和 1 时,两个组可能会有不同的大小。然而,在这里我们使用了一个随机种子(在第一行,np.random.seed(18811015))。每当有人使用这个随机种子时,他们“随机”生成的 0 和 1 列将是相同的。这意味着如果你使用这个随机种子,你在家里得到的结果应该和书中的结果一样。使用随机种子并不是必须的,但如果你使用我们这里使用的相同随机种子,你应该会发现 C 组和 D 组各有 15 个成员。

在生成了一个包含 0 和 1 的随机列,表示每个客户的组别分配后,我们创建了两个更小的数据框,groupcgroupd,它们包含了每个子组中用户的 ID 和信息。

你可以将群组成员信息发送给你的营销团队成员,并请他们向正确的群组发送相应的邮件。一个群组,可以是 C 组或 D 组,应该收到没有图片的邮件,而另一个群组,可以是 D 组或 C 组,应该收到带有图片的邮件。假设营销团队随后将最新的 A/B 测试结果文件发送给你,你可以从bradfordtuckfield.com/emailresults2.csv下载一个包含假设结果的虚构数据集。在你将数据存储在运行 Python 的同一位置后,接下来让我们按照以下方式将这个邮件活动的结果读取到 Python 中:

emailresults2=pd.read_csv('emailresults2.csv')

同样,让我们将邮件结果与群组数据框连接起来,就像我们之前做的那样:

groupc_withrevenue=groupc.merge(emailresults2,on='userid')
groupd_withrevenue=groupd.merge(emailresults2,on='userid')

我们还可以使用 t 检验来检查 C 组的收入是否与 D 组的收入不同:

print(scipy.stats.ttest_ind(groupc_withrevenue['revenue'],groupd_withrevenue['revenue']))

我们发现p-值小于 0.05,这表明两个群组之间的差异具有统计学意义。此次实验没有被混淆,因为我们使用了随机分配,以确保群组之间的差异是由于我们不同的邮件,而不是各组特征的差异。由于我们的实验没有受到混淆,而且我们发现 C 组和 D 组之间的收入差异显著,我们可以得出结论:在邮件中加入图片具有非零的效果。如果营销团队告诉我们他们只将图片发送给了 D 组,我们可以轻松地找出效果的估算大小:

print(np.mean(groupd_withrevenue['revenue'])-np.mean(groupc_withrevenue['revenue']))

我们在这里通过减法计算估算的效果:即 D 组参与者获得的平均收入减去 C 组参与者获得的平均收入。C 组与 D 组之间的平均收入差异,约为$260,这就是我们实验效果的大小。

我们进行 A/B 测试的过程其实非常简单,但它也非常强大。我们可以用它来回答各种各样的问题,尤其是那些我们在业务决策中可能遇到的疑问。任何时候,如果你对采取何种方法感到不确定,特别是在用户互动和产品设计方面,考虑采用 A/B 测试作为一种学习答案的方法是值得的。现在你已经了解了这个过程,接下来我们将继续深入了解它的细节。

使用冠军/挑战者框架进行优化

当我们制作出一封很棒的邮件时,我们可能会称其为我们的冠军邮件设计:根据我们迄今为止所了解的情况,我们认为它的表现会最好。一旦我们有了一个冠军邮件设计,我们可能会想停止进行 A/B 测试,直接停下来,依赖我们“完美”的邮件活动源源不断地收钱。

但这并不是一个好主意,原因有几个。首先,时代在变化。设计和营销的潮流变化很快,一个今天看起来令人兴奋且有效的营销手段,很快可能会变得过时和落后。像所有冠军一样,你的冠军邮件设计会随着时间的推移变得更弱、更无效。即使设计和营销潮流 没有 变化,随着新鲜感的消退,你的冠军邮件最终会显得乏味:新的刺激更容易吸引人们的注意。

你不应该停止 A/B 测试的另一个原因是,你的客户群会发生变化。你将失去一些老客户并获得新客户。你会发布新产品并进入新市场。随着客户群体的变化,他们倾向于响应的邮件类型也会发生变化,持续的 A/B 测试将帮助你跟上他们不断变化的特点和偏好。

继续 A/B 测试的另一个原因是,尽管你的冠军邮件可能已经不错,但你可能还没有在所有可能的方面对它进行优化。你尚未测试的某个维度可能会让你拥有一个更优秀的冠军邮件,从而获得更好的表现。如果我们能够成功地进行一次 A/B 测试并学到一些东西,我们自然会希望继续使用 A/B 测试技巧,学到更多,并将利润推向更高。

假设你有一封冠军邮件,并希望继续进行 A/B 测试,以尝试改进它。你再次将用户随机分组,分为新的 A 组和 B 组。你将冠军邮件发送给 A 组。你将另一封与冠军邮件在某个方面有所不同的邮件发送给 B 组,你希望通过这种方式获得一些信息;例如,也许它使用了正式的语言而不是非正式的语言。当我们在邮件活动结束后比较 A 组和 B 组的收入时,我们将能够看出这封新邮件是否比冠军邮件效果更好。

由于这封新邮件与冠军邮件直接竞争,我们称其为 挑战者。如果冠军邮件的表现优于挑战者,冠军将保留其冠军地位。如果挑战者的表现优于冠军,那么这个挑战者将成为新的冠军。

这个过程可以无限持续下去:我们拥有一个代表我们所做事情(在这里是营销邮件)最前沿水平的冠军邮件。我们不断通过与一系列挑战者在 A/B 测试中进行直接竞争来测试冠军邮件。每当某个挑战者的结果显著优于冠军邮件时,这个挑战者就会成为新的冠军,并且将与新的挑战者进行后续的竞争。

这个不断进行的过程被称为冠军/挑战者框架,用于 A/B 测试。它旨在通过持续改进、不断优化,逐步实现业务各个方面的最佳表现。世界上最大的科技公司每天都进行数百次 A/B 测试,数百个挑战者与数百个冠军对抗,有时会战胜他们,有时会被击败。冠军/挑战者框架是为业务中最重要和最具挑战性的部分设置和运行 A/B 测试的常见方法。

通过 Twyman 定律和 A/A 测试防止错误

A/B 测试从头到尾是一个相对简单的过程。然而,我们都是人,难免会犯错。在任何数据科学工作中,不仅仅是 A/B 测试,谨慎行事并不断检查是否存在错误是非常重要的。一个常见的证据,表明我们可能做错了什么,就是事情进展得过于顺利。

如果一切进展顺利,怎么会不好呢?考虑一个简单的例子。你进行了一次 A/B 测试:A 组收到一封电子邮件,B 组收到另一封。然后你衡量了每组的收入,发现 A 组的平均收入大约为$25,而 B 组的平均收入为$99,999。你为 B 组获得的巨大收入感到兴奋。你召集所有同事开紧急会议,告诉他们立即停止手头的工作,立即实施 B 组收到的电子邮件,并围绕这个奇迹般的电子邮件调整公司的整体战略。

当你的同事们日以继夜地将新的电子邮件发送给他们认识的每个人时,你开始感到一种挥之不去的疑虑。你开始思考,单一的电子邮件活动几乎每个收件人就能赚取接近$100,000 的收入,这种情况是多么不太可能,尤其是当你其他的活动每个用户仅赚取大约$25 时。你还想到了$99,999 这个数字——据说你每个用户赚取的收入,它是五个相同的数字重复出现的。也许你还记得曾与一位数据库管理员的对话,他告诉你公司数据库每当出现数据库错误或数据丢失时,系统会自动插入 99999。突然间,你意识到,实际上你的电子邮件活动并没有真的为每个用户赚取$99,999,而是由于 B 组的数据库错误导致了这一看似奇迹般的结果。

从数据科学的角度来看,A/B 测试是一个简单的过程,但从实践和社会角度来看,它可能相当复杂。例如,在任何比微小创业公司更大的公司中,设计营销电子邮件的创意人员与维护记录每个用户收入的数据库的技术人员不同。其他小组可能会参与 A/B 测试中的某些小部分:也许是一个负责维护安排和发送电子邮件软件的小组,也许是一个为电子邮件营销团队制作艺术设计的小组,甚至可能还有其他小组。

在涉及这些小组和步骤的情况下,存在许多可能导致沟通不畅和小错误的机会。也许设计了两封不同的电子邮件,但负责发送的人不了解 A/B 测试,结果将相同的电子邮件复制粘贴到两个小组中。也许他们不小心粘贴了本不应该出现在 A/B 测试中的内容。在我们的例子中,也许记录收入的数据库出现了错误,把 99999 当作错误代码写入结果,其他人误以为这是一个高收入的表现。无论我们如何小心,错误和误解总会找到发生的方式。

错误的不可避免性应该使我们自然地对任何看起来过于好、坏、有趣或奇怪的事物保持怀疑。这种天然的怀疑是Twyman 法则所提倡的,法则指出“任何看起来有趣或不同的数字通常是错误的。”这一法则已经以几种不同的方式重新表述过,包括“任何看起来有趣的统计数据几乎肯定是个错误”和“数据越不寻常或有趣,就越可能是错误的结果。”

除了极度小心和对好消息的天然怀疑外,我们还有另一种有效的方式来防止类似Twyman 法则所警告的解释性错误:A/A 测试。这种测试和字面意思一样;我们按照 A/B 测试中的步骤进行随机化、处理和比较两个小组,但我们不是向两个随机小组发送不同的电子邮件,而是向每个小组发送完全相同的电子邮件。在这种情况下,我们期望零假设成立,并且不会轻易被一个看似获得比另一个小组多$100,000 收入的组所说服。

如果我们一贯发现 A/A 测试在组间产生了统计学上显著的差异,我们可以得出结论:我们的过程存在问题:数据库出现故障、t 检验运行不正确、邮件粘贴错误、随机化执行不当,或者其他问题。A/A 测试还帮助我们意识到本章描述的第一个测试(其中组 A 由年轻人组成,组 B 由老年人组成)是有混淆的,因为我们会知道 A/A 测试结果之间的差异一定是由于年龄差异,而不是邮件之间的差异。A/A 测试可以作为一个有用的理智检查,防止我们被 Twyman 定律警告的那种不寻常、有趣、看似太好以至于不真实的结果所冲昏头脑。

理解效应大小

在我们进行的第一次 A/B 测试中,我们观察到组 A(收到黑色文字邮件的用户)和组 B(收到蓝色文字邮件的用户)之间的差异为$125。这个组间的$125 差异也被称为 A/B 测试的效应大小。我们自然会尝试判断是否应该将这个$125 的效应大小视为小效应、中效应,还是大效应。

要判断一个效应是小还是大,我们必须将其与其他内容进行比较。考虑以下各国的名义 GDP 数据(以 2019 年为准,单位为美元),分别是马来西亚、缅甸和马绍尔群岛:

gdps=[365303000000,65994000000,220000000]

当我们看到这些数字时,$125 开始显得相当小。例如,考虑我们gdps列表的标准差:

print(np.std(gdps))

结果是 158884197328.32672,约为$158,884,197,328(几乎是 1590 亿美元)。标准差是衡量数据集分散程度的常用方法。如果我们观察到两个国家的 GDP 差异约为 800 亿美元,我们不会认为这个差异是极其大或极其小,因为这意味着这两个国家的 GDP 大约相差一个标准差的一半,这是一个常见的差异规模。与其将差异表示为 800 亿美元的差异,不如说这两个国家的 GDP 相差了大约一个标准差的一半,并且预计任何具备一些统计学训练的人都会理解这一点。

相比之下,如果有人告诉你两个国家的 GDP 差异为 112 万亿缅元(缅甸的货币),如果你从未了解过 1 缅元的价值,你可能不确定这个差异是大还是小(112 万亿缅元大约等于$800 亿美元,按写作时的汇率计算)。世界上存在许多货币,它们的相对和绝对价值一直在变化。而标准差则不会特定于任何某个国家,也不受通货膨胀的影响,因此它是一个有用的衡量单位。

我们也可以在其他领域使用标准差作为衡量标准。来自欧洲的人可能习惯于使用米来表示身高。当你告诉你的欧洲数据科学家朋友某个人身高为 75 英寸时,如果他们不习惯从英寸换算,他们可能会困惑这个身高是高还是矮还是平均。如果你告诉他们这个人比平均值高了大约两个标准差,他们应该立刻明白他挺高的,但并不算破纪录的高度。观察身高比平均值高出三个标准差以上的人将会更为罕见,无论我们是使用米、英寸还是其他单位来衡量,结果都是一样的。

当我们谈论 A/B 测试中的$125 效应大小时,我们也可以尝试从标准差的角度来理解它。与我们所见的 GDP 测量值的标准差相比,$125 只是小菜一碟:

print(125/np.std(gdps))

结果大约是 7.9 · 10^(–10),这告诉我们$125 效应大小大约是我们 GDP 数据标准差的十亿分之一。与 GDP 测量的世界相比,$125 的 GDP 差异就像是比你的朋友高出一个微米——没有足够精确的测量技术根本察觉不到。

相反,假设我们对当地餐馆的汉堡价格进行了一项调查。也许我们找到了以下价格:

burgers=[9.0,12.99,10.50]

我们也可以检查这个标准差:

print(np.std(burgers))

我们的汉堡价格数据的标准差大约是 1.65。所以,两个国家的 GDP 相差约 800 亿美元,和两个汉堡价格相差大约 80 美分大致是相当的:它们分别代表了各自领域中大约一半的标准差。当我们将$125 效应大小与此比较时,我们可以看出它是巨大的:

print(125/np.std(burgers))

我们看到$125 大约是 75.9 个汉堡价格的标准差。所以,如果你所在的城市出现了$125 的汉堡价格差异,那就像看到一个超过 20 英尺高的男人——闻所未闻。

通过将我们的效应大小以不同数据集的标准差为单位进行衡量,我们可以轻松地进行比较,不仅可以比较使用相同单位的不同领域(以美元计的 GDP 与以美元计的汉堡价格),还可以比较使用完全不同单位的不同领域(以美元计的汉堡价格与以英寸计的身高)。我们在这里计算过几次的度量——效应大小除以相关标准差——叫做Cohen's d,它是衡量效应大小的常用度量。Cohen 的d就是两个群体的平均值之间的标准差差距。我们可以通过以下方式计算我们的第一次 A/B 测试的 Cohen 的d

print(125/np.std(emailresults1['revenue']))

我们看到结果大约是 0.76。当我们使用 Cohen 的d时,通常的约定是,如果 Cohen 的d大约为 0.2 或更低,我们认为效果较小;如果 Cohen 的d大约为 0.5,我们认为效果中等;如果 Cohen 的d大约为 0.8 甚至更高,我们认为效果较大。由于我们的结果大约是 0.76——非常接近 0.8——我们可以说我们正在处理一个大的效果大小。

计算数据的显著性

我们通常使用统计显著性作为关键证据,以证明我们在 A/B 测试中研究的效果是真实的。从数学上讲,统计显著性取决于三个因素:

  • 被研究的效果大小(比如改变电子邮件文本颜色所带来的收入增加)。更大的效果使统计显著性更有可能出现。

  • 被研究的样本大小(比如接收我们营销邮件的订阅者列表中的人数)。更大的样本使统计显著性更有可能出现。

  • 我们使用的显著性阈值(通常为 0.05)。更高的阈值使统计显著性更有可能出现。

如果我们有一个较大的样本量,并且研究的是一个较大的效果,我们的 t 检验很可能会达到统计显著性。另一方面,如果我们研究的是一个非常小的效果,且样本非常小,我们可能已经注定了自己的失败:我们检测到统计显著性结果的概率基本为 0——即使电子邮件确实有影响。由于运行 A/B 测试需要时间和金钱,我们宁愿不浪费资源进行这种注定无法达到统计显著性的测试。

正确运行的 A/B 测试拒绝虚无假设的概率被称为 A/B 测试的统计功效。如果改变文本颜色导致每位用户的收入增加 125 美元,我们可以说 125 美元是效果大小,且由于效果大小非零,我们知道虚无假设(改变文本颜色对收入没有影响)是错误的。但如果我们仅用三四个电子邮件订阅者的样本来研究这个真实效果,很有可能因为偶然原因,这些订阅者中没有人购买任何东西,因此我们未能发现真实的 125 美元效果。相比之下,如果我们使用一个有百万订阅者的电子邮件列表来研究文本颜色的变化效果,我们更有可能发现 125 美元的效果并将其测量为统计显著。对于百万订阅者列表,我们具有更大的统计功效。

我们可以导入一个 Python 模块,使得计算统计功效变得简单:

from statsmodels.stats.power import TTestIndPower

为了使用这个模块计算统计功效,我们需要定义影响统计显著性的三个因素的参数(参见前面的要点列表)。我们将定义alpha,即我们选择的统计显著性阈值,正如第三章所讨论的那样:

alpha=0.05

我们选择了标准的 0.05 阈值作为alpha,这是许多实证研究中常见的做法。我们还需要定义我们的样本大小。假设我们正在对一组由 90 名电子邮件订阅者组成的群体进行 A/B 测试。这意味着 A 组和 B 组各有 45 人,因此我们将每组的观测数量定义为 45。我们会将这个数字存储在一个名为nobs的变量中,nobs观测数量的缩写:

nobs=45

我们还需要定义一个估计的效应大小。在我们之前的 A/B 测试中,我们观察到的效应大小为$125。然而,对于该模块执行的统计功效计算,我们不能以美元或任何其他货币单位来表达效应大小。我们将改用 Cohen 的d,并指定一个中等大小:

effectsize=0.5

最后,我们可以使用一个函数,它将接受我们定义的三个参数,并计算我们应该预期的统计功效:

analysis = TTestIndPower()
power = analysis.solve_power(effect_size=effectsize, nobs1=nobs, alpha=alpha)

如果你运行print(power),你可以看到我们假设的 A/B 测试的统计功效大约是 0.65。这意味着我们预计在 A/B 测试中检测到效应的概率为 65%,而即使真实效应存在,我们的 A/B 测试没有发现它的概率为 35%。如果某个 A/B 测试预期费用较高,这样的赔率可能看起来不太理想;你需要根据自己对功效的最低接受标准做出决策。功效计算可以帮助你在规划阶段理解预期结果,并做好准备。一种常见的约定是,只批准预计功效至少为 80%的 A/B 测试。

你还可以使用我们在前一个代码片段中使用的相同solve_power()方法来“反向”计算统计功效:你可以从假定一个特定的功效水平开始,然后计算为了达到该统计功效水平所需的参数。例如,在以下代码片段中,我们定义了poweralpha和我们的效应大小,并运行solve_power()命令,这次不是计算功效,而是计算observations,即为了实现我们指定的功效水平, 每组所需的观测数量:

analysis = TTestIndPower()
alpha = 0.05
effect = 0.5
power = 0.8
observations = analysis.solve_power(effect_size=effect, power=power, alpha=alpha)

如果你运行print(observations),你会看到结果大约是 63.8。这意味着,如果我们希望在计划的 A/B 测试中达到 80%的统计功效,我们需要为两个组招募至少 64 个参与者。能够进行这些计算在 A/B 测试的规划阶段是非常有帮助的。

应用与高级考虑

到目前为止,我们只考虑了与营销电子邮件相关的 A/B 测试。但 A/B 测试适用于除了最佳电子邮件设计之外的各种商业挑战。A/B 测试最常见的应用之一是用户界面/体验设计。一个网站可能会随机将访问者分配到两个组(通常称为 A 组和 B 组),并向每组展示不同版本的网站。然后,该网站可以衡量哪个版本能带来更高的用户满意度、更高的收入、更多的链接点击、更多的停留时间,或者其他公司感兴趣的指标。整个过程可以完全自动化,这也使得今天顶尖科技公司能够进行高速、大规模的 A/B 测试。

电子商务公司进行测试,包括 A/B 测试,以确定产品定价。通过进行定价 A/B 测试,你可以衡量经济学家所说的需求价格弹性,即需求随价格变化的变化程度。如果你的 A/B 测试发现,当你提高价格时,需求变化非常小,那么你应该对所有人提高价格,利用他们更高的支付意愿。如果 A/B 测试发现,当你稍微提高价格时需求显著下降,那么你可以得出结论,客户对价格敏感,他们的购买决策很大程度上依赖于价格因素。如果客户对价格敏感并时刻考虑价格,他们很可能会对降价作出积极反应。如果是这样,你应该对所有人降价,并预计需求会大幅增加。一些企业必须依靠直觉或其他繁琐的计算来确定价格,但 A/B 测试使得确定正确价格变得相对简单。

电子邮件设计、用户界面设计和产品定价是商业对消费者(B2C)商业模型中的常见问题,在这种模型中,企业直接向消费者销售产品。B2C 场景非常适合进行 A/B 测试,因为 B2C 企业的客户、产品和交易数量通常比其他企业更高,因此我们可以获得更大的样本量和更强的统计能力。

这并不意味着商业对商业(B2B)公司不能进行 A/B 测试。事实上,A/B 测试在全球范围内以及许多领域已经被实践了几个世纪,尽管它过去仅被称为科学。例如,医学研究人员进行随机对照试验来测试新药,这种方法在本质上与 A/B 测试中的冠军/挑战者框架几乎相同。各类企业一直需要了解市场和客户,A/B 测试是一种自然、严谨的方式,几乎可以用来了解任何事情。

当你在业务中应用 A/B 测试时,你应该尽可能多地了解它,超越本章有限空间中的内容。一个你可能想深入研究的巨大领域是贝叶斯统计。一些数据科学家更倾向于使用贝叶斯方法,而不是显著性检验和p-值来测试 A/B 测试的成功。

另一个有趣且有用的主题是 A/B 测试中的探索/开发权衡。在这种权衡中,两个目标处于不断的张力之中:探索(例如,运行可能设计不佳的 A/B 测试以了解哪个最好)和开发(例如,仅发送表现最好的冠军电子邮件)。如果你的某个挑战者比冠军表现差很多,探索可能导致错失机会;你本可以直接向所有人发送冠军邮件。开发也可能导致错失机会,如果你的冠军不如另一个你还未测试的挑战者,因为你太专注于开发你的冠军而未进行必要的探索。

在运筹学研究中,你会发现大量关于多臂老丨虎丨机问题的研究,这是探索/开发困境的数学形式化。如果你真的有兴趣优化 A/B 测试,你可以了解一些研究人员提出的解决多臂老丨虎丨机问题的策略,以尽可能高效地进行 A/B 测试。

A/B 测试的伦理

A/B 测试充满了困难的伦理问题。这可能让人感到惊讶,但请记住,A/B 测试是一种实验方法,我们故意改变人类受试者的体验,以便研究结果并为自己的利益服务。这意味着 A/B 测试是人类实验。想想其他人类实验的例子,看看为什么人们对它有伦理担忧:

  1. 乔纳斯·索尔克开发了一种未经测试、前所未有的脊髓灰质炎疫苗,首先试验自己和家人,然后在数百万美国儿童中进行试验,以确保其有效。(它确实有效,并帮助消除了世界大部分地区的一种可怕的疾病。)

  2. 我的祖母为她的孙子孙女做了一只派,观察我们对它的反应,然后第二天做了一只不同的派,检查我们是更积极还是更消极地反应。(两者都很美味。)

  3. 一位教授伪装成学生,给 6300 位教授发送电子邮件,请他们安排时间与她交谈,谎称自己和意图,试图确定她的虚假身份是否会成为歧视的目标,以便她能够发表关于回复的论文。她没有为任何无意识参与研究的受试者提供补偿,既没有因欺骗或时间表干扰提供补偿,也没有事先获得他们同意作为实验对象。(这项研究的每一个细节都得到了大学伦理委员会的批准。)

  4. 一家公司故意操控其用户的情感,以便更好地理解并向他们推销产品。

  5. 约瑟夫·门格勒在奥斯维辛集中营对不愿意的受试者进行痛苦且致命的施虐实验。

  6. 你执行一个 A/B 测试。

这份人体实验列表中的前五项实际上发生过,除第二项外,所有事件都引发了社会科学家们关于伦理的讨论。你将不得不决定第六项是否会发生,以及你将在涉及伦理问题时采取何种立场。由于“人体实验”涵盖的活动范围广泛,因此对所有形式的“人体实验”做出单一的伦理判断是不可能的。在决定我们的 A/B 测试是否让我们像我祖母或萨尔克那样成为英雄,像门格勒那样成为恶棍,或是介于两者之间时,我们必须考虑多个重要的伦理概念。

我们应考虑的第一个概念是同意。萨尔克在对他人进行大规模测试之前,首先在自己身上测试了疫苗。相比之下,门格勒则对被囚禁在集中营中的不愿受试者进行实验。知情同意总是使人体实验更具伦理性。在某些情况下,获得知情同意是不可行的。例如,如果我们进行关于哪种户外广告牌设计最有效的实验,我们无法从每个可能的受试者那里获得知情同意,因为全球任何人都有可能看到公共广告牌,而我们无法联系到每一个活着的人。

其他情况则形成一个大的灰色地带。例如,执行 A/B 测试的网站可能有一个“服务条款”部分,其中有细则和法律术语声明,每一个访问该网站的用户都同意在访问网站时成为实验对象(通过 A/B 测试用户界面特征)。这在技术上可能符合知情同意的定义,但几乎所有网站访客中,只有极少数人会浏览并理解这些条款。在灰色地带的情况下,考虑其他伦理概念是有帮助的。

与 A/B 测试相关的另一个重要伦理考量是风险。风险本身涉及两个方面:作为人体受试者参与的潜在不利影响,以及经历这些不利影响的可能性。萨尔克的疫苗有一个大的潜在不利影响——感染小儿麻痹症——但由于萨尔克的准备和知识,受试者遭遇这一风险的可能性极低。市场营销活动的 A/B 测试通常涉及的潜在不利影响极其微小,因为很难想象任何可能的负面后果,比如(例如)某人收到了蓝色而非黑色文字的营销邮件。对于受试者风险较低的实验,其伦理性高于高风险实验。

我们还应考虑从我们的实验中可能带来的潜在好处。萨尔克的疫苗实验具有(后来实现的)消除大多数地区脊髓灰质炎的潜力。A/B 测试旨在提高利润,而不是治愈疾病,因此你对其好处的判断将取决于你对公司利润的道德地位的看法。公司营销实验可能带来的唯一其他好处是对人类心理学的理解进展。实际上,企业营销从业者偶尔会将营销实验的结果发表在心理学期刊上,所以这并不鲜见。

伦理和哲学问题永远无法得出所有人都认同的最终结论。你可以自己决定是否认为 A/B 测试本质上是好的,就像萨尔克的疫苗实验一样,或本质上令人反感,就像门吉莱的恐怖实验一样。大多数人都同意,大多数在线 A/B 测试的风险极低,而且人们很少拒绝同意无害的 A/B 测试,这意味着当 A/B 测试得当时,它是道德上可以辩护的活动。不管怎样,你应该仔细考虑自己的情况,并得出自己的结论。

总结

本章讨论了 A/B 测试。我们从一个简单的 t 检验开始,然后探讨了在 A/B 测试过程中随机、非混杂数据收集的必要性。我们介绍了 A/B 测试的一些细微差别,包括冠军/挑战者框架和特威曼定律,以及伦理问题。在下一章,我们将讨论二元分类,这是任何数据科学家必备的技能。

第五章:二元分类

许多难以回答的问题可以简单地表述为是/否问题:买这只股票还是不买?接受这份工作还是不接受?雇佣这个申请人还是不雇佣?本章讲述的是二元分类,这是回答是/否问题或在真与假、1 与 0 之间做出决策的技术术语。

我们将从介绍一个依赖于二元分类的常见商业场景开始。接着,我们将讨论线性概率模型,这是一种基于线性回归的简单但强大的二元分类方法。我们还会介绍逻辑回归,这是一种更先进的分类方法,能够改进线性概率模型的一些不足之处。最后,我们将讨论二元分类方法的众多应用,包括风险分析和预测。

最小化客户流失

假设你经营着一家大型科技公司,拥有大约 10,000 个大客户。每个客户与你签订了长期合同,承诺定期支付费用以使用贵公司的软件。然而,所有客户都可以随时退出合同,如果他们决定不再使用你的软件,就可以停止支付。你希望尽可能拥有更多的客户,因此你尽力做到两件事:一是通过与新客户签订新合同来推动公司发展,二是通过确保现有客户不退出合同来防止客户流失。

本章将重点讨论你的第二个目标:防止客户流失。这是各行业企业都非常关心的一个问题,也是每家公司都在努力解决的问题。特别重要的是,因为获取新客户的成本显然远高于留住现有客户的成本。

为了防止客户流失,你有一支客户经理团队与客户保持联系,确保他们满意,解决任何出现的问题,并确保他们足够满意以便继续无限期续签合同。然而,你的客户管理团队规模较小——只有几个人,他们必须共同努力保持 10,000 个客户的满意。对他们来说,无法与所有 10,000 个客户保持持续联系,难免会有一些客户的问题和担忧是你的客户管理团队无法发现或解决的。

作为公司领导者,你需要决定如何引导客户经理的努力,以最小化流失。他们每花一小时与高流失风险的客户合作,可能是值得的,但如果他们花太多时间在那些没有流失风险的客户身上,那就是浪费时间。客户经理们最有效的时间使用方式是集中精力处理那些最有可能取消合同的客户。所有经理需要的,只有一个高风险客户的名单,这样他们就可以以最高效率利用时间来减少流失。

获取准确的高流失风险客户列表并非易事,因为你不能读懂所有客户的心思,立即知道哪些客户面临取消合同的风险,哪些客户则一切如意。许多公司依赖直觉或猜测来决定哪些客户的流失风险最高。但直觉和猜测很少能得出最准确的结果。通过使用数据科学工具来决定每个客户是高风险还是低风险,我们可以获得更好的准确度,从而节省更多成本。

决定一个客户是高风险还是低风险流失是一个二分类问题;它由一个是或否的问题组成:这个客户面临高流失风险吗?最初作为一个令人头疼的商业问题(如何在有限的资源下增加收入增长)已经被简化为一个更简单的数据分析问题(如何进行流失风险的二分类)。我们将通过读取与过去流失相关的历史数据,分析这些数据以发现其中有用的模式,然后将我们对这些模式的理解应用于更近期的数据,以执行我们的二分类并提出有价值的商业建议。

使用线性概率模型来寻找高风险客户

我们可以从几种数据分析方法中选择来进行二分类。但在探索这些方法之前,我们需要先将一些数据读取到 Python 中。我们将使用关于我们虚构公司假设客户的虚拟数据。你可以通过使用以下代码段直接从它的在线地址加载到 Python 会话中:

import pandas as pd
attrition_past=pd.read_csv('https://bradfordtuckfield.com/attrition_past.csv')

在这个代码段中,我们导入了 pandas 并读取了数据文件。这次我们直接从存储文件的一个网站读取该文件。该文件是.csv格式,你在之前的章节中已经遇到过。你可以按照以下方式打印数据的前五行:

print(attrition_past.head())

你应该能看到以下输出:

 corporation  lastmonth_activity  ...  number_of_employees  exited
0        abcd                  78  ...                   12       1
1        asdf                  14  ...                   20       0
2        xyzz                 182  ...                   35       0
3        acme                 101  ...                    2       1
4        qwer                   0  ...                   42       1

输出的最后一行告诉我们数据集有五列。假设数据的前四列是在大约六个月前生成的。第一列是每个客户的四字符代码。第二列是lastmonth_activity,即该客户公司在生成此数据之前的一个月内访问我们软件的次数(大约在 6 到 7 个月前)。第三列是lastyear_activity,即数据生成之前整整一年的相同测量(大约在 6 到 18 个月前)。lastyear_activity列在前面的代码段中不可见,我们只能看到第二列和第四列之间的省略号。原因是 pandas 包有默认的显示设置,确保其输出足够小,能轻松适应屏幕。如果你想更改 pandas 打印的最大列数,可以在 Python 中运行以下代码:

pd.set_option('display.max_columns', 6)

在这里,我们使用 pandas 选项 display.max_columns 将 pandas 显示的最大列数更改为 6。这个更改确保了如果我们再次打印 attrition_past 数据集时,我们能够看到它的所有五列,当我们向数据集中添加一列时,我们就能看到它的所有六列。如果你希望显示所有数据集的所有列,不论多少列,你可以将 6 改为 None,这意味着 pandas 将不再对列数设置最大限制。

除了记录活动水平的列,我们还有记录每个公司六个月前员工数量的 number_of_employees 列。最后,假设最终列 exited 是今天生成的。这个列记录了某个公司是否在从前四列生成至今天的六个月期间退出了合同。这个列以二进制格式记录:1 表示在过去六个月内退出合同的客户,0 表示没有退出的客户。exited 列是我们衡量流失的二进制指标,它是我们最感兴趣的列,因为我们将学习如何预测它。

拥有四个六个月前的列和一个新列,可能看起来像是一个错误或不必要的复杂化。然而,我们列之间的时间差异使我们能够找到过去和未来之间的模式。我们将会在数据中找到一些模式,显示出某一时刻的活动水平和员工数量如何能够预测未来的流失水平。最终,我们在这些数据中找到的模式将使我们能够利用今天测量的客户活动来预测他们在接下来六个月内的流失可能性。如果我们能预测客户在未来六个月的流失风险,就可以在这六个月内采取行动,改变他们的想法,并留住他们。说服客户留下将是客户经理的角色——数据科学的贡献将是进行流失预测本身。

绘制流失风险

在我们深入寻找这些模式之前,让我们检查一下流失在数据中发生的频率:

print(attrition_past['exited'].mean())

我们得到的结果是大约 0.58,意味着数据中大约 58% 的客户在过去六个月内退出了他们的合同。这表明流失是业务的一大问题。

接下来,我们应该绘制数据图表。在任何数据分析场景中,提前并经常绘制图表都是一个好主意。我们对每个变量与二元变量 exited 之间的关系感兴趣,所以我们可以从绘制 lastmonth_activityexited 的关系开始:

from matplotlib import pyplot as plt
plt.scatter(attrition_past['lastmonth_activity'],attrition_past['exited'])
plt.title('Historical Attrition')
plt.xlabel('Last Month\'s Activity')
plt.ylabel('Attrition')
plt.show()

我们可以在图 5-1 中看到结果。

图 5-1:假设公司客户的历史流失情况

在 x 轴上,我们看到的是上个月的活动,尽管由于数据是六个月前记录的,因此实际上显示的是六到七个月前的活动。y 轴展示了我们exited变量的流失情况,这就是为什么最近六个月所有值都为 0(未流失)或 1(已流失)。从直观上看,这个图可以给我们一个关于过去活动与未来流失之间关系的基本印象。特别是,活动最多的客户(> 600)在记录了高活动后的六个月内没有退出他们的合同。高活动似乎是客户忠诚度的预测因子,如果是这样,低活动则会是客户流失的预测因子。

使用线性回归确认关系

我们希望通过进行更严格的定量测试来确认我们最初的直观印象。特别是,我们可以使用线性回归。记住,在第二章中,我们有一组点,并使用线性回归找到了一条最佳拟合的线。在这里,由于y变量的范围有限,我们的点看起来不太像云状:我们的“云”是位于y = 0 和y = 1 的两条散点线。然而,线性回归是线性代数中的一种数学方法,它并不关心我们的图形看起来是否像云状。我们可以使用与之前几乎相同的代码在我们的流失数据上执行线性回归:

x = attrition_past['lastmonth_activity'].values.reshape(-1,1)
y = attrition_past['exited'].values.reshape(-1,1)

from sklearn.linear_model import LinearRegression
regressor = LinearRegression()
regressor.fit(x, y)

在这段代码中,我们创建了一个名为regressor的变量,然后将其拟合到我们的数据上。在拟合完回归模型后,我们可以像在第二章中一样,将回归线绘制在我们的“数据云”上:

from matplotlib import pyplot as plt
plt.scatter(attrition_past['lastmonth_activity'],attrition_past['exited'])
prediction = [regressor.coef_[0]*x+regressor.intercept_[0] for x in \
list(attrition_past['lastmonth_activity'])]
plt.plot(attrition_past['lastmonth_activity'],  prediction, color='red')
plt.title('Historical Attrition')
plt.xlabel('Last Month\'s Activity')
plt.ylabel('Attrition')
plt.show()

图 5-2 展示了这段代码的结果。

图 5-2:预测 0–1 流失结果的线性回归

你可以将这个图与第二章中的图 2-2 进行比较。就像我们在图 2-2 中所做的那样,我们有一组数据点,并且我们添加了一条回归线,我们知道这条回归线是这些数据点的最佳拟合线。记住,我们将回归线的值解释为期望值。在图 2-2 中,我们看到回归线大约通过了点x = 109,y = 17,000,我们将其解释为在第 109 个月,我们预计汽车销量大约为 17,000 辆。

在图 5-2 中,解释我们的期望值的方法可能看起来不是立即显而易见的。例如,在x = 400 时,回归线的y值约为 0.4。这意味着我们期望exited的值为 0.4,但这是一个不太合适的说法,因为exited只能是 0 或 1(要么退出,要么不退出,没有中间状态)。那么,在这个活动水平下,期望有 0.4 个“退出”,或者 0.4 个流失单位,这又意味着什么呢?

我们如何解释0.4 单位流失的预期值呢?我们将其解释为概率:我们得出结论,最近一个月活动水平约为 400 的客户有约 40% 的概率会退出合同。由于我们的流失数据是活动水平记录后六个月的流失情况,我们将回归线的值解释为活动水平记录后的接下来的六个月内,流失概率为 40%。我们也可以这样表述我们估计的 40% 流失概率:我们估计活动水平为 400 的客户的流失风险为 40%。

图 5-2 中的回归是一个标准的线性回归,正如我们在第二章中创建的线性回归模型并在图 2-2 中绘制的那样。然而,当我们对二进制数据(仅由 0 和 1 两个值组成的数据)进行标准线性回归时,我们有一个特殊的名称:我们称之为线性概率模型(LPM)。这些模型简单且易于实现,但当我们想要预测一些难以预测的事情时,它们非常有用。

在进行回归并解释其值后,最后一个重要步骤是根据我们学到的所有知识做出商业决策。图 5-2 展示了活动与退出概率之间的简单关系:较低的活动与较高的退出概率相关联,而较高的活动与较低的退出概率相关联。我们所称的退出概率,也可以称作流失风险,因此我们也可以说上个月的活动与接下来六个月的流失风险呈负相关。从商业角度来看,这个负相关是有意义的:如果一个客户非常活跃,我们预计他们不太可能退出合同,而如果客户非常不活跃,我们预计他们更可能退出。

知道活动与流失风险之间普遍存在负相关性是有帮助的。但如果我们计算出每个客户的确切预测流失风险,我们的推理和决策会更加具体。这将使我们能够根据每个客户的预测风险做出个性化决策。以下代码计算每个客户的流失风险(我们回归模型的预测值),并将其存储在一个名为predicted的新列中:

attrition_past['predicted']=regressor.predict(x)

如果你运行print(attrition_past.head()),你可以看到我们的流失数据集现在有六列。它的新第六列是根据我们的回归模型预测的每个客户的流失概率。当然,这对我们并没有太大用处;我们不需要预测的流失概率,因为这是过去的流失记录,我们已经确定每个客户是否退出。

总的来说,员工流失预测有两个步骤。首先,我们通过使用过去的数据学习特征与目标变量之间的关系。其次,我们利用从过去数据中学到的关系来做未来的预测。到目前为止,我们只做了第一步:我们拟合了一个回归模型,捕捉了客户属性与流失风险之间的关系。接下来,我们需要做出未来的预测。

预测未来

让我们下载并打开更多的虚拟数据。这次,假设所有数据都是今天生成的,因此它的lastmonthactivity列指的是上个月,而lastyearactivity列指的是截至今天的 12 个月期间。我们可以按照以下方式读取我们的数据:

attrition_future=pd.read_csv('http://bradfordtuckfield.com/attrition2.csv')

我们之前使用的attrition_past数据集使用了旧数据(超过六个月的旧数据)来预测最近发生的员工流失(过去六个月内的任何时间)。相比之下,使用这个数据集时,我们将使用新的数据(今天生成的)来预测我们预期将在不久的将来(接下来的六个月内)发生的员工流失。这就是我们将其称为attrition_future的原因。如果你运行print(attrition_future.head()),你可以看到数据的前五行:

 corporation  lastmonth_activity  lastyear_activity  number_of_employees
0        hhtn                 166               1393                   91
1        slfm                 824              16920                  288
2        pryr                  68                549                   12
3        ahva                 121               1491                   16
4        dmai                   4                 94                    2

你可以看到这个数据集的前四列与attrition_past的前四列具有相同的名称和含义。然而,这个数据集没有第五列exited。数据集缺少这列是因为exited列应该记录客户在其他列生成后的六个月期间是否退出了他们的合同。但是那个六个月的时间段还没有发生;它是从今天开始的六个月。我们需要利用从流失数据集中学到的知识来预测这个新客户集的流失概率。当我们这样做时,我们就是在做未来的预测,而不是过去的预测。

attrition_future第一列中的所有四字符公司代码都是新的——它们在原始的流失数据集中没有出现。我们不能直接利用原始流失数据集中的任何数据来学习这个新的数据集。但是,我们可以利用我们拟合的回归模型来为这个新数据集做流失概率的预测。换句话说,我们不会直接用attrition_past中的实际数据来学习attrition_future,但我们会利用在attrition_past中发现的模式,这些模式我们通过线性回归编码出来,用来预测attrition_future

我们可以以与预测attrition_past数据集流失概率相同的方式预测attrition_future数据集的流失概率,如下所示:

x = attrition_future['lastmonth_activity'].values.reshape(-1,1)
attrition_future['predicted']=regressor.predict(x)

这段代码为attrition_future数据集添加了一个名为predicted的新列。我们可以运行print(attrition_future.head())来查看更改后的前五行:

 corporation  lastmonth_activity  ...  number_of_employees  predicted
0        hhtn                 166  ...                   91   0.576641
1        slfm                 824  ...                  288   0.040352
2        pryr                  68  ...                   12   0.656514
3        ahva                 121  ...                   16   0.613317
4        dmai                   4  ...                    2   0.708676

你可以看到,高活动客户的低预测流失概率模式与我们在attrition_past数据集中观察到的模式相匹配。这是因为我们的预测概率是使用与attrition_past数据集训练的相同回归模型生成的。

制定业务建议

在计算这些预测概率后,我们希望将它们转化为针对客户管理团队的业务建议。最简单的方式是为团队成员提供一个高风险客户名单,帮助他们集中精力。我们可以指定一个客户数量 n,以此为依据选择我们认为他们有时间和精力关注的客户,并创建前 n 个高风险客户的名单。对于 n = 5,我们可以按如下方式操作:

print(attrition_future.nlargest(5,'predicted'))

当我们运行这行代码时,得到如下输出:

 corporation  lastmonth_activity  ...  number_of_employees  predicted
8         whsh                   0  ...                   52   0.711936
12        mike                   0  ...                   49   0.711936
24        pian                   0  ...                   19   0.711936
21        bass                   2  ...                 1400   0.710306
4         dmai                   4  ...                    2   0.708676

[5 rows x 5 columns]

你可以看到,我们的前五大高风险客户的预测概率超过了 0.7(70%),这是相当高的流失概率。

现在,假设你的客户经理们不确定他们可以关注多少客户。与其要求提供前* n *个客户,他们可能只想要一个从最高到最低流失概率排序的客户名单。客户经理可以从名单的开头开始,并尽可能深入地处理下去。你可以轻松地打印出这个名单,方法如下:

print(list(attrition_future.sort_values(by='predicted',ascending=False).loc[:,'corporation']))

输出是一个按流失概率从高到低排名的attrition_future数据集中的所有公司名单:

['whsh', 'pian', 'mike', 'bass', 'pevc', 'dmai', 'ynus', 'kdic', 'hlpd',\
 'angl', 'erin', 'oscr', 'grce', 'zamk', 'hlly', 'xkcd', 'dwgt', 'pryr',\
 'skct', 'frgv', 'ejdc', 'ahva', 'wlcj', 'hhtn', 'slfm', 'cred']

该名单中的前三家公司——whshpianmike——预计具有最高的流失风险(即退出合同的概率最大)。在这种情况下,数据表明这三家公司存在三方平局的最高风险,因为这三家公司都具有相同的高风险预测,而其他公司则具有较低的流失风险预测。

最后,你可能决定只关注那些预测概率高于某个阈值 x 的客户。我们可以按如下方式进行操作,x = 0.7:

print(list(attrition_future.loc[attrition_future['predicted']>0.7,'corporation']))

你将看到所有公司完整的名单,所有这些公司预计在未来六个月内流失风险超过 70%。这可能是一个有用的优先级名单,供你的客户经理使用。

衡量预测准确性

在前一部分,我们已经完成了所有必要的步骤,将高风险公司名单发送给客户经理。在报告了我们的流失风险预测后,我们可能会觉得任务已经完成,可以继续进行下一个任务。但实际上,我们还没有完成。一旦我们将预测结果交给客户经理,他们很可能会立刻问我们,预测结果的准确性有多高。他们希望了解在采取行动之前,他们可以多大程度上信任我们的预测。

在第二章中,我们讨论了衡量线性回归准确性的两种常见方法:均方根误差(RMSE)和平均绝对误差(MAE)。我们的 LPM 本质上是一个线性回归,因此可以再次使用这些指标。然而,对于分类问题,常见的惯例是使用一组不同的指标,这些指标以更易于解释的方式表达分类准确性。我们首先需要做的是分别创建预测值和实际值的列表:

themedian=attrition_past['predicted'].median()
prediction=list(1*(attrition_past['predicted']>themedian))
actual=list(attrition_past['exited'])

在这个代码片段中,我们计算了predicted列的中位数值。然后我们创建了prediction,当 LPM 预测低于中位数概率时,prediction为 0,当 LPM 预测高于中位数概率时,prediction为 1。我们这样做是因为,在衡量分类任务的准确性时,我们将使用像predicted = 1,actual = 1 和 predicted = 0,actual = 0 这样的精确匹配的指标。典型的分类准确性指标不会对预测 0.99 概率而实际值为 1 的情况给予“部分积分”,因此我们将概率转换为 1 和 0,这样我们就能在可能的情况下获得“满分”。我们还将实际值列表(来自exited列)转换为 Python 列表。

现在我们的数据格式正确,我们可以创建一个混淆矩阵,这是衡量分类模型准确性的标准方法:

from sklearn.metrics import confusion_matrix
print(confusion_matrix(prediction,actual))

输出的混淆矩阵显示了我们在对数据集进行预测时,得到的真正正例、真正负例、假阳性和假阴性的数量。我们的混淆矩阵如下所示:

>>> **print(confusion_matrix(prediction,actual))**
[[7 6]
[4 9]]

每个混淆矩阵都有以下结构:

[[`true positives`       `false positives`]
 [`false negatives`     `true negatives`]]

因此,当我们查看我们的混淆矩阵时,我们发现我们的模型做出了七个真正的正例分类:对于七家公司,我们的模型预测了高于中位数的退出概率(高流失风险),而这七家公司确实退出了。我们的假阳性是六个案例,其中我们预测了高于中位数的退出概率,但公司没有退出。我们的假阴性是四个案例,其中我们预测了低于中位数的退出概率,但公司却退出了。最后,我们的真正负例是九个案例,其中我们预测了低于中位数的退出概率,而这些客户并未退出。

我们总是对真正的正例和真正的负例感到满意,并且我们总是希望这两者(混淆矩阵的主对角线上的值)尽可能高。我们从不对假阳性或假阴性感到满意,并且我们总是希望这两者(主对角线外的值)尽可能低。

混淆矩阵包含了我们所做分类及其正确性的所有可能信息。然而,数据科学家永远不会满足于寻找新的方法来切分、分析和重新表示数据。我们可以从我们的小混淆矩阵中计算出大量的派生指标。

我们可以导出的两个最常见的指标是精确度和召回率。精确度被定义为真正例 / (真正例 + 假正例)。召回率也叫敏感性,定义为真正例 / (真正例 + 假负例)。精确度回答的问题是:我们认为是正例的所有情况中,实际上有多少次是真正的正例?(在我们的案例中,正例指的是流失——在我们认为客户有很高流失风险的所有情况中,实际上有多少次他们真的流失了?)召回率回答的是稍微不同的问题:在所有实际的正例中,我们认为有多少是正例?(换句话说,在所有实际流失的客户中,我们预测有多少客户是高流失风险?)如果假正例很多,精确度会低。如果假负例很多,召回率会低。理想情况下,两者应该尽可能高。

我们可以按如下方式计算精确度和召回率:

conf_mat = confusion_matrix(prediction,actual)
precision = conf_mat[0][0]/(conf_mat[0][0]+conf_mat[0][1])
recall = conf_mat[0][0]/(conf_mat[0][0]+conf_mat[1][0])

你会看到我们的精确度大约为 0.54,召回率大约为 0.64。这些数值并不是非常令人鼓舞。精确度和召回率的值始终介于 0 和 1 之间,它们应该尽可能接近 1。我们的结果高于 0,这是好消息,但仍有很大的改进空间。让我们通过在接下来的部分中进行一些改进,尽力提高精确度和召回率。

使用多变量 LPMs

到目前为止,我们所有的结果都很简单:活动水平最低的客户也是预测流失概率最高的客户。这些模型如此简单,可能几乎看起来毫无价值。你可能会认为,低活动与流失风险之间的关系既直观又在图 5-2 中有清晰的视觉表现,因此拟合回归模型来确认这一点显得多余。这个观点是合理的,尽管即使在那些看似直观的情况下,寻求回归模型的严格验证仍然是明智的做法。

当我们没有明确的直觉关系,且没有能够立即显示这些关系的简单图表时,回归分析开始变得更有用。例如,我们可以使用三个预测变量来预测流失风险:上个月的活动、去年的活动以及客户的员工数量。如果我们想要同时绘制这三个变量与流失之间的关系,我们需要创建一个四维图表,这将很难读取和理解。如果我们不想创建四维图表,可以为每个单独的变量与流失之间的关系创建单独的图表。但每个图表只能显示一个变量与流失的关系,无法捕捉到整个数据集所传达的完整故事。

我们可以通过进行多变量回归分析,而不是通过绘制图表和直觉来发现流失风险,使用我们感兴趣的预测变量:

x3 = attrition_past.loc[:,['lastmonth_activity', 'lastyear_activity',\
 'number_of_employees']].values.reshape(-1,3)
y = attrition_past['exited'].values.reshape(-1,1)
regressor_multi = LinearRegression()
regressor_multi.fit(x3, y)

这是一种多元线性回归,就像我们在第二章介绍的多元线性回归一样。由于我们运行它来预测 0–1 数据,它是一个多元线性概率模型。正如我们之前为其他回归模型所做的那样,我们可以使用这个新的多元回归器来预测attrition_future数据集的概率:

attrition_future['predicted_multi']=regressor_multi.predict(x3)

当我们运行print(attrition_future.nlargest(5,'predicted_multi'))时,可以看到基于这个新多元回归器预测的五家公司流失风险最高。输出如下:

 corporation  lastmonth_activity  lastyear_activity  number_of_employees  \
11        ejdc                  95               1005                   61
12        mike                   0                  0                   49
13        pevc                   4                  6                 1686
4         dmai                   4                 94                    2
22        ynus                   9                 90                   12

    predicted  predicted_multi
11   0.634508         0.870000
12   0.711936         0.815677
13   0.708676         0.788110
4    0.708676         0.755625
22   0.704600         0.715362

[5 rows x 5 columns]

由于我们使用了三个变量来预测流失概率,而不是一个,因此很难直接看出哪些公司将拥有最高和最低的预测流失风险。在这种更复杂的情况下,回归模型的预测能力将非常有用。

让我们看一下按流失风险从高到低排序的所有公司列表,这个排序基于最新的回归结果:

print(list(attrition_future.sort_values(by='predicted_multi',\
ascending=False).loc[:,'corporation']))

你将看到以下公司列表:

['ejdc', 'mike', 'pevc', 'dmai', 'ynus', 'wlcj', 'angl', 'pian', 'slfm',\
 'hlpd', 'frgv', 'hlly', 'oscr', 'cred', 'dwgt', 'hhtn', 'whsh', 'grce',\
 'pryr', 'xkcd', 'bass', 'ahva', 'erin', 'zamk', 'skct', 'kdic']

这些公司是我们之前看到的公司,但是它们的顺序不同,因为它们的流失风险是使用regressor_multi而不是regressor来预测的。你可以看到,在某些情况下,顺序相似。例如,dmai公司在regressor中排名第六,而在regressor_multi中排名第四。还有一些情况下,顺序变化很大。例如,whsh公司在regressor中排名第一(与另外两家公司并列),但在regressor_multi的预测中排名第十七。顺序的变化是因为不同的回归模型考虑了不同的信息,并找到了不同的模式。

创建新的度量指标

在运行使用数据集中所有数值型预测变量的回归后,你可能会认为我们已经完成了所有可能的回归。但我们还能做更多的事情,因为我们并不严格局限于基于我们流失数据集中原始形式的列来创建 LPM(线性概率模型)。我们还可以创建一个派生特征,或称为工程特征——即通过转换和组合现有变量而创建的特征或度量指标。以下是一个派生特征的示例:

attrition_future['activity_per_employee']=attrition_future.loc[:,\
'lastmonth_activity']/attrition_future.loc[:,'number_of_employees']

在这里,我们创建了一个新的度量指标,叫做activity_per_employee。它简单地是整个公司上个月的活动总数除以公司员工的数量。这个新的派生度量指标可能比单独使用原始活动水平或原始员工数量更能有效预测员工流失风险。

例如,两家公司可能在 10,000 活跃度下相同。但是,如果其中一家公司有 10,000 名员工,另一家公司有 10 名员工,我们可能会对它们的员工流失风险有非常不同的预期。较小公司的平均员工每月使用我们的工具 1,000 次,而较大公司的平均员工每月只使用一次。尽管两家公司根据我们的原始测量具有相同的活跃度,但较小公司似乎具有较低的流失可能性,因为我们的工具对其员工的工作似乎更加重要。我们可以在回归分析中使用这个新的 activity_per_employee 指标,正如我们之前做过的所有回归分析一样:

attrition_past['activity_per_employee']=attrition_past.loc[:,\
'lastmonth_activity']/attrition_past.loc[:,'number_of_employees']
x = attrition_past.loc[:,['activity_per_employee','lastmonth_activity',\
 'lastyear_activity', 'number_of_employees']].values.reshape(-1,4)
y = attrition_past['exited'].values.reshape(-1,1)

regressor_derived= LinearRegression()
regressor_derived.fit(x, y)
attrition_past['predicted3']=regressor_derived.predict(x)

x = attrition_future.loc[:,['activity_per_employee','lastmonth_activity',\
 'lastyear_activity', 'number_of_employees']].values.reshape(-1,4)
attrition_future['predicted3']=regressor_derived.predict(x)

这个代码片段包含了很多代码,但它所做的每一件事你之前都做过。首先,我们定义了 activity_per_employee 指标,这是我们新推出的衍生特征。然后,我们定义了 xy 变量。x 变量将是我们的特征:我们用来预测流失的四个变量。y 变量将是我们的目标:我们尝试预测的那个变量。我们创建并拟合了一个线性回归模型,用 x 来预测 y,然后我们创建了 predicted3,这是一个新列,包含这个新回归模型做出的流失风险预测。我们为过去的数据和现在的数据都创建了一个 predicted3 列。

正如我们之前所做的,我们可以查看这个模型做出的预测:

print(list(attrition_future.sort_values(by='predicted3',ascending=False).loc[:,'corporation']))

再次,你会发现顺序与我们之前尝试的回归模型给出的顺序不同:

['pevc', 'bass', 'frgv', 'hlpd', 'angl', 'oscr', 'zamk', 'whsh', 'mike',\
 'hhtn', 'ejdc', 'grce', 'pian', 'ynus', 'dmai', 'kdic', 'erin', 'slfm',\
 'dwgt', 'pryr', 'hlly', 'xkcd', 'skct', 'ahva', 'wlcj', 'cred']

就像我们之前所做的那样,我们可以检查我们最新模型的混淆矩阵。首先,我们将预测值和实际值按正确的 0–1 格式放置:

themedian=attrition_past['predicted3'].median()
prediction=list(1*(attrition_past['predicted3']>themedian))
actual=list(attrition_past['exited'])

现在我们可以计算我们最新的混淆矩阵:

>>> **print(confusion_matrix(prediction,actual))**
[[9 4]
[2 11]]

这个混淆矩阵应该比我们之前的混淆矩阵看起来更好。如果你需要更多证据来证明我们最新的模型更好,看看这个模型的精度和召回率值:

conf_mat = confusion_matrix(prediction,actual)
precision = conf_mat[0][0]/(conf_mat[0][0]+conf_mat[0][1])
recall = conf_mat[0][0]/(conf_mat[0][0]+conf_mat[1][0])

你会看到我们的精度大约是 0.69,召回率大约是 0.82——虽然还不是完美的,但比我们之前的较低值有了很大的改进。

考虑 LPM 的缺点

LPM(线性概率模型)有一些优点:它们的值易于解释,使用几百年的方法和许多有用的 Python 模块可以轻松估计它们,而且它们的结构简单,就像一条直线一样。然而,LPM 也有一些缺点。一个缺点是它们并不能很好地拟合数据集的点:它们通过数据点的中间,并且仅靠近少数几个点。

如果你看看图 5-2 的右侧,你会发现 LPM 的最大弱点。在那里,你可以看到回归线跌到了 y = 0 以下。如果我们尝试解释回归线在该图部分的值,我们就会得出一个荒谬的结论:我们预测,拥有大约 1200 次登录的公司流失概率大约为-20%。没有合理的方式来解释负概率;这是我们模型输出的无意义结果。不幸的是,任何非水平的 LPM 回归线都会不可避免地给出低于 0%或高于 100%的预测。这种无意义预测的不可避免性是 LPM 的主要弱点,也是你应该学习其他二分类方法的原因。

使用逻辑回归预测二元结果

我们需要一种适用于二分类的方法,这种方法不受 LPM(线性概率模型)弱点的影响。如果你想一想图 5-2,你会意识到我们使用的任何方法都不能依赖于将直线拟合到数据点,因为除非是完美水平的直线,否则任何直线都不可避免地会预测出超过 100%或低于 0%的结果。任何直线也都会远离它试图拟合的许多数据点。如果我们要拟合一条直线来进行二分类,它必须是一条不会低于 0 或超过 1 的曲线,并且能够接近许多数据点(这些点的 y 值都为 0 或 1)。

一条符合这些标准的重要曲线被称为逻辑曲线。从数学上讲,逻辑曲线可以通过以下函数来描述:

逻辑函数用于建模人口、流行病、化学反应和语言变化等问题。如果你仔细观察这个函数的分母,你会看到β[0] + β[1]· x。如果这让你想起了我们在第二章做线性回归时使用的表达式,那是有原因的——它与标准回归公式中的表达式完全相同(包含截距、斜率和 x 变量)。

很快,我们将使用这个逻辑函数来讲解一种新的回归类型。我们将使用与之前相同的许多元素,因此我们所做的大部分内容应该会让你感到熟悉。我们将使用逻辑函数来建模流失风险,而我们使用它的方式可以应用于任何需要建模“是/否”或“0/1”答案概率的情况。

绘制逻辑曲线

我们可以用 Python 绘制一个简单的逻辑曲线,如下所示:

from matplotlib import pyplot as plt
import numpy as np
import math
x = np.arange(-5, 5, 0.05)
y = (1/(1+np.exp(-1-2*x)))
plt.plot(x,y)
plt.xlabel("X")
plt.ylabel("Value of Logistic Function")
plt.title('A Logistic Curve')
plt.show()

我们可以在图 5-3 中看到这段代码的输出结果。

图 5-3:逻辑曲线示例

逻辑曲线具有类似 S 形的形状,因此它在大多数范围内保持接近 y = 0 和 y = 1。同时,它永远不会超过 1,也永远不会低于 0,因此解决了 LPM 的弱点。

如果我们将逻辑方程中的系数从负数改为正数,我们就会反转逻辑曲线的方向,使其成为倒 S 形,而不是标准的 S 形:

from matplotlib import pyplot as plt
import numpy as np
import math
x = np.arange(-5, 5, 0.05)
y = (1/(1+np.exp(**1+2***x)))
plt.plot(x,y)
plt.xlabel("X")
plt.ylabel("Value of Logistic Function")
plt.title('A Logistic Curve')
plt.show()

这个代码片段与之前的代码片段相同,唯一不同的是有两个数字从负数改为正数(以加粗显示)。我们可以在图 5-4 中看到最终的图表。

图 5-4:另一个逻辑曲线示例,显示倒 S 形曲线

现在让我们使用逻辑曲线来处理我们的数据。

将逻辑函数拟合到我们的数据

我们可以像将直线拟合到二元数据一样,将逻辑曲线拟合到二元数据,这与我们创建 LPM 时拟合直线的方法相同。将逻辑曲线拟合到二元数据也叫做执行逻辑回归,它是二元分类中常见的标准替代方法,通常用于替代线性回归。我们可以选择几个有用的 Python 模块来执行逻辑回归:

from sklearn.linear_model import LogisticRegression
model = LogisticRegression(solver='liblinear', random_state=0)
x = attrition_past['lastmonth_activity'].values.reshape(-1,1)
y = attrition_past['exited']
model.fit(x, y)

在我们拟合模型后,可以如下获取每个元素的预测概率:

attrition_past['logisticprediction']=model.predict_proba(x)[:,1]

然后我们可以绘制结果:

fig = plt.scatter(attrition_past['lastmonth_activity'],attrition_past['exited'], color='blue')
attrition_past.sort_values('lastmonth_activity').plot('lastmonth_activity',\
'logisticprediction',ls='--', ax=fig.axes,color='red')
plt.title('Logistic Regression for Attrition Predictions')
plt.xlabel('Last Month\'s Activity')
plt.ylabel('Attrition (1=Exited)')
plt.show()

你可以从图 5-5 中的输出图中看到,我们得到了正是我们想要的结果:一个回归模型,它从不预测超过 100% 或低于 0% 的概率,并且非常接近我们那奇怪“云状”中的某些点。通过这种新方法,我们解决了 LPM 的弱点。

图 5-5:一个预测流失风险的逻辑回归

你可能会反对,我们介绍逻辑回归时提到它产生像图 5-3 和图 5-4 中的那种 S 形曲线,但图 5-5 中并没有 S 形曲线。但是,图 5-5 只显示了完整 S 曲线的一部分;它就像图 5-5 是放大了图 5-4 的右下角,所以我们只看到了 S 形曲线的右侧部分。如果我们将图形缩小并考虑假设的负活动水平,我们会看到一个更完整的倒 S 形曲线,包括接近 1 的预测流失概率。由于负活动水平是不可能的,我们只看到了逻辑方程指定的完整 S 曲线的一部分。

就像我们在其他回归中做的一样,我们可以查看我们的逻辑回归所做的预测。特别是,我们可以预测 attrition2 数据集中每个公司的流失概率,并按从最高到最低的流失风险顺序打印出来:

x = attrition_future['lastmonth_activity'].values.reshape(-1,1)
attrition_future['logisticprediction']=model.predict_proba(x)[:,1]
print(list(attrition_future.sort_values(by='logisticprediction',\
ascending=False).loc[:,'corporation']))

我们可以看到输出由 attrition2 中的每个公司组成,按预测的流失概率从高到低排序,这些预测基于我们的逻辑回归结果:

['whsh', 'pian', 'mike', 'bass', 'pevc', 'dmai', 'ynus', 'kdic', 'hlpd',\
'angl', 'erin', 'oscr', 'grce', 'zamk', 'hlly', 'xkcd', 'dwgt', 'pryr',\
'skct', 'frgv', 'ejdc', 'ahva', 'wlcj', 'hhtn', 'slfm', 'cred']

你可以查看这些结果,并将其与我们其他回归模型的预测进行比较。考虑到不同的信息,并使用不同的函数来建模数据,每次执行回归时可能会得到不同的结果。在这种情况下,由于我们的逻辑回归使用了与第一个 LPM 相同的预测变量(上个月的活动),因此它会按照相同的顺序将公司按风险从高到低排序。

二分类的应用

逻辑回归和 LPM 常用于预测二元结果。我们不仅可以用它们来预测流失,还可以用它们来预测股票是否会上涨、申请人是否会成功获得工作、项目是否会盈利、团队是否会赢得比赛,或者任何其他可以用真/假、0/1 框架表达的二分类问题。

本章中你学到的 LPM 和逻辑回归是统计工具,可以告诉我们流失的概率。但知道流失的概率并不能完全解决流失所代表的业务问题。业务领导者需要传达这些流失预测,并确保客户经理有效地采取行动。许多商业考虑因素可能会改变领导者应对流失问题的策略。例如,流失概率并不是决定客户优先级的唯一因素。该优先级还将取决于客户的相对重要性,可能包括公司期望从客户获得的收入、客户的规模以及其他战略性考虑。数据科学始终是更大业务流程的一部分,而这个流程中的每一步都既困难又重要。

LPM 和逻辑回归有一个重要的共同点:它们是单调的:它们表达的是只朝一个方向变化的趋势。在图 5-1、5-2 和 5-5 中,较少的活动总是与较高的流失风险相关,反之亦然。然而,想象一个更复杂的情况,其中低活动尤其与高流失风险相关,中等活动与低流失风险相关,而高活动再次与高流失风险相关。像本章中所研究的单调函数无法捕捉到这种模式,我们将不得不转向更复杂的模型。下一章将介绍机器学习方法——包括捕捉复杂、多变量数据中非单调趋势的方法——以使预测和分类更为准确。

总结

在本章中,我们讨论了二分类问题。我们从一个简单的商业场景开始,展示了线性回归如何帮助我们预测概率,从而解决商业问题。我们分析了这些线性概率模型的局限性,并引入了逻辑回归作为一种更复杂的模型,能够克服这些局限性。二分类问题看似一个不重要的主题,但我们可以用它来分析风险、预测未来,以及做出困难的是/否决策。在下一章的机器学习讨论中,我们将讨论超越回归的预测和分类方法。

第六章:监督学习

计算机科学家使用术语监督学习来指代一系列预测和分类的定量方法。事实上,你已经做过监督学习:你在第二章做的线性回归以及第五章中的 LPM 和逻辑回归都属于监督学习的实例。通过学习这些方法,你已经熟悉了监督学习的基本概念。本章介绍了一些先进的监督学习方法,并讨论了监督学习的一般概念。我们如此详细地探讨这一话题,因为它是数据科学中至关重要的组成部分。

我们将通过介绍另一个商业挑战,描述监督学习如何帮助我们解决这个问题来开始。我们将讨论线性回归作为一个不完美的解决方案,并一般性地讨论监督学习。接下来,我们将介绍 k-NN,这是一种简单而优雅的监督学习方法。我们还将简要介绍决策树、随机森林和神经网络,并讨论如何使用它们进行预测和分类。最后,我们将讨论如何衡量准确性以及这些不同方法的共同点。

预测网站流量

假设你经营一个网站。你的网站商业模式很简单:你发布有趣话题的文章,通过访问你网站文章的人赚取收入。无论收入来自广告销售、订阅还是捐赠,你的收入与访问你网站的人数成正比:访问者越多,收入越高。

非专业作家将文章提交给你,希望你能在网站上发布它们。你收到大量的投稿,根本不可能阅读,更不用说发布所有收到的文章。因此,你必须进行一些筛选。在决定发布哪些文章时,你可能会考虑许多因素。当然,你会尽量考虑提交文章的质量。你还会考虑哪些文章与网站的“品牌”相符。但最终,你的目标是经营一家成功的企业,最大化网站的收入对确保企业的长期生存至关重要。由于你的收入与访问你网站的人数成正比,最大化收入将取决于选择那些可能会吸引大量访问者的文章进行发布。

你可以尝试依靠直觉来决定哪些文章可能会获得更多的访问者。这需要你或你的团队阅读每一篇提交的文章,并作出关于哪些文章可能吸引访问者的艰难判断。这将非常耗时,即使在花费大量时间阅读文章之后,也不能完全确定你的团队会做出正确的判断,知道哪些文章会吸引最多的访问者。

解决这个问题的一个更快速且可能更准确的方法是通过监督学习。想象一下,你能够编写代码,在文章到达你的邮箱后立即阅读它们,并利用代码从每篇提交的文章中获取的信息,准确预测它将吸引的访客数量,在发布之前。如果你有这样的代码,你甚至可以完全自动化你的发布过程:一个机器人可以从邮件中读取提交内容,预测每篇提交文章的预期收入,并发布每篇预期收入超过特定阈值的文章。

该过程最难的部分是预测文章的预期收入;这是我们需要依赖监督学习来完成的部分。在本章的其余部分,我们将介绍实现这种自动化系统所需的监督学习步骤,以预测某篇文章将吸引的访客数量。

读取和绘制新闻文章数据

与大多数数据科学场景一样,监督学习要求我们读取数据。我们将读取一个可以免费获得的数据集,来自加利福尼亚大学欧文分校(UCI)机器学习库(archive-beta.ics.uci.edu/)。该库包含了数百个数据集,供机器学习研究人员和爱好者用于研究和娱乐。

我们将使用的特定数据集包含有关 2013 年和 2014 年在 Mashable 网站上发布的新闻文章的详细信息(mashable.com)。这个在线新闻流行度数据集有一个网页 archive-beta.ics.uci.edu/dataset/332/online+news+popularity,提供了更多关于数据的信息,包括数据的来源、包含的信息,以及已有分析的论文。

你可以从 archive.ics.uci.edu/ml/machine-learning-databases/00332/OnlineNewsPopularity.zip 获取该数据的 ZIP 文件。下载 ZIP 压缩包后,你必须将其解压到计算机中。然后你会看到 OnlineNewsPopularity.csv 文件,这就是数据集本身。在解压该 .csv 文件后,你可以按如下方式将其读取到 Python 会话中:

import pandas as pd
news=pd.read_csv('OnlineNewsPopularity.csv')

我们导入我们老朋友 pandas 包,并将新闻数据集读取到一个名为 news 的变量中。news 的每一行包含关于某篇特定文章的详细信息,这些文章都发布在 Mashable 上。第一列 url 包含该文章的原始 URL。如果你访问特定文章的 URL,你可以看到与之相关的文本和图像。

总的来说,我们的 news 数据集有 61 列。每一列从第二列开始,都包含关于文章的某些数值度量。例如,第三列名为 n_tokens_title。这是标题中的token数量,在这种情况下,指的是标题中的单词数。news 数据集中的许多列名称都涉及到自然语言处理(NLP)中的高级方法。NLP 是一个相对较新的领域,旨在利用计算机科学和数学算法,以快速、自动的方式分析、生成和翻译自然人类语言,而不需要人工干预。

请考虑第 46 列,global_sentiment_polarity。这一列包含了每篇文章的整体情感度量,从 -1(高度负面)到 0(中性)再到 1(高度正面)。能够自动衡量用自然人类语言写成的文本的情感是自然语言处理(NLP)领域最近令人兴奋的进展之一。最先进的情感分析算法能够与人类的情感评分紧密匹配,因此,一篇关于死亡、恐怖和悲伤的文章,无论是人类还是 NLP 算法都会将其评定为高度负面的情感(接近 -1),而一篇关于快乐、自由和数据分析的文章则普遍被认为具有高度正面的情感(接近 1)。我们的数据集创建者已经运行了情感分析算法来衡量数据集中每篇文章的情感,结果存储在 global_sentiment_polarity 中。其他列则包含了其他度量值,包括文章长度等简单的指标以及其他高级的 NLP 结果。

最后一列,shares,记录了每篇文章在社交媒体平台上被分享的次数。我们的真正目标是通过增加访问量来增加收入。但我们的数据集中并没有直接测量收入或访问量!这是数据科学实践中的一种常见情况:我们想分析某些内容,但数据中只有其他信息。在这种情况下,合理的推测是,社交媒体分享的次数与文章的访问量相关,因为高访问量的文章会经常被分享,而高度分享的文章也会频繁被访问。正如我们之前提到的,收入直接与网站访问量相关。因此,我们可以合理地假设,文章的社交媒体分享次数与该文章所获得的收入紧密相关。这意味着我们将使用分享次数作为访问量和收入的代理

如果我们能确定哪些文章的特征与分享数呈正相关,那将有助于我们的分析。例如,我们可能会猜测,情感分数较高的文章也会被更频繁地分享,如果我们相信人们喜欢分享愉快的事情的话。如果这是正确的,了解一篇文章的情感倾向将有助于我们预测它的分享次数。通过学习如何预测分享次数,我们假设我们也将同时学会如何预测访客数和收入。而且,如果我们知道一篇文章的高分享特征,我们将知道如何设计未来的文章以最大化我们的收入。

正如我们以前做过的那样(特别是在第一章中),我们可以从简单的探索开始。我们将从绘制图表开始。让我们考虑一个情感与分享数之间关系的图表:

from matplotlib import pyplot as plt
plt.scatter(news[' global_sentiment_polarity'],news[' shares'])
plt.title('Popularity by Sentiment')
plt.xlabel('Sentiment Polarity')
plt.ylabel('Shares')
plt.show()

你可能会注意到,当我们在这段 Python 代码中访问数据集的列时,我们在每个列名的开头加了一个空格。例如,我们写 news[' shares'] 而不是 news['shares'] 来引用记录分享次数的列。我们这样做是因为原始数据文件中的列名本身就包含了一个空格。无论是什么原因,这个文件中的每个列名前都带有一个空格,而不是只有列名本身,因此我们在告诉 Python 访问每一列时,需要包含这个空格。你将在本章中看到这些空格;每个数据集都有它自己的特殊之处,而成为一名成功的数据科学家的一部分,就是能够理解并适应这些特殊之处。

图 6-1 显示了情感极性与分享数之间的关系。

图 6-1:我们数据集中每篇文章的情感与分享数之间的关系

我们可以从这个图中注意到的一点是,至少从肉眼来看,情感极性与分享数之间似乎不存在明显的线性关系。高情感的文章似乎并没有比低情感的文章更频繁地被分享,反之亦然。事实上,情感接近中立的文章(情感较为中性的文章)似乎获得了最多的分享。

使用线性回归作为预测方法

我们可以通过执行线性回归来对这种(缺乏)线性关系进行更严格的检验,就像我们在第二章和第五章中做的那样:

from sklearn.linear_model import LinearRegression
x = news[' global_sentiment_polarity'].values.reshape(-1,1)
y = news[' shares'].values.reshape(-1,1)
regressor = LinearRegression()
regressor.fit(x, y)
print(regressor.coef_)
print(regressor.intercept_)

这段代码执行了一个使用情感极性预测分享数的线性回归。它的执行方式和我们在第二章中概述的相同。我们首先从 sklearn.linear_model 模块导入所需的 LinearRegression() 函数。接着,我们对数据进行重塑,以便导入的模块能够与之配合使用。我们创建一个名为 regressor 的变量,并将回归器拟合到我们的数据上。最后,我们打印出拟合回归后得到的系数和截距:499.3 和 3,335.8。

你会记得在第二章中,我们可以将这些数字解释为回归线的斜率和截距,分别对应于回归方程的系数。换句话说,我们的线性回归估计了情感与股票数之间的关系,如下所示:

shares = 3335.8 + 499.3 · sentiment

我们可以将这条回归线与我们的数据一起绘制,如下所示:

regline=regressor.predict(x)
plt.scatter(news[' global_sentiment_polarity'],news[' shares'],color='blue')
plt.plot(sorted(news[' global_sentiment_polarity'].tolist()),regline,'r')
plt.title('Shares by Sentiment')
plt.xlabel('Sentiment')
plt.ylabel('Shares')
plt.show()

输出应该类似于图 6-2。

图 6-2:显示情感与股票数之间估计关系的回归线

我们的回归线,如果你在家创建图表的话应该是红色的,看起来非常平坦,显示情感与股票数之间的关系较弱。使用这条回归线来预测股票数可能不会有太大帮助,因为它为每个情感值预测几乎相同的股票数。我们将需要探索其他监督学习方法,以便得到更好、更准确的预测。但首先,让我们先思考一下监督学习的一般概念,包括线性回归为什么是一种监督学习方法,以及其他哪些监督学习方法可能适用于我们的商业场景。

理解监督学习

我们刚才做的线性回归是监督学习的一个例子。在本章中,我们多次提到了监督学习,但没有准确地定义它。我们可以将其定义为学习一个将特征变量映射到目标变量的函数的过程。听起来可能不太直观或清晰。为了理解我们的意思,请参考图 6-3。

图 6-3:监督学习过程

想一想这个图如何应用于我们在本章早些时候完成的线性回归。我们使用情感作为唯一的特征(左侧的椭圆)。我们的目标变量是股票数(右侧的椭圆)。以下方程展示了我们的学习函数(中间的箭头):

shares = 3,335.8 + 499.3 · sentiment

这个函数做了监督学习中每个学习函数应该做的事情:它接受一个(或多个)特征作为输入,并输出目标变量值的预测。在我们的代码中,我们从 sklearn 模块中导入了功能,这些功能为我们确定了系数,或者说学习了这个函数。(在这方面,sklearn 通过依赖线性代数方程来学习这个函数,这些方程可以保证找到最小化目标变量均方误差的系数,正如我们在第二章中讨论的那样。)

监督学习一词指的是确定(学习)这个函数的过程。目标变量是监督这个过程的关键,因为在我们确定学习到的函数时,我们会检查它是否能准确预测目标值。如果没有目标变量,我们就无法学习函数,因为我们无法判断哪些系数导致了高准确度,哪些导致了低准确度。

你将使用的每一个监督学习方法都可以通过图 6-3 来描述。在某些情况下,我们可以进行特征工程,仔细选择数据集中哪些变量能够带来最准确的预测。在其他情况下,我们会调整目标变量——例如,使用代理变量或对原始变量进行转换。但任何监督学习方法中最重要的部分是学习到的函数,它将特征映射到目标。掌握新的监督学习方法就意味着掌握确定这些学习函数的新方法。

当我们选择线性回归作为监督学习方法时,得到的学习函数总是呈方程 6-1 所示的形式:

目标 = 截距 + 系数[1] · 特征[1] + 系数[2] · 特征[2] + … + 系数[n] · 特征[n]

方程 6-1:每个线性回归的学习函数的一般形式

对于那些上过许多代数课的人来说,这可能是函数采取的自然形式。系数与特征相乘并加总。当我们在二维空间中进行这种操作时,我们得到一条直线,就像图 6-2 中的直线。

然而,这并不是学习到的函数唯一可能的形式。如果我们更深入地思考这种形式,我们会意识到,线性回归的函数隐含地表达了一种假设的世界观或模型。特别地,线性回归隐含地假设世界可以用直线来描述:每当我们有两个变量xy时,就存在一种准确地将它们关联起来的方式,即直线y = a + bx,其中ab是某些常数。世界上许多事物可以用直线来描述,但并非所有事物都如此。宇宙是一个庞大的地方,存在许多世界模型、许多学习到的函数,以及许多监督学习方法,通过放弃这种线性假设,我们可以获得更准确的预测。

如果世界不是通过直线和线性关系来描述的,那么哪种世界模型是正确的,或者最准确或最有用的呢?有许多可能的答案。例如,我们可以将世界视为由围绕点的小区组成,而不是由直线构成。我们可以不用直线来进行预测,而是测量点周围小区的特征,并利用这些小区来进行预测。(这种方法将在下一节中更加清晰。)

如果我们在世界上观察到的一切都可以通过直线和线性关系来关联,那么线性回归就是研究它的正确模型。如果世界是由邻域组成的,那么另一种监督学习模型更加合适:k 最近邻。我们接下来将探讨这种方法。

k 最近邻

假设你有一个实习生,他从未学习过统计学、线性回归、监督学习或数据科学的任何内容。你刚刚收到一篇作者希望在你的网站上发布的新文章。你把这篇新提交的文章以及news数据集和一些 NLP 软件交给了实习生。你指派实习生预测这篇新文章将被分享的次数。如果实习生预测的分享次数很高,你将发布这篇文章。否则,你不会发布它。

你的实习生使用 NLP 软件确定这篇文章的global_sentiment_polarity为 0.42。你的实习生不知道如何做我们在本章开始时所做的线性回归分析。相反,他们有一个简单的想法来预测分享次数。他们的简单想法是遍历news数据集,直到找到一篇与这篇新文章相似的文章。如果数据集中有一篇现有文章与新提交的文章相似,那么可以合理地推测,新文章的分享次数将与现有文章的分享次数相似。

例如,假设他们在数据集中找到一篇现有文章,其global_sentiment_polarity为 0.4199。他们合理地得出结论,这篇现有文章与我们的新文章相似,因为它们的情感评分几乎相同。如果现有文章获得了 1,200 次分享,我们可以预期我们的新文章,具有几乎相同的global_sentiment_polarity,也应该有类似的分享次数。“相似的文章获得相似的分享次数”是对这种简单思维过程的总结。在监督学习的背景下,我们可以将其重新表述为“相似的特征值导致相似的目标值”,尽管你的实习生从未听说过监督学习。

由于我们正在处理的是数值数据,我们不需要仅仅定性地讨论文章之间的相似性。我们可以直接测量数据集中的任何两条观测值之间的距离。与我们的新文章相似的现有文章的global_sentiment_polarity为 0.4199,和我们新文章的global_sentiment_polarity(0.42)之间相差 0.0001。由于global_sentiment_polarity是我们迄今为止考虑的唯一变量,我们可以说这两篇文章之间的距离为 0.0001。

你可能认为距离是一个有着明确且不可改变定义的概念。但在数据科学和机器学习中,我们常常发现自己测量的距离并不符合我们日常生活中的定义。在这个例子中,我们使用的是情感评分的差异作为我们的距离,尽管这不是一种可以步行或用尺子测量的距离。在其他情况下,我们可能会发现自己在表达真假值之间的距离,特别是当我们在进行分类时,正如第五章所示。我们谈论距离时,通常是将其作为一种宽松的类比,而不是字面上的物理测量。

具有小距离的观测值可以被称为 邻居,在这个例子中,我们找到了两个相近的邻居。另一篇情感得分为 0.41 的文章,与我们新文章的距离是 0.1:仍然是一个邻居,但距离“街道”稍微远一些。对于任何两篇文章,我们都可以在所有感兴趣的变量上衡量它们之间的距离,并将此作为衡量它们是邻居的程度。

我们不仅仅考虑一个邻居文章,而是考虑围绕我们想要预测的新文章的整个邻里。我们可能会找到离新文章最近的 15 个邻居——数据集中 global_sentiment_polarity 最接近 0.42 的 15 个点。我们可以考虑这些 15 篇文章的分享数量。这 15 个最近邻的分享数量的平均值,是我们可以合理预测新文章将获得的分享数量。

你的实习生认为他们的预测方法没什么特别的,似乎只是一个简单自然的预测方式,没有使用任何微积分或计算机科学的知识。然而,他们的简单过程实际上是一个强大的监督学习算法,叫做 k-最近邻(k-NN)。我们可以用四个简单的步骤来描述整个方法;实际上,它本身就是简单:

  1. 选择一个你想要对目标变量进行预测的点 p

  2. 选择一个自然数,k

  3. 找到数据集中与点 p 最近的 k 个邻居。

  4. k 个最近邻的目标值的平均值就是 p 的目标值预测。

你可能已经注意到,k-NN 过程不需要任何矩阵乘法、微积分,甚至不需要任何数学。尽管它通常只在研究生级别的计算机科学课程中教授,k-NN 其实只是一个简单的思想,孩子甚至实习生都能直观地理解:如果事物在某些方面相似,那么它们在其他方面也很可能相似。如果事物处于同一个邻里,它们可能是相似的。

实现 k-NN

编写 k-NN 监督学习的代码很直接。我们将首先定义 k,即我们将查看的邻居数量,以及 newsentiment,它将保存我们想要预测的假设新文章的 global_sentiment_polarity。在这种情况下,假设我们收到另一篇新文章,这篇文章的情感评分为 0.5:

k=15
newsentiment=0.5

所以,我们将预测一个情感评分为 0.5 的新文章将获得的分享数量。我们将查看与新文章最接近的 15 个邻居来进行这些预测。将极性和分享数据转换为列表会更为方便,如下所示:

allsentiment=news[' global_sentiment_polarity'].tolist()
allshares=news[' shares'].tolist()

接下来,我们可以计算数据集中每篇文章与假设的新文章之间的距离:

distances=[abs(x-newsentiment) for x in allsentiment]

这段代码使用了列表推导式来计算每篇现有文章的情感与新文章情感之间差值的绝对值。

现在我们有了所有这些距离,我们需要找出哪些是最小的。记住,距离新文章最近的文章就是最邻近的邻居,我们将利用它们来做最终预测。Python 的 NumPy 包中的一个有用函数使我们可以轻松找到最近的邻居:

import numpy as np
idx = np.argsort(distances)

在这段代码中,我们导入了 NumPy,然后定义了一个名为 idx 的变量,它是 index(索引)的缩写。如果你运行 print(idx[0:k]),你可以看到这个变量的内容:

[30230, 30670, 13035, 7284, 36029, 19361, 29598, 22546, 25556, 6744, 26473,\
7211, 9200, 15198, 31496]

这 15 个数字是最邻近的邻居的索引编号。我们数据集中第 30,230 篇文章的 global_sentiment_polarity 是最接近 0.5 的。第 30,670 篇文章的 global_sentiment_polarity 排名第二,依此类推。我们使用的 argsort() 方法是一个便捷的方法,它将距离列表从小到大排序,然后提供 k 个最小距离(最邻近的邻居)的索引。

在我们知道了最近邻的索引后,我们可以创建一个包含每个邻居对应分享次数的列表:

nearbyshares=[allshares[i] for i in idx[0:k]]

我们的最终预测只是这个列表的平均值:

print(np.mean(nearbyshares))

你应该得到输出 7344.466666666666,这表明情感接近 0.5 的过去文章平均获得大约 7,344 次社交媒体分享。如果我们相信 k-NN 的逻辑,那么我们应该预期,任何未来情感接近 0.5 的文章也将获得大约 7,344 次社交媒体分享。

使用 Python 的 sklearn 执行 k-NN

我们不需要每次想要使用 k-NN 进行预测时都经过那个整个过程。某些 Python 包可以为我们执行 k-NN,包括 sklearn 包,我们可以通过以下方式将其相关模块导入到 Python 中:

from sklearn.neighbors import KNeighborsRegressor

你可能会惊讶于我们这里导入的模块叫做 KNeighborsRegressor。我们刚刚描述了 k-NN 与线性回归非常不同,那么为什么一个 k-NN 模块会像线性回归模块一样使用 regressor 这个词呢?

k-NN 方法显然不是线性回归,它也不使用线性回归依赖的任何矩阵代数,也不会像线性回归那样输出回归线。然而,既然它是一个监督学习方法,它实现的目标与线性回归相同:确定一个将特征映射到目标的函数。由于回归是主导了一个多世纪的监督学习方法,人们开始将回归视为监督学习的同义词。因此,人们开始将 k-NN 函数称为k-NN 回归器,因为它们实现的目标与回归相同,尽管没有进行实际的线性回归。

今天,回归回归器这两个词被用于所有关于连续数值型目标变量的监督学习方法,无论它们是否与线性回归直接相关。由于监督学习和数据科学是相对较新的领域(与数学相比,数学已有几千年的历史),许多混淆或冗余的术语仍然存在并未被清理;学习数据科学的一部分就是习惯这些令人困惑的名称。

就像我们对线性回归所做的那样,我们需要调整情感列表的形状,以便它符合这个包的预期格式:

x=np.array(allsentiment).reshape(-1,1)
y=np.array(allshares)

现在,我们不再计算距离和索引,而是可以简单地创建一个“回归器”并将其拟合到我们的数据中:

knnregressor = KNeighborsRegressor(n_neighbors=15)
knnregressor.fit(x,y)

现在,只要我们正确地调整了形状,就可以找到分类器对任何情感所做的预测:

print(knnregressor.predict(np.array([newsentiment]).reshape(1,-1)))

这个 k-NN 回归器预测新文章将获得 7,344.46666667 次分享。这与我们之前在手动进行 k-NN 过程时得到的结果完全一致。你应该为结果匹配而感到高兴:这意味着你至少和受尊敬的、流行的 sklearn 包的作者一样,知道如何编写 k-NN 的代码。

现在你已经学习了一种新的监督学习方法,试着思考它与线性回归有何相似之处与不同之处。线性回归和 k-NN 都依赖特征变量和目标变量,如图 6-3 所示。它们都创建了一个学习到的函数,将特征变量映射到目标变量。在线性回归的情况下,学习到的函数是变量与系数相乘后的线性和,形式如方程 6-1 所示。在 k-NN 的情况下,学习到的函数是一个寻找相关数据集中k个最近邻居的均值目标值的函数。

虽然线性回归隐式地表达了一个模型,其中所有变量可以通过直线相互关联,但 k-NN 隐式地表达了一个模型,其中点的邻域彼此相似。这些世界模型及其所暗示的学习函数是截然不同的。由于学习到的函数不同,它们可能会对文章分享次数或我们想要预测的其他任何内容做出不同的预测。但准确预测目标变量的目标在两者中是相同的,因此这两者都是常用的监督学习方法。

使用其他监督学习算法

线性回归和 k-NN 只是我们预测场景中可以使用的众多监督学习算法中的两种。允许我们轻松进行 k-NN 回归的相同 sklearn 包,也可以使我们使用这些其他监督学习算法。列表 6-1 展示了如何使用五种方法进行监督学习,每种方法使用相同的特征和目标变量,但使用不同的监督学习算法(不同的学习函数):

#linear regression
from sklearn.linear_model import LinearRegression
regressor = LinearRegression()
regressor.fit(np.array(allsentiment).reshape(-1,1), np.array(allshares))
print(regressor.predict(np.array([newsentiment]).reshape(1,-1)))

#knn
from sklearn.neighbors import KNeighborsRegressor
knnregressor = KNeighborsRegressor(n_neighbors=15)
knnregressor.fit(np.array(allsentiment).reshape(-1,1), np.array(allshares))
print(knnregressor.predict(np.array([newsentiment]).reshape(1,-1)))

#decision tree
from sklearn.tree import DecisionTreeRegressor
dtregressor = DecisionTreeRegressor(max_depth=3)
dtregressor.fit(np.array(allsentiment).reshape(-1,1), np.array(allshares))
print(dtregressor.predict(np.array([newsentiment]).reshape(1,-1)))

#random forest
from sklearn.ensemble import RandomForestRegressor
rfregressor = RandomForestRegressor()
rfregressor.fit(np.array(allsentiment).reshape(-1,1), np.array(allshares))
print(rfregressor.predict(np.array([newsentiment]).reshape(1,-1)))

#neural network
from sklearn.neural_network import MLPRegressor
nnregressor = MLPRegressor()
nnregressor.fit(np.array(allsentiment).reshape(-1,1), np.array(allshares))
print(nnregressor.predict(np.array([newsentiment]).reshape(1,-1)))

列表 6-1:五种监督学习方法的集合

这个代码片段包含五个部分,每个部分有四行代码。前两个部分用于线性回归和 k-NN;它们是我们之前运行的相同代码,用于使用 sklearn 的预构建包轻松获得线性回归和 k-NN 预测。其他三个部分与前两个部分具有完全相同的结构:

  1. 导入包。

  2. 定义一个“回归器”。

  3. 将回归器拟合到我们的数据。

  4. 使用拟合的回归器打印预测结果。

区别在于五个部分中的每一个都使用不同类型的回归器。第三部分使用决策树回归器,第四部分使用随机森林回归器,第五部分使用神经网络回归器。你可能不知道这些回归器的具体类型,但你可以将其看作一种便利的事情:监督学习如此简单,你甚至可以在不知道模型是什么的情况下编写代码来构建模型并做出预测!(这并不是说这是一个好的实践——通常最好对你使用的每个算法有一个扎实的理论理解。)

描述所有这些监督学习算法的每个细节超出了本书的范围。但我们可以提供主要思想的概述。每种方法实现了相同的目标(预测目标变量),但使用不同的学习函数。反过来,这些学习函数隐式地表达了不同的假设和不同的数学,换句话说,不同的世界模型。

决策树

让我们从决策树开始,这在我们的代码中是 k-NN 部分之后的第一种模型。与假设变量通过直线(如线性回归)或通过邻域关系(如 k-NN)相关不同,决策树假设变量之间的关系可以通过一个包含二叉分裂的树形结构来最好地表达。如果这个描述听起来不太清楚,别担心;我们将使用 sklearn 的决策树绘图功能,创建一个名为dtregressor的决策树回归器的图,这个回归器是通过清单 6-1 中的代码创建的:

from sklearn.tree import plot_tree
import matplotlib.pyplot as plt
plt.figure(figsize=(16,5))
plot_tree(dtregressor, filled=True, fontsize=8)
plt.savefig('decisiontree.png')

我们可以在图 6-4 中看到结果。

图 6-4:基于情感预测文章分享数的决策树

我们可以按照这个流程图,基于任何global_sentiment_polarity来做出关于分享数的预测。由于这个流程图具有类似树枝的分支结构,并且它能够进行决策,所以我们称它为决策树

我们从树顶的框开始。框的第一行表达了一个条件:X[0] <= 0.259。这里,X[0]是指global_sentiment_polarity变量,它是我们数据集中的唯一特征。如果条件成立,我们就沿着左箭头继续向下进入下一层的框。否则,我们沿着右箭头前往树的另一侧。我们继续检查每个框中的条件,直到到达一个没有条件并且没有箭头指向其他下层框的框。然后我们查看那里指定的值,并将其作为我们的预测结果。

对于我们在示例中使用的情感值(0.5),我们从第一个框开始,因为 0.5 > 0.259,然后我们因为相同的原因继续向右走到第二个框,接着我们再向右走到第三个框,因为 0.5 > 0.263。最后,我们到达第四个框,它没有任何条件需要检查,我们得到了预测结果:情感极性为 0.5 的文章大约会有 3,979 次分享。

如果你在家里创建这个决策树,你会发现一些框是有阴影或颜色的。这个阴影是自动生成的,应用的阴影程度与决策树预测的值成正比。例如,你可以看到在图 6-4 中有一个框表示预测为 57,100 股,它有最深的阴影。预测较低股数的框会有较浅的阴影或根本没有阴影。这个自动阴影的目的是为了突出显示特别高的预测值。

你可以在高级机器学习教科书中找到关于 sklearn 如何创建决策树的详细信息,尤其是在图 6-4 中。对于大多数标准的商业用例来说,优化决策树的细节和数学公式并不像写几行简单的 Python 代码来创建决策树并读取其图表那样重要。

图 6-4 中的决策树只需要几行代码就能生成,并且无需任何特别的培训就能进行解释。这意味着决策树非常适合商业应用。你可以快速生成一棵决策树并展示给客户或公司领导,并且无需深入讲解任何数学、计算机科学或其他复杂的话题就能进行解释。因此,数据科学家常常说决策树是可解释的模型,与神经网络等其他模型相比,后者较为不透明,且难以快速理解或解释。决策树可以自然地、迅速地成为任何演示或报告中的一部分,既能提供视觉吸引力,也能帮助他人理解数据集或预测问题。这些是决策树在商业应用中的重要优势。另一方面,决策树的准确性通常低于像随机森林这样的更复杂的方法(参见下一节)。

就像线性回归和 k-NN 一样,决策树使用数据的某个特征(在这个例子中是情感)来对目标(在这个例子中是股票)进行预测。不同之处在于,决策树不依赖于变量之间通过一条直线相互关联的假设(线性回归的假设)或变量在点周围的小邻域中相互关联的假设(k-NN 的假设)。相反,决策树是在假设图 6-4 中显示的分支结构是世界的适当模型的基础上构建的。

随机森林

清单 6-1 的第四部分使用随机森林进行预测。随机森林是一种集成方法。集成方法得名于它们由许多简单方法组成。正如你从名字中可以推测的,随机森林由许多简单的决策树组成。每次使用随机森林回归器进行预测时,sklearn 代码都会创建许多决策树回归器。每个单独的决策树回归器都是用不同的训练数据子集和不同的训练特征子集创建的。最终的随机森林预测是由每棵单独的决策树所做预测的均值。

在图 6-3 的背景下,随机森林学习一个复杂的函数:它由多个随机选择的决策树所学习的多个函数的均值组成。尽管如此,因为随机森林学习的是一个将特征映射到目标变量的函数,它仍然是一种标准的监督学习方法,就像线性回归、k-NN 和其他方法一样。

随机森林变得流行的原因在于其代码相对容易编写,而且通常比决策树或线性回归具有更高的准确性。这些是它们的主要优势。另一方面,虽然我们可以绘制出一个容易解释的决策树表示,如图 6-4,但随机森林通常由数百棵独特的决策树组成,这些树被平均在一起,而要以人类能够理解的方式绘制随机森林的表示并不容易。选择随机森林作为监督学习方法可能会提高准确性,但代价是可解释性和可说明性。每种监督学习方法都有优缺点,选择适合您情况的权衡是任何希望在监督学习中取得成功的数据科学家的关键。

神经网络

神经网络在近年来变得极为流行,因为我们的计算机硬件已经发展到能够处理它们的计算复杂性。神经网络的复杂性也使它们难以简洁地描述,除非说我们可以用它们进行监督学习。我们可以从展示一个特定神经网络的示意图开始(图 6-5)。

图 6-5:神经网络示意图

该图表示的是神经网络学习到的函数。在这张图中,您可以看到左侧一列 13 个圆圈,称为节点。这 13 个节点统称为神经网络的输入层。输入层的每个节点代表训练数据的一个特征。最右侧的单个节点表示神经网络对目标变量的最终预测。左侧和右侧之间的所有线条和节点表示一个复杂的学习函数,它将特征输入映射到最终的目标预测。

例如,您可以看到最左侧列中的最上方节点(标记为A)有一条箭头指向另一个节点(标记为B),箭头旁边写着数字 4.52768。这个数字是一个权重,我们应该将这个权重与节点 A 对应的特征值相乘。然后将这个乘积的结果加到对应于节点 B 的运行总和中。您可以看到节点 B 有 13 条箭头指向它,每条箭头对应输入层中的一个节点。每个特征将乘以一个不同的权重,特征值和权重的乘积将加到节点 B 的运行总和中。然后,数字–0.14254 将被加到结果中;这个数字是在一个蓝色节点(里面有数字 1)和节点 B 之间的箭头上绘制的。(这个蓝色节点也叫做偏置节点。)

在完成所有的乘法和加法之后,我们将得到节点 B 的累计总和,并对其应用一个叫做激活函数的新函数。激活函数有很多种,其中之一就是你在第五章遇到的逻辑函数。应用激活函数之后,我们将得到节点 B 的最终数值。我们才刚刚开始计算神经网络的学习函数过程。你可以看到节点 B 有四个箭头从它发出,每个箭头指向更右边的其他节点。对于这些箭头,我们必须遵循相同的步骤,即将权重乘以节点值,添加到每个节点的累计总和中,并应用激活函数。完成所有节点和图中所有箭头的计算后,我们将得到最右边节点的最终值:这将是我们对目标值的预测。

神经网络的设计方式使得这个过程,包括重复的乘法和加法及激活函数,最终会给出一个高度准确的预测。神经网络的复杂性可能是一个挑战,但它也正是使得神经网络能够准确地建模我们复杂的非线性世界的原因。

这些网络被称为神经网络,因为图 6-5 中的节点和箭头类似于大脑中的神经元和突触。这种相似性主要是表面的。你可以用一种不看起来像大脑的方式描绘神经网络,或者你也可以像描绘线性回归那样,用一种看起来像大脑的方式来写下其他方法。

要真正掌握神经网络,你需要学习更多的内容。神经网络的一些有趣进展来自于实验不同的节点结构或架构。例如,深度神经网络在最左边的输入节点和最右边的输出节点之间有许多层。卷积神经网络向网络结构中添加了一种额外的层,执行一种叫做卷积的特殊操作。递归神经网络允许连接在多个方向上流动,而不仅仅是从左到右。

研究人员已经发现神经网络在计算机视觉(如识别狗、猫、车或人)、语言处理(如机器翻译和语音识别)等领域的显著应用。另一方面,神经网络难以解释、难以理解,并且难以正确训练,有时还需要专门的硬件。尽管神经网络非常强大,这些缺点有时会使它们在商业应用中变得不那么吸引人。

测量预测准确性

无论我们选择哪种监督学习模型,在拟合之后,我们都需要衡量它的预测准确性。以下是我们在预测文章分享量的场景中如何操作:

allprediction=regressor.predict(np.array([allsentiment]).reshape(-1,1))
predictionerror=abs(allprediction-allsentiment)
print(np.mean(predictionerror))

这段简单的代码计算了平均绝对误差(MAE),正如我们之前所做的那样。在第一行,我们使用回归模型的predict()方法预测数据集中每篇文章的分享数。(记住,这个regressor是我们在本章开始时创建的线性回归模型。如果你愿意,你可以将regressor替换为rfregressornnregressor,以分别衡量我们的随机森林或神经网络的准确性。)在第二行,我们计算这些预测的预测误差:这只是预测值和实际值之间差异的绝对值。第三行计算的预测误差的平均值是我们特定监督学习方法表现如何的一个衡量标准,其中 0 是最佳值,较高的值则表示较差。我们可以使用这个过程来计算许多监督学习算法的预测准确性,然后选择导致最高准确度(最低平均绝对误差)的算法作为我们场景下的最佳方法。

这种方法唯一的问题是,它并不完全像一个真正的预测场景。在现实生活中,我们必须为那些不在训练数据集中的文章做出预测——这些是我们的回归模型在训练过程中从未见过的文章。相比之下,我们使用了 2013 年和 2014 年的一组文章数据,拟合回归模型到整个数据集,然后根据同一个 2013-14 数据集来评估我们的准确性,因为这个数据集用于拟合我们的回归模型。由于我们是根据用于拟合回归模型的相同数据来评估准确性的,因此我们所做的并不是真正的预测。这是事后推断——事后说明发生了什么,而不是事前预测。当我们进行事后推断时,我们可能会犯上过拟合的错误,这是我们在第二章中已经遇到的可怕陷阱。

为了避免事后推断和过拟合的问题,我们可以采用在第二章中使用过的相同方法:将数据集分为两个互不重叠的子集,一个训练集和一个测试集。我们使用训练集来训练数据,或者换句话说,允许我们的监督学习模型学习其学习的函数。在仅使用训练数据集训练数据后,我们使用测试数据对其进行测试。测试数据,因为它在训练过程中没有被使用,所以“仿佛”来自未来,因为我们的回归模型没有使用它进行学习,即使它实际上来自过去。

sklearn 包提供了一个方便的函数,我们可以用它来将数据拆分为训练集和测试集:

from sklearn.model_selection import train_test_split
x=np.array([allsentiment]).reshape(-1,1)
y=np.array(allshares)
trainingx,testx,trainingy,testy=train_test_split(x,y,random_state=1)

这段代码的四个输出是trainingxtrainingy——我们的训练数据的xy分量——以及testxtesty——我们的测试数据的xy分量。让我们检查一下这些输出的长度:

>>> **print(len(trainingx))**
29733
>>> **print(len(trainingy))**
29733
>>> **print(len(testx))**
9911
>>> **print(len(testy))**
9911

你可以看到,我们的训练数据包括trainingx(训练样本的情感得分)和trainingy(训练样本的市场份额统计)。这两个训练数据集都有 29,733 个观测值,占数据的 75%。测试数据集(testxtesty)有 9,911 个观测值,占剩余的 25%。这种划分方式与我们在第二章中采用的方法相同:用大部分数据训练模型,用较小的一部分数据测试模型。

我们在这里进行的训练/测试数据划分与第二章的不同之处在于,在第二章中,我们使用较早的数据(数据集的前几年)作为训练数据,而使用较晚的数据(数据集的后几年)作为测试数据。这里,我们没有按照时间顺序划分训练和测试数据。相反,我们使用的train_test_split()函数进行的是随机划分:随机选择训练集和测试集,而不是按时间顺序从早期和晚期数据中进行选择。这是一个重要的区分点:对于时间序列数据(按固定、顺序的时间间隔记录的数据),我们根据早期和晚期数据的划分来选择训练集和测试集;但对于其他所有数据集,我们都是随机选择训练集和测试集。

接下来,我们需要使用这些训练集来训练模型,并使用这些测试集来计算预测误差:

rfregressor = RandomForestRegressor(random_state=1)
rfregressor.fit(trainingx, trainingy)
predicted = rfregressor.predict(testx)
predictionerror = abs(predicted-testy)

你可以看到,在这个代码片段中,我们只使用训练数据来拟合回归器。然后,我们仅使用测试数据来计算预测误差。尽管我们所有的数据来自过去,但通过对未包含在训练中的数据进行预测,我们确保我们的过程更像是一个真实的预测过程,而不是后验预测。

我们可以通过运行print(np.mean(predictionerror))来查看测试集上的误差。你会看到,当使用我们的随机森林回归器时,测试集上的平均预测误差约为 3,816。

我们也可以对其他回归器进行相同的操作。例如,以下是我们检查 k-NN 回归器预测误差的方法:

knnregressor = KNeighborsRegressor(n_neighbors=15)
knnregressor.fit(trainingx, trainingy)
predicted = knnregressor.predict(testx)
predictionerror = abs(predicted-testy)

同样,我们可以使用print(np.mean(predictionerror))来找出这种方法是否比我们其他的监督学习方法表现更好。当我们这样做时,我们发现 k-NN 回归器在测试集上的平均预测误差约为 3,292。在这种情况下,k-NN 的表现优于随机森林,因为根据测试集上的预测误差来看,k-NN 的表现更好。当我们想为特定场景选择最好的监督学习方法时,最简单的方法是选择在测试集上预测误差最小的方法。

使用多变量模型

到目前为止,在本章中,我们只处理了单变量的监督学习,这意味着我们仅使用一个特征(情感)来预测分享数量。一旦你掌握了单变量监督学习,跳到多变量监督学习,即使用多个特征来预测一个目标,就变得非常简单。我们需要做的就是在我们的x变量中指定更多的特征,如下所示:

x=news[[' global_sentiment_polarity',' n_unique_tokens',' n_non_stop_words']]
y=np.array(allshares)
trainingx,testx,trainingy,testy=train_test_split(x,y,random_state=1)
from sklearn.ensemble import RandomForestRegressor
rfregressor = RandomForestRegressor(random_state=1)
rfregressor.fit(trainingx, trainingy)
predicted = rfregressor.predict(testx)
predictionerror = abs(predicted-testy)

在这里,我们指定一个x变量,它不仅包含文章的情感特征,还包含我们数据集中其他列中的两个特征。之后,过程与我们之前遵循的相同:将数据拆分为训练集和测试集,使用训练集创建并拟合回归模型,并计算测试集上的预测误差。当我们现在运行print(np.mean(predictionerror))时,我们看到我们的多变量模型的平均预测误差大约为 3,474,表明我们的多变量随机森林模型在测试集上比我们的单变量随机森林模型表现更好。

使用分类代替回归

到目前为止,本章介绍了多种方法来预测分享数量,给定文章的不同特征。shares变量可以取从 0 到无穷大的任何整数值。对于这样的数据(连续的数值变量),使用回归来预测它将取什么值是合适的。我们使用了线性回归、k-NN 回归、决策树回归、随机森林回归和神经网络回归:五种监督学习方法,所有这些方法都用于预测可以取广泛值的目标。

与其进行预测和回归,我们可能更希望进行分类,如同我们在第五章所做的那样。在我们的业务场景中,我们可能并不关心预测准确的分享数量。相反,我们可能只关心一篇文章是否会达到一个高于中位数的分享量。决定某物是否高于或低于中位数是一个分类场景,因为它是对一个只有两个可能答案的问题(真/假)进行决策。

我们可以创建一个变量,帮助我们进行分类,如下所示:

themedian=np.median(news[' shares'])
news['abovemedianshares']=1*(news[' shares']>themedian)

在这里,我们创建一个themedian变量,表示我们数据集中分享数量的中位数值。然后,我们在news数据集中添加一个名为abovemedianshares的新列。当文章的分享量高于中位数时,这一列的值为 1,否则为 0。这个新的测量值来源于一个数值测量(分享数量),但我们可以将其视为一个分类测量:它表达了一个真/假的命题,说明一篇文章是否属于高分享类别。由于我们的业务目标是发布高分享文章而不是低分享文章,能够准确地将新文章分类为可能的高分享文章或低分享文章,对我们来说是非常有用的。

要执行分类而非回归,我们需要更改我们的监督学习代码。但幸运的是,我们所需的更改非常小。在下面的代码片段中,我们使用分类器而不是回归器来处理新的类别目标变量:

x=news[[' global_sentiment_polarity',' n_unique_tokens',' n_non_stop_words']]
y=np.array(news['abovemedianshares'])
from sklearn.neighbors import **KNeighborsClassifier**
**knnclassifier** = **KNeighborsClassifier**(n_neighbors=15)
trainingx,testx,trainingy,testy=train_test_split(x,y,random_state=1)
**knnclassifier**.fit(trainingx, trainingy)
predicted = **knnclassifier**.predict(testx)

你可以看到我们之前进行的回归和这里进行的分类之间的区别其实非常小。唯一的变化部分是加粗的。特别是,我们不再导入KNeighborsRegressor模块,而是导入KNeighborsClassifier模块。两个模块都使用 k-NN 算法,但一个是为回归设计的,另一个是为分类设计的。我们将变量命名为knnclassifier,而不是knnregressor,但除此之外,监督学习的过程完全一样:导入监督学习模块,拆分数据集为训练集和测试集,将模型拟合到训练数据集,最后使用拟合后的模型进行测试集的预测。

你应该记得在第五章中,我们通常在分类场景中与回归场景中测量准确度的方式不同。以下代码片段创建了一个混淆矩阵,就像我们在第五章中做的那样:

from sklearn.metrics import confusion_matrix
print(confusion_matrix(testy,predicted))

记住,这段代码的输出是一个混淆矩阵,展示了我们测试集中的真阳性、真阴性、假阳性和假阴性的数量。混淆矩阵如下所示:

[[2703 2280]
 [2370 2558]]

记住,每个混淆矩阵都有以下结构:

[[`true positives `      `false positives`]
[`false negatives`     `true negatives`]]

因此,当我们查看混淆矩阵时,我们发现我们的模型做出了 2,703 个真阳性分类:我们的模型预测了 2,703 篇文章的分享数在中位数以上,而这些文章的分享数确实在中位数以上。我们有 2,280 个假阳性:预测分享数在中位数以上的文章,实际分享数在中位数以下。我们有 2,370 个假阴性:预测分享数在中位数以下的文章,实际分享数在中位数以上。最后,我们有 2,558 个真阴性:预测分享数在中位数以下的文章,实际分享数也在中位数以下。

我们可以按如下方式计算我们的精准度和召回率:

from sklearn.metrics import precision_score
from sklearn.metrics import recall_score

precision = precision_score(testy,predicted)
recall = recall_score(testy,predicted)

你会看到我们的精准度大约为 0.53,召回率大约为 0.52。这些数值并不是非常鼓舞人心;精准度和召回率应该尽可能接近 1。这些数值较低的原因之一是我们在尝试做出困难的预测。无论你的算法多么强大,预测一篇文章将获得多少分享本质上是非常困难的。

需要记住的是,尽管监督学习是一套基于巧妙思想并在强大硬件上执行的复杂方法,但它并不是魔法。宇宙中有很多事情本质上是难以预测的,即便使用最好的方法也是如此。但是,仅仅因为完美的预测可能不可能实现,并不意味着我们应该放弃做预测。在这种情况下,一个即使只稍微有帮助的模型总比什么都不做要好。

总结

在本章中,我们探讨了监督学习。我们从一个与预测相关的商业场景开始,回顾了线性回归及其不足之处。接着,我们讨论了监督学习的一般概念,并介绍了几种其他的监督学习方法。随后,我们进一步讨论了监督学习的一些细节,包括多变量监督学习和分类。

在下一章中,我们将讨论监督学习的“亲弟弟”——不太受欢迎的无监督学习。无监督学习为我们提供了强大的方法,可以探索和理解数据中隐藏的关系,而不需要使用目标变量进行监督。监督学习和无监督学习一起构成了机器学习的核心内容,它是数据科学中最基本的技能之一。

第七章:无监督学习

本章将从介绍无监督学习的概念开始,并将其与有监督学习进行比较。接着,我们将生成用于聚类的数据,聚类是与无监督学习最相关的任务。我们将首先聚焦于一种叫做 E-M 聚类的复杂方法。最后,我们将通过研究其他聚类方法与无监督学习其他部分的关系来完善本章内容。

无监督学习与有监督学习的对比

理解无监督学习最简单的方法是与有监督学习进行比较。记住在第六章中提到的,有监督学习过程在图 7-1 中有所展示。

图 7-1:所有有监督学习方法的概念图

图 7-1 所提到的目标是我们数据集中我们希望预测的特殊变量。特征是我们数据集中用于预测目标的变量。学习到的函数是将特征映射到目标的函数。我们可以通过将我们的预测与实际目标值进行比较来检查学习到的函数的准确性。如果预测值与目标值相差很大,我们就知道应该尝试找到更好的学习函数。就像目标值通过告诉我们函数的准确度来监督我们的过程,帮助我们朝着尽可能高的准确度迈进。

无监督学习没有监督,因为它没有目标变量。图 7-2 展示了无监督学习的过程。

图 7-2:无监督学习过程的概念图

无监督学习并不像有监督学习那样试图将特征映射到目标变量,而是关注于创建特征本身的模型;它通过发现观察值与特征中的自然群体之间的关系来实现这一点。一般来说,它是一种探索特征的方式。找出我们数据中观察值之间的关系可以帮助我们更好地理解数据,它还可以帮助我们发现异常情况,并使数据集变得不那么繁琐。

图 7-2 中的箭头将特征与自身连接。这个箭头表示我们正在寻找特征之间的关系,例如它们自然形成的群体;它并不表示一个循环或重复过程。这可能听起来有些抽象,因此我们通过一个具体的例子来使其更加清晰。

生成和探索数据

我们先来看一些数据。不同于以往章节中读取现有数据的方法,这次我们将通过使用 Python 的随机数生成能力来生成新的数据。随机生成的数据比现实生活中的数据更简单、更易于处理;这有助于我们讨论无监督学习的复杂性。

更重要的是,无监督学习的一个主要目标是理解数据子集之间的关系。通过我们自己生成数据,意味着我们可以判断我们的无监督学习方法是否发现了数据子集之间正确的关系,因为我们会准确知道这些子集来自哪里以及它们如何关联。

掷骰子

我们将从生成一些简单的示例数据开始,进行几次骰子掷骰:

from random import choices,seed
numberofrolls=1800
seed(9)
dice1=choices([1,2,3,4,5,6], k=numberofrolls)
dice2=choices([1,2,3,4,5,6], k=numberofrolls)

在这个代码段中,我们从random模块导入了choices()seed()函数。这些是我们用来生成随机数的函数。我们定义了一个名为numberofrolls的变量,它存储了值1800,即我们希望 Python 为我们生成的模拟掷骰子次数。我们调用了seed()函数,虽然这个步骤并非必须,但它会确保你得到和书中展示的相同的结果。

接下来,我们使用choices()函数创建两个列表,dice1dice2。我们传递给该函数两个参数:列表[1,2,3,4,5,6],告诉choices()函数我们希望从 1 到 6 的整数中随机选择,以及k=numberofrolls,告诉choices()函数我们希望它进行 1,800 次随机选择。dice1列表表示 1,800 次掷骰子的结果,而dice2变量同样表示第二个骰子的 1,800 次掷骰子结果。

你可以如下查看dice1的前 10 个元素:

print(dice1[0:10])

你应该会看到以下输出(如果你在前面的代码段中运行了seed(9)):

[3, 3, 1, 6, 1, 4, 6, 1, 4, 4]

这个列表看起来像是公平骰子 10 次掷骰子的记录。在生成了两颗骰子的 1,800 次随机掷骰子结果后,我们可以找出这 1,800 次掷骰子的和:

dicesum=[dice1[n]+dice2[n] for n in range(numberofrolls)]

这里我们通过列表推导式创建了dicesum变量。dicesum的第一个元素是dice1的第一个元素和dice2的第一个元素之和,dicesum的第二个元素是dice1的第二个元素和dice2的第二个元素之和,依此类推。所有这些代码模拟了一个常见的场景:一起掷两个骰子并查看掷出后的点数和。但不同的是,我们不是自己掷骰子,而是让 Python 为我们模拟所有的 1,800 次掷骰子。

一旦我们得到了掷骰子的和,我们可以绘制所有结果的直方图:

from matplotlib import pyplot as plt
import numpy as np
fig, ax = plt.subplots(figsize =(10, 7))
ax.hist(dicesum,bins=[2,3,4,5,6,7,8,9,10,11,12,13],align='left')
plt.show()

图 7-3 展示了结果。

图 7-3:1,800 次模拟掷骰子的结果

这是一个直方图,类似于我们在第一章和第三章中看到的那些。每个垂直条形表示某一特定骰子结果的频率。例如,最左边的条形表示,在 1,800 次掷骰子中,大约 50 次的结果和为 2。中间最高的条形表示,大约 300 次的掷骰子的和为 7。

像这样的直方图向我们展示了数据的分布——不同观察值出现的相对频率。我们的分布显示,像 2 和 12 这样的极值是相对不常见的,而像 7 这样的中间值则更为常见。我们还可以通过概率来解读分布:如果我们掷两个公平的骰子,7 是一个非常可能的结果,而 2 和 12 的结果则不太可能。通过观察每个直方图条形的高度,我们可以知道每个结果的大致可能性。

我们可以看到这个直方图呈现出类似钟形的形状。我们掷骰子的次数越多,直方图就越接近钟形。对于大量的骰子掷骰次数,结果的直方图会被一种特殊的分布——正态分布(或称高斯分布)所近似。你在第三章中也遇到过这种分布,尽管在那一章中我们称其为它的另一个名字:钟形曲线。正态分布是我们在测量某些事物的相对频率时常见的模式,例如第三章中的均值差异,或者此处的骰子掷骰和结果的和。

每个钟形曲线可以通过两个数字来完全描述:一个是均值,表示钟形曲线的中心和最高点;另一个是方差,表示钟形曲线的扩展范围。方差的平方根就是标准差,它是钟形曲线扩展程度的另一个度量。我们可以通过以下简单的函数来计算骰子掷骰数据的均值和标准差:

def getcenter(allpoints):
    center=np.mean(allpoints)
    stdev=np.sqrt(np.cov(allpoints))
    return(center,stdev)

print(getcenter(dicesum))

这个函数接受一个观察值列表作为输入。它使用np.mean()函数计算该列表的均值,并将其存储在名为center的变量中。然后,它使用np.cov()方法。这个方法的名字cov协方差(covariance)的缩写,协方差是衡量数据变化的一种方式。当我们计算两个不同观察值列表的协方差时,它告诉我们这两个数据集是如何一起变化的。而当我们计算单一观察值列表的协方差时,它就是方差,方差的平方根就是标准差。

如果我们运行之前的代码段,我们应该能够得到骰子掷骰的均值和标准差:

(6.9511111111111115, 2.468219092930105)

这个输出告诉我们,观察到的骰子掷骰的均值大约是 7,标准差大约是 2.5。现在我们知道了这些数字,我们可以将钟形曲线叠加在直方图上,像下面这样绘制:

fig, ax = plt.subplots(figsize =(10, 7))
ax.hist(dicesum,bins=range(2,14),align='left')
import scipy.stats as stats
import math
mu=7
sigma=2.5
x = np.linspace(mu - 2*sigma, mu + 2*sigma, 100)*1
plt.plot(x, stats.norm.pdf(x, mu, sigma)*numberofrolls,linewidth=5)
plt.show()

图 7-4 显示了我们的输出。

图 7-4:钟形曲线叠加在骰子掷骰的直方图上

你可以看到,钟形曲线是我们在直方图上绘制的连续曲线。它的值代表相对概率:由于它在 7 处的值较高,在 2 和 12 处的值较低,我们可以解读为掷出 7 的可能性比掷出 2 或 12 的可能性更大。我们可以看到,这些理论概率与我们观察到的骰子掷出的结果非常接近,因为钟形曲线的高度接近每个直方图条形的高度。我们可以轻松检查钟形曲线预测的掷骰次数,方法如下:

stats.norm.pdf(2, mu, sigma)*numberofrolls
# output: 38.8734958894954

stats.norm.pdf(7, mu, sigma)*numberofrolls
# output: 287.23844188903155

stats.norm.pdf(12, mu, sigma)*numberofrolls
# output: 38.8734958894954

在这里,我们使用stats.norm.pdf()函数来计算 2、7 和 12 的预期掷骰次数。这个函数来自stats模块,函数名norm.pdf正态概率密度函数的缩写,这也是我们熟悉的钟形曲线的另一种名称。这个代码片段使用stats.norm.pdf()来计算在x = 2、x = 7 和x = 12 时钟形曲线的高度(换句话说,就是根据我们之前计算的均值和标准差,掷出 2、掷出 7 和掷出 12 的可能性)。然后,它将这些可能性乘以我们希望掷骰的次数(在本例中为 1,800),以得到 2、7 和 12 的预期掷骰总次数。

使用另一种类型的骰子

我们已经计算了掷两个 6 面骰子的假设情境的概率,因为掷骰子为我们提供了一种简单、熟悉的方式来思考概率和分布等重要数据科学概念。但当然,这并不是我们可以分析的唯一数据类型,甚至不是我们可以分析的唯一类型的骰子掷骰。

想象一下,掷一对非标准的 12 面骰子,这些骰子面的标记为 4、5、6、...、14、15。当这对骰子一起掷出时,它们的和可能是 8 到 30 之间的任何整数。我们可以再次随机生成 1,800 次假设掷骰,并通过使用之前相同类型的代码,稍作修改,绘制这些掷骰的直方图:

seed(913)
dice1=choices([4,5,6,7,8,9,10,11,12,13,14,15], k=numberofrolls)
dice2=choices([4,5,6,7,8,9,10,11,12,13,14,15], k=numberofrolls)
dicesum12=[dice1[n]+dice2[n] for n in range(numberofrolls)]
fig, ax = plt.subplots(figsize =(10, 7))
ax.hist(dicesum12,bins=range(8,32),align='left')
mu=np.mean(dicesum12)
sigma=np.std(dicesum12)
x = np.linspace(mu - 2*sigma, mu + 2*sigma, 100)*1
plt.plot(x, stats.norm.pdf(x, mu, sigma)*numberofrolls,linewidth=5)
plt.show()

图 7-5 显示了结果的直方图。

图 7-5:使用一对定制的 12 面骰子掷骰结果的钟形曲线和直方图

锥形曲线大致与图 7-4 中的相同,但在这种情况下,19 是最可能的结果,而不是 7,范围从 8 到 30,而不是从 2 到 12。所以我们再次得到一个正态分布或钟形曲线,但具有不同的均值和标准差。

我们可以将两个直方图(图 7-4 和图 7-5)一起绘制,方法如下:

dicesumboth=dicesum+dicesum12
fig, ax = plt.subplots(figsize =(10, 7))
ax.hist(dicesumboth,bins=range(2,32),align='left')
import scipy.stats as stats
import math
mu=np.mean(dicesum12)
sigma=np.std(dicesum12)
x = np.linspace(mu - 2*sigma, mu + 2*sigma, 100)*1
plt.plot(x, stats.norm.pdf(x, mu, sigma)*numberofrolls,linewidth=5)
mu=np.mean(dicesum)
sigma=np.std(dicesum)
x = np.linspace(mu - 2*sigma, mu + 2*sigma, 100)*1
plt.plot(x, stats.norm.pdf(x, mu, sigma)*numberofrolls,linewidth=5)
plt.show()

图 7-6 显示了结果。

图 7-6:显示 6 面骰子和 12 面骰子配对结果的合并直方图

这在技术上是一个直方图,尽管我们知道它是通过结合两个独立直方图的数据生成的。记住,对于 6 面骰子对,7 是最常见的结果,而对于 12 面骰子对,19 是最常见的结果。我们可以在直方图中看到,7 处有一个局部峰值,19 处有另一个局部峰值。这两个局部峰值称为 模态。由于我们有两个模态,这就是我们所说的 双峰 直方图。

当你查看图 7-6 时,它应该帮助你开始理解图 7-2 中的概念图试图说明的内容。我们并不像在前面的监督学习章节中那样对掷骰子结果进行预测或分类。相反,我们在构建简单的理论模型——在这个例子中,就是我们的钟形曲线——来表达我们对数据的理解以及观察结果之间的关系。接下来的一节中,我们将使用这些钟形曲线模型来推理数据,并更好地理解它。

聚类观察的起源

假设我们从所有绘制的投骰子数据中随机选择一次掷骰子结果,如图 7-6 所示:

seed(494)
randomselection=choices(dicesumboth, k=1)
print(randomselection)

你应该会看到输出 [12],表示我们随机选择了一个数据点,其中我们掷出的和为 12。在没有任何其他信息的情况下,假设我要求你做出一个有根据的猜测,哪一对骰子最可能导致这个结果 12。可能是任何一对骰子:6 面骰子可能是两颗 6,或者 12 面骰子可能是其他组合,例如 8 和 4。那么,如何做出一个有根据的猜测,判断哪一对骰子最有可能是这个观察结果的来源呢?

你可能已经有强烈的直觉,认为 12 不太可能是由 6 面骰子掷出的。毕竟,12 是 6 面骰子中最不可能出现的结果(与 2 一起并列),但 12 更接近图 7-5 的中间位置,表明它是 12 面骰子更常见的结果。

你的有根据的猜测不需要仅仅依赖直觉。我们可以通过观察图 7-4 和图 7-5 中直方图条形的高度来看到,当我们掷骰子 1,800 次时,6 面骰子得到 12 的次数大约为 50 次,而 12 面骰子得到 12 的次数超过 60 次。从理论角度来看,图 7-6 中的钟形曲线高度使我们能够直接比较每对骰子每个结果的相对概率,因为我们对每对骰子投掷的次数是相同的。

我们可以用同样的推理方法来思考其他点数的掷骰结果。例如,我们知道 6 面骰子掷出 8 的概率更大,不仅仅是因为直觉,而且因为在图 7-6 中,左侧的钟形曲线在 x 值为 8 时高于右侧的钟形曲线。如果我们面前没有图 7-6,我们可以按照以下方法计算每个钟形曲线的高度:

stats.norm.pdf(8, np.mean(dicesum), np.std(dicesum))*numberofrolls
# output: 265.87855493973007

stats.norm.pdf(8, np.mean(dicesum12), np.std(dicesum12))*numberofrolls
# output: 11.2892030357587252

在这里我们看到,6 面骰子更可能是观察到的 8 点投掷的来源:它在 1,800 次 6 面骰子的投掷中大约会出现 266 次,而我们预计在 1,800 次 12 面骰子的投掷中,8 点只会出现大约 11 或 12 次。我们可以完全按照相同的过程来判断 12 面骰子对更可能是观察到的 12 点投掷的来源:

stats.norm.pdf(12, np.mean(dicesum), np.std(dicesum))*numberofrolls
# results in 35.87586208537935

stats.norm.pdf(12, np.mean(dicesum12), np.std(dicesum12))*numberofrolls
# results in 51.42993240324318

如果我们使用这种比较钟形曲线高度的方法,那么对于任何观察到的骰子投掷,我们都可以判断出最可能是哪个骰子对的来源。

现在我们可以对任何骰子投掷的来源做出有根据的猜测,我们已经准备好处理聚类,这是无监督学习中最重要、最常见的任务之一。聚类的目标是回答我们之前考虑过的一个全局版本的问题:哪一对骰子是我们数据中每个观测值的来源?

聚类开始时的推理过程类似于我们上一节中的推理。但不同的是,这次我们不是推理单次骰子投掷,而是尝试确定哪一对骰子是我们数据中每个观测值的来源。这是一个简单的过程,我们可以按如下步骤进行:

  • 对于所有 2 点的投掷,骰子对 1 的钟形曲线高于骰子对 2 的曲线,因此,在不考虑其他因素的情况下,我们假设所有 2 点的投掷来自骰子对 1。

  • 对于所有 3 点的投掷,骰子对 1 的钟形曲线高于骰子对 2 的曲线,因此,在不考虑其他因素的情况下,我们假设所有 3 点的投掷来自骰子对 1。

  • . . .

  • 对于所有 12 点的投掷,骰子对 2 的钟形曲线高于骰子对 1 的曲线,因此,在不考虑其他因素的情况下,我们假设所有 12 点的投掷来自骰子对 2。

  • . . .

  • 对于所有 30 点的投掷,骰子对 2 的钟形曲线高于骰子对 1 的曲线,因此,在不考虑其他因素的情况下,我们假设所有 30 点的投掷来自骰子对 2。

通过分别考虑 29 种可能的骰子投掷结果,我们可以对每个观测值的来源做出较好的猜测。我们也可以编写代码来完成这个过程:

from scipy.stats import multivariate_normal
def classify(allpts,allmns,allvar):
    vars=[]
    for n in range(len(allmns)):
        vars.append(multivariate_normal(mean=allmns[n], cov=allvar[n]))
    classification=[]
    for point in allpts:
        this_classification=-1
        this_pdf=0
        for n in range(len(allmns)):
 if vars[n].pdf(point)>this_pdf:
                this_pdf=vars[n].pdf(point)
                this_classification=n+1
        classification.append(this_classification)
    return classification

让我们来看一下classify()函数。它需要三个参数。第一个参数是allpts,表示我们数据中每个观测值的列表。函数需要的另外两个参数是allmnsallvar。这两个参数分别表示我们数据中每组(即每对骰子)的均值和方差。

该函数需要完成我们在查看图 7-6 时所做的工作,即找出每次投掷的骰子对的来源。我们考虑每个骰子对的钟形曲线,并假设对于某次特定的骰子投掷,具有更高值的钟形曲线就是它来自的骰子对。在我们的函数中, instead of visually looking at bell curves,我们需要计算钟形曲线的值并查看哪一个更高。这就是我们创建一个名为vars的列表的原因。这个列表最初为空,但我们随后使用multivariate_normal()函数将我们的钟形曲线添加到该列表中。

在我们收集了钟形曲线后,我们考虑数据中的每个点。如果在某个点,第一条钟形曲线比其他钟形曲线更高,我们就说这个点与第一个骰子对相关联。如果第二条钟形曲线在这个点最高,我们就说这个点属于第二个骰子对。如果我们有超过两个钟形曲线,我们可以比较所有钟形曲线,并根据哪个钟形曲线更高来分类每个点。我们找到最高的钟形曲线的方法与我们之前查看图 7-6 时一样,只不过现在我们是通过代码而非眼睛来完成。每次我们分类一个点时,我们都会将它的骰子对编号附加到一个名为classification的列表中。当函数运行完成时,它将列表填充为我们数据中每个点的骰子对分类,并返回该列表作为最终值。

让我们试试我们新的classify()函数。首先,定义一些点、均值和方差:

allpoints = [2,8,12,15,25]
allmeans = [7, 19]
allvar = [np.cov(dicesum),np.cov(dicesum12)]

我们的allpoints列表是一个包含我们想要分类的假设骰子投掷结果的集合。我们的allmeans列表由两个数字组成:7,即我们期望从 6 面骰子对中得到的平均投掷结果;19,即我们期望从 12 面骰子对中得到的平均投掷结果。我们的allvar列表包含两个骰子对的相应方差。现在我们有了三个必要的参数,我们可以调用classify()函数:

print(classify(allpoints,allmeans,allvar))

我们看到以下输出:

[1, 1, 2, 2, 2]

这个列表告诉我们,allpoints列表中的前两个骰子投掷结果,2 和 8,更可能与 6 面骰子对相关联。allpoints列表中的其他骰子投掷结果——12、15 和 25——更可能与 12 面骰子对相关联。

我们刚刚做的事情是将一个包含非常不同的骰子投掷结果的列表进行分类,分为两个不同的组。你可以称之为分类或分组,但在机器学习的世界里,这叫做聚类。如果你查看图 7-6,你可以开始理解原因。来自 6 面骰子的投掷结果似乎聚集在它们最常见的值 7 周围,而来自 12 面骰子的投掷结果则聚集在它们最常见的值 19 周围。它们形成了小山脉般的观察结果或组,我们将称这些为聚类,无论它们的形状或大小如何。

在实际应用中,数据通常具有这种类型的聚类结构,其中少数几个子集(聚类)是显而易见的,每个子集中的大多数观测值都接近该子集的均值,而只有少数观测值位于子集之间或远离均值。通过对我们数据中存在的聚类形成结论,并将每个观测值分配到一个聚类中,我们完成了本章的主要任务——聚类的简单版本。

聚类在商业应用中的作用

骰子掷出的结果具有容易理解和推理的概率,但在商业中,并非很多情境都需要直接关注骰子掷出的结果。尽管如此,聚类在商业中被广泛使用,尤其是营销人员。

假设图 7-6 不是骰子掷出的记录,而是你所经营的零售店的交易金额记录。围绕 7 的低聚类表明,一组人群在你的商店消费大约 7 美元,而围绕 19 的高聚类则表明,另一组人群在你的商店消费大约 19 美元。你可以说,你有一群低消费客户和一群高消费客户。

现在你知道你有两个截然不同的客户群体,并且知道他们是谁,你可以基于这一信息采取行动。例如,你可能不再对所有客户使用相同的广告策略,而是根据每个群体的不同进行有针对性的广告或营销。也许,强调优惠和实用性的广告对低消费群体有吸引力,而强调高端质量和社会声望的广告则更吸引高消费群体。一旦你清楚了解了这两个客户群体之间的边界、每个群体的大小以及他们最常见的消费习惯,你就有了制定复杂的双管齐下广告策略所需的主要信息。

另一方面,在发现数据中的聚类之后,你可能希望去除这些聚类,而不是迎合它们。例如,你可能认为低消费的客户并非预算紧张,而只是对你的一些价格较高但有用的产品不够了解。你可能会专门为他们推出更具攻击性和信息性的广告,以鼓励所有客户都成为高消费群体。你的具体方法将取决于许多其他的商业细节、你的产品以及你的战略。聚类分析能够通过显示客户群体及其特征为你的战略决策提供重要的输入,但它无法从零开始提供清晰的商业策略。

我们可以设想,直方图中图 7-6 的 x 轴表示另一个变量,比如客户年龄,而不是交易金额。这样,我们的聚类分析就会告诉我们,来光顾我们业务的有两个不同的群体:年轻群体和年长群体。你可以对任何你测量到的与客户相关的数值变量进行聚类分析,从而可能发现一些有趣的客户群体。

企业营销人员在“数据科学”这个术语普及之前,甚至在今天的大部分聚类方法发明之前,就已经在将客户分组。数据科学和聚类时代之前,营销人员通常称这种将客户分组的做法为客户细分

实际上,营销人员常常以非科学的方式进行细分,他们并不是通过从数据中发现聚类和边界,而是通过猜测或直觉选择一些圆整的数字。例如,一位电视制片人可能会委托调查观众,并以一种看似自然的方式分析数据,先查看所有 30 岁以下观众的结果,再单独查看 30 岁及以上观众的结果。使用这个看似自然的圆整数字 30 可能提供一个潜在的边界,将年轻观众和年长观众区分开。然而,也许该制片人的节目 30 岁以上的观众极为稀少,所以单独分析这一组观众的反馈会分散注意力,反而忽略了 30 岁以下更大规模的观众群体。相反,简单的聚类分析可能会揭示出 18 岁左右的观众和 28 岁左右的观众各自有一个大的群体,这两个群体之间的边界在 23 岁。基于这个聚类分析来分析细分群体,而不是基于看似合理但最终误导性的“30 岁以下”和“30 岁以上”细分,将更有助于理解节目的观众及其观点。

细分早于聚类,但聚类是一种很好的细分方法,因为它使我们能够找到更准确、更有用的细分群体,并且能精确地划定它们之间的边界。在这种情况下,你可以说,聚类方法为我们提供了基于数据的客观洞察,而与之相对的是基于直觉或经验的圆整数字细分。由直觉转向客观、数据驱动的洞察,正是数据科学为商业带来的主要贡献之一。

到目前为止,我们仅讨论了单个变量的细分:单独分析掷骰结果、消费或年龄等。我们可以开始思考多个维度,而不仅仅是在单个变量上进行聚类和细分。例如,如果我们在美国经营零售公司,我们可能会发现,在西部有一群年轻的高消费人群;在东南部有一群年长的低消费人群;在北部有一群中年、适度高消费的人群。为了发现这一点,我们需要在多个维度上同时进行聚类。数据科学中的聚类方法具备这种能力,给它们提供了相较于传统细分方法的另一优势。

分析多个维度

在我们的掷骰数据中,每个观察值仅由一个数字组成:我们掷出的骰子正面朝上的数字之和。我们不记录骰子的温度或颜色,也不记录骰子的边长或宽度,或者除了每次掷骰的一个原始数字外的任何其他信息。我们的掷骰数据集是一维的。在这里,维度不一定指空间中的维度,而是指任何可以在低值和高值之间变化的测量。掷骰的结果可以有很大差异,从像 2 这样的低点到像 12(或更多,取决于我们使用的骰子)这样的高点,但我们只测量它们在一个度量标准上的高低:掷出骰子后正面朝上的数字之和。

在商业场景中,我们几乎总是关心多个维度。例如,在分析客户群体时,我们想要了解客户的年龄、位置、收入、性别、教育年限等尽可能多的信息,以便能够成功地进行市场营销。当我们处理多个维度时,一些事物会看起来不同。例如,我们在图 7-3 到 7-6 中看到的钟形曲线将增加一个额外的维度,就像在图 7-7 右侧所示的那样。

图 7-7:单变量钟形曲线(左)和双变量钟形曲线(右)

该图左侧显示的是单变量钟形曲线,之所以叫单变量,是因为它仅展示一个变量(x 轴)的相对概率。右侧显示的是双变量钟形曲线,它展示的是沿两个维度变化的相对概率:x 轴和 y 轴。我们可以想象,在图 7-7 右侧的图中,x 轴和 y 轴可能分别表示年龄和平均交易金额。

单变量高斯曲线的均值由一个数字表示,例如在图 7-7 的左侧,x = 0。双变量高斯曲线的均值由两个数字表示:由一个 x 坐标和一个 y 坐标组成的有序对,如 (0, 0)。维度的数量增加,但使用每个维度的均值来找到钟形曲线的最高点的思路是相同的。找到每个维度的均值将告诉我们钟形曲线的中心和最高点的位置,其他观察值通常会围绕这一点聚集。在单变量和双变量的情况下,我们可以将钟形曲线的高度解释为概率:钟形曲线较高的点对应于更可能的观察值。

从一维到二维的转变还会影响我们表达钟形曲线分布范围的方式。在一维中,我们使用方差(或标准差)作为一个数字,表示曲线的分布程度。在二维或更高维度中,我们使用矩阵,或一组数字的矩形数组,来表示钟形曲线的分布范围。我们使用的矩阵称为协方差矩阵,它记录了每个维度的分布程度,以及不同维度之间的共同变化程度。我们不需要关心协方差矩阵的细节;我们主要只需要通过np.cov()函数来计算它,并将其作为输入应用于我们的聚类方法。

当你在聚类分析中将维度从二维增加到三维或更多时,这一调整是直接的。我们将不再使用单变量或双变量的钟形曲线,而是使用多变量钟形曲线。在三维空间中,均值将有三个坐标;在n维空间中,均值将有n个坐标。每次增加问题的维度时,协方差矩阵也会变得更大。但是,无论你有多少维度,钟形曲线的特征始终相同:它有一个均值,大多数观察值会接近该均值,并且有一个协方差度量,显示钟形曲线的分布范围。

在本章的其余部分,我们将看一个二维示例,该示例将展示聚类的概念和过程,同时仍能让我们绘制出简单、易于理解的图形。这个示例将展示聚类和无监督学习的所有基本特征,你可以将其应用于任意维度。

E-M 聚类

现在我们拥有了执行E-M 聚类所需的所有要素,这是一种强大的无监督学习方法,可以帮助我们智能地发现多维数据中的自然分组。这种技术也被称为高斯混合建模,因为它使用钟形曲线(高斯分布)来建模群体是如何相互融合的。无论你怎么称呼它,它都非常有用且相对直接。

我们将从查看我们想要进行聚类的新二维数据开始。我们可以按照以下方式从它的在线位置读取数据:

import ast
import requests
link = "https://bradfordtuckfield.com/emdata.txt"
f = requests.get(link)
allpoints = ast.literal_eval(f.text)

这个代码片段使用了两个模块:astrequestsrequests包允许 Python 从网站请求文件或数据集——在这个例子中,网站是存放聚类数据的地方。数据以 Python 列表的形式存储在文件中。Python 默认将.txt文件读取为字符串,但我们希望将数据读取为 Python 列表,而不是字符串。ast模块包含一个literal_eval()方法,使我们能够读取文件中的列表数据,否则它们会被当作字符串处理。我们将列表读取到一个名为allpoints的变量中。

现在我们已经将数据读取到 Python 中,可以绘制图形来看数据长什么样子:

allxs=[point[0] for point in allpoints]
allys=[point[1] for point in allpoints]
plt.plot(allxs, allys, 'x')
plt.axis('equal')
plt.show()

图 7-8 显示了结果。

图 7-8:我们新的二维数据的图示

你可能会注意到这些轴没有标签。这不是偶然的:我们将把这些数据作为一个无标签的示例来处理,然后讨论它如何应用于多种场景。你可以想象在这个示例中,轴可能有许多不同的标签:也许这些点代表城市,x 轴是人口增长百分比,y 轴是经济增长百分比。如果是这样,执行聚类将会识别出那些增长趋势最近相似的城市聚类。也许如果你是一个 CEO,正在考虑在哪里开设新分店,这个信息会很有用。但这些轴不一定代表城市的增长:它们可以代表任何东西,而我们的聚类算法将以相同的方式工作。

在图 7-8 中,一些其他的现象也立即显现出来。两个特别密集的观测聚类分别出现在图的顶部和右侧。在图的中心,另一个聚类似乎比其他两个更为稀疏。我们似乎有三个位置不同、大小和密度不同的聚类。

与其仅仅依靠肉眼进行聚类练习,我们不如使用一个强大的聚类算法:E-M 算法。E-M期望最大化(expectation-maximization)的缩写。我们可以用四个步骤来描述这个算法:

  1. 猜测:为每个聚类的均值和协方差做出猜测。

  2. 期望:根据最新的均值和协方差估计,按每个数据点最可能属于哪个聚类来对数据进行分类。(这被称为E,或期望,步骤,因为我们是根据每个点属于各个聚类的可能性进行分类。)

  3. 最大化:利用期望步骤获得的分类结果来计算每个聚类的均值和协方差的新估计值。(这被称为M,或最大化,步骤,因为我们找到的均值和方差最大化了数据匹配的概率。)

  4. 收敛:重复执行期望步骤和最大化步骤,直到达到停止条件。

如果这个算法看起来让你感到害怕,不要担心;你已经在本章的早期部分做了所有的困难部分。让我们依次通过每个步骤,以更好地理解它们。

猜测步骤

第一步是最简单的,因为我们可以对聚类的均值和协方差做出任何猜测。让我们做一些初步猜测:

#initial guesses
mean1=[-1,0]
mean2=[0.5,-1]
mean3=[0.5,0.5]

allmeans=[mean1,mean2,mean3]

cov1=[[1,0],[0,1]]
cov2=[[1,0],[0,1]]
cov3=[[1,0],[0,1]]

allvar=[cov1,cov2,cov3]

在这段代码中,我们首先对mean1mean2mean3进行猜测。这些猜测是二维点,应该是我们三个聚类的各自中心。然后,我们对每个聚类的协方差做出猜测。我们对协方差做出最简单的猜测:我们猜测一个特别简单的矩阵——单位矩阵作为每个聚类的协方差矩阵。(单位矩阵的细节现在不重要;我们之所以使用它,是因为它简单,并且作为初步猜测时往往足够有效。)我们可以绘制一个图来看看这些猜测是什么样的:

plt.plot(allxs, allys, 'x')
plt.plot(mean1[0],mean1[1],'r*', markersize=15)
plt.plot(mean2[0],mean2[1],'r*', markersize=15)
plt.plot(mean3[0],mean3[1],'r*', markersize=15)
plt.axis('equal')
plt.show()

图表类似于图 7-9。

图 7-9:我们的数据以及一些作为星星显示的聚类中心猜测

你可以再次看到绘制的点,星星代表我们对聚类中心的猜测。(如果你在家里绘制,星星会是红色的。)显然,我们的猜测并不是很好。特别是,我们的猜测没有一个位于图表顶部和右侧的两个密集聚类的中心,也没有一个接近主要点云的中心。在这种情况下,从不准确的猜测开始是好的,因为它能让我们看到期望最大化(E-M)聚类算法的强大:即使我们的初步猜测在猜测步骤中相当糟糕,它依然能够找到正确的聚类中心。

期望步骤

我们已经完成了算法的猜测步骤。在下一步中,我们需要根据我们认为每个点所属的聚类,对所有点进行分类。幸运的是,我们已经有了classify()函数来处理这个:

def classify(allpts,allmns,allvar):
    vars=[]
    for n in range(len(allmns)):
        vars.append(multivariate_normal(mean=allmns[n], cov=allvar[n]))
    classification=[]
    for point in allpts:
        this_classification=-1
        this_pdf=0
        for n in range(len(allmns)):
 if vars[n].pdf(point)>this_pdf:
                this_pdf=vars[n].pdf(point)
                this_classification=n+1
        classification.append(this_classification)
    return classification 

记住这个函数的作用。早些时候,我们使用它对骰子投掷结果进行分类。我们收集了一组骰子投掷的观察结果,通过比较两条钟形曲线的高度,找出每个骰子投掷结果最可能来自哪一对骰子。在这里,我们将使用这个函数来做一个类似的任务,但我们将使用新的未标记数据,而不是骰子投掷数据。对于我们新数据中的每个观察结果,这个函数通过比较与每个组相关联的钟形曲线的高度,来找出它最可能属于哪个组。我们将对我们的点、均值和方差调用这个函数:

theclass=classify(allpoints,allmeans,allvar)

现在我们有一个名为theclass的列表,其中包含了我们数据中每个点的分类。我们可以通过运行print(theclass[:10])来查看theclass的前 10 个元素。我们看到以下输出:

[1, 1, 1, 1, 3, 1, 3, 3, 1, 3]

这个输出告诉我们,我们数据中的第一个点似乎在第 1 类,第五个点在第 3 类,以此类推。我们已经完成了猜测步骤和期望步骤:我们有了每个簇的均值和方差,并且我们已经将每个数据点归类到了其中一个簇中。在继续之前,让我们创建一个函数来绘制我们的数据和簇:

def makeplot(allpoints,theclass,allmeans):
    thecolors=['black']*len(allpoints)
    for idx in range(len(thecolors)):
        if theclass[idx]==2:
            thecolors[idx]='green'
        if theclass[idx]==3:
            thecolors[idx]='yellow'
    allxs=[point[0] for point in allpoints]
    allys=[point[1] for point in allpoints]
    for i in range(len(allpoints)):
        plt.scatter(allxs[i], allys[i],color=thecolors[i])
    for i in range(len(allmeans)):
        plt.plot(allmeans[i][0],allmeans[i][1],'b*', markersize=15)
    plt.axis('equal')
    plt.show()

这个函数将我们的数据(allpoints)、簇分类(theclass)和簇均值(allmeans)作为输入。然后它为每个簇分配颜色:第一个簇的点为黑色,第二个簇的点为绿色,第三个簇的点为黄色。plt.scatter()函数将所有的点绘制成相应的颜色。最后,它用红色星形标记绘制每个簇中心。请注意,本书是黑白印刷的,所以只有在你在自己的电脑上运行这段代码时,才能看到这些颜色。

我们可以通过运行makeplot(allpoints,theclass,allmeans)来调用这个函数,我们应该能看到图 7-10。

图 7-10:初始簇分类

这是一个二维图。但是,为了理解它是如何将数据分类到簇中的,你可以想象有三个双变量的钟形曲线(就像在图 7-7 右侧的那个),它们从页面中凸出,每个曲线的中心位于一个星形的簇中心上。我们估算的协方差将决定每个钟形曲线的分布范围。簇的分类是根据每个数据点在哪个钟形曲线最高来确定的。你可以想象,如果我们移动了中心点或改变了协方差估计值,我们的双变量钟形曲线会有所不同,分类结果也可能会变化。(这一点很快就会发生。)

从图 7-10 可以清楚地看到,我们的聚类任务还没有完成。首先,簇的形状与我们在图 7-8 中看到的形状不匹配。更明显的是,我们称为簇中心的点,在这个图中显示为星形标记,显然并不位于它们各自簇的中心位置;它们更像是位于数据的边缘。这就是我们需要执行 E-M 聚类算法中的最大化步骤的原因,在该步骤中,我们将重新计算每个簇的均值和方差(从而将簇中心移动到更合适的位置)。

最大化步骤

这个步骤很简单:我们只需要拿出每个簇中的点,计算它们的均值和方差。我们可以更新之前使用的getcenters()函数来完成这一任务:

def getcenters(allpoints,theclass,k):
    centers=[]
    thevars=[]
    for n in range(k):
        pointsn=[allpoints[i] for i in range(0,len(allpoints)) if theclass[i]==(n+1)]
        xpointsn=[points[0] for points in pointsn]
 ypointsn=[points[1] for points in pointsn]
        xcenter=np.mean(xpointsn)
        ycenter=np.mean(ypointsn)
        centers.append([xcenter,ycenter])
        thevars.append(np.cov(xpointsn,ypointsn))
    return centers,thevars

我们更新后的getcenters()函数很简单。我们将一个数字k作为参数传递给该函数;这个数字表示我们数据中的聚类数量。我们还将数据和聚类分类传递给该函数。函数计算每个聚类的均值和方差,然后返回一个均值列表(我们称之为centers)和一个方差列表(我们称之为thevars)。

让我们调用更新后的getcenters()函数,找到我们三个聚类的实际均值和方差:

allmeans,allvar=getcenters(allpoints,theclass,3)

现在我们已经重新计算了均值和方差,让我们通过运行makeplot(allpoints,theclass,allmeans)再次绘制聚类图。结果应该类似于图 7-11。

图 7-11:重新计算的聚类中心

你可以看到,我们的聚类中心(星形)在重新计算后已经移动。由于聚类中心已移动,我们之前的一些聚类分类可能已经不正确。如果你在你的电脑上运行这个,你会看到一些黄色点,它们离黄色聚类的中心(图中的右上角聚类)相当远,而离其他聚类的中心则相当近。由于聚类中心已移动,并且我们重新计算了协方差,我们需要重新运行分类函数,将所有点重新分类到正确的聚类中(这意味着,我们需要再次运行期望步骤):

theclass=classify(allpoints,allmeans,allvar)

再次,为了理解这个分类是如何完成的,你可以想象三个双变量的钟形曲线从页面上突出出来,钟形曲线的中心由星形的位置决定,宽度由我们计算出的钟形曲线协方差决定。在每个点上,最高的钟形曲线将决定该点的聚类分类。

让我们再绘制一个图,反映这些新重新计算的聚类分类,通过再次运行makeplot(allpoints,theclass,allmeans)。结果是图 7-12。

图 7-12:重新分类的聚类分类

在这里,你可以看到星形(聚类中心)的位置与图 7-11 中的相同。但我们已经完成了点的重新分类:通过比较每个聚类的钟形曲线,我们找到了最有可能包含每个点的聚类,并据此改变了颜色。你可以将此与图 7-10 进行对比,看看自我们开始以来取得的进展:我们已经改变了对聚类中心位置的估计,也改变了对每个点属于哪个聚类的估计。

收敛步骤

你可以看到,两个聚类已经变大(下方的聚类和左侧的聚类),而一个聚类变小了(右上角的聚类)。但现在我们又回到了之前的情况:在重新分类聚类后,中心位置不再正确,因此我们也需要重新计算聚类中心。

希望到现在你能看出这个过程的规律:每次我们重新分类簇时,都需要重新计算簇的中心,而每次重新计算中心时,我们又需要重新分类簇。换句话说,每次我们执行期望步骤时,都必须执行最大化步骤,而每次我们执行最大化步骤时,又必须重新执行期望步骤。

这就是为什么 E-M 聚类的下一步、最后一步是重复执行期望和最大化步骤:这两个步骤相互依赖。我们可以写一个简短的循环来为我们完成这个任务:

for n in range(0,100):
    theclass=classify(allpoints,allmeans,allvar)
    allmeans,allvar=getcenters(allpoints,theclass,3)

循环体的第一行(以theclass=开始)完成了期望步骤,下一行则完成了最大化步骤。你可能会想,我们会不会陷入无限循环,需要不断重新计算中心并重新分类簇,永远无法得出最终答案。幸运的是,E-M 聚类在数学上是保证收敛的,这意味着最终我们会达到一个步骤,在这个步骤中我们重新计算中心并发现与上一步计算的中心相同,同时重新分类簇并发现与上一步分类的簇相同。此时,我们可以停止运行聚类,因为继续下去只是不断重复相同的结果。

在前面的代码片段中,我们没有检查是否收敛,而是设置了一个迭代次数限制为 100 次。对于像我们这样的小而简单的数据集,这肯定足够了。如果你有一个复杂的数据集,在 100 次迭代后似乎没有收敛,你可以增加到 1,000 次甚至更多,直到 E-M 聚类达到收敛。

让我们回顾一下我们做了什么。我们进行了猜测步骤,猜测了簇的均值和方差。我们进行了期望步骤,根据均值和方差对簇进行了分类。我们进行了最大化步骤,根据簇计算均值和方差。我们进行了收敛步骤,重复执行期望和最大化步骤直到达到停止条件。

我们已经完成了 E-M 聚类!现在,我们完成了聚类,让我们通过再次运行makeplot(allpoints,theclass,allmeans)来看一下最终估计的簇和中心的图形;见图 7-13。

当我们查看这个图表时,我们可以看到我们的聚类成功了。我们的一个聚类中心(星号)出现在那个大而分散的聚类中心附近。其他两个聚类中心出现在较小、更紧凑的聚类中心附近。重要的是,我们可以看到一些观察点,它们与小聚类的距离更近(绝对距离),但它们被归类为大聚类的一部分。这是因为 E-M 聚类考虑了方差;由于它看到中心聚类更分散,因此给它分配了更高的方差,从而能够包括更多的点。记住,我们开始时对聚类中心有一些非常糟糕的猜测,但最终得到了一个完全符合我们预期的结果。从图 7-10 中的糟糕猜测出发,我们最终在图 7-13 中得到了一个看起来合理的结果。这展示了 E-M 聚类的强大功能。

图 7-13:最终的 E-M 聚类结果

我们的 E-M 聚类过程已经识别出了数据中的聚类。我们已经完成了聚类算法,但还没有将其应用于任何业务场景。如何将其应用到业务中将取决于数据的具体内容。这只是为本书生成的示例数据,但我们可以在任何其他领域的数据上执行完全相同的 E-M 聚类过程。例如,我们可以假设,如前所述,图 7-13 中的点代表城市,x 轴和 y 轴代表不同类型的城市发展。或者,图 7-13 中的点可以代表客户,x 轴和 y 轴代表客户属性,如总消费、年龄、位置等。

你如何处理你的聚类将取决于你所使用的数据和你的目标。但在任何情况下,了解数据中存在的聚类可以帮助你为不同的聚类制定不同的营销方法、不同的产品,或者与每个聚类互动的不同策略。

其他聚类方法

E-M 聚类是一种强大的聚类方法,但它不是唯一的。另一种方法,k-means 聚类,更为流行,因为它更容易。如果你能做 E-M 聚类,那么通过一些简单的代码修改,k-means 聚类就变得容易了。以下是 k-means 聚类的步骤:

  1. 猜测:对每个聚类的均值进行猜测。

  2. 分类:根据每个观察点最有可能属于哪个聚类来分类我们的数据,依据的是它最接近哪个均值。

  3. 调整:利用分类步骤得到的分类结果来计算每个聚类的均值的新估计。

  4. 收敛:重复分类和调整步骤,直到达到停止条件。

你可以看到,k-means 聚类与 E-M 聚类一样,也由四个步骤组成。第一步和最后一步(猜测和收敛)是相同的:我们在两个过程的开始阶段都进行猜测,并且在两个过程中都重复步骤直到收敛。唯一的区别在于第二步和第三步。

在这两种算法中,第二步(E-M 聚类中的期望步骤,k-means 聚类中的分类步骤)决定了哪些观测值属于哪个聚类。区别在于我们如何确定哪些观测值属于哪个聚类。对于 E-M 聚类,我们通过比较钟形曲线的高度来确定一个观测值属于哪个聚类,如图 7-6 所示。对于 k-means 聚类,我们通过更简单的方式来确定观测值属于哪个聚类:即通过测量观测值与每个聚类中心之间的距离,找到最接近的聚类中心。所以,当我们看到一个掷骰子结果为 12 时,E-M 聚类会告诉我们这是 12 面骰子掷出的,因为在图 7-6 中钟形曲线的高度决定了这一点。然而,k-means 聚类会告诉我们这是 6 面骰子掷出的,因为 12 更接近 7(6 面骰子的平均点数),而不是 19(12 面骰子的平均点数)。

E-M 聚类和 k-means 聚类之间的另一个区别在于第三步(E-M 聚类中的最大化步骤和 k-means 聚类中的调整步骤)。在 E-M 聚类中,我们需要计算每个聚类的均值和协方差矩阵。但是在 k-means 聚类中,我们只需要计算每个聚类的均值—在 k-means 聚类中完全不使用协方差估计。你可以看到,E-M 聚类和 k-means 聚类有相同的基本框架,只有在分类和调整的具体步骤上有所不同。

实际上,我们只需导入正确的模块,就可以轻松地在 Python 中实现 k-means 聚类:

from sklearn.cluster import KMeans
kmeans = KMeans(init="random", n_clusters=3, n_init=10, max_iter=300, random_state=42)
kmeans.fit(allpoints)
newclass=[label+1 for label in kmeans.labels_]
makeplot(allpoints,newclass,kmeans.cluster_centers_)

在这里,我们从之前使用过的相同 sklearn 模块中导入KMeans()。然后,我们创建一个名为kmeans的对象;这个对象将用于对我们的数据进行 k-means 聚类。你可以看到,当我们调用KMeans()函数时,需要指定一些重要参数,包括我们想要的聚类数量(n_clusters)。在创建了kmeans对象后,我们可以调用它的fit()方法,来找到我们allpoints数据中的聚类(也就是之前使用的数据)。当我们调用fit()方法时,它会决定每个数据点属于哪个聚类,我们可以通过kmeans.labels_对象访问每个聚类的分类。我们还可以通过kmeans.cluster_centers_对象访问聚类的中心。最后,我们可以调用我们的makeplot()函数,绘制我们的数据以及我们使用 k-means 找到的聚类。图 7-14 展示了结果。

图 7-14:k-means 聚类的结果

你可以在这个图中看到,k-means 聚类的结果与 E-M 聚类的结果差别不大:我们已经识别出了图上方和右侧的两个密集簇,并且在图的其余部分识别出了一个较为松散的簇。一个不同之处是簇的边界不同:在 k-means 聚类中,上方和右侧的簇包含了一些看起来更像是较不密集簇的成员。这并非偶然;k-means 聚类的设计目的是发现大致相同大小的簇,它没有 E-M 聚类那样的灵活性,不能找到具有不同密度的不同大小的簇。

除了 E-M 和 k-means 聚类,还有许多其他聚类方法,这些方法多到无法在此详细列举。每种聚类方法都适用于特定类型的数据和特定的应用。例如,一种强大但被低估的聚类方法叫做 基于密度的空间聚类算法(带噪声)DBSCAN)。与 E-M 和 k-means 聚类不同,DBSCAN 能够检测出具有独特、非球形、非钟形形状的簇,就像图 7-15 所示的形状。

图 7-15:DBSCAN 聚类的结果,带有非球形簇

你可以看到两个明显的群体,或簇的数据。但由于它们彼此交织在一起,使用钟形曲线进行分类效果并不好。钟形曲线无法轻松找到这些簇的复杂边界。DBSCAN 并不依赖于钟形曲线,而是依靠对簇内外每个点之间距离的仔细考虑。

另一种重要的聚类方法叫做 层次聚类。层次聚类并不是简单地将观测值分类到不同的组中,而是生成一个嵌套的层次结构,展示观测值先是紧密相关的组,然后是逐渐更远的组。每种聚类方法都有不同的假设和方法。但它们的目标都是一样的:在没有标签或监督的情况下,将点分类到不同的组中。

其他无监督学习方法

聚类是无监督学习中最流行的应用,但除了聚类之外,许多算法也属于广义的无监督学习范畴。有几种无监督学习方法实现了 异常检测:即寻找那些不符合数据集一般模式的观测值。一些异常检测方法与聚类方法非常相似,因为它们有时会包含识别密集的邻近群体(像簇一样)并测量观测值与其最接近的簇之间的距离。

另一类无监督学习方法被称为潜变量模型。这些模型试图将数据集中的观测值表示为假设的隐藏变量或潜变量的函数。例如,一个数据集可能由学生在八门课程中的成绩组成。我们可能有一个假设,认为存在两种主要类型的智力:分析性智力和创造性智力。我们可以检查学生在定量的分析类课程(如数学和科学)中的成绩是否有相关性,以及学生在更具创造性的课程(如语言和音乐)中的成绩是否有相关性。换句话说,我们假设有两个隐藏的或潜在的变量——分析性智力和创造性智力——这两个潜变量在很大程度上决定了我们观察到的所有变量的值,也就是所有学生的成绩。

这并不是唯一的假设。我们也可以假设学生成绩仅由一个潜变量决定——通用智力,或者我们可以假设学生成绩是由三个或其他潜变量决定的,然后我们可以尝试测量和分析这些变量。

本章中我们完成的 E-M 聚类也可以看作是一种潜变量模型。在掷骰子聚类的情况下,我们感兴趣的潜变量是指示聚类位置和大小的钟形曲线的均值和标准差。许多潜变量模型依赖于线性代数和矩阵代数,因此如果你对无监督学习感兴趣,你应该认真学习这些主题。

请记住,所有这些方法都是无监督的,这意味着我们没有标签来严格地测试我们的假设。在图 7-13 和图 7-14 中,我们可以看到我们找到的聚类分类看起来是正确的,并在某些方面是有意义的,但我们不能确定它们是否是正确的。我们也不能确定 E-M 聚类(其结果如图 7-13 所示)或 k-means 聚类(其结果如图 7-14 所示)哪个更正确——因为没有“真实标签”可以用来判断正确性。这就是为什么无监督学习方法通常用于数据探索,但不常用于得出关于预测或分类的最终答案的原因。

由于无法明确判断任何无监督学习方法是否得出了正确的结果,做好无监督学习需要良好的判断力。它通常不会给出最终答案,而是通过提供对数据的洞察,帮助我们为其他分析(包括监督学习)提供思路。但这并不意味着它不值得一做;无监督学习可以提供无价的洞察和创意。

总结

在本章中,我们介绍了无监督学习,重点讲解了 E-M 聚类。我们讨论了无监督学习的概念、E-M 聚类的细节,以及 E-M 聚类与其他聚类方法(如 k-means 聚类)之间的区别。最后,我们讨论了其他无监督学习方法。在下一章中,我们将讨论网页抓取,以及如何从网站快速、轻松地获取数据进行分析和商业应用。

第八章:网页抓取

你需要数据来进行数据科学,当你手头没有数据集时,你可以尝试网页抓取,这是一种从公共网站直接读取信息并将其转换为可用数据集的技术。在本章中,我们将介绍一些常见的网页抓取技术。

我们将从最简单的抓取开始:下载网页代码并查找相关文本。然后,我们将讨论正则表达式,这是一组通过文本进行逻辑搜索的方法,以及 Beautiful Soup,这是一个免费的 Python 库,可以帮助你通过直接访问超文本标记语言(HTML)元素和属性来更容易地解析网站。我们将探讨表格,最后讨论一些与抓取相关的高级话题。让我们首先看看网站是如何工作的。

理解网站是如何工作的

假设你想查看 No Starch Press 网站,这是本书的出版商。你打开一个浏览器,如 Mozilla Firefox、Google Chrome 或 Apple Safari。你输入 No Starch 首页的网址,nostarch.com。然后,浏览器会展示给你页面,当前页面在写作时看起来像图 8-1。

图 8-1:本书出版商的主页,可以通过nostarch.com访问

你可以在这个页面上看到很多内容,包括文本、图片和链接,所有这些都经过精心排列和格式化,使得页面对人类来说易于阅读和理解。这种精心的格式化不是偶然发生的。每个网页都有源代码,指定了页面的文本和图像,以及它的格式和排列。当你访问一个网站时,你看到的是浏览器对这些代码的解释

如果你有兴趣查看网站的实际代码,而不是浏览器的视觉呈现,你可以使用特殊命令。在 Chrome 和 Firefox 中,你可以通过打开页面,右键点击(在 Windows 上,或在 macOS 上按住 CTRL 并点击)页面上的空白处,然后点击查看页面源代码,来查看nostarch.com的源代码。当你这样做时,你会看到一个类似于图 8-2 的标签。

图 8-2:No Starch Press 首页的 HTML 源代码

这个标签包含了指定 No Starch Press 首页所有内容的代码。它是以原始文本的形式呈现的,没有浏览器通常提供的视觉解释。网页的代码通常是用 HTML 和 JavaScript 语言编写的。

在本章中,我们对这些原始数据感兴趣。我们将编写 Python 脚本,自动扫描 HTML 代码,像图 8-2 中显示的代码一样,找到可以用于数据科学项目的有用信息。

创建你的第一个网页抓取工具

让我们从最简单的抓取器开始。这个抓取器将接受一个 URL,获取与该 URL 关联的网页的源代码,并打印出它获得的源代码的第一部分:

import requests
urltoget = 'https://bradfordtuckfield.com/indexarchive20210903.xhtml'
pagecode = requests.get(urltoget)
print(pagecode.text[0:600])

这个代码片段首先导入了我们在第七章中使用过的 requests 包;这里我们将用它来获取网页的源代码。接下来,我们指定 urltoget 变量,它将是我们想要请求的网页的 URL。在这个例子中,我们请求的是我个人网站上的一个归档页面。最后,我们使用 requests.get() 方法来获取网页的代码,并将这些代码存储在 pagecode 变量中。

pagecode 变量有一个 text 属性,其中包含了网页的所有代码。如果你运行 print(pagecode.text),你应该能够看到网页的所有 HTML 代码,它会作为一个长文本字符串存储。一些页面的代码量非常大,所以一次性打印所有的代码可能会不方便。如果是这样,你可以指定只打印部分代码。因此,在前面的代码片段中,我们通过运行 print(pagecode.text[0:600]) 来指定只打印页面代码的前 600 个字符。

输出看起来是这样的:

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html  xml:lang="en-US" lang="en-US">
  <head><meta http-equiv="Content-Type" content="text/html; charset=utf-8">

    <title>Bradford Tuckfield</title>
    <meta name="description" content="Bradford Tuckfield" />
    <meta name="keywords" content="Bradford Tuckfield" />
    <meta name="google-site-verification" content="eNw-LEFxVf71e-ZlYnv5tGSxTZ7V32coMCV9bxS3MGY" />
<link rel="stylesheet" type="text/css" href=

这个输出是 HTML,它主要由用尖括号(<>)标记的元素组成。每个元素向浏览器(如 Firefox 或 Chrome)提供有关如何向访客展示网站的信息。例如,你可以看到输出中有一个 <title> 标签;它也叫做开始标签,标记了标题元素的开始。在第七行的末尾,</title> 是另一个标签,这次叫做结束标签,标记了标题元素的结束。站点的实际标题是出现在开始标签和结束标签之间的文本;在这个例子中,它是 Bradford Tuckfield。当浏览器访问该站点时,它会解析标题元素的开始和结束标签的含义,然后在浏览器标签顶部显示标题文本 Bradford Tuckfield。这不是一本 HTML 书,所以我们不会详细讲解我们看到的每一个代码细节。即使没有深入的 HTML 专业知识,我们也可以成功地进行抓取。

现在我们已经抓取了一个网页,你可能觉得你已经掌握了所有的抓取技能。然而,你还有很多东西需要学习。大多数网页都有大量的 HTML 代码和内容,但数据科学家很少需要网页的全部源代码。在商业场景中,你更可能只需要网页上某个特定的信息或数据。为了找到你需要的特定信息,能够快速自动地在长字符串的 HTML 代码中进行搜索会非常有用。换句话说,你将需要解析HTML 代码。让我们来看一下如何做到这一点。

解析 HTML 代码

在上一节中,我们讨论了如何将任何公共网页的代码下载到 Python 会话中。现在让我们来谈谈如何解析下载的代码,以获取所需的准确数据。

抓取电子邮件地址

假设你有兴趣自动收集电子邮件地址以创建营销名单。你可能会使用我们之前介绍的抓取工具,下载多个网页的源代码。但你不需要每个页面代码中那些长字符串中的所有信息。相反,你只需要代表页面中电子邮件地址的小子字符串。所以,你需要在每个抓取的页面中搜索这些较小的子字符串。

假设你下载的其中一个页面的代码是 bradfordtuckfield.com/contactscrape.xhtml。如果你访问这个网页,你会看到浏览器显示的内容,如 图 8-3 所示。

图 8-3:可以轻松抓取的示例页面内容

这个页面只显示一个电子邮件地址,在浏览页面内容一眼看去就不难找到。如果我们想写一个脚本,找到格式像这样页面上的电子邮件地址,我们可以搜索 Email: 文本,并查看紧随其后的字符。让我们通过简单的文本搜索来实现这一点:

urltoget = 'https://bradfordtuckfield.com/contactscrape.xhtml'
pagecode = requests.get(urltoget)

mail_beginning=pagecode.text.find('Email:')
print(mail_beginning)

这段代码的前两行遵循与前一节相同的抓取流程:我们指定一个 URL,下载该 URL 的页面代码,并将代码存储在 pagecode 变量中。之后,我们使用 find() 方法搜索邮件文本。该方法以文本字符串作为输入,并返回该文本的位置。在这种情况下,我们将 Email: 字符串作为 find() 方法的输入,并将该文本的位置存储在 mail_beginning 变量中。最终输出为 511,表示 Email: 文本在页面代码中的位置是第 511 个字符。

在我们知道了 Email: 文本的位置后,我们可以尝试通过查看该文本后面的字符来获取实际的电子邮件地址:

print(pagecode.text[(mail_beginning):(mail_beginning+80)])

在这里,我们打印出紧跟 Email: 文本开头(即第 511 个字符)的 80 个字符。输出如下所示:

Email:  <label class="email" href="#">demo@bradfordtuckfield.com</label>
</div>

你可以看到,代码中包含的内容不仅仅是 图 8-3 中可见的文本。特别是,一个名为 label 的 HTML 元素出现在 Email: 文本和实际电子邮件地址之间。如果你只想要电子邮件地址,你必须跳过与 <label> 标签相关的字符,同时还需要删除电子邮件地址后的字符:

print(pagecode.text[(mail_beginning+38):(mail_beginning+64)])

这段代码将输出 demo@bradfordtuckfield.com,正是我们想要在页面上找到的文本,因为它跳过了 Email: 文本的 38 个字符和 <label> 标签,且去除了电子邮件地址之后的字符,电子邮件地址在 Email: 文本后第 64 个字符结束。

直接搜索地址

我们通过查找 Email: 文本后第 38 到第 64 个字符,成功找到了页面 HTML 代码中的电子邮件地址。这个方法的问题在于,当我们尝试在其他网页上使用时,它不太可能自动生效。如果其他页面没有我们找到的相同 <label> 标签,那么查找 Email: 后第 38 个字符就不适用了。或者,如果电子邮件地址的长度不同,那么在 Email: 后第 64 个字符停止搜索也无法奏效。由于抓取通常是快速、自动地在多个网站上进行的,因此手动检查应该查找哪个字符而不是第 38 和第 64 个字符,显然是不可行的。所以这种技术在实际业务场景中的抓取器中很可能不可行。

我们可以尝试搜索 Email: 文本,而不是查看后续的字符,尝试搜索 @ 符号本身。每个电子邮件地址应该包含一个 @,所以如果找到它,我们很可能找到了一个电子邮件地址。电子邮件地址中不会有 HTML 标签,所以我们不需要担心跳过 HTML 标签来找到地址。我们可以像搜索 Email: 文本那样搜索 @

urltoget = 'https://bradfordtuckfield.com/contactscrape.xhtml'
pagecode = requests.get(urltoget)

at_beginning=pagecode.text.find('@')
print(at_beginning)

这是我们之前使用的相同抓取代码。唯一的区别是我们搜索的是 @,而不是 Email:。最终的输出显示,@ 出现在代码的第 553 个字符处。我们可以打印出 @ 前后立即的字符,从而得到电子邮件地址本身:

print(pagecode.text[(at_beginning-4):(at_beginning+22)])

这里没有 HTML 标签需要跳过。但我们仍然面临一个问题:为了获得没有其他额外字符的电子邮件地址,我们必须知道 @ 前后各有多少个字符(分别是 4 和 22)。如果我们尝试重复这一方法来自动抓取多个网站上的电子邮件地址,它就无法奏效。

如果我们有一种方法可以进行智能搜索,我们的搜索将更成功,并且更容易自动化。例如,假设我们可以搜索符合以下模式的文本:

<匹配电子邮件地址开头的字符>

@

<匹配电子邮件地址结尾的字符>

事实上,有一种方法可以通过文本执行自动化搜索,从而识别像这里描述的模式。我们现在就来介绍这种方法。

使用正则表达式进行搜索

正则表达式是特殊的字符串,用于在文本中进行高级、灵活、定制化的模式搜索。在 Python 中,我们可以使用 re 模块进行正则表达式搜索,re 是 Python 标准库的一部分,Python 安装时会预先安装。以下是使用 re 模块进行正则表达式搜索的示例:

import re

print(re.search(r'recommend','irrelevant text I recommend irrelevant text').span())

在这个代码片段中,我们导入了 re 模块。正如其缩写所示,该模块用于正则表达式。该模块提供了一个 search() 方法,可以用于在任何字符串中搜索文本。在这种情况下,我们指定了两个参数:字符串 recommend 和一个包含 recommend 单词的文本字符串。我们要求该方法在包含一些无关文本的大字符串中搜索子字符串 recommend。请注意,我们在 recommend 字符串前添加了一个单独的 r 字符。这个 r 告诉 Python 将 recommend 字符串视为原始字符串,这意味着 Python 在搜索前不会处理或调整该字符串。span() 方法将为我们提供这个子字符串的起始和结束位置。

输出 (18,27) 表明 recommend 出现在第二个字符串中,从字符串的第 18 个索引开始,到第 27 个索引结束。这个 search() 方法类似于我们在前一部分使用的 find() 方法;两者都是用来查找子字符串在长字符串中的位置。

但是假设你正在搜索一个由某个容易拼错单词的人编写的网页。默认情况下,re.search() 方法查找的是精确匹配,因此如果你正在搜索一个拼错了 recommend 的网页,你将无法找到任何匹配项。在这种情况下,我们可能希望让 Python 查找 recommend,但查找它的不同拼写。以下是使用正则表达式实现这一目标的一种方法:

import re
print(re.search('rec+om+end', 'irrelevant text I recommend irrelevant text').span())

在这里,我们改变了代码的参数:我们不再搜索正确拼写的 recommend,而是搜索 rec+om+end。之所以有效,是因为 re 模块将加号(+)解释为元字符。当这种特殊类型的字符用于搜索时,它有一个特殊的逻辑解释,可以帮助你进行灵活的搜索,而不需要精确匹配。+ 元字符表示重复:它指定 Python 应该搜索前一个字符的一次或多次重复。所以当我们写 c+ 时,Python 知道应该搜索字母 c 的一次或多次重复,而当我们写 m+ 时,Python 知道应该搜索字母 m 的一次或多次重复。

使用像 + 这样的元字符并具有特殊逻辑意义的字符串称为正则表达式。正则表达式在每种主要编程语言中都有使用,并且在所有涉及文本的代码应用中非常重要。

你应该尝试使用 + 元字符进行实验,以便更好地理解它的工作方式。例如,你可以尝试搜索 recommend 的各种拼写错误,如下所示:

import re
print(re.search('rec+om+end','irrelevant text I recomend irrelevant text').span())
print(re.search('rec+om+end','irrelevant text I reccommend irrelevant text').span())
print(re.search('rec+om+end','irrelevant text I reommend irrelevant text').span())
print(re.search('rec+om+end','irrelevant text I recomment irrelevant text').span())

这段代码包含了四个正则表达式搜索。第一个搜索的输出是 (18,26),表示拼写错误的单词recomend匹配了我们搜索的正则表达式rec+om+end。记住,+元字符用于搜索前一个字符的一个或多个重复,所以它将匹配拼写错误的recomend中的单个c和单个m。第二个搜索的输出是 (18,28),表示拼写错误的reccommend也匹配了正则表达式rec+om+end,因为+元字符指定了一个或多个字符重复,在这里cm都重复了两次。在这种情况下,我们使用+的正则表达式为搜索提供了灵活性,使其能够匹配多个拼写变体。

但是正则表达式的灵活性并非绝对。当你在 Python 中运行第三个和第四个搜索时,它们会返回错误,因为正则表达式rec+om+end没有匹配指定字符串(reommendrecomment)中的任何部分。第三个搜索没有返回匹配项,因为c+指定了一个或多个c的重复,而reommend中没有任何c的重复。第四个搜索没有返回匹配项,因为虽然cm字符的数量是正确的,但搜索rec+om+end要求在末尾有一个d字符,而recomment没有匹配的d。使用正则表达式时,你需要小心确保它们准确表达了你想要的内容,并具备你所需的灵活性。

使用元字符进行灵活的搜索

除了+,Python 正则表达式中还可以使用其他几个重要的元字符。像+这样的元字符用于指定重复次数。例如,星号(*)表示前一个字符重复零次或更多次。请注意,这与+不同,+表示字符至少重复一次或更多次。我们可以按如下方式在正则表达式中使用*

re.search('10*','My bank balance is 100').span()

这个正则表达式将找到字符串中表示银行余额的位置,可以是 1、10、100、1,000,甚至是任何数量的 0(甚至是零个 0)。以下是使用*作为元字符的示例:

import re
print(re.search('10*','My bank balance is 1').span())
print(re.search('10*','My bank balance is 1000').span())
print(re.search('10*','My bank balance is 9000').span())
print(re.search('10*','My bank balance is 1000000').span())

在这段代码中,我们再次对四个字符串进行10*正则表达式搜索。我们发现第一个、第二个和第四个字符串匹配,因为尽管它们表示不同的金额,每个字符串都包含字符1,后面跟着零次或多次重复的字符0。第三个字符串虽然也包含重复的0字符,但没有匹配,因为该字符串没有与 0 相邻的1字符。

在实践中,文本中出现重复超过两次的字符是不常见的,因此*对你来说可能并不总是有用。如果你不想允许字符重复超过一次,问号(?)作为元字符非常有用。当?作为元字符使用时,它指定前面的字符出现零次或一次:

print(re.search('Clarke?','Please refer questions to Mr. Clark').span())

在这种情况下,我们使用?,因为我们想搜索 Clark 或 Clarke,而不是 Clarkee、Clarkeee 或含有更多e的 Clark。

使用转义序列微调搜索

元字符使你能够执行有用且灵活的文本搜索,允许多种拼写和格式。然而,它们也可能导致混淆。例如,假设你想在文本中搜索一个特定的数学方程式,比如 99 + 12 = 111\。你可以尝试如下搜索:

re.search('99+12=111','Example addition: 99+12=111').span()

当你运行这段代码时,你会遇到错误,因为 Python 没有找到与搜索字符串匹配的项。这可能会让你感到惊讶,因为你很容易看到我们在搜索字符串中指定的方程式的精确匹配。这个搜索没有返回结果,因为+的默认解释是作为元字符,而不是字面加号。记住,+表示前面的字符重复一次或多次。如果我们做如下搜索,就能找到匹配项:

re.search('99+12=111','Incorrect fact: 999912=111').span()

在这种情况下,Python 通过将+号解释为元字符找到精确匹配,因为9在右侧的字符串中重复。如果你想搜索实际的加号,而不是将+作为元字符,你需要使用另一个元字符来指定这个偏好。你可以这样做:

re.search('99\+12=111','Example addition: 99+12=111').span()

在这里,我们使用反斜杠(\)作为特殊的元字符。\称为转义字符。它允许+加号“逃脱”其元字符的身份,转而被字面解释。我们将\+字符串称为转义序列。在前面的代码片段中,我们找到了匹配项,因为我们对+加号进行了转义,因此 Python 查找字面上的加号,而不是将+解释为元字符并查找重复的9字符。

你可以通过使用转义序列对任何元字符进行字面搜索。例如,假设你想查找一个问号,而不是将问号作为元字符进行搜索。你可以这样做:

re.search('Clarke\?','Is anyone here named Clarke?').span()

这样可以找到Clarke?的匹配项,但找不到Clark?的匹配项。因为我们对问号进行了转义,Python 会查找字面上的问号,而不是将其解释为元字符。

如果你需要搜索反斜杠,你需要使用两个反斜杠——一个用来逃避元字符解释,另一个用来告诉 Python 搜索哪个字面字符:

re.search(r'\\',r'The escape character is \\').span()

在这个代码片段中,我们再次使用 r 字符来指定我们希望将字符串解释为原始文本,并确保 Python 在我们的搜索之前不做任何调整或处理。转义序列在正则表达式中很常见且有用。一些转义序列赋予标准字符(而非元字符)特殊意义。例如,\d 将搜索字符串中的任何数字(0 到 9),如下所示:

re.search('\d','The loneliest number is 1').span()

这段代码找到字符 1 的位置,因为 \d 转义序列表示任何数字。以下是其他有用的转义序列,它们使用非元字符:

\D 用于搜索任何非数字字符

\s 用于搜索空白字符(空格、制表符和换行符)

\w 用于搜索任何字母字符(字母、数字或下划线)

其他重要的元字符是方括号 []。它们可以成对出现在正则表达式中,用来表示字符类型。例如,我们可以如下搜索任何小写字母:

re.search('[a-z]','My Twitter is @fake; my email is abc@def.com').span()

这段代码指定我们要查找位于 az 之间的任何字符。它返回了输出 (1,2),其中只有字符 y,因为这是字符串中的第一个小写字母。我们也可以类似地搜索任何大写字母:

re.search('[A-Z]','My Twitter is @fake; my email is abc@def.com').span()

这个搜索返回 (0,1),因为它找到的第一个大写字母是字符串开头的 M

另一个重要的元字符是管道符号(|),它可以用作 逻辑表达式。如果你不确定两种拼写方式中哪一种正确,这尤其有用。例如:

re.search('Manchac[a|k]','Lets drive on Manchaca.').span()

在这里,我们指定要搜索以 Manchac 结尾,结尾可以是 ak。如果我们搜索 Lets drive on Manchack.,它也会返回匹配项。

结合元字符进行高级搜索

以下是你应该知道的其他元字符:

$ 表示行或字符串的结尾

^ 表示行或字符串的开头

. 用作通配符,表示除了行末(\n)之外的任何字符

你可以将文本和元字符结合起来进行高级搜索。例如,假设你有一份计算机上所有文件的列表,你想在所有文件名中搜索某个 .pdf 文件。也许你记得 .pdf 文件名和 school 有关系,但你不记得其他信息。你可以使用这个灵活的搜索来找到文件:

re.search('school.*\.pdf$','schoolforgottenname.pdf').span()

让我们看看这个片段中的正则表达式。它以school开始,因为你记得文件名中包含这个词。然后,它有两个元字符在一起:.*.是一个通配符元字符,*表示任何数量的重复。因此,.*表示在school之后的任何字符的任意数量。接下来,我们有一个转义的句点(圆点):\.,它指的是一个实际的句点符号,而不是通配符。接着,我们搜索字符串pdf,但只有在文件名末尾出现时才匹配(由$指定)。总结来说,这个正则表达式指定了一个文件名,它以school开头,以.pdf结尾,并且中间可能有任何其他字符。

让我们在不同的字符串中搜索这个正则表达式,确保你熟悉它正在搜索的模式:

import re
print(re.search('school.*\.pdf$','schoolforgottenname.pdf').span())
print(re.search('school.*\.pdf$','school.pdf').span())
print(re.search('school.*\.pdf$','schoolothername.pdf').span())
print(re.search('school.*\.pdf$','othername.pdf').span())
print(re.search('school.*\.pdf$','schoolothernamepdf').span())
print(re.search('school.*\.pdf$','schoolforgottenname.pdf.exe').span())

这些搜索中有一些会找到匹配项,而有一些会抛出错误,因为没有找到匹配项。仔细观察抛出错误的搜索,确保你理解为什么它们没有找到匹配项。当你对正则表达式和它们使用的元字符越来越熟悉时,你将能够迅速掌握任何你看到的正则表达式的逻辑,而不是把它看作一堆毫无意义的标点符号。

你可以使用正则表达式进行多种搜索。例如,你可以指定一个正则表达式来搜索街道地址、URL、特定类型的文件名或电子邮件地址。只要你要搜索的文本中存在一个逻辑模式,你就可以在正则表达式中指定该模式。

若要了解更多关于正则表达式的内容,你可以查看官方的 Python 文档:docs.python.org/3/howto/regex.xhtml。但实际上,掌握正则表达式的最佳方式是自己动手实践。

使用正则表达式搜索电子邮件地址

正则表达式使你能够灵活且智能地搜索多种类型的模式。让我们回到最初的例子,搜索电子邮件地址,看看我们如何在这里使用正则表达式。记住,我们想要搜索与以下模式匹配的文本:

<some text>``@``<some more text>

这是一个可以完成此搜索的正则表达式:

re.search('[a-zA-Z]+@[a-zA-Z]+\.[a-zA-Z]+',\
'My Twitter is @fake; my email is abc@def.com').span()

让我们仔细看看这个片段的元素:

  1. 它以[a-zA-Z]开始。这包括方括号元字符,指定了一个字符类。在这种情况下,它会查找由a-zA-Z表示的字符,即任何小写或大写的字母字符。

  2. [a-zA-Z]后面跟着+,指定一个或多个字母字符。

  3. 接下来是@。它不是元字符,而是搜索字面上的“at”符号(@)。

  4. 接下来,我们再次遇到[a-zA-Z]+,它指定在@后,任何数量的字母字符应该出现。这应该是电子邮件域名的第一部分,比如protonmailprotonmail.com中。

  5. \.指定了一个句点或圆点字符,用来在.com.org或任何其他顶级域名中查找这个字符。

  6. 最后,我们再次遇到[a-zA-Z]+,它指定在句号后应该跟着一些字母字符。这就是.com中的com.org中的org部分。

这六个元素一起指定了电子邮件地址的一般模式。如果你不熟悉正则表达式,可能会觉得[a-zA-Z]+@[a-zA-Z]+\.[a-zA-Z]+是在指定一个电子邮件地址。但由于 Python 能够解析正则表达式中的元字符,Python 能够理解这个搜索并返回电子邮件地址。同样重要的是,你也已经学会了正则表达式,并且理解了这个正则表达式的含义。

记住一个重要的事情:世界上有很多电子邮件地址。前面的正则表达式会识别许多电子邮件地址,但不是每一个可能的电子邮件地址。例如,有些域名使用的字符并不是英语中标准罗马字母的一部分。前面的正则表达式无法捕捉这些电子邮件地址。另外,电子邮件地址可能包含数字,而我们的正则表达式也无法匹配这些地址。能够可靠捕捉每一个可能的电子邮件地址中所有字符组合的正则表达式将会非常复杂,而达到这种复杂度超出了本书的范围。如果你对高级正则表达式感兴趣,可以查看由专业人士编写的正则表达式,专门用于查找电子邮件地址,网址是web.archive.org/web/20220721014244/https://emailregex.com/

将结果转换为可用数据

记住,我们是数据科学家,不仅仅是网页抓取者。在抓取网页之后,我们需要将抓取的结果转化为可用的数据。我们可以通过将所有抓取到的内容导入 pandas 数据框来实现。

让我们抓取以下网址中段落里列出的所有(虚假的)电子邮件地址:bradfordtuckfield.com/contactscrape2.xhtml。我们可以从读取网站的所有文本开始,具体如下:

import requests
urltoget = 'https://bradfordtuckfield.com/contactscrape2.xhtml'
pagecode = requests.get(urltoget)

这段代码和我们之前使用的是一样的:我们只是下载 HTML 代码并将其存储在pagecode变量中。如果你愿意,可以通过运行print(pagecode.text)来查看此页面的所有代码。

接下来,我们可以指定正则表达式来查找段落中的所有电子邮件地址:

allmatches=re.finditer('[a-zA-Z]+@[a-zA-Z]+\.[a-zA-Z]+',pagecode.text)

在这里,我们使用相同的字符作为我们的正则表达式。但我们使用了一种新的方法:re.finditer(),而不是 re.search()。我们这样做是因为 re.finditer() 能够获取多个匹配项,而我们需要这样做来获取所有的电子邮件地址。(默认情况下,re.search() 只会找到字符串或正则表达式的第一个匹配项。)

接下来,我们需要将这些电子邮件地址编译在一起:

alladdresses = []
for match in allmatches:
    alladdresses.append(match[0])

print(alladdresses)

我们从一个空列表 alladdresses 开始。然后我们将每个 allmatches 对象中的元素追加到该列表中。最后,我们打印出这个列表。

我们还可以将我们的列表转换为 pandas 数据框:

import pandas as pd
alladdpd=pd.DataFrame(alladdresses)
print(alladdpd)

现在我们已经将地址放入一个 pandas 数据框中,可以使用 pandas 库提供的大量方法来执行任何我们可能用其他 pandas 数据框完成的操作。例如,如果对我们有用,我们可以将其按字母顺序反转,然后将其导出为一个 .csv 文件:

alladdpd=alladdpd.sort_values(0,ascending=False)
alladdpd.to_csv('alladdpd20220720.csv')

让我们想一下到目前为止我们做了什么。从一个 URL 开始,我们下载了该 URL 指定的网页的完整 HTML 代码。我们使用正则表达式查找页面上列出的所有电子邮件。我们将这些电子邮件编译成 pandas 数据框,然后可以将其导出为 .csv 或 Excel 文件,或者根据需要进行其他转换。

下载 HTML 代码并指定正则表达式以查找特定信息,正如我们所做的那样,是完成任何抓取任务的合理方式。然而,在某些情况下,编写复杂的正则表达式来匹配难以匹配的模式可能会变得困难或不方便。在这些情况下,您可以使用其他库,这些库包含先进的 HTML 解析和抓取功能,而无需您编写任何正则表达式。一个这样的库叫做 Beautiful Soup。

使用 Beautiful Soup

Beautiful Soup 库 允许我们在不编写任何正则表达式的情况下,查找特定 HTML 元素的内容。例如,假设你想收集页面中的所有超链接。HTML 代码使用 anchor 元素来指定超链接。这个特殊元素通过一个简单的 <a> 起始标签来指定。以下是一个锚点元素在网页 HTML 代码中的示例:

<a href='https://bradfordtuckfield.com'>Click here</a>

这段代码指定了文本Click here。当用户点击 HTML 网页上的这个文本时,浏览器将跳转到bradfordtuckfield.com。HTML 元素以<a>开头,表示这是一个锚点或超链接,指向一个网页或文件。接下来,它有一个名为href的属性。在 HTML 代码中,属性是一个变量,提供有关元素的更多信息。在这个例子中,href属性包含了超链接应该“指向”的 URL:当某人点击Click here文本时,浏览器将跳转到href属性中包含的 URL。href属性后面是一个尖括号,然后是页面上显示的文本。最后,</a>表示超链接元素的结束。

我们可以通过在网页代码中进行正则表达式搜索<a>模式,或者指定一个正则表达式来查找网址,从而找到网页中的所有锚点元素。然而,Beautiful Soup 模块使得我们可以更加轻松地找到这些锚点元素,而无需担心正则表达式。我们可以通过以下方式找到网站中所有链接的 URL:

import requests
from bs4 import BeautifulSoup

URL = 'https://bradfordtuckfield.com/indexarchive20210903.xhtml'
response = requests.get(URL)
soup = BeautifulSoup(response.text, 'lxml')

all_urls = soup.find_all('a')
for each in all_urls:
    print(each['href'])

在这里,我们导入了requestsBeautifulSoup模块。就像其他所有的第三方 Python 包一样,你需要在脚本中使用BeautifulSoup之前先安装它。BeautifulSoup模块是一个名为 bs4 的包的一部分。bs4 包有一些所谓的依赖项:需要安装的其他包,以确保 bs4 正常工作。它的依赖项之一是名为 lxml 的包。在使用 bs4 和BeautifulSoup之前,你需要安装 lxml。导入所需的模块后,我们使用requests.get()方法下载网页代码,正如我们在本章之前做过的那样。然后,我们使用BeautifulSoup()方法解析代码,并将结果存储在一个名为soup的变量中。

拥有soup变量后,我们就可以使用 Beautiful Soup 的特定方法。特别是,我们可以使用find_all()方法来查找网页代码中的特定类型的元素。在这个例子中,我们搜索所有的锚点元素,它们由字符a表示。获取所有锚点元素后,我们打印出它们的href属性值——即它们链接到的页面或文件的 URL。你可以看到,使用 Beautiful Soup,我们可以用几行代码完成有用的解析,而无需使用复杂的正则表达式。

解析 HTML 标签元素

锚点元素并不是 HTML 代码中唯一的一种元素。我们在本章前面看到过<title>元素。有时网页也会使用<label>元素来为页面上的文本或内容添加标签。例如,假设你想从我们之前看到的bradfordtuckfield.com/contactscrape.xhtml网页中抓取联系信息。我们已经将图 8-3 复现为图 8-4 在这里。

图 8-4:一个可以轻松抓取的示例页面内容

你可能正在做一个项目,目的是搜索网页中的电子邮件地址、电话号码或网站。再一次,你可以尝试使用正则表达式来搜索这些内容。但是该网页上的电话号码和电子邮件地址是用 HTML 的<label>元素标注的,因此 Beautiful Soup 使得获取这些信息更加容易。首先,让我们看看该网页 HTML 代码中是如何使用<label>元素的。以下是该页面代码的一个小样本:

<div class="find-widget">
    Email:  <label class="email" href="#">demo@bradfordtuckfield.com</label>
</div>

正如你在本章前面看到的,<label>标签用于表示 HTML 代码中的某个部分属于某种特定类型。在这种情况下,class属性标识这是一个电子邮件地址的标签。如果你抓取的网页中有这些<label>元素,你可以按如下方式搜索电子邮件地址、电话号码和网站:

import requests
from bs4 import BeautifulSoup

URL = 'https://bradfordtuckfield.com/contactscrape.xhtml'
response = requests.get(URL)
soup = BeautifulSoup(response.text, 'lxml')

email = soup.find('label',{'class':'email'}).text
mobile = soup.find('label',{'class':'mobile'}).text
website = soup.find('a',{'class':'website'}).text

print("Email : {}".format(email))
print("Mobile : {}".format(mobile))
print("Website : {}".format(website))

这里,我们再次使用soup.find()方法。但这次不仅仅是查找标签为a的元素,正如我们在查找超链接时所做的那样,而是还要查找带有<label>标签的元素。代码中的每个<label>标签指定了不同的class。我们找到每种标签(如电子邮件和手机)的文本并打印出来。对于网站链接,我们查找带有website类的锚点标签。最终的结果是我们能够找到每一种我们需要的数据:电子邮件地址、手机号码和网站。

抓取和解析 HTML 表格

表格在网站上很常见,因此了解如何从网站表格中抓取数据是很有必要的。如果访问bradfordtuckfield.com/user_detailsscrape.xhtml,你可以看到一个简单的 HTML 表格示例。该网页包含了一个关于几个虚构人物的表格,展示在图 8-5 中。

图 8-5:可以使用 Beautiful Soup 抓取的表格

假设我们想从这个表格中抓取这些人物的信息。让我们来看看指定这个表格的 HTML 代码:

<table style="width:100%">
  <tr class="user-details-header">
    <th>Firstname</th>
    <th>Lastname</th>
    <th>Age</th>
  </tr>
  <tr class="user-details">
    <td>Jill</td>
    <td>Smith</td>
    <td>50</td>
  </tr>
  <tr class="user-details">
    <td>Eve</td>
    <td>Jackson</td>
    <td>44</td>
  </tr>
  <tr class="user-details">
    <td>John</td>
    <td>Jackson</td>
    <td>24</td>
  </tr>
  <tr class="user-details">
    <td>Kevin</td>
    <td>Snow</td>
    <td>34</td>
  </tr>
</table>

<table>标签指定表格的开始,</table>指定表格的结束。在开始和结束之间是一些<tr></tr>标签。每个<tr>标签指定一个表格行的开始(trtable row的缩写)。在每个表格行内,<td>标签指定特定单元格的内容(tdtable data的缩写)。你可以看到第一行是表格的头部,包含了每一列的名称。第一行之后的每一行都指定了一个人的信息:首先是名字,其次是姓氏,再次是年龄,分别放在三个不同的<td>元素中。

我们可以按照如下方式解析表格:

import requests
from bs4 import BeautifulSoup

URL = 'https://bradfordtuckfield.com/user_detailsscrape.xhtml'
response = requests.get(URL)
soup = BeautifulSoup(response.text, 'lxml')

all_user_entries = soup.find_all('tr',{'class':'user-details'})
for each_user in all_user_entries:
    user = each_user.find_all("td")
 print("User Firstname : {}, Lastname : {}, Age: {}"\
.format(user[0].text, user[1].text, user[2].text))

在这里,我们再次使用 Beautiful Soup。我们创建一个soup变量,包含网页的解析版本。然后,我们使用find_all()方法查找页面上的每一个tr元素(表格行)。对于每一行,我们再次使用find_all()来查找该行中的每一个td元素(表格数据)。找到每行的内容后,我们将它们打印出来,并格式化以标记名字、姓氏和年龄。除了打印这些元素外,你还可以考虑将它们添加到 pandas 数据框中,这样可以更轻松地导出、排序或进行其他分析。

高级爬虫

爬虫是一个深奥的话题,本章所覆盖的内容只是冰山一角,仍有很多需要学习的地方。你可以从本节所列出的一些领域开始。

首先,考虑到某些网页是动态的;它们会根据用户的交互发生变化,例如点击元素或滚动页面。网页的动态部分通常是通过 JavaScript 渲染的,而 JavaScript 的语法与我们在本章专注于抓取的 HTML 有很大不同。我们用来下载 HTML 代码的requests包,以及用来解析代码的 Beautiful Soup 模块,都是用于静态网页的。对于动态网页,你可能需要使用其他工具,如 Selenium 库,它专门用于抓取动态网页。使用 Selenium 时,你的脚本可以执行诸如填写网站表单、点击验证码挑战等操作,而无需直接的人工输入。

你还应该考虑应对被封锁的策略。许多网站对所有抓取其数据的尝试都抱有敌意。它们有策略来阻止爬虫,如果它们检测到你正在尝试抓取并获取它们的信息,它们可能会试图封锁你。应对被封锁的一种方式是放弃,这样可以避免因抓取敌对网站而产生的法律问题或道德问题。

如果你决定爬取那些试图阻止你的站点,你可以采取一些措施来避免被封锁。一个方法是设置一个或多个代理服务器。网站可能会阻止你的 IP 地址访问其数据,所以你可以设置一个不同的服务器,使用一个网站没有封锁的 IP 地址。如果网站继续试图封锁你的代理服务器的 IP 地址,你可以设置轮换代理,这样你可以不断获取新的、没有被封锁的 IP 地址,并仅使用这些新的、未封锁的 IP 地址进行爬取。

当你采取这种方法时,你应该考虑其伦理影响:你是否愿意使用这些策略来访问一个不希望你访问的网站?记住,在极少数情况下,未经授权的爬取可能会导致诉讼甚至刑事起诉。你应该始终保持谨慎,确保你已经仔细思考了你所做一切的实际和伦理影响。

并非所有网站都反感让人访问和爬取它们的数据。有些网站允许爬取,甚至有些网站还设置了应用程序接口(API)来方便数据访问。API 允许你自动查询网站的数据,并以用户友好的格式接收数据。如果你需要爬取某个网站,检查它是否有你可以访问的 API。如果网站有 API,API 文档应指明 API 提供的数据以及如何访问它。我们在本章讨论的许多工具和思路也适用于 API 使用。例如,requests包可以用来与 API 进行交互,获取 API 数据后,这些数据可以用于填充 pandas 数据框。

最后,定时是设置爬虫脚本时需要考虑的一个重要问题。有时候,爬虫脚本会快速连续地向一个网站发起多个请求,尽可能快速地下载尽可能多的数据。这可能会导致网站被压垮而崩溃,或者网站可能会阻止爬虫以防止过载。为了避免目标网站崩溃或阻止你,你可以调整爬虫,让它的运行速度变得更慢。一种减慢脚本速度的方法是故意添加暂停。例如,在从一个表格下载一行数据后,脚本可以暂停并什么也不做(脚本可以休眠)1 秒、2 秒或 10 秒,然后再下载表格的下一行。故意慢下来对那些喜欢快速完成任务的人来说可能会让人沮丧,但从长远来看,它往往能让爬取成功的概率更高。

总结

在本章中,我们介绍了网页抓取技术。我们概述了抓取的概念,并简要介绍了 HTML 代码的工作原理。接着,我们构建了一个简单的抓取工具,它仅仅是下载并打印出网页的代码。我们还通过解析网站代码进行了搜索,包括使用正则表达式进行高级搜索。我们展示了如何将抓取自网站的数据转换为可用的数据集。我们还使用了 Python 的 Beautiful Soup 库,轻松地在网页上找到超链接和标签信息。最后,我们简要讨论了一些抓取技能的高级应用,包括 API 集成和抓取动态网站。在下一章中,我们将讨论推荐系统。让我们继续!

第九章:推荐系统

每个有才能的销售员都知道如何向顾客提出聪明、精准的推荐,随着在线零售商规模和技术的不断发展,他们热衷于自动化这一销售策略。但是,做出这些推荐并不容易。为此,许多企业创建了自动化的推荐系统,通过分析产品和顾客的数据来确定哪些顾客最有可能对哪些产品感兴趣。

在本章中,我们将详细讨论推荐系统。我们将从最简单的推荐系统开始:一个仅向每个顾客推荐最受欢迎商品的系统。接下来,我们将讨论一个重要的技术——协同过滤,它使我们能够为每个顾客和每个商品提供独特、个性化的推荐。我们将讨论两种协同过滤方法:基于商品的和基于用户的协同过滤。最后,我们将通过一个案例研究和一些与推荐系统相关的高级概念来结束本章。

基于受欢迎程度的推荐

在我们编写推荐系统的代码之前,我们应该考虑如何进行推荐。假设你是一个销售员,想要向走进店里的顾客推荐商品。如果你了解这位顾客,你可以根据你对顾客口味和情况的了解来做推荐。如果是新顾客走进店里,且你对他们一无所知,你可以观察他们正在浏览的商品,并根据这个信息来推荐。但也有可能,在他们浏览任何商品之前,你就被要求做出推荐。需要在没有任何顾客具体信息的情况下做出智能推荐的问题,称为冷启动问题

面对冷启动问题时,一个合理的做法是推荐最受欢迎的商品。这样做简单且易于实施。虽然它不具备了解顾客所有信息并做出个性化推荐的复杂性,但如果某个商品在大众中很受欢迎,那么它很可能也会吸引新顾客。

在线零售商面临着类似的挑战:新的访客访问他们的网站,这些访客可能没有浏览历史记录,或者对于在线零售商来说是陌生的。这些零售商希望基于详细的顾客信息做出个性化推荐,但当他们面临冷启动问题时,只能依赖其他方法,比如普遍的受欢迎程度。冷启动问题在在线零售商中尤为常见,因为潜在顾客可以匿名访问网站,而不需要向网站或其销售团队提供任何个人信息。

让我们来思考一下我们会用什么代码来做基于流行度的推荐。对于这个或任何其他推荐系统,拥有与交易历史相关的数据是很有帮助的。我们可以下载、读取并查看一些伪造的交易历史数据,如下所示:

import pandas as pd
import numpy as np
interaction=pd.read_csv('https://bradfordtuckfield.com/purchasehistory1.csv')
interaction.set_index("Unnamed: 0", inplace = True)
print(interaction)

在这里,我们导入了 pandas 包来进行数据处理。我们从互联网读取一个.csv文件到interaction变量,并将其存储为一个 pandas 数据框。我们指定数据的第一列作为索引(行名称),然后打印数据框。最终输出的结果显示在列表 9-1 中。

 Unnamed: 0  user1  user2  user3  user4  user5
0      item1      1      1      0      1      1
1      item2      1      0      1      1      0
2      item3      1      1      0      1      1
3      item4      1      0      1      0      1
4      item5      1      1      0      0      1

列表 9-1:一个交互矩阵,显示每个商品的购买历史

列表 9-1 显示了一个矩阵,表示一个零售商的销售历史,该零售商有五个客户和五个待售商品。请注意,我们称客户为用户,假设他们是零售商网站的用户。但无论我们如何称呼他们,我们使用的推荐技术都是相同的。

该矩阵如果用户没有购买某个商品,则该位置为0,如果用户购买了该商品,则为1。例如,你可以看到user2购买了item3但没有购买item2,而user3购买了item2但没有购买item3。这种 0/1 矩阵是在构建推荐系统时常见的格式。我们可以称这个矩阵为交互矩阵;它表示了用户与商品之间的互动信息。由于几乎每家公司都有与其商品和购买历史相关的记录,因此基于交互矩阵构建推荐系统是一种非常常见的做法。

假设一个新客户,我们称之为user6,走进了你的商店(或访问了你的网站)。你面临一个冷启动问题,因为你对user6一无所知。如果你想为user6推荐可以购买的商品,你可以列出最受欢迎的商品,方法如下:

interaction_withcounts=interaction.copy()
interaction_withcounts.loc[:,'counts']=interaction_withcounts.sum(axis=1)
interaction_withcounts=interaction_withcounts.sort_values(by='counts',ascending=False)
print(list(interaction_withcounts.index))

在这里,我们创建了一个名为interaction_withcounts的交互矩阵副本。我们将使用这个副本来通过计算购买每个商品的用户数量来找出最受欢迎的商品。请注意,我们的矩阵并不记录用户是否多次购买某个商品或只购买了一次,因此我们的分析只会查看用户是否购买过商品;我们不会分析每个用户购买每个商品的次数。

由于我们矩阵的每一行记录的是某个特定商品的购买情况,因此我们使用sum()方法计算每一行的购买总和,并将结果存储在一个名为counts的新列中。然后我们使用sort_values()方法,它会将我们的矩阵行按购买数量从高到低排序。通过从最受欢迎到最不受欢迎排序,它将商品按流行度进行排序。最后,我们打印出排序后的矩阵的索引,显示出所有商品的名称,从最受欢迎到最不受欢迎:

['item1', 'item3', 'item2', 'item4', 'item5']

我们可以理解为item1是最受欢迎的项目(实际上与item3并列),item2是第三受欢迎的,依此类推。

现在你已经有了这个列表,你可以准备向不熟悉的客户提供推荐。推荐的呈现方式将取决于你的商业战略、网站开发团队的能力以及市场营销团队的偏好。推荐系统项目中的数据科学部分是创建优先推荐列表,并让市场营销人员或网页开发人员将这些推荐呈现给用户。这也是推荐系统项目具有挑战性的原因之一:它们需要多个团队的合作。

我们可以创建一个函数,通过将我们迄今为止的所有代码结合起来,生成适用于任何交互矩阵的基于受欢迎程度的推荐:

def popularity_based(interaction):
    interaction_withcounts=interaction.copy()
    interaction_withcounts.loc[:,'counts']=interaction_withcounts.sum(axis=1)
    sorted = interaction_withcounts.sort_values(by='counts',ascending=False)
    most_popular=list(sorted.index)
    return(most_popular)

这个函数只是将我们之前在本章中编写的代码功能进行了封装。它以交互矩阵作为输入,汇总每个项目的购买次数,按购买数量排序,并返回按从最受欢迎到最不受欢迎排序的项目名称列表。这个最终的排序列表可以用于向客户提供推荐,即使你对客户不熟悉。你可以通过在 Python 中运行 print(popularity_based(interaction)) 来调用这个函数。

基于受欢迎程度的推荐系统是一种简单合理的方式,用来解决冷启动问题,并向用户提供某种推荐。你可以在今天的许多网站上看到基于受欢迎程度的推荐,其中热门内容被突出显示。你也可以在实体零售商店看到基于受欢迎程度的推荐,例如书店中显著展示畅销书。

但是基于受欢迎程度的推荐不如个性化推荐有效。能够使用关于人和项目的详细信息的推荐系统,比通用的基于受欢迎程度的推荐系统更有可能成功。让我们现在来看一个这样的例子。

基于项目的协同过滤

假设你并没有完全面临冷启动问题。相反,你对第六个客户有一些信息:特别是,你知道他们对item1感兴趣。这个信息就是你在使用协同过滤时所需要的一切来做出推荐。

让我们再看一遍我们的交互矩阵,从中获取一些关于如何向对item1感兴趣的人推荐的思路:

 Unnamed: 0  user1  user2  user3  user4  user5
0      item1      1      1      0      1      1
1      item2      1      0      1      1      0
2      item3      1      1      0      1      1
3      item4      1      0      1      0      1
4      item5      1      1      0      0      1

如果我们查看互动矩阵的第一行,就能看到客户与 item1 的完整互动历史。该商品被 user1user2user4user5 购买,但没有被 user3 购买。如果我们查看 item3,我们会发现它与 item1 的购买历史完全相同。它们可能是类似的商品,比如两部詹姆斯·邦德电影,或者它们可能是互补的,比如花生酱和果酱。不管怎样,如果两个商品曾经一起被购买过,那么它们未来很可能会被一起购买。

相比之下,看看 item1item2 的购买历史;它们的客户重叠较少。这些商品的购买历史不太相似。由于它们过去没有经常一起被购买,因此它们未来也不太可能经常一起被购买。做出智能推荐的一种方式是使用这个思路:如果一个用户对某个商品感兴趣,就向他推荐其他购买历史与该商品最相似的商品。这种方法叫做基于商品的协同过滤

为了推荐购买历史最相似的商品,我们需要一种方法来定量衡量两个购买历史之间的相似度。我们看到 item1item3 的购买历史非常相似(完全相同),而 item1item2 的购买历史差异较大。如果我们比较 item1item5,我们会看到它们的历史之间有一些相似性,也有一些差异。但与其做出定性的判断,认为两个购买历史是非常相似还是不太相似,用数字精确量化这种相似性将会更有用。如果我们能找到一种量化两个商品相似度的度量标准,我们就可以使用这个标准来推荐商品。

测量向量相似性

让我们更仔细地看一下某个商品的购买历史,以获得定量衡量相似性的灵感:

print(list(interaction.loc['item1',:]))

这行代码打印出 item1 的购买历史。输出如下所示:

[1,1,0,1,1]

我们可以从几个角度来思考这个购买历史。它可能看起来只是一些数字的集合。由于这些数字被方括号括起来,Python 会将这个集合解释为一个列表。我们也可以将它看作一个矩阵中的一行(我们的互动矩阵)。最重要的是,我们可以将这个数字集合看作一个向量。你可能还记得在数学课上,向量是一个有方向的线段。表示一个向量的一种方式是将其表示为一组坐标数字。例如,图 9-1 描绘了两个向量,A⃗B⃗

图 9-1:两个向量,由坐标对表示

在这个例子中,A⃗B⃗ 是有向线段,或者说是向量。它们都是二维的。就像每个向量一样,这两个向量也可以通过它们的坐标完全描述:在我们知道这两个向量都从原点出发后,坐标对 (3,7) 完全描述了向量 A⃗,而坐标对 (8,4) 完全描述了向量 B⃗。我们之前看到的购买历史 [1,1,0,1,1] 可以看作是表示 item1 购买历史的向量。事实上,我们的交互矩阵中的所有行,或者任何交互矩阵,都可以看作是向量。

由于我们有表示项目的向量,我们可能想要像图 9-1 那样在图表中绘制我们的向量。然而,在我们的交互矩阵中,每个项目向量都有五个坐标,因此如果我们想绘制它们,我们将不得不在一个五维图中绘制,而这在直观上是无法用人类轻松理解的方式表示的。由于我们无法绘制项目向量,我们可以看一下图 9-1 中的 A⃗B⃗ 向量,来理解如何测量向量相似性,然后将我们学到的内容应用到后面的项目向量中。

你可以看到,向量 A⃗B⃗ 有些相似:它们都大致指向上方并朝向右侧。我们想要找到一个量化的度量,准确表示两个向量之间有多相似。我们所需要做的就是测量两个向量之间的角度,如在图 9-2 中所示。

图 9-2:两个向量之间的角度,用希腊字母 theta 表示

每一对向量之间都有一个角度,我们可以测量这个角度。在二维空间中,我们可以拿出量角器,实际测量两个向量之间的角度。在图 9-2 中,角度用希腊字母 theta 标注。如果角度 theta 很小,我们可以得出结论,两个向量是相似的。如果 theta 很大,我们可以得出结论,两个向量非常不同。两个向量之间的最小角度是 0;两个向量之间的 0 度角意味着它们指向完全相同的方向(它们重合)。

这不是一本几何学书籍,但试着记住你在数学和几何课上学到的最后一件事:余弦。余弦是我们可以测量的每个角度的一个函数。0 度角的余弦是 1;这是余弦能达到的最大值。随着角度大于 0,余弦值减小。对于 90 度角(也叫做垂直角,或直角),余弦值为 0。

余弦函数很重要,因为我们可以用它来衡量两个向量的相似性。如果两个向量相似,它们之间的角度会很小,因此它们之间的角度余弦值会很大(接近 1)。如果两个向量垂直,它们非常不同,它们之间的角度余弦值将是 0。像图 9-1 中的A⃗B⃗这样的向量并不完全相似,也不完全不同,因此它们之间的角度余弦值会在 0 和 1 之间。在比较向量时,我们通常提到两向量的余弦相似度(即两个向量之间的角度余弦值)。相似的向量将具有较高的余弦相似度,而不同的向量将具有较低的余弦相似度。

当向量具有许多维度时,比如我们购买历史中的五维向量,我们不会实际测量角度。相反,我们可以使用一个特殊的公式,允许我们在不使用量角器的情况下计算任意一对向量之间的角度余弦值;请参见图 9-3。

图 9-3:计算两个向量之间角度余弦值的公式

我们将在下一节中详细解释这个公式。

计算余弦相似度

让我们仔细看看图 9-3 中的公式。分子是A · B。在这个公式中,向量A⃗B⃗之间的点表示是点积,这是一种特殊的向量乘法方式。以下函数计算任何两个相同长度向量的点积:

def dot_product(vector1,vector2):
    thedotproduct=np.sum([vector1[k]*vector2[k] for k in range(0,len(vector1))])
    return(thedotproduct)

图 9-3 中的公式的分母显示了围绕AB的管道符号(||)。这些管道符号表示向量A⃗B⃗的大小,也称为它们的向量范数。以下函数计算任何向量的范数:

def vector_norm(vector):
    thenorm=np.sqrt(dot_product(vector,vector))
    return(thenorm)

两个向量之间的角度余弦值(即两个向量的余弦相似度)是两个向量的点积,除以这两个向量的范数的乘积。我们可以通过使用我们刚刚定义的两个函数,按图 9-3 中所示的公式组合,创建一个 Python 函数来计算任何两个向量的余弦相似度:

def cosine_similarity(vector1,vector2):
    thedotproduct=dot_product(vector1,vector2)
    thecosine=thedotproduct/(vector_norm(vector1)*vector_norm(vector2))
    thecosine=np.round(thecosine,4)
    return(thecosine)

这个函数计算的余弦相似度是一种常见的相似度度量,广泛应用于许多数据科学领域,不仅仅用于推荐系统。

让我们尝试计算一些我们项目向量的余弦相似度:

import numpy as np
item1=interaction.loc['item1',:]
item3=interaction.loc['item3',:]
print(cosine_similarity(item1,item3))

这个代码段会产生一个简单的输出:

1.0

我们可以看到,item1item3的余弦相似度为1.0,这意味着这两个向量之间的角度为 0。因此,它们是完全相同的向量,它们是能够达到的最相似的向量。相比之下,你可以通过运行以下代码段来检查item2item5之间的余弦相似度:

item2=list(interaction.loc['item2',:])
item5=list(interaction.loc['item5',:])
print(cosine_similarity(item2,item5))

这两个商品的余弦相似度为0.3333,意味着这两个向量之间的角度相对较大——大约 71 度,接近直角。因此,这两件商品非常不同。我们从它们的向量中可以看到这一点:五个用户中只有一个同时购买了这两件商品。如果我们按照类似的过程检查item3item5的余弦相似度,我们会发现它是0.866,表示这些向量相似但并非完全相同。

现在我们可以衡量任何两件商品的历史相似度,准备好利用这一计算来创建推荐系统了。

实现基于物品的协同过滤

让我们回顾一下假设的销售人员和假设的销售场景。你有一个互动矩阵,描述了所有五个客户和五个商品的购买历史。你看到一个新的、不熟悉的顾客进入你的商店(或访问你的网站),你所知道的唯一信息是这个新顾客对item1感兴趣。你应该如何为他们做出推荐呢?

你可以根据每个商品的购买历史与item1的购买历史相似度对所有商品进行排序。你的推荐将是一个有序的商品列表,按购买历史与item1最相似的商品到最不相似的商品排列。

让我们写一下 Python 代码来实现这一点,使用余弦相似度。我们可以首先定义需要进行计算的向量:

ouritem='item1'
otherrows=[rowname for rowname in interaction.index if rowname!=ouritem]
otheritems=interaction.loc[otherrows,:]
theitem=interaction.loc[ouritem,:]

接下来,我们可以计算每个商品与我们选择的商品的相似度,并通过找到与我们选择的商品最相似的其他商品来进行推荐:

similarities=[]
for items in otheritems.index:
    similarities.append(cosine_similarity(theitem,otheritems.loc[items,:]))

otheritems['similarities']=similarities
recommendations = list(otheritems.sort_values(by='similarities',ascending=False).index)

在这个代码片段中,我们创建了一个similarities变量,初始为空列表。然后,我们创建了一个循环,计算我们选择的商品与每个其他商品之间的余弦相似度。之后,我们得到了最终的推荐列表:一个所有其他商品的列表,从最相似到最不相似的商品排序。

你可以通过运行print(recommendations)来检查推荐结果,它会显示以下列表:

['item3', 'item5', 'item2', 'item4']

这个列表是你推荐系统的输出。最终输出类似于基于人气的推荐系统的输出:只是一个商品列表,按相关性从高到低排序(从最相关到最不相关的推荐)。不同之处在于,我们不再根据整体人气来衡量相关性,而是根据购买历史的相似度来衡量:购买历史越相似,越被视为更相关,因此在推荐给用户时具有更高的优先级。

我们还可以创建一个函数,将所有这些功能整合在一起:

def get_item_recommendations(interaction,itemname):
    otherrows=[rowname for rowname in interaction.index if rowname!=itemname]
    otheritems=interaction.loc[otherrows,:]
    theitem=list(interaction.loc[itemname,:])
    similarities=[]
    for items in otheritems.index:
        similarities.append(cosine_similarity(theitem,list(otheritems.loc[items,:])))
    otheritems['similarities']=similarities
    return list(otheritems.sort_values(by='similarities',ascending=False).index)

你可以运行get_item_recommendations(interaction,'item1')来查看对任何对item1感兴趣的用户推荐的商品。你也可以替换item1为其他任何商品,来查看对其他商品感兴趣的用户的推荐。

我们在这里创建的推荐系统是基于物品的协同过滤。它之所以是过滤,是因为我们不是向用户推荐每一件商品,而是过滤并仅展示最相关的商品。它之所以是协同,是因为我们使用与所有商品和所有用户相关的信息,所以就像用户和商品在协作帮助我们确定相关性一样。它之所以是基于物品,是因为我们的推荐是基于商品购买历史之间的相似性,而不是用户之间或其他任何因素的相似性。

基于物品的协同过滤相对容易实现,即使我们只知道潜在客户的一个信息(他们感兴趣的单一商品),也可以用它来做出“温暖”的推荐。你可以看到,它只需要几行代码就能实现,而唯一需要的输入数据就是交互矩阵。

基于物品的协同过滤有时会做出显而易见的推荐。比如,多个詹姆斯·邦德电影在购买历史上可能有很高的重叠,因此使用基于物品的协同过滤推荐一部詹姆斯·邦德电影时,可能会推荐另一部詹姆斯·邦德电影。但是詹姆斯·邦德的粉丝已经熟悉这些电影,他们不需要被推荐观看他们已经看过的电影。推荐系统在推荐那些不那么显而易见的商品时会更具价值。接下来,让我们看一下一个以生成一些不那么显而易见的推荐而闻名的方法。

基于用户的协同过滤

假设你想为一个你已经熟悉的客户提供推荐。例如,假设我们的第五个客户user5走进你的商店(或访问你的网站)。你的交互矩阵已经包含了与user5相关的详细记录以及他们之前购买的所有商品。我们可以利用这些详细信息为user5做出智能的、“温暖”的推荐,采用基于用户的协同过滤

这种方法基于这样一个理念:相似的人可能对相同的商品感兴趣。如果我们需要为一个特定客户提供推荐,我们会找到与该客户最相似的客户,并推荐这些相似客户购买过的商品。

让我们再次查看一下我们的交互矩阵:

 Unnamed: 0  user1  user2  user3  user4  user5
0      item1      1      1      0      1      1
1      item2      1      0      1      1      0
2      item3      1      1      0      1      1
3      item4      1      0      1      0      1
4      item5      1      1      0      0      1

这次,不再把看作与商品相关的向量,而是把看作与顾客相关的向量。向量[1,0,1,1,1](矩阵的最后一列)代表了user5的完整购买历史。如果我们查看其他顾客的购买历史向量,可以看到user2的购买历史与user5的购买历史相似。我们还可以看到user3的购买历史与user5的购买历史非常不同——几乎没有重叠。正如我们在实现基于商品的协同过滤时所做的那样,我们可以基于顾客的购买历史来计算顾客之间的相似度:

user2=interaction.loc[:,'user2']
user5=interaction.loc[:,'user5']
print(cosine_similarity(user2,user5))

这个代码片段的输出是0.866,这表示user2user5的余弦相似度相对较高(记住,越接近 1,两个向量就越相似)。我们可以通过对这个片段进行小的调整,来改变我们计算相似度的用户:

user3=interaction.loc[:,'user3']
user5=interaction.loc[:,'user5']
print(cosine_similarity(user3,user5))

在这里,我们发现user3user5的余弦相似度是 0.3536,说明它们的相似度相对较低,正如预期的那样。

我们还可以创建一个函数,用来计算与给定顾客最相似的顾客:

def get_similar_users(interaction,username):
    othercolumns=[columnname for columnname in interaction.columns if columnname!=username]
    otherusers=interaction[othercolumns]
    theuser=list(interaction[username])
    similarities=[]
    for users in otherusers.columns:
        similarities.append(cosine_similarity(theuser,list(otherusers.loc[:,users])))
    otherusers.loc['similarities',:]=similarities
    return list(otherusers.sort_values(by='similarities',axis=1,ascending=False).columns)

这个函数以顾客名称和交互矩阵作为输入。它计算输入顾客与交互矩阵中所有其他顾客的相似度。最终输出是一个顾客的排名列表,按与输入顾客的相似度从最相似到最不相似排序。

我们可以通过多种方式使用这个函数来获取推荐。以下是获取user5推荐的一种方式:

  1. 计算每个用户与user5的相似度。

  2. 将顾客按与user5的相似度从高到低进行排序。

  3. 找出与user5最相似的顾客。

  4. 推荐最相似的顾客所购买的,但user5没有购买的所有商品。

我们可以编写实现这个算法的代码如下:

def get_user_recommendations(interaction,username):
    similar_users=get_similar_users(interaction,username)
    purchase_history=interaction[similar_users[0]]
    purchased=list(purchase_history.loc[purchase_history==1].index)
 purchased2=list(interaction.loc[interaction[username]==1,:].index)
    recs=sorted(list(set(purchased) - set(purchased2)))
    return(recs)

在这个代码片段中,我们有一个函数,它以交互矩阵和用户名作为输入。该函数找到与输入用户最相似的用户,并将该用户的购买历史存储在名为purchase_history的变量中。接下来,它找出最相似的用户购买的所有商品(存储在变量purchased中)和输入用户购买的所有商品(存储在变量purchased2中)。然后,它找出最相似的用户购买的,但输入用户没有购买的商品。它通过使用set()函数来实现这一点。set()函数创建一个列表中唯一元素的集合。所以,当你运行set(purchased) - set(purchased2)时,你会得到purchased中那些不属于purchased2的唯一元素。最后,它将这些元素的列表作为最终推荐返回。

你可以简单地运行此函数,执行get_user_recommendations(interaction,'user2')。你应该会看到以下输出:

['item4']

在这种情况下,item4是我们的推荐,因为它是由与user2最相似的user5购买的,而且user2还没有购买它。我们已经创建了一个执行基于用户的协同过滤的函数!

你可以对这个函数做出许多调整。例如,你可能希望获得比只看一个相似客户更多的推荐。如果是这样,你可以查看比仅一个更相似的客户。你还可以添加基于项目的相似度计算,这样你就只会推荐那些由相似用户购买且也与焦点用户已经购买的项目相似的项目。

确保你理解基于用户和基于项目的协同过滤之间的相似性和差异是值得的。两者都依赖于余弦相似度计算,并且都依赖于交互矩阵作为输入。在基于项目的协同过滤中,我们计算项目之间的余弦相似度,并推荐与感兴趣的项目相似的其他项目。在基于用户的协同过滤中,我们计算用户之间的余弦相似度,并推荐来自相似用户购买历史中的项目。两者都能产生不错的推荐。

为了确定哪种方法适合你的业务,你可以同时尝试两种方法,看看哪一种能带来更好的结果:无论是更多的收入、更多的利润、更多的满意客户、更多的客户参与,还是任何你希望最大化的指标。进行这种实验比较的最佳方式是 A/B 测试,你在第四章中已经学过了。

基于用户的协同过滤以比基于项目的协同过滤提供更多惊喜结果而闻名。然而,它也通常计算上更加复杂。大多数零售商的客户数通常比项目数多,因此基于用户的协同过滤通常需要比基于项目的协同过滤更多的计算。

到目前为止,我们一直在使用一个不切实际的小型完全虚构的数据集。将我们迄今为止讲解的想法应用于来自真实业务的数据会更有益,真实的用户和他们的真实交互历史。在下一节中,我们将进行这样的操作:我们将通过一个案例研究,生成针对真实用户和他们可能感兴趣的真实项目的推荐。

案例研究:音乐推荐

我们将使用来自 Last.fm 的数据(last.fm)。这个网站允许人们登录并听音乐。在这个案例中,我们交互矩阵中的“项目”将是音乐艺术家,而交互矩阵中的 1 表示用户听过某个艺术家的音乐,而不是代表购买。尽管存在这些小的差异,我们仍然可以使用本章中讨论的所有方法来推荐用户应该接下来听的音乐。

让我们查看一些与 Last.fm 用户相关的数据并进行分析:

import pandas as pd
lastfm = pd.read_csv("https://bradfordtuckfield.com/lastfm-matrix-germany.csv")
print(lastfm.head())

和往常一样,我们导入了 pandas 包,读取我们的.csv文件,并将数据存储在lastfm变量中。当我们打印出数据的前几行时,看到如下输出:

 user  a perfect circle  abba  ...  underoath  volbeat  yann tiersen
0     1                 0     0  ...          0        0             0
1    33                 0     0  ...          0        0             0
2    42                 0     0  ...          0        0             0
3    51                 0     0  ...          0        0             0
4    62                 0     0  ...          0        0             0

在这些数据中,每一行代表一个独特的(匿名)用户。每一列代表一个音乐艺术家。矩阵中的条目可以像我们之前的交互矩阵中的条目一样进行解释:每个等于1的条目表示某个用户听过某个艺术家的音乐,每个等于0的条目表示用户没有听过该艺术家的音乐。在这种情况下,我们可以谈论一个用户或一个项目的听歌历史,而不是购买历史。不管怎样,这个矩阵中的条目展示了用户和项目之间的交互历史。我们不需要第一列(用户 ID),所以我们可以删除它:

lastfm.drop(['user'],axis=1,inplace=True)

在继续之前,注意这个交互矩阵和之前那个矩阵的区别。在我们之前的交互矩阵中,行对应的是项目,列对应的是用户。这个交互矩阵是反转的:行对应的是用户,列对应的是项目(歌曲)。我们编写的函数是针对具有前一种形状(行是项目,列是用户)的交互矩阵来工作的。为了确保我们的交互矩阵可以与我们的函数兼容,我们应该转置它,或者将它的行作为列,列作为行:

lastfmt=lastfm.T

这段代码使用了我们的矩阵的T属性来转置我们的交互矩阵,并将结果存储在lastfmt变量中。让我们查看一下数据的行数和列数:

print(lastfmt.shape)

输出是(285,1257):数据有 285 行和 1,257 列。所以,我们在查看 1,257 个真实用户和 285 个真实艺术家的信息,这些用户听过这些艺术家的音乐。相比我们之前的虚构数据,这要更为详实。现在让我们为这些用户生成推荐。只需要调用我们在本章之前已经创建的一个函数:

get_item_recommendations(lastfmt,'abba')[0:10]

你将看到如下输出:

['madonna', 'robbie williams', 'elvis presley', 'michael jackson', 'queen',
'the beatles', 'kelly clarkson', 'groove coverage', 'duffy', 'mika']

对于那些对 ABBA 音乐感兴趣的人,推荐了通过基于项目的协同过滤方法选择的这些艺术家。它们是按相关性从高到低排序的。记住,这些艺术家是基于相似的购买历史选择的:在所有艺术家中,麦当娜的听歌历史与 ABBA 最相似,而罗比·威廉姆斯的听歌历史是第二相似的,以此类推。

这就是所需要做的;我们可以为任何我们感兴趣的艺术家调用推荐函数。从虚构数据到真实数据的过渡非常简单。我们还可以调用我们的用户推荐函数:

print(get_user_recommendations(lastfmt,0)[0:3])

输出显示了给第一个用户(数据集中索引为 0 的用户)提供的三个推荐:

['billy talent', 'bob marley', 'die toten hosen']

这些推荐是通过基于用户的协同过滤获得的。记住这意味着什么:我们的代码找到了一个与第一个用户的听歌历史最相似的用户。最终的推荐是最相似用户听过的,但焦点用户尚未听过的艺术家。

使用高级系统生成推荐

协同过滤是构建推荐系统最常见的方式,但它不是唯一的。还有几种其他技术可以生成智能推荐。一种方法叫做奇异值分解,它依赖于矩阵代数将互动矩阵分解为几个较小的矩阵。这些较小的矩阵可以通过多种方式相乘,从而预测哪些产品会吸引哪些顾客。奇异值分解是使用线性代数来预测顾客偏好的几种方法之一。另一种这样的线性代数方法叫做交替最小二乘法

我们在第七章讨论的聚类方法也可以用于生成推荐系统。这些基于聚类的推荐系统采用以下类似的方法:

  1. 生成用户集群。

  2. 在每个用户集群中找到最受欢迎的项目。

  3. 推荐那些流行的项目,但仅限于每个集群内部。

这种方法与我们在本章开头讨论的基于流行度的推荐系统相同,但有一个改进:我们查看相似顾客群体内的流行度,而不是全局流行度。

其他推荐系统依赖于内容分析。例如,为了在音乐流媒体服务中推荐歌曲,你可以下载一份歌曲歌词的数据库。你可以使用一些自然语言处理工具来衡量不同歌曲歌词之间的相似性。如果用户听过 X 歌曲,你可以推荐那些与 X 歌曲歌词最相似的歌曲。这是一个基于项目的推荐系统,但它使用项目属性而不是购买历史来找到相关的推荐。像这样的基于属性的系统(也叫做内容推荐系统)在某些情况下可以有效工作。今天实现推荐系统的许多公司收集各种各样的数据作为输入,并使用包括神经网络在内的各种预测方法来预测每个用户喜欢什么。基于内容的方法的问题是,它可能很难获得可靠且在各项目之间具有可比性的属性数据。

属性数据并不是唯一可以添加到推荐系统中的数据类型。在推荐系统中使用日期也可能非常有价值。在基于流行度的系统中,日期或时间戳可以让你将所有时间最受欢迎的列表替换为今天最受欢迎的列表,或者展示最近一小时、一周或任何其他时间段的趋势内容。

你可能还需要构建包含非 0/1 矩阵的推荐系统。例如,你可以拥有一个交互矩阵,其条目表示某首歌曲播放的次数,而不是用 0/1 来指示歌曲是否被播放。你还可能会遇到一个包含评分而非交互的矩阵。在本章中实现的相同方法也可以应用于这些替代类型的交互矩阵:你仍然可以计算余弦相似度,并基于最相似的项目和用户进行推荐。

推荐系统的世界很大。这里有空间让你发挥创造力并尝试新的方法,在探索改进这一领域的过程中可以敞开思维。

摘要

在本章中,我们讨论了推荐系统。我们从基于流行度的系统开始,展示如何推荐流行项目和畅销书。接着介绍了协同过滤,包括如何衡量项目和顾客的相似性,以及如何利用相似性进行基于项目和用户的推荐。我们展示了一个案例研究,使用我们的协同过滤代码获取与音乐流媒体服务相关的推荐。最后,我们讨论了一些高级考量,包括其他可使用的方法和可以利用的其他数据。

接下来,我们将讨论一些用于文本分析的高级自然语言处理方法。

第十章:自然语言处理

找到数学化分析文本数据的方法是自然语言处理(NLP)领域的主要目标。在本章中,我们将回顾一些 NLP 世界中的重要思想,并讨论如何在数据科学项目中使用 NLP 工具。

我们将从介绍一个商业场景开始,并思考 NLP 如何能够帮助解决这个问题。我们将使用 word2vec 模型,它可以将单个单词转换为数字,从而使得进行各种强大的分析成为可能。我们将展示这个转换的 Python 代码,然后探索它的一些应用。接下来,我们将讨论通用句子编码器(USE),这是一种可以将整个句子转换为数字向量的工具。我们将回顾设置和使用 USE 的 Python 代码。在这个过程中,我们将找到使用前几章中的思想的方法。让我们开始吧!

使用 NLP 检测抄袭

假设你是一个文学代理机构的负责人。你的机构每天收到数百封电子邮件,每封邮件都包含来自有抱负的作者的书籍章节。这些章节可能相当长,每篇包含数千到数万字,而你的机构需要仔细筛选这些长篇章,试图找到一些值得接受的章节。代理人筛选这些提交的邮件所花费的时间越长,他们就越没有时间去处理其他重要任务,比如向出版社推销书籍。虽然困难,但文学代理机构确实有可能自动化其中的一些筛选工作。例如,你可以编写一个 Python 脚本,自动检测抄袭。

文学代理机构并不是唯一可能对抄袭检测感兴趣的企业。假设你是一个大型大学的校长。每年,你的学生会提交成千上万篇长论文,你希望确保这些论文没有抄袭。抄袭不仅是一个道德和教育问题,也是一个商业问题。如果你的大学因容忍抄袭而声名狼藉,毕业生将面临更差的就业前景,校友捐赠将下降,越来越少的学生愿意报名,大学的收入和利润无疑会急剧下降。你的大学教授和评分人员已经非常辛苦,因此你希望节省他们的时间,寻找一种自动化的抄袭检测方法。

一个简单的抄袭检测器可能会寻找完全匹配的文本。例如,你所在大学的某个学生可能在他们的论文中提交了以下几句话:

人们的一生确实会在死前在眼前一闪而过。这个过程被称为“生活”。

也许你读了这篇论文,感觉这个想法很熟悉,于是你请图书管理员在他们的图书数据库中搜索这段文字。他们找到了 Terry Pratchett 的经典作品 The Last Continent 中每个字符的精确匹配,表明了抄袭行为;学生因此受到惩罚。

其他学生可能更狡猾。他们不是直接从出版书籍中抄袭文本,而是学会了改写,这样他们可以通过对措辞做一些微小、不重要的改变来复制想法。例如,有一个学生可能想要抄袭以下文字(同样来自 Pratchett):

拥有开放心态的麻烦,当然在于,人们会坚持过来并试图把东西放进你的脑袋里。

这位学生稍微改写了句子,变成了如下所示:

拥有开放心态的问题在于,人们会坚持不懈地试图将东西插入你的脑海。

如果你的图书管理员搜索这个学生句子的精确匹配,他们是找不到任何结果的,因为学生稍微改写了句子。为了抓住像这样的聪明抄袭者,你需要依赖能检测到不仅仅是精确文本匹配,还能基于相似词语和句子的意义检测到“松散”或“模糊”匹配的 NLP 工具。例如,我们需要一种方法来识别 troubleproblem 是在学生改写中作为近义词使用的相似词。通过识别同义词和近义词,我们就能确定哪些不完全相同的句子彼此足够相似,可以作为抄袭证据。我们将使用一个名为 word2vec 的 NLP 模型来完成这一任务。

理解 word2vec 自然语言处理模型

我们需要一种方法,能够精确量化任意两个词语之间的相似性。让我们思考一下,两个词语相似意味着什么。考虑 swordknife 这两个词。它们的字母完全不同,没有重叠,但这两个词所指的事物是相似的:它们都是用来切东西的锋利金属物体。这两个词不是完全的同义词,但它们的意义非常相似。我们人类拥有一生的经验,赋予我们直观的感觉来判断这两个词的相似性,但我们的计算机程序不能依赖直觉,所以我们必须找到一种方法,基于数据来量化这些词语的相似性。

我们将使用来自大量自然语言文本的数据,这些文本也被称为 语料库。语料库可以是书籍、报纸文章、研究论文、戏剧或博客文章的集合,或者这些的混合。重要的一点是,它由 自然语言 组成——由人类组合的短语和句子,反映了人类的说话和写作方式。一旦我们拥有了自然语言语料库,我们就可以研究如何利用它来量化词语的意义。

量化词语之间的相似性

让我们从看一些自然语言句子开始,思考其中的单词。想象一下可能包含的两个句子:

  1. 韦斯利用剑攻击我,割破了我的皮肤。

  2. 韦斯利用刀攻击我,割破了我的皮肤。

你可以看到这些句子几乎是相同的,除了细节上攻击者使用了剑还是刀。将一个词替换为另一个词后,它们的意思依然相似。这是一个表明相似的迹象:它们可以在许多句子中互相替换,而不会大幅改变句子的意思或含义。当然,也有可能使用其他物品进行攻击,因此像下面这样的句子也可能出现在语料库中:

  1. 韦斯利用鲱鱼攻击我,割破了我的皮肤。

尽管关于用鲱鱼进行皮肤刺穿攻击的句子从技术上讲是可能的,但它在任何自然语言语料库中出现的可能性远低于关于剑或刀攻击的句子。一个不懂英语的人,或者一个 Python 脚本,能够通过查看我们的语料库并注意到攻击这个词经常出现在这个词附近,而不常出现在鲱鱼这个词附近,来找到这一证据。

注意哪些词汇经常出现在其他词汇附近将对我们非常有用,因为我们可以利用一个词的邻居更好地理解这个词。看看表 10-1,它展示了经常出现在鲱鱼附近的词汇。

表 10-1:自然语言中经常出现在彼此附近的词汇

词汇 自然语言语料库中常常出现在附近的词汇
切割,攻击,鞘,战斗,锋利,钢铁
切割,攻击,馅饼,战斗,锋利,钢铁
鲱鱼 腌制的,海洋,鱼片,馅饼,银色,切割

剑和刀都通常是锋利的,由钢铁制成,用来攻击,用来切割东西和战斗,因此我们可以在表 10-1 中看到,这些词汇经常出现在附近。然而,我们也可以看到这些邻近词汇列表之间的不同。例如,经常出现在附近,而则不常出现在附近。而且,经常出现在馅饼附近,但通常不会。至于鲱鱼,它有时出现在馅饼附近(因为人们有时吃鲱鱼馅饼),也有时出现在切割附近(因为人们有时在准备食物时切割鲱鱼)。但是,鲱鱼附近的其他词汇与附近的词汇没有重叠。

表 10-1 很有用,因为我们可以通过它来理解和表达两个单词的相似性,使用数据而不是直觉反应。我们可以说 是相似的,不仅仅是因为我们凭直觉觉得它们的意思相近,而是因为它们通常会出现在相同的邻近词附近。相比之下,鲱鱼 的相似性就比较小,因为它们在自然语言文本中的常见邻近词几乎没有重叠。表 10-1 给我们提供了一种基于数据的方法来判断单词是否相似,而不是基于模糊的直觉,而且,重要的是,即使是一个不会英语的人也能创建和解读表 10-1,因为即使是不懂英语的人也可以查看文本并找出哪些单词常常是邻近词。这个表也可以通过一个读取任何语料库并找到共同邻近词的 Python 脚本来生成。

我们的目标是将单词转化为数字,因此我们的下一步是创建一个表 10-1 的版本,该版本包含单词作为彼此邻近词的概率数值,如表 10-2 所示。

表 10-2:单词在自然语言语料库中共同出现的概率

单词 邻近词 邻近词在自然语言语料库中与该单词共同出现的概率
切割 61%
切割 69%
鲱鱼 切割 12%
1%
49%
鲱鱼 16%
56%
16%
鲱鱼 2%

表 10-2 提供的信息与表 10-1 大致相同;它展示了哪些单词有可能出现在自然语言语料库中的其他单词附近。但是表 10-2 更为精确;它给出了单词共同出现的概率数值,而不仅仅是邻近词的列表。再次可以看到,表 10-2 中的百分比似乎是合理的: 常常以 切割 作为邻近词,鲱鱼 更有可能以 作为邻近词,而 鲱鱼 很少以 作为邻近词。同样,表 10-2 可以由一个不会英语的人创建,也可以由一个有着书籍或英文文本的 Python 脚本生成。同样,甚至是不懂英语的人,或者一个 Python 脚本,也能查看表 10-2 并清楚了解不同单词之间的相似性与差异性。

创建方程组

我们几乎准备好将单词完全表示为数字了。下一步是创建一个比表 10-2 更具数字化的东西。我们不再使用表格表示这些百分比的可能性,而是尝试将它们表示为一个方程组。我们只需要几个方程就能简洁地表示表 10-2 中的所有信息。

让我们从一个算术事实开始。这个算术事实可能看起来毫无用处,但你稍后会看到它为什么有用:

61 = 5 · 10 – 5 · 1 + 3 · 5 + 1 · 1

你可以看到,这是数字 61 的一个方程——这正是根据表 10-2,单词cut出现在单词sword附近的概率。我们还可以通过使用不同的符号来重写方程的右边:

61 = (5, –5, 3, 1) · (10, 1, 5, 1)

这里,点(·)代表的是点积,这是我们在第九章引入的概念。在计算点积时,我们将两个向量的第一个元素相乘,将第二个元素相乘,依此类推,并将结果相加。我们可以使用乘法和加法来写出这个点积的更标准的方程式,形式如下:

61 = 5 · 10 + (–5) · 1 + 3 · 5 + 1 · 1

你可以看到,这与我们开始时的方程完全相同。5 和 10 相乘,因为它们分别是第一个和第二个向量的第一个元素。数字–5 和 1 也相乘,因为它们分别是第一个和第二个向量的第二个元素。当我们进行点积时,我们将所有这些对应的元素相乘并加总结果。让我们用这种点积风格再写一个算术事实:

12 = (5, –5, 3, 1) · (2, 2, 2, 6)

这只是另一个算术事实,使用点积表示法。但请注意,这是一个关于 12 的方程——正是根据表 10-2,单词cut出现在单词herring附近的概率。我们还可以注意到,方程中的第一个向量(5, –5, 3, 1)与前一个方程中的第一个向量完全相同。现在,我们有了这两个算术事实,可以再次将它们重写为一个简单的方程组:

sword = (10, 1, 5, 1)

herring = (2, 2 ,2, 6)

cut出现在某个单词附近的概率 = (5, –5, 3, 1) · 该单词的向量

在这里,我们迈出了重要的一步:我们不仅仅写下算术事实,而是声称我们拥有代表单词swordherring的数字向量,并且我们声称可以使用这些向量来计算单词cut与任何单词靠近的概率。也许这看起来是一个大胆的假设,但很快你会看到它为何是合理的。现在,我们可以继续,并写下更多的算术事实,如下所示:

60 = (5, –5, 3, 1) · (10, 1, 5, 9)

1 = (1, –10, –1, 6) · (10, 1, 5, 1)

49 = (1, –10, –1, 6) · (2, 2, 2, 6)

16 = (1, –10, –1, 6) · (10, 1, 5, 9)

56 = (1, 6, 9, –5) · (10, 1, 5, 1)

16 = (1, 6, 9, –5) · (2, 2, 2, 6)

2 = (1, 6, 9, –5) · (10, 1, 5, 9)

你可以把这些看作是任意的算术事实。但我们也可以将它们与表 10-2 连接起来。事实上,我们可以将目前所有的算术事实重写成方程 10-1 所示的系统。

剑 = (10, 1, 5, 1)

刀 = (10, 1, 5, 9)

鲱鱼 = (2, 2, 2, 6)

切割出现在某个单词附近的概率 = (5, –5, 3, 1) · 该单词的向量

出现在某个单词附近的概率 = (1, –10, –1, 6) · 该单词的向量

出现在某个单词附近的概率 = (1, 6, 9, –5) · 该单词的向量

方程 10-1:包含单词向量表示的方程组

从数学上讲,你可以验证方程 10-1 中的所有方程都是正确的:通过将单词向量代入方程,我们可以计算出表 10-2 中的所有概率。你可能会想知道我们为什么创建了这个方程组。它似乎不过是在以更复杂的方式用更多的向量重复我们在表 10-2 中已经拥有的概率而已。我们在这里迈出的重要一步是,通过创建这些向量和这个方程组,而不是直接使用表 10-2,我们找到了每个单词的数值表示。向量 (10, 1, 5, 1) 在某种意义上“捕捉了的含义”,同样,(10, 1, 5, 9) 捕捉了的含义,(2, 2, 2, 6) 捕捉了鲱鱼的含义。

尽管我们已经有了每个单词的向量,但你可能并不完全相信这些向量真的能代表英语单词的含义。为了帮助你更有信心,我们可以用这些向量做一些简单的计算,看看能学到什么。首先,让我们在 Python 会话中定义这些向量:

sword = [10,1,5,1]
knife = [10,1,5,9]
herring = [2,2,2,6]

在这里,我们将每个单词向量定义为 Python 列表,这是在 Python 中处理向量的标准方式。我们关注的是了解我们的单词之间有多相似,因此让我们定义一个函数来计算任意两个向量之间的距离:

import numpy as np
def euclidean(vec1,vec2):
    distance=np.array(vec1)-np.array(vec2)
    squared_sum=np.sum(distance**2)
    return np.sqrt(squared_sum)

这个函数叫做euclidean(),因为它本质上是在计算任意两个向量之间的欧几里得距离。在二维空间中,欧几里得距离是直角三角形的斜边长度,我们可以用毕达哥拉斯定理来计算。更非正式地,我们通常将欧几里得距离称作距离。在超过二维的空间中,我们使用相同的毕达哥拉斯定理公式来计算欧几里得距离,唯一的区别是它更难画出来。计算向量之间的欧几里得距离是一种合理的方式来计算两个向量的相似度:欧几里得距离越小,向量之间的相似度越高。让我们计算一下这些单词向量之间的欧几里得距离:

print(euclidean(sword,knife))
print(euclidean(sword,herring))
print(euclidean(knife,herring))

你应该能够看到 swordknife 之间的距离是 8。而 swordherring 之间的距离则是 9.9。这些距离测量反映了我们对这些单词的理解:swordknife 彼此相似,因此它们的向量很接近,而 swordherring 彼此不太相似,所以它们的向量相距较远。这证明了我们将单词转换为数值向量的方法是有效的:它让我们成功地量化了单词之间的相似度。

如果我们想要检测抄袭,我们需要找到能够代表的不仅仅是这三个词的数值向量。我们需要为英语语言中的每个单词,或者至少为那些经常出现在学生论文中的大多数单词找到向量。我们可以想象这些向量可能是什么。例如,haddock 是一种鱼类,跟鲱鱼差不多。因此,我们会预期 haddock 的邻近词与 herring 相似,并且在 表 10-2 中的概率也会相似(具有类似的邻居概率,像是 cutpie 或其他任何词)。

每当两个单词在 表 10-2 中有相似的概率时,我们预期它们会有相似的向量,因为我们会根据 方程 10-1 中的方程系统,将这些向量相乘,以得到这些概率。例如,我们可能会发现 haddock 的数值向量类似于 (2.1, 1.9, 2.3, 6.5)。这个向量在欧几里得距离上将接近 herring 的向量 (2, 2, 2, 6),如果我们将 haddock 向量与 方程 10-1 中的其他向量相乘,我们会发现 haddock 与每个邻近词的概率应该与 herring 在 表 10-2 中的概率相似。同样,我们还需要为英语中成千上万的其他单词找到向量,我们预期具有相似含义的单词应该有相似的向量。

说我们需要为每个英语单词找到向量很容易,但接下来问题就来了:我们应该如何确定这些向量呢?要理解如何为每个单词确定向量,看看 图 10-1 中的方程系统示意图。

图 10-1:单词彼此接近的概率的可视化表示

这个图看起来很复杂,但它的目的是为了说明我们的方程组。 在方程式 10-1 中,我们将每个单词表示为一个四个元素的向量,像这样:(abcd)。在图 10-1 左侧的 abcd 表示这些元素。从这些元素延伸出的每个箭头表示乘法。例如,从标记为 a 的圆圈到标记为 切割概率附近出现 的椭圆的箭头上标有 5,表示我们应该将每个 a 的值乘以 5,并加到估算的 cut 在一个单词附近出现的概率中。如果我们考虑所有这些箭头所表示的乘法,你可以从图 10-1 中看到,cut 在一个单词附近出现的概率是 5 · a – 5 · b + 3 · c + 1 · d,这正如方程式 10-1 中描述的那样。图 10-1 只是另一种表示方程式 10-1 的方式。如果我们能够找到每个英语单词的正确 abcd 值,我们就能得到用于检查抄袭所需的单词向量。

我们画图 10-1 的原因是要指出,它恰好呈现出一个神经网络的形式,这是我们在第六章已经讨论过的有监督学习模型的类型。由于它构成了一个神经网络,我们可以使用先进的软件(包括几个免费的 Python 包)来训练这个神经网络,找出每个英语单词的 abcd 应该是多少。创建表格 10-1 和表格 10-2,以及方程式 10-1 中的方程组的全部目的,是为了创建一个像图 10-1 所示的假设性神经网络。只要我们拥有像表格 10-2 中那样的数据,我们就可以使用这个神经网络软件来训练图 10-1 中所示的神经网络,并找到我们需要的所有单词向量。

这个神经网络训练的最重要输出将是我们数据中每个单词的 abcd 值。换句话说,神经网络训练的输出将是每个单词的 (abcd) 向量。这个过程被称为 word2vec 模型:为语料库中的每个单词创建像表格 10-2 那样的概率表,使用该表建立一个像图 10-1 所示的神经网络,然后训练该神经网络来找到表示每个单词的数字向量。

word2vec 模型之所以流行,是因为它可以为任何单词创建数字向量,这些向量可以用于许多有用的应用程序。word2vec 流行的一个原因是,我们可以仅使用原始文本作为输入来训练 word2vec 模型;在训练模型并获取词向量之前,我们不需要对任何单词进行注释或标记。因此,即使是一个不懂英语的人,也可以通过使用 word2vec 方法创建词向量并对其进行推理。

如果这听起来很复杂,别担心。接下来,我们将通过代码来处理这些类型的向量,你会看到,尽管 word2vec 的理论和概念很复杂,但代码和应用可以是直接且简单的。目前,尽量让自己对我们到目前为止讨论的基本概念感到舒适:如果我们创建了关于哪些单词在自然语言中彼此接近的数据,我们就可以利用这些数据创建向量,从而量化任何一对单词的相似性。接下来,让我们继续用一些代码,看看如何使用这些数字向量来检测抄袭。

分析 word2vec 中的数字向量

不仅有人已经创建了 word2vec 模型,而且他们还为我们做了所有的繁重工作:编写代码、计算向量,并将所有这些内容发布到网上,供我们随时免费下载。在 Python 中,我们可以使用 Gensim 包来访问许多英语单词的词向量:

import gensim.downloader as api

Gensim 包有一个downloader,允许我们仅通过使用load()方法来访问许多 NLP 模型和工具。你可以用以下一行代码加载一个词向量集合:

vectors = api.load('word2vec-google-news-300')

这段代码加载了一个由约 1000 亿个单词组成的新闻语料库创建的词向量集合。本质上,某人获取了类似于我们表 10-2 中的信息,但其中包含更多的单词。他们从由人类编写的真实新闻来源中获取了这些单词和概率。然后,他们利用这些信息创建了一个类似于图 10-1 中的神经网络——同样,其中包含更多的单词。他们训练了这个神经网络,并为语料库中的每个单词找到了对应的向量。

我们刚刚下载了他们计算的向量。我们预期这些向量会有用的一个原因是,用于创建这些向量的文本语料库既庞大又多样化,而庞大且多样化的文本数据源往往能提高 NLP 模型的准确性。我们可以按如下方式查看对应于任何单词的向量:

print(vectors['sword'])

在这里,我们打印出单词sword的向量。你将看到的输出是一个包含 512 个数字元素的向量。像这样的词向量,表示我们下载的模型中的单词sword,也被称为嵌入向量,因为我们已经成功地将一个单词嵌入向量空间中。简而言之,我们已经将一个单词转换成了一个数字向量。

你会注意到,sword的这个 512 维向量与我们在本章之前使用的sword的向量(10, 1, 5, 1)不同。这个向量与我们之前的向量不同有几个原因。首先,这个向量使用了与我们不同的语料库,因此在它的表 10-2 版本中会列出不同的概率。其次,这个模型的创建者决定使用 512 维的向量,而不是我们使用的 4 维向量,因此它们得到了更多的向量元素。第三,他们的向量调整为接近 0 的值,而我们的向量没有。每个语料库和每个神经网络都会导致略有不同的结果,但如果我们使用的是一个好的语料库和一个好的神经网络,我们期望得到相同的定性结果:这些向量代表了单词,并使我们能够检测抄袭(以及执行许多其他任务)。

下载这些向量后,我们可以像处理任何其他 Python 对象一样使用它们。例如,我们可以计算单词向量之间的距离,方法如下:

print(euclidean(vectors['sword'],vectors['knife']))
print(euclidean(vectors['sword'],vectors['herring']))
print(euclidean(vectors['car'],vectors['van']))

在这里,我们进行了与之前相同的欧几里得距离计算,但这次我们使用的是我们下载的向量,而不是方程 10-1 中的向量。当你运行这些比较时,你会看到以下输出:

>>> **print(euclidean(vectors['sword'],vectors['knife']))**
3.2766972
>>> **print(euclidean(vectors['sword'],vectors['herring']))**
4.9384727
>>> **print(euclidean(vectors['car'],vectors['van']))**
2.608656

你可以看到这些距离是合理的:sword的向量与knife的向量相似(它们之间的距离约为 3.28),但与herring的向量不同(它们之间的距离约为 4.94,比swordknife之间的差异要大得多)。你也可以对语料库中的其他词对(如carvan)进行相同的计算。你可以比较词对之间的差异,找出哪些词对的意思最相似或最不相似。

欧几里得距离并不是唯一用于比较单词向量的距离度量。使用余弦相似度度量也是常见的,就像我们在第九章中做的那样。记住,我们用这段代码来计算余弦相似度:

def dot_product(vector1,vector2):
    thedotproduct=np.sum([vector1[k]*vector2[k] for k in range(0,len(vector1))])
    return(thedotproduct)

def vector_norm(vector):
    thenorm=np.sqrt(dot_product(vector,vector))
    return(thenorm)

def cosine_similarity(vector1,vector2):
    thecosine=0
    thedotproduct=dot_product(vector1,vector2)
    thecosine=thedotproduct/(vector_norm(vector1)*vector_norm(vector2))
    thecosine=np.round(thecosine,4)
    return(thecosine)

这里我们定义了一个名为cosine_similarity()的函数,用来检查任意两个向量之间的角度余弦。我们可以如下检查一些向量之间的余弦相似度:

print(cosine_similarity(vectors['sword'],vectors['knife']))
print(cosine_similarity(vectors['sword'],vectors['herring']))
print(cosine_similarity(vectors['car'],vectors['van']))

当你运行这段代码时,你会看到以下结果:

>>> **print(cosine_similarity(vectors['sword'],vectors['knife']))**
0.5576
>>> **print(cosine_similarity(vectors['sword'],vectors['herring']))**
0.0529
>>> **print(cosine_similarity(vectors['car'],vectors['van']))**
0.6116

你可以看到这些度量正是我们想要的:它们对我们认为不同的词给出了较低的值,而对我们认为相似的词给出了较高的值。尽管你的笔记本电脑不会“说英语”,但通过分析自然语言文本语料库,它能够量化出不同词语之间的相似度和差异度。

使用数学计算操作向量

word2vec 的一个著名例子来自对kingqueen这两个词的分析。为了看到这个例子,我们首先获取一些英语单词的向量:

king = vectors['king']
queen = vectors['queen']
man = vectors['man']
woman = vectors['woman']

在这里,我们定义与几个单词相关的向量。作为人类,我们知道国王是君主制国家的男性领导人,而女王是女性领导人。我们甚至可能将kingqueen之间的关系表示如下:

king – man + woman = queen

king的概念开始,去掉man的概念,再加上woman的概念,最终得到queen的概念。如果我们仅从单词的角度来看,这个方程可能显得荒谬,因为通常情况下无法以这种方式加减单词或概念。然而,请记住,我们拥有这些单词的向量版本,因此我们可以相互加减向量,并看看结果如何。让我们尝试在 Python 中对这些单词对应的向量进行加减运算:

newvector = king-man+woman

在这里,我们取king向量,减去man向量,再加上woman向量,并将结果定义为一个新变量newvector。如果我们的加法和减法按预期进行,newvector应该捕捉到一个特定的含义:去除man属性后,加入woman属性的国王。换句话说,尽管newvector是三个向量的和,它们其中没有一个是queen的向量,但我们期望它们的和接近或等于queen的向量。让我们检查一下newvectorqueen向量之间的差异,看是否符合预期:

print(cosine_similarity(newvector,queen))
print(euclidean(newvector,queen))

我们可以看到我们的newvector与我们的queen向量相似:它们的余弦相似度为 0.76,欧几里得距离为 2.5。我们可以将其与其他熟悉的单词对的差异进行比较:

print(cosine_similarity(vectors['fish'],vectors['herring']))
print(euclidean(vectors['fish'],vectors['herring']))

你应该看到king – man + womanfishherring的相似度更接近queen。我们对单词向量的数学计算得到了完全符合我们对这些单词语言意义的预期结果。这证明了这些向量的有用性:我们不仅可以通过比较它们找到单词对之间的相似性,还可以通过加法和减法操作来添加和减去概念。能够对这些向量进行加减运算,并得到合乎逻辑的结果,进一步证明了这些向量可靠地捕捉了它们所关联单词的含义。

使用 word2vec 检测抄袭

让我们回到本章早些时候的抄袭情境。记得我们介绍了以下两个句子作为抄袭的例子:

  1. 拥有开放心态的问题,当然,是人们会坚持走近并试图将东西放进去。[原句]

  2. 拥有开放心态的问题是,人们会坚持走近并试图将东西塞进你的脑袋。[抄袭句子]

因为这两句话在某些地方有所不同,所以一个只寻找精确匹配的简单抄袭检测工具在这里是无法检测到抄袭的。我们并不希望检查每个字符的精确匹配,而是要检查每个单词的意思是否接近。对于那些完全相同的单词,我们会发现它们之间的距离为 0:

print(cosine_similarity(vectors['the'],vectors['the']))
print(euclidean(vectors['having'],vectors['having']))

这些结果并不令人惊讶。我们发现,一个单词的向量与其自身的余弦相似度为 1.0,而一个单词的向量与其自身的欧几里得距离为 0。每个单词都与自己相等。但请记住,狡猾的抄袭者是在进行释义,而不是使用与已发布文本完全相同的词语。如果我们比较那些经过释义的单词,我们可以预期它们会与原始单词相似,但不会完全相同。我们可以通过以下方式衡量这些潜在释义单词的相似性:

print(cosine_similarity(vectors['trouble'],vectors['problem']))
print(euclidean(vectors['come'],vectors['approach']))
print(cosine_similarity(vectors['put'],vectors['insert']))

你会看到这段代码的以下结果:

>>> **print(cosine_similarity(vectors['trouble'],vectors['problem']))**
0.5327
>>> **print(euclidean(vectors['come'],vectors['approach']))**
2.9844923
>>> **print(cosine_similarity(vectors['put'],vectors['insert']))**
0.3435

这段代码比较了抄袭文本和原文中的单词。结果显示,几乎每种情况下都有接近匹配:要么是相对较小的欧几里得距离,要么是相对较高的余弦相似度。如果学生句子的单个单词与已发表句子的单个单词都能接近匹配,那么即便没有精确匹配,这也是抄袭的有力证据。重要的是,我们可以通过自动化检查这些接近匹配,而不依赖于缓慢且成本高昂的人类判断,而是依赖于数据和快速的 Python 脚本。

检查句子中每个单词的接近匹配是检测抄袭的合理起点。然而,这并不完美。主要问题在于,迄今为止,我们只学会了评估单个单词,而不是完整句子。我们可以将句子看作是单个单词的集合或序列。但是,在许多情况下,最好采用一种能够同时评估整个句子的意义和相似性的技术,将句子视为独立的单元,而不仅仅是单词的集合。为此,我们将转向一种强大的新方法。

使用跳跃思维

跳跃思维模型是一个自然语言处理(NLP)模型,利用数据和神经网络将整个句子转换为数值向量。它与 word2vec 非常相似,但不同之处在于,它不是一次将单个单词转换为向量,而是将整个句子作为单元转换为向量。

跳跃思维的理论与 word2vec 的理论相似:你获取一个自然语言语料库,找出哪些句子往往彼此接近,然后训练一个神经网络,能够预测哪些句子可能出现在其他句子的前面或后面。对于 word2vec,我们在图 10-1 中看到过神经网络模型的示意图。对于跳跃思维,图 10-2 展示了类似的示意图。

图 10-2:我们使用跳跃思维模型来预测哪些句子会彼此接近。

图 10-2 基于 Ryan Kiros 及其同事在 2015 年的论文“Skip-Thought Vectors”中的模型(arxiv.org/pdf/1506.06726.pdf)。你可以看到图左侧的句子被作为输入。这个句子是一个单词序列,但所有单词都被视为一个单元一起考虑。skip-thoughts 模型试图找到这个句子的向量表示,当用作神经网络输入时,可以预测最可能出现在它之前和之后的句子(包括图 10-2 右侧的句子)。就像 word2vec 基于预期哪些单词接近其他单词一样,skip-thoughts 基于预测哪些句子接近其他句子。你不需要太担心理论,只需记住 skip-thoughts 是一种通过计算附近句子出现概率来对自然语言句子进行向量编码的方法。

就像使用 word2vec 一样,我们不需要自己编写任何代码。相反,我们将转向Universal Sentence Encoder (USE)。这个工具将句子转换为向量,使用 skip-thoughts 的思想来找到这些向量(以及其他高级技术方法)。我们将使用 USE 的向量输出进行抄袭检测,但 USE 向量也可以用于聊天机器人实现、图像标记等多种用途。

因为有其他人已经为我们编写了 USE 的代码,所以使用 USE 的代码并不难。我们可以从定义要分析的句子开始:

Sentences = [
    "The trouble with having an open mind, of course, is that people will insist on coming along and trying to put things in it.",\
    "The problem with having an open mind is that people will insist on approaching and trying to insert things into your mind.",\
    "To be or not to be, that is the question",\
    "Call me Ishmael"
]

这里我们有一个句子列表,我们想将每个句子转换为数值向量。我们可以导入其他人编写的代码来进行这种转换:

import tensorflow_hub as hub
embed = hub.load("https://tfhub.dev/google/universal-sentence-encoder-large/5")

tensorfow_hub模块允许我们从在线库中加载 USE。USE 是一个大模型,所以如果将其加载到 Python 会话中需要几分钟甚至更长时间也不必惊慌。加载后,我们将其保存为名为embed的变量。现在 USE 已经在我们的 Python 会话中,我们可以用一行简单的代码创建单词嵌入(向量):

embeddings = embed(Sentences)

我们的embeddings变量包含代表Sentences列表中每个句子的向量。你可以通过检查embeddings变量的第一个元素来查看第一句的向量:

print(embeddings[0])

运行这段代码时,你会看到一个包含 512 个元素的数值向量,其中前 16 个元素如下所示:

>>> **print(embeddings[0])**
tf.Tensor(
[ 9.70209017e-04 -5.99743128e-02 -2.84200953e-03  7.49062840e-03
  7.74949566e-02 -1.00521010e-03 -7.75496066e-02  4.12207991e-02
 -1.55476958e-03 -1.11693323e-01  2.58275736e-02 -1.15299867e-02
 -3.84882478e-05 -4.07184102e-02  3.69430222e-02  6.66357949e-02

这是你列表中第一句的向量表示,不是句子中的单个单词,而是整个句子本身。就像我们用 word2vec 向量做的那样,我们可以计算任意两个句子向量之间的距离。在这种情况下,向量之间的距离将代表两个句子整体意义的差异程度。让我们检查一下第一和第二句之间的距离:

print(cosine_similarity(embeddings[0],embeddings[1]))

我们可以看到,余弦相似度大约为 0.85——这表明我们Sentences列表中的前两个句子非常相似。这是证据,表明学生的句子(我们Sentences列表中的第二个句子)是从普拉特切特的句子(我们Sentences列表中的第一个句子)抄袭的。相反,我们可以检查其他向量之间的距离,发现它们并不像这两个句子那样相似。例如,如果你运行print(cosine_similarity(embeddings[0],embeddings[2])),你会看到这两个句子的余弦相似度约为 0.02,表明这两个句子几乎是完全不同的。这是证据,表明普拉特切特没有抄袭哈姆雷特。如果你运行print(cosine_similarity(embeddings[0],embeddings[3])),你会看到这两个句子的余弦相似度约为-0.07,另一个低相似度分数,表明普拉特切特也没有抄袭白鲸

你可以看到,检查任意两句子之间意义的距离是非常直接的。你的抄袭检测器可以简单地检查学生的作品和以前发布的句子之间的余弦相似度(或欧几里得距离),如果相似度很大(或欧几里得距离过小),你可以认为这是学生抄袭的证据。

主题建模

为了完成这一章,让我们介绍一个最后的商业场景,讨论如何将 NLP 工具与前几章的工具结合使用来处理这个问题。在这个场景中,假设你经营一个论坛网站。你的网站已经取得了巨大成功,以至于你再也无法亲自阅读所有的帖子和对话,但你仍然希望了解人们在你的网站上写的主题,以便了解你的用户是谁,他们关心什么。你希望找到一种可靠的自动化方式,分析你网站上的所有文本,并发现讨论的主要话题。这个目标,称为主题建模,在 NLP 中很常见。

首先,让我们来看一组可能出现在你网站上的句子。一个成功的论坛网站可能每秒钟接收到成千上万的评论,但我们从一个小样本开始,只有八个句子:

Sentences = [
    "The corn and cheese are delicious when they're roasted together",
    "Several of the scenes have rich settings but weak characterization",
    "Consider adding extra seasoning to the pork",
    "The prose was overwrought and pretentious",
    "There are some nice brisket slices on the menu",
    "It would be better to have a chapter to introduce your main plot ideas",
    "Everything was cold when the waiter brought it to the table",
    "You can probably find it at a cheaper price in bookstores"
]

就像你之前做的那样,你可以计算所有这些句子的向量或嵌入:

embeddings = embed(Sentences)

接下来,我们可以创建一个包含所有句子嵌入的矩阵:

arrays=[]
for i in range(len(Sentences)):
    arrays.append(np.array(embeddings[i]))

sentencematrix = np.empty((len(Sentences),512,), order = "F")

for i in range(len(Sentences)):
    sentencematrix[i]=arrays[i]

import pandas as pd
pandasmatrix=pd.DataFrame(sentencematrix)

这段代码首先创建一个名为arrays的列表,并将所有句子向量添加到该列表中。接下来,它创建一个名为sentencematrix的矩阵。这个矩阵只是将你的句子向量堆叠在一起,每个句子向量占一行。最后,我们将这个矩阵转换成一个 pandas 数据框,以便更方便地操作。最终结果,名为pandasmatrix,包含八行;每一行都是我们八个句子的句子向量。

现在我们有一个包含句子向量的矩阵。但仅仅获得我们的向量还不够;我们需要决定如何使用它们。记住,我们的目标是主题建模。我们希望理解人们在写什么主题,以及哪些句子与哪些主题相关。我们有几种方法可以实现这一目标。实现主题建模的一种自然方法是使用聚类,这是我们在第七章中已经讨论过的内容。

我们的聚类方法很简单:我们将句子向量的矩阵作为输入数据。我们将应用聚类来确定数据中存在的自然分组。我们将解释我们找到的这些组(簇)为你论坛网站上正在讨论的不同主题。我们可以按以下方式进行聚类:

from sklearn.cluster import KMeans
m = KMeans(2)
m.fit(pandasmatrix)

pandasmatrix['topic'] = m.labels_

pandasmatrix['sentences']=Sentences

你可以看到,聚类只需要几行代码。我们从 sklearn 模块导入了 KMeans 代码。我们创建了一个叫做 m 的变量,然后使用它的 fit() 方法为我们的矩阵(pandasmatrix)找到两个簇。fit() 方法利用欧几里得距离度量和我们的句子向量来找到两个文档簇。它找到的这两个簇就是我们将视为我们的句子集合的两个主要主题。在找到这些簇之后,我们向 pandasmatrix 添加了两个新列:首先,我们添加了作为聚类结果的标签(在 topic 变量中),其次,我们添加了我们正在尝试聚类的实际句子。让我们看看结果:

print(pandasmatrix.loc[pandasmatrix['topic']==0,'sentences'])
print(pandasmatrix.loc[pandasmatrix['topic']==1,'sentences'])

这个代码片段输出两组句子:首先是标记为属于簇 0 的句子(topic 变量等于 0 的行中的句子),其次是标记为属于簇 1 的句子(topic 变量等于 1 的行中的句子)。你应该看到以下结果:

>>> **print(pandasmatrix.loc[pandasmatrix['topic']==0,'sentences'])**
0    The corn and cheese are delicious when they're...
2          Consider adding extra seasoning to the pork
4       There are some nice brisket slices on the menu
6    Everything was cold when the waiter brought it...
Name: sentences, dtype: object
>>> **print(pandasmatrix.loc[pandasmatrix['topic']==1,'sentences'])**
1    Several of the scenes have rich settings but w...
3            The prose was overwrought and pretentious
5    It would be better to have a chapter to introd...
7    You can probably find it at a cheaper price in...
Name: sentences, dtype: object

你可以看到,这种方法已经在我们的数据中识别出了两个簇,并将其标记为簇 0 和簇 1。当你查看被分类为簇 0 的句子时,你会发现许多似乎是在讨论食物和餐馆。而当你查看簇 1 时,你会看到它似乎由书籍评论组成。至少根据这八个句子的样本,这些是你论坛上讨论的主要话题,并且它们已经被自动识别和组织。

我们已经完成了主题建模,使用了一个数值方法(聚类)来处理非数值数据(自然语言文本)。你可以看到,USE 和词嵌入在许多应用中是非常有用的。

自然语言处理的其他应用

NLP 的一个有用应用是在推荐系统中。假设你经营一个电影网站,并希望向用户推荐电影。在第九章中,我们讨论了如何使用互动矩阵基于交易历史的比较来进行推荐。然而,你也可以基于内容的比较来构建推荐系统。例如,你可以获取每部电影的情节概要,并使用 USE 为每个情节概要获取句子嵌入。然后,你可以计算情节概要之间的距离,确定各种电影的相似度,并在用户观看电影后,向该用户推荐情节最相似的电影。这就是基于内容的推荐系统

另一个有趣的 NLP 应用是情感分析。我们在第六章中稍微接触到过情感分析。某些工具能够判断给定句子的语气或表达的情感是积极、消极还是中立。有些工具依赖于我们在本章中介绍的词嵌入技术,而其他工具则不依赖。情感分析对于每天收到成千上万封电子邮件和消息的企业非常有用。通过对所有收到的电子邮件进行自动情感分析,企业可以判断哪些客户最为满意或不满,并可能根据客户的情感优先回应。

今天许多企业在其网站上部署聊天机器人—一种能够理解文本输入并回答问题的计算机程序。聊天机器人的复杂性不同,但许多都依赖于一些类似于本章所述的 word2vec 和 skip-thought 方法的词嵌入技术。

NLP 在商业中有许多其他潜在的应用。今天,律师事务所正在尝试使用 NLP 自动分析文档,甚至自动生成或至少整理合同。新闻网站也尝试使用 NLP 自动生成某些类型的格式化文章,例如体育赛事的回顾。随着 NLP 领域的不断发展,应用的可能性是无穷无尽的。如果你掌握了像 word2vec 和 skip-thoughts 这样强大的方法作为起点,创建有用的应用是没有限制的。

摘要

本章中,我们讨论了自然语言处理。我们讨论的所有 NLP 应用都依赖于嵌入技术:能够准确表示单词和句子的数值向量。如果你能够将一个词用数字表示,就可以对它进行数学运算,包括计算相似度(我们在抄袭检测中做过)和聚类(我们在主题建模中做过)。NLP 工具可能很难使用和掌握,但它们的能力可以让人惊叹。

在下一章,我们将转变方向,通过讨论一些与数据科学相关的其他编程语言的简单概念来结束本书。

第十一章:数据科学在其他语言中的应用

到目前为止,我们的所有商业解决方案都有一个共同点:它们只使用了 Python。Python 是数据科学领域的标准语言,但它并不是唯一的语言。最优秀的数据科学家是多才多艺的,能够使用多种语言编写代码。本章简要介绍了结构化查询语言(SQL)和 R 语言,这两种常见语言是每个优秀数据科学家都应该掌握的。本章并不是对这两种语言的全面概述,而是一个基本介绍,帮助你识别和编写几行 SQL 或 R 代码。

本章将从介绍一个商业场景开始。接着,我们将介绍一些简单的 SQL 代码,来创建数据库并操作其中的数据。接下来,我们将讨论 R 语言以及如何使用它执行简单的操作和线性回归。你将学习如何在 Python 会话中运行 SQL 和 R 命令,而不是花费大量精力配置环境来运行 SQL 和 R 命令。

用 SQL 赢得足球比赛

想象一下,你收到了一份作为欧洲足球队经理的工作邀请。这可能看起来更像是一个体育场景,而非商业场景,但请记住,体育也是一项商业,每年全球都有数十亿美元的收入。球队聘请经理是为了最大化他们的收入和利润,并确保一切运作顺利。

每个团队从商业角度来看需要做的一件最重要的事情就是赢得比赛——常常获胜的团队通常比那些常常输掉比赛的团队赚得更多。作为一名优秀的数据科学家,你知道如何为自己在新职位上的成功奠定基础:你开始深入数据,探索数据,试图了解赢得足球比赛所需的要素。

阅读和分析数据

你可以从以下网址下载几个包含与欧洲足球相关的数据的文件:bradfordtuckfield.com/players.csvbradfordtuckfield.com/games.csv,以及bradfordtuckfield.com/shots.csv。第一个文件,players.csv,包含了一份职业足球球员的名单,包括他们的名字和唯一的 ID 号。接下来的文件,games.csv,包含了与成千上万场足球比赛相关的详细统计数据,包括参赛的队伍、进球数以及更多信息。第三个文件,shots.csv,是最大的,包含了关于数十万次比赛射门的信息,包括谁进行了射门、球员使用哪只脚、射门发生的地点以及射门的结果(是否被封堵、是否偏出或是否进球)。

如果你能够对这些数据进行严格分析,你将能深入了解欧洲足球,并掌握作为经理成功所需的大部分重要知识。(这些公共领域数据的原始来源包括www.kaggle.com/technika148/football-databaseunderstat.comwww.football-data.co.uk。)

让我们从读取这些文件开始。我们这里使用 Python,但别担心,我们很快会使用 SQL:

import pandas as pd
players=pd.read_csv('players.csv', encoding = 'ISO-8859-1')
games=pd.read_csv('games.csv', encoding = 'ISO-8859-1')
shots=pd.read_csv('shots.csv', encoding = 'ISO-8859-1')

到目前为止,这应该看起来很熟悉。这是读取 .csv 文件的标准 Python 代码。在导入 pandas 后,我们读取包含关于欧洲足球信息的数据集。你可以看到,我们读取了将在此使用的所有三个数据集:players,包含有关个人球员的数据;games,包含关于单场比赛的数据;以及 shots,包含球员在比赛中射门的数据。

让我们来看一下这些数据集的前几行:

print(players.head())
print(games.head())
print(shots.head())

players 表只有两列,当你运行print(players.head())时,你应该能够看到它们的前五行:

 playerID            name
0       560   Sergio Romero
1       557  Matteo Darmian
2       548     Daley Blind
3       628  Chris Smalling
4      1006       Luke Shaw

shots 数据更为详细。当你运行print(shots.head())时,你应该能够看到它的前五行:

 gameID  shooterID  assisterID  ...     xGoal positionX positionY
0      81        554         NaN  ...  0.104347     0.794     0.421
1      81        555       631.0  ...  0.064342     0.860     0.627
2      81        554       629.0  ...  0.057157     0.843     0.333
3      81        554         NaN  ...  0.092141     0.848     0.533
4      81        555       654.0  ...  0.035742     0.812     0.707

你可以看到,默认情况下,pandas 包省略了一些在输出控制台中无法显示的列。你可以通过运行print(shots.columns)来查看数据集中所有列的列表,执行后将显示如下列表:

Index(['gameID', 'shooterID', 'assisterID', 'minute', 'situation',
       'lastAction', 'shotType', 'shotResult', 'xGoal', 'positionX',
       'positionY'],
       dtype='object')

我们有关于每次射门的详细数据。我们知道使用哪只脚进行射门(在 shotType 列中),射门的结果(在 shotResult 列中),以及射门的位置(在 positionXpositionY 列中)。但有一点数据中没有明确提到,那就是射门球员的姓名。我们所拥有的只是 shooterID,一个数字。如果我们想知道谁射门了,我们必须查找:首先在 shots 数据中找到 shooterID,然后在 players 数据集中查找与该 shooterID 匹配的球员姓名。

例如,第一脚射门是由 shooterID 为 554 的球员完成的。如果我们想知道这名球员的名字,我们需要查找 players 数据集。如果你浏览 players 数据,或者在 Python 中运行 print(players.loc[7,'name']),你会看到这名球员是胡安·马塔(Juan Mata)。

熟悉 SQL

让我们来看一些 SQL 代码,这些代码将帮助你进行此类查找。我们先从 SQL 代码开始,然后再讨论如何执行这些代码。单独的 SQL 命令通常被称为 SQL 查询。以下代码是一个 SQL 查询,它将展示整个 players 数据集:

SELECT * FROM playertable;

通常,短小的 SQL 查询只要你懂英语,就很容易理解。在这个代码片段中,SELECT 告诉我们我们正在选择数据。查询末尾的 FROM playertable 表示我们将从名为 playertable 的表中选择数据。在 SELECTFROM playertable 之间,我们需要指定要从 playertable 表中选择的列。星号(*)是一个快捷方式,表示我们要选择 playertable 表中的所有列。分号(;)告诉 SQL 我们已经完成了这个特定的查询。

所以,这个 SQL 查询选择了整个 players 表。如果你不想选择所有的列,你可以用一个或多个列名替换 *。例如,以下两个查询也是有效的 SQL 查询:

SELECT playerID FROM playertable
SELECT playerID, name FROM playertable

第一个查询只会从 playertable 表中选择 playerID 列。第二个查询会从 playertable 表中选择 playerIDname 两列——我们指定要选择这两列的输出结果和使用星号时的输出结果是一样的。

你可能注意到我们的 SQL 查询中,关键字都是大写的。这是编写 SQL 查询时的一种常见做法,虽然在大多数环境中它并不是强制要求的。我们这样做是为了遵循约定。

设置 SQL 数据库

如果你将前面的 SQL 查询直接粘贴到 Python 会话中,它们将无法正确运行;它们不是 Python 代码。如果你经常运行 SQL 查询,你可能希望设置一个专门用于编辑和运行 SQL 查询的环境。然而,这是一本 Python 书,我们不想让你陷入设置 SQL 环境的细节中。相反,让我们讲解几个步骤,帮助你直接在 Python 中运行 SQL 查询。你可以通过在 Python 中运行以下命令来开始:

import sqlite3
conn = sqlite3.connect("soccer.db")
curr = conn.cursor()

在这里,我们导入了 SQLite3 包,它允许我们在 Python 中运行 SQL 查询。SQL 是一种与数据库交互的语言,因此我们需要使用 SQLite3 来连接数据库。在第二行,我们告诉 SQLite3 连接到一个名为 soccer.db 的数据库。你可能电脑上没有名为 soccer.db 的数据库,所以 SQLite3 可能没有可以连接的数据库。这没关系,因为 SQLite3 模块非常有用:当我们指定一个要连接的数据库时,如果该数据库存在,它会连接到该数据库;如果数据库不存在,它会为我们创建一个新的数据库并连接到它。

既然我们已经连接到数据库,我们需要定义一个 游标 来访问这个数据库。你可以把这个游标看作类似于你在电脑上使用的鼠标光标,它帮助你选择和操作对象。如果你现在不理解这一点,也不要担心。稍后我们会更清楚地展示如何使用这个游标。

现在我们有了一个数据库,我们希望将其填充。通常,数据库包含多个表,但我们的soccer.db数据库目前是空的。到目前为止,我们处理的三个 pandas 数据框都可以作为表格保存到数据库中。我们可以通过一行代码将players数据框添加到数据库中:

players.to_sql('playertable', conn, if_exists='replace', index = False)

在这里,我们使用to_sql()方法将我们的players数据框推送到数据库的playertable表中。我们使用之前创建的连接conn,它确保该表被推送到我们的soccer.db数据库中。现在,玩家数据被存储在我们的数据库中,而不是仅仅作为 pandas 数据框在 Python 会话中访问。

执行 SQL 查询

我们终于准备好在数据上运行 SQL 查询了。以下是运行 SQL 查询的 Python 代码:

curr.execute('''
SELECT * FROM playertable
          ''')

你可以看到我们创建的游标curr终于派上用场了。游标是我们用来在数据上执行 SQL 查询的对象。在这种情况下,我们执行了一个简单的查询,选择了名为playertable的整个表。需要注意的是,这只选择了数据,但并没有显示它。如果我们想要实际查看选择的数据,我们需要将其打印到控制台:

for row in curr.fetchall():
    print(row)

游标已选择数据并将其推送到 Python 会话的内存中,但我们需要使用fetchall()方法来访问这些数据。当你运行fetchall()时,它会选择一组行。这就是为什么我们在for循环中逐行打印每一行的原因。playertable表有成千上万的行,你可能不希望一次性将所有行打印到屏幕上。你可以通过添加LIMIT子句来限制查询返回的行数:

curr.execute('''
SELECT * FROM playertable **LIMIT 5**
          ''')
for row in curr.fetchall():
    print (row)

在这里,我们运行与之前相同的代码,只是添加了七个字符:LIMIT 5。通过将LIMIT 5添加到 SQL 查询中,我们将返回的行数限制为仅前五行。由于我们只获取表中的前五行,打印它们到屏幕上变得更容易。这显示了我们在使用 pandas 时运行print(players.head())时看到的数据。但要小心:在这种情况下,LIMIT 5将返回前五行,但在其他数据库环境中,它可能会随机返回五行。你可以依赖于从LIMIT 5子句中获得五行,但不能总是确定会得到哪五行。

我们经常只想要数据的特定子集。例如,假设我们想要查找具有特定 ID 的玩家:

curr.execute('''
SELECT * FROM playertable WHERE playerID=554
          ''')
for row in curr.fetchall():
    print (row)

在这里,我们运行了大部分相同的代码,但我们添加了一个WHERE子句。我们不再选择整个表格,而只选择那些满足特定条件的行。我们感兴趣的条件是playerID=554。输出显示我们一行数据,告诉我们playerID等于 554 的玩家叫 Juan Mata。这告诉我们我们想知道的信息:Juan Mata 是数据中记录的第一次射门的球员。你应该开始注意到一个模式:在创建 SQL 查询时,我们从一个选择整个表格的简短查询开始,然后添加子句(就像我们在这里添加的LIMIT子句或WHERE子句),以精炼我们得到的结果。SQL 查询由多个子句组成,每个子句都会影响查询选择的数据。

我们可以使用WHERE子句来选择各种条件。例如,我们可以使用WHERE子句来选择具有特定名字的玩家的 ID:

curr.execute('''
SELECT playerID FROM playertable WHERE name="Juan Mata"
          ''')
for row in curr.fetchall():
    print (row)

我们还可以使用AND操作符来指定多个条件:

curr.execute('''
SELECT * FROM playertable WHERE playerID>100 AND playerID<200
          ''')
for row in curr.fetchall():
    print (row)

在这个例子中,我们选择了满足两个条件的playertable中的行:playerID>100playerID <200

你可能想查找表格中的一个名字,但不确定拼写。在这种情况下,你可以使用LIKE操作符:

curr.execute('''
SELECT * FROM playertable WHERE name LIKE "Juan M%"
          ''')
for row in curr.fetchall():
    print (row)

在这个例子中,我们使用百分号字符(%)作为通配符,意味着它代表任何字符集合。你可能会注意到,这与我们在查询中早些时候使用星号(*)的方式类似(SELECT *)。我们使用*表示所有列,而%表示任何可能的字符。尽管这两种用法相似(都表示未知值),但它们并不能互换使用,并且有两个重要的区别。首先,*可以作为查询本身的一部分使用,而%只能作为字符串的一部分使用。其次,*用于指代列,而%用于指代其他字符。

当你查看这段代码的结果时,你会发现我们找到了几个名字以Juan M开头的玩家:

(554, 'Juan Mata')
(2067, 'Juan Muñoz')
(4820, 'Juan Manuel Falcón')
(7095, 'Juan Musso')
(2585, 'Juan Muñiz')
(5009, 'Juan Manuel Valencia')
(7286, 'Juan Miranda')

如果到目前为止我们做的事情看起来很熟悉,那是正常的。我们搜索的字符串Juan M%是一个正则表达式,就像我们在第八章中讨论的正则表达式一样。你可以看到,每种编程语言都有自己的规则和语法,但这些语言之间有很大的重叠。大多数语言都允许使用正则表达式来搜索文本。许多语言允许你创建表格并选择它们的前五行。通常,当你学习一门新的编程语言时,你并不是在学习完全新的功能,而是在学习以新的方式做你已经做过的事情。

你可以使用 Python 和 pandas 以及 SQL 来创建和操作表格。使用 SQL 的优势在于,在许多情况下,SQL 比 pandas 更快、更可靠、更安全。它还可能与一些不允许你使用 Python 和 pandas 的程序兼容。

通过连接表格来组合数据

到目前为止,我们已经使用了我们的球员表。但我们也可以使用其他的表。让我们读取games表,将其推送到我们的足球数据库中,然后选择前五行:

games=pd.read_csv('games.csv', encoding = 'ISO-8859-1')

games.to_sql('gamestable', conn, if_exists='replace', index = False)

curr.execute('''
SELECT * FROM gamestable limit 5
          ''')

for row in curr.fetchall():
    print (row)

这个代码片段完成了我们之前在players表中做的所有操作:读取它,将其转换为 SQL 数据库表,并从中选择行。我们可以再次对shots表做相同的事情:

shots=pd.read_csv('shots.csv', encoding = 'ISO-8859-1')

shots.to_sql('shotstable', conn, if_exists='replace', index = False)

curr.execute('''
SELECT * FROM shotstable limit 5
          ''')

for row in curr.fetchall():
    print (row)

现在,我们的数据库中有三张表:一张是球员表,一张是投篮表,另一张是比赛表。这对我们来说有点新颖。在本书的大部分内容中,我们的数据都集中在每个章节中的单一表格中。然而,你感兴趣的数据可能会分散在多个表中。在这种情况下,我们已经注意到我们的shots表包含了关于每次投篮的详细信息,但没有包含投篮球员的名字。为了找出投篮球员的名字,我们需要在shots表中查找shooterID,然后在players表中查找该 ID 号。

我们需要在多张表之间进行匹配和查找。如果我们只需要做一两次,手动滚动查看表格可能不是大问题。但如果我们需要获取投篮了数千次的球员名字,反复使用手动查找将变得非常耗时。

相反,假设我们能自动将这两张表中的信息结合起来。这是 SQL 的一个自然特性。我们可以在图 11-1 中看到我们需要做的事情。

图 11-1:连接两张表,使得查找变得更加容易和快速

你可以看到,如果我们将两张表连接起来,就不再需要查看多个表来获取我们需要的所有信息。每一行不仅包含来自shots表的信息,还包含来自players表的投篮球员名字。我们将通过使用 SQL 查询来完成图 11-1 所示的连接操作:

SELECT * FROM shotstable JOIN playertable ON
shotstable.shooterID=playertable.playerID limit 5

让我们逐行查看这个代码片段。我们从SELECT *开始,就像我们之前的 SQL 查询一样。接下来是FROM shotstable,表示我们将从名为shotstable的表中选择数据。然而,差异从这里开始。我们看到shotstable JOIN playertable,这意味着我们不仅仅从shotstable中选择数据,而是想将这两张表连接起来,并从合并后的表中选择数据。

但是,这两张表应该如何连接呢?我们需要指定连接这两张表的方式。具体来说,我们将通过查找 ID 是否匹配来连接这些表。每当shotstable表中的shooterIDplayertable表中的playerID匹配时,我们就知道我们的行是匹配的,并可以将它们连接起来。最后,我们添加LIMIT 5,表示我们只想看到前五行,以避免输出的行数过多。

我们可以在 Python 中运行以下 SQL 查询:

curr.execute('''
SELECT * FROM shotstable JOIN playertable ON shotstable.shooterID=playertable.playerID limit 5
          ''')

for row in curr.fetchall():
    print(row)

在这里,我们运行了前面解释的 SQL 查询,查询的是我们数据库中的表格。我们的 SQL 查询以 图 11-1 中所示的方式将表格连接在一起。在该图中,你可以看到,对于每个投篮者 ID,我们找到了与之匹配的球员 ID,并将该球员的名字添加到了连接表格的匹配行中。我们的查询也做了同样的事情:由于我们指定了 WHERE shotstable.shooterID=playertable.playerID,它会查找 shooterID(来自 shotstable)和 playerID(来自 playertable)之间的所有匹配项。在找到这些匹配项后,它会将匹配行中的信息结合在一起,最终的结果将是一个包含更多完整信息的连接表格。

在运行查询后,我们会打印出查询返回的行。总体而言,我们遵循了之前相同的流程:使用光标执行查询,然后获取所选的数据并将其打印到控制台。

输出结果如下所示:

(81, 554, None, 27, 'DirectFreekick', 'Standard', 'LeftFoot', 'BlockedShot', 0.104346722364426, 0.794000015258789, 0.420999984741211, 554, 'Juan Mata')
(81, 555, 631.0, 27, 'SetPiece', 'Pass', 'RightFoot', 'BlockedShot', 0.064342200756073, 0.86, 0.627000007629395, 555, 'Memphis Depay')
(81, 554, 629.0, 35, 'OpenPlay', 'Pass', 'LeftFoot', 'BlockedShot', 0.0571568161249161, 0.843000030517578, 0.332999992370605, 554, 'Juan Mata')
(81, 554, None, 35, 'OpenPlay', 'Tackle', 'LeftFoot', 'MissedShots', 0.0921413898468018, 0.848000030517578, 0.532999992370605, 554, 'Juan Mata')
(81, 555, 654.0, 40, 'OpenPlay', 'BallRecovery', 'RightFoot', 'BlockedShot', 0.0357420146465302, 0.811999969482422, 0.706999969482422, 555, 'Memphis Depay')

你可以看到,输出结果展示了我们想要的数据:投篮数据,以及关于投篮球员的信息(球员的名字是每行的最后一个元素)。以这种方式连接表格可以为进行更复杂的分析提供有价值的数据支持,就像我们在前几章中所做的那样。

连接表格看起来可能很简单,但这个过程有很多细微之处,如果你想在 SQL 上做到精通,这些细节是你需要了解的。例如,如果你有一个 ID 不出现在 players 表中的投篮记录会发生什么?或者,如果两个球员有相同的 ID——我们如何知道哪个球员投出了那个 ID 的投篮?默认情况下,SQL 使用 INNER JOIN 来执行连接。如果没有球员 ID 与特定的投篮者 ID 匹配,内连接将不会返回任何结果;它只会返回那些能够精确知道是哪个球员投篮的行。但 SQL 还提供了其他类型的连接,每种连接使用不同的逻辑并遵循不同的规则。

这不是一本 SQL 书籍,因此我们不会深入讨论 SQL 语言的每个细节以及每种连接方式。当你深入学习 SQL 时,你会了解到,SQL 的高级功能通常包括越来越复杂的选择数据和连接表格的方式。目前,你可以为自己感到自豪,因为你已经能够执行基本的 SQL 查询了。你能够将数据存入数据库,从表格中选择数据,甚至将多个表格连接起来。

使用 R 赢得足球比赛

R 是另一种对数据科学职业非常有用的语言。让我们来学习如何运行一些 R 命令,这些命令将帮助你在足球管理的职业生涯中取得成功。就像我们使用 SQL 一样,我们可以在 Python 会话中运行 R 命令,而不必担心设置 R 环境。在许多方面,R 与 Python 非常相似,因此在掌握 Python 数据科学技能之后,你会发现掌握 R 技能相对容易一些。

熟悉 R 语言

让我们首先看一下一些 R 代码。就像我们对 SQL 查询的处理一样,我们也先看一下 R 代码,然后再执行它:

my_variable<-512
print(my_variable+12)

第一行定义了一个名为 my_variable 的变量。如果我们在写 Python,等价的语句将是 my_variable=512。在 R 中,我们使用 <- 而不是 =,因为在 R 中,<-赋值运算符,它是用于定义变量值的字符集合。<- 字符的形状像一支箭头,指示数字 512 从右侧传递并被赋值给 my_variable。在赋值之后,我们可以对变量进行加法运算、打印它,或做任何我们想做的操作。在我们的代码片段中,我们通过 print(my_variable+12) 打印出变量的值加上 12 的结果。

就像我们运行 SQL 查询时一样,你可能会想:我们如何运行这段 R 代码?如果你愿意,可以下载 R 并设置一个可以运行这段代码的 R 环境。但我们也可以通过一些简单的准备工作,在 Python 会话中直接运行它。让我们从导入一个需要的模块开始:

from rpy2 import robjects

在这种情况下,rpy2 包将帮助我们在 Python 会话中运行 R 命令。现在我们已经导入了这个包,运行 R 代码变得轻松简单:

robjects.r('''
my_variable<-512
print(my_variable+12)
''')

这与我们之前运行 SQL 代码的方法类似。我们可以使用 robjects.r() 函数在 Python 会话中运行任何 R 代码。你可以看到输出显示了 524,这是我们在代码中做加法运算的结果。

到目前为止,我们已经运行了一些简单的 R 代码,但都没有涉及到你在足球管理工作中需要的内容。接下来,让我们运行一些与我们的足球数据相关的 R 代码,如下所示:

robjects.r('''
players<-read.csv('players.csv')
print(head(players))
''')

这里,第一行使用 read.csv() 命令读取我们的 players.csv 文件。我们通过使用和之前相同的赋值运算符(<-)将数据存储在 players 变量中。在第二行,我们打印出数据的前几行。

通过查看这段 R 代码,你可以看到 R 和 Python 之间的一些差异。在 Python 中,我们使用 pd.read_csv(),而在 R 中,我们使用 read.csv()。这两个函数都用于读取 .csv 文件,但在写法上有一些小的差别。类似地,在 Python 中,我们需要使用 players.head() 来获取数据的前几行。而在 R 中,我们使用 head(players)。当我们使用 pandas 数据集时,head() 方法返回前五行数据。但在 R 中,head() 函数返回前六行。R 和 Python 有许多相似之处,但它们并不完全相同。

我们可以以相同的方式读取其他表格:

robjects.r('''
shots<-read.csv('shots.csv')
print(head(shots))
''')

这次,我们读取并打印出 shots 数据的前几行。我们还可以打印出数据中特定列的前几个元素:

robjects.r('''
print(head(shots$minute))
print(head(shots$positionX))
''')

在 R 中,美元符号($)用来通过名称引用列。此代码片段打印出 shots 数据中 minutepositionX 列的前六个元素。minute 列的前六个元素如下:

[1] 27 27 35 35 40 49

这些是我们数据中前六次拍摄的时间点。positionX 的前六个元素如下:

[1] 0.794 0.860 0.843 0.848 0.812 0.725

这些是数据中前六次投篮的 x 位置。在这里,我们使用术语x 位置来表示每次投篮距离“球场底线”的远近。一个队的球门 x 位置为 0,另一个队的球门 x 位置为 1,因此 x 位置告诉我们某次投篮距离对方球门有多远。

在 R 中应用线性回归

每当我们查看数据时,我们可以尝试从中学习。我们可能想了解的一件事是,比赛开始时的投篮与比赛结束时的投篮有何不同。在比赛过程中,时间如何影响投篮的位置?有几种假设可能成立:

  • 进攻球员可能会随着比赛的进行变得更加疲劳和焦虑,因此他们开始从距离球门更远的位置投篮(即较低的 x 位置)。

  • 防守球员可能会随着比赛的进行变得更加疲劳和马虎,因此进攻球员能够从距离球门较近的位置投篮(即较高的 x 位置)。

  • 也许前两个假设都不成立,或者比赛时间与投篮 x 位置之间存在其他模式。

为了决定这些假设中的哪一个是真实的,我们可以尝试在 R 中进行线性回归:

robjects.r('''
shot_location_model <- lm(positionX~minute,data=shots)
print(summary(shot_location_model))
''')

在这里,我们使用lm()命令进行线性回归。该回归旨在找出我们shots数据中minute变量和positionX变量之间的关系。正如我们在第二章中所做的那样,我们希望查看每个线性回归输出中的系数。请记住,系数可以解释为直线的斜率。如果我们从回归中找到一个正系数,我们可以解释为比赛进行时,球员投篮的距离球门越来越近。如果我们找到一个负系数,我们可以解释为比赛进行时,球员投篮的距离球门越来越远。当我们查看线性回归代码的输出时,它看起来是这样的:

Call:
lm(formula = positionX ~ minute, data = shots)

Residuals:
     Min       1Q   Median       3Q      Max
-0.84262 -0.06312  0.01885  0.06443  0.15716

Coefficients:
             Estimate Std. Error  t value Pr(>|t|)
(Intercept) 8.414e-01  3.291e-04 2556.513   <2e-16 ***
minute      5.251e-05  5.944e-06    8.835   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 0.09 on 324541 degrees of freedom
Multiple R-squared:  0.0002404,	Adjusted R-squared:  0.0002374 
F-statistic: 78.05 on 1 and 324541 DF,  p-value: < 2.2e-16

如果你查看此输出中的Estimate列,你会看到minute变量的估计系数是5.251e-05。这是一个正系数,所以随着比赛的进行,我们预计投篮会(稍微)更靠近球门。

使用 R 绘制数据

现在我们已经完成了回归分析,我们可以绘制数据图表,展示回归结果:

robjects.r('''
png(filename='the_plot_chapter11.png')
plot(shots$minute,shots$positionX)
abline(shot_location_model)
dev.off()
''')

在第一行,我们使用了png()命令。这告诉 R 打开一个文件用于绘制图表。我们还需要指定一个文件名,文件会被写入到这个位置。接下来,我们使用plot()命令,首先指定 x 轴的内容,然后指定 y 轴的内容。abline()命令用于绘制回归线。最后,我们运行dev.off()命令,这会关闭图形设备,意味着它告诉 R 绘制完成,文件应该写入到计算机的内存中。运行这个代码片段后,你应该能看到文件保存在你的笔记本上,应该像图 11-2 一样。

图 11-2:成千上万场足球比赛中每分钟射门的 x 位置及回归线

如果你在笔记本上找不到输出文件,可以修改上述代码片段中的文件名参数。例如,你可以写png(filename='/home/Yossarian/Documents/plotoutput.png'),将文件保存在你计算机的任何特定位置。

在这张图中,你可以看到大量的射门数据,许多数据点都叠加在一起。回归线几乎看不见——你能看到它在图的左右两侧稍微突出,接近y大约是 0.85 的位置。它有一个正斜率,但只是略微正向;通过分析足球比赛的每分钟射门位置,很难辨认出任何明显的模式。这是你本来可以用 Python 做到的,使用第二章中的代码和思路,但现在你也能用另一种语言完成。

这一张图和一个回归模型还不足以让你成为一个完美的足球经理,但它会为你提供信息和背景,帮助你学习如何赢得足球比赛,并帮助你的球队取得成功。与其依赖理论或道听途说,你拥有数据科学家的技能,可以通过直接分析数据来确定在足球比赛中什么是有效的。读完本章后,你不仅能用 Python,还能用 SQL 和 R 来分析数据并从中学习。

我们可以用 R 做更多的事情;本书中使用 Python 完成的任何任务也可以用 R 完成。除了绘图和线性回归,你还可以进行监督学习、K 均值聚类等操作。事实上,你已经学会了很多:你可以读取数据、计算回归并绘制图表。

获取其他宝贵技能

当你完成本书并合上它时,你将具备一些强大的数据科学技能。但你总是可以学习更多的内容。你应该考虑的一件事是提高你对更多编程语言的熟练度。除了 Python、SQL 和 R 之外,还有许多其他编程语言,至少在初级或中级水平上,你可能也想学习。以下是你可能考虑学习的其他编程语言:

C++

  1. C++ 是一种高性能语言;用 C++ 编写的代码功能强大且执行速度快。它通常比 Python 更难以使用。

Scala

  1. Scala 用于处理大数据——即那些包含数百万或数十亿行数据的 datasets。

Julia

  1. Julia 在近年来的流行度不断上升,因其高效性和数学计算速度而声名鹊起。

JavaScript

  1. JavaScript 在网页编程中极为常见。它使你能够创建动态的、互动性强的网站。

MATLAB

  1. MATLAB 是 矩阵实验室 的缩写,旨在进行精确的数学计算,包括矩阵操作。它通常用于科学计算,但只有那些能够负担其高昂价格的人或机构才能使用。

SAS,Stata,SPSS

  1. 这些是专有的统计软件包。Stata 在专业经济学家中广泛使用。SPSS 由 IBM 拥有,通常被一些社会科学家使用。SAS 被一些企业使用。和 MATLAB 一样,所有这些语言也都有高昂的价格,通常会促使人们选择像 Python、SQL 和 R 这样的免费替代品。

除了这些,还有许多其他方法。一些数据科学家认为,数据科学家应该比任何统计学家都更擅长编程,而比任何程序员都更擅长统计学。说到统计学,你可能希望进一步研究以下高级统计学主题:

线性代数

  1. 许多统计方法,如线性回归,本质上是线性代数方法。当你阅读与高级数据科学或高级机器学习相关的教科书时,你会看到线性代数符号和线性代数概念,比如矩阵求逆。如果你能深入理解线性代数,你将更好地掌握这些高级主题。

贝叶斯统计

  1. 在过去几十年里,一种被称为贝叶斯统计的统计技术变得非常流行。贝叶斯技术使我们能够有效地推理关于不同观点的信心水平,以及如何在面对新信息时更新我们的信念。它们还允许我们在统计推断中使用先验信念,并对我们对统计模型的不确定性进行细致推理。

非参数统计

  1. 与贝叶斯统计类似,非参数方法使我们能够以新的方式推理数据。非参数方法非常强大,因为它们要求我们对数据做极少的假设,因此它们具有鲁棒性,并适用于各种数据,即使是那些“不太规范”的数据。

数据科学不仅仅是统计理论。它还与技术部署相关。以下是你需要掌握的一些与技术部署相关的技术技能:

数据工程

  1. 本书的大多数章节中,我们为你提供了干净的数据进行分析。然而,在许多现实生活中的场景中,你将会收到杂乱、不完整、标签错误、不断变化,或其他需要精心管理的数据。数据工程是一套与大规模、难以控制的数据集打交道的技巧,以谨慎和高效的方式进行工作。你可能会发现自己在一家公司工作,那里有数据工程师负责为你清理和准备数据,但你也很可能会遇到许多需要你自己完成这些任务的情况。

DevOps

  1. 在数据科学家进行一些分析后,通常还需要更多的步骤,才能使分析结果变得有用。例如,如果你使用线性回归进行预测,你可能希望将回归模型安装到服务器上并定期执行。你将如何以及在哪里安装它?是否需要定期更新?你将如何监控它?如何以及何时重新部署它?这些问题与机器学习 DevOps 相关,也叫做* MLOps*,如果你能掌握一些 DevOps 和 MLOps 的技能,你在数据科学事业中将会更加成功。

高级/流利/高效编程

  1. 一个初级数据科学家能够编写可运行的代码。而一个有才华的数据科学家,相比之下,能够编写高效的代码。它将运行得更快,并且更加可读和简洁。

除了这些技能之外,你还需要在与你的工作(或你希望从事的工作)相关的应用领域获得专业知识。如果你有兴趣在金融领域担任数据科学家,你应该学习数学金融以及顶级金融公司使用的量化模型。如果你有兴趣在制药或医疗公司工作,你应该考虑生物统计学,甚至纯生物学作为研究领域。你知道的越多,你在数据科学事业中就会越有效。

总结

在本章中,我们讨论了除了 Python 之外,其他对数据科学家有用的编程语言。我们从 SQL 开始,SQL 是一个强大的语言,用于处理表格数据。我们使用 SQL 从表格中选择数据并将表格连接在一起。接着,我们讨论了 R 语言,R 是一种由统计学家设计的语言,适用于许多强大的数据分析。现在你已经完成了本书,并且掌握了出色的数据科学技能。恭喜你,祝你好运,万事如意!

posted @ 2025-12-01 09:40  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报