Pandas-数据分析实用指南第二版-二-

55 阅读1小时+

Pandas 数据分析实用指南第二版(二)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第二章:使用 Pandas 进行数据分析

现在我们已经对pandas库有所了解,理解了数据分析的内容,并且知道了收集数据的多种方式,接下来我们将重点学习进行数据清洗和探索性数据分析所需的技能。本节将提供我们在 Python 中操作、重塑、总结、聚合和可视化数据所需的工具。

本节包括以下章节:

  • 第三章使用 Pandas 进行数据清洗

  • 第四章聚合 Pandas DataFrame

  • 第五章使用 Pandas 和 Matplotlib 进行数据可视化

  • 第六章使用 Seaborn 绘图与定制技术

第四章:第三章:使用 Pandas 进行数据整理

在上一章中,我们学习了主要的pandas数据结构,如何用收集到的数据创建DataFrame对象,以及检查、总结、筛选、选择和处理DataFrame对象的各种方法。现在,我们已经熟练掌握了初步数据收集和检查阶段,可以开始进入数据整理的世界。

第一章,《数据分析简介》中所提到的,准备数据进行分析通常是从事数据工作的人耗费最多时间的部分,而且往往是最不令人愉快的部分。幸运的是,pandas非常适合处理这些任务,通过掌握本书中介绍的技能,我们将能够更快地进入更有趣的部分。

需要注意的是,数据整理并非我们在分析中只做一次的工作;很可能在完成一次数据整理并转向其他分析任务(如数据可视化)后,我们会发现仍然需要进行额外的数据整理。我们对数据越熟悉,就越能为分析做好准备。形成一种直觉,了解数据应该是什么类型、我们需要将数据转换成什么格式来进行可视化,以及我们需要收集哪些数据点来进行分析,这一点至关重要。这需要经验积累,因此我们必须在每次处理自己数据时,实践本章中将涉及的技能。

由于这是一个非常庞大的话题,关于数据整理的内容将在本章和第四章,《聚合 Pandas 数据框》中分开讲解。本章将概述数据整理,然后探索requests库。接着,我们将讨论一些数据整理任务,这些任务涉及为初步分析和可视化准备数据(我们将在第五章,《使用 Pandas 和 Matplotlib 进行数据可视化》以及第六章,《使用 Seaborn 绘图和定制技巧》中学习到的内容)。我们将针对与聚合和数据集合并相关的更高级的数据整理内容,在第四章,《聚合 Pandas 数据框》中进行讲解。

本章将涵盖以下主题:

  • 理解数据整理

  • 探索 API 查找并收集温度数据

  • 清理数据

  • 数据重塑

  • 处理重复、缺失或无效数据

本章材料

本章的材料可以在 GitHub 上找到,链接为 github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_03。这里有五个笔记本,我们将按顺序进行学习,并有两个目录,data/exercises/,分别包含了上述笔记本和章节末尾练习所需的所有 CSV 文件。data/ 目录中包含以下文件:

图 3.1 – 本章使用的数据集解析

图 3.1 – 本章使用的数据集解析

我们将在 1-wide_vs_long.ipynb 笔记本中开始,讨论宽格式和长格式数据的区别。接下来,我们将在 2-using_the_weather_api.ipynb 笔记本中从 NCEI API 获取每日气温数据,API 地址为 www.ncdc.noaa.gov/cdo-web/webservices/v2。我们将使用的 全球历史气候网络–每日GHCND)数据集的文档可以在 www1.ncdc.noaa.gov/pub/data/cdo/documentation/GHCND_documentation.pdf 找到。

重要提示

NCEI 是 国家海洋和大气管理局NOAA)的一部分。如 API 的 URL 所示,该资源是在 NCEI 被称为 NCDC 时创建的。如果将来该资源的 URL 发生变化,可以搜索 NCEI weather API 来找到更新后的 URL。

3-cleaning_data.ipynb 笔记本中,我们将学习如何对温度数据和一些财务数据进行初步清理,这些财务数据是通过我们将在 第七章 中构建的 stock_analysis 包收集的,财务分析——比特币与股市。然后,我们将在 4-reshaping_data.ipynb 笔记本中探讨如何重塑数据。最后,在 5-handling_data_issues.ipynb 笔记本中,我们将学习如何使用一些脏数据(可在 data/dirty_data.csv 中找到)处理重复、缺失或无效数据的策略。文本中将指示何时切换笔记本。

理解数据处理

就像任何专业领域一样,数据分析充满了术语,初学者往往很难理解这些行话——本章的主题也不例外。当我们进行数据清洗时,我们将输入数据从其原始状态转化为可以进行有意义分析的格式。数据操作是指这一过程的另一种说法。没有固定的操作清单;唯一的目标是,经过清洗后的数据对我们来说比开始时更有用。在实践中,数据清洗过程通常涉及以下三项常见任务:

  • 数据清理

  • 数据转换

  • 数据丰富化

应该注意的是,这些任务没有固定的顺序,我们很可能会在数据清洗过程中多次执行每一项。这一观点引出了一个有趣的难题:如果我们需要清洗数据以为分析做准备,难道不能以某种方式清洗数据,让它告诉我们它在说什么,而不是我们去了解它在说什么吗?

“如果你对数据施加足够的压力,它就会承认任何事情。”

— 罗纳德·科斯,诺贝尔经济学奖得主

从事数据工作的人会发现,通过操作数据很容易扭曲事实。然而,我们的责任是尽最大努力避免欺骗,通过时刻关注我们操作对数据完整性产生的影响,并且向分析使用者解释我们得出结论的过程,让他们也能作出自己的判断。

数据清理

一旦我们收集到数据,将其导入DataFrame对象,并运用在第二章中讨论的技巧熟悉数据后,我们将需要进行一些数据清理。初步的数据清理通常能为我们提供开始探索数据所需的最基本条件。一些必须掌握的基本数据清理任务包括:

  • 重命名

  • 排序与重新排序

  • 数据类型转换

  • 处理重复数据

  • 处理缺失或无效数据

  • 筛选出所需的数据子集

数据清理是数据清洗的最佳起点,因为将数据存储为正确的数据类型和易于引用的名称将为许多探索途径提供便利,如总结统计、排序和筛选。由于我们在第二章中已经讲解过筛选内容,与 Pandas DataFrame 的合作,本章将重点讨论上一节提到的其他主题。

数据转换

经常在一些初步数据清洗之后,我们会进入数据转换阶段,但完全有可能我们的数据集在当前的形式下无法使用,我们必须在进行任何数据清理之前对其进行重构。在数据转换中,我们专注于改变数据的结构,以促进后续分析;这通常涉及改变哪些数据沿行展示,哪些数据沿列展示。

我们将遇到的大多数数据都是宽格式长格式;这两种格式各有其优点,了解我们需要哪一种格式进行分析是非常重要的。通常,人们会以宽格式记录和呈现数据,但有些可视化方法需要数据采用长格式:

图 3.2 – (左)宽格式与(右)长格式

图 3.2 – (左)宽格式与(右)长格式

宽格式更适合分析和数据库设计,而长格式被认为是一种不良设计,因为每一列应该代表一个数据类型并具有单一含义。然而,在某些情况下,当需要在关系型数据库中添加新字段(或删除旧字段)时,数据库管理员可能会选择使用长格式,这样可以避免每次都修改所有表格。这样,他们可以为数据库用户提供固定的模式,同时根据需要更新数据库中的数据。在构建 API 时,如果需要灵活性,可能会选择长格式。比如,API 可能会提供一种通用的响应格式(例如日期、字段名和字段值),以支持来自数据库的各种表格。这也可能与如何根据 API 所使用的数据库存储数据来简化响应的构建过程相关。由于我们将遇到这两种格式的数据,理解如何处理它们并在两者之间转换是非常重要的。

现在,让我们导航到1-wide_vs_long.ipynb笔记本,查看一些示例。首先,我们将导入pandasmatplotlib(这些将帮助我们说明每种格式在可视化方面的优缺点,相关内容将在第五章《使用 Pandas 和 Matplotlib 进行数据可视化》和第六章《使用 Seaborn 和自定义技巧进行绘图》中讨论),并读取包含宽格式和长格式数据的 CSV 文件:

>>> import matplotlib.pyplot as plt
>>> import pandas as pd
>>> wide_df = \
...     pd.read_csv('data/wide_data.csv', parse_dates=['date'])
>>> long_df = pd.read_csv(
...     'data/long_data.csv', 
...     usecols=['date', 'datatype', 'value'], 
...     parse_dates=['date']
... )[['date', 'datatype', 'value']] # sort columns

宽格式数据

对于宽格式数据,我们用各自的列来表示变量的测量值,每一行代表这些变量的一个观测值。这使得我们可以轻松地比较不同观测中的变量,获取汇总统计数据,执行操作并展示我们的数据;然而,一些可视化方法无法使用这种数据格式,因为它们可能依赖于长格式来拆分、调整大小和/或着色图表内容。

让我们查看wide_df中宽格式数据的前六条记录:

>>> wide_df.head(6)

每列包含特定类别温度数据的前六条观测记录,单位是摄氏度——最高温度 (TMAX)、最低温度 (TMIN)、和观测时温度 (TOBS),其频率为每日:

图 3.3 – 宽格式温度数据

图 3.3 – 宽格式温度数据

在处理宽格式数据时,我们可以通过使用describe()方法轻松获得该数据的总结统计信息。请注意,虽然旧版本的pandasdatetimes视为分类数据,但pandas正朝着将其视为数值型数据的方向发展,因此我们传入datetime_is_numeric=True来抑制警告:

>>> wide_df.describe(include='all', datetime_is_numeric=True)

几乎不费力气,我们就能获得日期、最高温度、最低温度和观测时温度的总结统计信息:

图 3.4 – 宽格式温度数据的总结统计

图 3.4 – 宽格式温度数据的总结统计

正如我们之前讨论的,前面表格中的总结数据容易获得,并且富有信息。这种格式也可以用pandas轻松绘制,只要我们告诉它确切需要绘制的内容:

>>> wide_df.plot(
...     x='date', y=['TMAX', 'TMIN', 'TOBS'], figsize=(15, 5),
...     title='Temperature in NYC in October 2018' 
... ).set_ylabel('Temperature in Celsius')
>>> plt.show()

pandas将每日最高温度、最低温度和观测时温度绘制成单一折线图的三条线:

图 3.5 – 绘制宽格式温度数据

图 3.5 – 绘制宽格式温度数据

重要提示

现在不用担心理解可视化代码,它的目的是仅仅展示这些数据格式如何使某些任务更简单或更困难。我们将在第五章中讲解如何使用pandasmatplotlib进行数据可视化,使用 Pandas 和 Matplotlib 可视化数据

长格式数据

长格式数据每一行代表一个变量的观测值;这意味着,如果我们有三个每天测量的变量,每天的观测将有三行数据。长格式的设置可以通过将变量的列名转化为单一列来实现,这一列的值就是变量名称,然后将变量的值放在另一列。

我们可以查看long_df中长格式数据的前六行,看看宽格式数据和长格式数据之间的差异:

>>> long_df.head(6)

注意,现在我们为每个日期有三条记录,并且数据类型列告诉我们列中数据的含义:

图 3.6 – 长格式温度数据

图 3.6 – 长格式温度数据

如果我们像处理宽格式数据一样尝试获取总结统计,结果将不那么有用:

>>> long_df.describe(include='all', datetime_is_numeric=True)

value 列展示了汇总统计数据,但这是对每日最高气温、最低气温和观测时气温的汇总。最大值是每日最高气温的最大值,最小值是每日最低气温的最小值。这意味着这些汇总数据并不十分有用:

图 3.7 – 长格式温度数据的汇总统计

图 3.7 – 长格式温度数据的汇总统计

这种格式并不容易理解,当然也不应该是我们展示数据的方式;然而,它使得创建可视化变得容易,利用我们的绘图库可以根据变量的名称为线条着色、根据某个变量的值调整点的大小,并且进行分面。Pandas 期望绘图数据为宽格式数据,因此,为了轻松绘制出我们用宽格式数据绘制的相同图表,我们必须使用另一个绘图库,叫做 seaborn,我们将在第六章中讲解,使用 Seaborn 绘图与定制技术

>>> import seaborn as sns
>>> sns.set(rc={'figure.figsize': (15, 5)}, style='white')
>>> ax = sns.lineplot(
...     data=long_df, x='date', y='value', hue='datatype'
... )
>>> ax.set_ylabel('Temperature in Celsius')
>>> ax.set_title('Temperature in NYC in October 2018')
>>> plt.show()

Seaborn 可以根据 datatype 列对数据进行子集化,给我们展示每日最高气温、最低气温和观测时的气温的单独线条:

图 3.8 – 绘制长格式温度数据

图 3.8 – 绘制长格式温度数据

Seaborn 允许我们指定用于 hue 的列,这样可以根据温度类型为图 3.8中的线条着色。不过,我们并不局限于此;对于长格式数据,我们可以轻松地对图表进行分面:

>>> sns.set(
...     rc={'figure.figsize': (20, 10)},
...     style='white', font_scale=2 
... )
>>> g = sns.FacetGrid(long_df, col='datatype', height=10)
>>> g = g.map(plt.plot, 'date', 'value')
>>> g.set_titles(size=25)
>>> g.set_xticklabels(rotation=45)
>>> plt.show()

Seaborn 可以使用长格式数据为 datatype 列中的每个不同值创建子图:

图 3.9 – 绘制长格式温度数据的子集

图 3.9 – 绘制长格式温度数据的子集

重要提示

虽然使用 pandasmatplotlib 的子图可以创建类似于之前图表的图形,但更复杂的分面组合将使得使用 seaborn 变得极其简便。我们将在第六章中讲解 seaborn使用 Seaborn 绘图与定制技术

重塑数据部分,我们将讲解如何通过“melting”将数据从宽格式转换为长格式,以及如何通过“pivoting”将数据从长格式转换为宽格式。此外,我们还将学习如何转置数据,即翻转列和行。

数据丰富

一旦我们拥有了格式化良好的清理数据用于分析,我们可能会发现需要稍微丰富一下数据。数据丰富通过某种方式向数据添加更多内容,从而提升数据的质量。这个过程在建模和机器学习中非常重要,它是特征工程过程的一部分(我们将在第十章中提到,做出更好的预测——优化模型)。

当我们想要丰富数据时,我们可以合并新数据与原始数据(通过附加新行或列)或使用原始数据创建新数据。以下是使用原始数据增强数据的几种方法:

  • 添加新列:使用现有列中的数据,通过函数计算出新值。

  • 分箱:将连续数据或具有许多不同值的离散数据转换为区间,这使得列变为离散的,同时让我们能够控制列中可能值的数量。

  • 聚合:将数据汇总并概括。

  • 重采样:在特定的时间间隔内聚合时间序列数据。

现在我们已经理解了数据整理的概念,接下来让我们收集一些数据来进行操作。请注意,在本章中我们将讨论数据清理和转换,而数据丰富将在第四章中讨论,内容包括聚合 Pandas 数据框

探索 API 以查找并收集温度数据

第二章中,使用 Pandas 数据框,我们处理了数据收集以及如何进行初步检查和筛选数据;这通常会给我们一些启示,告诉我们在进一步分析之前需要解决的事项。由于本章内容建立在这些技能的基础上,我们也将在这里练习其中的一些技能。首先,我们将开始探索 NCEI 提供的天气 API。接下来,在下一节中,我们将学习如何使用之前从该 API 获取的温度数据进行数据整理。

重要提示

要使用 NCEI API,您需要填写此表格并提供您的电子邮件地址以申请一个令牌:www.ncdc.noaa.gov/cdo-web/token

在本节中,我们将在2-using_the_weather_api.ipynb笔记本中请求 NCEI API 的温度数据。正如我们在第二章中学到的,使用 Pandas 数据框,我们可以使用requests库与 API 进行交互。在下面的代码块中,我们导入了requests库,并创建了一个便捷函数来向特定端点发出请求,并附带我们的令牌。要使用此函数,我们需要提供一个令牌,具体如下所示(以粗体显示):

>>> import requests
>>> def make_request(endpoint, payload=None):
...     """
...     Make a request to a specific endpoint on the 
...     weather API passing headers and optional payload.
...     Parameters:
...         - endpoint: The endpoint of the API you want to 
...                     make a GET request to.
...         - payload: A dictionary of data to pass along 
...                    with the request.
...     
...     Returns:
...         A response object.
...     """
...     return requests.get(
...         'https://www.ncdc.noaa.gov/cdo-web/'
...         f'api/v2/{endpoint}',
...         headers={'token': 'PASTE_YOUR_TOKEN_HERE'},
...         params=payload
...     )

小贴士

这个函数使用了format()方法:'api/v2/{}'.format(endpoint)

要使用make_request()函数,我们需要学习如何构建请求。NCEI 提供了一个有用的入门页面(www.ncdc.noaa.gov/cdo-web/webservices/v2#gettingStarted),该页面向我们展示了如何构建请求;我们可以通过页面上的选项卡逐步确定查询中需要哪些过滤条件。requests库负责将我们的搜索参数字典(作为payload传递)转换为2018-08-28start2019-04-15end,最终得到?start=2018-08-28&end=2019-04-15,就像网站上的示例一样。这个 API 提供了许多不同的端点,供我们探索所提供的内容,并构建我们最终的实际数据集请求。我们将从使用datasets端点查找我们想查询的数据集 ID(datasetid)开始。让我们检查哪些数据集在 2018 年 10 月 1 日至今天之间有数据:

>>> response = \
...     make_request('datasets', {'startdate': '2018-10-01'})

请记住,我们需要检查status_code属性,以确保请求成功。或者,我们可以使用ok属性来获取布尔指示,查看是否一切按预期进行:

>>> response.status_code
200
>>> response.ok
True

提示

API 限制我们每秒最多 5 个请求,每天最多 10,000 个请求。如果超过这些限制,状态码将显示客户端错误(意味着错误似乎是由我们引起的)。客户端错误的状态码通常在 400 系列;例如,404 表示请求的资源无法找到,400 表示服务器无法理解我们的请求(或拒绝处理)。有时,服务器在处理我们的请求时遇到问题,在这种情况下,我们会看到 500 系列的状态码。您可以在restfulapi.net/http-status-codes/找到常见状态码及其含义的列表。

一旦我们得到响应,就可以使用json()方法获取有效载荷。然后,我们可以使用字典方法来确定我们想查看的部分:

>>> payload = response.json()
>>> payload.keys()
dict_keys(['metadata', 'results'])

metadata部分的 JSON 有效载荷告诉我们有关结果的信息,而results部分包含实际的结果。让我们看看我们收到了多少数据,这样我们就知道是否可以打印结果,或者是否应该尝试限制输出:

>>> payload['metadata']
{'resultset': {'offset': 1, 'count': 11, 'limit': 25}}

我们收到了 11 行数据,因此让我们看看results部分的 JSON 有效载荷包含哪些字段。results键包含一个字典列表。如果我们选择第一个字典,可以查看键以了解数据包含哪些字段。然后,我们可以将输出缩减到我们关心的字段:

>>> payload['results'][0].keys()
dict_keys(['uid', 'mindate', 'maxdate', 'name', 
           'datacoverage', 'id'])

对于我们的目的,我们希望查看数据集的 ID 和名称,因此让我们使用列表推导式仅查看这些:

>>> [(data['id'], data['name']) for data in payload['results']]
[('GHCND', 'Daily Summaries'),
 ('GSOM', 'Global Summary of the Month'),
 ('GSOY', 'Global Summary of the Year'),
 ('NEXRAD2', 'Weather Radar (Level II)'),
 ('NEXRAD3', 'Weather Radar (Level III)'),
 ('NORMAL_ANN', 'Normals Annual/Seasonal'),
 ('NORMAL_DLY', 'Normals Daily'),
 ('NORMAL_HLY', 'Normals Hourly'),
 ('NORMAL_MLY', 'Normals Monthly'),
 ('PRECIP_15', 'Precipitation 15 Minute'),
 ('PRECIP_HLY', 'Precipitation Hourly')]

结果中的第一个条目就是我们要找的。现在我们有了datasetid的值(GHCND),我们继续识别一个datacategoryid,我们需要使用datacategories端点请求温度数据。在这里,我们可以打印 JSON 负载,因为它并不是很大(仅九个条目):

>>> response = make_request(
...     'datacategories', payload={'datasetid': 'GHCND'}
... )
>>> response.status_code
200
>>> response.json()['results']
[{'name': 'Evaporation', 'id': 'EVAP'},
 {'name': 'Land', 'id': 'LAND'},
 {'name': 'Precipitation', 'id': 'PRCP'},
 {'name': 'Sky cover & clouds', 'id': 'SKY'},
 {'name': 'Sunshine', 'id': 'SUN'},
 {'name': 'Air Temperature', 'id': 'TEMP'},
 {'name': 'Water', 'id': 'WATER'},
 {'name': 'Wind', 'id': 'WIND'},
 {'name': 'Weather Type', 'id': 'WXTYPE'}]

根据先前的结果,我们知道我们想要TEMP的值作为datacategoryid。接下来,我们使用此值通过datatypes端点识别我们想要的数据类型。我们将再次使用列表推导式仅打印名称和 ID;这仍然是一个相当大的列表,因此输出已经被缩短。

>>> response = make_request(
...     'datatypes', 
...     payload={'datacategoryid': 'TEMP', 'limit': 100}
... )
>>> response.status_code
200
>>> [(datatype['id'], datatype['name'])
...  for datatype in response.json()['results']]
[('CDSD', 'Cooling Degree Days Season to Date'),
 ...,
 ('TAVG', 'Average Temperature.'),
 ('TMAX', 'Maximum temperature'),
 ('TMIN', 'Minimum temperature'),
 ('TOBS', 'Temperature at the time of observation')]

我们正在寻找TAVGTMAXTMIN数据类型。现在我们已经准备好请求所有位置的温度数据,我们需要将其缩小到特定位置。要确定locationcategoryid的值,我们必须使用locationcategories端点:

>>> response = make_request(
...     'locationcategories', payload={'datasetid': 'GHCND'}
... )
>>> response.status_code
200

注意我们可以使用来自 Python 标准库的pprintdocs.python.org/3/library/pprint.html)来以更易读的格式打印我们的 JSON 负载:

>>> import pprint
>>> pprint.pprint(response.json())
{'metadata': {
     'resultset': {'count': 12, 'limit': 25, 'offset': 1}},
 'results': [{'id': 'CITY', 'name': 'City'},
             {'id': 'CLIM_DIV', 'name': 'Climate Division'},
             {'id': 'CLIM_REG', 'name': 'Climate Region'},
             {'id': 'CNTRY', 'name': 'Country'},
             {'id': 'CNTY', 'name': 'County'},
             ...,
             {'id': 'ST', 'name': 'State'},
             {'id': 'US_TERR', 'name': 'US Territory'},
             {'id': 'ZIP', 'name': 'Zip Code'}]}

