Python-数据科学-全-
Python 数据科学(全)
原文:
zh.annas-archive.org/md5/89b6b137b84ef92e7564baffcbec099d译者:飞龙
第一章:数据基础

数据对不同的人意味着不同的东西:股票交易员可能认为数据是实时股票报价,而 NASA 工程师可能会将数据与来自火星探测器的信号联系在一起。然而,在数据处理和分析中,无论数据的来源如何,相同或类似的方法和技术可以应用于各种数据集。重要的是数据是如何被结构化的。
本章提供了数据处理和分析的概念性介绍。我们首先将介绍你可能需要处理的主要数据类别,然后简单讨论常见的数据来源。接下来,我们将考虑典型数据处理管道中的步骤(即获取、准备和分析数据的实际过程)。最后,我们将探讨 Python 作为数据科学工具的独特优势。
数据类别
程序员将数据分为三大类:非结构化数据、结构化数据和半结构化数据。在数据处理管道中,源数据通常是非结构化的;从这些数据中,你可以形成结构化或半结构化的数据集以便进一步处理。然而,一些管道从一开始就使用结构化数据。例如,处理地理位置的应用程序可能会直接从 GPS 传感器接收结构化数据。以下章节将探讨三大数据类别以及时间序列数据,这是一种可以是结构化或半结构化的数据类型。
非结构化数据
非结构化数据是没有预定义组织系统或模式的数据。这是最广泛的数据显示形式,常见的例子包括图像、视频、音频和自然语言文本。举个例子,考虑一下来自制药公司的财务报表:
GoodComp shares soared as much as 8.2% on 2021-01-07 after the company announced positive early-stage trial results for its vaccine.
这段文本被认为是非结构化数据,因为其中的信息并没有按照预定义的模式进行组织。相反,信息在报表中是随机分散的。你可以用许多不同的方式重写这份报表,同时传达相同的信息。例如:
Following the January 7, 2021, release of positive results from its vaccine trial, which is still in its early stages, shares in GoodComp rose by 8.2%.
尽管缺乏结构,非结构化数据可能包含重要信息,你可以通过适当的转换和分析步骤将其提取并转化为结构化或半结构化数据。例如,图像识别工具首先将图像中的像素集合转换为预定义格式的数据集,然后分析这些数据以识别图像中的内容。类似地,接下来的部分将展示一些方法,通过这些方法,我们可以将从财务报表中提取的数据进行结构化。
结构化数据
结构化数据具有预定义的格式,指定了数据的组织方式。这类数据通常存储在像关系数据库这样的存储库中,或者只是存储在一个.**csv(逗号分隔值)文件中。输入到这样的存储库中的数据称为记录,其中的信息按必须与预期结构匹配的顺序组织在字段中。在数据库中,具有相同结构的记录被逻辑地分组在一个名为表的容器中。一个数据库可以包含多个表,每个表都有一组结构化字段。
结构化数据有两种基本类型:数值型和分类型。分类数据是指可以根据相似特征进行分类的数据;例如,汽车可能根据品牌和型号进行分类。数值数据则以数字形式表示信息,允许对其进行数学运算。
请记住,分类数据有时可以采用数值形式。例如,考虑邮政编码或电话号码。虽然它们用数字表示,但对它们进行数学运算没有意义,比如找出中位数邮政编码或平均电话号码。
我们如何将上一节中介绍的文本样本组织为结构化数据?我们关注的是文本中的特定信息,例如公司名称、日期和股票价格。我们希望将这些信息以以下格式的字段呈现,准备插入数据库:
Company: `ABC`
Date: `yyyy-mm-dd`
Stock: `nnnnn`
使用自然语言处理(NLP)技术,这一学科训练机器理解人类可读的文本,我们可以提取适合这些字段的信息。例如,我们通过识别一个只能是预设值之一的分类数据变量来查找公司名称,比如 Google、Apple 或 GoodComp。同样,我们可以通过将日期的显式顺序与一组显式的排序格式(如yyyy-mm-dd)进行匹配来识别日期。在我们的示例中,我们识别、提取并以预定义的格式呈现数据,如下所示:
Company: GoodComp
Date: 2021-01-07
Stock: +8.2%
为了将此记录存储在数据库中,最好将其呈现为类似行的字段序列。因此,我们可能会将记录重新组织为矩形数据对象或二维矩阵:
Company | Date | Stock
---------------------------
GoodComp |2021-01-07 | +8.2%
你选择从同一非结构化数据源中提取的信息取决于你的需求。我们的示例语句不仅包含了 GoodComp 公司某一日期的股票变化,还指出了变化的原因,即“公司宣布其疫苗的早期阶段试验结果积极”。从这个角度来看,你可能会创建一个包含以下字段的记录:
Company: GoodComp
Date: 2021-01-07
Product: vaccine
Stage: early-stage trial
将此与我们提取的第一个记录进行比较:
Company: GoodComp
Date: 2021-01-07
Stock: +8.2%
请注意,这两个记录包含不同的字段,因此具有不同的结构。结果,它们必须存储在两个不同的数据库表中。
半结构化数据
在信息的结构标识与严格的格式要求不符的情况下,我们可能需要处理半结构化数据格式,它允许我们在同一个容器(数据库表或文档)中存储不同结构的记录。与非结构化数据一样,半结构化数据不依赖于预定义的组织模式;然而,与非结构化数据不同,半结构化数据的样本通常具有一定的结构,通常表现为自描述标签或其他标记。
最常见的半结构化数据格式包括 XML 和 JSON。这是我们的财务报表可能在 JSON 格式中的样子:
{
"Company": "GoodComp",
"Date": "2021-01-07",
"Stock": 8.2,
"Details": "the company announced positive early-stage trial results for its vaccine."
}
在这里,你可以识别我们之前从声明中提取的关键信息。每一条信息都配有一个描述性标签,如“公司”或“日期”。感谢这些标签,信息被组织得与前一部分中出现的方式相似,但现在我们有了一个第四个标签,“详情”,它与原始声明中的整个片段配对,该片段看起来没有结构。这个例子展示了半结构化数据格式如何在单个记录中容纳结构化和非结构化数据。
此外,你可以将多个不同结构的记录放入同一个容器中。在这里,我们将从示例财务报表中衍生的两条不同记录存储在同一个 JSON 文档中:
[
{
"Company": "GoodComp",
"Date": "2021-01-07",
"Stock": 8.2
},
{
"Company": "GoodComp",
"Date": "2021-01-07",
"Product": "vaccine",
"Stage": "early-stage trial"
}
]
回想前一部分的讨论,关系型数据库作为一种严格结构化的数据存储库,不能在同一表中容纳具有不同结构的记录。
时间序列数据
时间序列是一组按时间顺序排列或列出的数据点。许多金融数据集作为时间序列存储,因为金融数据通常包含特定时间的观察结果。
时间序列数据可以是结构化的或半结构化的。想象一下,你从出租车的 GPS 跟踪设备中按定时间隔接收位置数据。数据可能以以下格式到达:
[
{
"cab": "cab_238",
"coord": (43.602508,39.715685),
"tm": "14:47",
"state": "available"
},
{
"cab": "cab_238",
"coord": (43.613744,39.705718),
"tm": "14:48",
"state": "available"
}
...
]
每分钟会有一条新的数据记录,其中包含来自cab_238的最新位置坐标(纬度/经度)。每条记录的字段顺序相同,且每个字段在一条记录到下一条记录之间保持一致的结构,从而可以将这些时间序列数据存储在关系型数据库表中,作为常规结构化数据。
假设数据以不等时间间隔到达,这在实际中经常发生,并且你每分钟收到不止一组坐标。接收到的数据结构可能如下所示:
[
{
"cab": "cab_238",
"coord": [(43.602508,39.715685),(43.602402,39.709672)],
"tm": "14:47",
"state": "available"
},
{
"cab": "cab_238",
"coord": (43.613744,39.705718),
"tm": "14:48",
"state": "available"
}
]
请注意,第一个coord字段包含两组坐标,因此与第二个coord字段不一致。这些数据是半结构化的。
数据来源
现在你知道了数据的主要类别,那么你可能从哪些来源获取这些数据呢?一般来说,数据可以来自很多不同的来源,包括文本、视频、图像和设备传感器等。从你将编写的 Python 脚本的角度来看,最常见的数据来源有:
-
应用程序编程接口(API)
-
网页
-
数据库
-
文件
这个列表并不是为了全面或限制性地列出所有选项;还有很多其他的数据来源。例如,在第九章,你将看到如何使用智能手机作为数据处理管道的 GPS 数据提供者,具体来说是通过使用一个机器人应用程序作为中介,连接智能手机和你的 Python 脚本。
从技术上讲,这里列出的所有选项都需要你使用相应的 Python 库。例如,在你能够从 API 获取数据之前,你需要安装该 API 的 Python 封装器,或者直接使用 Requests Python 库向 API 发起 HTTP 请求。同样,为了从数据库中访问数据,你需要在你的 Python 代码中安装一个连接器,以便能够访问特定类型的数据库。
虽然许多这些库需要下载和安装,但有些用于加载数据的库默认情况下与 Python 一起分发。例如,要从 JSON 文件中加载数据,你可以利用 Python 内置的 json 包。
在第四章和第五章中,我们将更详细地讨论数据源问题。特别是,你将学习如何将来自不同来源的特定数据加载到 Python 脚本中的数据结构中,以便进一步处理。现在,我们简要看看前面提到的每种常见数据源类型。
API
也许今天获取数据最常见的方式是通过 API(一个软件中介,它使两个应用程序能够相互交互)。如前所述,要在 Python 中利用 API,你可能需要为该 API 安装一个 Python 库封装器。如今最常见的做法是通过 pip 命令。
并非所有 API 都有自己的 Python 封装器,但这并不意味着你不能通过 Python 向它们发起请求。如果一个 API 提供 HTTP 请求,你可以通过 Python 的 Requests 库与该 API 进行交互。这使你能够访问成千上万的 API,能够在你的 Python 代码中请求数据集以供进一步处理。
在为特定任务选择 API 时,你应该考虑以下几点:
-
功能性 许多 API 提供类似的功能,因此你需要了解你的具体需求。例如,许多 API 允许你在 Python 脚本中进行网页搜索,但只有一些 API 允许你按发布时间来缩小搜索结果范围。
-
成本 许多 API 允许你使用所谓的 开发者密钥,通常是免费的,但会有一些限制,例如每天调用次数的限制。
-
稳定性得益于 Python 包索引(PyPI)仓库(
pypi.org),任何人都可以将 API 打包成pip包并公开发布。因此,几乎所有你能想到的任务都有对应的 API(或多个 API),但并非所有 API 都完全可靠。幸运的是,PyPI 仓库会跟踪包的性能和使用情况。 -
文档:流行的 API 通常有相应的文档网站,允许你查看所有 API 命令及其示例用法。作为一个好的示范,查看 Nasdaq Data Link(又名 Quandl)API 的文档页面(
docs.data.nasdaq.com/docs/python-time-series),在这里你可以找到进行不同时间序列调用的示例。
许多 API 会以以下三种格式之一返回结果:JSON、XML 或 CSV。这些格式中的数据可以轻松转换为 Python 内置或常用的数据结构。例如,Yahoo Finance API 检索并分析股票数据,然后将信息转换为 pandas DataFrame,这是一种我们将在第三章讨论的广泛使用的数据结构。
网页
网页可以是静态的,也可以是根据用户的互动动态生成的,在这种情况下,它们可能包含来自多个不同来源的信息。无论是哪种情况,程序都可以读取网页并提取其中的部分内容。这种操作被称为网页抓取,只要页面是公开可用的,这种行为是合法的。
在 Python 中的典型抓取场景涉及两个库:Requests 和 BeautifulSoup。Requests 用于获取页面的源代码,然后 BeautifulSoup 为页面创建一个解析树,它是页面内容的层次化表示。你可以搜索解析树并使用 Pythonic 的习惯用法从中提取数据。例如,以下是解析树的一个片段:
[<td title="03/01/2020 00:00:00"><a href="Download.aspx?ID=630751" id="lnkDownload630751"
target="_blank">03/01/2020</a></td>,
<td title="03/01/2020 00:00:00"><a href="Download.aspx?ID=630753" id="lnkDownload630753"
target="_blank">03/01/2020</a></td>,
<td title="03/01/2020 00:00:00"><a href="Download.aspx?ID=630755" id="lnkDownload630755"
target="_blank">03/01/2020</a></td>]
可以在 Python 脚本中的for循环中轻松转换为以下项目列表:
[
{'Document_Reference': '630751', 'Document_Date': '03/01/2020',
'link': 'http://www.dummy.com/Download.aspx?ID=630751'}
{'Document_Reference': '630753', 'Document_Date': '03/01/2020',
'link': 'http://www.dummy.com/Download.aspx?ID=630753'}
{'Document_Reference': '630755', 'Document_Date': '03/01/2020',
'link': 'http://www.dummy.com/Download.aspx?ID=630755'}
]
这是将半结构化数据转换为结构化数据的一个示例。
数据库
另一个常见的数据来源是关系型数据库,这是一种提供高效存储、访问和操作结构化数据机制的结构。你可以通过结构化查询语言(SQL)请求,从数据库中的表格中提取数据或将数据发送到表格中。例如,以下请求发往数据库中的employees表格,只会检索在 IT 部门工作的程序员列表,这样就无需提取整个表格:
SELECT first_name, last_name FROM employees WHERE department = 'IT' and title = 'programmer'
Python 有一个内置的数据库引擎 SQLite。你也可以使用其他可用的数据库。在访问数据库之前,你需要在环境中安装数据库客户端软件。
除了传统的严格结构化数据库外,近年来对在类数据库容器中存储异构和非结构化数据的需求日益增长。这促使了所谓的 NoSQL(非 SQL 或 不仅仅是 SQL)数据库的兴起。NoSQL 数据库使用灵活的数据模型,允许你使用 键值 方法存储大量非结构化数据,其中每个数据项都可以通过关联的键进行访问。以下是我们早先示例的财务报表,如果存储在 NoSQL 数据库中,它可能看起来是这样的:
key value
--- -----
...
26 GoodComp shares soared as much as 8.2% on 2021-01-07 after the company announced ...
整个语句与一个标识键 26 配对存储。将整个语句存储在数据库中似乎有些奇怪。然而,回想一下,从单个语句中可以提取多个记录。存储整个语句为我们提供了灵活性,可以在稍后提取不同的数据信息。
文件
文件可能包含结构化、半结构化和非结构化的数据。Python 内置的 open() 函数允许你打开文件并在脚本中使用其数据。然而,根据数据的格式(例如 CSV、JSON 或 XML),你可能需要导入相应的库才能执行读取、写入和/或追加操作。
普通文本文件不需要额外的库进行处理,只需在 Python 中将其视为一系列行即可。例如,查看下面一条可能由思科路由器发送到日志文件的消息:
dat= 'Jul 19 10:30:37'
host='sm1-prt-highw157'
syslogtag='%SYS-1-CPURISINGTHRESHOLD:'
msg=' Threshold: Total CPU Utilization(Total/Intr): 17%/1%,
Top 3 processes(Pid/Util): 85/9%, 146/4%, 80/1%'
你可以逐行读取文件,查找所需的信息。因此,如果你的任务是找到包含 CPU 利用率信息的消息并提取其中的特定数字,你的脚本应该能够将该片段的最后一行识别为要选中的消息。在第二章中,你将看到如何使用文本处理技术从文本数据中提取特定信息的示例。
数据处理管道
在本节中,我们将从概念上了解数据处理的步骤,这也被称为数据处理管道。应用于数据的常见步骤包括:
-
获取
-
清洗
-
转换
-
分析
-
存储
如你所见,这些步骤并不总是非常明确。在某些应用中,你可以将多个步骤合并为一个,或者完全省略某些步骤。
获取
在你对数据进行任何操作之前,你需要先获取数据。这就是为什么数据采集是任何数据处理管道中的第一步。在上一节中,你已经了解了最常见的数据源类型。部分数据源允许你根据需求仅加载所需的数据部分。
例如,向 Yahoo Finance API 发出的请求要求你指定公司的股票代码以及要检索该公司股票价格的时间段。类似地,新闻 API 允许你检索新闻文章,并可以处理多个参数以缩小请求的文章列表,包括来源和发布日期。然而,尽管有这些限定参数,检索到的文章列表仍可能需要进一步过滤。也就是说,数据可能需要清理。
清理
数据清理是检测和修正损坏或不准确数据的过程,或者移除不必要的数据。在某些情况下,这一步骤是无需执行的,获取的数据可以直接用于分析。例如,yfinance 库(一个 Python 封装的 Yahoo Finance API)返回的股票数据是一个现成可用的 pandas DataFrame 对象。通常,这让你可以跳过清理和转换步骤,直接进入数据分析。
然而,如果你的采集工具是一个网页爬虫,那么数据肯定需要清理,因为 HTML 标记的片段可能会与有效载荷数据一起被包含在内,如下所示:
6.\tThe development shall comply with the requirements of DCCâ\x80\x99s Drainage Division as follows\r\n\r\n
清理后,这段文本应该是这样的:
6\. The development shall comply with the requirements of DCC's Drainage Division as follows
除了 HTML 标记外,抓取的文本可能还包括其他不需要的文本,如以下示例中,A View full text 只是一个超链接文本。你可能需要打开这个链接才能访问其中的文本:
Permission for proposed amendments to planning permission received on the 30th A View full text
你也可以使用数据清理步骤来过滤特定的实体。例如,在从新闻 API 请求一组文章后,你可能需要选择仅包含标题中带有金钱或百分比短语的指定时期内的文章。这个过滤步骤可以视为数据清理操作,因为它的目的是移除不必要的数据,并为数据转换和数据分析操作做好准备。
转换
数据转换是将数据的格式或结构改变,以便为分析做好准备。例如,为了像在“结构化数据”中那样提取我们从 GoodComp 非结构化文本数据中获得的信息,你可能会将其分解为单独的单词或 标记,以便命名实体识别(NER)工具可以寻找所需的信息。在信息提取中,命名实体 通常代表一个现实世界的对象,如一个人、一个组织或一个产品,这些对象可以通过专有名词来识别。还有一些命名实体代表日期、百分比、金融术语等。
许多 NLP 工具可以自动处理这种类型的转换。在经过这样的转换后,处理过的 GoodComp 数据会是这样的:
['GoodComp', 'shares', 'soared', 'as', 'much', 'as', '8.2%', 'on',
'2021-01-07', 'after', 'the', 'company', 'announced', 'positive',
'early-stage', 'trial', 'results', 'for', 'its', 'vaccine']
其他形式的数据转换更为深入,例如将文本数据转化为数字数据。例如,如果我们收集了一些新闻文章,我们可以通过执行情感分析来转换这些文章,情感分析是一种文本处理技术,可以生成一个表示文本中表达的情感的数字。
情感分析可以使用像 SentimentAnalyzer 这样的工具来实现,它可以在nltk.sentiment包中找到。典型的分析输出可能如下所示:
Sentiment URL
--------- ----------------------------------------------------------------
0.9313 https://mashable.com/uk/shopping/amazon-face-mask-store-july-28/
0.9387 https://skillet.lifehacker.com/save-those-crustacean-shells-to
-make-a-sauce-base-1844520024
我们的数据集中每个条目现在都包含一个数字,例如0.9313,表示对应文章中表达的情感。通过将每篇文章的情感转化为数字,我们可以计算整个数据集的平均情感,从而确定对某个感兴趣对象(如某公司或产品)的总体情感。
分析
分析是数据处理流程中的关键步骤。在此步骤中,您对原始数据进行解读,从而得出那些不立即显现的结论。
继续我们的情感分析示例,您可能想要研究在某一特定期间内,某公司股票价格与该公司情感的关系。或者,您可能会将股市指数数据(例如标准普尔 500 指数)与同一时期内广泛采样的新闻文章中表达的情感进行比较。以下片段展示了数据集的可能样子,其中 S&P 500 数据与当天新闻的整体情感一同呈现:
Date News_sentiment S&P_500
---------------------------------------
2021-04-16 0.281074 4185.47
2021-04-19 0.284052 4163.26
2021-04-20 0.262421 4134.94
由于情感数据和股票数据均以数字形式表示,您可以在同一图表上绘制两个相应的图形进行视觉分析,如图 1-1 所示。

图 1-1:数据可视化分析示例
可视化分析是最常用和高效的解读数据方法之一。我们将在第八章中更详细地讨论可视化分析。
存储
在大多数情况下,您需要存储在数据分析过程中生成的结果,以便以后使用。您的存储选项通常包括文件和数据库。如果您预计数据将频繁重用,数据库更为理想。
Pythonic 方式
在使用 Python 进行数据科学时,您的代码应当以Pythonic的方式编写,这意味着代码应该简洁高效。Pythonic 代码通常与使用列表推导式相关,列表推导式是一种通过单行代码实现有用数据处理功能的方法。
我们将在第二章中更详细地介绍列表推导式,但现在,以下快速示例演示了 Pythonic 概念在实践中的运作方式。假设您需要处理以下多句子文本片段:
txt = ''' Eight dollars a week or a million a year - what is the difference? A mathematician or a wit would give you the wrong answer. The magi brought valuable gifts, but that was not among them. - The Gift of the Magi, O'Henry'''
具体来说,你需要按句子拆分文本,为每个句子创建一个单独的单词列表,且不包含标点符号。由于 Python 的列表推导特性,所有这些都可以在一行代码中实现,这就是所谓的一行代码:
word_lists = [[w.replace(',','') ❶ for w in line.split() if w not in ['-']]
❷ for line in txt.replace('?','.').split('.')]
for line in txt 循环 ❷ 将文本拆分为句子,并将这些句子存储在一个列表中。接着,for w in line 循环 ❶ 将每个句子拆分成单独的单词,并将这些单词存储在更大列表中的子列表里。最终,你会得到以下的列表嵌套列表:
[['Eight', 'dollars', 'a', 'week', 'or', 'a', 'million', 'a', 'year', 'what',
'is', 'the', 'difference'], ['A', 'mathematician', 'or', 'a', 'wit',
'would', 'give', 'you', 'the', 'wrong', 'answer'], ['The', 'magi',
'brought', 'valuable', 'gifts', 'but', 'that', 'was', 'not', 'among',
'them'], ['The', 'Gift', 'of', 'the', 'Magi', "O'Henry"]]
在这一行代码中,你已经成功地完成了数据处理管道的两个步骤:数据清洗和转换。你通过去除文本中的标点符号清洗了数据,并通过将单词彼此分隔,将每个句子转化为一个单词列表。
如果你是从其他编程语言转到 Python 的,可以尝试用那个语言来实现这个任务。需要多少行代码呢?
概述
阅读完本章后,你应该对数据的主要类别、数据来源以及典型的数据处理管道的组织方式有一个大致的了解。
正如你所看到的,数据主要分为三大类:非结构化数据、结构化数据和半结构化数据。在数据处理管道中,原始输入材料通常是非结构化数据,经过清洗和转换步骤后,它会变成结构化或半结构化数据,准备好进行分析。你还了解了那些一开始就使用结构化或半结构化数据的数据处理管道,这些数据通常来自 API 或关系型数据库。
第二章:Python 数据结构

数据结构组织和存储数据,使得数据更容易访问。Python 提供了四种数据结构:列表、元组、字典和集合。这些结构易于使用,但也可以用于处理复杂的数据操作,使得 Python 成为数据分析中最受欢迎的语言之一。
本章将介绍 Python 的四种内置数据结构,重点讲解如何利用这些特性,轻松构建功能性数据驱动应用程序,减少编码量。你还将学习如何将这些基本结构组合成更复杂的结构,如字典列表,从而更准确地表示现实世界中的对象。你将在自然语言处理领域和照片处理的简要介绍中应用这些知识。
列表
Python 的列表是一个有序的对象集合。列表中的元素由逗号分隔,整个列表被方括号括起来,如下所示:
[2,4,7]
['Bob', 'John', 'Will']
列表是可变的,这意味着你可以添加、删除和修改列表的元素。与后面会讨论的集合不同,列表可以包含重复元素。
列表包含表示通常相关、相似事物的元素,这些元素可以被逻辑地分组在一起。典型的列表只包含属于同一类别的元素(即同质数据,如人名、文章标题或参与者编号)。理解这一点对于选择适合任务的工具至关重要。如果你需要一个包含具有不同属性对象的结构,可以考虑使用元组或字典。
创建列表
要创建一个基本列表,只需将一系列元素放入方括号中,并将该序列赋值给一个变量名:
regions = ['Asia', 'America', 'Europe']
然而,在实际应用中,列表通常是通过动态方式从零开始填充的,通常使用一个循环,在每次迭代中计算出一个项目。在这种情况下,你的第一步是创建一个空列表,如下所示:
regions = []
一旦你创建了一个列表,就可以根据需要添加、删除和排序列表中的项目。你可以使用 Python 的各种列表对象方法来执行这些操作以及其他任务。
使用常见的列表对象方法
列表对象方法是实现特定行为的函数。在本节中,我们将介绍一些常见的列表对象方法,包括append()、index()、insert()和count()。为了练习使用它们,可以从创建一个空列表开始。接下来,你将逐步将其构建为一个待办事项列表,填充其中的任务并进行整理:
my_list = []
也许最常见的列表对象方法是append()。它将一个项目添加到列表的末尾。你可以使用append()将一些待办事项添加到你的待办列表中,如下所示:
my_list.append('Pay bills')
my_list.append('Tidy up')
my_list.append('Walk the dog')
my_list.append('Cook dinner')
该列表现在包含四个项目,按照它们被添加的顺序:
['Pay bills', 'Tidy up', 'Walk the dog', 'Cook dinner']
列表中的每个项都有一个数字键,称为 索引。这个特性使得列表能够保持项的特定顺序。Python 使用零基索引,这意味着序列中的第一个项被分配为索引 0。
要访问列表中的单个项,指定列表的名称,后跟所需项的索引,并用方括号括起来。例如,以下是如何打印你的待办事项列表中的第一个项:
print(my_list[0])
print() 函数输出如下内容:
Pay bills
你不仅可以使用列表的索引来访问所需的项,还可以在列表中的某个位置插入新项。假设你想在遛狗和做晚餐之间添加一个新任务。要进行这个插入,你首先使用 index() 方法确定要插入新项的位置之前的项的索引。这里,你将其存储在变量 i 中:
i = my_list.index('Cook dinner')
这将成为新任务的索引,你现在可以使用 insert() 方法将其添加,如下所示:
my_list.insert(i, 'Go to the pharmacy')
新任务被添加到列表的指定索引位置,所有后续任务的索引向后移动一位。更新后的列表如下所示:
['Pay bills', 'Tidy up', 'Walk the dog', 'Go to the pharmacy', 'Cook dinner']
因为列表允许重复项,你可能需要检查某个项在列表中出现的次数。可以使用 count() 方法来实现,如以下示例所示:
print(my_list.count('Tidy up'))
print() 函数只会显示列表中 'Tidy up' 的一个实例,但你可能会想把这个任务在你的每日列表中多次列出!
使用切片表示法
你可以使用 切片表示法 从顺序数据类型(如列表)中访问一系列项。要获取列表的切片,需要指定起始位置的索引和结束位置的索引加 1。用冒号分隔这两个索引,并将它们括在方括号中。例如,你可以按如下方式打印你的待办事项列表中的前三个项:
print(my_list[0:3])
结果是一个包含索引从 0 到 2 的项的列表:
['Pay bills', 'Tidy up', 'Walk the dog']
切片中的起始和结束索引都是可选的。如果省略起始索引,切片将从列表的开头开始。这意味着前面的示例中的切片可以安全地改为:
print(my_list[:3])
如果省略结束索引,切片将一直延续到列表的末尾。以下是如何打印索引为 3 或更高的项:
print(my_list[3:])
结果是你待办事项列表中的最后两项:
['Go to the pharmacy', 'Cook dinner']
最后,你可以省略两个索引,在这种情况下,你将得到整个列表的副本:
print(my_list[:])
结果是:
['Pay bills', 'Tidy up', 'Walk the dog', 'Go to the pharmacy', 'Cook dinner']
切片表示法不仅限于从列表中提取子序列。你还可以使用它来代替 append() 和 insert() 方法,将数据填充到列表中。例如,在这里,你将两个项添加到列表的末尾:
my_list[len(my_list):] = ['Mow the lawn', 'Water plants']
len() 函数返回列表中项的数量,这也是列表之外第一个未使用位置的索引。你可以从这个索引开始安全地添加新项。现在列表如下所示:
['Pay bills', 'Tidy up', 'Walk the dog', 'Go to the pharmacy', 'Cook dinner',
'Mow the lawn', 'Water plants']
类似地,你可以使用del命令和切片来移除项目,如下所示:
del my_list[5:]
这会移除索引为 5 及以上的项,从而将列表恢复到之前的状态:
['Pay bills', 'Tidy up', 'Walk the dog', 'Go to the pharmacy', 'Cook dinner']
使用列表作为队列
队列是一种抽象数据类型,可以使用列表数据结构来实现。队列的一端总是用于插入元素(入队),另一端用于移除元素(出队),因此遵循先进先出(FIFO)方法论。在实际应用中,FIFO 方法论常用于仓储:最早到达仓库的商品是最先离开的商品。以这种方式组织商品销售有助于防止商品过期,确保先销售较旧的商品。
使用 Python 的deque对象(双端队列的缩写)将 Python 列表转变为队列非常简单。在这一部分,我们将通过使用你的待办事项列表来探索这一方法。为了让列表像队列一样工作,已完成的任务应该从列表的开头移除,而新任务则出现在列表的末尾,正如图 2-1 所示。

图 2-1:使用列表作为队列的示例
下面是如何实现图中所示的过程:
from collections import deque
queue = deque(my_list)
queue.append('Wash the car')
print(queue.popleft(), ' - Done!')
my_list_upd = list(queue)
在这个脚本中,你首先将之前示例中的my_list对象转换为deque对象,它是 Python collections模块的一部分。deque()对象构造器为传入的列表对象添加了一些方法,使得该列表更容易用作队列。在这个特定的示例中,你使用append()方法向队列的右侧添加了一个新元素,然后使用popleft()方法从队列的左侧移除一个元素。这个方法不仅移除最左边的元素,还会返回该元素,从而将它传入打印消息中。你应该看到以下消息作为结果:
Pay bills - Done!
在脚本的最后一行将deque对象转换回列表后,更新后的待办事项列表如下所示:
['Tidy up', 'Walk the dog', 'Go to the pharmacy', 'Cook dinner', 'Wash the car']
如你所见,列表中的第一个元素已经被推出,而一个新的元素被附加到了列表中。
使用列表作为栈
像队列一样,栈是一种抽象数据结构,你可以在列表上组织它。栈实现了后进先出(LIFO)方法论,最后加入的元素是第一个被取出的元素。为了让你的待办事项列表像栈一样工作,你需要按反向顺序完成任务,从最右边的任务开始。以下是如何在 Python 中实现这一概念:
my_list = ['Pay bills', 'Tidy up', 'Walk the dog', 'Go to the pharmacy', 'Cook dinner']
stack = []
for task in my_list:
stack.append(task)
while stack:
print(stack.pop(), ' - Done!'))
print('\nThe stack is empty')
在for循环中,你将待办事项列表中的任务逐一推入一个作为栈定义的另一个列表,从第一个任务开始。这是一个在循环中使用append()动态填充空列表的示例。然后,在while循环中,你从栈中移除任务,从最后一个任务开始。你通过pop()方法完成这项操作,该方法移除列表中的最后一个项目并返回被移除的项目。栈的输出将如下所示:
Cook dinner - Done!
Go to the pharmacy - Done!
Walk the dog - Done!
Tidy up - Done!
Pay bills - Done!
The stack is empty
使用列表和栈进行自然语言处理
列表和栈在许多现实世界的应用中都有广泛应用,包括自然语言处理领域。例如,你可以使用列表和栈来从文本中提取所有名词短语。一个名词短语由一个名词和其左侧句法子节点组成(即所有在名词左侧且句法依赖于名词的单词,如形容词或限定词)。因此,要从文本中提取名词短语,你需要搜索文本中的所有名词及其左侧句法子节点。这可以通过基于栈的算法来实现,如图 2-2 所示。

图 2-2:使用列表作为栈的示例
该图使用一个单一的名词短语作为示例,一个普遍存在的数据结构。右侧句法树中的箭头展示了如何将单词A、ubiquitous和data作为名词structure的句法子节点,structure是这些句法子节点的头。该算法从左到右逐个单词地分析文本,如果遇到名词或名词的左句法子节点,就将单词推入栈中。当算法遇到一个不符合此描述的单词,或者文本中没有剩余单词时,就找到了一个完整的名词短语,该短语会从栈中提取出来。
为了实现这个基于栈的名词短语提取算法,你需要安装 spaCy,领先的开源 Python 自然语言处理库,以及其中的一个英语模型。使用以下命令:
$ **pip install -U spacy**
$ **python -m spacy download en_core_web_sm**
以下脚本使用 spaCy 实现领先的开源 Python 自然语言处理库:
import spacy
txt = 'List is a ubiquitous data structure in the Python programming language.'
nlp = spacy.load('en_core_web_sm')
doc = nlp(txt)
stk = []
for w in doc:
if w.pos_ == 'NOUN' or w.pos_ == 'PROPN': ❶
stk.append(w.text)
elif (w.head.pos_ == 'NOUN' or w.head.pos_ == 'PROPN') and (w in w.head.lefts): ❷
stk.append(w.text)
elif stk: ❸
chunk = ''
while stk:
chunk = stk.pop() + ' ' + chunk ❹
print(chunk.strip())
脚本的前几行通过标准设置过程来分析一个使用 spaCy 处理的文本短语。你导入 spaCy 库,定义要处理的句子,并加载 spaCy 的英语模型。之后,你将nlp管道应用于句子,指示 spaCy 生成该句子的句法结构,这是进行名词短语提取等任务所必需的。
接下来,你实现之前描述的算法,遍历文本中的每个单词。如果你找到一个名词❶或其左语法子节点之一❷,你就通过append()操作将其加入栈中。你通过使用 spaCy 内置的属性来做出这些判断,例如w.head.lefts,它们允许你遍历句子的语法结构并在其中查找所需的单词。因此,使用w in w.head.lefts,你查找单词的主语(w.head),然后查找该主语的左语法子节点(.lefts),并确定该单词是否是其中之一。例如,在评估单词ubiquitous时,w.head会得到structure,它是ubiquitous的语法主语,而structure的.lefts会得到单词a、ubiquitous和data,这表明ubiquitous确实是structure的左孩子。
完成算法后,一旦你确定文本中的下一个单词不属于当前名词短语(既不是名词,也不是名词的左孩子)❸,你就得到了一个完整的名词短语,并从栈中提取出单词❹。该脚本会找到并输出以下三个名词短语:
List
a ubiquitous data structure
the Python programming language.
使用列表推导式进行改进
在第一章中,你看到过一个使用列表推导式创建列表的例子。在这一节中,我们将使用列表推导式来改进我们的名词短语提取算法。改进解决方案的功能通常需要对现有代码进行显著增强。然而,在这个案例中,由于涉及到列表推导式,所做的增强将非常简洁。
看一下图 2-2 中显示的语法依赖树,你可能会注意到那里所展示的每个短语元素都与名词structure通过语法弧线直接关联。然而,名词短语也可以遵循另一种模式,其中一些单词并未通过直接的语法关系与短语的名词连接。图 2-3 说明了这种短语的依赖树可能是什么样的。请注意副词most是形容词useful的孩子,而不是名词type的孩子,但它仍然是以type为主语的名词短语的一部分。

图 2-3:一个更复杂的名词短语的语法依赖树
我们需要改进上一节中的脚本,以便它也能提取像图 2-3 中所示的名词短语,其中一些词并未直接连接到短语的名词。为了优化我们的算法,我们首先通过比较图 2-2 和图 2-3 中展示的句法依赖树,找出它们的相似之处。重要的相似之处在于,在这两棵树中,作为名词短语依赖的每个单词的词头都可以在该单词的右侧找到。然而,构成短语的名词可能不遵循这个模式。例如,在句子“List is a ubiquitous data structure in the Python programming language.”中,单词structure是名词短语的词头,但它的词头is位于其左侧。为了确保这一点,你可以运行以下脚本,输出句子中每个单词的词头:
txt = 'List is a ubiquitous data structure in the Python programming language.'
import spacy
nlp = spacy.load('en')
doc = nlp(txt)
for t in doc:
print(t.text, t.head.text)
我们的新算法需要扫描文本,寻找其右侧有词头的单词,从而指示潜在的名词短语。其思路是为句子创建一种矩阵,指示一个单词的词头是否在其右侧。为了提高可读性,词头在右侧的单词可以按照它们在句子中的顺序包含在矩阵中,而其他所有单词则用零替代。因此,对于以下句子:
List is arguably the most useful type in the Python programming language.
你将得到以下矩阵:
['List', 0, 0, 'the', 'most', 'useful', 0, 0, 'the', 'Python', 'programming', 0, 0]
你可以通过列表推导式来创建这个矩阵:
txt = 'List is arguably the most useful type in the Python
programming language.'
import spacy
nlp = spacy.load('en')
doc = nlp(txt)
❶ head_lefts = [t.text if t in t.head.lefts else 0 for t in doc]
print(head_lefts)
在这里,你在列表推导式中的循环中遍历所提交句子的单词,对那些词头不在右侧的单词用零进行替换❶。
生成的列表如下所示:
['List', 0, 0, 'the', 'most', 'useful', 0, 0, 'the', 'Python', 'programming', 0, 0]
你可能会注意到,列表中包含的元素比句子中的单词数量多一个。这是因为 spaCy 实际上将文本拆分成标记(tokens),这些标记可能是单词或标点符号。列表中的最终0表示句子末尾的句号。
现在你需要一种方法来遍历这个列表,以便找到并提取名词短语。你需要创建一系列文本片段,每个片段从某个位置开始,并一直延续到文本的末尾。在下面的代码片段中,你从开始处逐词遍历文本的其余部分,在每次迭代中生成一个矩阵,用于表示每个单词词头的左右位置:
for w in doc:
head_lefts = [t.text if t in t.head.lefts else 0 for t in ❶ doc[w.i:]]
print(head_lefts)
你使用doc对象中的切片表示法来获取所需的文本片段❶。这个机制允许你在每次for循环迭代中,将结果切片的最左边位置向右移动一个单词。代码生成了以下矩阵集合:
['List', 0, 0, 'the', 'most', 'useful', 0, 0, 'the', 'Python', 'programming', 0, 0]
[0, 0, 'the', 'most', 'useful', 0, 0, 'the', 'Python', 'programming', 0, 0]
[0, 'the', 'most', 'useful', 0, 0, 'the', 'Python', 'programming', 0, 0]
['the', 'most', 'useful', 0, 0, 'the', 'Python', 'programming', 0, 0]
['most', 'useful', 0, 0, 'the', 'Python', 'programming', 0, 0]
['useful', 0, 0, 'the', 'Python', 'programming', 0, 0]
[0, 0, 'the', 'Python', 'programming', 0, 0]
[0, 'the', 'Python', 'programming', 0, 0]
['the', 'Python', 'programming', 0, 0]
['Python', 'programming', 0, 0]
['programming', 0, 0]
[0, 0]
[0]
接下来,你必须分析每个片段,寻找第一个零。到达该零并包括该零的所有单词可能构成一个名词短语。以下是实现这一点的代码:
for w in doc:
head_lefts = [t.text if t in t.head.lefts else 0 for t in doc[w.i:]]
❶ i0 = head_lefts.index(0)
if i0 > 0:
❷ noun = [1 if t.pos_== 'NOUN' or t.pos_== 'PROPN' else 0 for t in
reversed(doc[w.i:w.i+i0 +1])]
try:
❸ i1 = noun.index(1)+1
except ValueError:
pass
print(head_lefts[:i0 +1])
❹ print(doc[w.i+i0 +1-i1])
你将i0设置为head_lefts.index(0),以查找片段中第一个零的索引 ❶。如果有多个零元素,head_lefts.index(0)返回第一个元素的索引。然后,你检查i0 > 0,以筛选出那些不以头部左侧元素开始的片段。
然后,你使用另一个列表推导式来处理要发送到堆栈的名词短语元素。在这个第二个列表推导式中,你会查找每个片段中可能是名词短语的名词或专有名词。你逆序遍历片段,首先选取构成名词短语的名词或专有名词,因此它应该出现在片段的最后位置 ❷。当你找到名词或专有名词时,实际发送到列表的是1,其他元素则发送0。因此,列表中找到的第一个1表示片段中主名词相对于片段末尾的位置 ❸。在计算表示名词短语的文本切片时,你需要用到它 ❹。
目前,你只需输出生成的片段以及其中找到的名词。你将看到以下输出:
['List', 0]
List
['the', 'most', 'useful', 0]
type
['most', 'useful', 0]
type
['useful', 0]
type
['the', 'Python', 'programming', 0]
language
['Python', 'programming', 0]
language
['programming', 0]
language
现在,你可以将新代码融入到前一节中介绍的解决方案中。将所有内容结合在一起,你将得到以下脚本:
txt = 'List is arguably the most useful type in the Python
programming language.'
import spacy
nlp = spacy.load('en')
doc = nlp(txt)
stk = []
❶ for w in doc:
❷ head_lefts = [1 if t in t.head.lefts else 0 for t in doc[w.i:]]
i0 = 0
try: i0 = head_lefts.index(0)
except ValueError: pass
i1 = 0
if i0 > 0:
noun = [1 if t.pos_== 'NOUN' or t.pos_== 'PROPN' else 0 for t in
reversed(doc[w.i:w.i+i0 +1])]
try: i1 = noun.index(1)+1
except ValueError: pass
if w.pos_ == 'NOUN' or w.pos_ == 'PROPN':
❸ stk.append(w.text)
elif (i1 > 0):
❹ stk.append(w.text)
elif stk:
chunk = ''
while stk:
❺ chunk = stk.pop() + ' ' + chunk
print(chunk.strip())
你遍历提交的句子中的标记 ❶,在每次迭代中生成一个head_lefts列表 ❷。回想一下,这个列表是一个矩阵,其中包含那些句子中其句法主语位于其左侧的单词的零。这些矩阵用于识别名词短语。对于你识别的每个名词短语,你将每个名词或专有名词发送到堆栈 ❸,以及属于该短语但不是名词的其他单词 ❹。一旦到达名词短语的末尾,你从堆栈中提取标记,形成一个短语 ❺。
脚本将产生以下输出:
List
the most useful type
the Python programming language
元组
像列表一样,元组是一个有序的对象集合。然而,与列表不同的是,元组是不可变的。一旦创建了一个元组,它就不能被更改。元组中的项由逗号分隔,并可以选择性地用括号括起来,如下所示:
('Ford', 'Mustang', 1964)
元组通常用于存储异构数据集合;也就是说,存储不同类型的数据,例如汽车的品牌、型号和年份。正如这个例子所示,当你需要一个结构来保存现实世界对象的属性时,元组特别有用。
元组列表
在 Python 中,将数据结构嵌套在一起是常见的做法。例如,你可以有一个列表,每个元素是一个元组,这让你能够为列表中的每个元素分配多个属性。假设你想为之前章节中创建的待办事项列表中的每个任务分配一个开始时间。列表中的每个项目将成为一个数据结构,由两个元素组成:任务描述和计划的开始时间。
为了实现这样的结构,元组是理想的选择,因为它们旨在将异构数据收集到一个结构中。你的元组列表可能如下所示:
[('8:00','Pay bills'), ('8:30','Tidy up'), ('9:30','Walk the dog'),
('10:00','Go to the pharmacy'), ('10:30','Cook dinner')]
你可以从以下两个简单的列表构建这个元组列表:
task_list = ['Pay bills', 'Tidy up', 'Walk the dog', 'Go to the pharmacy', 'Cook dinner']
tm_list = ['8:00', '8:30', '9:30', '10:00', '10:30']
如你所见,第一个列表是原始的 my_list,第二个是包含对应开始时间的列表。将它们组合成元组列表的最简单方法是使用列表推导式,如下所示:
sched_list = [(tm, task) for tm, task in zip(tm_list, task_list)]
在列表推导式中,你使用了 Python 的 zip() 函数,它同时遍历这两个简单的列表,将相应的时间和任务组合成元组。
与列表一样,要访问元组中的元素,你需要在元组名称后面指定元素的索引,并将其用方括号括起来。然而,值得注意的是,嵌套在列表中的元组没有名称。要访问嵌套元组中的元素,你首先需要指定列表的名称,然后是列表中元组的索引,最后是元组中元素的索引。例如,要查看待办事项列表中第二个任务分配的时间,你可以使用以下语法:
print(sched_list[1][0])
这将生成以下输出:
8:30
不可变性
需要记住的一个重要点是,元组是不可变的。也就是说,你不能修改它们。例如,如果你尝试更改某个任务的开始时间:
sched_list[1][0] = '9:00'
你会收到以下错误:
TypeError: 'tuple' object does not support item assignment
由于元组是不可变的,它们不适合存储需要定期更新的数据值。
字典
字典 是 Python 中另一种广泛使用的内置数据结构。字典是可变的、无序的 键值对 集合,其中每个 键 是一个唯一的名称,用于标识数据项,即 值。字典由大括号界定。每个键与其值之间由冒号分隔,键值对之间由逗号分隔,如下所示:
{'Make': 'Ford', 'Model': 'Mustang', 'Year': 1964}
字典和元组一样,适用于存储关于现实世界对象的异构数据。正如这个示例所展示的,字典具有为每个数据项分配标签的额外好处。
字典列表
像其他数据结构一样,字典可以嵌套在其他结构中。将待办事项列表实现为字典的列表时,可能会如下所示:
dict_list = [
{'time': '8:00', 'name': 'Pay bills'},
{'time': '8:30', 'name': 'Tidy up'},
{'time': '9:30', 'name': 'Walk the dog'},
{'time': '10:00', 'name': 'Go to the pharmacy'},
{'time': '10:30', 'name': 'Cook dinner'}
]
与元组不同,字典是可变的,这意味着你可以轻松更改键值对中的值:
dict_list[1]['time'] = '9:00'
这个示例还展示了如何访问字典中的值:与列表和元组不同,你使用的是键名而非数字索引。
使用 setdefault() 向字典添加数据
setdefault() 方法提供了一种便捷的方式向字典添加新数据。它以键值对作为参数。如果指定的键已经存在,方法会直接返回该键的当前值。如果该键不存在,setdefault() 会插入该键并赋予指定的值。要查看示例,首先创建一个名为 car 的字典,其中模型是 Jetta:
car = {
"brand": "Volkswagen",
"style": "Sedan",
"model": "Jetta"
}
现在,尝试使用setdefault()添加一个新的model键,并将其值设置为Passat:
print(car.setdefault("model", "Passat"))
这将产生以下输出,显示model键的值保持不变:
Jetta
然而,如果你指定一个新的键,setdefault()会插入键值对并返回值:
print(car.setdefault("year", 2022))
输出将如下所示:
2022
如果现在打印整个字典:
print(car)
这就是你所看到的:
{
"brand": "Volkswagen",
"style": "Sedan",
"model": "Jetta",
"year": 2022
}
正如你所看到的,setdefault()方法使你无需手动检查要插入的键值对中的键是否已经存在于字典中。你可以安全地尝试将一个键值对插入字典,而不用担心覆盖已存在键的值。
现在你已经了解了setdefault()的工作原理,接下来我们来看一个实际的例子。统计文本中每个单词出现的次数是自然语言处理(NLP)中的常见任务。以下示例演示了如何借助字典,使用setdefault()方法来完成这个任务。以下是你需要处理的文本:
txt = '''Python is one of the most promising programming languages today. Due to the simplicity of Python syntax, many researchers and scientists prefer Python over many other languages.'''
第一步是去除文本中的标点符号。如果不这样做,'languages'和'languages.'会被算作两个不同的单词。这里,我们去掉句号和逗号:
txt = txt.replace('.', '').replace(',', '')
接下来,你将文本分割成单词,并将它们放入一个列表中:
lst = txt.split()
print(lst)
生成的单词列表如下:
['Python', 'is', 'one', 'of', 'the', 'most', 'promising', 'programming',
'languages', 'today', 'Due', 'to', 'the', 'simplicity', 'of', 'Python',
'syntax', 'many', 'researchers', 'and', 'scientists', 'prefer', 'Python',
'over', 'many', 'other', 'languages']
现在你可以计算列表中每个单词的出现次数。可以使用字典和setdefault()方法来实现,代码如下:
dct = {}
for w in lst:
c = dct.setdefault(w,0)
dct[w] += 1
首先,你创建一个空字典。然后,你将列表中的单词作为键,使用setdefault()方法将每个键的初始值设置为0。当每个单词第一次出现时,值会增加 1,得到计数 1。对于该单词的后续出现,setdefault()会保持之前的计数值不变,但会通过+=运算符将计数值增加 1,从而得到准确的计数。
在输出字典之前,你可能想根据单词出现的次数对其进行排序:
dct_sorted = dict(sorted(dct.items(), key=lambda x: x[1], reverse=True))
print(dct_sorted)
使用字典的items()方法,你可以将该字典转换为一个元组列表,其中每个元组包含一个键及其值。因此,当你在sorted()函数的key参数中为lambda指定x[1]时,你实际上是按照元组中索引为1的项(即原字典中的值,表示单词计数)进行排序。排序后的字典如下所示:
{'Python': 3, 'of': 2, 'the': 2, 'languages': 2, 'many': 2, 'is': 1, 'one': 1,
'most': 1, 'promising': 1, 'programming': 1, 'today': 1, 'Due': 1, 'to': 1,
'simplicity': 1, 'syntax': 1, 'researchers': 1, 'and': 1, 'scientists': 1,
'prefer': 1, 'over': 1, 'other': 1}
将 JSON 加载到字典中
借助字典,你可以轻松地将 Python 数据结构转换为 JSON 字符串,反之亦然。以下是如何仅使用赋值运算符将表示 JSON 文档的字符串加载到字典中的方法:
d = { "PONumber" : 2608,
"ShippingInstructions" : {"name" : "John Silver",
"Address": { "street" : "426 Light Street",
"city" : "South San Francisco",
"state" : "CA",
"zipCode" : 99237,
"country" : "United States of America" },
"Phone" : [ { "type" : "Office", "number" : "809-123-9309" },
{ "type" : "Mobile", "number" : "417-123-4567" }
]
}
}
正如你可能注意到的,这个字典具有复杂的结构。ShippingInstructions键的值本身是一个字典,其中Address键的值又是一个字典,而Phone键的值是一个字典列表。
你可以使用 Python 的json模块通过json.dump()方法将字典直接保存到 JSON 文件中:
import json
with open("po.json", "w") as outfile:
json.dump(d, outfile)
类似地,你可以使用json.load()方法将 JSON 文件的内容直接加载到 Python 字典中:
with open("po.json",) as fp:
d = json.load(fp)
结果是你得到的字典与本节开始时展示的字典相同。我们将在第四章详细讨论文件操作。
集合
Python 中的集合是一个无序的唯一项集合。集合中不允许有重复项。集合通过花括号定义,其中包含由逗号分隔的项,如下所示:
{'London', 'New York', 'Paris'}
从序列中删除重复项
由于集合的成员必须是唯一的,因此集合在需要从列表或元组中删除重复项时非常有用。假设某个企业想查看其客户的列表。你可能通过从他们所下的订单中提取客户的名字来获得这样的列表。由于一个客户可能下了多个订单,列表中可能会有重复的名字。可以通过使用集合来删除这些重复项,如下所示:
lst = ['John Silver', 'Tim Jemison', 'John Silver', 'Maya Smith']
lst = list(set(lst))
print(lst)
你只需将原始列表转换为集合,再转换回列表。集合构造函数会自动删除重复项。更新后的列表大致如下:
['Maya Smith', 'Tim Jemison', 'John Silver']
这种方法的一个缺点是它不能保留元素的初始顺序。这是因为集合是无序的项集合。实际上,如果你运行前面的代码两到三次,输出的顺序可能每次都不同。
为了在不丢失初始顺序的情况下执行相同的操作,可以使用 Python 的sorted()函数,如下所示:
lst = ['John Silver', 'Tim Jemison', 'John Silver', 'Maya Smith']
lst = list(sorted(set(lst), key=lst.index))
这会根据原始列表的索引对集合进行排序,从而保留顺序。更新后的列表如下:
['John Silver', 'Tim Jemison', 'Maya Smith']
执行常见的集合操作
集合对象带有用于对序列执行常见数学操作的方法,如并集和交集。这些方法使你可以轻松地合并集合或提取多个集合之间共享的元素。
假设你需要根据照片中的内容将大量照片分类。为了自动化这个任务,你可能会使用像 Clarifai API 这样的视觉识别工具,它会为每张照片生成一组描述性标签。然后,可以使用intersection()方法对这些标签集合进行比较。这个方法比较两个集合,并创建一个新集合,包含两个集合中都存在的所有元素。在这个特定的案例中,两个集合中标签数量越多,说明这两张照片的主题越相似。
为简便起见,以下示例仅使用两张照片。通过它们对应的描述性标签集合,你可以确定两张照片的主题内容重合的程度:
photo1_tags = {'coffee', 'breakfast', 'drink', 'table', 'tableware', 'cup', 'food'}
photo2_tags = {'food', 'dish', 'meat', 'meal', 'tableware', 'dinner', 'vegetable'}
intersection = photo1_tags.intersection(photo2_tags)
if len(intersection) >= 2:
print("The photos contain similar objects.")
在这段代码中,你执行交集操作来找到两个集合之间共享的项。如果两个集合中共同的项的数量大于或等于两个,则可以得出结论:这些照片具有相似的主题,因此可以将它们分组在一起。
总结
本章涵盖了 Python 的四种内置数据结构:列表、元组、字典和集合。你看到了大量的示例,展示了这些结构如何表示现实世界中的对象,同时你还学习了如何将它们组合成嵌套结构,包括元组的列表、字典的列表,以及值为列表的字典。
本章还探讨了让你能够轻松构建功能性数据分析应用程序的特性。例如,你学习了如何使用列表推导式从现有列表创建新列表,以及如何使用 setdefault() 方法高效地访问和操作字典中的数据。通过示例,你看到了这些特性如何应用于常见的挑战,如文本处理和照片分析。
第三章:Python 数据科学库

Python 提供了访问一个强大的第三方库生态系统,这些库对于数据分析和处理非常有用。本章将向你介绍三种较为流行的数据科学库:NumPy、pandas 和 scikit-learn。正如你所见,许多数据分析应用程序都广泛使用这些库,无论是显式使用还是隐式使用。
NumPy
NumPy,或称为数值 Python 库,对于处理数组非常有用,数组是一种存储相同数据类型值的数据结构。许多执行数值计算的 Python 库都依赖于 NumPy。
NumPy 数组是 NumPy 库的关键组成部分,它是一个元素类型相同的网格。NumPy 数组中的元素由一组非负整数元组进行索引。NumPy 数组类似于 Python 列表,不同之处在于它们需要更少的内存,并且通常更快,因为它们使用了优化过的预编译 C 代码。
NumPy 数组支持元素级操作,这使你可以使用简洁且易读的代码对整个数组执行基本的算术运算。元素级操作是对两个具有相同维度的数组执行操作,结果是一个相同维度的新数组,其中每个元素i,j 是对原始两个数组的元素i,j 进行计算后的结果。图 3-1 展示了对两个 NumPy 数组执行元素级操作的示意图。

图 3-1:添加两个 NumPy 数组
正如你所见,结果数组具有与原始两个数组相同的维度,每个新元素是原始数组中对应元素的和。
安装 NumPy
NumPy 是一个第三方库,这意味着它不是 Python 标准库的一部分。最简单的安装方法是使用以下命令:
$ **pip install NumPy**
Python 将 NumPy 视为一个模块,因此你需要在脚本中导入它才能使用。
创建 NumPy 数组
你可以通过一个或多个 Python 列表中的数据创建 NumPy 数组。假设你为每个员工创建一个列表,包含该员工过去三个月的基本薪资支付。你可以使用如下代码将所有薪资信息放入一个数据结构中:
❶ import numpy as np
❷ jeff_salary = [2700,3000,3000]
nick_salary = [2600,2800,2800]
tom_salary = [2300,2500,2500]
❸ base_salary = np.array([jeff_salary, nick_salary, tom_salary])
print(base_salary)
你可以通过导入 NumPy 库 ❶ 开始。然后定义一组列表,每个列表包含员工过去三个月的基本薪资数据 ❷。最后,将这些列表合并为一个 NumPy 数组 ❸。数组看起来是这样的:
[[2700 3000 3000]
[2600 2800 2800]
[2300 2500 2500]]
这是一个二维数组。它有两个轴,轴的索引是整数,从 0 开始。轴 0 垂直向下运行,跨越数组的行,而轴 1 水平运行,跨越数组的列。
你可以按照相同的步骤创建一个包含员工月度奖金的数组:
jeff_bonus = [500,400,400]
nick_bonus = [600,300,400]
tom_bonus = [200,500,400]
bonus = np.array([jeff_bonus, nick_bonus, tom_bonus])
执行逐元素操作
对多个维度相同的 NumPy 数组执行逐元素操作非常简单。例如,您可以将 base_salary 和 bonus 数组合并在一起,以确定每个月支付给每个员工的总金额:
❶ salary_bonus = base_salary + bonus
print(type(salary_bonus))
print(salary_bonus)
如您所见,添加操作是一行代码❶。结果数据集也是一个 NumPy 数组,其中每个元素是 base_salary 和 bonus 数组中对应元素的和:
<class 'NumPy.ndarray'>
[[3200 3400 3400]
[3200 3100 3200]
[2500 3000 2900]]
使用 NumPy 统计函数
NumPy 的统计函数允许您分析数组的内容。例如,您可以查找整个数组的最大值,或者沿着给定的轴查找数组的最大值。
假设您想要查找上节中创建的 salary_bonus 数组中的最大值。您可以使用 NumPy 数组的 max() 函数来实现:
print(salary_bonus.max())
该函数返回数据集中过去三个月内支付给任何员工的最高金额:
3400
NumPy 还可以沿着给定的轴查找数组的最大值。如果您想确定过去三个月支付给每个员工的最高金额,您可以使用 NumPy 的 amax() 函数,如下所示:
print(np.amax(salary_bonus, axis = 1))
通过指定 axis = 1,您指示 amax() 函数横向遍历列,查找 salary_bonus 数组中的最大值,从而对每一行应用该函数。这将计算过去三个月内每个月支付给每个员工的最高金额:
[3400 3200 3000]
同样,您可以通过将 axis 参数设置为 0 来计算每个月支付给任何员工的最高金额:
print(np.amax(salary_bonus, axis = 0))
结果如下:
[3200 3400 3400]
pandas
pandas 库是数据导向 Python 应用程序的事实标准。(如果您在想,名字源自 Python 数据分析库。)该库包含两种数据结构:Series(一维)和 DataFrame(二维)。虽然 DataFrame 是 pandas 的主要数据结构,但 DataFrame 实际上是多个 Series 对象的集合。因此,理解 Series 和 DataFrame 都很重要。
pandas 安装
标准的 Python 发行版并不包含 pandas 模块。您可以使用以下命令安装 pandas:
$ **pip install pandas**
pip 命令还会解决库的依赖关系,隐式地安装 NumPy、pytz 和 python-dateutil 包。
就像使用 NumPy 一样,您需要先将 pandas 模块导入到脚本中,才能使用它。
pandas Series
pandas Series 是一个一维标记数组。默认情况下,Series 中的元素根据其位置用整数标记,就像 Python 列表中的元素一样。然而,您也可以指定自定义标签。这些标签不必唯一,但必须是可哈希类型,如整数、浮动、字符串或元组。
Series 的元素可以是任何类型(整数、字符串、浮点数、Python 对象等),但如果 Series 中的所有元素类型相同,它的工作效果最好。最终,Series 可能成为一个更大 DataFrame 中的一列,而且你不太可能希望将不同类型的数据存储在同一列中。
创建一个 Series
创建 Series 有多种方式。在大多数情况下,你会传入某种 1D 数据集。以下是如何从 Python 列表创建 Series:
❶ import pandas as pd
❷ data = ['Jeff Russell','Jane Boorman','Tom Heints']
❸ emps_names = pd.Series(data)
print(emps_names)
你首先导入 pandas 库并将其别名为 pd ❶。然后,你创建一个用于 Series 数据的项列表 ❷。最后,你创建 Series,并将该列表传入 Series 构造方法 ❸。
这样,你就得到了一个默认从 0 开始的数字索引的单一列表:
0 Jeff Russell
1 Jane Boorman
2 Tom Heints
dtype: object
dtype 属性指示给定 Series 的底层数据类型。默认情况下,pandas 使用 object 数据类型来存储字符串。
你可以按如下方式创建一个具有用户定义索引的 Series:
data = ['Jeff Russell','Jane Boorman','Tom Heints']
emps_names = pd.Series(data,index=[9001,9002,9003])
print(emps_names)
这时 emps_names Series 对象中的数据如下所示:
9001 Jeff Russell
9002 Jane Boorman
9003 Tom Heints
dtype: object
访问 Series 中的数据
要访问 Series 中的元素,指定 Series 名称并在方括号内跟上元素的索引,如下所示:
print(emps_names[9001])
这将输出对应索引 9001 的元素:
Jeff Russell
或者,你可以使用 Series 对象的 loc 属性:
print(emps_names.loc[9001])
尽管在这个 Series 对象中使用了自定义索引,但你仍然可以通过位置(即使用基于整数位置的索引)来访问它的元素,通过 iloc 属性。例如,在这里,你可以打印 Series 中的第一个元素:
print(emps_names.iloc[0])
你可以通过切片操作按索引访问多个元素,正如第二章所讨论的那样:
print(emps_names.loc[9001:9002])
这将产生如下输出:
9001 Jeff Russell
9002 Jane Boorman
注意,使用 loc 切片时包括右端点(在此案例中是索引 9002),而通常 Python 的切片语法是不包括右端点的。
你也可以使用切片操作根据位置而非索引来定义元素的范围。例如,前面的结果可以通过以下代码生成:
print(emps_names.iloc[0:2])
或者简单地如下:
print(emps_names[0:2])
正如你所看到的,和 loc 切片不同,使用 [] 或 iloc 切片与普通的 Python 切片相同:起始位置包括,但停止位置不包括。因此,[0:2] 会跳过位置 2 的元素,只返回前两个元素。
将多个 Series 合并为 DataFrame
多个 Series 可以结合成一个 DataFrame。我们可以通过创建另一个 Series,并将其与 emps_names Series 合并,来尝试这一点:
data = ['jeff.russell','jane.boorman','tom.heints']
❶ emps_emails = pd.Series(data,index=[9001,9002,9003], name = 'emails')
❷ emps_names.name = 'names'
❸ df = pd.concat([emps_names,emps_emails], axis=1)
print(df)
要创建新的 Series,你需要调用 Series() 构造函数 ❶,并传入以下参数:要转换为 Series 的列表、Series 的索引以及 Series 的名称。
在将多个 Series 合并成 DataFrame 之前,你需要先为它们命名,因为它们的名称将成为相应 DataFrame 列的名称。由于你在之前创建 emps_names Series 时没有命名它,所以在此处通过设置它的 name 属性为 'names' ❷ 来命名。之后,你可以将它与 emps_emails Series ❸ 合并。你需要指定 axis=1,以便沿着列方向进行合并。
生成的 DataFrame 如下所示:
names emails
9001 Jeff Russell jeff.russell
9002 Jane Boorman jane.boorman
9003 Tom Heints tom.heints
pandas DataFrame
pandas DataFrame 是一个二维标签化的数据结构,具有不同类型的列。可以将 DataFrame 看作是一个类似字典的容器,其中每个字典的键是列标签,每个值是一个 Series。
如果你熟悉关系型数据库,你会发现 pandas DataFrame 类似于普通的 SQL 表。图 3-2 展示了一个 pandas DataFrame 的示例。

图 3-2:一个 pandas DataFrame 的示例
请注意,DataFrame 包含一个索引列。与 Series 一样,pandas 默认使用基于零的数字索引。但你可以用一个或多个现有列替代默认的索引。图 3-3 显示了相同的 DataFrame,但将 Date 列设置为索引。

图 3-3:一个使用列作为索引的 pandas DataFrame
在这个特定的示例中,索引是一个 date 类型的列。事实上,pandas 允许你使用任何类型作为 DataFrame 的索引。最常用的索引类型是整数和字符串。然而,你并不限于使用简单类型。你可以定义一个序列类型的索引,比如 List 或 Tuple,甚至使用不是 Python 内置的对象类型;这可以是第三方类型,甚至是你自己的对象类型。
创建 pandas DataFrame
你已经看到可以通过将多个 Series 对象合并来创建 pandas DataFrame。你还可以通过从数据库、CSV 文件、API 请求或其他外部源加载数据来创建 DataFrame,方法是使用 pandas 库的 reader 方法。Reader 方法允许你将不同类型的数据,如 JSON 和 Excel,读取到 DataFrame 中。
考虑图 3-2 所示的 DataFrame。它可能是通过 yfinance 库向 Yahoo Finance API 发起请求的结果。要自己创建该 DataFrame,首先使用 pip 安装 yfinance,方法如下:
**$ pip install yfinance**
然后请求股票数据,如下所示:
import yfinance as yf
❶ tkr = yf.Ticker('TSLA')
❷ hist = tkr.history(period="5d")
❸ hist = hist.drop("Dividends", axis = 1)
hist = hist.drop("Stock Splits", axis = 1)
❹ hist = hist.reset_index()
在这个脚本中,你向 API 发送请求,获取给定股票代码的股票价格数据❶,并使用 yfinance 的 history() 方法指定你希望获取五天的数据❷。结果数据存储在变量 hist 中,已经是 pandas DataFrame 的形式。你不需要显式地创建 DataFrame;yfinance 会在后台为你处理。获取 DataFrame 后,你删除其中一些列❸,并切换为数字索引❹,得到如 图 3-2 所示的结构。
要将索引设置为 Date 列,如 图 3-3 所示,你需要执行以下代码行:
hist = hist.set_index('Date')
现在,让我们尝试将一个 JSON 文档转换为 pandas 对象。此处使用的示例数据集包含三名员工的每月薪资数据,每名员工通过其在 Empno 列中的 ID 进行标识:
import json
import pandas as pd
data = [
{"Empno":9001,"Salary":3000},
{"Empno":9002,"Salary":2800},
{"Empno":9003,"Salary":2500}
]
❶ json_data = json.dumps(data)
❷ salary = pd.read_json(json_data)
❸ salary = salary.set_index('Empno')
print(salary)
你使用 pandas 的 read_json() 读取方法将 JSON 字符串传递给 DataFrame❷。为了简单起见,本示例使用通过 json.dumps() 从列表转换的 JSON 字符串❶。或者,你可以将一个 path 对象传递给读取器,该对象指向一个 JSON 文件,或者指向发布 JSON 格式数据的 HTTP API 的 URL。最后,你将 Empno 列设置为 DataFrame 索引❸,从而替换掉默认的数字索引。
结果的 DataFrame 看起来是这样的:
Salary
Empno
9001 3000
9002 2800
9003 2500
另一种常见的做法是从上一章介绍的标准 Python 数据结构中创建 pandas DataFrame。例如,以下是如何从一个列表的列表中创建 DataFrame:
import pandas as pd
❶ data = [['9001','Jeff Russell', 'sales'],
['9002','Jane Boorman', 'sales'],
['9003','Tom Heints', 'sales']]
❷ emps = pd.DataFrame(data, columns = ['Empno', 'Name', 'Job'])
❸ column_types = {'Empno': int, 'Name': str, 'Job': str}
emps = emps.astype(column_types)
❹ emps = emps.set_index('Empno')
print(emps)
首先,你初始化一个包含待发送数据的列表列表❶。每个嵌套列表将成为 DataFrame 中的一行。然后,你显式地创建 DataFrame,定义要使用的列❷。接下来,你使用一个字典 column_types 来更改列的默认数据类型❸。这一步是可选的,但如果你计划将 DataFrame 与另一个 DataFrame 合并,这一步是至关重要的。因为你只能在相同数据类型的列上合并两个 DataFrame。最后,你将 Empno 列设置为 DataFrame 索引❹。结果的 DataFrame 看起来是这样的:
Name Job
Empno
9001 Jeff Russell sales
9002 Jane Boorman sales
9003 Tom Heints sales
请注意,emps 和 salary DataFrame 都使用 Empno 作为索引列,以唯一标识每一行。在这两种情况下,你都将该列设置为 DataFrame 索引,以简化将两个 DataFrame 合并成一个 DataFrame 的过程,我们将在下一节中讨论。
合并 DataFrame
pandas 允许你合并(或连接)DataFrame,就像你在关系数据库中连接不同的表一样。这使得你可以将数据聚合在一起进行分析。DataFrame 支持通过两种方法:merge() 和 join() 进行数据库风格的连接操作。尽管这两种方法有一些不同的参数,但你可以在大多数情况下互换使用它们。
首先,让我们连接上一节中定义的 emps 和 salary DataFrame。这是一个 一对一连接 的例子,因为一个 DataFrame 中的一行与另一个 DataFrame 中的单独一行相关联。图 3-4 说明了这是如何工作的。

图 3-4:连接两个具有一对一关系的 DataFrame
这里我们可以看到 emps DataFrame 和 salary DataFrame 中的一个条目。它们共享相同的索引值 9001,因此可以合并成一个新的 emps_salary DataFrame 中的单个条目。在关系型数据库术语中,通过这些列关联的表被称为 关键列。尽管 pandas 使用 index 来表示这些列,图 3-4 使用关键图标来做视觉关联。
借助 join() 方法,实施起来非常简单:
emps_salary = emps.join(salary)
print(emps_salary)
join() 方法旨在基于索引轻松地连接 DataFrame。在这个具体的例子中,你甚至不需要提供任何额外的参数来连接这两个 DataFrame;按索引连接是默认行为。
结果数据集如下所示:
Name Job Salary
Empno
9001 Jeff Russell sales 3000
9002 Jane Boorman sales 2800
9003 Tom Heints sales 2500
在实际操作中,即使其中一个 DataFrame 中的某些行在另一个 DataFrame 中没有匹配的行,你可能仍然需要连接这两个 DataFrame。假设你在 emps DataFrame 中有多出的一行,而在 salary DataFrame 中没有相应的行:
new_emp = pd.Series({'Name': 'John Hardy', 'Job': 'sales'}, name = 9004)
emps = emps.append(new_emp)
print(emps)
在这里,你创建一个 pandas Series 对象,并使用 append() 方法将其添加到 emps DataFrame 中。这是向 DataFrame 添加新行的常见方法。
更新后的 emps DataFrame 如下所示:
Name Job
Empno
9001 Jeff Russell sales
9002 Jane Boorman sales
9003 Tom Heints sales
9004 John Hardy sales
如果你现在再次应用连接操作:
emps_salary = emps.join(salary)
print(emps_salary)
结果是如下的 DataFrame:
Name Job Salary
Empno
9001 Jeff Russell sales 3000.0
9002 Jane Boorman sales 2800.0
9003 Tom Heints sales 2500.0
9004 John Hardy sales NaN
注意,添加到 emps DataFrame 中的那一行出现在结果数据集中,尽管它在 salary DataFrame 中没有相关联的行。最后一行中的 Salary 字段中的 NaN 条目表示该工资值缺失。在某些情况下,你可能希望允许像这样的不完整行,但在其他情况下,你可能希望排除那些在另一个 DataFrame 中没有相关行的行。
默认情况下,join() 方法在结果的连接 DataFrame 中使用调用 DataFrame 的索引,从而执行 左连接。在这个例子中,调用 DataFrame 是 emps,它被视为连接操作中的左侧 DataFrame,因此它的所有行都会包含在结果数据集中。你可以通过传递 how 参数到 join() 方法来改变这一默认行为。这个参数可以取以下值:
-
left使用调用 DataFrame 的索引(或者如果指定了on参数,则为其他列),返回调用(左侧)DataFrame 中的所有行以及来自另一个(右侧)DataFrame 的匹配行。 -
right使用另一个(右侧)DataFrame 的索引,返回该 DataFrame 中的所有行以及调用(左侧)DataFrame 中的匹配行。 -
outer形成调用 DataFrame 的索引(或者如果指定了on参数,则为其他列)与另一个 DataFrame 的索引的组合,返回两个 DataFrame 中的所有行。 -
inner形成调用的 DataFrame 的索引(或者如果指定了on参数,则为其他列)与另一个 DataFrame 的索引的交集,只返回在两个 DataFrame 中都出现的那些行。
图 3-5 展示了每种连接类型。

图 3-5:不同类型连接的结果。
如果你希望结果 DataFrame 仅包含 emps 中那些在 salary DataFrame 中有相关行的行,可以将 join() 的 how 参数设置为 inner:
emps_salary = emps.join(salary, how = 'inner')
print(emps_salary)
结果 DataFrame 看起来是这样的:
Name Job Salary
Empno
9001 Jeff Russell sales 3000
9002 Jane Boorman sales 2800
9003 Tom Heints sales 2500
获取此结果的另一种方法是将 right 作为 how 参数传递。在这种情况下,join() 会返回所有来自 salary DataFrame 的行,并将来自 emps DataFrame 的匹配行的字段附加到这些行上。然而,重要的是要意识到,右连接在许多其他情况下与内连接并不相等。例如,如果你向 salary DataFrame 添加一行,并且该行在 emps DataFrame 中没有匹配项,则右连接会包括这行以及与 emps 中匹配的行。
一对多连接
在 一对多连接 中,来自一个 DataFrame 的一行可以与来自另一个 DataFrame 的多行匹配。考虑这样一种情况:emps DataFrame 中的每个销售人员处理了若干订单。这可以在 orders DataFrame 中反映如下:
import pandas as pd
data = [[2608, 9001,35], [2617, 9001,35], [2620, 9001,139],
[2621, 9002,95], [2626, 9002,218]]
orders = pd.DataFrame(data, columns = ['Pono', 'Empno', 'Total'])
print(orders)
这是 orders DataFrame 的样子:
Pono Empno Total
0 2608 9001 35
1 2617 9001 35
2 2620 9001 139
3 2621 9002 95
4 2626 9002 218
现在你已经有了一个订单的 DataFrame,可以将它与之前定义的员工 DataFrame 结合。这是一个一对多连接,因为 emps DataFrame 中的一名员工可以与 orders DataFrame 中的多行关联:
emps_orders = emps.merge(orders, how='inner', left_on='Empno',
right_on='Empno').set_index('Pono')
print(emps_orders)
在这段代码中,你使用 merge() 方法定义一对多连接,将 emps 和 orders DataFrame 中的数据合并。merge() 方法允许你指定在两个 DataFrame 中进行连接的列,使用 left_on 来指定调用 DataFrame 中的列,使用 right_on 来指定另一个 DataFrame 中的列。使用 join() 时,你只能为调用 DataFrame 指定连接的列。对于另一个 DataFrame,join() 使用索引列。
在这个示例中,你使用了内连接类型,只包括两个 DataFrame 中相关的行。结果数据集如下所示:
Empno Name Job Total
Pono
2608 9001 Jeff Russell sales 35
2617 9001 Jeff Russell sales 35
2620 9001 Jeff Russell sales 139
2621 9002 Jane Boorman sales 95
2626 9002 Jane Boorman sales 218
图 3-6 展示了这种一对多连接是如何工作的。

图 3-6:连接具有一对多关系的两个 DataFrame
如你所见,图中的一对多连接在连接的多方每一行都对应数据集中的一行。由于你使用的是内连接类型,其他行将不会被包含。然而,在左连接或外连接的情况下,连接还会包含那些在一方数据集中没有与多方匹配的行。
除了一对多和一对一连接,还有多对多连接。以这种关系为例,考虑两个数据集:一个列出书籍,另一个列出作者。作者数据集中的每条记录可以与书籍数据集中的一条或多条记录关联,而书籍数据集中的每条记录也可以与一个或多个作者记录关联。我们将在第七章中讨论这种类型的关系,届时将更详细地讲解如何连接、合并和连接数据集。
使用 groupby()聚合数据
pandas 的groupby()函数可以让你在 DataFrame 的多行数据中进行聚合。例如,它可以求出某一列的总和,或计算某一列中某一部分值的平均值。
假设你需要计算orders DataFrame 中每个员工处理的订单的平均总额。你可以像下面这样使用groupby()函数:
print(orders.groupby(['Empno'])['Total'].mean())
groupby()返回一个支持多种聚合函数的 GroupBy 对象。在这个具体的例子中,你使用mean()来计算与员工相关联的订单组的平均总额。为此,你首先根据Empno列对orders DataFrame 中的行进行分组,然后对Total列应用mean()操作。生成的数据集是一个 Series 对象:
Empno
9001 69.666667
9002 156.500000
Name: Total, dtype: float64
现在假设你想要对每组的订单总数进行求和。这时,GroupBy 对象的sum()函数就派上用场了:
print(orders.groupby(['Empno'])['Total'].sum())
生成的 Series 对象中的数据如下:
Empno
9001 209
9002 313
Name: Total, dtype: int64
scikit-learn
scikit-learn 是一个专为机器学习应用设计的 Python 包。与 NumPy 和 pandas 一起,它是 Python 数据科学生态系统中的另一个核心组件。scikit-learn 提供了高效、易于使用的工具,可应用于常见的机器学习问题,包括探索性和预测性数据分析。我们将在本书的最后部分深入探讨机器学习。目前,本节只是简单介绍 Python 在机器学习领域,特别是在预测数据分析中的应用。
预测性数据分析是机器学习的一个领域,依赖于分类和回归算法。分类和回归都使用过去的数据来预测新数据,但分类将数据分入离散的类别,而回归可以输出一个连续的数值范围。在本节中,我们将通过一个使用 scikit-learn 实现的分类示例来进行讲解。我们将构建一个预测模型,分析客户产品评论,并将其分为两类:正面评论和负面评论。该模型将从已经分类的样本中学习如何预测其他样本的类别。一旦我们训练好模型,就会展示一些新的评论,进行正面或负面的分类。
安装 scikit-learn
与 NumPy 和 pandas 一样,scikit-learn 是一个第三方 Python 库。你可以按照以下方式安装它:
$ **pip install sklearn**
scikit-learn 包含多个子模块,每个子模块都有其特定功能。因此,通常会根据具体任务导入所需的子模块(比如 sklearn.model_selection),而不是导入整个模块。
获取示例数据集
为了确保准确性,预测模型应在大量标注样本上进行训练。因此,构建一个能够分类产品评论的模型的第一步是获取一组已经标注为正面或负面的评论。这可以避免你自己收集评论并手动标注它们。
有多个在线资源提供标注数据集,其中最好的之一是 UC Irvine 的机器学习资源库,网址为archive.ics.uci.edu/ml/index.php。使用“customer product reviews”搜索该资源库,你会找到指向情感标注句子数据集的链接(或者,你也可以直接将浏览器指向archive.ics.uci.edu/ml/datasets/Sentiment+Labelled+Sentences)。从数据集的网页下载并解压sentiment labelled sentences.zip文件。
.zip 文件包含来自 IMDb、Amazon 和 Yelp 的评论,存储在三个不同的 .txt 文件中。这些评论已经根据情感(分别为 1 或 0)进行了标注;每个来源有 500 条正面评论和 500 条负面评论,总共有 3000 条标注评论。为了简化,我们将仅使用来自 Amazon 的实例。它们可以在 amazon_cells_labelled.txt 文件中找到。
将示例数据集加载到 pandas DataFrame 中
为了简化后续计算,你需要将评论从文本文件加载到一个更易于处理的数据结构中。你可以按如下方式将数据从 amazon_cells_labelled.txt 读取到 pandas DataFrame 中:
import pandas as pd
df = pd.read_csv('/usr/Downloads/sentiment labelled sentences/amazon_cells_labelled.txt',
names=[❶ 'review', ❷ 'sentiment'], ❸ sep='\t')
在这里,你使用 pandas 的 read_csv() 读取方法将数据加载到 DataFrame 中。你指定了两列:第一列存储评论 ❶,第二列存储相应的情感得分 ❷。由于原始文件中评论和情感得分之间是通过制表符分隔的,你指定 \t 作为分隔符 ❸。
将样本数据集拆分为训练集和测试集
现在你已经导入了数据集,接下来的步骤是将数据集拆分为两部分:一部分用于训练预测模型,另一部分用于测试模型的准确性。scikit-learn 允许你只需几行代码就能完成这一步:
from sklearn.model_selection import train_test_split
reviews = df['review'].values
sentiments = df['sentiment'].values
reviews_train, reviews_test, sentiment_train, sentiment_test = train_test_split(reviews,
sentiments, ❶ test_size=0.2, ❷ random_state=500)
你使用来自 sklearn.model_selection 模块的 train_test_split() 函数来拆分数据集。评论及其对应的情感(情感得分)作为通过 values 属性从 DataFrame 提取的相应 Series 对象获得的 NumPy 数组传入函数。你传入 test_size 参数 ❶ 来控制数据集的拆分方式。值 0.2 表示 20% 的评论将被随机分配到测试集。因此,你遵循 80/20 的模式;剩余的 80% 评论将组成训练集。random_state 参数 ❷ 初始化内部随机数生成器,用于随机拆分数据。
将文本转换为数值特征向量
要训练和测试你的模型,你需要一种将文本数据转化为数值的方式。这时,词袋模型(BoW) 就派上用场了。该模型通过将文本表示为单词的集合(词袋),以生成有关文本的数值数据。BoW 模型生成的最典型的数值特征是词频,即每个单词在文本中出现的次数。这个简单的示例展示了 BoW 模型如何根据词频将文本转化为数值特征向量:
Text: I know it. You know it too.
BoW: {"I":1,"know":2,"it":2,"You":1,"too":1}
Vector: [1,2,2,1,1]
你可以使用 scikit-learn 的 CountVectorizer() 函数来为文本数据创建一个词袋(BoW)矩阵。CountVectorizer() 将文本数据转换为数值特征向量(表示某个对象的 n 维数值特征向量),并使用默认的分词器或自定义分词器进行分词(将文本拆分为单独的单词和标点符号)。自定义分词器可以使用像 spaCy 这样的自然语言处理工具实现,spaCy 在上一章中有介绍。在这个示例中,我们将使用默认选项,以保持简单。
下面是将评论转换为特征向量的方法:
from sklearn.feature_extraction.text import CountVectorizer
vectorizer = CountVectorizer()
vectorizer.fit(reviews)
X_train = vectorizer.transform(reviews_train)
X_test = vectorizer.transform(reviews_test)
首先,你创建一个 vectorizer 对象。然后,应用 vectorizer 的 fit() 方法来构建 reviews 数据集中发现的词汇表,reviews 数据集包含训练集和测试集中的所有评论。之后,你使用 vectorizer 对象的 transform() 方法,将训练集和测试集中的文本数据转换为数值特征向量。
训练和评估模型
现在你已经拥有了以数值向量形式表示的训练集和测试集,你可以开始训练和测试模型了。首先,你将训练 scikit-learn 的 LogisticRegression() 分类器来预测评论的情感。逻辑回归是一种基础但流行的解决分类问题的算法。
在这里,你创建一个 LogisticRegression() 分类器,然后使用它的 fit() 方法根据给定的训练数据来训练模型:
from sklearn.linear_model import LogisticRegression
classifier = LogisticRegression()
classifier.fit(X_train, sentiment_train)
现在你必须评估模型在新数据上的预测准确性。你需要一组标注数据,因此通常会将一个标注数据集分为训练集和测试集,就像你之前所做的那样。在这里,你使用测试集来评估模型:
accuracy = classifier.score(X_test, sentiment_test)
print("Accuracy:", accuracy)
准确率通常如下所示:
Accuracy: 0.81
这意味着模型的准确率为 81%。如果你尝试调整 train_test_split() 函数中的 random_state 参数,可能会得到稍微不同的值,因为训练集和测试集中的实例是从原始数据集中随机选择的。
对新数据进行预测
现在你已经训练并测试了模型,它可以开始分析新的、未标记的数据了。这将帮助你更全面地了解模型的表现。试试看,给模型提供一些新的示例评论:
new_reviews = ['Old version of python useless', 'Very good effort, but not
five stars', 'Clear and concise']
X_new = vectorizer.transform(new_reviews)
print(classifier.predict(X_new))
首先,你需要创建一个新的评论列表,然后将这些新文本转换为数值特征向量。最后,你为这些新样本预测类别情感。情感结果将作为一个列表返回:
[0, 1, 1]
记住,0 表示负面评论,1 表示正面评论。正如你所见,模型已经对这些示例评论起作用,显示第一个是负面的,而后两个是正面的。
总结
本章介绍了一些最流行的第三方 Python 库,用于数据科学应用。首先,我们探索了 NumPy 库及其多维数组对象,接着了解了 pandas 库及其 Series 和 DataFrame 数据结构。你学会了如何从 Python 内建结构(如列表)以及存储在标准格式(如 JSON)中的数据源创建 NumPy 数组、pandas Series 和 pandas DataFrame 对象。你还了解了如何访问和操作这些对象中的数据。最后,你使用了 scikit-learn 这个流行的机器学习 Python 库,构建了一个分类预测模型。
第四章:从文件和 API 获取数据

访问数据并将其导入脚本是数据分析的第一步。本章介绍了几种从文件和其他来源导入数据到 Python 应用程序的方法,以及如何将数据导出到文件。你将学习如何访问不同类型文件的内容,包括那些存储在本地计算机上的文件,以及通过 HTTP 请求远程访问的文件。你还将了解如何通过发送请求到可以通过 URL 访问的 API 获取数据。最后,你将学习如何将不同类型的数据加载到 pandas DataFrame 中。
使用 Python 的 open() 函数导入数据
Python 的内置 open() 函数可以打开任何类型的文件,并在脚本中进行处理。该函数返回一个 file 对象,带有方法让你访问和操作文件中的内容。然而,如果文件包含某些特定格式的数据,如 CSV、JSON 或 HTML,你还需要导入相应的库来访问和操作这些数据。处理纯文本文件则不需要特殊的库;你可以直接使用 open() 返回的 file 对象的方法。
文本文件
文本文件(.txt)可能是你遇到的最常见文件类型。对 Python 来说,文本文件是一个字符串对象序列。每个字符串对象对应文本文件中的一行——即一系列以不可见的换行符(\n)或回车符结束的字符。
Python 提供了内置函数来处理文本文件,允许你执行读取、写入和追加操作。在本节中,我们将重点讲解如何从文本文件中读取数据。首先,在文本编辑器中输入以下段落,并将其保存为 excerpt.txt 文件。在第一个段落的结尾按两次 ENTER 键,创建段落之间的空行(但不要按 ENTER 键来分隔长行):
Today, robots can talk to humans using natural language, and they’re getting smarter. Even so, very few people understand how these robots work or how they might use these technologies in their own projects.
Natural language processing (NLP) – a branch of artificial intelligence that helps machines understand and respond to human language – is the key technology that lies at the heart of any digital assistant product.
对人类来说,段落包括两段文字,总共三句话。然而对 Python 来说,段落包括两行非空行和它们之间的一行空行。下面是如何将文件的全部内容读取到 Python 脚本中并打印出来:
❶ path = "`/path/to/excerpt.txt`"
with open(❷ path, ❸ "r") as ❹ f:
❺ content = f.read()
print(content)
你首先指定文件的路径 ❶。你需要根据文件保存的位置,将 /path/to/excerpt.txt 替换为你自己的文件路径。你将文件路径作为第一个参数传递给 open() 函数 ❷。第二个参数控制文件的使用方式。默认情况下,这个参数是以文本模式读取文件,这意味着文件的内容只会以只读方式打开(不能编辑),并被当作字符串处理。你可以显式地指定 "r" 用于 读取 ❸,但这并不是必须的。(传递 "rt" 会明确指定文本模式,并且指定读取方式。)open() 函数返回一个以指定模式打开的 file 对象 ❹。然后,你使用 file 对象的 read() 方法读取文件的全部内容 ❺。
py`Using the `with` keyword with `open()` ensures that the `file` object is properly closed when you’re done with it even if an exception has been raised. Otherwise, you would need to call `f.close()` to close the file object and free up the system resources it consumes. The following snippet reads in the same `/path/to/excerpt.txt` file content line by line, printing out only nonempty lines: path = "/path/to/excerpt.txt" with open(path,"r") as f: ❶ for i, line in enumerate(f): ❷ if line.strip(): print(f"第 {i} 行: ", line.strip()) py In this example, you add line numbers to each line with the `enumerate()` function ❶. Then you filter out empty lines with the `strip()` method ❷, which removes any whitespace from the start and end of the string object in each line. The blank second line of the text file contains only one character, a newline, which `strip()` removes. The second line thus becomes an empty string, which the `if` statement will evaluate as false and skip over. The output appears as follows. As you can see, there’s no `Line 1`: 第 0 行: 今天,机器人可以使用自然语言与人类对话,并且它们变得越来越智能。尽管如此,仍然很少有人理解这些机器人是如何工作的,或者他们如何在自己的项目中使用这些技术。 第 2 行: 自然语言处理(NLP)——一种帮助机器理解和回应人类语言的人工智能分支——是任何数字助手产品的核心技术。 py Rather than print the lines, you can send them to a list by using a list comprehension: path = "/path/to/excerpt.txt" with open(path,"r") as f: lst = [line.strip() for line in f if line.strip()] py Each nonempty line will be a separate item in the list. ### Tabular Data Files A *tabular* data file is a file in which the data is structured into rows. Each row typically contains information about someone or something, as shown here: Jeff Russell, jeff.russell, sales Jane Boorman, jane.boorman, sales py This is an example of a *flat file*, the most common type of tabular data file. The name comes from the structure: flat files contain simple-structured (flat) records, meaning the records do not contain nested structures, or subrecords. Typically, a flat file is a plaintext file in CSV or tab-separated values (TSV) format, containing one record per line. In *.csv* files, values in a record are separated by commas, while *.tsv* files use tabs as separators. Both formats are widely supported and are often used in data exchange to move tabular data between different applications. The following is an example of data in CSV format, where the first line contains headers that describe the content of the lines below it. The header descriptions are used as *keys* to the data in the lines that follow. Copy this data into a text editor and save it as *cars.csv*: 年份,品牌,型号,价格 1997,福特,E350,3200.00 1999,雪佛兰,Venture,4800.00 1996,吉普,Grand Cherokee,4900.00 py Python’s `open()` function can open *.csv* files in text mode. Then you can load the data into a Python object using a reader function from the `csv` module, as illustrated here: import csv path = "/path/to/cars.csv" with open(path, "r") as ❶ csv_file: csv_reader = ❷ csv.DictReader(csv_file) cars = [] for row in csv_reader: ❸ cars.append(dict(row)) print(cars) py The `open()` function returns a `file` object ❶, which you pass to a reader from the `csv` module. In this case, you use `DictReader()` ❷, which maps the data in each row to a dictionary using the corresponding headers from the first line as keys. You append these dictionaries to a list ❸. The resulting list of dictionaries looks like this: [ {'年份': '1997', '品牌': '福特', '型号': 'E350', '价格': '3200.00'}, {'年份': '1999', '品牌': '雪佛兰', '型号': 'Venture', '价格': '4800.00'}, {'年份': '1996', '品牌': '吉普', '型号': 'Grand Cherokee', '价格': '4900.00'} ] py Alternatively, you might use the `csv` module’s `reader()` method to turn the *.csv* file into a list of lists, where each inner list represents a row, including the header row: import csv path = "cars.csv" with open(path, "r") as csv_file: csv_reader = csv.reader(csv_file) cars = [] for row in csv_reader: cars.append(row) print(cars) py Here is the output: [ ['年份', '品牌', '型号', '价格'] ['1997', '福特', 'E350', '3200.00'] ['1999', '雪佛兰', 'Venture', '4800.00'] ['1996', '吉普', 'Grand Cherokee', '4900.00'] ] py The `csv.DictReader()` and `csv.reader()` methods have an optional `delimiter` parameter, allowing you to specify the character that separates fields in your tabular data file. This parameter defaults to a comma, which is perfect for *.csv* files. However, by setting the parameter to `delimiter = "\t"`, you can read in the tab-separated data of *.tsv* files instead. ### Binary Files Text files are not the only types of files you may have to deal with. There are also executable (*.exe*) and image files (*.jpeg*, *.bmp*, and so on), which contain data in binary format, represented as a sequence of bytes. Since these bytes are typically intended to be interpreted as something other than text characters, you can’t open a binary file in text mode to access and manipulate its content. Instead, you must use the `open()` function’s binary mode. The following example shows how to open an image file in binary mode. An attempt to do this in text mode would result in an error. You can run this code with any *.jpg* file on your computer: image = "/path/to/file.jpg" with open(image, ❶ "rb") as image_file: content = ❷ image_file.read() ❸ print(len(content)) py You instruct the `open()` function to open a file for reading in binary mode by passing in `"``rb"` as the second parameter ❶. The retrieved object, like an object retrieved in text mode, has the `read()` method to get the file’s content ❷. Here, the content is retrieved as a `bytes` object. In this example, you simply determine the number of bytes read from the file ❸. ## Exporting Data to Files After some processing, you may need to store data to a file so that you can use the data during the next execution of the script or import it into other scripts or applications. You may also need to store information in a file so that you or others can view it. For example, you may want to log information about the errors and exceptions generated by your application for later review. You can create a new file from your Python script and write data to it, or you can write over the data in an existing file. We’ll explore an example of the latter here. Returning to the example from “Tabular Data Files,” suppose you need to modify a row in the *cars.csv* file, changing the price of a certain car. Recall that the data was read from the *cars.csv* file into a list of dictionaries named `cars`. To see the values of each dictionary in that list, you can run the following loop: for row in cars: print(list(row.values())) py In the loop body, you call the `values()` method on each dictionary in the list, thus converting the dictionary’s values into a `dict_values` object that can be easily converted into a list. Each list represents a row from the original *.csv* file, as shown here: ['1997', '福特', 'E350', '3200.00'] ['1999', '雪佛兰', 'Venture', '4800.00'] ['1996', '吉普', 'Grand Cherokee', '4900.00'] py Suppose you need to update the `Price` field in the second row (for the Chevy Venture) and store this change in the original *cars.csv* file. You can make the change as follows: ❶ to_update = ['1999', '雪佛兰', 'Venture'] ❷ new_price = '4500.00' ❸ with open('path/to/cars.csv', 'w') as csvfile: ❹ fieldnames = cars[0].keys() ❺ writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() ❻ for row in cars: if set(to_update).issubset(set(row.values())): row['价格'] = new_price writer.writerow(row) py First, you need a way to identify the row to be updated. You create a list called `to_update`, including enough of the row’s fields to uniquely identify the row ❶. Then, you specify the new value for the field to be changed as `new_price` ❷. Next, you open the file for writing, passing in the `'w'` flag to the `open()` function ❸. The `w` mode used here will overwrite the existing content of the file. You therefore must define the field names to be sent to the file ❹. These are the names of the keys used in a dictionary representing a car row. Using the `csv.DictWriter()` function ❺, you create a writer object that will map the dictionaries from the `cars` list onto the output rows to be sent to the *cars.csv* file. In a loop over the dictionaries in the `cars` list ❻, you check if each row matches your specified identifier. If so, you update the row’s `Price` field. Finally, still within the loop, you write each row to the file using the `writer.writerow()` method. Here’s what you will see in the *cars.csv* file after executing the script: 年份,品牌,型号,价格 1997,福特,E350,3200.00 1999,雪佛兰,Venture,4500.00 1996,吉普,Grand Cherokee,4900.00 py As you can see, it looks like the original content, but the value of the `Price` field in the second row has been changed. ## Accessing Remote Files and APIs Several third-party Python libraries, including urllib3 and Requests, let you get data from a URL-accessible remote file. You can also use the libraries to send requests to HTTP APIs (those that use HTTP as their transfer protocol), many of which return the requested data in JSON format. Both urllib3 and Requests work by formulating custom HTTP requests based on information you input. *HTTP (HyperText Transfer Protocol)*, the client/server protocol that forms the foundation of data exchange over the web, is structured as a series of requests and responses. The HTTP messages sent by a client are *requests*, while the answer messages returned by the server are *responses*. For example, whenever you click a link in your browser, the browser, acting as the client, sends an HTTP request to fetch the desired web page from the appropriate web server. You can do the same thing from a Python script. The script, acting as the client, obtains the requested data in the form of a JSON or XML document. ### How HTTP Requests Work There are several types of HTTP requests. The most common ones include `GET`, `POST`, `PUT`, and `DELETE`. These are also known as *HTTP request methods*, *HTTP commands*, or just *HTTP verbs*. The HTTP command in any HTTP request defines the action to be performed for a specified resource. For example, a `GET` request retrieves data from a resource, while a `POST` request pushes data to a destination. An HTTP request also includes the request *target*, usually comprising a URL, and *headers*, the latter being fields that pass additional information along with the request. Some requests also include a *body*, which carries actual request data, such as a form submission. `POST` requests typically include a body, while `GET` requests don’t. As an example, consider the following HTTP request: ❶ GET ❷ /api/books?bibkeys=ISBN%3A1718500521&format=json HTTP/1.1 ❸ Host: openlibrary.org ❹ User-Agent: python-requests/2.24.0 ❺ Accept-Encoding: gzip, deflate Accept: / ❻ Connection: keep-alive py This request uses the `GET` HTTP command ❶ to retrieve data from the given server (indicated as `Host` ❸) using the specified URI ❷. The remaining lines include other headers specifying additional information. The `User-Agent` request header identifies the application making the request and its version ❹. The `Accept` headers advertise which content types the client is able to understand ❺. The `Connection` header, set to `keep-alive` ❻, instructs the server to establish a persistent connection to the client, which allows for subsequent requests to be made. In Python, you don’t have to fully understand the internal structure of HTTP requests to send them and receive responses. As you’ll learn in the following sections, libraries like Requests and urllib3 let you manipulate HTTP requests easily and efficiently, just by calling an appropriate method and passing the required parameters in to it. With the help of the Requests library, the preceding HTTP request can be generated by a simple Python script as follows: import requests PARAMS = {'bibkeys':'ISBN:1718500521', 'format':'json'} requests.get('http://openlibrary.org/api/books', params = PARAMS) py We’ll discuss the Requests library in detail shortly. For now, notice that the library saves you from having to set the headers of a request manually. It sets the default values behind the scenes, automatically generating a fully formatted HTTP request on your behalf based on just a few lines of code. ### The urllib3 Library urllib3 is a URL-handling library that lets you access and manipulate URL-accessible resources such as HTTP APIs, websites, and files. The library is designed to efficiently manipulate HTTP requests, using thread-safe connection pooling to minimize the resources needed on your server’s end. Compared to the Requests library, which we’ll discuss next, urllib3 requires more manual work, but it also gives you more direct control over the requests you prepare, which is useful when, for example, you need to customize pool behavior or explicitly decode HTTP responses. #### Installing urllib3 Since urllib3 is a dependency of many popular Python packages, like Requests and `pip`, chances are you have it already installed in your Python environment. To check this, try to import it in a Python session. If you get a `ModuleNotFoundError`, you can install it explicitly with this command: $ pip install urllib3 py #### Accessing Files with urllib3 To see how to load data from a URL-accessible file with urllib3, you can use the *excerpt.txt* file you created earlier. To make this file accessible via a URL, you might put it into the document folder of an HTTP server running on your local host. Alternatively, use the following URL to obtain it from the GitHub repository accompanying this book: [`github.com/pythondatabook/sources/blob/main/ch4/excerpt.txt`](https://github.com/pythondatabook/sources/blob/main/ch4/excerpt.txt). Run the following code, replacing the URL if necessary: import urllib3 ❶ http = urllib3.PoolManager() ❷ r = http.request('GET', 'http://localhost/excerpt.txt') for i, line in enumerate(❸ r.data.decode('utf-8').split('\n')): if line.strip(): ❹ print("第 %i 行: " %(i), line.strip()) py First you create a `PoolManager` instance ❶, which is how urllib3 makes requests. After that, you make an HTTP request to the specified URL with the `request()` method of `PoolManager` ❷. The `request()` method returns an `HTTPResponse` object. You access the requested data through the `data` attribute of this object ❸. Then you output only nonempty lines, enumerating them at the beginning of each line ❹. #### API Requests with urllib3 You can also use urllib3 to make requests to HTTP APIs. In the following example, you make a request to the News API ([`newsapi.org`](https://newsapi.org)), which searches for articles from a wide range of news sources, finding those that are most relevant to your request. Like many other APIs today, it requires you to pass in an API key with each request. You can get a developer API key for free at [`newsapi.org/register`](https://newsapi.org/register) after filling in a simple registration form. Then use this code to search for articles about the Python programming language: import json import urllib3 http = urllib3.PoolManager() r = http.request('GET', 'https://newsapi.org/v2/everything? ❶ q=Python programming language& ❷ apiKey=your_api_key_here& ❸ pageSize=5') ❹ articles = json.loads(r.data.decode('utf-8')) for article in articles['articles']: print(article['title']) print(article['publishedAt']) print(article['url']) print() py You pass in the search phrase as the `q` parameter in the request URL ❶. The only other required parameter to specify in the request URL is `apiKey` ❷, where you pass in your API key. There are also many other optional parameters. For example, you can specify the news sources or blogs you want articles from. In this particular example, you use `pageSize` to set the number of articles being retrieved to five ❸. The entire list of supported parameters can be found in the News API documentation at [`newsapi.org/docs`](https://newsapi.org/docs). The `data` attribute of the `HTTPResponse` object returned by `request()` is a JSON document in the form of a `bytes` object. You decode it to a string, which you then pass to the `json.loads()` method to convert it into a dictionary ❹. To see how the data is structured in this dictionary, you might output it, but this step is omitted in this listing. If you looked at the output, you’d see that information about the articles can be found in the list called `articles` within the returned document, and each record in this list has the fields `title`, `publishedAt`, and `url`. Using that information, you can print the retrieved list of articles in a more readable format, producing something like this: 一种表达编程挫折感的编程语言 2021-12-15T03:00:05Z https://hackaday.com/2021/12/14/a-programming-language-to-express-programming-frustration/ 提高你企业潜力的 Python 学习之路 2021-12-24T16:30:00Z https://www.entrepreneur.com/article/403981 TIOBE 宣布 2021 年的编程语言是 Python 2022-01-08T19:34:00Z https://developers.slashdot.org/story/22/01/08/017203/tiobe-announces-that-the-programming-language-of-the-year-was-python Python 是 2021 年 TIOBE 编程语言,究竟这一称号意味着什么? 2022-01-04T12:28:01Z https://thenextweb.com/news/python-c-tiobe-programming-language-of-the-year-title-analysis 哪种编程语言或编译器更快 2021-12-18T02:15:28Z py This example illustrated how to integrate the News API into a Python application using direct HTTP requests via the urllib3 library. An alternative would be to use the unofficial Python client library covered at [`newsapi.org/docs/client-libraries/python`](https://newsapi.org/docs/client-libraries/python). ### The Requests Library Requests is another popular URL-handling library that allows you to easily send HTTP requests. Requests uses urllib3 under the hood and makes it even easier to make requests and retrieve data. You can install the Requests library with the `pip` command: $ pip install requests py HTTP verbs are implemented as the library’s methods (for example, `requests.get()` for an HTTP `GET` request). Here’s how to remotely access *excerpt.txt* with Requests. Replace the URL with the file’s GitHub link if necessary: import requests ❶ r = requests.get('http://localhost/excerpt.txt') for i, line in enumerate(❷ r.text.split('\n')): if line.strip(): ❸ print("第 %i 行: " %(i), line.strip()) py You make an HTTP `GET` request using the `requests.get()` method, passing the file’s URL as the parameter ❶. The method returns a `Response` object that includes the retrieved content in the `text` attribute ❷. Requests automatically decodes the retrieved content, making knowledgeable guesses about the encoding, so you don’t have to do it manually. Just as in the urllib3 example, you output only nonempty lines, adding a line number at the beginning of each ❸. ## Moving Data to and from a DataFrame pandas comes with a range of reader methods, each of which is designed to load data in a certain format and/or from a certain type of source. These methods allow you to load tabular data into a DataFrame with the help of a single call, thus making the imported dataset immediately ready for analysis. pandas also has methods for converting DataFrame data into other formats, such as JSON. This section explores examples of these methods for moving data to or from a DataFrame. We’ll also consider the pandas-datareader library, which is helpful for loading data from various online sources into pandas DataFrames. ### Importing Nested JSON Structures Since JSON has become the de facto standard for data interchange between applications, it’s important to have a way to quickly import a JSON document and convert it to a Python data structure. In the previous chapter, you saw an example of loading a simple, non-nested JSON structure into a DataFrame with the pandas `read_json()` reader. In this section, you’ll learn how to load a more complex, nested JSON document, like this one: data = [{"员工":"Jeff Russell", "采购单":[{"单号":2608,"总数":35}, {"单号":2617,"总数":35}, {"单号":2620,"总数":139} ]}, {"员工":"Jane Boorman", "采购单":[{"单号":2621,"总数":95}, {"单号":2626,"总数":218} ] }] py As you can see, each entry in the JSON document begins with a simple-structured key-value pair with the key `Emp`, followed by a nested structure with the key `POs`. You can convert that hierarchical JSON structure into a tabular pandas DataFrame using the pandas library’s `json_normalize()` reader method, which takes a nested structure and flattens, or *normalizes*, it into a simple table. Here’s how: import json import pandas as pd df = pd.json_normalize(❶ data, ❷ "采购单", ❸ "员工").set_index([❹ "员工","单号"]) print(df) py Apart from the JSON sample ❶ to be processed by `json_normalize()`, you also specify `POs` as the nested array to be flattened ❷ and `Emp` as the field to be used as part of the complex index in the resulting table ❸. In the same line of code, you set two columns as the index: `Emp` and `Pono` ❹. As a result, you will see the following pandas DataFrame: 总数 员工 单号 Jeff Russell 2608 35 2617 35 2620 139 Jane Boorman 2621 95 2626 218 py ### Converting a DataFrame to JSON In practice, you may often need to perform the reverse operation, converting a pandas DataFrame into JSON. The following code converts our DataFrame back to the JSON sample from which it was originally generated: ❶ df = df.reset_index() json_doc = (❷ df.groupby(['员工'], as_index=True) ❸ .apply(lambda x: x[['单号','总数']].to_dict('records')) ❹ .reset_index() ❺ .rename(columns={0:'采购单'}) ❻ .to_json(orient='records')) py You start by dropping the two-column index of the DataFrame to make `Emp` and `Pono` regular columns ❶. Then, you use a composite one-liner to convert the DataFrame to a JSON document. First you apply a `groupby` operation to the DataFrame, grouping the rows by the `Emp` column ❷. You use `groupby()` in combination with `apply()` to apply a lambda function to each record in each group ❸. In the lambda expression, you specify the list of fields that you want to see in a row of the nested array associated with each `Emp` record. You use the `DataFrame.to_dict()` method with the `records` parameter to format the fields in the array as follows: `[{``column``:``value``}, ... , {``column``:``value``}]`, where each dictionary represents an order associated with a given employee. At this point, you have a Series object with the index `Emp` and a column containing an array of orders associated with an employee. To give this column a name (in this case, `POs`), you need to convert the Series to a DataFrame. One simple way to do this is with `reset_index()` ❹. In addition to converting the Series to a DataFrame, `reset_index()` changes `Emp` from an index to a regular column, which will be important when you convert the DataFrame to JSON format. Finally, you explicitly set the name of the column containing the nested array (`POs`) using the DataFrame’s `rename()` method ❺ and turn the revised DataFrame into JSON ❻. The content of `json_doc` looks as follows: [{"员工": "Jeff Russell", "采购单": [{"单号": 2608, "总数": 35}, {"单号": 2617, "总数": 35}, {"单号": 2620, "总数": 139} ]}, {"员工": "Jane Boorman", "采购单": [{"单号": 2621, "总数": 95}, {"单号": 2626, "总数": 218} ] }] py To improve readability, you might print it out using the following command: print(json.dumps(json.loads(json_doc), indent=2)) py ### Loading Online Data into a DataFrame with pandas-datareader Several third-party libraries come with pandas-compatible reader methods for accessing data from a variety of online sources, such as Quandl ([`data.nasdaq.com`](https://data.nasdaq.com)) and Stooq ([`stooq.com`](https://stooq.com)). The most popular of these is pandas-datareader. At the time of writing, this library included 70 methods, each designed to load data from a certain source into a pandas DataFrame. Many of the library’s methods are wrappers for finance APIs, allowing you to easily get financial data in pandas format. #### Installing pandas-datareader Enter this command to install pandas-datareader: $ pip install pandas-datareader py For descriptions of the library’s reader methods, consult the pandas-datareader documentation at [`pandas-datareader.readthedocs.io/en/latest/remote_data.html`](https://pandas-datareader.readthedocs.io/en/latest/remote_data.html). You can also print a list of the available methods with Python’s `dir()` function: import pandas_datareader.data as pdr print(dir(pdr)) py #### Obtaining Data from Stooq In the following example, you use the `get_data_stooq()` method to obtain S&P 500 index data for a specified period: import pandas_datareader.data as pdr spx_index = pdr.get_data_stooq('^SPX', '2022-01-03', '2022-01-10') print(spx_index) py The `get_data_stooq()` method obtains data from Stooq, a free site that provides information on a number of market indexes. Pass in the ticker of the market index you want as the first parameter. The available options can be found at [`stooq.com/t`](https://stooq.com/t). The obtained S&P 500 index data will typically appear in this format: 开盘价 最高价 最低价 收盘价 成交量 日期 2022-01-10 4655.34 4673.02 4582.24 4670.29
第五章:使用数据库

数据库是一个有组织的数据集合,可以方便地访问、管理和更新。即使你的项目初期架构中没有数据库,流经你应用的数据在某个时刻也很可能会接触到一个或多个数据库。
本章延续上一章关于将数据导入 Python 应用程序的讨论,讲解如何处理数据库中的数据。这里的示例将展示如何访问和操作存储在不同类型数据库中的数据,包括将 SQL 作为主要工具来处理数据的数据库以及不使用 SQL 的数据库。你将学习如何使用 Python 与多种流行数据库进行交互,包括 MySQL、Regis 和 MongoDB。
数据库提供了许多优势。首先,借助数据库,你可以在脚本的多次调用之间持久化数据,并高效地在不同应用程序之间共享数据。此外,数据库语言可以帮助你系统地组织数据并回答关于数据的问题。更重要的是,许多数据库系统允许你在数据库内部实现编程代码,这可以提高应用程序的性能、模块化和可重用性。例如,你可以在数据库中存储一个触发器;这是一段代码,每当发生某个特定事件时自动触发,比如每次向某个特定表格插入新行时。
数据库分为两类:关系型数据库和非关系型(NoSQL)数据库。关系型数据库有一个固定的结构,以数据的架构形式实现。这种方法有助于确保数据的完整性、一致性和总体准确性。然而,关系型数据库的主要缺点是随着数据量的增加,它们的扩展性较差。相比之下,NoSQL 数据库对数据结构没有限制,从而提供了更多的灵活性、适应性和可扩展性。本章将涵盖在关系型和非关系型数据库中存储和检索数据。
关系型数据库
关系型数据库,也叫做行列式数据库,是当今最常用的数据库类型。它们提供了一种结构化的方式来存储数据。就像亚马逊上的书籍列表有一套结构来存储信息,包括书名、作者、简介、评分等字段,存储在关系型数据库中的数据必须符合预定义的形式化架构。使用关系型数据库的工作从设计这个形式化架构开始:你定义一组表格,每个表格由一组字段或列组成,并且你指定每个字段将存储的数据类型。你还要建立表格之间的关系。然后,你就可以将数据存入数据库、从数据库中检索数据或根据需要更新数据。
关系型数据库旨在高效地插入、更新和/或删除少量到大量的结构化数据。在许多应用中,这种类型的数据库都能发挥重要作用。特别地,关系型数据库非常适合在线事务处理(OLTP)应用程序,这些应用程序处理大量用户的高交易量。
一些常见的关系型数据库系统包括 MySQL、MariaDB 和 PostgreSQL。本节将重点介绍 MySQL,毫无疑问是全球最流行的开源数据库,来说明如何与数据库交互。你将学习如何设置 MySQL、创建新数据库、定义其结构,并编写 Python 脚本来存储和检索数据。
理解 SQL 语句
SQL,或称结构化查询语言,是与关系型数据库进行交互的主要工具。尽管我们这里的重点是使用 Python 与数据库进行交互,但 Python 代码本身必须包含 SQL 语句才能实现这一点。全面了解 SQL 超出了本书的范围,但简要介绍这一查询语言仍然是必要的。
SQL 语句是数据库引擎(如 MySQL)识别并执行的文本命令。例如,这条 SQL 语句要求数据库检索orders表中status字段设置为Shipped的所有行:
SELECT * FROM orders WHERE status = 'Shipped';
SQL 语句通常包含三个主要组成部分:一个操作,一个目标以及一个条件,后者用以限定操作的范围。在前面的例子中,SELECT是 SQL 操作,意味着我们正在从数据库中访问行。orders表是操作的目标,由FROM子句定义,条件则在语句的WHERE子句中指定。例如,这条语句缺少条件,因此它将检索orders表中的所有行:
SELECT * FROM orders;
你还可以精炼 SQL 语句,使其仅影响表中的某些列。以下是如何仅检索orders表中所有行的pono和date列:
SELECT pono, date FROM orders;
按约定,SQL 中的保留字,如SELECT和FROM,通常采用全大写字母书写。然而,SQL 是一个大小写不敏感的语言,因此这种大写并非严格必要。每个 SQL 语句应以分号结束。
像刚才展示的SELECT操作就是数据操作语言(DML) 语句的例子,DML 语句是一类用于访问和操作数据库数据的 SQL 语句。其他 DML 操作包括INSERT、UPDATE和DELETE,分别用于向数据库中添加、修改和删除记录。数据定义语言(DDL) 语句是另一类常见的 SQL 语句。你使用这些语句来实际定义数据库结构。典型的 DDL 操作包括CREATE来创建、ALTER来修改、DROP来删除数据容器,无论是列、表还是整个数据库。
MySQL 入门
MySQL 可用于大多数现代操作系统,包括 Linux、Unix、Windows 和 macOS。提供免费和商业版。对于本章内容,你可以使用 MySQL 社区版(www.mysql.com/products/community),这是 MySQL 的免费下载版,适用于 GPL 许可证。有关你操作系统的 MySQL 安装详细说明,请参考 MySQL 最新版本的参考手册,网址为dev.mysql.com/doc。
在安装后,要启动 MySQL 服务器,你需要使用安装指南中为你的操作系统指定的命令。然后,你可以从系统终端使用mysql客户端程序连接到 MySQL 服务器:
$ **mysql -uroot -p**
系统会要求你输入在 MySQL 服务器安装过程中设置的密码。之后,你将看到 MySQL 提示符:
mysql>
如果你愿意,可以使用以下 SQL 命令为 root 用户选择一个新密码:
mysql> **ALTER USER 'root'@'localhost' IDENTIFIED BY '**`your_new_pswd`**';**
现在,你可以创建你的应用程序所需的数据库。在mysql>提示符下输入以下命令:
mysql> **CREATE DATABASE sampledb;**
Query OK, 1 row affected (0.01 sec)
这将创建一个名为sampledb的数据库。接下来,你必须选择该数据库进行使用:
mysql> **USE sampledb;**
Database changed
现在,任何后续命令都将作用于你的sampledb数据库。
定义数据库结构
关系型数据库的结构来自其组成表格的构成以及这些表格之间的连接。链接不同表格的字段称为键。有两种类型:主键和外键。主键唯一标识表格中的一条记录。外键是另一个表中的字段,指向第一个表中的主键。通常,主键和对应的外键在两个表中共享相同的名称。
现在你已经创建了sampledb数据库,可以开始创建一些表格并定义它们的结构。为了演示目的,这些表格将具有与你在第三章中使用的一些 pandas 数据框相同的结构。以下是需要在数据库中实现的三个表格数据结构:
emps
empno empname job
----------------------------
9001 Jeff Russell sales
9002 Jane Boorman sales
9003 Tom Heints sales
salary
empno salary
----------------
9001 3000
9002 2800
9003 2500
orders
pono empno total
-------------------
2608 9001 35
2617 9001 35
2620 9001 139
2621 9002 95
2626 9002 218
要查看这些结构之间可以建立哪些关系,请回顾第三章中的图 3-4 和图 3-6。如图 3-4 所示,emps表和salary表中的行之间具有一对一的关系。这个关系是通过empno字段建立的。emps表和orders表之间也通过empno字段建立了关系。这是一个一对多的关系,如图 3-6 所示。
你可以使用mysql>提示符通过 SQL 命令将这些数据结构添加到关系型数据库中。首先,创建emps表:
mysql> **CREATE TABLE emps (**
**empno INT NOT NULL,**
**empname VARCHAR(50),**
**job VARCHAR(30),**
**PRIMARY KEY (empno)**
**);**
你使用CREATE TABLE命令创建表,指定每一列及其数据类型,并可选择性地指定可存储数据的大小。例如,empno列用于存储整数(类型为INT),而对其应用的NOT NULL约束确保你无法插入一个empno字段为空的行。与此同时,empname列可以存储最长为 50 个字符的字符串(类型为VARCHAR),而job列可以存储最多 30 个字符的字符串。你还指定empno是该表的主键列,意味着它在表中不应有重复值。
在成功执行该命令后,你将看到以下消息:
Query OK, 0 rows affected (0.03 sec)
同样,下面是如何创建salary表的示例:
mysql> **CREATE TABLE salary (**
**empno INT NOT NULL,**
**salary INT,**
**PRIMARY KEY (empno)**
**);**
Query OK, 0 rows affected (0.05 sec)
接下来,你将在salary表的empno列中添加外键约束,引用emps表中的empno列:
mysql> **ALTER TABLE salary ADD FOREIGN KEY (empno) REFERENCES emps (empno);**
该命令创建了salary表和empno表之间的关系。它规定了salary表中的员工编号必须与emps表中的员工编号匹配。这个约束确保如果emps表中没有对应的行,你将无法向salary表插入新行。
由于salary表目前没有任何行,因此ALTER TABLE操作不会影响任何行,可以从返回的消息中看到这一点:
Query OK, 0 rows affected (0.14 sec)
Records: 0 Duplicates: 0 Warnings: 0
最后,创建orders表:
mysql> **CREATE TABLE orders (**
**pono INT NOT NULL,**
**empno INT NOT NULL,**
**total INT,**
**PRIMARY KEY (pono),**
**FOREIGN KEY (empno) REFERENCES emps (empno)**
**);**
Query OK, 0 rows affected (0.13 sec)
这次你在CREATE TABLE命令中添加了一个外键约束,从而在创建表时立即定义外键。
向数据库插入数据
现在你已经准备好向新创建的表中插入数据。虽然你可以使用mysql>提示符执行此操作,但通常这种操作是通过应用程序进行的。你将在 Python 代码中通过 MySQL Connector/Python 驱动与数据库进行交互。你可以通过pip安装它,如下所示:
$ **pip install mysql-connector-python**
运行以下脚本,将数据填充到数据库表中:
import mysql.connector
try:
❶ cnx = mysql.connector.connect(user='root', password='`your_pswd`',
host='127.0.0.1',
database='sampledb')
❷ cursor = cnx.cursor()
# defining employee rows
❸ emps = [
(9001, "Jeff Russell", "sales"),
(9002, "Jane Boorman", "sales"),
(9003, "Tom Heints", "sales")
]
# defining the query
❹ query_add_emp = ("""INSERT INTO emps (empno, empname, job)
VALUES (%s, %s, %s)""")
# inserting the employee rows
for emp in emps:
❺ cursor.execute(query_add_emp, emp)
# defining and inserting salaries
salary = [
(9001, 3000),
(9002, 2800),
(9003, 2500)
]
query_add_salary = ("""INSERT INTO salary (empno, salary)
VALUES (%s, %s)""")
for sal in salary:
cursor.execute(query_add_salary, sal)
# defining and inserting orders
orders = [
(2608, 9001, 35),
(2617, 9001, 35),
(2620, 9001, 139),
(2621, 9002, 95),
(2626, 9002, 218)
]
query_add_order = ("""INSERT INTO orders(pono, empno, total)
VALUES (%s, %s, %s)""")
for order in orders:
cursor.execute(query_add_order, order)
# making the insertions permanent in the database
❻ cnx.commit()
❼ except mysql.connector.Error as err:
print("Error-Code:", err.errno)
print("Error-Message: {}".format(err.msg))
❽ finally:
cursor.close()
cnx.close()
在这个脚本中,你将 MySQL Connector/Python 驱动导入为mysql.connector。然后你打开一个try/except块,它为你在脚本中执行任何与数据库相关的操作提供了一个模板。你将在try块中编写操作的代码,如果操作执行时发生错误,执行将转到except块。
在try块中,首先建立与数据库的连接,指定你的用户名和密码、主机 IP 地址(在这种情况下是本地主机)以及数据库名称❶。然后,获取与此连接相关的cursor对象❷。cursor对象提供了执行语句和获取结果的接口。
你将emps表的行定义为一个元组列表❸。然后,定义要执行的 SQL 语句,以便将这些行插入表中❹。在此INSERT语句中,你指定了要填充数据的字段,并使用%s占位符将这些字段与每个元组的成员相对应。在循环中,你执行该语句,使用cursor.execute()方法逐行插入数据❺。类似地,你也将数据插入salary和orders表中。在try块的末尾,你使用连接的commit()方法使所有插入的数据永久生效❻。
如果任何与数据库相关的操作失败,try块中的其余部分将被跳过,except子句会执行❼,并打印出由 MySQL 服务器生成的错误代码以及相应的错误信息。
finally子句无论如何都会执行❽。在此子句中,你显式地关闭cursor,然后关闭连接。
查询数据库数据
现在你已经用数据填充了表格,可以查询这些数据以供 Python 代码使用。假设你想检索emps表中empno大于9001的所有行。为此,可以参考前一节的脚本,只需更改try块如下:
`--snip--`
try:
cnx = mysql.connector.connect(user='root', password='`your_pswd`',
host='127.0.0.1',
database='sampledb')
cursor = cnx.cursor()
query = ("SELECT ❶ * FROM emps WHERE ❷ empno > %s")
❸ empno = 9001
❹ cursor.execute(query, (empno,))
❺ for (empno, empname, job) in cursor:
print("{}, {}, {}".format(
empno, empname, job))
`--snip--`
与插入操作不同,选择行不需要在循环中对每一行执行多个cursor.execute()操作。相反,你编写一个查询,指定要选择的行的条件,然后通过一次cursor.execute()操作一次性获取所有行。
在构成查询的SELECT语句中,你指定了星号符号(*),这意味着你想查看检索到的行中的所有字段❶。在WHERE子句中,你指定了选择行必须满足的条件。在这里,你表明只有当empno大于绑定到%s占位符的变量的值时,行才会被选中❷。在执行过程中,变量empno会绑定到占位符❸。当你使用cursor.execute()执行查询时,你将绑定变量作为第二个参数传递,并以元组的形式传递❹。即使只需要传递一个变量,execute()方法也要求将绑定变量作为元组或字典传递。
你通过cursor对象访问检索到的行,并在循环中遍历它。每一行作为元组访问,元组的项表示该行字段的值❺。在这里,你只是打印字段的值,逐行输出结果,如下所示:
9002, Jane Boorman, sales
9003, Tom Heints, sales
您还可以编写SELECT语句,连接来自不同表的行。连接关系型数据库表类似于连接 pandas DataFrame 的过程,如第三章所讨论的那样。您通常通过在设置数据库时定义的外键关系来连接表。
例如,假设您想要连接emps和salary表,并保持empno大于9001的条件。您可以通过它们共享的empno列来实现这一点,因为您在salary表中将empno定义为外键,引用了emps表中的empno。您可以通过修改脚本中的try块来实现此连接:
`--snip--`
try:
cnx = mysql.connector.connect(user='root', password='`your_pswd`',
host='127.0.0.1',
database='sampledb')
cursor = cnx.cursor()
query = ("""SELECT ❶ e.empno, e.empname, e.job, s.salary
FROM ❷ emps e JOIN salary s ON ❸ e.empno = s.empno
WHERE ❹ e.empno > %s""")
empno = 9001
cursor.execute(query, (empno,))
for (empno, empname, job, salary) in cursor:
print("{}, {}, {}, {}".format(
empno, empname, job, salary))
`--snip--`
这次,query包含一个SELECT语句,连接了emps和salary表。在SELECT列表中,您指定了要在连接中包含的两个表中的列 ❶。在FROM子句中,您指定了这两个表,并通过JOIN关键字连接它们,以及别名e和s,这对于区分两个表中同名的列是必要的 ❷。在ON子句中,您定义了连接条件,表示两个表中的empno列的值应该匹配 ❸。在WHERE子句中,与之前的例子一样,您使用%s占位符设置最小的empno值 ❹。
脚本输出以下行,每个员工的薪资与其在emps表中的记录连接:
9002, Jane Boorman, sales, 2800
9003, Tom Heints, sales, 2500
使用数据库分析工具
在 MySQL 中持久化数据时,您可以利用数据库内置的分析工具,如分析 SQL,显著减少应用程序和数据库之间传输的数据量。分析 SQL是一组额外的 SQL 命令,旨在实际分析存储在数据库中的数据,而不仅仅是存储、检索和更新数据。例如,假设您只想导入与那些股票价格在一定期间内未跌破前一天价格 1%的公司相关的股市数据。您可以通过分析 SQL 进行初步分析,这样您就无需将整个股票价格数据集从数据库加载到 Python 脚本中。
为了了解这个过程,您将通过第三章介绍的 yfinance 库获取股票数据并将其存储到数据库表中。然后,您将从 Python 脚本中查询该表,只加载满足指定条件的股票数据部分。首先,您需要在sampledb数据库中创建一个表来存储股票数据。该表应有三列:ticker、date和price。在mysql>提示符下输入以下命令:
mysql> **CREATE TABLE stocks(**
**ticker VARCHAR(10),**
**date VARCHAR(10),**
**price DECIMAL(15,2)**
**);**
现在使用此脚本通过 yfinance 获取一些股票数据:
import yfinance as yf
❶ data = []
❷ tickers = ['TSLA', 'FB', 'ORCL', 'AMZN']
for ticker in tickers:
❸ tkr = yf.Ticker(ticker)
hist = tkr.history(period='5d')
❹ .reset_index()
❺ records = hist[['Date','Close']].to_records(index=False)❻ records = list(records)
records = [(ticker, ❼ str(elem[0])[:10], round(elem[1],2)) for elem in records]
❽ data = data + records
首先,定义一个名为 data 的空列表,将填充股票数据❶。正如本章前面所见,cursor.execute() 方法在执行 INSERT 语句时期望数据以列表对象的形式传入。接下来,定义一个股票代码列表,你希望获取这些股票的数据❷。然后,在一个循环中,将 tickers 列表中的每个股票代码传递给 yfinance 的 Ticker() 函数❸。该函数返回一个 Ticker 对象,其 history() 方法提供与对应股票代码相关的数据。在此示例中,你获取了每个股票代码过去五个交易日的股票数据(period='5d')。
history() 方法将股票数据返回为 pandas DataFrame,并以 Date 列作为索引。最终,你需要将该 DataFrame 转换为元组列表,以便插入数据库。由于需要将 Date 列包含在数据集中,你可以通过 DataFrame 的 reset_index() 方法将其从索引中移除,从而将 Date 转换为普通列❹。然后,从获取的 DataFrame 中仅提取 Date 和 Close 列,其中 Close 包含当天的股票收盘价,并将其转换为 NumPy 记录数组,这是将输入数据转换的一个中间步骤❺。接下来,将数据转换为元组列表❻。之后,还需要重新格式化每个元组,以便将其作为一行插入到 stocks 数据库表中。特别地,每个 Date 字段包含大量多余信息(小时、分钟、秒等)。通过提取每个元组中第一个字段的前 10 个字符,你仅保留年、月和日,这是你分析所需的全部信息❼。例如,2022-01-06T00:00:00.000000000 将变为 2022-01-06。最后,仍在循环内,将与股票代码相关的元组追加到 data 列表中❽。
因此,data 元组列表的内容可能如下所示:
[
('TSLA', '2022-01-06', 1064.7),
('TSLA', '2022-01-07', 1026.96),
('TSLA', '2022-01-10', 1058.12),
('TSLA', '2022-01-11', 1064.4),
('TSLA', '2022-01-12', 1106.22),
('FB', '2022-01-06', 332.46),
('FB', '2022-01-07', 331.79),
('FB', '2022-01-10', 328.07),
('FB', '2022-01-11', 334.37),
('FB', '2022-01-12', 333.26),
('ORCL', '2022-01-06', 86.34),
('ORCL', '2022-01-07', 87.51),
('ORCL', '2022-01-10', 89.28),
('ORCL', '2022-01-11', 88.48),
('ORCL', '2022-01-12', 88.31),
('AMZN', '2022-01-06', 3265.08),
('AMZN', '2022-01-07', 3251.08),
('AMZN', '2022-01-10', 3229.72),
('AMZN', '2022-01-11', 3307.24),
('AMZN', '2022-01-12', 3304.14)
]
若要将此数据集作为一组行插入 stocks 表,请将以下代码附加到之前的脚本并重新执行:
import mysql.connector
from mysql.connector import errorcode
try:
cnx = mysql.connector.connect(user='root', password='`your_pswd`',
host='127.0.0.1',
database='sampledb')
cursor = cnx.cursor()
# defining the query
query_add_stocks = ("""INSERT INTO stocks (ticker, date, price)
VALUES (%s, %s, %s)""")
# adding the stock price rows
❶ cursor.executemany(query_add_stocks, data)
cnx.commit()
except mysql.connector.Error as err:
print("Error-Code:", err.errno)
print("Error-Message: {}".format(err.msg))
finally:
cursor.close()
cnx.close()
这段代码遵循你之前用于将数据插入数据库的相同模型。不过,这次你使用 cursor.executemany() 方法,它允许你高效地多次执行 INSERT 语句,每次执行时使用 data 元组列表中的一个元组❶。
现在,你已经将数据存入数据库,可以使用分析 SQL 查询来进行操作,尝试回答问题。例如,为了筛选出股价比前一天低于 1% 的股票,如本节开始时所建议的,你需要一个能够分析同一股票在多天内股价的查询。作为第一步,以下查询生成一个数据集,其中包含当前股价和前一天的股价,并将它们放在同一行中。在 mysql> 提示符下试试这个查询:
SELECT
date,
ticker,
price,
LAG(price) OVER(PARTITION BY ticker ORDER BY date) AS prev_price
FROM stocks;
SELECT列表中的LAG()函数是一个分析 SQL 函数。它让你从当前行访问上一行的数据。OVER子句中的PARTITION BY子句将数据集分成多个组,每个组对应一个 ticker。LAG()函数在每个组内单独应用,确保数据不会从一个 ticker 传递到另一个 ticker。查询生成的结果看起来大概是这样的:
+------------+--------+---------+------------+
| date | ticker | price | prev_price |
+------------+--------+---------+------------+
| 2022-01-06 | AMZN | 3265.08 | NULL |
| 2022-01-07 | AMZN | 3251.08 | 3265.08 |
| 2022-01-10 | AMZN | 3229.72 | 3251.08 |
| 2022-01-11 | AMZN | 3307.24 | 3229.72 |
| 2022-01-12 | AMZN | 3304.14 | 3307.24 |
| 2022-01-06 | FB | 332.46 | NULL |
| 2022-01-07 | FB | 331.79 | 332.46 |
| 2022-01-10 | FB | 328.07 | 331.79 |
| 2022-01-11 | FB | 334.37 | 328.07 |
| 2022-01-12 | FB | 333.26 | 334.37 |
| 2022-01-06 | ORCL | 86.34 | NULL |
| 2022-01-07 | ORCL | 87.51 | 86.34 |
| 2022-01-10 | ORCL | 89.28 | 87.51 |
| 2022-01-11 | ORCL | 88.48 | 89.28 |
| 2022-01-12 | ORCL | 88.31 | 88.48 |
| 2022-01-06 | TSLA | 1064.70 | NULL |
| 2022-01-07 | TSLA | 1026.96 | 1064.70 |
| 2022-01-10 | TSLA | 1058.12 | 1026.96 |
| 2022-01-11 | TSLA | 1064.40 | 1058.12 |
| 2022-01-12 | TSLA | 1106.22 | 1064.40 |
+------------+--------+---------+------------+
20 rows in set (0.00 sec)
查询生成了一个新的列prev_price,包含前一天的股票价格。如你所见,LAG()基本上让你在同一行中访问两行数据,这意味着你可以在同一个数学表达式中操作这两行数据作为查询的一部分。例如,你可以将一个价格除以另一个价格来计算每日的百分比变化。考虑到这一点,下面是一个查询,满足最初的要求,仅选择那些价格在指定期间内没有跌幅超过 1%的代码的行:
❶ SELECT s.* FROM stocks AS s
LEFT JOIN
❷ (SELECT DISTINCT(ticker) FROM
❸ (SELECT
❹ price/LAG(price) OVER(PARTITION BY ticker ORDER BY date) AS dif,
ticker
FROM stocks) AS b
❺ WHERE dif <0.99) AS a
❻ ON a.ticker = s.ticker
❼ WHERE a.ticker IS NULL;
这个 SQL 语句是对同一表stocks发出的两个不同查询的连接。连接的第一个查询检索了stocks表的所有行❶,而第二个查询仅检索了那些价格至少下降了 1%或更多的代码,这种下降发生在分析期的至少一天❷。这个连接的第二个查询结构较为复杂:它从子查询中选择数据,而不是直接从stocks表中选择。该子查询从❸开始,检索那些在price字段中的值比前一行低至少 1%的行。你通过将price除以LAG(price)❹并检查结果是否小于0.99❺来确定这一点。然后,在主查询的SELECT列表中,你对ticker字段应用DISTINCT()函数,以去除结果集中重复的代码名❷。
你在ticker列上连接了查询❻。在WHERE子句中,你指示连接只检索那些a.ticker字段(价格跌幅超过 1%的代码)和s.ticker字段(所有代码)之间没有对应关系的行❼。由于使用了左连接,只有来自第一个查询的匹配行会被检索。结果是,连接返回了所有tickers不在第二个查询返回的代码中的stocks表行。
根据前面展示的股票数据,查询生成的结果集如下:
+--------+------------+---------+
| ticker | date | price |
+--------+------------+---------+
| ORCL | 2022-01-06 | 86.34 |
| ORCL | 2022-01-07 | 87.51 |
| ORCL | 2022-01-10 | 89.28 |
| ORCL | 2022-01-11 | 88.48 |
| ORCL | 2022-01-12 | 88.31 |
| AMZN | 2022-01-06 | 3265.08 |
| AMZN | 2022-01-07 | 3251.08 |
| AMZN | 2022-01-10 | 3229.72 |
| AMZN | 2022-01-11 | 3307.24 |
| AMZN | 2022-01-12 | 3304.14 |
+--------+------------+--------+
10 rows in set (0.00 sec)
如你所见,并非所有来自stocks表的行都已被检索。特别是,你不会找到与 FB 和 TSLA 代码相关的行。例如,后者被排除是因为在之前查询生成的输出中发现了以下这一行:
+------------+--------+---------+------------+
| date | ticker | price | prev_price |
+------------+--------+---------+------------+
...
2022-01-07 | TSLA | 1026.96 | 1064.70 |
...
该行显示了 3.54%的跌幅,超出了 1%的阈值。
在以下脚本中,你在 Python 代码中发出相同的查询,并将结果提取到一个 pandas DataFrame 中:
import pandas as pd
import mysql.connector
from mysql.connector import errorcode
try:
cnx = mysql.connector.connect(user='root', password='`your_pswd`',
host='127.0.0.1',
database='sampledb')
query = ("""
SELECT s.* FROM stocks AS s
LEFT JOIN
(SELECT DISTINCT(ticker) FROM
(SELECT
price/LAG(price) OVER(PARTITION BY ticker ORDER BY date) AS dif,
ticker
FROM stocks) AS b
WHERE dif <0.99) AS a
ON a.ticker = s.ticker
WHERE a.ticker IS NULL""")
❶ df_stocks = pd.read_sql(query, con=cnx)
❷ df_stocks = df_stocks.set_index(['ticker','date'])
except mysql.connector.Error as err:
print("Error-Code:", err.errno)
print("Error-Message: {}".format(err.msg))
finally:
cnx.close()
这个脚本与本章前面展示的那些大体相似,主要区别是你直接将数据库数据加载到 pandas DataFrame 中。为此,你使用 pandas 的 read_sql() 方法,该方法的第一个参数是 SQL 查询语句(作为字符串),第二个参数是数据库连接对象 ❶。然后,你将 ticker 和 date 列设置为 DataFrame 的索引 ❷。
给定之前展示的股票数据,生成的 df_stocks DataFrame 将如下所示:
price
ticker date
ORCL 2022-01-06 86.34
2022-01-07 87.51
2022-01-10 89.28
2022-01-11 88.48
2022-01-12 88.31
AMZN 2022-01-06 3265.08
2022-01-07 3251.08
2022-01-10 3229.72
2022-01-11 3307.24
2022-01-12 3304.14
现在你已经将数据加载到 DataFrame 中,可以在 Python 中继续进行进一步分析。例如,你可能想计算每个股票符号在一定时间段内的平均价格。在下一章中,你将看到如何通过在 DataFrame 中按组级别应用适当的聚合函数来解决这类问题。
NoSQL 数据库
NoSQL 数据库,或称 非关系型数据库,不要求存储数据时有预先定义的组织架构,也不支持像连接(joins)这样的标准关系型数据库操作。相反,它们提供了更加灵活的结构化数据存储方式,使得处理大量数据变得更加容易。例如,一种类型的 NoSQL 数据库是键值存储(key-value store),它允许你将数据存储为键值对,例如时间-事件对。另一种类型的 NoSQL 数据库是面向文档的数据库,它们被设计为处理灵活结构的数据容器,如 JSON 文档。这使得你可以将与特定对象相关的所有信息作为数据库中的一个条目进行存储,而不是像关系型数据库中那样将信息拆分到多个表中。
尽管 NoSQL 数据库的出现时间不如关系型数据库那样久远,但由于它们允许开发人员以简单直观的格式存储数据,并且无需高级技术就能访问和操作数据,因此它们迅速获得了普及。它们的灵活性使其特别适用于实时和大数据应用,例如 Google Gmail 或 LinkedIn。
键值存储
键值存储 是一种持有键值对的数据库,类似于 Python 字典。一个典型的键值存储示例是 Redis,它代表远程字典服务(Remote Dictionary Service)。Redis 支持诸如 GET、SET 和 DEL 等命令,用于访问和操作键值对,以下是一个简单的示例:
$ **redis-cli**
127.0.0.1:6379> **SET emp1 "Maya Silver"**
OK
127.0.0.1:6379> **GET emp1**
"Maya Silver"
在这里,你使用 SET 命令创建键 emp1,并为其赋值 Maya Silver,然后使用 GET 命令通过键获取值。
设置 Redis
要自行探索 Redis,你需要安装它。你可以在redis.io/topics/quickstart上找到 Redis 快速入门页面的详细信息。安装完 Redis 服务器后,你还需要安装 redis-py,这是一个让你能够通过 Python 代码与 Redis 交互的 Python 库。你可以使用pip命令来完成安装:
$ **pip install redis**
然后,你可以通过命令import redis将 redis-py 导入到你的脚本中。
使用 Python 访问 Redis
以下是一个通过 redis-py 库从 Python 访问 Redis 服务器的简单示例:
> **import redis**
❶ > **r = redis.Redis()**
❷ > **r.mset({"emp1": "Maya Silver", "emp2": "John Jamison"})**
True
❸ > **r.get("emp1")**
b'Maya Silver'
你使用redis.Redis()方法建立与 Redis 服务器的连接 ❶。由于方法的参数被省略,默认值将被采用,这假设服务器运行在你的本地机器上:host='localhost',port=6379,以及db=0。
在建立连接后,你使用mset()方法设置多个键值对 ❷(m代表multiple)。当数据成功存储时,服务器返回True。然后,你可以使用get()方法获取任何已存储键的值 ❸。
像任何其他数据库一样,Redis 允许你持久化插入的数据,这样你就可以在另一个 Python 会话或脚本中通过键来获取值。Redis 还允许你在设置键值对时为键设置过期标志,指定它应该保留多长时间。这在实时应用程序中尤为有用,因为输入数据在一定时间后会变得无关紧要。例如,如果你的应用程序是一个出租车服务,你可能想要存储每辆出租车的可用性数据。由于这些数据会经常变化,你可能希望它们在短时间后过期。以下是这种情况的示例:
`--snip--`
> **from datetime import timedelta**
> **r.setex("cab26", timedelta(minutes=1), value="in the area now")**
True
你使用setex()方法设置一个键值对,该键值对将在指定的时间后自动从数据库中删除。在这里,你将过期时间指定为一个timedelta对象。或者,你可以将其指定为秒数。
到目前为止,我们只讨论了简单的键值对,但你还可以使用 Redis 存储与同一对象相关的多个信息,正如下例所示:
> **cabDict = {"ID": "cab48", "Driver": "Dan Varsky", "Brand": "Volvo"}**
> **r.hmset("cab48", cabDict)**
> **r.hgetall("cab48")**
{'Cab': 'cab48', 'Driver': 'Dan Varsky', 'Brand': 'Volvo'}
你首先定义一个 Python 字典,可以包含任意数量的键值对。然后,你将整个字典发送到数据库,并通过hmset()将其存储在cab48键下(h代表hash)。接着,你使用hgetall()函数来检索存储在cab48键下的所有键值对。
文档导向数据库
文档导向数据库 将每条记录存储为一个单独的文档。与关系型数据库表的字段必须遵循预定义的模式不同,文档导向数据库中的每个文档可以具有自己的结构。这种灵活性使得文档导向数据库成为最受欢迎的 NoSQL 数据库类型,而在文档导向数据库中,MongoDB 无疑是领先者。MongoDB 旨在管理 JSON 类文档的集合。在本节中,我们将探讨如何使用 MongoDB。
设置 MongoDB
你可以尝试使用 MongoDB 的几种方式。其中一种是将 MongoDB 数据库安装到你的系统上。详情请参考 MongoDB 文档:docs.mongodb.com/manual/installation。另一种无需安装的方式是使用 MongoDB Atlas 创建一个免费的托管 MongoDB 数据库。你需要在 www.mongodb.com/cloud/atlas/register 注册账号。
在你开始从 Python 与 MongoDB 数据库交互之前,你需要安装 PyMongo,这是 MongoDB 的官方 Python 驱动程序。你可以使用 pip 命令来安装:
$ **pip install pymongo**
使用 Python 访问 MongoDB
使用 Python 操作 MongoDB 的第一步是通过 PyMongo 的 MongoClient 对象建立与数据库服务器的连接,如下所示:
> **from pymongo import MongoClient**
> **client = MongoClient('**`connection_string`**')**
连接字符串可以是 MongoDB 连接 URI,例如 mongodb://localhost:27017。这个连接字符串假设你已在本地系统上安装了 MongoDB。如果你使用的是 MongoDB Atlas,你需要使用 Atlas 提供的连接字符串。有关详细信息,请参考 Atlas 文档中的“通过驱动程序连接”页面:docs.atlas.mongodb.com/driver-connection。你还可以查看 MongoDB 文档中的“连接字符串 URI 格式”页面:docs.mongodb.com/manual/reference/connection-string。
除了使用连接字符串外,你还可以将主机和端口作为 MongoClient() 构造函数的单独参数来指定:
> **client = MongoClient('localhost', 27017)**
一个 MongoDB 实例可以支持多个数据库,因此一旦与服务器建立连接,你需要指定要操作的数据库。MongoDB 并未提供单独的命令来创建数据库,因此你使用相同的语法来创建新数据库或访问现有数据库。例如,要创建一个名为 sampledb 的数据库(如果已存在,则访问它),你可以使用以下类似字典的语法:
> **db = client['sampledb']**
或者使用属性访问语法:
> **db = client.sampledb**
与关系型数据库不同,MongoDB 不将数据存储在表格中。相反,文档被分组到 集合 中。创建或访问集合类似于创建或访问数据库:
> **emps_collection = db['emps']**
此命令将在 sampledb 数据库中创建 emps 集合,如果该集合尚未创建。然后,你可以使用 insert_one() 方法将文档插入该集合。在这个例子中,你将插入一个格式为字典的 emp 文档:
> **emp = {"empno": 9001,**
... **"empname": "Jeff Russell",**
... **"orders": [2608, 2617, 2620]}**
> **result = emps_collection.insert_one(emp)**
在文档插入后,会自动为其添加一个 "_id" 字段。该字段的值在集合中是唯一的。你可以通过 insert_one() 返回的对象的 inserted_id 字段访问该 ID:
> **result.inserted_id**
ObjectId('69y67385ei0b650d867ef236')
现在你已经在数据库中插入了一些数据,如何查询它呢?最常见的查询类型是使用 find_one(),它返回一个与搜索条件匹配的单一文档:
> **emp = emps_collection.find_one({"empno": 9001})**
> **print(emp)**
如你所见,find_one() 不需要使用文档的 ID,该 ID 在插入时会自动添加。相反,你可以查询特定的元素,只要返回的文档与之匹配。
结果将类似于以下内容:
{
u'empno': 9001,
u'_id': ObjectId('69y67385ei0b650d867ef236'),
u'empname': u'Jeff Russell',
u'orders': [2608, 2617, 2620]
}
总结
在本章中,你看到了将数据从不同类型的数据库(包括关系型数据库和 NoSQL 数据库)进行迁移的示例。你使用了 MySQL,这是最受欢迎的关系型数据库之一。接着,你了解了 Redis,一种 NoSQL 解决方案,它允许你高效地存储和检索键值对。你还探讨了 MongoDB,可以说是今天最受欢迎的 NoSQL 数据库,它允许你以对 Python 友好的方式处理类似 JSON 的文档。
第六章:聚合数据

为了从数据中获得最大的决策价值,您通常需要生成数据的聚合。聚合是一个收集数据的过程,以便它可以以汇总形式呈现,通过小计、总计、平均数或其他统计数据分组。本章探讨了 pandas 内置的聚合技术,并讨论了如何使用它们来分析您的数据。
聚合是快速获取大型数据集概览的有效方式,它使您能够回答有关数据值的问题。例如,大型零售企业可能希望根据品牌确定产品的表现,或者查看不同地区的销售总额。网站所有者可能希望根据访问者数量确定网站上最具吸引力的资源。气候学家可能需要根据每年阳光明媚的天数,确定一个地区最阳光明媚的地方。
聚合可以通过将特定的数据值收集在一起并以总结的形式呈现,来回答像这样的问提。由于聚合是基于相关数据集群呈现信息,因此它首先意味着按一个或多个属性对数据进行分组。在大型零售商的情况下,这可能意味着按品牌分组数据,或者按地区和日期同时分组。
在接下来的示例中,您将看到如何在使用 pandas DataFrame 时,通过对每组行应用聚合函数,实现数据分组。聚合函数基于整个行组返回一个单一的结果行,从而为每个组形成一个单一的聚合总结行。
聚合数据
为了了解聚合如何工作,我们将创建一组包含在线户外时尚零售商销售数据的示例 DataFrame。数据将包括订单号和日期等值;每个订单中所购商品的详细信息,如价格和数量;完成每个订单的员工;以及公司履行订单的仓库位置。在实际应用中,这些数据很可能存储在数据库中,您可以从 Python 代码中访问,如第五章所述。为了简化,我们将从元组列表中将数据加载到 DataFrame 中。您可以从本书的 GitHub 仓库下载这些元组列表。
让我们从一些示例订单开始。名为orders的列表包含多个元组,每个元组代表一个订单。每个元组有三个字段:订单号、日期和完成订单的员工 ID,分别如下:
orders = [
(9423517, '2022-02-04', 9001),
(4626232, '2022-02-04', 9003),
(9423534, '2022-02-04', 9001),
(9423679, '2022-02-05', 9002),
(4626377, '2022-02-05', 9003),
(4626412, '2022-02-05', 9004),
(9423783, '2022-02-06', 9002),
(4626490, '2022-02-06', 9004)
]
导入 pandas 并按如下方式将列表加载到 DataFrame 中:
import pandas as pd
df_orders = pd.DataFrame(orders, columns =['OrderNo', 'Date', 'Empno'])
订单详情(也称为订单行)通常存储在另一个数据容器中。在本例中,我们有一个名为details的元组列表,您将把它加载到另一个 DataFrame 中。每个元组代表订单中的一行,包含订单号、商品名称、品牌、价格和数量等字段:
details = [
(9423517, 'Jeans', 'Rip Curl', 87.0, 1),
(9423517, 'Jacket', 'The North Face', 112.0, 1),
(4626232, 'Socks', 'Vans', 15.0, 1),
(4626232, 'Jeans', 'Quiksilver', 82.0, 1),
(9423534, 'Socks', 'DC', 10.0, 2),
(9423534, 'Socks', 'Quiksilver', 12.0, 2),
(9423679, 'T-shirt', 'Patagonia', 35.0, 1),
(4626377, 'Hoody', 'Animal', 44.0, 1),
(4626377, 'Cargo Shorts', 'Animal', 38.0, 1),
(4626412, 'Shirt', 'Volcom', 78.0, 1),
(9423783, 'Boxer Shorts', 'Superdry', 30.0, 2),
(9423783, 'Shorts', 'Globe', 26.0, 1),
(4626490, 'Cargo Shorts', 'Billabong', 54.0, 1),
(4626490, 'Sweater', 'Dickies', 56.0, 1)
]
# converting the list into a DataFrame
df_details = pd.DataFrame(details, columns =['OrderNo', 'Item', 'Brand', 'Price', 'Quantity'])
我们将在第三个 DataFrame 中存储公司员工的信息。通过另一个名为 emps 的元组列表创建该 DataFrame,其中包含员工编号、姓名和位置:
emps = [
(9001, 'Jeff Russell', 'LA'),
(9002, 'Jane Boorman', 'San Francisco'),
(9003, 'Tom Heints', 'NYC'),
(9004, 'Maya Silver', 'Philadelphia')
]
df_emps = pd.DataFrame(emps, columns =['Empno', 'Empname', 'Location'])
最后,我们将每个仓库的城市和地区存储在一个名为 locations 的元组列表中,这将被存储在第四个 DataFrame 中:
locations = [
('LA', 'West'),
('San Francisco', 'West'),
('NYC', 'East'),
('Philadelphia', 'East')
]
df_locations = pd.DataFrame(locations, columns =['Location', 'Region'])
现在你已经将数据加载到 DataFrame 中,你可以以多种方式进行汇总,从而回答关于业务状况的各种问题。例如,你可能想查看不同地区的销售表现,并按日期生成小计。为此,你首先需要将相关数据合并到一个单一的 DataFrame 中。然后,你可以对数据进行分组,并应用聚合函数。
合并 DataFrame
你经常需要从多个不同的容器中收集数据,才能获得完成所需聚合所需的所有信息。我们的示例也不例外。即使是表示订单的数据,也分布在两个不同的 DataFrame 中:df_orders 和 df_details。你的目标是生成按地区和日期汇总的销售总额。你需要合并哪些 DataFrame?每个 DataFrame 中应该包含哪些列?
由于你需要汇总销售额,因此必须包含 df_details 中的 Price 和 Quantity 列。同时,还需要包含 df_orders 中的 Date 列和 df_locations 中的 Region 列。这意味着你需要连接以下 DataFrame:df_orders、df_details 和 df_locations。
df_orders 和 df_details DataFrame 可以通过 pandas 的 merge() 方法直接连接,代码如下:
df_sales = df_orders.merge(df_details)
你可以根据 OrderNo 列连接 DataFrame。无需明确指定这一点,因为 OrderNo 在两个 DataFrame 中都存在,因此默认选择它。新合并的 DataFrame 现在包含每个订单行的一条记录,如同 df_details 中一样,但加入了来自 df_orders 的相应记录的信息。要查看合并后的 DataFrame 中的记录,你可以直接打印 DataFrame:
print(df_sales)
df_sales 的内容将如下所示:
OrderNo Date Empno Item Brand Price Quantity
0 9423517 2022-02-04 9001 Jeans Rip Curl 87.0 1
1 9423517 2022-02-04 9001 Jacket The North Face 112.0 1
2 4626232 2022-02-04 9003 Socks Vans 15.0 1
3 4626232 2022-02-04 9003 Jeans Quiksilver 82.0 1
4 9423534 2022-02-04 9001 Socks DC 10.0 2
5 9423534 2022-02-04 9001 Socks Quiksilver 12.0 2
6 9423679 2022-02-05 9002 T-shirt Patagonia 35.0 1
7 4626377 2022-02-05 9003 Hoody Animal 44.0 1
8 4626377 2022-02-05 9003 Cargo Shorts Animal 38.0 1
9 4626412 2022-02-05 9004 Shirt Volcom 78.0 1
10 9423783 2022-02-06 9002 Boxer Shorts Superdry 30.0 2
11 9423783 2022-02-06 9002 Shorts Globe 26.0 1
12 4626490 2022-02-06 9004 Cargo Shorts Billabong 54.0 1
13 4626490 2022-02-06 9004 Sweater Dickies 56.0 1
如你在 Quantity 列中所见,一条订单中可能包含多个相同的商品。因此,你需要将 Price 和 Quantity 字段的值相乘,以计算订单行的总额。你可以将这一乘法结果存储在 DataFrame 的新字段中,如下所示:
df_sales['Total'] = df_sales['Price'] * df_sales['Quantity']
这会在 DataFrame 中添加一个 Total 列,除了原有的七列之外。现在,你可以选择删除不需要的列,以便生成按地区和日期汇总的销售额。在这个阶段,你只需要保留 Date、Total 和 Empno 列。前两列显然对你的计算至关重要。稍后我们将讨论 Empno 的必要性。
要将 DataFrame 筛选到必要的列,传递一个列名列表给 DataFrame 的 [] 操作符,如下所示:
df_sales = df_sales[['Date','Empno','Total']]
现在,你需要将刚刚创建的 df_sales DataFrame 与 df_regions DataFrame 连接起来。然而,由于它们没有共同的列,不能直接连接。因此,你必须通过 df_emps DataFrame 来连接它们,因为 df_emps 和 df_sales 共享一个列,df_emps 和 df_regions 也共享一个列。具体来说,df_sales 和 df_emps 可以通过 Empno 列连接,这也是我们在 df_sales 中保留该列的原因,而 df_emps 和 df_locations 可以通过 Location 列连接。使用 merge() 方法实现这些连接:
df_sales_emps = df_sales.merge(df_emps)
df_result = df_sales_emps.merge(df_locations)
打印出的 df_result DataFrame 如下所示:
Date Empno Total Empname Location Region
0 2022-02-04 9001 87.0 Jeff Russell LA West
1 2022-02-04 9001 112.0 Jeff Russell LA West
2 2022-02-04 9001 20.0 Jeff Russell LA West
3 2022-02-04 9001 24.0 Jeff Russell LA West
4 2022-02-04 9003 15.0 Tom Heints NYC East
5 2022-02-04 9003 82.0 Tom Heints NYC East
6 2022-02-05 9003 44.0 Tom Heints NYC East
7 2022-02-05 9003 38.0 Tom Heints NYC East
8 2022-02-05 9002 35.0 Jane Boorman San Francisco West
9 2022-02-06 9002 60.0 Jane Boorman San Francisco West
10 2022-02-06 9002 26.0 Jane Boorman San Francisco West
11 2022-02-05 9004 78.0 Maya Silver Philadelphia East
12 2022-02-06 9004 54.0 Maya Silver Philadelphia East
13 2022-02-06 9004 56.0 Maya Silver Philadelphia East
再次,你可能想要移除不必要的列,只保留实际需要的列。这次你可以去掉 Empno、Empname 和 Location 列,保留 Date、Region 和 Total:
df_result = df_result[['Date','Region','Total']]
现在 df_result 的内容如下所示:
Date Region Total
0 2022-02-04 West 87.0
1 2022-02-04 West 112.0
2 2022-02-04 West 20.0
3 2022-02-04 West 24.0
4 2022-02-04 East 15.0
5 2022-02-04 East 82.0
6 2022-02-05 East 44.0
7 2022-02-05 East 38.0
8 2022-02-05 West 35.0
9 2022-02-06 West 60.0
10 2022-02-06 West 26.0
11 2022-02-05 East 78.0
12 2022-02-06 East 54.0
13 2022-02-06 East 56.0
在不包含不必要列的情况下,df_result DataFrame 现在已经理想地格式化,用于按地区和日期聚合销售数据。
数据分组与聚合
要对数据进行聚合计算,必须先将其排序为相关的组。pandas 的 groupby() 函数会将 DataFrame 的数据拆分为具有相同列值的子集。对于我们的例子,可以使用 groupby() 按日期和地区对 df_result DataFrame 进行分组。然后,可以对每个组应用 pandas 的 sum() 聚合函数。你可以在一行代码中同时完成这两个操作:
df_date_region = df_result.groupby(['Date','Region']).sum()
第一次分组是基于 Date 列。然后,在每个日期内,按照 Region 分组。groupby() 函数返回一个对象,然后你可以对其应用 sum() 聚合函数。这个函数会对数值型列的值进行求和。在这个特定的例子中,sum() 仅应用于 Total 列,因为这是 DataFrame 中唯一的数值型列。(如果 DataFrame 中有其他数值型列,聚合函数也会应用到这些列。)最终,你将得到如下的 DataFrame:
Total
Date Region
2022-02-04 East 97.0
West 243.0
2022-02-05 East 160.0
West 35.0
2022-02-06 East 110.0
West 86.0
Date 和 Region 是新 DataFrame 的索引列。它们一起构成了一个层次化索引,也称为多级索引,或者简称为MultiIndex。
MultiIndex 使得在 DataFrame 的 2D 结构中,可以通过使用多列来唯一标识每一行,从而处理具有任意维度的数据。在我们的例子中,df_date_region DataFrame 可以视为一个 3D 数据集,三个轴分别对应日期、地区和汇总值(每个轴代表对应的维度),如 表 6-1 所示。
表 6-1:df_date_region DataFrame 的三个维度
| 轴 | 坐标 |
|---|---|
| 日期 | 2022-02-04, 2022-02-05, 2022-02-06 |
| 地区 | 西部, 东部 |
| 聚合 | 总计 |
我们的数据框的 MultiIndex 使我们能够编写查询,遍历数据框的维度,按日期、地区或两者的总和进行访问。我们将能够唯一标识数据框中的每一行,并访问不同数据组内的选定聚合值。
按 MultiIndex 查看特定的汇总信息
在数据框中查看特定类别的信息是常见的需求。例如,在您刚创建的 df_date_region 数据框中,您可能只需要获取特定日期的聚合销售数据,或者同时获取某个地区和某个日期的销售数据。您可以使用数据框的索引(或 MultiIndex)来查找所需的聚合数据。
为了更好地理解如何使用 MultiIndex,查看每个 MultiIndex 值在 Python 中的表示方式会有所帮助。您可以使用 df_date_region 数据框的 index 属性来实现这一点:
print(df_date_region.index)
index 属性返回数据框的所有索引值或行标签,无论该数据框是使用简单索引还是 MultiIndex。以下是 df_date_region 的 MultiIndex 值:
MultiIndex([('2022-02-04', 'East'),
('2022-02-04', 'West'),
('2022-02-05', 'East'),
('2022-02-05', 'West'),
('2022-02-06', 'East'),
('2022-02-06', 'West')],
names=['Date', 'Region'])
如您所见,每个 MultiIndex 值都是一个元组,您可以使用它来访问 Total 字段中的相应销售数据。考虑到这一点,以下是如何访问特定日期和地区的总销售数据:
df_date_region❶ [df_date_region.index.isin(❷ [('2022-02-05', 'West')])]
您将表示所需 MultiIndex 的元组放入 [] 运算符 ❷ 中,并将其传递给 pandas 的 index.isin() 方法。该方法要求传递的参数必须是可迭代对象(如列表或元组)、Series、DataFrame 或字典,这就是为什么您需要将所需的 MultiIndex 放在方括号中的原因。该方法返回一个布尔数组,指示数据框中每个索引值是否与您指定的索引值匹配:如果匹配则为 True,否则为 False。在这个特定示例中,isin() 方法生成了数组 [False, False, False, True, False, False],表示第四个索引值是匹配的。
然后,您将布尔数组传递给 df_date_region 数据框中的 [] 运算符 ❶,从而选择相应的销售数据,如下所示:
Total
Date Region
2022-02-05 West 35.0
您不仅限于从数据框中检索单行数据。您可以将多个索引值传递给 index.isin(),以获取一组对应的销售数据,如下所示:
df_date_region[df_date_region.index.isin([('2022-02-05', 'East'), ('2022-02-05', 'West')])]
这将从 df_date_region 中检索以下两行数据:
Total
Date Region
2022-02-05 East 160.0
West 35.0
尽管此特定示例使用了两个相邻的索引,实际上您可以按任意顺序将任何索引传递给 index.isin(),如下所示:
df_date_region[df_date_region.index.isin([('2022-02-06', 'East'),
('2022-02-04', 'East'), ('2022-02-05', 'West')])]
检索到的行集合将如下所示:
Total
Date Region
2022-02-04 East 97.0
2022-02-05 West 35.0
2022-02-06 East 110.0
请注意,检索到的记录顺序与数据框中的记录顺序匹配,而不是您指定索引的顺序。
切片聚合值的范围
就像你可以使用切片从列表中获取一系列值一样,你也可以使用它从 DataFrame 中提取一系列聚合值。你可以在df_date_region DataFrame 中通过提供两个元组来实现,这两个元组指定了切片范围的开始和结束位置的 MultiIndex 键。以下示例获取了所有地区在2022-02-04到2022-02-05日期范围内的聚合值范围。你只需将开始和结束的 MultiIndex 键放在方括号内,用冒号分隔:
df_date_region[('2022-02-04', 'East'):('2022-02-05', 'West')]
结果,你将得到以下几行的 DataFrame:
Total
Date Region
2022-02-04 East 97.0
West 243.0
2022-02-05 East 160.0
West 35.0
由于在这个特定示例中,你正在获取指定日期范围内所有地区的销售数据,你可以省略地区名称,仅传递日期:
df_date_region['2022-02-04':'2022-02-05']
这将给你与前一个示例完全相同的结果。
在聚合级别中进行切片
你可能希望在不同层次的层级索引中对聚合结果进行切片。在我们的示例中,最高的聚合级别是Date级别,在该级别下我们有Region级别。假设你需要获取特定日期范围内的销售数据,并选择Region级别的所有内容。你可以通过结合使用 Python 的slice()函数和 DataFrame 的loc属性来实现这一点,如下所示:
df_date_region.loc[(slice('2022-02-05', '2022-02-06'), slice(None)), :]
这里你使用了两次slice()。第一次,slice()定义了Date(最高聚合级别)的切片范围,生成了指定开始和结束日期的slice对象。第二次调用slice()时,你针对的是Region级别(下一个较低级别)。通过指定None,你选择了Region级别的所有内容。在loc属性的[]操作符中,你还包括了一个逗号,后跟一个冒号(:)。这种语法表示你正在使用行标签而不是列标签。
结果集如下:
Total
Date Region
2022-02-05 East 160.0
West 35.0
2022-02-06 East 110.0
West 86.0
在下一个示例中,你将slice(None)替换为slice('East'),从而将检索的销售数据减少到仅包含East的行,这些行是在指定日期范围内选取的:
df_date_region.loc[(slice('2022-02-05', '2022-02-06'), slice('East')), :]
这将检索以下几行:
Total
Date Region
2022-02-05 East 160.0
2022-02-06 East 110.0
你可以为Region级别指定一个范围,而不是单个值,就像你为Date级别指定一个范围一样。然而,在这个特定示例中,该范围只能从'East'开始,并以'West'结束,表示为slice('East','West')。由于这是最大可能的范围,调用slice('East','West')将等同于调用slice(None)。
添加总计
在汇总销售数据时,你可能最终需要计算总计,或是所有其他销售值总和,并将其添加到 DataFrame 中。在我们的示例中,由于所有总计都在同一个 DataFrame df_date_region 中,你可以使用 pandas 的sum()方法来计算所有地区和所有日期的销售总额。该方法会计算指定轴上的值的总和,如下所示:
ps = df_date_region.sum(axis = 0)
print(ps)
在这里,sum()返回一个 pandas Series,它对df_date_region DataFrame 中的Total列进行求和。记住,在调用sum()时无需指定Total列,因为它会自动应用于任何数值型数据。ps Series 的内容如下所示:
Total 731.0
dtype: float64
要将新创建的 Series 附加到df_date_region DataFrame,首先需要为其命名。这个名称将作为 DataFrame 中总计行的索引。由于df_date_region DataFrame 中的索引键是元组,因此你也需要为 Series 命名时使用元组:
ps.name=('All','All')
元组中的第一个'All'与索引键的Date组件相关,而第二个'All'与Region索引键组件相关。现在,你可以将 Series 附加到 DataFrame:
df_date_region_total = df_date_region.append(ps)
如果你打印出新创建的 DataFrame,它的内容将如下所示:
Total
Date Region
2022-02-04 East 97.0
West 243.0
2022-02-05 East 160.0
West 35.0
2022-02-06 East 110.0
West 86.0
All All 731.0
你可以通过其索引访问总计行,就像访问 DataFrame 中的任何其他行一样。在这里,你将表示该行索引的元组传递给index.isin()方法,如前所述:
df_date_region_total[df_date_region_total.index.isin([('All', 'All')])]
这将为你提供总计行:
Total
Date Region
All All 731.0
添加小计
除了计算总计,你可能还希望为 DataFrame 中的每个日期添加小计,使得结果 DataFrame 如下所示:
Total
Date Region
2022-02-04 East 97.0
West 243.0
All 340.0
2022-02-05 East 160.0
West 35.0
All 195.0
2022-02-06 East 110.0
West 86.0
All 196.0
All All 731.0
生成这个 DataFrame 需要几个步骤。首先,你按索引的Date级别对 DataFrame 进行分组。然后你遍历生成的 GroupBy 对象,访问每个日期及其对应的行集合(称为子框架),该集合包含该日期的区域和总计信息。接着,你选择并附加每个子框架到一个空的 DataFrame,并附加一个对应的小计行。步骤如下:
❶ df_totals = pd.DataFrame()
for date, date_df in ❷ df_date_region.groupby(level=0):
❸ df_totals = df_totals.append(date_df)
❹ ps = date_df.sum(axis = 0)
❺ ps.name=(date,'All')
❻ df_totals = df_totals.append(ps)
你首先创建一个空的 DataFrame,df_totals,用来接收最终的数据 ❶。然后你创建一个 GroupBy 对象 ❷,将df_date_region DataFrame 按其索引的顶层层次(即Date列)进行分组,并进入一个for循环,遍历 GroupBy 对象。在每次迭代中,你将得到一个日期及其对应的子框架。你将子框架附加到df_totals DataFrame ❸,然后创建一个包含子框架行总和的 Series 作为小计行 ❹。接着,你用对应的日期和'All'为 Series 命名,表示所有区域 ❺,然后将 Series 附加到df_totals DataFrame ❻。
最后,你将总计行附加到 DataFrame,如下所示:
df_totals = df_totals.append(df_date_region_total.loc[('All','All')])
结果,你将得到一个包含每个日期的销售总和以及所有日期总和的 DataFrame。
选择某组中的所有行
除了帮助聚合,groupby()函数还可以帮助你选择属于某个特定组的所有行。为此,groupby()返回的对象提供了get_group()方法。操作方法如下:
group = df_result.groupby(['Date','Region'])
group.get_group(('2022-02-04','West'))
你将 df_result DataFrame 按照 Date 和 Region 分组,将列名作为列表传递给 groupby(),就像你之前做的那样。然后,你在结果的 GroupBy 对象上调用 get_group() 方法,传递包含所需索引的元组。这将返回以下 DataFrame:
Date Region Total
0 2022-02-04 West 87.0
1 2022-02-04 West 112.0
2 2022-02-04 West 20.0
3 2022-02-04 West 24.0
如你所见,结果集并不是一个聚合结果。相反,它包含了与指定日期和地区相关的所有订单行。
总结
你在本章中学到,聚合是收集数据并以总结格式表达它的过程。通常,这个过程涉及将数据分成多个组,然后为每个组计算总结。本章中的示例展示了如何聚合包含在 pandas DataFrame 中的数据,使用 DataFrame 方法和属性,如 merge()、groupby()、sum()、index 和 loc。你学习了如何利用 DataFrame 的层次化索引(MultiIndex),以建模在聚合数据中的多级关系。你还学会了如何使用 MultiIndex 查看和切片聚合数据。
第七章:数据集的结合

数据常常分布在多个容器中。因此,你通常需要将不同的数据集合并成一个。在前几章中你已经进行过一些数据合并,但在本章中,我们将深入探讨更多结合数据集的技术。
在某些情况下,结合数据集可能仅仅是将一个数据集添加到另一个数据集的末尾。例如,财务分析师每周可能会收到一批新的股票数据,需要将其添加到现有的股票数据集中。其他时候,你可能需要更有选择性地结合共享某个公共列的数据集,将它们合并为一个汇总数据集。例如,零售商可能希望将有关在线订单的通用数据与关于所订购商品的具体细节合并,正如你在第六章中所看到的。在任何情况下,一旦你将数据结合起来,就可以用它进行进一步的分析。例如,你可以对结合后的数据集运行一系列筛选、分组或聚合操作。
正如你在前几章中学到的那样,Python 中的数据集可以采用内建数据结构,如列表、元组和字典,或者可以使用第三方数据结构,如 NumPy 数组或 pandas DataFrame。在后者的情况下,你拥有更丰富的工具集来结合数据,因此在需要满足某些连接条件时,你有更多的选择。然而,这并不意味着你不能有效地结合内建的 Python 数据结构。本章将展示如何做到这一点,以及如何结合第三方数据结构。
结合内建数据结构
将 Python 内建数据结构结合的语法非常简单。在这一节中,你将看到如何使用+运算符结合列表或元组。接着,你将学习如何使用**运算符结合字典。你还将探索如何对元组列表执行连接、聚合和其他操作,基本上将它们当作数据库表来处理,每个元组代表一行数据。
使用 + 结合列表和元组
将两个或更多列表或两个或更多元组结合起来的最简单方法是使用 + 运算符。你只需写一个语句,将列表或元组加在一起,就像你加多个数字一样。当你需要将结构中的元素合并到一个新的结构中,而不改变元素本身时,这个方法非常有效。这个过程通常被称为 连接。
为了演示,我们将回到上一章中介绍的在线时尚零售商的例子。假设每天的订单信息都被收集在一个列表中,因此你会为每一天有一个列表。你可能有以下三个列表:
orders_2022_02_04 = [
(9423517, '2022-02-04', 9001),
(4626232, '2022-02-04', 9003),
(9423534, '2022-02-04', 9001)
]
orders_2022_02_05 = [
(9423679, '2022-02-05', 9002),
(4626377, '2022-02-05', 9003),
(4626412, '2022-02-05', 9004)
]
orders_2022_02_06 = [
(9423783, '2022-02-06', 9002),
(4626490, '2022-02-06', 9004)
]
为了进一步分析,你可能需要将这些列表合并为一个单一的列表。+ 运算符使这变得非常简单;你只需将三个列表加在一起:
orders = orders_2022_02_04 + orders_2022_02_05 + orders_2022_02_06
合并后的orders列表如下所示:
[
(9423517, '2022-02-04', 9001),
(4626232, '2022-02-04', 9003),
(9423534, '2022-02-04', 9001),
(9423679, '2022-02-05', 9002),
(4626377, '2022-02-05', 9003),
(4626412, '2022-02-05', 9004),
(9423783, '2022-02-06', 9002),
(4626490, '2022-02-06', 9004)
]
如你所见,三个原始列表中的元素现在都出现在一个单一列表中,顺序由你写合并语句时的顺序决定。在这个特定的例子中,合并的列表元素都是元组。然而,+运算符同样适用于合并元素类型为任何类型的列表。因此,你可以轻松地合并包含整数、字符串、字典或其他任何内容的列表。
你可以使用相同的+语法来合并多个元组。然而,如果你尝试使用+合并字典,你将遇到unsupported operand type(s)错误。以下部分将解释合并字典的正确语法。
使用**合并字典
**运算符将字典拆开,或称为解包,将其拆解为单个的键值对。要将两个字典合并为一个,你可以使用**解包两个字典,并将结果存储在一个新字典中。这即使在其中一个或两个字典具有层级结构的情况下也能工作。在我们的零售商示例中,考虑以下包含订单相关额外字段的字典:
extra_fields_9423517 = {
'ShippingInstructions' : { 'name' : 'John Silver',
'Phone' : [{ 'type' : 'Office', 'number' : '809-123-9309' },
{ 'type' : 'Mobile', 'number' : '417-123-4567' }
]}
}
得益于有意义的键名,字典的嵌套结构变得清晰。事实上,通过键而不是位置访问数据的能力,使得在处理层级数据结构时,字典比列表更为优越。
现在假设你有另一个字典,其中包含相同订单的其他字段:
order_9423517 = {'OrderNo':9423517, 'Date':'2022-02-04', 'Empno':9001}
你的任务是将这些字典合并成一个单一的字典,包含原始字典中的所有键值对。你可以像下面这样使用**运算符:
order_9423517 = {**order_9423517, **extra_fields_9423517}
你将要连接的字典放在花括号内,每个字典名前面加上**。**运算符解包这两个字典为它们的键值对,然后花括号将它们重新包装成一个单一的字典。现在,order_9423517看起来像这样:
{
'OrderNo': 9423517,
'Date': '2022-02-04',
'Empno': 9001,
'ShippingInstructions': {'name': 'John Silver',
'Phone': [{'type': 'Office', 'number': '809-123-9309'},
{'type': 'Mobile', 'number': '417-123-4567'}
]}
}
如你所见,原始字典中的所有元素都已存在,并且它们的层级结构得到了保留。
将两个结构中的对应行合并
你已经知道如何将多个列表合并成一个列表,而不改变这些列表的元素。实际上,你还经常需要将两个或多个共享公共列的数据结构合并为一个结构,将这些数据结构中的对应行合并为单一行。如果你的数据结构是 pandas DataFrame,你可以使用像join()和merge()这样的方法,正如你在前几章中看到的那样。然而,如果你的数据结构是包含“行”的元组列表,那么这些方法并不可用。相反,你需要遍历这些列表,并逐行地合并。
为了说明,我们将结合本章中“通过+合并列表和元组”部分中创建的orders列表与第六章“数据聚合”中介绍的details列表。提醒一下,以下是该列表的样子:
details = [
(9423517, 'Jeans', 'Rip Curl', 87.0, 1),
(9423517, 'Jacket', 'The North Face', 112.0, 1),
(4626232, 'Socks', 'Vans', 15.0, 1),
(4626232, 'Jeans', 'Quiksilver', 82.0, 1),
(9423534, 'Socks', 'DC', 10.0, 2),
(9423534, 'Socks', 'Quiksilver', 12.0, 2),
(9423679, 'T-shirt', 'Patagonia', 35.0, 1),
(4626377, 'Hoody', 'Animal', 44.0, 1),
(4626377, 'Cargo Shorts', 'Animal', 38.0, 1),
(4626412, 'Shirt', 'Volcom', 78.0, 1),
(9423783, 'Boxer Shorts', 'Superdry', 30.0, 2),
(9423783, 'Shorts', 'Globe', 26.0, 1),
(4626490, 'Cargo Shorts', 'Billabong', 54.0, 1),
(4626490, 'Sweater', 'Dickies', 56.0, 1)
]
两个列表都包含元组,元组的第一个元素是订单号。目标是找到具有匹配订单号的元组,将它们合并为一个元组,并将所有元组存储在一个列表中。实现方法如下:
❶ orders_details = []
❷ for o in orders:
for d in details:
❸ if d[0] == o[0]:
orders_details.append(o + ❹ d[1:])
首先,创建一个空列表来接收合并后的元组 ❶。然后,使用一对嵌套循环来遍历details列表 ❷,并通过if语句 ❸来合并只有匹配订单号的元组。为了避免在合并并追加到orders_details列表的元组中重复出现订单号,您可以对每个details元组使用切片 ❹,选择它的所有字段,除了第一个包含冗余订单号的字段。
看着这段代码,您可能会想,它是否可以更优雅地实现为一行代码。确实,借助列表推导式,您可以如下实现相同的结果:
orders_details = [[o for o in orders if d[0] == o[0]][0] + d[1:] for d in details]
在外部列表推导式中,您遍历details列表中的元组。在内部列表推导式中,您找到orders列表中与当前details元组的订单号匹配的元组。由于details中的订单行应该只有一个与orders中的元组匹配,内部列表推导式应该生成一个包含单个元素的列表(代表一个订单的元组)。因此,您通过[0]运算符取出内部列表推导式的第一个元素,然后使用+运算符将该订单的元组与details中对应的元组连接起来,省略冗余的订单号部分[1:]。
无论是通过列表推导式创建的orders_details,还是借助两个for循环创建的,如前所示,生成的列表将如下所示:
[
(9423517, '2022-02-04', 9001, 'Jeans', 'Rip Curl', 87.0, 1),
(9423517, '2022-02-04', 9001, 'Jacket', 'The North Face', 112.0, 1),
(4626232, '2022-02-04', 9003, 'Socks', 'Vans', 15.0, 1),
(4626232, '2022-02-04', 9003, 'Jeans', 'Quiksilver', 82.0, 1),
(9423534, '2022-02-04', 9001, 'Socks', 'DC', 10.0, 2),
(9423534, '2022-02-04', 9001, 'Socks', 'Quiksilver', 12.0, 2),
(9423679, '2022-02-05', 9002, 'T-shirt', 'Patagonia', 35.0, 1),
(4626377, '2022-02-05', 9003, 'Hoody', 'Animal', 44.0, 1),
(4626377, '2022-02-05', 9003, 'Cargo Shorts', 'Animal', 38.0, 1),
(4626412, '2022-02-05', 9004, 'Shirt', 'Volcom', 78.0, 1),
(9423783, '2022-02-06', 9002, 'Boxer Shorts', 'Superdry', 30.0, 2),
(9423783, '2022-02-06', 9002, 'Shorts', 'Globe', 26.0, 1),
(4626490, '2022-02-06', 9004, 'Cargo Shorts', 'Billabong', 54.0, 1),
(4626490, '2022-02-06', 9004, 'Sweater', 'Dickies', 56.0, 1)
]
该列表包含details列表中的所有元组,每个元组还包含来自orders列表中对应元组的附加信息。
实现不同类型的列表连接
您在前一部分执行的操作是一个标准的“一对多”连接:details中的每个订单行都有一个在orders中匹配的订单,而orders中的每个订单有一个或多个在details中的订单行。然而,在实际操作中,连接的两个数据集中的某些行可能没有在另一个数据集中找到匹配的行。为了应对这种情况,您必须能够执行类似于我们在第三章中讨论的各种数据库风格的连接操作:左连接、右连接、内连接和外连接。
举个例子,details列表可能包含一些在orders列表中找不到的订单行。发生这种情况的一种方式是你对orders进行某个日期范围的过滤;由于details中没有日期字段,因此你无法相应地过滤details列表。为了模拟这种情况,向details列表中添加一行,引用一个不在orders中的订单:
details.append((4626592, 'Shorts', 'Protest', 48.0, 1))
如果你现在像之前一样尝试生成orders_details列表:
orders_details = [[o for o in orders if d[0] == o][0] + d[1:] for d in details]
你会得到以下错误:
IndexError: list index out of range
问题出现在你到达details列表中的不匹配订单号时,并试图检索对应内部列表推导中的第一个元素。由于订单号不在orders列表中,因此不存在这样的元素。解决此问题的一种方法是在外部列表推导中的for d in details循环内添加if语句,检查details行中的订单号是否可以在orders的任何行中找到,如下所示:
orders_details = [[o for o in orders if d[0] in o][0] + d[1:] for d in details
❶ if d[0] in [o[0] for o in orders]]
你通过排除任何没有匹配行的details行来解决问题,并在for d in details循环后的if语句中实现检查❶。因此,下面显示的列表推导会导致一个内连接。
但是,如果你想在结果orders_details列表中包含所有的details行呢?你可能想这样做,例如,方便你总结所有订单的总额,而不仅仅是当前orders列表中的订单总额(该列表假设已经按日期进行过过滤)。你可能会总结当前orders列表中存在的订单总额,并比较这些总和。
py`` What you want to implement in this case is a right join, assuming the `orders` list is on the left side of the relationship and the `details` list is on the right. Recall that a right join returns all rows from the right dataset and only the matched rows from the left dataset. Update the previous list comprehension as follows: orders_details_right = [[o for o in orders if d[0] in o][0] + d[1:] if d[0] in [o[0] for o in orders] ❶ else (d[0], None, None) + d[1:] for d in details] py Here, you add an `else` clause ❶ to the `if` clause assigned to the `for d in details` loop. This `else` clause works for any `details` row that doesn’t have a matching row in `orders`. It creates a new tuple containing the order number plus two `None` entries to take the place of the missing `orders` fields, and it concatenates that tuple with the row from `details`, yielding a row with the same structure as all the others. So, the generated dataset will include the `details` row that doesn’t have a matched row in `orders` in addition to all the matching rows: [ --snip-- (4626490, '2022-02-06', 9004, 'Sweater', 'Dickies', 56.0, 1), (4626592, None, None, 'Shorts', 'Protest', 48.0, 1) ] py Now that you have the `orders_details_right` list (the right join of the `orders` and `details` lists), you can total up all the orders and compare the result with the total of just those orders included in the `orders` list. You add up the total of all the orders with Python’s built-in `sum()` function: sum(prqt for _, _, _, _, _, pr, qt in orders_details_right) py The `for` loop passed as a parameter to `sum()` is somewhat similar to the loop used in a list comprehension in that it allows you to take only the necessary elements with each iteration of the loop. In this particular example, all you need to find with each iteration is `pr*qt`, the multiplication of the Price and Quantity values from the tuple at hand. Since you’re not actually interested in the other values of each tuple, you use `_` placeholders for them in the clause after the `for` keyword. If you’ve followed the steps presented in this chapter so far, the preceding call will return: 779.0 py You can calculate the totals of only those orders that are in the `orders` list with a modified version of the `sum()` call: sum(prqt for _, dt, _, _, _, pr, qt in orders_details_right ❶ if dt != None) py Here you add an `if` clause to the loop to filter out orders that weren’t in the `orders` list ❶. You ignore rows where the Date (`dt`) field contains `None`, indicating that the row’s order information wasn’t retrieved from `orders`. The generated sum will be: 731.0 py ## Concatenating NumPy Arrays Unlike with lists, you can’t use the `+` operator to concatenate NumPy arrays. This is because, as discussed in Chapter 3, NumPy reserves the `+` operator for performing element-wise addition operations on multiple arrays. To concatenate two NumPy arrays, you instead use the `numpy.concatenate()` function. To demonstrate, we’ll use the `base_salary` array from “Creating a NumPy Array” in Chapter 3, which was created as follows (here, we’ll call it `base_salary1`): import numpy as np jeff_salary = [2700,3000,3000] nick_salary = [2600,2800,2800] tom_salary = [2300,2500,2500] base_salary1 = np.array([jeff_salary, nick_salary, tom_salary]) py Recall that each row in the array contains three months’ worth of base salary data for a particular employee. Now suppose you have salary information for two more employees in another array, `base_salary2`: maya_salary = [2200,2400,2400] john_salary = [2500,2700,2700] base_salary2 = np.array([maya_salary, john_salary]) py You want to store the salary information for all five employees in the same array. To do so, you concatenate `base_salary1` and `base_salary2` using `numpy.concatenate()`, as follows: base_salary = np.concatenate((base_salary1, base_salary2), axis=0) py The first parameter is a tuple containing the arrays to be concatenated. The second parameter, `axis`, is critical: it specifies whether the arrays should be concatenated horizontally or vertically, or in other words whether the second array will be added as new rows or new columns. The first axis (axis 0) runs vertically. Thus, `axis=0` instructs the `concatenate()` function to append the rows of `base_salary2` beneath those of `base_salary1`. The resulting array will look as follows: [[2700 3000 3000] [2600 2800 2800] [2300 2500 2500], [2200 2400 2400], [2500 2700 2700]] py Now imagine that the salary information for the next month has come in. You might put these new figures in another NumPy array, as follows: new_month_salary = np.array([[3000],[2900],[2500],[2500],[2700]]) py If you print the array, you’ll see the following output: [[3000] [2900] [2500] [2500] [2700]] py You need to add this `new_month_salary` array to the `base_salary` array as an extra column. Assuming the order of employees is the same in both arrays, you can use `concatenate()` for this as follows: base_salary = np.concatenate((base_salary, new_month_salary), axis=1) py Since axis 1 runs horizontally across the columns, `axis=1` instructs the `concatenate()` function to append the `new_month_salary` array as a column to the right of the `base_salary` array’s columns. Now `base_salary` will look like this: [[2700 3000 3000 3000] [2600 2800 2800 2900] [2300 2500 2500 2500] [2200 2400 2400 2500] [2500 2700 2700 2700]] py ## Combining pandas Data Structures In Chapter 3, we covered some basic techniques for combining pandas data structures. You saw examples of how Series objects can be combined into a DataFrame and how two DataFrames can be joined on their indexes. You also learned about the different types of joins you can create when merging two DataFrames into a single one, by passing the `how` parameter in to the pandas `join()` or `merge()` method. In this section, you’ll see more examples of how to use this parameter to create nondefault DataFrame joins, such as a right join. Before that, however, you’ll learn to concatenate two DataFrames along a particular axis. ### Concatenating DataFrames Like with NumPy arrays, you might need to concatenate two DataFrames along a particular axis, appending either the rows or the columns of one DataFrame to the other. The examples in this section show how to do this with the pandas `concat()` function. Before proceeding to the examples, you’ll need to create two DataFrames to be concatenated. Using the `jeff_salary`, `nick_salary`, and `tom_salary` lists from earlier in this chapter, you can create a DataFrame using a dictionary, like so: import pandas as pd salary_df1 = pd.DataFrame( {'jeff': jeff_salary, 'nick': nick_salary, 'tom': tom_salary }) py Each list becomes a value in the dictionary, which in turn becomes a column in the new DataFrame. The dictionary’s keys, which are the corresponding employee names, become the column labels. Each row of the DataFrame contains all the salary data for a single month. By default the rows are indexed numerically, but it would be more meaningful to index them by month. You can update the indices as follows: salary_df1.index = ['June', 'July', 'August'] py The `salary_df1` DataFrame will now look like this: jeff nick tom June 2700 2600 2300 July 3000 2800 2500 August 3000 2800 2500 py You may find it more convenient to view the salary data of an employee as a row rather than a column. You make this change with the DataFrame’s `T` property, which is a shorthand for the `DataFrame.``transpose()` method: salary_df1 = salary_df1.T py This statement *transposes* the DataFrame, turning its columns into rows and vice versa. The DataFrame is now indexed by employee name and looks as follows: June July August jeff 2700 3000 3000 nick 2600 2800 2800 tom 2300 2500 2500 py Now you need to create another DataFrame with the same columns to be concatenated with `salary_df1`. Following in line with the example of concatenating NumPy arrays, here you create a DataFrame that holds salary data for two more employees: salary_df2 = pd.DataFrame( {'maya': maya_salary, 'john': john_salary }, index = ['June', 'July', 'August'] ).T py You create the DataFrame, set the index, and transpose the rows and columns all in a single statement. The newly created DataFrame will look as follows: June July August maya 2200 2400 2400 john 2500 2700 2700 py Now that you’ve created both DataFrames, you’re ready to concatenate them. #### Concatenation Along Axis 0 The pandas `concat()` function concatenates pandas objects along a certain axis. By default, this function uses axis 0, meaning the rows of the DataFrame that appears second in the argument list will be appended below the rows of the DataFrame that appears first. Thus, to concatenate the `salary_df1` and `salary_df2` DataFrames in this manner, you can call `concat()` without passing the `axis` argument explicitly. All you have to do is specify the names of the DataFrames within square brackets: salary_df = pd.concat([salary_df1, salary_df2]) py This will generate the following DataFrame: June July August jeff 2700 3000 3000 nick 2600 2800 2800 tom 2300 2500 2500 maya 2200 2400 2400 john 2500 2700 2700 py As you can see, the `maya` and `john` rows from the second DataFrame have been added beneath the rows from the first DataFrame. #### Concatenation Along Axis 1 When concatenating along axis 1, the `concat()` function will append the columns of the second DataFrame to the right of the columns of the first one. To illustrate this, you can use `salary_df` from the preceding section as the first DataFrame. For the second DataFrame, create the following structure, which holds two more months’ worth of salary data: salary_df3 = pd.DataFrame( {'September': [3000,2800,2500,2400,2700], 'October': [3200,3000,2700,2500,2900] }, index = ['jeff', 'nick', 'tom', 'maya', 'john'] ) py Now call `concat()`, passing in the two DataFrames and specifying `axis=1` to ensure the concatenation is horizontal: salary_df = pd.concat([salary_df, salary_df3], axis=1) py The resulting DataFrame will look as follows: June July August September October jeff 2700 3000 3000 3000 3200 nick 2600 2800 2800 2800 3000 tom 2300 2500 2500 2500 2700 maya 2200 2400 2400 2400 2500 john 2500 2700 2700 2700 2900 py The salary data from the second DataFrame appears as new columns to the right of the salary data from the first DataFrame. #### Removing Columns/Rows from a DataFrame After combining DataFrames, you may need to remove some unnecessary rows or columns. Let’s say, for example, you want to remove the `September` and `October` columns from the `salary_df` DataFrame. You can do this with the `DataFrame.``drop()` method, as follows: salary_df = salary_df.drop(['September', 'October'], axis=1) py The first argument takes the names of the columns or rows to be deleted from the DataFrame. Then you use the `axis` argument to specify whether they’re rows or columns. In this example you’re deleting columns, because `axis` is set to `1`. With `drop()`, you aren’t limited to only deleting the last columns/rows of a DataFrame. You can pass in an arbitrary list of columns or rows to be deleted, as shown here: salary_df = salary_df.drop(['nick', 'maya'], axis=0) py After performing the two previous operations, `salary_df` will appear as follows: June July August jeff 2700 3000 3000 tom 2300 2500 2500 john 2500 2700 2700 py You’ve removed the columns for September and October and the rows for Nick and Maya. #### Concatenating DataFrames with a Hierarchical Index So far you’ve seen examples of concatenating DataFrames with simple indexes. Now let’s consider how to concatenate DataFrames with a MultiIndex. The following example uses the `df_date_region` DataFrame introduced in “Grouping and Aggregating the Data” in Chapter 6. The DataFrame was created as a result of several successive operations and looked as follows: Total Date Region 2022-02-04 East 97.0 West 243.0 2022-02-05 East 160.0 West 35.0 2022-02-06 East 110.0 West 86.0 py To re-create this DataFrame, you don’t have to follow the steps from Chapter 6. Instead, execute the following statement: df_date_region1 = pd.DataFrame( [ ('2022-02-04', 'East', 97.0), ('2022-02-04', 'West', 243.0), ('2022-02-05', 'East', 160.0), ('2022-02-05', 'West', 35.0), ('2022-02-06', 'East', 110.0), ('2022-02-06', 'West', 86.0) ], columns =['Date', 'Region', 'Total']).set_index(['Date','Region']) py Now you’ll need another DataFrame that’s also indexed by `Date` and `Region`. Create it like this: df_date_region2 = pd.DataFrame( [ ('2022-02-04', 'South', 114.0), ('2022-02-05', 'South', 325.0), ('2022-02-06', 'South', 212.0) ], columns =['Date', 'Region', 'Total']).set_index(['Date','Region']) py The second DataFrame features the same three dates as the first but has data for a new region, `South`. The challenge when concatenating these two DataFrames is to keep the result sorted by date rather than simply appending the second DataFrame beneath the first one. Here’s how you can do this: df_date_region = pd.concat([df_date_region1, df_date_region2]).sort_index(level=['Date','Region']) py You start with a `concat()` call that looks the same as it would if it were concatenating DataFrames with single-column indexes. You identify the DataFrames to be combined, and since you omit the `axis` parameter, they’ll be concatenated vertically by default. To sort the rows in the resulting DataFrame by date and region, you then have to call the `sort_index()` method. As a result, you’ll get the following DataFrame: Total Date Region 2022-02-04 East 97.0 South 114.0 West 243.0 2022-02-05 East 160.0 South 325.0 West 35.0 2022-02-06 East 110.0 South 212.0 West 86.0 py As you can see, the rows from the second DataFrame have been integrated among the rows from the first DataFrame, maintaining the top-level grouping by date. ### Joining Two DataFrames When you join two DataFrames, you combine each row from one dataset with the matching row(s) from the other, rather than simply appending one DataFrame’s rows or columns beneath or beside the other’s. To review the basics of joining DataFrames, refer back to “Combining DataFrames” in Chapter 3, which covers the different types of joins you can implement. In this section, you’ll go beyond what you learned in Chapter 3 by implementing a right join and a join based on a many-to-many relationship. #### Implementing a Right Join A right join takes all the rows from a second DataFrame and combines them with any matching rows from a first DataFrame. As you’ll see, this type of join comes with the possibility that some rows in the resulting DataFrame will have undefined fields, which can lead to unexpected challenges. To demonstrate, you’ll perform a right join of the `df_orders` and `df_details` DataFrames created from the `orders` and `details` lists introduced in Chapter 6, which you used in the examples in the opening section of this chapter. Create the DataFrames from these lists as follows: import pandas as pd df_orders = pd.DataFrame(orders, columns =['OrderNo', 'Date', 'Empno']) df_details = pd.DataFrame(details, columns =['OrderNo', 'Item', 'Brand', 'Price', 'Quantity']) py Recall that each row in the original `details` list has a matching row in the `orders` list. Therefore, the same is true for the `df_details` and `df_orders` DataFrames. To properly illustrate a right join, you need to add one or more new rows to `df_details` that don’t have matches in `df_orders`. You can add a row using the `DataFrame.``append()` method, which takes the row being appended either as a dictionary or a Series. If you followed along with the example in “Implementing Different Types of Joins for Lists” earlier in this chapter, you’ve already added the following row to the `details` list, and therefore it should already appear in the `df_details` DataFrame. In that case, you can ignore the following append operation. Otherwise, append this row to `df_details` as a dictionary. Note that the value of the new row’s `OrderNo` field can’t be found among the values of the `OrderNo` column in the `df_orders` DataFrame: df_details = df_details.append( {'OrderNo': 4626592, 'Item': 'Shorts', 'Brand': 'Protest', 'Price': 48.0, 'Quantity': 1 }, ❶ ignore_index = True ) py You must set the `ignore_index` parameter to `True` ❶, or you won’t be able to append a dictionary to a DataFrame. Setting this parameter to `True` also resets the DataFrame’s index, maintaining continuous index values (`0, 1, ...`) for the rows. Next, you join the `df_orders` and `df_details` DataFrames using the `merge()` method. As discussed in Chapter 3, `merge()` provides a convenient way of joining two DataFrames with a common column: df_orders_details_right = df_orders.merge(df_details, ❶ how='right', ❷ left_on='OrderNo', right_on='OrderNo') py You use the `how` parameter to specify the type of join—in this example, a right join ❶. With the `left_on` and `right_on` parameters, you specify the columns to join on from the `df_orders` and `df_details` DataFrames, respectively ❷. The resulting DataFrame will look as follows: OrderNo Date Empno Item Brand Price Quantity 0 9423517 2022-02-04 9001.0 Jeans Rip Curl 87.0 1 1 9423517 2022-02-04 9001.0 Jacket The North Face 112.0 1 2 4626232 2022-02-04 9003.0 Socks Vans 15.0 1 3 4626232 2022-02-04 9003.0 Jeans Quiksilver 82.0 1 4 9423534 2022-02-04 9001.0 Socks DC 10.0 2 5 9423534 2022-02-04 9001.0 Socks Quiksilver 12.0 2 6 9423679 2022-02-05 9002.0 T-shirt Patagonia 35.0 1 7 4626377 2022-02-05 9003
第八章:创建可视化图表

与原始数据相比,数据的可视化形式能更清晰地展示信息。例如,你可能想要创建一张折线图,展示股票价格随时间变化的趋势。或者,你也可以使用直方图跟踪网站文章的兴趣,显示每篇文章的日浏览量。像这样的可视化图表能帮助你立即识别数据中的趋势。
本章概述了最常见的数据可视化类型,并介绍了如何使用流行的 Python 绘图库 Matplotlib 创建这些可视化图表。你还将学习如何将 Matplotlib 与 pandas 集成,以及如何使用 Matplotlib 和 Cartopy 库创建地图。
常见的可视化图表
可用于数据可视化的图表类型有多种,包括折线图、条形图、饼图和直方图。在本节中,我们将讨论这些常见的可视化图表,并探索每种图表的典型使用案例。
折线图
折线图,也称为 折线图表,用于展示一段时间内数据的趋势。在折线图中,你将数据集的时间戳列放置在 x 轴上,将一个或多个数值列放置在 y 轴上。
以一个用户可以查看不同文章的网站为例,你可以为某篇文章创建一张图表,其中 x 轴表示一系列日期,y 轴表示每天该文章的浏览量。这一点在图 8-1 中得到了展示。

图 8-1:一张展示文章浏览量随时间变化的折线图
你可以在同一张折线图中叠加多个参数的数据,以展示它们之间的相关性,用不同颜色的线条绘制每个参数的数据。例如,图 8-2 展示了网站的日独立访客数与文章浏览量的叠加。

图 8-2:一张折线图,展示了各参数之间的关系
这张折线图的左侧 y 轴显示了文章浏览量,右侧 y 轴显示了独立访客数。将两者的数据叠加后,可以直观地看到文章浏览量与独立访客数之间的普遍关联。
条形图
条形图,也叫 条形图表 或 柱形图表,使用矩形条来显示类别数据,条形的高度与所代表的值成比例,从而可以对不同类别进行比较。例如,以下图示展示了一家公司按地区汇总的年度销售数据:
New England $882,703
Mid-Atlantic $532,648
Midwest $714,406
图 8-3 展示了将这些销售数据绘制为条形图后的效果。

图 8-3:一个显示比较分类数据的条形图
在此图中,y 轴显示了与 x 轴上显示的区域相比较的销售数据。
饼图
饼图展示了每个类别在整个数据集中的比例,通常以百分比表示。图 8-4 展示了之前示例中的销售数据在饼图中的表现。

图 8-4:饼图表示每个类别作为圆的一部分的百分比。
在这个图中,每个切片的大小提供了每个类别在整体中所占比例的可视化表示。你可以轻松地看出每个区域的销售额相互比较。对于每个切片表示整体中相对较大部分的情况,饼图非常有效,但如你所猜测的那样,当你需要表示非常小的部分时,饼图就不是最佳选择。例如,表示整个数据集 0.01% 的切片可能在图表中根本不可见。
直方图
直方图显示频率分布,即某个特定值或值的范围在数据集中出现的次数。每个值或结果由一个垂直条表示,条形的高度与该值的频率对应。例如,图 8-5 中的直方图立即展示了销售部门中不同薪资群体的频率。
在这个直方图中,薪资被分成$50 的区间,每个垂直条表示收入在某一范围内的人数。通过这种可视化,你可以迅速看到比如$1,200 到$1,250 之间的员工数量,与其他区间如$1,250 到$1,300 进行比较。

图 8-5:一个显示薪资分布的直方图
使用 Matplotlib 绘图
现在你已经了解了最常见的图表类型,我们将讨论如何使用 Matplotlib 来创建它们。Matplotlib 是一个流行的 Python 数据可视化库,你将学习如何制作折线图、饼图、条形图和直方图。
每个 Matplotlib 可视化图形或图表是由一系列嵌套的对象组成的。你可以直接操作这些对象来创建高度可定制的可视化,或者通过 matplotlib.pyplot 模块中提供的函数间接操作这些对象。后一种方法更简单,通常足以创建基本的图表和图形。
安装 Matplotlib
通过在 Python 解释器会话中尝试导入 Matplotlib,检查它是否已经安装:
> **import matplotlib**
如果你遇到ModuleNotFoundError错误,使用以下命令通过pip安装 Matplotlib:
$ **python -m pip install -U matplotlib**
使用 matplotlib.pyplot
matplotlib.pyplot模块,通常在代码中称为plt,提供了一系列函数,用于创建美观的图表。该模块使你能够轻松定义图表的各种属性,如标题、轴标签等。例如,下面是如何构建一个展示特斯拉五个连续交易日收盘股价的折线图:
from matplotlib import pyplot as plt
days = ['2021-01-04', '2021-01-05', '2021-01-06', '2021-01-07', '2021-01-08']
prices = [729.77, 735.11, 755.98, 816.04, 880.02]
plt.plot(days,prices)
plt.title('NASDAQ: TSLA')
plt.xlabel('Date')
plt.ylabel('USD')
plt.show()
首先,你将数据集定义为两个列表:days,包含将在 x 轴上绘制的日期,以及prices,包含将在 y 轴上绘制的价格。然后,你使用plt.plot()函数创建一个图表,即实际显示数据的部分,并传入 x 轴和 y 轴的数据。在接下来的三行代码中,你对图表进行定制:使用plt.title()添加标题,使用plt.xlabel()和plt.ylabel()为 x 轴和 y 轴添加标签。最后,使用plt.show()显示图表。图 8-6 展示了结果。

图 8-6:使用matplotlib.pyplot模块生成的简单折线图
默认情况下,plt.plot() 会生成一个可视化图像,将数据点连接成一系列线条,这些数据点会被绘制在 x 轴和 y 轴上。Matplotlib 自动为 y 轴选择了一个 720 到 880 的范围,间隔为 20,便于查看每日的股价。
构建一个基本的饼图和制作折线图一样简单。例如,以下代码生成了如前所示的图 8-4 中的饼图:
import matplotlib.pyplot as plt
regions = ['New England', 'Mid-Atlantic', 'Midwest']
sales = [882703, 532648, 714406]
plt.pie(sales, labels=regions, autopct='%1.1f%%')
plt.title('Sales per Region')
plt.show()
该脚本遵循了你用来生成折线图的基本模式:首先定义要绘制的数据,创建图表,定制一些图表的特征,然后显示它。这一次,数据包含了一个地区列表,这将作为每个饼图切片的标签,和一个包含各地区销售总额的列表,这将决定每个切片的大小。为了将图表变为饼图而非折线图,你调用plt.pie()函数,将sales作为要绘制的数据,regions作为数据的标签。你还使用autopct参数在饼图的切片上显示百分比值,并使用 Python 字符串格式化来显示百分比值到小数点后一位。
在这里,你将相同的输入数据可视化为柱状图,类似于图 8-3 中的那张图:
import matplotlib.pyplot as plt
regions = ['New England', 'Mid-Atlantic', 'Midwest']
sales = [882703, 532648, 714406]
plt.bar(regions, sales)
plt.xlabel("Regions")
plt.ylabel("Sales")
plt.title("Annual Sales Aggregated on a Regional Basis")
plt.show()
你将regions列表传递给plt.bar()函数,作为柱状图的 x 轴标签。你传递给plt.bar()的第二个参数是与regions中每个项对应的销售数据列表。无论在这里还是在饼图的例子中,你都能使用单独的列表来表示标签和销售数据,因为 Python 列表中的元素顺序是持久的。
使用 Figure 和 Axes 对象
本质上,一个 Matplotlib 可视化是由两种主要类型的对象构成的:Figure 对象和一个或多个 Axes 对象。在前面的例子中,matplotlib.pyplot 作为一个接口间接操作这些对象,允许你自定义可视化的一些元素。然而,你可以通过直接操作 Figure 和 Axes 对象本身,来对可视化进行更精细的控制。
Figure 对象是 Matplotlib 可视化的最上层、最外层容器。它可以包含一个或多个子图。当你需要对整体可视化进行操作时,比如调整其大小或保存到文件中,你会使用 Figure 对象。与此同时,每个 Axes 对象代表图形中的一个子图。你使用 Axes 对象来自定义子图并定义其布局。例如,你可以设置子图的坐标系统,并在坐标轴上标记位置。
你通过 matplotlib.pyplot.subplots() 函数访问 Figure 和 Axes 对象。当该函数没有参数时,它返回一个 Figure 实例和一个与该 Figure 关联的单一 Axes 实例。通过向 subplots() 函数添加参数,你可以创建一个 Figure 实例和多个关联的 Axes 实例。换句话说,你将创建一个包含多个绘图的图形。例如,调用 subplots(2,2) 创建一个四个子图的图形,排列为两行两列。每个子图由一个 Axes 对象表示。
使用 subplots() 创建直方图
在下面的脚本中,你使用 subplots() 创建一个 Figure 对象和一个 Axes 对象。然后你操作这些对象来生成先前在 图 8-5 中显示的直方图,展示一组员工的薪资分布。除了操作 Figure 和 Axes 对象外,你还会使用一个名为 matplotlib.ticker 的 Matplotlib 模块来格式化子图中 x 轴的刻度,并使用 NumPy 来定义一个以 50 为增量的直方图分箱序列:
# importing modules
import numpy as np
from matplotlib import pyplot as plt
import matplotlib.ticker as ticker
# data to plot
❶ salaries = [1215, 1221, 1263, 1267, 1271, 1274, 1275, 1318, 1320, 1324, 1324,
1326, 1337, 1346, 1354, 1355, 1364, 1367, 1372, 1375, 1376, 1378,
1378, 1410, 1415, 1415, 1418, 1420, 1422, 1426, 1430, 1434, 1437,
1451, 1454, 1467, 1470, 1473, 1477, 1479, 1480, 1514, 1516, 1522,
1529, 1544, 1547, 1554, 1562, 1584, 1595, 1616, 1626, 1717]
# preparing a histogram
❷ fig, ax = plt.subplots()
❸ fig.set_size_inches(5.6, 4.2)
❹ ax.hist(salaries, bins=np.arange(1100, 1900, 50), edgecolor='black',
linewidth=1.2)
❺ formatter = ticker.FormatStrFormatter('$%1.0f')
❻ ax.xaxis.set_major_formatter(formatter)
❼ plt.title('Monthly Salaries in the Sales Department')
plt.xlabel('Salary (bin size = $50)')
plt.ylabel('Frequency')
# showing the histogram
plt.show()
你首先定义一个 salaries 列表,包含你想要可视化的薪资数据❶。然后你调用 subplots() 函数,且不传递任何参数❷,从而指示该函数创建一个包含单个子图的图形。该函数返回一个元组,包含两个对象,分别是表示图形的 fig 和表示子图的 ax。
现在你已经拥有了这些 Figure 和 Axes 实例,你可以开始自定义它们了。首先,你调用 Figure 对象的 set_size_inches() 方法来调整整个图形的大小 ❸。然后,你调用 Axes 对象的 hist() 方法来绘制直方图 ❹。你将 salaries 列表作为直方图的输入数据传递给该方法,并使用 NumPy 数组定义直方图箱子的 x 轴点。你通过 NumPy 的 arange() 函数生成这个数组,它会在给定区间内生成一个均匀分布的数值数组(在这种情况下是 1100 到 1900 之间每 50 为递增值)。你使用 hist() 方法的 edgecolor 参数来绘制箱子的黑色边界线,并使用 linewidth 参数定义这些边界的宽度。
接下来,你使用 matplotlib.ticker 模块中的 FormatStrFormatter() 函数创建一个格式化器,用于在每个 x 轴标签前加上美元符号 ❺。你通过 ax.xaxis 对象的 set_major_formatter() 方法将格式化器应用到 x 轴标签 ❻。最后,你通过 matplotlib.pyplot 接口 ❼ 设置图表的总体属性,如标题和主坐标轴标签,并显示图表。
在饼图上显示频率分布
虽然直方图非常适合用于可视化频率分布,但你也可以使用饼图来传达频率分布的百分比。例如,本节展示了如何将你刚刚创建的薪资分布直方图转化为一个饼图,显示薪资如何作为整体的一部分进行分布。
在你创建这样的饼图之前,你需要从直方图中提取并组织一些关键信息。特别是,你需要了解每个 $50 区间内的薪资数量。你可以使用 NumPy 的 histogram() 函数来实现这一点;它会计算一个直方图,但不显示它:
import numpy as np
count, labels = np.histogram(salaries, bins=np.arange(1100, 1900, 50))
在这里,你调用了 histogram() 函数,将之前创建的相同的 salaries 列表传递给它,并再次使用 NumPy 的 arange() 函数生成均匀分布的箱子。调用 histogram() 会返回两个 NumPy 数组:count 和 labels。count 数组表示每个区间内具有相应薪资的员工数量,具体如下:
[0, 0, 2, 5, 7, 9, 10, 8, 6, 4, 2, 0, 1, 0, 0]
同时,labels 数组包含了箱间隔的边缘:
[1100, 1150, 1200, 1250, 1300, 1350, 1400, 1450, 1500, 1550, 1600, 1650,
1700, 1750, 1800, 1850]
接下来,你需要将 labels 数组中的相邻元素组合起来,将它们变成饼图切片的标签。例如,相邻的元素 1100 和 1150 应该合并为一个标签,格式为 '$1100-1150'。可以使用以下列表推导式:
labels = ['$'+str(labels[i])+'-'+str(labels[i+1]) for i, _ in enumerate(labels[1:])]
因此,labels 列表将如下所示:
['$1100-1150', '$1150-1200', '$1200-1250', '$1250-1300', '$1300-1350',
'$1350-1400', '$1400-1450', '$1450-1500', '$1500-1550', '$1550-1600',
'$1600-1650', '$1650-1700', '$1700-1750', '$1750-1800', '$1800-1850']
labels 中的每个元素对应 count 数组中相同索引位置的元素。然而,回顾 count 数组,你可能会注意到一个问题:某些区间的计数为 0,你不希望将这些空区间包含到饼图中。为了排除这些空区间,你需要生成一个列表,列出 count 数组中非空区间对应的索引:
non_zero_pos = [i for i, x in enumerate(count) if x != 0]
现在你可以使用 non_zero_pos 来过滤 count 和 labels,排除那些代表空区间的元素:
labels = [e for i, e in enumerate(labels) if i in non_zero_pos]
count = [e for i, e in enumerate(count) if i in non_zero_pos]
现在剩下的就是使用 matplotlib.pyplot 接口和 plt.pie() 创建并显示饼图:
from matplotlib import pyplot as plt
plt.pie(count, labels=labels, autopct='%1.1f%%')
plt.title('Monthly Salaries in the Sales Department')
plt.show()
图 8-7 显示了结果。
该饼图展示了与 图 8-5 中直方图相同的数据,但它显示的是每个区间占总体的百分比,而不是精确地表示有多少员工的薪资落在该区间内。

图 8-7:一个可视化频率分布的饼图
使用其他库与 Matplotlib 配合
Matplotlib 可以轻松地与其他 Python 库进行接口对接,从不同来源绘制数据或创建其他类型的可视化。例如,你可以将 Matplotlib 与 pandas 配合使用,绘制来自 DataFrame 的数据,或者通过将 Matplotlib 与 Cartopy(一个专门处理地理空间数据的库)结合使用来创建地图。
绘制 pandas 数据图
pandas 库与 Matplotlib 紧密集成。实际上,每个 pandas 的 Series 或 DataFrame 都有一个 plot() 方法,它实际上是 matplotlib.pyplot.plot() 方法的一个封装。它允许你直接将 pandas 数据结构转换为 Matplotlib 图表。为了演示,你将使用美国城市的人口数据创建一个条形图。你将使用来自 us-cities-top-1k.csv 文件的原始数据,文件可以在 github.com/plotly/datasets 获取。这个条形图将展示每个美国州的百万级大城市(人口达到或超过 1,000,000)的数量。操作步骤如下:
import pandas as pd
import matplotlib.pyplot as plt
# preparing the DataFrame
❶ us_cities = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/
master/us-cities-top-1k.csv")
❷ top_us_cities = us_cities[us_cities.Population.ge(1000000)]
❸ top_cities_count = top_us_cities.groupby(['State'], as_index = False)
.count().rename(columns={'City': 'cities_count'})
[['State','cities_count']]
# drawing the chart
❹ top_cities_count.plot.bar('State', 'cities_count', rot=0)
❺ plt.xlabel("States")
plt.ylabel("Top cities count")
plt.title("Number of Megacities per US State")
❻ plt.yticks(range(min(top_cities_count['cities_count']),
max(top_cities_count['cities_count'])+1 ))
plt.show()
首先,你通过 pandas 的 read_csv() 方法将数据集加载到 DataFrame 中 ❶。该数据集包含了美国 1,000 个最大城市的人口、纬度和经度。为了将数据集筛选为仅包含特大城市,你使用 DataFrame 的 ge() 方法,ge() 是 greater than or equal to(大于或等于)的缩写,要求仅返回 Population 字段大于或等于 1000000 的行 ❷。然后,你按 State 列对数据进行分组,并应用 count() 聚合函数,计算每个州的特大城市数量 ❸。在 groupby 操作中,你将 as_index 设置为 False,以避免将 State 列转换为结果 DataFrame 的索引。这是因为稍后你还需要在脚本中引用 State 列。你将 City 列重命名为 cities_count,以反映它现在包含了汇总信息,并且只保留 State 和 cities_count 列,生成新的 top_cities_count DataFrame。
接下来,你使用 DataFrame 的 plot.bar() 方法绘制一个条形图 ❹。记住,plot() 实际上是 Matplotlib 的 pyplot.plot() 方法的一个封装。在这次调用中,你指定了将用作图表 x 轴和 y 轴的 DataFrame 列名,并将 x 轴的刻度标签旋转为 0 度。创建图形后,你可以像前面的示例中那样,使用 matplotlib.pyplot 接口来定制图形。你设置了坐标轴标签和图形标题 ❺,并使用 plt.yticks() 设置 y 轴的数字标签,以反映特大城市的数量 ❻。最后,你通过 plt.show() 显示图形。图 8-8 显示了结果。

图 8-8:从 pandas DataFrame 生成的条形图
如你所见,图形的外观与本章中你创建的其他图形非常相似,尤其是 图 8-3 中的条形图。这并不令人惊讶,因为它是由同一个 Matplotlib 库生成的,而 pandas 在幕后也使用了这个库。
使用 Cartopy 绘制地理空间数据
Cartopy 是一个用于创建地理空间可视化(或地图)的 Python 库。它包含了 matplotlib.pyplot 的编程接口,使得绘制地图变得简单。基本上,使用 Cartopy 绘制地图就是创建一个 Matplotlib 图形,其中经度坐标沿 x 轴绘制,纬度坐标沿 y 轴绘制。Cartopy 处理将地球的球形转化为绘图二维平面的复杂性。为了演示,你将使用前一节介绍的 us-cities-top-1k.csv 数据集,绘制显示南加州各城市位置的轮廓图。但首先,你需要设置 Cartopy。
在 Google Colab 中使用 Cartopy
安装 Cartopy 可能会有些棘手,且过程因系统而异。因此,本节将展示如何通过 Google Colab Web IDE 使用 Cartopy,该 IDE 允许您通过浏览器编写和执行 Python 代码。
要加载 Colab,请访问 colab.research.google.com。然后点击新建 笔记本以启动一个新的 Colab 笔记本,您可以在其中创建、填充并运行任意数量的代码单元。在每个代码单元中,您可以将一行或多行 Python 代码组合在一起,并通过点击单元格左上角的运行按钮执行它们。Colab 会记住任何先前执行的单元所建立的执行状态,类似于 Python 解释器会话。您可以通过点击 Colab 窗口左上角的 +Code 按钮来创建新的代码单元。
在第一个代码单元中,输入并运行以下命令,以在 Colab 笔记本中安装 Cartopy:
**!pip install cartopy**
一旦安装了 Cartopy,您可以继续查看下一节的示例,将每个独立的代码列表示为单独的代码单元执行。
创建地图
在这一节中,您将使用 Cartopy 创建两张南加州的地图。首先,您将绘制一张显示 us-cities-top-1k.csv 数据集中所有南加州城市的地图。您从导入所有所需模块开始:
import pandas as pd
%matplotlib inline
import matplotlib.pyplot as plt
import cartopy.crs as ccrs
from cartopy.mpl.ticker import LongitudeFormatter, LatitudeFormatter
您将需要 pandas、matplotlib.pyplot 接口,以及一些不同的 Cartopy 模块:cartopy.crs 用于生成地图,LongitudeFormatter 和 LatitudeFormatter 用于正确格式化刻度标签。%matplotlib inline 命令是必需的,以便将 Matplotlib 图形嵌入到 Google Colab 笔记本中,显示在代码旁边。
接着,您加载所需的数据并绘制地图:
❶ us_cities = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/
master/us-cities-top-1k.csv")
❷ calif_cities = us_cities[us_cities.State.eq('California')]
❸ fig, ax = plt.subplots(figsize=(15,8))
❹ ax = plt.axes(projection=ccrs.Mercator())
❺ ax.coastlines('10m')
❻ ax.set_yticks([32,33,34,35,36], crs=ccrs.PlateCarree())
ax.set_xticks([-121, -120, -119, -118, -117, -116, -115],
crs=ccrs.PlateCarree())
❼ lon_formatter = LongitudeFormatter()
lat_formatter = LatitudeFormatter()
ax.xaxis.set_major_formatter(lon_formatter)
ax.yaxis.set_major_formatter(lat_formatter)
❽ ax.set_extent([-121, -115, 32, 36])
X = calif_cities['lon']
Y = calif_cities['lat']
❾ ax.scatter(X, Y, color='red', marker='o', transform=ccrs.PlateCarree())
plt.show()
您将 us-cities-top-1k.csv 数据集加载到 DataFrame 中 ❶,就像在上一节中做的那样。请记住,它包含以经纬度坐标形式表示的地理空间数据,以及人口数据。接着,您使用 DataFrame 的 eq() 方法 ❷(即 等于 的简写)来筛选数据,仅保留加利福尼亚州的城市。
由于绘制地图需要比 matplotlib.pyplot 接口更多的定制化,因此您需要直接操作可视化的底层 Matplotlib 对象。因此,您调用 plt.subplots() 函数来获取一个 Figure 对象和一个单独的 Axes 对象,同时设置图形大小 ❸。接着,您调用 plt.axes() 来覆盖 Axes 对象,将其转变为一个 Cartopy 地图 ❹。您通过告诉 Matplotlib 在绘制坐标时使用 Cartopy 的墨卡托投影法,来实现这一点。墨卡托投影是一种标准的制图技术,它将地球从球体转换为圆柱形,然后将圆柱展开成一个矩形。
接下来,您调用ax.coastlines()来显示地图上的陆地轮廓 ❺。海岸线是从 Natural Earth 的海岸线形状文件集合中添加到当前的Axes对象中的。通过指定10m,您以 1 比 1000 万的比例绘制海岸线;也就是说,地图上的 1 厘米相当于现实中的 100 公里。
要定义 y 轴和 x 轴上的刻度,您使用set_yticks()和set_xticks()方法,分别传递纬度和经度的列表 ❻。具体来说,您将32到36作为 y 刻度,将-121到-115作为 x 刻度(即 32°N 到 36°N 和 121°W 到 115°W),因为这些纬度和经度覆盖了南加州的区域。在这两种情况下,您都添加了crs=ccrs.PlateCarree()来指定如何将纬度和经度信息投影到平面上。像墨卡托投影一样,Plate Carrée 将地球视为一个圆柱体,并将其展平为矩形。
接下来,您使用 Cartopy 的LongitudeFormatter()和LatitudeFormatter()对象创建格式化器,并将它们应用于 x 轴和 y 轴 ❼。使用这些格式化器可以确保经度和纬度值显示带有度数符号,并且分别带有表示西或北的W或N。您还设置了绘图的范围,指定了适当的经纬度,以限制地图仅显示南加州 ❽。然后,您从 DataFrame 中提取两个 pandas Series 对象,分别为表示经度和纬度值的X和Y。最后,您使用 Matplotlib 的scatter()方法绘制地图 ❾,将数据传递到 x 轴和 y 轴上,并指示将城市作为红点显示。图 8-9 显示了结果。

图 8-9:标注了城市的南加州轮廓地图
这张地图清晰地展示了人口密度较高的区域。但是,如果您只想看到标注了城市名称的最大城市,应该怎么做呢?以下是您可以这样做的方法:
❶ top_calif_cities = calif_cities[calif_cities.Population.ge(400000)]
fig, ax = plt.subplots(figsize=(15,8))
ax = plt.axes(projection=ccrs.Mercator())
ax.coastlines('10m')
ax.set_yticks([32,33,34,35,36], crs=ccrs.PlateCarree())
ax.set_xticks([-121, -120, -119, -118, -117, -116, -115],
crs=ccrs.PlateCarree())
lon_formatter = LongitudeFormatter()
lat_formatter = LatitudeFormatter()
ax.xaxis.set_major_formatter(lon_formatter)
ax.yaxis.set_major_formatter(lat_formatter)
ax.set_extent([-121, -115, 32, 36])
X = top_calif_cities['lon']
Y = top_calif_cities['lat']
❷ cities = top_calif_cities['City']
ax.scatter(X, Y, color='red', marker='o', transform=ccrs.PlateCarree())
❸ for i in X.index:
label = cities[i]
plt.text(X[i], Y[i]+0.05, label, clip_on = True, fontsize = 20,
horizontalalignment='center', transform=ccrs.Geodetic())
plt.show()
您过滤了在前一部分生成的calif_cities DataFrame,仅包括人口超过 40 万的城市 ❶。然后,您按照之前的相同步骤生成图表,并添加一些额外的步骤来添加城市标签。您将城市名称存储在一个名为cities的 pandas Series 中 ❷,然后遍历城市名称,使用 Matplotlib 的plt.text()方法将其作为中心标签添加到地图上的点上 ❸。您指定transform=ccrs.Geodetic()以使 Matplotlib 在添加标签时使用 Cartopy 的 Geodetic 坐标系统。该系统将地球视为一个球体,并将坐标指定为纬度和经度值。图 8-10 显示了结果。

图 8-10:加利福尼亚南部最大的城市
现在,这张地图展示了加利福尼亚南部三座人口超过 40 万的城市的位置和名称。
摘要
如你所见,数据可视化是发现趋势和从数据中获得洞察的强大工具。例如,折线图能立即揭示股价的趋势,而地图则能清晰地展示人口密度较高的区域。在本章中,你学习了如何使用 Matplotlib 库创建常见的可视化图表,如折线图、柱状图、饼图和直方图。你了解了如何通过matplotlib.pyplot接口构建简单但强大的可视化图表,并通过直接操作可视化的底层Figure和Axes对象,获得更高的控制能力。你还学习了如何将 Matplotlib 与 pandas 配合使用,来可视化 DataFrame 数据,并且练习了使用 Matplotlib 和 Cartopy 地理空间数据处理库创建地图。
第九章:分析位置数据

每一件事都有发生的地点。这就是为什么对象的位置在数据分析中可能与其非空间属性一样重要的原因。实际上,空间数据和非空间数据往往是密切相关的。
例如,考虑一个共享出行应用。你在订车后,可能想要实时追踪汽车在前往你的途中在地图上的位置。你可能还想了解与订单相关的汽车和司机的一些基本非空间信息:如汽车的品牌和型号,司机的评分等。
在上一章中,你了解了如何使用位置数据生成地图。在这一章中,你将学习如何使用 Python 收集和分析位置数据,并且你将看到如何将空间数据和非空间数据整合到分析中。贯穿整个过程,我们将考虑一个出租车管理服务的例子,并试图回答哪个出租车应该被指派给特定任务的核心问题。
获取位置数据
执行空间分析的第一步是获取感兴趣对象的位置数据。具体来说,这些位置数据应该是地理坐标(简称geo 坐标),即纬度和经度值。这种坐标系统使得地球上的每个位置都能用一组数字表示,从而使得这些位置可以通过程序化方式进行分析。在本节中,我们将考虑获取静态和动态物体的 geo 坐标的方法。这将演示我们的出租车服务如何确定顾客的接送位置以及其各种出租车的实时位置。
将人类可读的地址转换为地理坐标
大多数人习惯于通过街道名称和楼号来思考,而不是地理坐标。这就是为什么出租车服务、外卖应用等通常允许用户指定接送地点为街道地址。然而,在后台,许多这些服务会将人类可读的地址转换为相应的 geo 坐标。这样,应用就可以利用位置数据进行计算,例如确定离指定接送地点最近的可用出租车。
如何将街道地址转换为地理坐标?一种方法是使用地理编码(Geocoding),这是 Google 提供的一个 API。要从 Python 脚本与 Geocoding API 进行交互,你需要使用 googlemaps 库。通过以下pip命令安装它:
$ **pip install -U googlemaps**
你还需要通过 Google Cloud 账户获取 Geocoding API 的 API 密钥。有关获取 API 密钥的信息,请参见 developers.google.com/maps/documentation/geocoding/get-api-key。API 费用结构的详细信息请见 cloud.google.com/maps-platform/pricing。截至本文撰写时,Google 为 API 用户提供每月 200 美元的信用额度,足够你在本书的代码中进行实验。
以下脚本展示了如何使用 googlemaps 调用 Geocoding API 的示例。此调用获取与地址 1600 Amphitheatre Parkway, Mountain View, CA 对应的经纬度坐标:
import googlemaps
gmaps = googlemaps.Client(key='`YOUR_API_KEY_HERE`')
address = '1600 Amphitheatre Parkway, Mountain View, CA'
geocode_result = gmaps.geocode(address)
print(geocode_result[0]['geometry']['location'].values())
在此脚本中,你与 API 建立连接并发送你想要转换的地址。API 返回一个具有嵌套结构的 JSON 文档。地理坐标存储在 location 键下,该键是 geometry 的子字段。在最后一行,你访问并打印坐标,输出如下:
dict_values([37.422388, -122.0841883])
获取移动物体的地理坐标
你现在知道如何通过街道地址获取一个固定位置的地理坐标,但如何获取一个移动物体的实时地理坐标,比如出租车呢?一些出租车服务可能使用专门的 GPS 设备来实现这一目的,但我们将重点讨论一个低成本、易于实现的解决方案。所需的仅仅是一部智能手机。
智能手机通过内置的 GPS 传感器来检测其位置,并可以调整设置以共享该信息。在这里,我们将探讨如何通过流行的消息应用 Telegram 收集智能手机的 GPS 坐标。使用 Telegram Bot API,你将创建一个机器人,即在 Telegram 中运行的应用程序。机器人通常用于自然语言处理,但这个将用于收集并记录选择与机器人共享数据的 Telegram 用户的地理位置数据。
设置 Telegram 机器人
要创建一个 Telegram 机器人,你需要下载 Telegram 应用并创建一个帐户。然后使用智能手机或 PC 按照以下步骤操作:
-
在 Telegram 应用中,搜索 @BotFather。BotFather 是一个 Telegram 机器人,负责管理你账户中的所有其他机器人。
-
在 BotFather 页面,点击 开始,查看你可以用来设置 Telegram 机器人的命令列表。
-
在消息框中输入
/newbot。系统会提示你为你的机器人设置名称和用户名。然后,你会获得新机器人的授权令牌。记下这个令牌;在编写程序时你会用到它。
完成这些步骤后,你可以使用 python-telegram-bot 库用 Python 实现这个机器人。安装库的方法如下:
$ **pip install python-telegram-bot –upgrade**
编写机器人程序所需的工具位于库的 telegram.ext 模块中。它是基于 Telegram Bot API 构建的。
编写机器人程序
在这里,你使用 python-telegram-bot 库的telegram.ext模块来编程,使机器人监听并记录 GPS 坐标:
from telegram.ext import Updater, MessageHandler, Filters
from datetime import datetime
import csv
❶ def get_location(update, context):
msg = None
if update.edited_message:
msg = update.edited_message
else:
msg = update.message
❷ gps = msg.location
sender = msg.from_user.username
tm = datetime.now().strftime("%H:%M:%S")
with open(r'`/HOME/PI/LOCATION_BOT/LOG.CSV`', 'a') as f:
writer = csv.writer(f)
❸ writer.writerow([sender, gps.latitude, gps.longitude, tm])
❹ context.bot.send_message(chat_id=msg.chat_id, text=str(gps))
def main():
❺ updater = Updater('`TOKEN`', use_context=True)
❻ updater.dispatcher.add_handler(MessageHandler(Filters.location,
get_location))
❼ updater.start_polling()
❽ updater.idle()
if __name__ == '__main__':
main()
main()函数包含实现 Telegram 机器人脚本中常见的调用。你首先创建一个Updater对象❺,并传入你的机器人授权令牌(由 BotFather 生成)。这个对象在脚本中负责协调机器人的执行过程。接着,你使用与Updater关联的Dispatcher对象,添加一个名为get_location()的处理函数,用于处理传入的消息❻。通过指定Filters.location,你为该处理函数添加了一个过滤器,使其只在机器人接收到包含发送者位置数据的消息时被调用。你通过调用Updater对象的start_polling()方法❼来启动机器人。由于start_polling()是一个非阻塞方法,你还需要调用Updater对象的idle()方法❽,以使脚本在收到消息之前保持阻塞状态。
在脚本的开头,你定义了get_location()处理函数❶。在处理函数中,你将传入的消息存储为msg,然后使用消息的location属性❷提取发送者的位置数据。你还会记录发送者的用户名并生成包含当前时间的字符串。然后,使用 Python 的csv模块,你将所有这些信息作为一行存储在你选择的位置的 CSV 文件中❸。你还将位置数据传回给发送者,让他们知道他们的位置已经被接收❹。
从机器人获取数据
在一台连接互联网的机器上运行脚本。一旦脚本运行,用户可以按照几个简单的步骤开始与机器人共享实时位置数据:
-
创建一个 Telegram 账户。
-
在 Telegram 中,点击机器人的名称。
-
点击回形针图标,从菜单中选择位置。
-
选择分享我的位置为,并设置 Telegram 将与机器人共享实时位置数据的时长。选项包括 15 分钟、1 小时或 8 小时。
图 9-1 中的截图展示了在 Telegram 中分享实时位置是多么简单。

图 9-1:在 Telegram 中分享你智能手机的实时位置
一旦用户开始共享他们的位置数据,机器人将开始以如下所示的行形式将数据发送到 CSV 文件中:
cab_26,43.602508,39.715685,14:47:44
cab_112,43.582243,39.752077,14:47:55
cab_26,43.607480,39.721521,14:49:11
cab_112,43.579258,39.758944,14:49:51
cab_112,43.574906,39.766325,14:51:53
cab_26,43.612203,39.720491,14:52:48
每行的第一个字段包含用户名,第二和第三个字段包含用户位置的纬度和经度,第四个字段包含时间戳。对于某些任务,如寻找离特定接送地点最近的车辆,你只需要每辆车的最新一行数据。然而,对于其他任务,如计算一段行程的总距离,多个时间排序的数据行会更有帮助。
使用 geopy 和 Shapely 进行空间数据分析
空间数据分析归结为回答关于关系的问题:哪个物体离某个位置最近?两个物体是否位于同一区域?在这一部分中,你将使用两个 Python 库——geopy 和 Shapely,结合我们出租车服务的实例来回答这些常见的空间分析问题。
由于 geopy 设计用于基于地理坐标进行计算,因此它特别适合用来回答关于距离的问题。而 Shapely 专注于定义和分析几何平面,因此它非常适合用来判断一个物体是否位于某个特定区域内。正如你将看到的,这两个库可以在识别最佳出租车时发挥作用。
在继续之前,请按照以下方式安装所需的库:
**$ pip install geopy**
**$ pip install shapely**
寻找最接近的物体
继续以我们出租车服务的例子,我们将学习如何利用位置数据来识别离接客地点最近的出租车。首先,你需要一些示例位置数据。如果你已经部署了前面部分讨论的 Telegram 机器人,可能已经有了以 CSV 文件形式存在的数据。在这里,你将数据加载到 pandas DataFrame 中,以便轻松地进行排序和过滤:
import pandas as pd
df = pd.read_csv("`HOME/PI/LOCATION_BOT/LOG.CSV`", names=['cab', 'lat',
'long', 'tm'])
如果你没有部署一个 Telegram 机器人,可以创建一个包含一些示例位置数据的元组列表,并将其加载到 DataFrame 中,如下所示:
import pandas as pd
locations = [
('cab_26',43.602508,39.715685,'14:47:44'),
('cab_112',43.582243,39.752077,'14:47:55'),
('cab_26',43.607480,39.721521,'14:49:11'),
('cab_112',43.579258,39.758944,'14:49:51'),
('cab_112',43.574906,39.766325,'14:51:53'),
('cab_26',43.612203,39.720491,'14:52:48')
]
df = pd.DataFrame(locations, columns =['cab', 'lat', 'long', 'tm'])
无论哪种方式,你都会得到一个名为 df 的 DataFrame,其中包含出租车 ID、纬度、经度和时间戳等列。
该 DataFrame 为每辆出租车包含多行数据,但为了识别离接客地点最近的出租车,你只需要每辆出租车的最新位置。你可以通过如下方式过滤掉不必要的行:
latestrows = df.sort_values(['cab','tm'],ascending=False).drop_duplicates('cab')
在这里,你按 cab 和 tm 字段对行进行降序排序。此操作通过 cab 列对数据集进行分组,并将每个出租车组中最新的行排在前面。然后,你应用 drop_duplicates() 方法删除每个出租车的除第一行以外的所有行。结果 latestrows DataFrame 如下所示:
cab lat long tm
5 cab_26 43.612203 39.720491 14:52:48
3 cab_112 43.574906 39.766325 14:51:53
现在,你拥有了一个仅包含每辆出租车最新位置数据的 DataFrame。为了方便后续计算,你将 DataFrame 转换为一个更简单的 Python 结构——列表的列表。这样,你就可以更容易地将新字段添加到每一行中,例如计算出租车与接客地点之间距离的字段:
latestrows = latestrows.values.tolist()
latestrows 的 values 属性返回一个 NumPy 表示形式的 DataFrame,你可以使用 tolist() 将其转换为一个列表的列表。
现在,你准备好计算每辆出租车与接客地点之间的距离了。你将使用 geopy 库,它可以通过几行代码完成此任务。在这里,你使用 geopy 的 distance 模块中的 distance() 函数来进行必要的计算:
from geopy.distance import distance
pick_up = 43.578854, 39.754995
for i,row in enumerate(latestrows):
❶ dist = distance(pick_up, (row[1],row[2])).m
print(row[0] + ':', round(dist))
latestrows[i].append(round(dist))
为了简化,你通过手动定义纬度和经度坐标来设置接送地点。然而,在实际操作中,你可能会使用 Google 的地理编码 API,从街道地址自动生成坐标,正如本章前面讨论的那样。接下来,你遍历数据集中的每一行,并通过调用distance() ❶计算每辆出租车与接送地点之间的距离。这个函数接受两个包含纬度/经度坐标的元组作为参数。通过添加.m,你可以获取以米为单位的距离。为了演示,你会打印每次距离计算的结果,然后将其附加到行尾作为新字段。脚本输出如下:
cab_112: 1015
cab_26: 4636
显然,cab_112更近,但你如何通过编程来确定这一点呢?使用 Python 内置的min()函数,如下所示:
closest = min(latestrows, key=lambda x: x[4])
print('The closest cab is: ', closest[0], ' - the distance in meters: ', closest[4])
你将数据传递给min()并使用一个 lambda 函数根据每行第4列的项目来评估排序顺序。这是新添加的距离计算方法。然后,你将结果以人类可读的格式打印出来,得到如下结果:
The closest cab is: cab_112 - the distance in meters: 1015
在这个例子中,你计算了每辆出租车与接送地点之间的直线距离。虽然这些信息肯定是有用的,但现实中的汽车几乎从不沿着一条完全笔直的路线从一个地方开到另一个地方。街道布局意味着,出租车到达接送地点的实际距离通常会大于直线距离。考虑到这一点,接下来我们将看看一种更可靠的方法来匹配接送地点和出租车。
在特定区域中寻找物体
通常,确定适合工作任务的最佳出租车时,正确的问题不是“哪辆出租车离得最近?”,而是“哪辆出租车在一个包含接送地点的特定区域内?”这不仅仅是因为两个点之间的驾驶距离几乎总是大于它们之间的直线距离。在实际操作中,河流或铁路等障碍物常常将地理区域划分为独立的区域,这些区域之间通过桥梁、隧道等有限的连接点相连。这使得直线距离往往具有误导性。考虑图 9-2 中的例子。

图 9-2:像河流这样的障碍物可能会导致距离测量产生误导。
如你所见,在这种情况下,cab_26在空间上离接送地点最近,但由于河流的存在,cab_112可能能够更快地到达那里。你可以通过查看地图轻松得出这个结论,但如何通过 Python 脚本得出相同的结论呢?一种方法是将区域划分为多个较小的多边形,即由一组连接的直线围成的区域,然后检查哪些出租车位于与接送地点相同的多边形内。
在这个具体的例子中,你应该定义一个包含接送地点并沿河流有边界的多边形。你可以通过 Google Maps 手动识别多边形的边界:右键点击几个连接起来形成封闭多边形的点,并记录每个点的地理坐标。一旦获取坐标,你就可以在 Python 中使用 Shapely 库定义该多边形。
以下是如何使用 Shapely 创建一个多边形并检查给定点是否位于该多边形内部的方法:
❶ from shapely.geometry import Point, Polygon
coords = [(46.082991, 38.987384), (46.075489, 38.987599), (46.079395,
38.997684), (46.073822, 39.007297), (46.081741, 39.008842)]
❷ poly = Polygon(coords)
❸ cab_26 = Point(46.073852, 38.991890)
cab_112 = Point(46.078228, 39.003949)
pick_up = Point(46.080074, 38.991289)
❹ print('cab_26 within the polygon:', cab_26.within(poly))
print('cab_112 within the polygon:', cab_112.within(poly))
print('pick_up within the polygon:', pick_up.within(poly))
首先,导入两个 Shapely 类,Point 和 Polygon ❶,然后使用一组纬度/经度元组创建一个 Polygon 对象 ❷。这个对象表示位于河流北侧的区域,包括接送地点。接下来,创建多个 Point 对象,分别表示 cab_26、cab_112 和接送地点的位置 ❸。最后,执行一系列空间查询,通过 Shapely 的 within() 方法检测某个点是否在多边形内部 ❹。最终,脚本应产生以下输出:
cab_26 within the polygon: False
cab_112 within the polygon: True
pick_up within the polygon: True
结合两种方法
到目前为止,我们通过计算线性距离并在特定区域内找到最接近的出租车来选择最佳的接送出租车。事实上,找到合适的出租车的最准确方法可能是结合这两种方法的元素。这是因为盲目排除所有不在接送位置同一多边形中的出租车并不一定安全。即使考虑到出租车可能需要绕过河流或其他障碍物,位于相邻多边形中的出租车也可能是实际驾车距离最短的。关键是考虑一个多边形和另一个多边形之间的入口点。图 9-3 展示了我们如何考虑这一点。

图 9-3:使用入口点连接相邻区域
图中间的虚线表示将区域分为两个多边形的边界:一个位于河流北侧,另一个位于河流南侧。放置在桥梁上的等号标记了出租车从一个多边形移动到另一个多边形的入口点。对于位于接送地点边界相邻多边形中的出租车,前往接送地点的距离由两个区间组成:出租车当前位置到入口点的区间,以及入口点到接送地点的区间。
为了找到最靠近的出租车,您需要确定每辆出租车所在的多边形,并基于此决定如何计算出租车与接送地点之间的距离:如果出租车在与接送地点相同的多边形内,则计算直接的直线距离;如果出租车在相邻的多边形中,则通过入口点计算距离。这里我们仅对cab_26进行这种计算:
from shapely.geometry import Point, Polygon
from geopy.distance import distance
coords = [(46.082991, 38.987384), (46.075489, 38.987599), (46.079395,
38.997684), (46.073822, 39.007297), (46.081741, 39.008842)]
❶ poly = Polygon(coords)
❷ cab_26 = Point(46.073852, 38.991890)
pick_up = Point(46.080074, 38.991289)
entry_point = Point(46.075357, 39.000298)
if cab_26.within(poly):
❸ dist = distance((pick_up.x, pick_up.y), (cab_26.x,cab_26.y)).m
else:
❹ dist = distance((cab_26.x,cab_26.y), (entry_point.x,entry_point.y)).m +
distance((entry_point.x,entry_point.y), (pick_up.x, pick_up.y)).m
print(round(dist))
脚本同时使用了 Shapely 和 geopy。首先,您定义一个包含接送地点的 Shapely Polygon 对象,如之前所示 ❶。接着,您定义Point对象,分别表示出租车、接送地点和入口点 ❷。然后,借助 geopy 的distance()函数计算距离(单位为米)。如果出租车在多边形内,您可以直接计算出租车和接送地点之间的距离 ❸。如果不在,您首先计算出租车与入口点之间的距离,再计算入口点与接送地点之间的距离,将两者相加得到总距离 ❹。以下是结果:
1544
结合空间数据与非空间数据
迄今为止,在本章中,您主要处理了空间数据,但需要意识到,空间分析往往也需要考虑非空间数据。例如,如果您不知道某个物品在店内是否有货,那么知道商店位于离您当前位置 10 英里以内又有什么用?又比如,回到出租车的例子,如果您知道如何找出离接送地点最近的出租车,但不知道该出租车是否有空,或者是否正在服务其他订单,又有何意义呢?在本节中,我们将探讨如何将非空间数据纳入空间分析中。
推导非空间属性
关于当前出租车可用性的信息可以通过包含乘车订单的数据集来获取。一旦订单被分配给某辆出租车,该信息可能会被放入一个orders数据结构中,其中订单按状态列出,状态可能是“开放”(进行中)或“关闭”(已完成)。根据这种方案,仅识别出那些“开放”的订单可以告诉您哪些出租车无法接受新的订单。以下是如何在 Python 中实现这一逻辑:
import pandas as pd
orders = [
('order_039', 'open', 'cab_14'),
('order_034', 'open', 'cab_79'),
('order_032', 'open', 'cab_104'),
('order_026', 'closed', 'cab_79'),
('order_021', 'open', 'cab_45'),
('order_018', 'closed', 'cab_26'),
('order_008', 'closed', 'cab_112')
]
df_orders = pd.DataFrame(orders, columns =['order','status','cab'])
df_orders_open = df_orders[df_orders['status']=='open']
unavailable_list = df_orders_open['cab'].values.tolist()
print(unavailable_list)
本例中使用的orders元组列表可能来自一个更完整的数据集,例如过去两小时内所有已开订单的集合,数据集包含每个订单的附加信息(如接送地点、送达地点、开始时间、结束时间等)。为了简化起见,这里数据集已被简化为仅包含当前任务所需的字段。您将列表转换为 DataFrame,然后筛选出状态为open的订单。最后,您将 DataFrame 转换为仅包含cab列值的列表。这个包含不可用出租车的列表如下所示:
['cab_14', 'cab_79', 'cab_104', 'cab_45']
拥有了这个列表之后,你需要检查其他出租车,并确定哪个出租车最接近接送地点。将以下代码附加到之前的脚本:
from geopy.distance import distance
pick_up = 46.083822, 38.967845
cab_26 = 46.073852, 38.991890
cab_112 = 46.078228, 39.003949
cab_104 = 46.071226, 39.004947
cab_14 = 46.004859, 38.095825
cab_79 = 46.088621, 39.033929
cab_45 = 46.141225, 39.124934
cabs = {'cab_26': cab_26, 'cab_112': cab_112, 'cab_14': cab_14,
'cab_104': cab_104, 'cab_79': cab_79, 'cab_45': cab_45}
dist_list = []
for cab_name, cab_loc in cabs.items():
if cab_name not in unavailable_list:
dist = distance(pick_up, cab_loc).m
dist_list.append((cab_name, round(dist)))
print(dist_list)
print(min(dist_list, key=lambda x: x[1]))
为了示例的目的,你手动定义了接送地点和所有出租车的地理坐标作为元组,并将出租车的坐标发送到一个字典中,字典的键是出租车名称。然后,你遍历字典,对于每辆不在unavailable_list中的出租车,使用 geopy 计算出租车与接送地点之间的距离。最后,你打印出所有可用出租车的完整列表及其到接送地点的距离,以及仅打印出最近的出租车,输出如下:
[('cab_26', 2165), ('cab_112', 2861)]
('cab_26', 2165)
在这种情况下,cab_26是最近的可用出租车。
空间数据和非空间数据的结合
在之前的示例中,你将空间数据(每辆出租车的位置)和非空间数据(哪些出租车可用)保存在不同的数据结构中。然而,有时将空间数据和非空间数据结合在同一结构中可能会更有利。
考虑到出租车可能需要满足除可用性外的其他条件才能被分配到订单。例如,客户可能需要带有婴儿座椅的出租车。为了找到合适的出租车,你需要依赖一个数据集,该数据集包含出租车的非空间信息,以及每辆出租车与接送地点的距离。对于前者,你可以使用一个仅包含两列的数据集:出租车名称和是否有婴儿座椅。你可以在这里创建它:
cabs_list = [
('cab_14',1),
('cab_79',0),
('cab_104',0),
('cab_45',1),
('cab_26',0),
('cab_112',1)
]
第二列中带有1的出租车包含婴儿座椅。接下来,你将列表转换为 DataFrame。你还将创建一个第二个 DataFrame,来自dist_list,即你在前一部分中生成的可用出租车及其到达接送地点的距离列表:
py` df_cabs = pd.DataFrame(cabs_list, columns =['cab', 'seat']) df_dist = pd.DataFrame(dist_list, columns =['cab', 'dist']) py You now merge these DataFrames based on the `cab` column: df = pd.merge(df_cabs, df_dist, on='cab', how='inner') py You use an inner join, meaning only cabs included in both `df_cabs` and `df_dist` make it into the new DataFrame. In practice, since `df_dist` contains only cabs that are currently available, this excludes unavailable cabs from the result set. The merged DataFrame now includes both spatial data (each cab’s distance to the pick-up place) and nonspatial data (whether or not each cab has a baby seat): cab seat dist 0 cab_26 0 2165 1 cab_112 1 2861 py You convert the DataFrame into a list of tuples, which you then filter, leaving only the rows where the `seat` field is set to `1`: result_list = list(df.itertuples(index=False,name=None)) result_list = [x for x in result_list if x[1] == 1] py You use the DataFrame’s `itertuples()` method to convert each row into a tuple, then you wrap the tuples into a list with the `list()` function. The final step is to determine the row with the lowest value in the distance field, which is identified by index `2`: print(min(result_list, key=lambda x: x[2])) py Here’s the result: ('cab_112', 1, 2861) py Compare this to the result shown at the end of the previous section. As you can see, the need for a baby seat led us to choose a different cab for the job. ## Summary Using the real-world example of a taxi service, this chapter illustrated how you can perform spatial data analyses. To start with, you looked at an example of turning a human-readable address into geo coordinates using Google’s Geocoding API and the googlemaps Python library. Then you learned to use a Telegram bot to collect location data from smartphones. Next, you used the geopy and Shapely libraries to perform fundamental geospatial operations, such as measuring the distance between points and determining if points are within a certain area. With the help of these libraries, built-in Python data structures, and pandas DataFrames, you designed an application to identify the best cab for a given pick-up based on various spatial and nonspatial criteria.
第十章:分析时间序列数据

时间序列数据,或称为时间戳数据,是一组按时间顺序排列的数据点。常见的例子包括经济指数、天气记录和患者健康指标,所有这些都是随着时间的推移而收集的。本章介绍了使用 pandas 库分析时间序列数据并从中提取有意义统计信息的技术。我们将重点分析股市数据,但这些技术也可以应用于各种时间序列数据。
规律与不规律时间序列
时间序列可以为任何随时间变化的变量创建,这些变化可以在规律或不规律的时间间隔内记录。规律时间间隔更为常见。例如在金融领域,通常使用时间序列来跟踪股票价格从一天到下一天的变化,正如这里所示:
Date Closing Price
----------- -------------
16-FEB-2022 10.26
17-FEB-2022 10.34
18-FEB-2022 10.99
如你所见,这个时间序列中的Date列包含了按时间顺序排列的一系列时间戳,代表连续几天的事件。相应的数据点,通常被称为观测值,展示在Closing Price列中。这类时间序列被称为规律或连续的,因为观测值是连续地在规律的时间间隔内收集的。
另一个规律时间序列的例子是记录一个车辆的经纬度坐标,每分钟记录一次,如下所示:
Time Coordinates
------- ----------------
20:43:00 37.801618, -122.374308
20:44:00 37.796599, -122.379432
20:45:00 37.788443, -122.388526
在这里,时间戳是时间而不是日期,但它们依然按时间顺序进行,逐分钟记录。
与规律时间序列不同,不规则时间序列用于记录事件发生或计划发生的顺序,而不是在规律的时间间隔内。以一个简单的例子为例,考虑一个会议的议程:
Time Event
------- ----------------
8:00 AM Registration
9:00 AM Morning Sessions
12:10 PM Lunch
12:30 PM Afternoon Sessions
这个数据点序列的时间戳是不规则分布的,基于每个事件预计需要的时间。
不规则时间序列通常用于数据不可预测的应用中。对于软件开发人员来说,一个典型的不规则时间序列是记录在运行服务器或执行应用程序时遇到的错误日志。很难预测这些错误何时发生,而且它们几乎肯定不会按规律的时间间隔发生。再举一个例子,一个跟踪电力消耗的应用程序可能会使用不规则时间序列来记录随机发生的异常,如突发和故障。
规律和不规律时间序列的共同点在于它们的数据点都是按时间顺序排列的。实际上,时间序列分析正是基于这一关键特性。严格的时间顺序使你能够始终如一地比较时间序列中的事件或数值,从而识别关键统计信息和趋势。
以股票数据为例,时间顺序让你能够跟踪股票的表现。对于逐分钟的车辆地理坐标数据,你可能会使用相邻的坐标对来计算每分钟行驶的距离,然后用这个距离来比较车辆在相邻两分钟之间的平均速度。同时,会议议程的时间顺序让你能够立即看到每个事件的预计持续时间。
在某些情况下,时间戳本身可能并不需要用于分析时间序列;重要的是确保序列中的记录按时间顺序排列。考虑以下不规则时间序列,它包含你在尝试使用错误密码连接到 MySQL 数据库时,脚本可能返回的两个连续错误信息:
_mysql_connector.MySQLInterfaceError: Access denied for user
'root'@'localhost' (using password: YES)
NameError: name 'cursor' is not defined
第二个错误信息告诉你名为cursor的变量尚未定义。然而,只有查看前面的错误信息,你才能理解问题的根源:由于密码错误,无法与数据库建立连接,因此无法创建cursor对象。
分析一系列错误信息是程序员常见的任务,但通常是手动完成的,且没有任何编码。在本章的其余部分,我们将重点讨论带有数值数据点的时间序列,因为这些数据可以通过 Python 脚本轻松分析。特别是,我们将讨论如何从包含股市数据的常规时间序列中提取有意义的信息。
常见的时间序列分析技巧
假设你想分析某只股票在一段时间内的每日收盘价时间序列。在这一节中,你将学习一些常见的分析技巧,但首先你需要一些股票数据。
如你在第三章和第五章中所见,你可以通过 Python 脚本使用 yfinance 库获取股市数据。例如,在这里你收集了过去五个交易日的 TSLA(特斯拉公司)股票数据:
import yfinance as yf
ticker = 'TSLA'
tkr = yf.Ticker(ticker)
df = tkr.history(period='5d')
结果以 pandas DataFrame 形式呈现,类似于以下内容(你的日期和返回的数据会有所不同):
Open High Low Close Volume Dividends Stock Splits
Date
2022-01-10 1000.00 1059.09 980.00 1058.11 30605000 0 0
2022-01-11 1053.67 1075.84 1038.81 1064.40 22021100 0 0
2022-01-12 1078.84 1114.83 1072.58 1106.21 27913000 0 0
2022-01-13 1109.06 1115.59 1026.54 1031.56 32403300 0 0
2022-01-14 1019.88 1052.00 1013.38 1049.60 24246600 0 0
如你所见,DataFrame 以日期为索引,这意味着数据是一个正确的、按时间顺序排列的时间序列。这里有开盘价、收盘价以及当天的最高价和最低价列。同时,Volume 列显示当天交易的总股数,最右边的两列提供了公司向股东发放的股息和分拆的详细信息。
你可能不需要所有这些列来进行分析。事实上,现在你只需要Close这一列。在这里,你将其打印为 pandas Series:
print(df['Close'])
该 Series 将类似于以下内容:
Date
2022-01-10 1058.11
2022-01-11 1064.40
2022-01-12 1106.21
2022-01-13 1031.56
2022-01-14 1049.60
现在,你准备开始时间序列分析了。我们将重点关注两种常见技术:计算随时间变化的百分比变化,以及在滚动时间窗口内进行汇总计算。你将看到这些技术如何相互配合,揭示数据中的趋势。
计算百分比变化
最典型的时间序列分析技术之一是跟踪观察数据随时间的变化。在股票市场数据的情况下,这可能涉及计算某个时间间隔内股票价值的百分比变化。通过这种方式,你可以量化股票的表现,并制定短期投资策略。
从技术角度讲,百分比变化是两个不同时间点的值之间的差异(以百分比表示)。因此,要计算这样的变化,你需要能够将数据点在时间上进行偏移。也就是说,你将较旧的数据点向前移动,使其与较新的数据点对齐;然后你可以比较这些数据点并计算百分比变化。
当时间序列作为 pandas Series 或 DataFrame 实现时,你可以使用shift()方法将数据点按所需的时间周期进行偏移。以我们的 TSLA 股票为例,你可能想知道股票的收盘价在两天期间内发生了多少变化。在这种情况下,你可以使用shift(2)方法将两天前的收盘价与某一天的收盘价对齐。为了更好地理解偏移的工作原理,你可以将Close列向前偏移两天,将结果保存为2DaysShift,然后将其与原始的Close列连接起来:
print(pd.concat([df['Close'], df['Close'].shift(2)], axis=1, keys= ['Close', '2DaysShift']))
输出结果应该类似于以下内容:
Close 2DaysShift
Date
2022-01-10 1058.11 NaN
2022-01-11 1064.40 NaN
2022-01-12 1106.21 1058.11
2022-01-13 1031.56 1064.40
2022-01-14 1049.60 1106.21
正如你所见,Close列中的值在2DaysShift列中有对应的反映,但偏移了两天。2DaysShift中的前两个值为NaN,因为在时间序列的前两天没有两天前的股价数据。
要计算某一天的股价与两天前的股价之间的百分比变化,你可以计算当天的股价与两天前股价的差值,并将其除以两天前的股价:
(df['Close'] - df['Close'].shift(2))/ df['Close'].shift(2)
然而,在财务分析中,通常的做法是将新值除以旧值,然后对结果取自然对数。当变化在+/- 5%范围内时,这种计算方法几乎能精确地近似百分比变化,且在+/- 20%的范围内仍然非常接近。这里,你使用自然对数计算两天的百分比差异,并将结果存储为2daysRise这一新的列,添加到df数据框中:
import numpy as np
df['2daysRise'] = np.log(df['Close'] / df['Close'].shift(2))
你获取当天的收盘价,并将其除以两天前的收盘价,使用shift(2)方法访问。然后,你可以使用 NumPy 的log()函数对结果取自然对数。现在,你可以打印Close列和2daysRise列的内容:
print(df[['Close','2daysRise']])
输出的时间序列将类似于以下内容:
Close 2daysRise
Date
2022-01-10 1058.11 NaN
2022-01-11 1064.40 NaN
2022-01-12 1106.21 0.044455
2022-01-13 1031.56 -0.031339
2022-01-14 1049.60 -0.052530
2daysRise 列显示与两天前相比的股票涨幅百分比。再次强调,列中的前两个值是 NaN,因为在时间序列的前两天没有两天前的价格数据。
滚动窗口计算
另一种常见的时间序列分析技术是将每个值与过去 n 个周期的平均值进行比较。这被称为 滚动窗口计算:你创建一个固定大小的时间窗口,并在时间窗口内的值上执行聚合计算,随着时间窗口在时间序列中“滚动”或滑动。以股票数据为例,你可以使用滚动窗口计算来找出前两天的平均收盘价,然后将当前日期的收盘价与该平均值进行比较。这将帮助你了解股票价格随时间的稳定性。
每个 pandas 对象都有一个 rolling() 方法,用于查看值的滚动窗口。在这里,你将其与 shift() 和 mean() 结合使用,以查找前两天的特斯拉股票价格平均值:
df['2daysAvg'] = df['Close'].shift(1).rolling(2).mean()
print(df[['Close', '2daysAvg']])
在第一行中,你使用 shift(1) 将数据点在时间序列中平移一天。这样做是因为你希望在计算平均值时排除当前日期的价格,这样就可以与它进行比较。接下来,使用 rolling(2) 来形成滚动窗口,表示在进行计算时希望参考连续的两行数据。最后,调用 mean() 方法来计算每对连续行的平均值。你将结果存储在一个名为 2daysAvg 的新列中,并与 Close 列一起打印出来。结果的 DataFrame 看起来类似于下面的样子:
Close 2daysAvg
Date
2022-01-10 1058.11 NaN
2022-01-11 1064.40 NaN
2022-01-12 1106.21 1061.26
2022-01-13 1031.56 1085.30
2022-01-14 1049.60 1068.89
2daysAvg 列中的价格是前两天的平均值。例如,2022-01-12 的值是 2022-01-10 和 2022-01-11 价格的平均值。
计算滚动平均的百分比变化
给定前两天的收盘价滚动平均,下一步逻辑是计算每一天的价格与其相关联的滚动平均之间的百分比变化。在这里,你执行了该计算,再次使用自然对数来近似百分比变化:
df['2daysAvgRise'] = np.log(df['Close'] / df['2daysAvg'])
print(df[['Close','2daysRise','2daysAvgRise']])
你将结果存储在一个名为 2daysAvgRise 的新列中。然后你将 Close、2daysRise 和 2daysAvgRise 列一起打印出来。输出将类似于下面的样子:
Close 2daysRise 2daysAvgRise
Date
2022-01-10 1058.11 NaN NaN
2022-01-11 1064.40 NaN NaN
2022-01-12 1106.21 0.044455 0.041492
2022-01-13 1031.56 -0.031339 -0.050793
2022-01-14 1049.60 -0.052530 -0.018202
对于这个特定的时间序列,两个新创建的指标,2daysRise 和 2daysAvgRise,都显示了负值和正值。这表明股票的收盘价在观察期内波动较大。当然,你自己的结果可能会显示不同的趋势。
多变量时间序列
多变量时间序列是指在时间轴上有多个变化变量的时间序列。例如,当你第一次通过 yfinance 库获取特斯拉的股票数据时,它就是一个多变量时间序列,因为它不仅包括股票的收盘价,还包括开盘价、最高价、最低价以及每天的其他数据点。在这种情况下,多变量时间序列追踪的是同一对象——一只股票的多个特征。其他的多变量时间序列可能追踪的是不同对象的相同特征,比如在相同时间段内收集的多只股票的收盘价。
在下面的脚本中,你创建了第二种类型的多变量时间序列,获取了多个股票代码的五天股价数据:
import pandas as pd
import yfinance as yf
❶ stocks = pd.DataFrame()
❷ tickers = ['MSFT','TSLA','GM','AAPL','ORCL','AMZN']
❸ for ticker in tickers:
tkr = yf.Ticker(ticker)
hist = tkr.history(period='5d')
❹ hist = pd.DataFrame(hist[['Close']].rename(columns={'Close': ticker}))
❺ if stocks.empty:
❻ stocks = hist
else:
❼ stocks = stocks.join(hist)
你首先定义stocks数据框❶,在其中累积多个股票代码的收盘价。然后你定义一个股票代码列表❷,并遍历该列表❸,使用 yfinance 库获取每个股票代码的最后五天数据。在循环内,你将 yfinance 返回的hist数据框缩减为一个单列数据框,其中包含给定股票的收盘价,并将相应的时间戳作为索引❹。然后你检查stocks数据框是否为空❺。如果为空,说明这是你第一次进入循环,因此你用hist数据框初始化stocks数据框❻。在后续的循环中,stocks将不为空,因此你将当前的hist数据框与stocks数据框连接,将另一个股票的收盘价添加到数据集中❼。需要使用if/else结构,因为你不能在空的数据框上执行连接操作。
生成的stocks数据框将类似于以下内容:
MSFT TSLA GM AAPL ORCL AMZN
Date
2022-01-10 314.26 1058.11 61.07 172.19 89.27 3229.71
2022-01-11 314.98 1064.40 61.45 175.08 88.48 3307.23
2022-01-12 318.26 1106.21 61.02 175.52 88.30 3304.13
2022-01-13 304.79 1031.56 61.77 172.19 87.79 3224.28
2022-01-14 310.20 1049.60 61.09 173.07 87.69 3242.76
你拥有一个多变量时间序列,不同的列显示了不同股票的收盘价,所有数据都覆盖相同的时间跨度。
处理多变量时间序列
处理多变量时间序列类似于处理单变量时间序列,不同之处在于你需要处理每一行中的多个变量。因此,你的计算通常是在一个循环中进行,遍历序列中的各个列。例如,假设你想过滤stocks数据框,剔除那些股票价格在给定时间段内至少有一天比前一天的价格下降超过某个阈值(比如 3%)的股票。在这里,你遍历各列,分析每个股票的数据,决定哪些股票应该保留在数据框中:
❶ stocks_to_keep = []
❷ for i in stocks.columns:
if stocks[stocks[i]/stocks[i].shift(1)< .97].empty:
stocks_to_keep.append(i)
print(stocks_to_keep)
首先,你创建一个列表来累积你想保留的列名 ❶。然后,你遍历 stocks 数据框的列 ❷,判断每列是否包含任何低于前一行数值 3% 的值。具体而言,你使用 [] 运算符来筛选数据框,并使用 shift() 方法将每一天的收盘价与前一天的收盘价进行比较。如果某列没有包含任何满足筛选条件的值(即如果该列为空),你就将列名添加到 stocks_to_keep 列表中。
根据之前显示的 stocks 数据框,最终的 stocks_to_keep 列表将如下所示:
['GM', 'AAPL', 'ORCL', 'AMZN']
正如你所看到的,TSLA 和 MSFT 不在列表中,因为它们包含一个或多个值,跌幅超过了前一天收盘价的 3%。当然,你自己的结果可能会有所不同;你可能会得到一个空列表,或者包含所有股票代码的列表。在这种情况下,可以尝试调整筛选阈值。如果列表为空,尝试将阈值从 0.97 降到 0.96 或更低。相反,如果列表包含所有股票代码,尝试增加阈值。
这里你打印 stocks 数据框,使其仅包含 stocks_to_keep 列表中的列:
print(stocks[stocks_to_keep])
在我的情况下,输出如下所示:
GM AAPL ORCL AMZN
Date
2022-01-10 61.07 172.19 89.27 3229.71
2022-01-11 61.45 175.08 88.48 3307.23
2022-01-12 61.02 175.52 88.30 3304.13
2022-01-13 61.77 172.19 87.79 3224.28
2022-01-14 61.09 173.07 87.69 3242.76
正如预期的那样,TSLA 和 MSFT 列已经被筛选掉,因为它们包含一个或多个超过 3% 波动阈值的值。
分析变量之间的依赖关系
在分析多变量时间序列时,一个常见的任务是识别数据集中不同变量之间的关系。这些关系可能存在也可能不存在。例如,股票的开盘价和收盘价之间可能有一定程度的依赖关系,因为在一天的交易中,收盘价通常不会与开盘价相差超过几个百分点。另一方面,可能在不同行业的两只股票的收盘价之间并不存在依赖关系。
在本节中,我们将介绍一些验证时间序列变量之间关系的技巧。为了演示,我们将检查股票价格的变化与销售量之间是否存在依赖关系。首先,运行以下脚本以获取一个月的股票数据用于分析:
import yfinance as yf
import numpy as np
ticker = 'TSLA'
tkr = yf.Ticker(ticker)
df = tkr.history(period='1mo')
正如你已经看到的,yfinance 生成了一个多变量时间序列,其形式为具有多列的 DataFrame。为了这个示例,你只需要其中的两列:Close 和 Volume。在这里,你根据需要缩减数据框,并将 Close 列的名称更改为 Price:
df = df[['Close','Volume']].rename(columns={'Close': 'Price'})
为了确定Price和Volume列之间是否存在关系,你应该计算每列从一天到下一天的百分比变化。在这里,你使用shift(1)和 NumPy 的log()函数来计算Price列的每日百分比变化,如之前所述,并将结果存储在新的priceRise列中:
df['priceRise'] = np.log(df['Price'] / df['Price'].shift(1))
你使用相同的方法来创建一个volumeRise列,显示与前一天相比的交易量百分比变化:
df['volumeRise'] = np.log(df['Volume'] / df['Volume'].shift(1))
如前所述,自然对数可以在+/-20%的范围内提供接近百分比变化的近似值。虽然volumeRise列中的某些值可能超出了这一范围,但你仍然可以在这里使用log(),因为在这个示例中不要求非常高的精度;股市分析通常更侧重于预测趋势,而不是找到准确的数值。
如果你现在打印df数据框,它大致会是这样:
Price Volume priceRise volumeRise
Date
2021-12-15 975.98 25056400 NaN NaN
2021-12-16 926.91 27590500 -0.051585 0.096342
2021-12-17 932.57 33479100 0.006077 0.193450
2021-12-20 899.94 18826700 -0.035616 -0.575645
2021-12-21 938.53 23839300 0.041987 0.236059
2021-12-22 1008.86 31211400 0.072271 0.269448
2021-12-23 1067.00 30904400 0.056020 -0.009885
2021-12-27 1093.93 23715300 0.024935 -0.264778
2021-12-28 1088.46 20108000 -0.005013 -0.165003
2021-12-29 1086.18 18718000 -0.002097 -0.071632
2021-12-30 1070.33 15680300 -0.014700 -0.177080
2021-12-31 1056.78 13528700 -0.012750 -0.147592
2022-01-03 1199.78 34643800 0.126912 0.940305
2022-01-04 1149.58 33416100 -0.042733 -0.036081
2022-01-05 1088.11 26706600 -0.054954 -0.224127
2022-01-06 1064.69 30112200 -0.021758 0.120020
2022-01-07 1026.95 27919000 -0.036090 -0.075623
2022-01-10 1058.11 30605000 0.029891 0.091856
2022-01-11 1064.40 22021100 0.005918 -0.329162
2022-01-12 1106.21 27913000 0.038537 0.237091
2022-01-13 1031.56 32403300 -0.069876 0.149168
2022-01-14 1049.60 24246600 0.017346 -0.289984
如果价格和交易量之间存在依赖关系,你会预期价格的高波动(即增加的波动性)会与交易量的高变化相关。为了检查这种情况,你应该为priceRise列设定一个阈值,并只查看那些价格变化超过该阈值的行。例如,查看此输出中特定的priceRise列的值时,你可能会选择 5%的阈值。另一个数据集可能会建议 3%或 7%的阈值。关键是,只有少数记录应该超过阈值,因此一般来说,股票波动性越大,阈值应该越高。
在这里,你只打印出那些priceRise超过阈值的行:
print(df[abs(df['priceRise']) > .05])
你使用abs()函数来获取百分比变化的绝对值,这样,举例来说,0.06和-0.06都能满足此处指定的条件。根据之前显示的示例数据,你最终得到如下结果:
Price Volume priceRise volumeRise
Date
2021-12-16 926.91 27590500 -0.051585 0.096342
2021-12-22 1008.86 31211400 0.072271 0.269448
2021-12-23 1067.00 30904400 0.056020 -0.009885
2022-01-03 1199.78 34643800 0.126912 0.940305
2022-01-05 1088.11 26706600 -0.054954 -0.224127
2022-01-13 1031.56 32403300 -0.069876 0.149168
接下来,你计算整个系列的平均交易量变化:
print(df['volumeRise'].mean().round(4))
对于这个特定的系列,结果如下:
-0.0016
最后,你计算仅针对那些价格变化超出平均水平的行的平均交易量变化。如果结果大于整个系列的平均交易量变化,你就可以知道波动性增加和交易量增加之间存在关系:
print(df[abs(df['priceRise']) > .05]['volumeRise'].mean().round(4))
这是你在此系列中获得的内容:
0.2035
如你所见,在过滤后的系列中计算的平均交易量变化要远高于整个系列的平均交易量变化。这表明价格波动性和销售量波动性之间可能存在正相关关系。
总结
正如你在本章中所学到的,时间序列是一种按时间顺序组织的数据集,其中一个或多个变量随时间变化。以股市数据为例,你了解了一些使用 pandas 分析时间序列数据的技术,以便从中得出有用的统计信息。你学会了如何在时间序列中移动数据点,以便计算随时间的变化。你还学会了执行滚动窗口计算,或在固定时间间隔内进行聚合,该时间间隔会在整个序列中滑动。结合这些技术,你可以对数据中的趋势做出判断。最后,你了解了识别多变量时间序列中不同变量之间依赖关系的方法。
第十一章:从数据中获取洞察

公司每天生成大量的数据,以原始事实、数字和事件的形式存在,但所有这些数据到底告诉了你什么?要从数据中提取知识并获得洞察,您需要将其转换、分析和可视化。换句话说,您需要将原始数据转化为可以用来做决策、回答问题和解决问题的有意义信息。
考虑一个收集大量客户交易数据的超市的情况。超市的分析师可能有兴趣研究这些数据,以洞察客户的购买偏好。特别是,他们可能希望执行市场篮子分析,这是一种分析交易并识别常一起购买的物品的数据挖掘技术。有了这些知识,超市可以做出更加明智的业务决策,例如关于商店内物品的布局或如何将物品捆绑成折扣。
在本章中,我们将详细探讨这个例子,通过 Python 执行市场篮子分析,深入分析如何从交易数据中获取洞察。您将学习如何使用 mlxtend 库和 Apriori 算法识别常一起购买的项目,并了解如何利用这些知识做出明智的业务决策。
尽管识别购买者偏好是本章的重点,但这并不是市场篮子分析的唯一应用。该技术还用于电信、网络使用挖掘、银行和医疗等领域。例如,在网络使用挖掘中,市场篮子分析可以确定网页用户下一步可能去哪里,并生成经常一起访问的页面的关联。
关联规则
市场篮子分析是衡量对象之间基于它们在相同交易中共同出现的关系强度的技术。对象之间的关系表示为关联规则,如下所示:
X -> Y
X 和 Y,分别被称为前项和后项规则,表示从被挖掘的交易数据中分组的不同项目集,或者一个或多个项的组。例如,描述curd和sour cream项之间关系的关联规则将如下所示:
curd -> sour cream
在这种情况下,curd是前项,sour cream是后项。规则断言购买 curd 的人也可能购买 sour cream。
单独看这样一条关联规则,其实并不能告诉你太多。成功的市场篮分析的关键在于利用交易数据根据各种指标评估关联规则的强度。为了演示这一点,我们用一个简单的例子。假设我们有 100 笔顾客交易,其中 25 笔包含奶酪,30 笔包含酸奶油。在这 30 笔包含酸奶油的交易中,有 20 笔同时包含奶酪。表 11-1 汇总了这些数据。
表 11-1:奶酪和酸奶油的交易数据
| 奶酪 | 酸奶油 | 奶酪和酸奶油 | 总计 | |
|---|---|---|---|---|
| 交易数量 | 25 | 30 | 20 | 100 |
根据这些交易数据,我们可以使用支持度、置信度和提升度等指标来评估 奶酪 -> 酸奶油 关联规则的强度。这些指标将帮助我们判断奶酪和酸奶油之间是否确实存在关联。
支持度
支持度是至少包含一个项目的交易占总交易数的比例。例如,可以如下计算样本交易数据中奶酪的支持度:
support(curd) = curd/total = 25/100 = 0.25
在关联规则的上下文中,支持度是同时包含前件和后件的交易占总交易数的比例。因此,奶酪 -> 酸奶油 关联规则的支持度为:
support(curd -> sour cream) = (curd & sour cream)/total = 20/100 = 0.2
支持度指标的范围是 0 到 1,它告诉你一个项目集在交易中出现的百分比。在这种情况下,我们可以看到 20% 的交易同时包含了奶酪和酸奶油。对于任何给定的关联规则,支持度是对称的;也就是说,奶酪 -> 酸奶油 的支持度与 酸奶油 -> 奶酪 的支持度是相同的。
置信度
关联规则的 置信度 是同时购买前件和后件的交易占购买前件的交易数的比例。换句话说,置信度衡量的是包含前件的交易中有多少比例也包含后件。可以如下计算 奶酪 -> 酸奶油 关联规则的置信度:
confidence(curd -> sour cream) = (curd & sour cream)/curd = 20/25 = 0.8
你可以将其理解为,如果一个顾客购买了奶酪,那么他们有 80% 的可能性也购买了酸奶油。
和支持度一样,置信度的范围是从 0 到 1,但与支持度不同的是,置信度并不是对称的。这意味着规则 奶酪 -> 酸奶油 的置信度可能与规则 酸奶油 -> 奶酪 的置信度不同,如下所示:
confidence(sour cream -> curd) = (curd & sour cream)/sour cream = 20/30 = 0.66
在这种情况下,当关联规则的前件和后件位置交换时,你会得到一个较低的置信度值。这告诉你,购买酸奶油的人同时购买奶酪的可能性低于购买奶酪的人同时购买酸奶油的可能性。
提升度
提升评估关联规则的强度,相对于规则中出现的项的随机共现情况。关联规则酸奶 -> 酸奶油的提升值是观察到的酸奶 -> 酸奶油支持度与假设酸奶和酸奶油彼此独立时预期的支持度之比。可以通过以下公式计算:
lift(sour cream -> curd) = support(curd & sour cream)/(support(curd)*support(sour cream))
= 0.2/(0.25*0.3) = 2.66
提升值是对称的——如果你交换前件和后件,提升值保持不变。提升值的可能范围是从 0 到无穷大,提升比率越大,关联性越强。特别地,提升比率大于 1 表明前件和后件之间的关系比它们独立时预期的要强,这意味着这两个项目经常一起被购买。提升比率等于 1 表示前件和后件之间没有相关性。提升比率小于 1 则表示前件和后件之间存在负相关,意味着它们不太可能一起被购买。在这种情况下,你可以将提升比率 2.66 解释为:当顾客购买酸奶时,预期他们购买酸奶油的概率增加了 166%。
Apriori 算法
你已经了解了什么是关联规则,并看到了评估其强度的一些指标,但你如何实际生成市场篮子分析的关联规则呢?一种方法是使用Apriori 算法,这是一种自动化的交易数据分析过程。一般来说,该算法由两个步骤组成:
-
在数据集中识别所有的频繁项集,即出现在许多交易中的一个或多个项目的组合。该算法通过找到所有支持度值超过某个阈值的项目或项目组合来实现这一点。
-
通过考虑每个频繁项集的所有可能的二元分割(即将项集分为前件组和后件组的所有划分),并为每个划分计算一组关联指标,来生成这些频繁项集的关联规则。
一旦生成了关联规则,你可以根据前一节中讨论的指标来评估它们。
有几个第三方 Python 库提供了 Apriori 算法的实现。其中一个是 mlxtend 库。mlxtend 是机器学习扩展的缩写,包含了执行许多常见数据科学任务的工具。在本节中,我们将通过一个使用 mlxtend 的 Apriori 算法实现的市场篮子分析示例来演示。但首先,使用pip安装 mlxtend 库,如下所示:
$ **pip install mlxtend**
创建交易数据集
为了进行市场篮子分析,你需要一些样本交易数据。为了简化,可以只使用几个交易数据,定义为如下所示的列表列表:
transactions = [
['curd', 'sour cream'], ['curd', 'orange', 'sour cream'],
['bread', 'cheese', 'butter'], ['bread', 'butter'], ['bread', 'milk'],
['apple', 'orange', 'pear'], ['bread', 'milk', 'eggs'], ['tea', 'lemon'],
['curd', 'sour cream', 'apple'], ['eggs', 'wheat flour', 'milk'],
['pasta', 'cheese'], ['bread', 'cheese'], ['pasta', 'olive oil', 'cheese'],
['curd', 'jam'], ['bread', 'cheese', 'butter'],
['bread', 'sour cream', 'butter'], ['strawberry', 'sour cream'],
['curd', 'sour cream'], ['bread', 'coffee'], ['onion', 'garlic']
]
每个内层列表包含单个交易的项集,而整个 transactions 列表包含 20 笔交易。为了保持原始酸奶/酸奶油示例中定义的数量比例,数据集包含五笔包含酸奶的交易,六笔包含酸奶油的交易,以及四笔同时包含酸奶和酸奶油的交易。
为了通过 mlxtend 的 Apriori 算法处理交易数据,你需要将其转换为 独热编码布尔数组,这种结构中每一列代表一个可以购买的商品,每一行代表一笔交易,每个值要么是 True(如果交易中包括该商品),要么是 False(如果交易中不包括该商品)。在这里,你通过 mlxtend 的 TransactionEncoder 对象执行必要的转换:
import pandas as pd
from mlxtend.preprocessing import TransactionEncoder
❶ encoder = TransactionEncoder()
❷ encoded_array = encoder.fit(transactions).transform(transactions)
❸ df_itemsets = pd.DataFrame(encoded_array, columns=encoder.columns_)
你创建一个 TransactionEncoder 对象 ❶ 并使用它将 transactions 列表列表转换为一个名为 encoded_array 的独热编码布尔数组 ❷。然后你将这个数组转换为一个名为 df_itemsets 的 pandas DataFrame ❸,其片段如下所示:
apple bread butter cheese coffee curd eggs ...
0 False False False False False True False ...
1 False False False False False True False ...
2 False True True True False False False ...
3 False True True False False False False ...
4 False True False False False False False ...
5 True False False False False False False ...
6 False True False False False False True ...
`--snip--`
[20 rows x 20 columns]
该 DataFrame 由 20 行和 20 列组成,行代表交易,列代表商品。为了确认原始的列表列表包含 20 笔交易并涉及 20 个可能的商品,可以使用以下代码:
print('Number of transactions: ', len(transactions))
print('Number of unique items: ', len(set(sum(transactions, []))))
在这两种情况下,你应该得到 20。
识别频繁项集
现在,交易数据已经是可用格式,你可以使用 mlxtend 的 apriori() 函数来识别交易数据中的所有频繁项集——也就是说,识别所有支持度足够高的商品或商品组合。操作如下:
from mlxtend.frequent_patterns import apriori
frequent_itemsets = apriori(df_itemsets, min_support=0.1, use_colnames=True)
你从 mlxtend.frequent_patterns 模块导入 apriori() 函数。然后调用该函数,将包含交易数据的 DataFrame 作为第一个参数传递。你还将 min_support 参数设置为 0.1,以返回至少支持度为 10% 的项集。(记住,支持度指标表示某个商品或商品组合出现在交易中的百分比。)你将 use_colnames 设置为 True,以通过名称(如 curd 或 sour cream)而非索引号来识别每个项集中的列。结果,apriori() 返回如下 DataFrame:
support itemsets
0 0.10 (apple)
1 0.40 (bread)
2 0.20 (butter)
3 0.25 (cheese)
4 0.25 (curd)
5 0.10 (eggs)
6 0.15 (milk)
7 0.10 (orange)
8 0.10 (pasta)
9 0.30 (sour cream)
10 0.20 (bread, butter)
11 0.15 (bread, cheese)
12 0.10 (bread, milk)
13 0.10 (cheese, butter)
14 0.10 (pasta, cheese)
15 0.20 (sour cream, curd)
16 0.10 (milk, eggs)
17 0.10 (bread, cheese, butter)
如前所述,项集可以由一个或多个商品组成,实际上,apriori() 返回了几个单一商品的项集。最终,mlxtend 在生成关联规则时会忽略这些单一商品的项集,但在成功生成规则时,仍然需要 所有 频繁项集的数据(包括那些只有一个商品的项集)。不过,出于兴趣,你此时可能希望仅查看包含多个商品的项集。为此,首先在 frequent_itemsets DataFrame 中添加一个 length 列,如下所示:
**frequent_itemsets['length'] = frequent_itemsets['itemsets'].apply(lambda itemset: len(itemset))**
然后,使用 pandas 的选择语法来过滤 DataFrame,仅保留 length 字段值为 2 或更多的行:
**print(frequent_itemsets[frequent_itemsets['length'] >= 2])**
你将看到以下结果,且没有任何单一项项集:
10 0.20 (bread, butter) 2
11 0.15 (bread, cheese) 2
12 0.10 (bread, milk) 2
13 0.10 (cheese, butter) 2
14 0.10 (pasta, cheese) 2
15 0.20 (sour cream, curd) 2
16 0.10 (milk, eggs) 2
17 0.10 (bread, cheese, butter) 3
然而,值得重申的是,mlxtend 在生成关联规则时需要所有频繁项集的信息。因此,请确保你没有从原始 frequent_itemsets DataFrame 中删除任何行。
生成关联规则
你已经找出了所有满足所需支持度阈值的项集。Apriori 算法的第二步是为这些项集生成关联规则。为此,你使用 mlxtend frequent_patterns 模块中的 association_rules() 函数:
from mlxtend.frequent_patterns import association_rules
rules = association_rules(frequent_itemsets, metric="confidence", min_threshold=0.5)
在这里,你调用 association_rules() 函数,将 frequent_itemsets DataFrame 作为第一个参数传入。你还选择了一个用于评估规则的指标,并设置了该指标的阈值。具体来说,你指定该函数仅返回置信度指标为 0.5 或更高的关联规则。如前一节所述,函数会自动跳过生成单一项集的规则。
association_rules() 函数返回以 DataFrame 形式表示的规则,每一行代表一个关联规则。该 DataFrame 包含前提、后果和各种指标的列,包括支持度、置信度和提升度。在这里,你可以打印出选择的列:
print(rules.iloc[:,0:7])
你将看到以下输出:
antecedents consequents antecedent sup. consequent sup. support confidence lift
0 (bread) (butter) 0.40 0.20 0.20 0.500000 2.500000
1 (butter) (bread) 0.20 0.40 0.20 1.000000 2.500000
2 (cheese) (bread) 0.25 0.40 0.15 0.600000 1.500000
3 (milk) (bread) 0.15 0.40 0.10 0.666667 1.666667
4 (butter) (cheese) 0.20 0.25 0.10 0.500000 2.000000
5 (pasta) (cheese) 0.10 0.25 0.10 1.000000 4.000000
6 (sour cream) (curd) 0.30 0.25 0.20 0.666667 2.666667
7 (curd) (sour cream) 0.25 0.30 0.20 0.800000 2.666667
8 (milk) (eggs) 0.15 0.10 0.10 0.666667 6.666667
9 (eggs) (milk) 0.10 0.15 0.10 1.000000 6.666667
10 (bread, cheese) (butter) 0.15 0.20 0.10 0.666667 3.333333
11 (bread, butter) (cheese) 0.20 0.25 0.10 0.500000 2.000000
12(cheese, butter) (bread) 0.10 0.40 0.10 1.000000 2.500000
13 (butter)(bread, cheese) 0.20 0.15 0.10 0.500000 3.333333
[14 rows x 7 columns]
看这些规则,有些可能显得冗余。例如,既有 bread -> butter 规则,又有 butter -> bread 规则。同样,也有多个基于 (bread, cheese, butter) 项集的规则。部分原因是,正如本章早些时候提到的,置信度并不是对称的;如果你交换规则中的前提和后果,置信度值可能会变化。此外,对于一个三项项集,提升度也可能会根据哪些项目是前提,哪些是后果而发生变化。因此,(bread, cheese) -> butter 的提升度与 (bread, butter) -> cheese 的提升度不同。
可视化关联规则
正如你在第八章中学到的,数据可视化是一种简单而强大的分析技术。在市场购物篮分析的背景下,可视化提供了一种便捷的方式,通过查看不同前提/后果对的指标来评估一组关联规则的强度。在这一节中,你将使用 Matplotlib 将前一节生成的关联规则可视化为带注释的热力图。
热力图 是一种网格状的图表,其中的单元格通过颜色编码来表示其值。在这个例子中,你将创建一个显示各种关联规则提升度的热力图。你将把所有前提沿着 y 轴排列,后果沿着 x 轴排列,并在规则的前提和后果相交的区域填充颜色,以表示该规则的提升度。颜色越深,提升度越高。
要创建可视化,首先创建一个空的 DataFrame,并将之前创建的 rules DataFrame 中的 antecedents、consequents 和 lift 列复制到其中:
rules_plot = pd.DataFrame()
rules_plot['antecedents']= rules['antecedents'].apply(lambda x: ','.join(list(x)))
rules_plot['consequents']= rules['consequents'].apply(lambda x: ','.join(list(x)))
rules_plot['lift']= rules['lift'].apply(lambda x: round(x, 2))
您使用 lambda 函数将 rules DataFrame 中 antecedents 和 consequents 列的值转换为字符串,这样可以更方便地将它们用作可视化中的标签。最初,值是 frozenset 类型,即 Python 集合的不可变版本。您使用另一个 lambda 函数将 lift 值四舍五入到小数点后两位。
接下来,您需要将新创建的 rules_plot DataFrame 转换为一个矩阵,用于创建热图,其中后件水平排列,前件垂直排列。为此,您可以重塑 rules_plot,使得 antecedents 列中的唯一值形成索引,consequents 列中的唯一值成为新列,而 lift 列的值用于填充重塑后的 DataFrame 值。您可以使用 rules_plot DataFrame 的 pivot() 方法,如下所示:
pivot = rules_plot.pivot(index = 'antecedents', columns = 'consequents', values= 'lift')
您指定 antecedents 和 consequents 列作为结果 pivot DataFrame 的轴,并用 lift 列作为值。如果打印 pivot,它将如下所示:
consequents bread butter cheese cheese,bread curd eggs milk sour cream
antecedents
bread NaN 2.50 NaN NaN NaN NaN NaN NaN
bread,butter NaN NaN 2.0 NaN NaN NaN NaN NaN
butter 2.50 NaN 2.0 3.33 NaN NaN NaN NaN
cheese 1.50 NaN NaN NaN NaN NaN NaN NaN
cheese,bread NaN 3.33 NaN NaN NaN NaN NaN NaN
cheese,butter 2.50 NaN NaN NaN NaN NaN NaN NaN
curd NaN NaN NaN NaN NaN NaN NaN 2.67
eggs NaN NaN NaN NaN NaN NaN 6.67 NaN
milk 1.67 NaN NaN NaN NaN 6.67 NaN NaN
pasta NaN NaN 4.0 NaN NaN NaN NaN NaN
sour cream NaN NaN NaN NaN 2.67 NaN NaN NaN
该 DataFrame 包含构建热图所需的一切:索引的值(前件)将成为 y 轴标签,列名(后件)将成为 x 轴标签,数字和 NaN 的网格将成为绘图的值。(在此上下文中,NaN 表示未为该前件/后件对生成关联规则。)在这里,您将这些组件提取到单独的变量中:
antecedents = list(pivot.index.values)
consequents = list(pivot.columns)
import numpy as np
pivot = pivot.to_numpy()
现在,您已经有了 antecedents 列表中的 y 轴标签,consequents 列表中的 x 轴标签,以及 pivot NumPy 数组中的绘图值。您将使用以下脚本中的所有组件来使用 Matplotlib 构建热图:
import matplotlib
import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots()
❶ im = ax.imshow(pivot, cmap = 'Reds')
ax.set_xticks(np.arange(len(consequents)))
ax.set_yticks(np.arange(len(antecedents)))
ax.set_xticklabels(consequents)
ax.set_yticklabels(antecedents)
❷ plt.setp(ax.get_xticklabels(), rotation=45, ha="right",
rotation_mode="anchor")
❸ for i in range(len(antecedents)):
for j in range(len(consequents)):
❹ if not np.isnan(pivot[i, j]):
❺ text = ax.text(j, i, pivot[i, j], ha="center", va="center")
ax.set_title("Lift metric for frequent itemsets")
fig.tight_layout()
plt.show()
绘制 Matplotlib 图表的关键点已在第八章中介绍。这里,我们只考虑与此特定示例相关的代码行。imshow() 方法将 pivot 数组中的数据转换为颜色编码的 2D 图像 ❶。通过该方法的 cmap 参数,您可以指定如何将数组中的数值映射到颜色。Matplotlib 提供了多个内置的颜色映射供您选择,其中包括此处使用的 Reds 映射。
在创建坐标轴标签后,你使用setp()方法将 x 轴标签旋转 45 度 ❷。这样可以帮助将标签适配到分配的水平空间内。然后,你遍历 pivot 数组中的数据 ❸,并使用 text() 方法为热力图中的每个方格创建文本注释 ❺。前两个参数,j 和 i,是标签的 x 和 y 坐标。下一个参数,pivot[i, j],是标签的文本,剩余的参数设置标签的对齐方式。在调用 text() 方法之前,你使用 if 语句过滤掉没有任何提升度数据的前置/后置项对 ❹。否则,热力图中的每个空白方格会显示 NaN 标签。
图 11-1 展示了结果可视化。

图 11-1:样本关联规则的提升度热力图
热力图帮助你通过阴影的深浅立即看到哪些关联规则具有最高的提升度值。通过观察这个可视化图表,你可以非常确定地说,购买牛奶的顾客也很可能购买鸡蛋。同样,你可以相当确定购买意大利面条的顾客也会购买奶酪。其他关联规则,例如黄油与奶酪的关系也存在,但正如你所见,它们并没有像前者那样得到提升度指标的强有力支持。
热力图还说明了提升度指标的对称性。例如,看看 bread -> butter 和 butter -> bread 规则的值。它们是一样的。然而,你可能会注意到,图中的某些前置/后置项对并没有对称的提升度值。例如,cheese -> bread 规则的提升度显示为 1.5,但图中没有显示 bread -> cheese 的提升度值。这是因为在最初使用 mlxtend 的 association_rules() 函数生成关联规则时,你设置了 50% 的置信度阈值。这排除了许多潜在的关联规则,包括 bread -> cheese,它的置信度为 37.5%,而 cheese -> bread 的置信度为 60%。因此,没有数据可供绘制 bread -> cheese 规则。
从关联规则中获得可操作的见解
使用 Apriori 算法,你从一批交易数据中识别出了频繁项集,并基于这些项集生成了关联规则。这些规则本质上告诉你,如果顾客购买了一种产品,他们购买另一种产品的可能性有多大。通过在热力图上可视化规则的提升度指标,你看到了哪些规则特别具有说服力。接下来需要考虑的合理问题是,企业如何从这些信息中获益。
在本节中,我们将探讨企业如何从关联规则集中提取有用的见解的两种不同方法。我们将研究如何基于客户已购买的商品生成产品推荐,以及如何围绕频繁项集高效地规划折扣。这两种应用不仅能为企业增加收入,还能为客户提供更好的体验。
生成推荐
在顾客的购物篮中出现一个商品后,下一个可能被添加的商品是什么?当然,你不能完全确定,但你可以根据从交易数据中挖掘出来的关联规则做出预测。这个预测的结果可以作为一组推荐的基础,推荐那些与当前购物篮中的商品经常一起购买的商品。零售商常常利用这些推荐向顾客展示他们可能想购买的其他商品。
生成此类推荐最自然的方式或许是查看所有当前购物篮中商品作为前项的关联规则。然后,找出最强的规则——可能是三个具有最高置信度值的规则——并提取它们的后项。以下示例演示了如何针对商品黄油进行此操作。你首先找到黄油作为前项的规则,使用 pandas 库的过滤功能:
butter_antecedent = rules[rules['antecedents'] == {'butter'}][['consequents','confidence']]
.sort_values('confidence', ascending = False)
在这里,你按 confidence 列对规则进行排序,这样置信度最高的规则就会出现在 butter_antecedent 数据框的前面。接着,你使用列表推导提取前三个后项:
butter_consequents = [list(item) for item in butter_antecedent.iloc[0:3:,]['consequents']]
在这个列表推导中,你遍历 butter_antecedent 数据框中的 consequents 列,提取前三个值。基于 butter_consequents 列表,你可以生成一个推荐:
item = 'butter'
print('Items frequently bought together with', item, 'are:', butter_consequents)
推荐将如下所示:
Items frequently bought together with butter are: [['bread'], ['cheese'], ['cheese', 'bread']]
这表明购买黄油的顾客也常常购买面包或奶酪,或者两者都买。
基于关联规则规划折扣
为频繁项集生成的关联规则也可以用于选择哪些产品进行折扣。理想情况下,你应该在每个重要的产品组中都有一个折扣商品,以满足尽可能多的客户。换句话说,你应该在每个频繁项集中选择一个商品进行折扣。
为了实现这一点,首先你需要一组频繁项集来进行处理。不幸的是,association_rules() 函数之前生成的 rules 数据框有前项和后项列,但没有规则的完整项集。因此,你需要通过合并 antecedents 和 consequents 列来创建一个 itemsets 列,如下所示:
from functools import reduce
rules['itemsets'] = rules[['antecedents', 'consequents']].apply(lambda x:
reduce(frozenset.union, x), axis=1)
你使用 Python 的functools模块中的reduce()函数,将frozenset.union()方法应用于antecedents和consequents列的值。这会将这两列中的单独 frozenset 合并成一个 frozenset。
若要查看结果,你可以打印新创建的itemsets列,以及antecedents和consequents列:
print(rules[['antecedents','consequents','itemsets']])
输出将如下所示:
antecedents consequents itemsets
0 (butter) (bread) (butter, bread)
1 (bread) (butter) (butter, bread)
2 (cheese) (bread) (bread, cheese)
3 (milk) (bread) (milk, bread)
4 (butter) (cheese) (butter, cheese)
5 (pasta) (cheese) (pasta, cheese)
6 (sour cream) (curd) (sour cream, curd)
7 (curd) (sour cream) (sour cream, curd)
8 (milk) (eggs) (milk, eggs)
9 (eggs) (milk) (milk, eggs)
10 (butter, cheese) (bread) (bread, butter, cheese)
11 (butter, bread) (cheese) (butter, cheese, bread)
12 (bread, cheese) (butter) (bread, butter, cheese)
13 (butter) (bread, cheese) (butter, cheese, bread)
请注意,新itemsets列中有一些重复项。正如前面讨论的,相同的项集可能形成多个关联规则,因为商品的顺序会影响某些规则度量。不过,对于当前任务,项集中的商品顺序并不重要,因此你可以安全地移除重复的项集,如下所示:
rules.drop_duplicates(subset=['itemsets'], keep='first', inplace=True)
你使用 DataFrame 的drop_duplicates()方法,指定检查itemsets列中的重复项。你保留一组重复项中的第一行,并通过设置inplace为True,直接从现有的 DataFrame 中删除重复行,而不是创建一个新的 DataFrame 来移除重复项。
如果你现在打印出itemsets列:
print(rules['itemsets'])
你将只看到以下内容:
0 (bread, butter)
2 (bread, cheese)
3 (bread, milk)
4 (butter, cheese)
5 (cheese, pasta)
6 (curd, sour cream)
8 (milk, eggs)
10 (bread, cheese, butter)
接下来,你从每个项集中选择一个商品进行折扣:
discounted = []
others = []
❶ for itemset in rules['itemsets']:
❷ for i, item in enumerate(itemset):
❸ if item not in others:
❹ discounted.append(item)
itemset = set(itemset)
itemset.discard(item)
❺ others.extend(itemset)
break
❻ if i == len(itemset)-1:
discounted.append(item)
itemset = set(itemset)
itemset.discard(item)
others.extend(itemset)
print(discounted)
你首先创建discounted列表来积累被选择为折扣商品的商品,并创建others列表来接收项集中未被选择为折扣商品的商品。然后你遍历每个项集❶和项集中的每个商品❷。你会查找一个未包含在others列表中的商品,因为这种商品要么不在任何前面的项集中,要么已经被选为前一个项集的折扣商品,这意味着选择它作为当前项集的折扣商品也是高效的❸。你将选择的商品发送到discounted列表❹,然后将项集中其余的商品发送到others列表❺。如果你遍历完项集中的所有商品后,仍未找到一个不在others列表中的商品,那么你选择项集中的最后一个商品,并将其发送到discounted列表❻。
结果的discounted列表会有所不同,因为表示项集的 Python frozensets 是无序的,但它看起来会像以下示例:
['bread', 'bread', 'bread', 'cheese', 'pasta', 'curd', 'eggs', 'bread']
将结果与之前显示的itemsets列进行比较,你会发现每个项集都有一个折扣商品。此外,你已经非常高效地分配了折扣,因此实际的折扣商品数量显著少于项集的数量。你可以通过从discounted列表中移除重复项来看到这一点:
print(list(set(discounted)))
正如输出所示,尽管有八个项集,你只需要对五个商品进行折扣:
['cheese', 'eggs', 'bread', 'pasta', 'curd']
因此,你成功地在每个项集中折扣了一件商品(对于许多客户来说这是一个显著的好处),而实际上并没有折扣太多商品(对于企业来说这是一个显著的好处)。
总结
正如你所看到的,进行市场篮分析是从大量交易数据中提取有用信息的一种宝贵方式。在本章中,你学习了如何使用 Apriori 算法挖掘交易数据中的关联规则,并了解了如何通过不同的度量标准来评估这些规则。通过这种方式,你能够深入了解哪些商品通常会一起购买。你利用这些知识向客户提供产品推荐,并有效地规划折扣。
第十二章:数据分析中的机器学习

机器学习是一种数据分析方法,应用程序利用现有数据发现模式并做出决策,而无需显式编程。换句话说,应用程序能够自主学习,无需人工干预。作为一种强大的数据分析技术,机器学习在许多领域得到应用,包括但不限于分类、聚类、预测分析、学习关联、异常检测、图像分析和自然语言处理。
本章概述了一些基本的机器学习概念,然后深入探讨了两个机器学习实例。首先,我们将进行情感分析,开发一个模型来预测与产品评论相关的星级评分(从一到五颗星)。之后,我们将开发另一个模型来预测股票价格的变化。
为什么选择机器学习?
机器学习让计算机能够完成一些使用传统编程技术很难甚至不可能完成的任务。例如,假设你需要构建一个图像处理应用程序,能够根据提交的照片区分不同种类的动物。在这个假设的场景中,你已经有了一个代码库,可以识别图像中物体(比如动物)的边缘。通过这种方式,你可以将照片中的动物转换为一组特征性的线条。但是,如何才能程序化地区分两种不同动物的线条——比如一只猫和一只狗呢?
传统的编程方法是手动编写规则,将每一种特征线条组合映射到一个动物上。不幸的是,这个方法需要大量的代码,并且在提交一张新照片时,如果该照片的边缘不符合任何手动定义的规则,系统可能完全失效。相比之下,基于机器学习算法的应用程序并不依赖预定义的逻辑,而是依靠应用程序从之前见过的数据中自动学习。因此,基于机器学习的照片标记应用程序会寻找从之前照片中提取的线条组合中的模式,然后根据概率统计对新照片中的动物进行预测。
机器学习的类型
数据科学家区分了几种不同类型的机器学习。其中最常见的是监督学习和无监督学习。在本章中,我们主要关注监督学习,但本节将简要概述这两种类型。
监督学习
有监督学习使用标记数据集(称为训练集)来教会模型在给定新的、先前未见过的数据时,产生期望的输出。从技术上讲,有监督学习是一种推断函数的技术,该函数基于训练集将输入映射到输出。您在第三章中已经看到了一个有监督学习的例子,我们使用一组示例产品评论训练模型,预测新的产品评论是正面还是负面。
有监督学习算法的输入数据可以表示现实世界对象或事件的特征。例如,您可能会使用待售房屋的特征(如面积、卧室和浴室的数量等)作为预测房价的算法的输入。这些房价将是算法的输出。您会用一组输入-输出对来训练该算法,这些输入-输出对由不同房屋的特征及其相关房价组成,然后将新房屋的特征输入给它,并将这些新房屋的估计房价作为输出。
其他有监督学习算法设计用来处理的不是特征,而是观察数据:通过观察某种活动或行为收集的数据。例如,考虑一个由监控机场噪音水平的传感器生成的时间序列。这些观察到的噪音水平数据可能会提交给一个机器学习算法,同时提供诸如时间和星期几等信息,以便算法能够学习预测未来几个小时的噪音水平。在这个例子中,时间和星期几是输入,噪音水平是输出。换句话说,该算法将被设计用来预测未来的观察数据。
房价预测和噪音水平预测都是回归的例子,回归是一种常见的有监督学习技术,用于预测连续值。另一种常见的有监督学习技术是分类,它使模型为每个输入分配有限个类别标签中的一个。区分有利和不利的产品评论就是分类的一个例子,其他情感分析应用也是如此,其中文本片段被识别为正面或负面。我们将在本章后面探讨一个情感分析的例子。
无监督学习
无监督学习是一种机器学习技术,其中没有训练阶段。您只需提供输入数据,而没有任何相应的输出值供其学习。从这个意义上讲,无监督机器学习模型必须独立工作,发现输入数据中的隐藏模式。
非监督学习的一个很好的例子是关联分析,在这种学习方式中,机器学习应用会识别数据集中彼此之间有亲和力的项目。在第十一章中,你对一组交易数据进行了关联分析,识别出了经常一起购买的商品。你使用了 Apriori 算法,该算法不需要示例输出数据进行学习;它将所有交易数据作为输入,搜索交易中的频繁项集,从而实现无监督学习。
机器学习如何工作
一个典型的机器学习流程依赖于三个主要组件:
-
学习数据
-
应用于数据的统计模型
-
需要处理的新数据
以下部分将更详细地介绍这些组件。
学习数据
机器学习基于计算机系统能够学习的理念,因此任何机器学习算法都需要数据来进行学习。正如我们之前讨论的那样,这些数据的性质取决于机器学习模型是监督学习还是非监督学习。在监督学习中,学习的数据以输入-输出对的形式出现,这样可以训练模型根据新的输入预测输出。而在非监督学习中,模型只接收输入数据,通过挖掘其中的模式来生成输出。
虽然所有机器学习应用都需要数据来进行学习,但这些数据的格式可能因算法而异。许多算法从组织成表格的数据集进行学习,其中行代表不同的实例,比如可能是单个对象或特定时间点,而列则代表与这些实例相关的属性。一个经典的例子是鸢尾花数据集(archive.ics.uci.edu/ml/datasets/Iris)。它有 150 行,每一行包含关于不同鸢尾花标本的观察数据。以下是该数据集的前四行:
sepal length sepal width petal length petal width species
5.1 3.5 1.4 0.2 Iris-setosa
4.9 3.0 1.4 0.2 Iris-setosa
4.7 3.2 1.3 0.2 Iris-setosa
4.6 3.1 1.5 0.2 Iris-setosa
前四列代表不同的属性或特征,描述的是标本的不同特征。第五列包含每个实例的标签:鸢尾花的具体物种名称。如果你用这个数据集训练一个分类模型,你将把前四列的值作为自变量或输入,而第五列则作为因变量或输出。通过从这些数据中学习,理想情况下,模型能够根据物种分类新的鸢尾花标本。
其他机器学习算法则从非表格数据中学习。例如,我们在上一章中讨论过的用于关联分析的 Apriori 算法,它将不同大小的交易(或购物篮)作为输入数据。下面是一个简单的交易集示例:
(butter, cheese)
(cheese, pasta, bread, milk)
(milk, cheese, eggs, bread, butter)
(bread, cheese, butter)
除了机器学习数据如何结构化的问题外,所使用的数据类型也因算法而异。正如前面的例子所示,一些机器学习算法处理的是数值或文本数据。也有一些算法是专门设计来处理照片、视频或音频数据的。
统计模型
无论机器学习算法需要什么样的数据格式,输入数据必须以某种方式进行转换,以便能够分析并生成输出。这时统计模型就派上用场了:统计数据被用来创建数据的表示形式,这样算法就能识别变量之间的关系、发现洞察、对新数据进行预测、生成推荐等等。统计模型是任何机器学习算法的核心。
例如,Apriori 算法使用支持度度量作为统计模型来寻找频繁项集。(如第十一章所讨论,支持度是包含某个项集的交易的百分比。)具体来说,算法识别所有可能的项集并计算相应的支持度度量,然后仅选择支持度足够高的项集。以下是一个简单的示例,说明算法背后的工作原理:
Itemset Support
-------------- -------
butter, cheese 0.75
bread, cheese 0.75
milk, bread 0.50
bread, butter 0.50
这个例子只展示了两项集合。实际上,在计算每个可能的两项集合的支持度之后,Apriori 算法会继续分析每个三项集合、四项集合,依此类推。然后,算法使用所有大小项集的支持度值来生成频繁项集的列表。
之前未见过的数据
在监督学习中,一旦你在示例数据上训练好模型,就可以将其应用于新的、之前未见过的数据。然而,在这样做之前,你可能需要评估模型,这就是为什么通常做法是将初始数据集拆分为训练集和测试集。前者是模型学习的数据,后者则是用于测试目的的之前未见过的数据。
测试数据仍然包含输入和输出,但模型只看到输入数据。然后,将实际输出与模型预测的输出进行比较,以评估其预测的准确性。一旦确保模型的准确性达到可接受的水平,你就可以使用新的输入数据进行预测分析。
在无监督学习的情况下,数据没有区分“用于学习的数据”和“之前未见过的数据”。所有数据本质上都是之前未见过的,模型通过分析其潜在特征来尝试从中学习。
情感分析示例:对产品评论进行分类
现在我们已经复习了机器学习的基础知识,您可以进行样本情感分析了。如前所述,这种自然语言处理技术允许您以编程方式确定一篇文章是积极的还是消极的。 (在某些应用中,还有更多类别,如中立、非常积极或非常消极。)实质上,情感分析是一种分类形式,是一种监督的机器学习技术,将数据排序为离散类别。
在第三章中,您使用 scikit-learn 对来自亚马逊的一组产品评论执行了基本的情感分析。您训练了一个模型来识别评论是好是坏。在本节中,您将扩展该章节中的工作。您将直接从亚马逊获取一组实际的产品评论,并用它来训练分类模型。该模型的目标是预测评论的星级评分,即一到五级。因此,模型将评论排序为五种可能的类别,而不仅仅是两种。
获取产品评论
在构建模型的第一步是从亚马逊下载一组实际的产品评论。一个简单的方法是使用亚马逊评论导出器,这是一个 Google Chrome 浏览器扩展程序,可以将亚马逊产品的评论下载为 CSV 文件。您可以从这个页面用一键安装这个扩展程序到您的 Chrome 浏览器:chrome.google.com/webstore/detail/amazon-reviews-exporter-c/njlppnciolcibljfdobcefcngiampidm。
安装了该扩展程序后,在 Chrome 中打开亚马逊产品页面。在这个示例中,我们将使用 No Starch Press 的 Python Crash Course 由 Eric Matthes 的亚马逊页面 (https://www.amazon.com/Python-Crash-Course-2nd-Edition/dp/1593279280),目前有 445 条评论。要下载这本书的评论,请找到并点击 Chrome 工具栏中的亚马逊评论导出器按钮。
一旦您将评论保存为 CSV 文件,您可以按以下方式将其读入 pandas DataFrame 中:
import pandas as pd
df = pd.read_csv('reviews.csv')
在继续之前,您可能想查看评论的总数以及 DataFrame 中加载的前几条评论:
print('The number of reviews: ', len(df))
print(df[['title', 'rating']].head(10))
输出应该看起来像这样:
The number of reviews: 445
title rating
0 Great inner content! Not that great outer qual... 4
1 Very enjoyable read 5
2 The updated preface 5
3 Good for beginner but does not go too far or deep 4
4 Worth Every Penny! 5
5 Easy to understand 5
6 Great book for python. 5
7 Not bad, but some disappointment 4
8 Truely for the person that doesn't know how to... 3
9 Easy to Follow, Good Intro for Self Learner 5
这个视图仅显示每条记录的title和rating字段。我们将评论标题视为模型中的独立变量(即输入),评分作为依赖变量(输出)。请注意,我们忽略了每条评论的完整文本,仅关注标题。对于训练情感分类模型来说,这似乎是合理的,因为标题通常代表评论者对产品感受的总结。相比之下,完整的评论文本通常包含其他非情感信息,例如书籍内容的描述。
清洗数据
在你处理真实世界数据之前,几乎总是需要进行数据清洗。在这个具体的例子中,你需要过滤掉不是用英语写的评论。为此,你需要一种方法来编程地确定每个评论的语言。有几个具有语言检测功能的 Python 库;我们将使用 google_trans_new。
安装 google_trans_new
使用pip安装 google_trans_new 库,如下所示:
$ **pip install google_trans_new**
在继续之前,确保 google_trans_new 已修复已知的 bug,该 bug 会在语言检测过程中抛出JSONDecodeError异常。为此,请在 Python 会话中运行以下测试:
$ **from google_trans_new import google_translator**
$ **detector = google_translator()**
$ **detector.detect('Good')**
如果这个测试没有报错,你可以继续进行。如果它抛出了JSONDecodeError异常,你需要对库的源代码中的google_trans_new.py做一些小修改。用pip定位该文件:
$ **pip show google_trans_new**
该命令将显示有关库的一些基本信息,包括其源代码在你本地机器上的位置。前往该位置,用文本编辑器打开google_trans_new.py。然后找到第 151 行和第 233 行,它们看起来像这样:
response = (decoded_line + ']')
然后将它们更改为:
response = decoded_line
保存更改,重新启动你的 Python 会话,然后重新运行测试。现在它应该能够正确识别good为英语单词:
$ **from google_trans_new import google_translator**
$ **detector = google_translator()**
$ **detector.detect('Good')**
['en', 'english']
移除非英语评论
现在,你已经准备好检测每个评论的语言并过滤掉不是用英语写的评论。在以下代码中,你使用 google_trans_new 中的google_translator模块来确定每个评论标题的语言,并将语言存储在 DataFrame 的新列中。检测大量样本的语言可能需要一些时间,所以在运行代码时要耐心:
from google_trans_new import google_translator
detector = google_translator()
df['lang'] = df['title'].apply(lambda x: detector.detect(x)[0])
你首先创建一个google_translator对象,然后使用 lambda 表达式将对象的detect()方法应用于每个评论标题。你将结果保存到名为lang的新列中。在这里,你打印该列,以及title和rating:
print(df[['title', 'rating', 'lang']])
输出将类似于以下内容:
title rating lang
0 Great inner content! Not that great outer qual... 4 en
1 Very enjoyable read 5 en
2 The updated preface 5 en
3 Good for beginner but does not go too far or deep 4 en
4 Worth Every Penny! 5 en
`--snip--`
440 Not bad 1 en
441 Good 5 en
442 Super 5 en
443 内容はとても良い、作りは× 4 ja
444 非常实用 5 zh-CN
你的下一步是过滤数据集,仅保留用英语写的评论:
df = df[df['lang'] == 'en']
该操作应减少数据集中的总行数。为了验证它是否有效,请计算更新后的 DataFrame 中的行数:
print(len(df))
行数应该比原来少,因为所有非英语评论已经被移除。
拆分和转换数据
在继续之前,你需要将评论分为用于训练模型的训练集和用于评估准确性的测试集。你还需要将评论标题的自然语言转换为模型可以理解的数字数据。正如你在第三章的“将文本转换为数字特征向量”中所看到的,词袋(BoW)技术可以用于此目的;要回顾其工作原理,请参考该章节。
以下代码使用 scikit-learn 进行数据的拆分和转换。代码遵循第三章中使用的相同格式:
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import CountVectorizer
reviews = df['title'].values
ratings = df['rating'].values
❶ reviews_train, reviews_test, y_train, y_test = train_test_split(reviews,
ratings, test_size=0.2, random_state=1000)
vectorizer = CountVectorizer()
vectorizer.fit(reviews_train)
❷ x_train = vectorizer.transform(reviews_train)
x_test = vectorizer.transform(reviews_test)
总结一下,scikit-learn 的train_test_split()函数将数据随机拆分为训练集和测试集❶,该库的CountVectorizer类有方法将文本数据转换为数值特征向量❷。代码生成以下结构,将训练集和测试集实现为 NumPy 数组,并将其相应的特征向量实现为 SciPy 稀疏矩阵:
-
reviews_train一个包含用于训练的评论标题的数组 -
reviews_test一个包含用于测试的评论标题的数组 -
y_train一个包含与reviews_train中的评论对应的星级评分的数组 -
y_test一个包含与reviews_test中的评论对应的星级评分的数组 -
x_train一个包含reviews_train数组中评论标题对应的特征向量的矩阵 -
x_test一个包含reviews_test数组中评论标题对应的特征向量的矩阵
我们最关心的是x_train和x_test,这些是 scikit-learn 从评论标题使用 BoW 技术生成的数值特征向量。这些矩阵中的每一行都代表一个评论标题的数值特征向量。要检查由reviews_train数组生成的矩阵的行数,可以使用:
print(len(x_train.toarray()))
结果数字应该是英语评论总数的 80%,因为你按 80/20 的模式将数据分为训练集和测试集。x_test矩阵应该包含其余 20%的特征向量,你可以通过以下方式验证:
print(len(x_test.toarray()))
你可能还想检查训练矩阵中特征向量的长度:
print(len(x_train.toarray()[0]))
你打印的是矩阵中第一行的长度,但每一行的长度是相同的。结果可能如下所示:
442
这意味着训练集中的评论标题中出现了 442 个独特的词汇。这个词汇集合被称为数据集的词汇字典。
如果你感兴趣的话,下面是如何打印整个矩阵:
print(x_train.toarray())
结果大概会是这样:
[[0 0 0 ... 1 0 0]
[0 0 0 ... 0 0 0]
[0 0 0 ... 0 0 0]
`--snip--`
[0 0 0 ... 0 0 0]
[0 0 0 ... 0 0 0]
[0 0 0 ... 0 0 0]]
矩阵的每一列对应数据集词汇字典中的一个词,数字表示该词在任何给定评论标题中出现的次数。如你所见,矩阵大多数情况下是零。这是预期的:示例集中平均的评论标题只有 5 到 10 个词,但整个数据集的词汇字典包含 442 个词,意味着在一个典型的行中,只有 5 到 10 个元素会被设置为 1 或更高。不过,这种示例数据的表示方式正是你训练情感分析分类模型所需要的。
训练模型
现在你已经准备好训练模型了。特别地,你需要训练一个分类器,这是一种将数据分到不同类别的机器学习模型,以便它能预测评论的星级。为此,你可以使用 scikit-learn 的LogisticRegression分类器:
from sklearn.linear_model import LogisticRegression
classifier = LogisticRegression()
classifier.fit(x_train, y_train)
你导入LogisticRegression类并创建一个classifier对象。然后,通过传递x_train矩阵(训练集中的评论标题的特征向量)和y_train数组(相应的星级评分)来训练分类器。
评估模型
现在,模型已经训练完成,你可以使用x_test矩阵来评估它的准确性,将模型的预测评分与y_test数组中的实际评分进行比较。在第三章中,你使用了classifier对象的score()方法来评估其准确性。这里你将使用另一种评估方法,这种方法允许更精确的分析:
import numpy as np
❶ predicted = classifier.predict(x_test)
accuracy = ❷ np.mean(❸ predicted == y_test)
print("Accuracy:", round(accuracy,2))
你使用分类器的predict()方法根据x_test特征向量❶来预测评分。然后,你测试模型的预测值与实际星级评分之间的等价性❸。这种比较的结果是一个布尔数组,其中True和False表示准确和不准确的预测。通过对数组❷取算术平均,你可以得到模型的总体准确率。(在计算均值时,每个True视为1,每个False视为0。)打印结果应该类似于:
Accuracy: 0.68
这表示模型的准确率为 68%,意味着平均每 10 次预测中大约有 7 次是正确的。然而,为了更细致地了解模型的准确性,你需要使用其他 scikit-learn 功能来检查更具体的指标。例如,你可以研究模型的混淆矩阵,这是一张将预测分类与实际分类进行比较的网格。混淆矩阵有助于揭示模型在每个单独类别中的准确性,还能显示模型是否容易将两个类别混淆(即将一个类别错误标记为另一个类别)。你可以通过以下方式为你的分类模型创建混淆矩阵:
from sklearn import metrics
print(metrics.confusion_matrix(y_test, predicted, labels = [1,2,3,4,5]))
你导入 scikit-learn 的metrics模块,然后使用confusion_matrix()方法生成矩阵。你将测试集的实际评分(y_test)、模型预测的评分(predicted)和对应的标签传递给该方法。矩阵大致如下所示:
[[ 0, 0, 0, 1, 7],
[ 0, 0, 1, 0, 1],
[ 0, 0, 0, 4, 3],
[ 0, 0, 0, 1, 6],
[ 0, 0, 0, 3, 54]]
这里,行对应实际的评分,列对应预测的评分。例如,查看第一行中的数字可以告诉你,测试集包含八个实际的 1 星评分,其中一个被预测为 4 星,七个被预测为 5 星。
混淆矩阵的主对角线(从左上到右下)显示了每个评分级别的正确预测数量。通过检查这条对角线,你可以看到模型为五星级评论做出了 54 个正确预测,而为四星级评论做出了 1 个正确预测。没有正确识别出一星、二星或三星级评论。总体而言,在 81 条测试集评论中,模型正确预测了 55 条。
这个结果引发了几个问题。例如,为什么模型仅对五星级评论表现良好?问题可能出在示例数据集中,只有五星级评论的数量足够。为了验证这一点,你可以统计每个评分组中的行数:
print(df.groupby('rating').size())
你通过rating列对包含训练和测试数据的原始 DataFrame 进行分组,并使用size()方法获取每个组中的条目数。输出结果可能如下所示:
rating
1 25
2 15
3 23
4 51
5 290
如你所见,这个统计结果验证了我们的假设:五星级评价远多于其他评分,这表明模型没有足够的数据有效地学习到四星级或更低星级评论的特征。
为了进一步探索模型的准确性,你可能还想查看其主要的分类指标,比较y_test和predicted数组。你可以借助 scikit-learn 的metrics模块中的classification_report()函数来实现:
print(metrics.classification_report(y_test, predicted, labels = [1,2,3,4,5]))
生成的报告大致如下所示:
precision recall f1-score support
1 0.00 0.00 0.00 8
2 0.00 0.00 0.00 2
3 0.00 0.00 0.00 7
4 0.11 0.14 0.12 7
5 0.76 0.95 0.84 57
accuracy 0.68 81
macro avg 0.17 0.22 0.19 81
weighted avg 0.54 0.68 0.60 81
该报告显示了每个评论类别的主要分类指标总结。在这里,我们将重点关注支持度和召回率;关于报告中其他指标的更多信息,请参见scikit-learn.org/stable/modules/generated/sklearn.metrics.classification_report.html#sklearn.metrics.classification_report。
支持度指标显示了每个评分类别的评论数量。特别地,它揭示了评论在评分组之间分布极为不均,测试集表现出与整个数据集相同的趋势。在总共 81 条评论中,有 57 条五星级评论,只有 2 条二星级评论。
召回率显示了正确预测的评论数量与某个评分级别的所有评论数量的比率。例如,五星级评论的召回率为 0.95,意味着模型预测五星级评论的准确率为 95%,而四星级评论的召回率仅为 0.14。由于其他评分的评论没有任何正确预测,因此整个测试集的加权平均召回率为 0.68,这也是你在本节开始时得到的准确率。
考虑到所有这些因素,你可以合理地得出结论,问题在于你使用的示例集在每个评分组中的评论数量极度不均衡。
预测股票趋势
为了进一步探索机器学习如何应用于数据分析,接下来我们将创建一个用于预测股市趋势的模型。为了简化起见,我们将创建另一个分类模型:该模型预测明天股票的价格是上涨、下跌还是保持不变。一个更复杂的模型可能会使用回归分析来预测股票的实际价格变化。
与本章的情感分析示例相比,我们的股票预测模型(实际上,许多涉及非文本数据的模型)提出了一个新问题:我们如何决定使用哪些数据作为模型的特征或输入?在情感分析模型中,你使用了通过 BoW 技术从评论标题文本中生成的特征向量。这种向量的内容牢牢依赖于相应文本的内容。从这个意义上讲,向量的内容是预定义的,是根据某种规则从相应文本中提取的特征形成的。
相比之下,当你的模型涉及非文本数据(如股票价格)时,通常由你来决定,甚至可能需要计算,作为模型输入数据的特征集合。与前一天相比的价格百分比变化、过去一周的平均价格、前两天的总交易量?可能会是。与两天前相比的价格百分比变化、过去一个月的平均价格、昨天以来的交易量变化?也有可能。金融分析师使用各种这样的指标,并通过不同的组合,作为股票预测模型的输入数据。
在第十章中,你学会了如何通过计算股市数据的百分比变化、滚动窗口平均值等来得出指标。在本节中,我们将回顾这些技术,生成用于预测模型的特征。但首先,我们需要获取一些数据。
获取数据
为了训练你的模型,你需要获取一年的个股数据。为了这个示例,我们将使用苹果公司(AAPL)。在这里,你将使用 yfinance 库来获取该公司过去一年的股市数据:
import yfinance as yf
tkr = yf.Ticker('AAPL')
hist = tkr.history(period="1y")
你将使用生成的hist DataFrame 来推导关于股票的指标,例如日常价格变动百分比,并将这些指标输入到模型中。然而,你可以合理假设,也有一些外部因素(即不能从股票数据本身推导出的信息)影响着苹果股票的价格。例如,整体股市表现可能会影响个别股票的表现。因此,将更广泛的股市指数数据作为模型的一部分来考虑,可能会更有趣。
最著名的股市指数之一是标准普尔 500 指数。它衡量 500 家大型公司的股票表现。正如你在第四章中看到的,你可以通过 pandas-datareader 库在 Python 中获取标准普尔 500 指数的数据。在这里,你使用该库的 get_data_stooq() 方法从 Stooq 网站获取一年的标准普尔 500 数据:
import pandas_datareader.data as pdr
from datetime import date, timedelta
end = date.today()
❶ start = end - timedelta(days=365)
❷ index_data = pdr.get_data_stooq('^SPX', start, end)
使用 Python 的 datetime 模块,你定义相对于当前日期的查询开始和结束日期 ❶。然后你调用 get_data_stooq() 方法,使用 '^SPX' 请求标准普尔 500 指数数据,并将结果存储在 index_data DataFrame 中 ❷。
现在,你已经有了苹果股票和标准普尔 500 指数的相同时间段的一年的数据,你可以将这些数据合并为一个单一的 DataFrame:
df = hist.join(index_data, rsuffix = '_idx')
连接的 DataFrame 中有列名相同的列。为了避免重叠,你使用 rsuffix 参数。它指示 join() 方法将后缀 '_idx' 添加到 index_data DataFrame 中所有列的名称上。
对于我们的目的,你只关心苹果和标准普尔 500 指数的每日收盘价和交易量。在这里,你将 DataFrame 过滤,只保留这些列:
df = df[['Close','Volume','Close_idx','Volume_idx']]
如果你现在打印 df DataFrame,你应该看到类似下面的内容:
Close Volume Close_idx Volume_idx
Date
2021-01-15 126.361000 111598500 3768.25 2741656357
2021-01-19 127.046791 90757300 3798.91 2485142099
2021-01-20 131.221039 104319500 3851.85 2350471631
2021-01-21 136.031403 120150900 3853.07 2591055660
2021-01-22 138.217926 114459400 3841.47 2290691535
`--snip--`
2022-01-10 172.190002 106765600 4670.29 2668776356
2022-01-11 175.080002 76138300 4713.07 2238558923
2022-01-12 175.529999 74805200 4726.35 2122392627
2022-01-13 172.190002 84505800 4659.03 2392404427
2022-01-14 173.070007 80355000 4662.85 2520603472
该 DataFrame 包含一个连续的多变量时间序列。下一步是从数据中提取特征,这些特征可以作为机器学习模型的输入。
从连续数据中提取特征
你希望根据每天的价格和交易量变化来训练模型。正如你在第十章中学到的,通过将数据点向时间上移,你可以计算连续时间序列数据中的百分比变化,将过去的数据点与当前数据点对齐以进行比较。在下面的代码中,你使用 shift(1) 来计算每列 DataFrame 中的百分比变化,并将结果保存在一组新的列中:
import numpy as np
df['priceRise'] = np.log(df['Close'] / df['Close'].shift(1))
df['volumeRise'] = np.log(df['Volume'] / df['Volume'].shift(1))
df['priceRise_idx'] = np.log(df['Close_idx'] / df['Close_idx'].shift(1))
df['volumeRise_idx'] = np.log(df['Volume_idx'] / df['Volume_idx'].shift(1))
df = df.dropna()
对于每一列数据,你需要将每个数据点除以前一天的对应数据点,然后取结果的自然对数。记住,自然对数能较好地近似百分比变化。最终,你将得到几列新的数据:
-
priceRise苹果股票价格从一天到另一天的百分比变化 -
volumeRise苹果交易量从一天到另一天的百分比变化 -
priceRise_idx标准普尔 500 指数价格从一天到另一天的百分比变化 -
volumeRise_idx标准普尔 500 指数交易量从一天到另一天的百分比变化
你现在可以再次过滤 DataFrame,仅保留新生成的列:
df = df[['priceRise','volumeRise','priceRise_idx','volumeRise_idx']]
DataFrame 的内容现在看起来会类似于以下内容:
priceRise volumeRise priceRise_idx volumeRise_idx
Date
2021-01-19 0.005413 -0.206719 0.008103 -0.098232
2021-01-20 0.032328 0.139269 0.013839 -0.055714
2021-01-21 0.036003 0.141290 0.000317 0.097449
2021-01-22 0.015946 -0.048528 -0.003015 -0.123212
2021-01-25 0.027308 0.319914 0.003609 0.199500
`--snip--`
2022-01-10 0.000116 0.209566 -0.001442 0.100199
2022-01-11 0.016644 -0.338084 0.009118 -0.175788
2022-01-12 0.002567 -0.017664 0.002814 -0.053288
2022-01-13 -0.019211 0.121933 -0.014346 0.119755
2022-01-14 0.005098 -0.050366 0.000820 0.052199
这些列将成为模型的特征,或者独立变量。
生成输出变量
下一步是为现有数据集生成输出变量(也称为目标或因变量)。这个变量应该反映股票第二天的价格变化:是上涨、下跌还是保持不变?你可以通过查看第二天的priceRise列来得知,使用df['priceRise'].shift(-1)可以获取该列。负向移动将未来的值向后移。基于这种偏移,你可以生成一个新列,若价格下跌则为-1,若价格不变则为0,若价格上涨则为1。操作如下:
❶ conditions = [
(df['priceRise'].shift(-1) > 0.01),
(df['priceRise'].shift(-1)< -0.01)
]
❷ choices = [1, -1]
df['Pred'] = ❸ np.select(conditions, choices, default=0)
这里实现的算法假设以下几点:
-
相较于第二天的价格,若价格上涨超过 1%,则视为上涨(
1)。 -
相较于第二天的价格,若价格下降超过 1%,则视为下跌(
-1)。 -
剩余部分视为停滞(
0)。
为了实现该算法,你定义了一个conditions列表,用于根据第 1 点和第 2 点❶检查数据,同时定义一个choices列表,包含1和-1的值,用于表示价格的上涨或下跌❷。然后,你将这两个列表传递给 NumPy 的select()函数❸,该函数根据conditions中的值从choices中选择相应的值,构建一个数组。如果没有满足条件的情况,将分配一个默认值0,以满足第 3 点的要求。你将该数组存储在一个新的 DataFrame 列Pred中,可以将其作为训练和测试模型的输出。实际上,-1、0和1现在是模型在对新数据进行分类时可以选择的可能类别。
训练与评估模型
要训练你的模型,scikit-learn 要求你将输入和输出数据分别呈现为独立的 NumPy 数组。你可以从这里的df数据框生成这些数组:
features = df[['priceRise','volumeRise','priceRise_idx','volumeRise_idx']].to_numpy()
features = np.around(features, decimals=2)
target = df['Pred'].to_numpy()
现在,features数组包含了四个自变量(输入),target数组包含了一个因变量(输出)。接下来,你可以将数据分为训练集和测试集,并训练模型:
from sklearn.model_selection import train_test_split
rows_train, rows_test, y_train, y_test = train_test_split(features, target, test_size=0.2)
from sklearn.linear_model import LogisticRegression
clf = LogisticRegression()
clf.fit(rows_train, y_train)
就像你在本章早些时候的情感分析示例中做的那样,你使用 scikit-learn 的train_test_split()函数按 80/20 的比例划分数据集,并使用LogisticRegression分类器来训练模型。接下来,你将数据集的测试部分传递给分类器的score()方法来评估其准确性:
print(clf.score(rows_test, y_test))
结果可能如下所示:
0.6274509803921569
这表明模型大约 62%的时间准确地预测了苹果股票第二天的走势。当然,你也可能得到不同的结果。
总结
在这一章中,你学习了如何使用机器学习来完成一些数据分析任务,例如分类,这是一种使计算机系统能够从历史数据或过去经验中学习的方法。特别地,你了解了如何将机器学习算法应用于自然语言处理任务中的情感分析。你将来自亚马逊产品评论的文本数据转化为机器可读的数值特征向量,然后训练模型根据评论的星级评分进行分类。你还学会了如何基于数值股市数据生成特征,并利用这些特征训练模型来预测股票价格的变化。
结合机器学习、统计方法、公共 API 和 Python 中可用的数据结构的可能性有很多。本书展示了其中的一些可能性,涵盖了各种主题,并希望能够激发你找到许多新的创新解决方案。
第十四章

第十四章:数据科学中的 Python
实践入门
作者:尤利·瓦西里耶夫

第十六章
Python 数据科学。版权 © 2022 由 Yuli Vasiliev 所有。
保留所有权利。未经版权所有者和出版商的书面许可,本书的任何部分不得以任何形式或通过任何手段复制或传输,包括复印、录音或任何信息存储或检索系统。
印刷于美国
首次印刷
26 25 24 23 22 1 2 3 4 5
ISBN-13:978-1-7185-0220-8(印刷版)
ISBN-13:978-1-7185-0221-5(电子书)
出版者:William Pollock
主编:Jill Franklin
制作经理:Rachel Monaghan
制作编辑:Jennifer Kepler
开发编辑:Nathan Heidelberger
封面插图:Gina Redman
内页设计:Octopod Studios
技术审阅:Daniel Zingaro
校对:Rachel Head
排版:Jeff Lytle, Happenstance Type-O-Rama
校对:Jamie Lauer
如需了解关于发行、大宗销售、企业销售或翻译的信息,请直接联系 No Starch Press, Inc.,电子邮件:info@nostarch.com 或:
No Starch Press, Inc.
245 8th Street, San Francisco, CA 94103
电话:1.415.863.9900
www.nostarch.com
美国国会图书馆出版数据目录
作者:Vasiliev, Yuli
标题:Python 数据科学:实践入门 / Yuli Vasiliev 著。
描述:San Francisco : No Starch Press, [2022] | 包含索引。
标识符:LCCN 2022002116(印刷版)| LCCN 2022002117(电子书)| ISBN
9781718502208(印刷版)| ISBN 9781718502215(电子书)
主题:LCSH: Python(计算机程序语言)| 电子数据
处理 | 数据挖掘。
分类:LCC QA76.73.P98 V37 2022(印刷版)| LCC QA76.73.P98
(电子书)| DDC 005.13/3--dc23/eng/20220325
美国国会记录可在 https://lccn.loc.gov/2022002116 查阅
电子书记录可在 https://lccn.loc.gov/2022002117 查阅
No Starch Press 和 No Starch Press 标志是 No Starch Press, Inc. 的注册商标。文中提到的其他产品和公司名称可能是其各自所有者的商标。为了避免每次使用商标时都附上商标符号,我们仅以编辑的方式使用这些名称,旨在为商标所有者带来利益,并无侵犯商标权的意图。
本书中的信息以“现状”分发,不提供任何保证。尽管在本书的准备过程中已采取一切预防措施,但无论是作者还是 No Starch Press, Inc.,对任何因使用或声称因使用本书中包含的信息而直接或间接造成的损失或损害概不负责。
第十六章:关于作者
尤里·瓦西里耶夫(Yuli Vasiliev)是一名程序员、作家和顾问,专注于开源开发、构建数据结构和模型,以及实现数据库后端。他是《Python 与 spaCy 自然语言处理》一书的作者(No Starch Press,2020 年)。
关于技术审阅者
丹尼尔·津加罗博士(Dr. Daniel Zingaro)是多伦多大学计算机科学的副教授和获奖教师。他的研究专注于理解和提升学生在计算机科学方面的学习。他是两本近期出版的 No Starch Press 书籍的作者:《算法思维》(2020 年),一本直白且无数学公式的算法和数据结构指南;以及《通过解决问题学习编程》(2021 年),一本学习 Python 和计算思维的入门书。
第十七章:介绍

我们生活在信息技术(IT)的世界里,计算机系统收集大量数据,处理这些数据,并从中提取有用的信息。这种以数据为驱动的现实不仅影响了现代企业的运作方式,也影响了我们的日常生活。如果没有这些采用数据驱动技术的设备和系统,许多人将很难与社会保持联系。移动地图和导航、在线购物、智能家居设备等,都是数据驱动技术在日常生活中的常见应用实例。
在商业世界中,公司常常使用 IT 系统,通过从大量数据中提取可操作的信息来做出决策。这些数据可能来自不同来源,采用不同格式,可能需要经过转换才能进行分析。例如,许多从事在线业务的公司利用数据分析来推动客户获取和保持,收集并衡量他们能够获取的所有信息,以便建模和理解用户行为。他们通常会结合分析来自不同来源(如用户资料、社交媒体和公司网站)的定量和定性用户数据。而在很多情况下,他们都是通过 Python 编程语言完成这些任务的。
本书将向你介绍 Pythonic 的数据处理世界,摒弃学术术语和过度复杂性。你将学习如何使用 Python 进行数据驱动的应用程序开发,编写代码支持拼车服务、生成产品推荐、预测股市趋势等。通过这些现实世界的实例,你将获得使用 Python 数据科学库的实际操作经验。
使用 Python 进行数据科学
这种对大脑友好的 Python 编程语言是访问、操作和从任何类型的数据中获取洞见的理想选择。它不仅提供了丰富的内建数据结构用于基本操作,还拥有强大的开源库生态系统,支持任何复杂度的数据分析和操作。我们将在本书中探索许多这样的库,包括 NumPy、pandas、scikit-learn、Matplotlib 等。
使用 Python,你可以用最少的时间和精力编写简洁直观的代码,用几行代码就能表达大多数概念。事实上,Python 的灵活语法允许你用一行代码实现多个数据操作。例如,你可以写一个单行代码,既过滤、转换,又聚合数据。
作为一种通用编程语言,Python 适用于各种任务。当你使用 Python 时,可以将数据科学与其他任务无缝集成,创建功能全面的应用程序。例如,你可以构建一个能够根据用户的自然语言请求进行股市预测的机器人应用程序。为了创建这样的应用,你需要一个机器人 API、一个用于预测的机器学习模型和一个用于与用户互动的自然语言处理(NLP)工具。Python 为所有这些任务提供了强大的库。
谁应该阅读这本书?
本书适合那些希望更好理解 Python 数据处理和分析能力的开发者。也许你在某家公司工作,想利用数据来改善业务流程、做出更好的决策、并吸引更多的客户。或者,你可能希望开发自己的数据驱动应用,或者只是想将 Python 的知识拓展到数据科学的领域。
本书假设你对 Python 有一定的基础经验,并且能够根据说明进行任务操作,例如安装数据库或获取 API 密钥。然而,本书会从基础开始讲解 Python 数据科学概念,通过实际示例进行深入阐述。你将通过实践学习,完全不需要先前的数据经验。
这本书内容是什么?
本书从数据处理和分析的概念性介绍开始,解释了一个典型的数据处理流程。然后,我们将讲解 Python 的内置数据结构和一些在数据科学应用中广泛使用的第三方 Python 库。接下来,我们将探讨越来越复杂的技术,涵盖如何获取、组合、聚合、分组、分析和可视化不同大小和数据类型的数据集。随着书籍内容的深入,我们将把 Python 数据科学技术应用到来自商业管理、市场营销和金融领域的实际案例中。在此过程中,每一章都包含“练习”部分,帮助你练习和巩固刚刚学到的内容。
以下是每一章的概览:
-
第一章:数据基础 提供了理解数据处理要点的必要背景。你将了解不同类别的数据,包括结构化数据、非结构化数据和半结构化数据。接下来,你将学习典型数据分析过程中的步骤。
-
第二章:Python 数据结构 介绍了 Python 内置的四种数据结构:列表、字典、元组和集合。你将学习如何使用每种结构,并如何将它们组合成可以表示现实世界对象的更复杂结构。
-
第三章:Python 数据科学库 讨论了 Python 强大的第三方数据分析和处理库生态系统。你将了解 pandas 库及其主要的数据结构——Series 和 DataFrame,它们已成为数据导向 Python 应用程序的事实标准。你还将学习 NumPy 和 scikit-learn,这两个库也是数据科学中常用的库。
-
第四章:从文件和 API 访问数据 深入讲解了如何获取数据并将其加载到脚本中。你将学习如何从不同的来源(如文件和 API)加载数据到 Python 脚本中的数据结构,以便进一步处理。
-
第五章:与数据库一起工作 继续讨论如何将数据导入到 Python 中,重点介绍如何处理数据库中的数据。你将看到访问和操作不同类型数据库中存储的数据的示例,包括像 MySQL 这样的关系型数据库和像 MongoDB 这样的 NoSQL 数据库。
-
第六章:数据聚合 探讨了通过将数据分组并执行聚合计算来总结数据的问题。你将学习如何使用 pandas 对数据进行分组,并生成小计、总计及其他聚合结果。
-
第七章:合并数据集 讲解了如何将来自不同来源的数据合并成一个单一的数据集。你将学习 SQL 开发人员用于连接数据库表的技术,并将这些技术应用到 Python 内置的数据结构、NumPy 数组和 pandas DataFrame 中。
-
第八章:创建可视化图表 讨论了可视化图表作为揭示数据中隐藏模式的最自然方式。你将了解不同类型的可视化图表,如折线图、柱状图和直方图,并学习如何使用 Matplotlib 这一领先的 Python 绘图库来创建它们。你还将使用 Cartopy 库生成地图。
-
第九章:分析位置数据 解释了如何使用 geopy 和 Shapely 库处理位置数据。你将学习如何获取并使用静止和移动物体的 GPS 坐标,并探索一个实际案例,说明共享出行服务如何识别最佳的接送车辆。
-
第十章:分析时间序列数据 介绍了一些分析技巧,你可以将它们应用于时间序列数据,以从中提取有意义的统计信息。特别是,本章中的示例展示了如何将时间序列数据分析应用于股市数据。
-
第十一章:从数据中获取洞察 探索了从数据中获取洞察的策略,以做出明智的决策。例如,你将学习如何发现超市中销售的产品之间的关联,以便确定哪些商品经常在同一交易中一起购买(这对于推荐和促销非常有用)。
-
第十二章:用于数据分析的机器学习 介绍了如何使用 scikit-learn 进行高级数据分析任务。你将训练机器学习模型,根据产品评论的星级进行分类,并预测股票价格的趋势。
第十八章:详细内容
-
封面页
-
版权
-
关于作者
-
简介
-
使用 Python 进行数据科学
-
谁应该阅读本书?
-
这本书讲了什么?
-
-
第一章: 数据基础
-
数据的类别
-
非结构化数据
-
结构化数据
-
半结构化数据
-
时间序列数据
-
-
数据来源
-
API
-
网页
-
数据库
-
文件
-
-
数据处理管道
-
获取
-
清洗
-
转换
-
分析
-
存储
-
-
Pythonic 风格
-
总结
-
-
第二章: Python 数据结构
-
列表
-
创建列表
-
使用常见的列表对象方法
-
使用切片表示法
-
将列表作为队列使用
-
将列表作为堆栈使用
-
使用列表和堆栈进行自然语言处理
-
使用列表推导式进行改进
-
-
元组
-
元组列表
-
不可变性
-
-
字典
-
字典列表
-
使用 setdefault() 向字典添加项
-
将 JSON 加载到字典中
-
-
集合
-
从序列中移除重复项
-
执行常见的集合操作
-
练习 #1: 改进的照片标签分析
-
-
总结
-
-
第三章: Python 数据科学库
-
NumPy
-
安装 NumPy
-
创建 NumPy 数组
-
执行逐元素操作
-
使用 NumPy 统计函数
-
练习 #2: 使用 NumPy 统计函数
-
-
pandas
-
pandas 安装
-
pandas Series
-
练习 #3: 合并三个 Series
-
pandas DataFrame
-
练习 #4: 使用不同的连接
-
-
scikit-learn
-
安装 scikit-learn
-
获取示例数据集
-
将示例数据集加载到 pandas DataFrame 中
-
将示例数据集分割为训练集和测试集
-
将文本转换为数值特征向量
-
训练和评估模型
-
在新数据上进行预测
-
-
总结
-
-
第四章:从文件和 API 中访问数据
-
使用 Python 的 open()函数导入数据
-
文本文件
-
表格数据文件
-
练习 #5:打开 JSON 文件
-
二进制文件
-
-
将数据导出到文件
-
访问远程文件和 API
-
HTTP 请求的工作原理
-
urllib3 库
-
Requests 库
-
练习 #6:使用 Requests 访问 API
-
-
在 DataFrame 和数据之间移动数据
-
导入嵌套 JSON 结构
-
将 DataFrame 转换为 JSON
-
练习 #7:操作复杂的 JSON 结构
-
使用 pandas-datareader 将在线数据加载到 DataFrame 中
-
-
总结
-
-
第五章:与数据库工作
-
关系数据库
-
理解 SQL 语句
-
开始学习 MySQL
-
定义数据库结构
-
将数据插入数据库
-
查询数据库数据
-
练习 #8:执行一对多连接
-
使用数据库分析工具
-
-
NoSQL 数据库
-
键-值存储
-
面向文档的数据库
-
练习 #9:插入和查询多个文档
-
-
总结
-
-
第六章:聚合数据
-
待聚合的数据
-
合并 DataFrames
-
分组和聚合数据
-
按多级索引查看特定聚合
-
切片一系列聚合值
-
在聚合级别内切片
-
添加总计
-
添加小计
-
练习 #10:排除 DataFrame 中的总行
-
-
选择组内所有行
-
总结
-
-
第七章:合并数据集
-
组合内置数据结构
-
合并列表和元组
-
使用 + 组合字典
-
合并两个结构的对应行
-
实现不同类型的列表连接
-
-
连接 NumPy 数组
- 练习 #11:向 NumPy 数组添加新行/列
-
结合 pandas 数据结构
-
连接数据框
-
连接两个数据框
-
-
总结
-
-
第八章:创建可视化
-
常见可视化
-
折线图
-
柱状图
-
饼图
-
直方图
-
-
使用 Matplotlib 绘图
-
安装 Matplotlib
-
使用 matplotlib.pyplot
-
使用 Figure 和 Axes 对象
-
练习 #12:将区间组合为“其他”切片
-
-
使用 Matplotlib 与其他库
-
绘制 pandas 数据
-
使用 Cartopy 绘制地理空间数据
-
练习 #13:使用 Cartopy 和 Matplotlib 绘制地图
-
-
总结
-
-
第九章:分析位置数据
-
获取位置数据
-
将人类可读地址转换为地理坐标
-
获取移动对象的地理坐标
-
-
使用 geopy 和 Shapely 进行空间数据分析
-
查找最近的对象
-
查找特定区域中的对象
-
练习 #14:定义两个或多个多边形
-
结合两种方法
-
练习 #15:进一步改进拾取算法
-
-
结合空间和非空间数据
-
推导非空间属性
-
练习 #16:使用列表推导式过滤数据
-
连接空间和非空间数据集
-
-
总结
-
-
第十章:分析时间序列数据
-
常规与不规则时间序列
-
常见时间序列分析技术
-
计算百分比变化
-
滚动窗口计算
-
计算滚动平均的百分比变化
-
-
多变量时间序列
-
处理多变量时间序列
-
分析变量间的依赖关系
-
练习 #17:增加更多度量来分析依赖关系
-
-
总结
-
-
第十一章:从数据中获取洞察
-
关联规则
-
支持
-
置信度
-
提升度
-
-
Apriori 算法
-
创建交易数据集
-
识别频繁项集
-
生成关联规则
-
-
可视化关联规则
-
从关联规则中获取可操作的洞察
-
生成推荐
-
基于关联规则计划折扣
-
练习 #18:挖掘真实交易数据
-
-
总结
-
-
第十二章:数据分析的机器学习
-
为什么选择机器学习?
-
机器学习的类型
-
监督学习
-
无监督学习
-
-
机器学习的工作原理
-
学习的数据
-
统计模型
-
以前未见过的数据
-
-
情感分析示例:分类产品评论
-
获取产品评论
-
清理数据
-
拆分和转换数据
-
训练模型
-
评估模型
-
练习 #19:扩展示例集
-
-
预测股票趋势
-
获取数据
-
从连续数据中提取特征
-
生成输出变量
-
训练和评估模型
-
练习 #20:尝试不同的股票和新指标
-
-
总结
-
-
索引
表格列表
-
表 6-1:
df_date_region数据框的三个维度 -
表 11-1:酸奶和酸奶油的交易数据
插图列表
-
图 1-1:数据可视化分析的示例
-
图 2-1:使用列表作为队列的示例
-
图 2-2:使用列表作为栈的示例
-
图 2-3:更复杂名词短语的句法依赖树
-
图 3-1:添加两个 NumPy 数组
-
图 3-2:一个 pandas DataFrame 示例
-
图 3-3:使用某列作为索引的 pandas DataFrame
-
图 3-4:连接一对一关系的两个 DataFrame
-
图 3-5:不同类型连接的结果
-
图 3-6:连接一对多关系的两个 DataFrame
-
图 8-1:展示文章访问量随时间变化的折线图
-
图 8-2:展示各种参数之间关系的折线图
-
图 8-3:展示比较类数据的条形图
-
图 8-4:饼图表示每个类别的百分比,作为圆的切片。
-
图 8-5:展示薪资分布的直方图
-
图 8-6:使用
matplotlib.pyplot模块生成的简单折线图 -
图 8-7:展示频率分布的饼图
-
图 8-8:从 pandas DataFrame 生成的条形图
-
图 8-9:带城市的南加州大纲图
-
图 8-10:南加州最大城市
-
图 9-1:在 Telegram 中共享智能手机的实时位置
-
图 9-2:像河流这样的障碍物可能会使距离测量产生误导。
-
图 9-3:使用入口点连接相邻区域
-
图 11-1:样本关联规则的提升度热图
指南
-
封面
-
前言
-
介绍
-
开始阅读
-
索引
页面
-
iii
-
iv
-
v
-
xv
-
xvi
-
xvii
-
xviii
-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
11
-
12
-
13
-
14
-
15
-
16
-
17
-
18
-
19
-
20
-
21
-
22
-
23
-
24
-
25
-
26
-
27
-
28
-
29
-
30
-
31
-
32
-
33
-
34
-
35
-
37
-
38
-
39
-
40
-
41
-
42
-
43
-
44
-
45
-
46
-
47
-
48
-
49
-
50
-
51
-
52
-
53
-
54
-
55
-
56
-
57
-
58
-
59
-
60
-
61
-
62
-
63
-
64
-
65
-
66
-
67
-
68
-
69
-
70
-
71
-
72
-
73
-
74
-
75
-
76
-
77
-
78
-
79
-
80
-
81
-
82
-
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
-
91
-
92
-
93
-
95
-
96
-
97
-
98
-
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
-
126
-
127
-
128
-
129
-
130
-
131
-
132
-
133
-
134
-
135
-
136
-
137
-
138
-
139
-
140
-
141
-
142
-
143
-
145
-
146
-
147
-
148
-
149
-
150
-
151
-
152
-
153
-
154
-
155
-
156
-
157
-
158
-
159
-
160
-
161
-
162
-
163
-
164
-
165
-
166
-
167
-
168
-
169
-
170
-
171
-
172
-
173
-
174
-
175
-
176
-
177
-
178
-
179
-
180
-
181
-
182
-
183
-
184
-
185
-
186
-
187
-
188
-
189
-
190
-
191
-
192
-
193
-
194
-
195
-
196
-
197
-
198
-
199
-
200
-
201
-
202
-
203
-
204
-
205
-
206
-
207
-
208
-
209
-
210
-
211
-
213
-
214
-
215
-
216
-
217


浙公网安备 33010602011771号