我们想要查看纽约市,因此对于locationcategoryid过滤器,CITY是正确的值。我们正在使用 API 上的二分搜索来搜索字段的笔记本;二分搜索是一种更有效的有序列表搜索方法。因为我们知道字段可以按字母顺序排序,并且 API 提供了有关请求的元数据,我们知道 API 对于给定字段有多少项,并且可以告诉我们是否已经通过了我们正在寻找的项。

每次请求时,我们获取中间条目并将其与我们的目标字母顺序比较;如果结果在我们的目标之前出现,我们查看大于我们刚获取的数据的一半;否则,我们查看较小的一半。每次,我们都将数据切成一半,因此当我们获取中间条目进行测试时,我们越来越接近我们寻找的值(见图 3.10):

>>> def get_item(name, what, endpoint, start=1, end=None):
...     """
...     Grab the JSON payload using binary search.
... 
...     Parameters:
...         - name: The item to look for.
...         - what: Dictionary specifying what item `name` is.
...         - endpoint: Where to look for the item.
...         - start: The position to start at. We don't need
...           to touch this, but the function will manipulate
...           this with recursion.
...         - end: The last position of the items. Used to 
...           find the midpoint, but like `start` this is not 
...           something we need to worry about.
... 
...     Returns: Dictionary of the information for the item 
...              if found, otherwise an empty dictionary.
...     """
...     # find the midpoint to cut the data in half each time 
...     mid = (start + (end or 1)) // 2
...     
...     # lowercase the name so this is not case-sensitive
...     name = name.lower()
...     # define the payload we will send with each request
...     payload = {
...         'datasetid': 'GHCND', 'sortfield': 'name',
...         'offset': mid, # we'll change the offset each time
...         'limit': 1 # we only want one value back
...     }
...     
...     # make request adding additional filters from `what`
...     response = make_request(endpoint, {**payload, **what})
...     
...     if response.ok:
...         payload = response.json()
...     
...         # if ok, grab the end index from the response 
...         # metadata the first time through
...         end = end or \
...             payload['metadata']['resultset']['count']
...         
...         # grab the lowercase version of the current name
...         current_name = \
...             payload['results'][0]['name'].lower()  
...
...         # if what we are searching for is in the current 
...         # name, we have found our item
...         if name in current_name:
...             # return the found item
...             return payload['results'][0] 
...         else:
...             if start >= end: 
...                 # if start index is greater than or equal
...                 # to end index, we couldn't find it
...                 return {}
...             elif name < current_name:
...                 # name comes before the current name in the 
...                 # alphabet => search further to the left
...                 return get_item(name, what, endpoint, 
...                                 start, mid - 1)
...             elif name > current_name:
...                 # name comes after the current name in the 
...                 # alphabet => search further to the right
...                 return get_item(name, what, endpoint,
...                                 mid + 1, end) 
...     else:
...         # response wasn't ok, use code to determine why
...         print('Response not OK, '
...               f'status: {response.status_code}')

这是算法的递归实现,这意味着我们从内部调用函数自身;我们在这样做时必须非常小心,以定义一个基本条件,以便最终停止并避免进入无限循环。可以以迭代方式实现此功能。有关二分搜索和递归的更多信息,请参阅本章末尾的进一步阅读部分。

重要说明

在传统的二分搜索实现中,查找我们搜索的列表的长度是微不足道的。使用 API,我们必须发出一个请求来获取计数;因此,我们必须请求第一个条目(偏移量为 1)来确定方向。这意味着与我们开始时所需的相比,我们在这里多做了一个额外的请求。

现在,让我们使用二分查找实现来查找纽约市的 ID,这将作为后续查询中 locationid 的值:

>>> nyc = get_item(
...     'New York', {'locationcategoryid': 'CITY'}, 'locations'
... )
>>> nyc
{'mindate': '1869-01-01',
 'maxdate': '2021-01-14',
 'name': 'New York, NY US',
 'datacoverage': 1,
 'id': 'CITY:US360019'}

通过在这里使用二分查找,我们只用了 8 次请求就找到了 纽约,尽管它位于 1,983 个条目的中间!做个对比,使用线性查找,我们在找到它之前会查看 1,254 个条目。在下面的图示中,我们可以看到二分查找是如何系统性地排除位置列表中的部分内容的,这在数轴上用黑色表示(白色表示该部分仍可能包含所需值):

图 3.10 – 二分查找定位纽约市

图 3.10 – 二分查找定位纽约市

提示

一些 API(如 NCEI API)限制我们在某些时间段内可以进行的请求次数,因此我们必须聪明地进行请求。当查找一个非常长的有序列表时,想一想二分查找。

可选地,我们可以深入挖掘收集数据的站点 ID。这是最细粒度的层次。再次使用二分查找,我们可以获取中央公园站点的站点 ID:

>>> central_park = get_item(
...     'NY City Central Park',
...     {'locationid': nyc['id']}, 'stations'
... )
>>> central_park
{'elevation': 42.7,
 'mindate': '1869-01-01',
 'maxdate': '2020-01-13',
 'latitude': 40.77898,
 'name': 'NY CITY CENTRAL PARK, NY US',
 'datacoverage': 1,
 'id': 'GHCND:USW00094728',
 'elevationUnit': 'METERS',
 'longitude': -73.96925}

现在,让我们请求 2018 年 10 月来自中央公园的纽约市温度数据(摄氏度)。为此,我们将使用 data 端点,并提供在探索 API 过程中收集的所有参数:

>>> response = make_request(
...     'data', 
...     {'datasetid': 'GHCND',
...      'stationid': central_park['id'],
...      'locationid': nyc['id'],
...      'startdate': '2018-10-01',
...      'enddate': '2018-10-31',
...      'datatypeid': ['TAVG', 'TMAX', 'TMIN'],
...      'units': 'metric',
...      'limit': 1000}
... )
>>> response.status_code
200

最后,我们将创建一个 DataFrame 对象;由于 JSON 数据负载中的 results 部分是字典列表,我们可以直接将其传递给 pd.DataFrame()

>>> import pandas as pd
>>> df = pd.DataFrame(response.json()['results'])
>>> df.head()

我们得到了长格式的数据。datatype 列是正在测量的温度变量,value 列包含测得的温度:

图 3.11 – 从 NCEI API 获取的数据

图 3.11 – 从 NCEI API 获取的数据

提示

我们可以使用之前的代码将本节中处理过的任何 JSON 响应转换为 DataFrame 对象,如果我们觉得这样更方便。但需要强调的是,JSON 数据负载几乎在所有 API 中都很常见(作为 Python 用户,我们应该熟悉类似字典的对象),因此,熟悉它们不会有什么坏处。

我们请求了 TAVGTMAXTMIN,但注意到我们没有得到 TAVG。这是因为尽管中央公园站点在 API 中列出了提供平均温度的选项,但它并没有记录该数据——现实世界中的数据是有缺陷的:

>>> df.datatype.unique()
array(['TMAX', 'TMIN'], dtype=object)
>>> if get_item(
...     'NY City Central Park', 
...     {'locationid': nyc['id'], 'datatypeid': 'TAVG'}, 
...     'stations'
... ):
...     print('Found!')
Found!

计划 B 的时候到了:让我们改用拉瓜迪亚机场作为本章剩余部分的站点。或者,我们本可以获取覆盖整个纽约市的所有站点的数据;不过,由于这会导致一些温度测量数据每天有多个条目,我们不会在这里这么做——要处理这些数据,我们需要一些将在 第四章 中介绍的技能,聚合 Pandas DataFrames

从 LaGuardia 机场站收集天气数据的过程与从中央公园站收集数据的过程相同,但为了简洁起见,我们将在下一个笔记本中讨论清洗数据时再读取 LaGuardia 的数据。请注意,当前笔记本底部的单元格包含用于收集这些数据的代码。

清洗数据

接下来,让我们转到3-cleaning_data.ipynb笔记本讨论数据清洗。和往常一样,我们将从导入pandas并读取数据开始。在这一部分,我们将使用nyc_temperatures.csv文件,该文件包含 2018 年 10 月纽约市 LaGuardia 机场站的每日最高气温(TMAX)、最低气温(TMIN)和平均气温(TAVG):

>>> import pandas as pd
>>> df = pd.read_csv('data/nyc_temperatures.csv')
>>> df.head()

我们从 API 获取的是长格式数据;对于我们的分析,我们需要宽格式数据,但我们将在本章稍后的数据透视部分讨论这个问题:

图 3.12 – 纽约市温度数据

图 3.12 – 纽约市温度数据

目前,我们将专注于对数据进行一些小的调整,使其更易于使用:重命名列、将每一列转换为最合适的数据类型、排序和重新索引。通常,这也是过滤数据的时机,但我们在从 API 请求数据时已经进行了过滤;有关使用pandas过滤的回顾,请参考第二章使用 Pandas DataFrame

重命名列

由于我们使用的 API 端点可以返回任何单位和类别的数据,因此它将该列命名为value。我们只提取了摄氏度的温度数据,因此所有观测值的单位都相同。这意味着我们可以重命名value列,以便明确我们正在处理的数据:

>>> df.columns
Index(['date', 'datatype', 'station', 'attributes', 'value'],
      dtype='object')

DataFrame类有一个rename()方法,该方法接收一个字典,将旧列名映射到新列名。除了重命名value列外,我们还将attributes列重命名为flags,因为 API 文档提到该列包含有关数据收集的信息标志:

>>> df.rename(
...     columns={'value': 'temp_C', 'attributes': 'flags'},
...     inplace=True
... )

大多数时候,pandas会返回一个新的DataFrame对象;然而,由于我们传递了inplace=True,原始数据框被直接更新了。使用原地操作时要小心,因为它们可能难以或不可能撤销。我们的列现在已经有了新名字:

>>> df.columns
Index(['date', 'datatype', 'station', 'flags', 'temp_C'], 
      dtype='object')

提示

SeriesIndex对象也可以使用它们的rename()方法重命名。只需传入新名称。例如,如果我们有一个名为temperatureSeries对象,并且我们想将其重命名为temp_C,我们可以运行temperature.rename('temp_C')。变量仍然叫做temperature,但 Series 本身的数据名称将变为temp_C

我们还可以使用rename()对列名进行转换。例如,我们可以将所有列名转换为大写:

>>> df.rename(str.upper, axis='columns').columns
Index(['DATE', 'DATATYPE', 'STATION', 'FLAGS', 'TEMP_C'], 
      dtype='object')

该方法甚至允许我们重命名索引的值,尽管目前我们还不需要这样做,因为我们的索引只是数字。然而,作为参考,只需将前面代码中的 axis='columns' 改为 axis='rows' 即可。

类型转换

现在,列名已经能够准确指示它们所包含的数据,我们可以检查它们所持有的数据类型。在之前使用 head() 方法查看数据框的前几行时,我们应该已经对数据类型有了一些直观的理解。通过类型转换,我们的目标是将当前的数据类型与我们认为应该是的类型进行对比;我们将更改数据的表示方式。

请注意,有时我们可能会遇到认为应该是某种类型的数据,比如日期,但它实际上存储为字符串;这可能有很合理的原因——数据可能丢失了。在这种情况下,存储为文本的缺失数据(例如 ?N/A)会被 pandas 在读取时作为字符串处理。使用 dtypes 属性查看数据框时,它将被标记为 object 类型。如果我们尝试转换(或强制转换)这些列,要么会出现错误,要么结果不符合预期。例如,如果我们有小数点数字的字符串,但尝试将其转换为整数,就会出现错误,因为 Python 知道它们不是整数;然而,如果我们尝试将小数数字转换为整数,就会丢失小数点后的任何信息。

话虽如此,让我们检查一下温度数据中的数据类型。请注意,date 列实际上并没有以日期时间格式存储:

>>> df.dtypes
date         object
datatype     object
station      object
flags        object 
temp_C      float64
dtype: object

我们可以使用 pd.to_datetime() 函数将其转换为日期时间:

>>> df.loc[:,'date'] = pd.to_datetime(df.date)
>>> df.dtypes 
date        datetime64[ns] 
datatype            object
station             object
flags               object 
temp_C             float64
dtype: object

现在好多了。现在,当我们总结 date 列时,可以得到有用的信息:

>>> df.date.describe(datetime_is_numeric=True)
count                     93
mean     2018-10-16 00:00:00
min      2018-10-01 00:00:00
25%      2018-10-08 00:00:00
50%      2018-10-16 00:00:00
75%      2018-10-24 00:00:00
max      2018-10-31 00:00:00
Name: date, dtype: object

处理日期可能会比较棘手,因为它们有很多不同的格式和时区;幸运的是,pandas 提供了更多我们可以用来处理转换日期时间对象的方法。例如,在处理 DatetimeIndex 对象时,如果我们需要跟踪时区,可以使用 tz_localize() 方法将我们的日期时间与时区关联:

>>> pd.date_range(start='2018-10-25', periods=2, freq='D')\
...     .tz_localize('EST')
DatetimeIndex(['2018-10-25 00:00:00-05:00', 
               '2018-10-26 00:00:00-05:00'], 
              dtype='datetime64[ns, EST]', freq=None)

这同样适用于具有 DatetimeIndex 类型索引的 SeriesDataFrame 对象。我们可以再次读取 CSV 文件,这次指定 date 列为索引,并将 CSV 文件中的所有日期解析为日期时间:

>>> eastern = pd.read_csv(
...     'data/nyc_temperatures.csv',
...     index_col='date', parse_dates=True
... ).tz_localize('EST')
>>> eastern.head()

在这个例子中,我们不得不重新读取文件,因为我们还没有学习如何更改数据的索引(将在本章稍后的重新排序、重新索引和排序数据部分讲解)。请注意,我们已经将东部标准时间偏移(UTC-05:00)添加到了索引中的日期时间:

图 3.13 – 索引中的时区感知日期

图 3.13 – 索引中的时区感知日期

我们可以使用tz_convert()方法将时区转换为其他时区。让我们将数据转换为 UTC 时区:

>>> eastern.tz_convert('UTC').head()

现在,偏移量是 UTC(+00:00),但请注意,日期的时间部分现在是上午 5 点;这次转换考虑了-05:00 的偏移:

图 3.14 – 将数据转换为另一个时区

图 3.14 – 将数据转换为另一个时区

我们也可以使用to_period()方法截断日期时间,如果我们不关心完整的日期,这个方法非常有用。例如,如果我们想按月汇总数据,我们可以将索引截断到仅包含月份和年份,然后进行汇总。由于我们将在第四章《聚合 Pandas DataFrame》中讨论聚合方法,我们这里只做截断。请注意,我们首先去除时区信息,以避免pandas的警告,提示PeriodArray类没有时区信息,因此会丢失。这是因为PeriodIndex对象的底层数据是存储为PeriodArray对象:

>>> eastern.tz_localize(None).to_period('M').index
PeriodIndex(['2018-10', '2018-10', ..., '2018-10', '2018-10'],
            dtype='period[M]', name='date', freq='M')

我们可以使用to_timestamp()方法将我们的PeriodIndex对象转换为DatetimeIndex对象;然而,所有的日期时间现在都从每月的第一天开始:

>>> eastern.tz_localize(None)\
...     .to_period('M').to_timestamp().index
DatetimeIndex(['2018-10-01', '2018-10-01', '2018-10-01', ...,
               '2018-10-01', '2018-10-01', '2018-10-01'],
              dtype='datetime64[ns]', name='date', freq=None)

或者,我们可以使用assign()方法来处理任何类型转换,通过将列名作为命名参数传递,并将其新值作为该参数的值传递给方法调用。在实践中,这样做会更有益,因为我们可以在一次调用中执行许多任务,并使用我们在该调用中创建的列来计算额外的列。例如,我们将date列转换为日期时间,并为华氏温度(temp_F)添加一个新列。assign()方法返回一个新的DataFrame对象,因此如果我们想保留它,必须记得将其分配给一个变量。在这里,我们将创建一个新的对象。请注意,我们原始的日期转换已经修改了该列,因此为了说明我们可以使用assign(),我们需要再次读取数据:

>>> df = pd.read_csv('data/nyc_temperatures.csv').rename(
...     columns={'value': 'temp_C', 'attributes': 'flags'}
... )
>>> new_df = df.assign(
...     date=pd.to_datetime(df.date),
...     temp_F=(df.temp_C * 9/5) + 32
... )
>>> new_df.dtypes
date        datetime64[ns] 
datatype            object
station             object
flags               object 
temp_C             float64
temp_F             float64
dtype: object
>>> new_df.head()

我们现在在date列中有日期时间,并且有了一个新列temp_F

图 3.15 – 同时进行类型转换和列创建

图 3.15 – 同时进行类型转换和列创建

此外,我们可以使用astype()方法一次转换一列。例如,假设我们只关心每个整数的温度,但不想进行四舍五入。在这种情况下,我们只是想去掉小数点后的信息。为此,我们可以将浮动值转换为整数。此次,我们将使用temp_F列创建temp_F_whole列,即使在调用assign()之前,df中并没有这个列。结合assign()使用 lambda 函数是非常常见(且有用)的:

>>> df = df.assign(
...     date=lambda x: pd.to_datetime(x.date),
...     temp_C_whole=lambda x: x.temp_C.astype('int'),
...     temp_F=lambda x: (x.temp_C * 9/5) + 32,
...     temp_F_whole=lambda x: x.temp_F.astype('int')
... )
>>> df.head()

注意,如果我们使用 lambda 函数,我们可以引用刚刚创建的列。还需要提到的是,我们不必知道是将列转换为浮动数值还是整数:我们可以使用pd.to_numeric(),如果数据中有小数,它会将数据转换为浮动数;如果所有数字都是整数,它将转换为整数(显然,如果数据根本不是数字,仍然会出现错误):

图 3.16 – 使用 lambda 函数创建列

图 3.16 – 使用 lambda 函数创建列

最后,我们有两列当前存储为字符串的数据,可以用更适合此数据集的方式来表示。stationdatatype列分别只有一个和三个不同的值,这意味着我们在内存使用上并不高效,因为我们将它们存储为字符串。这样可能会在后续的分析中出现问题。Pandas 能够将列定义为类别,并且其他包可以处理这些数据,提供有意义的统计信息,并正确使用它们。类别变量可以取几个值中的一个;例如,血型就是一个类别变量——人们只能有 A 型、B 型、AB 型或 O 型中的一种。

回到温度数据,我们的station列只有一个值,datatype列只有三个不同的值(TAVGTMAXTMIN)。我们可以使用astype()方法将它们转换为类别,并查看汇总统计信息:

>>> df_with_categories = df.assign(
...     station=df.station.astype('category'),
...     datatype=df.datatype.astype('category')
... )
>>> df_with_categories.dtypes 
date            datetime64[ns]
datatype              category
station               category
flags                   object
temp_C                 float64
temp_C_whole             int64
temp_F                 float64
temp_F_whole             int64
dtype: object
>>> df_with_categories.describe(include='category')

类别的汇总统计信息与字符串的汇总统计类似。我们可以看到非空条目的数量(count)、唯一值的数量(unique)、众数(top)以及众数的出现次数(freq):

图 3.17 – 类别列的汇总统计

图 3.17 – 类别列的汇总统计

我们刚刚创建的类别没有顺序,但是pandas确实支持这一点:

>>> pd.Categorical(
...     ['med', 'med', 'low', 'high'], 
...     categories=['low', 'med', 'high'], 
...     ordered=True
... )
['med', 'med', 'low', 'high'] 
Categories (3, object): ['low' < 'med' < 'high']

当我们的数据框中的列被存储为适当的类型时,它为探索其他领域打开了更多的可能性,比如计算统计数据、汇总数据和排序值。例如,取决于我们的数据源,数字数据可能被表示为字符串,在这种情况下,如果尝试按值进行排序,排序结果将按字典顺序重新排列,意味着结果可能是 1、10、11、2,而不是 1、2、10、11(数字排序)。类似地,如果日期以除 YYYY-MM-DD 格式以外的字符串表示,排序时可能会导致非按时间顺序排列;但是,通过使用pd.to_datetime()转换日期字符串,我们可以按任何格式提供的日期进行按时间排序。类型转换使得我们可以根据数值而非初始的字符串表示,重新排序数字数据和日期。

重新排序、重新索引和排序数据

我们经常需要根据一个或多个列的值对数据进行排序。例如,如果我们想找到 2018 年 10 月在纽约市达到最高温度的日期;我们可以按 temp_C(或 temp_F)列降序排序,并使用 head() 选择我们想查看的天数。为了实现这一点,我们可以使用 sort_values() 方法。让我们看看前 10 天:

>>> df[df.datatype == 'TMAX']\
...     .sort_values(by='temp_C', ascending=False).head(10)

根据 LaGuardia 站的数据,这表明 2018 年 10 月 7 日和 10 月 10 日的温度达到了 2018 年 10 月的最高值。我们还在 10 月 2 日和 4 日、10 月 1 日和 9 日、10 月 5 日和 8 日之间存在平局,但请注意,日期并不总是按顺序排列——10 日排在 7 日之后,但 4 日排在 2 日之前:

图 3.18 – 排序数据以找到最温暖的天数

图 3.18 – 排序数据以找到最温暖的天数

sort_values() 方法可以与列名列表一起使用,以打破平局。提供列的顺序将决定排序顺序,每个后续的列将用于打破平局。例如,确保在打破平局时按升序排列日期:

>>> df[df.datatype == 'TMAX'].sort_values(
...     by=['temp_C', 'date'], ascending=[False, True] 
... ).head(10)

由于我们按升序排序,在平局的情况下,年份较早的日期会排在年份较晚的日期之前。请注意,尽管 10 月 2 日和 4 日的温度读数相同,但现在 10 月 2 日排在 10 月 4 日之前:

图 3.19 – 使用多个列进行排序以打破平局

图 3.19 – 使用多个列进行排序以打破平局

提示

pandas 中,索引与行相关联——当我们删除行、筛选或执行任何返回部分行的操作时,我们的索引可能会看起来不按顺序(正如我们在之前的示例中看到的)。此时,索引仅代表数据中的行号,因此我们可能希望更改索引的值,使第一个条目出现在索引 0 位置。为了让 pandas 自动执行此操作,我们可以将 ignore_index=True 传递给 sort_values()

Pandas 还提供了一种额外的方式来查看排序值的子集;我们可以使用 nlargest() 按照特定标准抓取具有最大值的 n 行,使用 nsmallest() 抓取具有最小值的 n 行,无需事先对数据进行排序。两者都接受列名列表或单列的字符串。让我们这次抓取按平均温度排序的前 10 天:

>>> df[df.datatype == 'TAVG'].nlargest(n=10, columns='temp_C')

我们找到了 10 月份最温暖的天数(平均温度):

图 3.20 – 排序以找到平均温度最高的 10 天

图 3.20 – 排序以找到平均温度最高的 10 天

我们不仅限于对值进行排序;如果需要,我们甚至可以按字母顺序排列列,并按索引值对行进行排序。对于这些任务,我们可以使用sort_index()方法。默认情况下,sort_index()会针对行进行操作,以便我们在执行打乱操作后对索引进行排序。例如,sample()方法会随机选择若干行,这将导致索引混乱,所以我们可以使用sort_index()对它们进行排序:

>>> df.sample(5, random_state=0).index
Int64Index([2, 30, 55, 16, 13], dtype='int64')
>>> df.sample(5, random_state=0).sort_index().index
Int64Index([2, 13, 16, 30, 55], dtype='int64')

小贴士

如果我们希望sample()的结果是可重复的,可以传入random_state参数。种子初始化一个伪随机数生成器,只要使用相同的种子,结果就会是相同的。

当我们需要操作列时,必须传入axis=1;默认是操作行(axis=0)。请注意,这个参数在许多pandas方法和函数(包括sample())中都存在,因此理解其含义非常重要。我们可以利用这一点按字母顺序对数据框的列进行排序:

>>> df.sort_index(axis=1).head()

将列按字母顺序排列在使用loc[]时很有用,因为我们可以指定一系列具有相似名称的列;例如,现在我们可以使用df.loc[:,'station':'temp_F_whole']轻松获取所有温度列及站点信息:

图 3.21 – 按名称对列进行排序

图 3.21 – 按名称对列进行排序

重要提示

sort_index()sort_values()都会返回新的DataFrame对象。我们必须传入inplace=True来更新正在处理的数据框。

sort_index()方法还可以帮助我们在测试两个数据框是否相等时获得准确的答案。Pandas 会检查,在数据相同的情况下,两个数据框的索引值是否也相同。如果我们按摄氏温度对数据框进行排序,并检查其是否与原数据框相等,pandas会告诉我们它们不相等。我们必须先对索引进行排序,才能看到它们是相同的:

>>> df.equals(df.sort_values(by='temp_C'))
False
>>> df.equals(df.sort_values(by='temp_C').sort_index())
True

有时,我们并不关心数字索引,但希望使用其他列中的一个(或多个)作为索引。在这种情况下,我们可以使用set_index()方法。让我们将date列设置为索引:

>>> df.set_index('date', inplace=True)
>>> df.head()

请注意,date列已移到最左侧,作为索引的位置,我们不再有数字索引:

图 3.22 – 将日期列设置为索引

图 3.22 – 将日期列设置为索引

小贴士

我们还可以提供一个列的列表,将其作为索引使用。这将创建一个MultiIndex对象,其中列表中的第一个元素是最外层级,最后一个是最内层级。我们将在数据框的透视部分进一步讨论这一点。

将索引设置为日期时间格式,让我们能够利用日期时间切片和索引功能,正如我们在第二章《处理 Pandas 数据框》中简要讨论的那样。只要我们提供pandas能够理解的日期格式,就可以提取数据。要选择 2018 年的所有数据,我们可以使用df.loc['2018'];要选择 2018 年第四季度的数据,我们可以使用df.loc['2018-Q4'];而要选择 10 月的数据,我们可以使用df.loc['2018-10']。这些也可以组合起来构建范围。请注意,在使用范围时,loc[]是可选的:

>>> df['2018-10-11':'2018-10-12']

这为我们提供了从 2018 年 10 月 11 日到 2018 年 10 月 12 日(包括这两个端点)的数据:

图 3.23 – 选择日期范围

图 3.23 – 选择日期范围

我们可以使用reset_index()方法恢复date列:

>>> df['2018-10-11':'2018-10-12'].reset_index()

现在我们的索引从0开始,日期被放在一个叫做date的列中。如果我们有一些数据不想丢失在索引中,比如日期,但需要像没有在索引中一样进行操作,这种做法尤其有用:

图 3.24 – 重置索引

图 3.24 – 重置索引

在某些情况下,我们可能有一个想要继续使用的索引,但需要将其对齐到某些特定的值。为此,我们可以使用reindex()方法。我们提供一个要对齐数据的索引,它会相应地调整索引。请注意,这个新索引不一定是数据的一部分——我们只是有一个索引,并希望将当前数据与之匹配。

作为一个例子,我们将使用sp500.csv文件中的 S&P 500 股票数据。它将date列作为索引并解析日期:

>>> sp = pd.read_csv(
...     'data/sp500.csv', index_col='date', parse_dates=True
... ).drop(columns=['adj_close']) # not using this column

让我们看看数据的样子,并为每一行标注星期几,以便理解索引包含的内容。我们可以轻松地从类型为DatetimeIndex的索引中提取日期部分。在提取日期部分时,pandas会给出我们所需的数值表示;如果我们需要字符串版本,我们应该先看看是否已经有现成的方法,而不是自己编写转换函数。在这种情况下,方法是day_name()

>>> sp.head(10)\
...     .assign(day_of_week=lambda x: x.index.day_name())

小贴士

我们也可以通过系列来做这件事,但首先,我们需要访问dt属性。例如,如果在sp数据框中有一个date列,我们可以通过sp.date.dt.month提取月份。你可以在pandas.pydata.org/pandas-docs/stable/reference/series.html#datetimelike-properties找到可以访问的完整列表。

由于股市在周末(和假期)关闭,我们只有工作日的数据:

图 3.25 – S&P 500 OHLC 数据

图 3.25 – S&P 500 OHLC 数据

如果我们正在分析一个包括标准普尔 500 指数和像比特币这样在周末交易的资产组合,我们需要为标准普尔 500 指数的每一天提供数据。否则,在查看我们投资组合的每日价值时,我们会看到市场休市时每天的巨大跌幅。为了说明这一点,让我们从 bitcoin.csv 文件中读取比特币数据,并将标准普尔 500 指数和比特币的数据结合成一个投资组合。比特币数据还包含 OHLC 数据和交易量,但它有一列叫做 market_cap 的数据,我们不需要,因此我们首先需要删除这列:

>>> bitcoin = pd.read_csv(
...     'data/bitcoin.csv', index_col='date', parse_dates=True
... ).drop(columns=['market_cap'])

要分析投资组合,我们需要按天汇总数据;这是第四章的内容,汇总 Pandas DataFrame,所以现在不用过于担心汇总是如何执行的——只需知道我们按天将数据进行求和。例如,每天的收盘价将是标准普尔 500 指数收盘价和比特币收盘价的总和:

# every day's closing price = S&P 500 close + Bitcoin close
# (same for other metrics)
>>> portfolio = pd.concat([sp, bitcoin], sort=False)\
...     .groupby(level='date').sum()
>>> portfolio.head(10).assign(
...     day_of_week=lambda x: x.index.day_name()
... )

现在,如果我们检查我们的投资组合,我们会看到每周的每一天都有数据;到目前为止,一切正常:

图 3.26 – 标准普尔 500 指数和比特币的投资组合

图 3.26 – 标准普尔 500 指数和比特币的投资组合

然而,这种方法有一个问题,通过可视化展示会更容易看出。绘图将在第五章中详细介绍,使用 Pandas 和 Matplotlib 可视化数据,以及第六章使用 Seaborn 绘图与自定义技术,因此暂时不用担心细节:

>>> import matplotlib.pyplot as plt # module for plotting
>>> from matplotlib.ticker import StrMethodFormatter 
# plot the closing price from Q4 2017 through Q2 2018
>>> ax = portfolio['2017-Q4':'2018-Q2'].plot(
...     y='close', figsize=(15, 5), legend=False,
...     title='Bitcoin + S&P 500 value without accounting '
...           'for different indices'
... )
# formatting
>>> ax.set_ylabel('price')
>>> ax.yaxis\
...     .set_major_formatter(StrMethodFormatter('${x:,.0f}'))
>>> for spine in ['top', 'right']:
...     ax.spines[spine].set_visible(False)
# show the plot
>>> plt.show()

注意这里有一个周期性模式吗?它在每个市场关闭的日子都会下降,因为汇总时只能用比特币数据来填充那些天:

图 3.27 – 未考虑股市休市的投资组合收盘价

图 3.27 – 未考虑股市休市的投资组合收盘价

显然,这是一个问题;资产的价值不会因为市场关闭而降至零。如果我们希望 pandas 为我们填补缺失的数据,我们需要使用 reindex() 方法将标准普尔 500 指数的数据与比特币的索引重新对齐,并传递以下策略之一给 method 参数:

  • 'ffill':该方法将值向前填充。在前面的示例中,这会将股市休市的那几天填充为股市上次开盘时的数据。

  • 'bfill':该方法将值向后填充,这将导致将未来的数据传递到过去的日期,这意味着在这里并不是正确的选择。

  • 'nearest':该方法根据最接近缺失行的行来填充,在这个例子中,这将导致周日获取下一个周一的数据,而周六获取前一个周五的数据。

前向填充似乎是最好的选择,但由于我们不确定,我们首先会查看数据的几行,看看它的效果:

>>> sp.reindex(bitcoin.index, method='ffill').head(10)\
...     .assign(day_of_week=lambda x: x.index.day_name())

你注意到这个有问题吗?嗯,volume(成交量)列让它看起来像我们用前向填充的那些天,实际上是市场开放的日期:

图 3.28 – 前向填充缺失值的日期

图 3.28 – 前向填充缺失值的日期

提示

compare() 方法将展示在标记相同的数据框中(相同的索引和列)不同的值;我们可以使用它来在这里进行前向填充时,隔离数据中的变化。在笔记本中有一个示例。

理想情况下,我们只希望在股市关闭时保持股票的值——成交量应该为零。为了以不同的方式处理每列中的 NaN 值,我们将使用 assign() 方法。为了用 0 填充 volume(成交量)列中的任何 NaN 值,我们将使用 fillna() 方法,这部分我们将在本章稍后的 处理重复、缺失或无效数据 部分详细介绍。fillna() 方法还允许我们传入一个方法而不是一个值,这样我们就可以对 close(收盘价)列进行前向填充,而这个列是我们之前尝试中唯一合适的列。最后,我们可以对其余的列使用 np.where() 函数,这使得我们可以构建一个向量化的 if...else。它的形式如下:

np.where(boolean condition, value if True, value if False)

pandas 中,我们应避免编写循环,而应该使用向量化操作以提高性能。NumPy 函数设计用于操作数组,因此它们非常适合用于高性能的 pandas 代码。这将使我们能够轻松地将 open(开盘价)、high(最高价)或 low(最低价)列中的任何 NaN 值替换为同一天 close(收盘价)列中的值。由于这些操作发生在处理完 close 列之后,我们将可以使用前向填充的 close 值来填充其他列中需要的地方:

>>> import numpy as np
>>> sp_reindexed = sp.reindex(bitcoin.index).assign(
...     # volume is 0 when the market is closed
...     volume=lambda x: x.volume.fillna(0),
...     # carry this forward
...     close=lambda x: x.close.fillna(method='ffill'),
...     # take the closing price if these aren't available
...     open=lambda x: \
...         np.where(x.open.isnull(), x.close, x.open),
...     high=lambda x: \
...         np.where(x.high.isnull(), x.close, x.high),
...     low=lambda x: np.where(x.low.isnull(), x.close, x.low)
... )
>>> sp_reindexed.head(10).assign(
...     day_of_week=lambda x: x.index.day_name()
... )

在 1 月 7 日星期六和 1 月 8 日星期日,我们现在的成交量为零。OHLC(开盘、最高、最低、收盘)价格都等于 1 月 6 日星期五的收盘价:

图 3.29 – 根据每列的特定策略重新索引 S&P 500 数据

图 3.29 – 根据每列的特定策略重新索引 S&P 500 数据

提示

在这里,我们使用 np.where() 来引入一个我们将在本书中反复看到的函数,并且让它更容易理解发生了什么,但请注意,np.where(x.open.isnull(), x.close, x.open) 可以被 combine_first() 方法替代,在这个用例中它等同于 x.open.combine_first(x.close)。我们将在本章稍后的 处理重复、缺失或无效数据 部分中使用 combine_first() 方法。

现在,让我们使用重新索引后的标准普尔 500 数据重建投资组合,并使用可视化将其与之前的尝试进行比较(再次说明,不用担心绘图代码,这部分内容将在第五章使用 Pandas 和 Matplotlib 可视化数据”以及第六章使用 Seaborn 和自定义技术绘图”中详细讲解):

# every day's closing price = S&P 500 close adjusted for
# market closure + Bitcoin close (same for other metrics)
>>> fixed_portfolio = sp_reindexed + bitcoin
# plot the reindexed portfolio's close (Q4 2017 - Q2 2018)
>>> ax = fixed_portfolio['2017-Q4':'2018-Q2'].plot(
...     y='close', figsize=(15, 5), linewidth=2, 
...     label='reindexed portfolio of S&P 500 + Bitcoin', 
...     title='Reindexed portfolio vs.' 
...           'portfolio with mismatched indices'
... )
# add line for original portfolio for comparison
>>> portfolio['2017-Q4':'2018-Q2'].plot(
...     y='close', ax=ax, linestyle='--',
...     label='portfolio of S&P 500 + Bitcoin w/o reindexing' 
... ) 
# formatting
>>> ax.set_ylabel('price')
>>> ax.yaxis\
...     .set_major_formatter(StrMethodFormatter('${x:,.0f}'))
>>> for spine in ['top', 'right']:
...     ax.spines[spine].set_visible(False)
# show the plot
>>> plt.show() 

橙色虚线是我们最初尝试研究投资组合的结果(未重新索引),而蓝色实线是我们刚刚使用重新索引以及为每列采用不同填充策略构建的投资组合。请在第七章金融分析——比特币与股市”的练习中牢记此策略:

图 3.30 – 可视化重新索引的效果

](tos-cn-i-73owjymdk6/bca943b21bcf4e22a3974c9c0c1e853c)

图 3.30 – 可视化重新索引的效果

提示

我们还可以使用reindex()方法重新排序行。例如,如果我们的数据存储在x中,那么x.reindex([32, 20, 11])将返回一个新的DataFrame对象,包含三行:32、20 和 11(按此顺序)。也可以通过axis=1沿列进行此操作(默认值是axis=0,用于行)。

现在,让我们将注意力转向数据重构。回想一下,我们首先需要通过datatype列筛选温度数据,然后进行排序以找出最热的日子;重构数据将使这一过程更加简便,并使我们能够聚合和总结数据。

数据重构

数据并不总是以最方便我们分析的格式提供给我们。因此,我们需要能够根据我们想要进行的分析,将数据重构为宽格式或长格式。对于许多分析,我们希望使用宽格式数据,以便能够轻松查看汇总统计信息,并以这种格式分享我们的结果。

然而,数据重构并不总是像从长格式到宽格式或反之那样简单。请考虑以下来自练习部分的数据:

图 3.31 – 一些列为长格式,一些列为宽格式的数据

](tos-cn-i-73owjymdk6/c62c8ba028e74c68889db6063f8e4628)

图 3.31 – 一些列为长格式,一些列为宽格式的数据

有时数据的某些列可能是宽格式的(对这些数据使用describe()并没有帮助,除非我们先使用pandas进行筛选——我们需要使用seaborn。另外,我们也可以将数据重构为适合该可视化的格式。

现在我们理解了重构数据的动机,接下来让我们转到下一个笔记本4-reshaping_data.ipynb。我们将从导入pandas并读取long_data.csv文件开始,添加华氏温度列(temp_F),并进行一些我们刚刚学到的数据清理操作:

>>> import pandas as pd
>>> long_df = pd.read_csv(
...     'data/long_data.csv',
...     usecols=['date', 'datatype', 'value']
... ).rename(columns={'value': 'temp_C'}).assign(
...     date=lambda x: pd.to_datetime(x.date),
...     temp_F=lambda x: (x.temp_C * 9/5) + 32
... )

我们的长格式数据如下所示:

图 3.32 – 长格式的温度数据

](tos-cn-i-73owjymdk6/09ac290b467746e19dc66a22ad66d3eb)

图 3.32 – 长格式的温度数据

在本节中,我们将讨论数据的转置、透视和熔化。请注意,重塑数据后,我们通常会重新访问数据清理任务,因为数据可能已发生变化,或者我们可能需要修改之前无法轻易访问的内容。例如,如果在长格式下,所有值都变成了字符串,我们可能需要进行类型转换,但在宽格式下,一些列显然是数字类型的。

转置数据框(DataFrames)

虽然我们大多数时候只会使用宽格式或长格式,pandas提供了重构数据的方式,我们可以根据需要调整,包括执行转置(将行和列交换),这在打印数据框部分内容时可能有助于更好地利用显示区域:

>>> long_df.set_index('date').head(6).T

请注意,索引现在已转换为列,而列名已变为索引:

图 3.33 – 转置后的温度数据

图 3.33 – 转置后的温度数据

这可能一开始不太显而易见有多有用,但在本书中,我们会看到多次使用这种方法;例如,在第七章中,财务分析——比特币与股票市场,以及在第九章中,Python 中的机器学习入门,我们都会使用它来让内容更容易显示,并为机器学习构建特定的可视化。

数据框透视

我们的pivot()方法执行了这种数据框(DataFrame)对象的重构。为了进行数据透视,我们需要告诉pandas哪个列包含当前的值(通过values参数),以及哪个列将成为宽格式下的列名(通过columns参数)。我们还可以选择提供新的索引(通过index参数)。让我们将数据透视为宽格式,其中每一列都代表一个摄氏温度测量,并使用日期作为索引:

>>> pivoted_df = long_df.pivot(
...     index='date', columns='datatype', values='temp_C'
... )
>>> pivoted_df.head()

在我们初始的数据框中,有一个datatype列,其中只包含TMAXTMINTOBS作为字符串。现在,这些已经变成了列名,因为我们传入了columns='datatype'。通过传入index='date'date列成为了我们的索引,而无需运行set_index()。最后,对于每个datedatatype的组合,temp_C列中的值是对应的摄氏温度:

图 3.34 – 将长格式温度数据透视为宽格式

图 3.34 – 将长格式温度数据透视为宽格式

正如我们在本章开头讨论的那样,数据处于宽格式时,我们可以轻松地使用describe()方法获取有意义的汇总统计:

>>> pivoted_df.describe()

我们可以看到,对于所有三种温度测量,我们有 31 个观测值,并且本月的温度变化范围很广(最高日最高气温为 26.7°C,最低日最低气温为-1.1°C):

图 3.35 – 透视后的温度数据汇总统计

图 3.35 – 透视温度数据的汇总统计

不过,我们失去了华氏温度。如果我们想保留它,可以将多个列提供给values

>>> pivoted_df = long_df.pivot(
...     index='date', columns='datatype',
...     values=['temp_C', 'temp_F']
... )
>>> pivoted_df.head()

然而,我们现在在列名上方增加了一个额外的层级。这被称为层级索引

图 3.36 – 使用多个值列进行透视

图 3.36 – 使用多个值列进行透视

使用这个层级索引,如果我们想选择华氏温度中的TMIN,我们首先需要选择temp_F,然后选择TMIN

>>> pivoted_df['temp_F']['TMIN'].head()
date
2018-10-01    48.02
2018-10-02    57.02
2018-10-03    60.08
2018-10-04    53.06
2018-10-05    53.06
Name: TMIN, dtype: float64

重要提示

在需要在透视时进行聚合(因为索引中存在重复值)的情况下,我们可以使用pivot_table()方法,我们将在第四章中讨论,聚合 Pandas 数据框

在本章中,我们一直使用单一索引;然而,我们可以使用set_index()从任意数量的列创建索引。这将给我们一个MultiIndex类型的索引,其中最外层对应于提供给set_index()的列表中的第一个元素:

>>> multi_index_df = long_df.set_index(['date', 'datatype'])
>>> multi_index_df.head().index
MultiIndex([('2018-10-01', 'TMAX'),
            ('2018-10-01', 'TMIN'),
            ('2018-10-01', 'TOBS'),
            ('2018-10-02', 'TMAX'),
            ('2018-10-02', 'TMIN')],
           names=['date', 'datatype'])
>>> multi_index_df.head()

请注意,现在我们在索引中有两个层级——date是最外层,datatype是最内层:

图 3.37 – 操作多级索引

图 3.37 – 操作多级索引

pivot()方法期望数据只有一列可以设置为索引;如果我们有多级索引,我们应该改用unstack()方法。我们可以在multi_index_df上使用unstack(),并得到与之前类似的结果。顺序在这里很重要,因为默认情况下,unstack()会将索引的最内层移到列中;在这种情况下,这意味着我们将保留date层级在索引中,并将datatype层级移到列名中。要解开不同层级,只需传入要解开的层级的索引,0 表示最左侧,-1 表示最右侧,或者传入该层级的名称(如果有)。这里,我们将使用默认设置:

>>> unstacked_df = multi_index_df.unstack()
>>> unstacked_df.head()

multi_index_df中,我们将datatype作为索引的最内层,因此,在使用unstack()后,它出现在列中。注意,我们再次在列中有了层级索引。在第四章中,聚合 Pandas 数据框,我们将讨论如何将其压缩回单一层级的列:

图 3.38 – 解开多级索引以转换数据

图 3.38 – 解开多级索引以转换数据

unstack()方法的一个额外好处是,它允许我们指定如何填充在数据重塑过程中出现的缺失值。为此,我们可以使用fill_value参数。假设我们只得到 2018 年 10 月 1 日的TAVG数据。我们可以将其附加到long_df,并将索引设置为datedatatype列,正如我们之前所做的:

>>> extra_data = long_df.append([{
...     'datatype': 'TAVG', 
...     'date': '2018-10-01', 
...     'temp_C': 10, 
...     'temp_F': 50
... }]).set_index(['date', 'datatype']).sort_index()
>>> extra_data['2018-10-01':'2018-10-02']

我们现在有了 2018 年 10 月 1 日的四个温度测量值,但剩余的日期只有三个:

图 3.39 – 向数据中引入额外的温度测量

图 3.39 – 向数据中引入额外的温度测量

使用 unstack(),正如我们之前所做的,将会导致大部分 TAVG 数据变为 NaN 值:

>>> extra_data.unstack().head()

看一下我们解压栈后的 TAVG 列:

图 3.40 – 解压栈可能会导致空值

图 3.40 – 解压栈可能会导致空值

为了应对这个问题,我们可以传入适当的 fill_value。然而,我们仅能为此传入一个值,而非策略(正如我们在讨论重建索引时所看到的),因此,虽然在这个案例中没有合适的值,我们可以使用 -40 来说明这种方法是如何工作的:

>>> extra_data.unstack(fill_value=-40).head()

现在,NaN 值已经被 -40.0 替换。然而,值得注意的是,现在 temp_Ctemp_F 都有相同的温度读数。实际上,这就是我们选择 -40 作为 fill_value 的原因;这是摄氏度和华氏度相等的温度,因此我们不会因为它们是相同的数字而混淆人们;比如 0(因为 0°C = 32°F 和 0°F = -17.78°C)。由于这个温度也远低于纽约市的测量温度,并且低于我们所有数据的 TMIN,它更可能被认为是数据输入错误或数据缺失的信号,而不是如果我们使用了 0 的情况。请注意,实际上,如果我们与他人共享数据时,最好明确指出缺失的数据,并保留 NaN 值:

图 3.41 – 使用默认值解压栈以填补缺失的组合

图 3.41 – 使用默认值解压栈以填补缺失的组合

总结来说,当我们有一个多级索引并希望将其中一个或多个级别移动到列时,unstack() 应该是我们的首选方法;然而,如果我们仅使用单一索引,pivot() 方法的语法可能更容易正确指定,因为哪个数据会出现在何处更加明确。

熔化数据框

要从宽格式转为长格式,我们需要 wide_data.csv 文件:

>>> wide_df = pd.read_csv('data/wide_data.csv')
>>> wide_df.head()

我们的宽格式数据包含一个日期列和每个温度测量列:

图 3.42 – 宽格式温度数据

图 3.42 – 宽格式温度数据

我们可以使用 melt() 方法进行灵活的重塑—使我们能够将其转为长格式,类似于从 API 获取的数据。重塑需要我们指定以下内容:

  • 哪一列(们)能唯一标识宽格式数据中的一行,使用 id_vars 参数

  • 哪一列(们)包含 value_vars 参数的变量(们)

可选地,我们还可以指定如何命名包含变量名的列(var_name)和包含变量值的列(value_name)。默认情况下,这些列名将分别为 variablevalue

现在,让我们使用 melt() 方法将宽格式数据转换为长格式:

>>> melted_df = wide_df.melt(
...     id_vars='date', value_vars=['TMAX', 'TMIN', 'TOBS'], 
...     value_name='temp_C', var_name='measurement'
... )
>>> melted_df.head()

date 列是我们行的标识符,因此我们将其作为 id_vars 提供。我们将 TMAXTMINTOBS 列中的值转换为一个单独的列,列中包含温度(value_vars),并将它们的列名作为测量列的值(var_name='measurement')。最后,我们将值列命名为(value_name='temp_C')。现在我们只有三列;日期、摄氏度的温度读数(temp_C),以及一个列,指示该行的 temp_C 单元格中的温度测量(measurement):

图 3.43 – 将宽格式的温度数据转化为长格式

图 3.43 – 将宽格式的温度数据转化为长格式

图 3.43 – 将宽格式的温度数据转化为长格式

就像我们有另一种方法通过 unstack() 方法对数据进行透视一样,我们也有另一种方法通过 stack() 方法对数据进行熔化。该方法会将列透视到索引的最内层(导致生成 MultiIndex 类型的索引),因此我们在调用该方法之前需要仔细检查索引。它还允许我们在选择时删除没有数据的行/列组合。我们可以通过以下方式获得与 melt() 方法相似的输出:

>>> wide_df.set_index('date', inplace=True)
>>> stacked_series = wide_df.stack() # put datatypes in index
>>> stacked_series.head()
date          
2018-10-01  TMAX    21.1
            TMIN     8.9
            TOBS    13.9
2018-10-02  TMAX    23.9
            TMIN    13.9
dtype: float64

注意,结果返回的是一个 Series 对象,因此我们需要重新创建 DataFrame 对象。我们可以使用 to_frame() 方法,并传入一个名称,用于在数据框中作为列名:

>>> stacked_df = stacked_series.to_frame('values')
>>> stacked_df.head()

现在,我们有一个包含多层索引的 DataFrame,其中包含 datedatatypevalues 作为唯一列。然而,注意到的是,只有索引中的 date 部分有名称:

图 3.44 – 堆叠数据以将温度数据转化为长格式

图 3.44 – 堆叠数据以将温度数据转化为长格式

图 3.44 – 堆叠数据以将温度数据转化为长格式

最初,我们使用 set_index() 将索引设置为 date 列,因为我们不想对其进行熔化;这形成了多层索引的第一层。然后,stack() 方法将 TMAXTMINTOBS 列移动到索引的第二层。然而,这一层从未命名,因此显示为 None,但我们知道这一层应该命名为 datatype

>>> stacked_df.head().index
MultiIndex([('2018-10-01', 'TMAX'),
            ('2018-10-01', 'TMIN'),
            ('2018-10-01', 'TOBS'),
            ('2018-10-02', 'TMAX'),
            ('2018-10-02', 'TMIN')],
           names=['date', None])

我们可以使用 set_names() 方法来处理这个问题:

>>> stacked_df.index\
...     .set_names(['date', 'datatype'], inplace=True)
>>> stacked_df.index.names
FrozenList(['date', 'datatype'])

现在我们已经了解了数据清洗和重塑的基础知识,接下来我们将通过一个示例来展示如何将这些技巧结合使用,处理包含各种问题的数据。

处理重复、缺失或无效数据

到目前为止,我们讨论的是可以改变数据表示方式而不会带来后果的事情。然而,我们还没有讨论数据清理中一个非常重要的部分:如何处理看起来是重复的、无效的或缺失的数据。这部分与数据清理的其他内容分开讨论,因为它是一个需要我们做一些初步数据清理、重塑数据,并最终处理这些潜在问题的例子;这也是一个相当庞大的话题。

我们将在 5-handling_data_issues.ipynb 笔记本中工作,并使用 dirty_data.csv 文件。让我们先导入 pandas 并读取数据:

>>> import pandas as pd
>>> df = pd.read_csv('data/dirty_data.csv')

dirty_data.csv 文件包含来自天气 API 的宽格式数据,经过修改以引入许多我们在实际中会遇到的常见数据问题。它包含以下字段:

  • PRCP: 降水量(毫米)

  • SNOW: 降雪量(毫米)

  • SNWD: 雪深(毫米)

  • TMAX: 日最高温度(摄氏度)

  • TMIN: 日最低温度(摄氏度)

  • TOBS: 观察时的温度(摄氏度)

  • WESF: 雪的水当量(毫米)

本节分为两部分。在第一部分,我们将讨论一些揭示数据集问题的策略,在第二部分,我们将学习如何减轻数据集中的一些问题。

寻找问题数据

第二章《与 Pandas DataFrames 一起工作》中,我们学习了获取数据时检查数据的重要性;并非巧合的是,许多检查数据的方法将帮助我们发现这些问题。调用 head()tail() 来检查数据的结果始终是一个好的第一步:

>>> df.head()

实际上,head()tail() 并不像我们将在本节讨论的其他方法那样强大,但我们仍然可以通过从这里开始获取一些有用的信息。我们的数据是宽格式的,快速浏览一下,我们可以看到一些潜在的问题。有时,station 字段记录为 ?,而有时则记录为站点 ID。我们有些雪深度 (SNWD) 的值是负无穷大 (-inf),同时 TMAX 也有一些非常高的温度。最后,我们可以看到多个列中有许多 NaN 值,包括 inclement_weather 列,这列似乎还包含布尔值:

图 3.45 – 脏数据

图 3.45 – 脏数据

使用 describe(),我们可以查看是否有缺失数据,并通过五数概括(5-number summary)来发现潜在的问题:

>>> df.describe()

SNWD列似乎没什么用,而TMAX列看起来也不可靠。为了提供参考,太阳光球的温度大约是 5,505°C,因此我们肯定不会在纽约市(或者地球上的任何地方)看到如此高的气温。这很可能意味着当TMAX列的数据不可用时,它被设置为一个荒谬的大数字。它之所以能通过describe()函数得到的总结统计结果被识别出来,正是因为这个值过大。如果这些未知值是通过其他值来编码的,比如 40°C,那么我们就不能确定这是不是实际的数据:

图 3.46 – 脏数据的总结统计

图 3.46 – 脏数据的总结统计

我们可以使用info()方法查看是否有缺失值,并检查我们的列是否具有预期的数据类型。这样做时,我们立即发现两个问题:我们有 765 行数据,但其中五列的非空值条目远少于其他列。该输出还告诉我们inclement_weather列的数据类型不是布尔值,尽管从名称上我们可能以为是。注意,当我们使用head()时,station列中看到的?值在这里没有出现——从多个角度检查数据非常重要:

>>> df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 765 entries, 0 to 764
Data columns (total 10 columns):
 #   Column             Non-Null Count  Dtype  
---  ------             --------------  -----  
 0   date               765 non-null    object 
 1   station            765 non-null    object 
 2   PRCP               765 non-null    float64
 3   SNOW               577 non-null    float64
 4   SNWD               577 non-null    float64
 5   TMAX               765 non-null    float64
 6   TMIN               765 non-null    float64
 7   TOBS               398 non-null    float64
 8   WESF               11 non-null     float64
 9   inclement_weather  408 non-null    object 
dtypes: float64(7), object(3)
memory usage: 59.9+ KB

现在,让我们找出这些空值。SeriesDataFrame对象提供了两个方法来实现这一点:isnull()isna()。请注意,如果我们在DataFrame对象上使用该方法,结果将告诉我们哪些行完全为空值,而这并不是我们想要的。在这种情况下,我们想检查那些在SNOWSNWDTOBSWESFinclement_weather列中含有空值的行。这意味着我们需要结合每列的检查,并使用|(按位或)运算符:

>>> contain_nulls = df[
...     df.SNOW.isna() | df.SNWD.isna() | df.TOBS.isna() 
...     | df.WESF.isna() | df.inclement_weather.isna()
... ]
>>> contain_nulls.shape[0]
765
>>> contain_nulls.head(10)

如果我们查看contain_nulls数据框的shape属性,我们会看到每一行都包含一些空数据。查看前 10 行时,我们可以看到每行中都有一些NaN值:

图 3.47 – 脏数据中的含有空值的行

图 3.47 – 脏数据中的含有空值的行

提示

默认情况下,我们在本章早些时候讨论的sort_values()方法会将任何NaN值放在最后。如果我们希望将它们放在最前面,可以通过传递na_position='first'来改变这种行为,这在数据排序列含有空值时,查找数据模式也会很有帮助。

请注意,我们无法检查列的值是否等于NaN,因为NaN与任何值都不相等:

>>> import numpy as np
>>> df[df.inclement_weather == 'NaN'].shape[0] # doesn't work
0
>>> df[df.inclement_weather == np.nan].shape[0] # doesn't work
0

我们必须使用上述选项(isna()/isnull()):

>>> df[df.inclement_weather.isna()].shape[0] # works
357

请注意,inf-inf实际上是np.inf-np.inf。因此,我们可以通过以下方式找到包含inf-inf值的行数:

>>> df[df.SNWD.isin([-np.inf, np.inf])].shape[0]
577

这仅仅告诉我们关于单一列的信息,因此我们可以编写一个函数,使用字典推导式返回每列中无限值的数量:

>>> def get_inf_count(df):
...     """Find the number of inf/-inf values per column"""
...     return {
...         col: df[
...             df[col].isin([np.inf, -np.inf])
...         ].shape[0] for col in df.columns
...     }

使用我们的函数,我们发现SNWD列是唯一一个具有无限值的列,但该列中的大多数值都是无限的:

>>> get_inf_count(df)
{'date': 0, 'station': 0, 'PRCP': 0, 'SNOW': 0, 'SNWD': 577,
 'TMAX': 0, 'TMIN': 0, 'TOBS': 0, 'WESF': 0,
 'inclement_weather': 0}

在我们决定如何处理雪深的无限值之前,我们应该查看降雪(SNOW)的总结统计信息,因为这在确定雪深(SNWD)时占据了很大一部分。为此,我们可以创建一个包含两列的数据框,其中一列包含当雪深为np.inf时的降雪列的总结统计信息,另一列则包含当雪深为-np.inf时的总结统计信息。此外,我们将使用T属性对数据进行转置,以便更容易查看:

>>> pd.DataFrame({
...     'np.inf Snow Depth':
...         df[df.SNWD == np.inf].SNOW.describe(),
...     '-np.inf Snow Depth': 
...         df[df.SNWD == -np.inf].SNOW.describe()
... }).T

当没有降雪时,雪深被记录为负无穷;然而,我们不能确定这是不是只是巧合。如果我们只是处理这个固定的日期范围,我们可以将其视为雪深为0NaN,因为没有降雪。不幸的是,我们无法对正无穷的条目做出任何假设。它们肯定不是真正的无穷大,但我们无法决定它们应该是什么,因此最好还是将其忽略,或者不看这一列:

Figure 3.48 – 当雪深为无穷大时的降雪总结统计信息

Figure 3.48 – 当雪深为无穷大时的降雪总结统计信息

我们正在处理一年的数据,但不知为何我们有 765 行数据,所以我们应该检查一下原因。我们尚未检查的唯一列是datestation列。我们可以使用describe()方法查看它们的总结统计信息:

>>> df.describe(include='object')

在 765 行数据中,date列只有 324 个唯一值(意味着某些日期缺失),有些日期出现了多达八次(station列,最常见的值为?,当我们之前使用head()查看时(Figure 3.45),我们知道那是另一个值;不过如果我们没有使用unique(),我们可以查看所有唯一值)。我们还知道?出现了 367 次(765 - 398),无需使用value_counts()

Figure 3.49 – 脏数据中非数值列的总结统计信息

Figure 3.49 – 脏数据中非数值列的总结统计信息

在实际操作中,我们可能不知道为什么有时站点被记录为?——这可能是故意的,表示他们没有该站点,或者是记录软件的错误,或者是由于意外遗漏导致被编码为?。我们如何处理这种情况将是一个判断问题,正如我们将在下一部分讨论的那样。

当我们看到有 765 行数据和两个不同的站点 ID 值时,我们可能会假设每天有两条记录——每个站点一条。然而,这样只能解释 730 行数据,而且我们现在也知道一些日期缺失了。我们来看看能否找到任何可能的重复数据来解释这个问题。我们可以使用duplicated()方法的结果作为布尔掩码来查找重复的行:

>>> df[df.duplicated()].shape[0]
284

根据我们试图实现的目标不同,我们可能会以不同的方式处理重复项。返回的行可以用keep参数进行修改。默认情况下,它是'first',对于每个出现超过一次的行,我们只会得到额外的行(除了第一个)。但是,如果我们传入keep=False,我们将得到所有出现超过一次的行,而不仅仅是每个额外的出现:

>>> df[df.duplicated(keep=False)].shape[0] 
482

还有一个subset参数(第一个位置参数),它允许我们只专注于某些列的重复项。使用这个参数,我们可以看到当datestation列重复时,其余数据也重复了,因为我们得到了与之前相同的结果。然而,我们不知道这是否真的是一个问题:

>>> df[df.duplicated(['date', 'station'])].shape[0]
284

现在,让我们检查一些重复的行:

>>> df[df.duplicated()].head()

只看前五行就能看到一些行至少重复了三次。请记住,duplicated()的默认行为是不显示第一次出现,这意味着第 1 行和第 2 行在数据中有另一个匹配值(第 5 行和第 6 行也是如此):

图 3.50 – 检查重复数据

图 3.50 – 检查重复数据

现在我们知道如何在我们的数据中找到问题了,让我们学习一些可以尝试解决它们的方法。请注意,这里没有万能药,通常都要了解我们正在处理的数据并做出判断。

缓解问题

我们的数据处于一个不理想的状态,尽管我们可以努力改善它,但最佳的行动计划并不总是明显的。也许当面对这类数据问题时,我们可以做的最简单的事情就是删除重复的行。然而,评估这样一个决定可能对我们的分析产生的后果至关重要。即使在看起来我们处理的数据是从一个有额外列的更大数据集中收集而来,从而使我们所有的数据都是不同的,我们也不能确定移除这些列是导致剩余数据重复的原因——我们需要查阅数据的来源和任何可用的文档。

由于我们知道两个站点都属于纽约市,我们可能决定删除station列——它们可能只是在收集不同的数据。如果我们然后决定使用date列删除重复行,并保留非?站点的数据,在重复的情况下,我们将失去所有关于WESF列的数据,因为?站点是唯一报告WESF测量值的站点:

>>> df[df.WESF.notna()].station.unique()
array(['?'], dtype=object)

在这种情况下,一个令人满意的解决方案可能是执行以下操作:

  1. date列进行类型转换:

    >>> df.date = pd.to_datetime(df.date)
    
  2. WESF列保存为一个系列:

    >>> station_qm_wesf = df[df.station == '?']\
    ...     .drop_duplicates('date').set_index('date').WESF
    
  3. station列降序排序数据框,以将没有 ID(?)的站点放在最后:

    >>> df.sort_values(
    ...     'station', ascending=False, inplace=True
    ... )
    
  4. 删除基于日期的重复行,保留首次出现的行,即那些station列有 ID 的行(如果该站点有测量数据)。需要注意的是,drop_duplicates()可以原地操作,但如果我们要做的事情比较复杂,最好不要一开始就使用原地操作:

    >>> df_deduped = df.drop_duplicates('date')
    
  5. 丢弃station列,并将索引设置为date列(这样它就能与WESF数据匹配):

    >>> df_deduped = df_deduped.drop(columns='station')\
    ...     .set_index('date').sort_index()
    
  6. 使用combine_first()方法更新WESF列为?。由于df_dedupedstation_qm_wesf都使用日期作为索引,因此值将正确匹配到相应的日期:

    >>> df_deduped = df_deduped.assign(WESF= 
    ...     lambda x: x.WESF.combine_first(station_qm_wesf)
    ... )
    

这可能听起来有些复杂,但主要是因为我们还没有学习聚合的内容。在第四章《聚合 Pandas DataFrame》中,我们将学习另一种方法来处理这个问题。让我们通过上述实现查看结果:

>>> df_deduped.shape
(324, 8)
>>> df_deduped.head()

现在,我们剩下 324 行——每行代表数据中的一个独特日期。我们通过将WESF列与其他站点的数据并排放置,成功保存了WESF列:

图 3.51 – 使用数据整理保持 WESF 列中的信息

图 3.51 – 使用数据整理保持 WESF 列中的信息

提示

我们还可以指定保留最后一项数据而不是第一项,或者使用keep参数丢弃所有重复项,就像我们使用duplicated()检查重复项时一样。记住,duplicated()方法可以帮助我们在去重任务中做干运行,以确认最终结果。

现在,让我们处理空值数据。我们可以选择丢弃空值、用任意值替换,或使用周围的数据进行填充。每个选项都有其影响。如果我们丢弃数据,我们的分析将只基于部分数据;如果我们丢弃了大部分行,这将对结果产生很大影响。更改数据值时,我们可能会影响分析的结果。

要删除包含任何空值的行(这不必在行的所有列中都为真,因此要小心),我们使用dropna()方法;在我们的例子中,这会留下 4 行数据:

>>> df_deduped.dropna().shape
(4, 8)

我们可以通过how参数更改默认行为,仅在所有列都为空时才删除行,但这样做并不会删除任何内容:

>>> df_deduped.dropna(how='all').shape # default is 'any'
(324, 8)

幸运的是,我们还可以使用部分列来确定需要丢弃的内容。假设我们想查看雪的数据;我们很可能希望确保数据中包含SNOWSNWDinclement_weather的值。这可以通过subset参数来实现:

>>> df_deduped.dropna(
...     how='all', subset=['inclement_weather', 'SNOW', 'SNWD']
... ).shape
(293, 8)

请注意,此操作也可以沿着列进行,并且我们可以通过thresh参数设置必须观察到的空值数量阈值,以决定是否丢弃数据。例如,如果我们要求至少 75%的行必须为空才能丢弃该列,那么我们将丢弃WESF列:

>>> df_deduped.dropna(
...     axis='columns', 
...     thresh=df_deduped.shape[0] * .75 # 75% of rows
... ).columns
Index(['PRCP', 'SNOW', 'SNWD', 'TMAX', 'TMIN', 'TOBS',
       'inclement_weather'],
      dtype='object')

由于我们有很多空值,我们可能更关心保留这些空值,并可能找到一种更好的方式来表示它们。如果我们替换空数据,必须在决定填充什么内容时谨慎;用其他值填充我们没有的数据可能会导致后续产生奇怪的结果,因此我们必须首先思考如何使用这些数据。

要用其他数据填充空值,我们使用fillna()方法,它允许我们指定一个值或填充策略。我们首先讨论用单一值填充的情况。WESF列大多是空值,但由于它是以毫升为单位的测量,当没有降雪的水当量时,它的值为NaN,因此我们可以用零来填充这些空值。请注意,这可以就地完成(同样,作为一般规则,我们应该谨慎使用就地操作):

>>> df_deduped.loc[:,'WESF'].fillna(0, inplace=True)
>>> df_deduped.head()

WESF列不再包含NaN值:

图 3.52 – 填充 WESF 列中的空值

图 3.52 – 填充 WESF 列中的空值

到目前为止,我们已经做了所有不扭曲数据的工作。我们知道缺少日期,但如果重新索引,我们不知道如何填充结果中的NaN值。对于天气数据,我们不能假设某天下雪了就意味着第二天也会下雪,或者温度会相同。因此,请注意,以下示例仅供说明用途——只是因为我们能做某件事,并不意味着我们应该这样做。正确的解决方案很可能取决于领域和我们希望解决的问题。

话虽如此,让我们试着解决一些剩余的温度数据问题。我们知道当TMAX表示太阳的温度时,必须是因为没有测量值,因此我们将其替换为NaN。对于TMIN,目前使用-40°C 作为占位符,尽管纽约市记录的最低温度是 1934 年 2 月 9 日的-15°F(-26.1°C)(www.weather.gov/media/okx/Climate/CentralPark/extremes.pdf),我们也会进行同样的处理:

>>> df_deduped = df_deduped.assign(
...     TMAX=lambda x: x.TMAX.replace(5505, np.nan), 
...     TMIN=lambda x: x.TMIN.replace(-40, np.nan) 
... )

我们还会假设温度在不同日期之间不会剧烈变化。请注意,这其实是一个很大的假设,但它将帮助我们理解当我们通过method参数提供策略时,fillna()方法如何工作:'ffill'向前填充或'bfill'向后填充。注意我们没有像在重新索引时那样使用'nearest'选项,这本来是最佳选择;因此,为了说明这种方法的工作原理,我们将使用向前填充:

>>> df_deduped.assign(
...     TMAX=lambda x: x.TMAX.fillna(method='ffill'),
...     TMIN=lambda x: x.TMIN.fillna(method='ffill')
... ).head()

查看 1 月 1 日和 4 日的TMAXTMIN列。1 日的值都是NaN,因为我们没有之前的数据可以向前填充,但 4 日现在的值和 3 日相同:

图 3.53 – 向前填充空值

图 3.53 – 向前填充空值

如果我们想处理SNWD列中的空值和无限值,我们可以使用np.nan_to_num()函数;它会将NaN转换为 0,将inf/-inf转换为非常大的正/负有限数字,从而使得机器学习模型(在第九章**, 用 Python 入门机器学习中讨论)可以从这些数据中学习:

>>> df_deduped.assign(
...     SNWD=lambda x: np.nan_to_num(x.SNWD)
... ).head()

然而,这对于我们的用例来说意义不大。对于-np.inf的情况,我们可以选择将SNWD设置为 0,因为我们已经看到这些天没有降雪。然而,我们不知道该如何处理np.inf,而且较大的正数无疑使得数据更难以解读:

图 3.54 – 替换无限值

图 3.54 – 替换无限值

根据我们处理的数据,我们可能选择使用clip()方法作为np.nan_to_num()函数的替代方法。clip()方法使得我们能够将值限制在特定的最小值和/或最大值阈值内。由于雪深不能为负值,我们可以使用clip()强制设定下限为零。为了展示上限如何工作,我们将使用降雪量(SNOW)作为估算值:

>>> df_deduped.assign(
...     SNWD=lambda x: x.SNWD.clip(0, x.SNOW)
... ).head()

1 月 1 日至 3 日的SNWD值现在为0,而不是-inf,1 月 4 日和 5 日的SNWD值从inf变为当日的SNOW值:

图 3.55 – 将值限制在阈值内

图 3.55 – 将值限制在阈值内

我们的最后一个策略是插补。当我们用从数据中得出的新值替代缺失值时,使用汇总统计数据或其他观测值的数据,这种方法叫做插补。例如,我们可以用平均值来替代温度值。不幸的是,如果我们仅在十月月底缺失数据,而我们用剩余月份的平均值替代,这可能会偏向极端值,在这个例子中是十月初的较高温度。就像本节中讨论的其他内容一样,我们必须小心谨慎,考虑我们的行为可能带来的任何后果或副作用。

我们可以将插补与fillna()方法结合使用。例如,我们可以用TMAXTMIN的中位数来填充NaN值,用TMINTMAX的平均值来填充TOBS(在插补后):

>>> df_deduped.assign(
...     TMAX=lambda x: x.TMAX.fillna(x.TMAX.median()),
...     TMIN=lambda x: x.TMIN.fillna(x.TMIN.median()),
...     # average of TMAX and TMIN
...     TOBS=lambda x: x.TOBS.fillna((x.TMAX + x.TMIN) / 2)
... ).head()

从 1 月 1 日和 4 日数据的变化可以看出,最大和最小温度的中位数分别是 14.4°C 和 5.6°C。这意味着,当我们插补TOBS且数据中没有TMAXTMIN时,我们得到 10°C:

图 3.56 – 使用汇总统计数据插补缺失值

图 3.56 – 使用汇总统计数据插补缺失值

如果我们想对所有列进行相同的计算,我们应该使用apply()方法,而不是assign(),因为这样可以避免为每一列重复写相同的计算。例如,让我们用滚动 7 天中位数填充所有缺失值,并将计算所需的周期数设置为零,以确保我们不会引入额外的空值。我们将在第四章《聚合 Pandas 数据框》中详细讲解滚动计算和apply()方法,所以这里只是一个预览:

>>> df_deduped.apply(lambda x:
...     # Rolling 7-day median (covered in chapter 4).
...     # we set min_periods (# of periods required for
...     # calculation) to 0 so we always get a result
...     x.fillna(x.rolling(7, min_periods=0).median())
... ).head(10)

这里很难看出我们的填补值在哪里——温度会随着每天的变化波动相当大。我们知道 1 月 4 日的数据缺失,正如我们之前的尝试所示;使用这种策略时,我们填补的温度比周围的温度要低。在实际情况下,那天的温度稍微温暖一些(大约-3°C):

图 3.57 – 使用滚动中位数填补缺失值

图 3.57 – 使用滚动中位数填补缺失值

重要提示

在进行填补时,必须小心谨慎。如果我们选择了错误的策略,可能会弄得一团糟。

另一种填补缺失数据的方法是让pandas使用interpolate()方法计算出这些值。默认情况下,它会执行线性插值,假设所有行是均匀间隔的。我们的数据是每日数据,虽然有些天的数据缺失,因此只需要先重新索引即可。我们可以将其与apply()方法结合,来一次性插值所有列:

>>> df_deduped.reindex(
...     pd.date_range('2018-01-01', '2018-12-31', freq='D')
... ).apply(lambda x: x.interpolate()).head(10)

看看 1 月 9 日,这是我们之前没有的数据——TMAXTMINTOBS的值是前一天(1 月 8 日)和后一天(1 月 10 日)的平均值:

图 3.58 – 插值缺失值

图 3.58 – 插值缺失值

可以通过method参数指定不同的插值策略;务必查看interpolate()方法的文档,了解可用的选项。

摘要

恭喜你完成了本章!数据整理可能不是分析流程中最令人兴奋的部分,但我们将花费大量时间在这上面,因此最好熟悉pandas提供的功能。

在本章中,我们进一步了解了数据整理的概念(不仅仅是数据科学的流行术语),并亲身体验了如何清洗和重塑数据。通过使用requests库,我们再次练习了如何使用 API 提取感兴趣的数据;然后,我们使用pandas开始了数据整理的介绍,下一章我们将继续深入探讨这一主题。最后,我们学习了如何处理重复、缺失和无效的数据点,并讨论了这些决策的后果。

基于这些概念,在下一章中,我们将学习如何聚合数据框并处理时间序列数据。在继续之前,请务必完成本章末的练习。

练习

使用我们到目前为止在本书中学到的知识和exercises/目录中的数据完成以下练习:

  1. 我们希望查看我们将在第七章中构建的stock_analysis包的数据,金融分析 - 比特币与股票市场)。将它们合并成一个文件,并将 FAANG 数据的数据框存储为faang,以便进行接下来的练习:

    a) 读取aapl.csvamzn.csvfb.csvgoog.csvnflx.csv文件。

    b) 向每个数据框中添加一个名为ticker的列,表示它对应的股票代码(例如,苹果的股票代码是 AAPL);这是查询股票的方式。在这个例子中,文件名恰好是股票代码。

    c) 将它们合并成一个单一的数据框。

    d) 将结果保存为名为faang.csv的 CSV 文件。

  2. 使用faang,通过类型转换将date列的值转换为日期时间格式,并将volume列的值转换为整数。然后,按dateticker进行排序。

  3. 找出faangvolume值最小的七行数据。

  4. 现在,数据介于长格式和宽格式之间。使用melt()将其完全转化为长格式。提示:dateticker是我们的 ID 变量(它们唯一标识每一行)。我们需要将其他列进行转化,以避免有单独的openhighlowclosevolume列。

  5. 假设我们发现 2018 年 7 月 26 日数据记录出现了故障。我们应该如何处理这个问题?注意,此练习不需要编写代码。

  6. covid19_cases.csv文件。

    b) 使用dateRep列中的数据和pd.to_datetime()函数创建一个date列。

    c) 将date列设置为索引并对索引进行排序。

    d) 将所有出现的United_States_of_AmericaUnited_Kingdom分别替换为USAUK。提示:replace()方法可以在整个数据框上运行。

    e) 使用countriesAndTerritories列,将清洗后的 COVID-19 数据筛选为阿根廷、巴西、中国、哥伦比亚、印度、意大利、墨西哥、秘鲁、俄罗斯、西班牙、土耳其、英国和美国的数据。

    f) 将数据透视,使索引包含日期,列包含国家名称,值为病例数(即cases列)。确保将NaN值填充为0

  7. 为了高效地确定每个国家的病例总数,我们需要在第四章中学习到的聚合技能,即聚合 Pandas 数据框,因此 ECDC 数据在 covid19_cases.csv 文件中已为我们进行了聚合,并保存在 covid19_total_cases.csv 文件中。该文件包含每个国家的病例总数。使用这些数据查找 COVID-19 病例总数最多的 20 个国家。提示:在读取 CSV 文件时,传入 index_col='cases',并注意,在提取国家信息之前,先转置数据将会很有帮助。

深入阅读

查看以下资源,了解更多关于本章涵盖的主题信息:

第五章:第四章:聚合 Pandas DataFrames

在本章中,我们将继续探讨第三章中的数据清理内容,使用 Pandas 进行数据清理,通过讨论数据的丰富和聚合。包括一些重要技能,如合并数据框、创建新列、执行窗口计算以及按组进行聚合。计算聚合和总结有助于我们从数据中得出结论。

我们还将探讨pandas在处理时间序列数据时的额外功能,超出我们在前几章中介绍的时间序列切片内容,包括如何通过聚合对数据进行汇总,以及如何根据一天中的时间选择数据。我们将遇到的大部分数据都是时间序列数据,因此有效地处理时间序列数据至关重要。当然,高效地执行这些操作也很重要,因此我们还将回顾如何编写高效的pandas代码。

本章将帮助我们熟练使用DataFrame对象进行分析。因此,这些主题相比之前的内容更为高级,可能需要反复阅读几遍,所以请确保跟随带有额外示例的笔记本进行学习。

本章将涵盖以下主题:

  • DataFrame上执行类似数据库的操作

  • 使用DataFrame操作来丰富数据

  • 聚合数据

  • 处理时间序列数据

本章资料

本章的资料可以在 GitHub 上找到,网址为github.com/stefmolin/Hands-On-Data-Analysis-with-Pandas-2nd-edition/tree/master/ch_04。我们将通过四个笔记本来学习,每个笔记本按使用顺序编号,文本会提示你切换。我们将从1-querying_and_merging.ipynb笔记本开始,学习如何查询和合并数据框。然后,我们将进入2-dataframe_operations.ipynb笔记本,讨论通过操作如分箱、窗口函数和管道来丰富数据。在这一部分,我们还将使用window_calc.py Python 文件,该文件包含一个使用管道进行窗口计算的函数。

提示

understanding_window_calculations.ipynb笔记本包含一些交互式可视化,用于帮助理解窗口函数。可能需要一些额外的设置,但笔记本中有说明。

接下来,在3-aggregations.ipynb笔记本中,我们将讨论聚合、透视表和交叉表。最后,我们将重点介绍pandas在处理时间序列数据时提供的额外功能,这些内容将在4-time_series.ipynb笔记本中讨论。请注意,我们不会讲解0-weather_data_collection.ipynb笔记本;然而,对于有兴趣的人,它包含了从国家环境信息中心NCEI)API 收集数据的代码,API 链接可见于www.ncdc.noaa.gov/cdo-web/webservices/v2

在本章中,我们将使用多种数据集,所有数据集均可以在data/目录中找到:

图 4.1 – 本章使用的数据集

图 4.1 – 本章使用的数据集

请注意,exercises/目录包含了完成本章末尾练习所需的 CSV 文件。更多关于这些数据集的信息可以在exercises/README.md文件中找到。

在 DataFrame 上执行数据库风格的操作

DataFrame对象类似于数据库中的表:每个对象都有一个我们用来引用它的名称,由行组成,并包含特定数据类型的列。因此,pandas允许我们在其上执行数据库风格的操作。传统上,数据库支持至少四种操作,称为CRUDCreate(创建)、Read(读取)、Update(更新)和Delete(删除)。

一种数据库查询语言——在本节中将讨论的大多数是pandas操作,它可能有助于熟悉 SQL 的人理解。许多数据专业人员都对基础的 SQL 有所了解,因此请参考进一步阅读部分以获取提供更正式介绍的资源。

在本节中,我们将在1-querying_and_merging.ipynb笔记本中进行操作。我们将从导入库并读取纽约市天气数据的 CSV 文件开始:

>>> import pandas as pd
>>> weather = pd.read_csv('data/nyc_weather_2018.csv')
>>> weather.head()

这是长格式数据——我们有多个不同的天气观测数据,涵盖了 2018 年纽约市各个站点的每日数据:

图 4.2 – 纽约市天气数据

图 4.2 – 纽约市天气数据

第二章使用 Pandas DataFrame 中,我们介绍了如何创建 DataFrame;这相当于 SQL 中的 "CREATE TABLE ..." 语句。当我们在 第二章使用 Pandas DataFrame第三章使用 Pandas 进行数据清洗 中讨论选择和过滤时,我们主要关注的是从 DataFrame 中读取数据,这等同于 SQL 中的 SELECT(选择列)和 WHERE(按布尔条件过滤)子句。我们在讨论处理缺失数据时执行了更新(SQL 中的 UPDATE)和删除(SQL 中的 DELETE FROM)操作,这些内容出现在 第三章使用 Pandas 进行数据清洗 中。除了这些基本的 CRUD 操作外,本节还介绍了 pandas 在实现查询 DataFrame 对象方面的概念。

查询 DataFrame

Pandas 提供了 query() 方法,使我们能够轻松编写复杂的过滤器,而无需使用布尔掩码。其语法类似于 SQL 语句中的 WHERE 子句。为了说明这一点,让我们查询所有 SNOW 列值大于零且站点 ID 包含 US1NY 的站点的天气数据:

>>> snow_data = weather.query(
...     'datatype == "SNOW" and value > 0 '
...     'and station.str.contains("US1NY")'
... ) 
>>> snow_data.head()

每一行都是某个日期和站点组合下的雪观测数据。注意,1 月 4 日的数据差异很大——有些站点的降雪量比其他站点多:

图 4.3 – 查询雪的天气数据观测值

图 4.3 – 查询雪的天气数据观测值

这个查询在 SQL 中等价于以下语句。注意,SELECT * 会选择表中的所有列(在这里是我们的 DataFrame):

SELECT * FROM weather
WHERE
  datatype == "SNOW" AND value > 0 AND station LIKE "%US1NY%";

第二章使用 Pandas DataFrame 中,我们学习了如何使用布尔掩码得到相同的结果:

>>> weather[
...     (weather.datatype == 'SNOW') & (weather.value > 0)
...     & weather.station.str.contains('US1NY')
... ].equals(snow_data)
True

大部分情况下,我们选择使用哪种方式主要取决于个人偏好;然而,如果我们的 DataFrame 名称很长,我们可能会更喜欢使用 query() 方法。在前面的例子中,我们不得不额外输入三次 DataFrame 的名称来使用掩码。

提示

在使用 query() 方法时,我们可以使用布尔逻辑操作符(andornot)和按位操作符(&|~)。

合并 DataFrame

当我们在 第二章使用 Pandas DataFrame 中讨论通过 pd.concat() 函数和 append() 方法将 DataFrame 堆叠在一起时,我们实际上在执行 SQL 中的 UNION ALL 语句(如果删除重复项,我们就是在执行 UNION,正如我们在 第三章使用 Pandas 进行数据清洗 中所看到的那样)。合并 DataFrame 涉及如何按行将它们对齐。

在数据库中,合并通常被称为连接。连接有四种类型:全连接(外连接)、左连接、右连接和内连接。这些连接类型告诉我们,如何根据连接两边只有一方有的值来影响结果。这是一个更容易通过图示来理解的概念,所以我们来看一些维恩图,并对天气数据进行一些示例连接。在这里,较深的区域表示我们在执行连接后留下的数据:

图 4.4 – 理解连接类型

图 4.4 – 理解连接类型

我们一直在处理来自众多气象站的数据,但除了它们的 ID 外,我们对这些站点一无所知。如果能够了解每个气象站的具体位置,将有助于更好地理解同一天纽约市天气读数之间的差异。当我们查询雪量数据时,我们看到 1 月 4 日的读数存在相当大的变化(见图 4.3)。这很可能是由于气象站的位置不同。位于更高海拔或更北方的站点可能会记录更多的降雪。根据它们与纽约市的距离,它们可能正经历某个地方的暴风雪,例如康涅狄格州或北新泽西。

NCEI API 的stations端点提供了我们所需的所有气象站信息。这些信息存储在weather_stations.csv文件中,并且也存在于 SQLite 数据库中的stations表中。我们可以将这些数据读取到一个数据框中:

>>> station_info = pd.read_csv('data/weather_stations.csv')
>>> station_info.head()

供参考,纽约市中央公园的坐标是 40.7829° N, 73.9654° W(纬度 40.7829, 经度-73.9654),纽约市的海拔为 10 米。记录纽约市数据的前五个站点不在纽约州。这些位于新泽西州的站点在纽约市的西南,而位于康涅狄格州的站点则位于纽约市的东北:

图 4.5 – 气象站数据集

图 4.5 – 气象站数据集

连接要求我们指定如何匹配数据。weather数据框架与station_info数据框架唯一共有的数据是气象站 ID。然而,包含这些信息的列名并不相同:在weather数据框架中,这一列被称为station,而在station_info数据框架中,它被称为id。在我们进行连接之前,先获取一些关于有多少个不同气象站以及每个数据框架中有多少条记录的信息:

>>> station_info.id.describe()
count                   279
unique                  279
top       GHCND:US1NJBG0029
freq                      1
Name: id, dtype: object
>>> weather.station.describe()
count                 78780
unique                  110
top       GHCND:USW00094789
freq                   4270
Name: station, dtype: object

数据框架中唯一站点数量的差异告诉我们,它们并不包含完全相同的站点。根据我们选择的连接类型,我们可能会丢失一些数据。因此,在连接前后查看行数是很重要的。我们可以在describe()中查看这一点,但不需要仅仅为了获取行数而运行它。相反,我们可以使用shape属性,它会返回一个元组,格式为(行数,列数)。要选择行,我们只需获取索引为0的值(列数为1):

>>> station_info.shape[0], weather.shape[0] # 0=rows, 1=cols
(279, 78780)

由于我们将频繁检查行数,所以编写一个函数来为任意数量的数据框提供行数更为合适。*dfs参数将所有输入收集成一个元组,我们可以通过列表推导式遍历这个元组来获取行数:

>>> def get_row_count(*dfs):
...     return [df.shape[0] for df in dfs]
>>> get_row_count(station_info, weather)
[279, 78780]

现在我们知道,天气数据有 78,780 行,站点信息数据有 279 行,我们可以开始查看不同类型的连接。我们将从内连接开始,内连接会产生最少的行数(除非两个数据框在连接列上有完全相同的值,在这种情况下,所有的连接结果都是等价的)。在weather.station列和station_info.id列上连接,我们将只获得station_info中存在的站点的天气数据。

我们将使用merge()方法来执行连接(默认是内连接),通过提供左右数据框,并指定要连接的列名。由于站点 ID 列在不同数据框中命名不同,我们必须使用left_onright_on来指定列名。左侧数据框是我们调用merge()的方法所在的数据框,而右侧数据框是作为参数传递进来的:

>>> inner_join = weather.merge(
...     station_info, left_on='station', right_on='id'
... )
>>> inner_join.sample(5, random_state=0)

请注意,我们有五个额外的列,它们被添加到了右侧。这些列来自station_info数据框。这个操作也保留了stationid列,它们是完全相同的:

图 4.6 – 天气数据集和站点数据集的内连接结果

图 4.6 – 天气数据集和站点数据集的内连接结果

为了去除stationid列中的重复信息,我们可以在连接前重命名其中一列。因此,我们只需要为on参数提供一个值,因为这两列将共享相同的名称:

>>> weather.merge(
...     station_info.rename(dict(id='station'), axis=1), 
...     on='station'
... ).sample(5, random_state=0)

由于这两列共享相同的名称,所以在连接时我们只会得到一列数据:

图 4.7 – 匹配连接列的名称以防止结果中的重复数据

图 4.7 – 匹配连接列的名称以防止结果中的重复数据

提示

我们可以通过将列名列表传递给on参数,或者传递给left_onright_on参数来进行多列连接。

请记住,我们在station_info数据框中有 279 个唯一的站点,但在天气数据中只有 110 个唯一的站点。当我们执行内连接时,所有没有天气观测数据的站点都丢失了。如果我们不想丢失某一侧的数据框的行,可以改为执行左连接或右连接。左连接要求我们将希望保留的行所在的数据框(即使它们在另一个数据框中不存在)放在左侧,将另一个数据框放在右侧;右连接则是相反的操作:

>>> left_join = station_info.merge(
...     weather, left_on='id', right_on='station', how='left'
... )
>>> right_join = weather.merge(
...     station_info, left_on='station', right_on='id',
...     how='right'
... )
>>> right_join[right_join.datatype.isna()].head() # see nulls

在另一个数据框没有数据的地方,我们会得到 null 值。我们可能需要调查为什么这些站点没有关联的天气数据。或者,我们的分析可能涉及确定每个站点的数据可用性,所以获得 null 值不一定是个问题:

图 4.8 – 不使用内连接时可能引入 null 值

图 4.8 – 不使用内连接时可能引入 null 值

因为我们将station_info数据框放在左边用于左连接,右边用于右连接,所以这里的结果是等效的。在两种情况下,我们都选择保留station_info数据框中所有的站点,并接受天气观测值为 null。为了证明它们是等效的,我们需要将列按相同顺序排列,重置索引,并排序数据:

>>> left_join.sort_index(axis=1)\
...     .sort_values(['date', 'station'], ignore_index=True)\
...     .equals(right_join.sort_index(axis=1).sort_values(
...         ['date', 'station'], ignore_index=True
...     ))
True

请注意,在左连接和右连接中我们有额外的行,因为我们保留了所有没有天气观测值的站点:

>>> get_row_count(inner_join, left_join, right_join)
[78780, 78949, 78949]

最后一种连接类型是US1NY作为站点 ID,因为我们认为测量 NYC 天气的站点必须标注为此。这意味着,内连接会丢失来自康涅狄格州和新泽西州站点的观测数据,而左连接或右连接则可能导致站点信息或天气数据丢失。外连接将保留所有数据。我们还会传入indicator=True,为结果数据框添加一列,指示每一行数据来自哪个数据框:

>>> outer_join = weather.merge(
...     station_info[station_info.id.str.contains('US1NY')], 
...     left_on='station', right_on='id',
...     how='outer', indicator=True
... )
# view effect of outer join
>>> pd.concat([
...     outer_join.query(f'_merge == "{kind}"')\
...         .sample(2, random_state=0)
...     for kind in outer_join._merge.unique()
... ]).sort_index()

站点 ID 中的US1NY导致了站点信息列为 null。底部的两行是来自纽约的站点,但没有提供 NYC 的天气观测数据。这个连接保留了所有数据,通常会引入 null 值,不同于内连接,内连接不会引入 null 值:

图 4.9 – 外连接保留所有数据

图 4.9 – 外连接保留所有数据

前述的连接等同于 SQL 语句,形式如下,其中我们只需将<JOIN_TYPE>替换为(INNER) JOINLEFT JOINRIGHT JOINFULL OUTER JOIN,以适应所需的连接类型:

SELECT *
FROM left_table
<JOIN_TYPE> right_table
ON left_table.<col> == right_table.<col>;

连接数据框使得处理第三章中脏数据变得更容易,Pandas 数据清洗也因此变得更简单。记住,我们有来自两个不同站点的数据:一个有有效的站点 ID,另一个是??站点是唯一一个记录雪的水当量(WESF)的站点。现在我们了解了连接数据框的方式,我们可以通过日期将有效站点 ID 的数据与我们缺失的?站点的数据连接起来。首先,我们需要读取 CSV 文件,并将date列设为索引。然后,我们将删除重复数据和SNWD列(雪深),因为我们发现SNWD列在大多数情况下没有提供有用信息(无论是有雪还是没有雪,值都是无限大):

>>> dirty_data = pd.read_csv(
...     'data/dirty_data.csv', index_col='date'
... ).drop_duplicates().drop(columns='SNWD')
>>> dirty_data.head()

我们的起始数据看起来是这样的:

图 4.10 – 上一章中的脏数据

图 4.10 – 上一章中的脏数据

现在,我们需要为每个站点创建一个数据框。为了减少输出,我们将删除一些额外的列:

>>> valid_station = dirty_data.query('station != "?"')\
...     .drop(columns=['WESF', 'station'])
>>> station_with_wesf = dirty_data.query('station == "?"')\
...     .drop(columns=['station', 'TOBS', 'TMIN', 'TMAX'])

这次,我们要连接的列(日期)实际上是索引,因此我们将传入left_index来表示左侧数据框要使用的列是索引,接着传入right_index来表示右侧数据框的相应列也是索引。我们将执行左连接,确保不会丢失任何有效站点的行,并且在可能的情况下,用?站点的观测数据补充它们:

>>> valid_station.merge(
...     station_with_wesf, how='left',
...     left_index=True, right_index=True
... ).query('WESF > 0').head()

对于数据框中共有的所有列,但未参与连接的列,现在我们有了两个版本。来自左侧数据框的版本在列名后添加了_x后缀,来自右侧数据框的版本则在列名后添加了_y后缀:

图 4.11 – 来自不同站点的天气数据合并

图 4.11 – 来自不同站点的天气数据合并

我们可以通过suffixes参数提供自定义的后缀。让我们只为?站点使用一个后缀:

>>> valid_station.merge(
...     station_with_wesf, how='left',
...     left_index=True, right_index=True, 
...     suffixes=('', '_?')
... ).query('WESF > 0').head()

由于我们为左侧后缀指定了空字符串,来自左侧数据框的列保持其原始名称。然而,右侧后缀_?被添加到了来自右侧数据框的列名中:

图 4.12 – 为不参与连接的共享列指定后缀

图 4.12 – 为不参与连接的共享列指定后缀

当我们在索引上进行连接时,一种更简单的方法是使用join()方法,而不是merge()。它也默认为内连接,但可以通过how参数更改此行为,就像merge()一样。join()方法将始终使用左侧数据框的索引进行连接,但如果传递右侧数据框的列名给on参数,它也可以使用右侧数据框中的列。需要注意的是,后缀现在通过lsuffix指定左侧数据框的后缀,rsuffix指定右侧数据框的后缀。这将产生与之前示例相同的结果(图 4.12):

>>> valid_station.join(
...     station_with_wesf, how='left', rsuffix='_?'
... ).query('WESF > 0').head()

需要记住的一件重要事情是,连接操作可能相当消耗资源,因此在执行连接之前,弄清楚行会发生什么通常是有益的。如果我们还不知道想要哪种类型的连接,使用这种方式可以帮助我们得到一些思路。我们可以在计划连接的索引上使用集合操作来弄清楚这一点。

请记住,集合的数学定义是不同对象的集合。按照定义,索引是一个集合。集合操作通常通过维恩图来解释:

图 4.13 – 集合操作

图 4.13 – 集合操作

重要提示

请注意,set也是 Python 标准库中可用的一种类型。集合的一个常见用途是去除列表中的重复项。有关 Python 中集合的更多信息,请参见文档:docs.python.org/3/library/stdtypes.html#set-types-set-frozenset

让我们使用weatherstation_info数据框来说明集合操作。首先,我们必须将索引设置为用于连接操作的列:

>>> weather.set_index('station', inplace=True)
>>> station_info.set_index('id', inplace=True)

要查看内连接后将保留的内容,我们可以取索引的交集,这将显示我们重叠的站点:

>>> weather.index.intersection(station_info.index)
Index(['GHCND:US1CTFR0039', ..., 'GHCND:USW1NYQN0029'],
      dtype='object', length=110)

正如我们在执行内连接时看到的那样,我们只得到了有天气观测的站点信息。但这并没有告诉我们丢失了什么;为此,我们需要找到集合差异,即减去两个集合,得到第一个索引中不在第二个索引中的值。通过集合差异,我们可以轻松看到,在执行内连接时,我们并没有丢失天气数据中的任何行,但我们丢失了 169 个没有天气观测的站点:

>>> weather.index.difference(station_info.index)
Index([], dtype='object')
>>> station_info.index.difference(weather.index)
Index(['GHCND:US1CTFR0022', ..., 'GHCND:USW00014786'],
      dtype='object', length=169)

请注意,这个输出还告诉我们左连接和右连接的结果如何。为了避免丢失行,我们希望将station_info数据框放在连接的同一侧(左连接时在左边,右连接时在右边)。

提示

我们可以使用symmetric_difference()方法对参与连接的数据框的索引进行操作,查看从两侧丢失的内容:index_1.symmetric_difference(index_2)。结果将是仅在其中一个索引中存在的值。笔记本中有一个示例。

最后,我们可以使用weather数据框,它包含了重复出现的站点信息,因为这些站点提供每日测量值,所以在进行并集操作之前,我们会调用unique()方法查看我们将保留的站点数量:

>>> weather.index.unique().union(station_info.index)
Index(['GHCND:US1CTFR0022', ..., 'GHCND:USW00094789'],
      dtype='object', length=279)

本章最后的进一步阅读部分包含了一些关于集合操作的资源,以及pandas与 SQL 的对比。目前,让我们继续进行数据丰富化操作。

使用 DataFrame 操作来丰富数据

现在我们已经讨论了如何查询和合并DataFrame对象,接下来让我们学习如何在这些对象上执行复杂操作,创建和修改列和行。在本节中,我们将在2-dataframe_operations.ipynb笔记本中使用天气数据,以及 2018 年 Facebook 股票的交易量和每日开盘价、最高价、最低价和收盘价。让我们导入所需的库并读取数据:

>>> import numpy as np
>>> import pandas as pd
>>> weather = pd.read_csv(
...     'data/nyc_weather_2018.csv', parse_dates=['date']
... )
>>> fb = pd.read_csv(
...     'data/fb_2018.csv', index_col='date', parse_dates=True
... )

我们将首先回顾总结整行和整列的操作,然后再学习分箱、在行和列上应用函数,以及窗口计算,这些操作是在一定数量的观测值上对数据进行汇总(例如,移动平均)。

算术和统计

Pandas 提供了几种计算统计数据和执行数学运算的方法,包括比较、整除和取模运算。这些方法使我们在定义计算时更加灵活,允许我们指定在哪个轴上执行计算(当对DataFrame对象进行操作时)。默认情况下,计算将在列上执行(axis=1axis='columns'),列通常包含单一变量的单一数据类型的观测值;但是,我们也可以传入axis=0axis='index'来沿着行执行计算。

在本节中,我们将使用这些方法中的一些来创建新列并修改数据,看看如何利用新数据得出一些初步结论。完整的列表可以在pandas.pydata.org/pandas-docs/stable/reference/series.html#binary-operator-functions找到。

首先,让我们创建一列 Facebook 股票交易量的 Z 分数,并利用它找出 Z 分数绝对值大于三的日期。这些值距离均值超过三倍标准差,可能是异常值(具体取决于数据)。回想一下我们在第一章《数据分析导论》中讨论的 Z 分数,我们通过减去均值并除以标准差来计算 Z 分数。我们将不使用减法和除法的数学运算符,而是分别使用sub()div()方法:

>>> fb.assign(
...     abs_z_score_volume=lambda x: x.volume \
...         .sub(x.volume.mean()).div(x.volume.std()).abs()
... ).query('abs_z_score_volume > 3')

2018 年有五天的交易量 Z 分数绝对值大于三。这些日期将会在本章的后续内容中频繁出现,因为它们标志着 Facebook 股价的一些问题点:

图 4.14 – 添加 Z 分数列

图 4.14 – 添加 Z 分数列

另外两个非常有用的方法是rank()pct_change(),它们分别用于对列的值进行排名(并将排名存储在新列中)和计算不同时间段之间的百分比变化。通过将这两者结合使用,我们可以看到 Facebook 股票在前一天与五天内交易量变化百分比最大的一天:

>>> fb.assign(
...     volume_pct_change=fb.volume.pct_change(),
...     pct_change_rank=lambda x: \
...         x.volume_pct_change.abs().rank(ascending=False)
... ).nsmallest(5, 'pct_change_rank')

交易量变化百分比最大的一天是 2018 年 1 月 12 日,这恰好与 2018 年震撼股市的多个 Facebook 丑闻之一重合(www.cnbc.com/2018/11/20/facebooks-scandals-in-2018-effect-on-stock.html)。当时 Facebook 公布了新闻源的变化,优先显示来自用户朋友的内容,而非他们所关注的品牌。考虑到 Facebook 的收入大部分来自广告(2017 年约为 89%,来源www.investopedia.com/ask/answers/120114/how-does-facebook-fb-make-money.asp),这引发了恐慌,许多人抛售股票,导致交易量大幅上升,并使股票价格下跌:

图 4.15 – 按交易量变化百分比对交易日进行排名

图 4.15 – 按交易量变化百分比对交易日进行排名

我们可以使用切片方法查看这一公告前后的变化:

>>> fb['2018-01-11':'2018-01-12']

请注意,我们如何能够将前几章所学的所有内容结合起来,从数据中获取有趣的见解。我们能够筛选出一整年的股票数据,并找到一些对 Facebook 股票产生巨大影响的日期(无论是好是坏):

图 4.16 – 公布新闻源变化前后 Facebook 股票数据

图 4.16 – 公布新闻源变化前后 Facebook 股票数据

最后,我们可以使用聚合布尔操作来检查数据框。例如,我们可以使用any()方法看到 Facebook 股票在 2018 年内从未有过低于 $215 的日最低价:

>>> (fb > 215).any()
open          True
high          True
low          False
close         True
volume        True
dtype: bool

如果我们想查看某列中的所有行是否符合标准,可以使用all()方法。该方法告诉我们,Facebook 至少有一天的开盘价、最高价、最低价和收盘价小于或等于 $215:

>>> (fb > 215).all()
open      False
high      False
low       False
close     False
volume     True
dtype: bool

现在,让我们看看如何使用分箱法来划分数据,而不是使用具体的数值,例如在any()all()示例中的 $215。

分箱法

有时候,使用类别而不是具体数值进行分析更加方便。一个常见的例子是年龄分析——通常我们不想查看每个年龄的数据,比如 25 岁和 26 岁之间的差异;然而,我们很可能会对 25-34 岁组与 35-44 岁组之间的比较感兴趣。这就是所谓的分箱离散化(从连续数据转为离散数据);我们将数据按照其所属的范围放入不同的箱(或桶)中。通过这样做,我们可以大幅减少数据中的不同数值,并使分析变得更容易。

重要提示

虽然对数据进行分箱可以使某些分析部分变得更简单,但请记住,它会减少该字段中的信息,因为粒度被降低了。

我们可以做的一件有趣的事情是观察哪些日期的交易量较高,并查看这些日期是否有关于 Facebook 的新闻,或者是否有股价的大幅波动。不幸的是,几乎不可能有两天的交易量是相同的;事实上,我们可以确认,在数据中,没有两天的交易量是相同的:

>>> (fb.volume.value_counts() > 1).sum()
0

请记住,fb.volume.value_counts()会告诉我们每个唯一volume值的出现次数。然后,我们可以创建一个布尔掩码,判断该次数是否大于 1,并对其进行求和(True会被计算为1False则为0)。另外,我们也可以使用any()代替sum(),这样做会告诉我们,如果至少有一个交易量发生了多次,返回True,否则返回False,而不是告诉我们有多少个唯一的volume值出现超过一次。

显然,我们需要为交易量创建一些区间,以便查看高交易量的日期,但我们如何决定哪个区间是合适的呢?一种方法是使用pd.cut()函数基于数值进行分箱。首先,我们应该决定要创建多少个区间——三个位数似乎是一个好的分割,因为我们可以将这些区间分别标记为低、中和高。接下来,我们需要确定每个区间的宽度;pandas会尽可能简化这个过程,所以如果我们想要等宽的区间,只需要指定我们想要的区间数(否则,我们必须指定每个区间的上限作为列表):

>>> volume_binned = pd.cut(
...     fb.volume, bins=3, labels=['low', 'med', 'high']
... )
>>> volume_binned.value_counts()
low     240
med       8
high      3
Name: volume, dtype: int64

提示

请注意,我们在这里为每个区间提供了标签;如果我们不这样做,系统会根据包含的数值区间为每个区间标记标签,这可能对我们有用,也可能没有用,取决于我们的应用。如果我们想同时标记区间的值并在后续查看区间,可以在调用pd.cut()时传入retbins=True。然后,我们可以通过返回的元组的第一个元素访问分箱数据,第二个元素则是区间范围。

看起来绝大多数交易日都属于低交易量区间;请记住,这一切都是相对的,因为我们将最小和最大交易量之间的范围进行了均匀分割。现在让我们看一下这三天高交易量的交易数据:

>>> fb[volume_binned == 'high']\
...     .sort_values('volume', ascending=False)

即使在高交易量的日子里,我们也能看到 2018 年 7 月 26 日的交易量远高于 3 月的其他两个日期(交易量增加了近 4000 万股):

图 4.17 – 高交易量区间内的 Facebook 股票数据

图 4.17 – 高交易量区间内的 Facebook 股票数据

实际上,通过搜索引擎查询 Facebook 股票价格 2018 年 7 月 26 日 可以发现,Facebook 在 7 月 25 日股市收盘后宣布了其收益和令人失望的用户增长,随后发生了大量盘后抛售。当第二天股市开盘时,股票从 25 日收盘的 217.50跌至26日开盘时的217.50 跌至 26 日开盘时的 174.89。让我们提取这些数据:

>>> fb['2018-07-25':'2018-07-26']

不仅股票价格大幅下跌,而且交易量也飙升,增加了超过 1 亿股。所有这些导致了 Facebook 市值约 1200 亿美元的损失(www.marketwatch.com/story/facebook-stock-crushed-after-revenue-user-growth-miss-2018-07-25):

图 4.18 – 2018 年 Facebook 股票数据,直至最高交易量那天

图 4.18 – 2018 年 Facebook 股票数据,直至最高交易量那天

如果我们查看另外两天被标记为高交易量的交易日,会发现有大量的信息说明原因。这两天都与 Facebook 的丑闻有关。剑桥分析公司(Cambridge Analytica)的政治数据隐私丑闻于 2018 年 3 月 17 日星期六爆发,因此与该信息相关的交易直到 3 月 19 日星期一才开始(www.nytimes.com/2018/03/19/technology/facebook-cambridge-analytica-explained.html):

>>> fb['2018-03-16':'2018-03-20']

一旦在接下来的几天里披露了关于事件严重性的更多信息,情况变得更糟:

图 4.19 – 剑桥分析丑闻爆发时的 Facebook 股票数据

图 4.19 – 剑桥分析丑闻爆发时的 Facebook 股票数据

至于第三个高交易量的交易日(2018 年 3 月 26 日),美国联邦贸易委员会(FTC)启动了对剑桥分析丑闻的调查,因此 Facebook 的困境持续下去(www.cnbc.com/2018/03/26/ftc-confirms-facebook-data-breach-investigation.html)。

如果我们查看中等交易量组内的一些日期,会发现许多都属于我们刚刚讨论的三个交易事件。这迫使我们重新审视最初创建区间的方式。也许等宽区间并不是答案?大多数日期的交易量相对接近;然而,少数几天导致区间宽度较大,这使得每个区间内的日期数量不均衡:

图 4.20 – 可视化等宽区间

图 4.20 – 可视化等宽区间

如果我们希望每个区间有相同数量的观测值,可以使用 pd.qcut() 函数基于均匀间隔的分位数来划分区间。我们可以将交易量划分为四分位数,从而将观测值均匀分配到宽度不同的区间中,得到q4区间中的 63 个最高交易量的天数:

>>> volume_qbinned = pd.qcut(
...     fb.volume, q=4, labels=['q1', 'q2', 'q3', 'q4']
... )
>>> volume_qbinned.value_counts()
q1    63
q2    63
q4    63
q3    62
Name: volume, dtype: int64

请注意,这些区间现在不再覆盖相同的交易量范围:

图 4.21 – 基于四分位数可视化区间

](tos-cn-i-73owjymdk6/c39afadc2d2e46b4acdba65350e94ffa)

图 4.21 – 基于四分位数可视化区间

小贴士

在这两个例子中,我们让 pandas 计算区间范围;然而,pd.cut()pd.qcut() 都允许我们将每个区间的上界指定为列表。

应用函数

到目前为止,我们对数据进行的大多数操作都是针对单独列进行的。当我们希望在数据框架的所有列上运行相同的代码时,可以使用 apply() 方法,使代码更加简洁。请注意,这个操作不会就地执行。

在开始之前,让我们先隔离中央公园站的天气观测数据,并将数据透视:

>>> central_park_weather = weather.query(
...     'station == "GHCND:USW00094728"'
... ).pivot(index='date', columns='datatype', values='value')

让我们计算 2018 年 10 月中央公园的TMIN(最低气温)、TMAX(最高气温)和PRCP(降水量)观测值的 Z 分数。重要的是,我们不要试图跨全年的数据计算 Z 分数。纽约市有四个季节,什么是正常的天气取决于我们查看的是哪个季节。通过将计算范围限制在 10 月份,我们可以看看 10 月是否有任何天气与其他天差异很大:

>>> oct_weather_z_scores = central_park_weather\
...     .loc['2018-10', ['TMIN', 'TMAX', 'PRCP']]\
...     .apply(lambda x: x.sub(x.mean()).div(x.std()))
>>> oct_weather_z_scores.describe().T

TMINTMAX 似乎没有任何与 10 月其余时间显著不同的数值,但 PRCP 确实有:

图 4.22 – 一次计算多个列的 Z 分数

](tos-cn-i-73owjymdk6/a03a25e579a04dc4aa4e341a9f94afc5)

图 4.22 – 一次计算多个列的 Z 分数

我们可以使用 query() 提取该日期的值:

>>> oct_weather_z_scores.query('PRCP > 3').PRCP
date
2018-10-27    3.936167
Name: PRCP, dtype: float64

如果我们查看 10 月份降水量的汇总统计数据,我们可以看到,这一天的降水量远远超过其他日子:

>>> central_park_weather.loc['2018-10', 'PRCP'].describe()
count    31.000000
mean      2.941935
std       7.458542
min       0.000000
25%       0.000000
50%       0.000000
75%       1.150000
max      32.300000
Name: PRCP, dtype: float64

apply() 方法让我们可以对整个列或行一次性运行矢量化操作。我们可以应用几乎任何我们能想到的函数,只要这些操作在数据的所有列(或行)上都是有效的。例如,我们可以使用之前讨论的 pd.cut()pd.qcut() 分箱函数,将每一列分成若干区间(前提是我们希望分成相同数量的区间或数值范围)。请注意,如果我们要应用的函数不是矢量化的,还可以使用 applymap() 方法。或者,我们可以使用 np.vectorize() 将函数矢量化,以便与 apply() 一起使用。有关示例,请参考笔记本。

Pandas 确实提供了一些用于迭代数据框的功能,包括iteritems()itertuples()iterrows()方法;然而,除非我们完全找不到其他解决方案,否则应避免使用这些方法。Pandas 和 NumPy 是为向量化操作设计的,这些操作要快得多,因为它们是用高效的 C 代码编写的;通过编写一个循环逐个迭代元素,我们让计算变得更加复杂,因为 Python 实现整数和浮点数的方式。举个例子,看看完成一个简单操作(将数字10加到每个浮点数值上)所需的时间,使用iteritems()时,它会随着行数的增加线性增长,而使用向量化操作时,无论行数多大,所需时间几乎保持不变:

图 4.23 – 向量化与迭代操作

图 4.23 – 向量化与迭代操作

到目前为止,我们使用的所有函数和方法都涉及整个行或列;然而,有时我们更关心进行窗口计算,它们使用数据的一部分。

窗口计算

Pandas 使得对窗口或行/列范围进行计算成为可能。在这一部分中,我们将讨论几种构建这些窗口的方法。根据窗口类型的不同,我们可以得到数据的不同视角。

滚动窗口

当我们的索引类型为DatetimeIndex时,我们可以按日期部分指定窗口(例如,2H表示两小时,3D表示三天);否则,我们可以指定期数的整数值。例如,如果我们对滚动 3 天窗口内的降水量感兴趣;用我们目前所学的知识来实现这一点会显得相当繁琐(而且可能效率低下)。幸运的是,我们可以使用rolling()方法轻松获取这些信息:

>>> central_park_weather.loc['2018-10'].assign(
...     rolling_PRCP=lambda x: x.PRCP.rolling('3D').sum()
... )[['PRCP', 'rolling_PRCP']].head(7).T

在执行滚动 3 天总和后,每个日期将显示该日期和前两天的降水量总和:

图 4.24 – 滚动 3 天总降水量

图 4.24 – 滚动 3 天总降水量

提示

如果我们想使用日期进行滚动计算,但索引中没有日期,我们可以将日期列的名称传递给rolling()调用中的on参数。相反,如果我们想使用行号的整数索引,我们可以直接传递一个整数作为窗口;例如,rolling(3)表示一个 3 行的窗口。

要更改聚合,只需在rolling()的结果上调用不同的方法;例如,mean()表示平均值,max()表示最大值。滚动计算也可以应用于所有列一次:

>>> central_park_weather.loc['2018-10']\
...     .rolling('3D').mean().head(7).iloc[:,:6]

这会给我们提供来自中央公园的所有天气观测数据的 3 天滚动平均值:

图 4.25 – 所有天气观测数据的滚动 3 天平均值

图 4.25 – 所有天气观测数据的滚动 3 天平均值

若要对不同的列应用不同的聚合操作,我们可以使用agg()方法,它允许我们为每列指定预定义的或自定义的聚合函数。我们只需传入一个字典,将列映射到需要执行的聚合操作。让我们来找出滚动的 3 天最高气温(TMAX)、最低气温(TMIN)、平均风速(AWND)和总降水量(PRCP)。然后,我们将其与原始数据进行合并,以便进行比较:

>>> central_park_weather\
...     ['2018-10-01':'2018-10-07'].rolling('3D').agg({
...     'TMAX': 'max', 'TMIN': 'min',
...     'AWND': 'mean', 'PRCP': 'sum'
... }).join( # join with original data for comparison
...     central_park_weather[['TMAX', 'TMIN', 'AWND', 'PRCP']], 
...     lsuffix='_rolling'
... ).sort_index(axis=1) # put rolling calcs next to originals

使用agg(),我们能够为每列计算不同的滚动聚合操作:

图 4.26 – 对每列使用不同的滚动计算

图 4.26 – 对每列使用不同的滚动计算

小提示

我们还可以通过额外的努力使用变宽窗口:我们可以创建BaseIndexer的子类,并在get_window_bounds()方法中提供确定窗口边界的逻辑(更多信息请参考pandas.pydata.org/pandas-docs/stable/user_guide/computation.html#custom-window-rolling),或者我们可以使用pandas.api.indexers模块中的预定义类。我们当前使用的笔记本中包含了使用VariableOffsetWindowIndexer类执行 3 个工作日滚动计算的示例。

使用滚动计算时,我们有一个滑动窗口,在这个窗口上我们计算我们的函数;然而,在某些情况下,我们更关心函数在所有数据点上的输出,这时我们使用扩展窗口。

扩展窗口

扩展计算将为我们提供聚合函数的累积值。我们使用expanding()方法进行扩展窗口计算;像cumsum()cummax()这样的函数会使用扩展窗口进行计算。直接使用expanding()的优点是额外的灵活性:我们不局限于预定义的聚合方法,并且可以通过min_periods参数指定计算开始前的最小周期数(默认为 1)。使用中央公园的天气数据,让我们使用expanding()方法来计算当月至今的平均降水量:

>>> central_park_weather.loc['2018-06'].assign(
...     TOTAL_PRCP=lambda x: x.PRCP.cumsum(),
...     AVG_PRCP=lambda x: x.PRCP.expanding().mean()
... ).head(10)[['PRCP', 'TOTAL_PRCP', 'AVG_PRCP']].T

请注意,虽然没有计算累积平均值的方法,但我们可以使用expanding()方法来计算它。AVG_PRCP列中的值是TOTAL_PRCP列中的值除以处理的天数:

图 4.27 – 计算当月至今的平均降水量

图 4.27 – 计算当月至今的平均降水量

正如我们在使用rolling()时做的那样,我们可以通过agg()方法提供列特定的聚合操作。让我们来找出扩展后的最高气温、最低气温、平均风速和总降水量。注意,我们还可以将 NumPy 函数传递给agg()

>>> central_park_weather\
...     ['2018-10-01':'2018-10-07'].expanding().agg({
...     'TMAX': np.max, 'TMIN': np.min, 
...     'AWND': np.mean, 'PRCP': np.sum
... }).join(
...     central_park_weather[['TMAX', 'TMIN', 'AWND', 'PRCP']], 
...     lsuffix='_expanding'
... ).sort_index(axis=1)

我们再次将窗口计算与原始数据结合进行比较:

图 4.28 – 对每列执行不同的扩展窗口计算

图 4.28 – 对每列执行不同的扩展窗口计算

滚动窗口和扩展窗口在执行计算时,都会平等地权重窗口中的所有观测值,但有时我们希望对较新的值赋予更多的重视。一种选择是对观测值进行指数加权。

指数加权移动窗口

Pandas 还提供了ewm()方法,用于进行指数加权移动计算。正如在第一章《数据分析导论》中讨论的那样,我们可以使用span参数指定用于 EWMA 计算的周期数:

>>> central_park_weather.assign(
...     AVG=lambda x: x.TMAX.rolling('30D').mean(),
...     EWMA=lambda x: x.TMAX.ewm(span=30).mean()
... ).loc['2018-09-29':'2018-10-08', ['TMAX', 'EWMA', 'AVG']].T

与滚动平均不同,EWMA 对最近的观测值赋予更高的权重,因此 10 月 7 日的温度突变对 EWMA 的影响大于对滚动平均的影响:

图 4.29 – 使用移动平均平滑数据

图 4.29 – 使用移动平均平滑数据

提示

查看understanding_window_calculations.ipynb笔记本,其中包含了一些用于理解窗口函数的交互式可视化。这可能需要一些额外的设置,但相关说明已包含在笔记本中。

管道

管道使得将多个操作链接在一起变得更加简便,这些操作期望pandas数据结构作为它们的第一个参数。通过使用管道,我们可以构建复杂的工作流,而不需要编写高度嵌套且难以阅读的代码。通常,管道让我们能够将像f(g(h(data), 20), x=True)这样的表达式转变为以下形式,使其更易读:

data.pipe(h)\ # first call h(data)
    .pipe(g, 20)\ # call g on the result with positional arg 20
    .pipe(f, x=True) # call f on result with keyword arg x=True

假设我们希望通过调用此函数,打印 Facebook 数据框某个子集的维度,并进行一些格式化:

>>> def get_info(df):
...     return '%d rows, %d cols and max closing Z-score: %d' 
...             % (*df.shape, df.close.max()) 

然而,在调用函数之前,我们需要计算所有列的 Z 得分。一种方法如下:

>>> get_info(fb.loc['2018-Q1']\
...            .apply(lambda x: (x - x.mean())/x.std()))

另外,我们可以在计算完 Z 得分后,将数据框传递给这个函数:

>>> fb.loc['2018-Q1'].apply(lambda x: (x - x.mean())/x.std())\
...     .pipe(get_info)

管道还可以使编写可重用代码变得更容易。在本书中的多个代码片段中,我们看到了将一个函数传递给另一个函数的概念,例如我们将 NumPy 函数传递给apply(),并在每一列上执行它。我们可以使用管道将该功能扩展到pandas数据结构的方法:

>>> fb.pipe(pd.DataFrame.rolling, '20D').mean().equals(
...     fb.rolling('20D').mean()
... ) # the pipe is calling pd.DataFrame.rolling(fb, '20D')
True

为了说明这如何为我们带来好处,让我们看一个函数,它将给出我们选择的窗口计算结果。该函数位于window_calc.py文件中。我们将导入该函数,并使用??从 IPython 查看函数定义:

>>> from window_calc import window_calc
>>> window_calc??
Signature: window_calc(df, func, agg_dict, *args, **kwargs)
Source:   
def window_calc(df, func, agg_dict, *args, **kwargs):
    """
    Run a window calculation of your choice on the data.
    Parameters:
        - df: The `DataFrame` object to run the calculation on.
        - func: The window calculation method that takes `df` 
          as the first argument.
        - agg_dict: Information to pass to `agg()`, could be 
          a dictionary mapping the columns to the aggregation 
          function to use, a string name for the function, 
          or the function itself.
        - args: Positional arguments to pass to `func`.
        - kwargs: Keyword arguments to pass to `func`.

    Returns:
        A new `DataFrame` object.
    """
    return df.pipe(func, *args, **kwargs).agg(agg_dict)
File:      ~/.../ch_04/window_calc.py
Type:      function

我们的window_calc()函数接受数据框架、需要执行的函数(只要它的第一个参数是数据框架),以及如何聚合结果的信息,还可以传递任何可选的参数,然后返回一个包含窗口计算结果的新数据框架。让我们使用这个函数来计算 Facebook 股票数据的扩展中位数:

>>> window_calc(fb, pd.DataFrame.expanding, np.median).head()

注意,expanding()方法不需要我们指定任何参数,因此我们只需要传入pd.DataFrame.expanding(不带括号),并附带需要进行的聚合操作,作为数据框架上的窗口计算:

图 4.30 – 使用管道进行扩展窗口计算

图 4.30 – 使用管道进行扩展窗口计算

window_calc()函数还接受*args**kwargs;这些是可选参数,如果提供,它们会在按名称传递时被 Python 收集到kwargs中(例如span=20),如果未提供(按位置传递),则会收集到args中。然后可以使用*表示args**表示kwargs。我们需要这种行为,以便使用ewm()方法计算 Facebook 股票收盘价的指数加权移动平均(EWMA):

>>> window_calc(fb, pd.DataFrame.ewm, 'mean', span=3).head()

在前面的示例中,我们不得不使用**kwargs,因为span参数并不是ewm()接收的第一个参数,我们不想传递它前面的那些参数:

图 4.31 – 使用管道进行指数加权窗口计算

图 4.31 – 使用管道进行指数加权窗口计算

为了计算中央公园的 3 天滚动天气聚合,我们利用了*args,因为我们知道窗口是rolling()的第一个参数:

>>> window_calc(
...     central_park_weather.loc['2018-10'], 
...     pd.DataFrame.rolling, 
...     {'TMAX': 'max', 'TMIN': 'min',
...      'AWND': 'mean', 'PRCP': 'sum'},
...     '3D'
... ).head()

我们能够对每一列进行不同的聚合,因为我们传入了一个字典,而不是一个单一的值:

图 4.32 – 使用管道进行滚动窗口计算

图 4.32 – 使用管道进行滚动窗口计算

注意,我们是如何能够为窗口计算创建一致的 API,而调用者无需弄清楚在窗口函数之后需要调用哪个聚合方法。这隐藏了一些实现细节,同时使得使用更加方便。我们将在第七章中构建的StockVisualizer类的某些功能中使用这个函数,金融分析 - 比特币与股市

聚合数据

在我们讨论窗口计算和管道操作的上一节中,已经对聚合有了初步了解。在这里,我们将专注于通过聚合来汇总数据帧,这将改变我们数据帧的形状(通常是通过减少行数)。我们还看到,利用 NumPy 的矢量化函数在pandas数据结构上进行聚合是多么容易。这正是 NumPy 最擅长的:在数值数组上执行高效的数学计算。

NumPy 与聚合数据框非常搭配,因为它为我们提供了一种通过不同的预写函数汇总数据的简便方法;通常,在进行聚合时,我们只需要使用 NumPy 函数,因为大多数我们自己编写的函数已经由 NumPy 实现。我们已经看到了一些常用的 NumPy 聚合函数,比如np.sum()np.mean()np.min()np.max();然而,我们不仅限于数值操作——我们还可以在字符串上使用诸如np.unique()之类的函数。总是检查 NumPy 是否已经提供了所需的函数,再决定是否自己实现。

本节中,我们将在3-aggregations.ipynb笔记本中进行操作。让我们导入pandasnumpy,并读取我们将要处理的数据:

>>> import numpy as np
>>> import pandas as pd
>>> fb = pd.read_csv(
...     'data/fb_2018.csv', index_col='date', parse_dates=True
... ).assign(trading_volume=lambda x: pd.cut(
...     x.volume, bins=3, labels=['low', 'med', 'high'] 
... ))
>>> weather = pd.read_csv(
...     'data/weather_by_station.csv', 
...     index_col='date', parse_dates=True
... )

请注意,本节的天气数据已经与部分站点数据合并:

图 4.33 – 本节合并的天气与站点数据

图 4.33 – 本节合并的天气与站点数据

在进行任何计算之前,我们先确保数据不会以科学计数法显示。我们将修改浮动数值的显示格式。我们将应用的格式是.2f,该格式会提供一个小数点后两位的浮动数值:

>>> pd.set_option('display.float_format', lambda x: '%.2f' % x)

首先,我们将查看如何汇总完整数据集,然后再进行按组汇总,并构建数据透视表和交叉表。

汇总数据帧

当我们讨论窗口计算时,我们看到可以在rolling()expanding()ewm()的结果上运行agg()方法;然而,我们也可以像对待数据框一样直接调用它。唯一的区别是,通过这种方式执行的聚合将在所有数据上进行,这意味着我们将只得到一个包含整体结果的系列。让我们像在窗口计算中一样对 Facebook 的股票数据进行聚合。请注意,对于trading_volume列(它包含pd.cut()生成的交易量区间),我们不会得到任何结果;这是因为我们没有为该列指定聚合操作:

>>> fb.agg({
...     'open': np.mean, 'high': np.max, 'low': np.min, 
...     'close': np.mean, 'volume': np.sum
... })
open            171.45
high            218.62
low             123.02
close           171.51
volume   6949682394.00
dtype: float64

我们可以使用聚合函数轻松找到 2018 年中央公园的总降雪量和降水量。在这种情况下,由于我们将在两者上执行求和操作,我们可以使用agg('sum')或直接调用sum()

>>> weather.query('station == "GHCND:USW00094728"')\
...     .pivot(columns='datatype', values='value')\
...     [['SNOW', 'PRCP']].sum()
datatype
SNOW   1007.00
PRCP   1665.30
dtype: float64

此外,我们还可以为每个要聚合的列提供多个函数。正如我们之前所见,当每列只有一个聚合函数时,我们会得到一个Series对象。若每列有多个聚合函数,pandas将返回一个DataFrame对象。这个数据框的索引会告诉我们正在为哪个列计算哪种指标:

>>> fb.agg({
...     'open': 'mean', 
...     'high': ['min', 'max'],
...     'low': ['min', 'max'], 
...     'close': 'mean'
... })

这将产生一个数据框,其中行表示应用于数据列的聚合函数。请注意,对于我们没有明确要求的任何聚合与列的组合,结果会是空值:

图 4.34 – 每列执行多个聚合

图 4.34 – 每列执行多个聚合

到目前为止,我们已经学会了如何在特定窗口和整个数据框上进行聚合;然而,真正的强大之处在于按组别进行聚合。这使我们能够计算如每月、每个站点的总降水量,以及我们为每个交易量区间创建的股票 OHLC 平均价格等内容。

按组聚合

要按组计算聚合,我们必须首先在数据框上调用groupby()方法,并提供我们想用来确定不同组的列。让我们来看一下我们用pd.cut()创建的每个交易量区间的股票数据点的平均值;记住,这些是三个等宽区间:

>>> fb.groupby('trading_volume').mean()

较大交易量的 OHLC 平均价格较小,这是预期之中的,因为高交易量区间的三个日期是抛售日:

图 4.35 – 按组聚合

图 4.35 – 按组聚合

在运行groupby()后,我们还可以选择特定的列进行聚合:

>>> fb.groupby('trading_volume')\
...     ['close'].agg(['min', 'max', 'mean'])

这给我们带来了每个交易量区间的收盘价聚合:

图 4.36 – 按组聚合特定列

图 4.36 – 按组聚合特定列

如果我们需要更精细地控制每个列的聚合方式,我们可以再次使用agg()方法,并提供一个将列映射到聚合函数的字典。如同之前一样,我们可以为每一列提供多个函数;不过结果会看起来有些不同:

>>> fb_agg = fb.groupby('trading_volume').agg({
...     'open': 'mean', 'high': ['min', 'max'],
...     'low': ['min', 'max'], 'close': 'mean'
... })
>>> fb_agg

现在,我们的列有了层级索引。记住,这意味着,如果我们想选择中等交易量区间的最低低价,我们需要使用fb_agg.loc['med', 'low']['min']

图 4.37 – 按组进行每列的多个聚合

图 4.37 – 按组进行每列的多个聚合

列存储在一个MultiIndex对象中:

>>> fb_agg.columns
MultiIndex([( 'open', 'mean'),
            ( 'high',  'min'),
            ( 'high',  'max'),
            (  'low',  'min'),
            (  'low',  'max'),
            ('close', 'mean')],
           )

我们可以使用列表推导来移除这个层级,而是将列名转换为<column>_<agg>的形式。在每次迭代中,我们将从MultiIndex对象中获取一个元组的层级,这些层级可以组合成一个单一的字符串来去除层级:

>>> fb_agg.columns = ['_'.join(col_agg) 
...                   for col_agg in fb_agg.columns]
>>> fb_agg.head()

这将把列中的层级替换为单一层级:

图 4.38 – 扁平化层级索引

图 4.38 – 扁平化层级索引

假设我们想查看所有站点每天的平均降水量。我们需要按日期进行分组,但日期在索引中。在这种情况下,我们有几种选择:

  • 重采样,我们将在本章后面的处理时间序列数据部分讲解。

  • 重置索引,并使用从索引中创建的日期列。

  • level=0传递给groupby(),表示分组应在索引的最外层进行。

  • 使用Grouper对象。

在这里,我们将level=0传递给groupby(),但请注意,我们也可以传入level='date',因为我们的索引已经命名。这将给我们一个跨所有站点的平均降水量观察结果,这可能比仅查看某个站点的数据更能帮助我们了解天气。由于结果是一个单列的DataFrame对象,我们调用squeeze()将其转换为Series对象:

>>> weather.loc['2018-10'].query('datatype == "PRCP"')\ 
...     .groupby(level=0).mean().head().squeeze()
date
2018-10-01    0.01
2018-10-02    2.23
2018-10-03   19.69
2018-10-04    0.32
2018-10-05    0.96
Name: value, dtype: float64

我们还可以一次性按多个类别进行分组。让我们找出每个站点每季度的降水总量。在这里,我们需要使用Grouper对象将频率从日度聚合到季度,而不是将level=0传递给groupby()。由于这将创建多层级索引,我们还将使用unstack()在聚合完成后将内层级(季度)放置在列中:

>>> weather.query('datatype == "PRCP"').groupby(
...     ['station_name', pd.Grouper(freq='Q')]
... ).sum().unstack().sample(5, random_state=1)

对于这个结果,有许多可能的后续分析。我们可以查看哪些站点接收到的降水最多/最少。我们还可以回到每个站点的位置信息和海拔数据,看看这些是否影响降水。我们还可以查看哪个季度在所有站点中降水最多/最少:

图 4.39 – 按包含日期的列进行聚合

图 4.39 – 按包含日期的列进行聚合

提示

groupby()方法返回的DataFrameGroupBy对象具有一个filter()方法,允许我们过滤组。我们可以使用它来从聚合中排除某些组。只需传入一个返回布尔值的函数,针对每个组的子集(True表示包含该组,False表示排除该组)。示例可以在笔记本中查看。

让我们看看哪个月份降水最多。首先,我们需要按天进行分组,并对各个站点的降水量求平均。然后,我们可以按月分组并求和。最后,我们将使用nlargest()来获取降水量最多的前五个月:

>>> weather.query('datatype == "PRCP"')\
...     .groupby(level=0).mean()\
...     .groupby(pd.Grouper(freq='M')).sum().value.nlargest()
date
2018-11-30   210.59
2018-09-30   193.09
2018-08-31   192.45
2018-07-31   160.98
2018-02-28   158.11
Name: value, dtype: float64

也许前面的结果让人感到惊讶。俗话说,四月的阵雨带来五月的花;然而,四月并未出现在前五名中(五月也没有)。雪也会计入降水量,但这并不能解释为何夏季的降水量会高于四月。让我们查找在某月降水量占比较大的天数,看看四月是否会出现在那里。

为此,我们需要计算每个气象站的日均降水量,并计算每月的总降水量;这将作为分母。然而,为了将每日的值除以其所在月份的总值,我们需要一个维度相等的Series对象。这意味着我们需要使用transform()方法,它会对数据执行指定的计算,并始终返回一个与起始对象维度相同的对象。因此,我们可以在Series对象上调用它,并始终返回一个Series对象,无论聚合函数本身返回的是什么:

>>> weather.query('datatype == "PRCP"')\
...     .rename(dict(value='prcp'), axis=1)\
...     .groupby(level=0).mean()\
...     .groupby(pd.Grouper(freq='M'))\
...     .transform(np.sum)['2018-01-28':'2018-02-03']

与其对一月和二月分别得到一个总和,不如注意到我们在一月的条目中得到了相同的值,而在二月的条目中得到了不同的值。注意,二月的值是我们在前面结果中找到的那个值:

图 4.40 – 用于计算月度降水百分比的分母

](tos-cn-i-73owjymdk6/2ca95c65d05d48b3bb773dcd5846b8b9)

图 4.40 – 用于计算月度降水百分比的分母

我们可以在数据框中创建这一列,以便轻松计算每天发生的月度降水百分比。然后,我们可以使用nlargest()方法提取最大的值:

>>> weather.query('datatype == "PRCP"')\
...     .rename(dict(value='prcp'), axis=1)\
...     .groupby(level=0).mean()\
...     .assign(
...         total_prcp_in_month=lambda x: x.groupby(
...             pd.Grouper(freq='M')).transform(np.sum),
...         pct_monthly_prcp=lambda x: \
...             x.prcp.div(x.total_prcp_in_month)
...     ).nlargest(5, 'pct_monthly_prcp')

在按月降水量排序的前四和第五天中,它们加起来占了四月降水量的 50%以上。这两天也是连续的:

图 4.41 – 计算每天发生的月度降水百分比

](tos-cn-i-73owjymdk6/8b469b6732b14983a91216d828844da8)

图 4.41 – 计算每天发生的月度降水百分比

重要提示

transform()方法同样适用于DataFrame对象,在这种情况下它将返回一个DataFrame对象。我们可以使用它一次性轻松地标准化所有列。示例可以在笔记本中找到。

数据透视表和交叉表

本节的最后,我们将讨论一些pandas函数,这些函数能够将我们的数据聚合为一些常见格式。我们之前讨论的聚合方法会给我们最大的自定义空间;然而,pandas还提供了一些函数,可以快速生成数据透视表和交叉表,格式上符合常见标准。

为了生成数据透视表,我们必须指定按什么进行分组,并可选择指定要聚合的列子集和/或聚合方式(默认情况下为平均)。让我们创建一个 Facebook 按交易量分组的平均 OHLC 数据的透视表:

>>> fb.pivot_table(columns='trading_volume')

由于我们传入了columns='trading_volume'trading_volume列中的不同值被放置在了列中。原始数据框中的列则转到了索引。请注意,列的索引有一个名称(trading_volume):

图 4.42 – 每个交易量区间的列平均值透视表

图 4.42 – 每个交易量区间的列平均值透视表

提示

如果我们将trading_volume作为index参数传入,得到的将是图 4.42的转置,这也是使用groupby()时得到的与图 4.35完全相同的输出。

使用pivot()方法时,我们无法处理多级索引或具有重复值的索引。正因为如此,我们无法将天气数据放入宽格式。pivot_table()方法解决了这个问题。为此,我们需要将datestation信息放入索引中,将datatype列的不同值放入列中。值将来自value列。对于任何重叠的组合(如果有),我们将使用中位数进行聚合:

>>> weather.reset_index().pivot_table(
...     index=['date', 'station', 'station_name'], 
...     columns='datatype', 
...     values='value', 
...     aggfunc='median'
... ).reset_index().tail()

在重置索引后,我们得到了宽格式的数据。最后一步是重命名索引:

图 4.43 – 每种数据类型、站点和日期的中位数值透视表

图 4.43 – 每种数据类型、站点和日期的中位数值透视表

我们可以使用pd.crosstab()函数来创建一个频率表。例如,如果我们想查看每个月 Facebook 股票的低、中、高交易量交易天数,可以使用交叉表。语法非常简单;我们将行和列标签分别传递给indexcolumns参数。默认情况下,单元格中的值将是计数:

>>> pd.crosstab(
...     index=fb.trading_volume, columns=fb.index.month,
...     colnames=['month'] # name the columns index
... )

这样可以方便地查看 Facebook 股票在不同月份的高交易量:

图 4.44 – 显示每个月、每个交易量区间的交易天数的交叉表

图 4.44 – 显示每个月、每个交易量区间的交易天数的交叉表

提示

我们可以通过传入normalize='rows'/normalize='columns'来将输出标准化为行/列总计的百分比。示例见笔记本。

要更改聚合函数,我们可以为values提供一个参数,然后指定aggfunc。为了说明这一点,让我们计算每个月每个交易量区间的平均收盘价,而不是上一个示例中的计数:

>>> pd.crosstab(
...     index=fb.trading_volume, columns=fb.index.month,
...     colnames=['month'], values=fb.close, aggfunc=np.mean
... )

现在我们得到了每个月、每个交易量区间的平均收盘价,数据中没有该组合时则显示为 null 值:

图 4.45 – 使用平均值而非计数的交叉表

图 4.45 – 使用平均值而非计数的交叉表

我们还可以使用margins参数来获取行和列的小计。让我们统计每个月每个站点记录到雪的次数,并包括小计:

>>> snow_data = weather.query('datatype == "SNOW"')
>>> pd.crosstab(
...     index=snow_data.station_name,
...     columns=snow_data.index.month, 
...     colnames=['month'],
...     values=snow_data.value,
...     aggfunc=lambda x: (x > 0).sum(),
...     margins=True, # show row and column subtotals
...     margins_name='total observations of snow' # subtotals
... )

在底部的行中,我们展示了每月的总降雪观测数据,而在最右边的列中,我们列出了 2018 年每个站点的总降雪观测数据:

图 4.46 – 统计每个月每个站点降雪天数的交叉表

](tos-cn-i-73owjymdk6/2c8080c330ac45f7a72ffa92a226f22d)

图 4.46 – 统计每个月每个站点降雪天数的交叉表

通过查看少数几个站点,我们可以发现,尽管它们都提供了纽约市的天气信息,但它们并不共享天气的每个方面。根据我们选择查看的站点,我们可能会添加或减少与纽约市实际发生的雪量。

使用时间序列数据

使用时间序列数据时,我们可以进行一些额外的操作,涵盖从选择和筛选到聚合的各个方面。我们将在 4-time_series.ipynb 笔记本中探索一些这种功能。我们先从读取前面章节中的 Facebook 数据开始:

>>> import numpy as np
>>> import pandas as pd
>>> fb = pd.read_csv(
...     'data/fb_2018.csv', index_col='date', parse_dates=True
... ).assign(trading_volume=lambda x: pd.cut( 
...     x.volume, bins=3, labels=['low', 'med', 'high']     
... ))

本节将从讨论时间序列数据的选择和筛选开始,接着讲解数据的平移、差分、重采样,最后讲解基于时间的数据合并。请注意,设置日期(或日期时间)列作为索引非常重要,这将使我们能够利用接下来要讲解的额外功能。某些操作无需设置索引也能工作,但为了确保分析过程的顺利进行,建议使用 DatetimeIndex 类型的索引。

基于时间的选择和筛选

让我们先快速回顾一下日期时间切片和索引的内容。我们可以通过索引轻松提取特定年份的数据:fb.loc['2018']。对于我们的股票数据,由于只有 2018 年的数据,因此会返回整个数据框;不过,我们也可以过滤出某个月的数据(例如 fb.loc['2018-10']) 或者某个日期范围的数据。请注意,对于范围的选择,使用 loc[] 是可选的:

>>> fb['2018-10-11':'2018-10-15']

我们仅能获得三天的数据,因为股市在周末休市:

图 4.47 – 基于日期范围选择数据

](tos-cn-i-73owjymdk6/587640023bf149738ddd3c73ed9bffd8)

图 4.47 – 基于日期范围选择数据

请记住,日期范围也可以使用其他频率提供,例如按月或按季度:

>>> fb.loc['2018-q1'].equals(fb['2018-01':'2018-03'])
True

当目标是日期范围的开始或结束时,pandas 提供了一些额外的方法来选择指定时间单位内的第一行或最后一行数据。我们可以使用 first() 方法和 1W 偏移量来选择 2018 年的第一周股价数据:

>>> fb.first('1W')

2018 年 1 月 1 日是节假日,股市休市。而且那天是周一,因此这一周只有四天:

图 4.48 – 2018 年首周 Facebook 股票数据

](github.com/OpenDocCN/f…)

图 4.48 – 2018 年首周 Facebook 股票数据

我们也可以对最近的日期执行类似的操作。选择数据中的最后一周,只需将 first() 方法替换为 last() 方法即可:

>>> fb.last('1W')

由于 2018 年 12 月 31 日是星期一,最后一周只包含一天:

图 4.49 – 2018 年最后一周的 Facebook 股票数据

图 4.49 – 2018 年最后一周的 Facebook 股票数据

在处理每日股票数据时,我们只有股市开放日期的数据。假设我们将数据重新索引,以便为每一天添加一行:

>>> fb_reindexed = fb.reindex(
...     pd.date_range('2018-01-01', '2018-12-31', freq='D')
... )

重新索引的数据将在 1 月 1 日和其他市场关闭的日期上显示所有空值。我们可以结合使用 first()isna()all() 方法来确认这一点。这里,我们还将使用 squeeze() 方法将通过调用 first('1D').isna() 得到的 1 行 DataFrame 对象转换为 Series 对象,以便调用 all() 时返回单一值:

>>> fb_reindexed.first('1D').isna().squeeze().all()
True

我们可以使用 first_valid_index() 方法获取数据中第一个非空条目的索引,它将是数据中的第一个交易日。要获取最后一个交易日,我们可以使用 last_valid_index() 方法。对于 2018 年第一季度,交易的第一天是 1 月 2 日,最后一天是 3 月 29 日:

>>> fb_reindexed.loc['2018-Q1'].first_valid_index()
Timestamp('2018-01-02 00:00:00', freq='D')
>>> fb_reindexed.loc['2018-Q1'].last_valid_index()
Timestamp('2018-03-29 00:00:00', freq='D')

如果我们想知道 2018 年 3 月 31 日 Facebook 的股票价格,最初的想法可能是使用索引来获取它。然而,如果我们尝试通过 loc[] (fb_reindexed.loc['2018-03-31']) 来获取,我们将得到空值,因为那天股市并未开放。如果我们改用 asof() 方法,它将返回在我们要求的日期之前的最近非空数据,在本例中是 3 月 29 日。因此,如果我们想查看 Facebook 在每个月的最后一天的表现,可以使用 asof(),而无需先检查当天股市是否开放:

>>> fb_reindexed.asof('2018-03-31')
open                   155.15
high                   161.42
low                    154.14
close                  159.79
volume            59434293.00
trading_volume            low
Name: 2018-03-31 00:00:00, dtype: object

在接下来的几个例子中,我们除了日期之外还需要时间信息。到目前为止,我们处理的数据集缺少时间组件,因此我们将切换到来自 Nasdaq.com 的 2019 年 5 月 20 日至 2019 年 5 月 24 日的按分钟划分的 Facebook 股票数据。为了正确解析日期时间,我们需要将一个 lambda 函数作为 date_parser 参数传入,因为这些数据并非标准格式(例如,2019 年 5 月 20 日 9:30 AM 表示为 2019-05-20 09-30);该 lambda 函数将指定如何将 date 字段中的数据转换为日期时间:

>>> stock_data_per_minute = pd.read_csv(
...     'data/fb_week_of_may_20_per_minute.csv', 
...     index_col='date', parse_dates=True, 
...     date_parser=lambda x: \
...         pd.to_datetime(x, format='%Y-%m-%d %H-%M')
... )
>>> stock_data_per_minute.head()

我们有每分钟的 OHLC 数据,以及每分钟的成交量数据:

图 4.50 – Facebook 股票数据按分钟划分

图 4.50 – Facebook 股票数据按分钟划分

重要说明

为了正确解析非标准格式的日期时间,我们需要指定其格式。有关可用代码的参考,请查阅 Python 文档:docs.python.org/3/library/datetime.html#strftime-strptime-behavior

我们可以使用first()last()结合agg()将这些数据转换为每日的粒度。为了获取真实的开盘价,我们需要每天获取第一个观察值;相反,对于真实的收盘价,我们需要每天获取最后一个观察值。高点和低点将分别是它们每天各自列的最大和最小值。成交量将是每日的总和:

>>> stock_data_per_minute.groupby(pd.Grouper(freq='1D')).agg({
...     'open': 'first', 
...     'high': 'max', 
...     'low': 'min', 
...     'close': 'last', 
...     'volume': 'sum'
... })

这将数据升级到每日频率:

Figure 4.51 – 将数据从分钟级别升级到日级别

Figure 4.51 – 将数据从分钟级别升级到日级别

接下来我们将讨论的两种方法帮助我们基于日期时间的时间部分选择数据。at_time()方法允许我们隔离日期时间的时间部分是我们指定的时间的行。通过运行at_time('9:30'),我们可以抓取所有开盘价格(股票市场在上午 9:30 开盘):

>>> stock_data_per_minute.at_time('9:30')

这告诉我们每天开盘铃响时的股票数据是怎样的:

Figure 4.52 – 每天市场开盘时的股票数据

Figure 4.52 – 每天市场开盘时的股票数据

我们可以使用between_time()方法来抓取时间部分位于两个时间之间(默认包含端点)的所有行。如果我们想要逐日查看某个时间范围内的数据,这种方法非常有用。让我们抓取每天交易的最后两分钟内的所有行(15:59 - 16:00):

>>> stock_data_per_minute.between_time('15:59', '16:00')

看起来最后一分钟(16:00)每天的交易量显著高于前一分钟(15:59)。也许人们会在闭市前赶紧进行交易:

Figure 4.53 – 每天交易最后两分钟的股票数据

Figure 4.53 – 每天交易最后两分钟的股票数据

我们可能会想知道这是否也发生在前两分钟内。人们是否在前一天晚上进行交易,然后在市场开盘时执行?更改前面的代码以回答这个问题是微不足道的。相反,让我们看看在讨论的这周中,平均而言,更多的股票是在开盘后的前 30 分钟内交易还是在最后 30 分钟内交易。我们可以结合between_time()groupby()来回答这个问题。此外,我们需要使用filter()来排除聚合中的组。被排除的组是不在我们想要的时间范围内的时间:

>>> shares_traded_in_first_30_min = stock_data_per_minute\
...     .between_time('9:30', '10:00')\
...     .groupby(pd.Grouper(freq='1D'))\
...     .filter(lambda x: (x.volume > 0).all())\
...     .volume.mean()
>>> shares_traded_in_last_30_min = stock_data_per_minute\
...     .between_time('15:30', '16:00')\
...     .groupby(pd.Grouper(freq='1D'))\
...     .filter(lambda x: (x.volume > 0).all())\
...     .volume.mean()

在讨论的这周中,开盘时间周围的平均交易量比收盘时间多了 18,593 笔:

>>> shares_traded_in_first_30_min \
... - shares_traded_in_last_30_min
18592.967741935485

小贴士

我们可以对DatetimeIndex对象使用normalize()方法或在首次访问Series对象的dt属性后使用它,以将所有日期时间规范化为午夜。当时间对我们的数据没有添加价值时,这非常有用。笔记本中有此类示例。

通过股票数据,我们可以获得每分钟或每天的价格快照(具体取决于数据粒度),但我们可能更关心将时间段之间的变化作为时间序列显示,而不是聚合数据。为此,我们需要学习如何创建滞后数据。

滞后数据的偏移

我们可以使用shift()方法创建滞后数据。默认情况下,偏移量为一个周期,但它可以是任何整数(正数或负数)。我们来使用shift()方法创建一个新列,表示每日 Facebook 股票的前一日收盘价。通过这个新列,我们可以计算由于盘后交易导致的价格变化(即一天市场收盘后至下一天市场开盘前的交易):

>>> fb.assign(
...     prior_close=lambda x: x.close.shift(),
...     after_hours_change_in_price=lambda x: \
...         x.open - x.prior_close,
...     abs_change=lambda x: \
...         x.after_hours_change_in_price.abs()
... ).nlargest(5, 'abs_change')

这给我们展示了受盘后交易影响最大的日期:

图 4.54 – 使用滞后数据计算盘后股价变化

图 4.54 – 使用滞后数据计算盘后股价变化

提示

若要从索引中的日期时间添加/减去时间,可以考虑使用Timedelta对象。笔记本中有相关示例。

在之前的例子中,我们使用了偏移后的数据来计算跨列的变化。然而,如果我们感兴趣的不是盘后交易,而是 Facebook 股价的每日变化,我们将计算收盘价与偏移后收盘价之间的差异。Pandas 使这变得比这更简单,我们稍后会看到。

差分数据

我们已经讨论过如何使用shift()方法创建滞后数据。然而,通常我们关心的是值从一个时间周期到下一个时间周期的变化。为此,pandas提供了diff()方法。默认情况下,它会计算从时间周期t-1到时间周期t的变化:

请注意,这相当于从原始数据中减去shift()的结果:

>>> (fb.drop(columns='trading_volume') 
...  - fb.drop(columns='trading_volume').shift()
... ).equals(fb.drop(columns='trading_volume').diff())
True

我们可以使用diff()轻松计算 Facebook 股票数据的逐日变化:

>>> fb.drop(columns='trading_volume').diff().head()

对于年的前几个交易日,我们可以看到股价上涨,而成交量每天减少:

图 4.55 – 计算逐日变化

图 4.55 – 计算逐日变化

提示

要指定用于计算差异的周期数,只需向diff()传递一个整数。请注意,这个数字可以是负数。笔记本中有相关示例。

重采样

有时,数据的粒度不适合我们的分析。假设我们有 2018 年全年的每分钟数据,粒度和数据的性质可能使得绘图无用。因此,我们需要将数据汇总到一个较低粒度的频率:

图 4.56 – 重采样可以用于汇总细粒度数据

图 4.56 – 重采样可以用于汇总细粒度数据

假设我们拥有图 4.50中的一整年的数据(Facebook 股票按分钟显示)。这种粒度可能超出了我们的需求,在这种情况下,我们可以使用 resample() 方法将时间序列数据聚合为不同的粒度。使用 resample() 时,我们只需要告诉它如何汇总数据,并可选地调用聚合方法。例如,我们可以将这些按分钟的股票数据重采样为每日频率,并指定如何聚合每一列:

>>> stock_data_per_minute.resample('1D').agg({
...     'open': 'first', 
...     'high': 'max', 
...     'low': 'min', 
...     'close': 'last', 
...     'volume': 'sum'
... })

这与我们在基于时间的选择和过滤部分得到的结果相当(图 4.51):

图 4.57 – 每分钟的数据重采样为日数据

](tos-cn-i-73owjymdk6/9112e628b3d1464aafeb35e4b61a6308)

图 4.57 – 将每分钟的数据重采样为日数据

我们可以重采样为 pandas 支持的任何频率(更多信息可以参考文档:pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html)。让我们将每日的 Facebook 股票数据重采样为季度平均值:

>>> fb.resample('Q').mean()

这给出了股票的季度平均表现。2018 年第四季度显然很糟糕:

图 4.58 – 重采样为季度平均值

](github.com/OpenDocCN/f…)

图 4.58 – 重采样为季度平均值

为了进一步分析,我们可以使用 apply() 方法查看季度开始和结束时的差异。我们还需要在基于时间的选择和过滤部分使用 first()last() 方法:

>>> fb.drop(columns='trading_volume').resample('Q').apply(
...     lambda x: x.last('1D').values - x.first('1D').values
... )

Facebook 的股票价格在除第二季度外的所有季度都出现了下降:

图 4.59 – 总结 Facebook 2018 年每个季度的股票表现

](tos-cn-i-73owjymdk6/9112e628b3d1464aafeb35e4b61a6308)

图 4.59 – 总结 Facebook 2018 年每个季度的股票表现

请考虑 melted_stock_data.csv 中按分钟融化的股票数据:

>>> melted_stock_data = pd.read_csv(
...     'data/melted_stock_data.csv', 
...     index_col='date', parse_dates=True
... )
>>> melted_stock_data.head()

OHLC 格式使得分析股票数据变得更加容易,但如果数据只有一列,则会变得更加复杂:

图 4.60 – 按分钟显示股票价格

](github.com/OpenDocCN/f…)

图 4.60 – 按分钟显示股票价格

我们在调用 resample() 后得到的 Resampler 对象有一个 ohlc() 方法,我们可以使用它来获取我们习惯看到的 OHLC 数据:

>>> melted_stock_data.resample('1D').ohlc()['price']

由于原始数据中的列叫做 price,我们在调用 ohlc() 后选择它,这样我们就能对数据进行透视处理。否则,我们会在列中得到一个层次化索引:

图 4.61 – 将按分钟的股票价格重采样以形成每日 OHLC 数据

](tos-cn-i-73owjymdk6/69666e464c1b4a8996bfbe9b7fd15752)

图 4.61 – 将按分钟的股票价格重采样以形成每日 OHLC 数据

在之前的示例中,我们使用 asfreq() 来避免对结果进行聚合:

>>> fb.resample('6H').asfreq().head()

请注意,当我们在比已有数据更细粒度的情况下进行重采样时,它将引入 NaN 值:

图 4.62 – 向上采样会增加数据的粒度,并且会引入空值

](github.com/OpenDocCN/f…)

图 4.62 – 向上采样增加了数据的粒度,并会引入空值

以下是处理NaN值的一些方法。为了简洁起见,示例在笔记本中:

  • resample()之后使用pad()进行前向填充。

  • resample()之后调用fillna(),正如我们在第三章《使用 Pandas 进行数据清洗》中看到的,当我们处理缺失值时。

  • 使用asfreq()后接assign()来单独处理每一列。

到目前为止,我们一直在处理存储在单个DataFrame对象中的时间序列数据,但我们可能希望合并多个时间序列。虽然在合并 DataFrame部分讨论的技术适用于时间序列,pandas提供了更多的功能来合并时间序列,这样我们就可以基于接近的匹配进行合并,而不需要完全匹配。接下来我们将讨论这些内容。

合并时间序列

时间序列通常精确到秒,甚至更为细致,这意味着如果条目没有相同的日期时间,合并可能会非常困难。Pandas 通过两个附加的合并函数解决了这个问题。当我们想要配对接近时间的观测值时,可以使用pd.merge_asof(),根据附近的键进行匹配,而不是像我们在连接中那样使用相等的键。另一方面,如果我们想匹配相等的键并交错没有匹配项的键,可以使用pd.merge_ordered()

为了说明这些方法的工作原理,我们将使用stocks.db SQLite 数据库中的fb_pricesaapl_prices表。这些表分别包含 Facebook 和 Apple 股票的价格,以及记录价格时的时间戳。请注意,Apple 数据是在 2020 年 8 月股票拆分之前收集的(www.marketwatch.com/story/3-things-to-know-about-apples-stock-split-2020-08-28)。让我们从数据库中读取这些表:

>>> import sqlite3
>>> with sqlite3.connect('data/stocks.db') as connection:
...     fb_prices = pd.read_sql(
...         'SELECT * FROM fb_prices', connection, 
...         index_col='date', parse_dates=['date']
...     )
...     aapl_prices = pd.read_sql(
...         'SELECT * FROM aapl_prices', connection, 
...         index_col='date', parse_dates=['date']
...     )

Facebook 的数据是按分钟粒度的;然而,Apple 的数据是按(虚构的)秒粒度的:

>>> fb_prices.index.second.unique()
Int64Index([0], dtype='int64', name='date')
>>> aapl_prices.index.second.unique()
Int64Index([ 0, 52, ..., 37, 28], dtype='int64', name='date')

如果我们使用merge()join(),只有当 Apple 的价格位于整分钟时,我们才会得到两者的匹配值。相反,为了对齐这些数据,我们可以执行as of合并。为了处理这种不匹配情况,我们将指定合并时使用最近的分钟(direction='nearest'),并要求匹配只能发生在相差不超过 30 秒的时间之间(tolerance)。这将把 Apple 数据与最接近的分钟对齐,因此9:31:52将与9:32匹配,9:37:07将与9:37匹配。由于时间位于索引中,我们像在merge()中一样,传入left_indexright_index

>>> pd.merge_asof(
...     fb_prices, aapl_prices, 
...     left_index=True, right_index=True,
...     # merge with nearest minute
...     direction='nearest',
...     tolerance=pd.Timedelta(30, unit='s')
... ).head()

这类似于左连接;然而,在匹配键时我们更加宽松。需要注意的是,如果多个苹果数据条目匹配相同的分钟,这个函数只会保留最接近的一个。我们在9:31处得到一个空值,因为苹果在9:31的条目是9:31:52,当使用nearest时它会被放置到9:32

图 4.63 – 在 30 秒容忍度下合并时间序列数据

图 4.63 – 在 30 秒容忍度下合并时间序列数据

如果我们不希望使用左连接的行为,可以改用pd.merge_ordered()函数。这将允许我们指定连接类型,默认情况下为'outer'。然而,我们需要重置索引才能在日期时间上进行连接:

>>> pd.merge_ordered(
...     fb_prices.reset_index(), aapl_prices.reset_index()
... ).set_index('date').head()

这种策略会在时间不完全匹配时给我们空值,但至少会对它们进行排序:

图 4.64 – 对时间序列数据执行严格的合并并对其进行排序

图 4.64 – 对时间序列数据执行严格的合并并对其进行排序

提示

我们可以将fill_method='ffill'传递给pd.merge_ordered(),以在一个值后填充第一个NaN,但它不会继续传播;另外,我们可以链式调用fillna()。笔记本中有一个示例。

pd.merge_ordered()函数还使得按组合并成为可能,因此请务必查看文档以获取更多信息。

概述

在本章中,我们讨论了如何连接数据框,如何使用集合操作确定每种连接类型丢失的数据,以及如何像查询数据库一样查询数据框。接着我们讲解了一些更复杂的列变换,例如分箱和排名,以及如何使用apply()方法高效地进行这些操作。我们还学习了在编写高效pandas代码时矢量化操作的重要性。随后,我们探索了窗口计算和使用管道来使代码更简洁。关于窗口计算的讨论为聚合整个数据框和按组聚合奠定了基础。我们还讨论了如何生成透视表和交叉表。最后,我们介绍了pandas中针对时间序列的特定功能,涵盖了从选择、聚合到合并等各个方面。

在下一章中,我们将讨论可视化,pandas通过提供一个封装器来实现这一功能,封装了matplotlib。数据清洗将在准备数据以进行可视化时发挥关键作用,因此在继续之前,一定要完成下一节提供的练习。

练习

使用exercises/文件夹中的 CSV 文件以及我们到目前为止在本书中学到的内容,完成以下练习:

  1. 使用earthquakes.csv文件,选择所有日本的地震,并使用mb震级类型筛选震级为 4.9 或更大的地震。

  2. 创建每个地震震级完整数字的箱子(例如,第一个箱子是(0, 1],第二个箱子是(1, 2],以此类推),使用ml震级类型并计算每个箱子中的数量。

  3. 使用faang.csv文件,按股票代码分组并重采样为月频率。进行以下聚合:

    a) 开盘价的均值

    b) 最高价的最大值

    c) 最低价的最小值

    d) 收盘价的均值

    e) 成交量总和

  4. 构建一个交叉表,展示地震数据中tsunami列和magType列之间的关系。不要显示频次计数,而是显示每个组合观察到的最大震级。将震级类型放在列中。

  5. 计算 FAANG 数据中每个股票的 OHLC 数据的滚动 60 天聚合值。使用与练习3相同的聚合方法。

  6. 创建一个 FAANG 数据的透视表,比较股票。将股票代码放入行中,显示 OHLC 和成交量数据的平均值。

  7. 使用apply()方法计算 2018 年第四季度亚马逊数据(ticker为 AMZN)每个数值列的 Z 分数。

  8. 添加事件描述:

    a) 创建一个数据框,包含以下三列:tickerdateevent。这些列应包含以下值:

    i) ticker: 'FB'

    ii) date: ['2018-07-25', '2018-03-19', '2018-03-20']

    iii) event: ['公布财报后用户增长令人失望。', '剑桥分析丑闻', 'FTC 调查']

    b) 将索引设置为['date', 'ticker']

    c) 使用外连接将此数据与 FAANG 数据合并。

  9. 对 FAANG 数据使用transform()方法,将所有的值表示为数据中第一个日期的值。为此,将每个股票的所有值除以该股票在数据中第一个日期的值。这被称为transform()可以接受一个函数名称。

  10. covid19_cases.csv 文件。

    ii) 通过解析dateRep列为 datetime 格式来创建一个date列。

    iii) 将date列设置为索引。

    iv) 使用replace()方法将所有United_States_of_AmericaUnited_Kingdom替换为USAUK

    v) 排序索引。

    b) 对于病例最多的五个国家(累计),找出病例数最多的那一天。

    c) 找出数据中最后一周五个疫情病例最多的国家的 COVID-19 病例 7 天平均变化。

    d) 找出中国以外的每个国家首次出现病例的日期。

    e) 按累计病例数使用百分位数对国家进行排名。

进一步阅读

查看以下资源,了解本章中涉及的主题: